diff --git a/CHANGELOG.md b/CHANGELOG.md index b7b23777a..b20614ee0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,14 @@ All notable changes to Submersion are documented in this file. 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. +- **MacDive native XML import.** MacDive's own `.xml` logbook format is now + a first-class import source. Unlike MacDive UDDF (which doesn't emit tags), + the native XML export carries dive tags — so users migrating tag metadata + from MacDive should choose this format. Supports both Imperial and Metric + unit modes; depths/temperatures/pressures are converted to the canonical + internal units at the reader boundary. +- **MacDive (XML) source override** in the import wizard's detected-source + dropdown, alongside the existing MacDive (CSV) option. - 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 diff --git a/docs/superpowers/plans/2026-04-21-macdive-native-xml-import.md b/docs/superpowers/plans/2026-04-21-macdive-native-xml-import.md index c58e01148..7e45eb357 100644 --- a/docs/superpowers/plans/2026-04-21-macdive-native-xml-import.md +++ b/docs/superpowers/plans/2026-04-21-macdive-native-xml-import.md @@ -14,6 +14,29 @@ --- +## Milestone 2 Status — COMPLETE + +- All 11 tasks landed. 10 implementation commits + 1 commit that cherry-picked + the MacDiveValueMapper onto this branch after it originally landed on main + by mistake. +- New `ImportFormat.macdiveXml` with source override and format-detector + recognition (DOCTYPE + ``/`` fallback). +- `MacDiveXmlReader` produces typed `MacDiveXmlLogbook` from XML with SI + canonical units at the boundary. Imperial↔Metric verified via an explicit + imperial fixture. +- `MacDiveXmlParser` implements `ImportParser`, dedups sites/buddies/tags/gear + inline, routes raw MacDive strings through `MacDiveValueMapper` so + `waterType`/`entryType` resolve to Submersion enums. +- Gated real-sample test (`@Tags(['real-data'])`) asserts 540 dives, tag + preservation (20+ unique tags), site dedup, unit-conversion sanity. +- Full test suite passes (7000+ tests). + +Next: M3 (MacDive SQLite) builds on top of this. Key shared assets — +`MacDiveValueMapper`, `MacDiveUnitConverter` — are reused by M3's SQLite +parser. + +--- + ## File Structure | File | Role | New / Modified | diff --git a/lib/features/universal_import/data/models/import_enums.dart b/lib/features/universal_import/data/models/import_enums.dart index 09bfbd7b5..f7e6cc090 100644 --- a/lib/features/universal_import/data/models/import_enums.dart +++ b/lib/features/universal_import/data/models/import_enums.dart @@ -2,6 +2,7 @@ enum ImportFormat { csv, uddf, + macdiveXml, subsurfaceXml, divingLogXml, suuntoSml, @@ -16,6 +17,7 @@ enum ImportFormat { String get displayName => switch (this) { csv => 'CSV', uddf => 'UDDF', + macdiveXml => 'MacDive XML', subsurfaceXml => 'Subsurface XML', divingLogXml => 'Diving Log XML', suuntoSml => 'Suunto SML', @@ -30,7 +32,7 @@ enum ImportFormat { /// Whether this format has a parser implemented in v1.5. bool get isSupported => switch (this) { - csv || uddf || subsurfaceXml || fit || shearwaterDb => true, + csv || uddf || subsurfaceXml || fit || shearwaterDb || macdiveXml => true, _ => false, }; } @@ -123,6 +125,11 @@ class SourceOverrideOption { format: ImportFormat.csv, displayName: 'MacDive (CSV)', ), + SourceOverrideOption( + sourceApp: SourceApp.macdive, + format: ImportFormat.macdiveXml, + displayName: 'MacDive (XML)', + ), SourceOverrideOption( sourceApp: SourceApp.divingLog, format: ImportFormat.csv, diff --git a/lib/features/universal_import/data/parsers/macdive_xml_parser.dart b/lib/features/universal_import/data/parsers/macdive_xml_parser.dart new file mode 100644 index 000000000..6b11cebe2 --- /dev/null +++ b/lib/features/universal_import/data/parsers/macdive_xml_parser.dart @@ -0,0 +1,336 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:submersion/features/dive_log/domain/entities/dive.dart' + show GasMix; +import 'package:submersion/features/universal_import/data/models/import_enums.dart'; +import 'package:submersion/features/universal_import/data/models/import_options.dart'; +import 'package:submersion/features/universal_import/data/models/import_payload.dart'; +import 'package:submersion/features/universal_import/data/models/import_warning.dart'; +import 'package:submersion/features/universal_import/data/parsers/import_parser.dart'; +import 'package:submersion/features/universal_import/data/services/macdive_value_mapper.dart'; +import 'package:submersion/features/universal_import/data/services/macdive_xml_models.dart'; +import 'package:submersion/features/universal_import/data/services/macdive_xml_reader.dart'; + +/// Parses MacDive native XML (`` root) into a unified [ImportPayload]. +/// +/// Uses [MacDiveXmlReader] for the XML -> typed-object step, then maps each +/// typed object to the dive-map key conventions the UDDF parser uses so the +/// downstream importer (UddfEntityImporter) can consume MacDive XML without +/// code changes. +/// +/// MacDive native XML is an inline-only format (no top-level ``, +/// ``, ``, or `` lists — every dive carries its own +/// copies), so this parser deduplicates as it walks the dives: +/// - sites: by `name` +/// - buddies: by name +/// - tags: by name +/// - gear: by `(manufacturer|name|serial)` composite key +class MacDiveXmlParser implements ImportParser { + const MacDiveXmlParser(); + + @override + List get supportedFormats => const [ImportFormat.macdiveXml]; + + @override + Future parse( + Uint8List fileBytes, { + ImportOptions? options, + }) async { + if (fileBytes.isEmpty) { + return const ImportPayload( + entities: {}, + warnings: [ + ImportWarning( + severity: ImportWarningSeverity.error, + message: 'Empty file', + ), + ], + metadata: {'source': 'macdive_xml'}, + ); + } + + final String content; + try { + content = utf8.decode(fileBytes, allowMalformed: true); + } catch (e) { + return ImportPayload( + entities: const {}, + warnings: [ + ImportWarning( + severity: ImportWarningSeverity.error, + message: 'Could not decode MacDive XML as UTF-8: $e', + ), + ], + metadata: const {'source': 'macdive_xml'}, + ); + } + + final MacDiveXmlLogbook logbook; + try { + logbook = MacDiveXmlReader.parse(content); + } catch (e) { + return ImportPayload( + entities: const {}, + warnings: [ + ImportWarning( + severity: ImportWarningSeverity.error, + message: 'Failed to parse MacDive XML: $e', + ), + ], + metadata: const {'source': 'macdive_xml'}, + ); + } + + final warnings = []; + final diveMaps = >[]; + + // Dedup containers. MacDive emits site / gear / buddies / tags inline per + // dive, so we fold them into shared maps keyed by identity. + final sitesByName = >{}; + final buddiesByName = >{}; + final gearByKey = >{}; + final tagsByName = >{}; + + for (final dive in logbook.dives) { + final diveMap = _mapDive(dive); + + final site = dive.site; + if (site != null) { + final name = site.name; + if (name != null && name.isNotEmpty) { + sitesByName.putIfAbsent(name, () => _mapSite(site, name)); + diveMap['siteName'] = name; + // UddfEntityImporter links sites via `dive['site']['uddfId']`. + // Use the site name as the uddf-style id since MacDive doesn't + // carry a separate identifier. + diveMap['site'] = {'uddfId': name}; + } + } + + if (dive.buddies.isNotEmpty) { + // Per-dive dedup: a dive with duplicate `` entries would + // otherwise emit repeated refs. The buddy repo tolerates this by + // UPSERTing, but the redundant round-trips are wasted work. + final buddyRefs = []; + for (final buddy in dive.buddies) { + final trimmed = buddy.trim(); + if (trimmed.isEmpty) continue; + if (!buddyRefs.contains(trimmed)) buddyRefs.add(trimmed); + buddiesByName.putIfAbsent( + trimmed, + () => {'name': trimmed, 'uddfId': trimmed}, + ); + } + if (buddyRefs.isNotEmpty) { + // `buddyRefs` matches the `uddfId` values of the buddy entities we + // just added to `buddiesByName`, so `UddfEntityImporter` resolves + // them via `buddyIdMapping`. That mapping only has entries for + // buddies the user actually selected for import — using + // `unmatchedBuddyNames` here would bypass that selection and create + // buddies unconditionally. This mirrors SubsurfaceXmlParser. + diveMap['buddyRefs'] = buddyRefs; + } + } + + // Collect gear: record a per-dive `equipmentRefs` list keyed by the + // same composite key used for dedup, so `UddfEntityImporter` can link + // the imported equipment entities back to the dive via its + // `equipmentIdMapping` (uddfId -> newId) lookup. Without this, dedup + // still produces the entity list but the dive wouldn't reference any + // of it. + if (dive.gear.isNotEmpty) { + final equipmentRefs = []; + for (final g in dive.gear) { + final key = _gearKey(g); + if (key.isEmpty) continue; + if (!equipmentRefs.contains(key)) equipmentRefs.add(key); + gearByKey.putIfAbsent(key, () => _mapGear(g, uddfId: key)); + } + if (equipmentRefs.isNotEmpty) { + diveMap['equipmentRefs'] = equipmentRefs; + } + } + + if (dive.tags.isNotEmpty) { + // Per-dive dedup: `dive_tags` has no UNIQUE(diveId, tagId) + // constraint, so duplicate `` entries would create duplicate + // junction rows that surface as a tag appearing twice on a dive. + final tagNames = []; + for (final tag in dive.tags) { + final trimmed = tag.trim(); + if (trimmed.isEmpty) continue; + if (!tagNames.contains(trimmed)) tagNames.add(trimmed); + tagsByName.putIfAbsent( + trimmed, + () => {'name': trimmed, 'uddfId': trimmed}, + ); + } + if (tagNames.isNotEmpty) { + diveMap['tagRefs'] = tagNames; + } + } + + diveMaps.add(diveMap); + } + + final entities = >>{}; + if (diveMaps.isNotEmpty) entities[ImportEntityType.dives] = diveMaps; + if (sitesByName.isNotEmpty) { + entities[ImportEntityType.sites] = sitesByName.values.toList(); + } + if (buddiesByName.isNotEmpty) { + entities[ImportEntityType.buddies] = buddiesByName.values.toList(); + } + if (gearByKey.isNotEmpty) { + entities[ImportEntityType.equipment] = gearByKey.values.toList(); + } + if (tagsByName.isNotEmpty) { + entities[ImportEntityType.tags] = tagsByName.values.toList(); + } + + return ImportPayload( + entities: entities, + warnings: warnings, + metadata: { + 'source': 'macdive_xml', + 'diveCount': logbook.dives.length, + 'units': logbook.units.name, + if (logbook.schemaVersion != null) + 'schemaVersion': logbook.schemaVersion, + }, + ); + } + + // ---- mappers ---- + + Map _mapDive(MacDiveXmlDive d) { + final map = {}; + if (d.identifier != null) map['sourceUuid'] = d.identifier; + if (d.date != null) map['dateTime'] = d.date; + if (d.diveNumber != null) map['diveNumber'] = d.diveNumber; + // MacDive's (per-day counter) is intentionally dropped: + // main's refactor removed the `dive_number_of_day` column because it's + // derivable from dateTime and goes stale after manual edits. + if (d.maxDepthMeters != null) map['maxDepth'] = d.maxDepthMeters; + if (d.avgDepthMeters != null) map['avgDepth'] = d.avgDepthMeters; + if (d.duration != null) { + // Both keys are read downstream: `runtime` is the dive's end-to-end + // wall-clock (UDDF convention); `duration` is CSV's bottom-time key. + // Mirror both so the entity importer can populate runtime + bottomTime + // from a single source the way it does for Subsurface imports. + map['runtime'] = d.duration; + map['duration'] = d.duration; + } + if (d.surfaceInterval != null) map['surfaceInterval'] = d.surfaceInterval; + if (d.tempLowCelsius != null) map['waterTemp'] = d.tempLowCelsius; + if (d.airTempCelsius != null) map['airTemp'] = d.airTempCelsius; + if (d.cns != null) map['cnsEnd'] = d.cns; + if (d.decoModel != null) map['decoModel'] = d.decoModel; + if (d.gasModel != null) map['gasModel'] = d.gasModel; + if (d.visibility != null) map['visibility'] = d.visibility; + if (d.weightKg != null) map['weightUsed'] = d.weightKg; + if (d.notes != null) map['notes'] = d.notes; + if (d.diveMaster != null) map['diveMaster'] = d.diveMaster; + if (d.diveOperator != null) map['diveOperator'] = d.diveOperator; + if (d.skipper != null) map['boatCaptain'] = d.skipper; + if (d.boat != null) map['boatName'] = d.boat; + if (d.weather != null) map['weather'] = d.weather; + if (d.current != null) map['currentDirection'] = d.current; + if (d.surfaceConditions != null) { + map['surfaceConditions'] = d.surfaceConditions; + } + final entryMethod = MacDiveValueMapper.entryType(d.entryType); + if (entryMethod != null) map['entryMethod'] = entryMethod.name; + if (d.computer != null) map['diveComputerModel'] = d.computer; + if (d.serial != null) map['diveComputerSerial'] = d.serial; + final rating = MacDiveValueMapper.rating(d.rating); + if (rating != null) map['rating'] = rating; + + // Tanks: each becomes a tank map using the same key conventions as + // the Subsurface and UDDF parsers so `UddfEntityImporter._buildTanks` can + // consume MacDive tanks unchanged — keys `volume` / `workingPressure` + // (not `volumeL` / `workingPressureBar`), and `gasMix` as a `GasMix` + // object (the importer casts `t['gasMix'] as GasMix?`). `GasMix` stores + // o2/he as percentages 0-100, which matches what the reader already emits. + final tanks = >[]; + for (var i = 0; i < d.gases.length; i++) { + final g = d.gases[i]; + final tank = {'index': i, 'order': i}; + if (g.pressureStartBar != null) { + tank['startPressure'] = g.pressureStartBar; + } + if (g.pressureEndBar != null) tank['endPressure'] = g.pressureEndBar; + if (g.tankSizeLiters != null) tank['volume'] = g.tankSizeLiters; + if (g.workingPressureBar != null) { + tank['workingPressure'] = g.workingPressureBar; + } + if (g.tankName != null) tank['name'] = g.tankName; + if (g.supplyType != null) tank['supplyType'] = g.supplyType; + if (g.duration != null) tank['runtime'] = g.duration; + tank['gasMix'] = GasMix( + o2: g.oxygenPercent ?? 21.0, + he: g.heliumPercent ?? 0.0, + ); + tanks.add(tank); + } + if (tanks.isNotEmpty) map['tanks'] = tanks; + + final profile = >[]; + for (final s in d.samples) { + final point = {'timestamp': s.time.inSeconds}; + if (s.depthMeters != null) point['depth'] = s.depthMeters; + if (s.pressureBar != null) point['pressure'] = s.pressureBar; + if (s.temperatureCelsius != null) { + point['temperature'] = s.temperatureCelsius; + } + if (s.ppO2 != null) point['ppO2'] = s.ppO2; + if (s.ndtSeconds != null) point['ndl'] = s.ndtSeconds; + profile.add(point); + } + if (profile.isNotEmpty) map['profile'] = profile; + + return map; + } + + Map _mapSite(MacDiveXmlSite s, String name) { + // `uddfId` matches the site name so UddfEntityImporter can resolve + // `dive['site']['uddfId']` against this site during import linking. + final map = {'name': name, 'uddfId': name}; + if (s.country != null) map['country'] = s.country; + if (s.location != null) map['region'] = s.location; + if (s.bodyOfWater != null) map['bodyOfWater'] = s.bodyOfWater; + final waterType = MacDiveValueMapper.waterType(s.waterType); + if (waterType != null) map['waterType'] = waterType.name; + if (s.difficulty != null) map['difficulty'] = s.difficulty; + if (s.altitudeMeters != null) map['altitude'] = s.altitudeMeters; + if (s.latitude != null) map['latitude'] = s.latitude; + if (s.longitude != null) map['longitude'] = s.longitude; + return map; + } + + Map _mapGear( + MacDiveXmlGearItem g, { + required String uddfId, + }) { + // `uddfId` is the gear's composite dedup key, referenced from each dive's + // `equipmentRefs`. UddfEntityImporter resolves it via `equipmentIdMapping`. + final map = {'uddfId': uddfId}; + if (g.name != null) map['name'] = g.name; + if (g.manufacturer != null) map['brand'] = g.manufacturer; + if (g.type != null) map['type'] = g.type; + if (g.serial != null) map['serialNumber'] = g.serial; + return map; + } + + String _gearKey(MacDiveXmlGearItem g) { + final manufacturer = g.manufacturer?.trim() ?? ''; + final name = g.name?.trim() ?? ''; + final serial = g.serial?.trim() ?? ''; + // Empty `` elements would otherwise collapse to a `"||"` key and + // show up as a phantom equipment entity. Skip them by returning an + // empty key; the caller (`_gearKey(...).isEmpty`) drops the item. + if (manufacturer.isEmpty && name.isEmpty && serial.isEmpty) return ''; + return '$manufacturer|$name|$serial'; + } +} diff --git a/lib/features/universal_import/data/services/format_detector.dart b/lib/features/universal_import/data/services/format_detector.dart index b09ada301..608aee07b 100644 --- a/lib/features/universal_import/data/services/format_detector.dart +++ b/lib/features/universal_import/data/services/format_detector.dart @@ -143,6 +143,20 @@ class FormatDetector { ); } + // MacDive native XML: root , DOCTYPE macdive_logbook.dtd, . + // Must precede the UDDF check because both are XML but MacDive's native + // XML is a different format entirely. Match opening tags as prefixes so + // attributes/namespace declarations (``) or trailing + // whitespace (``) don't defeat detection. + if (lower.contains('mac-dive.com/macdive_logbook.dtd') || + (lower.contains(' root element if (lower.contains(' units == MacDiveUnitSystem.imperial; + + /// Feet → meters when imperial; passthrough otherwise. + double? depthToMeters(double? raw) { + if (raw == null) return null; + return _isImperial ? raw * 0.3048 : raw; + } + + /// Fahrenheit → Celsius when imperial; passthrough otherwise. + double? tempToCelsius(double? raw) { + if (raw == null) return null; + return _isImperial ? (raw - 32.0) * 5.0 / 9.0 : raw; + } + + /// PSI → bar when imperial; passthrough otherwise. + double? pressureToBar(double? raw) { + if (raw == null) return null; + return _isImperial ? raw * 0.0689476 : raw; + } + + /// Pounds → kilograms when imperial; passthrough otherwise. + double? weightToKg(double? raw) { + if (raw == null) return null; + return _isImperial ? raw * 0.453592 : raw; + } + + /// Tank size conversion is not a scalar under imperial: + /// MacDive Imperial tank size is expressed as cubic feet at the tank's + /// working pressure (e.g. AL80 = 77.4 cft @ 3000 psi). To get a water + /// capacity in liters we use: + /// litresAtSurface = cft * 28.3168 + /// workingPressureBar = psi * 0.0689476 + /// waterCapacityL = litresAtSurface / workingPressureBar + /// + /// Metric MacDive already stores water capacity in liters directly — + /// passthrough. + /// + /// Returns null when [rawSize] is null, or (imperial only) when + /// [rawWorkingPressure] is null or zero, since we can't complete the + /// computation without both values. + double? tankSizeLiters(double? rawSize, double? rawWorkingPressure) { + if (rawSize == null) return null; + if (!_isImperial) return rawSize; + if (rawWorkingPressure == null || rawWorkingPressure <= 0) return null; + final litresAtSurface = rawSize * 28.3168; + final workingPressureBar = rawWorkingPressure * 0.0689476; + return litresAtSurface / workingPressureBar; + } +} diff --git a/lib/features/universal_import/data/services/macdive_value_mapper.dart b/lib/features/universal_import/data/services/macdive_value_mapper.dart new file mode 100644 index 000000000..996819d13 --- /dev/null +++ b/lib/features/universal_import/data/services/macdive_value_mapper.dart @@ -0,0 +1,72 @@ +import 'package:submersion/core/constants/enums.dart'; + +/// Static mappings from MacDive's raw XML string values to Submersion's +/// typed domain enums. Used by the MacDive XML parser and will also be +/// used by the MacDive SQLite parser (Milestone 3). +/// +/// Mapping strategy: case-insensitive, substring-based. Unknown or empty +/// input returns null so the importer can omit the field rather than +/// write a default that misrepresents the data. +class MacDiveValueMapper { + const MacDiveValueMapper._(); + + static WaterType? waterType(String? raw) { + final s = raw?.trim().toLowerCase(); + if (s == null || s.isEmpty) return null; + if (s.contains('salt') || s == 'sea' || s == 'ocean') { + return WaterType.salt; + } + if (s.contains('fresh') || s == 'lake' || s == 'river' || s == 'quarry') { + return WaterType.fresh; + } + if (s.contains('brackish')) { + return WaterType.brackish; + } + return null; + } + + static EntryMethod? entryType(String? raw) { + final s = raw?.trim().toLowerCase(); + if (s == null || s.isEmpty) return null; + + if (s == 'shore' || s == 'beach') { + return EntryMethod.shore; + } + if (s.contains('boat') || s.contains('liveaboard')) { + return EntryMethod.boat; + } + if (s.contains('back') && s.contains('roll')) { + return EntryMethod.backRoll; + } + if (s.contains('giant') && s.contains('stride')) { + return EntryMethod.giantStride; + } + if (s.contains('seated')) { + return EntryMethod.seatedEntry; + } + if (s == 'ladder') { + return EntryMethod.ladder; + } + if (s == 'platform') { + return EntryMethod.platform; + } + if (s.contains('jetty') || s.contains('dock')) { + return EntryMethod.jetty; + } + + return null; + } + + /// Maps a MacDive 0.0-5.0 rating to an integer 0-5. Clamps out-of-range + /// values. Returns null for null input. + static int? rating(double? raw) { + if (raw == null) return null; + return raw.clamp(0.0, 5.0).round(); + } + + /// Normalizes a MacDive dive-type string to a trimmed canonical form. + /// MacDive uses arbitrary dive-type labels; Submersion doesn't constrain + /// to an enum. Callers who need to create DiveTypes entities pass the + /// result as a tag name. + static String normalizeDiveType(String raw) => raw.trim(); +} diff --git a/lib/features/universal_import/data/services/macdive_xml_models.dart b/lib/features/universal_import/data/services/macdive_xml_models.dart new file mode 100644 index 000000000..16eb5ef67 --- /dev/null +++ b/lib/features/universal_import/data/services/macdive_xml_models.dart @@ -0,0 +1,267 @@ +/// Unit system declared at the top of a MacDive XML document (`` child). +enum MacDiveUnitSystem { + /// Feet, Fahrenheit, PSI, pounds. + imperial, + + /// Meters, Celsius, bar, kilograms. + metric, + + /// Value absent or unrecognised; converter should no-op and downstream + /// code should treat fields as their raw form. + unknown; + + /// Parse a MacDive `` element text value. Case-insensitive. Whitespace + /// is trimmed. Unknown or null input returns [unknown]. + static MacDiveUnitSystem fromXml(String? raw) { + switch (raw?.trim().toLowerCase()) { + case 'imperial': + return MacDiveUnitSystem.imperial; + case 'metric': + return MacDiveUnitSystem.metric; + default: + return MacDiveUnitSystem.unknown; + } + } +} + +/// Root of a parsed MacDive XML document (`` root element). +class MacDiveXmlLogbook { + final MacDiveUnitSystem units; + + /// Schema version from the `` element (e.g. "2.2.0"). Nullable + /// because older exports may omit it. + final String? schemaVersion; + + final List dives; + + const MacDiveXmlLogbook({ + required this.units, + required this.schemaVersion, + required this.dives, + }); +} + +/// A single dive from a MacDive XML file. All numeric fields are in SI +/// canonical units after the unit converter has run at the reader boundary. +class MacDiveXmlDive { + /// Stable MacDive identifier, e.g. `"20260311140918-CB115EF0"` (datetime + + /// computer serial). Carried through as `sourceUuid` in the payload. + final String? identifier; + + final DateTime? date; + final int? diveNumber; + + /// Repetitive-dive number within a 24-hour window (0 for first dive of day). + final int? repetitiveDive; + + /// 0.0 - 5.0 in MacDive (stars). + final double? rating; + + final double? maxDepthMeters; + final double? avgDepthMeters; + + /// CNS % at surface. + final double? cns; + + /// Text label, e.g. "ZHL-16C GF 50/85". + final String? decoModel; + + final Duration? duration; + final Duration? surfaceInterval; + final Duration? sampleInterval; + + /// E.g. "Air", "Nitrox", "Trimix". + final String? gasModel; + + final double? airTempCelsius; + final double? tempHighCelsius; + final double? tempLowCelsius; + + /// Raw visibility text (unit may vary, often feet or meters). + final String? visibility; + + final double? weightKg; + final String? notes; + final String? diveMaster; + final String? diveOperator; + final String? skipper; + final String? boat; + final String? weather; + final String? current; + final String? surfaceConditions; + final String? entryType; + + /// Dive-computer model string (e.g. "Shearwater Tern"). + final String? computer; + + /// Dive-computer serial number. + final String? serial; + + /// Owner/diver name (rarely populated in MacDive XML). + final String? diver; + + final MacDiveXmlSite? site; + final List tags; + final List diveTypes; + final List buddies; + final List gear; + final List gases; + final List samples; + + const MacDiveXmlDive({ + this.identifier, + this.date, + this.diveNumber, + this.repetitiveDive, + this.rating, + this.maxDepthMeters, + this.avgDepthMeters, + this.cns, + this.decoModel, + this.duration, + this.surfaceInterval, + this.sampleInterval, + this.gasModel, + this.airTempCelsius, + this.tempHighCelsius, + this.tempLowCelsius, + this.visibility, + this.weightKg, + this.notes, + this.diveMaster, + this.diveOperator, + this.skipper, + this.boat, + this.weather, + this.current, + this.surfaceConditions, + this.entryType, + this.computer, + this.serial, + this.diver, + this.site, + this.tags = const [], + this.diveTypes = const [], + this.buddies = const [], + this.gear = const [], + this.gases = const [], + this.samples = const [], + }); +} + +/// A dive-site record nested under a dive in MacDive XML. MacDive does not +/// emit a separate top-level site list; every dive carries its own site +/// block. Deduplication by name happens in the parser (Task 8). +class MacDiveXmlSite { + final String? name; + final String? country; + final String? location; + final String? bodyOfWater; + + /// Raw string like `"saltwater"`, `"freshwater"`, `"brackish"`. Mapped to + /// an enum in the parser via [MacDiveValueMapper]. + final String? waterType; + + final String? difficulty; + final double? altitudeMeters; + + /// Latitude in decimal degrees. Null when MacDive wrote 0.0/0.0 (which + /// MacDive uses as "no GPS set" — the reader applies that filter). + final double? latitude; + final double? longitude; + + const MacDiveXmlSite({ + this.name, + this.country, + this.location, + this.bodyOfWater, + this.waterType, + this.difficulty, + this.altitudeMeters, + this.latitude, + this.longitude, + }); +} + +/// A gear / equipment item attached to a dive. MacDive's `` format. +class MacDiveXmlGearItem { + /// E.g. "Regulator", "BCD - Wing", "Computer", "Suit". + final String? type; + + final String? manufacturer; + final String? name; + final String? serial; + + const MacDiveXmlGearItem({ + this.type, + this.manufacturer, + this.name, + this.serial, + }); +} + +/// A gas / tank entry from ``. A dive may have multiple for +/// multi-tank / deco setups. +class MacDiveXmlGas { + final double? pressureStartBar; + final double? pressureEndBar; + + /// 0-100. + final double? oxygenPercent; + + /// 0-100. + final double? heliumPercent; + + /// MacDive emits an integer flag (0 or 1) for double-tank setups. + final bool? doubleTank; + + /// Tank size in liters after unit conversion (MacDive Imperial uses cubic + /// feet at the working pressure; metric uses liters directly). + final double? tankSizeLiters; + + final double? workingPressureBar; + + /// E.g. "Open Circuit", "CCR", "SCR". + final String? supplyType; + + final Duration? duration; + + /// E.g. "AL80", "Steel 72". + final String? tankName; + + const MacDiveXmlGas({ + this.pressureStartBar, + this.pressureEndBar, + this.oxygenPercent, + this.heliumPercent, + this.doubleTank, + this.tankSizeLiters, + this.workingPressureBar, + this.supplyType, + this.duration, + this.tankName, + }); +} + +/// A single profile sample (``). MacDive samples are +/// typically 10-second intervals. +class MacDiveXmlSample { + final Duration time; + final double? depthMeters; + final double? pressureBar; + final double? temperatureCelsius; + final double? ppO2; + + /// No-deco limit in seconds after unit conversion. MacDive stores as + /// minutes; reader converts. + final int? ndtSeconds; + + const MacDiveXmlSample({ + required this.time, + this.depthMeters, + this.pressureBar, + this.temperatureCelsius, + this.ppO2, + this.ndtSeconds, + }); +} diff --git a/lib/features/universal_import/data/services/macdive_xml_reader.dart b/lib/features/universal_import/data/services/macdive_xml_reader.dart new file mode 100644 index 000000000..b4a6ad120 --- /dev/null +++ b/lib/features/universal_import/data/services/macdive_xml_reader.dart @@ -0,0 +1,265 @@ +import 'package:intl/intl.dart'; +import 'package:xml/xml.dart'; + +import 'package:submersion/features/universal_import/data/services/macdive_unit_converter.dart'; +import 'package:submersion/features/universal_import/data/services/macdive_xml_models.dart'; + +/// Parses a MacDive native XML document (`` root, DOCTYPE +/// `http://www.mac-dive.com/macdive_logbook.dtd`) into [MacDiveXmlLogbook]. +/// +/// All numeric fields are normalised to Submersion canonical units +/// (meters, Celsius, bar, kilograms, seconds) at this reader boundary, +/// so downstream code never sees raw imperial values. +/// +/// Error policy: malformed XML throws [XmlException] from the underlying +/// parser. Missing optional elements are silently represented as null. +/// Empty element content is also represented as null (not empty string). +class MacDiveXmlReader { + const MacDiveXmlReader._(); + + static final _dateFormat = DateFormat('yyyy-MM-dd HH:mm:ss'); + + /// Parse a MacDive XML string into a logbook. + static MacDiveXmlLogbook parse(String content) { + final doc = XmlDocument.parse(content); + final root = doc.rootElement; + + // Reject non-MacDive XML early: MacDive native XML must have at + // the root. Without this check, a user who forces a source override onto + // a UDDF or other dive XML would get a silent empty logbook because our + // `findElements('dive')` walk doesn't match anything at UDDF's top level. + // The parser's try/catch converts this into a user-visible ImportWarning. + if (root.name.local != 'dives') { + throw const FormatException( + 'Not a MacDive native XML document: expected root element', + ); + } + + final units = MacDiveUnitSystem.fromXml(_text(root, 'units')); + final converter = MacDiveUnitConverter(units); + final schemaVersion = _text(root, 'schema'); + + final dives = root + .findElements('dive') + .map((el) => _parseDive(el, converter)) + .toList(growable: false); + + return MacDiveXmlLogbook( + units: units, + schemaVersion: schemaVersion, + dives: dives, + ); + } + + // ---- dive ---- + + static MacDiveXmlDive _parseDive(XmlElement el, MacDiveUnitConverter c) { + return MacDiveXmlDive( + identifier: _text(el, 'identifier'), + date: _parseDate(_text(el, 'date')), + diveNumber: _int(_text(el, 'diveNumber')), + repetitiveDive: _int(_text(el, 'repetitiveDive')), + rating: _double(_text(el, 'rating')), + maxDepthMeters: c.depthToMeters(_double(_text(el, 'maxDepth'))), + avgDepthMeters: c.depthToMeters(_double(_text(el, 'averageDepth'))), + cns: _double(_text(el, 'cns')), + decoModel: _text(el, 'decoModel'), + duration: _durationSeconds(_int(_text(el, 'duration'))), + // MacDive's real files emit surfaceInterval in seconds (observed in + // the Apr 4 Socorro sample: 142 between adjacent liveaboard dives). + surfaceInterval: _durationSeconds(_int(_text(el, 'surfaceInterval'))), + sampleInterval: _durationSeconds(_int(_text(el, 'sampleInterval'))), + gasModel: _text(el, 'gasModel'), + airTempCelsius: c.tempToCelsius(_double(_text(el, 'tempAir'))), + tempHighCelsius: c.tempToCelsius(_double(_text(el, 'tempHigh'))), + tempLowCelsius: c.tempToCelsius(_double(_text(el, 'tempLow'))), + visibility: _text(el, 'visibility'), + weightKg: c.weightToKg(_double(_text(el, 'weight'))), + notes: _text(el, 'notes'), + diveMaster: _text(el, 'diveMaster'), + diveOperator: _text(el, 'diveOperator'), + skipper: _text(el, 'skipper'), + boat: _text(el, 'boat'), + weather: _text(el, 'weather'), + current: _text(el, 'current'), + surfaceConditions: _text(el, 'surfaceConditions'), + entryType: _text(el, 'entryType'), + computer: _text(el, 'computer'), + serial: _text(el, 'serial'), + diver: _text(el, 'diver'), + site: _parseSite(el.findElements('site').firstOrNull, c), + tags: _childList(el, 'tags', 'tag'), + diveTypes: _childList(el, 'types', 'type'), + buddies: _childList(el, 'buddies', 'buddy'), + gear: _parseGear(el), + gases: _parseGases(el, c), + samples: _parseSamples(el, c), + ); + } + + // ---- site ---- + + static MacDiveXmlSite? _parseSite(XmlElement? el, MacDiveUnitConverter c) { + if (el == null) return null; + final lat = _double(_text(el, 'lat')); + final lon = _double(_text(el, 'lon')); + // MacDive writes 0.0 / 0.0 when GPS isn't set — treat as absent. + final hasGps = lat != null && lon != null && !(lat == 0.0 && lon == 0.0); + + return MacDiveXmlSite( + name: _text(el, 'name'), + country: _text(el, 'country'), + location: _text(el, 'location'), + bodyOfWater: _text(el, 'bodyOfWater'), + waterType: _text(el, 'waterType'), + difficulty: _text(el, 'difficulty'), + altitudeMeters: c.depthToMeters(_double(_text(el, 'altitude'))), + latitude: hasGps ? lat : null, + longitude: hasGps ? lon : null, + ); + } + + // ---- gear ---- + + static List _parseGear(XmlElement dive) { + final container = dive.findElements('gear').firstOrNull; + if (container == null) return const []; + return container + .findElements('item') + .map( + (it) => MacDiveXmlGearItem( + type: _text(it, 'type'), + manufacturer: _text(it, 'manufacturer'), + name: _text(it, 'name'), + serial: _text(it, 'serial'), + ), + ) + .toList(growable: false); + } + + // ---- gases ---- + + static List _parseGases( + XmlElement dive, + MacDiveUnitConverter c, + ) { + final container = dive.findElements('gases').firstOrNull; + if (container == null) return const []; + return container + .findElements('gas') + .map((gas) { + final workingPressure = _double(_text(gas, 'workingPressure')); + return MacDiveXmlGas( + pressureStartBar: c.pressureToBar( + _double(_text(gas, 'pressureStart')), + ), + pressureEndBar: c.pressureToBar(_double(_text(gas, 'pressureEnd'))), + oxygenPercent: _double(_text(gas, 'oxygen')), + heliumPercent: _double(_text(gas, 'helium')), + doubleTank: (_int(_text(gas, 'double')) ?? 0) != 0, + tankSizeLiters: c.tankSizeLiters( + _double(_text(gas, 'tankSize')), + workingPressure, + ), + workingPressureBar: c.pressureToBar(workingPressure), + supplyType: _text(gas, 'supplyType'), + duration: _durationSeconds(_int(_text(gas, 'duration'))), + tankName: _text(gas, 'tankName'), + ); + }) + .toList(growable: false); + } + + // ---- samples ---- + + static List _parseSamples( + XmlElement dive, + MacDiveUnitConverter c, + ) { + final container = dive.findElements('samples').firstOrNull; + if (container == null) return const []; + return container + .findElements('sample') + .map((s) { + final timeSec = _int(_text(s, 'time')) ?? 0; + // MacDive emits ndt in minutes per spec; convert to seconds here. + final ndtMin = _int(_text(s, 'ndt')); + return MacDiveXmlSample( + time: Duration(seconds: timeSec), + depthMeters: c.depthToMeters(_double(_text(s, 'depth'))), + pressureBar: c.pressureToBar(_double(_text(s, 'pressure'))), + temperatureCelsius: c.tempToCelsius( + _double(_text(s, 'temperature')), + ), + ppO2: _double(_text(s, 'ppo2')), + ndtSeconds: ndtMin == null ? null : ndtMin * 60, + ); + }) + .toList(growable: false); + } + + // ---- helpers ---- + + static String? _text(XmlElement parent, String name) { + final el = parent.findElements(name).firstOrNull; + if (el == null) return null; + final trimmed = el.innerText.trim(); + return trimmed.isEmpty ? null : trimmed; + } + + static int? _int(String? raw) { + if (raw == null) return null; + return int.tryParse(raw); + } + + static double? _double(String? raw) { + if (raw == null) return null; + return double.tryParse(raw); + } + + static DateTime? _parseDate(String? raw) { + if (raw == null || raw.isEmpty) return null; + // MacDive XML carries no timezone info — treat the timestamp as a wall + // clock and encode it in UTC so dedup and display don't drift when the + // device's timezone changes (travel, DST). Matches the Subsurface XML + // parser's convention (see SubsurfaceXmlParser._parseDive). + try { + return _asUtcWallTime(_dateFormat.parseStrict(raw)); + } catch (_) { + final parsed = DateTime.tryParse(raw.replaceFirst(' ', 'T')); + return parsed == null ? null : _asUtcWallTime(parsed); + } + } + + static DateTime _asUtcWallTime(DateTime value) { + return DateTime.utc( + value.year, + value.month, + value.day, + value.hour, + value.minute, + value.second, + value.millisecond, + value.microsecond, + ); + } + + static Duration? _durationSeconds(int? seconds) { + if (seconds == null) return null; + return Duration(seconds: seconds); + } + + static List _childList( + XmlElement parent, + String containerTag, + String itemTag, + ) { + final container = parent.findElements(containerTag).firstOrNull; + if (container == null) return const []; + return container + .findElements(itemTag) + .map((e) => e.innerText.trim()) + .where((s) => s.isNotEmpty) + .toList(growable: false); + } +} diff --git a/lib/features/universal_import/presentation/providers/universal_import_providers.dart b/lib/features/universal_import/presentation/providers/universal_import_providers.dart index 31ad223ad..627682325 100644 --- a/lib/features/universal_import/presentation/providers/universal_import_providers.dart +++ b/lib/features/universal_import/presentation/providers/universal_import_providers.dart @@ -32,6 +32,7 @@ import 'package:submersion/features/universal_import/presentation/providers/csv_ import 'package:submersion/features/universal_import/data/parsers/csv_import_parser.dart'; import 'package:submersion/features/universal_import/data/parsers/fit_import_parser.dart'; import 'package:submersion/features/universal_import/data/parsers/import_parser.dart'; +import 'package:submersion/features/universal_import/data/parsers/macdive_xml_parser.dart'; import 'package:submersion/features/universal_import/data/parsers/placeholder_parser.dart'; import 'package:submersion/features/universal_import/data/parsers/subsurface_xml_parser.dart'; import 'package:submersion/features/universal_import/data/parsers/shearwater_cloud_parser.dart'; @@ -425,6 +426,7 @@ class UniversalImportNotifier extends StateNotifier { pipeline: registry != null ? CsvPipeline(registry: registry) : null, ), ImportFormat.uddf => UddfImportParser(), + ImportFormat.macdiveXml => const MacDiveXmlParser(), ImportFormat.subsurfaceXml => SubsurfaceXmlParser(), ImportFormat.fit => const FitImportParser(), ImportFormat.shearwaterDb => ShearwaterCloudParser(), diff --git a/test/features/universal_import/data/models/import_enums_test.dart b/test/features/universal_import/data/models/import_enums_test.dart index 9d7f47f1d..46e1a8f84 100644 --- a/test/features/universal_import/data/models/import_enums_test.dart +++ b/test/features/universal_import/data/models/import_enums_test.dart @@ -25,12 +25,13 @@ void main() { group('ImportFormat', () { test('has all expected values', () { - expect(ImportFormat.values, hasLength(12)); + expect(ImportFormat.values, hasLength(13)); }); test('displayName for each format', () { expect(ImportFormat.csv.displayName, 'CSV'); expect(ImportFormat.uddf.displayName, 'UDDF'); + expect(ImportFormat.macdiveXml.displayName, 'MacDive XML'); expect(ImportFormat.subsurfaceXml.displayName, 'Subsurface XML'); expect(ImportFormat.divingLogXml.displayName, 'Diving Log XML'); expect(ImportFormat.suuntoSml.displayName, 'Suunto SML'); @@ -44,13 +45,14 @@ void main() { }); test( - 'isSupported returns true for csv, uddf, subsurfaceXml, fit, shearwaterDb', + 'isSupported returns true for csv, uddf, subsurfaceXml, fit, shearwaterDb, macdiveXml', () { expect(ImportFormat.csv.isSupported, isTrue); expect(ImportFormat.uddf.isSupported, isTrue); expect(ImportFormat.subsurfaceXml.isSupported, isTrue); expect(ImportFormat.fit.isSupported, isTrue); expect(ImportFormat.shearwaterDb.isSupported, isTrue); + expect(ImportFormat.macdiveXml.isSupported, isTrue); }, ); @@ -149,7 +151,7 @@ void main() { group('SourceOverrideOption', () { group('supported list', () { test('contains expected number of entries', () { - expect(SourceOverrideOption.supported.length, 14); + expect(SourceOverrideOption.supported.length, 15); }); test('contains Submersion CSV entry', () { @@ -239,6 +241,16 @@ void main() { expect(match.first.displayName, 'MacDive (CSV)'); }); + test('contains MacDive XML entry', () { + final match = SourceOverrideOption.supported.where( + (o) => + o.sourceApp == SourceApp.macdive && + o.format == ImportFormat.macdiveXml, + ); + expect(match, hasLength(1)); + expect(match.first.displayName, 'MacDive (XML)'); + }); + test('contains Diving Log CSV entry', () { final match = SourceOverrideOption.supported.where( (o) => diff --git a/test/features/universal_import/data/parsers/macdive_xml_parser_test.dart b/test/features/universal_import/data/parsers/macdive_xml_parser_test.dart new file mode 100644 index 000000000..e243fe8bf --- /dev/null +++ b/test/features/universal_import/data/parsers/macdive_xml_parser_test.dart @@ -0,0 +1,293 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:submersion/features/dive_log/domain/entities/dive.dart' + show GasMix; +import 'package:submersion/features/universal_import/data/models/import_enums.dart'; +import 'package:submersion/features/universal_import/data/models/import_warning.dart'; +import 'package:submersion/features/universal_import/data/parsers/macdive_xml_parser.dart'; + +void main() { + group('MacDiveXmlParser', () { + late Uint8List bytes; + + setUpAll(() async { + final content = await File( + 'test/fixtures/macdive_xml/metric_small.xml', + ).readAsString(); + bytes = Uint8List.fromList(utf8.encode(content)); + }); + + test('supportedFormats exposes macdiveXml', () { + expect(const MacDiveXmlParser().supportedFormats, [ + ImportFormat.macdiveXml, + ]); + }); + + test('produces one dive entity', () async { + final payload = await const MacDiveXmlParser().parse(bytes); + expect(payload.entitiesOf(ImportEntityType.dives).length, 1); + }); + + test('produces one site, one buddy, one equipment, two tags', () async { + final payload = await const MacDiveXmlParser().parse(bytes); + expect(payload.entitiesOf(ImportEntityType.sites).length, 1); + expect(payload.entitiesOf(ImportEntityType.buddies).length, 1); + expect(payload.entitiesOf(ImportEntityType.equipment).length, 1); + expect(payload.entitiesOf(ImportEntityType.tags).length, 2); + }); + + test('dive carries sourceUuid from ', () async { + final payload = await const MacDiveXmlParser().parse(bytes); + final dive = payload.entitiesOf(ImportEntityType.dives).first; + expect(dive['sourceUuid'], '20240601090000-ABC123'); + }); + + test('dive carries tagRefs matching tag entity names', () async { + final payload = await const MacDiveXmlParser().parse(bytes); + final dive = payload.entitiesOf(ImportEntityType.dives).first; + expect(dive['tagRefs'], containsAll(['Reef', 'Photography'])); + final tagNames = payload + .entitiesOf(ImportEntityType.tags) + .map((t) => t['name'] as String) + .toSet(); + expect(tagNames, containsAll(['Reef', 'Photography'])); + }); + + test('dive carries site data (siteName + dive map refs)', () async { + final payload = await const MacDiveXmlParser().parse(bytes); + final dive = payload.entitiesOf(ImportEntityType.dives).first; + final site = payload.entitiesOf(ImportEntityType.sites).first; + expect(dive['siteName'], 'Test Reef'); + expect(site['name'], 'Test Reef'); + expect(site['country'], 'Mexico'); + expect(site['waterType'], 'salt'); + expect(site['latitude'], closeTo(24.12345, 0.00001)); + expect(site['longitude'], closeTo(-110.54321, 0.00001)); + }); + + test('dive has tank data derived from gases', () async { + final payload = await const MacDiveXmlParser().parse(bytes); + final dive = payload.entitiesOf(ImportEntityType.dives).first; + final tanks = dive['tanks'] as List?; + expect(tanks, isNotNull); + expect(tanks!.length, 1); + final tank = tanks.first as Map; + expect(tank['startPressure'], 200); + expect(tank['endPressure'], 60); + // Keys match the UDDF/Subsurface tank-map convention so + // UddfEntityImporter._buildTanks can consume them directly. + expect(tank['volume'], 12); + expect(tank['workingPressure'], 232); + // gasMix must be a `GasMix` object, not a Map — the importer does + // `t['gasMix'] as GasMix?` and a Map cast would throw. + expect(tank['gasMix'], isA()); + final gasMix = tank['gasMix'] as GasMix; + // GasMix stores o2/he as percentages 0-100 (not 0-1 fractions). + expect(gasMix.o2, closeTo(32.0, 0.01)); + expect(gasMix.he, closeTo(0.0, 0.01)); + }); + + test('dive has profile samples with timestamp+depth', () async { + final payload = await const MacDiveXmlParser().parse(bytes); + final dive = payload.entitiesOf(ImportEntityType.dives).first; + final profile = dive['profile'] as List?; + expect(profile, isNotNull); + expect(profile!.length, 3); + expect((profile[0] as Map)['timestamp'], 0); + expect((profile[1] as Map)['timestamp'], 60); + }); + + test('dive links gear via equipmentRefs with matching uddfId', () async { + final payload = await const MacDiveXmlParser().parse(bytes); + final dive = payload.entitiesOf(ImportEntityType.dives).first; + final equipment = payload.entitiesOf(ImportEntityType.equipment); + // Fixture has one gear item: manufacturer=Test, name=BCD1, no serial. + // Composite key "Test|BCD1|" lands on both sides. + expect(dive['equipmentRefs'], ['Test|BCD1|']); + expect(equipment.first['uddfId'], 'Test|BCD1|'); + expect(equipment.first['name'], 'BCD1'); + }); + + test( + 'dive maps boat/diveOperator/weather into existing UDDF key names', + () async { + final payload = await const MacDiveXmlParser().parse(bytes); + final dive = payload.entitiesOf(ImportEntityType.dives).first; + expect(dive['boatName'], 'MV Test'); + expect(dive['diveOperator'], 'Test Operator'); + expect(dive['weather'], 'Sunny'); + }, + ); + + test('returns error payload on invalid XML', () async { + final bad = Uint8List.fromList(utf8.encode('not xml at all')); + final payload = await const MacDiveXmlParser().parse(bad); + expect(payload.entitiesOf(ImportEntityType.dives), isEmpty); + expect(payload.warnings, isNotEmpty); + expect(payload.warnings.first.severity, ImportWarningSeverity.error); + }); + + test('maps MacDive waterType "saltwater" to WaterType.salt.name', () async { + final payload = await const MacDiveXmlParser().parse(bytes); + final site = payload.entitiesOf(ImportEntityType.sites).first; + // Downstream UddfEntityImporter calls _parseEnum(raw, WaterType.values) + // which matches by `.name`. So "saltwater" (MacDive raw) should become + // "salt" (WaterType.salt.name). + expect(site['waterType'], 'salt'); + }); + + test('maps MacDive entryType "Boat" to EntryMethod.boat.name', () async { + const xml = ''' +Metric2.2.0 + + 2024-01-01 09:00:00d1 + 201800 + Boat + + +'''; + final bytes = Uint8List.fromList(utf8.encode(xml)); + final payload = await const MacDiveXmlParser().parse(bytes); + final dive = payload.entitiesOf(ImportEntityType.dives).first; + expect(dive['entryMethod'], 'boat'); + }); + + test('unknown entryType strings pass through as null', () async { + const xml = ''' +Metric2.2.0 + + 2024-01-01 09:00:00d1 + 201800 + WormholeDive + + +'''; + final bytes = Uint8List.fromList(utf8.encode(xml)); + final payload = await const MacDiveXmlParser().parse(bytes); + final dive = payload.entitiesOf(ImportEntityType.dives).first; + // Unknown value: omit the key entirely so the importer leaves the + // dive's entryMethod at its default, rather than writing a garbage + // value. + expect(dive.containsKey('entryMethod'), isFalse); + }); + }); + + group('MacDiveXmlParser dedup behavior', () { + test('two dives with same site name produce one site entity', () async { + const xml = ''' +Metric2.2.0 + + 2024-01-01 09:00:00d1 + 201800 + ReefUS + + + + 2024-01-01 13:00:00d2 + 221800 + ReefUS + + +'''; + final bytes = Uint8List.fromList(utf8.encode(xml)); + final payload = await const MacDiveXmlParser().parse(bytes); + expect(payload.entitiesOf(ImportEntityType.dives).length, 2); + expect( + payload.entitiesOf(ImportEntityType.sites).length, + 1, + reason: 'sites dedup by name', + ); + }); + + test( + 'empty gear elements are skipped (no phantom entity)', + () async { + const xml = ''' +Metric2.2.0 + + 2024-01-01 09:00:00d1 + 201800 + + + + TestBCD1 + + + +'''; + final bytes = Uint8List.fromList(utf8.encode(xml)); + final payload = await const MacDiveXmlParser().parse(bytes); + final equipment = payload.entitiesOf(ImportEntityType.equipment); + expect(equipment.length, 1, reason: 'only the populated item survives'); + expect(equipment.first['name'], 'BCD1'); + // equipmentRefs reflects only the surviving item; skipped empty + // `` elements do not leave phantom refs on the dive either. + final dive = payload.entitiesOf(ImportEntityType.dives).first; + expect(dive['equipmentRefs'], ['Test|BCD1|']); + }, + ); + + test('duplicate entries on one dive dedupe in buddyRefs', () async { + const xml = ''' +Metric2.2.0 + + 2024-01-01 09:00:00d1 + 201800 + AliceAliceBob + + +'''; + final bytes = Uint8List.fromList(utf8.encode(xml)); + final payload = await const MacDiveXmlParser().parse(bytes); + final dive = payload.entitiesOf(ImportEntityType.dives).first; + expect(dive['buddyRefs'], ['Alice', 'Bob']); + }); + + test('duplicate entries on one dive dedupe in tagRefs', () async { + // dive_tags has no UNIQUE(diveId, tagId) constraint, so duplicates + // would surface as the same tag shown twice on a dive. + const xml = ''' +Metric2.2.0 + + 2024-01-01 09:00:00d1 + 201800 + ReefReefPhotography + + +'''; + final bytes = Uint8List.fromList(utf8.encode(xml)); + final payload = await const MacDiveXmlParser().parse(bytes); + final dive = payload.entitiesOf(ImportEntityType.dives).first; + expect(dive['tagRefs'], ['Reef', 'Photography']); + }); + + test('multiple dives with overlapping buddies dedup', () async { + const xml = ''' +Metric2.2.0 + + 2024-01-01 09:00:00d1 + 201800 + AliceBob + + + + 2024-01-01 13:00:00d2 + 221800 + Alice + + +'''; + final bytes = Uint8List.fromList(utf8.encode(xml)); + final payload = await const MacDiveXmlParser().parse(bytes); + expect( + payload.entitiesOf(ImportEntityType.buddies).length, + 2, + reason: 'Alice+Bob unique', + ); + }); + }); +} diff --git a/test/features/universal_import/data/parsers/macdive_xml_real_sample_test.dart b/test/features/universal_import/data/parsers/macdive_xml_real_sample_test.dart new file mode 100644 index 000000000..c88a56924 --- /dev/null +++ b/test/features/universal_import/data/parsers/macdive_xml_real_sample_test.dart @@ -0,0 +1,159 @@ +/// MacDive XML real-sample regression suite. +/// +/// These tests exercise a real MacDive native XML export that is not checked +/// into the repository. To run them locally, point the [MACDIVE_XML_SAMPLE] +/// compile-time environment variable at your local sample file: +/// +/// flutter test \ +/// --dart-define=MACDIVE_XML_SAMPLE=/absolute/path/to/sample.xml \ +/// --run-skipped --tags=real-data \ +/// test/features/universal_import/data/parsers/macdive_xml_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:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:submersion/features/universal_import/data/models/import_enums.dart'; +import 'package:submersion/features/universal_import/data/models/import_warning.dart'; +import 'package:submersion/features/universal_import/data/parsers/macdive_xml_parser.dart'; + +/// Compile-time env var that points at a local MacDive native XML sample. +/// +/// Injected via `flutter test --dart-define=MACDIVE_XML_SAMPLE=...`. +const _realSamplePathEnvVar = String.fromEnvironment('MACDIVE_XML_SAMPLE'); + +String? _realSamplePath() { + if (_realSamplePathEnvVar.isEmpty) return null; + return _realSamplePathEnvVar; +} + +void main() { + group('MacDive XML real-sample regression', () { + late Uint8List bytes; + var hasFixture = false; + + setUpAll(() async { + final path = _realSamplePath(); + if (path == null) return; + final file = File(path); + if (!file.existsSync()) return; + bytes = Uint8List.fromList(utf8.encode(await file.readAsString())); + hasFixture = true; + }); + + bool skipIfNoFixture() { + if (hasFixture) return false; + markTestSkipped( + 'Real sample not available. Set MACDIVE_XML_SAMPLE via ' + '--dart-define and pass --run-skipped --tags=real-data to run.', + ); + return true; + } + + test('parses 540 dives without errors', () async { + if (skipIfNoFixture()) return; + final payload = await const MacDiveXmlParser().parse(bytes); + expect(payload.entitiesOf(ImportEntityType.dives).length, 540); + // No errors (warnings are ok). + expect( + payload.warnings.where( + (w) => w.severity == ImportWarningSeverity.error, + ), + isEmpty, + ); + }); + + test('every dive has a sourceUuid from ', () async { + if (skipIfNoFixture()) return; + final payload = await const MacDiveXmlParser().parse(bytes); + final dives = payload.entitiesOf(ImportEntityType.dives); + expect( + dives.every((d) => d['sourceUuid'] is String), + isTrue, + reason: 'MacDive assigns every dive an identifier', + ); + }); + + test('tags imported (MacDive XML has tags, unlike MacDive UDDF)', () async { + if (skipIfNoFixture()) return; + final payload = await const MacDiveXmlParser().parse(bytes); + final tags = payload.entitiesOf(ImportEntityType.tags); + expect( + tags, + isNotEmpty, + reason: 'key user value of MacDive XML import is tag preservation', + ); + expect( + tags.length, + greaterThanOrEqualTo(20), + reason: 'real sample has 645 tag occurrences, deduped to 40+ unique', + ); + }); + + test('sites deduped by name', () async { + if (skipIfNoFixture()) return; + final payload = await const MacDiveXmlParser().parse(bytes); + final sites = payload.entitiesOf(ImportEntityType.sites); + expect(sites, isNotEmpty); + final names = sites.map((s) => s['name']).toSet(); + expect( + names.length, + sites.length, + reason: 'site list should have no duplicate names', + ); + }); + + test('at least one dive has tagRefs populated', () async { + if (skipIfNoFixture()) return; + final payload = await const MacDiveXmlParser().parse(bytes); + final dives = payload.entitiesOf(ImportEntityType.dives); + final withTags = dives.where( + (d) => (d['tagRefs'] as List?)?.isNotEmpty ?? false, + ); + expect(withTags, isNotEmpty); + }); + + test('at least one dive has tanks + profile populated', () async { + if (skipIfNoFixture()) return; + final payload = await const MacDiveXmlParser().parse(bytes); + final dives = payload.entitiesOf(ImportEntityType.dives); + final withTanks = dives.where( + (d) => (d['tanks'] as List?)?.isNotEmpty ?? false, + ); + final withProfile = dives.where( + (d) => (d['profile'] as List?)?.isNotEmpty ?? false, + ); + expect(withTanks, isNotEmpty); + expect(withProfile, isNotEmpty); + }); + + test('imperial sample: max depth values are plausible in meters', () async { + if (skipIfNoFixture()) return; + final payload = await const MacDiveXmlParser().parse(bytes); + final dives = payload.entitiesOf(ImportEntityType.dives); + // After conversion, max depths should be mostly 5-80 meters (reasonable + // recreational / tech range). If the converter is missed, we'd see + // raw feet values: 16-260 ft, detectable as depths > 100. + final depths = dives + .map((d) => d['maxDepth'] as double?) + .whereType() + .toList(); + expect(depths, isNotEmpty); + // 95th percentile should be under 100 meters (would be 328 ft in raw feet) + depths.sort(); + final p95 = depths[(depths.length * 0.95).floor()]; + expect( + p95, + lessThan(100.0), + reason: 'max depth 95th percentile should be in meters, not feet', + ); + }); + }); +} diff --git a/test/features/universal_import/data/services/format_detector_test.dart b/test/features/universal_import/data/services/format_detector_test.dart index 66b94cc2a..d8e45d899 100644 --- a/test/features/universal_import/data/services/format_detector_test.dart +++ b/test/features/universal_import/data/services/format_detector_test.dart @@ -121,6 +121,48 @@ void main() { // Should fall through to CSV or unknown expect(result.format, isNot(ImportFormat.uddf)); }); + + test('detects MacDive XML via DOCTYPE', () { + const xml = ''' + +2.2.0'''; + final result = detector.detect(Uint8List.fromList(utf8.encode(xml))); + expect(result.format, ImportFormat.macdiveXml); + expect(result.sourceApp, SourceApp.macdive); + expect(result.confidence, greaterThanOrEqualTo(0.9)); + }); + + test('detects MacDive XML via + when DOCTYPE missing', () { + const xml = + '2.2.0'; + final result = detector.detect(Uint8List.fromList(utf8.encode(xml))); + expect(result.format, ImportFormat.macdiveXml); + expect(result.sourceApp, SourceApp.macdive); + }); + + test('does not match plain UDDF as MacDive XML', () { + const xml = ''; + final result = detector.detect(Uint8List.fromList(utf8.encode(xml))); + expect(result.format, ImportFormat.uddf); + }); + + test('does not match lone without as MacDive XML', () { + const xml = ''; + final result = detector.detect(Uint8List.fromList(utf8.encode(xml))); + expect(result.format, isNot(ImportFormat.macdiveXml)); + }); + + test('detects MacDive XML when root tags carry attributes', () { + const xml = + '' + '' + '2.2.0' + '' + ''; + final result = detector.detect(Uint8List.fromList(utf8.encode(xml))); + expect(result.format, ImportFormat.macdiveXml); + expect(result.sourceApp, SourceApp.macdive); + }); }); group('CSV detection', () { diff --git a/test/features/universal_import/data/services/macdive_unit_converter_test.dart b/test/features/universal_import/data/services/macdive_unit_converter_test.dart new file mode 100644 index 000000000..ea28b5e25 --- /dev/null +++ b/test/features/universal_import/data/services/macdive_unit_converter_test.dart @@ -0,0 +1,90 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:submersion/features/universal_import/data/services/macdive_unit_converter.dart'; +import 'package:submersion/features/universal_import/data/services/macdive_xml_models.dart'; + +void main() { + const metric = MacDiveUnitConverter(MacDiveUnitSystem.metric); + const imperial = MacDiveUnitConverter(MacDiveUnitSystem.imperial); + const unknown = MacDiveUnitConverter(MacDiveUnitSystem.unknown); + + group('depthToMeters', () { + test('metric passes through', () { + expect(metric.depthToMeters(20.0), 20.0); + }); + test('imperial converts feet → meters (30.48 cm / ft)', () { + expect(imperial.depthToMeters(100.0), closeTo(30.48, 0.001)); + expect(imperial.depthToMeters(33.0), closeTo(10.058, 0.001)); + }); + test('unknown passes through (best effort)', () { + expect(unknown.depthToMeters(20.0), 20.0); + }); + test('null passes through', () { + expect(imperial.depthToMeters(null), isNull); + expect(metric.depthToMeters(null), isNull); + }); + }); + + group('tempToCelsius', () { + test('imperial F → C', () { + expect(imperial.tempToCelsius(80.0), closeTo(26.667, 0.01)); + expect(imperial.tempToCelsius(32.0), closeTo(0.0, 0.001)); + expect(imperial.tempToCelsius(212.0), closeTo(100.0, 0.001)); + }); + test('metric passes through', () { + expect(metric.tempToCelsius(25.0), 25.0); + }); + test('null passes through', () { + expect(imperial.tempToCelsius(null), isNull); + }); + }); + + group('pressureToBar', () { + test('imperial psi → bar (1 psi = 0.0689476 bar)', () { + expect(imperial.pressureToBar(3000.0), closeTo(206.843, 0.01)); + expect(imperial.pressureToBar(14.5038), closeTo(1.0, 0.001)); + }); + test('metric passes through', () { + expect(metric.pressureToBar(200.0), 200.0); + }); + test('null passes through', () { + expect(imperial.pressureToBar(null), isNull); + }); + }); + + group('weightToKg', () { + test('imperial lb → kg (1 lb = 0.453592 kg)', () { + expect(imperial.weightToKg(10.0), closeTo(4.536, 0.01)); + expect(imperial.weightToKg(2.205), closeTo(1.0, 0.01)); + }); + test('metric passes through', () { + expect(metric.weightToKg(5.0), 5.0); + }); + test('null passes through', () { + expect(imperial.weightToKg(null), isNull); + }); + }); + + group('tankSizeLiters', () { + test('metric passes through (liters)', () { + expect(metric.tankSizeLiters(12.0, 232.0), 12.0); + }); + + test('imperial AL80 (77.4 cft @ 3000 psi) approximates 10.59 L', () { + // cft to L: multiply by 28.3168 + // divide by working pressure in bar (3000 psi = 206.843 bar) + // 77.4 * 28.3168 / 206.843 ≈ 10.59 L + final result = imperial.tankSizeLiters(77.4, 3000.0); + expect(result, closeTo(10.59, 0.1)); + }); + + test('null size passes through', () { + expect(imperial.tankSizeLiters(null, 3000.0), isNull); + }); + + test('imperial with missing working pressure returns null', () { + expect(imperial.tankSizeLiters(77.4, null), isNull); + expect(imperial.tankSizeLiters(77.4, 0.0), isNull); + }); + }); +} diff --git a/test/features/universal_import/data/services/macdive_value_mapper_test.dart b/test/features/universal_import/data/services/macdive_value_mapper_test.dart new file mode 100644 index 000000000..f33061c04 --- /dev/null +++ b/test/features/universal_import/data/services/macdive_value_mapper_test.dart @@ -0,0 +1,104 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:submersion/features/universal_import/data/services/macdive_value_mapper.dart'; + +void main() { + group('MacDiveValueMapper.waterType', () { + test('maps saltwater strings', () { + expect(MacDiveValueMapper.waterType('saltwater'), isNotNull); + expect(MacDiveValueMapper.waterType('Salt'), isNotNull); + expect(MacDiveValueMapper.waterType('sea'), isNotNull); + expect(MacDiveValueMapper.waterType('Ocean'), isNotNull); + }); + + test('maps freshwater strings', () { + expect(MacDiveValueMapper.waterType('freshwater'), isNotNull); + expect(MacDiveValueMapper.waterType('Fresh'), isNotNull); + expect(MacDiveValueMapper.waterType('lake'), isNotNull); + expect(MacDiveValueMapper.waterType('river'), isNotNull); + }); + + test('maps brackish strings', () { + expect(MacDiveValueMapper.waterType('brackish'), isNotNull); + expect(MacDiveValueMapper.waterType('Brackish'), isNotNull); + }); + + test('returns null for unknown or empty', () { + expect(MacDiveValueMapper.waterType('swamp'), isNull); + expect(MacDiveValueMapper.waterType(null), isNull); + expect(MacDiveValueMapper.waterType(''), isNull); + expect(MacDiveValueMapper.waterType(' '), isNull); + }); + }); + + group('MacDiveValueMapper.entryType', () { + test('maps boat and related entries', () { + expect(MacDiveValueMapper.entryType('boat'), isNotNull); + expect(MacDiveValueMapper.entryType('Boat'), isNotNull); + expect(MacDiveValueMapper.entryType('liveaboard'), isNotNull); + }); + + test('maps shore and related entries', () { + expect(MacDiveValueMapper.entryType('shore'), isNotNull); + expect(MacDiveValueMapper.entryType('beach'), isNotNull); + expect(MacDiveValueMapper.entryType('Shore'), isNotNull); + }); + + test('maps back roll entries', () { + expect(MacDiveValueMapper.entryType('back roll'), isNotNull); + expect(MacDiveValueMapper.entryType('backroll'), isNotNull); + expect(MacDiveValueMapper.entryType('Back Roll'), isNotNull); + }); + + test('maps giant stride entries', () { + expect(MacDiveValueMapper.entryType('giant stride'), isNotNull); + expect(MacDiveValueMapper.entryType('giantstride'), isNotNull); + expect(MacDiveValueMapper.entryType('Giant Stride'), isNotNull); + }); + + test('returns null for unknown or empty', () { + expect(MacDiveValueMapper.entryType(null), isNull); + expect(MacDiveValueMapper.entryType(''), isNull); + expect(MacDiveValueMapper.entryType(' '), isNull); + expect(MacDiveValueMapper.entryType('cave'), isNull); + }); + }); + + group('MacDiveValueMapper.rating', () { + test('rounds fractional ratings', () { + expect(MacDiveValueMapper.rating(3.2), 3); + expect(MacDiveValueMapper.rating(4.7), 5); + expect(MacDiveValueMapper.rating(2.5), 3); + }); + + test('clamps out-of-range', () { + expect(MacDiveValueMapper.rating(-1.0), 0); + expect(MacDiveValueMapper.rating(7.5), 5); + expect(MacDiveValueMapper.rating(0.0), 0); + expect(MacDiveValueMapper.rating(5.0), 5); + }); + + test('null passes through', () { + expect(MacDiveValueMapper.rating(null), isNull); + }); + }); + + group('MacDiveValueMapper.normalizeDiveType', () { + test('trims whitespace', () { + expect( + MacDiveValueMapper.normalizeDiveType(' Recreational '), + 'Recreational', + ); + expect(MacDiveValueMapper.normalizeDiveType('\tNight\n'), 'Night'); + }); + + test('passes through arbitrary strings', () { + expect(MacDiveValueMapper.normalizeDiveType('Night'), 'Night'); + expect(MacDiveValueMapper.normalizeDiveType('Cave'), 'Cave'); + expect( + MacDiveValueMapper.normalizeDiveType('custom-dive-type'), + 'custom-dive-type', + ); + }); + }); +} diff --git a/test/features/universal_import/data/services/macdive_xml_reader_test.dart b/test/features/universal_import/data/services/macdive_xml_reader_test.dart new file mode 100644 index 000000000..cb8b9c167 --- /dev/null +++ b/test/features/universal_import/data/services/macdive_xml_reader_test.dart @@ -0,0 +1,277 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:submersion/features/universal_import/data/services/macdive_xml_models.dart'; +import 'package:submersion/features/universal_import/data/services/macdive_xml_reader.dart'; + +void main() { + group('MacDiveXmlReader (metric fixture)', () { + late String content; + + setUpAll(() async { + content = await File( + 'test/fixtures/macdive_xml/metric_small.xml', + ).readAsString(); + }); + + test('parses units, schema version, and single dive', () { + final logbook = MacDiveXmlReader.parse(content); + expect(logbook.units, MacDiveUnitSystem.metric); + expect(logbook.schemaVersion, '2.2.0'); + expect(logbook.dives.length, 1); + }); + + test('parses identifier, date, diveNumber', () { + final dive = MacDiveXmlReader.parse(content).dives.first; + expect(dive.identifier, '20240601090000-ABC123'); + // MacDive XML has no timezone; we encode the wall clock in UTC so + // timestamps don't drift across DST or travel. See _parseDate. + expect(dive.date, DateTime.utc(2024, 6, 1, 9, 0, 0)); + expect(dive.date!.isUtc, isTrue); + expect(dive.diveNumber, 42); + }); + + test( + 'parses metric depths/temps/weight in canonical units (no conversion)', + () { + final dive = MacDiveXmlReader.parse(content).dives.first; + expect(dive.maxDepthMeters, 25.4); + expect(dive.avgDepthMeters, 18.0); + expect(dive.tempHighCelsius, 26.5); + expect(dive.tempLowCelsius, 20.0); + expect(dive.weightKg, 5.0); + }, + ); + + test('parses durations as Duration objects', () { + final dive = MacDiveXmlReader.parse(content).dives.first; + expect(dive.duration, const Duration(seconds: 2400)); + expect(dive.sampleInterval, const Duration(seconds: 10)); + }); + + test('parses tags as list', () { + final dive = MacDiveXmlReader.parse(content).dives.first; + expect(dive.tags, ['Reef', 'Photography']); + }); + + test('parses buddies as list', () { + final dive = MacDiveXmlReader.parse(content).dives.first; + expect(dive.buddies, ['Alice']); + }); + + test('parses site block with coordinates', () { + final dive = MacDiveXmlReader.parse(content).dives.first; + expect(dive.site?.name, 'Test Reef'); + expect(dive.site?.country, 'Mexico'); + expect(dive.site?.location, 'Baja California'); + expect(dive.site?.waterType, 'saltwater'); + expect(dive.site?.latitude, closeTo(24.12345, 0.00001)); + expect(dive.site?.longitude, closeTo(-110.54321, 0.00001)); + }); + + test('parses gear items', () { + final dive = MacDiveXmlReader.parse(content).dives.first; + expect(dive.gear.length, 1); + expect(dive.gear.first.type, 'BCD'); + expect(dive.gear.first.manufacturer, 'Test'); + expect(dive.gear.first.name, 'BCD1'); + }); + + test('parses gas definition (metric)', () { + final dive = MacDiveXmlReader.parse(content).dives.first; + expect(dive.gases.length, 1); + final gas = dive.gases.first; + expect(gas.pressureStartBar, 200); + expect(gas.pressureEndBar, 60); + expect(gas.oxygenPercent, 32); + expect(gas.heliumPercent, 0); + expect(gas.supplyType, 'Open Circuit'); + expect(gas.duration, const Duration(seconds: 2400)); + expect(gas.tankName, 'AL80'); + expect(gas.doubleTank, false); + expect(gas.workingPressureBar, 232); + }); + + test('parses samples with time as Duration', () { + final dive = MacDiveXmlReader.parse(content).dives.first; + expect(dive.samples.length, 3); + expect(dive.samples[0].time, Duration.zero); + expect(dive.samples[1].time, const Duration(seconds: 60)); + expect(dive.samples[2].time, const Duration(seconds: 2400)); + expect(dive.samples[0].temperatureCelsius, 26.5); + }); + + test('parses notes from CDATA', () { + final dive = MacDiveXmlReader.parse(content).dives.first; + expect(dive.notes, 'Nice reef dive'); + }); + + test('parses operator and boat', () { + final dive = MacDiveXmlReader.parse(content).dives.first; + expect(dive.diveOperator, 'Test Operator'); + expect(dive.boat, 'MV Test'); + expect(dive.weather, 'Sunny'); + }); + }); + + group('MacDiveXmlReader edge cases', () { + test('treats lat=0 lon=0 as no-GPS', () { + const xml = ''' + + Metric + 2.2.0 + + 2024-01-01 00:00:00 + X00 + + +'''; + final dive = MacDiveXmlReader.parse(xml).dives.first; + expect(dive.site?.latitude, isNull); + expect(dive.site?.longitude, isNull); + }); + + test('missing optional fields produce null, not crash', () { + const xml = ''' + + Metric + 2.2.0 + + 2024-01-01 00:00:00 + 20 + 1800 + + +'''; + final dive = MacDiveXmlReader.parse(xml).dives.first; + expect(dive.site, isNull); + expect(dive.tags, isEmpty); + expect(dive.buddies, isEmpty); + expect(dive.gases, isEmpty); + expect(dive.gear, isEmpty); + expect(dive.samples, isEmpty); + expect(dive.notes, isNull); + expect(dive.boat, isNull); + }); + + test('empty elements produce null, not empty string', () { + const xml = ''' + + Metric + 2.2.0 + + 2024-01-01 00:00:00 + + + + +'''; + final dive = MacDiveXmlReader.parse(xml).dives.first; + expect(dive.notes, isNull); + expect(dive.boat, isNull); + }); + + test('throws when root element is not ', () { + // Guards against a source-override misuse where a user forces "MacDive + // XML" on a UDDF or other file: without this check, the reader would + // silently return an empty logbook. + const uddf = + '' + ''; + expect( + () => MacDiveXmlReader.parse(uddf), + throwsA(isA()), + ); + }); + + test('unknown units system passes through numerics unchanged', () { + const xml = ''' + + 2.2.0 + + 2024-01-01 00:00:00 + 50 + + +'''; + final logbook = MacDiveXmlReader.parse(xml); + expect(logbook.units, MacDiveUnitSystem.unknown); + expect(logbook.dives.first.maxDepthMeters, 50); + }); + }); + + group('MacDiveXmlReader (imperial fixture)', () { + late String content; + + setUpAll(() async { + content = await File( + 'test/fixtures/macdive_xml/imperial_small.xml', + ).readAsString(); + }); + + test('declares imperial unit system', () { + final logbook = MacDiveXmlReader.parse(content); + expect(logbook.units, MacDiveUnitSystem.imperial); + }); + + test('depths converted feet → meters', () { + final dive = MacDiveXmlReader.parse(content).dives.first; + // 100 ft × 0.3048 = 30.48 m + expect(dive.maxDepthMeters, closeTo(30.48, 0.01)); + // 60 ft × 0.3048 = 18.288 m + expect(dive.avgDepthMeters, closeTo(18.288, 0.01)); + }); + + test('temperatures converted °F → °C', () { + final dive = MacDiveXmlReader.parse(content).dives.first; + // 80°F = 26.667°C + expect(dive.tempHighCelsius, closeTo(26.667, 0.01)); + // 70°F = 21.111°C + expect(dive.tempLowCelsius, closeTo(21.111, 0.01)); + }); + + test('weight converted lb → kg', () { + final dive = MacDiveXmlReader.parse(content).dives.first; + // 10 lb × 0.453592 = 4.536 kg + expect(dive.weightKg, closeTo(4.536, 0.01)); + }); + + test('gas pressures converted psi → bar', () { + final dive = MacDiveXmlReader.parse(content).dives.first; + final gas = dive.gases.first; + // 3000 psi × 0.0689476 = 206.843 bar + expect(gas.pressureStartBar, closeTo(206.843, 0.1)); + // 1000 psi × 0.0689476 = 68.948 bar + expect(gas.pressureEndBar, closeTo(68.948, 0.1)); + expect(gas.workingPressureBar, closeTo(206.843, 0.1)); + }); + + test('tank size converted cft-at-working-pressure → liters', () { + final dive = MacDiveXmlReader.parse(content).dives.first; + // 77.4 cft × 28.3168 / (3000 × 0.0689476) ≈ 10.59 L + expect(dive.gases.first.tankSizeLiters, closeTo(10.59, 0.1)); + }); + + test('sample temperatures converted °F → °C', () { + final dive = MacDiveXmlReader.parse(content).dives.first; + // Sample 1: 80°F → 26.667°C + expect(dive.samples.first.temperatureCelsius, closeTo(26.667, 0.01)); + // Sample 2: 72°F → 22.222°C + expect(dive.samples[1].temperatureCelsius, closeTo(22.222, 0.01)); + }); + + test('sample depths and pressures converted', () { + final dive = MacDiveXmlReader.parse(content).dives.first; + // Sample 2: 100 ft → 30.48 m, 2000 psi → 137.9 bar + expect(dive.samples[1].depthMeters, closeTo(30.48, 0.01)); + expect(dive.samples[1].pressureBar, closeTo(137.895, 0.1)); + }); + + test('sample times remain in seconds (no unit conversion)', () { + final dive = MacDiveXmlReader.parse(content).dives.first; + expect(dive.samples[0].time, Duration.zero); + expect(dive.samples[1].time, const Duration(seconds: 600)); + }); + }); +} diff --git a/test/features/universal_import/presentation/providers/universal_import_notifier_test.dart b/test/features/universal_import/presentation/providers/universal_import_notifier_test.dart index 6c89e8fc7..ac0a7683f 100644 --- a/test/features/universal_import/presentation/providers/universal_import_notifier_test.dart +++ b/test/features/universal_import/presentation/providers/universal_import_notifier_test.dart @@ -3,12 +3,15 @@ import 'dart:typed_data'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'package:sqlite3/sqlite3.dart' as sqlite3; +import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; import 'package:submersion/features/universal_import/data/models/detection_result.dart'; import 'package:submersion/features/universal_import/data/models/field_mapping.dart'; import 'package:submersion/features/universal_import/data/models/import_enums.dart'; import 'package:submersion/features/universal_import/data/models/import_options.dart'; import 'package:submersion/features/universal_import/data/models/import_payload.dart'; +import 'package:submersion/features/universal_import/data/parsers/macdive_xml_parser.dart'; import 'package:submersion/features/universal_import/presentation/providers/universal_import_providers.dart'; /// Helper to encode a CSV string to bytes for testing. @@ -29,8 +32,13 @@ void main() { late ProviderContainer container; late UniversalImportNotifier notifier; - setUp(() { - container = ProviderContainer(); + setUp(() async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + + container = ProviderContainer( + overrides: [sharedPreferencesProvider.overrideWithValue(prefs)], + ); notifier = container.read(universalImportNotifierProvider.notifier); }); @@ -1411,5 +1419,61 @@ void main() { expect(notifier.state.fileBytes, isNull); }); }); + + group('UniversalImportNotifier - MacDive XML', () { + test('detects MacDive native XML format', () async { + final content = await File( + 'test/fixtures/macdive_xml/metric_small.xml', + ).readAsString(); + final bytes = Uint8List.fromList(content.codeUnits); + + final detection = await notifier.loadFileFromBytes( + bytes, + 'metric_small.xml', + ); + + expect(detection.format, ImportFormat.macdiveXml); + expect(detection.sourceApp, SourceApp.macdive); + expect(notifier.state.fileBytes, isNotNull); + expect(notifier.state.fileName, 'metric_small.xml'); + expect(notifier.state.currentStep, ImportWizardStep.sourceConfirmation); + }); + + test( + 'MacDiveXmlParser produces full payload with tags and sites', + () async { + final content = await File( + 'test/fixtures/macdive_xml/metric_small.xml', + ).readAsString(); + final bytes = Uint8List.fromList(content.codeUnits); + + // Test that MacDiveXmlParser (the parser returned by _parserFor + // at line 429 for ImportFormat.macdiveXml) produces the correct + // payload with all expected entities. If the switch case is + // regressed to return PlaceholderParser, this test would fail + // because PlaceholderParser always returns an empty payload (0 tags). + // NOTE: Testing _parserFor indirectly via confirmSource would + // require full database initialization (SharedPreferences, Drift, + // etc.), so we test the parser directly here. + const parser = MacDiveXmlParser(); + final payload = await parser.parse(bytes); + + expect( + payload.entitiesOf(ImportEntityType.dives).length, + 1, + reason: 'metric_small.xml fixture contains 1 dive', + ); + expect( + payload.entitiesOf(ImportEntityType.tags).length, + 2, + reason: + 'metric_small.xml has 2 tags (Reef, Photography); PlaceholderParser would return 0 tags', + ); + expect(payload.entitiesOf(ImportEntityType.sites).length, 1); + expect(payload.entitiesOf(ImportEntityType.buddies).length, 1); + expect(payload.entitiesOf(ImportEntityType.equipment).length, 1); + }, + ); + }); }); } diff --git a/test/fixtures/macdive_xml/imperial_small.xml b/test/fixtures/macdive_xml/imperial_small.xml new file mode 100644 index 000000000..449000fba --- /dev/null +++ b/test/fixtures/macdive_xml/imperial_small.xml @@ -0,0 +1,31 @@ + + + + Imperial + 2.2.0 + + 2024-06-01 09:00:00 + 20240601090000-IMP + 100 + 60 + 2400 + 80 + 70 + 10 + + + 3000 + 1000 + 32 + 77.4 + 3000 + Open Circuit + AL80 + + + + 0300080 + 100200072 + + + diff --git a/test/fixtures/macdive_xml/metric_small.xml b/test/fixtures/macdive_xml/metric_small.xml new file mode 100644 index 000000000..a1658dc0a --- /dev/null +++ b/test/fixtures/macdive_xml/metric_small.xml @@ -0,0 +1,60 @@ + + + + Metric + 2.2.0 + + 2024-06-01 09:00:00 + 20240601090000-ABC123 + 42 + 4 + 25.40 + 18.00 + 2400 + 10 + 26.5 + 20.0 + 5 + + Test Operator + MV Test + Sunny + + Mexico + Baja California + Test Reef + saltwater + 24.12345 + -110.54321 + + + Reef + Photography + + + Alice + + + BCDTestBCD1 + + + + 200 + 60 + 32 + 0 + 0 + 12 + 232 + Open Circuit + 2400 + AL80 + + + + 020026.5 + 1019524 + 56023 + + +