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
|
## 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
|
- Fixed the Web history deletion failure for Program-origin completed
|
||||||
sessions. The history detail query now reads explicit MAX results from
|
sessions. The history detail query now reads explicit MAX results from
|
||||||
`max_results`; mobile history import seeds a deterministic causal live
|
`max_results`; mobile history import seeds a deterministic causal live
|
||||||
|
|
|
||||||
|
|
@ -58,8 +58,14 @@ replay.
|
||||||
`web_programs` exercises validation preview without writes, transactional
|
`web_programs` exercises validation preview without writes, transactional
|
||||||
import, exact replay, responsive detail serialization, archive replay,
|
import, exact replay, responsive detail serialization, archive replay,
|
||||||
revision-guarded terminal deletion, response replay, preparation creation,
|
revision-guarded terminal deletion, response replay, preparation creation,
|
||||||
usage/provenance and absence of delivery. Schema migration coverage checks all
|
usage/provenance and absence of delivery. Mixed-profile preparation coverage
|
||||||
v26 tables and columns. `schema_v26_migration` additionally migrates a
|
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,
|
deterministically populated v25 fixture containing 24 sessions and 48 entries,
|
||||||
with preparation provenance and history, and verifies exact typed projections
|
with preparation provenance and history, and verifies exact typed projections
|
||||||
after migration. Programs Vitest covers preview before the only importing
|
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 conflict. Archiving preserves definitions and derived preparations. Creating
|
||||||
a preparation from one active program session copies targets and provenance
|
a preparation from one active program session copies targets and provenance
|
||||||
into a new manual preparation but creates no delivery, execution or performed
|
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
|
Desktop schema v27 enriches each Program session with a persisted execution
|
||||||
state. Web displays `À faire`, `Préparée`, `En cours`, or `Effectuée`; completed
|
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);
|
return sqlite3_bind_null(statement, parameter);
|
||||||
}
|
}
|
||||||
if (real_value) {
|
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));
|
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;
|
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,
|
static yyjson_mut_val *program_copy_preparation_occurrences(yyjson_mut_doc *document,
|
||||||
yyjson_val *session) {
|
yyjson_val *session) {
|
||||||
static const char *const KEYS[] = {"exercise_id",
|
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) {
|
for (key_index = 0U; key_index < sizeof(KEYS) / sizeof(KEYS[0]); ++key_index) {
|
||||||
yyjson_mut_val *value =
|
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 ||
|
if (value == NULL ||
|
||||||
!yyjson_mut_obj_add_val(document, destination_occurrence, KEYS[key_index], value)) {
|
!yyjson_mut_obj_add_val(document, destination_occurrence, KEYS[key_index], value)) {
|
||||||
return NULL;
|
return NULL;
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
#include "database_internal.h"
|
#include "database_internal.h"
|
||||||
#include "trainlog/database.h"
|
#include "trainlog/database.h"
|
||||||
#include "trainlog/web_programs.h"
|
#include "trainlog/web_programs.h"
|
||||||
|
#include "trainlog/web_sessions.h"
|
||||||
|
|
||||||
#define CHECK(expression) \
|
#define CHECK(expression) \
|
||||||
do { \
|
do { \
|
||||||
|
|
@ -31,6 +32,31 @@ static const char PROGRAM_JSON[] =
|
||||||
"\"target_sets\":3,\"target_reps\":8,\"target_duration_seconds\":null,"
|
"\"target_sets\":3,\"target_reps\":8,\"target_duration_seconds\":null,"
|
||||||
"\"target_weight_kg\":40.0,\"notes\":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) {
|
static int scalar(TrainlogDatabase *database, const char *sql) {
|
||||||
sqlite3_stmt *statement = NULL;
|
sqlite3_stmt *statement = NULL;
|
||||||
int value = -1;
|
int value = -1;
|
||||||
|
|
@ -373,6 +399,188 @@ static bool import_archive_and_prepare(void) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
int main(void) {
|
static bool insert_mixed_profile_exercises(TrainlogDatabase *database) {
|
||||||
return import_archive_and_prepare() && strict_import_rejections() ? EXIT_SUCCESS : EXIT_FAILURE;
|
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