Files
haptika/ios/HaptikaWatchTests/WatchCoreTests.swift
2026-07-31 16:20:10 +02:00

127 lines
5.5 KiB
Swift

import XCTest
@testable import HaptikaWatch
final class WatchCoreTests: XCTestCase {
private let device = WatchDeviceIdentityV1(
id: "watch-series-10-test",
name: "Apple Watch de test",
model: "Watch7,8",
systemVersion: "11.0"
)
func testValidationAcceptsEightSamplesCoveringFortyFiveSeconds() {
let start = Date(timeIntervalSince1970: 1_700_000_000)
let samples = (0..<8).map { index in
WatchHeartRateSampleV1(date: start.addingTimeInterval(Double(index) * 7), bpm: 70 + Double(index % 3))
}
let result = WatchMeasurementValidator.validate(samples: samples)
XCTAssertTrue(result.isValid)
XCTAssertEqual(result.validSamples.count, 8)
XCTAssertEqual(result.coverageSeconds, 49, accuracy: 0.001)
}
func testValidationRejectsOutOfRangeAndLongSilence() {
let start = Date(timeIntervalSince1970: 1_700_000_000)
let offsets: [TimeInterval] = [0, 5, 10, 15, 20, 25, 45, 50, 55]
var samples = offsets.map { WatchHeartRateSampleV1(date: start.addingTimeInterval($0), bpm: 72) }
samples.append(.init(date: start.addingTimeInterval(56), bpm: 221))
let result = WatchMeasurementValidator.validate(samples: samples)
XCTAssertFalse(result.isValid)
XCTAssertTrue(result.invalidReasons.contains(.staleData))
XCTAssertEqual(result.validSamples.count, 9)
}
func testBaselineMaturesAtFiveAndTenComparableMeasurements() {
let five = (0..<5).map { measurement(index: $0, bpm: 70 + Double($0 % 2)) }
XCTAssertEqual(WatchBaselineSnapshot(measurements: five).maturity, .provisional)
let ten = (0..<10).map { measurement(index: $0, bpm: 70 + Double($0 % 2)) }
XCTAssertEqual(WatchBaselineSnapshot(measurements: ten).maturity, .personalized)
XCTAssertEqual(WatchBaselineSnapshot(measurements: ten).compare(bpm: 71), .usual)
XCTAssertEqual(WatchBaselineSnapshot(measurements: ten).compare(bpm: 90), .clearlyDifferent)
}
func testBaselineExcludesLowQualityAndNonRestMeasurements() {
var lowQuality = measurement(index: 0, bpm: 70)
lowQuality.quality = 0.64
var afterExercise = measurement(index: 1, bpm: 71)
afterExercise.context.recentExercise = true
XCTAssertEqual(WatchBaselineSnapshot(measurements: [lowQuality, afterExercise]).comparableMeasurements, 0)
}
func testPhaseEngineMovesThroughBoxBreathing() throws {
let protocolValue = try XCTUnwrap(WatchProtocolCatalog.protocolWithID("box4444"))
XCTAssertEqual(RegulationPhaseEngine.position(in: protocolValue, elapsedSeconds: 1)?.phase.kind, .inhale)
XCTAssertEqual(RegulationPhaseEngine.position(in: protocolValue, elapsedSeconds: 5)?.phase.kind, .hold)
XCTAssertEqual(RegulationPhaseEngine.position(in: protocolValue, elapsedSeconds: 9)?.phase.kind, .exhale)
XCTAssertEqual(RegulationPhaseEngine.position(in: protocolValue, elapsedSeconds: 13)?.phase.kind, .hold)
}
func testEnvelopeEncodesPayloadAsJSONObject() throws {
let summary = measurement(index: 0, bpm: 72)
let envelope = try WatchSyncEnvelopeV1(
eventID: UUID(uuidString: "11111111-1111-4111-8111-111111111111")!,
deviceID: device.id,
sequence: 42,
kind: .measurementUpsert,
payload: summary
)
let data = try WatchCoding.encoder.encode(envelope)
let object = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any])
XCTAssertEqual(object["schemaVersion"] as? Int, 1)
XCTAssertNotNil(object["payload"] as? [String: Any])
XCTAssertNil(object["payload"] as? String, "Payload must not be a base64 Data string")
let decoded = try WatchCoding.decoder.decode(WatchSyncEnvelopeV1.self, from: data)
let decodedSummary = try decoded.decodePayload(WatchMeasurementSummaryV1.self)
XCTAssertEqual(decodedSummary.id, summary.id)
}
func testResourceArbiterExcludesHapticsDuringMeasurement() async throws {
let arbiter = WatchResourceArbiter()
try await arbiter.acquire(.heartRateMeasurement)
do {
try await arbiter.acquire(.hapticRegulation)
XCTFail("Second resource must be rejected")
} catch let error as WatchResourceError {
XCTAssertEqual(error, .busy(.heartRateMeasurement))
}
try await arbiter.release(.heartRateMeasurement)
try await arbiter.acquire(.hapticRegulation)
}
@MainActor
func testStoreCapsCanonicalHistoryButKeepsOutbox() throws {
let suite = "HaptikaWatchTests.\(UUID().uuidString)"
let defaults = try XCTUnwrap(UserDefaults(suiteName: suite))
defer { defaults.removePersistentDomain(forName: suite) }
let store = WatchStore(defaults: defaults)
for index in 0..<35 {
let value = measurement(index: index, bpm: 70)
let envelope = try WatchSyncEnvelopeV1(deviceID: device.id, sequence: UInt64(index), kind: .measurementUpsert, payload: value)
store.upsert(value, enqueue: envelope)
}
XCTAssertEqual(store.measurements.count, 30)
XCTAssertEqual(store.outbox.count, 35)
}
private func measurement(index: Int, bpm: Double) -> WatchMeasurementSummaryV1 {
let start = Date(timeIntervalSince1970: 1_700_000_000 + Double(index * 100))
let samples = (0..<8).map { sampleIndex in
WatchHeartRateSampleV1(date: start.addingTimeInterval(Double(sampleIndex) * 7), bpm: bpm)
}
return .init(
startedAt: start,
endedAt: start.addingTimeInterval(60),
device: device,
currentBPM: bpm,
averageBPM: bpm,
minimumBPM: bpm,
maximumBPM: bpm,
samples: samples,
coverageSeconds: 49,
quality: 0.82,
isValid: true,
context: .init(posture: .seated)
)
}
}