trainlog/tui/tests/test_statistics.c

359 lines
20 KiB
C

/**
* @file test_statistics.c
* @brief Read-model regressions for factual desktop statistics.
*/
#include <sqlite3.h>
#include <math.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <unistd.h>
#include "trainlog/database.h"
#include "trainlog/statistics.h"
#define NOW_UTC INT64_C(1789473600)
#define CHECK(condition) do { if (!(condition)) { \
(void)fprintf(stderr, "CHECK failed at %s:%d: %s\n", \
__FILE__, __LINE__, #condition); return false; } } while (0)
static bool exec_sql(sqlite3 *database, const char *sql)
{
char *error = NULL;
int status = sqlite3_exec(database, sql, NULL, NULL, &error);
if (status != SQLITE_OK) {
(void)fprintf(stderr, "SQLite fixture error: %s\n",
error != NULL ? error : "unknown");
sqlite3_free(error);
return false;
}
return true;
}
static void fixture_profile_revision(sqlite3_context *context, int count,
sqlite3_value **values)
{
static unsigned sequence = 0U;
char value[32];
(void)count; (void)values;
++sequence;
(void)snprintf(value, sizeof(value), "pr_fixture_%u", sequence);
sqlite3_result_text(context, value, -1, SQLITE_TRANSIENT);
}
static bool build_fixture(const char *path)
{
TrainlogDatabase *database = NULL;
sqlite3 *raw = NULL;
CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK);
trainlog_database_close(database);
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(sqlite3_create_function(raw, "trainlog_profile_revision", -1,
SQLITE_UTF8, NULL, fixture_profile_revision, NULL, NULL) == SQLITE_OK);
CHECK(exec_sql(raw,
"PRAGMA foreign_keys=ON;"
"INSERT INTO exercises(exercise_id,name,normalized_name,tracking_mode,recording_mode,data_fields,load_semantics) VALUES"
"('ex_stat_load','Charge canonique','charge canonique','reps','sets',0,'external'),"
"('ex_stat_alias','Ancien alias','ancien alias','reps','sets',0,'external'),"
"('ex_stat_cont','Continu','continu','duration','continuous',0,'cardio');"
"INSERT INTO exercise_body_zones(exercise_row_id,zone_id,role) SELECT id,'chest','primary' FROM exercises WHERE exercise_id='ex_stat_load';"
"INSERT INTO exercise_body_zones(exercise_row_id,zone_id,role) SELECT id,'arms','secondary' FROM exercises WHERE exercise_id='ex_stat_load';"
"INSERT INTO exercise_body_zones(exercise_row_id,zone_id,role) SELECT id,'back','primary' FROM exercises WHERE exercise_id='ex_stat_cont';"
"INSERT INTO sessions(session_id,started_at,ended_at,session_type) VALUES"
"('se_stat_1','2026-09-10T10:00:00Z','2026-09-10T11:00:00Z','training'),"
"('se_stat_2','2026-09-14T10:00:00Z','2026-09-14T10:30:00Z','training'),"
"('se_stat_old','2026-06-17T10:00:00Z','2026-06-17T10:20:00Z','training'),"
"('se_stat_max','2026-09-15T10:00:00Z','2026-09-15T10:10:00Z','max_test');"));
CHECK(exec_sql(raw,
"INSERT INTO session_exercises(entry_id,session_row_id,exercise_row_id,recording_mode,tracking_mode,data_fields,position,load_mode,rest_seconds,target_sets,target_reps,target_weight_kg) "
"SELECT 'sxe_stat_load',s.id,e.id,'sets','reps',0,0,'external',60,2,10,50 FROM sessions s,exercises e WHERE s.session_id='se_stat_1' AND e.exercise_id='ex_stat_load';"
"INSERT INTO session_exercises(entry_id,session_row_id,exercise_row_id,recording_mode,tracking_mode,data_fields,position,load_mode,rest_seconds) "
"SELECT 'sxe_stat_cont',s.id,e.id,'continuous','duration',0,1,'none',0 FROM sessions s,exercises e WHERE s.session_id='se_stat_1' AND e.exercise_id='ex_stat_cont';"
"INSERT INTO session_exercises(entry_id,session_row_id,exercise_row_id,recording_mode,tracking_mode,data_fields,position,load_mode,rest_seconds,target_sets,target_reps,target_weight_kg) "
"SELECT 'sxe_stat_alias',s.id,e.id,'sets','reps',0,0,'external',60,1,12,60 FROM sessions s,exercises e WHERE s.session_id='se_stat_2' AND e.exercise_id='ex_stat_alias';"
"INSERT INTO session_exercises(entry_id,session_row_id,exercise_row_id,recording_mode,tracking_mode,data_fields,position,load_mode,rest_seconds,target_sets,target_reps,target_weight_kg) "
"SELECT 'sxe_stat_old',s.id,e.id,'sets','reps',0,0,'external',60,1,5,5 FROM sessions s,exercises e WHERE s.session_id='se_stat_old' AND e.exercise_id='ex_stat_load';"
"INSERT INTO session_exercises(entry_id,session_row_id,exercise_row_id,recording_mode,tracking_mode,data_fields,position,load_mode,rest_seconds) "
"SELECT 'sxe_stat_max',s.id,e.id,'sets','reps',0,0,'external',60 FROM sessions s,exercises e WHERE s.session_id='se_stat_max' AND e.exercise_id='ex_stat_load';"
"INSERT INTO performed_sets(session_exercise_row_id,position,reps,weight_kg) SELECT id,0,10,50 FROM session_exercises WHERE entry_id='sxe_stat_load';"
"INSERT INTO performed_sets(session_exercise_row_id,position,reps,weight_kg) SELECT id,1,8,50 FROM session_exercises WHERE entry_id='sxe_stat_load';"
"INSERT INTO performed_sets(session_exercise_row_id,position,reps,weight_kg) SELECT id,0,12,60 FROM session_exercises WHERE entry_id='sxe_stat_alias';"
"INSERT INTO performed_sets(session_exercise_row_id,position,reps,weight_kg) SELECT id,0,5,5 FROM session_exercises WHERE entry_id='sxe_stat_old';"
"INSERT INTO continuous_activity(session_exercise_row_id,duration_seconds) SELECT id,1800 FROM session_exercises WHERE entry_id='sxe_stat_cont';"
"INSERT INTO max_results(session_exercise_row_id,max_weight_kg) SELECT id,100 FROM session_exercises WHERE entry_id='sxe_stat_max';"
"INSERT INTO exercise_feedback(feedback_id,session_exercise_row_id,observed_at,raw_text) SELECT 'fb_stat',id,'2026-09-10T11:01:00Z','texte libre' FROM session_exercises WHERE entry_id='sxe_stat_load';"
"INSERT INTO session_followups(followup_id,session_row_id,observed_at,raw_text) SELECT 'fu_stat',id,'2026-09-11T08:00:00Z','texte libre global' FROM sessions WHERE session_id='se_stat_1';"));
CHECK(sqlite3_close(raw) == SQLITE_OK);
return true;
}
static bool test_empty(void)
{
TrainlogDatabase *database = NULL;
TrainlogStatisticsSummary summary;
TrainlogZoneStatistics zones[4];
size_t count = 99U;
bool partial = true;
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
for (TrainlogStatisticsWindow window = TRAINLOG_STATISTICS_7_DAYS;
window <= TRAINLOG_STATISTICS_ALL; ++window) {
CHECK(trainlog_statistics_load_summary(database, window,
NOW_UTC, &summary) == TRAINLOG_STATUS_OK);
CHECK(summary.sessions == 0U && summary.sets == 0U &&
summary.loaded_volume_kg == 0.0 && summary.bucket_count == 0U);
}
CHECK(trainlog_statistics_load_zones(database, NOW_UTC, zones, 4U,
&count, &partial) == TRAINLOG_STATUS_OK);
CHECK(count == 0U && !partial);
CHECK(trainlog_statistics_load_exercise(database, "ex_absent", NOW_UTC,
&(TrainlogExerciseStatistics){0}) == TRAINLOG_STATUS_NOT_FOUND);
trainlog_database_close(database);
return true;
}
static bool test_calendar_boundaries(void)
{
char path[] = "/tmp/trainlog-statistics-calendar-XXXXXX";
int descriptor = mkstemp(path);
TrainlogDatabase *database = NULL;
TrainlogStatisticsSummary summary;
sqlite3 *raw = NULL;
const int64_t march_2024_now = INT64_C(1709726400); /* 06/03/2024 13:00 CET */
const int64_t april_2024_now = INT64_C(1713175200); /* 15/04/2024 12:00 CEST */
const int64_t march_2023_now = INT64_C(1678881600); /* 15/03/2023 13:00 CET */
CHECK(descriptor >= 0 && close(descriptor) == 0);
CHECK(build_fixture(path));
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(sqlite3_create_function(raw, "trainlog_profile_revision", -1,
SQLITE_UTF8, NULL, fixture_profile_revision, NULL, NULL) == SQLITE_OK);
CHECK(exec_sql(raw,
"INSERT INTO sessions(session_id,started_at,ended_at,session_type) VALUES"
"('se_cal_2023_feb_first','2023-02-01T08:00:00+14:00',NULL,'training'),"
"('se_cal_2023_feb_last','2023-02-28T23:00:00-10:00',NULL,'training'),"
"('se_cal_2023_mar_first','2023-03-01T08:00:00Z',NULL,'training'),"
"('se_cal_2024_feb_first','2024-02-01T08:00:00Z',NULL,'training'),"
"('se_cal_2024_feb_last','2024-02-29T08:00:00Z',NULL,'training'),"
"('se_cal_sunday','2024-03-03T08:00:00Z',NULL,'training'),"
"('se_cal_monday','2024-03-04T08:00:00Z',NULL,'training'),"
"('se_cal_april','2024-04-15T08:00:00Z',NULL,'training');"
"INSERT INTO session_exercises(entry_id,session_row_id,exercise_row_id,recording_mode,tracking_mode,data_fields,position,load_mode,rest_seconds) "
"SELECT 'sxe_'||s.session_id,s.id,e.id,'sets','reps',0,0,'external',0 FROM sessions s,exercises e WHERE s.session_id LIKE 'se_cal_%' AND e.exercise_id='ex_stat_load';"
"INSERT INTO performed_sets(session_exercise_row_id,position,reps,weight_kg) SELECT id,0,1,1 FROM session_exercises WHERE entry_id LIKE 'sxe_se_cal_%';"));
CHECK(sqlite3_close(raw) == SQLITE_OK);
CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK);
CHECK(trainlog_statistics_load_summary(database, TRAINLOG_STATISTICS_7_DAYS,
march_2024_now, &summary) == TRAINLOG_STATUS_OK);
CHECK(summary.bucket_kind == TRAINLOG_STATISTICS_BUCKET_7_DAYS);
CHECK(summary.bucket_count >= 2U);
CHECK(strcmp(summary.buckets[summary.bucket_count - 2U].label,
"26/02-03/03") == 0);
CHECK(summary.buckets[summary.bucket_count - 2U].sessions == 2U);
CHECK(strcmp(summary.buckets[summary.bucket_count - 1U].label,
"04/03-10/03") == 0);
CHECK(summary.buckets[summary.bucket_count - 1U].sessions == 1U);
CHECK(summary.buckets[summary.bucket_count - 1U].end_timestamp -
summary.buckets[summary.bucket_count - 1U].timestamp ==
INT64_C(7) * INT64_C(86400));
CHECK(trainlog_statistics_load_summary(database, TRAINLOG_STATISTICS_30_DAYS,
april_2024_now, &summary) == TRAINLOG_STATUS_OK);
CHECK(summary.bucket_kind == TRAINLOG_STATISTICS_BUCKET_CALENDAR_MONTH);
CHECK(summary.bucket_count == 3U);
CHECK(strcmp(summary.buckets[0].label, "févr. 2024") == 0 &&
summary.buckets[0].sessions == 2U);
CHECK(strcmp(summary.buckets[1].label, "mars 2024") == 0 &&
summary.buckets[1].sessions == 2U);
CHECK(strcmp(summary.buckets[2].label, "avr. 2024") == 0 &&
summary.buckets[2].sessions == 1U);
CHECK(summary.buckets[0].end_timestamp - summary.buckets[0].timestamp ==
INT64_C(29) * INT64_C(86400));
CHECK(summary.buckets[1].end_timestamp - summary.buckets[1].timestamp ==
INT64_C(31) * INT64_C(86400));
CHECK(trainlog_statistics_load_summary(database, TRAINLOG_STATISTICS_30_DAYS,
march_2023_now, &summary) == TRAINLOG_STATUS_OK);
CHECK(summary.bucket_count == 2U);
CHECK(strcmp(summary.buckets[0].label, "févr. 2023") == 0 &&
summary.buckets[0].sessions == 2U);
CHECK(summary.buckets[0].end_timestamp - summary.buckets[0].timestamp ==
INT64_C(28) * INT64_C(86400));
CHECK(strcmp(summary.buckets[1].label, "mars 2023") == 0 &&
summary.buckets[1].sessions == 1U);
trainlog_database_close(database);
CHECK(unlink(path) == 0);
return true;
}
static bool test_aggregates(void)
{
char path[] = "/tmp/trainlog-statistics-XXXXXX";
int descriptor = mkstemp(path);
TrainlogDatabase *database = NULL;
TrainlogStatisticsSummary summary;
TrainlogExerciseStatistics exercise;
TrainlogZoneStatistics zones[16];
TrainlogMaxListItem maxima[16];
TrainlogExerciseListStatistic exercise_list[16];
size_t maximum_count = 0U;
size_t exercise_list_count = 0U;
size_t zone_count = 0U;
bool partial = false;
size_t index;
CHECK(descriptor >= 0 && close(descriptor) == 0);
CHECK(build_fixture(path));
CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK);
/* Alias reconciliation repoints the persisted occurrence; statistics then
* expose only the canonical identity, never the retired display name. */
CHECK(trainlog_database_merge_exercises(database, "ex_stat_alias",
"ex_stat_load") == TRAINLOG_STATUS_OK);
CHECK(trainlog_statistics_load_summary(database, TRAINLOG_STATISTICS_7_DAYS,
NOW_UTC, &summary) == TRAINLOG_STATUS_OK);
CHECK(summary.sessions == 2U && summary.distinct_exercises == 1U);
CHECK(summary.occurrences == 2U && summary.sets == 1U);
CHECK(summary.repetitions == 12U && summary.activity_duration_seconds == 0U);
CHECK(summary.session_duration_seconds == 2400U &&
summary.sessions_with_duration == 2U);
CHECK(fabs(summary.loaded_volume_kg - 720.0) < 0.001);
CHECK(summary.active_days == 2U && summary.active_weeks == 1U);
CHECK(summary.days_since_last_session == 0U);
CHECK(summary.immediate_feedback_count == 0U && summary.followup_count == 0U);
CHECK(summary.top_feedback_count == 0U);
CHECK(summary.planned_actual_occurrences == 1U &&
summary.planned_sets == 1U && summary.actual_sets_for_plans == 1U);
CHECK(summary.planned_repetitions == 12U &&
summary.actual_repetitions_for_plans == 12U);
CHECK(summary.bucket_count >= 1U);
CHECK(summary.bucket_count == 4U);
CHECK(summary.bucket_kind == TRAINLOG_STATISTICS_BUCKET_7_DAYS);
CHECK(strcmp(summary.buckets[0].label, "24/08-30/08") == 0);
CHECK(strcmp(summary.buckets[summary.bucket_count - 1U].label,
"14/09-20/09") == 0);
CHECK(summary.buckets[2].sessions == 1U &&
summary.buckets[3].sessions == 2U);
{
size_t bucket_sessions = 0U;
for (index = 0U; index < summary.bucket_count; ++index)
bucket_sessions += summary.buckets[index].sessions;
CHECK(bucket_sessions == 3U);
}
CHECK(trainlog_statistics_load_summary(database, TRAINLOG_STATISTICS_30_DAYS,
NOW_UTC, &summary) == TRAINLOG_STATUS_OK && summary.sessions == 3U);
CHECK(summary.bucket_kind == TRAINLOG_STATISTICS_BUCKET_CALENDAR_MONTH);
CHECK(summary.bucket_count == 4U);
CHECK(strcmp(summary.buckets[0].label, "juin 2026") == 0);
CHECK(strcmp(summary.buckets[1].label, "juil. 2026") == 0);
CHECK(strcmp(summary.buckets[2].label, "août 2026") == 0);
CHECK(strcmp(summary.buckets[3].label, "sept. 2026") == 0);
CHECK(summary.buckets[0].sessions == 1U);
CHECK(summary.buckets[1].sessions == 0U &&
summary.buckets[2].sessions == 0U);
CHECK(summary.buckets[3].sessions == 3U);
{
size_t bucket_sessions = 0U;
TrainlogStatisticsSummary repeated;
for (index = 0U; index < summary.bucket_count; ++index)
bucket_sessions += summary.buckets[index].sessions;
/* The persisted 17/06 date belongs to June exactly once. */
CHECK(bucket_sessions == 4U);
CHECK(trainlog_statistics_load_summary(database,
TRAINLOG_STATISTICS_30_DAYS, NOW_UTC, &repeated) ==
TRAINLOG_STATUS_OK);
CHECK(repeated.bucket_count == summary.bucket_count &&
memcmp(repeated.buckets, summary.buckets,
summary.bucket_count * sizeof(summary.buckets[0])) == 0);
}
CHECK(trainlog_statistics_load_summary(database, TRAINLOG_STATISTICS_ALL,
NOW_UTC, &summary) == TRAINLOG_STATUS_OK && summary.sessions == 4U &&
summary.repetitions == 35U);
CHECK(summary.bucket_kind == TRAINLOG_STATISTICS_BUCKET_7_DAYS);
{
bool has_zero_week = false;
for (index = 0U; index < summary.bucket_count; ++index)
if (summary.buckets[index].sessions == 0U) has_zero_week = true;
CHECK(has_zero_week);
}
CHECK(trainlog_statistics_load_zones(database, NOW_UTC, zones, 16U,
&zone_count, &partial) == TRAINLOG_STATUS_OK && !partial);
for (index = 0U; index < zone_count; ++index) {
if (strcmp(zones[index].zone_id, "chest") == 0)
CHECK(zones[index].primary_7 == 3U &&
zones[index].secondary_7 == 0U);
if (strcmp(zones[index].zone_id, "arms") == 0)
CHECK(zones[index].secondary_30 == 3U);
if (strcmp(zones[index].zone_id, "back") == 0)
CHECK(zones[index].primary_7 == 1U);
}
CHECK(trainlog_statistics_load_exercise(database, "ex_stat_load", NOW_UTC,
&exercise) == TRAINLOG_STATUS_OK);
CHECK(exercise.occurrences == 4U && exercise.sessions == 4U);
CHECK(exercise.frequency_7 == 3U && exercise.frequency_30 == 3U);
CHECK(exercise.sets == 4U && exercise.repetitions == 35U);
CHECK(fabs(exercise.loaded_volume_kg - 1645.0) < 0.001);
CHECK(exercise.has_highest_load && exercise.highest_load_kg == 60.0);
CHECK(exercise.has_repetitions_at_load && exercise.highest_repetitions == 12 &&
exercise.highest_repetitions_load_kg == 60.0);
CHECK(exercise.max_count == 1U && exercise.latest_max_kg == 100.0 &&
!exercise.has_previous_max);
CHECK(exercise.immediate_feedback_count == 1U);
CHECK(exercise.has_latest_planned_actual_weight &&
exercise.latest_planned_weight_kg == 60.0 &&
exercise.latest_actual_weight_kg == 60.0);
CHECK(exercise.series_count[TRAINLOG_EXERCISE_SERIES_MAX] == 1U);
CHECK(trainlog_statistics_load_exercise(database, "ex_stat_alias", NOW_UTC,
&exercise) == TRAINLOG_STATUS_NOT_FOUND);
CHECK(trainlog_statistics_list_maxima(database, maxima, 16U,
&maximum_count, &partial) == TRAINLOG_STATUS_OK && !partial &&
maximum_count == 1U && strcmp(maxima[0].exercise_id, "ex_stat_load") == 0 &&
maxima[0].latest_max_kg == 100.0);
CHECK(trainlog_statistics_list_exercises(database, exercise_list, 16U,
&exercise_list_count, &partial) == TRAINLOG_STATUS_OK && !partial &&
exercise_list_count == 2U);
/* A sparse old observation still expands total duration. Adaptive choice
* is duration-based, not dependent on the number of active weeks. */
{
sqlite3 *raw = NULL;
TrainlogStatisticsSummary repeated;
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(sqlite3_create_function(raw, "trainlog_profile_revision", -1,
SQLITE_UTF8, NULL, fixture_profile_revision, NULL, NULL) == SQLITE_OK);
CHECK(exec_sql(raw,
"INSERT INTO sessions(session_id,started_at,ended_at,session_type) VALUES"
"('se_stat_ancient','2020-01-01T10:00:00Z','2020-01-01T10:10:00Z','training');"
"INSERT INTO session_exercises(entry_id,session_row_id,exercise_row_id,recording_mode,tracking_mode,data_fields,position,load_mode,rest_seconds) "
"SELECT 'sxe_stat_ancient',s.id,e.id,'sets','reps',0,0,'external',0 FROM sessions s,exercises e WHERE s.session_id='se_stat_ancient' AND e.exercise_id='ex_stat_load';"
"INSERT INTO performed_sets(session_exercise_row_id,position,reps,weight_kg) SELECT id,0,1,1 FROM session_exercises WHERE entry_id='sxe_stat_ancient';"));
CHECK(sqlite3_close(raw) == SQLITE_OK);
CHECK(trainlog_statistics_load_summary(database, TRAINLOG_STATISTICS_ALL,
NOW_UTC, &summary) == TRAINLOG_STATUS_OK);
CHECK(summary.bucket_kind ==
TRAINLOG_STATISTICS_BUCKET_CALENDAR_MONTH);
CHECK(trainlog_statistics_load_summary(database, TRAINLOG_STATISTICS_ALL,
NOW_UTC, &repeated) == TRAINLOG_STATUS_OK);
CHECK(repeated.bucket_kind == summary.bucket_kind &&
repeated.bucket_count == summary.bucket_count);
}
trainlog_database_close(database);
CHECK(unlink(path) == 0);
return true;
}
int main(void)
{
/* The production contract uses the user's local civil date. Pin the test
* locale so exact bucket-boundary fixtures are deterministic in CI. */
if (setenv("TZ", "Europe/Paris", 1) != 0) return 1;
tzset();
if (!test_empty() || !test_calendar_boundaries() || !test_aggregates())
return 1;
(void)printf("PASS statistics\n");
return 0;
}