From 99939568872557d578eadcc7926bbfb7e6501bbb Mon Sep 17 00:00:00 2001 From: Fanboynz Date: Sat, 22 Aug 2026 22:54:59 +1200 Subject: [PATCH] fix: round the imported Apple step total on Apple, matching Android (#1535 follow-up) Found reviewing #1535. The same Apple Health export produced a different stored step count on each platform: Apple steps: d.steps.map { Int($0) } truncates Android steps = d.steps?.let { Math.round(it).toInt() } rounds A fractional daily total is reachable: the aggregator accumulates per source (`stepsBySource[...] += v`) before taking the winning source's figure, and nothing constrains a source to whole counts. On such a day Apple stored a total one lower than Android for the same import -- stored-data divergence on an imported column. Rounding is the side to converge on, and this is an oversight rather than a policy: every other count and rate converted on the SAME rows already rounds (avgHr, maxHr, walkingHr, restingHr all use `Int($0.rounded())`), and Android rounds throughout. `steps` was the only field on the row that did not. Both conversion sites now route through one `stepsInt` helper. Extracted because the mapping lived inside a file-IO function and had no test seam at all -- the same shape #1535 used for `dailyMetricRow`, so a test exercises the production conversion instead of a copy of it. Swift's `.rounded()` is half-away-from-zero and Kotlin's `Math.round` is half-up. They differ only for negatives, which a step count cannot be, and the test pins that agreement rather than assuming it. No Android change: it already rounds. No backfill -- this corrects future imports, and re-importing an affected export now stores the same value both platforms would. Verified: doc lint clean; app-build for the app-target Swift -- see the PR. --- Strand/Data/AppleHealthImport.swift | 19 +++++++++++-- StrandTests/AppleStepsRoundingTests.swift | 34 +++++++++++++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) create mode 100644 StrandTests/AppleStepsRoundingTests.swift 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) + } +}