Define Trainlog v1 catalog identity rules
This commit is contained in:
parent
9d9a9223a0
commit
dfd6717cb7
9 changed files with 539 additions and 34 deletions
11
CHANGELOG.md
11
CHANGELOG.md
|
|
@ -15,17 +15,22 @@ The project uses a simple pre-release changelog during early development.
|
||||||
- Positive and negative exchange-format fixtures.
|
- Positive and negative exchange-format fixtures.
|
||||||
- Gate 0 review and closure records.
|
- Gate 0 review and closure records.
|
||||||
- Gate 1 exchange-format freeze-candidate review.
|
- Gate 1 exchange-format freeze-candidate review.
|
||||||
|
- Gate 1 exercise-identity collision review.
|
||||||
- Stable exercise tracking modes.
|
- Stable exercise tracking modes.
|
||||||
- Explicit load modes for no load, external resistance, and assistance.
|
- Explicit load modes for no load, external resistance, and assistance.
|
||||||
- Optional bounded session and exercise notes.
|
- Optional bounded session and exercise notes.
|
||||||
- Extended body measurement list.
|
- Extended body measurement list.
|
||||||
|
- Executable local catalog reconciliation contract.
|
||||||
|
- UUIDv4 generation policy for new Trainlog exercise and session IDs.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Gate 0 project contract is complete.
|
- Gate 0 project contract is complete.
|
||||||
- Gate 1 is the active format-freeze gate.
|
- Gate 1 remains active pending review #2 validation.
|
||||||
- Actual repetition count may be zero for a failed attempt.
|
- Actual repetition count may be zero for a failed attempt.
|
||||||
- Planned exercises may contain zero actual sets.
|
- Planned exercises may contain zero actual sets.
|
||||||
- Top-level exercise metadata must exactly match the session exercise references.
|
- Top-level exercise metadata must exactly match session exercise references.
|
||||||
- Assistance kilograms have distinct semantics from external resistance.
|
- Assistance kilograms have distinct semantics from external resistance.
|
||||||
- `TRAINLOG_FORMAT_V1` remains draft pending Gate 1 validation and mirrored review.
|
- Different exercise IDs with equivalent normalized names are hard import conflicts.
|
||||||
|
- Same exercise ID with incompatible tracking mode is a hard import conflict.
|
||||||
|
- `TRAINLOG_FORMAT_V1` remains draft pending Gate 1 closure.
|
||||||
|
|
|
||||||
|
|
@ -178,3 +178,30 @@ Initial Android versions do not need:
|
||||||
- muscle classification;
|
- muscle classification;
|
||||||
- distance/cardio metrics;
|
- distance/cardio metrics;
|
||||||
- per-set rest measurement.
|
- per-set rest measurement.
|
||||||
|
## 13. Identifier generation
|
||||||
|
|
||||||
|
When Android creates a new exercise, it generates:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ex_<random UUID v4>
|
||||||
|
```
|
||||||
|
|
||||||
|
When Android creates a new session, it generates:
|
||||||
|
|
||||||
|
```text
|
||||||
|
se_<random UUID v4>
|
||||||
|
```
|
||||||
|
|
||||||
|
Display-name slugs must not be used as persistent identifiers.
|
||||||
|
|
||||||
|
The visible exercise name remains independent from identity.
|
||||||
|
|
||||||
|
## 14. Catalog conflict behavior
|
||||||
|
|
||||||
|
Android must prevent duplicate normalized names inside its own local catalog.
|
||||||
|
|
||||||
|
A valid Android export can still conflict with an independently edited TUI catalog.
|
||||||
|
|
||||||
|
The TUI owns final reconciliation.
|
||||||
|
|
||||||
|
Android must not assume that a matching display name means two different IDs may be silently merged.
|
||||||
|
|
|
||||||
|
|
@ -480,3 +480,89 @@ Until then:
|
||||||
```text
|
```text
|
||||||
TRAINLOG_FORMAT_V1=DRAFT
|
TRAINLOG_FORMAT_V1=DRAFT
|
||||||
```
|
```
|
||||||
|
## 23. Generated identifier policy
|
||||||
|
|
||||||
|
The wire format treats `exercise_id` and `session_id` as opaque identifiers satisfying their defined syntax.
|
||||||
|
|
||||||
|
Official Trainlog implementations that create new identifiers must generate random UUID version 4 values.
|
||||||
|
|
||||||
|
Generated exercise identifiers use:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ex_<uuid-v4>
|
||||||
|
```
|
||||||
|
|
||||||
|
Generated session identifiers use:
|
||||||
|
|
||||||
|
```text
|
||||||
|
se_<uuid-v4>
|
||||||
|
```
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ex_550e8400-e29b-41d4-a716-446655440000
|
||||||
|
se_550e8400-e29b-41d4-a716-446655440000
|
||||||
|
```
|
||||||
|
|
||||||
|
Importers accept any identifier valid under the v1 schema; they must not require that legacy or externally created IDs follow the UUID generation convention.
|
||||||
|
|
||||||
|
The generation rule exists to prevent collisions when Android and TUI can both create exercises independently.
|
||||||
|
|
||||||
|
## 24. Local catalog reconciliation
|
||||||
|
|
||||||
|
Document validity and local importability are separate concepts.
|
||||||
|
|
||||||
|
For each incoming exercise, the TUI reconciles against the local canonical catalog before the import transaction commits.
|
||||||
|
|
||||||
|
### Same ID, same tracking mode
|
||||||
|
|
||||||
|
Reuse the existing exercise.
|
||||||
|
|
||||||
|
If the normalized display name differs, import is allowed but a non-fatal metadata warning is surfaced.
|
||||||
|
|
||||||
|
The canonical local name is not silently changed.
|
||||||
|
|
||||||
|
### Same ID, different tracking mode
|
||||||
|
|
||||||
|
Reject the entire session import.
|
||||||
|
|
||||||
|
A repetition identity and duration identity are semantically incompatible.
|
||||||
|
|
||||||
|
### Different ID, equivalent normalized name
|
||||||
|
|
||||||
|
Reject the entire session import as an identity conflict.
|
||||||
|
|
||||||
|
Trainlog must not silently:
|
||||||
|
|
||||||
|
- create a duplicate;
|
||||||
|
- merge identities;
|
||||||
|
- rewrite historical identifiers.
|
||||||
|
|
||||||
|
The user must explicitly reconcile the conflict.
|
||||||
|
|
||||||
|
### New ID, unique normalized name
|
||||||
|
|
||||||
|
Create the exercise as part of the same database transaction as the session import.
|
||||||
|
|
||||||
|
### Atomicity
|
||||||
|
|
||||||
|
Any hard catalog conflict aborts the entire import.
|
||||||
|
|
||||||
|
No partial session or exercise data may remain.
|
||||||
|
|
||||||
|
## 25. Gate 1 executable import contract
|
||||||
|
|
||||||
|
Before the production importer exists, catalog reconciliation is specified by:
|
||||||
|
|
||||||
|
```text
|
||||||
|
tests/contract/catalog-import-cases.json
|
||||||
|
tools/validate_import_contract.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Canonical validation requires both:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python tools/validate_json.py
|
||||||
|
python tools/validate_import_contract.py
|
||||||
|
```
|
||||||
|
|
|
||||||
165
docs/reviews/gate1_review_02.md
Normal file
165
docs/reviews/gate1_review_02.md
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
# Gate 1 Review #2 — Exercise Identity Collision Rules
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
```text
|
||||||
|
GATE_1_REVIEW_01=IMPLEMENTED
|
||||||
|
GATE_1_REVIEW_02=IMPLEMENTED
|
||||||
|
GATE_1=VALIDATION_PENDING
|
||||||
|
TRAINLOG_FORMAT_V1=DRAFT
|
||||||
|
```
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Review #1 defined the shape of Trainlog v1.
|
||||||
|
|
||||||
|
Review #2 closes the remaining identity ambiguity between the Android exercise catalog and the canonical TUI catalog.
|
||||||
|
|
||||||
|
This review is intentionally narrow.
|
||||||
|
|
||||||
|
## G1-R2-01 — Generated identifiers must be globally collision-resistant
|
||||||
|
|
||||||
|
The serialized format continues to treat `exercise_id` and `session_id` as opaque identifiers satisfying their documented syntax.
|
||||||
|
|
||||||
|
Trainlog implementations that create new identifiers must use random UUID version 4 values.
|
||||||
|
|
||||||
|
Recommended generated exercise identifier:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ex_550e8400-e29b-41d4-a716-446655440000
|
||||||
|
```
|
||||||
|
|
||||||
|
Recommended generated session identifier:
|
||||||
|
|
||||||
|
```text
|
||||||
|
se_550e8400-e29b-41d4-a716-446655440000
|
||||||
|
```
|
||||||
|
|
||||||
|
The prefixes are part of the generated identifier.
|
||||||
|
|
||||||
|
Importers must not require the UUID generation pattern for already-existing conforming v1 documents.
|
||||||
|
|
||||||
|
This separates two concerns:
|
||||||
|
|
||||||
|
- the wire format accepts the documented opaque identifier syntax;
|
||||||
|
- official Trainlog implementations generate collision-resistant identifiers.
|
||||||
|
|
||||||
|
## G1-R2-02 — Existing ID and compatible metadata
|
||||||
|
|
||||||
|
If an incoming exercise has the same `exercise_id` and the same `tracking_mode` as an existing local exercise, the existing identity is reused.
|
||||||
|
|
||||||
|
If the normalized display name is also equal:
|
||||||
|
|
||||||
|
```text
|
||||||
|
action = reuse
|
||||||
|
```
|
||||||
|
|
||||||
|
No warning is needed.
|
||||||
|
|
||||||
|
## G1-R2-03 — Existing ID with a different display name
|
||||||
|
|
||||||
|
If `exercise_id` and `tracking_mode` match but the display name differs:
|
||||||
|
|
||||||
|
```text
|
||||||
|
action = reuse_with_name_warning
|
||||||
|
```
|
||||||
|
|
||||||
|
The session may import.
|
||||||
|
|
||||||
|
The local canonical exercise is not silently renamed.
|
||||||
|
|
||||||
|
The UI must surface a non-fatal metadata warning.
|
||||||
|
|
||||||
|
Deliberate rename/synchronization is a separate user action.
|
||||||
|
|
||||||
|
## G1-R2-04 — Existing ID with incompatible tracking mode
|
||||||
|
|
||||||
|
If the same `exercise_id` arrives with a different `tracking_mode`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
action = reject_mode_conflict
|
||||||
|
```
|
||||||
|
|
||||||
|
The session import must not continue.
|
||||||
|
|
||||||
|
The conflict cannot be repaired by silently changing history because `reps` and `duration` have different semantics.
|
||||||
|
|
||||||
|
## G1-R2-05 — Different IDs with equivalent normalized names
|
||||||
|
|
||||||
|
If an incoming exercise has a different `exercise_id` but its normalized name equals an existing local exercise name:
|
||||||
|
|
||||||
|
```text
|
||||||
|
action = reject_name_identity_conflict
|
||||||
|
```
|
||||||
|
|
||||||
|
The importer must not:
|
||||||
|
|
||||||
|
- create a duplicate exercise;
|
||||||
|
- silently merge the two identities;
|
||||||
|
- silently rewrite the incoming session to the existing ID.
|
||||||
|
|
||||||
|
The user must explicitly reconcile the identity conflict.
|
||||||
|
|
||||||
|
This is the critical anti-duplicate rule for independently edited Android and TUI catalogs.
|
||||||
|
|
||||||
|
## G1-R2-06 — New ID and unique normalized name
|
||||||
|
|
||||||
|
If neither the identifier nor normalized name conflicts with the local catalog:
|
||||||
|
|
||||||
|
```text
|
||||||
|
action = create
|
||||||
|
```
|
||||||
|
|
||||||
|
The new exercise is inserted during the same import transaction as the session.
|
||||||
|
|
||||||
|
## G1-R2-07 — Reconciliation is atomic
|
||||||
|
|
||||||
|
Any hard catalog conflict rejects the entire session import transaction.
|
||||||
|
|
||||||
|
The TUI must not partially:
|
||||||
|
|
||||||
|
- create some new exercises;
|
||||||
|
- insert the session;
|
||||||
|
- insert sets.
|
||||||
|
|
||||||
|
The database remains unchanged after a rejected import.
|
||||||
|
|
||||||
|
## G1-R2-08 — Document validity versus local importability
|
||||||
|
|
||||||
|
A Trainlog JSON document can be structurally and semantically valid on its own yet still be non-importable into a particular local database because of a catalog identity conflict.
|
||||||
|
|
||||||
|
Therefore v1 has two distinct checks:
|
||||||
|
|
||||||
|
```text
|
||||||
|
document validation
|
||||||
|
local catalog reconciliation
|
||||||
|
```
|
||||||
|
|
||||||
|
Gate 1 freezes both contracts.
|
||||||
|
|
||||||
|
## Executable contract
|
||||||
|
|
||||||
|
Canonical reconciliation cases live in:
|
||||||
|
|
||||||
|
```text
|
||||||
|
tests/contract/catalog-import-cases.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Validate them with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python tools/validate_import_contract.py
|
||||||
|
```
|
||||||
|
|
||||||
|
This validator is an executable specification used before the production C17 importer exists.
|
||||||
|
|
||||||
|
## Freeze decision
|
||||||
|
|
||||||
|
After both Gate 1 reviews are validated, committed, pushed, and mirrored, the closure step may set:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GATE_1_REVIEW_01=PASS
|
||||||
|
GATE_1_REVIEW_02=PASS
|
||||||
|
GATE_1=PASS
|
||||||
|
TRAINLOG_FORMAT_V1=FROZEN
|
||||||
|
```
|
||||||
|
|
@ -11,16 +11,6 @@ GATE_0=PASS
|
||||||
GATE_0_REVIEW_01=PASS
|
GATE_0_REVIEW_01=PASS
|
||||||
```
|
```
|
||||||
|
|
||||||
Gate 0 established:
|
|
||||||
|
|
||||||
- repository structure;
|
|
||||||
- development contract;
|
|
||||||
- architecture documentation;
|
|
||||||
- coding-style rules;
|
|
||||||
- exchange-format draft;
|
|
||||||
- structural and semantic validation;
|
|
||||||
- positive and negative fixture strategy.
|
|
||||||
|
|
||||||
Reviewed hardening commit:
|
Reviewed hardening commit:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|
@ -35,43 +25,46 @@ ebd4316ed68c58598a471e567edf13455d00f92b
|
||||||
|
|
||||||
## Gate 1 — Exchange format v1 freeze
|
## Gate 1 — Exchange format v1 freeze
|
||||||
|
|
||||||
Status: VALIDATION PENDING — REVIEW #1
|
Status: VALIDATION PENDING — REVIEW #2
|
||||||
|
|
||||||
Canonical state:
|
Canonical state:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
GATE_1_REVIEW_01=IMPLEMENTED
|
GATE_1_REVIEW_01=IMPLEMENTED
|
||||||
|
GATE_1_REVIEW_02=IMPLEMENTED
|
||||||
GATE_1=VALIDATION_PENDING
|
GATE_1=VALIDATION_PENDING
|
||||||
TRAINLOG_FORMAT_V1=DRAFT
|
TRAINLOG_FORMAT_V1=DRAFT
|
||||||
```
|
```
|
||||||
|
|
||||||
Review #1 freezes the proposed model for:
|
Review #1 defined:
|
||||||
|
|
||||||
- exercise identity;
|
- exercise identity and tracking mode;
|
||||||
- stable repetition/duration tracking mode;
|
- session identity and ordering;
|
||||||
- session identity;
|
- load semantics;
|
||||||
- session ordering;
|
- target versus actual work;
|
||||||
- external/none/assistance load semantics;
|
- rest;
|
||||||
- planned versus actual sets;
|
- body weight and measurements;
|
||||||
- zero-repetition failed attempts;
|
- notes;
|
||||||
- planned exercises with zero actual sets;
|
- strict document validation.
|
||||||
- planned rest;
|
|
||||||
- body weight;
|
Review #2 closes:
|
||||||
- final v1 body-measurement field list;
|
|
||||||
- optional notes;
|
- official UUIDv4 identifier generation;
|
||||||
- strict catalog completeness;
|
- Android/TUI catalog collision handling;
|
||||||
- strict unknown-field behavior.
|
- hard tracking-mode identity conflicts;
|
||||||
|
- different-ID/same-name anti-duplicate conflicts;
|
||||||
|
- atomic catalog reconciliation.
|
||||||
|
|
||||||
Exit criteria:
|
Exit criteria:
|
||||||
|
|
||||||
- all valid fixtures accepted;
|
- `python tools/validate_json.py` passes;
|
||||||
- all invalid fixtures rejected for the intended reason;
|
- `python tools/validate_import_contract.py` passes;
|
||||||
|
- `git diff --check` passes;
|
||||||
- Android documentation aligned;
|
- Android documentation aligned;
|
||||||
- TUI documentation aligned;
|
- TUI documentation aligned;
|
||||||
- schema and semantic validator aligned;
|
- both review commits pushed to Forgejo and GitHub;
|
||||||
- review commit pushed to Forgejo and GitHub;
|
|
||||||
- mirrored review passes;
|
- mirrored review passes;
|
||||||
- `TRAINLOG_FORMAT_V1=FROZEN`.
|
- closure sets `TRAINLOG_FORMAT_V1=FROZEN`.
|
||||||
|
|
||||||
## Gate 2 — TUI persistence core
|
## Gate 2 — TUI persistence core
|
||||||
|
|
||||||
|
|
@ -80,7 +73,9 @@ Deliverables:
|
||||||
- Meson C17 project;
|
- Meson C17 project;
|
||||||
- SQLite open/create;
|
- SQLite open/create;
|
||||||
- schema versioning;
|
- schema versioning;
|
||||||
|
- UUIDv4 identity generation;
|
||||||
- exercise catalog;
|
- exercise catalog;
|
||||||
|
- atomic catalog reconciliation;
|
||||||
- session import transaction;
|
- session import transaction;
|
||||||
- idempotent import tests.
|
- idempotent import tests.
|
||||||
|
|
||||||
|
|
@ -113,6 +108,7 @@ Exit criteria:
|
||||||
Deliverables:
|
Deliverables:
|
||||||
|
|
||||||
- local exercise catalog;
|
- local exercise catalog;
|
||||||
|
- UUIDv4 identity generation;
|
||||||
- start/stop session timestamps;
|
- start/stop session timestamps;
|
||||||
- target entry;
|
- target entry;
|
||||||
- actual-set entry;
|
- actual-set entry;
|
||||||
|
|
|
||||||
|
|
@ -111,3 +111,28 @@ Before every meaningful push:
|
||||||
4. run `git diff --check`;
|
4. run `git diff --check`;
|
||||||
5. inspect `git status --short`;
|
5. inspect `git status --short`;
|
||||||
6. review documentation changes.
|
6. review documentation changes.
|
||||||
|
## 10. Catalog reconciliation contract
|
||||||
|
|
||||||
|
Before the C17 importer exists, Gate 1 defines local catalog merge behavior through an executable Python specification.
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python tools/validate_import_contract.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Canonical cases cover:
|
||||||
|
|
||||||
|
- exact existing exercise reuse;
|
||||||
|
- same identity with renamed display text;
|
||||||
|
- same identity with incompatible tracking mode;
|
||||||
|
- different identities with equivalent normalized names;
|
||||||
|
- new unique exercise creation.
|
||||||
|
|
||||||
|
The full Gate 1 validation command is:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python tools/validate_json.py
|
||||||
|
python tools/validate_import_contract.py
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
|
||||||
44
docs/tui.md
44
docs/tui.md
|
|
@ -144,3 +144,47 @@ Numeric input is validated before persistent state is committed.
|
||||||
Invalid input must never partially mutate a saved session.
|
Invalid input must never partially mutate a saved session.
|
||||||
|
|
||||||
Imports use full validation before the database transaction commits.
|
Imports use full validation before the database transaction commits.
|
||||||
|
## 14. Catalog reconciliation during import
|
||||||
|
|
||||||
|
Before creating any exercise or session rows, the TUI classifies incoming exercise metadata against the canonical local catalog.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
```text
|
||||||
|
same ID + same mode + same normalized name
|
||||||
|
-> reuse
|
||||||
|
|
||||||
|
same ID + same mode + different name
|
||||||
|
-> reuse + metadata warning
|
||||||
|
|
||||||
|
same ID + different mode
|
||||||
|
-> reject entire import
|
||||||
|
|
||||||
|
different ID + same normalized name
|
||||||
|
-> reject entire import
|
||||||
|
|
||||||
|
new ID + unique normalized name
|
||||||
|
-> create inside import transaction
|
||||||
|
```
|
||||||
|
|
||||||
|
The TUI must never silently merge different exercise IDs merely because names match.
|
||||||
|
|
||||||
|
The TUI must never create two identities with equivalent normalized display names.
|
||||||
|
|
||||||
|
Any hard catalog conflict aborts the complete session import transaction.
|
||||||
|
|
||||||
|
## 15. Generated identifiers
|
||||||
|
|
||||||
|
When the TUI creates a new exercise directly, it generates:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ex_<random UUID v4>
|
||||||
|
```
|
||||||
|
|
||||||
|
When the TUI creates a new session directly, it generates:
|
||||||
|
|
||||||
|
```text
|
||||||
|
se_<random UUID v4>
|
||||||
|
```
|
||||||
|
|
||||||
|
The database stores these identifiers as opaque stable text.
|
||||||
|
|
|
||||||
86
tests/contract/catalog-import-cases.json
Normal file
86
tests/contract/catalog-import-cases.json
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "same-id-same-name-same-mode",
|
||||||
|
"local": {
|
||||||
|
"exercise_id": "ex_11111111-1111-4111-8111-111111111111",
|
||||||
|
"name": "Presse à cuisses",
|
||||||
|
"tracking_mode": "reps"
|
||||||
|
},
|
||||||
|
"incoming": {
|
||||||
|
"exercise_id": "ex_11111111-1111-4111-8111-111111111111",
|
||||||
|
"name": "Presse à cuisses",
|
||||||
|
"tracking_mode": "reps"
|
||||||
|
},
|
||||||
|
"expected": "reuse"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "same-id-renamed-same-mode",
|
||||||
|
"local": {
|
||||||
|
"exercise_id": "ex_11111111-1111-4111-8111-111111111111",
|
||||||
|
"name": "Presse à cuisses",
|
||||||
|
"tracking_mode": "reps"
|
||||||
|
},
|
||||||
|
"incoming": {
|
||||||
|
"exercise_id": "ex_11111111-1111-4111-8111-111111111111",
|
||||||
|
"name": "Presse jambes",
|
||||||
|
"tracking_mode": "reps"
|
||||||
|
},
|
||||||
|
"expected": "reuse_with_name_warning"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "same-id-mode-conflict",
|
||||||
|
"local": {
|
||||||
|
"exercise_id": "ex_11111111-1111-4111-8111-111111111111",
|
||||||
|
"name": "Gainage",
|
||||||
|
"tracking_mode": "reps"
|
||||||
|
},
|
||||||
|
"incoming": {
|
||||||
|
"exercise_id": "ex_11111111-1111-4111-8111-111111111111",
|
||||||
|
"name": "Gainage",
|
||||||
|
"tracking_mode": "duration"
|
||||||
|
},
|
||||||
|
"expected": "reject_mode_conflict"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "different-id-equivalent-name",
|
||||||
|
"local": {
|
||||||
|
"exercise_id": "ex_11111111-1111-4111-8111-111111111111",
|
||||||
|
"name": "Presse à cuisses",
|
||||||
|
"tracking_mode": "reps"
|
||||||
|
},
|
||||||
|
"incoming": {
|
||||||
|
"exercise_id": "ex_22222222-2222-4222-8222-222222222222",
|
||||||
|
"name": " PRESSE À CUISSES ",
|
||||||
|
"tracking_mode": "reps"
|
||||||
|
},
|
||||||
|
"expected": "reject_name_identity_conflict"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "different-id-equivalent-name-different-mode",
|
||||||
|
"local": {
|
||||||
|
"exercise_id": "ex_11111111-1111-4111-8111-111111111111",
|
||||||
|
"name": "Gainage",
|
||||||
|
"tracking_mode": "reps"
|
||||||
|
},
|
||||||
|
"incoming": {
|
||||||
|
"exercise_id": "ex_22222222-2222-4222-8222-222222222222",
|
||||||
|
"name": " gainage ",
|
||||||
|
"tracking_mode": "duration"
|
||||||
|
},
|
||||||
|
"expected": "reject_name_identity_conflict"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "new-id-unique-name",
|
||||||
|
"local": {
|
||||||
|
"exercise_id": "ex_11111111-1111-4111-8111-111111111111",
|
||||||
|
"name": "Presse à cuisses",
|
||||||
|
"tracking_mode": "reps"
|
||||||
|
},
|
||||||
|
"incoming": {
|
||||||
|
"exercise_id": "ex_22222222-2222-4222-8222-222222222222",
|
||||||
|
"name": "Gainage ventral",
|
||||||
|
"tracking_mode": "duration"
|
||||||
|
},
|
||||||
|
"expected": "create"
|
||||||
|
}
|
||||||
|
]
|
||||||
71
tools/validate_import_contract.py
Executable file
71
tools/validate_import_contract.py
Executable file
|
|
@ -0,0 +1,71 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Validate Trainlog v1 catalog reconciliation contract cases."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import unicodedata
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
CASES_PATH = ROOT / "tests" / "contract" / "catalog-import-cases.json"
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_exercise_name(name: str) -> str:
|
||||||
|
"""Return the v1 normalized comparison form for an exercise name."""
|
||||||
|
nfc = unicodedata.normalize("NFC", name)
|
||||||
|
collapsed = " ".join(nfc.strip().split())
|
||||||
|
return collapsed.casefold()
|
||||||
|
|
||||||
|
|
||||||
|
def classify(local: dict[str, Any], incoming: dict[str, Any]) -> str:
|
||||||
|
"""Classify one incoming catalog entry against one existing local entry.
|
||||||
|
|
||||||
|
This function is executable specification for the Gate 1 import contract.
|
||||||
|
It does not mutate either object.
|
||||||
|
"""
|
||||||
|
same_id = local["exercise_id"] == incoming["exercise_id"]
|
||||||
|
same_name = normalize_exercise_name(local["name"]) == normalize_exercise_name(
|
||||||
|
incoming["name"]
|
||||||
|
)
|
||||||
|
same_mode = local["tracking_mode"] == incoming["tracking_mode"]
|
||||||
|
|
||||||
|
if same_id:
|
||||||
|
if not same_mode:
|
||||||
|
return "reject_mode_conflict"
|
||||||
|
if same_name:
|
||||||
|
return "reuse"
|
||||||
|
return "reuse_with_name_warning"
|
||||||
|
|
||||||
|
if same_name:
|
||||||
|
return "reject_name_identity_conflict"
|
||||||
|
|
||||||
|
return "create"
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
"""Run every canonical catalog reconciliation case."""
|
||||||
|
with CASES_PATH.open("r", encoding="utf-8") as handle:
|
||||||
|
cases = json.load(handle)
|
||||||
|
|
||||||
|
failed = False
|
||||||
|
|
||||||
|
for case in cases:
|
||||||
|
actual = classify(case["local"], case["incoming"])
|
||||||
|
expected = case["expected"]
|
||||||
|
|
||||||
|
if actual != expected:
|
||||||
|
print(
|
||||||
|
f"FAIL {case['name']}: expected {expected!r}, got {actual!r}"
|
||||||
|
)
|
||||||
|
failed = True
|
||||||
|
else:
|
||||||
|
print(f"PASS {case['name']}: {actual}")
|
||||||
|
|
||||||
|
return 1 if failed else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Loading…
Reference in a new issue