From ca45683813327f21344ae15b27255eb9eaa52ce4 Mon Sep 17 00:00:00 2001 From: fy59 Date: Sat, 19 Sep 2026 08:05:48 +0200 Subject: [PATCH] fix(programs): preserve integer target weights --- CHANGELOG.md | 9 ++ docs/tests.md | 10 +- docs/web_sessions.md | 6 +- tui/src/web_programs.c | 25 +++- tui/tests/test_web_programs.c | 212 +++++++++++++++++++++++++++++++++- 5 files changed, 255 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a6d0759..78dcf56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## Unreleased — 0.1.2 +- Fixed Program-to-preparation creation for Program V1 target weights written + as integer JSON numbers. Imports now preserve both integer and real number + spellings instead of persisting integer weights as zero. Existing affected + Programs remain unchanged; when deriving an editable draft, an impossible + legacy zero weight is represented as an unspecified target rather than + blocking the entire preparation. Mixed continuous-duration, unloaded-reps, + and externally loaded reps coverage verifies order, targets, provenance, + zero delivery/execution side effects, readable detail, and request replay. + - Fixed the Web history deletion failure for Program-origin completed sessions. The history detail query now reads explicit MAX results from `max_results`; mobile history import seeds a deterministic causal live diff --git a/docs/tests.md b/docs/tests.md index bff1776..61ee30c 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -58,8 +58,14 @@ replay. `web_programs` exercises validation preview without writes, transactional import, exact replay, responsive detail serialization, archive replay, revision-guarded terminal deletion, response replay, preparation creation, -usage/provenance and absence of delivery. Schema migration coverage checks all -v26 tables and columns. `schema_v26_migration` additionally migrates a +usage/provenance and absence of delivery. Mixed-profile preparation coverage +combines continuous duration, externally loaded reps with an integer JSON +weight, and unloaded reps; it verifies preserved order and targets, generated +entry identities, readable detail, request replay, and zero execution or +delivery creation. A separate compatibility fixture retains an affected source +Program's impossible zero weight while deriving an editable preparation with an +unspecified target. Schema migration coverage checks all v26 tables and +columns. `schema_v26_migration` additionally migrates a deterministically populated v25 fixture containing 24 sessions and 48 entries, with preparation provenance and history, and verifies exact typed projections after migration. Programs Vitest covers preview before the only importing diff --git a/docs/web_sessions.md b/docs/web_sessions.md index a2ca273..6ff3365 100644 --- a/docs/web_sessions.md +++ b/docs/web_sessions.md @@ -77,7 +77,11 @@ confirmation. Exact content replay is idempotent; same-ID divergent content is a conflict. Archiving preserves definitions and derived preparations. Creating a preparation from one active program session copies targets and provenance into a new manual preparation but creates no delivery, execution or performed -data. Archived or deleted programs cannot be prepared. +data. Program imports preserve target weights expressed as either integer or +real JSON numbers. A Program row affected by the former integer-weight import +defect can still create an editable draft: its impossible zero weight becomes +an unspecified draft target without rewriting the imported Program. Archived +or deleted programs cannot be prepared. Desktop schema v27 enriches each Program session with a persisted execution state. Web displays `À faire`, `Préparée`, `En cours`, or `Effectuée`; completed diff --git a/tui/src/web_programs.c b/tui/src/web_programs.c index 654847b..aebe39f 100644 --- a/tui/src/web_programs.c +++ b/tui/src/web_programs.c @@ -803,7 +803,10 @@ bind_optional_number(sqlite3_stmt *statement, int parameter, yyjson_val *value, return sqlite3_bind_null(statement, parameter); } if (real_value) { - return sqlite3_bind_double(statement, parameter, yyjson_get_real(value)); + /* CONTRACT: Program V1 accepts both integer and real JSON spellings + * for a weight. yyjson_get_num() preserves either representation; + * yyjson_get_real() would silently turn an integer spelling into 0. */ + return sqlite3_bind_double(statement, parameter, yyjson_get_num(value)); } return sqlite3_bind_int64(statement, parameter, yyjson_get_sint(value)); } @@ -1518,6 +1521,24 @@ static yyjson_val *program_find_session(yyjson_val *root, const char *program_se return NULL; } +static yyjson_mut_val *program_copy_preparation_value(yyjson_mut_doc *document, + yyjson_val *source_occurrence, + const char *key) { + yyjson_val *source_value = yyjson_obj_get(source_occurrence, key); + + /* WHY: older imports accepted integer target weights but persisted them + * as 0.0. Program V1 forbids zero, so it cannot be a legitimate target. + * A preparation is an editable draft and may represent the irrecoverable + * legacy target as unspecified without rewriting the imported program. + * INVARIANT: every valid positive target, including integer spellings + * fixed at import, is copied exactly. */ + if (strcmp(key, "target_weight_kg") == 0 && yyjson_is_num(source_value) && + yyjson_get_num(source_value) == 0.0) { + return yyjson_mut_null(document); + } + return yyjson_val_mut_copy(document, source_value); +} + static yyjson_mut_val *program_copy_preparation_occurrences(yyjson_mut_doc *document, yyjson_val *session) { static const char *const KEYS[] = {"exercise_id", @@ -1546,7 +1567,7 @@ static yyjson_mut_val *program_copy_preparation_occurrences(yyjson_mut_doc *docu } for (key_index = 0U; key_index < sizeof(KEYS) / sizeof(KEYS[0]); ++key_index) { yyjson_mut_val *value = - yyjson_val_mut_copy(document, yyjson_obj_get(source_occurrence, KEYS[key_index])); + program_copy_preparation_value(document, source_occurrence, KEYS[key_index]); if (value == NULL || !yyjson_mut_obj_add_val(document, destination_occurrence, KEYS[key_index], value)) { return NULL; diff --git a/tui/tests/test_web_programs.c b/tui/tests/test_web_programs.c index 6151194..a3c527a 100644 --- a/tui/tests/test_web_programs.c +++ b/tui/tests/test_web_programs.c @@ -8,6 +8,7 @@ #include "database_internal.h" #include "trainlog/database.h" #include "trainlog/web_programs.h" +#include "trainlog/web_sessions.h" #define CHECK(expression) \ do { \ @@ -31,6 +32,31 @@ static const char PROGRAM_JSON[] = "\"target_sets\":3,\"target_reps\":8,\"target_duration_seconds\":null," "\"target_weight_kg\":40.0,\"notes\":null}]}]}}"; +static const char MIXED_PROGRAM_JSON[] = + "{\"format\":\"trainlog-program\",\"version\":1,\"program\":{" + "\"program_id\":\"pg_10000000-0000-4000-8000-000000000001\"," + "\"title\":\"Mixed profile cycle\",\"note\":null,\"state\":\"active\"," + "\"start_date\":\"2026-09-21\",\"end_date\":null,\"sessions\":[{" + "\"program_session_id\":\"pgs_10000000-0000-4000-8000-000000000002\"," + "\"title\":\"Mixed profile session\",\"session_type\":\"training\"," + "\"planned_for\":\"2026-09-21\",\"note\":\"Regression fixture\"," + "\"occurrences\":[{" + "\"entry_id\":\"pge_10000000-0000-4000-8000-000000000003\"," + "\"exercise_id\":\"ex_10000000-0000-4000-8000-000000000004\"," + "\"equipment_id\":null,\"load_mode\":\"none\",\"rest_seconds\":0," + "\"target_sets\":null,\"target_reps\":null,\"target_duration_seconds\":600," + "\"target_weight_kg\":null,\"notes\":\"Warm-up\"},{" + "\"entry_id\":\"pge_10000000-0000-4000-8000-000000000005\"," + "\"exercise_id\":\"ex_10000000-0000-4000-8000-000000000006\"," + "\"equipment_id\":null,\"load_mode\":\"external\",\"rest_seconds\":90," + "\"target_sets\":3,\"target_reps\":10,\"target_duration_seconds\":null," + "\"target_weight_kg\":52,\"notes\":null},{" + "\"entry_id\":\"pge_10000000-0000-4000-8000-000000000007\"," + "\"exercise_id\":\"ex_10000000-0000-4000-8000-000000000008\"," + "\"equipment_id\":null,\"load_mode\":\"none\",\"rest_seconds\":60," + "\"target_sets\":2,\"target_reps\":12,\"target_duration_seconds\":null," + "\"target_weight_kg\":null,\"notes\":\"Bodyweight\"}] }]}}"; + static int scalar(TrainlogDatabase *database, const char *sql) { sqlite3_stmt *statement = NULL; int value = -1; @@ -373,6 +399,188 @@ static bool import_archive_and_prepare(void) { return true; } -int main(void) { - return import_archive_and_prepare() && strict_import_rejections() ? EXIT_SUCCESS : EXIT_FAILURE; +static bool insert_mixed_profile_exercises(TrainlogDatabase *database) { + return trainlog_database_insert_exercise_profiled(database, + "ex_10000000-0000-4000-8000-000000000004", + "Continuous duration fixture", + "continuous duration fixture", + TRAINLOG_TRACKING_DURATION, + TRAINLOG_RECORDING_CONTINUOUS, + 0U) == TRAINLOG_STATUS_OK && + trainlog_database_insert_exercise_profiled(database, + "ex_10000000-0000-4000-8000-000000000006", + "External reps fixture", + "external reps fixture", + TRAINLOG_TRACKING_REPS, + TRAINLOG_RECORDING_SETS, + 0U) == TRAINLOG_STATUS_OK && + trainlog_database_insert_exercise_profiled(database, + "ex_10000000-0000-4000-8000-000000000008", + "Bodyweight reps fixture", + "bodyweight reps fixture", + TRAINLOG_TRACKING_REPS, + TRAINLOG_RECORDING_SETS, + 0U) == TRAINLOG_STATUS_OK; +} + +static bool mixed_profile_prepare_regression(void) { + TrainlogDatabase *database = NULL; + char *response = NULL; + char *replayed = NULL; + char *detail = NULL; + char *saved_response = NULL; + size_t response_size = 0U; + size_t replayed_size = 0U; + size_t detail_size = 0U; + char preparation_id[128]; + const char *continuous_position; + const char *external_position; + const char *bodyweight_position; + + CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK); + CHECK(insert_mixed_profile_exercises(database)); + CHECK(trainlog_web_programs_import_json(database, + MIXED_PROGRAM_JSON, + strlen(MIXED_PROGRAM_JSON), + false, + &response, + &response_size) == TRAINLOG_STATUS_OK); + CHECK(strstr(response, "\"imported\":false") != NULL); + CHECK(scalar(database, "SELECT COUNT(*) FROM programs") == 0); + free(response); + response = NULL; + + CHECK(trainlog_web_programs_import_json(database, + MIXED_PROGRAM_JSON, + strlen(MIXED_PROGRAM_JSON), + true, + &response, + &response_size) == TRAINLOG_STATUS_OK); + CHECK(strstr(response, "\"imported\":true") != NULL); + free(response); + response = NULL; + CHECK(scalar(database, "SELECT COUNT(*) FROM program_session_entries") == 3); + CHECK(scalar(database, + "SELECT COUNT(*) FROM program_session_entries WHERE position=1 AND " + "typeof(target_weight_kg)='real' AND target_weight_kg=52.0") == 1); + + CHECK(trainlog_web_programs_prepare_json(database, + "pg_10000000-0000-4000-8000-000000000001", + "pgs_10000000-0000-4000-8000-000000000002", + "request-mixed-prepare", + &response, + &response_size) == TRAINLOG_STATUS_OK); + CHECK(extract_string(response, "preparation_id", preparation_id, sizeof(preparation_id))); + saved_response = strdup(response); + CHECK(saved_response != NULL); + free(response); + response = NULL; + + CHECK(scalar(database, "SELECT COUNT(*) FROM session_preparations") == 1); + CHECK(scalar(database, "SELECT COUNT(*) FROM session_preparation_revisions") == 1); + CHECK(scalar(database, "SELECT COUNT(*) FROM session_preparation_entries") == 3); + CHECK(scalar(database, + "SELECT COUNT(*) FROM session_preparations WHERE " + "source_program_id='pg_10000000-0000-4000-8000-000000000001' AND " + "source_program_session_id='pgs_10000000-0000-4000-8000-000000000002'") == 1); + CHECK(scalar(database, + "SELECT COUNT(*) FROM session_preparation_entries WHERE " + "position=0 AND recording_mode='continuous' AND tracking_mode='duration' AND " + "load_mode='none' AND target_sets IS NULL AND target_reps IS NULL AND " + "target_duration_seconds=600 AND target_weight_kg IS NULL") == 1); + CHECK(scalar(database, + "SELECT COUNT(*) FROM session_preparation_entries WHERE " + "position=1 AND recording_mode='sets' AND tracking_mode='reps' AND " + "load_mode='external' AND target_sets=3 AND target_reps=10 AND " + "target_duration_seconds IS NULL AND target_weight_kg=52.0") == 1); + CHECK(scalar(database, + "SELECT COUNT(*) FROM session_preparation_entries WHERE " + "position=2 AND recording_mode='sets' AND tracking_mode='reps' AND " + "load_mode='none' AND target_sets=2 AND target_reps=12 AND " + "target_duration_seconds IS NULL AND target_weight_kg IS NULL") == 1); + CHECK(scalar(database, + "SELECT COUNT(*) FROM session_preparation_entries WHERE " + "entry_id LIKE 'spe_%'") == 3); + CHECK(scalar(database, "SELECT COUNT(*) FROM sessions") == 0); + CHECK(scalar(database, "SELECT COUNT(*) FROM session_preparation_deliveries") == 0); + + CHECK(trainlog_web_sessions_detail_json( + database, "preparation", preparation_id, &detail, &detail_size) == + TRAINLOG_STATUS_OK); + continuous_position = strstr(detail, "ex_10000000-0000-4000-8000-000000000004"); + external_position = strstr(detail, "ex_10000000-0000-4000-8000-000000000006"); + bodyweight_position = strstr(detail, "ex_10000000-0000-4000-8000-000000000008"); + CHECK(continuous_position != NULL && external_position != NULL && bodyweight_position != NULL); + CHECK(continuous_position < external_position && external_position < bodyweight_position); + free(detail); + detail = NULL; + + CHECK(trainlog_web_programs_prepare_json(database, + "pg_10000000-0000-4000-8000-000000000001", + "pgs_10000000-0000-4000-8000-000000000002", + "request-mixed-prepare", + &replayed, + &replayed_size) == TRAINLOG_STATUS_OK); + CHECK(strcmp(saved_response, replayed) == 0); + CHECK(scalar(database, "SELECT COUNT(*) FROM session_preparations") == 1); + CHECK(scalar(database, "SELECT COUNT(*) FROM session_preparation_revisions") == 1); + CHECK(scalar(database, "SELECT COUNT(*) FROM session_preparation_entries") == 3); + CHECK(scalar(database, "SELECT COUNT(*) FROM session_preparation_requests") == 1); + + free(replayed); + free(saved_response); + trainlog_database_close(database); + return true; +} + +static bool legacy_integer_weight_zero_prepares_editable_draft(void) { + TrainlogDatabase *database = NULL; + char *response = NULL; + size_t response_size = 0U; + + CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK); + CHECK(insert_mixed_profile_exercises(database)); + CHECK(trainlog_web_programs_import_json(database, + MIXED_PROGRAM_JSON, + strlen(MIXED_PROGRAM_JSON), + true, + &response, + &response_size) == TRAINLOG_STATUS_OK); + free(response); + response = NULL; + + /* This impossible Program V1 value reproduces rows written by the former + * integer-weight import bug. Production compatibility must not rewrite + * the source row merely to create an editable derived draft. */ + CHECK(sqlite3_exec(database->connection, + "UPDATE program_session_entries SET target_weight_kg=0.0 " + "WHERE position=1", + NULL, + NULL, + NULL) == SQLITE_OK); + CHECK(trainlog_web_programs_prepare_json(database, + "pg_10000000-0000-4000-8000-000000000001", + "pgs_10000000-0000-4000-8000-000000000002", + "request-legacy-zero-prepare", + &response, + &response_size) == TRAINLOG_STATUS_OK); + CHECK(scalar(database, + "SELECT COUNT(*) FROM session_preparation_entries WHERE " + "position=1 AND load_mode='external' AND target_weight_kg IS NULL") == 1); + CHECK(scalar(database, + "SELECT COUNT(*) FROM program_session_entries WHERE " + "position=1 AND target_weight_kg=0.0") == 1); + CHECK(scalar(database, "SELECT COUNT(*) FROM session_preparation_deliveries") == 0); + + free(response); + trainlog_database_close(database); + return true; +} + +int main(void) { + return import_archive_and_prepare() && strict_import_rejections() && + mixed_profile_prepare_regression() && + legacy_integer_weight_zero_prepares_editable_draft() + ? EXIT_SUCCESS + : EXIT_FAILURE; }