test: freeze TUI dashboard behavior
This commit is contained in:
parent
7ea6022eac
commit
af86895c2e
8 changed files with 539 additions and 20 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
46
docs/tui.md
46
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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
|
|
|
|||
|
|
@ -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() ||
|
||||
|
|
|
|||
Loading…
Reference in a new issue