diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e10cca..007628a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ this file is about the package, whose version moves independently. ## Unreleased +- **A diver carries a portrait.** §6.1 of + [the specification](https://github.com/divejson/divejson/blob/main/spec/divejson.md) adds + `portrait_file`, a Stored File (§6.7), and `divejson validate` claims its uuid in the + document's one identifier space, so a portrait sharing a uuid with any other record is + refused (§5.3). UDDF carries no portrait in either direction: the reader takes no image + from ``, and the writer reports the member rather than writing it. + ## 0.11.0 - **Breaking: a diver carries a date of birth, a phone, emergency contacts and insurances, diff --git a/SPEC_REF b/SPEC_REF index 654fda5..b3b35b4 100644 --- a/SPEC_REF +++ b/SPEC_REF @@ -1 +1 @@ -c5034f083d178de0d4c4130dd989213e8031db92 +929788950d59e3b8130ccdbd27811e44aeb074ff diff --git a/divejson/validate.py b/divejson/validate.py index b4ba1bb..4ca6226 100644 --- a/divejson/validate.py +++ b/divejson/validate.py @@ -200,6 +200,9 @@ def _semantic_issues(doc: dict[str, Any]) -> list[Issue]: seen_uuids: dict[str, str] = {} if isinstance(diver, dict): _claim_uuid(diver, "diver", seen_uuids, issues) + portrait = diver.get("portrait_file") + if isinstance(portrait, dict): + _claim_uuid(portrait, "diver/portrait_file", seen_uuids, issues) _check_datetime(diver, "created_at", "diver", issues) collections = { diff --git a/docs/uddf-mapping.md b/docs/uddf-mapping.md index 56b8831..05b02d2 100644 --- a/docs/uddf-mapping.md +++ b/docs/uddf-mapping.md @@ -144,6 +144,14 @@ mapped* below. (§6.1). Nothing fills `diver.emergency_contacts` or an insurance's `number`: UDDF has no element for either. +**Nothing fills `diver.portrait_file`, and a linked image is not read as one.** `` +has no image of its own. The one route to a picture, a `` to a +``, carries no role: UDDF's own documentation of `` links an +equipment photo, a group photo, an audio clip and a video that way, so a reader taking the +first linked image for a portrait would import a boat photo as the diver's face. The +`` also names its file by a path whose bytes the UDDF file does not carry, so a Stored +File's digest and size (§6.7) could only be invented. + **`@id` is never read as a name or a handle.** It is an XML id, and Subsurface's is the literal string `owner`. diff --git a/docs/uddf-writing.md b/docs/uddf-writing.md index 12a7ca9..8298756 100644 --- a/docs/uddf-writing.md +++ b/docs/uddf-writing.md @@ -161,8 +161,8 @@ its examples. It is ``'s answer (*Sites and trips* below) and it inv the member is a date, so a reader takes the date back off the front and the midnight never reaches a document. -An insurance's `number` and the diver's `emergency_contacts` have no element, and each is -reported — *What is never written* has why. +An insurance's `number` and the diver's `emergency_contacts` and `portrait_file` have no +element, and each is reported — *What is never written* has why. **Differs from the reference writer**: `diver.email` is written to ``. The reference writer deliberately omits it — a UDDF file is the thing a @@ -645,6 +645,7 @@ once per record that carries it, and none of them has anywhere in UDDF to go: | `diver.username` | `` is an XML id and not a handle | | `diver.emergency_contacts` | UDDF has no element for one, and `` describes the logbook's owner and nobody else | | `diver.insurances[].number` | `insuranceType` holds a `name`, `aliasname`, `issuedate`, `validdate` and `notes`, with nothing for the identifier the insurer knows the diver by. `` would read back as a remark, and `` as a membership, which a reader cannot tell from a club's | +| `diver.portrait_file` | `` has no image element. A `` to a `` names a file by path, whose bytes a UDDF file does not carry, and reads back as a linked picture with no role — `uddf-mapping.md`'s *Diver* section says why no linked image is read as a portrait | | a recording's `source_files`, `started_at` and its device's `firmware`, and every recording after the first | UDDF gives a dive one ``, and `equipmentPieceType` no firmware element — *Devices* above has each answer and why the device of a dropped recording is kept even so | | `trips[].parts[].location.bbox` | `geographyType` carries a point, not a box | | `sites[].location.full_name`, `position` and `bbox` | a site's `` is its own, so the locality gets only `` and that slot holds `location.name`; ``'s coordinates are the site's pin, and the box has nowhere either — *Sites and trips* above has the asymmetry with a part | diff --git a/fixtures/README.md b/fixtures/README.md index 0378d75..8e946c7 100644 --- a/fixtures/README.md +++ b/fixtures/README.md @@ -33,7 +33,7 @@ what makes a fixture dropped into this tree impossible to ignore. | `minimal.divejson` | The smallest conforming document: `format`, `version`, `exported_at` — no diver (a source that records nothing about its owner omits the member), no collections (absent ≡ empty). | | `demo-logbook.divejson` | A real export of the reference writer's demo account (all names are seeded demo data; pulled 2026-09-02, courses-era writer): 8 dives, one carrying a recording with its device, its stored file and a full sampled profile, which is what a dive imported from a computer file looks like — sites, trips, an empty `courses` collection, gear with a service history, certifications, and producer extensions carrying application-specific values. Its device is written by hand: the export predates recordings, and the file it names was read by the `suunto_json` parser, so the device is what that reader takes from such a file. So are the diver's `phone`, `born_on`, `emergency_contacts` and `insurances`, one element in each array, which the export predates as well. Regenerate from a fresh export when the writer changes. | | `two-computers.divejson` | Hand-built coverage of §6.4a, and of the half of it no converter can reach: a dive with **two** recordings that both carry a profile — `ssrf/two-computers.ssrf` reaches that much from one file — where the first holds **two** `source_files`, one recording the app exported twice, as JSON beside FIT, each file read by a different parser. Its second recording carries its own `started_at`, 31.33 s after the dive's, so its samples sit on their own axis. Its two devices differ in every way §6.4b allows two devices to differ, and the second computer's deepest sample is deeper than the dive's logged `max_depth`, which is the ordinary disagreement between two devices rather than a defect. It has no pair: §6.7's stored-file records are the application's and no reader produces one, which is the point of it. It is also where the two halves of §6.4c the other hand-built file cannot reach live: its first recording runs an RGBM model named in words with a **negative** `conservatism` and no gradient factors, the shape a Suunto states, while its second is in **`gauge`** mode and carries no `deco_model` at all — a computer run as a bottom timer runs no decompression model, and saying so is not the same as saying nothing. Between the two files every member of §6.4c's table and two of §6.4a's five `mode` values are exercised by a document no converter wrote; `freedive` comes from `suunto_xml/freedive.xml`, and `closed_circuit` and `semi_closed` wait for a file, no source in hand stating either. | -| `technical-dive.divejson` | Hand-built coverage of what the demo corpus lacks: trimix, a sidemount pair (two cylinders, one blend, `usage: "parallel"`), staged deco cylinders, gas-switch events, a ceiling channel with a gap, per-cylinder pressure channels, a `+12:45` UTC offset **and** an offset-less local `started_at` (§5.2's third state), a dive with no recorded duration, a common-name-only species, a trip of three parts — a place with dates, a place with none, and dates with no place — the second of them carrying an antimeridian-crossing bounding box, the corpus's only **dive site whose locality is filled in** — a full name, a centre and a box on `location`, beside the site's own pin at a different point, which is the pair §6.10 exists to keep apart — an `agency: "other"` certification with `front_file`/`back_file`, a dive-count service interval, three courses — a completed `"other"`-agency course linked from a dive and its certification, an unreferenced `"planned"` one with no dates, and one with **no `agency`**, the private-instructor case §6.17 leaves the member out for — a lowercase-`z`, one-digit-fraction `created_at` (both spellings the grammar allows and naive parsers reject), and a `bookmark` 85 s **past** `profile.duration` — the surface-marker case §6.4 blesses, which the pre-2026-09-04 validator rejected, and which also carries a `label` beside a `type`, the pairing §6.6 asks for on any value defined after 1.0. Its recording's device carries **all six** of §6.4b's members, hand-built like the rest of the file. Its diver carries an emergency contact and an insurance with a `number`, the two members of §6.1 UDDF has no element for. It is also the **decompression** fixture: `mode: "open_circuit"` and a Bühlmann `deco_model` with a name and a 30/70 gradient-factor pair, and all six of §6.4's readout channels sampled on the depth channel's own seconds — an `ndl` that runs from the 99-minute display cap down to zero and back, a zero `ndl` at the same second as a `ceiling` (the coexistence §6.4 blesses), a `tts` present only while there is an obligation, a `ppo2` that climbs through three gases, a `cns` whose ends are the dive's own `cns_start` and `cns_end`, and a `surface_gradient_factor` above 100 with the `gradient_factor` beside it far below. Its profile's `extensions` carries a tissue-loading array, which is what §6.4c says tissue state rides until a reader can compute from it. | +| `technical-dive.divejson` | Hand-built coverage of what the demo corpus lacks: trimix, a sidemount pair (two cylinders, one blend, `usage: "parallel"`), staged deco cylinders, gas-switch events, a ceiling channel with a gap, per-cylinder pressure channels, a `+12:45` UTC offset **and** an offset-less local `started_at` (§5.2's third state), a dive with no recorded duration, a common-name-only species, a trip of three parts — a place with dates, a place with none, and dates with no place — the second of them carrying an antimeridian-crossing bounding box, the corpus's only **dive site whose locality is filled in** — a full name, a centre and a box on `location`, beside the site's own pin at a different point, which is the pair §6.10 exists to keep apart — an `agency: "other"` certification with `front_file`/`back_file`, a dive-count service interval, three courses — a completed `"other"`-agency course linked from a dive and its certification, an unreferenced `"planned"` one with no dates, and one with **no `agency`**, the private-instructor case §6.17 leaves the member out for — a lowercase-`z`, one-digit-fraction `created_at` (both spellings the grammar allows and naive parsers reject), and a `bookmark` 85 s **past** `profile.duration` — the surface-marker case §6.4 blesses, which the pre-2026-09-04 validator rejected, and which also carries a `label` beside a `type`, the pairing §6.6 asks for on any value defined after 1.0. Its recording's device carries **all six** of §6.4b's members, hand-built like the rest of the file. Its diver carries an emergency contact, an insurance with a `number` and a `portrait_file`, the three members of §6.1 UDDF has no element for. It is also the **decompression** fixture: `mode: "open_circuit"` and a Bühlmann `deco_model` with a name and a 30/70 gradient-factor pair, and all six of §6.4's readout channels sampled on the depth channel's own seconds — an `ndl` that runs from the 99-minute display cap down to zero and back, a zero `ndl` at the same second as a `ceiling` (the coexistence §6.4 blesses), a `tts` present only while there is an obligation, a `ppo2` that climbs through three gases, a `cns` whose ends are the dive's own `cns_start` and `cns_end`, and a `surface_gradient_factor` above 100 with the `gradient_factor` beside it far below. Its profile's `extensions` carries a tissue-loading array, which is what §6.4c says tissue state rides until a reader can compute from it. | ## invalid/ @@ -66,6 +66,7 @@ validator does not look. | `duplicate-uuid.divejson` | two records share a uuid | §5.3 | | `dangling-reference.divejson` | a `site_uuids` entry resolves to nothing | §5.3 | | `duplicate-file-uuid-across-recordings.divejson` | two recordings of one dive carry the same stored-file record | §5.3 | +| `duplicate-file-uuid-portrait.divejson` | the diver's `portrait_file` carries the uuid of a certification's `front_file` | §5.3 | | `recording-without-content.divejson` | a recording carrying none of `device`, `profile`, `source_files` | §3, §6.4a | | `dive-profile-outside-recording.divejson` | a `profile` on the dive rather than inside a recording — the retired shape | §6.2, §6.4a | | `device-empty-member.divejson` | a device with an empty `serial` | §6.4b | @@ -243,5 +244,5 @@ point of each row below is which part of `uddf-writing.md` it reaches. | file | written from | what it covers | | --- | --- | --- | | `opendiving.divejson` | `uddf/opendiving.divejson`, plus a device | The round trip that matters most, and the pair that exercises almost none of the report: this document is itself the *reading* of a UDDF export, so there is nothing in it UDDF cannot hold, and the only finding is the `extensions` exclusion every written file carries. `dive-` ids that come back as those uuids, a trip of one part, written back as the `` with its dates and its place that it was read from, a kit list under `` with per-dive `` links, two cylinders on two gases with their pressure channels, a `` gas switch and a ``. And the **fold**: its dive lists that `computer` gear item, which satisfies the predicate's link leg, and its recording's device and the gear item carry the same serial, so the serial leg fires and the two become one `` element carrying both halves, with the device's counter on the dive as ``. It is the corpus's only pair that reaches the serial leg. **No pair reaches the link leg refusing** — a `computer` gear item the document carries and one of its own dives does not link — so that branch has no pair either way, and [`docs/uddf-writing.md`](../docs/uddf-writing.md) writes it down rather than leaving it to the first writer to meet one. Its diver's `born_on`, `phone` and insurance are written to ``, `` and ``, which puts a `` between `` and `` and the insurances after the kit list, the order `personType` fixes. | -| `technical-dive.divejson` | `valid/technical-dive.divejson` | Everything the first one cannot reach, being hand-built to hold what no UDDF export carries. The `dropped` half of the report: `courses`, `certifications`, `gear_sets`, gear service and `species`, which UDDF has no slot for; `role` and `usage` on a sidemount pair and its staged deco cylinders; a ceiling channel; a trip part's bounding box, and a dive site's locality entire but for its name — its full name, its centre and its box, which is three findings off one record where the part loses one; the gas numbering UDDF cannot record; `shears` landing in `` and reading back as `other`; a trip part whose location has coordinates and no full name, which loses the coordinates rather than borrowing the name; a `bookmark` carrying a label, which keeps its type and loses the label; an event with a label and no type, which goes out as a `` carrying the label; the `deco_model`, `tts` and `surface_gradient_factor` its document now carries, each reported `dropped` — the first because UDDF's `` requires a tissue table this format has no member for, the other two because UDDF has no element at all; an empty note, which no UDDF file can spell; a device's `firmware`, for which `equipmentPieceType` has no element; and the diver's `emergency_contacts` and an insurance's `number`, which UDDF has no element for either — the insurance itself is written, as a `` entry carrying its provider. It is also the **other** half of the device fold: its gear list holds no computer, so the device matches nothing and gets a `` of its own with a non-UUID id and a `` from the dive — the one case where reading the written file back returns a gear item the input never had. That its device carries a `name` is what puts it inside that exception rather than beside it: a nameless device on an element of its own gets an empty ``, which comes back as no gear item at all. The `absent` half is its second dive and its third trip part: no maximum depth, no duration and a cylinder with no start pressure, so ``, `` and `` are each written as the `0` a reader takes back off, and a part with no place gets the empty `` `simpleNamedType` requires. Its trip is the corpus's only three-`` one, and the three are the three shapes a part comes in: a place with dates, a place with none — which writes no ``, that being the one thing here that costs nothing — and dates with no place. | +| `technical-dive.divejson` | `valid/technical-dive.divejson` | Everything the first one cannot reach, being hand-built to hold what no UDDF export carries. The `dropped` half of the report: `courses`, `certifications`, `gear_sets`, gear service and `species`, which UDDF has no slot for; `role` and `usage` on a sidemount pair and its staged deco cylinders; a ceiling channel; a trip part's bounding box, and a dive site's locality entire but for its name — its full name, its centre and its box, which is three findings off one record where the part loses one; the gas numbering UDDF cannot record; `shears` landing in `` and reading back as `other`; a trip part whose location has coordinates and no full name, which loses the coordinates rather than borrowing the name; a `bookmark` carrying a label, which keeps its type and loses the label; an event with a label and no type, which goes out as a `` carrying the label; the `deco_model`, `tts` and `surface_gradient_factor` its document now carries, each reported `dropped` — the first because UDDF's `` requires a tissue table this format has no member for, the other two because UDDF has no element at all; an empty note, which no UDDF file can spell; a device's `firmware`, for which `equipmentPieceType` has no element; and the diver's `emergency_contacts` and `portrait_file` and an insurance's `number`, which UDDF has no element for either — the insurance itself is written, as a `` entry carrying its provider. It is also the **other** half of the device fold: its gear list holds no computer, so the device matches nothing and gets a `` of its own with a non-UUID id and a `` from the dive — the one case where reading the written file back returns a gear item the input never had. That its device carries a `name` is what puts it inside that exception rather than beside it: a nameless device on an element of its own gets an empty ``, which comes back as no gear item at all. The `absent` half is its second dive and its third trip part: no maximum depth, no duration and a cylinder with no start pressure, so ``, `` and `` are each written as the `0` a reader takes back off, and a part with no place gets the empty `` `simpleNamedType` requires. Its trip is the corpus's only three-`` one, and the three are the three shapes a part comes in: a place with dates, a place with none — which writes no ``, that being the one thing here that costs nothing — and dates with no place. | | `owner-profile-only.divejson` | `uddf/owner-profile-only.divejson` | The owner that records a person and names nobody, going out: a diver with a uuid and a `born_on` and no name or email still gets a ``, under `` because the document records something about the person, with the empty names `personalType` requires beside the ``. The written file is its reader pair's input again, `` aside. | diff --git a/fixtures/invalid/duplicate-file-uuid-portrait.divejson b/fixtures/invalid/duplicate-file-uuid-portrait.divejson new file mode 100644 index 0000000..c034988 --- /dev/null +++ b/fixtures/invalid/duplicate-file-uuid-portrait.divejson @@ -0,0 +1,696 @@ +{ + "format": "divejson", + "version": "1.0", + "exported_at": "2026-08-28T12:00:00Z", + "generator": { + "name": "DiveJSON fixtures" + }, + "diver": { + "uuid": "0198a6f0-1111-7001-8000-000000000001", + "name": "Alex Trim", + "emergency_contacts": [ + { + "name": "Robin Trim", + "phone": "+64 3 555 0142", + "relationship": "brother" + } + ], + "insurances": [ + { + "provider": "DAN World", + "number": "DW-88213" + } + ], + "portrait_file": { + "uuid": "0198a6f0-1111-7081-8000-000000000081", + "original_filename": "IMG_4471.jpg", + "content_type": "image/jpeg", + "byte_size": 2861904, + "sha256": "5e0c7a1b9d24f3e6a8b0c2d4e6f80a1b3c5d7e9f0a2b4c6d8e0f1a3b5c7d9e1f" + }, + "created_at": "2024-01-05T09:00:00+00:00" + }, + "dives": [ + { + "uuid": "0198a6f0-1111-7010-8000-000000000010", + "number": 412, + "started_at": "2026-07-03T09:14:00+12:45", + "duration": 5520, + "notes": "Trimix wall dive; two deco gases staged on the line.", + "max_depth": 62.3, + "avg_depth": 31.8, + "bottom_temperature": 7.4, + "visibility": 25, + "weight": 0, + "water_type": "salt", + "altitude": 0, + "cns_start": 4.5, + "cns_end": 61.0, + "otu_start": 0, + "otu_end": 88.5, + "surface_pressure": 1.013, + "entry_position": { + "latitude": -45.86055, + "longitude": 170.66973 + }, + "exit_position": { + "latitude": -45.860632, + "longitude": 170.669841 + }, + "trip_uuid": "0198a6f0-1111-7030-8000-000000000030", + "course_uuid": "0198a6f0-1111-7090-8000-000000000090", + "site_uuids": [ + "0198a6f0-1111-7040-8000-000000000040", + "0198a6f0-1111-7041-8000-000000000041" + ], + "gear_uuids": [ + "0198a6f0-1111-7050-8000-000000000050", + "0198a6f0-1111-7051-8000-000000000051" + ], + "species_uuids": [ + "0198a6f0-1111-7060-8000-000000000060" + ], + "cylinders": [ + { + "volume": 12.0, + "start_pressure": 220.0, + "end_pressure": 80.0, + "oxygen": 18.0, + "helium": 45.0, + "po2_limit": 1.4, + "gas_number": 1, + "role": "bottom", + "usage": "parallel" + }, + { + "volume": 12.0, + "start_pressure": 218.0, + "end_pressure": 95.0, + "oxygen": 18.0, + "helium": 45.0, + "po2_limit": 1.4, + "gas_number": 2, + "role": "bottom", + "usage": "parallel" + }, + { + "volume": 7.0, + "start_pressure": 200.0, + "end_pressure": 120.0, + "oxygen": 50.0, + "helium": 0.0, + "po2_limit": 1.6, + "gas_number": 3, + "role": "deco", + "usage": "staged" + }, + { + "volume": 7.0, + "start_pressure": 195.0, + "end_pressure": 0.0, + "oxygen": 100.0, + "helium": 0.0, + "po2_limit": 1.6, + "gas_number": 4, + "role": "oxygen", + "usage": "staged" + } + ], + "recordings": [ + { + "device": { + "brand": "Shearwater", + "model": "Perdix 2", + "serial": "D9123456", + "firmware": "V92", + "name": "Deco", + "dive_number": 87 + }, + "mode": "open_circuit", + "deco_model": { + "algorithm": "buhlmann", + "name": "ZHL-16C", + "gf_low": 30, + "gf_high": 70 + }, + "profile": { + "duration": 5520, + "depth": { + "times": [ + 0, + 120, + 600, + 1500, + 2400, + 3000, + 3600, + 4200, + 4800, + 5400, + 5520 + ], + "values": [ + 0, + 1830, + 6100, + 6230, + 5800, + 2100, + 1200, + 900, + 600, + 300, + 0 + ] + }, + "ceiling": { + "times": [ + 1500, + 2400, + 3000, + 3600, + 4200, + 4800 + ], + "values": [ + 1800, + 1500, + 900, + 600, + 300, + 300 + ] + }, + "temperature": { + "times": [ + 0, + 1500, + 5400 + ], + "values": [ + 162, + 74, + 158 + ] + }, + "pressures": [ + { + "times": [ + 0, + 2400, + 5400 + ], + "values": [ + 2200, + 1400, + 800 + ], + "gas_number": 1 + }, + { + "times": [ + 0, + 2400, + 5400 + ], + "values": [ + 2180, + 1500, + 950 + ], + "gas_number": 2 + }, + { + "times": [ + 3000, + 4200 + ], + "values": [ + 2000, + 1600 + ], + "gas_number": 3 + }, + { + "times": [ + 4200, + 5400 + ], + "values": [ + 1950, + 0 + ], + "gas_number": 4 + } + ], + "ndl": { + "times": [ + 0, + 120, + 600, + 1500, + 2400, + 3000, + 3600, + 4200, + 4800, + 5400, + 5520 + ], + "values": [ + 5940, + 1200, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1800, + 5940 + ] + }, + "tts": { + "times": [ + 600, + 1500, + 2400, + 3000, + 3600, + 4200, + 4800, + 5400 + ], + "values": [ + 900, + 3900, + 3300, + 2400, + 1500, + 900, + 420, + 180 + ] + }, + "ppo2": { + "times": [ + 0, + 120, + 600, + 1500, + 2400, + 3000, + 3600, + 4200, + 4800, + 5400, + 5520 + ], + "values": [ + 18, + 51, + 128, + 130, + 122, + 56, + 110, + 95, + 160, + 130, + 100 + ] + }, + "cns": { + "times": [ + 0, + 120, + 600, + 1500, + 2400, + 3000, + 3600, + 4200, + 4800, + 5400, + 5520 + ], + "values": [ + 45, + 50, + 95, + 210, + 330, + 360, + 430, + 480, + 560, + 600, + 610 + ] + }, + "gradient_factor": { + "times": [ + 0, + 120, + 600, + 1500, + 2400, + 3000, + 3600, + 4200, + 4800, + 5400, + 5520 + ], + "values": [ + 0, + 8, + 22, + 31, + 38, + 64, + 68, + 69, + 67, + 55, + 41 + ] + }, + "surface_gradient_factor": { + "times": [ + 0, + 120, + 600, + 1500, + 2400, + 3000, + 3600, + 4200, + 4800, + 5400, + 5520 + ], + "values": [ + 0, + 26, + 88, + 141, + 158, + 96, + 84, + 78, + 72, + 66, + 41 + ] + }, + "events": [ + { + "time": 0, + "type": "gas_switch", + "gas_number": 1 + }, + { + "time": 2980, + "type": "deep_stop" + }, + { + "time": 3005, + "type": "gas_switch", + "gas_number": 3 + }, + { + "time": 4210, + "type": "gas_switch", + "gas_number": 4 + }, + { + "time": 5100, + "type": "safety_stop" + }, + { + "time": 5200, + "label": "Tissue ceiling cleared" + }, + { + "time": 5605, + "type": "bookmark", + "label": "Surface marker" + } + ], + "extensions": { + "com.example.deco": { + "tissue_loading_bar": [ + 0.41, + 0.55, + 0.68, + 0.62 + ] + } + } + } + } + ], + "created_at": "2026-07-03T18:30:00+12:45" + }, + { + "uuid": "0198a6f0-1111-7011-8000-000000000011", + "started_at": "2026-07-04T10:02:00", + "site_uuids": [ + "0198a6f0-1111-7040-8000-000000000040" + ], + "species_uuids": [ + "0198a6f0-1111-7061-8000-000000000061" + ], + "cylinders": [ + { + "oxygen": 32.0 + } + ], + "notes": "Converted from a legacy log: no offset, no duration recorded." + } + ], + "trips": [ + { + "uuid": "0198a6f0-1111-7030-8000-000000000030", + "name": "Fiordland tech week", + "parts": [ + { + "starts_on": "2026-07-01", + "ends_on": "2026-07-04", + "location": { + "name": "Milford Sound", + "full_name": "Milford Sound / Piopiotahi, Southland, New Zealand", + "position": { + "latitude": -44.6414, + "longitude": 167.8974 + }, + "bbox": { + "south": -44.7, + "north": -44.58, + "west": 167.8, + "east": 167.99 + } + } + }, + { + "location": { + "name": "Chatham Rise crossing", + "position": { + "latitude": -43.9, + "longitude": 179.9 + }, + "bbox": { + "south": -44.2, + "north": -43.6, + "west": 179.5, + "east": -179.6 + } + } + }, + { + "starts_on": "2026-07-08", + "ends_on": "2026-07-09" + } + ], + "notes": "", + "created_at": "2026-06-01T08:00:00+12:00" + } + ], + "courses": [ + { + "uuid": "0198a6f0-1111-7090-8000-000000000090", + "name": "Normoxic Trimix", + "agency": "other", + "agency_other": "NZ Tech Diving Society", + "status": "completed", + "starts_on": "2026-06-30", + "ends_on": "2026-07-05", + "instructor_name": "J. Harbord", + "training_center": "Deep South Divers", + "notes": "Four training dives; the wall dive was the final assessment.", + "created_at": "2026-06-01T09:00:00+12:00" + }, + { + "uuid": "0198a6f0-1111-7091-8000-000000000091", + "name": "Cave 1", + "agency": "gue", + "status": "planned", + "notes": "Booked for the southern winter; no dates confirmed yet." + }, + { + "uuid": "0198a6f0-1111-7092-8000-000000000092", + "name": "Underwater Videography", + "status": "completed", + "starts_on": "2026-08-14", + "ends_on": "2026-08-16", + "instructor_name": "T. Mahuta", + "notes": "Taught privately over two weekends; no agency issued it." + } + ], + "sites": [ + { + "uuid": "0198a6f0-1111-7040-8000-000000000040", + "name": "Harrys Wall", + "location": { + "name": "Milford Sound, New Zealand", + "full_name": "Milford Sound / Piopiotahi, Southland, New Zealand", + "position": { + "latitude": -44.6414, + "longitude": 167.8974 + }, + "bbox": { + "south": -44.7, + "north": -44.58, + "west": 167.8, + "east": 167.99 + } + }, + "position": { + "latitude": -44.6301, + "longitude": 167.8901 + }, + "notes": "Black coral from 12 m.", + "created_at": "2026-06-01T08:05:00+12:00" + }, + { + "uuid": "0198a6f0-1111-7041-8000-000000000041", + "name": "The Chimney" + } + ], + "species": [ + { + "uuid": "0198a6f0-1111-7060-8000-000000000060", + "aphia_id": 103695, + "scientific_name": "Antipathella fiordensis", + "common_name": "Fiordland black coral", + "rank": "Species", + "created_at": "2026-07-03T19:00:00+12:45" + }, + { + "uuid": "0198a6f0-1111-7061-8000-000000000061", + "common_name": "carpet shark" + } + ], + "gear": [ + { + "uuid": "0198a6f0-1111-7050-8000-000000000050", + "name": "Twin 12 manifold", + "type": "cylinder", + "notes": "", + "rented": false, + "archived": false, + "dive_count": 84, + "created_at": "2024-02-01T10:00:00+00:00" + }, + { + "uuid": "0198a6f0-1111-7051-8000-000000000051", + "name": "Tech drysuit", + "brand": "Santi", + "type": "drysuit", + "rented": false, + "dive_count": 122 + }, + { + "uuid": "0198a6f0-1111-7052-8000-000000000052", + "name": "EMT shears", + "type": "shears", + "rented": false, + "created_at": "2024-03-01T08:00:00.5z" + } + ], + "gear_sets": [ + { + "uuid": "0198a6f0-1111-7055-8000-000000000055", + "name": "Cold water tech", + "weight": 4.0, + "gear_uuids": [ + "0198a6f0-1111-7050-8000-000000000050", + "0198a6f0-1111-7051-8000-000000000051" + ], + "created_at": "2024-02-01T10:05:00+00:00" + } + ], + "gear_service_schedules": [ + { + "uuid": "0198a6f0-1111-7070-8000-000000000070", + "gear_uuid": "0198a6f0-1111-7050-8000-000000000050", + "type": "visual_inspection", + "starts_on": "2024-02-01", + "interval_dives": 100, + "dive_count_at_start": 0, + "active": true, + "last_service_on": "2025-11-10", + "next_due_at_dive_count": 184, + "created_at": "2024-02-01T10:06:00+00:00" + } + ], + "gear_service_records": [ + { + "uuid": "0198a6f0-1111-7071-8000-000000000071", + "gear_uuid": "0198a6f0-1111-7050-8000-000000000050", + "gear_service_schedule_uuid": "0198a6f0-1111-7070-8000-000000000070", + "type": "visual_inspection", + "serviced_on": "2025-11-10", + "dive_count_at_service": 84, + "performed_by": "Southern Cylinders Ltd", + "notes": "Passed.", + "created_at": "2025-11-10T15:00:00+13:00" + } + ], + "certifications": [ + { + "uuid": "0198a6f0-1111-7080-8000-000000000080", + "agency": "other", + "agency_other": "NZ Tech Diving Society", + "name": "Normoxic Trimix Diver", + "number": "NZT-2044", + "certified_on": "2025-03-14", + "instructor_name": "J. Harbord", + "training_center": "Deep South Divers", + "course_uuid": "0198a6f0-1111-7090-8000-000000000090", + "notes": "", + "front_file": { + "uuid": "0198a6f0-1111-7081-8000-000000000081", + "original_filename": "trimix-card-front.jpg", + "content_type": "image/jpeg", + "byte_size": 240113, + "sha256": "a3f1c2d4e5b697887960a1b2c3d4e5f60718293a4b5c6d7e8f9012345678abcd" + }, + "back_file": { + "uuid": "0198a6f0-1111-7082-8000-000000000082", + "original_filename": "trimix-card-back.jpg", + "content_type": "image/jpeg", + "byte_size": 199004, + "sha256": "b4f1c2d4e5b697887960a1b2c3d4e5f60718293a4b5c6d7e8f9012345678abce" + }, + "created_at": "2025-03-15T09:00:00+13:00" + } + ], + "extensions": { + "opendiving": { + "note": "hand-built conformance fixture" + } + } +} diff --git a/fixtures/valid/technical-dive.divejson b/fixtures/valid/technical-dive.divejson index 966544a..4a51ff6 100644 --- a/fixtures/valid/technical-dive.divejson +++ b/fixtures/valid/technical-dive.divejson @@ -21,6 +21,13 @@ "number": "DW-88213" } ], + "portrait_file": { + "uuid": "0198a6f0-1111-7002-8000-000000000002", + "original_filename": "IMG_4471.jpg", + "content_type": "image/jpeg", + "byte_size": 2861904, + "sha256": "5e0c7a1b9d24f3e6a8b0c2d4e6f80a1b3c5d7e9f0a2b4c6d8e0f1a3b5c7d9e1f" + }, "created_at": "2024-01-05T09:00:00+00:00" }, "dives": [ diff --git a/fixtures/write/uddf/technical-dive.divejson b/fixtures/write/uddf/technical-dive.divejson index 966544a..4a51ff6 100644 --- a/fixtures/write/uddf/technical-dive.divejson +++ b/fixtures/write/uddf/technical-dive.divejson @@ -21,6 +21,13 @@ "number": "DW-88213" } ], + "portrait_file": { + "uuid": "0198a6f0-1111-7002-8000-000000000002", + "original_filename": "IMG_4471.jpg", + "content_type": "image/jpeg", + "byte_size": 2861904, + "sha256": "5e0c7a1b9d24f3e6a8b0c2d4e6f80a1b3c5d7e9f0a2b4c6d8e0f1a3b5c7d9e1f" + }, "created_at": "2024-01-05T09:00:00+00:00" }, "dives": [ diff --git a/schema/1.0/divejson.schema.json b/schema/1.0/divejson.schema.json index 518fc60..d4be143 100644 --- a/schema/1.0/divejson.schema.json +++ b/schema/1.0/divejson.schema.json @@ -100,6 +100,7 @@ "born_on": { "$ref": "#/$defs/date" }, "emergency_contacts": { "type": "array", "items": { "$ref": "#/$defs/emergency_contact" } }, "insurances": { "type": "array", "items": { "$ref": "#/$defs/insurance" } }, + "portrait_file": { "$ref": "#/$defs/stored_file" }, "created_at": { "$ref": "#/$defs/date_time" }, "extensions": { "$ref": "#/$defs/extensions" } }, diff --git a/tests/test_uddf_write_fixtures.py b/tests/test_uddf_write_fixtures.py index 57f14d6..f8aed5a 100644 --- a/tests/test_uddf_write_fixtures.py +++ b/tests/test_uddf_write_fixtures.py @@ -82,6 +82,8 @@ # has no element for the identifier the insurer knows the diver by. "diver/emergency_contacts", "diver/insurances/0/number", + # `` has no image, and a notes-linked one has no role to read as a face. + "diver/portrait_file", "sites/0/created_at", "trips/0/created_at", "trips/0/parts/0/location/bbox", diff --git a/tests/test_uddf_writing.py b/tests/test_uddf_writing.py index e68860a..ff3c6b3 100644 --- a/tests/test_uddf_writing.py +++ b/tests/test_uddf_writing.py @@ -871,6 +871,38 @@ def test_a_diver_recording_only_what_uddf_cannot_hold_writes_no_diver(schema) -> ] +PORTRAIT = { + "uuid": "0198a6f0-9999-7010-8000-000000000010", + "original_filename": "portrait.jpg", + "content_type": "image/jpeg", + "byte_size": 1024, + "sha256": "0" * 64, +} + + +def test_a_portrait_beside_an_owner_is_reported_and_not_written(schema) -> None: + """UDDF's `` has no image, and linking one from its notes gives it no role.""" + source = document(diver={"name": "Sam Reef", "portrait_file": PORTRAIT}) + assert "portrait.jpg" not in written(source, schema) + assert messages(source, "diver") == ["UDDF has no slot for portrait_file; it is not written"] + assert "portrait_file" not in read_back(source)["diver"] + + +def test_a_portrait_alone_writes_no_diver_and_the_guards_note_covers_it(schema) -> None: + """The guard returns before `unmapped` runs, so its one note at `diver` is the report.""" + source = document( + diver={"uuid": DIVER_UUID, "portrait_file": PORTRAIT}, + dives=[{"uuid": DIVE_UUID, "started_at": STARTED_AT}], + ) + assert "" not in written(source, schema) + assert messages(source, "diver") == [ + ( + "the document records nothing about the logbook's owner that UDDF's has an element for; " + "no diver is written" + ) + ] + + # -- the file itself ------------------------------------------------------------------- diff --git a/tests/test_validate.py b/tests/test_validate.py index d9ec4ec..d77db6e 100644 --- a/tests/test_validate.py +++ b/tests/test_validate.py @@ -1,19 +1,25 @@ -"""The two §3 rules the schema cannot express about a recording's decompression data. +"""What the corpus cannot say about this validator: a rule's reach, and why a document fails. The corpus is where most of this validator is exercised: `fixtures/invalid/` holds a document per rule and `divejson conform fixtures --strict` runs every one of them, which `test_conform.py` does inside this suite. What that cannot reach is a rule's *reach* — §3's rule 3 is quantified over every series in a recording, so a channel missing from the validator's list is one whose defect no `invalid/` document would ever show, there being no -fixture for a defect nobody checks. +fixture for a defect nobody checks. Nor can it say *why* an invalid document fails, only +that it does. -So these two, and deliberately nothing else: the channels §6.4 added, and the gradient-factor -ordering §3 rule 7 states. Everything already covered by a pair stays covered by the pair. +So these, and deliberately nothing else: the channels §6.4 added, the gradient-factor +ordering §3 rule 7 states, and the one uuid claim whose fixture is refused for another +reason by any validator that does not know its member. Everything already covered by a pair +stays covered by the pair. """ from __future__ import annotations +import json + import pytest +from helpers import FIXTURES from divejson.validate import CHANNELS, validate_document @@ -93,3 +99,12 @@ def test_the_rule_reaches_every_recording_and_not_only_the_first() -> None: assert [str(issue) for issue in validate_document(doc)] == [ "dives/0/recordings/1/deco_model: gf_low exceeds gf_high (spec §3, §6.4c)" ] + + +def test_a_portrait_shares_the_documents_one_identifier_space() -> None: + """§5.3: a Stored File's uuid is claimed like any record's, and the portrait's is claimed + beside the diver's. The fixture is schema-valid, so this is the only thing refusing it.""" + doc = json.loads((FIXTURES / "invalid" / "duplicate-file-uuid-portrait.divejson").read_text(encoding="utf-8")) + assert [str(issue) for issue in validate_document(doc)] == [ + "certifications/0/front_file: uuid 0198a6f0-1111-7081-8000-000000000081 already used at diver/portrait_file" + ]