feat: implement Haptika PPG and Watch V1
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user