test: freeze TUI dashboard behavior

This commit is contained in:
fy59 2026-09-16 08:07:37 +02:00
parent 7ea6022eac
commit af86895c2e
8 changed files with 539 additions and 20 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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"},

View file

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