1127 lines
53 KiB
C
1127 lines
53 KiB
C
/**
|
|
* @file test_tui_workflows.c
|
|
* @brief Scripted regressions through the production TUI workflow functions.
|
|
*/
|
|
|
|
#include <errno.h>
|
|
#include <stdarg.h>
|
|
|
|
/* The workflows remain file-private in production. Including their translation
|
|
* unit lets this test drive the exact code paths with the terminal port mocked,
|
|
* without adding a public test API to Trainlog. */
|
|
#include "../src/tui.c"
|
|
|
|
struct TrainlogTerminal {
|
|
int events[128];
|
|
size_t event_count;
|
|
size_t event_index;
|
|
char output[32768];
|
|
size_t output_used;
|
|
int rows;
|
|
int columns;
|
|
bool coordinate_overflow;
|
|
bool text_overflow;
|
|
size_t surface_draw_count;
|
|
};
|
|
|
|
struct TrainlogPanel { int unused; };
|
|
struct TrainlogSurface { TrainlogTerminal *terminal; };
|
|
|
|
#define CHECK(condition) do { if (!(condition)) { \
|
|
(void)fprintf(stderr, "CHECK failed at %s:%d: %s\n", \
|
|
__FILE__, __LINE__, #condition); return false; } } while (0)
|
|
|
|
TrainlogTerminal *trainlog_terminal_create(void) { return NULL; }
|
|
void trainlog_terminal_destroy(TrainlogTerminal *terminal) { (void)terminal; }
|
|
int trainlog_terminal_rows(const TrainlogTerminal *terminal) { return terminal->rows; }
|
|
int trainlog_terminal_columns(const TrainlogTerminal *terminal) { return terminal->columns; }
|
|
void trainlog_terminal_erase(TrainlogTerminal *terminal) { (void)terminal; }
|
|
void trainlog_terminal_render(TrainlogTerminal *terminal) { (void)terminal; }
|
|
void trainlog_terminal_style_on(TrainlogTerminal *terminal, TrainlogTextStyle style) { (void)terminal; (void)style; }
|
|
void trainlog_terminal_style_off(TrainlogTerminal *terminal, TrainlogTextStyle style) { (void)terminal; (void)style; }
|
|
void trainlog_terminal_printf(TrainlogTerminal *terminal, int row, int column,
|
|
const char *format, ...)
|
|
{
|
|
va_list arguments;
|
|
int written;
|
|
if (row < 0 || row >= terminal->rows || column < 0 || column >= terminal->columns)
|
|
terminal->coordinate_overflow = true;
|
|
if (terminal->output_used >= sizeof(terminal->output)) return;
|
|
va_start(arguments, format);
|
|
written = vsnprintf(terminal->output + terminal->output_used,
|
|
sizeof(terminal->output) - terminal->output_used, format, arguments);
|
|
va_end(arguments);
|
|
if (written > 0 && (size_t)written < sizeof(terminal->output) - terminal->output_used) {
|
|
const char *text = terminal->output + terminal->output_used;
|
|
const char *at = text;
|
|
int cells = 0;
|
|
while (*at != '\0') {
|
|
utf8proc_int32_t codepoint;
|
|
utf8proc_ssize_t bytes = utf8proc_iterate((const utf8proc_uint8_t *)at,
|
|
-1, &codepoint);
|
|
int codepoint_cells;
|
|
if (bytes <= 0) { bytes = 1; codepoint = (unsigned char)*at; }
|
|
codepoint_cells = utf8proc_charwidth(codepoint);
|
|
cells += codepoint_cells < 0 ? 1 : codepoint_cells;
|
|
at += bytes;
|
|
}
|
|
if (column + cells >= terminal->columns) terminal->text_overflow = true;
|
|
terminal->output_used += (size_t)written;
|
|
terminal->output[terminal->output_used++] = '\n';
|
|
terminal->output[terminal->output_used] = '\0';
|
|
}
|
|
}
|
|
void trainlog_terminal_putn(TrainlogTerminal *terminal, const char *text, size_t length) { (void)terminal; (void)text; (void)length; }
|
|
void trainlog_terminal_move(TrainlogTerminal *terminal, int row, int column) { (void)terminal; (void)row; (void)column; }
|
|
void trainlog_terminal_cursor_yx(const TrainlogTerminal *terminal, int *row, int *column) { (void)terminal; if (row) *row = 0; if (column) *column = 40; }
|
|
void trainlog_terminal_clear_to_end(TrainlogTerminal *terminal) { (void)terminal; }
|
|
void trainlog_terminal_cursor_visible(TrainlogTerminal *terminal, bool visible) { (void)terminal; (void)visible; }
|
|
void trainlog_terminal_draw(TrainlogTerminal *terminal, int row, int column, uint32_t codepoint) { (void)terminal; (void)row; (void)column; (void)codepoint; }
|
|
void trainlog_terminal_box(TrainlogTerminal *terminal, int top, int left, int bottom, int right) { (void)terminal; (void)top; (void)left; (void)bottom; (void)right; }
|
|
int trainlog_terminal_get_key(TrainlogTerminal *terminal)
|
|
{
|
|
int key = terminal->event_index < terminal->event_count
|
|
? terminal->events[terminal->event_index++] : TRAINLOG_KEY_NONE;
|
|
if (key == TRAINLOG_KEY_RESIZE) {
|
|
terminal->rows = 20;
|
|
terminal->columns = 72;
|
|
}
|
|
return key;
|
|
}
|
|
bool trainlog_terminal_read_unicode(TrainlogTerminal *terminal, int *codepoint, char utf8[5])
|
|
{
|
|
int value = trainlog_terminal_get_key(terminal);
|
|
if (value == TRAINLOG_KEY_NONE) return false;
|
|
*codepoint = value;
|
|
utf8[0] = value >= 0 && value < 128 ? (char)value : '\0';
|
|
utf8[1] = '\0';
|
|
return true;
|
|
}
|
|
bool trainlog_terminal_push_key(TrainlogTerminal *terminal, int key) { (void)terminal; (void)key; return false; }
|
|
bool trainlog_terminal_refresh_geometry(TrainlogTerminal *terminal)
|
|
{ (void)terminal; return true; }
|
|
TrainlogPanel *tui_panel_create(TrainlogTerminal *terminal, int height, int width, int top, int left) { (void)terminal; (void)height; (void)width; (void)top; (void)left; return NULL; }
|
|
void tui_panel_destroy(TrainlogPanel *panel) { (void)panel; }
|
|
void tui_panel_box(TrainlogPanel *panel) { (void)panel; }
|
|
void tui_panel_style_on(TrainlogPanel *panel, TrainlogTextStyle style) { (void)panel; (void)style; }
|
|
void tui_panel_style_off(TrainlogPanel *panel, TrainlogTextStyle style) { (void)panel; (void)style; }
|
|
void tui_panel_print(TrainlogPanel *panel, int row, int column, const char *format, ...) { (void)panel; (void)row; (void)column; (void)format; }
|
|
void tui_panel_commit(TrainlogPanel *panel) { (void)panel; }
|
|
TrainlogSurface *trainlog_surface_create(TrainlogTerminal *terminal,
|
|
const char *name, int top, int left, int height, int width)
|
|
{
|
|
static TrainlogSurface surface;
|
|
(void)name; (void)top; (void)left; (void)height; (void)width;
|
|
surface.terminal = terminal; return &surface;
|
|
}
|
|
bool trainlog_surface_set_rect(TrainlogSurface *surface, int top, int left,
|
|
int height, int width)
|
|
{
|
|
(void)surface; (void)top; (void)left; (void)height; (void)width; return true;
|
|
}
|
|
void trainlog_surface_destroy(TrainlogSurface *surface) { (void)surface; }
|
|
void trainlog_surface_erase(TrainlogSurface *surface) { (void)surface; }
|
|
void trainlog_surface_set_role(TrainlogSurface *surface,
|
|
TrainlogColorRole foreground, unsigned background_rgb, TrainlogTextStyle style)
|
|
{
|
|
(void)surface; (void)foreground; (void)background_rgb; (void)style;
|
|
}
|
|
void trainlog_surface_printf(TrainlogSurface *surface, int row, int column,
|
|
const char *format, ...)
|
|
{
|
|
va_list arguments;
|
|
if (surface == NULL || surface->terminal == NULL) return;
|
|
va_start(arguments, format);
|
|
if (surface->terminal->output_used < sizeof(surface->terminal->output)) {
|
|
int written = vsnprintf(surface->terminal->output + surface->terminal->output_used,
|
|
sizeof(surface->terminal->output) - surface->terminal->output_used,
|
|
format, arguments);
|
|
if (written > 0 && (size_t)written < sizeof(surface->terminal->output) -
|
|
surface->terminal->output_used) surface->terminal->output_used += (size_t)written;
|
|
}
|
|
va_end(arguments); (void)row; (void)column;
|
|
}
|
|
void trainlog_surface_draw(TrainlogSurface *surface, int row, int column,
|
|
uint32_t codepoint)
|
|
{
|
|
if (surface != NULL && surface->terminal != NULL)
|
|
++surface->terminal->surface_draw_count;
|
|
(void)row; (void)column; (void)codepoint;
|
|
}
|
|
void trainlog_surface_move_top(TrainlogSurface *surface) { (void)surface; }
|
|
|
|
static bool test_knowledge_scrolls_long_lists_at_minimum_terminal(void)
|
|
{
|
|
TrainlogAppContext app;
|
|
TrainlogTerminal terminal;
|
|
TrainlogSurface surface;
|
|
size_t page;
|
|
(void)memset(&app, 0, sizeof(app));
|
|
(void)snprintf(app.exercise_detail.exercise_id,
|
|
sizeof(app.exercise_detail.exercise_id), "%s",
|
|
"ex_a72fa713-4b0e-431d-95e2-42d95beb77b1");
|
|
(void)snprintf(app.exercise_detail.name, sizeof(app.exercise_detail.name),
|
|
"%s", "Lat pull");
|
|
(void)memset(&terminal, 0, sizeof(terminal));
|
|
terminal.rows = 20;
|
|
terminal.columns = 72;
|
|
surface.terminal = &terminal;
|
|
app.terminal = &terminal;
|
|
app.content = &surface;
|
|
app.layout.usable = true;
|
|
app.layout.content.height = 16;
|
|
app.layout.content.width = 72;
|
|
app.focus = TRAINLOG_FOCUS_CONTENT;
|
|
app.navigation.current.route = TRAINLOG_ROUTE_EXERCISE_KNOWLEDGE;
|
|
for (page = 0U; page < 5U; ++page) {
|
|
app_shell_render_content(&app);
|
|
app_shell_dispatch(&app, TRAINLOG_KEY_PAGE_DOWN);
|
|
}
|
|
CHECK(!terminal.coordinate_overflow);
|
|
CHECK(!terminal.text_overflow);
|
|
CHECK(strstr(terminal.output, "Supra-épineux") != NULL);
|
|
CHECK(strstr(terminal.output, "Sources :") != NULL);
|
|
return true;
|
|
}
|
|
|
|
static bool seed_generator_leg_press(TrainlogDatabase *database)
|
|
{
|
|
return trainlog_database_insert_exercise_profiled(database,
|
|
"ex_b432623f-bfe9-4daf-a653-60ec7fdffbde", "Leg press", "leg press",
|
|
TRAINLOG_TRACKING_REPS, TRAINLOG_RECORDING_SETS, 0U) == TRAINLOG_STATUS_OK;
|
|
}
|
|
|
|
static bool test_shell_session_navigation_preserves_run_draft(void)
|
|
{
|
|
TrainlogDatabase *database = NULL;
|
|
TrainlogAppContext app;
|
|
size_t sessions = 99U;
|
|
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
|
|
(void)memset(&app, 0, sizeof(app));
|
|
app.database = database;
|
|
trainlog_navigation_init(&app.navigation);
|
|
app.navigation.current.route = TRAINLOG_ROUTE_SESSION_CURRENT;
|
|
app.session.has_draft = true;
|
|
app.session.dirty = true;
|
|
app.session.draft_count = 1U;
|
|
(void)snprintf(app.session.drafts[0].input.exercise_id,
|
|
sizeof(app.session.drafts[0].input.exercise_id), "%s",
|
|
"ex_11111111-1111-4111-8111-111111111111");
|
|
app_shell_open_route(&app, TRAINLOG_ROUTE_EXERCISES);
|
|
CHECK(app.navigation.current.route == TRAINLOG_ROUTE_EXERCISES);
|
|
CHECK(app.session.has_draft && app.session.draft_count == 1U);
|
|
CHECK(strcmp(app.session.drafts[0].input.exercise_id,
|
|
"ex_11111111-1111-4111-8111-111111111111") == 0);
|
|
CHECK(trainlog_database_session_count(database, &sessions) == TRAINLOG_STATUS_OK);
|
|
CHECK(sessions == 0U);
|
|
trainlog_database_close(database);
|
|
return true;
|
|
}
|
|
|
|
static bool test_shell_generator_keep_and_accept_has_no_actuals(void)
|
|
{
|
|
TrainlogDatabase *database = NULL;
|
|
TrainlogAppContext app;
|
|
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
|
|
CHECK(seed_generator_leg_press(database));
|
|
(void)memset(&app, 0, sizeof(app));
|
|
app.database = database;
|
|
trainlog_navigation_init(&app.navigation);
|
|
app.navigation.current.route = TRAINLOG_ROUTE_SESSION_GENERATOR;
|
|
app.session.phase = TRAINLOG_SESSION_GENERATOR_PREVIEW;
|
|
app.session.generated_preview = true;
|
|
app.session.generated.exercise_count = 1U;
|
|
(void)snprintf(app.session.generated.exercises[0].exercise_id,
|
|
sizeof(app.session.generated.exercises[0].exercise_id), "%s",
|
|
"ex_b432623f-bfe9-4daf-a653-60ec7fdffbde");
|
|
(void)snprintf(app.session.generated.exercises[0].equipment_id,
|
|
sizeof(app.session.generated.exercises[0].equipment_id), "%s", "leg_press");
|
|
app.session.generated.exercises[0].target_sets = 3;
|
|
app.session.generated.exercises[0].target_repetitions = 10;
|
|
app.session.generated.exercises[0].rest_seconds = 120;
|
|
app_shell_open_route(&app, TRAINLOG_ROUTE_EXERCISES);
|
|
CHECK(app.navigation.current.route == TRAINLOG_ROUTE_SESSION_GENERATOR);
|
|
CHECK(app.session.phase == TRAINLOG_SESSION_CONFIRM_LEAVE);
|
|
CHECK(app_shell_dispatch_session(&app, TRAINLOG_KEY_ENTER));
|
|
CHECK(app.navigation.current.route == TRAINLOG_ROUTE_EXERCISES);
|
|
CHECK(app.session.generated_preview);
|
|
app.navigation.current.route = TRAINLOG_ROUTE_SESSION_GENERATOR;
|
|
app.session.phase = TRAINLOG_SESSION_GENERATOR_PREVIEW;
|
|
CHECK(session_controller_accept_generator(&app));
|
|
CHECK(app.session.has_draft && app.session.draft_count == 1U);
|
|
CHECK(app.session.drafts[0].input.target_sets == 3);
|
|
CHECK(app.session.drafts[0].input.set_count == 0U);
|
|
trainlog_database_close(database);
|
|
return true;
|
|
}
|
|
|
|
static bool test_shell_session_forms_keep_plan_and_actuals_separate(void)
|
|
{
|
|
TrainlogDatabase *database = NULL;
|
|
TrainlogAppContext app;
|
|
TrainlogSessionDraftExercise *draft;
|
|
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
|
|
(void)memset(&app, 0, sizeof(app)); app.database = database;
|
|
trainlog_navigation_init(&app.navigation);
|
|
app.navigation.current.route = TRAINLOG_ROUTE_SESSION_CURRENT;
|
|
app.session.has_draft = true; app.session.draft_count = 1U;
|
|
app.session.phase = TRAINLOG_SESSION_DRAFT;
|
|
draft = &app.session.drafts[0];
|
|
draft->tracking_mode = TRAINLOG_TRACKING_REPS;
|
|
draft->input.recording_mode = TRAINLOG_RECORDING_SETS;
|
|
draft->input.target_sets = 3; draft->input.target_reps = 10;
|
|
draft_bind_input(draft);
|
|
CHECK(app_shell_dispatch_session(&app, 'e'));
|
|
CHECK(app_shell_dispatch_session(&app, 'a'));
|
|
CHECK(app_shell_dispatch_session(&app, '8'));
|
|
CHECK(app_shell_dispatch_session(&app, TRAINLOG_KEY_ENTER));
|
|
CHECK(draft->input.set_count == 1U && draft->sets[0].reps == 8);
|
|
CHECK(draft->input.target_sets == 3 && draft->input.target_reps == 10);
|
|
trainlog_database_close(database); return true;
|
|
}
|
|
|
|
static bool test_shell_continuous_supplemental_fields_have_no_fake_set(void)
|
|
{
|
|
TrainlogDatabase *database = NULL;
|
|
TrainlogAppContext app;
|
|
TrainlogSessionDraftExercise *draft;
|
|
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
|
|
(void)memset(&app, 0, sizeof(app)); app.database = database;
|
|
app.navigation.current.route = TRAINLOG_ROUTE_SESSION_CURRENT;
|
|
app.session.has_draft = true; app.session.draft_count = 1U;
|
|
app.session.phase = TRAINLOG_SESSION_ACTUALS;
|
|
draft = &app.session.drafts[0];
|
|
draft->tracking_mode = TRAINLOG_TRACKING_DURATION;
|
|
draft->input.recording_mode = TRAINLOG_RECORDING_CONTINUOUS;
|
|
draft->input.data_fields = TRAINLOG_EXERCISE_DATA_SPEED_KMH |
|
|
TRAINLOG_EXERCISE_DATA_DISTANCE_KM;
|
|
draft_bind_input(draft);
|
|
CHECK(app_shell_dispatch_session(&app, 'e'));
|
|
CHECK(app_shell_dispatch_session(&app, '9'));
|
|
CHECK(app_shell_dispatch_session(&app, '0'));
|
|
CHECK(app_shell_dispatch_session(&app, TRAINLOG_KEY_ENTER));
|
|
CHECK(app_shell_dispatch_session(&app, 's'));
|
|
CHECK(app_shell_dispatch_session(&app, '1'));
|
|
CHECK(app_shell_dispatch_session(&app, '2'));
|
|
CHECK(app_shell_dispatch_session(&app, TRAINLOG_KEY_ENTER));
|
|
CHECK(app_shell_dispatch_session(&app, 'k'));
|
|
CHECK(app_shell_dispatch_session(&app, '2'));
|
|
CHECK(app_shell_dispatch_session(&app, '.'));
|
|
CHECK(app_shell_dispatch_session(&app, '5'));
|
|
CHECK(app_shell_dispatch_session(&app, TRAINLOG_KEY_ENTER));
|
|
CHECK(draft->input.continuous_duration_seconds == 90);
|
|
CHECK(draft->input.continuous_has_speed && draft->input.continuous_speed_kmh == 12.0);
|
|
CHECK(draft->input.continuous_has_distance && draft->input.continuous_distance_km == 2.5);
|
|
CHECK(draft->input.set_count == 0U && !draft->input.has_max_weight);
|
|
trainlog_database_close(database); return true;
|
|
}
|
|
|
|
static bool test_shell_generator_dose_edit_drops_stale_load_provenance(void)
|
|
{
|
|
TrainlogDatabase *database = NULL;
|
|
TrainlogAppContext app;
|
|
TrainlogGeneratedExercise *generated;
|
|
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
|
|
(void)memset(&app, 0, sizeof(app)); app.database = database;
|
|
app.navigation.current.route = TRAINLOG_ROUTE_SESSION_GENERATOR;
|
|
app.session.generation_zone = trainlog_body_zone_catalog_lookup("thighs");
|
|
app.session.generation_goal = &trainlog_session_generation_policy_v1.goals[0];
|
|
app.session.generation_duration_minutes = 30;
|
|
CHECK(seed_generator_leg_press(database));
|
|
CHECK(session_controller_generate(&app));
|
|
CHECK(app.session.generated.exercise_count == 1U);
|
|
generated = &app.session.generated.exercises[0];
|
|
generated->target_sets = 3; generated->target_repetitions = 10;
|
|
generated->has_target_weight = true; generated->target_weight_kg = 80.0;
|
|
(void)snprintf(generated->load_source_session_id,
|
|
sizeof(generated->load_source_session_id), "se_source");
|
|
CHECK(app_shell_dispatch_session(&app, 'e'));
|
|
CHECK(app_shell_dispatch_session(&app, TRAINLOG_KEY_BACKSPACE));
|
|
CHECK(app_shell_dispatch_session(&app, '4'));
|
|
CHECK(app_shell_dispatch_session(&app, TRAINLOG_KEY_ENTER));
|
|
CHECK(app_shell_dispatch_session(&app, TRAINLOG_KEY_BACKSPACE));
|
|
CHECK(app_shell_dispatch_session(&app, TRAINLOG_KEY_BACKSPACE));
|
|
CHECK(app_shell_dispatch_session(&app, '1'));
|
|
CHECK(app_shell_dispatch_session(&app, '2'));
|
|
CHECK(app_shell_dispatch_session(&app, TRAINLOG_KEY_ENTER));
|
|
CHECK(generated->target_sets == 4 && generated->target_repetitions == 12);
|
|
CHECK(!generated->has_target_weight && generated->load_source_session_id[0] == '\0');
|
|
trainlog_database_close(database); return true;
|
|
}
|
|
|
|
static bool test_shell_inline_equipment_creation_resumes_occurrence(void)
|
|
{
|
|
TrainlogDatabase *database = NULL;
|
|
TrainlogAppContext app;
|
|
const char *name = "Cable maison";
|
|
const char *type = "cable";
|
|
size_t index;
|
|
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
|
|
(void)memset(&app, 0, sizeof(app)); app.database = database;
|
|
app.navigation.current.route = TRAINLOG_ROUTE_SESSION_CURRENT;
|
|
app.session.has_draft = true; app.session.draft_count = 1U;
|
|
app.session.phase = TRAINLOG_SESSION_EQUIPMENT_PICKER;
|
|
CHECK(app_shell_dispatch_session(&app, 'n'));
|
|
for (index = 0U; name[index] != '\0'; ++index)
|
|
CHECK(app_shell_dispatch_session(&app, (unsigned char)name[index]));
|
|
CHECK(app_shell_dispatch_session(&app, TRAINLOG_KEY_ENTER));
|
|
CHECK(app_shell_dispatch_session(&app, TRAINLOG_KEY_ENTER));
|
|
for (index = 0U; type[index] != '\0'; ++index)
|
|
CHECK(app_shell_dispatch_session(&app, (unsigned char)type[index]));
|
|
CHECK(app_shell_dispatch_session(&app, TRAINLOG_KEY_ENTER));
|
|
CHECK(app_shell_dispatch_session(&app, TRAINLOG_KEY_ENTER));
|
|
CHECK(app.session.phase == TRAINLOG_SESSION_EQUIPMENT_PICKER);
|
|
CHECK(app.session.equipment_selected > 0U);
|
|
CHECK(app_shell_dispatch_session(&app, TRAINLOG_KEY_ENTER));
|
|
CHECK(app.session.phase == TRAINLOG_SESSION_DRAFT);
|
|
CHECK(app.session.drafts[0].input.equipment_id[0] != '\0');
|
|
CHECK(app.session.drafts[0].input.load_mode == TRAINLOG_LOAD_EXTERNAL);
|
|
trainlog_database_close(database); return true;
|
|
}
|
|
|
|
static bool test_shell_equipment_catalog_create_preserves_invalid_form(void)
|
|
{
|
|
TrainlogDatabase *database = NULL;
|
|
TrainlogAppContext app;
|
|
TrainlogCustomEquipment items[2];
|
|
size_t count = 0U;
|
|
const char *name = "Poulie personnelle";
|
|
const char *type = "cable";
|
|
size_t index;
|
|
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
|
|
(void)memset(&app, 0, sizeof(app));
|
|
app.database = database;
|
|
app.navigation.current.route = TRAINLOG_ROUTE_EQUIPMENT;
|
|
app.layout.content.height = 20;
|
|
trainlog_list_init(&app.list);
|
|
equipment_controller_start(&app);
|
|
CHECK(app_shell_dispatch_equipment_controller(&app, TRAINLOG_KEY_ENTER));
|
|
CHECK(app.equipment_controller.phase == TRAINLOG_EQUIPMENT_NAME);
|
|
CHECK(app.equipment_controller.form.active);
|
|
CHECK(strstr(app.equipment_controller.message, "obligatoire") != NULL);
|
|
for (index = 0U; name[index] != '\0'; ++index)
|
|
CHECK(app_shell_dispatch_equipment_controller(&app,
|
|
(unsigned char)name[index]));
|
|
app_shell_open_route(&app, TRAINLOG_ROUTE_HOME);
|
|
CHECK(app.navigation.current.route == TRAINLOG_ROUTE_EQUIPMENT);
|
|
CHECK(app.equipment_controller.phase == TRAINLOG_EQUIPMENT_CONFIRM_DISCARD);
|
|
CHECK(strcmp(app.equipment_controller.form.text, name) == 0);
|
|
CHECK(app_shell_dispatch_equipment_controller(&app, '0'));
|
|
CHECK(app.equipment_controller.phase == TRAINLOG_EQUIPMENT_NAME);
|
|
CHECK(app_shell_dispatch_equipment_controller(&app, TRAINLOG_KEY_ENTER));
|
|
CHECK(app_shell_dispatch_equipment_controller(&app, TRAINLOG_KEY_ENTER));
|
|
for (index = 0U; type[index] != '\0'; ++index)
|
|
CHECK(app_shell_dispatch_equipment_controller(&app,
|
|
(unsigned char)type[index]));
|
|
CHECK(app_shell_dispatch_equipment_controller(&app, TRAINLOG_KEY_ENTER));
|
|
CHECK(app.equipment_controller.phase == TRAINLOG_EQUIPMENT_LOAD);
|
|
CHECK(app_shell_dispatch_equipment_controller(&app, '3'));
|
|
CHECK(app_shell_dispatch_equipment_controller(&app, TRAINLOG_KEY_ENTER));
|
|
CHECK(app.equipment_controller.phase == TRAINLOG_EQUIPMENT_MESSAGE);
|
|
CHECK(trainlog_database_list_custom_equipment(database, items, 2U,
|
|
&count) == TRAINLOG_STATUS_OK);
|
|
CHECK(count == 1U);
|
|
CHECK(strcmp(items[0].display_name, name) == 0);
|
|
CHECK(strcmp(items[0].equipment_type, type) == 0);
|
|
CHECK(strcmp(items[0].load_semantics, "assistance") == 0);
|
|
trainlog_database_close(database);
|
|
return true;
|
|
}
|
|
|
|
static bool test_shell_inline_exercise_creation_and_stable_edit(void)
|
|
{
|
|
TrainlogDatabase *database = NULL;
|
|
TrainlogAppContext app;
|
|
TrainlogExercise created;
|
|
TrainlogExercise listed[4];
|
|
size_t count = 0U;
|
|
const char *name = "Course inclinée";
|
|
size_t index;
|
|
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
|
|
(void)memset(&app, 0, sizeof(app));
|
|
app.database = database;
|
|
app.navigation.current.route = TRAINLOG_ROUTE_SESSION_CURRENT;
|
|
app.session.phase = TRAINLOG_SESSION_EXERCISE_PICKER;
|
|
exercise_controller_start_create(&app, true);
|
|
for (index = 0U; name[index] != '\0'; ++index)
|
|
CHECK(app_shell_dispatch_exercise_controller(&app,
|
|
(unsigned char)name[index]));
|
|
CHECK(app_shell_dispatch_exercise_controller(&app, TRAINLOG_KEY_ENTER));
|
|
CHECK(app_shell_dispatch_exercise_controller(&app, '2'));
|
|
CHECK(app_shell_dispatch_exercise_controller(&app, TRAINLOG_KEY_ENTER));
|
|
CHECK(app_shell_dispatch_exercise_controller(&app, '2'));
|
|
CHECK(app_shell_dispatch_exercise_controller(&app, TRAINLOG_KEY_ENTER));
|
|
CHECK(app_shell_dispatch_exercise_controller(&app, '1'));
|
|
CHECK(app_shell_dispatch_exercise_controller(&app, TRAINLOG_KEY_ENTER));
|
|
CHECK(app_shell_dispatch_exercise_controller(&app, '0'));
|
|
CHECK(app_shell_dispatch_exercise_controller(&app, TRAINLOG_KEY_ENTER));
|
|
CHECK(app.exercise_controller.phase == TRAINLOG_EXERCISE_ZONES);
|
|
CHECK(app_shell_dispatch_exercise_controller(&app, TRAINLOG_KEY_ENTER));
|
|
CHECK(app.exercise_controller.phase == TRAINLOG_EXERCISE_IDLE);
|
|
CHECK(app.session.phase == TRAINLOG_SESSION_EXERCISE_PICKER);
|
|
CHECK(app.session.picker_count == 1U);
|
|
created = app.session.picker[app.session.picker_selected];
|
|
CHECK(created.recording_mode == TRAINLOG_RECORDING_CONTINUOUS);
|
|
CHECK(created.tracking_mode == TRAINLOG_TRACKING_DURATION);
|
|
CHECK((created.data_fields & TRAINLOG_EXERCISE_DATA_SPEED_KMH) != 0U);
|
|
app.exercise_detail = created;
|
|
exercise_controller_start_edit(&app);
|
|
trainlog_form_init(&app.exercise_controller.form, "Course renommée");
|
|
app.exercise_controller.dirty = true;
|
|
CHECK(app_shell_dispatch_exercise_controller(&app, TRAINLOG_KEY_ENTER));
|
|
CHECK(app.exercise_controller.phase == TRAINLOG_EXERCISE_ZONES);
|
|
CHECK(app_shell_dispatch_exercise_controller(&app, TRAINLOG_KEY_ENTER));
|
|
CHECK(strcmp(app.exercise_detail.exercise_id, created.exercise_id) == 0);
|
|
CHECK(strcmp(app.exercise_detail.name, "Course renommée") == 0);
|
|
CHECK(trainlog_database_list_exercises(database, listed, 4U,
|
|
&count) == TRAINLOG_STATUS_OK);
|
|
CHECK(count == 1U && strcmp(listed[0].exercise_id, created.exercise_id) == 0);
|
|
trainlog_database_close(database);
|
|
return true;
|
|
}
|
|
|
|
static bool test_shell_navigation_focus_consumes_input(void)
|
|
{
|
|
TrainlogDatabase *database = NULL;
|
|
TrainlogAppContext app;
|
|
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
|
|
(void)memset(&app, 0, sizeof(app));
|
|
app.database = database;
|
|
app.layout.usable = true;
|
|
app.layout.sidebar_visible = true;
|
|
app.layout.content.height = 20;
|
|
app.focus = TRAINLOG_FOCUS_CONTENT;
|
|
trainlog_navigation_init(&app.navigation);
|
|
app_shell_dispatch(&app, TRAINLOG_KEY_F6);
|
|
CHECK(app.focus == TRAINLOG_FOCUS_NAVIGATION);
|
|
app_shell_dispatch(&app, TRAINLOG_KEY_ESCAPE);
|
|
CHECK(app.focus == TRAINLOG_FOCUS_CONTENT);
|
|
CHECK(app.navigation.current.route == TRAINLOG_ROUTE_HOME);
|
|
app_shell_dispatch(&app, TRAINLOG_KEY_SHIFT_TAB);
|
|
CHECK(app.focus == TRAINLOG_FOCUS_SEARCH);
|
|
app_shell_dispatch(&app, TRAINLOG_KEY_SHIFT_TAB);
|
|
CHECK(app.focus == TRAINLOG_FOCUS_NAVIGATION);
|
|
app.navigation_selected = 3U;
|
|
app_shell_dispatch(&app, TRAINLOG_KEY_ENTER);
|
|
CHECK(app.navigation.current.route == TRAINLOG_ROUTE_EQUIPMENT);
|
|
CHECK(app.focus == TRAINLOG_FOCUS_CONTENT);
|
|
trainlog_database_close(database);
|
|
return true;
|
|
}
|
|
|
|
static bool test_shell_actions_overlay_executes_registered_search_action(void)
|
|
{
|
|
TrainlogDatabase *database = NULL;
|
|
TrainlogAppContext app;
|
|
const TrainlogAction *search_action;
|
|
TrainlogCustomEquipment equipment;
|
|
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
|
|
(void)memset(&equipment, 0, sizeof(equipment));
|
|
generate_custom_equipment_uuid(equipment.equipment_id);
|
|
(void)snprintf(equipment.display_name, sizeof(equipment.display_name),
|
|
"overlay search needle");
|
|
(void)snprintf(equipment.equipment_type, sizeof(equipment.equipment_type),
|
|
"machine");
|
|
(void)snprintf(equipment.load_semantics, sizeof(equipment.load_semantics),
|
|
"external");
|
|
CHECK(trainlog_database_create_custom_equipment(database, &equipment) ==
|
|
TRAINLOG_STATUS_OK);
|
|
(void)memset(&app, 0, sizeof(app));
|
|
app.database = database;
|
|
app.layout.usable = true;
|
|
app.layout.content.height = 20;
|
|
app.focus = TRAINLOG_FOCUS_CONTENT;
|
|
trainlog_navigation_init(&app.navigation);
|
|
app.navigation.current.route = TRAINLOG_ROUTE_EQUIPMENT;
|
|
trainlog_search_init(&app.search);
|
|
app_shell_refresh_list(&app);
|
|
app_shell_actions(&app);
|
|
search_action = trainlog_actions_find_key(&app.actions, '/');
|
|
CHECK(search_action != NULL);
|
|
CHECK(strcmp(search_action->identifier, "list.search") == 0);
|
|
CHECK(search_action->intent == TRAINLOG_INTENT_OPEN_SEARCH);
|
|
app_shell_dispatch(&app, TRAINLOG_KEY_F7);
|
|
CHECK(trainlog_overlays_top(&app.overlays)->type == TRAINLOG_OVERLAY_ACTIONS);
|
|
app_shell_dispatch(&app, TRAINLOG_KEY_DOWN);
|
|
app_shell_dispatch(&app, TRAINLOG_KEY_ENTER);
|
|
CHECK(app.focus == TRAINLOG_FOCUS_SEARCH);
|
|
CHECK(app.search.open && app.search.focused);
|
|
app_shell_dispatch(&app, 'n');
|
|
app_shell_dispatch(&app, 'e');
|
|
app_shell_dispatch(&app, 'e');
|
|
app_shell_dispatch(&app, 'd');
|
|
app_shell_dispatch(&app, 'l');
|
|
app_shell_dispatch(&app, 'e');
|
|
CHECK(strcmp(app.search.text, "needle") == 0);
|
|
CHECK(app.loaded_count == 1U);
|
|
CHECK(strcmp(app.equipment[0].display_name, "overlay search needle") == 0);
|
|
app_shell_dispatch(&app, TRAINLOG_KEY_ESCAPE);
|
|
CHECK(app.search.focused && app.search.bytes == 0U);
|
|
CHECK(app.loaded_count > 1U);
|
|
app_shell_dispatch(&app, TRAINLOG_KEY_ESCAPE);
|
|
CHECK(!app.search.focused && app.focus == TRAINLOG_FOCUS_CONTENT);
|
|
trainlog_database_close(database);
|
|
return true;
|
|
}
|
|
|
|
static bool test_shell_compact_navigation_focus_contract(void)
|
|
{
|
|
TrainlogDatabase *database = NULL;
|
|
TrainlogAppContext app;
|
|
TrainlogTerminal terminal = {0};
|
|
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
|
|
(void)memset(&app, 0, sizeof(app));
|
|
terminal.columns = 120; terminal.rows = 35;
|
|
app.database = database; app.terminal = &terminal;
|
|
app.focus = TRAINLOG_FOCUS_CONTENT;
|
|
trainlog_navigation_init(&app.navigation);
|
|
CHECK(app_shell_layout(&app));
|
|
app_shell_dispatch(&app, TRAINLOG_KEY_F6);
|
|
CHECK(app.focus == TRAINLOG_FOCUS_NAVIGATION);
|
|
app.navigation_selected = 3U;
|
|
terminal.columns = 100; terminal.rows = 25;
|
|
CHECK(app_shell_layout(&app));
|
|
CHECK(!app.layout.sidebar_visible && app.focus == TRAINLOG_FOCUS_NAVIGATION);
|
|
app_shell_dispatch(&app, TRAINLOG_KEY_ENTER);
|
|
CHECK(trainlog_overlays_top(&app.overlays)->type == TRAINLOG_OVERLAY_NAVIGATION);
|
|
app_shell_dispatch(&app, TRAINLOG_KEY_ESCAPE);
|
|
CHECK(app.focus == TRAINLOG_FOCUS_NAVIGATION && app.navigation_selected == 3U);
|
|
app_shell_dispatch(&app, TRAINLOG_KEY_SHIFT_TAB);
|
|
CHECK(app.focus == TRAINLOG_FOCUS_ACTIONS);
|
|
app_shell_dispatch(&app, TRAINLOG_KEY_SHIFT_TAB);
|
|
CHECK(app.focus == TRAINLOG_FOCUS_CONTENT);
|
|
app_shell_dispatch(&app, TRAINLOG_KEY_SHIFT_TAB);
|
|
CHECK(app.focus == TRAINLOG_FOCUS_SEARCH && app.search.focused);
|
|
app_shell_dispatch(&app, TRAINLOG_KEY_SHIFT_TAB);
|
|
CHECK(app.focus == TRAINLOG_FOCUS_NAVIGATION && !app.search.focused);
|
|
app_shell_dispatch(&app, TRAINLOG_KEY_TAB);
|
|
CHECK(app.focus == TRAINLOG_FOCUS_SEARCH && app.search.focused);
|
|
app.navigation.current.route = TRAINLOG_ROUTE_EQUIPMENT;
|
|
app.search.open = true;
|
|
app_shell_dispatch(&app, TRAINLOG_KEY_F6);
|
|
CHECK(trainlog_overlays_top(&app.overlays)->type == TRAINLOG_OVERLAY_NAVIGATION);
|
|
app_shell_dispatch(&app, TRAINLOG_KEY_ESCAPE);
|
|
CHECK(app.focus == TRAINLOG_FOCUS_SEARCH && app.search.focused);
|
|
app.focus = TRAINLOG_FOCUS_ACTIONS; app.search.focused = false;
|
|
app_shell_dispatch(&app, TRAINLOG_KEY_ENTER);
|
|
CHECK(trainlog_overlays_top(&app.overlays)->type == TRAINLOG_OVERLAY_ACTIONS);
|
|
trainlog_database_close(database);
|
|
return true;
|
|
}
|
|
|
|
static bool test_shell_detail_back_restores_stable_list_selection(void)
|
|
{
|
|
TrainlogDatabase *database = NULL;
|
|
TrainlogAppContext app;
|
|
char selected_id[TRAINLOG_SHELL_STABLE_ID_CAPACITY];
|
|
size_t selected_index;
|
|
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
|
|
(void)memset(&app, 0, sizeof(app));
|
|
app.database = database;
|
|
app.layout.content.height = 16;
|
|
trainlog_navigation_init(&app.navigation);
|
|
app.navigation.current.route = TRAINLOG_ROUTE_EQUIPMENT;
|
|
trainlog_list_init(&app.list);
|
|
trainlog_search_init(&app.search);
|
|
app_shell_refresh_list(&app);
|
|
CHECK(app.loaded_count > 1U);
|
|
trainlog_list_move(&app.list, app.stable_ids, 1);
|
|
(void)snprintf(selected_id, sizeof(selected_id), "%s",
|
|
app.list.selected_id);
|
|
selected_index = app.list.selected_index;
|
|
app_shell_primary(&app);
|
|
CHECK(app.navigation.current.route == TRAINLOG_ROUTE_EQUIPMENT_DETAIL);
|
|
CHECK(app_shell_back(&app));
|
|
CHECK(app.navigation.current.route == TRAINLOG_ROUTE_EQUIPMENT);
|
|
CHECK(strcmp(app.list.selected_id, selected_id) == 0);
|
|
CHECK(app.list.selected_index == selected_index);
|
|
trainlog_database_close(database);
|
|
return true;
|
|
}
|
|
|
|
static int sync_probe_calls;
|
|
static int sync_run_calls;
|
|
static TrainlogSyncDirection sync_last_direction;
|
|
static TrainlogAppContext *sync_running_app;
|
|
static char sync_journal_root[PATH_MAX + 1U];
|
|
|
|
static TrainlogStatus mock_sync_probe(TrainlogSyncDeviceInfo *output)
|
|
{
|
|
++sync_probe_calls;
|
|
(void)memset(output, 0, sizeof(*output));
|
|
output->connected = true;
|
|
output->storage_ready = true;
|
|
(void)snprintf(output->device.vendor, sizeof(output->device.vendor), "Test");
|
|
(void)snprintf(output->device.model, sizeof(output->device.model), "Phone");
|
|
return TRAINLOG_STATUS_OK;
|
|
}
|
|
|
|
static TrainlogStatus mock_sync_run(TrainlogSyncTrigger trigger,
|
|
bool require_request, TrainlogSyncDirection direction,
|
|
TrainlogSyncReport *output)
|
|
{
|
|
char directory[PATH_MAX + 1U];
|
|
char history_path[PATH_MAX + 1U];
|
|
FILE *history;
|
|
int run_number;
|
|
CHECK(trigger == TRAINLOG_SYNC_TRIGGER_TUI);
|
|
CHECK(!require_request);
|
|
++sync_run_calls;
|
|
run_number = sync_run_calls;
|
|
sync_last_direction = direction;
|
|
CHECK(sync_running_app != NULL && sync_running_app->sync_controller.running);
|
|
CHECK(strstr(sync_running_app->terminal->output,
|
|
"Synchronisation en cours") != NULL);
|
|
/* A re-entrant launch attempt is consumed while the sole run is active. */
|
|
CHECK(app_shell_dispatch_sync(sync_running_app, 's'));
|
|
CHECK(app_shell_dispatch_sync(sync_running_app, TRAINLOG_KEY_ENTER));
|
|
CHECK(sync_run_calls == run_number);
|
|
(void)memset(output, 0, sizeof(*output));
|
|
output->success = run_number != 2;
|
|
output->direction = direction;
|
|
(void)snprintf(output->sync_id, sizeof(output->sync_id),
|
|
"sy_00000000-0000-4000-8000-%012d", run_number);
|
|
(void)snprintf(output->summary, sizeof(output->summary), "%s",
|
|
output->success ? "sync test réussie" : "sync test échouée");
|
|
if (!output->success)
|
|
(void)snprintf(output->error, sizeof(output->error), "%s",
|
|
"Appareil déconnecté pendant la synchronisation.");
|
|
CHECK(snprintf(directory, sizeof(directory), "%s/trainlog",
|
|
sync_journal_root) > 0);
|
|
CHECK(mkdir(directory, 0700) == 0 || errno == EEXIST);
|
|
CHECK(snprintf(history_path, sizeof(history_path), "%s/sync_history.log",
|
|
directory) > 0);
|
|
history = fopen(history_path, "ab");
|
|
CHECK(history != NULL);
|
|
CHECK(fprintf(history, "%s\t11/09/2026 12:%02d\t%d\tb\t%s\n",
|
|
output->sync_id, run_number, output->success ? 1 : 0,
|
|
output->summary) > 0);
|
|
CHECK(fclose(history) == 0);
|
|
return output->success ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_SYSTEM_ERROR;
|
|
}
|
|
|
|
static bool test_shell_body_controller_preserves_identity_and_failed_edit(void)
|
|
{
|
|
TrainlogDatabase *database = NULL;
|
|
TrainlogAppContext app;
|
|
TrainlogBodyObservationRecord records[4];
|
|
size_t count = 0U;
|
|
size_t field;
|
|
char id[TRAINLOG_ID_MAX + 1U];
|
|
char observed_at[TRAINLOG_TIMESTAMP_MAX + 1U];
|
|
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
|
|
(void)memset(&app, 0, sizeof(app));
|
|
app.database = database;
|
|
app.layout.usable = true;
|
|
app.layout.content.height = 16;
|
|
trainlog_navigation_init(&app.navigation);
|
|
app.navigation.current.route = TRAINLOG_ROUTE_BODY;
|
|
body_controller_start_add(&app);
|
|
CHECK(app.body_controller.phase == TRAINLOG_BODY_FORM);
|
|
CHECK(app_shell_dispatch_body_controller(&app, '8'));
|
|
CHECK(app_shell_dispatch_body_controller(&app, '0'));
|
|
for (field = 0U; field < 14U; ++field)
|
|
CHECK(app_shell_dispatch_body_controller(&app, TRAINLOG_KEY_ENTER));
|
|
CHECK(app.body_controller.phase == TRAINLOG_BODY_MESSAGE);
|
|
CHECK(trainlog_database_list_body_observations(database, records, 4U,
|
|
&count) == TRAINLOG_STATUS_OK);
|
|
CHECK(count == 1U && records[0].has_body_weight &&
|
|
records[0].body_weight_kg == 80.0);
|
|
(void)snprintf(id, sizeof(id), "%s", records[0].observation_id);
|
|
(void)snprintf(observed_at, sizeof(observed_at), "%s", records[0].observed_at);
|
|
(void)memset(&app.body_controller, 0, sizeof(app.body_controller));
|
|
CHECK(body_controller_start_edit(&app, id));
|
|
trainlog_form_init(&app.body_controller.form, "-");
|
|
CHECK(app_shell_dispatch_body_controller(&app, TRAINLOG_KEY_ENTER));
|
|
for (field = 1U; field < 14U; ++field)
|
|
CHECK(app_shell_dispatch_body_controller(&app, TRAINLOG_KEY_ENTER));
|
|
CHECK(app.body_controller.phase == TRAINLOG_BODY_FORM);
|
|
CHECK(strstr(app.body_controller.message, "au moins une mesure") != NULL);
|
|
CHECK(trainlog_database_get_body_observation(database, id, &records[0]) ==
|
|
TRAINLOG_STATUS_OK);
|
|
CHECK(records[0].has_body_weight && records[0].body_weight_kg == 80.0);
|
|
trainlog_form_init(&app.body_controller.form, "81");
|
|
app.body_controller.field = 0U;
|
|
CHECK(app_shell_dispatch_body_controller(&app, TRAINLOG_KEY_ENTER));
|
|
for (field = 1U; field < 14U; ++field)
|
|
CHECK(app_shell_dispatch_body_controller(&app, TRAINLOG_KEY_ENTER));
|
|
CHECK(app.body_controller.phase == TRAINLOG_BODY_MESSAGE);
|
|
CHECK(trainlog_database_get_body_observation(database, id, &records[0]) ==
|
|
TRAINLOG_STATUS_OK);
|
|
CHECK(strcmp(records[0].observation_id, id) == 0);
|
|
CHECK(strcmp(records[0].observed_at, observed_at) == 0);
|
|
CHECK(records[0].body_weight_kg == 81.0);
|
|
(void)memset(&app.body_controller, 0, sizeof(app.body_controller));
|
|
app.navigation.current.route = TRAINLOG_ROUTE_BODY;
|
|
app_shell_open_route(&app, TRAINLOG_ROUTE_BODY_METRIC);
|
|
CHECK(trainlog_database_list_body_observations(database, records, 4U,
|
|
&count) == TRAINLOG_STATUS_OK);
|
|
CHECK(count == 1U && strcmp(records[0].observation_id, id) == 0 &&
|
|
records[0].body_weight_kg == 81.0);
|
|
trainlog_database_close(database);
|
|
return true;
|
|
}
|
|
|
|
static bool test_shell_sync_f7_runs_bidirectional_and_refreshes_history(void)
|
|
{
|
|
TrainlogDatabase *database = NULL;
|
|
TrainlogAppContext app;
|
|
TrainlogTerminal terminal = {0};
|
|
char temporary[] = "/tmp/trainlog-tui-sync-XXXXXX";
|
|
const char *old_data_home = getenv("XDG_DATA_HOME");
|
|
char *saved_data_home = old_data_home != NULL ? strdup(old_data_home) : NULL;
|
|
size_t index;
|
|
size_t sync_action_index = SIZE_MAX;
|
|
size_t sync_action_count = 0U;
|
|
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
|
|
CHECK(mkdtemp(temporary) != NULL);
|
|
CHECK(setenv("XDG_DATA_HOME", temporary, 1) == 0);
|
|
(void)snprintf(sync_journal_root, sizeof(sync_journal_root), "%s", temporary);
|
|
(void)memset(&app, 0, sizeof(app));
|
|
app.database = database;
|
|
terminal.columns = 120;
|
|
terminal.rows = 35;
|
|
app.terminal = &terminal;
|
|
trainlog_navigation_init(&app.navigation);
|
|
trainlog_overlays_init(&app.overlays);
|
|
trainlog_sync_screen_state_init(&app.sync_controller.action);
|
|
app.sync_controller.probe = mock_sync_probe;
|
|
app.sync_controller.run = mock_sync_run;
|
|
sync_probe_calls = 0;
|
|
sync_run_calls = 0;
|
|
sync_running_app = &app;
|
|
app_shell_open_route(&app, TRAINLOG_ROUTE_SYNC);
|
|
CHECK(app_shell_layout(&app));
|
|
CHECK(sync_probe_calls == 0 && sync_run_calls == 0);
|
|
app_shell_actions(&app);
|
|
for (index = 0U; index < app.actions.count; ++index) {
|
|
const TrainlogAction *action = &app.actions.items[index];
|
|
CHECK(strstr(action->label, "Android→PC") == NULL);
|
|
CHECK(strstr(action->label, "PC→Android") == NULL);
|
|
if (strcmp(action->identifier, "sync.now") == 0) {
|
|
++sync_action_count;
|
|
sync_action_index = index;
|
|
CHECK(strcmp(action->label,
|
|
"s Synchroniser maintenant · PC↔Android") == 0);
|
|
}
|
|
}
|
|
CHECK(sync_action_count == 1U && sync_action_index != SIZE_MAX);
|
|
|
|
/* Actual user path: Synchronisation screen -> F7 -> registered action. */
|
|
app_shell_dispatch(&app, TRAINLOG_KEY_F7);
|
|
CHECK(trainlog_overlays_top(&app.overlays) != NULL);
|
|
app.overlays.items[app.overlays.count - 1U].selected = sync_action_index;
|
|
app_shell_dispatch(&app, TRAINLOG_KEY_ENTER);
|
|
CHECK(app.sync_controller.action.confirming && sync_run_calls == 0);
|
|
CHECK(app_shell_dispatch_sync(&app, TRAINLOG_KEY_ESCAPE));
|
|
CHECK(!app.sync_controller.action.confirming && sync_run_calls == 0);
|
|
|
|
app_shell_dispatch(&app, TRAINLOG_KEY_F7);
|
|
app.overlays.items[app.overlays.count - 1U].selected = sync_action_index;
|
|
app_shell_dispatch(&app, TRAINLOG_KEY_ENTER);
|
|
CHECK(sync_run_calls == 0);
|
|
CHECK(app_shell_dispatch_sync(&app, TRAINLOG_KEY_ENTER));
|
|
CHECK(sync_run_calls == 1);
|
|
CHECK(sync_last_direction == TRAINLOG_SYNC_BIDIRECTIONAL);
|
|
CHECK(sync_probe_calls == 1);
|
|
CHECK(!app.sync_controller.running && app.sync_controller.has_report);
|
|
CHECK(app.sync_controller.report.success);
|
|
CHECK(app.sync_controller.history_count == 1U);
|
|
terminal.output_used = 0U; terminal.output[0] = '\0';
|
|
app_shell_render_sync(&app);
|
|
CHECK(strstr(terminal.output, "sync test réussie") != NULL);
|
|
|
|
/* Repeating the action creates the next legitimate journal event. */
|
|
app_shell_dispatch(&app, 's');
|
|
CHECK(app.sync_controller.action.confirming);
|
|
app_shell_dispatch(&app, TRAINLOG_KEY_ENTER);
|
|
CHECK(sync_run_calls == 2);
|
|
CHECK(sync_last_direction == TRAINLOG_SYNC_BIDIRECTIONAL);
|
|
CHECK(!app.sync_controller.report.success);
|
|
CHECK(app.sync_controller.history_count == 2U);
|
|
terminal.output_used = 0U; terminal.output[0] = '\0';
|
|
app_shell_render_sync(&app);
|
|
CHECK(strstr(terminal.output,
|
|
"Appareil déconnecté pendant la synchronisation.") != NULL);
|
|
CHECK(app_shell_dispatch_sync(&app, 'r'));
|
|
CHECK(sync_probe_calls == 3 && sync_run_calls == 2);
|
|
if (saved_data_home != NULL) {
|
|
CHECK(setenv("XDG_DATA_HOME", saved_data_home, 1) == 0);
|
|
free(saved_data_home);
|
|
} else CHECK(unsetenv("XDG_DATA_HOME") == 0);
|
|
{
|
|
char history_path[PATH_MAX + 1U];
|
|
char directory[PATH_MAX + 1U];
|
|
CHECK(snprintf(history_path, sizeof(history_path),
|
|
"%s/trainlog/sync_history.log", temporary) > 0);
|
|
CHECK(snprintf(directory, sizeof(directory), "%s/trainlog",
|
|
temporary) > 0);
|
|
CHECK(unlink(history_path) == 0);
|
|
CHECK(rmdir(directory) == 0);
|
|
CHECK(rmdir(temporary) == 0);
|
|
}
|
|
sync_running_app = NULL;
|
|
trainlog_database_close(database);
|
|
return true;
|
|
}
|
|
|
|
static bool test_shell_sync_history_neutralizes_legacy_directions(void)
|
|
{
|
|
TrainlogAppContext app;
|
|
TrainlogTerminal terminal = {0};
|
|
TrainlogSurface surface = {0};
|
|
char temporary[] = "/tmp/trainlog-tui-sync-legacy-XXXXXX";
|
|
char directory[PATH_MAX + 1U];
|
|
char history_path[PATH_MAX + 1U];
|
|
char runs_directory[PATH_MAX + 1U];
|
|
char detail_path[PATH_MAX + 1U];
|
|
const char *old_data_home = getenv("XDG_DATA_HOME");
|
|
char *saved_data_home = old_data_home != NULL ? strdup(old_data_home) : NULL;
|
|
FILE *history;
|
|
CHECK(mkdtemp(temporary) != NULL);
|
|
CHECK(snprintf(directory, sizeof(directory), "%s/trainlog", temporary) > 0);
|
|
CHECK(mkdir(directory, 0700) == 0);
|
|
CHECK(snprintf(history_path, sizeof(history_path), "%s/sync_history.log",
|
|
directory) > 0);
|
|
CHECK(snprintf(runs_directory, sizeof(runs_directory), "%s/sync_runs",
|
|
directory) > 0);
|
|
CHECK(mkdir(runs_directory, 0700) == 0);
|
|
history = fopen(history_path, "wb");
|
|
CHECK(history != NULL);
|
|
CHECK(fprintf(history,
|
|
"sy_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa\t09/09/2026 10:11\t1\ta\tAndroid→PC import ancien\n"
|
|
"sy_pppppppp-pppp-4ppp-8ppp-pppppppppppp\t10/09/2026 11:12\t0\tp\tPC→Android publication ancienne\n") > 0);
|
|
CHECK(fclose(history) == 0);
|
|
CHECK(snprintf(detail_path, sizeof(detail_path), "%s/%s.txt", runs_directory,
|
|
"sy_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") > 0);
|
|
history = fopen(detail_path, "wb");
|
|
CHECK(history != NULL);
|
|
CHECK(fputs("Direction: Android→PC\nAndroid→PC import details\n", history) >= 0);
|
|
CHECK(fclose(history) == 0);
|
|
CHECK(snprintf(detail_path, sizeof(detail_path), "%s/%s.txt", runs_directory,
|
|
"sy_pppppppp-pppp-4ppp-8ppp-pppppppppppp") > 0);
|
|
history = fopen(detail_path, "wb");
|
|
CHECK(history != NULL);
|
|
CHECK(fputs("Direction: PC→Android\nPC→Android publication details\n", history) >= 0);
|
|
CHECK(fclose(history) == 0);
|
|
CHECK(setenv("XDG_DATA_HOME", temporary, 1) == 0);
|
|
|
|
(void)memset(&app, 0, sizeof(app));
|
|
terminal.columns = 120;
|
|
terminal.rows = 35;
|
|
surface.terminal = &terminal;
|
|
app.terminal = &terminal;
|
|
app.content = &surface;
|
|
app.layout.content.width = 120;
|
|
app.layout.content.height = 30;
|
|
trainlog_navigation_init(&app.navigation);
|
|
app.navigation.current.route = TRAINLOG_ROUTE_SYNC;
|
|
trainlog_sync_screen_state_init(&app.sync_controller.action);
|
|
sync_history_load(app.sync_controller.history, SYNC_HISTORY_CAPACITY,
|
|
&app.sync_controller.history_count);
|
|
CHECK(app.sync_controller.history_count == 2U);
|
|
app_shell_render_sync(&app);
|
|
CHECK(strstr(terminal.output, "09/09/2026 10:11") != NULL);
|
|
CHECK(strstr(terminal.output, "10/09/2026 11:12") != NULL);
|
|
CHECK(strstr(terminal.output, "entrée historique") != NULL);
|
|
CHECK(strstr(terminal.output, "PC↔Android") != NULL);
|
|
CHECK(strstr(terminal.output, "Android→PC") == NULL);
|
|
CHECK(strstr(terminal.output, "PC→Android") == NULL);
|
|
|
|
/* Actual keyboard path: select and open each retained legacy artifact. */
|
|
for (app.sync_controller.selected = 0U;
|
|
app.sync_controller.selected < app.sync_controller.history_count;
|
|
++app.sync_controller.selected) {
|
|
terminal.output_used = 0U;
|
|
terminal.output[0] = '\0';
|
|
CHECK(app_shell_dispatch_sync(&app, TRAINLOG_KEY_ENTER));
|
|
CHECK(app.sync_controller.showing_detail);
|
|
app_shell_render_sync(&app);
|
|
CHECK(strstr(terminal.output,
|
|
"Synchronisation historique PC↔Android") != NULL);
|
|
CHECK(strstr(terminal.output, "Horodatage") != NULL);
|
|
CHECK(strstr(terminal.output, "État") != NULL);
|
|
CHECK(strstr(terminal.output, "Android→PC") == NULL);
|
|
CHECK(strstr(terminal.output, "PC→Android") == NULL);
|
|
app.sync_controller.showing_detail = false;
|
|
}
|
|
|
|
if (saved_data_home != NULL) {
|
|
CHECK(setenv("XDG_DATA_HOME", saved_data_home, 1) == 0);
|
|
free(saved_data_home);
|
|
} else CHECK(unsetenv("XDG_DATA_HOME") == 0);
|
|
CHECK(unlink(history_path) == 0);
|
|
CHECK(snprintf(detail_path, sizeof(detail_path), "%s/%s.txt", runs_directory,
|
|
"sy_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") > 0);
|
|
CHECK(unlink(detail_path) == 0);
|
|
CHECK(snprintf(detail_path, sizeof(detail_path), "%s/%s.txt", runs_directory,
|
|
"sy_pppppppp-pppp-4ppp-8ppp-pppppppppppp") > 0);
|
|
CHECK(unlink(detail_path) == 0);
|
|
CHECK(rmdir(runs_directory) == 0);
|
|
CHECK(rmdir(directory) == 0);
|
|
CHECK(rmdir(temporary) == 0);
|
|
return true;
|
|
}
|
|
|
|
static bool test_equipment_collector_finds_match_after_first_pages(void)
|
|
{
|
|
TrainlogDatabase *database = NULL;
|
|
TrainlogCustomEquipment equipment;
|
|
TrainlogResolvedEquipment result[2];
|
|
bool truncated = false;
|
|
bool failed = false;
|
|
size_t index;
|
|
size_t count;
|
|
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
|
|
for (index = 0U; index < 140U; ++index) {
|
|
(void)memset(&equipment, 0, sizeof(equipment));
|
|
generate_custom_equipment_uuid(equipment.equipment_id);
|
|
(void)snprintf(equipment.display_name, sizeof(equipment.display_name),
|
|
index == 139U ? "zz needle-late" : "custom %03zu", index);
|
|
(void)snprintf(equipment.equipment_type,
|
|
sizeof(equipment.equipment_type), "machine");
|
|
(void)snprintf(equipment.load_semantics,
|
|
sizeof(equipment.load_semantics), "external");
|
|
CHECK(trainlog_database_create_custom_equipment(database, &equipment) ==
|
|
TRAINLOG_STATUS_OK);
|
|
}
|
|
count = equipment_collect(database, "needle-late", result, 2U,
|
|
&truncated, &failed);
|
|
CHECK(!failed && !truncated && count == 1U);
|
|
CHECK(strcmp(result[0].display_name, "zz needle-late") == 0);
|
|
trainlog_database_close(database);
|
|
return true;
|
|
}
|
|
|
|
static bool test_body_graphs_draw_inside_minimum_content_surface(void)
|
|
{
|
|
TrainlogAppContext app;
|
|
TrainlogTerminal terminal;
|
|
TrainlogSurface surface;
|
|
TrainlogDashboardMonth previous;
|
|
TrainlogDashboardMonth current;
|
|
long current_key;
|
|
(void)memset(&app, 0, sizeof(app));
|
|
(void)memset(&terminal, 0, sizeof(terminal));
|
|
terminal.rows = 20;
|
|
terminal.columns = 72;
|
|
surface.terminal = &terminal;
|
|
app.terminal = &terminal;
|
|
app.content = &surface;
|
|
app.layout.content.height = 16;
|
|
app.layout.content.width = 72;
|
|
app.body_global_initialized = true;
|
|
app.body_global_enabled[0] = true;
|
|
app.body_global_series[0].symbol = GLOBAL_BODY_SYMBOLS[0];
|
|
app.body_global_series[0].role = GLOBAL_BODY_ROLES[0];
|
|
app.body_global_series[0].baseline = 80.0;
|
|
app.body_global_series[0].count = 2U;
|
|
CHECK(dashboard_current_month_key(¤t_key));
|
|
dashboard_month_from_key(current_key - 1L, &previous);
|
|
dashboard_month_from_key(current_key, ¤t);
|
|
(void)snprintf(app.body_global_series[0].points[0].observed_at,
|
|
sizeof(app.body_global_series[0].points[0].observed_at),
|
|
"%04d-%02d-01T08:00:00Z", previous.year, previous.month);
|
|
app.body_global_series[0].points[0].value = 80.0;
|
|
(void)snprintf(app.body_global_series[0].points[1].observed_at,
|
|
sizeof(app.body_global_series[0].points[1].observed_at),
|
|
"%04d-%02d-01T08:00:00Z", current.year, current.month);
|
|
app.body_global_series[0].points[1].value = 81.0;
|
|
(void)snprintf(app.body_global_dates[0], sizeof(app.body_global_dates[0]),
|
|
"%s", app.body_global_series[0].points[0].observed_at);
|
|
(void)snprintf(app.body_global_dates[1], sizeof(app.body_global_dates[1]),
|
|
"%s", app.body_global_series[0].points[1].observed_at);
|
|
app.body_global_date_count = 2U;
|
|
app_shell_render_body_global(&app);
|
|
CHECK(terminal.surface_draw_count > 0U);
|
|
terminal.surface_draw_count = 0U;
|
|
app_shell_render_body_trends(&app);
|
|
CHECK(terminal.surface_draw_count > 0U);
|
|
return true;
|
|
}
|
|
|
|
static bool test_exercise_merge_action_search_confirm_selects_target(void)
|
|
{
|
|
TrainlogDatabase *database = NULL;
|
|
TrainlogAppContext app;
|
|
char resolved[TRAINLOG_ID_MAX + 1U];
|
|
size_t action_index;
|
|
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
|
|
CHECK(trainlog_database_insert_exercise(database,
|
|
"ex_11111111-1111-4111-8111-111111111111", "Source curl",
|
|
"source curl", TRAINLOG_TRACKING_REPS) == TRAINLOG_STATUS_OK);
|
|
CHECK(trainlog_database_insert_exercise(database,
|
|
"ex_22222222-2222-4222-8222-222222222222", "Target curl",
|
|
"target curl", TRAINLOG_TRACKING_REPS) == TRAINLOG_STATUS_OK);
|
|
(void)memset(&app, 0, sizeof(app));
|
|
app.database = database;
|
|
trainlog_navigation_init(&app.navigation);
|
|
trainlog_overlays_init(&app.overlays);
|
|
app.navigation.current.route = TRAINLOG_ROUTE_EXERCISE_DETAIL;
|
|
(void)snprintf(app.exercise_detail.exercise_id,
|
|
sizeof(app.exercise_detail.exercise_id), "%s",
|
|
"ex_11111111-1111-4111-8111-111111111111");
|
|
(void)snprintf(app.exercise_detail.name, sizeof(app.exercise_detail.name),
|
|
"%s", "Source curl");
|
|
app.exercise_detail.tracking_mode = TRAINLOG_TRACKING_REPS;
|
|
app.exercise_detail.recording_mode = TRAINLOG_RECORDING_SETS;
|
|
app_shell_actions(&app);
|
|
for (action_index = 0U; action_index < app.actions.count; ++action_index)
|
|
if (strcmp(app.actions.items[action_index].identifier,
|
|
"exercise.merge") == 0) break;
|
|
CHECK(action_index < app.actions.count);
|
|
(void)trainlog_overlays_push(&app.overlays, TRAINLOG_OVERLAY_ACTIONS,
|
|
TRAINLOG_FOCUS_CONTENT, app.exercise_detail.exercise_id);
|
|
app.overlays.items[0].selected = action_index;
|
|
app_shell_dispatch_overlay(&app, TRAINLOG_KEY_ENTER);
|
|
CHECK(trainlog_overlays_top(&app.overlays)->type ==
|
|
TRAINLOG_OVERLAY_EXERCISE_MERGE);
|
|
CHECK(app.exercise_controller.phase == TRAINLOG_EXERCISE_MERGE_PICKER);
|
|
app_shell_dispatch_overlay(&app, 't');
|
|
app_shell_dispatch_overlay(&app, 'a');
|
|
app_shell_dispatch_overlay(&app, 'r');
|
|
CHECK(exercise_merge_match_count(&app.exercise_controller) == 1U);
|
|
app_shell_dispatch_overlay(&app, TRAINLOG_KEY_ENTER);
|
|
CHECK(app.exercise_controller.phase == TRAINLOG_EXERCISE_MERGE_CONFIRM);
|
|
app_shell_dispatch_overlay(&app, TRAINLOG_KEY_DOWN);
|
|
app_shell_dispatch_overlay(&app, TRAINLOG_KEY_ENTER);
|
|
CHECK(app.exercise_controller.phase == TRAINLOG_EXERCISE_MERGE_RESULT);
|
|
CHECK(strcmp(app.exercise_detail.exercise_id,
|
|
"ex_22222222-2222-4222-8222-222222222222") == 0);
|
|
CHECK(strcmp(app.navigation.current.stable_id,
|
|
"ex_22222222-2222-4222-8222-222222222222") == 0);
|
|
CHECK(trainlog_database_resolve_exercise_id(database,
|
|
"ex_11111111-1111-4111-8111-111111111111", resolved,
|
|
sizeof(resolved)) == TRAINLOG_STATUS_OK);
|
|
CHECK(strcmp(resolved, "ex_22222222-2222-4222-8222-222222222222") == 0);
|
|
app_shell_dispatch_overlay(&app, TRAINLOG_KEY_ENTER);
|
|
CHECK(app.overlays.count == 0U);
|
|
CHECK(app.exercise_controller.phase == TRAINLOG_EXERCISE_IDLE);
|
|
trainlog_database_close(database);
|
|
return true;
|
|
}
|
|
|
|
int main(void)
|
|
{
|
|
if (!test_knowledge_scrolls_long_lists_at_minimum_terminal() ||
|
|
!test_shell_session_navigation_preserves_run_draft() ||
|
|
!test_shell_generator_keep_and_accept_has_no_actuals() ||
|
|
!test_shell_session_forms_keep_plan_and_actuals_separate() ||
|
|
!test_shell_continuous_supplemental_fields_have_no_fake_set() ||
|
|
!test_shell_generator_dose_edit_drops_stale_load_provenance() ||
|
|
!test_shell_inline_equipment_creation_resumes_occurrence() ||
|
|
!test_shell_equipment_catalog_create_preserves_invalid_form() ||
|
|
!test_shell_inline_exercise_creation_and_stable_edit() ||
|
|
!test_shell_navigation_focus_consumes_input() ||
|
|
!test_shell_actions_overlay_executes_registered_search_action() ||
|
|
!test_shell_compact_navigation_focus_contract() ||
|
|
!test_shell_detail_back_restores_stable_list_selection() ||
|
|
!test_shell_body_controller_preserves_identity_and_failed_edit() ||
|
|
!test_shell_sync_f7_runs_bidirectional_and_refreshes_history() ||
|
|
!test_shell_sync_history_neutralizes_legacy_directions() ||
|
|
!test_equipment_collector_finds_match_after_first_pages() ||
|
|
!test_exercise_merge_action_search_confirm_selects_target() ||
|
|
!test_body_graphs_draw_inside_minimum_content_surface()) return 1;
|
|
(void)printf("PASS tui_workflows\n");
|
|
return 0;
|
|
}
|