Skip to content

Commit 81c78d7

Browse files
committed
Merge remote-tracking branch 'origin/feature/macdive-native-xml' into feature/macdive-sqlite
2 parents 524ffc0 + af121c3 commit 81c78d7

5 files changed

Lines changed: 151 additions & 17 deletions

File tree

lib/features/universal_import/data/parsers/macdive_xml_parser.dart

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -109,11 +109,14 @@ class MacDiveXmlParser implements ImportParser {
109109
}
110110

111111
if (dive.buddies.isNotEmpty) {
112+
// Per-dive dedup: a dive with duplicate `<buddy>` entries would
113+
// otherwise emit repeated refs. The buddy repo tolerates this by
114+
// UPSERTing, but the redundant round-trips are wasted work.
112115
final buddyRefs = <String>[];
113116
for (final buddy in dive.buddies) {
114117
final trimmed = buddy.trim();
115118
if (trimmed.isEmpty) continue;
116-
buddyRefs.add(trimmed);
119+
if (!buddyRefs.contains(trimmed)) buddyRefs.add(trimmed);
117120
buddiesByName.putIfAbsent(
118121
trimmed,
119122
() => <String, dynamic>{'name': trimmed, 'uddfId': trimmed},
@@ -130,18 +133,34 @@ class MacDiveXmlParser implements ImportParser {
130133
}
131134
}
132135

133-
for (final g in dive.gear) {
134-
final key = _gearKey(g);
135-
if (key.isEmpty) continue;
136-
gearByKey.putIfAbsent(key, () => _mapGear(g));
136+
// Collect gear: record a per-dive `equipmentRefs` list keyed by the
137+
// same composite key used for dedup, so `UddfEntityImporter` can link
138+
// the imported equipment entities back to the dive via its
139+
// `equipmentIdMapping` (uddfId -> newId) lookup. Without this, dedup
140+
// still produces the entity list but the dive wouldn't reference any
141+
// of it.
142+
if (dive.gear.isNotEmpty) {
143+
final equipmentRefs = <String>[];
144+
for (final g in dive.gear) {
145+
final key = _gearKey(g);
146+
if (key.isEmpty) continue;
147+
if (!equipmentRefs.contains(key)) equipmentRefs.add(key);
148+
gearByKey.putIfAbsent(key, () => _mapGear(g, uddfId: key));
149+
}
150+
if (equipmentRefs.isNotEmpty) {
151+
diveMap['equipmentRefs'] = equipmentRefs;
152+
}
137153
}
138154

139155
if (dive.tags.isNotEmpty) {
156+
// Per-dive dedup: `dive_tags` has no UNIQUE(diveId, tagId)
157+
// constraint, so duplicate `<tag>` entries would create duplicate
158+
// junction rows that surface as a tag appearing twice on a dive.
140159
final tagNames = <String>[];
141160
for (final tag in dive.tags) {
142161
final trimmed = tag.trim();
143162
if (trimmed.isEmpty) continue;
144-
tagNames.add(trimmed);
163+
if (!tagNames.contains(trimmed)) tagNames.add(trimmed);
145164
tagsByName.putIfAbsent(
146165
trimmed,
147166
() => <String, dynamic>{'name': trimmed, 'uddfId': trimmed},
@@ -225,8 +244,8 @@ class MacDiveXmlParser implements ImportParser {
225244
if (entryMethod != null) map['entryMethod'] = entryMethod.name;
226245
if (d.computer != null) map['diveComputerModel'] = d.computer;
227246
if (d.serial != null) map['diveComputerSerial'] = d.serial;
228-
// MacDive rating is a 0.0-5.0 float; Submersion stores 0-5 int.
229-
if (d.rating != null) map['rating'] = d.rating!.clamp(0.0, 5.0).round();
247+
final rating = MacDiveValueMapper.rating(d.rating);
248+
if (rating != null) map['rating'] = rating;
230249

231250
// Tanks: each <gas> becomes a tank map using the same key conventions as
232251
// the Subsurface and UDDF parsers so `UddfEntityImporter._buildTanks` can
@@ -290,8 +309,13 @@ class MacDiveXmlParser implements ImportParser {
290309
return map;
291310
}
292311

293-
Map<String, dynamic> _mapGear(MacDiveXmlGearItem g) {
294-
final map = <String, dynamic>{};
312+
Map<String, dynamic> _mapGear(
313+
MacDiveXmlGearItem g, {
314+
required String uddfId,
315+
}) {
316+
// `uddfId` is the gear's composite dedup key, referenced from each dive's
317+
// `equipmentRefs`. UddfEntityImporter resolves it via `equipmentIdMapping`.
318+
final map = <String, dynamic>{'uddfId': uddfId};
295319
if (g.name != null) map['name'] = g.name;
296320
if (g.manufacturer != null) map['brand'] = g.manufacturer;
297321
if (g.type != null) map['type'] = g.type;

lib/features/universal_import/data/services/macdive_xml_reader.dart

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,17 @@ class MacDiveXmlReader {
2424
final doc = XmlDocument.parse(content);
2525
final root = doc.rootElement;
2626

27+
// Reject non-MacDive XML early: MacDive native XML must have <dives> at
28+
// the root. Without this check, a user who forces a source override onto
29+
// a UDDF or other dive XML would get a silent empty logbook because our
30+
// `findElements('dive')` walk doesn't match anything at UDDF's top level.
31+
// The parser's try/catch converts this into a user-visible ImportWarning.
32+
if (root.name.local != 'dives') {
33+
throw const FormatException(
34+
'Not a MacDive native XML document: expected <dives> root element',
35+
);
36+
}
37+
2738
final units = MacDiveUnitSystem.fromXml(_text(root, 'units'));
2839
final converter = MacDiveUnitConverter(units);
2940
final schemaVersion = _text(root, 'schema');

test/features/universal_import/data/parsers/macdive_xml_parser_test.dart

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,17 @@ void main() {
101101
expect((profile[1] as Map)['timestamp'], 60);
102102
});
103103

104+
test('dive links gear via equipmentRefs with matching uddfId', () async {
105+
final payload = await const MacDiveXmlParser().parse(bytes);
106+
final dive = payload.entitiesOf(ImportEntityType.dives).first;
107+
final equipment = payload.entitiesOf(ImportEntityType.equipment);
108+
// Fixture has one gear item: manufacturer=Test, name=BCD1, no serial.
109+
// Composite key "Test|BCD1|" lands on both sides.
110+
expect(dive['equipmentRefs'], ['Test|BCD1|']);
111+
expect(equipment.first['uddfId'], 'Test|BCD1|');
112+
expect(equipment.first['name'], 'BCD1');
113+
});
114+
104115
test(
105116
'dive maps boat/diveOperator/weather into existing UDDF key names',
106117
() async {
@@ -213,9 +224,47 @@ void main() {
213224
final equipment = payload.entitiesOf(ImportEntityType.equipment);
214225
expect(equipment.length, 1, reason: 'only the populated item survives');
215226
expect(equipment.first['name'], 'BCD1');
227+
// equipmentRefs reflects only the surviving item; skipped empty
228+
// `<item>` elements do not leave phantom refs on the dive either.
229+
final dive = payload.entitiesOf(ImportEntityType.dives).first;
230+
expect(dive['equipmentRefs'], ['Test|BCD1|']);
216231
},
217232
);
218233

234+
test('duplicate <buddy> entries on one dive dedupe in buddyRefs', () async {
235+
const xml = '''<?xml version="1.0"?>
236+
<dives><units>Metric</units><schema>2.2.0</schema>
237+
<dive>
238+
<date>2024-01-01 09:00:00</date><identifier>d1</identifier>
239+
<maxDepth>20</maxDepth><duration>1800</duration>
240+
<buddies><buddy>Alice</buddy><buddy>Alice</buddy><buddy>Bob</buddy></buddies>
241+
<samples/>
242+
</dive>
243+
</dives>''';
244+
final bytes = Uint8List.fromList(utf8.encode(xml));
245+
final payload = await const MacDiveXmlParser().parse(bytes);
246+
final dive = payload.entitiesOf(ImportEntityType.dives).first;
247+
expect(dive['buddyRefs'], ['Alice', 'Bob']);
248+
});
249+
250+
test('duplicate <tag> entries on one dive dedupe in tagRefs', () async {
251+
// dive_tags has no UNIQUE(diveId, tagId) constraint, so duplicates
252+
// would surface as the same tag shown twice on a dive.
253+
const xml = '''<?xml version="1.0"?>
254+
<dives><units>Metric</units><schema>2.2.0</schema>
255+
<dive>
256+
<date>2024-01-01 09:00:00</date><identifier>d1</identifier>
257+
<maxDepth>20</maxDepth><duration>1800</duration>
258+
<tags><tag>Reef</tag><tag>Reef</tag><tag>Photography</tag></tags>
259+
<samples/>
260+
</dive>
261+
</dives>''';
262+
final bytes = Uint8List.fromList(utf8.encode(xml));
263+
final payload = await const MacDiveXmlParser().parse(bytes);
264+
final dive = payload.entitiesOf(ImportEntityType.dives).first;
265+
expect(dive['tagRefs'], ['Reef', 'Photography']);
266+
});
267+
219268
test('multiple dives with overlapping buddies dedup', () async {
220269
const xml = '''<?xml version="1.0"?>
221270
<dives><units>Metric</units><schema>2.2.0</schema>

test/features/universal_import/data/parsers/macdive_xml_real_sample_test.dart

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,16 @@
1+
/// MacDive XML real-sample regression suite.
2+
///
3+
/// These tests exercise a real MacDive native XML export that is not checked
4+
/// into the repository. To run them locally, point the [MACDIVE_XML_SAMPLE]
5+
/// compile-time environment variable at your local sample file:
6+
///
7+
/// flutter test \
8+
/// --dart-define=MACDIVE_XML_SAMPLE=/absolute/path/to/sample.xml \
9+
/// --run-skipped --tags=real-data \
10+
/// test/features/universal_import/data/parsers/macdive_xml_real_sample_test.dart
11+
///
12+
/// Without the env var (or when the file at that path does not exist), every
13+
/// test in this suite is cleanly skipped so CI and fresh clones stay green.
114
@Tags(['real-data'])
215
library;
316

@@ -11,23 +24,41 @@ import 'package:submersion/features/universal_import/data/models/import_enums.da
1124
import 'package:submersion/features/universal_import/data/models/import_warning.dart';
1225
import 'package:submersion/features/universal_import/data/parsers/macdive_xml_parser.dart';
1326

14-
const _realSamplePath =
15-
'/Users/ericgriffin/Documents/submersion development/submersion data/Macdive/Apr 4 no iPad Mini sync.xml';
27+
/// Compile-time env var that points at a local MacDive native XML sample.
28+
///
29+
/// Injected via `flutter test --dart-define=MACDIVE_XML_SAMPLE=...`.
30+
const _realSamplePathEnvVar = String.fromEnvironment('MACDIVE_XML_SAMPLE');
31+
32+
String? _realSamplePath() {
33+
if (_realSamplePathEnvVar.isEmpty) return null;
34+
return _realSamplePathEnvVar;
35+
}
1636

1737
void main() {
1838
group('MacDive XML real-sample regression', () {
1939
late Uint8List bytes;
40+
var hasFixture = false;
2041

2142
setUpAll(() async {
22-
final file = File(_realSamplePath);
23-
if (!file.existsSync()) {
24-
markTestSkipped('Real sample not available in this environment');
25-
return;
26-
}
43+
final path = _realSamplePath();
44+
if (path == null) return;
45+
final file = File(path);
46+
if (!file.existsSync()) return;
2747
bytes = Uint8List.fromList(utf8.encode(await file.readAsString()));
48+
hasFixture = true;
2849
});
2950

51+
bool skipIfNoFixture() {
52+
if (hasFixture) return false;
53+
markTestSkipped(
54+
'Real sample not available. Set MACDIVE_XML_SAMPLE via '
55+
'--dart-define and pass --run-skipped --tags=real-data to run.',
56+
);
57+
return true;
58+
}
59+
3060
test('parses 540 dives without errors', () async {
61+
if (skipIfNoFixture()) return;
3162
final payload = await const MacDiveXmlParser().parse(bytes);
3263
expect(payload.entitiesOf(ImportEntityType.dives).length, 540);
3364
// No errors (warnings are ok).
@@ -40,6 +71,7 @@ void main() {
4071
});
4172

4273
test('every dive has a sourceUuid from <identifier>', () async {
74+
if (skipIfNoFixture()) return;
4375
final payload = await const MacDiveXmlParser().parse(bytes);
4476
final dives = payload.entitiesOf(ImportEntityType.dives);
4577
expect(
@@ -50,6 +82,7 @@ void main() {
5082
});
5183

5284
test('tags imported (MacDive XML has tags, unlike MacDive UDDF)', () async {
85+
if (skipIfNoFixture()) return;
5386
final payload = await const MacDiveXmlParser().parse(bytes);
5487
final tags = payload.entitiesOf(ImportEntityType.tags);
5588
expect(
@@ -65,6 +98,7 @@ void main() {
6598
});
6699

67100
test('sites deduped by name', () async {
101+
if (skipIfNoFixture()) return;
68102
final payload = await const MacDiveXmlParser().parse(bytes);
69103
final sites = payload.entitiesOf(ImportEntityType.sites);
70104
expect(sites, isNotEmpty);
@@ -77,6 +111,7 @@ void main() {
77111
});
78112

79113
test('at least one dive has tagRefs populated', () async {
114+
if (skipIfNoFixture()) return;
80115
final payload = await const MacDiveXmlParser().parse(bytes);
81116
final dives = payload.entitiesOf(ImportEntityType.dives);
82117
final withTags = dives.where(
@@ -86,6 +121,7 @@ void main() {
86121
});
87122

88123
test('at least one dive has tanks + profile populated', () async {
124+
if (skipIfNoFixture()) return;
89125
final payload = await const MacDiveXmlParser().parse(bytes);
90126
final dives = payload.entitiesOf(ImportEntityType.dives);
91127
final withTanks = dives.where(
@@ -99,6 +135,7 @@ void main() {
99135
});
100136

101137
test('imperial sample: max depth values are plausible in meters', () async {
138+
if (skipIfNoFixture()) return;
102139
final payload = await const MacDiveXmlParser().parse(bytes);
103140
final dives = payload.entitiesOf(ImportEntityType.dives);
104141
// After conversion, max depths should be mostly 5-80 meters (reasonable

test/features/universal_import/data/services/macdive_xml_reader_test.dart

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,19 @@ void main() {
172172
expect(dive.boat, isNull);
173173
});
174174

175+
test('throws when root element is not <dives>', () {
176+
// Guards against a source-override misuse where a user forces "MacDive
177+
// XML" on a UDDF or other file: without this check, the reader would
178+
// silently return an empty logbook.
179+
const uddf =
180+
'<?xml version="1.0"?>'
181+
'<uddf version="3.2.0"><profiledata/></uddf>';
182+
expect(
183+
() => MacDiveXmlReader.parse(uddf),
184+
throwsA(isA<FormatException>()),
185+
);
186+
});
187+
175188
test('unknown units system passes through numerics unchanged', () {
176189
const xml = '''<?xml version="1.0"?>
177190
<dives>

0 commit comments

Comments
 (0)