299 lines
13 KiB
C
299 lines
13 KiB
C
/**
|
|
* @file test_database.c
|
|
* @brief Black-box tests for Trainlog persistence.
|
|
*/
|
|
|
|
#include <stdbool.h>
|
|
#include <math.h>
|
|
#include <stdio.h>
|
|
#include <string.h>
|
|
|
|
#include "trainlog/database.h"
|
|
#include "trainlog/id.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 test_database_open_and_schema(void) {
|
|
TrainlogDatabase *database = NULL;
|
|
int schema_version = 0;
|
|
int foreign_keys = 0;
|
|
|
|
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
|
|
CHECK(trainlog_database_schema_version(database, &schema_version) == TRAINLOG_STATUS_OK);
|
|
CHECK(schema_version == TRAINLOG_DATABASE_SCHEMA_VERSION);
|
|
CHECK(trainlog_database_foreign_keys_enabled(database, &foreign_keys) == TRAINLOG_STATUS_OK);
|
|
CHECK(foreign_keys == 1);
|
|
|
|
trainlog_database_close(database);
|
|
return true;
|
|
}
|
|
|
|
static bool test_database_open_diagnostic(void) {
|
|
TrainlogDatabase *database = NULL;
|
|
char diagnostic[256];
|
|
|
|
/* CONTRACT: a launcher must retain the stable status code while showing
|
|
* the SQLite operation that blocked access to the user's real database. */
|
|
CHECK(trainlog_database_open_with_diagnostic("/", &database, diagnostic, sizeof(diagnostic)) ==
|
|
TRAINLOG_STATUS_DATABASE_ERROR);
|
|
CHECK(database == NULL);
|
|
CHECK(strstr(diagnostic, "open database:") != NULL);
|
|
|
|
return true;
|
|
}
|
|
|
|
static bool test_generated_ids(void) {
|
|
char first[TRAINLOG_GENERATED_ID_CAPACITY];
|
|
char second[TRAINLOG_GENERATED_ID_CAPACITY];
|
|
char sync_id[TRAINLOG_GENERATED_ID_CAPACITY];
|
|
char entry_id[TRAINLOG_GENERATED_ID_CAPACITY];
|
|
char v1_exact_capacity[2U + 1U + TRAINLOG_UUID_TEXT_LENGTH + 1U];
|
|
|
|
CHECK(trainlog_id_generate("ex", first, sizeof(first)) == TRAINLOG_STATUS_OK);
|
|
CHECK(trainlog_id_generate("ex", second, sizeof(second)) == TRAINLOG_STATUS_OK);
|
|
CHECK(strncmp(first, "ex_", 3U) == 0);
|
|
CHECK(strlen(first) == 2U + 1U + TRAINLOG_UUID_TEXT_LENGTH);
|
|
CHECK(strcmp(first, second) != 0);
|
|
CHECK(first[3U + 14U] == '4');
|
|
/* ABI behavior: the new longer occurrence prefix must not invalidate the
|
|
* exact buffer size historically sufficient for two-character prefixes. */
|
|
CHECK(trainlog_id_generate("se", v1_exact_capacity, sizeof(v1_exact_capacity)) ==
|
|
TRAINLOG_STATUS_OK);
|
|
|
|
/* TRAINLOG_SYNC_ID_PREFIX_TEST */
|
|
CHECK(trainlog_id_generate("sy", sync_id, sizeof(sync_id)) == TRAINLOG_STATUS_OK);
|
|
|
|
CHECK(strncmp(sync_id, "sy_", 3U) == 0);
|
|
|
|
CHECK(sync_id[3U + 14U] == '4');
|
|
|
|
CHECK(trainlog_id_generate("sxe", entry_id, sizeof(entry_id)) == TRAINLOG_STATUS_OK);
|
|
CHECK(strncmp(entry_id, "sxe_", 4U) == 0);
|
|
CHECK(strlen(entry_id) == TRAINLOG_GENERATED_ID_CAPACITY - 1U);
|
|
CHECK(entry_id[4U + 14U] == '4');
|
|
|
|
return true;
|
|
}
|
|
|
|
static bool seed_exercise(TrainlogDatabase *database) {
|
|
return trainlog_database_insert_exercise(database,
|
|
"ex_test",
|
|
"Presse à cuisses",
|
|
"presse à cuisses",
|
|
TRAINLOG_TRACKING_REPS) == TRAINLOG_STATUS_OK;
|
|
}
|
|
|
|
static bool test_session_insert(void) {
|
|
TrainlogDatabase *database = NULL;
|
|
TrainlogSetInput sets[3];
|
|
TrainlogSessionExerciseInput exercise;
|
|
TrainlogSessionInput session;
|
|
TrainlogSessionSummary persisted_session;
|
|
TrainlogPersistedExerciseDetail persisted_exercises[1];
|
|
size_t count = 0U;
|
|
|
|
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
|
|
CHECK(seed_exercise(database));
|
|
|
|
(void)memset(sets, 0, sizeof(sets));
|
|
sets[0].reps = 5;
|
|
sets[0].has_weight = true;
|
|
sets[0].weight_kg = 32.5;
|
|
sets[1] = sets[0];
|
|
sets[1].weight_kg = 0.0;
|
|
sets[2] = sets[0];
|
|
sets[2].has_weight = false;
|
|
sets[2].weight_kg = 0.0;
|
|
|
|
(void)memset(&exercise, 0, sizeof(exercise));
|
|
(void)snprintf(exercise.exercise_id, sizeof(exercise.exercise_id), "%s", "ex_test");
|
|
exercise.load_mode = TRAINLOG_LOAD_EXTERNAL;
|
|
exercise.rest_seconds = 60;
|
|
exercise.target_sets = 3;
|
|
exercise.target_reps = 5;
|
|
exercise.target_has_weight = true;
|
|
exercise.target_weight_kg = 80.0;
|
|
exercise.sets = sets;
|
|
exercise.set_count = 3U;
|
|
|
|
(void)memset(&session, 0, sizeof(session));
|
|
(void)snprintf(session.session_id, sizeof(session.session_id), "%s", "se_test");
|
|
(void)snprintf(
|
|
session.started_at, sizeof(session.started_at), "%s", "2026-09-05T18:00:00+02:00");
|
|
(void)snprintf(session.ended_at, sizeof(session.ended_at), "%s", "2026-09-05T19:00:00+02:00");
|
|
session.exercises = &exercise;
|
|
session.exercise_count = 1U;
|
|
|
|
CHECK(trainlog_database_insert_session(database, &session) == TRAINLOG_STATUS_OK);
|
|
CHECK(trainlog_database_insert_session(database, &session) == TRAINLOG_STATUS_CONFLICT);
|
|
CHECK(trainlog_database_session_count(database, &count) == TRAINLOG_STATUS_OK);
|
|
CHECK(count == 1U);
|
|
CHECK(trainlog_database_get_session_details(
|
|
database, "se_test", &persisted_session, persisted_exercises, 1U, &count) ==
|
|
TRAINLOG_STATUS_OK);
|
|
CHECK(count == 1U);
|
|
/* Regression: local occurrences are `sxe`, never synchronization runs. */
|
|
CHECK(strncmp(persisted_exercises[0].entry_id, "sxe_", 4U) == 0);
|
|
CHECK(persisted_exercises[0].actual_set_count == 3U);
|
|
CHECK(persisted_exercises[0].actual_sets[0].has_weight);
|
|
CHECK(persisted_exercises[0].actual_sets[0].weight_kg == 32.5);
|
|
CHECK(persisted_exercises[0].actual_sets[1].has_weight);
|
|
CHECK(persisted_exercises[0].actual_sets[1].weight_kg == 0.0);
|
|
CHECK(!persisted_exercises[0].actual_sets[2].has_weight);
|
|
|
|
trainlog_database_free_session_details(persisted_exercises, count);
|
|
|
|
(void)snprintf(session.session_id, sizeof(session.session_id), "%s", "se_negative_weight");
|
|
sets[0].weight_kg = -0.5;
|
|
CHECK(trainlog_database_insert_session(database, &session) == TRAINLOG_STATUS_INVALID_ARGUMENT);
|
|
(void)snprintf(session.session_id, sizeof(session.session_id), "%s", "se_nonfinite_weight");
|
|
sets[0].weight_kg = INFINITY;
|
|
CHECK(trainlog_database_insert_session(database, &session) == TRAINLOG_STATUS_INVALID_ARGUMENT);
|
|
CHECK(trainlog_database_session_count(database, &count) == TRAINLOG_STATUS_OK);
|
|
CHECK(count == 1U);
|
|
|
|
trainlog_database_close(database);
|
|
return true;
|
|
}
|
|
|
|
static bool test_body_weight_history(void) {
|
|
TrainlogDatabase *database = NULL;
|
|
TrainlogBodyObservationInput observation;
|
|
TrainlogWeightPoint points[4];
|
|
size_t count = 0U;
|
|
|
|
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
|
|
|
|
(void)memset(&observation, 0, sizeof(observation));
|
|
(void)snprintf(observation.observation_id, sizeof(observation.observation_id), "%s", "bo_test");
|
|
(void)snprintf(observation.observed_at,
|
|
sizeof(observation.observed_at),
|
|
"%s",
|
|
"2026-09-05T07:00:00+02:00");
|
|
observation.has_body_weight = true;
|
|
observation.body_weight_kg = 82.4;
|
|
|
|
CHECK(trainlog_database_insert_body_observation(database, &observation) == TRAINLOG_STATUS_OK);
|
|
|
|
CHECK(trainlog_database_list_weight_points(database, points, 4U, &count) == TRAINLOG_STATUS_OK);
|
|
CHECK(count == 1U);
|
|
CHECK(points[0].body_weight_kg > 82.39);
|
|
CHECK(points[0].body_weight_kg < 82.41);
|
|
|
|
trainlog_database_close(database);
|
|
return true;
|
|
}
|
|
|
|
static bool test_transaction_rollback(void) {
|
|
TrainlogDatabase *database = NULL;
|
|
size_t count = 0U;
|
|
|
|
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
|
|
CHECK(trainlog_database_begin(database) == TRAINLOG_STATUS_OK);
|
|
CHECK(seed_exercise(database));
|
|
CHECK(trainlog_database_rollback(database) == TRAINLOG_STATUS_OK);
|
|
CHECK(trainlog_database_exercise_count(database, &count) == TRAINLOG_STATUS_OK);
|
|
/* Schema v13 seeds the five frozen supplied machine identities. The
|
|
* rolled-back custom row must not change that baseline. */
|
|
CHECK(count == 5U);
|
|
|
|
trainlog_database_close(database);
|
|
return true;
|
|
}
|
|
|
|
static bool test_exercise_merge_aliases_and_conflicts(void) {
|
|
TrainlogDatabase *database = NULL;
|
|
TrainlogExerciseBodyZone zones[4];
|
|
const char *source_secondary[] = {"shoulders"};
|
|
const char *target_secondary[] = {"chest"};
|
|
char canonical[TRAINLOG_ID_MAX + 1U];
|
|
TrainlogExerciseMergePreview preview;
|
|
size_t count = 0U;
|
|
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
|
|
CHECK(trainlog_database_insert_exercise(
|
|
database, "ex_source", "Curl A", "curl a", TRAINLOG_TRACKING_REPS) ==
|
|
TRAINLOG_STATUS_OK);
|
|
CHECK(trainlog_database_insert_exercise(
|
|
database, "ex_target", "Curl B", "curl b", TRAINLOG_TRACKING_REPS) ==
|
|
TRAINLOG_STATUS_OK);
|
|
CHECK(trainlog_database_insert_exercise(
|
|
database, "ex_final", "Curl C", "curl c", TRAINLOG_TRACKING_REPS) ==
|
|
TRAINLOG_STATUS_OK);
|
|
CHECK(trainlog_database_insert_exercise(
|
|
database, "ex_duration", "Hold", "hold", TRAINLOG_TRACKING_DURATION) ==
|
|
TRAINLOG_STATUS_OK);
|
|
CHECK(trainlog_database_replace_exercise_body_zones(
|
|
database, "ex_source", "arms", source_secondary, 1U) == TRAINLOG_STATUS_OK);
|
|
CHECK(trainlog_database_replace_exercise_body_zones(
|
|
database, "ex_target", "arms", target_secondary, 1U) == TRAINLOG_STATUS_OK);
|
|
CHECK(trainlog_database_preview_exercise_merge(database, "ex_source", &preview) ==
|
|
TRAINLOG_STATUS_OK);
|
|
CHECK(preview.occurrences == 0U && preview.performed_sets == 0U &&
|
|
preview.continuous_activities == 0U && preview.max_results == 0U &&
|
|
preview.associated_equipment == 0U && preview.body_zones == 2U);
|
|
|
|
CHECK(trainlog_database_merge_exercises(database, "ex_source", "ex_target") ==
|
|
TRAINLOG_STATUS_OK);
|
|
CHECK(trainlog_database_resolve_exercise_id(
|
|
database, "ex_source", canonical, sizeof(canonical)) == TRAINLOG_STATUS_OK);
|
|
CHECK(strcmp(canonical, "ex_target") == 0);
|
|
CHECK(trainlog_database_list_exercise_body_zones(database, "ex_target", zones, 4U, &count) ==
|
|
TRAINLOG_STATUS_OK);
|
|
CHECK(count == 3U);
|
|
CHECK(strcmp(zones[0].zone_id, "arms") == 0 && zones[0].role == TRAINLOG_BODY_ZONE_PRIMARY);
|
|
|
|
/* A second merge must collapse every old source directly to the newest
|
|
* canonical ID; resolution never depends on an unbounded alias walk. */
|
|
CHECK(trainlog_database_merge_exercises(database, "ex_target", "ex_final") ==
|
|
TRAINLOG_STATUS_OK);
|
|
CHECK(trainlog_database_resolve_exercise_id(
|
|
database, "ex_source", canonical, sizeof(canonical)) == TRAINLOG_STATUS_OK);
|
|
CHECK(strcmp(canonical, "ex_final") == 0);
|
|
CHECK(trainlog_database_resolve_exercise_id(
|
|
database, "ex_target", canonical, sizeof(canonical)) == TRAINLOG_STATUS_OK);
|
|
CHECK(strcmp(canonical, "ex_final") == 0);
|
|
|
|
CHECK(trainlog_database_merge_exercises(database, "ex_final", "ex_duration") ==
|
|
TRAINLOG_STATUS_CONFLICT);
|
|
CHECK(trainlog_database_resolve_exercise_id(
|
|
database, "ex_final", canonical, sizeof(canonical)) == TRAINLOG_STATUS_OK);
|
|
CHECK(strcmp(canonical, "ex_final") == 0);
|
|
CHECK(trainlog_database_exercise_count(database, &count) == TRAINLOG_STATUS_OK);
|
|
CHECK(count == 7U);
|
|
trainlog_database_close(database);
|
|
return true;
|
|
}
|
|
|
|
struct TestCase {
|
|
const char *name;
|
|
bool (*function)(void);
|
|
};
|
|
|
|
int main(void) {
|
|
static const struct TestCase tests[] = {
|
|
{"database_open_and_schema", test_database_open_and_schema},
|
|
{"database_open_diagnostic", test_database_open_diagnostic},
|
|
{"generated_ids", test_generated_ids},
|
|
{"session_insert", test_session_insert},
|
|
{"body_weight_history", test_body_weight_history},
|
|
{"transaction_rollback", test_transaction_rollback},
|
|
{"exercise_merge_aliases_and_conflicts", test_exercise_merge_aliases_and_conflicts},
|
|
};
|
|
size_t index;
|
|
|
|
for (index = 0U; index < sizeof(tests) / sizeof(tests[0]); ++index) {
|
|
if (!tests[index].function()) {
|
|
(void)fprintf(stderr, "FAIL %s\n", tests[index].name);
|
|
return 1;
|
|
}
|
|
(void)printf("PASS %s\n", tests[index].name);
|
|
}
|
|
|
|
return 0;
|
|
}
|