feat(tui): add bilingual presentation
This commit is contained in:
parent
1674059203
commit
d4b56a3aa5
10 changed files with 2292 additions and 484 deletions
76
tui/include/trainlog/presentation.h
Normal file
76
tui/include/trainlog/presentation.h
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
/*
|
||||
* Trainlog local presentation preferences and translations.
|
||||
*/
|
||||
#ifndef TRAINLOG_PRESENTATION_H
|
||||
#define TRAINLOG_PRESENTATION_H
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdarg.h>
|
||||
|
||||
typedef enum TrainlogPresentationLanguage {
|
||||
TRAINLOG_PRESENTATION_FRENCH = 0,
|
||||
TRAINLOG_PRESENTATION_ENGLISH
|
||||
} TrainlogPresentationLanguage;
|
||||
|
||||
typedef enum TrainlogPresentationSaveResult {
|
||||
TRAINLOG_PRESENTATION_SAVE_NOT_COMMITTED = 0,
|
||||
TRAINLOG_PRESENTATION_SAVE_COMMITTED_DURABLE,
|
||||
TRAINLOG_PRESENTATION_SAVE_COMMITTED_DURABILITY_UNCERTAIN
|
||||
} TrainlogPresentationSaveResult;
|
||||
|
||||
/* WHY: language is a local presentation choice, not workout data.
|
||||
* CONTRACT: init always selects French. load accepts only the versioned v1
|
||||
* file and leaves French selected when it is absent or invalid. Neither
|
||||
* function reads or mutates the canonical database.
|
||||
* INVARIANT: the only runtime values are French and English. */
|
||||
void trainlog_presentation_init(void);
|
||||
bool trainlog_presentation_load(void);
|
||||
TrainlogPresentationSaveResult trainlog_presentation_save(void);
|
||||
void trainlog_presentation_resolve_save(
|
||||
TrainlogPresentationLanguage previous_language,
|
||||
TrainlogPresentationSaveResult result);
|
||||
TrainlogPresentationLanguage trainlog_presentation_language(void);
|
||||
bool trainlog_presentation_set_language(TrainlogPresentationLanguage language);
|
||||
const char *trainlog_presentation_language_code(
|
||||
TrainlogPresentationLanguage language);
|
||||
const char *trainlog_presentation_language_name(
|
||||
TrainlogPresentationLanguage language);
|
||||
|
||||
/* WHY: stable keys keep screen code independent of wording and language.
|
||||
* CONTRACT: returned storage is static and must not be freed. Unknown/NULL
|
||||
* keys return a visible deterministic fallback and never user/catalog data.
|
||||
* INVARIANT: translations have no side effects. */
|
||||
const char *trainlog_presentation_text(const char *key);
|
||||
|
||||
/* Transitional centralized lookup for presentation format strings. It exists
|
||||
* so large legacy renderers can cross one translation boundary without
|
||||
* language conditionals; new UI must use stable keys above. Unknown strings
|
||||
* are returned byte-for-byte, preserving user and protocol literals. */
|
||||
const char *trainlog_presentation_source(const char *source);
|
||||
const char *trainlog_presentation_month_label(const char *source,
|
||||
char *output, size_t output_size);
|
||||
|
||||
/* Format one translated presentation string into caller-owned storage. This
|
||||
* is the serialization-safe adapter used by strict renderer APIs: format
|
||||
* placeholders are identical in both catalog columns. */
|
||||
int trainlog_presentation_vformat(char *output, size_t capacity,
|
||||
const char *source, va_list arguments);
|
||||
int trainlog_presentation_format(char *output, size_t capacity,
|
||||
const char *source, ...);
|
||||
/* Locale-neutral formatting for editable numeric seed text. This is not
|
||||
* presentation: both UI languages and every host locale receive ASCII '.'. */
|
||||
int trainlog_presentation_format_input(char *output, size_t capacity,
|
||||
const char *format, ...) __attribute__((format(printf, 3, 4)));
|
||||
int trainlog_presentation_format_key(char *output, size_t capacity,
|
||||
const char *key, ...);
|
||||
|
||||
/* Format YYYY-MM-DD as app-owned compact presentation text. Stored timestamp
|
||||
* bytes are borrowed and never changed; French uses DD/MM, English MM/DD. */
|
||||
bool trainlog_presentation_compact_date(const char *iso_date, bool with_year,
|
||||
char *output, size_t capacity);
|
||||
|
||||
/* Exposed for focused persistence tests; normal callers use load/save. */
|
||||
bool trainlog_presentation_config_path(char *output, size_t capacity);
|
||||
|
||||
#endif
|
||||
|
|
@ -141,6 +141,7 @@ trainlog_core_dep = declare_dependency(
|
|||
trainlog_tui_sources = files(
|
||||
'src/main.c',
|
||||
'src/app_shell.c',
|
||||
'src/presentation.c',
|
||||
'src/chart.c',
|
||||
'src/sync_screen_action.c',
|
||||
'src/terminal.c',
|
||||
|
|
@ -598,6 +599,7 @@ test_sync_screen_action = executable(
|
|||
'test_sync_screen_action',
|
||||
'tests/test_sync_screen_action.c',
|
||||
'src/sync_screen_action.c',
|
||||
'src/presentation.c',
|
||||
dependencies: [trainlog_core_dep, notcurses_dep],
|
||||
c_args: strict_c_args,
|
||||
)
|
||||
|
|
@ -654,6 +656,7 @@ test_tui_workflows = executable(
|
|||
'tests/test_tui_workflows.c',
|
||||
'src/app_shell.c',
|
||||
'src/chart.c',
|
||||
'src/presentation.c',
|
||||
'src/theme.c',
|
||||
'src/sync_screen_action.c',
|
||||
dependencies: trainlog_core_dep,
|
||||
|
|
@ -666,6 +669,7 @@ test_chart = executable(
|
|||
'test_chart',
|
||||
'tests/test_chart.c',
|
||||
'src/chart.c',
|
||||
'src/presentation.c',
|
||||
include_directories: trainlog_include,
|
||||
dependencies: m_dep,
|
||||
c_args: strict_c_args,
|
||||
|
|
@ -736,8 +740,28 @@ test_app_shell = executable(
|
|||
'test_app_shell',
|
||||
'tests/test_app_shell.c',
|
||||
'src/app_shell.c',
|
||||
'src/presentation.c',
|
||||
dependencies: trainlog_core_dep,
|
||||
c_args: strict_c_args,
|
||||
)
|
||||
|
||||
test('app_shell', test_app_shell)
|
||||
|
||||
test_presentation = executable(
|
||||
'test_presentation',
|
||||
'tests/test_presentation.c',
|
||||
'src/presentation.c',
|
||||
'src/app_shell.c',
|
||||
'src/sync_screen_action.c',
|
||||
dependencies: trainlog_core_dep,
|
||||
c_args: strict_c_args,
|
||||
)
|
||||
|
||||
test('presentation_i18n', test_presentation)
|
||||
|
||||
test(
|
||||
'presentation_catalog_completeness',
|
||||
python3,
|
||||
args: [meson.project_source_root() / 'tui/tests/test_presentation_catalog.py'],
|
||||
workdir: meson.project_source_root(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
#include <utf8proc.h>
|
||||
|
||||
#include "trainlog/terminal.h"
|
||||
#include "trainlog/presentation.h"
|
||||
|
||||
static void copy_id(char output[TRAINLOG_SHELL_STABLE_ID_CAPACITY],
|
||||
const char *value)
|
||||
|
|
@ -501,37 +502,24 @@ TrainlogFormResult trainlog_form_handle(TrainlogFormField *field, int key)
|
|||
|
||||
const char *trainlog_route_title(TrainlogAppRoute route)
|
||||
{
|
||||
switch (route) {
|
||||
case TRAINLOG_ROUTE_HOME: return "Accueil";
|
||||
case TRAINLOG_ROUTE_SESSIONS: return "Séances";
|
||||
case TRAINLOG_ROUTE_SESSION_CURRENT: return "Séances / Séance en cours";
|
||||
case TRAINLOG_ROUTE_SESSION_GENERATOR: return "Séances / Programmer";
|
||||
case TRAINLOG_ROUTE_SESSION_MANUAL: return "Séances / Nouvelle séance";
|
||||
case TRAINLOG_ROUTE_SESSIONS_COMPLETED: return "Séances / Effectuées";
|
||||
case TRAINLOG_ROUTE_SESSION_DETAIL: return "Séances / Détail";
|
||||
case TRAINLOG_ROUTE_EXERCISES: return "Exercices / Catalogue";
|
||||
case TRAINLOG_ROUTE_EXERCISE_DETAIL: return "Exercices / Fiche";
|
||||
case TRAINLOG_ROUTE_EXERCISE_KNOWLEDGE: return "Exercices / Connaissances";
|
||||
case TRAINLOG_ROUTE_EXERCISE_PERFORMANCE: return "Statistiques / Performance";
|
||||
case TRAINLOG_ROUTE_EXERCISE_MAX: return "Statistiques / MAX mesuré";
|
||||
case TRAINLOG_ROUTE_EQUIPMENT: return "Équipements / Catalogue";
|
||||
case TRAINLOG_ROUTE_EQUIPMENT_DETAIL: return "Équipements / Fiche";
|
||||
case TRAINLOG_ROUTE_STATS: return "Statistiques";
|
||||
case TRAINLOG_ROUTE_STATS_TRAINING: return "Statistiques / Entraînement";
|
||||
case TRAINLOG_ROUTE_STATS_EXERCISE: return "Statistiques / Par exercice";
|
||||
case TRAINLOG_ROUTE_STATS_EXERCISE_DETAIL: return "Statistiques / Exercice";
|
||||
case TRAINLOG_ROUTE_STATS_ZONES: return "Statistiques / Zones";
|
||||
case TRAINLOG_ROUTE_BODY: return "Statistiques / Mensurations";
|
||||
case TRAINLOG_ROUTE_BODY_DETAIL: return "Mensurations / Relevé";
|
||||
case TRAINLOG_ROUTE_BODY_METRIC: return "Mensurations / Historique";
|
||||
case TRAINLOG_ROUTE_BODY_TRENDS: return "Mensurations / 12 mois";
|
||||
case TRAINLOG_ROUTE_BODY_GLOBAL: return "Mensurations / Vue globale";
|
||||
case TRAINLOG_ROUTE_BODY_ANALYTICS: return "Mensurations / Analyse";
|
||||
case TRAINLOG_ROUTE_MAX: return "Statistiques / Capacités MAX";
|
||||
case TRAINLOG_ROUTE_SYNC: return "Synchronisation";
|
||||
case TRAINLOG_ROUTE_SETTINGS: return "Paramètres";
|
||||
default: return "Trainlog";
|
||||
}
|
||||
static const char *const keys[TRAINLOG_ROUTE_COUNT] = {
|
||||
"nav.home", "nav.sessions", "route.session.current",
|
||||
"route.session.generator", "route.session.manual",
|
||||
"route.session.completed", "route.session.detail",
|
||||
"route.exercise.catalog", "route.exercise.detail",
|
||||
"route.exercise.knowledge", "route.exercise.performance",
|
||||
"route.exercise.max", "route.equipment.catalog",
|
||||
"route.equipment.detail", "nav.statistics", "route.stats.training",
|
||||
"route.stats.exercise", "route.stats.exercise.detail",
|
||||
"route.stats.zones", "route.body", "route.body.detail",
|
||||
"route.body.metric", "route.body.trends", "route.body.global",
|
||||
"route.body.analytics", "route.max", "nav.sync", "nav.settings"
|
||||
};
|
||||
/* INVARIANT: enum order is the stable route identity; wording is resolved
|
||||
* only at this presentation boundary. */
|
||||
if (route >= TRAINLOG_ROUTE_HOME && route < TRAINLOG_ROUTE_COUNT)
|
||||
return trainlog_presentation_text(keys[route]);
|
||||
return "Trainlog";
|
||||
}
|
||||
|
||||
TrainlogAppRoute trainlog_route_section(TrainlogAppRoute route)
|
||||
|
|
|
|||
|
|
@ -9,10 +9,26 @@
|
|||
#include <limits.h>
|
||||
#include <math.h>
|
||||
#include <stdio.h>
|
||||
#include <stdarg.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
|
||||
#include "trainlog/presentation.h"
|
||||
|
||||
static void chart_surface_printf(TrainlogSurface *surface, int row, int column,
|
||||
const char *format, ...)
|
||||
{
|
||||
va_list arguments;
|
||||
char value[512];
|
||||
va_start(arguments, format);
|
||||
(void)trainlog_presentation_vformat(value, sizeof(value), format, arguments);
|
||||
va_end(arguments);
|
||||
trainlog_surface_printf(surface, row, column, "%s", value);
|
||||
}
|
||||
|
||||
#define trainlog_surface_printf chart_surface_printf
|
||||
|
||||
#define CHART_LABEL_WIDTH 12
|
||||
#define CHART_MIN_WIDTH 24
|
||||
#define CHART_MIN_HEIGHT 6
|
||||
|
|
@ -160,21 +176,18 @@ static void short_date(const TrainlogChartPoint *point, char output[6])
|
|||
{
|
||||
time_t seconds;
|
||||
struct tm value;
|
||||
char iso_date[11];
|
||||
if (point != NULL && point->timestamp_label != NULL &&
|
||||
strlen(point->timestamp_label) >= 10U &&
|
||||
point->timestamp_label[4] == '-' && point->timestamp_label[7] == '-') {
|
||||
output[0] = point->timestamp_label[8];
|
||||
output[1] = point->timestamp_label[9];
|
||||
output[2] = '/';
|
||||
output[3] = point->timestamp_label[5];
|
||||
output[4] = point->timestamp_label[6];
|
||||
output[5] = '\0';
|
||||
return;
|
||||
if (trainlog_presentation_compact_date(point->timestamp_label, false,
|
||||
output, 6U)) return;
|
||||
}
|
||||
seconds = point != NULL ? (time_t)point->timestamp : (time_t)-1;
|
||||
if (point == NULL || (int64_t)seconds != point->timestamp ||
|
||||
gmtime_r(&seconds, &value) == NULL ||
|
||||
strftime(output, 6U, "%d/%m", &value) != 5U)
|
||||
strftime(iso_date, sizeof(iso_date), "%Y-%m-%d", &value) != 10U ||
|
||||
!trainlog_presentation_compact_date(iso_date, false, output, 6U))
|
||||
(void)snprintf(output, 6U, "--/--");
|
||||
}
|
||||
|
||||
|
|
@ -184,12 +197,13 @@ static void render_empty(TrainlogSurface *surface, TrainlogChartRect rect,
|
|||
trainlog_surface_set_role(surface, TRAINLOG_COLOR_DEFAULT,
|
||||
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_BOLD);
|
||||
trainlog_surface_printf(surface, rect.top, rect.left, "%s",
|
||||
title != NULL ? title : "ÉVOLUTION DANS LE TEMPS");
|
||||
title != NULL ? trainlog_presentation_source(title)
|
||||
: trainlog_presentation_text("chart.timeline"));
|
||||
trainlog_surface_set_role(surface, TRAINLOG_COLOR_MUTED,
|
||||
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL);
|
||||
if (rect.height > 2)
|
||||
trainlog_surface_printf(surface, rect.top + 2, rect.left,
|
||||
"Aucune donnée disponible.");
|
||||
"%s", trainlog_presentation_text("empty.no_data"));
|
||||
}
|
||||
|
||||
void trainlog_chart_render(TrainlogSurface *surface, TrainlogChartRect rect,
|
||||
|
|
@ -229,7 +243,8 @@ void trainlog_chart_render_line(TrainlogSurface *surface, TrainlogChartRect rect
|
|||
trainlog_surface_set_role(surface, TRAINLOG_COLOR_DEFAULT,
|
||||
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_BOLD);
|
||||
trainlog_surface_printf(surface, rect.top, rect.left, "%s",
|
||||
title != NULL ? title : "ÉVOLUTION DANS LE TEMPS");
|
||||
title != NULL ? trainlog_presentation_source(title)
|
||||
: trainlog_presentation_text("chart.timeline"));
|
||||
if (rect.width < CHART_MIN_WIDTH || rect.height < CHART_MIN_HEIGHT) {
|
||||
trainlog_surface_set_role(surface, TRAINLOG_COLOR_ACCENT,
|
||||
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_BOLD);
|
||||
|
|
@ -359,10 +374,12 @@ void trainlog_chart_render_bars(TrainlogSurface *surface, TrainlogChartRect rect
|
|||
trainlog_surface_set_role(surface, TRAINLOG_COLOR_DEFAULT,
|
||||
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_BOLD);
|
||||
trainlog_surface_printf(surface, rect.top, rect.left, "%s",
|
||||
title != NULL ? title : "TOTAUX PAR PÉRIODE");
|
||||
title != NULL ? trainlog_presentation_source(title)
|
||||
: trainlog_presentation_text("chart.totals"));
|
||||
if (rect.width < CHART_MIN_WIDTH || rect.height < CHART_MIN_HEIGHT) {
|
||||
trainlog_surface_printf(surface, rect.top + 2, rect.left,
|
||||
"%zu périodes · %.2f %s", count, points[count - 1U].value, safe_unit);
|
||||
"%zu périodes · %.2f %s", count,
|
||||
points[count - 1U].value, safe_unit);
|
||||
return;
|
||||
}
|
||||
plot_left = rect.left + CHART_LABEL_WIDTH;
|
||||
|
|
@ -410,9 +427,13 @@ void trainlog_chart_render_bars(TrainlogSurface *surface, TrainlogChartRect rect
|
|||
if (column < plot_left) column = plot_left;
|
||||
if (column > plot_left + plot_width - 11)
|
||||
column = plot_left + plot_width - 11;
|
||||
if (label != NULL)
|
||||
if (label != NULL) {
|
||||
char localized_label[32];
|
||||
trainlog_surface_printf(surface,
|
||||
plot_top + plot_height + 1, column, "%.11s", label);
|
||||
plot_top + plot_height + 1, column, "%.11s",
|
||||
trainlog_presentation_month_label(label, localized_label,
|
||||
sizeof(localized_label)));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
short_date(&first_domain, first); short_date(&last_domain, last);
|
||||
|
|
|
|||
1233
tui/src/presentation.c
Normal file
1233
tui/src/presentation.c
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -6,6 +6,7 @@
|
|||
#include "trainlog/sync_screen_action.h"
|
||||
|
||||
#include "trainlog/terminal.h"
|
||||
#include "trainlog/presentation.h"
|
||||
|
||||
static TrainlogSyncScreenAction sync_screen_action(
|
||||
TrainlogSyncScreenEffect effect,
|
||||
|
|
@ -143,14 +144,14 @@ const char *trainlog_sync_screen_confirmation(
|
|||
)
|
||||
{
|
||||
if (direction == TRAINLOG_SYNC_ANDROID_TO_PC) {
|
||||
return "Importer Android vers le PC maintenant ?";
|
||||
return trainlog_presentation_text("sync.confirm.import");
|
||||
}
|
||||
|
||||
if (direction == TRAINLOG_SYNC_PC_TO_ANDROID) {
|
||||
return "Publier le catalogue du PC vers Android maintenant ?";
|
||||
return trainlog_presentation_text("sync.confirm.publish");
|
||||
}
|
||||
|
||||
return "Synchroniser Android et PC dans les deux sens maintenant ?";
|
||||
return trainlog_presentation_text("sync.confirm.both");
|
||||
}
|
||||
|
||||
const char *trainlog_sync_screen_footer(
|
||||
|
|
@ -158,8 +159,8 @@ const char *trainlog_sync_screen_footer(
|
|||
)
|
||||
{
|
||||
if (terminal_columns >= 100) {
|
||||
return "s Synchroniser maintenant (PC↔Android) r Actualiser appareil Échap retour";
|
||||
return trainlog_presentation_text("sync.footer.wide");
|
||||
}
|
||||
|
||||
return "s Synchroniser PC↔Android r Actualiser Échap retour";
|
||||
return trainlog_presentation_text("sync.footer.compact");
|
||||
}
|
||||
|
|
|
|||
894
tui/src/tui.c
894
tui/src/tui.c
File diff suppressed because it is too large
Load diff
226
tui/tests/test_presentation.c
Normal file
226
tui/tests/test_presentation.c
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
#include <assert.h>
|
||||
#include <locale.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include "trainlog/app_shell.h"
|
||||
#include "trainlog/presentation.h"
|
||||
#include "trainlog/sync_screen_action.h"
|
||||
|
||||
static void test_default_and_catalog(void)
|
||||
{
|
||||
TrainlogShellLayout french;
|
||||
TrainlogShellLayout english;
|
||||
char date[9];
|
||||
char dynamic[512];
|
||||
trainlog_presentation_init();
|
||||
assert(trainlog_presentation_language() == TRAINLOG_PRESENTATION_FRENCH);
|
||||
assert(strcmp(trainlog_presentation_text("nav.settings"), "Paramètres") == 0);
|
||||
assert(strcmp(trainlog_route_title(TRAINLOG_ROUTE_STATS_TRAINING),
|
||||
"Statistiques / Entraînement") == 0);
|
||||
assert(trainlog_presentation_compact_date("2026-09-15", true,
|
||||
date, sizeof(date)));
|
||||
assert(strcmp(date, "15/09/26") == 0);
|
||||
trainlog_shell_layout_compute(72, 20, &french);
|
||||
assert(french.usable);
|
||||
|
||||
assert(trainlog_presentation_set_language(TRAINLOG_PRESENTATION_ENGLISH));
|
||||
assert(strcmp(trainlog_presentation_text("nav.settings"), "Settings") == 0);
|
||||
assert(strcmp(trainlog_route_title(TRAINLOG_ROUTE_STATS_TRAINING),
|
||||
"Statistics / Training") == 0);
|
||||
assert(strcmp(trainlog_sync_screen_confirmation(TRAINLOG_SYNC_BIDIRECTIONAL),
|
||||
"Synchronize Android and PC in both directions now?") == 0);
|
||||
assert(strstr(trainlog_sync_screen_footer(100), "Refresh device") != NULL);
|
||||
assert(strcmp(trainlog_presentation_source("Aucune donnée disponible."),
|
||||
"No data available.") == 0);
|
||||
assert(trainlog_presentation_format_key(dynamic, sizeof(dynamic),
|
||||
"msg.actual.required", "Squat Français") > 0);
|
||||
assert(strcmp(dynamic, "Enter an actual result for Squat Français.") == 0);
|
||||
assert(trainlog_presentation_format_key(dynamic, sizeof(dynamic),
|
||||
"sync.history.legacy", "2026-09-15T10:30:00Z",
|
||||
trainlog_presentation_text("sync.state.success")) > 0);
|
||||
assert(strstr(dynamic, "Historical PC↔Android synchronization") != NULL);
|
||||
assert(strstr(dynamic, "Timestamp: 2026-09-15T10:30:00Z") != NULL);
|
||||
assert(strstr(dynamic, "Status: successful") != NULL);
|
||||
/* Representative generator/profile/body/MAX/search/merge/feedback UI
|
||||
* formats must render English while preserving caller-provided values. */
|
||||
assert(trainlog_presentation_format(dynamic, sizeof(dynamic),
|
||||
"Objectif : %s", "Force") > 0);
|
||||
assert(strcmp(dynamic, "Goal: Force") == 0);
|
||||
assert(trainlog_presentation_format(dynamic, sizeof(dynamic),
|
||||
"%zu exercice(s) · cible %d min · estimation %d min", (size_t)3,
|
||||
45, 42) > 0);
|
||||
assert(strcmp(dynamic, "3 exercise(s) · target 45 min · estimate 42 min") == 0);
|
||||
assert(trainlog_presentation_format(dynamic, sizeof(dynamic),
|
||||
"Tour de taille %.1f cm · variation %+.1f cm", 81.5, -1.0) > 0);
|
||||
assert(strcmp(dynamic, "Waist circumference 81.5 cm · change -1.0 cm") == 0);
|
||||
assert(trainlog_presentation_format(dynamic, sizeof(dynamic),
|
||||
"Historique des MAX") > 0);
|
||||
assert(strcmp(dynamic, "MAX history") == 0);
|
||||
assert(trainlog_presentation_format(dynamic, sizeof(dynamic),
|
||||
"Recherche cible : %s_", "Squat") > 0);
|
||||
assert(strcmp(dynamic, "Search target: Squat_") == 0);
|
||||
assert(trainlog_presentation_format(dynamic, sizeof(dynamic),
|
||||
"Contexte : %s · %s · %zu point%s", "Squat", "MAX", (size_t)1,
|
||||
"") > 0);
|
||||
assert(strcmp(dynamic, "Context: Squat · MAX · 1 point") == 0);
|
||||
assert(trainlog_presentation_compact_date("2026-09-15", true,
|
||||
date, sizeof(date)));
|
||||
assert(strcmp(date, "09/15/26") == 0);
|
||||
/* Persisted and catalog names are not keys and must remain byte-exact. */
|
||||
assert(strcmp(trainlog_presentation_source("Développé incliné — Alice"),
|
||||
"Développé incliné — Alice") == 0);
|
||||
trainlog_shell_layout_compute(72, 20, &english);
|
||||
assert(memcmp(&french, &english, sizeof(french)) == 0);
|
||||
assert(!trainlog_presentation_set_language(
|
||||
(TrainlogPresentationLanguage)99));
|
||||
assert(trainlog_presentation_set_language(TRAINLOG_PRESENTATION_FRENCH));
|
||||
/* Decimal localization is scoped to the float conversion: persisted/user
|
||||
* text is not parsed or mutated merely because it contains digits. */
|
||||
assert(trainlog_presentation_format(dynamic, sizeof(dynamic),
|
||||
"Valeur %.1f · modèle %s", 12.5, "Model 1.2") > 0);
|
||||
assert(strcmp(dynamic, "Valeur 12,5 · modèle Model 1.2") == 0);
|
||||
assert(strcmp(trainlog_presentation_source("Pendant la séance"),
|
||||
"Pendant la séance") == 0);
|
||||
assert(strcmp(trainlog_presentation_month_label("févr. 2026", dynamic,
|
||||
sizeof(dynamic)), "févr. 2026") == 0);
|
||||
assert(trainlog_presentation_set_language(TRAINLOG_PRESENTATION_ENGLISH));
|
||||
assert(strcmp(trainlog_presentation_source("Pendant la séance"),
|
||||
"During session") == 0);
|
||||
assert(strcmp(trainlog_presentation_month_label("févr. 2026", dynamic,
|
||||
sizeof(dynamic)), "Feb 2026") == 0);
|
||||
assert(trainlog_presentation_set_language(TRAINLOG_PRESENTATION_FRENCH));
|
||||
assert(trainlog_presentation_format_key(dynamic, sizeof(dynamic),
|
||||
"msg.actual.required", "Squat Français") > 0);
|
||||
assert(strcmp(dynamic, "Saisissez un résultat réel pour Squat Français.") == 0);
|
||||
}
|
||||
|
||||
static void test_selected_language_owns_decimals(void)
|
||||
{
|
||||
char output[128];
|
||||
char *original = setlocale(LC_NUMERIC, NULL);
|
||||
char saved[128];
|
||||
if (original != NULL) (void)snprintf(saved, sizeof(saved), "%s", original);
|
||||
else saved[0] = '\0';
|
||||
|
||||
trainlog_presentation_init();
|
||||
assert(trainlog_presentation_format(output, sizeof(output),
|
||||
"Valeur %.2f · delta %+.1f", 12.5, 0.25) > 0);
|
||||
assert(strcmp(output, "Valeur 12,50 · delta +0,2") == 0);
|
||||
assert(trainlog_presentation_format(output, sizeof(output),
|
||||
"%+.1f%%", 3.25) > 0);
|
||||
assert(strcmp(output, "+3,2%") == 0);
|
||||
assert(trainlog_presentation_format(output, sizeof(output),
|
||||
"%2$*3$.*4$f · %1$s · %%", "Model 1.2", 12.5, 7, 2) > 0);
|
||||
assert(strcmp(output, " 12,50 · Model 1.2 · %") == 0);
|
||||
assert(trainlog_presentation_format(output, sizeof(output),
|
||||
"%Lf · %zu", (long double)1.25L, (size_t)4) > 0);
|
||||
assert(strcmp(output, "1,250000 · 4") == 0);
|
||||
assert(trainlog_presentation_set_language(TRAINLOG_PRESENTATION_ENGLISH));
|
||||
assert(trainlog_presentation_format(output, sizeof(output),
|
||||
"Value %.2f", 12.5) > 0);
|
||||
assert(strcmp(output, "Value 12.50") == 0);
|
||||
|
||||
/* Contrary-locale integration proof when the host supplies French. The
|
||||
* assertions above remain the locale-independent fallback proof. */
|
||||
if (setlocale(LC_NUMERIC, "fr_FR.UTF-8") != NULL ||
|
||||
setlocale(LC_NUMERIC, "fr_FR.utf8") != NULL) {
|
||||
assert(trainlog_presentation_format(output, sizeof(output),
|
||||
"Value %.2f", 12.5) > 0);
|
||||
assert(strcmp(output, "Value 12.50") == 0);
|
||||
assert(trainlog_presentation_set_language(TRAINLOG_PRESENTATION_FRENCH));
|
||||
assert(trainlog_presentation_format(output, sizeof(output),
|
||||
"Valeur %.2f", 12.5) > 0);
|
||||
assert(strcmp(output, "Valeur 12,50") == 0);
|
||||
}
|
||||
if (saved[0] != '\0') assert(setlocale(LC_NUMERIC, saved) != NULL);
|
||||
}
|
||||
|
||||
static void test_persistence(void)
|
||||
{
|
||||
char root_template[] = "/tmp/trainlog-presentation-XXXXXX";
|
||||
char *root = mkdtemp(root_template);
|
||||
char path[4096];
|
||||
char directory[4096];
|
||||
char invalid[] = "version=1\nlanguage=de\n";
|
||||
FILE *file;
|
||||
assert(root != NULL);
|
||||
assert(setenv("XDG_CONFIG_HOME", root, 1) == 0);
|
||||
assert(trainlog_presentation_config_path(path, sizeof(path)));
|
||||
assert(strstr(path, "/trainlog/presentation.conf") != NULL);
|
||||
|
||||
assert(unsetenv("XDG_CONFIG_HOME") == 0);
|
||||
assert(setenv("HOME", root, 1) == 0);
|
||||
assert(trainlog_presentation_config_path(path, sizeof(path)));
|
||||
assert(strstr(path, "/.config/trainlog/presentation.conf") != NULL);
|
||||
assert(setenv("XDG_CONFIG_HOME", root, 1) == 0);
|
||||
assert(trainlog_presentation_config_path(path, sizeof(path)));
|
||||
|
||||
trainlog_presentation_init();
|
||||
assert(trainlog_presentation_load());
|
||||
assert(trainlog_presentation_language() == TRAINLOG_PRESENTATION_FRENCH);
|
||||
assert(trainlog_presentation_set_language(TRAINLOG_PRESENTATION_ENGLISH));
|
||||
(void)snprintf(directory, sizeof(directory), "%s/trainlog", root);
|
||||
assert(mkdir(directory, 0700) == 0);
|
||||
{
|
||||
char stale[4128];
|
||||
FILE *stale_file;
|
||||
(void)snprintf(stale, sizeof(stale), "%s.tmp.%ld", path,
|
||||
(long)getpid());
|
||||
stale_file = fopen(stale, "wb");
|
||||
assert(stale_file != NULL);
|
||||
assert(fclose(stale_file) == 0);
|
||||
assert(trainlog_presentation_save() ==
|
||||
TRAINLOG_PRESENTATION_SAVE_COMMITTED_DURABLE);
|
||||
assert(unlink(stale) == 0);
|
||||
}
|
||||
trainlog_presentation_init();
|
||||
assert(trainlog_presentation_load());
|
||||
assert(trainlog_presentation_language() == TRAINLOG_PRESENTATION_ENGLISH);
|
||||
|
||||
assert(trainlog_presentation_set_language(TRAINLOG_PRESENTATION_FRENCH));
|
||||
assert(trainlog_presentation_save() ==
|
||||
TRAINLOG_PRESENTATION_SAVE_COMMITTED_DURABLE);
|
||||
assert(trainlog_presentation_set_language(TRAINLOG_PRESENTATION_ENGLISH));
|
||||
assert(trainlog_presentation_load());
|
||||
assert(trainlog_presentation_language() == TRAINLOG_PRESENTATION_FRENCH);
|
||||
|
||||
file = fopen(path, "wb");
|
||||
assert(file != NULL);
|
||||
assert(fwrite(invalid, 1U, strlen(invalid), file) == strlen(invalid));
|
||||
assert(fclose(file) == 0);
|
||||
assert(!trainlog_presentation_load());
|
||||
assert(trainlog_presentation_language() == TRAINLOG_PRESENTATION_FRENCH);
|
||||
|
||||
assert(unlink(path) == 0);
|
||||
(void)snprintf(directory, sizeof(directory), "%s/trainlog", root);
|
||||
assert(rmdir(directory) == 0);
|
||||
file = fopen(directory, "wb");
|
||||
assert(file != NULL);
|
||||
assert(fclose(file) == 0);
|
||||
assert(trainlog_presentation_set_language(TRAINLOG_PRESENTATION_ENGLISH));
|
||||
assert(trainlog_presentation_save() ==
|
||||
TRAINLOG_PRESENTATION_SAVE_NOT_COMMITTED);
|
||||
trainlog_presentation_resolve_save(TRAINLOG_PRESENTATION_FRENCH,
|
||||
TRAINLOG_PRESENTATION_SAVE_NOT_COMMITTED);
|
||||
assert(trainlog_presentation_language() == TRAINLOG_PRESENTATION_FRENCH);
|
||||
assert(trainlog_presentation_set_language(TRAINLOG_PRESENTATION_ENGLISH));
|
||||
trainlog_presentation_resolve_save(TRAINLOG_PRESENTATION_FRENCH,
|
||||
TRAINLOG_PRESENTATION_SAVE_COMMITTED_DURABILITY_UNCERTAIN);
|
||||
assert(trainlog_presentation_language() == TRAINLOG_PRESENTATION_ENGLISH);
|
||||
assert(unlink(directory) == 0);
|
||||
|
||||
assert(rmdir(root) == 0);
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
test_default_and_catalog();
|
||||
test_selected_language_owns_decimals();
|
||||
test_persistence();
|
||||
puts("presentation tests passed");
|
||||
return 0;
|
||||
}
|
||||
86
tui/tests/test_presentation_catalog.py
Normal file
86
tui/tests/test_presentation_catalog.py
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Source-derived, default-fail catalogue audit for active TUI renderers."""
|
||||
import pathlib, re, sys
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[2]
|
||||
# WHY: implementation-directory membership is not presentation reachability;
|
||||
# scanning core SQL, synchronization codecs, and catalogs confuses durable raw
|
||||
# diagnostics/identifiers with renderer copy.
|
||||
# CONTRACT: this reviewed allowlist contains every active renderer plus the two
|
||||
# known presentation-producing helpers. A new renderer must be added here and
|
||||
# defaults to failure as soon as it contains uncatalogued French copy.
|
||||
# INVARIANT: database/sync/sync_history/catalog implementations are not UI-copy
|
||||
# owners and cannot become implicit exceptions through this audit.
|
||||
RENDERER_SOURCES = (
|
||||
"tui/src/tui.c",
|
||||
"tui/src/chart.c",
|
||||
"tui/src/app_shell.c",
|
||||
"tui/src/sync_screen_action.c",
|
||||
)
|
||||
PRESENTATION_PRODUCERS = (
|
||||
"tui/src/timeutil.c",
|
||||
"tui/src/statistics.c",
|
||||
)
|
||||
SOURCES = RENDERER_SOURCES + PRESENTATION_PRODUCERS
|
||||
assert not {"tui/src/database.c", "tui/src/sync.c", "tui/src/sync_history.c",
|
||||
"tui/src/catalog.c"}.intersection(SOURCES)
|
||||
RENDERER_MARKER = re.compile(
|
||||
r"trainlog_(?:surface|terminal)_(?:printf|put|draw|set)|"
|
||||
r"trainlog_presentation_(?:text|source|format)"
|
||||
)
|
||||
INFRASTRUCTURE = {"tui/src/presentation.c", "tui/src/terminal.c"}
|
||||
discovered_renderers = {
|
||||
str(path.relative_to(ROOT))
|
||||
for path in (ROOT / "tui/src").glob("*.c")
|
||||
if RENDERER_MARKER.search(path.read_text(encoding="utf-8"))
|
||||
} - INFRASTRUCTURE
|
||||
unreviewed_renderers = discovered_renderers - set(RENDERER_SOURCES)
|
||||
if unreviewed_renderers:
|
||||
sys.stderr.write("UNREVIEWED presentation renderer(s):\n" +
|
||||
"\n".join(f" {path}" for path in sorted(unreviewed_renderers)) + "\n")
|
||||
sys.exit(1)
|
||||
P = (ROOT / "tui/src/presentation.c").read_text(encoding="utf-8")
|
||||
CATALOG = set(re.findall(r'\{\s*"(?:[^"\\]|\\.)*"\s*,\s*"((?:[^"\\]|\\.)*)"\s*,\s*"', P))
|
||||
LIT = re.compile(r'"((?:[^"\\]|\\.)*)"')
|
||||
FR = re.compile(r'[À-ÿ]|\b(?:aucun|aucune|abandonner|annuler|choisir|durée|entrée|exercice|fusion|historique|impossible|mesure|objectif|profil|recherche|séance|synchron|taille|zone|série|charge)\b', re.I)
|
||||
|
||||
# Exact source-bound exceptions, never pattern exemptions. Each is a
|
||||
# non-copy token: punctuation/layout, a protocol token, or an internal key.
|
||||
EXCLUDED = {
|
||||
"tui/src/tui.c": {
|
||||
1083:"—",1495:"— kg",3970:"J−%zu",3973:"M−%zu",3976:"P−%zu",4759:"msg.zone.required",5204:"statistics.zone.previous",5206:"statistics.zone.next",5270:"generator.warning.zone",5612:"—",5655:"— ",5660:"— ",5678:"— ",5693:"—",5696:"—",5820:"—",5823:"—",5826:"—",5881:"—",6312:"%-22s —",6612:"▶",6615:"▶",6643:"▶",6652:"▶",6665:"▶",6668:"▶",6692:"▶",6697:"▶",6888:"%02d/%02d … %02d/%02d",6930:"— ",7040:"PC↔Android",7086:"▶",7310:"%s · %s",7318:"—",7319:"—",7395:"—",7398:"—",7567:"—",7595:"—",7597:"—",7599:"—",7717:"aucun",7719:"aucun",7884:"—",7935:"—",8065:" · ↑",8066:" · ↓"},
|
||||
"tui/src/chart.c": {254:"◆ %.2f %s"},
|
||||
"tui/src/app_shell.c": {317:"…"},
|
||||
"tui/src/sync_screen_action.c": {132:"Android→PC",136:"PC→Android",139:"PC↔Android"},
|
||||
"tui/src/statistics.c": {230:("août", "déc.")},
|
||||
}
|
||||
|
||||
def candidates(text, path):
|
||||
"""Independent source scanner: adding uncatalogued French copy fails."""
|
||||
out=[]
|
||||
for line_no,line in enumerate(text.splitlines(),1):
|
||||
for match in LIT.finditer(line):
|
||||
value=match.group(1)
|
||||
if FR.search(value): out.append((path,line_no,value))
|
||||
return out
|
||||
|
||||
# Non-tautology proof: this never appears in the catalogue/manifest.
|
||||
probe=candidates('trainlog_surface_printf(s, 1, 1, "Bonjour séance");','fixture.c')
|
||||
assert probe == [('fixture.c',1,'Bonjour séance')]
|
||||
assert probe[0][2] not in CATALOG
|
||||
assert RENDERER_MARKER.search('trainlog_surface_printf(s, 1, 1, "new copy")')
|
||||
|
||||
unknown=[]; used=set()
|
||||
for path in SOURCES:
|
||||
for candidate in candidates((ROOT/path).read_text(encoding='utf-8'),path):
|
||||
_,line,value=candidate
|
||||
if value in CATALOG: continue
|
||||
allowed = EXCLUDED.get(path,{}).get(line)
|
||||
if allowed == value or isinstance(allowed, tuple) and value in allowed:
|
||||
used.add(candidate); continue
|
||||
unknown.append(candidate)
|
||||
if unknown:
|
||||
for kind, rows in (("TRANSLATABLE_UI catalogue omissions",unknown),):
|
||||
if rows: sys.stderr.write(kind+':\n'+'\n'.join(' %s:%s: %s'%r for r in rows)+'\n')
|
||||
sys.exit(1)
|
||||
print('TRANSLATABLE_UI=0 unexplained; source-derived exclusions:',len(used))
|
||||
|
|
@ -835,7 +835,9 @@ static TrainlogStatus mock_sync_run(TrainlogSyncTrigger trigger,
|
|||
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);
|
||||
"Synchronisation en cours") != NULL ||
|
||||
strstr(sync_running_app->terminal->output,
|
||||
"Synchronization in progress") != 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));
|
||||
|
|
@ -843,13 +845,23 @@ static TrainlogStatus mock_sync_run(TrainlogSyncTrigger trigger,
|
|||
(void)memset(output, 0, sizeof(*output));
|
||||
output->success = run_number != 2;
|
||||
output->direction = direction;
|
||||
output->exercises_imported = 2U;
|
||||
output->exercises_reconciled = 3U;
|
||||
output->exercises_skipped = 4U;
|
||||
output->sessions_imported = 5U;
|
||||
output->sessions_skipped = 6U;
|
||||
output->body_imported = 7U;
|
||||
output->body_skipped = 8U;
|
||||
output->equipment_definitions_imported = 9U;
|
||||
output->equipment_definitions_skipped = 10U;
|
||||
output->catalog_published = 11U;
|
||||
(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");
|
||||
output->success ? "RÉSUMÉ BRUT succès" : "RÉSUMÉ BRUT échec");
|
||||
if (!output->success)
|
||||
(void)snprintf(output->error, sizeof(output->error), "%s",
|
||||
"Appareil déconnecté pendant la synchronisation.");
|
||||
"DIAGNOSTIC BRUT appareil déconnecté.");
|
||||
CHECK(snprintf(directory, sizeof(directory), "%s/trainlog",
|
||||
sync_journal_root) > 0);
|
||||
CHECK(mkdir(directory, 0700) == 0 || errno == EEXIST);
|
||||
|
|
@ -864,6 +876,53 @@ static TrainlogStatus mock_sync_run(TrainlogSyncTrigger trigger,
|
|||
return output->success ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||
}
|
||||
|
||||
static bool test_sync_result_presenter_maps_typed_statuses_in_both_languages(void)
|
||||
{
|
||||
static const struct {
|
||||
TrainlogStatus status;
|
||||
const char *fr;
|
||||
const char *en;
|
||||
} cases[] = {
|
||||
{TRAINLOG_STATUS_CONFLICT, "une autre exécution est active",
|
||||
"another run is active"},
|
||||
{TRAINLOG_STATUS_NOT_FOUND, "aucun appareil MTP Trainlog détecté",
|
||||
"no Trainlog MTP device detected"},
|
||||
{TRAINLOG_STATUS_INVALID_ARGUMENT, "données ou demande invalides",
|
||||
"invalid data or request"},
|
||||
{TRAINLOG_STATUS_DATABASE_ERROR, "erreur de base de données locale",
|
||||
"local database error"},
|
||||
{TRAINLOG_STATUS_SCHEMA_UNSUPPORTED, "version de données non prise en charge",
|
||||
"unsupported data version"},
|
||||
{TRAINLOG_STATUS_SYSTEM_ERROR, "erreur d’accès appareil ou fichier",
|
||||
"device or file access error"},
|
||||
{TRAINLOG_STATUS_OK, "sans rapport de réussite",
|
||||
"without a success report"},
|
||||
};
|
||||
TrainlogSyncController sync;
|
||||
char presented[768];
|
||||
size_t index;
|
||||
(void)memset(&sync, 0, sizeof(sync));
|
||||
(void)snprintf(sync.report.summary, sizeof(sync.report.summary),
|
||||
"%s", "RÉSUMÉ BRUT interdit");
|
||||
(void)snprintf(sync.report.error, sizeof(sync.report.error),
|
||||
"%s", "DIAGNOSTIC BRUT interdit");
|
||||
for (index = 0U; index < sizeof(cases) / sizeof(cases[0]); ++index) {
|
||||
sync.latest_status = cases[index].status;
|
||||
trainlog_presentation_init();
|
||||
sync_controller_present_result(&sync, presented, sizeof(presented));
|
||||
CHECK(strstr(presented, cases[index].fr) != NULL);
|
||||
CHECK(strstr(presented, "BRUT") == NULL);
|
||||
CHECK(trainlog_presentation_set_language(TRAINLOG_PRESENTATION_ENGLISH));
|
||||
sync_controller_present_result(&sync, presented, sizeof(presented));
|
||||
CHECK(strstr(presented, cases[index].en) != NULL);
|
||||
CHECK(strstr(presented, "BRUT") == NULL);
|
||||
}
|
||||
CHECK(strcmp(sync.report.summary, "RÉSUMÉ BRUT interdit") == 0);
|
||||
CHECK(strcmp(sync.report.error, "DIAGNOSTIC BRUT interdit") == 0);
|
||||
trainlog_presentation_init();
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool test_shell_body_controller_preserves_identity_and_failed_edit(void)
|
||||
{
|
||||
TrainlogDatabase *database = NULL;
|
||||
|
|
@ -993,7 +1052,18 @@ static bool test_shell_sync_f7_runs_bidirectional_and_refreshes_history(void)
|
|||
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);
|
||||
CHECK(strstr(terminal.output, "Synchronisation réussie") != NULL);
|
||||
CHECK(strstr(terminal.output, "exercices +2, réconciliés 3, présents 4") != NULL);
|
||||
CHECK(strstr(terminal.output, "catalogue publié 11") != NULL);
|
||||
CHECK(strstr(terminal.output, "RÉSUMÉ BRUT") == NULL);
|
||||
CHECK(strcmp(app.sync_controller.report.summary, "RÉSUMÉ BRUT succès") == 0);
|
||||
CHECK(trainlog_presentation_set_language(TRAINLOG_PRESENTATION_ENGLISH));
|
||||
terminal.output_used = 0U; terminal.output[0] = '\0';
|
||||
app_shell_render_sync(&app);
|
||||
CHECK(strstr(terminal.output, "Synchronization successful") != NULL);
|
||||
CHECK(strstr(terminal.output, "exercises +2, reconciled 3, present 4") != NULL);
|
||||
CHECK(strstr(terminal.output, "catalog published 11") != NULL);
|
||||
CHECK(strstr(terminal.output, "RÉSUMÉ BRUT") == NULL);
|
||||
|
||||
/* Repeating the action creates the next legitimate journal event. */
|
||||
app_shell_dispatch(&app, 's');
|
||||
|
|
@ -1002,11 +1072,17 @@ static bool test_shell_sync_f7_runs_bidirectional_and_refreshes_history(void)
|
|||
CHECK(sync_run_calls == 2);
|
||||
CHECK(sync_last_direction == TRAINLOG_SYNC_BIDIRECTIONAL);
|
||||
CHECK(!app.sync_controller.report.success);
|
||||
CHECK(app.sync_controller.latest_status == TRAINLOG_STATUS_SYSTEM_ERROR);
|
||||
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);
|
||||
"Synchronization stopped: device or file access error.") != NULL);
|
||||
CHECK(strstr(terminal.output, "DIAGNOSTIC BRUT") == NULL);
|
||||
CHECK(strstr(terminal.output, "RÉSUMÉ BRUT") == NULL);
|
||||
CHECK(strcmp(app.sync_controller.report.error,
|
||||
"DIAGNOSTIC BRUT appareil déconnecté.") == 0);
|
||||
CHECK(strcmp(app.sync_controller.report.summary, "RÉSUMÉ BRUT échec") == 0);
|
||||
CHECK(app_shell_dispatch_sync(&app, 'r'));
|
||||
CHECK(sync_probe_calls == 3 && sync_run_calls == 2);
|
||||
if (saved_data_home != NULL) {
|
||||
|
|
@ -1025,6 +1101,7 @@ static bool test_shell_sync_f7_runs_bidirectional_and_refreshes_history(void)
|
|||
CHECK(rmdir(temporary) == 0);
|
||||
}
|
||||
sync_running_app = NULL;
|
||||
trainlog_presentation_init();
|
||||
trainlog_database_close(database);
|
||||
return true;
|
||||
}
|
||||
|
|
@ -1110,6 +1187,21 @@ static bool test_shell_sync_history_neutralizes_legacy_directions(void)
|
|||
app.sync_controller.showing_detail = false;
|
||||
}
|
||||
|
||||
trainlog_presentation_set_language(TRAINLOG_PRESENTATION_ENGLISH);
|
||||
app.sync_controller.selected = 0U;
|
||||
terminal.output_used = 0U;
|
||||
terminal.output[0] = '\0';
|
||||
CHECK(app_shell_dispatch_sync(&app, TRAINLOG_KEY_ENTER));
|
||||
app_shell_render_sync(&app);
|
||||
CHECK(strstr(terminal.output,
|
||||
"Historical PC↔Android synchronization") != NULL);
|
||||
CHECK(strstr(terminal.output, "Timestamp:") != NULL);
|
||||
CHECK(strstr(terminal.output, "Status:") != NULL);
|
||||
CHECK(strstr(terminal.output, "successful") != NULL ||
|
||||
strstr(terminal.output, "failed") != NULL);
|
||||
CHECK(strstr(terminal.output, "Horodatage") == NULL);
|
||||
trainlog_presentation_init();
|
||||
|
||||
if (saved_data_home != NULL) {
|
||||
CHECK(setenv("XDG_DATA_HOME", saved_data_home, 1) == 0);
|
||||
free(saved_data_home);
|
||||
|
|
@ -2223,11 +2315,11 @@ static bool test_body_profile_snapshot_and_evolution_are_separate(void)
|
|||
|
||||
dashboard_prepare_render(&app, &terminal, &surface, 72, 20, 72, 16);
|
||||
app_shell_render_body_profile(&app);
|
||||
CHECK(strstr(terminal.output, "Poids : 80.00 kg") != NULL);
|
||||
CHECK(strstr(terminal.output, "Poids : 80,00 kg") != NULL);
|
||||
CHECK(strstr(terminal.output, "Poitrine") != NULL);
|
||||
CHECK(strstr(terminal.output, "100.0 cm") != NULL);
|
||||
CHECK(strstr(terminal.output, "100,0 cm") != NULL);
|
||||
CHECK(strstr(terminal.output, "Tour de taille") != NULL);
|
||||
CHECK(strstr(terminal.output, "85.0 cm") != NULL);
|
||||
CHECK(strstr(terminal.output, "85,0 cm") != NULL);
|
||||
CHECK(strstr(terminal.output, "Épaules") == NULL);
|
||||
CHECK(rendered_draw_column(&terminal, 0x25c6U, 0U) >= 0);
|
||||
CHECK(rendered_draw_column(&terminal, 0x2586U, 0U) >= 0);
|
||||
|
|
@ -2257,9 +2349,9 @@ static bool test_body_profile_snapshot_and_evolution_are_separate(void)
|
|||
CHECK(app.list.selected_index == 1U);
|
||||
dashboard_prepare_render(&app, &terminal, &surface, 80, 24, 80, 20);
|
||||
app_shell_render_body_profile(&app);
|
||||
CHECK(strstr(terminal.output, "Poids : 78.00 kg") != NULL);
|
||||
CHECK(strstr(terminal.output, "90.0 cm") != NULL);
|
||||
CHECK(strstr(terminal.output, "100.0 cm") == NULL);
|
||||
CHECK(strstr(terminal.output, "Poids : 78,00 kg") != NULL);
|
||||
CHECK(strstr(terminal.output, "90,0 cm") != NULL);
|
||||
CHECK(strstr(terminal.output, "100,0 cm") == NULL);
|
||||
|
||||
app.body_metric_selected = 0U;
|
||||
app_shell_move_available_body_metric(&app, 1);
|
||||
|
|
@ -2305,7 +2397,7 @@ static bool test_statistics_center_renders_and_cycles_factual_views(void)
|
|||
app_shell_render_statistics_training(&app);
|
||||
CHECK(strstr(terminal.output, "ACTIVITÉ") != NULL);
|
||||
CHECK(strstr(terminal.output, "TRAVAIL") != NULL);
|
||||
CHECK(strstr(terminal.output, "Volume 3200.0 kg") != NULL);
|
||||
CHECK(strstr(terminal.output, "Volume 3200,0 kg") != NULL);
|
||||
CHECK(strstr(terminal.output,
|
||||
"Volume chargé · mois calendaires") != NULL);
|
||||
CHECK(!terminal.coordinate_overflow && !terminal.text_overflow);
|
||||
|
|
@ -2366,6 +2458,14 @@ static bool test_statistics_center_renders_and_cycles_factual_views(void)
|
|||
CHECK(strstr(terminal.output, "Développé couché") != NULL);
|
||||
CHECK(strstr(terminal.output, "Charge observée") != NULL);
|
||||
CHECK(!terminal.coordinate_overflow && !terminal.text_overflow);
|
||||
trainlog_presentation_set_language(TRAINLOG_PRESENTATION_ENGLISH);
|
||||
terminal.output_used = 0U;
|
||||
terminal.output[0] = '\0';
|
||||
app_shell_render_exercise_statistics(&app);
|
||||
CHECK(strstr(terminal.output, "Développé couché") != NULL);
|
||||
CHECK(strstr(terminal.output, "Observed load") != NULL);
|
||||
CHECK(strstr(terminal.output, "Charge observée") == NULL);
|
||||
trainlog_presentation_init();
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -2386,6 +2486,7 @@ int main(void)
|
|||
!test_shell_compact_navigation_focus_contract() ||
|
||||
!test_shell_detail_back_restores_stable_list_selection() ||
|
||||
!test_shell_body_controller_preserves_identity_and_failed_edit() ||
|
||||
!test_sync_result_presenter_maps_typed_statuses_in_both_languages() ||
|
||||
!test_shell_sync_f7_runs_bidirectional_and_refreshes_history() ||
|
||||
!test_shell_sync_history_neutralizes_legacy_directions() ||
|
||||
!test_equipment_collector_finds_match_after_first_pages() ||
|
||||
|
|
|
|||
Loading…
Reference in a new issue