fix(programs): preserve integer target weights

This commit is contained in:
fy59 2026-09-19 08:05:48 +02:00
parent 4ad7ccc9c0
commit ca45683813
5 changed files with 255 additions and 7 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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;

View file

@ -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;
}