From 9d9a9223a0c46df72f5c3ab208107c0ac6698438 Mon Sep 17 00:00:00 2001 From: fy59 Date: Sat, 5 Sep 2026 19:22:37 +0200 Subject: [PATCH] Define Trainlog v1 freeze candidate --- CHANGELOG.md | 34 +- docs/android.md | 147 ++++-- docs/exchange_format.md | 463 ++++++++++++------ docs/reviews/gate1_review_01.md | 146 ++++++ docs/roadmap.md | 82 ++-- docs/tests.md | 146 +++--- docs/tui.md | 114 +++-- examples/session-v1.json | 16 +- format/README.md | 25 +- format/trainlog-v1.schema.json | 69 ++- .../fixtures/invalid/blank-exercise-note.json | 82 ++++ .../fixtures/invalid/blank-session-note.json | 82 ++++ .../invalid/duplicate-exercise-id.json | 54 +- .../invalid/duplicate-exercise-name.json | 54 +- .../invalid/duplicate-workout-exercise.json | 60 ++- tests/fixtures/invalid/end-before-start.json | 51 +- .../invalid/external-set-missing-weight.json | 80 +++ .../external-target-missing-weight.json | 80 +++ .../invalid/load-none-with-weight.json | 81 +++ .../invalid/negative-actual-reps.json | 81 +++ .../invalid/target-actual-mode-mismatch.json | 54 +- .../invalid/target-both-reps-duration.json | 51 +- .../invalid/timestamp-without-offset.json | 51 +- .../tracking-mode-target-mismatch.json | 81 +++ .../invalid/unknown-exercise-reference.json | 51 +- tests/fixtures/invalid/unknown-field.json | 51 +- .../invalid/unreferenced-catalog-entry.json | 86 ++++ tests/fixtures/valid/active-session.json | 13 +- tests/fixtures/valid/assistance-load.json | 42 ++ tests/fixtures/valid/bodyweight-zero-rep.json | 32 ++ .../valid/empty-interrupted-session.json | 11 + tools/validate_json.py | 136 ++++- 32 files changed, 2135 insertions(+), 471 deletions(-) create mode 100644 docs/reviews/gate1_review_01.md create mode 100644 tests/fixtures/invalid/blank-exercise-note.json create mode 100644 tests/fixtures/invalid/blank-session-note.json create mode 100644 tests/fixtures/invalid/external-set-missing-weight.json create mode 100644 tests/fixtures/invalid/external-target-missing-weight.json create mode 100644 tests/fixtures/invalid/load-none-with-weight.json create mode 100644 tests/fixtures/invalid/negative-actual-reps.json create mode 100644 tests/fixtures/invalid/tracking-mode-target-mismatch.json create mode 100644 tests/fixtures/invalid/unreferenced-catalog-entry.json create mode 100644 tests/fixtures/valid/assistance-load.json create mode 100644 tests/fixtures/valid/bodyweight-zero-rep.json create mode 100644 tests/fixtures/valid/empty-interrupted-session.json diff --git a/CHANGELOG.md b/CHANGELOG.md index dfbd707..87d781b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,24 +8,24 @@ The project uses a simple pre-release changelog during early development. ### Added -- Initial repository structure. -- Development contract. -- Architecture documentation. -- Coding-style rules. -- Exchange-format v1 draft. -- JSON Schema draft for Trainlog v1. -- Initial example workout export. -- Database, TUI, Android, testing, and roadmap documentation. -- Trainlog semantic JSON validator. +- Initial repository structure and development contract. +- Architecture, coding-style, database, Android, TUI, testing, and roadmap documentation. +- Trainlog v1 JSON Schema draft. +- Structural and semantic Trainlog validator. - Positive and negative exchange-format fixtures. -- Gate 0 review #1 report. +- Gate 0 review and closure records. +- Gate 1 exchange-format freeze-candidate review. +- Stable exercise tracking modes. +- Explicit load modes for no load, external resistance, and assistance. +- Optional bounded session and exercise notes. +- Extended body measurement list. ### Changed -- Gate 0 project contract review completed successfully. -- `ended_at` is optional for active or interrupted sessions. -- Repetition and timed exercise modes are now mutually exclusive. -- Exercise display-name anti-duplication semantics are defined. -- Session/catalog cross-reference rules are executable. -- Timestamp offset and chronology rules are executable. -- Unknown fields are explicitly rejected in v1. +- Gate 0 project contract is complete. +- Gate 1 is the active format-freeze gate. +- Actual repetition count may be zero for a failed attempt. +- Planned exercises may contain zero actual sets. +- Top-level exercise metadata must exactly match the session exercise references. +- Assistance kilograms have distinct semantics from external resistance. +- `TRAINLOG_FORMAT_V1` remains draft pending Gate 1 validation and mirrored review. diff --git a/docs/android.md b/docs/android.md index ae878a1..947f1f2 100644 --- a/docs/android.md +++ b/docs/android.md @@ -6,9 +6,9 @@ The Android application is a lightweight training-session recorder. Its design priority is low-friction data entry during a workout. -## 2. Session flow +It is not the canonical history or analytics application. -Expected flow: +## 2. Session flow ```text Start session @@ -17,13 +17,16 @@ Start session record started_at | v -add exercises and targets +select/create exercise + | + v +enter target + planned rest | v record actual sets | v -optional body data +optional body data / notes | v Finish session @@ -32,45 +35,68 @@ Finish session record ended_at | v -export Trainlog JSON +validate + export Trainlog JSON ``` ## 3. Exercise catalog -The application maintains a local exercise catalog for selection. +The Android application keeps a local exercise catalog so names are not retyped every session. -The user must not need to retype the same exercise every session. +Creating an exercise requires: -When a new exercise is created: +- display name; +- tracking mode: repetitions or duration. -- generate a stable `exercise_id`; -- store the display name; -- use that same identifier in future sessions; -- include the exercise catalog entry in exported files as required. +The application generates a stable `exercise_id`. -## 4. Planned work +A new exercise used in an exported session is included in the top-level session export metadata and is therefore importable by the TUI. -The basic exercise form supports: +The Android application must prevent accidental duplicate normalized names according to the Trainlog v1 contract. -- number of sets; -- repetitions or timed duration; -- load when relevant; -- rest duration. +## 4. Fast exercise form -Example: +For a repetition exercise, the basic form is conceptually: ```text -4 sets -5 repetitions -80 kg -60 seconds rest +Exercise Presse à cuisses +Load mode External +Load 80 kg +Sets 4 +Repetitions 5 +Rest 60 s ``` -## 5. Actual work +For a timed exercise: -The UI should pre-populate performed sets from the target when convenient. +```text +Exercise Gainage ventral +Load mode None +Sets 3 +Duration 45 s +Rest 60 s +``` -The user only needs to edit differences. +The application should remember practical defaults from the previous use of an exercise when that reduces typing, but remembered UI defaults are not part of the exchange-format contract. + +## 5. Load modes + +The user chooses only when relevant: + +- none; +- external; +- assistance. + +`external` covers free weights and machine-displayed load. + +`assistance` stores a positive assistance value. + +The UI should label assistance explicitly so it cannot be confused with added resistance. + +## 6. Actual work + +The application should pre-populate actual sets from the target. + +The user edits only what differs. Example target: @@ -84,30 +110,71 @@ Actual: 5 / 5 / 5 / 3 ``` -The exported file must preserve both target and actual values. +The export preserves both target and actual values. -## 6. Timestamps +Zero actual repetitions are valid for a real failed attempt. + +A planned exercise may also have zero actual sets if it was never started. + +## 7. Rest + +`rest_seconds` is the planned rest duration for the exercise. + +v1 does not require a running rest timer and does not serialize measured per-set rest. + +A timer can be added later as UI behavior without changing the v1 format. + +## 8. Timestamps `started_at` is recorded automatically when the session starts. -`ended_at` is recorded automatically when the session ends. +`ended_at` is recorded automatically when the user finishes the session. -The UI may later allow explicit correction for forgotten starts or stops, but such correction must be visible to the user. +An active/interrupted local session may exist without `ended_at`. -## 7. Export +The application must never invent an end timestamp merely to make export validation pass. -The application exports valid Trainlog JSON. +## 9. Body data -It must not silently export malformed or incomplete data. +Optional session-associated data: -The application must validate required fields before final export. +- body weight; +- neck; +- shoulders; +- chest; +- waist; +- hips; +- left/right arm; +- left/right forearm; +- left/right thigh; +- left/right calf. -## 8. Non-goals +The Android UI does not need to force these fields during every workout. + +## 10. Notes + +Session and exercise notes are optional. + +The initial Android UI may omit note controls without violating v1, because the fields are optional. + +## 11. Export + +Before export, Android must enforce both: + +- JSON structural validity; +- Trainlog v1 semantic validity. + +A malformed or semantically inconsistent file must not be exported as a completed Trainlog document. + +## 12. Non-goals Initial Android versions do not need: -- advanced analytics; -- complex charts; -- a cloud account; -- a remote database; -- social features. +- analytics; +- complex graphs; +- cloud accounts; +- remote databases; +- social features; +- muscle classification; +- distance/cardio metrics; +- per-set rest measurement. diff --git a/docs/exchange_format.md b/docs/exchange_format.md index ebcfe48..16916d6 100644 --- a/docs/exchange_format.md +++ b/docs/exchange_format.md @@ -2,32 +2,53 @@ ## 1. Status -This document defines the Trainlog v1 exchange contract draft. - -Current state: - ```text -TRAINLOG_FORMAT_V1=DRAFT GATE_0=PASS +GATE_1_REVIEW_01=IMPLEMENTED +GATE_1=VALIDATION_PENDING +TRAINLOG_FORMAT_V1=DRAFT ``` -Incompatible changes are allowed until the format is explicitly marked `FROZEN`. +This document defines the proposed final Trainlog v1 exchange contract. -Once frozen, incompatible changes require a new format version. +The format remains `DRAFT` until Gate 1 validation and mirror review complete. -## 2. Encoding +## 2. Design goal -A Trainlog exchange document is: +Trainlog v1 must represent the training patterns required by the initial applications without turning the Android recorder into a complex training platform. + +The format supports: + +- repetition-based exercises; +- time-based exercises; +- bodyweight work; +- free-weight and machine load; +- assistance load; +- planned versus performed work; +- planned rest; +- body weight; +- body measurements; +- active or interrupted sessions; +- optional notes. + +Distance, velocity, heart rate, per-set measured rest, supersets, and arbitrary custom metrics are outside v1. + +## 3. Encoding and strictness + +A Trainlog v1 document is: - JSON; - UTF-8; -- one top-level JSON object. +- one top-level object; +- structurally strict. -Unknown fields are rejected in v1. +Unknown fields are rejected. -This strict rule is intentional: a misspelled or unsupported field must fail validation rather than be silently ignored. +This is intentional. A misspelled field must fail validation instead of being silently discarded. -## 3. Required top-level fields +## 4. Top-level object + +Required fields: ```json { @@ -38,89 +59,78 @@ This strict rule is intentional: a misspelled or unsupported field must fail val } ``` -### `format` +`format` must equal `trainlog`. -Must equal: +`version` must equal integer `1`. -```text -trainlog -``` +The top-level `exercises` array contains metadata for exactly the exercises referenced by the session. -### `version` +It is not a full catalog synchronization document. -Must equal integer `1`. - -## 4. Exercise catalog +## 5. Exercise identity Each catalog entry contains: ```json { "exercise_id": "leg_press", - "name": "Presse à cuisses" + "name": "Presse à cuisses", + "tracking_mode": "reps" } ``` -### 4.1 Stable identity +### 5.1 `exercise_id` -`exercise_id` is the permanent machine identifier. +`exercise_id` is the permanent machine identity. Rules: -- ASCII lowercase identifier; - 1 to 128 characters; -- allowed characters: `a-z`, `0-9`, `_`, `-`; -- unique within one exchange document; -- must not change merely because the visible name changes. +- ASCII lowercase; +- first character: `a-z` or `0-9`; +- remaining characters: `a-z`, `0-9`, `_`, `-`; +- unique inside the document; +- unchanged when the visible name changes. -The visible name is not the persistent identity. +### 5.2 `name` -### 4.2 Display-name anti-duplication rule +`name` is the human-readable display name. -Two catalog entries must not have equivalent display names. +The display name is not the identity. -For duplicate detection, implementations normalize names using this semantic algorithm: +Two exercises in one document must not have equivalent normalized names. + +Normalization for comparison is: 1. Unicode NFC normalization; -2. remove leading and trailing whitespace; -3. collapse each internal run of whitespace to one ASCII space; +2. trim leading and trailing Unicode whitespace; +3. collapse each internal whitespace run to one ASCII space; 4. Unicode case folding. -Example: +The serialized name is never rewritten by this normalization rule. + +The future C implementation must use a Unicode implementation capable of reproducing this contract exactly; `utf8proc` or an equivalent tested implementation is acceptable. + +### 5.3 `tracking_mode` + +Every exercise has one stable tracking mode: ```text -"Presse à cuisses" -" presse à cuisses " -"PRESSE À CUISSES" +reps +duration ``` -are considered the same display name. +`reps` is used for repetition-counted exercises. -This rule prevents accidental duplicate exercises while still allowing an exercise to be renamed without changing `exercise_id`. +`duration` is used for time-counted exercises such as planks. -JSON Schema cannot express this normalization rule. It is mandatory semantic validation. +The tracking mode determines the Android input control and the interpretation of all sets for that exercise. -## 5. Session +Changing the fundamental tracking mode of an existing exercise should normally create a new exercise identity rather than silently changing historical semantics. -Required fields: +## 6. Session identity -- `session_id`; -- `started_at`; -- `exercises`. - -Optional fields: - -- `ended_at`; -- `body_weight_kg`; -- `measurements`. - -`ended_at` is optional because Trainlog may preserve an active or interrupted session. - -A completed Android export normally includes `ended_at`. - -The TUI must never invent an end timestamp for a session that does not have one. - -## 6. Session identifier +A session contains a unique opaque `session_id`. Example: @@ -132,18 +142,21 @@ Rules: - 1 to 128 characters; - starts with an ASCII alphanumeric character; -- remaining characters are ASCII alphanumeric, `_`, or `-`; -- treated as an opaque unique identifier. +- remaining characters are ASCII alphanumeric, `_`, or `-`. -The generation algorithm is implementation-defined in v1. +The generation algorithm remains implementation-defined. -The TUI enforces uniqueness in SQLite. +The database uniqueness constraint is the final anti-duplication barrier. -Repeated import of the same `session_id` is idempotent. +Importing an already-known `session_id` is idempotent. -## 7. Timestamps +## 7. Session timestamps -Timestamps use RFC 3339 / ISO 8601 date-time syntax with an explicit UTC offset. +`started_at` is required. + +`ended_at` is optional. + +Both use RFC 3339 / ISO 8601 date-time syntax with an explicit UTC offset. Examples: @@ -152,19 +165,32 @@ Examples: 2026-09-05T16:34:12Z ``` -An offset-less timestamp is invalid. +Offset-less timestamps are invalid. -If `ended_at` is present, it must represent an instant strictly later than `started_at`. +If `ended_at` exists, it must represent an instant strictly later than `started_at`. -Chronological ordering is a semantic validation rule. +An absent `ended_at` means the session is still active, interrupted, or otherwise not formally completed. -## 8. Workout exercise entry +Trainlog must not invent an end time. -Example: +## 8. Session exercise order + +The order of `session.exercises` is meaningful. + +It records the exercise order entered by the user. + +The order of `sets` is also meaningful and defines performed set order. + +No separate set number is serialized. + +## 9. Planned work and performed work + +Each session exercise contains: ```json { "exercise_id": "leg_press", + "load_mode": "external", "rest_seconds": 60, "target": { "sets": 4, @@ -184,60 +210,123 @@ Example: `sets` describes actual performed work. -The two concepts must remain distinct. +They must remain separate. -The number of actual sets is deliberately allowed to differ from `target.sets`. +`target.sets` is the intended number of sets. -This records failure, extra work, interrupted sessions, and manual corrections truthfully. +The length of `sets` is the actual number of recorded sets and may be: -## 9. Repetition mode and timed mode +- smaller than the target; +- equal to the target; +- greater than the target; +- zero. -Each workout exercise has exactly one target mode: +An empty actual-set array is valid for a planned exercise that was not performed. -- repetition mode: `reps`; -- timed mode: `duration_seconds`. +## 10. Repetition mode -A target must not contain both. +For a `tracking_mode` of `reps`: -All actual sets for that exercise must use the same mode as the target. +- `target.reps` is required; +- `target.duration_seconds` is forbidden; +- each actual set contains `reps`; +- each actual set forbids `duration_seconds`. -### Repetition example +Target repetitions must be at least 1. -```json -{ - "target": { - "sets": 4, - "reps": 5 - } -} +Actual repetitions may be 0. + +A zero-repetition set represents a real attempted set with no completed repetition. + +A skipped set should normally be omitted instead. + +## 11. Duration mode + +For a `tracking_mode` of `duration`: + +- `target.duration_seconds` is required; +- `target.reps` is forbidden; +- each actual set contains `duration_seconds`; +- each actual set forbids `reps`. + +Duration values are positive integer seconds. + +## 12. Load model + +Each session exercise has exactly one `load_mode`: + +```text +none +external +assistance ``` -### Timed example +### 12.1 `none` -```json -{ - "target": { - "sets": 3, - "duration_seconds": 45 - } -} +Use for exercises where no separate load value is recorded. + +Examples: + +- bodyweight squat; +- unweighted plank; +- push-up; +- pull-up without added or assisted load. + +When `load_mode` is `none`, `weight_kg` is forbidden in the target and actual sets. + +### 12.2 `external` + +Use for a positive externally applied or machine-displayed load. + +Examples: + +- barbell; +- dumbbell; +- cable machine; +- leg press; +- weighted pull-up. + +When `load_mode` is `external`: + +- `target.weight_kg` is required; +- every actual set requires its own `weight_kg`. + +Actual set load is stored per set so load changes remain representable. + +### 12.3 `assistance` + +Use when the numeric load represents assistance that reduces the effective difficulty of a bodyweight movement. + +Example: + +```text +Assisted pull-up: 20 kg assistance ``` -The schema rejects a set or target containing both `reps` and `duration_seconds`. +When `load_mode` is `assistance`: -The target/actual mode-match rule is semantic validation. +- `target.weight_kg` is required; +- every actual set requires its own `weight_kg`. -## 10. Load +Assistance remains a positive value. -`weight_kg` represents external load in kilograms. +Analytics must not treat increasing assistance as increasing strength. -It is optional because some exercises are bodyweight or duration-only exercises. +### 12.4 Unit and physical meaning -When supplied, actual-set load is recorded per set so a session can truthfully represent load changes between sets. +All serialized loads use kilograms. -## 11. Rest +For a machine, `weight_kg` records the load value displayed or declared by the machine/user. -`rest_seconds` is the planned rest duration after sets for the workout exercise. +Trainlog does not claim that this value equals exact mechanical force at the body. + +This distinction matters when comparing different machines. + +## 13. Rest + +`rest_seconds` is required for every session exercise. + +It stores the planned rest interval in integer seconds. Example: @@ -247,93 +336,147 @@ Example: means one minute. -v1 does not record measured rest duration per individual set. +Zero is valid when no planned rest exists. -That may be introduced only by an additive compatible extension before freeze or a later format version after freeze. +v1 does not record measured rest between individual sets. -## 12. Body weight +## 14. Body weight -`body_weight_kg` is optional and uses kilograms. +`body_weight_kg` is optional. -It represents body weight associated with the session. +It is a positive kilogram value associated with the session timestamp. -Standalone body-weight observations outside a workout session are a TUI/database concern and do not require this session exchange object. +The TUI database may also support standalone body-weight observations; those records are outside this session-exchange document. -## 13. Body measurements +## 15. Body measurements -Supported v1 measurements use centimeters: +`measurements` is optional. -- `waist_cm`; -- `chest_cm`; -- `shoulders_cm`; -- `left_arm_cm`; -- `right_arm_cm`; -- `left_thigh_cm`; -- `right_thigh_cm`; -- `left_calf_cm`; -- `right_calf_cm`. +If present, it contains at least one measurement. -If `measurements` is present, it must contain at least one measurement. +All measurements are circumferences in centimeters unless the field name itself defines another interpretation. -Additional measurements may still be added before v1 is frozen. +Frozen v1 measurement names proposed by Gate 1 review #1: -## 14. Catalog references +```text +neck_cm +shoulders_cm +chest_cm +waist_cm +hips_cm +left_arm_cm +right_arm_cm +left_forearm_cm +right_forearm_cm +left_thigh_cm +right_thigh_cm +left_calf_cm +right_calf_cm +``` -Every `session.exercises[*].exercise_id` must reference an entry present in the top-level `exercises` catalog. +`shoulders_cm` means shoulder-girdle circumference, not straight-line shoulder width. -This permits Android to introduce a new exercise safely during import. +Left/right fields are intentionally separate so asymmetry can be followed over time. -An unknown reference makes the document invalid. +## 16. Notes -JSON Schema cannot express this cross-reference rule. It is mandatory semantic validation. +Optional UTF-8 notes are supported at: -## 15. Duplicate workout exercise entries +- session level; +- session-exercise level. -A session must not contain the same `exercise_id` more than once. +A present note must not be empty or whitespace-only. -All performed sets for one exercise belong to its single workout entry. +Session notes are limited to 4000 characters. -This keeps analysis and editing deterministic. +Exercise notes are limited to 1000 characters. -## 16. Idempotent import +Notes are user content and must be preserved exactly after validation. -The TUI treats `session_id` as a uniqueness key. +## 17. Catalog completeness -If the session is already present: +The top-level exercise catalog must contain exactly the exercise identities referenced by `session.exercises`. -- do not create another session; -- do not duplicate sets; -- do not partially merge the repeated document; -- report that the session already exists. +Therefore: -Database constraints are the final anti-duplication barrier. +- every session exercise has one matching catalog entry; +- no catalog entry is unreferenced; +- an empty session exercise array requires an empty top-level exercise catalog. -## 17. Validation layers +This keeps each session export self-contained without silently importing unrelated Android catalog entries. -A valid Trainlog v1 document must pass both: +## 18. One workout entry per exercise + +A `session_id` may contain a given `exercise_id` at most once. + +All performed sets for that exercise belong to its single session-exercise entry. + +This makes editing and analytics deterministic. + +## 19. Existing local exercise with different display name + +Identity wins over display text. + +If the TUI already knows an `exercise_id` and an imported session carries a different display name for that same identifier: + +- the session may still import; +- the existing canonical TUI identity is used; +- the import must surface a non-fatal metadata warning; +- the session import must not silently rename the canonical local exercise. + +Catalog synchronization and deliberate renaming are separate operations outside the v1 session-import transaction. + +## 20. Structural and semantic validation + +A valid v1 document passes both: 1. JSON Schema validation; 2. Trainlog semantic validation. -Schema validation handles structure and primitive bounds. +Semantic rules include: -Semantic validation handles rules such as: +- unique exercise identifiers; +- normalized display-name uniqueness; +- explicit timestamp offsets; +- timestamp chronology; +- exact catalog/reference set equality; +- one workout entry per exercise; +- catalog `tracking_mode` matching target and actual sets; +- `load_mode` matching weight presence; +- non-blank notes. -- normalized exercise-name uniqueness; -- exercise identifier uniqueness; -- catalog-reference integrity; -- duplicate workout exercise rejection; -- target/actual mode consistency; -- timestamp chronology. +## 21. Explicit non-goals for v1 -Both Android export and TUI import must eventually implement the same semantic contract. +The following are deliberately not represented by v1: -## 18. Freeze policy +- distance; +- speed; +- velocity; +- heart rate; +- calories; +- measured per-set rest; +- supersets/circuits as first-class objects; +- arbitrary custom set metrics; +- muscle-group classification; +- machine seat/settings metadata; +- photos; +- cloud synchronization. -`TRAINLOG_FORMAT_V1` must not be marked `FROZEN` until: +Those features can be introduced later without corrupting the simple initial recorder. -- all v1 fields are reviewed; -- valid fixtures pass; -- invalid fixtures fail for the intended reason; -- Android and TUI requirements contain no format ambiguity; -- the semantic validator contract is stable. +## 22. Freeze criteria + +Gate 1 may freeze v1 only after: + +- canonical valid fixtures pass; +- canonical invalid fixtures fail for the intended reason; +- Android requirements are aligned; +- TUI requirements are aligned; +- the schema and semantic contract contain no known ambiguity; +- the reviewed commit is pushed and mirrored. + +Until then: + +```text +TRAINLOG_FORMAT_V1=DRAFT +``` diff --git a/docs/reviews/gate1_review_01.md b/docs/reviews/gate1_review_01.md new file mode 100644 index 0000000..e2920d6 --- /dev/null +++ b/docs/reviews/gate1_review_01.md @@ -0,0 +1,146 @@ +# Gate 1 Review #1 — Exchange Format Freeze Candidate + +## Status + +```text +GATE_1_REVIEW_01=IMPLEMENTED +GATE_1=VALIDATION_PENDING +TRAINLOG_FORMAT_V1=DRAFT +``` + +## Purpose + +Gate 1 review #1 turns the Gate 0 format skeleton into a complete v1 implementation contract. + +No C17 or Android production implementation should depend on v1 until this review passes and the format is explicitly frozen. + +## Decisions + +### G1-R1-01 — Stable exercise tracking mode + +Each exercise declares exactly one stable `tracking_mode`: + +```text +reps +duration +``` + +This keeps Android and TUI forms deterministic. + +### G1-R1-02 — Load semantics belong to the session exercise + +A given movement may be bodyweight, externally loaded, or assisted on different sessions. + +Therefore `load_mode` belongs to the session exercise, not permanently to the catalog. + +Frozen candidates: + +```text +none +external +assistance +``` + +### G1-R1-03 — Assistance is not resistance + +Positive assistance kilograms are stored as positive values. + +Analytics must interpret higher assistance as more help, not more strength. + +### G1-R1-04 — Actual load is per set + +Loaded exercises require `weight_kg` on every performed set. + +This permits truthful representation of drop sets, changed machine loads, and failed attempts. + +### G1-R1-05 — Failed zero-repetition attempts are representable + +Actual `reps` may equal zero. + +Target `reps` remain strictly positive. + +A skipped set is omitted; zero represents an actual attempt with no completed repetition. + +### G1-R1-06 — Planned exercise with no actual sets is representable + +`sets` may be empty. + +This supports interrupted sessions and exercises planned but never started. + +### G1-R1-07 — Session and set order are array order + +No redundant serialized ordinal is needed. + +Array order is canonical order. + +### G1-R1-08 — Measurement list is explicit + +The proposed final v1 list is: + +- neck; +- shoulders; +- chest; +- waist; +- hips; +- left/right arm; +- left/right forearm; +- left/right thigh; +- left/right calf. + +### G1-R1-09 — Notes are optional and bounded + +Session notes: maximum 4000 characters. + +Exercise notes: maximum 1000 characters. + +Whitespace-only notes are semantically invalid. + +### G1-R1-10 — Catalog metadata is session-scoped, not full synchronization + +The top-level catalog contains exactly the exercise IDs referenced by the exported session. + +This prevents an ordinary session import from silently importing unrelated Android catalog entries. + +### G1-R1-11 — Existing ID/name mismatch is non-fatal + +Stable identity wins over display metadata. + +A session may import using an existing ID while surfacing a metadata warning. + +Session import does not silently rename the canonical local exercise. + +### G1-R1-12 — v1 non-goals are explicit + +Distance, cardio telemetry, custom metrics, supersets, machine settings, muscle classification, and measured per-set rest are intentionally deferred. + +## Validation + +Run: + +```bash +python tools/validate_json.py +git diff --check +``` + +The suite must include valid examples of: + +- loaded repetition exercise; +- unweighted timed exercise; +- active session; +- zero-repetition failed attempt; +- assistance load; +- empty interrupted session. + +The invalid suite must cover all frozen semantic boundaries. + +## Freeze decision + +This review is a freeze candidate, not the freeze itself. + +After canonical validation, commit, push, and mirrored review, a separate closure patch may set: + +```text +GATE_1_REVIEW_01=PASS +GATE_1=PASS +TRAINLOG_FORMAT_V1=FROZEN +``` diff --git a/docs/roadmap.md b/docs/roadmap.md index b2ae764..4074d69 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -11,59 +11,67 @@ GATE_0=PASS GATE_0_REVIEW_01=PASS ``` -Deliverables: +Gate 0 established: - repository structure; - development contract; - architecture documentation; -- coding-style documentation; -- exchange-format v1 draft; -- JSON Schema draft; -- valid example fixture; -- semantic validator; -- positive and negative fixture suite; -- Gate 0 review report. +- coding-style rules; +- exchange-format draft; +- structural and semantic validation; +- positive and negative fixture strategy. -Exit criteria: +Reviewed hardening commit: -- documentation reviewed; -- JSON example validates; -- valid fixtures are accepted; -- invalid fixtures are rejected; -- semantic invariants are documented; -- repository clean after commit. +```text +bc54d6b4ce10d098916823b6a79f72b39d9c7703 +``` -Gate 0 passed after: +Gate 0 closure commit: -- canonical local fixture validation succeeded; -- `git diff --check` succeeded; -- review commit `bc54d6b4ce10d098916823b6a79f72b39d9c7703` was pushed; -- the GitHub mirror was independently read back and reviewed. - -Gate 1 is now the active gate. +```text +ebd4316ed68c58598a471e567edf13455d00f92b +``` ## Gate 1 — Exchange format v1 freeze -Deliverables: +Status: VALIDATION PENDING — REVIEW #1 -- complete field list; -- exercise identity rules; -- session identity rules; -- repetitions and timed-set representation; -- rest representation; +Canonical state: + +```text +GATE_1_REVIEW_01=IMPLEMENTED +GATE_1=VALIDATION_PENDING +TRAINLOG_FORMAT_V1=DRAFT +``` + +Review #1 freezes the proposed model for: + +- exercise identity; +- stable repetition/duration tracking mode; +- session identity; +- session ordering; +- external/none/assistance load semantics; +- planned versus actual sets; +- zero-repetition failed attempts; +- planned exercises with zero actual sets; +- planned rest; - body weight; -- measurement list; -- strict unknown-field policy; -- valid fixture suite; -- invalid fixture suite; -- stable semantic-validation contract. +- final v1 body-measurement field list; +- optional notes; +- strict catalog completeness; +- strict unknown-field behavior. Exit criteria: -- `TRAINLOG_FORMAT_V1=FROZEN`; -- schema tests pass; -- semantic tests pass; -- Android and TUI can implement against the contract without ambiguity. +- all valid fixtures accepted; +- all invalid fixtures rejected for the intended reason; +- Android documentation aligned; +- TUI documentation aligned; +- schema and semantic validator aligned; +- review commit pushed to Forgejo and GitHub; +- mirrored review passes; +- `TRAINLOG_FORMAT_V1=FROZEN`. ## Gate 2 — TUI persistence core diff --git a/docs/tests.md b/docs/tests.md index 7187312..daf4297 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -4,23 +4,11 @@ A feature is not complete without relevant validation. -The exchange-format validator is part of the executable contract during early development. +The exchange validator is executable specification during the format gates. -## 2. Validation layers +## 2. Canonical exchange validation -Trainlog uses or will use: - -- JSON Schema validation; -- Trainlog semantic validation; -- unit tests; -- integration tests; -- database constraint tests; -- TUI smoke tests; -- sanitizer builds where practical. - -## 3. Exchange-format validation - -Run the canonical suite with: +Run: ```bash python tools/validate_json.py @@ -29,83 +17,97 @@ python tools/validate_json.py The command validates: - `examples/session-v1.json`; -- every file in `tests/fixtures/valid/` as valid; -- every file in `tests/fixtures/invalid/` as invalid. +- all `tests/fixtures/valid/*.json` as valid; +- all `tests/fixtures/invalid/*.json` as invalid. -A negative fixture passes only when validation rejects it. +A negative fixture passes only when Trainlog rejects it. -## 4. Structural versus semantic validation +## 3. Validation layers -JSON Schema validates structure and primitive bounds. +A v1 document must pass: -`tools/validate_json.py` additionally validates rules JSON Schema cannot safely express, including: +1. JSON Schema validation; +2. Trainlog semantic validation. -- unique `exercise_id` values; -- normalized display-name uniqueness; -- catalog-reference integrity; -- one workout entry per exercise; -- target/actual mode consistency; +Schema handles shape, enumerations, and primitive ranges. + +Semantic validation handles cross-object and normalized rules. + +## 4. Gate 1 semantic coverage + +The canonical validator checks: + +- unique `exercise_id`; +- normalized exercise-name uniqueness; - explicit timestamp offsets; -- end-time chronology. +- end time later than start time; +- exact catalog/reference set equality; +- one workout entry per exercise; +- catalog tracking mode matching target; +- catalog tracking mode matching actual sets; +- load-mode/weight consistency; +- non-blank notes. -Android export and TUI import must eventually implement the same semantic rules. +## 5. Positive fixture coverage -## 5. Initial invalid fixture coverage +Gate 1 includes: -The Gate 0 suite covers: +- mixed loaded repetition + timed session; +- active session without `ended_at`; +- planned exercise with zero actual sets; +- bodyweight exercise with zero-repetition failed attempt; +- assistance load; +- completely interrupted session with zero exercises. -- duplicate exercise identifiers; +## 6. Negative fixture coverage + +Gate 1 includes rejection of: + +- duplicate exercise IDs; - duplicate normalized exercise names; -- unknown exercise references; - duplicate workout exercise entries; -- end timestamp before start timestamp; -- offset-less timestamp; -- target/actual mode mismatch; +- end timestamp before start; +- missing timestamp offset; +- target/actual tracking mismatch; - target containing both repetitions and duration; -- unknown JSON field. +- unknown exercise reference; +- unknown JSON field; +- catalog tracking-mode mismatch; +- `load_mode=none` carrying weight; +- loaded target missing weight; +- loaded actual set missing weight; +- unreferenced catalog entries; +- blank session notes; +- blank exercise notes; +- negative actual repetitions. -## 6. Database tests +## 7. Future database validation -Future tests must verify: +Gate 2 must verify: -- foreign keys are active; -- duplicate `session_id` is rejected or handled idempotently; -- duplicate `exercise_id` is rejected; -- failed imports roll back completely; -- migrations preserve data. +- foreign keys enabled; +- duplicate `session_id` idempotency; +- duplicate `exercise_id` barrier; +- transactional rollback; +- schema migration correctness. -## 7. C validation +## 8. Future C validation -Initial C validation will include: +C implementation gates will include: -```text -normal build -strict warning build -ASan/UBSan build -``` - -Exact commands will be frozen when `meson.build` exists. - -## 8. TUI tests - -At minimum: - -- application starts in a supported terminal; -- small-terminal fallback works; -- navigation does not corrupt state; -- UTF-8 labels render correctly; -- color roles render correctly; -- monochrome fallback remains understandable. +- normal build; +- strict-warning build; +- ASan/UBSan build; +- formatter check; +- relevant unit/integration tests. ## 9. Pre-push checklist -Before a meaningful push: +Before every meaningful push: -1. format code; -2. run `python tools/validate_json.py`; -3. build when buildable code exists; -4. run relevant tests; -5. run sanitizer suite when relevant; -6. run `git diff --check`; -7. inspect `git status --short`; -8. update documentation. +1. run `python tools/validate_json.py`; +2. run relevant compiled tests when available; +3. run sanitizers when relevant; +4. run `git diff --check`; +5. inspect `git status --short`; +6. review documentation changes. diff --git a/docs/tui.md b/docs/tui.md index 148969b..b071bcc 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -2,7 +2,7 @@ ## 1. Purpose -The Trainlog TUI is the primary history, analysis, and visualization application. +The Trainlog TUI is the primary local history, analysis, and visualization application. It is implemented in C17 with `ncursesw`. @@ -17,30 +17,28 @@ Initial screen plan: - Body; - Import. -The final key bindings will be frozen before implementation. - ## 3. Color -The TUI should be visually rich but remain readable. +The TUI is intentionally colorful. -Color is used to reinforce meaning, never as the only indicator. +Color reinforces meaning but is never the sole indicator. Conceptual roles: -- accent: titles, active selection, highlighted metrics; +- accent: titles and current selection; - success: completed target; - warning: partial target or attention state; - error: invalid input or failed operation; - muted: secondary information; - graph series: consistent distinguishable colors. -All color pairs must be centralized in a theme module. +All color pairs must be centralized in a dedicated theme module. -Do not scatter raw `COLOR_*` decisions throughout screens. +Raw screen code must not scatter `COLOR_*` decisions. ## 4. Monochrome fallback -Every meaningful color state must also have a textual or symbolic representation. +Meaningful states also use text or symbols. Examples: @@ -51,60 +49,98 @@ x failed > selected ``` -## 5. UTF-8 +## 5. UTF-8 and exercise-name normalization -The TUI uses wide-character ncurses support. +The TUI uses wide-character ncurses support and initializes locale before ncurses. -The implementation must initialize locale correctly before ncurses use. +Trainlog v1 duplicate-name validation requires Unicode NFC normalization, whitespace normalization, and Unicode case folding. -Rendering must be tested with accented French text and common symbols. +The C implementation must use a tested Unicode library or equivalent implementation that reproduces the v1 contract exactly. -## 6. Dashboard +A likely implementation dependency is `utf8proc`; the final dependency choice is frozen before the relevant C module is implemented. + +## 6. Exercise catalog + +Each exercise stores: + +- stable `exercise_id`; +- mutable display name; +- stable `tracking_mode` (`reps` or `duration`). + +The TUI uses this metadata to select the correct data-entry control. + +A session import may introduce a previously unknown exercise. + +If an existing ID arrives with a different display name, the session may import but the TUI must surface a metadata warning and must not silently rename the canonical local exercise. + +## 7. New session + +The TUI can record a workout directly using the same logical exercise model as Android. + +Per exercise: + +- exercise; +- load mode; +- target sets; +- target repetitions or duration; +- target load when applicable; +- planned rest; +- actual sets; +- optional note. + +## 8. Load semantics + +The TUI must distinguish: + +- no separate load; +- external resistance; +- assistance. + +Analytics must not rank assistance as though more assistance represented more strength. + +Machine-displayed kilograms are stored faithfully but must not be presented as exact cross-machine mechanical equivalence. + +## 9. Dashboard The dashboard should eventually show: - current body weight; -- recent body-weight change; -- number of sessions in a selected period; +- recent weight change; +- sessions in a selected period; - total training duration; - recent performance highlights; - compact terminal graphs. -## 7. New session +## 10. Body tracking -The TUI must be able to record a workout directly, using the same logical exercise catalog as Android. +The TUI database may store standalone body observations independently from workout imports. -A typical exercise form contains: +The session exchange format can also attach weight and measurements to one session timestamp. -- exercise; -- target sets; -- target repetitions or duration; -- target load when relevant; -- rest duration; -- actual performed sets. +Body-trend graphs operate on the canonical database representation, not directly on raw JSON files. -## 8. Graphs +## 11. Graphs -Graphs must be terminal-native. +Terminal-native graph targets include: -Possible graph types: - -- weight trend; +- body-weight trend; - measurement trend; -- exercise load trend; -- estimated or measured max trend; -- training volume trend. +- external-load trend; +- measured or estimated maximum trend; +- training-volume trend. -Graphs should adapt to terminal size. +Assistance exercises require direction-aware analytics. -## 9. Minimum terminal size +## 12. Minimum terminal size A minimum supported terminal size will be defined during the first TUI milestone. -Below the minimum size, Trainlog must display a clear message instead of rendering a broken layout. +Below that size, Trainlog displays a clear fallback message rather than a corrupted layout. -## 10. Input safety +## 13. Input safety -The TUI must validate numeric fields before committing data. +Numeric input is validated before persistent state is committed. -Invalid input must not partially modify persistent state. +Invalid input must never partially mutate a saved session. + +Imports use full validation before the database transaction commits. diff --git a/examples/session-v1.json b/examples/session-v1.json index 765c913..f9f3ac5 100644 --- a/examples/session-v1.json +++ b/examples/session-v1.json @@ -4,11 +4,13 @@ "exercises": [ { "exercise_id": "leg_press", - "name": "Presse à cuisses" + "name": "Presse à cuisses", + "tracking_mode": "reps" }, { "exercise_id": "plank", - "name": "Gainage ventral" + "name": "Gainage ventral", + "tracking_mode": "duration" } ], "session": { @@ -17,14 +19,19 @@ "ended_at": "2026-09-05T19:27:48+02:00", "body_weight_kg": 82.4, "measurements": { - "waist_cm": 91.0, + "neck_cm": 39.0, "chest_cm": 104.0, + "waist_cm": 91.0, "left_arm_cm": 35.0, - "right_arm_cm": 35.0 + "right_arm_cm": 35.0, + "left_thigh_cm": 58.0, + "right_thigh_cm": 57.0 }, + "notes": "Séance jambes et gainage.", "exercises": [ { "exercise_id": "leg_press", + "load_mode": "external", "rest_seconds": 60, "target": { "sets": 4, @@ -52,6 +59,7 @@ }, { "exercise_id": "plank", + "load_mode": "none", "rest_seconds": 60, "target": { "sets": 3, diff --git a/format/README.md b/format/README.md index 5613dd4..75e21eb 100644 --- a/format/README.md +++ b/format/README.md @@ -1,19 +1,24 @@ # Trainlog Format -This directory contains machine-readable definitions of the Trainlog exchange format. +This directory contains the machine-readable Trainlog exchange contract. -Current draft: +Current canonical state: -- `trainlog-v1.schema.json` +```text +GATE_0=PASS +GATE_1_REVIEW_01=IMPLEMENTED +GATE_1=VALIDATION_PENDING +TRAINLOG_FORMAT_V1=DRAFT +``` -Canonical human-readable semantics live in: +Files: -- `docs/exchange_format.md` +- `trainlog-v1.schema.json`: structural JSON Schema; +- `../docs/exchange_format.md`: canonical semantic specification; +- `../tools/validate_json.py`: executable structural + semantic validator. -The schema is a structural validator. +The JSON Schema alone is not the complete Trainlog contract. -Semantic rules that JSON Schema cannot safely express remain documented and must be tested in application code. +A document is valid only when it passes both structural and semantic validation. -Before Trainlog v1 is frozen, schema changes are allowed. - -After `TRAINLOG_FORMAT_V1=FROZEN`, incompatible changes require a new version. +After `TRAINLOG_FORMAT_V1=FROZEN`, incompatible changes require a new exchange-format version. diff --git a/format/trainlog-v1.schema.json b/format/trainlog-v1.schema.json index 7d99390..d7d598e 100644 --- a/format/trainlog-v1.schema.json +++ b/format/trainlog-v1.schema.json @@ -19,6 +19,7 @@ }, "exercises": { "type": "array", + "maxItems": 1000, "items": { "$ref": "#/$defs/exerciseCatalogEntry" } @@ -40,12 +41,18 @@ "maxLength": 128, "pattern": "^[A-Za-z0-9][A-Za-z0-9_-]*$" }, + "note": { + "type": "string", + "minLength": 1, + "maxLength": 4000 + }, "exerciseCatalogEntry": { "type": "object", "additionalProperties": false, "required": [ "exercise_id", - "name" + "name", + "tracking_mode" ], "properties": { "exercise_id": { @@ -55,16 +62,23 @@ "type": "string", "minLength": 1, "maxLength": 200 + }, + "tracking_mode": { + "type": "string", + "enum": [ + "reps", + "duration" + ] } } }, - "set": { + "actualSet": { "type": "object", "additionalProperties": false, "properties": { "reps": { "type": "integer", - "minimum": 1, + "minimum": 0, "maximum": 10000 }, "duration_seconds": { @@ -74,7 +88,7 @@ }, "weight_kg": { "type": "number", - "minimum": 0, + "exclusiveMinimum": 0, "maximum": 5000 } }, @@ -125,7 +139,7 @@ }, "weight_kg": { "type": "number", - "minimum": 0, + "exclusiveMinimum": 0, "maximum": 5000 } }, @@ -157,6 +171,7 @@ "additionalProperties": false, "required": [ "exercise_id", + "load_mode", "rest_seconds", "target", "sets" @@ -165,6 +180,14 @@ "exercise_id": { "$ref": "#/$defs/exerciseId" }, + "load_mode": { + "type": "string", + "enum": [ + "none", + "external", + "assistance" + ] + }, "rest_seconds": { "type": "integer", "minimum": 0, @@ -175,11 +198,15 @@ }, "sets": { "type": "array", - "minItems": 1, "maxItems": 1000, "items": { - "$ref": "#/$defs/set" + "$ref": "#/$defs/actualSet" } + }, + "notes": { + "type": "string", + "minLength": 1, + "maxLength": 1000 } } }, @@ -188,7 +215,12 @@ "additionalProperties": false, "minProperties": 1, "properties": { - "waist_cm": { + "neck_cm": { + "type": "number", + "exclusiveMinimum": 0, + "maximum": 500 + }, + "shoulders_cm": { "type": "number", "exclusiveMinimum": 0, "maximum": 500 @@ -198,7 +230,12 @@ "exclusiveMinimum": 0, "maximum": 500 }, - "shoulders_cm": { + "waist_cm": { + "type": "number", + "exclusiveMinimum": 0, + "maximum": 500 + }, + "hips_cm": { "type": "number", "exclusiveMinimum": 0, "maximum": 500 @@ -213,6 +250,16 @@ "exclusiveMinimum": 0, "maximum": 500 }, + "left_forearm_cm": { + "type": "number", + "exclusiveMinimum": 0, + "maximum": 500 + }, + "right_forearm_cm": { + "type": "number", + "exclusiveMinimum": 0, + "maximum": 500 + }, "left_thigh_cm": { "type": "number", "exclusiveMinimum": 0, @@ -263,9 +310,11 @@ "measurements": { "$ref": "#/$defs/measurements" }, + "notes": { + "$ref": "#/$defs/note" + }, "exercises": { "type": "array", - "minItems": 1, "maxItems": 1000, "items": { "$ref": "#/$defs/sessionExercise" diff --git a/tests/fixtures/invalid/blank-exercise-note.json b/tests/fixtures/invalid/blank-exercise-note.json new file mode 100644 index 0000000..f2fd241 --- /dev/null +++ b/tests/fixtures/invalid/blank-exercise-note.json @@ -0,0 +1,82 @@ +{ + "format": "trainlog", + "version": 1, + "exercises": [ + { + "exercise_id": "leg_press", + "name": "Presse à cuisses", + "tracking_mode": "reps" + }, + { + "exercise_id": "plank", + "name": "Gainage ventral", + "tracking_mode": "duration" + } + ], + "session": { + "session_id": "invalid-base", + "started_at": "2026-09-05T18:34:12+02:00", + "ended_at": "2026-09-05T19:27:48+02:00", + "body_weight_kg": 82.4, + "measurements": { + "neck_cm": 39.0, + "chest_cm": 104.0, + "waist_cm": 91.0, + "left_arm_cm": 35.0, + "right_arm_cm": 35.0, + "left_thigh_cm": 58.0, + "right_thigh_cm": 57.0 + }, + "exercises": [ + { + "exercise_id": "leg_press", + "load_mode": "external", + "rest_seconds": 60, + "target": { + "sets": 4, + "reps": 5, + "weight_kg": 80 + }, + "sets": [ + { + "reps": 5, + "weight_kg": 80 + }, + { + "reps": 5, + "weight_kg": 80 + }, + { + "reps": 5, + "weight_kg": 80 + }, + { + "reps": 3, + "weight_kg": 80 + } + ], + "notes": "\t\n" + }, + { + "exercise_id": "plank", + "load_mode": "none", + "rest_seconds": 60, + "target": { + "sets": 3, + "duration_seconds": 45 + }, + "sets": [ + { + "duration_seconds": 45 + }, + { + "duration_seconds": 45 + }, + { + "duration_seconds": 38 + } + ] + } + ] + } +} diff --git a/tests/fixtures/invalid/blank-session-note.json b/tests/fixtures/invalid/blank-session-note.json new file mode 100644 index 0000000..9118012 --- /dev/null +++ b/tests/fixtures/invalid/blank-session-note.json @@ -0,0 +1,82 @@ +{ + "format": "trainlog", + "version": 1, + "exercises": [ + { + "exercise_id": "leg_press", + "name": "Presse à cuisses", + "tracking_mode": "reps" + }, + { + "exercise_id": "plank", + "name": "Gainage ventral", + "tracking_mode": "duration" + } + ], + "session": { + "session_id": "invalid-base", + "started_at": "2026-09-05T18:34:12+02:00", + "ended_at": "2026-09-05T19:27:48+02:00", + "body_weight_kg": 82.4, + "measurements": { + "neck_cm": 39.0, + "chest_cm": 104.0, + "waist_cm": 91.0, + "left_arm_cm": 35.0, + "right_arm_cm": 35.0, + "left_thigh_cm": 58.0, + "right_thigh_cm": 57.0 + }, + "exercises": [ + { + "exercise_id": "leg_press", + "load_mode": "external", + "rest_seconds": 60, + "target": { + "sets": 4, + "reps": 5, + "weight_kg": 80 + }, + "sets": [ + { + "reps": 5, + "weight_kg": 80 + }, + { + "reps": 5, + "weight_kg": 80 + }, + { + "reps": 5, + "weight_kg": 80 + }, + { + "reps": 3, + "weight_kg": 80 + } + ] + }, + { + "exercise_id": "plank", + "load_mode": "none", + "rest_seconds": 60, + "target": { + "sets": 3, + "duration_seconds": 45 + }, + "sets": [ + { + "duration_seconds": 45 + }, + { + "duration_seconds": 45 + }, + { + "duration_seconds": 38 + } + ] + } + ], + "notes": " " + } +} diff --git a/tests/fixtures/invalid/duplicate-exercise-id.json b/tests/fixtures/invalid/duplicate-exercise-id.json index 35f958c..9c3c1a0 100644 --- a/tests/fixtures/invalid/duplicate-exercise-id.json +++ b/tests/fixtures/invalid/duplicate-exercise-id.json @@ -4,24 +4,38 @@ "exercises": [ { "exercise_id": "leg_press", - "name": "Presse à cuisses" + "name": "Presse à cuisses", + "tracking_mode": "reps" }, { "exercise_id": "plank", - "name": "Gainage ventral" + "name": "Gainage ventral", + "tracking_mode": "duration" }, { "exercise_id": "leg_press", - "name": "Autre presse" + "name": "Presse à cuisses", + "tracking_mode": "reps" } ], "session": { - "session_id": "fixture-session-1", - "started_at": "2026-09-05T18:00:00+02:00", - "ended_at": "2026-09-05T19:00:00+02:00", + "session_id": "invalid-base", + "started_at": "2026-09-05T18:34:12+02:00", + "ended_at": "2026-09-05T19:27:48+02:00", + "body_weight_kg": 82.4, + "measurements": { + "neck_cm": 39.0, + "chest_cm": 104.0, + "waist_cm": 91.0, + "left_arm_cm": 35.0, + "right_arm_cm": 35.0, + "left_thigh_cm": 58.0, + "right_thigh_cm": 57.0 + }, "exercises": [ { "exercise_id": "leg_press", + "load_mode": "external", "rest_seconds": 60, "target": { "sets": 4, @@ -36,6 +50,34 @@ { "reps": 5, "weight_kg": 80 + }, + { + "reps": 5, + "weight_kg": 80 + }, + { + "reps": 3, + "weight_kg": 80 + } + ] + }, + { + "exercise_id": "plank", + "load_mode": "none", + "rest_seconds": 60, + "target": { + "sets": 3, + "duration_seconds": 45 + }, + "sets": [ + { + "duration_seconds": 45 + }, + { + "duration_seconds": 45 + }, + { + "duration_seconds": 38 } ] } diff --git a/tests/fixtures/invalid/duplicate-exercise-name.json b/tests/fixtures/invalid/duplicate-exercise-name.json index a801556..23ecf66 100644 --- a/tests/fixtures/invalid/duplicate-exercise-name.json +++ b/tests/fixtures/invalid/duplicate-exercise-name.json @@ -4,24 +4,38 @@ "exercises": [ { "exercise_id": "leg_press", - "name": "Presse à cuisses" + "name": "Presse à cuisses", + "tracking_mode": "reps" }, { "exercise_id": "plank", - "name": "Gainage ventral" + "name": "Gainage ventral", + "tracking_mode": "duration" }, { "exercise_id": "leg_press_alt", - "name": " PRESSE À CUISSES " + "name": " PRESSE À CUISSES ", + "tracking_mode": "reps" } ], "session": { - "session_id": "fixture-session-1", - "started_at": "2026-09-05T18:00:00+02:00", - "ended_at": "2026-09-05T19:00:00+02:00", + "session_id": "invalid-base", + "started_at": "2026-09-05T18:34:12+02:00", + "ended_at": "2026-09-05T19:27:48+02:00", + "body_weight_kg": 82.4, + "measurements": { + "neck_cm": 39.0, + "chest_cm": 104.0, + "waist_cm": 91.0, + "left_arm_cm": 35.0, + "right_arm_cm": 35.0, + "left_thigh_cm": 58.0, + "right_thigh_cm": 57.0 + }, "exercises": [ { "exercise_id": "leg_press", + "load_mode": "external", "rest_seconds": 60, "target": { "sets": 4, @@ -36,6 +50,34 @@ { "reps": 5, "weight_kg": 80 + }, + { + "reps": 5, + "weight_kg": 80 + }, + { + "reps": 3, + "weight_kg": 80 + } + ] + }, + { + "exercise_id": "plank", + "load_mode": "none", + "rest_seconds": 60, + "target": { + "sets": 3, + "duration_seconds": 45 + }, + "sets": [ + { + "duration_seconds": 45 + }, + { + "duration_seconds": 45 + }, + { + "duration_seconds": 38 } ] } diff --git a/tests/fixtures/invalid/duplicate-workout-exercise.json b/tests/fixtures/invalid/duplicate-workout-exercise.json index fd6b9ae..b2c5297 100644 --- a/tests/fixtures/invalid/duplicate-workout-exercise.json +++ b/tests/fixtures/invalid/duplicate-workout-exercise.json @@ -4,20 +4,33 @@ "exercises": [ { "exercise_id": "leg_press", - "name": "Presse à cuisses" + "name": "Presse à cuisses", + "tracking_mode": "reps" }, { "exercise_id": "plank", - "name": "Gainage ventral" + "name": "Gainage ventral", + "tracking_mode": "duration" } ], "session": { - "session_id": "fixture-session-1", - "started_at": "2026-09-05T18:00:00+02:00", - "ended_at": "2026-09-05T19:00:00+02:00", + "session_id": "invalid-base", + "started_at": "2026-09-05T18:34:12+02:00", + "ended_at": "2026-09-05T19:27:48+02:00", + "body_weight_kg": 82.4, + "measurements": { + "neck_cm": 39.0, + "chest_cm": 104.0, + "waist_cm": 91.0, + "left_arm_cm": 35.0, + "right_arm_cm": 35.0, + "left_thigh_cm": 58.0, + "right_thigh_cm": 57.0 + }, "exercises": [ { "exercise_id": "leg_press", + "load_mode": "external", "rest_seconds": 60, "target": { "sets": 4, @@ -32,11 +45,40 @@ { "reps": 5, "weight_kg": 80 + }, + { + "reps": 5, + "weight_kg": 80 + }, + { + "reps": 3, + "weight_kg": 80 + } + ] + }, + { + "exercise_id": "plank", + "load_mode": "none", + "rest_seconds": 60, + "target": { + "sets": 3, + "duration_seconds": 45 + }, + "sets": [ + { + "duration_seconds": 45 + }, + { + "duration_seconds": 45 + }, + { + "duration_seconds": 38 } ] }, { "exercise_id": "leg_press", + "load_mode": "external", "rest_seconds": 60, "target": { "sets": 4, @@ -51,6 +93,14 @@ { "reps": 5, "weight_kg": 80 + }, + { + "reps": 5, + "weight_kg": 80 + }, + { + "reps": 3, + "weight_kg": 80 } ] } diff --git a/tests/fixtures/invalid/end-before-start.json b/tests/fixtures/invalid/end-before-start.json index d65488b..35dd411 100644 --- a/tests/fixtures/invalid/end-before-start.json +++ b/tests/fixtures/invalid/end-before-start.json @@ -4,20 +4,33 @@ "exercises": [ { "exercise_id": "leg_press", - "name": "Presse à cuisses" + "name": "Presse à cuisses", + "tracking_mode": "reps" }, { "exercise_id": "plank", - "name": "Gainage ventral" + "name": "Gainage ventral", + "tracking_mode": "duration" } ], "session": { - "session_id": "fixture-session-1", - "started_at": "2026-09-05T18:00:00+02:00", - "ended_at": "2026-09-05T17:59:59+02:00", + "session_id": "invalid-base", + "started_at": "2026-09-05T18:34:12+02:00", + "ended_at": "2026-09-05T17:00:00+02:00", + "body_weight_kg": 82.4, + "measurements": { + "neck_cm": 39.0, + "chest_cm": 104.0, + "waist_cm": 91.0, + "left_arm_cm": 35.0, + "right_arm_cm": 35.0, + "left_thigh_cm": 58.0, + "right_thigh_cm": 57.0 + }, "exercises": [ { "exercise_id": "leg_press", + "load_mode": "external", "rest_seconds": 60, "target": { "sets": 4, @@ -32,6 +45,34 @@ { "reps": 5, "weight_kg": 80 + }, + { + "reps": 5, + "weight_kg": 80 + }, + { + "reps": 3, + "weight_kg": 80 + } + ] + }, + { + "exercise_id": "plank", + "load_mode": "none", + "rest_seconds": 60, + "target": { + "sets": 3, + "duration_seconds": 45 + }, + "sets": [ + { + "duration_seconds": 45 + }, + { + "duration_seconds": 45 + }, + { + "duration_seconds": 38 } ] } diff --git a/tests/fixtures/invalid/external-set-missing-weight.json b/tests/fixtures/invalid/external-set-missing-weight.json new file mode 100644 index 0000000..4ef2971 --- /dev/null +++ b/tests/fixtures/invalid/external-set-missing-weight.json @@ -0,0 +1,80 @@ +{ + "format": "trainlog", + "version": 1, + "exercises": [ + { + "exercise_id": "leg_press", + "name": "Presse à cuisses", + "tracking_mode": "reps" + }, + { + "exercise_id": "plank", + "name": "Gainage ventral", + "tracking_mode": "duration" + } + ], + "session": { + "session_id": "invalid-base", + "started_at": "2026-09-05T18:34:12+02:00", + "ended_at": "2026-09-05T19:27:48+02:00", + "body_weight_kg": 82.4, + "measurements": { + "neck_cm": 39.0, + "chest_cm": 104.0, + "waist_cm": 91.0, + "left_arm_cm": 35.0, + "right_arm_cm": 35.0, + "left_thigh_cm": 58.0, + "right_thigh_cm": 57.0 + }, + "exercises": [ + { + "exercise_id": "leg_press", + "load_mode": "external", + "rest_seconds": 60, + "target": { + "sets": 4, + "reps": 5, + "weight_kg": 80 + }, + "sets": [ + { + "reps": 5, + "weight_kg": 80 + }, + { + "reps": 5 + }, + { + "reps": 5, + "weight_kg": 80 + }, + { + "reps": 3, + "weight_kg": 80 + } + ] + }, + { + "exercise_id": "plank", + "load_mode": "none", + "rest_seconds": 60, + "target": { + "sets": 3, + "duration_seconds": 45 + }, + "sets": [ + { + "duration_seconds": 45 + }, + { + "duration_seconds": 45 + }, + { + "duration_seconds": 38 + } + ] + } + ] + } +} diff --git a/tests/fixtures/invalid/external-target-missing-weight.json b/tests/fixtures/invalid/external-target-missing-weight.json new file mode 100644 index 0000000..4d64ee1 --- /dev/null +++ b/tests/fixtures/invalid/external-target-missing-weight.json @@ -0,0 +1,80 @@ +{ + "format": "trainlog", + "version": 1, + "exercises": [ + { + "exercise_id": "leg_press", + "name": "Presse à cuisses", + "tracking_mode": "reps" + }, + { + "exercise_id": "plank", + "name": "Gainage ventral", + "tracking_mode": "duration" + } + ], + "session": { + "session_id": "invalid-base", + "started_at": "2026-09-05T18:34:12+02:00", + "ended_at": "2026-09-05T19:27:48+02:00", + "body_weight_kg": 82.4, + "measurements": { + "neck_cm": 39.0, + "chest_cm": 104.0, + "waist_cm": 91.0, + "left_arm_cm": 35.0, + "right_arm_cm": 35.0, + "left_thigh_cm": 58.0, + "right_thigh_cm": 57.0 + }, + "exercises": [ + { + "exercise_id": "leg_press", + "load_mode": "external", + "rest_seconds": 60, + "target": { + "sets": 4, + "reps": 5 + }, + "sets": [ + { + "reps": 5, + "weight_kg": 80 + }, + { + "reps": 5, + "weight_kg": 80 + }, + { + "reps": 5, + "weight_kg": 80 + }, + { + "reps": 3, + "weight_kg": 80 + } + ] + }, + { + "exercise_id": "plank", + "load_mode": "none", + "rest_seconds": 60, + "target": { + "sets": 3, + "duration_seconds": 45 + }, + "sets": [ + { + "duration_seconds": 45 + }, + { + "duration_seconds": 45 + }, + { + "duration_seconds": 38 + } + ] + } + ] + } +} diff --git a/tests/fixtures/invalid/load-none-with-weight.json b/tests/fixtures/invalid/load-none-with-weight.json new file mode 100644 index 0000000..0d59a96 --- /dev/null +++ b/tests/fixtures/invalid/load-none-with-weight.json @@ -0,0 +1,81 @@ +{ + "format": "trainlog", + "version": 1, + "exercises": [ + { + "exercise_id": "leg_press", + "name": "Presse à cuisses", + "tracking_mode": "reps" + }, + { + "exercise_id": "plank", + "name": "Gainage ventral", + "tracking_mode": "duration" + } + ], + "session": { + "session_id": "invalid-base", + "started_at": "2026-09-05T18:34:12+02:00", + "ended_at": "2026-09-05T19:27:48+02:00", + "body_weight_kg": 82.4, + "measurements": { + "neck_cm": 39.0, + "chest_cm": 104.0, + "waist_cm": 91.0, + "left_arm_cm": 35.0, + "right_arm_cm": 35.0, + "left_thigh_cm": 58.0, + "right_thigh_cm": 57.0 + }, + "exercises": [ + { + "exercise_id": "leg_press", + "load_mode": "none", + "rest_seconds": 60, + "target": { + "sets": 4, + "reps": 5, + "weight_kg": 80 + }, + "sets": [ + { + "reps": 5, + "weight_kg": 80 + }, + { + "reps": 5, + "weight_kg": 80 + }, + { + "reps": 5, + "weight_kg": 80 + }, + { + "reps": 3, + "weight_kg": 80 + } + ] + }, + { + "exercise_id": "plank", + "load_mode": "none", + "rest_seconds": 60, + "target": { + "sets": 3, + "duration_seconds": 45 + }, + "sets": [ + { + "duration_seconds": 45 + }, + { + "duration_seconds": 45 + }, + { + "duration_seconds": 38 + } + ] + } + ] + } +} diff --git a/tests/fixtures/invalid/negative-actual-reps.json b/tests/fixtures/invalid/negative-actual-reps.json new file mode 100644 index 0000000..83b90ad --- /dev/null +++ b/tests/fixtures/invalid/negative-actual-reps.json @@ -0,0 +1,81 @@ +{ + "format": "trainlog", + "version": 1, + "exercises": [ + { + "exercise_id": "leg_press", + "name": "Presse à cuisses", + "tracking_mode": "reps" + }, + { + "exercise_id": "plank", + "name": "Gainage ventral", + "tracking_mode": "duration" + } + ], + "session": { + "session_id": "invalid-base", + "started_at": "2026-09-05T18:34:12+02:00", + "ended_at": "2026-09-05T19:27:48+02:00", + "body_weight_kg": 82.4, + "measurements": { + "neck_cm": 39.0, + "chest_cm": 104.0, + "waist_cm": 91.0, + "left_arm_cm": 35.0, + "right_arm_cm": 35.0, + "left_thigh_cm": 58.0, + "right_thigh_cm": 57.0 + }, + "exercises": [ + { + "exercise_id": "leg_press", + "load_mode": "external", + "rest_seconds": 60, + "target": { + "sets": 4, + "reps": 5, + "weight_kg": 80 + }, + "sets": [ + { + "reps": -1, + "weight_kg": 80 + }, + { + "reps": 5, + "weight_kg": 80 + }, + { + "reps": 5, + "weight_kg": 80 + }, + { + "reps": 3, + "weight_kg": 80 + } + ] + }, + { + "exercise_id": "plank", + "load_mode": "none", + "rest_seconds": 60, + "target": { + "sets": 3, + "duration_seconds": 45 + }, + "sets": [ + { + "duration_seconds": 45 + }, + { + "duration_seconds": 45 + }, + { + "duration_seconds": 38 + } + ] + } + ] + } +} diff --git a/tests/fixtures/invalid/target-actual-mode-mismatch.json b/tests/fixtures/invalid/target-actual-mode-mismatch.json index 661534e..88ece63 100644 --- a/tests/fixtures/invalid/target-actual-mode-mismatch.json +++ b/tests/fixtures/invalid/target-actual-mode-mismatch.json @@ -4,20 +4,33 @@ "exercises": [ { "exercise_id": "leg_press", - "name": "Presse à cuisses" + "name": "Presse à cuisses", + "tracking_mode": "reps" }, { "exercise_id": "plank", - "name": "Gainage ventral" + "name": "Gainage ventral", + "tracking_mode": "duration" } ], "session": { - "session_id": "fixture-session-1", - "started_at": "2026-09-05T18:00:00+02:00", - "ended_at": "2026-09-05T19:00:00+02:00", + "session_id": "invalid-base", + "started_at": "2026-09-05T18:34:12+02:00", + "ended_at": "2026-09-05T19:27:48+02:00", + "body_weight_kg": 82.4, + "measurements": { + "neck_cm": 39.0, + "chest_cm": 104.0, + "waist_cm": 91.0, + "left_arm_cm": 35.0, + "right_arm_cm": 35.0, + "left_thigh_cm": 58.0, + "right_thigh_cm": 57.0 + }, "exercises": [ { "exercise_id": "leg_press", + "load_mode": "external", "rest_seconds": 60, "target": { "sets": 4, @@ -26,11 +39,40 @@ }, "sets": [ { - "duration_seconds": 30 + "duration_seconds": 30, + "weight_kg": 80 }, { "reps": 5, "weight_kg": 80 + }, + { + "reps": 5, + "weight_kg": 80 + }, + { + "reps": 3, + "weight_kg": 80 + } + ] + }, + { + "exercise_id": "plank", + "load_mode": "none", + "rest_seconds": 60, + "target": { + "sets": 3, + "duration_seconds": 45 + }, + "sets": [ + { + "duration_seconds": 45 + }, + { + "duration_seconds": 45 + }, + { + "duration_seconds": 38 } ] } diff --git a/tests/fixtures/invalid/target-both-reps-duration.json b/tests/fixtures/invalid/target-both-reps-duration.json index 9a3f808..48d4b71 100644 --- a/tests/fixtures/invalid/target-both-reps-duration.json +++ b/tests/fixtures/invalid/target-both-reps-duration.json @@ -4,20 +4,33 @@ "exercises": [ { "exercise_id": "leg_press", - "name": "Presse à cuisses" + "name": "Presse à cuisses", + "tracking_mode": "reps" }, { "exercise_id": "plank", - "name": "Gainage ventral" + "name": "Gainage ventral", + "tracking_mode": "duration" } ], "session": { - "session_id": "fixture-session-1", - "started_at": "2026-09-05T18:00:00+02:00", - "ended_at": "2026-09-05T19:00:00+02:00", + "session_id": "invalid-base", + "started_at": "2026-09-05T18:34:12+02:00", + "ended_at": "2026-09-05T19:27:48+02:00", + "body_weight_kg": 82.4, + "measurements": { + "neck_cm": 39.0, + "chest_cm": 104.0, + "waist_cm": 91.0, + "left_arm_cm": 35.0, + "right_arm_cm": 35.0, + "left_thigh_cm": 58.0, + "right_thigh_cm": 57.0 + }, "exercises": [ { "exercise_id": "leg_press", + "load_mode": "external", "rest_seconds": 60, "target": { "sets": 4, @@ -33,6 +46,34 @@ { "reps": 5, "weight_kg": 80 + }, + { + "reps": 5, + "weight_kg": 80 + }, + { + "reps": 3, + "weight_kg": 80 + } + ] + }, + { + "exercise_id": "plank", + "load_mode": "none", + "rest_seconds": 60, + "target": { + "sets": 3, + "duration_seconds": 45 + }, + "sets": [ + { + "duration_seconds": 45 + }, + { + "duration_seconds": 45 + }, + { + "duration_seconds": 38 } ] } diff --git a/tests/fixtures/invalid/timestamp-without-offset.json b/tests/fixtures/invalid/timestamp-without-offset.json index fb6ac47..3d0f5f2 100644 --- a/tests/fixtures/invalid/timestamp-without-offset.json +++ b/tests/fixtures/invalid/timestamp-without-offset.json @@ -4,20 +4,33 @@ "exercises": [ { "exercise_id": "leg_press", - "name": "Presse à cuisses" + "name": "Presse à cuisses", + "tracking_mode": "reps" }, { "exercise_id": "plank", - "name": "Gainage ventral" + "name": "Gainage ventral", + "tracking_mode": "duration" } ], "session": { - "session_id": "fixture-session-1", - "started_at": "2026-09-05T18:00:00", - "ended_at": "2026-09-05T19:00:00+02:00", + "session_id": "invalid-base", + "started_at": "2026-09-05T18:34:12", + "ended_at": "2026-09-05T19:27:48+02:00", + "body_weight_kg": 82.4, + "measurements": { + "neck_cm": 39.0, + "chest_cm": 104.0, + "waist_cm": 91.0, + "left_arm_cm": 35.0, + "right_arm_cm": 35.0, + "left_thigh_cm": 58.0, + "right_thigh_cm": 57.0 + }, "exercises": [ { "exercise_id": "leg_press", + "load_mode": "external", "rest_seconds": 60, "target": { "sets": 4, @@ -32,6 +45,34 @@ { "reps": 5, "weight_kg": 80 + }, + { + "reps": 5, + "weight_kg": 80 + }, + { + "reps": 3, + "weight_kg": 80 + } + ] + }, + { + "exercise_id": "plank", + "load_mode": "none", + "rest_seconds": 60, + "target": { + "sets": 3, + "duration_seconds": 45 + }, + "sets": [ + { + "duration_seconds": 45 + }, + { + "duration_seconds": 45 + }, + { + "duration_seconds": 38 } ] } diff --git a/tests/fixtures/invalid/tracking-mode-target-mismatch.json b/tests/fixtures/invalid/tracking-mode-target-mismatch.json new file mode 100644 index 0000000..4a3a011 --- /dev/null +++ b/tests/fixtures/invalid/tracking-mode-target-mismatch.json @@ -0,0 +1,81 @@ +{ + "format": "trainlog", + "version": 1, + "exercises": [ + { + "exercise_id": "leg_press", + "name": "Presse à cuisses", + "tracking_mode": "duration" + }, + { + "exercise_id": "plank", + "name": "Gainage ventral", + "tracking_mode": "duration" + } + ], + "session": { + "session_id": "invalid-base", + "started_at": "2026-09-05T18:34:12+02:00", + "ended_at": "2026-09-05T19:27:48+02:00", + "body_weight_kg": 82.4, + "measurements": { + "neck_cm": 39.0, + "chest_cm": 104.0, + "waist_cm": 91.0, + "left_arm_cm": 35.0, + "right_arm_cm": 35.0, + "left_thigh_cm": 58.0, + "right_thigh_cm": 57.0 + }, + "exercises": [ + { + "exercise_id": "leg_press", + "load_mode": "external", + "rest_seconds": 60, + "target": { + "sets": 4, + "reps": 5, + "weight_kg": 80 + }, + "sets": [ + { + "reps": 5, + "weight_kg": 80 + }, + { + "reps": 5, + "weight_kg": 80 + }, + { + "reps": 5, + "weight_kg": 80 + }, + { + "reps": 3, + "weight_kg": 80 + } + ] + }, + { + "exercise_id": "plank", + "load_mode": "none", + "rest_seconds": 60, + "target": { + "sets": 3, + "duration_seconds": 45 + }, + "sets": [ + { + "duration_seconds": 45 + }, + { + "duration_seconds": 45 + }, + { + "duration_seconds": 38 + } + ] + } + ] + } +} diff --git a/tests/fixtures/invalid/unknown-exercise-reference.json b/tests/fixtures/invalid/unknown-exercise-reference.json index a264fd0..b800abd 100644 --- a/tests/fixtures/invalid/unknown-exercise-reference.json +++ b/tests/fixtures/invalid/unknown-exercise-reference.json @@ -4,20 +4,33 @@ "exercises": [ { "exercise_id": "leg_press", - "name": "Presse à cuisses" + "name": "Presse à cuisses", + "tracking_mode": "reps" }, { "exercise_id": "plank", - "name": "Gainage ventral" + "name": "Gainage ventral", + "tracking_mode": "duration" } ], "session": { - "session_id": "fixture-session-1", - "started_at": "2026-09-05T18:00:00+02:00", - "ended_at": "2026-09-05T19:00:00+02:00", + "session_id": "invalid-base", + "started_at": "2026-09-05T18:34:12+02:00", + "ended_at": "2026-09-05T19:27:48+02:00", + "body_weight_kg": 82.4, + "measurements": { + "neck_cm": 39.0, + "chest_cm": 104.0, + "waist_cm": 91.0, + "left_arm_cm": 35.0, + "right_arm_cm": 35.0, + "left_thigh_cm": 58.0, + "right_thigh_cm": 57.0 + }, "exercises": [ { "exercise_id": "unknown_exercise", + "load_mode": "external", "rest_seconds": 60, "target": { "sets": 4, @@ -32,6 +45,34 @@ { "reps": 5, "weight_kg": 80 + }, + { + "reps": 5, + "weight_kg": 80 + }, + { + "reps": 3, + "weight_kg": 80 + } + ] + }, + { + "exercise_id": "plank", + "load_mode": "none", + "rest_seconds": 60, + "target": { + "sets": 3, + "duration_seconds": 45 + }, + "sets": [ + { + "duration_seconds": 45 + }, + { + "duration_seconds": 45 + }, + { + "duration_seconds": 38 } ] } diff --git a/tests/fixtures/invalid/unknown-field.json b/tests/fixtures/invalid/unknown-field.json index 1bd72b8..7e0678d 100644 --- a/tests/fixtures/invalid/unknown-field.json +++ b/tests/fixtures/invalid/unknown-field.json @@ -4,20 +4,33 @@ "exercises": [ { "exercise_id": "leg_press", - "name": "Presse à cuisses" + "name": "Presse à cuisses", + "tracking_mode": "reps" }, { "exercise_id": "plank", - "name": "Gainage ventral" + "name": "Gainage ventral", + "tracking_mode": "duration" } ], "session": { - "session_id": "fixture-session-1", - "started_at": "2026-09-05T18:00:00+02:00", - "ended_at": "2026-09-05T19:00:00+02:00", + "session_id": "invalid-base", + "started_at": "2026-09-05T18:34:12+02:00", + "ended_at": "2026-09-05T19:27:48+02:00", + "body_weight_kg": 82.4, + "measurements": { + "neck_cm": 39.0, + "chest_cm": 104.0, + "waist_cm": 91.0, + "left_arm_cm": 35.0, + "right_arm_cm": 35.0, + "left_thigh_cm": 58.0, + "right_thigh_cm": 57.0 + }, "exercises": [ { "exercise_id": "leg_press", + "load_mode": "external", "rest_seconds": 60, "target": { "sets": 4, @@ -32,6 +45,34 @@ { "reps": 5, "weight_kg": 80 + }, + { + "reps": 5, + "weight_kg": 80 + }, + { + "reps": 3, + "weight_kg": 80 + } + ] + }, + { + "exercise_id": "plank", + "load_mode": "none", + "rest_seconds": 60, + "target": { + "sets": 3, + "duration_seconds": 45 + }, + "sets": [ + { + "duration_seconds": 45 + }, + { + "duration_seconds": 45 + }, + { + "duration_seconds": 38 } ] } diff --git a/tests/fixtures/invalid/unreferenced-catalog-entry.json b/tests/fixtures/invalid/unreferenced-catalog-entry.json new file mode 100644 index 0000000..3c1aef1 --- /dev/null +++ b/tests/fixtures/invalid/unreferenced-catalog-entry.json @@ -0,0 +1,86 @@ +{ + "format": "trainlog", + "version": 1, + "exercises": [ + { + "exercise_id": "leg_press", + "name": "Presse à cuisses", + "tracking_mode": "reps" + }, + { + "exercise_id": "plank", + "name": "Gainage ventral", + "tracking_mode": "duration" + }, + { + "exercise_id": "unused", + "name": "Exercice inutilisé", + "tracking_mode": "reps" + } + ], + "session": { + "session_id": "invalid-base", + "started_at": "2026-09-05T18:34:12+02:00", + "ended_at": "2026-09-05T19:27:48+02:00", + "body_weight_kg": 82.4, + "measurements": { + "neck_cm": 39.0, + "chest_cm": 104.0, + "waist_cm": 91.0, + "left_arm_cm": 35.0, + "right_arm_cm": 35.0, + "left_thigh_cm": 58.0, + "right_thigh_cm": 57.0 + }, + "exercises": [ + { + "exercise_id": "leg_press", + "load_mode": "external", + "rest_seconds": 60, + "target": { + "sets": 4, + "reps": 5, + "weight_kg": 80 + }, + "sets": [ + { + "reps": 5, + "weight_kg": 80 + }, + { + "reps": 5, + "weight_kg": 80 + }, + { + "reps": 5, + "weight_kg": 80 + }, + { + "reps": 3, + "weight_kg": 80 + } + ] + }, + { + "exercise_id": "plank", + "load_mode": "none", + "rest_seconds": 60, + "target": { + "sets": 3, + "duration_seconds": 45 + }, + "sets": [ + { + "duration_seconds": 45 + }, + { + "duration_seconds": 45 + }, + { + "duration_seconds": 38 + } + ] + } + ] + } +} diff --git a/tests/fixtures/valid/active-session.json b/tests/fixtures/valid/active-session.json index b10358c..5e11712 100644 --- a/tests/fixtures/valid/active-session.json +++ b/tests/fixtures/valid/active-session.json @@ -4,7 +4,8 @@ "exercises": [ { "exercise_id": "plank", - "name": "Gainage ventral" + "name": "Gainage ventral", + "tracking_mode": "duration" } ], "session": { @@ -13,19 +14,13 @@ "exercises": [ { "exercise_id": "plank", + "load_mode": "none", "rest_seconds": 60, "target": { "sets": 3, "duration_seconds": 45 }, - "sets": [ - { - "duration_seconds": 45 - }, - { - "duration_seconds": 38 - } - ] + "sets": [] } ] } diff --git a/tests/fixtures/valid/assistance-load.json b/tests/fixtures/valid/assistance-load.json new file mode 100644 index 0000000..5c1c009 --- /dev/null +++ b/tests/fixtures/valid/assistance-load.json @@ -0,0 +1,42 @@ +{ + "format": "trainlog", + "version": 1, + "exercises": [ + { + "exercise_id": "assisted_pull_up", + "name": "Traction assistée", + "tracking_mode": "reps" + } + ], + "session": { + "session_id": "assisted-pull-up", + "started_at": "2026-09-05T18:00:00+02:00", + "ended_at": "2026-09-05T18:15:00+02:00", + "exercises": [ + { + "exercise_id": "assisted_pull_up", + "load_mode": "assistance", + "rest_seconds": 90, + "target": { + "sets": 3, + "reps": 8, + "weight_kg": 20 + }, + "sets": [ + { + "reps": 8, + "weight_kg": 20 + }, + { + "reps": 8, + "weight_kg": 20 + }, + { + "reps": 6, + "weight_kg": 20 + } + ] + } + ] + } +} diff --git a/tests/fixtures/valid/bodyweight-zero-rep.json b/tests/fixtures/valid/bodyweight-zero-rep.json new file mode 100644 index 0000000..5de2586 --- /dev/null +++ b/tests/fixtures/valid/bodyweight-zero-rep.json @@ -0,0 +1,32 @@ +{ + "format": "trainlog", + "version": 1, + "exercises": [ + { + "exercise_id": "pull_up", + "name": "Traction", + "tracking_mode": "reps" + } + ], + "session": { + "session_id": "zero-rep-attempt", + "started_at": "2026-09-05T18:00:00+02:00", + "ended_at": "2026-09-05T18:10:00+02:00", + "exercises": [ + { + "exercise_id": "pull_up", + "load_mode": "none", + "rest_seconds": 120, + "target": { + "sets": 1, + "reps": 1 + }, + "sets": [ + { + "reps": 0 + } + ] + } + ] + } +} diff --git a/tests/fixtures/valid/empty-interrupted-session.json b/tests/fixtures/valid/empty-interrupted-session.json new file mode 100644 index 0000000..a96fa39 --- /dev/null +++ b/tests/fixtures/valid/empty-interrupted-session.json @@ -0,0 +1,11 @@ +{ + "format": "trainlog", + "version": 1, + "exercises": [], + "session": { + "session_id": "interrupted-before-first-exercise", + "started_at": "2026-09-05T18:00:00+02:00", + "notes": "Séance interrompue avant le premier exercice.", + "exercises": [] + } +} diff --git a/tools/validate_json.py b/tools/validate_json.py index 2b42764..3834655 100755 --- a/tools/validate_json.py +++ b/tools/validate_json.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Validate Trainlog JSON documents structurally and semantically.""" +"""Validate Trainlog v1 JSON documents structurally and semantically.""" from __future__ import annotations @@ -29,7 +29,7 @@ INVALID_FIXTURE_DIR = ROOT / "tests" / "fixtures" / "invalid" class TrainlogSemanticError(ValueError): - """Raised when structurally valid JSON violates Trainlog semantics.""" + """Raised when structurally valid JSON violates Trainlog v1 semantics.""" def load_json(path: Path) -> Any: @@ -39,11 +39,7 @@ def load_json(path: Path) -> Any: def normalize_exercise_name(name: str) -> str: - """Return the canonical comparison form used for duplicate-name checks. - - The serialized display name is never rewritten by this function. The - normalized value exists only for semantic identity checks. - """ + """Return the canonical comparison form for duplicate-name validation.""" nfc = unicodedata.normalize("NFC", name) collapsed = " ".join(nfc.strip().split()) return collapsed.casefold() @@ -70,24 +66,96 @@ def parse_timestamp(value: str, field_name: str) -> datetime: return parsed +def require_non_blank(value: str, field_name: str) -> None: + """Reject a note that contains only Unicode whitespace.""" + if not value.strip(): + raise TrainlogSemanticError(f"{field_name}: must not be blank") + + +def validate_load_mode( + workout: dict[str, Any], + workout_index: int, +) -> None: + """Validate load-mode rules for one session exercise.""" + load_mode = workout["load_mode"] + target = workout["target"] + actual_sets = workout["sets"] + + target_has_weight = "weight_kg" in target + + if load_mode == "none": + if target_has_weight: + raise TrainlogSemanticError( + f"session.exercises[{workout_index}].target.weight_kg: " + "forbidden when load_mode is 'none'" + ) + + for set_index, actual_set in enumerate(actual_sets): + if "weight_kg" in actual_set: + raise TrainlogSemanticError( + f"session.exercises[{workout_index}].sets[{set_index}]." + "weight_kg: forbidden when load_mode is 'none'" + ) + return + + if not target_has_weight: + raise TrainlogSemanticError( + f"session.exercises[{workout_index}].target.weight_kg: " + f"required when load_mode is {load_mode!r}" + ) + + for set_index, actual_set in enumerate(actual_sets): + if "weight_kg" not in actual_set: + raise TrainlogSemanticError( + f"session.exercises[{workout_index}].sets[{set_index}]." + f"weight_kg: required when load_mode is {load_mode!r}" + ) + + +def validate_tracking_mode( + workout: dict[str, Any], + workout_index: int, + tracking_mode: str, +) -> None: + """Validate target and actual-set metric fields against catalog mode.""" + target = workout["target"] + target_mode = "reps" if "reps" in target else "duration" + + if target_mode != tracking_mode: + raise TrainlogSemanticError( + f"session.exercises[{workout_index}].target: mode " + f"{target_mode!r} does not match catalog tracking_mode " + f"{tracking_mode!r}" + ) + + for set_index, actual_set in enumerate(workout["sets"]): + actual_mode = "reps" if "reps" in actual_set else "duration" + if actual_mode != tracking_mode: + raise TrainlogSemanticError( + f"session.exercises[{workout_index}].sets[{set_index}]: " + f"mode {actual_mode!r} does not match catalog tracking_mode " + f"{tracking_mode!r}" + ) + + def validate_semantics(document: dict[str, Any]) -> None: - """Validate cross-field and normalized Trainlog v1 invariants.""" + """Validate Trainlog v1 cross-field and normalized invariants.""" catalog = document["exercises"] session = document["session"] - exercise_ids: set[str] = set() + catalog_by_id: dict[str, dict[str, Any]] = {} normalized_names: dict[str, str] = {} for index, exercise in enumerate(catalog): exercise_id = exercise["exercise_id"] name = exercise["name"] - if exercise_id in exercise_ids: + if exercise_id in catalog_by_id: raise TrainlogSemanticError( f"exercises[{index}].exercise_id: duplicate exercise_id " f"{exercise_id!r}" ) - exercise_ids.add(exercise_id) + catalog_by_id[exercise_id] = exercise normalized = normalize_exercise_name(name) if not normalized: @@ -112,12 +180,16 @@ def validate_semantics(document: dict[str, Any]) -> None: "session.ended_at: must be strictly later than session.started_at" ) + if "notes" in session: + require_non_blank(session["notes"], "session.notes") + workout_ids: set[str] = set() for index, workout in enumerate(session["exercises"]): exercise_id = workout["exercise_id"] - if exercise_id not in exercise_ids: + catalog_entry = catalog_by_id.get(exercise_id) + if catalog_entry is None: raise TrainlogSemanticError( f"session.exercises[{index}].exercise_id: unknown catalog " f"reference {exercise_id!r}" @@ -130,21 +202,31 @@ def validate_semantics(document: dict[str, Any]) -> None: ) workout_ids.add(exercise_id) - target = workout["target"] - target_mode = ( - "reps" if "reps" in target else "duration_seconds" + validate_tracking_mode( + workout, + index, + catalog_entry["tracking_mode"], ) + validate_load_mode(workout, index) - for set_index, actual_set in enumerate(workout["sets"]): - actual_mode = ( - "reps" if "reps" in actual_set else "duration_seconds" + if "notes" in workout: + require_non_blank( + workout["notes"], + f"session.exercises[{index}].notes", ) - if actual_mode != target_mode: - raise TrainlogSemanticError( - f"session.exercises[{index}].sets[{set_index}]: " - f"actual mode {actual_mode!r} does not match target mode " - f"{target_mode!r}" - ) + + catalog_ids = set(catalog_by_id) + if catalog_ids != workout_ids: + unreferenced = sorted(catalog_ids - workout_ids) + missing = sorted(workout_ids - catalog_ids) + details: list[str] = [] + if unreferenced: + details.append(f"unreferenced catalog ids: {unreferenced}") + if missing: + details.append(f"missing catalog ids: {missing}") + raise TrainlogSemanticError( + "catalog/reference set mismatch: " + "; ".join(details) + ) def structural_errors( @@ -171,7 +253,7 @@ def validate_document( validator: jsonschema.Draft202012Validator, path: Path, ) -> list[str]: - """Return all validation errors for one Trainlog document.""" + """Return validation errors for one Trainlog document.""" try: document = load_json(path) except (OSError, json.JSONDecodeError) as exc: @@ -202,6 +284,10 @@ def run_suite(validator: jsonschema.Draft202012Validator) -> int: valid, invalid = discover_suite() failed = False + if not valid: + print("FAIL test suite: no valid fixtures found") + return 1 + if not invalid: print("FAIL test suite: no invalid fixtures found") return 1