From f708a2e9f347d08df4a9da7af2a1ee561674c300 Mon Sep 17 00:00:00 2001 From: Aleksei Vesnin Date: Mon, 21 Sep 2026 19:09:47 +0300 Subject: [PATCH 1/2] feat!: a place carries a name and a full name, on a site and on a part MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §6.9 is one Location object referenced by a trip part and a dive site alike, so `sites[].location` is that object where it was a free-text string, and the object's second text member is `full_name` rather than `display_name`. The UDDF reader fills a site's locality with its name alone — ``'s coordinates are the site's own pin (§6.10) — and the writer reports the locality's `full_name`, `position` and `bbox` dropped at the locality's own path, where a note at the site's path would read as a claim about the coordinates it just wrote. The beyond-schema `south ≤ north` check reaches both hosts. `SPEC_REF` names the specification branch this vendors from; the ancestor check is red until that branch merges. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 19 + SPEC_REF | 2 +- divejson/uddf.py | 22 +- divejson/uddf_write.py | 38 +- divejson/validate.py | 35 +- docs/ssrf-mapping.md | 12 +- docs/uddf-mapping.md | 10 +- docs/uddf-writing.md | 14 +- fixtures/README.md | 18 +- .../invalid/agency-other-missing.divejson | 6 +- .../invalid/avg-depth-exceeds-max.divejson | 6 +- fixtures/invalid/bad-version.divejson | 6 +- fixtures/invalid/bbox-missing-corner.divejson | 6 +- .../invalid/bbox-south-exceeds-north.divejson | 6 +- .../invalid/bbox-without-position.divejson | 6 +- .../invalid/channel-length-mismatch.divejson | 6 +- ...ourse-agency-other-without-agency.divejson | 6 +- .../invalid/course-dates-reversed.divejson | 6 +- fixtures/invalid/dangling-reference.divejson | 6 +- fixtures/invalid/deco-model-gf-order.divejson | 6 +- fixtures/invalid/device-empty-member.divejson | 6 +- .../dive-profile-outside-recording.divejson | 6 +- ...icate-file-uuid-across-recordings.divejson | 6 +- fixtures/invalid/duplicate-uuid.divejson | 6 +- .../event-without-type-or-label.divejson | 6 +- fixtures/invalid/missing-format.divejson | 6 +- fixtures/invalid/naive-exported-at.divejson | 6 +- .../invalid/non-increasing-samples.divejson | 6 +- fixtures/invalid/null-member.divejson | 6 +- fixtures/invalid/oxygen-helium-sum.divejson | 6 +- fixtures/invalid/position-incomplete.divejson | 6 +- fixtures/invalid/pressure-order.divejson | 6 +- .../invalid/profile-duration-short.divejson | 6 +- .../recording-without-content.divejson | 6 +- .../site-bbox-south-exceeds-north.divejson | 484 ++++++++++++++++++ fixtures/invalid/species-no-identity.divejson | 6 +- .../trailing-newline-datetime.divejson | 6 +- fixtures/invalid/trip-dates-reversed.divejson | 6 +- fixtures/invalid/undefined-member.divejson | 6 +- fixtures/uddf/divelogs.divejson | 4 +- fixtures/uddf/legacy-writer.divejson | 4 +- fixtures/uddf/mix-only-cylinder.divejson | 4 +- fixtures/uddf/opendiving.divejson | 6 +- fixtures/uddf/subsurface.divejson | 8 +- fixtures/valid/demo-logbook.divejson | 20 +- fixtures/valid/technical-dive.divejson | 17 +- fixtures/valid/two-computers.divejson | 4 +- fixtures/write/uddf/opendiving.divejson | 6 +- fixtures/write/uddf/technical-dive.divejson | 17 +- schema/1.0/divejson.schema.json | 8 +- tests/test_ssrf_fixtures.py | 5 +- tests/test_uddf_parsing.py | 2 +- tests/test_uddf_write_fixtures.py | 9 +- tests/test_uddf_writing.py | 47 +- 54 files changed, 858 insertions(+), 131 deletions(-) create mode 100644 fixtures/invalid/site-bbox-south-exceeds-north.divejson diff --git a/CHANGELOG.md b/CHANGELOG.md index 57db76d..a07732e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,25 @@ this file is about the package, whose version moves independently. ## Unreleased +- **Breaking: a place is one object, and a dive site carries it.** §6.9 of + [the specification](https://github.com/divejson/divejson/blob/main/spec/divejson.md) is + *Location* rather than *Trip Location* and both a trip part and a dive site reference it, so + `sites[].location` is an object where it was a free-text string and anything reading it as + text reads `location.name`. Its second text member is `full_name`, the fullest written form + the source held for the place; `display_name` is removed rather than re-pointed, so a + document still carrying it is refused as an undefined member rather than read as something + it no longer means. §3's `south ≤ north` reads on either host now, and a dive site's + reversed bounding box is reported at `sites//location/bbox`. + +- **The UDDF reader fills a site's locality with its name and nothing else.** + ``'s own coordinates are the site's pin and not the locality's centre (§6.10), + and UDDF has no element for a fuller form of a place or for its extent. The writer spends + the one `` element each host has the same way it is read — a site's on + `location.name`, a trip part's on `location.full_name`, the part's own `` already + holding the name — and reports a site's `full_name`, `position` and `bbox` dropped at + `sites//location`, where a note at the site's own path would read as a claim about the + coordinates the same element just carried. + ## 0.9.0 - **Breaking: a trip is a sequence of parts, and records no dates of its own.** §6.8 and the diff --git a/SPEC_REF b/SPEC_REF index dc6427b..02ee4fb 100644 --- a/SPEC_REF +++ b/SPEC_REF @@ -1 +1 @@ -373bf78002d15102e60f8913f31b74dbe7c12ffc +9e423d4d4bbd412d9aec3aa1c744eb59732808c9 diff --git a/divejson/uddf.py b/divejson/uddf.py index 5df94d4..5a01abb 100644 --- a/divejson/uddf.py +++ b/divejson/uddf.py @@ -128,13 +128,13 @@ # specifies — see `_volume_litres`. LITRES_THRESHOLD = Decimal(1) -# `MAX_NOTES` and `MAX_NAME` are `converter.py`'s: every adapter meets those two. The three -# 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 +# `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. -MAX_LOCATION = 255 -MAX_DISPLAY_NAME = 512 +MAX_FULL_NAME = 512 MAX_SERIAL = 64 MIN_PO2_LIMIT = Decimal("0.4") MAX_PO2_LIMIT = Decimal("2.0") @@ -678,7 +678,11 @@ def read_sites(self) -> list[dict[str, Any]]: geography = _kid(element, "geography") location = _text_of(geography, "location") if location: - site["location"] = self.capped(location, MAX_LOCATION, where, "the site location") + # The place's name and nothing else. ``'s own coordinates are the + # **site's** pin, not the locality's centre (§6.10), and UDDF has no element + # for a fuller form of the place or for its extent — so a site read from + # here never arrives with a `full_name`, a `location.position` or a `bbox`. + site["location"] = {"name": self.capped(location, MAX_NAME, where, "the site's locality")} position = self.position(geography, where) if position: site["position"] = position @@ -765,12 +769,12 @@ def read_trip_parts(self, element: ET.Element, where: str) -> tuple[list[dict[st geography = _kid(part, "geography") part_name = _text_of(part, "name") - display_name = _text_of(geography, "location") + full_name = _text_of(geography, "location") location: dict[str, Any] | None = None if part_name: location = {"name": self.capped(part_name, MAX_NAME, part_where, "the trip part's name")} - if display_name and display_name != part_name: - location["display_name"] = self.capped(display_name, MAX_DISPLAY_NAME, part_where, "the location") + if full_name and full_name != part_name: + location["full_name"] = self.capped(full_name, MAX_FULL_NAME, part_where, "the location") position = self.position(geography, part_where) if position: location["position"] = position diff --git a/divejson/uddf_write.py b/divejson/uddf_write.py index 1c8f754..4eacf36 100644 --- a/divejson/uddf_write.py +++ b/divejson/uddf_write.py @@ -916,7 +916,22 @@ def divesite_element(self) -> ET.Element | None: self.unmapped(where, site, frozenset({"uuid", "name", "location", "position", "notes"})) element = _sub(divesite, "site", id=_uddf_id("site", site["uuid"])) _sub(element, "name", str(site.get("name") or "")) - self.geography(element, where, site.get("location"), site.get("position"), noun="site") + location = site.get("location") + if location: + # The locality's own members report here or nowhere: `unmapped` is flat, and + # the call above carries `location` as a member *name* rather than walking + # into it. At the locality's path and not the site's, which is not cosmetic + # — `geography` writes the **site's** pin two lines down, so a note saying + # UDDF has no slot for `position` at `sites/` would read as a claim about + # the coordinates this same call just wrote (§6.10). + self.unmapped(f"{where}/location", location, frozenset({"name"})) + self.geography( + element, + where, + location.get("name") if location else None, + site.get("position"), + noun="site", + ) self.notes_of(element, where, site) return divesite @@ -924,13 +939,18 @@ def geography( self, parent: ET.Element, where: str, - location: Any, + place: Any, position: Any, *, noun: str, ) -> None: """``, which UDDF will not let carry coordinates without a place name. + `place` is the text the one `` element gets, and each host spends that + element on a different member of §6.9's location — a site on the place's `name`, a + trip part on its `full_name`, the part's own `` already holding the name. + `docs/uddf-mapping.md` has the asymmetry and why UDDF forces it. + `` is mandatory in `geographyType`, and there is nothing honest to put there for a record that has none: copying the record's own **name** in — which is what the reference writer does, having one to spare and an app's own export to @@ -938,7 +958,7 @@ def geography( coordinates are dropped and reported, which is the loss this format actually imposes. """ - if not location: + if not place: if position: self.note( where, @@ -948,7 +968,7 @@ def geography( ) return geography = _sub(parent, "geography") - _sub(geography, "location", str(location)) + _sub(geography, "location", str(place)) if position: _sub(geography, "latitude", _num(position["latitude"])) _sub(geography, "longitude", _num(position["longitude"])) @@ -989,17 +1009,17 @@ def divetrip_element(self) -> ET.Element | None: if record is not None: self.nameless_part(part_where, record) else: - self.unmapped(part_where, location, frozenset({"name", "display_name", "position"})) + self.unmapped(part_where, location, frozenset({"name", "full_name", "position"})) _sub(part, "name", str(location.get("name") or "")) if record is not None: self.date_of_trip(part, part_where, record) if location is not None: - # `display_name` and nothing else: the reader takes a part's - # `` as the display name and only where it differs + # `full_name` and nothing else: the reader takes a part's + # `` as the fuller form and only where it differs # from the part's own name, so writing the name here would round-trip as - # no display name at all. + # no full name at all. self.geography( - part, part_where, location.get("display_name"), location.get("position"), noun="location" + part, part_where, location.get("full_name"), location.get("position"), noun="location" ) if part_index == 0: self.notes_of(part, where, trip) diff --git a/divejson/validate.py b/divejson/validate.py index d23b56e..b4ba1bb 100644 --- a/divejson/validate.py +++ b/divejson/validate.py @@ -170,6 +170,27 @@ def _present(obj: dict[str, Any], member: str) -> bool: return obj.get(member) is not None +def _check_location_bbox(record: dict[str, Any], path: str, issues: list[Issue]) -> None: + """§3's `south ≤ north`, on whichever record carries the location. + + §6.9's box has two hosts — a trip part and a dive site — so a check that walked trips + alone would pass a site's reversed box perfectly, the schema bounding each corner and + saying nothing about the pair. `fixtures/invalid/site-bbox-south-exceeds-north.divejson` + is the document that fails only for a validator reaching both. + """ + location = record.get("location") + if not isinstance(location, dict): + return + bbox = location.get("bbox") + if not isinstance(bbox, dict): + return + try: + if bbox["south"] > bbox["north"]: + issues.append(Issue(f"{path}/location/bbox", "south exceeds north")) + except (KeyError, TypeError): + pass + + def _semantic_issues(doc: dict[str, Any]) -> list[Issue]: issues: list[Issue] = [] @@ -289,16 +310,10 @@ def _semantic_issues(doc: dict[str, Any]) -> list[Issue]: issues.append(Issue(part_path, "ends_on precedes starts_on")) except TypeError: pass - location = part.get("location") - if not isinstance(location, dict): - continue - bbox = location.get("bbox") - if isinstance(bbox, dict): - try: - if bbox["south"] > bbox["north"]: - issues.append(Issue(f"{part_path}/location/bbox", "south exceeds north")) - except (KeyError, TypeError): - pass + _check_location_bbox(part, part_path, issues) + + for index, site in enumerate(collections["sites"]): + _check_location_bbox(site, f"sites/{index}", issues) for index, gear_set in enumerate(collections["gear_sets"]): _check_reference_list( diff --git a/docs/ssrf-mapping.md b/docs/ssrf-mapping.md index 70584ab..308e0c4 100644 --- a/docs/ssrf-mapping.md +++ b/docs/ssrf-mapping.md @@ -324,7 +324,7 @@ one, so nothing about it could be checked against output Subsurface actually pro | --- | --- | | ``'s own attributes | No real export in hand carries a `` to read its `@date` and `@location` from — so the dives inside one are carried and the grouping is reported as dropped. The element is still walked *through*, or a trip's dives would disappear with it. §6.8's `starts_on` was a second reason and is no longer one: a trip records no dates now, and §6.9a's part is the shape `@date` and `@location` together make. What is left to settle is a trip's `name`, which §6.8 still REQUIRES and which no attribute here states. This is the first thing to map when such a file arrives. | | `` | site coordinates, and the highest-value entry in this table. No file in hand carries one, so neither the separator nor the coordinate order can be checked; `converting.md`'s Null Island and half-a-pair rules are already shared and waiting for it. | -| `` | Subsurface's country/region taxonomy, whose `@cat` codes are not documented in any file here. `sites[].location` is where it would land. | +| `` | Subsurface's country/region taxonomy, whose `@cat` codes are not documented in any file here. `sites[].location.name` is where it would land — the tags say what the place is called and nothing about where it sits, so the rest of §6.9's members would stay empty. | | `` | `dive.weight` is the member, and the unit spelling and the multiple-system summing rule are both unchecked against a real file. | | ``, `@sensor` | `profile.pressures[]` and the cylinder numbering it needs. No file in hand carries a sample pressure, and a channel tied to the wrong cylinder is worse than no channel. | | ``, `@tts`, `@cns`, `@dc_supplied_ppo2` | §6.4's `ndl`, `tts`, `cns` and `ppo2` channels. Nothing in this table is closer to landing: the members exist and Subsurface writes all four. No `.ssrf` in hand carries one — every `` in the four fixtures states depth, temperature and nothing else — and this corpus does not adopt a mapping no pair exercises. Named here as Subsurface writes them so the reader that maps them starts from the right list. | @@ -387,11 +387,11 @@ exporter's doing. Everything else in both documents is equal. does carry these two they are per-waypoint series rather than the dive's end scalar, and `uddf-mapping.md` records this reader declining to derive a scalar from them; that policy never comes into play here, because there is nothing in the export to derive from. -- **`sites[].location`** — the site's own name there, absent here, on all five sites. The - exporter writes a `` holding exactly what `` holds, and the - UDDF reader carries it because §6.10's `location` is a real member and a reader cannot - know that a writer filled it by copying. The save file's `` has one name and no - second field to copy it into. This is the one difference `dives` cannot see: the other +- **`sites[].location`** — a place named after the site itself there, absent here, on all + five sites. The exporter writes a `` holding exactly what `` + holds, and the UDDF reader carries it into `location.name` because §6.10's `location` is a + real member and a reader cannot know that a writer filled it by copying. The save file's + `` has one name and no second field to copy it into. This is the one difference `dives` cannot see: the other seven all live on a dive. The record UUIDs differ too, and always will: each format has its own frozen identity diff --git a/docs/uddf-mapping.md b/docs/uddf-mapping.md index 1a65fba..d09439c 100644 --- a/docs/uddf-mapping.md +++ b/docs/uddf-mapping.md @@ -144,7 +144,7 @@ holding `n/a`, a dash or a person's name is read as no email recorded, and repor | UDDF | DiveJSON | | --- | --- | | `name` | `sites[].name` — REQUIRED, so a nameless site is dropped | -| `geography/location` | `sites[].location` | +| `geography/location` | `sites[].location.name` — the only slot UDDF has for a locality, so nothing else of the place is filled in | | `geography/latitude` + `longitude` | `sites[].position` | | `notes/para` | `sites[].notes`, paragraphs joined with blank lines | @@ -162,13 +162,19 @@ which is the file that taught `converting.md`'s rule that such a pair is not a p | `name` | `trips[].name` | | `trippart` | `trips[].parts[]`, in file order | | `trippart/name` | `trips[].parts[].location.name` | -| `trippart/geography/location` | `trips[].parts[].location.display_name`, when it differs from the name | +| `trippart/geography/location` | `trips[].parts[].location.full_name`, when it differs from the name | | `trippart/geography/latitude` + `longitude` | `trips[].parts[].location.position` | | `trippart/dateoftrip/@startdate` | `trips[].parts[].starts_on` | | `trippart/dateoftrip/@enddate` | `trips[].parts[].ends_on` | | `trippart/notes/para` | `trips[].notes`, every part's joined | | dive's `informationbeforedive/tripmembership/@ref` | `dives[].trip_uuid` | +**`` lands in a different member on each host**, and the asymmetry is +UDDF's rather than this reader's. A ``'s `` *is* its place's name, so the +element beside it is free to carry the fuller form; a ``'s `` is the site's own, +which leaves `` as the only slot the locality has at all — and a site read from +UDDF therefore never arrives with a `full_name`, a locality position or a box. + **A `` is a part**, which is as close to an identity as this table gets: both formats model a trip as a sequence of stretches each carrying its own dates and its own place, so the dates stay where the file put them instead of being collapsed into a span. diff --git a/docs/uddf-writing.md b/docs/uddf-writing.md index 706e8b5..c2c12ad 100644 --- a/docs/uddf-writing.md +++ b/docs/uddf-writing.md @@ -361,7 +361,7 @@ UDDF file expects to find there. `geographyType` makes `` mandatory, so **coordinates are written only where the record has a place name**: a site with a `position` and no `location`, or a part's location -with a `position` and no `display_name`, keeps its name and loses its coordinates, reported. +with a `position` and no `full_name`, keeps its name and loses its coordinates, reported. **Differs from the reference writer**: it puts the record's own **name** in `` and keeps the coordinates, which is defensible for an application exporting data it holds a name @@ -372,7 +372,7 @@ place in the document where the reference writer takes the third of them. A trip becomes one `` **per §6.9a part**, which is as close to an identity as this document gets: both formats model a trip as a sequence of stretches, each carrying its own dates and its own place, so a part goes out whole instead of having its dates lifted to -the trip. A part's `location.name` is the ``, its `location.display_name` the +the trip. A part's `location.name` is the ``, its `location.full_name` the ``, and its own dates the ``. **A trip with no parts still needs one ``** — `tripType` requires at least one — @@ -406,6 +406,15 @@ held the dates: `trips[].parts[].location.bbox` has no UDDF slot at all. +**A dive site's locality loses three members rather than one**, and the reason is the +element it has to share. A site's `` is the site's own, so the locality has only +`` to live in and that one slot takes `location.name` — which leaves +`sites[].location.full_name` with nowhere to go, and `sites[].location.position` and +`sites[].location.bbox` with nowhere either, ``'s own `` and +`` being the **site's** pin and not the locality's. All three are reported +dropped. A part gives the same element to its `full_name` and so loses only its box, which +is the asymmetry [`uddf-mapping.md`](uddf-mapping.md) describes from the reading side. + ### Dives `` is an `xs:positiveInteger` where §6.2 puts no floor under `number`, so a @@ -604,6 +613,7 @@ once per record that carries it, and none of them has anywhere in UDDF to go: | `diver.username` | `` is an XML id and not a handle | | 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 | | a record's `extensions` | producer-defined members (§5.5) | An **empty** note — `notes: ""` — is not written either: `` and no `` at diff --git a/fixtures/README.md b/fixtures/README.md index 7584059..a6160ad 100644 --- a/fixtures/README.md +++ b/fixtures/README.md @@ -33,13 +33,18 @@ what makes a fixture dropped into this tree impossible to ignore. | `minimal.divejson` | The smallest conforming document: `format`, `version`, `exported_at` — no diver (a source that records nothing about its owner omits the member), no collections (absent ≡ empty). | | `demo-logbook.divejson` | A real export of the reference writer's demo account (all names are seeded demo data; pulled 2026-09-02, courses-era writer): 8 dives, one carrying a recording with its device, its stored file and a full sampled profile, which is what a dive imported from a computer file looks like — sites, trips, an empty `courses` collection, gear with a service history, certifications, and producer extensions carrying application-specific values. Its device is written by hand: the export predates recordings, and the file it names was read by the `suunto_json` parser, so the device is what that reader takes from such a file. 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, 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. 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/ -One defect per file. Every rule the schema alone cannot express (spec §3) has a fixture -here; several schema-level defects are included so the validator's schema pass and the -format's structural guarantees (Position objects, the null ban) are exercised too. +One defect per file, and two kinds of defect. Every rule the schema alone cannot express +(spec §3) has a fixture here, and so do defects the schema rejects by itself: those are +what catch a schema loosened until it stops rejecting something it should, and they are +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. **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` @@ -78,7 +83,8 @@ validator does not look. | `trip-dates-reversed.divejson` | `ends_on` before `starts_on` on a trip part | §6.9a | | `course-dates-reversed.divejson` | `ends_on` before `starts_on` on a course | §6.17 | | `bbox-missing-corner.divejson` | a Bounding Box missing one corner | §6.9 | -| `bbox-south-exceeds-north.divejson` | `south > north` in a Bounding Box | §6.9 | +| `bbox-south-exceeds-north.divejson` | `south > north` in a Bounding Box, on a trip part's location | §6.9 | +| `site-bbox-south-exceeds-north.divejson` | the same defect on a **dive site's** location: §6.9's box has two hosts now, and a validator walking only trips passes the file above perfectly | §6.9, §6.10 | | `bbox-without-position.divejson` | a `bbox` on a location with no `position` | §6.9 | | `species-no-identity.divejson` | a species with no AphiaID and no name at all | §6.11 | | `agency-other-missing.divejson` | `agency: "other"` without `agency_other` | §6.16 | @@ -231,4 +237,4 @@ together — the point of each row below is which half of `uddf-writing.md` it r | 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 location's bounding box; the gas numbering UDDF cannot record; `shears` landing in `` and reading back as `other`; a trip part whose location has coordinates and no display 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. | +| `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. | diff --git a/fixtures/invalid/agency-other-missing.divejson b/fixtures/invalid/agency-other-missing.divejson index 69c3c33..bea6749 100644 --- a/fixtures/invalid/agency-other-missing.divejson +++ b/fixtures/invalid/agency-other-missing.divejson @@ -278,7 +278,7 @@ "ends_on": "2026-07-04", "location": { "name": "Milford Sound", - "display_name": "Milford Sound / Piopiotahi, Southland, New Zealand", + "full_name": "Milford Sound / Piopiotahi, Southland, New Zealand", "position": { "latitude": -44.6414, "longitude": 167.8974 @@ -341,7 +341,9 @@ { "uuid": "0198a6f0-1111-7040-8000-000000000040", "name": "Harrys Wall", - "location": "Milford Sound, New Zealand", + "location": { + "name": "Milford Sound, New Zealand" + }, "position": { "latitude": -44.6301, "longitude": 167.8901 diff --git a/fixtures/invalid/avg-depth-exceeds-max.divejson b/fixtures/invalid/avg-depth-exceeds-max.divejson index 3f7a0ac..dd6f4b6 100644 --- a/fixtures/invalid/avg-depth-exceeds-max.divejson +++ b/fixtures/invalid/avg-depth-exceeds-max.divejson @@ -278,7 +278,7 @@ "ends_on": "2026-07-04", "location": { "name": "Milford Sound", - "display_name": "Milford Sound / Piopiotahi, Southland, New Zealand", + "full_name": "Milford Sound / Piopiotahi, Southland, New Zealand", "position": { "latitude": -44.6414, "longitude": 167.8974 @@ -341,7 +341,9 @@ { "uuid": "0198a6f0-1111-7040-8000-000000000040", "name": "Harrys Wall", - "location": "Milford Sound, New Zealand", + "location": { + "name": "Milford Sound, New Zealand" + }, "position": { "latitude": -44.6301, "longitude": 167.8901 diff --git a/fixtures/invalid/bad-version.divejson b/fixtures/invalid/bad-version.divejson index 19b71b6..ac4d5f1 100644 --- a/fixtures/invalid/bad-version.divejson +++ b/fixtures/invalid/bad-version.divejson @@ -278,7 +278,7 @@ "ends_on": "2026-07-04", "location": { "name": "Milford Sound", - "display_name": "Milford Sound / Piopiotahi, Southland, New Zealand", + "full_name": "Milford Sound / Piopiotahi, Southland, New Zealand", "position": { "latitude": -44.6414, "longitude": 167.8974 @@ -341,7 +341,9 @@ { "uuid": "0198a6f0-1111-7040-8000-000000000040", "name": "Harrys Wall", - "location": "Milford Sound, New Zealand", + "location": { + "name": "Milford Sound, New Zealand" + }, "position": { "latitude": -44.6301, "longitude": 167.8901 diff --git a/fixtures/invalid/bbox-missing-corner.divejson b/fixtures/invalid/bbox-missing-corner.divejson index 5d87db1..d5853f6 100644 --- a/fixtures/invalid/bbox-missing-corner.divejson +++ b/fixtures/invalid/bbox-missing-corner.divejson @@ -278,7 +278,7 @@ "ends_on": "2026-07-04", "location": { "name": "Milford Sound", - "display_name": "Milford Sound / Piopiotahi, Southland, New Zealand", + "full_name": "Milford Sound / Piopiotahi, Southland, New Zealand", "position": { "latitude": -44.6414, "longitude": 167.8974 @@ -340,7 +340,9 @@ { "uuid": "0198a6f0-1111-7040-8000-000000000040", "name": "Harrys Wall", - "location": "Milford Sound, New Zealand", + "location": { + "name": "Milford Sound, New Zealand" + }, "position": { "latitude": -44.6301, "longitude": 167.8901 diff --git a/fixtures/invalid/bbox-south-exceeds-north.divejson b/fixtures/invalid/bbox-south-exceeds-north.divejson index fbaca11..14d0fc0 100644 --- a/fixtures/invalid/bbox-south-exceeds-north.divejson +++ b/fixtures/invalid/bbox-south-exceeds-north.divejson @@ -278,7 +278,7 @@ "ends_on": "2026-07-04", "location": { "name": "Milford Sound", - "display_name": "Milford Sound / Piopiotahi, Southland, New Zealand", + "full_name": "Milford Sound / Piopiotahi, Southland, New Zealand", "position": { "latitude": -44.6414, "longitude": 167.8974 @@ -341,7 +341,9 @@ { "uuid": "0198a6f0-1111-7040-8000-000000000040", "name": "Harrys Wall", - "location": "Milford Sound, New Zealand", + "location": { + "name": "Milford Sound, New Zealand" + }, "position": { "latitude": -44.6301, "longitude": 167.8901 diff --git a/fixtures/invalid/bbox-without-position.divejson b/fixtures/invalid/bbox-without-position.divejson index 5c126f7..7d2bc72 100644 --- a/fixtures/invalid/bbox-without-position.divejson +++ b/fixtures/invalid/bbox-without-position.divejson @@ -278,7 +278,7 @@ "ends_on": "2026-07-04", "location": { "name": "Milford Sound", - "display_name": "Milford Sound / Piopiotahi, Southland, New Zealand", + "full_name": "Milford Sound / Piopiotahi, Southland, New Zealand", "position": { "latitude": -44.6414, "longitude": 167.8974 @@ -337,7 +337,9 @@ { "uuid": "0198a6f0-1111-7040-8000-000000000040", "name": "Harrys Wall", - "location": "Milford Sound, New Zealand", + "location": { + "name": "Milford Sound, New Zealand" + }, "position": { "latitude": -44.6301, "longitude": 167.8901 diff --git a/fixtures/invalid/channel-length-mismatch.divejson b/fixtures/invalid/channel-length-mismatch.divejson index 23b1948..3fdff28 100644 --- a/fixtures/invalid/channel-length-mismatch.divejson +++ b/fixtures/invalid/channel-length-mismatch.divejson @@ -279,7 +279,7 @@ "ends_on": "2026-07-04", "location": { "name": "Milford Sound", - "display_name": "Milford Sound / Piopiotahi, Southland, New Zealand", + "full_name": "Milford Sound / Piopiotahi, Southland, New Zealand", "position": { "latitude": -44.6414, "longitude": 167.8974 @@ -342,7 +342,9 @@ { "uuid": "0198a6f0-1111-7040-8000-000000000040", "name": "Harrys Wall", - "location": "Milford Sound, New Zealand", + "location": { + "name": "Milford Sound, New Zealand" + }, "position": { "latitude": -44.6301, "longitude": 167.8901 diff --git a/fixtures/invalid/course-agency-other-without-agency.divejson b/fixtures/invalid/course-agency-other-without-agency.divejson index e9963b0..5c64175 100644 --- a/fixtures/invalid/course-agency-other-without-agency.divejson +++ b/fixtures/invalid/course-agency-other-without-agency.divejson @@ -461,7 +461,7 @@ "ends_on": "2026-07-04", "location": { "name": "Milford Sound", - "display_name": "Milford Sound / Piopiotahi, Southland, New Zealand", + "full_name": "Milford Sound / Piopiotahi, Southland, New Zealand", "position": { "latitude": -44.6414, "longitude": 167.8974 @@ -534,7 +534,9 @@ { "uuid": "0198a6f0-1111-7040-8000-000000000040", "name": "Harrys Wall", - "location": "Milford Sound, New Zealand", + "location": { + "name": "Milford Sound, New Zealand" + }, "position": { "latitude": -44.6301, "longitude": 167.8901 diff --git a/fixtures/invalid/course-dates-reversed.divejson b/fixtures/invalid/course-dates-reversed.divejson index 6df6696..997d662 100644 --- a/fixtures/invalid/course-dates-reversed.divejson +++ b/fixtures/invalid/course-dates-reversed.divejson @@ -278,7 +278,7 @@ "ends_on": "2026-07-04", "location": { "name": "Milford Sound", - "display_name": "Milford Sound / Piopiotahi, Southland, New Zealand", + "full_name": "Milford Sound / Piopiotahi, Southland, New Zealand", "position": { "latitude": -44.6414, "longitude": 167.8974 @@ -341,7 +341,9 @@ { "uuid": "0198a6f0-1111-7040-8000-000000000040", "name": "Harrys Wall", - "location": "Milford Sound, New Zealand", + "location": { + "name": "Milford Sound, New Zealand" + }, "position": { "latitude": -44.6301, "longitude": 167.8901 diff --git a/fixtures/invalid/dangling-reference.divejson b/fixtures/invalid/dangling-reference.divejson index b717eb8..9e12759 100644 --- a/fixtures/invalid/dangling-reference.divejson +++ b/fixtures/invalid/dangling-reference.divejson @@ -279,7 +279,7 @@ "ends_on": "2026-07-04", "location": { "name": "Milford Sound", - "display_name": "Milford Sound / Piopiotahi, Southland, New Zealand", + "full_name": "Milford Sound / Piopiotahi, Southland, New Zealand", "position": { "latitude": -44.6414, "longitude": 167.8974 @@ -342,7 +342,9 @@ { "uuid": "0198a6f0-1111-7040-8000-000000000040", "name": "Harrys Wall", - "location": "Milford Sound, New Zealand", + "location": { + "name": "Milford Sound, New Zealand" + }, "position": { "latitude": -44.6301, "longitude": 167.8901 diff --git a/fixtures/invalid/deco-model-gf-order.divejson b/fixtures/invalid/deco-model-gf-order.divejson index 3a836cb..0783dbf 100644 --- a/fixtures/invalid/deco-model-gf-order.divejson +++ b/fixtures/invalid/deco-model-gf-order.divejson @@ -285,7 +285,7 @@ "ends_on": "2026-07-04", "location": { "name": "Milford Sound", - "display_name": "Milford Sound / Piopiotahi, Southland, New Zealand", + "full_name": "Milford Sound / Piopiotahi, Southland, New Zealand", "position": { "latitude": -44.6414, "longitude": 167.8974 @@ -348,7 +348,9 @@ { "uuid": "0198a6f0-1111-7040-8000-000000000040", "name": "Harrys Wall", - "location": "Milford Sound, New Zealand", + "location": { + "name": "Milford Sound, New Zealand" + }, "position": { "latitude": -44.6301, "longitude": 167.8901 diff --git a/fixtures/invalid/device-empty-member.divejson b/fixtures/invalid/device-empty-member.divejson index 5e1727f..0ca4b58 100644 --- a/fixtures/invalid/device-empty-member.divejson +++ b/fixtures/invalid/device-empty-member.divejson @@ -291,7 +291,7 @@ "ends_on": "2026-07-04", "location": { "name": "Milford Sound", - "display_name": "Milford Sound / Piopiotahi, Southland, New Zealand", + "full_name": "Milford Sound / Piopiotahi, Southland, New Zealand", "position": { "latitude": -44.6414, "longitude": 167.8974 @@ -354,7 +354,9 @@ { "uuid": "0198a6f0-1111-7040-8000-000000000040", "name": "Harrys Wall", - "location": "Milford Sound, New Zealand", + "location": { + "name": "Milford Sound, New Zealand" + }, "position": { "latitude": -44.6301, "longitude": 167.8901 diff --git a/fixtures/invalid/dive-profile-outside-recording.divejson b/fixtures/invalid/dive-profile-outside-recording.divejson index 27ad3bc..9c2cde1 100644 --- a/fixtures/invalid/dive-profile-outside-recording.divejson +++ b/fixtures/invalid/dive-profile-outside-recording.divejson @@ -279,7 +279,7 @@ "ends_on": "2026-07-04", "location": { "name": "Milford Sound", - "display_name": "Milford Sound / Piopiotahi, Southland, New Zealand", + "full_name": "Milford Sound / Piopiotahi, Southland, New Zealand", "position": { "latitude": -44.6414, "longitude": 167.8974 @@ -342,7 +342,9 @@ { "uuid": "0198a6f0-1111-7040-8000-000000000040", "name": "Harrys Wall", - "location": "Milford Sound, New Zealand", + "location": { + "name": "Milford Sound, New Zealand" + }, "position": { "latitude": -44.6301, "longitude": 167.8901 diff --git a/fixtures/invalid/duplicate-file-uuid-across-recordings.divejson b/fixtures/invalid/duplicate-file-uuid-across-recordings.divejson index 00b255f..2113f2e 100644 --- a/fixtures/invalid/duplicate-file-uuid-across-recordings.divejson +++ b/fixtures/invalid/duplicate-file-uuid-across-recordings.divejson @@ -315,7 +315,7 @@ "ends_on": "2026-07-04", "location": { "name": "Milford Sound", - "display_name": "Milford Sound / Piopiotahi, Southland, New Zealand", + "full_name": "Milford Sound / Piopiotahi, Southland, New Zealand", "position": { "latitude": -44.6414, "longitude": 167.8974 @@ -378,7 +378,9 @@ { "uuid": "0198a6f0-1111-7040-8000-000000000040", "name": "Harrys Wall", - "location": "Milford Sound, New Zealand", + "location": { + "name": "Milford Sound, New Zealand" + }, "position": { "latitude": -44.6301, "longitude": 167.8901 diff --git a/fixtures/invalid/duplicate-uuid.divejson b/fixtures/invalid/duplicate-uuid.divejson index be3ec0d..b013874 100644 --- a/fixtures/invalid/duplicate-uuid.divejson +++ b/fixtures/invalid/duplicate-uuid.divejson @@ -278,7 +278,7 @@ "ends_on": "2026-07-04", "location": { "name": "Milford Sound", - "display_name": "Milford Sound / Piopiotahi, Southland, New Zealand", + "full_name": "Milford Sound / Piopiotahi, Southland, New Zealand", "position": { "latitude": -44.6414, "longitude": 167.8974 @@ -341,7 +341,9 @@ { "uuid": "0198a6f0-1111-7040-8000-000000000040", "name": "Harrys Wall", - "location": "Milford Sound, New Zealand", + "location": { + "name": "Milford Sound, New Zealand" + }, "position": { "latitude": -44.6301, "longitude": 167.8901 diff --git a/fixtures/invalid/event-without-type-or-label.divejson b/fixtures/invalid/event-without-type-or-label.divejson index af98d05..c764f19 100644 --- a/fixtures/invalid/event-without-type-or-label.divejson +++ b/fixtures/invalid/event-without-type-or-label.divejson @@ -277,7 +277,7 @@ "ends_on": "2026-07-04", "location": { "name": "Milford Sound", - "display_name": "Milford Sound / Piopiotahi, Southland, New Zealand", + "full_name": "Milford Sound / Piopiotahi, Southland, New Zealand", "position": { "latitude": -44.6414, "longitude": 167.8974 @@ -340,7 +340,9 @@ { "uuid": "0198a6f0-1111-7040-8000-000000000040", "name": "Harrys Wall", - "location": "Milford Sound, New Zealand", + "location": { + "name": "Milford Sound, New Zealand" + }, "position": { "latitude": -44.6301, "longitude": 167.8901 diff --git a/fixtures/invalid/missing-format.divejson b/fixtures/invalid/missing-format.divejson index a536813..2b067d1 100644 --- a/fixtures/invalid/missing-format.divejson +++ b/fixtures/invalid/missing-format.divejson @@ -277,7 +277,7 @@ "ends_on": "2026-07-04", "location": { "name": "Milford Sound", - "display_name": "Milford Sound / Piopiotahi, Southland, New Zealand", + "full_name": "Milford Sound / Piopiotahi, Southland, New Zealand", "position": { "latitude": -44.6414, "longitude": 167.8974 @@ -340,7 +340,9 @@ { "uuid": "0198a6f0-1111-7040-8000-000000000040", "name": "Harrys Wall", - "location": "Milford Sound, New Zealand", + "location": { + "name": "Milford Sound, New Zealand" + }, "position": { "latitude": -44.6301, "longitude": 167.8901 diff --git a/fixtures/invalid/naive-exported-at.divejson b/fixtures/invalid/naive-exported-at.divejson index 6c0dc9a..074c75a 100644 --- a/fixtures/invalid/naive-exported-at.divejson +++ b/fixtures/invalid/naive-exported-at.divejson @@ -278,7 +278,7 @@ "ends_on": "2026-07-04", "location": { "name": "Milford Sound", - "display_name": "Milford Sound / Piopiotahi, Southland, New Zealand", + "full_name": "Milford Sound / Piopiotahi, Southland, New Zealand", "position": { "latitude": -44.6414, "longitude": 167.8974 @@ -341,7 +341,9 @@ { "uuid": "0198a6f0-1111-7040-8000-000000000040", "name": "Harrys Wall", - "location": "Milford Sound, New Zealand", + "location": { + "name": "Milford Sound, New Zealand" + }, "position": { "latitude": -44.6301, "longitude": 167.8901 diff --git a/fixtures/invalid/non-increasing-samples.divejson b/fixtures/invalid/non-increasing-samples.divejson index 49d1958..ac9f9a9 100644 --- a/fixtures/invalid/non-increasing-samples.divejson +++ b/fixtures/invalid/non-increasing-samples.divejson @@ -286,7 +286,7 @@ "ends_on": "2026-07-04", "location": { "name": "Milford Sound", - "display_name": "Milford Sound / Piopiotahi, Southland, New Zealand", + "full_name": "Milford Sound / Piopiotahi, Southland, New Zealand", "position": { "latitude": -44.6414, "longitude": 167.8974 @@ -349,7 +349,9 @@ { "uuid": "0198a6f0-1111-7040-8000-000000000040", "name": "Harrys Wall", - "location": "Milford Sound, New Zealand", + "location": { + "name": "Milford Sound, New Zealand" + }, "position": { "latitude": -44.6301, "longitude": 167.8901 diff --git a/fixtures/invalid/null-member.divejson b/fixtures/invalid/null-member.divejson index 6817e49..dac4ebe 100644 --- a/fixtures/invalid/null-member.divejson +++ b/fixtures/invalid/null-member.divejson @@ -278,7 +278,7 @@ "ends_on": "2026-07-04", "location": { "name": "Milford Sound", - "display_name": "Milford Sound / Piopiotahi, Southland, New Zealand", + "full_name": "Milford Sound / Piopiotahi, Southland, New Zealand", "position": { "latitude": -44.6414, "longitude": 167.8974 @@ -341,7 +341,9 @@ { "uuid": "0198a6f0-1111-7040-8000-000000000040", "name": "Harrys Wall", - "location": "Milford Sound, New Zealand", + "location": { + "name": "Milford Sound, New Zealand" + }, "position": { "latitude": -44.6301, "longitude": 167.8901 diff --git a/fixtures/invalid/oxygen-helium-sum.divejson b/fixtures/invalid/oxygen-helium-sum.divejson index 79363b2..ba80d76 100644 --- a/fixtures/invalid/oxygen-helium-sum.divejson +++ b/fixtures/invalid/oxygen-helium-sum.divejson @@ -278,7 +278,7 @@ "ends_on": "2026-07-04", "location": { "name": "Milford Sound", - "display_name": "Milford Sound / Piopiotahi, Southland, New Zealand", + "full_name": "Milford Sound / Piopiotahi, Southland, New Zealand", "position": { "latitude": -44.6414, "longitude": 167.8974 @@ -341,7 +341,9 @@ { "uuid": "0198a6f0-1111-7040-8000-000000000040", "name": "Harrys Wall", - "location": "Milford Sound, New Zealand", + "location": { + "name": "Milford Sound, New Zealand" + }, "position": { "latitude": -44.6301, "longitude": 167.8901 diff --git a/fixtures/invalid/position-incomplete.divejson b/fixtures/invalid/position-incomplete.divejson index 9cee508..2e3658f 100644 --- a/fixtures/invalid/position-incomplete.divejson +++ b/fixtures/invalid/position-incomplete.divejson @@ -277,7 +277,7 @@ "ends_on": "2026-07-04", "location": { "name": "Milford Sound", - "display_name": "Milford Sound / Piopiotahi, Southland, New Zealand", + "full_name": "Milford Sound / Piopiotahi, Southland, New Zealand", "position": { "latitude": -44.6414, "longitude": 167.8974 @@ -340,7 +340,9 @@ { "uuid": "0198a6f0-1111-7040-8000-000000000040", "name": "Harrys Wall", - "location": "Milford Sound, New Zealand", + "location": { + "name": "Milford Sound, New Zealand" + }, "position": { "latitude": -44.6301, "longitude": 167.8901 diff --git a/fixtures/invalid/pressure-order.divejson b/fixtures/invalid/pressure-order.divejson index f7be772..f72f717 100644 --- a/fixtures/invalid/pressure-order.divejson +++ b/fixtures/invalid/pressure-order.divejson @@ -278,7 +278,7 @@ "ends_on": "2026-07-04", "location": { "name": "Milford Sound", - "display_name": "Milford Sound / Piopiotahi, Southland, New Zealand", + "full_name": "Milford Sound / Piopiotahi, Southland, New Zealand", "position": { "latitude": -44.6414, "longitude": 167.8974 @@ -341,7 +341,9 @@ { "uuid": "0198a6f0-1111-7040-8000-000000000040", "name": "Harrys Wall", - "location": "Milford Sound, New Zealand", + "location": { + "name": "Milford Sound, New Zealand" + }, "position": { "latitude": -44.6301, "longitude": 167.8901 diff --git a/fixtures/invalid/profile-duration-short.divejson b/fixtures/invalid/profile-duration-short.divejson index 751e51b..c0ba36c 100644 --- a/fixtures/invalid/profile-duration-short.divejson +++ b/fixtures/invalid/profile-duration-short.divejson @@ -278,7 +278,7 @@ "ends_on": "2026-07-04", "location": { "name": "Milford Sound", - "display_name": "Milford Sound / Piopiotahi, Southland, New Zealand", + "full_name": "Milford Sound / Piopiotahi, Southland, New Zealand", "position": { "latitude": -44.6414, "longitude": 167.8974 @@ -341,7 +341,9 @@ { "uuid": "0198a6f0-1111-7040-8000-000000000040", "name": "Harrys Wall", - "location": "Milford Sound, New Zealand", + "location": { + "name": "Milford Sound, New Zealand" + }, "position": { "latitude": -44.6301, "longitude": 167.8901 diff --git a/fixtures/invalid/recording-without-content.divejson b/fixtures/invalid/recording-without-content.divejson index 1cbbaf0..a393f86 100644 --- a/fixtures/invalid/recording-without-content.divejson +++ b/fixtures/invalid/recording-without-content.divejson @@ -298,7 +298,7 @@ "ends_on": "2026-07-04", "location": { "name": "Milford Sound", - "display_name": "Milford Sound / Piopiotahi, Southland, New Zealand", + "full_name": "Milford Sound / Piopiotahi, Southland, New Zealand", "position": { "latitude": -44.6414, "longitude": 167.8974 @@ -361,7 +361,9 @@ { "uuid": "0198a6f0-1111-7040-8000-000000000040", "name": "Harrys Wall", - "location": "Milford Sound, New Zealand", + "location": { + "name": "Milford Sound, New Zealand" + }, "position": { "latitude": -44.6301, "longitude": 167.8901 diff --git a/fixtures/invalid/site-bbox-south-exceeds-north.divejson b/fixtures/invalid/site-bbox-south-exceeds-north.divejson new file mode 100644 index 0000000..1702f96 --- /dev/null +++ b/fixtures/invalid/site-bbox-south-exceeds-north.divejson @@ -0,0 +1,484 @@ +{ + "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", + "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": [ + { + "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 + } + ], + "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" + } + ], + "extensions": { + "com.example.deco": { + "model": "ZHL-16C", + "gf": [ + 30, + 70 + ] + } + } + } + } + ], + "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." + } + ], + "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.58, + "north": -44.7, + "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/species-no-identity.divejson b/fixtures/invalid/species-no-identity.divejson index eb1bf23..e035cb3 100644 --- a/fixtures/invalid/species-no-identity.divejson +++ b/fixtures/invalid/species-no-identity.divejson @@ -278,7 +278,7 @@ "ends_on": "2026-07-04", "location": { "name": "Milford Sound", - "display_name": "Milford Sound / Piopiotahi, Southland, New Zealand", + "full_name": "Milford Sound / Piopiotahi, Southland, New Zealand", "position": { "latitude": -44.6414, "longitude": 167.8974 @@ -341,7 +341,9 @@ { "uuid": "0198a6f0-1111-7040-8000-000000000040", "name": "Harrys Wall", - "location": "Milford Sound, New Zealand", + "location": { + "name": "Milford Sound, New Zealand" + }, "position": { "latitude": -44.6301, "longitude": 167.8901 diff --git a/fixtures/invalid/trailing-newline-datetime.divejson b/fixtures/invalid/trailing-newline-datetime.divejson index f39d80c..cc1b22a 100644 --- a/fixtures/invalid/trailing-newline-datetime.divejson +++ b/fixtures/invalid/trailing-newline-datetime.divejson @@ -278,7 +278,7 @@ "ends_on": "2026-07-04", "location": { "name": "Milford Sound", - "display_name": "Milford Sound / Piopiotahi, Southland, New Zealand", + "full_name": "Milford Sound / Piopiotahi, Southland, New Zealand", "position": { "latitude": -44.6414, "longitude": 167.8974 @@ -341,7 +341,9 @@ { "uuid": "0198a6f0-1111-7040-8000-000000000040", "name": "Harrys Wall", - "location": "Milford Sound, New Zealand", + "location": { + "name": "Milford Sound, New Zealand" + }, "position": { "latitude": -44.6301, "longitude": 167.8901 diff --git a/fixtures/invalid/trip-dates-reversed.divejson b/fixtures/invalid/trip-dates-reversed.divejson index 978c875..fb1b58d 100644 --- a/fixtures/invalid/trip-dates-reversed.divejson +++ b/fixtures/invalid/trip-dates-reversed.divejson @@ -278,7 +278,7 @@ "ends_on": "2026-06-28", "location": { "name": "Milford Sound", - "display_name": "Milford Sound / Piopiotahi, Southland, New Zealand", + "full_name": "Milford Sound / Piopiotahi, Southland, New Zealand", "position": { "latitude": -44.6414, "longitude": 167.8974 @@ -341,7 +341,9 @@ { "uuid": "0198a6f0-1111-7040-8000-000000000040", "name": "Harrys Wall", - "location": "Milford Sound, New Zealand", + "location": { + "name": "Milford Sound, New Zealand" + }, "position": { "latitude": -44.6301, "longitude": 167.8901 diff --git a/fixtures/invalid/undefined-member.divejson b/fixtures/invalid/undefined-member.divejson index 8013fb8..1a470cc 100644 --- a/fixtures/invalid/undefined-member.divejson +++ b/fixtures/invalid/undefined-member.divejson @@ -279,7 +279,7 @@ "ends_on": "2026-07-04", "location": { "name": "Milford Sound", - "display_name": "Milford Sound / Piopiotahi, Southland, New Zealand", + "full_name": "Milford Sound / Piopiotahi, Southland, New Zealand", "position": { "latitude": -44.6414, "longitude": 167.8974 @@ -342,7 +342,9 @@ { "uuid": "0198a6f0-1111-7040-8000-000000000040", "name": "Harrys Wall", - "location": "Milford Sound, New Zealand", + "location": { + "name": "Milford Sound, New Zealand" + }, "position": { "latitude": -44.6301, "longitude": 167.8901 diff --git a/fixtures/uddf/divelogs.divejson b/fixtures/uddf/divelogs.divejson index 674c8ae..eb9d91a 100644 --- a/fixtures/uddf/divelogs.divejson +++ b/fixtures/uddf/divelogs.divejson @@ -84,7 +84,9 @@ { "uuid": "3c7a70ee-b290-560b-8be1-d4919397c58d", "name": "El Puertito", - "location": "Tenerife" + "location": { + "name": "Tenerife" + } } ], "extensions": { diff --git a/fixtures/uddf/legacy-writer.divejson b/fixtures/uddf/legacy-writer.divejson index 9179a41..1532390 100644 --- a/fixtures/uddf/legacy-writer.divejson +++ b/fixtures/uddf/legacy-writer.divejson @@ -48,7 +48,9 @@ { "uuid": "ea58bb9e-dd47-5727-8360-47261cb768bd", "name": "Um El Faroud", - "location": "Wied iz-Zurrieq, Malta", + "location": { + "name": "Wied iz-Zurrieq, Malta" + }, "position": { "latitude": 35.8225, "longitude": 14.4392 diff --git a/fixtures/uddf/mix-only-cylinder.divejson b/fixtures/uddf/mix-only-cylinder.divejson index f2d8085..459a2ad 100644 --- a/fixtures/uddf/mix-only-cylinder.divejson +++ b/fixtures/uddf/mix-only-cylinder.divejson @@ -48,7 +48,9 @@ { "uuid": "b46b2efb-112c-5fc3-8566-50aee4c46ce8", "name": "Condesito Wreck", - "location": "Las Galletas, Tenerife", + "location": { + "name": "Las Galletas, Tenerife" + }, "position": { "latitude": 28.001389, "longitude": -16.653611 diff --git a/fixtures/uddf/opendiving.divejson b/fixtures/uddf/opendiving.divejson index 21518fe..203dc84 100644 --- a/fixtures/uddf/opendiving.divejson +++ b/fixtures/uddf/opendiving.divejson @@ -154,7 +154,7 @@ "ends_on": "2026-04-25", "location": { "name": "Red Sea Liveaboard", - "display_name": "Sha'ab Ali, Egypt" + "full_name": "Sha'ab Ali, Egypt" } } ], @@ -165,7 +165,9 @@ { "uuid": "0198a6f0-3333-7001-8000-000000000001", "name": "SS Thistlegorm", - "location": "Sha'ab Ali, Red Sea", + "location": { + "name": "Sha'ab Ali, Red Sea" + }, "position": { "latitude": 27.813611, "longitude": 33.920833 diff --git a/fixtures/uddf/subsurface.divejson b/fixtures/uddf/subsurface.divejson index b6863ec..e64e305 100644 --- a/fixtures/uddf/subsurface.divejson +++ b/fixtures/uddf/subsurface.divejson @@ -116,12 +116,16 @@ { "uuid": "a2f279fa-7d08-5be8-b6a0-8360779ca203", "name": "Small Brother", - "location": "Small Brother" + "location": { + "name": "Small Brother" + } }, { "uuid": "0e7d38f0-c092-5f6e-a0d9-e89514563f95", "name": "SS Thistlegorm", - "location": "SS Thistlegorm" + "location": { + "name": "SS Thistlegorm" + } } ], "extensions": { diff --git a/fixtures/valid/demo-logbook.divejson b/fixtures/valid/demo-logbook.divejson index 3c23ab7..0d9f0d1 100644 --- a/fixtures/valid/demo-logbook.divejson +++ b/fixtures/valid/demo-logbook.divejson @@ -4022,35 +4022,45 @@ { "uuid": "019fec36-b8bf-7289-9959-05cd3316f256", "name": "Condesito Wreck", - "location": "Las Galletas, Tenerife", + "location": { + "name": "Las Galletas, Tenerife" + }, "notes": "Broken cargo wreck at 18-21 m.", "created_at": "2026-08-10T15:07:17.055390Z" }, { "uuid": "019fec36-b8b8-7cc9-a4b9-ede85f907c94", "name": "El Puertito", - "location": "Tenerife", + "location": { + "name": "Tenerife" + }, "notes": "Shallow bay, resident green turtles.", "created_at": "2026-08-10T15:07:17.048128Z" }, { "uuid": "019fec36-b8a8-734b-84c1-66963865e729", "name": "Ras Mohammed - Shark & Yolanda", - "location": "Sinai, Red Sea", + "location": { + "name": "Sinai, Red Sea" + }, "notes": "Drift along two pinnacles, toilets at the Yolanda wreck.", "created_at": "2026-08-10T15:07:17.032718Z" }, { "uuid": "019fec36-b8b1-7af6-936f-d40e7cc3cf36", "name": "Small Brother", - "location": "Brothers Islands, Red Sea", + "location": { + "name": "Brothers Islands, Red Sea" + }, "notes": "Steep walls, chance of thresher sharks.", "created_at": "2026-08-10T15:07:17.041073Z" }, { "uuid": "019fec36-b89a-773e-b886-44717fc4d19f", "name": "SS Thistlegorm", - "location": "Sha'ab Ali, Red Sea", + "location": { + "name": "Sha'ab Ali, Red Sea" + }, "notes": "WWII wreck at ~30 m. Motorbikes in hold no. 2.", "created_at": "2026-08-10T15:07:17.018941Z" } diff --git a/fixtures/valid/technical-dive.divejson b/fixtures/valid/technical-dive.divejson index 260b81b..59a6719 100644 --- a/fixtures/valid/technical-dive.divejson +++ b/fixtures/valid/technical-dive.divejson @@ -461,7 +461,7 @@ "ends_on": "2026-07-04", "location": { "name": "Milford Sound", - "display_name": "Milford Sound / Piopiotahi, Southland, New Zealand", + "full_name": "Milford Sound / Piopiotahi, Southland, New Zealand", "position": { "latitude": -44.6414, "longitude": 167.8974 @@ -533,7 +533,20 @@ { "uuid": "0198a6f0-1111-7040-8000-000000000040", "name": "Harrys Wall", - "location": "Milford Sound, New Zealand", + "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 diff --git a/fixtures/valid/two-computers.divejson b/fixtures/valid/two-computers.divejson index 524e465..f19b101 100644 --- a/fixtures/valid/two-computers.divejson +++ b/fixtures/valid/two-computers.divejson @@ -175,7 +175,9 @@ { "uuid": "0198a6f0-2222-7140-8000-000000000140", "name": "House Reef", - "location": "Dahab, Egypt", + "location": { + "name": "Dahab, Egypt" + }, "position": { "latitude": 28.4965, "longitude": 34.5168 diff --git a/fixtures/write/uddf/opendiving.divejson b/fixtures/write/uddf/opendiving.divejson index 35ad91b..7659565 100644 --- a/fixtures/write/uddf/opendiving.divejson +++ b/fixtures/write/uddf/opendiving.divejson @@ -157,7 +157,7 @@ "ends_on": "2026-04-25", "location": { "name": "Red Sea Liveaboard", - "display_name": "Sha'ab Ali, Egypt" + "full_name": "Sha'ab Ali, Egypt" } } ], @@ -168,7 +168,9 @@ { "uuid": "0198a6f0-3333-7001-8000-000000000001", "name": "SS Thistlegorm", - "location": "Sha'ab Ali, Red Sea", + "location": { + "name": "Sha'ab Ali, Red Sea" + }, "position": { "latitude": 27.813611, "longitude": 33.920833 diff --git a/fixtures/write/uddf/technical-dive.divejson b/fixtures/write/uddf/technical-dive.divejson index 260b81b..59a6719 100644 --- a/fixtures/write/uddf/technical-dive.divejson +++ b/fixtures/write/uddf/technical-dive.divejson @@ -461,7 +461,7 @@ "ends_on": "2026-07-04", "location": { "name": "Milford Sound", - "display_name": "Milford Sound / Piopiotahi, Southland, New Zealand", + "full_name": "Milford Sound / Piopiotahi, Southland, New Zealand", "position": { "latitude": -44.6414, "longitude": 167.8974 @@ -533,7 +533,20 @@ { "uuid": "0198a6f0-1111-7040-8000-000000000040", "name": "Harrys Wall", - "location": "Milford Sound, New Zealand", + "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 diff --git a/schema/1.0/divejson.schema.json b/schema/1.0/divejson.schema.json index afe6506..1c1777a 100644 --- a/schema/1.0/divejson.schema.json +++ b/schema/1.0/divejson.schema.json @@ -302,16 +302,16 @@ "properties": { "starts_on": { "$ref": "#/$defs/date" }, "ends_on": { "$ref": "#/$defs/date" }, - "location": { "$ref": "#/$defs/trip_location" }, + "location": { "$ref": "#/$defs/location" }, "extensions": { "$ref": "#/$defs/extensions" } }, "additionalProperties": false }, - "trip_location": { + "location": { "type": "object", "properties": { "name": { "type": "string", "minLength": 1, "maxLength": 255 }, - "display_name": { "type": "string", "maxLength": 512 }, + "full_name": { "type": "string", "maxLength": 512 }, "position": { "$ref": "#/$defs/position" }, "bbox": { "$ref": "#/$defs/bounding_box" }, "extensions": { "$ref": "#/$defs/extensions" } @@ -363,7 +363,7 @@ "properties": { "uuid": { "$ref": "#/$defs/uuid" }, "name": { "type": "string", "minLength": 1, "maxLength": 255 }, - "location": { "type": "string", "maxLength": 255 }, + "location": { "$ref": "#/$defs/location" }, "position": { "$ref": "#/$defs/position" }, "notes": { "$ref": "#/$defs/notes" }, "created_at": { "$ref": "#/$defs/date_time" }, diff --git a/tests/test_ssrf_fixtures.py b/tests/test_ssrf_fixtures.py index 78cab51..6bee365 100644 --- a/tests/test_ssrf_fixtures.py +++ b/tests/test_ssrf_fixtures.py @@ -151,7 +151,7 @@ def test_where_the_two_readings_differ_it_is_the_exporters_doing() -> None: "dives[]/bottom_temperature", "dives[]/cns_end", "dives[]/otu_end", - "sites[]/location", + "sites[]/location/name", "dives[]/recordings[]/device/model", } @@ -163,7 +163,8 @@ def test_where_the_two_readings_differ_it_is_the_exporters_doing() -> None: assert first_ssrf["bottom_temperature"] == 22.4 and "bottom_temperature" not in first_uddf assert first_ssrf["cns_end"] == 11.0 and "cns_end" not in first_uddf assert first_ssrf["otu_end"] == 31.0 and "otu_end" not in first_uddf - assert "location" not in ssrf["sites"][0] and uddf["sites"][0]["location"] == uddf["sites"][0]["name"] + assert "location" not in ssrf["sites"][0] + assert uddf["sites"][0]["location"] == {"name": uddf["sites"][0]["name"]} assert (ssrf["dives"][0]["recordings"][0]["device"] or {}).get("model") == "Open Diving" assert "device" not in uddf["dives"][0]["recordings"][0] diff --git a/tests/test_uddf_parsing.py b/tests/test_uddf_parsing.py index 171b583..96d3374 100644 --- a/tests/test_uddf_parsing.py +++ b/tests/test_uddf_parsing.py @@ -139,7 +139,7 @@ def test_an_empty_element_is_absent_rather_than_zero() -> None: header = "Small BrotherEgypt" site = convert(one_dive(STARTED_AT, header=header)).document["sites"][0] assert "position" not in site - assert site["location"] == "Egypt" + assert site["location"] == {"name": "Egypt"} # -- nothing invented ---------------------------------------------------------------- diff --git a/tests/test_uddf_write_fixtures.py b/tests/test_uddf_write_fixtures.py index 1c12ee7..8b706de 100644 --- a/tests/test_uddf_write_fixtures.py +++ b/tests/test_uddf_write_fixtures.py @@ -80,6 +80,13 @@ "trips/0/created_at", "trips/0/parts/0/location/bbox", "trips/0/parts/1/location/bbox", + # A site's locality entire but for its name: its `` is the site's own, so + # `` is the one slot the place has and `location.name` + # takes it. ``'s coordinates are the **site's** pin (§6.10), and + # UDDF has no box element — three losses off one record where a part loses one. + "sites/0/location/full_name", + "sites/0/location/position", + "sites/0/location/bbox", "dives/0/water_type", "dives/0/cns_start", "dives/0/cns_end", @@ -102,7 +109,7 @@ # `equipmentPieceType` carries no firmware element at all (§6.4b). "dives/0/recordings/0/device/firmware", *(f"dives/0/cylinders/{index}/{member}" for index in range(4) for member in ("role", "usage")), - # A place UDDF will not record without a display name for it. + # A place UDDF will not record without a full name for it. "trips/0/parts/1/location/position", # An empty note, which reads back as no note: `` and no `` # at all are the same file to every reader here. diff --git a/tests/test_uddf_writing.py b/tests/test_uddf_writing.py index 1d77290..5d0ffca 100644 --- a/tests/test_uddf_writing.py +++ b/tests/test_uddf_writing.py @@ -249,14 +249,59 @@ def test_a_site_with_a_location_carries_its_coordinates(schema) -> None: { "uuid": SITE_UUID, "name": "The Chimney", - "location": "Milford Sound", + "location": {"name": "Milford Sound"}, "position": {"latitude": -44.6301, "longitude": 167.8901}, } ] ) written(source, schema) assert read_back(source)["sites"][0]["position"] == {"latitude": -44.6301, "longitude": 167.8901} + assert messages(source, "sites/0") == [] and messages(source, "sites/0/location") == [] + + +def test_a_sites_locality_loses_everything_but_its_name_and_says_so(schema) -> None: + """Three members with nowhere to go, each named at the locality's own path. + + A site's `` is the site's own, so `` is the only slot the + place has and `location.name` takes it. The rest of §6.9 has none: ``'s + coordinates are the **site's** pin and not the locality's centre (§6.10), and UDDF has + no box element anywhere. + + **Where each note sits is half the assertion.** The trip part's own call reports at the + part's path, which is safe only because a part has no position of its own; mirrored onto + a site, a dropped locality centre would say "no slot for position" beside the + `` this writer just wrote from the site's pin — the confusion §6.10 forbids in + as many words. + """ + source = document( + sites=[ + { + "uuid": SITE_UUID, + "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}, + } + ] + ) + text = written(source, schema) + assert "Milford Sound, New Zealand" in text + assert "-44.6301" in text and "167.8974" not in text + assert messages(source, "sites/0") == [] + assert sorted(messages(source, "sites/0/location")) == [ + "UDDF has no slot for bbox; it is not written", + "UDDF has no slot for full_name; it is not written", + "UDDF has no slot for position; it is not written", + ] + + site = read_back(source)["sites"][0] + assert site["location"] == {"name": "Milford Sound, New Zealand"} + assert site["position"] == {"latitude": -44.6301, "longitude": 167.8901} def test_a_dive_numbered_zero_is_not_written(schema) -> None: From 26d2590850b0266be3d9bd0306960e9cf5fe8ae0 Mon Sep 17 00:00:00 2001 From: Aleksei Vesnin Date: Mon, 21 Sep 2026 19:55:44 +0300 Subject: [PATCH 2/2] chore: pin the specification at the merged commit The location change is on the specification's `main`, so `SPEC_REF` names that commit rather than the branch head it was written from and the vendored trees are re-copied from it. `schema/` and `fixtures/` are unchanged; `docs/ssrf-mapping.md` rewraps one paragraph. Co-Authored-By: Claude Opus 5 --- SPEC_REF | 2 +- docs/ssrf-mapping.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/SPEC_REF b/SPEC_REF index 02ee4fb..a040bb6 100644 --- a/SPEC_REF +++ b/SPEC_REF @@ -1 +1 @@ -9e423d4d4bbd412d9aec3aa1c744eb59732808c9 +977288489f1312ff94451ec7379fcef1f27f6d75 diff --git a/docs/ssrf-mapping.md b/docs/ssrf-mapping.md index 308e0c4..ca4ff00 100644 --- a/docs/ssrf-mapping.md +++ b/docs/ssrf-mapping.md @@ -391,8 +391,8 @@ exporter's doing. Everything else in both documents is equal. five sites. The exporter writes a `` holding exactly what `` holds, and the UDDF reader carries it into `location.name` because §6.10's `location` is a real member and a reader cannot know that a writer filled it by copying. The save file's - `` has one name and no second field to copy it into. This is the one difference `dives` cannot see: the other - seven all live on a dive. + `` has one name and no second field to copy it into. This is the one difference + `dives` cannot see: the other seven all live on a dive. The record UUIDs differ too, and always will: each format has its own frozen identity namespace, so the same site converted through both paths is two records. `converting.md`