127 lines
5.5 KiB
Swift
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)
|
|
)
|
|
}
|
|
}
|