HealthProbe / HealthProbeTests / SQLiteHealthArchiveStoreTests.swift
Newer Older
801 lines | 38.657kb
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 4 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 5 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 4 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 2 weeks ago
172
    func testDiffSummaryAndRecordsBetweenObservationsUseSQLVisibility() async throws {
173
        let url = databaseURL()
174
        let store = SQLiteHealthArchiveStore(databaseURL: url)
175
        let firstSample = makeStepCountSample(value: 42, start: 1_000)
176
        let secondSample = makeStepCountSample(value: 7, start: 2_000)
177
        let typeIdentifier = HKQuantityTypeIdentifier.stepCount.rawValue
178

            
179
        _ = try await store.upsertSamples([firstSample], observedAt: Date(timeIntervalSince1970: 3_000))
180
        _ = try await store.upsertSamples([firstSample, secondSample], observedAt: Date(timeIntervalSince1970: 3_060))
181
        try await store.recordDisappearance(
182
            sampleUUIDHash: HashService.sampleUUIDHash(firstSample.uuid.uuidString),
183
            sampleTypeIdentifier: typeIdentifier,
184
            observedMissingAt: Date(timeIntervalSince1970: 3_120)
185
        )
186
        let observationIDs = try observationIDs(at: url)
187
        XCTAssertEqual(observationIDs.count, 3)
188

            
189
        let appearedSummary = try await store.diffSummary(HealthArchiveDiffRequest(
190
            fromObservationID: observationIDs[0],
191
            toObservationID: observationIDs[1],
192
            sampleTypeIdentifier: typeIdentifier
193
        ))
194
        let appearedRecords = try await store.diffRecords(HealthArchiveDiffRecordRequest(
195
            fromObservationID: observationIDs[0],
196
            toObservationID: observationIDs[1],
197
            sampleTypeIdentifier: typeIdentifier,
198
            kind: .appeared,
199
            limit: 10
200
        ))
201
        let disappearedSummary = try await store.diffSummary(HealthArchiveDiffRequest(
202
            fromObservationID: observationIDs[1],
203
            toObservationID: observationIDs[2],
204
            sampleTypeIdentifier: typeIdentifier
205
        ))
206
        let disappearedRecords = try await store.diffRecords(HealthArchiveDiffRecordRequest(
207
            fromObservationID: observationIDs[1],
208
            toObservationID: observationIDs[2],
209
            sampleTypeIdentifier: typeIdentifier,
210
            kind: .disappeared,
211
            limit: 10
212
        ))
213

            
214
        XCTAssertEqual(appearedSummary.appearedCount, 1)
215
        XCTAssertEqual(appearedSummary.disappearedCount, 0)
216
        XCTAssertEqual(appearedSummary.representationChangedCount, 0)
217
        XCTAssertEqual(appearedRecords.map(\.displayValue), ["7.0 count"])
218
        XCTAssertEqual(disappearedSummary.appearedCount, 0)
219
        XCTAssertEqual(disappearedSummary.disappearedCount, 1)
220
        XCTAssertEqual(disappearedSummary.representationChangedCount, 0)
221
        XCTAssertEqual(disappearedRecords.map(\.displayValue), ["42.0 count"])
222
        XCTAssertNotNil(disappearedRecords.first?.disappearedAt)
Bogdan Timofte authored 2 weeks ago
223

            
224
        let exportRequest = HealthArchiveReportRequest(
225
            reportID: UUID(),
226
            title: "Disappeared Step Count Export",
227
            typeIdentifierFilter: typeIdentifier,
228
            diffFromObservationID: observationIDs[1],
229
            diffToObservationID: observationIDs[2],
230
            diffKind: .disappeared
231
        )
232
        let exportPreview = try await store.exportPreview(exportRequest)
233
        let exportURL = try await store.exportReport(exportRequest)
234

            
235
        XCTAssertEqual(exportPreview.estimatedRecordCount, 1)
236
        XCTAssertTrue(FileManager.default.fileExists(atPath: exportURL.path))
237
        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)
238
    }
239

            
240
    func testExportPreviewAndReportUseSQLiteManifest() async throws {
241
        let url = databaseURL()
242
        let store = SQLiteHealthArchiveStore(databaseURL: url)
243
        let typeIdentifier = HKQuantityTypeIdentifier.stepCount.rawValue
244
        let samples = [
245
            makeStepCountSample(value: 42, start: 1_000),
246
            makeStepCountSample(value: 7, start: 2_000)
247
        ]
248

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

            
251
        let request = HealthArchiveReportRequest(
252
            reportID: UUID(),
253
            title: "Step Count Export",
254
            typeIdentifierFilter: typeIdentifier
255
        )
256
        let preview = try await store.exportPreview(request)
257
        let exportURL = try await store.exportReport(request)
258

            
259
        XCTAssertEqual(preview.estimatedRecordCount, 2)
260
        XCTAssertTrue(FileManager.default.fileExists(atPath: exportURL.path))
261
        XCTAssertEqual(try countRows(in: "export_manifests", at: url), 1)
262
        XCTAssertEqual(try countRows(in: "export_manifests WHERE record_count = 2", at: url), 1)
263

            
264
        let cache = try CoreDataArchiveCacheStore(inMemory: true)
265
        let rebuild = try cache.rebuild(fromArchiveAt: url)
266
        XCTAssertEqual(rebuild.exportManifestRows, 1)
Bogdan Timofte authored 2 weeks ago
267
    }
268

            
Bogdan Timofte authored 2 weeks ago
269
    func testGroupedObservationKeepsPageWritesDeletesAndVerificationTogether() async throws {
270
        let url = databaseURL()
271
        let store = SQLiteHealthArchiveStore(databaseURL: url)
272
        let firstSample = makeStepCountSample(value: 42, start: 1_000)
273
        let secondSample = makeStepCountSample(value: 7, start: 2_000)
274
        let typeIdentifier = HKQuantityTypeIdentifier.stepCount.rawValue
275

            
276
        _ = try await store.upsertSamples([firstSample], observedAt: Date(timeIntervalSince1970: 3_000))
277
        let observationID = try await store.beginObservation(
278
            observedAt: Date(timeIntervalSince1970: 3_060),
279
            triggerReason: "manual",
280
            selectedTypeSetHash: "selected-types"
281
        )
282
        _ = try await store.upsertSamples(
283
            [secondSample],
284
            observedAt: Date(timeIntervalSince1970: 3_060),
285
            observationID: observationID
286
        )
287
        try await store.recordDisappearance(
288
            sampleUUIDHash: HashService.sampleUUIDHash(firstSample.uuid.uuidString),
289
            sampleTypeIdentifier: typeIdentifier,
290
            observedMissingAt: Date(timeIntervalSince1970: 3_060),
291
            observationID: observationID
292
        )
Bogdan Timofte authored a week ago
293
        XCTAssertEqual(
294
            try countRows(
295
                in: "observation_type_summaries WHERE observation_id = \(observationID)",
296
                at: url
297
            ),
298
            0
299
        )
300
        XCTAssertEqual(
301
            try countRows(
302
                in: "daily_type_aggregates WHERE observation_id = \(observationID)",
303
                at: url
304
            ),
305
            0
306
        )
Bogdan Timofte authored 2 weeks ago
307
        try await store.markVerification(
308
            sampleType: secondSample.sampleType,
309
            verifiedAt: Date(timeIntervalSince1970: 3_060),
310
            observationID: observationID
311
        )
312
        try await store.finishObservation(
313
            observationID: observationID,
314
            status: "completed",
315
            endedAt: Date(timeIntervalSince1970: 3_070)
316
        )
317

            
318
        let observationIDs = try observationIDs(at: url)
319
        let summary = try await store.diffSummary(HealthArchiveDiffRequest(
320
            fromObservationID: observationIDs[0],
321
            toObservationID: observationID,
322
            sampleTypeIdentifier: typeIdentifier
323
        ))
324

            
325
        XCTAssertEqual(observationIDs.count, 2)
326
        XCTAssertEqual(summary.appearedCount, 1)
327
        XCTAssertEqual(summary.disappearedCount, 1)
328
        XCTAssertEqual(try countRows(in: "observations WHERE id = \(observationID) AND status = 'completed' AND selected_type_set_hash = 'selected-types'", at: url), 1)
329
        XCTAssertEqual(try countRows(in: "sample_observation_events WHERE observation_id = \(observationID)", at: url), 2)
330
        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
331
        XCTAssertEqual(try countRows(in: "observation_type_summaries WHERE observation_id = \(observationID) AND visible_record_count = 1", at: url), 1)
332
        XCTAssertGreaterThan(try countRows(in: "daily_type_aggregates WHERE observation_id = \(observationID)", at: url), 0)
Bogdan Timofte authored 2 weeks ago
333
    }
334

            
Bogdan Timofte authored a week ago
335
    func testGroupedObservationCanBatchDeletedObjectsInSingleRun() async throws {
336
        let url = databaseURL()
337
        let store = SQLiteHealthArchiveStore(databaseURL: url)
338
        let samples = [
339
            makeStepCountSample(value: 42, start: 1_000),
340
            makeStepCountSample(value: 7, start: 2_000),
341
            makeStepCountSample(value: 9, start: 3_000)
342
        ]
343
        let typeIdentifier = HKQuantityTypeIdentifier.stepCount.rawValue
344

            
345
        _ = try await store.upsertSamples(samples, observedAt: Date(timeIntervalSince1970: 4_000))
346
        let observationID = try await store.beginObservation(
347
            observedAt: Date(timeIntervalSince1970: 4_060),
348
            triggerReason: "manual",
349
            selectedTypeSetHash: "selected-types"
350
        )
351
        let deletedCount = try await store.recordDisappearances(
352
            sampleUUIDHashes: samples.map { HashService.sampleUUIDHash($0.uuid.uuidString) },
353
            sampleTypeIdentifier: typeIdentifier,
354
            observedMissingAt: Date(timeIntervalSince1970: 4_060),
355
            observationID: observationID
356
        )
357
        try await store.markVerification(
358
            sampleType: samples[0].sampleType,
359
            verifiedAt: Date(timeIntervalSince1970: 4_060),
360
            observationID: observationID
361
        )
362

            
363
        XCTAssertEqual(deletedCount, 3)
364
        XCTAssertEqual(try countRows(in: "sample_observation_events WHERE observation_id = \(observationID) AND event_kind = 'disappeared'", at: url), 3)
365
        XCTAssertEqual(try countRows(in: "observation_type_runs WHERE observation_id = \(observationID) AND deleted_event_count = 3 AND status = 'completed'", at: url), 1)
366
    }
367

            
Bogdan Timofte authored 2 weeks ago
368
    func testLargeSyntheticDiffReturnsCountsAndBoundedPages() async throws {
369
        let url = databaseURL()
370
        let store = SQLiteHealthArchiveStore(databaseURL: url)
371
        let typeIdentifier = HKQuantityTypeIdentifier.stepCount.rawValue
372
        let initialCount = 1_200
373
        let appearedCount = 180
374
        let disappearedCount = 160
375
        let pageSize = 25
376
        let initialSamples = makeStepCountSamples(count: initialCount, startIndex: 0)
377
        let appearedSamples = makeStepCountSamples(count: appearedCount, startIndex: initialCount)
378

            
379
        _ = try await store.upsertSamples(initialSamples, observedAt: Date(timeIntervalSince1970: 1_000_000))
380
        _ = try await store.upsertSamples(initialSamples + appearedSamples, observedAt: Date(timeIntervalSince1970: 1_000_060))
381
        for (offset, sample) in initialSamples.prefix(disappearedCount).enumerated() {
382
            try await store.recordDisappearance(
383
                sampleUUIDHash: HashService.sampleUUIDHash(sample.uuid.uuidString),
384
                sampleTypeIdentifier: typeIdentifier,
385
                observedMissingAt: Date(timeIntervalSince1970: 1_000_120 + Double(offset))
386
            )
387
        }
388

            
389
        let observationIDs = try observationIDs(at: url)
390
        let firstObservationID = try XCTUnwrap(observationIDs.first)
391
        let lastObservationID = try XCTUnwrap(observationIDs.last)
392
        let queryStartedAt = Date()
393
        let summary = try await store.diffSummary(HealthArchiveDiffRequest(
394
            fromObservationID: firstObservationID,
395
            toObservationID: lastObservationID,
396
            sampleTypeIdentifier: typeIdentifier
397
        ))
398
        let firstPage = try await store.diffRecords(HealthArchiveDiffRecordRequest(
399
            fromObservationID: firstObservationID,
400
            toObservationID: lastObservationID,
401
            sampleTypeIdentifier: typeIdentifier,
402
            kind: .appeared,
403
            limit: pageSize
404
        ))
405
        let firstPageLastRecord = try XCTUnwrap(firstPage.last)
406
        let secondPage = try await store.diffRecords(HealthArchiveDiffRecordRequest(
407
            fromObservationID: firstObservationID,
408
            toObservationID: lastObservationID,
409
            sampleTypeIdentifier: typeIdentifier,
410
            kind: .appeared,
411
            afterCursor: RecordCursor(
412
                startDate: firstPageLastRecord.startDate,
413
                strictFingerprint: firstPageLastRecord.strictFingerprint
414
            ),
415
            limit: pageSize
416
        ))
417
        let queryElapsedSeconds = Date().timeIntervalSince(queryStartedAt)
418

            
419
        XCTAssertEqual(summary.appearedCount, appearedCount)
420
        XCTAssertEqual(summary.disappearedCount, disappearedCount)
421
        XCTAssertEqual(summary.representationChangedCount, 0)
422
        XCTAssertEqual(firstPage.count, pageSize)
423
        XCTAssertEqual(secondPage.count, pageSize)
424
        XCTAssertTrue(Set(firstPage.map(\.id)).isDisjoint(with: Set(secondPage.map(\.id))))
425
        XCTAssertLessThan(queryElapsedSeconds, 10)
426
    }
427

            
Bogdan Timofte authored 2 weeks ago
428
    func testLargeSyntheticDiffQueryMetrics() throws {
429
        let url = databaseURL()
430
        let store = SQLiteHealthArchiveStore(databaseURL: url)
431
        let typeIdentifier = HKQuantityTypeIdentifier.stepCount.rawValue
432
        let initialCount = 900
433
        let appearedCount = 120
434
        let disappearedCount = 100
435
        let pageSize = 25
436
        let initialSamples = makeStepCountSamples(count: initialCount, startIndex: 0)
437
        let appearedSamples = makeStepCountSamples(count: appearedCount, startIndex: initialCount)
438

            
439
        try waitForArchiveOperation {
440
            _ = try await store.upsertSamples(initialSamples, observedAt: Date(timeIntervalSince1970: 1_200_000))
441
            _ = try await store.upsertSamples(initialSamples + appearedSamples, observedAt: Date(timeIntervalSince1970: 1_200_060))
442
            for (offset, sample) in initialSamples.prefix(disappearedCount).enumerated() {
443
                try await store.recordDisappearance(
444
                    sampleUUIDHash: HashService.sampleUUIDHash(sample.uuid.uuidString),
445
                    sampleTypeIdentifier: typeIdentifier,
446
                    observedMissingAt: Date(timeIntervalSince1970: 1_200_120 + Double(offset))
447
                )
448
            }
449
        }
450

            
451
        let observationIDs = try observationIDs(at: url)
452
        let firstObservationID = try XCTUnwrap(observationIDs.first)
453
        let lastObservationID = try XCTUnwrap(observationIDs.last)
454
        let options = XCTMeasureOptions()
455
        options.iterationCount = 3
456

            
457
        measure(metrics: [XCTClockMetric(), XCTMemoryMetric()], options: options) {
458
            do {
459
                let result = try waitForArchiveOperation {
460
                    let summary = try await store.diffSummary(HealthArchiveDiffRequest(
461
                        fromObservationID: firstObservationID,
462
                        toObservationID: lastObservationID,
463
                        sampleTypeIdentifier: typeIdentifier
464
                    ))
465
                    let records = try await store.diffRecords(HealthArchiveDiffRecordRequest(
466
                        fromObservationID: firstObservationID,
467
                        toObservationID: lastObservationID,
468
                        sampleTypeIdentifier: typeIdentifier,
469
                        kind: .appeared,
470
                        limit: pageSize
471
                    ))
472
                    return (summary, records)
473
                }
474
                XCTAssertEqual(result.0.appearedCount, appearedCount)
475
                XCTAssertEqual(result.0.disappearedCount, disappearedCount)
476
                XCTAssertEqual(result.1.count, pageSize)
477
            } catch {
478
                XCTFail("Measured archive query failed: \(error)")
479
            }
480
        }
481
    }
482

            
Bogdan Timofte authored 2 weeks ago
483
    func testAggregateComparisonUsesMaterializedDailyAggregates() async throws {
484
        let url = databaseURL()
485
        let store = SQLiteHealthArchiveStore(databaseURL: url)
486
        let firstSample = makeStepCountSample(value: 42, start: 1_000)
487
        let secondSample = makeStepCountSample(value: 7, start: 2_000)
488
        let typeIdentifier = HKQuantityTypeIdentifier.stepCount.rawValue
489

            
490
        _ = try await store.upsertSamples([firstSample], observedAt: Date(timeIntervalSince1970: 3_000))
491
        _ = try await store.upsertSamples([firstSample, secondSample], observedAt: Date(timeIntervalSince1970: 3_060))
492
        let observationIDs = try observationIDs(at: url)
493
        XCTAssertEqual(observationIDs.count, 2)
494

            
495
        let rows = try await store.aggregateComparison(HealthArchiveAggregateComparisonRequest(
496
            fromObservationID: observationIDs[0],
497
            toObservationID: observationIDs[1],
498
            sampleTypeIdentifier: typeIdentifier,
499
            limit: 10
500
        ))
501

            
502
        XCTAssertEqual(rows.count, 1)
503
        XCTAssertEqual(rows.first?.sampleTypeIdentifier, typeIdentifier)
504
        XCTAssertEqual(rows.first?.fromVisibleRecordCount, 1)
505
        XCTAssertEqual(rows.first?.toVisibleRecordCount, 2)
506
        XCTAssertEqual(rows.first?.visibleRecordDelta, 1)
507
        XCTAssertEqual(rows.first?.fromValueSum, 42)
508
        XCTAssertEqual(rows.first?.toValueSum, 49)
509
        XCTAssertEqual(rows.first?.valueSumDelta, 7)
510
    }
511

            
Bogdan Timofte authored 2 weeks ago
512
    func testSourceProvenanceBreakdownReturnsVisibleSourceRows() async throws {
513
        let url = databaseURL()
514
        let store = SQLiteHealthArchiveStore(databaseURL: url)
515
        let sample = makeStepCountSample(value: 42, start: 1_000)
516
        let typeIdentifier = sample.sampleType.identifier
517

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

            
520
        let rows = try await store.sourceProvenanceBreakdown(HealthArchiveSourceProvenanceRequest(
521
            visibleAtObservationID: nil,
522
            sampleTypeIdentifier: typeIdentifier,
523
            limit: 10
524
        ))
525

            
526
        XCTAssertEqual(rows.count, 1)
527
        XCTAssertEqual(rows.first?.sampleTypeIdentifier, typeIdentifier)
528
        XCTAssertEqual(rows.first?.visibleRecordCount, 1)
529
        XCTAssertEqual(rows.first?.valueSum, 42)
530
        XCTAssertEqual(rows.first?.sourceBundleIdentifier, sample.sourceRevision.source.bundleIdentifier)
531
    }
532

            
533
    func testConsolidationEvidenceClassifiesStableCountDropAsConsolidationLikely() async throws {
534
        let url = databaseURL()
535
        let store = SQLiteHealthArchiveStore(databaseURL: url)
536
        let typeIdentifier = HKQuantityTypeIdentifier.stepCount.rawValue
537

            
538
        let firstOldSample = makeStepCountSample(value: 10, start: 1_000, end: 1_500)
539
        let secondOldSample = makeStepCountSample(value: 20, start: 1_600, end: 2_000)
540
        let consolidatedSample = makeStepCountSample(value: 30, start: 1_000, end: 2_500)
541

            
542
        _ = try await store.upsertSamples([firstOldSample, secondOldSample], observedAt: Date(timeIntervalSince1970: 3_000))
543
        _ = try await store.upsertSamples([consolidatedSample], observedAt: Date(timeIntervalSince1970: 3_060))
544
        try await store.recordDisappearance(sampleUUIDHash: HashService.sampleUUIDHash(firstOldSample.uuid.uuidString), sampleTypeIdentifier: typeIdentifier, observedMissingAt: Date(timeIntervalSince1970: 3_120))
545
        try await store.recordDisappearance(sampleUUIDHash: HashService.sampleUUIDHash(secondOldSample.uuid.uuidString), sampleTypeIdentifier: typeIdentifier, observedMissingAt: Date(timeIntervalSince1970: 3_180))
546

            
547
        let observationIDs = try observationIDs(at: url)
548
        XCTAssertEqual(observationIDs.count, 4)
549

            
550
        let rows = try await store.consolidationEvidence(HealthArchiveConsolidationEvidenceRequest(
551
            fromObservationID: observationIDs[0],
552
            toObservationID: observationIDs[observationIDs.count - 1],
553
            sampleTypeIdentifier: typeIdentifier
554
        ))
555

            
556
        XCTAssertEqual(rows.count, 1)
557
        XCTAssertEqual(rows.first?.sampleTypeIdentifier, typeIdentifier)
558
        XCTAssertEqual(rows.first?.disappearedCount, 2)
559
        XCTAssertEqual(rows.first?.appearedCount, 1)
560
        XCTAssertEqual(rows.first?.fromVisibleRecordCount, 2)
561
        XCTAssertEqual(rows.first?.toVisibleRecordCount, 1)
562
        XCTAssertEqual(rows.first?.fromValueSum, 30)
563
        XCTAssertEqual(rows.first?.toValueSum, 30)
564
        XCTAssertEqual(rows.first?.label, "consolidation_likely")
565
        XCTAssertTrue(rows.first?.sourceCompatible == true)
566
    }
567

            
568
    func testConsolidationEvidenceClassifiesDenseStableShiftAsAggregateChanged() async throws {
569
        let url = databaseURL()
570
        let store = SQLiteHealthArchiveStore(databaseURL: url)
571
        let typeIdentifier = HKQuantityTypeIdentifier.stepCount.rawValue
572

            
573
        let firstOldSample = makeStepCountSample(value: 10, start: 1_000, end: 1_500)
574
        let secondOldSample = makeStepCountSample(value: 20, start: 5_000, end: 6_000)
575
        let denseSample = makeStepCountSample(value: 30, start: 1_000, end: 1_200)
576

            
577
        _ = try await store.upsertSamples([firstOldSample, secondOldSample], observedAt: Date(timeIntervalSince1970: 3_000))
578
        _ = try await store.upsertSamples([denseSample], observedAt: Date(timeIntervalSince1970: 3_060))
579
        try await store.recordDisappearance(sampleUUIDHash: HashService.sampleUUIDHash(firstOldSample.uuid.uuidString), sampleTypeIdentifier: typeIdentifier, observedMissingAt: Date(timeIntervalSince1970: 3_120))
580
        try await store.recordDisappearance(sampleUUIDHash: HashService.sampleUUIDHash(secondOldSample.uuid.uuidString), sampleTypeIdentifier: typeIdentifier, observedMissingAt: Date(timeIntervalSince1970: 3_180))
581

            
582
        let observationIDs = try observationIDs(at: url)
583
        XCTAssertEqual(observationIDs.count, 4)
584

            
585
        let rows = try await store.consolidationEvidence(HealthArchiveConsolidationEvidenceRequest(
586
            fromObservationID: observationIDs[0],
587
            toObservationID: observationIDs[observationIDs.count - 1],
588
            sampleTypeIdentifier: typeIdentifier
589
        ))
590

            
591
        XCTAssertEqual(rows.count, 1)
592
        XCTAssertEqual(rows.first?.label, "aggregate_changed")
593
        XCTAssertEqual(rows.first?.fromVisibleRecordCount, 2)
594
        XCTAssertEqual(rows.first?.toVisibleRecordCount, 1)
595
        XCTAssertEqual(rows.first?.fromValueSum, 30)
596
        XCTAssertEqual(rows.first?.toValueSum, 30)
597
    }
598

            
Bogdan Timofte authored 2 weeks ago
599
    private func databaseURL() -> URL {
600
        temporaryDirectory.appending(path: "Archive.sqlite")
601
    }
602

            
603
    private func createPrototypeDatabase(at url: URL) throws {
604
        var db: OpaquePointer?
605
        guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_CREATE | SQLITE_OPEN_READWRITE | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
606
            sqlite3_close(db)
607
            XCTFail("Could not create prototype database")
608
            return
609
        }
610
        defer { sqlite3_close(db) }
611
        let status = sqlite3_exec(db, "CREATE TABLE archive_samples (id TEXT PRIMARY KEY)", nil, nil, nil)
612
        XCTAssertEqual(status, SQLITE_OK)
613
    }
614

            
615
    private func makeStepCountSample() -> HKQuantitySample {
Bogdan Timofte authored 2 weeks ago
616
        makeStepCountSample(value: 42, start: 1_000)
617
    }
618

            
Bogdan Timofte authored 2 weeks ago
619
    private func makeStepCountSample(value: Double, start: TimeInterval, end: TimeInterval? = nil) -> HKQuantitySample {
Bogdan Timofte authored 2 weeks ago
620
        let quantityType = HKQuantityType.quantityType(forIdentifier: .stepCount)!
Bogdan Timofte authored 2 weeks ago
621
        let quantity = HKQuantity(unit: .count(), doubleValue: value)
622
        let startDate = Date(timeIntervalSince1970: start)
Bogdan Timofte authored 2 weeks ago
623
        let endDate = Date(timeIntervalSince1970: end ?? (start + 300))
Bogdan Timofte authored 2 weeks ago
624
        return HKQuantitySample(type: quantityType, quantity: quantity, start: startDate, end: endDate)
Bogdan Timofte authored 2 weeks ago
625
    }
626

            
Bogdan Timofte authored 2 weeks ago
627
    private func makeStepCountSamples(count: Int, startIndex: Int) -> [HKQuantitySample] {
628
        (0..<count).map { offset in
629
            let index = startIndex + offset
630
            return makeStepCountSample(
631
                value: Double((index % 97) + 1),
632
                start: 10_000 + Double(index * 600)
633
            )
634
        }
635
    }
636

            
Bogdan Timofte authored 2 weeks ago
637
    private func waitForArchiveOperation<T>(_ operation: @escaping () async throws -> T) throws -> T {
638
        let expectation = expectation(description: "archive operation")
639
        let box = AsyncResultBox<T>()
640

            
641
        Task {
642
            do {
643
                box.result = .success(try await operation())
644
            } catch {
645
                box.result = .failure(error)
646
            }
647
            expectation.fulfill()
648
        }
649

            
650
        wait(for: [expectation], timeout: 20)
651
        return try XCTUnwrap(box.result).get()
652
    }
653

            
Bogdan Timofte authored 2 weeks ago
654
    private func countRows(in tableName: String, at url: URL) throws -> Int {
655
        var db: OpaquePointer?
656
        guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
657
            sqlite3_close(db)
658
            XCTFail("Could not open test database")
659
            return 0
660
        }
661
        defer { sqlite3_close(db) }
662

            
663
        var statement: OpaquePointer?
664
        guard sqlite3_prepare_v2(db, "SELECT COUNT(*) FROM \(tableName)", -1, &statement, nil) == SQLITE_OK else {
665
            sqlite3_finalize(statement)
666
            XCTFail("Could not prepare count query")
667
            return 0
668
        }
669
        defer { sqlite3_finalize(statement) }
670

            
671
        guard sqlite3_step(statement) == SQLITE_ROW else {
672
            return 0
673
        }
674
        return Int(sqlite3_column_int(statement, 0))
675
    }
676

            
Bogdan Timofte authored 2 weeks ago
677
    private func tableExists(_ tableName: String, at url: URL) throws -> Bool {
678
        var db: OpaquePointer?
679
        guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
680
            sqlite3_close(db)
681
            XCTFail("Could not open test database")
682
            return false
683
        }
684
        defer { sqlite3_close(db) }
685

            
686
        let sql = """
687
        SELECT 1
688
        FROM sqlite_master
689
        WHERE type = 'table' AND name = ?
690
        LIMIT 1
691
        """
692
        var statement: OpaquePointer?
693
        guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else {
694
            sqlite3_finalize(statement)
695
            XCTFail("Could not prepare table existence query")
696
            return false
697
        }
698
        defer { sqlite3_finalize(statement) }
699

            
700
        sqlite3_bind_text(statement, 1, tableName, -1, unsafeBitCast(-1, to: sqlite3_destructor_type.self))
701
        return sqlite3_step(statement) == SQLITE_ROW
702
    }
703

            
Bogdan Timofte authored 2 weeks ago
704
    private func observationIDs(at url: URL) throws -> [Int64] {
705
        var db: OpaquePointer?
706
        guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
707
            sqlite3_close(db)
708
            XCTFail("Could not open test database")
709
            return []
710
        }
711
        defer { sqlite3_close(db) }
712

            
713
        var statement: OpaquePointer?
714
        guard sqlite3_prepare_v2(db, "SELECT id FROM observations ORDER BY id", -1, &statement, nil) == SQLITE_OK else {
715
            sqlite3_finalize(statement)
716
            XCTFail("Could not prepare observation query")
717
            return []
718
        }
719
        defer { sqlite3_finalize(statement) }
720

            
721
        var ids: [Int64] = []
722
        while sqlite3_step(statement) == SQLITE_ROW {
723
            ids.append(sqlite3_column_int64(statement, 0))
724
        }
725
        return ids
726
    }
727

            
Bogdan Timofte authored 2 weeks ago
728
    private func sampleVersionDebugRows(at url: URL) throws -> String {
729
        var db: OpaquePointer?
730
        guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
731
            sqlite3_close(db)
732
            return "could not open database"
733
        }
734
        defer { sqlite3_close(db) }
735

            
736
        let sql = """
737
        SELECT v.payload_hash, v.start_date, v.end_date, v.value_kind, v.numeric_value, v.unit,
738
               v.source_revision_id, src.bundle_identifier, sr.product_type, sr.version,
739
               sr.operating_system_version, v.hk_device_id, v.metadata_id
740
        FROM sample_versions v
741
        LEFT JOIN source_revisions sr ON sr.id = v.source_revision_id
742
        LEFT JOIN sources src ON src.id = sr.source_id
743
        ORDER BY v.id
744
        """
745
        var statement: OpaquePointer?
746
        guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else {
747
            sqlite3_finalize(statement)
748
            return "could not prepare version debug query"
749
        }
750
        defer { sqlite3_finalize(statement) }
751

            
752
        var rows: [String] = []
753
        while sqlite3_step(statement) == SQLITE_ROW {
754
            rows.append((0..<13).map { index in
755
                if sqlite3_column_type(statement, Int32(index)) == SQLITE_NULL {
756
                    return "null"
757
                }
758
                if let text = sqlite3_column_text(statement, Int32(index)) {
759
                    return String(cString: text)
760
                }
761
                return "\(sqlite3_column_double(statement, Int32(index)))"
762
            }.joined(separator: "|"))
763
        }
764
        return rows.joined(separator: "\n")
765
    }
Bogdan Timofte authored a week ago
766

            
767
    private func visibilityRangeDebugRows(at url: URL) throws -> String {
768
        var db: OpaquePointer?
769
        guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
770
            sqlite3_close(db)
771
            return "could not open database"
772
        }
773
        defer { sqlite3_close(db) }
774

            
775
        let sql = """
776
        SELECT sample_id, version_id, first_observation_id, last_observation_id, first_seen_at, last_seen_at
777
        FROM sample_visibility_ranges
778
        ORDER BY sample_id, version_id, first_observation_id
779
        """
780
        var statement: OpaquePointer?
781
        guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else {
782
            sqlite3_finalize(statement)
783
            return "could not prepare visibility debug query"
784
        }
785
        defer { sqlite3_finalize(statement) }
786

            
787
        var rows: [String] = []
788
        while sqlite3_step(statement) == SQLITE_ROW {
789
            rows.append((0..<6).map { index in
790
                if sqlite3_column_type(statement, Int32(index)) == SQLITE_NULL {
791
                    return "null"
792
                }
793
                if let text = sqlite3_column_text(statement, Int32(index)) {
794
                    return String(cString: text)
795
                }
796
                return "\(sqlite3_column_double(statement, Int32(index)))"
797
            }.joined(separator: "|"))
798
        }
799
        return rows.joined(separator: "\n")
800
    }
Bogdan Timofte authored 2 weeks ago
801
}