feat(tui): add bilingual presentation

This commit is contained in:
fy59 2026-09-16 07:46:01 +02:00
parent 1674059203
commit d4b56a3aa5
10 changed files with 2292 additions and 484 deletions

View 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

View file

@ -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(),
)

View file

@ -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)

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -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");
}

File diff suppressed because it is too large Load diff

View 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;
}

View 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))

View file

@ -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() ||