HealthProbe / HealthProbeTests / SQLiteHealthArchiveStoreTests.swift
Newer Older
662 lines | 31.473kb
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

            
44
    func testPrototypeArchiveIsResetAndReinitializedAsV2() async throws {
45
        let url = databaseURL()
46
        try createPrototypeDatabase(at: url)
47
        let store = SQLiteHealthArchiveStore(databaseURL: url)
48

            
49
        let report = try await store.checkIntegrity()
50

            
51
        XCTAssertTrue(report.passed)
52
        XCTAssertEqual(report.schemaVersion, 2)
53
        XCTAssertTrue(report.missingTableNames.isEmpty)
54
    }
55

            
56
    func testRepeatedSamplePageDoesNotDuplicateIdentityOrVersion() async throws {
57
        let url = databaseURL()
58
        let store = SQLiteHealthArchiveStore(databaseURL: url)
59
        let sample = makeStepCountSample()
60

            
61
        let firstWrite = try await store.upsertSamples([sample], observedAt: Date(timeIntervalSince1970: 2_000))
62
        let secondWrite = try await store.upsertSamples([sample], observedAt: Date(timeIntervalSince1970: 2_060))
63
        let records = try await store.records(for: HealthArchiveRecordRequest(
64
            sampleTypeIdentifier: sample.sampleType.identifier
65
        ))
66
        let report = try await store.checkIntegrity()
67
        let versionDebugRows = try sampleVersionDebugRows(at: url)
68

            
69
        XCTAssertEqual(firstWrite.insertedCount, 1)
70
        XCTAssertEqual(firstWrite.updatedCount, 0)
71
        XCTAssertEqual(firstWrite.unchangedCount, 0)
72
        XCTAssertEqual(try countRows(in: "samples", at: url), 1)
73
        XCTAssertEqual(try countRows(in: "sample_versions", at: url), 1, versionDebugRows)
74
        XCTAssertEqual(try countRows(in: "sample_visibility_ranges", at: url), 1)
75
        XCTAssertEqual(try countRows(in: "source_revisions", at: url), 1)
Bogdan Timofte authored 2 weeks ago
76
        XCTAssertFalse(try tableExists("archive_samples", at: url))
Bogdan Timofte authored 2 weeks ago
77
        XCTAssertEqual(secondWrite.insertedCount, 0)
78
        XCTAssertEqual(secondWrite.updatedCount, 0)
79
        XCTAssertEqual(secondWrite.unchangedCount, 1)
80
        XCTAssertEqual(records.count, 1)
81
        XCTAssertEqual(records.first?.displayValue, "42.0 count")
82
        XCTAssertTrue(report.passed)
83
    }
84

            
Bogdan Timofte authored 2 weeks ago
85
    func testVerificationUsesArchiveV2TablesWithoutLegacyMirror() async throws {
86
        let url = databaseURL()
87
        let store = SQLiteHealthArchiveStore(databaseURL: url)
88
        let sample = makeStepCountSample()
89

            
90
        _ = try await store.upsertSamples([sample], observedAt: Date(timeIntervalSince1970: 2_000))
91
        try await store.markVerification(sampleType: sample.sampleType, verifiedAt: Date(timeIntervalSince1970: 2_060))
92
        let observationIDs = try observationIDs(at: url)
93

            
94
        XCTAssertEqual(observationIDs.count, 2)
Bogdan Timofte authored 2 weeks ago
95
        XCTAssertEqual(try countRows(in: "observation_type_runs", at: url), 2)
96
        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
97
        XCTAssertEqual(try countRows(in: "observation_type_summaries WHERE observation_id = \(observationIDs[1]) AND visible_record_count = 1", at: url), 1)
98
        XCTAssertFalse(try tableExists("archive_samples", at: url))
99
    }
100

            
Bogdan Timofte authored 2 weeks ago
101
    func testDiffSummaryAndRecordsBetweenObservationsUseSQLVisibility() async throws {
102
        let url = databaseURL()
103
        let store = SQLiteHealthArchiveStore(databaseURL: url)
104
        let firstSample = makeStepCountSample(value: 42, start: 1_000)
105
        let secondSample = makeStepCountSample(value: 7, start: 2_000)
106
        let typeIdentifier = HKQuantityTypeIdentifier.stepCount.rawValue
107

            
108
        _ = try await store.upsertSamples([firstSample], observedAt: Date(timeIntervalSince1970: 3_000))
109
        _ = try await store.upsertSamples([firstSample, secondSample], observedAt: Date(timeIntervalSince1970: 3_060))
110
        try await store.recordDisappearance(
111
            sampleUUIDHash: HashService.sampleUUIDHash(firstSample.uuid.uuidString),
112
            sampleTypeIdentifier: typeIdentifier,
113
            observedMissingAt: Date(timeIntervalSince1970: 3_120)
114
        )
115
        let observationIDs = try observationIDs(at: url)
116
        XCTAssertEqual(observationIDs.count, 3)
117

            
118
        let appearedSummary = try await store.diffSummary(HealthArchiveDiffRequest(
119
            fromObservationID: observationIDs[0],
120
            toObservationID: observationIDs[1],
121
            sampleTypeIdentifier: typeIdentifier
122
        ))
123
        let appearedRecords = try await store.diffRecords(HealthArchiveDiffRecordRequest(
124
            fromObservationID: observationIDs[0],
125
            toObservationID: observationIDs[1],
126
            sampleTypeIdentifier: typeIdentifier,
127
            kind: .appeared,
128
            limit: 10
129
        ))
130
        let disappearedSummary = try await store.diffSummary(HealthArchiveDiffRequest(
131
            fromObservationID: observationIDs[1],
132
            toObservationID: observationIDs[2],
133
            sampleTypeIdentifier: typeIdentifier
134
        ))
135
        let disappearedRecords = try await store.diffRecords(HealthArchiveDiffRecordRequest(
136
            fromObservationID: observationIDs[1],
137
            toObservationID: observationIDs[2],
138
            sampleTypeIdentifier: typeIdentifier,
139
            kind: .disappeared,
140
            limit: 10
141
        ))
142

            
143
        XCTAssertEqual(appearedSummary.appearedCount, 1)
144
        XCTAssertEqual(appearedSummary.disappearedCount, 0)
145
        XCTAssertEqual(appearedSummary.representationChangedCount, 0)
146
        XCTAssertEqual(appearedRecords.map(\.displayValue), ["7.0 count"])
147
        XCTAssertEqual(disappearedSummary.appearedCount, 0)
148
        XCTAssertEqual(disappearedSummary.disappearedCount, 1)
149
        XCTAssertEqual(disappearedSummary.representationChangedCount, 0)
150
        XCTAssertEqual(disappearedRecords.map(\.displayValue), ["42.0 count"])
151
        XCTAssertNotNil(disappearedRecords.first?.disappearedAt)
Bogdan Timofte authored 2 weeks ago
152

            
153
        let exportRequest = HealthArchiveReportRequest(
154
            reportID: UUID(),
155
            title: "Disappeared Step Count Export",
156
            typeIdentifierFilter: typeIdentifier,
157
            diffFromObservationID: observationIDs[1],
158
            diffToObservationID: observationIDs[2],
159
            diffKind: .disappeared
160
        )
161
        let exportPreview = try await store.exportPreview(exportRequest)
162
        let exportURL = try await store.exportReport(exportRequest)
163

            
164
        XCTAssertEqual(exportPreview.estimatedRecordCount, 1)
165
        XCTAssertTrue(FileManager.default.fileExists(atPath: exportURL.path))
166
        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)
167
    }
168

            
169
    func testExportPreviewAndReportUseSQLiteManifest() async throws {
170
        let url = databaseURL()
171
        let store = SQLiteHealthArchiveStore(databaseURL: url)
172
        let typeIdentifier = HKQuantityTypeIdentifier.stepCount.rawValue
173
        let samples = [
174
            makeStepCountSample(value: 42, start: 1_000),
175
            makeStepCountSample(value: 7, start: 2_000)
176
        ]
177

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

            
180
        let request = HealthArchiveReportRequest(
181
            reportID: UUID(),
182
            title: "Step Count Export",
183
            typeIdentifierFilter: typeIdentifier
184
        )
185
        let preview = try await store.exportPreview(request)
186
        let exportURL = try await store.exportReport(request)
187

            
188
        XCTAssertEqual(preview.estimatedRecordCount, 2)
189
        XCTAssertTrue(FileManager.default.fileExists(atPath: exportURL.path))
190
        XCTAssertEqual(try countRows(in: "export_manifests", at: url), 1)
191
        XCTAssertEqual(try countRows(in: "export_manifests WHERE record_count = 2", at: url), 1)
192

            
193
        let cache = try CoreDataArchiveCacheStore(inMemory: true)
194
        let rebuild = try cache.rebuild(fromArchiveAt: url)
195
        XCTAssertEqual(rebuild.exportManifestRows, 1)
Bogdan Timofte authored 2 weeks ago
196
    }
197

            
Bogdan Timofte authored 2 weeks ago
198
    func testGroupedObservationKeepsPageWritesDeletesAndVerificationTogether() async throws {
199
        let url = databaseURL()
200
        let store = SQLiteHealthArchiveStore(databaseURL: url)
201
        let firstSample = makeStepCountSample(value: 42, start: 1_000)
202
        let secondSample = makeStepCountSample(value: 7, start: 2_000)
203
        let typeIdentifier = HKQuantityTypeIdentifier.stepCount.rawValue
204

            
205
        _ = try await store.upsertSamples([firstSample], observedAt: Date(timeIntervalSince1970: 3_000))
206
        let observationID = try await store.beginObservation(
207
            observedAt: Date(timeIntervalSince1970: 3_060),
208
            triggerReason: "manual",
209
            selectedTypeSetHash: "selected-types"
210
        )
211
        _ = try await store.upsertSamples(
212
            [secondSample],
213
            observedAt: Date(timeIntervalSince1970: 3_060),
214
            observationID: observationID
215
        )
216
        try await store.recordDisappearance(
217
            sampleUUIDHash: HashService.sampleUUIDHash(firstSample.uuid.uuidString),
218
            sampleTypeIdentifier: typeIdentifier,
219
            observedMissingAt: Date(timeIntervalSince1970: 3_060),
220
            observationID: observationID
221
        )
Bogdan Timofte authored a week ago
222
        XCTAssertEqual(
223
            try countRows(
224
                in: "observation_type_summaries WHERE observation_id = \(observationID)",
225
                at: url
226
            ),
227
            0
228
        )
229
        XCTAssertEqual(
230
            try countRows(
231
                in: "daily_type_aggregates WHERE observation_id = \(observationID)",
232
                at: url
233
            ),
234
            0
235
        )
Bogdan Timofte authored 2 weeks ago
236
        try await store.markVerification(
237
            sampleType: secondSample.sampleType,
238
            verifiedAt: Date(timeIntervalSince1970: 3_060),
239
            observationID: observationID
240
        )
241
        try await store.finishObservation(
242
            observationID: observationID,
243
            status: "completed",
244
            endedAt: Date(timeIntervalSince1970: 3_070)
245
        )
246

            
247
        let observationIDs = try observationIDs(at: url)
248
        let summary = try await store.diffSummary(HealthArchiveDiffRequest(
249
            fromObservationID: observationIDs[0],
250
            toObservationID: observationID,
251
            sampleTypeIdentifier: typeIdentifier
252
        ))
253

            
254
        XCTAssertEqual(observationIDs.count, 2)
255
        XCTAssertEqual(summary.appearedCount, 1)
256
        XCTAssertEqual(summary.disappearedCount, 1)
257
        XCTAssertEqual(try countRows(in: "observations WHERE id = \(observationID) AND status = 'completed' AND selected_type_set_hash = 'selected-types'", at: url), 1)
258
        XCTAssertEqual(try countRows(in: "sample_observation_events WHERE observation_id = \(observationID)", at: url), 2)
259
        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
260
        XCTAssertEqual(try countRows(in: "observation_type_summaries WHERE observation_id = \(observationID) AND visible_record_count = 1", at: url), 1)
261
        XCTAssertGreaterThan(try countRows(in: "daily_type_aggregates WHERE observation_id = \(observationID)", at: url), 0)
Bogdan Timofte authored 2 weeks ago
262
    }
263

            
Bogdan Timofte authored 2 weeks ago
264
    func testLargeSyntheticDiffReturnsCountsAndBoundedPages() async throws {
265
        let url = databaseURL()
266
        let store = SQLiteHealthArchiveStore(databaseURL: url)
267
        let typeIdentifier = HKQuantityTypeIdentifier.stepCount.rawValue
268
        let initialCount = 1_200
269
        let appearedCount = 180
270
        let disappearedCount = 160
271
        let pageSize = 25
272
        let initialSamples = makeStepCountSamples(count: initialCount, startIndex: 0)
273
        let appearedSamples = makeStepCountSamples(count: appearedCount, startIndex: initialCount)
274

            
275
        _ = try await store.upsertSamples(initialSamples, observedAt: Date(timeIntervalSince1970: 1_000_000))
276
        _ = try await store.upsertSamples(initialSamples + appearedSamples, observedAt: Date(timeIntervalSince1970: 1_000_060))
277
        for (offset, sample) in initialSamples.prefix(disappearedCount).enumerated() {
278
            try await store.recordDisappearance(
279
                sampleUUIDHash: HashService.sampleUUIDHash(sample.uuid.uuidString),
280
                sampleTypeIdentifier: typeIdentifier,
281
                observedMissingAt: Date(timeIntervalSince1970: 1_000_120 + Double(offset))
282
            )
283
        }
284

            
285
        let observationIDs = try observationIDs(at: url)
286
        let firstObservationID = try XCTUnwrap(observationIDs.first)
287
        let lastObservationID = try XCTUnwrap(observationIDs.last)
288
        let queryStartedAt = Date()
289
        let summary = try await store.diffSummary(HealthArchiveDiffRequest(
290
            fromObservationID: firstObservationID,
291
            toObservationID: lastObservationID,
292
            sampleTypeIdentifier: typeIdentifier
293
        ))
294
        let firstPage = try await store.diffRecords(HealthArchiveDiffRecordRequest(
295
            fromObservationID: firstObservationID,
296
            toObservationID: lastObservationID,
297
            sampleTypeIdentifier: typeIdentifier,
298
            kind: .appeared,
299
            limit: pageSize
300
        ))
301
        let firstPageLastRecord = try XCTUnwrap(firstPage.last)
302
        let secondPage = try await store.diffRecords(HealthArchiveDiffRecordRequest(
303
            fromObservationID: firstObservationID,
304
            toObservationID: lastObservationID,
305
            sampleTypeIdentifier: typeIdentifier,
306
            kind: .appeared,
307
            afterCursor: RecordCursor(
308
                startDate: firstPageLastRecord.startDate,
309
                strictFingerprint: firstPageLastRecord.strictFingerprint
310
            ),
311
            limit: pageSize
312
        ))
313
        let queryElapsedSeconds = Date().timeIntervalSince(queryStartedAt)
314

            
315
        XCTAssertEqual(summary.appearedCount, appearedCount)
316
        XCTAssertEqual(summary.disappearedCount, disappearedCount)
317
        XCTAssertEqual(summary.representationChangedCount, 0)
318
        XCTAssertEqual(firstPage.count, pageSize)
319
        XCTAssertEqual(secondPage.count, pageSize)
320
        XCTAssertTrue(Set(firstPage.map(\.id)).isDisjoint(with: Set(secondPage.map(\.id))))
321
        XCTAssertLessThan(queryElapsedSeconds, 10)
322
    }
323

            
Bogdan Timofte authored 2 weeks ago
324
    func testLargeSyntheticDiffQueryMetrics() throws {
325
        let url = databaseURL()
326
        let store = SQLiteHealthArchiveStore(databaseURL: url)
327
        let typeIdentifier = HKQuantityTypeIdentifier.stepCount.rawValue
328
        let initialCount = 900
329
        let appearedCount = 120
330
        let disappearedCount = 100
331
        let pageSize = 25
332
        let initialSamples = makeStepCountSamples(count: initialCount, startIndex: 0)
333
        let appearedSamples = makeStepCountSamples(count: appearedCount, startIndex: initialCount)
334

            
335
        try waitForArchiveOperation {
336
            _ = try await store.upsertSamples(initialSamples, observedAt: Date(timeIntervalSince1970: 1_200_000))
337
            _ = try await store.upsertSamples(initialSamples + appearedSamples, observedAt: Date(timeIntervalSince1970: 1_200_060))
338
            for (offset, sample) in initialSamples.prefix(disappearedCount).enumerated() {
339
                try await store.recordDisappearance(
340
                    sampleUUIDHash: HashService.sampleUUIDHash(sample.uuid.uuidString),
341
                    sampleTypeIdentifier: typeIdentifier,
342
                    observedMissingAt: Date(timeIntervalSince1970: 1_200_120 + Double(offset))
343
                )
344
            }
345
        }
346

            
347
        let observationIDs = try observationIDs(at: url)
348
        let firstObservationID = try XCTUnwrap(observationIDs.first)
349
        let lastObservationID = try XCTUnwrap(observationIDs.last)
350
        let options = XCTMeasureOptions()
351
        options.iterationCount = 3
352

            
353
        measure(metrics: [XCTClockMetric(), XCTMemoryMetric()], options: options) {
354
            do {
355
                let result = try waitForArchiveOperation {
356
                    let summary = try await store.diffSummary(HealthArchiveDiffRequest(
357
                        fromObservationID: firstObservationID,
358
                        toObservationID: lastObservationID,
359
                        sampleTypeIdentifier: typeIdentifier
360
                    ))
361
                    let records = try await store.diffRecords(HealthArchiveDiffRecordRequest(
362
                        fromObservationID: firstObservationID,
363
                        toObservationID: lastObservationID,
364
                        sampleTypeIdentifier: typeIdentifier,
365
                        kind: .appeared,
366
                        limit: pageSize
367
                    ))
368
                    return (summary, records)
369
                }
370
                XCTAssertEqual(result.0.appearedCount, appearedCount)
371
                XCTAssertEqual(result.0.disappearedCount, disappearedCount)
372
                XCTAssertEqual(result.1.count, pageSize)
373
            } catch {
374
                XCTFail("Measured archive query failed: \(error)")
375
            }
376
        }
377
    }
378

            
Bogdan Timofte authored 2 weeks ago
379
    func testAggregateComparisonUsesMaterializedDailyAggregates() async throws {
380
        let url = databaseURL()
381
        let store = SQLiteHealthArchiveStore(databaseURL: url)
382
        let firstSample = makeStepCountSample(value: 42, start: 1_000)
383
        let secondSample = makeStepCountSample(value: 7, start: 2_000)
384
        let typeIdentifier = HKQuantityTypeIdentifier.stepCount.rawValue
385

            
386
        _ = try await store.upsertSamples([firstSample], observedAt: Date(timeIntervalSince1970: 3_000))
387
        _ = try await store.upsertSamples([firstSample, secondSample], observedAt: Date(timeIntervalSince1970: 3_060))
388
        let observationIDs = try observationIDs(at: url)
389
        XCTAssertEqual(observationIDs.count, 2)
390

            
391
        let rows = try await store.aggregateComparison(HealthArchiveAggregateComparisonRequest(
392
            fromObservationID: observationIDs[0],
393
            toObservationID: observationIDs[1],
394
            sampleTypeIdentifier: typeIdentifier,
395
            limit: 10
396
        ))
397

            
398
        XCTAssertEqual(rows.count, 1)
399
        XCTAssertEqual(rows.first?.sampleTypeIdentifier, typeIdentifier)
400
        XCTAssertEqual(rows.first?.fromVisibleRecordCount, 1)
401
        XCTAssertEqual(rows.first?.toVisibleRecordCount, 2)
402
        XCTAssertEqual(rows.first?.visibleRecordDelta, 1)
403
        XCTAssertEqual(rows.first?.fromValueSum, 42)
404
        XCTAssertEqual(rows.first?.toValueSum, 49)
405
        XCTAssertEqual(rows.first?.valueSumDelta, 7)
406
    }
407

            
Bogdan Timofte authored 2 weeks ago
408
    func testSourceProvenanceBreakdownReturnsVisibleSourceRows() async throws {
409
        let url = databaseURL()
410
        let store = SQLiteHealthArchiveStore(databaseURL: url)
411
        let sample = makeStepCountSample(value: 42, start: 1_000)
412
        let typeIdentifier = sample.sampleType.identifier
413

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

            
416
        let rows = try await store.sourceProvenanceBreakdown(HealthArchiveSourceProvenanceRequest(
417
            visibleAtObservationID: nil,
418
            sampleTypeIdentifier: typeIdentifier,
419
            limit: 10
420
        ))
421

            
422
        XCTAssertEqual(rows.count, 1)
423
        XCTAssertEqual(rows.first?.sampleTypeIdentifier, typeIdentifier)
424
        XCTAssertEqual(rows.first?.visibleRecordCount, 1)
425
        XCTAssertEqual(rows.first?.valueSum, 42)
426
        XCTAssertEqual(rows.first?.sourceBundleIdentifier, sample.sourceRevision.source.bundleIdentifier)
427
    }
428

            
429
    func testConsolidationEvidenceClassifiesStableCountDropAsConsolidationLikely() async throws {
430
        let url = databaseURL()
431
        let store = SQLiteHealthArchiveStore(databaseURL: url)
432
        let typeIdentifier = HKQuantityTypeIdentifier.stepCount.rawValue
433

            
434
        let firstOldSample = makeStepCountSample(value: 10, start: 1_000, end: 1_500)
435
        let secondOldSample = makeStepCountSample(value: 20, start: 1_600, end: 2_000)
436
        let consolidatedSample = makeStepCountSample(value: 30, start: 1_000, end: 2_500)
437

            
438
        _ = try await store.upsertSamples([firstOldSample, secondOldSample], observedAt: Date(timeIntervalSince1970: 3_000))
439
        _ = try await store.upsertSamples([consolidatedSample], observedAt: Date(timeIntervalSince1970: 3_060))
440
        try await store.recordDisappearance(sampleUUIDHash: HashService.sampleUUIDHash(firstOldSample.uuid.uuidString), sampleTypeIdentifier: typeIdentifier, observedMissingAt: Date(timeIntervalSince1970: 3_120))
441
        try await store.recordDisappearance(sampleUUIDHash: HashService.sampleUUIDHash(secondOldSample.uuid.uuidString), sampleTypeIdentifier: typeIdentifier, observedMissingAt: Date(timeIntervalSince1970: 3_180))
442

            
443
        let observationIDs = try observationIDs(at: url)
444
        XCTAssertEqual(observationIDs.count, 4)
445

            
446
        let rows = try await store.consolidationEvidence(HealthArchiveConsolidationEvidenceRequest(
447
            fromObservationID: observationIDs[0],
448
            toObservationID: observationIDs[observationIDs.count - 1],
449
            sampleTypeIdentifier: typeIdentifier
450
        ))
451

            
452
        XCTAssertEqual(rows.count, 1)
453
        XCTAssertEqual(rows.first?.sampleTypeIdentifier, typeIdentifier)
454
        XCTAssertEqual(rows.first?.disappearedCount, 2)
455
        XCTAssertEqual(rows.first?.appearedCount, 1)
456
        XCTAssertEqual(rows.first?.fromVisibleRecordCount, 2)
457
        XCTAssertEqual(rows.first?.toVisibleRecordCount, 1)
458
        XCTAssertEqual(rows.first?.fromValueSum, 30)
459
        XCTAssertEqual(rows.first?.toValueSum, 30)
460
        XCTAssertEqual(rows.first?.label, "consolidation_likely")
461
        XCTAssertTrue(rows.first?.sourceCompatible == true)
462
    }
463

            
464
    func testConsolidationEvidenceClassifiesDenseStableShiftAsAggregateChanged() async throws {
465
        let url = databaseURL()
466
        let store = SQLiteHealthArchiveStore(databaseURL: url)
467
        let typeIdentifier = HKQuantityTypeIdentifier.stepCount.rawValue
468

            
469
        let firstOldSample = makeStepCountSample(value: 10, start: 1_000, end: 1_500)
470
        let secondOldSample = makeStepCountSample(value: 20, start: 5_000, end: 6_000)
471
        let denseSample = makeStepCountSample(value: 30, start: 1_000, end: 1_200)
472

            
473
        _ = try await store.upsertSamples([firstOldSample, secondOldSample], observedAt: Date(timeIntervalSince1970: 3_000))
474
        _ = try await store.upsertSamples([denseSample], observedAt: Date(timeIntervalSince1970: 3_060))
475
        try await store.recordDisappearance(sampleUUIDHash: HashService.sampleUUIDHash(firstOldSample.uuid.uuidString), sampleTypeIdentifier: typeIdentifier, observedMissingAt: Date(timeIntervalSince1970: 3_120))
476
        try await store.recordDisappearance(sampleUUIDHash: HashService.sampleUUIDHash(secondOldSample.uuid.uuidString), sampleTypeIdentifier: typeIdentifier, observedMissingAt: Date(timeIntervalSince1970: 3_180))
477

            
478
        let observationIDs = try observationIDs(at: url)
479
        XCTAssertEqual(observationIDs.count, 4)
480

            
481
        let rows = try await store.consolidationEvidence(HealthArchiveConsolidationEvidenceRequest(
482
            fromObservationID: observationIDs[0],
483
            toObservationID: observationIDs[observationIDs.count - 1],
484
            sampleTypeIdentifier: typeIdentifier
485
        ))
486

            
487
        XCTAssertEqual(rows.count, 1)
488
        XCTAssertEqual(rows.first?.label, "aggregate_changed")
489
        XCTAssertEqual(rows.first?.fromVisibleRecordCount, 2)
490
        XCTAssertEqual(rows.first?.toVisibleRecordCount, 1)
491
        XCTAssertEqual(rows.first?.fromValueSum, 30)
492
        XCTAssertEqual(rows.first?.toValueSum, 30)
493
    }
494

            
Bogdan Timofte authored 2 weeks ago
495
    private func databaseURL() -> URL {
496
        temporaryDirectory.appending(path: "Archive.sqlite")
497
    }
498

            
499
    private func createPrototypeDatabase(at url: URL) throws {
500
        var db: OpaquePointer?
501
        guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_CREATE | SQLITE_OPEN_READWRITE | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
502
            sqlite3_close(db)
503
            XCTFail("Could not create prototype database")
504
            return
505
        }
506
        defer { sqlite3_close(db) }
507
        let status = sqlite3_exec(db, "CREATE TABLE archive_samples (id TEXT PRIMARY KEY)", nil, nil, nil)
508
        XCTAssertEqual(status, SQLITE_OK)
509
    }
510

            
511
    private func makeStepCountSample() -> HKQuantitySample {
Bogdan Timofte authored 2 weeks ago
512
        makeStepCountSample(value: 42, start: 1_000)
513
    }
514

            
Bogdan Timofte authored 2 weeks ago
515
    private func makeStepCountSample(value: Double, start: TimeInterval, end: TimeInterval? = nil) -> HKQuantitySample {
Bogdan Timofte authored 2 weeks ago
516
        let quantityType = HKQuantityType.quantityType(forIdentifier: .stepCount)!
Bogdan Timofte authored 2 weeks ago
517
        let quantity = HKQuantity(unit: .count(), doubleValue: value)
518
        let startDate = Date(timeIntervalSince1970: start)
Bogdan Timofte authored 2 weeks ago
519
        let endDate = Date(timeIntervalSince1970: end ?? (start + 300))
Bogdan Timofte authored 2 weeks ago
520
        return HKQuantitySample(type: quantityType, quantity: quantity, start: startDate, end: endDate)
Bogdan Timofte authored 2 weeks ago
521
    }
522

            
Bogdan Timofte authored 2 weeks ago
523
    private func makeStepCountSamples(count: Int, startIndex: Int) -> [HKQuantitySample] {
524
        (0..<count).map { offset in
525
            let index = startIndex + offset
526
            return makeStepCountSample(
527
                value: Double((index % 97) + 1),
528
                start: 10_000 + Double(index * 600)
529
            )
530
        }
531
    }
532

            
Bogdan Timofte authored 2 weeks ago
533
    private func waitForArchiveOperation<T>(_ operation: @escaping () async throws -> T) throws -> T {
534
        let expectation = expectation(description: "archive operation")
535
        let box = AsyncResultBox<T>()
536

            
537
        Task {
538
            do {
539
                box.result = .success(try await operation())
540
            } catch {
541
                box.result = .failure(error)
542
            }
543
            expectation.fulfill()
544
        }
545

            
546
        wait(for: [expectation], timeout: 20)
547
        return try XCTUnwrap(box.result).get()
548
    }
549

            
Bogdan Timofte authored 2 weeks ago
550
    private func countRows(in tableName: String, at url: URL) throws -> Int {
551
        var db: OpaquePointer?
552
        guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
553
            sqlite3_close(db)
554
            XCTFail("Could not open test database")
555
            return 0
556
        }
557
        defer { sqlite3_close(db) }
558

            
559
        var statement: OpaquePointer?
560
        guard sqlite3_prepare_v2(db, "SELECT COUNT(*) FROM \(tableName)", -1, &statement, nil) == SQLITE_OK else {
561
            sqlite3_finalize(statement)
562
            XCTFail("Could not prepare count query")
563
            return 0
564
        }
565
        defer { sqlite3_finalize(statement) }
566

            
567
        guard sqlite3_step(statement) == SQLITE_ROW else {
568
            return 0
569
        }
570
        return Int(sqlite3_column_int(statement, 0))
571
    }
572

            
Bogdan Timofte authored 2 weeks ago
573
    private func tableExists(_ tableName: String, at url: URL) throws -> Bool {
574
        var db: OpaquePointer?
575
        guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
576
            sqlite3_close(db)
577
            XCTFail("Could not open test database")
578
            return false
579
        }
580
        defer { sqlite3_close(db) }
581

            
582
        let sql = """
583
        SELECT 1
584
        FROM sqlite_master
585
        WHERE type = 'table' AND name = ?
586
        LIMIT 1
587
        """
588
        var statement: OpaquePointer?
589
        guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else {
590
            sqlite3_finalize(statement)
591
            XCTFail("Could not prepare table existence query")
592
            return false
593
        }
594
        defer { sqlite3_finalize(statement) }
595

            
596
        sqlite3_bind_text(statement, 1, tableName, -1, unsafeBitCast(-1, to: sqlite3_destructor_type.self))
597
        return sqlite3_step(statement) == SQLITE_ROW
598
    }
599

            
Bogdan Timofte authored 2 weeks ago
600
    private func observationIDs(at url: URL) throws -> [Int64] {
601
        var db: OpaquePointer?
602
        guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
603
            sqlite3_close(db)
604
            XCTFail("Could not open test database")
605
            return []
606
        }
607
        defer { sqlite3_close(db) }
608

            
609
        var statement: OpaquePointer?
610
        guard sqlite3_prepare_v2(db, "SELECT id FROM observations ORDER BY id", -1, &statement, nil) == SQLITE_OK else {
611
            sqlite3_finalize(statement)
612
            XCTFail("Could not prepare observation query")
613
            return []
614
        }
615
        defer { sqlite3_finalize(statement) }
616

            
617
        var ids: [Int64] = []
618
        while sqlite3_step(statement) == SQLITE_ROW {
619
            ids.append(sqlite3_column_int64(statement, 0))
620
        }
621
        return ids
622
    }
623

            
Bogdan Timofte authored 2 weeks ago
624
    private func sampleVersionDebugRows(at url: URL) throws -> String {
625
        var db: OpaquePointer?
626
        guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
627
            sqlite3_close(db)
628
            return "could not open database"
629
        }
630
        defer { sqlite3_close(db) }
631

            
632
        let sql = """
633
        SELECT v.payload_hash, v.start_date, v.end_date, v.value_kind, v.numeric_value, v.unit,
634
               v.source_revision_id, src.bundle_identifier, sr.product_type, sr.version,
635
               sr.operating_system_version, v.hk_device_id, v.metadata_id
636
        FROM sample_versions v
637
        LEFT JOIN source_revisions sr ON sr.id = v.source_revision_id
638
        LEFT JOIN sources src ON src.id = sr.source_id
639
        ORDER BY v.id
640
        """
641
        var statement: OpaquePointer?
642
        guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else {
643
            sqlite3_finalize(statement)
644
            return "could not prepare version debug query"
645
        }
646
        defer { sqlite3_finalize(statement) }
647

            
648
        var rows: [String] = []
649
        while sqlite3_step(statement) == SQLITE_ROW {
650
            rows.append((0..<13).map { index in
651
                if sqlite3_column_type(statement, Int32(index)) == SQLITE_NULL {
652
                    return "null"
653
                }
654
                if let text = sqlite3_column_text(statement, Int32(index)) {
655
                    return String(cString: text)
656
                }
657
                return "\(sqlite3_column_double(statement, Int32(index)))"
658
            }.joined(separator: "|"))
659
        }
660
        return rows.joined(separator: "\n")
661
    }
662
}