diff --git a/CHANGELOG.md b/CHANGELOG.md index f942917..26c6297 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,27 @@ this file is about the package, whose version moves independently. ## Unreleased +- **Breaking: a trip is a sequence of parts, and records no dates of its own.** §6.8 and the + new §6.9a of + [the specification](https://github.com/divejson/divejson/blob/main/spec/divejson.md) replace + a trip's `locations` with `parts`, each part carrying its own OPTIONAL `starts_on`, `ends_on` + and nested `location`. A trip's own `starts_on` and `ends_on` are gone, so anything reaching + into a document for them finds nothing and `divejson validate` refuses either as an undefined + member; a trip's span is the earliest `starts_on` among its parts and the latest `ends_on`, + and a trip whose parts carry none has no span. §3's `ends_on ≥ starts_on` reads on a part, so + that issue is now reported at `trips//parts/` and a bounding box's at + `trips//parts//location/bbox`. + +- **The UDDF reader keeps each ``'s own dates and place**, where it returned the + earliest start, the latest end and a flat list of names. A trip whose parts carry no dates is + carried rather than dropped, the REQUIRED `starts_on` that forced that being gone; a + `` with a `` and no `` keeps its dates and loses the place, §6.9 + still requiring a location's name; and one carrying neither a name nor a date produces no part + at all, which is what lets a trip with no parts come back as one. The writer emits one + `` per part with its own ``, omits that element for a part with neither + date, and writes a part's single date into both of its attributes — both are required there, + and dropping the element would lose the date the document held. + ## 0.8.0 - **Breaking: a course's `agency` is OPTIONAL.** §6.17 of diff --git a/SPEC_REF b/SPEC_REF index d556e66..dc6427b 100644 --- a/SPEC_REF +++ b/SPEC_REF @@ -1 +1 @@ -d92517fbd669d3c9fcd8ef6e89fb8d231e8eb019 +373bf78002d15102e60f8913f31b74dbe7c12ffc diff --git a/divejson/uddf.py b/divejson/uddf.py index d134755..5df94d4 100644 --- a/divejson/uddf.py +++ b/divejson/uddf.py @@ -694,10 +694,10 @@ def read_sites(self) -> list[dict[str, Any]]: def read_trips(self) -> list[dict[str, Any]]: """`` as Trip records. - A `` becomes a Trip Location: UDDF models a trip as a sequence of parts, - each with its own place and dates, and §6.9's location list is the nearest thing - this format has. The trip's own dates are the span of its parts, because `tripType` - records none of its own. + A `` is a §6.9a part, which is as close to an identity as this mapping + gets: both formats model a trip as a sequence of stretches each carrying its own + dates and its own place. Neither records dates on the trip itself, so a trip whose + parts carry none has no span in either and is still a trip. """ trips: list[dict[str, Any]] = [] for index, element in enumerate(_kids(_kid(self.root, "divetrip"), "trip")): @@ -707,15 +707,7 @@ def read_trips(self) -> list[dict[str, Any]]: self.note(where, "the trip has no name, which the format requires of one; it is dropped (spec §6.8)", "dropped") continue - starts, ends, locations, notes = self.read_trip_parts(element, where) - if starts is None: - self.note( - where, - "the trip records no dates, and the format requires a start date; it is dropped along with " - "the dives' membership of it (spec §6.8)", - "dropped", - ) - continue + parts, notes = self.read_trip_parts(element, where) claimed, carried = self.uuid_for("trip", _attr(element, "id"), where, index) if claimed is None: continue @@ -729,31 +721,31 @@ def read_trips(self) -> list[dict[str, Any]]: continue trip: dict[str, Any] = {"uuid": claimed, "name": self.capped(name, MAX_NAME, where, "the trip name")} - if locations: - trip["locations"] = locations - trip["starts_on"] = starts - if ends is not None and ends >= starts: - trip["ends_on"] = ends - elif ends is not None: - self.note(where, f"the trip ends on {ends}, before it starts on {starts}; the end date is dropped", "dropped") + if parts: + trip["parts"] = parts if notes: trip["notes"] = notes trips.append(trip) return trips - def read_trip_parts( - self, element: ET.Element, where: str - ) -> tuple[str | None, str | None, list[dict[str, Any]], str | None]: - starts: list[str] = [] - ends: list[str] = [] - locations: list[dict[str, Any]] = [] + def read_trip_parts(self, element: ET.Element, where: str) -> tuple[list[dict[str, Any]], str | None]: + """Every `` as a part, in file order, and the trip's note. + + A part with neither a name nor a date is **no part at all**, which is what lets a + trip with no parts survive a round trip: `tripType` requires at least one + ``, so a writer with nothing to put in one emits exactly this element + (`docs/uddf-writing.md`), and reading it back as nothing is what closes the circle. + Its note is still collected — a note belongs to the trip (§6.9a gives a part none). + """ + parts: list[dict[str, Any]] = [] paragraphs: list[str] = [] for part_index, part in enumerate(_kids(element, "trippart")): part_where = f"{where}/trippart/{part_index}" date_of_trip = _kid(part, "dateoftrip") - for attribute, collected in (("startdate", starts), ("enddate", ends)): + dates: dict[str, str] = {} + for attribute, member in (("startdate", "starts_on"), ("enddate", "ends_on")): raw = _attr(date_of_trip, attribute) if raw is None: continue @@ -761,24 +753,40 @@ def read_trip_parts( if value is None: self.note(part_where, f"{attribute} is {raw!r}, which is not a date; dropped", "dropped") else: - collected.append(value[:10]) + dates[member] = value[:10] + starts, ends = dates.get("starts_on"), dates.get("ends_on") + if starts is not None and ends is not None and ends < starts: + self.note( + part_where, + f"the part ends on {ends}, before it starts on {starts}; the end date is dropped", + "dropped", + ) + ends = None geography = _kid(part, "geography") part_name = _text_of(part, "name") display_name = _text_of(geography, "location") + location: dict[str, Any] | None = None if part_name: - location: dict[str, Any] = {"name": self.capped(part_name, MAX_NAME, part_where, "the trip part's 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") position = self.position(geography, part_where) if position: location["position"] = position - locations.append(location) elif geography is not None: + # What the finding has to say is what survives, and that turns on the dates + # — a dateless nameless part is nothing at all once the place goes, which is + # the same split `uddf_write.nameless_part` makes from the other side. + kept = ( + "the part keeps its dates" + if starts is not None or ends is not None + else "the part records no dates either, so nothing of it is carried" + ) self.note( part_where, - "the trip part has no name, which the format requires of a location; the place is dropped " - "(spec §6.9)", + f"the trip part has no name, which the format requires of a location; the place is " + f"dropped and {kept} (spec §6.9)", "dropped", ) @@ -786,8 +794,18 @@ def read_trip_parts( if part_notes: paragraphs.append(part_notes) + record: dict[str, Any] = {} + if starts is not None: + record["starts_on"] = starts + if ends is not None: + record["ends_on"] = ends + if location is not None: + record["location"] = location + if record: + parts.append(record) + joined = self.capped("\n\n".join(paragraphs), MAX_NOTES, where, "the trip note") if paragraphs else None - return (min(starts) if starts else None), (max(ends) if ends else None), locations, joined + return parts, joined # -- gear -------------------------------------------------------------------- diff --git a/divejson/uddf_write.py b/divejson/uddf_write.py index dbaf7e0..1c8f754 100644 --- a/divejson/uddf_write.py +++ b/divejson/uddf_write.py @@ -956,14 +956,16 @@ def geography( # -- trips ------------------------------------------------------------------- def divetrip_element(self) -> ET.Element | None: - """``, one `` per §6.9 location. - - UDDF models a trip as a sequence of parts, each with its own place, and that is the - only shape a list of locations fits: the reader takes a trip's span as the span of - its parts and its locations from their names, so a part per location comes back as - the list it was written from. A trip with no locations still needs one part — - `tripType` requires at least one — and it gets a nameless one, an empty `` - being a valid `xs:string` that reads back as no location rather than as one. + """``, one `` per §6.9a part. + + 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 rather than having its dates lifted to + the trip. A trip with **no** parts still needs one `` — `tripType` + requires at least one — and gets a nameless, dateless one, which reads back as no + part rather than as an empty one and so is reported as nothing lost. + + The trip's note goes on the first part and nowhere else: a reader joins every + part's notes, so writing it on each would hand back one copy per part. """ trips = self.document.get("trips") if not trips: @@ -971,26 +973,26 @@ def divetrip_element(self) -> ET.Element | None: divetrip = ET.Element("divetrip") for index, trip in enumerate(trips): where = f"trips/{index}" - self.unmapped( - where, trip, frozenset({"uuid", "name", "locations", "starts_on", "ends_on", "notes"}) - ) + self.unmapped(where, trip, frozenset({"uuid", "name", "parts", "notes"})) element = _sub(divetrip, "trip", id=_uddf_id("trip", trip["uuid"])) _sub(element, "name", str(trip.get("name") or "")) - locations = trip.get("locations") or [None] - for part_index, location in enumerate(locations): - part_where = f"{where}/locations/{part_index}" + parts = trip.get("parts") or [None] + for part_index, record in enumerate(parts): + part_where = f"{where}/parts/{part_index}" + location = None if record is None else record.get("location") # `trippartType` is an `xs:sequence`: name, dateoftrip, geography, notes. part = _sub(element, "trippart") + if record is not None: + self.unmapped(part_where, record, frozenset({"starts_on", "ends_on", "location"})) if location is None: _sub(part, "name", "") + if record is not None: + self.nameless_part(part_where, record) else: self.unmapped(part_where, location, frozenset({"name", "display_name", "position"})) _sub(part, "name", str(location.get("name") or "")) - # The dates and the note belong to the trip and not to any one part, so they - # go on the first: the reader takes the span of every part's dates and joins - # every part's notes, both of which return what one part carried. - if part_index == 0: - self.date_of_trip(part, where, trip) + 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 @@ -1003,25 +1005,60 @@ def divetrip_element(self) -> ET.Element | None: self.notes_of(part, where, trip) return divetrip - def date_of_trip(self, part: ET.Element, where: str, trip: dict[str, Any]) -> None: + def nameless_part(self, where: str, record: dict[str, Any]) -> None: + """The empty `` a placeless part is written with, reported. + + `simpleNamedType` makes `` mandatory and the part has nothing for it, so what + the finding has to say is what a reader will take the placeholder as — and that + turns on the part's dates. A dated one comes back as the placeless part it was; one + carrying neither a place nor a date does not come back at all, being the same + element as the floor a partless trip is written with. + """ + if record.get("starts_on") or record.get("ends_on"): + self.note( + where, + "the part records no place, and UDDF's requires a ; an empty one is " + "written, which reads back as the dated placeless part it is", + "absent", + ) + else: + self.note( + where, + "the part records neither a place nor a date, and UDDF's requires a ; an " + "empty one is written, which is the element a trip with no parts is written as and reads " + "back as no part at all", + "absent", + ) + + def date_of_trip(self, part: ET.Element, where: str, record: dict[str, Any]) -> None: """``, whose two attributes are both `use="required"`. - A trip with no end date has nothing to put in `enddate`, and UDDF has no spelling - for an open one — so the start date is repeated and the report says what a reader - will make of it, which is a trip that ended the day it began. + The element itself is `minOccurs="0"`, so a part with neither date gets none of it + and loses nothing. A part with **one** of the two has nothing for the other + attribute, and UDDF has no spelling for an open stretch — so the date it does have + is written into both and the report says what a reader will make of that, which is + a stretch that began and ended on one day. Dropping the element instead would lose + the date the source did record. """ - starts = trip.get("starts_on") - if not starts: + starts, ends = record.get("starts_on"), record.get("ends_on") + if not starts and not ends: return - ends = trip.get("ends_on") if not ends: self.note( where, - "the trip records no end date, and UDDF's requires one; the start date is " - "written there, so a reader sees a trip that ended the day it began", + "the part records no end date, and UDDF's requires both; the start date is " + "written into both, so a reader sees a stretch that began and ended on one day", "absent", ) ends = starts + elif not starts: + self.note( + where, + "the part records no start date, and UDDF's requires both; the end date is " + "written into both, so a reader sees a stretch that began and ended on one day", + "absent", + ) + starts = ends # `xs:dateTime` where DiveJSON holds a plain date, so each is widened to midnight — # and the reader takes the date back off the front, which is what makes it exact. _sub(part, "dateoftrip", startdate=f"{starts}T00:00:00", enddate=f"{ends}T00:00:00") diff --git a/divejson/validate.py b/divejson/validate.py index c44699f..d23b56e 100644 --- a/divejson/validate.py +++ b/divejson/validate.py @@ -279,22 +279,24 @@ def _semantic_issues(doc: dict[str, Any]) -> list[Issue]: for index, trip in enumerate(collections["trips"]): here = f"trips/{index}" - if _present(trip, "starts_on") and _present(trip, "ends_on"): - try: - if trip["ends_on"] < trip["starts_on"]: - issues.append(Issue(here, "ends_on precedes starts_on")) - except TypeError: - pass - for loc_index, location in enumerate(trip.get("locations") or []): + for part_index, part in enumerate(trip.get("parts") or []): + if not isinstance(part, dict): + continue + part_path = f"{here}/parts/{part_index}" + if _present(part, "starts_on") and _present(part, "ends_on"): + try: + if part["ends_on"] < part["starts_on"]: + 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"{here}/locations/{loc_index}/bbox", "south exceeds north") - ) + issues.append(Issue(f"{part_path}/location/bbox", "south exceeds north")) except (KeyError, TypeError): pass diff --git a/docs/converting.md b/docs/converting.md index 33d1f75..e2feb08 100644 --- a/docs/converting.md +++ b/docs/converting.md @@ -159,11 +159,11 @@ document. `exported_at` is the moment of conversion, always offset-aware. logbook. Any mapping added later that lands a source string on a constrained member owes the same guard. - **A record whose format-required member the source never recorded goes, along with every - reference to it** — a trip with no dates (§6.8 makes `starts_on` REQUIRED), a site or a - gear item with no name (§6.10, §6.12) — rather than gaining an invented one. §5.3 forbids - a dangling reference, so the references go with the record. A source that records nothing - at all about a logbook's owner produces no `diver` member (§6.1): minting an identity for - one would be §5.4's fabrication applied to people. + reference to it** — a trip, a site or a gear item with no name (§6.8, §6.10, §6.12) — + rather than gaining an invented one. §5.3 forbids a dangling reference, so the references + go with the record. A source that records nothing at all about a logbook's owner produces + no `diver` member (§6.1): minting an identity for one would be §5.4's fabrication applied + to people. - **A source record that is not a dive at all is skipped, and reported.** A run, a swim, an activity with no depth: a tracker writes them in the same shape as a dive, and §6.2's object is a dive. Skipping it and saying so is the honest answer; a marker under diff --git a/docs/ssrf-mapping.md b/docs/ssrf-mapping.md index 1488a38..70584ab 100644 --- a/docs/ssrf-mapping.md +++ b/docs/ssrf-mapping.md @@ -322,7 +322,7 @@ one, so nothing about it could be checked against output Subsurface actually pro | `.ssrf` | why not | | --- | --- | -| ``'s own attributes | §6.8 makes a trip's `starts_on` REQUIRED, and no file in hand carries a `` to read its dates and place 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. This is the first thing to map when such a file arrives. | +| ``'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. | | `` | `dive.weight` is the member, and the unit spelling and the multiple-system summing rule are both unchecked against a real file. | diff --git a/docs/uddf-mapping.md b/docs/uddf-mapping.md index 2ec3b04..1a65fba 100644 --- a/docs/uddf-mapping.md +++ b/docs/uddf-mapping.md @@ -160,16 +160,33 @@ which is the file that taught `converting.md`'s rule that such a pair is not a p | UDDF | DiveJSON | | --- | --- | | `name` | `trips[].name` | -| `trippart/name` | `trips[].locations[].name` | -| `trippart/geography/location` | `trips[].locations[].display_name`, when it differs from the name | -| `trippart/geography/latitude` + `longitude` | `trips[].locations[].position` | -| `trippart/dateoftrip/@startdate`, earliest | `trips[].starts_on` | -| `trippart/dateoftrip/@enddate`, latest | `trips[].ends_on` | -| `trippart/notes/para` | `trips[].notes` | +| `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/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` | -`tripType` records no dates of its own, so a trip's span is the span of its parts — and a -trip whose parts carry none has nothing to put in `starts_on`. +**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. +`tripType` records no dates of its own and §6.8 records none either — a trip's span is the +span of its parts in both, and a trip whose parts carry none has no span in either. + +A `` with a `` becomes a part with a location; one without becomes a part +with dates and no location, which §6.9a allows and which is what a `` carrying +only a `` says. A `` on a nameless part has no `name` to hang off +and is dropped and reported (§6.9 makes `name` REQUIRED of a location) — the part survives +with its dates, where before the whole element did nothing but widen the trip's span. +**Where it carries no dates either, the rule below reaches the same element**: nothing comes +back at all, and the finding says that rather than saying the part survived. + +**A `` carrying neither a name nor a date produces no part at all**, and that is +what closes the round trip in the other direction: `tripType` requires at least one +``, so [`uddf-writing.md`](uddf-writing.md) emits a nameless empty one for a trip +with no parts, and a trip with no parts is what comes back. UDDF also allows the opposite direction — `trippart/relateddives/link` pointing from the trip at its dives. It is not read, because no writer in the corpus emits it. @@ -611,6 +628,7 @@ 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`. | | ``, ``, `

` | §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. | diff --git a/docs/uddf-writing.md b/docs/uddf-writing.md index 6361f18..706e8b5 100644 --- a/docs/uddf-writing.md +++ b/docs/uddf-writing.md @@ -80,7 +80,7 @@ else. A round trip through that would hand the diver back a location they never requires that the document had nothing for, and `dropped` is a member UDDF has nowhere to put — or, in the two findings *Devices* below describes, a fact about the order a dive's recordings come back in, which the file has nowhere to carry either; the paths are -`dives/0`, `dives/0/cylinders/1`, `trips/0/locations/1` and `$`. +`dives/0`, `dives/0/cylinders/1`, `trips/0/parts/1` and `$`. A member with nowhere to go is reported from the record itself and not from a list (`writing.md`), so **the tables below are a description of what a writer does and not the @@ -360,7 +360,7 @@ UDDF file expects to find there. ### Sites and trips `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 trip location +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. **Differs from the reference writer**: it puts the record's own **name** in `` and @@ -369,21 +369,42 @@ for and wrong for a converter — a round trip through it hands the diver back a they never wrote. This is the same trade *The three answers* describes, and it is the one place in the document where the reference writer takes the third of them. -A trip becomes one `` **per §6.9 location**, which is the only shape a list of -places fits: a reader takes a trip's span as the span of its parts and its locations from -their names, so a part per location comes back as the list it was written from. A trip with -no locations still needs one part — `tripType` requires at least one — and gets a nameless -one, an empty `` being a valid `xs:string` that reads back as no location rather than -as one. The trip's dates and its note go on the **first** part, since a reader takes the -span of every part's dates and joins every part's notes. +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 +``, and its own dates the ``. + +**A trip with no parts still needs one ``** — `tripType` requires at least one — +and gets a nameless, dateless one, an empty `` being a valid `xs:string` that reads +back as no part rather than as an empty one. Nothing is reported for it: the document held +no part, and no part is what comes back. + +**A part with no `location` gets that same empty ``, and it is reported `absent`**, +`simpleNamedType` making `` mandatory where the part has nothing for it. The finding +says what a reader will take the placeholder as, and that turns on the part's dates: one +that has them comes back as the dated placeless part it was, and one carrying **neither** a +location nor a date does not come back at all, being indistinguishable from the floor above. +That second case is the one shape of part the self round trip loses, and naming it in the +report is what `writing.md` asks of a loss. + +The trip's **note** goes on the first `` and nowhere else, since a reader joins +every part's notes: writing it on each would hand back a note repeated once per part. A +part carries no note of its own (§6.9a), so nothing is displaced by it. ``'s `startdate` and `enddate` are both `use="required"` and both `xs:dateTime` where DiveJSON holds plain dates, so each is widened to midnight and a reader takes the date -back off the front. A trip with no `ends_on` has nothing for `enddate`: **the start date is -repeated**, reported, and a reader sees a trip that ended the day it began. UDDF has no -spelling for an open one. +back off the front. The rules below are **per part**, where they were per trip while a trip +held the dates: -`trips[].locations[].bbox` has no UDDF slot at all. +- A part with **neither** date gets **no ``** at all, the element being + `minOccurs="0"`. Nothing is lost and nothing is reported. +- A part with **one** of the two has nothing for the other attribute, so **the date it has + is written into both** and the finding says so: a reader sees a stretch that began and + ended on one day. UDDF has no spelling for an open one, and the alternative — dropping + the element — would lose the date the source did record. + +`trips[].parts[].location.bbox` has no UDDF slot at all. ### Dives @@ -582,7 +603,7 @@ once per record that carries it, and none of them has anywhere in UDDF to go: | `gear` `rented`, `archived`, `archived_at`, `dive_count` | no slot | | `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[].locations[].bbox` | `geographyType` carries a point, not a box | +| `trips[].parts[].location.bbox` | `geographyType` carries a point, not a box | | 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 b86ebfc..7584059 100644 --- a/fixtures/README.md +++ b/fixtures/README.md @@ -33,7 +33,7 @@ what makes a fixture dropped into this tree impossible to ignore. | `minimal.divejson` | The smallest conforming document: `format`, `version`, `exported_at` — no diver (a source that records nothing about its owner omits the member), no collections (absent ≡ empty). | | `demo-logbook.divejson` | A real export of the reference writer's demo account (all names are seeded demo data; pulled 2026-09-02, courses-era writer): 8 dives, one carrying a recording with its device, its stored file and a full sampled profile, which is what a dive imported from a computer file looks like — sites, trips, an empty `courses` collection, gear with a service history, certifications, and producer extensions carrying application-specific values. Its device is written by hand: the export predates recordings, and the file it names was read by the `suunto_json` parser, so the device is what that reader takes from such a file. 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, 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, 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/ @@ -75,7 +75,7 @@ validator does not look. | `non-increasing-samples.divejson` | a series' `times` is not strictly increasing, **in a dive's second recording** | §6.5, §3 rule 3 | | `event-without-type-or-label.divejson` | an event with neither a `type` nor a `label` | §6.6 | | `deco-model-gf-order.divejson` | `gf_low > gf_high` on a recording's deco model | §3 rule 7, §6.4c | -| `trip-dates-reversed.divejson` | `ends_on` before `starts_on` on a trip | §6.8 | +| `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 | @@ -230,5 +230,5 @@ 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 as a `` with its dates and its place, 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 location with coordinates and no 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: 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. | +| `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. | diff --git a/fixtures/invalid/agency-other-missing.divejson b/fixtures/invalid/agency-other-missing.divejson index 3d5858a..69c3c33 100644 --- a/fixtures/invalid/agency-other-missing.divejson +++ b/fixtures/invalid/agency-other-missing.divejson @@ -272,37 +272,45 @@ { "uuid": "0198a6f0-1111-7030-8000-000000000030", "name": "Fiordland tech week", - "locations": [ + "parts": [ { - "name": "Milford Sound", - "display_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 + "starts_on": "2026-07-01", + "ends_on": "2026-07-04", + "location": { + "name": "Milford Sound", + "display_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 + } } }, { - "name": "Chatham Rise crossing", - "position": { - "latitude": -43.9, - "longitude": 179.9 - }, - "bbox": { - "south": -44.2, - "north": -43.6, - "west": 179.5, - "east": -179.6 + "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" } ], - "starts_on": "2026-07-01", - "ends_on": "2026-07-09", "notes": "", "created_at": "2026-06-01T08:00:00+12:00" } diff --git a/fixtures/invalid/avg-depth-exceeds-max.divejson b/fixtures/invalid/avg-depth-exceeds-max.divejson index 6e9047c..3f7a0ac 100644 --- a/fixtures/invalid/avg-depth-exceeds-max.divejson +++ b/fixtures/invalid/avg-depth-exceeds-max.divejson @@ -272,37 +272,45 @@ { "uuid": "0198a6f0-1111-7030-8000-000000000030", "name": "Fiordland tech week", - "locations": [ + "parts": [ { - "name": "Milford Sound", - "display_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 + "starts_on": "2026-07-01", + "ends_on": "2026-07-04", + "location": { + "name": "Milford Sound", + "display_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 + } } }, { - "name": "Chatham Rise crossing", - "position": { - "latitude": -43.9, - "longitude": 179.9 - }, - "bbox": { - "south": -44.2, - "north": -43.6, - "west": 179.5, - "east": -179.6 + "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" } ], - "starts_on": "2026-07-01", - "ends_on": "2026-07-09", "notes": "", "created_at": "2026-06-01T08:00:00+12:00" } diff --git a/fixtures/invalid/bad-version.divejson b/fixtures/invalid/bad-version.divejson index fe2ba6b..19b71b6 100644 --- a/fixtures/invalid/bad-version.divejson +++ b/fixtures/invalid/bad-version.divejson @@ -272,37 +272,45 @@ { "uuid": "0198a6f0-1111-7030-8000-000000000030", "name": "Fiordland tech week", - "locations": [ + "parts": [ { - "name": "Milford Sound", - "display_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 + "starts_on": "2026-07-01", + "ends_on": "2026-07-04", + "location": { + "name": "Milford Sound", + "display_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 + } } }, { - "name": "Chatham Rise crossing", - "position": { - "latitude": -43.9, - "longitude": 179.9 - }, - "bbox": { - "south": -44.2, - "north": -43.6, - "west": 179.5, - "east": -179.6 + "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" } ], - "starts_on": "2026-07-01", - "ends_on": "2026-07-09", "notes": "", "created_at": "2026-06-01T08:00:00+12:00" } diff --git a/fixtures/invalid/bbox-missing-corner.divejson b/fixtures/invalid/bbox-missing-corner.divejson index b2f95e4..5d87db1 100644 --- a/fixtures/invalid/bbox-missing-corner.divejson +++ b/fixtures/invalid/bbox-missing-corner.divejson @@ -272,36 +272,44 @@ { "uuid": "0198a6f0-1111-7030-8000-000000000030", "name": "Fiordland tech week", - "locations": [ + "parts": [ { - "name": "Milford Sound", - "display_name": "Milford Sound / Piopiotahi, Southland, New Zealand", - "position": { - "latitude": -44.6414, - "longitude": 167.8974 - }, - "bbox": { - "south": -44.7, - "north": -44.58, - "west": 167.8 + "starts_on": "2026-07-01", + "ends_on": "2026-07-04", + "location": { + "name": "Milford Sound", + "display_name": "Milford Sound / Piopiotahi, Southland, New Zealand", + "position": { + "latitude": -44.6414, + "longitude": 167.8974 + }, + "bbox": { + "south": -44.7, + "north": -44.58, + "west": 167.8 + } } }, { - "name": "Chatham Rise crossing", - "position": { - "latitude": -43.9, - "longitude": 179.9 - }, - "bbox": { - "south": -44.2, - "north": -43.6, - "west": 179.5, - "east": -179.6 + "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" } ], - "starts_on": "2026-07-01", - "ends_on": "2026-07-09", "notes": "", "created_at": "2026-06-01T08:00:00+12:00" } diff --git a/fixtures/invalid/bbox-south-exceeds-north.divejson b/fixtures/invalid/bbox-south-exceeds-north.divejson index e70d7ee..fbaca11 100644 --- a/fixtures/invalid/bbox-south-exceeds-north.divejson +++ b/fixtures/invalid/bbox-south-exceeds-north.divejson @@ -272,37 +272,45 @@ { "uuid": "0198a6f0-1111-7030-8000-000000000030", "name": "Fiordland tech week", - "locations": [ + "parts": [ { - "name": "Milford Sound", - "display_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 + "starts_on": "2026-07-01", + "ends_on": "2026-07-04", + "location": { + "name": "Milford Sound", + "display_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 + } } }, { - "name": "Chatham Rise crossing", - "position": { - "latitude": -43.9, - "longitude": 179.9 - }, - "bbox": { - "south": -44.2, - "north": -43.6, - "west": 179.5, - "east": -179.6 + "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" } ], - "starts_on": "2026-07-01", - "ends_on": "2026-07-09", "notes": "", "created_at": "2026-06-01T08:00:00+12:00" } diff --git a/fixtures/invalid/bbox-without-position.divejson b/fixtures/invalid/bbox-without-position.divejson index 57bc63e..5c126f7 100644 --- a/fixtures/invalid/bbox-without-position.divejson +++ b/fixtures/invalid/bbox-without-position.divejson @@ -272,33 +272,41 @@ { "uuid": "0198a6f0-1111-7030-8000-000000000030", "name": "Fiordland tech week", - "locations": [ + "parts": [ { - "name": "Milford Sound", - "display_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 + "starts_on": "2026-07-01", + "ends_on": "2026-07-04", + "location": { + "name": "Milford Sound", + "display_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 + } } }, { - "name": "Chatham Rise crossing", - "bbox": { - "south": -44.2, - "north": -43.6, - "west": 179.5, - "east": -179.6 + "location": { + "name": "Chatham Rise crossing", + "bbox": { + "south": -44.2, + "north": -43.6, + "west": 179.5, + "east": -179.6 + } } + }, + { + "starts_on": "2026-07-08", + "ends_on": "2026-07-09" } ], - "starts_on": "2026-07-01", - "ends_on": "2026-07-09", "notes": "", "created_at": "2026-06-01T08:00:00+12:00" } diff --git a/fixtures/invalid/channel-length-mismatch.divejson b/fixtures/invalid/channel-length-mismatch.divejson index 6fd95f3..23b1948 100644 --- a/fixtures/invalid/channel-length-mismatch.divejson +++ b/fixtures/invalid/channel-length-mismatch.divejson @@ -273,37 +273,45 @@ { "uuid": "0198a6f0-1111-7030-8000-000000000030", "name": "Fiordland tech week", - "locations": [ + "parts": [ { - "name": "Milford Sound", - "display_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 + "starts_on": "2026-07-01", + "ends_on": "2026-07-04", + "location": { + "name": "Milford Sound", + "display_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 + } } }, { - "name": "Chatham Rise crossing", - "position": { - "latitude": -43.9, - "longitude": 179.9 - }, - "bbox": { - "south": -44.2, - "north": -43.6, - "west": 179.5, - "east": -179.6 + "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" } ], - "starts_on": "2026-07-01", - "ends_on": "2026-07-09", "notes": "", "created_at": "2026-06-01T08:00:00+12:00" } diff --git a/fixtures/invalid/course-agency-other-without-agency.divejson b/fixtures/invalid/course-agency-other-without-agency.divejson index 502d52a..e9963b0 100644 --- a/fixtures/invalid/course-agency-other-without-agency.divejson +++ b/fixtures/invalid/course-agency-other-without-agency.divejson @@ -455,37 +455,45 @@ { "uuid": "0198a6f0-1111-7030-8000-000000000030", "name": "Fiordland tech week", - "locations": [ + "parts": [ { - "name": "Milford Sound", - "display_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 + "starts_on": "2026-07-01", + "ends_on": "2026-07-04", + "location": { + "name": "Milford Sound", + "display_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 + } } }, { - "name": "Chatham Rise crossing", - "position": { - "latitude": -43.9, - "longitude": 179.9 - }, - "bbox": { - "south": -44.2, - "north": -43.6, - "west": 179.5, - "east": -179.6 + "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" } ], - "starts_on": "2026-07-01", - "ends_on": "2026-07-09", "notes": "", "created_at": "2026-06-01T08:00:00+12:00" } diff --git a/fixtures/invalid/course-dates-reversed.divejson b/fixtures/invalid/course-dates-reversed.divejson index a877ce6..6df6696 100644 --- a/fixtures/invalid/course-dates-reversed.divejson +++ b/fixtures/invalid/course-dates-reversed.divejson @@ -272,37 +272,45 @@ { "uuid": "0198a6f0-1111-7030-8000-000000000030", "name": "Fiordland tech week", - "locations": [ + "parts": [ { - "name": "Milford Sound", - "display_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 + "starts_on": "2026-07-01", + "ends_on": "2026-07-04", + "location": { + "name": "Milford Sound", + "display_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 + } } }, { - "name": "Chatham Rise crossing", - "position": { - "latitude": -43.9, - "longitude": 179.9 - }, - "bbox": { - "south": -44.2, - "north": -43.6, - "west": 179.5, - "east": -179.6 + "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" } ], - "starts_on": "2026-07-01", - "ends_on": "2026-07-09", "notes": "", "created_at": "2026-06-01T08:00:00+12:00" } diff --git a/fixtures/invalid/dangling-reference.divejson b/fixtures/invalid/dangling-reference.divejson index d88f9c5..b717eb8 100644 --- a/fixtures/invalid/dangling-reference.divejson +++ b/fixtures/invalid/dangling-reference.divejson @@ -273,37 +273,45 @@ { "uuid": "0198a6f0-1111-7030-8000-000000000030", "name": "Fiordland tech week", - "locations": [ + "parts": [ { - "name": "Milford Sound", - "display_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 + "starts_on": "2026-07-01", + "ends_on": "2026-07-04", + "location": { + "name": "Milford Sound", + "display_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 + } } }, { - "name": "Chatham Rise crossing", - "position": { - "latitude": -43.9, - "longitude": 179.9 - }, - "bbox": { - "south": -44.2, - "north": -43.6, - "west": 179.5, - "east": -179.6 + "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" } ], - "starts_on": "2026-07-01", - "ends_on": "2026-07-09", "notes": "", "created_at": "2026-06-01T08:00:00+12:00" } diff --git a/fixtures/invalid/deco-model-gf-order.divejson b/fixtures/invalid/deco-model-gf-order.divejson index 47ae592..3a836cb 100644 --- a/fixtures/invalid/deco-model-gf-order.divejson +++ b/fixtures/invalid/deco-model-gf-order.divejson @@ -279,37 +279,45 @@ { "uuid": "0198a6f0-1111-7030-8000-000000000030", "name": "Fiordland tech week", - "locations": [ + "parts": [ { - "name": "Milford Sound", - "display_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 + "starts_on": "2026-07-01", + "ends_on": "2026-07-04", + "location": { + "name": "Milford Sound", + "display_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 + } } }, { - "name": "Chatham Rise crossing", - "position": { - "latitude": -43.9, - "longitude": 179.9 - }, - "bbox": { - "south": -44.2, - "north": -43.6, - "west": 179.5, - "east": -179.6 + "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" } ], - "starts_on": "2026-07-01", - "ends_on": "2026-07-09", "notes": "", "created_at": "2026-06-01T08:00:00+12:00" } diff --git a/fixtures/invalid/device-empty-member.divejson b/fixtures/invalid/device-empty-member.divejson index b697912..5e1727f 100644 --- a/fixtures/invalid/device-empty-member.divejson +++ b/fixtures/invalid/device-empty-member.divejson @@ -285,37 +285,45 @@ { "uuid": "0198a6f0-1111-7030-8000-000000000030", "name": "Fiordland tech week", - "locations": [ + "parts": [ { - "name": "Milford Sound", - "display_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 + "starts_on": "2026-07-01", + "ends_on": "2026-07-04", + "location": { + "name": "Milford Sound", + "display_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 + } } }, { - "name": "Chatham Rise crossing", - "position": { - "latitude": -43.9, - "longitude": 179.9 - }, - "bbox": { - "south": -44.2, - "north": -43.6, - "west": 179.5, - "east": -179.6 + "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" } ], - "starts_on": "2026-07-01", - "ends_on": "2026-07-09", "notes": "", "created_at": "2026-06-01T08:00:00+12:00" } diff --git a/fixtures/invalid/dive-profile-outside-recording.divejson b/fixtures/invalid/dive-profile-outside-recording.divejson index d034bf4..27ad3bc 100644 --- a/fixtures/invalid/dive-profile-outside-recording.divejson +++ b/fixtures/invalid/dive-profile-outside-recording.divejson @@ -273,37 +273,45 @@ { "uuid": "0198a6f0-1111-7030-8000-000000000030", "name": "Fiordland tech week", - "locations": [ + "parts": [ { - "name": "Milford Sound", - "display_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 + "starts_on": "2026-07-01", + "ends_on": "2026-07-04", + "location": { + "name": "Milford Sound", + "display_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 + } } }, { - "name": "Chatham Rise crossing", - "position": { - "latitude": -43.9, - "longitude": 179.9 - }, - "bbox": { - "south": -44.2, - "north": -43.6, - "west": 179.5, - "east": -179.6 + "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" } ], - "starts_on": "2026-07-01", - "ends_on": "2026-07-09", "notes": "", "created_at": "2026-06-01T08:00:00+12:00" } diff --git a/fixtures/invalid/duplicate-file-uuid-across-recordings.divejson b/fixtures/invalid/duplicate-file-uuid-across-recordings.divejson index ca095f8..00b255f 100644 --- a/fixtures/invalid/duplicate-file-uuid-across-recordings.divejson +++ b/fixtures/invalid/duplicate-file-uuid-across-recordings.divejson @@ -309,37 +309,45 @@ { "uuid": "0198a6f0-1111-7030-8000-000000000030", "name": "Fiordland tech week", - "locations": [ + "parts": [ { - "name": "Milford Sound", - "display_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 + "starts_on": "2026-07-01", + "ends_on": "2026-07-04", + "location": { + "name": "Milford Sound", + "display_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 + } } }, { - "name": "Chatham Rise crossing", - "position": { - "latitude": -43.9, - "longitude": 179.9 - }, - "bbox": { - "south": -44.2, - "north": -43.6, - "west": 179.5, - "east": -179.6 + "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" } ], - "starts_on": "2026-07-01", - "ends_on": "2026-07-09", "notes": "", "created_at": "2026-06-01T08:00:00+12:00" } diff --git a/fixtures/invalid/duplicate-uuid.divejson b/fixtures/invalid/duplicate-uuid.divejson index 8183b32..be3ec0d 100644 --- a/fixtures/invalid/duplicate-uuid.divejson +++ b/fixtures/invalid/duplicate-uuid.divejson @@ -272,37 +272,45 @@ { "uuid": "0198a6f0-1111-7030-8000-000000000030", "name": "Fiordland tech week", - "locations": [ + "parts": [ { - "name": "Milford Sound", - "display_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 + "starts_on": "2026-07-01", + "ends_on": "2026-07-04", + "location": { + "name": "Milford Sound", + "display_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 + } } }, { - "name": "Chatham Rise crossing", - "position": { - "latitude": -43.9, - "longitude": 179.9 - }, - "bbox": { - "south": -44.2, - "north": -43.6, - "west": 179.5, - "east": -179.6 + "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" } ], - "starts_on": "2026-07-01", - "ends_on": "2026-07-09", "notes": "", "created_at": "2026-06-01T08:00:00+12:00" } diff --git a/fixtures/invalid/event-without-type-or-label.divejson b/fixtures/invalid/event-without-type-or-label.divejson index 48d8b97..af98d05 100644 --- a/fixtures/invalid/event-without-type-or-label.divejson +++ b/fixtures/invalid/event-without-type-or-label.divejson @@ -271,37 +271,45 @@ { "uuid": "0198a6f0-1111-7030-8000-000000000030", "name": "Fiordland tech week", - "locations": [ + "parts": [ { - "name": "Milford Sound", - "display_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 + "starts_on": "2026-07-01", + "ends_on": "2026-07-04", + "location": { + "name": "Milford Sound", + "display_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 + } } }, { - "name": "Chatham Rise crossing", - "position": { - "latitude": -43.9, - "longitude": 179.9 - }, - "bbox": { - "south": -44.2, - "north": -43.6, - "west": 179.5, - "east": -179.6 + "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" } ], - "starts_on": "2026-07-01", - "ends_on": "2026-07-09", "notes": "", "created_at": "2026-06-01T08:00:00+12:00" } diff --git a/fixtures/invalid/missing-format.divejson b/fixtures/invalid/missing-format.divejson index 02f05b1..a536813 100644 --- a/fixtures/invalid/missing-format.divejson +++ b/fixtures/invalid/missing-format.divejson @@ -271,37 +271,45 @@ { "uuid": "0198a6f0-1111-7030-8000-000000000030", "name": "Fiordland tech week", - "locations": [ + "parts": [ { - "name": "Milford Sound", - "display_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 + "starts_on": "2026-07-01", + "ends_on": "2026-07-04", + "location": { + "name": "Milford Sound", + "display_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 + } } }, { - "name": "Chatham Rise crossing", - "position": { - "latitude": -43.9, - "longitude": 179.9 - }, - "bbox": { - "south": -44.2, - "north": -43.6, - "west": 179.5, - "east": -179.6 + "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" } ], - "starts_on": "2026-07-01", - "ends_on": "2026-07-09", "notes": "", "created_at": "2026-06-01T08:00:00+12:00" } diff --git a/fixtures/invalid/naive-exported-at.divejson b/fixtures/invalid/naive-exported-at.divejson index ecc2c5d..6c0dc9a 100644 --- a/fixtures/invalid/naive-exported-at.divejson +++ b/fixtures/invalid/naive-exported-at.divejson @@ -272,37 +272,45 @@ { "uuid": "0198a6f0-1111-7030-8000-000000000030", "name": "Fiordland tech week", - "locations": [ + "parts": [ { - "name": "Milford Sound", - "display_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 + "starts_on": "2026-07-01", + "ends_on": "2026-07-04", + "location": { + "name": "Milford Sound", + "display_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 + } } }, { - "name": "Chatham Rise crossing", - "position": { - "latitude": -43.9, - "longitude": 179.9 - }, - "bbox": { - "south": -44.2, - "north": -43.6, - "west": 179.5, - "east": -179.6 + "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" } ], - "starts_on": "2026-07-01", - "ends_on": "2026-07-09", "notes": "", "created_at": "2026-06-01T08:00:00+12:00" } diff --git a/fixtures/invalid/non-increasing-samples.divejson b/fixtures/invalid/non-increasing-samples.divejson index 32d3642..49d1958 100644 --- a/fixtures/invalid/non-increasing-samples.divejson +++ b/fixtures/invalid/non-increasing-samples.divejson @@ -280,37 +280,45 @@ { "uuid": "0198a6f0-1111-7030-8000-000000000030", "name": "Fiordland tech week", - "locations": [ + "parts": [ { - "name": "Milford Sound", - "display_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 + "starts_on": "2026-07-01", + "ends_on": "2026-07-04", + "location": { + "name": "Milford Sound", + "display_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 + } } }, { - "name": "Chatham Rise crossing", - "position": { - "latitude": -43.9, - "longitude": 179.9 - }, - "bbox": { - "south": -44.2, - "north": -43.6, - "west": 179.5, - "east": -179.6 + "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" } ], - "starts_on": "2026-07-01", - "ends_on": "2026-07-09", "notes": "", "created_at": "2026-06-01T08:00:00+12:00" } diff --git a/fixtures/invalid/null-member.divejson b/fixtures/invalid/null-member.divejson index 31a8b65..6817e49 100644 --- a/fixtures/invalid/null-member.divejson +++ b/fixtures/invalid/null-member.divejson @@ -272,37 +272,45 @@ { "uuid": "0198a6f0-1111-7030-8000-000000000030", "name": "Fiordland tech week", - "locations": [ + "parts": [ { - "name": "Milford Sound", - "display_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 + "starts_on": "2026-07-01", + "ends_on": "2026-07-04", + "location": { + "name": "Milford Sound", + "display_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 + } } }, { - "name": "Chatham Rise crossing", - "position": { - "latitude": -43.9, - "longitude": 179.9 - }, - "bbox": { - "south": -44.2, - "north": -43.6, - "west": 179.5, - "east": -179.6 + "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" } ], - "starts_on": "2026-07-01", - "ends_on": "2026-07-09", "notes": "", "created_at": "2026-06-01T08:00:00+12:00" } diff --git a/fixtures/invalid/oxygen-helium-sum.divejson b/fixtures/invalid/oxygen-helium-sum.divejson index 66a9449..79363b2 100644 --- a/fixtures/invalid/oxygen-helium-sum.divejson +++ b/fixtures/invalid/oxygen-helium-sum.divejson @@ -272,37 +272,45 @@ { "uuid": "0198a6f0-1111-7030-8000-000000000030", "name": "Fiordland tech week", - "locations": [ + "parts": [ { - "name": "Milford Sound", - "display_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 + "starts_on": "2026-07-01", + "ends_on": "2026-07-04", + "location": { + "name": "Milford Sound", + "display_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 + } } }, { - "name": "Chatham Rise crossing", - "position": { - "latitude": -43.9, - "longitude": 179.9 - }, - "bbox": { - "south": -44.2, - "north": -43.6, - "west": 179.5, - "east": -179.6 + "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" } ], - "starts_on": "2026-07-01", - "ends_on": "2026-07-09", "notes": "", "created_at": "2026-06-01T08:00:00+12:00" } diff --git a/fixtures/invalid/position-incomplete.divejson b/fixtures/invalid/position-incomplete.divejson index 0e35397..9cee508 100644 --- a/fixtures/invalid/position-incomplete.divejson +++ b/fixtures/invalid/position-incomplete.divejson @@ -271,37 +271,45 @@ { "uuid": "0198a6f0-1111-7030-8000-000000000030", "name": "Fiordland tech week", - "locations": [ + "parts": [ { - "name": "Milford Sound", - "display_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 + "starts_on": "2026-07-01", + "ends_on": "2026-07-04", + "location": { + "name": "Milford Sound", + "display_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 + } } }, { - "name": "Chatham Rise crossing", - "position": { - "latitude": -43.9, - "longitude": 179.9 - }, - "bbox": { - "south": -44.2, - "north": -43.6, - "west": 179.5, - "east": -179.6 + "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" } ], - "starts_on": "2026-07-01", - "ends_on": "2026-07-09", "notes": "", "created_at": "2026-06-01T08:00:00+12:00" } diff --git a/fixtures/invalid/pressure-order.divejson b/fixtures/invalid/pressure-order.divejson index 4867e03..f7be772 100644 --- a/fixtures/invalid/pressure-order.divejson +++ b/fixtures/invalid/pressure-order.divejson @@ -272,37 +272,45 @@ { "uuid": "0198a6f0-1111-7030-8000-000000000030", "name": "Fiordland tech week", - "locations": [ + "parts": [ { - "name": "Milford Sound", - "display_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 + "starts_on": "2026-07-01", + "ends_on": "2026-07-04", + "location": { + "name": "Milford Sound", + "display_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 + } } }, { - "name": "Chatham Rise crossing", - "position": { - "latitude": -43.9, - "longitude": 179.9 - }, - "bbox": { - "south": -44.2, - "north": -43.6, - "west": 179.5, - "east": -179.6 + "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" } ], - "starts_on": "2026-07-01", - "ends_on": "2026-07-09", "notes": "", "created_at": "2026-06-01T08:00:00+12:00" } diff --git a/fixtures/invalid/profile-duration-short.divejson b/fixtures/invalid/profile-duration-short.divejson index 3d7d038..751e51b 100644 --- a/fixtures/invalid/profile-duration-short.divejson +++ b/fixtures/invalid/profile-duration-short.divejson @@ -272,37 +272,45 @@ { "uuid": "0198a6f0-1111-7030-8000-000000000030", "name": "Fiordland tech week", - "locations": [ + "parts": [ { - "name": "Milford Sound", - "display_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 + "starts_on": "2026-07-01", + "ends_on": "2026-07-04", + "location": { + "name": "Milford Sound", + "display_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 + } } }, { - "name": "Chatham Rise crossing", - "position": { - "latitude": -43.9, - "longitude": 179.9 - }, - "bbox": { - "south": -44.2, - "north": -43.6, - "west": 179.5, - "east": -179.6 + "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" } ], - "starts_on": "2026-07-01", - "ends_on": "2026-07-09", "notes": "", "created_at": "2026-06-01T08:00:00+12:00" } diff --git a/fixtures/invalid/recording-without-content.divejson b/fixtures/invalid/recording-without-content.divejson index 53befe2..1cbbaf0 100644 --- a/fixtures/invalid/recording-without-content.divejson +++ b/fixtures/invalid/recording-without-content.divejson @@ -292,37 +292,45 @@ { "uuid": "0198a6f0-1111-7030-8000-000000000030", "name": "Fiordland tech week", - "locations": [ + "parts": [ { - "name": "Milford Sound", - "display_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 + "starts_on": "2026-07-01", + "ends_on": "2026-07-04", + "location": { + "name": "Milford Sound", + "display_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 + } } }, { - "name": "Chatham Rise crossing", - "position": { - "latitude": -43.9, - "longitude": 179.9 - }, - "bbox": { - "south": -44.2, - "north": -43.6, - "west": 179.5, - "east": -179.6 + "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" } ], - "starts_on": "2026-07-01", - "ends_on": "2026-07-09", "notes": "", "created_at": "2026-06-01T08:00:00+12:00" } diff --git a/fixtures/invalid/species-no-identity.divejson b/fixtures/invalid/species-no-identity.divejson index e994f08..eb1bf23 100644 --- a/fixtures/invalid/species-no-identity.divejson +++ b/fixtures/invalid/species-no-identity.divejson @@ -272,37 +272,45 @@ { "uuid": "0198a6f0-1111-7030-8000-000000000030", "name": "Fiordland tech week", - "locations": [ + "parts": [ { - "name": "Milford Sound", - "display_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 + "starts_on": "2026-07-01", + "ends_on": "2026-07-04", + "location": { + "name": "Milford Sound", + "display_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 + } } }, { - "name": "Chatham Rise crossing", - "position": { - "latitude": -43.9, - "longitude": 179.9 - }, - "bbox": { - "south": -44.2, - "north": -43.6, - "west": 179.5, - "east": -179.6 + "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" } ], - "starts_on": "2026-07-01", - "ends_on": "2026-07-09", "notes": "", "created_at": "2026-06-01T08:00:00+12:00" } diff --git a/fixtures/invalid/trailing-newline-datetime.divejson b/fixtures/invalid/trailing-newline-datetime.divejson index dbb43df..f39d80c 100644 --- a/fixtures/invalid/trailing-newline-datetime.divejson +++ b/fixtures/invalid/trailing-newline-datetime.divejson @@ -272,37 +272,45 @@ { "uuid": "0198a6f0-1111-7030-8000-000000000030", "name": "Fiordland tech week", - "locations": [ + "parts": [ { - "name": "Milford Sound", - "display_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 + "starts_on": "2026-07-01", + "ends_on": "2026-07-04", + "location": { + "name": "Milford Sound", + "display_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 + } } }, { - "name": "Chatham Rise crossing", - "position": { - "latitude": -43.9, - "longitude": 179.9 - }, - "bbox": { - "south": -44.2, - "north": -43.6, - "west": 179.5, - "east": -179.6 + "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" } ], - "starts_on": "2026-07-01", - "ends_on": "2026-07-09", "notes": "", "created_at": "2026-06-01T08:00:00+12:00" } diff --git a/fixtures/invalid/trip-dates-reversed.divejson b/fixtures/invalid/trip-dates-reversed.divejson index a4dd254..978c875 100644 --- a/fixtures/invalid/trip-dates-reversed.divejson +++ b/fixtures/invalid/trip-dates-reversed.divejson @@ -272,37 +272,45 @@ { "uuid": "0198a6f0-1111-7030-8000-000000000030", "name": "Fiordland tech week", - "locations": [ + "parts": [ { - "name": "Milford Sound", - "display_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 + "starts_on": "2026-07-01", + "ends_on": "2026-06-28", + "location": { + "name": "Milford Sound", + "display_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 + } } }, { - "name": "Chatham Rise crossing", - "position": { - "latitude": -43.9, - "longitude": 179.9 - }, - "bbox": { - "south": -44.2, - "north": -43.6, - "west": 179.5, - "east": -179.6 + "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" } ], - "starts_on": "2026-07-01", - "ends_on": "2026-06-28", "notes": "", "created_at": "2026-06-01T08:00:00+12:00" } diff --git a/fixtures/invalid/undefined-member.divejson b/fixtures/invalid/undefined-member.divejson index 16b2b6f..8013fb8 100644 --- a/fixtures/invalid/undefined-member.divejson +++ b/fixtures/invalid/undefined-member.divejson @@ -273,37 +273,45 @@ { "uuid": "0198a6f0-1111-7030-8000-000000000030", "name": "Fiordland tech week", - "locations": [ + "parts": [ { - "name": "Milford Sound", - "display_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 + "starts_on": "2026-07-01", + "ends_on": "2026-07-04", + "location": { + "name": "Milford Sound", + "display_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 + } } }, { - "name": "Chatham Rise crossing", - "position": { - "latitude": -43.9, - "longitude": 179.9 - }, - "bbox": { - "south": -44.2, - "north": -43.6, - "west": 179.5, - "east": -179.6 + "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" } ], - "starts_on": "2026-07-01", - "ends_on": "2026-07-09", "notes": "", "created_at": "2026-06-01T08:00:00+12:00" } diff --git a/fixtures/uddf/opendiving.divejson b/fixtures/uddf/opendiving.divejson index 9c6573c..21518fe 100644 --- a/fixtures/uddf/opendiving.divejson +++ b/fixtures/uddf/opendiving.divejson @@ -148,14 +148,16 @@ { "uuid": "0198a6f0-4444-7001-8000-000000000001", "name": "Red Sea Liveaboard", - "locations": [ + "parts": [ { - "name": "Red Sea Liveaboard", - "display_name": "Sha'ab Ali, Egypt" + "starts_on": "2026-04-18", + "ends_on": "2026-04-25", + "location": { + "name": "Red Sea Liveaboard", + "display_name": "Sha'ab Ali, Egypt" + } } ], - "starts_on": "2026-04-18", - "ends_on": "2026-04-25", "notes": "Northern wrecks itinerary out of Hurghada." } ], diff --git a/fixtures/valid/demo-logbook.divejson b/fixtures/valid/demo-logbook.divejson index f1403e1..3c23ab7 100644 --- a/fixtures/valid/demo-logbook.divejson +++ b/fixtures/valid/demo-logbook.divejson @@ -3989,28 +3989,32 @@ { "uuid": "019fec36-b882-7e23-97fa-9e297e8c9701", "name": "Red Sea Liveaboard", - "locations": [ + "parts": [ { - "name": "Northern Red Sea, Egypt" + "starts_on": "2026-04-18", + "ends_on": "2026-04-25", + "location": { + "name": "Northern Red Sea, Egypt" + } } ], "notes": "Wrecks & reefs itinerary aboard MV Coral Queen.", - "created_at": "2026-08-10T15:07:16.994206Z", - "starts_on": "2026-04-18", - "ends_on": "2026-04-25" + "created_at": "2026-08-10T15:07:16.994206Z" }, { "uuid": "019fec36-b88f-714d-a52f-2360218753b8", "name": "Tenerife Week", - "locations": [ + "parts": [ { - "name": "Costa Adeje, Tenerife" + "starts_on": "2026-07-06", + "ends_on": "2026-07-12", + "location": { + "name": "Costa Adeje, Tenerife" + } } ], "notes": "Shore and boat diving with the local club.", - "created_at": "2026-08-10T15:07:17.007783Z", - "starts_on": "2026-07-06", - "ends_on": "2026-07-12" + "created_at": "2026-08-10T15:07:17.007783Z" } ], "courses": [], diff --git a/fixtures/valid/technical-dive.divejson b/fixtures/valid/technical-dive.divejson index aec2f4b..260b81b 100644 --- a/fixtures/valid/technical-dive.divejson +++ b/fixtures/valid/technical-dive.divejson @@ -455,37 +455,45 @@ { "uuid": "0198a6f0-1111-7030-8000-000000000030", "name": "Fiordland tech week", - "locations": [ + "parts": [ { - "name": "Milford Sound", - "display_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 + "starts_on": "2026-07-01", + "ends_on": "2026-07-04", + "location": { + "name": "Milford Sound", + "display_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 + } } }, { - "name": "Chatham Rise crossing", - "position": { - "latitude": -43.9, - "longitude": 179.9 - }, - "bbox": { - "south": -44.2, - "north": -43.6, - "west": 179.5, - "east": -179.6 + "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" } ], - "starts_on": "2026-07-01", - "ends_on": "2026-07-09", "notes": "", "created_at": "2026-06-01T08:00:00+12:00" } diff --git a/fixtures/write/uddf/opendiving.divejson b/fixtures/write/uddf/opendiving.divejson index a3e4129..35ad91b 100644 --- a/fixtures/write/uddf/opendiving.divejson +++ b/fixtures/write/uddf/opendiving.divejson @@ -151,14 +151,16 @@ { "uuid": "0198a6f0-4444-7001-8000-000000000001", "name": "Red Sea Liveaboard", - "locations": [ + "parts": [ { - "name": "Red Sea Liveaboard", - "display_name": "Sha'ab Ali, Egypt" + "starts_on": "2026-04-18", + "ends_on": "2026-04-25", + "location": { + "name": "Red Sea Liveaboard", + "display_name": "Sha'ab Ali, Egypt" + } } ], - "starts_on": "2026-04-18", - "ends_on": "2026-04-25", "notes": "Northern wrecks itinerary out of Hurghada." } ], diff --git a/fixtures/write/uddf/technical-dive.divejson b/fixtures/write/uddf/technical-dive.divejson index aec2f4b..260b81b 100644 --- a/fixtures/write/uddf/technical-dive.divejson +++ b/fixtures/write/uddf/technical-dive.divejson @@ -455,37 +455,45 @@ { "uuid": "0198a6f0-1111-7030-8000-000000000030", "name": "Fiordland tech week", - "locations": [ + "parts": [ { - "name": "Milford Sound", - "display_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 + "starts_on": "2026-07-01", + "ends_on": "2026-07-04", + "location": { + "name": "Milford Sound", + "display_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 + } } }, { - "name": "Chatham Rise crossing", - "position": { - "latitude": -43.9, - "longitude": 179.9 - }, - "bbox": { - "south": -44.2, - "north": -43.6, - "west": 179.5, - "east": -179.6 + "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" } ], - "starts_on": "2026-07-01", - "ends_on": "2026-07-09", "notes": "", "created_at": "2026-06-01T08:00:00+12:00" } diff --git a/fixtures/write/uddf/technical-dive.uddf b/fixtures/write/uddf/technical-dive.uddf index 8547294..efd4982 100644 --- a/fixtures/write/uddf/technical-dive.uddf +++ b/fixtures/write/uddf/technical-dive.uddf @@ -58,7 +58,7 @@ Fiordland tech week Milford Sound - + Milford Sound / Piopiotahi, Southland, New Zealand -44.6414 @@ -68,6 +68,10 @@ Chatham Rise crossing + + + + diff --git a/schema/1.0/divejson.schema.json b/schema/1.0/divejson.schema.json index fd87319..afe6506 100644 --- a/schema/1.0/divejson.schema.json +++ b/schema/1.0/divejson.schema.json @@ -289,14 +289,22 @@ "properties": { "uuid": { "$ref": "#/$defs/uuid" }, "name": { "type": "string", "minLength": 1, "maxLength": 255 }, - "locations": { "type": "array", "items": { "$ref": "#/$defs/trip_location" } }, - "starts_on": { "$ref": "#/$defs/date" }, - "ends_on": { "$ref": "#/$defs/date" }, + "parts": { "type": "array", "items": { "$ref": "#/$defs/trip_part" } }, "notes": { "$ref": "#/$defs/notes" }, "created_at": { "$ref": "#/$defs/date_time" }, "extensions": { "$ref": "#/$defs/extensions" } }, - "required": ["uuid", "name", "starts_on"], + "required": ["uuid", "name"], + "additionalProperties": false + }, + "trip_part": { + "type": "object", + "properties": { + "starts_on": { "$ref": "#/$defs/date" }, + "ends_on": { "$ref": "#/$defs/date" }, + "location": { "$ref": "#/$defs/trip_location" }, + "extensions": { "$ref": "#/$defs/extensions" } + }, "additionalProperties": false }, "trip_location": { diff --git a/tests/test_uddf_parsing.py b/tests/test_uddf_parsing.py index 0dc9a76..171b583 100644 --- a/tests/test_uddf_parsing.py +++ b/tests/test_uddf_parsing.py @@ -340,6 +340,98 @@ def test_hostile_source_strings_still_produce_a_conforming_document() -> None: assert len(conversion.document["sites"][0]["name"]) == 255 +# -- trips ----------------------------------------------------------------------------- + + +def trip_of(trip_body: str) -> tuple[dict, list[str]]: + """The one trip a document holding one `` converts to, and the report's messages.""" + header = f'Egypt, spring{trip_body}' + conversion = convert(one_dive(STARTED_AT, header=header)) + assert validate_document(conversion.document) == [] + return conversion.document["trips"][0], [note.message for note in conversion.notes] + + +def test_each_trippart_keeps_its_own_dates_rather_than_widening_a_span() -> None: + """A `` is a §6.9a part, and its dates stay where the file put them. + + Collapsing two parts a fortnight apart into one range with the places listed beside it + says nothing about which dives happened where, which is the whole of what §6.9a fixes. + """ + trip, _ = trip_of( + 'Hurghada' + 'Marsa Alam' + ) + assert trip["parts"] == [ + {"starts_on": "2026-04-18", "ends_on": "2026-04-22", "location": {"name": "Hurghada"}}, + {"starts_on": "2026-04-22", "ends_on": "2026-04-25", "location": {"name": "Marsa Alam"}}, + ] + + +def test_a_trip_whose_parts_carry_no_dates_is_carried() -> None: + """§6.8 records no dates, so a trip whose parts carry none has no span and is still a trip. + + Nothing is dropped for want of one: a place the diver named and never dated is a + record, and the dives' membership of it is worth as much as any other trip's. + """ + trip, messages = trip_of("Hurghada") + assert trip["parts"] == [{"location": {"name": "Hurghada"}}] + assert not [message for message in messages if "records no dates" in message] + + +def test_a_nameless_trippart_keeps_its_dates_and_loses_its_place() -> None: + """§6.9 makes a location's `name` REQUIRED, and §6.9a lets the part live without one.""" + trip, messages = trip_of( + '' + "Cairo, Egypt30.04" + "31.24" + ) + assert trip["parts"] == [{"starts_on": "2026-04-25", "ends_on": "2026-04-26"}] + assert "the place is dropped and the part keeps its dates" in "\n".join(messages) + + +def test_a_nameless_undated_trippart_says_that_nothing_of_it_is_carried() -> None: + """The place goes for want of a name, and with no dates behind it the part goes too. + + The report has to say which of the two happened, because a diver reading "the place is + dropped" beside a part that is not there has been told the smaller half of it. + """ + trip, messages = trip_of( + "Cairo, Egypt" + "30.0431.24" + ) + assert "parts" not in trip + assert "the part records no dates either, so nothing of it is carried" in "\n".join(messages) + + +def test_a_trippart_with_neither_a_name_nor_a_date_produces_no_part() -> None: + """The element a writer emits for a trip that has no parts, read back as none. + + `tripType` requires at least one ``, so a writer with nothing to put in one + emits exactly this (`docs/uddf-writing.md`) — and a reader that produced an empty part + from it would hand back a stretch of a trip nobody recorded. + """ + trip, _ = trip_of("") + assert "parts" not in trip + + +def test_a_partless_trippart_still_gives_its_note_to_the_trip() -> None: + """A part carries no note of its own (§6.9a), so the note is the trip's wherever it sat.""" + trip, _ = trip_of("Booked late.") + assert trip["notes"] == "Booked late." and "parts" not in trip + + +def test_a_parts_end_before_its_start_is_dropped_from_that_part() -> None: + """§3 rule 2 reads on a part, so the guard that enforces it sits on the part too.""" + trip, messages = trip_of( + 'Hurghada' + ) + assert trip["parts"] == [{"starts_on": "2026-04-18", "location": {"name": "Hurghada"}}] + assert [message for message in messages if "before it starts on 2026-04-18" in message] + + # -- identity ------------------------------------------------------------------------ diff --git a/tests/test_uddf_write_fixtures.py b/tests/test_uddf_write_fixtures.py index 4079d74..1c12ee7 100644 --- a/tests/test_uddf_write_fixtures.py +++ b/tests/test_uddf_write_fixtures.py @@ -78,8 +78,8 @@ "diver/created_at", "sites/0/created_at", "trips/0/created_at", - "trips/0/locations/0/bbox", - "trips/0/locations/1/bbox", + "trips/0/parts/0/location/bbox", + "trips/0/parts/1/location/bbox", "dives/0/water_type", "dives/0/cns_start", "dives/0/cns_end", @@ -102,8 +102,8 @@ # `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 name for it. - "trips/0/locations/1/position", + # A place UDDF will not record without a display 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. "trips/0/notes", @@ -288,7 +288,7 @@ def test_the_report_names_everything_that_changed(source) -> None: # What is compared record by record. Identity and the dive-level scalars: everything a # diver would notice, and nothing about how either file was laid out. -IDENTITY = ("uuid", "name", "brand", "type", "location", "position", "notes", "starts_on", "ends_on") +IDENTITY = ("uuid", "name", "brand", "type", "location", "position", "notes", "parts") SCALARS = ( "number", "started_at", diff --git a/tests/test_uddf_writing.py b/tests/test_uddf_writing.py index 47ac2b9..1d77290 100644 --- a/tests/test_uddf_writing.py +++ b/tests/test_uddf_writing.py @@ -127,14 +127,97 @@ def test_a_cylinder_with_no_start_pressure_keeps_everything_else(schema) -> None assert [kind for kind, where, _ in notes(source) if where == "dives/0/cylinders/0"] == ["absent"] -def test_a_trip_with_no_end_date_repeats_the_start(schema) -> None: - """``'s two attributes are both required, and an open trip has one date.""" - source = document(trips=[{"uuid": TRIP_UUID, "name": "Weekend", "starts_on": "2026-05-01"}]) +def trip_of(*parts: Any) -> dict[str, Any]: + """A document whose only trip is `parts`, which is every trip test's whole subject.""" + return document(trips=[{"uuid": TRIP_UUID, "name": "Weekend", "parts": list(parts)}]) + + +@pytest.mark.parametrize( + ("part", "written_date"), + [ + ({"starts_on": "2026-05-01"}, "2026-05-01"), + ({"ends_on": "2026-05-03"}, "2026-05-03"), + ], + ids=["start-only", "end-only"], +) +def test_a_part_with_one_date_writes_it_into_both_attributes(schema, part, written_date) -> None: + """``'s two attributes are both required, and §6.9a's dates are each optional. + + Symmetric because the format is: §6.9a makes each date independently optional, so an + end with no start is as reachable as a start with no end, and the alternative to + repeating the one date — dropping the element, which `minOccurs="0"` allows — would + lose the date the document did carry. + No fixture reaches either branch: the written corpus's three parts carry both dates, + neither and both. + """ + source = trip_of(part) + assert f'startdate="{written_date}T00:00:00" enddate="{written_date}T00:00:00"' in written(source, schema) + + read = read_back(source)["trips"][0]["parts"][0] + assert read == {"starts_on": written_date, "ends_on": written_date} + assert [kind for kind, where, _ in notes(source) if where == "trips/0/parts/0"] == ["absent", "absent"] + + +def test_a_part_with_no_dates_gets_no_dateoftrip_at_all(schema) -> None: + """The element is `minOccurs="0"`, so an undated part loses nothing and reports nothing.""" + source = trip_of({"location": {"name": "Sha'ab Ali"}}) + assert " None: + """`tripType` requires one ``, and a partless trip has nothing to put in it. + + An empty `` is a valid `xs:string` that the reader takes as no part at all, so + the floor element round-trips to the partless trip it was written from — which is why + nothing is reported for it. + """ + source = document(trips=[{"uuid": TRIP_UUID, "name": "Weekend"}]) text = written(source, schema) - assert 'startdate="2026-05-01T00:00:00" enddate="2026-05-01T00:00:00"' in text + assert "" in text and " None: + """`simpleNamedType` makes `` mandatory, and the finding turns on the dates. + + A dated placeless part comes back as itself; one carrying neither a place nor a date is + the same element as the floor above, so it does not come back at all — and that is the + one shape of part the self round trip loses. + """ + dated = trip_of({"starts_on": "2026-05-01", "ends_on": "2026-05-03"}) + assert read_back(dated)["trips"][0]["parts"] == [{"starts_on": "2026-05-01", "ends_on": "2026-05-03"}] + assert "reads back as the dated placeless part it is" in messages(dated, "trips/0/parts/0")[0] + + empty = trip_of({}) + written(empty, schema) + assert "parts" not in read_back(empty)["trips"][0] + assert "reads back as no part at all" in messages(empty, "trips/0/parts/0")[0] - assert read_back(source)["trips"][0]["ends_on"] == "2026-05-01" - assert [kind for kind, where, _ in notes(source) if where == "trips/0"] == ["absent"] + +def test_parts_keep_the_divers_order_rather_than_date_order(schema) -> None: + """§6.9a makes the array's order recorded data, and an undated part has no date order. + + Written in file order and read back in it, which is the whole of the mapping: UDDF's + `` sequence carries the order and neither side re-sorts. + """ + source = trip_of( + {"starts_on": "2026-05-08", "location": {"name": "Marsa Alam"}}, + {"location": {"name": "Hurghada"}}, + {"starts_on": "2026-05-01", "ends_on": "2026-05-03"}, + ) + divetrip = written(source, schema).partition("")[2] + assert re.findall(r"([^<]*)", divetrip) == ["Weekend", "Marsa Alam", "Hurghada"] + + assert [part.get("location", {}).get("name") for part in read_back(source)["trips"][0]["parts"]] == [ + "Marsa Alam", + "Hurghada", + None, + ] # -- what is dropped rather than invented ----------------------------------------------