diff --git a/CHANGELOG.md b/CHANGELOG.md index 1668383..a2ea989 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,3 +16,15 @@ The project uses a simple pre-release changelog during early development. - JSON Schema draft for Trainlog v1. - Initial example workout export. - Database, TUI, Android, testing, and roadmap documentation. +- Trainlog semantic JSON validator. +- Positive and negative exchange-format fixtures. +- Gate 0 review #1 report. + +### Changed + +- `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. diff --git a/docs/exchange_format.md b/docs/exchange_format.md index e15a42e..028f8e5 100644 --- a/docs/exchange_format.md +++ b/docs/exchange_format.md @@ -2,9 +2,16 @@ ## 1. Status -This document defines the initial Trainlog v1 exchange contract. +This document defines the Trainlog v1 exchange contract draft. -Until explicitly marked `FROZEN`, incompatible changes are allowed during early development. +Current state: + +```text +TRAINLOG_FORMAT_V1=DRAFT +GATE_0=VALIDATION_PENDING +``` + +Incompatible changes are allowed until the format is explicitly marked `FROZEN`. Once frozen, incompatible changes require a new format version. @@ -16,6 +23,10 @@ A Trainlog exchange document is: - UTF-8; - one top-level JSON object. +Unknown fields are rejected in v1. + +This strict rule is intentional: a misspelled or unsupported field must fail validation rather than be silently ignored. + ## 3. Required top-level fields ```json @@ -37,17 +48,11 @@ trainlog ### `version` -Integer schema version. +Must equal integer `1`. -For this document: +## 4. Exercise catalog -```text -1 -``` - -## 4. Exercise catalog entries - -Each exercise entry contains: +Each catalog entry contains: ```json { @@ -56,22 +61,44 @@ Each exercise entry contains: } ``` -### `exercise_id` +### 4.1 Stable identity -Stable identifier. +`exercise_id` is the permanent machine identifier. Rules: -- non-empty; -- unique within the document; -- treated as identity; -- must not change merely because the display name changes. +- 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. -### `name` +The visible name is not the persistent identity. -Human-readable display name. +### 4.2 Display-name anti-duplication rule -The TUI may update the local display name later without changing `exercise_id`. +Two catalog entries must not have equivalent display names. + +For duplicate detection, implementations normalize names using this semantic algorithm: + +1. Unicode NFC normalization; +2. remove leading and trailing whitespace; +3. collapse each internal run of whitespace to one ASCII space; +4. Unicode case folding. + +Example: + +```text +"Presse à cuisses" +" presse à cuisses " +"PRESSE À CUISSES" +``` + +are considered the same display name. + +This rule prevents accidental duplicate exercises while still allowing an exercise to be renamed without changing `exercise_id`. + +JSON Schema cannot express this normalization rule. It is mandatory semantic validation. ## 5. Session @@ -79,14 +106,20 @@ Required fields: - `session_id`; - `started_at`; -- `ended_at`; - `exercises`. -Optional body fields may include: +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 Example: @@ -95,25 +128,35 @@ Example: 20260905-183412-a84c ``` -The exact generation algorithm is implementation-defined in v1. +Rules: -The invariant is uniqueness. +- 1 to 128 characters; +- starts with an ASCII alphanumeric character; +- remaining characters are ASCII alphanumeric, `_`, or `-`; +- treated as an opaque unique identifier. -The TUI must enforce uniqueness at import. +The generation algorithm is implementation-defined in v1. + +The TUI enforces uniqueness in SQLite. + +Repeated import of the same `session_id` is idempotent. ## 7. Timestamps -Timestamps use ISO 8601 with an explicit UTC offset. +Timestamps use RFC 3339 / ISO 8601 date-time syntax with an explicit UTC offset. -Example: +Examples: ```text 2026-09-05T18:34:12+02:00 +2026-09-05T16:34:12Z ``` -The timezone offset is part of the serialized value. +An offset-less timestamp is invalid. -The TUI must not silently reinterpret a timestamp as local time without using the encoded offset. +If `ended_at` is present, it must represent an instant strictly later than `started_at`. + +Chronological ordering is a semantic validation rule. ## 8. Workout exercise entry @@ -137,83 +180,160 @@ Example: } ``` -The `target` object describes the intended work. +`target` describes intended work. -The `sets` array describes what was actually performed. +`sets` describes actual performed work. -These two concepts must remain distinct. +The two concepts must remain distinct. -## 9. Timed exercises +The number of actual sets is deliberately allowed to differ from `target.sets`. -Timed exercises such as planks use `duration_seconds`. +This records failure, extra work, interrupted sessions, and manual corrections truthfully. -Example: +## 9. Repetition mode and timed mode + +Each workout exercise has exactly one target mode: + +- repetition mode: `reps`; +- timed mode: `duration_seconds`. + +A target must not contain both. + +All actual sets for that exercise must use the same mode as the target. + +### Repetition example ```json { - "exercise_id": "plank", - "rest_seconds": 60, "target": { - "sets": 3, - "duration_seconds": 45 - }, - "sets": [ - { "duration_seconds": 45 }, - { "duration_seconds": 45 }, - { "duration_seconds": 38 } - ] -} -``` - -A set may represent repetitions or duration. - -The schema forbids an empty set object. - -## 10. Body measurements - -The initial v1 measurement object supports named measurements in centimeters. - -Example: - -```json -{ - "measurements": { - "waist_cm": 91.0, - "chest_cm": 104.0, - "left_arm_cm": 35.0, - "right_arm_cm": 35.0, - "left_thigh_cm": 58.0, - "right_thigh_cm": 57.0 + "sets": 4, + "reps": 5 } } ``` -The initial schema intentionally uses explicit field names rather than arbitrary free-form keys. +### Timed example -Additional measurements may be added before v1 is frozen. +```json +{ + "target": { + "sets": 3, + "duration_seconds": 45 + } +} +``` -## 11. Idempotent import +The schema rejects a set or target containing both `reps` and `duration_seconds`. -The TUI must treat `session_id` as a uniqueness key. +The target/actual mode-match rule is semantic validation. -If a session has already been imported: +## 10. Load + +`weight_kg` represents external load in kilograms. + +It is optional because some exercises are bodyweight or duration-only exercises. + +When supplied, actual-set load is recorded per set so a session can truthfully represent load changes between sets. + +## 11. Rest + +`rest_seconds` is the planned rest duration after sets for the workout exercise. + +Example: + +```text +60 +``` + +means one minute. + +v1 does not record measured rest duration per individual set. + +That may be introduced only by an additive compatible extension before freeze or a later format version after freeze. + +## 12. Body weight + +`body_weight_kg` is optional and uses kilograms. + +It represents body weight associated with the session. + +Standalone body-weight observations outside a workout session are a TUI/database concern and do not require this session exchange object. + +## 13. Body measurements + +Supported v1 measurements use centimeters: + +- `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 `measurements` is present, it must contain at least one measurement. + +Additional measurements may still be added before v1 is frozen. + +## 14. Catalog references + +Every `session.exercises[*].exercise_id` must reference an entry present in the top-level `exercises` catalog. + +This permits Android to introduce a new exercise safely during import. + +An unknown reference makes the document invalid. + +JSON Schema cannot express this cross-reference rule. It is mandatory semantic validation. + +## 15. Duplicate workout exercise entries + +A session must not contain the same `exercise_id` more than once. + +All performed sets for one exercise belong to its single workout entry. + +This keeps analysis and editing deterministic. + +## 16. Idempotent import + +The TUI treats `session_id` as a uniqueness key. + +If the session is already present: - do not create another session; -- do not duplicate its sets; +- do not duplicate sets; +- do not partially merge the repeated document; - report that the session already exists. -## 12. New exercises +Database constraints are the final anti-duplication barrier. -When Android exports a session containing an exercise unknown to the TUI: +## 17. Validation layers -1. the exercise must exist in the top-level `exercises` array; -2. its `exercise_id` must be valid; -3. its `name` must be non-empty; -4. the TUI imports the catalog entry; -5. the session may then reference that exercise. +A valid Trainlog v1 document must pass both: -## 13. Unknown fields +1. JSON Schema validation; +2. Trainlog semantic validation. -Before v1 is frozen, implementations may reject unknown fields during development to catch mistakes early. +Schema validation handles structure and primitive bounds. -The final forward-compatibility policy will be frozen explicitly before release. +Semantic validation handles rules such as: + +- normalized exercise-name uniqueness; +- exercise identifier uniqueness; +- catalog-reference integrity; +- duplicate workout exercise rejection; +- target/actual mode consistency; +- timestamp chronology. + +Both Android export and TUI import must eventually implement the same semantic contract. + +## 18. Freeze policy + +`TRAINLOG_FORMAT_V1` must not be marked `FROZEN` until: + +- 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. diff --git a/docs/reviews/gate0_review_01.md b/docs/reviews/gate0_review_01.md new file mode 100644 index 0000000..8c916e4 --- /dev/null +++ b/docs/reviews/gate0_review_01.md @@ -0,0 +1,128 @@ +# Gate 0 Review #1 + +## Status + +```text +GATE_0_REVIEW_01=IMPLEMENTED +GATE_0=VALIDATION_PENDING +TRAINLOG_FORMAT_V1=DRAFT +``` + +## Scope + +This review hardens the initial project contract before any C17 or Android implementation begins. + +## Findings corrected + +### G0-R1-01 — Completed timestamp was structurally mandatory + +The initial schema required `ended_at`. + +That contradicted the architecture requirement to preserve an active or interrupted session without inventing a completion time. + +Correction: + +- `ended_at` is optional; +- when present, semantic validation requires it to be strictly later than `started_at`. + +### G0-R1-02 — Repetitions and duration were not exclusive + +The initial schema used `anyOf`. + +A target or set containing both `reps` and `duration_seconds` therefore satisfied both alternatives and could be accepted. + +Correction: + +- schema uses an exclusive representation; +- every target/set contains exactly one activity mode. + +### G0-R1-03 — Exercise identifier uniqueness was undocumented executable behavior + +JSON Schema cannot enforce uniqueness of one property across different objects in an array. + +Correction: + +- semantic validator rejects duplicate `exercise_id` values; +- negative fixture added. + +### G0-R1-04 — Display-name duplicates could create duplicate exercises + +Stable identifiers alone do not prevent accidental creation of two exercises with visually equivalent names. + +Correction: + +- semantic normalization algorithm documented; +- semantic validator rejects duplicate normalized display names; +- negative fixture added. + +### G0-R1-05 — Session exercise references were not checked against the catalog + +JSON Schema cannot validate this cross-reference. + +Correction: + +- semantic validator requires every session exercise to exist in the top-level catalog; +- negative fixture added. + +### G0-R1-06 — Duplicate exercise entries inside one workout were ambiguous + +A session could contain the same exercise twice, complicating editing and analytics. + +Correction: + +- v1 requires one workout entry per `exercise_id`; +- all actual sets belong to that entry; +- negative fixture added. + +### G0-R1-07 — Target and actual set modes could disagree + +A repetition target could contain duration-based actual sets or vice versa. + +Correction: + +- semantic validator enforces mode consistency; +- negative fixture added. + +### G0-R1-08 — Timestamp offset and chronology required semantic enforcement + +The contract requires explicit timezone information and meaningful ordering. + +Correction: + +- validator rejects offset-less timestamps; +- validator rejects `ended_at <= started_at`; +- negative fixtures added. + +### G0-R1-09 — Unknown-field behavior was not frozen + +Silently accepting misspelled fields would risk data loss. + +Correction: + +- v1 draft explicitly rejects unknown fields; +- schema keeps `additionalProperties: false`; +- negative fixture added. + +## Validation command + +```bash +python tools/validate_json.py +git diff --check +``` + +Expected result: + +- every valid fixture reports `PASS valid`; +- every invalid fixture reports `PASS invalid`; +- `git diff --check` prints nothing. + +## Gate decision + +Review #1 does not itself mark Gate 0 as PASS. + +Gate 0 becomes eligible for PASS after: + +1. the canonical local validation succeeds; +2. the review is committed; +3. the commit is pushed to Forgejo and GitHub; +4. the mirrored repository is reviewed. diff --git a/docs/roadmap.md b/docs/roadmap.md index be95dbb..7b76766 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -2,7 +2,7 @@ ## Gate 0 — Project contract -Status: IN PROGRESS +Status: VALIDATION PENDING — REVIEW #1 Deliverables: @@ -12,14 +12,22 @@ Deliverables: - coding-style documentation; - exchange-format v1 draft; - JSON Schema draft; -- valid example fixture. +- valid example fixture; +- semantic validator; +- positive and negative fixture suite; +- Gate 0 review report. Exit criteria: - documentation reviewed; -- JSON example validates against the schema; +- JSON example validates; +- valid fixtures are accepted; +- invalid fixtures are rejected; +- semantic invariants are documented; - repository clean after commit. +Gate 0 is not PASS until the review #1 validation commands pass in the canonical working tree and the resulting commit is pushed. + ## Gate 1 — Exchange format v1 freeze Deliverables: @@ -31,13 +39,16 @@ Deliverables: - rest representation; - body weight; - measurement list; -- unknown-field policy; -- invalid fixture suite. +- strict unknown-field policy; +- valid fixture suite; +- invalid fixture suite; +- stable semantic-validation contract. Exit criteria: - `TRAINLOG_FORMAT_V1=FROZEN`; - schema tests pass; +- semantic tests pass; - Android and TUI can implement against the contract without ambiguity. ## Gate 2 — TUI persistence core @@ -61,10 +72,11 @@ Exit criteria: Deliverables: - ncursesw initialization; -- theme module; +- centralized color theme module; - dashboard shell; - exercise list; - session history; +- direct session entry; - import screen; - minimum-terminal fallback. diff --git a/docs/tests.md b/docs/tests.md index 80d091e..7187312 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -2,38 +2,71 @@ ## 1. Principle -A feature is not complete without its relevant validation. +A feature is not complete without relevant validation. + +The exchange-format validator is part of the executable contract during early development. ## 2. Validation layers -Trainlog will use: +Trainlog uses or will use: +- JSON Schema validation; +- Trainlog semantic validation; - unit tests; - integration tests; -- JSON Schema validation; - database constraint tests; - TUI smoke tests; - sanitizer builds where practical. -## 3. Exchange-format tests +## 3. Exchange-format validation -The repository must contain valid and invalid fixtures. +Run the canonical suite with: -Valid fixtures must pass the schema. +```bash +python tools/validate_json.py +``` -Invalid fixtures should cover: +The command validates: + +- `examples/session-v1.json`; +- every file in `tests/fixtures/valid/` as valid; +- every file in `tests/fixtures/invalid/` as invalid. + +A negative fixture passes only when validation rejects it. + +## 4. Structural versus semantic validation + +JSON Schema validates structure and primitive bounds. + +`tools/validate_json.py` additionally validates rules JSON Schema cannot safely express, including: + +- unique `exercise_id` values; +- normalized display-name uniqueness; +- catalog-reference integrity; +- one workout entry per exercise; +- target/actual mode consistency; +- explicit timestamp offsets; +- end-time chronology. + +Android export and TUI import must eventually implement the same semantic rules. + +## 5. Initial invalid fixture coverage + +The Gate 0 suite covers: -- missing required fields; - duplicate exercise identifiers; -- invalid timestamps; -- invalid negative values; -- empty set data; -- malformed targets; -- unsupported format version. +- duplicate normalized exercise names; +- unknown exercise references; +- duplicate workout exercise entries; +- end timestamp before start timestamp; +- offset-less timestamp; +- target/actual mode mismatch; +- target containing both repetitions and duration; +- unknown JSON field. -## 4. Database tests +## 6. Database tests -Tests must verify: +Future tests must verify: - foreign keys are active; - duplicate `session_id` is rejected or handled idempotently; @@ -41,9 +74,9 @@ Tests must verify: - failed imports roll back completely; - migrations preserve data. -## 5. C validation +## 7. C validation -Initial build validation should include: +Initial C validation will include: ```text normal build @@ -53,7 +86,7 @@ ASan/UBSan build Exact commands will be frozen when `meson.build` exists. -## 6. TUI tests +## 8. TUI tests At minimum: @@ -61,17 +94,18 @@ At minimum: - small-terminal fallback works; - navigation does not corrupt state; - UTF-8 labels render correctly; +- color roles render correctly; - monochrome fallback remains understandable. -## 7. Pre-push checklist +## 9. Pre-push checklist Before a meaningful push: 1. format code; -2. build; -3. run tests; -4. validate JSON fixtures; +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`; +7. inspect `git status --short`; 8. update documentation. diff --git a/format/trainlog-v1.schema.json b/format/trainlog-v1.schema.json index 13208c2..7d99390 100644 --- a/format/trainlog-v1.schema.json +++ b/format/trainlog-v1.schema.json @@ -34,6 +34,12 @@ "maxLength": 128, "pattern": "^[a-z0-9][a-z0-9_-]*$" }, + "sessionId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9][A-Za-z0-9_-]*$" + }, "exerciseCatalogEntry": { "type": "object", "additionalProperties": false, @@ -72,16 +78,26 @@ "maximum": 5000 } }, - "anyOf": [ + "oneOf": [ { "required": [ "reps" - ] + ], + "not": { + "required": [ + "duration_seconds" + ] + } }, { "required": [ "duration_seconds" - ] + ], + "not": { + "required": [ + "reps" + ] + } } ] }, @@ -113,16 +129,26 @@ "maximum": 5000 } }, - "anyOf": [ + "oneOf": [ { "required": [ "reps" - ] + ], + "not": { + "required": [ + "duration_seconds" + ] + } }, { "required": [ "duration_seconds" - ] + ], + "not": { + "required": [ + "reps" + ] + } } ] }, @@ -160,6 +186,7 @@ "measurements": { "type": "object", "additionalProperties": false, + "minProperties": 1, "properties": { "waist_cm": { "type": "number", @@ -214,14 +241,11 @@ "required": [ "session_id", "started_at", - "ended_at", "exercises" ], "properties": { "session_id": { - "type": "string", - "minLength": 1, - "maxLength": 128 + "$ref": "#/$defs/sessionId" }, "started_at": { "type": "string", diff --git a/tests/fixtures/invalid/duplicate-exercise-id.json b/tests/fixtures/invalid/duplicate-exercise-id.json new file mode 100644 index 0000000..35f958c --- /dev/null +++ b/tests/fixtures/invalid/duplicate-exercise-id.json @@ -0,0 +1,44 @@ +{ + "format": "trainlog", + "version": 1, + "exercises": [ + { + "exercise_id": "leg_press", + "name": "Presse à cuisses" + }, + { + "exercise_id": "plank", + "name": "Gainage ventral" + }, + { + "exercise_id": "leg_press", + "name": "Autre presse" + } + ], + "session": { + "session_id": "fixture-session-1", + "started_at": "2026-09-05T18:00:00+02:00", + "ended_at": "2026-09-05T19:00:00+02:00", + "exercises": [ + { + "exercise_id": "leg_press", + "rest_seconds": 60, + "target": { + "sets": 4, + "reps": 5, + "weight_kg": 80 + }, + "sets": [ + { + "reps": 5, + "weight_kg": 80 + }, + { + "reps": 5, + "weight_kg": 80 + } + ] + } + ] + } +} diff --git a/tests/fixtures/invalid/duplicate-exercise-name.json b/tests/fixtures/invalid/duplicate-exercise-name.json new file mode 100644 index 0000000..a801556 --- /dev/null +++ b/tests/fixtures/invalid/duplicate-exercise-name.json @@ -0,0 +1,44 @@ +{ + "format": "trainlog", + "version": 1, + "exercises": [ + { + "exercise_id": "leg_press", + "name": "Presse à cuisses" + }, + { + "exercise_id": "plank", + "name": "Gainage ventral" + }, + { + "exercise_id": "leg_press_alt", + "name": " PRESSE À CUISSES " + } + ], + "session": { + "session_id": "fixture-session-1", + "started_at": "2026-09-05T18:00:00+02:00", + "ended_at": "2026-09-05T19:00:00+02:00", + "exercises": [ + { + "exercise_id": "leg_press", + "rest_seconds": 60, + "target": { + "sets": 4, + "reps": 5, + "weight_kg": 80 + }, + "sets": [ + { + "reps": 5, + "weight_kg": 80 + }, + { + "reps": 5, + "weight_kg": 80 + } + ] + } + ] + } +} diff --git a/tests/fixtures/invalid/duplicate-workout-exercise.json b/tests/fixtures/invalid/duplicate-workout-exercise.json new file mode 100644 index 0000000..fd6b9ae --- /dev/null +++ b/tests/fixtures/invalid/duplicate-workout-exercise.json @@ -0,0 +1,59 @@ +{ + "format": "trainlog", + "version": 1, + "exercises": [ + { + "exercise_id": "leg_press", + "name": "Presse à cuisses" + }, + { + "exercise_id": "plank", + "name": "Gainage ventral" + } + ], + "session": { + "session_id": "fixture-session-1", + "started_at": "2026-09-05T18:00:00+02:00", + "ended_at": "2026-09-05T19:00:00+02:00", + "exercises": [ + { + "exercise_id": "leg_press", + "rest_seconds": 60, + "target": { + "sets": 4, + "reps": 5, + "weight_kg": 80 + }, + "sets": [ + { + "reps": 5, + "weight_kg": 80 + }, + { + "reps": 5, + "weight_kg": 80 + } + ] + }, + { + "exercise_id": "leg_press", + "rest_seconds": 60, + "target": { + "sets": 4, + "reps": 5, + "weight_kg": 80 + }, + "sets": [ + { + "reps": 5, + "weight_kg": 80 + }, + { + "reps": 5, + "weight_kg": 80 + } + ] + } + ] + } +} diff --git a/tests/fixtures/invalid/end-before-start.json b/tests/fixtures/invalid/end-before-start.json new file mode 100644 index 0000000..d65488b --- /dev/null +++ b/tests/fixtures/invalid/end-before-start.json @@ -0,0 +1,40 @@ +{ + "format": "trainlog", + "version": 1, + "exercises": [ + { + "exercise_id": "leg_press", + "name": "Presse à cuisses" + }, + { + "exercise_id": "plank", + "name": "Gainage ventral" + } + ], + "session": { + "session_id": "fixture-session-1", + "started_at": "2026-09-05T18:00:00+02:00", + "ended_at": "2026-09-05T17:59:59+02:00", + "exercises": [ + { + "exercise_id": "leg_press", + "rest_seconds": 60, + "target": { + "sets": 4, + "reps": 5, + "weight_kg": 80 + }, + "sets": [ + { + "reps": 5, + "weight_kg": 80 + }, + { + "reps": 5, + "weight_kg": 80 + } + ] + } + ] + } +} diff --git a/tests/fixtures/invalid/target-actual-mode-mismatch.json b/tests/fixtures/invalid/target-actual-mode-mismatch.json new file mode 100644 index 0000000..661534e --- /dev/null +++ b/tests/fixtures/invalid/target-actual-mode-mismatch.json @@ -0,0 +1,39 @@ +{ + "format": "trainlog", + "version": 1, + "exercises": [ + { + "exercise_id": "leg_press", + "name": "Presse à cuisses" + }, + { + "exercise_id": "plank", + "name": "Gainage ventral" + } + ], + "session": { + "session_id": "fixture-session-1", + "started_at": "2026-09-05T18:00:00+02:00", + "ended_at": "2026-09-05T19:00:00+02:00", + "exercises": [ + { + "exercise_id": "leg_press", + "rest_seconds": 60, + "target": { + "sets": 4, + "reps": 5, + "weight_kg": 80 + }, + "sets": [ + { + "duration_seconds": 30 + }, + { + "reps": 5, + "weight_kg": 80 + } + ] + } + ] + } +} diff --git a/tests/fixtures/invalid/target-both-reps-duration.json b/tests/fixtures/invalid/target-both-reps-duration.json new file mode 100644 index 0000000..9a3f808 --- /dev/null +++ b/tests/fixtures/invalid/target-both-reps-duration.json @@ -0,0 +1,41 @@ +{ + "format": "trainlog", + "version": 1, + "exercises": [ + { + "exercise_id": "leg_press", + "name": "Presse à cuisses" + }, + { + "exercise_id": "plank", + "name": "Gainage ventral" + } + ], + "session": { + "session_id": "fixture-session-1", + "started_at": "2026-09-05T18:00:00+02:00", + "ended_at": "2026-09-05T19:00:00+02:00", + "exercises": [ + { + "exercise_id": "leg_press", + "rest_seconds": 60, + "target": { + "sets": 4, + "reps": 5, + "weight_kg": 80, + "duration_seconds": 30 + }, + "sets": [ + { + "reps": 5, + "weight_kg": 80 + }, + { + "reps": 5, + "weight_kg": 80 + } + ] + } + ] + } +} diff --git a/tests/fixtures/invalid/timestamp-without-offset.json b/tests/fixtures/invalid/timestamp-without-offset.json new file mode 100644 index 0000000..fb6ac47 --- /dev/null +++ b/tests/fixtures/invalid/timestamp-without-offset.json @@ -0,0 +1,40 @@ +{ + "format": "trainlog", + "version": 1, + "exercises": [ + { + "exercise_id": "leg_press", + "name": "Presse à cuisses" + }, + { + "exercise_id": "plank", + "name": "Gainage ventral" + } + ], + "session": { + "session_id": "fixture-session-1", + "started_at": "2026-09-05T18:00:00", + "ended_at": "2026-09-05T19:00:00+02:00", + "exercises": [ + { + "exercise_id": "leg_press", + "rest_seconds": 60, + "target": { + "sets": 4, + "reps": 5, + "weight_kg": 80 + }, + "sets": [ + { + "reps": 5, + "weight_kg": 80 + }, + { + "reps": 5, + "weight_kg": 80 + } + ] + } + ] + } +} diff --git a/tests/fixtures/invalid/unknown-exercise-reference.json b/tests/fixtures/invalid/unknown-exercise-reference.json new file mode 100644 index 0000000..a264fd0 --- /dev/null +++ b/tests/fixtures/invalid/unknown-exercise-reference.json @@ -0,0 +1,40 @@ +{ + "format": "trainlog", + "version": 1, + "exercises": [ + { + "exercise_id": "leg_press", + "name": "Presse à cuisses" + }, + { + "exercise_id": "plank", + "name": "Gainage ventral" + } + ], + "session": { + "session_id": "fixture-session-1", + "started_at": "2026-09-05T18:00:00+02:00", + "ended_at": "2026-09-05T19:00:00+02:00", + "exercises": [ + { + "exercise_id": "unknown_exercise", + "rest_seconds": 60, + "target": { + "sets": 4, + "reps": 5, + "weight_kg": 80 + }, + "sets": [ + { + "reps": 5, + "weight_kg": 80 + }, + { + "reps": 5, + "weight_kg": 80 + } + ] + } + ] + } +} diff --git a/tests/fixtures/invalid/unknown-field.json b/tests/fixtures/invalid/unknown-field.json new file mode 100644 index 0000000..1bd72b8 --- /dev/null +++ b/tests/fixtures/invalid/unknown-field.json @@ -0,0 +1,41 @@ +{ + "format": "trainlog", + "version": 1, + "exercises": [ + { + "exercise_id": "leg_press", + "name": "Presse à cuisses" + }, + { + "exercise_id": "plank", + "name": "Gainage ventral" + } + ], + "session": { + "session_id": "fixture-session-1", + "started_at": "2026-09-05T18:00:00+02:00", + "ended_at": "2026-09-05T19:00:00+02:00", + "exercises": [ + { + "exercise_id": "leg_press", + "rest_seconds": 60, + "target": { + "sets": 4, + "reps": 5, + "weight_kg": 80 + }, + "sets": [ + { + "reps": 5, + "weight_kg": 80 + }, + { + "reps": 5, + "weight_kg": 80 + } + ] + } + ], + "unexpected": "typo" + } +} diff --git a/tests/fixtures/valid/active-session.json b/tests/fixtures/valid/active-session.json new file mode 100644 index 0000000..b10358c --- /dev/null +++ b/tests/fixtures/valid/active-session.json @@ -0,0 +1,32 @@ +{ + "format": "trainlog", + "version": 1, + "exercises": [ + { + "exercise_id": "plank", + "name": "Gainage ventral" + } + ], + "session": { + "session_id": "20260905-190000-active1", + "started_at": "2026-09-05T19:00:00+02:00", + "exercises": [ + { + "exercise_id": "plank", + "rest_seconds": 60, + "target": { + "sets": 3, + "duration_seconds": 45 + }, + "sets": [ + { + "duration_seconds": 45 + }, + { + "duration_seconds": 38 + } + ] + } + ] + } +} diff --git a/tools/validate_json.py b/tools/validate_json.py index eb5a519..2b42764 100755 --- a/tools/validate_json.py +++ b/tools/validate_json.py @@ -1,63 +1,268 @@ #!/usr/bin/env python3 -"""Validate Trainlog JSON fixtures against the canonical JSON Schema.""" +"""Validate Trainlog JSON documents structurally and semantically.""" from __future__ import annotations +import argparse import json import sys +import unicodedata +from datetime import datetime from pathlib import Path +from typing import Any try: import jsonschema except ImportError: print( "error: missing Python dependency 'jsonschema'\n" - "install it with: python -m pip install --user jsonschema", + "Arch Linux: sudo pacman -S python-jsonschema", file=sys.stderr, ) raise SystemExit(2) + ROOT = Path(__file__).resolve().parents[1] SCHEMA_PATH = ROOT / "format" / "trainlog-v1.schema.json" +VALID_FIXTURE_DIR = ROOT / "tests" / "fixtures" / "valid" +INVALID_FIXTURE_DIR = ROOT / "tests" / "fixtures" / "invalid" -def load_json(path: Path) -> object: +class TrainlogSemanticError(ValueError): + """Raised when structurally valid JSON violates Trainlog semantics.""" + + +def load_json(path: Path) -> Any: """Load one UTF-8 JSON file and return its decoded value.""" with path.open("r", encoding="utf-8") as handle: return json.load(handle) -def main(argv: list[str]) -> int: - """Validate one or more Trainlog JSON files.""" - schema = load_json(SCHEMA_PATH) +def normalize_exercise_name(name: str) -> str: + """Return the canonical comparison form used for duplicate-name checks. - targets = [Path(arg) for arg in argv[1:]] - if not targets: - targets = [ROOT / "examples" / "session-v1.json"] + The serialized display name is never rewritten by this function. The + normalized value exists only for semantic identity checks. + """ + nfc = unicodedata.normalize("NFC", name) + collapsed = " ".join(nfc.strip().split()) + return collapsed.casefold() + + +def parse_timestamp(value: str, field_name: str) -> datetime: + """Parse a Trainlog timestamp while requiring an explicit UTC offset.""" + candidate = value + if candidate.endswith("Z"): + candidate = candidate[:-1] + "+00:00" + + try: + parsed = datetime.fromisoformat(candidate) + except ValueError as exc: + raise TrainlogSemanticError( + f"{field_name}: invalid date-time: {value!r}" + ) from exc + + if parsed.utcoffset() is None: + raise TrainlogSemanticError( + f"{field_name}: UTC offset is required: {value!r}" + ) + + return parsed + + +def validate_semantics(document: dict[str, Any]) -> None: + """Validate cross-field and normalized Trainlog v1 invariants.""" + catalog = document["exercises"] + session = document["session"] + + exercise_ids: set[str] = set() + 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: + raise TrainlogSemanticError( + f"exercises[{index}].exercise_id: duplicate exercise_id " + f"{exercise_id!r}" + ) + exercise_ids.add(exercise_id) + + normalized = normalize_exercise_name(name) + if not normalized: + raise TrainlogSemanticError( + f"exercises[{index}].name: name is empty after normalization" + ) + + previous_id = normalized_names.get(normalized) + if previous_id is not None: + raise TrainlogSemanticError( + f"exercises[{index}].name: normalized name duplicates exercise " + f"{previous_id!r}" + ) + normalized_names[normalized] = exercise_id + + started_at = parse_timestamp(session["started_at"], "session.started_at") + + if "ended_at" in session: + ended_at = parse_timestamp(session["ended_at"], "session.ended_at") + if ended_at <= started_at: + raise TrainlogSemanticError( + "session.ended_at: must be strictly later than session.started_at" + ) + + workout_ids: set[str] = set() + + for index, workout in enumerate(session["exercises"]): + exercise_id = workout["exercise_id"] + + if exercise_id not in exercise_ids: + raise TrainlogSemanticError( + f"session.exercises[{index}].exercise_id: unknown catalog " + f"reference {exercise_id!r}" + ) + + if exercise_id in workout_ids: + raise TrainlogSemanticError( + f"session.exercises[{index}].exercise_id: duplicate workout " + f"exercise {exercise_id!r}" + ) + workout_ids.add(exercise_id) + + target = workout["target"] + target_mode = ( + "reps" if "reps" in target else "duration_seconds" + ) + + for set_index, actual_set in enumerate(workout["sets"]): + actual_mode = ( + "reps" if "reps" in actual_set else "duration_seconds" + ) + 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}" + ) + + +def structural_errors( + validator: jsonschema.Draft202012Validator, + document: Any, +) -> list[str]: + """Return deterministic human-readable JSON Schema errors.""" + errors = sorted( + validator.iter_errors(document), + key=lambda error: [str(part) for part in error.absolute_path], + ) + + rendered: list[str] = [] + for error in errors: + location = ".".join(str(part) for part in error.absolute_path) + if not location: + location = "" + rendered.append(f"{location}: {error.message}") + + return rendered + + +def validate_document( + validator: jsonschema.Draft202012Validator, + path: Path, +) -> list[str]: + """Return all validation errors for one Trainlog document.""" + try: + document = load_json(path) + except (OSError, json.JSONDecodeError) as exc: + return [str(exc)] + + errors = structural_errors(validator, document) + if errors: + return errors + + try: + validate_semantics(document) + except TrainlogSemanticError as exc: + return [str(exc)] + + return [] + + +def discover_suite() -> tuple[list[Path], list[Path]]: + """Return repository fixtures with their expected validation result.""" + valid = [ROOT / "examples" / "session-v1.json"] + valid.extend(sorted(VALID_FIXTURE_DIR.glob("*.json"))) + invalid = sorted(INVALID_FIXTURE_DIR.glob("*.json")) + return valid, invalid + + +def run_suite(validator: jsonschema.Draft202012Validator) -> int: + """Validate all positive and negative repository fixtures.""" + valid, invalid = discover_suite() + failed = False + + if not invalid: + print("FAIL test suite: no invalid fixtures found") + return 1 + + for path in valid: + errors = validate_document(validator, path) + if errors: + print(f"FAIL expected valid: {path}") + for error in errors: + print(f" {error}") + failed = True + else: + print(f"PASS valid: {path}") + + for path in invalid: + errors = validate_document(validator, path) + if not errors: + print(f"FAIL expected invalid: {path}") + failed = True + else: + print(f"PASS invalid: {path}") + print(f" rejected: {errors[0]}") + + return 1 if failed else 0 + + +def parse_args(argv: list[str]) -> argparse.Namespace: + """Parse command-line arguments.""" + parser = argparse.ArgumentParser( + description="Validate Trainlog v1 JSON structurally and semantically." + ) + parser.add_argument( + "paths", + nargs="*", + type=Path, + help="documents expected to be valid; omit to run the repository suite", + ) + return parser.parse_args(argv[1:]) + + +def main(argv: list[str]) -> int: + """Validate explicit documents or run the canonical fixture suite.""" + args = parse_args(argv) + schema = load_json(SCHEMA_PATH) validator = jsonschema.Draft202012Validator( schema, format_checker=jsonschema.FormatChecker(), ) + validator.check_schema(schema) + + if not args.paths: + return run_suite(validator) failed = False - - for path in targets: - try: - document = load_json(path) - except (OSError, json.JSONDecodeError) as exc: - print(f"FAIL {path}: {exc}") - failed = True - continue - - errors = sorted(validator.iter_errors(document), key=lambda e: list(e.path)) + for path in args.paths: + errors = validate_document(validator, path) if errors: print(f"FAIL {path}") for error in errors: - location = ".".join(str(part) for part in error.absolute_path) - if not location: - location = "" - print(f" {location}: {error.message}") + print(f" {error}") failed = True else: print(f"PASS {path}")