@@ -8,8 +8,19 @@ |
||
| 8 | 8 |
|
| 9 | 9 |
/* Begin PBXFileReference section */ |
| 10 | 10 |
439832792FA4933E003C0182 /* HealthProbe.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = HealthProbe.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
| 11 |
+ 43A100012FA5000000000001 /* HealthProbeTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = HealthProbeTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
|
| 11 | 12 |
/* End PBXFileReference section */ |
| 12 | 13 |
|
| 14 |
+/* Begin PBXContainerItemProxy section */ |
|
| 15 |
+ 43A1000B2FA5000000000001 /* PBXContainerItemProxy */ = {
|
|
| 16 |
+ isa = PBXContainerItemProxy; |
|
| 17 |
+ containerPortal = 439832712FA4933E003C0182 /* Project object */; |
|
| 18 |
+ proxyType = 1; |
|
| 19 |
+ remoteGlobalIDString = 439832782FA4933E003C0182; |
|
| 20 |
+ remoteInfo = HealthProbe; |
|
| 21 |
+ }; |
|
| 22 |
+/* End PBXContainerItemProxy section */ |
|
| 23 |
+ |
|
| 13 | 24 |
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ |
| 14 | 25 |
439832862FA4933F003C0182 /* Exceptions for "HealthProbe" folder in "HealthProbe" target */ = {
|
| 15 | 26 |
isa = PBXFileSystemSynchronizedBuildFileExceptionSet; |
@@ -44,6 +55,11 @@ |
||
| 44 | 55 |
path = HealthProbe; |
| 45 | 56 |
sourceTree = "<group>"; |
| 46 | 57 |
}; |
| 58 |
+ 43A100022FA5000000000001 /* HealthProbeTests */ = {
|
|
| 59 |
+ isa = PBXFileSystemSynchronizedRootGroup; |
|
| 60 |
+ path = HealthProbeTests; |
|
| 61 |
+ sourceTree = "<group>"; |
|
| 62 |
+ }; |
|
| 47 | 63 |
/* End PBXFileSystemSynchronizedRootGroup section */ |
| 48 | 64 |
|
| 49 | 65 |
/* Begin PBXFrameworksBuildPhase section */ |
@@ -54,6 +70,13 @@ |
||
| 54 | 70 |
); |
| 55 | 71 |
runOnlyForDeploymentPostprocessing = 0; |
| 56 | 72 |
}; |
| 73 |
+ 43A100042FA5000000000001 /* Frameworks */ = {
|
|
| 74 |
+ isa = PBXFrameworksBuildPhase; |
|
| 75 |
+ buildActionMask = 2147483647; |
|
| 76 |
+ files = ( |
|
| 77 |
+ ); |
|
| 78 |
+ runOnlyForDeploymentPostprocessing = 0; |
|
| 79 |
+ }; |
|
| 57 | 80 |
/* End PBXFrameworksBuildPhase section */ |
| 58 | 81 |
|
| 59 | 82 |
/* Begin PBXGroup section */ |
@@ -61,6 +84,7 @@ |
||
| 61 | 84 |
isa = PBXGroup; |
| 62 | 85 |
children = ( |
| 63 | 86 |
4398327B2FA4933E003C0182 /* HealthProbe */, |
| 87 |
+ 43A100022FA5000000000001 /* HealthProbeTests */, |
|
| 64 | 88 |
4398327A2FA4933E003C0182 /* Products */, |
| 65 | 89 |
); |
| 66 | 90 |
sourceTree = "<group>"; |
@@ -69,6 +93,7 @@ |
||
| 69 | 93 |
isa = PBXGroup; |
| 70 | 94 |
children = ( |
| 71 | 95 |
439832792FA4933E003C0182 /* HealthProbe.app */, |
| 96 |
+ 43A100012FA5000000000001 /* HealthProbeTests.xctest */, |
|
| 72 | 97 |
); |
| 73 | 98 |
name = Products; |
| 74 | 99 |
sourceTree = "<group>"; |
@@ -98,6 +123,29 @@ |
||
| 98 | 123 |
productReference = 439832792FA4933E003C0182 /* HealthProbe.app */; |
| 99 | 124 |
productType = "com.apple.product-type.application"; |
| 100 | 125 |
}; |
| 126 |
+ 43A100062FA5000000000001 /* HealthProbeTests */ = {
|
|
| 127 |
+ isa = PBXNativeTarget; |
|
| 128 |
+ buildConfigurationList = 43A100092FA5000000000001 /* Build configuration list for PBXNativeTarget "HealthProbeTests" */; |
|
| 129 |
+ buildPhases = ( |
|
| 130 |
+ 43A100032FA5000000000001 /* Sources */, |
|
| 131 |
+ 43A100042FA5000000000001 /* Frameworks */, |
|
| 132 |
+ 43A100052FA5000000000001 /* Resources */, |
|
| 133 |
+ ); |
|
| 134 |
+ buildRules = ( |
|
| 135 |
+ ); |
|
| 136 |
+ dependencies = ( |
|
| 137 |
+ 43A1000A2FA5000000000001 /* PBXTargetDependency */, |
|
| 138 |
+ ); |
|
| 139 |
+ fileSystemSynchronizedGroups = ( |
|
| 140 |
+ 43A100022FA5000000000001 /* HealthProbeTests */, |
|
| 141 |
+ ); |
|
| 142 |
+ name = HealthProbeTests; |
|
| 143 |
+ packageProductDependencies = ( |
|
| 144 |
+ ); |
|
| 145 |
+ productName = HealthProbeTests; |
|
| 146 |
+ productReference = 43A100012FA5000000000001 /* HealthProbeTests.xctest */; |
|
| 147 |
+ productType = "com.apple.product-type.bundle.unit-test"; |
|
| 148 |
+ }; |
|
| 101 | 149 |
/* End PBXNativeTarget section */ |
| 102 | 150 |
|
| 103 | 151 |
/* Begin PBXProject section */ |
@@ -116,6 +164,10 @@ |
||
| 116 | 164 |
}; |
| 117 | 165 |
}; |
| 118 | 166 |
}; |
| 167 |
+ 43A100062FA5000000000001 = {
|
|
| 168 |
+ CreatedOnToolsVersion = 26.4.1; |
|
| 169 |
+ TestTargetID = 439832782FA4933E003C0182; |
|
| 170 |
+ }; |
|
| 119 | 171 |
}; |
| 120 | 172 |
}; |
| 121 | 173 |
buildConfigurationList = 439832742FA4933E003C0182 /* Build configuration list for PBXProject "HealthProbe" */; |
@@ -133,6 +185,7 @@ |
||
| 133 | 185 |
projectRoot = ""; |
| 134 | 186 |
targets = ( |
| 135 | 187 |
439832782FA4933E003C0182 /* HealthProbe */, |
| 188 |
+ 43A100062FA5000000000001 /* HealthProbeTests */, |
|
| 136 | 189 |
); |
| 137 | 190 |
}; |
| 138 | 191 |
/* End PBXProject section */ |
@@ -145,6 +198,13 @@ |
||
| 145 | 198 |
); |
| 146 | 199 |
runOnlyForDeploymentPostprocessing = 0; |
| 147 | 200 |
}; |
| 201 |
+ 43A100052FA5000000000001 /* Resources */ = {
|
|
| 202 |
+ isa = PBXResourcesBuildPhase; |
|
| 203 |
+ buildActionMask = 2147483647; |
|
| 204 |
+ files = ( |
|
| 205 |
+ ); |
|
| 206 |
+ runOnlyForDeploymentPostprocessing = 0; |
|
| 207 |
+ }; |
|
| 148 | 208 |
/* End PBXResourcesBuildPhase section */ |
| 149 | 209 |
|
| 150 | 210 |
/* Begin PBXSourcesBuildPhase section */ |
@@ -155,8 +215,23 @@ |
||
| 155 | 215 |
); |
| 156 | 216 |
runOnlyForDeploymentPostprocessing = 0; |
| 157 | 217 |
}; |
| 218 |
+ 43A100032FA5000000000001 /* Sources */ = {
|
|
| 219 |
+ isa = PBXSourcesBuildPhase; |
|
| 220 |
+ buildActionMask = 2147483647; |
|
| 221 |
+ files = ( |
|
| 222 |
+ ); |
|
| 223 |
+ runOnlyForDeploymentPostprocessing = 0; |
|
| 224 |
+ }; |
|
| 158 | 225 |
/* End PBXSourcesBuildPhase section */ |
| 159 | 226 |
|
| 227 |
+/* Begin PBXTargetDependency section */ |
|
| 228 |
+ 43A1000A2FA5000000000001 /* PBXTargetDependency */ = {
|
|
| 229 |
+ isa = PBXTargetDependency; |
|
| 230 |
+ target = 439832782FA4933E003C0182 /* HealthProbe */; |
|
| 231 |
+ targetProxy = 43A1000B2FA5000000000001 /* PBXContainerItemProxy */; |
|
| 232 |
+ }; |
|
| 233 |
+/* End PBXTargetDependency section */ |
|
| 234 |
+ |
|
| 160 | 235 |
/* Begin XCBuildConfiguration section */ |
| 161 | 236 |
439832882FA4933F003C0182 /* Debug */ = {
|
| 162 | 237 |
isa = XCBuildConfiguration; |
@@ -234,6 +309,60 @@ |
||
| 234 | 309 |
}; |
| 235 | 310 |
name = Release; |
| 236 | 311 |
}; |
| 312 |
+ 43A100072FA5000000000001 /* Debug */ = {
|
|
| 313 |
+ isa = XCBuildConfiguration; |
|
| 314 |
+ buildSettings = {
|
|
| 315 |
+ BUNDLE_LOADER = "$(TEST_HOST)"; |
|
| 316 |
+ CODE_SIGN_STYLE = Automatic; |
|
| 317 |
+ CURRENT_PROJECT_VERSION = 1; |
|
| 318 |
+ DEVELOPMENT_TEAM = 9K2U3V9GZF; |
|
| 319 |
+ GENERATE_INFOPLIST_FILE = YES; |
|
| 320 |
+ IPHONEOS_DEPLOYMENT_TARGET = 26.4; |
|
| 321 |
+ LD_RUNPATH_SEARCH_PATHS = ( |
|
| 322 |
+ "$(inherited)", |
|
| 323 |
+ "@executable_path/Frameworks", |
|
| 324 |
+ "@loader_path/Frameworks", |
|
| 325 |
+ ); |
|
| 326 |
+ MARKETING_VERSION = 1.0; |
|
| 327 |
+ PRODUCT_BUNDLE_IDENTIFIER = ro.xdev.HealthProbeTests; |
|
| 328 |
+ PRODUCT_NAME = "$(TARGET_NAME)"; |
|
| 329 |
+ SDKROOT = iphoneos; |
|
| 330 |
+ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; |
|
| 331 |
+ SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; |
|
| 332 |
+ SWIFT_VERSION = 5.0; |
|
| 333 |
+ TARGETED_DEVICE_FAMILY = 1; |
|
| 334 |
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/HealthProbe.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/HealthProbe"; |
|
| 335 |
+ TEST_TARGET_NAME = HealthProbe; |
|
| 336 |
+ }; |
|
| 337 |
+ name = Debug; |
|
| 338 |
+ }; |
|
| 339 |
+ 43A100082FA5000000000001 /* Release */ = {
|
|
| 340 |
+ isa = XCBuildConfiguration; |
|
| 341 |
+ buildSettings = {
|
|
| 342 |
+ BUNDLE_LOADER = "$(TEST_HOST)"; |
|
| 343 |
+ CODE_SIGN_STYLE = Automatic; |
|
| 344 |
+ CURRENT_PROJECT_VERSION = 1; |
|
| 345 |
+ DEVELOPMENT_TEAM = 9K2U3V9GZF; |
|
| 346 |
+ GENERATE_INFOPLIST_FILE = YES; |
|
| 347 |
+ IPHONEOS_DEPLOYMENT_TARGET = 26.4; |
|
| 348 |
+ LD_RUNPATH_SEARCH_PATHS = ( |
|
| 349 |
+ "$(inherited)", |
|
| 350 |
+ "@executable_path/Frameworks", |
|
| 351 |
+ "@loader_path/Frameworks", |
|
| 352 |
+ ); |
|
| 353 |
+ MARKETING_VERSION = 1.0; |
|
| 354 |
+ PRODUCT_BUNDLE_IDENTIFIER = ro.xdev.HealthProbeTests; |
|
| 355 |
+ PRODUCT_NAME = "$(TARGET_NAME)"; |
|
| 356 |
+ SDKROOT = iphoneos; |
|
| 357 |
+ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; |
|
| 358 |
+ SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; |
|
| 359 |
+ SWIFT_VERSION = 5.0; |
|
| 360 |
+ TARGETED_DEVICE_FAMILY = 1; |
|
| 361 |
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/HealthProbe.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/HealthProbe"; |
|
| 362 |
+ TEST_TARGET_NAME = HealthProbe; |
|
| 363 |
+ }; |
|
| 364 |
+ name = Release; |
|
| 365 |
+ }; |
|
| 237 | 366 |
4398328A2FA4933F003C0182 /* Debug */ = {
|
| 238 | 367 |
isa = XCBuildConfiguration; |
| 239 | 368 |
buildSettings = {
|
@@ -378,6 +507,15 @@ |
||
| 378 | 507 |
defaultConfigurationIsVisible = 0; |
| 379 | 508 |
defaultConfigurationName = Release; |
| 380 | 509 |
}; |
| 510 |
+ 43A100092FA5000000000001 /* Build configuration list for PBXNativeTarget "HealthProbeTests" */ = {
|
|
| 511 |
+ isa = XCConfigurationList; |
|
| 512 |
+ buildConfigurations = ( |
|
| 513 |
+ 43A100072FA5000000000001 /* Debug */, |
|
| 514 |
+ 43A100082FA5000000000001 /* Release */, |
|
| 515 |
+ ); |
|
| 516 |
+ defaultConfigurationIsVisible = 0; |
|
| 517 |
+ defaultConfigurationName = Release; |
|
| 518 |
+ }; |
|
| 381 | 519 |
/* End XCConfigurationList section */ |
| 382 | 520 |
}; |
| 383 | 521 |
rootObject = 439832712FA4933E003C0182 /* Project object */; |
@@ -21,6 +21,20 @@ |
||
| 21 | 21 |
ReferencedContainer = "container:HealthProbe.xcodeproj"> |
| 22 | 22 |
</BuildableReference> |
| 23 | 23 |
</BuildActionEntry> |
| 24 |
+ <BuildActionEntry |
|
| 25 |
+ buildForTesting = "YES" |
|
| 26 |
+ buildForRunning = "NO" |
|
| 27 |
+ buildForProfiling = "NO" |
|
| 28 |
+ buildForArchiving = "NO" |
|
| 29 |
+ buildForAnalyzing = "NO"> |
|
| 30 |
+ <BuildableReference |
|
| 31 |
+ BuildableIdentifier = "primary" |
|
| 32 |
+ BlueprintIdentifier = "43A100062FA5000000000001" |
|
| 33 |
+ BuildableName = "HealthProbeTests.xctest" |
|
| 34 |
+ BlueprintName = "HealthProbeTests" |
|
| 35 |
+ ReferencedContainer = "container:HealthProbe.xcodeproj"> |
|
| 36 |
+ </BuildableReference> |
|
| 37 |
+ </BuildActionEntry> |
|
| 24 | 38 |
</BuildActionEntries> |
| 25 | 39 |
</BuildAction> |
| 26 | 40 |
<TestAction |
@@ -29,6 +43,19 @@ |
||
| 29 | 43 |
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" |
| 30 | 44 |
shouldUseLaunchSchemeArgsEnv = "YES" |
| 31 | 45 |
shouldAutocreateTestPlan = "YES"> |
| 46 |
+ <Testables> |
|
| 47 |
+ <TestableReference |
|
| 48 |
+ skipped = "NO" |
|
| 49 |
+ parallelizable = "YES"> |
|
| 50 |
+ <BuildableReference |
|
| 51 |
+ BuildableIdentifier = "primary" |
|
| 52 |
+ BlueprintIdentifier = "43A100062FA5000000000001" |
|
| 53 |
+ BuildableName = "HealthProbeTests.xctest" |
|
| 54 |
+ BlueprintName = "HealthProbeTests" |
|
| 55 |
+ ReferencedContainer = "container:HealthProbe.xcodeproj"> |
|
| 56 |
+ </BuildableReference> |
|
| 57 |
+ </TestableReference> |
|
| 58 |
+ </Testables> |
|
| 32 | 59 |
</TestAction> |
| 33 | 60 |
<LaunchAction |
| 34 | 61 |
buildConfiguration = "Debug" |
@@ -338,4 +338,4 @@ When one agent needs to communicate a decision or change to another: |
||
| 338 | 338 |
| UI – Snapshots + Detail | ✅ Done | Claude Code | |
| 339 | 339 |
| UI – Data Types | ✅ Done | Claude Code | |
| 340 | 340 |
| UI – Settings | ✅ Done | Claude Code | |
| 341 |
-| Unit Tests | ⏳ Not started | Tests agent | |
|
| 341 |
+| Unit Tests | ⏳ Started: SQLite archive init/reset/idempotency tests | Tests agent | |
|
@@ -25,7 +25,7 @@ There are no real deployments, only test installations. Existing prototype datab |
||
| 25 | 25 |
|------|----------------|--------------------| |
| 26 | 26 |
| Product docs | Updated | Keep `HealthProbe/Doc/README.md` as canonical index | |
| 27 | 27 |
| HealthKit capture | Prototype exists | Adapt capture to write differential SQLite observations first | |
| 28 |
-| SQLite archive | Archive v2 schema, differential write path, daily aggregate rebuilds, integrity report, and v2 record reads are partially implemented; legacy write mirror still exists | Add diff SQL, open/schema-version tests, idempotency tests, then retire `archive_samples` | |
|
| 28 |
+| SQLite archive | Archive v2 schema, differential write path, daily aggregate rebuilds, integrity report, v2 record reads, and initial XCTest coverage are in place; legacy write mirror still exists | Add SQL diff/count queries, large synthetic-data tests, then retire `archive_samples` | |
|
| 29 | 29 |
| Core Data cache | Not implemented | Add rebuildable cache for expensive counts, summaries, report metadata, UI state | |
| 30 | 30 |
| SwiftData cache | Exists | Treat as disposable prototype data; reset/ignore during v2 transition | |
| 31 | 31 |
| UI | Prototype exists | Reframe screens around observations, diffs, export, archive status | |
@@ -38,14 +38,13 @@ There are no real deployments, only test installations. Existing prototype datab |
||
| 38 | 38 |
|
| 39 | 39 |
Detailed checkable milestones live in [`Refactoring-Plan.md`](Refactoring-Plan.md). |
| 40 | 40 |
|
| 41 |
-1. Finish differential write path hardening with retry/idempotency tests. |
|
| 42 |
-2. Add SQLite integrity/open/schema-version tests. |
|
| 43 |
-3. Move large diffs/counts into SQL queries with indexes/temp tables/paged results. |
|
| 44 |
-4. Add Core Data UI/report cache and rebuild pipeline. |
|
| 45 |
-5. Replace SwiftData UI dependencies with Core Data/cache DTOs. |
|
| 46 |
-6. Update UI language from anomaly/status to observation/diff/export. |
|
| 47 |
-7. Add streaming exports with manifests. |
|
| 48 |
-8. Validate on low-memory/legacy-class devices. |
|
| 41 |
+1. Move large diffs/counts into SQL queries with indexes/temp tables/paged results. |
|
| 42 |
+2. Expand the synthetic large-data test harness for diff/export memory behavior. |
|
| 43 |
+3. Add Core Data UI/report cache and rebuild pipeline. |
|
| 44 |
+4. Replace SwiftData UI dependencies with Core Data/cache DTOs. |
|
| 45 |
+5. Update UI language from anomaly/status to observation/diff/export. |
|
| 46 |
+6. Add streaming exports with manifests. |
|
| 47 |
+7. Validate on low-memory/legacy-class devices. |
|
| 49 | 48 |
|
| 50 | 49 |
## Known Prototype Mismatches |
| 51 | 50 |
|
@@ -55,6 +54,7 @@ Detailed checkable milestones live in [`Refactoring-Plan.md`](Refactoring-Plan.m |
||
| 55 | 54 |
- Current archive schema is not sufficient as the long-term source of truth. |
| 56 | 55 |
- Existing implementation may decode or cache too much data for low-end devices. |
| 57 | 56 |
- Old prototype database compatibility is no longer required. |
| 57 |
+- Initial SQLite archive tests cover open/init/reset/idempotency, but not yet large-volume diff/export behavior. |
|
| 58 | 58 |
|
| 59 | 59 |
## Verification Checklist |
| 60 | 60 |
|
@@ -103,15 +103,15 @@ Checklist: |
||
| 103 | 103 |
- [x] Implement `export_items`. |
| 104 | 104 |
- [x] Add required indexes. |
| 105 | 105 |
- [x] Add archive integrity report for schema version, required tables, `PRAGMA integrity_check`, and foreign keys. |
| 106 |
-- [ ] Add SQLite integrity/open/schema-version tests. |
|
| 106 |
+- [x] Add SQLite integrity/open/schema-version tests. |
|
| 107 | 107 |
|
| 108 | 108 |
Acceptance: |
| 109 |
-- [ ] Fresh archive initializes successfully. |
|
| 109 |
+- [x] Fresh archive initializes successfully. |
|
| 110 | 110 |
- [x] Schema version is recorded. |
| 111 | 111 |
- [x] Archive v2 can initialize after old prototype stores are removed or ignored. |
| 112 |
-- [ ] `PRAGMA integrity_check` passes. |
|
| 112 |
+- [x] `PRAGMA integrity_check` passes. |
|
| 113 | 113 |
- [x] Required indexes exist. |
| 114 |
-- [ ] Empty archive queries return valid empty results. |
|
| 114 |
+- [x] Empty archive queries return valid empty results. |
|
| 115 | 115 |
|
| 116 | 116 |
## Milestone 4 - Differential Write Path |
| 117 | 117 |
|
@@ -129,7 +129,7 @@ Checklist: |
||
| 129 | 129 |
- [x] Maintain open visibility ranges for visible samples. |
| 130 | 130 |
- [x] Rebuild/update affected type summaries and daily aggregates after capture/delete observations. |
| 131 | 131 |
- [x] Commit SQLite before Core Data/cache work. |
| 132 |
-- [ ] Make repeated capture page writes idempotent. |
|
| 132 |
+- [x] Make repeated capture page writes idempotent. |
|
| 133 | 133 |
|
| 134 | 134 |
Acceptance: |
| 135 | 135 |
- [x] Initial import stores identities and versions once. |
@@ -1010,8 +1010,18 @@ actor SQLiteHealthArchiveStore: HealthArchiveStore {
|
||
| 1010 | 1010 |
guard row.sourceName != nil || row.sourceBundleIdentifier != nil else { return nil }
|
| 1011 | 1011 |
let sourceNameHash = row.sourceName.map { HashService.archiveContentHash(domain: "hp:v2:source_name", parts: [$0]) }
|
| 1012 | 1012 |
let sourceID = try upsertSource(sourceNameHash: sourceNameHash, bundleIdentifier: row.sourceBundleIdentifier, db: db) |
| 1013 |
+ if let existing = try sourceRevisionID( |
|
| 1014 |
+ sourceID: sourceID, |
|
| 1015 |
+ productType: row.sourceProductType, |
|
| 1016 |
+ version: row.sourceVersion, |
|
| 1017 |
+ operatingSystemVersion: row.sourceOperatingSystemVersion, |
|
| 1018 |
+ db: db |
|
| 1019 |
+ ) {
|
|
| 1020 |
+ return existing |
|
| 1021 |
+ } |
|
| 1022 |
+ |
|
| 1013 | 1023 |
try withStatement( |
| 1014 |
- "INSERT OR IGNORE INTO source_revisions (source_id, product_type, version, operating_system_version) VALUES (?, ?, ?, ?)", |
|
| 1024 |
+ "INSERT INTO source_revisions (source_id, product_type, version, operating_system_version) VALUES (?, ?, ?, ?)", |
|
| 1015 | 1025 |
db: db |
| 1016 | 1026 |
) { statement in
|
| 1017 | 1027 |
bindInt64(sourceID, to: 1, in: statement) |
@@ -1022,7 +1032,17 @@ actor SQLiteHealthArchiveStore: HealthArchiveStore {
|
||
| 1022 | 1032 |
throw SQLiteHealthArchiveStoreError.stepFailed(lastErrorMessage(db)) |
| 1023 | 1033 |
} |
| 1024 | 1034 |
} |
| 1025 |
- return try requiredInt64( |
|
| 1035 |
+ return sqlite3_last_insert_rowid(db) |
|
| 1036 |
+ } |
|
| 1037 |
+ |
|
| 1038 |
+ private func sourceRevisionID( |
|
| 1039 |
+ sourceID: Int64, |
|
| 1040 |
+ productType: String?, |
|
| 1041 |
+ version: String?, |
|
| 1042 |
+ operatingSystemVersion: String?, |
|
| 1043 |
+ db: OpaquePointer? |
|
| 1044 |
+ ) throws -> Int64? {
|
|
| 1045 |
+ try optionalInt64( |
|
| 1026 | 1046 |
""" |
| 1027 | 1047 |
SELECT id FROM source_revisions |
| 1028 | 1048 |
WHERE source_id = ? |
@@ -1034,12 +1054,12 @@ actor SQLiteHealthArchiveStore: HealthArchiveStore {
|
||
| 1034 | 1054 |
db: db |
| 1035 | 1055 |
) { statement in
|
| 1036 | 1056 |
bindInt64(sourceID, to: 1, in: statement) |
| 1037 |
- bindText(row.sourceProductType, to: 2, in: statement) |
|
| 1038 |
- bindText(row.sourceProductType, to: 3, in: statement) |
|
| 1039 |
- bindText(row.sourceVersion, to: 4, in: statement) |
|
| 1040 |
- bindText(row.sourceVersion, to: 5, in: statement) |
|
| 1041 |
- bindText(row.sourceOperatingSystemVersion, to: 6, in: statement) |
|
| 1042 |
- bindText(row.sourceOperatingSystemVersion, to: 7, in: statement) |
|
| 1057 |
+ bindText(productType, to: 2, in: statement) |
|
| 1058 |
+ bindText(productType, to: 3, in: statement) |
|
| 1059 |
+ bindText(version, to: 4, in: statement) |
|
| 1060 |
+ bindText(version, to: 5, in: statement) |
|
| 1061 |
+ bindText(operatingSystemVersion, to: 6, in: statement) |
|
| 1062 |
+ bindText(operatingSystemVersion, to: 7, in: statement) |
|
| 1043 | 1063 |
} |
| 1044 | 1064 |
} |
| 1045 | 1065 |
|
@@ -1831,8 +1851,13 @@ private struct ArchiveSampleRow {
|
||
| 1831 | 1851 |
} |
| 1832 | 1852 |
} |
| 1833 | 1853 |
|
| 1834 |
- nonisolated private static func operatingSystemVersionString(_ version: OperatingSystemVersion) -> String {
|
|
| 1835 |
- "\(version.majorVersion).\(version.minorVersion).\(version.patchVersion)" |
|
| 1854 |
+ nonisolated private static func operatingSystemVersionString(_ version: OperatingSystemVersion) -> String? {
|
|
| 1855 |
+ guard (0...100).contains(version.majorVersion), |
|
| 1856 |
+ (0...1_000).contains(version.minorVersion), |
|
| 1857 |
+ (0...1_000).contains(version.patchVersion) else {
|
|
| 1858 |
+ return nil |
|
| 1859 |
+ } |
|
| 1860 |
+ return "\(version.majorVersion).\(version.minorVersion).\(version.patchVersion)" |
|
| 1836 | 1861 |
} |
| 1837 | 1862 |
} |
| 1838 | 1863 |
|
@@ -0,0 +1,165 @@ |
||
| 1 |
+import HealthKit |
|
| 2 |
+import SQLite3 |
|
| 3 |
+import XCTest |
|
| 4 |
+@testable import HealthProbe |
|
| 5 |
+ |
|
| 6 |
+final class SQLiteHealthArchiveStoreTests: XCTestCase {
|
|
| 7 |
+ private var temporaryDirectory: URL! |
|
| 8 |
+ |
|
| 9 |
+ override func setUpWithError() throws {
|
|
| 10 |
+ temporaryDirectory = FileManager.default.temporaryDirectory |
|
| 11 |
+ .appending(path: "HealthProbeTests-\(UUID().uuidString)", directoryHint: .isDirectory) |
|
| 12 |
+ try FileManager.default.createDirectory(at: temporaryDirectory, withIntermediateDirectories: true) |
|
| 13 |
+ } |
|
| 14 |
+ |
|
| 15 |
+ override func tearDownWithError() throws {
|
|
| 16 |
+ if let temporaryDirectory {
|
|
| 17 |
+ try? FileManager.default.removeItem(at: temporaryDirectory) |
|
| 18 |
+ } |
|
| 19 |
+ temporaryDirectory = nil |
|
| 20 |
+ } |
|
| 21 |
+ |
|
| 22 |
+ func testFreshArchiveInitializesSchemaAndPassesIntegrity() async throws {
|
|
| 23 |
+ let store = SQLiteHealthArchiveStore(databaseURL: databaseURL()) |
|
| 24 |
+ |
|
| 25 |
+ let report = try await store.checkIntegrity() |
|
| 26 |
+ let records = try await store.records(for: HealthArchiveRecordRequest( |
|
| 27 |
+ sampleTypeIdentifier: HKQuantityTypeIdentifier.stepCount.rawValue |
|
| 28 |
+ )) |
|
| 29 |
+ |
|
| 30 |
+ XCTAssertTrue(report.passed) |
|
| 31 |
+ XCTAssertEqual(report.schemaVersion, 2) |
|
| 32 |
+ XCTAssertEqual(report.sqliteIntegrityStatus, "ok") |
|
| 33 |
+ XCTAssertEqual(report.foreignKeyIssueCount, 0) |
|
| 34 |
+ XCTAssertTrue(report.missingTableNames.isEmpty) |
|
| 35 |
+ XCTAssertTrue(report.requiredTableNames.contains("sample_visibility_ranges"))
|
|
| 36 |
+ XCTAssertTrue(report.requiredTableNames.contains("daily_type_aggregates"))
|
|
| 37 |
+ XCTAssertTrue(records.isEmpty) |
|
| 38 |
+ } |
|
| 39 |
+ |
|
| 40 |
+ func testPrototypeArchiveIsResetAndReinitializedAsV2() async throws {
|
|
| 41 |
+ let url = databaseURL() |
|
| 42 |
+ try createPrototypeDatabase(at: url) |
|
| 43 |
+ let store = SQLiteHealthArchiveStore(databaseURL: url) |
|
| 44 |
+ |
|
| 45 |
+ let report = try await store.checkIntegrity() |
|
| 46 |
+ |
|
| 47 |
+ XCTAssertTrue(report.passed) |
|
| 48 |
+ XCTAssertEqual(report.schemaVersion, 2) |
|
| 49 |
+ XCTAssertTrue(report.missingTableNames.isEmpty) |
|
| 50 |
+ } |
|
| 51 |
+ |
|
| 52 |
+ func testRepeatedSamplePageDoesNotDuplicateIdentityOrVersion() async throws {
|
|
| 53 |
+ let url = databaseURL() |
|
| 54 |
+ let store = SQLiteHealthArchiveStore(databaseURL: url) |
|
| 55 |
+ let sample = makeStepCountSample() |
|
| 56 |
+ |
|
| 57 |
+ let firstWrite = try await store.upsertSamples([sample], observedAt: Date(timeIntervalSince1970: 2_000)) |
|
| 58 |
+ let secondWrite = try await store.upsertSamples([sample], observedAt: Date(timeIntervalSince1970: 2_060)) |
|
| 59 |
+ let records = try await store.records(for: HealthArchiveRecordRequest( |
|
| 60 |
+ sampleTypeIdentifier: sample.sampleType.identifier |
|
| 61 |
+ )) |
|
| 62 |
+ let report = try await store.checkIntegrity() |
|
| 63 |
+ let versionDebugRows = try sampleVersionDebugRows(at: url) |
|
| 64 |
+ |
|
| 65 |
+ XCTAssertEqual(firstWrite.insertedCount, 1) |
|
| 66 |
+ XCTAssertEqual(firstWrite.updatedCount, 0) |
|
| 67 |
+ XCTAssertEqual(firstWrite.unchangedCount, 0) |
|
| 68 |
+ XCTAssertEqual(try countRows(in: "samples", at: url), 1) |
|
| 69 |
+ XCTAssertEqual(try countRows(in: "sample_versions", at: url), 1, versionDebugRows) |
|
| 70 |
+ XCTAssertEqual(try countRows(in: "sample_visibility_ranges", at: url), 1) |
|
| 71 |
+ XCTAssertEqual(try countRows(in: "source_revisions", at: url), 1) |
|
| 72 |
+ XCTAssertEqual(secondWrite.insertedCount, 0) |
|
| 73 |
+ XCTAssertEqual(secondWrite.updatedCount, 0) |
|
| 74 |
+ XCTAssertEqual(secondWrite.unchangedCount, 1) |
|
| 75 |
+ XCTAssertEqual(records.count, 1) |
|
| 76 |
+ XCTAssertEqual(records.first?.displayValue, "42.0 count") |
|
| 77 |
+ XCTAssertTrue(report.passed) |
|
| 78 |
+ } |
|
| 79 |
+ |
|
| 80 |
+ private func databaseURL() -> URL {
|
|
| 81 |
+ temporaryDirectory.appending(path: "Archive.sqlite") |
|
| 82 |
+ } |
|
| 83 |
+ |
|
| 84 |
+ private func createPrototypeDatabase(at url: URL) throws {
|
|
| 85 |
+ var db: OpaquePointer? |
|
| 86 |
+ guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_CREATE | SQLITE_OPEN_READWRITE | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
|
|
| 87 |
+ sqlite3_close(db) |
|
| 88 |
+ XCTFail("Could not create prototype database")
|
|
| 89 |
+ return |
|
| 90 |
+ } |
|
| 91 |
+ defer { sqlite3_close(db) }
|
|
| 92 |
+ let status = sqlite3_exec(db, "CREATE TABLE archive_samples (id TEXT PRIMARY KEY)", nil, nil, nil) |
|
| 93 |
+ XCTAssertEqual(status, SQLITE_OK) |
|
| 94 |
+ } |
|
| 95 |
+ |
|
| 96 |
+ private func makeStepCountSample() -> HKQuantitySample {
|
|
| 97 |
+ let quantityType = HKQuantityType.quantityType(forIdentifier: .stepCount)! |
|
| 98 |
+ let quantity = HKQuantity(unit: .count(), doubleValue: 42) |
|
| 99 |
+ let start = Date(timeIntervalSince1970: 1_000) |
|
| 100 |
+ let end = Date(timeIntervalSince1970: 1_300) |
|
| 101 |
+ return HKQuantitySample(type: quantityType, quantity: quantity, start: start, end: end) |
|
| 102 |
+ } |
|
| 103 |
+ |
|
| 104 |
+ private func countRows(in tableName: String, at url: URL) throws -> Int {
|
|
| 105 |
+ var db: OpaquePointer? |
|
| 106 |
+ guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
|
|
| 107 |
+ sqlite3_close(db) |
|
| 108 |
+ XCTFail("Could not open test database")
|
|
| 109 |
+ return 0 |
|
| 110 |
+ } |
|
| 111 |
+ defer { sqlite3_close(db) }
|
|
| 112 |
+ |
|
| 113 |
+ var statement: OpaquePointer? |
|
| 114 |
+ guard sqlite3_prepare_v2(db, "SELECT COUNT(*) FROM \(tableName)", -1, &statement, nil) == SQLITE_OK else {
|
|
| 115 |
+ sqlite3_finalize(statement) |
|
| 116 |
+ XCTFail("Could not prepare count query")
|
|
| 117 |
+ return 0 |
|
| 118 |
+ } |
|
| 119 |
+ defer { sqlite3_finalize(statement) }
|
|
| 120 |
+ |
|
| 121 |
+ guard sqlite3_step(statement) == SQLITE_ROW else {
|
|
| 122 |
+ return 0 |
|
| 123 |
+ } |
|
| 124 |
+ return Int(sqlite3_column_int(statement, 0)) |
|
| 125 |
+ } |
|
| 126 |
+ |
|
| 127 |
+ private func sampleVersionDebugRows(at url: URL) throws -> String {
|
|
| 128 |
+ var db: OpaquePointer? |
|
| 129 |
+ guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
|
|
| 130 |
+ sqlite3_close(db) |
|
| 131 |
+ return "could not open database" |
|
| 132 |
+ } |
|
| 133 |
+ defer { sqlite3_close(db) }
|
|
| 134 |
+ |
|
| 135 |
+ let sql = """ |
|
| 136 |
+ SELECT v.payload_hash, v.start_date, v.end_date, v.value_kind, v.numeric_value, v.unit, |
|
| 137 |
+ v.source_revision_id, src.bundle_identifier, sr.product_type, sr.version, |
|
| 138 |
+ sr.operating_system_version, v.hk_device_id, v.metadata_id |
|
| 139 |
+ FROM sample_versions v |
|
| 140 |
+ LEFT JOIN source_revisions sr ON sr.id = v.source_revision_id |
|
| 141 |
+ LEFT JOIN sources src ON src.id = sr.source_id |
|
| 142 |
+ ORDER BY v.id |
|
| 143 |
+ """ |
|
| 144 |
+ var statement: OpaquePointer? |
|
| 145 |
+ guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else {
|
|
| 146 |
+ sqlite3_finalize(statement) |
|
| 147 |
+ return "could not prepare version debug query" |
|
| 148 |
+ } |
|
| 149 |
+ defer { sqlite3_finalize(statement) }
|
|
| 150 |
+ |
|
| 151 |
+ var rows: [String] = [] |
|
| 152 |
+ while sqlite3_step(statement) == SQLITE_ROW {
|
|
| 153 |
+ rows.append((0..<13).map { index in
|
|
| 154 |
+ if sqlite3_column_type(statement, Int32(index)) == SQLITE_NULL {
|
|
| 155 |
+ return "null" |
|
| 156 |
+ } |
|
| 157 |
+ if let text = sqlite3_column_text(statement, Int32(index)) {
|
|
| 158 |
+ return String(cString: text) |
|
| 159 |
+ } |
|
| 160 |
+ return "\(sqlite3_column_double(statement, Int32(index)))" |
|
| 161 |
+ }.joined(separator: "|")) |
|
| 162 |
+ } |
|
| 163 |
+ return rows.joined(separator: "\n") |
|
| 164 |
+ } |
|
| 165 |
+} |
|