Harden Trainlog v1 exchange contract
This commit is contained in:
parent
ca080d8145
commit
bc54d6b4ce
17 changed files with 1100 additions and 145 deletions
12
CHANGELOG.md
12
CHANGELOG.md
|
|
@ -16,3 +16,15 @@ The project uses a simple pre-release changelog during early development.
|
||||||
- JSON Schema draft for Trainlog v1.
|
- JSON Schema draft for Trainlog v1.
|
||||||
- Initial example workout export.
|
- Initial example workout export.
|
||||||
- Database, TUI, Android, testing, and roadmap documentation.
|
- 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.
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,16 @@
|
||||||
|
|
||||||
## 1. Status
|
## 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.
|
Once frozen, incompatible changes require a new format version.
|
||||||
|
|
||||||
|
|
@ -16,6 +23,10 @@ A Trainlog exchange document is:
|
||||||
- UTF-8;
|
- UTF-8;
|
||||||
- one top-level JSON object.
|
- 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
|
## 3. Required top-level fields
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|
@ -37,17 +48,11 @@ trainlog
|
||||||
|
|
||||||
### `version`
|
### `version`
|
||||||
|
|
||||||
Integer schema version.
|
Must equal integer `1`.
|
||||||
|
|
||||||
For this document:
|
## 4. Exercise catalog
|
||||||
|
|
||||||
```text
|
Each catalog entry contains:
|
||||||
1
|
|
||||||
```
|
|
||||||
|
|
||||||
## 4. Exercise catalog entries
|
|
||||||
|
|
||||||
Each exercise entry contains:
|
|
||||||
|
|
||||||
```json
|
```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:
|
Rules:
|
||||||
|
|
||||||
- non-empty;
|
- ASCII lowercase identifier;
|
||||||
- unique within the document;
|
- 1 to 128 characters;
|
||||||
- treated as identity;
|
- allowed characters: `a-z`, `0-9`, `_`, `-`;
|
||||||
- must not change merely because the display name changes.
|
- 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
|
## 5. Session
|
||||||
|
|
||||||
|
|
@ -79,14 +106,20 @@ Required fields:
|
||||||
|
|
||||||
- `session_id`;
|
- `session_id`;
|
||||||
- `started_at`;
|
- `started_at`;
|
||||||
- `ended_at`;
|
|
||||||
- `exercises`.
|
- `exercises`.
|
||||||
|
|
||||||
Optional body fields may include:
|
Optional fields:
|
||||||
|
|
||||||
|
- `ended_at`;
|
||||||
- `body_weight_kg`;
|
- `body_weight_kg`;
|
||||||
- `measurements`.
|
- `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
|
## 6. Session identifier
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
@ -95,25 +128,35 @@ Example:
|
||||||
20260905-183412-a84c
|
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
|
## 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
|
```text
|
||||||
2026-09-05T18:34:12+02:00
|
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
|
## 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
|
||||||
|
{
|
||||||
|
"target": {
|
||||||
|
"sets": 4,
|
||||||
|
"reps": 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Timed example
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"exercise_id": "plank",
|
|
||||||
"rest_seconds": 60,
|
|
||||||
"target": {
|
"target": {
|
||||||
"sets": 3,
|
"sets": 3,
|
||||||
"duration_seconds": 45
|
"duration_seconds": 45
|
||||||
},
|
}
|
||||||
"sets": [
|
|
||||||
{ "duration_seconds": 45 },
|
|
||||||
{ "duration_seconds": 45 },
|
|
||||||
{ "duration_seconds": 38 }
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
A set may represent repetitions or duration.
|
The schema rejects a set or target containing both `reps` and `duration_seconds`.
|
||||||
|
|
||||||
The schema forbids an empty set object.
|
The target/actual mode-match rule is semantic validation.
|
||||||
|
|
||||||
## 10. Body measurements
|
## 10. Load
|
||||||
|
|
||||||
The initial v1 measurement object supports named measurements in centimeters.
|
`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:
|
Example:
|
||||||
|
|
||||||
```json
|
```text
|
||||||
{
|
60
|
||||||
"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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The initial schema intentionally uses explicit field names rather than arbitrary free-form keys.
|
means one minute.
|
||||||
|
|
||||||
Additional measurements may be added before v1 is frozen.
|
v1 does not record measured rest duration per individual set.
|
||||||
|
|
||||||
## 11. Idempotent import
|
That may be introduced only by an additive compatible extension before freeze or a later format version after freeze.
|
||||||
|
|
||||||
The TUI must treat `session_id` as a uniqueness key.
|
## 12. Body weight
|
||||||
|
|
||||||
If a session has already been imported:
|
`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 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.
|
- 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;
|
A valid Trainlog v1 document must pass both:
|
||||||
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.
|
|
||||||
|
|
||||||
## 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.
|
||||||
|
|
|
||||||
128
docs/reviews/gate0_review_01.md
Normal file
128
docs/reviews/gate0_review_01.md
Normal file
|
|
@ -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.
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
## Gate 0 — Project contract
|
## Gate 0 — Project contract
|
||||||
|
|
||||||
Status: IN PROGRESS
|
Status: VALIDATION PENDING — REVIEW #1
|
||||||
|
|
||||||
Deliverables:
|
Deliverables:
|
||||||
|
|
||||||
|
|
@ -12,14 +12,22 @@ Deliverables:
|
||||||
- coding-style documentation;
|
- coding-style documentation;
|
||||||
- exchange-format v1 draft;
|
- exchange-format v1 draft;
|
||||||
- JSON Schema draft;
|
- JSON Schema draft;
|
||||||
- valid example fixture.
|
- valid example fixture;
|
||||||
|
- semantic validator;
|
||||||
|
- positive and negative fixture suite;
|
||||||
|
- Gate 0 review report.
|
||||||
|
|
||||||
Exit criteria:
|
Exit criteria:
|
||||||
|
|
||||||
- documentation reviewed;
|
- 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.
|
- 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
|
## Gate 1 — Exchange format v1 freeze
|
||||||
|
|
||||||
Deliverables:
|
Deliverables:
|
||||||
|
|
@ -31,13 +39,16 @@ Deliverables:
|
||||||
- rest representation;
|
- rest representation;
|
||||||
- body weight;
|
- body weight;
|
||||||
- measurement list;
|
- measurement list;
|
||||||
- unknown-field policy;
|
- strict unknown-field policy;
|
||||||
- invalid fixture suite.
|
- valid fixture suite;
|
||||||
|
- invalid fixture suite;
|
||||||
|
- stable semantic-validation contract.
|
||||||
|
|
||||||
Exit criteria:
|
Exit criteria:
|
||||||
|
|
||||||
- `TRAINLOG_FORMAT_V1=FROZEN`;
|
- `TRAINLOG_FORMAT_V1=FROZEN`;
|
||||||
- schema tests pass;
|
- schema tests pass;
|
||||||
|
- semantic tests pass;
|
||||||
- Android and TUI can implement against the contract without ambiguity.
|
- Android and TUI can implement against the contract without ambiguity.
|
||||||
|
|
||||||
## Gate 2 — TUI persistence core
|
## Gate 2 — TUI persistence core
|
||||||
|
|
@ -61,10 +72,11 @@ Exit criteria:
|
||||||
Deliverables:
|
Deliverables:
|
||||||
|
|
||||||
- ncursesw initialization;
|
- ncursesw initialization;
|
||||||
- theme module;
|
- centralized color theme module;
|
||||||
- dashboard shell;
|
- dashboard shell;
|
||||||
- exercise list;
|
- exercise list;
|
||||||
- session history;
|
- session history;
|
||||||
|
- direct session entry;
|
||||||
- import screen;
|
- import screen;
|
||||||
- minimum-terminal fallback.
|
- minimum-terminal fallback.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,38 +2,71 @@
|
||||||
|
|
||||||
## 1. Principle
|
## 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
|
## 2. Validation layers
|
||||||
|
|
||||||
Trainlog will use:
|
Trainlog uses or will use:
|
||||||
|
|
||||||
|
- JSON Schema validation;
|
||||||
|
- Trainlog semantic validation;
|
||||||
- unit tests;
|
- unit tests;
|
||||||
- integration tests;
|
- integration tests;
|
||||||
- JSON Schema validation;
|
|
||||||
- database constraint tests;
|
- database constraint tests;
|
||||||
- TUI smoke tests;
|
- TUI smoke tests;
|
||||||
- sanitizer builds where practical.
|
- 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;
|
- duplicate exercise identifiers;
|
||||||
- invalid timestamps;
|
- duplicate normalized exercise names;
|
||||||
- invalid negative values;
|
- unknown exercise references;
|
||||||
- empty set data;
|
- duplicate workout exercise entries;
|
||||||
- malformed targets;
|
- end timestamp before start timestamp;
|
||||||
- unsupported format version.
|
- 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;
|
- foreign keys are active;
|
||||||
- duplicate `session_id` is rejected or handled idempotently;
|
- duplicate `session_id` is rejected or handled idempotently;
|
||||||
|
|
@ -41,9 +74,9 @@ Tests must verify:
|
||||||
- failed imports roll back completely;
|
- failed imports roll back completely;
|
||||||
- migrations preserve data.
|
- migrations preserve data.
|
||||||
|
|
||||||
## 5. C validation
|
## 7. C validation
|
||||||
|
|
||||||
Initial build validation should include:
|
Initial C validation will include:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
normal build
|
normal build
|
||||||
|
|
@ -53,7 +86,7 @@ ASan/UBSan build
|
||||||
|
|
||||||
Exact commands will be frozen when `meson.build` exists.
|
Exact commands will be frozen when `meson.build` exists.
|
||||||
|
|
||||||
## 6. TUI tests
|
## 8. TUI tests
|
||||||
|
|
||||||
At minimum:
|
At minimum:
|
||||||
|
|
||||||
|
|
@ -61,17 +94,18 @@ At minimum:
|
||||||
- small-terminal fallback works;
|
- small-terminal fallback works;
|
||||||
- navigation does not corrupt state;
|
- navigation does not corrupt state;
|
||||||
- UTF-8 labels render correctly;
|
- UTF-8 labels render correctly;
|
||||||
|
- color roles render correctly;
|
||||||
- monochrome fallback remains understandable.
|
- monochrome fallback remains understandable.
|
||||||
|
|
||||||
## 7. Pre-push checklist
|
## 9. Pre-push checklist
|
||||||
|
|
||||||
Before a meaningful push:
|
Before a meaningful push:
|
||||||
|
|
||||||
1. format code;
|
1. format code;
|
||||||
2. build;
|
2. run `python tools/validate_json.py`;
|
||||||
3. run tests;
|
3. build when buildable code exists;
|
||||||
4. validate JSON fixtures;
|
4. run relevant tests;
|
||||||
5. run sanitizer suite when relevant;
|
5. run sanitizer suite when relevant;
|
||||||
6. run `git diff --check`;
|
6. run `git diff --check`;
|
||||||
7. inspect `git status`;
|
7. inspect `git status --short`;
|
||||||
8. update documentation.
|
8. update documentation.
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,12 @@
|
||||||
"maxLength": 128,
|
"maxLength": 128,
|
||||||
"pattern": "^[a-z0-9][a-z0-9_-]*$"
|
"pattern": "^[a-z0-9][a-z0-9_-]*$"
|
||||||
},
|
},
|
||||||
|
"sessionId": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"maxLength": 128,
|
||||||
|
"pattern": "^[A-Za-z0-9][A-Za-z0-9_-]*$"
|
||||||
|
},
|
||||||
"exerciseCatalogEntry": {
|
"exerciseCatalogEntry": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
|
|
@ -72,17 +78,27 @@
|
||||||
"maximum": 5000
|
"maximum": 5000
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"anyOf": [
|
"oneOf": [
|
||||||
{
|
{
|
||||||
"required": [
|
"required": [
|
||||||
"reps"
|
"reps"
|
||||||
|
],
|
||||||
|
"not": {
|
||||||
|
"required": [
|
||||||
|
"duration_seconds"
|
||||||
]
|
]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"required": [
|
"required": [
|
||||||
"duration_seconds"
|
"duration_seconds"
|
||||||
|
],
|
||||||
|
"not": {
|
||||||
|
"required": [
|
||||||
|
"reps"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"target": {
|
"target": {
|
||||||
|
|
@ -113,17 +129,27 @@
|
||||||
"maximum": 5000
|
"maximum": 5000
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"anyOf": [
|
"oneOf": [
|
||||||
{
|
{
|
||||||
"required": [
|
"required": [
|
||||||
"reps"
|
"reps"
|
||||||
|
],
|
||||||
|
"not": {
|
||||||
|
"required": [
|
||||||
|
"duration_seconds"
|
||||||
]
|
]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"required": [
|
"required": [
|
||||||
"duration_seconds"
|
"duration_seconds"
|
||||||
|
],
|
||||||
|
"not": {
|
||||||
|
"required": [
|
||||||
|
"reps"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"sessionExercise": {
|
"sessionExercise": {
|
||||||
|
|
@ -160,6 +186,7 @@
|
||||||
"measurements": {
|
"measurements": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
|
"minProperties": 1,
|
||||||
"properties": {
|
"properties": {
|
||||||
"waist_cm": {
|
"waist_cm": {
|
||||||
"type": "number",
|
"type": "number",
|
||||||
|
|
@ -214,14 +241,11 @@
|
||||||
"required": [
|
"required": [
|
||||||
"session_id",
|
"session_id",
|
||||||
"started_at",
|
"started_at",
|
||||||
"ended_at",
|
|
||||||
"exercises"
|
"exercises"
|
||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
"session_id": {
|
"session_id": {
|
||||||
"type": "string",
|
"$ref": "#/$defs/sessionId"
|
||||||
"minLength": 1,
|
|
||||||
"maxLength": 128
|
|
||||||
},
|
},
|
||||||
"started_at": {
|
"started_at": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|
|
||||||
44
tests/fixtures/invalid/duplicate-exercise-id.json
vendored
Normal file
44
tests/fixtures/invalid/duplicate-exercise-id.json
vendored
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
44
tests/fixtures/invalid/duplicate-exercise-name.json
vendored
Normal file
44
tests/fixtures/invalid/duplicate-exercise-name.json
vendored
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
59
tests/fixtures/invalid/duplicate-workout-exercise.json
vendored
Normal file
59
tests/fixtures/invalid/duplicate-workout-exercise.json
vendored
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
40
tests/fixtures/invalid/end-before-start.json
vendored
Normal file
40
tests/fixtures/invalid/end-before-start.json
vendored
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
39
tests/fixtures/invalid/target-actual-mode-mismatch.json
vendored
Normal file
39
tests/fixtures/invalid/target-actual-mode-mismatch.json
vendored
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
41
tests/fixtures/invalid/target-both-reps-duration.json
vendored
Normal file
41
tests/fixtures/invalid/target-both-reps-duration.json
vendored
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
40
tests/fixtures/invalid/timestamp-without-offset.json
vendored
Normal file
40
tests/fixtures/invalid/timestamp-without-offset.json
vendored
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
40
tests/fixtures/invalid/unknown-exercise-reference.json
vendored
Normal file
40
tests/fixtures/invalid/unknown-exercise-reference.json
vendored
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
41
tests/fixtures/invalid/unknown-field.json
vendored
Normal file
41
tests/fixtures/invalid/unknown-field.json
vendored
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
32
tests/fixtures/valid/active-session.json
vendored
Normal file
32
tests/fixtures/valid/active-session.json
vendored
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,63 +1,268 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Validate Trainlog JSON fixtures against the canonical JSON Schema."""
|
"""Validate Trainlog JSON documents structurally and semantically."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
|
import unicodedata
|
||||||
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import jsonschema
|
import jsonschema
|
||||||
except ImportError:
|
except ImportError:
|
||||||
print(
|
print(
|
||||||
"error: missing Python dependency 'jsonschema'\n"
|
"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,
|
file=sys.stderr,
|
||||||
)
|
)
|
||||||
raise SystemExit(2)
|
raise SystemExit(2)
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
SCHEMA_PATH = ROOT / "format" / "trainlog-v1.schema.json"
|
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."""
|
"""Load one UTF-8 JSON file and return its decoded value."""
|
||||||
with path.open("r", encoding="utf-8") as handle:
|
with path.open("r", encoding="utf-8") as handle:
|
||||||
return json.load(handle)
|
return json.load(handle)
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str]) -> int:
|
def normalize_exercise_name(name: str) -> str:
|
||||||
"""Validate one or more Trainlog JSON files."""
|
"""Return the canonical comparison form used for duplicate-name checks.
|
||||||
schema = load_json(SCHEMA_PATH)
|
|
||||||
|
|
||||||
targets = [Path(arg) for arg in argv[1:]]
|
The serialized display name is never rewritten by this function. The
|
||||||
if not targets:
|
normalized value exists only for semantic identity checks.
|
||||||
targets = [ROOT / "examples" / "session-v1.json"]
|
"""
|
||||||
|
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 = "<root>"
|
||||||
|
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(
|
validator = jsonschema.Draft202012Validator(
|
||||||
schema,
|
schema,
|
||||||
format_checker=jsonschema.FormatChecker(),
|
format_checker=jsonschema.FormatChecker(),
|
||||||
)
|
)
|
||||||
|
validator.check_schema(schema)
|
||||||
|
|
||||||
|
if not args.paths:
|
||||||
|
return run_suite(validator)
|
||||||
|
|
||||||
failed = False
|
failed = False
|
||||||
|
for path in args.paths:
|
||||||
for path in targets:
|
errors = validate_document(validator, path)
|
||||||
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))
|
|
||||||
if errors:
|
if errors:
|
||||||
print(f"FAIL {path}")
|
print(f"FAIL {path}")
|
||||||
for error in errors:
|
for error in errors:
|
||||||
location = ".".join(str(part) for part in error.absolute_path)
|
print(f" {error}")
|
||||||
if not location:
|
|
||||||
location = "<root>"
|
|
||||||
print(f" {location}: {error.message}")
|
|
||||||
failed = True
|
failed = True
|
||||||
else:
|
else:
|
||||||
print(f"PASS {path}")
|
print(f"PASS {path}")
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue