trainlog/tui/tests/test_sync_body_zone_wiring.c

614 lines
22 KiB
C

#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sqlite3.h>
#include "trainlog/database.h"
#include "trainlog/mtp.h"
#include "trainlog/sync.h"
#include "trainlog/usb.h"
#define CHECK(value) do { \
if (!(value)) { \
(void)fprintf(stderr, "FAIL line %d: %s\n", __LINE__, #value); \
return false; \
} \
} while (0)
static const char *const SOURCE_ID =
"ex_43c7375f-934c-4650-930c-45807d2f2929";
static const char *const CANONICAL_ID =
"ex_2d488c08-194c-4051-a3c9-34471646c1d3";
static const char *const EXERCISE_NAME = "Sync wiring exercise";
static char remote_root[4096];
static const char *remote_mobile_name = "trainlog-mobile-export-v2.json";
static unsigned int remote_mobile_version = 2U;
static bool copy_file(const char *source, const char *target)
{
FILE *input = fopen(source, "rb");
FILE *output;
char buffer[4096];
size_t count;
bool copied = true;
if (input == NULL) {
return false;
}
output = fopen(target, "wb");
if (output == NULL) {
(void)fclose(input);
return false;
}
while ((count = fread(buffer, 1U, sizeof(buffer), input)) != 0U) {
if (fwrite(buffer, 1U, count, output) != count) {
copied = false;
break;
}
}
if (ferror(input) != 0) {
copied = false;
}
if (fclose(input) != 0) {
copied = false;
}
if (fclose(output) != 0) {
copied = false;
}
return copied;
}
TrainlogStatus trainlog_usb_list_mtp_devices(
TrainlogUsbDevice *output,
size_t capacity,
size_t *output_count
)
{
if (output_count == NULL || output == NULL || capacity < 1U) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
(void)memset(output, 0, sizeof(*output));
output->bus_number = 1U;
output->device_number = 2U;
output->mtp = true;
*output_count = 1U;
return TRAINLOG_STATUS_OK;
}
TrainlogStatus trainlog_mtp_list_storages(
unsigned int bus_number,
unsigned int device_number,
TrainlogMtpStorage *output,
size_t capacity,
size_t *output_count
)
{
(void)bus_number;
(void)device_number;
if (output_count == NULL || output == NULL || capacity < 1U) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
(void)memset(output, 0, sizeof(*output));
output->storage_id = 3U;
*output_count = 1U;
return TRAINLOG_STATUS_OK;
}
static void set_entry(
TrainlogMtpEntry *entry,
uint32_t item_id,
uint32_t parent_id,
const char *name,
bool folder
)
{
(void)memset(entry, 0, sizeof(*entry));
entry->item_id = item_id;
entry->parent_id = parent_id;
entry->storage_id = 3U;
entry->folder = folder;
entry->modification_unix_seconds = 1U;
(void)snprintf(entry->name, sizeof(entry->name), "%s", name);
}
TrainlogStatus trainlog_mtp_list_folder(
unsigned int bus_number,
unsigned int device_number,
uint32_t storage_id,
uint32_t parent_folder_id,
TrainlogMtpEntry *output,
size_t capacity,
size_t *output_count
)
{
(void)bus_number;
(void)device_number;
(void)storage_id;
if (output_count == NULL || output == NULL) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
if (parent_folder_id == UINT32_MAX) {
if (capacity < 1U) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
set_entry(&output[0], 1U, UINT32_MAX, "Download", true);
*output_count = 1U;
return TRAINLOG_STATUS_OK;
}
if (parent_folder_id == 1U) {
if (capacity < 1U) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
set_entry(&output[0], 2U, 1U, "Trainlog", true);
*output_count = 1U;
return TRAINLOG_STATUS_OK;
}
if (parent_folder_id == 2U) {
if (capacity < 3U) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
set_entry(&output[0], 10U, 2U, remote_mobile_name, false);
set_entry(&output[1], 11U, 2U,
"trainlog-exercise-body-zones-v1.json", false);
set_entry(&output[2], 12U, 2U,
"trainlog-equipment-associations-v2.json", false);
*output_count = 3U;
return TRAINLOG_STATUS_OK;
}
*output_count = 0U;
return TRAINLOG_STATUS_OK;
}
TrainlogStatus trainlog_mtp_receive_file(
unsigned int bus_number,
unsigned int device_number,
uint32_t item_id,
const char *local_path
)
{
char source[4096];
const char *name;
int written;
(void)bus_number;
(void)device_number;
name = item_id == 10U ? remote_mobile_name :
item_id == 11U ? "trainlog-exercise-body-zones-v1.json" :
item_id == 12U ? "trainlog-equipment-associations-v2.json" : NULL;
if (name == NULL || local_path == NULL) {
return TRAINLOG_STATUS_NOT_FOUND;
}
written = snprintf(source, sizeof(source), "%s/%s", remote_root, name);
if (written < 0 || (size_t)written >= sizeof(source) ||
!copy_file(source, local_path)) {
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
return TRAINLOG_STATUS_OK;
}
TrainlogStatus trainlog_mtp_send_text_file(
unsigned int bus_number,
unsigned int device_number,
uint32_t storage_id,
uint32_t parent_folder_id,
const char *local_path,
const char *remote_filename,
uint32_t *output_item_id
)
{
(void)bus_number;
(void)device_number;
(void)storage_id;
(void)parent_folder_id;
{
char target[4096];
int written = snprintf(target, sizeof(target), "%s/%s", remote_root,
remote_filename);
if (local_path == NULL || remote_filename == NULL || written < 0 ||
(size_t)written >= sizeof(target) || !copy_file(local_path, target)) {
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
}
if (output_item_id != NULL) {
*output_item_id = 99U;
}
return TRAINLOG_STATUS_OK;
}
TrainlogStatus trainlog_mtp_delete_object(
unsigned int bus_number,
unsigned int device_number,
uint32_t item_id
)
{
(void)bus_number;
(void)device_number;
(void)item_id;
return TRAINLOG_STATUS_OK;
}
static bool write_artifacts(
const char *mobile_id,
const char *zone_id,
bool historical_alias_data
)
{
char path[4096];
FILE *file;
int written = snprintf(path, sizeof(path),
"%s/%s", remote_root, remote_mobile_name);
CHECK(written >= 0 && (size_t)written < sizeof(path));
file = fopen(path, "wb");
CHECK(file != NULL);
if (historical_alias_data) {
CHECK(fprintf(file,
"{\"format\":\"trainlog-mobile-export\",\"version\":%u,"
"\"generated_at\":\"2032-01-01T00:00:00+00:00\","
"\"exercises\":["
"{\"exercise_id\":\"%s\",\"name\":\"Retired sync wiring exercise\","
"\"recording_mode\":\"sets\",\"tracking_mode\":\"reps\",\"data_fields\":0},"
"{\"exercise_id\":\"%s\",\"name\":\"%s\","
"\"recording_mode\":\"sets\",\"tracking_mode\":\"reps\",\"data_fields\":0}],"
"\"sessions\":[{\"session_id\":\"se_alias_sync_fixture\","
"\"started_at\":\"2032-01-01T01:00:00+00:00\",\"session_type\":\"training\","
"\"exercises\":["
"{\"entry_id\":\"sxe_alias_historical_a\",\"position\":0,"
"\"exercise_id\":\"%s\",\"name\":\"Retired sync wiring exercise\","
"\"recording_mode\":\"sets\",\"tracking_mode\":\"reps\",\"data_fields\":0,"
"\"load_mode\":\"none\",\"rest_seconds\":0,\"target\":null,"
"\"equipment_id\":null,\"sets\":[{\"reps\":8}]},"
"{\"entry_id\":\"sxe_alias_historical_b\",\"position\":1,"
"\"exercise_id\":\"%s\",\"name\":\"%s\","
"\"recording_mode\":\"sets\",\"tracking_mode\":\"reps\",\"data_fields\":0,"
"\"load_mode\":\"none\",\"rest_seconds\":0,\"target\":null,"
"\"equipment_id\":null,\"sets\":[{\"reps\":9}]}]}],"
"\"body_observations\":[]}",
remote_mobile_version, SOURCE_ID, CANONICAL_ID, EXERCISE_NAME,
SOURCE_ID, CANONICAL_ID, EXERCISE_NAME) > 0);
} else {
CHECK(fprintf(file,
"{\"format\":\"trainlog-mobile-export\",\"version\":%u,"
"\"generated_at\":\"2032-01-01T00:00:00+00:00\","
"\"exercises\":[{\"exercise_id\":\"%s\",\"name\":\"%s\","
"\"recording_mode\":\"sets\",\"tracking_mode\":\"reps\","
"\"data_fields\":0}],\"sessions\":[],\"body_observations\":[]}",
remote_mobile_version, mobile_id, EXERCISE_NAME) > 0);
}
CHECK(fclose(file) == 0);
written = snprintf(path, sizeof(path),
"%s/trainlog-exercise-body-zones-v1.json", remote_root);
CHECK(written >= 0 && (size_t)written < sizeof(path));
file = fopen(path, "wb");
CHECK(file != NULL);
CHECK(fprintf(file,
"{\"format\":\"trainlog-exercise-body-zones\",\"version\":1,"
"\"generated_at\":\"2032-01-01T00:00:00+00:00\","
"\"exercises\":[{\"exercise_id\":\"%s\","
"\"primary_zone_id\":\"back\",\"secondary_zone_ids\":[\"arms\"]},"
"{\"exercise_id\":\"%s\",\"primary_zone_id\":\"back\","
"\"secondary_zone_ids\":[\"arms\"]}]}",
zone_id, CANONICAL_ID) > 0);
CHECK(fclose(file) == 0);
written = snprintf(path, sizeof(path),
"%s/trainlog-equipment-associations-v2.json", remote_root);
CHECK(written >= 0 && (size_t)written < sizeof(path));
file = fopen(path, "wb");
CHECK(file != NULL);
CHECK(fprintf(file,
"{\"format\":\"trainlog-equipment-associations\",\"version\":2,"
"\"generated_at\":\"2032-01-01T00:00:00+00:00\",\"associations\":%s}",
historical_alias_data
? "[{\"session_id\":\"se_alias_sync_fixture\",\"entry_id\":\"sxe_alias_historical_a\",\"exercise_id\":\"ex_43c7375f-934c-4650-930c-45807d2f2929\",\"state\":\"cleared\"},{\"session_id\":\"se_alias_sync_fixture\",\"entry_id\":\"sxe_alias_historical_b\",\"exercise_id\":\"ex_2d488c08-194c-4051-a3c9-34471646c1d3\",\"state\":\"cleared\"}]"
: "[]") > 0);
CHECK(fclose(file) == 0);
return true;
}
static bool prepare_database(
const char *case_root,
bool persistent_alias,
bool initial_zone,
char *database_path,
size_t database_path_size
)
{
TrainlogDatabase *database = NULL;
const char *no_secondary[1] = {NULL};
char data_directory[4096];
int written;
CHECK(mkdir(case_root, 0700) == 0);
CHECK(setenv("XDG_DATA_HOME", case_root, 1) == 0);
written = snprintf(data_directory, sizeof(data_directory),
"%s/trainlog", case_root);
CHECK(written >= 0 && (size_t)written < sizeof(data_directory));
CHECK(mkdir(data_directory, 0700) == 0);
written = snprintf(database_path, database_path_size,
"%s/trainlog.db", data_directory);
CHECK(written >= 0 && (size_t)written < database_path_size);
CHECK(trainlog_database_open(database_path, &database) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_insert_exercise_profiled(
database, CANONICAL_ID, EXERCISE_NAME, "sync wiring exercise",
TRAINLOG_TRACKING_REPS, TRAINLOG_RECORDING_SETS,
UINT32_C(0)) == TRAINLOG_STATUS_OK);
if (initial_zone) {
CHECK(trainlog_database_replace_exercise_body_zones(
database, CANONICAL_ID, "chest", no_secondary, 0U) ==
TRAINLOG_STATUS_OK);
}
if (persistent_alias) {
CHECK(trainlog_database_insert_exercise_profiled(
database, SOURCE_ID, "Retired sync wiring exercise",
"retired sync wiring exercise", TRAINLOG_TRACKING_REPS,
TRAINLOG_RECORDING_SETS, UINT32_C(0)) ==
TRAINLOG_STATUS_OK);
CHECK(trainlog_database_merge_exercises(
database, SOURCE_ID, CANONICAL_ID) == TRAINLOG_STATUS_OK);
}
trainlog_database_close(database);
return true;
}
static bool database_state(
const char *database_path,
bool source_should_resolve,
const char *expected_primary,
const char *expected_secondary,
size_t expected_zone_count
)
{
TrainlogDatabase *database = NULL;
TrainlogExerciseBodyZone zones[4];
char resolved[TRAINLOG_ID_MAX + 1U];
size_t count = 0U;
size_t index;
bool found_primary = false;
bool found_secondary = expected_secondary == NULL;
sqlite3 *raw = NULL;
sqlite3_stmt *statement = NULL;
CHECK(trainlog_database_open(database_path, &database) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_resolve_exercise_id(
database, SOURCE_ID, resolved, sizeof(resolved)) ==
(source_should_resolve ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_NOT_FOUND));
if (source_should_resolve) {
CHECK(strcmp(resolved, CANONICAL_ID) == 0);
}
CHECK(trainlog_database_list_exercise_body_zones(
database, CANONICAL_ID, zones, 4U, &count) == TRAINLOG_STATUS_OK);
CHECK(count == expected_zone_count);
for (index = 0U; index < count; ++index) {
if (zones[index].role == TRAINLOG_BODY_ZONE_PRIMARY &&
strcmp(zones[index].zone_id, expected_primary) == 0) {
found_primary = true;
}
if (expected_secondary != NULL &&
zones[index].role == TRAINLOG_BODY_ZONE_SECONDARY &&
strcmp(zones[index].zone_id, expected_secondary) == 0) {
found_secondary = true;
}
}
CHECK(found_primary && found_secondary);
trainlog_database_close(database);
/* INVARIANT: alias coalescing updates one canonical row; it never
* resurrects the retired creator or duplicates mappings/aliases. */
CHECK(sqlite3_open(database_path, &raw) == SQLITE_OK);
CHECK(sqlite3_prepare_v2(raw, "SELECT COUNT(*) FROM exercises", -1,
&statement, NULL) == SQLITE_OK);
CHECK(sqlite3_step(statement) == SQLITE_ROW);
CHECK(sqlite3_column_int(statement, 0) == 1);
CHECK(sqlite3_finalize(statement) == SQLITE_OK);
statement = NULL;
if (source_should_resolve) {
CHECK(sqlite3_prepare_v2(raw,
"SELECT COUNT(*) FROM exercise_aliases WHERE source_exercise_id=?1 "
"AND canonical_exercise_id=?2", -1, &statement, NULL) == SQLITE_OK);
CHECK(sqlite3_bind_text(statement, 1, SOURCE_ID, -1, SQLITE_STATIC) == SQLITE_OK);
CHECK(sqlite3_bind_text(statement, 2, CANONICAL_ID, -1, SQLITE_STATIC) == SQLITE_OK);
CHECK(sqlite3_step(statement) == SQLITE_ROW);
CHECK(sqlite3_column_int(statement, 0) == 1);
CHECK(sqlite3_finalize(statement) == SQLITE_OK);
}
CHECK(sqlite3_close(raw) == SQLITE_OK);
return true;
}
static bool successful_journal(const char *case_root, const char *sync_id)
{
char path[4096];
char content[8192];
FILE *file;
size_t count;
int written = snprintf(path, sizeof(path), "%s/trainlog/sync_runs/%s.json",
case_root, sync_id);
CHECK(written >= 0 && (size_t)written < sizeof(path));
file = fopen(path, "rb");
CHECK(file != NULL);
count = fread(content, 1U, sizeof(content) - 1U, file);
CHECK(ferror(file) == 0);
CHECK(fclose(file) == 0);
content[count] = '\0';
CHECK(strstr(content, "\"status\":\"success\"") != NULL);
return true;
}
static bool file_contains(const char *name, const char *needle, bool expected)
{
char path[4096];
char content[65536];
FILE *file;
size_t count;
int written = snprintf(path, sizeof(path), "%s/%s", remote_root, name);
CHECK(written >= 0 && (size_t)written < sizeof(path));
file = fopen(path, "rb");
CHECK(file != NULL);
count = fread(content, 1U, sizeof(content) - 1U, file);
CHECK(ferror(file) == 0);
CHECK(fclose(file) == 0);
content[count] = '\0';
CHECK((strstr(content, needle) != NULL) == expected);
return true;
}
static bool canonical_alias_exports(void)
{
/* CONTRACT: retired identities are published only by the alias companion;
* every catalogue, session, zone, and equipment reference uses live B. */
static const char *const canonical_outputs[] = {
"trainlog-pc-catalog-v1.json",
"trainlog-pc-mobile-export-v3.json",
"trainlog-exercise-body-zones-v1.json",
"trainlog-equipment-associations-v2.json",
};
size_t index;
for (index = 0U;
index < sizeof(canonical_outputs) / sizeof(canonical_outputs[0]);
++index) {
CHECK(file_contains(canonical_outputs[index], SOURCE_ID, false));
CHECK(file_contains(canonical_outputs[index], CANONICAL_ID, true));
}
CHECK(file_contains("trainlog-exercise-aliases-v1.json", SOURCE_ID, true));
CHECK(file_contains("trainlog-exercise-aliases-v1.json", CANONICAL_ID, true));
return true;
}
static bool run_success_case(
const char *case_root,
bool persistent_alias
)
{
TrainlogSyncReport report;
char database_path[4096];
if (persistent_alias) {
remote_mobile_name = "trainlog-mobile-export-v3.json";
remote_mobile_version = 3U;
}
CHECK(prepare_database(case_root, persistent_alias, false,
database_path, sizeof(database_path)));
CHECK(write_artifacts(SOURCE_ID, SOURCE_ID, persistent_alias));
CHECK(trainlog_sync_run(TRAINLOG_SYNC_TRIGGER_TUI, false,
persistent_alias ? TRAINLOG_SYNC_BIDIRECTIONAL :
TRAINLOG_SYNC_ANDROID_TO_PC, &report) == TRAINLOG_STATUS_OK);
CHECK(report.success);
CHECK(database_state(database_path, persistent_alias, "back", "arms", 2U));
CHECK(successful_journal(case_root, report.sync_id));
if (persistent_alias) {
sqlite3 *raw = NULL;
sqlite3_stmt *statement = NULL;
CHECK(sqlite3_open(database_path, &raw) == SQLITE_OK);
CHECK(sqlite3_prepare_v2(raw,
"SELECT COUNT(*) FROM session_exercises se JOIN exercises e "
"ON e.id=se.exercise_row_id WHERE se.entry_id IN "
"('sxe_alias_historical_a','sxe_alias_historical_b') "
"AND e.exercise_id=?1", -1, &statement, NULL) == SQLITE_OK);
CHECK(sqlite3_bind_text(statement, 1, CANONICAL_ID, -1,
SQLITE_STATIC) == SQLITE_OK);
CHECK(sqlite3_step(statement) == SQLITE_ROW);
CHECK(sqlite3_column_int(statement, 0) == 2);
CHECK(sqlite3_finalize(statement) == SQLITE_OK);
CHECK(sqlite3_close(raw) == SQLITE_OK);
CHECK(canonical_alias_exports());
}
/* Replay exercises the same production argv path and sync baseline. */
CHECK(trainlog_sync_run(TRAINLOG_SYNC_TRIGGER_TUI, false,
persistent_alias ? TRAINLOG_SYNC_BIDIRECTIONAL :
TRAINLOG_SYNC_ANDROID_TO_PC, &report) == TRAINLOG_STATUS_OK);
CHECK(report.success);
CHECK(database_state(database_path, persistent_alias, "back", "arms", 2U));
CHECK(successful_journal(case_root, report.sync_id));
if (persistent_alias) {
CHECK(canonical_alias_exports());
remote_mobile_name = "trainlog-mobile-export-v2.json";
remote_mobile_version = 2U;
}
return true;
}
static bool run_unknown_without_proof_case(const char *case_root)
{
TrainlogSyncReport report;
char database_path[4096];
CHECK(prepare_database(case_root, false, true,
database_path, sizeof(database_path)));
CHECK(write_artifacts(CANONICAL_ID, SOURCE_ID, false));
CHECK(trainlog_sync_run(TRAINLOG_SYNC_TRIGGER_TUI, false,
TRAINLOG_SYNC_ANDROID_TO_PC, &report) ==
TRAINLOG_STATUS_DATABASE_ERROR);
CHECK(!report.success);
CHECK(strstr(report.error, "exercice inconnu") != NULL);
CHECK(database_state(database_path, false, "chest", NULL, 1U));
return true;
}
static bool run_v3_unknown_with_stale_v2_proof_case(const char *case_root)
{
TrainlogSyncReport report;
char database_path[4096];
FILE *stale;
/* This file deliberately survives from an earlier V2 run. A selected V3
* cycle must not discover it as reconciliation evidence for its companion. */
stale = fopen("/tmp/trainlog-mobile-export-v2.json", "wb");
CHECK(stale != NULL);
CHECK(fprintf(stale,
"{\"format\":\"trainlog-mobile-export\",\"version\":2,"
"\"generated_at\":\"2032-01-01T00:00:00+00:00\","
"\"exercises\":[{\"exercise_id\":\"%s\",\"name\":\"%s\","
"\"recording_mode\":\"sets\",\"tracking_mode\":\"reps\","
"\"data_fields\":0}],\"sessions\":[],\"body_observations\":[]}",
SOURCE_ID, EXERCISE_NAME) > 0);
CHECK(fclose(stale) == 0);
remote_mobile_name = "trainlog-mobile-export-v3.json";
remote_mobile_version = 3U;
CHECK(prepare_database(case_root, false, true,
database_path, sizeof(database_path)));
CHECK(write_artifacts(CANONICAL_ID, SOURCE_ID, false));
CHECK(trainlog_sync_run(TRAINLOG_SYNC_TRIGGER_TUI, false,
TRAINLOG_SYNC_ANDROID_TO_PC, &report) ==
TRAINLOG_STATUS_DATABASE_ERROR);
CHECK(!report.success);
CHECK(strstr(report.error, "exercice inconnu") != NULL);
CHECK(database_state(database_path, false, "chest", NULL, 1U));
CHECK(remove("/tmp/trainlog-mobile-export-v2.json") == 0);
remote_mobile_name = "trainlog-mobile-export-v2.json";
remote_mobile_version = 2U;
return true;
}
static bool run_all(void)
{
char temporary[] = "/tmp/trainlog-sync-body-zone-wiring-XXXXXX";
char case_a[4096];
char case_b[4096];
char case_c[4096];
char case_d[4096];
char *root = mkdtemp(temporary);
CHECK(root != NULL);
CHECK(snprintf(remote_root, sizeof(remote_root), "%s/remote", root) > 0);
CHECK(mkdir(remote_root, 0700) == 0);
CHECK(snprintf(case_a, sizeof(case_a), "%s/case-a", root) > 0);
CHECK(snprintf(case_b, sizeof(case_b), "%s/case-b", root) > 0);
CHECK(snprintf(case_c, sizeof(case_c), "%s/case-c", root) > 0);
CHECK(snprintf(case_d, sizeof(case_d), "%s/case-d", root) > 0);
CHECK(run_success_case(case_a, false));
CHECK(run_success_case(case_b, true));
CHECK(run_unknown_without_proof_case(case_c));
CHECK(run_v3_unknown_with_stale_v2_proof_case(case_d));
(void)puts("PASS production sync body-zone V2 proof wiring");
return true;
}
int main(void)
{
return run_all() ? EXIT_SUCCESS : EXIT_FAILURE;
}