diff --git a/lib/features/dive_computer/data/services/parsed_tank_resolver.dart b/lib/features/dive_computer/data/services/parsed_tank_resolver.dart index 2240309027..90c0b4bf87 100644 --- a/lib/features/dive_computer/data/services/parsed_tank_resolver.dart +++ b/lib/features/dive_computer/data/services/parsed_tank_resolver.dart @@ -127,7 +127,9 @@ _ResolvedCylinders _resolveCylinders( return const _ResolvedCylinders([], {}); } final tanks = []; - final roles = _inferSensorlessRoles(gasMixes, parsed.diveMode); + final roles = _inferSensorlessRoles(gasMixes, [ + for (var i = 0; i < gasMixes.length; i++) i, + ], parsed.diveMode); for (var i = 0; i < gasMixes.length; i++) { final g = gasMixes[i]; gasIndexToTankIndex[i] = g.index; @@ -195,18 +197,32 @@ _ResolvedCylinders _resolveCylinders( // Pressureless cylinder for any gas not on a transmitter; indices sit above // every real tank/sample index so they never capture per-sample pressure. + // These are sensorless cylinders, so they get the same roles a tankless dive + // gives its gases: the gas's own usage tag first, then on CCR the bailout + // ranking (issue #2318). The breathed diluent goes first, so a dive whose + // diluent has no transmitter still gets the one used, not merely the first + // programmed one, from [resolveDiluentGas]. + final breathedDiluent = _breathedDiluentIndex(parsed.samples, gasMixes); + final unclaimed = [ + if (breathedDiluent != null && !consumed.contains(breathedDiluent)) + breathedDiluent, + for (var i = 0; i < gasMixes.length; i++) + if (!consumed.contains(i) && i != breathedDiluent) i, + ]; + final unclaimedRoles = _inferSensorlessRoles( + gasMixes, + unclaimed, + parsed.diveMode, + ); var nextIndex = _firstFreeIndex(parsed); - for (var i = 0; i < gasMixes.length; i++) { - if (consumed.contains(i)) { - continue; - } + for (final i in unclaimed) { gasIndexToTankIndex[i] = nextIndex; result.add( DownloadedTank( index: nextIndex++, o2Percent: gasMixes[i].o2Percent, hePercent: gasMixes[i].hePercent, - role: _inferRole(null, gasMixes[i].o2Percent, gasMixes[i].hePercent), + role: unclaimedRoles[i], ), ); } @@ -236,7 +252,9 @@ String _inferRole(int? usage, double o2Percent, double hePercent) { return TankRole.backGas.name; } -/// Role for each of [gasMixes], in order, on a sensorless (tankless) dive. +/// Role for each gas in [indices] (positions into [gasMixes]) that has no +/// transmitter: every gas on a tankless dive, or the gases left unclaimed by +/// the transmitters on one that has tank records. Keyed by gas index. /// /// A gas whose usage the computer reported directly on the gas mix itself /// (`dc_gasmix_t.usage`, independent of any tank/transmitter record) is @@ -249,7 +267,8 @@ String _inferRole(int? usage, double o2Percent, double hePercent) { /// For a dive recognized as CCR, the gases left with no reported usage are /// the open-circuit bailout candidates and are ranked against each other /// instead of scored in isolation: -/// 1. Bottom gas: the lowest O2 percentage among them becomes +/// 1. Bottom gas: the lowest O2 percentage among every gas of the dive with no +/// reported usage (one on a transmitter included) becomes /// [TankRole.bailout]; a tie is broken by the higher helium percentage, /// and a further tie gives Bailout to every still-tied gas. A gas that /// only loses the helium tie-break gets no automatic Bailout role and @@ -260,14 +279,16 @@ String _inferRole(int? usage, double o2Percent, double hePercent) { /// /// On any other recognized dive mode, a gas with no reported usage keeps /// [_inferRole]'s original single-threshold heuristic, unaffected by this. -List _inferSensorlessRoles( +Map _inferSensorlessRoles( List gasMixes, + List indices, String? diveMode, ) { - final roles = List.filled(gasMixes.length, null); + final roles = {}; final unranked = []; - for (var i = 0; i < gasMixes.length; i++) { + for (final i in indices) { final g = gasMixes[i]; + // Keep in step with _hasReportedUsage. switch (g.usage) { case 1: // DC_USAGE_OXYGEN roles[i] = TankRole.oxygenSupply.name; @@ -285,35 +306,49 @@ List _inferSensorlessRoles( final g = gasMixes[i]; roles[i] = _inferRole(null, g.o2Percent, g.hePercent); } - return [for (final role in roles) role!]; + return roles; } if (unranked.isNotEmpty) { - final lowestO2 = unranked + // Ranked against every gas of the dive with no reported usage, including + // one a transmitter claimed: a bailout cylinder on its own transmitter is + // still the bottom gas, and leaving it out would promote the leanest + // remaining gas (say a 50% deco gas) to Bailout (review on #2318). + final candidates = [ + for (var i = 0; i < gasMixes.length; i++) + if (!_hasReportedUsage(gasMixes[i])) i, + ]; + final lowestO2 = candidates .map((i) => gasMixes[i].o2Percent) .reduce((a, b) => a < b ? a : b); - final atLowestO2 = unranked.where( + final atLowestO2 = candidates.where( (i) => _nearlyEqualPercent(gasMixes[i].o2Percent, lowestO2), ); final highestHeAtLowestO2 = atLowestO2 .map((i) => gasMixes[i].hePercent) .reduce((a, b) => a > b ? a : b); for (final i in atLowestO2) { - if (_nearlyEqualPercent(gasMixes[i].hePercent, highestHeAtLowestO2)) { + if (unranked.contains(i) && + _nearlyEqualPercent(gasMixes[i].hePercent, highestHeAtLowestO2)) { roles[i] = TankRole.bailout.name; } } for (final i in unranked) { - if (roles[i] != null) continue; + if (roles.containsKey(i)) continue; roles[i] = gasMixes[i].o2Percent >= 41.0 ? TankRole.deco.name : TankRole.stage.name; } } - return [for (final role in roles) role!]; + return roles; } +/// Whether the computer tagged [gas] with a usage that fixes its role +/// (oxygen, diluent or sidemount), taking it out of the bailout ranking. +bool _hasReportedUsage(pigeon.GasMix gas) => + gas.usage == 1 || gas.usage == 2 || gas.usage == 3; + /// Whether two gas percentages are the same value within floating-point /// noise. Each of the four platform converters independently computes /// `fraction * 100.0` from the native `dc_gasmix_t`, so two mixes the diver @@ -324,7 +359,7 @@ bool _nearlyEqualPercent(double a, double b) => (a - b).abs() < 1e-6; /// The gas-mix index (position in [gasMixes]) for [tank], preferring the gas /// actually breathed on it. Returns null when there are no gas mixes, or for -/// a CCR oxygen supply tank that matched neither of the first two rules. +/// a CCR oxygen supply tank the computer gave no gas mix of its own. int? _resolveTankGasIndex( pigeon.TankInfo tank, List samples, @@ -333,49 +368,90 @@ int? _resolveTankGasIndex( if (gasMixes.isEmpty) { return null; } + final linked = tank.gasMixIndex >= 0 && tank.gasMixIndex < gasMixes.length + ? tank.gasMixIndex + : null; + // A CCR supply cylinder is never breathed the way rule 1 below measures it: + // on the loop the active gas is always the diluent, so every transmitter + // reporting during the dive would be credited with it, and the oxygen + // cylinder came out as the diluent (issue #2318). Its usage tag decides. + switch (tank.usage) { + case 1: // DC_USAGE_OXYGEN + // The computer's own link, else a gas mix it tagged as oxygen (some + // computers report one without index-linking the tank, caught in review + // on #1972). Otherwise left gasless so the caller applies the 100% O2 + // default (#726): Shearwater never links it and never tags a gas oxygen. + if (linked != null) return linked; + final oxygenGasIndex = gasMixes.indexWhere((g) => g.usage == 1); + return oxygenGasIndex >= 0 ? oxygenGasIndex : null; + case 2: // DC_USAGE_DILUENT + // The computer's own link, else the diluent actually breathed. Only + // when the computer tagged no gas as a diluent do the generic rules + // below apply. + final diluent = linked ?? _breathedDiluentIndex(samples, gasMixes); + if (diluent != null) return diluent; + } // 1. The gas breathed on this transmitter (per-sample DC_SAMPLE_GASMIX). final breathed = _dominantGasIndex(tank.index, samples, gasMixes.length); if (breathed != null) { return breathed; } // 2. The computer's own tank->gas link, when it set one (non-Shearwater). - if (tank.gasMixIndex >= 0 && tank.gasMixIndex < gasMixes.length) { - return tank.gasMixIndex; - } - // A CCR oxygen supply cylinder is never "breathed" in the OC sense rule 1 - // tracks, and libdivecomputer's Shearwater parser never links it to a gas - // mix (rule 2), so falling through to rule 3 below would mislabel pure O2 - // as whatever gas happens to be first (#726). Some other computers DO - // report an explicit usage-tagged gas mix for it without index-linking the - // tank to it, though -- match that by its usage tag first, so it's - // consumed here rather than synthesized a second time as an unclaimed gas - // mix (caught in review on #1972). Only when no such entry exists is it - // left gasless, so the caller can apply the correct 100% O2 default. - if (tank.usage == 1 /* DC_USAGE_OXYGEN */ ) { - final oxygenGasIndex = gasMixes.indexWhere((g) => g.usage == 1); - return oxygenGasIndex >= 0 ? oxygenGasIndex : null; + if (linked != null) { + return linked; } // 3. Last resort: the dive's primary (first) mix -- never a hardcoded air // default, which would mislabel an EAN dive. return 0; } +/// The diluent-tagged gas mix (`usage == 2`) breathed in the most samples, or +/// the first diluent-tagged one when none was breathed. Null when the computer +/// tagged no gas mix as a diluent. +/// +/// Shearwater reports every enabled diluent, not only the one used, and its +/// gas list puts the open-circuit gases first, so neither "the first gas" nor +/// "the first diluent" is safe on its own. +int? _breathedDiluentIndex( + List samples, + List gasMixes, +) { + bool isDiluent(int? i) => + i != null && i >= 0 && i < gasMixes.length && gasMixes[i].usage == 2; + final breathed = _mostFrequent([ + for (final s in samples) + if (isDiluent(s.gasMixIndex)) s.gasMixIndex!, + ]); + if (breathed != null) { + return breathed; + } + final first = gasMixes.indexWhere((g) => g.usage == 2); + return first >= 0 ? first : null; +} + /// The most frequent gas-mix index among the pressure samples of [tankIndex], /// or null when none of that tank's samples carry a gas mix. +/// +/// Deliberately keyed on the sample's own `tankIndex` rather than on every +/// transmitter the sample carries: a transmitter reports all dive long, so +/// crediting every reporting tank would hand each one the dive's main gas and +/// override a computer's own tank->gas link (review on #2318). int? _dominantGasIndex( int tankIndex, List samples, int gasCount, -) { +) => _mostFrequent([ + for (final s in samples) + if (s.gasMixIndex case final gasIndex? + when s.tankIndex == tankIndex && gasIndex >= 0 && gasIndex < gasCount) + gasIndex, +]); + +/// The most frequent value in [values], or null when it is empty. +int? _mostFrequent(List values) { final counts = {}; - for (final s in samples) { - final gasIndex = s.gasMixIndex; - if (s.tankIndex == tankIndex && - gasIndex != null && - gasIndex >= 0 && - gasIndex < gasCount) { - counts[gasIndex] = (counts[gasIndex] ?? 0) + 1; - } + for (final value in values) { + counts[value] = (counts[value] ?? 0) + 1; } if (counts.isEmpty) { return null; @@ -406,20 +482,44 @@ int _firstFreeIndex(pigeon.ParsedDive parsed) { if (tankIndex != null && tankIndex > maxIndex) { maxIndex = tankIndex; } + final perTank = s.tankPressuresBar; + if (perTank != null && perTank.length - 1 > maxIndex) { + maxIndex = perTank.length - 1; + } } return maxIndex + 1; } +/// Every transmitter reading [sample] carries, keyed by tank index. +/// +/// libdivecomputer reports one pressure per air-integrated transmitter, so a +/// sample can carry several, and `pressureBar`/`tankIndex` hold only the last +/// of them (on a Shearwater CCR, always the oxygen transmitter). Reading the +/// pair alone left every other transmitter without a reading at surfacing, so +/// the diluent kept its post-surfacing bleed-down (issue #2318). +/// `tankPressuresBar` is the complete record; the pair remains the fallback +/// for sources that never report more than one tank per sample, the same rule +/// `groupPressuresByTank` applies to the stored pressure series. +Map _sampleTankReadings(pigeon.ProfileSample sample) { + final perTank = sample.tankPressuresBar; + if (perTank != null) { + return { + for (var index = 0; index < perTank.length; index++) + index: ?perTank[index], + }; + } + // A reading without a tank index belongs to tank 0, as in the stored series. + final pressure = sample.pressureBar; + return pressure != null ? {sample.tankIndex ?? 0: pressure} : const {}; +} + /// Reduce libdivecomputer samples to the depth-plus-pressure points the -/// surfacing rule reads. A sample carries at most one transmitter reading, so -/// each point holds either one entry or none. +/// surfacing rule reads, with every transmitter the sample carries. List _surfacingPoints(List s) => [ for (final sample in s) SurfacingProfilePoint( timeSeconds: sample.timeSeconds, depthMeters: sample.depthMeters, - tankPressuresBar: sample.pressureBar != null && sample.tankIndex != null - ? {sample.tankIndex!: sample.pressureBar!} - : const {}, + tankPressuresBar: _sampleTankReadings(sample), ), ]; diff --git a/test/features/dive_computer/data/services/parsed_tank_resolver_ccr_transmitters_test.dart b/test/features/dive_computer/data/services/parsed_tank_resolver_ccr_transmitters_test.dart new file mode 100644 index 0000000000..82d293623d --- /dev/null +++ b/test/features/dive_computer/data/services/parsed_tank_resolver_ccr_transmitters_test.dart @@ -0,0 +1,418 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:libdivecomputer_plugin/libdivecomputer_plugin.dart' as pigeon; +import 'package:submersion/features/dive_computer/data/services/parsed_tank_resolver.dart'; +import 'package:submersion/features/dive_computer/domain/entities/downloaded_dive.dart'; + +/// Issue #2318: a Shearwater CCR dive with a diluent and an oxygen transmitter. +/// +/// Both transmitters report in the same sample, but a sample's +/// `pressureBar`/`tankIndex` pair only holds the LAST reading, which on the +/// Petrel 3 is always the oxygen transmitter. Every reading is in +/// `tankPressuresBar`. These tests model the real download: gas list in the +/// parser's order (OC slots first, so OC1 99/0 is gas 0), the whole dive on the +/// loop breathing the diluent, and both transmitters in almost every sample. +void main() { + // DC_GASMIX_UNKNOWN: Shearwater never links a tank to a gas mix. + const unknownGasMixIndex = 4294967295; + const oxygenUsage = 1; // DC_USAGE_OXYGEN + const diluentUsage = 2; // DC_USAGE_DILUENT + + // Gas list as the Shearwater parser reports it for this setup. + const oc1Deco = 0; // OC1 99/0 + const oc5Bailout = 1; // OC5 15/55 + const dil2 = 2; // DIL2 15/55, the breathed diluent + final gasMixes = [ + pigeon.GasMix(index: oc1Deco, o2Percent: 99.0, hePercent: 0.0), + pigeon.GasMix(index: oc5Bailout, o2Percent: 15.0, hePercent: 55.0), + pigeon.GasMix( + index: dil2, + o2Percent: 15.0, + hePercent: 55.0, + usage: diluentUsage, + ), + ]; + + // Tank 0 is the transmitter named "D1", tank 1 the one named "O2". The + // reported end pressures are the last tail samples, as libdivecomputer + // reports them. + final tanks = [ + pigeon.TankInfo( + index: 0, + gasMixIndex: unknownGasMixIndex, + startPressureBar: 128.9, + endPressureBar: 13.4, + usage: diluentUsage, + ), + pigeon.TankInfo( + index: 1, + gasMixIndex: unknownGasMixIndex, + startPressureBar: 107.7, + endPressureBar: 29.5, + usage: oxygenUsage, + ), + ]; + + /// A loop sample carrying both transmitters, oxygen reported last. + pigeon.ProfileSample bothReport( + int t, + double depth, + double diluentBar, + double oxygenBar, + ) => pigeon.ProfileSample( + timeSeconds: t, + depthMeters: depth, + pressureBar: oxygenBar, + tankIndex: 1, + tankPressuresBar: [diluentBar, oxygenBar], + gasMixIndex: dil2, + ); + + pigeon.ParsedDive ccrDive(List samples) => + pigeon.ParsedDive( + fingerprint: 'test', + dateTimeYear: 2026, + dateTimeMonth: 9, + dateTimeDay: 12, + dateTimeHour: 8, + dateTimeMinute: 56, + dateTimeSecond: 33, + maxDepthMeters: 61.9, + avgDepthMeters: 30.0, + durationSeconds: 3900, + diveMode: 'ccr', + samples: samples, + tanks: tanks, + gasMixes: gasMixes, + events: const [], + ); + + /// Both transmitters in every sample, the oxygen transmitter always last, so + /// the diluent transmitter never owns a sample's tankIndex. On the diver's + /// 21.09.2026 dive this imported the diluent cylinder as 99/0. + pigeon.ParsedDive oxygenAlwaysLast() => ccrDive([ + bothReport(0, 0.0, 128.9, 107.7), + bothReport(600, 40.0, 120.0, 90.0), + bothReport(1800, 61.9, 100.0, 60.0), + bothReport(3600, 1.2, 77.6, 35.0), // last sample below the surface + bothReport(3700, 0.0, 40.0, 31.0), + bothReport(3800, 0.0, 13.4, 29.5), // post-surfacing tail + ]); + + DownloadedTank tankAt(List tanks, int index) => + tanks.firstWhere((t) => t.index == index); + + group('CCR dive with a diluent and an oxygen transmitter (#2318)', () { + test('the oxygen cylinder is pure O2, not the breathed diluent', () { + final oxygen = tankAt(resolveParsedTanks(oxygenAlwaysLast()), 1); + expect(oxygen.role, 'oxygenSupply'); + expect(oxygen.o2Percent, 100.0); + expect(oxygen.hePercent, 0.0); + }); + + test('the diluent cylinder carries the breathed diluent even when it ' + 'never owns a sample tankIndex', () { + final diluent = tankAt(resolveParsedTanks(oxygenAlwaysLast()), 0); + expect(diluent.role, 'diluent'); + expect(diluent.o2Percent, 15.0); + expect(diluent.hePercent, 55.0); + }); + + test('the dive-level diluent follows the diluent cylinder', () { + final diluent = resolveDiluentGas(resolveParsedTanks(oxygenAlwaysLast())); + expect(diluent, isNotNull); + expect(diluent!.o2, 15.0); + expect(diluent.he, 55.0); + }); + + test('gases without a transmitter keep their CCR roles: the open circuit ' + 'bottom gas is bailout, 99% is deco', () { + final resolved = resolveParsedTanks(oxygenAlwaysLast()); + expect(resolved, hasLength(4)); + final withoutTransmitter = resolved.where((t) => t.index > 1).toList(); + expect( + withoutTransmitter.firstWhere((t) => t.o2Percent == 99.0).role, + 'deco', + ); + expect( + withoutTransmitter.firstWhere((t) => t.o2Percent == 15.0).role, + 'bailout', + ); + }); + + test('both transmitters are trimmed to their reading at surfacing', () { + final resolved = resolveParsedTanks(oxygenAlwaysLast()); + expect(tankAt(resolved, 0).endPressure, 77.6); + expect(tankAt(resolved, 1).endPressure, 35.0); + }); + + test('without the surfacing trim both keep the reported end pressure', () { + final resolved = resolveParsedTanks( + oxygenAlwaysLast(), + trimAtSurfacing: false, + ); + expect(tankAt(resolved, 0).endPressure, 13.4); + expect(tankAt(resolved, 1).endPressure, 29.5); + }); + + test('the most-breathed diluent wins when several are programmed', () { + // DIL1 21/0 is programmed and enabled but never breathed. + final parsed = pigeon.ParsedDive( + fingerprint: 'test', + dateTimeYear: 2026, + dateTimeMonth: 9, + dateTimeDay: 12, + dateTimeHour: 8, + dateTimeMinute: 56, + dateTimeSecond: 33, + maxDepthMeters: 61.9, + avgDepthMeters: 30.0, + durationSeconds: 3900, + diveMode: 'ccr', + samples: [ + bothReport(0, 0.0, 128.9, 107.7), + bothReport(600, 40.0, 120.0, 90.0), + ], + tanks: tanks, + gasMixes: [ + ...gasMixes, + pigeon.GasMix( + index: 3, + o2Percent: 21.0, + hePercent: 0.0, + usage: diluentUsage, + ), + ], + events: const [], + ); + final diluent = tankAt(resolveParsedTanks(parsed), 0); + expect(diluent.o2Percent, 15.0); + expect(diluent.hePercent, 55.0); + }); + + test('a diluent cylinder falls back to the first diluent when no diluent ' + 'was breathed', () { + final parsed = pigeon.ParsedDive( + fingerprint: 'test', + dateTimeYear: 2026, + dateTimeMonth: 9, + dateTimeDay: 12, + dateTimeHour: 8, + dateTimeMinute: 56, + dateTimeSecond: 33, + maxDepthMeters: 61.9, + avgDepthMeters: 30.0, + durationSeconds: 3900, + diveMode: 'ccr', + samples: const [], + tanks: tanks, + gasMixes: gasMixes, + events: const [], + ); + final diluent = tankAt(resolveParsedTanks(parsed), 0); + expect(diluent.o2Percent, 15.0); + expect(diluent.hePercent, 55.0); + }); + + test('no gas switch is derived for a dive that stays on the loop', () { + expect(resolveGasSwitches(oxygenAlwaysLast()), isEmpty); + }); + }); + + group('CCR dive with only an oxygen transmitter', () { + test('the dive-level diluent is the breathed one, not the first ' + 'programmed one', () { + // DIL1 21/0 is enabled but never breathed; DIL2 15/55 is on the loop. + // Neither has a transmitter, so both become pressureless cylinders. + final parsed = pigeon.ParsedDive( + fingerprint: 'test', + dateTimeYear: 2026, + dateTimeMonth: 9, + dateTimeDay: 12, + dateTimeHour: 8, + dateTimeMinute: 56, + dateTimeSecond: 33, + maxDepthMeters: 61.9, + avgDepthMeters: 30.0, + durationSeconds: 3900, + diveMode: 'ccr', + samples: [ + for (final t in [0, 600, 1200]) + pigeon.ProfileSample( + timeSeconds: t, + depthMeters: 30.0, + pressureBar: 100.0, + tankIndex: 0, + tankPressuresBar: const [100.0], + gasMixIndex: 2, + ), + ], + tanks: [ + pigeon.TankInfo( + index: 0, + gasMixIndex: unknownGasMixIndex, + startPressureBar: 107.7, + usage: oxygenUsage, + ), + ], + gasMixes: [ + pigeon.GasMix(index: 0, o2Percent: 99.0, hePercent: 0.0), + pigeon.GasMix( + index: 1, + o2Percent: 21.0, + hePercent: 0.0, + usage: diluentUsage, + ), + pigeon.GasMix( + index: 2, + o2Percent: 15.0, + hePercent: 55.0, + usage: diluentUsage, + ), + ], + events: const [], + ); + final resolved = resolveParsedTanks(parsed); + expect(tankAt(resolved, 0).o2Percent, 100.0); + final diluent = resolveDiluentGas(resolved); + expect(diluent, isNotNull); + expect(diluent!.o2, 15.0); + expect(diluent.he, 55.0); + }); + }); + + group('CCR dive with a bailout cylinder on its own transmitter', () { + test('a leaner deco gas without a transmitter stays deco, not bailout', () { + final parsed = pigeon.ParsedDive( + fingerprint: 'test', + dateTimeYear: 2026, + dateTimeMonth: 9, + dateTimeDay: 12, + dateTimeHour: 8, + dateTimeMinute: 56, + dateTimeSecond: 33, + maxDepthMeters: 61.9, + avgDepthMeters: 30.0, + durationSeconds: 3900, + diveMode: 'ccr', + samples: [ + for (final t in [0, 600, 1200]) + pigeon.ProfileSample( + timeSeconds: t, + depthMeters: 30.0, + pressureBar: 100.0, + tankIndex: 1, + tankPressuresBar: const [120.0, 100.0, 200.0], + gasMixIndex: 2, + ), + ], + tanks: [ + ...tanks, + // An untagged third transmitter on the 15/55 bailout cylinder, + // linked by the computer to its gas. + pigeon.TankInfo(index: 2, gasMixIndex: 1, startPressureBar: 200.0), + ], + gasMixes: [ + pigeon.GasMix(index: 0, o2Percent: 50.0, hePercent: 0.0), + pigeon.GasMix(index: 1, o2Percent: 15.0, hePercent: 55.0), + pigeon.GasMix( + index: 2, + o2Percent: 15.0, + hePercent: 55.0, + usage: diluentUsage, + ), + ], + events: const [], + ); + final resolved = resolveParsedTanks(parsed); + expect(resolved.firstWhere((t) => t.o2Percent == 50.0).role, 'deco'); + }); + }); + + group('legacy single-reading samples', () { + test('a reading without a tank index is trimmed as tank 0, like the ' + 'stored pressure series', () { + pigeon.ProfileSample legacy(int t, double depth, double bar) => + pigeon.ProfileSample( + timeSeconds: t, + depthMeters: depth, + pressureBar: bar, + gasMixIndex: 0, + ); + final parsed = pigeon.ParsedDive( + fingerprint: 'test', + dateTimeYear: 2026, + dateTimeMonth: 9, + dateTimeDay: 12, + dateTimeHour: 8, + dateTimeMinute: 56, + dateTimeSecond: 33, + maxDepthMeters: 30.0, + avgDepthMeters: 20.0, + durationSeconds: 3000, + samples: [ + legacy(0, 0.0, 200.0), + legacy(1200, 30.0, 120.0), + legacy(2400, 1.2, 60.0), // last sample below the surface + legacy(2500, 0.0, 20.0), // post-surfacing tail + ], + tanks: [ + pigeon.TankInfo( + index: 0, + gasMixIndex: 0, + startPressureBar: 200.0, + endPressureBar: 20.0, + ), + ], + gasMixes: [pigeon.GasMix(index: 0, o2Percent: 100.0, hePercent: 0.0)], + events: const [], + ); + expect(resolveParsedTanks(parsed).single.endPressure, 60.0); + }); + }); + + group('open circuit with two transmitters in one sample', () { + test("a computer's own tank->gas link survives a transmitter that " + 'reports all dive long', () { + // Tank 0 is linked to the 50% deco gas and reports in every sample, but + // tank 1 is always the last reading, so tank 0 never owns a sample's + // tankIndex. Crediting every reporting transmitter with the breathed + // gas would override that link with the 32% bottom gas. + final parsed = pigeon.ParsedDive( + fingerprint: 'test', + dateTimeYear: 2026, + dateTimeMonth: 9, + dateTimeDay: 12, + dateTimeHour: 8, + dateTimeMinute: 56, + dateTimeSecond: 33, + maxDepthMeters: 30.0, + avgDepthMeters: 20.0, + durationSeconds: 3000, + diveMode: 'oc', + samples: [ + for (final t in [0, 600, 1200, 1800]) + pigeon.ProfileSample( + timeSeconds: t, + depthMeters: 20.0, + pressureBar: 180.0, + tankIndex: 1, + tankPressuresBar: const [190.0, 180.0], + gasMixIndex: 1, + ), + ], + tanks: [ + pigeon.TankInfo(index: 0, gasMixIndex: 0), + pigeon.TankInfo(index: 1, gasMixIndex: unknownGasMixIndex), + ], + gasMixes: [ + pigeon.GasMix(index: 0, o2Percent: 50.0, hePercent: 0.0), + pigeon.GasMix(index: 1, o2Percent: 32.0, hePercent: 0.0), + ], + events: const [], + ); + final resolved = resolveParsedTanks(parsed); + expect(tankAt(resolved, 0).o2Percent, 50.0); + expect(tankAt(resolved, 1).o2Percent, 32.0); + }); + }); +}