Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions Strand/Data/AppleHealthImport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()) },
Expand All @@ -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)
Expand Down
34 changes: 34 additions & 0 deletions StrandTests/AppleStepsRoundingTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading