diff --git a/Strand/Data/AppleHealthImport.swift b/Strand/Data/AppleHealthImport.swift index 82fdf8de0f..789d48123a 100644 --- a/Strand/Data/AppleHealthImport.swift +++ b/Strand/Data/AppleHealthImport.swift @@ -7,6 +7,21 @@ import StrandImport /// consensus. Populates appleDaily, dailyMetric, the generic metricSeries, and workouts. enum AppleHealthImport { + /// Apple's per-day aggregates arrive as `Double`, and a step total genuinely can be fractional: the + /// aggregator accumulates per source (`stepsBySource[…] += v`) before taking the winning source's + /// figure, and nothing constrains a source to whole counts. + /// + /// ROUNDS, for two reasons. It matches every other count/rate converted on the same rows — `avgHr`, + /// `maxHr`, `walkingHr`, `restingHr` all use `Int($0.rounded())` — and it matches the Kotlin twin's + /// `Math.round(it).toInt()`. Truncating made the same Apple export store a step total one LOWER on + /// Apple than on Android for any fractional day, which is stored-data divergence on an imported + /// column. (#1535 follow-up) + /// + /// The two rules agree here because step counts are non-negative: Swift's `.rounded()` is + /// half-away-from-zero and Kotlin's `Math.round` is half-up, which differ only for negatives. + static func stepsInt(_ v: Double) -> Int { Int(v.rounded()) } + + /// The Apple Health mapping revision, stamped into the Import test-mode parser line. Bump when this /// importer's aggregate->store mapping changes. static let importerVersion = 1 @@ -25,7 +40,7 @@ enum AppleHealthImport { // Apple-specific daily aggregates (steps/energy/vo2/hr/weight). let appleRows = daily.map { d in AppleDaily(day: d.day, - steps: d.steps.map { Int($0) }, + steps: d.steps.map(stepsInt), activeKcal: d.activeKcal, basalKcal: d.basalKcal, vo2max: d.vo2max, avgHr: d.avgHr.map { Int($0.rounded()) }, maxHr: d.maxHr.map { Int($0.rounded()) }, @@ -48,7 +63,7 @@ enum AppleHealthImport { // #89: Apple Health steps must land in DailyMetric.steps too — the sourced-daily // arbitration resolves "steps" via metricValue(d) = d.steps, so leaving it nil (the // pre-fix state) meant imported Apple steps never surfaced there. - steps: d.steps.map { Int($0) }, + steps: d.steps.map(stepsInt), avgSdnn: d.hrvSDNN) // Apple HRV is SDNN — mirror into the SDNN field } let dmWritten = try await store.upsertDailyMetrics(dm, deviceId: deviceId) diff --git a/StrandTests/AppleStepsRoundingTests.swift b/StrandTests/AppleStepsRoundingTests.swift new file mode 100644 index 0000000000..6ac1b0f952 --- /dev/null +++ b/StrandTests/AppleStepsRoundingTests.swift @@ -0,0 +1,34 @@ +import XCTest +@testable import Strand + +/// #1535 follow-up: the Apple step total must reach storage as the SAME integer on both platforms. +/// +/// Apple's per-day figures arrive as `Double` and a step total genuinely can be fractional — the +/// aggregator accumulates per source before the winning source's figure is taken, and nothing constrains +/// a source to whole counts. Apple truncated where Android rounded, so one fractional day stored a total +/// one lower on Apple than on Android from the same export. +/// +/// Pins the rule rather than the symptom: these are the values Kotlin's `Math.round(it).toInt()` produces +/// for the same inputs. +final class AppleStepsRoundingTests: XCTestCase { + + /// A whole total is unchanged — the common case must not move. + func testWholeTotalsAreUnchanged() { + XCTAssertEqual(AppleHealthImport.stepsInt(10_432), 10_432) + XCTAssertEqual(AppleHealthImport.stepsInt(0), 0) + } + + /// The regression: truncation lost a step here, so Apple stored 10432 against Android's 10433. + func testAFractionalTotalRoundsRatherThanTruncating() { + XCTAssertEqual(AppleHealthImport.stepsInt(10_432.6), 10_433) + XCTAssertEqual(AppleHealthImport.stepsInt(10_432.4), 10_432) + } + + /// The half case, where the two languages' rules could have disagreed. Swift's `.rounded()` is + /// half-away-from-zero and Kotlin's `Math.round` is half-up; they agree for the non-negative values a + /// step count can take, and this pins that agreement rather than assuming it. + func testTheHalfCaseMatchesTheKotlinRule() { + XCTAssertEqual(AppleHealthImport.stepsInt(10_432.5), 10_433) + XCTAssertEqual(AppleHealthImport.stepsInt(0.5), 1) + } +}