From 61ee92fd9af047fe1ad905fee576bdb9eeb6f1aa Mon Sep 17 00:00:00 2001 From: Aleksei Vesnin Date: Wed, 23 Sep 2026 18:00:41 +0300 Subject: [PATCH 1/4] chore: vendor the specification at e3b8b03 Co-Authored-By: Claude Opus 5.5 --- SPEC_REF | 2 +- docs/converting.md | 2 +- docs/fit-mapping.md | 4 +- docs/ssrf-mapping.md | 2 +- docs/suunto-json-mapping.md | 2 +- docs/suunto-xml-mapping.md | 2 +- docs/uddf-mapping.md | 31 +- docs/uddf-writing.md | 58 +- docs/writing.md | 2 +- fixtures/README.md | 35 +- .../invalid/diver-email-too-long.divejson | 690 ++++++++++++++++++ fixtures/invalid/diver-name-too-long.divejson | 689 +++++++++++++++++ .../invalid/diver-username-too-long.divejson | 690 ++++++++++++++++++ .../emergency-contact-without-name.divejson | 688 +++++++++++++++++ .../insurance-without-provider.divejson | 688 +++++++++++++++++ fixtures/uddf/opendiving.divejson | 10 +- fixtures/uddf/opendiving.uddf | 14 + fixtures/uddf/owner-profile-only.divejson | 22 + fixtures/uddf/owner-profile-only.uddf | 17 + fixtures/valid/demo-logbook.divejson | 16 + fixtures/valid/technical-dive.divejson | 13 + fixtures/write/uddf/opendiving.divejson | 10 +- fixtures/write/uddf/opendiving.uddf | 14 + .../write/uddf/owner-profile-only.divejson | 22 + fixtures/write/uddf/owner-profile-only.uddf | 20 + fixtures/write/uddf/technical-dive.divejson | 13 + fixtures/write/uddf/technical-dive.uddf | 5 + schema/1.0/divejson.schema.json | 32 +- 28 files changed, 3752 insertions(+), 41 deletions(-) create mode 100644 fixtures/invalid/diver-email-too-long.divejson create mode 100644 fixtures/invalid/diver-name-too-long.divejson create mode 100644 fixtures/invalid/diver-username-too-long.divejson create mode 100644 fixtures/invalid/emergency-contact-without-name.divejson create mode 100644 fixtures/invalid/insurance-without-provider.divejson create mode 100644 fixtures/uddf/owner-profile-only.divejson create mode 100644 fixtures/uddf/owner-profile-only.uddf create mode 100644 fixtures/write/uddf/owner-profile-only.divejson create mode 100644 fixtures/write/uddf/owner-profile-only.uddf diff --git a/SPEC_REF b/SPEC_REF index a040bb6..a34560c 100644 --- a/SPEC_REF +++ b/SPEC_REF @@ -1 +1 @@ -977288489f1312ff94451ec7379fcef1f27f6d75 +e3b8b0343aaf9ec7d143b276b00af53d867a2a42 diff --git a/docs/converting.md b/docs/converting.md index e2feb08..92f994a 100644 --- a/docs/converting.md +++ b/docs/converting.md @@ -1,6 +1,6 @@ # Converting into DiveJSON -**Non-normative.** The specification is [`spec/divejson.md`](../spec/divejson.md); nothing +**Non-normative.** The specification is [`spec/divejson.md`](https://github.com/divejson/divejson/blob/main/spec/divejson.md); nothing here changes what a conforming document is. This document records the rules a converter follows whatever it is reading, so that they survive being reimplemented: the portable part of a converter is its rules, not its code, and a port in another language starts here. diff --git a/docs/fit-mapping.md b/docs/fit-mapping.md index 1b7500e..c116138 100644 --- a/docs/fit-mapping.md +++ b/docs/fit-mapping.md @@ -1,6 +1,6 @@ # Reading ANT/Garmin FIT into DiveJSON -**Non-normative.** The specification is [`spec/divejson.md`](../spec/divejson.md); nothing +**Non-normative.** The specification is [`spec/divejson.md`](https://github.com/divejson/divejson/blob/main/spec/divejson.md); nothing here changes what a conforming document is. **Several of the messages below have never been read from a device that wrote them.** @@ -170,7 +170,7 @@ dive, and a watch that writes one file per dive gives an archive its order. Every number below was read off real files and off the MIT-licensed global FIT profile that open decoders carry. **Nothing here comes from Garmin's `Profile.xlsx`** — see the notice in -[`README.md`](../README.md#notices). +[divejson/divejson's `README.md`](https://github.com/divejson/divejson/blob/main/README.md#notices). ### Provenance — `file_id` (0), `device_info` (23), and the file header diff --git a/docs/ssrf-mapping.md b/docs/ssrf-mapping.md index ca4ff00..05eb4ee 100644 --- a/docs/ssrf-mapping.md +++ b/docs/ssrf-mapping.md @@ -1,6 +1,6 @@ # Reading Subsurface `.ssrf` into DiveJSON -**Non-normative.** The specification is [`spec/divejson.md`](../spec/divejson.md); nothing +**Non-normative.** The specification is [`spec/divejson.md`](https://github.com/divejson/divejson/blob/main/spec/divejson.md); nothing here changes what a conforming document is. **The rules that hold for every source format are in diff --git a/docs/suunto-json-mapping.md b/docs/suunto-json-mapping.md index 8321b4a..9bc0a69 100644 --- a/docs/suunto-json-mapping.md +++ b/docs/suunto-json-mapping.md @@ -1,6 +1,6 @@ # Reading the Suunto app's JSON into DiveJSON -**Non-normative.** The specification is [`spec/divejson.md`](../spec/divejson.md); nothing +**Non-normative.** The specification is [`spec/divejson.md`](https://github.com/divejson/divejson/blob/main/spec/divejson.md); nothing here changes what a conforming document is. **The rules that hold for every source format are in diff --git a/docs/suunto-xml-mapping.md b/docs/suunto-xml-mapping.md index 509513f..b9b4760 100644 --- a/docs/suunto-xml-mapping.md +++ b/docs/suunto-xml-mapping.md @@ -1,6 +1,6 @@ # Reading Suunto's DM5 XML into DiveJSON -**Non-normative.** The specification is [`spec/divejson.md`](../spec/divejson.md); nothing +**Non-normative.** The specification is [`spec/divejson.md`](https://github.com/divejson/divejson/blob/main/spec/divejson.md); nothing here changes what a conforming document is. The rules every converter follows whatever it is reading are in [`converting.md`](converting.md) and are **not repeated here** — the note kinds, identity, the way a zero reads, the representability bound, sample ordering, the diff --git a/docs/uddf-mapping.md b/docs/uddf-mapping.md index d09439c..56b8831 100644 --- a/docs/uddf-mapping.md +++ b/docs/uddf-mapping.md @@ -1,6 +1,6 @@ # Reading UDDF into DiveJSON -**Non-normative.** The specification is [`spec/divejson.md`](../spec/divejson.md); nothing +**Non-normative.** The specification is [`spec/divejson.md`](https://github.com/divejson/divejson/blob/main/spec/divejson.md); nothing here changes what a conforming document is. **The rules that hold for every source format are in @@ -131,14 +131,38 @@ mapped* below. | --- | --- | | `personal/firstname` + `middlename` + `lastname` | `diver.name`, joined with spaces | | `contact/email` | `diver.email` | +| `contact/phone`, else `contact/mobilephone` | `diver.phone` — the first ``, or the first `` where there is none | +| `personal/birthdate/datetime` | `diver.born_on` | +| `diveinsurances/insurance` | `diver.insurances[]`, in file order | +| `diveinsurances/insurance/name` | `diver.insurances[].provider` — REQUIRED, so an insurance whose `` holds no text is dropped | +| `diveinsurances/insurance/validdate/datetime` | `diver.insurances[].expires_on` | | `@id` | `diver.uuid` | +**An owner is a diver when it records anything this table maps**, `@id` aside. An +`` whose names are empty and which carries a `` reads to a diver with a +`born_on` and no `name`; one carrying nothing the table maps reads to no diver at all +(§6.1). Nothing fills `diver.emergency_contacts` or an insurance's `number`: UDDF has no +element for either. + **`@id` is never read as a name or a handle.** It is an XML id, and Subsurface's is the literal string `owner`. `` is UDDF's instance of `converting.md`'s constrained-member guard: one holding `n/a`, a dash or a person's name is read as no email recorded, and reported. +**`` and `` hold a date in a date-time slot** — each is +`encapsulatedDateTimeType`, whose one child is an `xs:dateTime` — and §6.1's members are +dates, so the date is taken off the front of the `` and the time of day, which a +writer supplies only because the slot demands one, is discarded without a finding. A bare +date, the spelling UDDF's own documentation uses in its examples, reads the same way. Text +that is not a date is dropped and reported. + +**What is read stays within §6.1's bounds**, since a converter's output that fails +validation fails the whole file. A provider longer than 255 characters is cut to 255 and +reported, the way a diver's name is; a phone longer than 32, or an email longer than 255, +is dropped and reported instead, a number or an address with its end cut off being a wrong +one rather than a short one. + ### Dive sites — `/uddf/divesite/site` | UDDF | DiveJSON | @@ -634,9 +658,12 @@ writes all three that way. | ``, ``, `` | no core member. | | `` | site-level flora and fauna, where §6.11's species are per-dive sightings. | | `` | the reverse of ``; no writer in the corpus emits it. | -| `` | `boat`, `hotel`, `individual` or `organized` — the liveaboard-then-hotel distinction §6.9a's part exists to record, and the one member a part might plausibly gain next. There is nowhere to read it into: a core field arrives when an implementation stores it (`CONTRIBUTING.md`), and none does. A reader that wants it has `extensions`. | +| `` | `boat`, `hotel`, `individual` or `organized` — the liveaboard-then-hotel distinction §6.9a's part exists to record, and the one member a part might plausibly gain next. There is nowhere to read it into: a core field arrives when an implementation stores it ([divejson/divejson's CONTRIBUTING.md](https://github.com/divejson/divejson/blob/main/CONTRIBUTING.md#proposing-additions-to-the-data-model)), and none does. A reader that wants it has `extensions`. | | ``, ``, `

` | §6.3 models the remainder as nitrogen and does not model argon or trace gases. | | `courses`, `certifications`, `gear_sets`, `gear service` | UDDF has no slot for any of them. | +| ``, ``, `` | §6.1's Insurance holds the insurer, the diver's identifier with it and the last day of cover, and none of these is any of the three. Each is reported. | +| `` | an organisation and a member id, which may be a club, a federation or an insurer, and nothing in the element says which: UDDF's own sample document gives it a diving federation. Read as an insurance, a club membership would come back as a policy nobody holds. | +| every `` and `` but the one read | §6.1 carries one phone, the way it carries one email. Each other is reported. | ## Known writer artefacts diff --git a/docs/uddf-writing.md b/docs/uddf-writing.md index c2c12ad..12a7ca9 100644 --- a/docs/uddf-writing.md +++ b/docs/uddf-writing.md @@ -1,6 +1,6 @@ # Writing DiveJSON as UDDF -**Non-normative.** The specification is [`spec/divejson.md`](../spec/divejson.md); nothing +**Non-normative.** The specification is [`spec/divejson.md`](https://github.com/divejson/divejson/blob/main/spec/divejson.md); nothing here changes what a conforming document is. [`uddf-mapping.md`](uddf-mapping.md) is the other direction — every element a reader takes into DiveJSON — and this document is what a writer does with a DiveJSON document that has to become a UDDF file: which member lands in @@ -117,21 +117,52 @@ this way returns as the UUID it was and a logbook that went out through UDDF com the identities it left with. The prefixes are `dive`, `site`, `trip`, `gear`, `diver` and `mfr`. -`` is `diver-` where the document names a person and the bare string `owner` -otherwise — the latter being what every UDDF writer in the corpus emits and what a reader is -careful never to read as an identity. +`` is `diver-` where the document records anything about the person this +direction maps, and the bare string `owner` otherwise — the latter being what every UDDF +writer in the corpus emits and what a reader is careful never to read as an identity. ### Diver and gear A document's gear lives inside ``, so `` is written whenever -there is either an owner to describe **or** a piece of kit to hang on one. An owner the -document says nothing about gets empty ``/`` elements, which are valid -`xs:string`s that a reader takes as no diver at all. +there is either an owner to describe — any member the table below maps, `uuid` aside — +**or** a piece of kit to hang on one. A diver recording only what UDDF has no element for, +such as `emergency_contacts`, therefore gets no ``, and the report says so. + +| DiveJSON | UDDF | +| --- | --- | +| `name` | `personal/firstname` + `lastname`, split as below | +| `email` | `contact/email` — see the note below | +| `phone` | `contact/phone` | +| `born_on` | `personal/birthdate/datetime` | +| `insurances[]` | `diveinsurances/insurance`, one per element, in document order | +| `insurances[].provider` | `diveinsurances/insurance/name` | +| `insurances[].expires_on` | `diveinsurances/insurance/validdate/datetime` | `personalType` requires a first and last name where DiveJSON holds one string: the first whitespace-separated token becomes the given name and the remainder the family name, and a one-token name leaves `` **empty** rather than repeating the given name. A reader -joins them back with a space, so any split survives. +joins them back with a space, so any split survives. A diver with no `name` gets both +elements empty, `personalType` requiring them whatever else the owner carries; they are +valid `xs:string`s, which a reader takes as a nameless diver beside anything else the table +maps and as no diver at all beside nothing but a kit list. + +**The owner's children run in the XSD's order**, which a reader taking children by name +never checks. ``'s type extends the one UDDF gives every person, so they run +`personal`, `address`, `contact`, then `equipment`, `medical`, `education`, +`divepermissions`, `diveinsurances`, `notes`: a `` sits between `` and +``, and `` after the kit list. Inside ``, `` +comes before ``, `contactType` being a sequence. `personalType` is an `xs:all`, so +`` may sit anywhere among the names. + +**A date goes out widened to midnight, `YYYY-MM-DDT00:00:00`.** `` and +`` are `encapsulatedDateTimeType`, whose one child `` is an +`xs:dateTime`, and a bare date fails the XSD — though UDDF's own documentation writes one in +its examples. It is ``'s answer (*Sites and trips* below) and it invents nothing: +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. **Differs from the reference writer**: `diver.email` is written to ``. The reference writer deliberately omits it — a UDDF file is the thing a @@ -332,9 +363,9 @@ Two shapes, each reported against the dive: about the document rather than a presentation detail: a recording has no uuid (§5.3), so its position is the only thing that says it is the primary. -**Neither has a pair**, both writer pairs putting the primary's element first, so both are -written down here rather than left to the first writer to meet one — the same answer the link -leg's refusal gets in [`fixtures/README.md`](../fixtures/README.md#writeuddf). +**Neither has a pair**, every writer pair with a dive putting the primary's element first, so +both are written down here rather than left to the first writer to meet one — the same answer +the link leg's refusal gets in [`fixtures/README.md`](../fixtures/README.md#writeuddf). **What is *gained* is not reported, which is the same rule read the other way.** A linked computer no recording answers to, anywhere but that first link, comes back as a recording the @@ -523,7 +554,8 @@ stamps `divejson convert`, and the table's one row is branch of that rule, the fraction one, and a written `0.67` comes back as `67`. Writing whole percent instead would come back as `6700`: the round trip a writing document exists to prevent, and one no conformance pair would catch, since the corpus never reads a written -file back (`CONTRIBUTING.md`, *the checks the corpus cannot make*). +file back ([divejson/divejson's CONTRIBUTING.md](https://github.com/divejson/divejson/blob/main/CONTRIBUTING.md#adding-an-adapter), +*the checks the corpus cannot make*). *Rejected:* adding this writer to the generator table so it could write whole percent. The table exists to record what a **third party's** files need read differently; a writer that @@ -611,6 +643,8 @@ once per record that carries it, and none of them has anywhere in UDDF to go: | a record's `created_at` | no slot on any of them | | `gear` `rented`, `archived`, `archived_at`, `dive_count` | no slot | | `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 | | 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/docs/writing.md b/docs/writing.md index 4b65047..60597d6 100644 --- a/docs/writing.md +++ b/docs/writing.md @@ -1,6 +1,6 @@ # Writing DiveJSON out again -**Non-normative.** The specification is [`spec/divejson.md`](../spec/divejson.md); nothing +**Non-normative.** The specification is [`spec/divejson.md`](https://github.com/divejson/divejson/blob/main/spec/divejson.md); nothing here changes what a conforming document is. This document records the rules a converter follows whatever format it is **writing**, so that they survive being reimplemented: the mirror of [`converting.md`](converting.md), which records the rules it follows whatever it diff --git a/fixtures/README.md b/fixtures/README.md index a6160ad..0378d75 100644 --- a/fixtures/README.md +++ b/fixtures/README.md @@ -3,7 +3,7 @@ `divejson validate` must pass every document in `valid/` and reject every document in `invalid/`. CI enforces exactly that, so a change to the spec or schema that alters what conforms shows up here as a failing fixture — update the three together -([CONTRIBUTING.md](../CONTRIBUTING.md)). +([divejson/divejson's CONTRIBUTING.md](https://github.com/divejson/divejson/blob/main/CONTRIBUTING.md)). Beside those two sit the **pair** corpora, which are for *converters* rather than validators: each input is paired with the document a correct reader produces from it. @@ -12,8 +12,8 @@ pairs under `write/` run the other way — a document, and the file a correct wr from it — and `write/uddf/` is the first of those. An implementation walks all of it with one command — `divejson conform fixtures/ --strict` -([CONTRIBUTING.md](../CONTRIBUTING.md)) — and the directory layout is that command's -contract: +([divejson/divejson's CONTRIBUTING.md](https://github.com/divejson/divejson/blob/main/CONTRIBUTING.md)) — +and the directory layout is that command's contract: | path | what | | --- | --- | @@ -31,9 +31,9 @@ what makes a fixture dropped into this tree impossible to ignore. | file | what it covers | | --- | --- | | `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. Regenerate from a fresh export when the writer changes. | +| `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. 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 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. | ## invalid/ @@ -43,8 +43,8 @@ what catch a schema loosened until it stops rejecting something it should, and t why the schema pass and the format's structural guarantees (Position objects, the null ban) are exercised by this directory rather than only by `valid/`. Which kind any one file is, the schema pass answers — the table below gives the rule, not the pass, and -[CONTRIBUTING.md](../CONTRIBUTING.md) carries the command that sorts the directory in one -run. +[divejson/divejson's CONTRIBUTING.md](https://github.com/divejson/divejson/blob/main/CONTRIBUTING.md) +carries the command that sorts the directory in one run. **Three of §3's rules now read *per recording*, and each keeps its defect off the first one** — `recording-without-content` puts the empty recording second, `non-increasing-samples` @@ -72,6 +72,11 @@ validator does not look. | `naive-exported-at.divejson` | `exported_at` without a UTC offset | §5.2 | | `trailing-newline-datetime.divejson` | a date-time with a trailing newline inside the string | §5.2 | | `position-incomplete.divejson` | a Position missing `longitude` | §6 | +| `diver-name-too-long.divejson` | a diver `name` of 256 characters | §6.1 | +| `diver-username-too-long.divejson` | a diver `username` of 65 characters | §6.1 | +| `diver-email-too-long.divejson` | a diver `email` of 256 characters, an address in every other respect | §6.1 | +| `emergency-contact-without-name.divejson` | an emergency contact with a phone and a relationship and no `name` | §6.1 | +| `insurance-without-provider.divejson` | an insurance with a `number` and no `provider` | §6.1 | | `oxygen-helium-sum.divejson` | `oxygen + helium > 100` on a cylinder | §6.3 | | `pressure-order.divejson` | `end_pressure > start_pressure` on a cylinder | §6.3 | | `avg-depth-exceeds-max.divejson` | `avg_depth > max_depth` on a dive | §6.2 | @@ -126,8 +131,8 @@ where it differs. `.divejson` is the input and `.` is what a co produces from it, so what is compared is two files of *that* format rather than two DiveJSON documents, and each written format says how ([`docs/writing.md`](../docs/writing.md)). And its input is a document rather than a reduction of somebody's export, so neither the -hand-built rule nor the binary exception reaches it: both of `write/uddf/`'s inputs are -documents this tree already carries, which is what makes the answers checkable by eye. +hand-built rule nor the binary exception reaches it: every one of `write/uddf/`'s inputs is +a document this tree already carries, which is what makes the answers checkable by eye. `write/uddf/opendiving.divejson` is the one place that stops being literally true, and only on purpose. It is `uddf/opendiving.divejson` with a richer **device** on its recording and a @@ -161,7 +166,8 @@ format's document under [`docs/`](../docs). | `shearwater-cloud.uddf` | Shearwater Cloud Desktop 2.12.10 | The generator table's only pair, and the export the rule in [`docs/uddf-mapping.md`](../docs/uddf-mapping.md) was written against: a `` of `2026-09-08T15:18:10Z` read as a wall clock with **no offset**, reported `resolved`, because `` is the exact string `Shearwater Cloud Desktop` with the manufacturer id `Shearwater_Research_Inc` beside it — while the `Z` on `` is left alone. Its `/uddf/@version` is `3.2.3`. Its `` is the full hardware block `opendiving.uddf`'s is not — a ``, a `` and a `` holding the firmware and log versions UDDF has no element for, which reach the gear item's notes and never the device's `firmware` — under an `id` of `Perdix 3_D9772626`, which is neither a `-` nor an `NCName`, so both records take a derived uuid rather than one read back off it. Also four ``s under `` where a site is expected, none of which is one: the file's own calculated profile and a string nothing defines are dropped and reported, an empty `ref` passes without a finding, and the fourth — `ref="zhl16c"` — **resolves**, to the `` that gives this recording its `deco_model` of `buhlmann` with a 50/85 gradient-factor pair. It was the "not a site this converter carries" note until §6.4c existed. It is the generator table's other rule too: those gradient factors are whole percent because Shearwater Cloud Desktop wrote them, where the documentation says fraction. The recording's `mode` is `open_circuit` off the first waypoint's ``, which the last four waypoints stop stating without that being a change, and the profile carries an `ndl` sitting on `5940` — the Perdix's 99-minute display maximum, a reading and not an absence — beside a `ppo2` read as bar rather than the documented Pascal and a three-sample `gradient_factor`. Its two `` blocks state both pressures as `0`, so each cylinder loses the start to §6.3's absent-marker and keeps the zero end pressure, and its `` at second 0 names a mix no `` links, kept as a gas switch that cannot say which cylinder it was to. | | `shearwater-cloud-cns.uddf` | Shearwater Cloud Desktop 2.12.10 | The **second** dive of the same day off the same Perdix 3, reduced the same way — the only file in hand with a per-sample ``, and the one that makes the `cns` channel a checked mapping rather than a written one. Sixteen of its 295 waypoints, chosen for what they carry: the CNS series climbing 1 % to 8 % (`10` to `80` in the channel's tenths), the `` counting down from the display cap to 3 720 s and back, a `` spread of `0`, `1` and `4` through `17` where the first pair has only `0` and `1`, and a `` from `0.34` to `0.96`. Six `` blocks with both pressures zero, where the other pair has two, so six cylinders arrive carrying an end pressure each and no start. Its dive links the same `` and carries the same 50/85 pair, which is the point: the model is the diver's setting and does not move between dives, while everything sampled does. | | `mix-only-cylinder.uddf` | hand-built | The shape §6.3 blesses and no real file on disk carried: a `` with a gas link, both pressures and a real drop between them, and **no ``** — so a cylinder's size is the only input a gas-consumption figure lacks. Its second dive has no `` at all, which is what APD DiveSight exports. | -| `opendiving.uddf` | this format's reference writer | The richest mapping, and the round trip that matters most: `dive-` style ids coming back as those uuids, trips with `` dates and places, a kit list under `` with per-dive `` links, two cylinders on two gases, `` channels, a `` gas switch and a ``. It is also a `` read two ways at its thinnest: its `Ocean` becomes both the gear item's `name` and the device's, which is the shape the format produces far more often than the full hardware block `shearwater-cloud.uddf` carries — this element states no ``, `` or `` at all. | +| `owner-profile-only.uddf` | hand-built | An `` that records a person and names nobody: empty `` and ``, which `personalType` requires, and a ``. It reads to a diver carrying its uuid, off the `diver-` id, and `born_on`, and nothing else: an owner is a diver whenever it records anything the reader maps, a name or not. | +| `opendiving.uddf` | this format's reference writer | The richest mapping, and the round trip that matters most: `dive-` style ids coming back as those uuids, trips with `` dates and places, a kit list under `` with per-dive `` links, two cylinders on two gases, `` channels, a `` gas switch and a ``. It is also a `` read two ways at its thinnest: its `Ocean` becomes both the gear item's `name` and the device's, which is the shape the format produces far more often than the full hardware block `shearwater-cloud.uddf` carries — this element states no ``, `` or `` at all. Its owner carries a ``, a `` and one `` with a ``, added by hand in the shape [`docs/uddf-writing.md`](../docs/uddf-writing.md) gives them: after the kit list for the insurance, before it for the contact, and both dates as a `` at midnight. | | `legacy-writer.uddf` | 2.x writers | Four habits in one dive: an uppercase `` root with uppercase element *and attribute* names, `34` as whole percent, `12` as litres where UDDF specifies cubic metres, and the `2002-06-18T` a midnight dive gets from an unguarded string concatenation. Its waypoints are out of order, two of them round to the same second, and one has no `` at all. | ## ssrf/ @@ -231,10 +237,11 @@ Compared as canonical XML with `` ignored, which is what makes them s releases: everything else in a written file is a function of the document, `` included — it is the document's own `exported_at` and never the clock. -Both inputs are documents this tree already carries, and the pair is the two of them -together — the point of each row below is which half of `uddf-writing.md` it reaches. +Every input is a document this tree already carries, and the pairs are read together — the +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. | -| `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; and a device's `firmware`, for which `equipmentPieceType` has no element. 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. | +| `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. | +| `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/diver-email-too-long.divejson b/fixtures/invalid/diver-email-too-long.divejson new file mode 100644 index 0000000..7e541ff --- /dev/null +++ b/fixtures/invalid/diver-email-too-long.divejson @@ -0,0 +1,690 @@ +{ + "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", + "email": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa@bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc.ddddddddddddddddddddddddddddddddddddddddddddddddddddddd.example", + "emergency_contacts": [ + { + "name": "Robin Trim", + "phone": "+64 3 555 0142", + "relationship": "brother" + } + ], + "insurances": [ + { + "provider": "DAN World", + "number": "DW-88213" + } + ], + "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/invalid/diver-name-too-long.divejson b/fixtures/invalid/diver-name-too-long.divejson new file mode 100644 index 0000000..e548105 --- /dev/null +++ b/fixtures/invalid/diver-name-too-long.divejson @@ -0,0 +1,689 @@ +{ + "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 TTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTT", + "emergency_contacts": [ + { + "name": "Robin Trim", + "phone": "+64 3 555 0142", + "relationship": "brother" + } + ], + "insurances": [ + { + "provider": "DAN World", + "number": "DW-88213" + } + ], + "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/invalid/diver-username-too-long.divejson b/fixtures/invalid/diver-username-too-long.divejson new file mode 100644 index 0000000..f6e84e3 --- /dev/null +++ b/fixtures/invalid/diver-username-too-long.divejson @@ -0,0 +1,690 @@ +{ + "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", + "username": "alextrimxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "emergency_contacts": [ + { + "name": "Robin Trim", + "phone": "+64 3 555 0142", + "relationship": "brother" + } + ], + "insurances": [ + { + "provider": "DAN World", + "number": "DW-88213" + } + ], + "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/invalid/emergency-contact-without-name.divejson b/fixtures/invalid/emergency-contact-without-name.divejson new file mode 100644 index 0000000..4ce2633 --- /dev/null +++ b/fixtures/invalid/emergency-contact-without-name.divejson @@ -0,0 +1,688 @@ +{ + "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": [ + { + "phone": "+64 3 555 0142", + "relationship": "brother" + } + ], + "insurances": [ + { + "provider": "DAN World", + "number": "DW-88213" + } + ], + "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/invalid/insurance-without-provider.divejson b/fixtures/invalid/insurance-without-provider.divejson new file mode 100644 index 0000000..0db6bf2 --- /dev/null +++ b/fixtures/invalid/insurance-without-provider.divejson @@ -0,0 +1,688 @@ +{ + "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": [ + { + "number": "DW-88213" + } + ], + "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/uddf/opendiving.divejson b/fixtures/uddf/opendiving.divejson index 203dc84..e5f9897 100644 --- a/fixtures/uddf/opendiving.divejson +++ b/fixtures/uddf/opendiving.divejson @@ -8,7 +8,15 @@ }, "diver": { "uuid": "d8b9bbd3-c66e-52c1-b8e8-d908f921af7e", - "name": "Sam Reef" + "name": "Sam Reef", + "phone": "+44 7700 900123", + "born_on": "1988-03-14", + "insurances": [ + { + "provider": "DAN Europe", + "expires_on": "2027-03-31" + } + ] }, "dives": [ { diff --git a/fixtures/uddf/opendiving.uddf b/fixtures/uddf/opendiving.uddf index 90bbd80..5fe96b2 100644 --- a/fixtures/uddf/opendiving.uddf +++ b/fixtures/uddf/opendiving.uddf @@ -11,7 +11,13 @@ Sam Reef + + 1988-03-14T00:00:00 + + + +44 7700 900123 + Hydros Pro @@ -33,6 +39,14 @@ dry-suit + + + DAN Europe + + 2027-03-31T00:00:00 + + + diff --git a/fixtures/uddf/owner-profile-only.divejson b/fixtures/uddf/owner-profile-only.divejson new file mode 100644 index 0000000..119fc72 --- /dev/null +++ b/fixtures/uddf/owner-profile-only.divejson @@ -0,0 +1,22 @@ +{ + "format": "divejson", + "version": "1.0", + "exported_at": "2026-09-23T00:00:00+00:00", + "generator": { + "name": "divejson convert", + "version": "0.11.0" + }, + "diver": { + "uuid": "0198a6f0-6666-7001-8000-000000000001", + "born_on": "1979-11-02" + }, + "extensions": { + "divejson": { + "converted_from": "uddf", + "uddf_version": "3.2.2", + "source_generator": { + "name": "DiveJSON fixtures" + } + } + } +} diff --git a/fixtures/uddf/owner-profile-only.uddf b/fixtures/uddf/owner-profile-only.uddf new file mode 100644 index 0000000..5070606 --- /dev/null +++ b/fixtures/uddf/owner-profile-only.uddf @@ -0,0 +1,17 @@ + + + + DiveJSON fixtures + + + + + + + + 1979-11-02T00:00:00 + + + + + diff --git a/fixtures/valid/demo-logbook.divejson b/fixtures/valid/demo-logbook.divejson index 0d9f0d1..e091594 100644 --- a/fixtures/valid/demo-logbook.divejson +++ b/fixtures/valid/demo-logbook.divejson @@ -11,6 +11,22 @@ "name": "Sam Reef", "username": "samreef", "email": "demo@opendiving.example", + "phone": "+44 7700 900123", + "born_on": "1988-03-14", + "emergency_contacts": [ + { + "name": "Jo Reef", + "phone": "+44 7700 900456", + "relationship": "partner" + } + ], + "insurances": [ + { + "provider": "DAN Europe", + "number": "0042317", + "expires_on": "2027-03-31" + } + ], "created_at": "2026-08-10T15:05:45.913772Z", "extensions": { "opendiving": { diff --git a/fixtures/valid/technical-dive.divejson b/fixtures/valid/technical-dive.divejson index 59a6719..966544a 100644 --- a/fixtures/valid/technical-dive.divejson +++ b/fixtures/valid/technical-dive.divejson @@ -8,6 +8,19 @@ "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" + } + ], "created_at": "2024-01-05T09:00:00+00:00" }, "dives": [ diff --git a/fixtures/write/uddf/opendiving.divejson b/fixtures/write/uddf/opendiving.divejson index 7659565..0af595e 100644 --- a/fixtures/write/uddf/opendiving.divejson +++ b/fixtures/write/uddf/opendiving.divejson @@ -8,7 +8,15 @@ }, "diver": { "uuid": "d8b9bbd3-c66e-52c1-b8e8-d908f921af7e", - "name": "Sam Reef" + "name": "Sam Reef", + "phone": "+44 7700 900123", + "born_on": "1988-03-14", + "insurances": [ + { + "provider": "DAN Europe", + "expires_on": "2027-03-31" + } + ] }, "dives": [ { diff --git a/fixtures/write/uddf/opendiving.uddf b/fixtures/write/uddf/opendiving.uddf index 3945597..902d618 100644 --- a/fixtures/write/uddf/opendiving.uddf +++ b/fixtures/write/uddf/opendiving.uddf @@ -11,7 +11,13 @@ Sam Reef + + 1988-03-14T00:00:00 + + + +44 7700 900123 + Hydros Pro @@ -35,6 +41,14 @@ dry-suit + + + DAN Europe + + 2027-03-31T00:00:00 + + + diff --git a/fixtures/write/uddf/owner-profile-only.divejson b/fixtures/write/uddf/owner-profile-only.divejson new file mode 100644 index 0000000..119fc72 --- /dev/null +++ b/fixtures/write/uddf/owner-profile-only.divejson @@ -0,0 +1,22 @@ +{ + "format": "divejson", + "version": "1.0", + "exported_at": "2026-09-23T00:00:00+00:00", + "generator": { + "name": "divejson convert", + "version": "0.11.0" + }, + "diver": { + "uuid": "0198a6f0-6666-7001-8000-000000000001", + "born_on": "1979-11-02" + }, + "extensions": { + "divejson": { + "converted_from": "uddf", + "uddf_version": "3.2.2", + "source_generator": { + "name": "DiveJSON fixtures" + } + } + } +} diff --git a/fixtures/write/uddf/owner-profile-only.uddf b/fixtures/write/uddf/owner-profile-only.uddf new file mode 100644 index 0000000..4f6ece8 --- /dev/null +++ b/fixtures/write/uddf/owner-profile-only.uddf @@ -0,0 +1,20 @@ + + + + divejson convert + converter + 0.11.0 + 2026-09-23T00:00:00+00:00 + + + + + + + + 1979-11-02T00:00:00 + + + + + diff --git a/fixtures/write/uddf/technical-dive.divejson b/fixtures/write/uddf/technical-dive.divejson index 59a6719..966544a 100644 --- a/fixtures/write/uddf/technical-dive.divejson +++ b/fixtures/write/uddf/technical-dive.divejson @@ -8,6 +8,19 @@ "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" + } + ], "created_at": "2024-01-05T09:00:00+00:00" }, "dives": [ diff --git a/fixtures/write/uddf/technical-dive.uddf b/fixtures/write/uddf/technical-dive.uddf index efd4982..29f6f4a 100644 --- a/fixtures/write/uddf/technical-dive.uddf +++ b/fixtures/write/uddf/technical-dive.uddf @@ -35,6 +35,11 @@ EMT shears + + + DAN World + + diff --git a/schema/1.0/divejson.schema.json b/schema/1.0/divejson.schema.json index 1c1777a..518fc60 100644 --- a/schema/1.0/divejson.schema.json +++ b/schema/1.0/divejson.schema.json @@ -93,14 +93,40 @@ "type": "object", "properties": { "uuid": { "$ref": "#/$defs/uuid" }, - "name": { "type": "string" }, - "username": { "type": "string" }, - "email": { "type": "string", "format": "email" }, + "name": { "type": "string", "maxLength": 255 }, + "username": { "type": "string", "maxLength": 64 }, + "email": { "type": "string", "format": "email", "maxLength": 255 }, + "phone": { "type": "string", "maxLength": 32 }, + "born_on": { "$ref": "#/$defs/date" }, + "emergency_contacts": { "type": "array", "items": { "$ref": "#/$defs/emergency_contact" } }, + "insurances": { "type": "array", "items": { "$ref": "#/$defs/insurance" } }, "created_at": { "$ref": "#/$defs/date_time" }, "extensions": { "$ref": "#/$defs/extensions" } }, "additionalProperties": false }, + "emergency_contact": { + "type": "object", + "properties": { + "name": { "type": "string", "minLength": 1, "maxLength": 255 }, + "phone": { "type": "string", "maxLength": 32 }, + "relationship": { "type": "string", "maxLength": 64 }, + "extensions": { "$ref": "#/$defs/extensions" } + }, + "required": ["name"], + "additionalProperties": false + }, + "insurance": { + "type": "object", + "properties": { + "provider": { "type": "string", "minLength": 1, "maxLength": 255 }, + "number": { "type": "string", "maxLength": 64 }, + "expires_on": { "$ref": "#/$defs/date" }, + "extensions": { "$ref": "#/$defs/extensions" } + }, + "required": ["provider"], + "additionalProperties": false + }, "dive": { "type": "object", "properties": { From 068fcb5e573994aa103b42711c1169d1e772059f Mon Sep 17 00:00:00 2001 From: Aleksei Vesnin Date: Wed, 23 Sep 2026 18:00:42 +0300 Subject: [PATCH 2/4] feat!: a diver's date of birth, phone, emergency contacts and insurances, read from and written to UDDF Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 13 +++ divejson/registry.py | 6 +- divejson/uddf.py | 149 ++++++++++++++++++++++++---- divejson/uddf_write.py | 56 ++++++++--- tests/test_uddf_parsing.py | 160 +++++++++++++++++++++++++++++- tests/test_uddf_write_fixtures.py | 7 ++ tests/test_uddf_writing.py | 81 +++++++++++++++ 7 files changed, 428 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b3cd4f0..35f76fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ this file is about the package, whose version moves independently. ## Unreleased +- **Breaking: a diver carries a date of birth, a phone, emergency contacts and insurances, + and the Diver's strings are bounded.** §6.1 of + [the specification](https://github.com/divejson/divejson/blob/main/spec/divejson.md) adds + `born_on`, `phone`, `emergency_contacts` and `insurances`, and bounds `name` at 255, + `username` at 64 and `email` at 255, so `divejson validate` refuses a document it accepted + before if one of those is longer. The UDDF reader takes `born_on` from ``, + `phone` from the first `` or else the first ``, and one insurance per + ``, and an owner recording any of them is a diver even with no name. An + insurance with no `` is dropped, a phone or an email past its bound is dropped rather + than cut and an insurer's name is capped at 255, each with a report line. The writer puts + the same three into `` in the XSD's order, dates widened to midnight, and reports + `emergency_contacts` and an insurance's `number`, which UDDF has no element for. + ## 0.10.0 - **Breaking: a place is one object, and a dive site carries it.** §6.9 of diff --git a/divejson/registry.py b/divejson/registry.py index 831a332..2214013 100644 --- a/divejson/registry.py +++ b/divejson/registry.py @@ -401,9 +401,9 @@ def _recorded_owner(diver: dict[str, Any]) -> dict[str, Any]: Without the UUID, which for this one record says nothing: it is derived from an `` that every UDDF writer spells `owner`, so it is the same for two different people and different for one person whose two exports spell it differently. What is - left is the name and the email — and comparing those is what separates an archive of - one diver's dives, where every file repeats the same owner and nothing is lost, from - one where something is. + left is every member the file recorded about the person — and comparing those is what + separates an archive of one diver's dives, where every file repeats the same owner and + nothing is lost, from one where something is. Any difference is a drop, and the report says only that. Two people's exports in one zip and one person's two files where the later adds an email the first omitted are both diff --git a/divejson/uddf.py b/divejson/uddf.py index 5a01abb..7f5b3af 100644 --- a/divejson/uddf.py +++ b/divejson/uddf.py @@ -130,12 +130,16 @@ # `MAX_NOTES` and `MAX_NAME` are `converter.py`'s: every adapter meets those two, and # §6.9's `name` is one of them — a place's name shares the 255 every name in the format -# has. The two below are UDDF's own, being the only reader that fills the members they cap. -# §6.12's `serial` is 1-64 rather than the 255 its neighbours share, and the reason is worth -# knowing: a gear serial longer than a device's (§6.4b) could never equal one, and equality -# between the two is what says a kit item and a device are one machine. +# has, and so does an insurer's. The caps below are UDDF's own, being the only reader that +# fills the members they cap. §6.12's `serial` is 1-64 rather than the 255 its neighbours +# share, and the reason is worth knowing: a gear serial longer than a device's (§6.4b) could +# never equal one, and equality between the two is what says a kit item and a device are +# one machine. §6.1's phone and email are never cut to theirs, only dropped past them — a +# number or an address with its end missing is a wrong one rather than a short one. MAX_FULL_NAME = 512 MAX_SERIAL = 64 +MAX_PHONE = 32 +MAX_EMAIL = 255 MIN_PO2_LIMIT = Decimal("0.4") MAX_PO2_LIMIT = Decimal("2.0") MIN_SURFACE_PRESSURE = Decimal("0.4") @@ -501,19 +505,76 @@ def capped(self, value: str, limit: int, where: str, member: str) -> str: return capped(value, limit, note=self.note, where=where, member=member) def email(self, value: str | None, where: str) -> str | None: - """`` when it is an address, and nothing when it is not. + """`` when it is an address §6.1 can hold, and nothing when it is not. Every other source string reaches a member the format types as free text, where the - only limit is a length this converter caps. `email` is the exception — the schema - types it as an email address, so a `-` or an `n/a` is a value the member cannot - hold. Without this the whole conversion fails on it: the output would not validate, - which this module treats as its own bug, so one unusable header field would discard - an entire logbook instead of costing it one member and a line in the report. + only limit is a length. `email` is the exception — the schema types it as an email + address, so a `-` or an `n/a` is a value the member cannot hold. Without this the + whole conversion fails on it: the output would not validate, which this module + treats as its own bug, so one unusable header field would discard an entire logbook + instead of costing it one member and a line in the report. An address past §6.1's + bound is the same case. """ - if value is None or _EMAIL.match(value): - return value - self.note(where, f"the recorded email {value!r} is not an address; read as no email recorded", "dropped") - return None + if value is None: + return None + if not _EMAIL.match(value): + self.note(where, f"the recorded email {value!r} is not an address; read as no email recorded", "dropped") + return None + if len(value) > MAX_EMAIL: + self.note( + where, + f"the recorded email is {len(value)} characters and the format allows {MAX_EMAIL}; an address " + "cut short is a wrong one, so it is dropped", + "dropped", + ) + return None + return value + + def phone(self, contact: ET.Element | None, where: str) -> str | None: + """The first ``, else the first ``, reporting every other one. + + §6.1 carries one number the way it carries one address, and `contactType` holds any + number of each. A first number past §6.1's bound is dropped rather than cut, for the + reason `MAX_PHONE` gives, and the next is not read in its place. + """ + recorded = [ + (tag, value) + for tag in ("phone", "mobilephone") + for value in (_text(kid) for kid in _kids(contact, tag)) + if value + ] + if not recorded: + return None + for tag, value in recorded[1:]: + self.note( + where, f"§6.1 carries one phone, the first the owner records; <{tag}> {value!r} is not read", "dropped" + ) + _, value = recorded[0] + if len(value) > MAX_PHONE: + self.note( + where, + f"the recorded phone is {len(value)} characters and the format allows {MAX_PHONE}; a number cut " + "short is a wrong one, so it is dropped", + "dropped", + ) + return None + return value + + def date_of(self, element: ET.Element | None, where: str, member: str) -> str | None: + """The date an `encapsulatedDateTimeType` holds, taken off the front of its ``. + + The slot is an `xs:dateTime` and §6.1's members are dates, so a time of day is one a + writer supplied only because the slot demands one and is discarded without a finding; + a bare date, which UDDF's own examples write, reads the same way. + """ + raw = _text_of(element, "datetime") + if raw is None: + return None + value, _ = _date_time(raw) + if value is None: + self.note(where, f"{member} is {raw!r}, which is not a date; dropped", "dropped") + return None + return value[:10] def notes_text(self, parent: ET.Element | None, where: str) -> str | None: """A `` block as one string. Its `` children carry no note text.""" @@ -621,19 +682,28 @@ def read_diver(self) -> dict[str, Any] | None: than an identity and every UDDF writer in the corpus spells it `owner`. So a second member's diver is written out — without the identity that is not its own — and the merge is where a logbook's one owner is chosen and the rest reported. Collapsing it - here would discard a second person's name and email in silence. + here would discard what a second person's file records about them in silence. + + **An owner is a diver when it records anything this maps**, the id aside: an + `` with empty names and a `` is a nameless diver with a date of + birth, and one whose only content is a kit list is no diver at all. """ owner = _dig(self.root, "diver", "owner") if owner is None: return None where = "diver" + personal = _kid(owner, "personal") + contact = _kid(owner, "contact") names = [ - text - for text in (_text_of(owner, "personal", part) for part in ("firstname", "middlename", "lastname")) - if text + text for text in (_text_of(personal, part) for part in ("firstname", "middlename", "lastname")) if text ] - email = self.email(_text_of(owner, "contact", "email"), where) - if not names and not email: + recorded: dict[str, Any] = { + "email": self.email(_text_of(contact, "email"), where), + "phone": self.phone(contact, where), + "born_on": self.date_of(_kid(personal, "birthdate"), where, "the date of birth"), + "insurances": self.read_insurances(owner, where), + } + if not names and not any(recorded.values()): self.note(where, "the source records nothing about the logbook's owner; no diver is written (spec §6.1)", "absent") return None @@ -643,10 +713,45 @@ def read_diver(self) -> dict[str, Any] | None: diver["uuid"] = claimed if names: diver["name"] = self.capped(" ".join(names), MAX_NAME, where, "the diver's name") - if email: - diver["email"] = email + diver.update((member, value) for member, value in recorded.items() if value) return diver + def read_insurances(self, owner: ET.Element, where: str) -> list[dict[str, Any]]: + """`` as §6.1's Insurance objects, in file order. + + `` is the insurer and REQUIRED of one, so an insurance whose `` holds no + text is dropped rather than carried without it. The XSD requires the element and + types it as nothing, so `` is valid UDDF; mapped without a `provider` it would + fail the schema, and the whole file with it. What §6.1's Insurance has no member for + is reported, and nothing fills its `number`: `insuranceType` has no element for one. + """ + insurances: list[dict[str, Any]] = [] + for index, element in enumerate(_kids(_kid(owner, "diveinsurances"), "insurance")): + here = f"{where}/insurance/{index}" + provider = _text_of(element, "name") + if not provider: + self.note( + here, + "the insurance has no name, which the format requires of one as its insurer; it is dropped " + "(spec §6.1)", + "dropped", + ) + continue + insurance: dict[str, Any] = {"provider": self.capped(provider, MAX_NAME, here, "the insurer's name")} + expires_on = self.date_of(_kid(element, "validdate"), here, "the insurance's validdate") + if expires_on: + insurance["expires_on"] = expires_on + aliases = (_text(kid) for kid in _kids(element, "aliasname")) + unread = [f" {alias!r}" for alias in aliases if alias] + if _text_of(element, "issuedate", "datetime"): + unread.append("") + if any(_text(para) for para in _kids(_kid(element, "notes"), "para")): + unread.append("") + for what in unread: + self.note(here, f"§6.1's Insurance has no member for {what}; it is not read", "dropped") + insurances.append(insurance) + return insurances + # -- sites ------------------------------------------------------------------- def read_sites(self) -> list[dict[str, Any]]: diff --git a/divejson/uddf_write.py b/divejson/uddf_write.py index 4eacf36..28f5d65 100644 --- a/divejson/uddf_write.py +++ b/divejson/uddf_write.py @@ -594,41 +594,65 @@ def generator_element(self) -> ET.Element: def diver_element(self) -> ET.Element | None: """``, which is also the only place a logbook's gear can live. - So the element is written whenever there is either an owner to describe or a piece - of kit to hang on one, and the two are independent: `` sits inside - ``, and a logbook with gear and no diver would otherwise lose the whole list - to a member it has nothing to do with. An owner with nothing recorded about the - person gets empty names — a valid `xs:string`, which the reader reads back as no - diver at all rather than as a nameless one — and a note saying so. + So the element is written whenever there is either an owner to describe — any member + this maps, `uuid` aside — or a piece of kit to hang on one, and the two are + independent: `` sits inside ``, and a logbook with gear and no + diver would otherwise lose the whole list to a member it has nothing to do with. A + diver with no `name` gets both names empty, `personalType` requiring them whatever + else the owner carries — valid `xs:string`s, which the reader reads back as a + nameless diver beside anything else this maps and as no diver at all beside a kit + list alone. + + The owner's children go in the XSD's order, ``'s type extending the one UDDF + gives every person: `personal`, then `contact`, then `equipment`, then + `diveinsurances`. A date goes out widened to midnight, as `date_of_trip`'s do. """ diver = self.document.get("diver") or {} - name, email = diver.get("name"), diver.get("email") + name, email, phone, born_on = (diver.get(member) for member in ("name", "email", "phone", "born_on")) + insurances = diver.get("insurances") or [] gear = self.equipment_element() - if not (name or email or gear is not None): + described = bool(name or email or phone or born_on or insurances) + if not (described or gear is not None): if diver: self.note( "diver", - "the document records no name and no email for the logbook's owner, and UDDF's " - "carries nothing else about a person; no diver is written", + "the document records nothing about the logbook's owner that UDDF's has an element " + "for; no diver is written", "dropped", ) return None if diver: - self.unmapped("diver", diver, frozenset({"uuid", "name", "email"})) + self.unmapped("diver", diver, frozenset({"uuid", "name", "email", "phone", "born_on", "insurances"})) element = ET.Element("diver") uuid = diver.get("uuid") - # A plain `owner` where the document names nobody, which is what every UDDF writer - # in the corpus emits and what the reader is careful never to read as an identity. - owner = _sub(element, "owner", id=_uddf_id("diver", uuid) if uuid and (name or email) else "owner") + # A plain `owner` where the document records nothing about the person this maps, + # which is what every UDDF writer in the corpus emits and what the reader is careful + # never to read as an identity. + owner = _sub(element, "owner", id=_uddf_id("diver", uuid) if uuid and described else "owner") personal = _sub(owner, "personal") first, last = _person_names(str(name or "")) _sub(personal, "firstname", first) _sub(personal, "lastname", last) - if email: - _sub(_sub(owner, "contact"), "email", str(email)) + if born_on: + _sub(_sub(personal, "birthdate"), "datetime", f"{born_on}T00:00:00") + if phone or email: + contact = _sub(owner, "contact") + # `contactType` is a sequence, and `` comes before `` in it. + if phone: + _sub(contact, "phone", str(phone)) + if email: + _sub(contact, "email", str(email)) if gear is not None: owner.append(gear) + if insurances: + policies = _sub(owner, "diveinsurances") + for index, insurance in enumerate(insurances): + self.unmapped(f"diver/insurances/{index}", insurance, frozenset({"provider", "expires_on"})) + policy = _sub(policies, "insurance") + _sub(policy, "name", str(insurance["provider"])) + if insurance.get("expires_on"): + _sub(_sub(policy, "validdate"), "datetime", f"{insurance['expires_on']}T00:00:00") return element # -- computers --------------------------------------------------------------- diff --git a/tests/test_uddf_parsing.py b/tests/test_uddf_parsing.py index 96d3374..c7e0d78 100644 --- a/tests/test_uddf_parsing.py +++ b/tests/test_uddf_parsing.py @@ -274,6 +274,155 @@ def test_an_email_that_is_not_an_address_costs_one_member_and_not_the_logbook(wr assert any("is not an address" in note.message for note in conversion.notes) +def owner(personal: str = "SamReef", rest: str = "") -> str: + """A `` header whose owner carries `personal`'s children and then `rest`.""" + return f"{personal}{rest}" + + +def read_owner(header: str) -> tuple[dict | None, list[tuple[str, str, str]]]: + """The diver a header reads as, and the report's lines about the owner.""" + conversion = convert(one_dive(STARTED_AT, header=header)) + assert validate_document(conversion.document) == [] + notes = [(note.kind, note.where, note.message) for note in conversion.notes if note.where.startswith("diver")] + return conversion.document.get("diver"), notes + + +def test_an_owner_with_only_a_birthdate_is_a_nameless_diver() -> None: + """The loss the check-in members exist to stop: the guard used to ask for a name or an + email, and an owner recording anything else read as nobody at all.""" + birthdate = "1979-11-02T00:00:00" + diver, notes = read_owner(owner(f"{birthdate}")) + assert diver is not None + assert {member: value for member, value in diver.items() if member != "uuid"} == {"born_on": "1979-11-02"} + assert notes == [] + + +@pytest.mark.parametrize("written", ["1988-03-14T00:00:00", "1988-03-14", "1988-03-14T23:30:00+02:00"]) +def test_a_birthdate_is_the_date_off_the_front_of_its_datetime(written: str) -> None: + """The slot is an `xs:dateTime` and the member a date, so the time of day is the slot's + and not the diver's; a bare date is the spelling UDDF's own examples use.""" + diver, notes = read_owner(owner(f"{written}")) + assert diver is not None and diver["born_on"] == "1988-03-14" + assert notes == [] + + +def test_a_birthdate_that_is_not_a_date_is_dropped_and_reported() -> None: + """`1919-02-30` is on UDDF's own documentation page for ``.""" + birthdate = "1919-02-30" + diver, notes = read_owner(owner(f"SamReef{birthdate}")) + assert diver is not None and "born_on" not in diver + assert notes == [("dropped", "diver", "the date of birth is '1919-02-30', which is not a date; dropped")] + + +def test_the_first_phone_is_read_and_every_other_is_reported() -> None: + """§6.1 carries one number, the way it carries one email.""" + contact = ( + "+44 7700 900123+44 20 7946 0000" + "+44 7700 900999" + ) + diver, notes = read_owner(owner(rest=contact)) + assert diver is not None and diver["phone"] == "+44 7700 900123" + assert [message for _, _, message in notes] == [ + "§6.1 carries one phone, the first the owner records; '+44 20 7946 0000' is not read", + "§6.1 carries one phone, the first the owner records; '+44 7700 900999' is not read", + ] + + +def test_a_mobile_phone_is_read_where_there_is_no_phone() -> None: + diver, notes = read_owner(owner(rest="+44 7700 900999")) + assert diver is not None and diver["phone"] == "+44 7700 900999" + assert notes == [] + + +def test_a_phone_past_the_bound_is_dropped_rather_than_cut() -> None: + """A number with its end missing is a wrong number, and §6.1 bounds `phone` at 32.""" + diver, notes = read_owner(owner(rest=f"{'4' * 33}")) + assert diver is not None and "phone" not in diver + assert notes == [ + ( + "dropped", + "diver", + ( + "the recorded phone is 33 characters and the format allows 32; a number cut short is a wrong " + "one, so it is dropped" + ), + ) + ] + + +def test_an_email_past_the_bound_is_dropped_rather_than_cut() -> None: + address = f"{'a' * 250}@example.org" + diver, notes = read_owner(owner(rest=f"{address}")) + assert diver is not None and "email" not in diver + assert notes == [ + ( + "dropped", + "diver", + ( + f"the recorded email is {len(address)} characters and the format allows 255; an address cut " + "short is a wrong one, so it is dropped" + ), + ) + ] + + +def test_each_insurance_is_read_in_file_order() -> None: + rest = ( + "" + "DAN Europe2027-03-31T00:00:00" + "Aqua Med" + "" + ) + diver, notes = read_owner(owner(rest=rest)) + assert diver is not None + assert diver["insurances"] == [{"provider": "DAN Europe", "expires_on": "2027-03-31"}, {"provider": "Aqua Med"}] + assert notes == [] + + +def test_an_insurance_with_no_name_is_dropped_and_reported() -> None: + """`insuranceType` requires `` and types it as nothing, so `` is valid UDDF — + and §6.1's `provider` is REQUIRED, so mapping it would fail the whole file.""" + rest = ( + "2027-03-31T00:00:00" + "DAN Europe" + ) + diver, notes = read_owner(owner(rest=rest)) + assert diver is not None and diver["insurances"] == [{"provider": "DAN Europe"}] + assert notes == [ + ( + "dropped", + "diver/insurance/0", + "the insurance has no name, which the format requires of one as its insurer; it is dropped (spec §6.1)", + ) + ] + + +def test_an_insurers_name_past_the_bound_is_capped_and_reported() -> None: + """The same 255 as the diver's own name, and cut the same way: a name is still the name + with its end missing, where a number is not.""" + insurance = f"{'D' * 300}" + diver, notes = read_owner(owner(rest=f"{insurance}")) + assert diver is not None and diver["insurances"] == [{"provider": "D" * 255}] + assert [(kind, where) for kind, where, _ in notes] == [("dropped", "diver/insurance/0")] + assert "the insurer's name is 300 characters" in notes[0][2] + + +def test_what_an_insurance_carries_beyond_its_three_members_is_reported() -> None: + rest = ( + "DAN EuropeDAN" + "2026-04-01T00:00:00" + "2027-03-31T00:00:00" + "Gold cover" + ) + diver, notes = read_owner(owner(rest=rest)) + assert diver is not None and diver["insurances"] == [{"provider": "DAN Europe", "expires_on": "2027-03-31"}] + assert [message for _, _, message in notes] == [ + "§6.1's Insurance has no member for 'DAN'; it is not read", + "§6.1's Insurance has no member for ; it is not read", + "§6.1's Insurance has no member for ; it is not read", + ] + + @pytest.mark.parametrize("written", ["1e999", "1e999999999", "-1e999", "NaN", "Infinity", "-Infinity"]) def test_a_number_too_large_to_carry_is_not_a_number(written: str) -> None: """`Decimal` parses all of these and calls the first three finite. Neither survives. @@ -315,11 +464,14 @@ def test_hostile_source_strings_still_produce_a_conforming_document() -> None: long_name = "N" * 400 long_note = "note. " * 3000 header = ( - f"{long_name}" - "whatever they typed" + f"{long_name}" + "whenever" + f"{long_name}whatever they typed" f"{long_name}" f"{long_name}" - f"{long_note}" + f"{long_note}" + f"{long_name}" + "never" f"{long_name}" f"{long_name}" f"{long_note}" @@ -336,6 +488,8 @@ def test_hostile_source_strings_still_produce_a_conforming_document() -> None: conversion = convert(one_dive(body, header=header)) assert validate_document(conversion.document) == [] assert "email" not in conversion.document["diver"] + assert "phone" not in conversion.document["diver"] + assert len(conversion.document["diver"]["insurances"][0]["provider"]) == 255 assert len(conversion.document["dives"][0]["notes"]) == 10_000 assert len(conversion.document["sites"][0]["name"]) == 255 diff --git a/tests/test_uddf_write_fixtures.py b/tests/test_uddf_write_fixtures.py index 8b706de..57f14d6 100644 --- a/tests/test_uddf_write_fixtures.py +++ b/tests/test_uddf_write_fixtures.py @@ -66,6 +66,8 @@ # this set used to hold are inside that one line now. LOST: dict[str, frozenset[str]] = { "opendiving": frozenset(), + # An owner recorded by a date of birth alone, which comes back with its identity. + "owner-profile-only": frozenset(), "technical-dive": frozenset( { # No slot anywhere in UDDF. @@ -76,6 +78,10 @@ "gear_service_records", "certifications", "diver/created_at", + # `` describes the logbook's owner and nobody else, and `insuranceType` + # has no element for the identifier the insurer knows the diver by. + "diver/emergency_contacts", + "diver/insurances/0/number", "sites/0/created_at", "trips/0/created_at", "trips/0/parts/0/location/bbox", @@ -141,6 +147,7 @@ # did not produce. RETURNED: dict[str, frozenset[str]] = { "opendiving": frozenset(), + "owner-profile-only": frozenset(), "technical-dive": frozenset({"gear", "dives/0/gear_uuids"}), } diff --git a/tests/test_uddf_writing.py b/tests/test_uddf_writing.py index 5d0ffca..e68860a 100644 --- a/tests/test_uddf_writing.py +++ b/tests/test_uddf_writing.py @@ -790,6 +790,87 @@ def test_a_one_token_name_leaves_the_surname_empty(schema) -> None: assert read_back(source)["diver"]["name"] == "Cousteau" +DIVER_UUID = "0198a6f0-9999-7009-8000-000000000009" + + +def test_the_owners_children_go_in_the_schemas_order(schema) -> None: + """``'s type extends the one UDDF gives every person, so the sequence runs + `personal`, `contact`, `equipment` and on to `diveinsurances` — and inside ``, + `` before ``. The reader takes children by name, so only the XSD sees it.""" + source = document( + diver={ + "uuid": DIVER_UUID, + "name": "Sam Reef", + "email": "sam@example.org", + "phone": "+44 7700 900123", + "born_on": "1988-03-14", + "insurances": [{"provider": "DAN Europe", "expires_on": "2027-03-31"}], + }, + gear=[{"uuid": GEAR_UUID, "name": "Fins", "type": "fins"}], + ) + text = written(source, schema) + assert ( + text.index("") + < text.index("") + < text.index("") + < text.index("") + < text.index("") + < text.index("") + < text.index("") + ) + assert read_back(source)["diver"] == source["diver"] + assert notes(source) == [] + + +def test_a_date_goes_out_widened_to_midnight_and_comes_back_a_date(schema) -> None: + """`` and `` hold an `xs:dateTime`, which a bare date fails.""" + source = document( + diver={"born_on": "1988-03-14", "insurances": [{"provider": "DAN Europe", "expires_on": "2027-03-31"}]} + ) + text = written(source, schema) + assert re.search(r"\s*1988-03-14T00:00:00", text) + assert re.search(r"\s*2027-03-31T00:00:00", text) + back = read_back(source)["diver"] + assert back["born_on"] == "1988-03-14" + assert back["insurances"] == [{"provider": "DAN Europe", "expires_on": "2027-03-31"}] + + +def test_an_owner_recorded_by_anything_this_maps_keeps_its_identity(schema) -> None: + """The owner id follows the guard: a diver the document records by a phone alone is still + that diver, and the id is what brings its uuid back.""" + source = document(diver={"uuid": DIVER_UUID, "phone": "+44 7700 900123"}) + assert f'' in written(source, schema) + assert read_back(source)["diver"] == source["diver"] + + +def test_an_insurances_number_has_no_element_and_is_reported(schema) -> None: + source = document(diver={"name": "Sam Reef", "insurances": [{"provider": "DAN World", "number": "DW-88213"}]}) + assert "DW-88213" not in written(source, schema) + assert messages(source, "diver/insurances/0") == ["UDDF has no slot for number; it is not written"] + assert read_back(source)["diver"]["insurances"] == [{"provider": "DAN World"}] + + +def test_emergency_contacts_have_no_element_and_are_reported(schema) -> None: + source = document(diver={"name": "Sam Reef", "emergency_contacts": [{"name": "Robin Reef"}]}) + assert "Robin" not in written(source, schema) + assert messages(source, "diver") == ["UDDF has no slot for emergency_contacts; it is not written"] + + +def test_a_diver_recording_only_what_uddf_cannot_hold_writes_no_diver(schema) -> None: + """Nothing in it maps, so an `` would be empty names that read back as nobody.""" + source = document( + diver={"uuid": DIVER_UUID, "emergency_contacts": [{"name": "Robin Reef", "phone": "+44 7700 900456"}]}, + 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 ------------------------------------------------------------------- From 9cefd035d79c9f9017c0c81ef96a596631c79003 Mon Sep 17 00:00:00 2001 From: Aleksei Vesnin Date: Wed, 23 Sep 2026 18:09:03 +0300 Subject: [PATCH 3/4] test: the profile-only owner test says what it pins Co-Authored-By: Claude Opus 5.5 --- tests/test_uddf_parsing.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_uddf_parsing.py b/tests/test_uddf_parsing.py index c7e0d78..1a8feee 100644 --- a/tests/test_uddf_parsing.py +++ b/tests/test_uddf_parsing.py @@ -288,8 +288,8 @@ def read_owner(header: str) -> tuple[dict | None, list[tuple[str, str, str]]]: def test_an_owner_with_only_a_birthdate_is_a_nameless_diver() -> None: - """The loss the check-in members exist to stop: the guard used to ask for a name or an - email, and an owner recording anything else read as nobody at all.""" + """An owner is a diver when it records anything the mapping reads, not only a name or an + email — otherwise a date of birth with nobody named beside it would be lost unreported.""" birthdate = "1979-11-02T00:00:00" diver, notes = read_owner(owner(f"{birthdate}")) assert diver is not None From 3b6c7a148950f253b106b8434a65b7e8c0ec3b45 Mon Sep 17 00:00:00 2001 From: Aleksei Vesnin Date: Wed, 23 Sep 2026 18:21:57 +0300 Subject: [PATCH 4/4] chore: pin the specification at its merged c5034f0 Co-Authored-By: Claude Opus 5.5 --- SPEC_REF | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SPEC_REF b/SPEC_REF index a34560c..654fda5 100644 --- a/SPEC_REF +++ b/SPEC_REF @@ -1 +1 @@ -e3b8b0343aaf9ec7d143b276b00af53d867a2a42 +c5034f083d178de0d4c4130dd989213e8031db92