1215 lines
46 KiB
C
1215 lines
46 KiB
C
/*
|
|
* Regression coverage for sync body zone wiring.
|
|
*
|
|
* Exercises production contracts without owning runtime behavior or persistent formats.
|
|
*/
|
|
#include <stdbool.h>
|
|
#include <fcntl.h>
|
|
#include <stdint.h>
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
#include <sys/file.h>
|
|
#include <sys/stat.h>
|
|
#include <sys/wait.h>
|
|
#include <unistd.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 const char *remote_tracking_mode = "reps";
|
|
static bool remote_profile_available = true;
|
|
static bool remote_alias_available = false;
|
|
static bool remote_request_available = false;
|
|
static bool remote_pc_catalog_available = false;
|
|
static const char *failed_send_name = NULL;
|
|
static size_t remote_delete_count = 0U;
|
|
static char rclone_log[4096];
|
|
static size_t mtp_probe_count = 0U;
|
|
|
|
static bool write_artifacts(const char *mobile_id, const char *zone_id,
|
|
bool historical_alias_data);
|
|
static bool prepare_database(const char *case_root, bool persistent_alias,
|
|
bool initial_zone, char *database_path,
|
|
size_t database_path_size);
|
|
static bool file_contains(const char *name, const char *needle, bool expected);
|
|
|
|
static bool install_fake_rclone(const char *root)
|
|
{
|
|
char bin[4096];
|
|
char executable[4096];
|
|
char path[8192];
|
|
const char *old_path = getenv("PATH");
|
|
FILE *file;
|
|
|
|
CHECK(snprintf(bin, sizeof(bin), "%s/bin", root) > 0);
|
|
CHECK(mkdir(bin, 0700) == 0);
|
|
CHECK(snprintf(executable, sizeof(executable), "%s/rclone", bin) > 0);
|
|
file = fopen(executable, "wb");
|
|
CHECK(file != NULL);
|
|
CHECK(fputs("#!/bin/sh\n"
|
|
"if test \"$1\" = lsf; then\n"
|
|
" test \"$2\" = 'TrainLog Gdrive:Trainlog/AI/inbox' || exit 65\n"
|
|
" case \"${TRAINLOG_TEST_AI_INBOUND_MODE:-drive_fail}\" in\n"
|
|
" invalid|archive_fail|imported|already) printf 'trainlog_ai_session_draft_v1.json\\n'; exit 0 ;;\n"
|
|
" marker_prefix) printf 'diagnostic AI_SESSION_DRAFT_INBOUND=IMPORTED\\n' >&2; exit 65 ;;\n"
|
|
" marker_suffix) printf 'AI_SESSION_DRAFT_INBOUND=IMPORTED diagnostic\\n' >&2; exit 65 ;;\n"
|
|
" none) exit 0 ;;\n"
|
|
" *) exit 65 ;;\n"
|
|
" esac\n"
|
|
"fi\n"
|
|
"test \"$1\" = copyto || exit 64\n"
|
|
"case \"$2\" in\n"
|
|
" 'TrainLog Gdrive:Trainlog/AI/inbox/trainlog_ai_session_draft_v1.json')\n"
|
|
" case \"${TRAINLOG_TEST_AI_INBOUND_MODE:-drive_fail}\" in\n"
|
|
" invalid) printf '{}\\n' > \"$3\"; exit 0 ;;\n"
|
|
" archive_fail|imported|already) cp \"$TRAINLOG_TEST_AI_SOURCE\" \"$3\"; exit 0 ;;\n"
|
|
" *) exit 65 ;;\n"
|
|
" esac ;;\n"
|
|
"esac\n"
|
|
"test -f \"$2\" || exit 65\n"
|
|
"case \"$3\" in\n"
|
|
" 'TrainLog Gdrive:Trainlog/AI/archive/'*)\n"
|
|
" test \"${TRAINLOG_TEST_AI_INBOUND_MODE:-drive_fail}\" != archive_fail || exit 9 ;;\n"
|
|
"esac\n"
|
|
"printf '%s\\n' \"$3\" >> \"$TRAINLOG_TEST_RCLONE_LOG\"\n",
|
|
file) >= 0);
|
|
CHECK(fclose(file) == 0);
|
|
CHECK(chmod(executable, 0700) == 0);
|
|
CHECK(snprintf(rclone_log, sizeof(rclone_log), "%s/rclone.log", root) > 0);
|
|
CHECK(setenv("TRAINLOG_TEST_RCLONE_LOG", rclone_log, 1) == 0);
|
|
CHECK(snprintf(path, sizeof(path), "%s:%s", bin,
|
|
old_path != NULL ? old_path : "") > 0);
|
|
CHECK(setenv("PATH", path, 1) == 0);
|
|
return true;
|
|
}
|
|
|
|
static size_t rclone_upload_count(void)
|
|
{
|
|
FILE *file = fopen(rclone_log, "rb");
|
|
size_t count = 0U;
|
|
int byte;
|
|
if (file == NULL) {
|
|
return 0U;
|
|
}
|
|
while ((byte = fgetc(file)) != EOF) {
|
|
if (byte == '\n') {
|
|
count += 1U;
|
|
}
|
|
}
|
|
CHECK(fclose(file) == 0);
|
|
return count;
|
|
}
|
|
|
|
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
|
|
)
|
|
{
|
|
mtp_probe_count += 1U;
|
|
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;
|
|
}
|
|
|
|
static bool run_lock_contention_case(const char *case_root)
|
|
{
|
|
TrainlogSyncReport report;
|
|
char trainlog_root[4096];
|
|
char lock_path[4096];
|
|
int ready[2];
|
|
int release[2];
|
|
pid_t child;
|
|
char signal_byte;
|
|
int status;
|
|
size_t probes_before = mtp_probe_count;
|
|
|
|
CHECK(mkdir(case_root, 0700) == 0);
|
|
CHECK(setenv("XDG_DATA_HOME", case_root, 1) == 0);
|
|
CHECK(snprintf(trainlog_root, sizeof(trainlog_root), "%s/trainlog",
|
|
case_root) > 0);
|
|
CHECK(mkdir(trainlog_root, 0700) == 0);
|
|
CHECK(snprintf(lock_path, sizeof(lock_path), "%s/sync.lock",
|
|
trainlog_root) > 0);
|
|
CHECK(pipe(ready) == 0);
|
|
CHECK(pipe(release) == 0);
|
|
child = fork();
|
|
CHECK(child >= 0);
|
|
if (child == 0) {
|
|
int descriptor;
|
|
(void)close(ready[0]);
|
|
(void)close(release[1]);
|
|
descriptor = open(lock_path, O_RDWR | O_CREAT | O_CLOEXEC, 0600);
|
|
if (descriptor < 0 || flock(descriptor, LOCK_EX) != 0 ||
|
|
write(ready[1], "L", 1U) != 1 ||
|
|
read(release[0], &signal_byte, 1U) != 1) {
|
|
_exit(EXIT_FAILURE);
|
|
}
|
|
(void)flock(descriptor, LOCK_UN);
|
|
(void)close(descriptor);
|
|
_exit(EXIT_SUCCESS);
|
|
}
|
|
(void)close(ready[1]);
|
|
(void)close(release[0]);
|
|
CHECK(read(ready[0], &signal_byte, 1U) == 1);
|
|
CHECK(trainlog_sync_run(TRAINLOG_SYNC_TRIGGER_ANDROID, true,
|
|
TRAINLOG_SYNC_BIDIRECTIONAL, &report) == TRAINLOG_STATUS_CONFLICT);
|
|
CHECK(!report.success);
|
|
CHECK(strstr(report.error, "déjà en cours") != NULL);
|
|
CHECK(mtp_probe_count == probes_before);
|
|
CHECK(write(release[1], "R", 1U) == 1);
|
|
CHECK(close(ready[0]) == 0);
|
|
CHECK(close(release[1]) == 0);
|
|
CHECK(waitpid(child, &status, 0) == child);
|
|
CHECK(WIFEXITED(status) && WEXITSTATUS(status) == EXIT_SUCCESS);
|
|
return true;
|
|
}
|
|
|
|
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 < 2U) {
|
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
|
}
|
|
set_entry(&output[0], 1U, UINT32_MAX, "Documents", true);
|
|
/* CONTRACT fixture: the historical exchange tree remains present but
|
|
* must never be selected for reads or publications after migration. */
|
|
set_entry(&output[1], 90U, UINT32_MAX, "Download", true);
|
|
*output_count = 2U;
|
|
return TRAINLOG_STATUS_OK;
|
|
}
|
|
if (parent_folder_id == 90U) {
|
|
fprintf(stderr, "legacy Download tree was accessed\n");
|
|
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
|
}
|
|
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) {
|
|
size_t count = 3U + (remote_profile_available ? 1U : 0U) +
|
|
(remote_alias_available ? 1U : 0U) +
|
|
(remote_request_available ? 1U : 0U) +
|
|
(remote_pc_catalog_available ? 1U : 0U);
|
|
size_t next = 3U;
|
|
if (capacity < count) {
|
|
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);
|
|
if (remote_profile_available) {
|
|
set_entry(&output[next], 13U, 2U,
|
|
"trainlog-exercise-profile-state-v1.json", false);
|
|
next += 1U;
|
|
}
|
|
if (remote_alias_available) {
|
|
set_entry(&output[next], 14U, 2U,
|
|
"trainlog-exercise-aliases-v1.json", false);
|
|
next += 1U;
|
|
}
|
|
if (remote_request_available) {
|
|
set_entry(&output[next], 15U, 2U,
|
|
"trainlog-sync-request-v1.json", false);
|
|
next += 1U;
|
|
}
|
|
if (remote_pc_catalog_available) {
|
|
set_entry(&output[next], 16U, 2U,
|
|
"trainlog-pc-catalog-v1.json", false);
|
|
}
|
|
*output_count = count;
|
|
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" :
|
|
item_id == 13U ? "trainlog-exercise-profile-state-v1.json" :
|
|
item_id == 14U ? "trainlog-exercise-aliases-v1.json" :
|
|
item_id == 15U ? "trainlog-sync-request-v1.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;
|
|
if (failed_send_name != NULL && remote_filename != NULL &&
|
|
strcmp(failed_send_name, remote_filename) == 0) {
|
|
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
|
}
|
|
{
|
|
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;
|
|
}
|
|
|
|
static bool write_request(const char *request_id)
|
|
{
|
|
char path[4096];
|
|
FILE *file;
|
|
CHECK(snprintf(path, sizeof(path), "%s/trainlog-sync-request-v1.json",
|
|
remote_root) > 0);
|
|
file = fopen(path, "wb");
|
|
CHECK(file != NULL);
|
|
CHECK(fprintf(file,
|
|
"{\"format\":\"trainlog-sync-request\",\"version\":1,"
|
|
"\"request_id\":\"%s\","
|
|
"\"requested_at\":\"2032-01-01T00:00:00+00:00\"}", request_id) > 0);
|
|
CHECK(fclose(file) == 0);
|
|
return true;
|
|
}
|
|
|
|
static bool run_request_receipt_case(const char *case_root)
|
|
{
|
|
static const char *const first_request =
|
|
"sr_10000000-0000-4000-8000-000000000001";
|
|
static const char *const second_request =
|
|
"sr_10000000-0000-4000-8000-000000000002";
|
|
TrainlogSyncReport report;
|
|
char database_path[4096];
|
|
char last_request_path[4096];
|
|
char last_request[128];
|
|
FILE *file;
|
|
size_t count;
|
|
|
|
CHECK(prepare_database(case_root, false, false,
|
|
database_path, sizeof(database_path)));
|
|
CHECK(write_artifacts(SOURCE_ID, SOURCE_ID, false));
|
|
CHECK(write_request(first_request));
|
|
remote_request_available = true;
|
|
CHECK(trainlog_sync_run(TRAINLOG_SYNC_TRIGGER_ANDROID, true,
|
|
TRAINLOG_SYNC_BIDIRECTIONAL, &report) == TRAINLOG_STATUS_OK);
|
|
CHECK(report.success && report.request_present);
|
|
CHECK(strcmp(report.request_id, first_request) == 0);
|
|
CHECK(file_contains("trainlog-sync-receipt-v1.json", first_request, true));
|
|
CHECK(snprintf(last_request_path, sizeof(last_request_path),
|
|
"%s/trainlog/sync_last_request.txt", case_root) > 0);
|
|
file = fopen(last_request_path, "rb");
|
|
CHECK(file != NULL);
|
|
count = fread(last_request, 1U, sizeof(last_request) - 1U, file);
|
|
CHECK(ferror(file) == 0);
|
|
CHECK(fclose(file) == 0);
|
|
last_request[count] = '\0';
|
|
CHECK(strstr(last_request, first_request) != NULL);
|
|
|
|
CHECK(trainlog_sync_run(TRAINLOG_SYNC_TRIGGER_ANDROID, true,
|
|
TRAINLOG_SYNC_BIDIRECTIONAL, &report) == TRAINLOG_STATUS_NOT_FOUND);
|
|
CHECK(report.request_present);
|
|
CHECK(strcmp(report.request_id, first_request) == 0);
|
|
|
|
CHECK(write_request(second_request));
|
|
failed_send_name = "trainlog-sync-receipt-v1.json";
|
|
CHECK(trainlog_sync_run(TRAINLOG_SYNC_TRIGGER_ANDROID, true,
|
|
TRAINLOG_SYNC_BIDIRECTIONAL, &report) == TRAINLOG_STATUS_SYSTEM_ERROR);
|
|
CHECK(!report.success && report.request_present);
|
|
CHECK(strcmp(report.request_id, second_request) == 0);
|
|
failed_send_name = NULL;
|
|
file = fopen(last_request_path, "rb");
|
|
CHECK(file != NULL);
|
|
count = fread(last_request, 1U, sizeof(last_request) - 1U, file);
|
|
CHECK(ferror(file) == 0);
|
|
CHECK(fclose(file) == 0);
|
|
last_request[count] = '\0';
|
|
CHECK(strstr(last_request, first_request) != NULL);
|
|
CHECK(strstr(last_request, second_request) == NULL);
|
|
remote_request_available = false;
|
|
return true;
|
|
}
|
|
|
|
TrainlogStatus trainlog_mtp_delete_object(
|
|
unsigned int bus_number,
|
|
unsigned int device_number,
|
|
uint32_t item_id
|
|
)
|
|
{
|
|
char path[4096];
|
|
int written;
|
|
(void)bus_number;
|
|
(void)device_number;
|
|
if (item_id == 16U) {
|
|
written = snprintf(path, sizeof(path), "%s/trainlog-pc-catalog-v1.json",
|
|
remote_root);
|
|
if (written < 0 || (size_t)written >= sizeof(path) || remove(path) != 0) {
|
|
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
|
}
|
|
remote_pc_catalog_available = false;
|
|
remote_delete_count += 1U;
|
|
}
|
|
return TRAINLOG_STATUS_OK;
|
|
}
|
|
|
|
static bool run_publish_delete_failure_case(const char *case_root)
|
|
{
|
|
TrainlogSyncReport report;
|
|
char database_path[4096];
|
|
char catalog_path[4096];
|
|
FILE *file;
|
|
size_t deletes_before = remote_delete_count;
|
|
|
|
CHECK(prepare_database(case_root, false, false,
|
|
database_path, sizeof(database_path)));
|
|
CHECK(snprintf(catalog_path, sizeof(catalog_path),
|
|
"%s/trainlog-pc-catalog-v1.json", remote_root) > 0);
|
|
file = fopen(catalog_path, "wb");
|
|
CHECK(file != NULL);
|
|
CHECK(fputs("old remote catalog", file) >= 0);
|
|
CHECK(fclose(file) == 0);
|
|
remote_pc_catalog_available = true;
|
|
failed_send_name = "trainlog-pc-catalog-v1.json";
|
|
CHECK(trainlog_sync_run(TRAINLOG_SYNC_TRIGGER_TUI, false,
|
|
TRAINLOG_SYNC_PC_TO_ANDROID, &report) == TRAINLOG_STATUS_SYSTEM_ERROR);
|
|
CHECK(!report.success);
|
|
CHECK(strstr(report.error, "publication MTP du catalogue échouée") != NULL);
|
|
CHECK(remote_delete_count == deletes_before + 1U);
|
|
CHECK(access(catalog_path, F_OK) != 0);
|
|
failed_send_name = NULL;
|
|
remote_pc_catalog_available = false;
|
|
return true;
|
|
}
|
|
|
|
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\":\"%s\","
|
|
"\"data_fields\":0}],\"sessions\":[],\"body_observations\":[]}",
|
|
remote_mobile_version, mobile_id, EXERCISE_NAME,
|
|
remote_tracking_mode) > 0);
|
|
}
|
|
CHECK(fclose(file) == 0);
|
|
|
|
written = snprintf(path, sizeof(path),
|
|
"%s/trainlog-exercise-aliases-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-aliases\",\"version\":1,"
|
|
"\"aliases\":[{\"source_exercise_id\":\"%s\","
|
|
"\"canonical_exercise_id\":\"%s\"}]}",
|
|
SOURCE_ID, CANONICAL_ID) > 0);
|
|
CHECK(fclose(file) == 0);
|
|
|
|
written = snprintf(path, sizeof(path),
|
|
"%s/trainlog-exercise-profile-state-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-profile-state\",\"version\":1,"
|
|
"\"generated_at\":\"2032-01-01T00:00:00+00:00\",\"exercises\":[{"
|
|
"\"exercise_id\":\"%s\",\"recording_mode\":\"sets\",\"tracking_mode\":\"reps\","
|
|
"\"data_fields\":0,\"load_semantics\":null,\"machine_variant\":null,"
|
|
"\"machine_provenance\":null,\"scientific_profile_id\":null,"
|
|
"\"science_state\":\"unresolved\",\"legacy_equipment_id\":null,"
|
|
"\"revision_id\":\"pr_legacy_v1\",\"parent_revision_id\":null,\"legacy_seed\":true,"
|
|
"\"history\":[{\"revision_id\":\"pr_legacy_v1\",\"parent_revision_id\":null,"
|
|
"\"recording_mode\":\"sets\",\"tracking_mode\":\"reps\",\"data_fields\":0,\"legacy_seed\":true}]}]}",
|
|
CANONICAL_ID) > 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 add_live_source_exercise(const char *database_path)
|
|
{
|
|
TrainlogDatabase *database = NULL;
|
|
CHECK(trainlog_database_open(database_path, &database) == TRAINLOG_STATUS_OK);
|
|
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);
|
|
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) == 6);
|
|
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 history_contains(const char *case_root, const char *needle)
|
|
{
|
|
char path[4096];
|
|
char content[65536];
|
|
FILE *file;
|
|
size_t count;
|
|
CHECK(snprintf(path, sizeof(path), "%s/trainlog/sync_history.log",
|
|
case_root) > 0);
|
|
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);
|
|
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));
|
|
{
|
|
TrainlogSyncTrigger trigger = persistent_alias
|
|
? TRAINLOG_SYNC_TRIGGER_ANDROID : TRAINLOG_SYNC_TRIGGER_TUI;
|
|
TrainlogStatus status = trainlog_sync_run(trigger, false,
|
|
persistent_alias ? TRAINLOG_SYNC_BIDIRECTIONAL :
|
|
TRAINLOG_SYNC_ANDROID_TO_PC, &report);
|
|
if (status != TRAINLOG_STATUS_OK) {
|
|
(void)fprintf(stderr, "sync failure: %s\n", report.error);
|
|
}
|
|
CHECK(status == TRAINLOG_STATUS_OK);
|
|
}
|
|
CHECK(report.success);
|
|
CHECK(strstr(report.summary, "AI_INBOUND=DRIVE_FAIL") != NULL);
|
|
CHECK(strstr(report.summary,
|
|
"SYNC=PASS AI_EXPORT=PASS GDRIVE_UPLOAD=PASS") != NULL);
|
|
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(persistent_alias ? TRAINLOG_SYNC_TRIGGER_ANDROID :
|
|
TRAINLOG_SYNC_TRIGGER_TUI, false,
|
|
persistent_alias ? TRAINLOG_SYNC_BIDIRECTIONAL :
|
|
TRAINLOG_SYNC_ANDROID_TO_PC, &report) == TRAINLOG_STATUS_OK);
|
|
CHECK(report.success);
|
|
CHECK(strstr(report.summary,
|
|
"SYNC=PASS AI_EXPORT=PASS GDRIVE_UPLOAD=PASS") != NULL);
|
|
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_missing_profile_companion_case(const char *case_root)
|
|
{
|
|
TrainlogSyncReport report;
|
|
char database_path[4096];
|
|
/* A stale local profile file is deliberately left by earlier cases. The
|
|
* selected MTP generation omits it, so neither causal pass may consume it. */
|
|
remote_mobile_name = "trainlog-mobile-export-v3.json";
|
|
remote_mobile_version = 3U;
|
|
remote_tracking_mode = "duration";
|
|
remote_profile_available = false;
|
|
CHECK(prepare_database(case_root, false, false,
|
|
database_path, sizeof(database_path)));
|
|
CHECK(write_artifacts(SOURCE_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, "PROFILE_STATE_COMPANION_MISSING") != NULL);
|
|
CHECK(strstr(report.error, "profil incompatible entre identités") != NULL);
|
|
remote_profile_available = true;
|
|
remote_tracking_mode = "reps";
|
|
remote_mobile_name = "trainlog-mobile-export-v2.json";
|
|
remote_mobile_version = 2U;
|
|
return true;
|
|
}
|
|
|
|
static bool run_incoming_alias_case(const char *case_root)
|
|
{
|
|
TrainlogSyncReport report;
|
|
char database_path[4096];
|
|
remote_alias_available = true;
|
|
remote_mobile_name = "trainlog-mobile-export-v3.json";
|
|
remote_mobile_version = 3U;
|
|
CHECK(prepare_database(case_root, false, false,
|
|
database_path, sizeof(database_path)));
|
|
CHECK(add_live_source_exercise(database_path));
|
|
CHECK(write_artifacts(SOURCE_ID, SOURCE_ID, true));
|
|
CHECK(trainlog_sync_run(TRAINLOG_SYNC_TRIGGER_ANDROID, false,
|
|
TRAINLOG_SYNC_ANDROID_TO_PC, &report) == TRAINLOG_STATUS_OK);
|
|
CHECK(report.success);
|
|
CHECK(database_state(database_path, true, "back", "arms", 2U));
|
|
CHECK(successful_journal(case_root, report.sync_id));
|
|
remote_alias_available = false;
|
|
remote_mobile_name = "trainlog-mobile-export-v2.json";
|
|
remote_mobile_version = 2U;
|
|
return true;
|
|
}
|
|
|
|
static bool write_fake_alias_tool(const char *directory, const char *source)
|
|
{
|
|
char path[4096];
|
|
FILE *file;
|
|
CHECK(snprintf(path, sizeof(path), "%s/import_exercise_aliases.py",
|
|
directory) > 0);
|
|
file = fopen(path, "wb");
|
|
CHECK(file != NULL);
|
|
CHECK(fputs(source, file) >= 0);
|
|
CHECK(fclose(file) == 0);
|
|
return true;
|
|
}
|
|
|
|
static bool run_alias_diagnostic_case(
|
|
const char *case_root,
|
|
const char *tools_directory,
|
|
const char *script,
|
|
const char *expected
|
|
)
|
|
{
|
|
static const char *const result_path =
|
|
"/tmp/trainlog-exercise-aliases-result.txt";
|
|
TrainlogSyncReport report;
|
|
char database_path[4096];
|
|
char result[256];
|
|
FILE *file;
|
|
size_t count;
|
|
|
|
CHECK(prepare_database(case_root, false, false,
|
|
database_path, sizeof(database_path)));
|
|
CHECK(write_artifacts(CANONICAL_ID, CANONICAL_ID, false));
|
|
if (script != NULL) {
|
|
CHECK(write_fake_alias_tool(tools_directory, script));
|
|
}
|
|
file = fopen(result_path, "wb");
|
|
CHECK(file != NULL);
|
|
CHECK(fputs("EXERCISE_ALIAS_EXPORT=PASS aliases=99\n", file) >= 0);
|
|
CHECK(fclose(file) == 0);
|
|
CHECK(setenv("TRAINLOG_TOOLS_DIR", tools_directory, 1) == 0);
|
|
remote_alias_available = true;
|
|
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, expected) != NULL);
|
|
CHECK(history_contains(case_root, expected));
|
|
file = fopen(result_path, "rb");
|
|
CHECK(file != NULL);
|
|
count = fread(result, 1U, sizeof(result) - 1U, file);
|
|
CHECK(ferror(file) == 0);
|
|
CHECK(fclose(file) == 0);
|
|
result[count] = '\0';
|
|
CHECK(strstr(result, "EXERCISE_ALIAS_EXPORT=PASS") == NULL);
|
|
CHECK(unsetenv("TRAINLOG_TOOLS_DIR") == 0);
|
|
remote_alias_available = false;
|
|
return true;
|
|
}
|
|
|
|
static bool run_ai_inbound_diagnostic_case(
|
|
const char *case_root,
|
|
const char *mode,
|
|
const char *expected,
|
|
bool repeat
|
|
)
|
|
{
|
|
TrainlogSyncReport report;
|
|
char database_path[4096];
|
|
char source_path[4096];
|
|
FILE *file;
|
|
|
|
CHECK(prepare_database(case_root, false, false,
|
|
database_path, sizeof(database_path)));
|
|
CHECK(write_artifacts(SOURCE_ID, SOURCE_ID, false));
|
|
CHECK(snprintf(source_path, sizeof(source_path), "%s/ai-source.json",
|
|
case_root) > 0);
|
|
file = fopen(source_path, "wb");
|
|
CHECK(file != NULL);
|
|
CHECK(fprintf(file,
|
|
"{\"format\":\"TRAINLOG_AI_SESSION_DRAFT\",\"version\":1,\"draft\":{"
|
|
"\"draft_id\":\"aid_12345678-1234-4abc-8abc-123456789abc\","
|
|
"\"created_at\":\"2032-01-01T02:00:00Z\",\"planned_for\":null,"
|
|
"\"session_type\":\"training\",\"title\":null,\"notes\":null,"
|
|
"\"entries\":[{\"position\":0,\"exercise_id\":\"%s\","
|
|
"\"target_sets\":3,\"target_reps\":8,"
|
|
"\"target_duration_seconds\":null,\"target_weight_kg\":null,"
|
|
"\"rest_seconds\":60}]}}", CANONICAL_ID) > 0);
|
|
CHECK(fclose(file) == 0);
|
|
CHECK(setenv("TRAINLOG_TEST_AI_SOURCE", source_path, 1) == 0);
|
|
CHECK(setenv("TRAINLOG_TEST_AI_INBOUND_MODE", mode, 1) == 0);
|
|
{
|
|
TrainlogStatus status = trainlog_sync_run(TRAINLOG_SYNC_TRIGGER_TUI,
|
|
false, TRAINLOG_SYNC_ANDROID_TO_PC, &report);
|
|
if (status != TRAINLOG_STATUS_OK)
|
|
(void)fprintf(stderr, "AI diagnostic sync failure: %s\n", report.error);
|
|
CHECK(status == TRAINLOG_STATUS_OK);
|
|
}
|
|
CHECK(report.success);
|
|
CHECK(strstr(report.summary, expected) != NULL);
|
|
CHECK(history_contains(case_root, expected));
|
|
if (repeat) {
|
|
CHECK(trainlog_sync_run(TRAINLOG_SYNC_TRIGGER_TUI, false,
|
|
TRAINLOG_SYNC_ANDROID_TO_PC, &report) == TRAINLOG_STATUS_OK);
|
|
CHECK(report.success);
|
|
CHECK(strstr(report.summary, "AI_INBOUND=ALREADY_IMPORTED") != NULL);
|
|
CHECK(history_contains(case_root, "AI_INBOUND=ALREADY_IMPORTED"));
|
|
}
|
|
CHECK(unsetenv("TRAINLOG_TEST_AI_INBOUND_MODE") == 0);
|
|
CHECK(unsetenv("TRAINLOG_TEST_AI_SOURCE") == 0);
|
|
return true;
|
|
}
|
|
|
|
static bool run_all(void)
|
|
{
|
|
char temporary[4096];
|
|
const char *temporary_root = getenv("TMPDIR");
|
|
char case_a[4096];
|
|
char case_b[4096];
|
|
char case_c[4096];
|
|
char case_d[4096];
|
|
char case_e[4096];
|
|
char case_f[4096];
|
|
char case_g[4096];
|
|
char case_h[4096];
|
|
char case_i[4096];
|
|
char case_j[4096];
|
|
char case_k[4096];
|
|
char case_l[4096];
|
|
char case_m[4096];
|
|
char case_n[4096];
|
|
char case_o[4096];
|
|
char case_p[4096];
|
|
char case_q[4096];
|
|
char case_r[4096];
|
|
char fake_tools[4096];
|
|
char *root;
|
|
int temporary_written;
|
|
|
|
if (temporary_root == NULL || temporary_root[0] == '\0') temporary_root = "/tmp";
|
|
temporary_written = snprintf(temporary, sizeof(temporary),
|
|
"%s/trainlog-sync-body-zone-wiring-XXXXXX", temporary_root);
|
|
CHECK(temporary_written > 0 && (size_t)temporary_written < sizeof(temporary));
|
|
root = mkdtemp(temporary);
|
|
|
|
CHECK(root != NULL);
|
|
CHECK(install_fake_rclone(root));
|
|
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(snprintf(case_e, sizeof(case_e), "%s/case-e", root) > 0);
|
|
CHECK(snprintf(case_f, sizeof(case_f), "%s/case-f", root) > 0);
|
|
CHECK(snprintf(case_g, sizeof(case_g), "%s/case-g", root) > 0);
|
|
CHECK(snprintf(case_h, sizeof(case_h), "%s/case-h", root) > 0);
|
|
CHECK(snprintf(case_i, sizeof(case_i), "%s/case-i", root) > 0);
|
|
CHECK(snprintf(case_j, sizeof(case_j), "%s/case-j", root) > 0);
|
|
CHECK(snprintf(case_k, sizeof(case_k), "%s/case-k", root) > 0);
|
|
CHECK(snprintf(case_l, sizeof(case_l), "%s/case-l", root) > 0);
|
|
CHECK(snprintf(case_m, sizeof(case_m), "%s/case-m", root) > 0);
|
|
CHECK(snprintf(case_n, sizeof(case_n), "%s/case-n", root) > 0);
|
|
CHECK(snprintf(case_o, sizeof(case_o), "%s/case-o", root) > 0);
|
|
CHECK(snprintf(case_p, sizeof(case_p), "%s/case-p", root) > 0);
|
|
CHECK(snprintf(case_q, sizeof(case_q), "%s/case-q", root) > 0);
|
|
CHECK(snprintf(case_r, sizeof(case_r), "%s/case-r", root) > 0);
|
|
CHECK(snprintf(fake_tools, sizeof(fake_tools), "%s/fake-tools", root) > 0);
|
|
CHECK(mkdir(fake_tools, 0700) == 0);
|
|
|
|
CHECK(run_success_case(case_a, false));
|
|
CHECK(run_success_case(case_b, true));
|
|
CHECK(rclone_upload_count() == 4U);
|
|
CHECK(run_ai_inbound_diagnostic_case(case_j, "invalid",
|
|
"AI_INBOUND=REJECTED", false));
|
|
CHECK(run_ai_inbound_diagnostic_case(case_k, "archive_fail",
|
|
"AI_INBOUND=ARCHIVE_FAIL", false));
|
|
CHECK(run_ai_inbound_diagnostic_case(case_l, "none",
|
|
"AI_INBOUND=NONE", false));
|
|
CHECK(run_ai_inbound_diagnostic_case(case_m, "already",
|
|
"AI_INBOUND=IMPORTED", true));
|
|
/* Marker-like diagnostic text is not a result line; the final exact
|
|
* helper marker, including a later failure, remains authoritative. */
|
|
CHECK(run_ai_inbound_diagnostic_case(case_n, "marker_prefix",
|
|
"AI_INBOUND=DRIVE_FAIL", false));
|
|
CHECK(run_ai_inbound_diagnostic_case(case_o, "marker_suffix",
|
|
"AI_INBOUND=DRIVE_FAIL", false));
|
|
CHECK(run_unknown_without_proof_case(case_c));
|
|
CHECK(run_v3_unknown_with_stale_v2_proof_case(case_d));
|
|
CHECK(run_missing_profile_companion_case(case_e));
|
|
CHECK(run_incoming_alias_case(case_f));
|
|
CHECK(run_alias_diagnostic_case(case_g, fake_tools, NULL,
|
|
"outil Python introuvable"));
|
|
CHECK(run_alias_diagnostic_case(case_h, fake_tools,
|
|
"import argparse\nargparse.ArgumentParser().parse_args()\n",
|
|
"unrecognized arguments"));
|
|
CHECK(run_alias_diagnostic_case(case_i, fake_tools,
|
|
"raise RuntimeError('alias importer synthetic exception')\n",
|
|
"RuntimeError: alias importer synthetic exception"));
|
|
/* INVARIANT: none of the three failed synchronization paths may start a
|
|
* post-sync Drive upload, even when an older export remains on disk. */
|
|
CHECK(rclone_upload_count() == 14U);
|
|
CHECK(run_lock_contention_case(case_p));
|
|
CHECK(run_request_receipt_case(case_q));
|
|
CHECK(run_publish_delete_failure_case(case_r));
|
|
(void)puts("PASS production sync body-zone V2 proof wiring");
|
|
return true;
|
|
}
|
|
|
|
int main(void)
|
|
{
|
|
return run_all() ? EXIT_SUCCESS : EXIT_FAILURE;
|
|
}
|