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.
|
||||
- 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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
"target": {
|
||||
"sets": 4,
|
||||
"reps": 5
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Timed 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 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:
|
||||
|
||||
```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
|
||||
}
|
||||
}
|
||||
```text
|
||||
60
|
||||
```
|
||||
|
||||
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 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.
|
||||
|
|
|
|||
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
|
||||
|
||||
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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,17 +78,27 @@
|
|||
"maximum": 5000
|
||||
}
|
||||
},
|
||||
"anyOf": [
|
||||
"oneOf": [
|
||||
{
|
||||
"required": [
|
||||
"reps"
|
||||
],
|
||||
"not": {
|
||||
"required": [
|
||||
"duration_seconds"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"required": [
|
||||
"duration_seconds"
|
||||
],
|
||||
"not": {
|
||||
"required": [
|
||||
"reps"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"target": {
|
||||
|
|
@ -113,17 +129,27 @@
|
|||
"maximum": 5000
|
||||
}
|
||||
},
|
||||
"anyOf": [
|
||||
"oneOf": [
|
||||
{
|
||||
"required": [
|
||||
"reps"
|
||||
],
|
||||
"not": {
|
||||
"required": [
|
||||
"duration_seconds"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"required": [
|
||||
"duration_seconds"
|
||||
],
|
||||
"not": {
|
||||
"required": [
|
||||
"reps"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"sessionExercise": {
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
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
|
||||
"""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 = "<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(
|
||||
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 = "<root>"
|
||||
print(f" {location}: {error.message}")
|
||||
print(f" {error}")
|
||||
failed = True
|
||||
else:
|
||||
print(f"PASS {path}")
|
||||
|
|
|
|||
Loading…
Reference in a new issue