feat(exercises): add canonical naming and finalize app shell

This commit is contained in:
fy59 2026-09-11 14:16:31 +02:00
parent 1f027743f2
commit b2249b10b1
31 changed files with 543 additions and 65 deletions

View file

@ -338,3 +338,7 @@ Added:
Estimated analytics remain derived display values and are never persisted as
direct measurements. Android remains capture-only for this feature.
- Hid `SESSION_GENERATOR_V1` from normal UI pending V2; manual `%MAX` entry is unchanged.
- Added versioned canonical exercise display names, stale-peer-name convergence,
and the STATS_V1 query-contract preparation (no analytics implementation).

View file

@ -346,3 +346,7 @@ BODY_PROPORTION_RATIOS=PASS
BODY_SYMMETRY_ANALYTICS=PASS
DESKTOP_TESTS=39/39 PASS
```
`SESSION_GENERATOR_V1` remains implemented but is hidden from normal Android
and TUI navigation pending V2. `catalog/exercise-names-v1.json` owns mapped
exercise display metadata across sync while preserving stable IDs and history.

View file

@ -98,7 +98,6 @@ class DraftUiTestActivity : ComponentActivity() {
onBody = {},
onOpenLatestSession = {},
onSync = {},
onGenerateSession = {},
)
}
}

View file

@ -0,0 +1,61 @@
package com.labfytools.trainlog.data
import android.content.Context
import org.json.JSONObject
import java.text.Normalizer
import java.util.Locale
import java.util.UUID
/** Strict shared owner map for presentation names of known stable exercise IDs. */
object ExerciseNameCatalog {
private const val ASSET_NAME = "exercise-names-v1.json"
fun load(context: Context): Map<String, String> {
val root = context.assets.open(ASSET_NAME).bufferedReader().use {
JSONObject(it.readText())
}
check(root.keys().asSequence().toSet() == setOf("format", "version", "names"))
check(root.getString("format") == "trainlog-exercise-names-v1")
check(root.getInt("version") == 1)
val names = root.getJSONArray("names")
val result = linkedMapOf<String, String>()
val canonicalOwners = mutableSetOf<String>()
val previousOwners = mutableSetOf<String>()
for (index in 0 until names.length()) {
val item = names.getJSONObject(index)
check(item.keys().asSequence().toSet() == setOf(
"exercise_id", "display_name", "normalized_name", "previous_display_names",
))
val exerciseId = item.getString("exercise_id")
check(exerciseId.startsWith("ex_"))
val uuid = UUID.fromString(exerciseId.removePrefix("ex_"))
check(uuid.version() == 4 && "ex_$uuid" == exerciseId)
val displayName = item.getString("display_name")
val normalizedName = normalizeManifestName(displayName)
check(displayName == displayName.trim() && item.getString("normalized_name") == normalizedName)
check(result.put(exerciseId, displayName) == null)
check(canonicalOwners.add(normalizedName))
val previous = item.getJSONArray("previous_display_names")
check(previous.length() > 0)
val localPrevious = mutableSetOf<String>()
for (previousIndex in 0 until previous.length()) {
val oldName = previous.getString(previousIndex)
val oldNormalized = normalizeManifestName(oldName)
check(oldName == oldName.trim() && oldNormalized != normalizedName)
check(localPrevious.add(oldNormalized) && previousOwners.add(oldNormalized))
}
}
check(canonicalOwners.intersect(previousOwners).isEmpty())
return result
}
private fun normalizeManifestName(value: String): String {
val normalized = Normalizer.normalize(value.lowercase(Locale.ROOT), Normalizer.Form.NFC)
.replace(Regex("\\s+"), " ")
.trim()
check(normalized.isNotEmpty())
return normalized
}
}

View file

@ -274,6 +274,7 @@ class TrainlogRepository(
ANDROID_DATABASE_NAME,
) {
private val applicationContext = context.applicationContext
private val canonicalExerciseNames = ExerciseNameCatalog.load(applicationContext)
private val bodyZones = BodyZoneCatalog.load(applicationContext)
private val trainingKnowledge = TrainingKnowledgeCatalog.load(applicationContext, bodyZones)
private val sessionGenerationPolicy =
@ -1534,10 +1535,8 @@ class TrainlogRepository(
val exerciseId = resolveExerciseId(db, suppliedExerciseId)
val suppliedRetiredAlias = suppliedExerciseId != exerciseId
val name =
item.getString(
"name"
)
val suppliedName = item.getString("name")
val name = canonicalExerciseNames[exerciseId] ?: suppliedName
val normalized =
normalizeName(
@ -1600,6 +1599,12 @@ class TrainlogRepository(
}
if (
!NewExerciseProfile(
name = suppliedName,
recordingMode = recording,
trackingMode = tracking,
dataFields = dataFields,
).validate() ||
!NewExerciseProfile(
name = name,
recordingMode = recording,

View file

@ -14,7 +14,6 @@ fun HomeScreen(
latestSession: SessionSummary?,
latestMaximum: LatestExerciseMax?,
onSession: () -> Unit,
onGenerateSession: () -> Unit,
onBody: () -> Unit,
onOpenLatestSession: (String) -> Unit,
onSync: () -> Unit,
@ -26,10 +25,8 @@ fun HomeScreen(
if (activeDraft != null) {
val kind = if (activeDraft.sessionType == SessionType.MAX_TEST) "Test max" else "Entraînement"
TrainlogPrimaryAction("Reprendre la séance en cours", "$kind · ${activeDraft.exercises.size} exercice(s)", onSession)
TrainlogAction("Programmer une séance", "La proposition sera conservée séparément.", onGenerateSession)
} else {
TrainlogPrimaryAction("Programmer une séance", "Préparer une proposition modifiable à partir d'une zone, d'un objectif et d'une durée.", onGenerateSession)
TrainlogAction("Nouvelle séance manuelle", "Créer explicitement un brouillon durable.", onSession)
TrainlogPrimaryAction("Nouvelle séance manuelle", "Créer explicitement un brouillon durable.", onSession)
}
}
latestSession?.let { session ->

View file

@ -20,7 +20,6 @@ import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
fun SessionsHub(
draft: ActiveSessionDraft?,
onResume: () -> Unit,
onGenerate: () -> Unit,
onManual: () -> Unit,
onHistory: () -> Unit,
) {
@ -31,7 +30,6 @@ fun SessionsHub(
else TrainlogAction("Reprendre", "${draft.exercises.size} exercice(s) · le brouillon durable est conservé.", onResume, accent = colors.success)
}
TrainlogFrame("Préparer") {
TrainlogAction("Programmer une séance", "Générer une proposition modifiable.", onGenerate, accent = if (draft == null) colors.success else colors.accent)
TrainlogAction("Nouvelle séance manuelle", if (draft == null) "Créer explicitement un brouillon de séance." else "Ouvrir la séance en cours sans l'écraser.", onManual)
}
TrainlogAction("Séances effectuées", "Consulter les actuals, plans et MAX enregistrés.", onHistory)

View file

@ -76,10 +76,10 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
AppRoute.Home -> HomeScreen(
activeDraft, draftMessage ?: (draftLoad as? ActiveDraftLoadResult.Error)?.message ?: (draftLoad as? ActiveDraftLoadResult.Loaded)?.warning,
repository.listSessions().firstOrNull(), repository.listLatestExerciseMaxima().firstOrNull(),
::openManualSession, { open(AppRoute.SessionGenerator) },
::openManualSession,
{ open(AppRoute.BodyMeasurements) }, { open(AppRoute.SessionDetail(it)) }, { open(AppRoute.Sync) },
)
AppRoute.Sessions -> SessionsHub(activeDraft, { open(AppRoute.SessionEditor) }, { open(AppRoute.SessionGenerator) }, ::openManualSession, { open(AppRoute.CompletedSessions) })
AppRoute.Sessions -> SessionsHub(activeDraft, { open(AppRoute.SessionEditor) }, ::openManualSession, { open(AppRoute.CompletedSessions) })
AppRoute.SessionEditor -> SessionScreen(repository, catalogRevision, { draftRevision++; back() }, { open(AppRoute.ExerciseCreate(AppRoute.SessionEditor)) }, { exporter.exportMobileBundle(); draftRevision++ })
AppRoute.SessionGenerator -> SessionGeneratorScreen(repository, generatorState, { back() }, { generatorState.abandon(); draftRevision++; open(AppRoute.SessionEditor) }, {
draftMessage = "Une séance est déjà en cours. Reprenez-la ou revenez à la proposition conservée."

View file

@ -590,6 +590,49 @@ class TrainlogRepositoryDraftTest {
assertEquals(1, repo.listExercises().count { it.exerciseId == canonicalId })
}
@Test
fun pcCatalogUsesCanonicalMappedNameButLeavesDistinctAndCustomIdsAlone() {
val repo = openRepository()
val mappedId = "ex_a1ef5047-b44b-4c64-a6ed-c7a3bc13b163"
val distinctId = "ex_617007f9-7420-4408-91b9-8ffb77900f13"
val customId = "ex_fcc75fa7-671e-4868-bab2-47033128dfb7"
fun item(id: String, name: String) = JSONObject()
.put("exercise_id", id)
.put("name", name)
.put("recording_mode", "sets")
.put("tracking_mode", "reps")
.put("data_fields", 0)
val stalePcCatalog = JSONObject()
.put("format", "trainlog-pc-catalog")
.put("version", 1)
.put(
"exercises",
org.json.JSONArray()
.put(item(mappedId, "Seated leg curl"))
.put(item(distinctId, "Seated Leg"))
.put(item(customId, "My custom curl")),
)
assertEquals(
PcCatalogImportResult.Applied(imported = 3, reconciled = 0, skipped = 0),
repo.applyPcCatalogJson(stalePcCatalog.toString()),
)
val byId = repo.listExercises().associateBy { it.exerciseId }
assertEquals("Flexion de genou assise", byId.getValue(mappedId).name)
assertEquals("Seated Leg", byId.getValue(distinctId).name)
assertEquals("My custom curl", byId.getValue(customId).name)
/* Replaying the stale mapped label is idempotent and cannot restore it. */
assertEquals(
PcCatalogImportResult.Applied(imported = 0, reconciled = 0, skipped = 3),
repo.applyPcCatalogJson(stalePcCatalog.toString()),
)
assertEquals(
"Flexion de genou assise",
repo.listExercises().single { it.exerciseId == mappedId }.name,
)
}
@Test
fun marcheReconciliationKeepsRicherProfileAndTwoOccurrenceValues() {
val repo = openRepository()

View file

@ -0,0 +1,22 @@
{
"format": "trainlog-exercise-names-v1",
"version": 1,
"names": [
{"exercise_id":"ex_01ff06dd-ad00-46ee-9b46-ed32cabedbef","display_name":"Adduction de hanche assise","normalized_name":"adduction de hanche assise","previous_display_names":["Hip adduction"]},
{"exercise_id":"ex_1872246a-39ae-44dc-b58d-f87e90ca49ab","display_name":"Extension de genou assise","normalized_name":"extension de genou assise","previous_display_names":["Leg extension"]},
{"exercise_id":"ex_1a34814c-2e46-40fc-b1f4-6d60b8e5a3e0","display_name":"Rotation du tronc à la machine","normalized_name":"rotation du tronc à la machine","previous_display_names":["Rotary torso"]},
{"exercise_id":"ex_1b0c6b8b-b05e-4e6f-8809-5f7d85d668de","display_name":"Tirage horizontal divergent assis","normalized_name":"tirage horizontal divergent assis","previous_display_names":["Diverged seated row"]},
{"exercise_id":"ex_33f79331-871c-4eed-babe-346e53a99070","display_name":"Tirage horizontal assis","normalized_name":"tirage horizontal assis","previous_display_names":["Seated row"]},
{"exercise_id":"ex_474ec393-3efa-4aaa-8e08-1a0245ed7835","display_name":"Extension du tronc","normalized_name":"extension du tronc","previous_display_names":["Back extension"]},
{"exercise_id":"ex_4cd2433e-80b1-478a-b8df-73fc6ef80962","display_name":"Écarté inversé à la machine","normalized_name":"écarté inversé à la machine","previous_display_names":["Rear Delt"]},
{"exercise_id":"ex_6dfc7ffd-8891-464e-a995-808baf1b0d7b","display_name":"Développé épaules convergent","normalized_name":"développé épaules convergent","previous_display_names":["Converging Shoulder Press"]},
{"exercise_id":"ex_7e7cf906-2214-4066-bcb7-c16382d83b3b","display_name":"Abduction de hanche assise","normalized_name":"abduction de hanche assise","previous_display_names":["Hip abduction"]},
{"exercise_id":"ex_9adf7566-f10c-443c-b63b-681d37665693","display_name":"Crunch à la machine","normalized_name":"crunch à la machine","previous_display_names":["Abdominal crunch"]},
{"exercise_id":"ex_a1ef5047-b44b-4c64-a6ed-c7a3bc13b163","display_name":"Flexion de genou assise","normalized_name":"flexion de genou assise","previous_display_names":["Seated leg curl"]},
{"exercise_id":"ex_a72fa713-4b0e-431d-95e2-42d95beb77b1","display_name":"Tirage vertical à la poulie","normalized_name":"tirage vertical à la poulie","previous_display_names":["Lat pull"]},
{"exercise_id":"ex_b432623f-bfe9-4daf-a653-60ec7fdffbde","display_name":"Presse à cuisses","normalized_name":"presse à cuisses","previous_display_names":["Leg press"]},
{"exercise_id":"ex_b4d1daf1-de4a-4016-abdf-487bf6014ce6","display_name":"Tirage vertical divergent","normalized_name":"tirage vertical divergent","previous_display_names":["Diverging lat pulldown"]},
{"exercise_id":"ex_d7398d9f-d928-4d2e-94e9-74e201da55c5","display_name":"Flexion de genou couchée","normalized_name":"flexion de genou couchée","previous_display_names":["Prone leg curl"]},
{"exercise_id":"ex_ec619fc2-4685-4044-873c-86764bd4a0fe","display_name":"Flexion de coude à la machine","normalized_name":"flexion de coude à la machine","previous_display_names":["Arm curl"]}
]
}

View file

@ -637,3 +637,7 @@ The tranche is `TRAINING_KNOWLEDGE_V1=PASS`. The Android loader enforces canonic
and equipment identity syntax, bidirectional exercise/capability compatibility,
HIGH evidence source type, and non-unresolved BODY ZONE audit evidence. Its
full source and uncertainty contract is in [Training knowledge system V1](domain/knowledge_system.md).
The normal shell exposes manual sessions but not SESSION_GENERATOR_V1 pending
V2. When importing the PC catalog, Android applies the versioned canonical
exercise-name mapping for mapped IDs; unmapped user exercises remain editable.

View file

@ -567,3 +567,8 @@ fails explicitly. The initial full-tranche audit's stale temporal-documentation,
Android loader, Meson input, and C role-only query findings were resolved by one
bounded repair chain and independently verified. Its full contract, uncertainty boundary and
future-only planning architecture are in [Training knowledge system V1](domain/knowledge_system.md).
Canonical mapped exercise names are a separate, small versioned presentation
catalog. Stable `exercise_id` owns both display and normalized metadata for
mapped IDs during PC/Android reconciliation; it does not alter observations,
aliases, BODY ZONES, or equipment context.

View file

@ -487,3 +487,11 @@ needed for the optional body-fat estimate.
No estimated body-fat, fat-mass, lean-mass, ratio, or asymmetry value is stored
as if it were a real measurement.
## Shell and naming closeout
`SESSION_GENERATOR_V1` is technically retained but hidden from normal UI pending
V2. Manual `%MAX` remains available. `exercise-names-v1.json` supplies
movement-oriented canonical metadata for mapped stable IDs; stale peer labels
cannot overwrite those names, while custom exercise naming remains editable.
STATS_V1 is prepared only in `reviews/stats_v1_design.md`.

View file

@ -618,3 +618,7 @@ The temporal contract has independent PASS evidence. The initial audit's stale
temporal documentation, Android loader, Meson input, and C role-only query
findings were repaired, independently verified, and final-validated;
`TRAINING_KNOWLEDGE_V1=PASS`.
Exercise naming changes update only `exercises.name` and `normalized_name` for
explicitly mapped stable IDs; no schema migration or historical-row rewrite is
required.

View file

@ -352,3 +352,7 @@ the same comparison; malformed cursors or matching stored timestamps report
explicit errors. The initial audit's stale temporal documentation, Android
loader, Meson input, and C role-only query findings were repaired and
independently verified; final validation passed.
Exercise names are mutable display metadata. The versioned canonical-name map
applies only to listed stable IDs and never makes two distinct exercises,
equipment contexts, MAX observations, or historical occurrences equivalent.

View file

@ -0,0 +1,37 @@
# EXERCISE_NAMING_V1 audit
The mapping in `catalog/exercise-names-v1.json` is a presentation-metadata
mapping keyed solely by stable `exercise_id`. It does not create IDs, merge
rows, or rewrite session observations. Its evidence is the resolved movement
interpretation in `catalog/exercise-knowledge-v1.json`.
| id | current | proposed | status / confidence | action |
| --- | --- | --- | --- | --- |
| ex_01ff06dd-ad00-46ee-9b46-ed32cabedbef | Hip adduction | Adduction de hanche assise | resolved/high | SAFE_RENAME |
| ex_1872246a-39ae-44dc-b58d-f87e90ca49ab | Leg extension | Extension de genou assise | resolved/high | SAFE_RENAME |
| ex_1a34814c-2e46-40fc-b1f4-6d60b8e5a3e0 | Rotary torso | Rotation du tronc à la machine | resolved/moderate | SAFE_RENAME |
| ex_1b0c6b8b-b05e-4e6f-8809-5f7d85d668de | Diverged seated row | Tirage horizontal divergent assis | resolved/moderate | SAFE_RENAME |
| ex_33f79331-871c-4eed-babe-346e53a99070 | Seated row | Tirage horizontal assis | resolved/moderate | SAFE_RENAME |
| ex_474ec393-3efa-4aaa-8e08-1a0245ed7835 | Back extension | Extension du tronc | resolved/moderate | SAFE_RENAME |
| ex_4cd2433e-80b1-478a-b8df-73fc6ef80962 | Rear Delt | Écarté inversé à la machine | resolved/moderate | SAFE_RENAME |
| ex_6dfc7ffd-8891-464e-a995-808baf1b0d7b | Converging Shoulder Press | Développé épaules convergent | resolved/moderate | SAFE_RENAME |
| ex_7e7cf906-2214-4066-bcb7-c16382d83b3b | Hip abduction | Abduction de hanche assise | resolved/moderate | SAFE_RENAME |
| ex_9adf7566-f10c-443c-b63b-681d37665693 | Abdominal crunch | Crunch à la machine | resolved/moderate | SAFE_RENAME |
| ex_a1ef5047-b44b-4c64-a6ed-c7a3bc13b163 | Seated leg curl | Flexion de genou assise | resolved/high | SAFE_RENAME |
| ex_a72fa713-4b0e-431d-95e2-42d95beb77b1 | Lat pull | Tirage vertical à la poulie | resolved/moderate | SAFE_RENAME |
| ex_b432623f-bfe9-4daf-a653-60ec7fdffbde | Leg press | Presse à cuisses | resolved/moderate | SAFE_RENAME |
| ex_b4d1daf1-de4a-4016-abdf-487bf6014ce6 | Diverging lat pulldown | Tirage vertical divergent | resolved/moderate | SAFE_RENAME |
| ex_d7398d9f-d928-4d2e-94e9-74e201da55c5 | Prone leg curl | Flexion de genou couchée | resolved/high | SAFE_RENAME |
| ex_ec619fc2-4685-4044-873c-86764bd4a0fe | Arm curl | Flexion de coude à la machine | resolved/high | SAFE_RENAME |
`Abdominal`, `Chest press`, and `Converting chest press` are conditional with
uncertain identity and are intentionally unchanged. `Gym échauffement`,
`Marche`, `Planche`, and custom floor variants are human-readable or have no
resolved catalog mapping and remain unchanged. The user explicitly confirmed
that `Seated Leg` (`ex_617007f9-7420-4408-91b9-8ffb77900f13`) and `Seated leg
curl` (`ex_a1ef5047-b44b-4c64-a6ed-c7a3bc13b163`) are distinct exercises on
different physical machines. They are **NOT_DUPLICATE**: no merge, shared MAX,
shared performance context, or history collapse is permitted. `Seated Leg`
therefore remains unchanged until its exact identity has independent evidence;
only the independently named `Seated leg curl` receives the resolved seated
knee-flexion label.

View file

@ -0,0 +1,30 @@
# STATS_V1 preparation
STATS_V1 is not implemented and needs no schema change. Queries read completed
sessions only: `sessions.ended_at IS NOT NULL`, `session_exercises`,
`performed_sets`, `continuous_activity`, `max_results`, `exercise_body_zones`,
and `body_observations`. Drafts and planned targets are never frequency or
volume facts.
Proposed query boundary: date-windowed session count; completed exercise
occurrence count; performed-set count; continuous duration; per-exercise
working-weight observations; explicit MAX chronology; body-observation series;
and BODY ZONE/movement-pattern projections through the read-only knowledge
catalog. Windows use local calendar week/month derived from RFC3339 timestamps;
missing actual sets, duration, equipment, or load are omitted from that metric,
never coerced to zero.
Comparisons group first by `exercise_id`, then equipment context and load mode.
External resistance on different equipment IDs is not comparable; assistance is
not external resistance. A trend may label equipment-specific series but must
not blend them. Body-zone frequency counts completed occurrences/sets through
the existing primary/secondary hierarchy without double-counting parent and
child zones. Movement patterns are read-only knowledge projections; unresolved
exercises remain unclassified.
Android starts with overview, frequency, progress, exercise, zones,
mensurations and MAX cards; TUI uses the same query contract with tables and
small text sparklines. Validate empty windows, DST/date boundaries, duplicate
aliases, heterogeneous sets, continuous-only records, NULL equipment, planned
but unperformed targets, and fixture parity. Caches are future-only and require
an explicit schema tranche.

View file

@ -436,3 +436,14 @@ assistance interpreted as external load
body-composition estimates presented as direct measurements
incompatible changes to Trainlog JSON v1 without a new format version
```
## Session generator V2
V1 remains technically implemented but hidden from normal UI. V2 must construct
the requested duration with warm-up, appropriate ramp-up, coherent main work,
cool-down, transition/setup time, and explicit candidate-scarcity handling.
## STATS_V1
Prepared only: see `reviews/stats_v1_design.md`. Implementation remains a
future derived-query tranche with no new summary schema in this checkpoint.

View file

@ -651,3 +651,7 @@ The historical V1 fallback never consumes a V2 equipment companion: V1 carries
no occurrence-level equipment signal, so a neighboring V2 companion belongs to
a different generation and is ignored. This preserves rather than clears
existing equipment.
Mapped canonical exercise names are metadata owned by `exercise_id`: PC exports
them and both import paths prevent stale mapped peer labels from restoring an
old display name. This does not change TRAINLOG_FORMAT_V1.

View file

@ -638,3 +638,7 @@ verified. See the [temporal contract](reviews/training_knowledge_v1_temporal_con
for the established contract. No real Android install,
manual TUI visual exercise, or manual MTP hardware validation was performed
for Training Knowledge V1.
Shell/naming closeout coverage includes real-PTY shell behavior, canonical
name export/import replay on temporary databases, and preservation of sets,
MAX, equipment, BODY ZONES, aliases, and distinct unmapped exercises.

View file

@ -547,3 +547,7 @@ exclusive source-text cursors, bounded selection before hydration, and one
read snapshot per call. Malformed cursors and matching stored timestamps fail
explicitly. The full-tranche audit repair chain and independent verification
passed. See [Training knowledge system V1](domain/knowledge_system.md).
The normal Sessions hub has current/manual/completed paths only; the retained
SESSION_GENERATOR_V1 route is not advertised. Physical `/` and F7 Rechercher
dispatch the one registered list-search action on supported lists.

View file

@ -23,6 +23,8 @@ DESKTOP_WALK_ID = "ex_b1e6ffc6-75b5-45ff-a3c0-e7433c58013d"
ANDROID_WALK_ID = "ex_23212d79-52ce-4195-914d-dd983f133936"
ANDROID_SESSION_ID = "se_ac3908d6-8e3e-4ac6-81a6-62dc2c39075a"
ANDROID_WALK_ENTRY_ID = "sxe_f25142c8-455e-4346-9bfc-31d0989e275d"
SEATED_CURL_ID = "ex_a1ef5047-b44b-4c64-a6ed-c7a3bc13b163"
SEATED_LEG_DISTINCT_ID = "ex_617007f9-7420-4408-91b9-8ffb77900f13"
SCHEMA = """
PRAGMA foreign_keys=ON;
@ -485,6 +487,96 @@ def main():
assert before == after
assert_no_integrity_error(connection)
# Canonical-name ownership is ID-scoped. A stale Android catalogue
# cannot rename the mapped curl back, while the distinct ex_617...
# identity and an unmapped custom exercise keep their own semantics.
names_db = root / "canonical-names.db"
create_database(names_db, fields=1)
with sqlite3.connect(names_db) as connection:
connection.execute("DELETE FROM exercises")
for exercise_id, name in (
(SEATED_CURL_ID, "Seated leg curl"),
(SEATED_LEG_DISTINCT_ID, "Seated Leg"),
("ex_fcc75fa7-671e-4868-bab2-47033128dfb7", "Custom curl"),
):
connection.execute(
"INSERT INTO exercises(exercise_id,name,normalized_name,tracking_mode,recording_mode,data_fields) "
"VALUES(?,?,lower(?),'reps','sets',0)",
(exercise_id, name, name),
)
exercise_row = connection.execute(
"SELECT id FROM exercises WHERE exercise_id=?", (SEATED_CURL_ID,)
).fetchone()[0]
session_row = connection.execute(
"INSERT INTO sessions(session_id,started_at,session_type) VALUES(?,?,?)",
("se_c6daf1c6-b98b-4f18-8583-72e8c7cc3c99", "2026-09-10T08:00:00+02:00", "max_test"),
).lastrowid
set_entry = connection.execute(
"INSERT INTO session_exercises(entry_id,session_row_id,exercise_row_id,recording_mode,data_fields,position,load_mode,rest_seconds,equipment_id) "
"VALUES(?,?,?,'sets',0,0,'none',0,'seated_leg_curl')",
("sxe_names_set", session_row, exercise_row),
).lastrowid
connection.execute(
"INSERT INTO performed_sets(session_exercise_row_id,position,reps,weight_kg) VALUES(?,0,11,42.5)",
(set_entry,),
)
max_entry = connection.execute(
"INSERT INTO session_exercises(entry_id,session_row_id,exercise_row_id,recording_mode,data_fields,position,load_mode,rest_seconds,equipment_id) "
"VALUES(?,?,?,'sets',0,1,'none',0,'seated_leg_curl')",
("sxe_names_max", session_row, exercise_row),
).lastrowid
connection.execute(
"INSERT INTO max_results(session_exercise_row_id,max_weight_kg) VALUES(?,?)",
(max_entry, 73.0),
)
connection.execute(
"CREATE TABLE exercise_body_zones(exercise_row_id INTEGER,zone_id TEXT,role TEXT)"
)
connection.execute(
"INSERT INTO exercise_body_zones VALUES(?,'thighs','primary')", (exercise_row,)
)
connection.commit()
stale = {
"format": "trainlog-mobile-export", "version": 2,
"generated_at": "2026-09-10T09:00:00+02:00",
"exercises": [
{"exercise_id": SEATED_CURL_ID, "name": "Seated leg curl", "recording_mode": "sets", "tracking_mode": "reps", "data_fields": 0},
{"exercise_id": SEATED_LEG_DISTINCT_ID, "name": "Seated Leg", "recording_mode": "sets", "tracking_mode": "reps", "data_fields": 0},
{"exercise_id": "ex_fcc75fa7-671e-4868-bab2-47033128dfb7", "name": "Custom curl", "recording_mode": "sets", "tracking_mode": "reps", "data_fields": 0},
],
"sessions": [], "body_observations": [],
}
stale_path = root / "stale-names.json"
stale_path.write_text(json.dumps(stale), encoding="utf-8")
run(IMPORT_MOBILE, stale_path, "--database", names_db)
run(IMPORT_MOBILE, stale_path, "--database", names_db)
names_catalog = root / "names-pc-catalog.json"
names_mobile = root / "names-pc-mobile.json"
run(EXPORT_CATALOG, names_catalog, "--database", names_db)
run(EXPORT_MOBILE, names_mobile, "--database", names_db, "--version", "2")
with sqlite3.connect(names_db) as connection:
assert connection.execute(
"SELECT name FROM exercises WHERE exercise_id=?", (SEATED_CURL_ID,)
).fetchone()[0] == "Flexion de genou assise"
assert connection.execute(
"SELECT name FROM exercises WHERE exercise_id=?", (SEATED_LEG_DISTINCT_ID,)
).fetchone()[0] == "Seated Leg"
assert connection.execute(
"SELECT name FROM exercises WHERE exercise_id='ex_fcc75fa7-671e-4868-bab2-47033128dfb7'"
).fetchone()[0] == "Custom curl"
assert connection.execute("SELECT reps,weight_kg FROM performed_sets").fetchall() == [(11, 42.5)]
assert connection.execute("SELECT max_weight_kg FROM max_results").fetchall() == [(73.0,)]
assert connection.execute("SELECT equipment_id FROM session_exercises ORDER BY position").fetchall() == [("seated_leg_curl",), ("seated_leg_curl",)]
assert connection.execute("SELECT zone_id,role FROM exercise_body_zones").fetchall() == [("thighs", "primary")]
for artifact in (names_catalog, names_mobile):
exercises = json.loads(artifact.read_text(encoding="utf-8"))["exercises"]
by_id = {item["exercise_id"]: item["name"] for item in exercises}
assert by_id[SEATED_CURL_ID] == "Flexion de genou assise"
assert by_id[SEATED_LEG_DISTINCT_ID] == "Seated Leg"
assert by_id["ex_fcc75fa7-671e-4868-bab2-47033128dfb7"] == "Custom curl"
print("PASS exercise_reconciliation")

83
tools/exercise_names.py Normal file
View file

@ -0,0 +1,83 @@
#!/usr/bin/env python3
"""Strict reader for the shared canonical exercise-name ownership map."""
import json
import unicodedata
import uuid
from pathlib import Path
CATALOG_PATH = (
Path(__file__).resolve().parents[1] / "catalog" / "exercise-names-v1.json"
)
class ExerciseNameCatalogError(ValueError):
pass
def normalize_catalog_name(value):
if not isinstance(value, str):
raise ExerciseNameCatalogError("nom canonique non textuel")
normalized = " ".join(unicodedata.normalize("NFC", value.casefold()).split())
if not normalized:
raise ExerciseNameCatalogError("nom canonique vide")
return normalized
def load_exercise_names(path=CATALOG_PATH):
try:
root = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as error:
raise ExerciseNameCatalogError(
f"catalogue de noms d'exercices illisible: {error}"
) from error
if not isinstance(root, dict) or set(root) != {"format", "version", "names"}:
raise ExerciseNameCatalogError("forme racine exercise-names-v1 invalide")
if root["format"] != "trainlog-exercise-names-v1" or root["version"] != 1:
raise ExerciseNameCatalogError("format/version exercise-names-v1 invalide")
if not isinstance(root["names"], list):
raise ExerciseNameCatalogError("liste names absente")
result = {}
normalized_owners = set()
previous_names = set()
for index, item in enumerate(root["names"]):
label = f"names[{index}]"
if not isinstance(item, dict) or set(item) != {
"exercise_id", "display_name", "normalized_name", "previous_display_names"
}:
raise ExerciseNameCatalogError(f"{label}: forme invalide")
exercise_id = item["exercise_id"]
if not isinstance(exercise_id, str) or not exercise_id.startswith("ex_"):
raise ExerciseNameCatalogError(f"{label}: exercise_id invalide")
try:
parsed = uuid.UUID(exercise_id[3:])
except (ValueError, AttributeError) as error:
raise ExerciseNameCatalogError(f"{label}: exercise_id invalide") from error
if parsed.version != 4 or str(parsed) != exercise_id[3:]:
raise ExerciseNameCatalogError(f"{label}: exercise_id UUIDv4 invalide")
display_name = item["display_name"]
normalized_name = normalize_catalog_name(display_name)
if display_name != display_name.strip() or item["normalized_name"] != normalized_name:
raise ExerciseNameCatalogError(f"{label}: nom normalisé incohérent")
if exercise_id in result or normalized_name in normalized_owners:
raise ExerciseNameCatalogError(f"{label}: identité canonique dupliquée")
previous = item["previous_display_names"]
if not isinstance(previous, list) or not previous:
raise ExerciseNameCatalogError(f"{label}: previous_display_names invalide")
local_previous = set()
for old_name in previous:
old_normalized = normalize_catalog_name(old_name)
if old_name != old_name.strip() or old_normalized == normalized_name:
raise ExerciseNameCatalogError(f"{label}: ancien nom invalide")
if old_normalized in local_previous or old_normalized in previous_names:
raise ExerciseNameCatalogError(f"{label}: ancien nom dupliqué")
local_previous.add(old_normalized)
result[exercise_id] = display_name
normalized_owners.add(normalized_name)
previous_names.update(local_previous)
if normalized_owners & previous_names:
raise ExerciseNameCatalogError("un ancien nom appartient déjà à un nom canonique")
return result

View file

@ -6,6 +6,8 @@ import sqlite3
from datetime import datetime
from pathlib import Path
from exercise_names import load_exercise_names
def default_database_path():
data_home = os.environ.get("XDG_DATA_HOME")
@ -57,6 +59,7 @@ def main():
)
try:
canonical_names = load_exercise_names()
version = connection.execute(
"PRAGMA user_version;"
).fetchone()[0]
@ -95,7 +98,7 @@ def main():
"exercises": [
{
"exercise_id": row[0],
"name": row[1],
"name": canonical_names.get(row[0], row[1]),
"recording_mode": row[2],
"tracking_mode": row[3],
"data_fields": row[4],

View file

@ -9,6 +9,7 @@ from datetime import datetime
from pathlib import Path
from validate_json import TrainlogSemanticError, parse_timestamp
from exercise_names import load_exercise_names
CATALOG_PATH = Path(__file__).resolve().parents[1] / "catalog" / "equipment-v1.json"
@ -70,13 +71,16 @@ def main():
if schema_version not in (11, 12) and not (args.version == 2 and schema_version == 10):
raise ValueError("schema desktop v11/v12 requis (v10 accepté pour export V2 explicite)")
known_equipment = supplied_equipment_ids()
canonical_names = load_exercise_names()
known_equipment.update(row[0] for row in con.execute(
"SELECT equipment_id FROM custom_equipment"))
root = {"format": "trainlog-mobile-export", "version": args.version,
"generated_at": datetime.now().astimezone().isoformat(),
"exercises": [], "sessions": [], "body_observations": []}
for row in con.execute("SELECT exercise_id,name,recording_mode,tracking_mode,data_fields FROM exercises ORDER BY exercise_id"):
root["exercises"].append(dict(row))
exported = dict(row)
exported["name"] = canonical_names.get(row["exercise_id"], row["name"])
root["exercises"].append(exported)
for session in con.execute("SELECT id,session_id,started_at,session_type FROM sessions ORDER BY started_at,id"):
# CONTRACT: a V3 producer must not publish history which the exact
# temporal readers reject. V2 export retains its published behavior.
@ -108,7 +112,8 @@ def main():
f"session_id={session['session_id']} entry_id={entry['entry_id']}"
)
item = {"entry_id": entry["entry_id"], "position": entry["position"],
"exercise_id": entry["exercise_id"], "name": entry["name"],
"exercise_id": entry["exercise_id"],
"name": canonical_names.get(entry["exercise_id"], entry["name"]),
"recording_mode": entry["recording_mode"], "tracking_mode": entry["tracking_mode"],
"data_fields": entry["data_fields"],
"load_mode": entry["load_mode"] if args.version == 3 else "none",

View file

@ -9,6 +9,7 @@ import unicodedata
from pathlib import Path
from validate_json import TrainlogSemanticError, parse_timestamp
from exercise_names import ExerciseNameCatalogError, load_exercise_names
FORMAT = "trainlog-mobile-export"
@ -1005,8 +1006,16 @@ def import_exercises(
trace_exercises=False,
):
mapping = {}
try:
canonical_names = load_exercise_names()
except ExerciseNameCatalogError as error:
raise ImportFailure(str(error)) from error
for exercise in payload["exercises"]:
for supplied_exercise in payload["exercises"]:
exercise = dict(supplied_exercise)
exercise["name"] = canonical_names.get(
exercise["exercise_id"], exercise["name"]
)
exercise_id = exercise["exercise_id"]
normalized = normalize_name(
exercise["name"]

View file

@ -441,6 +441,7 @@ def validate_document(
return [str(exc)]
is_body_zones = isinstance(document, dict) and document.get("format") == "trainlog-body-zone-catalog"
is_exercise_names = isinstance(document, dict) and document.get("format") == "trainlog-exercise-names-v1"
is_mobile_v2 = isinstance(document, dict) and document.get("format") == "trainlog-mobile-export" and document.get("version") == 2
if is_body_zones:
try:
@ -448,6 +449,13 @@ def validate_document(
except TrainlogSemanticError as exc:
return [str(exc)]
return []
if is_exercise_names:
try:
from exercise_names import load_exercise_names
load_exercise_names(path)
except ValueError as exc:
return [str(exc)]
return []
if not is_mobile_v2:
errors = structural_errors(validator, document)
if errors:
@ -486,6 +494,17 @@ def run_suite(validator: jsonschema.Draft202012Validator) -> int:
else:
print(f"PASS body-zone catalog: {BODY_ZONE_CATALOG_PATH}")
try:
from exercise_names import load_exercise_names
exercise_names = load_exercise_names()
print(
"PASS exercise-name catalog: "
f"{ROOT / 'catalog' / 'exercise-names-v1.json'} ({len(exercise_names)} names)"
)
except ValueError as error:
print(f"FAIL exercise-name catalog: {error}")
failed = True
if not valid:
print("FAIL test suite: no valid fixtures found")
return 1

View file

@ -72,10 +72,14 @@ void trainlog_terminal_cursor_visible(TrainlogTerminal *terminal, bool visible);
void trainlog_terminal_draw(TrainlogTerminal *terminal, int row, int column, uint32_t codepoint);
void trainlog_terminal_box(TrainlogTerminal *terminal, int top, int left,
int bottom, int right);
bool trainlog_terminal_translate_input(uint32_t id,
/* WHY: Notcurses can report a physical key in id while eff_text holds its
* layout-resolved Unicode character. CONTRACT: Shift may produce a printable
* action character (for example French AZERTY '/'); Ctrl/Alt/Super never
* collapse into an unmodified action. */
bool trainlog_terminal_translate_input(uint32_t id, uint32_t effective_text,
TrainlogInputEventType event_type,
bool shifted,
int *key);
bool shifted, bool control, bool alt,
bool super, int *key);
int trainlog_terminal_get_key(TrainlogTerminal *terminal);
bool trainlog_terminal_read_unicode(TrainlogTerminal *terminal, int *codepoint,
char utf8[5]);

View file

@ -299,10 +299,10 @@ static int terminal_key(uint32_t id, bool shifted)
}
}
bool trainlog_terminal_translate_input(uint32_t id,
bool trainlog_terminal_translate_input(uint32_t id, uint32_t effective_text,
TrainlogInputEventType event_type,
bool shifted,
int *key)
bool shifted, bool control, bool alt,
bool super, int *key)
{
if (key == NULL || id == 0U || id == UINT32_MAX) {
return false;
@ -312,7 +312,14 @@ bool trainlog_terminal_translate_input(uint32_t id,
case TRAINLOG_INPUT_UNKNOWN:
case TRAINLOG_INPUT_PRESS:
case TRAINLOG_INPUT_REPEAT:
*key = terminal_key(id, shifted);
/* INVARIANT: only Shift is a text-producing modifier. The effective
* character is layout-independent; do not turn Ctrl/Alt/Super chords
* into ordinary shortcuts. */
if (effective_text >= 0x20U && effective_text <= 0x10ffffU) {
if (control || alt || super) return false;
*key = (int)effective_text;
} else
*key = terminal_key(id, shifted);
return true;
case TRAINLOG_INPUT_RELEASE:
default:
@ -347,10 +354,12 @@ static bool terminal_read_input(TrainlogTerminal *terminal,
if (id == 0U || id == UINT32_MAX) {
return false;
}
if (!trainlog_terminal_translate_input(id,
if (!trainlog_terminal_translate_input(id, input.eff_text[0],
terminal_event_type(input.evtype),
ncinput_shift_p(&input),
key)) {
ncinput_ctrl_p(&input),
ncinput_alt_p(&input),
ncinput_super_p(&input), key)) {
continue;
}
if (utf8 != NULL) {

View file

@ -4235,9 +4235,6 @@ static void app_shell_actions(TrainlogAppContext *app)
if (route == TRAINLOG_ROUTE_HOME)
app_shell_add_action(app, "session", '1', "1 Séance", 1U,
TRAINLOG_INTENT_OPEN_ROUTE, TRAINLOG_ROUTE_SESSION_MANUAL);
if (route == TRAINLOG_ROUTE_SESSIONS)
app_shell_add_action(app, "generate", 'g', "g Programmer", 1U,
TRAINLOG_INTENT_OPEN_ROUTE, TRAINLOG_ROUTE_SESSION_GENERATOR);
if (route == TRAINLOG_ROUTE_BODY ||
route == TRAINLOG_ROUTE_SYNC || route == TRAINLOG_ROUTE_SETTINGS)
app_shell_add_action(app, "open", TRAINLOG_KEY_ENTER, "Entrée Ouvrir", 1U,
@ -5488,13 +5485,12 @@ static void app_shell_render_content(TrainlogAppContext *app)
} else if (route == TRAINLOG_ROUTE_HOME) {
trainlog_surface_printf(app->content, 4, 2, "Votre entraînement, au même endroit.");
trainlog_surface_printf(app->content, 7, 2, "1 Reprendre ou commencer une séance");
trainlog_surface_printf(app->content, 9, 2, "g Programmer une séance");
trainlog_surface_printf(app->content, 11, 2, "5 Ajouter ou consulter des mensurations");
trainlog_surface_printf(app->content, 9, 2, "5 Ajouter ou consulter des mensurations");
} else if (route == TRAINLOG_ROUTE_SESSIONS) {
static const char *const rows[] = {"Séance en cours", "Programmer une séance",
"Nouvelle séance manuelle", "Séances effectuées"};
static const char *const rows[] = {"Séance en cours", "Nouvelle séance manuelle",
"Séances effectuées"};
size_t index;
for (index = 0U; index < 4U; ++index) {
for (index = 0U; index < 3U; ++index) {
trainlog_surface_set_role(app->content,
app->content_selected == index ? TRAINLOG_COLOR_ACCENT : TRAINLOG_COLOR_DEFAULT,
TRAINLOG_RGB_BASE,
@ -6214,9 +6210,8 @@ static void app_shell_primary(TrainlogAppContext *app)
TrainlogAppRoute route = app->navigation.current.route;
if (route == TRAINLOG_ROUTE_SESSIONS) {
static const TrainlogAppRoute targets[] = {TRAINLOG_ROUTE_SESSION_CURRENT,
TRAINLOG_ROUTE_SESSION_GENERATOR, TRAINLOG_ROUTE_SESSION_MANUAL,
TRAINLOG_ROUTE_SESSIONS_COMPLETED};
app_shell_open_route(app, targets[app->content_selected % 4U]);
TRAINLOG_ROUTE_SESSION_MANUAL, TRAINLOG_ROUTE_SESSIONS_COMPLETED};
app_shell_open_route(app, targets[app->content_selected % 3U]);
} else if (route == TRAINLOG_ROUTE_STATS) {
static const TrainlogAppRoute targets[] = {TRAINLOG_ROUTE_STATS_EXERCISE,
TRAINLOG_ROUTE_BODY, TRAINLOG_ROUTE_MAX};
@ -7379,6 +7374,17 @@ static void app_shell_dispatch(TrainlogAppContext *app, int key)
/* INVARIANT: the rendered Actions control owns its input. */
return;
}
/* WHY: route controllers may legitimately own printable keys. Search is a
* shell action, so resolve its registered '/' before those controllers.
* CONTRACT: physical '/' and F7 select the same list.search action. */
action = trainlog_actions_find_key(&app->actions, key);
if (action != NULL && action->intent == TRAINLOG_INTENT_OPEN_SEARCH &&
app_shell_is_list_route(app->navigation.current.route)) {
app->search.open = true;
app->search.focused = true;
app->focus = TRAINLOG_FOCUS_SEARCH;
return;
}
if (app_shell_dispatch_exercise_controller(app, key)) return;
if (app_shell_dispatch_equipment_controller(app, key)) return;
if (app_shell_dispatch_body_controller(app, key)) return;
@ -7388,7 +7394,7 @@ static void app_shell_dispatch(TrainlogAppContext *app, int key)
if ((app->navigation.current.route == TRAINLOG_ROUTE_SESSIONS ||
app->navigation.current.route == TRAINLOG_ROUTE_STATS) &&
(key == TRAINLOG_KEY_UP || key == TRAINLOG_KEY_DOWN)) {
size_t count = app->navigation.current.route == TRAINLOG_ROUTE_SESSIONS ? 4U : 3U;
size_t count = 3U;
if (key == TRAINLOG_KEY_UP && app->content_selected > 0U) --app->content_selected;
if (key == TRAINLOG_KEY_DOWN && app->content_selected + 1U < count) ++app->content_selected;
return;
@ -7641,20 +7647,7 @@ shell_nonanalytics_input:
else if (key == TRAINLOG_KEY_F3) key = '3';
else if (key == TRAINLOG_KEY_F4) key = '4';
else if (key == TRAINLOG_KEY_F5) key = '5';
if ((key == 'g' || key == 'G') && app->navigation.current.route == TRAINLOG_ROUTE_HOME) {
app_shell_open_route(app, TRAINLOG_ROUTE_SESSION_GENERATOR); return;
}
action = trainlog_actions_find_key(&app->actions, key);
if (action != NULL && action->intent == TRAINLOG_INTENT_OPEN_SEARCH &&
app_shell_is_list_route(app->navigation.current.route)) {
/* CONTRACT: the physical '/' follows input adapter -> top-level action
* registry -> focus/search -> list filter. F7 invokes the same stable
* action and Escape retains the established clear-then-close behavior. */
app->search.open = true;
app->search.focused = true;
app->focus = TRAINLOG_FOCUS_SEARCH;
return;
}
if (action != NULL && action->intent == TRAINLOG_INTENT_OPEN_ROUTE) {
app_shell_open_route(app, action->route); return;
}

View file

@ -29,24 +29,24 @@ static bool test_event_type_policy(void)
int key = 12345;
CHECK(trainlog_terminal_translate_input(
NCKEY_RIGHT, TRAINLOG_INPUT_UNKNOWN, false, &key));
NCKEY_RIGHT, 0U, TRAINLOG_INPUT_UNKNOWN, false, false, false, false, &key));
CHECK(key == TRAINLOG_KEY_RIGHT);
CHECK(trainlog_terminal_translate_input(
NCKEY_RIGHT, TRAINLOG_INPUT_PRESS, false, &key));
NCKEY_RIGHT, 0U, TRAINLOG_INPUT_PRESS, false, false, false, false, &key));
CHECK(key == TRAINLOG_KEY_RIGHT);
CHECK(trainlog_terminal_translate_input(
NCKEY_RIGHT, TRAINLOG_INPUT_REPEAT, false, &key));
NCKEY_RIGHT, 0U, TRAINLOG_INPUT_REPEAT, false, false, false, false, &key));
CHECK(key == TRAINLOG_KEY_RIGHT);
key = 12345;
CHECK(!trainlog_terminal_translate_input(
NCKEY_RIGHT, TRAINLOG_INPUT_RELEASE, false, &key));
NCKEY_RIGHT, 0U, TRAINLOG_INPUT_RELEASE, false, false, false, false, &key));
CHECK(key == 12345);
CHECK(!trainlog_terminal_translate_input(
NCKEY_RIGHT, (TrainlogInputEventType)99, false, &key));
NCKEY_RIGHT, 0U, (TrainlogInputEventType)99, false, false, false, false, &key));
CHECK(key == 12345);
return true;
@ -57,35 +57,48 @@ static bool test_key_translation(void)
int key = TRAINLOG_KEY_NONE;
CHECK(trainlog_terminal_translate_input(
NCKEY_TAB, TRAINLOG_INPUT_PRESS, false, &key));
NCKEY_TAB, 0U, TRAINLOG_INPUT_PRESS, false, false, false, false, &key));
CHECK(key == TRAINLOG_KEY_TAB);
CHECK(trainlog_terminal_translate_input(
NCKEY_TAB, TRAINLOG_INPUT_PRESS, true, &key));
NCKEY_TAB, 0U, TRAINLOG_INPUT_PRESS, true, false, false, false, &key));
CHECK(key == TRAINLOG_KEY_SHIFT_TAB);
CHECK(trainlog_terminal_translate_input(
NCKEY_F06, TRAINLOG_INPUT_PRESS, false, &key));
NCKEY_F06, 0U, TRAINLOG_INPUT_PRESS, false, false, false, false, &key));
CHECK(key == TRAINLOG_KEY_F6);
CHECK(trainlog_terminal_translate_input(
NCKEY_F07, TRAINLOG_INPUT_REPEAT, false, &key));
NCKEY_F07, 0U, TRAINLOG_INPUT_REPEAT, false, false, false, false, &key));
CHECK(key == TRAINLOG_KEY_F7);
CHECK(trainlog_terminal_translate_input(
27U, TRAINLOG_INPUT_PRESS, false, &key));
27U, 0U, TRAINLOG_INPUT_PRESS, false, false, false, false, &key));
CHECK(key == TRAINLOG_KEY_ESCAPE);
CHECK(trainlog_terminal_translate_input(
0x00e9U, TRAINLOG_INPUT_REPEAT, false, &key));
0x00e9U, 0x00e9U, TRAINLOG_INPUT_REPEAT, false, false, false, false, &key));
CHECK(key == 0x00e9);
CHECK(!trainlog_terminal_translate_input(
0U, TRAINLOG_INPUT_PRESS, false, &key));
0U, 0U, TRAINLOG_INPUT_PRESS, false, false, false, false, &key));
CHECK(!trainlog_terminal_translate_input(
UINT32_MAX, TRAINLOG_INPUT_PRESS, false, &key));
UINT32_MAX, 0U, TRAINLOG_INPUT_PRESS, false, false, false, false, &key));
CHECK(!trainlog_terminal_translate_input(
NCKEY_RIGHT, TRAINLOG_INPUT_PRESS, false, NULL));
NCKEY_RIGHT, 0U, TRAINLOG_INPUT_PRESS, false, false, false, false, NULL));
/* French AZERTY commonly needs Shift to yield '/'. eff_text, rather than
* the physical id, is the action character and must reach list.search. */
CHECK(trainlog_terminal_translate_input(
':', '/', TRAINLOG_INPUT_PRESS, true, false, false, false, &key));
CHECK(key == '/');
CHECK(trainlog_terminal_translate_input(
'/', '/', TRAINLOG_INPUT_PRESS, false, false, false, false, &key));
CHECK(key == '/');
CHECK(!trainlog_terminal_translate_input(
':', '/', TRAINLOG_INPUT_PRESS, true, true, false, false, &key));
CHECK(!trainlog_terminal_translate_input(
':', '/', TRAINLOG_INPUT_PRESS, true, false, true, false, &key));
return true;
}