diff --git a/Profundum/Profundum/Views/DiveDetailView.swift b/Profundum/Profundum/Views/DiveDetailView.swift index 3eae273..be2e8f3 100644 --- a/Profundum/Profundum/Views/DiveDetailView.swift +++ b/Profundum/Profundum/Views/DiveDetailView.swift @@ -8,6 +8,7 @@ struct DiveDetailView: View { @State private var samples: [DiveSample] = [] @State private var tags: [String] = [] @State private var gasMixes: [GasMix] = [] + @State private var deviceSettings: [DiveDeviceSettings] = [] @State private var stats: DiveStats? @State private var showEditSheet = false @State private var loadedTeammateIds: [String] = [] @@ -87,6 +88,43 @@ struct DiveDetailView: View { return filtered.isEmpty ? samples : filtered } + /// Gas mixes scoped to the selected device. + private var scopedGasMixes: [GasMix] { + guard let deviceId = selectedDeviceId else { return gasMixes } + let filtered = gasMixes.filter { $0.deviceId == deviceId } + return filtered.isEmpty ? gasMixes : filtered + } + + /// Per-device settings for the selected computer. + private var activeDeviceSettings: DiveDeviceSettings? { + if let deviceId = selectedDeviceId { + return deviceSettings.first(where: { $0.deviceId == deviceId }) + } + return deviceSettings.first(where: { $0.isPrimary }) ?? deviceSettings.first + } + + /// Footnote when computers disagree on GF or deco model. + private var deviceSettingsConflictNote: String? { + guard deviceSettings.count > 1 else { return nil } + let gfPairs = Set(deviceSettings.compactMap { setting -> String? in + guard let low = setting.gfLow, let high = setting.gfHigh else { return nil } + return "\(low)/\(high)" + }) + let models = Set(deviceSettings.compactMap(\.decoModel)) + guard gfPairs.count > 1 || models.count > 1 else { return nil } + + let deviceName: String + if let deviceId = selectedDeviceId, let name = devicesWithSamples[deviceId] { + deviceName = name + } else if let primary = deviceSettings.first(where: { $0.isPrimary }), + let name = devicesWithSamples[primary.deviceId] { + deviceName = name + } else { + deviceName = "primary computer" + } + return "Varies by computer — showing \(deviceName)" + } + var body: some View { ScrollView { VStack(alignment: .leading, spacing: 20) { @@ -107,8 +145,11 @@ struct DiveDetailView: View { statsSection - // Deco / GF info - if dive.decoModel != nil || dive.gfLow != nil || dive.endGf99 != nil { + // Deco / GF info (device settings with dive-level fallback for + // dives imported before per-device settings existed) + if (activeDeviceSettings?.decoModel ?? dive.decoModel) != nil + || (activeDeviceSettings?.gfLow ?? dive.gfLow) != nil + || dive.endGf99 != nil { Divider() decoSection } @@ -138,7 +179,7 @@ struct DiveDetailView: View { } // Gas mixes - if !gasMixes.isEmpty { + if !scopedGasMixes.isEmpty { Divider() gasMixSection } @@ -526,7 +567,7 @@ struct DiveDetailView: View { showAtPlusFive: showAtPlusFive, showDeltaFive: showDeltaFive, showSurfGf: showSurfGf, - gasMixes: gasMixes, + gasMixes: scopedGasMixes, showPpo2: showPpo2, showTankPressure: showTankPressure, pressureUnit: appState.pressureUnit, @@ -548,7 +589,7 @@ struct DiveDetailView: View { samples: chartSamples, depthUnit: appState.depthUnit, temperatureUnit: appState.temperatureUnit, - gasMixes: gasMixes, + gasMixes: scopedGasMixes, pressureUnit: appState.pressureUnit, bottomEndT: stats?.bottomEndT, decoStartT: stats?.decoStartT, @@ -562,7 +603,7 @@ struct DiveDetailView: View { samples: chartSamples, depthUnit: appState.depthUnit, temperatureUnit: appState.temperatureUnit, - gasMixes: gasMixes, + gasMixes: scopedGasMixes, pressureUnit: appState.pressureUnit, bottomEndT: stats?.bottomEndT, decoStartT: stats?.decoStartT, @@ -573,7 +614,13 @@ struct DiveDetailView: View { } #endif .sheet(isPresented: $showReplaySheet) { - ReplayProfileSheet(dive: dive, gasMixes: gasMixes, stats: stats, samples: samples) + ReplayProfileSheet( + dive: dive, + gasMixes: scopedGasMixes, + stats: stats, + samples: chartSamples, + deviceSettings: activeDeviceSettings + ) } } @@ -595,10 +642,11 @@ struct DiveDetailView: View { GridItem(.flexible()), GridItem(.flexible()) ], spacing: 12) { - if let model = dive.decoModel { + if let model = activeDeviceSettings?.decoModel ?? dive.decoModel { StatCard(title: "Deco Model", value: model.capitalized) } - if let gfLow = dive.gfLow, let gfHigh = dive.gfHigh { + if let gfLow = activeDeviceSettings?.gfLow ?? dive.gfLow, + let gfHigh = activeDeviceSettings?.gfHigh ?? dive.gfHigh { StatCard(title: "GF Setting", value: "\(gfLow)/\(gfHigh)") } if let endGf = dive.endGf99 { @@ -606,6 +654,12 @@ struct DiveDetailView: View { color: endGf > 85 ? .orange : nil) } } + + if let note = deviceSettingsConflictNote { + Text(note) + .font(.caption) + .foregroundColor(.secondary) + } } } @@ -779,7 +833,7 @@ struct DiveDetailView: View { Text("Gas Mixes") .font(.headline) - ForEach(gasMixes) { mix in + ForEach(scopedGasMixes) { mix in HStack { Text(gasMixLabel(mix)) .font(.body) @@ -949,11 +1003,18 @@ struct DiveDetailView: View { samples = detail.samples tags = detail.tags gasMixes = detail.gasMixes + deviceSettings = detail.deviceSettings loadedTeammateIds = detail.teammateIds loadedEquipmentIds = detail.equipmentIds sourceDeviceMap = detail.sourceDeviceMap if !hasPickedDevice { - selectedDeviceId = dive.deviceId + let multiDevice = Set(detail.samples.compactMap(\.deviceId)).count > 1 + if multiDevice { + selectedDeviceId = detail.deviceSettings.first(where: { $0.isPrimary })?.deviceId + ?? dive.deviceId + } else { + selectedDeviceId = dive.deviceId + } hasPickedDevice = true } diff --git a/Profundum/Profundum/Views/ReplayProfileSheet.swift b/Profundum/Profundum/Views/ReplayProfileSheet.swift index 29e28f0..948d615 100644 --- a/Profundum/Profundum/Views/ReplayProfileSheet.swift +++ b/Profundum/Profundum/Views/ReplayProfileSheet.swift @@ -9,6 +9,21 @@ struct ReplayProfileSheet: View { let gasMixes: [GasMix] let stats: DiveStats? let samples: [DiveSample] + let deviceSettings: DiveDeviceSettings? + + init( + dive: Dive, + gasMixes: [GasMix], + stats: DiveStats?, + samples: [DiveSample], + deviceSettings: DiveDeviceSettings? = nil + ) { + self.dive = dive + self.gasMixes = gasMixes + self.stats = stats + self.samples = samples + self.deviceSettings = deviceSettings + } // MARK: - Mode @@ -640,7 +655,7 @@ struct ReplayProfileSheet: View { } // Deco model - if let model = dive.decoModel?.lowercased() { + if let model = (deviceSettings?.decoModel ?? dive.decoModel)?.lowercased() { if model.contains("thalmann") { selectedModel = .thalmannElDca } else { @@ -648,8 +663,8 @@ struct ReplayProfileSheet: View { } } - gfLow = dive.gfLow ?? 30 - gfHigh = dive.gfHigh ?? 70 + gfLow = deviceSettings?.gfLow ?? dive.gfLow ?? 30 + gfHigh = deviceSettings?.gfHigh ?? dive.gfHigh ?? 70 gfLowText = "\(gfLow)" gfHighText = "\(gfHigh)" diff --git a/apple/DivelogCore/Sources/Database/DivelogDatabase.swift b/apple/DivelogCore/Sources/Database/DivelogDatabase.swift index ea57c3b..51f95e9 100644 --- a/apple/DivelogCore/Sources/Database/DivelogDatabase.swift +++ b/apple/DivelogCore/Sources/Database/DivelogDatabase.swift @@ -408,6 +408,26 @@ public final class DivelogDatabase: Sendable { try db.execute(sql: "ALTER TABLE dives ADD COLUMN deco_start_t_override_sec INTEGER") } + // Migration 18: Per-device deco/settings for multi-computer dives + migrator.registerMigration("018_dive_device_settings") { db in + try db.execute(sql: """ + CREATE TABLE dive_device_settings ( + dive_id TEXT NOT NULL REFERENCES dives(id) ON DELETE CASCADE, + device_id TEXT NOT NULL REFERENCES devices(id), + gf_low INTEGER, + gf_high INTEGER, + deco_model TEXT, + salinity TEXT, + surface_pressure_bar REAL, + is_primary INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (dive_id, device_id) + ) + """) + try db.execute(sql: """ + CREATE INDEX idx_dive_device_settings_dive ON dive_device_settings(dive_id) + """) + } + try migrator.migrate(dbQueue) } } diff --git a/apple/DivelogCore/Sources/Models/DiveDeviceSettings.swift b/apple/DivelogCore/Sources/Models/DiveDeviceSettings.swift new file mode 100644 index 0000000..39ddea4 --- /dev/null +++ b/apple/DivelogCore/Sources/Models/DiveDeviceSettings.swift @@ -0,0 +1,110 @@ +import Foundation +import GRDB + +/// Per-computer deco and environment settings for a multi-device dive. +public struct DiveDeviceSettings: Equatable, Sendable { + public var diveId: String + public var deviceId: String + public var gfLow: Int? + public var gfHigh: Int? + public var decoModel: String? + public var salinity: String? + public var surfacePressureBar: Float? + public var isPrimary: Bool + + public init( + diveId: String, + deviceId: String, + gfLow: Int? = nil, + gfHigh: Int? = nil, + decoModel: String? = nil, + salinity: String? = nil, + surfacePressureBar: Float? = nil, + isPrimary: Bool = false + ) { + self.diveId = diveId + self.deviceId = deviceId + self.gfLow = gfLow + self.gfHigh = gfHigh + self.decoModel = decoModel + self.salinity = salinity + self.surfacePressureBar = surfacePressureBar + self.isPrimary = isPrimary + } +} + +// MARK: - GRDB Conformance + +extension DiveDeviceSettings: Codable, FetchableRecord, PersistableRecord { + public static let databaseTableName = "dive_device_settings" + + enum CodingKeys: String, CodingKey { + case diveId = "dive_id" + case deviceId = "device_id" + case gfLow = "gf_low" + case gfHigh = "gf_high" + case decoModel = "deco_model" + case salinity + case surfacePressureBar = "surface_pressure_bar" + case isPrimary = "is_primary" + } +} + +// MARK: - Import Helpers + +extension DiveDeviceSettings { + /// Inserts or replaces per-device settings from a parsed dive computer import. + static func upsert( + diveId: String, + deviceId: String, + gfLow: Int?, + gfHigh: Int?, + decoModel: String?, + salinity: String?, + surfacePressureBar: Float?, + isPrimary: Bool, + db: Database + ) throws { + let settings = DiveDeviceSettings( + diveId: diveId, + deviceId: deviceId, + gfLow: gfLow, + gfHigh: gfHigh, + decoModel: decoModel, + salinity: salinity, + surfacePressureBar: surfacePressureBar, + isPrimary: isPrimary + ) + try settings.insert(db, onConflict: .replace) + } + + /// Creates a per-device settings row when missing (re-import backfill). + static func backfillIfMissing( + diveId: String, + deviceId: String, + gfLow: Int?, + gfHigh: Int?, + decoModel: String?, + salinity: String?, + surfacePressureBar: Float?, + isPrimary: Bool, + db: Database + ) throws { + let exists = try Self + .filter(Column("dive_id") == diveId) + .filter(Column("device_id") == deviceId) + .fetchCount(db) > 0 + guard !exists else { return } + try upsert( + diveId: diveId, + deviceId: deviceId, + gfLow: gfLow, + gfHigh: gfHigh, + decoModel: decoModel, + salinity: salinity, + surfacePressureBar: surfacePressureBar, + isPrimary: isPrimary, + db: db + ) + } +} diff --git a/apple/DivelogCore/Sources/Services/DiveComputerImportService.swift b/apple/DivelogCore/Sources/Services/DiveComputerImportService.swift index 5470a9e..c79ff1e 100644 --- a/apple/DivelogCore/Sources/Services/DiveComputerImportService.swift +++ b/apple/DivelogCore/Sources/Services/DiveComputerImportService.swift @@ -49,13 +49,6 @@ public final class ImportProgressTracker: @unchecked Sendable { } } -/// Key for deduplicating gas mixes by composition and usage. -struct GasMixKey: Hashable { - let o2: Int // o2Fraction * 1000 as integer for reliable hashing - let he: Int // heFraction * 1000 as integer for reliable hashing - let usage: String? -} - /// Service for importing dives from a dive computer. /// /// All libdivecomputer operations run on a dedicated serial queue, @@ -148,25 +141,7 @@ public final class DiveComputerImportService: Sendable { } // Prepare domain objects outside the write lock (pure mapping) - let (dive, samples, gasMixes) = DiveDataMapper.toDive(parsed, deviceId: deviceId) - - // Deduplicate gas mixes by (o2, he, usage) - var seenMixes = Set() - var uniqueMixes: [GasMix] = [] - for mix in gasMixes { - let key = GasMixKey( - o2: Int(mix.o2Fraction * 1000), - he: Int(mix.heFraction * 1000), - usage: mix.usage - ) - if seenMixes.insert(key).inserted { - uniqueMixes.append(mix) - } - } - // Re-index sequentially - for i in uniqueMixes.indices { - uniqueMixes[i].mixIndex = i - } + let (dive, _, _) = DiveDataMapper.toDive(parsed, deviceId: deviceId) // Single write transaction handles merge, skip, and new-dive paths. // Fingerprint dedup is re-checked here (TOCTOU guard against the @@ -238,12 +213,33 @@ public final class DiveComputerImportService: Sendable { for activityTag in activityTags { try DiveTag(diveId: dive.id, tag: activityTag.rawValue).insert(db) } - for sample in samples { - try sample.insert(db) - } - for mix in uniqueMixes { - try mix.insert(db) - } + + let usedMixes = GasMixMergeHelper.filterUsedGasMixes(parsed.gasMixes, samples: parsed.samples) + let indexRemap = try GasMixMergeHelper.mergeGasMixes( + existingMixes: [], + incomingMixes: usedMixes, + diveId: dive.id, + deviceId: deviceId, + db: db + ) + try GasMixMergeHelper.insertSamples( + samples: parsed.samples, + diveId: dive.id, + deviceId: deviceId, + indexRemap: indexRemap, + db: db + ) + try DiveDeviceSettings.upsert( + diveId: dive.id, + deviceId: deviceId, + gfLow: parsed.gfLow, + gfHigh: parsed.gfHigh, + decoModel: parsed.decoModel, + salinity: parsed.salinity, + surfacePressureBar: parsed.surfacePressureBar, + isPrimary: true, + db: db + ) // Record BLE fingerprint in dive_source_fingerprints for future dedup if let fp = dive.fingerprint { @@ -288,67 +284,39 @@ public final class DiveComputerImportService: Sendable { private static func mergeSamplesInTransaction( _ parsed: ParsedDive, deviceId: String, intoDiveId existingDiveId: String, db: Database ) throws { - // Build index remap from incoming gas mix indices → persisted indices. - // This must happen BEFORE inserting samples so gasmixIndex values are correct. let existingMixes = try GasMix .filter(Column("dive_id") == existingDiveId) .fetchAll(db) - // Use uniquingKeysWith to handle potential duplicate compositions in existing data - // (no DB uniqueness constraint). Keep the lowest mixIndex for stability. - var mixByKey: [GasMixKey: Int] = Dictionary( - existingMixes.map { - (GasMixKey(o2: Int($0.o2Fraction * 1000), he: Int($0.heFraction * 1000), usage: $0.usage), - $0.mixIndex) - }, - uniquingKeysWith: { first, _ in first } + let usedMixes = GasMixMergeHelper.filterUsedGasMixes(parsed.gasMixes, samples: parsed.samples) + let indexRemap = try GasMixMergeHelper.mergeGasMixes( + existingMixes: existingMixes, + incomingMixes: usedMixes, + diveId: existingDiveId, + deviceId: deviceId, + db: db + ) + try GasMixMergeHelper.insertSamples( + samples: parsed.samples, + diveId: existingDiveId, + deviceId: deviceId, + indexRemap: indexRemap, + db: db ) - var nextMixIndex = (existingMixes.map(\.mixIndex).max() ?? -1) + 1 - - var indexRemap: [Int: Int] = [:] - for m in parsed.gasMixes { - let key = GasMixKey(o2: Int(m.o2Fraction * 1000), he: Int(m.heFraction * 1000), usage: m.usage) - if let existingIdx = mixByKey[key] { - indexRemap[m.index] = existingIdx - } else { - indexRemap[m.index] = nextMixIndex - mixByKey[key] = nextMixIndex - try GasMix( - diveId: existingDiveId, - mixIndex: nextMixIndex, - o2Fraction: m.o2Fraction, - heFraction: m.heFraction, - usage: m.usage, - deviceId: deviceId - ).insert(db) - nextMixIndex += 1 - } - } - // Insert samples with remapped gas mix indices - for s in parsed.samples { - try DiveSample( - diveId: existingDiveId, - deviceId: deviceId, - tSec: s.tSec, - depthM: s.depthM, - tempC: s.tempC, - setpointPpo2: s.setpointPpo2, - ceilingM: s.ceilingM, - gf99: s.gf99, - ppo2_1: s.ppo2_1, - ppo2_2: s.ppo2_2, - ppo2_3: s.ppo2_3, - cns: s.cns, - tankPressure1Bar: s.tankPressure1Bar, - tankPressure2Bar: s.tankPressure2Bar, - ttsSec: s.ttsSec, - ndlSec: s.ndlSec, - decoStopDepthM: s.decoStopDepthM, - rbtSec: s.rbtSec, - gasmixIndex: s.gasmixIndex.flatMap { indexRemap[$0] }, - atPlusFiveTtsMin: s.atPlusFiveTtsMin - ).insert(db) - } + let isPrimary = try DiveDeviceSettings + .filter(Column("dive_id") == existingDiveId) + .fetchCount(db) == 0 + try DiveDeviceSettings.upsert( + diveId: existingDiveId, + deviceId: deviceId, + gfLow: parsed.gfLow, + gfHigh: parsed.gfHigh, + decoModel: parsed.decoModel, + salinity: parsed.salinity, + surfacePressureBar: parsed.surfacePressureBar, + isPrimary: isPrimary, + db: db + ) // Link fingerprint if let fp = parsed.fingerprint { @@ -556,6 +524,17 @@ public final class DiveComputerImportService: Sendable { arguments: [newDiveId, diveId, deviceId] ) + // 5b. Move this device's settings row to the new dive. + // It becomes the only device on the new dive, so mark it primary. + try db.execute( + sql: """ + UPDATE dive_device_settings + SET dive_id = ?, is_primary = 1 + WHERE dive_id = ? AND device_id = ? + """, + arguments: [newDiveId, diveId, deviceId] + ) + // 6. Copy tags let tags = try DiveTag .filter(Column("dive_id") == diveId) @@ -607,6 +586,19 @@ public final class DiveComputerImportService: Sendable { ] ) + // If the split device was primary, promote the new primary's + // settings row so the original dive keeps a primary device. + if originalDive.deviceId == deviceId { + try db.execute( + sql: """ + UPDATE dive_device_settings + SET is_primary = 1 + WHERE dive_id = ? AND device_id = ? + """, + arguments: [diveId, newPrimaryDeviceId] + ) + } + return SplitResult(newDiveId: newDiveId, originalDiveId: diveId) } } diff --git a/apple/DivelogCore/Sources/Services/DiveService.swift b/apple/DivelogCore/Sources/Services/DiveService.swift index 463e5d8..8883d7d 100644 --- a/apple/DivelogCore/Sources/Services/DiveService.swift +++ b/apple/DivelogCore/Sources/Services/DiveService.swift @@ -117,6 +117,25 @@ public final class DiveService: Sendable { sql: "UPDATE dive_source_fingerprints SET device_id = ? WHERE device_id = ?", arguments: [winnerId, loserId] ) + // Reassign per-device settings. If a dive already has a settings row + // for the winner (both IDs are the same physical computer), drop the + // loser's row instead of violating the (dive_id, device_id) PK. + try db.execute( + sql: """ + DELETE FROM dive_device_settings + WHERE device_id = ? + AND EXISTS ( + SELECT 1 FROM dive_device_settings w + WHERE w.dive_id = dive_device_settings.dive_id + AND w.device_id = ? + ) + """, + arguments: [loserId, winnerId] + ) + try db.execute( + sql: "UPDATE dive_device_settings SET device_id = ? WHERE device_id = ?", + arguments: [winnerId, loserId] + ) // Merge metadata onto winner. // Freshness-sensitive fields (bleUuid, firmwareVersion, lastSyncUnix): @@ -346,6 +365,8 @@ public final class DiveService: Sendable { public let sourceDeviceNames: [String] /// Maps device ID → human-readable display name for all source devices. public let sourceDeviceMap: [String: String] + /// Per-computer deco and environment settings. + public let deviceSettings: [DiveDeviceSettings] } /// Load all dive relations in a single read transaction (eliminates N+1 queries). @@ -378,6 +399,10 @@ public final class DiveService: Sendable { .filter(Column("dive_id") == diveId) .fetchAll(db) + let deviceSettings = try DiveDeviceSettings + .filter(Column("dive_id") == diveId) + .fetchAll(db) + // Batch-fetch device names for all source fingerprint device IDs let deviceIds = Set(sourceFingerprints.map(\.deviceId)) var sourceDeviceNames: [String] = [] @@ -411,11 +436,38 @@ public final class DiveService: Sendable { equipmentIds: equipmentIds, sourceFingerprints: sourceFingerprints, sourceDeviceNames: sourceDeviceNames, - sourceDeviceMap: sourceDeviceMap + sourceDeviceMap: sourceDeviceMap, + deviceSettings: deviceSettings ) } } + /// Returns true when multiple computers on this dive disagree on GF or deco model. + public func hasConflictingDeviceSettings(diveId: String) throws -> Bool { + try database.dbQueue.read { db in + let settings = try DiveDeviceSettings + .filter(Column("dive_id") == diveId) + .fetchAll(db) + guard settings.count > 1 else { return false } + + let gfPairs = Set(settings.compactMap { setting -> String? in + guard let low = setting.gfLow, let high = setting.gfHigh else { return nil } + return "\(low)/\(high)" + }) + let models = Set(settings.compactMap(\.decoModel)) + return gfPairs.count > 1 || models.count > 1 + } + } + + /// Load per-device settings for a dive. + public func getDeviceSettings(diveId: String) throws -> [DiveDeviceSettings] { + try database.dbQueue.read { db in + try DiveDeviceSettings + .filter(Column("dive_id") == diveId) + .fetchAll(db) + } + } + // MARK: - Surface Interval /// Calculate the surface interval before a dive (time since previous dive ended). diff --git a/apple/DivelogCore/Sources/Services/GasMixMergeHelper.swift b/apple/DivelogCore/Sources/Services/GasMixMergeHelper.swift new file mode 100644 index 0000000..f0da660 --- /dev/null +++ b/apple/DivelogCore/Sources/Services/GasMixMergeHelper.swift @@ -0,0 +1,96 @@ +import GRDB + +/// Key for deduplicating gas mixes by composition and usage. +struct GasMixKey: Hashable { + let o2: Int // o2Fraction * 1000 as integer for reliable hashing + let he: Int // heFraction * 1000 as integer for reliable hashing + let usage: String? +} + +/// Shared gas mix merge and sample index remapping for BLE and Cloud import paths. +enum GasMixMergeHelper { + /// Returns only gas mixes whose index appears on at least one sample. + static func filterUsedGasMixes(_ mixes: [ParsedGasMix], samples: [ParsedSample]) -> [ParsedGasMix] { + let usedIndices = Set(samples.compactMap(\.gasmixIndex)) + guard !usedIndices.isEmpty else { return mixes } + return mixes.filter { usedIndices.contains($0.index) } + } + + /// Merges incoming gas mixes into existing dive mixes and inserts new rows. + /// Returns a map from source mix index to persisted `mix_index`. + static func mergeGasMixes( + existingMixes: [GasMix], + incomingMixes: [ParsedGasMix], + diveId: String, + deviceId: String, + db: Database + ) throws -> [Int: Int] { + var mixByKey: [GasMixKey: Int] = Dictionary( + existingMixes.map { + (GasMixKey(o2: Int($0.o2Fraction * 1000), he: Int($0.heFraction * 1000), usage: $0.usage), + $0.mixIndex) + }, + uniquingKeysWith: { first, _ in first } + ) + var nextMixIndex = (existingMixes.map(\.mixIndex).max() ?? -1) + 1 + + var indexRemap: [Int: Int] = [:] + for mix in incomingMixes { + let key = GasMixKey( + o2: Int(mix.o2Fraction * 1000), + he: Int(mix.heFraction * 1000), + usage: mix.usage + ) + if let existingIdx = mixByKey[key] { + indexRemap[mix.index] = existingIdx + } else { + indexRemap[mix.index] = nextMixIndex + mixByKey[key] = nextMixIndex + try GasMix( + diveId: diveId, + mixIndex: nextMixIndex, + o2Fraction: mix.o2Fraction, + heFraction: mix.heFraction, + usage: mix.usage, + deviceId: deviceId + ).insert(db) + nextMixIndex += 1 + } + } + return indexRemap + } + + /// Inserts parsed samples with remapped `gasmixIndex` values. + static func insertSamples( + samples: [ParsedSample], + diveId: String, + deviceId: String, + indexRemap: [Int: Int], + db: Database + ) throws { + for sample in samples { + try DiveSample( + diveId: diveId, + deviceId: deviceId, + tSec: sample.tSec, + depthM: sample.depthM, + tempC: sample.tempC, + setpointPpo2: sample.setpointPpo2, + ceilingM: sample.ceilingM, + gf99: sample.gf99, + ppo2_1: sample.ppo2_1, + ppo2_2: sample.ppo2_2, + ppo2_3: sample.ppo2_3, + cns: sample.cns, + tankPressure1Bar: sample.tankPressure1Bar, + tankPressure2Bar: sample.tankPressure2Bar, + ttsSec: sample.ttsSec, + ndlSec: sample.ndlSec, + decoStopDepthM: sample.decoStopDepthM, + rbtSec: sample.rbtSec, + gasmixIndex: sample.gasmixIndex.flatMap { indexRemap[$0] }, + atPlusFiveTtsMin: sample.atPlusFiveTtsMin + ).insert(db) + } + } +} diff --git a/apple/DivelogCore/Sources/Services/ShearwaterCloudImportService.swift b/apple/DivelogCore/Sources/Services/ShearwaterCloudImportService.swift index 2fc6314..ffa4ab5 100644 --- a/apple/DivelogCore/Sources/Services/ShearwaterCloudImportService.swift +++ b/apple/DivelogCore/Sources/Services/ShearwaterCloudImportService.swift @@ -236,6 +236,16 @@ public final class ShearwaterCloudImportService: Sendable { // Rows that didn't pass validation in Phase 2 divesSkipped += (totalDives - importRows.count) + let deviceOwnershipById: [String: DeviceOwnership] = try database.dbQueue.read { db in + var result: [String: DeviceOwnership] = [:] + for deviceId in Set(importRows.map(\.deviceId)) { + if let device = try Device.fetchOne(db, key: deviceId) { + result[deviceId] = device.ownership + } + } + return result + } + // Phase 3: Group rows by time proximity for merging // Sort by start time, then group consecutive rows from different serials within 2 minutes let sorted = importRows.sorted { $0.startTimeUnix < $1.startTimeUnix } @@ -243,6 +253,14 @@ public final class ShearwaterCloudImportService: Sendable { var currentGroup: [ImportRow] = [] for ir in sorted { + // Buddy-owned rows never merge: emit as singleton groups without + // disturbing the current group, so two owned computers still merge + // even when a buddy row interleaves chronologically between them. + let irOwn = deviceOwnershipById[ir.deviceId] ?? .mine + if irOwn == .other { + groups.append([ir]) + continue + } if let last = currentGroup.last { let timeDiff = abs(ir.startTimeUnix - last.startTimeUnix) let differentSerial = ir.serial != last.serial @@ -335,15 +353,6 @@ public final class ShearwaterCloudImportService: Sendable { // Partial merge: add new samples, fingerprints, and gas mixes to existing dive let existingDiveId = existingFpRecord.diveId try database.dbQueue.write { db in - // Build seen-set from existing gas mixes for this dive - let existingMixes = try GasMix - .filter(Column("dive_id") == existingDiveId) - .fetchAll(db) - var seenMixes = Set(existingMixes.map { - GasMixKey(o2: Int($0.o2Fraction * 1000), he: Int($0.heFraction * 1000), usage: $0.usage) - }) - var nextMixIndex = (existingMixes.map(\.mixIndex).max() ?? -1) + 1 - for ir in group { // Skip if this specific fingerprint already exists let fpExists = try DiveSourceFingerprint @@ -370,49 +379,39 @@ public final class ShearwaterCloudImportService: Sendable { fingerprint: ir.fingerprint ).insert(db) - // Insert samples with this device_id - for sample in parsedInfo.samples { - try DiveSample( - diveId: existingDiveId, - deviceId: ir.deviceId, - tSec: sample.tSec, - depthM: sample.depthM, - tempC: sample.tempC, - setpointPpo2: sample.setpointPpo2, - ceilingM: sample.ceilingM, - gf99: sample.gf99, - ppo2_1: sample.ppo2_1, - ppo2_2: sample.ppo2_2, - ppo2_3: sample.ppo2_3, - cns: sample.cns, - tankPressure1Bar: sample.tankPressure1Bar, - tankPressure2Bar: sample.tankPressure2Bar, - ttsSec: sample.ttsSec, - ndlSec: sample.ndlSec, - decoStopDepthM: sample.decoStopDepthM, - rbtSec: sample.rbtSec, - gasmixIndex: sample.gasmixIndex, - atPlusFiveTtsMin: sample.atPlusFiveTtsMin - ).insert(db) - } + let existingMixes = try GasMix + .filter(Column("dive_id") == existingDiveId) + .fetchAll(db) + let usedMixes = GasMixMergeHelper.filterUsedGasMixes( + parsedInfo.gasMixes, samples: parsedInfo.samples + ) + let indexRemap = try GasMixMergeHelper.mergeGasMixes( + existingMixes: existingMixes, + incomingMixes: usedMixes, + diveId: existingDiveId, + deviceId: ir.deviceId, + db: db + ) + try GasMixMergeHelper.insertSamples( + samples: parsedInfo.samples, + diveId: existingDiveId, + deviceId: ir.deviceId, + indexRemap: indexRemap, + db: db + ) - // Insert new unique gas mixes from this device - for mix in parsedInfo.gasMixes { - let key = GasMixKey(o2: Int(mix.o2Fraction * 1000), - he: Int(mix.heFraction * 1000), - usage: mix.usage) - if seenMixes.insert(key).inserted { - try GasMix( - diveId: existingDiveId, - mixIndex: nextMixIndex, - o2Fraction: mix.o2Fraction, - heFraction: mix.heFraction, - usage: mix.usage, - deviceId: ir.deviceId - ).insert(db) - nextMixIndex += 1 - } - } + let dive = try Dive.fetchOne(db, key: existingDiveId) + try DiveDeviceSettings.upsert( + diveId: existingDiveId, + deviceId: ir.deviceId, + gfLow: parsedInfo.gfLow, + gfHigh: parsedInfo.gfHigh, + decoModel: parsedInfo.decoModel, + salinity: parsedInfo.salinity, + surfacePressureBar: parsedInfo.surfacePressureBar, + isPrimary: dive?.deviceId == ir.deviceId, + db: db + ) divesMerged += 1 } @@ -477,11 +476,11 @@ public final class ShearwaterCloudImportService: Sendable { let mergedMaxTempC = parseResults.compactMap(\.parsedInfo.maxTempC).max() let mergedAvgTempC = parseResults.compactMap(\.parsedInfo.avgTempC).first let mergedEndGf99 = parseResults.compactMap(\.parsedInfo.endGf99).first - let mergedGfLow = parseResults.compactMap(\.parsedInfo.gfLow).first - let mergedGfHigh = parseResults.compactMap(\.parsedInfo.gfHigh).first - let mergedDecoModel = parseResults.compactMap(\.parsedInfo.decoModel).first - let mergedSalinity = parseResults.compactMap(\.parsedInfo.salinity).first - let mergedSurfacePressure = parseResults.compactMap(\.parsedInfo.surfacePressureBar).first + let mergedGfLow = primaryResult.parsedInfo.gfLow + let mergedGfHigh = primaryResult.parsedInfo.gfHigh + let mergedDecoModel = primaryResult.parsedInfo.decoModel + let mergedSalinity = primaryResult.parsedInfo.salinity + let mergedSurfacePressure = primaryResult.parsedInfo.surfacePressureBar let mergedLat = parseResults.compactMap(\.parsedInfo.lat).first let mergedLon = parseResults.compactMap(\.parsedInfo.lon).first let mergedEnvironment = parseResults.compactMap(\.parsedInfo.environment).first @@ -590,10 +589,6 @@ public final class ShearwaterCloudImportService: Sendable { } // Insert source fingerprints + samples from each device - // Collect gas mixes across all devices, deduplicating by (o2, he, usage) - var seenMixes = Set() - var uniqueMixes: [ParsedGasMix] = [] - for pr in parseResults { try DiveSourceFingerprint( diveId: diveId, @@ -601,51 +596,38 @@ public final class ShearwaterCloudImportService: Sendable { fingerprint: pr.ir.fingerprint ).insert(db) - for sample in pr.parsedInfo.samples { - try DiveSample( - diveId: diveId, - deviceId: pr.ir.deviceId, - tSec: sample.tSec, - depthM: sample.depthM, - tempC: sample.tempC, - setpointPpo2: sample.setpointPpo2, - ceilingM: sample.ceilingM, - gf99: sample.gf99, - ppo2_1: sample.ppo2_1, - ppo2_2: sample.ppo2_2, - ppo2_3: sample.ppo2_3, - cns: sample.cns, - tankPressure1Bar: sample.tankPressure1Bar, - tankPressure2Bar: sample.tankPressure2Bar, - ttsSec: sample.ttsSec, - ndlSec: sample.ndlSec, - decoStopDepthM: sample.decoStopDepthM, - rbtSec: sample.rbtSec, - gasmixIndex: sample.gasmixIndex, - atPlusFiveTtsMin: sample.atPlusFiveTtsMin - ).insert(db) - } - - for mix in pr.parsedInfo.gasMixes { - let key = GasMixKey(o2: Int(mix.o2Fraction * 1000), - he: Int(mix.heFraction * 1000), - usage: mix.usage) - if seenMixes.insert(key).inserted { - uniqueMixes.append(mix) - } - } - } + let existingMixes = try GasMix + .filter(Column("dive_id") == diveId) + .fetchAll(db) + let usedMixes = GasMixMergeHelper.filterUsedGasMixes( + pr.parsedInfo.gasMixes, samples: pr.parsedInfo.samples + ) + let indexRemap = try GasMixMergeHelper.mergeGasMixes( + existingMixes: existingMixes, + incomingMixes: usedMixes, + diveId: diveId, + deviceId: pr.ir.deviceId, + db: db + ) + try GasMixMergeHelper.insertSamples( + samples: pr.parsedInfo.samples, + diveId: diveId, + deviceId: pr.ir.deviceId, + indexRemap: indexRemap, + db: db + ) - // Insert deduplicated gas mixes with sequential indices - for (idx, mix) in uniqueMixes.enumerated() { - try GasMix( + try DiveDeviceSettings.upsert( diveId: diveId, - mixIndex: idx, - o2Fraction: mix.o2Fraction, - heFraction: mix.heFraction, - usage: mix.usage, - deviceId: primaryIr.deviceId - ).insert(db) + deviceId: pr.ir.deviceId, + gfLow: pr.parsedInfo.gfLow, + gfHigh: pr.parsedInfo.gfHigh, + decoModel: pr.parsedInfo.decoModel, + salinity: pr.parsedInfo.salinity, + surfacePressureBar: pr.parsedInfo.surfacePressureBar, + isPrimary: pr.ir.deviceId == primaryIr.deviceId, + db: db + ) } } @@ -683,13 +665,15 @@ public final class ShearwaterCloudImportService: Sendable { /// Backfills metadata fields on already-imported dives that are nil because /// an older app version didn't extract them (GF settings, deco model, - /// salinity, surface pressure, end GF99 — PRO-62). + /// salinity, surface pressure, end GF99 — PRO-62), and creates missing + /// per-device settings rows (multi-computer support). /// - /// The binary dive log is only parsed when at least one target field is - /// missing, so re-imports of fully populated databases stay cheap. Existing - /// non-nil values are never overwritten. + /// The binary dive log is only parsed when at least one target field or + /// the per-device settings row is missing, so re-imports of fully + /// populated databases stay cheap. Existing non-nil values are never + /// overwritten. /// - /// - Returns: Number of dives updated. + /// - Returns: Number of dives updated (dive-level fields only). private func backfillMissingMetadata( rows: [(row: Row, fingerprint: Data)], dateFormatter: DateFormatter @@ -701,14 +685,20 @@ public final class ShearwaterCloudImportService: Sendable { // Resolve the dive via source fingerprint, falling back to the // legacy dives.fingerprint column. let diveId: String? + let deviceId: String? if let fpRecord = try DiveSourceFingerprint .filter(Column("fingerprint") == entry.fingerprint) .fetchOne(db) { diveId = fpRecord.diveId + deviceId = fpRecord.deviceId + } else if let legacyDive = try Dive + .filter(Column("fingerprint") == entry.fingerprint) + .fetchOne(db) { + diveId = legacyDive.id + deviceId = legacyDive.deviceId } else { - diveId = try Dive - .filter(Column("fingerprint") == entry.fingerprint) - .fetchOne(db)?.id + diveId = nil + deviceId = nil } // Note: a dive already updated by an earlier row in the group is // still re-checked — a second computer's row may fill fields the @@ -717,11 +707,19 @@ public final class ShearwaterCloudImportService: Sendable { guard let diveId, var dive = try Dive.fetchOne(db, key: diveId) else { continue } - // Only parse the binary log when something is actually missing. - let needsBackfill = dive.gfLow == nil || dive.gfHigh == nil + // Only parse the binary log when something is actually missing: + // a nil dive-level field or an absent per-device settings row. + let needsDiveFieldBackfill = dive.gfLow == nil || dive.gfHigh == nil || dive.decoModel == nil || dive.salinity == nil || dive.surfacePressureBar == nil || dive.endGf99 == nil - guard needsBackfill else { continue } + let needsSettingsRow: Bool = try { + guard let deviceId else { return false } + return try DiveDeviceSettings + .filter(Column("dive_id") == diveId) + .filter(Column("device_id") == deviceId) + .fetchCount(db) == 0 + }() + guard needsDiveFieldBackfill || needsSettingsRow else { continue } let calcVals: CalculatedValues? = decodeJSON( entry.row["calculated_values_from_samples"] as DatabaseValue @@ -732,6 +730,22 @@ public final class ShearwaterCloudImportService: Sendable { calcValues: calcVals, metadata: meta ) + if needsSettingsRow, let deviceId { + try DiveDeviceSettings.backfillIfMissing( + diveId: diveId, + deviceId: deviceId, + gfLow: parsed.gfLow, + gfHigh: parsed.gfHigh, + decoModel: parsed.decoModel, + salinity: parsed.salinity, + surfacePressureBar: parsed.surfacePressureBar, + isPrimary: dive.deviceId == deviceId, + db: db + ) + } + + guard needsDiveFieldBackfill else { continue } + var changed = false if dive.gfLow == nil, let v = parsed.gfLow { dive.gfLow = v; changed = true } if dive.gfHigh == nil, let v = parsed.gfHigh { dive.gfHigh = v; changed = true } diff --git a/apple/DivelogCore/Tests/DeviceMergeTests.swift b/apple/DivelogCore/Tests/DeviceMergeTests.swift index ba1f39a..c24af2e 100644 --- a/apple/DivelogCore/Tests/DeviceMergeTests.swift +++ b/apple/DivelogCore/Tests/DeviceMergeTests.swift @@ -137,6 +137,68 @@ final class DeviceMergeTests: XCTestCase { XCTAssertEqual(winner?.firmwareVersion, "1.2.3") } + func testMergeDevicesReassignsDeviceSettings() throws { + let winner = Device(model: "Petrel 3", serialNumber: "A31F4CE2", + firmwareVersion: "", manufacturer: "Shearwater") + let loser = Device(model: "Petrel 3", serialNumber: "A31F4CE2", + firmwareVersion: "1.2.3", bleUuid: "BLE-UUID") + try diveService.saveDevice(winner) + try diveService.saveDevice(loser) + + let dive = Dive(deviceId: loser.id, startTimeUnix: 1000, endTimeUnix: 2000, + maxDepthM: 30, avgDepthM: 20, bottomTimeSec: 1000, + isCcr: false, decoRequired: false) + try diveService.saveDive(dive) + try database.dbQueue.write { db in + try DiveDeviceSettings( + diveId: dive.id, deviceId: loser.id, + gfLow: 30, gfHigh: 70, decoModel: "buhlmann", isPrimary: true + ).insert(db) + } + + try diveService.mergeDevices(winnerId: winner.id, loserId: loser.id) + + // Settings row should follow the samples/fingerprints to the winner + let settings = try diveService.getDeviceSettings(diveId: dive.id) + XCTAssertEqual(settings.count, 1) + XCTAssertEqual(settings[0].deviceId, winner.id) + XCTAssertEqual(settings[0].gfLow, 30) + } + + func testMergeDevicesDropsDuplicateSettingsRow() throws { + let winner = Device(model: "Petrel 3", serialNumber: "A31F4CE2", + firmwareVersion: "", manufacturer: "Shearwater") + let loser = Device(model: "Petrel 3", serialNumber: "A31F4CE2", + firmwareVersion: "1.2.3", bleUuid: "BLE-UUID") + try diveService.saveDevice(winner) + try diveService.saveDevice(loser) + + let dive = Dive(deviceId: winner.id, startTimeUnix: 1000, endTimeUnix: 2000, + maxDepthM: 30, avgDepthM: 20, bottomTimeSec: 1000, + isCcr: false, decoRequired: false) + try diveService.saveDive(dive) + // Same dive has settings rows under both IDs (same physical computer + // imported via Cloud and BLE before the merge) + try database.dbQueue.write { db in + try DiveDeviceSettings( + diveId: dive.id, deviceId: winner.id, + gfLow: 30, gfHigh: 70, isPrimary: true + ).insert(db) + try DiveDeviceSettings( + diveId: dive.id, deviceId: loser.id, + gfLow: 30, gfHigh: 70, isPrimary: false + ).insert(db) + } + + // Must not throw a PK violation; loser's duplicate row is dropped + try diveService.mergeDevices(winnerId: winner.id, loserId: loser.id) + + let settings = try diveService.getDeviceSettings(diveId: dive.id) + XCTAssertEqual(settings.count, 1) + XCTAssertEqual(settings[0].deviceId, winner.id) + XCTAssertTrue(settings[0].isPrimary) + } + func testMergeDevicesAdoptsSpecificModel() throws { let cloudDevice = Device(model: "Shearwater", serialNumber: "A31F4CE2", firmwareVersion: "", manufacturer: "Shearwater") diff --git a/apple/DivelogCore/Tests/GasMixMergeHelperTests.swift b/apple/DivelogCore/Tests/GasMixMergeHelperTests.swift new file mode 100644 index 0000000..6498881 --- /dev/null +++ b/apple/DivelogCore/Tests/GasMixMergeHelperTests.swift @@ -0,0 +1,245 @@ +import GRDB +import XCTest +@testable import DivelogCore + +final class GasMixMergeHelperTests: XCTestCase { + var database: DivelogDatabase! + var diveService: DiveService! + + override func setUp() async throws { + database = try DivelogDatabase(path: ":memory:") + diveService = DiveService(database: database) + } + + func testFilterUsedGasMixesKeepsOnlyReferencedIndices() { + let mixes = [ + ParsedGasMix(index: 0, o2Fraction: 0.21, heFraction: 0.0), + ParsedGasMix(index: 1, o2Fraction: 0.50, heFraction: 0.0), + ParsedGasMix(index: 2, o2Fraction: 0.21, heFraction: 0.35), + ] + let samples = [ + ParsedSample(tSec: 0, depthM: 0, tempC: 22, gasmixIndex: 0), + ParsedSample(tSec: 60, depthM: 15, tempC: 20, gasmixIndex: 2), + ] + + let filtered = GasMixMergeHelper.filterUsedGasMixes(mixes, samples: samples) + XCTAssertEqual(filtered.map(\.index), [0, 2]) + } + + func testFilterUsedGasMixesReturnsAllWhenNoSampleIndices() { + let mixes = [ + ParsedGasMix(index: 0, o2Fraction: 0.21, heFraction: 0.0), + ParsedGasMix(index: 1, o2Fraction: 0.32, heFraction: 0.0), + ] + let samples = [ + ParsedSample(tSec: 0, depthM: 0, tempC: 22), + ParsedSample(tSec: 60, depthM: 15, tempC: 20), + ] + + let filtered = GasMixMergeHelper.filterUsedGasMixes(mixes, samples: samples) + XCTAssertEqual(filtered.count, 2) + } + + func testMergeGasMixesRemapsIndicesAcrossDevices() throws { + let deviceA = Device(model: "Perdix", serialNumber: "A-1234", firmwareVersion: "93") + let deviceB = Device(model: "Petrel", serialNumber: "B-5678", firmwareVersion: "93") + try diveService.saveDevice(deviceA) + try diveService.saveDevice(deviceB) + + let dive = Dive( + deviceId: deviceA.id, + startTimeUnix: 1_700_000_000, + endTimeUnix: 1_700_003_600, + maxDepthM: 30, + avgDepthM: 18, + bottomTimeSec: 3_000 + ) + try diveService.saveDive(dive) + + try database.dbQueue.write { db in + let mixesA = [ + ParsedGasMix(index: 0, o2Fraction: 0.21, heFraction: 0.0), + ParsedGasMix(index: 1, o2Fraction: 0.32, heFraction: 0.0), + ] + let samplesA = [ + ParsedSample(tSec: 0, depthM: 0, tempC: 22, gasmixIndex: 0), + ParsedSample(tSec: 60, depthM: 15, tempC: 20, gasmixIndex: 1), + ] + let usedA = GasMixMergeHelper.filterUsedGasMixes(mixesA, samples: samplesA) + let remapA = try GasMixMergeHelper.mergeGasMixes( + existingMixes: [], + incomingMixes: usedA, + diveId: dive.id, + deviceId: deviceA.id, + db: db + ) + try GasMixMergeHelper.insertSamples( + samples: samplesA, + diveId: dive.id, + deviceId: deviceA.id, + indexRemap: remapA, + db: db + ) + + let existingMixes = try GasMix.filter(Column("dive_id") == dive.id).fetchAll(db) + let mixesB = [ + ParsedGasMix(index: 0, o2Fraction: 0.32, heFraction: 0.0), + ParsedGasMix(index: 1, o2Fraction: 0.21, heFraction: 0.0), + ] + let samplesB = [ + ParsedSample(tSec: 0, depthM: 0, tempC: 22.5, gasmixIndex: 0), + ParsedSample(tSec: 60, depthM: 15, tempC: 20.5, gasmixIndex: 1), + ] + let usedB = GasMixMergeHelper.filterUsedGasMixes(mixesB, samples: samplesB) + let remapB = try GasMixMergeHelper.mergeGasMixes( + existingMixes: existingMixes, + incomingMixes: usedB, + diveId: dive.id, + deviceId: deviceB.id, + db: db + ) + try GasMixMergeHelper.insertSamples( + samples: samplesB, + diveId: dive.id, + deviceId: deviceB.id, + indexRemap: remapB, + db: db + ) + } + + let mixes = try diveService.getGasMixes(diveId: dive.id) + XCTAssertEqual(mixes.count, 2, "Air and nx32 should dedupe to two mixes") + + let samples = try diveService.getSamples(diveId: dive.id) + let samplesB = samples.filter { $0.deviceId == deviceB.id } + XCTAssertEqual(samplesB[0].gasmixIndex, 1, "B's index 0 (nx32) should remap to persisted index 1") + XCTAssertEqual(samplesB[1].gasmixIndex, 0, "B's index 1 (air) should remap to persisted index 0") + + let deviceIds = Set(mixes.compactMap(\.deviceId)) + XCTAssertFalse(deviceIds.isEmpty) + } + + // MARK: - Conflict Detection + + func testHasConflictingDeviceSettingsDetectsGfDisagreement() throws { + let deviceA = Device(model: "Petrel", serialNumber: "A-1", firmwareVersion: "93") + let deviceB = Device(model: "Perdix", serialNumber: "B-2", firmwareVersion: "93") + try diveService.saveDevice(deviceA) + try diveService.saveDevice(deviceB) + let dive = Dive( + deviceId: deviceA.id, startTimeUnix: 1_700_000_000, endTimeUnix: 1_700_003_600, + maxDepthM: 30, avgDepthM: 18, bottomTimeSec: 3_000 + ) + try diveService.saveDive(dive) + + try database.dbQueue.write { db in + try DiveDeviceSettings(diveId: dive.id, deviceId: deviceA.id, + gfLow: 30, gfHigh: 70, decoModel: "buhlmann", + isPrimary: true).insert(db) + try DiveDeviceSettings(diveId: dive.id, deviceId: deviceB.id, + gfLow: 50, gfHigh: 80, decoModel: "buhlmann").insert(db) + } + + XCTAssertTrue(try diveService.hasConflictingDeviceSettings(diveId: dive.id)) + } + + func testHasConflictingDeviceSettingsFalseWhenAgreeing() throws { + let deviceA = Device(model: "Petrel", serialNumber: "A-1", firmwareVersion: "93") + let deviceB = Device(model: "Perdix", serialNumber: "B-2", firmwareVersion: "93") + try diveService.saveDevice(deviceA) + try diveService.saveDevice(deviceB) + let dive = Dive( + deviceId: deviceA.id, startTimeUnix: 1_700_000_000, endTimeUnix: 1_700_003_600, + maxDepthM: 30, avgDepthM: 18, bottomTimeSec: 3_000 + ) + try diveService.saveDive(dive) + + try database.dbQueue.write { db in + try DiveDeviceSettings(diveId: dive.id, deviceId: deviceA.id, + gfLow: 30, gfHigh: 70, decoModel: "buhlmann", + isPrimary: true).insert(db) + try DiveDeviceSettings(diveId: dive.id, deviceId: deviceB.id, + gfLow: 30, gfHigh: 70, decoModel: "buhlmann").insert(db) + } + + XCTAssertFalse(try diveService.hasConflictingDeviceSettings(diveId: dive.id)) + } + + func testHasConflictingDeviceSettingsFalseForSingleDevice() throws { + let device = Device(model: "Petrel", serialNumber: "A-1", firmwareVersion: "93") + try diveService.saveDevice(device) + let dive = Dive( + deviceId: device.id, startTimeUnix: 1_700_000_000, endTimeUnix: 1_700_003_600, + maxDepthM: 30, avgDepthM: 18, bottomTimeSec: 3_000 + ) + try diveService.saveDive(dive) + + try database.dbQueue.write { db in + try DiveDeviceSettings(diveId: dive.id, deviceId: device.id, + gfLow: 30, gfHigh: 70, isPrimary: true).insert(db) + } + + XCTAssertFalse(try diveService.hasConflictingDeviceSettings(diveId: dive.id)) + } + + func testHasConflictingDeviceSettingsDetectsDecoModelDisagreement() throws { + let deviceA = Device(model: "Petrel", serialNumber: "A-1", firmwareVersion: "93") + let deviceB = Device(model: "Descent", serialNumber: "B-2", firmwareVersion: "1") + try diveService.saveDevice(deviceA) + try diveService.saveDevice(deviceB) + let dive = Dive( + deviceId: deviceA.id, startTimeUnix: 1_700_000_000, endTimeUnix: 1_700_003_600, + maxDepthM: 30, avgDepthM: 18, bottomTimeSec: 3_000 + ) + try diveService.saveDive(dive) + + // Same GF, different deco model + try database.dbQueue.write { db in + try DiveDeviceSettings(diveId: dive.id, deviceId: deviceA.id, + gfLow: 30, gfHigh: 70, decoModel: "buhlmann", + isPrimary: true).insert(db) + try DiveDeviceSettings(diveId: dive.id, deviceId: deviceB.id, + gfLow: 30, gfHigh: 70, decoModel: "thalmann").insert(db) + } + + XCTAssertTrue(try diveService.hasConflictingDeviceSettings(diveId: dive.id)) + } + + func testMergeGasMixesSkipsUnusedProgrammedSlots() throws { + let device = Device(model: "Perdix", serialNumber: "A-1234", firmwareVersion: "93") + try diveService.saveDevice(device) + + let dive = Dive( + deviceId: device.id, + startTimeUnix: 1_700_000_000, + endTimeUnix: 1_700_003_600, + maxDepthM: 30, + avgDepthM: 18, + bottomTimeSec: 3_000 + ) + try diveService.saveDive(dive) + + try database.dbQueue.write { db in + let mixes = [ + ParsedGasMix(index: 0, o2Fraction: 0.21, heFraction: 0.0, usage: "diluent"), + ParsedGasMix(index: 1, o2Fraction: 1.0, heFraction: 0.0, usage: "oxygen"), + ParsedGasMix(index: 2, o2Fraction: 0.50, heFraction: 0.0), + ] + let samples = [ + ParsedSample(tSec: 0, depthM: 0, tempC: 22, gasmixIndex: 0), + ] + let used = GasMixMergeHelper.filterUsedGasMixes(mixes, samples: samples) + _ = try GasMixMergeHelper.mergeGasMixes( + existingMixes: [], + incomingMixes: used, + diveId: dive.id, + deviceId: device.id, + db: db + ) + } + + let persisted = try diveService.getGasMixes(diveId: dive.id) + XCTAssertEqual(persisted.count, 1) + XCTAssertEqual(persisted[0].usage, "diluent") + } +} diff --git a/apple/DivelogCore/Tests/ShearwaterCloudImportTests.swift b/apple/DivelogCore/Tests/ShearwaterCloudImportTests.swift index 61ef72b..1a2fb94 100644 --- a/apple/DivelogCore/Tests/ShearwaterCloudImportTests.swift +++ b/apple/DivelogCore/Tests/ShearwaterCloudImportTests.swift @@ -419,6 +419,199 @@ final class ShearwaterCloudImportTests: XCTestCase { XCTAssertEqual(dives.count, 2) } + func testImportDoesNotMergeWhenDeviceOwnershipIsOther() throws { + let startTime: Int64 = 1718444400 + let buddyDevice = Device( + model: "Symbios", serialNumber: "SERIAL_B", firmwareVersion: "1", ownership: .other + ) + try diveService.saveDevice(buddyDevice) + + let path = try createShearwaterDB(dives: [ + ShearwaterTestDive( + diveId: 100, diveDate: "2024-06-15 10:00:00", depthFt: 100, + durationSec: 3600, serial: "SERIAL_A", + dataBytes2: """ + {"DIVE_START_TIME": \(startTime)} + """ + ), + ShearwaterTestDive( + diveId: 200, diveDate: "2024-06-15 10:00:30", depthFt: 98, + durationSec: 3580, serial: "SERIAL_B", + dataBytes2: """ + {"DIVE_START_TIME": \(startTime + 30)} + """ + ), + ]) + + let result = try importService.importFromFile(at: path) + XCTAssertEqual(result.divesImported, 2) + XCTAssertEqual(result.divesMerged, 0) + + let dives = try diveService.listDives() + XCTAssertEqual(dives.count, 2) + } + + func testOwnedComputersMergeAcrossInterleavedBuddyRow() throws { + // mine A (t=0), buddy B (t=30), mine C (t=60): A and C must still + // merge into one dive; B stays separate. + let startTime: Int64 = 1718444400 + let buddyDevice = Device( + model: "Symbios", serialNumber: "SERIAL_B", firmwareVersion: "1", ownership: .other + ) + try diveService.saveDevice(buddyDevice) + + let path = try createShearwaterDB(dives: [ + ShearwaterTestDive( + diveId: 100, diveDate: "2024-06-15 10:00:00", depthFt: 100, + durationSec: 3600, serial: "SERIAL_A", + dataBytes2: """ + {"DIVE_START_TIME": \(startTime)} + """ + ), + ShearwaterTestDive( + diveId: 200, diveDate: "2024-06-15 10:00:30", depthFt: 98, + durationSec: 3580, serial: "SERIAL_B", + dataBytes2: """ + {"DIVE_START_TIME": \(startTime + 30)} + """ + ), + ShearwaterTestDive( + diveId: 300, diveDate: "2024-06-15 10:01:00", depthFt: 99, + durationSec: 3590, serial: "SERIAL_C", + dataBytes2: """ + {"DIVE_START_TIME": \(startTime + 60)} + """ + ), + ]) + + let result = try importService.importFromFile(at: path) + + // A+C merge into one dive, B imports separately + XCTAssertEqual(result.divesImported, 2) + XCTAssertEqual(result.divesMerged, 1) + + let dives = try diveService.listDives() + XCTAssertEqual(dives.count, 2) + + // The merged dive has fingerprints from both owned serials + let merged = try dives.first { dive in + try diveService.getSourceFingerprints(diveId: dive.id).count == 2 + } + XCTAssertNotNil(merged, "Owned computers A and C should merge despite buddy row between them") + } + + func testMergedImportCreatesPerDeviceSettingsRows() throws { + let startTime: Int64 = 1718444400 + let path = try createShearwaterDB(dives: [ + ShearwaterTestDive( + diveId: 100, diveDate: "2024-06-15 10:00:00", depthFt: 100, + durationSec: 3600, serial: "SERIAL_A", + dataBytes2: """ + {"DIVE_START_TIME": \(startTime)} + """ + ), + ShearwaterTestDive( + diveId: 200, diveDate: "2024-06-15 10:00:30", depthFt: 98, + durationSec: 3580, serial: "SERIAL_B", + dataBytes2: """ + {"DIVE_START_TIME": \(startTime + 30)} + """ + ), + ]) + + _ = try importService.importFromFile(at: path) + + let dive = try diveService.listDives()[0] + let settings = try diveService.getDeviceSettings(diveId: dive.id) + XCTAssertEqual(settings.count, 2) + XCTAssertEqual(settings.filter(\.isPrimary).count, 1) + } + + func testReimportBackfillsPerDeviceSettingsRows() throws { + let startTime: Int64 = 1718444400 + let diveA = ShearwaterTestDive( + diveId: 100, diveDate: "2024-06-15 10:00:00", depthFt: 100, + durationSec: 3600, serial: "SERIAL_A", + dataBytes2: "{\"DIVE_START_TIME\": \(startTime)}" + ) + let diveB = ShearwaterTestDive( + diveId: 200, diveDate: "2024-06-15 10:00:30", depthFt: 98, + durationSec: 3580, serial: "SERIAL_B", + dataBytes2: "{\"DIVE_START_TIME\": \(startTime + 30)}" + ) + + let pathA = try createShearwaterDB(dives: [diveA]) + _ = try importService.importFromFile(at: pathA) + let diveId = try diveService.listDives()[0].id + XCTAssertEqual(try diveService.getDeviceSettings(diveId: diveId).count, 1) + + let pathAB = try createShearwaterDB(dives: [diveA, diveB]) + _ = try importService.importFromFile(at: pathAB) + XCTAssertEqual(try diveService.getDeviceSettings(diveId: diveId).count, 2) + } + + func testReimportRecreatesSettingsRowDeletedByOlderVersion() throws { + // Dives imported before migration 018 have no settings row. A full + // re-import (all fingerprints known → skip path) must create it via + // backfillMissingMetadata without touching populated dive fields. + let path = try createShearwaterDB(dives: [ + ShearwaterTestDive(diveId: 1, diveDate: "2024-06-15 10:30:00", depthFt: 100, + durationSec: 3600, serial: "SN001"), + ]) + _ = try importService.importFromFile(at: path) + let diveId = try diveService.listDives()[0].id + + // Simulate pre-migration state: settings row absent + try database.dbQueue.write { db in + try db.execute(sql: "DELETE FROM dive_device_settings WHERE dive_id = ?", + arguments: [diveId]) + } + XCTAssertTrue(try diveService.getDeviceSettings(diveId: diveId).isEmpty) + + let reimport = try importService.importFromFile(at: path) + XCTAssertEqual(reimport.divesSkipped, 1) + + let settings = try diveService.getDeviceSettings(diveId: diveId) + XCTAssertEqual(settings.count, 1) + XCTAssertTrue(settings[0].isPrimary) + } + + func testReimportBackfillsDiveWithLegacyFingerprintOnly() throws { + // Dives from very old app versions have only dives.fingerprint, no + // dive_source_fingerprints row. Re-import must resolve them through + // the legacy column and still backfill settings. + let device = Device(model: "Petrel", serialNumber: "SN001", firmwareVersion: "93") + try diveService.saveDevice(device) + let legacyDive = Dive( + deviceId: device.id, startTimeUnix: 1718444400, endTimeUnix: 1718448000, + maxDepthM: 30.48, avgDepthM: 18.0, bottomTimeSec: 3600, + isCcr: false, decoRequired: false, + fingerprint: "1".data(using: .utf8) + ) + try diveService.saveDive(legacyDive) + + let path = try createShearwaterDB(dives: [ + ShearwaterTestDive(diveId: 1, diveDate: "2024-06-15 10:30:00", depthFt: 100, + durationSec: 3600, serial: "SN001", endGf99: 42.0), + ]) + let result = try importService.importFromFile(at: path) + + // Legacy dedup: skipped, not re-imported + XCTAssertEqual(result.divesImported, 0) + XCTAssertEqual(result.divesSkipped, 1) + XCTAssertEqual(result.divesBackfilled, 1) + + // Dive-level field backfilled through the legacy fingerprint path + let dive = try diveService.getDive(id: legacyDive.id) + XCTAssertEqual(dive?.endGf99, 42.0) + + // Settings row created for the legacy dive's device + let settings = try diveService.getDeviceSettings(diveId: legacyDive.id) + XCTAssertEqual(settings.count, 1) + XCTAssertEqual(settings[0].deviceId, device.id) + XCTAssertTrue(settings[0].isPrimary) + } + // MARK: - New Tests: Metadata Import func testImportNotes() throws { diff --git a/apple/DivelogCore/Tests/SplitDiveTests.swift b/apple/DivelogCore/Tests/SplitDiveTests.swift index a7a6549..f1b4e42 100644 --- a/apple/DivelogCore/Tests/SplitDiveTests.swift +++ b/apple/DivelogCore/Tests/SplitDiveTests.swift @@ -115,6 +115,47 @@ final class SplitDiveTests: XCTestCase { XCTAssertEqual(originalDive?.maxDepthM ?? 0, 30.0, accuracy: 0.1) } + func testSplitMovesDeviceSettingsToNewDive() throws { + let deviceA = Device(model: "Perdix", serialNumber: "A-1234", firmwareVersion: "93") + let deviceB = Device(model: "Petrel", serialNumber: "B-5678", firmwareVersion: "93") + let diveId = try createMergedDive(deviceA: deviceA, deviceB: deviceB) + + // Merged dive has one settings row per device + XCTAssertEqual(try diveService.getDeviceSettings(diveId: diveId).count, 2) + + let result = try importService.splitDive(diveId: diveId, deviceId: deviceB.id) + + // B's settings row moves to the new dive and becomes primary + let newSettings = try diveService.getDeviceSettings(diveId: result.newDiveId) + XCTAssertEqual(newSettings.count, 1) + XCTAssertEqual(newSettings[0].deviceId, deviceB.id) + XCTAssertTrue(newSettings[0].isPrimary) + + // Original keeps only A's row + let originalSettings = try diveService.getDeviceSettings(diveId: diveId) + XCTAssertEqual(originalSettings.count, 1) + XCTAssertEqual(originalSettings[0].deviceId, deviceA.id) + } + + func testSplitPrimaryDevicePromotesRemainingSettings() throws { + let deviceA = Device(model: "Perdix", serialNumber: "A-1234", firmwareVersion: "93") + let deviceB = Device(model: "Petrel", serialNumber: "B-5678", firmwareVersion: "93") + let diveId = try createMergedDive(deviceA: deviceA, deviceB: deviceB) + + // Split out device A (the primary) — B's row on the original gets promoted + let result = try importService.splitDive(diveId: diveId, deviceId: deviceA.id) + + let newSettings = try diveService.getDeviceSettings(diveId: result.newDiveId) + XCTAssertEqual(newSettings.count, 1) + XCTAssertEqual(newSettings[0].deviceId, deviceA.id) + XCTAssertTrue(newSettings[0].isPrimary) + + let originalSettings = try diveService.getDeviceSettings(diveId: diveId) + XCTAssertEqual(originalSettings.count, 1) + XCTAssertEqual(originalSettings[0].deviceId, deviceB.id) + XCTAssertTrue(originalSettings[0].isPrimary) + } + func testSplitDuplicatesGasMixes() throws { let deviceA = Device(model: "Perdix", serialNumber: "A-1234", firmwareVersion: "93") let deviceB = Device(model: "Petrel", serialNumber: "B-5678", firmwareVersion: "93")