Skip to content

Commit c58c3e3

Browse files
committed
fix(import): address PR #254 review round 2 — MacDive XML polish
- MacDiveXmlReader now rejects non-`<dives>` root elements with a clear FormatException, instead of silently producing an empty logbook when a user forces the MacDive XML source onto a UDDF or other file. The parser's try/catch converts the exception into a user-visible ImportWarning. - MacDive XML real-sample test replaces the hardcoded `/Users/ericgriffin/...` path with a `MACDIVE_XML_SAMPLE` compile- time env var and skip-safe fallback, mirroring the UDDF sample test. CI and fresh clones now stay green. - MacDive XML parser links imported gear back onto each dive via `equipmentRefs` keyed by the composite `(manufacturer|name|serial)` uddfId, so dives actually reference the equipment entities created from their `<gear>` children. Added tests covering both the normal case and the empty-item path (no phantom refs).
1 parent f8c4616 commit c58c3e3

5 files changed

Lines changed: 107 additions & 13 deletions

File tree

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

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -130,10 +130,23 @@ class MacDiveXmlParser implements ImportParser {
130130
}
131131
}
132132

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

139152
if (dive.tags.isNotEmpty) {
@@ -290,8 +303,13 @@ class MacDiveXmlParser implements ImportParser {
290303
return map;
291304
}
292305

293-
Map<String, dynamic> _mapGear(MacDiveXmlGearItem g) {
294-
final map = <String, dynamic>{};
306+
Map<String, dynamic> _mapGear(
307+
MacDiveXmlGearItem g, {
308+
required String uddfId,
309+
}) {
310+
// `uddfId` is the gear's composite dedup key, referenced from each dive's
311+
// `equipmentRefs`. UddfEntityImporter resolves it via `equipmentIdMapping`.
312+
final map = <String, dynamic>{'uddfId': uddfId};
295313
if (g.name != null) map['name'] = g.name;
296314
if (g.manufacturer != null) map['brand'] = g.manufacturer;
297315
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: 15 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,6 +224,10 @@ 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

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)