fix(programs): preserve integer target weights
This commit is contained in:
parent
4ad7ccc9c0
commit
ca45683813
5 changed files with 255 additions and 7 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue