From cc08b0ad82653e5ffd272a838df1eb0bf41293d5 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 12 Aug 2026 21:50:12 -0700 Subject: [PATCH 1/2] Guard Settings navigation content footprints --- .agents/skills/building-ui/SKILL.md | 5 ++ Where/WhereUI/AGENTS.md | 5 ++ .../Sources/Settings/SettingsRow.swift | 17 ++++++- .../Sources/Settings/SettingsView.swift | 20 +++++++- Where/WhereUI/Tests/SettingsViewTests.swift | 46 +++++++++++++++++++ 5 files changed, 91 insertions(+), 2 deletions(-) create mode 100644 Where/WhereUI/Tests/SettingsViewTests.swift diff --git a/.agents/skills/building-ui/SKILL.md b/.agents/skills/building-ui/SKILL.md index 7781abc6..12bbe924 100644 --- a/.agents/skills/building-ui/SKILL.md +++ b/.agents/skills/building-ui/SKILL.md @@ -113,6 +113,11 @@ module-owned stylesheets. than remain at zero opacity. - Keep the visual structure consistent across states and variants unless the difference is intentional and modeled by the component style. +- Put a large declarative subtree behind a nominal child `View` before passing + it through a custom generic container that stores or repeatedly transforms + its `Content`. In particular, do not pass a multi-section `Form` or `List` + directly into such a wrapper: SwiftUI may copy the full concrete value on the + stack while applying environment or navigation updates. ## Build for accessibility and localization diff --git a/Where/WhereUI/AGENTS.md b/Where/WhereUI/AGENTS.md index 1148b387..2e587675 100644 --- a/Where/WhereUI/AGENTS.md +++ b/Where/WhereUI/AGENTS.md @@ -83,6 +83,11 @@ Layering, localization, preview, and testing conventions live in the feature ([`Sources/Shared/MotionIsStatic.swift`](Sources/Shared/MotionIsStatic.swift)) for its static end-state — never hand-roll the `\.accessibilityReduceMotion` + `\.isCapturingSnapshot` pair. +- Keep `SettingsFocusScope` content below its guarded value-size limit by + passing large `Form`/`List` trees through a nominal child view. Diagnostic: + `EXC_BAD_ACCESS` / `___chkstk_darwin` followed by `View.environment` while + preparing a navigation push means inspect the concrete content stored by a + generic wrapper (`SettingsViewTests` exercises every registered route). - A step joins `WhereLaunch`'s plan through `.measured()` and so must declare a `budget` (`BudgetedLaunchStep`) — see [Spans](../AGENTS.md#spans). WhereUI also owns log retention: `LogHistoryPruner` bounds the store by age *and* event diff --git a/Where/WhereUI/Sources/Settings/SettingsRow.swift b/Where/WhereUI/Sources/Settings/SettingsRow.swift index d5d2fa2f..323e07f9 100644 --- a/Where/WhereUI/Sources/Settings/SettingsRow.swift +++ b/Where/WhereUI/Sources/Settings/SettingsRow.swift @@ -97,6 +97,14 @@ struct SettingsRowModifier: ViewModifier { /// (no fade — still scrolls and shows a brief static highlight). Flashes once per /// appearance so returning to the screen doesn't re-flash. struct SettingsFocusScope: View { + /// Separates the largest known-safe Settings form (46,912 bytes) from the + /// 62,680-byte concrete value that overflowed a device stack while SwiftUI + /// applied this scope's environment during a navigation push. Large trees + /// belong behind a small nominal child view before crossing this boundary. + static var maximumContentFootprint: Int { + 56 * 1024 + } + let focus: SettingsFocus? let isReady: Bool let content: Content @@ -113,7 +121,14 @@ struct SettingsFocusScope: View { ) { self.focus = focus self.isReady = isReady - self.content = content() + let content = content() + let contentFootprint = MemoryLayout.size + precondition( + contentFootprint <= Self.maximumContentFootprint, + "SettingsFocusScope Content is \(contentFootprint) bytes; extract its large " + + "Form/List subtree behind a nominal child View.", + ) + self.content = content } var body: some View { diff --git a/Where/WhereUI/Sources/Settings/SettingsView.swift b/Where/WhereUI/Sources/Settings/SettingsView.swift index ed2cd5c7..b95728fc 100644 --- a/Where/WhereUI/Sources/Settings/SettingsView.swift +++ b/Where/WhereUI/Sources/Settings/SettingsView.swift @@ -20,6 +20,7 @@ struct SettingsView: View { let recordingWarning: RecordingConfigurationWarningModel @State private var backup: BackupModel @State private var reminders: RemindersSettingsModel + @State private var path: [SettingsRoute] @State private var searchText = "" @State private var showRegions = false @@ -31,6 +32,14 @@ struct SettingsView: View { init( report: YearReportModel, recordingWarning: RecordingConfigurationWarningModel? = nil, + ) { + self.init(report: report, recordingWarning: recordingWarning, initialPath: []) + } + + private init( + report: YearReportModel, + recordingWarning: RecordingConfigurationWarningModel?, + initialPath: [SettingsRoute], ) { self.report = report self.recordingWarning = recordingWarning ?? RecordingConfigurationWarningModel( @@ -42,8 +51,17 @@ struct SettingsView: View { preferences: report.preferences, now: report.now, )) + _path = State(initialValue: initialPath) } + #if DEBUG + /// Drives the production `NavigationStack` to one destination in tests. + /// The test-only seam stays out of release and creates no parallel route. + init(report: YearReportModel, testingRoute route: SettingsRoute) { + self.init(report: report, recordingWarning: nil, initialPath: [route]) + } + #endif + private var searchQuery: String { searchText.trimmingCharacters(in: .whitespacesAndNewlines) } @@ -67,7 +85,7 @@ struct SettingsView: View { } var body: some View { - NavigationStack { + NavigationStack(path: $path) { List { if isSearching { ForEach(searchResults) { result in diff --git a/Where/WhereUI/Tests/SettingsViewTests.swift b/Where/WhereUI/Tests/SettingsViewTests.swift new file mode 100644 index 00000000..5b99070f --- /dev/null +++ b/Where/WhereUI/Tests/SettingsViewTests.swift @@ -0,0 +1,46 @@ +import SwiftUI +import TestHostSupport +import Testing +@testable import WhereUI + +/// Exercises every typed Settings route through the production navigation +/// stack. This is also the executable tripwire for oversized concrete content +/// stored by `SettingsFocusScope` while SwiftUI prepares a destination. +@MainActor +struct SettingsViewTests { + @Test func everyPushDestinationHostsThroughProductionNavigation() throws { + let report = PreviewSupport.loadedYearReportModel() + let model = PreviewSupport.loadedModel() + let session = PreviewSupport.loadedSession() + + for destination in SettingsDestination.allCases where !destination.isSheet { + let rootView = SettingsView( + report: report, + testingRoute: SettingsRoute(destination), + ) + .environment(model) + .environment(session) + + try show(UIHostingController(rootView: rootView)) { hosted in + try waitFor { + hosted.viewIfLoaded?.window != nil + && navigationController(in: hosted)?.viewControllers.count == 2 + } + } + } + } + + private func navigationController( + in viewController: UIViewController, + ) -> UINavigationController? { + if let navigationController = viewController as? UINavigationController { + return navigationController + } + for child in viewController.children { + if let navigationController = navigationController(in: child) { + return navigationController + } + } + return nil + } +} From 2aee2faa1c2e5e8c6ec5b938ea78ea93debe0e18 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Thu, 13 Aug 2026 11:23:59 -0700 Subject: [PATCH 2/2] Fix Settings navigation guard coverage --- .agents/skills/building-ui/SKILL.md | 4 +- Where/WhereUI/AGENTS.md | 14 +- .../Evidence/ShareEvidenceFeaturesView.swift | 2 +- .../Sources/Settings/SettingsRouteView.swift | 72 +++++++ .../Sources/Settings/SettingsRow.swift | 33 +-- .../Sources/Settings/SettingsSearch.swift | 4 +- .../Sources/Settings/SettingsView.swift | 87 +------- .../Tests/SettingsRouteViewTests.swift | 193 ++++++++++++++++++ Where/WhereUI/Tests/SettingsViewTests.swift | 46 ----- 9 files changed, 305 insertions(+), 150 deletions(-) create mode 100644 Where/WhereUI/Sources/Settings/SettingsRouteView.swift create mode 100644 Where/WhereUI/Tests/SettingsRouteViewTests.swift delete mode 100644 Where/WhereUI/Tests/SettingsViewTests.swift diff --git a/.agents/skills/building-ui/SKILL.md b/.agents/skills/building-ui/SKILL.md index 12bbe924..54fd6f13 100644 --- a/.agents/skills/building-ui/SKILL.md +++ b/.agents/skills/building-ui/SKILL.md @@ -117,7 +117,9 @@ module-owned stylesheets. it through a custom generic container that stores or repeatedly transforms its `Content`. In particular, do not pass a multi-section `Form` or `List` directly into such a wrapper: SwiftUI may copy the full concrete value on the - stack while applying environment or navigation updates. + stack while applying environment or navigation updates. Treat a DEBUG-only + content-footprint guard as a heuristic tripwire for extraction, not a + shipping layout contract or a threshold to retune around. ## Build for accessibility and localization diff --git a/Where/WhereUI/AGENTS.md b/Where/WhereUI/AGENTS.md index 2e587675..a1afa4f2 100644 --- a/Where/WhereUI/AGENTS.md +++ b/Where/WhereUI/AGENTS.md @@ -83,11 +83,15 @@ Layering, localization, preview, and testing conventions live in the feature ([`Sources/Shared/MotionIsStatic.swift`](Sources/Shared/MotionIsStatic.swift)) for its static end-state — never hand-roll the `\.accessibilityReduceMotion` + `\.isCapturingSnapshot` pair. -- Keep `SettingsFocusScope` content below its guarded value-size limit by - passing large `Form`/`List` trees through a nominal child view. Diagnostic: - `EXC_BAD_ACCESS` / `___chkstk_darwin` followed by `View.environment` while - preparing a navigation push means inspect the concrete content stored by a - generic wrapper (`SettingsViewTests` exercises every registered route). +- Pass large `Form`/`List` subtrees through a nominal child before + `SettingsFocusScope`; `SettingsRouteViewTests` exercises every push + destination and its nested heterogeneous routes against the DEBUG-only + value-size tripwire. +- Keep the Settings content-footprint tripwire DEBUG-only; Release has no + footprint precondition. +- Diagnostic: `EXC_BAD_ACCESS` / `___chkstk_darwin` followed by + `View.environment` during a navigation push means inspect concrete content + stored by generic wrappers. - A step joins `WhereLaunch`'s plan through `.measured()` and so must declare a `budget` (`BudgetedLaunchStep`) — see [Spans](../AGENTS.md#spans). WhereUI also owns log retention: `LogHistoryPruner` bounds the store by age *and* event diff --git a/Where/WhereUI/Sources/Settings/FeaturePreviews/Evidence/ShareEvidenceFeaturesView.swift b/Where/WhereUI/Sources/Settings/FeaturePreviews/Evidence/ShareEvidenceFeaturesView.swift index dc69959c..9c51bc91 100644 --- a/Where/WhereUI/Sources/Settings/FeaturePreviews/Evidence/ShareEvidenceFeaturesView.swift +++ b/Where/WhereUI/Sources/Settings/FeaturePreviews/Evidence/ShareEvidenceFeaturesView.swift @@ -116,7 +116,7 @@ struct ShareEvidenceFeaturesView: View { } } - private enum Route: Hashable { + enum Route: Hashable { case archive } } diff --git a/Where/WhereUI/Sources/Settings/SettingsRouteView.swift b/Where/WhereUI/Sources/Settings/SettingsRouteView.swift new file mode 100644 index 00000000..f4ee599c --- /dev/null +++ b/Where/WhereUI/Sources/Settings/SettingsRouteView.swift @@ -0,0 +1,72 @@ +import SwiftUI + +/// Builds one pushed Settings destination from the root's typed route and +/// view-scoped collaborators. Keeping route rendering in a nominal child lets +/// tests exercise the same destination tree without a test-only Settings API. +struct SettingsRouteView: View { + let route: SettingsRoute + let report: YearReportModel + let backup: BackupModel + let reminders: RemindersSettingsModel + + @Environment(WhereSession.self) private var session + + var body: some View { + switch route.destination { + case .attachments: + EvidenceListView(report: report) + case .loggedDays: + LoggedDaysView(report: report) + case .devices: + DevicesSettingsView(session: session, focus: route.focus) + case .regions: + // Regions is presented as a sheet (`isSheet`), so it's never + // routed here; this arm only keeps the switch exhaustive. + EmptyView() + case .alerts: + AlertsSettingsView(report: report, reminders: reminders, focus: route.focus) + case .appearance: + AppearanceSettingsView(report: report, focus: route.focus) + case .year: + VisibleYearSettingsView(report: report, focus: route.focus) + case .siri: + SiriFeaturesView( + focus: route.focus, + presentation: featureDiscoveryPresentation, + ) + case .widgets: + WidgetFeaturesView( + focus: route.focus, + presentation: featureDiscoveryPresentation, + ) + case .shareEvidence: + ShareEvidenceFeaturesView( + report: report, + focus: route.focus, + presentation: featureDiscoveryPresentation, + ) + case .estimatedTime: + EstimatedTimeFeaturesView(report: report, focus: route.focus) + case .insightsAccuracy: + InsightsAccuracyFeaturesView( + report: report, + focus: route.focus, + ) + case .personalization: + PersonalizationFeaturesView(report: report, focus: route.focus) + case .data: + DataSettingsView(report: report, backup: backup, focus: route.focus) + case .about: + AboutSettingsView(focus: route.focus) + } + } + + private var featureDiscoveryPresentation: FeatureDiscoveryPresentation { + FeatureDiscoveryPresentation( + report: report.report, + selectedYear: report.selectedYear, + referenceDate: report.referenceDate, + calendar: report.calendar, + ) + } +} diff --git a/Where/WhereUI/Sources/Settings/SettingsRow.swift b/Where/WhereUI/Sources/Settings/SettingsRow.swift index 323e07f9..3677f261 100644 --- a/Where/WhereUI/Sources/Settings/SettingsRow.swift +++ b/Where/WhereUI/Sources/Settings/SettingsRow.swift @@ -97,13 +97,15 @@ struct SettingsRowModifier: ViewModifier { /// (no fade — still scrolls and shows a brief static highlight). Flashes once per /// appearance so returning to the screen doesn't re-flash. struct SettingsFocusScope: View { - /// Separates the largest known-safe Settings form (46,912 bytes) from the - /// 62,680-byte concrete value that overflowed a device stack while SwiftUI - /// applied this scope's environment during a navigation push. Large trees - /// belong behind a small nominal child view before crossing this boundary. - static var maximumContentFootprint: Int { - 56 * 1024 - } + #if DEBUG + /// A DEBUG-only heuristic separating the largest known-safe Settings form + /// (46,912 bytes) from the 62,680-byte concrete value that overflowed a + /// device stack while SwiftUI applied this scope's environment during a + /// navigation push. Large trees belong behind a small nominal child view. + static var maximumContentFootprint: Int { + 56 * 1024 + } + #endif let focus: SettingsFocus? let isReady: Bool @@ -121,14 +123,15 @@ struct SettingsFocusScope: View { ) { self.focus = focus self.isReady = isReady - let content = content() - let contentFootprint = MemoryLayout.size - precondition( - contentFootprint <= Self.maximumContentFootprint, - "SettingsFocusScope Content is \(contentFootprint) bytes; extract its large " - + "Form/List subtree behind a nominal child View.", - ) - self.content = content + #if DEBUG + let contentFootprint = MemoryLayout.size + precondition( + contentFootprint <= Self.maximumContentFootprint, + "SettingsFocusScope Content is \(contentFootprint) bytes; extract its large " + + "Form/List subtree behind a nominal child View.", + ) + #endif + self.content = content() } var body: some View { diff --git a/Where/WhereUI/Sources/Settings/SettingsSearch.swift b/Where/WhereUI/Sources/Settings/SettingsSearch.swift index 9473e80d..6a416d0a 100644 --- a/Where/WhereUI/Sources/Settings/SettingsSearch.swift +++ b/Where/WhereUI/Sources/Settings/SettingsSearch.swift @@ -3,8 +3,8 @@ import SwiftUI /// The top-level Settings groups. Each drills into its own sub-screen; the /// top-level list and `SettingsRoute` route on these, and the -/// `navigationDestination` switch (in `SettingsView`) builds a screen for every -/// case with no `default:`, so adding a case is a compile error until wired. +/// `SettingsRouteView` switch builds a screen for every case with no `default:`, +/// so adding a case is a compile error until wired. enum SettingsDestination: Hashable, CaseIterable { case attachments case loggedDays diff --git a/Where/WhereUI/Sources/Settings/SettingsView.swift b/Where/WhereUI/Sources/Settings/SettingsView.swift index b95728fc..cc5daeaf 100644 --- a/Where/WhereUI/Sources/Settings/SettingsView.swift +++ b/Where/WhereUI/Sources/Settings/SettingsView.swift @@ -20,7 +20,6 @@ struct SettingsView: View { let recordingWarning: RecordingConfigurationWarningModel @State private var backup: BackupModel @State private var reminders: RemindersSettingsModel - @State private var path: [SettingsRoute] @State private var searchText = "" @State private var showRegions = false @@ -32,14 +31,6 @@ struct SettingsView: View { init( report: YearReportModel, recordingWarning: RecordingConfigurationWarningModel? = nil, - ) { - self.init(report: report, recordingWarning: recordingWarning, initialPath: []) - } - - private init( - report: YearReportModel, - recordingWarning: RecordingConfigurationWarningModel?, - initialPath: [SettingsRoute], ) { self.report = report self.recordingWarning = recordingWarning ?? RecordingConfigurationWarningModel( @@ -51,17 +42,8 @@ struct SettingsView: View { preferences: report.preferences, now: report.now, )) - _path = State(initialValue: initialPath) } - #if DEBUG - /// Drives the production `NavigationStack` to one destination in tests. - /// The test-only seam stays out of release and creates no parallel route. - init(report: YearReportModel, testingRoute route: SettingsRoute) { - self.init(report: report, recordingWarning: nil, initialPath: [route]) - } - #endif - private var searchQuery: String { searchText.trimmingCharacters(in: .whitespacesAndNewlines) } @@ -85,7 +67,7 @@ struct SettingsView: View { } var body: some View { - NavigationStack(path: $path) { + NavigationStack { List { if isSearching { ForEach(searchResults) { result in @@ -122,7 +104,12 @@ struct SettingsView: View { } } .navigationDestination(for: SettingsRoute.self) { route in - destination(for: route) + SettingsRouteView( + route: route, + report: report, + backup: backup, + reminders: reminders, + ) } .sheet(isPresented: $showRegions) { RegionsSettingsView(usedThisYear: regionsUsedThisYear) @@ -272,57 +259,6 @@ struct SettingsView: View { } } - @ViewBuilder - private func destination(for route: SettingsRoute) -> some View { - switch route.destination { - case .attachments: - EvidenceListView(report: report) - case .loggedDays: - LoggedDaysView(report: report) - case .devices: - DevicesSettingsView(session: session, focus: route.focus) - case .regions: - // Regions is presented as a sheet (`isSheet`), so it's never - // routed here; this arm only keeps the switch exhaustive. - EmptyView() - case .alerts: - AlertsSettingsView(report: report, reminders: reminders, focus: route.focus) - case .appearance: - AppearanceSettingsView(report: report, focus: route.focus) - case .year: - VisibleYearSettingsView(report: report, focus: route.focus) - case .siri: - SiriFeaturesView( - focus: route.focus, - presentation: featureDiscoveryPresentation, - ) - case .widgets: - WidgetFeaturesView( - focus: route.focus, - presentation: featureDiscoveryPresentation, - ) - case .shareEvidence: - ShareEvidenceFeaturesView( - report: report, - focus: route.focus, - presentation: featureDiscoveryPresentation, - ) - case .estimatedTime: - EstimatedTimeFeaturesView(report: report, focus: route.focus) - case .insightsAccuracy: - InsightsAccuracyFeaturesView( - report: report, - focus: route.focus, - ) - case .personalization: - PersonalizationFeaturesView(report: report, focus: route.focus) - case .data: - DataSettingsView(report: report, backup: backup, focus: route.focus) - case .about: - AboutSettingsView(focus: route.focus) - } - } - /// Regions with days in the selected report year, so the region editor can /// surface a "used this year" group (grouping order only — it doesn't affect /// what's saved). `.other` isn't a pickable region, so it's dropped. @@ -330,15 +266,6 @@ struct SettingsView: View { guard let totals = report.report?.totals else { return [] } return Set(totals.filter { $0.key != .other && $0.value > 0 }.map(\.key)) } - - private var featureDiscoveryPresentation: FeatureDiscoveryPresentation { - FeatureDiscoveryPresentation( - report: report.report, - selectedYear: report.selectedYear, - referenceDate: report.referenceDate, - calendar: report.calendar, - ) - } } #if DEBUG diff --git a/Where/WhereUI/Tests/SettingsRouteViewTests.swift b/Where/WhereUI/Tests/SettingsRouteViewTests.swift new file mode 100644 index 00000000..625df2ab --- /dev/null +++ b/Where/WhereUI/Tests/SettingsRouteViewTests.swift @@ -0,0 +1,193 @@ +import SwiftUI +import TestHostSupport +import Testing +@testable import WhereUI + +/// Exercises every typed Settings push through the production route renderer, +/// including heterogeneous values pushed by child destinations. +@MainActor +@Suite(.serialized) +struct SettingsRouteViewTests { + @Test(arguments: SettingsDestination.allCases.filter { !$0.isSheet }) + func everyPushDestinationHosts(_ destination: SettingsDestination) throws { + let world = try World() + let rootView = world.host(route: SettingsRoute(destination)) + + try show(UIHostingController(rootView: rootView)) { hosted in + try waitFor { + hosted.viewIfLoaded?.window != nil + && navigationController(in: hosted)?.viewControllers.count == 2 + } + } + } + + @Test func shareEvidenceArchivePushesASecondHeterogeneousRoute() throws { + let world = try World() + let rootView = world.host( + route: SettingsRoute(.shareEvidence), + nested: ShareEvidenceFeaturesView.Route.archive, + ) + + try show(UIHostingController(rootView: rootView)) { hosted in + try waitFor { + hosted.viewIfLoaded?.window != nil + && navigationController(in: hosted)?.viewControllers.count == 3 + } + } + } + + @Test func attachmentDetailPushesASecondHeterogeneousValue() throws { + let evidence = try #require(PreviewSupport.sampleEvidence().first) + let world = try World() + let rootView = world.host( + route: SettingsRoute(.attachments), + nested: evidence, + ) + + try show(UIHostingController(rootView: rootView)) { hosted in + try waitFor { + hosted.viewIfLoaded?.window != nil + && navigationController(in: hosted)?.viewControllers.count == 3 + } + } + } + + private func navigationController( + in viewController: UIViewController, + ) -> UINavigationController? { + if let navigationController = viewController as? UINavigationController { + return navigationController + } + for child in viewController.children { + if let navigationController = navigationController(in: child) { + return navigationController + } + } + return nil + } +} + +@MainActor +private struct World { + let model: WhereModel + let session: WhereSession + let report: YearReportModel + let backup: BackupModel + let reminders: RemindersSettingsModel + + init() throws { + let model = PreviewSupport.loadedModel() + let session = try #require( + model.session, + "PreviewSupport.loadedModel() should create its session.", + ) + + self.model = model + self.session = session + report = YearReportModel( + services: session.services, + details: model.initialYearDetails, + selectedYear: model.initialSelectedYear, + preferences: session.preferences, + now: session.now, + ) + backup = BackupModel(services: session.services) + reminders = RemindersSettingsModel( + services: session.services, + preferences: session.preferences, + now: session.now, + ) + } + + func host(route: SettingsRoute) -> some View { + NavigationHost( + route: route, + report: report, + backup: backup, + reminders: reminders, + ) + .environment(model) + .environment(session) + } + + func host(route: SettingsRoute, nested: some Hashable) -> some View { + NavigationHost( + route: route, + nested: nested, + report: report, + backup: backup, + reminders: reminders, + ) + .environment(model) + .environment(session) + } +} + +@MainActor +private struct NavigationHost: View { + let report: YearReportModel + let backup: BackupModel + let reminders: RemindersSettingsModel + + @State private var path: NavigationPath + + init( + route: SettingsRoute, + report: YearReportModel, + backup: BackupModel, + reminders: RemindersSettingsModel, + ) { + var path = NavigationPath() + path.append(route) + self.init( + path: path, + report: report, + backup: backup, + reminders: reminders, + ) + } + + init( + route: SettingsRoute, + nested: some Hashable, + report: YearReportModel, + backup: BackupModel, + reminders: RemindersSettingsModel, + ) { + var path = NavigationPath() + path.append(route) + path.append(nested) + self.init( + path: path, + report: report, + backup: backup, + reminders: reminders, + ) + } + + private init( + path: NavigationPath, + report: YearReportModel, + backup: BackupModel, + reminders: RemindersSettingsModel, + ) { + _path = State(initialValue: path) + self.report = report + self.backup = backup + self.reminders = reminders + } + + var body: some View { + NavigationStack(path: $path) { + Color.clear + .navigationDestination(for: SettingsRoute.self) { route in + SettingsRouteView( + route: route, + report: report, + backup: backup, + reminders: reminders, + ) + } + } + } +} diff --git a/Where/WhereUI/Tests/SettingsViewTests.swift b/Where/WhereUI/Tests/SettingsViewTests.swift deleted file mode 100644 index 5b99070f..00000000 --- a/Where/WhereUI/Tests/SettingsViewTests.swift +++ /dev/null @@ -1,46 +0,0 @@ -import SwiftUI -import TestHostSupport -import Testing -@testable import WhereUI - -/// Exercises every typed Settings route through the production navigation -/// stack. This is also the executable tripwire for oversized concrete content -/// stored by `SettingsFocusScope` while SwiftUI prepares a destination. -@MainActor -struct SettingsViewTests { - @Test func everyPushDestinationHostsThroughProductionNavigation() throws { - let report = PreviewSupport.loadedYearReportModel() - let model = PreviewSupport.loadedModel() - let session = PreviewSupport.loadedSession() - - for destination in SettingsDestination.allCases where !destination.isSheet { - let rootView = SettingsView( - report: report, - testingRoute: SettingsRoute(destination), - ) - .environment(model) - .environment(session) - - try show(UIHostingController(rootView: rootView)) { hosted in - try waitFor { - hosted.viewIfLoaded?.window != nil - && navigationController(in: hosted)?.viewControllers.count == 2 - } - } - } - } - - private func navigationController( - in viewController: UIViewController, - ) -> UINavigationController? { - if let navigationController = viewController as? UINavigationController { - return navigationController - } - for child in viewController.children { - if let navigationController = navigationController(in: child) { - return navigationController - } - } - return nil - } -}