373 lines
18 KiB
C
373 lines
18 KiB
C
#include <stdbool.h>
|
|
#include <stdint.h>
|
|
#include <stdio.h>
|
|
#include <string.h>
|
|
|
|
#include <sqlite3.h>
|
|
#include <yyjson.h>
|
|
|
|
#include "database_internal.h"
|
|
#include "trainlog/web_analysis.h"
|
|
|
|
#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 read_snapshot(TrainlogDatabase *database,
|
|
TrainlogWebAnalysisPeriod period,
|
|
const char *exercise_id,
|
|
const char *metric,
|
|
yyjson_doc **document) {
|
|
TrainlogWebAnalysisQuery query = {
|
|
.period = period,
|
|
.reference_unix_second = INT64_C(1789560000),
|
|
.exercise_id = exercise_id,
|
|
.measurement_metric = metric,
|
|
};
|
|
char json[TRAINLOG_WEB_ANALYSIS_JSON_CAPACITY];
|
|
size_t size = 0U;
|
|
TrainlogStatus status;
|
|
|
|
status = trainlog_web_analysis_json(database, &query, json, sizeof(json), &size);
|
|
if (status != TRAINLOG_STATUS_OK || size == 0U) {
|
|
(void)fprintf(stderr, "analysis status=%d size=%zu\n", (int)status, size);
|
|
return false;
|
|
}
|
|
*document = yyjson_read(json, size, 0U);
|
|
return *document != NULL;
|
|
}
|
|
|
|
static bool insert_session(TrainlogDatabase *database,
|
|
const char *session_id,
|
|
const char *entry_id,
|
|
const char *exercise_id,
|
|
const char *started_at,
|
|
TrainlogTrackingMode tracking,
|
|
TrainlogRecordingMode recording,
|
|
TrainlogLoadMode load,
|
|
bool explicit_max) {
|
|
TrainlogSessionInput session;
|
|
TrainlogSessionExerciseInput occurrence;
|
|
TrainlogSetInput sets[2] = {
|
|
{.reps = 8, .has_weight = load == TRAINLOG_LOAD_EXTERNAL, .weight_kg = 40.0},
|
|
{.reps = 10, .has_weight = load == TRAINLOG_LOAD_EXTERNAL, .weight_kg = 42.0},
|
|
};
|
|
|
|
(void)memset(&session, 0, sizeof(session));
|
|
(void)memset(&occurrence, 0, sizeof(occurrence));
|
|
(void)snprintf(session.session_id, sizeof(session.session_id), "%s", session_id);
|
|
(void)snprintf(session.started_at, sizeof(session.started_at), "%s", started_at);
|
|
(void)snprintf(occurrence.entry_id, sizeof(occurrence.entry_id), "%s", entry_id);
|
|
(void)snprintf(occurrence.exercise_id, sizeof(occurrence.exercise_id), "%s", exercise_id);
|
|
session.session_type = explicit_max ? TRAINLOG_SESSION_MAX_TEST : TRAINLOG_SESSION_TRAINING;
|
|
session.exercises = &occurrence;
|
|
session.exercise_count = 1U;
|
|
occurrence.tracking_mode = tracking;
|
|
occurrence.recording_mode = recording;
|
|
occurrence.load_mode = load;
|
|
if (explicit_max) {
|
|
occurrence.has_max_weight = true;
|
|
occurrence.max_weight_kg = 100.0;
|
|
} else if (recording == TRAINLOG_RECORDING_CONTINUOUS) {
|
|
occurrence.continuous_duration_seconds = 1200;
|
|
occurrence.continuous_distance_km = 3.0;
|
|
occurrence.continuous_speed_kmh = 9.0;
|
|
} else {
|
|
occurrence.sets = sets;
|
|
occurrence.set_count = 2U;
|
|
}
|
|
return trainlog_database_insert_session(database, &session) == TRAINLOG_STATUS_OK;
|
|
}
|
|
|
|
static bool test_empty_and_periods(void) {
|
|
static const TrainlogWebAnalysisPeriod PERIODS[] = {
|
|
TRAINLOG_WEB_ANALYSIS_7_DAYS,
|
|
TRAINLOG_WEB_ANALYSIS_30_DAYS,
|
|
TRAINLOG_WEB_ANALYSIS_90_DAYS,
|
|
TRAINLOG_WEB_ANALYSIS_ALL,
|
|
};
|
|
TrainlogDatabase *database = NULL;
|
|
size_t index;
|
|
|
|
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
|
|
for (index = 0U; index < sizeof(PERIODS) / sizeof(PERIODS[0]); ++index) {
|
|
yyjson_doc *document = NULL;
|
|
yyjson_val *root;
|
|
CHECK(read_snapshot(database, PERIODS[index], NULL, "weight", &document));
|
|
root = yyjson_doc_get_root(document);
|
|
CHECK(yyjson_get_int(yyjson_obj_get(yyjson_obj_get(root, "overview"), "sessions")) == 0);
|
|
CHECK(yyjson_is_null(yyjson_obj_get(root, "exercise")));
|
|
CHECK(yyjson_is_null(yyjson_obj_get(root, "active_program")));
|
|
yyjson_doc_free(document);
|
|
}
|
|
trainlog_database_close(database);
|
|
return true;
|
|
}
|
|
|
|
static bool test_factual_models(void) {
|
|
static const char LOADED[] = "ex_10000000-0000-4000-8000-000000000001";
|
|
static const char UNLOADED[] = "ex_10000000-0000-4000-8000-000000000002";
|
|
static const char DURATION[] = "ex_10000000-0000-4000-8000-000000000003";
|
|
const char *none[] = {NULL};
|
|
TrainlogDatabase *database = NULL;
|
|
TrainlogBodyObservationInput observation;
|
|
yyjson_doc *document = NULL;
|
|
yyjson_val *root;
|
|
yyjson_val *exercise;
|
|
yyjson_val *measurements;
|
|
yyjson_val *summaries;
|
|
yyjson_val *waist;
|
|
|
|
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
|
|
CHECK(trainlog_database_insert_exercise_profiled(database,
|
|
LOADED,
|
|
"Loaded",
|
|
"loaded",
|
|
TRAINLOG_TRACKING_REPS,
|
|
TRAINLOG_RECORDING_SETS,
|
|
0) == TRAINLOG_STATUS_OK);
|
|
CHECK(trainlog_database_insert_exercise_profiled(database,
|
|
UNLOADED,
|
|
"Unloaded",
|
|
"unloaded",
|
|
TRAINLOG_TRACKING_REPS,
|
|
TRAINLOG_RECORDING_SETS,
|
|
0) == TRAINLOG_STATUS_OK);
|
|
CHECK(trainlog_database_insert_exercise_profiled(database,
|
|
DURATION,
|
|
"Duration",
|
|
"duration",
|
|
TRAINLOG_TRACKING_DURATION,
|
|
TRAINLOG_RECORDING_CONTINUOUS,
|
|
3) == TRAINLOG_STATUS_OK);
|
|
CHECK(trainlog_database_replace_exercise_body_zones(database, LOADED, "chest", none, 0U) ==
|
|
TRAINLOG_STATUS_OK);
|
|
CHECK(trainlog_database_replace_exercise_body_zones(database, UNLOADED, "arms", none, 0U) ==
|
|
TRAINLOG_STATUS_OK);
|
|
CHECK(trainlog_database_replace_exercise_body_zones(database, DURATION, "thighs", none, 0U) ==
|
|
TRAINLOG_STATUS_OK);
|
|
CHECK(insert_session(database,
|
|
"se_10000000-0000-4000-8000-000000000001",
|
|
"en_10000000-0000-4000-8000-000000000001",
|
|
LOADED,
|
|
"2026-09-15T10:00:00Z",
|
|
TRAINLOG_TRACKING_REPS,
|
|
TRAINLOG_RECORDING_SETS,
|
|
TRAINLOG_LOAD_EXTERNAL,
|
|
false));
|
|
CHECK(insert_session(database,
|
|
"se_10000000-0000-4000-8000-000000000002",
|
|
"en_10000000-0000-4000-8000-000000000002",
|
|
UNLOADED,
|
|
"2026-09-14T10:00:00Z",
|
|
TRAINLOG_TRACKING_REPS,
|
|
TRAINLOG_RECORDING_SETS,
|
|
TRAINLOG_LOAD_NONE,
|
|
false));
|
|
CHECK(insert_session(database,
|
|
"se_10000000-0000-4000-8000-000000000003",
|
|
"en_10000000-0000-4000-8000-000000000003",
|
|
DURATION,
|
|
"2026-09-13T10:00:00Z",
|
|
TRAINLOG_TRACKING_DURATION,
|
|
TRAINLOG_RECORDING_CONTINUOUS,
|
|
TRAINLOG_LOAD_NONE,
|
|
false));
|
|
CHECK(insert_session(database,
|
|
"se_10000000-0000-4000-8000-000000000004",
|
|
"en_10000000-0000-4000-8000-000000000004",
|
|
LOADED,
|
|
"2026-09-12T10:00:00Z",
|
|
TRAINLOG_TRACKING_REPS,
|
|
TRAINLOG_RECORDING_SETS,
|
|
TRAINLOG_LOAD_EXTERNAL,
|
|
true));
|
|
|
|
(void)memset(&observation, 0, sizeof(observation));
|
|
(void)snprintf(observation.observation_id, sizeof(observation.observation_id), "bo_analysis_1");
|
|
(void)snprintf(
|
|
observation.observed_at, sizeof(observation.observed_at), "2026-09-10T08:00:00Z");
|
|
observation.has_body_weight = true;
|
|
observation.body_weight_kg = 80.0;
|
|
observation.has_waist = true;
|
|
observation.waist_cm = 90.0;
|
|
CHECK(trainlog_database_insert_body_observation(database, &observation) == TRAINLOG_STATUS_OK);
|
|
(void)snprintf(observation.observation_id, sizeof(observation.observation_id), "bo_analysis_2");
|
|
(void)snprintf(
|
|
observation.observed_at, sizeof(observation.observed_at), "2026-09-15T08:00:00Z");
|
|
observation.body_weight_kg = 79.5;
|
|
observation.waist_cm = 88.0;
|
|
CHECK(trainlog_database_insert_body_observation(database, &observation) == TRAINLOG_STATUS_OK);
|
|
|
|
CHECK(
|
|
sqlite3_exec(database->connection,
|
|
"INSERT INTO programs VALUES('pg_analysis','Program',NULL,'active',NULL,NULL,"
|
|
"'2026-09-01T00:00:00Z','2026-09-01T00:00:00Z','rev','test',1,"
|
|
"'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',NULL);"
|
|
"INSERT INTO program_sessions VALUES('ps_analysis','pg_analysis',0,'Next',"
|
|
"'training','2026-09-21',NULL);",
|
|
NULL,
|
|
NULL,
|
|
NULL) == SQLITE_OK);
|
|
|
|
CHECK(read_snapshot(database, TRAINLOG_WEB_ANALYSIS_30_DAYS, LOADED, "waist", &document));
|
|
root = yyjson_doc_get_root(document);
|
|
CHECK(yyjson_get_int(yyjson_obj_get(yyjson_obj_get(root, "overview"), "sessions")) == 4);
|
|
exercise = yyjson_obj_get(root, "exercise");
|
|
CHECK(strcmp(yyjson_get_str(yyjson_obj_get(exercise, "exercise_id")), LOADED) == 0);
|
|
CHECK(yyjson_arr_size(yyjson_obj_get(exercise, "points")) == 2U);
|
|
CHECK(yyjson_arr_size(yyjson_obj_get(root, "body_zones")) == 3U);
|
|
CHECK(!yyjson_is_null(yyjson_obj_get(root, "active_program")));
|
|
{
|
|
yyjson_val *program = yyjson_obj_get(root, "active_program");
|
|
CHECK(strcmp(yyjson_get_str(yyjson_obj_get(program, "next_session_title")), "Next") == 0);
|
|
CHECK(strcmp(yyjson_get_str(yyjson_obj_get(program, "next_session_id")), "ps_analysis") ==
|
|
0);
|
|
CHECK(strcmp(yyjson_get_str(yyjson_obj_get(program, "next_session_planned_for")),
|
|
"2026-09-21") == 0);
|
|
}
|
|
measurements = yyjson_obj_get(root, "measurements");
|
|
CHECK(yyjson_arr_size(yyjson_obj_get(measurements, "points")) == 2U);
|
|
{
|
|
yyjson_val *series = yyjson_obj_get(measurements, "series");
|
|
yyjson_val *first_series = yyjson_arr_get(series, 0U);
|
|
yyjson_val *second_series = yyjson_arr_get(series, 1U);
|
|
CHECK(yyjson_arr_size(series) == 2U);
|
|
CHECK(strcmp(yyjson_get_str(yyjson_obj_get(first_series, "metric")), "weight") == 0);
|
|
CHECK(strcmp(yyjson_get_str(yyjson_obj_get(second_series, "metric")), "waist") == 0);
|
|
CHECK(yyjson_arr_size(yyjson_obj_get(first_series, "points")) == 2U);
|
|
CHECK(yyjson_arr_size(yyjson_obj_get(second_series, "points")) == 2U);
|
|
}
|
|
summaries = yyjson_obj_get(measurements, "summaries");
|
|
waist = yyjson_arr_get(summaries, 4U);
|
|
CHECK(yyjson_get_int(yyjson_obj_get(waist, "count")) == 2);
|
|
CHECK(yyjson_get_real(yyjson_obj_get(waist, "delta")) == -2.0);
|
|
yyjson_doc_free(document);
|
|
trainlog_database_close(database);
|
|
return true;
|
|
}
|
|
|
|
static bool check_next_session(TrainlogDatabase *database,
|
|
const char *expected_id,
|
|
const char *expected_title,
|
|
const char *expected_date) {
|
|
yyjson_doc *document = NULL;
|
|
yyjson_val *program;
|
|
yyjson_val *id;
|
|
yyjson_val *title;
|
|
yyjson_val *date;
|
|
|
|
CHECK(read_snapshot(database, TRAINLOG_WEB_ANALYSIS_30_DAYS, NULL, "weight", &document));
|
|
program = yyjson_obj_get(yyjson_doc_get_root(document), "active_program");
|
|
CHECK(!yyjson_is_null(program));
|
|
id = yyjson_obj_get(program, "next_session_id");
|
|
title = yyjson_obj_get(program, "next_session_title");
|
|
date = yyjson_obj_get(program, "next_session_planned_for");
|
|
CHECK(expected_id == NULL ? yyjson_is_null(id)
|
|
: yyjson_is_str(id) && strcmp(yyjson_get_str(id), expected_id) == 0);
|
|
CHECK(expected_title == NULL
|
|
? yyjson_is_null(title)
|
|
: yyjson_is_str(title) && strcmp(yyjson_get_str(title), expected_title) == 0);
|
|
CHECK(expected_date == NULL
|
|
? yyjson_is_null(date)
|
|
: yyjson_is_str(date) && strcmp(yyjson_get_str(date), expected_date) == 0);
|
|
yyjson_doc_free(document);
|
|
return true;
|
|
}
|
|
|
|
static bool test_current_program_plan(void) {
|
|
TrainlogDatabase *database = NULL;
|
|
yyjson_doc *document = NULL;
|
|
|
|
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
|
|
/* WHY: source dates remain historical; current planning and execution are
|
|
* independent facts. These rows exercise their precedence without touching
|
|
* any user database or invoking the rescheduling command. */
|
|
CHECK(sqlite3_exec(database->connection,
|
|
"INSERT INTO programs VALUES('pg_current','Current',NULL,'active',NULL,"
|
|
"NULL,'2026-09-01T00:00:00Z','2026-09-01T00:00:00Z','rev','test',1,"
|
|
"'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',"
|
|
"NULL);"
|
|
"INSERT INTO program_sessions VALUES"
|
|
"('ps_ceded','pg_current',0,'Optional','training','2026-09-29',NULL),"
|
|
"('ps_s2c','pg_current',1,'S2 C','training','2026-09-30',NULL),"
|
|
"('ps_later','pg_current',2,'Later','training','2026-10-03',NULL),"
|
|
"('ps_tie','pg_current',3,'Tie','training','2026-10-02',NULL),"
|
|
"('ps_undated','pg_current',4,'Undated','training',NULL,NULL);",
|
|
NULL,
|
|
NULL,
|
|
NULL) == SQLITE_OK);
|
|
CHECK(check_next_session(database, "ps_ceded", "Optional", "2026-09-29"));
|
|
|
|
CHECK(sqlite3_exec(database->connection,
|
|
"INSERT INTO program_session_planning VALUES"
|
|
"('ps_ceded',NULL,'ceded','op_a','2026-10-01T00:00:00Z'),"
|
|
"('ps_s2c','2026-10-02','active','op_b','2026-10-01T00:00:00Z');",
|
|
NULL,
|
|
NULL,
|
|
NULL) == SQLITE_OK);
|
|
CHECK(check_next_session(database, "ps_s2c", "S2 C", "2026-10-02"));
|
|
|
|
CHECK(sqlite3_exec(database->connection,
|
|
"INSERT INTO program_session_executions VALUES"
|
|
"('ps_s2c','pg_current','se_fixture_1','in_progress',"
|
|
"'2026-10-02T12:00:00Z');",
|
|
NULL,
|
|
NULL,
|
|
NULL) == SQLITE_OK);
|
|
CHECK(check_next_session(database, "ps_s2c", "S2 C", "2026-10-02"));
|
|
|
|
CHECK(sqlite3_exec(database->connection,
|
|
"UPDATE program_session_executions SET state='completed' "
|
|
"WHERE program_session_id='ps_s2c';",
|
|
NULL,
|
|
NULL,
|
|
NULL) == SQLITE_OK);
|
|
CHECK(check_next_session(database, "ps_tie", "Tie", "2026-10-02"));
|
|
CHECK(sqlite3_exec(database->connection,
|
|
"INSERT INTO program_session_executions VALUES"
|
|
"('ps_tie','pg_current','se_fixture_2','completed','2026-10-02T12:00:00Z'),"
|
|
"('ps_later','pg_current','se_fixture_3','deleted','2026-10-03T12:00:00Z');",
|
|
NULL,
|
|
NULL,
|
|
NULL) == SQLITE_OK);
|
|
CHECK(check_next_session(database, "ps_undated", "Undated", NULL));
|
|
CHECK(sqlite3_exec(database->connection,
|
|
"INSERT INTO program_session_executions VALUES"
|
|
"('ps_undated','pg_current','se_fixture_4','completed',"
|
|
"'2026-10-04T12:00:00Z');",
|
|
NULL,
|
|
NULL,
|
|
NULL) == SQLITE_OK);
|
|
CHECK(check_next_session(database, NULL, NULL, NULL));
|
|
|
|
CHECK(sqlite3_exec(database->connection,
|
|
"UPDATE programs SET state='archived' WHERE program_id='pg_current';",
|
|
NULL,
|
|
NULL,
|
|
NULL) == SQLITE_OK);
|
|
CHECK(read_snapshot(database, TRAINLOG_WEB_ANALYSIS_30_DAYS, NULL, "weight", &document));
|
|
CHECK(yyjson_is_null(yyjson_obj_get(yyjson_doc_get_root(document), "active_program")));
|
|
yyjson_doc_free(document);
|
|
document = NULL;
|
|
|
|
CHECK(sqlite3_exec(database->connection,
|
|
"UPDATE programs SET state='active',deleted_at='2026-10-05T00:00:00Z' "
|
|
"WHERE program_id='pg_current';",
|
|
NULL,
|
|
NULL,
|
|
NULL) == SQLITE_OK);
|
|
CHECK(read_snapshot(database, TRAINLOG_WEB_ANALYSIS_30_DAYS, NULL, "weight", &document));
|
|
CHECK(yyjson_is_null(yyjson_obj_get(yyjson_doc_get_root(document), "active_program")));
|
|
yyjson_doc_free(document);
|
|
trainlog_database_close(database);
|
|
return true;
|
|
}
|
|
|
|
int main(void) {
|
|
return test_empty_and_periods() && test_factual_models() && test_current_program_plan() ? 0 : 1;
|
|
}
|