diff --git a/Packages/AppMonetizationKit/Package.swift b/Packages/AppMonetizationKit/Package.swift index 557a451a..87bcb2fe 100644 --- a/Packages/AppMonetizationKit/Package.swift +++ b/Packages/AppMonetizationKit/Package.swift @@ -101,7 +101,7 @@ let testTargets: [Target] = [ let package = Package( name: "AppMonetizationKit", platforms: [ - .iOS(.v26), + .iOS("26.1"), .macOS(.v26), ], products: [ @@ -116,7 +116,7 @@ let package = Package( // Pinned to 13.x — first major exposing Swift-native API names // (`MobileAds.shared`, `ConsentForm`, `ConsentInformation`). v13.0 // raised the minimum deployment target and removed a swathe of - // deprecated ObjC-prefixed surface; our `.iOS(.v26)` floor exceeds the + // deprecated ObjC-prefixed surface; our `.iOS("26.1")` floor exceeds the // v13 min iOS 13 requirement. Bridge seam (`AdMobBridge`) shields the // rest of the package from future API churn. .package( diff --git a/Packages/AppMonetizationKit/Sources/AdsAdMob/LiveAdMobBridge.swift b/Packages/AppMonetizationKit/Sources/AdsAdMob/LiveAdMobBridge.swift index 9771852d..a67a6d97 100644 --- a/Packages/AppMonetizationKit/Sources/AdsAdMob/LiveAdMobBridge.swift +++ b/Packages/AppMonetizationKit/Sources/AdsAdMob/LiveAdMobBridge.swift @@ -106,6 +106,12 @@ internal final class LiveAdMobBridge: AdMobBridge { let view = BannerView(adSize: AdSizeBanner) view.adUnitID = bannerAdUnitID view.rootViewController = rootVC + // #1024 (B-6 bonus finding, #1029): the SDK's own opaque fill + // shows through as black side slivers when the accessory + // stretches this view past the fixed 320×50 creative's own + // bounds (`.expanded` capsule). Clear lets the SwiftUI-side + // themed background (`BannerAccessoryView`) letterbox it instead. + view.backgroundColor = .clear return view } @@ -283,6 +289,18 @@ internal final class BannerLoadDelegate: NSObject, BannerViewDelegate, @unchecke internal struct BannerViewRepresentable: UIViewRepresentable { let bannerView: BannerView + // #1080: a `tabViewBottomAccessory` placement that hands this same + // `bannerView` a `BannerSlotLease` owned by a longer-lived host + // (`GameAppKit.GameRoot`) can have this representable re-hosted by UIKit + // — the SwiftUI content is torn down and rebuilt, but the retained + // `BannerView` (and its lease) survives. Measured invariant (probe, + // #1080): on each re-host the NEW host's `makeUIView`/`updateUIView` + // adopts the view (via `addSubview`, done by SwiftUI internally) BEFORE + // the OLD host's `dismantleUIView` runs. This type deliberately has no + // `dismantleUIView` today. If one is ever added, it must NEVER call + // `removeFromSuperview()` or otherwise reset `uiView` — that would strip + // the view back out of the new host that already adopted it, not just + // clean up the old one. func makeUIView(context: Context) -> BannerView { bannerView } diff --git a/Packages/AppMonetizationKit/Sources/MonetizationUI/BannerSlotBandLayout.swift b/Packages/AppMonetizationKit/Sources/MonetizationUI/BannerSlotBandLayout.swift new file mode 100644 index 00000000..c555ae63 --- /dev/null +++ b/Packages/AppMonetizationKit/Sources/MonetizationUI/BannerSlotBandLayout.swift @@ -0,0 +1,120 @@ +// BannerSlotBandLayout — the Layout that places `BannerSlotView`'s visible +// band background and its creative + ✕ pair (#1084). +// +// Extracted out of BannerSlotView.swift (#1080): adding the externally-owned- +// lease init there pushed that file to 402 lines, over the repo's +// `file_length` ceiling (400, SwiftLint `--strict`) — same move CLAUDE.md +// documents for `Live.swift`-style files ("extract Live+Feature.swift +// instead of growing it"). Pure move, no behavior change: `BannerSlotBandRole`, +// `BannerSlotBandRoleKey` and `BannerSlotBandLayout` go from `private` (file- +// scoped) to `internal` (module-scoped) ONLY because `BannerSlotView.swift`'s +// `banner(session:id:)` constructs `BannerSlotBandLayout` and tags subviews +// with `BannerSlotBandRoleKey` — neither becomes `public`. + +import SwiftUI + +/// Which of `BannerSlotBandLayout`'s two subviews a child is — tagged via +/// `.layoutValue(key:value:)` since `placeSubviews` must propose the band a +/// different width than the content (#1084 review fix 1 and 2: the band must +/// stop at the padded-in width, not bleed into the horizontal inset or fill +/// the Layout's full un-padded width). +enum BannerSlotBandRole: Sendable { + /// The visible band background — proposed exactly the padded-in width + /// (`bounds.width − 2×padding`), so its `.slot` anchor reads that rect. + case band + /// The creative + ✕ pair — proposed `.unspecified` so each keeps its own + /// declared, fixed size. + case content +} + +struct BannerSlotBandRoleKey: LayoutValueKey { + static let defaultValue: BannerSlotBandRole = .content +} + +/// Arranges the visible band background and the creative + ✕ pair at a +/// horizontal inset that is `nominalPadding` while `width ≥ needed + +/// nominalPadding` (380pt at the shipped constants: 320 + 44 + 16), shrinking +/// symmetrically (never below 0) below that. The constraint PM ruled on is +/// "the ✕ stays fully on-screen", not "the ✕ stays inside the band" — see +/// `padding(for:)`. A custom `Layout`, not `onGeometryChange` + `@State`: the +/// state-loop approach draws one frame at the wrong padding before +/// correcting on the next layout pass (PM ruling, #1084). +/// +/// `sizeThatFits` always returns a concrete, finite size — including for an +/// unconstrained ("ideal") proposal — which is also what keeps +/// `NSHostingView.fittingSize` (the pre-existing `BannerSlotViewTests` +/// `measuredHeight` / `pauseKeepsLease` measurement path) from degenerating +/// to a zero height the way a bare `.frame(maxWidth: .infinity)` chain does. +struct BannerSlotBandLayout: Layout { + let nominalPadding: CGFloat + let creativeWidth: CGFloat + let dismissTargetSize: CGFloat + let bannerHeight: CGFloat + + /// The creative and the ✕ side by side, with no gap between them. + private var needed: CGFloat { creativeWidth + dismissTargetSize } + + /// `nominalPadding` while `width ≥ needed + nominalPadding` (PM's final + /// ruling, #1084: the constraint is "the ✕ stays fully on-screen", not + /// "the ✕ stays inside the band" — a ONE-SIDED comfort check, not + /// `needed + 2×nominalPadding`. At 393pt (the most common iPhone width) + /// this keeps the full 16pt left padding instead of shrinking to 14.5pt, + /// which would visibly mismatch 402pt's 16pt for no reason; the ✕ then + /// sits a few points past the band's own trailing edge but still well + /// inside the screen. Below `needed + nominalPadding`, the padding + /// shrinks symmetrically, clamped at 0. + private func padding(for width: CGFloat) -> CGFloat { + let comfortable = needed + nominalPadding + guard width < comfortable else { return nominalPadding } + return max(0, (width - needed) / 2) + } + + /// The comfortable width at the full nominal padding on both sides — + /// `sizeThatFits`'s ideal, and the fallback `resolvedBannerBandWidth` + /// substitutes for a `nil` or non-finite proposal. + private var idealWidth: CGFloat { needed + 2 * nominalPadding } + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + CGSize(width: resolvedBannerBandWidth(proposal: proposal.width, ideal: idealWidth), height: bannerHeight) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + let padding = padding(for: bounds.width) + let origin = CGPoint(x: bounds.minX + padding, y: bounds.minY) + for subview in subviews { + switch subview[BannerSlotBandRoleKey.self] { + case .band: + // Stops at the padded-in width — never the Layout's full, + // un-padded width (#1084 review fix 1 and 2). + let bandWidth = max(0, bounds.width - 2 * padding) + subview.place( + at: origin, + anchor: .topLeading, + proposal: ProposedViewSize(width: bandWidth, height: bannerHeight) + ) + case .content: + subview.place(at: origin, anchor: .topLeading, proposal: .unspecified) + } + } + } +} + +/// Resolves a proposed width to a concrete, finite value: `proposal` itself +/// when it's present and finite, `ideal` otherwise — covering both a `nil` +/// proposal (an unconstrained/"ideal" query) and a non-finite one (e.g. +/// `.infinity`, which a horizontal `ScrollView` can propose to its content +/// along the scroll axis). Without this, `BannerSlotBandLayout.sizeThatFits` +/// would propagate `.infinity`/`NaN`, contradicting its own "always a +/// concrete, finite size" doc comment (#1084 review fix 3). +/// +/// `internal`, not `private`, purely as a test seam: `BannerSlotBandLayout` +/// itself stays `internal` (not `public`, #1080's file split), but this pure +/// function is unit-tested directly with a literal `.infinity` argument, +/// since no host inside `BannerSlotDismissPlacementTests`'s headless macOS +/// harness actually produces a literal `.infinity` proposal (confirmed +/// empirically — a horizontal `ScrollView` and `.fixedSize(horizontal:)` +/// both resolve to a concrete or `nil` proposal there instead). +internal func resolvedBannerBandWidth(proposal: CGFloat?, ideal: CGFloat) -> CGFloat { + guard let proposal, proposal.isFinite else { return ideal } + return proposal +} diff --git a/Packages/AppMonetizationKit/Sources/MonetizationUI/BannerSlotRegistration.swift b/Packages/AppMonetizationKit/Sources/MonetizationUI/BannerSlotRegistration.swift index 692a274c..1248a4a9 100644 --- a/Packages/AppMonetizationKit/Sources/MonetizationUI/BannerSlotRegistration.swift +++ b/Packages/AppMonetizationKit/Sources/MonetizationUI/BannerSlotRegistration.swift @@ -44,18 +44,33 @@ extension BannerSessionModel { // loudly if that ever stops being true. struct BannerSlotRegistration: DynamicProperty { @Environment(\.bannerSession) private var environmentSession - @StateObject private var lease: BannerSlotLease + @StateObject private var ownLease: BannerSlotLease + // #1080: an externally owned lease, when supplied, is used INSTEAD of + // `ownLease` — `ownLease` is still constructed (a `@StateObject`'s + // storage exists unconditionally) but is never attached, so its + // `isolated deinit` finds no session and unregisters nothing. This is + // what lets a host that outlives this view's own identity (`GameRoot`, + // for the `tabViewBottomAccessory` placement that gets natively + // re-hosted) keep one lease alive across that re-hosting instead of + // every remount registering a fresh one. Do not "simplify" this to a + // single lease — `ownLease` existing-but-inert while `external` is set + // is the point, not an oversight. + private let external: BannerSlotLease? @MainActor - init() { - _lease = StateObject(wrappedValue: BannerSlotLease()) + init(lease: BannerSlotLease? = nil) { + _ownLease = StateObject(wrappedValue: BannerSlotLease()) + external = lease } + @MainActor + private var effective: BannerSlotLease { external ?? ownLease } + @MainActor var session: BannerSessionModel? { environmentSession } @MainActor - var id: BannerSlotID { lease.id } + var id: BannerSlotID { effective.id } nonisolated func update() { MainActor.assumeIsolated { @@ -63,7 +78,7 @@ struct BannerSlotRegistration: DynamicProperty { BannerSessionModel.onMissingSession() return } - lease.attach(to: session) + effective.attach(to: session) } } } @@ -72,11 +87,19 @@ struct BannerSlotRegistration: DynamicProperty { /// One slot view's registration with the session: created once per view /// identity, released with it. +/// +/// `public` (#1080): a host that outlives a placement's own view identity +/// (`GameAppKit.GameRoot`, for the `tabViewBottomAccessory` capsule) needs to +/// construct one of these itself and hand it to `BannerSlotView(lease:...)`. +/// `id` and `attach(to:)` stay `internal` — a host only ever needs to own the +/// lease's lifetime, never its registration mechanics. @MainActor -final class BannerSlotLease: ObservableObject { +public final class BannerSlotLease: ObservableObject { let id = BannerSlotID() private weak var session: BannerSessionModel? + public init() {} + /// Idempotent. `update()` runs many times per mount, so a repeat call with /// the same session must do nothing — re-registering would cancel and /// restart the slot's load. A different session (a re-injected diff --git a/Packages/AppMonetizationKit/Sources/MonetizationUI/BannerSlotView.swift b/Packages/AppMonetizationKit/Sources/MonetizationUI/BannerSlotView.swift index 282f0ab7..98f6a8e0 100644 --- a/Packages/AppMonetizationKit/Sources/MonetizationUI/BannerSlotView.swift +++ b/Packages/AppMonetizationKit/Sources/MonetizationUI/BannerSlotView.swift @@ -107,6 +107,31 @@ public struct BannerSlotView: View { self.verticalPadding = verticalPadding } + /// Joins the session with a lease OWNED BY THE CALLER instead of a fresh + /// `@StateObject` one. Only for a placement whose native host rebuilds + /// this view's SwiftUI content (`tabViewBottomAccessory`, #1080) — the + /// caller must keep this lease alive across that re-hosting, or + /// registration churns exactly like it would without this init. + public init( + lease: BannerSlotLease, + isSuppressed: Bool, + backgroundColor: Color = .clear, + progressTint: Color = .accentColor, + captionColor: Color = .secondary, + dismissTint: Color = .secondary, + horizontalPadding: CGFloat = 0, + verticalPadding: CGFloat = 0 + ) { + self.registration = BannerSlotRegistration(lease: lease) + self.isSuppressed = isSuppressed + self.backgroundColor = backgroundColor + self.progressTint = progressTint + self.captionColor = captionColor + self.dismissTint = dismissTint + self.horizontalPadding = horizontalPadding + self.verticalPadding = verticalPadding + } + public var body: some View { if let session = registration.session, session.isVisible, !isSuppressed { // Horizontal inset is `BannerSlotBandLayout`'s job now (#1084) — @@ -226,114 +251,6 @@ public struct BannerSlotView: View { } } -// MARK: - BannerSlotBandLayout (#1084) - -/// Which of `BannerSlotBandLayout`'s two subviews a child is — tagged via -/// `.layoutValue(key:value:)` since `placeSubviews` must propose the band a -/// different width than the content (#1084 review fix 1 and 2: the band must -/// stop at the padded-in width, not bleed into the horizontal inset or fill -/// the Layout's full un-padded width). -private enum BannerSlotBandRole: Sendable { - /// The visible band background — proposed exactly the padded-in width - /// (`bounds.width − 2×padding`), so its `.slot` anchor reads that rect. - case band - /// The creative + ✕ pair — proposed `.unspecified` so each keeps its own - /// declared, fixed size. - case content -} - -private struct BannerSlotBandRoleKey: LayoutValueKey { - static let defaultValue: BannerSlotBandRole = .content -} - -/// Arranges the visible band background and the creative + ✕ pair at a -/// horizontal inset that is `nominalPadding` while `width ≥ needed + -/// nominalPadding` (380pt at the shipped constants: 320 + 44 + 16), shrinking -/// symmetrically (never below 0) below that. The constraint PM ruled on is -/// "the ✕ stays fully on-screen", not "the ✕ stays inside the band" — see -/// `padding(for:)`. A custom `Layout`, not `onGeometryChange` + `@State`: the -/// state-loop approach draws one frame at the wrong padding before -/// correcting on the next layout pass (PM ruling, #1084). -/// -/// `sizeThatFits` always returns a concrete, finite size — including for an -/// unconstrained ("ideal") proposal — which is also what keeps -/// `NSHostingView.fittingSize` (the pre-existing `BannerSlotViewTests` -/// `measuredHeight` / `pauseKeepsLease` measurement path) from degenerating -/// to a zero height the way a bare `.frame(maxWidth: .infinity)` chain does. -private struct BannerSlotBandLayout: Layout { - let nominalPadding: CGFloat - let creativeWidth: CGFloat - let dismissTargetSize: CGFloat - let bannerHeight: CGFloat - - /// The creative and the ✕ side by side, with no gap between them. - private var needed: CGFloat { creativeWidth + dismissTargetSize } - - /// `nominalPadding` while `width ≥ needed + nominalPadding` (PM's final - /// ruling, #1084: the constraint is "the ✕ stays fully on-screen", not - /// "the ✕ stays inside the band" — a ONE-SIDED comfort check, not - /// `needed + 2×nominalPadding`. At 393pt (the most common iPhone width) - /// this keeps the full 16pt left padding instead of shrinking to 14.5pt, - /// which would visibly mismatch 402pt's 16pt for no reason; the ✕ then - /// sits a few points past the band's own trailing edge but still well - /// inside the screen. Below `needed + nominalPadding`, the padding - /// shrinks symmetrically, clamped at 0. - private func padding(for width: CGFloat) -> CGFloat { - let comfortable = needed + nominalPadding - guard width < comfortable else { return nominalPadding } - return max(0, (width - needed) / 2) - } - - /// The comfortable width at the full nominal padding on both sides — - /// `sizeThatFits`'s ideal, and the fallback `resolvedBannerBandWidth` - /// substitutes for a `nil` or non-finite proposal. - private var idealWidth: CGFloat { needed + 2 * nominalPadding } - - func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { - CGSize(width: resolvedBannerBandWidth(proposal: proposal.width, ideal: idealWidth), height: bannerHeight) - } - - func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { - let padding = padding(for: bounds.width) - let origin = CGPoint(x: bounds.minX + padding, y: bounds.minY) - for subview in subviews { - switch subview[BannerSlotBandRoleKey.self] { - case .band: - // Stops at the padded-in width — never the Layout's full, - // un-padded width (#1084 review fix 1 and 2). - let bandWidth = max(0, bounds.width - 2 * padding) - subview.place( - at: origin, - anchor: .topLeading, - proposal: ProposedViewSize(width: bandWidth, height: bannerHeight) - ) - case .content: - subview.place(at: origin, anchor: .topLeading, proposal: .unspecified) - } - } - } -} - -/// Resolves a proposed width to a concrete, finite value: `proposal` itself -/// when it's present and finite, `ideal` otherwise — covering both a `nil` -/// proposal (an unconstrained/"ideal" query) and a non-finite one (e.g. -/// `.infinity`, which a horizontal `ScrollView` can propose to its content -/// along the scroll axis). Without this, `BannerSlotBandLayout.sizeThatFits` -/// would propagate `.infinity`/`NaN`, contradicting its own "always a -/// concrete, finite size" doc comment (#1084 review fix 3). -/// -/// `internal`, not `private`, purely as a test seam: `BannerSlotBandLayout` -/// itself stays `private`, but this pure function is unit-tested directly -/// with a literal `.infinity` argument, since no host inside -/// `BannerSlotDismissPlacementTests`'s headless macOS harness actually -/// produces a literal `.infinity` proposal (confirmed empirically — a -/// horizontal `ScrollView` and `.fixedSize(horizontal:)` both resolve to a -/// concrete or `nil` proposal there instead). -internal func resolvedBannerBandWidth(proposal: CGFloat?, ideal: CGFloat) -> CGFloat { - guard let proposal, proposal.isFinite else { return ideal } - return proposal -} - // MARK: - Geometry preference (#1084, test-only) /// The three named parts `BannerSlotGeometryKey` reports bounds for. diff --git a/Packages/AppMonetizationKit/Tests/MonetizationUITests/BannerSlotCollapsedHeightTests.swift b/Packages/AppMonetizationKit/Tests/MonetizationUITests/BannerSlotCollapsedHeightTests.swift index e5097d6a..7fb86a83 100644 --- a/Packages/AppMonetizationKit/Tests/MonetizationUITests/BannerSlotCollapsedHeightTests.swift +++ b/Packages/AppMonetizationKit/Tests/MonetizationUITests/BannerSlotCollapsedHeightTests.swift @@ -8,15 +8,17 @@ // that a gate-denied slot measures 0pt with each host's real arguments. // // Rows are audited from `rg -n "BannerSlotView\(" Packages/*/Sources`: -// - `GameAppKit.TodayTabHost.bannerSlot` (Today tab, both apps): 16 / 12. -// - Sudoku `LiveRouteFactory.themedBanner()` and Minesweeper -// `LiveRouteFactory.bannerSlot()` (Practice + Settings): 16 / 12. // - Sudoku `BoardView+Layout.themedBanner(horizontalPadding:)` and // Minesweeper `MinesweeperBoardView.bannerSlot(horizontalPadding:)`: // `theme.spacing.medium` on the compact layout (16 — both themes use // `SpacingTokens()`), 0 on the regular layout; no vertical padding. // AppMonetizationKit cannot import the host modules, so the rows carry the // literal values; the hosts' own snapshot suites pin that they pass them. +// +// #1080: dropped the `TodayTabHost (Today tab, both apps)` row — +// `TodayTabHost` has had no `BannerSlotView` of its own since #1024 (its +// banner moved into the shared `tabViewBottomAccessory`); the row was +// exercising a host that no longer constructs this view. #if canImport(AppKit) @@ -45,10 +47,10 @@ struct BannerSlotCollapsedHeightTests { /// `nonisolated` (not the suite's own `@MainActor`): `@Test(arguments:)` /// evaluates this at test-discovery time, outside MainActor isolation. + /// #1080: dropped the Sudoku `LiveRouteFactory.themedBanner` / Minesweeper + /// `LiveRouteFactory.bannerSlot` rows (Practice, Settings) — both helpers + /// (the unused tab-content-bottom fallback) were deleted. nonisolated static let productionHosts: [HostPadding] = [ - HostPadding(name: "TodayTabHost (Today tab, both apps)", horizontal: 16, vertical: 12), - HostPadding(name: "Sudoku LiveRouteFactory.themedBanner (Practice, Settings)", horizontal: 16, vertical: 12), - HostPadding(name: "Minesweeper LiveRouteFactory.bannerSlot (Practice, Settings)", horizontal: 16, vertical: 12), HostPadding(name: "Sudoku Board compact (BoardView+Layout.themedBanner)", horizontal: 16, vertical: 0), HostPadding(name: "Sudoku Board regular (BoardView+Layout.themedBanner)", horizontal: 0, vertical: 0), HostPadding(name: "Minesweeper Board compact (MinesweeperBoardView.bannerSlot)", horizontal: 16, vertical: 0), diff --git a/Packages/AppMonetizationKit/Tests/MonetizationUITests/BannerSlotExternalLeaseTests.swift b/Packages/AppMonetizationKit/Tests/MonetizationUITests/BannerSlotExternalLeaseTests.swift new file mode 100644 index 00000000..4c3ba853 --- /dev/null +++ b/Packages/AppMonetizationKit/Tests/MonetizationUITests/BannerSlotExternalLeaseTests.swift @@ -0,0 +1,118 @@ +// BannerSlotExternalLeaseTests — pins the externally-owned-lease init added +// for #1080 (`BannerSlotView(lease:...)` / `BannerSlotRegistration(lease:)`). +// +// The production motivation (`GameAppKit.BannerAccessoryView`, hosted inside +// `tabViewBottomAccessory`) is that a native host can re-host a view's +// SwiftUI content under a NEW view identity without re-rendering anything +// above it — see `meetings/2026-09-11_1058-slot-model-design.md` §"Externally +// owned lease (#1080)". `NSHostingView` +// can't reproduce that native re-host mechanism directly, but the behavior +// under test — a `BannerSlotRegistration` backed by the SAME externally +// owned `BannerSlotLease` must not re-register when ITS OWN view identity +// changes — is exactly what `BannerSlotRegistration.update()` decides, and a +// plain identity swap (`ExternalLeaseHostA` → `ExternalLeaseHostB`, two +// distinct types) exercises that decision the same way a re-host would. + +#if canImport(AppKit) + +import AppKit +import Foundation +import SwiftUI +import Testing + +import MonetizationCore +import MonetizationTesting +@testable import MonetizationUI + +@MainActor +@Suite("BannerSlotView — externally owned lease (#1080)", .timeLimit(.minutes(1))) +struct BannerSlotExternalLeaseTests { + + private func startedSession(provider: FakeAdProvider) async -> BannerSessionModel { + let (gate, _) = SessionFixture.gate(SessionFixture.openState) + let model = BannerSessionModel(adProvider: provider, adGate: gate, now: { SessionFixture.today }) + await model.start() + return model + } + + private func loadedHandleIDs(_ model: BannerSessionModel) -> Set { + Set(model.slots.values.compactMap { $0.loadedHandle?.id }) + } + + /// Mutation target (#1080, executed and reverted — see the dispatch + /// report): in `BannerSlotRegistration.update()`, change + /// `effective.attach(to: session)` to `ownLease.attach(to: session)`, + /// ignoring the caller-supplied lease. + @Test("T1: the same external lease survives its view being re-hosted under a new identity") + func externalLeaseSurvivesReHost() async { + let provider = FakeAdProvider() + let session = await startedSession(provider: provider) + let lease = BannerSlotLease() + + let host = NSHostingView( + rootView: ExternalLeaseSwitcher(session: session, lease: lease, useFirstIdentity: true) + ) + host.frame = CGRect(x: 0, y: 0, width: 320, height: 400) + host.layoutSubtreeIfNeeded() + + #expect(await eventually { loadedHandleIDs(session).count == 1 }) + #expect(await provider.refreshCallCount == 1) + #expect(session.slots.count == 1) + let firstID = session.slots.keys.first + let handles = loadedHandleIDs(session) + + // The re-host: a structurally different view type hosts the SAME + // external lease, with no `.id(...)` bridging the two identities — + // matching how `BannerAccessoryView`'s own identity is what changes + // under `tabViewBottomAccessory`, not the lease it's handed. + host.rootView = ExternalLeaseSwitcher(session: session, lease: lease, useFirstIdentity: false) + host.layoutSubtreeIfNeeded() + try? await Task.sleep(for: .milliseconds(300)) + + #expect(await provider.refreshCallCount == 1, "the re-host must not re-request the banner") + #expect(session.slots.count == 1, "the re-host must not register a second slot") + #expect(session.slots.keys.first == firstID, "the re-host must keep the same slot id") + #expect(loadedHandleIDs(session) == handles, "the re-host must keep the same loaded handle") + } +} + +// MARK: - Hosts + +/// Two structurally distinct types (not the same type reused) so SwiftUI +/// treats them as different view identities — the same shape of change a +/// native re-host produces on `BannerAccessoryView`'s own identity. +private struct ExternalLeaseHostA: View { + let session: BannerSessionModel + let lease: BannerSlotLease + + var body: some View { + BannerSlotView(lease: lease, isSuppressed: false) + .environment(\.bannerSession, session) + } +} + +private struct ExternalLeaseHostB: View { + let session: BannerSessionModel + let lease: BannerSlotLease + + var body: some View { + BannerSlotView(lease: lease, isSuppressed: false) + .environment(\.bannerSession, session) + } +} + +private struct ExternalLeaseSwitcher: View { + let session: BannerSessionModel + let lease: BannerSlotLease + let useFirstIdentity: Bool + + var body: some View { + if useFirstIdentity { + ExternalLeaseHostA(session: session, lease: lease) + } else { + ExternalLeaseHostB(session: session, lease: lease) + } + } +} + +#endif diff --git a/Packages/DeterminismKit/Package.swift b/Packages/DeterminismKit/Package.swift index 2263fbe9..a83fce24 100644 --- a/Packages/DeterminismKit/Package.swift +++ b/Packages/DeterminismKit/Package.swift @@ -40,7 +40,7 @@ let testTargets: [Target] = [ let package = Package( name: "DeterminismKit", platforms: [ - .iOS(.v26), + .iOS("26.1"), .macOS(.v26), ], products: [ diff --git a/Packages/GameAppKit/Package.swift b/Packages/GameAppKit/Package.swift index ba3bb55c..469b07e9 100644 --- a/Packages/GameAppKit/Package.swift +++ b/Packages/GameAppKit/Package.swift @@ -102,7 +102,7 @@ let testTargets: [Target] = [ let package = Package( name: "GameAppKit", platforms: [ - .iOS(.v26), + .iOS("26.1"), .macOS(.v26), ], products: [ diff --git a/Packages/GameAppKit/Sources/GameAppKit/BannerAccessoryView.swift b/Packages/GameAppKit/Sources/GameAppKit/BannerAccessoryView.swift new file mode 100644 index 00000000..0bf2050b --- /dev/null +++ b/Packages/GameAppKit/Sources/GameAppKit/BannerAccessoryView.swift @@ -0,0 +1,92 @@ +// BannerAccessoryView — the `tabViewBottomAccessory` content (#1024, +// design.md §2.4). iOS/iPadOS only: `tabViewBottomAccessory` doesn't exist on +// macOS (§2.4.1 option A — no banner on macOS at all), so this whole file is +// gated `#if os(iOS)` and never compiles into the macOS binary. That is the +// structural exclusion the acceptance grep checks for, not a runtime check. +// +// Retired: the old per-Today-tab inline banner slot (`TodayTabHost`'s +// `bannerSlot`) and the Practice/Settings `banner:` closures that built their +// own `BannerSlotView`. The accessory now covers the WHOLE tab shell — Today, +// Practice, Settings (pushed onto a tab's stack, so it stays inside the +// TabView and keeps the accessory) — with ONE shared banner instead of N +// per-screen ones. Board screens are untouched (`fullScreenCover`, outside +// the TabView — #1022 owns their bottom chrome). +// +// Session model (#1062): this is just another `BannerSlotView`. It reads the +// session's `BannerSessionModel` through `\.bannerSession` (injected once on +// the `GameRoot` value by `MakeGameApp`) — no provider / gate / primer +// parameters flow through here. The ATT anchor (C-33) lives on the session's +// `onAdContext` hook, wired in `makeBannerSession`, so the primer fires +// before the first ad load no matter which slot triggers it. +// +// Lease ownership (#1080): unlike every other slot, this one does NOT hold +// its own `@StateObject` lease — `tabViewBottomAccessory` re-hosts this +// view's SwiftUI content natively (no re-render of `GameRoot`, no `.id` +// change), which would reset a self-owned lease and re-request the ad on +// every re-host (measured: 8 requests vs 2 on `main`'s never-re-hosted +// slots). `GameRoot` owns the lease instead (`@State`, scene lifetime) and +// injects it via `\.bannerAccessoryLease`; see +// `meetings/2026-09-11_1058-slot-model-design.md` §"Externally owned lease +// (#1080)". +// +// Visibility: `RootShellView` drives `tabViewBottomAccessory(isEnabled:)` +// from `bannerSession.isVisible` (#1079 option 1, iOS 26.1 floor), so when +// the gate denies the whole capsule is gone — not an empty capsule. Inside +// the enabled capsule `BannerSlotView` still collapses on its own for the +// same `isVisible` read, so both layers agree by construction. +// +// Cosmetic (B-6 bonus finding, #1029): a fixed-size 320×50 test creative can +// leave the accessory's rounded capsule ends uncovered. `backgroundColor` +// below letterboxes that gap with the theme surface color instead of the +// SDK's own opaque fill. + +#if os(iOS) + +public import SwiftUI +public import MonetizationUI + +@MainActor +struct BannerAccessoryView: View { + @Environment(\.theme) private var theme + // #1080: `GameRoot`-owned lease, see the file header. `nil` only for a + // lost injection — see `onMissingAccessoryLease` below. + @Environment(\.bannerAccessoryLease) private var accessoryLease + + var body: some View { + content + .padding(.horizontal, 12) + } + + @ViewBuilder + private var content: some View { + if let accessoryLease { + BannerSlotView( + lease: accessoryLease, + isSuppressed: false, + backgroundColor: theme.surface.background.resolved, + progressTint: theme.accent.primary.resolved, + captionColor: theme.text.secondary.resolved, + dismissTint: theme.text.secondary.resolved + ) + } else { + // #1080: no silent fallback (mirrors `BannerSessionModel + // .onMissingSession`, #1058) — a missing lease means `GameRoot` + // stopped injecting it, which must fail loudly, not quietly + // regress to a self-owned lease that re-registers on every + // re-host. `let _ =` is the standard `@ViewBuilder` side-effect + // idiom — a bare call statement doesn't type-check as `View`. + // swiftlint:disable:next redundant_discardable_let + let _ = Self.onMissingAccessoryLease() + EmptyView() + } + } + + /// Called when `BannerAccessoryView` renders with no `\.bannerAccessoryLease` + /// in its environment — a lost injection. Asserts in DEBUG; the accessory + /// renders nothing either way. Tests swap it to observe the call. + static var onMissingAccessoryLease: @MainActor () -> Void = { + assertionFailure("BannerAccessoryView mounted without a \\.bannerAccessoryLease (#1080)") + } +} + +#endif diff --git a/Packages/GameAppKit/Sources/GameAppKit/GameConfig.swift b/Packages/GameAppKit/Sources/GameAppKit/GameConfig.swift index 4bcf8790..35d52e0a 100644 --- a/Packages/GameAppKit/Sources/GameAppKit/GameConfig.swift +++ b/Packages/GameAppKit/Sources/GameAppKit/GameConfig.swift @@ -202,9 +202,10 @@ public struct GameConfig { /// its own daily hub / practice hub / statistics screen without GameAppKit /// naming any of them. /// - /// `.today`'s content is additionally wrapped by `TodayTabHost` (resume pill - /// + banner slot + the C-33 ATT anchor), so a game supplies only the middle - /// of that sandwich. + /// `.today`'s content is additionally wrapped by `TodayTabHost` (resume + /// pill only since #1024 — banner + C-33 ATT anchor live in the shell's + /// `tabViewBottomAccessory`), so a game supplies only the middle of that + /// sandwich. /// /// Replaces the retired home-modes / make-home / sidebar-items trio: with /// `sidebarAdaptable` generating its own chrome from `AppTab`, there is no diff --git a/Packages/GameAppKit/Sources/GameAppKit/GameRoot.swift b/Packages/GameAppKit/Sources/GameAppKit/GameRoot.swift index aff77436..49bcca2d 100644 --- a/Packages/GameAppKit/Sources/GameAppKit/GameRoot.swift +++ b/Packages/GameAppKit/Sources/GameAppKit/GameRoot.swift @@ -49,7 +49,7 @@ public import SwiftUI public import GameShellUI public import MonetizationUI -public struct GameRoot: View { +public struct GameRoot: View { // The app-side Root owns the VM as `@State`; GameRoot holds the same // `@Observable` reference. Property access in `body` registers observation, // so a plain stored reference (not a second `@State`) is correct here and @@ -70,6 +70,11 @@ public struct GameRoot: View { private let failureTint: Color private let infoTint: Color private let tabRoot: (AppTab) -> TabRoot + // #1024: forwarded straight into `RootShellView`'s `tabViewBottomAccessory` + // content (design.md §2.4). `MakeGameApp` supplies the real (monetization) + // content on iOS and `{ EmptyView() }` on macOS — this type stays agnostic + // either way, same as `tabRoot`. + private let bottomAccessory: () -> Accessory // SDD-003 OQ-001: single chrome state instance, owned here so it outlives // individual modal presentations. Reset on dismiss so a stale label from a @@ -80,6 +85,16 @@ public struct GameRoot: View { @State private var chromeState = GameChromeState() #endif + // #1080: the accessory's banner lease, owned HERE (not by + // `BannerAccessoryView` itself) so it outlives `tabViewBottomAccessory`'s + // native re-hosting of its content — see + // `meetings/2026-09-11_1058-slot-model-design.md` §"Externally owned + // lease (#1080)". `GameRoot` is the scene-lifetime owner every other + // `@State` on this type already relies on (`chromeState` above), and + // injecting it here (not in GameShellKit) is what keeps GameShellKit + // itself zero-dependency on monetization types. + @State private var accessoryLease = BannerSlotLease() + @Environment(\.scenePhase) private var scenePhase // #823 / #1042: the join point between a board's terminal-persist Task @@ -98,7 +113,8 @@ public struct GameRoot: View { successTint: Color, failureTint: Color, infoTint: Color, - @ViewBuilder tabRoot: @escaping (AppTab) -> TabRoot + @ViewBuilder tabRoot: @escaping (AppTab) -> TabRoot, + @ViewBuilder bottomAccessory: @escaping () -> Accessory ) { self.viewModel = viewModel self.bannerSession = bannerSession @@ -109,6 +125,7 @@ public struct GameRoot: View { self.failureTint = failureTint self.infoTint = infoTint self.tabRoot = tabRoot + self.bottomAccessory = bottomAccessory } public var body: some View { @@ -228,8 +245,17 @@ public struct GameRoot: View { path: viewModel.pathBinding(for:), routeFactory: routeFactory, settingsRoute: settingsRoute, - tabRoot: tabRoot + tabRoot: tabRoot, + // #1079: drives `tabViewBottomAccessory(isEnabled:)` — this read + // happens inside `shellContent`, which is only ever evaluated + // from `body`, so `@Observable`'s dependency tracking picks up + // `bannerSession.isVisible` changes and re-renders the shell. + bottomAccessoryIsEnabled: bannerSession.isVisible, + bottomAccessory: bottomAccessory ) + // #1080: the accessory's own externally-owned lease, scene-lifetime + // via `accessoryLease` above. + .environment(\.bannerAccessoryLease, accessoryLease) } } diff --git a/Packages/GameAppKit/Sources/GameAppKit/GameRootViewModel+Environment.swift b/Packages/GameAppKit/Sources/GameAppKit/GameRootViewModel+Environment.swift index d3ebb3ac..0053bf5b 100644 --- a/Packages/GameAppKit/Sources/GameAppKit/GameRootViewModel+Environment.swift +++ b/Packages/GameAppKit/Sources/GameAppKit/GameRootViewModel+Environment.swift @@ -6,6 +6,7 @@ public import SwiftUI public import GameShellUI +internal import MonetizationUI // MARK: - EnvironmentKey (#761) @@ -48,3 +49,26 @@ public extension EnvironmentValues { set { self[GameSelectedTabKey.self] = newValue } } } + +// MARK: - EnvironmentKey (#1080) + +private struct BannerAccessoryLeaseKey: EnvironmentKey { + static let defaultValue: BannerSlotLease? = nil +} + +// `internal` (not `public`, unlike the other environment keys above): only +// `GameRoot` (injects it) and `BannerAccessoryView` (reads it) use this key, +// both inside GameAppKit — #1080 CR (over-public API). +extension EnvironmentValues { + /// The `tabViewBottomAccessory` capsule's banner lease, injected by + /// `GameRoot` (scene-lifetime `@State`) so `BannerAccessoryView` can keep + /// its registration stable across the accessory's native re-hosting of + /// its content (#1080 — see `meetings/2026-09-11_1058-slot-model-design.md` + /// §"Externally owned lease (#1080)"). + /// `nil` for any other host: every OTHER slot keeps its own self-owned + /// lease via `BannerSlotView`'s default init. + var bannerAccessoryLease: BannerSlotLease? { + get { self[BannerAccessoryLeaseKey.self] } + set { self[BannerAccessoryLeaseKey.self] = newValue } + } +} diff --git a/Packages/GameAppKit/Sources/GameAppKit/MakeGameApp+Helpers.swift b/Packages/GameAppKit/Sources/GameAppKit/MakeGameApp+Helpers.swift index bfc35f83..b24848a2 100644 --- a/Packages/GameAppKit/Sources/GameAppKit/MakeGameApp+Helpers.swift +++ b/Packages/GameAppKit/Sources/GameAppKit/MakeGameApp+Helpers.swift @@ -89,6 +89,25 @@ func chromedTabRoots( } } +// MARK: - makeBottomAccessory (#1024) + +/// `GameRoot.bottomAccessory` content: the real `BannerAccessoryView` on +/// iOS/iPadOS, `EmptyView` on macOS. The `#if os(iOS)` branch is what makes +/// the exclusion STRUCTURAL rather than a runtime check — on a macOS build +/// the `BannerAccessoryView` type (and the `BannerSlotView` it wraps) never +/// compiles into this function at all; the macOS binary constructs +/// `EmptyView()` and nothing else. Takes no monetization parameters: the +/// accessory reads the session through `\.bannerSession` (#1062) like every +/// other slot. +@MainActor +func makeBottomAccessory() -> some View { + #if os(iOS) + BannerAccessoryView() + #else + EmptyView() + #endif +} + // MARK: - bootMonetization /// App-launch monetization boot. Runs UMP consent → AdMob SDK initialize. diff --git a/Packages/GameAppKit/Sources/GameAppKit/MakeGameApp+UITestOverrides.swift b/Packages/GameAppKit/Sources/GameAppKit/MakeGameApp+UITestOverrides.swift index 11059a8e..8dd59df4 100644 --- a/Packages/GameAppKit/Sources/GameAppKit/MakeGameApp+UITestOverrides.swift +++ b/Packages/GameAppKit/Sources/GameAppKit/MakeGameApp+UITestOverrides.swift @@ -19,9 +19,22 @@ internal import Persistence /// (`BannerSessionModel.sceneDidBecomeActive()`) deterministically. See `UITestFakeSeams.swift` for /// why a THROWING fake (rather than a plain state flip) is required to defeat /// `AdGate.currentState()`'s caching. +/// +/// #1024 (PM-approved): under `-uitest-open-ad-gate` (DEBUG only, and only +/// when `fakeAdGateRepoll` is NOT also present), swaps in +/// `UITestAlwaysOpenAdGateStateStore` instead — a narrower fake than the +/// repoll one above; see `UITestLaunchArg.openAdGate`'s doc for why this +/// leaves `resolveAdProvider` (below) completely untouched. @MainActor func resolveAdGateStore(fallback: any AdGateStateStore) -> any AdGateStateStore { #if DEBUG + // #1024: checked BEFORE the repoll guard below (untouched from #931) so + // `-uitest-fake-ad-gate-repoll` keeps taking priority if both args are + // ever passed together, and so that guard's own lines stay byte-identical. + if ProcessInfo.processInfo.arguments.contains(UITestLaunchArg.openAdGate), + !ProcessInfo.processInfo.arguments.contains(UITestLaunchArg.fakeAdGateRepoll) { + return UITestAlwaysOpenAdGateStateStore() + } guard ProcessInfo.processInfo.arguments.contains(UITestLaunchArg.fakeAdGateRepoll) else { return fallback } diff --git a/Packages/GameAppKit/Sources/GameAppKit/MakeGameApp.swift b/Packages/GameAppKit/Sources/GameAppKit/MakeGameApp.swift index 360e54d7..536eb932 100644 --- a/Packages/GameAppKit/Sources/GameAppKit/MakeGameApp.swift +++ b/Packages/GameAppKit/Sources/GameAppKit/MakeGameApp.swift @@ -323,10 +323,10 @@ private func makeGameAppCore( // #1020: per-tab root content. The game builds each tab's screen; the Today // tab is additionally wrapped in the shared `TodayTabHost`, which carries - // the resume pill and the themed banner slot. The ATT primer (C-33) is no - // longer tied to that slot: the banner session requests it after provider - // readiness and before the first load (`onAdContext`). The other two tabs - // get the game's content unwrapped. + // the resume pill (#1024: the banner slot and the C-33 ATT anchor moved + // out of it, into `BannerAccessoryView` / the shell-level + // `tabViewBottomAccessory`). The other two tabs get the game's content + // unwrapped. // // `chromedTabRoots` runs this builder exactly ONCE per tab, at composition // time, then attaches the shared Settings gear to every tab root (§2.1 / @@ -341,11 +341,10 @@ private func makeGameAppCore( ) { tab in let content = config.makeTabRoot(tab, deps, rootViewModel) guard tab == .today else { return content } + // #1024: TodayTabHost no longer carries a banner slot / ATT anchor — + // both moved into the shared `tabViewBottomAccessory` below. return AnyView( - TodayTabHost( - rootViewModel: rootViewModel, - content: { content } - ) + TodayTabHost(rootViewModel: rootViewModel, content: { content }) ) } @@ -360,7 +359,11 @@ private func makeGameAppCore( successTint: config.successTint, failureTint: config.failureTint, infoTint: config.infoTint, - tabRoot: tabRoot + tabRoot: tabRoot, + // #1024: shared banner accessory (design.md §2.4) — iOS/iPadOS real + // content, `EmptyView` on macOS (no `tabViewBottomAccessory` API + // there at all, §2.4.1 option A). See `makeBottomAccessory`'s doc. + bottomAccessory: { makeBottomAccessory() } ) .environment(\.theme, config.theme) // #1058 P3a: `\.theme`'s level, which reaches the board cover content. diff --git a/Packages/GameAppKit/Sources/GameAppKit/SettingsView.swift b/Packages/GameAppKit/Sources/GameAppKit/SettingsView.swift index f5dd2042..a78aef59 100644 --- a/Packages/GameAppKit/Sources/GameAppKit/SettingsView.swift +++ b/Packages/GameAppKit/Sources/GameAppKit/SettingsView.swift @@ -42,7 +42,7 @@ internal import GameCenterClient // param (mirrors `SettingsScreen`'s decoupled emit closure). public import Telemetry -public struct SettingsView: View { +public struct SettingsView: View { // #1058 2f: owned as `@State`, not a plain stored property. Both apps' // `.settings` destination builders mint a fresh `SettingsViewModel` on // every ancestor re-render (the #909 shape, see `GameConfig.reminderSettings`); @@ -65,10 +65,6 @@ public struct SettingsView: View { // `nil` in previews / tests → no audio section, byte-identical screen. Live // wiring injects one whose setters fan out to the running `LiveSoundPlayer`. private let audioSettings: AudioSettingsModel? - // Epic 5: optional banner slot below the Form. SettingsKit / GameShellUI - // must NOT import MonetizationUI; the actual BannerSlotView is injected by - // each app's LiveRouteFactory. EmptyView default keeps previews/tests inert. - private let banner: Banner // #685: the Game Center row previously called `GameCenterDashboard.present()` // directly with no signed-out guard — a silent no-op when unauthenticated. // Injected so the live wiring can route through @@ -115,8 +111,7 @@ public struct SettingsView: View { presentGameCenter: (@MainActor () -> Void)? = nil, appStoreID: String? = nil, presentInviteFriends: (@MainActor () -> Void)? = nil, - telemetryEmit: @escaping @Sendable (TelemetryEvent) -> Void = { _ in }, - @ViewBuilder banner: () -> Banner = { EmptyView() } + telemetryEmit: @escaping @Sendable (TelemetryEvent) -> Void = { _ in } ) { _viewModel = State(initialValue: viewModel) self.monetizationController = monetizationController @@ -127,7 +122,6 @@ public struct SettingsView: View { self.appStoreID = appStoreID self.presentInviteFriends = presentInviteFriends self.telemetryEmit = telemetryEmit - self.banner = banner() } public var body: some View { @@ -214,10 +208,7 @@ public struct SettingsView: View { tintColor: theme.accent.primary.resolved ) } - }, - // Epic 5: banner injected by each app's LiveRouteFactory; EmptyView - // in previews/tests. - banner: { banner } + } ) .task { await viewModel.bootstrap() } .task { diff --git a/Packages/GameAppKit/Sources/GameAppKit/TodayTabHost.swift b/Packages/GameAppKit/Sources/GameAppKit/TodayTabHost.swift index 51d7396d..1f4b4422 100644 --- a/Packages/GameAppKit/Sources/GameAppKit/TodayTabHost.swift +++ b/Packages/GameAppKit/Sources/GameAppKit/TodayTabHost.swift @@ -1,27 +1,17 @@ // TodayTabHost — the shared frame around every game's Today tab (#1020). // -// The retired HOME view owned three universal pieces on top of each game's -// own content: the resume pill, the themed banner slot, and — hanging off that -// banner slot — the ATT pre-prompt anchor. HOME is gone in v3.0, so the resume -// pill and banner slot move here, wrapped around whatever the app supplies as -// its Today content (design.md §2.1: Today is a tab identity now, not a route). +// The retired HOME view owned two universal pieces on top of each game's own +// content: the resume pill and the themed banner slot. HOME is gone in v3.0, +// so both moved here, wrapped around whatever the app supplies as its Today +// content (design.md §2.1: Today is a tab identity now, not a route). // -// **C-33 (BREAK) — the ATT anchor.** `screen-contracts.md` recorded -// ATT-PRIMER's entry point as "the HOME view's banner slot"; design.md §3.6.1 -// re-anchored it to the Today tab's first banner load. Since #1058 the primer -// is requested by the session's `BannerSessionModel` readiness task (its -// `onAdContext` hook, wired in `makeGameApp`), not by this host's slot. The -// semantics are unchanged — still the first ad-relevant context — and so is -// every existing behavior: the primer does not block Today's interaction, the -// one-shot `hasOffered` latch still gates it, a decline is never re-offered, -// and it only appears while ATT is `.notDetermined`. `ATTPrimerCoordinator` -// itself is NOT modified; only the call site moved. -// -// The banner styling (tint tokens, padding) is carried over unchanged from that -// retired banner slot so this one renders identically. +// **#1024 — the banner slot (and the ATT anchor riding on it, C-33) moved +// OUT again**, into `BannerAccessoryView` / `tabViewBottomAccessory` (design.md +// §2.4): the accessory now covers the whole tab shell (Today/Practice/Settings) +// with ONE shared banner instead of a separate one per screen. `TodayTabHost` +// keeps only the resume pill. public import SwiftUI -internal import MonetizationUI internal import GameShellUI @MainActor @@ -44,7 +34,6 @@ public struct TodayTabHost: View { resumeHeader content .frame(maxWidth: .infinity, maxHeight: .infinity) - bannerSlot } .background(theme.surface.background.resolved) // Stable, non-localized anchor for the landing assertion in host-driven @@ -70,27 +59,4 @@ public struct TodayTabHost: View { .padding(.top, 12) } } - - // MARK: - BannerSlotView (#371 / #195 / #441) - - /// Themed banner slot. Whether it shows is the environment's - /// `BannerSessionModel`'s decision (#1058); Today never suppresses it. - private var bannerSlot: some View { - BannerSlotView( - isSuppressed: false, - // #688 item 2: match the page background so an empty/loading slot is - // invisible instead of reading as a mismatched seam in dark mode. - backgroundColor: theme.surface.background.resolved, - progressTint: theme.accent.primary.resolved, - captionColor: theme.text.secondary.resolved, - // uiux-bugfix-plan P1-6 / #1084: text.secondary, not accent.muted - // — the muted accent reads as a ghost on the paper ground even - // at 16pt and full opacity. - dismissTint: theme.text.secondary.resolved, - // Padding lives inside `BannerSlotView` so a hidden slot collapses - // to zero height instead of leaving a padded gap. - horizontalPadding: 16, - verticalPadding: 12 - ) - } } diff --git a/Packages/GameAppKit/Sources/GameAppKit/UITestFakeSeams.swift b/Packages/GameAppKit/Sources/GameAppKit/UITestFakeSeams.swift index f2135083..a9162ba0 100644 --- a/Packages/GameAppKit/Sources/GameAppKit/UITestFakeSeams.swift +++ b/Packages/GameAppKit/Sources/GameAppKit/UITestFakeSeams.swift @@ -88,6 +88,27 @@ final actor UITestFlipOnBackgroundAdGateStateStore: AdGateStateStore { } } +/// #1024: unconditionally-open ad-gate store — no background-flip +/// discrimination needed (unlike `UITestFlipOnBackgroundAdGateStateStore` +/// above), because this arg's purpose isn't pinning a repoll hook, it's +/// letting the REAL `AdProvider` load and render a real ad for sim +/// verification of the `tabViewBottomAccessory` banner. `AdGate`'s decision +/// logic (docs/v1/design.md §How.3) reads every field here as "never +/// suppress": no purchase, no same-day dismissal, no clock-tamper baseline, +/// and a `firstLaunchAt` far enough in the past that the (currently +/// zero-valued) grace period can never apply either. +struct UITestAlwaysOpenAdGateStateStore: AdGateStateStore { + func loadState() async throws -> AdGateState { + AdGateState(firstLaunchAt: .distantPast) + } + + func saveState(_ state: AdGateState) async throws { + // No-op: this fake exists purely to seed an always-open READ; nothing + // in the `-uitest-open-ad-gate` scenario needs the write path (no + // dismiss/purchase interaction is part of that sim-verification flow). + } +} + /// Minimal `AdProvider` fake so the #931 ad-gate E2E case never touches the /// real AdMob SDK or network. Always reports `.loaded` once asked — the /// discriminating signal for that test is the ad GATE (open/closed, via diff --git a/Packages/GameAppKit/Sources/GameAppKit/UITestLaunchArg.swift b/Packages/GameAppKit/Sources/GameAppKit/UITestLaunchArg.swift index 1bba15bf..bef7108f 100644 --- a/Packages/GameAppKit/Sources/GameAppKit/UITestLaunchArg.swift +++ b/Packages/GameAppKit/Sources/GameAppKit/UITestLaunchArg.swift @@ -164,6 +164,18 @@ public enum UITestLaunchArg { /// flag also backs the restore-with-nothing-to-restore negative flow /// (N23 follow-up). Absent from Release builds via the `#if DEBUG` guard. public static let iapScript = "-uitest-iap-script" + + /// #1024 (PM-approved): forces `AdGate` to resolve the banner as + /// unconditionally open (ancient `firstLaunchAt`, no purchase, no + /// dismissal, no clock-tamper baseline) so the `tabViewBottomAccessory` + /// banner can be sim-verified showing a REAL rendered ad, not just an + /// empty/gated slot. Deliberately narrower than `fakeAdGateRepoll` + /// above: swaps ONLY the `AdGateStateStore` (`UITestAlwaysOpenAdGateStateStore`) + /// — `resolveAdProvider` is UNCHANGED and untouched by this arg, so the + /// real live `AdProvider` (AdMob SDK, Google's public DEBUG test creative + /// per `LiveAdMobBridge`) still loads and renders for real. Absent from + /// Release builds via the `#if DEBUG` guard. + public static let openAdGate = "-uitest-open-ad-gate" } #endif diff --git a/Packages/GameAppKit/Tests/GameAppKitTests/BannerAccessoryMissingLeaseTests.swift b/Packages/GameAppKit/Tests/GameAppKitTests/BannerAccessoryMissingLeaseTests.swift new file mode 100644 index 00000000..9c22bd08 --- /dev/null +++ b/Packages/GameAppKit/Tests/GameAppKitTests/BannerAccessoryMissingLeaseTests.swift @@ -0,0 +1,100 @@ +// BannerAccessoryMissingLeaseTests — pins the #1080 no-silent-fallback rule: +// `BannerAccessoryView` mounted with no `\.bannerAccessoryLease` in its +// environment (a lost `GameRoot` injection) must call +// `onMissingAccessoryLease()` and register/load NOTHING with the session, +// mirroring `BannerSessionModel.onMissingSession` (#1058, M1) rather than +// quietly falling back to a self-owned lease that would re-register on +// every `tabViewBottomAccessory` re-host. +// +// iOS only: `BannerAccessoryView` itself only compiles `#if os(iOS)`. + +#if os(iOS) + +import Foundation +import SwiftUI +import Testing +import UIKit +import MonetizationCore +import MonetizationUI +import MonetizationTesting +@testable import GameAppKit + +/// A started, gate-open `BannerSessionModel` over `provider` — if the +/// missing-lease branch ever regressed to a self-owned-lease fallback, this +/// is the session it would (wrongly) register and load against. +@MainActor +private func makeOpenStartedSession(provider: FakeAdProvider) async -> BannerSessionModel { + let state = AdGateState(firstLaunchAt: Date(timeIntervalSince1970: 0)) + let adGate = AdGate(store: FakeAdGateStateStore(initial: state)) + let session = BannerSessionModel(adProvider: provider, adGate: adGate) + await session.start() + return session +} + +/// Polls `condition` every 10ms until it holds or `timeout` elapses (mirrors +/// `BannerAccessoryPinTests.eventually`). +@MainActor +private func eventually(timeout: Duration = .seconds(2), _ condition: () -> Bool) async -> Bool { + let deadline = ContinuousClock.now.advanced(by: timeout) + while !condition() { + if ContinuousClock.now >= deadline { return false } + try? await Task.sleep(for: .milliseconds(10)) + } + return true +} + +// `.serialized`: the test swaps the static `BannerAccessoryView +// .onMissingAccessoryLease` hook to observe it, then restores the +// original — that mutable static must not race a concurrently-running test +// in this suite (mirrors `BannerAccessoryPinTests`'s own `.serialized`). +@MainActor +@Suite("BannerAccessoryView — missing lease (#1080)", .serialized) +struct BannerAccessoryMissingLeaseTests { + + /// Mutation target: in `BannerAccessoryView.content`'s `nil` branch, + /// render the self-owned-lease `BannerSlotView(isSuppressed:...)` AFTER + /// the `Self.onMissingAccessoryLease()` call — the hook still fires, but + /// `session.slots` stops being empty and `refreshCallCount` goes to 1. + /// This is what makes the test pin "no fallback", not just "hook fired": + /// a prior version of this test only asserted `calls.count > 0`, which + /// stays green even with a silent fallback re-added next to the call. + @Test("T4: no \\.bannerAccessoryLease reports it and registers/loads nothing (no fallback)") + func missingLeaseReportsAndRegistersNothing() async { + let provider = FakeAdProvider() + let session = await makeOpenStartedSession(provider: provider) + #expect(session.isVisible, "precondition: gate must be open for a fallback slot to ever register") + + let calls = CallBox() + let original = BannerAccessoryView.onMissingAccessoryLease + BannerAccessoryView.onMissingAccessoryLease = { calls.count += 1 } + defer { BannerAccessoryView.onMissingAccessoryLease = original } + + // `\.bannerSession` IS injected (a fallback slot would find a real + // session to register with) — only `\.bannerAccessoryLease` is + // missing, the exact lost-injection case. + let root = BannerAccessoryView().environment(\.bannerSession, session) + let controller = UIHostingController(rootView: root) + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 100)) + window.rootViewController = controller + window.makeKeyAndVisible() + window.layoutIfNeeded() + + let called = await eventually(timeout: .seconds(1.5)) { calls.count > 0 } + #expect(called, "missing-lease mount must call onMissingAccessoryLease()") + + // Bounded settle past the hook firing — a fallback slot's register + + // load both run asynchronously, so a load in flight at the moment + // the hook fires would not yet show up in `session.slots`. + try? await Task.sleep(for: .seconds(1)) + + #expect(session.slots.isEmpty, "no fallback: nothing should register with the session (currently: \(session.slots.count) slot(s))") + #expect(await provider.refreshCallCount == 0, "no fallback: nothing should request a banner") + } +} + +@MainActor +private final class CallBox { + var count = 0 +} + +#endif diff --git a/Packages/GameAppKit/Tests/GameAppKitTests/BannerAccessoryPinTests.swift b/Packages/GameAppKit/Tests/GameAppKitTests/BannerAccessoryPinTests.swift new file mode 100644 index 00000000..407eaba9 --- /dev/null +++ b/Packages/GameAppKit/Tests/GameAppKitTests/BannerAccessoryPinTests.swift @@ -0,0 +1,359 @@ +// BannerAccessoryPinTests — pin test for the shared banner accessory (#1080). +// +// PM requirement: the accessory's pre-#1080-merge render-level test suite +// (deleted during the #1062 rebase — see +// meetings/2026-09-07_1024-banner-accessory.impl-notes.md's "#1080 +// follow-through") needed a replacement pin — an E2E-only pin does not +// count. This suite renders the REAL `GameRoot` (not a bare `RootShellView`) +// hosting the REAL `BannerAccessoryView` and drives it through an +// UN-started `BannerSessionModel` — `GameRoot.onAppear` is what calls +// `bannerSession.start()`, exactly like production (`MakeGameApp.swift`) — +// so the suite actually exercises the `bannerSession.isVisible` flip inside +// `GameRoot.shellContent`, not a value frozen at construction time (round-1 +// gap: a bare `RootShellView` host never re-reads anything, so a regression +// hard-coding `bottomAccessoryIsEnabled` to either constant passed every +// round-1 test). +// +// (a) gate open → the accessory renders a registered slot that reaches +// `.loaded`, not an empty capsule; the `\.bannerSession` injection +// actually reaches the slot (no `onMissingSession` fallback); and +// calling `session.dismiss()` afterward collapses the capsule again — +// proving the true→false direction through Observation, not just the +// initial render. +// (b) gate denied → no accessory container at all (or a zero-height one +// with no registered slot) once the (un-started, GameRoot-started) +// session resolves `shouldShow == false`. +// (c) macOS → `makeBottomAccessory()` returns `EmptyView`, +// STRUCTURALLY: `BannerAccessoryView` does not compile into the macOS +// binary at all (mirrors GameShellKit's +// `RootShellViewBottomAccessoryRenderTests`, which cannot even be +// compiled on macOS for the same reason). +// +// Mutations, each named where it bites and EXECUTED red (then reverted) per +// PM instruction — a named-only mutation is weaker evidence than one run: +// (a1) `BannerAccessoryView.body` → `EmptyView()` — (a) goes red (no +// container, no registration). +// (a2) drop `.environment(\.bannerSession, session)` from the hosting +// helper — NOT executed: it mutates this test file's own helper, not +// production, so it stays a named-only guard (the `onMissingSession` +// assertion inside (a) is what it would trip). +// (b) `RootShellView.body`'s `.tabViewBottomAccessory(isEnabled:)` call +// hard-coded to `true` — (b) goes red, and the dismiss half of (a) +// goes red (the capsule survives `dismiss()`). +// (c) see above — structural, not a runtime mutation. +// (d1) `GameRoot.shellContent`'s `bottomAccessoryIsEnabled:` hard-coded to +// `true` — (b) goes red, and the dismiss half of (a) goes red. +// (d2) `GameRoot.shellContent`'s `bottomAccessoryIsEnabled:` hard-coded to +// `false` — (a) goes red (the capsule never appears at all). + +#if os(macOS) + +import SwiftUI +import Testing +@testable import GameAppKit + +@MainActor +@Suite("Banner accessory pin (#1080)") +struct BannerAccessoryPinTests { + + @Test("(c) macOS: makeBottomAccessory() is structurally EmptyView") + func macOSAccessoryIsStructurallyEmpty() { + // Mutation: remove the `#if os(iOS)` guard inside `makeBottomAccessory` + // (MakeGameApp+Helpers.swift) so both branches construct + // `BannerAccessoryView()`. On macOS that mutation doesn't just make + // this test red — `BannerAccessoryView` (and the `BannerSlotView` it + // wraps) is itself gated `#if os(iOS)`, so the type doesn't exist on + // this platform at all and the macOS build fails outright. That + // build failure IS the structural exclusion this test stands in for. + #expect((makeBottomAccessory() as Any) is EmptyView) + } +} + +#endif + +#if os(iOS) + +import Foundation +import SwiftUI +import Testing +import UIKit +import GameCenterClient +import MonetizationCore +import MonetizationUI +import MonetizationTesting +import Persistence +import GameShellUI +import SudokuEngine +import SudokuGameState +@testable import GameAppKit + +// MARK: - Sentinel route/factory +// +// A private copy, not shared with GameShellKit's +// `RootShellViewBottomAccessoryRenderTests` — per instruction, GameShellKit's +// test helpers stay test-only and unshared across packages. + +private enum AccessoryPinSentinelRoute: Hashable, Sendable { + case first + case settings +} + +private struct AccessoryPinSentinelFactory: RouteFactory { + typealias Route = AccessoryPinSentinelRoute + + @MainActor + func view(for route: AccessoryPinSentinelRoute, path: Binding<[AccessoryPinSentinelRoute]>?) -> AnyView { + AnyView(Text("destination")) + } +} + +// MARK: - GameRootViewModel doubles +// +// Private copies of `TodayTabHostTests`' `ATTHostStubPersistence` / +// `ATTHostStubGameCenter` (that file's are `private`, so not importable) — +// the minimal stubs needed to construct a real `GameRootViewModel` whose +// `bootstrap()` (called from `GameRoot.onAppear`) completes instantly and +// harmlessly. + +private actor AccessoryPinStubPersistence: PersistenceProtocol { + func bootstrap() async throws {} + func latestInProgress() async throws -> SavedGameSummary? { nil } + func loadOrCreate( + puzzleId: String, + mode: Mode, + difficulty: Difficulty + ) async throws -> GameSessionSnapshot { + throw PersistenceError.zoneNotProvisioned + } + func save( + _ snapshot: GameSessionSnapshot, + puzzleId: String, + mode: Mode, + difficulty: Difficulty + ) async throws {} + func markCompleted(_ summary: SavedGameSummary) async throws {} + func deleteAbandoned(recordName: String) async throws {} + func fetchCompletedDailyIds(for date: Date) async throws -> Set { [] } + func fetchCompletedDailyIdsByDay() async throws -> [String: Set] { [:] } + func fetchPersonalRecord(mode: Mode, difficulty: Difficulty) async throws -> PersonalRecord { + PersonalRecord( + recordName: "", + mode: .daily, + difficulty: .easy, + bestTimeSeconds: nil, + totalTimeSeconds: 0, + completedCount: 0, + lastUpdatedAt: Date(timeIntervalSince1970: 0), + completedPuzzleIds: [] + ) + } + func upsertPersonalRecord(_ record: PersonalRecord) async throws {} +} + +private struct AccessoryPinStubGameCenter: GameCenterClient { + func authenticate() async throws -> GameCenterAuthState { .unauthenticated } + func authStateUpdates() async -> AsyncStream { + AsyncStream { $0.finish() } + } + func submitScore(puzzleId: String, elapsedSeconds: Int, leaderboardKind: LeaderboardKind) async throws {} + func submitScore(leaderboardId: String, elapsedSeconds: Int) async throws {} + func reportAchievement(_ achievement: AchievementProgress) async throws {} +} + +// MARK: - Container-walking helpers +// +// Private copies of GameShellKit's `findAccessoryContainer` / +// `waitForAccessoryContainer` (RootShellViewBottomAccessoryRenderTests.swift) +// — same proof strategy (SwiftUI mounts `tabViewBottomAccessory`'s content +// inside a private container view whose class name contains +// "BottomAccessory"), not exported across packages. + +@MainActor +private func findAccessoryContainer(in view: UIView) -> UIView? { + if String(describing: type(of: view)).contains("BottomAccessory") { + return view + } + for subview in view.subviews { + if let found = findAccessoryContainer(in: subview) { + return found + } + } + return nil +} + +@MainActor +private func waitForAccessoryContainer(in window: UIWindow) async -> UIView? { + var iterations = 0 + while iterations < 50 { + if let container = findAccessoryContainer(in: window) { + return container + } + try? await Task.sleep(for: .milliseconds(20)) + iterations += 1 + } + return nil +} + +/// Polls `condition` every 10ms until it holds or `timeout` elapses (mirrors +/// `TodayTabHostTests.eventually`). +@MainActor +private func eventually(timeout: Duration = .seconds(2), _ condition: () -> Bool) async -> Bool { + let deadline = ContinuousClock.now.advanced(by: timeout) + while !condition() { + if ContinuousClock.now >= deadline { return false } + try? await Task.sleep(for: .milliseconds(10)) + } + return true +} + +// MARK: - Session + hosted-GameRoot helpers + +/// A `BannerSessionModel` whose gate is open or denied — NOT started here. +/// `GameRoot.onAppear` is the one that calls `start()`, mirroring +/// production exactly (`MakeGameApp.swift`). Denied via +/// `hasPurchasedRemoveAds` — the highest-precedence, `now`-independent +/// `AdGate` rule (`AdGate.swift` resolution order, rule 1). +@MainActor +private func makeSession(gateOpen: Bool) -> BannerSessionModel { + let state = AdGateState( + firstLaunchAt: Date(timeIntervalSince1970: 0), + hasPurchasedRemoveAds: !gateOpen + ) + let adGate = AdGate(store: FakeAdGateStateStore(initial: state)) + return BannerSessionModel(adProvider: FakeAdProvider(), adGate: adGate) +} + +/// Hosts the REAL `GameRoot` — same shape `makeGameAppCore` builds +/// (`MakeGameApp.swift`): `GameRoot(...)` then `.environment(\.bannerSession, +/// session)` on the `GameRoot` value itself, with `bottomAccessory: { +/// makeBottomAccessory() }` supplying the REAL `BannerAccessoryView` on iOS. +/// `session` must not already be started — `GameRoot.onAppear` does that. +@MainActor +private func makeHostedGameRoot(session: BannerSessionModel) -> UIWindow { + let viewModel = GameRootViewModel( + gameCenter: AccessoryPinStubGameCenter(), + persistence: AccessoryPinStubPersistence() + ) + let root = GameRoot( + viewModel: viewModel, + bannerSession: session, + routeFactory: AccessoryPinSentinelFactory(), + settingsRoute: .settings, + toastController: nil, + successTint: .green, + failureTint: .red, + infoTint: .blue, + tabRoot: { tab in Text(tab.rawValue) }, + bottomAccessory: { makeBottomAccessory() } + ) + .environment(\.bannerSession, session) + + let controller = UIHostingController(rootView: root) + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844)) + window.rootViewController = controller + window.makeKeyAndVisible() + window.layoutIfNeeded() + return window +} + +// MARK: - Suite +// +// `.serialized`: test (a) temporarily swaps the static +// `BannerSessionModel.onMissingSession` hook to observe it, then restores +// the original — that mutable static must not race a concurrently-running +// test in this suite. + +@MainActor +@Suite("Banner accessory pin (#1080)", .serialized) +struct BannerAccessoryPinTests { + + @Test("(a) gate open: accessory renders a loaded slot, then hides on dismiss") + func gateOpenRendersThenHidesOnDismiss() async { + let session = makeSession(gateOpen: true) + + // Prove the `\.bannerSession` injection actually reaches the slot — + // mutation (a2, named-only, see file header): dropping + // `.environment(\.bannerSession, session)` from the hosting helper + // would make `BannerSlotRegistration` call `onMissingSession()` + // instead of registering. Swap + restore the static hook around the + // render. + let originalOnMissingSession = BannerSessionModel.onMissingSession + var missingSessionCalled = false + BannerSessionModel.onMissingSession = { missingSessionCalled = true } + defer { BannerSessionModel.onMissingSession = originalOnMissingSession } + + let window = makeHostedGameRoot(session: session) + + // Nothing here calls `session.start()` — only `GameRoot.onAppear` + // does. Mutation (d2): `bottomAccessoryIsEnabled:` hard-coded false + // makes `becameVisible` never resolve. + let becameVisible = await eventually { session.isVisible } + #expect(becameVisible, "GameRoot.onAppear must start() the session and flip isVisible true when the gate is open") + + let container = await waitForAccessoryContainer(in: window) + #expect( + !missingSessionCalled, + "mutation (a2): the \\.bannerSession injection was dropped — the slot fell back to onMissingSession()" + ) + + guard let container else { + Issue.record("no tabViewBottomAccessory container ever appeared — mutation (a1) or (d2)") + return + } + #expect( + container.bounds.height > 0 && !container.subviews.isEmpty, + "accessory container present but empty/zero-height — mutation (a1): BannerAccessoryView.body reverted to EmptyView()" + ) + + let registered = await eventually { session.slots.count == 1 } + #expect( + registered, + "expected exactly one registered slot — the accessory's BannerSlotView never registered (currently: \(session.slots.count))" + ) + + let reachedLoaded = await eventually { + if case .loaded = session.slots.values.first { return true } + return false + } + #expect( + reachedLoaded, + "registered slot never reached .loaded — got \(session.slots.values.first.map { String(describing: $0) } ?? "none")" + ) + + // Prove true→false through Observation, not just the initial + // render. Mutations (b) and (d1) — `isEnabled`/`bottomAccessoryIsEnabled` + // hard-coded `true` — leave the capsule up after dismiss. + await session.dismiss() + let hiddenAfterDismiss = await eventually { + let liveContainer = findAccessoryContainer(in: window) + return liveContainer == nil || (liveContainer!.bounds.height == 0 && liveContainer!.subviews.isEmpty) + } + #expect( + hiddenAfterDismiss, + "capsule survived dismiss() — mutation (b): RootShellView isEnabled hard-coded true, or (d1): GameRoot bottomAccessoryIsEnabled hard-coded true" + ) + } + + @Test("(b) gate denied: no accessory capsule and no registered slot") + func gateDeniedRendersNothing() async { + let session = makeSession(gateOpen: false) + let window = makeHostedGameRoot(session: session) + + // Mutation (d1): `bottomAccessoryIsEnabled:` hard-coded true makes + // `shouldShow` still resolve false (the gate itself is untouched by + // that mutation) but the capsule below would incorrectly render. + let resolved = await eventually { session.shouldShow == false } + #expect(resolved, "GameRoot.onAppear must start() the session and resolve shouldShow to false when the gate is denied") + + _ = await waitForAccessoryContainer(in: window) + let container = findAccessoryContainer(in: window) + + #expect( + container == nil || (container!.bounds.height == 0 && container!.subviews.isEmpty), + "mutation (b): RootShellView isEnabled hard-coded true, or (d1): GameRoot bottomAccessoryIsEnabled hard-coded true" + ) + #expect(session.slots.isEmpty, "no slot should hold load state while the accessory never mounted a visible BannerSlotView") + } +} + +#endif diff --git a/Packages/GameAppKit/Tests/GameAppKitTests/BannerAccessoryReHostTests.swift b/Packages/GameAppKit/Tests/GameAppKitTests/BannerAccessoryReHostTests.swift new file mode 100644 index 00000000..93150335 --- /dev/null +++ b/Packages/GameAppKit/Tests/GameAppKitTests/BannerAccessoryReHostTests.swift @@ -0,0 +1,198 @@ +// BannerAccessoryReHostTests — T2 for #1080: the accessory's banner lease +// must survive `tabViewBottomAccessory` natively re-hosting its content +// (push, pop, sheet dismissal — see +// `meetings/2026-09-11_1058-slot-model-design.md` §"Externally owned lease +// (#1080)"). +// +// Separate file, not added into `BannerAccessoryPinTests`'s suite (PM's +// "T2 可以直接加進同一個 suite" was permissive, not mandatory): folding T2's +// helper in there pushed that file to 438 lines, over the repo's +// `file_length` ceiling (400, SwiftLint `--strict`). This file duplicates +// the minimal stubs it needs (sentinel route/factory, `GameRootViewModel` +// doubles, the `eventually` poll) rather than loosening any `private` in +// `BannerAccessoryPinTests.swift` to share them across files — the same +// call the #1080 remount probe's own (unshipped) teardown test made for the +// same reason ("Mirrors TodayTabHostTests'... those are file-`private`, so +// this throwaway file needs its own copies"). +// +// iOS only — `BannerAccessoryView` (and `GameRoot`'s accessory wiring it +// exercises) only compiles `#if os(iOS)`. + +#if os(iOS) + +import Foundation +import SwiftUI +import Testing +import UIKit +import GameCenterClient +import MonetizationCore +import MonetizationUI +import MonetizationTesting +import Persistence +import GameShellUI +import SudokuEngine +import SudokuGameState +@testable import GameAppKit + +private enum ReHostSentinelRoute: Hashable, Sendable { + case first + case settings +} + +private struct ReHostSentinelFactory: RouteFactory { + typealias Route = ReHostSentinelRoute + + @MainActor + func view(for route: ReHostSentinelRoute, path: Binding<[ReHostSentinelRoute]>?) -> AnyView { + AnyView(Text("destination")) + } +} + +private actor ReHostStubPersistence: PersistenceProtocol { + func bootstrap() async throws {} + func latestInProgress() async throws -> SavedGameSummary? { nil } + func loadOrCreate( + puzzleId: String, + mode: Mode, + difficulty: Difficulty + ) async throws -> GameSessionSnapshot { + throw PersistenceError.zoneNotProvisioned + } + func save( + _ snapshot: GameSessionSnapshot, + puzzleId: String, + mode: Mode, + difficulty: Difficulty + ) async throws {} + func markCompleted(_ summary: SavedGameSummary) async throws {} + func deleteAbandoned(recordName: String) async throws {} + func fetchCompletedDailyIds(for date: Date) async throws -> Set { [] } + func fetchCompletedDailyIdsByDay() async throws -> [String: Set] { [:] } + func fetchPersonalRecord(mode: Mode, difficulty: Difficulty) async throws -> PersonalRecord { + PersonalRecord( + recordName: "", + mode: .daily, + difficulty: .easy, + bestTimeSeconds: nil, + totalTimeSeconds: 0, + completedCount: 0, + lastUpdatedAt: Date(timeIntervalSince1970: 0), + completedPuzzleIds: [] + ) + } + func upsertPersonalRecord(_ record: PersonalRecord) async throws {} +} + +private struct ReHostStubGameCenter: GameCenterClient { + func authenticate() async throws -> GameCenterAuthState { .unauthenticated } + func authStateUpdates() async -> AsyncStream { + AsyncStream { $0.finish() } + } + func submitScore(puzzleId: String, elapsedSeconds: Int, leaderboardKind: LeaderboardKind) async throws {} + func submitScore(leaderboardId: String, elapsedSeconds: Int) async throws {} + func reportAchievement(_ achievement: AchievementProgress) async throws {} +} + +/// Polls `condition` every 10ms until it holds or `timeout` elapses (mirrors +/// `BannerAccessoryPinTests.eventually`). +@MainActor +private func eventually(timeout: Duration = .seconds(2), _ condition: () -> Bool) async -> Bool { + let deadline = ContinuousClock.now.advanced(by: timeout) + while !condition() { + if ContinuousClock.now >= deadline { return false } + try? await Task.sleep(for: .milliseconds(10)) + } + return true +} + +/// A `BannerSessionModel` with an open gate, backed by the given (or a +/// fresh) `FakeAdProvider` so the test can read `refreshCallCount` after +/// driving the hosted window. NOT started here — `GameRoot.onAppear` does +/// that, exactly like production. +@MainActor +private func makeOpenSession(provider: FakeAdProvider) -> BannerSessionModel { + let state = AdGateState(firstLaunchAt: Date(timeIntervalSince1970: 0)) + let adGate = AdGate(store: FakeAdGateStateStore(initial: state)) + return BannerSessionModel(adProvider: provider, adGate: adGate) +} + +/// Hosts the REAL `GameRoot`, same shape as `BannerAccessoryPinTests +/// .makeHostedGameRoot`, and also returns the view model — needed here to +/// push/pop a route on a tab's own `NavigationStack`. +@MainActor +private func makeHostedGameRoot( + session: BannerSessionModel +) -> (window: UIWindow, viewModel: GameRootViewModel) { + let viewModel = GameRootViewModel( + gameCenter: ReHostStubGameCenter(), + persistence: ReHostStubPersistence() + ) + let root = GameRoot( + viewModel: viewModel, + bannerSession: session, + routeFactory: ReHostSentinelFactory(), + settingsRoute: .settings, + toastController: nil, + successTint: .green, + failureTint: .red, + infoTint: .blue, + tabRoot: { tab in Text(tab.rawValue) }, + bottomAccessory: { makeBottomAccessory() } + ) + .environment(\.bannerSession, session) + + let controller = UIHostingController(rootView: root) + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844)) + window.rootViewController = controller + window.makeKeyAndVisible() + window.layoutIfNeeded() + return (window, viewModel) +} + +@MainActor +@Suite("Banner accessory re-host lease (#1080)") +struct BannerAccessoryReHostTests { + + /// Mutation target (#1080, executed and reverted — see the dispatch + /// report): `BannerAccessoryView.content`'s non-nil branch reverted to + /// the self-owned-lease init (`BannerSlotView(isSuppressed:...)` with no + /// `lease:` argument) — refresh count goes to 2 or the slot id changes. + @Test("T2: the accessory's lease survives a push+pop re-host on its own tab") + func accessorySurvivesPushPopReHost() async { + let provider = FakeAdProvider() + let session = makeOpenSession(provider: provider) + let (window, viewModel) = makeHostedGameRoot(session: session) + + let becameVisible = await eventually { session.isVisible } + #expect(becameVisible, "precondition: gate must be open") + + let reachedLoaded = await eventually { + if case .loaded = session.slots.values.first { return true } + return false + } + #expect(reachedLoaded, "precondition: the accessory's slot must load before probing re-host behavior") + #expect(await provider.refreshCallCount == 1, "precondition: exactly one initial request") + let initialID = session.slots.keys.first + + // Push then pop a route on the Today tab's own NavigationStack — the + // #1080 probe's measured trigger set for a native re-host of + // `tabViewBottomAccessory`'s content includes a push/pop pair. + // Confirmed empirically (dispatch report): under the pre-fix + // mutation this push/pop DOES flip the assertions below red, so the + // harness genuinely reproduces a re-host rather than trivially + // passing regardless of the fix. + let path = viewModel.pathBinding(for: .today) + path.wrappedValue.append(.first) + window.layoutIfNeeded() + try? await Task.sleep(for: .milliseconds(200)) + path.wrappedValue.removeLast() + window.layoutIfNeeded() + try? await Task.sleep(for: .milliseconds(400)) + + #expect(session.slots.count == 1, "the push/pop must not register a second slot") + #expect(session.slots.keys.first == initialID, "the push/pop must not change the slot id") + #expect(await provider.refreshCallCount == 1, "the push/pop must not re-request the banner") + } +} + +#endif diff --git a/Packages/GameAppKit/Tests/GameAppKitTests/UITestAlwaysOpenAdGateStateStoreTests.swift b/Packages/GameAppKit/Tests/GameAppKitTests/UITestAlwaysOpenAdGateStateStoreTests.swift new file mode 100644 index 00000000..d21036d2 --- /dev/null +++ b/Packages/GameAppKit/Tests/GameAppKitTests/UITestAlwaysOpenAdGateStateStoreTests.swift @@ -0,0 +1,73 @@ +// UITestAlwaysOpenAdGateStateStoreTests — #1024 (PM-approved) fake contract. +// +// The whole fake is `#if DEBUG` (defined in UITestFakeSeams.swift), so this +// suite is too. Two things this arg's design depends on, both worth pinning +// directly rather than trusting by inspection alone (mirrors the pattern of +// `UITestSignedOutGameCenterClientTests` / `UITestScriptedIAPClientTests` — +// test the FAKE's behavior; the `#if DEBUG` + `ProcessInfo.arguments.contains` +// gating shape itself is the same, already-proven wiring every prior +// `UITestLaunchArg` uses, not re-tested per-arg): +// +// 1. The fake, run through a REAL `AdGate`, actually opens the gate — not +// just "the struct's fields look right", but the end effect +// `-uitest-open-ad-gate` exists for. +// 2. `resolveAdProvider` is unaffected by this arg's existence — it still +// returns whatever `makeLive()` produces, proving the PM's "never +// touches the provider resolution" condition behaviorally, not just by +// the `git diff` showing zero changes to that function's body. + +#if DEBUG + +import Foundation +import Testing +import MonetizationCore +@testable import GameAppKit + +@Suite("UITestAlwaysOpenAdGateStateStore (#1024)") +struct UITestAlwaysOpenAdGateStateStoreTests { + + @Test("loadState reports an unconditionally-open state: no purchase, no dismissal, no tamper baseline") + func loadStateReportsOpenState() async throws { + let state = try await UITestAlwaysOpenAdGateStateStore().loadState() + + #expect(state.hasPurchasedRemoveAds == false) + #expect(state.dismissedDate == nil) + #expect(state.lastSeenWallClock == nil) + #expect( + state.firstLaunchAt < Date().addingTimeInterval(-365 * 86_400), + "firstLaunchAt must be far enough in the past that a future non-zero grace period can't suppress the banner" + ) + } + + @Test("wired into a real AdGate, the fake actually opens shouldShowBanner") + func realAdGateOpensWithThisFake() async { + let gate = AdGate(store: UITestAlwaysOpenAdGateStateStore()) + + let allowed = await gate.shouldShowBanner(now: Date()) + + #expect(allowed == true, "the fake must produce an open gate through AdGate's real decision logic, not just look open on paper") + } + + @MainActor + @Test("resolveAdProvider is unaffected by -uitest-open-ad-gate — the live provider path is untouched") + func resolveAdProviderStillReturnsMakeLiveResult() { + struct MarkerProvider: AdProvider { + func initialize() async throws {} + func awaitReady() async throws {} + var bannerStatus: AdBannerStatus { get async { .notInitialized } } + func refreshBanner() async throws -> AdBannerHandle { throw AdProviderError.unsupported } + func dispose(handle: AdBannerHandle) async {} + } + + // This test process's own launch arguments never contain + // `-uitest-fake-ad-gate-repoll` (the only arg `resolveAdProvider` + // reads), so `makeLive()` must always win here — proving the + // function's live-provider path is reachable and untouched by + // #1024's new arg, which `resolveAdProvider` doesn't even reference. + let provider = resolveAdProvider { MarkerProvider() } + + #expect(provider is MarkerProvider, "resolveAdProvider must still hand back makeLive()'s result unchanged") + } +} + +#endif diff --git a/Packages/GameAudioKit/Package.swift b/Packages/GameAudioKit/Package.swift index 8f195f2a..fc0cf87b 100644 --- a/Packages/GameAudioKit/Package.swift +++ b/Packages/GameAudioKit/Package.swift @@ -36,7 +36,7 @@ let swiftSettings: [SwiftSetting] = [ let package = Package( name: "GameAudioKit", platforms: [ - .iOS(.v26), + .iOS("26.1"), .macOS(.v26), ], products: [ diff --git a/Packages/GameCenterKit/Package.swift b/Packages/GameCenterKit/Package.swift index 0f9a7662..1479fcf1 100644 --- a/Packages/GameCenterKit/Package.swift +++ b/Packages/GameCenterKit/Package.swift @@ -37,7 +37,7 @@ let swiftSettings: [SwiftSetting] = [ let package = Package( name: "GameCenterKit", platforms: [ - .iOS(.v26), + .iOS("26.1"), .macOS(.v26), ], products: [ diff --git a/Packages/GameShellKit/Package.swift b/Packages/GameShellKit/Package.swift index f6eb33a9..24b5d85d 100644 --- a/Packages/GameShellKit/Package.swift +++ b/Packages/GameShellKit/Package.swift @@ -67,7 +67,7 @@ let testTargets: [Target] = [ let package = Package( name: "GameShellKit", platforms: [ - .iOS(.v26), + .iOS("26.1"), .macOS(.v26), ], products: [ diff --git a/Packages/GameShellKit/Sources/GameShellUI/PracticeHubShellView.swift b/Packages/GameShellKit/Sources/GameShellUI/PracticeHubShellView.swift index 5b00ab04..435deb3b 100644 --- a/Packages/GameShellKit/Sources/GameShellUI/PracticeHubShellView.swift +++ b/Packages/GameShellKit/Sources/GameShellUI/PracticeHubShellView.swift @@ -9,9 +9,6 @@ // - the chrome triple (`.background(Color)` + `.navigationTitle`) // - the inline section header `Text` for the filter slot, with caller- // supplied foreground color (`headerForeground`) -// - the optional `banner` slot pinned below the scrollable content (Epic 5). -// GameShellKit is zero-dep: the actual `BannerSlotView` is injected by -// each app at the RouteFactory level; the default is EmptyView. // // Caller supplies: // - `title` and `filterHeader` (both `LocalizedStringKey`) @@ -21,8 +18,12 @@ // glassEffect / padding decoration — none of which the shell touches) // - the `cta` slot (the game's draw/start affordance — Sudoku's `drawCard` // with its shimmer + glassEffect, or Minesweeper's simpler Start button) -// - the `banner` slot (injected by each app; EmptyView default for -// previews/tests; the actual BannerSlotView is never imported here) +// +// #1080: the `banner` slot (Epic 5) was removed — its only two +// forwarders (`PracticeHubView`, `MinesweeperPracticeHubView`) dropped their +// own `banner:` params in the same PR, so nothing fed this one any more. +// The shared `tabViewBottomAccessory` (design.md §2.4) covers Practice's +// banner today. // // Loading state (Sudoku's `PracticeHubLoadingState`) deliberately stays in // the caller's CTA — the shell carries no state machine. Lets Minesweeper @@ -30,15 +31,14 @@ public import SwiftUI -public struct PracticeHubShellView: View -where Filter: View, CTA: View, Banner: View { +public struct PracticeHubShellView: View +where Filter: View, CTA: View { private let title: LocalizedStringKey private let backgroundColor: Color private let filterHeader: LocalizedStringKey private let headerForeground: Color private let filter: () -> Filter private let cta: () -> CTA - private let banner: Banner // Structural screen-edge inset (#762 PR1 two-tier spacing contract). // This shell deliberately does not read `@Environment(\.theme)` for @@ -56,8 +56,7 @@ where Filter: View, CTA: View, Banner: View { filterHeader: LocalizedStringKey, headerForeground: Color, @ViewBuilder filter: @escaping () -> Filter, - @ViewBuilder cta: @escaping () -> CTA, - @ViewBuilder banner: () -> Banner = { EmptyView() } + @ViewBuilder cta: @escaping () -> CTA ) { self.title = title self.backgroundColor = backgroundColor @@ -65,39 +64,32 @@ where Filter: View, CTA: View, Banner: View { self.headerForeground = headerForeground self.filter = filter self.cta = cta - self.banner = banner() } public var body: some View { - // spacing-exempt: zero-gap chrome seam between the scrollable content - // and the banner slot — not a spacing decision. - VStack(spacing: 0) { - // #1021 Phase G: the filter/CTA content flows in a `ScrollView` - // below the navigation title instead of a fixed, screen-filling - // `VStack`. A bare `VStack` under a large title only reserves a - // single-line title's worth of top space; at an accessibility - // Dynamic Type size the title wraps to 2+ lines and the fixed - // content overlapped it (the "Difficulty" label drawn under - // "Practice"'s second line). A `ScrollView` lets the system - // report the title's actual (multi-line) height as a top - // safe-area inset the content flows below, with no magic offset, - // and also satisfies G3 (content stays reachable if it ever - // grows taller than the screen at large text sizes). - ScrollView { - VStack(alignment: .leading, spacing: contentGap) { - Text(filterHeader) - .font(.title3.weight(.semibold)) - .foregroundStyle(headerForeground) + // #1021 Phase G: the filter/CTA content flows in a `ScrollView` + // below the navigation title instead of a fixed, screen-filling + // `VStack`. A bare `VStack` under a large title only reserves a + // single-line title's worth of top space; at an accessibility + // Dynamic Type size the title wraps to 2+ lines and the fixed + // content overlapped it (the "Difficulty" label drawn under + // "Practice"'s second line). A `ScrollView` lets the system + // report the title's actual (multi-line) height as a top + // safe-area inset the content flows below, with no magic offset, + // and also satisfies G3 (content stays reachable if it ever + // grows taller than the screen at large text sizes). + ScrollView { + VStack(alignment: .leading, spacing: contentGap) { + Text(filterHeader) + .font(.title3.weight(.semibold)) + .foregroundStyle(headerForeground) - filter() + filter() - cta() - } - .padding(screenEdgeInset) - .frame(maxWidth: .infinity, alignment: .top) + cta() } - - banner + .padding(screenEdgeInset) + .frame(maxWidth: .infinity, alignment: .top) } .background(backgroundColor) .navigationTitle(title) diff --git a/Packages/GameShellKit/Sources/GameShellUI/RootShellView.swift b/Packages/GameShellKit/Sources/GameShellUI/RootShellView.swift index ab5eec66..bf3c1787 100644 --- a/Packages/GameShellKit/Sources/GameShellUI/RootShellView.swift +++ b/Packages/GameShellKit/Sources/GameShellUI/RootShellView.swift @@ -57,15 +57,66 @@ // - The modifier is STATIC: it reads no state that changes while a board is // pushed (no `coordinator`, no path length), so it cannot trip rule 2 // above and unmount a pushed destination. +// +// #1024 — `tabViewBottomAccessory` (design.md §2.4, iOS/iPadOS 26.0+, NOT +// available on macOS): +// +// - `bottomAccessory` is a plain generic `ViewBuilder` closure, exactly +// like `tabRoot` — GameShellKit stays zero-dependency, so it has no idea +// the content is a monetization banner. GameAppKit supplies the real +// content; the `Accessory == EmptyView` convenience init below lets +// macOS (and any pre-#1024 test) keep constructing this type without +// naming an accessory at all. +// - The modifier attaches UNCONDITIONALLY inside `#if os(iOS)` — never +// `if someCondition { .tabViewBottomAccessory { … } }`. A conditionally +// ATTACHED modifier is the same shape of hazard rule 2 above already +// burned us on (#1020): whether or not to show the accessory must be +// decided INSIDE `bottomAccessory`'s own content (it can render an empty +// / zero-height view), never by branching whether the modifier itself is +// present. (PM ruling, 2026-09-08 — see `meetings/ +// 2026-09-07_1024-banner-accessory.impl-notes.md`.) +// - Structural macOS exclusion: the `.tabViewBottomAccessory` call site +// itself only exists inside `#if os(iOS)` — on macOS the string doesn't +// appear in the compiled binary at all, satisfying design.md §2.4.1 +// option A (no banner, no accessory, ever) by construction rather than a +// runtime check. +// +// #1079 (owner decision, 2026-09-15, option 1) — `isEnabled:`: +// +// Sim evidence after #1024 shipped found the "decide visibility INSIDE the +// content" rule above still left a visible artifact: an empty/zero-height +// `bottomAccessory` still reserves the accessory's capsule chrome (~48pt), +// so a Remove-Ads purchaser (or any gate-denied state) saw a blank capsule +// floating above the tab bar. `tabViewBottomAccessory(isEnabled:content:)` +// (iOS 26.1+, `@available(macOS, unavailable)`) fixes this at the SDK +// level: when `isEnabled` is false the WHOLE capsule — chrome included — is +// never drawn, not just its content. That is why the deployment floor rose +// to iOS 26.1 (Project.swift / every `Package.swift`) in the same change: +// neither app has shipped, so raising the floor costs no existing user +// (#1080 — see CLAUDE.md: both apps are pre-first-release, TestFlight-only). +// +// `isEnabled` is still just a `Bool` this shell receives from its host — +// GameShellKit stays zero-dependency and still has no idea it is wired to +// `BannerSessionModel.isVisible` in GameAppKit. This does NOT reopen the +// "never conditionally attach" rule above: the modifier itself is still +// attached unconditionally, every render, inside the same `#if os(iOS)` +// block — `isEnabled` is an SDK-provided display switch on an +// always-present modifier, not a branch on whether to call +// `.tabViewBottomAccessory` at all. The content-side "decide your own +// visibility" convention also stays in place as defence in depth (see +// `GameAppKit.BannerAccessoryView`) — both layers now agree by +// construction rather than by coincidence. public import SwiftUI -public struct RootShellView: View { +public struct RootShellView: View { @Binding private var selectedTab: AppTab private let path: (AppTab) -> Binding<[Route]> private let routeFactory: any RouteFactory private let settingsRoute: Route private let tabRoot: (AppTab) -> TabRoot + private let bottomAccessoryIsEnabled: Bool + private let bottomAccessory: () -> Accessory // #1019: owns the hoisted overlay. `@State` so its identity survives body // re-evaluations; injected into the TabView subtree so a pushed board can @@ -86,18 +137,32 @@ public struct RootShellView: View { /// - tabRoot: the per-app root content for a given tab (Today hub / /// Practice hub / Progress). Supplied by the app so the shell stays /// game-agnostic. + /// - bottomAccessoryIsEnabled: drives `tabViewBottomAccessory(isEnabled:)` + /// (#1079, iOS 26.1+, see the file header) — when `false` the whole + /// accessory capsule is never drawn, not just its content. The host + /// decides this (GameAppKit passes `bannerSession.isVisible`); this + /// shell has no idea what it means. + /// - bottomAccessory: content for `tabViewBottomAccessory` (#1024, + /// design.md §2.4) — iOS/iPadOS only, see the file header. Also + /// decides its OWN visibility (empty / zero-height when there is + /// nothing to show) as defence in depth; this shell never + /// conditionally attaches or detaches the modifier itself. public init( selectedTab: Binding, path: @escaping (AppTab) -> Binding<[Route]>, routeFactory: any RouteFactory, settingsRoute: Route, - @ViewBuilder tabRoot: @escaping (AppTab) -> TabRoot + @ViewBuilder tabRoot: @escaping (AppTab) -> TabRoot, + bottomAccessoryIsEnabled: Bool, + @ViewBuilder bottomAccessory: @escaping () -> Accessory ) { self._selectedTab = selectedTab self.path = path self.routeFactory = routeFactory self.settingsRoute = settingsRoute self.tabRoot = tabRoot + self.bottomAccessoryIsEnabled = bottomAccessoryIsEnabled + self.bottomAccessory = bottomAccessory } public var body: some View { @@ -112,6 +177,14 @@ public struct RootShellView: View { .tabViewStyle(.sidebarAdaptable) .tabViewSidebarFooter { sidebarSettingsRow } .environment(\.boardModalOverlayCoordinator, coordinator) + // #1024 / #1079: iOS/iPadOS only — macOS has no + // `tabViewBottomAccessory` API at all (design.md §2.4.1 option A). + // Attached UNCONDITIONALLY; `isEnabled` is an SDK-provided display + // switch on this always-present modifier, not a conditional + // attach — see the file header for why that distinction matters. + #if os(iOS) + .tabViewBottomAccessory(isEnabled: bottomAccessoryIsEnabled) { bottomAccessory() } + #endif // The overlay renders OUTSIDE the TabView so its own Resume / Close // stays live (#1019). `HoistedOverlayHost` — not this body — is what @@ -168,6 +241,34 @@ public struct RootShellView: View { } } +// MARK: - No-accessory convenience (macOS + pre-#1024 call sites) + +public extension RootShellView where Accessory == EmptyView { + /// Same as the designated init, minus `bottomAccessory` — macOS (no + /// `tabViewBottomAccessory` API) and existing tests construct the shell + /// this way. `bottomAccessory` resolves to `{ EmptyView() }`, which the + /// `#if os(iOS)` guard in `body` never even calls on macOS. + /// `bottomAccessoryIsEnabled` is `false` — there is no accessory here, so + /// the capsule chrome (#1079) never draws either. + init( + selectedTab: Binding, + path: @escaping (AppTab) -> Binding<[Route]>, + routeFactory: any RouteFactory, + settingsRoute: Route, + @ViewBuilder tabRoot: @escaping (AppTab) -> TabRoot + ) { + self.init( + selectedTab: selectedTab, + path: path, + routeFactory: routeFactory, + settingsRoute: settingsRoute, + tabRoot: tabRoot, + bottomAccessoryIsEnabled: false, + bottomAccessory: { EmptyView() } + ) + } +} + // MARK: - HoistedOverlayHost /// Renders the hoisted overlay, and is the ONLY view that observes the diff --git a/Packages/GameShellKit/Tests/GameShellUITests/RootShellViewBottomAccessoryRenderTests.swift b/Packages/GameShellKit/Tests/GameShellUITests/RootShellViewBottomAccessoryRenderTests.swift new file mode 100644 index 00000000..d574c1d0 --- /dev/null +++ b/Packages/GameShellKit/Tests/GameShellUITests/RootShellViewBottomAccessoryRenderTests.swift @@ -0,0 +1,185 @@ +// RootShellViewBottomAccessoryRenderTests — #1024 PM ruling (2026-09-08): the +// #1029 B-6 spike already proved AdMob-in-`tabViewBottomAccessory` works as an +// API; that is not proof OUR wiring (`RootShellView`'s generic `bottomAccessory` +// param → `.tabViewBottomAccessory { bottomAccessory() }`, RootShellView.swift) +// actually threads a caller's content through. This suite renders the REAL +// shell with a sentinel accessory and asserts it lands in the live view +// hierarchy — no monetization types, GameShellKit stays zero-dep. +// +// iOS-only, like `GameAppKit.BannerAccessoryPinTests`: `tabViewBottomAccessory` +// doesn't exist on macOS (design.md §2.4.1), and a macOS `swift test` host has +// no `UIApplication` run loop to mount a `UIHostingController` in anyway. +// +// Proof strategy (why NOT an accessibility-identifier lookup): tried first, +// empirically dead end. `.accessibilityIdentifier(_:)` on plain content +// (`Text`) does not surface through the public `UIAccessibilityIdentification` +// protocol OR the `UIAccessibilityContainer` count/index methods on any view +// in this hierarchy when walked in-process in a headless XCTest host — both +// came back empty on every ancestor, confirmed via `-recursiveDescription` +// (SwiftUI's real accessibility tree is built by the OS Accessibility +// server, not by calling these NSObject methods directly). What +// `-recursiveDescription` DOES show reliably: SwiftUI mounts +// `tabViewBottomAccessory`'s content inside a private container view whose +// class name contains "BottomAccessory" (`UIKit._UITabAccessoryContainer` → +// `SwiftUI.UIKitTabBarBottomAccessory` → the accessory's own rendered +// content, e.g. `SwiftUI.CGDrawingView` for `Text`). This suite proves +// wiring by container PRESENCE + non-empty CONTENT, contrasted against an +// `EmptyView` accessory to rule out "the container always has stray +// children regardless of what we pass" as a false-positive explanation. + +#if os(iOS) + +import SwiftUI +import Testing +import UIKit +@testable import GameShellUI + +// MARK: - Sentinel route/factory (mirrors RootShellViewGenericityTests' shape) + +private enum AccessoryRenderSentinelRoute: Hashable { + case first + case settings +} + +private struct AccessoryRenderSentinelFactory: RouteFactory { + typealias Route = AccessoryRenderSentinelRoute + + @MainActor + func view(for route: AccessoryRenderSentinelRoute, path: Binding<[AccessoryRenderSentinelRoute]>?) -> AnyView { + AnyView(Text("destination")) + } +} + +@MainActor +private func makeHostedShell( + isEnabled: Bool = true, + @ViewBuilder bottomAccessory: @escaping () -> AnyView +) -> UIWindow { + let shell = RootShellView( + selectedTab: .constant(.today), + path: { _ in .constant([]) }, + routeFactory: AccessoryRenderSentinelFactory(), + settingsRoute: .settings, + tabRoot: { tab in Text(tab.rawValue) }, + bottomAccessoryIsEnabled: isEnabled, + bottomAccessory: bottomAccessory + ) + let controller = UIHostingController(rootView: shell) + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844)) + window.rootViewController = controller + window.makeKeyAndVisible() + window.layoutIfNeeded() + return window +} + +/// Recursively finds SwiftUI's `tabViewBottomAccessory` container — see the +/// file header for how this was derived and why it is the proof strategy. +/// `@MainActor`: `UIView` is main-thread-only; without this, the polling +/// loop below resumes off-main after `Task.sleep` and trips the Main Thread +/// Checker on `.subviews`. +@MainActor +private func findAccessoryContainer(in view: UIView) -> UIView? { + if String(describing: type(of: view)).contains("BottomAccessory") { + return view + } + for subview in view.subviews { + if let found = findAccessoryContainer(in: subview) { + return found + } + } + return nil +} + +/// Bounded settle — SwiftUI's own content inside the accessory (a `Text`'s +/// `CGDrawingView`, say) is not necessarily present on the very first +/// `layoutIfNeeded()`; poll briefly rather than assume a fixed number of +/// run-loop turns is enough. +@MainActor +private func waitForAccessoryContainer(in window: UIWindow) async -> UIView? { + var iterations = 0 + while iterations < 50 { + if let container = findAccessoryContainer(in: window) { + return container + } + try? await Task.sleep(for: .milliseconds(20)) + iterations += 1 + } + return nil +} + +// MARK: - Suite + +@MainActor +@Suite("RootShellView — tabViewBottomAccessory wiring actually renders (#1024)") +struct RootShellViewBottomAccessoryRenderTests { + + @Test("a non-EmptyView bottomAccessory is mounted with real content in the live hierarchy") + func sentinelAccessoryRendersInHierarchy() async { + let window = makeHostedShell { + AnyView( + Text("SENTINEL") + .frame(width: 111, height: 22) + .background(Color.red) + ) + } + + let container = await waitForAccessoryContainer(in: window) + + guard let container else { + Issue.record("no tabViewBottomAccessory container ever appeared — RootShellView's #if os(iOS) attach point is broken") + return + } + #expect( + !container.subviews.isEmpty, + "tabViewBottomAccessory container exists but is empty — bottomAccessory's content never made it inside" + ) + } + + @Test("an EmptyView bottomAccessory leaves the container empty (contrast case)") + func emptyAccessoryLeavesContainerEmpty() async { + let window = makeHostedShell { AnyView(EmptyView()) } + + // Same bounded settle as the sentinel case, but we EXPECT no + // meaningful content to ever show up — poll once for a container, + // then confirm whatever exists (container present or not at all) + // carries no rendered subview, ruling out "the container always has + // stray children" as an alternate explanation for the sentinel test + // passing above. + _ = await waitForAccessoryContainer(in: window) + let container = findAccessoryContainer(in: window) + + #expect( + container?.subviews.isEmpty ?? true, + "EmptyView bottomAccessory produced a non-empty container — the sentinel test's pass is not proof of real wiring" + ) + } + + @Test("isEnabled: false never draws the accessory capsule, even with real content (#1079)") + func disabledAccessoryDrawsNoCapsule() async { + let window = makeHostedShell(isEnabled: false) { + AnyView( + Text("SENTINEL") + .frame(width: 111, height: 22) + .background(Color.red) + ) + } + + // Same bounded settle as the sentinel case above, but we EXPECT the + // capsule itself to never appear — `isEnabled: false` must suppress + // the whole container, not just leave it empty (that would be the + // pre-#1079 empty-capsule regression this test guards against). + // Checking `subviews.isEmpty` alone is not enough: an empty capsule + // with zero height still passes that check, so also require the + // container (when present at all) to have zero height — the + // capsule reserving no space is the actual #1079 contract. + _ = await waitForAccessoryContainer(in: window) + let container = findAccessoryContainer(in: window) + + #expect( + container == nil || (container!.bounds.height == 0 && container!.subviews.isEmpty), + "isEnabled:false still drew a non-empty or non-zero-height tabViewBottomAccessory container — the empty-capsule regression is back" + ) + } +} + +#endif diff --git a/Packages/GameShellKit/Tests/GameShellUITests/RootShellViewGenericityTests.swift b/Packages/GameShellKit/Tests/GameShellUITests/RootShellViewGenericityTests.swift index 5606978e..6d605dfc 100644 --- a/Packages/GameShellKit/Tests/GameShellUITests/RootShellViewGenericityTests.swift +++ b/Packages/GameShellKit/Tests/GameShellUITests/RootShellViewGenericityTests.swift @@ -42,7 +42,7 @@ private struct SentinelFactory: RouteFactory { @Suite("GameShellUI — RootShellView stays generic") struct RootShellViewGenericityTests { @Test @MainActor func instantiatesWithNonSudokuRoute() { - let shell = RootShellView( + let shell = RootShellView( selectedTab: .constant(.today), path: { _ in .constant([.first]) }, routeFactory: SentinelFactory(), @@ -68,7 +68,7 @@ struct RootShellViewGenericityTests { ) } - let shell = RootShellView( + let shell = RootShellView( selectedTab: .constant(.practice), path: path, routeFactory: SentinelFactory(), @@ -105,7 +105,7 @@ struct RootShellViewGenericityTests { ) } - let shell = RootShellView( + let shell = RootShellView( selectedTab: .constant(.practice), path: path, routeFactory: SentinelFactory(), @@ -136,7 +136,7 @@ struct RootShellViewGenericityTests { ) } - let shell = RootShellView( + let shell = RootShellView( selectedTab: .constant(.practice), path: path, routeFactory: SentinelFactory(), @@ -166,7 +166,7 @@ struct RootShellViewGenericityTests { ) } - let shell = RootShellView( + let shell = RootShellView( selectedTab: .constant(.today), path: path, routeFactory: SentinelFactory(), diff --git a/Packages/GameTestSupportKit/Package.swift b/Packages/GameTestSupportKit/Package.swift index 3533e2f2..e607a165 100644 --- a/Packages/GameTestSupportKit/Package.swift +++ b/Packages/GameTestSupportKit/Package.swift @@ -45,7 +45,7 @@ let testTargets: [Target] = [ let package = Package( name: "GameTestSupportKit", platforms: [ - .iOS(.v26), + .iOS("26.1"), .macOS(.v26), ], products: [ diff --git a/Packages/MinesweeperCoreKit/Package.swift b/Packages/MinesweeperCoreKit/Package.swift index eb826437..f31469e6 100644 --- a/Packages/MinesweeperCoreKit/Package.swift +++ b/Packages/MinesweeperCoreKit/Package.swift @@ -77,7 +77,7 @@ let testTargets: [Target] = [ let package = Package( name: "MinesweeperCoreKit", platforms: [ - .iOS(.v26), + .iOS("26.1"), .macOS(.v26), ], products: [ diff --git a/Packages/MinesweeperKit/Package.swift b/Packages/MinesweeperKit/Package.swift index a8b83bfc..243f3d56 100644 --- a/Packages/MinesweeperKit/Package.swift +++ b/Packages/MinesweeperKit/Package.swift @@ -269,7 +269,7 @@ let testTargets: [Target] = [ let package = Package( name: "MinesweeperKit", platforms: [ - .iOS(.v26), + .iOS("26.1"), .macOS(.v26), ], products: [ diff --git a/Packages/MinesweeperKit/Sources/MinesweeperAppComposition/Live+TabRoots.swift b/Packages/MinesweeperKit/Sources/MinesweeperAppComposition/Live+TabRoots.swift index 36b3d1d0..93bdfe3a 100644 --- a/Packages/MinesweeperKit/Sources/MinesweeperAppComposition/Live+TabRoots.swift +++ b/Packages/MinesweeperKit/Sources/MinesweeperAppComposition/Live+TabRoots.swift @@ -47,10 +47,14 @@ extension MinesweeperAppComposition { // cards via `MinesweeperSavedGameStore.fetchCompletedDailyIds` / // `fetchFailedDailyIds` (Epic 8 / SDD-003; #816 moved completed // off the `puzzleId`-assuming `PersistenceProtocol` query). - // #1020 CR: no `banner:` here — `TodayTabHost` (GameAppKit) already - // wraps this content with its OWN `BannerSlotView` (and is the C-33 - // ATT anchor); passing another one too would render two banners. - // `MinesweeperDailyHubView`'s `banner:` param defaults to `EmptyView()`. + // #1024: no `banner:` here — the shared `tabViewBottomAccessory` + // (design.md §2.4) covers this tab; `TodayTabHost` (GameAppKit) + // has carried no `BannerSlotView` of its own since that move. The + // accessory slot's registration is now the "first ad context" + // that fires the C-33 ATT primer, not a Today-tab-local slot. + // `MinesweeperDailyHubView`'s `banner:` param is dead (defaults + // to `EmptyView()`, no caller overrides it) — tracked in + // #1096. return AnyView( MinesweeperDailyHubView( viewModel: MinesweeperDailyHubViewModel( @@ -74,14 +78,16 @@ extension MinesweeperAppComposition { key: "com.wei18.minesweeper.practice.lastDifficulty", fallback: Difficulty.beginner.rawValue ) + // #1024: no `banner:` here any more — the shared + // `tabViewBottomAccessory` (design.md §2.4) covers this tab. + // #1080: `MinesweeperPracticeHubView`'s `banner:` param and the + // tab-content-bottom fallback it fed were both removed — + // obsolete once #1079 confirmed the accessory path. return AnyView( MinesweeperPracticeHubView( path: rootViewModel.pathBinding(for: .practice), initialDifficulty: Difficulty(rawValue: difficultyStore.load()) ?? .beginner, - onDifficultyChanged: { difficultyStore.save($0.rawValue) }, - banner: { - LiveRouteFactory.bannerSlot() - } + onDifficultyChanged: { difficultyStore.save($0.rawValue) } ) ) case .progress: diff --git a/Packages/MinesweeperKit/Sources/MinesweeperAppComposition/LiveRouteFactory+Helpers.swift b/Packages/MinesweeperKit/Sources/MinesweeperAppComposition/LiveRouteFactory+Helpers.swift index 7996ab01..3a35ec42 100644 --- a/Packages/MinesweeperKit/Sources/MinesweeperAppComposition/LiveRouteFactory+Helpers.swift +++ b/Packages/MinesweeperKit/Sources/MinesweeperAppComposition/LiveRouteFactory+Helpers.swift @@ -3,45 +3,14 @@ // ceiling; extraction per the repo convention instead of a file-wide // swiftlint disable). -internal import SwiftUI -// #814: `bannerSlot()` moved here from LiveRouteFactory.swift (400-line -// ceiling). -internal import MonetizationUI -internal import MinesweeperUI -internal import GameShellUI internal import SettingsUI internal import Foundation +#if canImport(UIKit) +internal import UIKit +#endif extension LiveRouteFactory { - // MARK: - Banner helper - - /// Epic 5: banner slot for non-Board screens (Today/Practice tab roots + - /// Settings). The session model in the environment decides whether the - /// slot shows (#1058). - /// - /// #1020: `static` (not an instance method) so `Live+TabRoots.swift`'s - /// Today/Practice tab-root builder — which has no `LiveRouteFactory` - /// instance to call through, only the wired `GameDeps` bag — can reuse - /// the exact same banner instead of re-deriving it. - @MainActor - // #851: was relying on `BannerSlotView`'s bare default (`.clear`) — the - // #468 Epic 5 theming note above already flagged this as unfinished - // ("if MS adopts per-theme accents, pass theme tokens here like Sudoku's - // RouteFactory.themedBanner()"). Now does exactly that, mirroring - // `MinesweeperBoardView.themedBanner`'s `theme.surface.background.resolved` - // so the Today/Practice/Settings banner slot matches the themed Board - // banner instead of depending on an un-themed transparent default. - static func bannerSlot() -> some View { - BannerSlotView( - isSuppressed: false, - backgroundColor: MinesweeperTheme().surface.background.resolved, - // Padding lives inside `BannerSlotView` so a hidden slot collapses - // to zero height. - horizontalPadding: 16, - verticalPadding: 12 - ) - } /// acknowledgements row deep-links to the app's iOS Settings page where /// LicensePlist's `Settings.bundle` surfaces (omitted on macOS, no /// deep-link); copyright derived locally; privacy/support URLs unwired diff --git a/Packages/MinesweeperKit/Sources/MinesweeperAppComposition/LiveRouteFactory.swift b/Packages/MinesweeperKit/Sources/MinesweeperAppComposition/LiveRouteFactory.swift index afd1dd03..9bd3a550 100644 --- a/Packages/MinesweeperKit/Sources/MinesweeperAppComposition/LiveRouteFactory.swift +++ b/Packages/MinesweeperKit/Sources/MinesweeperAppComposition/LiveRouteFactory.swift @@ -320,17 +320,16 @@ public struct LiveRouteFactory: RouteFactory { presentGameCenter: presentGameCenter, appStoreID: appStoreID, presentInviteFriends: presentInviteFriends, - telemetryEmit: { event in Task { await telemetry?.observe(event) } }, - banner: { Self.bannerSlot() } + telemetryEmit: { event in Task { await telemetry?.observe(event) } } + // #1024: no `banner:` here any more — Settings is pushed + // onto a tab's stack, so it stays inside the TabView and + // the shared `tabViewBottomAccessory` (design.md §2.4) + // already covers it. #1080: `SettingsView`'s `banner:` + // param and the `Self.bannerSlot` tab-content-bottom + // fallback it fed were both removed — obsolete once + // #1079 confirmed the accessory path. ) ) } } - - // `bannerSlot()` moved to LiveRouteFactory+Helpers.swift - // (#814 — this file sat at the 400-line ceiling; extraction per the repo - // convention). #1020: `static` so `Live+TabRoots.swift`'s Today/Practice - // tab-root builder — which has no `LiveRouteFactory` instance to call - // through, only the wired `GameDeps` bag — can reuse the exact same - // banner instead of re-deriving it. } diff --git a/Packages/MinesweeperKit/Sources/MinesweeperUI/Practice/MinesweeperPracticeHubView.swift b/Packages/MinesweeperKit/Sources/MinesweeperUI/Practice/MinesweeperPracticeHubView.swift index 467b04ee..deb3c79e 100644 --- a/Packages/MinesweeperKit/Sources/MinesweeperUI/Practice/MinesweeperPracticeHubView.swift +++ b/Packages/MinesweeperKit/Sources/MinesweeperUI/Practice/MinesweeperPracticeHubView.swift @@ -18,7 +18,7 @@ public import SwiftUI internal import GameShellUI public import MinesweeperEngine -public struct MinesweeperPracticeHubView: View { +public struct MinesweeperPracticeHubView: View { @Binding private var path: [AppRoute] @State private var difficulty: Difficulty // #765: threads MS's theme through the hub, mirroring Sudoku's @@ -27,7 +27,6 @@ public struct MinesweeperPracticeHubView: View { // #1021 Phase C: gap between the 3 difficulty cards — content tier, // scales with Dynamic Type. @ScaledSpacing(.small) private var cardGap - private let banner: Banner // #720 G2: fires when the player picks a new difficulty segment so the // composition root can persist it (mirrors Sudoku's // `PracticeHubViewModel.persistDifficulty`). `nil` (previews / most unit @@ -37,13 +36,11 @@ public struct MinesweeperPracticeHubView: View { public init( path: Binding<[AppRoute]>, initialDifficulty: Difficulty = .beginner, - onDifficultyChanged: ((Difficulty) -> Void)? = nil, - @ViewBuilder banner: () -> Banner = { EmptyView() } + onDifficultyChanged: ((Difficulty) -> Void)? = nil ) { self._path = path self._difficulty = State(initialValue: initialDifficulty) self.onDifficultyChanged = onDifficultyChanged - self.banner = banner() } public var body: some View { @@ -53,8 +50,7 @@ public struct MinesweeperPracticeHubView: View { filterHeader: "Difficulty", headerForeground: theme.text.primary.resolved, filter: { difficultyCards }, - cta: { startCard }, - banner: { banner } + cta: { startCard } ) } diff --git a/Packages/MinesweeperKit/Sources/MinesweeperUI/SettingsView.swift b/Packages/MinesweeperKit/Sources/MinesweeperUI/SettingsView.swift index 62d2e847..7e23ebc4 100644 --- a/Packages/MinesweeperKit/Sources/MinesweeperUI/SettingsView.swift +++ b/Packages/MinesweeperKit/Sources/MinesweeperUI/SettingsView.swift @@ -8,7 +8,6 @@ // `MinesweeperUI.SettingsView(...)` / `SettingsViewModel(...)` call sites // (production + tests) unqualified and source-compatible. public import GameAppKit -public import SwiftUI -public typealias SettingsView = GameAppKit.SettingsView +public typealias SettingsView = GameAppKit.SettingsView public typealias SettingsViewModel = GameAppKit.SettingsViewModel diff --git a/Packages/MinesweeperKit/Tests/MinesweeperUITests/ASCScreenshotEmitTests.swift b/Packages/MinesweeperKit/Tests/MinesweeperUITests/ASCScreenshotEmitTests.swift index 1c731099..e54f814d 100644 --- a/Packages/MinesweeperKit/Tests/MinesweeperUITests/ASCScreenshotEmitTests.swift +++ b/Packages/MinesweeperKit/Tests/MinesweeperUITests/ASCScreenshotEmitTests.swift @@ -92,8 +92,11 @@ struct ASCScreenshotEmitTests { // #1020: HOME is gone — the marketing "01-home" slot (name kept; ASC // ordering depends on it) now renders the Today tab's real content: - // `TodayTabHost` (resume pill + banner + ATT anchor) wrapping the Daily - // hub, exactly what `Live+TabRoots.swift` wires for the `.today` tab. + // `TodayTabHost` (resume pill) wrapping the Daily hub, exactly what + // `Live+TabRoots.swift` wires for the `.today` tab. #1024: the banner / + // ATT anchor `TodayTabHost` used to carry moved to the shared + // `tabViewBottomAccessory` — this ASC fixture has no ad seams to stub any + // more. private func todayTabView() -> some View { let rootVM = MinesweeperRootViewModel( gameCenter: FakeGameCenterClient(), diff --git a/Packages/MinesweeperKit/Tests/MinesweeperUITests/MinesweeperTodayTabViewTests.swift b/Packages/MinesweeperKit/Tests/MinesweeperUITests/MinesweeperTodayTabViewTests.swift index 6be4f844..5dc23b72 100644 --- a/Packages/MinesweeperKit/Tests/MinesweeperUITests/MinesweeperTodayTabViewTests.swift +++ b/Packages/MinesweeperKit/Tests/MinesweeperUITests/MinesweeperTodayTabViewTests.swift @@ -1,13 +1,19 @@ // MinesweeperTodayTabViewTests — snapshot baselines for the Today tab's real -// root content: `GameAppKit.TodayTabHost` (resume pill + banner + the C-33 -// ATT anchor) wrapping `MinesweeperDailyHubView`, exactly what -// `Live+TabRoots.swift` wires for `AppTab.today`. +// root content: `GameAppKit.TodayTabHost` (resume pill) wrapping +// `MinesweeperDailyHubView`, exactly what `Live+TabRoots.swift` wires for +// `AppTab.today`. // // #1020: replaces the retired `MinesweeperHomeSnapshotTests` (HOME is gone; // the marketing "01-home" slot now sources from this suite via // `ASCScreenshotEmitTests.todayTabView()`) — same iPhone/iPad/Mac + dark + // AX5 baseline matrix as SudokuKit's `TodayTabViewTests`, scoped down to the // surface that actually still exists. +// +// #1024: `TodayTabHost` no longer owns a banner slot at all — it moved to +// the shared `tabViewBottomAccessory` (design.md §2.4, `BannerAccessoryView` +// in GameAppKit). The retired banner-region coverage below (`bannerVisible`) +// is now `BannerAccessoryPinTests` + the pre-existing `BannerSlotView` +// coverage — this suite no longer has any banner state to seed or assert on. #if canImport(AppKit) import Foundation @@ -19,9 +25,6 @@ import Testing import GameAppKit import GameCenterTesting import MinesweeperEngine -import MonetizationCore -import MonetizationTesting -import MonetizationUI import PersistenceTesting @MainActor @@ -50,12 +53,10 @@ struct MinesweeperTodayTabViewTests { ] /// `TodayTabHost` wrapping a seeded Daily hub — the same composition - /// `MinesweeperAppComposition.makeTabRoot(.today, …)` builds - /// (`MinesweeperDailyHubView` itself takes no `banner:` here — - /// `TodayTabHost` is the ONE banner slot for the whole tab; see the CR - /// fix note on `Live+TabRoots.swift`). Injects `BannerSessionModel.disabled`, - /// so every baseline below has the banner region collapsed — - /// `bannerVisible` below is the one fixture that shows the banner region. + /// `MinesweeperAppComposition.makeTabRoot(.today, …)` builds. #1024: + /// `TodayTabHost` carries no banner slot / ad seams at all any more + /// (moved to the shared `tabViewBottomAccessory`), so there is nothing + /// left to seed here. private func todayTabHost() -> some View { let rootVM = MinesweeperRootViewModel( gameCenter: FakeGameCenterClient(), @@ -67,47 +68,6 @@ struct MinesweeperTodayTabViewTests { return TodayTabHost(rootViewModel: rootVM) { MinesweeperDailyHubView(viewModel: dailyViewModel) } - .environment(\.bannerSession, .disabled) - } - - /// Deterministic stand-in for the live `ProgressView` spinner (#732, - /// mirrors `BoardViewBannerTests`/SudokuKit's `TodayTabViewTests`) — same - /// static ring look, no animation-frame dependency. - private var deterministicBannerLoadingPreview: AnyView { - AnyView( - Circle() - .strokeBorder(Color.accentColor, lineWidth: 2) - .frame(width: 16, height: 16) - ) - } - - /// Same composition as `todayTabHost()` but with a started session over an - /// OPEN gate (`hasPurchasedRemoveAds: false`, 30 days post-launch) and a - /// readiness-held fake provider, so the banner's 50pt rect reserves space - /// on the very first layout and stays in its loading state. - private func todayTabHostWithVisibleBanner() async -> some View { - let rootVM = MinesweeperRootViewModel( - gameCenter: FakeGameCenterClient(), - persistence: FakePersistence() - ) - let dailyViewModel = MinesweeperDailyHubViewModel(path: .constant([])) - dailyViewModel.setStateForTesting(.loaded(Self.loadedTrio)) - dailyViewModel.setPhase2PendingForTesting(false) - let session = BannerSessionModel( - adProvider: FakeAdProvider(readinessHeld: true), - adGate: AdGate(store: FakeAdGateStateStore( - initial: AdGateState( - firstLaunchAt: Date().addingTimeInterval(-30 * 86_400), - hasPurchasedRemoveAds: false - ) - )) - ) - await session.start() - return TodayTabHost(rootViewModel: rootVM) { - MinesweeperDailyHubView(viewModel: dailyViewModel) - } - .environment(\.bannerSlotLoadingPreview, deterministicBannerLoadingPreview) - .environment(\.bannerSession, session) } @Test(.enabled(if: !SnapshotEnv.isXcodeCloud)) func snapshotIPhoneLight() { @@ -176,26 +136,5 @@ struct MinesweeperTodayTabViewTests { ) } - // MARK: - Banner region coverage (CR follow-up) - // - // Every baseline above seeds the gate CLOSED, so none of them exercises - // `TodayTabHost`'s own banner slot — this is the marketing "01-home" - // source, so a banner regression there would ship unnoticed. This one - // fixture opens the gate (mirrors `BoardViewBannerTests`'s convention). - - @Test(.enabled(if: !SnapshotEnv.isXcodeCloud)) func snapshotIPhoneLightBannerVisible() async { - let host = hostingView( - await todayTabHostWithVisibleBanner(), - size: SnapshotLayouts.iPhone, - colorScheme: .light, - sizeClass: .compact - ) - assertUISnapshot( - of: host, as: .image, named: "TodayTabView-iPhone-light-bannerVisible", record: SnapshotMode.recordMode - ) - assertViewStructure( - of: host, named: "TodayTabView-iPhone-light-bannerVisible", record: SnapshotMode.recordMode - ) - } } #endif diff --git a/Packages/MinesweeperKit/Tests/MinesweeperUITests/__Snapshots__/MinesweeperTodayTabViewTests/snapshotAccessibility5IPhoneLight.TodayTabView-iPhone-light-accessibility5.txt b/Packages/MinesweeperKit/Tests/MinesweeperUITests/__Snapshots__/MinesweeperTodayTabViewTests/snapshotAccessibility5IPhoneLight.TodayTabView-iPhone-light-accessibility5.txt index f8a9a099..a04e1950 100644 --- a/Packages/MinesweeperKit/Tests/MinesweeperUITests/__Snapshots__/MinesweeperTodayTabViewTests/snapshotAccessibility5IPhoneLight.TodayTabView-iPhone-light-accessibility5.txt +++ b/Packages/MinesweeperKit/Tests/MinesweeperUITests/__Snapshots__/MinesweeperTodayTabViewTests/snapshotAccessibility5IPhoneLight.TodayTabView-iPhone-light-accessibility5.txt @@ -1,4 +1,4 @@ -[ AF ! WlU ] h=--- v=--- _TtGC7SwiftUI13NSHostingViewGVS_15ModifiedContentGS1_GS1_GS1_GS1_GS1_GS1_GS1_GV10GameAppKit12TodayTabHostO13MinesweeperUI8AppRouteGVS4_23MinesweeperDailyHubViewVS_9EmptyView__GVS_30_EnvironmentKeyWritingModifierGSqC14MonetizationUI18BannerSessionModel___GS8_P11GameShellUI5Theme___GS8_VS4_21MinesweeperCellTokens__GS8_GSqOS_22UserInterfaceSizeClass___GS8_V10Foundation6Locale__GS8_OS_15DynamicTypeSize__GVS_26_PreferenceWritingModifierVS_23PreferredColorSchemeKey__VS_12_FrameLayout__ f=(0,0,393,852) b=(-) => +[ AF ! WlU ] h=--- v=--- _TtGC7SwiftUI13NSHostingViewGVS_15ModifiedContentGS1_GS1_GS1_GS1_GS1_GS1_GV10GameAppKit12TodayTabHostO13MinesweeperUI8AppRouteGVS4_23MinesweeperDailyHubViewVS_9EmptyView__GVS_30_EnvironmentKeyWritingModifierP11GameShellUI5Theme___GS8_VS4_21MinesweeperCellTokens__GS8_GSqOS_22UserInterfaceSizeClass___GS8_V10Foundation6Locale__GS8_OS_15DynamicTypeSize__GVS_26_PreferenceWritingModifierVS_23PreferredColorSchemeKey__VS_12_FrameLayout__ f=(0,0,393,852) b=(-) => [ A ! WlU ] h=--- v=--- _TtCC7SwiftUI17HostingScrollView17PlatformContainer f=(0,0,393,852) b=(-) => [ AF ! wLU ] h=--- v=--- SwiftUI.HostingScrollView f=(0,0,393,852) b=(-) => [ AF ! wLU ] h=--- v=--- NSClipView f=(0,0,393,852) b=(-) => diff --git a/Packages/MinesweeperKit/Tests/MinesweeperUITests/__Snapshots__/MinesweeperTodayTabViewTests/snapshotIPadLight.TodayTabView-iPad-light.txt b/Packages/MinesweeperKit/Tests/MinesweeperUITests/__Snapshots__/MinesweeperTodayTabViewTests/snapshotIPadLight.TodayTabView-iPad-light.txt index 5c39222a..a7f49a27 100644 --- a/Packages/MinesweeperKit/Tests/MinesweeperUITests/__Snapshots__/MinesweeperTodayTabViewTests/snapshotIPadLight.TodayTabView-iPad-light.txt +++ b/Packages/MinesweeperKit/Tests/MinesweeperUITests/__Snapshots__/MinesweeperTodayTabViewTests/snapshotIPadLight.TodayTabView-iPad-light.txt @@ -1,4 +1,4 @@ -[ AF ! WlU ] h=--- v=--- _TtGC7SwiftUI13NSHostingViewGVS_15ModifiedContentGS1_GS1_GS1_GS1_GS1_GS1_GV10GameAppKit12TodayTabHostO13MinesweeperUI8AppRouteGVS4_23MinesweeperDailyHubViewVS_9EmptyView__GVS_30_EnvironmentKeyWritingModifierGSqC14MonetizationUI18BannerSessionModel___GS8_P11GameShellUI5Theme___GS8_VS4_21MinesweeperCellTokens__GS8_GSqOS_22UserInterfaceSizeClass___GS8_V10Foundation6Locale__GVS_26_PreferenceWritingModifierVS_23PreferredColorSchemeKey__VS_12_FrameLayout__ f=(0,0,1032,1376) b=(-) => +[ AF ! WlU ] h=--- v=--- _TtGC7SwiftUI13NSHostingViewGVS_15ModifiedContentGS1_GS1_GS1_GS1_GS1_GV10GameAppKit12TodayTabHostO13MinesweeperUI8AppRouteGVS4_23MinesweeperDailyHubViewVS_9EmptyView__GVS_30_EnvironmentKeyWritingModifierP11GameShellUI5Theme___GS8_VS4_21MinesweeperCellTokens__GS8_GSqOS_22UserInterfaceSizeClass___GS8_V10Foundation6Locale__GVS_26_PreferenceWritingModifierVS_23PreferredColorSchemeKey__VS_12_FrameLayout__ f=(0,0,1032,1376) b=(-) => [ A ! WlU ] h=--- v=--- _TtCC7SwiftUI17HostingScrollView17PlatformContainer f=(0,0,1032,1376) b=(-) => [ AF ! wLU ] h=--- v=--- SwiftUI.HostingScrollView f=(0,0,1032,1376) b=(-) => [ AF ! wLU ] h=--- v=--- NSClipView f=(0,0,1032,1376) b=(-) => diff --git a/Packages/MinesweeperKit/Tests/MinesweeperUITests/__Snapshots__/MinesweeperTodayTabViewTests/snapshotIPhoneLight.TodayTabView-iPhone-light.txt b/Packages/MinesweeperKit/Tests/MinesweeperUITests/__Snapshots__/MinesweeperTodayTabViewTests/snapshotIPhoneLight.TodayTabView-iPhone-light.txt index 002566da..c3fa558a 100644 --- a/Packages/MinesweeperKit/Tests/MinesweeperUITests/__Snapshots__/MinesweeperTodayTabViewTests/snapshotIPhoneLight.TodayTabView-iPhone-light.txt +++ b/Packages/MinesweeperKit/Tests/MinesweeperUITests/__Snapshots__/MinesweeperTodayTabViewTests/snapshotIPhoneLight.TodayTabView-iPhone-light.txt @@ -1,4 +1,4 @@ -[ AF ! WlU ] h=--- v=--- _TtGC7SwiftUI13NSHostingViewGVS_15ModifiedContentGS1_GS1_GS1_GS1_GS1_GS1_GV10GameAppKit12TodayTabHostO13MinesweeperUI8AppRouteGVS4_23MinesweeperDailyHubViewVS_9EmptyView__GVS_30_EnvironmentKeyWritingModifierGSqC14MonetizationUI18BannerSessionModel___GS8_P11GameShellUI5Theme___GS8_VS4_21MinesweeperCellTokens__GS8_GSqOS_22UserInterfaceSizeClass___GS8_V10Foundation6Locale__GVS_26_PreferenceWritingModifierVS_23PreferredColorSchemeKey__VS_12_FrameLayout__ f=(0,0,393,852) b=(-) => +[ AF ! WlU ] h=--- v=--- _TtGC7SwiftUI13NSHostingViewGVS_15ModifiedContentGS1_GS1_GS1_GS1_GS1_GV10GameAppKit12TodayTabHostO13MinesweeperUI8AppRouteGVS4_23MinesweeperDailyHubViewVS_9EmptyView__GVS_30_EnvironmentKeyWritingModifierP11GameShellUI5Theme___GS8_VS4_21MinesweeperCellTokens__GS8_GSqOS_22UserInterfaceSizeClass___GS8_V10Foundation6Locale__GVS_26_PreferenceWritingModifierVS_23PreferredColorSchemeKey__VS_12_FrameLayout__ f=(0,0,393,852) b=(-) => [ A ! WlU ] h=--- v=--- _TtCC7SwiftUI17HostingScrollView17PlatformContainer f=(0,0,393,852) b=(-) => [ AF ! wLU ] h=--- v=--- SwiftUI.HostingScrollView f=(0,0,393,852) b=(-) => [ AF ! wLU ] h=--- v=--- NSClipView f=(0,0,393,852) b=(-) => diff --git a/Packages/MinesweeperKit/Tests/MinesweeperUITests/__Snapshots__/MinesweeperTodayTabViewTests/snapshotIPhoneLightBannerVisible.TodayTabView-iPhone-light-bannerVisible.png b/Packages/MinesweeperKit/Tests/MinesweeperUITests/__Snapshots__/MinesweeperTodayTabViewTests/snapshotIPhoneLightBannerVisible.TodayTabView-iPhone-light-bannerVisible.png deleted file mode 100644 index ddc9c9ab..00000000 Binary files a/Packages/MinesweeperKit/Tests/MinesweeperUITests/__Snapshots__/MinesweeperTodayTabViewTests/snapshotIPhoneLightBannerVisible.TodayTabView-iPhone-light-bannerVisible.png and /dev/null differ diff --git a/Packages/MinesweeperKit/Tests/MinesweeperUITests/__Snapshots__/MinesweeperTodayTabViewTests/snapshotIPhoneLightBannerVisible.TodayTabView-iPhone-light-bannerVisible.txt b/Packages/MinesweeperKit/Tests/MinesweeperUITests/__Snapshots__/MinesweeperTodayTabViewTests/snapshotIPhoneLightBannerVisible.TodayTabView-iPhone-light-bannerVisible.txt deleted file mode 100644 index 0516f689..00000000 --- a/Packages/MinesweeperKit/Tests/MinesweeperUITests/__Snapshots__/MinesweeperTodayTabViewTests/snapshotIPhoneLightBannerVisible.TodayTabView-iPhone-light-bannerVisible.txt +++ /dev/null @@ -1,12 +0,0 @@ -[ AF ! WlU ] h=--- v=--- _TtGC7SwiftUI13NSHostingViewGVS_15ModifiedContentGS1_GS1_GS1_GS1_GS1_GS1_GS1_GV10GameAppKit12TodayTabHostO13MinesweeperUI8AppRouteGVS4_23MinesweeperDailyHubViewVS_9EmptyView__GVS_30_EnvironmentKeyWritingModifierGSqVS_7AnyView___GS8_GSqC14MonetizationUI18BannerSessionModel___GS8_P11GameShellUI5Theme___GS8_VS4_21MinesweeperCellTokens__GS8_GSqOS_22UserInterfaceSizeClass___GS8_V10Foundation6Locale__GVS_26_PreferenceWritingModifierVS_23PreferredColorSchemeKey__VS_12_FrameLayout__ f=(0,0,393,852) b=(-) => - [ A ! WlU ] h=--- v=--- _TtCC7SwiftUI17HostingScrollView17PlatformContainer f=(0,0,393,778) b=(-) => - [ AF ! wLU ] h=--- v=--- SwiftUI.HostingScrollView f=(0,0,393,778) b=(-) => - [ AF ! wLU ] h=--- v=--- NSClipView f=(0,0,393,778) b=(-) => - [ AF ! wLU ] h=--- v=--- _TtC7SwiftUIP33_6B9589E2B2301F373BAB5CF17408C38412DocumentView f=(0,0,0,0) b=(-) => - [ AF ! W U ] h=--- v=--- _TtCC7SwiftUI17HostingScrollView22PlatformGroupContainer f=(0,0,0,0) b=(-) => - [ AF ! W U ] h=--- v=--- _TtC7SwiftUIP33_B9E8471D77A01696E0731B43A95A934614_FocusRingView f=(16,69,361,96) b=(-) => - [ AF ! W U ] h=--- v=--- _TtC7SwiftUIP33_B9E8471D77A01696E0731B43A95A934614_FocusRingView f=(16,177,361,96) b=(-) => - [ AF ! W U ] h=--- v=--- _TtC7SwiftUIP33_B9E8471D77A01696E0731B43A95A934614_FocusRingView f=(16,285,361,96) b=(-) => - [ HAF ! W U ] h=--- v=--- NSScroller f=(0,0,0,100) b=(-) => - [ AF ! W U ] h=--- v=--- _TtC7SwiftUIP33_B9E8471D77A01696E0731B43A95A934614_FocusRingView f=(336,793,44,44) b=(-) => -A=autoresizesSubviews, C=canDrawConcurrently, D=needsDisplay, F=flipped, G=gstate, H=hidden (h=by ancestor), L=needsLayout (l=child needsLayout), U=needsUpdateConstraints (u=child needsUpdateConstraints), O=opaque, P=preservesContentDuringLiveResize, S=scaled/rotated, W=wantsLayer (w=ancestor wantsLayer), V=needsVibrancy (v=allowsVibrancy), #=has surface diff --git a/Packages/MinesweeperKit/Tests/MinesweeperUITests/__Snapshots__/MinesweeperTodayTabViewTests/snapshotMacDark.TodayTabView-Mac-dark.txt b/Packages/MinesweeperKit/Tests/MinesweeperUITests/__Snapshots__/MinesweeperTodayTabViewTests/snapshotMacDark.TodayTabView-Mac-dark.txt index c6a0e8a4..84e55939 100644 --- a/Packages/MinesweeperKit/Tests/MinesweeperUITests/__Snapshots__/MinesweeperTodayTabViewTests/snapshotMacDark.TodayTabView-Mac-dark.txt +++ b/Packages/MinesweeperKit/Tests/MinesweeperUITests/__Snapshots__/MinesweeperTodayTabViewTests/snapshotMacDark.TodayTabView-Mac-dark.txt @@ -1,4 +1,4 @@ -[ AF ! WlU ] h=--- v=--- _TtGC7SwiftUI13NSHostingViewGVS_15ModifiedContentGS1_GS1_GS1_GS1_GS1_GS1_GV10GameAppKit12TodayTabHostO13MinesweeperUI8AppRouteGVS4_23MinesweeperDailyHubViewVS_9EmptyView__GVS_30_EnvironmentKeyWritingModifierGSqC14MonetizationUI18BannerSessionModel___GS8_P11GameShellUI5Theme___GS8_VS4_21MinesweeperCellTokens__GS8_GSqOS_22UserInterfaceSizeClass___GS8_V10Foundation6Locale__GVS_26_PreferenceWritingModifierVS_23PreferredColorSchemeKey__VS_12_FrameLayout__ f=(0,0,900,600) b=(-) => +[ AF ! WlU ] h=--- v=--- _TtGC7SwiftUI13NSHostingViewGVS_15ModifiedContentGS1_GS1_GS1_GS1_GS1_GV10GameAppKit12TodayTabHostO13MinesweeperUI8AppRouteGVS4_23MinesweeperDailyHubViewVS_9EmptyView__GVS_30_EnvironmentKeyWritingModifierP11GameShellUI5Theme___GS8_VS4_21MinesweeperCellTokens__GS8_GSqOS_22UserInterfaceSizeClass___GS8_V10Foundation6Locale__GVS_26_PreferenceWritingModifierVS_23PreferredColorSchemeKey__VS_12_FrameLayout__ f=(0,0,900,600) b=(-) => [ A ! WlU ] h=--- v=--- _TtCC7SwiftUI17HostingScrollView17PlatformContainer f=(0,0,900,600) b=(-) => [ AF ! wLU ] h=--- v=--- SwiftUI.HostingScrollView f=(0,0,900,600) b=(-) => [ AF ! wLU ] h=--- v=--- NSClipView f=(0,0,900,600) b=(-) => diff --git a/Packages/MinesweeperKit/Tests/MinesweeperUITests/__Snapshots__/MinesweeperTodayTabViewTests/snapshotMacLight.TodayTabView-Mac-light.txt b/Packages/MinesweeperKit/Tests/MinesweeperUITests/__Snapshots__/MinesweeperTodayTabViewTests/snapshotMacLight.TodayTabView-Mac-light.txt index c6a0e8a4..84e55939 100644 --- a/Packages/MinesweeperKit/Tests/MinesweeperUITests/__Snapshots__/MinesweeperTodayTabViewTests/snapshotMacLight.TodayTabView-Mac-light.txt +++ b/Packages/MinesweeperKit/Tests/MinesweeperUITests/__Snapshots__/MinesweeperTodayTabViewTests/snapshotMacLight.TodayTabView-Mac-light.txt @@ -1,4 +1,4 @@ -[ AF ! WlU ] h=--- v=--- _TtGC7SwiftUI13NSHostingViewGVS_15ModifiedContentGS1_GS1_GS1_GS1_GS1_GS1_GV10GameAppKit12TodayTabHostO13MinesweeperUI8AppRouteGVS4_23MinesweeperDailyHubViewVS_9EmptyView__GVS_30_EnvironmentKeyWritingModifierGSqC14MonetizationUI18BannerSessionModel___GS8_P11GameShellUI5Theme___GS8_VS4_21MinesweeperCellTokens__GS8_GSqOS_22UserInterfaceSizeClass___GS8_V10Foundation6Locale__GVS_26_PreferenceWritingModifierVS_23PreferredColorSchemeKey__VS_12_FrameLayout__ f=(0,0,900,600) b=(-) => +[ AF ! WlU ] h=--- v=--- _TtGC7SwiftUI13NSHostingViewGVS_15ModifiedContentGS1_GS1_GS1_GS1_GS1_GV10GameAppKit12TodayTabHostO13MinesweeperUI8AppRouteGVS4_23MinesweeperDailyHubViewVS_9EmptyView__GVS_30_EnvironmentKeyWritingModifierP11GameShellUI5Theme___GS8_VS4_21MinesweeperCellTokens__GS8_GSqOS_22UserInterfaceSizeClass___GS8_V10Foundation6Locale__GVS_26_PreferenceWritingModifierVS_23PreferredColorSchemeKey__VS_12_FrameLayout__ f=(0,0,900,600) b=(-) => [ A ! WlU ] h=--- v=--- _TtCC7SwiftUI17HostingScrollView17PlatformContainer f=(0,0,900,600) b=(-) => [ AF ! wLU ] h=--- v=--- SwiftUI.HostingScrollView f=(0,0,900,600) b=(-) => [ AF ! wLU ] h=--- v=--- NSClipView f=(0,0,900,600) b=(-) => diff --git a/Packages/PersistenceKit/Package.swift b/Packages/PersistenceKit/Package.swift index 27a680a2..8b40de77 100644 --- a/Packages/PersistenceKit/Package.swift +++ b/Packages/PersistenceKit/Package.swift @@ -29,7 +29,7 @@ let swiftSettings: [SwiftSetting] = [ let package = Package( name: "PersistenceKit", platforms: [ - .iOS(.v26), + .iOS("26.1"), .macOS(.v26), ], products: [ diff --git a/Packages/RemindersKit/Package.swift b/Packages/RemindersKit/Package.swift index 6fc3b377..e0fc6ce1 100644 --- a/Packages/RemindersKit/Package.swift +++ b/Packages/RemindersKit/Package.swift @@ -34,7 +34,7 @@ let swiftSettings: [SwiftSetting] = [ let package = Package( name: "RemindersKit", platforms: [ - .iOS(.v26), + .iOS("26.1"), .macOS(.v26), ], products: [ diff --git a/Packages/SettingsKit/Package.swift b/Packages/SettingsKit/Package.swift index 56ed18a6..a8ea38ed 100644 --- a/Packages/SettingsKit/Package.swift +++ b/Packages/SettingsKit/Package.swift @@ -75,7 +75,7 @@ let testTargets: [Target] = [ let package = Package( name: "SettingsKit", platforms: [ - .iOS(.v26), + .iOS("26.1"), .macOS(.v26), ], products: [ diff --git a/Packages/SettingsKit/Sources/SettingsUI/Settings/SettingsScreen.swift b/Packages/SettingsKit/Sources/SettingsUI/Settings/SettingsScreen.swift index 84ebb423..443ad458 100644 --- a/Packages/SettingsKit/Sources/SettingsUI/Settings/SettingsScreen.swift +++ b/Packages/SettingsKit/Sources/SettingsUI/Settings/SettingsScreen.swift @@ -19,9 +19,6 @@ // leaderboard-as-value-type decoupling). // - `aboutExtraRows` (@ViewBuilder, default EmptyView) — Sudoku injects its // Sudoku-only "Generator" row here; Minesweeper injects nothing. -// - `banner` (@ViewBuilder, default EmptyView) — the app-injected -// `BannerSlotView` (Epic 5). SettingsKit must NOT import MonetizationUI / -// AppMonetizationKit; the actual slot is injected at the RouteFactory level. // - `version`, `reminderSettings`, `notices`, `clearCache`, `tint` — injected // config exactly as the prior wrappers passed them. // @@ -35,7 +32,7 @@ public import SwiftUI // (SettingsKit already depends on TelemetryKit for that type). public import Telemetry -public struct SettingsScreen: View { +public struct SettingsScreen: View { private let purchases: () -> Purchases private let reminderSettings: SettingsScreenReminderConfig? private let audioSettings: AudioSettingsModel? @@ -48,7 +45,6 @@ public struct SettingsScreen Banner // Game Center entry point: when non-nil the shared `Section("Game Center")` // row is rendered. The action is injected per-app so SettingsKit never // imports GameKit. Every game passes `{ GameCenterDashboard.present() }` @@ -89,8 +85,7 @@ public struct SettingsScreen Void)? = nil, telemetryEmit: @escaping @Sendable (TelemetryEvent) -> Void = { _ in }, @ViewBuilder purchases: @escaping () -> Purchases, - @ViewBuilder aboutExtraRows: @escaping () -> AboutExtraRows = { EmptyView() }, - @ViewBuilder banner: @escaping () -> Banner = { EmptyView() } + @ViewBuilder aboutExtraRows: @escaping () -> AboutExtraRows = { EmptyView() } ) { self.version = version self.tint = tint @@ -105,7 +100,6 @@ public struct SettingsScreen: View { +public struct SettingsShellView: View { // #516: warm-paper backdrop instead of the cold default grouped-Form gray, so // Settings matches the tonal continuity of the hubs/board. The host injects // its concrete palette (Sudoku warm / MS slate); falls back to NeutralTheme. @@ -33,46 +33,35 @@ public struct SettingsShellView: View { private let title: LocalizedStringKey private let sections: () -> Sections - private let banner: Banner public init( title: LocalizedStringKey, - @ViewBuilder sections: @escaping () -> Sections, - @ViewBuilder banner: () -> Banner = { EmptyView() } + @ViewBuilder sections: @escaping () -> Sections ) { self.title = title self.sections = sections - self.banner = banner() } public var body: some View { - // spacing-exempt: zero-gap chrome seam between the Form and the - // banner slot — not a spacing decision (#762 PR2, mirrors - // GameShellUI's DailyHubShellView/PracticeHubShellView precedent). - VStack(spacing: 0) { - Form { - sections() - } - // `Form` on macOS inside NavigationSplitView's detail pane picks a - // per-row layout based on the content primitive (Button → pill, - // LabeledContent → 2-column preferences row, no pill background). - // `.formStyle(.grouped)` forces grouped/pill treatment uniformly so - // sibling sections all render as full-width pill rows. iOS Form - // defaults to grouped in NavigationStack already, so this is a - // no-op on iPhone. (Sudoku issue #197, carried into the shell.) - .formStyle(.grouped) - // #516: drop the Form's own systemGroupedBackground so the warm-paper - // theme backdrop below shows through the gaps between the pill rows - // (the rows keep their own elevated/secondary fill). - .scrollContentBackground(.hidden) - // Explicit fill so the Form claims the height above the banner on - // macOS (matches the hub shells; a detail pane at arbitrary window - // heights must not squash the form). - .frame(maxWidth: .infinity, maxHeight: .infinity) - - banner + Form { + sections() } - // #516: warm-paper backdrop behind the Form + banner (matches the hubs). + // `Form` on macOS inside NavigationSplitView's detail pane picks a + // per-row layout based on the content primitive (Button → pill, + // LabeledContent → 2-column preferences row, no pill background). + // `.formStyle(.grouped)` forces grouped/pill treatment uniformly so + // sibling sections all render as full-width pill rows. iOS Form + // defaults to grouped in NavigationStack already, so this is a + // no-op on iPhone. (Sudoku issue #197, carried into the shell.) + .formStyle(.grouped) + // #516: drop the Form's own systemGroupedBackground so the warm-paper + // theme backdrop below shows through the gaps between the pill rows + // (the rows keep their own elevated/secondary fill). + .scrollContentBackground(.hidden) + // Explicit fill so the Form claims the full pane height on macOS (a + // detail pane at arbitrary window heights must not squash the form). + .frame(maxWidth: .infinity, maxHeight: .infinity) + // #516: warm-paper backdrop behind the Form (matches the hubs). .background(theme.surface.background.resolved.ignoresSafeArea()) // On the outer container (matches DailyHub/PracticeHub shells) so the // NavigationSplitView title preference reads from the pane root. diff --git a/Packages/SudokuCoreKit/Package.swift b/Packages/SudokuCoreKit/Package.swift index 80ef87d9..c9fc0e6a 100644 --- a/Packages/SudokuCoreKit/Package.swift +++ b/Packages/SudokuCoreKit/Package.swift @@ -62,7 +62,7 @@ let testTargets: [Target] = [ let package = Package( name: "SudokuCoreKit", platforms: [ - .iOS(.v26), + .iOS("26.1"), .macOS(.v26), ], products: [ diff --git a/Packages/SudokuKit/Package.swift b/Packages/SudokuKit/Package.swift index de8fc7f7..0d8bac98 100644 --- a/Packages/SudokuKit/Package.swift +++ b/Packages/SudokuKit/Package.swift @@ -327,7 +327,7 @@ let testTargets: [Target] = [ let package = Package( name: "SudokuKit", platforms: [ - .iOS(.v26), + .iOS("26.1"), .macOS(.v26), ], products: [ diff --git a/Packages/SudokuKit/Sources/SudokuAppComposition/Live+TabRoots.swift b/Packages/SudokuKit/Sources/SudokuAppComposition/Live+TabRoots.swift index eb2b470a..94040754 100644 --- a/Packages/SudokuKit/Sources/SudokuAppComposition/Live+TabRoots.swift +++ b/Packages/SudokuKit/Sources/SudokuAppComposition/Live+TabRoots.swift @@ -39,10 +39,13 @@ extension SudokuAppComposition { ) -> AnyView { switch tab { case .today: - // #1020 CR: no `banner:` here — `TodayTabHost` (GameAppKit) already - // wraps this content with its OWN `BannerSlotView` (and is the C-33 - // ATT anchor); passing another one too would render two banners. - // `DailyHubView`'s `banner:` param defaults to `EmptyView()`. + // #1024: no `banner:` here — the shared `tabViewBottomAccessory` + // (design.md §2.4) covers this tab; `TodayTabHost` (GameAppKit) + // has carried no `BannerSlotView` of its own since that move. The + // accessory slot's registration is now the "first ad context" + // that fires the C-33 ATT primer, not a Today-tab-local slot. + // `DailyHubView`'s `banner:` param is dead (defaults to + // `EmptyView()`, no caller overrides it) — tracked in #1096. return AnyView( DailyHubView( viewModel: DailyHubViewModel( @@ -64,6 +67,11 @@ extension SudokuAppComposition { key: "com.wei18.sudoku.practice.lastDifficulty", fallback: Difficulty.medium.rawValue ) + // #1024: no `banner:` here any more — the shared + // `tabViewBottomAccessory` (design.md §2.4) covers this tab. + // #1080: `PracticeHubView`'s `banner:` param and the + // tab-content-bottom fallback it fed were both removed — + // obsolete once #1079 confirmed the accessory path. return AnyView( PracticeHubView( viewModel: PracticeHubViewModel( @@ -71,10 +79,7 @@ extension SudokuAppComposition { initialDifficulty: Difficulty(rawValue: difficultyStore.load()) ?? .medium, persistDifficulty: { difficultyStore.save($0.rawValue) }, path: rootViewModel.pathBinding(for: .practice) - ), - banner: { - LiveRouteFactory.themedBanner() - } + ) ) ) case .progress: diff --git a/Packages/SudokuKit/Sources/SudokuAppComposition/LiveRouteFactory.swift b/Packages/SudokuKit/Sources/SudokuAppComposition/LiveRouteFactory.swift index dfbed2c0..a99b40c5 100644 --- a/Packages/SudokuKit/Sources/SudokuAppComposition/LiveRouteFactory.swift +++ b/Packages/SudokuKit/Sources/SudokuAppComposition/LiveRouteFactory.swift @@ -301,8 +301,14 @@ public struct LiveRouteFactory: RouteFactory { presentInviteFriends: presentInviteFriends, telemetryEmit: { event in Task { await telemetry.observe(event) } - }, - banner: { Self.themedBanner() } + } + // #1024: no `banner:` here any more — Settings is pushed + // onto a tab's stack, so it stays inside the TabView and + // the shared `tabViewBottomAccessory` (design.md §2.4) + // already covers it. #1080: `SettingsView`'s `banner:` + // param and the `Self.themedBanner` tab-content-bottom + // fallback it fed were both removed — obsolete once + // #1079 confirmed the accessory path. ) ) } @@ -336,42 +342,4 @@ public struct LiveRouteFactory: RouteFactory { } } - // MARK: - Banner helper - - /// Epic 5: themed `BannerSlotView` for all non-Board screens. Same theme - /// tokens across Today/Practice/Settings — no per-screen override. Board - /// never calls this; it owns its own `themedBanner` method. The session - /// model in the environment decides whether the slot shows (#1058). - /// - /// #851: `backgroundColor` was `Color.secondary.opacity(0.12)` — a - /// translucent SYSTEM-GRAY tint left over from before #688 gave - /// `BannerSlotView` a themed default. Composited over the app's actual - /// warm-dark page background (`DefaultTheme.surface.background.dark`, - /// 0x15171A) it painted a visibly lighter 50pt rounded band across the - /// bottom of Today/Practice/Settings whenever the ad gate opened — - /// exactly the "faint horizontal banding" reported. `LiveRouteFactory` is - /// a plain struct (not a `View`), so it cannot read `@Environment(\.theme)` - /// like `BoardView.themedBanner` does; `DefaultTheme()` is the same - /// concrete theme the app injects into the environment everywhere else, - /// so resolving it directly here yields an identical color. - /// - /// #1020: `static` (not an instance method) so `Live+TabRoots.swift`'s - /// Today/Practice tab-root builder — which has no `LiveRouteFactory` - /// instance to call through, only the wired `GameDeps` bag — can reuse - /// the exact same banner instead of re-deriving it. - @MainActor - static func themedBanner() -> some View { - BannerSlotView( - isSuppressed: false, - backgroundColor: DefaultTheme().surface.background.resolved, - progressTint: .accentColor, - captionColor: .secondary, - // uiux-bugfix-plan P1-6: `.opacity(0.7)` left the ✕ glyph nearly - // invisible after crop — full-strength token instead (#1084). - dismissTint: Color.secondary, - // #1058: moved inside `BannerSlotView` (was chained here). - horizontalPadding: 16, - verticalPadding: 12 - ) - } } diff --git a/Packages/SudokuKit/Sources/SudokuUI/Practice/PracticeHubView.swift b/Packages/SudokuKit/Sources/SudokuUI/Practice/PracticeHubView.swift index c95ede5d..3ea2223a 100644 --- a/Packages/SudokuKit/Sources/SudokuUI/Practice/PracticeHubView.swift +++ b/Packages/SudokuKit/Sources/SudokuUI/Practice/PracticeHubView.swift @@ -21,25 +21,19 @@ // (design.md §4.2: content-layer cards don't get the Slider/Toggle glass // exception). -public import MonetizationCore public import SwiftUI internal import GameShellUI internal import SudokuEngine -public struct PracticeHubView: View { +public struct PracticeHubView: View { @Bindable private var viewModel: PracticeHubViewModel @Environment(\.theme) private var theme - private let banner: Banner // Gap between the 3 difficulty cards (#1021 Phase C) — content tier, // scales with Dynamic Type. @ScaledSpacing(.small) private var cardGap - public init( - viewModel: PracticeHubViewModel, - @ViewBuilder banner: () -> Banner = { EmptyView() } - ) { + public init(viewModel: PracticeHubViewModel) { self.viewModel = viewModel - self.banner = banner() } public var body: some View { @@ -49,8 +43,7 @@ public struct PracticeHubView: View { filterHeader: "Difficulty", headerForeground: theme.text.primary.resolved, filter: { difficultyCards }, - cta: { drawCard }, - banner: { banner } + cta: { drawCard } ) } diff --git a/Packages/SudokuKit/Sources/SudokuUI/Settings/SettingsView.swift b/Packages/SudokuKit/Sources/SudokuUI/Settings/SettingsView.swift index 043624ce..f3b871d2 100644 --- a/Packages/SudokuKit/Sources/SudokuUI/Settings/SettingsView.swift +++ b/Packages/SudokuKit/Sources/SudokuUI/Settings/SettingsView.swift @@ -11,7 +11,6 @@ // `SudokuUI.SettingsViewModel(...)` call sites (production + tests) // unqualified and source-compatible. public import GameAppKit -public import SwiftUI -public typealias SettingsView = GameAppKit.SettingsView +public typealias SettingsView = GameAppKit.SettingsView public typealias SettingsViewModel = GameAppKit.SettingsViewModel diff --git a/Packages/SudokuKit/Tests/SudokuUITests/ASCScreenshotEmitTests.swift b/Packages/SudokuKit/Tests/SudokuUITests/ASCScreenshotEmitTests.swift index 33168113..663ead9c 100644 --- a/Packages/SudokuKit/Tests/SudokuUITests/ASCScreenshotEmitTests.swift +++ b/Packages/SudokuKit/Tests/SudokuUITests/ASCScreenshotEmitTests.swift @@ -145,8 +145,11 @@ struct ASCScreenshotEmitTests { // #1020: HOME is gone — the marketing "01-home" slot (name kept; ASC // ordering depends on it) now renders the Today tab's real content: - // `TodayTabHost` (resume pill + banner + ATT anchor) wrapping the Daily - // hub, exactly what `Live+TabRoots.swift` wires for the `.today` tab. + // `TodayTabHost` (resume pill) wrapping the Daily hub, exactly what + // `Live+TabRoots.swift` wires for the `.today` tab. #1024: the banner / + // ATT anchor `TodayTabHost` used to carry moved to the shared + // `tabViewBottomAccessory` — this ASC fixture has no ad seams to stub any + // more. private func todayTabView() async -> some View { let rootVM = RootViewModel( gameCenter: FakeGameCenterClient(), diff --git a/Packages/SudokuKit/Tests/SudokuUITests/BannerSlotDarkBandRegressionTests.swift b/Packages/SudokuKit/Tests/SudokuUITests/BannerSlotDarkBandRegressionTests.swift index 8aa1eeee..c358f05b 100644 --- a/Packages/SudokuKit/Tests/SudokuUITests/BannerSlotDarkBandRegressionTests.swift +++ b/Packages/SudokuKit/Tests/SudokuUITests/BannerSlotDarkBandRegressionTests.swift @@ -74,8 +74,8 @@ struct BannerSlotDarkBandRegressionTests { // MARK: - Post-#866: themed background — the real production path - /// Mirrors `SudokuAppComposition.LiveRouteFactory.themedBanner()` - /// exactly (same `backgroundColor` token). Pins the fix: the slot's + /// Mirrors `SudokuAppComposition.LiveRouteFactory.themedBanner()` (removed + /// in #1080) exactly (same `backgroundColor` token). Pins the fix: the slot's /// background must equal the page's own background token so no seam is /// visible in dark mode, regardless of which hub mounts it. @Test(.enabled(if: !SnapshotEnv.isXcodeCloud)) diff --git a/Packages/SudokuKit/Tests/SudokuUITests/TodayTabViewTests.swift b/Packages/SudokuKit/Tests/SudokuUITests/TodayTabViewTests.swift index 444334f6..02daae5e 100644 --- a/Packages/SudokuKit/Tests/SudokuUITests/TodayTabViewTests.swift +++ b/Packages/SudokuKit/Tests/SudokuUITests/TodayTabViewTests.swift @@ -1,12 +1,18 @@ // TodayTabViewTests — snapshot baselines for the Today tab's real root -// content: `GameAppKit.TodayTabHost` (resume pill + banner + the C-33 ATT -// anchor) wrapping `DailyHubView`, exactly what `Live+TabRoots.swift` wires -// for `AppTab.today`. +// content: `GameAppKit.TodayTabHost` (resume pill) wrapping `DailyHubView`, +// exactly what `Live+TabRoots.swift` wires for `AppTab.today`. // // #1020: replaces the retired `HomeViewTests` (HOME is gone; the marketing // "01-home" slot now sources from this suite via `ASCScreenshotEmitTests // .todayTabView()`) — same iPhone/iPad/Mac + dark + AX5 baseline matrix, // scoped down to the surface that actually still exists. +// +// #1024: `TodayTabHost` no longer owns a banner slot at all — it moved to +// the shared `tabViewBottomAccessory` (design.md §2.4, `BannerAccessoryView` +// in GameAppKit). The retired banner-region coverage below (`bannerVisible`) +// is now `BannerAccessoryPinTests` + the pre-existing `BannerSlotView` +// coverage (`HubSettingsBannerTests`, `BoardViewBannerTests`) — this suite +// no longer has any banner state to seed or assert on. import Foundation import SnapshotTesting @@ -16,9 +22,6 @@ import Testing import GameAppKit import GameCenterTesting -import MonetizationCore -import MonetizationTesting -import MonetizationUI import Persistence import SudokuKitTesting import SudokuPersistence @@ -30,12 +33,10 @@ struct TodayTabViewTests { nonisolated(unsafe) private static let fixedDate = Date(timeIntervalSince1970: 1_715_000_000) /// `TodayTabHost` wrapping a bootstrapped `DailyHubView` — the same - /// composition `SudokuAppComposition.makeTabRoot(.today, …)` builds - /// (`DailyHubView` itself takes no `banner:` here — `TodayTabHost` is the - /// ONE banner slot for the whole tab; see the CR fix note on - /// `Live+TabRoots.swift`). Injects `BannerSessionModel.disabled`, so every - /// baseline below has the banner region collapsed — `bannerVisible` below - /// is the one fixture that shows the banner region. + /// composition `SudokuAppComposition.makeTabRoot(.today, …)` builds. + /// #1024: `TodayTabHost` carries no banner slot / ad seams at all any + /// more (moved to the shared `tabViewBottomAccessory`), so there is + /// nothing left to seed here. private func todayTabHost() async -> some View { let rootVM = RootViewModel( gameCenter: FakeGameCenterClient(), @@ -55,52 +56,6 @@ struct TodayTabViewTests { .environment(\.bannerSession, .disabled) } - /// Deterministic stand-in for the live `ProgressView` spinner (#732, - /// mirrors `BoardViewBannerTests`) — same static ring look, no - /// animation-frame dependency, so this baseline isn't timing-sensitive. - private var deterministicBannerLoadingPreview: AnyView { - AnyView( - Circle() - .strokeBorder(Color.accentColor, lineWidth: 2) - .frame(width: 16, height: 16) - ) - } - - /// Same composition as `todayTabHost()` but with a started session over an - /// OPEN gate (`hasPurchasedRemoveAds: false`, 30 days post-launch) and a - /// readiness-held fake provider, so the banner's 50pt rect reserves space - /// on the very first layout and stays in its loading state (mirrors - /// `BoardViewBannerTests`). - private func todayTabHostWithVisibleBanner() async -> some View { - let rootVM = RootViewModel( - gameCenter: FakeGameCenterClient(), - persistence: FakePersistence() - ) - let provider = FakePuzzleProvider() - await provider.setDailyTrioResult(.success(FakePuzzleProvider.defaultDailyTrio(date: Self.fixedDate))) - let dailyViewModel = DailyHubViewModel( - provider: provider, - persistence: FakePersistence(completedDailyIds: []), - dateProvider: { Self.fixedDate } - ) - await dailyViewModel.bootstrap() - let session = BannerSessionModel( - adProvider: FakeAdProvider(readinessHeld: true), - adGate: AdGate(store: FakeAdGateStateStore( - initial: AdGateState( - firstLaunchAt: Date().addingTimeInterval(-30 * 86_400), - hasPurchasedRemoveAds: false - ) - )) - ) - await session.start() - return TodayTabHost(rootViewModel: rootVM) { - DailyHubView(viewModel: dailyViewModel) - } - .environment(\.bannerSlotLoadingPreview, deterministicBannerLoadingPreview) - .environment(\.bannerSession, session) - } - @Test(.enabled(if: !SnapshotEnv.isXcodeCloud)) func snapshotIPhoneLight() async { let host = hostingView( await todayTabHost(), @@ -173,23 +128,4 @@ struct TodayTabViewTests { assertViewStructure(of: host, named: "TodayTabView-iPhone-light-accessibility5", record: SnapshotMode.recordMode) } - // MARK: - Banner region coverage (CR follow-up) - // - // Every baseline above seeds the gate CLOSED, so none of them exercises - // `TodayTabHost`'s own banner slot — this is the marketing "01-home" - // source, so a banner regression there would ship unnoticed. This one - // fixture opens the gate (mirrors `BoardViewBannerTests`'s convention). - - @Test(.enabled(if: !SnapshotEnv.isXcodeCloud)) func snapshotIPhoneLightBannerVisible() async { - let host = hostingView( - await todayTabHostWithVisibleBanner(), - size: SnapshotLayouts.iPhone, - colorScheme: .light, - sizeClass: .compact - ) - withSnapshotTesting(record: SnapshotMode.recordMode) { - assertSnapshot(of: host, as: .image, named: "TodayTabView-iPhone-light-bannerVisible") - } - assertViewStructure(of: host, named: "TodayTabView-iPhone-light-bannerVisible", record: SnapshotMode.recordMode) - } } diff --git a/Packages/SudokuKit/Tests/SudokuUITests/__Snapshots__/TodayTabViewTests/snapshotIPhoneLightBannerVisible.TodayTabView-iPhone-light-bannerVisible.png b/Packages/SudokuKit/Tests/SudokuUITests/__Snapshots__/TodayTabViewTests/snapshotIPhoneLightBannerVisible.TodayTabView-iPhone-light-bannerVisible.png deleted file mode 100644 index 6a559294..00000000 Binary files a/Packages/SudokuKit/Tests/SudokuUITests/__Snapshots__/TodayTabViewTests/snapshotIPhoneLightBannerVisible.TodayTabView-iPhone-light-bannerVisible.png and /dev/null differ diff --git a/Packages/SudokuKit/Tests/SudokuUITests/__Snapshots__/TodayTabViewTests/snapshotIPhoneLightBannerVisible.TodayTabView-iPhone-light-bannerVisible.txt b/Packages/SudokuKit/Tests/SudokuUITests/__Snapshots__/TodayTabViewTests/snapshotIPhoneLightBannerVisible.TodayTabView-iPhone-light-bannerVisible.txt deleted file mode 100644 index 080fe0a2..00000000 --- a/Packages/SudokuKit/Tests/SudokuUITests/__Snapshots__/TodayTabViewTests/snapshotIPhoneLightBannerVisible.TodayTabView-iPhone-light-bannerVisible.txt +++ /dev/null @@ -1,12 +0,0 @@ -[ AF ! WlU ] h=--- v=--- _TtGC7SwiftUI13NSHostingViewGVS_15ModifiedContentGS1_GS1_GS1_GS1_GS1_GS1_GS1_GV10GameAppKit12TodayTabHostO8SudokuUI8AppRouteGVS4_12DailyHubViewVS_9EmptyView__GVS_30_EnvironmentKeyWritingModifierGSqVS_7AnyView___GS8_GSqC14MonetizationUI18BannerSessionModel___GS8_P11GameShellUI5Theme___GS8_VS4_10CellTokens__GS8_GSqOS_22UserInterfaceSizeClass___GS8_V10Foundation6Locale__GVS_26_PreferenceWritingModifierVS_23PreferredColorSchemeKey__VS_12_FrameLayout__ f=(0,0,393,852) b=(-) => - [ A ! WlU ] h=--- v=--- _TtCC7SwiftUI17HostingScrollView17PlatformContainer f=(0,0,393,778) b=(-) => - [ AF ! wLU ] h=--- v=--- SwiftUI.HostingScrollView f=(0,0,393,778) b=(-) => - [ AF ! wLU ] h=--- v=--- NSClipView f=(0,0,393,778) b=(-) => - [ AF ! wLU ] h=--- v=--- _TtC7SwiftUIP33_6B9589E2B2301F373BAB5CF17408C38412DocumentView f=(0,0,0,0) b=(-) => - [ AF ! W U ] h=--- v=--- _TtCC7SwiftUI17HostingScrollView22PlatformGroupContainer f=(0,0,0,0) b=(-) => - [ AF ! W U ] h=--- v=--- _TtC7SwiftUIP33_B9E8471D77A01696E0731B43A95A934614_FocusRingView f=(16,69,361,96) b=(-) => - [ AF ! W U ] h=--- v=--- _TtC7SwiftUIP33_B9E8471D77A01696E0731B43A95A934614_FocusRingView f=(16,177,361,96) b=(-) => - [ AF ! W U ] h=--- v=--- _TtC7SwiftUIP33_B9E8471D77A01696E0731B43A95A934614_FocusRingView f=(16,285,361,96) b=(-) => - [ HAF ! W U ] h=--- v=--- NSScroller f=(0,0,0,100) b=(-) => - [ AF ! W U ] h=--- v=--- _TtC7SwiftUIP33_B9E8471D77A01696E0731B43A95A934614_FocusRingView f=(336,793,44,44) b=(-) => -A=autoresizesSubviews, C=canDrawConcurrently, D=needsDisplay, F=flipped, G=gstate, H=hidden (h=by ancestor), L=needsLayout (l=child needsLayout), U=needsUpdateConstraints (u=child needsUpdateConstraints), O=opaque, P=preservesContentDuringLiveResize, S=scaled/rotated, W=wantsLayer (w=ancestor wantsLayer), V=needsVibrancy (v=allowsVibrancy), #=has surface diff --git a/Packages/TelemetryKit/Package.swift b/Packages/TelemetryKit/Package.swift index 8ec65eb7..43e38d8d 100644 --- a/Packages/TelemetryKit/Package.swift +++ b/Packages/TelemetryKit/Package.swift @@ -30,7 +30,7 @@ let swiftSettings: [SwiftSetting] = [ let package = Package( name: "TelemetryKit", platforms: [ - .iOS(.v26), + .iOS("26.1"), .macOS(.v26), ], products: [ diff --git a/Packages/TimeKit/Package.swift b/Packages/TimeKit/Package.swift index 6bc0070e..c6487aff 100644 --- a/Packages/TimeKit/Package.swift +++ b/Packages/TimeKit/Package.swift @@ -36,7 +36,7 @@ let testTargets: [Target] = [ let package = Package( name: "TimeKit", platforms: [ - .iOS(.v26), + .iOS("26.1"), .macOS(.v26), ], products: [ diff --git a/Project.swift b/Project.swift index 0eef6b35..5b9302df 100644 --- a/Project.swift +++ b/Project.swift @@ -46,7 +46,7 @@ let sudokuTarget = Target.target( destinations: [.iPhone, .iPad, .mac], product: .app, bundleId: "com.wei18.sudoku", - deploymentTargets: .multiplatform(iOS: "26.0", macOS: "26.0"), + deploymentTargets: .multiplatform(iOS: "26.1", macOS: "26.0"), infoPlist: .file(path: "App/Sudoku/Info.plist"), sources: ["App/Sudoku/**/*.swift"], resources: [ @@ -109,7 +109,7 @@ let minesweeperTarget = Target.target( destinations: [.iPhone, .iPad, .mac], product: .app, bundleId: "com.wei18.minesweeper", - deploymentTargets: .multiplatform(iOS: "26.0", macOS: "26.0"), + deploymentTargets: .multiplatform(iOS: "26.1", macOS: "26.0"), infoPlist: .file(path: "App/Minesweeper/Info.plist"), sources: ["App/Minesweeper/**/*.swift"], resources: [ @@ -166,7 +166,7 @@ let sudokuE2ETestsTarget = Target.target( destinations: [.iPhone, .iPad, .mac], product: .uiTests, bundleId: "com.wei18.sudoku.e2e", - deploymentTargets: .multiplatform(iOS: "26.0", macOS: "26.0"), + deploymentTargets: .multiplatform(iOS: "26.1", macOS: "26.0"), // The E2E targets depend only on `.target(name: "Sudoku")` (no package // product deps — see minesweeperE2ETestsTarget for the mirrored setup), so // `UITestLaunchArg` (GameAppKit) can't be reached via `import GameAppKit`. @@ -191,7 +191,7 @@ let minesweeperE2ETestsTarget = Target.target( destinations: [.iPhone, .iPad, .mac], product: .uiTests, bundleId: "com.wei18.minesweeper.e2e", - deploymentTargets: .multiplatform(iOS: "26.0", macOS: "26.0"), + deploymentTargets: .multiplatform(iOS: "26.1", macOS: "26.0"), // Mirrors sudokuE2ETestsTarget: no package product deps on this target // (only `.target(name: "Minesweeper")`), so `UITestLaunchArg` is compiled // in directly rather than imported as a GameAppKit module. diff --git a/docs/app-store/SUBMISSION-CHECKLIST.md b/docs/app-store/SUBMISSION-CHECKLIST.md index 63823cee..8f20ca85 100644 --- a/docs/app-store/SUBMISSION-CHECKLIST.md +++ b/docs/app-store/SUBMISSION-CHECKLIST.md @@ -3,6 +3,8 @@ Linear copy-paste session guide. Work top-to-bottom per app. All source files live under `docs/app-store/`. +> **Minimum OS (v2.6):** iOS 26.1 / macOS 26.0 — raised from iOS 26.0 by #1079 (banner accessory `isEnabled`). Neither app has shipped, so no existing users are affected. + --- ## Step 0 — run the preflight gate FIRST (Leader-orderable, read-only) diff --git a/docs/app-store/review/minesweeper-v2.6-review-information.md b/docs/app-store/review/minesweeper-v2.6-review-information.md index 5d62e024..234e3b58 100644 --- a/docs/app-store/review/minesweeper-v2.6-review-information.md +++ b/docs/app-store/review/minesweeper-v2.6-review-information.md @@ -63,6 +63,8 @@ To test: sign in to Game Center (Sandbox Apple Account) → play a Daily board → Home → Leaderboard → confirm the board's time appears in Apple's dashboard. +MINIMUM OS: iOS 26.1 / macOS 26.0. + PLATFORM DIFFERENCES (IF REVIEWING THE MAC BUILD) The macOS build ships NO advertising: AdMob/UMP have no macOS slice, so no banner ever appears, the Remove Ads purchase is not offered, and the ATT / diff --git a/docs/app-store/review/sudoku-v2.6-review-information.md b/docs/app-store/review/sudoku-v2.6-review-information.md index 961a3e31..c83438bc 100644 --- a/docs/app-store/review/sudoku-v2.6-review-information.md +++ b/docs/app-store/review/sudoku-v2.6-review-information.md @@ -32,6 +32,8 @@ leaderboards stay fair. The macOS build is a native SwiftUI app, not a Mac Catalyst wrapper. All UI text ships in 7 full localizations. Ads are banner-only — no interstitials — with a one-time Remove Ads unlock. +MINIMUM OS: iOS 26.1 / macOS 26.0. + PLATFORM DIFFERENCES (IF REVIEWING THE MAC BUILD) The macOS build ships NO advertising: AdMob/UMP have no macOS slice, so no banner ever appears, the Remove Ads purchase is not offered, and the ATT / diff --git a/docs/designs/v3/design.md b/docs/designs/v3/design.md index 1a317365..4530efe2 100644 --- a/docs/designs/v3/design.md +++ b/docs/designs/v3/design.md @@ -131,6 +131,43 @@ CK 失敗時**不顯示 badge**(不是顯示 0)。 ⚠️ **AdMob banner 是 `UIViewRepresentable`,包進 accessory 的相容性未驗證**(U-10)。 **降級備案:不相容則退回 tab 內容底部**,不阻擋任何其他設計。**變現路徑不卡在未驗證 API 上。** +**【AS-BUILT,#1029 B-6 → #1024,2026-09-08】** U-10 已驗證 **PASS**(#1029 B-6 +spike:真實 `BannerView` 在 accessory 內非零尺寸渲染、`.expanded`↔`.inline` +切換存活、impression 正常),accessory 路徑照建;降級備案**沒有啟用**,原本保留 +在 `SudokuAppComposition.themedBanner` / `MinesweeperKit`'s `LiveRouteFactory +.bannerSlot` 裡隨時可接回。**【AS-BUILT,#1080,2026-09-15】** 兩個函式與 +`PracticeHubView`/`SettingsView` 的 `banner:` 參數已一併刪除——accessory 路徑 +經 #1079 驗證穩定後,這個未啟用的降級備案視為過時,不再保留。實作: +`RootShellView`(GameShellKit)以泛型 `bottomAccessory` ViewBuilder 收內容, +`.tabViewBottomAccessory { … }` 在 `#if os(iOS)` 內**無條件**掛上(不做條件式 +掛載/卸載——那是 #1020 macOS unmount 傷疤同一類地雷)。是否顯示由內容自己決定 +(gate 關就渲染空/零高度),不是由「掛不掛這個 modifier」決定。 +`GameAppKit.BannerAccessoryView` 是實際內容,包住既有的 `BannerSlotView`, +一份共用 banner 覆蓋 Today/Practice/Progress/Settings(Settings 是 push 進 +tab 的 stack,還在 TabView 裡,所以也吃得到)。 + +**【AS-BUILT,#1079 → #1080,2026-09-15】** owner 裁定 option 1:iOS +deployment floor 升到 26.1(`Project.swift` 四個 app target + 所有 +`Packages/*/Package.swift`);`RootShellView` 改用 +`tabViewBottomAccessory(isEnabled:content:)`(iOS 26.1+, +`@available(macOS, unavailable)`),`isEnabled` 由 +`GameAppKit.GameRoot` 傳入的 `bannerSession.isVisible` 驅動。gate 拒絕時 +(signed out / Remove Ads / 今日已關)整個 capsule 完全不畫——先前量到的 +48pt 空 capsule(見上方 2026-09-08 Leader verification round 的 sim 證據) +已由此取代,不再出現。單一 code path,無 `#available` 分支——`.tabViewBottomAccessory` +modifier 仍在 `#if os(iOS)` 內每次 render 無條件掛上,`isEnabled` 只是 SDK +提供的顯示開關,不是條件式掛載(不重開 2.4 節「絕不條件式掛載」的規則)。 +macOS 仍是 2.4.1 節 option A,`tabViewBottomAccessory` 命中數維持 0。 + +**【AS-BUILT,#1080,2026-09-15】** 原生 re-host 修正:`tabViewBottomAccessory` +會在 push/pop/sheet 關閉等時機原生重建內容而不重跑 `GameRoot.body`,原本 +accessory 自有的 `@StateObject` lease 因此被重置、廣告重新請求(量到單次腳本 +8 次 vs main 的 2 次)。修正:`GameRoot` 用 `@State` 持有一個 `BannerSlotLease` +並經 `\.bannerAccessoryLease` 注入,`BannerAccessoryView` 改用 +`BannerSlotView(lease:...)` 這個外部 lease 加入 session,修正後同一腳本降到 +1 次請求。詳見 `meetings/2026-09-11_1058-slot-model-design.md` +「Externally owned lease (#1080)」段;重繪殘留 gap 另追 #1094。 + #### 2.4.1 ⚠️ macOS 沒有 tab accessory —— D18 在 macOS 缺承載機制 `tabViewBottomAccessory` 只到 iOS / iPadOS / Mac Catalyst,**macOS 原生沒有**。 @@ -144,6 +181,16 @@ D18(banner 覆蓋範圍是 feature)在 macOS 因此沒有落點。**方案(擇 **推薦 A**,理由是 B 與盤面滿版衝突、C 違反層歸屬 —— 兩者都要犧牲 3.0 的核心結構。 +**【AS-BUILT,#1024,2026-09-08】** U-13 裁定 **A(定案)**,B、C 沒有實作 +(連 dead code 都沒留)。結構性排除:`.tabViewBottomAccessory` 呼叫本身只存在 +`RootShellView.swift` 一處、包在 `#if os(iOS)` 裡 —— macOS binary 裡完全不會 +出現這支 API(`rg "tabViewBottomAccessory"` 驗證過,不是 runtime 判斷)。 +`MakeGameApp+Helpers.swift` 的 `makeBottomAccessory` 在 macOS 那個 `#else` +分支只回 `EmptyView()`,不會建構 `BannerAccessoryView`;macOS 建置裡也不存在 +`tabViewBottomAccessory` 這支呼叫。(`AdProvider`/`AdGate`/`BannerSlotView` +本身在 macOS 仍會建構——`MakeGameApp.swift` 建 `NoopAdProvider` + `AdGate`, +Board 的 slot 也照常掛;只有 accessory 這條路徑是 iOS-only。) + --- ## 3. 畫面規格 @@ -335,6 +382,14 @@ re-view、Sudoku loader 的 `.completedRedirect`、MS Tier2 guard 的 `.resolved 一次性 `hasOffered` latch、decline 後不再提供、`.notDetermined` 才出現。 → 契約總表補一條 **BREAK**(C-33)。 +**【AS-BUILT,#1024,2026-09-08】** C-33 再錨一次:banner 本體移進 §2.4 的 +`tabViewBottomAccessory` 後,ATT 觸發點跟著從「Today tab 自己的 banner slot」 +(`TodayTabHost`)搬到「整個 shell 共用的那一個 accessory banner slot」 +(`BannerAccessoryView`)。語意仍然等價(第一個廣告脈絡、`hasOffered` 一次性、 +不擋互動),但**可及性變寬**:accessory 是 TabView 的 chrome,不是任一 tab 的 +內容,所以不再要求「使用者曾經看過 Today」才觸發 —— 哪個 tab 在前景都算數。 +詳細 before/after 見 `docs/screen-contracts.md` ATT-PRIMER 章節。 + --- **⚠️ 型別約束(前一版寫錯模組,已更正):** `ResumeCandidate` 住在 **GameAppKit**(它依賴 Persistence), diff --git a/docs/foundations.md b/docs/foundations.md index d3da1326..f7e132b7 100644 --- a/docs/foundations.md +++ b/docs/foundations.md @@ -23,7 +23,7 @@ **牽連**: - 工具鏈最低需求:**Xcode 16+**(首個正式支援 Swift 6 language mode 的版本)。 -- 部署目標最低需求:**iOS 26 / macOS 26**。理由:對齊 §4 鎖定的 Xcode 26.5 工具鏈、保留 Liquid Glass(`.glassEffect()` 為 iOS 26+ API);本 App 無向下相容歷史包袱、屬個人作品兼案例展示,cut off 26 以下用戶可接受。**偏離 [[apple-platform-targets]] 的 iOS 18 / macOS 15 預設**——該 skill 為一般專案預設,本專案因 Liquid Glass 採用而上調。 +- 部署目標最低需求:**iOS 26.1 / macOS 26**(#1079:`tabViewBottomAccessory(isEnabled:)` 需要 26.1)。理由:對齊 §4 鎖定的 Xcode 26.5 工具鏈、保留 Liquid Glass(`.glassEffect()` 為 iOS 26+ API);本 App 無向下相容歷史包袱、屬個人作品兼案例展示,cut off 26 以下用戶可接受。**偏離 [[apple-platform-targets]] 的 iOS 18 / macOS 15 預設**——該 skill 為一般專案預設,本專案因 Liquid Glass 採用而上調。 - 所有自寫型別預設視為需要 `Sendable`;跨 actor 共享型別必須顯式宣告。 - 第三方相依若尚未支援 Swift 6 complete check,需評估:(a) 用 `@preconcurrency` 隔離匯入、(b) 換套件、(c) 暫緩採用。此議題在 §2、§3、§5、§6 各別套件選擇時逐一核對。 @@ -39,7 +39,7 @@ 1. **單 Package 多 target**:所有 module 收在一個 `SudokuKit` Swift Package 內,以 target 切分。 2. **App target 極薄**:只含 `@main`、`App` struct、Info.plist、entitlements、Assets,以及一個 DI composition root(把 protocol 與具體實作接起來)。所有畫面、邏輯、Storage 都在 Package。 -3. **Package platforms 與 App target 對齊**:`iOS 26 / macOS 26`,與 §1 一致。 +3. **Package platforms 與 App target 對齊**:`iOS 26.1 / macOS 26`(#1079:`tabViewBottomAccessory(isEnabled:)` 需要 26.1),與 §1 一致。 4. **Apple 框架 import 範圍受限**:`CloudKit` 只在 `PuzzleStore` + `Persistence` 直接 import;`GameKit` 只在 `GameCenterClient`。`SudokuUI` 與 `GameState` 透過 protocol 注入使用,不直接 import — 便於 UI/邏輯層的單元測試與 SwiftUI preview。**例外(issue #49, 2026-05-20)**:`SudokuUI/Leaderboard/GameCenterDashboard.swift` 直接 `import GameKit` / `UIKit` / `AppKit`,因 Apple 原生 Game Center dashboard(`GKAccessPoint` / `GKGameCenterViewController`)為終端 UI 表面、無 protocol-injectable seam 可走。檔案層級的局部 import 不污染 SudokuUI 其餘 Views 的測試性(其他 View 仍透過 `any GameCenterClient` 注入)。 5. **測試 target 一對一**:每個 production target 對應一個 `Tests` target。 @@ -52,7 +52,7 @@ │ └── (Assets, Info.plist, entitlements) └── Packages/ └── SudokuKit/ - ├── Package.swift # platforms: [.iOS(.v26), .macOS(.v26)] + ├── Package.swift # platforms: [.iOS("26.1"), .macOS(.v26)] └── Sources/ ├── SudokuEngine/ # 純 Swift 核心:board / rules / validator ├── GameState/ # 進行中局面:moves, undo/redo, notes diff --git a/docs/screen-contracts.md b/docs/screen-contracts.md index 8b5e0235..79af1691 100644 --- a/docs/screen-contracts.md +++ b/docs/screen-contracts.md @@ -2,17 +2,21 @@ **Status:** AS-BUILT · **Date:** 2026-07-05 · **main @** `9d6bf71` -> ⚠️ **Partially superseded by 3.0 (#1020, 2026-08-25).** The HOME screen -> (`GameHomeView` / `GameHomeViewModel` / `HomeScreen`), the hand-authored -> sidebar (`SidebarItem` / `NavigationStackHost`) and the `home` / `daily` / -> `practice` / `stats` routes no longer exist. The root is a `sidebarAdaptable` -> TabView (`GameShellUI/RootShellView.swift`) with one path per tab -> (`GameAppKit/GameRootViewModel.swift`); ATT-PRIMER's entry point is the Today -> tab's banner slot (`GameAppKit/TodayTabHost.swift`, C-33); the HOME -> Leaderboard card is gone (C-35) and Progress gained an `Achievements` row +> ⚠️ **Partially superseded by 3.0 (#1020, 2026-08-25; banner re-anchored again +> by #1024, 2026-09-08).** The HOME screen (`GameHomeView` / `GameHomeViewModel` +> / `HomeScreen`), the hand-authored sidebar (`SidebarItem` / `NavigationStackHost`) +> and the `home` / `daily` / `practice` / `stats` routes no longer exist. The +> root is a `sidebarAdaptable` TabView (`GameShellUI/RootShellView.swift`) with +> one path per tab (`GameAppKit/GameRootViewModel.swift`); the banner (and +> ATT-PRIMER's entry point riding on it, C-33) lives in the shared +> `tabViewBottomAccessory` (`GameAppKit/BannerAccessoryView.swift`, design.md +> §2.4) — covering Today/Practice/Progress/Settings from ONE instance instead +> of `GameAppKit/TodayTabHost.swift`'s Today-only slot; macOS has no accessory +> and shows no banner at all (§2.4.1). The HOME Leaderboard card is gone +> (C-35) and Progress gained an `Achievements` row > (`GameAppKit/AchievementsRow.swift`, C-36); resume-pill refresh follows > design.md §3.6.2 (C-34 / N-AB). Current model: `docs/designs/v3/design.md` -> §2.1–§2.3, §3.6, §9.4. Rows below that cite the retired screens are kept as +> §2.1–§2.4, §3.6, §9.4. Rows below that cite the retired screens are kept as > the pre-3.0 as-built record until this doc is regenerated for 3.0. **Companion to:** `docs/navigation-flows.md` (nav model + flow chains + negative flows references the screen IDs defined here). This doc supersedes the flow @@ -1270,13 +1274,42 @@ GC row entry point: `App/SudokuE2ETests/SudokuE2ETests.swift` ## ATT-PRIMER -**Entry points (3.0, C-33 BREAK — #1020):** first ad-relevant moment — the -**Today tab's** banner slot (`GameAppKit/TodayTabHost.swift`, `onAdContext`) -calls `attPrimer.maybePresentOnAdContext()` (i.e., the **first Today banner -load**, not app launch). Pre-3.0 this was `GameHomeView`'s banner slot; HOME -is retired. One-offer-per-launch latch (`hasOffered`) unchanged. **Does not -block Today interaction** — the tab is already rendered and tappable when -this sheet appears (CODE CONTRADICTED vs. a "boot-time gate" assumption). +**Entry points (3.0, C-33 BREAK — #1020, re-anchored again by #1024, moved onto +the session model by #1062):** first ad-relevant moment — the session-scoped +`BannerSessionModel` (`AppMonetizationKit/Sources/MonetizationUI/BannerSessionModel.swift`) +runs its `onAdContext` hook once provider readiness resolves. The trigger is +the session's `start()`, called from `GameRoot.onAppear` (`GameRoot.swift`) +and re-checked on the `sceneDidBecomeActive` foreground repoll, as soon as +the gate is open and the provider is not suppressed — independent of any +slot or tab. `MakeGameApp+BannerSession.swift`'s +`makeBannerSession(adProvider:adGate:attPrimer:)` wires that hook to +`attPrimer.maybePresentOnAdContext()`. Pre-3.0 this was `GameHomeView`'s +banner slot; #1020 moved it to the Today tab's own slot +(`GameAppKit/TodayTabHost.swift`); #1024 moved it again, from Today-only to +the ONE shared `tabViewBottomAccessory` banner that now covers every tab; +#1062 moved the hook off any one slot's view code entirely, onto the +session, whose `start()` no longer depends on any slot mounting at all. One-offer-per-launch +latch (`hasOffered`) unchanged — same `ATTPrimerCoordinator`, only the call +site moved. + +**Reachability change (#1024, before vs. after):** + +| | Before (#1020) | After (#1024 / #1062) | +|---|---|---| +| Trigger surface | Today tab's OWN `BannerSlotView` (`TodayTabHost`) — Practice/Settings had independent `BannerSlotView`s with no `onAdContext` wired | `BannerSessionModel.start()`, called from `GameRoot.onAppear` — no slot involved | +| Gated on | Visiting/rendering the Today tab at least once | Nothing tab-specific — `GameRoot.onAppear` fires once at launch regardless of which tab is initially selected | +| Practical effect | A user who navigated straight to Practice/Progress without visiting Today never triggered the primer until they did | Fires at launch as soon as the gate is open and the provider isn't suppressed, independent of which tab is on screen and before any slot has registered | + +Order-pinning is unchanged and still enforced by `BannerSessionModel`'s own +readiness sequence: gate open → provider suppression check → `awaitReady()` +→ `onAdContext` → `sessionReady` opens → registered slots load. `onAdContext` +always fires before any slot's actual ad load — see +`GameAppKitTests/TodayTabHostTests` (`makeBannerSession`'s C-33 ordering +tests) and `GameAppKitTests/BannerAccessoryPinTests` (the accessory's own +slot only ever loads after that same session order). + +**Does not block interaction** — the shell is already rendered and tappable +when this sheet appears (CODE CONTRADICTED vs. a "boot-time gate" assumption). **Code:** `AppMonetizationKit/Sources/MonetizationUI/ATTPrimerSheet.swift`, `ATTPrimerCoordinator.swift`. diff --git a/meetings/2026-09-07_1024-banner-accessory.impl-notes.md b/meetings/2026-09-07_1024-banner-accessory.impl-notes.md new file mode 100644 index 00000000..d5312aa2 --- /dev/null +++ b/meetings/2026-09-07_1024-banner-accessory.impl-notes.md @@ -0,0 +1,289 @@ +# impl-notes — #1024 banner → tabViewBottomAccessory (session/brisk-stork-isio) + +Running log during implementation. Post-hoc meeting log is separate. + +## PM Task A — sentinel-accessory render test (2026-09-08, post-merge follow-up) + +`Packages/GameShellKit/Tests/GameShellUITests/RootShellViewBottomAccessoryRenderTests.swift` +(new, `#if os(iOS)`). Renders the real `RootShellView` with a sentinel +`bottomAccessory` in a `UIHostingController`/`UIWindow`, proving OUR wiring +(not just the #1029 API spike) actually threads content through +`.tabViewBottomAccessory`. + +Dead end tried first, documented in the file: accessibility-identifier +lookup. `.accessibilityIdentifier(_:)` on plain `Text` does not surface via +`UIAccessibilityIdentification` or the `UIAccessibilityContainer` count/index +methods when walked in-process in a headless XCTest host — confirmed via +`-recursiveDescription`, which showed the identifier-less content view +(`SwiftUI.CGDrawingView`) sitting directly inside a real, populated +`SwiftUI.UIKitTabBarBottomAccessory` container. + +Final proof strategy: find the container by class name (contains +"BottomAccessory") and assert it has real content — contrasted against an +`EmptyView` accessory (separate test) to rule out "the container always has +stray children" as a false-positive explanation. Both tests pass on iOS +Simulator; `@MainActor` added to the polling helpers after a Main Thread +Checker warning surfaced on the first pass (`.subviews` off-main after +`Task.sleep`). + +## PM Task B — `-uitest-open-ad-gate` launch arg (2026-09-08, post-merge follow-up) + +New DEBUG-only `UITestLaunchArg.openAdGate` ("-uitest-open-ad-gate") + +`UITestAlwaysOpenAdGateStateStore` (UITestFakeSeams.swift) + a new branch in +`resolveAdGateStore` (MakeGameApp+UITestOverrides.swift), inserted BEFORE the +existing `-uitest-fake-ad-gate-repoll` guard so that guard's own lines stay +byte-identical (verified via `git diff` scoped to the function — zero changes +to the repoll branch's text). `resolveAdProvider` is not touched at all — +confirmed both by the diff (function doesn't appear in it) and by a runtime +test (`resolveAdProviderStillReturnsMakeLiveResult`) proving it still returns +whatever `makeLive()` produces. + +Release-unreachability proof: `swift build -c release` succeeds (a leaked +reference to a DEBUG-only symbol from outside `#if DEBUG` would fail to +compile), AND `strings` over every compiled `.o` in the Release +`GameAppKit.build` directory returns ZERO hits for `uitest-` (not just this +one arg — the whole family), confirming stripped, not just visually +`#if DEBUG`-fenced. + +New tests: `UITestAlwaysOpenAdGateStateStoreTests.swift` (3 tests) — the fake +run through a REAL `AdGate` actually opens `shouldShowBanner` (not just +"looks open on paper"), plus the `resolveAdProvider`-untouched proof above. +All pass on macOS `swift test` (85/85 full GameAppKit suite, up from 82). + +## Decisions locked by dispatch / gates (not re-litigated) + +- **Accessory path ships** — B-6 gate (#1029) PASSED (comment: real BannerView in accessory, + {360,48} non-zero, survives .expanded→.inline, impression fires every launch). Fallback + (tab-content-bottom, today's TodayTabHost slot) stays reachable + documented, not shipped. + **Update (#1080, 2026-09-15):** removed. Once #1079 confirmed the accessory path stable, + the PM ruled the unused fallback (`themedBanner()`/`bannerSlot()` + the `banner:` params + on `PracticeHubView`/`SettingsView`) obsolete — deleted, not kept reachable. +- **macOS: NO banner at all** (§2.4.1 option A, FINAL). Structural exclusion + (`#if os(macOS)` / platform-conditional composition), never runtime check. + Acceptance: zero `tabViewBottomAccessory` hits in macOS path. +- §2.4.1 options B (detail footer) / C (sidebar) forbidden, even as dead code. +- Mirror principle: one shared parameterized implementation; verify BOTH apps. +- Test AdMob IDs only in code/tests/diff; prod via secrets/.env + xcconfig `$()`. +- #1022 rebuilds board bottom controls concurrently — avoid board files; rebase on main pre-PR. + +## Corrections applied + +- 2026-09-07: issue #1024 body had 5× "V-3 (#1028)" where the B-6 gate is V-4 (#1029); + verified #1028 = B-5 a11y matrix, fixed body + audit comment. (Dispatch-authorized.) + +## Scope adjudications (2026-09-08, Leader; PM informed) + +- **Board banners untouched** (BoardView / MinesweeperBoardView): board is `fullScreenCover` + OUTSIDE the TabView (GameRoot.swift:149-176) so the accessory categorically cannot host it; + also #1022 owns board bottom chrome. Out of #1024 scope. +- **In-shell inline banners retire**: Today (TodayTabHost slot), Practice + Settings + (`banner:` closures) stop being passed — the accessory covers the whole tab shell. + The shells' generic `banner:` parameters STAY (documented §2.4 fallback mechanism). +- **ATT primer anchor (C-33)** moves with the banner: `onAdContext` fires from the + accessory's slot, not TodayTabHost. +- **GameShellKit stays zero-dep**: RootShellView gains a generic bottom-accessory + ViewBuilder param; monetization types never enter GameShellKit. Modifier application + wrapped `#if os(iOS)`; accessory content constructed only in GameAppKit `#if os(iOS)`. +- **Static-modifier rule honored** (RootShellView.swift:29-59): the accessory closure must + not read state that changes while a board is pushed. + +## In-flight decisions + +- (2026-09-07) Explorer mapping banner wiring; implementation dispatched to sonnet developer + after map returns. Subagent write probe: PASS. +- B-6 bonus finding: 320×50 creative leaves black side slivers in the .expanded capsule. + DECIDED + implemented: `LiveAdMobBridge.loadBanner()` sets the native `BannerView + .backgroundColor = .clear` so `BannerAccessoryView`'s themed SwiftUI background + letterboxes the gap instead of the SDK's own opaque fill. One-line, low-risk; full visual + confirmation is the Leader's idb screenshot pass (sim access is PM's per the ruling below). + +## Final status (2026-09-08) — all phases complete, pushed + +- Phase 1 (b527c3d9): RootShellView generic `bottomAccessory` param, `#if os(iOS)` attach. +- Phase 2 (637b18eb): `BannerAccessoryView` + composition wiring, `TodayTabHost` simplified. +- Phase 3 (debad990 + 6dfaed11): both apps' Practice/Settings inline `banner:` closures + retired; orphaned `adProvider`/`adGate` params cleaned from both `makeTabRoot`s; every + broken test call site (TodayTabHost's simplified init, `makeTabRoot` signature) fixed; + retired banner-visible snapshot fixtures deleted (surface moved to BannerAccessoryViewTests). +- Order-pinning test (c7028953): `BannerAccessoryViewTests.primerFiresBeforeAnyAdLoad` — + real render + shared event log, proves primer-before-load empirically on iOS Simulator + (confirmed the macOS headless `.task`-actor-hop limitation does NOT apply there). +- Docs (fb102021): design.md §2.4/§2.4.1 AS-BUILT notes + §3.6.1 re-anchor note; + screen-contracts.md HOME-note + ATT-PRIMER section re-anchored with an explicit + before/after reachability table. +- B-6 cosmetic fix: `LiveAdMobBridge` banner background made `.clear` (pending commit). + +## Leader verification round (2026-09-08 PM) + +- CR dual-model: haiku APPROVE; sonnet APPROVE-WITH-NITS. 2 stale comments fixed + inline (4fe8be2c). MEDIUM gap (no accessory-through-shell render test) escalated + to PM (in-PR sentinel test vs fast-follow). +- Sim evidence (iPhone 17 Pro, iOS 26.5, both apps): **empty accessory renders a + visible blank capsule** above the tab bar (su-01/su-02/ms-02 screenshots) — the + Remove-Ads-purchaser steady state. Escalated to PM with options (zero-height + experiment / `isEnabled:` needs iOS 26.1 > our 26.0 floor / accept). +- **Loaded-banner evidence structurally blocked on sims**: AdGate fail-closed on + store error + CloudKit-Private live store + no iCloud on sims → gate never opens + in-app (pre-existing environmental, not a #1024 regression). #931 seam swaps + provider to Noop so it can't produce a real ad. Escalated with recommendation: + DEBUG-only `-uitest-open-ad-gate` (store-only fake, live provider). + +## PM rulings (2026-09-08, sly-bunting — binding) + +- Done-when checklist received (6 items: PR merged by PM w/ closes #1024 · T6 ACCEPT · + issue closed · claim posted ✅ · docs in-PR (design.md §2.4/§2.4.1 as-built + + screen-contracts C-33/banner re-anchor) · branch/worktree cleanup post-merge). +- Adjudications 1–3 APPROVED (board untouched / ATT anchor moves w/ conditions / + no banner over sheets — all to be stated in PR body). +- ATT conditions: order-pinning test (primer BEFORE any ad-context/ad-load); document + before/after trigger set; escalate if primer timing couples to accessory mount. +- Open question A DECIDED: `.tabViewBottomAccessory` applied STATICALLY, suppression + inside content (gate denies → empty/zero-height). Conditional modifier forbidden + (#1020 unmount scar class). Empty-capsule-chrome question answered by sim evidence, + escalated to PM if visible — that trade is PM's. +- Commit+PUSH every phase boundary; sim access serialized to Leader. + +## Open questions for PM/user + +- PM done-when checklist requested (check-in sent 2026-09-07); proceeding with local impl, + PR opens after PM ack + rebase. + +## #1080 follow-through (2026-09-15, plucky-wren) + +- **Merge resolution summary (#1080, rebasing #1024 onto the #1062 session + model)**: `BannerAccessoryView` was rewritten onto `\.bannerSession` — a + plain `BannerSlotView(isSuppressed: false, …)` like every other slot, no + `provider` / `gate` / `primer` parameters flowing through it any more; the + ATT anchor (C-33) lives on the session's `onAdContext` hook, wired in + `makeBannerSession`. `BannerAccessoryViewTests` was deleted rather than + ported: it called an init that no longer exists, and the ATT-primer + coverage it existed for is already pinned by `makeBannerSession`'s own + tests plus `TodayTabHostTests` (restored from main, unchanged). `#1024`'s + removal of the inline Today/Practice/Settings banner slots won every + conflicting hunk; no board file was touched. +- **Pin test restored (#1080, PM requirement — an E2E-only pin does not + count)**: `GameAppKitTests/BannerAccessoryPinTests.swift` replaces the + deleted `BannerAccessoryViewTests` as the accessory's own render-level pin. + Round 2 (review gap): the (a)/(b) tests host the REAL `GameRoot`, not a + bare `RootShellView` with `isVisible` frozen at construction — a bare host + never re-reads anything, so a regression hard-coding + `bottomAccessoryIsEnabled` to either constant passed every round-1 test. + Hosting `GameRoot` with an UN-started `BannerSessionModel` (its own + `.onAppear` calls `start()`, exactly like `MakeGameApp.swift`) closes that + gap. Six mutations, each named where it bites; all but (a2) executed red + and reverted: (a1) `BannerAccessoryView.body` → `EmptyView()` — (a) red + (no registration, no `.loaded`); (a2) drop the `\.bannerSession` + injection from the test's own hosting helper — named-only, since it + mutates the test file, not production; (b) `RootShellView`'s + `.tabViewBottomAccessory(isEnabled:)` hard-coded `true` — (b) red and the + dismiss half of (a) red; (c) macOS — `makeBottomAccessory()` returns + `EmptyView` structurally, `BannerAccessoryView` doesn't compile into the + macOS binary; (d1) `GameRoot.shellContent`'s `bottomAccessoryIsEnabled:` + hard-coded `true` — (b) red and the dismiss half of (a) red; (d2) same + site hard-coded `false` — (a) red (capsule never appears). +- **`isEnabled` design (#1079, this session)**: `RootShellView` gained a + plain `Bool` parameter, `bottomAccessoryIsEnabled`, feeding + `tabViewBottomAccessory(isEnabled:content:)`. GameShellKit still has no + idea what the Bool means — GameAppKit's `GameRoot` is the one that reads + `bannerSession.isVisible` and passes it through, keeping GameShellKit + zero-dependency. The modifier itself stays attached unconditionally, every + render, inside `#if os(iOS)` — `isEnabled` is an SDK display switch, not a + conditional attach, so the #1020 unmount-hazard rule this file's design.md + section already documents still holds. +- **Floor**: iOS deployment target raised 26.0 → 26.1 across `Project.swift`'s + four app targets and every `Packages/*/Package.swift` (macOS unchanged at + 26.0) — `tabViewBottomAccessory(isEnabled:content:)` requires iOS 26.1. + Neither app has shipped, so no existing user is affected by the raise. +- **Open question**: when `isEnabled` flips (banner session's `isVisible` + changes), `GameRoot.body` re-evaluates. Whether a pushed Settings stack or a + presented board `fullScreenCover` survives that re-evaluation the way #1020 + requires (rule 2 in `RootShellView`'s file header) has not been verified on + a simulator — the automated tests here only prove the render-level wiring + (capsule appears/disappears with `isEnabled`), not this interaction with an + in-flight navigation state. Needs sim verification before treating this as + fully closed. + +## #1080 fix: host-owned accessory lease (2026-09-15, plucky-wren round 3) + +- **Probe summary**: a DEBUG `os_log`-instrumented prototype (`remount-probe`, + not shipped — every `PROBE1080`/`identityLog`/`ProbeEC` line was stripped + before this change) found `tabViewBottomAccessory` re-hosts its content + natively — a new hosting view, `GameRoot.body` never re-runs — on push, + pop, sheet dismissal, and cold-launch transitions. A `BannerSlotView`'s + `@StateObject` lease inside that content was re-created 8 times across one + launch-to-idle script (2 on `main`'s never-re-hosted inline slots), each + re-creation disposing the loaded ad handle and sending a fresh request. The + host-owned-lease prototype cut that to 1 request per launch on both + devices. Full measurement recorded in + `meetings/2026-09-11_1058-slot-model-design.md` §"Externally owned lease + (#1080)". +- **E-a trigger mechanism**: `BannerAccessoryPinTests`'s T2 + (`accessorySurvivesPushPopReHost`) reproduces the re-host inside the same + bare-`UIWindow`/`UIHostingController` harness (a) and (b) already use — no + real app scene needed — by pushing then popping a route on the Today tab's + own `NavigationStack` (`viewModel.pathBinding(for: .today)`). Confirmed + empirically: under the pre-fix mutation (accessory reverted to a + self-owned lease) the test goes red (refresh count 1→2, slot id changes); + with the fix it stays green. This closes the PM condition that a hosted- + window harness must be proven to actually re-host before it can serve as + the pin, rather than assumed. +- **E-c numbers (ad-request count, same scripted push/pop/sheet script, both + devices)**: accessory before the fix sent 8 ad requests per device; `main` + sent 2; the host-owned-lease fix sent 1 on both iPhone and iPad. +- **PM ruling on the residual repaint gap**: after each re-host the + reparented banner's creative stays visually unpainted for 0.6–1.9s past the + transition animation — video-measured empty-creative durations: iPad ATT + 1926ms (single recording — an earlier 1855ms figure for the same video was + a superseded ad-hoc estimate, not a second sample), push 1580ms, reminder + decline 644ms, pop 1428ms; iPhone ATT 1236ms, push 934ms, reminder decline + 824ms, pop 1341ms. Control (C1): the + main branch's never-re-hosted inline slot stayed painted in 144/144 frames, + ruling out a recording artifact. A `setNeedsLayout`/`layoutIfNeeded` nudge + in `updateUIView` was tried and dropped — its trigger (a window-change + observation) never fired, since no window change is visible at that call + site. PM accepted this gap as out of #1080's scope; it is NOT fixed by this + change. Follow-up: #1094. +- **New pins and mutations (this round)**: + - T1 (`AppMonetizationKit/Tests/MonetizationUITests/BannerSlotExternalLeaseTests.swift`, + macOS): the same external lease survives its view being re-hosted under + a new SwiftUI identity — one registration, one refresh, same slot id and + loaded handle. Mutation (`BannerSlotRegistration.update()` attaches + `ownLease` instead of `effective`) executed red, reverted green. + - T2 (`GameAppKitTests/BannerAccessoryReHostTests.swift`, iOS Simulator, a + sibling suite rather than folded into the round-2 file — that pushed it + to 438 lines, over the `file_length` ceiling; duplicates its own stubs + per the codebase's established file-private-copy convention): see E-a + above. Mutation (`BannerAccessoryView` reverted to the self-owned-lease + init) executed red on-device, reverted green. + - T4 (`GameAppKitTests/BannerAccessoryMissingLeaseTests.swift`, iOS + Simulator): a missing `\.bannerAccessoryLease` injection calls + `BannerAccessoryView.onMissingAccessoryLease()` — mirrors + `BannerSessionModel.onMissingSession` (#1058 M1), no silent fallback. + Mutation (drop the call from the `nil` branch) executed red, reverted + green. The API contract's "renders nothing" half is documented, not + independently asserted — `UIHostingController.sizeThatFits(in:)` + reported a nonzero height for this exact tree even with `EmptyView()` as + the only content on an unwindowed, unparented hosting controller, the + same false-negative class this file's own (a)/(b) header already flags + for accessibility-identifier lookup in a headless host. + - Round-2 vs T2 dedupe (PM condition 3): round-2's (a)/(b)/(c)/(d1)/(d2) + pin `isEnabled`/visibility wiring (does the capsule show/hide with the + gate); T2 pins lease-identity stability across a native re-host (does + registration churn). Disjoint behaviors — both kept, nothing redundant. + - Teardown (T3): no pin added this round (PM ruling: hygiene, not a gate) + — a separate bounded diagnostic covers it, not investigated further + here. The app is single-scene (no `UIApplicationSceneManifest`), so + `GameRoot` and its accessory lease live for the process lifetime. In a + hosted-window diagnostic + (`build/evidence-1080/remount-probe/test-ec-teardown-v2.log`), releasing + the hosting controller leaves the `GameRoot`-owned lease alive: + `session.slots` still holds that same lease id after 5s and nothing is + disposed. The retain path is not identified. An earlier v1 run that + reported the lease deallocated was invalid (its capture hook never + fired). Per-re-host accumulation checked from the E-c logs + (`ec-head-{iphone,ipad}.log`): self-owned never-attached leases + inits/deinits = iPhone 23/22, iPad 16/15. The one alive on each device is + the live host's own lease, so old hosted content is released on every + re-host. The harness-teardown retention does not accumulate in normal + use. Board/Practice/Settings slots keep self-owned leases and deinit + normally. diff --git a/meetings/2026-09-11_1058-slot-model-design.md b/meetings/2026-09-11_1058-slot-model-design.md index b6d8b42f..d191a1a6 100644 --- a/meetings/2026-09-11_1058-slot-model-design.md +++ b/meetings/2026-09-11_1058-slot-model-design.md @@ -52,6 +52,37 @@ A cancelled wait or load produces **no status change**. - `update()` with no session calls `BannerSessionModel.onMissingSession`: `assertionFailure` in DEBUG, and the slot renders nothing in Release. That assertion is the regression signal for a lost injection. - Only the three allow-listed uses inject `BannerSessionModel.disabled`: previews, snapshot fixtures, and the DEBUG near-win covers. +## Externally owned lease (#1080) +- **Why:** `tabViewBottomAccessory`'s content is re-hosted by UIKit natively (push, pop, + sheet dismissal, layout changes) without re-evaluating `GameRoot`'s body — a `BannerSlotView` + inside it had its `@StateObject` lease re-created 8 times in one launch-to-idle script (measured, + #1080 probe), each re-creation disposing the loaded ad and sending a fresh request (vs 2 on + `main`'s never-re-hosted inline slots). +- **Shape:** `BannerSlotRegistration` grows an `init(lease: BannerSlotLease? = nil)`; when + `external` is supplied it is used INSTEAD of the still-constructed-but-inert `ownLease`. A new + `BannerSlotView(lease:...)` public init threads the caller's lease through. + `GameAppKit.GameRoot` owns one `BannerSlotLease` as `@State` (scene lifetime, same as + `chromeState`) and injects it via `\.bannerAccessoryLease`; `BannerAccessoryView` reads it and, + when present, joins the session through it instead of a self-owned lease. One lease per + placement (only the accessory), `BannerSessionModel` keying untouched. +- **Why this does not reopen "host-side registration would copy four hosts' mount conditions" + (line 50 above):** that ruling was about the SESSION MODEL's registration mechanics moving into + each host's own mount/render logic — the four hosts each deciding for themselves when to + register. Here registration still happens in exactly one place, `BannerSlotRegistration.update()`, + on every mount regardless of caller; only WHICH `BannerSlotLease` instance `update()` attaches + changes, and only for the one placement (the accessory) whose host outlives its own view identity. + No host copies another's mount condition. +- **Dismantle invariant (`AdsAdMob.BannerViewRepresentable`):** the retained `BannerView` survives + a re-host because the NEW host's `makeUIView`/`updateUIView` adopts it before the OLD host's + `dismantleUIView` would run (there is none today — documented as an invariant a future + `dismantleUIView` must preserve, not implemented as new code). +- **Residual gap, tracked separately:** the reparented banner's creative stays visually unpainted + for 0.6–1.9s after each re-host (video-measured, iPhone: ATT 1236, push 934, reminder decline + 824, pop 1341ms; iPad: ATT 1926, push 1580, reminder decline 644, pop 1428ms). A + `setNeedsLayout`/`layoutIfNeeded` nudge in `updateUIView` was tried and never fired (no window + change is visible there) — dropped, not shipped. PM-accepted as out of #1080's scope; tracked in + #1094. + ## Start, ordering, repoll - **Environment injection (P3a disposition, PM-approved):** `.environment(\.bannerSession, bannerSession)` goes on the **`GameRoot(…)` value inside `makeGameApp`, right next to `.environment(\.theme, config.theme)`** (MakeGameApp.swift:374). - It does **not** go on `shellContent` inside `GameRoot.body`. That placement sits inside the chain, with `.fullScreenCover` applied outside it, which is S1's `innerChain` variant. diff --git a/mise-tasks/new_game/scaffold b/mise-tasks/new_game/scaffold index 6adb43a3..85c38970 100755 --- a/mise-tasks/new_game/scaffold +++ b/mise-tasks/new_game/scaffold @@ -267,7 +267,7 @@ let {name}Target = Target.target( destinations: [.iPhone, .iPad, .mac], product: .app, bundleId: "com.wei18.{name}", - deploymentTargets: .multiplatform(iOS: "26.0", macOS: "26.0"), + deploymentTargets: .multiplatform(iOS: "26.1", macOS: "26.0"), infoPlist: .file(path: "App/{NAME}/Info.plist"), sources: ["App/{NAME}/**/*.swift"], resources: [ @@ -295,7 +295,7 @@ let {name}E2ETestsTarget = Target.target( destinations: [.iPhone, .iPad, .mac], product: .uiTests, bundleId: "com.wei18.{name}.e2e", - deploymentTargets: .multiplatform(iOS: "26.0", macOS: "26.0"), + deploymentTargets: .multiplatform(iOS: "26.1", macOS: "26.0"), sources: ["App/{NAME}E2ETests/**/*.swift", "App/UITestsShared/**/*.swift"], dependencies: [.target(name: "{NAME}")], settings: .settings(base: swiftSettings)