From af86895c2e2424413e7019232901cdc868bd5872 Mon Sep 17 00:00:00 2001 From: fy59 Date: Wed, 16 Sep 2026 08:07:37 +0200 Subject: [PATCH] test: freeze TUI dashboard behavior --- docs/architecture.md | 11 +- docs/current_state.md | 14 +- docs/roadmap.md | 12 +- docs/tests.md | 14 + docs/tui.md | 46 +++ tui/src/tui.c | 24 +- tui/tests/test_presentation_catalog.py | 2 +- tui/tests/test_tui_workflows.c | 436 +++++++++++++++++++++++++ 8 files changed, 539 insertions(+), 20 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index afc7801..ce9cc78 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -121,11 +121,12 @@ product semantics. The current Dashboard implementation retains one known layering violation: `tui.c` uses SQLite and `database_internal.h` directly to build Dashboard facts -and calculations. This is a blocking debt for the Web Dashboard. Before any -Dashboard HTTP endpoint exists, characterization tests must capture the current -behavior, the query and calculations must move incrementally into a typed Core -read model, and the TUI must consume that read model. This requirement does not -authorize a global `tui.c` refactor. +and calculations. `WEB_DASHBOARD_CHARACTERIZATION_V1=PASS/FROZEN` now captures +that behavior, including its bounds and legacy-data flags. This remains a +blocking debt for the Web Dashboard: before any Dashboard HTTP endpoint exists, +the query and calculations must move incrementally into a typed Core read model +and the TUI must consume that read model. This requirement does not authorize a +global `tui.c` refactor. ### Local Web application diff --git a/docs/current_state.md b/docs/current_state.md index 8f633ee..8fd283e 100644 --- a/docs/current_state.md +++ b/docs/current_state.md @@ -36,6 +36,7 @@ parallel implementation of its rules. | Session generator | `SESSION_GENERATOR_V1=PASS`, hidden pending V2 | | Application shell | `APP_SHELL_V1=IMPLEMENTED_AWAITING_VISUAL_REVIEW_2` | | Statistics | `STATS_V1=IMPLEMENTED` | +| Dashboard characterization | `WEB_DASHBOARD_CHARACTERIZATION_V1=PASS/FROZEN` | | Local Web | `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_NOT_STARTED` | Desktop and Android schema numbers are independent. Neither changes the frozen @@ -126,10 +127,10 @@ The desktop is a strict C17 application using Notcurses. It currently provides: Rendering is not permitted to own SQL or business rules. Statistics are read-only projections; they are not persisted as facts. The current TUI Dashboard still has a known exception: its direct SQLite/ -`database_internal.h` fact projection must be characterized, extracted into a -typed Core read model, and consumed by the TUI before any Web Dashboard -endpoint is created. This is an incremental extraction, not authorization for -a global `tui.c` refactor. +`database_internal.h` fact projection is now frozen by characterization tests. +It must next be extracted into a typed Core read model and consumed by the TUI +before any Web Dashboard endpoint is created. This is an incremental +extraction, not authorization for a global `tui.c` refactor. ## Local Web @@ -176,8 +177,9 @@ smoke test are not automated. The latter is why ## Active limitations -- `TRAINLOG_WEB_V1` is contract-only; its implementation starts with Dashboard - characterization and Core extraction, not React or HTTP infrastructure. +- `TRAINLOG_WEB_V1` is contract-only; Dashboard characterization is frozen and + the next implementation step is Core read-model extraction, not React or + HTTP infrastructure. - `APP_SHELL_V1` still awaits the recorded human visual/accessibility review. - AI proposal exchange still awaits one real Drive plus Android-triggered bidirectional smoke test. diff --git a/docs/roadmap.md b/docs/roadmap.md index 74fc958..df5bdb8 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -30,14 +30,14 @@ architecture is canonical, but no Web code exists yet. ## Current cursor ```text -CURRENT_OPERATIONAL_CURSOR=WEB_DASHBOARD_CHARACTERIZATION_V1 +CURRENT_OPERATIONAL_CURSOR=WEB_DASHBOARD_CORE_READ_MODEL_V1 ``` -Characterize the existing TUI Dashboard before moving its direct SQLite and -`database_internal.h` projection into a typed Core read model. Then adapt the -TUI to consume that read model. This incremental sequence is a blocking -prerequisite for a Web Dashboard endpoint and does not authorize a global -`tui.c` refactor. +`WEB_DASHBOARD_CHARACTERIZATION_V1=PASS/FROZEN`. Move the now-characterized +direct SQLite and `database_internal.h` projection into a typed Core read +model without changing its results. Then adapt the TUI to consume that read +model. This incremental sequence is a blocking prerequisite for a Web +Dashboard endpoint and does not authorize a global `tui.c` refactor. ## TRAINLOG_WEB_V1 implementation gate diff --git a/docs/tests.md b/docs/tests.md index 81243ca..cee103b 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -188,6 +188,20 @@ tests (198 passed, 4 skipped, 0 failed), with both JSON validators passing. Automated coverage does not replace a real device/manual visual language-switch smoke; that remains explicit manual validation. +`WEB_DASHBOARD_CHARACTERIZATION_V1` exercises the production Dashboard +projection through a real in-memory SQLite database. Its fixed test clock +covers an empty training history, exact rolling-window inclusion immediately +before/at/after the 30-day boundary, valid actual history without `ended_at`, +and plan-only exclusion. It covers repeated occurrences, heterogeneous REPS +sets, SETS/DURATION, CONTINUOUS/DURATION without fake sets, assistance, +unweighted actuals, ordinary heavy sets versus explicit MAX, equal-instant +tie-breaks, primary-only catalogue zones, secondary-zone exclusion, and seeded +catalogue behavior. Exact and exceeded bounds are asserted for 128 observable +sessions and 4096 Dashboard facts, including the independently truncated totals +and `partial` flag. Existing regressions continue to cover malformed legacy +timestamps, body-metric selection, responsive rendering, all-history buckets, +strictly earlier exact-dose improvement, and explicit-MAX separation. + Notable regression coverage: - transactional persisted-session replacement; diff --git a/docs/tui.md b/docs/tui.md index 5dc78df..710ce14 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -408,6 +408,52 @@ bounded source reaches its local limit. Malformed persisted timestamps are excluded before sorting, reported as ignored legacy data, and do not make the remaining dashboard unavailable. +`WEB_DASHBOARD_CHARACTERIZATION_V1=PASS/FROZEN` fixes the current projection +before Core extraction. Its persisted sources are `sessions`, +`session_exercises`, `performed_sets`, `continuous_activity`, `max_results`, +`exercises`, exercise BODY ZONE relations, equipment resolution, and body +observations through their existing database readers. The landing snapshot has +no “last session” record and computes no loaded volume: it exposes counts, +period buckets, one selected working-performance identity/series, the latest +eligible explicit-MAX event, one latest body-metric series, and catalogue-zone +buckets. + +SETS/REPS and SETS/DURATION each contribute one performed-set fact per stored +row; their dose is respectively repetitions or seconds. CONTINUOUS/DURATION +contributes one exercise/session fact but never a performed set or working +performance point. Multiple occurrences are retained as independent facts, +while the distinct-exercise total deduplicates by `exercise_id`. Plans without +actual sets, continuous data, or explicit MAX do not enter observable session +history. A valid actual session with empty `ended_at` does enter it. + +Working improvement requires the exact exercise, equipment, tracking mode, +load mode and positive dose, a resolved equipment whose load semantics are +`external`, an occurrence load mode of `external`, a finite nonnegative actual +weight, and a strictly earlier comparable instant. Assistance facts remain in +session/set/exercise counts but not in working comparison. Explicit MAX remains +separate from ordinary heavy sets; it requires resolved external equipment and +a non-assistance occurrence. The headline MAX is the latest eligible event, +not the greatest weight. Equal-instant facts never compare against one another; +stable session/entry/kind/position order is used internally, the selected +exercise tie-break is bytewise `exercise_id`, and performance points then order +by instant and `session_id`. + +The selected working series currently copies every matching exact-dose set and +marks its point as weighted even when the persisted set has no weight, yielding +`0.0 kg` for that point. This surprising legacy behavior is characterized, not +endorsed or corrected by this tranche. The Core extraction must initially +preserve it or explicitly return to contract review before changing semantics. + +The snapshot reads at most 128 observable sessions, 4096 occurrence facts, 128 +selected performance points, and 256 points for each body metric. Reaching a +bound sets the conservative `partial` flag. At exactly 128 sessions the view is +already partial; beyond that limit the session total stays at 128 while facts +continue independently up to 4096. At exactly 4096 facts the view is complete; +the next fact is omitted and sets `partial`. Malformed fact/timestamp rows are +omitted and set `invalid_data`. A database with no training history still has +the seeded canonical exercise catalogue, so its training/MAX/body values are +empty while its catalogue zone distribution is not. + `Répartition du catalogue` is a separate global catalogue projection. It counts every active canonical exercise exactly once by its persisted **primary** BODY ZONE; secondary zones do not inflate the result, merged source IDs are not diff --git a/tui/src/tui.c b/tui/src/tui.c index a8d5241..9be47b2 100644 --- a/tui/src/tui.c +++ b/tui/src/tui.c @@ -3865,12 +3865,32 @@ static int64_t dashboard_period_days(TrainlogStatisticsPeriod period) return 30; } +/* WHY: characterization tests must exercise rolling-window boundaries without + * depending on wall-clock execution time. CONTRACT: production always reads + * time(NULL); only the translation-unit test build may supply a fixed instant. + * INVARIANT: the override is absent from the installed binary and cannot alter + * persisted data or Dashboard semantics. */ +#ifdef TRAINLOG_DASHBOARD_CHARACTERIZATION_TEST +static bool dashboard_characterization_clock_enabled; +static time_t dashboard_characterization_clock_value; +#endif + +static time_t dashboard_clock_now(void) +{ +#ifdef TRAINLOG_DASHBOARD_CHARACTERIZATION_TEST + if (dashboard_characterization_clock_enabled) + return dashboard_characterization_clock_value; +#endif + return time(NULL); +} + static bool dashboard_in_period(const TrainlogTimestampKey *key, TrainlogStatisticsPeriod period) { const int64_t days = dashboard_period_days(period); /* Unix time and Trainlog's proleptic day zero differ by 719162 days. */ - const int64_t now = (int64_t)time(NULL) + INT64_C(719162) * INT64_C(86400); + const int64_t now = (int64_t)dashboard_clock_now() + + INT64_C(719162) * INT64_C(86400); return key->utc_second <= now && (days == 0 || key->utc_second >= now - days * INT64_C(86400)); } @@ -4068,7 +4088,7 @@ static void __attribute__((unused)) app_shell_load_dashboard(TrainlogAppContext * uses canonical instants, while buckets use represented local dates. * Presentation always ends at the canonical current day, so the bucket * containing that day remains explicit even when it is zero. */ - now = time(NULL); + now = dashboard_clock_now(); if (now == (time_t)-1) { dashboard->error = true; goto finish; diff --git a/tui/tests/test_presentation_catalog.py b/tui/tests/test_presentation_catalog.py index b9fb19a..2f73608 100644 --- a/tui/tests/test_presentation_catalog.py +++ b/tui/tests/test_presentation_catalog.py @@ -48,7 +48,7 @@ FR = re.compile(r'[À-ÿ]|\b(?:aucun|aucune|abandonner|annuler|choisir|durée|en # non-copy token: punctuation/layout, a protocol token, or an internal key. EXCLUDED = { "tui/src/tui.c": { - 1083:"—",1495:"— kg",3970:"J−%zu",3973:"M−%zu",3976:"P−%zu",4759:"msg.zone.required",5204:"statistics.zone.previous",5206:"statistics.zone.next",5270:"generator.warning.zone",5612:"—",5655:"— ",5660:"— ",5678:"— ",5693:"—",5696:"—",5820:"—",5823:"—",5826:"—",5881:"—",6312:"%-22s —",6612:"▶",6615:"▶",6643:"▶",6652:"▶",6665:"▶",6668:"▶",6692:"▶",6697:"▶",6888:"%02d/%02d … %02d/%02d",6930:"— ",7040:"PC↔Android",7086:"▶",7310:"%s · %s",7318:"—",7319:"—",7395:"—",7398:"—",7567:"—",7595:"—",7597:"—",7599:"—",7717:"aucun",7719:"aucun",7884:"—",7935:"—",8065:" · ↑",8066:" · ↓"}, + 1083:"—",1495:"— kg",3990:"J−%zu",3993:"M−%zu",3996:"P−%zu",4779:"msg.zone.required",5224:"statistics.zone.previous",5226:"statistics.zone.next",5290:"generator.warning.zone",5632:"—",5675:"— ",5680:"— ",5698:"— ",5713:"—",5716:"—",5840:"—",5843:"—",5846:"—",5901:"—",6332:"%-22s —",6632:"▶",6635:"▶",6663:"▶",6672:"▶",6685:"▶",6688:"▶",6712:"▶",6717:"▶",6908:"%02d/%02d … %02d/%02d",6950:"— ",7060:"PC↔Android",7106:"▶",7330:"%s · %s",7338:"—",7339:"—",7415:"—",7418:"—",7587:"—",7615:"—",7617:"—",7619:"—",7737:"aucun",7739:"aucun",7904:"—",7955:"—",8085:" · ↑",8086:" · ↓"}, "tui/src/chart.c": {254:"◆ %.2f %s"}, "tui/src/app_shell.c": {317:"…"}, "tui/src/sync_screen_action.c": {132:"Android→PC",136:"PC→Android",139:"PC↔Android"}, diff --git a/tui/tests/test_tui_workflows.c b/tui/tests/test_tui_workflows.c index beadc4e..3c10f6c 100644 --- a/tui/tests/test_tui_workflows.c +++ b/tui/tests/test_tui_workflows.c @@ -9,6 +9,7 @@ /* The workflows remain file-private in production. Including their translation * unit lets this test drive the exact code paths with the terminal port mocked, * without adding a public test API to Trainlog. */ +#define TRAINLOG_DASHBOARD_CHARACTERIZATION_TEST 1 #include "../src/tui.c" struct TrainlogTerminal { @@ -1436,6 +1437,438 @@ static bool dashboard_utc_timestamp_at(time_t value, char output[TRAINLOG_TIMEST TRAINLOG_TIMESTAMP_MAX + 1U, "%Y-%m-%dT%H:%M:%SZ", &utc) > 0U; } +static bool dashboard_characterization_fix_clock(const char *timestamp) +{ + TrainlogTimestampKey key; + int64_t unix_second; + time_t converted; + if (!trainlog_timestamp_parse(timestamp, strlen(timestamp), &key)) return false; + unix_second = key.utc_second - INT64_C(719162) * INT64_C(86400); + converted = (time_t)unix_second; + if ((int64_t)converted != unix_second) return false; + dashboard_characterization_clock_value = converted; + dashboard_characterization_clock_enabled = true; + return true; +} + +static void dashboard_characterization_restore_clock(void) +{ + dashboard_characterization_clock_enabled = false; +} + +static bool dashboard_insert_max_for_exercise(TrainlogDatabase *database, + const char *session_id, const char *entry_id, const char *exercise_id, + const char *started_at, double weight) +{ + TrainlogSessionExerciseInput occurrence; + TrainlogSessionInput session; + (void)memset(&occurrence, 0, sizeof(occurrence)); + (void)memset(&session, 0, sizeof(session)); + (void)snprintf(occurrence.entry_id, sizeof(occurrence.entry_id), "%s", entry_id); + (void)snprintf(occurrence.exercise_id, sizeof(occurrence.exercise_id), "%s", + exercise_id); + (void)snprintf(occurrence.equipment_id, sizeof(occurrence.equipment_id), "%s", + "leg_press"); + occurrence.recording_mode = TRAINLOG_RECORDING_SETS; + occurrence.tracking_mode = TRAINLOG_TRACKING_REPS; + occurrence.load_mode = TRAINLOG_LOAD_NONE; + occurrence.has_max_weight = true; + occurrence.max_weight_kg = weight; + (void)snprintf(session.session_id, sizeof(session.session_id), "%s", session_id); + (void)snprintf(session.started_at, sizeof(session.started_at), "%s", started_at); + session.session_type = TRAINLOG_SESSION_MAX_TEST; + session.exercises = &occurrence; + session.exercise_count = 1U; + return trainlog_database_insert_session(database, &session) == TRAINLOG_STATUS_OK; +} + +static bool dashboard_insert_plan_for_exercise(TrainlogDatabase *database, + const char *session_id, const char *exercise_id, const char *started_at) +{ + TrainlogSessionExerciseInput occurrence; + TrainlogSessionInput session; + (void)memset(&occurrence, 0, sizeof(occurrence)); + (void)memset(&session, 0, sizeof(session)); + (void)snprintf(occurrence.exercise_id, sizeof(occurrence.exercise_id), "%s", + exercise_id); + (void)snprintf(occurrence.equipment_id, sizeof(occurrence.equipment_id), "%s", + "leg_press"); + occurrence.recording_mode = TRAINLOG_RECORDING_SETS; + occurrence.tracking_mode = TRAINLOG_TRACKING_REPS; + occurrence.load_mode = TRAINLOG_LOAD_EXTERNAL; + occurrence.target_sets = 3; + occurrence.target_reps = 10; + occurrence.target_has_weight = true; + occurrence.target_weight_kg = 50.0; + (void)snprintf(session.session_id, sizeof(session.session_id), "%s", session_id); + (void)snprintf(session.started_at, sizeof(session.started_at), "%s", started_at); + session.session_type = TRAINLOG_SESSION_TRAINING; + session.exercises = &occurrence; + session.exercise_count = 1U; + return trainlog_database_insert_session(database, &session) == TRAINLOG_STATUS_OK; +} + +static bool test_dashboard_characterization_empty_and_window_boundaries(void) +{ + TrainlogDatabase *database = NULL; + TrainlogAppContext app; + size_t index; + CHECK(dashboard_characterization_fix_clock("2026-09-16T12:00:00Z")); + CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK); + (void)memset(&app, 0, sizeof(app)); + app.database = database; + app.dashboard.period = TRAINLOG_STATS_30_DAYS; + app_shell_load_dashboard(&app); + CHECK(!app.dashboard.error && !app.dashboard.partial && + !app.dashboard.invalid_data); + CHECK(!app.dashboard.has_performance && !app.dashboard.has_explicit_max && + !app.dashboard.has_body); + CHECK(app.dashboard.performance_count == 0U && app.dashboard.body_count == 0U); + CHECK(app.dashboard.completed_session_count == 0U && + app.dashboard.performed_set_count == 0U && + app.dashboard.distinct_exercise_count == 0U && + app.dashboard.explicit_max_count == 0U); + /* The database is empty of training history, not of the seeded canonical + * catalogue: zone distribution is therefore deliberately non-empty. */ + CHECK(app.dashboard.zone_count == 4U); + CHECK(strcmp(app.dashboard.zones[0].label, "Non classés") == 0 && + app.dashboard.zones[0].count == 2U); + CHECK(app.dashboard.week_count == 6U); + for (index = 0U; index < app.dashboard.week_count; ++index) + CHECK(app.dashboard.weeks[index].sessions == 0U && + app.dashboard.weeks[index].maxima == 0U && + app.dashboard.weeks[index].working_improvements == 0U && + app.dashboard.weeks[index].max_improvements == 0U); + + CHECK(trainlog_database_insert_exercise_profiled(database, + "ex_33333333-3333-4333-8333-333333333333", "Frontière", + "frontiere", TRAINLOG_TRACKING_REPS, TRAINLOG_RECORDING_SETS, + (TrainlogExerciseDataFields)0) == TRAINLOG_STATUS_OK); + CHECK(dashboard_insert_completed_set(database, + "se_31000000-0000-4000-8000-000000000001", + "2026-08-17T11:59:59Z", "", TRAINLOG_LOAD_EXTERNAL, "leg_press", 10.0)); + CHECK(dashboard_insert_completed_set(database, + "se_31000000-0000-4000-8000-000000000002", + "2026-08-17T12:00:00Z", "", TRAINLOG_LOAD_EXTERNAL, "leg_press", 20.0)); + CHECK(dashboard_insert_completed_set(database, + "se_31000000-0000-4000-8000-000000000003", + "2026-08-17T12:00:01Z", "", TRAINLOG_LOAD_EXTERNAL, "leg_press", 30.0)); + app_shell_load_dashboard(&app); + CHECK(!app.dashboard.error && !app.dashboard.invalid_data); + CHECK(app.dashboard.completed_session_count == 2U); + CHECK(app.dashboard.performed_set_count == 2U); + CHECK(app.dashboard.distinct_exercise_count == 1U); + CHECK(app.dashboard.has_performance && app.dashboard.performance_count == 2U); + CHECK(strcmp(app.dashboard.performance[0].started_at, + "2026-08-17T12:00:00Z") == 0); + CHECK(strcmp(app.dashboard.performance[1].started_at, + "2026-08-17T12:00:01Z") == 0); + trainlog_database_close(database); + dashboard_characterization_restore_clock(); + return true; +} + +static bool test_dashboard_characterization_modalities_ties_and_zones(void) +{ + static const char *const primary_secondary[] = {"back"}; + TrainlogDatabase *database = NULL; + TrainlogAppContext app; + TrainlogSetInput rep_sets[] = { + {10, 0, true, 40.0}, {8, 0, true, 1000.0} + }; + TrainlogSetInput repeated_set = {12, 0, true, 50.0}; + TrainlogSetInput duration_set = {0, 60, true, 30.0}; + TrainlogSetInput assistance_set = {10, 0, true, 5.0}; + TrainlogSetInput unweighted_set = {10, 0, false, 0.0}; + TrainlogSessionExerciseInput occurrences[4]; + TrainlogSessionInput session; + bool chest = false; + bool back = false; + bool unclassified = false; + size_t working = 0U; + size_t index; + CHECK(dashboard_characterization_fix_clock("2026-09-16T12:00:00Z")); + CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK); + CHECK(trainlog_database_insert_exercise_profiled(database, + "ex_10000000-0000-4000-8000-000000000000", "MAX égalité", "max egalite", + TRAINLOG_TRACKING_REPS, TRAINLOG_RECORDING_SETS, + (TrainlogExerciseDataFields)0) == TRAINLOG_STATUS_OK); + CHECK(trainlog_database_insert_exercise_profiled(database, + "ex_33333333-3333-4333-8333-333333333333", "Répétitions", + "repetitions", TRAINLOG_TRACKING_REPS, TRAINLOG_RECORDING_SETS, + (TrainlogExerciseDataFields)0) == TRAINLOG_STATUS_OK); + CHECK(trainlog_database_insert_exercise_profiled(database, + "ex_10000000-0000-4000-8000-000000000001", "MAX secondaire", + "max secondaire", + TRAINLOG_TRACKING_REPS, TRAINLOG_RECORDING_SETS, + (TrainlogExerciseDataFields)0) == TRAINLOG_STATUS_OK); + CHECK(trainlog_database_insert_exercise_profiled(database, + "ex_10000000-0000-4000-8000-000000000002", "Durée séries", "duree series", + TRAINLOG_TRACKING_DURATION, TRAINLOG_RECORDING_SETS, + (TrainlogExerciseDataFields)0) == TRAINLOG_STATUS_OK); + CHECK(trainlog_database_insert_exercise_profiled(database, + "ex_10000000-0000-4000-8000-000000000003", "Continu", "continu", + TRAINLOG_TRACKING_DURATION, TRAINLOG_RECORDING_CONTINUOUS, + (TrainlogExerciseDataFields)0) == TRAINLOG_STATUS_OK); + CHECK(trainlog_database_insert_exercise_profiled(database, + "ex_10000000-0000-4000-8000-000000000004", "Assistance", "assistance", + TRAINLOG_TRACKING_REPS, TRAINLOG_RECORDING_SETS, + (TrainlogExerciseDataFields)0) == TRAINLOG_STATUS_OK); + CHECK(trainlog_database_replace_exercise_body_zones(database, + "ex_33333333-3333-4333-8333-333333333333", "chest", + primary_secondary, 1U) == TRAINLOG_STATUS_OK); + CHECK(trainlog_database_replace_exercise_body_zones(database, + "ex_10000000-0000-4000-8000-000000000002", "back", NULL, 0U) == + TRAINLOG_STATUS_OK); + CHECK(trainlog_database_replace_exercise_body_zones(database, + "ex_10000000-0000-4000-8000-000000000004", "chest", NULL, 0U) == + TRAINLOG_STATUS_OK); + + (void)memset(occurrences, 0, sizeof(occurrences)); + (void)memset(&session, 0, sizeof(session)); + for (index = 0U; index < 2U; ++index) { + (void)snprintf(occurrences[index].exercise_id, + sizeof(occurrences[index].exercise_id), "%s", + "ex_33333333-3333-4333-8333-333333333333"); + (void)snprintf(occurrences[index].equipment_id, + sizeof(occurrences[index].equipment_id), "%s", "leg_press"); + occurrences[index].recording_mode = TRAINLOG_RECORDING_SETS; + occurrences[index].tracking_mode = TRAINLOG_TRACKING_REPS; + occurrences[index].load_mode = TRAINLOG_LOAD_EXTERNAL; + occurrences[index].target_sets = 1; + occurrences[index].target_reps = 10; + occurrences[index].target_has_weight = true; + occurrences[index].target_weight_kg = 40.0; + } + (void)snprintf(occurrences[0].entry_id, sizeof(occurrences[0].entry_id), "%s", + "sxe_10000000-0000-4000-8000-000000000001"); + occurrences[0].sets = rep_sets; + occurrences[0].set_count = 2U; + (void)snprintf(occurrences[1].entry_id, sizeof(occurrences[1].entry_id), "%s", + "sxe_10000000-0000-4000-8000-000000000002"); + occurrences[1].sets = &repeated_set; + occurrences[1].set_count = 1U; + (void)snprintf(occurrences[2].exercise_id, sizeof(occurrences[2].exercise_id), + "%s", "ex_10000000-0000-4000-8000-000000000002"); + (void)snprintf(occurrences[2].equipment_id, sizeof(occurrences[2].equipment_id), + "%s", "leg_press"); + occurrences[2].recording_mode = TRAINLOG_RECORDING_SETS; + occurrences[2].tracking_mode = TRAINLOG_TRACKING_DURATION; + occurrences[2].load_mode = TRAINLOG_LOAD_EXTERNAL; + occurrences[2].target_sets = 1; + occurrences[2].target_duration_seconds = 60; + occurrences[2].target_has_weight = true; + occurrences[2].target_weight_kg = 30.0; + occurrences[2].sets = &duration_set; + occurrences[2].set_count = 1U; + (void)snprintf(occurrences[3].exercise_id, sizeof(occurrences[3].exercise_id), + "%s", "ex_10000000-0000-4000-8000-000000000003"); + occurrences[3].recording_mode = TRAINLOG_RECORDING_CONTINUOUS; + occurrences[3].tracking_mode = TRAINLOG_TRACKING_DURATION; + occurrences[3].load_mode = TRAINLOG_LOAD_NONE; + occurrences[3].continuous_duration_seconds = 600; + (void)snprintf(session.session_id, sizeof(session.session_id), "%s", + "se_32000000-0000-4000-8000-000000000001"); + (void)snprintf(session.started_at, sizeof(session.started_at), "%s", + "2026-09-10T08:00:00Z"); + session.session_type = TRAINLOG_SESSION_TRAINING; + session.exercises = occurrences; + session.exercise_count = 4U; + CHECK(trainlog_database_insert_session(database, &session) == TRAINLOG_STATUS_OK); + + (void)memset(&occurrences[0], 0, sizeof(occurrences[0])); + (void)memset(&session, 0, sizeof(session)); + (void)snprintf(occurrences[0].exercise_id, sizeof(occurrences[0].exercise_id), + "%s", "ex_10000000-0000-4000-8000-000000000004"); + (void)snprintf(occurrences[0].equipment_id, sizeof(occurrences[0].equipment_id), + "%s", "assisted_dip_chin_machine"); + occurrences[0].recording_mode = TRAINLOG_RECORDING_SETS; + occurrences[0].tracking_mode = TRAINLOG_TRACKING_REPS; + occurrences[0].load_mode = TRAINLOG_LOAD_ASSISTANCE; + occurrences[0].target_sets = 1; + occurrences[0].target_reps = 10; + occurrences[0].target_has_weight = true; + occurrences[0].target_weight_kg = 5.0; + occurrences[0].sets = &assistance_set; + occurrences[0].set_count = 1U; + (void)snprintf(session.session_id, sizeof(session.session_id), "%s", + "se_32000000-0000-4000-8000-000000000002"); + (void)snprintf(session.started_at, sizeof(session.started_at), "%s", + "2026-09-11T08:00:00Z"); + (void)snprintf(session.ended_at, sizeof(session.ended_at), "%s", + "2026-09-11T09:00:00Z"); + session.session_type = TRAINLOG_SESSION_TRAINING; + session.exercises = occurrences; + session.exercise_count = 1U; + CHECK(trainlog_database_insert_session(database, &session) == TRAINLOG_STATUS_OK); + CHECK(dashboard_insert_plan_for_exercise(database, + "se_32000000-0000-4000-8000-000000000003", + "ex_10000000-0000-4000-8000-000000000001", "2026-09-12T08:00:00Z")); + (void)memset(&occurrences[0], 0, sizeof(occurrences[0])); + (void)memset(&session, 0, sizeof(session)); + (void)snprintf(occurrences[0].exercise_id, sizeof(occurrences[0].exercise_id), + "%s", "ex_33333333-3333-4333-8333-333333333333"); + (void)snprintf(occurrences[0].equipment_id, sizeof(occurrences[0].equipment_id), + "%s", "leg_press"); + occurrences[0].recording_mode = TRAINLOG_RECORDING_SETS; + occurrences[0].tracking_mode = TRAINLOG_TRACKING_REPS; + occurrences[0].load_mode = TRAINLOG_LOAD_EXTERNAL; + occurrences[0].target_sets = 1; + occurrences[0].target_reps = 10; + occurrences[0].target_has_weight = true; + occurrences[0].target_weight_kg = 40.0; + occurrences[0].sets = &unweighted_set; + occurrences[0].set_count = 1U; + (void)snprintf(session.session_id, sizeof(session.session_id), "%s", + "se_32000000-0000-4000-8000-000000000008"); + (void)snprintf(session.started_at, sizeof(session.started_at), "%s", + "2026-09-12T12:00:00Z"); + session.session_type = TRAINLOG_SESSION_TRAINING; + session.exercises = occurrences; + session.exercise_count = 1U; + CHECK(trainlog_database_insert_session(database, &session) == TRAINLOG_STATUS_OK); + CHECK(dashboard_insert_completed_set_dose(database, + "se_32000000-0000-4000-8000-000000000004", "2026-09-13T08:00:00Z", + "", TRAINLOG_LOAD_EXTERNAL, "leg_press", 45.0, 10)); + CHECK(dashboard_insert_completed_set_dose(database, + "se_32000000-0000-4000-8000-000000000005", "2026-09-13T08:00:00Z", + "", TRAINLOG_LOAD_EXTERNAL, "leg_press", 50.0, 10)); + CHECK(dashboard_insert_max_for_exercise(database, + "se_32000000-0000-4000-8000-000000000006", + "sxe_32000000-0000-4000-8000-000000000006", + "ex_10000000-0000-4000-8000-000000000001", "2026-09-14T08:00:00Z", 100.0)); + CHECK(dashboard_insert_max_for_exercise(database, + "se_32000000-0000-4000-8000-000000000007", + "sxe_32000000-0000-4000-8000-000000000007", + "ex_10000000-0000-4000-8000-000000000000", "2026-09-14T08:00:00Z", 200.0)); + + (void)memset(&app, 0, sizeof(app)); + app.database = database; + app.dashboard.period = TRAINLOG_STATS_30_DAYS; + app_shell_load_dashboard(&app); + CHECK(!app.dashboard.error && !app.dashboard.partial && + !app.dashboard.invalid_data); + CHECK(app.dashboard.completed_session_count == 7U); + CHECK(app.dashboard.performed_set_count == 8U); + CHECK(app.dashboard.explicit_max_count == 2U); + CHECK(app.dashboard.distinct_exercise_count == 6U); + CHECK(app.dashboard.has_performance); + CHECK(strcmp(app.dashboard.exercise.exercise_id, + "ex_33333333-3333-4333-8333-333333333333") == 0); + CHECK(app.dashboard.performance_tracking_mode == TRAINLOG_TRACKING_REPS && + app.dashboard.performance_dose == 10); + CHECK(app.dashboard.performance_count == 4U); + CHECK(app.dashboard.performance[0].weight_kg == 40.0 && + app.dashboard.performance[1].has_weight == 1 && + app.dashboard.performance[1].weight_kg == 0.0 && + app.dashboard.performance[2].weight_kg == 45.0 && + app.dashboard.performance[3].weight_kg == 50.0); + CHECK(app.dashboard.has_explicit_max && app.dashboard.max_weight_kg == 200.0); + CHECK(strcmp(app.dashboard.max_exercise.exercise_id, + "ex_10000000-0000-4000-8000-000000000000") == 0); + for (index = 0U; index < app.dashboard.week_count; ++index) + working += app.dashboard.weeks[index].working_improvements; + CHECK(working == 2U); + CHECK(app.dashboard.zone_count == 4U); + for (index = 0U; index < app.dashboard.zone_count; ++index) { + chest = chest || (strcmp(app.dashboard.zones[index].label, "Pectoraux") == 0 && + app.dashboard.zones[index].count == 3U); + back = back || (strcmp(app.dashboard.zones[index].label, "Dos") == 0 && + app.dashboard.zones[index].count == 2U); + unclassified = unclassified || + (strcmp(app.dashboard.zones[index].label, "Non classés") == 0 && + app.dashboard.zones[index].count == 5U); + } + CHECK(chest && back && unclassified); + trainlog_database_close(database); + dashboard_characterization_restore_clock(); + return true; +} + +static bool test_dashboard_characterization_session_and_fact_bounds(void) +{ + TrainlogDatabase *database = NULL; + TrainlogAppContext app; + TrainlogSetInput *sets = NULL; + TrainlogSessionExerciseInput occurrence; + TrainlogSessionInput session; + size_t index; + CHECK(dashboard_characterization_fix_clock("2026-09-16T12:00:00Z")); + CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK); + CHECK(trainlog_database_insert_exercise_profiled(database, + "ex_33333333-3333-4333-8333-333333333333", "Bornes", "bornes", + TRAINLOG_TRACKING_REPS, TRAINLOG_RECORDING_SETS, + (TrainlogExerciseDataFields)0) == TRAINLOG_STATUS_OK); + for (index = 0U; index < MAX_SESSIONS; ++index) { + char session_id[TRAINLOG_ID_MAX + 1U]; + (void)snprintf(session_id, sizeof(session_id), + "se_33000000-0000-4000-8000-%012zu", index); + CHECK(dashboard_insert_completed_set(database, session_id, + "2026-09-10T08:00:00Z", "", TRAINLOG_LOAD_NONE, "", 1.0)); + } + (void)memset(&app, 0, sizeof(app)); + app.database = database; + app.dashboard.period = TRAINLOG_STATS_ALL; + app_shell_load_dashboard(&app); + CHECK(!app.dashboard.error && app.dashboard.partial); + CHECK(app.dashboard.completed_session_count == MAX_SESSIONS); + CHECK(app.dashboard.performed_set_count == MAX_SESSIONS); + CHECK(dashboard_insert_completed_set(database, + "se_33000000-0000-4000-8000-000000000128", + "2026-09-10T08:00:00Z", "", TRAINLOG_LOAD_NONE, "", 1.0)); + app_shell_load_dashboard(&app); + CHECK(!app.dashboard.error && app.dashboard.partial); + CHECK(app.dashboard.completed_session_count == MAX_SESSIONS); + CHECK(app.dashboard.performed_set_count == MAX_SESSIONS + 1U); + trainlog_database_close(database); + + CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK); + CHECK(trainlog_database_insert_exercise_profiled(database, + "ex_33333333-3333-4333-8333-333333333333", "Faits bornés", + "faits bornes", TRAINLOG_TRACKING_REPS, TRAINLOG_RECORDING_SETS, + (TrainlogExerciseDataFields)0) == TRAINLOG_STATUS_OK); + sets = calloc(DASHBOARD_FACT_CAPACITY, sizeof(*sets)); + CHECK(sets != NULL); + (void)memset(&occurrence, 0, sizeof(occurrence)); + (void)memset(&session, 0, sizeof(session)); + (void)snprintf(occurrence.exercise_id, sizeof(occurrence.exercise_id), "%s", + "ex_33333333-3333-4333-8333-333333333333"); + occurrence.recording_mode = TRAINLOG_RECORDING_SETS; + occurrence.tracking_mode = TRAINLOG_TRACKING_REPS; + occurrence.load_mode = TRAINLOG_LOAD_NONE; + occurrence.target_sets = 1; + occurrence.target_reps = 1; + occurrence.sets = sets; + occurrence.set_count = DASHBOARD_FACT_CAPACITY; + (void)snprintf(session.session_id, sizeof(session.session_id), "%s", + "se_34000000-0000-4000-8000-000000000001"); + (void)snprintf(session.started_at, sizeof(session.started_at), "%s", + "2026-09-10T08:00:00Z"); + session.session_type = TRAINLOG_SESSION_TRAINING; + session.exercises = &occurrence; + session.exercise_count = 1U; + CHECK(trainlog_database_insert_session(database, &session) == TRAINLOG_STATUS_OK); + free(sets); + sets = NULL; + (void)memset(&app, 0, sizeof(app)); + app.database = database; + app.dashboard.period = TRAINLOG_STATS_ALL; + app_shell_load_dashboard(&app); + CHECK(!app.dashboard.error && !app.dashboard.partial); + CHECK(app.dashboard.performed_set_count == DASHBOARD_FACT_CAPACITY); + CHECK(sqlite3_exec(database->connection, + "INSERT INTO performed_sets(session_exercise_row_id,position,reps,weight_kg) " + "SELECT id,4096,0,NULL FROM session_exercises WHERE " + "entry_id=(SELECT entry_id FROM session_exercises LIMIT 1);", + NULL, NULL, NULL) == SQLITE_OK); + app_shell_load_dashboard(&app); + CHECK(!app.dashboard.error && app.dashboard.partial); + CHECK(app.dashboard.performed_set_count == DASHBOARD_FACT_CAPACITY); + trainlog_database_close(database); + dashboard_characterization_restore_clock(); + return true; +} + static bool test_stats_dashboard_sparse_series_names_frequency_and_footer(void) { TrainlogAppContext app; @@ -2491,6 +2924,9 @@ int main(void) !test_shell_sync_history_neutralizes_legacy_directions() || !test_equipment_collector_finds_match_after_first_pages() || !test_exercise_merge_action_search_confirm_selects_target() || + !test_dashboard_characterization_empty_and_window_boundaries() || + !test_dashboard_characterization_modalities_ties_and_zones() || + !test_dashboard_characterization_session_and_fact_bounds() || !test_stats_dashboard_sparse_series_names_frequency_and_footer() || !test_stats_dashboard_uses_real_exact_time_snapshots_responsively() || !test_stats_dashboard_uses_represented_local_weeks() ||