diff --git a/.agents/skills/upgrade-where-backup/scripts/upgrade_and_verify.rb b/.agents/skills/upgrade-where-backup/scripts/upgrade_and_verify.rb index 605bc313c..396ee8c40 100644 --- a/.agents/skills/upgrade-where-backup/scripts/upgrade_and_verify.rb +++ b/.agents/skills/upgrade-where-backup/scripts/upgrade_and_verify.rb @@ -13,6 +13,7 @@ class UpgradeAndVerify PRESERVED_COLLECTIONS = %w[ samples evidence manualDays dismissedIssues trackedRegions recordingDeviceProfiles recordingDeviceMetadataChanges recordingDeviceRemovals assets + plannedStayRecords sampleAttributionRevisions ].freeze def initialize(argv) @@ -148,6 +149,8 @@ def verification_configuration(manifest) deviceProfilesCount: manifest.fetch("recordingDeviceProfiles").length, deviceChangesCount: manifest.fetch("recordingDeviceMetadataChanges").length, deviceRemovalsCount: manifest.fetch("recordingDeviceRemovals").length, + plannedStayRecordsCount: manifest.fetch("plannedStayRecords").length, + sampleAttributionRevisionsCount: manifest.fetch("sampleAttributionRevisions").length, assetsCount: manifest.fetch("assets").length, } end @@ -177,9 +180,11 @@ def fail_with(message) end end -begin - UpgradeAndVerify.new(ARGV).run -rescue OptionParser::ParseError, RuntimeError => error - warn "error: #{error.message}" - exit 1 +if $PROGRAM_NAME == __FILE__ + begin + UpgradeAndVerify.new(ARGV).run + rescue OptionParser::ParseError, RuntimeError => error + warn "error: #{error.message}" + exit 1 + end end diff --git a/.bumper/RULES.md b/.bumper/RULES.md index 21b3d5633..2aa83c680 100644 --- a/.bumper/RULES.md +++ b/.bumper/RULES.md @@ -109,10 +109,13 @@ and retained as the closest lower-level pattern. `where.store_transaction_boundary` requires calls to the mutating `WhereStore` surface through `store` or `self.store` to be lexically contained by `store.perform { ... }` or `store.performInCurrentGeneration { ... }`. +Attribution revisions use this same boundary; their coordinator also requires +the reviewed data generation and reassesses before writing. +Guard: `sample attribution revisions require a guarded transaction`. The checked methods are `add`, `write`, `setManualDay`, `clearManualDay`, `clear`, `clearAll`, `setIssueDismissed`, `restoreDismissedIssue`, -`setTrackedRegion`, and `setPrimaryRegions`. +`setTrackedRegion`, `setPrimaryRegions`, and `addSampleAttributionRevision`. ## App Shortcuts provider ownership diff --git a/.bumper/Sources/WhereProjectRules.swift b/.bumper/Sources/WhereProjectRules.swift index 0e047ad3b..0e6ec75a0 100644 --- a/.bumper/Sources/WhereProjectRules.swift +++ b/.bumper/Sources/WhereProjectRules.swift @@ -147,6 +147,7 @@ private let whereStoreMutatingMethods: Set = [ "restoreDismissedIssue", "setTrackedRegion", "setPrimaryRegions", + "addSampleAttributionRevision", ] private let storeTransactionBoundaryRule = Rules.files( diff --git a/.bumper/Tests/WhereProjectRulesTests.swift b/.bumper/Tests/WhereProjectRulesTests.swift index d42a8c7c9..4ab1edf3d 100644 --- a/.bumper/Tests/WhereProjectRulesTests.swift +++ b/.bumper/Tests/WhereProjectRulesTests.swift @@ -223,6 +223,22 @@ struct WhereProjectRulesTests { #expect(violation.path == rejectedPath) } + @Test + func `sample attribution revisions require a guarded transaction`() throws { + let allowed = try evaluate( + path: "Where/WhereCore/Sources/Corrections.swift", + component: .whereCore, + source: "func apply() async throws { try await store.perform(expectedDataGenerationID: generation) { try await store.addSampleAttributionRevision(revision) } }", + ) + let rejected = try evaluate( + path: "Where/WhereCore/Sources/UnguardedCorrections.swift", + component: .whereCore, + source: "func apply() async throws { try await store.addSampleAttributionRevision(revision) }", + ) + #expect(allowed.violations.isEmpty) + #expect(rejected.violations.map(\.rule.id) == ["where.store_transaction_boundary"]) + } + @Test func `AppShortcutsProvider stays in the app target`() throws { let allowed = try evaluate( diff --git a/Where/TODOs.md b/Where/TODOs.md index c375572e7..33710303f 100644 --- a/Where/TODOs.md +++ b/Where/TODOs.md @@ -31,7 +31,7 @@ The item format and the placement rule live in the root - fix(WhereCore) [needs-design]: The retry queue evicts FIFO at its 1000-sample capacity and drops samples with a warning only (`LocationIngestor.swift:502-507`, capacity default at `:112`, event case at `LocationIngestorLog.swift:53` with its `warning` level at `:64-65` and message at `:91-92` — the previously cited `:76-77` was a different event's message). Decide the capacity policy and whether eviction warrants user-visible degradation, then document it. (audit 2026-07-26; re-verified 2026-08-30) - fix(WhereUI) [quick-win]: `PresenceTimelineList` returns `[]` whenever `report.report` is nil (`PresenceTimelineList.swift:30-31`, empty-state branch at `:43`), so the Timeline segment of Your Year renders the "no stays" empty state while the year is still loading (and during a year switch) — unlike the Calendar segment beside it, which gates on `loadState` (`CalendarContentView.swift:64-66`). PR #307 moved the branch while adding joined planned stays and the estimate panel but left the missing load gate unchanged. (audit 2026-07-26; re-verified 2026-09-06 after PR #307) - refactor(WhereUI) [needs-design]: Extract a shared `ReportLoadGate`. The same `YearReportModel.loadState` gate is copy-pasted across `LocationsView.swift:151-176`, `ElsewhereView.swift:51-70`, `ResolutionView.swift:59-89`, and `CalendarContentView.swift:64-74`, and `PresenceTimelineList` skips it entirely (above). One gate view would cover all five. (audit 2026-07-26; re-verified 2026-09-06) -- fix(WhereUI) [quick-win]: The Elsewhere entry card renders raw inflection markup instead of an agreed region count — it shows literally `^[3 region](inflect: true)`. `locations.elsewhere.subtitle` is authored for automatic grammar agreement (`^[%lld region](inflect: true)`), but the string-catalog compiler passes that markup through **verbatim** into the compiled `Localizable.strings` (unlike a real plural such as `primary.elsewhereOnly.description`, which compiles to an `NSStringLocalizedFormatKey` dict), and flattening the resource to a `String` never runs the inflection engine. Pre-existing — the catalog entry is byte-identical on `main` and predates the String Catalog symbol migration. Fix by either rendering the resource directly so SwiftUI applies inflection (`Text(.locationsElsewhereSubtitle(regionCount))` in `ElsewhereSummaryCard.swift:29`, dropping the `WhereFormat.elsewhereCardSubtitle` hop at `WhereFormat.swift:49-50`) or replacing the markup with an explicit plural variation. `WhereFormatTests.elsewhereCardSubtitleInflectsTheRegionCount` (`WhereFormatTests.swift:92-101`, the `withKnownIssue` at `:98` — moved when PR #302 added format tests above it) pins the expected output behind `withKnownIssue`, so it trips as soon as this is fixed. The bug is also baked into the `locations.Loaded_iPad.png` reference (ledgered in the broken-snapshots cluster below) — PR #302 re-recorded the Locations references for the sticker redesign with the broken hop untouched, so re-record that image again when this lands. (agent; citations refreshed 2026-09-06) +- fix(WhereUI) [quick-win]: The Elsewhere entry card renders raw inflection markup instead of an agreed region count — it shows literally `^[3 region](inflect: true)`. `locations.elsewhere.subtitle` is authored for automatic grammar agreement (`^[%lld region](inflect: true)`), but the string-catalog compiler passes that markup through **verbatim** into the compiled `Localizable.strings` (unlike a real plural such as `primary.elsewhereOnly.description`, which compiles to an `NSStringLocalizedFormatKey` dict), and flattening the resource to a `String` never runs the inflection engine. Pre-existing — the catalog entry is byte-identical on `main` and predates the String Catalog symbol migration. Fix by either rendering the resource directly so SwiftUI applies inflection (`Text(.locationsElsewhereSubtitle(regionCount))` in `ElsewhereSummaryCard.swift:29`, dropping the `WhereFormat.elsewhereCardSubtitle` hop at `WhereFormat.swift:49-50`) or replacing the markup with an explicit plural variation. `WhereFormatTests.elsewhereCardSubtitleInflectsTheRegionCount` (`WhereFormatTests.swift:92-101`, the `withKnownIssue` at `:98` — moved when PR #302 added format tests above it) pins the expected output behind `withKnownIssue`, so it trips as soon as this is fixed. The bug is also baked into the `locations.Loaded_iPad.png` reference (ledgered in the broken-snapshots cluster below) — PR #302 re-recorded the Locations references for the sticker redesign with the broken hop untouched, so re-record that image again when this lands. The flight review also reproduced the literal `^[3 region](inflect: true)` in `WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Flight-FlightLikely_iPhone.png`; the existing catalog key and formatter were unchanged by the flight work. (agent; citations refreshed 2026-09-06; flight snapshot evidence 2026-09-13) - refactor(WhereUI) [needs-design]: Split `WhereSession` into an always-on coordinator + a presentation view-model whose lifetime scopes its subscriptions. **Partial progress (July 2026):** `YearReportModel` is now scene-scoped in `MainTabs` — `activate()` / `deactivate()` on `scenePhase` drive `observeDataChanges()` and refresh, closing the headless-relaunch rescan leak that previously wired the subscription through launch `syncAuth`. `ResolveModel`, `BackupModel`, `RemindersSettingsModel`, and now `DevicesSettingsModel` are view-scoped. Remaining: the coordinator is **still exactly 636 lines** as of 2026-09-06 (`Model/WhereSession.swift`) — it grew past the ~460 recorded when this was filed because PR #160's multi-device recording landed on it, and it has now held at 636 through three windows of privacy, theming, forecasting, ranking-motion, endorsement, and demo-launch work, so it is neither growing nor being worked down — and still mixes recording runtime, authorization, reset, the launch-time notification reconcile, region-style mirrors, and device rejoin (its own header comment inventories them at `WhereSession.swift:6-30`). Finish extracting presentation collaborators and drive any leftover reactive work from scene lifetime. (agent; re-measured 2026-09-06) - test(WhereUI) [quick-win]: `ManualDayView`'s range mode has no test coverage — including its capture-only code. The deleted `manualDayViewHostsAddModes` hosted a *range-prefilled* add (two `DatePicker`s), but the `addPrefill` snapshot case is still a single day (`ManualDayView.swift:510-512`, `start == end` → `dayCount: 1`), so no test ever renders the `.range` branch (`:199-212`) — live or stand-in. The range stand-in code has never executed, and the From/Through picker row rendering is unpinned. Fix: add an `AddRange` snapshot case with a multi-day `MissingDayRange` prefill (the Resolve backfill flow the deleted test existed for). (From the July 2026 snapshot-testing PR review.) - test(WhereUI) [needs-design]: `RegionMapView`'s live `Map` branch is no longer constructed by any test. The deleted `regionMapViewHosts` mounted the real MapKit `Map` (polygon building via `clLocationCoordinates`, `mapStyle`); under capture the view always takes the `SnapshotMapStandIn` branch (`RegionMapView.swift:248-253`, the live `Map` at `:255-262`), so a crash or regression in the production map path — which every real user sees — would ship untested. The stand-in substitution is what the framework carve-out sanctions; the gap is purely coverage. Fix: keep one lightweight hosting test for the live branch in `WhereUITests` (this specific surface is the exception the "no hosting smoke tests" rule shouldn't swallow) — until then, this entry records the accepted gap. (From the July 2026 snapshot-testing PR review.) diff --git a/Where/Tools/Tests/upgrade_backup_test.rb b/Where/Tools/Tests/upgrade_backup_test.rb index 876fe3b3a..2fa161ff9 100644 --- a/Where/Tools/Tests/upgrade_backup_test.rb +++ b/Where/Tools/Tests/upgrade_backup_test.rb @@ -2,16 +2,19 @@ require "minitest/autorun" require_relative "../upgrade-backup" +require_relative "../../../.agents/skills/upgrade-where-backup/scripts/upgrade_and_verify" class UpgradeBackupTest < Minitest::Test def test_v1_adds_current_tables_without_inventing_recording_consent upgraded = upgrade_manifest(base_manifest(1)) - assert_equal 5, upgraded.fetch("formatVersion") + assert_equal 6, upgraded.fetch("formatVersion") assert_equal [], upgraded.fetch("recordingDeviceProfiles") assert_equal [], upgraded.fetch("recordingDeviceMetadataChanges") assert_equal [], upgraded.fetch("recordingDeviceRemovals") assert_equal [], upgraded.fetch("plannedStayRecords") + assert_equal [], upgraded.fetch("sampleAttributionRevisions") + assert_nil upgraded.fetch("samples").first.fetch("motion") assert_nil upgraded.fetch("samples").first.fetch("recordingDeviceID") end @@ -54,7 +57,7 @@ def test_v3_reshapes_recording_device_data upgraded = upgrade_manifest(manifest) - assert_equal 5, upgraded.fetch("formatVersion") + assert_equal 6, upgraded.fetch("formatVersion") assert_equal({ "kind" => { "other" => {} }, "registrationGenerationID" => "generation-id", @@ -67,11 +70,56 @@ def test_v3_reshapes_recording_device_data }, upgraded.fetch("recordingDeviceMetadataChanges").last) end - def test_v5_is_idempotent - once = upgrade_manifest(base_manifest(5)) + def test_v6_is_idempotent + once = upgrade_manifest(base_manifest(6)) assert_equal once, upgrade_manifest(Marshal.load(Marshal.dump(once))) end + def test_v5_gains_empty_corrections_and_unknown_motion_without_altering_raw_fixes + manifest = base_manifest(5) + manifest["samples"] = [{ "id" => "sample", "timestamp" => 1000.25, "coordinate" => { "latitude" => 40, "longitude" => -100 } }] + original = Marshal.load(Marshal.dump(manifest["samples"].first)) + + upgraded = upgrade_manifest(manifest) + + assert_equal original.merge("recordingDeviceID" => nil, "motion" => nil), upgraded.fetch("samples").first + assert_equal [], upgraded.fetch("sampleAttributionRevisions") + end + + def test_v6_preserves_motion_and_every_correction_revision_including_tombstones + motion = { + "speed" => { "metersPerSecond" => 240, "accuracyMetersPerSecond" => 2 }, + "altitude" => { "meters" => 11000, "accuracyMeters" => 12 }, + } + revisions = [nil, [], ["us-NY"]].each_with_index.map do |regions, index| + { "id" => "revision-#{index}", "sampleID" => "sample", "updatedAt" => 1000 + index, "replacementRegions" => regions } + end + manifest = base_manifest(6).merge("sampleAttributionRevisions" => revisions) + manifest["samples"].first["motion"] = motion + + upgraded = upgrade_manifest(manifest) + + assert_equal motion, upgraded.fetch("samples").first.fetch("motion") + assert_equal revisions, upgraded.fetch("sampleAttributionRevisions") + assert_equal upgraded, upgrade_manifest(Marshal.load(Marshal.dump(upgraded))) + end + + def test_verification_driver_counts_planned_stays_and_correction_tombstones + manifest = upgrade_manifest(base_manifest(6)) + manifest["plannedStayRecords"] = [{ "id" => "stay", "value" => nil, "updatedAt" => 1000 }] + manifest["sampleAttributionRevisions"] = [{ "id" => "reset", "sampleID" => "sample", "updatedAt" => 1001 }] + driver = UpgradeAndVerify.new(["input.zip", "output.zip"]) + counts = driver.send(:expected_counts, manifest) + configuration = driver.send(:verification_configuration, manifest) + + assert_equal 1, counts.fetch("plannedStayRecords") + assert_equal 1, counts.fetch("sampleAttributionRevisions") + assert_equal 1, configuration.fetch(:plannedStayRecordsCount) + assert_equal 1, configuration.fetch(:sampleAttributionRevisionsCount) + manifest["sampleAttributionRevisions"] = [] + assert_raises(RuntimeError) { driver.send(:verify_counts!, manifest, counts) } + end + def test_normalizes_legacy_iso8601_dates_to_current_unix_timestamps manifest = base_manifest(1) manifest["exportedAt"] = "2023-11-14T22:13:20Z" @@ -84,7 +132,7 @@ def test_normalizes_legacy_iso8601_dates_to_current_unix_timestamps end def test_rejects_branch_only_or_future_formats - error = assert_raises(SystemExit) { upgrade_manifest(base_manifest(6)) } + error = assert_raises(SystemExit) { upgrade_manifest(base_manifest(7)) } assert_equal 1, error.status end diff --git a/Where/Tools/upgrade-backup.rb b/Where/Tools/upgrade-backup.rb index dd85486f2..5854212c5 100755 --- a/Where/Tools/upgrade-backup.rb +++ b/Where/Tools/upgrade-backup.rb @@ -1,10 +1,11 @@ #!/usr/bin/env ruby # frozen_string_literal: true -# Reshapes a legacy Where backup into the current v5 manifest. The automatic-recording feature +# Reshapes a legacy Where backup into the current v6 manifest. The automatic-recording feature # was not shipped in v1 or v2, so upgrading adds the recording tables empty; it never invents an # installation or recording consent. v4 expands device kinds and groups metadata edit payloads; # v5 adds an empty planned-stay register when the source predates it. +# v6 preserves optional motion readings and adds per-sample attribution history. require "json" require "tmpdir" @@ -13,7 +14,7 @@ require "set" MANIFEST_NAME = "manifest.json" -CURRENT_FORMAT_VERSION = 5 +CURRENT_FORMAT_VERSION = 6 SUPPORTED_SOURCE_FORMAT_VERSIONS = (1..CURRENT_FORMAT_VERSION).freeze REGION_MAP = { @@ -32,7 +33,7 @@ DATE_KEYS = %w[ exportedAt timestamp capturedAt dismissedAt registeredAt changedAt removedAt recordedAt - lastSeenAt auditRecordedAt auditLocationTimestamp + lastSeenAt auditRecordedAt auditLocationTimestamp updatedAt ].to_set.freeze def die(message) @@ -184,12 +185,14 @@ def upgrade_manifest(manifest) end Array(manifest["samples"]).each do |sample| sample["recordingDeviceID"] = nil unless sample.key?("recordingDeviceID") + sample["motion"] = nil unless sample.key?("motion") end manifest["recordingDeviceProfiles"] ||= [] manifest["recordingDeviceMetadataChanges"] ||= [] manifest["recordingDeviceRemovals"] ||= [] manifest["plannedStayRecords"] ||= [] + manifest["sampleAttributionRevisions"] ||= [] upgrade_recording_devices!(manifest, source_version) manifest.delete("recordingDevices") manifest.delete("recordingDeviceCheckIns") diff --git a/Where/WhereCore/AGENTS.md b/Where/WhereCore/AGENTS.md index 7c45f440d..616d37383 100644 --- a/Where/WhereCore/AGENTS.md +++ b/Where/WhereCore/AGENTS.md @@ -31,7 +31,9 @@ internal shape. `perform { … }` (the production store traps otherwise). Stale-decision writes use `perform(expectedDataGenerationID:)`. Multi-table reads use `readSnapshot`. Guard: - `SwiftDataStoreTests.readSnapshotRejectsCommitBeforeNotification`. Each + `SwiftDataStoreTests.readSnapshotRejectsCommitBeforeNotification`. A snapshot + inside a mutation pins durable history until its pre-save check; concurrent + commits throw `WhereStoreReadConflictError` before local edits are saved. Each committed transaction pings `changes()`. Never expose its `ModelContainer` through `WhereServices`. The separate DEBUG Inspector runtime uses `SwiftDataStore.makeContainer`, `inspectorModelTypes`, and @@ -58,7 +60,8 @@ internal shape. persisted `RegionSymbol`. Its mapping to SFSafeSymbols and `Color` is presentation (WhereUI). - **Export backups from one `readSnapshot` and keep restorable user data - lossless.** Add persisted user-data shapes end-to-end and cover both import + lossless.** Preserve optional motion and every sample-attribution revision, + including reset tombstones. Add persisted user-data shapes end-to-end and cover both import strategies. Export no target-owned recording check-ins. Ignore any in an imported archive (`BackupServiceTests` / `BackupCoordinatorTests`). - **Backup import never adopts or changes local recording consent.** Archives @@ -113,10 +116,30 @@ internal shape. wired at the composition root (`BackupCoordinator.ImportLifecycle.didCommit`). Existing exceptions are `setPrimaryRegions` and the local summary fan-out, tracked in [`../TODOs.md`](../TODOs.md). Do not copy those omissions. -- **Detectors read aggregated input. The speed-based one needs raw fixes.** - `DataIssueInput.daySamples` carries per-day GPS fixes only (`.gpsVisit` / - `.gpsSignificantChange`, sorted). Manual and evidence-implied samples are - excluded so `FlightDayDetector`'s speed math is not skewed. +- **Assess flight trajectories before calendar bucketing and per recording device.** + Keep legacy samples in their own track. Preserve unknown observations; silence, + midnight, and restart never establish arrival (`FlightTrajectoryAnalyzerTests`). +- **Apply automatic GPS corrections to reviewed sample IDs only.** Reassess inside + `perform(expectedDataGenerationID:)`; changed evidence returns a fresh review + without expanding Apply (`SampleCorrectionCoordinatorTests`). Preserve manual + and evidence-derived assertions and existing authoritative day overrides. + Propose at most one edit per sample ID; preserve conflicting duplicate observations + (`SampleCorrectionAssessmentTests`). +- **Resolve sample attribution as a generation-scoped immutable revision register.** + Order by timestamp then UUID; nil is a reset tombstone, an empty set excludes, + and a populated set replaces attribution. Retain revisions arriving before + samples. Reset to GPS clears manual overrides and writes newer sample tombstones + atomically (`DayJournalTests` / `LocationHistoryReaderTests`). +- **Use `LocationHistoryReader.projection` for every user-facing location read.** + Apply removal cutoffs before correction revisions. Reports, maps, artwork, + widgets, summaries, reminders, and intents use effective attribution; trajectory + assessment and review retain raw observations. Pin live attribution to the + tracked set in the same store snapshot. +- **Publish one scan revision containing issues, reviews, and its next deadline.** + Let flight reviews own overlapping day transitions. Pending flights do not + enter actionable badges or notifications. An invalidated + scan cannot repopulate its cache (`DataIssueScannerTests`). UI deadlines run only + in the foreground; do not introduce background polling. - **Read related year projections from one samples snapshot.** Use `ReportReader.yearReportDetails(for:primaryRegionCount:)` for the scene's report and primary-region locations. diff --git a/Where/WhereCore/BACKUP_FORMAT.md b/Where/WhereCore/BACKUP_FORMAT.md new file mode 100644 index 000000000..887950f2b --- /dev/null +++ b/Where/WhereCore/BACKUP_FORMAT.md @@ -0,0 +1,38 @@ +# Where backup format v6 + +`BackupArchive` is the authoritative manifest schema. A ZIP contains its JSON +manifest and the referenced evidence assets. The production `BackupService` +decoder accepts the current version only; upgrade older archives with +[`Where/Tools/upgrade-backup.rb`](../Tools/upgrade-backup.rb). + +Version 6 adds optional `motion` to each raw location sample. Its `speed` holds +meters per second and its accuracy; its `altitude` holds meters and vertical +accuracy. Missing or invalid system readings become absent values. Accuracy +and coordinate observations remain raw, independent of later corrections. +The callback adapter follows Apple's [speed accuracy](https://developer.apple.com/documentation/corelocation/cllocation/speedaccuracy) +and [vertical accuracy](https://developer.apple.com/documentation/corelocation/cllocation/verticalaccuracy) +rules: speed accuracy may be zero; vertical accuracy must be positive. + +The new `sampleAttributionRevisions` array preserves every immutable revision: + +| Field | Meaning | +| --- | --- | +| `id` | Revision UUID, preserved by Merge and Replace | +| `sampleID` | Raw location sample UUID; delivery may precede the sample | +| `updatedAt` | Original revision timestamp | +| `replacementRegions` | Absent/null restores GPS; `[]` excludes; a set replaces attribution | + +Effective attribution takes the latest timestamp, breaking ties by revision UUID. +Reset writes a newer nil replacement. Export includes superseded revisions and +reset tombstones in the active data generation, even when their sample is absent. +Raw samples and evidence assets are never rewritten by a GPS correction. + +Merge retains exact revision identities and timestamps. Replace restores archive +corrections into the new data generation, while preserving device-removal +tombstones and local recording consent through the existing import lifecycle. +Old-generation local corrections do not leak into the replacement dataset. + +Upgrading v1–v5 retains all existing records and assets, records unknown motion, +and adds empty correction history. The upgrade driver checks ZIP integrity and +record counts, then loads the result and its assets through the production +decoder using `./test`. The original input remains unchanged. diff --git a/Where/WhereCore/README.md b/Where/WhereCore/README.md index 8b7ca2a13..c1e0365fa 100644 --- a/Where/WhereCore/README.md +++ b/Where/WhereCore/README.md @@ -154,16 +154,39 @@ one it belongs to rather than to a god-object: ### Detection, notifications & the rest -- **`DataIssueScanner`** + the `DataIssue` family (missing days, border drift, - abrupt change, flight days) — the "Resolve" tab's detections and their - `IssueResolution` fixes. Dismissals persist under a stable, device- and - timezone-independent `storageKey` (a `CalendarDay` ISO string), so a dismissal - doesn't reappear after travel. The `FlightDayDetector` reads the per-day GPS - fixes the scanner puts on `DataIssueInput.daySamples` (timestamped, GPS-only) - to spot cruise-speed points that added a spurious region. Each detector - declares the category it finds (`DataIssueDetecting.detects`), which both - labels its scan span and lets the scanner talk about categories without - knowing the concrete detector types. +- **`DataIssueScanner`** — publishes a coherent revision of actionable issues, + informational GPS reviews, and the next reassessment deadline. Committed raw + evidence invalidates its cache even when day/region totals remain equal. + Missing-day and abrupt-change detection share the same snapshot; flight and + boundary cleanup use one sample assessment. Supported flight transitions stay + in that review instead of producing a separate whole-day abrupt-change suggestion. + Pending flights remain reviewable without adding to correction badges or notifications. +- **`FlightTrajectoryAnalyzer`** — a pure, per-device GPS analysis before day + bucketing, with 24 hours of report-boundary context. It uses independent fixes + at least 60 seconds apart, positional uncertainty, sustained jet-speed progress, + turning departure/approach segments, and an observed ground dwell. The initial + policy is defined in its source: three anchors spanning three minutes at + 450–1,500 km/h, then three ground anchors spanning ten minutes within 2 km and + at most 50 km/h. Motion measurements can corroborate speed and contradict ground + dwell; altitude is context only. Missing or stale updates never establish + arrival. Slower aircraft and sparse recordings can remain uncertain. +- **`SampleCorrectionCoordinator`** — reviews exact GPS edits and reassesses them + in the guarded store transaction before Apply. Any changed evidence refreshes + the review instead of expanding the reviewed sample set. Supported airborne + points can be excluded only after arrival. A boundary relabel needs local + same-device brackets in the retained region within ten minutes on both sides. + Manual assertions, unknown fixes, ground endpoints, layovers, and later + destinations retain their contributions. Reviews coalesce identical synced rows + by sample identity; conflicting representations remain uncorrected. + `LocationHistoryReader.projection` + joins lossless raw samples to the effective attribution for reports, maps, + artwork, widgets, summaries, reminders, and intents. +- **`SampleAttributionRevision`** — an immutable, generation-scoped sample register, + resolved by timestamp then UUID. A nil replacement restores GPS attribution, + an empty set excludes the sample, and a populated set replaces its regions. + Reset to GPS clears the day's manual override and writes newer tombstones in + one transaction. Revisions can arrive before their samples; device-removal + filtering takes precedence. - **Reconcilers** — `ReminderReconciler` (daily logging reminder + app-icon badge), `DailySummaryReconciler` (year-to-date recap), `DataIssueAlertReconciler` ("issues to resolve"). @@ -172,7 +195,8 @@ one it belongs to rather than to a god-object: - **`WidgetPresentationPublisher`** — atomically writes the device-local `WhereTheme` to its own App Group file and reloads WidgetKit without reading or rebuilding widget data. - **`BackupCoordinator`** — ZIP export/import via `ZIPFoundation`. Export pins - tables, planned-stay revisions, and evidence blobs to one generation-consistent snapshot. Merge preserves queued locations + tables, planned-stay revisions, sample-attribution revisions/tombstones, and evidence blobs + to one generation-consistent snapshot. Merge preserves queued locations and the installation-local recording choice. Replace writes the archive into a new child generation, retains existing removal tombstones, and preserves the local choice before pending fixes are discarded. A prepared @@ -184,6 +208,12 @@ one it belongs to rather than to a god-object: sidecar tombstone before clearing recovery, so a cold launch can repair a preference write that did not reach disk without offering the same archive again. Check-ins are deliberately neither exported nor restored because they are live advisory status. + Backup format **v6** retains optional grouped speed/altitude measurements and + every correction revision losslessly. Merge preserves revision IDs/timestamps; + Replace restores archive revisions into its new generation. The offline + [`upgrade-backup.rb`](../Tools/upgrade-backup.rb) transforms formats v1–v5 with + unknown motion and an empty correction history. The production decoder accepts + only the current format. See [backup format](BACKUP_FORMAT.md). - **`InstallationRecordingContext`** — the device-local installation identity, explicitly confirmed local recording choice, and stable timestamp for recreating its immutable device profile idempotently. diff --git a/Where/WhereCore/Sources/Backup/BackupArchive.swift b/Where/WhereCore/Sources/Backup/BackupArchive.swift index 418b8a535..05e244d5b 100644 --- a/Where/WhereCore/Sources/Backup/BackupArchive.swift +++ b/Where/WhereCore/Sources/Backup/BackupArchive.swift @@ -20,11 +20,12 @@ public struct BackupArchive: Codable, Sendable, Hashable { /// /// v3 adds sample provenance, immutable installation profiles, nickname changes, and archive /// tombstones. v4 expands device kinds, groups metadata edit payloads, and renames the - /// profile's registration-generation key; v5 adds `plannedStayRecords`. There's no in-app + /// profile's registration-generation key; v5 adds `plannedStayRecords`; v6 retains motion + /// measurements and sample-attribution revisions. There's no in-app /// decode fallback for an older archive — it is reshaped out of band by /// `Tools/upgrade-backup.rb`, matching the module's no-migration-on-read rule (see /// `AGENTS.md`). - public static let currentFormatVersion = 5 + public static let currentFormatVersion = 6 public let formatVersion: Int public let exportedAt: Date @@ -52,6 +53,8 @@ public struct BackupArchive: Codable, Sendable, Hashable { /// Revisions of the synced planned-stay register, including its clearing /// tombstone, so restore cannot resurrect an older active stay. public let plannedStayRecords: [PlannedStayRecord] + /// Lossless per-sample correction history, including reset tombstones. + public let sampleAttributionRevisions: [SampleAttributionRevision] /// One entry per evidence record that has blob bytes in the archive. /// Evidence without bytes simply has no entry here. public let assets: [BackupAssetEntry] @@ -69,6 +72,7 @@ public struct BackupArchive: Codable, Sendable, Hashable { recordingDeviceMetadataChanges: [RecordingDeviceMetadataChange], recordingDeviceRemovals: [RecordingDeviceRemoval], plannedStayRecords: [PlannedStayRecord], + sampleAttributionRevisions: [SampleAttributionRevision], assets: [BackupAssetEntry], ) { self.formatVersion = formatVersion @@ -83,6 +87,7 @@ public struct BackupArchive: Codable, Sendable, Hashable { self.recordingDeviceMetadataChanges = recordingDeviceMetadataChanges self.recordingDeviceRemovals = recordingDeviceRemovals self.plannedStayRecords = plannedStayRecords + self.sampleAttributionRevisions = sampleAttributionRevisions self.assets = assets } } diff --git a/Where/WhereCore/Sources/Backup/BackupCoordinator.swift b/Where/WhereCore/Sources/Backup/BackupCoordinator.swift index 976033687..d3f8200bd 100644 --- a/Where/WhereCore/Sources/Backup/BackupCoordinator.swift +++ b/Where/WhereCore/Sources/Backup/BackupCoordinator.swift @@ -167,6 +167,7 @@ public actor BackupCoordinator { recordingDeviceMetadataChanges: store.recordingDeviceMetadataChanges(), recordingDeviceRemovals: store.recordingDeviceRemovals(), plannedStayRecords: store.plannedStayRecords(), + sampleAttributionRevisions: store.allSampleAttributionRevisions(), ) } let evidence = tables.evidence @@ -202,6 +203,7 @@ public actor BackupCoordinator { recordingDeviceMetadataChanges: tables.recordingDeviceMetadataChanges, recordingDeviceRemovals: tables.recordingDeviceRemovals, plannedStayRecords: tables.plannedStayRecords, + sampleAttributionRevisions: tables.sampleAttributionRevisions, blobs: snapshot.blobs, ) }.value @@ -223,6 +225,7 @@ public actor BackupCoordinator { let recordingDeviceMetadataChanges: [RecordingDeviceMetadataChange] let recordingDeviceRemovals: [RecordingDeviceRemoval] let plannedStayRecords: [PlannedStayRecord] + let sampleAttributionRevisions: [SampleAttributionRevision] } private struct ExportSnapshot { @@ -415,6 +418,7 @@ public actor BackupCoordinator { + archive.recordingDeviceMetadataChanges.count + archive.recordingDeviceRemovals.count + archive.plannedStayRecords.count + + archive.sampleAttributionRevisions.count // Decode and validate before touching live recording. Once the archive is known-good, // close ingestion before either merge or replace so a streamed sample cannot cross the @@ -484,6 +488,10 @@ public actor BackupCoordinator { try await store.restorePlannedStayRecord(plannedStay) report() } + for revision in archive.sampleAttributionRevisions { + try await store.addSampleAttributionRevision(revision) + report() + } for profile in archive.recordingDeviceProfiles { try await store.addRecordingDeviceProfile(profile) report() diff --git a/Where/WhereCore/Sources/Backup/BackupService.swift b/Where/WhereCore/Sources/Backup/BackupService.swift index 686a8f27e..0695d279a 100644 --- a/Where/WhereCore/Sources/Backup/BackupService.swift +++ b/Where/WhereCore/Sources/Backup/BackupService.swift @@ -100,12 +100,14 @@ public struct BackupService: Sendable { recordingDeviceMetadataChanges: [RecordingDeviceMetadataChange], recordingDeviceRemovals: [RecordingDeviceRemoval], plannedStayRecords: [PlannedStayRecord], + sampleAttributionRevisions: [SampleAttributionRevision], blobs: [UUID: Data], exportedAt: Date = Date(), archiveName: String? = nil, ) throws -> URL { try Self.validateRecordingData( metadataChanges: recordingDeviceMetadataChanges, + sampleAttributionRevisions: sampleAttributionRevisions, ) let fileManager = FileManager.default let workRoot = fileManager.temporaryDirectory @@ -141,6 +143,7 @@ public struct BackupService: Sendable { recordingDeviceMetadataChanges: recordingDeviceMetadataChanges, recordingDeviceRemovals: recordingDeviceRemovals, plannedStayRecords: plannedStayRecords, + sampleAttributionRevisions: sampleAttributionRevisions, assets: assetEntries, ) try Self.logger.measure(.encodeManifest) { @@ -252,14 +255,21 @@ public struct BackupService: Sendable { static func validateRecordingData(_ archive: BackupArchive) throws { try validateRecordingData( metadataChanges: archive.recordingDeviceMetadataChanges, + sampleAttributionRevisions: archive.sampleAttributionRevisions, ) } private static func validateRecordingData( metadataChanges: [RecordingDeviceMetadataChange], + sampleAttributionRevisions: [SampleAttributionRevision], ) throws { guard metadataChanges.allSatisfy({ $0.revision >= 0 }) else { throw BackupError.invalidRecordingData } + for (_, revisions) in Dictionary(grouping: sampleAttributionRevisions, by: \.id) { + guard Set(revisions).count == 1, + revisions.allSatisfy(\.updatedAt.timeIntervalSince1970.isFinite) + else { throw BackupError.invalidRecordingData } + } } } diff --git a/Where/WhereCore/Sources/DataResolution/BorderDriftDetector.swift b/Where/WhereCore/Sources/DataResolution/BorderDriftDetector.swift deleted file mode 100644 index eb74d69c3..000000000 --- a/Where/WhereCore/Sources/DataResolution/BorderDriftDetector.swift +++ /dev/null @@ -1,62 +0,0 @@ -import Foundation -import RegionKit - -/// Detects days carrying an `.other` attribution whose GPS coordinates actually -/// sit just outside a primary region's border — likely GPS jitter near a -/// boundary — and reports the nearest region as a `BorderDriftIssue`. -/// -/// This covers both a day attributed *only* to `.other` and a "mixed" day where -/// a real region picked up a stray `.other` from a couple of drifted fixes -/// (e.g. New York plus a point that jittered across a river): the spurious -/// `.other` pollutes the data even when the day already counts correctly, and -/// the fix (`BorderDriftIssue.resolution`) relabels it away. -/// -/// For each day containing `.other` it measures every recorded coordinate's -/// distance to each primary region's boundary (only coordinates the attributor -/// still resolves to `.other`, so genuinely-inside points are ignored) and, -/// when the closest is within `input.driftThresholdMeters`, flags the day with -/// that region and distance. -public struct BorderDriftDetector: DataIssueDetector { - public typealias Issue = BorderDriftIssue - - public let detects = DataIssueCategory.borderDrift - - public init() {} - - public func detectIssues(in input: DataIssueInput) -> [BorderDriftIssue] { - guard !input.primaryRegions.isEmpty else { return [] } - - var issues: [BorderDriftIssue] = [] - for day in input.report.days where day.regions.contains(.other) { - guard let coordinates = input.otherDayCoordinates[day.day], - !coordinates.isEmpty else { continue } - - var bestRegion: Region? - var bestDistance = Double.infinity - - for coordinate in coordinates where input.attributor.region(at: coordinate) == .other { - for region in input.primaryRegions { - guard let distance = input.attributor.distanceToBoundary( - of: region, - from: coordinate, - ) else { - continue - } - if distance < bestDistance { - bestDistance = distance - bestRegion = region - } - } - } - - if let bestRegion, bestDistance <= input.driftThresholdMeters { - issues.append(BorderDriftIssue( - day: day, - nearestRegion: bestRegion, - distanceMeters: bestDistance, - )) - } - } - return issues - } -} diff --git a/Where/WhereCore/Sources/DataResolution/BorderDriftIssue.swift b/Where/WhereCore/Sources/DataResolution/BorderDriftIssue.swift deleted file mode 100644 index ecdc0e781..000000000 --- a/Where/WhereCore/Sources/DataResolution/BorderDriftIssue.swift +++ /dev/null @@ -1,42 +0,0 @@ -import Foundation -import RegionKit - -public struct BorderDriftIssue: DataIssue, Hashable { - public let day: DayPresence - public let nearestRegion: Region - public let distanceMeters: Double - - public init(day: DayPresence, nearestRegion: Region, distanceMeters: Double) { - self.day = day - self.nearestRegion = nearestRegion - self.distanceMeters = distanceMeters - } - - public var id: DataIssueID { - .borderDrift(day: day.day) - } - - public var category: DataIssueCategory { - .borderDrift - } - - public var sortKey: CalendarDay { - day.day - } - - public var isDismissible: Bool { - true - } - - public var resolution: IssueResolution { - // Drop the spurious `.other` while keeping whatever real regions the - // day already counts for. A pure-`.other` day has nothing left, so it - // falls back to the nearest primary region as the suggested label. - let realRegions = day.regions.subtracting([.other]) - return .relabelDay( - day: day, - suggestedRegions: realRegions.isEmpty ? [nearestRegion] : realRegions, - approximateMeters: distanceMeters, - ) - } -} diff --git a/Where/WhereCore/Sources/DataResolution/DataIssue.swift b/Where/WhereCore/Sources/DataResolution/DataIssue.swift index 970a4dab6..8b81689a3 100644 --- a/Where/WhereCore/Sources/DataResolution/DataIssue.swift +++ b/Where/WhereCore/Sources/DataResolution/DataIssue.swift @@ -12,20 +12,10 @@ public enum DataIssueCategory: String, Codable, Sendable, Hashable, CaseIterable /// reuses a shape needs no UI change; a genuinely new fix shape adds a case /// (the compiler then flags the UI switch). public enum IssueResolution: Sendable, Hashable { + /// An exact, reversible change to reviewed GPS observations. + case correctSamples(SampleCorrectionProposal) case backfill(MissingDayRange) - case relabelDay(day: DayPresence, suggestedRegions: Set, approximateMeters: Double?) case markTravelDay(earlier: DayPresence, later: DayPresence, suggestedRegions: Set) - /// A day whose region set was polluted by cruise-speed GPS fixes crossing - /// untracked geography (a flight). `keepRegions` are the endpoints/dwell - /// regions to preserve; `removedRegions` are the fly-over-only regions the - /// one-tap fix drops (applied as an authoritative `overrideDay(keepRegions)`). - /// `peakSpeedKMH` is the fastest leg, for the detail view's copy. - case correctFlightDay( - day: DayPresence, - keepRegions: Set, - removedRegions: Set, - peakSpeedKMH: Double, - ) } public enum DataIssueID: Hashable, Sendable, WhereStoreURLCodable { diff --git a/Where/WhereCore/Sources/DataResolution/DataIssueDetector.swift b/Where/WhereCore/Sources/DataResolution/DataIssueDetector.swift index 1c67c71d4..6e1530dc1 100644 --- a/Where/WhereCore/Sources/DataResolution/DataIssueDetector.swift +++ b/Where/WhereCore/Sources/DataResolution/DataIssueDetector.swift @@ -28,7 +28,7 @@ public struct DataIssueInput: Sendable { public let report: YearReport public let otherDayCoordinates: [CalendarDay: [Coordinate]] /// The year's passive GPS fixes, queryable per day, for a speed-based - /// detector (`FlightDayDetector`) that needs per-fix timestamps the + /// detector that needs per-fix timestamps the /// aggregated `report` has collapsed away. Lazy and memoized (see /// `DaySamples`), so a scan whose detectors never consult it does no /// grouping work. diff --git a/Where/WhereCore/Sources/DataResolution/DataIssueScanResult.swift b/Where/WhereCore/Sources/DataResolution/DataIssueScanResult.swift new file mode 100644 index 000000000..3cdad2afe --- /dev/null +++ b/Where/WhereCore/Sources/DataResolution/DataIssueScanResult.swift @@ -0,0 +1,21 @@ +import Foundation + +/// One coherent scan publication for badges, live flight notices, and reviews. +public struct DataIssueScanResult: Sendable { + public let revision: UUID + public let issues: [any DataIssue] + public let reviews: [GPSCorrectionReview] + public let nextReassessmentAt: Date? + + public init( + revision: UUID, + issues: [any DataIssue], + reviews: [GPSCorrectionReview], + nextReassessmentAt: Date?, + ) { + self.revision = revision + self.issues = issues + self.reviews = reviews + self.nextReassessmentAt = nextReassessmentAt + } +} diff --git a/Where/WhereCore/Sources/DataResolution/DataIssueScanner.swift b/Where/WhereCore/Sources/DataResolution/DataIssueScanner.swift index cbd535f10..8511411cc 100644 --- a/Where/WhereCore/Sources/DataResolution/DataIssueScanner.swift +++ b/Where/WhereCore/Sources/DataResolution/DataIssueScanner.swift @@ -1,14 +1,9 @@ import Foundation import RegionKit -/// Reads the persisted year + dismissals, runs the pure detectors, and returns -/// the sorted, not-yet-dismissed issues — throttling repeat scans of the same -/// (year, threshold, calendar day) to once per `scanInterval` and serving the -/// cached result in between. The calendar day is part of the key because the -/// missing-days backlog cutoff (`MissingDays.backlogCutoff`) is day-relative, so -/// a midnight rollover must recompute even mid-throttle — the cache fully -/// describes when it's stale, so callers just keep asking with `force: false`. -/// An `actor` because it holds that cache; composes `ReportReader`. +/// Publishes issues and informational GPS reviews from one evidence snapshot. +/// Cache invalidation has its own epoch so a suspended scan cannot republish +/// evidence that was invalidated while its reads were in flight. public actor DataIssueScanner { private static let logger = WhereLog.reporting(DataIssueScannerLog.self) @@ -21,15 +16,18 @@ public actor DataIssueScanner { private struct CachedScan { let year: Int + let primaryRegions: [Region] + let trackedRegions: [Region] let driftThresholdMeters: Double /// Start-of-day of the `now` this scan ran against. The day-relative /// backlog cutoff is baked into `issues`, so a different day is a miss. let day: Date let at: Date - let issues: [any DataIssue] + let result: DataIssueScanResult } private var cache: CachedScan? + private var invalidationRevision: UInt64 = 0 /// Drops the cache whenever the store reports a committed change. Lets the /// cache stay honest for `force: false` readers even when no session is @@ -48,9 +46,7 @@ public actor DataIssueScanner { scanInterval: TimeInterval = 3 * 60 * 60, detectors: [any DataIssueDetecting] = [ MissingDaysDetector(), - BorderDriftDetector(), AbruptLocationChangeDetector(), - FlightDayDetector(), ], // Defaults to an already-finished stream — *not* `AsyncStream { _ in }`, // which never yields or finishes and so would park the observation task @@ -75,63 +71,116 @@ public actor DataIssueScanner { invalidationTask?.cancel() } - /// Throttled detection. Recomputes when `force`, when the cache is empty, - /// when `(year, driftThresholdMeters)` differs from the cached run, when the - /// calendar day has rolled over since it (the backlog cutoff is - /// day-relative), or when the cached run is older than `scanInterval`; - /// otherwise returns cached. public func issues( year: Int, primaryRegions: [Region], driftThresholdMeters: Double, force: Bool = false, ) async throws -> [any DataIssue] { - let currentDate = now() - let currentDay = calendar.startOfDay(for: currentDate) - if !force, - let cached = cache, - cached.year == year, - cached.driftThresholdMeters == driftThresholdMeters, - cached.day == currentDay, - currentDate.timeIntervalSince(cached.at) < scanInterval - { - return cached.issues - } + try await scan( + year: year, + primaryRegions: primaryRegions, + driftThresholdMeters: driftThresholdMeters, + force: force, + ).issues + } - // Only a miss is spanned — a throttled hit returns above, so the span - // history holds real scans rather than a run of near-zero cache reads. - let sorted = try await Self.logger.measure(.scan, budget: .seconds(3)) { - let reads = try await reportReader.dataIssueReads(for: year) - let dismissed = try await reportReader.dismissedIssueIDs() - let input = DataIssueInput( + public func scan( + year: Int, + primaryRegions: [Region], + driftThresholdMeters: Double, + force: Bool, + ) async throws -> DataIssueScanResult { + while true { + try Task.checkCancellation() + let currentDate = now() + let currentDay = calendar.startOfDay(for: currentDate) + let trackedRegions = attributor.loadedRegions + if !force, let cached = cache, + cached.year == year, + cached.primaryRegions == primaryRegions, + cached.trackedRegions == trackedRegions, + cached.driftThresholdMeters == driftThresholdMeters, + cached.day == currentDay, + currentDate.timeIntervalSince(cached.at) < scanInterval, + cached.result.nextReassessmentAt.map({ currentDate < $0 }) ?? true + { + return cached.result + } + let revision = invalidationRevision + let result = try await Self.logger.measure(.scan, budget: .seconds(3)) { + let reads = try await reportReader.dataIssueReads(for: year) + let input = DataIssueInput( + year: year, + report: reads.report, + otherDayCoordinates: reads.otherDayCoordinates, + daySamples: reads.daySamples, + primaryRegions: primaryRegions, + attributor: reads.attribution, + driftThresholdMeters: driftThresholdMeters, + calendar: calendar, + now: currentDate, + ) + let reviews = Self.logger.measure(.assessGPS) { + SampleCorrectionAssessment(attributor: reads.attribution, calendar: calendar) + .reviews( + reads: reads, + primaryRegions: primaryRegions, + driftThresholdMeters: driftThresholdMeters, + now: currentDate, + ) + } + let otherIssues = detectors.flatMap { detector in + Self.logger.measure(.detect(detector.detects)) { + detector.detectAnyIssues(in: input) + } + } + let flightDays = Set(reviews.filter { !$0.flights.isEmpty }.map(\.day.day)) + let unexplainedIssues = otherIssues.filter { issue in + // Flight reviews own these transitions before and after + // arrival, including their exact-sample correction. Do not + // also offer a whole-day travel correction for that flight. + guard case let .markTravelDay(earlier, later, _) = issue.resolution else { + return true + } + return !flightDays.contains(earlier.day) && !flightDays.contains(later.day) + } + let gpsIssues: [any DataIssue] = reviews.compactMap { review in + review.proposal.map { SampleCorrectionIssue(proposal: $0) } + } + let issues = Self.sortIssues((unexplainedIssues + gpsIssues).filter { + !reads.dismissedIssueIDs.contains($0.id) + }) + let deadlines = reviews.flatMap(\.flights).flatMap { flight in + [ + flight.nextReassessmentAt, + flight.lastObservationAt.addingTimeInterval(24 * 60 * 60), + ] + .compactMap(\.self).filter { $0 > currentDate } + } + return DataIssueScanResult( + revision: UUID(), + issues: issues, + reviews: reviews, + nextReassessmentAt: deadlines.min(), + ) + } + guard revision == invalidationRevision, + trackedRegions == attributor.loadedRegions + else { + continue + } + cache = CachedScan( year: year, - report: reads.report, - otherDayCoordinates: reads.otherDayCoordinates, - daySamples: reads.daySamples, primaryRegions: primaryRegions, - attributor: attributor, + trackedRegions: trackedRegions, driftThresholdMeters: driftThresholdMeters, - calendar: calendar, - now: currentDate, - ) - return Self.sortIssues( - detectors - .flatMap { detector in - Self.logger.measure(.detect(detector.detects)) { - detector.detectAnyIssues(in: input) - } - } - .filter { !dismissed.contains($0.id) }, + day: currentDay, + at: currentDate, + result: result, ) + return result } - cache = CachedScan( - year: year, - driftThresholdMeters: driftThresholdMeters, - day: currentDay, - at: currentDate, - issues: sorted, - ) - return sorted } /// Count of unresolved issues for `year`, for headless callers (the app-icon @@ -161,6 +210,7 @@ public actor DataIssueScanner { /// Drop the cache so the next `issues(...)` recomputes regardless of throttle. public func invalidate() { + invalidationRevision &+= 1 cache = nil } diff --git a/Where/WhereCore/Sources/DataResolution/DaySamples.swift b/Where/WhereCore/Sources/DataResolution/DaySamples.swift index 2c40d5558..2f7097c39 100644 --- a/Where/WhereCore/Sources/DataResolution/DaySamples.swift +++ b/Where/WhereCore/Sources/DataResolution/DaySamples.swift @@ -7,8 +7,8 @@ import RegionKit /// detectors never consult it pays nothing, and the result is reused across /// detectors. Built from the samples the scanner already read for the year's /// report (see `ReportReader.dataIssueReads(for:)`), so it adds no extra store -/// read — only the speed-based `FlightDayDetector` currently needs per-fix -/// timestamps, and even it only forces the grouping when it runs. +/// read. Flight trajectory analysis uses the chronological raw projection directly +/// so a calendar boundary cannot split its motion evidence. /// /// Only `.gpsVisit` / `.gpsSignificantChange` fixes are kept — manual and /// evidence-implied samples carry user-asserted timestamps that would produce diff --git a/Where/WhereCore/Sources/DataResolution/FlightAssessment.swift b/Where/WhereCore/Sources/DataResolution/FlightAssessment.swift new file mode 100644 index 000000000..94993d7d7 --- /dev/null +++ b/Where/WhereCore/Sources/DataResolution/FlightAssessment.swift @@ -0,0 +1,60 @@ +import Foundation + +/// A derived flight on one recording installation's trajectory. Missing arrival +/// evidence remains unresolved; neither silence nor a calendar rollover lands it. +public struct FlightAssessment: Identifiable, Hashable, Sendable { + public struct ID: Hashable, Sendable { + public let recordingDeviceID: RecordingDeviceID? + public let departureSampleID: UUID + + public init(recordingDeviceID: RecordingDeviceID?, departureSampleID: UUID) { + self.recordingDeviceID = recordingDeviceID + self.departureSampleID = departureSampleID + } + } + + public enum Progress: Hashable, Sendable { + case flightLikely + case awaitingArrival + case completed(arrivedAt: Date) + } + + public let id: ID + public let startedAt: Date + public let lastObservationAt: Date + public let lastFlightAt: Date + public let airborneSampleIDs: Set + public let groundSampleIDs: Set + public let peakSpeedKMH: Double + public let progress: Progress + + public init( + id: ID, + startedAt: Date, + lastObservationAt: Date, + lastFlightAt: Date, + airborneSampleIDs: Set, + groundSampleIDs: Set, + peakSpeedKMH: Double, + progress: Progress, + ) { + self.id = id + self.startedAt = startedAt + self.lastObservationAt = lastObservationAt + self.lastFlightAt = lastFlightAt + self.airborneSampleIDs = airborneSampleIDs + self.groundSampleIDs = groundSampleIDs + self.peakSpeedKMH = peakSpeedKMH + self.progress = progress + } + + /// Time alone changes freshness, never the evidence needed to correct samples. + public var nextReassessmentAt: Date? { + switch progress { + case .flightLikely: lastFlightAt.addingTimeInterval(Self.freshnessInterval) + case .awaitingArrival, .completed: nil + } + } + + static let freshnessInterval: TimeInterval = 30 * 60 +} diff --git a/Where/WhereCore/Sources/DataResolution/FlightDayDetector.swift b/Where/WhereCore/Sources/DataResolution/FlightDayDetector.swift deleted file mode 100644 index 1f7e8e2d5..000000000 --- a/Where/WhereCore/Sources/DataResolution/FlightDayDetector.swift +++ /dev/null @@ -1,158 +0,0 @@ -import Foundation -import RegionKit - -/// Detects days that look like a flight: consecutive GPS fixes moving at -/// cruise speed across the map. The symptom is spurious region presence — on a -/// coast-to-coast flight the fly-over states collapse to `.other` (or any -/// untracked region), so the day counts for somewhere the user only passed over -/// at 35 000 feet. -/// -/// The rule, per day, over the timestamp-sorted GPS fixes in -/// `DataIssueInput.daySamples`: -/// 1. A *leg* between consecutive fixes is a "flight leg" when its ground -/// speed is at least `speedThresholdKMH` **and** it spans at least -/// `minLegSeconds` — the duration floor rejects a close pair whose tiny -/// time delta manufactures a huge speed (GPS jitter, a teleport glitch), -/// without rejecting a densely-sampled cruise (significant-change fires -/// every few minutes, so a real cruise leg is only ~70 km even though it's -/// minutes long — a distance floor would wrongly drop those). -/// 2. A fix is a *fly-over* point when the legs on **both** sides are flight -/// legs — a point you were only passing through. A leg's endpoints (the -/// take-off / landing fixes) have a flight leg on just one side, so they -/// are not fly-over points and their region survives. -/// 3. A region is *spurious* when every fix attributing the day to it is a -/// fly-over point (and it has at least one) — it exists only because the -/// flight crossed it. Endpoint and dwell regions keep at least one -/// non-fly-over fix. -/// -/// It reports an issue only when at least one region would be removed and at -/// least one survives, so a day spent entirely mid-flight (no endpoint on this -/// calendar day — an overnight flight) is left alone rather than blanked. -public struct FlightDayDetector: DataIssueDetector { - public typealias Issue = FlightDayIssue - - public let detects = DataIssueCategory.flightDay - - /// Minimum ground speed for a leg to count as flight. ~300 km/h sits well - /// above sustained driving / rail and far below jet cruise (~800-900 km/h), - /// so it separates the two with a wide margin. - let speedThresholdKMH: Double - /// Minimum leg *duration* for a leg to count as flight, so a sub-minute GPS - /// glitch (a huge implied speed over a few seconds) can't manufacture a - /// flight leg. Cruise fixes arrive every few minutes, so real flight legs - /// clear this comfortably — and unlike a distance floor it doesn't drop a - /// densely-sampled cruise's short (~70 km) five-minute legs. - let minLegSeconds: TimeInterval - - public init() { - self.init(speedThresholdKMH: 300, minLegSeconds: 60) - } - - @_spi(Testing) - public init(speedThresholdKMH: Double, minLegSeconds: TimeInterval) { - self.speedThresholdKMH = speedThresholdKMH - self.minLegSeconds = minLegSeconds - } - - public func detectIssues(in input: DataIssueInput) -> [FlightDayIssue] { - var issues: [FlightDayIssue] = [] - for day in input.report.days { - let samples = input.daySamples.samples(on: day.day) - if let issue = flightIssue(for: day, samples: samples, attributor: input.attributor) { - issues.append(issue) - } - } - return issues - } - - private func flightIssue( - for day: DayPresence, - samples: [LocationSample], - attributor: any RegionAttributing, - ) -> FlightDayIssue? { - let fixes = Self.coalescingSimultaneous(samples) - guard fixes.count >= 3 else { return nil } - - // `flightLeg[i]` is the leg from `fixes[i]` to `fixes[i + 1]`. - var flightLeg = [Bool](repeating: false, count: fixes.count - 1) - var peakSpeedKMH = 0.0 - for index in flightLeg.indices { - let distanceMeters = fixes[index].coordinate - .distance(to: fixes[index + 1].coordinate) - let seconds = fixes[index + 1].timestamp - .timeIntervalSince(fixes[index].timestamp) - guard seconds >= minLegSeconds else { continue } - let speedKMH = distanceMeters / seconds * 3.6 - if speedKMH >= speedThresholdKMH { - flightLeg[index] = true - peakSpeedKMH = max(peakSpeedKMH, speedKMH) - } - } - - // A region is spurious when it has at least one fly-over fix and no - // other kind. Track both facts (plus a total fly-over count) as we walk - // the fixes. - var hasFlyOver: Set = [] - var hasGrounded: Set = [] - var flyOverCount = 0 - for index in fixes.indices { - let flightBefore = index > 0 && flightLeg[index - 1] - let flightAfter = index < flightLeg.count && flightLeg[index] - let region = attributor.region(at: fixes[index].coordinate) - if flightBefore, flightAfter { - hasFlyOver.insert(region) - flyOverCount += 1 - } else { - hasGrounded.insert(region) - } - } - - // A flight is a *sustained* cruise, not a lone outlier: a teleport - // glitch jumps out and straight back, leaving a single fly-over fix - // between two flight legs. Require at least two so one bad fix — which - // is a different kind of data issue — doesn't read as a plane. - guard flyOverCount >= 2 else { return nil } - - let spurious = hasFlyOver.subtracting(hasGrounded) - let removedRegions = day.regions.intersection(spurious) - let keepRegions = day.regions.subtracting(spurious) - guard !removedRegions.isEmpty, !keepRegions.isEmpty else { return nil } - - return FlightDayIssue( - day: day, - keepRegions: keepRegions, - removedRegions: removedRegions, - peakSpeedKMH: peakSpeedKMH, - ) - } - - /// Fixes closer together in time than this are treated as one reading. - private static let coalesceWindowSeconds: TimeInterval = 1 - - /// Collapse runs of near-simultaneous fixes to a single, most-accurate - /// representative. CoreLocation routinely emits a `CLVisit` *and* a - /// significant-change update at the *same* instant, so a day's samples carry - /// exact-timestamp duplicates. A zero-duration leg between two such fixes has - /// no speed, so it isn't a flight leg — and left in, it breaks a fly-over - /// run: the cruise fix beside a duplicate reads as "grounded" (one adjacent - /// leg has no speed), its region stops looking spurious, and a real flight - /// goes unflagged. Collapsing by *time* (not distance) leaves genuine dwells - /// — fixes minutes apart at one spot — intact, so a real layover still - /// grounds its region. - private static func coalescingSimultaneous(_ samples: [LocationSample]) -> [LocationSample] { - var result: [LocationSample] = [] - for sample in samples { - if let last = result.last, - sample.timestamp.timeIntervalSince(last.timestamp) < coalesceWindowSeconds - { - // Same instant as the representative — keep the more accurate fix. - if sample.horizontalAccuracy < last.horizontalAccuracy { - result[result.count - 1] = sample - } - } else { - result.append(sample) - } - } - return result - } -} diff --git a/Where/WhereCore/Sources/DataResolution/FlightDayIssue.swift b/Where/WhereCore/Sources/DataResolution/FlightDayIssue.swift deleted file mode 100644 index f9fe8af50..000000000 --- a/Where/WhereCore/Sources/DataResolution/FlightDayIssue.swift +++ /dev/null @@ -1,51 +0,0 @@ -import Foundation -import RegionKit - -/// A single calendar day whose region set was inflated by cruise-speed GPS -/// fixes crossing untracked geography — i.e. a flight. `keepRegions` are the -/// real endpoints/dwell regions; `removedRegions` are the fly-over-only regions -/// the fix drops. `peakSpeedKMH` is the fastest leg, used for the detail view's -/// explanation copy. -public struct FlightDayIssue: DataIssue, Hashable { - public let day: DayPresence - public let keepRegions: Set - public let removedRegions: Set - public let peakSpeedKMH: Double - - public init( - day: DayPresence, - keepRegions: Set, - removedRegions: Set, - peakSpeedKMH: Double, - ) { - self.day = day - self.keepRegions = keepRegions - self.removedRegions = removedRegions - self.peakSpeedKMH = peakSpeedKMH - } - - public var id: DataIssueID { - .flightDay(day: day.day) - } - - public var category: DataIssueCategory { - .flightDay - } - - public var sortKey: CalendarDay { - day.day - } - - public var isDismissible: Bool { - true - } - - public var resolution: IssueResolution { - .correctFlightDay( - day: day, - keepRegions: keepRegions, - removedRegions: removedRegions, - peakSpeedKMH: peakSpeedKMH, - ) - } -} diff --git a/Where/WhereCore/Sources/DataResolution/FlightTrajectoryAnalyzer.swift b/Where/WhereCore/Sources/DataResolution/FlightTrajectoryAnalyzer.swift new file mode 100644 index 000000000..ae8f82a4b --- /dev/null +++ b/Where/WhereCore/Sources/DataResolution/FlightTrajectoryAnalyzer.swift @@ -0,0 +1,358 @@ +import Foundation + +/// Conservative jet-flight inference over independent device trajectories. All +/// thresholds are product policy, not measurements of a particular user's trip. +/// Short-interval fixes retain their identities but do not break motion baselines. +public struct FlightTrajectoryAnalyzer: Sendable { + private enum Policy { + static let maximumAccuracy = 250.0 + static let anchorInterval: TimeInterval = 60 + static let maximumBaseline: TimeInterval = 2 * 60 * 60 + static let coreSpeed = 450.0 + static let maximumSpeed = 1500.0 + static let minimumCoreDuration: TimeInterval = 3 * 60 + static let minimumProgress = 0.75 + static let transitionSpeed = 150.0 + static let transitionDuration: TimeInterval = 30 * 60 + static let groundRadius = 2000.0 + static let groundSpeed = 50.0 + static let groundDuration: TimeInterval = 10 * 60 + static let groundWindow: TimeInterval = 30 * 60 + static let groundGap: TimeInterval = 10 * 60 + } + + private struct Leg { + let seconds: TimeInterval + let distance: Double + let lowerSpeed: Double + let upperSpeed: Double + + var isCore: Bool { + qualifies(minimumSpeed: Policy.coreSpeed) + } + + var isTransition: Bool { + qualifies(minimumSpeed: Policy.transitionSpeed) + } + + private func qualifies(minimumSpeed: Double) -> Bool { + seconds >= Policy.anchorInterval && seconds <= Policy.maximumBaseline + && lowerSpeed >= minimumSpeed && upperSpeed <= Policy.maximumSpeed + } + } + + private struct Core { + let start: Int + let end: Int + } + + private struct Ground { + let start: Int + let confirmation: Int + } + + public init() {} + + public func analyze(samples: [LocationSample], now: Date) -> [FlightAssessment] { + let tracks = Dictionary(grouping: samples.filter(\.source.isGPS), by: \.recordingDeviceID) + return tracks.flatMap { deviceID, samples in + analyzeTrack(samples, deviceID: deviceID, now: now) + }.sorted { + if $0.startedAt != $1.startedAt { return $0.startedAt < $1.startedAt } + return $0.id.departureSampleID.uuidString < $1.id.departureSampleID.uuidString + } + } + + private func analyzeTrack( + _ samples: [LocationSample], + deviceID: RecordingDeviceID?, + now: Date, + ) -> [FlightAssessment] { + let usable = samples.filter { Self.isUsable($0) && $0.timestamp <= now }.sorted { + if $0.timestamp != $1.timestamp { return $0.timestamp < $1.timestamp } + if $0.horizontalAccuracy != $1.horizontalAccuracy { + return $0.horizontalAccuracy < $1.horizontalAccuracy + } + return $0.id.uuidString < $1.id.uuidString + } + var anchors: [LocationSample] = [] + for sample in usable { + if let last = anchors.last, + sample.timestamp.timeIntervalSince(last.timestamp) < Policy.anchorInterval + { continue } + anchors.append(sample) + } + guard anchors.count >= 3 else { return [] } + let legs = zip(anchors, anchors.dropFirst()).map { Self.leg(from: $0, to: $1) } + let cores = Self.cores(anchors: anchors, legs: legs) + guard !cores.isEmpty else { return [] } + let grounds = Self.grounds(anchors: anchors, legs: legs, observations: usable) + var groups: [[Core]] = [] + for core in cores { + if let previous = groups.last?.last, + anchors[core.start].timestamp.timeIntervalSince(anchors[previous.end].timestamp) + <= Policy.maximumBaseline, + !grounds.contains(where: { + $0.start >= previous.end && $0.confirmation <= core.start + }) + { + groups[groups.count - 1].append(core) + } else { + groups.append([core]) + } + } + return groups.indices.map { index in + assessment( + cores: groups[index], + anchors: anchors, + legs: legs, + usable: usable, + grounds: grounds, + nextStart: index + 1 < groups.count ? groups[index + 1][0].start : anchors.count, + deviceID: deviceID, + now: now, + ) + } + } + + private func assessment( + cores: [Core], + anchors: [LocationSample], + legs: [Leg], + usable: [LocationSample], + grounds: [Ground], + nextStart: Int, + deviceID: RecordingDeviceID?, + now: Date, + ) -> FlightAssessment { + let first = cores[0] + let last = cores[cores.count - 1] + let arrival = grounds.first { $0.start >= last.end && $0.confirmation <= nextStart } + let departure = grounds.last { $0.confirmation <= first.start } + var supportedLegs: Set = [] + for core in cores { + supportedLegs.formUnion(core.start ..< core.end) + var before = core.start + while before > 0, + legs[before - 1].isTransition, + anchors[core.start].timestamp.timeIntervalSince(anchors[before - 1].timestamp) + <= Policy.transitionDuration + { + before -= 1 + supportedLegs.insert(before) + } + var after = core.end + let limit = arrival?.start ?? nextStart - 1 + while after < limit, + legs[after].isTransition, + anchors[after + 1].timestamp.timeIntervalSince(anchors[core.end].timestamp) + <= Policy.transitionDuration + { + supportedLegs.insert(after) + after += 1 + } + } + let groundIDs = Set([departure, arrival].compactMap(\.self).flatMap { ground in + usable.filter { + $0.timestamp >= anchors[ground.start].timestamp + && $0.timestamp <= anchors[ground.confirmation].timestamp + && Self.fitsGround($0, origin: anchors[ground.start]) + }.map(\.id) + }) + let startIndex = supportedLegs.min() ?? first.start + let endIndex = (supportedLegs.max() ?? last.end - 1) + 1 + let earliest = anchors[startIndex] + let latest = anchors[endIndex] + var airborneIDs: Set = [] + var sampleIndex = 0 + for legIndex in supportedLegs.sorted() { + let before = anchors[legIndex] + let after = anchors[legIndex + 1] + // A gap leaves both neighboring endpoints unknown, even when the + // cruise cores on either side belong to one flight review. + let startsRun = !supportedLegs.contains(legIndex - 1) + let endsRun = !supportedLegs.contains(legIndex + 1) + while sampleIndex < usable.count, usable[sampleIndex].timestamp < before.timestamp { + sampleIndex += 1 + } + var candidate = sampleIndex + while candidate < usable.count, usable[candidate].timestamp <= after.timestamp { + let sample = usable[candidate] + if !startsRun || (sample.timestamp > before.timestamp + && !Self.isSameObservation(sample, as: before)), + !endsRun || (sample.timestamp < after.timestamp + && !Self.isSameObservation(sample, as: after)), + !groundIDs.contains(sample.id), + Self.fitsMotion(sample, from: before, to: after) + { + airborneIDs.insert(sample.id) + } + candidate += 1 + } + } + let observationLimit = if let arrival { + anchors[arrival.confirmation].timestamp + } else if nextStart < anchors.count { + anchors[nextStart].timestamp + } else { + now + } + let lastObservation = usable.last { $0.timestamp <= observationLimit } ?? latest + let trailingMotionIsPlausible = anchors.last(where: { + $0.timestamp <= lastObservation.timestamp + }).map { Self.isPlausible(lastObservation, relativeTo: $0) } ?? false + let observationStillCruising = trailingMotionIsPlausible + && (anchors.last(where: { + lastObservation.timestamp.timeIntervalSince($0.timestamp) >= Policy.anchorInterval + }).map { Self.leg(from: $0, to: lastObservation).isCore } ?? false) + let lastFlightAt = observationStillCruising + ? max(latest.timestamp, lastObservation.timestamp) : latest.timestamp + let progress: FlightAssessment.Progress = if let arrival { + .completed(arrivedAt: anchors[arrival.start].timestamp) + } else if observationStillCruising, + now.timeIntervalSince(lastFlightAt) < FlightAssessment.freshnessInterval + { + .flightLikely + } else { + .awaitingArrival + } + return FlightAssessment( + id: .init(recordingDeviceID: deviceID, departureSampleID: earliest.id), + startedAt: earliest.timestamp, + lastObservationAt: lastObservation.timestamp, + lastFlightAt: lastFlightAt, + airborneSampleIDs: airborneIDs, + groundSampleIDs: groundIDs, + peakSpeedKMH: supportedLegs.map { legs[$0].distance / legs[$0].seconds * 3.6 } + .max() ?? 0, + progress: progress, + ) + } + + private static func cores(anchors: [LocationSample], legs: [Leg]) -> [Core] { + var result: [Core] = [] + var index = 0 + while index < legs.count { + guard legs[index].isCore else { index += 1; continue } + let start = index + var path = 0.0 + while index < legs.count, legs[index].isCore { + path += legs[index].distance + index += 1 + } + let seconds = anchors[index].timestamp.timeIntervalSince(anchors[start].timestamp) + let progress = anchors[start].coordinate.distance(to: anchors[index].coordinate) + if index - start >= 2, seconds >= Policy.minimumCoreDuration, + path > 0, progress / path >= Policy.minimumProgress + { + result.append(Core(start: start, end: index)) + } + } + return result + } + + private static func grounds( + anchors: [LocationSample], + legs: [Leg], + observations: [LocationSample], + ) -> [Ground] { + // Preserve dense contradictory callbacks as evidence. Bucketing once + // keeps each bounded ground-window check local to its own observations. + var observationsByLeg = [[LocationSample]](repeating: [], count: legs.count) + var legIndex = 0 + for sample in observations { + while legIndex + 1 < legs.count, sample.timestamp > anchors[legIndex + 1].timestamp { + legIndex += 1 + } + if sample.timestamp <= anchors[legIndex + 1].timestamp { + observationsByLeg[legIndex].append(sample) + if legIndex + 1 < legs.count, + sample.timestamp == anchors[legIndex + 1].timestamp + { + observationsByLeg[legIndex + 1].append(sample) + } + } + } + var result: [Ground] = [] + for start in anchors.indices where fitsGround(anchors[start], origin: anchors[start]) { + var end = start + 1 + while end < anchors.count, + anchors[end].timestamp.timeIntervalSince(anchors[start].timestamp) + <= Policy.groundWindow, + legs[end - 1].seconds <= Policy.groundGap, + legs[end - 1].upperSpeed <= Policy.groundSpeed, + fitsGround(anchors[end], origin: anchors[start]), + observationsByLeg[end - 1].allSatisfy({ + fitsGround($0, origin: anchors[start]) + }) + { + if end - start >= 2, + anchors[end].timestamp.timeIntervalSince(anchors[start].timestamp) + >= Policy.groundDuration + { + result.append(Ground(start: start, confirmation: end)) + break + } + end += 1 + } + } + return result + } + + private static func isSameObservation( + _ sample: LocationSample, + as endpoint: LocationSample, + ) -> Bool { + abs(sample.timestamp.timeIntervalSince(endpoint.timestamp)) < 1 + && sample.coordinate.distance(to: endpoint.coordinate) + <= sample.horizontalAccuracy + endpoint.horizontalAccuracy + } + + private static func fitsGround(_ sample: LocationSample, origin: LocationSample) -> Bool { + if let speed = sample.motion?.speed, + (speed.metersPerSecond + speed.accuracyMetersPerSecond) * 3.6 > Policy.groundSpeed + { return false } + return sample.coordinate.distance(to: origin.coordinate) + + sample.horizontalAccuracy + origin.horizontalAccuracy <= Policy.groundRadius + } + + private static func fitsMotion( + _ sample: LocationSample, + from before: LocationSample, + to after: LocationSample, + ) -> Bool { + isPlausible(sample, relativeTo: before) && isPlausible(sample, relativeTo: after) + } + + private static func isPlausible( + _ sample: LocationSample, + relativeTo endpoint: LocationSample, + ) -> Bool { + let seconds = abs(sample.timestamp.timeIntervalSince(endpoint.timestamp)) + let distance = sample.coordinate.distance(to: endpoint.coordinate) + let uncertainty = sample.horizontalAccuracy + endpoint.horizontalAccuracy + if seconds < 1 { return distance <= uncertainty } + return max(0, distance - uncertainty) / seconds * 3.6 <= Policy.maximumSpeed + } + + private static func leg(from before: LocationSample, to after: LocationSample) -> Leg { + let seconds = after.timestamp.timeIntervalSince(before.timestamp) + let distance = before.coordinate.distance(to: after.coordinate) + let uncertainty = before.horizontalAccuracy + after.horizontalAccuracy + return Leg( + seconds: seconds, + distance: distance, + lowerSpeed: max(0, distance - uncertainty) / seconds * 3.6, + upperSpeed: (distance + uncertainty) / seconds * 3.6, + ) + } + + private static func isUsable(_ sample: LocationSample) -> Bool { + sample.timestamp.timeIntervalSince1970.isFinite + && sample.coordinate.latitude.isFinite && sample.coordinate.longitude.isFinite + && (-90 ... 90).contains(sample.coordinate.latitude) + && (-180 ... 180).contains(sample.coordinate.longitude) + && sample.horizontalAccuracy.isFinite + && (0 ... Policy.maximumAccuracy).contains(sample.horizontalAccuracy) + } +} diff --git a/Where/WhereCore/Sources/DataResolution/GPSCorrectionReview.swift b/Where/WhereCore/Sources/DataResolution/GPSCorrectionReview.swift new file mode 100644 index 000000000..7b8c50581 --- /dev/null +++ b/Where/WhereCore/Sources/DataResolution/GPSCorrectionReview.swift @@ -0,0 +1,58 @@ +import Foundation +import RegionKit + +/// A derived review can explain an ongoing flight before any correction exists. +/// Only a ready state carries an applyable, evidence-bound proposal. +public struct GPSCorrectionReview: Identifiable, Hashable, Sendable { + public enum State: Hashable, Sendable { + case pending(FlightAssessment) + case ready(SampleCorrectionProposal, flight: FlightAssessment?) + case completed(FlightAssessment) + } + + public let id: DataIssueID + public let day: DayPresence + public let points: [SampleCorrectionPoint] + public let state: State + public let flights: [FlightAssessment] + + public init( + id: DataIssueID, + day: DayPresence, + points: [SampleCorrectionPoint], + state: State, + flights: [FlightAssessment] = [], + ) { + self.id = id + self.day = day + self.points = points + self.state = state + if flights.isEmpty { + switch state { + case let .pending(flight), let .completed(flight): self.flights = [flight] + case let .ready(_, flight): self.flights = flight.map { [$0] } ?? [] + } + } else { + self.flights = flights + } + } + + public var flight: FlightAssessment? { + switch state { + case let .pending(flight), let .completed(flight): flight + case let .ready(_, flight): flight + } + } + + public var proposal: SampleCorrectionProposal? { + switch state { + case let .ready(proposal, _): proposal + case .pending, .completed: nil + } + } + + public var isPending: Bool { + if case .pending = state { return true } + return false + } +} diff --git a/Where/WhereCore/Sources/DataResolution/SampleAttributionPersistenceError.swift b/Where/WhereCore/Sources/DataResolution/SampleAttributionPersistenceError.swift new file mode 100644 index 000000000..b57ffe896 --- /dev/null +++ b/Where/WhereCore/Sources/DataResolution/SampleAttributionPersistenceError.swift @@ -0,0 +1,7 @@ +import Foundation + +/// An unreadable or conflicting correction history must never resurrect excluded GPS presence. +public enum SampleAttributionPersistenceError: Error, Sendable, Equatable { + case incompleteHistory + case conflictingRevision(id: UUID) +} diff --git a/Where/WhereCore/Sources/DataResolution/SampleAttributionReset.swift b/Where/WhereCore/Sources/DataResolution/SampleAttributionReset.swift new file mode 100644 index 000000000..1645c7903 --- /dev/null +++ b/Where/WhereCore/Sources/DataResolution/SampleAttributionReset.swift @@ -0,0 +1,29 @@ +import Foundation + +/// Writes reset tombstones, joining the caller’s transaction when resetting a day. +enum SampleAttributionReset { + static func write(sampleIDs: Set, store: any WhereStore, now: Date) async throws { + try await store.performInCurrentGeneration { + let revisions = try await store.sampleAttributionRevisions(for: sampleIDs) + var winners: [UUID: SampleAttributionRevision] = [:] + for revision in revisions { + if let current = winners[revision.sampleID], + !SampleAttributionRevision.newer(revision, than: current) { continue } + winners[revision.sampleID] = revision + } + // A correction can still be waiting to sync even when no local + // replacement is active. Every reset advances each requested register. + for sampleID in sampleIDs { + let updatedAt = winners[sampleID].map { + max(now, $0.updatedAt.addingTimeInterval(0.001)) + } ?? now + try await store.addSampleAttributionRevision(.init( + id: UUID(), + sampleID: sampleID, + updatedAt: updatedAt, + replacementRegions: nil, + )) + } + } + } +} diff --git a/Where/WhereCore/Sources/DataResolution/SampleAttributionRevision.swift b/Where/WhereCore/Sources/DataResolution/SampleAttributionRevision.swift new file mode 100644 index 000000000..5d61a962e --- /dev/null +++ b/Where/WhereCore/Sources/DataResolution/SampleAttributionRevision.swift @@ -0,0 +1,33 @@ +import Foundation +import RegionKit + +/// One immutable revision of a GPS sample's attribution. Nil restores automatic +/// attribution, an empty set excludes the sample from presence, and a nonempty set +/// replaces its attributed regions. Reset tombstones outlive delayed older revisions. +public struct SampleAttributionRevision: Identifiable, Hashable, Codable, Sendable { + public let id: UUID + public let sampleID: UUID + public let updatedAt: Date + public let replacementRegions: Set? + + public init( + id: UUID, + sampleID: UUID, + updatedAt: Date, + replacementRegions: Set?, + ) { + self.id = id + self.sampleID = sampleID + self.updatedAt = updatedAt + self.replacementRegions = replacementRegions + } + + /// Deterministic last-writer ordering for concurrent CloudKit revisions. + public static func newer( + _ lhs: SampleAttributionRevision, + than rhs: SampleAttributionRevision, + ) -> Bool { + if lhs.updatedAt != rhs.updatedAt { return lhs.updatedAt > rhs.updatedAt } + return lhs.id.uuidString > rhs.id.uuidString + } +} diff --git a/Where/WhereCore/Sources/DataResolution/SampleCorrectionApplyResult.swift b/Where/WhereCore/Sources/DataResolution/SampleCorrectionApplyResult.swift new file mode 100644 index 000000000..4aca667fe --- /dev/null +++ b/Where/WhereCore/Sources/DataResolution/SampleCorrectionApplyResult.swift @@ -0,0 +1,5 @@ +/// A successful commit or the replacement review required before another Apply. +public enum SampleCorrectionApplyResult: Sendable { + case applied + case stale(GPSCorrectionReview?) +} diff --git a/Where/WhereCore/Sources/DataResolution/SampleCorrectionAssessment.swift b/Where/WhereCore/Sources/DataResolution/SampleCorrectionAssessment.swift new file mode 100644 index 000000000..efc875140 --- /dev/null +++ b/Where/WhereCore/Sources/DataResolution/SampleCorrectionAssessment.swift @@ -0,0 +1,258 @@ +import Foundation +import RegionKit + +/// Combines trajectory and local boundary evidence into exact, reversible GPS +/// edits. Unknown observations and user assertions always retain their support. +struct SampleCorrectionAssessment { + let attributor: any RegionAttributing + let calendar: Calendar + + func reviews( + reads: DataIssueReads, + primaryRegions: [Region], + driftThresholdMeters: Double, + now: Date, + ) -> [GPSCorrectionReview] { + let flights = FlightTrajectoryAnalyzer().analyze( + samples: reads.history.rawSamples, + now: now, + ) + let byDay = Dictionary(grouping: reads.history.samples) { + CalendarDay(from: $0.sample.timestamp, in: calendar) + } + // Revisions follow a sample identity across days. A conflict anywhere + // in the reviewed snapshot must prevent correcting that identity. + let conflictingSampleIDs = Set(Dictionary( + grouping: reads.history.samples, + by: \.sample.id, + ).filter { Set($0.value).count > 1 }.keys) + let revisionsBySample = Dictionary(grouping: reads.history.revisions, by: \.sampleID) + return byDay.keys.filter { $0.year == reads.report.year }.sorted().compactMap { day in + let start = day.startOfDay(in: calendar) + guard let end = calendar.date(byAdding: .day, value: 1, to: start) else { + assertionFailure("Cannot derive a calendar day's successor") + return nil + } + let contextStart = start.addingTimeInterval(-24 * 60 * 60) + let contextEnd = end.addingTimeInterval(24 * 60 * 60) + let contextDays = CalendarDay(from: contextStart, in: calendar) + .days(through: CalendarDay(from: contextEnd, in: calendar)) + let context = contextDays.flatMap { byDay[$0] ?? [] }.filter { + $0.sample.timestamp >= contextStart && $0.sample.timestamp < contextEnd + }.sorted { $0.sample.timestamp < $1.sample.timestamp } + return review( + day: day, + entries: byDay[day] ?? [], + conflictingSampleIDs: conflictingSampleIDs, + flights: flights, + reads: reads, + primaryRegions: primaryRegions, + driftThresholdMeters: driftThresholdMeters, + context: LocationHistoryProjection( + samples: context, + revisions: context.flatMap { revisionsBySample[$0.sample.id] ?? [] } + .sorted { $0.id.uuidString < $1.id.uuidString }, + ), + ) + } + } + + private func review( + day: CalendarDay, + entries: [AttributedLocationSample], + conflictingSampleIDs: Set, + flights: [FlightAssessment], + reads: DataIssueReads, + primaryRegions: [Region], + driftThresholdMeters: Double, + context: LocationHistoryProjection, + ) -> GPSCorrectionReview? { + let start = day.startOfDay(in: calendar) + guard let end = calendar.date(byAdding: .day, value: 1, to: start) else { + assertionFailure("Cannot derive the end of a Gregorian day") + return nil + } + let dayFlights = flights.filter { flight in + let last: Date = switch flight.progress { + case let .completed(arrivedAt): arrivedAt + case .flightLikely, .awaitingArrival: flight.lastObservationAt + } + return flight.startedAt < end && last >= start + } + let presence = reads.report.days.first { $0.day == day } + ?? DayPresence(day: day, regions: []) + let points = entries.map { SampleCorrectionPoint(sample: $0.sample, regions: $0.regions) } + let reviewID: DataIssueID = dayFlights + .isEmpty ? .borderDrift(day: day) : .flightDay(day: day) + if let pending = dayFlights.last(where: { + switch $0.progress { + case .flightLikely, .awaitingArrival: true + case .completed: false + } + }) { + return GPSCorrectionReview( + id: reviewID, + day: presence, + points: points, + state: .pending(pending), + flights: dayFlights, + ) + } + + let boundaryEvidence = Dictionary(grouping: context.samples.filter { + $0.sample.source.isGPS && usable($0.sample) + }, by: \.sample.recordingDeviceID) + let airborne = dayFlights.reduce(into: Set()) { $0.formUnion($1.airborneSampleIDs) } + let allAirborne = flights.reduce(into: Set()) { $0.formUnion($1.airborneSampleIDs) } + let manuals = reads.manualDays.filter { $0.day == day } + var edits: [SampleCorrectionProposal.Edit] = [] + // An explicit whole-day assertion is authoritative. Keep it intact and + // offer only the informational completed-flight state beneath it. + if !manuals.contains(where: \.isAuthoritative) { + // Concurrent imports can sync multiple physical rows for one sample. + // Review its identity once, retaining conflicting representations as + // unknown evidence instead of choosing an arbitrary row to correct. + for duplicates in Dictionary(grouping: entries, by: \.sample.id).values { + guard let entry = duplicates.first, + !conflictingSampleIDs.contains(entry.sample.id), + entry.sample.source.isGPS, !entry.regions.isEmpty else { continue } + if airborne.contains(entry.sample.id) { + edits.append(.init(sampleID: entry.sample.id, replacementRegions: [])) + } else if let region = boundaryReplacement( + for: entry, + neighbors: boundaryEvidence[entry.sample.recordingDeviceID] ?? [], + airborne: allAirborne, + primaryRegions: primaryRegions, + threshold: driftThresholdMeters, + ), entry.regions != [region] { + edits.append(.init(sampleID: entry.sample.id, replacementRegions: [region])) + } + } + } + edits.sort { $0.sampleID.uuidString < $1.sampleID.uuidString } + let replacement = Dictionary(uniqueKeysWithValues: edits.map { ( + $0.sampleID, + $0.replacementRegions, + ) }) + let corrected = entries.map { + AttributedLocationSample( + sample: $0.sample, + regions: replacement[$0.sample.id] ?? $0.regions, + ) + } + let resulting = DayAggregator(calendar: calendar, timeZone: calendar.timeZone) + .report(for: day.year, history: corrected, manualDays: manuals) + .days.first { $0.day == day }?.regions ?? [] + + if !edits.isEmpty { + let proposal = SampleCorrectionProposal( + reviewID: reviewID, + day: presence, + resultingRegions: resulting, + edits: edits, + dataGenerationID: reads.dataGenerationID, + evidence: .init( + history: context, + manualDays: manuals, + primaryRegions: primaryRegions, + trackedRegions: attributor.loadedRegions, + driftThresholdMeters: driftThresholdMeters, + calendar: calendar, + flights: dayFlights, + ), + ) + return GPSCorrectionReview( + id: reviewID, + day: presence, + points: points, + state: .ready(proposal, flight: dayFlights.last), + flights: dayFlights, + ) + } + guard let flight = dayFlights.last else { return nil } + return GPSCorrectionReview( + id: reviewID, + day: presence, + points: points, + state: .completed(flight), + flights: dayFlights, + ) + } + + private func boundaryReplacement( + for entry: AttributedLocationSample, + neighbors: [AttributedLocationSample], + airborne: Set, + primaryRegions: [Region], + threshold: Double, + ) -> Region? { + let sample = entry.sample + guard entry.regions.contains(.other), + usable(sample), + attributor.region(at: sample.coordinate) == .other, + !airborne.contains(sample.id) else { return nil } + let beforeIndex = insertionIndex( + in: neighbors, + at: sample.timestamp.addingTimeInterval(-1), + afterEqual: true, + ) - 1 + let afterIndex = insertionIndex( + in: neighbors, + at: sample.timestamp.addingTimeInterval(1), + afterEqual: false, + ) + guard beforeIndex >= 0, afterIndex < neighbors.count else { return nil } + let before = neighbors[beforeIndex] + let after = neighbors[afterIndex] + guard sample.timestamp.timeIntervalSince(before.sample.timestamp) <= 10 * 60, + after.sample.timestamp.timeIntervalSince(sample.timestamp) <= 10 * 60, + !airborne.contains(before.sample.id), !airborne.contains(after.sample.id), + localMovement(before.sample, sample), + localMovement(sample, after.sample) else { return nil } + let region = attributor.region(at: before.sample.coordinate) + guard region != .other, + primaryRegions.contains(region), + before.regions.contains(region), after.regions.contains(region), + attributor.region(at: after.sample.coordinate) == region, + let distance = attributor.distanceToBoundary(of: region, from: sample.coordinate), + distance.isFinite, distance <= threshold else { return nil } + return region + } + + /// Binary search keeps dense boundary recordings from rescanning the day + /// for every proposed sample. + private func insertionIndex( + in samples: [AttributedLocationSample], + at timestamp: Date, + afterEqual: Bool, + ) -> Int { + var lower = 0 + var upper = samples.count + while lower < upper { + let middle = lower + (upper - lower) / 2 + let date = samples[middle].sample.timestamp + if date < timestamp || (afterEqual && date == timestamp) { + lower = middle + 1 + } else { + upper = middle + } + } + return lower + } + + private func localMovement(_ before: LocationSample, _ after: LocationSample) -> Bool { + let seconds = after.timestamp.timeIntervalSince(before.timestamp) + guard seconds > 0 else { return false } + let upperDistance = before.coordinate.distance(to: after.coordinate) + + before.horizontalAccuracy + after.horizontalAccuracy + return upperDistance / seconds * 3.6 < 150 + } + + private func usable(_ sample: LocationSample) -> Bool { + sample.timestamp.timeIntervalSince1970.isFinite + && sample.coordinate.latitude.isFinite && sample.coordinate.longitude.isFinite + && (-90 ... 90).contains(sample.coordinate.latitude) + && (-180 ... 180).contains(sample.coordinate.longitude) + && sample.horizontalAccuracy.isFinite && (0 ... 250).contains(sample.horizontalAccuracy) + } +} diff --git a/Where/WhereCore/Sources/DataResolution/SampleCorrectionCoordinator.swift b/Where/WhereCore/Sources/DataResolution/SampleCorrectionCoordinator.swift new file mode 100644 index 000000000..415792ff1 --- /dev/null +++ b/Where/WhereCore/Sources/DataResolution/SampleCorrectionCoordinator.swift @@ -0,0 +1,107 @@ +import Foundation +import RegionKit + +/// Reassesses an exact review inside the store's guarded transaction before +/// appending attribution revisions. A changed review never expands an Apply. +public struct SampleCorrectionCoordinator: Sendable { + private static let logger = WhereLog.reporting(SampleCorrectionCoordinatorLog.self) + private let store: any WhereStore + private let reports: ReportReader + private let calendar: Calendar + private let now: @Sendable () -> Date + private let onCommitted: @Sendable () async -> Void + + init( + store: any WhereStore, + reports: ReportReader, + calendar: Calendar, + now: @escaping @Sendable () -> Date, + onCommitted: @escaping @Sendable () async -> Void, + ) { + self.store = store + self.reports = reports + self.calendar = calendar + self.now = now + self.onCommitted = onCommitted + } + + public func review( + id: DataIssueID, + year: Int, + primaryRegions: [Region], + driftThresholdMeters: Double, + ) async throws -> GPSCorrectionReview? { + let reads = try await reports.dataIssueReads(for: year) + return SampleCorrectionAssessment(attributor: reads.attribution, calendar: calendar) + .reviews( + reads: reads, + primaryRegions: primaryRegions, + driftThresholdMeters: driftThresholdMeters, + now: now(), + ).first { $0.id == id } + } + + public func apply(_ proposal: SampleCorrectionProposal) async throws + -> SampleCorrectionApplyResult + { + let result: SampleCorrectionApplyResult + do { + result = try await store.perform(expectedDataGenerationID: proposal.dataGenerationID) { + let fresh = try await review( + id: proposal.reviewID, + year: proposal.day.day.year, + primaryRegions: proposal.evidence.primaryRegions, + driftThresholdMeters: proposal.evidence.driftThresholdMeters, + ) + guard let current = fresh?.proposal, current == proposal else { + return .stale(fresh) + } + let revisions = try await store + .sampleAttributionRevisions(for: Set(proposal.edits.map(\.sampleID))) + for edit in proposal.edits { + let prior = revisions.filter { $0.sampleID == edit.sampleID } + .max { SampleAttributionRevision.newer($1, than: $0) } + let timestamp = max(now(), prior?.updatedAt.addingTimeInterval(0.001) ?? now()) + try await store.addSampleAttributionRevision(.init( + id: UUID(), + sampleID: edit.sampleID, + updatedAt: timestamp, + replacementRegions: edit.replacementRegions, + )) + } + return .applied + } + } catch RecordingPersistenceError.dataGenerationChanged { + // The review belongs to a retired data world. Show the new world's + // assessment, which is usually absent after Reset or Replace. + Self.logger { .reviewInvalidated } + return try await refreshedReview(for: proposal) + } catch WhereStoreReadConflictError.changedDuringTransaction { + Self.logger { .reviewInvalidated } + return try await refreshedReview(for: proposal) + } + if case .applied = result { await onCommitted() } + return result + } + + private func refreshedReview(for proposal: SampleCorrectionProposal) async throws + -> SampleCorrectionApplyResult + { + try await .stale(review( + id: proposal.reviewID, + year: proposal.day.day.year, + primaryRegions: proposal.evidence.primaryRegions, + driftThresholdMeters: proposal.evidence.driftThresholdMeters, + )) + } + + /// Reset only the named samples, leaving the immutable history available + /// for delayed sync. Day-level Reset to GPS additionally clears its override. + public func reset(sampleIDs: Set) async throws { + guard !sampleIDs.isEmpty else { return } + try await store.performInCurrentGeneration { + try await SampleAttributionReset.write(sampleIDs: sampleIDs, store: store, now: now()) + } + await onCommitted() + } +} diff --git a/Where/WhereCore/Sources/DataResolution/SampleCorrectionIssue.swift b/Where/WhereCore/Sources/DataResolution/SampleCorrectionIssue.swift new file mode 100644 index 000000000..39f8d6651 --- /dev/null +++ b/Where/WhereCore/Sources/DataResolution/SampleCorrectionIssue.swift @@ -0,0 +1,35 @@ +import Foundation + +/// An actionable GPS issue. Pending and completed-without-changes reviews never +/// enter the issue badge or notification count. +public struct SampleCorrectionIssue: DataIssue { + public let proposal: SampleCorrectionProposal + public init(proposal: SampleCorrectionProposal) { + self.proposal = proposal + } + + public var id: DataIssueID { + proposal.reviewID + } + + public var category: DataIssueCategory { + switch id { + case .flightDay: .flightDay + case .borderDrift: .borderDrift + case .missingDays, .abruptChange: + preconditionFailure("A sample correction must have a GPS issue identity") + } + } + + public var sortKey: CalendarDay { + proposal.day.day + } + + public var isDismissible: Bool { + true + } + + public var resolution: IssueResolution { + .correctSamples(proposal) + } +} diff --git a/Where/WhereCore/Sources/DataResolution/SampleCorrectionPoint.swift b/Where/WhereCore/Sources/DataResolution/SampleCorrectionPoint.swift new file mode 100644 index 000000000..a131f3432 --- /dev/null +++ b/Where/WhereCore/Sources/DataResolution/SampleCorrectionPoint.swift @@ -0,0 +1,13 @@ +import Foundation +import RegionKit + +/// One recorded point in a correction review, including excluded raw evidence. +public struct SampleCorrectionPoint: Hashable, Sendable { + public let sample: LocationSample + public let regions: Set + + public init(sample: LocationSample, regions: Set) { + self.sample = sample + self.regions = regions + } +} diff --git a/Where/WhereCore/Sources/DataResolution/SampleCorrectionProposal.swift b/Where/WhereCore/Sources/DataResolution/SampleCorrectionProposal.swift new file mode 100644 index 000000000..b5f822fa2 --- /dev/null +++ b/Where/WhereCore/Sources/DataResolution/SampleCorrectionProposal.swift @@ -0,0 +1,76 @@ +import Foundation +import RegionKit + +/// The exact sample edits a user reviewed, together with the evidence that must +/// still hold when they apply them. It never asserts a whole calendar day. +public struct SampleCorrectionProposal: Hashable, Sendable { + public struct Edit: Hashable, Sendable { + public let sampleID: UUID + public let replacementRegions: Set + + public init(sampleID: UUID, replacementRegions: Set) { + self.sampleID = sampleID + self.replacementRegions = replacementRegions + } + } + + /// A bounded, lossless input identity; equality detects late observations, + /// changed manual assertions, corrections, or attribution configuration. + struct Evidence: Hashable { + let history: LocationHistoryProjection + let manualDays: [DayPresence] + let primaryRegions: [Region] + let trackedRegions: [Region] + let driftThresholdMeters: Double + let calendar: Calendar + let flights: [FlightAssessment] + } + + public let reviewID: DataIssueID + public let day: DayPresence + public let resultingRegions: Set + public let edits: [Edit] + public let dataGenerationID: WhereDataGenerationID + let evidence: Evidence + + init( + reviewID: DataIssueID, + day: DayPresence, + resultingRegions: Set, + edits: [Edit], + dataGenerationID: WhereDataGenerationID, + evidence: Evidence, + ) { + self.reviewID = reviewID + self.day = day + self.resultingRegions = resultingRegions + self.edits = edits + self.dataGenerationID = dataGenerationID + self.evidence = evidence + } + + @_spi(Testing) + public init( + reviewID: DataIssueID, + day: DayPresence, + resultingRegions: Set, + edits: [Edit], + ) { + self.init( + reviewID: reviewID, + day: day, + resultingRegions: resultingRegions, + edits: edits, + dataGenerationID: .initial, + evidence: Evidence( + history: LocationHistoryProjection(samples: [], revisions: []), + manualDays: [], + primaryRegions: [], + trackedRegions: [], + driftThresholdMeters: 1000, + calendar: Calendar(identifier: .gregorian), + flights: [], + ), + ) + } +} diff --git a/Where/WhereCore/Sources/Days/DayAggregator.swift b/Where/WhereCore/Sources/Days/DayAggregator.swift index 1b7cff5ca..9f501789c 100644 --- a/Where/WhereCore/Sources/Days/DayAggregator.swift +++ b/Where/WhereCore/Sources/Days/DayAggregator.swift @@ -28,11 +28,16 @@ public struct DayAggregator: Sendable { samples: [LocationSample], attributor: any RegionAttributing, ) -> [DayPresence] { + aggregate(history: AttributedLocationSample.raw(samples, attributor: attributor)) + } + + public func aggregate(history: [AttributedLocationSample]) -> [DayPresence] { var dayRegions: [CalendarDay: Set] = [:] - for sample in samples { - let day = CalendarDay(from: sample.timestamp, in: calendar) - let region = attributor.region(at: sample.coordinate) - dayRegions[day, default: []].insert(region) + // A reviewed transit-only day remains recorded even when no region + // receives presence. It must not become a new missing-day suggestion. + for entry in history { + let day = CalendarDay(from: entry.sample.timestamp, in: calendar) + dayRegions[day, default: []].formUnion(entry.regions) } return dayRegions .map { DayPresence(day: $0.key, regions: $0.value) } @@ -66,20 +71,38 @@ public struct DayAggregator: Sendable { in regions: Set, samples: [LocationSample], attributor: any RegionAttributing, + ) -> [Region: [RegionDayLocations]] { + locations( + in: regions, + history: AttributedLocationSample.raw(samples, attributor: attributor), + ) + } + + public func locations( + in region: Region, + history: [AttributedLocationSample], + ) -> [RegionDayLocations] { + locations(in: [region], history: history)[region] ?? [] + } + + public func locations( + in regions: Set, + history: [AttributedLocationSample], ) -> [Region: [RegionDayLocations]] { guard regions.isEmpty == false else { return [:] } var pointsByRegionAndDay: [Region: [CalendarDay: [RegionDayPoint]]] = [:] - for sample in samples { - let region = attributor.region(at: sample.coordinate) - guard regions.contains(region) else { continue } + for entry in history { + let sample = entry.sample let day = CalendarDay(from: sample.timestamp, in: calendar) - pointsByRegionAndDay[region, default: [:]][day, default: []].append( - RegionDayPoint( - coordinate: sample.coordinate, - horizontalAccuracy: sample.horizontalAccuracy, - ), - ) + for region in entry.regions.intersection(regions) { + pointsByRegionAndDay[region, default: [:]][day, default: []].append( + RegionDayPoint( + coordinate: sample.coordinate, + horizontalAccuracy: sample.horizontalAccuracy, + ), + ) + } } return pointsByRegionAndDay.mapValues { byDay in @@ -100,14 +123,25 @@ public struct DayAggregator: Sendable { onDay day: CalendarDay, samples: [LocationSample], attributor: any RegionAttributing, + ) -> [Region: [RegionDayPoint]] { + pointsByRegion( + onDay: day, + history: AttributedLocationSample.raw(samples, attributor: attributor), + ) + } + + public func pointsByRegion( + onDay day: CalendarDay, + history: [AttributedLocationSample], ) -> [Region: [RegionDayPoint]] { var byRegion: [Region: [RegionDayPoint]] = [:] - for sample in samples where CalendarDay(from: sample.timestamp, in: calendar) == day { - let region = attributor.region(at: sample.coordinate) - byRegion[region, default: []].append(RegionDayPoint( - coordinate: sample.coordinate, - horizontalAccuracy: sample.horizontalAccuracy, - )) + for entry in history where CalendarDay(from: entry.sample.timestamp, in: calendar) == day { + for region in entry.regions { + byRegion[region, default: []].append(RegionDayPoint( + coordinate: entry.sample.coordinate, + horizontalAccuracy: entry.sample.horizontalAccuracy, + )) + } } return byRegion } @@ -120,23 +154,34 @@ public struct DayAggregator: Sendable { samples: [LocationSample], attributor: any RegionAttributing, ) -> [Region: Coordinate] { + representativeCoordinates(history: AttributedLocationSample.raw( + samples, + attributor: attributor, + )) + } + + public func representativeCoordinates(history: [AttributedLocationSample]) + -> [Region: Coordinate] + { let precision = 20.0 var tallies: [Region: [Int: CellTally]] = [:] - for sample in samples { - let region = attributor.region(at: sample.coordinate) + for entry in history { + let sample = entry.sample let latBucket = Int((sample.coordinate.latitude * precision).rounded()) let lngBucket = Int((sample.coordinate.longitude * precision).rounded()) let cell = latBucket &* 100_000 &+ lngBucket - if let existing = tallies[region]?[cell] { - tallies[region]?[cell] = CellTally( - count: existing.count + 1, - coordinate: existing.coordinate, - ) - } else { - tallies[region, default: [:]][cell] = CellTally( - count: 1, - coordinate: sample.coordinate, - ) + for region in entry.regions { + if let existing = tallies[region]?[cell] { + tallies[region]?[cell] = CellTally( + count: existing.count + 1, + coordinate: existing.coordinate, + ) + } else { + tallies[region, default: [:]][cell] = CellTally( + count: 1, + coordinate: sample.coordinate, + ) + } } } var representatives: [Region: Coordinate] = [:] @@ -166,9 +211,21 @@ public struct DayAggregator: Sendable { samples: [LocationSample], manualDays: [DayPresence] = [], attributor: any RegionAttributing, + ) -> YearReport { + report( + for: year, + history: AttributedLocationSample.raw(samples, attributor: attributor), + manualDays: manualDays, + ) + } + + public func report( + for year: Int, + history: [AttributedLocationSample], + manualDays: [DayPresence], ) -> YearReport { var dayRegions: [CalendarDay: Set] = [:] - for day in aggregate(samples: samples, attributor: attributor) { + for day in aggregate(history: history) { dayRegions[day.day, default: []].formUnion(day.regions) } // Additive manual days union with GPS (backfilling a region GPS diff --git a/Where/WhereCore/Sources/Demo/DemoDataBuilder.swift b/Where/WhereCore/Sources/Demo/DemoDataBuilder.swift index 04539dc8f..a050a3b9a 100644 --- a/Where/WhereCore/Sources/Demo/DemoDataBuilder.swift +++ b/Where/WhereCore/Sources/Demo/DemoDataBuilder.swift @@ -53,16 +53,36 @@ public struct DemoDataBuilder: Sendable { case .flightDay: 5 } }.max() ?? 1 - guard elapsedDays < requiredElapsedDays else { return requestedDate } - guard let referenceDate = calendar.date( + guard let firstReference = calendar.date( byAdding: .day, - value: requiredElapsedDays - elapsedDays, + value: max(0, requiredElapsedDays - elapsedDays), to: requestedDate, ) else { assertionFailure("Could not advance the demo reference date") return requestedDate } - return referenceDate + let referenceDay = max(elapsedDays, requiredElapsedDays) + let fixtureEndMinute: Int? = if issueCategories.contains(.flightDay), + referenceDay == 5 + { + 11 * 60 + 30 + } else if issueCategories.contains(.borderDrift), referenceDay == 4 { + 12 * 60 + 20 + } else { + nil + } + guard let fixtureEndMinute else { return firstReference } + guard let fixtureEnd = calendar.date( + byAdding: .minute, + value: fixtureEndMinute, + to: calendar.startOfDay(for: firstReference), + ) else { + assertionFailure("Could not resolve the demo fixture completion time") + return firstReference + } + // A current-day fixture needs its final real observation on the + // demo clock; future samples cannot establish flight arrival. + return max(firstReference, fixtureEnd) } } @@ -137,7 +157,9 @@ public struct DemoDataBuilder: Sendable { try await services.setPrimaryRegions(Self.primaryRegions) let script = makeScript() - try await services.journal.ingest(script.samples) + try await services.journal.ingest(script.samples.map { + $0.recorded(by: services.recording.currentDevice.id) + }) for entry in script.backfills { try await services.journal.addManualDay( date: entry.date, @@ -386,18 +408,17 @@ public struct DemoDataBuilder: Sendable { let day = elapsedDays < 30 ? 4 : allocatedDay(0.44, minimum: 4) detectorFixtureDays.insert(day) replaceDay(day, in: &script) { date in - var samples = samples(on: date, in: Self.newYorkPlaces, using: &random) - for point in Self.borderDriftCoordinates { - samples.append(sample( + Self.borderDriftFixes.map { fix in + sample( on: date, - hour: 18 + samples.count, - at: point, + hour: fix.minute / 60, + minute: fix.minute % 60, + at: fix.coordinate, source: .gpsSignificantChange, using: &random, jittersCoordinate: false, - )) + ) } - return samples } } @@ -405,11 +426,12 @@ public struct DemoDataBuilder: Sendable { let day = elapsedDays < 30 ? 5 : allocatedDay(0.70, minimum: 5) detectorFixtureDays.insert(day) replaceDay(day, in: &script) { date in - Self.flightCoordinates.enumerated().map { index, coordinate in + Self.flightFixes.enumerated().map { index, fix in sample( on: date, - hour: 8 + index, - at: coordinate, + hour: fix.minute / 60, + minute: fix.minute % 60, + at: fix.coordinate, source: index == 0 ? .gpsVisit : .gpsSignificantChange, using: &random, jittersCoordinate: false, @@ -465,20 +487,54 @@ public struct DemoDataBuilder: Sendable { } } - private static let borderDriftCoordinates = [ - Coordinate(latitude: 40.80015, longitude: -73.99439), - Coordinate(latitude: 40.81084, longitude: -73.98805), - ] + /// Timed GPS evidence for the ordinary shared correction assessment. + private struct FixtureFix { + let minute: Int + let coordinate: Coordinate + } - private static let flightCoordinates = [ - Coordinate(latitude: 40.6413, longitude: -73.7781), - Coordinate(latitude: 40.6413, longitude: -73.7781), - Coordinate(latitude: 40.29, longitude: -90.39), - Coordinate(latitude: 39.53, longitude: -106.16), - Coordinate(latitude: 38.68, longitude: -116.90), - Coordinate(latitude: 37.6213, longitude: -122.3790), - Coordinate(latitude: 37.6213, longitude: -122.3790), - ] + private static let borderDriftFixes: [FixtureFix] = { + let retained = Coordinate(latitude: 40.80015, longitude: -73.971) + return [ + FixtureFix(minute: 12 * 60, coordinate: retained), + FixtureFix( + minute: 12 * 60 + 5, + coordinate: Coordinate(latitude: 40.80015, longitude: -73.99439), + ), + FixtureFix(minute: 12 * 60 + 10, coordinate: retained), + FixtureFix( + minute: 12 * 60 + 15, + coordinate: Coordinate(latitude: 40.81084, longitude: -73.98805), + ), + FixtureFix(minute: 12 * 60 + 20, coordinate: retained), + ] + }() + + private static let flightFixes: [FixtureFix] = { + let departure = Coordinate(latitude: 40.6413, longitude: -73.7781) + let arrival = Coordinate(latitude: 37.6213, longitude: -122.3790) + return [ + FixtureFix(minute: 6 * 60, coordinate: departure), + FixtureFix(minute: 6 * 60 + 5, coordinate: departure), + FixtureFix(minute: 6 * 60 + 10, coordinate: departure), + FixtureFix(minute: 6 * 60 + 20, coordinate: departure), + FixtureFix( + minute: 7 * 60 + 50, + coordinate: Coordinate(latitude: 40.29, longitude: -90.39), + ), + FixtureFix( + minute: 9 * 60 + 20, + coordinate: Coordinate(latitude: 39.53, longitude: -106.16), + ), + FixtureFix( + minute: 10 * 60 + 20, + coordinate: Coordinate(latitude: 38.68, longitude: -116.90), + ), + FixtureFix(minute: 11 * 60 + 20, coordinate: arrival), + FixtureFix(minute: 11 * 60 + 25, coordinate: arrival), + FixtureFix(minute: 11 * 60 + 30, coordinate: arrival), + ] + }() private func replaceDay( _ dayOfYear: Int, @@ -538,6 +594,7 @@ public struct DemoDataBuilder: Sendable { private func sample( on date: Date, hour: Int, + minute: Int = 0, at place: Coordinate, source: SampleSource, using random: inout SeededRandom, @@ -547,7 +604,10 @@ public struct DemoDataBuilder: Sendable { latitude: place.latitude + (jittersCoordinate ? jitter(using: &random) : 0), longitude: place.longitude + (jittersCoordinate ? jitter(using: &random) : 0), ) - let timestamp = calendar.date(byAdding: .hour, value: hour, to: date) ?? date + guard let timestamp = calendar.date(byAdding: .minute, value: hour * 60 + minute, to: date) + else { + preconditionFailure("Could not construct a demo sample timestamp") + } return LocationSample( timestamp: timestamp, coordinate: coordinate, diff --git a/Where/WhereCore/Sources/Devices/AttributedLocationSample.swift b/Where/WhereCore/Sources/Devices/AttributedLocationSample.swift new file mode 100644 index 000000000..cd14fa814 --- /dev/null +++ b/Where/WhereCore/Sources/Devices/AttributedLocationSample.swift @@ -0,0 +1,18 @@ +import Foundation +import RegionKit + +/// A raw observation joined to its effective region attribution. An empty +/// set excludes presence without removing the original trajectory evidence. +public struct AttributedLocationSample: Hashable, Sendable { + public let sample: LocationSample + public let regions: Set + + public init(sample: LocationSample, regions: Set) { + self.sample = sample + self.regions = regions + } + + static func raw(_ samples: [LocationSample], attributor: any RegionAttributing) -> [Self] { + samples.map { Self(sample: $0, regions: [attributor.region(at: $0.coordinate)]) } + } +} diff --git a/Where/WhereCore/Sources/Devices/LocationHistoryProjection.swift b/Where/WhereCore/Sources/Devices/LocationHistoryProjection.swift new file mode 100644 index 000000000..540420d2a --- /dev/null +++ b/Where/WhereCore/Sources/Devices/LocationHistoryProjection.swift @@ -0,0 +1,18 @@ +import Foundation + +/// One consistent read of visible observations and their attribution revisions. +/// Revisions include tombstones so review equality detects changes whose +/// resulting presence happens to be unchanged. +public struct LocationHistoryProjection: Hashable, Sendable { + public let samples: [AttributedLocationSample] + public let revisions: [SampleAttributionRevision] + + public var rawSamples: [LocationSample] { + samples.map(\.sample) + } + + public init(samples: [AttributedLocationSample], revisions: [SampleAttributionRevision]) { + self.samples = samples + self.revisions = revisions + } +} diff --git a/Where/WhereCore/Sources/Devices/LocationHistoryReader.swift b/Where/WhereCore/Sources/Devices/LocationHistoryReader.swift index f44fde94e..1192ec4a8 100644 --- a/Where/WhereCore/Sources/Devices/LocationHistoryReader.swift +++ b/Where/WhereCore/Sources/Devices/LocationHistoryReader.swift @@ -1,4 +1,5 @@ import Foundation +import RegionKit /// Shared policy-aware read path for every user-facing projection of location /// history. The store remains a raw, lossless persistence boundary; this reader @@ -24,4 +25,48 @@ public struct LocationHistoryReader: Sendable { ) } } + + /// Join corrections after applying device cutoffs. Store and trajectory reads + /// remain lossless; only the region attribution changes. + public func projection( + in interval: DateInterval, + attributor: any RegionAttributing, + ) async throws -> LocationHistoryProjection { + try await store.readSnapshot { + let attribution = try await attributionSnapshot(attributor) + let visible = try await samples(in: interval) + .sorted { lhs, rhs in + if lhs.timestamp != rhs.timestamp { return lhs.timestamp < rhs.timestamp } + return lhs.id.uuidString < rhs.id.uuidString + } + let revisions = try await store.sampleAttributionRevisions(for: Set(visible.map(\.id))) + .sorted { $0.id.uuidString < $1.id.uuidString } + var winners: [UUID: SampleAttributionRevision] = [:] + for revision in revisions { + if let current = winners[revision.sampleID], + !SampleAttributionRevision.newer(revision, than: current) { continue } + winners[revision.sampleID] = revision + } + return LocationHistoryProjection( + samples: visible.map { sample in + let replacement = sample.source.isGPS + ? winners[sample.id]?.replacementRegions : nil + return AttributedLocationSample( + sample: sample, + regions: replacement ?? [attribution.region(at: sample.coordinate)], + ) + }, + revisions: revisions, + ) + } + } + + func attributionSnapshot(_ attributor: any RegionAttributing) async throws + -> any RegionAttributing + { + if let live = attributor as? RegionAttribution { + return try await live.snapshot(for: store.trackedRegions()) + } + return attributor + } } diff --git a/Where/WhereCore/Sources/Journal/DayJournal.swift b/Where/WhereCore/Sources/Journal/DayJournal.swift index 9dff51334..e0e6f1d1d 100644 --- a/Where/WhereCore/Sources/Journal/DayJournal.swift +++ b/Where/WhereCore/Sources/Journal/DayJournal.swift @@ -150,14 +150,14 @@ public actor DayJournal { Self.logger { .overrodeDay(day: String(describing: day), regionCount: regions.count) } } - /// Drop the manual overlay for a single calendar day, restoring the - /// GPS-derived attribution (the relabel "reset to GPS" path). A no-op when - /// the day has no manual record. Raw samples are never touched, so this - /// simply lets the aggregator fall back to whatever GPS recorded. + /// Restore GPS attribution by atomically clearing the day's manual overlay + /// and appending reset tombstones for its sample corrections. Raw samples + /// remain unchanged and delayed older revisions cannot revive the correction. public func clearManualDay(date: Date) async throws { let day = CalendarDay(from: date, in: aggregator.calendar) try await store.performInCurrentGeneration { try await store.clearManualDay(day) + try await resetSampleCorrections(on: day) } await reconcileAfterDayDataChange() Self.logger { .clearedManualDay(day: String(describing: day)) } @@ -179,6 +179,7 @@ public actor DayJournal { try await store.performInCurrentGeneration { for day in days { try await store.clearManualDay(day) + try await resetSampleCorrections(on: day) } } } @@ -224,6 +225,19 @@ public actor DayJournal { // MARK: - Clearing + private func resetSampleCorrections(on day: CalendarDay) async throws { + let start = day.startOfDay(in: aggregator.calendar) + guard let end = aggregator.calendar.date(byAdding: .day, value: 1, to: start) else { + preconditionFailure("A valid calendar day must have a successor") + } + let samples = try await store.samples(in: DateInterval(start: start, end: end)) + try await SampleAttributionReset.write( + sampleIDs: Set(samples.filter(\.source.isGPS).map(\.id)), + store: store, + now: now(), + ) + } + public func clearYear(_ year: Int) async throws { let interval = aggregator.yearInterval(year: year) let dayRange = CalendarDay.yearRange(year) diff --git a/Where/WhereCore/Sources/Location/CoreLocationSource.swift b/Where/WhereCore/Sources/Location/CoreLocationSource.swift index eec9ce87c..4c2d5a864 100644 --- a/Where/WhereCore/Sources/Location/CoreLocationSource.swift +++ b/Where/WhereCore/Sources/Location/CoreLocationSource.swift @@ -356,6 +356,29 @@ public final class CoreLocationSource: NSObject, LocationSource { @unknown default: .notDetermined } } + + /// Normalize unavailable CLLocation measurements at the system boundary. + nonisolated static func motion(from location: CLLocation) -> LocationMotion? { + let speed: LocationMotion.Speed? = if location.speed.isFinite, + location.speed >= 0, + location.speedAccuracy.isFinite, + location.speedAccuracy >= 0 + { + .init(metersPerSecond: location.speed, accuracyMetersPerSecond: location.speedAccuracy) + } else { + nil + } + let altitude: LocationMotion.Altitude? = if location.altitude.isFinite, + location.verticalAccuracy.isFinite, + location.verticalAccuracy > 0 + { + .init(meters: location.altitude, accuracyMeters: location.verticalAccuracy) + } else { + nil + } + guard speed != nil || altitude != nil else { return nil } + return LocationMotion(speed: speed, altitude: altitude) + } } extension CoreLocationSource: CLLocationManagerDelegate { @@ -374,6 +397,7 @@ extension CoreLocationSource: CLLocationManagerDelegate { ), horizontalAccuracy: location.horizontalAccuracy, source: .gpsSignificantChange, + motion: Self.motion(from: location), ) sampleContinuation.yield(sample) validSamples.append(sample) diff --git a/Where/WhereCore/Sources/Location/LocationMotion.swift b/Where/WhereCore/Sources/Location/LocationMotion.swift new file mode 100644 index 000000000..b54b6e96d --- /dev/null +++ b/Where/WhereCore/Sources/Location/LocationMotion.swift @@ -0,0 +1,33 @@ +import Foundation + +/// Optional sensor measurements accompanying a GPS fix. Missing or invalid system +/// measurements stay absent; inferred travel speeds are never stored as sensor readings. +public struct LocationMotion: Hashable, Codable, Sendable { + public struct Speed: Hashable, Codable, Sendable { + public let metersPerSecond: Double + public let accuracyMetersPerSecond: Double + + public init(metersPerSecond: Double, accuracyMetersPerSecond: Double) { + self.metersPerSecond = metersPerSecond + self.accuracyMetersPerSecond = accuracyMetersPerSecond + } + } + + public struct Altitude: Hashable, Codable, Sendable { + public let meters: Double + public let accuracyMeters: Double + + public init(meters: Double, accuracyMeters: Double) { + self.meters = meters + self.accuracyMeters = accuracyMeters + } + } + + public let speed: Speed? + public let altitude: Altitude? + + public init(speed: Speed?, altitude: Altitude?) { + self.speed = speed + self.altitude = altitude + } +} diff --git a/Where/WhereCore/Sources/Location/LocationSample.swift b/Where/WhereCore/Sources/Location/LocationSample.swift index b0e2b7d4e..be1cf9ecd 100644 --- a/Where/WhereCore/Sources/Location/LocationSample.swift +++ b/Where/WhereCore/Sources/Location/LocationSample.swift @@ -106,6 +106,8 @@ public struct LocationSample: Identifiable, Hashable, Codable, Sendable { /// Installation that produced an automatic GPS sample. Nil for legacy /// samples and user-asserted/manual data. public let recordingDeviceID: RecordingDeviceID? + /// Sensor readings when the source supplies valid speed or altitude evidence. + public let motion: LocationMotion? public init( id: UUID = UUID(), @@ -114,6 +116,7 @@ public struct LocationSample: Identifiable, Hashable, Codable, Sendable { horizontalAccuracy: Double, source: SampleSource, recordingDeviceID: RecordingDeviceID? = nil, + motion: LocationMotion? = nil, ) { self.id = id self.timestamp = timestamp @@ -121,6 +124,7 @@ public struct LocationSample: Identifiable, Hashable, Codable, Sendable { self.horizontalAccuracy = horizontalAccuracy self.source = source self.recordingDeviceID = recordingDeviceID + self.motion = motion } /// Stamp an automatic sample with the installation that received it. @@ -134,6 +138,7 @@ public struct LocationSample: Identifiable, Hashable, Codable, Sendable { horizontalAccuracy: horizontalAccuracy, source: source, recordingDeviceID: deviceID, + motion: motion, ) } } diff --git a/Where/WhereCore/Sources/Logging/DataIssueScannerLog.swift b/Where/WhereCore/Sources/Logging/DataIssueScannerLog.swift index 4c65baeaa..23bcd13a7 100644 --- a/Where/WhereCore/Sources/Logging/DataIssueScannerLog.swift +++ b/Where/WhereCore/Sources/Logging/DataIssueScannerLog.swift @@ -13,6 +13,7 @@ struct DataIssueScannerLog: LogEvent { enum SpanName: Hashable, CustomStringConvertible { /// A full scan on a cache miss: the reads, then every detector. case scan + case assessGPS /// One detector's pass over the already-read input, so a scan that runs /// long attributes to the detector responsible rather than to "the scan". case detect(DataIssueCategory) @@ -20,6 +21,7 @@ struct DataIssueScannerLog: LogEvent { var description: String { switch self { case .scan: "scan" + case .assessGPS: "assess-gps" case let .detect(category): "detect(\(category.name))" } } diff --git a/Where/WhereCore/Sources/Logging/SampleCorrectionCoordinatorLog.swift b/Where/WhereCore/Sources/Logging/SampleCorrectionCoordinatorLog.swift new file mode 100644 index 000000000..df8625388 --- /dev/null +++ b/Where/WhereCore/Sources/Logging/SampleCorrectionCoordinatorLog.swift @@ -0,0 +1,14 @@ +import PeriscopeCore + +/// Privacy-preserving outcome of an Apply invalidated by another writer. +enum SampleCorrectionCoordinatorLog: LogEvent { + case reviewInvalidated + + static let eventName = "SampleCorrectionCoordinator" + + var message: String { + switch self { + case .reviewInvalidated: "Correction review refreshed after a concurrent data change" + } + } +} diff --git a/Where/WhereCore/Sources/Logging/SwiftDataStoreLog.swift b/Where/WhereCore/Sources/Logging/SwiftDataStoreLog.swift index 22d85316d..6fdfc1110 100644 --- a/Where/WhereCore/Sources/Logging/SwiftDataStoreLog.swift +++ b/Where/WhereCore/Sources/Logging/SwiftDataStoreLog.swift @@ -41,6 +41,8 @@ enum SwiftDataStoreLog: LogEvent { case ignoredUnknownPrimaryRegions(ids: [String]) /// Dropped a record that failed to materialize into a domain value. case droppedCorruptRecord(type: String) + /// Preserved a valid raw position while optional motion fields were incomplete. + case ignoredIncompleteSampleMotion /// Chose a deterministic value when CloudKit delivered conflicting rows for an immutable id. case resolvedConflictingImmutableRecords(type: String, id: String, count: Int) /// Persistent history could not distinguish a local save from an external import; the @@ -54,6 +56,7 @@ enum SwiftDataStoreLog: LogEvent { case .openedInMemory, .openedOnDisk: .info case .ignoredUnknownTrackedRegions, .ignoredUnknownPrimaryRegions, + .ignoredIncompleteSampleMotion, .remoteChangeClassificationFailed: .warning case .droppedCorruptRecord, .resolvedConflictingImmutableRecords: .fault @@ -72,6 +75,8 @@ enum SwiftDataStoreLog: LogEvent { "Ignored \(ids.count) unknown primary-region id(s): \(ids.joined(separator: ", "))" case let .droppedCorruptRecord(type): "Dropped corrupt SwiftData record of type \(type)" + case .ignoredIncompleteSampleMotion: + "Preserved raw location while optional motion fields were incomplete" case let .resolvedConflictingImmutableRecords(type, id, count): "Resolved \(count) conflicting immutable \(type) records for id \(id)" case let .remoteChangeClassificationFailed(description): @@ -96,7 +101,8 @@ enum SwiftDataStoreLog: LogEvent { key: RemoteLogFieldKey("conflict_count"), value: .count(count), )] - case .openedInMemory, .droppedCorruptRecord, .remoteChangeClassificationFailed: + case .openedInMemory, .droppedCorruptRecord, .ignoredIncompleteSampleMotion, + .remoteChangeClassificationFailed: [] } } diff --git a/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift b/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift index 295f65547..61fc547dc 100644 --- a/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift +++ b/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift @@ -59,6 +59,30 @@ private enum GenerationScopedFetch { }, sortBy: sortBy) } + static func sampleAttributions( + belongingTo generationID: WhereDataGenerationID, + ) -> FetchDescriptor { + let membership = GenerationMembership(generationID) + let storedGenerationID = membership.storedID + let includesLegacy = membership.includesLegacy + return descriptor(predicate: #Predicate { + $0.generationID == storedGenerationID || (includesLegacy && $0.generationID == nil) + }) + } + + static func sampleAttributions( + belongingTo generationID: WhereDataGenerationID, + revisionID: UUID, + ) -> FetchDescriptor { + let membership = GenerationMembership(generationID) + let storedGenerationID = membership.storedID + let includesLegacy = membership.includesLegacy + return descriptor(predicate: #Predicate { + ($0.generationID == storedGenerationID || (includesLegacy && $0.generationID == nil)) && + $0.id == revisionID + }) + } + static func evidence( belongingTo generationID: WhereDataGenerationID, sortBy: [SortDescriptor] = [], @@ -583,6 +607,7 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { SDWhereDataGeneration.self, SDBackupImportReceipt.self, SDLocationSample.self, + SDSampleAttributionRevision.self, SDEvidence.self, SDManualDay.self, SDDismissedIssue.self, @@ -654,6 +679,9 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { private struct ActiveTransaction { let context: ModelContext var generation: WhereDataGeneration.Resolution + /// The earliest snapshot read in this transaction determines which + /// external commits may invalidate the pending evidence-based writes. + var snapshotHistoryTransactionID: Int64? } private struct ActiveSnapshot { @@ -740,9 +768,17 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { _ block: @Sendable () async throws -> T, ) async throws -> T { let storeID = ObjectIdentifier(self) - if Self.activeSnapshotStores.contains(storeID) - || Self.activeTransactionStores.contains(storeID) - { + if Self.activeTransactionStores.contains(storeID) { + guard let transaction = activeTransaction else { + preconditionFailure("A nested snapshot must retain its active transaction.") + } + if transaction.snapshotHistoryTransactionID == nil { + activeTransaction?.snapshotHistoryTransactionID = try Self + .latestHistoryTransactionID(in: transaction.context) + } + return try await block() + } + if Self.activeSnapshotStores.contains(storeID) { return try await block() } @@ -864,7 +900,11 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { let peer = ModelContext(modelContainer) peer.author = localTransactionAuthor let generation = try Self.resolvedDataGenerationResolution(in: peer) - activeTransaction = ActiveTransaction(context: peer, generation: generation) + activeTransaction = ActiveTransaction( + context: peer, + generation: generation, + snapshotHistoryTransactionID: nil, + ) defer { activeTransaction = nil } if let expectedDataGenerationID, activeTransaction?.generation.current.id != expectedDataGenerationID @@ -890,6 +930,16 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { // reaching the persistent store — a clean rollback of the entire // transaction — while the enclosing scopes still clear the active // state and release the gate. + // readSnapshot inside a mutation uses this peer, but still + // needs its evidence protected from same-generation CloudKit + // or sibling-process commits. Keep this check and save adjacent + // with no suspension so a conflicting read discards every write. + if let startingHistoryTransactionID = activeTransaction? + .snapshotHistoryTransactionID, + try Self.latestHistoryTransactionID(in: peer) != startingHistoryTransactionID + { + throw WhereStoreReadConflictError.changedDuringTransaction + } try peer.save() // The persistent store can import a CloudKit reset while this // asynchronous transaction body is suspended. Saving old-generation @@ -1174,6 +1224,11 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { in context: ModelContext, belongingTo generationID: WhereDataGenerationID, ) throws { + for record in try context.fetch(GenerationScopedFetch.sampleAttributions( + belongingTo: generationID, + )) { + context.delete(record) + } for record in try context.fetch(GenerationScopedFetch.samples( belongingTo: generationID, )) { @@ -1267,6 +1322,65 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { } } + // MARK: - Sample attributions + + public func sampleAttributionRevisions( + for sampleIDs: Set, + ) async throws -> [SampleAttributionRevision] { + guard !sampleIDs.isEmpty else { return [] } + return try await allSampleAttributionRevisions().filter { sampleIDs.contains($0.sampleID) } + } + + public func allSampleAttributionRevisions() async throws -> [SampleAttributionRevision] { + let context = readContext() + let generationID = try readGenerationID(in: context) + let records = try context + .fetch(GenerationScopedFetch.sampleAttributions(belongingTo: generationID)) + let values = try records.map { record in + guard let value = record.toValue() else { + Self.logFault(forCorrupt: record) + throw SampleAttributionPersistenceError.incompleteHistory + } + return value + } + return try Dictionary(grouping: values, by: \.id).map { revisionID, duplicates in + guard let canonical = duplicates.first else { + preconditionFailure("A grouped revision must contain at least one value.") + } + guard duplicates.allSatisfy({ $0 == canonical }) else { + Self.logImmutableConflict( + type: String(describing: SampleAttributionRevision.self), + id: revisionID.uuidString, + count: duplicates.count, + ) + throw SampleAttributionPersistenceError.conflictingRevision(id: revisionID) + } + return canonical + }.sorted { SampleAttributionRevision.newer($1, than: $0) } + } + + public func addSampleAttributionRevision(_ revision: SampleAttributionRevision) async throws { + guard revision.updatedAt.timeIntervalSince1970.isFinite else { + throw SampleAttributionPersistenceError.incompleteHistory + } + let context = mutationContext() + let generationID = mutationGenerationID() + let existing = try context.fetch(GenerationScopedFetch.sampleAttributions( + belongingTo: generationID, + revisionID: revision.id, + )) + guard !existing.isEmpty else { + context.insert(SDSampleAttributionRevision(value: revision, generationID: generationID)) + return + } + guard existing.allSatisfy({ $0.toValue() == revision }) else { + throw SampleAttributionPersistenceError.conflictingRevision(id: revision.id) + } + for duplicate in existing.dropFirst() { + context.delete(duplicate) + } + } + // MARK: - Recording devices public func recordingDevices() async throws -> [RecordingDevice] { @@ -1695,6 +1809,10 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { belongingTo: generationID, in: interval, )) + // Clearing history also clears its reviewed attributions. Keep reset revisions + // so a delayed old correction cannot regain authority if its raw sample returns. + let sampleIDs = Set(samples.compactMap(\.id)) + try await SampleAttributionReset.write(sampleIDs: sampleIDs, store: self, now: Date()) for record in samples { context.delete(record) } @@ -2076,6 +2194,8 @@ final class SDWhereDataGeneration { @Model final class SDLocationSample { + private static let logger = WhereLog.root(SwiftDataStoreLog.self) + /// Nil belongs to the implicit initial generation, preserving rows from builds before /// generations. var generationID: UUID? @@ -2096,6 +2216,12 @@ final class SDLocationSample { /// Installation that produced an automatic sample. Nil on legacy rows and /// manual/evidence-implied samples. var recordingDeviceID: UUID? + /// Retains the distinction between absent motion and a present, empty measurement bundle. + var motionPresent: Bool? + var speedMetersPerSecond: Double? + var speedAccuracyMetersPerSecond: Double? + var altitudeMeters: Double? + var altitudeAccuracyMeters: Double? init() {} @@ -2115,6 +2241,11 @@ final class SDLocationSample { evidenceId = value.source.evidenceId evidenceKindRaw = value.source.evidenceKind?.discriminator recordingDeviceID = value.recordingDeviceID?.rawValue + motionPresent = value.motion.map { _ in true } + speedMetersPerSecond = value.motion?.speed?.metersPerSecond + speedAccuracyMetersPerSecond = value.motion?.speed?.accuracyMetersPerSecond + altitudeMeters = value.motion?.altitude?.meters + altitudeAccuracyMeters = value.motion?.altitude?.accuracyMeters } func toValue() -> LocationSample? { @@ -2126,6 +2257,28 @@ final class SDLocationSample { evidenceId: evidenceId, evidenceKindRaw: evidenceKindRaw, ) else { return nil } + // Optional motion may arrive partially without invalidating the raw + // position. Preserve each complete measurement independently, and keep + // these reads non-mutating so a later sync can complete the other one. + let hasMotionFields = speedMetersPerSecond != nil || speedAccuracyMetersPerSecond != nil + || altitudeMeters != nil || altitudeAccuracyMeters != nil + if (speedMetersPerSecond == nil) != (speedAccuracyMetersPerSecond == nil) + || (altitudeMeters == nil) != (altitudeAccuracyMeters == nil) + || (motionPresent != true && hasMotionFields) + { + Self.logger { .ignoredIncompleteSampleMotion } + } + let speed: LocationMotion.Speed? = if let speedMetersPerSecond, + let speedAccuracyMetersPerSecond + { + .init( + metersPerSecond: speedMetersPerSecond, + accuracyMetersPerSecond: speedAccuracyMetersPerSecond, + ) + } else { nil } + let altitude: LocationMotion.Altitude? = if let altitudeMeters, let altitudeAccuracyMeters { + .init(meters: altitudeMeters, accuracyMeters: altitudeAccuracyMeters) + } else { nil } return LocationSample( id: id, timestamp: timestamp, @@ -2133,6 +2286,49 @@ final class SDLocationSample { horizontalAccuracy: horizontalAccuracy, source: source, recordingDeviceID: recordingDeviceID.map(RecordingDeviceID.init(rawValue:)), + motion: motionPresent == true || speed != nil || altitude != nil + ? LocationMotion(speed: speed, altitude: altitude) : nil, + ) + } +} + +/// Immutable, generation-scoped correction revision. An optional array preserves +/// all three values: nil reset, empty exclusion, and explicit replacement regions. +@Model +final class SDSampleAttributionRevision { + var generationID: UUID? + var id: UUID? + var sampleID: UUID? + var updatedAt: Date? + var replacementRegionIDs: [String]? + + init() {} + + convenience init(value: SampleAttributionRevision, generationID: WhereDataGenerationID) { + self.init() + self.generationID = generationID.rawValue + id = value.id + sampleID = value.sampleID + updatedAt = value.updatedAt + replacementRegionIDs = value.replacementRegions.map { $0.map(\.rawValue).sorted() } + } + + func toValue() -> SampleAttributionRevision? { + guard generationID != nil, let id, let sampleID, let updatedAt, + updatedAt.timeIntervalSince1970.isFinite else { return nil } + let replacementRegions: Set? + if let replacementRegionIDs { + let regions = replacementRegionIDs.compactMap { Region(rawValue: $0) } + guard regions.count == replacementRegionIDs.count else { return nil } + replacementRegions = Set(regions) + } else { + replacementRegions = nil + } + return SampleAttributionRevision( + id: id, + sampleID: sampleID, + updatedAt: updatedAt, + replacementRegions: replacementRegions, ) } } diff --git a/Where/WhereCore/Sources/Persistence/WhereStore.swift b/Where/WhereCore/Sources/Persistence/WhereStore.swift index 2e4ce648f..caef9cf9d 100644 --- a/Where/WhereCore/Sources/Persistence/WhereStore.swift +++ b/Where/WhereCore/Sources/Persistence/WhereStore.swift @@ -44,6 +44,8 @@ public protocol WhereStore: Sendable { /// for authority decisions and backup export; a remote commit crossing its reads invalidates /// the /// result through persistent history even if its notification has not arrived yet. + /// Inside `perform`, retain that evidence boundary until the outer save. An external commit + /// crossing it throws `WhereStoreReadConflictError.changedDuringTransaction` before saving. @discardableResult func readSnapshot( _ block: @Sendable () async throws -> T, @@ -113,6 +115,17 @@ public protocol WhereStore: Sendable { func samples(in interval: DateInterval) async throws -> [LocationSample] func allSamples() async throws -> [LocationSample] + /// Every current-generation attribution revision for the requested raw sample identities. + func sampleAttributionRevisions(for sampleIDs: Set) async throws + -> [SampleAttributionRevision] + /// Full correction history, including reset tombstones and revisions whose sample has not + /// synced. + func allSampleAttributionRevisions() async throws -> [SampleAttributionRevision] + /// Insert an immutable revision, accepting identical retries and rejecting conflicting + /// identities. + /// Must run inside `perform { ... }`; the sample may arrive later through CloudKit. + func addSampleAttributionRevision(_ revision: SampleAttributionRevision) async throws + /// Every assembled synced device read model, including removed devices. func recordingDevices() async throws -> [RecordingDevice] diff --git a/Where/WhereCore/Sources/Persistence/WhereStoreReadConflictError.swift b/Where/WhereCore/Sources/Persistence/WhereStoreReadConflictError.swift new file mode 100644 index 000000000..3f9af42d1 --- /dev/null +++ b/Where/WhereCore/Sources/Persistence/WhereStoreReadConflictError.swift @@ -0,0 +1,5 @@ +/// An external commit changed the evidence read by a pending store mutation. +/// The transaction was discarded before saving; callers must read fresh evidence. +public enum WhereStoreReadConflictError: Error, Sendable, Equatable { + case changedDuringTransaction +} diff --git a/Where/WhereCore/Sources/RegionAttribution.swift b/Where/WhereCore/Sources/RegionAttribution.swift index 01f929093..aa5f46a3a 100644 --- a/Where/WhereCore/Sources/RegionAttribution.swift +++ b/Where/WhereCore/Sources/RegionAttribution.swift @@ -84,6 +84,14 @@ final class RegionAttribution: RegionAttributing { state.withLock { $0.attributor } } + /// Pin attribution to the tracked set read by an enclosing store snapshot. + /// A pending change-stream callback must not change an authority decision. + func snapshot(for tracked: Set) -> RegionAttributor { + let existing = state.withLock { $0 } + if existing.trackedIDs == Set(tracked.map(\.rawValue)) { return existing.attributor } + return RegionAttributor(for: Region.inCanonicalOrder(tracked)) + } + func region(at coordinate: Coordinate) -> Region { current.region(at: coordinate) } diff --git a/Where/WhereCore/Sources/Reporting/ReportReader.swift b/Where/WhereCore/Sources/Reporting/ReportReader.swift index 739920f74..33ffcd8be 100644 --- a/Where/WhereCore/Sources/Reporting/ReportReader.swift +++ b/Where/WhereCore/Sources/Reporting/ReportReader.swift @@ -38,13 +38,12 @@ public struct ReportReader: Sendable { try await Self.logger.measure(.yearReport, budget: .seconds(1)) { try await store.readSnapshot { let interval = aggregator.yearInterval(year: year) - let samples = try await history.samples(in: interval) + let projection = try await history.projection(in: interval, attributor: attributor) let manuals = try await store.manualDays(in: dayRange(for: year)) return aggregator.report( for: year, - samples: samples, + history: projection.samples, manualDays: manuals, - attributor: attributor, ) } } @@ -60,15 +59,15 @@ public struct ReportReader: Sendable { ) async throws -> YearReportDetails { try await Self.logger.measure(.yearReportDetails, budget: .seconds(1)) { try await store.readSnapshot { - let samples = try await history.samples( + let projection = try await history.projection( in: aggregator.yearInterval(year: year), + attributor: attributor, ) let manuals = try await store.manualDays(in: dayRange(for: year)) let report = aggregator.report( for: year, - samples: samples, + history: projection.samples, manualDays: manuals, - attributor: attributor, ) let primaryRegions = Set(Region.primaryRegions( in: report.totals, @@ -76,8 +75,7 @@ public struct ReportReader: Sendable { )) let locations = aggregator.locations( in: primaryRegions, - samples: samples, - attributor: attributor, + history: projection.samples, ) return YearReportDetails( report: report, @@ -96,28 +94,44 @@ public struct ReportReader: Sendable { public func dataIssueReads(for year: Int) async throws -> DataIssueReads { try await Self.logger.measure(.dataIssueReads, budget: .seconds(2)) { try await store.readSnapshot { - let samples = try await history.samples(in: aggregator.yearInterval(year: year)) + let interval = aggregator.yearInterval(year: year) + let contextInterval = DateInterval( + start: interval.start.addingTimeInterval(-24 * 60 * 60), + end: interval.end.addingTimeInterval(24 * 60 * 60), + ) + let attribution = try await history.attributionSnapshot(attributor) + let projection = try await history.projection( + in: contextInterval, + attributor: attribution, + ) + let generation = try await store.dataGeneration() let manuals = try await store.manualDays(in: dayRange(for: year)) let report = aggregator.report( for: year, - samples: samples, + history: projection.samples, manualDays: manuals, - attributor: attributor, ) let otherLocations = aggregator.locations( in: .other, - samples: samples, - attributor: attributor, + history: projection.samples, ) let otherDayCoordinates = Dictionary( uniqueKeysWithValues: otherLocations.map { ($0.day, $0.points.map(\.coordinate)) }, ) - return DataIssueReads( + return try await DataIssueReads( report: report, otherDayCoordinates: otherDayCoordinates, - daySamples: DaySamples(samples: samples, calendar: aggregator.calendar), + daySamples: DaySamples( + samples: projection.rawSamples, + calendar: aggregator.calendar, + ), + history: projection, + manualDays: manuals, + dataGenerationID: generation.id, + dismissedIssueIDs: store.dismissedIssueIDs(), + attribution: attribution, ) } } @@ -149,8 +163,8 @@ public struct ReportReader: Sendable { ) async throws -> [Region: [RegionDayLocations]] { try await Self.logger.measure(.regionLocations, budget: .seconds(1)) { let interval = aggregator.yearInterval(year: year) - let samples = try await history.samples(in: interval) - return aggregator.locations(in: regions, samples: samples, attributor: attributor) + let projection = try await history.projection(in: interval, attributor: attributor) + return aggregator.locations(in: regions, history: projection.samples) } } @@ -165,8 +179,11 @@ public struct ReportReader: Sendable { guard let end = aggregator.calendar.date(byAdding: .day, value: 1, to: start) else { return [:] } - let samples = try await history.samples(in: DateInterval(start: start, end: end)) - return aggregator.pointsByRegion(onDay: day, samples: samples, attributor: attributor) + let projection = try await history.projection( + in: DateInterval(start: start, end: end), + attributor: attributor, + ) + return aggregator.pointsByRegion(onDay: day, history: projection.samples) } } @@ -176,8 +193,8 @@ public struct ReportReader: Sendable { public func representativeCoordinates(for year: Int) async throws -> [Region: Coordinate] { try await Self.logger.measure(.representativeCoordinates, budget: .seconds(1)) { let interval = aggregator.yearInterval(year: year) - let samples = try await history.samples(in: interval) - return aggregator.representativeCoordinates(samples: samples, attributor: attributor) + let projection = try await history.projection(in: interval, attributor: attributor) + return aggregator.representativeCoordinates(history: projection.samples) } } @@ -193,14 +210,29 @@ public struct DataIssueReads: Sendable { public let report: YearReport public let otherDayCoordinates: [CalendarDay: [Coordinate]] public let daySamples: DaySamples + public let history: LocationHistoryProjection + public let manualDays: [DayPresence] + public let dataGenerationID: WhereDataGenerationID + public let dismissedIssueIDs: Set + public let attribution: any RegionAttributing public init( report: YearReport, otherDayCoordinates: [CalendarDay: [Coordinate]], daySamples: DaySamples, + history: LocationHistoryProjection, + manualDays: [DayPresence], + dataGenerationID: WhereDataGenerationID, + dismissedIssueIDs: Set, + attribution: any RegionAttributing, ) { self.report = report self.otherDayCoordinates = otherDayCoordinates self.daySamples = daySamples + self.history = history + self.manualDays = manualDays + self.dataGenerationID = dataGenerationID + self.dismissedIssueIDs = dismissedIssueIDs + self.attribution = attribution } } diff --git a/Where/WhereCore/Sources/WhereServices.swift b/Where/WhereCore/Sources/WhereServices.swift index 6deecf7fe..7a79a4534 100644 --- a/Where/WhereCore/Sources/WhereServices.swift +++ b/Where/WhereCore/Sources/WhereServices.swift @@ -67,6 +67,8 @@ public struct WhereServices: Sendable { public let currentRegion: CurrentRegionResolver /// Data-quality issue detection for the Resolve tab. public let resolution: DataIssueScanner + /// Exact GPS correction reviews, transactional Apply, and reset tombstones. + public let corrections: SampleCorrectionCoordinator /// The persistence boundary, retained so `dataChangeUpdates()` can hand out /// the store's `changes()` stream — the single read-refresh signal every /// write origin (manual edit, live GPS, remote sync) funnels through. @@ -292,6 +294,13 @@ public struct WhereServices: Sendable { self.plannedStayLocation = plannedStayLocation self.currentRegion = currentRegion self.resolution = resolution + corrections = SampleCorrectionCoordinator( + store: store, + reports: reports, + calendar: aggregator.calendar, + now: now, + onCommitted: { await derivedData.reconcile() }, + ) self.store = store self.attributor = attributor self.aggregator = aggregator diff --git a/Where/WhereCore/Sources/Widgets/WidgetDataReader.swift b/Where/WhereCore/Sources/Widgets/WidgetDataReader.swift index 31de4ccff..1884cd680 100644 --- a/Where/WhereCore/Sources/Widgets/WidgetDataReader.swift +++ b/Where/WhereCore/Sources/Widgets/WidgetDataReader.swift @@ -92,13 +92,12 @@ public struct WidgetDataReader: Sendable { let year = calendarDay.year let interval = aggregator.yearInterval(year: year) let dayRange = CalendarDay.yearRange(year) - let samples = try await history.samples(in: interval) + let projection = try await history.projection(in: interval, attributor: attributor) let manualDays = try await store.manualDays(in: dayRange) let report = aggregator.report( for: year, - samples: samples, + history: projection.samples, manualDays: manualDays, - attributor: attributor, ) let dayRegions = report.days .first { $0.day == calendarDay }? diff --git a/Where/WhereCore/Tests/BackupCoordinatorTests.swift b/Where/WhereCore/Tests/BackupCoordinatorTests.swift index e2018e753..206d68881 100644 --- a/Where/WhereCore/Tests/BackupCoordinatorTests.swift +++ b/Where/WhereCore/Tests/BackupCoordinatorTests.swift @@ -6,6 +6,66 @@ import Testing /// Covers export/import round-trips and the post-commit lifecycle hook the /// coordinator invokes once new data lands. struct BackupCoordinatorTests { + @Test(arguments: [BackupCoordinator.ImportStrategy.merge, .replace]) + func importingPreservesMotionAndCorrectionHistoryWithStrategyAppropriateResetAuthority( + _ strategy: BackupCoordinator.ImportStrategy, + ) async throws { + let source = try Self.makeHarness() + let sample = LocationSample( + timestamp: Date(timeIntervalSince1970: 1000), + coordinate: Coordinate(latitude: 40, longitude: -100), + horizontalAccuracy: 10, + source: .gpsSignificantChange, + motion: LocationMotion( + speed: .init(metersPerSecond: 240, accuracyMetersPerSecond: 2), + altitude: .init(meters: 11000, accuracyMeters: 12), + ), + ) + let replacements: [Set?] = [[], nil, [.newYork]] + let revisions = replacements.enumerated().map { offset, regions in + SampleAttributionRevision( + id: UUID(), + sampleID: sample.id, + updatedAt: Date(timeIntervalSince1970: 1000 + Double(offset)), + replacementRegions: regions, + ) + } + try await source.store.perform { + try await source.store.add(sample: sample) + for revision in revisions { + try await source.store.addSampleAttributionRevision(revision) + } + } + let url = try await source.coordinator.exportBackup() + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + let exported = try BackupService().readArchive(at: url).archive + #expect(exported.samples == [sample]) + #expect(exported.sampleAttributionRevisions == revisions) + + let destination = try Self.makeHarness() + let reset = SampleAttributionRevision( + id: UUID(), + sampleID: sample.id, + updatedAt: Date(timeIntervalSince1970: 2000), + replacementRegions: nil, + ) + try await destination.store.perform { + try await destination.store.addSampleAttributionRevision(reset) + } + _ = try await destination.coordinator.importAndAcknowledgeBackup( + from: url, + strategy: strategy, + ) + #expect(try await destination.store.allSamples() == [sample]) + let restored = try await destination.store.allSampleAttributionRevisions() + switch strategy { + case .merge: + #expect(restored == revisions + [reset]) + case .replace: + #expect(restored == revisions) + } + } + private struct Harness { let coordinator: BackupCoordinator let store: SwiftDataStore @@ -525,6 +585,7 @@ struct BackupCoordinatorTests { recordingDeviceMetadataChanges: [], recordingDeviceRemovals: [], plannedStayRecords: [], + sampleAttributionRevisions: [], blobs: [:], ) defer { try? FileManager.default.removeItem(at: secondURL.deletingLastPathComponent()) } diff --git a/Where/WhereCore/Tests/BackupServiceTests.swift b/Where/WhereCore/Tests/BackupServiceTests.swift index 7e8cddd30..6acfe00f5 100644 --- a/Where/WhereCore/Tests/BackupServiceTests.swift +++ b/Where/WhereCore/Tests/BackupServiceTests.swift @@ -15,6 +15,8 @@ struct BackupServiceTests { let deviceProfilesCount: Int let deviceChangesCount: Int let deviceRemovalsCount: Int + let plannedStayRecordsCount: Int + let sampleAttributionRevisionsCount: Int let assetsCount: Int } @@ -57,6 +59,9 @@ struct BackupServiceTests { #expect(result.archive.recordingDeviceMetadataChanges.count == configuration .deviceChangesCount) #expect(result.archive.recordingDeviceRemovals.count == configuration.deviceRemovalsCount) + #expect(result.archive.plannedStayRecords.count == configuration.plannedStayRecordsCount) + #expect(result.archive.sampleAttributionRevisions.count == configuration + .sampleAttributionRevisionsCount) #expect(result.archive.assets.count == configuration.assetsCount) #expect(result.blobs.count == configuration.assetsCount) } @@ -70,6 +75,10 @@ struct BackupServiceTests { horizontalAccuracy: 5, source: .gpsVisit, recordingDeviceID: recordingDeviceID, + motion: LocationMotion( + speed: .init(metersPerSecond: 240, accuracyMetersPerSecond: 2), + altitude: .init(meters: 11000, accuracyMeters: 12), + ), ), LocationSample( id: UUID(uuidString: "22222222-2222-2222-2222-222222222222")!, @@ -147,10 +156,24 @@ struct BackupServiceTests { recordingDeviceMetadataChanges: [], recordingDeviceRemovals: [], plannedStayRecords: [], + sampleAttributionRevisions: [], assets: [], ) } + private static func attributionFixtures() -> [SampleAttributionRevision] { + let sampleID = sampleFixtures()[0].id + let replacements: [Set?] = [[], [.newYork], nil] + return replacements.enumerated().map { offset, regions in + SampleAttributionRevision( + id: UUID(), + sampleID: sampleID, + updatedAt: exportDate.addingTimeInterval(Double(offset)), + replacementRegions: regions, + ) + } + } + private static func evidenceFixtures() -> [Evidence] { [ Evidence( @@ -205,6 +228,7 @@ struct BackupServiceTests { let dismissedIssues = Self.dismissedIssueFixtures() let recordingDeviceProfiles = Self.recordingDeviceProfileFixtures() let recordingDeviceMetadataChanges = Self.recordingDeviceMetadataFixtures() + let sampleAttributionRevisions = Self.attributionFixtures() let deviceArchive = RecordingDeviceRemoval( id: .init(rawValue: UUID()), deviceID: Self.recordingDeviceID, @@ -221,6 +245,7 @@ struct BackupServiceTests { recordingDeviceMetadataChanges: recordingDeviceMetadataChanges, recordingDeviceRemovals: [deviceArchive], plannedStayRecords: Self.plannedStayFixtures(), + sampleAttributionRevisions: sampleAttributionRevisions, blobs: blobs, exportedAt: Self.exportDate, ) @@ -242,6 +267,7 @@ struct BackupServiceTests { #expect(result.archive.recordingDeviceMetadataChanges == recordingDeviceMetadataChanges) #expect(result.archive.recordingDeviceRemovals == [deviceArchive]) #expect(result.archive.plannedStayRecords == Self.plannedStayFixtures()) + #expect(result.archive.sampleAttributionRevisions == sampleAttributionRevisions) let encodedManifest = try #require(String( data: BackupService.makeEncoder().encode(result.archive), encoding: .utf8, @@ -255,12 +281,12 @@ struct BackupServiceTests { } @Test func unsupportedFormatIsRejectedBeforeItsMissingCurrentFieldsAreDecoded() { - let legacyManifest = Data(#"{"formatVersion":6}"#.utf8) + let legacyManifest = Data(#"{"formatVersion":5}"#.utf8) do { _ = try BackupService.decodeManifest(legacyManifest) Issue.record("Expected the legacy backup format to be rejected.") - } catch BackupService.BackupError.unsupportedFormatVersion(6) { + } catch BackupService.BackupError.unsupportedFormatVersion(5) { // Expected: the version envelope was decoded before the strict current shape. } catch { Issue.record("Unexpected error: \(error)") @@ -308,6 +334,7 @@ struct BackupServiceTests { recordingDeviceMetadataChanges: Self.recordingDeviceMetadataFixtures(), recordingDeviceRemovals: [], plannedStayRecords: [], + sampleAttributionRevisions: [], assets: [], ) var json = try #require(String( @@ -339,6 +366,7 @@ struct BackupServiceTests { recordingDeviceMetadataChanges: [], recordingDeviceRemovals: [], plannedStayRecords: [], + sampleAttributionRevisions: [], blobs: [:], exportedAt: Self.exportDate, ) @@ -371,6 +399,7 @@ struct BackupServiceTests { recordingDeviceMetadataChanges: [], recordingDeviceRemovals: [], plannedStayRecords: [], + sampleAttributionRevisions: [], blobs: [:], exportedAt: Self.exportDate, ) @@ -393,6 +422,7 @@ struct BackupServiceTests { recordingDeviceMetadataChanges: [], recordingDeviceRemovals: [], plannedStayRecords: [], + sampleAttributionRevisions: [], blobs: [:], exportedAt: Self.exportDate, ) @@ -427,6 +457,7 @@ struct BackupServiceTests { recordingDeviceMetadataChanges: [], recordingDeviceRemovals: [], plannedStayRecords: [], + sampleAttributionRevisions: [], blobs: [:], exportedAt: Self.exportDate, ) @@ -465,6 +496,7 @@ struct BackupServiceTests { recordingDeviceMetadataChanges: [], recordingDeviceRemovals: [], plannedStayRecords: [], + sampleAttributionRevisions: [], blobs: [:], exportedAt: Self.exportDate, ) @@ -499,6 +531,7 @@ struct BackupServiceTests { recordingDeviceMetadataChanges: Self.recordingDeviceMetadataFixtures(), recordingDeviceRemovals: [], plannedStayRecords: Self.plannedStayFixtures(), + sampleAttributionRevisions: [], assets: [BackupAssetEntry( evidenceId: Self.evidenceWithBlobId, filename: "assets/\(Self.evidenceWithBlobId.uuidString)", diff --git a/Where/WhereCore/Tests/BorderDriftDetectorTests.swift b/Where/WhereCore/Tests/BorderDriftDetectorTests.swift deleted file mode 100644 index 52a33c672..000000000 --- a/Where/WhereCore/Tests/BorderDriftDetectorTests.swift +++ /dev/null @@ -1,111 +0,0 @@ -import Foundation -import RegionKit -import Testing -import WhereCore - -private typealias Fixtures = DataIssueDetectorFixtures - -struct BorderDriftDetectorTests { - @Test func flagsNearbyOtherDay() { - let otherDay = DayPresence( - date: Fixtures.day(2026, 3, 1), - in: Fixtures.calendar, - regions: [.other], - ) - // Reno — outside CA but close to the border. - let reno = Coordinate(latitude: 39.5296, longitude: -119.8138) - let issues = BorderDriftDetector().detectIssues(in: Fixtures.input( - days: [otherDay], - otherDayCoordinates: [otherDay.day: [reno]], - driftThresholdMeters: 50000, - )) - #expect(issues.count == 1) - #expect(issues[0].nearestRegion == .california) - #expect(issues[0].distanceMeters <= 50000) - // A pure-.other day has no real region left, so it suggests the nearest. - guard case let .relabelDay(_, suggested, _) = issues[0].resolution else { - Issue.record("expected relabelDay resolution") - return - } - #expect(suggested == [.california]) - } - - @Test func flagsMixedDayWithStrayOther() { - // A day that already counts for California but picked up a stray .other. - let mixedDay = DayPresence( - date: Fixtures.day(2026, 3, 1), - in: Fixtures.calendar, - regions: [.california, .other], - ) - let reno = Coordinate(latitude: 39.5296, longitude: -119.8138) - let issues = BorderDriftDetector().detectIssues(in: Fixtures.input( - days: [mixedDay], - otherDayCoordinates: [mixedDay.day: [reno]], - driftThresholdMeters: 50000, - )) - #expect(issues.count == 1) - #expect(issues[0].nearestRegion == .california) - // The fix keeps the real region and drops only the spurious .other. - guard case let .relabelDay(_, suggested, _) = issues[0].resolution else { - Issue.record("expected relabelDay resolution") - return - } - #expect(suggested == [.california]) - } - - @Test func flagsManhattanRiverDriftLikeJune26() { - // Real-world regression: a New York day whose only .other coordinates - // are the two coarse June 26 fixes that drifted west toward the Hudson - // (~852 m / ~952 m outside the NY polygon), checked at the 1 km default. - let day = DayPresence( - date: Fixtures.day(2026, 6, 26), - in: Fixtures.calendar, - regions: [.newYork, .other], - ) - let strayPoints = [ - Coordinate(latitude: 40.80015, longitude: -73.99439), - Coordinate(latitude: 40.81084, longitude: -73.98805), - ] - let issues = BorderDriftDetector().detectIssues(in: Fixtures.input( - days: [day], - otherDayCoordinates: [day.day: strayPoints], - driftThresholdMeters: DriftThreshold.km1.meters, - )) - #expect(issues.count == 1) - #expect(issues[0].nearestRegion == .newYork) - #expect(issues[0].distanceMeters < DriftThreshold.km1.meters) - guard case let .relabelDay(_, suggested, _) = issues[0].resolution else { - Issue.record("expected relabelDay resolution") - return - } - #expect(suggested == [.newYork]) // the spurious .other is dropped - } - - @Test func skipsWhenBeyondThreshold() { - let otherDay = DayPresence( - date: Fixtures.day(2026, 3, 1), - in: Fixtures.calendar, - regions: [.other], - ) - let tokyo = Coordinate(latitude: 35.6762, longitude: 139.6503) - let issues = BorderDriftDetector().detectIssues(in: Fixtures.input( - days: [otherDay], - otherDayCoordinates: [otherDay.day: [tokyo]], - )) - #expect(issues.isEmpty) - } - - @Test func skipsAttributedRegion() { - let caDay = DayPresence( - date: Fixtures.day(2026, 3, 1), - in: Fixtures.calendar, - regions: [.california], - ) - let sf = Coordinate(latitude: 37.7749, longitude: -122.4194) - let issues = BorderDriftDetector().detectIssues(in: Fixtures.input( - days: [caDay], - otherDayCoordinates: [caDay.day: [sf]], - )) - #expect(issues.isEmpty) - } -} diff --git a/Where/WhereCore/Tests/CoreLocationSourceTests.swift b/Where/WhereCore/Tests/CoreLocationSourceTests.swift index 63e1ccb56..23273f685 100644 --- a/Where/WhereCore/Tests/CoreLocationSourceTests.swift +++ b/Where/WhereCore/Tests/CoreLocationSourceTests.swift @@ -1,10 +1,85 @@ +import CoreLocation import Foundation import RegionKit import Testing -@_spi(Testing) import WhereCore +@_spi(Testing) @testable import WhereCore @MainActor struct CoreLocationSourceTests { + @Test func retainsValidSpeedAndAltitudeWithTheirAccuracies() { + let location = Self.location( + speed: 240, + speedAccuracy: 2, + altitude: 11000, + verticalAccuracy: 8, + ) + #expect(CoreLocationSource.motion(from: location) == LocationMotion( + speed: .init(metersPerSecond: 240, accuracyMetersPerSecond: 2), + altitude: .init(meters: 11000, accuracyMeters: 8), + )) + } + + @Test func invalidSpeedDoesNotDiscardValidBelowSeaLevelAltitude() { + let location = Self.location( + speed: -1, + speedAccuracy: -1, + altitude: -30, + verticalAccuracy: 3, + ) + #expect(CoreLocationSource.motion(from: location) == LocationMotion( + speed: nil, + altitude: .init(meters: -30, accuracyMeters: 3), + )) + } + + @Test(arguments: [-1.0, Double.nan, Double.infinity]) + func unavailableOrNonfiniteMeasurementsStayAbsent(_ invalid: Double) { + let location = Self.location( + speed: 220, + speedAccuracy: invalid, + altitude: 10000, + verticalAccuracy: invalid, + ) + #expect(CoreLocationSource.motion(from: location) == nil) + } + + @Test func nonfiniteValuesStayAbsentEvenWithValidAccuracies() { + let location = Self.location( + speed: .infinity, + speedAccuracy: 1, + altitude: .nan, + verticalAccuracy: 1, + ) + #expect(CoreLocationSource.motion(from: location) == nil) + } + + @Test func zeroSpeedAccuracyIsValidButZeroVerticalAccuracyIsUnavailable() { + let location = Self.location(speed: 0, speedAccuracy: 0, altitude: 100, verticalAccuracy: 0) + #expect(CoreLocationSource.motion(from: location) == LocationMotion( + speed: .init(metersPerSecond: 0, accuracyMetersPerSecond: 0), + altitude: nil, + )) + } + + private static func location( + speed: Double, + speedAccuracy: Double, + altitude: Double, + verticalAccuracy: Double, + ) -> CLLocation { + CLLocation( + coordinate: CLLocationCoordinate2D(latitude: 40, longitude: -100), + altitude: altitude, + horizontalAccuracy: 5, + verticalAccuracy: verticalAccuracy, + course: 90, + courseAccuracy: 1, + speed: speed, + speedAccuracy: speedAccuracy, + timestamp: Date(timeIntervalSince1970: 1000), + ) + } + @Test func reducedPrecisionReturnsAnExplicitFailureWithoutRequesting() async { let (source, probe) = configuredSource(hasPreciseLocation: false) diff --git a/Where/WhereCore/Tests/DataIssueDetectorTestSupport.swift b/Where/WhereCore/Tests/DataIssueDetectorTestSupport.swift index 4932066e1..c67e30a16 100644 --- a/Where/WhereCore/Tests/DataIssueDetectorTestSupport.swift +++ b/Where/WhereCore/Tests/DataIssueDetectorTestSupport.swift @@ -3,7 +3,7 @@ import RegionKit import WhereCore /// Shared fixtures for the per-detector test files (`MissingDaysDetectorTests`, -/// `BorderDriftDetectorTests`, `AbruptLocationChangeDetectorTests`, and the +/// `AbruptLocationChangeDetectorTests`, and the /// type-erasure coverage in `DataIssueDetectorTests`), so the `DataIssueInput` /// builder isn't duplicated across them. enum DataIssueDetectorFixtures { diff --git a/Where/WhereCore/Tests/DataIssueScannerTests.swift b/Where/WhereCore/Tests/DataIssueScannerTests.swift index 80342b64e..f4710baa2 100644 --- a/Where/WhereCore/Tests/DataIssueScannerTests.swift +++ b/Where/WhereCore/Tests/DataIssueScannerTests.swift @@ -51,6 +51,393 @@ struct DataIssueScannerTests { ) } + private func makeReviewScanner( + store: SwiftDataStore, + now: @escaping @Sendable () -> Date, + attributor: any RegionAttributing = SampleCorrectionTestSupport.attribution, + detectors: [any DataIssueDetecting] = [], + storeChanges: AsyncStream = AsyncStream { $0.finish() }, + ) -> DataIssueScanner { + DataIssueScanner( + reportReader: ReportReader( + store: store, + aggregator: SampleCorrectionTestSupport.aggregator, + attributor: attributor, + ), + attributor: attributor, + calendar: SampleCorrectionTestSupport.calendar, + now: now, + scanInterval: 3 * 60 * 60, + detectors: detectors, + storeChanges: storeChanges, + ) + } + + @Test func scanPublishesPendingReviewsWithoutActionableGPSIssues() async throws { + let store = try SwiftDataStore.inMemory() + let now = FlightTrajectoryFixtures.date(minutes: 25) + let samples = FlightTrajectoryFixtures.turningFlight().samples + .filter { $0.timestamp <= now } + try await store.perform { + for sample in samples { + try await store.add(sample: sample) + } + } + let scanner = makeReviewScanner(store: store, now: { now }) + let first = try await scanner.scan( + year: 2026, + primaryRegions: [.california, .newYork], + driftThresholdMeters: 1000, + force: false, + ) + let review = try #require(first.reviews.first) + #expect(review.isPending) + #expect(review.proposal == nil) + #expect(review.flight?.progress == .flightLikely) + #expect(first.issues.isEmpty) + #expect(try await scanner.currentIssueCount(year: 2026, driftThresholdMeters: 1000) == 0) + let repeated = try await scanner.scan( + year: 2026, + primaryRegions: [.california, .newYork], + driftThresholdMeters: 1000, + force: false, + ) + // The issue count's independently ranked primary set can change the key; + // the next call with this same key must publish one coherent result. + let cached = try await scanner.scan( + year: 2026, + primaryRegions: [.california, .newYork], + driftThresholdMeters: 1000, + force: false, + ) + #expect(cached.revision == repeated.revision) + #expect(cached.reviews == repeated.reviews) + #expect(cached.issues.map(\.id) == repeated.issues.map(\.id)) + } + + @Test func flightFreshnessDeadlineExpiresTheCacheBeforeTheScanInterval() async throws { + let store = try SwiftDataStore.inMemory() + let clock = MutableClock(FlightTrajectoryFixtures.date(minutes: 25)) + let samples = FlightTrajectoryFixtures.turningFlight().samples + .filter { $0.timestamp <= clock.now } + try await store.perform { + for sample in samples { + try await store.add(sample: sample) + } + } + let scanner = makeReviewScanner(store: store, now: { clock.now }) + let first = try await scanner.scan( + year: 2026, + primaryRegions: [.california, .newYork], + driftThresholdMeters: 1000, + force: false, + ) + #expect(first.nextReassessmentAt == FlightTrajectoryFixtures.date(minutes: 55)) + clock.advance(by: 1799) + let beforeDeadline = try await scanner.scan( + year: 2026, + primaryRegions: [.california, .newYork], + driftThresholdMeters: 1000, + force: false, + ) + #expect(beforeDeadline.revision == first.revision) + clock.advance(by: 1) + let expired = try await scanner.scan( + year: 2026, + primaryRegions: [.california, .newYork], + driftThresholdMeters: 1000, + force: false, + ) + #expect(expired.revision != first.revision) + let review = try #require(expired.reviews.first) + #expect(review.flight?.progress == .awaitingArrival) + #expect(review.isPending) + #expect(review.proposal == nil) + #expect(expired.issues.isEmpty) + } + + @Test func midnightFlightReviewsOwnTheirTransitionBeforeAndAfterArrival() async throws { + let store = try SwiftDataStore.inMemory() + let unrelatedEarlier = CalendarDay(year: 2026, month: 1, day: 1) + let unrelatedLater = CalendarDay(year: 2026, month: 1, day: 2) + let departureDay = CalendarDay(year: 2026, month: 1, day: 4) + let arrivalDay = CalendarDay(year: 2026, month: 1, day: 5) + let unrelatedID = DataIssueID.abruptChange(earlier: unrelatedEarlier, later: unrelatedLater) + let flightTransitionID = DataIssueID.abruptChange(earlier: departureDay, later: arrivalDay) + let clock = MutableClock(FlightTrajectoryFixtures.date(minutes: 5768)) + // Grounded in California just before midnight, then sustained flight + // over Other after midnight. The resulting day sets are disjoint. + let samples = [ + FlightTrajectoryFixtures.sample(1, minutes: 5750, east: 0), + FlightTrajectoryFixtures.sample(2, minutes: 5755, east: 0), + FlightTrajectoryFixtures.sample(3, minutes: 5758, east: 0), + FlightTrajectoryFixtures.sample(4, minutes: 5763, east: 75), + FlightTrajectoryFixtures.sample(5, minutes: 5768, east: 150), + ] + try await store.perform { + try await store.setManualDay(DayPresence(day: unrelatedEarlier, regions: [.newYork])) + try await store.setManualDay(DayPresence(day: unrelatedLater, regions: [.canada])) + for sample in samples { + try await store.add(sample: sample) + } + } + let reader = ReportReader( + store: store, + aggregator: SampleCorrectionTestSupport.aggregator, + attributor: SampleCorrectionTestSupport.attribution, + ) + let report = try await reader.yearReport(for: 2026) + let abruptChanges = AbruptLocationChangeDetector().detectIssues( + in: DataIssueDetectorFixtures.input(days: report.days), + ) + #expect(Set(abruptChanges.map(\.id)) == [unrelatedID, flightTransitionID]) + let scanner = makeReviewScanner( + store: store, + now: { clock.now }, + detectors: [AbruptLocationChangeDetector()], + ) + let pending = try await scanner.scan( + year: 2026, + primaryRegions: [.california, .newYork], + driftThresholdMeters: 1000, + force: false, + ) + #expect(Set(pending.reviews.map(\.day.day)) == [departureDay, arrivalDay]) + let allPending = pending.reviews.allSatisfy(\.isPending) + #expect(allPending) + #expect(pending.reviews.allSatisfy { $0.flight?.progress == .flightLikely }) + #expect(pending.issues.map(\.id) == [unrelatedID]) + + clock.advance(by: 30 * 60) + let stale = try await scanner.scan( + year: 2026, + primaryRegions: [.california, .newYork], + driftThresholdMeters: 1000, + force: false, + ) + let allStillPending = stale.reviews.allSatisfy(\.isPending) + #expect(allStillPending) + #expect(stale.reviews.allSatisfy { $0.flight?.progress == .awaitingArrival }) + #expect(stale.issues.map(\.id) == [unrelatedID]) + + // Late callbacks establish a real ten-minute ground dwell. Arrival + // exposes the precise GPS edit without reviving a whole-day rewrite. + try await store.perform { + try await store.add(sample: FlightTrajectoryFixtures.sample( + 6, + minutes: 5773, + east: 150, + )) + try await store.add(sample: FlightTrajectoryFixtures.sample( + 7, + minutes: 5778, + east: 150, + )) + } + await scanner.invalidate() + let completed = try await scanner.scan( + year: 2026, + primaryRegions: [.california, .newYork], + driftThresholdMeters: 1000, + force: false, + ) + let hasPendingReview = completed.reviews.contains(where: \.isPending) + #expect(!hasPendingReview) + #expect(completed.reviews.allSatisfy { + $0.flight? + .progress == .completed(arrivedAt: FlightTrajectoryFixtures.date(minutes: 5768)) + }) + #expect(Set(completed.issues.map(\.id)) == [unrelatedID, .flightDay(day: arrivalDay)]) + let proposal = try #require(completed.reviews.first { $0.day.day == arrivalDay }?.proposal) + #expect(proposal.edits.map(\.sampleID) == [FlightTrajectoryFixtures.sampleID(4)]) + } + + @Test func driftOnlyReviewDoesNotSuppressAnAbruptTransition() async throws { + let store = try SwiftDataStore.inMemory() + let earlier = CalendarDay(year: 2026, month: 1, day: 1) + let later = CalendarDay(year: 2026, month: 1, day: 2) + let samples = [ + SampleCorrectionAssessmentFixtures.point(1, minutes: 1440, longitude: -0.001), + SampleCorrectionAssessmentFixtures.point(2, minutes: 1445, longitude: 0.001), + SampleCorrectionAssessmentFixtures.point(3, minutes: 1450, longitude: -0.001), + ] + try await store.perform { + try await store.setManualDay(DayPresence(day: earlier, regions: [.newYork])) + for sample in samples { + try await store.add(sample: sample) + } + } + let scanner = makeReviewScanner( + store: store, + now: { FlightTrajectoryFixtures.date(minutes: 1460) }, + attributor: SampleCorrectionAssessmentFixtures.Boundary(), + detectors: [AbruptLocationChangeDetector()], + ) + let result = try await scanner.scan( + year: 2026, + primaryRegions: [.california, .newYork], + driftThresholdMeters: 1000, + force: false, + ) + let review = try #require(result.reviews.first) + #expect(review.flights.isEmpty) + #expect(review.proposal != nil) + #expect(Set(result.issues.map(\.id)) == [ + .abruptChange(earlier: earlier, later: later), + .borderDrift(day: later), + ]) + } + + @Test func primaryRegionChangesInvalidateTheCoherentResultKey() async throws { + let store = try SwiftDataStore.inMemory() + let now = FlightTrajectoryFixtures.date(minutes: 25) + let scanner = makeReviewScanner(store: store, now: { now }) + let first = try await scanner.scan( + year: 2026, + primaryRegions: [.california], + driftThresholdMeters: 1000, + force: false, + ) + let changed = try await scanner.scan( + year: 2026, + primaryRegions: [.newYork], + driftThresholdMeters: 1000, + force: false, + ) + #expect(changed.revision != first.revision) + let repeated = try await scanner.scan( + year: 2026, + primaryRegions: [.newYork], + driftThresholdMeters: 1000, + force: false, + ) + #expect(repeated.revision == changed.revision) + } + + @Test func aRawFixRefreshesReviewsWhenTheAggregateReportIsUnchanged() async throws { + let store = try SwiftDataStore.inMemory() + let now = FlightTrajectoryFixtures.date(minutes: 30) + let trace = FlightTrajectoryFixtures.turningFlight() + let initialSamples = trace.samples.filter { + $0.timestamp <= FlightTrajectoryFixtures.date(minutes: 25) + } + try await store.perform { + for sample in initialSamples { + try await store.add(sample: sample) + } + } + let scanner = makeReviewScanner(store: store, now: { now }, storeChanges: store.changes()) + let reader = ReportReader( + store: store, + aggregator: SampleCorrectionTestSupport.aggregator, + attributor: SampleCorrectionTestSupport.attribution, + ) + let beforeReport = try await reader.yearReport(for: 2026) + let first = try await scanner.scan( + year: 2026, + primaryRegions: [.california, .newYork], + driftThresholdMeters: 1000, + force: false, + ) + let denseFix = try #require(trace.samples.first { $0.id == trace.shortIntervalID }) + try await store.perform { try await store.add(sample: denseFix) } + let afterReport = try await reader.yearReport(for: 2026) + #expect(beforeReport.days == afterReport.days) + #expect(beforeReport.totals == afterReport.totals) + try await waitUntil { + let scan = try await scanner.scan( + year: 2026, + primaryRegions: [.california, .newYork], + driftThresholdMeters: 1000, + force: false, + ) + return scan.revision != first.revision + } + let refreshed = try await scanner.scan( + year: 2026, + primaryRegions: [.california, .newYork], + driftThresholdMeters: 1000, + force: false, + ) + #expect(refreshed.revision != first.revision) + #expect(refreshed.reviews.first?.flight?.lastObservationAt == denseFix.timestamp) + #expect(refreshed.reviews.first?.flight?.lastObservationAt + != first.reviews.first?.flight?.lastObservationAt) + } + + @Test func invalidationDuringASuspendedReadCannotRepublishTheOldScan() async throws { + let store = try SwiftDataStore.inMemory() + let clock = MutableClock(FlightTrajectoryFixtures.date(minutes: 20)) + let trace = FlightTrajectoryFixtures.turningFlight() + let initial = trace.samples.filter { $0.timestamp <= clock.now } + try await store.perform { + for sample in initial { + try await store.add(sample: sample) + } + } + let next = try #require(trace.samples + .first { $0.timestamp == FlightTrajectoryFixtures.date(minutes: 25) }) + let (writerStarted, writerStartedContinuation) = AsyncStream.makeStream(of: Void.self) + let (release, releaseContinuation) = AsyncStream.makeStream(of: Void.self) + let (scanStarted, scanStartedContinuation) = AsyncStream.makeStream(of: Void.self) + defer { + releaseContinuation.yield() + releaseContinuation.finish() + writerStartedContinuation.finish() + scanStartedContinuation.finish() + } + let scanner = makeReviewScanner(store: store, now: { + let captured = clock.now + scanStartedContinuation.yield() + return captured + }) + let writer = Task { + try await store.perform { + try await store.add(sample: next) + writerStartedContinuation.yield() + writerStartedContinuation.finish() + for await _ in release { + break + } + } + } + for await _ in writerStarted { + break + } + let scan = Task { + try await scanner.scan( + year: 2026, + primaryRegions: [.california, .newYork], + driftThresholdMeters: 1000, + force: false, + ) + } + for await _ in scanStarted { + break + } + // The scanner captured 00:20 and then suspended behind the store's + // exclusive writer. Actor ordering places this invalidation after its + // revision capture; the committed fix only becomes visible afterward. + clock.advance(by: 300) + await scanner.invalidate() + releaseContinuation.yield() + releaseContinuation.finish() + try await writer.value + let result = try await scan.value + let review = try #require(result.reviews.first) + #expect(review.isPending) + #expect(review.flight?.lastObservationAt == next.timestamp) + #expect(review.flight?.progress == .flightLikely) + let cached = try await scanner.scan( + year: 2026, + primaryRegions: [.california, .newYork], + driftThresholdMeters: 1000, + force: false, + ) + #expect(cached.revision == result.revision) + #expect(cached.reviews == result.reviews) + } + /// The speed-based `FlightDayDetector` must ignore manual and /// evidence-implied samples: their timestamps are user-asserted, so a speed /// computed across them is meaningless. The exact coast-to-coast pattern diff --git a/Where/WhereCore/Tests/DayAggregatorTests.swift b/Where/WhereCore/Tests/DayAggregatorTests.swift index dc690950f..e4dad381a 100644 --- a/Where/WhereCore/Tests/DayAggregatorTests.swift +++ b/Where/WhereCore/Tests/DayAggregatorTests.swift @@ -6,6 +6,37 @@ import WhereCore struct DayAggregatorTests { let attributor = RegionAttributor.shared + @Test func whollyExcludedObservedDayDoesNotBecomeAMissingBackfill() { + let sample = LocationSample( + timestamp: WhereCoreTestSupport.iso("2026-03-15T12:00:00-07:00"), + coordinate: Coordinate(latitude: 37, longitude: -140), + horizontalAccuracy: 5, + source: .gpsSignificantChange, + ) + let day = CalendarDay(from: sample.timestamp, in: calendar) + let report = aggregator.report( + for: day.year, + history: [AttributedLocationSample(sample: sample, regions: [])], + manualDays: [], + ) + #expect(report.days == [DayPresence(day: day, regions: [])]) + #expect(report.totals.isEmpty) + + let issues = MissingDaysDetector().detectIssues(in: DataIssueInput( + year: day.year, + report: report, + otherDayCoordinates: [:], + daySamples: DaySamples(samples: [sample], calendar: calendar), + primaryRegions: [.california], + attributor: attributor, + driftThresholdMeters: 1000, + calendar: calendar, + now: WhereCoreTestSupport.iso("2026-03-20T12:00:00-07:00"), + )) + #expect(!issues.isEmpty) // Truly unobserved days still require backfill. + #expect(issues.allSatisfy { !($0.range.start ... $0.range.end).contains(day) }) + } + /// All tests pin themselves to America/Los_Angeles so behavior doesn't /// shift with the test runner's clock or default locale. private var aggregator: DayAggregator { diff --git a/Where/WhereCore/Tests/DayJournalTests.swift b/Where/WhereCore/Tests/DayJournalTests.swift index fe35fc0b1..2fc46fb87 100644 --- a/Where/WhereCore/Tests/DayJournalTests.swift +++ b/Where/WhereCore/Tests/DayJournalTests.swift @@ -7,6 +7,60 @@ import Testing /// backfills, clears, evidence) and the reminder reconcile + widget publish /// each one fans out to — the work the controller delegates to `DayJournal`. struct DayJournalTests { + @Test(arguments: [false, true]) + func resetToGPSClearsManualOverlayAndWritesCorrectionTombstones(bulk: Bool) async throws { + let h = try await SampleCorrectionTestSupport.completedFlight() + let proposal = try await h.proposal() + let result = try await h.coordinator.apply(proposal) + guard case .applied = result else { + Issue.record("The completed flight correction must apply") + return + } + let appliedRevisions = try await h.store.allSampleAttributionRevisions() + try await h.services.journal.overrideDay( + date: FlightTrajectoryFixtures.start, + regions: [.canada], + audit: nil, + ) + if bulk { + try await h.services.journal.clearManualDays(dates: [FlightTrajectoryFixtures.start]) + } else { + try await h.services.journal.clearManualDay(date: FlightTrajectoryFixtures.start) + } + + let revisions = try await h.store.allSampleAttributionRevisions() + let resets = revisions.filter { $0.replacementRegions == nil } + let raw = FlightTrajectoryFixtures.turningFlight().samples + #expect(resets.count == raw.count) + #expect(Set(resets.map(\.sampleID)) == Set(raw.map(\.id))) + #expect(try await h.reader.manualDays(inYear: h.day.year).isEmpty) + let originalRegions = Set(raw + .map { SampleCorrectionTestSupport.attribution.region(at: $0.coordinate) }) + #expect(try await h.reader.yearReport(for: h.day.year).days.first? + .regions == originalRegions) + #expect(try await Set(h.store.allSamples()) == Set(raw)) + + // A delayed immutable correction cannot resurrect what Reset to GPS undid. + let first = try #require(appliedRevisions.first) + try await h.store.perform { + try await h.store.addSampleAttributionRevision(.init( + id: UUID(), + sampleID: first.sampleID, + updatedAt: first.updatedAt, + replacementRegions: first.replacementRegions, + )) + } + #expect(try await h.reader.yearReport(for: h.day.year).days.first? + .regions == originalRegions) + let projection = try await LocationHistoryReader(store: h.store).projection( + in: h.interval, + attributor: SampleCorrectionTestSupport.attribution, + ) + #expect(projection.samples.allSatisfy { + $0.regions == [SampleCorrectionTestSupport.attribution.region(at: $0.sample.coordinate)] + }) + } + private struct Harness { let journal: DayJournal let store: SwiftDataStore diff --git a/Where/WhereCore/Tests/DemoDataBuilderTests.swift b/Where/WhereCore/Tests/DemoDataBuilderTests.swift index 79f14d6a2..257cc915a 100644 --- a/Where/WhereCore/Tests/DemoDataBuilderTests.swift +++ b/Where/WhereCore/Tests/DemoDataBuilderTests.swift @@ -207,6 +207,34 @@ struct DemoDataBuilderTests { ) } + @Test func currentDayFlightFixtureIncludesArrivalBeforeDemoNow() async throws { + let requested = WhereCoreTestSupport.iso("2026-01-01T09:30:00Z") + let configuration = DemoDataBuilder.Configuration(issueCategories: [.flightDay]) + let referenceDate = configuration.referenceDate(from: requested, calendar: calendar) + let services = try makeServices(now: referenceDate) + try await DemoDataBuilder( + now: referenceDate, + calendar: calendar, + configuration: configuration, + ).seed(into: services) + + let scan = try await services.resolution.scan( + year: 2026, + primaryRegions: [.newYork, .california], + driftThresholdMeters: DriftThreshold.default.meters, + force: true, + ) + let review = try #require(scan.reviews.first { $0.proposal != nil }) + let flight = try #require(review.flight) + guard case .completed = flight.progress else { + Issue.record("Expected supported arrival in current-day demo fixture") + return + } + #expect(flight.lastObservationAt <= referenceDate) + #expect(flight.id.recordingDeviceID == services.recording.currentDevice.id) + #expect(review.proposal?.resultingRegions == [.newYork, .california]) + } + /// The shape has to survive being entered at any point in the year, which /// is the bug this pins: with fixed-size trips and gaps, a January demo was /// more than half unlogged and a February one counted more California days diff --git a/Where/WhereCore/Tests/FlightAssessmentTests.swift b/Where/WhereCore/Tests/FlightAssessmentTests.swift new file mode 100644 index 000000000..8a5407dc3 --- /dev/null +++ b/Where/WhereCore/Tests/FlightAssessmentTests.swift @@ -0,0 +1,24 @@ +import Foundation +import Testing +import WhereCore + +struct FlightAssessmentTests { + @Test func onlyFreshFlightHasATimeBasedReassessment() { + let lastFlight = FlightTrajectoryFixtures.start + func assessment(_ progress: FlightAssessment.Progress) -> FlightAssessment { + FlightAssessment( + id: .init(recordingDeviceID: nil, departureSampleID: UUID()), + startedAt: lastFlight, + lastObservationAt: lastFlight.addingTimeInterval(600), + lastFlightAt: lastFlight, + airborneSampleIDs: [], + groundSampleIDs: [], + peakSpeedKMH: 900, + progress: progress, + ) + } + #expect(assessment(.flightLikely).nextReassessmentAt == lastFlight.addingTimeInterval(1800)) + #expect(assessment(.awaitingArrival).nextReassessmentAt == nil) + #expect(assessment(.completed(arrivedAt: lastFlight)).nextReassessmentAt == nil) + } +} diff --git a/Where/WhereCore/Tests/FlightDayDetectorTests.swift b/Where/WhereCore/Tests/FlightDayDetectorTests.swift deleted file mode 100644 index fbfd45e24..000000000 --- a/Where/WhereCore/Tests/FlightDayDetectorTests.swift +++ /dev/null @@ -1,197 +0,0 @@ -import Foundation -import RegionKit -import Testing -import WhereCore - -private typealias Fixtures = DataIssueDetectorFixtures - -struct FlightDayDetectorTests { - // Real coordinates the shared attributor resolves as expected. - private static let jfk = Coordinate(latitude: 40.6413, longitude: -73.7781) // New York - private static let sfo = Coordinate(latitude: 37.6213, longitude: -122.3790) // California - private static let illinois = Coordinate(latitude: 40.29, longitude: -90.39) // .other - private static let colorado = Coordinate(latitude: 39.53, longitude: -106.16) // .other - private static let nevada = Coordinate(latitude: 38.68, longitude: -116.90) // .other - - /// The July 14 NYC→SF profile: two grounded NY fixes in the morning, a run - /// of cruise-speed legs across untracked states, then grounded SFO fixes. - @Test func flagsCoastToCoastFlight() { - let date = Fixtures.calendarDay(2026, 7, 14) - let day = DayPresence(day: date, regions: [.newYork, .other, .california]) - let samples = [ - Fixtures.gpsSample(day: date, hoursAfterStart: 8.0, Self.jfk), - Fixtures.gpsSample(day: date, hoursAfterStart: 8.5, Self.jfk), - Fixtures.gpsSample(day: date, hoursAfterStart: 12.0, Self.jfk), - Fixtures.gpsSample(day: date, hoursAfterStart: 13.5, Self.illinois), - Fixtures.gpsSample(day: date, hoursAfterStart: 15.0, Self.colorado), - Fixtures.gpsSample(day: date, hoursAfterStart: 16.5, Self.nevada), - Fixtures.gpsSample(day: date, hoursAfterStart: 17.5, Self.sfo), - Fixtures.gpsSample(day: date, hoursAfterStart: 18.0, Self.sfo), - ] - let issues = FlightDayDetector().detectIssues(in: Fixtures.input( - days: [day], - daySamples: [date: samples], - )) - - #expect(issues.count == 1) - #expect(issues[0].keepRegions == [.newYork, .california]) - #expect(issues[0].removedRegions == [.other]) - #expect(issues[0].peakSpeedKMH > 300) - guard case let .correctFlightDay(d, keep, removed, peak) = issues[0].resolution else { - Issue.record("expected correctFlightDay resolution") - return - } - #expect(d == day) - #expect(keep == [.newYork, .california]) - #expect(removed == [.other]) - #expect(peak > 300) - } - - /// Real-world regression (the July 14 export that didn't flag): CoreLocation - /// emits a Visit *and* a significant-change fix at the same instant, so a - /// mid-cruise waypoint has an exact-timestamp duplicate. The zero-duration - /// leg between them must not make that cruise fix read as "grounded" — the - /// day should still flag with `.other` removed. - @Test func flagsFlightWithDuplicateMidCruiseFixes() { - let date = Fixtures.calendarDay(2026, 7, 14) - let day = DayPresence(day: date, regions: [.newYork, .other, .california]) - let samples = [ - Fixtures.gpsSample(day: date, hoursAfterStart: 8.0, Self.jfk), - Fixtures.gpsSample(day: date, hoursAfterStart: 8.5, Self.jfk), - Fixtures.gpsSample(day: date, hoursAfterStart: 12.0, Self.jfk), - Fixtures.gpsSample(day: date, hoursAfterStart: 13.5, Self.illinois), - // A Visit + significant-change at the same instant, mid-cruise. - Fixtures.gpsSample(day: date, hoursAfterStart: 15.0, Self.colorado, source: .gpsVisit), - Fixtures.gpsSample(day: date, hoursAfterStart: 15.0, Self.colorado), - Fixtures.gpsSample(day: date, hoursAfterStart: 16.5, Self.nevada, source: .gpsVisit), - Fixtures.gpsSample(day: date, hoursAfterStart: 16.5, Self.nevada), - Fixtures.gpsSample(day: date, hoursAfterStart: 17.5, Self.sfo), - Fixtures.gpsSample(day: date, hoursAfterStart: 18.0, Self.sfo), - ] - let issues = FlightDayDetector().detectIssues(in: Fixtures.input( - days: [day], - daySamples: [date: samples], - )) - #expect(issues.count == 1) - #expect(issues[0].keepRegions == [.newYork, .california]) - #expect(issues[0].removedRegions == [.other]) - } - - /// Real-world regression (the July 14 export that didn't flag): at cruise, - /// significant-change fires every ~5 min, so each leg is only ~70 km even - /// though it's minutes long. Those legs must still count as flight (a - /// distance floor would wrongly drop them and leave the fly-over region - /// looking grounded), so a densely-sampled cruise still flags. - @Test func flagsDenselySampledCruise() { - let date = Fixtures.calendarDay(2026, 7, 14) - let day = DayPresence(day: date, regions: [.newYork, .other, .california]) - // Untracked mid-continent waypoints ~70 km / 6 min apart (~700 km/h). - let a = Coordinate(latitude: 38.5, longitude: -100.0) - let b = Coordinate(latitude: 38.5, longitude: -100.8) - let c = Coordinate(latitude: 38.5, longitude: -101.6) - let d = Coordinate(latitude: 38.5, longitude: -102.4) - let samples = [ - Fixtures.gpsSample(day: date, hoursAfterStart: 8.0, Self.jfk), - Fixtures.gpsSample(day: date, hoursAfterStart: 8.5, Self.jfk), - Fixtures.gpsSample(day: date, hoursAfterStart: 9.0, a), - Fixtures.gpsSample(day: date, hoursAfterStart: 9.1, b), - Fixtures.gpsSample(day: date, hoursAfterStart: 9.2, c), - Fixtures.gpsSample(day: date, hoursAfterStart: 9.3, d), - Fixtures.gpsSample(day: date, hoursAfterStart: 10.0, Self.sfo), - Fixtures.gpsSample(day: date, hoursAfterStart: 10.5, Self.sfo), - ] - let issues = FlightDayDetector().detectIssues(in: Fixtures.input( - days: [day], - daySamples: [date: samples], - )) - #expect(issues.count == 1) - #expect(issues[0].keepRegions == [.newYork, .california]) - #expect(issues[0].removedRegions == [.other]) - } - - /// A flight with a genuine layover in an untracked region: even with two - /// fly-over `.other` fixes, a real `.other` stop (dwell fixes in Chicago) - /// keeps `.other` grounded, so nothing is removed and the day isn't flagged. - /// The detector must not strip a region the user actually stopped in. - @Test func keepsRegionWithALegitimateLayover() { - let date = Fixtures.calendarDay(2026, 7, 14) - let chicago = Coordinate(latitude: 41.8781, longitude: -87.6298) // .other, with dwell - let day = DayPresence(day: date, regions: [.newYork, .other, .california]) - let samples = [ - Fixtures.gpsSample(day: date, hoursAfterStart: 8.0, Self.jfk), - Fixtures.gpsSample(day: date, hoursAfterStart: 9.0, Self.jfk), - Fixtures.gpsSample(day: date, hoursAfterStart: 11.0, Self.colorado), // fly-over - Fixtures.gpsSample(day: date, hoursAfterStart: 12.5, Self.nevada), // fly-over - Fixtures.gpsSample(day: date, hoursAfterStart: 14.0, chicago), // land + dwell - Fixtures.gpsSample(day: date, hoursAfterStart: 15.0, chicago), - Fixtures.gpsSample(day: date, hoursAfterStart: 16.0, chicago), - Fixtures.gpsSample(day: date, hoursAfterStart: 19.0, Self.sfo), - ] - let issues = FlightDayDetector().detectIssues(in: Fixtures.input( - days: [day], - daySamples: [date: samples], - )) - #expect(issues.isEmpty) - } - - /// A fast road trip that dips into `.other` but never reaches cruise speed: - /// no leg clears the threshold, so nothing is a fly-over point. - @Test func ignoresFastDrivingBelowThreshold() { - let date = Fixtures.calendarDay(2026, 7, 14) - let day = DayPresence(day: date, regions: [.newYork, .other]) - let newYorkCity = Coordinate(latitude: 40.7128, longitude: -74.0060) - let newJersey = Coordinate(latitude: 40.05, longitude: -74.60) // .other, ~80 km away - let samples = [ - Fixtures.gpsSample(day: date, hoursAfterStart: 9.0, newYorkCity), - Fixtures.gpsSample(day: date, hoursAfterStart: 10.0, newJersey), - Fixtures.gpsSample(day: date, hoursAfterStart: 11.0, newYorkCity), - ] - let issues = FlightDayDetector().detectIssues(in: Fixtures.input( - days: [day], - daySamples: [date: samples], - )) - #expect(issues.isEmpty) - } - - /// A single teleport glitch — one bad fix that jumps far away and straight - /// back — is a different data issue, not a plane, so it isn't flagged. - @Test func ignoresLoneTeleportGlitch() { - let date = Fixtures.calendarDay(2026, 7, 14) - let day = DayPresence(day: date, regions: [.newYork, .other]) - let samples = [ - Fixtures.gpsSample(day: date, hoursAfterStart: 9.0, Self.jfk), - Fixtures.gpsSample(day: date, hoursAfterStart: 9.05, Self.colorado), - Fixtures.gpsSample(day: date, hoursAfterStart: 9.1, Self.jfk), - ] - let issues = FlightDayDetector().detectIssues(in: Fixtures.input( - days: [day], - daySamples: [date: samples], - )) - #expect(issues.isEmpty) - } - - /// A day spent entirely mid-cruise (no grounded endpoint on this calendar - /// day) leaves the crossed region grounded at its first/last fix, so there - /// is nothing spurious to remove — the day is left alone, not blanked. - @Test func skipsMidFlightOnlyDay() { - let date = Fixtures.calendarDay(2026, 7, 14) - let day = DayPresence(day: date, regions: [.other]) - let samples = [ - Fixtures.gpsSample(day: date, hoursAfterStart: 0.5, Self.illinois), - Fixtures.gpsSample(day: date, hoursAfterStart: 2.0, Self.colorado), - Fixtures.gpsSample(day: date, hoursAfterStart: 3.5, Self.nevada), - ] - let issues = FlightDayDetector().detectIssues(in: Fixtures.input( - days: [day], - daySamples: [date: samples], - )) - #expect(issues.isEmpty) - } - - @Test func skipsDayWithNoSamples() { - let date = Fixtures.calendarDay(2026, 7, 14) - let day = DayPresence(day: date, regions: [.newYork, .other, .california]) - let issues = FlightDayDetector().detectIssues(in: Fixtures.input(days: [day])) - #expect(issues.isEmpty) - } -} diff --git a/Where/WhereCore/Tests/FlightTrajectoryAnalyzerTests.swift b/Where/WhereCore/Tests/FlightTrajectoryAnalyzerTests.swift new file mode 100644 index 000000000..1285c5389 --- /dev/null +++ b/Where/WhereCore/Tests/FlightTrajectoryAnalyzerTests.swift @@ -0,0 +1,493 @@ +import Foundation +import Testing +import WhereCore + +private typealias Fixtures = FlightTrajectoryFixtures + +struct FlightTrajectoryAnalyzerTests { + private let analyzer = FlightTrajectoryAnalyzer() + + @Test(.disabled( + if: ProcessInfo.processInfo.environment["WHERE_FLIGHT_VERIFICATION_CONFIG"] == nil, + "Supply a local flight-verification configuration to replay an external backup.", + )) + func productionArchiveReplayOnRequest() throws { + let path = try #require(ProcessInfo.processInfo + .environment["WHERE_FLIGHT_VERIFICATION_CONFIG"]) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .secondsSince1970 + let configuration = try decoder.decode( + FlightArchiveVerificationConfiguration.self, + from: Data(contentsOf: URL(fileURLWithPath: path)), + ) + let archive = try BackupService().readArchive( + at: URL(fileURLWithPath: configuration.backupPath), + ).archive + let samples = RecordingDeviceRemovalFilter.visibleSamples( + archive.samples, + removals: archive.recordingDeviceRemovals, + ).filter { $0.timestamp >= configuration.from && $0.timestamp < configuration.until } + #expect(samples.count == configuration.expectedInputSampleCount) + let assessments = analyzer.analyze(samples: samples, now: configuration.now) + try #require(assessments.count == 1) + let flight = assessments[0] + #expect(flight.progress == .completed(arrivedAt: configuration.expectedArrivalAt)) + #expect(flight.lastObservationAt == configuration.expectedArrivalConfirmedAt) + #expect(configuration.expectedAirborneSampleIDs.isSubset(of: flight.airborneSampleIDs)) + #expect(configuration.expectedPreservedSampleIDs.isDisjoint(with: flight.airborneSampleIDs)) + let cruise = try #require(analyzer.analyze( + samples: samples, + now: configuration.cruiseCheckAt, + ).first) + #expect(cruise.progress == .flightLikely) + let awaiting = try #require(analyzer.analyze( + samples: samples, + now: configuration.awaitingArrivalCheckAt, + ).first) + #expect(awaiting.progress == .awaitingArrival) + } + + @Test func recognizesTheSyntheticCruiseAndTurningApproachAfterDwell() throws { + let trace = Fixtures.turningFlight() + let assessments = analyzer.analyze(samples: trace.samples, now: trace.readyAt) + let flight = try #require(assessments.first) + #expect(assessments.count == 1) + #expect(flight.progress == .completed(arrivedAt: trace.firstGroundAt)) + #expect(flight.peakSpeedKMH > 850) + #expect(flight.airborneSampleIDs.contains(trace.shortIntervalID)) + #expect(flight.airborneSampleIDs.contains(Fixtures.sampleID(13))) + #expect(flight.airborneSampleIDs.contains(Fixtures.sampleID(14))) + #expect(flight.airborneSampleIDs.contains(trace.departureNoiseID) == false) + #expect(flight.airborneSampleIDs.contains(Fixtures.sampleID(15)) == false) + #expect(flight.groundSampleIDs.contains(Fixtures.sampleID(15))) + #expect(flight.groundSampleIDs.contains(Fixtures.sampleID(17))) + #expect(flight.airborneSampleIDs.isDisjoint(with: flight.groundSampleIDs)) + } + + @Test func prefixesRequireCruiseEvidenceAndThenObservedArrival() throws { + let trace = Fixtures.turningFlight() + #expect(analyzer.analyze(samples: trace.samples, now: Fixtures.date(minutes: 20)).isEmpty) + let cruise = try #require(analyzer.analyze( + samples: trace.samples, + now: Fixtures.date(minutes: 25), + ).first) + #expect(cruise.progress == .flightLikely) + let approach = try #require(analyzer.analyze( + samples: trace.samples, + now: Fixtures.date(minutes: 125), + ).first) + #expect(approach.progress == .awaitingArrival) + let taxi = try #require(analyzer.analyze( + samples: trace.samples, + now: Fixtures.date(minutes: 135), + ).first) + #expect(taxi.progress == .awaitingArrival) + #expect(taxi.groundSampleIDs.contains(Fixtures.sampleID(15)) == false) + #expect(cruise.id == taxi.id) + } + + @Test func silenceChangesFreshnessWithoutInventingArrival() throws { + let trace = Fixtures.turningFlight() + let prefix = trace.samples.filter { $0.timestamp <= trace.lastCruiseAt } + let justBefore = try #require(analyzer.analyze( + samples: prefix, + now: trace.lastCruiseAt.addingTimeInterval(1799), + ).first) + #expect(justBefore.progress == .flightLikely) + let stale = try #require(analyzer.analyze( + samples: prefix, + now: trace.lastCruiseAt.addingTimeInterval(1800), + ).first) + #expect(stale.progress == .awaitingArrival) + #expect(stale.nextReassessmentAt == nil) + let muchLater = try #require(analyzer.analyze( + samples: prefix, + now: trace.lastCruiseAt.addingTimeInterval(86400), + ).first) + #expect(muchLater.progress == .awaitingArrival) + #expect(muchLater.id == stale.id) + } + + @Test func duplicateCallbacksKeepEveryReviewedSampleIdentity() throws { + let trace = Fixtures.turningFlight() + let duplicate = Fixtures.sample(99, minutes: 25, east: 120, source: .gpsVisit) + let flight = try #require(analyzer.analyze( + samples: trace.samples + [duplicate], + now: trace.readyAt, + ).first) + #expect(flight.airborneSampleIDs.contains(duplicate.id)) + #expect(flight.airborneSampleIDs.contains(Fixtures.sampleID(7))) + #expect(flight.airborneSampleIDs.contains(trace.shortIntervalID)) + } + + @Test func nearSimultaneousDepartureDuplicatesRemainEndpoints() throws { + let trace = Fixtures.turningFlight() + let duplicate = Fixtures.sample(99, minutes: 15 + 0.5 / 60, east: 5, source: .gpsVisit) + let flight = try #require(analyzer.analyze( + samples: trace.samples + [duplicate], + now: trace.readyAt, + ).first) + #expect(flight.airborneSampleIDs.contains(duplicate.id) == false) + #expect(flight.airborneSampleIDs.contains(Fixtures.sampleID(5)) == false) + } + + @Test func denseCruiseCallbacksDoNotPrematurelyReportAwaitingArrival() throws { + let trace = Fixtures.turningFlight() + let flight = try #require(analyzer.analyze( + samples: trace.samples, + now: Fixtures.date(minutes: 25 + 5.0 / 60), + ).first) + #expect(flight.progress == .flightLikely) + #expect(flight.lastFlightAt == Fixtures.date(minutes: 25 + 5.0 / 60)) + } + + @Test func aContradictoryTrailingFixDoesNotExtendFreshCruiseEvidence() throws { + let trace = Fixtures.turningFlight() + let prefix = trace.samples.filter { $0.timestamp <= Fixtures.date(minutes: 25) } + // Plausible against the older five-minute baseline, but impossible + // against the preceding anchor only five seconds before this fix. + let teleport = Fixtures.sample(99, minutes: 25 + 5.0 / 60, east: 150) + let flight = try #require(analyzer.analyze( + samples: prefix + [teleport], + now: teleport.timestamp, + ).first) + #expect(flight.progress == .awaitingArrival) + #expect(flight.lastFlightAt == Fixtures.date(minutes: 25)) + #expect(flight.airborneSampleIDs.contains(teleport.id) == false) + } + + @Test(arguments: [15, 30, 60, 300]) + func samplingDensityDoesNotTurnCruiseIntoGround(cadenceSeconds: Int) throws { + var samples = [ + Fixtures.sample(1, minutes: 0, east: 0), + Fixtures.sample(2, minutes: 5, east: 0), + Fixtures.sample(3, minutes: 10, east: 0), + ] + for seconds in stride(from: 0, through: 1200, by: cadenceSeconds) { + samples.append(Fixtures.sample( + 100 + seconds, + minutes: 15 + Double(seconds) / 60, + east: Double(seconds) * 0.25, + )) + } + samples += [ + Fixtures.sample(2001, minutes: 40, east: 300), + Fixtures.sample(2002, minutes: 45, east: 300), + ] + let flight = try #require(analyzer.analyze( + samples: samples, + now: Fixtures.date(minutes: 45), + ).first) + #expect(flight.startedAt == Fixtures.date(minutes: 15)) + #expect(flight.progress == .completed(arrivedAt: Fixtures.date(minutes: 35))) + #expect(flight.airborneSampleIDs.isEmpty == false) + } + + @Test func separateDevicesAndLegacyHistoryCannotManufactureMovement() { + let second = RecordingDeviceID(rawValue: Fixtures.sampleID(9001)) + var samples: [LocationSample] = [] + for index in 0 ..< 6 { + samples.append(Fixtures.sample(index + 1, minutes: Double(index) * 5, east: 0)) + samples.append(Fixtures.sample( + index + 101, + minutes: Double(index) * 5 + 1, + east: 40, + deviceID: second, + )) + samples.append(Fixtures.sample( + index + 201, + minutes: Double(index) * 5 + 2, + east: 80, + deviceID: nil, + )) + } + #expect(analyzer.analyze(samples: samples, now: Fixtures.date(minutes: 60)).isEmpty) + } + + @Test func anotherDeviceCannotLandTheFlyingDevice() throws { + let trace = Fixtures.turningFlight() + let prefix = trace.samples.filter { $0.timestamp <= trace.lastCruiseAt } + let second = RecordingDeviceID(rawValue: Fixtures.sampleID(9001)) + let ground = (0 ..< 4).map { index in + Fixtures.sample( + index + 101, + minutes: 130 + Double(index) * 5, + east: 1470, + deviceID: second, + ) + } + let flight = try #require(analyzer.analyze( + samples: prefix + ground, + now: Fixtures.date(minutes: 145), + ).first) + #expect(flight.id.recordingDeviceID == Fixtures.device) + #expect(flight.progress == .awaitingArrival) + #expect(flight.groundSampleIDs.isDisjoint(with: Set(ground.map(\.id)))) + } + + @Test func poorAndConflictingShortIntervalFixesStayUncorrected() throws { + let trace = Fixtures.turningFlight() + let poor = Fixtures.sample(101, minutes: 26, east: 150, accuracy: 2000) + let conflicting = Fixtures.sample(102, minutes: 25 + 0.5 / 60, east: 300) + let flight = try #require(analyzer.analyze( + samples: trace.samples + [poor, conflicting], + now: trace.readyAt, + ).first) + #expect(flight.airborneSampleIDs.contains(poor.id) == false) + #expect(flight.airborneSampleIDs.contains(conflicting.id) == false) + #expect(flight.progress == .completed(arrivedAt: trace.firstGroundAt)) + } + + @Test func teleportReversalsAndImpossibleSpeedsDoNotConfirmAFlight() { + let reversal = [ + Fixtures.sample(1, minutes: 0, east: 0), + Fixtures.sample(2, minutes: 5, east: 75), + Fixtures.sample(3, minutes: 10, east: 0), + ] + let impossible = [ + Fixtures.sample(1, minutes: 0, east: 0), + Fixtures.sample(2, minutes: 5, east: 200), + Fixtures.sample(3, minutes: 10, east: 400), + ] + #expect(analyzer.analyze(samples: reversal, now: Fixtures.date(minutes: 20)).isEmpty) + #expect(analyzer.analyze(samples: impossible, now: Fixtures.date(minutes: 20)).isEmpty) + } + + @Test func motionBelowTheJetThresholdRemainsUncertain() { + let samples = [ + Fixtures.sample(1, minutes: 0, east: 0), + Fixtures.sample(2, minutes: 5, east: 30), + Fixtures.sample(3, minutes: 10, east: 60), + Fixtures.sample(4, minutes: 15, east: 90), + ] + #expect(analyzer.analyze(samples: samples, now: Fixtures.date(minutes: 20)).isEmpty) + } + + @Test func motionSensorsCanVetoDwellButAltitudeDoesNotCreateFlight() throws { + let trace = Fixtures.turningFlight() + let flyingSpeed = LocationMotion( + speed: .init(metersPerSecond: 200, accuracyMetersPerSecond: 2), + altitude: .init(meters: 9000, accuracyMeters: 10), + ) + let contradicted = trace.samples.map { sample in + sample.timestamp >= trace.firstGroundAt + ? Fixtures.replacing(sample, motion: flyingSpeed) : sample + } + let flight = try #require(analyzer.analyze( + samples: contradicted, + now: Fixtures.date(minutes: 170), + ).first) + #expect(flight.progress == .awaitingArrival) + let highGround = (0 ..< 4).map { index in + Fixtures.sample( + index + 101, + minutes: Double(index) * 5, + east: 0, + motion: .init(speed: nil, altitude: .init(meters: 5000, accuracyMeters: 10)), + ) + } + #expect(analyzer.analyze(samples: highGround, now: Fixtures.date(minutes: 20)).isEmpty) + } + + @Test func denseContradictoryObservationsPreventArrivalConfirmation() throws { + let trace = Fixtures.turningFlight() + let highSpeed = Fixtures.sample( + 101, + minutes: 135 + 20.0 / 60, + east: 1445, + north: 8, + motion: .init( + speed: .init(metersPerSecond: 200, accuracyMetersPerSecond: 1), + altitude: nil, + ), + ) + let teleport = Fixtures.sample(102, minutes: 135 + 30.0 / 60, east: 1800) + for contradiction in [highSpeed, teleport] { + let flight = try #require(analyzer.analyze( + samples: trace.samples + [contradiction], + now: trace.readyAt, + ).first) + #expect(flight.progress == .awaitingArrival) + #expect(flight.groundSampleIDs.contains(contradiction.id) == false) + #expect(flight.airborneSampleIDs.contains(contradiction.id) == false) + } + } + + @Test func midnightAndInputOrderingDoNotSplitAFlight() throws { + let trace = Fixtures.turningFlight() + let offset: TimeInterval = 23 * 3600 + let shifted = trace.samples.map { + Fixtures.replacing($0, timestamp: $0.timestamp.addingTimeInterval(offset)) + } + let expected = analyzer.analyze( + samples: shifted, + now: trace.readyAt.addingTimeInterval(offset), + ) + let reversed = analyzer.analyze( + samples: Array(shifted.reversed()), + now: trace.readyAt.addingTimeInterval(offset), + ) + let flight = try #require(expected.first) + #expect(expected.count == 1) + #expect(flight + .progress == .completed(arrivedAt: trace.firstGroundAt.addingTimeInterval(offset))) + #expect(reversed == expected) + } + + @Test func completedObservationDoesNotFollowLaterUnrelatedLocations() throws { + let trace = Fixtures.turningFlight() + let later = Fixtures.sample(999, minutes: 24 * 60, east: 1500) + let flight = try #require(analyzer.analyze( + samples: trace.samples + [later], + now: later.timestamp, + ).first) + #expect(flight.progress == .completed(arrivedAt: trace.firstGroundAt)) + #expect(flight.lastObservationAt == trace.readyAt) + } + + @Test func aRealLayoverSeparatesFlightsAndProtectsGroundSamples() throws { + let samples = [ + Fixtures.sample(1, minutes: 0, east: 0), + Fixtures.sample(2, minutes: 5, east: 75), + Fixtures.sample(3, minutes: 10, east: 150), + Fixtures.sample(4, minutes: 15, east: 150), + Fixtures.sample(5, minutes: 20, east: 150), + Fixtures.sample(6, minutes: 25, east: 150), + Fixtures.sample(7, minutes: 30, east: 225), + Fixtures.sample(8, minutes: 35, east: 300), + Fixtures.sample(9, minutes: 40, east: 300), + Fixtures.sample(10, minutes: 45, east: 300), + ] + let flights = analyzer.analyze(samples: samples, now: Fixtures.date(minutes: 45)) + try #require(flights.count == 2) + #expect(flights[0].progress == .completed(arrivedAt: Fixtures.date(minutes: 10))) + #expect(flights[1].progress == .completed(arrivedAt: Fixtures.date(minutes: 35))) + let layoverIDs = Set(samples[2 ... 5].map(\.id)) + #expect(flights.allSatisfy { $0.airborneSampleIDs.isDisjoint(with: layoverIDs) }) + #expect(flights[0].groundSampleIDs.contains(Fixtures.sampleID(4))) + } + + @Test func aSparseLayoverPreservesUnknownEndpointsBetweenCruiseSegments() throws { + let trace = Fixtures.sparseLayover() + let flights = analyzer.analyze(samples: trace.samples, now: trace.readyAt) + let flight = try #require(flights.first) + #expect(flights.count == 1) + #expect(flight.progress == .completed(arrivedAt: trace.firstGroundAt)) + #expect(flight.airborneSampleIDs == trace.airborneSampleIDs) + #expect(flight.airborneSampleIDs.isDisjoint(with: trace.layoverSampleIDs)) + #expect(flight.groundSampleIDs.isDisjoint(with: trace.layoverSampleIDs)) + #expect(analyzer.analyze(samples: Array(trace.samples.reversed()), now: trace.readyAt) + == flights) + } + + @Test(arguments: [-0.5, 0, 0.5]) + func sparseLayoverEndpointCallbacksRemainUnknown(offsetSeconds: Double) throws { + let trace = Fixtures.sparseLayover() + let duplicates = [10.0, 30.0].enumerated().map { index, minutes in + Fixtures.sample( + 101 + index, + minutes: minutes + offsetSeconds / 60, + east: 150, + source: .gpsVisit, + ) + } + let samples = trace.samples + duplicates + let flights = analyzer.analyze(samples: samples, now: trace.readyAt) + let flight = try #require(flights.first) + #expect(flights.count == 1) + #expect(flight.progress == .completed(arrivedAt: trace.firstGroundAt)) + #expect(flight.airborneSampleIDs == trace.airborneSampleIDs) + let unknownIDs = trace.layoverSampleIDs.union(duplicates.map(\.id)) + #expect(flight.airborneSampleIDs.isDisjoint(with: unknownIDs)) + #expect(flight.groundSampleIDs.isDisjoint(with: unknownIDs)) + #expect(analyzer.analyze(samples: Array(samples.reversed()), now: trace.readyAt) == flights) + } + + @Test func layoverConfirmationCanShareTheNextDepartureAnchor() throws { + let samples = [ + Fixtures.sample(1, minutes: 0, east: 0), + Fixtures.sample(2, minutes: 5, east: 75), + Fixtures.sample(3, minutes: 10, east: 150), + Fixtures.sample(4, minutes: 15, east: 150), + // This third ground anchor confirms arrival and starts the next + // flight's first cruise leg. Both flights must retain it as ground. + Fixtures.sample(5, minutes: 20, east: 150), + Fixtures.sample(6, minutes: 25, east: 225), + Fixtures.sample(7, minutes: 30, east: 300), + Fixtures.sample(8, minutes: 35, east: 300), + Fixtures.sample(9, minutes: 40, east: 300), + ] + let flights = analyzer.analyze(samples: samples, now: Fixtures.date(minutes: 40)) + try #require(flights.count == 2) + #expect(flights[0].progress == .completed(arrivedAt: Fixtures.date(minutes: 10))) + #expect(flights[0].lastObservationAt == Fixtures.date(minutes: 20)) + #expect(flights[1].progress == .completed(arrivedAt: Fixtures.date(minutes: 30))) + #expect(flights[1].id.departureSampleID == Fixtures.sampleID(5)) + let layoverIDs = Set(samples[2 ... 4].map(\.id)) + #expect(flights.allSatisfy { $0.airborneSampleIDs.isDisjoint(with: layoverIDs) }) + #expect(flights.allSatisfy { layoverIDs.isSubset(of: $0.groundSampleIDs) }) + } + + @Test func aLongUnknownGapCannotBecomeAMotionBaseline() { + let samples = [ + Fixtures.sample(1, minutes: 0, east: 0), + Fixtures.sample(2, minutes: 125, east: 1875), + Fixtures.sample(3, minutes: 250, east: 3750), + ] + #expect(analyzer.analyze(samples: samples, now: Fixtures.date(minutes: 250)).isEmpty) + } + + @Test func approachExtensionStopsAtItsTimeBound() throws { + var samples = [ + Fixtures.sample(1, minutes: 0, east: 0), + Fixtures.sample(2, minutes: 5, east: 75), + Fixtures.sample(3, minutes: 10, east: 150), + ] + for index in 1 ... 7 { + samples.append(Fixtures.sample( + index + 3, + minutes: 10 + Double(index) * 5, + east: 150 + Double(index) * 20, + )) + } + samples += [ + Fixtures.sample(11, minutes: 50, east: 290), + Fixtures.sample(12, minutes: 55, east: 290), + ] + let flight = try #require(analyzer.analyze( + samples: samples, + now: Fixtures.date(minutes: 55), + ).first) + #expect(flight.progress == .completed(arrivedAt: Fixtures.date(minutes: 45))) + #expect(flight.airborneSampleIDs.contains(Fixtures.sampleID(8))) + #expect(flight.airborneSampleIDs.contains(Fixtures.sampleID(9)) == false) + #expect(flight.airborneSampleIDs.contains(Fixtures.sampleID(10)) == false) + } + + @Test func shortBurstsAndUncertainThresholdSpeedsDoNotConfirmFlight() { + let brief = [ + Fixtures.sample(1, minutes: 0, east: 0), + Fixtures.sample(2, minutes: 1, east: 15), + Fixtures.sample(3, minutes: 2, east: 30), + ] + let uncertain = [ + Fixtures.sample(1, minutes: 0, east: 0, accuracy: 250), + Fixtures.sample(2, minutes: 5, east: 37.6, accuracy: 250), + Fixtures.sample(3, minutes: 10, east: 75.2, accuracy: 250), + ] + #expect(analyzer.analyze(samples: brief, now: Fixtures.date(minutes: 10)).isEmpty) + #expect(analyzer.analyze(samples: uncertain, now: Fixtures.date(minutes: 10)).isEmpty) + } + + @Test func manualAndEvidenceCoordinatesNeverBecomeFlightEvidence() { + let samples = [ + Fixtures.sample(1, minutes: 0, east: 0, source: .manual), + Fixtures.sample(2, minutes: 5, east: 75, source: .manual), + Fixtures.sample(3, minutes: 10, east: 150, source: .evidenceImplied( + id: Fixtures.sampleID(999), + kind: .boardingPass, + )), + ] + #expect(analyzer.analyze(samples: samples, now: Fixtures.date(minutes: 20)).isEmpty) + } +} diff --git a/Where/WhereCore/Tests/FlightTrajectoryTestSupport.swift b/Where/WhereCore/Tests/FlightTrajectoryTestSupport.swift new file mode 100644 index 000000000..f9e893f10 --- /dev/null +++ b/Where/WhereCore/Tests/FlightTrajectoryTestSupport.swift @@ -0,0 +1,143 @@ +import Foundation +import RegionKit +import WhereCore + +/// Optional local verification inputs. Private archives and expected identities +/// are supplied outside the checkout and never become repository fixtures. +struct FlightArchiveVerificationConfiguration: Decodable { + let backupPath: String + let from: Date + let until: Date + let now: Date + let cruiseCheckAt: Date + let awaitingArrivalCheckAt: Date + let expectedArrivalAt: Date + let expectedArrivalConfirmedAt: Date + let expectedInputSampleCount: Int + let expectedAirborneSampleIDs: Set + let expectedPreservedSampleIDs: Set + let calendarTimeZoneID: String + let expectedResultingRegions: Set +} + +/// An invented equatorial route, not exported personal location history. Its +/// structure exercises short callbacks, a recording gap, a turning approach, +/// and delayed arrival confirmation without retaining a real itinerary. +enum FlightTrajectoryFixtures { + static let start = Date(timeIntervalSince1970: 1_767_225_600) + static let device = RecordingDeviceID(rawValue: sampleID(9000)) + + struct Trace { + let samples: [LocationSample] + let departureNoiseID: UUID + let shortIntervalID: UUID + let lastCruiseAt: Date + let firstGroundAt: Date + let readyAt: Date + } + + struct SparseLayoverTrace { + let samples: [LocationSample] + let layoverSampleIDs: Set + let airborneSampleIDs: Set + let firstGroundAt: Date + let readyAt: Date + } + + static func sampleID(_ number: Int) -> UUID { + UUID(uuidString: String(format: "00000000-0000-0000-0000-%012d", number))! + } + + static func date(minutes: Double) -> Date { + start.addingTimeInterval(minutes * 60) + } + + static func sample( + _ number: Int, + minutes: Double, + east: Double, + north: Double = 0, + accuracy: Double = 5, + deviceID: RecordingDeviceID? = device, + source: SampleSource = .gpsSignificantChange, + motion: LocationMotion? = nil, + ) -> LocationSample { + LocationSample( + id: sampleID(number), + timestamp: date(minutes: minutes), + coordinate: Coordinate(latitude: north / 111.195, longitude: east / 111.195), + horizontalAccuracy: accuracy, + source: source, + recordingDeviceID: deviceID, + motion: motion, + ) + } + + static func turningFlight() -> Trace { + Trace( + samples: [ + sample(1, minutes: 0, east: 0), + sample(2, minutes: 5, east: 0), + sample(3, minutes: 10, east: 0), + sample(4, minutes: 11, east: 1), + sample(5, minutes: 15, east: 5), + sample(6, minutes: 20, east: 45), + sample(7, minutes: 25, east: 120), + sample(8, minutes: 25 + 5.0 / 60, east: 121.25), + sample(9, minutes: 30, east: 195), + sample(10, minutes: 105, east: 1320), + sample(11, minutes: 110, east: 1395), + sample(12, minutes: 115, east: 1470), + // A turn away from the eventual arrival, then back toward it. + sample(13, minutes: 120, east: 1490, north: -5), + sample(14, minutes: 125, east: 1462, north: -8), + sample(15, minutes: 130, east: 1444, north: 8), + sample(16, minutes: 135, east: 1445, north: 8), + sample(17, minutes: 140, east: 1445, north: 8), + sample(18, minutes: 145, east: 1445, north: 8), + ], + departureNoiseID: sampleID(4), + shortIntervalID: sampleID(8), + lastCruiseAt: date(minutes: 115), + firstGroundAt: date(minutes: 130), + readyAt: date(minutes: 140), + ) + } + + /// Two stationary observations cannot confirm a dwell, but they must keep + /// their presence when the surrounding cruise segments are corrected. + static func sparseLayover() -> SparseLayoverTrace { + SparseLayoverTrace( + samples: [ + sample(1, minutes: 0, east: 0), + sample(2, minutes: 5, east: 75), + sample(3, minutes: 10, east: 150), + sample(4, minutes: 30, east: 150), + sample(5, minutes: 35, east: 225), + sample(6, minutes: 40, east: 300), + sample(7, minutes: 45, east: 300), + sample(8, minutes: 50, east: 300), + ], + layoverSampleIDs: [sampleID(3), sampleID(4)], + airborneSampleIDs: [sampleID(2), sampleID(5)], + firstGroundAt: date(minutes: 40), + readyAt: date(minutes: 50), + ) + } + + static func replacing( + _ sample: LocationSample, + timestamp: Date? = nil, + motion: LocationMotion? = nil, + ) -> LocationSample { + LocationSample( + id: sample.id, + timestamp: timestamp ?? sample.timestamp, + coordinate: sample.coordinate, + horizontalAccuracy: sample.horizontalAccuracy, + source: sample.source, + recordingDeviceID: sample.recordingDeviceID, + motion: motion, + ) + } +} diff --git a/Where/WhereCore/Tests/LocationHistoryReaderTests.swift b/Where/WhereCore/Tests/LocationHistoryReaderTests.swift new file mode 100644 index 000000000..4077231d6 --- /dev/null +++ b/Where/WhereCore/Tests/LocationHistoryReaderTests.swift @@ -0,0 +1,106 @@ +import Foundation +import RegionKit +import Testing +@testable import WhereCore + +struct LocationHistoryReaderTests { + @Test func correctionsProjectWithoutChangingRawSamplesAndResetWinsLateDelivery() async throws { + let h = try SampleCorrectionTestSupport.makeHarness() + let samples = [ + FlightTrajectoryFixtures.sample(101, minutes: 1, east: 0), + FlightTrajectoryFixtures.sample(102, minutes: 2, east: 0), + ] + let override = SampleAttributionRevision( + id: FlightTrajectoryFixtures.sampleID(201), + sampleID: samples[0].id, + updatedAt: FlightTrajectoryFixtures.date(minutes: 3), + replacementRegions: [.newYork], + ) + let exclusion = SampleAttributionRevision( + id: FlightTrajectoryFixtures.sampleID(202), + sampleID: samples[1].id, + updatedAt: FlightTrajectoryFixtures.date(minutes: 3), + replacementRegions: [], + ) + try await h.store.perform { + for sample in samples { + try await h.store.add(sample: sample) + } + try await h.store.addSampleAttributionRevision(override) + try await h.store.addSampleAttributionRevision(exclusion) + } + let reader = LocationHistoryReader(store: h.store) + let corrected = try await reader.projection( + in: h.interval, + attributor: SampleCorrectionTestSupport.attribution, + ) + #expect(corrected.samples.map(\.regions) == [[.newYork], []]) + #expect(corrected.rawSamples == samples) + #expect(try await reader.samples(in: h.interval) == samples) + #expect(try await h.store.allSamples().sorted { $0.timestamp < $1.timestamp } == samples) + + try await h.coordinator.reset(sampleIDs: Set(samples.map(\.id))) + // A previously unseen older revision arrives after reset, as with offline sync. + try await h.store.perform { + try await h.store.addSampleAttributionRevision(.init( + id: FlightTrajectoryFixtures.sampleID(203), + sampleID: samples[0].id, + updatedAt: FlightTrajectoryFixtures.date(minutes: 4), + replacementRegions: [], + )) + } + let restored = try await reader.projection( + in: h.interval, + attributor: SampleCorrectionTestSupport.attribution, + ) + #expect(restored.samples.map(\.regions) == [[.california], [.california]]) + #expect(restored.rawSamples == samples) + #expect(restored.revisions.count == 5) + } + + @Test func removalCutoffsPrecedeCorrectionsAndNonGPSAttributionIsUnaffected() async throws { + let h = try SampleCorrectionTestSupport.makeHarness() + let before = FlightTrajectoryFixtures.sample(301, minutes: 1, east: 0) + let removed = FlightTrajectoryFixtures.sample(302, minutes: 5, east: 0) + let manual = FlightTrajectoryFixtures.sample(303, minutes: 6, east: 0, source: .manual) + let evidence = FlightTrajectoryFixtures.sample( + 304, + minutes: 7, + east: 0, + source: .evidenceImplied( + id: FlightTrajectoryFixtures.sampleID(305), + kind: .boardingPass, + ), + ) + let samples = [before, removed, manual, evidence] + try await h.store.perform { + for sample in samples { + try await h.store.add(sample: sample) + try await h.store.addSampleAttributionRevision(.init( + id: UUID(), + sampleID: sample.id, + updatedAt: FlightTrajectoryFixtures.date(minutes: 10), + replacementRegions: [.newYork], + )) + } + try await h.store.addRecordingDeviceRemoval(.init( + id: .init(rawValue: UUID()), + deviceID: FlightTrajectoryFixtures.device, + removedAt: FlightTrajectoryFixtures.date(minutes: 5), + removedByDeviceID: FlightTrajectoryFixtures.device, + )) + } + let projection = try await LocationHistoryReader(store: h.store).projection( + in: h.interval, + attributor: SampleCorrectionTestSupport.attribution, + ) + #expect(projection.rawSamples == [before, manual, evidence]) + #expect(projection.samples.map(\.regions) == [[.newYork], [.california], [.california]]) + #expect(!projection.revisions.contains { $0.sampleID == removed.id }) + #expect(try await h.store.allSamples().count == samples.count) + #expect(try await h.reader.yearReport(for: h.day.year).days.first?.regions == [ + .newYork, + .california, + ]) + } +} diff --git a/Where/WhereCore/Tests/LocationIngestorTests.swift b/Where/WhereCore/Tests/LocationIngestorTests.swift index f52c31eb8..b5fe03629 100644 --- a/Where/WhereCore/Tests/LocationIngestorTests.swift +++ b/Where/WhereCore/Tests/LocationIngestorTests.swift @@ -1053,6 +1053,20 @@ private actor ToggleFailingStore: WhereStore { try await backing.allSamples() } + func sampleAttributionRevisions(for sampleIDs: Set) async throws + -> [SampleAttributionRevision] + { + try await backing.sampleAttributionRevisions(for: sampleIDs) + } + + func allSampleAttributionRevisions() async throws -> [SampleAttributionRevision] { + try await backing.allSampleAttributionRevisions() + } + + func addSampleAttributionRevision(_ revision: SampleAttributionRevision) async throws { + try await backing.addSampleAttributionRevision(revision) + } + func recordingDevices() async throws -> [RecordingDevice] { try await backing.recordingDevices() } diff --git a/Where/WhereCore/Tests/LocationMotionTests.swift b/Where/WhereCore/Tests/LocationMotionTests.swift new file mode 100644 index 000000000..a9da1d28a --- /dev/null +++ b/Where/WhereCore/Tests/LocationMotionTests.swift @@ -0,0 +1,18 @@ +import Foundation +import Testing +@testable import WhereCore + +struct LocationMotionTests { + @Test(arguments: [ + LocationMotion( + speed: .init(metersPerSecond: 240, accuracyMetersPerSecond: 2), + altitude: nil, + ), + LocationMotion(speed: nil, altitude: .init(meters: -20, accuracyMeters: 5)), + LocationMotion(speed: nil, altitude: nil), + ]) + func roundTripPreservesIndependentMeasurements(_ motion: LocationMotion) throws { + let data = try JSONEncoder().encode(motion) + #expect(try JSONDecoder().decode(LocationMotion.self, from: data) == motion) + } +} diff --git a/Where/WhereCore/Tests/LocationOutboxTests.swift b/Where/WhereCore/Tests/LocationOutboxTests.swift index 8eed84569..5e2c8f07c 100644 --- a/Where/WhereCore/Tests/LocationOutboxTests.swift +++ b/Where/WhereCore/Tests/LocationOutboxTests.swift @@ -72,6 +72,26 @@ struct LocationOutboxTests { #expect(try await loadedSamples(from: outbox) == samples) } + @Test func retryJournalPreservesGPSMotion() async throws { + let url = tempURL() + defer { cleanup(url) } + let outbox = FileLocationOutbox(fileURL: url) + let sample = LocationSample( + timestamp: Date(timeIntervalSince1970: 1000), + coordinate: Coordinate(latitude: 40, longitude: -100), + horizontalAccuracy: 10, + source: .gpsSignificantChange, + motion: LocationMotion( + speed: .init(metersPerSecond: 240, accuracyMetersPerSecond: 2), + altitude: .init(meters: 10500, accuracyMeters: 12), + ), + ) + let entry = LocationOutboxEntry(sample: sample, dataGenerationID: .initial) + try await outbox.save([entry]) + let reopened = FileLocationOutbox(fileURL: url) + #expect(try await reopened.load() == [entry]) + } + @Test func roundTripPreservesNoninitialDataGeneration() async throws { let url = tempURL() defer { cleanup(url) } diff --git a/Where/WhereCore/Tests/LocationSampleTests.swift b/Where/WhereCore/Tests/LocationSampleTests.swift new file mode 100644 index 000000000..2fc238091 --- /dev/null +++ b/Where/WhereCore/Tests/LocationSampleTests.swift @@ -0,0 +1,27 @@ +import Foundation +import RegionKit +import Testing +@testable import WhereCore + +struct LocationSampleTests { + @Test func stampingTheRecordingDevicePreservesRawMotion() throws { + let motion = LocationMotion( + speed: .init(metersPerSecond: 220, accuracyMetersPerSecond: 1.5), + altitude: .init(meters: 10500, accuracyMeters: 12), + ) + let sample = LocationSample( + timestamp: Date(timeIntervalSince1970: 1000), + coordinate: Coordinate(latitude: 40, longitude: -100), + horizontalAccuracy: 10, + source: .gpsSignificantChange, + motion: motion, + ) + let deviceID = RecordingDeviceID(rawValue: UUID()) + let stamped = sample.recorded(by: deviceID) + #expect(stamped.id == sample.id) + #expect(stamped.motion == motion) + #expect(stamped.recordingDeviceID == deviceID) + #expect(try JSONDecoder() + .decode(LocationSample.self, from: JSONEncoder().encode(stamped)) == stamped) + } +} diff --git a/Where/WhereCore/Tests/Logging/WhereLogTests.swift b/Where/WhereCore/Tests/Logging/WhereLogTests.swift index 625b3587e..dea7e5c85 100644 --- a/Where/WhereCore/Tests/Logging/WhereLogTests.swift +++ b/Where/WhereCore/Tests/Logging/WhereLogTests.swift @@ -76,9 +76,7 @@ struct WhereLogSpanNameTests { @Test func detectorsDeclareTheCategoryTheyDetect() { #expect(MissingDaysDetector().detects == .missingDays) - #expect(BorderDriftDetector().detects == .borderDrift) #expect(AbruptLocationChangeDetector().detects == .abruptChange) - #expect(FlightDayDetector().detects == .flightDay) } } diff --git a/Where/WhereCore/Tests/SampleAttributionResetTests.swift b/Where/WhereCore/Tests/SampleAttributionResetTests.swift new file mode 100644 index 000000000..080c873a3 --- /dev/null +++ b/Where/WhereCore/Tests/SampleAttributionResetTests.swift @@ -0,0 +1,76 @@ +import Foundation +import RegionKit +import Testing +@testable import WhereCore + +struct SampleAttributionResetTests { + @Test(arguments: [false, true]) + func resetSuppressesDelayedCorrectionsWithoutAnActiveReplacement( + repeated: Bool, + ) async throws { + let h = try SampleCorrectionTestSupport.makeHarness() + let sample = FlightTrajectoryFixtures.sample(101, minutes: 1, east: 0) + try await h.store.perform { try await h.store.add(sample: sample) } + if repeated { + try await SampleAttributionReset.write( + sampleIDs: [sample.id], + store: h.store, + now: FlightTrajectoryFixtures.date(minutes: 10), + ) + } + + let resetAt = FlightTrajectoryFixtures.date(minutes: 30) + try await SampleAttributionReset.write( + sampleIDs: [sample.id], + store: h.store, + now: resetAt, + ) + // The other device's edit predates this reset but, on a repeated reset, + // is newer than the tombstone we already knew about. + try await h.store.perform { + try await h.store.addSampleAttributionRevision(.init( + id: UUID(), + sampleID: sample.id, + updatedAt: FlightTrajectoryFixtures.date(minutes: 20), + replacementRegions: [.newYork], + )) + } + + let projection = try await LocationHistoryReader(store: h.store).projection( + in: h.interval, + attributor: SampleCorrectionTestSupport.attribution, + ) + #expect(projection.rawSamples == [sample]) + #expect(projection.samples.map(\.regions) == [[.california]]) + let revisions = try await h.store.allSampleAttributionRevisions() + #expect(revisions.count(where: { $0.replacementRegions == nil }) == (repeated ? 2 : 1)) + let winner = try #require(revisions.last) + #expect(winner.updatedAt == resetAt) + #expect(winner.replacementRegions == nil) + } + + @Test(arguments: [false, true]) + func resetAdvancesPastAKnownRevisionWhenItsClockIsAhead(alreadyReset: Bool) async throws { + let store = try SwiftDataStore.inMemory() + let sampleID = UUID() + let prior = SampleAttributionRevision( + id: UUID(), + sampleID: sampleID, + updatedAt: FlightTrajectoryFixtures.date(minutes: 30), + replacementRegions: alreadyReset ? nil : [.newYork], + ) + try await store.perform { try await store.addSampleAttributionRevision(prior) } + + try await SampleAttributionReset.write( + sampleIDs: [sampleID], + store: store, + now: FlightTrajectoryFixtures.date(minutes: 20), + ) + + let revisions = try await store.allSampleAttributionRevisions() + #expect(revisions.count == 2) + let winner = try #require(revisions.last) + #expect(winner.updatedAt > prior.updatedAt) + #expect(winner.replacementRegions == nil) + } +} diff --git a/Where/WhereCore/Tests/SampleAttributionRevisionTests.swift b/Where/WhereCore/Tests/SampleAttributionRevisionTests.swift new file mode 100644 index 000000000..5b6d12732 --- /dev/null +++ b/Where/WhereCore/Tests/SampleAttributionRevisionTests.swift @@ -0,0 +1,47 @@ +import Foundation +import RegionKit +import Testing +@testable import WhereCore + +struct SampleAttributionRevisionTests { + @Test func resetExclusionAndReplacementRemainDistinctOnTheWire() throws { + let sampleID = UUID() + let replacements: [Set?] = [nil, [], [.newYork]] + let revisions = replacements.map { + SampleAttributionRevision( + id: UUID(), + sampleID: sampleID, + updatedAt: Date(timeIntervalSince1970: 1000), + replacementRegions: $0, + ) + } + let encoded = try JSONEncoder().encode(revisions) + #expect(try JSONDecoder() + .decode([SampleAttributionRevision].self, from: encoded) == revisions) + } + + @Test func equalTimeRevisionsUseTheirStableIdentityToBreakTies() throws { + let sampleID = UUID() + let lower = try SampleAttributionRevision( + id: #require(UUID(uuidString: "00000000-0000-0000-0000-000000000001")), + sampleID: sampleID, + updatedAt: Date(timeIntervalSince1970: 1000), + replacementRegions: [], + ) + let higher = try SampleAttributionRevision( + id: #require(UUID(uuidString: "00000000-0000-0000-0000-000000000002")), + sampleID: sampleID, + updatedAt: lower.updatedAt, + replacementRegions: nil, + ) + #expect(SampleAttributionRevision.newer(higher, than: lower)) + #expect(!SampleAttributionRevision.newer(lower, than: higher)) + let later = SampleAttributionRevision( + id: lower.id, + sampleID: sampleID, + updatedAt: lower.updatedAt.addingTimeInterval(1), + replacementRegions: [], + ) + #expect(SampleAttributionRevision.newer(later, than: higher)) + } +} diff --git a/Where/WhereCore/Tests/SampleCorrectionAssessmentTestSupport.swift b/Where/WhereCore/Tests/SampleCorrectionAssessmentTestSupport.swift new file mode 100644 index 000000000..1fe781e71 --- /dev/null +++ b/Where/WhereCore/Tests/SampleCorrectionAssessmentTestSupport.swift @@ -0,0 +1,69 @@ +import Foundation +import RegionKit +@testable import WhereCore + +/// A straight invented boundary, with all points independent of private exports. +enum SampleCorrectionAssessmentFixtures { + struct Boundary: RegionAttributing { + let loadedRegions: [Region] = [.california, .newYork] + func region(at coordinate: Coordinate) -> Region { + coordinate.longitude <= 0 ? .california : .other + } + + func distanceToBoundary(of region: Region, from coordinate: Coordinate) -> Double? { + region == .california ? abs(coordinate.longitude) * 111_195 : nil + } + } + + static func point( + _ number: Int, + minutes: Double, + longitude: Double, + accuracy: Double = 5, + deviceID: RecordingDeviceID? = FlightTrajectoryFixtures.device, + source: SampleSource = .gpsSignificantChange, + ) -> LocationSample { + LocationSample( + id: FlightTrajectoryFixtures.sampleID(number), + timestamp: FlightTrajectoryFixtures.date(minutes: minutes), + coordinate: Coordinate(latitude: 0, longitude: longitude), + horizontalAccuracy: accuracy, + source: source, + recordingDeviceID: deviceID, + ) + } + + static func reviews( + _ samples: [LocationSample], + manuals: [DayPresence] = [], + attributor: any RegionAttributing = Boundary(), + now: Date = FlightTrajectoryFixtures.date(minutes: 300), + ) -> [GPSCorrectionReview] { + let calendar = SampleCorrectionTestSupport.calendar + let projection = LocationHistoryProjection( + samples: AttributedLocationSample.raw(samples, attributor: attributor) + .sorted { $0.sample.timestamp < $1.sample.timestamp }, + revisions: [], + ) + let report = SampleCorrectionTestSupport.aggregator.report( + for: 2026, + history: projection.samples, + manualDays: manuals, + ) + return SampleCorrectionAssessment(attributor: attributor, calendar: calendar).reviews( + reads: DataIssueReads( + report: report, + otherDayCoordinates: [:], + daySamples: DaySamples(samples: samples, calendar: calendar), + history: projection, + manualDays: manuals, + dataGenerationID: .initial, + dismissedIssueIDs: [], + attribution: attributor, + ), + primaryRegions: attributor.loadedRegions, + driftThresholdMeters: 1000, + now: now, + ) + } +} diff --git a/Where/WhereCore/Tests/SampleCorrectionAssessmentTests.swift b/Where/WhereCore/Tests/SampleCorrectionAssessmentTests.swift new file mode 100644 index 000000000..4fca219c0 --- /dev/null +++ b/Where/WhereCore/Tests/SampleCorrectionAssessmentTests.swift @@ -0,0 +1,213 @@ +import Foundation +import RegionKit +import SwiftData +import Testing +@_spi(Testing) @testable import WhereCore + +struct SampleCorrectionAssessmentTests { + private typealias F = SampleCorrectionAssessmentFixtures + + @Test @MainActor func syncedDuplicateRowsApplyOneRevisionPerSample() async throws { + let samples = FlightTrajectoryFixtures.turningFlight().samples + let container = try SwiftDataStore.makeContainer(storage: .inMemory) + let context = ModelContext(container) + // Distinct synced records can carry the same logical sample identity. + for sample in samples + samples { + context.insert(SDLocationSample(value: sample, generationID: .initial)) + } + try context.save() + let store = SwiftDataStore(modelContainer: container) + let services = WhereServices( + store: store, + locationSource: ScriptedLocationSource(), + attributor: SampleCorrectionTestSupport.attribution, + aggregator: SampleCorrectionTestSupport.aggregator, + now: { FlightTrajectoryFixtures.date(minutes: 150) }, + ) + let day = CalendarDay( + from: FlightTrajectoryFixtures.start, + in: SampleCorrectionTestSupport.calendar, + ) + let review = try await services.corrections.review( + id: .flightDay(day: day), + year: day.year, + primaryRegions: SampleCorrectionTestSupport.attribution.loadedRegions, + driftThresholdMeters: 1000, + ) + let proposal = try #require(review?.proposal) + try #require(proposal.edits.isEmpty == false) + #expect(Set(proposal.edits.map(\.sampleID)).count == proposal.edits.count) + guard case .applied = try await services.corrections.apply(proposal) else { + Issue.record("Duplicate synced rows must not prevent the reviewed correction") + return + } + let revisions = try await store.allSampleAttributionRevisions() + #expect(revisions.count == proposal.edits.count) + #expect(Set(revisions.map(\.sampleID)) == Set(proposal.edits.map(\.sampleID))) + #expect(try await store.allSamples().count == samples.count * 2) + #expect(try await services.reports.yearReport(for: day.year).days.first?.regions + == proposal.resultingRegions) + } + + @Test(arguments: [false, true]) + func duplicateRowsProduceOneEditPerSample(isFlight: Bool) throws { + let samples = if isFlight { + FlightTrajectoryFixtures.turningFlight().samples + } else { + [ + F.point(101, minutes: 0, longitude: -0.001), + F.point(102, minutes: 5, longitude: 0.0005), + F.point(103, minutes: 10, longitude: -0.001), + ] + } + let attributor: any RegionAttributing = isFlight + ? SampleCorrectionTestSupport.attribution : F.Boundary() + let original = try #require(F.reviews(samples, attributor: attributor).first?.proposal) + for duplicated in [samples + samples, Array((samples + samples).reversed())] { + let review = try #require(F.reviews(duplicated, attributor: attributor).first) + let proposal = try #require(review.proposal) + #expect(proposal.edits == original.edits) + #expect(proposal.resultingRegions == original.resultingRegions) + #expect(Set(proposal.edits.map(\.sampleID)).count == proposal.edits.count) + #expect(review.points.count == duplicated.count) + #expect(proposal.evidence.history.rawSamples.count == duplicated.count) + } + } + + @Test(arguments: [120.0, 24 * 60 + 5, 48 * 60 + 5]) + func conflictingDuplicateRowsRemainUncorrected(conflictMinutes: Double) throws { + let samples = [ + F.point(101, minutes: 0, longitude: -0.001), + F.point(102, minutes: 5, longitude: 0.0005), + F.point(103, minutes: 10, longitude: -0.001), + ] + let original = try #require(F.reviews(samples).first?.proposal) + #expect(original.edits.map(\.sampleID) == [samples[1].id]) + let conflict = F.point(102, minutes: conflictMinutes, longitude: 1) + let now = conflict.timestamp.addingTimeInterval(60) + #expect(F.reviews(samples + [conflict], now: now).isEmpty) + #expect(F.reviews([conflict] + samples, now: now).isEmpty) + } + + @Test func boundaryCleanupNamesOnlyTheSupportedSampleAndRetainsDistantEvidence() throws { + let samples = [ + F.point(101, minutes: 0, longitude: -0.001), + F.point(102, minutes: 5, longitude: 0.0005), + F.point(103, minutes: 10, longitude: -0.001), + F.point(104, minutes: 120, longitude: 1), + ] + let proposal = try #require(F.reviews(samples).first?.proposal) + #expect(proposal.edits == [.init( + sampleID: samples[1].id, + replacementRegions: [.california], + )]) + #expect(proposal.resultingRegions == [.california, .other]) + } + + @Test(arguments: [false, true]) + func boundaryNeedsIndependentBracketsOnBothSides(missingBefore: Bool) { + let bracket = F.point(101, minutes: missingBefore ? 10 : 0, longitude: -0.001) + let noise = F.point(102, minutes: 5, longitude: 0.0005) + #expect(F.reviews([bracket, noise]).isEmpty) + } + + @Test(arguments: [0.0, 11.0]) + func simultaneousOrDistantBracketsDoNotSupportDrift(minutes: Double) { + let samples = [ + F.point(101, minutes: 20 - minutes, longitude: -0.001), + F.point(102, minutes: 20, longitude: 0.0005), + F.point(103, minutes: 20 + minutes, longitude: -0.001), + ] + #expect(F.reviews(samples).isEmpty) + } + + @Test func differentDevicesCannotBracketBoundaryNoise() { + let samples = [ + F.point(101, minutes: 0, longitude: -0.001, deviceID: nil), + F.point(102, minutes: 5, longitude: 0.0005), + F.point(103, minutes: 10, longitude: -0.001, deviceID: nil), + ] + #expect(F.reviews(samples).isEmpty) + } + + @Test func fastTravelAndPoorPositionsDoNotBecomeBoundaryCleanup() { + let before = F.point(101, minutes: 0, longitude: -1) + let noise = F.point(102, minutes: 5, longitude: 0.0005) + let after = F.point(103, minutes: 10, longitude: -1) + #expect(F.reviews([before, noise, after]).isEmpty) + let poor = F.point(104, minutes: 5, longitude: 0.0005, accuracy: 251) + #expect(F.reviews([ + F.point(105, minutes: 0, longitude: -0.001), + poor, + F.point(106, minutes: 10, longitude: -0.001), + ]).isEmpty) + } + + @Test func nonGPSAndManualAssertionsKeepTheirContributions() throws { + let samples = [ + F.point(101, minutes: 0, longitude: -0.001), + F.point(102, minutes: 5, longitude: 0.0005), + F.point(103, minutes: 10, longitude: -0.001), + F.point(104, minutes: 6, longitude: 0.0005, source: .manual), + ] + let day = CalendarDay(from: samples[0].timestamp, in: SampleCorrectionTestSupport.calendar) + let manual = DayPresence(day: day, regions: [.newYork]) + let proposal = try #require(F.reviews(samples, manuals: [manual]).first?.proposal) + #expect(proposal.edits.map(\.sampleID) == [samples[1].id]) + #expect(proposal.resultingRegions == [.california, .newYork, .other]) + #expect(F.reviews( + samples, + manuals: [DayPresence(day: day, regions: [.newYork], isAuthoritative: true)], + ).isEmpty) + } + + @Test func pendingFlightHoldsAllAutomaticCleanupUntilArrival() throws { + let trace = FlightTrajectoryFixtures.turningFlight() + for now in [trace.lastCruiseAt, trace.firstGroundAt] { + let review = try #require(F.reviews( + trace.samples.filter { $0.timestamp <= now }, + attributor: SampleCorrectionTestSupport.attribution, + now: now, + ).first) + #expect(review.isPending) + #expect(review.proposal == nil) + } + let ready = try #require(F.reviews( + trace.samples, + attributor: SampleCorrectionTestSupport.attribution, + ).first?.proposal) + #expect(ready.resultingRegions.contains(.california)) + #expect(ready.resultingRegions.contains(.newYork)) + #expect(ready.resultingRegions.contains(.other)) // Independent departure noise survives. + #expect(!ready.edits.contains { $0.sampleID == trace.departureNoiseID }) + } + + @Test func historicalSilenceStaysPendingWithoutAnActionableProposal() throws { + let trace = FlightTrajectoryFixtures.turningFlight() + let review = try #require(F.reviews( + trace.samples.filter { $0.timestamp <= trace.lastCruiseAt }, + attributor: SampleCorrectionTestSupport.attribution, + now: trace.readyAt.addingTimeInterval(90 * 24 * 60 * 60), + ).first) + #expect(review.isPending) + #expect(review.flight?.progress == .awaitingArrival) + #expect(review.proposal == nil) + } + + @Test func authoritativeManualKeepsFlightInformational() throws { + let trace = FlightTrajectoryFixtures.turningFlight() + let day = CalendarDay( + from: trace.samples[0].timestamp, + in: SampleCorrectionTestSupport.calendar, + ) + let review = try #require(F.reviews( + trace.samples, + manuals: [DayPresence(day: day, regions: [.canada], isAuthoritative: true)], + attributor: SampleCorrectionTestSupport.attribution, + ).first) + #expect(review.proposal == nil) + #expect(review.day.regions == [.canada]) + guard case .completed = review.state + else { Issue.record("Expected completed informational flight"); return } + } +} diff --git a/Where/WhereCore/Tests/SampleCorrectionCoordinatorTests.swift b/Where/WhereCore/Tests/SampleCorrectionCoordinatorTests.swift new file mode 100644 index 000000000..e349d6014 --- /dev/null +++ b/Where/WhereCore/Tests/SampleCorrectionCoordinatorTests.swift @@ -0,0 +1,241 @@ +import Foundation +import RegionKit +import Testing +@_spi(Testing) @testable import WhereCore + +struct SampleCorrectionCoordinatorTests { + @Test func applyingJoinedFlightsPreservesSparseLayoverPresence() async throws { + let trace = FlightTrajectoryFixtures.sparseLayover() + let attributor = SampleCorrectionTestSupport.SparseLayoverRegions() + let store = try SwiftDataStore.inMemory() + let services = WhereServices( + store: store, + locationSource: ScriptedLocationSource(), + attributor: attributor, + aggregator: SampleCorrectionTestSupport.aggregator, + now: { trace.readyAt }, + ) + try await store.perform { + for sample in trace.samples { + try await store.add(sample: sample) + } + } + let day = CalendarDay( + from: FlightTrajectoryFixtures.start, + in: SampleCorrectionTestSupport.calendar, + ) + let review = try await services.corrections.review( + id: .flightDay(day: day), + year: day.year, + primaryRegions: attributor.loadedRegions, + driftThresholdMeters: 1000, + ) + let proposal = try #require(review?.proposal) + #expect(Set(proposal.edits.map(\.sampleID)) == trace.airborneSampleIDs) + #expect(proposal.resultingRegions == [.california, .canada, .newYork]) + guard case .applied = try await services.corrections.apply(proposal) else { + Issue.record("The completed flight review must preserve the uncertain layover") + return + } + #expect(try await services.reports.yearReport(for: day.year).days.first?.regions + == [.california, .canada, .newYork]) + let history = try await services.reports.dataIssueReads(for: day.year).history + #expect(history.samples.filter { trace.layoverSampleIDs.contains($0.sample.id) } + .allSatisfy { $0.regions == [.canada] }) + #expect(try await store.allSamples() == trace.samples) + } + + @Test(.disabled( + if: ProcessInfo.processInfo.environment["WHERE_FLIGHT_VERIFICATION_CONFIG"] == nil, + "Supply a local flight-verification configuration to replay an external backup.", + )) + func productionArchiveCorrectionOnRequest() async throws { + let path = try #require(ProcessInfo.processInfo + .environment["WHERE_FLIGHT_VERIFICATION_CONFIG"]) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .secondsSince1970 + let configuration = try decoder.decode( + FlightArchiveVerificationConfiguration.self, + from: Data(contentsOf: URL(fileURLWithPath: path)), + ) + let backupURL = URL(fileURLWithPath: configuration.backupPath) + let archive = try BackupService().readArchive(at: backupURL).archive + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(identifier: configuration.calendarTimeZoneID)) + let store = try SwiftDataStore.inMemory() + let services = WhereServices( + store: store, + locationSource: ScriptedLocationSource(), + attributor: RegionAttributor(for: archive.trackedRegions), + aggregator: DayAggregator(calendar: calendar, timeZone: calendar.timeZone), + now: { configuration.now }, + ) + _ = try await services.backup.importAndAcknowledgeBackup( + from: backupURL, + strategy: .replace, + ) + let day = CalendarDay(from: configuration.from, in: calendar) + let review = try await services.corrections.review( + id: .flightDay(day: day), + year: day.year, + primaryRegions: archive.trackedRegions, + driftThresholdMeters: 1000, + ) + let proposal = try #require(review?.proposal) + #expect(proposal.resultingRegions == configuration.expectedResultingRegions) + guard case .applied = try await services.corrections.apply(proposal) else { + Issue.record("The archive's unchanged reviewed correction must apply") + return + } + let result = try await services.reports.yearReport(for: day.year) + #expect(result.days.first { $0.day == day }?.regions == configuration + .expectedResultingRegions) + #expect(try await Set(store.allSamples()) == Set(archive.samples)) + } + + @Test func changedTrackedRegionsRejectApplyBeforeLiveObserverReconciles() async throws { + let store = try SwiftDataStore.inMemory() + try await store.perform { + try await store.setPrimaryRegions([ + PrimaryRegion(region: .california, appearance: nil, order: 0), + ]) + for sample in FlightTrajectoryFixtures.turningFlight().samples { + try await store.add(sample: sample) + } + } + // Hold the live cache at the reviewed policy. Store-backed snapshots + // must see a committed policy change before the stream observer does. + let live = RegionAttribution( + store: store, + changes: AsyncStream { $0.finish() }, + initial: RegionAttributor(for: [.california]), + trackedIDs: [Region.california.rawValue], + ) + let services = WhereServices( + store: store, + locationSource: ScriptedLocationSource(), + attributor: live, + aggregator: SampleCorrectionTestSupport.aggregator, + now: { FlightTrajectoryFixtures.date(minutes: 150) }, + ) + let day = CalendarDay( + from: FlightTrajectoryFixtures.start, + in: SampleCorrectionTestSupport.calendar, + ) + let review = try await services.corrections.review( + id: .flightDay(day: day), + year: day.year, + primaryRegions: [.california], + driftThresholdMeters: 1000, + ) + let proposal = try #require(review?.proposal) + try await store.perform { + try await store.setPrimaryRegions([ + PrimaryRegion(region: .newYork, appearance: nil, order: 0), + ]) + } + #expect(live.loadedRegions == [.california]) + let result = try await services.corrections.apply(proposal) + guard case let .stale(fresh) = result else { + Issue.record("A committed attribution policy change must require fresh review") + return + } + let replacement = try #require(fresh?.proposal) + // The invented route lies outside both real policies, so this test + // detects the policy change even though its effective presence is equal. + #expect(replacement.resultingRegions == proposal.resultingRegions) + #expect(replacement.evidence.trackedRegions == [.newYork]) + #expect(proposal.evidence.trackedRegions == [.california]) + #expect(try await store.allSampleAttributionRevisions().isEmpty) + } + + @Test func applyOnlyRevisesReviewedSamplesAndPreservesRawTrajectory() async throws { + let h = try await SampleCorrectionTestSupport.completedFlight() + let proposal = try await h.proposal() + let originals = try await h.store.allSamples() + let result = try await h.coordinator.apply(proposal) + guard case .applied = result else { + Issue.record("An unchanged completed flight review must apply") + return + } + + let revisions = try await h.store.allSampleAttributionRevisions() + #expect(Set(revisions.map(\.sampleID)) == Set(proposal.edits.map(\.sampleID))) + #expect(revisions.count == proposal.edits.count) + #expect(try await Set(h.store.allSamples()) == Set(originals)) + #expect(try await h.reader.manualDays(inYear: h.day.year).isEmpty) + #expect(try await h.reader.yearReport(for: h.day.year).days.first?.regions == proposal + .resultingRegions) + + let fresh = try await h.coordinator.review( + id: proposal.reviewID, + year: h.day.year, + primaryRegions: SampleCorrectionTestSupport.attribution.loadedRegions, + driftThresholdMeters: 1000, + ) + #expect(fresh?.proposal == nil) + #expect(fresh?.flight != nil) + #expect(try await h.reader.dataIssueReads(for: h.day.year).history.rawSamples + .count == originals.count) + + let second = try await h.coordinator.apply(proposal) + guard case .stale = second else { + Issue.record("A committed proposal must not be applied twice") + return + } + #expect(try await h.store.allSampleAttributionRevisions().count == revisions.count) + } + + enum InterveningChange: CaseIterable { + case sample + case manual + case revision + case generation + } + + @Test(arguments: InterveningChange.allCases) + func changedEvidenceRejectsApplyWithoutWritingAnyCorrections( + change: InterveningChange, + ) async throws { + let h = try await SampleCorrectionTestSupport.completedFlight() + let proposal = try await h.proposal() + try await h.store.perform { + switch change { + case .sample: + try await h.store.add(sample: FlightTrajectoryFixtures.sample( + 401, + minutes: 146, + east: 1445, + north: 8, + )) + case .manual: + try await h.store.setManualDay(.init( + day: h.day, + regions: [.canada], + isAuthoritative: true, + audit: nil, + )) + case .revision: + try await h.store.addSampleAttributionRevision(.init( + id: UUID(), + sampleID: FlightTrajectoryFixtures.sampleID(1), + updatedAt: FlightTrajectoryFixtures.date(minutes: 149), + replacementRegions: [.california], + )) + case .generation: + _ = try await h.store.rotateDataGeneration( + reason: .accountReset, + changedBy: FlightTrajectoryFixtures.device, + at: FlightTrajectoryFixtures.date(minutes: 149), + ) + } + } + let before = try await h.store.allSampleAttributionRevisions() + let result = try await h.coordinator.apply(proposal) + guard case .stale = result else { + Issue.record("Changed review evidence must require another review: \(change)") + return + } + #expect(try await h.store.allSampleAttributionRevisions() == before) + } +} diff --git a/Where/WhereCore/Tests/SampleCorrectionTestSupport.swift b/Where/WhereCore/Tests/SampleCorrectionTestSupport.swift new file mode 100644 index 000000000..4b2d8e4b0 --- /dev/null +++ b/Where/WhereCore/Tests/SampleCorrectionTestSupport.swift @@ -0,0 +1,116 @@ +import Foundation +import RegionKit +import Testing +@_spi(Testing) @testable import WhereCore + +/// Synthetic geography for the invented trajectory fixture. Real region polygons +/// play no part in these persistence and projection integration tests. +enum SampleCorrectionTestSupport { + struct SparseLayoverRegions: RegionAttributing { + let loadedRegions: [Region] = [.california, .newYork, .canada] + + func region(at coordinate: Coordinate) -> Region { + let east = coordinate.longitude * 111.195 + if east < 0.5 { return .california } + if abs(east - 150) < 0.5 { return .canada } + if east >= 299.5 { return .newYork } + return .other + } + + func distanceToBoundary(of _: Region, from _: Coordinate) -> Double? { + nil + } + } + + struct FlightRegions: RegionAttributing { + let loadedRegions: [Region] = [.california, .newYork, .canada] + + func region(at coordinate: Coordinate) -> Region { + let east = coordinate.longitude * 111.195 + let north = coordinate.latitude * 111.195 + if east < 0.5 { return .california } + if (1440 ... 1455).contains(east), north > 0 { return .newYork } + if east >= 1500 { return .canada } + return .other + } + + func distanceToBoundary(of _: Region, from _: Coordinate) -> Double? { + nil + } + } + + static let attribution = FlightRegions() + static let calendar: Calendar = { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return calendar + }() + + static var aggregator: DayAggregator { + DayAggregator(calendar: calendar, timeZone: calendar.timeZone) + } + + struct Harness { + let store: SwiftDataStore + let services: WhereServices + let widgets: WidgetDataReader + + var reader: ReportReader { + services.reports + } + + var coordinator: SampleCorrectionCoordinator { + services.corrections + } + + var day: CalendarDay { + CalendarDay(from: FlightTrajectoryFixtures.start, in: calendar) + } + + var interval: DateInterval { + calendar.dateInterval(of: .day, for: FlightTrajectoryFixtures.start)! + } + + func proposal() async throws -> SampleCorrectionProposal { + let review = try await coordinator.review( + id: .flightDay(day: day), + year: day.year, + primaryRegions: attribution.loadedRegions, + driftThresholdMeters: 1000, + ) + return try #require(review?.proposal) + } + } + + static func makeHarness( + now: Date = FlightTrajectoryFixtures.date(minutes: 150), + ) throws -> Harness { + let store = try SwiftDataStore.inMemory() + let services = WhereServices( + store: store, + locationSource: ScriptedLocationSource(), + attributor: attribution, + aggregator: aggregator, + now: { now }, + ) + return Harness( + store: store, + services: services, + widgets: WidgetDataReader( + store: store, + aggregator: aggregator, + attributor: attribution, + ), + ) + } + + static func completedFlight() async throws -> Harness { + let harness = try makeHarness() + try await harness.store.perform { + for sample in FlightTrajectoryFixtures.turningFlight().samples { + try await harness.store.add(sample: sample) + } + } + return harness + } +} diff --git a/Where/WhereCore/Tests/SwiftDataStoreTests.swift b/Where/WhereCore/Tests/SwiftDataStoreTests.swift index 1d30d2807..45e766142 100644 --- a/Where/WhereCore/Tests/SwiftDataStoreTests.swift +++ b/Where/WhereCore/Tests/SwiftDataStoreTests.swift @@ -9,6 +9,258 @@ import Testing /// covered by `StoreChangeBroadcasterTests`; here we assert the *store* fires it /// on a committed `perform` and stays silent on a rolled-back one. struct SwiftDataStoreTests { + enum IncompleteMotion: CaseIterable { + case missingSpeedValue + case missingSpeedAccuracy + case missingAltitudeValue + case missingAltitudeAccuracy + case missingPresenceFlag + case onlyIncompleteSpeed + } + + @Test(arguments: IncompleteMotion.allCases) + @MainActor func partialOptionalMotionNeverDropsAValidRawPosition( + partial: IncompleteMotion, + ) async throws { + let container = try SwiftDataStore.makeContainer(storage: .inMemory) + let speed = LocationMotion.Speed(metersPerSecond: 240, accuracyMetersPerSecond: 2) + let altitude = LocationMotion.Altitude(meters: 11000, accuracyMeters: 10) + let sample = FlightTrajectoryFixtures.sample( + 801, + minutes: 1, + east: 0, + motion: LocationMotion(speed: speed, altitude: altitude), + ) + let context = ModelContext(container) + let row = SDLocationSample(value: sample, generationID: .initial) + let expectedMotion: LocationMotion? + switch partial { + case .missingSpeedValue: + row.speedMetersPerSecond = nil + expectedMotion = LocationMotion(speed: nil, altitude: altitude) + case .missingSpeedAccuracy: + row.speedAccuracyMetersPerSecond = nil + expectedMotion = LocationMotion(speed: nil, altitude: altitude) + case .missingAltitudeValue: + row.altitudeMeters = nil + expectedMotion = LocationMotion(speed: speed, altitude: nil) + case .missingAltitudeAccuracy: + row.altitudeAccuracyMeters = nil + expectedMotion = LocationMotion(speed: speed, altitude: nil) + case .missingPresenceFlag: + row.motionPresent = nil + expectedMotion = sample.motion + case .onlyIncompleteSpeed: + row.motionPresent = nil + row.speedAccuracyMetersPerSecond = nil + row.altitudeMeters = nil + row.altitudeAccuracyMeters = nil + expectedMotion = nil + } + context.insert(row) + try context.save() + + let store = SwiftDataStore(modelContainer: container) + let expected = FlightTrajectoryFixtures.replacing(sample, motion: expectedMotion) + let interval = DateInterval(start: sample.timestamp, duration: 1) + #expect(try await store.samples(in: interval) == [expected]) + #expect(try await store.allSamples() == [expected]) // Backup export's raw read. + + // Reading the partial row must not erase its durable measurement fields; + // completing delivery makes the original motion available again. + let inspected = try #require(ModelContext(container) + .fetch(FetchDescriptor()).first) + #expect(inspected.speedMetersPerSecond == row.speedMetersPerSecond) + #expect(inspected.speedAccuracyMetersPerSecond == row.speedAccuracyMetersPerSecond) + #expect(inspected.altitudeMeters == row.altitudeMeters) + #expect(inspected.altitudeAccuracyMeters == row.altitudeAccuracyMeters) + #expect(inspected.motionPresent == row.motionPresent) + row.update(from: sample, generationID: .initial) + try context.save() + let refreshed = SwiftDataStore(modelContainer: container) + #expect(try await refreshed.allSamples() == [sample]) + } + + @Test func rawMotionAndAllAttributionStatesRoundTripWithoutChangingTheSample() async throws { + let store = try SwiftDataStore.inMemory() + let sample = LocationSample( + timestamp: Date(timeIntervalSince1970: 1000), + coordinate: Coordinate(latitude: 40, longitude: -100), + horizontalAccuracy: 10, + source: .gpsSignificantChange, + motion: LocationMotion( + speed: .init(metersPerSecond: 240, accuracyMetersPerSecond: 2), + altitude: .init(meters: 11000, accuracyMeters: 10), + ), + ) + let replacements: [Set?] = [[], [.newYork], nil] + let revisions = replacements.enumerated().map { offset, replacement in + SampleAttributionRevision( + id: UUID(), + sampleID: sample.id, + updatedAt: Date(timeIntervalSince1970: 1000 + Double(offset)), + replacementRegions: replacement, + ) + } + try await store.perform { + // CloudKit can deliver an attribution before its raw sample. + for revision in revisions.reversed() { + try await store.addSampleAttributionRevision(revision) + try await store.addSampleAttributionRevision(revision) + } + try await store.add(sample: sample) + } + #expect(try await store.allSamples() == [sample]) + #expect(try await store.sampleAttributionRevisions(for: [sample.id]) == revisions) + #expect(try await store.sampleAttributionRevisions(for: [UUID()]).isEmpty) + #expect(try await store.allSampleAttributionRevisions() == revisions) + } + + @Test func conflictingAttributionRevisionFailsWithoutChangingTheWinner() async throws { + let store = try SwiftDataStore.inMemory() + let revision = SampleAttributionRevision( + id: UUID(), + sampleID: UUID(), + updatedAt: Date(timeIntervalSince1970: 1000), + replacementRegions: [], + ) + try await store.perform { try await store.addSampleAttributionRevision(revision) } + let conflict = SampleAttributionRevision( + id: revision.id, + sampleID: revision.sampleID, + updatedAt: revision.updatedAt, + replacementRegions: nil, + ) + await #expect(throws: SampleAttributionPersistenceError + .conflictingRevision(id: revision.id)) + { + try await store.perform { try await store.addSampleAttributionRevision(conflict) } + } + #expect(try await store.allSampleAttributionRevisions() == [revision]) + } + + @Test func clearingSamplesRetainsResetTombstonesAgainstLateCorrections() async throws { + let store = try SwiftDataStore.inMemory() + let timestamp = Date(timeIntervalSince1970: 1000) + let sample = LocationSample( + timestamp: timestamp, + coordinate: Coordinate(latitude: 40, longitude: -100), + horizontalAccuracy: 10, + source: .gpsSignificantChange, + ) + let revision = SampleAttributionRevision( + id: UUID(), + sampleID: sample.id, + updatedAt: timestamp, + replacementRegions: [], + ) + try await store.perform { + try await store.add(sample: sample) + try await store.addSampleAttributionRevision(revision) + } + let day = CalendarDay(from: timestamp, in: Self.calendar) + try await store.perform { + try await store.clear( + in: DateInterval(start: timestamp, end: timestamp.addingTimeInterval(1)), + manualDays: day ... day, + ) + try await store.addSampleAttributionRevision(revision) + } + let revisions = try await store.allSampleAttributionRevisions() + #expect(revisions.count == 2) + #expect(revisions.last?.replacementRegions == nil) + #expect(try await store.allSamples().isEmpty) + } + + @Test(arguments: [false, true]) + func clearingSamplesWithoutActiveCorrectionsPreventsLateResurrection( + previouslyReset: Bool, + ) async throws { + let store = try SwiftDataStore.inMemory() + let timestamp = Date(timeIntervalSince1970: 1000) + let sample = LocationSample( + timestamp: timestamp, + coordinate: Coordinate(latitude: 0, longitude: 0), + horizontalAccuracy: 10, + source: .gpsSignificantChange, + ) + try await store.perform { + try await store.add(sample: sample) + if previouslyReset { + try await store.addSampleAttributionRevision(.init( + id: UUID(), + sampleID: sample.id, + updatedAt: timestamp.addingTimeInterval(100), + replacementRegions: nil, + )) + } + } + let day = CalendarDay(from: timestamp, in: Self.calendar) + let interval = DateInterval(start: timestamp, duration: 1) + try await store.perform { + try await store.clear(in: interval, manualDays: day ... day) + } + #expect(try await store.allSamples().isEmpty) + + // An older backup or a delayed device can restore the raw observation + // together with a correction newer than our previous reset. + try await store.perform { + try await store.add(sample: sample) + try await store.addSampleAttributionRevision(.init( + id: UUID(), + sampleID: sample.id, + updatedAt: timestamp.addingTimeInterval(200), + replacementRegions: [.newYork], + )) + } + + let projection = try await LocationHistoryReader(store: store).projection( + in: interval, + attributor: SampleCorrectionTestSupport.attribution, + ) + #expect(projection.rawSamples == [sample]) + #expect(projection.samples.map(\.regions) == [[.california]]) + let revisions = try await store.allSampleAttributionRevisions() + #expect(revisions.count(where: { $0.replacementRegions == nil }) + == (previouslyReset ? 2 : 1)) + let winner = try #require(revisions.last) + #expect(winner.replacementRegions == nil) + } + + @Test @MainActor func attributionHistoryIsGenerationScopedAndCorruptionFailsClosed( + ) async throws { + let container = try SwiftDataStore.makeContainer(storage: .inMemory) + let store = SwiftDataStore(modelContainer: container) + let revision = SampleAttributionRevision( + id: UUID(), + sampleID: UUID(), + updatedAt: Date(timeIntervalSince1970: 1000), + replacementRegions: [], + ) + try await store.perform { try await store.addSampleAttributionRevision(revision) } + let current = try await store.perform { + try await store.rotateDataGeneration( + reason: .backupReplace, + changedBy: Self.generationWriterID, + at: Date(timeIntervalSince1970: 2000), + ) + } + let context = ModelContext(container) + context.insert(SDSampleAttributionRevision(value: revision, generationID: .initial)) + try context.save() + let reader = SwiftDataStore(modelContainer: container) + #expect(try await reader.allSampleAttributionRevisions().isEmpty) + let corrupt = SDSampleAttributionRevision() + corrupt.generationID = current.id.rawValue + corrupt.id = UUID() + context.insert(corrupt) + try context.save() + let corruptReader = SwiftDataStore(modelContainer: container) + await #expect(throws: SampleAttributionPersistenceError.incompleteHistory) { + try await corruptReader.allSampleAttributionRevisions() + } + } + @Test func inspectorStoreURLUsesTheResolvedAppGroupRoot() { let groupURL = FileManager.default.temporaryDirectory.appending( path: "where-group-\(UUID().uuidString)", @@ -999,6 +1251,108 @@ struct SwiftDataStoreTests { #expect(try await store.allSamples().isEmpty) } + @Test(arguments: [false, true]) + func mutationSnapshotRejectsExternalCommitWithoutSavingRevisions( + afterSnapshot: Bool, + ) async throws { + let container = try SwiftDataStore.makeContainer(storage: .inMemory) + let store = SwiftDataStore(modelContainer: container) + let sample = LocationSample( + timestamp: Date(timeIntervalSince1970: 60), + coordinate: Coordinate(latitude: 0, longitude: 0), + horizontalAccuracy: 5, + source: .gpsSignificantChange, + ) + try await store.perform { try await store.add(sample: sample) } + let correction = SampleAttributionRevision( + id: UUID(), + sampleID: sample.id, + updatedAt: Date(timeIntervalSince1970: 600), + replacementRegions: [], + ) + let (started, startedContinuation) = AsyncStream.makeStream(of: Void.self) + let (release, releaseContinuation) = AsyncStream.makeStream(of: Void.self) + let pause: @Sendable () async -> Void = { + startedContinuation.yield() + startedContinuation.finish() + for await _ in release { + break + } + } + let writer = Task { + try await store.perform(expectedDataGenerationID: .initial) { + try await store.readSnapshot { + _ = try await store.allSamples() + if !afterSnapshot { await pause() } + } + if afterSnapshot { await pause() } + // A later snapshot must retain the first snapshot's guard, + // even if its own reads now include the external commit. + try await store.readSnapshot { + _ = try await store.allManualDays() + } + try await store.addSampleAttributionRevision(correction) + } + } + for await _ in started { + break + } + + let remoteDay = DayPresence( + date: sample.timestamp, + in: Self.calendar, + regions: [.newYork], + isAuthoritative: true, + ) + let remoteContext = ModelContext(container) + remoteContext.insert(SDManualDay(value: remoteDay, generationID: .initial)) + try remoteContext.save() + // The rows are durable, but no remote notification has been delivered. + releaseContinuation.yield() + releaseContinuation.finish() + + await #expect(throws: WhereStoreReadConflictError.changedDuringTransaction) { + try await writer.value + } + let inspectionContext = ModelContext(container) + #expect(try inspectionContext.fetch(FetchDescriptor()).isEmpty) + #expect(try await store.allSamples() == [sample]) + #expect(try await store.allManualDays() == [remoteDay]) + #expect(try await store.dataGeneration().id == .initial) + } + + @Test func mutationSnapshotsAllowOwnStagedWritesWithoutExternalCommit() async throws { + let store = try SwiftDataStore.inMemory() + let sample = LocationSample( + timestamp: Date(timeIntervalSince1970: 60), + coordinate: Coordinate(latitude: 0, longitude: 0), + horizontalAccuracy: 5, + source: .gpsSignificantChange, + ) + let correction = SampleAttributionRevision( + id: UUID(), + sampleID: sample.id, + updatedAt: Date(timeIntervalSince1970: 600), + replacementRegions: [], + ) + try await store.perform(expectedDataGenerationID: .initial) { + try await store.readSnapshot { + let samples = try await store.allSamples() + #expect(samples.isEmpty) + } + try await store.add(sample: sample) + try await store.addSampleAttributionRevision(correction) + try await store.readSnapshot { + let samples = try await store.allSamples() + let revisions = try await store.allSampleAttributionRevisions() + #expect(samples == [sample]) + #expect(revisions == [correction]) + } + } + #expect(try await store.allSamples() == [sample]) + #expect(try await store.allSampleAttributionRevisions() == [correction]) + } + @Test func readSnapshotRejectsCommitBeforeNotification() async throws { let source = ScriptedStoreRemoteChangeSource() let container = try SwiftDataStore.makeContainer(storage: .inMemory) diff --git a/Where/WhereCore/Tests/WhereServices+IntentsTests.swift b/Where/WhereCore/Tests/WhereServices+IntentsTests.swift index 437470239..d4225672c 100644 --- a/Where/WhereCore/Tests/WhereServices+IntentsTests.swift +++ b/Where/WhereCore/Tests/WhereServices+IntentsTests.swift @@ -43,6 +43,44 @@ struct WhereServicesIntentsTests { /// fresh install, is the regression this pins); only the location source /// differs — the stack wires the idle source, so intents never start GPS. struct WhereServicesForIntentsSharingTests { + @Test func flightCorrectionAndLaterSameDayPresenceReachReportsWidgetsAndIntents() async throws { + let h = try await SampleCorrectionTestSupport.completedFlight() + let proposal = try await h.proposal() + let result = try await h.coordinator.apply(proposal) + guard case .applied = result else { + Issue.record("The completed flight correction must apply") + return + } + let later = FlightTrajectoryFixtures.sample(501, minutes: 240, east: 1505) + try await h.store.perform { try await h.store.add(sample: later) } + + let expected = proposal.resultingRegions.union([.canada]) + let intents = WhereServices.forIntents(sharingStoreOf: h.services) + let report = try await h.reader.yearReport(for: h.day.year) + let intentReport = try await intents.reports.yearReport(for: h.day.year) + let widget = try await h.widgets.snapshot(asOf: later.timestamp) + #expect(report.days.first?.regions == expected) + #expect(intentReport.days == report.days) + #expect(widget.dayRegions == expected) + #expect(widget.totals == report.totals) + #expect(try await h.reader.manualDays(inYear: h.day.year).isEmpty) + #expect(try await h.store.sampleAttributionRevisions(for: [later.id]).isEmpty) + + let maps = try await h.reader.locations(onDay: h.day) + #expect(maps[.canada]?.map(\.coordinate) == [later.coordinate]) + let originalSamples = FlightTrajectoryFixtures.turningFlight().samples + let excludedIDs = Set(proposal.edits.filter(\.replacementRegions.isEmpty).map(\.sampleID)) + let excludedCoordinates = Set(originalSamples.filter { excludedIDs.contains($0.id) } + .map(\.coordinate)) + let mappedCoordinates = Set(maps.values.flatMap { $0.map(\.coordinate) }) + #expect(mappedCoordinates.isDisjoint(with: excludedCoordinates)) + #expect(try await h.reader.representativeCoordinates(for: h.day.year)[.canada] == later + .coordinate) + #expect(try await h.reader.locations(in: .canada, year: h.day.year).first?.points + .map(\.coordinate) == [later.coordinate]) + #expect(try await h.store.allSamples().count == originalSamples.count + 1) + } + @Test func sharedStackRidesTheBaseServicesStore() async throws { let store = try SwiftDataStore.inMemory() let base = try await WhereServices.makeForIntents(store: store) diff --git a/Where/WhereCore/Tests/WhereServicesTests.swift b/Where/WhereCore/Tests/WhereServicesTests.swift index a1ad9d8be..da59c7b25 100644 --- a/Where/WhereCore/Tests/WhereServicesTests.swift +++ b/Where/WhereCore/Tests/WhereServicesTests.swift @@ -1832,6 +1832,20 @@ private actor ToggleFailingStore: WhereStore { try await backing.allSamples() } + func sampleAttributionRevisions(for sampleIDs: Set) async throws + -> [SampleAttributionRevision] + { + try await backing.sampleAttributionRevisions(for: sampleIDs) + } + + func allSampleAttributionRevisions() async throws -> [SampleAttributionRevision] { + try await backing.allSampleAttributionRevisions() + } + + func addSampleAttributionRevision(_ revision: SampleAttributionRevision) async throws { + try await backing.addSampleAttributionRevision(revision) + } + func recordingDevices() async throws -> [RecordingDevice] { try await backing.recordingDevices() } diff --git a/Where/WhereUI/AGENTS.md b/Where/WhereUI/AGENTS.md index a6cc79584..34543827c 100644 --- a/Where/WhereUI/AGENTS.md +++ b/Where/WhereUI/AGENTS.md @@ -85,6 +85,13 @@ Layering, localization, preview, and testing conventions live in the feature Project Locations-card GPS points through the cache's shared `RegionArtworkProjection`. Never project, simplify, or spatially reduce artwork in a card's `body`. +- Publish flight notices, correction reviews, and actionable counts from one + `YearReportModel` scan result. Re-key on raw evidence changes; never gate refresh + on aggregate-report equality. Schedule presentation deadlines only in the foreground. +- Route automatic GPS fixes through Core's sample-correction coordinator. Keep + stale Apply results open for review and preserve manual editing while arrival is pending. +- Use this installation's assessment for a live flight notice. Identify each + recording device in shared reviews and retain historical pending review access. - Keep Locations-card points on `YearReportModel`'s loaded `YearReportDetails`. - Keep `RootView` passing LifecycleKitUI the stylesheet's positive splash diff --git a/Where/WhereUI/README.md b/Where/WhereUI/README.md index 90ef8a0e8..b063b6df7 100644 --- a/Where/WhereUI/README.md +++ b/Where/WhereUI/README.md @@ -134,6 +134,23 @@ the feature [`Where/AGENTS.md`](../AGENTS.md) and this module's services or presentation state. None reimplements Core rules. +### Flight status and GPS corrections + +Locations shows a quiet notice for this installation's recent flight evidence. +The Resolve list keeps pending and completed flight reviews separate from +its actionable issue count. A shared review shows recording devices, the last +observation, flight evidence, and the exact sample edits. Arrival must be +supported before Apply is offered; manual day editing stays available. + +`YearReportModel` publishes one `DataIssueScanResult` for the banner, list, +badge, and open detail. Committed raw GPS writes refresh it even when region +totals do not change. Foreground-only deadlines update stale notices and remove +live notices after 24 hours; unresolved historical reviews remain accessible. +`FlightReviewModel` submits reviewed proposals to Core and keeps a changed +proposal on screen for review instead of reporting a stale Apply as success. +Reset to GPS restores both manual and sample attribution through the existing +journal intent. + ### Reusable views & styling - **`RegionWelcomeCard`** — an app-wide overlay over the selected tab that combines a region's emoji, diff --git a/Where/WhereUI/SnapshotTests/FlightStatusBannerSnapshotTests.swift b/Where/WhereUI/SnapshotTests/FlightStatusBannerSnapshotTests.swift new file mode 100644 index 000000000..2cb34d3a8 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/FlightStatusBannerSnapshotTests.swift @@ -0,0 +1,10 @@ +import SnapshotKitTesting +import Testing +@testable import WhereUI + +@MainActor +struct FlightStatusBannerSnapshotTests { + @Test func flightStatusBanner() async { + await assertSnapshots(of: FlightStatusBanner.self) + } +} diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DayRelabelViewSnapshotTests/dayRelabel.Default_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DayRelabelViewSnapshotTests/dayRelabel.Default_iPad.png index 9865c8481..e5f4d7f25 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/DayRelabelViewSnapshotTests/dayRelabel.Default_iPad.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DayRelabelViewSnapshotTests/dayRelabel.Default_iPad.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:821ba3357d58cde6d803438932019042c9b9b5d2c66d6746dce6149f94415e61 -size 1367923 +oid sha256:c86e229a1ed770dafdb6f20b802fb5c28dce84625b9967f6562922de0ae75aff +size 1359977 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DayRelabelViewSnapshotTests/dayRelabel.Default_iPad_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DayRelabelViewSnapshotTests/dayRelabel.Default_iPad_accessibility.png index d881941dc..dc3627a97 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/DayRelabelViewSnapshotTests/dayRelabel.Default_iPad_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DayRelabelViewSnapshotTests/dayRelabel.Default_iPad_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e440c86254c3a6968f4e0e954f1f312267c69e2fe87d938336ceae943e8cd115 -size 2682990 +oid sha256:58c40f318de4cbd889dd8f02cc043c395bee9164a6cf87fbb34cc3ef4c74edd6 +size 2671039 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DayRelabelViewSnapshotTests/dayRelabel.Default_iPad_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DayRelabelViewSnapshotTests/dayRelabel.Default_iPad_ax5.png index 6244a5d57..37f57237d 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/DayRelabelViewSnapshotTests/dayRelabel.Default_iPad_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DayRelabelViewSnapshotTests/dayRelabel.Default_iPad_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5f5b012b1b7408359c6db3b76188f3c3916ec3b8b3d4e473fb2ff85ba7a84b02 -size 3676007 +oid sha256:3aef56c1fce1b3feb3f124f5680573e9de5081e7fd3e45520691d9d94ccfaf92 +size 3652068 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DayRelabelViewSnapshotTests/dayRelabel.Default_iPad_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DayRelabelViewSnapshotTests/dayRelabel.Default_iPad_contrast.png index cfd53202b..52c547950 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/DayRelabelViewSnapshotTests/dayRelabel.Default_iPad_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DayRelabelViewSnapshotTests/dayRelabel.Default_iPad_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3425ef90d63b5e036b4a3d233df6385de40441833862d026adad163b71254bae -size 1385488 +oid sha256:a384c371a6f25ca9c7b338c8d6b38d12bcbf55f4762905100d9187f7de19a816 +size 1378245 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DayRelabelViewSnapshotTests/dayRelabel.Default_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DayRelabelViewSnapshotTests/dayRelabel.Default_iPad_dark.png index acd74c7d0..6470468a8 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/DayRelabelViewSnapshotTests/dayRelabel.Default_iPad_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DayRelabelViewSnapshotTests/dayRelabel.Default_iPad_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1cb170408021397400753fbb4ec925e4f8476d161f313e23e23a1d957fbe1cf1 -size 1405097 +oid sha256:3dba7de7fa688d47e1df8a36db584a8127a77231c0ee40d5ed4918c02f4f9e6c +size 1396956 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DayRelabelViewSnapshotTests/dayRelabel.Default_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DayRelabelViewSnapshotTests/dayRelabel.Default_iPhone.png index 33cf0c818..2ccd2d2b2 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/DayRelabelViewSnapshotTests/dayRelabel.Default_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DayRelabelViewSnapshotTests/dayRelabel.Default_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3d4a3b4a1ddc250f1536a783bb95f6ee142450f3a7b628d4a4e7f2f9241b09c6 -size 1113396 +oid sha256:63d2a6d739609c930ebb44bcb38644ef70917bb7a8b2499b10ec7b8a619ec347 +size 1107224 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DayRelabelViewSnapshotTests/dayRelabel.Default_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DayRelabelViewSnapshotTests/dayRelabel.Default_iPhone_accessibility.png index 9065511f8..39d759d0e 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/DayRelabelViewSnapshotTests/dayRelabel.Default_iPhone_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DayRelabelViewSnapshotTests/dayRelabel.Default_iPhone_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:69fe47b7f24e49b5636ef9ada120e4a5bc59000bd0c73a6a441e80cbd87b4b2c -size 2370724 +oid sha256:b585c168db611c4a3524fe9126f59830aa1379b7aec19805bd6a011575e40652 +size 2360597 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DayRelabelViewSnapshotTests/dayRelabel.Default_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DayRelabelViewSnapshotTests/dayRelabel.Default_iPhone_ax5.png index 3c26970a7..9ce273fb3 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/DayRelabelViewSnapshotTests/dayRelabel.Default_iPhone_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DayRelabelViewSnapshotTests/dayRelabel.Default_iPhone_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d05aff90ac82f9eaeb947fe33b2dac4f2ef32675e55b04c6a4d0eb78e94e50f9 -size 3562870 +oid sha256:f579acfe7125d9547008880ec847076e57640374009fcffbf0a6e4581f9b25a9 +size 3543013 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DayRelabelViewSnapshotTests/dayRelabel.Default_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DayRelabelViewSnapshotTests/dayRelabel.Default_iPhone_contrast.png index 6f8f466f5..8cb077317 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/DayRelabelViewSnapshotTests/dayRelabel.Default_iPhone_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DayRelabelViewSnapshotTests/dayRelabel.Default_iPhone_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:184474eb4cf47d99bdd5674214da944e7588c87eb1d7b8cdce825cde98b36a53 -size 1130560 +oid sha256:42fb6fb3f093e4ae426c96019c40d00b2e048411582083595f069763c53cda74 +size 1124455 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DayRelabelViewSnapshotTests/dayRelabel.Default_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DayRelabelViewSnapshotTests/dayRelabel.Default_iPhone_dark.png index ca78ef9b4..0d0dd5268 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/DayRelabelViewSnapshotTests/dayRelabel.Default_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DayRelabelViewSnapshotTests/dayRelabel.Default_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d244e603d954959cc6052a5cf176a260a13776fb2588727572695236301deef7 -size 1123134 +oid sha256:d0b34a42e8996421c496c35c60377fbf0a7bdc25b8308a287b75dfc03af9a2d5 +size 1116884 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DayRelabelViewSnapshotTests/dayRelabel.WithAudit_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DayRelabelViewSnapshotTests/dayRelabel.WithAudit_iPhone.png index 7a8608185..7313a9ae1 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/DayRelabelViewSnapshotTests/dayRelabel.WithAudit_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DayRelabelViewSnapshotTests/dayRelabel.WithAudit_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ff807d2c82d26acf4307a450f67e66ea752aecd87b74b31957d52cf21418aa19 -size 1229819 +oid sha256:5257e9ee70a9a6dba4d35e767ce1e52e811d7ec03e19ae959b7da513a20fdf65 +size 1223669 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/DayRelabelViewSnapshotTests/dayRelabel.WithAudit_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/DayRelabelViewSnapshotTests/dayRelabel.WithAudit_iPhone_dark.png index 9e9c5a5b0..7e2355d13 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/DayRelabelViewSnapshotTests/dayRelabel.WithAudit_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/DayRelabelViewSnapshotTests/dayRelabel.WithAudit_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:385e91bcb7e01012f0b2da4fc9b512a61450eae82e65731b268d7feb1bbd65f7 -size 1261832 +oid sha256:1463f2ff3ce2a06a980d82e3fd73ff048f4585d75d11421502d73303a8e02f1f +size 1254280 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Completed_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Completed_iPad.png new file mode 100644 index 000000000..a57c4d018 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Completed_iPad.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7dc20a084ea454cd67d8262bb54a2e0aca046a97095e6dea1f37ebebd861d30a +size 303344 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Completed_iPad_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Completed_iPad_accessibility.png new file mode 100644 index 000000000..a62ebcd91 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Completed_iPad_accessibility.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:be75514cb11783ff682d33de5310805da28542b2f5617f7c47986f7c9caa2f34 +size 549767 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Completed_iPad_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Completed_iPad_ax5.png new file mode 100644 index 000000000..412180caa --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Completed_iPad_ax5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:13659049eb7c630ae40bffc86b22bc9581352385c0457004f47ab9675ca27404 +size 687716 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Completed_iPad_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Completed_iPad_contrast.png new file mode 100644 index 000000000..183b715a9 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Completed_iPad_contrast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b803db8614f8675bab6e5b779d5d3dec996bf494b3654539f4bac7a4807fc5c0 +size 303701 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Completed_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Completed_iPad_dark.png new file mode 100644 index 000000000..35ee27a5f --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Completed_iPad_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b467f2d8e3ca3d0c6a2a3f3aceda75e2213f5f029c6ba39d4b2cadbd03139466 +size 308593 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Completed_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Completed_iPhone.png new file mode 100644 index 000000000..3ae4316b7 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Completed_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2058432274bc1fff20fbfa65e0deb78fb5f352329dcd056d0d78f50845264908 +size 196464 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Completed_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Completed_iPhone_accessibility.png new file mode 100644 index 000000000..97667bf07 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Completed_iPhone_accessibility.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9c505b8328a7fa0c29096db3027149e6945d4eb2d2763aa75c19906b82db47d0 +size 414221 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Completed_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Completed_iPhone_ax5.png new file mode 100644 index 000000000..0f051d54f --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Completed_iPhone_ax5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:731a9bb22a3f5a99c085f6510c63f6f39631f8dd631953a355dbf73e43a347e4 +size 689394 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Completed_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Completed_iPhone_contrast.png new file mode 100644 index 000000000..08fc1fde1 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Completed_iPhone_contrast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:50b6409ffc4841dd5b3ab7e9ef3929ab45baf3b29862e55a7a1b3aa4f98331af +size 198967 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Completed_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Completed_iPhone_dark.png new file mode 100644 index 000000000..360cd4f1f --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Completed_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fee699e3057f3adc3e1bc46609592b846fd13775cb08b5a5070020d13a793567 +size 201923 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Default_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Default_iPad.png index c1f56d1c8..34117286c 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Default_iPad.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Default_iPad.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:36d2c2db85aea5b19abdda0855bb991bee117264cde930cb2f9b5a8a01dce6ea -size 291695 +oid sha256:b83c28ea55f84d192adfbd13c92ea069d8601613a3624baed165e86e734d7d2c +size 385215 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Default_iPad_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Default_iPad_accessibility.png index 6d91c9847..8d95e0ef4 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Default_iPad_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Default_iPad_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0fea7852e24615968641c60f8a40db9336de7cc1f8082382b207bcda07182e67 -size 531261 +oid sha256:1bbb20fee385d0bfc976dc615b52f5c302424cb38de392e9c438c79e2b5dc071 +size 729186 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Default_iPad_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Default_iPad_ax5.png index fa803eddd..764482153 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Default_iPad_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Default_iPad_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e915053c1b9d84c360009a9d52354dca32ff4efd20616aba0b6ddf9af75722aa -size 619901 +oid sha256:54c42b001ee8c5c85c4a67614dc68fb6db9f31ad274aa5c0790753bfbd4f8b9a +size 1024737 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Default_iPad_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Default_iPad_contrast.png index ace4b6e30..0209ce988 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Default_iPad_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Default_iPad_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7dde5c7c9e36e828d4a8081c1224e550a3fdab5cf4559a3acbc59b51fd8c4811 -size 293492 +oid sha256:ea71673bb236b19f7c39882473e4177bb137f5af9b7b0fd2f55dce935abe61a0 +size 387107 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Default_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Default_iPad_dark.png index 79053ff67..1dd7c2a13 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Default_iPad_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Default_iPad_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5187aea0fd106f3dfec7517cd91912d43e1589fd2ff2711ed5e41b137d66af23 -size 295590 +oid sha256:5f3fc647b810607ad5eb96ab2ea3c88a6a151fcf24c66bab0b7b36d2186aa813 +size 393081 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Default_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Default_iPhone.png index 4987351a9..ecb1ee61b 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Default_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Default_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1f24320e0b6aa62bde0b11247a22f0fdef45c70abae8ce3638a84e4e055c6e2d -size 187864 +oid sha256:7bf5d899e06a94480042851a36d43a0d86c4fb902ec3675455c57bfcea2556e0 +size 295988 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Default_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Default_iPhone_accessibility.png index e0852c8e6..a9238613f 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Default_iPhone_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Default_iPhone_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2a742b3a32d9cd63eb545acf2ffe7006823aae9b8f4b776379270c2e2d50c135 -size 397706 +oid sha256:5f1c0057b5ececc57f33bd52c4eb3e3a20c150d46a2720660efd7e2943e86691 +size 624188 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Default_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Default_iPhone_ax5.png index e08dacbd9..0ae24a370 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Default_iPhone_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Default_iPhone_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:07b92e13b5e1ebf7f78db9690de6c83bfe1fe3d25d9d88874a9144e8513cc9fd -size 617045 +oid sha256:0d8d7237e7ca426c6229a65342a3ab9461d52f0ee7f072d79eef6cb515a63a61 +size 1083610 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Default_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Default_iPhone_contrast.png index 8a5cef0f9..6c766a274 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Default_iPhone_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Default_iPhone_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:dd88736b5680de3f8c559d2c07c2b209b68914792a0794452bfa272b49d50de9 -size 189276 +oid sha256:a682b73aaef58d2b0e76d54cdd03e3ff02360e0760b526e7707141ceff810b2f +size 298933 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Default_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Default_iPhone_dark.png index bb2868df9..aac7372fc 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Default_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Default_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6c6f318c44612a5f422ea6d7fb6a37a60f126f5510c8862eacef27c0e0c9dba1 -size 191802 +oid sha256:198542194e2183c8a428cf19b42dec6c692e7060362158e3fbf5645c8df3ea72 +size 305146 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.FlightLikely_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.FlightLikely_iPad.png new file mode 100644 index 000000000..afb96b22b --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.FlightLikely_iPad.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cae5c4de4fa91f7ba4ce7732e46562c79a0e2d9b01c3b815a9973b59e946f0da +size 305483 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.FlightLikely_iPad_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.FlightLikely_iPad_accessibility.png new file mode 100644 index 000000000..8f955033e --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.FlightLikely_iPad_accessibility.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:57b3645547ea797967f6107166cc512a018d8f105942bb089b182cb21204b9f5 +size 557376 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.FlightLikely_iPad_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.FlightLikely_iPad_ax5.png new file mode 100644 index 000000000..3d19c7fd7 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.FlightLikely_iPad_ax5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fddea2069409b73a582cefec94b782bc0affcebcc34407ea42389b345633ea8a +size 715937 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.FlightLikely_iPad_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.FlightLikely_iPad_contrast.png new file mode 100644 index 000000000..62d981ea1 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.FlightLikely_iPad_contrast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2794cacad87ae84f497aec4c981cdd5fc2b691be32f3f2faf39513ac4ef08f19 +size 305884 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.FlightLikely_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.FlightLikely_iPad_dark.png new file mode 100644 index 000000000..f69e0156f --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.FlightLikely_iPad_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e45ba3c25d00c25d0959a3abae8b1bb60564304c41df3a8ffdc0e419f79e4eae +size 310595 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.FlightLikely_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.FlightLikely_iPhone.png new file mode 100644 index 000000000..b52e6cc16 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.FlightLikely_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:08cbeea9b06eaa887a6b1ebef3ac47d6e91cba6c63dd4734f53bb120c3531d3e +size 199933 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.FlightLikely_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.FlightLikely_iPhone_accessibility.png new file mode 100644 index 000000000..fbe080619 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.FlightLikely_iPhone_accessibility.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:adaf7f41edf8b601c349acf86eda21ff9e950d1f188da55cbcc27cf2669f9311 +size 422955 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.FlightLikely_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.FlightLikely_iPhone_ax5.png new file mode 100644 index 000000000..25729fe09 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.FlightLikely_iPhone_ax5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9e915388139e0e8d2f8650251f930b7d281d3a017254887ec1c2e25250ae665a +size 716818 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.FlightLikely_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.FlightLikely_iPhone_contrast.png new file mode 100644 index 000000000..401962cdf --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.FlightLikely_iPhone_contrast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5f5f3fb51a6fa483ceca1333f60ede6c669462134e7a3238c8937b24adb237c7 +size 202076 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.FlightLikely_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.FlightLikely_iPhone_dark.png new file mode 100644 index 000000000..b9d1f5cb1 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.FlightLikely_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1816a51b0535971a9d482e357b61a37df938ae954b22e58b4e0ebddef7d0d114 +size 205626 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.NoPresence_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.NoPresence_iPad.png new file mode 100644 index 000000000..a4bd287f3 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.NoPresence_iPad.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:86a0fa33ce2fc7e4ad010c855c1527725dedd0899049d3fe20ed517056b61112 +size 387045 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.NoPresence_iPad_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.NoPresence_iPad_accessibility.png new file mode 100644 index 000000000..d6feb95d5 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.NoPresence_iPad_accessibility.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:df0f160e0d09a6970c636f43ef5953c1d257cd4110e884547a1be240dffe5485 +size 731753 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.NoPresence_iPad_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.NoPresence_iPad_ax5.png new file mode 100644 index 000000000..e5cef850f --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.NoPresence_iPad_ax5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c70edc78d3ed10d7a13710151c9009f0948c3b4ecd91957794a8d89c03c458f5 +size 1027986 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.NoPresence_iPad_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.NoPresence_iPad_contrast.png new file mode 100644 index 000000000..d9d899c42 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.NoPresence_iPad_contrast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f20acc0bda453dc699c63436b39ebb2c292e38fe1e851b3baa2a7438bc40471f +size 388532 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.NoPresence_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.NoPresence_iPad_dark.png new file mode 100644 index 000000000..2184c43e4 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.NoPresence_iPad_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2d1cc7c135275a4b52ac82c2be000d55cebe44feab1415661a8e89ba83c6125b +size 394483 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.NoPresence_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.NoPresence_iPhone.png new file mode 100644 index 000000000..3199b5789 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.NoPresence_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e553109055a4cd66cfbfbb6c7fdd43e1a0c778310c4e5d3a15c0616ac6c4c7db +size 295494 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.NoPresence_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.NoPresence_iPhone_accessibility.png new file mode 100644 index 000000000..824589f51 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.NoPresence_iPhone_accessibility.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1786541339e1c71ab74e11f2e4b900caa655a7cd5a7a71d0b17a949836bdc634 +size 626597 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.NoPresence_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.NoPresence_iPhone_ax5.png new file mode 100644 index 000000000..79b27dab7 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.NoPresence_iPhone_ax5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:14456b9c94c74c4aa799df9dcededaa8a045a1dba06e8ab6c43593b4780b4af3 +size 1096425 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.NoPresence_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.NoPresence_iPhone_contrast.png new file mode 100644 index 000000000..2c983a82e --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.NoPresence_iPhone_contrast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d382ee3c60d9d06e88ea90504807cbfad5cc9779c7426a8ea72f20d283920b1b +size 299147 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.NoPresence_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.NoPresence_iPhone_dark.png new file mode 100644 index 000000000..2ab6da12d --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.NoPresence_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:73166fb1ae75c156a4810c22d020c114598e5d0d199cc2bdd2b76b68e17b01cb +size 306201 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Stale_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Stale_iPad.png new file mode 100644 index 000000000..710e79856 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Stale_iPad.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a46c2221d09a06843969a1883fe740654b05daf55fe00d1d8da9d10836fa31b2 +size 311539 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Stale_iPad_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Stale_iPad_accessibility.png new file mode 100644 index 000000000..22e38df46 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Stale_iPad_accessibility.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1e07b3036895ec8841ca843a1fb27203f94ca6dedd95e388e58d367600b852d0 +size 573541 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Stale_iPad_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Stale_iPad_ax5.png new file mode 100644 index 000000000..558bf2c5e --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Stale_iPad_ax5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ad21d3e7554ab8202d6033eea9eda54447d5cd71d67a71e056d7b8914855385b +size 780602 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Stale_iPad_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Stale_iPad_contrast.png new file mode 100644 index 000000000..1d2d61874 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Stale_iPad_contrast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:325a1b2c5aaa6a79270dc5e047b83e635d234b5ab04b96668e6245bd8f37db3b +size 312957 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Stale_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Stale_iPad_dark.png new file mode 100644 index 000000000..5228c86b6 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Stale_iPad_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4048e9d995a393fcf9ede1739277eefb0bce6d05fdfaf1318cc02df5fa9acc66 +size 317136 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Stale_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Stale_iPhone.png new file mode 100644 index 000000000..6373f4a70 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Stale_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c531244105fc209381adee14ec49ca95ecc23c23a866fa844f0927561d3cf90c +size 207705 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Stale_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Stale_iPhone_accessibility.png new file mode 100644 index 000000000..6cad10c92 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Stale_iPhone_accessibility.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0532120a4cc5b36b2f380b976a0278e994577f154d94613d19b80189c284edd4 +size 438507 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Stale_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Stale_iPhone_ax5.png new file mode 100644 index 000000000..628fcad3d --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Stale_iPhone_ax5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:98b3878d1d015f0dbdb1b28463ccf9c7634eb22f4da2ae8a132fa02335849962 +size 788855 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Stale_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Stale_iPhone_contrast.png new file mode 100644 index 000000000..3536e5ca8 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Stale_iPhone_contrast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:54c1ad431ac9236825b01a0871ac2b19af29d2745247ef8b592ea4afa21f3fe2 +size 209990 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Stale_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Stale_iPhone_dark.png new file mode 100644 index 000000000..354f481ec --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Stale_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:983193d683de7c7c7bc8be4c6a1ff7217dd41e02381dfafa2266f70ea00334cb +size 213028 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Waiting_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Waiting_iPad.png new file mode 100644 index 000000000..f204e2397 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Waiting_iPad.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6c430758f30cc770ca1a4dbcefb1f45383e925b55cdd8dac8d72e0fff3be4578 +size 311489 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Waiting_iPad_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Waiting_iPad_accessibility.png new file mode 100644 index 000000000..021b6cc2e --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Waiting_iPad_accessibility.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a2515d4d9f829066901ad1e83e34e1214256e093ba368b54111de18bc5ed98b8 +size 573427 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Waiting_iPad_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Waiting_iPad_ax5.png new file mode 100644 index 000000000..ef5b19d80 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Waiting_iPad_ax5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a5160f009a399ab792b715826b591e5513cb7ced9a570114999f9b5ab727035e +size 780513 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Waiting_iPad_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Waiting_iPad_contrast.png new file mode 100644 index 000000000..acd4aef55 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Waiting_iPad_contrast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:160571740594d6d5aaa7ede29138bb079ed660722e2a37ed5f52125e7ad458c9 +size 312906 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Waiting_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Waiting_iPad_dark.png new file mode 100644 index 000000000..547f01e98 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Waiting_iPad_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2ab22287a00a52d7ea9e3f07b5afc73b04004099250e80d031c67cc080dae61c +size 317086 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Waiting_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Waiting_iPhone.png new file mode 100644 index 000000000..388cc622e --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Waiting_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:765f8dd3e2949b2de48ae888c303f474255d8f5025a09b12706808883c001239 +size 207677 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Waiting_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Waiting_iPhone_accessibility.png new file mode 100644 index 000000000..dfe2538f2 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Waiting_iPhone_accessibility.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a98b4bf3435393a780809518f82262a71fe47665391c598ceb113e6c1483cdbb +size 438346 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Waiting_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Waiting_iPhone_ax5.png new file mode 100644 index 000000000..b3cb4668e --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Waiting_iPhone_ax5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e3c1febb33c3fd8e5f05901a9a702325e35f72e5419e6f861c31e0fa7964ccd2 +size 789836 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Waiting_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Waiting_iPhone_contrast.png new file mode 100644 index 000000000..5a773048f --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Waiting_iPhone_contrast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:34b4265dfe530fdb62d6ba808e9f68c6e3d3bc041d910ab1ac182d5120dceef3 +size 209953 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Waiting_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Waiting_iPhone_dark.png new file mode 100644 index 000000000..06088caaf --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightDayDetailViewSnapshotTests/flightDayDetail.Waiting_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1eb7a8906d851f3fd8cce3d2ed77003826faa613adc82d5030cbe8368a7e97d0 +size 212968 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Completed_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Completed_iPad.png new file mode 100644 index 000000000..c0d844b9c --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Completed_iPad.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3d4652c431dd1a2d2a176b6d2ba1adc3e05410796f190134fb91da634105eb3e +size 208502 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Completed_iPad_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Completed_iPad_accessibility.png new file mode 100644 index 000000000..9ebebb0fe --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Completed_iPad_accessibility.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6fa382dfd34314265f546b9593b6ffa0ebc3790057519ceb8e9e537489602a2e +size 361823 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Completed_iPad_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Completed_iPad_ax5.png new file mode 100644 index 000000000..f6cb37838 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Completed_iPad_ax5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ee11f23ba35e4f16144d739d30dd7c95c50d7f628d966752c9ce22954333cd41 +size 306177 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Completed_iPad_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Completed_iPad_contrast.png new file mode 100644 index 000000000..10572f846 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Completed_iPad_contrast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9cbc997a14c36911fde9a762f682291126d83f986b6bbb9a51eece78f41626ac +size 209352 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Completed_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Completed_iPad_dark.png new file mode 100644 index 000000000..66385a165 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Completed_iPad_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a3ccfc7d6dc66479f5a44f56a8ba0377e094867cd389388cb83300feb7dfd5f7 +size 231916 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Completed_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Completed_iPhone.png new file mode 100644 index 000000000..5ca984f2d --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Completed_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8fee16d232125ce50c068146fda11804a5eb886092009fa4fc242728335b3664 +size 104152 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Completed_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Completed_iPhone_accessibility.png new file mode 100644 index 000000000..682564aef --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Completed_iPhone_accessibility.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3f09a21b767fe513e35e9e660fc4052d668594282d3eaa60cb275e6967daaf70 +size 226248 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Completed_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Completed_iPhone_ax5.png new file mode 100644 index 000000000..e3adc6703 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Completed_iPhone_ax5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ee0c30fcfcf822c98d8bb3c977995ba8bf0eebb744b8588da3c64a0a835b6e3c +size 218045 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Completed_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Completed_iPhone_contrast.png new file mode 100644 index 000000000..95c00dd06 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Completed_iPhone_contrast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:68fc6373ff5a1e8491753e5359541c05f3aa1a2bf7d8997306efac89ca7609f3 +size 105017 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Completed_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Completed_iPhone_dark.png new file mode 100644 index 000000000..dcbd45d02 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Completed_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0bfb88236e41c0cac6386212fe5903ce540e0d5eecae336a5fbf3cec18962a1d +size 115269 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.FlightLikely_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.FlightLikely_iPad.png new file mode 100644 index 000000000..af8f06230 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.FlightLikely_iPad.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7b5b49af1ccacbd28fdabd61ce282f220b0db1e33649942827bce54b25b0c133 +size 210609 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.FlightLikely_iPad_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.FlightLikely_iPad_accessibility.png new file mode 100644 index 000000000..dcd073722 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.FlightLikely_iPad_accessibility.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9b1a122bca56a83992af89fd7449690601e77a0b955d2582e1184ae45b161799 +size 368343 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.FlightLikely_iPad_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.FlightLikely_iPad_ax5.png new file mode 100644 index 000000000..c2f305a40 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.FlightLikely_iPad_ax5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1e74e99a262d88dc0b771b152b71a7187312e649818e5f0cdc5376a373a617de +size 325782 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.FlightLikely_iPad_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.FlightLikely_iPad_contrast.png new file mode 100644 index 000000000..5b855b089 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.FlightLikely_iPad_contrast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:048e33e898bb9ee0a0d0c3867373bde3ffc2ec6a36730dc734ca2f5c82b937d5 +size 211506 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.FlightLikely_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.FlightLikely_iPad_dark.png new file mode 100644 index 000000000..e644de60c --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.FlightLikely_iPad_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9c32cd765cd3064d65f1c38869a3df3e1360c50bbdc4ec951e556d4ebc49942a +size 234102 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.FlightLikely_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.FlightLikely_iPhone.png new file mode 100644 index 000000000..bdcdd5da4 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.FlightLikely_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e25336066b6057e59bf2c05c175c30390f57537a807e6899fb1c34b9c0bb80cf +size 108298 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.FlightLikely_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.FlightLikely_iPhone_accessibility.png new file mode 100644 index 000000000..d603aebd0 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.FlightLikely_iPhone_accessibility.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:981edfe7e5150c120f4a68d171123131eb748901f1db28500de1b83e2075767f +size 234426 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.FlightLikely_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.FlightLikely_iPhone_ax5.png new file mode 100644 index 000000000..61b483dda --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.FlightLikely_iPhone_ax5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:870c92e9b7ecd6578f480364d5dc269ee4956ad37b9ec346b2a74b2fb0c37d96 +size 237964 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.FlightLikely_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.FlightLikely_iPhone_contrast.png new file mode 100644 index 000000000..7fbad0ecd --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.FlightLikely_iPhone_contrast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5ef420ed0a79811e1116ee829dfe97a5df9d3a256b4489c1011722054d0811d7 +size 109668 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.FlightLikely_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.FlightLikely_iPhone_dark.png new file mode 100644 index 000000000..62ec2c221 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.FlightLikely_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f56ba18af4110385a78ccc1890b1f92b7e9c6a678b2e0786283597faa79d6885 +size 119689 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.NoPresence_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.NoPresence_iPad.png new file mode 100644 index 000000000..c8ccb8783 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.NoPresence_iPad.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0871d1dcb666f5e46b05136d4a0f73c122c5e806f4b57d7d9fd7325fac82e134 +size 211918 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.NoPresence_iPad_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.NoPresence_iPad_accessibility.png new file mode 100644 index 000000000..9af1f6723 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.NoPresence_iPad_accessibility.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:73cb63de87a02b408cd276af80c6b7a5c7369435888c5cad21224213ab87d81b +size 368828 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.NoPresence_iPad_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.NoPresence_iPad_ax5.png new file mode 100644 index 000000000..b507081b2 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.NoPresence_iPad_ax5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f76a7ba70d40e115001ec6b7770360b7f0c1bc412a98adbce100e6c2dffe94ad +size 317708 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.NoPresence_iPad_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.NoPresence_iPad_contrast.png new file mode 100644 index 000000000..0d645f1f6 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.NoPresence_iPad_contrast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5d6558613874f61ed7ab7b2a7672b79a0ed0f962582545af033b3c37f3f5b760 +size 212811 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.NoPresence_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.NoPresence_iPad_dark.png new file mode 100644 index 000000000..5586f6c06 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.NoPresence_iPad_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:32ffe9c2408a3f4b222f5d98c58cdd6290b1f810209dd5131c7e0e3e819b173a +size 235494 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.NoPresence_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.NoPresence_iPhone.png new file mode 100644 index 000000000..b8c637d46 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.NoPresence_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c7c2277e2cf5721bee395616113ccd2bee76c30837434060028b4b6f77d9ac47 +size 107919 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.NoPresence_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.NoPresence_iPhone_accessibility.png new file mode 100644 index 000000000..d126ee79e --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.NoPresence_iPhone_accessibility.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2e1ce3837dbaae5751806cd1ad0672a160aac40be3fb267918d3714d2b8e66bf +size 233549 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.NoPresence_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.NoPresence_iPhone_ax5.png new file mode 100644 index 000000000..96a721f63 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.NoPresence_iPhone_ax5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f0179787cf8821ee7c0852e6d78d309a02ed52536665a1d76b5523b827e6dd35 +size 232232 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.NoPresence_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.NoPresence_iPhone_contrast.png new file mode 100644 index 000000000..7c37c1535 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.NoPresence_iPhone_contrast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2dc62b6015ad341df8de54d1d25a93fe78c528d4455117b02f41db5587c64650 +size 109065 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.NoPresence_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.NoPresence_iPhone_dark.png new file mode 100644 index 000000000..74240e2d6 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.NoPresence_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8157993c1de4bfadb89092d76eb43ca3edae80a8731af72a80b7f3652a3192e2 +size 119380 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Ready_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Ready_iPad.png new file mode 100644 index 000000000..c8ccb8783 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Ready_iPad.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0871d1dcb666f5e46b05136d4a0f73c122c5e806f4b57d7d9fd7325fac82e134 +size 211918 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Ready_iPad_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Ready_iPad_accessibility.png new file mode 100644 index 000000000..9af1f6723 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Ready_iPad_accessibility.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:73cb63de87a02b408cd276af80c6b7a5c7369435888c5cad21224213ab87d81b +size 368828 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Ready_iPad_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Ready_iPad_ax5.png new file mode 100644 index 000000000..b507081b2 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Ready_iPad_ax5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f76a7ba70d40e115001ec6b7770360b7f0c1bc412a98adbce100e6c2dffe94ad +size 317708 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Ready_iPad_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Ready_iPad_contrast.png new file mode 100644 index 000000000..0d645f1f6 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Ready_iPad_contrast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5d6558613874f61ed7ab7b2a7672b79a0ed0f962582545af033b3c37f3f5b760 +size 212811 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Ready_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Ready_iPad_dark.png new file mode 100644 index 000000000..5586f6c06 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Ready_iPad_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:32ffe9c2408a3f4b222f5d98c58cdd6290b1f810209dd5131c7e0e3e819b173a +size 235494 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Ready_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Ready_iPhone.png new file mode 100644 index 000000000..b8c637d46 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Ready_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c7c2277e2cf5721bee395616113ccd2bee76c30837434060028b4b6f77d9ac47 +size 107919 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Ready_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Ready_iPhone_accessibility.png new file mode 100644 index 000000000..d126ee79e --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Ready_iPhone_accessibility.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2e1ce3837dbaae5751806cd1ad0672a160aac40be3fb267918d3714d2b8e66bf +size 233549 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Ready_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Ready_iPhone_ax5.png new file mode 100644 index 000000000..96a721f63 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Ready_iPhone_ax5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f0179787cf8821ee7c0852e6d78d309a02ed52536665a1d76b5523b827e6dd35 +size 232232 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Ready_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Ready_iPhone_contrast.png new file mode 100644 index 000000000..7c37c1535 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Ready_iPhone_contrast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2dc62b6015ad341df8de54d1d25a93fe78c528d4455117b02f41db5587c64650 +size 109065 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Ready_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Ready_iPhone_dark.png new file mode 100644 index 000000000..74240e2d6 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Ready_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8157993c1de4bfadb89092d76eb43ca3edae80a8731af72a80b7f3652a3192e2 +size 119380 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Stale_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Stale_iPad.png new file mode 100644 index 000000000..3028e0d98 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Stale_iPad.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cfa28c579a364238c3fa7187b8fa9355807a164f653a4cacd92a171963fa6788 +size 217052 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Stale_iPad_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Stale_iPad_accessibility.png new file mode 100644 index 000000000..e392c2887 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Stale_iPad_accessibility.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fd7e5d1c787f5e32041908c3d838ca6fb7e650ffccaad67879b73ec7071d71db +size 383329 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Stale_iPad_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Stale_iPad_ax5.png new file mode 100644 index 000000000..2c919d066 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Stale_iPad_ax5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d38af4ba143e1779ed3d17f78ce2d903d0e2ce797e2f2d53c90e10d7e7436a4a +size 360881 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Stale_iPad_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Stale_iPad_contrast.png new file mode 100644 index 000000000..278981ac7 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Stale_iPad_contrast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3fc40cf821a4cdc283576d50b1c1906681d94994588a6c8d263d73425cad5a04 +size 218066 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Stale_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Stale_iPad_dark.png new file mode 100644 index 000000000..b7694009a --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Stale_iPad_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a38e6d6b0cf0fcb530ec57f8b37fd7bf2ea49a44c0cfddee276c7df22aae9945 +size 240475 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Stale_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Stale_iPhone.png new file mode 100644 index 000000000..b912611a5 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Stale_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c789ff9704bc65db661d621de4fb14c9b7243b59cef6ea1954970c0f31330508 +size 115525 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Stale_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Stale_iPhone_accessibility.png new file mode 100644 index 000000000..292ac612d --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Stale_iPhone_accessibility.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4728f83cff4cca0f4ea54e9b347f9c7f7b07804b13f9d7cfae883dcb31cbdd88 +size 251632 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Stale_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Stale_iPhone_ax5.png new file mode 100644 index 000000000..ec6e29a26 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Stale_iPhone_ax5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:abf75530285cdc55af63dcc4765f3028f3e827bee40e859692d943c26a512d07 +size 319698 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Stale_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Stale_iPhone_contrast.png new file mode 100644 index 000000000..459346bb7 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Stale_iPhone_contrast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1653ba94f330998bde384233115d25ba91125942851b77ed179309d6c186c160 +size 116619 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Stale_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Stale_iPhone_dark.png new file mode 100644 index 000000000..086f93ee2 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Stale_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0040fba80fc4c1a159c8f7a07e4fc982f3f892a86206f17bb04d721c780d9332 +size 126749 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Waiting_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Waiting_iPad.png new file mode 100644 index 000000000..bc1c7b08f --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Waiting_iPad.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1a12735050f56eb2ed2e54cf1163e9b69d60144454d30ea537d3ef979a313140 +size 217021 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Waiting_iPad_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Waiting_iPad_accessibility.png new file mode 100644 index 000000000..bdc1a8786 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Waiting_iPad_accessibility.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d1f47e7abf54d66330387c1fef11725228f3d61f96711bca5144f57f68340b4f +size 383231 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Waiting_iPad_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Waiting_iPad_ax5.png new file mode 100644 index 000000000..e596ee94b --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Waiting_iPad_ax5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a7723b1361e7fc77f0e757121a236ef9a3cf8b7126b33170cc6aa50dc736e3c0 +size 360537 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Waiting_iPad_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Waiting_iPad_contrast.png new file mode 100644 index 000000000..fbe5699a0 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Waiting_iPad_contrast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dc2d2503228a7e442eae46b2df7cd7adc8473d792e526c403438d133b8956837 +size 217997 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Waiting_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Waiting_iPad_dark.png new file mode 100644 index 000000000..a934e2908 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Waiting_iPad_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1de4c0784d4c3e11513322cd13112111194984d21428940e94e778d36f591f37 +size 240398 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Waiting_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Waiting_iPhone.png new file mode 100644 index 000000000..c70f2c679 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Waiting_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1132c76185227f9a418e10171c008804f15822cf373b214a1b0ec4377efb5d8e +size 115478 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Waiting_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Waiting_iPhone_accessibility.png new file mode 100644 index 000000000..dee46d6d5 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Waiting_iPhone_accessibility.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f1ea07b0bc22afd2ce68fee650859a8cb1095634b7ef3a560f8510d59eff03f3 +size 251476 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Waiting_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Waiting_iPhone_ax5.png new file mode 100644 index 000000000..e38482adb --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Waiting_iPhone_ax5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:76bbec400777ab70790a14d07d601aa532872fcff62e1b5ee6b9fe01f4be346c +size 320510 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Waiting_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Waiting_iPhone_contrast.png new file mode 100644 index 000000000..25170e04e --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Waiting_iPhone_contrast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:070061873ac8e76995b0e48b8eef2512d9d05760765045983e4979953682572f +size 116613 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Waiting_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Waiting_iPhone_dark.png new file mode 100644 index 000000000..d30e8cff2 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/FlightStatusBannerSnapshotTests/flightStatusBanner.Waiting_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9d6180dfd4545ed2ec8083a0ddaf3c79c3a122f4239b38c04484069bfb7756db +size 126678 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Flight-Completed_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Flight-Completed_iPhone.png new file mode 100644 index 000000000..1942ca751 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Flight-Completed_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a3a82bc2d78e7b139e4f99894ca6a40a8f6168d7192aff6b5af65baebd259044 +size 2309716 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Flight-Completed_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Flight-Completed_iPhone_dark.png new file mode 100644 index 000000000..c1b7f83c9 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Flight-Completed_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8b6d4050b2010dca8e5ae07d1fae7bc00d7ebc2a61671dfdd7d94421f04fd001 +size 2404468 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Flight-FlightLikely_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Flight-FlightLikely_iPad.png new file mode 100644 index 000000000..31f0dc4bf --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Flight-FlightLikely_iPad.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:34e73394764db3394f92aecbb0db6d3380608b4483c717ec5476935408d6dc6d +size 3645299 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Flight-FlightLikely_iPad_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Flight-FlightLikely_iPad_accessibility.png new file mode 100644 index 000000000..2f9161e76 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Flight-FlightLikely_iPad_accessibility.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4e8315462d1900db8c544778a89d20a77253da55fc9403b3b1dc6ee9d695773c +size 2502066 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Flight-FlightLikely_iPad_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Flight-FlightLikely_iPad_ax5.png new file mode 100644 index 000000000..44561664a --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Flight-FlightLikely_iPad_ax5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:947b4468c3f6e592ba1f77c9118ed4286458df626af2dfdea4e6541d66d786c1 +size 4598465 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Flight-FlightLikely_iPad_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Flight-FlightLikely_iPad_contrast.png new file mode 100644 index 000000000..c2b230552 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Flight-FlightLikely_iPad_contrast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:00c5c41cd4ced504476d65af367576a32d5184d8e9620e495329feb2621660a3 +size 3611436 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Flight-FlightLikely_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Flight-FlightLikely_iPad_dark.png new file mode 100644 index 000000000..66b44e32f --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Flight-FlightLikely_iPad_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a32224cd6bac346a1be213498830908e5d796698df23e7b9ec91507d946a8740 +size 3872417 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Flight-FlightLikely_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Flight-FlightLikely_iPhone.png new file mode 100644 index 000000000..42da268ef --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Flight-FlightLikely_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8b06e20b8b4ead2d255f260728c4999d45249803b68e8b578596447505508652 +size 2313833 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Flight-FlightLikely_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Flight-FlightLikely_iPhone_accessibility.png new file mode 100644 index 000000000..e2bd2e208 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Flight-FlightLikely_iPhone_accessibility.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0f33169de5b00409e53f1d31d6ab15bcb0520824e0cf2443d46a5502dcfd6568 +size 1591697 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Flight-FlightLikely_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Flight-FlightLikely_iPhone_ax5.png new file mode 100644 index 000000000..8ba64e163 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Flight-FlightLikely_iPhone_ax5.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a979261f637c663fee555e1a266df0dc34efefa66ce789281321b82d4d732119 +size 3415654 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Flight-FlightLikely_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Flight-FlightLikely_iPhone_contrast.png new file mode 100644 index 000000000..e594e972c --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Flight-FlightLikely_iPhone_contrast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:39ca8ba3b0e8b4f021bcdc7f617ed25d3de7a38f685ef0cad703150907a61cae +size 2255875 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Flight-FlightLikely_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Flight-FlightLikely_iPhone_dark.png new file mode 100644 index 000000000..1a56c7b23 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Flight-FlightLikely_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:767d0c275d1ca18b659a3a84b9baaa1dbd5222b5e760b813c56add083a1c8ed8 +size 2408791 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Flight-Ready_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Flight-Ready_iPhone.png new file mode 100644 index 000000000..301da0656 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Flight-Ready_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8b2604116d53845cb1e9bc60dea7d258ff8f5bf978a85653b9c169cb6349572c +size 2314874 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Flight-Ready_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Flight-Ready_iPhone_dark.png new file mode 100644 index 000000000..a88b61dce --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Flight-Ready_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5daea26a8a60413a84342a6db90836211f103ce24a4350351be65ddbb41d3714 +size 2410406 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Flight-Stale_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Flight-Stale_iPhone.png new file mode 100644 index 000000000..a6925c5d9 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Flight-Stale_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d14bae6a81bf79e6f886860d824b6f41b80afff500abe169d419ae0748521fe6 +size 2319585 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Flight-Stale_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Flight-Stale_iPhone_dark.png new file mode 100644 index 000000000..086f4436b --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationsViewSnapshotTests/locations.Flight-Stale_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ac1de41c90fe6598e9f026a8046cb3793744cab8fa1751c5212d0500f4b7bed6 +size 2414556 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/ResolutionViewSnapshotTests/resolution.Completed_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/ResolutionViewSnapshotTests/resolution.Completed_iPhone.png new file mode 100644 index 000000000..97a5b8152 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/ResolutionViewSnapshotTests/resolution.Completed_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:742f3d8618bd20a626dfa6abd45431a97fc3ff8ebab9ce61b1d43e53640fb9a7 +size 156109 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/ResolutionViewSnapshotTests/resolution.Completed_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/ResolutionViewSnapshotTests/resolution.Completed_iPhone_dark.png new file mode 100644 index 000000000..193a7671d --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/ResolutionViewSnapshotTests/resolution.Completed_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ccb8c51a037030dd43823c39728836cdd9249a6bd9d42cb1bd0870b69ebb935f +size 148467 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/ResolutionViewSnapshotTests/resolution.Ready_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/ResolutionViewSnapshotTests/resolution.Ready_iPhone.png new file mode 100644 index 000000000..a0eafb1c2 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/ResolutionViewSnapshotTests/resolution.Ready_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8bea30b6673b5367dc4ab175a233ab07010ac5b30a0440f74668b9d517cbc63c +size 125230 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/ResolutionViewSnapshotTests/resolution.Ready_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/ResolutionViewSnapshotTests/resolution.Ready_iPhone_dark.png new file mode 100644 index 000000000..073435096 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/ResolutionViewSnapshotTests/resolution.Ready_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d8bab1e917814ff1e499d2a2f5f3b840914cf42a26f074550d59373a9fd18be3 +size 116060 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/ResolutionViewSnapshotTests/resolution.Waiting_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/ResolutionViewSnapshotTests/resolution.Waiting_iPhone.png new file mode 100644 index 000000000..613e4dc51 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/ResolutionViewSnapshotTests/resolution.Waiting_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1221b0b89119b0bb4304a70bde072089907a344ea0f853ed407aa7de4871a5f9 +size 166711 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/ResolutionViewSnapshotTests/resolution.Waiting_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/ResolutionViewSnapshotTests/resolution.Waiting_iPhone_dark.png new file mode 100644 index 000000000..210752c1a --- /dev/null +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/ResolutionViewSnapshotTests/resolution.Waiting_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f98cf7129543a18421e9d8bdac710da73adbe53e25b443f8aecc8063d7d964a9 +size 159276 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/ResolutionViewSnapshotTests/resolution.WithIssues_iPad.png b/Where/WhereUI/SnapshotTests/__Snapshots__/ResolutionViewSnapshotTests/resolution.WithIssues_iPad.png index 2c770687f..33b56f7a6 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/ResolutionViewSnapshotTests/resolution.WithIssues_iPad.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/ResolutionViewSnapshotTests/resolution.WithIssues_iPad.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7429c819e22c3cf408b886dfec97482ab712f7e16ffd7cef39f569e40df2a1da -size 303715 +oid sha256:52f373ee7f88c0a5e5a44f7ed62299d1d2c5cab719f52b8ee4d56a5549e90c0c +size 299608 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/ResolutionViewSnapshotTests/resolution.WithIssues_iPad_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/ResolutionViewSnapshotTests/resolution.WithIssues_iPad_accessibility.png index ab36a457e..2e538501b 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/ResolutionViewSnapshotTests/resolution.WithIssues_iPad_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/ResolutionViewSnapshotTests/resolution.WithIssues_iPad_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e886d0bb16543241ca091da9630d9d157469aa73ee08d010273630ac7a8e2a9b -size 562897 +oid sha256:bf1b941932985bf5b41d99e7b72b607250ad31b97eb5868bc08125e7c7d6ab6c +size 553347 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/ResolutionViewSnapshotTests/resolution.WithIssues_iPad_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/ResolutionViewSnapshotTests/resolution.WithIssues_iPad_ax5.png index ac6924a42..b01dee4e6 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/ResolutionViewSnapshotTests/resolution.WithIssues_iPad_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/ResolutionViewSnapshotTests/resolution.WithIssues_iPad_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e7e0fa12b96ee119411032e17f6b5a8759c6ba4aedef964287d86544c4410ad7 -size 557791 +oid sha256:e20ad071ff6865b6eb93e3bd9bc97a9ad0be10f769f38cd9b82b685674b4e97e +size 527647 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/ResolutionViewSnapshotTests/resolution.WithIssues_iPad_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/ResolutionViewSnapshotTests/resolution.WithIssues_iPad_contrast.png index e0cbf552f..bb4a8c0e9 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/ResolutionViewSnapshotTests/resolution.WithIssues_iPad_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/ResolutionViewSnapshotTests/resolution.WithIssues_iPad_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:83d3beefa0eca106d2c7fc2106661a338d58f5fa529704a72fe508dd6975b296 -size 306021 +oid sha256:b09020aa4c519e332844f8277c0a451dd612cffd7d3647f5a91f8292098ea854 +size 301822 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/ResolutionViewSnapshotTests/resolution.WithIssues_iPad_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/ResolutionViewSnapshotTests/resolution.WithIssues_iPad_dark.png index 20fb364f9..73651f7ba 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/ResolutionViewSnapshotTests/resolution.WithIssues_iPad_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/ResolutionViewSnapshotTests/resolution.WithIssues_iPad_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f22dfecfe8f5bb85d9a4edd19b35369a31d4867ad14c69c6f110cdeba79059f3 -size 302405 +oid sha256:941d246f3f33bfe27353f69a0176c41b8708c7d7ea3c64e4cf0bba5fa3442d80 +size 297832 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/ResolutionViewSnapshotTests/resolution.WithIssues_iPhone.png b/Where/WhereUI/SnapshotTests/__Snapshots__/ResolutionViewSnapshotTests/resolution.WithIssues_iPhone.png index 3eb8a26d3..55b9c144f 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/ResolutionViewSnapshotTests/resolution.WithIssues_iPhone.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/ResolutionViewSnapshotTests/resolution.WithIssues_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9acb6aca29aa379cc37ac15a206202d94c51474dfab0fd1d7d5e698821ff0ca8 -size 191304 +oid sha256:5cc9090e638515c2c39a24dd437b7a8cb904a3d92527bf4a4d5fc1dc2ab4f65b +size 187224 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/ResolutionViewSnapshotTests/resolution.WithIssues_iPhone_accessibility.png b/Where/WhereUI/SnapshotTests/__Snapshots__/ResolutionViewSnapshotTests/resolution.WithIssues_iPhone_accessibility.png index 1416a5dea..bef583b38 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/ResolutionViewSnapshotTests/resolution.WithIssues_iPhone_accessibility.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/ResolutionViewSnapshotTests/resolution.WithIssues_iPhone_accessibility.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c4f262ea08e4c327795c94f9c654c2604fbdfff9197b2aaf902fa54c9c93b173 -size 415683 +oid sha256:c167e8ee55e1a552e89530bffaa22695006768a57c0e46f246441ff89443f11b +size 406312 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/ResolutionViewSnapshotTests/resolution.WithIssues_iPhone_ax5.png b/Where/WhereUI/SnapshotTests/__Snapshots__/ResolutionViewSnapshotTests/resolution.WithIssues_iPhone_ax5.png index f07169217..cee5e7185 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/ResolutionViewSnapshotTests/resolution.WithIssues_iPhone_ax5.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/ResolutionViewSnapshotTests/resolution.WithIssues_iPhone_ax5.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:589782f0bc7d01e46be5b7722ad02c481d9b891be4f3c6f76e511f8b80f9cb04 -size 522621 +oid sha256:2d93531814938784bc9c60fffd19a22f288adcb0650f6ec9bdff09174e3d952b +size 488009 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/ResolutionViewSnapshotTests/resolution.WithIssues_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/ResolutionViewSnapshotTests/resolution.WithIssues_iPhone_contrast.png index b77628986..92c26acbc 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/ResolutionViewSnapshotTests/resolution.WithIssues_iPhone_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/ResolutionViewSnapshotTests/resolution.WithIssues_iPhone_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:eca4fc3de63d645c36293acd2d8110457b4585de7a2fbedfd49d0b308e419a35 -size 188538 +oid sha256:0af188e159f7d4023552ce27c711a4b566f7ebb5f67562eb902f7751db8b6f54 +size 184530 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/ResolutionViewSnapshotTests/resolution.WithIssues_iPhone_dark.png b/Where/WhereUI/SnapshotTests/__Snapshots__/ResolutionViewSnapshotTests/resolution.WithIssues_iPhone_dark.png index 666ffe524..83926517e 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/ResolutionViewSnapshotTests/resolution.WithIssues_iPhone_dark.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/ResolutionViewSnapshotTests/resolution.WithIssues_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7e9914bb6430874123fd24f7822a84a991bf04e1de90099950f55fc3c2484c92 -size 187233 +oid sha256:3d15a08b7604dc52378aff8bbf5cc8a1cf478c0b7cd3dd99d9d1452c6e2a70ef +size 183104 diff --git a/Where/WhereUI/Sources/Logging/ResolveModelLog.swift b/Where/WhereUI/Sources/Logging/ResolveModelLog.swift index 97c5a29a1..c494aeacb 100644 --- a/Where/WhereUI/Sources/Logging/ResolveModelLog.swift +++ b/Where/WhereUI/Sources/Logging/ResolveModelLog.swift @@ -1,4 +1,5 @@ import PeriscopeCore +import WhereCore /// Structured events for `ResolveModel`, the data-issue resolution flow. Read / /// dismiss failures leave an honest UI error, so they log at `.warning`. A @@ -6,6 +7,7 @@ import PeriscopeCore enum ResolveModelLog: LogEvent { case dataIssueScanFailed(description: String) case dismissFailed(issueID: String, description: String) + case correctionApplyFailed(issueID: DataIssueID) static let eventName = "Resolve" @@ -19,6 +21,8 @@ enum ResolveModelLog: LogEvent { "Failed to scan for data issues: \(description)" case let .dismissFailed(issueID, description): "Failed to dismiss data issue \(issueID): \(description)" + case .correctionApplyFailed: + "Failed to apply reviewed GPS sample corrections" } } @@ -26,6 +30,7 @@ enum ResolveModelLog: LogEvent { switch self { case let .dismissFailed(issueID, _): issueID case .dataIssueScanFailed: nil + case let .correctionApplyFailed(issueID): issueID.storeURL.absoluteString } } } diff --git a/Where/WhereUI/Sources/Model/YearReportModel.swift b/Where/WhereUI/Sources/Model/YearReportModel.swift index 057fe64a4..6bde9634d 100644 --- a/Where/WhereUI/Sources/Model/YearReportModel.swift +++ b/Where/WhereUI/Sources/Model/YearReportModel.swift @@ -6,8 +6,8 @@ import WhereCore /// The scene-scoped presentation model for the selected year: the loaded /// `YearReport` and everything derived from it (ranking, missing days, the -/// calendar inputs), the day-write intents, and the data-issue *count* the -/// Resolve tab badge reads. +/// calendar inputs), day-write intents, and the coherent GPS/data-issue assessment +/// shared by notices, correction screens, and actionable badges. /// /// Unlike `WhereSession` — the always-on coordinator that lives for the whole /// logged-in lifetime — a `YearReportModel` is created by `MainTabs` only once the @@ -62,12 +62,19 @@ public final class YearReportModel { /// the inputs and reloads the Resolve list even when nothing else /// (year / report / threshold) changed. let manualScanToken: Int + let evidenceRevision: UUID + let scanRevision: UUID? + let scanError: String? } /// Incremented by `rescanForIssues()`; folded into `dataIssueScanInputs` so a /// forced rescan reloads the Resolve list. Observed (so the `.task(id:)` /// re-fires), never persisted. private var manualScanToken = 0 + private var evidenceRevision = UUID() + private var scanRequestID = UUID() + private var isActive = false + private var lifecycleRevision = UUID() private struct LoadedYear { let details: YearReportDetails @@ -108,12 +115,46 @@ public final class YearReportModel { /// tracked separately from `report` (which drives residency). public private(set) var evidenceDayKeys: Set = [] - /// Unresolved data-issue count for the selected year — the Resolve tab badge. - /// The full issue list lives on the view-scoped `ResolveModel`; only this - /// count is kept here because the badge must render before the Resolve tab - /// is ever materialized. + /// One coherent assessment drives Locations, the review list, and actionable badges. + public private(set) var dataIssueScan: DataIssueScanResult? + public private(set) var dataIssueScanError: String? + private var recordingDeviceNames: [RecordingDeviceID: String] = [:] public private(set) var dataIssueCount = 0 + var correctionReviews: [GPSCorrectionReview] { + dataIssueScan?.reviews ?? [] + } + + /// Historical pending reviews remain accessible after their live notice expires. + var hasCorrectionReviews: Bool { + !correctionReviews.isEmpty + } + + var liveFlightReview: GPSCorrectionReview? { + correctionReviews.filter { liveFlightAssessment(in: $0) != nil }.max { lhs, rhs in + (liveFlightAssessment(in: lhs)?.lastObservationAt ?? .distantPast) + < (liveFlightAssessment(in: rhs)?.lastObservationAt ?? .distantPast) + } + } + + func liveFlightAssessment(in review: GPSCorrectionReview) -> FlightAssessment? { + review.flights.filter { flight in + flight.id.recordingDeviceID == services.recording.currentDevice.id + && now().timeIntervalSince(flight.lastObservationAt) < 24 * 60 * 60 + }.max { $0.lastObservationAt < $1.lastObservationAt } + } + + func flightDeviceLabel(_ flight: FlightAssessment) -> String { + guard let deviceID = flight.id.recordingDeviceID else { + return String(localized: .flightStatusDeviceLegacy) + } + if deviceID == services.recording.currentDevice.id { + return String(localized: .flightStatusDeviceCurrent) + } + let name = recordingDeviceNames[deviceID] ?? deviceID.rawValue.uuidString + return String(localized: .flightStatusDeviceNamed(name)) + } + /// The services every read/write funnels through. Exposed so sibling /// view-scoped models can be built from the injected `report`. let services: WhereServices @@ -134,6 +175,7 @@ public final class YearReportModel { /// `nonisolated(unsafe)` so `deinit` can cancel it; every other access is on /// the main actor, and `deinit` runs with no other live references. @ObservationIgnored private nonisolated(unsafe) var dataChangeTask: Task? + @ObservationIgnored private nonisolated(unsafe) var reassessmentTask: Task? private static let logger = WhereLog.session(YearReportModelLog.self) @@ -203,12 +245,7 @@ public final class YearReportModel { } #endif - /// GPS border-drift detection threshold (device setting). The setter persists - /// it, forces a badge recount, and — through the observed mirror — re-keys - /// `dataIssueScanInputs` so the Resolve list re-scans immediately, not just on - /// its next unrelated load. The scanner cache is keyed by `(year, threshold)`, - /// so the recount and the list both recompute for the new threshold no matter - /// which of the two concurrent scans runs first. + /// Persist the GPS drift threshold and refresh the scene's shared assessment. public var driftThreshold: DriftThreshold { get { driftThresholdStorage } set { @@ -219,17 +256,17 @@ public final class YearReportModel { } } - /// The inputs that determine a data-issue scan's result: the selected year, - /// the loaded report (any committed write re-pulls it; a year switch nils - /// then reloads it), and the drift threshold. `ResolutionView` keys its scan - /// `.task(id:)` on this, so the Resolve list re-scans on exactly the triggers - /// the badge count recomputes on — the two can't drift apart. + /// Raw writes re-key mounted reviews even when region totals are equal. + /// Scan revision/error also propagates deadline and explicit refresh results. var dataIssueScanInputs: DataIssueScanInputs { DataIssueScanInputs( year: selectedYear, report: report, driftThreshold: driftThreshold, manualScanToken: manualScanToken, + evidenceRevision: evidenceRevision, + scanRevision: dataIssueScan?.revision, + scanError: dataIssueScanError, ) } @@ -305,14 +342,17 @@ public final class YearReportModel { deinit { dataChangeTask?.cancel() + reassessmentTask?.cancel() } /// Start observing committed writes and pull fresh data. Called by `MainTabs` /// when the scene becomes active. Safe to call repeatedly — the subscription /// is set up at most once until `deactivate()`. public func activate() async { + lifecycleRevision = UUID() + isActive = true observeDataChanges() - await refreshAll(forceDataIssueCount: false) + await refreshAll(forceDataIssueCount: true) } /// Stop observing committed writes. Called by `MainTabs` when the scene goes @@ -320,6 +360,11 @@ public final class YearReportModel { /// `activate()` re-subscribes and pulls (covering the background→foreground /// gap). public func deactivate() { + lifecycleRevision = UUID() + isActive = false + scanRequestID = UUID() + reassessmentTask?.cancel() + reassessmentTask = nil dataChangeTask?.cancel() dataChangeTask = nil } @@ -335,7 +380,9 @@ public final class YearReportModel { let updates = services.dataChangeUpdates() dataChangeTask = Task { @MainActor [weak self] in for await _ in updates { - guard let self else { break } + guard let self, !Task.isCancelled else { break } + evidenceRevision = UUID() + scanRequestID = UUID() await refreshAll(forceDataIssueCount: true) } } @@ -351,19 +398,23 @@ public final class YearReportModel { // Clear the previous year's evidence markers too, so the calendar can't // briefly badge the new year's days with the old year's evidence. evidenceDayKeys = [] + dataIssueScan = nil + dataIssueCount = 0 + dataIssueScanError = nil await refreshAll(forceDataIssueCount: true) } - /// Pull a fresh year report *and* recompute the Resolve badge count — the - /// common case after any committed write or a (re)activation. `refresh()` - /// and `refreshDataIssueCount(force:)` stay separately callable rather than - /// folded together, because the drift-threshold change recomputes only the - /// count (no report re-pull); this just names the pairing the shared sites use. + /// Refresh the report, forecast, evidence markers, and shared correction + /// assessment after a committed write or foreground activation. func refreshAll(forceDataIssueCount: Bool) async { + let requestedLifecycle = lifecycleRevision await Self.logger.measure(.sceneRefresh, budget: .seconds(3)) { await refresh() + guard requestedLifecycle == lifecycleRevision, !Task.isCancelled else { return } await forecasts.refresh() + guard requestedLifecycle == lifecycleRevision, !Task.isCancelled else { return } await refreshEvidenceDayKeys() + guard requestedLifecycle == lifecycleRevision, !Task.isCancelled else { return } await refreshDataIssueCount(force: forceDataIssueCount) } } @@ -392,47 +443,90 @@ public final class YearReportModel { } } - /// Force a fresh data-issue scan past the ~3h throttle — the Settings "Find - /// issues now" action. Recomputes the badge with `force: true` (which also - /// refreshes the scanner's shared cache), then re-keys `dataIssueScanInputs` - /// so an already-open Resolve list reloads from that now-fresh cache too. - /// This mirrors the dual refresh a drift-threshold change performs. + /// Explicit refresh shares the same publication and deadline scheduling as + /// live writes. Its token also refreshes an already-open review after failure. public func rescanForIssues() async { await refreshDataIssueCount(force: true) manualScanToken += 1 } - /// Recompute the Resolve badge count for the selected year. Uses the cached - /// scan (shared with the Resolve list) unless `force`. + /// Reassess raw evidence even if its aggregate region/day set did not change. + /// A later request, committed write, year switch, or deactivation prevents + /// an older suspended scan from publishing into the current scene. func refreshDataIssueCount(force: Bool) async { - // Capture the year this scan is for; the model is reentrant while - // awaiting, so a concurrent `select(year:)` could otherwise install a - // count under the wrong year's label. let requestedYear = selectedYear + let requestID = UUID() + scanRequestID = requestID do { - let issues = try await services.resolution.issues( + let scan = try await services.resolution.scan( year: requestedYear, primaryRegions: ranking.primary.map(\.region), - // Read the observable mirror (same value the `dataIssueScanInputs` - // key the Resolve list scans on uses), so the badge recount and - // the list can't scan against different thresholds. driftThresholdMeters: Double(driftThreshold.rawValue), force: force, ) - guard requestedYear == selectedYear else { return } - if dataIssueCount != issues.count { dataIssueCount = issues.count } + guard requestedYear == selectedYear, scanRequestID == requestID, + !Task.isCancelled else { return } + dataIssueScan = scan + dataIssueCount = scan.issues.count + dataIssueScanError = nil + scheduleReassessment() + await refreshRecordingDeviceNames() + } catch is CancellationError { + return } catch { - // Surface the failure and keep the last good count rather than - // silently blanking the badge. + guard requestedYear == selectedYear, scanRequestID == requestID else { return } + dataIssueScanError = error.localizedDescription Self.logger { .dataIssueScanFailed(description: error.localizedDescription) } } } + private func refreshRecordingDeviceNames() async { + do { + let devices = try await services.recording.devices() + guard !Task.isCancelled else { return } + recordingDeviceNames = Dictionary(uniqueKeysWithValues: devices.map { + ($0.id, $0.device.displayName) + }) + } catch is CancellationError { + return + } catch { + Self.logger { .dataIssueScanFailed(description: error.localizedDescription) } + } + } + + /// Only the foreground scene schedules presentation deadlines. Recording + /// silence changes the notice's freshness; it can never establish arrival. + private func scheduleReassessment() { + reassessmentTask?.cancel() + reassessmentTask = nil + guard isActive else { return } + let currentDate = now() + let expiryDates = correctionReviews.flatMap(\.flights).map { + $0.lastObservationAt.addingTimeInterval(24 * 60 * 60) + } + let deadlines = expiryDates + [dataIssueScan?.nextReassessmentAt].compactMap(\.self) + guard let deadline = deadlines.filter({ $0 > currentDate }).min() else { return } + let delay = deadline.timeIntervalSince(currentDate) + reassessmentTask = Task { @MainActor [weak self] in + do { + try await Task.sleep(for: .seconds(delay)) + } catch is CancellationError { + return + } catch { + Self.logger { .dataIssueScanFailed(description: error.localizedDescription) } + return + } + guard let self, isActive, !Task.isCancelled else { return } + await refreshDataIssueCount(force: true) + } + } + public func refresh() async { // Capture the year this fetch is for; the model is reentrant while // awaiting `yearReportDetails`, so a rapid second `select(year:)` could // install a stale report under the newer year's label. let requestedYear = selectedYear + let requestedLifecycle = lifecycleRevision // Only surface the loading state when there's nothing on screen yet — an // initial load or a year switch (which nils `report` first). A background // refresh keeps the current report visible (no spinner flicker), and the @@ -443,7 +537,8 @@ public final class YearReportModel { for: requestedYear, primaryRegionCount: RegionRanking.primaryCount, ) - guard requestedYear == selectedYear else { return } + guard requestedYear == selectedYear, requestedLifecycle == lifecycleRevision, + !Task.isCancelled else { return } let changed = loadedYear?.details != details if changed { loadedYear = LoadedYear(details: details, previous: loadedYear) } if loadState != .loaded { loadState = .loaded } @@ -453,7 +548,8 @@ public final class YearReportModel { } } } catch { - guard requestedYear == selectedYear else { return } + guard requestedYear == selectedYear, requestedLifecycle == lifecycleRevision, + !Task.isCancelled else { return } loadState = .failed(.reportUnavailable(message: error.localizedDescription)) Self.logger { .reportLoadFailed(year: requestedYear, description: error.localizedDescription) @@ -609,6 +705,11 @@ public final class YearReportModel { #if DEBUG @_spi(Testing) extension YearReportModel { /// Inject a badge count for previews/tests without seeding raw samples. + public func setDataIssueScan(_ scan: DataIssueScanResult) { + dataIssueScan = scan + dataIssueCount = scan.issues.count + } + public func setDataIssueCount(_ count: Int) { dataIssueCount = count } diff --git a/Where/WhereUI/Sources/Preview/FlightReviewPreviewState.swift b/Where/WhereUI/Sources/Preview/FlightReviewPreviewState.swift new file mode 100644 index 000000000..383135d0d --- /dev/null +++ b/Where/WhereUI/Sources/Preview/FlightReviewPreviewState.swift @@ -0,0 +1,121 @@ +#if DEBUG + import Foundation + import RegionKit + @_spi(Testing) import WhereCore + + /// Deterministic production-shaped states shared by the notice and review snapshots. + enum FlightReviewPreviewState: String, CaseIterable { + case flightLikely = "FlightLikely" + case waiting = "Waiting" + case stale = "Stale" + case ready = "Ready" + case noPresence = "NoPresence" + case completed = "Completed" + } + + extension PreviewSupport { + @MainActor + static func flightYearReportModel(state: FlightReviewPreviewState) -> YearReportModel { + let report = loadedYearReportModel() + report.setDataIssueScan(flightScan(state: state)) + return report + } + + static func flightScan(state: FlightReviewPreviewState) -> DataIssueScanResult { + let review = flightReview(state: state) + var issues: [any DataIssue] = [] + if let proposal = review.proposal { + issues.append(SampleCorrectionIssue(proposal: proposal)) + } + return DataIssueScanResult( + revision: UUID(uuidString: "00000000-0000-0000-0000-000000000303")!, + issues: issues, + reviews: [review], + nextReassessmentAt: review.flight?.nextReassessmentAt, + ) + } + + @MainActor + static func flightResolveModel(state: FlightReviewPreviewState) -> ResolveModel { + let resolve = resolveModel(seededWithIssues: false) + let scan = flightScan(state: state) + resolve.setDataIssues(scan.issues) + resolve.setReviews(scan.reviews) + return resolve + } + + static func borderDriftReview(date: Date) -> GPSCorrectionReview { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = .current + let day = DayPresence(date: date, in: calendar, regions: [.newYork, .other]) + let reviewID = DataIssueID.borderDrift(day: day.day) + return GPSCorrectionReview( + id: reviewID, + day: day, + points: [], + state: .ready(SampleCorrectionProposal( + reviewID: reviewID, + day: day, + resultingRegions: [.newYork], + edits: [.init( + sampleID: UUID(uuidString: "00000000-0000-0000-0000-000000000302")!, + replacementRegions: [.newYork], + )], + ), flight: nil), + ) + } + + static func flightReview(state: FlightReviewPreviewState) -> GPSCorrectionReview { + flightReview(state: state, date: referenceNow) + } + + static func flightReview( + state: FlightReviewPreviewState, + date: Date, + ) -> GPSCorrectionReview { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = .current + let sampleID = UUID(uuidString: "00000000-0000-0000-0000-000000000301")! + let day = DayPresence( + date: date, + in: calendar, + regions: state == .noPresence ? [.other] : [.california, .other, .newYork], + ) + let reviewID = DataIssueID.flightDay(day: day.day) + let progress: FlightAssessment.Progress = switch state { + case .flightLikely: .flightLikely + case .waiting, .stale: .awaitingArrival + case .ready, .noPresence, .completed: .completed(arrivedAt: date) + } + let flight = FlightAssessment( + id: .init( + recordingDeviceID: CurrentRecordingDevice.preview.id, + departureSampleID: sampleID, + ), + startedAt: date.addingTimeInterval(-5 * 60 * 60), + lastObservationAt: date.addingTimeInterval(state == .stale ? -2 * 60 * 60 : 0), + lastFlightAt: date.addingTimeInterval(-15 * 60), + airborneSampleIDs: [sampleID], + groundSampleIDs: [], + peakSpeedKMH: 1040, + progress: progress, + ) + let reviewState: GPSCorrectionReview.State = switch state { + case .flightLikely, .waiting, .stale: + .pending(flight) + case .ready, .noPresence: + .ready(SampleCorrectionProposal( + reviewID: reviewID, + day: day, + resultingRegions: state == .noPresence ? [] : [.california, .newYork], + edits: [.init(sampleID: sampleID, replacementRegions: [])], + ), flight: flight) + case .completed: + .completed(flight) + } + // No network-backed map in these fixtures. The same review body + // renders evidence, arrival state, and sample-edit summary synchronously. + return GPSCorrectionReview(id: reviewID, day: day, points: [], state: reviewState) + } + } +#endif diff --git a/Where/WhereUI/Sources/Preview/PreviewSupport.swift b/Where/WhereUI/Sources/Preview/PreviewSupport.swift index 18e9422d1..61569370f 100644 --- a/Where/WhereUI/Sources/Preview/PreviewSupport.swift +++ b/Where/WhereUI/Sources/Preview/PreviewSupport.swift @@ -549,11 +549,16 @@ /// One data-resolution issue per category, for Resolve tab previews/tests. public static func sampleDataIssues() -> [any DataIssue] { var calendar = Calendar(identifier: .gregorian) - calendar.timeZone = TimeZone(identifier: "America/Los_Angeles")! + calendar.timeZone = .current let start = calendar.date(from: DateComponents(year: year, month: 3, day: 1))! let day2 = calendar.date(byAdding: .day, value: 1, to: start)! let day3 = calendar.date(byAdding: .day, value: 2, to: start)! let day4 = calendar.date(byAdding: .day, value: 3, to: start)! + guard let driftProposal = borderDriftReview(date: day2).proposal, + let flightProposal = flightReview(state: .ready, date: day4).proposal + else { + preconditionFailure("Sample correction previews must provide actionable proposals") + } let startDay = CalendarDay(from: start, in: calendar) return [ MissingDaysIssue(range: MissingDayRange( @@ -561,25 +566,12 @@ end: startDay, dayCount: 1, )), - BorderDriftIssue( - day: DayPresence(date: day2, in: calendar, regions: [.other]), - nearestRegion: .california, - distanceMeters: 6000, - ), + SampleCorrectionIssue(proposal: driftProposal), AbruptChangeIssue( earlierDay: DayPresence(date: day2, in: calendar, regions: [.california]), laterDay: DayPresence(date: day3, in: calendar, regions: [.newYork]), ), - FlightDayIssue( - day: DayPresence( - date: day4, - in: calendar, - regions: [.newYork, .other, .california], - ), - keepRegions: [.newYork, .california], - removedRegions: [.other], - peakSpeedKMH: 880, - ), + SampleCorrectionIssue(proposal: flightProposal), ] } @@ -600,6 +592,16 @@ services: previewServices(), preferences: previewPreferences(), ) + if seededWithIssues { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = .current + let driftDay = calendar.date(from: DateComponents(year: year, month: 3, day: 2))! + let flightDay = calendar.date(from: DateComponents(year: year, month: 3, day: 4))! + resolve.setReviews([ + borderDriftReview(date: driftDay), + flightReview(state: .ready, date: flightDay), + ]) + } resolve.setDataIssues(seededWithIssues ? sampleDataIssues() : []) return resolve } diff --git a/Where/WhereUI/Sources/Primary/LocationsView.swift b/Where/WhereUI/Sources/Primary/LocationsView.swift index a57e99e7a..397161acf 100644 --- a/Where/WhereUI/Sources/Primary/LocationsView.swift +++ b/Where/WhereUI/Sources/Primary/LocationsView.swift @@ -53,9 +53,9 @@ struct LocationsView: View { .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItemGroup(placement: .topBarTrailing) { - // Resolve stays immediately left of the stable planning - // affordance and appears only while issues need attention. - if report.dataIssueCount > 0 { + // Pending reviews remain accessible without increasing + // the actionable issue badge. + if report.dataIssueCount > 0 || report.hasCorrectionReviews { Button { showingResolution = true } label: { @@ -106,15 +106,19 @@ struct LocationsView: View { private var screen: some View { switch report.loadState { case .loading where report.report == nil: - AppIconLoadingView(caption: String(localized: .primaryLoading)) + stateWithFlightNotice { + AppIconLoadingView(caption: String(localized: .primaryLoading)) + } case let .failed(error): - ContentUnavailableView { - Label( - String(localized: .commonLoadErrorTitle), - systemSymbol: .exclamationmarkIcloud, - ) - } description: { - Text(error.message) + stateWithFlightNotice { + ContentUnavailableView { + Label( + String(localized: .commonLoadErrorTitle), + systemSymbol: .exclamationmarkIcloud, + ) + } description: { + Text(error.message) + } } case .idle, .loaded, .loading: if report.ranking.primary.isEmpty { @@ -122,9 +126,9 @@ struct LocationsView: View { // exist, but only in non-headline regions" (e.g. all in // `.other`) — otherwise the latter wrongly reads as empty. if report.trackedDayCount == 0 { - emptyState + stateWithFlightNotice { emptyState } } else { - elsewhereOnlyState + stateWithFlightNotice { elsewhereOnlyState } } } else { content @@ -132,14 +136,51 @@ struct LocationsView: View { } } + @ViewBuilder + private var flightNotice: some View { + if let review = report.liveFlightReview { + NavigationLink { + FlightDayDetailView(review: review, report: report) + } label: { + FlightStatusBanner( + review: review, + deviceLabel: report.liveFlightAssessment(in: review) + .map(report.flightDeviceLabel), + flight: report.liveFlightAssessment(in: review), + ) + } + .buttonStyle(.plain) + .accessibilityHint(String(localized: .flightStatusOpenReview)) + } + } + + /// A flight notice must remain scrollable even when the report has no cards. + @ViewBuilder + private func stateWithFlightNotice(@ViewBuilder content: () -> some View) -> some View { + if report.liveFlightReview != nil { + ScrollView { + VStack(spacing: stylesheet.spacing.xxLarge) { + flightNotice + content() + } + .padding() + .frame(maxWidth: .infinity) + } + .scrollBounceBehavior(.basedOnSize) + } else { + content() + } + } + private var content: some View { let presentedCards = cardPresentation.presented(report.ranking.primary) - // `.defaultScrollAnchor(.center)` vertically centers a short list (one or - // two cards) rather than pinning it to the top, while a longer list still - // scrolls from the top. + // Keep the flight notice immediately below navigation. Without a notice, + // a short card list retains its centered presentation. return ScrollView { VStack(spacing: stylesheet.spacing.xxLarge) { + flightNotice + LocationCardRankingStack( spacing: stylesheet.spacing.xxLarge, presentation: cardPresentation, @@ -218,7 +259,7 @@ struct LocationsView: View { .padding() .frame(maxWidth: .infinity) } - .defaultScrollAnchor(.center) + .defaultScrollAnchor(report.liveFlightReview == nil ? .center : .top) .scrollBounceBehavior(.basedOnSize) // The card normally stays clipped to the scrolling viewport. Reveal // overflow only while the authored arc, scale, and rotation need it. @@ -308,8 +349,7 @@ struct LocationsView: View { } /// The Locations toolbar's Resolve affordance: the checklist icon with a count -/// badge. Rendered only while there are issues (the toolbar item is gated on -/// the count), so it always carries a positive `count`. +/// badge for actionable issues. Pending reviews keep the unbadged icon available. private struct ResolveToolbarLabel: View { let count: Int @@ -318,17 +358,19 @@ private struct ResolveToolbarLabel: View { var body: some View { Image(systemSymbol: .checklist) .overlay(alignment: .topTrailing) { - Text(count, format: .number) - .font(.caption2.weight(.bold)) - .foregroundStyle(.white) - .padding(.horizontal, stylesheet.spacing.xSmall) - .padding(.vertical, stylesheet.spacing.xxSmall) - .background(.red, in: Capsule()) - .offset(x: stylesheet.spacing.small, y: -stylesheet.spacing.small) - .accessibilityHidden(true) + if count > 0 { + Text(count, format: .number) + .font(.caption2.weight(.bold)) + .foregroundStyle(.white) + .padding(.horizontal, stylesheet.spacing.xSmall) + .padding(.vertical, stylesheet.spacing.xxSmall) + .background(.red, in: Capsule()) + .offset(x: stylesheet.spacing.small, y: -stylesheet.spacing.small) + .accessibilityHidden(true) + } } .accessibilityLabel(String(localized: .tabResolution)) - .accessibilityValue(Text(count, format: .number)) + .accessibilityValue(count > 0 ? Text(count, format: .number) : Text("")) } } @@ -346,6 +388,17 @@ private struct ResolveToolbarLabel: View { ) { LocationsView(report: PreviewSupport.loadedYearReportModel()) } + for state in [FlightReviewPreviewState.flightLikely, .stale, .ready, .completed] { + whereSnapshot( + name: "Flight-" + state.rawValue, + configurations: state == .flightLikely + ? .fullContentScreenDefaults : .fullContentPhoneLightDark, + measurementReadiness: .immediate, + settle: .settledAtLeast(minDuration: 1.0), + ) { + LocationsView(report: PreviewSupport.flightYearReportModel(state: state)) + } + } whereSnapshot( name: "PlannedStay", configurations: .fullContentPhoneLightDark, diff --git a/Where/WhereUI/Sources/Resolution/FlightDayDetailView.swift b/Where/WhereUI/Sources/Resolution/FlightDayDetailView.swift index bfd2046de..10aaa56ba 100644 --- a/Where/WhereUI/Sources/Resolution/FlightDayDetailView.swift +++ b/Where/WhereUI/Sources/Resolution/FlightDayDetailView.swift @@ -4,191 +4,172 @@ import SnapshotKit import SwiftUI import WhereCore -/// Detail screen for a suspected flight day: a map of the day's recorded points -/// (with the fly-over `.other` pins tinted apart from the grounded endpoints) -/// above an explanation and a one-tap fix. "Apply" writes an authoritative -/// `overrideDay` keeping only the endpoints; "Not what you expected?" hands off -/// to the regular `DayRelabelView` (seeded from the day's *actual* regions, so a -/// wrong guess isn't baked in); "These are all correct" dismisses the issue. +/// Shared flight/drift review. Pending evidence leaves manual editing available; +/// Apply submits only the exact reviewed samples to Core's guarded transaction. struct FlightDayDetailView: View { - @Environment(\.dismiss) private var dismiss - @Environment(\.stylesheet) private var stylesheet - - let issue: any DataIssue let report: YearReportModel - let resolve: ResolveModel + @State private var model: FlightReviewModel + @Environment(\.dismiss) private var dismiss - @State private var mapPoints: [RecordedMapPoint] = [] - @State private var applying = false - @State private var saveError = SaveErrorAlertState() + init(review: GPSCorrectionReview, report: YearReportModel) { + self.report = report + _model = State(initialValue: FlightReviewModel(review: review, report: report)) + } var body: some View { - @Bindable var saveError = saveError - - Group { - if let payload = flightPayload { - content(payload) - .task(id: payload.day.day) { await loadPoints(for: payload.day.day) } - } else { - ContentUnavailableView( - String(localized: .commonLoadErrorTitle), - systemSymbol: .exclamationmarkTriangle, - ) - } - } - .navigationTitle(String(localized: .resolutionFlightDetailTitle)) - .navigationBarTitleDisplayMode(.inline) - .alert( - String(localized: .manualSaveErrorTitle), - isPresented: $saveError.isPresented, - ) { - Button(String(localized: .commonOk), role: .cancel) {} - } message: { - if let message = saveError.message { - Text(message) - } - } - } + Form { + if let review = model.review { + Section { + if review.flights.isEmpty { + FlightStatusBanner(review: review, deviceLabel: nil) + .listRowInsets(EdgeInsets()) + } else { + ForEach(review.flights) { flight in + FlightStatusBanner( + review: review, + deviceLabel: report.flightDeviceLabel(flight), + flight: flight, + ) + .listRowInsets(EdgeInsets()) + } + } + } - private func content(_ payload: FlightPayload) -> some View { - VStack(spacing: 0) { - if !mapPoints.isEmpty { - RecordedPointsMap(points: mapPoints) - } - form(payload) - } - .animation(.default, value: applying) - } + if !model.mapPoints.isEmpty { + Section { + RecordedPointsMap(points: model.mapPoints) + .listRowInsets(EdgeInsets()) + } + } - private func form(_ payload: FlightPayload) -> some View { - Form { - Section { - Text(WhereFormat.resolutionFlightDetailExplanation( - peakSpeedKMH: payload.peakSpeedKMH, - removed: payload.removed, - )) - } header: { - Text(dateText(payload.day)) - } + if let flight = review.flight { + Section(String(localized: .flightReviewEvidenceTitle)) { + LabeledContent(String(localized: .flightReviewPeakSpeed)) { + Text( + Measurement( + value: flight.peakSpeedKMH, + unit: UnitSpeed.kilometersPerHour, + ), + format: .measurement(width: .abbreviated, usage: .asProvided), + ) + } + Text(String(localized: .flightReviewEvidenceDescription)) + .foregroundStyle(.secondary) + } + } - if applying { + if let proposal = review.proposal { + Section(String(localized: .flightReviewChangesTitle)) { + LabeledContent(String(localized: .flightReviewResultingRegions)) { + Text(proposal.resultingRegions.isEmpty + ? String(localized: .flightReviewNoPresence) + : proposal.resultingRegions.map(\.localizedName).sorted() + .joined(separator: ", ")) + } + DisclosureGroup(String(localized: .flightReviewChangeCount(proposal.edits + .count))) + { + ForEach(model.editedPoints, id: \.sample.id) { point in + VStack(alignment: .leading) { + Text( + point.sample.timestamp, + format: .dateTime.hour().minute().second(), + ) + Text(model.replacementDescription(for: point.sample.id)) + .foregroundStyle(.secondary) + if let speed = WhereFormat + .recordedFlightSpeed(point.sample.motion?.speed) + { + Text(speed) + .font(.caption) + .foregroundStyle(.secondary) + } + if let altitude = WhereFormat + .recordedFlightAltitude(point.sample.motion?.altitude) + { + Text(altitude) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } + Text(String(localized: .flightReviewChangesDescription)) + .foregroundStyle(.secondary) + } + } + } else { Section { - SavingStatusRow(text: String(localized: .manualSavingStatus)) + Label(String(localized: .flightReviewUnavailable), systemSymbol: .infoCircle) } } - Section { - Button { - apply(payload) - } label: { - Text(WhereFormat.resolutionFlightApply(regions: payload.keep)) + switch model.saveState { + case .idle, .applied: + EmptyView() + case .applying: + Section { SavingStatusRow(text: String(localized: .manualSavingStatus)) } + case .refreshed: + Section { + Label( + String(localized: .flightReviewRefreshed), + systemSymbol: .arrowClockwise, + ) + } + case let .failed(message): + Section { + Label(message, systemSymbol: .exclamationmarkTriangle) + } + } + + if model.review?.proposal != nil { + Section { + Button(String(localized: .flightReviewApply)) { + Task { + await model.apply() + if model.saveState == .applied { dismiss() } + } + } + .disabled(!model.canApply) + .accessibilityIdentifier("where_flight_apply") } - .disabled(applying) } Section { NavigationLink { - DayRelabelView( - day: payload.day, - report: report, - reason: .flight(removed: payload.removed), - ) + DayRelabelView(day: model.review?.day ?? model.initialDay, report: report) } label: { - Text(String(localized: .resolutionFlightManualFix)) + Text(String(localized: .flightReviewManualEdit)) } - .disabled(applying) + .disabled(model.saveState == .applying) } footer: { Text(String(localized: .resolutionFlightManualFixFooter)) } - - Section { - Button(String(localized: .resolutionFlightBothRight)) { - Task { - await resolve.dismiss(issue) - dismiss() - } - } - .disabled(applying) - } } - } - - private func dateText(_ day: DayPresence) -> String { - day.displayDate.formatted(.dateTime.month(.abbreviated).day().year()) - } - - private func loadPoints(for day: CalendarDay) async { - let byRegion = await report.locations(onDay: day) - guard !Task.isCancelled else { return } - mapPoints = byRegion.flatMap { region, points in - points.map { - RecordedMapPoint( - coordinate: $0.coordinate, - horizontalAccuracy: $0.horizontalAccuracy, - region: region, - ) - } - } - } - - private func apply(_ payload: FlightPayload) { - applying = true - saveError.message = nil - Task { - do { - try await report.overrideDay( - date: payload.day.startOfDay(in: report.calendar), - regions: payload.keep, - ) - dismiss() - } catch { - // Keep the screen up so the user can retry; the fix didn't land. - saveError.message = error.localizedDescription - applying = false - } - } - } - - private var flightPayload: FlightPayload? { - if case let .correctFlightDay(day, keep, removed, peak) = issue.resolution { - return FlightPayload(day: day, keep: keep, removed: removed, peakSpeedKMH: peak) + .navigationTitle(model.initialDay.displayDate + .formatted(.dateTime.month(.abbreviated).day().year())) + .navigationBarTitleDisplayMode(.inline) + .task(id: report.dataIssueScanInputs) { + model.receive(report.dataIssueScan) } - return nil - } - - /// The `.correctFlightDay` resolution unpacked for the view (a named struct - /// rather than a tuple, since it escapes into `content`/`form`). - private struct FlightPayload { - let day: DayPresence - let keep: Set - let removed: Set - let peakSpeedKMH: Double + .refreshable { await report.rescanForIssues() } } } #if DEBUG extension FlightDayDetailView: SnapshotProviding { - /// The flight-day fixture pins its day to `referenceNow` (a bespoke - /// `.now` would churn the reference daily). The preview store seeds no - /// raw samples, so the recorded-points map stays out of the tree and the - /// capture is deterministic. static var snapshots: [SnapshotCase] { - whereSnapshot(name: "Default", configurations: .fullContentScreenDefaults) { - NavigationStack { - FlightDayDetailView( - issue: FlightDayIssue( - day: DayPresence( - date: PreviewSupport.referenceNow, - in: .current, - regions: [.newYork, .other, .california], - ), - keepRegions: [.newYork, .california], - removedRegions: [.other], - peakSpeedKMH: 880, - ), - report: PreviewSupport.loadedYearReportModel(), - resolve: PreviewSupport.resolveModel(), - ) + for state in FlightReviewPreviewState.allCases { + whereSnapshot( + name: state == .ready ? "Default" : state.rawValue, + configurations: .fullContentScreenDefaults, + ) { + NavigationStack { + FlightDayDetailView( + review: PreviewSupport.flightReview(state: state), + report: PreviewSupport.loadedYearReportModel(), + ) + } } } } @@ -204,9 +185,7 @@ struct FlightDayDetailView: View { static let flyoverData = WhereFlyoverData.snapshots( FlightDayDetailView.self, title: "Flight Day", - routes: [ - .push(to: DayRelabelView.flyoverID), - ], + routes: [.push(to: DayRelabelView.flyoverID)], ) } #endif diff --git a/Where/WhereUI/Sources/Resolution/FlightReviewModel.swift b/Where/WhereUI/Sources/Resolution/FlightReviewModel.swift new file mode 100644 index 000000000..f4d4c8233 --- /dev/null +++ b/Where/WhereUI/Sources/Resolution/FlightReviewModel.swift @@ -0,0 +1,95 @@ +import Foundation +import Observation +import PeriscopeCore +import RegionKit +import WhereCore + +/// Owns review refresh and transactional Apply; a stale proposal stays on screen +/// with the newly assessed evidence and requires an explicit second review. +@MainActor +@Observable +final class FlightReviewModel { + enum SaveState: Equatable { + case idle + case applying + case refreshed + case failed(String) + case applied + } + + private(set) var review: GPSCorrectionReview? + private(set) var saveState = SaveState.idle + let reviewID: DataIssueID + let initialDay: DayPresence + private let report: YearReportModel + private static let logger = WhereLog.session(ResolveModelLog.self) + + init(review: GPSCorrectionReview, report: YearReportModel) { + self.review = review + reviewID = review.id + initialDay = review.day + self.report = report + } + + var canApply: Bool { + review?.proposal != nil && saveState != .applying && saveState != .applied + } + + var mapPoints: [RecordedMapPoint] { + review?.points.flatMap { point in + point.regions.sorted { $0.rawValue < $1.rawValue }.map { region in + RecordedMapPoint( + coordinate: point.sample.coordinate, + horizontalAccuracy: point.sample.horizontalAccuracy, + region: region, + ) + } + } ?? [] + } + + var editedPoints: [SampleCorrectionPoint] { + guard let review, let proposal = review.proposal else { return [] } + let editedIDs = Set(proposal.edits.map(\.sampleID)) + return review.points.filter { editedIDs.contains($0.sample.id) } + .sorted { $0.sample.timestamp < $1.sample.timestamp } + } + + func replacementDescription(for sampleID: UUID) -> String { + guard let edit = review?.proposal?.edits.first(where: { $0.sampleID == sampleID }) else { + return String(localized: .flightReviewUnchanged) + } + if edit.replacementRegions.isEmpty { + return String(localized: .flightReviewExcluded) + } + return edit.replacementRegions.map(\.localizedName).sorted().joined(separator: ", ") + } + + func receive(_ scan: DataIssueScanResult?) { + guard let scan, saveState != .applying, saveState != .applied else { return } + let updated = scan.reviews.first { $0.id == reviewID } + guard updated != review else { return } + review = updated + saveState = .refreshed + } + + func apply() async { + guard canApply, let proposal = review?.proposal else { return } + saveState = .applying + do { + switch try await report.services.corrections.apply(proposal) { + case .applied: + saveState = .applied + await report.rescanForIssues() + case let .stale(updated): + review = updated + saveState = .refreshed + await report.rescanForIssues() + } + } catch { + Self.logger(attachments: [.error(error, name: "sample-correction-error")]) { + .correctionApplyFailed(issueID: reviewID) + } + saveState = .failed(error.localizedDescription) + } + } +} diff --git a/Where/WhereUI/Sources/Resolution/FlightStatusBanner.swift b/Where/WhereUI/Sources/Resolution/FlightStatusBanner.swift new file mode 100644 index 000000000..a2e2e70bf --- /dev/null +++ b/Where/WhereUI/Sources/Resolution/FlightStatusBanner.swift @@ -0,0 +1,101 @@ +import SFSafeSymbols +import SnapshotKit +import SwiftUI +import WhereCore + +/// Shared, static flight status used on Locations and in correction review. +struct FlightStatusBanner: View { + let review: GPSCorrectionReview + let deviceLabel: String? + private let flight: FlightAssessment? + + init(review: GPSCorrectionReview, deviceLabel: String?, flight: FlightAssessment? = nil) { + self.review = review + self.deviceLabel = deviceLabel + self.flight = flight ?? review.flight + } + + @Environment(\.stylesheet) private var stylesheet + + var body: some View { + let style = stylesheet.flightStatus + VStack(alignment: .leading, spacing: style.spacing) { + Label(title, systemSymbol: flight == nil ? .locationCircle : .airplane) + .font(style.titleFont) + Text(explanation) + .font(style.bodyFont) + .foregroundStyle(.secondary) + if let flight { + if let deviceLabel { + Text(deviceLabel) + .font(.caption) + .foregroundStyle(.secondary) + } + Text(String(localized: .flightStatusLastObservation( + flight.lastObservationAt + .formatted(.dateTime.month(.abbreviated).day().hour().minute()), + ))) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(style.padding) + .background(style.background, in: RoundedRectangle(cornerRadius: style.cornerRadius)) + .accessibilityElement(children: .combine) + } + + private var title: String { + switch review.state { + case let .pending(pendingFlight): + switch (flight ?? pendingFlight).progress { + case .flightLikely: String(localized: .flightStatusLikelyTitle) + case .awaitingArrival: String(localized: .flightStatusWaitingTitle) + case .completed: String(localized: .flightStatusArrivalTitle) + } + case .ready: String(localized: .flightStatusReadyTitle) + case .completed: String(localized: .flightStatusCompletedTitle) + } + } + + private var explanation: String { + switch review.state { + case let .pending(pendingFlight): + switch (flight ?? pendingFlight).progress { + case .flightLikely: String(localized: .flightStatusLikelyDescription) + case .awaitingArrival: String(localized: .flightStatusWaitingDescription) + case .completed: String(localized: .flightStatusArrivalDescription) + } + case .ready: String(localized: .flightStatusReadyDescription) + case .completed: String(localized: .flightStatusCompletedDescription) + } + } +} + +#if DEBUG + extension FlightStatusBanner: SnapshotProviding { + static var snapshots: [SnapshotCase] { + for state in FlightReviewPreviewState.allCases { + whereSnapshot( + name: state.rawValue, + configurations: .fullContentScreenDefaults, + ) { + NavigationStack { + ScrollView { + FlightStatusBanner( + review: PreviewSupport.flightReview(state: state), + deviceLabel: String(localized: .flightStatusDeviceCurrent), + ) + .padding() + } + .navigationTitle(String(localized: .tabLocations)) + } + } + } + } + } + + #Preview { + FlightStatusBanner.snapshotPreviews + } +#endif diff --git a/Where/WhereUI/Sources/Resolution/ResolutionView.swift b/Where/WhereUI/Sources/Resolution/ResolutionView.swift index 58379b7cb..17c1ebaec 100644 --- a/Where/WhereUI/Sources/Resolution/ResolutionView.swift +++ b/Where/WhereUI/Sources/Resolution/ResolutionView.swift @@ -5,12 +5,7 @@ import SnapshotKit import SwiftUI import WhereCore -/// Lists data-quality issues for the selected year and routes each to its fix -/// flow. The scene's `YearReportModel` owns the badge *count*; this view owns the -/// list via a view-scoped `ResolveModel`, re-scanned from a `.task(id:)` keyed -/// on the report's `dataIssueScanInputs` (so it refreshes on appear, on any -/// committed write, on a year switch, and on a drift-threshold change — the same -/// triggers that recompute the badge count). +/// Lists actionable corrections and retained flight reviews from one scene scan. struct ResolutionView: View { let report: YearReportModel @State private var resolve: ResolveModel @@ -43,10 +38,11 @@ struct ResolutionView: View { } } .task(id: report.dataIssueScanInputs) { - await resolve.load( - year: report.selectedYear, - primaryRegions: report.ranking.primary.map(\.region), - ) + if report.dataIssueScan == nil, report.dataIssueScanError == nil { + await report.refreshDataIssueCount(force: false) + } + guard !Task.isCancelled else { return } + resolve.receive(scan: report.dataIssueScan, error: report.dataIssueScanError) } } // Log View Mode: reveal an inspect badge for data-issue resolution @@ -74,7 +70,22 @@ struct ResolutionView: View { // yet; show the loading state rather than flash "all clear" // under a non-zero badge. AppIconLoadingView(caption: String(localized: .primaryLoading)) - } else if resolve.dataIssues.isEmpty { + } else if let error = resolve.loadError, resolve.dataIssues.isEmpty, + resolve.reviews.isEmpty + { + ContentUnavailableView { + Label( + String(localized: .commonLoadErrorTitle), + systemSymbol: .exclamationmarkTriangle, + ) + } description: { + Text(error) + } actions: { + Button(String(localized: .commonRetry)) { + Task { await report.rescanForIssues() } + } + } + } else if resolve.dataIssues.isEmpty, resolve.reviews.isEmpty { ContentUnavailableView { Label( String(localized: .resolutionEmptyTitle), @@ -91,6 +102,18 @@ struct ResolutionView: View { private var issueList: some View { List { + if let error = resolve.loadError { + Section { + Label(error, systemSymbol: .exclamationmarkTriangle) + } + } + if !resolve.pendingReviews.isEmpty { + Section(String(localized: .flightStatusPendingSection)) { + ForEach(resolve.pendingReviews) { review in + reviewLink(review) + } + } + } ForEach(DataIssueCategory.allCases, id: \.self) { category in let issues = issues(in: category) if !issues.isEmpty { @@ -106,10 +129,33 @@ struct ResolutionView: View { } } } + if !resolve.completedReviews.isEmpty { + Section(String(localized: .flightStatusCompletedSection)) { + ForEach(resolve.completedReviews) { review in + reviewLink(review) + } + } + } } + .refreshable { await report.rescanForIssues() } .accessibilityIdentifier("where_resolution_list") } + private func reviewLink(_ review: GPSCorrectionReview) -> some View { + NavigationLink { + FlightDayDetailView(review: review, report: report) + } label: { + VStack(alignment: .leading) { + Text(review.day.displayDate, format: .dateTime.month(.abbreviated).day().year()) + .font(.headline) + FlightStatusBanner( + review: review, + deviceLabel: review.flight.map(report.flightDeviceLabel), + ) + } + } + } + private func issues(in category: DataIssueCategory) -> [any DataIssue] { resolve.dataIssues.filter { $0.category == category } } @@ -162,17 +208,22 @@ private struct IssueRow: View { switch issue.resolution { case let .backfill(range): ManualDayView(report: report, mode: .add(prefill: range)) - case let .relabelDay(day, suggested, meters): - DayRelabelView( - day: day, - report: report, - initialRegions: suggested, - reason: .borderDrift(region: suggested.first ?? .other, distanceMeters: meters), - ) case .markTravelDay: AbruptChangeDetailView(issue: issue, report: report, resolve: resolve) - case .correctFlightDay: - FlightDayDetailView(issue: issue, report: report, resolve: resolve) + case let .correctSamples(proposal): + if let review = resolve.review(for: issue) { + FlightDayDetailView(review: review, report: report) + } else { + ContentUnavailableView { + Label(String(localized: .commonLoadErrorTitle), systemSymbol: .infoCircle) + } description: { + Text(String(localized: .flightReviewUnavailable)) + } actions: { + NavigationLink(String(localized: .flightReviewManualEdit)) { + DayRelabelView(day: proposal.day, report: report) + } + } + } } } @@ -180,15 +231,13 @@ private struct IssueRow: View { switch issue.resolution { case let .backfill(range): DateRangeFormatting.abbreviated(start: range.start, end: range.end) - case let .relabelDay(day, _, _): - day.displayDate.formatted(.dateTime.month(.abbreviated).day().year()) case let .markTravelDay(earlier, later, _): WhereFormat.resolutionAbruptRowTitle( earlier: earlier.regions, later: later.regions, ) - case let .correctFlightDay(day, _, _, _): - day.displayDate.formatted(.dateTime.month(.abbreviated).day().year()) + case let .correctSamples(proposal): + proposal.day.displayDate.formatted(.dateTime.month(.abbreviated).day().year()) } } @@ -196,24 +245,12 @@ private struct IssueRow: View { switch issue.resolution { case let .backfill(range): WhereFormat.dayCount(range.dayCount) - case let .relabelDay(_, suggested, meters): - Self.relabelSubtitle(suggested: suggested, meters: meters) case let .markTravelDay(_, later, _): later.displayDate.formatted(.dateTime.month(.abbreviated).day().year()) - case .correctFlightDay: - String(localized: .resolutionFlightRowSubtitle) + case .correctSamples: + String(localized: .flightStatusReadyTitle) } } - - private static func relabelSubtitle(suggested: Set, meters: Double?) -> String { - if let meters { - let regionName = suggested.first?.localizedName ?? "" - let distance = Measurement(value: meters, unit: UnitLength.meters) - .formatted(.measurement(width: .abbreviated, usage: .road)) - return WhereFormat.driftRowSubtitle(region: regionName, distance: distance) - } - return suggested.map(\.localizedName).sorted().joined(separator: ", ") - } } #if DEBUG @@ -225,6 +262,14 @@ private struct IssueRow: View { resolve: PreviewSupport.resolveModel(), ) } + for state in [FlightReviewPreviewState.waiting, .ready, .completed] { + whereSnapshot(name: state.rawValue, configurations: .fullContentPhoneLightDark) { + ResolutionView( + report: PreviewSupport.flightYearReportModel(state: state), + resolve: PreviewSupport.flightResolveModel(state: state), + ) + } + } whereSnapshot(name: "Empty", configurations: .phoneLightDark) { ResolutionView( report: PreviewSupport.loadedYearReportModel(), diff --git a/Where/WhereUI/Sources/Resolution/ResolveModel.swift b/Where/WhereUI/Sources/Resolution/ResolveModel.swift index 394ce88eb..5824a8dc5 100644 --- a/Where/WhereUI/Sources/Resolution/ResolveModel.swift +++ b/Where/WhereUI/Sources/Resolution/ResolveModel.swift @@ -4,24 +4,17 @@ import PeriscopeCore import RegionKit import WhereCore -/// View-scoped model for the Resolve tab: the full list of unresolved -/// data-quality issues for the selected year, plus the dismiss action. Owned as -/// `@State` by `ResolutionView`, so it's created when the Resolve tab is first -/// shown and torn down with it. -/// -/// The tab-bar badge *count* lives on the scene-scoped `YearReportModel` instead -/// (it must render before this tab is ever materialized); this model owns the -/// list the screen shows. `ResolutionView` drives `load(year:primaryRegions:)` -/// from a `.task(id:)` keyed on the report's `dataIssueScanInputs`, so the list -/// re-scans on appear, on any committed write, on a year switch, and on a -/// drift-threshold change — all while sharing the scanner's cache with the badge -/// recount. +/// Mirrors the scene's coherent scan for correction navigation and dismiss actions. +/// Standalone consumers can load through the same Core scanner. @MainActor @Observable public final class ResolveModel { /// Unresolved data-quality issues for the selected year, grouped and sorted /// by the scanner. public private(set) var dataIssues: [any DataIssue] = [] + public private(set) var reviews: [GPSCorrectionReview] = [] + public private(set) var loadError: String? + private var loadRequestID = UUID() /// Whether the first scan has completed (or a fixture was seeded). Until it /// has, `ResolutionView` shows a spinner rather than the "all clear" empty @@ -54,15 +47,22 @@ public final class ResolveModel { #if DEBUG if isSeeded { return } #endif + let requestID = UUID() + loadRequestID = requestID do { - let issues = try await services.resolution.issues( + let scan = try await services.resolution.scan( year: year, primaryRegions: primaryRegions, driftThresholdMeters: Double(preferences.driftThresholdMeters), force: false, ) - dataIssues = issues + guard loadRequestID == requestID, !Task.isCancelled else { return } + receive(scan: scan, error: nil) + } catch is CancellationError { + return } catch { + guard loadRequestID == requestID, !Task.isCancelled else { return } + loadError = error.localizedDescription // Surface the failure and keep the last good list rather than // silently blanking the tab (which would read as "all clear"). Self.logger { .dataIssueScanFailed(description: error.localizedDescription) } @@ -73,6 +73,32 @@ public final class ResolveModel { hasLoaded = true } + func receive(scan: DataIssueScanResult?, error: String?) { + #if DEBUG + if isSeeded { return } + #endif + loadError = error + if let scan { + dataIssues = scan.issues + reviews = scan.reviews + hasLoaded = true + } else if error != nil { + hasLoaded = true + } + } + + var pendingReviews: [GPSCorrectionReview] { + reviews.filter(\.isPending) + } + + var completedReviews: [GPSCorrectionReview] { + reviews.filter { !$0.isPending && $0.proposal == nil } + } + + func review(for issue: any DataIssue) -> GPSCorrectionReview? { + reviews.first { $0.id == issue.id } + } + public func dismiss(_ issue: any DataIssue) async { guard issue.isDismissible else { return } do { @@ -81,6 +107,7 @@ public final class ResolveModel { // write pings the store-change signal, so the scene's `YearReportModel` // recomputes the badge count a beat later. dataIssues.removeAll { $0.id == issue.id } + reviews.removeAll { $0.id == issue.id } } catch { Self.logger { .dismissFailed( @@ -96,6 +123,12 @@ public final class ResolveModel { @_spi(Testing) extension ResolveModel { /// Inject issues for previews/tests without seeding raw samples. Marks the /// model seeded so a subsequent `load(...)` leaves the fixture in place. + public func setReviews(_ reviews: [GPSCorrectionReview]) { + self.reviews = reviews + isSeeded = true + hasLoaded = true + } + public func setDataIssues(_ issues: [any DataIssue]) { dataIssues = issues isSeeded = true diff --git a/Where/WhereUI/Sources/Resources/Localizable.xcstrings b/Where/WhereUI/Sources/Resources/Localizable.xcstrings index 89163e7fa..80be3e7e5 100644 --- a/Where/WhereUI/Sources/Resources/Localizable.xcstrings +++ b/Where/WhereUI/Sources/Resources/Localizable.xcstrings @@ -3294,6 +3294,381 @@ } } }, + "flight.review.apply" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Apply GPS correction" + } + } + } + }, + "flight.review.changeCount" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "new", + "value" : "%lld GPS fix to correct" + } + }, + "other" : { + "stringUnit" : { + "state" : "new", + "value" : "%lld GPS fixes to correct" + } + } + } + } + } + } + }, + "flight.review.changes.description" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Only the reviewed GPS fixes change. Ground visits, uncertain readings, and manual entries are preserved. Later locations can still be recorded." + } + } + } + }, + "flight.review.changes.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "GPS changes" + } + } + } + }, + "flight.review.evidence.description" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Sustained movement supports a flight. Speed and altitude readings can add context; arrival needs a sequence of nearby, slow GPS readings." + } + } + } + }, + "flight.review.evidence.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Flight evidence" + } + } + } + }, + "flight.review.excluded" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Exclude from region totals" + } + } + } + }, + "flight.review.manualEdit" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Edit this day manually" + } + } + } + }, + "flight.review.noPresence" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "No GPS presence remains" + } + } + } + }, + "flight.review.peakSpeed" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Peak GPS speed" + } + } + } + }, + "flight.review.recordedAltitude" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Recorded altitude: %1$@ (±%2$@)" + } + } + } + }, + "flight.review.recordedSpeed" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Recorded speed: %1$@ (±%2$@)" + } + } + } + }, + "flight.review.refreshed" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "GPS evidence changed. Review the updated information before applying a correction." + } + } + } + }, + "flight.review.resultingRegions" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Regions after correction" + } + } + } + }, + "flight.review.unavailable" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "This GPS correction is no longer available. Review the updated day or edit it manually." + } + } + } + }, + "flight.review.unchanged" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Keep GPS attribution" + } + } + } + }, + "flight.status.arrival.description" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "This flight has arrival information. Corrections are waiting for the remaining flight evidence on this day." + } + } + } + }, + "flight.status.arrival.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Arrival supported" + } + } + } + }, + "flight.status.completed.description" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Arrival is supported and no GPS correction is needed." + } + } + } + }, + "flight.status.completed.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Flight completed" + } + } + } + }, + "flight.status.completedSection" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Completed flights" + } + } + } + }, + "flight.status.device.current" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Recorded on this device" + } + } + } + }, + "flight.status.device.legacy" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Recording device unavailable" + } + } + } + }, + "flight.status.device.named" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Recorded on %@" + } + } + } + }, + "flight.status.lastObservation" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Last GPS reading: %@" + } + } + } + }, + "flight.status.likely.description" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "GPS may include places passed over. Wait until you land to review corrections." + } + } + } + }, + "flight.status.likely.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Flight likely" + } + } + } + }, + "flight.status.openReview" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Open flight information and GPS correction review" + } + } + } + }, + "flight.status.pendingSection" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Waiting for arrival" + } + } + } + }, + "flight.status.ready.description" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Review the specific GPS changes before applying a correction." + } + } + } + }, + "flight.status.ready.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Ready to review" + } + } + } + }, + "flight.status.waiting.description" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Corrections will become available once location readings support arrival. You can still edit this day manually." + } + } + } + }, + "flight.status.waiting.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Waiting for arrival information" + } + } + } + }, "launch.accessibilityLabel" : { "comment" : "Spoken by VoiceOver while the launch splash is on screen (the icon and radar animation are decorative and hidden from accessibility).", "extractionState" : "manual", @@ -5358,7 +5733,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Removes any manual correction for this day and restores the regions detected from GPS. Your raw location data is untouched." + "value" : "Restore the original GPS attribution by removing this day’s manual entry and GPS corrections." } } } diff --git a/Where/WhereUI/Sources/Shared/WhereFormat.swift b/Where/WhereUI/Sources/Shared/WhereFormat.swift index f6b78101e..522b69422 100644 --- a/Where/WhereUI/Sources/Shared/WhereFormat.swift +++ b/Where/WhereUI/Sources/Shared/WhereFormat.swift @@ -34,6 +34,34 @@ enum WhereFormat { .formatted(.measurement(width: .abbreviated, usage: .asProvided)) } + /// Recorded sensor context with its reported uncertainty. Invalid values + /// have no presentable measurement; neither value supplies arrival status. + static func recordedFlightSpeed(_ speed: LocationMotion.Speed?) -> String? { + guard let speed, + speed.metersPerSecond.isFinite, speed.metersPerSecond >= 0, + speed.accuracyMetersPerSecond.isFinite, + speed.accuracyMetersPerSecond >= 0 else { return nil } + let value = Measurement(value: speed.metersPerSecond, unit: UnitSpeed.metersPerSecond) + .formatted(.measurement(width: .abbreviated, usage: .general)) + let uncertainty = Measurement( + value: speed.accuracyMetersPerSecond, + unit: UnitSpeed.metersPerSecond, + ) + .formatted(.measurement(width: .abbreviated, usage: .general)) + return String(localized: .flightReviewRecordedSpeed(value, uncertainty)) + } + + static func recordedFlightAltitude(_ altitude: LocationMotion.Altitude?) -> String? { + guard let altitude, + altitude.meters.isFinite, + altitude.accuracyMeters.isFinite, altitude.accuracyMeters > 0 else { return nil } + let value = Measurement(value: altitude.meters, unit: UnitLength.meters) + .formatted(.measurement(width: .abbreviated, usage: .general)) + let uncertainty = Measurement(value: altitude.accuracyMeters, unit: UnitLength.meters) + .formatted(.measurement(width: .abbreviated, usage: .general)) + return String(localized: .flightReviewRecordedAltitude(value, uncertainty)) + } + // MARK: Counts /// "1 day" / "5 days" — with the count rendered. diff --git a/Where/WhereUI/Sources/Shared/WhereStylesheet.swift b/Where/WhereUI/Sources/Shared/WhereStylesheet.swift index 09709d18b..d9054cb0c 100644 --- a/Where/WhereUI/Sources/Shared/WhereStylesheet.swift +++ b/Where/WhereUI/Sources/Shared/WhereStylesheet.swift @@ -19,6 +19,7 @@ struct WhereStylesheet: BStylesheet { var card = CardStyles.standard var locationCardStack = LocationCardStackStyle.standard var locationWelcome = LocationWelcomeStyle.standard + var flightStatus = FlightStatusStyle() var calendar = CalendarStyle.standard var appIcon = AppIconStyle.standard var timeline = TimelineStyle.standard @@ -100,6 +101,19 @@ struct WhereStylesheet: BStylesheet { static let `default` = WhereStylesheet() } +// MARK: - Flight status + +extension WhereStylesheet { + struct FlightStatusStyle: Equatable { + var padding: CGFloat = 14 + var spacing: CGFloat = 8 + var cornerRadius: CGFloat = 14 + var titleFont = Font.headline + var bodyFont = Font.subheadline + var background = Color(uiColor: .secondarySystemGroupedBackground) + } +} + // MARK: - Location welcome extension WhereStylesheet { diff --git a/Where/WhereUI/Tests/FlightReviewModelTests.swift b/Where/WhereUI/Tests/FlightReviewModelTests.swift new file mode 100644 index 000000000..e23df7e9c --- /dev/null +++ b/Where/WhereUI/Tests/FlightReviewModelTests.swift @@ -0,0 +1,141 @@ +import Foundation +import RegionKit +import Testing +@_spi(Testing) import WhereCore +@_spi(Testing) @testable import WhereUI + +@MainActor +struct FlightReviewModelTests { + @Test func pendingFlightCannotApply() async throws { + let store = try TestStore() + let now = FlightReviewTestSupport.date(hour: 16.5) + let services = FlightReviewTestSupport.services(store: store, now: now) + let report = YearReportModel( + services: services, + selectedYear: 2026, + preferences: makePreferences(), + now: { now }, + ) + try await FlightReviewTestSupport.seed(into: store, includeArrival: false) + await report.refresh() + await report.rescanForIssues() + let review = try #require(report.correctionReviews.first) + let model = FlightReviewModel(review: review, report: report) + + #expect(!model.canApply) + await model.apply() + #expect(model.saveState == .idle) + #expect(try await store.allSampleAttributionRevisions().isEmpty) + } + + @Test func lateEvidenceRefreshesReviewInsteadOfReportingSuccess() async throws { + let store = try TestStore() + let now = FlightReviewTestSupport.date(hour: 18) + let services = FlightReviewTestSupport.services(store: store, now: now) + let report = YearReportModel( + services: services, + selectedYear: 2026, + preferences: makePreferences(), + now: { now }, + ) + try await FlightReviewTestSupport.seed(into: store, includeArrival: true) + await report.refresh() + await report.rescanForIssues() + let review = try #require(report.correctionReviews.first { $0.proposal != nil }) + let model = FlightReviewModel(review: review, report: report) + let late = LocationSample( + timestamp: FlightReviewTestSupport.date(hour: 17.75), + coordinate: FlightReviewTestSupport.destination, + horizontalAccuracy: 20, + source: .gpsSignificantChange, + recordingDeviceID: CurrentRecordingDevice.preview.id, + ) + try await store.perform { try await store.add(sample: late) } + + await model.apply() + + #expect(model.saveState == .refreshed) + #expect(model.review != review) + #expect(try await store.allSampleAttributionRevisions().isEmpty) + } + + @Test func appliesReviewedSamplesAndRefreshesScene() async throws { + let store = try TestStore() + let now = FlightReviewTestSupport.date(hour: 18) + let services = FlightReviewTestSupport.services(store: store, now: now) + let report = YearReportModel( + services: services, + selectedYear: 2026, + preferences: makePreferences(), + now: { now }, + ) + try await FlightReviewTestSupport.seed(into: store, includeArrival: true) + await report.refresh() + await report.rescanForIssues() + let review = try #require(report.correctionReviews.first { $0.proposal != nil }) + let proposal = try #require(review.proposal) + let model = FlightReviewModel(review: review, report: report) + + await model.apply() + + #expect(model.saveState == .applied) + #expect(!model.canApply) + #expect(try await store.allSampleAttributionRevisions().count == proposal.edits.count) + #expect(report.correctionReviews.allSatisfy { $0.proposal == nil }) + } + + @Test func failedApplyKeepsTheReviewedProposalVisible() async throws { + let store = try TestStore() + let now = FlightReviewTestSupport.date(hour: 18) + let services = FlightReviewTestSupport.services(store: store, now: now) + let report = YearReportModel( + services: services, + selectedYear: 2026, + preferences: makePreferences(), + now: { now }, + ) + try await FlightReviewTestSupport.seed(into: store, includeArrival: true) + await report.refresh() + await report.rescanForIssues() + let review = try #require(report.correctionReviews.first { $0.proposal != nil }) + let model = FlightReviewModel(review: review, report: report) + await store.failSamples() + + await model.apply() + + guard case .failed = model.saveState else { + Issue.record("Expected an observable correction failure") + return + } + #expect(model.review == review) + #expect(model.canApply) + #expect(try await store.allSampleAttributionRevisions().isEmpty) + } + + @Test func openReviewReceivesArrivalAndRevokedCorrection() { + let report = PreviewSupport.loadedYearReportModel() + let pending = PreviewSupport.flightReview(state: .flightLikely) + let ready = PreviewSupport.flightReview(state: .ready) + let model = FlightReviewModel(review: pending, report: report) + #expect(!model.canApply) + + model.receive(DataIssueScanResult( + revision: UUID(), + issues: [], + reviews: [ready], + nextReassessmentAt: nil, + )) + #expect(model.canApply) + #expect(model.saveState == .refreshed) + + model.receive(DataIssueScanResult( + revision: UUID(), + issues: [], + reviews: [], + nextReassessmentAt: nil, + )) + #expect(!model.canApply) + #expect(model.review == nil) + #expect(model.initialDay == pending.day) + } +} diff --git a/Where/WhereUI/Tests/ResolveModelTests.swift b/Where/WhereUI/Tests/ResolveModelTests.swift index e13d2cfa6..4f4882c9d 100644 --- a/Where/WhereUI/Tests/ResolveModelTests.swift +++ b/Where/WhereUI/Tests/ResolveModelTests.swift @@ -85,110 +85,69 @@ struct ResolveModelTests { @Test func loadSurfacesFlightDayIssue() async throws { let store = try TestStore() let now = date(year: 2026, month: 6, day: 15) - let services = flightServices(store: store, now: now) + let services = FlightReviewTestSupport.services(store: store, now: now) let resolve = ResolveModel( services: services, preferences: makePreferences(), ) - try await seedCoastToCoastFlight(into: store) + try await FlightReviewTestSupport.seed(into: store, includeArrival: true) await resolve.load(year: 2026, primaryRegions: [.california, .newYork]) let flight = try #require(resolve.dataIssues.first { $0.category == .flightDay }) - guard case let .correctFlightDay(_, keep, removed, peak) = flight.resolution else { - Issue.record("expected correctFlightDay resolution") + guard case let .correctSamples(proposal) = flight.resolution else { + Issue.record("expected sample correction resolution") return } - #expect(keep == [.newYork, .california]) - #expect(removed == [.other]) - #expect(peak > 300) + #expect(proposal.resultingRegions == [.newYork, .california]) + #expect(!proposal.edits.isEmpty) + #expect(resolve.review(for: flight)?.flight?.peakSpeedKMH ?? 0 > 450) } - /// Idempotence: applying the one-tap fix (an authoritative `overrideDay` to - /// the kept regions, exactly what `FlightDayDetailView` does) clears the - /// issue — a rescan no longer flags the day, so "Apply" visibly resolves it - /// rather than leaving a sticky row. + /// Applying reviewed samples clears the actionable issue while retaining + /// the completed flight information. @Test func applyingFlightFixClearsTheIssue() async throws { let store = try TestStore() let now = date(year: 2026, month: 6, day: 15) - let services = flightServices(store: store, now: now) + let services = FlightReviewTestSupport.services(store: store, now: now) let resolve = ResolveModel( services: services, preferences: makePreferences(), ) - try await seedCoastToCoastFlight(into: store) + try await FlightReviewTestSupport.seed(into: store, includeArrival: true) await resolve.load(year: 2026, primaryRegions: [.california, .newYork]) let flight = try #require(resolve.dataIssues.first { $0.category == .flightDay }) - guard case let .correctFlightDay(_, keep, _, _) = flight.resolution else { - Issue.record("expected correctFlightDay resolution") + guard case let .correctSamples(proposal) = flight.resolution else { + Issue.record("expected sample correction resolution") + return + } + let result = try await services.corrections.apply(proposal) + guard case .applied = result else { + Issue.record("expected correction to apply") return } - - // Apply the fix the same way the detail view's "Apply" button does, then - // drop the scanner cache so the reload sees a fresh scan. - try await services.journal.overrideDay( - date: flightSampleDate(hour: 12), - regions: keep, - audit: nil, - ) await services.resolution.invalidate() await resolve.load(year: 2026, primaryRegions: [.california, .newYork]) #expect(!resolve.dataIssues.contains { $0.category == .flightDay }) } - private func flightServices(store: TestStore, now: Date) -> WhereServices { - WhereServices( + @Test func ongoingFlightRemainsReviewableWithoutAnActionableFlightIssue() async throws { + let store = try TestStore() + let services = FlightReviewTestSupport.services( store: store, - locationSource: ScriptedLocationSource(), - reminderScheduler: NoopLoggingReminderScheduler(), - widgetRefresher: NoopWidgetTimelineRefresher(), - now: { now }, + now: FlightReviewTestSupport.date(hour: 16.5), ) - } + let resolve = ResolveModel(services: services, preferences: makePreferences()) + try await FlightReviewTestSupport.seed(into: store, includeArrival: false) - /// Seed the NYC→SF cruise profile as passive GPS fixes: grounded NY in the - /// morning, cruise-speed legs across untracked states, then grounded SFO. - private func seedCoastToCoastFlight(into store: TestStore) async throws { - let jfk = Coordinate(latitude: 40.6413, longitude: -73.7781) - let sfo = Coordinate(latitude: 37.6213, longitude: -122.3790) - let illinois = Coordinate(latitude: 40.29, longitude: -90.39) - let colorado = Coordinate(latitude: 39.53, longitude: -106.16) - let nevada = Coordinate(latitude: 38.68, longitude: -116.90) - let fixes: [(Double, Coordinate)] = [ - (8.0, jfk), - (8.5, jfk), - (12.0, jfk), - (13.5, illinois), - (15.0, colorado), - (16.5, nevada), - (17.5, sfo), - (18.0, sfo), - ] - try await store.perform { - for (hour, coordinate) in fixes { - try await store.add(sample: LocationSample( - timestamp: flightSampleDate(hour: hour), - coordinate: coordinate, - horizontalAccuracy: 20, - source: .gpsSignificantChange, - )) - } - } - } + await resolve.load(year: 2026, primaryRegions: [.newYork, .california]) - private func flightSampleDate(hour: Double) -> Date { - let wholeHour = Int(hour) - let minutes = Int((hour - Double(wholeHour)) * 60) - return Calendar.current.date(from: DateComponents( - year: 2026, - month: 3, - day: 15, - hour: wholeHour, - minute: minutes, - ))! + #expect(resolve.dataIssues.allSatisfy { $0.category != .flightDay }) + #expect(resolve.pendingReviews.count == 1) + #expect(resolve.pendingReviews.first?.proposal == nil) } /// The empty-state guard: `hasLoaded` starts false and flips once the first diff --git a/Where/WhereUI/Tests/Support/FlightReviewTestSupport.swift b/Where/WhereUI/Tests/Support/FlightReviewTestSupport.swift new file mode 100644 index 000000000..c01cf74f4 --- /dev/null +++ b/Where/WhereUI/Tests/Support/FlightReviewTestSupport.swift @@ -0,0 +1,66 @@ +import Foundation +import RegionKit +@_spi(Testing) import WhereCore + +/// Public airport coordinates and synthetic timing; contains no exported user GPS data. +enum FlightReviewTestSupport { + struct Fix { + let hour: Double + let coordinate: Coordinate + } + + static let destination = Coordinate(latitude: 37.6213, longitude: -122.3790) + + static func date(hour: Double) -> Date { + Calendar.current.date(from: DateComponents( + year: 2026, + month: 3, + day: 15, + hour: Int(hour), + minute: Int(((hour - Double(Int(hour))) * 60).rounded()), + ))! + } + + static func services(store: TestStore, now: Date) -> WhereServices { + WhereServices( + store: store, + locationSource: ScriptedLocationSource(), + reminderScheduler: NoopLoggingReminderScheduler(), + widgetRefresher: NoopWidgetTimelineRefresher(), + now: { now }, + ) + } + + static func seed(into store: TestStore, includeArrival: Bool) async throws { + let origin = Coordinate(latitude: 40.6413, longitude: -73.7781) + var fixes = [ + Fix(hour: 8, coordinate: origin), + Fix(hour: 8.5, coordinate: origin), + Fix(hour: 12, coordinate: origin), + Fix(hour: 13.5, coordinate: Coordinate(latitude: 40.29, longitude: -90.39)), + Fix(hour: 15, coordinate: Coordinate(latitude: 39.53, longitude: -106.16)), + Fix(hour: 16.5, coordinate: Coordinate(latitude: 38.68, longitude: -116.90)), + ] + if includeArrival { + fixes.append(contentsOf: [ + Fix(hour: 17.5, coordinate: destination), + Fix(hour: 17.5 + 5.0 / 60, coordinate: destination), + Fix(hour: 17.5 + 10.0 / 60, coordinate: destination), + ]) + } + let samples = fixes.map { fix in + LocationSample( + timestamp: date(hour: fix.hour), + coordinate: fix.coordinate, + horizontalAccuracy: 20, + source: .gpsSignificantChange, + recordingDeviceID: CurrentRecordingDevice.preview.id, + ) + } + try await store.perform { + for sample in samples { + try await store.add(sample: sample) + } + } + } +} diff --git a/Where/WhereUI/Tests/Support/TestStore.swift b/Where/WhereUI/Tests/Support/TestStore.swift index b92d688d1..a40679b80 100644 --- a/Where/WhereUI/Tests/Support/TestStore.swift +++ b/Where/WhereUI/Tests/Support/TestStore.swift @@ -170,6 +170,20 @@ actor TestStore: WhereStore { try await backing.allSamples() } + func sampleAttributionRevisions(for sampleIDs: Set) async throws + -> [SampleAttributionRevision] + { + try await backing.sampleAttributionRevisions(for: sampleIDs) + } + + func allSampleAttributionRevisions() async throws -> [SampleAttributionRevision] { + try await backing.allSampleAttributionRevisions() + } + + func addSampleAttributionRevision(_ revision: SampleAttributionRevision) async throws { + try await backing.addSampleAttributionRevision(revision) + } + func recordingDevices() async throws -> [RecordingDevice] { let devices = try await backing.recordingDevices() guard let calls = recordingDeviceCallsBeforeGate else { return devices } diff --git a/Where/WhereUI/Tests/WhereFormatTests.swift b/Where/WhereUI/Tests/WhereFormatTests.swift index b16f3d678..831e5c99e 100644 --- a/Where/WhereUI/Tests/WhereFormatTests.swift +++ b/Where/WhereUI/Tests/WhereFormatTests.swift @@ -10,6 +10,33 @@ import WhereCore /// catalog value. A removed/renamed key is caught by the compiler, so these /// tests focus on the runtime logic, not that every simple symbol exists. struct WhereFormatTests { + @Test func unavailableRecordedMotionHasNoPresentation() { + #expect(WhereFormat.recordedFlightSpeed(nil) == nil) + #expect(WhereFormat.recordedFlightAltitude(nil) == nil) + #expect(WhereFormat.recordedFlightSpeed(.init( + metersPerSecond: 200, + accuracyMetersPerSecond: -1, + )) == nil) + #expect(WhereFormat + .recordedFlightAltitude(.init(meters: .infinity, accuracyMeters: 20)) == nil) + } + + @Test func recordedMotionLabelsRetainUncertaintyAndBelowSeaLevelAltitude() throws { + let speed = try #require(WhereFormat.recordedFlightSpeed(.init( + metersPerSecond: 250, + accuracyMetersPerSecond: 2, + ))) + let altitude = try #require(WhereFormat.recordedFlightAltitude(.init( + meters: -30, + accuracyMeters: 5, + ))) + + #expect(speed.hasPrefix("Recorded speed: ")) + #expect(speed.contains("±")) + #expect(altitude.hasPrefix("Recorded altitude: -")) + #expect(altitude.contains("±")) + } + @Test func generatedSymbolsResolveToCatalogValues() { #expect(String(localized: .tabSettings) == "Settings") #expect(String(localized: .commonOk) == "OK") diff --git a/Where/WhereUI/Tests/WhereStylesheetTests.swift b/Where/WhereUI/Tests/WhereStylesheetTests.swift index 1670ffabe..3c2d8fbd6 100644 --- a/Where/WhereUI/Tests/WhereStylesheetTests.swift +++ b/Where/WhereUI/Tests/WhereStylesheetTests.swift @@ -45,6 +45,15 @@ struct WhereStylesheetTests { #expect(style.spacing.xxxLarge == 20) } + @Test func flightStatusStyle() { + #expect(style.flightStatus.padding == 14) + #expect(style.flightStatus.spacing == 8) + #expect(style.flightStatus.cornerRadius == 14) + #expect(style.flightStatus.titleFont == .headline) + #expect(style.flightStatus.bodyFont == .subheadline) + #expect(style.flightStatus.background == Color(uiColor: .secondarySystemGroupedBackground)) + } + @Test func locationWelcomeStyle() { let welcome = style.locationWelcome #expect(welcome.maxWidth == 390) diff --git a/Where/WhereUI/Tests/YearReportModelTests.swift b/Where/WhereUI/Tests/YearReportModelTests.swift index 2fd0c3597..fdf9f0f36 100644 --- a/Where/WhereUI/Tests/YearReportModelTests.swift +++ b/Where/WhereUI/Tests/YearReportModelTests.swift @@ -2,7 +2,7 @@ import Foundation import TestHostSupport import Testing @_spi(Testing) import WhereCore -@testable import WhereUI +@_spi(Testing) @testable import WhereUI /// Covers `YearReportModel`: the year report load (out-of-order year fetches, failed /// manual saves), the missing-day computation the banner / backfill read, the @@ -362,6 +362,147 @@ struct YearReportModelTests { #expect(report.dataIssueScanInputs != before) } + @Test func rawGPSWriteRekeysReviewWhenYearReportIsUnchanged() async throws { + let store = try TestStore() + let now = FlightReviewTestSupport.date(hour: 18) + let services = FlightReviewTestSupport.services(store: store, now: now) + let report = YearReportModel( + services: services, + selectedYear: 2026, + preferences: makePreferences(), + now: { now }, + ) + let first = LocationSample( + timestamp: FlightReviewTestSupport.date(hour: 17), + coordinate: FlightReviewTestSupport.destination, + horizontalAccuracy: 20, + source: .gpsSignificantChange, + ) + try await store.perform { try await store.add(sample: first) } + await report.activate() + defer { report.deactivate() } + let beforeReport = report.report + let beforeInputs = report.dataIssueScanInputs + let beforeScan = report.dataIssueScan?.revision + let later = LocationSample( + timestamp: FlightReviewTestSupport.date(hour: 17.5), + coordinate: FlightReviewTestSupport.destination, + horizontalAccuracy: 20, + source: .gpsSignificantChange, + ) + + try await store.perform { try await store.add(sample: later) } + await waitUntil { report.dataIssueScan?.revision != beforeScan } + + #expect(report.report == beforeReport) + #expect(report.dataIssueScanInputs != beforeInputs) + } + + @Test func liveFlightNoticeIsCurrentDeviceOnlyAndExpiresWithoutRemovingHistory() throws { + let report = PreviewSupport.loadedYearReportModel() + let current = PreviewSupport.flightReview(state: .flightLikely) + let flight = try #require(current.flight) + let remoteFlight = FlightAssessment( + id: .init( + recordingDeviceID: RecordingDeviceID(rawValue: UUID()), + departureSampleID: flight.id.departureSampleID, + ), + startedAt: flight.startedAt, + lastObservationAt: flight.lastObservationAt, + lastFlightAt: flight.lastFlightAt, + airborneSampleIDs: flight.airborneSampleIDs, + groundSampleIDs: flight.groundSampleIDs, + peakSpeedKMH: flight.peakSpeedKMH, + progress: flight.progress, + ) + let remote = GPSCorrectionReview( + id: current.id, + day: current.day, + points: [], + state: .pending(remoteFlight), + ) + report.setDataIssueScan(DataIssueScanResult( + revision: UUID(), + issues: [], + reviews: [remote], + nextReassessmentAt: nil, + )) + #expect(report.liveFlightReview == nil) + #expect(report.hasCorrectionReviews) + #expect(report.dataIssueCount == 0) + + let shared = GPSCorrectionReview( + id: current.id, + day: current.day, + points: [], + state: .pending(remoteFlight), + flights: [flight, remoteFlight], + ) + report.setDataIssueScan(DataIssueScanResult( + revision: UUID(), + issues: [], + reviews: [shared], + nextReassessmentAt: nil, + )) + #expect(report.liveFlightReview == shared) + #expect(report.liveFlightAssessment(in: shared) == flight) + + report.setDataIssueScan(DataIssueScanResult( + revision: UUID(), + issues: [], + reviews: [current], + nextReassessmentAt: nil, + )) + #expect(report.liveFlightReview == current) + + let expiredFlight = FlightAssessment( + id: flight.id, + startedAt: flight.startedAt.addingTimeInterval(-24 * 60 * 60), + lastObservationAt: flight.lastObservationAt.addingTimeInterval(-24 * 60 * 60), + lastFlightAt: flight.lastFlightAt.addingTimeInterval(-24 * 60 * 60), + airborneSampleIDs: flight.airborneSampleIDs, + groundSampleIDs: flight.groundSampleIDs, + peakSpeedKMH: flight.peakSpeedKMH, + progress: .awaitingArrival, + ) + let expired = GPSCorrectionReview( + id: current.id, + day: current.day, + points: [], + state: .pending(expiredFlight), + ) + report.setDataIssueScan(DataIssueScanResult( + revision: UUID(), + issues: [], + reviews: [expired], + nextReassessmentAt: nil, + )) + #expect(report.liveFlightReview == nil) + #expect(report.correctionReviews == [expired]) + } + + @Test func deactivationPreventsAnInFlightActivationFromStartingNewScans() async throws { + let store = try TestStore() + let now = FlightReviewTestSupport.date(hour: 18) + let services = FlightReviewTestSupport.services(store: store, now: now) + let report = YearReportModel( + services: services, + selectedYear: 2026, + preferences: makePreferences(), + now: { now }, + ) + await store.enableFirstSamplesGate() + let activation = Task { await report.activate() } + await store.awaitFirstSamplesCall() + + report.deactivate() + await store.releaseFirstSamplesCall() + await activation.value + + #expect(report.report == nil) + #expect(report.dataIssueScan == nil) + } + // MARK: - Store-change observer /// The write path no longer refreshes inline: a manual edit commits, the