HealthProbe / HealthProbeTests / SQLiteHealthArchiveStoreTests.swift
Newer Older
856 lines | 40.991kb
Bogdan Timofte authored 2 weeks ago
1
import HealthKit
2
import SQLite3
3
import XCTest
4
@testable import HealthProbe
5

            
6
final class SQLiteHealthArchiveStoreTests: XCTestCase {
Bogdan Timofte authored 2 weeks ago
7
    private final class AsyncResultBox<T>: @unchecked Sendable {
8
        var result: Result<T, Error>?
9
    }
10

            
Bogdan Timofte authored 2 weeks ago
11
    private var temporaryDirectory: URL!
12

            
13
    override func setUpWithError() throws {
14
        temporaryDirectory = FileManager.default.temporaryDirectory
15
            .appending(path: "HealthProbeTests-\(UUID().uuidString)", directoryHint: .isDirectory)
16
        try FileManager.default.createDirectory(at: temporaryDirectory, withIntermediateDirectories: true)
17
    }
18

            
19
    override func tearDownWithError() throws {
20
        if let temporaryDirectory {
21
            try? FileManager.default.removeItem(at: temporaryDirectory)
22
        }
23
        temporaryDirectory = nil
24
    }
25

            
26
    func testFreshArchiveInitializesSchemaAndPassesIntegrity() async throws {
27
        let store = SQLiteHealthArchiveStore(databaseURL: databaseURL())
28

            
29
        let report = try await store.checkIntegrity()
30
        let records = try await store.records(for: HealthArchiveRecordRequest(
31
            sampleTypeIdentifier: HKQuantityTypeIdentifier.stepCount.rawValue
32
        ))
33

            
34
        XCTAssertTrue(report.passed)
35
        XCTAssertEqual(report.schemaVersion, 2)
36
        XCTAssertEqual(report.sqliteIntegrityStatus, "ok")
37
        XCTAssertEqual(report.foreignKeyIssueCount, 0)
38
        XCTAssertTrue(report.missingTableNames.isEmpty)
39
        XCTAssertTrue(report.requiredTableNames.contains("sample_visibility_ranges"))
40
        XCTAssertTrue(report.requiredTableNames.contains("daily_type_aggregates"))
41
        XCTAssertTrue(records.isEmpty)
42
    }
43

            
Bogdan Timofte authored 5 days ago
44
    func testTypeCaptureStateRoundTripsAnchorAndSummary() async throws {
45
        let url = databaseURL()
46
        let store = SQLiteHealthArchiveStore(databaseURL: url)
47
        let observationID = try await store.beginObservation(
48
            observedAt: Date(timeIntervalSince1970: 2_000),
49
            triggerReason: "manual",
50
            selectedTypeSetHash: "selected-types"
51
        )
52
        let expected = HealthArchiveTypeCaptureState(
53
            sampleTypeIdentifier: HKQuantityTypeIdentifier.stepCount.rawValue,
54
            count: 42,
55
            contentHash: "content-hash",
56
            earliestDate: Date(timeIntervalSince1970: 1_000),
57
            latestDate: Date(timeIntervalSince1970: 1_500),
58
            yearlyCounts: [2026: 42],
59
            anchorData: Data([0xde, 0xad, 0xbe, 0xef])
60
        )
61

            
62
        try await store.upsertTypeCaptureState(expected, observationID: observationID)
63
        let actual = try await store.typeCaptureState(sampleTypeIdentifier: HKQuantityTypeIdentifier.stepCount.rawValue)
64
        let report = try await store.checkIntegrity()
65

            
66
        XCTAssertEqual(actual, HealthArchiveTypeCaptureState(
67
            sampleTypeIdentifier: expected.sampleTypeIdentifier,
68
            observationID: observationID,
69
            count: expected.count,
70
            contentHash: expected.contentHash,
71
            earliestDate: expected.earliestDate,
72
            latestDate: expected.latestDate,
73
            yearlyCounts: expected.yearlyCounts,
74
            anchorData: expected.anchorData
75
        ))
76
        XCTAssertTrue(report.passed)
77
        XCTAssertEqual(try countRows(in: "type_capture_states", at: url), 1)
78
    }
79

            
Bogdan Timofte authored 2 weeks ago
80
    func testPrototypeArchiveIsResetAndReinitializedAsV2() async throws {
81
        let url = databaseURL()
82
        try createPrototypeDatabase(at: url)
83
        let store = SQLiteHealthArchiveStore(databaseURL: url)
84

            
85
        let report = try await store.checkIntegrity()
86

            
87
        XCTAssertTrue(report.passed)
88
        XCTAssertEqual(report.schemaVersion, 2)
89
        XCTAssertTrue(report.missingTableNames.isEmpty)
90
    }
91

            
92
    func testRepeatedSamplePageDoesNotDuplicateIdentityOrVersion() async throws {
93
        let url = databaseURL()
94
        let store = SQLiteHealthArchiveStore(databaseURL: url)
95
        let sample = makeStepCountSample()
96

            
97
        let firstWrite = try await store.upsertSamples([sample], observedAt: Date(timeIntervalSince1970: 2_000))
98
        let secondWrite = try await store.upsertSamples([sample], observedAt: Date(timeIntervalSince1970: 2_060))
99
        let records = try await store.records(for: HealthArchiveRecordRequest(
100
            sampleTypeIdentifier: sample.sampleType.identifier
101
        ))
102
        let report = try await store.checkIntegrity()
103
        let versionDebugRows = try sampleVersionDebugRows(at: url)
Bogdan Timofte authored a week ago
104
        let visibilityDebugRows = try visibilityRangeDebugRows(at: url)
Bogdan Timofte authored 2 weeks ago
105

            
106
        XCTAssertEqual(firstWrite.insertedCount, 1)
107
        XCTAssertEqual(firstWrite.updatedCount, 0)
108
        XCTAssertEqual(firstWrite.unchangedCount, 0)
109
        XCTAssertEqual(try countRows(in: "samples", at: url), 1)
110
        XCTAssertEqual(try countRows(in: "sample_versions", at: url), 1, versionDebugRows)
Bogdan Timofte authored 6 days ago
111
        XCTAssertEqual(try countRows(in: "sample_observation_events", at: url), 1)
112
        XCTAssertEqual(try countRows(in: "sample_observation_events WHERE event_kind = 'appeared'", at: url), 1)
113
        XCTAssertEqual(try countRows(in: "sample_observation_events WHERE event_kind = 'verified'", at: url), 0)
Bogdan Timofte authored a week ago
114
        XCTAssertEqual(try countRows(in: "sample_visibility_ranges", at: url), 1, visibilityDebugRows)
Bogdan Timofte authored 2 weeks ago
115
        XCTAssertEqual(try countRows(in: "source_revisions", at: url), 1)
Bogdan Timofte authored 2 weeks ago
116
        XCTAssertFalse(try tableExists("archive_samples", at: url))
Bogdan Timofte authored 2 weeks ago
117
        XCTAssertEqual(secondWrite.insertedCount, 0)
118
        XCTAssertEqual(secondWrite.updatedCount, 0)
119
        XCTAssertEqual(secondWrite.unchangedCount, 1)
120
        XCTAssertEqual(records.count, 1)
121
        XCTAssertEqual(records.first?.displayValue, "42.0 count")
122
        XCTAssertTrue(report.passed)
123
    }
124

            
Bogdan Timofte authored 2 weeks ago
125
    func testVerificationUsesArchiveV2TablesWithoutLegacyMirror() async throws {
126
        let url = databaseURL()
127
        let store = SQLiteHealthArchiveStore(databaseURL: url)
128
        let sample = makeStepCountSample()
129

            
130
        _ = try await store.upsertSamples([sample], observedAt: Date(timeIntervalSince1970: 2_000))
131
        try await store.markVerification(sampleType: sample.sampleType, verifiedAt: Date(timeIntervalSince1970: 2_060))
132
        let observationIDs = try observationIDs(at: url)
133

            
134
        XCTAssertEqual(observationIDs.count, 2)
Bogdan Timofte authored 2 weeks ago
135
        XCTAssertEqual(try countRows(in: "observation_type_runs", at: url), 2)
136
        XCTAssertEqual(try countRows(in: "observation_type_runs WHERE observation_id = \(observationIDs[0]) AND inserted_event_count = 1", at: url), 1)
Bogdan Timofte authored 2 weeks ago
137
        XCTAssertEqual(try countRows(in: "observation_type_summaries WHERE observation_id = \(observationIDs[1]) AND visible_record_count = 1", at: url), 1)
Bogdan Timofte authored 5 days ago
138
        XCTAssertEqual(try countRows(in: "daily_type_aggregates WHERE observation_id = \(observationIDs[1]) AND visible_record_count = 1", at: url), 1)
Bogdan Timofte authored 2 weeks ago
139
        XCTAssertFalse(try tableExists("archive_samples", at: url))
140
    }
141

            
Bogdan Timofte authored 5 days ago
142
    func testUnchangedVerificationCopiesPreviousSummaryWithoutNewEvents() async throws {
143
        let url = databaseURL()
144
        let store = SQLiteHealthArchiveStore(databaseURL: url)
145
        let sample = makeStepCountSample()
146

            
147
        _ = try await store.upsertSamples([sample], observedAt: Date(timeIntervalSince1970: 2_000))
148
        try await store.markVerification(sampleType: sample.sampleType, verifiedAt: Date(timeIntervalSince1970: 2_060))
149
        let unchangedObservationID = try await store.beginObservation(
150
            observedAt: Date(timeIntervalSince1970: 2_120),
151
            triggerReason: "manual",
152
            selectedTypeSetHash: "selected-types"
153
        )
154
        try await store.markUnchangedVerification(
155
            sampleType: sample.sampleType,
156
            verifiedAt: Date(timeIntervalSince1970: 2_120),
157
            observationID: unchangedObservationID
158
        )
159
        try await store.finishObservation(
160
            observationID: unchangedObservationID,
161
            status: "completed",
162
            endedAt: Date(timeIntervalSince1970: 2_121)
163
        )
164

            
165
        XCTAssertEqual(try countRows(in: "sample_observation_events", at: url), 1)
166
        XCTAssertEqual(try countRows(in: "observation_type_runs WHERE observation_id = \(unchangedObservationID) AND inserted_event_count = 0 AND deleted_event_count = 0 AND verified_visible_count = 1", at: url), 1)
167
        XCTAssertEqual(try countRows(in: "observation_type_summaries WHERE observation_id = \(unchangedObservationID) AND visible_record_count = 1 AND appeared_count = 0 AND disappeared_count = 0 AND representation_changed_count = 0", at: url), 1)
168
        XCTAssertEqual(try countRows(in: "daily_type_aggregates WHERE observation_id = \(unchangedObservationID) AND visible_record_count = 1", at: url), 1)
169
        XCTAssertFalse(try tableExists("archive_samples", at: url))
170
    }
171

            
Bogdan Timofte authored 4 days ago
172
    func testChangedVerificationReplacesOnlyAffectedDailyAggregateBuckets() async throws {
173
        let url = databaseURL()
174
        let store = SQLiteHealthArchiveStore(databaseURL: url)
175
        let firstDaySample = makeStepCountSample(value: 10, start: 1_000)
176
        let secondDaySample = makeStepCountSample(value: 20, start: 90_000)
177
        let secondDayDeltaSample = makeStepCountSample(value: 30, start: 91_000)
178

            
179
        _ = try await store.upsertSamples(
180
            [firstDaySample, secondDaySample],
181
            observedAt: Date(timeIntervalSince1970: 95_000)
182
        )
183
        try await store.markVerification(
184
            sampleType: firstDaySample.sampleType,
185
            verifiedAt: Date(timeIntervalSince1970: 95_060)
186
        )
187
        let changedObservationID = try await store.beginObservation(
188
            observedAt: Date(timeIntervalSince1970: 96_000),
189
            triggerReason: "manual",
190
            selectedTypeSetHash: "selected-types"
191
        )
192

            
193
        _ = try await store.upsertSamples(
194
            [secondDayDeltaSample],
195
            observedAt: Date(timeIntervalSince1970: 96_000),
196
            observationID: changedObservationID
197
        )
198
        try await store.markVerification(
199
            sampleType: firstDaySample.sampleType,
200
            verifiedAt: Date(timeIntervalSince1970: 96_000),
201
            observationID: changedObservationID
202
        )
203
        try await store.finishObservation(
204
            observationID: changedObservationID,
205
            status: "completed",
206
            endedAt: Date(timeIntervalSince1970: 96_010)
207
        )
208

            
209
        XCTAssertEqual(
210
            try countRows(in: "daily_type_aggregates WHERE observation_id = \(changedObservationID)", at: url),
211
            2
212
        )
213
        XCTAssertEqual(
214
            try countRows(in: "daily_type_aggregates WHERE observation_id = \(changedObservationID) AND visible_record_count = 1", at: url),
215
            1
216
        )
217
        XCTAssertEqual(
218
            try countRows(in: "daily_type_aggregates WHERE observation_id = \(changedObservationID) AND visible_record_count = 2", at: url),
219
            1
220
        )
221
        XCTAssertEqual(
222
            try countRows(in: "observation_type_summaries WHERE observation_id = \(changedObservationID) AND visible_record_count = 3 AND appeared_count = 1", at: url),
223
            1
224
        )
225
    }
226

            
Bogdan Timofte authored 2 weeks ago
227
    func testDiffSummaryAndRecordsBetweenObservationsUseSQLVisibility() async throws {
228
        let url = databaseURL()
229
        let store = SQLiteHealthArchiveStore(databaseURL: url)
230
        let firstSample = makeStepCountSample(value: 42, start: 1_000)
231
        let secondSample = makeStepCountSample(value: 7, start: 2_000)
232
        let typeIdentifier = HKQuantityTypeIdentifier.stepCount.rawValue
233

            
234
        _ = try await store.upsertSamples([firstSample], observedAt: Date(timeIntervalSince1970: 3_000))
235
        _ = try await store.upsertSamples([firstSample, secondSample], observedAt: Date(timeIntervalSince1970: 3_060))
236
        try await store.recordDisappearance(
237
            sampleUUIDHash: HashService.sampleUUIDHash(firstSample.uuid.uuidString),
238
            sampleTypeIdentifier: typeIdentifier,
239
            observedMissingAt: Date(timeIntervalSince1970: 3_120)
240
        )
241
        let observationIDs = try observationIDs(at: url)
242
        XCTAssertEqual(observationIDs.count, 3)
243

            
244
        let appearedSummary = try await store.diffSummary(HealthArchiveDiffRequest(
245
            fromObservationID: observationIDs[0],
246
            toObservationID: observationIDs[1],
247
            sampleTypeIdentifier: typeIdentifier
248
        ))
249
        let appearedRecords = try await store.diffRecords(HealthArchiveDiffRecordRequest(
250
            fromObservationID: observationIDs[0],
251
            toObservationID: observationIDs[1],
252
            sampleTypeIdentifier: typeIdentifier,
253
            kind: .appeared,
254
            limit: 10
255
        ))
256
        let disappearedSummary = try await store.diffSummary(HealthArchiveDiffRequest(
257
            fromObservationID: observationIDs[1],
258
            toObservationID: observationIDs[2],
259
            sampleTypeIdentifier: typeIdentifier
260
        ))
261
        let disappearedRecords = try await store.diffRecords(HealthArchiveDiffRecordRequest(
262
            fromObservationID: observationIDs[1],
263
            toObservationID: observationIDs[2],
264
            sampleTypeIdentifier: typeIdentifier,
265
            kind: .disappeared,
266
            limit: 10
267
        ))
268

            
269
        XCTAssertEqual(appearedSummary.appearedCount, 1)
270
        XCTAssertEqual(appearedSummary.disappearedCount, 0)
271
        XCTAssertEqual(appearedSummary.representationChangedCount, 0)
272
        XCTAssertEqual(appearedRecords.map(\.displayValue), ["7.0 count"])
273
        XCTAssertEqual(disappearedSummary.appearedCount, 0)
274
        XCTAssertEqual(disappearedSummary.disappearedCount, 1)
275
        XCTAssertEqual(disappearedSummary.representationChangedCount, 0)
276
        XCTAssertEqual(disappearedRecords.map(\.displayValue), ["42.0 count"])
277
        XCTAssertNotNil(disappearedRecords.first?.disappearedAt)
Bogdan Timofte authored 2 weeks ago
278

            
279
        let exportRequest = HealthArchiveReportRequest(
280
            reportID: UUID(),
281
            title: "Disappeared Step Count Export",
282
            typeIdentifierFilter: typeIdentifier,
283
            diffFromObservationID: observationIDs[1],
284
            diffToObservationID: observationIDs[2],
285
            diffKind: .disappeared
286
        )
287
        let exportPreview = try await store.exportPreview(exportRequest)
288
        let exportURL = try await store.exportReport(exportRequest)
289

            
290
        XCTAssertEqual(exportPreview.estimatedRecordCount, 1)
291
        XCTAssertTrue(FileManager.default.fileExists(atPath: exportURL.path))
292
        XCTAssertEqual(try countRows(in: "export_manifests WHERE record_count = 1 AND from_observation_id = \(observationIDs[1]) AND to_observation_id = \(observationIDs[2])", at: url), 1)
293
    }
294

            
295
    func testExportPreviewAndReportUseSQLiteManifest() async throws {
296
        let url = databaseURL()
297
        let store = SQLiteHealthArchiveStore(databaseURL: url)
298
        let typeIdentifier = HKQuantityTypeIdentifier.stepCount.rawValue
299
        let samples = [
300
            makeStepCountSample(value: 42, start: 1_000),
301
            makeStepCountSample(value: 7, start: 2_000)
302
        ]
303

            
304
        _ = try await store.upsertSamples(samples, observedAt: Date(timeIntervalSince1970: 3_000))
305

            
306
        let request = HealthArchiveReportRequest(
307
            reportID: UUID(),
308
            title: "Step Count Export",
309
            typeIdentifierFilter: typeIdentifier
310
        )
311
        let preview = try await store.exportPreview(request)
312
        let exportURL = try await store.exportReport(request)
313

            
314
        XCTAssertEqual(preview.estimatedRecordCount, 2)
315
        XCTAssertTrue(FileManager.default.fileExists(atPath: exportURL.path))
316
        XCTAssertEqual(try countRows(in: "export_manifests", at: url), 1)
317
        XCTAssertEqual(try countRows(in: "export_manifests WHERE record_count = 2", at: url), 1)
318

            
319
        let cache = try CoreDataArchiveCacheStore(inMemory: true)
320
        let rebuild = try cache.rebuild(fromArchiveAt: url)
321
        XCTAssertEqual(rebuild.exportManifestRows, 1)
Bogdan Timofte authored 2 weeks ago
322
    }
323

            
Bogdan Timofte authored 2 weeks ago
324
    func testGroupedObservationKeepsPageWritesDeletesAndVerificationTogether() async throws {
325
        let url = databaseURL()
326
        let store = SQLiteHealthArchiveStore(databaseURL: url)
327
        let firstSample = makeStepCountSample(value: 42, start: 1_000)
328
        let secondSample = makeStepCountSample(value: 7, start: 2_000)
329
        let typeIdentifier = HKQuantityTypeIdentifier.stepCount.rawValue
330

            
331
        _ = try await store.upsertSamples([firstSample], observedAt: Date(timeIntervalSince1970: 3_000))
332
        let observationID = try await store.beginObservation(
333
            observedAt: Date(timeIntervalSince1970: 3_060),
334
            triggerReason: "manual",
335
            selectedTypeSetHash: "selected-types"
336
        )
337
        _ = try await store.upsertSamples(
338
            [secondSample],
339
            observedAt: Date(timeIntervalSince1970: 3_060),
340
            observationID: observationID
341
        )
342
        try await store.recordDisappearance(
343
            sampleUUIDHash: HashService.sampleUUIDHash(firstSample.uuid.uuidString),
344
            sampleTypeIdentifier: typeIdentifier,
345
            observedMissingAt: Date(timeIntervalSince1970: 3_060),
346
            observationID: observationID
347
        )
Bogdan Timofte authored a week ago
348
        XCTAssertEqual(
349
            try countRows(
350
                in: "observation_type_summaries WHERE observation_id = \(observationID)",
351
                at: url
352
            ),
353
            0
354
        )
355
        XCTAssertEqual(
356
            try countRows(
357
                in: "daily_type_aggregates WHERE observation_id = \(observationID)",
358
                at: url
359
            ),
360
            0
361
        )
Bogdan Timofte authored 2 weeks ago
362
        try await store.markVerification(
363
            sampleType: secondSample.sampleType,
364
            verifiedAt: Date(timeIntervalSince1970: 3_060),
365
            observationID: observationID
366
        )
367
        try await store.finishObservation(
368
            observationID: observationID,
369
            status: "completed",
370
            endedAt: Date(timeIntervalSince1970: 3_070)
371
        )
372

            
373
        let observationIDs = try observationIDs(at: url)
374
        let summary = try await store.diffSummary(HealthArchiveDiffRequest(
375
            fromObservationID: observationIDs[0],
376
            toObservationID: observationID,
377
            sampleTypeIdentifier: typeIdentifier
378
        ))
379

            
380
        XCTAssertEqual(observationIDs.count, 2)
381
        XCTAssertEqual(summary.appearedCount, 1)
382
        XCTAssertEqual(summary.disappearedCount, 1)
383
        XCTAssertEqual(try countRows(in: "observations WHERE id = \(observationID) AND status = 'completed' AND selected_type_set_hash = 'selected-types'", at: url), 1)
384
        XCTAssertEqual(try countRows(in: "sample_observation_events WHERE observation_id = \(observationID)", at: url), 2)
385
        XCTAssertEqual(try countRows(in: "observation_type_runs WHERE observation_id = \(observationID) AND inserted_event_count = 1 AND deleted_event_count = 1 AND verified_visible_count = 1 AND status = 'completed'", at: url), 1)
Bogdan Timofte authored a week ago
386
        XCTAssertEqual(try countRows(in: "observation_type_summaries WHERE observation_id = \(observationID) AND visible_record_count = 1", at: url), 1)
387
        XCTAssertGreaterThan(try countRows(in: "daily_type_aggregates WHERE observation_id = \(observationID)", at: url), 0)
Bogdan Timofte authored 2 weeks ago
388
    }
389

            
Bogdan Timofte authored a week ago
390
    func testGroupedObservationCanBatchDeletedObjectsInSingleRun() async throws {
391
        let url = databaseURL()
392
        let store = SQLiteHealthArchiveStore(databaseURL: url)
393
        let samples = [
394
            makeStepCountSample(value: 42, start: 1_000),
395
            makeStepCountSample(value: 7, start: 2_000),
396
            makeStepCountSample(value: 9, start: 3_000)
397
        ]
398
        let typeIdentifier = HKQuantityTypeIdentifier.stepCount.rawValue
399

            
400
        _ = try await store.upsertSamples(samples, observedAt: Date(timeIntervalSince1970: 4_000))
401
        let observationID = try await store.beginObservation(
402
            observedAt: Date(timeIntervalSince1970: 4_060),
403
            triggerReason: "manual",
404
            selectedTypeSetHash: "selected-types"
405
        )
406
        let deletedCount = try await store.recordDisappearances(
407
            sampleUUIDHashes: samples.map { HashService.sampleUUIDHash($0.uuid.uuidString) },
408
            sampleTypeIdentifier: typeIdentifier,
409
            observedMissingAt: Date(timeIntervalSince1970: 4_060),
410
            observationID: observationID
411
        )
412
        try await store.markVerification(
413
            sampleType: samples[0].sampleType,
414
            verifiedAt: Date(timeIntervalSince1970: 4_060),
415
            observationID: observationID
416
        )
417

            
418
        XCTAssertEqual(deletedCount, 3)
419
        XCTAssertEqual(try countRows(in: "sample_observation_events WHERE observation_id = \(observationID) AND event_kind = 'disappeared'", at: url), 3)
420
        XCTAssertEqual(try countRows(in: "observation_type_runs WHERE observation_id = \(observationID) AND deleted_event_count = 3 AND status = 'completed'", at: url), 1)
421
    }
422

            
Bogdan Timofte authored 2 weeks ago
423
    func testLargeSyntheticDiffReturnsCountsAndBoundedPages() async throws {
424
        let url = databaseURL()
425
        let store = SQLiteHealthArchiveStore(databaseURL: url)
426
        let typeIdentifier = HKQuantityTypeIdentifier.stepCount.rawValue
427
        let initialCount = 1_200
428
        let appearedCount = 180
429
        let disappearedCount = 160
430
        let pageSize = 25
431
        let initialSamples = makeStepCountSamples(count: initialCount, startIndex: 0)
432
        let appearedSamples = makeStepCountSamples(count: appearedCount, startIndex: initialCount)
433

            
434
        _ = try await store.upsertSamples(initialSamples, observedAt: Date(timeIntervalSince1970: 1_000_000))
435
        _ = try await store.upsertSamples(initialSamples + appearedSamples, observedAt: Date(timeIntervalSince1970: 1_000_060))
436
        for (offset, sample) in initialSamples.prefix(disappearedCount).enumerated() {
437
            try await store.recordDisappearance(
438
                sampleUUIDHash: HashService.sampleUUIDHash(sample.uuid.uuidString),
439
                sampleTypeIdentifier: typeIdentifier,
440
                observedMissingAt: Date(timeIntervalSince1970: 1_000_120 + Double(offset))
441
            )
442
        }
443

            
444
        let observationIDs = try observationIDs(at: url)
445
        let firstObservationID = try XCTUnwrap(observationIDs.first)
446
        let lastObservationID = try XCTUnwrap(observationIDs.last)
447
        let queryStartedAt = Date()
448
        let summary = try await store.diffSummary(HealthArchiveDiffRequest(
449
            fromObservationID: firstObservationID,
450
            toObservationID: lastObservationID,
451
            sampleTypeIdentifier: typeIdentifier
452
        ))
453
        let firstPage = try await store.diffRecords(HealthArchiveDiffRecordRequest(
454
            fromObservationID: firstObservationID,
455
            toObservationID: lastObservationID,
456
            sampleTypeIdentifier: typeIdentifier,
457
            kind: .appeared,
458
            limit: pageSize
459
        ))
460
        let firstPageLastRecord = try XCTUnwrap(firstPage.last)
461
        let secondPage = try await store.diffRecords(HealthArchiveDiffRecordRequest(
462
            fromObservationID: firstObservationID,
463
            toObservationID: lastObservationID,
464
            sampleTypeIdentifier: typeIdentifier,
465
            kind: .appeared,
466
            afterCursor: RecordCursor(
467
                startDate: firstPageLastRecord.startDate,
468
                strictFingerprint: firstPageLastRecord.strictFingerprint
469
            ),
470
            limit: pageSize
471
        ))
472
        let queryElapsedSeconds = Date().timeIntervalSince(queryStartedAt)
473

            
474
        XCTAssertEqual(summary.appearedCount, appearedCount)
475
        XCTAssertEqual(summary.disappearedCount, disappearedCount)
476
        XCTAssertEqual(summary.representationChangedCount, 0)
477
        XCTAssertEqual(firstPage.count, pageSize)
478
        XCTAssertEqual(secondPage.count, pageSize)
479
        XCTAssertTrue(Set(firstPage.map(\.id)).isDisjoint(with: Set(secondPage.map(\.id))))
480
        XCTAssertLessThan(queryElapsedSeconds, 10)
481
    }
482

            
Bogdan Timofte authored 2 weeks ago
483
    func testLargeSyntheticDiffQueryMetrics() throws {
484
        let url = databaseURL()
485
        let store = SQLiteHealthArchiveStore(databaseURL: url)
486
        let typeIdentifier = HKQuantityTypeIdentifier.stepCount.rawValue
487
        let initialCount = 900
488
        let appearedCount = 120
489
        let disappearedCount = 100
490
        let pageSize = 25
491
        let initialSamples = makeStepCountSamples(count: initialCount, startIndex: 0)
492
        let appearedSamples = makeStepCountSamples(count: appearedCount, startIndex: initialCount)
493

            
494
        try waitForArchiveOperation {
495
            _ = try await store.upsertSamples(initialSamples, observedAt: Date(timeIntervalSince1970: 1_200_000))
496
            _ = try await store.upsertSamples(initialSamples + appearedSamples, observedAt: Date(timeIntervalSince1970: 1_200_060))
497
            for (offset, sample) in initialSamples.prefix(disappearedCount).enumerated() {
498
                try await store.recordDisappearance(
499
                    sampleUUIDHash: HashService.sampleUUIDHash(sample.uuid.uuidString),
500
                    sampleTypeIdentifier: typeIdentifier,
501
                    observedMissingAt: Date(timeIntervalSince1970: 1_200_120 + Double(offset))
502
                )
503
            }
504
        }
505

            
506
        let observationIDs = try observationIDs(at: url)
507
        let firstObservationID = try XCTUnwrap(observationIDs.first)
508
        let lastObservationID = try XCTUnwrap(observationIDs.last)
509
        let options = XCTMeasureOptions()
510
        options.iterationCount = 3
511

            
512
        measure(metrics: [XCTClockMetric(), XCTMemoryMetric()], options: options) {
513
            do {
514
                let result = try waitForArchiveOperation {
515
                    let summary = try await store.diffSummary(HealthArchiveDiffRequest(
516
                        fromObservationID: firstObservationID,
517
                        toObservationID: lastObservationID,
518
                        sampleTypeIdentifier: typeIdentifier
519
                    ))
520
                    let records = try await store.diffRecords(HealthArchiveDiffRecordRequest(
521
                        fromObservationID: firstObservationID,
522
                        toObservationID: lastObservationID,
523
                        sampleTypeIdentifier: typeIdentifier,
524
                        kind: .appeared,
525
                        limit: pageSize
526
                    ))
527
                    return (summary, records)
528
                }
529
                XCTAssertEqual(result.0.appearedCount, appearedCount)
530
                XCTAssertEqual(result.0.disappearedCount, disappearedCount)
531
                XCTAssertEqual(result.1.count, pageSize)
532
            } catch {
533
                XCTFail("Measured archive query failed: \(error)")
534
            }
535
        }
536
    }
537

            
Bogdan Timofte authored 2 weeks ago
538
    func testAggregateComparisonUsesMaterializedDailyAggregates() async throws {
539
        let url = databaseURL()
540
        let store = SQLiteHealthArchiveStore(databaseURL: url)
541
        let firstSample = makeStepCountSample(value: 42, start: 1_000)
542
        let secondSample = makeStepCountSample(value: 7, start: 2_000)
543
        let typeIdentifier = HKQuantityTypeIdentifier.stepCount.rawValue
544

            
545
        _ = try await store.upsertSamples([firstSample], observedAt: Date(timeIntervalSince1970: 3_000))
546
        _ = try await store.upsertSamples([firstSample, secondSample], observedAt: Date(timeIntervalSince1970: 3_060))
547
        let observationIDs = try observationIDs(at: url)
548
        XCTAssertEqual(observationIDs.count, 2)
549

            
550
        let rows = try await store.aggregateComparison(HealthArchiveAggregateComparisonRequest(
551
            fromObservationID: observationIDs[0],
552
            toObservationID: observationIDs[1],
553
            sampleTypeIdentifier: typeIdentifier,
554
            limit: 10
555
        ))
556

            
557
        XCTAssertEqual(rows.count, 1)
558
        XCTAssertEqual(rows.first?.sampleTypeIdentifier, typeIdentifier)
559
        XCTAssertEqual(rows.first?.fromVisibleRecordCount, 1)
560
        XCTAssertEqual(rows.first?.toVisibleRecordCount, 2)
561
        XCTAssertEqual(rows.first?.visibleRecordDelta, 1)
562
        XCTAssertEqual(rows.first?.fromValueSum, 42)
563
        XCTAssertEqual(rows.first?.toValueSum, 49)
564
        XCTAssertEqual(rows.first?.valueSumDelta, 7)
565
    }
566

            
Bogdan Timofte authored 2 weeks ago
567
    func testSourceProvenanceBreakdownReturnsVisibleSourceRows() async throws {
568
        let url = databaseURL()
569
        let store = SQLiteHealthArchiveStore(databaseURL: url)
570
        let sample = makeStepCountSample(value: 42, start: 1_000)
571
        let typeIdentifier = sample.sampleType.identifier
572

            
573
        _ = try await store.upsertSamples([sample], observedAt: Date(timeIntervalSince1970: 3_000))
574

            
575
        let rows = try await store.sourceProvenanceBreakdown(HealthArchiveSourceProvenanceRequest(
576
            visibleAtObservationID: nil,
577
            sampleTypeIdentifier: typeIdentifier,
578
            limit: 10
579
        ))
580

            
581
        XCTAssertEqual(rows.count, 1)
582
        XCTAssertEqual(rows.first?.sampleTypeIdentifier, typeIdentifier)
583
        XCTAssertEqual(rows.first?.visibleRecordCount, 1)
584
        XCTAssertEqual(rows.first?.valueSum, 42)
585
        XCTAssertEqual(rows.first?.sourceBundleIdentifier, sample.sourceRevision.source.bundleIdentifier)
586
    }
587

            
588
    func testConsolidationEvidenceClassifiesStableCountDropAsConsolidationLikely() async throws {
589
        let url = databaseURL()
590
        let store = SQLiteHealthArchiveStore(databaseURL: url)
591
        let typeIdentifier = HKQuantityTypeIdentifier.stepCount.rawValue
592

            
593
        let firstOldSample = makeStepCountSample(value: 10, start: 1_000, end: 1_500)
594
        let secondOldSample = makeStepCountSample(value: 20, start: 1_600, end: 2_000)
595
        let consolidatedSample = makeStepCountSample(value: 30, start: 1_000, end: 2_500)
596

            
597
        _ = try await store.upsertSamples([firstOldSample, secondOldSample], observedAt: Date(timeIntervalSince1970: 3_000))
598
        _ = try await store.upsertSamples([consolidatedSample], observedAt: Date(timeIntervalSince1970: 3_060))
599
        try await store.recordDisappearance(sampleUUIDHash: HashService.sampleUUIDHash(firstOldSample.uuid.uuidString), sampleTypeIdentifier: typeIdentifier, observedMissingAt: Date(timeIntervalSince1970: 3_120))
600
        try await store.recordDisappearance(sampleUUIDHash: HashService.sampleUUIDHash(secondOldSample.uuid.uuidString), sampleTypeIdentifier: typeIdentifier, observedMissingAt: Date(timeIntervalSince1970: 3_180))
601

            
602
        let observationIDs = try observationIDs(at: url)
603
        XCTAssertEqual(observationIDs.count, 4)
604

            
605
        let rows = try await store.consolidationEvidence(HealthArchiveConsolidationEvidenceRequest(
606
            fromObservationID: observationIDs[0],
607
            toObservationID: observationIDs[observationIDs.count - 1],
608
            sampleTypeIdentifier: typeIdentifier
609
        ))
610

            
611
        XCTAssertEqual(rows.count, 1)
612
        XCTAssertEqual(rows.first?.sampleTypeIdentifier, typeIdentifier)
613
        XCTAssertEqual(rows.first?.disappearedCount, 2)
614
        XCTAssertEqual(rows.first?.appearedCount, 1)
615
        XCTAssertEqual(rows.first?.fromVisibleRecordCount, 2)
616
        XCTAssertEqual(rows.first?.toVisibleRecordCount, 1)
617
        XCTAssertEqual(rows.first?.fromValueSum, 30)
618
        XCTAssertEqual(rows.first?.toValueSum, 30)
619
        XCTAssertEqual(rows.first?.label, "consolidation_likely")
620
        XCTAssertTrue(rows.first?.sourceCompatible == true)
621
    }
622

            
623
    func testConsolidationEvidenceClassifiesDenseStableShiftAsAggregateChanged() async throws {
624
        let url = databaseURL()
625
        let store = SQLiteHealthArchiveStore(databaseURL: url)
626
        let typeIdentifier = HKQuantityTypeIdentifier.stepCount.rawValue
627

            
628
        let firstOldSample = makeStepCountSample(value: 10, start: 1_000, end: 1_500)
629
        let secondOldSample = makeStepCountSample(value: 20, start: 5_000, end: 6_000)
630
        let denseSample = makeStepCountSample(value: 30, start: 1_000, end: 1_200)
631

            
632
        _ = try await store.upsertSamples([firstOldSample, secondOldSample], observedAt: Date(timeIntervalSince1970: 3_000))
633
        _ = try await store.upsertSamples([denseSample], observedAt: Date(timeIntervalSince1970: 3_060))
634
        try await store.recordDisappearance(sampleUUIDHash: HashService.sampleUUIDHash(firstOldSample.uuid.uuidString), sampleTypeIdentifier: typeIdentifier, observedMissingAt: Date(timeIntervalSince1970: 3_120))
635
        try await store.recordDisappearance(sampleUUIDHash: HashService.sampleUUIDHash(secondOldSample.uuid.uuidString), sampleTypeIdentifier: typeIdentifier, observedMissingAt: Date(timeIntervalSince1970: 3_180))
636

            
637
        let observationIDs = try observationIDs(at: url)
638
        XCTAssertEqual(observationIDs.count, 4)
639

            
640
        let rows = try await store.consolidationEvidence(HealthArchiveConsolidationEvidenceRequest(
641
            fromObservationID: observationIDs[0],
642
            toObservationID: observationIDs[observationIDs.count - 1],
643
            sampleTypeIdentifier: typeIdentifier
644
        ))
645

            
646
        XCTAssertEqual(rows.count, 1)
647
        XCTAssertEqual(rows.first?.label, "aggregate_changed")
648
        XCTAssertEqual(rows.first?.fromVisibleRecordCount, 2)
649
        XCTAssertEqual(rows.first?.toVisibleRecordCount, 1)
650
        XCTAssertEqual(rows.first?.fromValueSum, 30)
651
        XCTAssertEqual(rows.first?.toValueSum, 30)
652
    }
653

            
Bogdan Timofte authored 2 weeks ago
654
    private func databaseURL() -> URL {
655
        temporaryDirectory.appending(path: "Archive.sqlite")
656
    }
657

            
658
    private func createPrototypeDatabase(at url: URL) throws {
659
        var db: OpaquePointer?
660
        guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_CREATE | SQLITE_OPEN_READWRITE | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
661
            sqlite3_close(db)
662
            XCTFail("Could not create prototype database")
663
            return
664
        }
665
        defer { sqlite3_close(db) }
666
        let status = sqlite3_exec(db, "CREATE TABLE archive_samples (id TEXT PRIMARY KEY)", nil, nil, nil)
667
        XCTAssertEqual(status, SQLITE_OK)
668
    }
669

            
670
    private func makeStepCountSample() -> HKQuantitySample {
Bogdan Timofte authored 2 weeks ago
671
        makeStepCountSample(value: 42, start: 1_000)
672
    }
673

            
Bogdan Timofte authored 2 weeks ago
674
    private func makeStepCountSample(value: Double, start: TimeInterval, end: TimeInterval? = nil) -> HKQuantitySample {
Bogdan Timofte authored 2 weeks ago
675
        let quantityType = HKQuantityType.quantityType(forIdentifier: .stepCount)!
Bogdan Timofte authored 2 weeks ago
676
        let quantity = HKQuantity(unit: .count(), doubleValue: value)
677
        let startDate = Date(timeIntervalSince1970: start)
Bogdan Timofte authored 2 weeks ago
678
        let endDate = Date(timeIntervalSince1970: end ?? (start + 300))
Bogdan Timofte authored 2 weeks ago
679
        return HKQuantitySample(type: quantityType, quantity: quantity, start: startDate, end: endDate)
Bogdan Timofte authored 2 weeks ago
680
    }
681

            
Bogdan Timofte authored 2 weeks ago
682
    private func makeStepCountSamples(count: Int, startIndex: Int) -> [HKQuantitySample] {
683
        (0..<count).map { offset in
684
            let index = startIndex + offset
685
            return makeStepCountSample(
686
                value: Double((index % 97) + 1),
687
                start: 10_000 + Double(index * 600)
688
            )
689
        }
690
    }
691

            
Bogdan Timofte authored 2 weeks ago
692
    private func waitForArchiveOperation<T>(_ operation: @escaping () async throws -> T) throws -> T {
693
        let expectation = expectation(description: "archive operation")
694
        let box = AsyncResultBox<T>()
695

            
696
        Task {
697
            do {
698
                box.result = .success(try await operation())
699
            } catch {
700
                box.result = .failure(error)
701
            }
702
            expectation.fulfill()
703
        }
704

            
705
        wait(for: [expectation], timeout: 20)
706
        return try XCTUnwrap(box.result).get()
707
    }
708

            
Bogdan Timofte authored 2 weeks ago
709
    private func countRows(in tableName: String, at url: URL) throws -> Int {
710
        var db: OpaquePointer?
711
        guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
712
            sqlite3_close(db)
713
            XCTFail("Could not open test database")
714
            return 0
715
        }
716
        defer { sqlite3_close(db) }
717

            
718
        var statement: OpaquePointer?
719
        guard sqlite3_prepare_v2(db, "SELECT COUNT(*) FROM \(tableName)", -1, &statement, nil) == SQLITE_OK else {
720
            sqlite3_finalize(statement)
721
            XCTFail("Could not prepare count query")
722
            return 0
723
        }
724
        defer { sqlite3_finalize(statement) }
725

            
726
        guard sqlite3_step(statement) == SQLITE_ROW else {
727
            return 0
728
        }
729
        return Int(sqlite3_column_int(statement, 0))
730
    }
731

            
Bogdan Timofte authored 2 weeks ago
732
    private func tableExists(_ tableName: String, at url: URL) throws -> Bool {
733
        var db: OpaquePointer?
734
        guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
735
            sqlite3_close(db)
736
            XCTFail("Could not open test database")
737
            return false
738
        }
739
        defer { sqlite3_close(db) }
740

            
741
        let sql = """
742
        SELECT 1
743
        FROM sqlite_master
744
        WHERE type = 'table' AND name = ?
745
        LIMIT 1
746
        """
747
        var statement: OpaquePointer?
748
        guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else {
749
            sqlite3_finalize(statement)
750
            XCTFail("Could not prepare table existence query")
751
            return false
752
        }
753
        defer { sqlite3_finalize(statement) }
754

            
755
        sqlite3_bind_text(statement, 1, tableName, -1, unsafeBitCast(-1, to: sqlite3_destructor_type.self))
756
        return sqlite3_step(statement) == SQLITE_ROW
757
    }
758

            
Bogdan Timofte authored 2 weeks ago
759
    private func observationIDs(at url: URL) throws -> [Int64] {
760
        var db: OpaquePointer?
761
        guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
762
            sqlite3_close(db)
763
            XCTFail("Could not open test database")
764
            return []
765
        }
766
        defer { sqlite3_close(db) }
767

            
768
        var statement: OpaquePointer?
769
        guard sqlite3_prepare_v2(db, "SELECT id FROM observations ORDER BY id", -1, &statement, nil) == SQLITE_OK else {
770
            sqlite3_finalize(statement)
771
            XCTFail("Could not prepare observation query")
772
            return []
773
        }
774
        defer { sqlite3_finalize(statement) }
775

            
776
        var ids: [Int64] = []
777
        while sqlite3_step(statement) == SQLITE_ROW {
778
            ids.append(sqlite3_column_int64(statement, 0))
779
        }
780
        return ids
781
    }
782

            
Bogdan Timofte authored 2 weeks ago
783
    private func sampleVersionDebugRows(at url: URL) throws -> String {
784
        var db: OpaquePointer?
785
        guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
786
            sqlite3_close(db)
787
            return "could not open database"
788
        }
789
        defer { sqlite3_close(db) }
790

            
791
        let sql = """
792
        SELECT v.payload_hash, v.start_date, v.end_date, v.value_kind, v.numeric_value, v.unit,
793
               v.source_revision_id, src.bundle_identifier, sr.product_type, sr.version,
794
               sr.operating_system_version, v.hk_device_id, v.metadata_id
795
        FROM sample_versions v
796
        LEFT JOIN source_revisions sr ON sr.id = v.source_revision_id
797
        LEFT JOIN sources src ON src.id = sr.source_id
798
        ORDER BY v.id
799
        """
800
        var statement: OpaquePointer?
801
        guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else {
802
            sqlite3_finalize(statement)
803
            return "could not prepare version debug query"
804
        }
805
        defer { sqlite3_finalize(statement) }
806

            
807
        var rows: [String] = []
808
        while sqlite3_step(statement) == SQLITE_ROW {
809
            rows.append((0..<13).map { index in
810
                if sqlite3_column_type(statement, Int32(index)) == SQLITE_NULL {
811
                    return "null"
812
                }
813
                if let text = sqlite3_column_text(statement, Int32(index)) {
814
                    return String(cString: text)
815
                }
816
                return "\(sqlite3_column_double(statement, Int32(index)))"
817
            }.joined(separator: "|"))
818
        }
819
        return rows.joined(separator: "\n")
820
    }
Bogdan Timofte authored a week ago
821

            
822
    private func visibilityRangeDebugRows(at url: URL) throws -> String {
823
        var db: OpaquePointer?
824
        guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
825
            sqlite3_close(db)
826
            return "could not open database"
827
        }
828
        defer { sqlite3_close(db) }
829

            
830
        let sql = """
831
        SELECT sample_id, version_id, first_observation_id, last_observation_id, first_seen_at, last_seen_at
832
        FROM sample_visibility_ranges
833
        ORDER BY sample_id, version_id, first_observation_id
834
        """
835
        var statement: OpaquePointer?
836
        guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else {
837
            sqlite3_finalize(statement)
838
            return "could not prepare visibility debug query"
839
        }
840
        defer { sqlite3_finalize(statement) }
841

            
842
        var rows: [String] = []
843
        while sqlite3_step(statement) == SQLITE_ROW {
844
            rows.append((0..<6).map { index in
845
                if sqlite3_column_type(statement, Int32(index)) == SQLITE_NULL {
846
                    return "null"
847
                }
848
                if let text = sqlite3_column_text(statement, Int32(index)) {
849
                    return String(cString: text)
850
                }
851
                return "\(sqlite3_column_double(statement, Int32(index)))"
852
            }.joined(separator: "|"))
853
        }
854
        return rows.joined(separator: "\n")
855
    }
Bogdan Timofte authored 2 weeks ago
856
}