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