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