HealthProbe / HealthProbeTests / SQLiteHealthArchiveStoreTests.swift
Newer Older
734 lines | 34.999kb
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)
Bogdan Timofte authored a week ago
68
        let visibilityDebugRows = try visibilityRangeDebugRows(at: url)
Bogdan Timofte authored 2 weeks ago
69

            
70
        XCTAssertEqual(firstWrite.insertedCount, 1)
71
        XCTAssertEqual(firstWrite.updatedCount, 0)
72
        XCTAssertEqual(firstWrite.unchangedCount, 0)
73
        XCTAssertEqual(try countRows(in: "samples", at: url), 1)
74
        XCTAssertEqual(try countRows(in: "sample_versions", at: url), 1, versionDebugRows)
Bogdan Timofte authored 5 days ago
75
        XCTAssertEqual(try countRows(in: "sample_observation_events", at: url), 1)
76
        XCTAssertEqual(try countRows(in: "sample_observation_events WHERE event_kind = 'appeared'", at: url), 1)
77
        XCTAssertEqual(try countRows(in: "sample_observation_events WHERE event_kind = 'verified'", at: url), 0)
Bogdan Timofte authored a week ago
78
        XCTAssertEqual(try countRows(in: "sample_visibility_ranges", at: url), 1, visibilityDebugRows)
Bogdan Timofte authored 2 weeks ago
79
        XCTAssertEqual(try countRows(in: "source_revisions", at: url), 1)
Bogdan Timofte authored 2 weeks ago
80
        XCTAssertFalse(try tableExists("archive_samples", at: url))
Bogdan Timofte authored 2 weeks ago
81
        XCTAssertEqual(secondWrite.insertedCount, 0)
82
        XCTAssertEqual(secondWrite.updatedCount, 0)
83
        XCTAssertEqual(secondWrite.unchangedCount, 1)
84
        XCTAssertEqual(records.count, 1)
85
        XCTAssertEqual(records.first?.displayValue, "42.0 count")
86
        XCTAssertTrue(report.passed)
87
    }
88

            
Bogdan Timofte authored 2 weeks ago
89
    func testVerificationUsesArchiveV2TablesWithoutLegacyMirror() async throws {
90
        let url = databaseURL()
91
        let store = SQLiteHealthArchiveStore(databaseURL: url)
92
        let sample = makeStepCountSample()
93

            
94
        _ = try await store.upsertSamples([sample], observedAt: Date(timeIntervalSince1970: 2_000))
95
        try await store.markVerification(sampleType: sample.sampleType, verifiedAt: Date(timeIntervalSince1970: 2_060))
96
        let observationIDs = try observationIDs(at: url)
97

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

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

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

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

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

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

            
168
        XCTAssertEqual(exportPreview.estimatedRecordCount, 1)
169
        XCTAssertTrue(FileManager.default.fileExists(atPath: exportURL.path))
170
        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)
171
    }
172

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

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

            
184
        let request = HealthArchiveReportRequest(
185
            reportID: UUID(),
186
            title: "Step Count Export",
187
            typeIdentifierFilter: typeIdentifier
188
        )
189
        let preview = try await store.exportPreview(request)
190
        let exportURL = try await store.exportReport(request)
191

            
192
        XCTAssertEqual(preview.estimatedRecordCount, 2)
193
        XCTAssertTrue(FileManager.default.fileExists(atPath: exportURL.path))
194
        XCTAssertEqual(try countRows(in: "export_manifests", at: url), 1)
195
        XCTAssertEqual(try countRows(in: "export_manifests WHERE record_count = 2", at: url), 1)
196

            
197
        let cache = try CoreDataArchiveCacheStore(inMemory: true)
198
        let rebuild = try cache.rebuild(fromArchiveAt: url)
199
        XCTAssertEqual(rebuild.exportManifestRows, 1)
Bogdan Timofte authored 2 weeks ago
200
    }
201

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

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

            
251
        let observationIDs = try observationIDs(at: url)
252
        let summary = try await store.diffSummary(HealthArchiveDiffRequest(
253
            fromObservationID: observationIDs[0],
254
            toObservationID: observationID,
255
            sampleTypeIdentifier: typeIdentifier
256
        ))
257

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

            
Bogdan Timofte authored a week ago
268
    func testGroupedObservationCanBatchDeletedObjectsInSingleRun() async throws {
269
        let url = databaseURL()
270
        let store = SQLiteHealthArchiveStore(databaseURL: url)
271
        let samples = [
272
            makeStepCountSample(value: 42, start: 1_000),
273
            makeStepCountSample(value: 7, start: 2_000),
274
            makeStepCountSample(value: 9, start: 3_000)
275
        ]
276
        let typeIdentifier = HKQuantityTypeIdentifier.stepCount.rawValue
277

            
278
        _ = try await store.upsertSamples(samples, observedAt: Date(timeIntervalSince1970: 4_000))
279
        let observationID = try await store.beginObservation(
280
            observedAt: Date(timeIntervalSince1970: 4_060),
281
            triggerReason: "manual",
282
            selectedTypeSetHash: "selected-types"
283
        )
284
        let deletedCount = try await store.recordDisappearances(
285
            sampleUUIDHashes: samples.map { HashService.sampleUUIDHash($0.uuid.uuidString) },
286
            sampleTypeIdentifier: typeIdentifier,
287
            observedMissingAt: Date(timeIntervalSince1970: 4_060),
288
            observationID: observationID
289
        )
290
        try await store.markVerification(
291
            sampleType: samples[0].sampleType,
292
            verifiedAt: Date(timeIntervalSince1970: 4_060),
293
            observationID: observationID
294
        )
295

            
296
        XCTAssertEqual(deletedCount, 3)
297
        XCTAssertEqual(try countRows(in: "sample_observation_events WHERE observation_id = \(observationID) AND event_kind = 'disappeared'", at: url), 3)
298
        XCTAssertEqual(try countRows(in: "observation_type_runs WHERE observation_id = \(observationID) AND deleted_event_count = 3 AND status = 'completed'", at: url), 1)
299
    }
300

            
Bogdan Timofte authored 2 weeks ago
301
    func testLargeSyntheticDiffReturnsCountsAndBoundedPages() async throws {
302
        let url = databaseURL()
303
        let store = SQLiteHealthArchiveStore(databaseURL: url)
304
        let typeIdentifier = HKQuantityTypeIdentifier.stepCount.rawValue
305
        let initialCount = 1_200
306
        let appearedCount = 180
307
        let disappearedCount = 160
308
        let pageSize = 25
309
        let initialSamples = makeStepCountSamples(count: initialCount, startIndex: 0)
310
        let appearedSamples = makeStepCountSamples(count: appearedCount, startIndex: initialCount)
311

            
312
        _ = try await store.upsertSamples(initialSamples, observedAt: Date(timeIntervalSince1970: 1_000_000))
313
        _ = try await store.upsertSamples(initialSamples + appearedSamples, observedAt: Date(timeIntervalSince1970: 1_000_060))
314
        for (offset, sample) in initialSamples.prefix(disappearedCount).enumerated() {
315
            try await store.recordDisappearance(
316
                sampleUUIDHash: HashService.sampleUUIDHash(sample.uuid.uuidString),
317
                sampleTypeIdentifier: typeIdentifier,
318
                observedMissingAt: Date(timeIntervalSince1970: 1_000_120 + Double(offset))
319
            )
320
        }
321

            
322
        let observationIDs = try observationIDs(at: url)
323
        let firstObservationID = try XCTUnwrap(observationIDs.first)
324
        let lastObservationID = try XCTUnwrap(observationIDs.last)
325
        let queryStartedAt = Date()
326
        let summary = try await store.diffSummary(HealthArchiveDiffRequest(
327
            fromObservationID: firstObservationID,
328
            toObservationID: lastObservationID,
329
            sampleTypeIdentifier: typeIdentifier
330
        ))
331
        let firstPage = try await store.diffRecords(HealthArchiveDiffRecordRequest(
332
            fromObservationID: firstObservationID,
333
            toObservationID: lastObservationID,
334
            sampleTypeIdentifier: typeIdentifier,
335
            kind: .appeared,
336
            limit: pageSize
337
        ))
338
        let firstPageLastRecord = try XCTUnwrap(firstPage.last)
339
        let secondPage = try await store.diffRecords(HealthArchiveDiffRecordRequest(
340
            fromObservationID: firstObservationID,
341
            toObservationID: lastObservationID,
342
            sampleTypeIdentifier: typeIdentifier,
343
            kind: .appeared,
344
            afterCursor: RecordCursor(
345
                startDate: firstPageLastRecord.startDate,
346
                strictFingerprint: firstPageLastRecord.strictFingerprint
347
            ),
348
            limit: pageSize
349
        ))
350
        let queryElapsedSeconds = Date().timeIntervalSince(queryStartedAt)
351

            
352
        XCTAssertEqual(summary.appearedCount, appearedCount)
353
        XCTAssertEqual(summary.disappearedCount, disappearedCount)
354
        XCTAssertEqual(summary.representationChangedCount, 0)
355
        XCTAssertEqual(firstPage.count, pageSize)
356
        XCTAssertEqual(secondPage.count, pageSize)
357
        XCTAssertTrue(Set(firstPage.map(\.id)).isDisjoint(with: Set(secondPage.map(\.id))))
358
        XCTAssertLessThan(queryElapsedSeconds, 10)
359
    }
360

            
Bogdan Timofte authored 2 weeks ago
361
    func testLargeSyntheticDiffQueryMetrics() throws {
362
        let url = databaseURL()
363
        let store = SQLiteHealthArchiveStore(databaseURL: url)
364
        let typeIdentifier = HKQuantityTypeIdentifier.stepCount.rawValue
365
        let initialCount = 900
366
        let appearedCount = 120
367
        let disappearedCount = 100
368
        let pageSize = 25
369
        let initialSamples = makeStepCountSamples(count: initialCount, startIndex: 0)
370
        let appearedSamples = makeStepCountSamples(count: appearedCount, startIndex: initialCount)
371

            
372
        try waitForArchiveOperation {
373
            _ = try await store.upsertSamples(initialSamples, observedAt: Date(timeIntervalSince1970: 1_200_000))
374
            _ = try await store.upsertSamples(initialSamples + appearedSamples, observedAt: Date(timeIntervalSince1970: 1_200_060))
375
            for (offset, sample) in initialSamples.prefix(disappearedCount).enumerated() {
376
                try await store.recordDisappearance(
377
                    sampleUUIDHash: HashService.sampleUUIDHash(sample.uuid.uuidString),
378
                    sampleTypeIdentifier: typeIdentifier,
379
                    observedMissingAt: Date(timeIntervalSince1970: 1_200_120 + Double(offset))
380
                )
381
            }
382
        }
383

            
384
        let observationIDs = try observationIDs(at: url)
385
        let firstObservationID = try XCTUnwrap(observationIDs.first)
386
        let lastObservationID = try XCTUnwrap(observationIDs.last)
387
        let options = XCTMeasureOptions()
388
        options.iterationCount = 3
389

            
390
        measure(metrics: [XCTClockMetric(), XCTMemoryMetric()], options: options) {
391
            do {
392
                let result = try waitForArchiveOperation {
393
                    let summary = try await store.diffSummary(HealthArchiveDiffRequest(
394
                        fromObservationID: firstObservationID,
395
                        toObservationID: lastObservationID,
396
                        sampleTypeIdentifier: typeIdentifier
397
                    ))
398
                    let records = try await store.diffRecords(HealthArchiveDiffRecordRequest(
399
                        fromObservationID: firstObservationID,
400
                        toObservationID: lastObservationID,
401
                        sampleTypeIdentifier: typeIdentifier,
402
                        kind: .appeared,
403
                        limit: pageSize
404
                    ))
405
                    return (summary, records)
406
                }
407
                XCTAssertEqual(result.0.appearedCount, appearedCount)
408
                XCTAssertEqual(result.0.disappearedCount, disappearedCount)
409
                XCTAssertEqual(result.1.count, pageSize)
410
            } catch {
411
                XCTFail("Measured archive query failed: \(error)")
412
            }
413
        }
414
    }
415

            
Bogdan Timofte authored 2 weeks ago
416
    func testAggregateComparisonUsesMaterializedDailyAggregates() async throws {
417
        let url = databaseURL()
418
        let store = SQLiteHealthArchiveStore(databaseURL: url)
419
        let firstSample = makeStepCountSample(value: 42, start: 1_000)
420
        let secondSample = makeStepCountSample(value: 7, start: 2_000)
421
        let typeIdentifier = HKQuantityTypeIdentifier.stepCount.rawValue
422

            
423
        _ = try await store.upsertSamples([firstSample], observedAt: Date(timeIntervalSince1970: 3_000))
424
        _ = try await store.upsertSamples([firstSample, secondSample], observedAt: Date(timeIntervalSince1970: 3_060))
425
        let observationIDs = try observationIDs(at: url)
426
        XCTAssertEqual(observationIDs.count, 2)
427

            
428
        let rows = try await store.aggregateComparison(HealthArchiveAggregateComparisonRequest(
429
            fromObservationID: observationIDs[0],
430
            toObservationID: observationIDs[1],
431
            sampleTypeIdentifier: typeIdentifier,
432
            limit: 10
433
        ))
434

            
435
        XCTAssertEqual(rows.count, 1)
436
        XCTAssertEqual(rows.first?.sampleTypeIdentifier, typeIdentifier)
437
        XCTAssertEqual(rows.first?.fromVisibleRecordCount, 1)
438
        XCTAssertEqual(rows.first?.toVisibleRecordCount, 2)
439
        XCTAssertEqual(rows.first?.visibleRecordDelta, 1)
440
        XCTAssertEqual(rows.first?.fromValueSum, 42)
441
        XCTAssertEqual(rows.first?.toValueSum, 49)
442
        XCTAssertEqual(rows.first?.valueSumDelta, 7)
443
    }
444

            
Bogdan Timofte authored 2 weeks ago
445
    func testSourceProvenanceBreakdownReturnsVisibleSourceRows() async throws {
446
        let url = databaseURL()
447
        let store = SQLiteHealthArchiveStore(databaseURL: url)
448
        let sample = makeStepCountSample(value: 42, start: 1_000)
449
        let typeIdentifier = sample.sampleType.identifier
450

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

            
453
        let rows = try await store.sourceProvenanceBreakdown(HealthArchiveSourceProvenanceRequest(
454
            visibleAtObservationID: nil,
455
            sampleTypeIdentifier: typeIdentifier,
456
            limit: 10
457
        ))
458

            
459
        XCTAssertEqual(rows.count, 1)
460
        XCTAssertEqual(rows.first?.sampleTypeIdentifier, typeIdentifier)
461
        XCTAssertEqual(rows.first?.visibleRecordCount, 1)
462
        XCTAssertEqual(rows.first?.valueSum, 42)
463
        XCTAssertEqual(rows.first?.sourceBundleIdentifier, sample.sourceRevision.source.bundleIdentifier)
464
    }
465

            
466
    func testConsolidationEvidenceClassifiesStableCountDropAsConsolidationLikely() async throws {
467
        let url = databaseURL()
468
        let store = SQLiteHealthArchiveStore(databaseURL: url)
469
        let typeIdentifier = HKQuantityTypeIdentifier.stepCount.rawValue
470

            
471
        let firstOldSample = makeStepCountSample(value: 10, start: 1_000, end: 1_500)
472
        let secondOldSample = makeStepCountSample(value: 20, start: 1_600, end: 2_000)
473
        let consolidatedSample = makeStepCountSample(value: 30, start: 1_000, end: 2_500)
474

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

            
480
        let observationIDs = try observationIDs(at: url)
481
        XCTAssertEqual(observationIDs.count, 4)
482

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

            
489
        XCTAssertEqual(rows.count, 1)
490
        XCTAssertEqual(rows.first?.sampleTypeIdentifier, typeIdentifier)
491
        XCTAssertEqual(rows.first?.disappearedCount, 2)
492
        XCTAssertEqual(rows.first?.appearedCount, 1)
493
        XCTAssertEqual(rows.first?.fromVisibleRecordCount, 2)
494
        XCTAssertEqual(rows.first?.toVisibleRecordCount, 1)
495
        XCTAssertEqual(rows.first?.fromValueSum, 30)
496
        XCTAssertEqual(rows.first?.toValueSum, 30)
497
        XCTAssertEqual(rows.first?.label, "consolidation_likely")
498
        XCTAssertTrue(rows.first?.sourceCompatible == true)
499
    }
500

            
501
    func testConsolidationEvidenceClassifiesDenseStableShiftAsAggregateChanged() async throws {
502
        let url = databaseURL()
503
        let store = SQLiteHealthArchiveStore(databaseURL: url)
504
        let typeIdentifier = HKQuantityTypeIdentifier.stepCount.rawValue
505

            
506
        let firstOldSample = makeStepCountSample(value: 10, start: 1_000, end: 1_500)
507
        let secondOldSample = makeStepCountSample(value: 20, start: 5_000, end: 6_000)
508
        let denseSample = makeStepCountSample(value: 30, start: 1_000, end: 1_200)
509

            
510
        _ = try await store.upsertSamples([firstOldSample, secondOldSample], observedAt: Date(timeIntervalSince1970: 3_000))
511
        _ = try await store.upsertSamples([denseSample], observedAt: Date(timeIntervalSince1970: 3_060))
512
        try await store.recordDisappearance(sampleUUIDHash: HashService.sampleUUIDHash(firstOldSample.uuid.uuidString), sampleTypeIdentifier: typeIdentifier, observedMissingAt: Date(timeIntervalSince1970: 3_120))
513
        try await store.recordDisappearance(sampleUUIDHash: HashService.sampleUUIDHash(secondOldSample.uuid.uuidString), sampleTypeIdentifier: typeIdentifier, observedMissingAt: Date(timeIntervalSince1970: 3_180))
514

            
515
        let observationIDs = try observationIDs(at: url)
516
        XCTAssertEqual(observationIDs.count, 4)
517

            
518
        let rows = try await store.consolidationEvidence(HealthArchiveConsolidationEvidenceRequest(
519
            fromObservationID: observationIDs[0],
520
            toObservationID: observationIDs[observationIDs.count - 1],
521
            sampleTypeIdentifier: typeIdentifier
522
        ))
523

            
524
        XCTAssertEqual(rows.count, 1)
525
        XCTAssertEqual(rows.first?.label, "aggregate_changed")
526
        XCTAssertEqual(rows.first?.fromVisibleRecordCount, 2)
527
        XCTAssertEqual(rows.first?.toVisibleRecordCount, 1)
528
        XCTAssertEqual(rows.first?.fromValueSum, 30)
529
        XCTAssertEqual(rows.first?.toValueSum, 30)
530
    }
531

            
Bogdan Timofte authored 2 weeks ago
532
    private func databaseURL() -> URL {
533
        temporaryDirectory.appending(path: "Archive.sqlite")
534
    }
535

            
536
    private func createPrototypeDatabase(at url: URL) throws {
537
        var db: OpaquePointer?
538
        guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_CREATE | SQLITE_OPEN_READWRITE | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
539
            sqlite3_close(db)
540
            XCTFail("Could not create prototype database")
541
            return
542
        }
543
        defer { sqlite3_close(db) }
544
        let status = sqlite3_exec(db, "CREATE TABLE archive_samples (id TEXT PRIMARY KEY)", nil, nil, nil)
545
        XCTAssertEqual(status, SQLITE_OK)
546
    }
547

            
548
    private func makeStepCountSample() -> HKQuantitySample {
Bogdan Timofte authored 2 weeks ago
549
        makeStepCountSample(value: 42, start: 1_000)
550
    }
551

            
Bogdan Timofte authored 2 weeks ago
552
    private func makeStepCountSample(value: Double, start: TimeInterval, end: TimeInterval? = nil) -> HKQuantitySample {
Bogdan Timofte authored 2 weeks ago
553
        let quantityType = HKQuantityType.quantityType(forIdentifier: .stepCount)!
Bogdan Timofte authored 2 weeks ago
554
        let quantity = HKQuantity(unit: .count(), doubleValue: value)
555
        let startDate = Date(timeIntervalSince1970: start)
Bogdan Timofte authored 2 weeks ago
556
        let endDate = Date(timeIntervalSince1970: end ?? (start + 300))
Bogdan Timofte authored 2 weeks ago
557
        return HKQuantitySample(type: quantityType, quantity: quantity, start: startDate, end: endDate)
Bogdan Timofte authored 2 weeks ago
558
    }
559

            
Bogdan Timofte authored 2 weeks ago
560
    private func makeStepCountSamples(count: Int, startIndex: Int) -> [HKQuantitySample] {
561
        (0..<count).map { offset in
562
            let index = startIndex + offset
563
            return makeStepCountSample(
564
                value: Double((index % 97) + 1),
565
                start: 10_000 + Double(index * 600)
566
            )
567
        }
568
    }
569

            
Bogdan Timofte authored 2 weeks ago
570
    private func waitForArchiveOperation<T>(_ operation: @escaping () async throws -> T) throws -> T {
571
        let expectation = expectation(description: "archive operation")
572
        let box = AsyncResultBox<T>()
573

            
574
        Task {
575
            do {
576
                box.result = .success(try await operation())
577
            } catch {
578
                box.result = .failure(error)
579
            }
580
            expectation.fulfill()
581
        }
582

            
583
        wait(for: [expectation], timeout: 20)
584
        return try XCTUnwrap(box.result).get()
585
    }
586

            
Bogdan Timofte authored 2 weeks ago
587
    private func countRows(in tableName: String, at url: URL) throws -> Int {
588
        var db: OpaquePointer?
589
        guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
590
            sqlite3_close(db)
591
            XCTFail("Could not open test database")
592
            return 0
593
        }
594
        defer { sqlite3_close(db) }
595

            
596
        var statement: OpaquePointer?
597
        guard sqlite3_prepare_v2(db, "SELECT COUNT(*) FROM \(tableName)", -1, &statement, nil) == SQLITE_OK else {
598
            sqlite3_finalize(statement)
599
            XCTFail("Could not prepare count query")
600
            return 0
601
        }
602
        defer { sqlite3_finalize(statement) }
603

            
604
        guard sqlite3_step(statement) == SQLITE_ROW else {
605
            return 0
606
        }
607
        return Int(sqlite3_column_int(statement, 0))
608
    }
609

            
Bogdan Timofte authored 2 weeks ago
610
    private func tableExists(_ tableName: String, at url: URL) throws -> Bool {
611
        var db: OpaquePointer?
612
        guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
613
            sqlite3_close(db)
614
            XCTFail("Could not open test database")
615
            return false
616
        }
617
        defer { sqlite3_close(db) }
618

            
619
        let sql = """
620
        SELECT 1
621
        FROM sqlite_master
622
        WHERE type = 'table' AND name = ?
623
        LIMIT 1
624
        """
625
        var statement: OpaquePointer?
626
        guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else {
627
            sqlite3_finalize(statement)
628
            XCTFail("Could not prepare table existence query")
629
            return false
630
        }
631
        defer { sqlite3_finalize(statement) }
632

            
633
        sqlite3_bind_text(statement, 1, tableName, -1, unsafeBitCast(-1, to: sqlite3_destructor_type.self))
634
        return sqlite3_step(statement) == SQLITE_ROW
635
    }
636

            
Bogdan Timofte authored 2 weeks ago
637
    private func observationIDs(at url: URL) throws -> [Int64] {
638
        var db: OpaquePointer?
639
        guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
640
            sqlite3_close(db)
641
            XCTFail("Could not open test database")
642
            return []
643
        }
644
        defer { sqlite3_close(db) }
645

            
646
        var statement: OpaquePointer?
647
        guard sqlite3_prepare_v2(db, "SELECT id FROM observations ORDER BY id", -1, &statement, nil) == SQLITE_OK else {
648
            sqlite3_finalize(statement)
649
            XCTFail("Could not prepare observation query")
650
            return []
651
        }
652
        defer { sqlite3_finalize(statement) }
653

            
654
        var ids: [Int64] = []
655
        while sqlite3_step(statement) == SQLITE_ROW {
656
            ids.append(sqlite3_column_int64(statement, 0))
657
        }
658
        return ids
659
    }
660

            
Bogdan Timofte authored 2 weeks ago
661
    private func sampleVersionDebugRows(at url: URL) throws -> String {
662
        var db: OpaquePointer?
663
        guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
664
            sqlite3_close(db)
665
            return "could not open database"
666
        }
667
        defer { sqlite3_close(db) }
668

            
669
        let sql = """
670
        SELECT v.payload_hash, v.start_date, v.end_date, v.value_kind, v.numeric_value, v.unit,
671
               v.source_revision_id, src.bundle_identifier, sr.product_type, sr.version,
672
               sr.operating_system_version, v.hk_device_id, v.metadata_id
673
        FROM sample_versions v
674
        LEFT JOIN source_revisions sr ON sr.id = v.source_revision_id
675
        LEFT JOIN sources src ON src.id = sr.source_id
676
        ORDER BY v.id
677
        """
678
        var statement: OpaquePointer?
679
        guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else {
680
            sqlite3_finalize(statement)
681
            return "could not prepare version debug query"
682
        }
683
        defer { sqlite3_finalize(statement) }
684

            
685
        var rows: [String] = []
686
        while sqlite3_step(statement) == SQLITE_ROW {
687
            rows.append((0..<13).map { index in
688
                if sqlite3_column_type(statement, Int32(index)) == SQLITE_NULL {
689
                    return "null"
690
                }
691
                if let text = sqlite3_column_text(statement, Int32(index)) {
692
                    return String(cString: text)
693
                }
694
                return "\(sqlite3_column_double(statement, Int32(index)))"
695
            }.joined(separator: "|"))
696
        }
697
        return rows.joined(separator: "\n")
698
    }
Bogdan Timofte authored a week ago
699

            
700
    private func visibilityRangeDebugRows(at url: URL) throws -> String {
701
        var db: OpaquePointer?
702
        guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
703
            sqlite3_close(db)
704
            return "could not open database"
705
        }
706
        defer { sqlite3_close(db) }
707

            
708
        let sql = """
709
        SELECT sample_id, version_id, first_observation_id, last_observation_id, first_seen_at, last_seen_at
710
        FROM sample_visibility_ranges
711
        ORDER BY sample_id, version_id, first_observation_id
712
        """
713
        var statement: OpaquePointer?
714
        guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else {
715
            sqlite3_finalize(statement)
716
            return "could not prepare visibility debug query"
717
        }
718
        defer { sqlite3_finalize(statement) }
719

            
720
        var rows: [String] = []
721
        while sqlite3_step(statement) == SQLITE_ROW {
722
            rows.append((0..<6).map { index in
723
                if sqlite3_column_type(statement, Int32(index)) == SQLITE_NULL {
724
                    return "null"
725
                }
726
                if let text = sqlite3_column_text(statement, Int32(index)) {
727
                    return String(cString: text)
728
                }
729
                return "\(sqlite3_column_double(statement, Int32(index)))"
730
            }.joined(separator: "|"))
731
        }
732
        return rows.joined(separator: "\n")
733
    }
Bogdan Timofte authored 2 weeks ago
734
}