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) ) } }