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 afb932ee3..4e91793cd 100644 --- a/lib/core/services/export/uddf/uddf_full_import_service.dart +++ b/lib/core/services/export/uddf/uddf_full_import_service.dart @@ -1362,9 +1362,16 @@ class UddfFullImportService { // Get linked gas mix final mixLink = tankDataElement.findElements('link').firstOrNull; if (mixLink != null) { - final mixRef = mixLink.getAttribute('ref'); - if (mixRef != null && gasMixes.containsKey(mixRef)) { - tankInfo['gasMix'] = gasMixes[mixRef]; + final rawMixRef = mixLink.getAttribute('ref'); + final mixRef = rawMixRef?.trim(); + if (mixRef != null && mixRef.isNotEmpty) { + // Record the UDDF gas-mix UUID on the tank so the importer can + // resolve waypoint-level markers (which reference + // gas mixes, not tanks) back to a tank for the gas_switches row. + tankInfo['uddfGasMixRef'] = mixRef; + if (gasMixes.containsKey(mixRef)) { + tankInfo['gasMix'] = gasMixes[mixRef]; + } } } @@ -1518,6 +1525,11 @@ class UddfFullImportService { final samplesElement = diveElement.findElements('samples').firstOrNull; if (samplesElement != null) { final profile = >[]; + // Gas switches emitted from waypoint-level . + // MacDive marks deco gas changes on individual samples this way; we feed + // them into the same `diveData['gasSwitches']` pipe as the top-level + // section so the importer has one consumer. + final waypointGasSwitches = >[]; GasMix? currentMix; GasMix? pendingSwitchMix; double? lastWaypointCns; @@ -1553,10 +1565,28 @@ class UddfFullImportService { final switchMix = waypoint.findElements('switchmix').firstOrNull; if (switchMix != null) { - final mixRef = switchMix.getAttribute('ref'); - if (mixRef != null) { - // Record the gas mix reference on the sample for downstream consumers - point['gasMixRef'] = mixRef; + final rawMixRef = switchMix.getAttribute('ref'); + final mixRef = rawMixRef?.trim(); + // Skip emission entirely when the ref is empty or whitespace-only: + // the importer would have no way to resolve such a dangling ref + // back to a persisted tank row. + if (mixRef != null && mixRef.isNotEmpty) { + // Emit a gas switch entry for the importer to persist. Shape + // matches the top-level parser (timestamp/depth/ + // tankRef), plus `gasMixRef` so the importer can resolve the + // MacDive-style gas-UUID reference to a tank. + final timestamp = point['timestamp'] as int?; + if (timestamp != null) { + final entry = { + 'timestamp': timestamp, + 'gasMixRef': mixRef, + }; + final depth = point['depth']; + if (depth != null) { + entry['depth'] = depth; + } + waypointGasSwitches.add(entry); + } if (gasMixes.containsKey(mixRef)) { currentMix = gasMixes[mixRef]; @@ -1723,6 +1753,24 @@ class UddfFullImportService { if (currentMix != null && !diveData.containsKey('tanks')) { diveData['gasMix'] = currentMix; } + + // Merge waypoint-level gas switches with any entries emitted earlier + // from the top-level section, deduping on + // timestamp+gasMixRef+tankRef so both paths feed one consumer. + if (waypointGasSwitches.isNotEmpty) { + final existing = + (diveData['gasSwitches'] as List>?) ?? + const >[]; + final seen = {}; + final merged = >[]; + for (final gs in [...existing, ...waypointGasSwitches]) { + final key = '${gs['timestamp']}|${gs['gasMixRef']}|${gs['tankRef']}'; + if (seen.add(key)) { + merged.add(gs); + } + } + diveData['gasSwitches'] = merged; + } } // Parse information after dive 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 6fb329989..da94e7bdb 100644 --- a/lib/features/dive_import/data/services/uddf_entity_importer.dart +++ b/lib/features/dive_import/data/services/uddf_entity_importer.dart @@ -1255,7 +1255,12 @@ class UddfEntityImporter { final gasSwitchesData = diveData['gasSwitches'] as List>?; if (gasSwitchesData != null && gasSwitchesData.isNotEmpty) { + // Build lookups from both UDDF tank ID and UDDF gas mix UUID to the + // persisted tank row id. MacDive-style switches reference a gas mix + // UUID (via ), while top-level + // entries reference a tank UUID (via ); we accept either. final tankIdByRef = {}; + final tankIdByGasMixRef = {}; final tanksData = diveData['tanks'] as List>?; if (tanksData != null) { for (var i = 0; i < tanks.length && i < tanksData.length; i++) { @@ -1265,6 +1270,15 @@ class UddfEntityImporter { if (ref != null && ref.isNotEmpty) { tankIdByRef[ref] = tank.id; } + final gasMixRef = (tankData['uddfGasMixRef'] as String?)?.trim(); + // First tank linked to a given gas wins; later tanks sharing the + // same gas don't overwrite. This is a pragmatic resolution for + // dives where multiple tanks carry the same mix. + if (gasMixRef != null && + gasMixRef.isNotEmpty && + !tankIdByGasMixRef.containsKey(gasMixRef)) { + tankIdByGasMixRef[gasMixRef] = tank.id; + } } } @@ -1273,9 +1287,16 @@ class UddfEntityImporter { final timestamp = gs['timestamp'] as int?; if (timestamp == null) return null; final tankRef = (gs['tankRef'] as String?)?.trim(); - final tankId = tankRef != null && tankRef.isNotEmpty - ? tankIdByRef[tankRef] - : null; + final gasMixRef = (gs['gasMixRef'] as String?)?.trim(); + String? tankId; + if (tankRef != null && tankRef.isNotEmpty) { + tankId = tankIdByRef[tankRef]; + } + if ((tankId == null || tankId.isEmpty) && + gasMixRef != null && + gasMixRef.isNotEmpty) { + tankId = tankIdByGasMixRef[gasMixRef]; + } if (tankId == null || tankId.isEmpty) return null; return GasSwitch( id: _uuid.v4(), 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 ad5477785..0808031d3 100644 --- a/test/core/services/export/uddf/uddf_macdive_import_test.dart +++ b/test/core/services/export/uddf/uddf_macdive_import_test.dart @@ -761,18 +761,31 @@ void main() { }); test( - 'samples with record gasMixRef on the right sample', + 'emits gasSwitches from waypoint for multi-tank dives', () async { + // MacDive deco-dive style: two tanks, each linked to a gas definition; + // profile samples mark the switch via . const uddf = ''' - 0.32 - 0.80 + 0.320.0 + 0.800.0 2024-06-01T09:00:00 - 403600 + + 40 + 3600 + + + + 0.012 + + + + 0.007 + 00 12030 @@ -782,17 +795,20 @@ void main() { '''; 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(); + final dive = r.dives.first; + final switches = + (dive['gasSwitches'] as List?)?.cast>() ?? + const []; expect( - switches, - ['mix-bottom', 'mix-deco'], - reason: 'only samples with should have gasMixRef', + switches.length, + 2, + reason: 'two waypoints carry ', ); + expect(switches[0]['timestamp'], 0); + expect(switches[0]['gasMixRef'], 'mix-bottom'); + expect(switches[1]['timestamp'], 2400); + expect(switches[1]['gasMixRef'], 'mix-deco'); + expect(switches[1]['depth'], 6); }, ); }); 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 index 6041363b1..ea0648352 100644 --- a/test/core/services/export/uddf/uddf_macdive_real_sample_test.dart +++ b/test/core/services/export/uddf/uddf_macdive_real_sample_test.dart @@ -108,20 +108,24 @@ void main() { ); }); - 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 dive has gasSwitches entries from waypoint switchmix', + () async { + if (skipIfNoFixture()) return; + final result = await service.importAllDataFromUddf(content); + final withSwitches = result.dives.where((d) { + final switches = d['gasSwitches'] as List?; + return switches != null && switches.isNotEmpty; + }); + expect( + withSwitches, + isNotEmpty, + reason: + 'sample contains multi-gas deco dives marked via ' + 'on waypoints; parser should emit those to diveData["gasSwitches"]', + ); + }, + ); test('at least one site has country populated', () async { if (skipIfNoFixture()) return; diff --git a/test/features/dive_import/macdive_waypoint_gas_switch_test.dart b/test/features/dive_import/macdive_waypoint_gas_switch_test.dart new file mode 100644 index 000000000..90eaf77ea --- /dev/null +++ b/test/features/dive_import/macdive_waypoint_gas_switch_test.dart @@ -0,0 +1,195 @@ +// Integration test for UddfEntityImporter's persistence of MacDive-style +// waypoint gas switches to the `gas_switches` table. +// +// MacDive marks gas changes via inside individual +// samples (the ref points at a gas mix UUID, not a tank UUID). +// The parser emits these into `diveData['gasSwitches']` with a `gasMixRef` +// key; the importer resolves that back to the persisted tank row by matching +// against the tank's linked gas mix UUID (`tanksData[i]['uddfGasMixRef']`). +// +// This test exercises the full parser -> importer -> DB path and asserts that +// each waypoint lands in the gas_switches table with its +// tank_id pointing at the correct dive_tanks row. + +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-style UDDF with two tanks (each linked to a distinct gas mix) and +/// two profile waypoints that carry `` markers. +const _macDiveMultiTankUddf = ''' + + + 0.320.0 + 0.800.0 + + + + + + 2024-06-01T09:00:00 + + + 40.0 + 3600.0 + + + + 0.012 + 20000000 + 8000000 + + + + 0.007 + 20000000 + 12000000 + + + 00.0 + 12030.0 + 24006.0 + + + + +'''; + +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-gasswitches-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 waypoint gas switches', () { + test( + 'writes one gas_switches row per , tank_id resolved via gas mix UUID', + () async { + final diverId = await _createTestDiver(); + + final parsed = await exportService.importAllDataFromUddf( + _macDiveMultiTankUddf, + ); + expect(parsed.dives, hasLength(1)); + + // Sanity: parser emitted two gas-switch entries with gasMixRef. + final parsedSwitches = + (parsed.dives[0]['gasSwitches'] as List?) + ?.cast>() ?? + const >[]; + expect(parsedSwitches, hasLength(2)); + expect(parsedSwitches[0]['gasMixRef'], 'mix-bottom'); + expect(parsedSwitches[1]['gasMixRef'], 'mix-deco'); + + // Sanity: both tanks carry their UDDF gas-mix UUID so the importer + // can resolve the switchmix refs back to tanks. + final parsedTanks = (parsed.dives[0]['tanks'] as List) + .cast>(); + expect(parsedTanks, hasLength(2)); + expect(parsedTanks[0]['uddfGasMixRef'], 'mix-bottom'); + expect(parsedTanks[1]['uddfGasMixRef'], 'mix-deco'); + + await importer.import( + data: parsed, + selections: const UddfImportSelections(dives: {0}), + repositories: _buildRepositories(), + diverId: diverId, + ); + + final diveTanks = await db.select(db.diveTanks).get(); + expect( + diveTanks, + hasLength(2), + reason: 'expected two dive_tanks rows for the two UDDF tanks', + ); + + final switches = await db.select(db.gasSwitches).get(); + expect( + switches, + hasLength(2), + reason: 'expected one gas_switches row per ', + ); + + // Identify tanks by o2Percent rather than relying on row insertion + // order: SQLite/Drift do not guarantee `select(...).get()` ordering + // without an explicit ORDER BY, so positional indexing is flaky + // across engine versions. The synthetic UDDF uses 32% for the + // bottom mix and 80% for the deco mix, which are unambiguous. + final bottomTankId = diveTanks.firstWhere((t) => t.o2Percent == 32).id; + final decoTankId = diveTanks.firstWhere((t) => t.o2Percent == 80).id; + + final byTimestamp = {for (final gs in switches) gs.timestamp: gs}; + expect(byTimestamp.keys, containsAll([0, 2400])); + expect( + byTimestamp[0]!.tankId, + bottomTankId, + reason: + 'waypoint 0s switch to mix-bottom should land on the 32% tank', + ); + expect( + byTimestamp[2400]!.tankId, + decoTankId, + reason: + 'waypoint 2400s switch to mix-deco should land on the 80% tank', + ); + expect(byTimestamp[2400]!.depth, 6.0); + }, + ); + }); +}