diff --git a/CHANGELOG.md b/CHANGELOG.md index db84667ff..b7b23777a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,38 @@ All notable changes to Submersion are documented in this file. +## Unreleased + +### Added + +- MacDive UDDF imports now capture substantially richer dive data: boat + name and captain, dive operator, surface conditions, weather (stored + in the existing weather description field), plus site water type, body + of water, and difficulty rating. +- Cross-format import deduplication: stable per-dive UUIDs from MacDive, + Shearwater Cloud, Subsurface SSRF, and generic UDDF are now preserved on + the `dive_data_sources` sidecar. Re-importing the same dives in a + different format no longer creates duplicates. + +### Fixed + +- MacDive UDDF: equipment / gear now imports correctly. The parser + previously only scanned Submersion's private equipment extension, + missing the standard UDDF gear location (``) + where MacDive and other compliant exporters place their inventory. +- MacDive UDDF: `` is now captured from both + `` and ``. +- MacDive UDDF: `` is now + explicitly handled as "no prior dive" rather than relying on silent + int-parse failure. + +### Known limitations (to be addressed in a follow-up) + +- Gas switch markers (``) in MacDive dive profiles are + parsed but not yet persisted to the profile samples table. A future + milestone will wire them through, likely via the dive-events table. + + ## 1.4.6 (2026-04-22) ### Bug Fixes diff --git a/dart_test.yaml b/dart_test.yaml index 62c954230..daa1e1e79 100644 --- a/dart_test.yaml +++ b/dart_test.yaml @@ -4,3 +4,7 @@ tags: # Run explicitly: flutter test --run-skipped --tags performance # Or: flutter test --run-skipped test/performance/ skip: "Slow performance tests; run with --run-skipped --tags performance" + real-data: + # Real-data tests use actual user exports and may take time to run. + # Run explicitly: flutter test --run-skipped --tags real-data + skip: "Real-data regression tests; run with --run-skipped --tags real-data" diff --git a/docs/superpowers/plans/2026-04-21-macdive-uddf-gap-fill.md b/docs/superpowers/plans/2026-04-21-macdive-uddf-gap-fill.md index 41301764a..226d75e02 100644 --- a/docs/superpowers/plans/2026-04-21-macdive-uddf-gap-fill.md +++ b/docs/superpowers/plans/2026-04-21-macdive-uddf-gap-fill.md @@ -12,11 +12,49 @@ --- +## Milestone 1 Status — COMPLETE + +- All 12 tasks landed or explicitly skipped (Task 3 proven non-bug; see Task 3 section). +- Schema bumped v69 → v70 (source_uuid on dive_data_sources) → v71 + (6 new dive + site metadata columns: boat_name, boat_captain, + dive_operator, surface_conditions on dives; water_type, body_of_water + on dive_sites). Task 8 and Task 10's snippets below show an earlier + plan that also added `dive_number_of_day`; see "Post-completion + revision" note directly below. +- Cross-format import dedup now works via `dive_data_sources.source_uuid`. +- Parser-to-DB gap closed for MacDive rich fields. `weather` now lands + on the existing weather_description column. difficulty continues to + flow through the DiveSite entity path. +- Dropped from scope: personalMode, altitudeMode, signature, site flag + (niche / redundant). LinkRefKind/LinkRefIndex (Task 2) also removed + after Task 3 investigation showed the bug they targeted didn't exist. +- Known limitation: profile `gasMixRef` (from ``) is + parsed but not yet persisted to the profile samples table — deferred + to a future milestone, likely via dive-events. +- Real-sample regression test passes (gated behind `@Tags(['real-data'])`). + +### Post-completion revision: dive_number_of_day is derived, not stored + +Originally this milestone planned a `dive_number_of_day` INT column on +`dives`, populated from UDDF ``. After landing, +review found the value is trivially derivable from `diveDateTime` +(`ROW_NUMBER() OVER (PARTITION BY DATE(diveDateTime) ORDER BY +diveDateTime)`) and has no current reader in the app. Storing it would +also desync the moment a diver manually adds a dive between two +imported dives. The column, its v71 ALTER, parser extraction, +`IncomingDiveData` field, and importer write path were all removed. +Task 8's "Add fields to the domain model" snippet and Task 10's DB +write snippet in this document are the pre-revision plan; they are +kept for historical context. `` is now ignored on +import and computed on demand if/when the UI needs it. + +--- + ## File Structure | File | Role | New / Modified | |---|---|---| -| `lib/core/database/database.dart` | Add `sourceUuid` columns to `Dives`, `DiveSites`, `Buddies`, `Gear`, `Tags`, `Certifications`, `Species`, `DiveTypes`, `Trips`. Bump schema version and add migration step. | Modified | +| `lib/core/database/database.dart` | Add single `sourceUuid TEXT NULL` column to the existing `DiveDataSources` 1:N sidecar table. That's where per-source dive identity already lives (alongside `rawFingerprint`, `sourceFormat`, `sourceFileName`, `computerSerial`). Bump schema version 69 → 70 and add migration step. No changes to `dives`, `dive_sites`, `buddies`, or any other top-level entity tables. | Modified | | `lib/core/services/export/uddf/dialects/macdive_dialect.dart` | Add `` normalization for surface interval; preserve idempotency for all existing rewrites. | Modified | | `lib/core/services/export/uddf/uddf_import_parsers.dart` | Add `resolveLinkRef(XmlElement, Map)` helper that resolves a `` against a pre-built index of top-level IDs and returns the entity kind. | Modified | | `lib/core/services/export/uddf/uddf_full_import_service.dart` | Build ID→kind index once per import; use the helper inside `informationbeforedive` / `equipmentused` parsing; extract newly-supported dive and site fields; store each entity's source UUID on its dive/site/buddy/etc. map under `sourceUuid`. | Modified | @@ -29,7 +67,9 @@ --- -## Task 1: Schema migration — add `source_uuid` columns +## Task 1: Schema migration — add `source_uuid` column to `dive_data_sources` + +**Design rationale:** The project already has a 1:N `dive_data_sources` sidecar table carrying per-source dive metadata: `raw_fingerprint` (libdivecomputer), `source_format`, `source_file_name`, `computer_serial`, etc. This is where "where this dive came from" already lives. Adding `source_uuid` here (rather than on `dives` or on every top-level table) keeps the schema change minimal (one column, one table) and aligns with the existing architecture. libdivecomputer continues to use its existing `raw_fingerprint` BLOB — different mechanism, same role. The new `source_uuid` column captures the string/UUID identifiers that Shearwater Cloud (`DiveId`), MacDive (UDDF ``, XML ``, SQLite `ZUUID`), Subsurface SSRF, and generic UDDF all provide. **Files:** - Modify: `lib/core/database/database.dart` @@ -40,37 +80,22 @@ Create `test/core/database/source_uuid_migration_test.dart`: ```dart -import 'package:drift/drift.dart'; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:submersion/core/database/database.dart'; void main() { - test('fresh database has source_uuid column on dives and related tables', - () async { + test('fresh database has source_uuid column on dive_data_sources', () async { final db = AppDatabase.forTesting(NativeDatabase.memory()); - final tables = [ - 'dives', - 'dive_sites', - 'buddies', - 'gear', - 'tags', - 'certifications', - 'species', - 'dive_types', - 'trips', - ]; - for (final table in tables) { - final cols = await db - .customSelect("PRAGMA table_info('$table')") - .get(); - final names = cols.map((r) => r.data['name'] as String).toSet(); - expect( - names.contains('source_uuid'), - isTrue, - reason: 'table $table must have source_uuid column', - ); - } + final cols = await db + .customSelect("PRAGMA table_info('dive_data_sources')") + .get(); + final names = cols.map((r) => r.data['name'] as String).toSet(); + expect( + names.contains('source_uuid'), + isTrue, + reason: 'dive_data_sources must have source_uuid column', + ); await db.close(); }); } @@ -79,33 +104,32 @@ void main() { - [ ] **Step 2: Run test to verify it fails** Run: `flutter test test/core/database/source_uuid_migration_test.dart` -Expected: FAIL — `table dives must have source_uuid column`. +Expected: FAIL — `dive_data_sources must have source_uuid column`. -- [ ] **Step 3: Add columns to each table definition in `database.dart`** +- [ ] **Step 3: Add the column to `DiveDataSources`** -For each of the nine tables, add: +In `lib/core/database/database.dart`, locate `class DiveDataSources extends Table` (around line 953). Add a nullable text column near the other per-source identity columns (e.g. immediately after `rawFingerprint`): ```dart -TextColumn get sourceUuid => text().nullable()(); + TextColumn get sourceUuid => text().nullable()(); ``` -Place it at the end of each table class before the `override Set get primaryKey => …` (if any). - - [ ] **Step 4: Bump schema version and add migration step** -Open `lib/core/database/database.dart`, find the `schemaVersion` getter, bump it by one (e.g. `49 → 50`). In the `MigrationStrategy.onUpgrade`, add a new case matching the new version that runs: +Find `currentSchemaVersion` (around line 1329) and bump from `69` to `70`. + +Locate the existing `dive_data_sources` ALTER migration pattern (around lines 3083-3108 — where `raw_fingerprint`, `descriptor_vendor`, `descriptor_product`, etc. are added with `if (!existing.contains('X')) { await customStatement('ALTER TABLE dive_data_sources ADD COLUMN X Y') }`). Append to the same block (inside the same `if (from < N)` guard or in a new `if (from < 70)` block, matching the file's existing idiom): ```dart -if (from < 50) { - for (final table in const [ - 'dives', 'dive_sites', 'buddies', 'gear', 'tags', - 'certifications', 'species', 'dive_types', 'trips', - ]) { - await m.customStatement('ALTER TABLE $table ADD COLUMN source_uuid TEXT;'); - } +if (!existing.contains('source_uuid')) { + await customStatement( + 'ALTER TABLE dive_data_sources ADD COLUMN source_uuid TEXT', + ); } ``` +Grep the existing migration code for the exact `existing` variable name and reuse it; don't duplicate the `PRAGMA table_info` introspection if it's already in scope. + - [ ] **Step 5: Regenerate drift outputs and run the test** Run: @@ -118,9 +142,8 @@ Expected: PASS. - [ ] **Step 6: Commit** ```bash -git add lib/core/database/database.dart test/core/database/source_uuid_migration_test.dart -git add lib/core/database/database.g.dart # generated -git commit -m "feat(db): add source_uuid columns for cross-format import dedup" +git add lib/core/database/database.dart lib/core/database/database.g.dart test/core/database/source_uuid_migration_test.dart +git commit -m "feat(db): add source_uuid to dive_data_sources for cross-format import dedup" ``` --- @@ -202,7 +225,19 @@ git commit -m "feat(uddf): add LinkRefIndex for ref-kind disambiguation" --- -## Task 3: Build and use the ID index in `UddfFullImportService` +## Task 3: Build and use the ID index in `UddfFullImportService` — **SKIPPED** + +**Investigation outcome:** The plan's premise (that the current parser assumes positional order for `` children in ``) turned out to be wrong. The existing `_parseUddfDive` at `uddf_full_import_service.dart:1173-1211` already classifies each link by looking up its `ref` attribute in pre-built entity maps (`sites`, `buddies`, `decoModels`, `diveComputers`). The prescribed failing test passes against unmodified code. + +**What the real gap is (deferred):** `_parseFullDive` at lines 557-571 uses string-prefix matching (`ref.startsWith('trip_')`, `'center_'`, `'course_'`) to classify trip/center/course refs. This WOULD fail for MacDive-style UUIDs — but MacDive UDDF (per the user's 29MB sample) does not emit `` / `` / `` references at all. For Milestone 1 scope (MacDive UDDF), this codepath is not exercised. Revisit in Milestone 3 (MacDive SQLite) if trip/center refs start showing up. + +**LinkRefKind / LinkRefIndex from Task 2 are retained** — they cost ~22 lines + 2 tests and may be useful for the Milestone 3 SQLite work where cross-entity UUID resolution matters. + +**Status:** Task 3 marked as SKIPPED in the task tracker. No code change for this task. + +--- + +## Task 3 (ORIGINAL, for reference) — not executed **Files:** - Modify: `lib/core/services/export/uddf/uddf_full_import_service.dart` diff --git a/lib/core/database/database.dart b/lib/core/database/database.dart index 1420da27d..d669cda5e 100644 --- a/lib/core/database/database.dart +++ b/lib/core/database/database.dart @@ -126,6 +126,11 @@ class Dives extends Table { text().withDefault(const Constant('recreational'))(); TextColumn get buddy => text().nullable()(); TextColumn get diveMaster => text().nullable()(); + // MacDive import fields — common dive metadata + TextColumn get boatName => text().nullable()(); + TextColumn get boatCaptain => text().nullable()(); + TextColumn get diveOperator => text().nullable()(); + TextColumn get surfaceConditions => text().nullable()(); TextColumn get notes => text().withDefault(const Constant(''))(); TextColumn get siteId => text().nullable().references(DiveSites, #id)(); IntColumn get rating => integer().nullable()(); @@ -300,6 +305,9 @@ class DiveSites extends Table { RealColumn get maxDepth => real().nullable()(); // Deepest point TextColumn get difficulty => text().nullable()(); // Beginner, Intermediate, Advanced, Technical + // MacDive site metadata + TextColumn get waterType => text().nullable()(); + TextColumn get bodyOfWater => text().nullable()(); TextColumn get country => text().nullable()(); TextColumn get region => text().nullable()(); RealColumn get rating => real().nullable()(); @@ -983,6 +991,7 @@ class DiveDataSources extends Table { DateTimeColumn get createdAt => dateTime()(); BlobColumn get rawData => blob().nullable()(); BlobColumn get rawFingerprint => blob().nullable()(); + TextColumn get sourceUuid => text().nullable()(); TextColumn get descriptorVendor => text().nullable()(); TextColumn get descriptorProduct => text().nullable()(); IntColumn get descriptorModel => integer().nullable()(); @@ -1326,7 +1335,7 @@ class AppDatabase extends _$AppDatabase { /// The current schema version as a static constant so that pre-open checks /// (e.g. version-mismatch guard) can reference it without an instance. - static const int currentSchemaVersion = 69; + static const int currentSchemaVersion = 71; /// Every schema version that has a migration block in onUpgrade. /// Used to calculate progress step counts. When adding a new migration, @@ -1399,6 +1408,8 @@ class AppDatabase extends _$AppDatabase { 67, 68, 69, + 70, + 71, ]; /// Returns the number of migration steps that will execute when upgrading @@ -3244,6 +3255,75 @@ class AppDatabase extends _$AppDatabase { } } if (from < 69) await reportProgress(); + if (from < 70) { + // Migration 70: add source_uuid to dive_data_sources for + // cross-format import deduplication (MacDive UUID, Shearwater + // DiveId, Subsurface SSRF id, generic UDDF dive id). + // libdivecomputer continues to use raw_fingerprint. + // Guard: dive_data_sources may not exist in older migration tests. + final cols = await customSelect( + "PRAGMA table_info('dive_data_sources')", + ).get(); + if (cols.isNotEmpty) { + final existing = cols.map((c) => c.read('name')).toSet(); + if (!existing.contains('source_uuid')) { + await customStatement( + 'ALTER TABLE dive_data_sources ADD COLUMN source_uuid TEXT', + ); + } + } + } + if (from < 70) await reportProgress(); + if (from < 71) { + // Migration 71: add MacDive dive + site metadata fields. + final divesCols = await customSelect( + "PRAGMA table_info('dives')", + ).get(); + final divesExisting = divesCols + .map((r) => r.data['name'] as String) + .toSet(); + if (divesCols.isNotEmpty) { + if (!divesExisting.contains('boat_name')) { + await customStatement( + 'ALTER TABLE dives ADD COLUMN boat_name TEXT', + ); + } + if (!divesExisting.contains('boat_captain')) { + await customStatement( + 'ALTER TABLE dives ADD COLUMN boat_captain TEXT', + ); + } + if (!divesExisting.contains('dive_operator')) { + await customStatement( + 'ALTER TABLE dives ADD COLUMN dive_operator TEXT', + ); + } + if (!divesExisting.contains('surface_conditions')) { + await customStatement( + 'ALTER TABLE dives ADD COLUMN surface_conditions TEXT', + ); + } + } + final sitesCols = await customSelect( + "PRAGMA table_info('dive_sites')", + ).get(); + final sitesExisting = sitesCols + .map((r) => r.data['name'] as String) + .toSet(); + if (sitesCols.isNotEmpty) { + if (!sitesExisting.contains('water_type')) { + await customStatement( + 'ALTER TABLE dive_sites ADD COLUMN water_type TEXT', + ); + } + if (!sitesExisting.contains('body_of_water')) { + await customStatement( + 'ALTER TABLE dive_sites ADD COLUMN body_of_water TEXT', + ); + } + } + } + if (from < 71) await reportProgress(); }, beforeOpen: (details) async { // Enable foreign keys diff --git a/lib/core/domain/models/incoming_dive_data.dart b/lib/core/domain/models/incoming_dive_data.dart index 3457baaf8..7d385c428 100644 --- a/lib/core/domain/models/incoming_dive_data.dart +++ b/lib/core/domain/models/incoming_dive_data.dart @@ -18,6 +18,12 @@ class IncomingDiveData { final String? computerSerial; final List profile; final String? siteName; + final String? weather; + final String? surfaceConditions; + final String? boatName; + final String? boatCaptain; + final String? diveOperator; + final String? sourceUuid; const IncomingDiveData({ this.startTime, @@ -30,6 +36,12 @@ class IncomingDiveData { this.computerSerial, this.profile = const [], this.siteName, + this.weather, + this.surfaceConditions, + this.boatName, + this.boatCaptain, + this.diveOperator, + this.sourceUuid, }); /// Create from a [DownloadedDive] (dive computer download flow). @@ -86,6 +98,12 @@ class IncomingDiveData { computerSerial: data['diveComputerSerial'] as String?, siteName: data['siteName'] as String?, profile: profile, + weather: data['weather'] as String?, + surfaceConditions: data['surfaceConditions'] as String?, + boatName: data['boatName'] as String?, + boatCaptain: data['boatCaptain'] as String?, + diveOperator: data['diveOperator'] as String?, + sourceUuid: data['sourceUuid'] as String?, ); } } diff --git a/lib/core/services/export/uddf/uddf_full_import_service.dart b/lib/core/services/export/uddf/uddf_full_import_service.dart index 6e04b84cd..afb932ee3 100644 --- a/lib/core/services/export/uddf/uddf_full_import_service.dart +++ b/lib/core/services/export/uddf/uddf_full_import_service.dart @@ -210,6 +210,12 @@ class UddfFullImportService { final equipmentSets = >[]; final courses = >[]; + // Standard UDDF location: with child elements + // like , , , , , etc. + // MacDive and other UDDF-compliant exporters use this location rather + // than the Submersion-private . + _collectStandardEquipment(uddfElement, equipment); + final appDataElement = uddfElement .findElements('applicationdata') .firstOrNull; @@ -218,16 +224,25 @@ class UddfFullImportService { .findElements('submersion') .firstOrNull; if (submersionElement != null) { - // Parse equipment + // Parse equipment from the Submersion-private extension path. + // Dedup against items already extracted from the standard location. + final existingGearUuids = { + for (final e in equipment) + if (e['sourceUuid'] is String) e['sourceUuid'] as String, + }; final equipmentSection = submersionElement .findElements('equipment') .firstOrNull; if (equipmentSection != null) { for (final itemElement in equipmentSection.findElements('item')) { final itemData = UddfImportParsers.parseEquipmentItem(itemElement); - if (itemData.isNotEmpty) { - equipment.add(itemData); + if (itemData.isEmpty) continue; + final uddfId = itemData['uddfId']; + if (uddfId is String && existingGearUuids.contains(uddfId)) { + continue; } + equipment.add(itemData); + if (uddfId is String) existingGearUuids.add(uddfId); } } @@ -459,6 +474,23 @@ class UddfFullImportService { 'notes', ); + // Extract MacDive-specific site fields + final siteId = siteElement.getAttribute('id'); + if (siteId != null && siteId.isNotEmpty) { + site['sourceUuid'] = siteId; + } + + for (final entry in const { + 'watertype': 'waterType', + 'bodyofwater': 'bodyOfWater', + 'difficulty': 'difficulty', + }.entries) { + final v = UddfImportParsers.getElementText(siteElement, entry.key); + if (v != null && v.isNotEmpty) { + site[entry.value] = v; + } + } + return site; } @@ -480,6 +512,13 @@ class UddfFullImportService { diveComputers, ); + // Capture the source UUID from the element's id attribute so + // downstream consumers (e.g., dive_data_sources sidecar) can persist it. + final diveId = diveElement.getAttribute('id'); + if (diveId != null && diveId.isNotEmpty) { + diveData['sourceUuid'] = diveId; + } + // Parse additional fields from informationbeforedive final beforeElement = diveElement .findElements('informationbeforedive') @@ -622,6 +661,21 @@ class UddfFullImportService { .findElements('informationafterdive') .firstOrNull; if (afterElement != null) { + // MacDive-specific string fields that the standard UDDF parser ignores. + // Element local name -> diveData map key. + for (final entry in const { + 'weather': 'weather', + 'surfaceconditions': 'surfaceConditions', + 'boatname': 'boatName', + 'boatcaptain': 'boatCaptain', + 'diveoperator': 'diveOperator', + }.entries) { + final text = UddfImportParsers.getElementText(afterElement, entry.key); + if (text != null && text.isNotEmpty) { + diveData[entry.value] = text; + } + } + final waterType = UddfImportParsers.getElementText( afterElement, 'watertype', @@ -1156,14 +1210,25 @@ class UddfFullImportService { } } - // Parse equipment references + // Parse equipment references (both and ) final equipmentRefs = []; + + // Collect elements for (final equipRef in equipmentElement.findElements('equipmentref')) { final ref = equipRef.innerText.trim(); if (ref.isNotEmpty) { equipmentRefs.add(ref); } } + + // Collect elements from equipmentused + for (final linkElement in equipmentElement.findElements('link')) { + final ref = linkElement.getAttribute('ref'); + if (ref != null && ref.isNotEmpty && !equipmentRefs.contains(ref)) { + equipmentRefs.add(ref); + } + } + if (equipmentRefs.isNotEmpty) { diveData['equipmentRefs'] = equipmentRefs; } @@ -1230,6 +1295,43 @@ class UddfFullImportService { } } + // Also parse equipment refs from informationafterdive (if they exist there) + final afterDiveElement = diveElement + .findElements('informationafterdive') + .firstOrNull; + if (afterDiveElement != null) { + final afterEquipmentElement = afterDiveElement + .findElements('equipmentused') + .firstOrNull; + if (afterEquipmentElement != null) { + // Get existing refs if any (from beforeElement) + final existingRefs = + (diveData['equipmentRefs'] as List?) ?? []; + + // Collect elements from afterDiveElement.equipmentused + for (final linkElement in afterEquipmentElement.findElements('link')) { + final ref = linkElement.getAttribute('ref'); + if (ref != null && ref.isNotEmpty && !existingRefs.contains(ref)) { + existingRefs.add(ref); + } + } + + // Collect elements from afterDiveElement.equipmentused + for (final equipRef in afterEquipmentElement.findElements( + 'equipmentref', + )) { + final ref = equipRef.innerText.trim(); + if (ref.isNotEmpty && !existingRefs.contains(ref)) { + existingRefs.add(ref); + } + } + + if (existingRefs.isNotEmpty) { + diveData['equipmentRefs'] = existingRefs; + } + } + } + // Parse tank data final tanks = >[]; for (final tankDataElement in diveElement.findElements('tankdata')) { @@ -1452,17 +1554,22 @@ class UddfFullImportService { final switchMix = waypoint.findElements('switchmix').firstOrNull; if (switchMix != null) { final mixRef = switchMix.getAttribute('ref'); - if (mixRef != null && gasMixes.containsKey(mixRef)) { - currentMix = gasMixes[mixRef]; - pendingSwitchMix = currentMix; - - if (tanks.length == 1) { - UddfImportParsers.assignGasMixToTankIfMissing( - tanks: tanks, - tankIndex: 0, - gasMix: currentMix!, - ); - pendingSwitchMix = null; + if (mixRef != null) { + // Record the gas mix reference on the sample for downstream consumers + point['gasMixRef'] = mixRef; + + if (gasMixes.containsKey(mixRef)) { + currentMix = gasMixes[mixRef]; + pendingSwitchMix = currentMix; + + if (tanks.length == 1) { + UddfImportParsers.assignGasMixToTankIfMissing( + tanks: tanks, + tankIndex: 0, + gasMix: currentMix!, + ); + pendingSwitchMix = null; + } } } } @@ -1900,4 +2007,170 @@ class UddfFullImportService { } return 'recreational'; } + + /// UDDF 3.2 standard equipment child elements that appear under + /// ``. Each element represents one gear item and + /// maps to a human-readable type plus the matching [enums.EquipmentType]. + static const Map + _standardGearTags = { + 'variouspieces': (label: 'Accessory', type: enums.EquipmentType.other), + 'suit': (label: 'Suit', type: enums.EquipmentType.wetsuit), + 'divecomputer': ( + label: 'Dive Computer', + type: enums.EquipmentType.computer, + ), + 'regulator': (label: 'Regulator', type: enums.EquipmentType.regulator), + 'bcd': (label: 'BCD', type: enums.EquipmentType.bcd), + 'boots': (label: 'Boots', type: enums.EquipmentType.boots), + 'fins': (label: 'Fins', type: enums.EquipmentType.fins), + 'compass': (label: 'Compass', type: enums.EquipmentType.other), + 'knife': (label: 'Knife', type: enums.EquipmentType.knife), + 'tankrelatedequipment': (label: 'Tank', type: enums.EquipmentType.tank), + }; + + /// Scans the standard UDDF location (``) for + /// gear entries and appends them to [equipment]. Dedupes by the `id` + /// attribute against anything already collected. + void _collectStandardEquipment( + XmlElement uddfElement, + List> equipment, + ) { + final existingGearUuids = { + for (final e in equipment) + if (e['sourceUuid'] is String) e['sourceUuid'] as String, + }; + for (final diverEl in uddfElement.findElements('diver')) { + for (final ownerEl in diverEl.findElements('owner')) { + final equipContainer = ownerEl.findElements('equipment').firstOrNull; + if (equipContainer == null) continue; + for (final entry in _standardGearTags.entries) { + final tagName = entry.key; + final tagMeta = entry.value; + for (final itemEl in equipContainer.findElements(tagName)) { + final id = itemEl.getAttribute('id'); + if (id != null && existingGearUuids.contains(id)) continue; + final itemData = _parseStandardEquipmentItem( + itemEl, + tagName: tagName, + label: tagMeta.label, + type: tagMeta.type, + ); + if (itemData.isEmpty) continue; + equipment.add(itemData); + if (id != null && id.isNotEmpty) existingGearUuids.add(id); + } + } + } + } + } + + /// Parses a single UDDF standard equipment element (e.g. ``, + /// ``, ``) into a map with the same key conventions + /// used by the Submersion-extension path (`parseEquipmentItem`), plus a + /// `sourceUuid` carrying the element's `id` attribute. + Map _parseStandardEquipmentItem( + XmlElement itemElement, { + required String tagName, + required String label, + required enums.EquipmentType type, + }) { + final item = {}; + final id = itemElement.getAttribute('id'); + if (id != null && id.isNotEmpty) { + item['sourceUuid'] = id; + item['uddfId'] = id; + } + + final name = UddfImportParsers.getElementText(itemElement, 'name'); + if (name != null && name.isNotEmpty) { + item['name'] = name; + } + + // UDDF wraps manufacturer in a nested element with its own child. + final manufacturerEl = itemElement.findElements('manufacturer').firstOrNull; + String? manufacturer; + if (manufacturerEl != null) { + manufacturer = UddfImportParsers.getElementText(manufacturerEl, 'name'); + } + manufacturer ??= UddfImportParsers.getElementText( + itemElement, + 'manufacturer', + ); + if (manufacturer != null && manufacturer.isNotEmpty) { + item['manufacturer'] = manufacturer; + // Also store under 'brand' so the downstream duplicate checker and + // entity importer (which read 'brand') find the same value. + item['brand'] = manufacturer; + } + + final model = UddfImportParsers.getElementText(itemElement, 'model'); + if (model != null && model.isNotEmpty) { + item['model'] = model; + } + + final serial = UddfImportParsers.getElementText( + itemElement, + 'serialnumber', + ); + if (serial != null && serial.isNotEmpty) { + item['serial'] = serial; + item['serialNumber'] = serial; + } + + // Narrow the suit type for wet-suit vs dry-suit when the sub-element + // is present; defaults to wetsuit. + if (tagName == 'suit') { + final suitType = UddfImportParsers.getElementText( + itemElement, + 'suittype', + ); + if (suitType != null && suitType.toLowerCase().contains('dry')) { + item['type'] = enums.EquipmentType.drysuit; + } else { + item['type'] = type; + } + } else { + item['type'] = type; + } + item['typeLabel'] = label; + + final purchase = itemElement.findElements('purchase').firstOrNull; + if (purchase != null) { + final dateText = + UddfImportParsers.getElementText(purchase, 'date') ?? + UddfImportParsers.getElementText(purchase, 'datetime'); + if (dateText != null) { + final parsed = DateTime.tryParse(dateText); + if (parsed != null) { + item['purchaseDate'] = parsed; + } + } + final price = UddfImportParsers.getElementText(purchase, 'price'); + if (price != null) { + item['purchasePrice'] = double.tryParse(price); + } + final currency = UddfImportParsers.getElementText(purchase, 'currency'); + if (currency != null && currency.isNotEmpty) { + item['purchaseCurrency'] = currency; + } + } else { + final purchaseDateText = UddfImportParsers.getElementText( + itemElement, + 'purchasedate', + ); + if (purchaseDateText != null) { + final parsed = DateTime.tryParse(purchaseDateText); + if (parsed != null) { + item['purchaseDate'] = parsed; + } + } + } + + final notes = UddfImportParsers.getElementText(itemElement, 'notes'); + if (notes != null && notes.isNotEmpty) { + item['notes'] = notes; + } + + return item; + } } diff --git a/lib/features/dive_import/data/services/uddf_entity_importer.dart b/lib/features/dive_import/data/services/uddf_entity_importer.dart index 02142a360..6fb329989 100644 --- a/lib/features/dive_import/data/services/uddf_entity_importer.dart +++ b/lib/features/dive_import/data/services/uddf_entity_importer.dart @@ -1,7 +1,7 @@ import 'package:drift/drift.dart' show Value; import 'package:submersion/core/constants/enums.dart'; import 'package:submersion/core/database/database.dart' - show DiveDataSourcesCompanion; + show DiveDataSourcesCompanion, DiveSitesCompanion, DivesCompanion; import 'package:submersion/core/services/export/export_service.dart'; import 'package:submersion/core/services/location_service.dart'; import 'package:submersion/core/services/logger_service.dart'; @@ -813,6 +813,25 @@ class UddfEntityImporter { ); final createdSite = await repository.createSite(newSite); + + // Write MacDive site metadata columns that don't flow through the + // DiveSite domain entity. Only set columns when source provides a value. + final waterType = siteData['waterType'] as String?; + final bodyOfWater = siteData['bodyOfWater'] as String?; + if (waterType != null || bodyOfWater != null) { + await repository.applyImportedMetadata( + createdSite.id, + DiveSitesCompanion( + waterType: waterType != null + ? Value(waterType) + : const Value.absent(), + bodyOfWater: bodyOfWater != null + ? Value(bodyOfWater) + : const Value.absent(), + ), + ); + } + if (uddfId != null) idMapping[uddfId] = createdSite; count++; onProgress?.call(ImportPhase.sites, count, selected.length); @@ -1188,6 +1207,40 @@ class UddfEntityImporter { await repos.diveRepository.createDive(dive); importedDiveIds.add(diveId); + // Write MacDive dive metadata columns that don't flow through the Dive + // domain entity. Also plug `weather` into the existing weatherDescription + // column (it wasn't being populated for UDDF imports). Only issue the + // UPDATE when at least one value is present to avoid a no-op write. + final boatName = diveData['boatName'] as String?; + final boatCaptain = diveData['boatCaptain'] as String?; + final diveOperator = diveData['diveOperator'] as String?; + final surfaceConditions = diveData['surfaceConditions'] as String?; + final weather = diveData['weather'] as String?; + if (boatName != null || + boatCaptain != null || + diveOperator != null || + surfaceConditions != null || + weather != null) { + await repos.diveRepository.applyImportedMetadata( + diveId, + DivesCompanion( + boatName: boatName != null ? Value(boatName) : const Value.absent(), + boatCaptain: boatCaptain != null + ? Value(boatCaptain) + : const Value.absent(), + diveOperator: diveOperator != null + ? Value(diveOperator) + : const Value.absent(), + surfaceConditions: surfaceConditions != null + ? Value(surfaceConditions) + : const Value.absent(), + weatherDescription: weather != null + ? Value(weather) + : const Value.absent(), + ), + ); + } + // Store per-tank pressure data if (profileData != null && tanks.isNotEmpty) { await _storeTankPressures( @@ -1432,6 +1485,7 @@ class UddfEntityImporter { computerSerial: Value(diveData['diveComputerSerial'] as String?), sourceFileName: Value(sourceFileName), sourceFileFormat: const Value('uddf'), + sourceUuid: Value(diveData['sourceUuid'] as String?), maxDepth: Value(asDoubleOrNull(diveData['maxDepth'])), avgDepth: Value(asDoubleOrNull(diveData['avgDepth'])), duration: Value(dive.bottomTime?.inSeconds), diff --git a/lib/features/dive_log/data/repositories/dive_repository_impl.dart b/lib/features/dive_log/data/repositories/dive_repository_impl.dart index 2233b7077..95bd06efa 100644 --- a/lib/features/dive_log/data/repositories/dive_repository_impl.dart +++ b/lib/features/dive_log/data/repositories/dive_repository_impl.dart @@ -3573,6 +3573,37 @@ class DiveRepository { } } + /// Apply a partial [DivesCompanion] update to a dive row. + /// + /// Used by the UDDF importer to persist fields that do not flow through + /// the [domain.Dive] entity (e.g. MacDive boat/operator/weather metadata). + /// Only columns set on [patch] are written; others are left untouched. + /// Marks the row pending for sync. + Future applyImportedMetadata( + String diveId, + DivesCompanion patch, + ) async { + try { + final now = DateTime.now().millisecondsSinceEpoch; + await (_db.update(_db.dives)..where((t) => t.id.equals(diveId))).write( + patch.copyWith(updatedAt: Value(now)), + ); + await _syncRepository.markRecordPending( + entityType: 'dives', + recordId: diveId, + localUpdatedAt: now, + ); + SyncEventBus.notifyLocalChange(); + } catch (e, stackTrace) { + _log.error( + 'Failed to apply imported metadata to dive: $diveId', + error: e, + stackTrace: stackTrace, + ); + rethrow; + } + } + /// Insert a new computer reading snapshot. Future saveComputerReading(DiveDataSourcesCompanion reading) async { try { diff --git a/lib/features/dive_sites/data/repositories/site_repository_impl.dart b/lib/features/dive_sites/data/repositories/site_repository_impl.dart index 53548ea25..16a1710dc 100644 --- a/lib/features/dive_sites/data/repositories/site_repository_impl.dart +++ b/lib/features/dive_sites/data/repositories/site_repository_impl.dart @@ -153,6 +153,36 @@ class SiteRepository { } } + /// Apply a partial [DiveSitesCompanion] update to a site row. + /// + /// Used by the UDDF importer to persist columns that do not flow through + /// the [domain.DiveSite] entity (e.g. MacDive waterType / bodyOfWater). + /// Only columns set on [patch] are written; others are left untouched. + /// Marks the row pending for sync. + Future applyImportedMetadata( + String siteId, + DiveSitesCompanion patch, + ) async { + try { + final now = DateTime.now().millisecondsSinceEpoch; + await (_db.update(_db.diveSites)..where((t) => t.id.equals(siteId))) + .write(patch.copyWith(updatedAt: Value(now))); + await _syncRepository.markRecordPending( + entityType: 'diveSites', + recordId: siteId, + localUpdatedAt: now, + ); + SyncEventBus.notifyLocalChange(); + } catch (e, stackTrace) { + _log.error( + 'Failed to apply imported metadata to site: $siteId', + error: e, + stackTrace: stackTrace, + ); + rethrow; + } + } + /// Flip the shared state of a single site. Marks it pending for sync. Future setShared(String id, bool isShared) async { try { diff --git a/test/core/database/migration_v70_test.dart b/test/core/database/migration_v70_test.dart new file mode 100644 index 000000000..5ac42644a --- /dev/null +++ b/test/core/database/migration_v70_test.dart @@ -0,0 +1,109 @@ +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/database/database.dart'; + +void main() { + group('Migration v70 - source_uuid on dive_data_sources', () { + /// Creates an in-memory database at v69 (pre-migration) with the + /// dive_data_sources table the v70 migration touches. + /// + /// The dive_data_sources schema matches the post-v66 layout (final v69 + /// state) with all columns present before v70 -- no source_uuid column. + NativeDatabase setupDb() { + return NativeDatabase.memory( + setup: (rawDb) { + rawDb.execute('PRAGMA foreign_keys = ON'); + rawDb.execute('PRAGMA user_version = 69'); + + // Minimal parent tables referenced by dive_data_sources foreign keys. + // dive_data_sources references dives(id) and dive_computers(id). + rawDb.execute(''' + CREATE TABLE dives ( + id TEXT NOT NULL PRIMARY KEY + ) + '''); + rawDb.execute(''' + CREATE TABLE dive_computers ( + id TEXT NOT NULL PRIMARY KEY + ) + '''); + + // v69 schema: all columns present before v70 -- no source_uuid. + // Matches the layout produced by the v66 rebuild in database.dart. + rawDb.execute(''' + CREATE TABLE dive_data_sources ( + id TEXT NOT NULL PRIMARY KEY, + dive_id TEXT NOT NULL REFERENCES dives(id) ON DELETE CASCADE, + computer_id TEXT REFERENCES dive_computers(id) ON DELETE SET NULL, + is_primary INTEGER NOT NULL DEFAULT 0, + computer_model TEXT, + computer_serial TEXT, + source_format TEXT, + source_file_name TEXT, + source_file_format TEXT, + max_depth REAL, + avg_depth REAL, + duration INTEGER, + water_temp REAL, + entry_time INTEGER, + exit_time INTEGER, + max_ascent_rate REAL, + max_descent_rate REAL, + surface_interval INTEGER, + cns REAL, + otu REAL, + deco_algorithm TEXT, + gradient_factor_low INTEGER, + gradient_factor_high INTEGER, + imported_at INTEGER NOT NULL, + created_at INTEGER NOT NULL, + raw_data BLOB, + raw_fingerprint BLOB, + descriptor_vendor TEXT, + descriptor_product TEXT, + descriptor_model INTEGER, + libdivecomputer_version TEXT, + last_parsed_at INTEGER + ) + '''); + }, + ); + } + + test( + 'fresh database has source_uuid column on dive_data_sources', + () async { + final db = AppDatabase(NativeDatabase.memory()); + addTearDown(db.close); + + final cols = await db + .customSelect("PRAGMA table_info('dive_data_sources')") + .get(); + final names = cols.map((c) => c.read('name')).toSet(); + + expect( + names, + contains('source_uuid'), + reason: 'dive_data_sources must have source_uuid column', + ); + }, + ); + + test( + 'v69 -> v70 migration adds source_uuid column to dive_data_sources', + () async { + final nativeDb = setupDb(); + final db = AppDatabase(nativeDb); + addTearDown(db.close); + + // Opening the database triggers the migration from v69 to v70. + final cols = await db + .customSelect("PRAGMA table_info('dive_data_sources')") + .get(); + final names = cols.map((c) => c.read('name')).toSet(); + + expect(names, contains('source_uuid')); + }, + ); + }); +} diff --git a/test/core/database/migration_v71_test.dart b/test/core/database/migration_v71_test.dart new file mode 100644 index 000000000..fc2ed21f6 --- /dev/null +++ b/test/core/database/migration_v71_test.dart @@ -0,0 +1,115 @@ +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/database/database.dart'; + +void main() { + group('Migration v71 - MacDive dive + site metadata columns', () { + /// Creates an in-memory database at v70 (pre-migration) with the dives + /// and dive_sites tables the v71 migration touches. + /// + /// Only the parent tables and the two columns-of-interest tables are + /// created; the migration must cope with missing ancillary tables as it + /// runs in older migration contexts. + NativeDatabase setupDb() { + return NativeDatabase.memory( + setup: (rawDb) { + rawDb.execute('PRAGMA foreign_keys = ON'); + rawDb.execute('PRAGMA user_version = 70'); + + // v70 schema: minimal columns present before v71 -- no MacDive + // metadata. Only what's needed for the PRAGMA introspection to + // find the tables and the ALTER TABLE statements to succeed. + rawDb.execute(''' + CREATE TABLE dives ( + id TEXT NOT NULL PRIMARY KEY, + dive_date_time INTEGER NOT NULL DEFAULT 0, + dive_type TEXT NOT NULL DEFAULT 'recreational', + notes TEXT NOT NULL DEFAULT '', + is_favorite INTEGER NOT NULL DEFAULT 0, + dive_mode TEXT NOT NULL DEFAULT 'oc', + cns_start REAL NOT NULL DEFAULT 0, + is_planned INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) + '''); + rawDb.execute(''' + CREATE TABLE dive_sites ( + id TEXT NOT NULL PRIMARY KEY, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + notes TEXT NOT NULL DEFAULT '', + is_shared INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) + '''); + }, + ); + } + + test('fresh database has MacDive dive columns on dives table', () async { + final db = AppDatabase(NativeDatabase.memory()); + addTearDown(db.close); + + final cols = await db.customSelect("PRAGMA table_info('dives')").get(); + final names = cols.map((c) => c.read('name')).toSet(); + + expect(names, contains('boat_name')); + expect(names, contains('boat_captain')); + expect(names, contains('dive_operator')); + expect(names, contains('surface_conditions')); + }); + + test( + 'fresh database has MacDive site columns on dive_sites table', + () async { + final db = AppDatabase(NativeDatabase.memory()); + addTearDown(db.close); + + final cols = await db + .customSelect("PRAGMA table_info('dive_sites')") + .get(); + final names = cols.map((c) => c.read('name')).toSet(); + + expect(names, contains('water_type')); + expect(names, contains('body_of_water')); + }, + ); + + test( + 'v70 -> v71 migration adds MacDive dive columns idempotently', + () async { + final nativeDb = setupDb(); + final db = AppDatabase(nativeDb); + addTearDown(db.close); + + // Opening the database triggers migration from v70 to v71. + final cols = await db.customSelect("PRAGMA table_info('dives')").get(); + final names = cols.map((c) => c.read('name')).toSet(); + + expect(names, contains('boat_name')); + expect(names, contains('boat_captain')); + expect(names, contains('dive_operator')); + expect(names, contains('surface_conditions')); + }, + ); + + test( + 'v70 -> v71 migration adds MacDive site columns idempotently', + () async { + final nativeDb = setupDb(); + final db = AppDatabase(nativeDb); + addTearDown(db.close); + + final cols = await db + .customSelect("PRAGMA table_info('dive_sites')") + .get(); + final names = cols.map((c) => c.read('name')).toSet(); + + expect(names, contains('water_type')); + expect(names, contains('body_of_water')); + }, + ); + }); +} diff --git a/test/core/services/export/uddf/uddf_macdive_import_test.dart b/test/core/services/export/uddf/uddf_macdive_import_test.dart index 2d38ce797..ad5477785 100644 --- a/test/core/services/export/uddf/uddf_macdive_import_test.dart +++ b/test/core/services/export/uddf/uddf_macdive_import_test.dart @@ -172,6 +172,31 @@ const _submersionUddf = ''' '''; +// MacDive-style UDDF exercising the rich informationafterdive / before fields +// and the dive element's id attribute used as source UUID. +const _macDiveRichFields = ''' + + MacDive + + + + 2024-06-01T09:00:00 + 42 + + + 18 + 2400 + Sunny + Calm + MV Nautilus + Jane Smith + Nautilus Liveaboards + + 00 + + +'''; + // MacDive-style UDDF without , where equipmentused // is only in . const _macDiveNoBeforeInfo = ''' @@ -559,5 +584,216 @@ void main() { expect(gasMix, isNotNull); } }); + + test( + 'treats surface interval as absent (first dive)', + () async { + const uddf = ''' + + + + + + 2024-06-01T09:00:00 + + + 12 + 1800 + + 00 + + +'''; + final result = await service.importAllDataFromUddf(uddf); + final dive = result.dives.first; + expect( + dive.containsKey('surfaceInterval'), + isFalse, + reason: + ' means no prior dive; must not set surfaceInterval', + ); + }, + ); + }); + + group('UddfFullImportService - MacDive extended fields', () { + late UddfFullImportService service; + setUp(() => service = UddfFullImportService()); + + test('extracts weather, surfaceConditions, boatName, boatCaptain, ' + 'diveOperator, sourceUuid', () async { + final r = await service.importAllDataFromUddf(_macDiveRichFields); + final d = r.dives.first; + expect(d['weather'], 'Sunny'); + expect(d['surfaceConditions'], 'Calm'); + expect(d['boatName'], 'MV Nautilus'); + expect(d['boatCaptain'], 'Jane Smith'); + expect(d['diveOperator'], 'Nautilus Liveaboards'); + expect(d['sourceUuid'], 'd-RICH-UUID'); + }); + + test( + 'extracts site watertype, bodyOfWater, difficulty, sourceUuid', + () async { + const uddf = ''' + + + + Rich Site + saltwater + Pacific Ocean + advanced +
Mexico
+
+
+ + + + + 2024-06-01T09:00:00 + + + 12 + 1800 + + 00 + + +
'''; + final r = await service.importAllDataFromUddf(uddf); + final site = r.sites.firstWhere( + (s) => s['sourceUuid'] == 'site-RICH-UUID', + orElse: () => {}, + ); + expect(site['name'], 'Rich Site'); + expect(site['waterType'], 'saltwater'); + expect(site['bodyOfWater'], 'Pacific Ocean'); + expect(site['difficulty'], 'advanced'); + }, + ); + + test( + 'equipmentused resolves gear UUIDs from before AND after sections', + () async { + const uddf = ''' + + M + + Travel Reg + Hydros + + + + 2024-06-01T09:00:00 + + 10 + 1800 + + + + + + 00 + + +'''; + final r = await service.importAllDataFromUddf(uddf); + final dive = r.dives.first; + final refs = dive['equipmentRefs'] as List?; + expect(refs, isNotNull, reason: 'equipmentRefs must be populated'); + expect( + refs, + containsAll(['gear-REG-1', 'gear-BCD-1']), + reason: 'both gear UUIDs should be captured', + ); + }, + ); + + test('extracts equipment from ' + '(standard UDDF location)', () async { + const uddf = ''' + + + + MG + + + Hollis SS BP/W + Hollis + SS BP/W + + + + Aqualung 7mm + Aqualung + 7mm + wet-suit + + + Shearwater Tern + Shearwater + Tern + ABC123 + + + + + + + 2024-06-01T09:00:00 + 121800 + 00 + + +'''; + final r = await service.importAllDataFromUddf(uddf); + expect( + r.equipment.length, + 3, + reason: 'MacDive emits gear under ', + ); + final bcd = r.equipment.firstWhere( + (e) => e['sourceUuid'] == 'gear-BCD-UUID', + ); + expect(bcd['name'], 'Hollis SS BP/W'); + expect(bcd['manufacturer'], 'Hollis'); + expect(bcd['model'], 'SS BP/W'); + }); + + test( + 'samples with record gasMixRef on the right sample', + () async { + const uddf = ''' + + + 0.32 + 0.80 + + + + 2024-06-01T09:00:00 + 403600 + + 00 + 12030 + 24006 + + + +'''; + final r = await service.importAllDataFromUddf(uddf); + final profile = r.dives.first['profile'] as List>; + expect(profile.length, 3); + final switches = profile + .where((p) => p['gasMixRef'] != null) + .map((p) => p['gasMixRef']) + .toList(); + expect( + switches, + ['mix-bottom', 'mix-deco'], + reason: 'only samples with should have gasMixRef', + ); + }, + ); }); } diff --git a/test/core/services/export/uddf/uddf_macdive_real_sample_test.dart b/test/core/services/export/uddf/uddf_macdive_real_sample_test.dart new file mode 100644 index 000000000..6041363b1 --- /dev/null +++ b/test/core/services/export/uddf/uddf_macdive_real_sample_test.dart @@ -0,0 +1,141 @@ +/// MacDive real-sample regression suite. +/// +/// These tests exercise a real MacDive UDDF export that is not checked into +/// the repository. To run them locally, point the [MACDIVE_UDDF_SAMPLE] +/// compile-time environment variable at your local sample file: +/// +/// flutter test \ +/// --dart-define=MACDIVE_UDDF_SAMPLE=/absolute/path/to/sample.uddf \ +/// --run-skipped --tags=real-data \ +/// test/core/services/export/uddf/uddf_macdive_real_sample_test.dart +/// +/// Without the env var (or when the file at that path does not exist), every +/// test in this suite is cleanly skipped so CI and fresh clones stay green. +@Tags(['real-data']) +library; + +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/services/export/uddf/uddf_full_import_service.dart'; + +/// Compile-time env var that points at a local MacDive UDDF sample. +/// +/// Injected via `flutter test --dart-define=MACDIVE_UDDF_SAMPLE=...`. +const _realSamplePathEnvVar = String.fromEnvironment('MACDIVE_UDDF_SAMPLE'); + +String? _realSamplePath() { + if (_realSamplePathEnvVar.isEmpty) return null; + return _realSamplePathEnvVar; +} + +void main() { + group('MacDive real-sample regression', () { + late UddfFullImportService service; + late String content; + var hasFixture = false; + + setUpAll(() async { + final path = _realSamplePath(); + if (path == null) return; + final file = File(path); + if (!file.existsSync()) return; + content = await file.readAsString(); + service = UddfFullImportService(); + hasFixture = true; + }); + + bool skipIfNoFixture() { + if (hasFixture) return false; + markTestSkipped( + 'Real sample not available. Set MACDIVE_UDDF_SAMPLE via ' + '--dart-define and pass --run-skipped --tags=real-data to run.', + ); + return true; + } + + test('parses 540 dives', () async { + if (skipIfNoFixture()) return; + final result = await service.importAllDataFromUddf(content); + expect(result.dives.length, 540); + }); + + test('every dive has a sourceUuid from ', () async { + if (skipIfNoFixture()) return; + final result = await service.importAllDataFromUddf(content); + expect( + result.dives.every((d) => d['sourceUuid'] is String), + isTrue, + reason: 'every MacDive dive has a stable UUID in ', + ); + }); + + test('parses at least 350 sites', () async { + if (skipIfNoFixture()) return; + final result = await service.importAllDataFromUddf(content); + expect(result.sites.length, greaterThanOrEqualTo(350)); + }); + + test('parses at least 30 buddies', () async { + if (skipIfNoFixture()) return; + final result = await service.importAllDataFromUddf(content); + expect(result.buddies.length, greaterThanOrEqualTo(30)); + }); + + test( + 'parses at least 20 gear items from ', + () async { + if (skipIfNoFixture()) return; + final result = await service.importAllDataFromUddf(content); + expect( + result.equipment.length, + greaterThanOrEqualTo(20), + reason: 'sample has 29 equipment items (BCs, suits, computers, regs)', + ); + }, + ); + + test('at least one dive has equipmentRefs populated', () async { + if (skipIfNoFixture()) return; + final result = await service.importAllDataFromUddf(content); + final withGear = result.dives.where( + (d) => (d['equipmentRefs'] as List?)?.isNotEmpty ?? false, + ); + expect( + withGear, + isNotEmpty, + reason: 'MacDive emits on most dives', + ); + }); + + test('at least one dive has gas-switch markers', () async { + if (skipIfNoFixture()) return; + final result = await service.importAllDataFromUddf(content); + final withSwitch = result.dives.where((d) { + final profile = d['profile'] as List?; + if (profile == null) return false; + return profile.any((p) => (p as Map)['gasMixRef'] != null); + }); + expect( + withSwitch, + isNotEmpty, + reason: 'sample contains multi-gas dives with ', + ); + }); + + test('at least one site has country populated', () async { + if (skipIfNoFixture()) return; + final result = await service.importAllDataFromUddf(content); + final withCountry = result.sites.where( + (s) => (s['country'] as String?)?.isNotEmpty ?? false, + ); + expect( + withCountry, + isNotEmpty, + reason: + 'MacDive nests country under geography/address; ' + 'dialect normalization copies it to direct site child', + ); + }); + }); +} diff --git a/test/features/dive_computer/data/services/dive_import_service_test.mocks.dart b/test/features/dive_computer/data/services/dive_import_service_test.mocks.dart index 541eb7fdf..fc4d04f49 100644 --- a/test/features/dive_computer/data/services/dive_import_service_test.mocks.dart +++ b/test/features/dive_computer/data/services/dive_import_service_test.mocks.dart @@ -1000,6 +1000,18 @@ class MockDiveRepository extends _i1.Mock implements _i4.DiveRepository { ) as _i7.Future); + @override + _i7.Future applyImportedMetadata( + String? diveId, + _i8.DivesCompanion? patch, + ) => + (super.noSuchMethod( + Invocation.method(#applyImportedMetadata, [diveId, patch]), + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) + as _i7.Future); + @override _i7.Future saveComputerReading(_i8.DiveDataSourcesCompanion? reading) => (super.noSuchMethod( diff --git a/test/features/dive_import/data/services/uddf_entity_importer_test.mocks.dart b/test/features/dive_import/data/services/uddf_entity_importer_test.mocks.dart index dcc6a8d48..9078cb489 100644 --- a/test/features/dive_import/data/services/uddf_entity_importer_test.mocks.dart +++ b/test/features/dive_import/data/services/uddf_entity_importer_test.mocks.dart @@ -6,11 +6,11 @@ import 'dart:async' as _i17; import 'package:mockito/mockito.dart' as _i1; -import 'package:mockito/src/dummies.dart' as _i32; +import 'package:mockito/src/dummies.dart' as _i33; import 'package:submersion/core/constants/enums.dart' as _i20; -import 'package:submersion/core/constants/sort_options.dart' as _i31; -import 'package:submersion/core/database/database.dart' as _i35; -import 'package:submersion/core/models/sort_state.dart' as _i30; +import 'package:submersion/core/constants/sort_options.dart' as _i32; +import 'package:submersion/core/database/database.dart' as _i28; +import 'package:submersion/core/models/sort_state.dart' as _i31; import 'package:submersion/features/buddies/data/repositories/buddy_merge_repository.dart' as _i22; import 'package:submersion/features/buddies/data/repositories/buddy_repository.dart' @@ -34,15 +34,15 @@ import 'package:submersion/features/dive_log/data/repositories/tank_pressure_rep as _i36; import 'package:submersion/features/dive_log/domain/entities/dive.dart' as _i12; import 'package:submersion/features/dive_log/domain/entities/dive_data_source.dart' - as _i34; + as _i35; import 'package:submersion/features/dive_log/domain/entities/dive_summary.dart' - as _i28; + as _i29; import 'package:submersion/features/dive_log/domain/entities/gas_switch.dart' as _i14; import 'package:submersion/features/dive_log/domain/entities/profile_event.dart' - as _i33; + as _i34; import 'package:submersion/features/dive_log/domain/models/dive_filter_state.dart' - as _i29; + as _i30; import 'package:submersion/features/dive_sites/data/repositories/site_repository_impl.dart' as _i27; import 'package:submersion/features/dive_sites/domain/entities/dive_site.dart' @@ -1426,6 +1426,18 @@ class MockSiteRepository extends _i1.Mock implements _i27.SiteRepository { ) as _i17.Future); + @override + _i17.Future applyImportedMetadata( + String? siteId, + _i28.DiveSitesCompanion? patch, + ) => + (super.noSuchMethod( + Invocation.method(#applyImportedMetadata, [siteId, patch]), + returnValue: _i17.Future.value(), + returnValueForMissingStub: _i17.Future.value(), + ) + as _i17.Future); + @override _i17.Future setShared(String? id, bool? isShared) => (super.noSuchMethod( @@ -1657,13 +1669,13 @@ class MockDiveRepository extends _i1.Mock implements _i13.DiveRepository { as _i17.Future>); @override - _i17.Future> getDiveSummaries({ + _i17.Future> getDiveSummaries({ String? diverId, - _i29.DiveFilterState? filter = const _i29.DiveFilterState(), - _i28.DiveSummaryCursor? cursor, + _i30.DiveFilterState? filter = const _i30.DiveFilterState(), + _i29.DiveSummaryCursor? cursor, int? offset, int? limit = 50, - _i30.SortState<_i31.DiveSortField>? sort, + _i31.SortState<_i32.DiveSortField>? sort, }) => (super.noSuchMethod( Invocation.method(#getDiveSummaries, [], { @@ -1674,16 +1686,16 @@ class MockDiveRepository extends _i1.Mock implements _i13.DiveRepository { #limit: limit, #sort: sort, }), - returnValue: _i17.Future>.value( - <_i28.DiveSummary>[], + returnValue: _i17.Future>.value( + <_i29.DiveSummary>[], ), ) - as _i17.Future>); + as _i17.Future>); @override _i17.Future getDiveCount({ String? diverId, - _i29.DiveFilterState? filter = const _i29.DiveFilterState(), + _i30.DiveFilterState? filter = const _i30.DiveFilterState(), }) => (super.noSuchMethod( Invocation.method(#getDiveCount, [], { @@ -1848,7 +1860,7 @@ class MockDiveRepository extends _i1.Mock implements _i13.DiveRepository { {#actualDateTime: actualDateTime}, ), returnValue: _i17.Future.value( - _i32.dummyValue( + _i33.dummyValue( this, Invocation.method( #convertPlanToActualDive, @@ -1922,7 +1934,7 @@ class MockDiveRepository extends _i1.Mock implements _i13.DiveRepository { as _i17.Future); @override - _i17.Future insertProfileEvents(List<_i33.ProfileEvent>? events) => + _i17.Future insertProfileEvents(List<_i34.ProfileEvent>? events) => (super.noSuchMethod( Invocation.method(#insertProfileEvents, [events]), returnValue: _i17.Future.value(), @@ -1931,16 +1943,16 @@ class MockDiveRepository extends _i1.Mock implements _i13.DiveRepository { as _i17.Future); @override - _i17.Future> getProfileEventsForDive( + _i17.Future> getProfileEventsForDive( String? diveId, ) => (super.noSuchMethod( Invocation.method(#getProfileEventsForDive, [diveId]), - returnValue: _i17.Future>.value( - <_i33.ProfileEvent>[], + returnValue: _i17.Future>.value( + <_i34.ProfileEvent>[], ), ) - as _i17.Future>); + as _i17.Future>); @override _i17.Future deleteProfileEventsForDive(String? diveId) => @@ -2036,14 +2048,14 @@ class MockDiveRepository extends _i1.Mock implements _i13.DiveRepository { as _i17.Future); @override - _i17.Future> getDataSources(String? diveId) => + _i17.Future> getDataSources(String? diveId) => (super.noSuchMethod( Invocation.method(#getDataSources, [diveId]), - returnValue: _i17.Future>.value( - <_i34.DiveDataSource>[], + returnValue: _i17.Future>.value( + <_i35.DiveDataSource>[], ), ) - as _i17.Future>); + as _i17.Future>); @override _i17.Future hasMultipleDataSources(String? diveId) => @@ -2053,9 +2065,21 @@ class MockDiveRepository extends _i1.Mock implements _i13.DiveRepository { ) as _i17.Future); + @override + _i17.Future applyImportedMetadata( + String? diveId, + _i28.DivesCompanion? patch, + ) => + (super.noSuchMethod( + Invocation.method(#applyImportedMetadata, [diveId, patch]), + returnValue: _i17.Future.value(), + returnValueForMissingStub: _i17.Future.value(), + ) + as _i17.Future); + @override _i17.Future saveComputerReading( - _i35.DiveDataSourcesCompanion? reading, + _i28.DiveDataSourcesCompanion? reading, ) => (super.noSuchMethod( Invocation.method(#saveComputerReading, [reading]), @@ -2085,8 +2109,8 @@ class MockDiveRepository extends _i1.Mock implements _i13.DiveRepository { @override _i17.Future consolidateComputer({ required String? targetDiveId, - required _i35.DiveDataSourcesCompanion? secondaryReading, - required List<_i35.DiveProfilesCompanion>? secondaryProfile, + required _i28.DiveDataSourcesCompanion? secondaryReading, + required List<_i28.DiveProfilesCompanion>? secondaryProfile, }) => (super.noSuchMethod( Invocation.method(#consolidateComputer, [], { @@ -2125,7 +2149,7 @@ class MockDiveRepository extends _i1.Mock implements _i13.DiveRepository { #computerReadingId: computerReadingId, }), returnValue: _i17.Future.value( - _i32.dummyValue( + _i33.dummyValue( this, Invocation.method(#unlinkComputer, [], { #diveId: diveId, diff --git a/test/features/dive_import/presentation/providers/dive_import_notifier_test.mocks.dart b/test/features/dive_import/presentation/providers/dive_import_notifier_test.mocks.dart index 428214f62..2d6776d97 100644 --- a/test/features/dive_import/presentation/providers/dive_import_notifier_test.mocks.dart +++ b/test/features/dive_import/presentation/providers/dive_import_notifier_test.mocks.dart @@ -595,6 +595,18 @@ class MockDiveRepository extends _i1.Mock implements _i3.DiveRepository { ) as _i5.Future); + @override + _i5.Future applyImportedMetadata( + String? diveId, + _i13.DivesCompanion? patch, + ) => + (super.noSuchMethod( + Invocation.method(#applyImportedMetadata, [diveId, patch]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); + @override _i5.Future saveComputerReading( _i13.DiveDataSourcesCompanion? reading, diff --git a/test/features/dive_import/uddf_import_macdive_fields_test.dart b/test/features/dive_import/uddf_import_macdive_fields_test.dart new file mode 100644 index 000000000..0e6844fe2 --- /dev/null +++ b/test/features/dive_import/uddf_import_macdive_fields_test.dart @@ -0,0 +1,170 @@ +// Integration test for UddfEntityImporter's persistence of MacDive-extended +// dive + site metadata introduced in schema v71. +// +// Exercises the importer path end-to-end against an in-memory AppDatabase +// to verify that the fields parsed out of MacDive UDDF land in the new DB +// columns (dives.boat_name / boat_captain / dive_operator / surface_conditions +// / weather_description and dive_sites.water_type / body_of_water / +// difficulty). + +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/database/database.dart'; +import 'package:submersion/core/services/export/export_service.dart'; +import 'package:submersion/features/buddies/data/repositories/buddy_repository.dart'; +import 'package:submersion/features/certifications/data/repositories/certification_repository.dart'; +import 'package:submersion/features/courses/data/repositories/course_repository.dart'; +import 'package:submersion/features/dive_centers/data/repositories/dive_center_repository.dart'; +import 'package:submersion/features/dive_import/data/services/uddf_entity_importer.dart'; +import 'package:submersion/features/dive_log/data/repositories/dive_repository_impl.dart'; +import 'package:submersion/features/dive_log/data/repositories/tank_pressure_repository.dart'; +import 'package:submersion/features/dive_sites/data/repositories/site_repository_impl.dart'; +import 'package:submersion/features/dive_types/data/repositories/dive_type_repository.dart'; +import 'package:submersion/features/divers/data/repositories/diver_repository.dart'; +import 'package:submersion/features/divers/domain/entities/diver.dart' + as domain; +import 'package:submersion/features/equipment/data/repositories/equipment_repository_impl.dart'; +import 'package:submersion/features/equipment/data/repositories/equipment_set_repository_impl.dart'; +import 'package:submersion/features/tags/data/repositories/tag_repository.dart'; +import 'package:submersion/features/trips/data/repositories/trip_repository.dart'; + +import '../../helpers/test_database.dart'; + +/// MacDive UDDF that exercises every new metadata field. +const _macDiveRichUddf = ''' + + MacDive + + + Rich Site + saltwater + Pacific Ocean + advanced +
Mexico
+
+
+ + + + + 2024-06-01T09:00:00 + 42 + + + 18 + 2400 + Sunny + Calm + MV Nautilus + Jane Smith + Nautilus Liveaboards + + 00 + + +
'''; + +ImportRepositories buildRepositories() { + return ImportRepositories( + tripRepository: TripRepository(), + equipmentRepository: EquipmentRepository(), + equipmentSetRepository: EquipmentSetRepository(), + buddyRepository: BuddyRepository(), + diveCenterRepository: DiveCenterRepository(), + certificationRepository: CertificationRepository(), + tagRepository: TagRepository(), + diveTypeRepository: DiveTypeRepository(), + siteRepository: SiteRepository(), + diveRepository: DiveRepository(), + tankPressureRepository: TankPressureRepository(), + courseRepository: CourseRepository(), + ); +} + +Future createTestDiver() async { + final now = DateTime.now(); + const diverId = 'diver-macdive-fields-test'; + final diver = domain.Diver( + id: diverId, + name: 'Test Diver', + isDefault: true, + createdAt: now, + updatedAt: now, + ); + await DiverRepository().createDiver(diver); + return diverId; +} + +void main() { + late AppDatabase db; + final importer = UddfEntityImporter(); + final exportService = ExportService(); + + setUp(() async { + db = await setUpTestDatabase(); + }); + + tearDown(() async { + await tearDownTestDatabase(); + }); + + group('UddfEntityImporter persists MacDive metadata', () { + test('MacDive UDDF persists dive-level metadata to dives table', () async { + final diverId = await createTestDiver(); + final parsed = await exportService.importAllDataFromUddf( + _macDiveRichUddf, + ); + expect(parsed.dives, hasLength(1)); + + await importer.import( + data: parsed, + selections: UddfImportSelections.selectAll(parsed), + repositories: buildRepositories(), + diverId: diverId, + ); + + final diveRows = await db.select(db.dives).get(); + expect(diveRows, hasLength(1)); + final dive = diveRows.single; + expect(dive.boatName, 'MV Nautilus'); + expect(dive.boatCaptain, 'Jane Smith'); + expect(dive.diveOperator, 'Nautilus Liveaboards'); + expect(dive.surfaceConditions, 'Calm'); + expect( + dive.weatherDescription, + 'Sunny', + reason: 'UDDF must land on weather_description column', + ); + }); + + test( + 'MacDive UDDF persists site-level metadata to dive_sites table', + () async { + final diverId = await createTestDiver(); + final parsed = await exportService.importAllDataFromUddf( + _macDiveRichUddf, + ); + expect(parsed.sites, hasLength(1)); + + await importer.import( + data: parsed, + selections: UddfImportSelections.selectAll(parsed), + repositories: buildRepositories(), + diverId: diverId, + ); + + final siteRows = await db.select(db.diveSites).get(); + expect(siteRows, hasLength(1)); + final site = siteRows.single; + expect(site.waterType, 'saltwater'); + expect(site.bodyOfWater, 'Pacific Ocean'); + expect( + site.difficulty, + 'advanced', + reason: + 'difficulty was already extracted by the parser; verify the ' + 'importer persists it via the DiveSite entity path.', + ); + }, + ); + }); +} diff --git a/test/features/dive_import/uddf_import_source_uuid_test.dart b/test/features/dive_import/uddf_import_source_uuid_test.dart new file mode 100644 index 000000000..7a743c948 --- /dev/null +++ b/test/features/dive_import/uddf_import_source_uuid_test.dart @@ -0,0 +1,196 @@ +// Integration test for UddfEntityImporter's persistence of the UDDF `` +// attribute to `dive_data_sources.source_uuid`. +// +// This test exercises the importer path end-to-end against an in-memory +// AppDatabase, not just the parser. It verifies the one-line change at +// lib/features/dive_import/data/services/uddf_entity_importer.dart (around the +// `sourceUuid: Value(diveData['sourceUuid'] as String?)` line on the +// DiveDataSourcesCompanion) actually writes the value to the DB. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/database/database.dart'; +import 'package:submersion/core/services/export/export_service.dart'; +import 'package:submersion/features/buddies/data/repositories/buddy_repository.dart'; +import 'package:submersion/features/certifications/data/repositories/certification_repository.dart'; +import 'package:submersion/features/courses/data/repositories/course_repository.dart'; +import 'package:submersion/features/dive_centers/data/repositories/dive_center_repository.dart'; +import 'package:submersion/features/dive_import/data/services/uddf_entity_importer.dart'; +import 'package:submersion/features/dive_log/data/repositories/dive_repository_impl.dart'; +import 'package:submersion/features/dive_log/data/repositories/tank_pressure_repository.dart'; +import 'package:submersion/features/dive_sites/data/repositories/site_repository_impl.dart'; +import 'package:submersion/features/dive_types/data/repositories/dive_type_repository.dart'; +import 'package:submersion/features/divers/data/repositories/diver_repository.dart'; +import 'package:submersion/features/divers/domain/entities/diver.dart' + as domain; +import 'package:submersion/features/equipment/data/repositories/equipment_repository_impl.dart'; +import 'package:submersion/features/equipment/data/repositories/equipment_set_repository_impl.dart'; +import 'package:submersion/features/tags/data/repositories/tag_repository.dart'; +import 'package:submersion/features/trips/data/repositories/trip_repository.dart'; + +import '../../helpers/test_database.dart'; + +/// Builds a minimal MacDive-style UDDF string. +/// +/// If [diveId] is provided, renders ``; otherwise renders ``. +String buildMinimalUddf({String? diveId}) { + final diveAttr = diveId == null ? '' : ' id="$diveId"'; + return ''' + + + Air + 0.21 + 0.00 + + + + + + + 2024-01-15T10:00:00 + 1 + + + 30.0 + 2400.0 + 280.15 + + + + 12.0 + 20000000 + 5000000 + + + + 0.0 + 0.0 + 280.15 + + + 60.0 + 5.0 + 280.15 + + + + + +'''; +} + +/// Builds an [ImportRepositories] bundle using real repositories wired to the +/// in-memory AppDatabase set up via [setUpTestDatabase]. +ImportRepositories buildRepositories() { + return ImportRepositories( + tripRepository: TripRepository(), + equipmentRepository: EquipmentRepository(), + equipmentSetRepository: EquipmentSetRepository(), + buddyRepository: BuddyRepository(), + diveCenterRepository: DiveCenterRepository(), + certificationRepository: CertificationRepository(), + tagRepository: TagRepository(), + diveTypeRepository: DiveTypeRepository(), + siteRepository: SiteRepository(), + diveRepository: DiveRepository(), + tankPressureRepository: TankPressureRepository(), + courseRepository: CourseRepository(), + ); +} + +/// Creates a default diver in the DB and returns its id. +Future createTestDiver() async { + final now = DateTime.now(); + const diverId = 'diver-source-uuid-test'; + final diver = domain.Diver( + id: diverId, + name: 'Test Diver', + isDefault: true, + createdAt: now, + updatedAt: now, + ); + await DiverRepository().createDiver(diver); + return diverId; +} + +void main() { + late AppDatabase db; + final importer = UddfEntityImporter(); + final exportService = ExportService(); + + setUp(() async { + db = await setUpTestDatabase(); + }); + + tearDown(() async { + await tearDownTestDatabase(); + }); + + group('UddfEntityImporter persists sourceUuid to dive_data_sources', () { + test('writes source_uuid when UDDF has id attribute', () async { + final diverId = await createTestDiver(); + + final parsed = await exportService.importAllDataFromUddf( + buildMinimalUddf(diveId: 'DIVE-SOURCE-UUID-1'), + ); + expect(parsed.dives, hasLength(1)); + expect( + parsed.dives[0]['sourceUuid'], + 'DIVE-SOURCE-UUID-1', + reason: + 'Sanity check: parser populates sourceUuid; the importer step ' + 'below is what we actually want to test.', + ); + + await importer.import( + data: parsed, + selections: const UddfImportSelections(dives: {0}), + repositories: buildRepositories(), + diverId: diverId, + ); + + final rows = await db.select(db.diveDataSources).get(); + expect( + rows, + hasLength(1), + reason: 'Importer should have written exactly one data source row', + ); + expect( + rows.single.sourceUuid, + 'DIVE-SOURCE-UUID-1', + reason: + 'UddfEntityImporter must persist UDDF into ' + 'dive_data_sources.source_uuid', + ); + }); + + test( + 'writes null source_uuid when UDDF has no id attribute', + () async { + final diverId = await createTestDiver(); + + final parsed = await exportService.importAllDataFromUddf( + buildMinimalUddf(), + ); + expect(parsed.dives, hasLength(1)); + expect(parsed.dives[0]['sourceUuid'], isNull); + + await importer.import( + data: parsed, + selections: const UddfImportSelections(dives: {0}), + repositories: buildRepositories(), + diverId: diverId, + ); + + final rows = await db.select(db.diveDataSources).get(); + expect(rows, hasLength(1)); + expect( + rows.single.sourceUuid, + isNull, + reason: + 'With no in source UDDF, source_uuid column should be ' + 'null', + ); + }, + ); + }); +} diff --git a/test/features/import_wizard/data/adapters/dive_computer_adapter_reimport_test.mocks.dart b/test/features/import_wizard/data/adapters/dive_computer_adapter_reimport_test.mocks.dart index 21cbc373a..6ca0e7bbe 100644 --- a/test/features/import_wizard/data/adapters/dive_computer_adapter_reimport_test.mocks.dart +++ b/test/features/import_wizard/data/adapters/dive_computer_adapter_reimport_test.mocks.dart @@ -1171,6 +1171,18 @@ class MockDiveRepository extends _i1.Mock implements _i5.DiveRepository { ) as _i7.Future); + @override + _i7.Future applyImportedMetadata( + String? diveId, + _i11.DivesCompanion? patch, + ) => + (super.noSuchMethod( + Invocation.method(#applyImportedMetadata, [diveId, patch]), + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) + as _i7.Future); + @override _i7.Future saveComputerReading( _i11.DiveDataSourcesCompanion? reading, diff --git a/test/features/import_wizard/data/adapters/dive_computer_adapter_test.mocks.dart b/test/features/import_wizard/data/adapters/dive_computer_adapter_test.mocks.dart index 3b1785190..7d69d72e5 100644 --- a/test/features/import_wizard/data/adapters/dive_computer_adapter_test.mocks.dart +++ b/test/features/import_wizard/data/adapters/dive_computer_adapter_test.mocks.dart @@ -1386,6 +1386,18 @@ class MockDiveRepository extends _i1.Mock implements _i5.DiveRepository { ) as _i7.Future); + @override + _i7.Future applyImportedMetadata( + String? diveId, + _i11.DivesCompanion? patch, + ) => + (super.noSuchMethod( + Invocation.method(#applyImportedMetadata, [diveId, patch]), + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) + as _i7.Future); + @override _i7.Future saveComputerReading( _i11.DiveDataSourcesCompanion? reading, diff --git a/test/features/import_wizard/data/adapters/healthkit_adapter_test.mocks.dart b/test/features/import_wizard/data/adapters/healthkit_adapter_test.mocks.dart index c550068af..d88bcc596 100644 --- a/test/features/import_wizard/data/adapters/healthkit_adapter_test.mocks.dart +++ b/test/features/import_wizard/data/adapters/healthkit_adapter_test.mocks.dart @@ -861,6 +861,18 @@ class MockDiveRepository extends _i1.Mock implements _i3.DiveRepository { ) as _i7.Future); + @override + _i7.Future applyImportedMetadata( + String? diveId, + _i17.DivesCompanion? patch, + ) => + (super.noSuchMethod( + Invocation.method(#applyImportedMetadata, [diveId, patch]), + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) + as _i7.Future); + @override _i7.Future saveComputerReading( _i17.DiveDataSourcesCompanion? reading, diff --git a/test/features/import_wizard/data/adapters/universal_adapter_test.mocks.dart b/test/features/import_wizard/data/adapters/universal_adapter_test.mocks.dart index d74e75894..b75bdff9a 100644 --- a/test/features/import_wizard/data/adapters/universal_adapter_test.mocks.dart +++ b/test/features/import_wizard/data/adapters/universal_adapter_test.mocks.dart @@ -823,6 +823,18 @@ class MockDiveRepository extends _i1.Mock implements _i3.DiveRepository { ) as _i18.Future); + @override + _i18.Future applyImportedMetadata( + String? diveId, + _i26.DivesCompanion? patch, + ) => + (super.noSuchMethod( + Invocation.method(#applyImportedMetadata, [diveId, patch]), + returnValue: _i18.Future.value(), + returnValueForMissingStub: _i18.Future.value(), + ) + as _i18.Future); + @override _i18.Future saveComputerReading( _i26.DiveDataSourcesCompanion? reading, @@ -979,6 +991,18 @@ class MockSiteRepository extends _i1.Mock implements _i27.SiteRepository { ) as _i18.Future); + @override + _i18.Future applyImportedMetadata( + String? siteId, + _i26.DiveSitesCompanion? patch, + ) => + (super.noSuchMethod( + Invocation.method(#applyImportedMetadata, [siteId, patch]), + returnValue: _i18.Future.value(), + returnValueForMissingStub: _i18.Future.value(), + ) + as _i18.Future); + @override _i18.Future setShared(String? id, bool? isShared) => (super.noSuchMethod( diff --git a/test/features/universal_import/presentation/providers/import_consolidation_service_test.mocks.dart b/test/features/universal_import/presentation/providers/import_consolidation_service_test.mocks.dart index 09a24c6de..59d4479b9 100644 --- a/test/features/universal_import/presentation/providers/import_consolidation_service_test.mocks.dart +++ b/test/features/universal_import/presentation/providers/import_consolidation_service_test.mocks.dart @@ -591,6 +591,18 @@ class MockDiveRepository extends _i1.Mock implements _i3.DiveRepository { ) as _i5.Future); + @override + _i5.Future applyImportedMetadata( + String? diveId, + _i13.DivesCompanion? patch, + ) => + (super.noSuchMethod( + Invocation.method(#applyImportedMetadata, [diveId, patch]), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) + as _i5.Future); + @override _i5.Future saveComputerReading( _i13.DiveDataSourcesCompanion? reading, diff --git a/test/features/weather/data/repositories/weather_repository_test.mocks.dart b/test/features/weather/data/repositories/weather_repository_test.mocks.dart index fa6fdc72f..fda8ed2b8 100644 --- a/test/features/weather/data/repositories/weather_repository_test.mocks.dart +++ b/test/features/weather/data/repositories/weather_repository_test.mocks.dart @@ -622,6 +622,18 @@ class MockDiveRepository extends _i1.Mock implements _i3.DiveRepository { ) as _i6.Future); + @override + _i6.Future applyImportedMetadata( + String? diveId, + _i15.DivesCompanion? patch, + ) => + (super.noSuchMethod( + Invocation.method(#applyImportedMetadata, [diveId, patch]), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), + ) + as _i6.Future); + @override _i6.Future saveComputerReading( _i15.DiveDataSourcesCompanion? reading,