From fa19e6de3132966e494659887c9a40ab2b8dd442 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Wed, 30 Sep 2026 03:09:54 -0500 Subject: [PATCH 01/48] ios: log why Vision failed before reading a close-up as no number The reader's catch around VNImageRequestHandler.perform returned .noNumber without a trace. On one Mac's iOS 26.5 simulator Vision throws com.apple.Vision code 9, "Could not create inference context", so Practice replay failed there with nothing in the app log to say why. Log the error on RuntimeLog.engine: domain and code public, Vision's message private, since it is framework free text nobody has vetted. The returned readout is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Runtime/MeterReader/VisionMeterNumberReader.swift | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/ios/HouseScan/Runtime/MeterReader/VisionMeterNumberReader.swift b/ios/HouseScan/Runtime/MeterReader/VisionMeterNumberReader.swift index 6a1c9c495..90dc14b56 100644 --- a/ios/HouseScan/Runtime/MeterReader/VisionMeterNumberReader.swift +++ b/ios/HouseScan/Runtime/MeterReader/VisionMeterNumberReader.swift @@ -2,6 +2,7 @@ import CoreGraphics import Foundation import HouseScanKit import ImageIO +import OSLog import Vision /// Reads the meter number from a close-up with Apple Vision, configured as the close-up eval @@ -46,6 +47,13 @@ struct VisionMeterNumberReader: MeterNumberReader { do { try VNImageRequestHandler(cgImage: image, orientation: orientation).perform([text, barcodes]) } catch { + // The homeowner sees this as "no number", so the log is the only place that says Vision + // itself failed (e.g. com.apple.Vision 9, "Could not create inference context", in one + // Mac's simulator). Domain and code are framework identifiers and stay public. The + // message is Vision's free text, so it stays private: nothing vouches that it never + // names a file or carries content. + let failure = error as NSError + RuntimeLog.engine.error("meter number: Vision failed, reading as no number: \(failure.domain, privacy: .public) \(failure.code, privacy: .public) \(failure.localizedDescription, privacy: .private)") return retakeNoNumber } From 50e10696ec8b6c61a9b8b94928ac95dbffbd9f76 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Wed, 30 Sep 2026 10:01:06 -0500 Subject: [PATCH 02/48] ios: keep AX5 wall-walk actions, aim ring, page dots and badges readable At the largest text sizes: - Wall-walk action pairs stack full-width instead of breaking their labels into one or two letters a line (B-28). - The instruction card ends above the middle of the screen and its words scroll inside it, with the reply pinned. The aim ring shows only where it is wholly in the open camera between the card and the actions, measured from the layout rather than fixed bands (B-36). - Onboarding pages are clipped above an opaque footer, so text no longer runs under the page dots. The illustration's "About 2 min" stops growing past xxxLarge; the page text states the duration. - The result's Replay and sample badges sit above the answer card instead of over the 3D view. UI tests check stacking and button height, the ring against the card, reply and actions, the dots row's pixels, and badge placement. Co-Authored-By: Claude Opus 5.5 (1M context) --- ios/HouseScan/UI/Camera/CameraOverlays.swift | 20 +- .../UI/Camera/WayfindingOverlay.swift | 90 +++++-- .../UI/Components/CameraChrome.swift | 65 ++++- .../UI/Components/InstructionCard.swift | 73 +++++- .../UI/Screens/GapRequestScreen.swift | 7 +- .../UI/Screens/OnboardingScreen.swift | 13 + ios/HouseScan/UI/Screens/ResultScreen.swift | 46 ++-- ios/HouseScan/UI/Screens/WallWalkScreen.swift | 55 ++-- .../ScreenStatesUITests.swift | 245 +++++++++++++++++- 9 files changed, 532 insertions(+), 82 deletions(-) diff --git a/ios/HouseScan/UI/Camera/CameraOverlays.swift b/ios/HouseScan/UI/Camera/CameraOverlays.swift index 10b8fb1c6..79fb8f2e2 100644 --- a/ios/HouseScan/UI/Camera/CameraOverlays.swift +++ b/ios/HouseScan/UI/Camera/CameraOverlays.swift @@ -7,13 +7,19 @@ struct CameraOverlays: View { /// Set to the aim ring's legend while it shows, for the screen to draw under its card /// (`CameraChrome.legend`). Nil on screens with no legend. var cardLegend: Binding? = nil + /// The open camera between the card and the actions (`CameraChrome.cameraWindow`), for the + /// aim ring to stay inside. + var clearArea: CGRect? = nil /// The step the aim ring's legend first showed with. The legend explains the first ring that /// fills and retires once that step ends (#81); it isn't needed on every ring after. @State private var legendStep: GuidanceStep? = nil @State private var legendRetired = false - /// Whether a filling ring is on screen (`WayfindingOverlay.onFillingRingShown`): the legend - /// explains the ring, so it shows only with it. + /// Whether a filling ring has shown during this step (`WayfindingOverlay.onFillingRingShown`): + /// the legend explains the ring, so it waits for one. It then stays for the step even if the + /// ring turns into an arrow. The legend takes room from the open camera the ring must sit + /// in, so a legend that left with the ring could let the ring back, and bring itself back, + /// on every frame. @State private var fillingRingShown = false /// The aim step on screen, with its target, so the update that ends it can still score it. @State private var lastAim: AimSnapshot? = nil @@ -38,6 +44,7 @@ struct CameraOverlays: View { .animation(.easeOut(duration: 0.2), value: state.tracking == .normal) .onChange(of: AimSnapshot(step: state.guidance, target: state.target), initial: true) { old, new in if new.step != old.step { + fillingRingShown = false if let legendStep, new.step != legendStep { legendRetired = true } if let target = completed(old) { completedTarget = target @@ -82,7 +89,8 @@ struct CameraOverlays: View { target: state.target, progress: state.aimProgress, completed: heldTarget, - onFillingRingShown: { shown in fillingRingShown = shown } + onFillingRingShown: { shown in if shown { fillingRingShown = true } }, + clearArea: clearArea ) .transition(.opacity) } @@ -132,13 +140,13 @@ struct CameraOverlays: View { return target } - /// The legend, while the first filling ring is on screen and short of full (#81). + /// The legend, once the first filling ring has shown and while it is short of full (#81). /// /// It goes under the instruction card, in the chrome's own stack, rather than beside the /// ring: beside it, it went behind the card at accessibility text sizes (the card grows /// and is drawn over the camera layers), and any layout that dropped it where there was no - /// room left the people with the largest text without it. In the stack it grows with the - /// card and scrolls with it at any size. + /// room left the people with the largest text without it. At the accessibility sizes it + /// sits inside the card and scrolls with the card's words. private var shownLegend: String? { guard fillingRingShown, !legendRetired, state.marking == nil, heldTarget == nil, let progress = state.aimProgress, progress < 1 else { return nil } diff --git a/ios/HouseScan/UI/Camera/WayfindingOverlay.swift b/ios/HouseScan/UI/Camera/WayfindingOverlay.swift index 129093187..f4d0024fe 100644 --- a/ios/HouseScan/UI/Camera/WayfindingOverlay.swift +++ b/ios/HouseScan/UI/Camera/WayfindingOverlay.swift @@ -19,12 +19,17 @@ struct WayfindingOverlay: View { /// while it is on screen (`CameraOverlays` holds it for a moment). var completed: SIMD3? = nil /// Called with whether a filling ring is on screen, each time that changes (`CameraOverlays` - /// shows the ring's legend under the card only while it is). + /// shows the ring's legend once one has). var onFillingRingShown: ((Bool) -> Void)? = nil + /// The open camera between the instruction card and the actions, in global coordinates + /// (`CameraChrome.cameraWindow`). The ring shows only inside it and the edge arrows keep to + /// it. Nil until the chrome has been laid out, when fixed bands stand in. + var clearArea: CGRect? = nil var body: some View { GeometryReader { proxy in let size = proxy.size + let band = verticalBand(origin: proxy.frame(in: .global).origin) ZStack { Canvas { context, size in drawPath(in: &context, size: size) @@ -32,7 +37,7 @@ struct WayfindingOverlay: View { .accessibilityHidden(true) // One marker either way, so a ring that completes keeps its identity and animates // from its last fill to green. - if let shown = marker(in: size) { + if let shown = marker(in: size, band: band) { TargetMarker(placement: shown.placement, progress: shown.progress) if progress != nil, completed == nil, case .onScreen = shown.placement { Color.clear @@ -50,10 +55,19 @@ struct WayfindingOverlay: View { // MARK: Lane - /// The band clear of the instruction card above and the buttons and map below, which are - /// drawn over this layer. The off-screen arrows keep to it. - private static let laneTop: CGFloat = 260 - private static let laneBottomInset: CGFloat = 300 + /// The top and bottom of the camera left open by the instruction card above and the + /// buttons and map below, which are drawn over this layer, in this view's coordinates. + struct Band: Equatable { + var top: CGFloat + var bottom: CGFloat + } + + /// How far inside the band the edge arrows sit: half the arrow's 52 pt disc and a margin. + static let arrowInset: CGFloat = 32 + + private func verticalBand(origin: CGPoint) -> Band? { + clearArea.map { Band(top: $0.minY - origin.y, bottom: $0.maxY - origin.y) } + } // MARK: Path @@ -93,42 +107,64 @@ struct WayfindingOverlay: View { // MARK: Target /// The completed ring while it is on screen, else the target's ring or arrow. - private func marker(in size: CGSize) -> (placement: TargetMarker.Placement, progress: Double?)? { + private func marker(in size: CGSize, band: Band?) -> (placement: TargetMarker.Placement, progress: Double?)? { if let completed { - let held = placement(for: completed, in: size) + let held = placement(for: completed, in: size, band: band) if case .onScreen = held { return (placement: held, progress: 1.0) } } guard let target else { return nil } - return (placement: placement(for: target, in: size), progress: progress) + return (placement: placement(for: target, in: size, band: band), progress: progress) } - private func placement(for target: SIMD3, in size: CGSize) -> TargetMarker.Placement { - // The ring shows while its center is comfortably on screen; the chevron takes over - // near the edges, where a half-visible ring would be ambiguous. - let bounds = CGRect(x: 36, y: 150, width: size.width - 72, height: size.height - 330) - if let point = projection.viewPoint(for: target, in: size), bounds.contains(point) { + private func placement(for target: SIMD3, in size: CGSize, band: Band?) -> TargetMarker.Placement { + if let point = projection.viewPoint(for: target, in: size) { let scale = wall.flatMap { WallProjection(projection: projection, wall: $0, size: size).pointsPerMeter(at: target) } let radius = min(64, max(30, (scale ?? 90) * 0.28)) - return .onScreen(point, radius: radius) + if Self.ringBounds(in: size, band: band, radius: radius).contains(point) { + return .onScreen(point, radius: radius) + } } guard let direction = projection.screenDirection(toward: target) else { return .hidden } - // Arrows keep to the lane, clear of the card and the controls. - let lane = CGRect( - x: 40, - y: Self.laneTop, - width: size.width - 80, - height: max(size.height - Self.laneTop - Self.laneBottomInset, 80) + return .offScreen(Self.arrowPoint(toward: direction, in: size, band: band), angle: .radians(atan2(direction.dy, direction.dx))) + } + + /// Where a ring's centre may be for the ring to show: comfortably on screen, and with the + /// whole ring in the open camera. Near the edges or partly under the card, the arrow takes + /// over, since a half-visible ring would be ambiguous and one under the card can't be seen + /// at all. Without a band, the fixed rectangle used before the chrome reported one, sized + /// for the default text. + static func ringBounds(in size: CGSize, band: Band?, radius: CGFloat) -> CGRect { + guard let band else { + return CGRect(x: 36, y: 150, width: size.width - 72, height: size.height - 330) + } + return CGRect( + x: 36, + y: band.top + radius, + width: max(size.width - 72, 0), + height: max(band.bottom - band.top - 2 * radius, 0) ) + } + + /// The edge arrow's centre: where a line from the middle of the lane toward the target + /// leaves the lane. The lane is the band inset by `arrowInset`; a band too short for the + /// arrow puts it on the band's middle line, which still keeps it off the card. Without a + /// band, the fixed lane used before the chrome reported one. + static func arrowPoint(toward direction: CGVector, in size: CGSize, band: Band?) -> CGPoint { + let lane: CGRect + if let band { + let top = band.top + arrowInset + let bottom = max(band.bottom - arrowInset, top) + lane = CGRect(x: 40, y: top, width: max(size.width - 80, 0), height: bottom - top) + } else { + lane = CGRect(x: 40, y: 260, width: size.width - 80, height: max(size.height - 560, 80)) + } let center = CGPoint(x: lane.midX, y: lane.midY) - let halfWidth = lane.width / 2 - let halfHeight = lane.height / 2 - let tx = direction.dx == 0 ? CGFloat.infinity : halfWidth / abs(direction.dx) - let ty = direction.dy == 0 ? CGFloat.infinity : halfHeight / abs(direction.dy) + let tx = direction.dx == 0 ? CGFloat.infinity : (lane.width / 2) / abs(direction.dx) + let ty = direction.dy == 0 ? CGFloat.infinity : (lane.height / 2) / abs(direction.dy) let t = min(tx, ty) - let edge = CGPoint(x: center.x + direction.dx * t, y: center.y + direction.dy * t) - return .offScreen(edge, angle: .radians(atan2(direction.dy, direction.dx))) + return CGPoint(x: center.x + direction.dx * t, y: center.y + direction.dy * t) } } diff --git a/ios/HouseScan/UI/Components/CameraChrome.swift b/ios/HouseScan/UI/Components/CameraChrome.swift index 003cbb99a..a961f426d 100644 --- a/ios/HouseScan/UI/Components/CameraChrome.swift +++ b/ios/HouseScan/UI/Components/CameraChrome.swift @@ -16,11 +16,21 @@ struct CameraChrome: View { /// the window's global space). Nil when the screen has nothing to tap. var onCameraTap: ((CGPoint) -> Void)? /// A line under the card about something drawn on the camera: the aim ring's legend - /// (`CameraOverlays`). It is part of the chrome's stack, so it grows with the card and - /// scrolls with it at the largest text sizes, where the card covers most of the camera. + /// (`CameraOverlays`). At the accessibility sizes it goes inside the card instead and + /// scrolls with the words there (`InstructionCard.legend`). var legend: String? = nil + /// Set to the open camera between the card and the actions, in global coordinates, for + /// the aim ring to stay inside (`WayfindingOverlay.clearArea`). It moves when the chrome + /// scrolls. + var cameraWindow: Binding? = nil @ViewBuilder var bottom: Bottom + @Environment(\.dynamicTypeSize) private var typeSize + + private var capsWords: Bool { typeSize.isAccessibilitySize } + /// The status row's height, for the card's budget. + @State private var statusRowHeight: CGFloat = 0 + var body: some View { // One layout at every text size: the chrome fills the screen and, only when the largest // text makes it taller than the screen, scrolls instead of squeezing the words. The @@ -28,7 +38,7 @@ struct CameraChrome: View { // it, behind the chrome. GeometryReader { proxy in ScrollView { - stack + stack(cardMaxHeight: capsWords ? cardMaxHeight(proxy) : nil) .frame(width: proxy.size.width) .frame(minHeight: proxy.size.height) .background { @@ -45,8 +55,16 @@ struct CameraChrome: View { } } - private var stack: some View { - VStack(spacing: 10) { + /// The tallest the card may be for it to end `windowHalfHeight` above the middle of the + /// screen, measured from the top of this view, which starts under the status bar. + private func cardMaxHeight(_ proxy: GeometryProxy) -> CGFloat { + let screenMiddle = (proxy.size.height + proxy.safeAreaInsets.top + proxy.safeAreaInsets.bottom) / 2 - proxy.safeAreaInsets.top + let above = CameraChromeLayout.stackTopPadding + statusRowHeight + CameraChromeLayout.stackSpacing + return max(CameraChromeLayout.minCardHeight, (screenMiddle - CameraChromeLayout.windowHalfHeight - above).rounded(.down)) + } + + private func stack(cardMaxHeight: CGFloat?) -> some View { + VStack(spacing: CameraChromeLayout.stackSpacing) { HStack(alignment: .center) { ModeBadge(isReplay: isReplay, isAutopilot: isAutopilot) Spacer(minLength: 8) @@ -55,16 +73,29 @@ struct CameraChrome: View { } } .frame(minHeight: 36) - InstructionCard(instruction: instruction, tone: tone, reply: reply, eyebrow: eyebrow) - if let legend { + .onGeometryChange(for: CGFloat.self, of: \.size.height) { statusRowHeight = $0 } + InstructionCard( + instruction: instruction, + tone: tone, + reply: reply, + eyebrow: eyebrow, + // Capped, the words scroll and the legend scrolls with them; a separate legend + // line at these sizes ran to seven lines and took the camera's place. + legend: capsWords ? legend : nil, + maxHeight: cardMaxHeight + ) + if let legend, !capsWords { legendLine(legend) } - Spacer(minLength: 0) + Spacer(minLength: capsWords ? 2 * CameraChromeLayout.windowHalfHeight : 0) + .onGeometryChange(for: CGRect.self, of: { $0.frame(in: .global) }) { window in + if let cameraWindow, cameraWindow.wrappedValue != window { cameraWindow.wrappedValue = window } + } bottom } .animation(.easeOut(duration: 0.2), value: legend) .padding(.horizontal, Metrics.edge) - .padding(.top, 4) + .padding(.top, CameraChromeLayout.stackTopPadding) .padding(.bottom, 8) } @@ -85,6 +116,22 @@ struct CameraChrome: View { } } +/// `CameraChrome`'s layout constants, outside it because a generic type can't hold stored +/// static properties. +enum CameraChromeLayout { + /// Half the open camera the chrome keeps around the middle of the screen at the + /// accessibility sizes, where the reticle sits and where a target the phone points at + /// lands. Room for a 64 pt aim ring with a margin. Uncapped, one instruction at AX5 filled + /// an iPhone 17's screen and hid the ring under it (B-36). A layout choice, not measured on + /// a phone. + static let windowHalfHeight: CGFloat = 90 + /// The shortest the card gets to keep that window: about two lines of the instruction at + /// AX5 and its reply. On a small screen the window gives way first. + static let minCardHeight: CGFloat = 200 + static let stackSpacing: CGFloat = 10 + static let stackTopPadding: CGFloat = 4 +} + /// Reads the full-screen camera view size so buttons can send "the reticle" (nil point) /// with the right `viewSize`. struct CameraSizeReader: View { diff --git a/ios/HouseScan/UI/Components/InstructionCard.swift b/ios/HouseScan/UI/Components/InstructionCard.swift index 9a3773c21..70e264717 100644 --- a/ios/HouseScan/UI/Components/InstructionCard.swift +++ b/ios/HouseScan/UI/Components/InstructionCard.swift @@ -34,6 +34,18 @@ struct InstructionCard: View { /// A short line above the instruction that places it in the flow ("One more view to /// finish"). Read with the instruction as one VoiceOver element. var eyebrow: String? + /// The aim ring's legend, drawn in the card under the words. `CameraChrome` puts it here + /// only when it caps the card (`maxHeight`); otherwise the legend is its own line under + /// the card. + var legend: String? = nil + /// The tallest the card may be. Past it, the words (and `legend`) scroll inside the card + /// and the reply stays put under them. Nil: the card is as tall as its words. + /// `CameraChrome` sets it at the accessibility text sizes, where uncapped words covered the + /// whole camera and the aim ring under them (B-36). + var maxHeight: CGFloat? = nil + + /// The least the words keep when `maxHeight` is small, so a line and a half still shows. + private static let minWordsHeight: CGFloat = 96 /// How long a new card's reply ignores taps, so a tap meant for the card that just left /// can't answer the next one: in the 4.1 field test, quick taps on "Can't get there" each @@ -45,14 +57,36 @@ struct InstructionCard: View { /// The task whose reply has been on screen for `replyLock`. Compared with the current task /// in the same update that changes it, so a new reply is locked from its first frame. @State private var unlockedTask: Instruction? + /// The capped words' full height, measured inside their scroll view. + @State private var wordsHeight: CGFloat? + /// The reply's height with its padding, taken from `maxHeight` to cap the words. + @State private var replyHeight: CGFloat = 0 + + private var wordsCap: CGFloat? { + maxHeight.map { max(Self.minWordsHeight, $0 - (reply == nil ? 0 : replyHeight)) } + } private var replyTask: Instruction { reply?.task ?? instruction } var body: some View { VStack(alignment: .trailing, spacing: 0) { - message - .id(instruction) - .transition(.identity) + if let wordsCap { + // Measured rather than proposed: the chrome's own scroll view proposes no + // height, so a flexible frame would clip the words instead of scrolling them. + // Until the first measurement the card takes the cap. + ScrollView { + words + .onGeometryChange(for: CGFloat.self, of: \.size.height) { wordsHeight = $0 } + } + .frame(height: min(wordsHeight ?? wordsCap, wordsCap)) + .scrollBounceBehavior(.basedOnSize) + .scrollIndicators(.visible) + // A line cut off at the bottom is the only other sign there is more to read. + .scrollIndicatorsFlash(onAppear: true) + .accessibilityIdentifier("instruction.scroll") + } else { + words + } if let reply { Button(reply.title, action: reply.perform) .font(Typeface.caption.weight(.bold)) @@ -69,6 +103,7 @@ struct InstructionCard: View { .accessibilityIdentifier(reply.identifier) .padding([.horizontal, .bottom], 12) .padding(.top, -4) + .onGeometryChange(for: CGFloat.self, of: \.size.height) { replyHeight = $0 } // Appears and goes at once, like the message above it: the walk's request can // change every few seconds, and a fading button spends those moments as faint // text on the card, which the accessibility audit caught twice on CI (runs @@ -101,6 +136,38 @@ struct InstructionCard: View { } } + private var words: some View { + VStack(alignment: .leading, spacing: 0) { + message + .id(instruction) + .transition(.identity) + if let legend { + legendLine(legend) + } + } + } + + /// The legend inside the card: set off from the words by a rule and the ring's symbol, in + /// the words' own colours so it is as legible as they are. + private func legendLine(_ text: String) -> some View { + VStack(alignment: .leading, spacing: 12) { + Rectangle() + .fill(Palette.chalk.opacity(0.2)) + .frame(height: 1) + .accessibilityHidden(true) + Text("\(Image(systemName: "scope")) \(text)") + .font(Typeface.hint) + .foregroundStyle(Palette.chalk) + .fixedSize(horizontal: false, vertical: true) + .accessibilityLabel(text) + .accessibilityIdentifier("aim.legend") + } + .padding(.horizontal, 18) + .padding(.bottom, 16) + .frame(maxWidth: .infinity, alignment: .leading) + .transition(.opacity) + } + private var message: some View { HStack(alignment: .firstTextBaseline, spacing: 12) { if let symbol = iconName { diff --git a/ios/HouseScan/UI/Screens/GapRequestScreen.swift b/ios/HouseScan/UI/Screens/GapRequestScreen.swift index 29fcdcd74..022298703 100644 --- a/ios/HouseScan/UI/Screens/GapRequestScreen.swift +++ b/ios/HouseScan/UI/Screens/GapRequestScreen.swift @@ -13,13 +13,15 @@ struct GapRequestScreen: View { let actions: any ScanActions @State private var cameraSize: CGSize = .zero + /// The open camera between the card and the actions, for the aim ring (`CameraChrome`). + @State private var cameraWindow: CGRect? @Environment(\.accessibilityReduceMotion) private var reduceMotion @Environment(\.dynamicTypeSize) private var typeSize var body: some View { ZStack { CameraSizeReader(size: $cameraSize) - CameraOverlays(state: state, highlight: state.gap) + CameraOverlays(state: state, highlight: state.gap, clearArea: cameraWindow) if state.gap?.isSatisfied == true { SuccessBadge() .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -42,7 +44,8 @@ struct GapRequestScreen: View { photoCount: state.captureCount, lastCaptureID: state.lastCapture?.id, isReplay: state.isReplay, - isAutopilot: state.isAutopilot + isAutopilot: state.isAutopilot, + cameraWindow: $cameraWindow ) { VStack(spacing: 10) { if asking { diff --git a/ios/HouseScan/UI/Screens/OnboardingScreen.swift b/ios/HouseScan/UI/Screens/OnboardingScreen.swift index e7d2e5cf8..62776a004 100644 --- a/ios/HouseScan/UI/Screens/OnboardingScreen.swift +++ b/ios/HouseScan/UI/Screens/OnboardingScreen.swift @@ -78,6 +78,9 @@ struct OnboardingScreen: View { } } .tabViewStyle(.page(indexDisplayMode: .never)) + // At AX5 a scrolled page's text showed under the page dots (screenshots at e7031990). + // Clipped here and covered by the footer's background below, a page can't draw there. + .clipped() VStack(spacing: 16) { PageDots(count: pages.count, current: page) @@ -110,7 +113,13 @@ struct OnboardingScreen: View { } .animation(Motion.screen, value: page) .padding(.horizontal, 24) + // Space between the last line of a scrolled page and the dots. + .padding(.top, 12) .padding(.bottom, 12) + // Opaque, so nothing drawn past the page view's edge shows under the dots. + .background(Palette.canvas) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("onboarding.footer") } .background(Palette.canvas.ignoresSafeArea()) } @@ -341,6 +350,10 @@ private struct WalkArt: View { .position(x: size.width * (0.14 + 0.72 * phase), y: size.height * 0.72) Text("About 2 min") .font(Typeface.caption) + // The drawing is a fixed 260 pt and hidden from VoiceOver, and the page's + // first sentence says "about 2 minutes" at full size. Past this size the + // caption covered the meter it is drawn beside. + .dynamicTypeSize(...DynamicTypeSize.xxxLarge) .foregroundStyle(.white) .padding(.horizontal, 10) .padding(.vertical, 5) diff --git a/ios/HouseScan/UI/Screens/ResultScreen.swift b/ios/HouseScan/UI/Screens/ResultScreen.swift index 12b72c262..30f751acd 100644 --- a/ios/HouseScan/UI/Screens/ResultScreen.swift +++ b/ios/HouseScan/UI/Screens/ResultScreen.swift @@ -10,6 +10,7 @@ struct ResultScreen: View { @State private var revealed = false @State private var detailsExpanded = false + @Environment(\.dynamicTypeSize) private var typeSize var body: some View { if let result = state.result { @@ -31,6 +32,9 @@ struct ResultScreen: View { VStack(alignment: .leading, spacing: 0) { diorama(result) VStack(alignment: .leading, spacing: 20) { + if typeSize.isAccessibilitySize { + badges(result) + } AnswerCard(result: result, sourceAvailable: state.spatialResultAvailable, revealed: revealed, actions: actions) if result.spot != nil, state.spotCheck?.answer == .somethingThere { Notice(symbol: "exclamationmark.triangle.fill", text: ScanCopy.spotRefused) @@ -68,30 +72,38 @@ struct ResultScreen: View { } else { Palette.canvas } - VStack(alignment: .leading, spacing: 6) { - ModeBadge(isReplay: state.isReplay, isAutopilot: state.isAutopilot) - if result.isSample { - // The same quiet pill as ModeBadge: the answer, not the test mode, is the - // loudest thing on this screen. - // One Text with the flask inline, built as ModeBadge is. As a Label with - // fixedSize, the audit reported its Dynamic Type as partially unsupported. - Text("\(Image(systemName: "flask.fill")) Sample result, not from the server") - .font(.caption2.weight(.medium)) - .foregroundStyle(Palette.chalk) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(Palette.ink.opacity(0.7), in: .capsule) - .accessibilityLabel("Sample result, not from the server") - .accessibilityIdentifier("result.sampleBadge") - } + if !typeSize.isAccessibilitySize { + badges(result) + .padding(14) } - .padding(14) } .frame(height: 340) .clipShape(.rect(bottomLeadingRadius: 28, bottomTrailingRadius: 28, style: .continuous)) .ignoresSafeArea(edges: .top) } + /// Over the model at the default sizes. At the accessibility sizes the two pills covered + /// most of it, so they sit above the answer card instead. + private func badges(_ result: ResultPresentation) -> some View { + VStack(alignment: .leading, spacing: 6) { + ModeBadge(isReplay: state.isReplay, isAutopilot: state.isAutopilot) + if result.isSample { + // The same quiet pill as ModeBadge: the answer, not the test mode, is the + // loudest thing on this screen. + // One Text with the flask inline, built as ModeBadge is. As a Label with + // fixedSize, the audit reported its Dynamic Type as partially unsupported. + Text("\(Image(systemName: "flask.fill")) Sample result, not from the server") + .font(.caption2.weight(.medium)) + .foregroundStyle(Palette.chalk) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Palette.ink.opacity(0.7), in: .capsule) + .accessibilityLabel("Sample result, not from the server") + .accessibilityIdentifier("result.sampleBadge") + } + } + } + /// Plain small print, never boxes: none of it changes what the homeowner does next. private func footnotes(_ result: ResultPresentation) -> some View { VStack(alignment: .leading, spacing: 6) { diff --git a/ios/HouseScan/UI/Screens/WallWalkScreen.swift b/ios/HouseScan/UI/Screens/WallWalkScreen.swift index 0d3eb55a0..33e2f0e6a 100644 --- a/ios/HouseScan/UI/Screens/WallWalkScreen.swift +++ b/ios/HouseScan/UI/Screens/WallWalkScreen.swift @@ -12,6 +12,8 @@ struct WallWalkScreen: View { let actions: any ScanActions @State private var cameraSize: CGSize = .zero + /// The open camera between the card and the actions, for the aim ring (`CameraChrome`). + @State private var cameraWindow: CGRect? @State private var trayOpen = false @State private var taps: [TapRipple.Ripple] = [] /// The aim ring's legend while it shows, drawn under the card (#81). @@ -22,7 +24,7 @@ struct WallWalkScreen: View { var body: some View { ZStack { CameraSizeReader(size: $cameraSize) - CameraOverlays(state: state, highlight: nil, cardLegend: $cardLegend) + CameraOverlays(state: state, highlight: nil, cardLegend: $cardLegend, clearArea: cameraWindow) if state.coaching == .relocalizing, let meterPhoto { // "Point at the meter like this.": the saved close-up shows what to aim at. SavedMeterPhoto(image: meterPhoto) @@ -48,7 +50,8 @@ struct WallWalkScreen: View { taps.append(.init(point: point)) actions.markFeaturePoint(at: point, viewSize: cameraSize) }, - legend: cardLegend + legend: cardLegend, + cameraWindow: $cameraWindow ) { VStack(spacing: 10) { controls @@ -165,10 +168,15 @@ struct WallWalkScreen: View { private var controls: some View { switch controlsKey { case .marking: - HStack(spacing: 10) { - Button("Cancel") { actions.cancelMarking() } - .buttonStyle(.secondaryProminent) - .accessibilityIdentifier("action.cancelMarking") + actionRow { + Button { + actions.cancelMarking() + } label: { + Text("Cancel") + .frame(maxWidth: stacksActions ? .infinity : nil) + } + .buttonStyle(.secondaryProminent) + .accessibilityIdentifier("action.cancelMarking") Button { taps.append(.init(point: CGPoint(x: cameraSize.width / 2, y: cameraSize.height / 2))) actions.markFeaturePoint(at: nil, viewSize: cameraSize) @@ -278,13 +286,12 @@ struct WallWalkScreen: View { .transition(.opacity) case .nextWall: // Back asks about the end again (#70); marking something waits for the walk. - let layout = typeSize.isAccessibilitySize ? AnyLayout(VStackLayout(spacing: 8)) : AnyLayout(HStackLayout(spacing: 10)) - layout { + actionRow { Button { actions.cancelNextWall() } label: { Label("Back", systemImage: "chevron.backward") - .frame(maxWidth: typeSize.isAccessibilitySize ? .infinity : nil) + .frame(maxWidth: stacksActions ? .infinity : nil) } .buttonStyle(.secondaryProminent) .accessibilityHint("Asks again what's at this end of the wall") @@ -309,13 +316,16 @@ struct WallWalkScreen: View { case .markEnd, .finish, .walking: // One row for all three, so "Mark something" stays the same view while the button // beside it changes. Rebuilt per case, it crossfaded out as a frozen copy that the - // accessibility audit reported as not following Dynamic Type. - HStack(spacing: 10) { + // accessibility audit reported as not following Dynamic Type. `actionRow` swaps + // the row for a stack at accessibility sizes without rebuilding it. + actionRow { // While the walk asks to look past an obstruction, that step has one way out // ("Can't see past it" in the card), so "Mark something" steps aside without - // leaving the row: it keeps its place and stays the same view. + // leaving the row: it keeps its place and stays the same view. Stacked, an + // empty full-width slot would push the map up, so it also gives up its height. markSomethingButton .opacity(isSeeingBehind ? 0 : 1) + .frame(height: isSeeingBehind && stacksActions ? 0 : nil) .allowsHitTesting(!isSeeingBehind) .accessibilityHidden(isSeeingBehind) switch controlsKey { @@ -324,6 +334,7 @@ struct WallWalkScreen: View { actions.markWallEnd(at: nil, viewSize: cameraSize) } label: { Label("Wall ends here", systemImage: "flag.fill") + .frame(maxWidth: stacksActions ? .infinity : nil) } .buttonStyle(.primary) .accessibilityHint("Marks the end of the wall at the circle in the middle of the screen") @@ -334,7 +345,7 @@ struct WallWalkScreen: View { actions.endWallHere() } label: { Label("Wall ends here", systemImage: "flag") - .frame(maxWidth: typeSize.isAccessibilitySize ? .infinity : nil) + .frame(maxWidth: stacksActions ? .infinity : nil) } .buttonStyle(.secondaryProminent) .accessibilityHint("Ends the wall where you're standing, at the dashed line on the map") @@ -345,18 +356,32 @@ struct WallWalkScreen: View { actions.finishWalk() } label: { Label("Done with this wall", systemImage: "checkmark") + .frame(maxWidth: stacksActions ? .infinity : nil) } .buttonStyle(.primary) .accessibilityIdentifier("action.finishWalk") .transition(.opacity) default: - Spacer(minLength: 0) + // Keeps "Mark something" to its own width in the row; stacked, it already + // spans the screen and a spacer would only add height. + if !stacksActions { Spacer(minLength: 0) } } } .transition(.opacity) } } + /// Two actions side by side while their words fit on a line, one above the other at the + /// accessibility text sizes, where a side-by-side pair broke "Mark something" and "Wall + /// ends here" into one or two letters a line (B-28). `AnyLayout` keeps each button the + /// same view when the size changes, so nothing is rebuilt or crossfaded. + private var stacksActions: Bool { typeSize.isAccessibilitySize } + + private func actionRow(@ViewBuilder _ content: () -> Content) -> some View { + let layout = stacksActions ? AnyLayout(VStackLayout(spacing: 8)) : AnyLayout(HStackLayout(spacing: 10)) + return layout(content) + } + /// The capture gate's coaching (slow down, texture, light, hold steady) comes and goes within /// a second while the homeowner walks, and the task under it stays the same, so the card's /// reply stays put through it: hiding it each time made the button fade in and out under the @@ -410,7 +435,7 @@ struct WallWalkScreen: View { } label: { Label("Mark something", systemImage: "mappin.and.ellipse") .labelStyle(.titleAndIcon) - .frame(maxWidth: typeSize.isAccessibilitySize ? .infinity : nil) + .frame(maxWidth: stacksActions ? .infinity : nil) } .buttonStyle(.secondaryProminent) .accessibilityHint("Pin a gas meter, door, window, AC unit, driveway or fence") diff --git a/ios/HouseScanUITests/ScreenStatesUITests.swift b/ios/HouseScanUITests/ScreenStatesUITests.swift index 390206b27..70dbc474a 100644 --- a/ios/HouseScanUITests/ScreenStatesUITests.swift +++ b/ios/HouseScanUITests/ScreenStatesUITests.swift @@ -1,3 +1,4 @@ +import UIKit import XCTest /// Every screen state, including ones a replay never reaches (camera denied, offline upload, @@ -626,9 +627,8 @@ final class ScreenStatesUITests: XCTestCase { XCTAssertFalse(legend.frame.intersects(card.frame), "the legend must keep clear of the card: \(legend.frame) vs \(card.frame)") app.terminate() - // At the largest text size the card fills most of the screen. The legend sits under it in - // the same stack, so it grows and scrolls with the card instead of going behind it or - // disappearing. + // At the largest text size the card's words scroll inside it, and the legend scrolls with + // them in the card instead of going behind it or disappearing. app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoFreeze", "-uiDemoPhase", "wallWalk", "-uiDemoAim"] + Self.largestText app.launch() XCTAssertTrue(element(app, "screen.wallWalk").waitForExistence(timeout: 15)) @@ -646,6 +646,189 @@ final class ScreenStatesUITests: XCTestCase { XCTAssertFalse(element(app, "aim.legend").exists, "the legend goes with the ring") } + // MARK: Largest text layout + + /// The content width inside a camera screen's side margins (`Metrics.edge`, 16 pt), less a + /// point for rounding. + private static func contentWidth(_ window: CGRect) -> CGFloat { window.width - 2 * 16 - 1 } + + /// B-28: side by side at AX5, "Mark something" and "Wall ends here" broke into one or two + /// letters a line. Every pair of wall-walk actions must stack there, each spanning the + /// content width. A button taller than a quarter of the screen is a label broken a letter a + /// line; a stacked two-line label is about 130 pt on an iPhone 17. At the default size the + /// walk's pair stays on one row. + @MainActor + func testWallWalkActionsStackAtLargestTextSize() throws { + continueAfterFailure = false + let app = XCUIApplication() + func launch(_ extra: [String], textSize: [String] = Self.largestText) { + app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoFreeze", "-uiDemoPhase", "wallWalk"] + extra + textSize + app.launch() + XCTAssertTrue(element(app, "screen.wallWalk").waitForExistence(timeout: 15)) + } + + launch([], textSize: []) + let mark = element(app, "action.markSomething"), end = element(app, "action.endHere") + XCTAssertTrue(mark.waitForExistence(timeout: 5) && end.waitForExistence(timeout: 5)) + XCTAssertEqual(mark.frame.midY, end.frame.midY, accuracy: 1, "at the default size the walk's two actions share a row") + app.terminate() + + launch([]) + assertStacked(app, "action.markSomething", "action.endHere", name: "wallWalk-walking-AX5-stacked") + // The walk's reply ends the side here, which asks for the wall's end. + scrollToTop(app) + tapReply(app, "Can't get there") + assertStacked(app, "action.markSomething", "action.markEnd", name: "wallWalk-markEnd-AX5-stacked") + app.terminate() + + launch(["-uiDemoTiltUp"]) + assertStacked(app, "action.markSomething", "action.finishWalk", name: "wallWalk-finish-AX5-stacked") + app.terminate() + + launch(["-uiDemoMarking", "window"]) + assertStacked(app, "action.cancelMarking", "action.markPoint", name: "wallWalk-marking-AX5-stacked") + app.terminate() + } + + @MainActor + private func assertStacked(_ app: XCUIApplication, _ upper: String, _ lower: String, name: String) { + let window = app.windows.firstMatch.frame + let first = element(app, upper), second = element(app, lower) + XCTAssertTrue(first.waitForExistence(timeout: 5), "\(name): missing \(upper)") + XCTAssertTrue(second.waitForExistence(timeout: 5), "\(name): missing \(lower)") + for (identifier, target) in [(upper, first), (lower, second)] { + let frame = target.frame + XCTAssertGreaterThanOrEqual(frame.width, Self.contentWidth(window), "\(name): \(identifier) is \(frame.width) pt wide in a \(window.width) pt window; beside another action its words break apart") + XCTAssertLessThanOrEqual(frame.height, window.height / 4, "\(name): \(identifier) is \(frame.height) pt tall; its label breaks a letter or two a line") + } + XCTAssertLessThanOrEqual(first.frame.maxY, second.frame.minY + 0.5, "\(name): \(upper) \(first.frame) must sit above \(lower) \(second.frame)") + XCTAssertTrue(scrollUntilHittable(second, in: app), "\(name): \(lower) can't be reached") + attach(app, name: name) + } + + /// Drags the screen down until its top shows, for a control that scrolled out of view. + @MainActor + private func scrollToTop(_ app: XCUIApplication) { + let step = app.windows.firstMatch.frame.height * 0.4 + for _ in 0..<3 { + let start = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.6)) + start.press(forDuration: 0.1, thenDragTo: start.withOffset(CGVector(dx: 0, dy: step)), withVelocity: .slow, thenHoldForDuration: 0.3) + } + } + + /// B-36: at AX5 the aim card filled the screen and the ring was drawn under it. The card now + /// ends above the middle of the screen with its words scrolling inside it, the actions start + /// below the middle, and the ring shows in the open camera between them without touching + /// the words, the reply or an action. + @MainActor + func testAimRingStaysInTheOpenCameraAtLargestTextSize() throws { + continueAfterFailure = false + let app = XCUIApplication() + app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoFreeze", "-uiDemoPhase", "wallWalk", "-uiDemoAim"] + Self.largestText + app.launch() + defer { app.terminate() } + XCTAssertTrue(element(app, "screen.wallWalk").waitForExistence(timeout: 15)) + let window = app.windows.firstMatch.frame + let ring = element(app, "aim.ring") + let words = element(app, "instruction.scroll") + let reply = element(app, "action.cannotAccess") + let mark = element(app, "action.markSomething") + XCTAssertTrue(words.waitForExistence(timeout: 5), "at AX5 the card's words scroll inside it") + XCTAssertTrue(reply.waitForExistence(timeout: 5)) + XCTAssertTrue(mark.waitForExistence(timeout: 5)) + XCTAssertTrue(ring.waitForExistence(timeout: 5), "the aim ring must show in the open camera") + XCTAssertEqual(ring.value as? String, "50 percent captured") + attach(app, name: "wallWalk-aim-AX5-openCamera") + + let cardBottom = max(words.frame.maxY, reply.frame.maxY) + XCTAssertLessThanOrEqual(cardBottom, window.midY - 80, "the card must end above the middle of the screen: \(cardBottom) in \(window)") + XCTAssertGreaterThanOrEqual(mark.frame.minY, window.midY + 80, "the actions must start below the middle of the screen: \(mark.frame)") + XCTAssertTrue(window.contains(ring.frame), "ring \(ring.frame) is not wholly on screen") + for (identifier, frame) in [("instruction.scroll", words.frame), ("action.cannotAccess", reply.frame), ("action.markSomething", mark.frame)] { + XCTAssertFalse(ring.frame.intersects(frame), "ring \(ring.frame) is under \(identifier) \(frame)") + } + // The legend comes with the ring, inside the card, and reads in full there. + let legend = element(app, "aim.legend") + XCTAssertTrue(legend.waitForExistence(timeout: 5), "the first aim ring must come with its legend") + XCTAssertTrue(legend.label.contains("It fills as your phone captures this spot"), "legend reads \(legend.label)") + } + + /// At AX5 a scrolled onboarding page ran on under the page dots. On every page, before and + /// after scrolling it to its end, the row the dots sit in must show only the background + /// beside the dots. Read from the screenshot's pixels: the dots are hidden from + /// accessibility, and a page's frame alone can't show what it paints past its edge. + @MainActor + func testOnboardingTextStaysClearOfThePageDotsAtLargestTextSize() throws { + continueAfterFailure = false + let app = XCUIApplication() + app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoFreeze"] + Self.largestText + app.launch() + defer { app.terminate() } + XCTAssertTrue(element(app, "screen.onboarding").waitForExistence(timeout: 15)) + let footer = element(app, "onboarding.footer") + XCTAssertTrue(footer.waitForExistence(timeout: 5)) + for page in 1...4 { + // Let the page slide settle. + Thread.sleep(forTimeInterval: 0.8) + assertDotsRowClear(app, name: "onboarding-page\(page)-AX5-dots") + for _ in 0..<4 { + let start = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.6)) + start.press(forDuration: 0.1, thenDragTo: start.withOffset(CGVector(dx: 0, dy: -240)), withVelocity: .slow, thenHoldForDuration: 0.2) + } + Thread.sleep(forTimeInterval: 0.5) + assertDotsRowClear(app, name: "onboarding-page\(page)-AX5-dots-scrolled") + if page < 4 { tap(app, "action.onboardingNext") } + } + } + + /// The band from the footer's top edge to the button's, where the dots sit, must be the + /// background colour except for the middle 120 pt, which holds the dots. + @MainActor + private func assertDotsRowClear(_ app: XCUIApplication, name: String) { + let footer = element(app, "onboarding.footer").frame + let button = app.buttons.matching(NSPredicate(format: "identifier IN %@", ["action.onboardingNext", "action.finishOnboarding"])).firstMatch + XCTAssertTrue(button.waitForExistence(timeout: 5), "\(name): missing the footer's button") + let band = CGRect(x: footer.minX, y: footer.minY, width: footer.width, height: button.frame.minY - footer.minY) + XCTAssertGreaterThan(band.height, 8, "\(name): no room for the dots above the button: \(footer) vs \(button.frame)") + let screenshot = app.screenshot() + attach(app, name: name) + guard let pixels = ScreenPixels(screenshot.image) else { + XCTFail("\(name): cannot read the screenshot's pixels") + return + } + if let stray = pixels.firstMismatch(in: band, excludingMiddle: 120) { + XCTFail("\(name): something other than the background is drawn beside the page dots at \(stray) in \(band)") + } + } + + /// At AX5 the Replay and sample-result badges covered most of the result's 3D view. There + /// they sit clear of it, in full on screen; at the default size they stay on the view. + @MainActor + func testResultBadgesKeepOffTheModelAtLargestTextSize() throws { + continueAfterFailure = false + let app = XCUIApplication() + for (textSize, name) in [([String](), "result-badges"), (Self.largestText, "result-badges-AX5")] { + app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoFreeze", "-uiDemoPhase", "result"] + textSize + app.launch() + XCTAssertTrue(element(app, "screen.result").waitForExistence(timeout: 15)) + let window = app.windows.firstMatch.frame + let model = app.descendants(matching: .any).matching(NSPredicate(format: "label == %@", "3D view of your wall")).firstMatch + XCTAssertTrue(model.waitForExistence(timeout: 5), "\(name): missing the 3D view") + for identifier in ["modeBadge", "result.sampleBadge"] { + let badge = element(app, identifier) + XCTAssertTrue(badge.waitForExistence(timeout: 5), "\(name): missing \(identifier)") + XCTAssertTrue(window.contains(badge.frame), "\(name): \(identifier) \(badge.frame) runs off screen") + if textSize.isEmpty { + XCTAssertTrue(model.frame.contains(badge.frame), "\(name): \(identifier) \(badge.frame) must stay on the 3D view \(model.frame)") + } else { + XCTAssertFalse(model.frame.intersects(badge.frame), "\(name): \(identifier) \(badge.frame) covers the 3D view \(model.frame)") + } + } + attach(app, name: name) + app.terminate() + } + } + /// "Share scan" opens the system share sheet with the scan file. @MainActor func testShareScanOpensTheShareSheet() throws { @@ -828,3 +1011,59 @@ final class ScreenStatesUITests: XCTestCase { target.tap() } } + +/// A screenshot's pixels, read in the screen's points. +private struct ScreenPixels { + private let data: [UInt8] + private let width: Int + private let height: Int + private let scale: CGFloat + + init?(_ image: UIImage) { + guard let cgImage = image.cgImage, image.size.width > 0 else { return nil } + let w = cgImage.width, h = cgImage.height + var bytes = [UInt8](repeating: 0, count: w * h * 4) + // Drawn upright into a bitmap context, the first row in memory is the image's top row. + let drawn = bytes.withUnsafeMutableBytes { buffer -> Bool in + guard let context = CGContext( + data: buffer.baseAddress, width: w, height: h, bitsPerComponent: 8, bytesPerRow: w * 4, + space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { return false } + context.draw(cgImage, in: CGRect(x: 0, y: 0, width: w, height: h)) + return true + } + guard drawn else { return nil } + data = bytes + width = w + height = h + scale = CGFloat(w) / image.size.width + } + + private func pixel(_ x: Int, _ y: Int) -> (Int, Int, Int) { + let i = (y * width + x) * 4 + return (Int(data[i]), Int(data[i + 1]), Int(data[i + 2])) + } + + /// The first pixel in `rect`, outside `middle` points around its centre line, that differs + /// from the pixel 2 pt inside the rect's left edge on the same row by more than a rendering + /// tolerance. Nil when every pixel matches. + func firstMismatch(in rect: CGRect, excludingMiddle middle: CGFloat) -> CGPoint? { + let tolerance = 24 + let top = max(Int((rect.minY * scale).rounded(.up)), 0), bottom = min(Int((rect.maxY * scale).rounded(.down)), height) + let left = max(Int((rect.minX * scale).rounded(.up)), 0), right = min(Int((rect.maxX * scale).rounded(.down)), width) + guard top < bottom, left < right else { return nil } + let rows = top.. tolerance || abs(g - background.1) > tolerance || abs(b - background.2) > tolerance { + return CGPoint(x: CGFloat(x) / scale, y: CGFloat(y) / scale) + } + } + } + return nil + } +} From dcaaf7ba217c0f9224181719afdd1176ffa5f5ab Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Wed, 30 Sep 2026 10:14:16 -0500 Subject: [PATCH 03/48] ios: a ground change after a first upload is packaged withdraws its answer and resends once The scene fixes the ground when it is packaged, but ARKit keeps refining the ground while the upload is in flight, and the spot check, 3D preview and AR draw the answer against the current ground. A first upload's answer could therefore show a spot shifted by the ground change. GroundFreshness now treats a packaged first upload like an answer on screen: withdraw and resend once, and fail if the ground changes again before a new answer is shown. A change before packaging still keeps the upload, because the scene is built from the ground as it is then. Co-Authored-By: Claude Opus 5.5 (1M context) --- ios/HouseScan/Runtime/LaunchOptions.swift | 2 +- ios/HouseScan/Runtime/ScanEngine.swift | 9 +++- .../HouseScanKit/Upload/GroundFreshness.swift | 12 ++++-- .../GroundFreshnessTests.swift | 31 ++++++++++++++ .../GroundFreshnessUITests.swift | 42 +++++++++++++++++++ 5 files changed, 89 insertions(+), 7 deletions(-) diff --git a/ios/HouseScan/Runtime/LaunchOptions.swift b/ios/HouseScan/Runtime/LaunchOptions.swift index 403b14fe8..3d659c6ed 100644 --- a/ios/HouseScan/Runtime/LaunchOptions.swift +++ b/ios/HouseScan/Runtime/LaunchOptions.swift @@ -23,7 +23,7 @@ import OSLog /// - `-autopilotSomethingThere`: the autopilot answers the first spot check "Something's there" /// instead of "It's clear", so the scan is checked again without that area. /// - `-injectGroundRise `: with `-replay`, `-autopilot` and `-autopilotGate`, once the -/// first result shows, each time a file named `inject-ground` appears in the gate folder the +/// first upload starts, each time a file named `inject-ground` appears in the gate folder the /// app deletes it and hands the engine a detected floor that many meters above its current /// ground, as ARKit refining the ground would (`ScanEngine.injectGroundForTest`). Replays carry /// no plane evidence, so this is the only way a UI test reaches that path. Meters must be over diff --git a/ios/HouseScan/Runtime/ScanEngine.swift b/ios/HouseScan/Runtime/ScanEngine.swift index d9131a954..449008999 100644 --- a/ios/HouseScan/Runtime/ScanEngine.swift +++ b/ios/HouseScan/Runtime/ScanEngine.swift @@ -190,6 +190,8 @@ final class ScanEngine { // Upload let resultClient: any ResultClient private var uploadTask: Task? + /// The current upload's scene has fixed its ground; keep this true through answer pacing. + private var scenePackaged = false private(set) var placement: PlacementResult? /// Whether a ground change took the answer down and no answer has been shown since /// (`answerAfter(_:)`). @@ -384,7 +386,7 @@ final class ScanEngine { } } - /// The watch behind `-injectGroundRise`, started at the first result of a scan. + /// The watch behind `-injectGroundRise`, started at the first upload of a scan. private var groundInjection: Task? /// With `-injectGroundRise`, feeds the ground refine a detected floor above the current @@ -1674,7 +1676,7 @@ final class ScanEngine { case .resultAR: .resultInCamera default: .noAnswer } - switch groundFreshness.after(change, on: screen) { + switch groundFreshness.after(change, on: screen, scenePackaged: scenePackaged) { case .keep: return case .sendAgain: @@ -1989,7 +1991,9 @@ final class ScanEngine { // MARK: Upload func startUpload() { + scenePackaged = false go(.uploading) + injectGroundForTest() uploadTask?.cancel() uploadTask = Task { await upload() } } @@ -2020,6 +2024,7 @@ final class ScanEngine { let scene: Data do { scene = try sceneJSON(mesh: measured) + scenePackaged = true } catch { RuntimeLog.engine.error("scene.json export failed: \(String(describing: error), privacy: .public)") state.upload = UploadFailure.packaging(error) diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Upload/GroundFreshness.swift b/ios/HouseScanKit/Sources/HouseScanKit/Upload/GroundFreshness.swift index 39667b8cf..9ac8a4df8 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Upload/GroundFreshness.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Upload/GroundFreshness.swift @@ -54,15 +54,19 @@ public struct GroundFreshness: Equatable, Sendable { public init() {} /// The decision for one change on one screen, recording a withdrawal. - public mutating func after(_ change: Change, on screen: Screen) -> Action { + /// `scenePackaged` means the upload's ground is fixed, even while its answer is readied. + /// Before packaging, a first upload still reads the current ground; after it, the answer + /// needs the same withdrawal and single resend as an answer already on screen. + public mutating func after(_ change: Change, on screen: Screen, scenePackaged: Bool = false) -> Action { guard change == .ground else { return .keep } switch screen { case .noAnswer: return .keep case .sending: - // A first upload is built from the scan at the moment it is packaged; only an - // upload that is itself a resend has already used its one try. - return awaitingNewAnswer ? .fail : .keep + if awaitingNewAnswer { return .fail } + guard scenePackaged else { return .keep } + awaitingNewAnswer = true + return .sendAgain case .spotCheck, .result, .resultInCamera: if awaitingNewAnswer { return .fail } awaitingNewAnswer = true diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/GroundFreshnessTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/GroundFreshnessTests.swift index fda9ac6cd..69053ac27 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/GroundFreshnessTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/GroundFreshnessTests.swift @@ -57,6 +57,37 @@ import Testing #expect(!rule.awaitingNewAnswer) } + @Test(arguments: GroundFreshness.Screen.allCases, [false, true]) + func packagingAndAwaitingMatrix(screen: GroundFreshness.Screen, packaged: Bool) { + for awaiting in [false, true] { + for change in GroundFreshness.Change.allCases { + var rule = awaiting ? Self.afterOneWithdrawal() : GroundFreshness() + let expected: GroundFreshness.Action + if change == .anchorCorrection || screen == .noAnswer { + expected = .keep + } else if awaiting { + expected = .fail + } else if screen == .sending && !packaged { + expected = .keep + } else { + expected = .sendAgain + } + #expect(rule.after(change, on: screen, scenePackaged: packaged) == expected) + #expect(rule.awaitingNewAnswer == (awaiting || expected == .sendAgain)) + } + } + } + + @Test func aPackagedFirstUploadUsesOneResendUntilAnAnswerIsShown() { + var rule = GroundFreshness() + #expect(rule.after(.ground, on: .sending, scenePackaged: true) == .sendAgain) + #expect(rule.awaitingNewAnswer) + #expect(rule.after(.ground, on: .sending, scenePackaged: false) == .fail) + #expect(rule.awaitingNewAnswer) + rule.answerShown() + #expect(rule.after(.ground, on: .sending, scenePackaged: true) == .sendAgain) + } + @Test(arguments: answerScreens) func anAnswerShownStartsANewResend(screen: GroundFreshness.Screen) { var rule = Self.afterOneWithdrawal() diff --git a/ios/HouseScanUITests/GroundFreshnessUITests.swift b/ios/HouseScanUITests/GroundFreshnessUITests.swift index c3b99f194..3d21cdb0b 100644 --- a/ios/HouseScanUITests/GroundFreshnessUITests.swift +++ b/ios/HouseScanUITests/GroundFreshnessUITests.swift @@ -87,6 +87,48 @@ final class GroundFreshnessUITests: XCTestCase { XCTAssertTrue(app.buttons["action.retryUpload"].exists) } + @MainActor + func testAGroundRefineAfterFirstPackagingResendsBeforeShowingTheAnswer() throws { + let files = FileManager.default + let gate = URL(fileURLWithPath: NSTemporaryDirectory()).appending(path: "housescan-gate-\(UUID().uuidString)", directoryHint: .isDirectory) + try files.createDirectory(at: gate, withIntermediateDirectories: true) + defer { try? files.removeItem(at: gate) } + // Hold the first answer before it can open a spot check or an automatic server request. + for phase in ["onboarding", "findMeter", "meterCloseUp", "wallWalk", "markFeatures", "gapRequest", "spotConfirm"] { + try Data().write(to: gate.appending(path: phase)) + } + let app = XCUIApplication() + app.launchArguments = [ + "-replay", FullFlowUITests.fixture, "-autopilot", "-autopilotHold", "1.5", "-autopilotGate", gate.path, + "-practiceMeter", "NO", "-sampleResult", "-injectGroundRise", "0.05", + ] + app.launch() + let any = app.descendants(matching: .any) + let result = any["screen.result"] + let spotCheck = any["screen.spotConfirm"] + let uploading = any["screen.uploading"] + let held = gate.appending(path: "uploading.held") + XCTAssertTrue(waitUntil(timeout: 300) { files.fileExists(atPath: held.path) }, "the first packaged upload never held its answer") + XCTAssertTrue(uploading.exists) + XCTAssertFalse(result.exists) + XCTAssertFalse(spotCheck.exists) + + // The first upload is still awaiting presentation. A new held marker can only come + // from another completed upload, not from the first upload's already-waiting gate. + try files.removeItem(at: held) + let trigger = gate.appending(path: "inject-ground") + try Data().write(to: trigger) + XCTAssertTrue(waitUntil(timeout: 10) { !files.fileExists(atPath: trigger.path) }, "the first upload never took the injected ground") + XCTAssertTrue(waitUntil(timeout: 60) { files.fileExists(atPath: held.path) }, "a ground change after first packaging did not resend") + XCTAssertTrue(uploading.exists) + XCTAssertFalse(result.exists, "the withdrawn first answer reached the result") + XCTAssertFalse(spotCheck.exists, "the withdrawn first answer reached the spot check") + XCTAssertFalse(app.buttons["action.retryUpload"].exists, "the first change failed instead of using its one resend") + + try Data().write(to: gate.appending(path: "uploading")) + XCTAssertTrue(result.waitForExistence(timeout: 120), "the resend's answer never reached the result") + } + private func waitUntil(timeout: TimeInterval, _ condition: () -> Bool) -> Bool { let deadline = Date().addingTimeInterval(timeout) repeat { From 1f7e076b1772e0fb2b05c4c5ef96ce9889516508 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Wed, 30 Sep 2026 10:26:18 -0500 Subject: [PATCH 04/48] ios: a ground change on a failed or refused first upload keeps the failure instead of resending dcaaf7ba left the packaged flag set after the request failed or was refused, so a later ground refinement withdrew an answer that was never coming and sent the scan again without a tap, skipping the refusal's review. The upload state now decides: a failed or refused upload has no pending answer, which restores #187's behavior on those screens. Found by a fresh Sol review. Co-Authored-By: Claude Opus 5.5 (1M context) --- ios/HouseScan/Runtime/ScanEngine.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/ios/HouseScan/Runtime/ScanEngine.swift b/ios/HouseScan/Runtime/ScanEngine.swift index 449008999..25c3cae5e 100644 --- a/ios/HouseScan/Runtime/ScanEngine.swift +++ b/ios/HouseScan/Runtime/ScanEngine.swift @@ -1676,7 +1676,13 @@ final class ScanEngine { case .resultAR: .resultInCamera default: .noAnswer } - switch groundFreshness.after(change, on: screen, scenePackaged: scenePackaged) { + // A failed or refused upload has no answer coming, so its scene no longer counts as + // packaged: the failure stays until "Try again", which packages the current ground. + let answerPending = switch state.upload { + case .failed, .rejected: false + case .idle, .packaging, .uploading, .analyzing, .done: scenePackaged + } + switch groundFreshness.after(change, on: screen, scenePackaged: answerPending) { case .keep: return case .sendAgain: From 5484ac3137afcb53ccd72402118a0aac4cbda5cd Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Wed, 30 Sep 2026 10:52:50 -0500 Subject: [PATCH 05/48] ios: lay out the capped card in one pass and fix review findings The AX5 aim state never drew a frame on CI run 36733592843: the card's height came from measured state (words, reply and status-row heights) fed back into its own layout. The status row and card now go through a small Layout that offers the card the height left above the screen's middle, and the card's words use ViewThatFits to scroll only when they don't fit. Nothing measured feeds back into the card. - The open camera's frame lives in an observable object that only the aim overlay reads, so scrolling no longer re-evaluates the screen. - The ring's fit includes its horizontal footprint and pulse; the band is cut to the screen, and a short band puts the arrow on its middle line. - A target drawn under the card or actions gets an arrow pointing to where it is drawn, instead of the optical-axis direction, which can be undefined at the centre. - Default-size action rows are the original HStack again; the Practice replay audit flagged "Done with this wall" as clipped with the AnyLayout row. - Reduce Motion drops the legend-driven resize animation. - Tests scroll the capped words until the legend shows with the reply fixed, and scroll each onboarding page to its end before sampling. Co-Authored-By: Claude Opus 5.5 (1M context) --- ios/HouseScan/UI/Camera/CameraOverlays.swift | 6 +- .../UI/Camera/WayfindingOverlay.swift | 81 ++++++++---- .../UI/Components/CameraChrome.swift | 115 ++++++++++++------ .../UI/Components/InstructionCard.swift | 54 ++++---- .../UI/Screens/GapRequestScreen.swift | 6 +- ios/HouseScan/UI/Screens/WallWalkScreen.swift | 40 ++++-- .../ScreenStatesUITests.swift | 45 +++++-- 7 files changed, 226 insertions(+), 121 deletions(-) diff --git a/ios/HouseScan/UI/Camera/CameraOverlays.swift b/ios/HouseScan/UI/Camera/CameraOverlays.swift index 79fb8f2e2..dbfb13720 100644 --- a/ios/HouseScan/UI/Camera/CameraOverlays.swift +++ b/ios/HouseScan/UI/Camera/CameraOverlays.swift @@ -8,8 +8,8 @@ struct CameraOverlays: View { /// (`CameraChrome.legend`). Nil on screens with no legend. var cardLegend: Binding? = nil /// The open camera between the card and the actions (`CameraChrome.cameraWindow`), for the - /// aim ring to stay inside. - var clearArea: CGRect? = nil + /// aim ring to stay inside. Passed through unread, so its changes don't redraw the fog. + var cameraWindow: CameraWindow? = nil /// The step the aim ring's legend first showed with. The legend explains the first ring that /// fills and retires once that step ends (#81); it isn't needed on every ring after. @@ -90,7 +90,7 @@ struct CameraOverlays: View { progress: state.aimProgress, completed: heldTarget, onFillingRingShown: { shown in if shown { fillingRingShown = true } }, - clearArea: clearArea + cameraWindow: cameraWindow ) .transition(.opacity) } diff --git a/ios/HouseScan/UI/Camera/WayfindingOverlay.swift b/ios/HouseScan/UI/Camera/WayfindingOverlay.swift index f4d0024fe..203280ad4 100644 --- a/ios/HouseScan/UI/Camera/WayfindingOverlay.swift +++ b/ios/HouseScan/UI/Camera/WayfindingOverlay.swift @@ -21,15 +21,17 @@ struct WayfindingOverlay: View { /// Called with whether a filling ring is on screen, each time that changes (`CameraOverlays` /// shows the ring's legend once one has). var onFillingRingShown: ((Bool) -> Void)? = nil - /// The open camera between the instruction card and the actions, in global coordinates - /// (`CameraChrome.cameraWindow`). The ring shows only inside it and the edge arrows keep to - /// it. Nil until the chrome has been laid out, when fixed bands stand in. - var clearArea: CGRect? = nil + /// The open camera between the instruction card and the actions (`CameraChrome`). The ring + /// shows only inside it and the edge arrows keep to it. Until the chrome has been laid out, + /// fixed bands stand in. + var cameraWindow: CameraWindow? = nil var body: some View { + // Read here, in this view's body, so a change redraws this overlay alone. + let clearArea = cameraWindow?.frame GeometryReader { proxy in let size = proxy.size - let band = verticalBand(origin: proxy.frame(in: .global).origin) + let band = Self.verticalBand(clearArea, origin: proxy.frame(in: .global).origin, height: size.height) ZStack { Canvas { context, size in drawPath(in: &context, size: size) @@ -65,8 +67,13 @@ struct WayfindingOverlay: View { /// How far inside the band the edge arrows sit: half the arrow's 52 pt disc and a margin. static let arrowInset: CGFloat = 32 - private func verticalBand(origin: CGPoint) -> Band? { - clearArea.map { Band(top: $0.minY - origin.y, bottom: $0.maxY - origin.y) } + /// The measured window in this view's coordinates, cut to the part on screen: scrolling the + /// chrome at large text sizes can carry the window partly past an edge. + private static func verticalBand(_ clearArea: CGRect?, origin: CGPoint, height: CGFloat) -> Band? { + clearArea.map { + let top = min(max($0.minY - origin.y, 0), height) + return Band(top: top, bottom: max(min($0.maxY - origin.y, height), top)) + } } // MARK: Path @@ -123,6 +130,19 @@ struct WayfindingOverlay: View { if Self.ringBounds(in: size, band: band, radius: radius).contains(point) { return .onScreen(point, radius: radius) } + // On screen but under the card or the actions. The camera's own direction to it + // starts at the optical axis, which can be the target itself, so the arrow points + // from the lane to where the target is drawn. + if band != nil, CGRect(origin: .zero, size: size).contains(point) { + let lane = Self.lane(in: size, band: band) + let offset = CGVector(dx: point.x - lane.midX, dy: point.y - lane.midY) + let length = (offset.dx * offset.dx + offset.dy * offset.dy).squareRoot() + // At the lane's middle there is no way to point; a band too short for the ring + // shows it where it is. + guard length > 1 else { return .onScreen(point, radius: radius) } + let direction = CGVector(dx: offset.dx / length, dy: offset.dy / length) + return .offScreen(Self.arrowPoint(toward: direction, in: size, band: band), angle: .radians(atan2(direction.dy, direction.dx))) + } } guard let direction = projection.screenDirection(toward: target) else { return .hidden @@ -130,36 +150,43 @@ struct WayfindingOverlay: View { return .offScreen(Self.arrowPoint(toward: direction, in: size, band: band), angle: .radians(atan2(direction.dy, direction.dx))) } - /// Where a ring's centre may be for the ring to show: comfortably on screen, and with the - /// whole ring in the open camera. Near the edges or partly under the card, the arrow takes - /// over, since a half-visible ring would be ambiguous and one under the card can't be seen - /// at all. Without a band, the fixed rectangle used before the chrome reported one, sized - /// for the default text. + /// Where a ring's centre may be for the ring to show: with the whole ring, at the largest + /// point of its pulse, on screen and in the open camera. Near the edges or partly under the + /// card, the arrow takes over, since a half-visible ring would be ambiguous and one under + /// the card can't be seen at all. Without a band, the fixed rectangle used before the chrome + /// reported one, sized for the default text. static func ringBounds(in size: CGSize, band: Band?, radius: CGFloat) -> CGRect { guard let band else { return CGRect(x: 36, y: 150, width: size.width - 72, height: size.height - 330) } + // `TargetMarker`'s plain ring pulses to 108 percent. + let reach = radius * 1.08 + let inset = max(36, reach) return CGRect( - x: 36, - y: band.top + radius, - width: max(size.width - 72, 0), - height: max(band.bottom - band.top - 2 * radius, 0) + x: inset, + y: band.top + reach, + width: max(size.width - 2 * inset, 0), + height: max(band.bottom - band.top - 2 * reach, 0) ) } + /// Where the edge arrows keep: the band inset by `arrowInset`. A band too short for that + /// leaves a lane of no height on the band's middle line, where the arrow overlaps the band + /// least. Without a band, the fixed lane used before the chrome reported one. + static func lane(in size: CGSize, band: Band?) -> CGRect { + guard let band else { + return CGRect(x: 40, y: 260, width: size.width - 80, height: max(size.height - 560, 80)) + } + let middle = (band.top + band.bottom) / 2 + let top = min(band.top + arrowInset, middle) + let bottom = max(band.bottom - arrowInset, middle) + return CGRect(x: 40, y: top, width: max(size.width - 80, 0), height: bottom - top) + } + /// The edge arrow's centre: where a line from the middle of the lane toward the target - /// leaves the lane. The lane is the band inset by `arrowInset`; a band too short for the - /// arrow puts it on the band's middle line, which still keeps it off the card. Without a - /// band, the fixed lane used before the chrome reported one. + /// leaves the lane. static func arrowPoint(toward direction: CGVector, in size: CGSize, band: Band?) -> CGPoint { - let lane: CGRect - if let band { - let top = band.top + arrowInset - let bottom = max(band.bottom - arrowInset, top) - lane = CGRect(x: 40, y: top, width: max(size.width - 80, 0), height: bottom - top) - } else { - lane = CGRect(x: 40, y: 260, width: size.width - 80, height: max(size.height - 560, 80)) - } + let lane = lane(in: size, band: band) let center = CGPoint(x: lane.midX, y: lane.midY) let tx = direction.dx == 0 ? CGFloat.infinity : (lane.width / 2) / abs(direction.dx) let ty = direction.dy == 0 ? CGFloat.infinity : (lane.height / 2) / abs(direction.dy) diff --git a/ios/HouseScan/UI/Components/CameraChrome.swift b/ios/HouseScan/UI/Components/CameraChrome.swift index a961f426d..9b6af8e26 100644 --- a/ios/HouseScan/UI/Components/CameraChrome.swift +++ b/ios/HouseScan/UI/Components/CameraChrome.swift @@ -19,17 +19,15 @@ struct CameraChrome: View { /// (`CameraOverlays`). At the accessibility sizes it goes inside the card instead and /// scrolls with the words there (`InstructionCard.legend`). var legend: String? = nil - /// Set to the open camera between the card and the actions, in global coordinates, for - /// the aim ring to stay inside (`WayfindingOverlay.clearArea`). It moves when the chrome - /// scrolls. - var cameraWindow: Binding? = nil + /// Given the open camera between the card and the actions, for the aim ring to stay inside + /// (`WayfindingOverlay`). It moves when the chrome scrolls. + var cameraWindow: CameraWindow? = nil @ViewBuilder var bottom: Bottom @Environment(\.dynamicTypeSize) private var typeSize + @Environment(\.accessibilityReduceMotion) private var reduceMotion private var capsWords: Bool { typeSize.isAccessibilitySize } - /// The status row's height, for the card's budget. - @State private var statusRowHeight: CGFloat = 0 var body: some View { // One layout at every text size: the chrome fills the screen and, only when the largest @@ -38,7 +36,7 @@ struct CameraChrome: View { // it, behind the chrome. GeometryReader { proxy in ScrollView { - stack(cardMaxHeight: capsWords ? cardMaxHeight(proxy) : nil) + stack(topLimit: capsWords ? topLimit(proxy) : nil) .frame(width: proxy.size.width) .frame(minHeight: proxy.size.height) .background { @@ -55,50 +53,59 @@ struct CameraChrome: View { } } - /// The tallest the card may be for it to end `windowHalfHeight` above the middle of the - /// screen, measured from the top of this view, which starts under the status bar. - private func cardMaxHeight(_ proxy: GeometryProxy) -> CGFloat { + /// How tall the status row and the card together may be for the card to end + /// `windowHalfHeight` above the middle of the screen. This view starts under the status + /// bar, above the stack's top padding. + private func topLimit(_ proxy: GeometryProxy) -> CGFloat { let screenMiddle = (proxy.size.height + proxy.safeAreaInsets.top + proxy.safeAreaInsets.bottom) / 2 - proxy.safeAreaInsets.top - let above = CameraChromeLayout.stackTopPadding + statusRowHeight + CameraChromeLayout.stackSpacing - return max(CameraChromeLayout.minCardHeight, (screenMiddle - CameraChromeLayout.windowHalfHeight - above).rounded(.down)) + return (screenMiddle - CameraChromeLayout.windowHalfHeight - CameraChromeLayout.stackTopPadding).rounded(.down) } - private func stack(cardMaxHeight: CGFloat?) -> some View { + private func stack(topLimit: CGFloat?) -> some View { VStack(spacing: CameraChromeLayout.stackSpacing) { - HStack(alignment: .center) { - ModeBadge(isReplay: isReplay, isAutopilot: isAutopilot) - Spacer(minLength: 8) - if let photoCount { - PhotoCounter(count: photoCount, lastCaptureID: lastCaptureID) + if let topLimit { + CardUnderStatusLayout(spacing: CameraChromeLayout.stackSpacing, limit: topLimit) { + statusRow + // The legend scrolls with the words in the card; a separate legend line at + // these sizes ran to seven lines and took the camera's place. + card(scrollsWords: true, legend: legend) + } + } else { + statusRow + card(scrollsWords: false, legend: nil) + if let legend { + legendLine(legend) } - } - .frame(minHeight: 36) - .onGeometryChange(for: CGFloat.self, of: \.size.height) { statusRowHeight = $0 } - InstructionCard( - instruction: instruction, - tone: tone, - reply: reply, - eyebrow: eyebrow, - // Capped, the words scroll and the legend scrolls with them; a separate legend - // line at these sizes ran to seven lines and took the camera's place. - legend: capsWords ? legend : nil, - maxHeight: cardMaxHeight - ) - if let legend, !capsWords { - legendLine(legend) } Spacer(minLength: capsWords ? 2 * CameraChromeLayout.windowHalfHeight : 0) .onGeometryChange(for: CGRect.self, of: { $0.frame(in: .global) }) { window in - if let cameraWindow, cameraWindow.wrappedValue != window { cameraWindow.wrappedValue = window } + if let cameraWindow, cameraWindow.frame != window { cameraWindow.frame = window } } bottom } - .animation(.easeOut(duration: 0.2), value: legend) + // The legend's arrival resizes the stack. With Reduce Motion it lands at once, since a + // shorter resize still moves everything under it. + .animation(reduceMotion ? nil : .easeOut(duration: 0.2), value: legend) .padding(.horizontal, Metrics.edge) .padding(.top, CameraChromeLayout.stackTopPadding) .padding(.bottom, 8) } + private var statusRow: some View { + HStack(alignment: .center) { + ModeBadge(isReplay: isReplay, isAutopilot: isAutopilot) + Spacer(minLength: 8) + if let photoCount { + PhotoCounter(count: photoCount, lastCaptureID: lastCaptureID) + } + } + .frame(minHeight: 36) + } + + private func card(scrollsWords: Bool, legend: String?) -> some View { + InstructionCard(instruction: instruction, tone: tone, reply: reply, eyebrow: eyebrow, scrollsWords: scrollsWords, legend: legend) + } + /// One Text with the symbol inline, as the result's sample badge is: the audit reported a /// Label with fixedSize as partially supporting Dynamic Type. private func legendLine(_ text: String) -> some View { @@ -116,6 +123,15 @@ struct CameraChrome: View { } } +/// The open camera between the instruction card and the actions, in global coordinates. +/// `CameraChrome` writes it and only `WayfindingOverlay` reads it, so scrolling the chrome +/// redraws the aim overlay rather than the whole screen, as state owned by the screen did. +@MainActor +@Observable +final class CameraWindow { + var frame: CGRect? +} + /// `CameraChrome`'s layout constants, outside it because a generic type can't hold stored /// static properties. enum CameraChromeLayout { @@ -132,6 +148,35 @@ enum CameraChromeLayout { static let stackTopPadding: CGFloat = 4 } +/// The status row, then the card under it, offered the height left under `limit` (never less +/// than `CameraChromeLayout.minCardHeight`). The chrome's scroll view offers its content no +/// height at all, so without this the card would never be told it has to fit. +private struct CardUnderStatusLayout: Layout { + var spacing: CGFloat + var limit: CGFloat + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + let (status, card) = sizes(proposal: proposal, subviews: subviews) + return CGSize(width: proposal.width ?? max(status.width, card.width), height: status.height + spacing + card.height) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + let (status, card) = sizes(proposal: ProposedViewSize(width: bounds.width, height: nil), subviews: subviews) + subviews[0].place(at: bounds.origin, proposal: ProposedViewSize(width: bounds.width, height: status.height)) + subviews[1].place( + at: CGPoint(x: bounds.minX, y: bounds.minY + status.height + spacing), + proposal: ProposedViewSize(width: bounds.width, height: card.height) + ) + } + + private func sizes(proposal: ProposedViewSize, subviews: Subviews) -> (status: CGSize, card: CGSize) { + let status = subviews[0].sizeThatFits(ProposedViewSize(width: proposal.width, height: nil)) + let room = max(CameraChromeLayout.minCardHeight, limit - status.height - spacing) + let card = subviews[1].sizeThatFits(ProposedViewSize(width: proposal.width, height: room)) + return (status, CGSize(width: card.width, height: min(card.height, room))) + } +} + /// Reads the full-screen camera view size so buttons can send "the reticle" (nil point) /// with the right `viewSize`. struct CameraSizeReader: View { diff --git a/ios/HouseScan/UI/Components/InstructionCard.swift b/ios/HouseScan/UI/Components/InstructionCard.swift index 70e264717..30c691444 100644 --- a/ios/HouseScan/UI/Components/InstructionCard.swift +++ b/ios/HouseScan/UI/Components/InstructionCard.swift @@ -34,18 +34,14 @@ struct InstructionCard: View { /// A short line above the instruction that places it in the flow ("One more view to /// finish"). Read with the instruction as one VoiceOver element. var eyebrow: String? + /// Whether the words (and `legend`) scroll inside the card when the card is offered less + /// height than they need, with the reply staying put under them. `CameraChrome` sets it at + /// the accessibility text sizes, where uncapped words covered the whole camera and the aim + /// ring under them (B-36), and offers the card the height it may have. + var scrollsWords = false /// The aim ring's legend, drawn in the card under the words. `CameraChrome` puts it here - /// only when it caps the card (`maxHeight`); otherwise the legend is its own line under - /// the card. + /// only when the words scroll; otherwise the legend is its own line under the card. var legend: String? = nil - /// The tallest the card may be. Past it, the words (and `legend`) scroll inside the card - /// and the reply stays put under them. Nil: the card is as tall as its words. - /// `CameraChrome` sets it at the accessibility text sizes, where uncapped words covered the - /// whole camera and the aim ring under them (B-36). - var maxHeight: CGFloat? = nil - - /// The least the words keep when `maxHeight` is small, so a line and a half still shows. - private static let minWordsHeight: CGFloat = 96 /// How long a new card's reply ignores taps, so a tap meant for the card that just left /// can't answer the next one: in the 4.1 field test, quick taps on "Can't get there" each @@ -57,35 +53,30 @@ struct InstructionCard: View { /// The task whose reply has been on screen for `replyLock`. Compared with the current task /// in the same update that changes it, so a new reply is locked from its first frame. @State private var unlockedTask: Instruction? - /// The capped words' full height, measured inside their scroll view. - @State private var wordsHeight: CGFloat? - /// The reply's height with its padding, taken from `maxHeight` to cap the words. - @State private var replyHeight: CGFloat = 0 - - private var wordsCap: CGFloat? { - maxHeight.map { max(Self.minWordsHeight, $0 - (reply == nil ? 0 : replyHeight)) } - } private var replyTask: Instruction { reply?.task ?? instruction } var body: some View { VStack(alignment: .trailing, spacing: 0) { - if let wordsCap { - // Measured rather than proposed: the chrome's own scroll view proposes no - // height, so a flexible frame would clip the words instead of scrolling them. - // Until the first measurement the card takes the cap. - ScrollView { + if scrollsWords { + // Laid out in the same pass as the rest of the card: the stack gives the reply + // its height first, and the words take what is left, scrolling only when they + // don't fit. Nothing is measured into state, so no layout feeds back into itself. + ViewThatFits(in: .vertical) { words - .onGeometryChange(for: CGFloat.self, of: \.size.height) { wordsHeight = $0 } + ScrollView { + words + } + .scrollBounceBehavior(.basedOnSize) + .scrollIndicators(.visible) + // A line cut off at the bottom is the only other sign there is more to read. + .scrollIndicatorsFlash(onAppear: true) + .accessibilityIdentifier("instruction.scroll") } - .frame(height: min(wordsHeight ?? wordsCap, wordsCap)) - .scrollBounceBehavior(.basedOnSize) - .scrollIndicators(.visible) - // A line cut off at the bottom is the only other sign there is more to read. - .scrollIndicatorsFlash(onAppear: true) - .accessibilityIdentifier("instruction.scroll") } else { - words + message + .id(instruction) + .transition(.identity) } if let reply { Button(reply.title, action: reply.perform) @@ -103,7 +94,6 @@ struct InstructionCard: View { .accessibilityIdentifier(reply.identifier) .padding([.horizontal, .bottom], 12) .padding(.top, -4) - .onGeometryChange(for: CGFloat.self, of: \.size.height) { replyHeight = $0 } // Appears and goes at once, like the message above it: the walk's request can // change every few seconds, and a fading button spends those moments as faint // text on the card, which the accessibility audit caught twice on CI (runs diff --git a/ios/HouseScan/UI/Screens/GapRequestScreen.swift b/ios/HouseScan/UI/Screens/GapRequestScreen.swift index 022298703..fd7b012d5 100644 --- a/ios/HouseScan/UI/Screens/GapRequestScreen.swift +++ b/ios/HouseScan/UI/Screens/GapRequestScreen.swift @@ -14,14 +14,14 @@ struct GapRequestScreen: View { @State private var cameraSize: CGSize = .zero /// The open camera between the card and the actions, for the aim ring (`CameraChrome`). - @State private var cameraWindow: CGRect? + @State private var cameraWindow = CameraWindow() @Environment(\.accessibilityReduceMotion) private var reduceMotion @Environment(\.dynamicTypeSize) private var typeSize var body: some View { ZStack { CameraSizeReader(size: $cameraSize) - CameraOverlays(state: state, highlight: state.gap, clearArea: cameraWindow) + CameraOverlays(state: state, highlight: state.gap, cameraWindow: cameraWindow) if state.gap?.isSatisfied == true { SuccessBadge() .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -45,7 +45,7 @@ struct GapRequestScreen: View { lastCaptureID: state.lastCapture?.id, isReplay: state.isReplay, isAutopilot: state.isAutopilot, - cameraWindow: $cameraWindow + cameraWindow: cameraWindow ) { VStack(spacing: 10) { if asking { diff --git a/ios/HouseScan/UI/Screens/WallWalkScreen.swift b/ios/HouseScan/UI/Screens/WallWalkScreen.swift index 33e2f0e6a..9a84184b0 100644 --- a/ios/HouseScan/UI/Screens/WallWalkScreen.swift +++ b/ios/HouseScan/UI/Screens/WallWalkScreen.swift @@ -13,7 +13,7 @@ struct WallWalkScreen: View { @State private var cameraSize: CGSize = .zero /// The open camera between the card and the actions, for the aim ring (`CameraChrome`). - @State private var cameraWindow: CGRect? + @State private var cameraWindow = CameraWindow() @State private var trayOpen = false @State private var taps: [TapRipple.Ripple] = [] /// The aim ring's legend while it shows, drawn under the card (#81). @@ -24,7 +24,7 @@ struct WallWalkScreen: View { var body: some View { ZStack { CameraSizeReader(size: $cameraSize) - CameraOverlays(state: state, highlight: nil, cardLegend: $cardLegend, clearArea: cameraWindow) + CameraOverlays(state: state, highlight: nil, cardLegend: $cardLegend, cameraWindow: cameraWindow) if state.coaching == .relocalizing, let meterPhoto { // "Point at the meter like this.": the saved close-up shows what to aim at. SavedMeterPhoto(image: meterPhoto) @@ -51,7 +51,7 @@ struct WallWalkScreen: View { actions.markFeaturePoint(at: point, viewSize: cameraSize) }, legend: cardLegend, - cameraWindow: $cameraWindow + cameraWindow: cameraWindow ) { VStack(spacing: 10) { controls @@ -173,7 +173,7 @@ struct WallWalkScreen: View { actions.cancelMarking() } label: { Text("Cancel") - .frame(maxWidth: stacksActions ? .infinity : nil) + .spansStack(stacksActions) } .buttonStyle(.secondaryProminent) .accessibilityIdentifier("action.cancelMarking") @@ -334,7 +334,7 @@ struct WallWalkScreen: View { actions.markWallEnd(at: nil, viewSize: cameraSize) } label: { Label("Wall ends here", systemImage: "flag.fill") - .frame(maxWidth: stacksActions ? .infinity : nil) + .spansStack(stacksActions) } .buttonStyle(.primary) .accessibilityHint("Marks the end of the wall at the circle in the middle of the screen") @@ -356,7 +356,7 @@ struct WallWalkScreen: View { actions.finishWalk() } label: { Label("Done with this wall", systemImage: "checkmark") - .frame(maxWidth: stacksActions ? .infinity : nil) + .spansStack(stacksActions) } .buttonStyle(.primary) .accessibilityIdentifier("action.finishWalk") @@ -371,15 +371,19 @@ struct WallWalkScreen: View { } } - /// Two actions side by side while their words fit on a line, one above the other at the - /// accessibility text sizes, where a side-by-side pair broke "Mark something" and "Wall - /// ends here" into one or two letters a line (B-28). `AnyLayout` keeps each button the - /// same view when the size changes, so nothing is rebuilt or crossfaded. + /// Two actions side by side at the default sizes, one above the other at the accessibility + /// sizes, where a side-by-side pair broke "Mark something" and "Wall ends here" into one or + /// two letters a line (B-28). The default row is the same `HStack` as before; the buttons + /// are rebuilt only when the text size crosses into the accessibility sizes. private var stacksActions: Bool { typeSize.isAccessibilitySize } + @ViewBuilder private func actionRow(@ViewBuilder _ content: () -> Content) -> some View { - let layout = stacksActions ? AnyLayout(VStackLayout(spacing: 8)) : AnyLayout(HStackLayout(spacing: 10)) - return layout(content) + if stacksActions { + VStack(spacing: 8, content: content) + } else { + HStack(spacing: 10, content: content) + } } /// The capture gate's coaching (slow down, texture, light, hold steady) comes and goes within @@ -511,3 +515,15 @@ struct FeatureTray: View { .background(ScrimShape.rounded()) } } + +private extension View { + /// Full width when the actions stack, as wide as its words when they share a row. + @ViewBuilder + func spansStack(_ stacked: Bool) -> some View { + if stacked { + frame(maxWidth: .infinity) + } else { + self + } + } +} diff --git a/ios/HouseScanUITests/ScreenStatesUITests.swift b/ios/HouseScanUITests/ScreenStatesUITests.swift index 70dbc474a..64c059d35 100644 --- a/ios/HouseScanUITests/ScreenStatesUITests.swift +++ b/ios/HouseScanUITests/ScreenStatesUITests.swift @@ -747,10 +747,21 @@ final class ScreenStatesUITests: XCTestCase { for (identifier, frame) in [("instruction.scroll", words.frame), ("action.cannotAccess", reply.frame), ("action.markSomething", mark.frame)] { XCTAssertFalse(ring.frame.intersects(frame), "ring \(ring.frame) is under \(identifier) \(frame)") } - // The legend comes with the ring, inside the card, and reads in full there. + // The legend comes with the ring, inside the card, below the words. Dragging the words + // must bring all of it into the card's visible part while the reply stays where it is. let legend = element(app, "aim.legend") XCTAssertTrue(legend.waitForExistence(timeout: 5), "the first aim ring must come with its legend") XCTAssertTrue(legend.label.contains("It fills as your phone captures this spot"), "legend reads \(legend.label)") + let replyFrame = reply.frame + var shown = words.frame.contains(legend.frame) + for _ in 0..<8 where !shown { + let start = words.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.85)) + start.press(forDuration: 0.1, thenDragTo: words.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.15)), withVelocity: .slow, thenHoldForDuration: 0.3) + shown = words.frame.contains(legend.frame) + } + XCTAssertTrue(shown, "the legend \(legend.frame) never scrolled fully into the card's words \(words.frame)") + XCTAssertEqual(reply.frame.minY, replyFrame.minY, accuracy: 1, "the reply must stay put while the words scroll") + attach(app, name: "wallWalk-aim-AX5-legendScrolled") } /// At AX5 a scrolled onboarding page ran on under the page dots. On every page, before and @@ -767,18 +778,34 @@ final class ScreenStatesUITests: XCTestCase { XCTAssertTrue(element(app, "screen.onboarding").waitForExistence(timeout: 15)) let footer = element(app, "onboarding.footer") XCTAssertTrue(footer.waitForExistence(timeout: 5)) - for page in 1...4 { + // Each page's heading, to tell when its page has scrolled as far as it goes. + let headings = ["Let's find a spot for your battery", "What the walk asks for", "Your phone takes the photos", "Stay safe out there"] + for (index, title) in headings.enumerated() { + let page = index + 1 // Let the page slide settle. Thread.sleep(forTimeInterval: 0.8) assertDotsRowClear(app, name: "onboarding-page\(page)-AX5-dots") - for _ in 0..<4 { - let start = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.6)) - start.press(forDuration: 0.1, thenDragTo: start.withOffset(CGVector(dx: 0, dy: -240)), withVelocity: .slow, thenHoldForDuration: 0.2) - } - Thread.sleep(forTimeInterval: 0.5) - assertDotsRowClear(app, name: "onboarding-page\(page)-AX5-dots-scrolled") - if page < 4 { tap(app, "action.onboardingNext") } + let heading = app.staticTexts[title] + XCTAssertTrue(heading.waitForExistence(timeout: 5), "page \(page): missing its heading") + XCTAssertTrue(scrollPageToEnd(app, heading: heading), "page \(page) never stopped scrolling") + assertDotsRowClear(app, name: "onboarding-page\(page)-AX5-dots-end") + if page < headings.count { tap(app, "action.onboardingNext") } + } + } + + /// Drags the onboarding page up until its heading stops moving: the page's end. False if it + /// still moved after twelve drags. + @MainActor + private func scrollPageToEnd(_ app: XCUIApplication, heading: XCUIElement) -> Bool { + var last = heading.frame.minY + for _ in 0..<12 { + let start = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.6)) + start.press(forDuration: 0.1, thenDragTo: start.withOffset(CGVector(dx: 0, dy: -240)), withVelocity: .slow, thenHoldForDuration: 0.3) + let now = heading.frame.minY + if abs(now - last) < 1 { return true } + last = now } + return false } /// The band from the footer's top edge to the button's, where the dots sit, must be the From bfc36e1bf1569c1b54981017e3169025d87ecae7 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Wed, 30 Sep 2026 10:55:24 -0500 Subject: [PATCH 06/48] ios: square the wrapped sample-result badge at accessibility sizes At AX5 the two-line pill's capsule ends cut into "not from the server" (hosted screenshot at 50e10696). Co-Authored-By: Claude Opus 5.5 (1M context) --- ios/HouseScan/UI/Screens/ResultScreen.swift | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/ios/HouseScan/UI/Screens/ResultScreen.swift b/ios/HouseScan/UI/Screens/ResultScreen.swift index 30f751acd..3e90850b5 100644 --- a/ios/HouseScan/UI/Screens/ResultScreen.swift +++ b/ios/HouseScan/UI/Screens/ResultScreen.swift @@ -95,9 +95,10 @@ struct ResultScreen: View { Text("\(Image(systemName: "flask.fill")) Sample result, not from the server") .font(.caption2.weight(.medium)) .foregroundStyle(Palette.chalk) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(Palette.ink.opacity(0.7), in: .capsule) + .padding(.horizontal, typeSize.isAccessibilitySize ? 14 : 8) + .padding(.vertical, typeSize.isAccessibilitySize ? 8 : 4) + // Wrapped onto two lines, a capsule's round ends cut into the text's corners. + .background(Palette.ink.opacity(0.7), in: typeSize.isAccessibilitySize ? AnyShape(.rect(cornerRadius: 16, style: .continuous)) : AnyShape(.capsule)) .accessibilityLabel("Sample result, not from the server") .accessibilityIdentifier("result.sampleBadge") } From 33c43fd22abc034b5697c518570ca3f91ff35f23 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Wed, 30 Sep 2026 11:16:35 -0500 Subject: [PATCH 07/48] ios: hide a ring that can't fit, and prove scrolled ends in AX5 tests - A target at the middle of an open camera too short for its ring no longer shows a ring over the card. - The aim test scrolls the card's words until the legend's end shows, checks the content moved and the reply stayed; the AX5 legend is taller than the words' viewport, so full containment was impossible. - The onboarding test scrolls each page until its last element sits above the footer, instead of trusting a heading that stopped moving. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../UI/Camera/WayfindingOverlay.swift | 6 +-- .../ScreenStatesUITests.swift | 53 +++++++++++-------- 2 files changed, 34 insertions(+), 25 deletions(-) diff --git a/ios/HouseScan/UI/Camera/WayfindingOverlay.swift b/ios/HouseScan/UI/Camera/WayfindingOverlay.swift index 203280ad4..da530e953 100644 --- a/ios/HouseScan/UI/Camera/WayfindingOverlay.swift +++ b/ios/HouseScan/UI/Camera/WayfindingOverlay.swift @@ -137,9 +137,9 @@ struct WayfindingOverlay: View { let lane = Self.lane(in: size, band: band) let offset = CGVector(dx: point.x - lane.midX, dy: point.y - lane.midY) let length = (offset.dx * offset.dx + offset.dy * offset.dy).squareRoot() - // At the lane's middle there is no way to point; a band too short for the ring - // shows it where it is. - guard length > 1 else { return .onScreen(point, radius: radius) } + // At the lane's middle there is no way to point, and the ring doesn't fit: the + // open camera is too short to guide in, so neither shows until it grows. + guard length > 1 else { return .hidden } let direction = CGVector(dx: offset.dx / length, dy: offset.dy / length) return .offScreen(Self.arrowPoint(toward: direction, in: size, band: band), angle: .radians(atan2(direction.dy, direction.dx))) } diff --git a/ios/HouseScanUITests/ScreenStatesUITests.swift b/ios/HouseScanUITests/ScreenStatesUITests.swift index 64c059d35..2e7ad9e17 100644 --- a/ios/HouseScanUITests/ScreenStatesUITests.swift +++ b/ios/HouseScanUITests/ScreenStatesUITests.swift @@ -747,19 +747,24 @@ final class ScreenStatesUITests: XCTestCase { for (identifier, frame) in [("instruction.scroll", words.frame), ("action.cannotAccess", reply.frame), ("action.markSomething", mark.frame)] { XCTAssertFalse(ring.frame.intersects(frame), "ring \(ring.frame) is under \(identifier) \(frame)") } - // The legend comes with the ring, inside the card, below the words. Dragging the words - // must bring all of it into the card's visible part while the reply stays where it is. + // The legend comes with the ring, inside the card, as the last thing under the words. + // Dragging the words must bring its end into the card's visible part, which is the end + // of what the card says, while the reply stays where it is. let legend = element(app, "aim.legend") XCTAssertTrue(legend.waitForExistence(timeout: 5), "the first aim ring must come with its legend") XCTAssertTrue(legend.label.contains("It fills as your phone captures this spot"), "legend reads \(legend.label)") let replyFrame = reply.frame - var shown = words.frame.contains(legend.frame) - for _ in 0..<8 where !shown { + let startY = legend.frame.minY + func endShows() -> Bool { + let viewport = words.frame, shown = legend.frame + return shown.maxY <= viewport.maxY + 1 && shown.maxY > viewport.minY + } + for _ in 0..<12 where !endShows() { let start = words.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.85)) start.press(forDuration: 0.1, thenDragTo: words.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.15)), withVelocity: .slow, thenHoldForDuration: 0.3) - shown = words.frame.contains(legend.frame) } - XCTAssertTrue(shown, "the legend \(legend.frame) never scrolled fully into the card's words \(words.frame)") + XCTAssertTrue(endShows(), "the legend's end \(legend.frame) never scrolled into the card's words \(words.frame)") + XCTAssertLessThan(legend.frame.minY, startY - 1, "the words never scrolled: the legend stayed at \(startY)") XCTAssertEqual(reply.frame.minY, replyFrame.minY, accuracy: 1, "the reply must stay put while the words scroll") attach(app, name: "wallWalk-aim-AX5-legendScrolled") } @@ -778,34 +783,38 @@ final class ScreenStatesUITests: XCTestCase { XCTAssertTrue(element(app, "screen.onboarding").waitForExistence(timeout: 15)) let footer = element(app, "onboarding.footer") XCTAssertTrue(footer.waitForExistence(timeout: 5)) - // Each page's heading, to tell when its page has scrolled as far as it goes. - let headings = ["Let's find a spot for your battery", "What the walk asks for", "Your phone takes the photos", "Stay safe out there"] - for (index, title) in headings.enumerated() { + // Each page's last element: the page is at its end once that sits wholly above the footer. + let ends: [XCUIElement] = [ + app.staticTexts.matching(NSPredicate(format: "label BEGINSWITH %@", "Walk along the wall by your electric meter")).firstMatch, + element(app, "onboarding.move.5"), + element(app, "onboarding.privacy"), + element(app, "onboarding.permissions"), + ] + for (index, last) in ends.enumerated() { let page = index + 1 // Let the page slide settle. Thread.sleep(forTimeInterval: 0.8) assertDotsRowClear(app, name: "onboarding-page\(page)-AX5-dots") - let heading = app.staticTexts[title] - XCTAssertTrue(heading.waitForExistence(timeout: 5), "page \(page): missing its heading") - XCTAssertTrue(scrollPageToEnd(app, heading: heading), "page \(page) never stopped scrolling") + XCTAssertTrue(last.waitForExistence(timeout: 5), "page \(page): missing its last element") + XCTAssertTrue(scrollPageToEnd(app, last: last, footer: footer), "page \(page): its end \(last.frame) never came above the footer \(footer.frame)") assertDotsRowClear(app, name: "onboarding-page\(page)-AX5-dots-end") - if page < headings.count { tap(app, "action.onboardingNext") } + if page < ends.count { tap(app, "action.onboardingNext") } } } - /// Drags the onboarding page up until its heading stops moving: the page's end. False if it - /// still moved after twelve drags. + /// Drags the onboarding page up, at most twelve times, until its last element sits wholly + /// between the top of the screen and the footer. @MainActor - private func scrollPageToEnd(_ app: XCUIApplication, heading: XCUIElement) -> Bool { - var last = heading.frame.minY - for _ in 0..<12 { + private func scrollPageToEnd(_ app: XCUIApplication, last: XCUIElement, footer: XCUIElement) -> Bool { + func atEnd() -> Bool { + let frame = last.frame + return frame.minY >= app.windows.firstMatch.frame.minY && frame.maxY <= footer.frame.minY + 1 + } + for _ in 0..<12 where !atEnd() { let start = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.6)) start.press(forDuration: 0.1, thenDragTo: start.withOffset(CGVector(dx: 0, dy: -240)), withVelocity: .slow, thenHoldForDuration: 0.3) - let now = heading.frame.minY - if abs(now - last) < 1 { return true } - last = now } - return false + return atEnd() } /// The band from the footer's top edge to the button's, where the dots sit, must be the From 40d7b180e0d2cedb4eae0309e58300d1948d6314 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Wed, 30 Sep 2026 12:27:58 -0500 Subject: [PATCH 08/48] ios: show the card's scroll bar only while scrolling; fix onboarding end check The every-state audit at 33c43fd2 (run 36743027718) failed contrast with no element on every AX5 camera state whose words scroll, the only place the always-visible inner scroll bar appears. It now shows while scrolling and flashes once on appearance. At AX5 one onboarding move is taller than the page's viewport, so the page's end is when the last element's bottom shows above the footer. Co-Authored-By: Claude Opus 5.5 (1M context) --- ios/HouseScan/UI/Components/InstructionCard.swift | 7 +++++-- ios/HouseScanUITests/ScreenStatesUITests.swift | 11 ++++++----- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/ios/HouseScan/UI/Components/InstructionCard.swift b/ios/HouseScan/UI/Components/InstructionCard.swift index 30c691444..5ae0c9c9f 100644 --- a/ios/HouseScan/UI/Components/InstructionCard.swift +++ b/ios/HouseScan/UI/Components/InstructionCard.swift @@ -68,8 +68,11 @@ struct InstructionCard: View { words } .scrollBounceBehavior(.basedOnSize) - .scrollIndicators(.visible) - // A line cut off at the bottom is the only other sign there is more to read. + // Shown while scrolling and flashed once when the card appears. Left + // showing, the thin grey bar on the dark card failed the accessibility + // audit's contrast check in every AX5 state whose words scroll (run + // 36743027718). A line cut off at the bottom is the lasting sign of more. + .scrollIndicators(.automatic) .scrollIndicatorsFlash(onAppear: true) .accessibilityIdentifier("instruction.scroll") } diff --git a/ios/HouseScanUITests/ScreenStatesUITests.swift b/ios/HouseScanUITests/ScreenStatesUITests.swift index 2e7ad9e17..b1499160e 100644 --- a/ios/HouseScanUITests/ScreenStatesUITests.swift +++ b/ios/HouseScanUITests/ScreenStatesUITests.swift @@ -783,7 +783,7 @@ final class ScreenStatesUITests: XCTestCase { XCTAssertTrue(element(app, "screen.onboarding").waitForExistence(timeout: 15)) let footer = element(app, "onboarding.footer") XCTAssertTrue(footer.waitForExistence(timeout: 5)) - // Each page's last element: the page is at its end once that sits wholly above the footer. + // Each page's last element: the page is at its end once its bottom shows above the footer. let ends: [XCUIElement] = [ app.staticTexts.matching(NSPredicate(format: "label BEGINSWITH %@", "Walk along the wall by your electric meter")).firstMatch, element(app, "onboarding.move.5"), @@ -796,19 +796,20 @@ final class ScreenStatesUITests: XCTestCase { Thread.sleep(forTimeInterval: 0.8) assertDotsRowClear(app, name: "onboarding-page\(page)-AX5-dots") XCTAssertTrue(last.waitForExistence(timeout: 5), "page \(page): missing its last element") - XCTAssertTrue(scrollPageToEnd(app, last: last, footer: footer), "page \(page): its end \(last.frame) never came above the footer \(footer.frame)") + XCTAssertTrue(scrollPageToEnd(app, last: last, footer: footer), "page \(page): the bottom of its last element \(last.frame) never came above the footer \(footer.frame)") assertDotsRowClear(app, name: "onboarding-page\(page)-AX5-dots-end") if page < ends.count { tap(app, "action.onboardingNext") } } } - /// Drags the onboarding page up, at most twelve times, until its last element sits wholly - /// between the top of the screen and the footer. + /// Drags the onboarding page up, at most twelve times, until the bottom of its last element + /// shows above the footer. At AX5 a single move can be taller than the page's viewport, so + /// only its bottom edge marks the end. @MainActor private func scrollPageToEnd(_ app: XCUIApplication, last: XCUIElement, footer: XCUIElement) -> Bool { func atEnd() -> Bool { let frame = last.frame - return frame.minY >= app.windows.firstMatch.frame.minY && frame.maxY <= footer.frame.minY + 1 + return frame.maxY <= footer.frame.minY + 1 && frame.maxY > app.windows.firstMatch.frame.minY } for _ in 0..<12 where !atEnd() { let start = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.6)) From e9f821505b7f4c3adc29a866ea80bcf73f773457 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Wed, 30 Sep 2026 13:35:59 -0500 Subject: [PATCH 09/48] ios: drop the capped card; keep the aim ring out from under the chrome Scrolling the card's words inside the card failed the every-state audit's contrast check on every AX5 camera state: the audit samples each text's full frame, and text clipped by the card's own scroll area lands on the reply or the camera (run 36751580657). The card is back to its e7031990 layout at every size. What stays for B-36: at accessibility sizes the chrome keeps a 180 pt open camera between the card and the actions, which scrolls into view, and the aim ring and edge arrow show only inside it, so neither is drawn under the card, the legend or the actions. With less of the window on screen than an arrow's disc, neither shows. The legend again shows whenever a filling ring's target is on screen, as at e7031990, so it no longer depends on the measured window and the per-step latch is gone. The AX5 aim audit state expects the legend; the ring's progress is checked where it appears while the chrome scrolls. The onboarding test swipes the page's own scroll view, since a drag at a screen point left page two unmoved. Co-Authored-By: Claude Opus 5.5 (1M context) --- ios/HouseScan/UI/Camera/CameraOverlays.swift | 16 +-- .../UI/Camera/WayfindingOverlay.swift | 34 ++++-- .../UI/Components/CameraChrome.swift | 110 ++++-------------- .../UI/Components/InstructionCard.swift | 66 +---------- .../ScreenStatesUITests.swift | 89 +++++++------- 5 files changed, 102 insertions(+), 213 deletions(-) diff --git a/ios/HouseScan/UI/Camera/CameraOverlays.swift b/ios/HouseScan/UI/Camera/CameraOverlays.swift index dbfb13720..5d067eeb7 100644 --- a/ios/HouseScan/UI/Camera/CameraOverlays.swift +++ b/ios/HouseScan/UI/Camera/CameraOverlays.swift @@ -15,11 +15,8 @@ struct CameraOverlays: View { /// fills and retires once that step ends (#81); it isn't needed on every ring after. @State private var legendStep: GuidanceStep? = nil @State private var legendRetired = false - /// Whether a filling ring has shown during this step (`WayfindingOverlay.onFillingRingShown`): - /// the legend explains the ring, so it waits for one. It then stays for the step even if the - /// ring turns into an arrow. The legend takes room from the open camera the ring must sit - /// in, so a legend that left with the ring could let the ring back, and bring itself back, - /// on every frame. + /// Whether a filling ring is on screen (`WayfindingOverlay.onFillingRingShown`): the legend + /// explains the ring, so it shows only with it. @State private var fillingRingShown = false /// The aim step on screen, with its target, so the update that ends it can still score it. @State private var lastAim: AimSnapshot? = nil @@ -44,7 +41,6 @@ struct CameraOverlays: View { .animation(.easeOut(duration: 0.2), value: state.tracking == .normal) .onChange(of: AimSnapshot(step: state.guidance, target: state.target), initial: true) { old, new in if new.step != old.step { - fillingRingShown = false if let legendStep, new.step != legendStep { legendRetired = true } if let target = completed(old) { completedTarget = target @@ -89,7 +85,7 @@ struct CameraOverlays: View { target: state.target, progress: state.aimProgress, completed: heldTarget, - onFillingRingShown: { shown in if shown { fillingRingShown = true } }, + onFillingRingShown: { shown in fillingRingShown = shown }, cameraWindow: cameraWindow ) .transition(.opacity) @@ -140,13 +136,13 @@ struct CameraOverlays: View { return target } - /// The legend, once the first filling ring has shown and while it is short of full (#81). + /// The legend, while the first filling ring is on screen and short of full (#81). /// /// It goes under the instruction card, in the chrome's own stack, rather than beside the /// ring: beside it, it went behind the card at accessibility text sizes (the card grows /// and is drawn over the camera layers), and any layout that dropped it where there was no - /// room left the people with the largest text without it. At the accessibility sizes it - /// sits inside the card and scrolls with the card's words. + /// room left the people with the largest text without it. In the stack it grows with the + /// card and scrolls with it at any size. private var shownLegend: String? { guard fillingRingShown, !legendRetired, state.marking == nil, heldTarget == nil, let progress = state.aimProgress, progress < 1 else { return nil } diff --git a/ios/HouseScan/UI/Camera/WayfindingOverlay.swift b/ios/HouseScan/UI/Camera/WayfindingOverlay.swift index da530e953..1b4ced2a3 100644 --- a/ios/HouseScan/UI/Camera/WayfindingOverlay.swift +++ b/ios/HouseScan/UI/Camera/WayfindingOverlay.swift @@ -18,8 +18,10 @@ struct WayfindingOverlay: View { /// An aim step's target that was just completed, drawn as a full ring in place of `target` /// while it is on screen (`CameraOverlays` holds it for a moment). var completed: SIMD3? = nil - /// Called with whether a filling ring is on screen, each time that changes (`CameraOverlays` - /// shows the ring's legend once one has). + /// Called with whether an aim step's filling ring has its target on screen, each time that + /// changes (`CameraOverlays` shows the ring's legend once one has). On screen, not in the + /// open camera: at the largest text sizes the card can cover the ring's place until the + /// chrome scrolls, and the legend under the card still says what the ring is for. var onFillingRingShown: ((Bool) -> Void)? = nil /// The open camera between the instruction card and the actions (`CameraChrome`). The ring /// shows only inside it and the edge arrows keep to it. Until the chrome has been laid out, @@ -41,12 +43,12 @@ struct WayfindingOverlay: View { // from its last fill to green. if let shown = marker(in: size, band: band) { TargetMarker(placement: shown.placement, progress: shown.progress) - if progress != nil, completed == nil, case .onScreen = shown.placement { - Color.clear - .frame(width: 0, height: 0) - .onAppear { onFillingRingShown?(true) } - .onDisappear { onFillingRingShown?(false) } - } + } + if fillingTargetOnScreen(in: size) { + Color.clear + .frame(width: 0, height: 0) + .onAppear { onFillingRingShown?(true) } + .onDisappear { onFillingRingShown?(false) } } } .frame(width: size.width, height: size.height) @@ -64,7 +66,9 @@ struct WayfindingOverlay: View { var bottom: CGFloat } - /// How far inside the band the edge arrows sit: half the arrow's 52 pt disc and a margin. + /// The edge arrow's disc (`TargetMarker`). + static let arrowDiameter: CGFloat = 52 + /// How far inside the band the edge arrows sit: half the arrow's disc and a margin. static let arrowInset: CGFloat = 32 /// The measured window in this view's coordinates, cut to the part on screen: scrolling the @@ -113,6 +117,15 @@ struct WayfindingOverlay: View { // MARK: Target + /// Whether an aim step's target is where a filling ring would show without the card: the + /// fixed rectangle used before the chrome reported its open camera. + private func fillingTargetOnScreen(in size: CGSize) -> Bool { + guard progress != nil, completed == nil, let target, let point = projection.viewPoint(for: target, in: size) else { + return false + } + return Self.ringBounds(in: size, band: nil, radius: 0).contains(point) + } + /// The completed ring while it is on screen, else the target's ring or arrow. private func marker(in size: CGSize, band: Band?) -> (placement: TargetMarker.Placement, progress: Double?)? { if let completed { @@ -124,6 +137,9 @@ struct WayfindingOverlay: View { } private func placement(for target: SIMD3, in size: CGSize, band: Band?) -> TargetMarker.Placement { + // Less of the open camera on screen than an arrow's disc: at the largest text sizes the + // chrome has scrolled it away, and anything drawn would sit on the card or the actions. + if let band, band.bottom - band.top < Self.arrowDiameter { return .hidden } if let point = projection.viewPoint(for: target, in: size) { let scale = wall.flatMap { WallProjection(projection: projection, wall: $0, size: size).pointsPerMeter(at: target) } let radius = min(64, max(30, (scale ?? 90) * 0.28)) diff --git a/ios/HouseScan/UI/Components/CameraChrome.swift b/ios/HouseScan/UI/Components/CameraChrome.swift index 9b6af8e26..267b56d11 100644 --- a/ios/HouseScan/UI/Components/CameraChrome.swift +++ b/ios/HouseScan/UI/Components/CameraChrome.swift @@ -16,8 +16,8 @@ struct CameraChrome: View { /// the window's global space). Nil when the screen has nothing to tap. var onCameraTap: ((CGPoint) -> Void)? /// A line under the card about something drawn on the camera: the aim ring's legend - /// (`CameraOverlays`). At the accessibility sizes it goes inside the card instead and - /// scrolls with the words there (`InstructionCard.legend`). + /// (`CameraOverlays`). It is part of the chrome's stack, so it grows with the card and + /// scrolls with it at the largest text sizes, where the card covers most of the camera. var legend: String? = nil /// Given the open camera between the card and the actions, for the aim ring to stay inside /// (`WayfindingOverlay`). It moves when the chrome scrolls. @@ -27,8 +27,6 @@ struct CameraChrome: View { @Environment(\.dynamicTypeSize) private var typeSize @Environment(\.accessibilityReduceMotion) private var reduceMotion - private var capsWords: Bool { typeSize.isAccessibilitySize } - var body: some View { // One layout at every text size: the chrome fills the screen and, only when the largest // text makes it taller than the screen, scrolls instead of squeezing the words. The @@ -36,7 +34,7 @@ struct CameraChrome: View { // it, behind the chrome. GeometryReader { proxy in ScrollView { - stack(topLimit: capsWords ? topLimit(proxy) : nil) + stack .frame(width: proxy.size.width) .frame(minHeight: proxy.size.height) .background { @@ -53,31 +51,24 @@ struct CameraChrome: View { } } - /// How tall the status row and the card together may be for the card to end - /// `windowHalfHeight` above the middle of the screen. This view starts under the status - /// bar, above the stack's top padding. - private func topLimit(_ proxy: GeometryProxy) -> CGFloat { - let screenMiddle = (proxy.size.height + proxy.safeAreaInsets.top + proxy.safeAreaInsets.bottom) / 2 - proxy.safeAreaInsets.top - return (screenMiddle - CameraChromeLayout.windowHalfHeight - CameraChromeLayout.stackTopPadding).rounded(.down) - } - - private func stack(topLimit: CGFloat?) -> some View { - VStack(spacing: CameraChromeLayout.stackSpacing) { - if let topLimit { - CardUnderStatusLayout(spacing: CameraChromeLayout.stackSpacing, limit: topLimit) { - statusRow - // The legend scrolls with the words in the card; a separate legend line at - // these sizes ran to seven lines and took the camera's place. - card(scrollsWords: true, legend: legend) - } - } else { - statusRow - card(scrollsWords: false, legend: nil) - if let legend { - legendLine(legend) + private var stack: some View { + VStack(spacing: 10) { + HStack(alignment: .center) { + ModeBadge(isReplay: isReplay, isAutopilot: isAutopilot) + Spacer(minLength: 8) + if let photoCount { + PhotoCounter(count: photoCount, lastCaptureID: lastCaptureID) } } - Spacer(minLength: capsWords ? 2 * CameraChromeLayout.windowHalfHeight : 0) + .frame(minHeight: 36) + InstructionCard(instruction: instruction, tone: tone, reply: reply, eyebrow: eyebrow) + if let legend { + legendLine(legend) + } + // The open camera between the card and the actions. At the accessibility sizes the + // card can fill the screen, so the chrome keeps at least `minCameraWindow` of camera + // here, which scrolls into view; the aim ring shows only inside it (B-36). + Spacer(minLength: typeSize.isAccessibilitySize ? CameraChromeLayout.minCameraWindow : 0) .onGeometryChange(for: CGRect.self, of: { $0.frame(in: .global) }) { window in if let cameraWindow, cameraWindow.frame != window { cameraWindow.frame = window } } @@ -87,25 +78,10 @@ struct CameraChrome: View { // shorter resize still moves everything under it. .animation(reduceMotion ? nil : .easeOut(duration: 0.2), value: legend) .padding(.horizontal, Metrics.edge) - .padding(.top, CameraChromeLayout.stackTopPadding) + .padding(.top, 4) .padding(.bottom, 8) } - private var statusRow: some View { - HStack(alignment: .center) { - ModeBadge(isReplay: isReplay, isAutopilot: isAutopilot) - Spacer(minLength: 8) - if let photoCount { - PhotoCounter(count: photoCount, lastCaptureID: lastCaptureID) - } - } - .frame(minHeight: 36) - } - - private func card(scrollsWords: Bool, legend: String?) -> some View { - InstructionCard(instruction: instruction, tone: tone, reply: reply, eyebrow: eyebrow, scrollsWords: scrollsWords, legend: legend) - } - /// One Text with the symbol inline, as the result's sample badge is: the audit reported a /// Label with fixedSize as partially supporting Dynamic Type. private func legendLine(_ text: String) -> some View { @@ -125,7 +101,7 @@ struct CameraChrome: View { /// The open camera between the instruction card and the actions, in global coordinates. /// `CameraChrome` writes it and only `WayfindingOverlay` reads it, so scrolling the chrome -/// redraws the aim overlay rather than the whole screen, as state owned by the screen did. +/// redraws the aim overlay, not the whole screen. @MainActor @Observable final class CameraWindow { @@ -135,46 +111,10 @@ final class CameraWindow { /// `CameraChrome`'s layout constants, outside it because a generic type can't hold stored /// static properties. enum CameraChromeLayout { - /// Half the open camera the chrome keeps around the middle of the screen at the - /// accessibility sizes, where the reticle sits and where a target the phone points at - /// lands. Room for a 64 pt aim ring with a margin. Uncapped, one instruction at AX5 filled - /// an iPhone 17's screen and hid the ring under it (B-36). A layout choice, not measured on - /// a phone. - static let windowHalfHeight: CGFloat = 90 - /// The shortest the card gets to keep that window: about two lines of the instruction at - /// AX5 and its reply. On a small screen the window gives way first. - static let minCardHeight: CGFloat = 200 - static let stackSpacing: CGFloat = 10 - static let stackTopPadding: CGFloat = 4 -} - -/// The status row, then the card under it, offered the height left under `limit` (never less -/// than `CameraChromeLayout.minCardHeight`). The chrome's scroll view offers its content no -/// height at all, so without this the card would never be told it has to fit. -private struct CardUnderStatusLayout: Layout { - var spacing: CGFloat - var limit: CGFloat - - func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { - let (status, card) = sizes(proposal: proposal, subviews: subviews) - return CGSize(width: proposal.width ?? max(status.width, card.width), height: status.height + spacing + card.height) - } - - func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { - let (status, card) = sizes(proposal: ProposedViewSize(width: bounds.width, height: nil), subviews: subviews) - subviews[0].place(at: bounds.origin, proposal: ProposedViewSize(width: bounds.width, height: status.height)) - subviews[1].place( - at: CGPoint(x: bounds.minX, y: bounds.minY + status.height + spacing), - proposal: ProposedViewSize(width: bounds.width, height: card.height) - ) - } - - private func sizes(proposal: ProposedViewSize, subviews: Subviews) -> (status: CGSize, card: CGSize) { - let status = subviews[0].sizeThatFits(ProposedViewSize(width: proposal.width, height: nil)) - let room = max(CameraChromeLayout.minCardHeight, limit - status.height - spacing) - let card = subviews[1].sizeThatFits(ProposedViewSize(width: proposal.width, height: room)) - return (status, CGSize(width: card.width, height: min(card.height, room))) - } + /// The least open camera kept between the card and the actions at the accessibility sizes: + /// room for the largest aim ring (64 pt radius, 108 percent at its pulse) with a margin. A + /// layout choice, not measured on a phone. + static let minCameraWindow: CGFloat = 180 } /// Reads the full-screen camera view size so buttons can send "the reticle" (nil point) diff --git a/ios/HouseScan/UI/Components/InstructionCard.swift b/ios/HouseScan/UI/Components/InstructionCard.swift index 5ae0c9c9f..9a3773c21 100644 --- a/ios/HouseScan/UI/Components/InstructionCard.swift +++ b/ios/HouseScan/UI/Components/InstructionCard.swift @@ -34,14 +34,6 @@ struct InstructionCard: View { /// A short line above the instruction that places it in the flow ("One more view to /// finish"). Read with the instruction as one VoiceOver element. var eyebrow: String? - /// Whether the words (and `legend`) scroll inside the card when the card is offered less - /// height than they need, with the reply staying put under them. `CameraChrome` sets it at - /// the accessibility text sizes, where uncapped words covered the whole camera and the aim - /// ring under them (B-36), and offers the card the height it may have. - var scrollsWords = false - /// The aim ring's legend, drawn in the card under the words. `CameraChrome` puts it here - /// only when the words scroll; otherwise the legend is its own line under the card. - var legend: String? = nil /// How long a new card's reply ignores taps, so a tap meant for the card that just left /// can't answer the next one: in the 4.1 field test, quick taps on "Can't get there" each @@ -58,29 +50,9 @@ struct InstructionCard: View { var body: some View { VStack(alignment: .trailing, spacing: 0) { - if scrollsWords { - // Laid out in the same pass as the rest of the card: the stack gives the reply - // its height first, and the words take what is left, scrolling only when they - // don't fit. Nothing is measured into state, so no layout feeds back into itself. - ViewThatFits(in: .vertical) { - words - ScrollView { - words - } - .scrollBounceBehavior(.basedOnSize) - // Shown while scrolling and flashed once when the card appears. Left - // showing, the thin grey bar on the dark card failed the accessibility - // audit's contrast check in every AX5 state whose words scroll (run - // 36743027718). A line cut off at the bottom is the lasting sign of more. - .scrollIndicators(.automatic) - .scrollIndicatorsFlash(onAppear: true) - .accessibilityIdentifier("instruction.scroll") - } - } else { - message - .id(instruction) - .transition(.identity) - } + message + .id(instruction) + .transition(.identity) if let reply { Button(reply.title, action: reply.perform) .font(Typeface.caption.weight(.bold)) @@ -129,38 +101,6 @@ struct InstructionCard: View { } } - private var words: some View { - VStack(alignment: .leading, spacing: 0) { - message - .id(instruction) - .transition(.identity) - if let legend { - legendLine(legend) - } - } - } - - /// The legend inside the card: set off from the words by a rule and the ring's symbol, in - /// the words' own colours so it is as legible as they are. - private func legendLine(_ text: String) -> some View { - VStack(alignment: .leading, spacing: 12) { - Rectangle() - .fill(Palette.chalk.opacity(0.2)) - .frame(height: 1) - .accessibilityHidden(true) - Text("\(Image(systemName: "scope")) \(text)") - .font(Typeface.hint) - .foregroundStyle(Palette.chalk) - .fixedSize(horizontal: false, vertical: true) - .accessibilityLabel(text) - .accessibilityIdentifier("aim.legend") - } - .padding(.horizontal, 18) - .padding(.bottom, 16) - .frame(maxWidth: .infinity, alignment: .leading) - .transition(.opacity) - } - private var message: some View { HStack(alignment: .firstTextBaseline, spacing: 12) { if let symbol = iconName { diff --git a/ios/HouseScanUITests/ScreenStatesUITests.swift b/ios/HouseScanUITests/ScreenStatesUITests.swift index b1499160e..1d5c11add 100644 --- a/ios/HouseScanUITests/ScreenStatesUITests.swift +++ b/ios/HouseScanUITests/ScreenStatesUITests.swift @@ -133,6 +133,10 @@ final class ScreenStatesUITests: XCTestCase { "resultAR": [("ar.overlay", "drawn on your wall")], // #81: the aim ring fills as its stretch is captured. "wallWalk-aim": [("aim.ring", "50 percent captured")], + // At AX5 the card covers the ring's place until the open camera under it scrolls into + // view (testAimRingStaysInTheOpenCameraAtLargestTextSize); the legend under the card + // still says what the ring is for. + "wallWalk-aim-AX5": [("aim.legend", "It fills as your phone captures this spot")], // #82: a second "Can't get there" soon after the first asks before ending the scan. "wallWalk-endScanQuestion": [("instruction", "End the scan here?")], "wallWalk-endScanTooShort": [("instruction", "You haven't walked enough of the wall")], @@ -716,10 +720,11 @@ final class ScreenStatesUITests: XCTestCase { } } - /// B-36: at AX5 the aim card filled the screen and the ring was drawn under it. The card now - /// ends above the middle of the screen with its words scrolling inside it, the actions start - /// below the middle, and the ring shows in the open camera between them without touching - /// the words, the reply or an action. + /// B-36: at AX5 the aim card filled the screen and the ring was drawn under it, where it + /// couldn't be seen. The ring now shows only in the open camera the chrome keeps between the + /// card and the actions. At every position while the chrome scrolls from its top to its end, + /// a ring on screen must be wholly in the window and clear of the words, the reply, the legend + /// and the first action; and it must show at some position, once the open camera reaches it. @MainActor func testAimRingStaysInTheOpenCameraAtLargestTextSize() throws { continueAfterFailure = false @@ -730,43 +735,30 @@ final class ScreenStatesUITests: XCTestCase { XCTAssertTrue(element(app, "screen.wallWalk").waitForExistence(timeout: 15)) let window = app.windows.firstMatch.frame let ring = element(app, "aim.ring") - let words = element(app, "instruction.scroll") - let reply = element(app, "action.cannotAccess") - let mark = element(app, "action.markSomething") - XCTAssertTrue(words.waitForExistence(timeout: 5), "at AX5 the card's words scroll inside it") - XCTAssertTrue(reply.waitForExistence(timeout: 5)) - XCTAssertTrue(mark.waitForExistence(timeout: 5)) - XCTAssertTrue(ring.waitForExistence(timeout: 5), "the aim ring must show in the open camera") - XCTAssertEqual(ring.value as? String, "50 percent captured") - attach(app, name: "wallWalk-aim-AX5-openCamera") - - let cardBottom = max(words.frame.maxY, reply.frame.maxY) - XCTAssertLessThanOrEqual(cardBottom, window.midY - 80, "the card must end above the middle of the screen: \(cardBottom) in \(window)") - XCTAssertGreaterThanOrEqual(mark.frame.minY, window.midY + 80, "the actions must start below the middle of the screen: \(mark.frame)") - XCTAssertTrue(window.contains(ring.frame), "ring \(ring.frame) is not wholly on screen") - for (identifier, frame) in [("instruction.scroll", words.frame), ("action.cannotAccess", reply.frame), ("action.markSomething", mark.frame)] { - XCTAssertFalse(ring.frame.intersects(frame), "ring \(ring.frame) is under \(identifier) \(frame)") - } - // The legend comes with the ring, inside the card, as the last thing under the words. - // Dragging the words must bring its end into the card's visible part, which is the end - // of what the card says, while the reply stays where it is. - let legend = element(app, "aim.legend") - XCTAssertTrue(legend.waitForExistence(timeout: 5), "the first aim ring must come with its legend") - XCTAssertTrue(legend.label.contains("It fills as your phone captures this spot"), "legend reads \(legend.label)") - let replyFrame = reply.frame - let startY = legend.frame.minY - func endShows() -> Bool { - let viewport = words.frame, shown = legend.frame - return shown.maxY <= viewport.maxY + 1 && shown.maxY > viewport.minY - } - for _ in 0..<12 where !endShows() { - let start = words.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.85)) - start.press(forDuration: 0.1, thenDragTo: words.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.15)), withVelocity: .slow, thenHoldForDuration: 0.3) + let covers = ["instruction", "action.cannotAccess", "aim.legend", "action.markSomething"].map { ($0, element(app, $0)) } + XCTAssertTrue(element(app, "action.cannotAccess").waitForExistence(timeout: 5)) + XCTAssertTrue(element(app, "aim.legend").waitForExistence(timeout: 5), "the legend must still show at the largest text size") + attach(app, name: "wallWalk-aim-AX5-top") + + var sightings = 0 + // Short drags, so the scroll passes through every position where the window holds the ring. + for step in 0..<30 { + if ring.exists { + let frame = ring.frame + XCTAssertTrue(window.contains(frame), "step \(step): ring \(frame) is not wholly on screen") + for (identifier, cover) in covers where cover.exists { + XCTAssertFalse(frame.intersects(cover.frame), "step \(step): ring \(frame) is under \(identifier) \(cover.frame)") + } + if sightings == 0 { + XCTAssertEqual(ring.value as? String, "50 percent captured") + attach(app, name: "wallWalk-aim-AX5-openCamera") + } + sightings += 1 + } + let start = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.75)) + start.press(forDuration: 0.1, thenDragTo: start.withOffset(CGVector(dx: 0, dy: -60)), withVelocity: .slow, thenHoldForDuration: 0.2) } - XCTAssertTrue(endShows(), "the legend's end \(legend.frame) never scrolled into the card's words \(words.frame)") - XCTAssertLessThan(legend.frame.minY, startY - 1, "the words never scrolled: the legend stayed at \(startY)") - XCTAssertEqual(reply.frame.minY, replyFrame.minY, accuracy: 1, "the reply must stay put while the words scroll") - attach(app, name: "wallWalk-aim-AX5-legendScrolled") + XCTAssertGreaterThan(sightings, 0, "the ring never showed while the open camera scrolled past it") } /// At AX5 a scrolled onboarding page ran on under the page dots. On every page, before and @@ -802,18 +794,22 @@ final class ScreenStatesUITests: XCTestCase { } } - /// Drags the onboarding page up, at most twelve times, until the bottom of its last element + /// Swipes the page on screen up, at most twelve times, until the bottom of its last element /// shows above the footer. At AX5 a single move can be taller than the page's viewport, so - /// only its bottom edge marks the end. + /// only its bottom edge marks the end. The swipe goes to the page's own scroll view: a drag + /// at a screen point left the second page where it was (run 36743027718). @MainActor private func scrollPageToEnd(_ app: XCUIApplication, last: XCUIElement, footer: XCUIElement) -> Bool { + let window = app.windows.firstMatch.frame func atEnd() -> Bool { let frame = last.frame - return frame.maxY <= footer.frame.minY + 1 && frame.maxY > app.windows.firstMatch.frame.minY + return frame.maxY <= footer.frame.minY + 1 && frame.maxY > window.minY + } + guard let page = app.scrollViews.allElementsBoundByIndex.first(where: { abs($0.frame.minX - window.minX) < 1 && $0.frame.width > 0 }) else { + return false } for _ in 0..<12 where !atEnd() { - let start = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.6)) - start.press(forDuration: 0.1, thenDragTo: start.withOffset(CGVector(dx: 0, dy: -240)), withVelocity: .slow, thenHoldForDuration: 0.3) + page.swipeUp(velocity: .slow) } return atEnd() } @@ -928,7 +924,8 @@ final class ScreenStatesUITests: XCTestCase { if screen == "result" { tap(app, "result.details") } XCTAssertTrue(element(app, "action.shareScan").waitForExistence(timeout: 5), "\(name): Share scan is missing") } - for expected in Self.expectations[name.hasSuffix("-AX5") ? String(name.dropLast(4)) : name] ?? [] { + // An AX5 state takes its own entry where it has one, else the default size's. + for expected in Self.expectations[name] ?? Self.expectations[name.hasSuffix("-AX5") ? String(name.dropLast(4)) : name] ?? [] { let found: Bool if let identifier = expected.identifier { let target = ElementRead.snapshot(element(app, identifier)) From bec7935264b21b39080ecbff683a615bdf9611fa Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Wed, 30 Sep 2026 13:50:49 -0500 Subject: [PATCH 10/48] ios: fit the aim ring to its clearance and keep the arrow short of the target - Near the card or the actions, a ring that doesn't fit at its size shows at the largest radius that fits whole, down to the smallest ring's. - A target inside the arrow lane put the lane's edge past it, with the arrow pointing back; the arrow now stops a disc short of the target. - Each onboarding page's scroll view has an identifier, and the test swipes the one on screen rather than the first scroll view it finds. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../UI/Camera/WayfindingOverlay.swift | 21 ++++++++++++++++++- .../UI/Screens/OnboardingScreen.swift | 1 + .../ScreenStatesUITests.swift | 9 +++++--- 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/ios/HouseScan/UI/Camera/WayfindingOverlay.swift b/ios/HouseScan/UI/Camera/WayfindingOverlay.swift index 1b4ced2a3..24941b8ad 100644 --- a/ios/HouseScan/UI/Camera/WayfindingOverlay.swift +++ b/ios/HouseScan/UI/Camera/WayfindingOverlay.swift @@ -146,6 +146,11 @@ struct WayfindingOverlay: View { if Self.ringBounds(in: size, band: band, radius: radius).contains(point) { return .onScreen(point, radius: radius) } + // Near the card or the actions a smaller ring can still fit whole; below the smallest + // ring's size, the arrow takes over. + if let band, let fitted = Self.fittedRadius(at: point, in: size, band: band), fitted < radius { + return .onScreen(point, radius: fitted) + } // On screen but under the card or the actions. The camera's own direction to it // starts at the optical axis, which can be the target itself, so the arrow points // from the lane to where the target is drawn. @@ -157,7 +162,13 @@ struct WayfindingOverlay: View { // open camera is too short to guide in, so neither shows until it grows. guard length > 1 else { return .hidden } let direction = CGVector(dx: offset.dx / length, dy: offset.dy / length) - return .offScreen(Self.arrowPoint(toward: direction, in: size, band: band), angle: .radians(atan2(direction.dy, direction.dx))) + // A target inside the lane would put the lane's edge past it, with the arrow + // pointing back the way it came; the arrow stops a disc short of the target. + let edge = Self.arrowPoint(toward: direction, in: size, band: band) + let toEdge = ((edge.x - lane.midX) * (edge.x - lane.midX) + (edge.y - lane.midY) * (edge.y - lane.midY)).squareRoot() + let reach = min(toEdge, max(length - Self.arrowDiameter, 0)) + let arrow = CGPoint(x: lane.midX + direction.dx * reach, y: lane.midY + direction.dy * reach) + return .offScreen(arrow, angle: .radians(atan2(direction.dy, direction.dx))) } } guard let direction = projection.screenDirection(toward: target) else { @@ -186,6 +197,14 @@ struct WayfindingOverlay: View { ) } + /// The largest ring radius, down to the smallest ring's 30 pt, that fits whole at `point` in + /// the band and on screen, pulse included. Nil when not even that fits. + static func fittedRadius(at point: CGPoint, in size: CGSize, band: Band) -> CGFloat? { + let clearance = min(point.y - band.top, band.bottom - point.y, point.x, size.width - point.x) + let radius = clearance / 1.08 + return radius >= 30 ? radius : nil + } + /// Where the edge arrows keep: the band inset by `arrowInset`. A band too short for that /// leaves a lane of no height on the band's middle line, where the arrow overlaps the band /// least. Without a band, the fixed lane used before the chrome reported one. diff --git a/ios/HouseScan/UI/Screens/OnboardingScreen.swift b/ios/HouseScan/UI/Screens/OnboardingScreen.swift index 62776a004..5a935c8f0 100644 --- a/ios/HouseScan/UI/Screens/OnboardingScreen.swift +++ b/ios/HouseScan/UI/Screens/OnboardingScreen.swift @@ -228,6 +228,7 @@ private struct OnboardingPageView: View { .frame(maxWidth: .infinity) } .scrollBounceBehavior(.basedOnSize) + .accessibilityIdentifier("onboarding.page") } private var text: some View { diff --git a/ios/HouseScanUITests/ScreenStatesUITests.swift b/ios/HouseScanUITests/ScreenStatesUITests.swift index 1d5c11add..17f84bac2 100644 --- a/ios/HouseScanUITests/ScreenStatesUITests.swift +++ b/ios/HouseScanUITests/ScreenStatesUITests.swift @@ -631,8 +631,9 @@ final class ScreenStatesUITests: XCTestCase { XCTAssertFalse(legend.frame.intersects(card.frame), "the legend must keep clear of the card: \(legend.frame) vs \(card.frame)") app.terminate() - // At the largest text size the card's words scroll inside it, and the legend scrolls with - // them in the card instead of going behind it or disappearing. + // At the largest text size the card fills most of the screen. The legend sits under it in + // the same stack, so it grows and scrolls with the card instead of going behind it or + // disappearing. app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoFreeze", "-uiDemoPhase", "wallWalk", "-uiDemoAim"] + Self.largestText app.launch() XCTAssertTrue(element(app, "screen.wallWalk").waitForExistence(timeout: 15)) @@ -805,7 +806,9 @@ final class ScreenStatesUITests: XCTestCase { let frame = last.frame return frame.maxY <= footer.frame.minY + 1 && frame.maxY > window.minY } - guard let page = app.scrollViews.allElementsBoundByIndex.first(where: { abs($0.frame.minX - window.minX) < 1 && $0.frame.width > 0 }) else { + // The page on screen, not its neighbours in the pager. + guard let page = app.scrollViews.matching(identifier: "onboarding.page").allElementsBoundByIndex + .first(where: { abs($0.frame.minX - window.minX) < 1 && $0.frame.width > 0 }) else { return false } for _ in 0..<12 where !atEnd() { From 22f78017187e7bd45785ce8d03baca3f988ed61d Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Wed, 30 Sep 2026 14:50:28 -0500 Subject: [PATCH 11/48] ios: draw nothing for an aim target under the card At bec79352 the every-state audit failed contrast with no element in the four AX5 walking states whose open camera starts on screen (run 36761500732). Their screenshots show one new thing: an edge arrow in the open camera pointing at the target under the card, over the path's dots. At e7031990 those states drew the ring out of sight under the card and passed. A target on screen but under the card or the actions now draws nothing until the open camera reaches it; arrows stay for targets off screen or at its sides. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../UI/Camera/WayfindingOverlay.swift | 35 ++++++------------- 1 file changed, 11 insertions(+), 24 deletions(-) diff --git a/ios/HouseScan/UI/Camera/WayfindingOverlay.swift b/ios/HouseScan/UI/Camera/WayfindingOverlay.swift index 24941b8ad..21a69a476 100644 --- a/ios/HouseScan/UI/Camera/WayfindingOverlay.swift +++ b/ios/HouseScan/UI/Camera/WayfindingOverlay.swift @@ -146,29 +146,17 @@ struct WayfindingOverlay: View { if Self.ringBounds(in: size, band: band, radius: radius).contains(point) { return .onScreen(point, radius: radius) } - // Near the card or the actions a smaller ring can still fit whole; below the smallest - // ring's size, the arrow takes over. + // Near the card or the actions a smaller ring can still fit whole. if let band, let fitted = Self.fittedRadius(at: point, in: size, band: band), fitted < radius { return .onScreen(point, radius: fitted) } - // On screen but under the card or the actions. The camera's own direction to it - // starts at the optical axis, which can be the target itself, so the arrow points - // from the lane to where the target is drawn. - if band != nil, CGRect(origin: .zero, size: size).contains(point) { - let lane = Self.lane(in: size, band: band) - let offset = CGVector(dx: point.x - lane.midX, dy: point.y - lane.midY) - let length = (offset.dx * offset.dx + offset.dy * offset.dy).squareRoot() - // At the lane's middle there is no way to point, and the ring doesn't fit: the - // open camera is too short to guide in, so neither shows until it grows. - guard length > 1 else { return .hidden } - let direction = CGVector(dx: offset.dx / length, dy: offset.dy / length) - // A target inside the lane would put the lane's edge past it, with the arrow - // pointing back the way it came; the arrow stops a disc short of the target. - let edge = Self.arrowPoint(toward: direction, in: size, band: band) - let toEdge = ((edge.x - lane.midX) * (edge.x - lane.midX) + (edge.y - lane.midY) * (edge.y - lane.midY)).squareRoot() - let reach = min(toEdge, max(length - Self.arrowDiameter, 0)) - let arrow = CGPoint(x: lane.midX + direction.dx * reach, y: lane.midY + direction.dy * reach) - return .offScreen(arrow, angle: .radians(atan2(direction.dy, direction.dx))) + // On screen but under the card or the actions: nothing, as before the chrome reported + // its open camera, when the ring was drawn there out of sight. The ring shows once + // the open camera reaches it. An arrow there pointed at a place the homeowner can't + // see, and failed the accessibility audit's contrast check over the path's dots at + // AX5 (run 36761500732). + if let band, point.y >= 0, point.y <= size.height, point.y < band.top || point.y > band.bottom { + return .hidden } } guard let direction = projection.screenDirection(toward: target) else { @@ -178,10 +166,9 @@ struct WayfindingOverlay: View { } /// Where a ring's centre may be for the ring to show: with the whole ring, at the largest - /// point of its pulse, on screen and in the open camera. Near the edges or partly under the - /// card, the arrow takes over, since a half-visible ring would be ambiguous and one under - /// the card can't be seen at all. Without a band, the fixed rectangle used before the chrome - /// reported one, sized for the default text. + /// point of its pulse, on screen and in the open camera. Near the screen's edges the arrow + /// takes over, since a half-visible ring would be ambiguous. Without a band, the fixed + /// rectangle used before the chrome reported one, sized for the default text. static func ringBounds(in size: CGSize, band: Band?, radius: CGFloat) -> CGRect { guard let band else { return CGRect(x: 36, y: 150, width: size.width - 72, height: size.height - 330) From e297d383e2b3a812ca3fda3c78fb2ab908120727 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Wed, 30 Sep 2026 15:48:58 -0500 Subject: [PATCH 12/48] test(ios): give frameless audit findings the edge scroll located ones get The every-state audit fails an issue only when two passes find it, and scrolls between them to bring a cut-off element into full view. An issue reported with no element has no frame, so nothing scrolled, and the four AX5 walking states whose open camera reaches the bottom edge failed contrast with no element (run 36768462318). With such a finding, the screen now scrolls one step before the second pass. An issue still on screen after it fails the test as before. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../ScreenStatesUITests.swift | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/ios/HouseScanUITests/ScreenStatesUITests.swift b/ios/HouseScanUITests/ScreenStatesUITests.swift index 17f84bac2..6d726309b 100644 --- a/ios/HouseScanUITests/ScreenStatesUITests.swift +++ b/ios/HouseScanUITests/ScreenStatesUITests.swift @@ -946,7 +946,7 @@ final class ScreenStatesUITests: XCTestCase { // audit, after the banner's few seconds on screen, finds it again. let outcome = try AccessibilityAudit.run(app) { first in Thread.sleep(forTimeInterval: 6) - revealCutOff(first.findings.values.compactMap(\.frame), in: app) + revealCutOff(first.findings.values.compactMap(\.frame), unlocated: first.findings.values.contains { $0.frame == nil }, in: app) } if !outcome.unread.isEmpty { let note = XCTAttachment(string: outcome.unread.joined(separator: "\n")) @@ -988,13 +988,24 @@ final class ScreenStatesUITests: XCTestCase { /// the second audit the screen scrolls until every flagged element that crosses the bottom /// edge is in full view; one that still fails there fails the test. @MainActor - private func revealCutOff(_ frames: [CGRect], in app: XCUIApplication) { + private func revealCutOff(_ frames: [CGRect], unlocated: Bool, in app: XCUIApplication) { let screen = app.windows.firstMatch.frame - guard let lowest = frames.filter({ $0.minY < screen.maxY && $0.maxY > screen.maxY }).map(\.maxY).max() else { return } + let start = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.7)) + guard let lowest = frames.filter({ $0.minY < screen.maxY && $0.maxY > screen.maxY }).map(\.maxY).max() else { + // An issue the audit reports with no element has no frame to scroll to. At AX5 it + // reported a contrast failure with no element on each walking state whose open camera + // reaches the bottom edge, where the first action's top or a path dot is cut off + // (run 36768462318); which node it measured is not identified. One step of the same + // slow drag gives an edge sliver the full view a homeowner would scroll to. An issue + // still on screen after the step is found again and fails the test. + if unlocated { + start.press(forDuration: 0.1, thenDragTo: start.withOffset(CGVector(dx: 0, dy: -screen.height * 0.25)), withVelocity: .slow, thenHoldForDuration: 0.5) + } + return + } let distance = min(lowest - screen.maxY + 60, screen.height * 0.5) // A slow drag from mid-screen: it scrolls by about the distance dragged, without the // momentum a swipe adds. - let start = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.7)) start.press(forDuration: 0.1, thenDragTo: start.withOffset(CGVector(dx: 0, dy: -distance)), withVelocity: .slow, thenHoldForDuration: 0.5) } From 455749d31d9da0a0df6b8406f73a752f20e5b673 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Wed, 30 Sep 2026 16:49:04 -0500 Subject: [PATCH 13/48] Revert "test(ios): give frameless audit findings the edge scroll located ones get" This reverts commit e297d383e2b3a812ca3fda3c78fb2ab908120727. --- .../ScreenStatesUITests.swift | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/ios/HouseScanUITests/ScreenStatesUITests.swift b/ios/HouseScanUITests/ScreenStatesUITests.swift index 6d726309b..17f84bac2 100644 --- a/ios/HouseScanUITests/ScreenStatesUITests.swift +++ b/ios/HouseScanUITests/ScreenStatesUITests.swift @@ -946,7 +946,7 @@ final class ScreenStatesUITests: XCTestCase { // audit, after the banner's few seconds on screen, finds it again. let outcome = try AccessibilityAudit.run(app) { first in Thread.sleep(forTimeInterval: 6) - revealCutOff(first.findings.values.compactMap(\.frame), unlocated: first.findings.values.contains { $0.frame == nil }, in: app) + revealCutOff(first.findings.values.compactMap(\.frame), in: app) } if !outcome.unread.isEmpty { let note = XCTAttachment(string: outcome.unread.joined(separator: "\n")) @@ -988,24 +988,13 @@ final class ScreenStatesUITests: XCTestCase { /// the second audit the screen scrolls until every flagged element that crosses the bottom /// edge is in full view; one that still fails there fails the test. @MainActor - private func revealCutOff(_ frames: [CGRect], unlocated: Bool, in app: XCUIApplication) { + private func revealCutOff(_ frames: [CGRect], in app: XCUIApplication) { let screen = app.windows.firstMatch.frame - let start = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.7)) - guard let lowest = frames.filter({ $0.minY < screen.maxY && $0.maxY > screen.maxY }).map(\.maxY).max() else { - // An issue the audit reports with no element has no frame to scroll to. At AX5 it - // reported a contrast failure with no element on each walking state whose open camera - // reaches the bottom edge, where the first action's top or a path dot is cut off - // (run 36768462318); which node it measured is not identified. One step of the same - // slow drag gives an edge sliver the full view a homeowner would scroll to. An issue - // still on screen after the step is found again and fails the test. - if unlocated { - start.press(forDuration: 0.1, thenDragTo: start.withOffset(CGVector(dx: 0, dy: -screen.height * 0.25)), withVelocity: .slow, thenHoldForDuration: 0.5) - } - return - } + guard let lowest = frames.filter({ $0.minY < screen.maxY && $0.maxY > screen.maxY }).map(\.maxY).max() else { return } let distance = min(lowest - screen.maxY + 60, screen.height * 0.5) // A slow drag from mid-screen: it scrolls by about the distance dragged, without the // momentum a swipe adds. + let start = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.7)) start.press(forDuration: 0.1, thenDragTo: start.withOffset(CGVector(dx: 0, dy: -distance)), withVelocity: .slow, thenHoldForDuration: 0.5) } From 545448eeb100b668f01e471fb7658d1112da199d Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Wed, 30 Sep 2026 16:49:14 -0500 Subject: [PATCH 14/48] ios: drop the AX5 open-camera minimum With a 180 pt open camera kept below the card at accessibility sizes, the every-state audit failed contrast with no element on the four AX5 walking states whose short card leaves that camera on screen, in three runs (36761500732, 36768462318, 36775276861), and a scroll step between passes did not clear it. The node it measured is not identified. The spacer is back to zero at every size, as at e7031990, whose AX5 states passed; the frameless-finding scroll is reverted. The ring still shows only where it fits in the measured open camera, so it is never drawn under the card; at AX5, where the chrome overflows, that means it is not drawn. The B-36 test now checks that invariant across the chrome's scroll positions. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../UI/Components/CameraChrome.swift | 20 +++++--------- .../ScreenStatesUITests.swift | 27 +++++++------------ 2 files changed, 16 insertions(+), 31 deletions(-) diff --git a/ios/HouseScan/UI/Components/CameraChrome.swift b/ios/HouseScan/UI/Components/CameraChrome.swift index 267b56d11..8e9eb9db6 100644 --- a/ios/HouseScan/UI/Components/CameraChrome.swift +++ b/ios/HouseScan/UI/Components/CameraChrome.swift @@ -24,7 +24,6 @@ struct CameraChrome: View { var cameraWindow: CameraWindow? = nil @ViewBuilder var bottom: Bottom - @Environment(\.dynamicTypeSize) private var typeSize @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { @@ -65,10 +64,12 @@ struct CameraChrome: View { if let legend { legendLine(legend) } - // The open camera between the card and the actions. At the accessibility sizes the - // card can fill the screen, so the chrome keeps at least `minCameraWindow` of camera - // here, which scrolls into view; the aim ring shows only inside it (B-36). - Spacer(minLength: typeSize.isAccessibilitySize ? CameraChromeLayout.minCameraWindow : 0) + // The open camera between the card and the actions; the aim ring shows only inside + // it (B-36). At the largest text sizes it can be empty. A 180 pt minimum here, kept + // open below the card, failed the every-state audit's contrast check with no element + // on the four AX5 walking states whose card leaves it on screen (runs 36761500732, + // 36768462318, 36775276861); what the audit measured is not identified. + Spacer(minLength: 0) .onGeometryChange(for: CGRect.self, of: { $0.frame(in: .global) }) { window in if let cameraWindow, cameraWindow.frame != window { cameraWindow.frame = window } } @@ -108,15 +109,6 @@ final class CameraWindow { var frame: CGRect? } -/// `CameraChrome`'s layout constants, outside it because a generic type can't hold stored -/// static properties. -enum CameraChromeLayout { - /// The least open camera kept between the card and the actions at the accessibility sizes: - /// room for the largest aim ring (64 pt radius, 108 percent at its pulse) with a margin. A - /// layout choice, not measured on a phone. - static let minCameraWindow: CGFloat = 180 -} - /// Reads the full-screen camera view size so buttons can send "the reticle" (nil point) /// with the right `viewSize`. struct CameraSizeReader: View { diff --git a/ios/HouseScanUITests/ScreenStatesUITests.swift b/ios/HouseScanUITests/ScreenStatesUITests.swift index 17f84bac2..624330a06 100644 --- a/ios/HouseScanUITests/ScreenStatesUITests.swift +++ b/ios/HouseScanUITests/ScreenStatesUITests.swift @@ -133,9 +133,9 @@ final class ScreenStatesUITests: XCTestCase { "resultAR": [("ar.overlay", "drawn on your wall")], // #81: the aim ring fills as its stretch is captured. "wallWalk-aim": [("aim.ring", "50 percent captured")], - // At AX5 the card covers the ring's place until the open camera under it scrolls into - // view (testAimRingStaysInTheOpenCameraAtLargestTextSize); the legend under the card - // still says what the ring is for. + // At AX5 the card and its legend fill the screen and the ring is not drawn under them + // (testAimRingIsNeverDrawnUnderTheChromeAtLargestTextSize); the legend still says what + // the ring is for. "wallWalk-aim-AX5": [("aim.legend", "It fills as your phone captures this spot")], // #82: a second "Can't get there" soon after the first asks before ending the scan. "wallWalk-endScanQuestion": [("instruction", "End the scan here?")], @@ -722,12 +722,12 @@ final class ScreenStatesUITests: XCTestCase { } /// B-36: at AX5 the aim card filled the screen and the ring was drawn under it, where it - /// couldn't be seen. The ring now shows only in the open camera the chrome keeps between the - /// card and the actions. At every position while the chrome scrolls from its top to its end, - /// a ring on screen must be wholly in the window and clear of the words, the reply, the legend - /// and the first action; and it must show at some position, once the open camera reaches it. + /// couldn't be seen. The ring now shows only in the open camera between the card and the + /// actions. At every position while the chrome scrolls from its top to its end, a ring on + /// screen must be wholly on screen and clear of the words, the reply, the legend and the + /// first action. @MainActor - func testAimRingStaysInTheOpenCameraAtLargestTextSize() throws { + func testAimRingIsNeverDrawnUnderTheChromeAtLargestTextSize() throws { continueAfterFailure = false let app = XCUIApplication() app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoFreeze", "-uiDemoPhase", "wallWalk", "-uiDemoAim"] + Self.largestText @@ -740,9 +740,7 @@ final class ScreenStatesUITests: XCTestCase { XCTAssertTrue(element(app, "action.cannotAccess").waitForExistence(timeout: 5)) XCTAssertTrue(element(app, "aim.legend").waitForExistence(timeout: 5), "the legend must still show at the largest text size") attach(app, name: "wallWalk-aim-AX5-top") - - var sightings = 0 - // Short drags, so the scroll passes through every position where the window holds the ring. + // Short drags, so the scroll passes through every position where a ring could show. for step in 0..<30 { if ring.exists { let frame = ring.frame @@ -750,16 +748,11 @@ final class ScreenStatesUITests: XCTestCase { for (identifier, cover) in covers where cover.exists { XCTAssertFalse(frame.intersects(cover.frame), "step \(step): ring \(frame) is under \(identifier) \(cover.frame)") } - if sightings == 0 { - XCTAssertEqual(ring.value as? String, "50 percent captured") - attach(app, name: "wallWalk-aim-AX5-openCamera") - } - sightings += 1 } let start = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.75)) start.press(forDuration: 0.1, thenDragTo: start.withOffset(CGVector(dx: 0, dy: -60)), withVelocity: .slow, thenHoldForDuration: 0.2) } - XCTAssertGreaterThan(sightings, 0, "the ring never showed while the open camera scrolled past it") + attach(app, name: "wallWalk-aim-AX5-end") } /// At AX5 a scrolled onboarding page ran on under the page dots. On every page, before and From 30e791a6bba809c5e7e2141bf5986a4342641865 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Thu, 1 Oct 2026 00:28:41 -0500 Subject: [PATCH 15/48] ios: fold the aim card's how-to words under Details at AX5 (B-36) At the accessibility sizes, on a step where the homeowner aims the camera, the instruction card keeps the task, the eyebrow, ride-along coaching such as "Slow down" and its reply in view, and folds the detail and the aim ring's legend under a "Details" DisclosureGroup, the word and control the result screen uses. The chrome keeps 180 pt of open camera under the card on those steps; the actions scroll into reach. A target under the card or actions gets an arrow in the open camera pointing to it. Questions, marking, refusals and coaching that replaces the task keep every word in view. Default sizes render as before. Ride-along coaching now travels in Instruction.note instead of being appended to the detail, so folding the detail cannot hide it. Stacked wall-walk actions drop their icons, which broke "Mark something" into three lines at AX5. The rejected-upload test uses the pass sample and skips the review's own view, so no single wait covers a second capture, two uploads and the server's follow-up (run 36736877861 timed out still uploading). A temporary audit of the seven aiming states at AX5 stands in for the every-state run while this iterates. Co-Authored-By: Claude Opus 5.5 (1M context) --- ios/HouseScan/UI/Camera/CameraOverlays.swift | 4 +- .../UI/Camera/WayfindingOverlay.swift | 23 ++- .../UI/Components/CameraChrome.swift | 35 +++-- .../UI/Components/InstructionCard.swift | 65 ++++++++- ios/HouseScan/UI/Copy/ScanCopy.swift | 13 +- .../UI/Screens/GapRequestScreen.swift | 12 +- ios/HouseScan/UI/Screens/WallWalkScreen.swift | 35 +++-- .../ScreenStatesUITests.swift | 133 +++++++++++++++--- 8 files changed, 268 insertions(+), 52 deletions(-) diff --git a/ios/HouseScan/UI/Camera/CameraOverlays.swift b/ios/HouseScan/UI/Camera/CameraOverlays.swift index 5d067eeb7..22ecd5db0 100644 --- a/ios/HouseScan/UI/Camera/CameraOverlays.swift +++ b/ios/HouseScan/UI/Camera/CameraOverlays.swift @@ -141,8 +141,8 @@ struct CameraOverlays: View { /// It goes under the instruction card, in the chrome's own stack, rather than beside the /// ring: beside it, it went behind the card at accessibility text sizes (the card grows /// and is drawn over the camera layers), and any layout that dropped it where there was no - /// room left the people with the largest text without it. In the stack it grows with the - /// card and scrolls with it at any size. + /// room left the people with the largest text without it. At the accessibility sizes it + /// folds under the card's Details with the card's other how-to words (`CameraChrome.aims`). private var shownLegend: String? { guard fillingRingShown, !legendRetired, state.marking == nil, heldTarget == nil, let progress = state.aimProgress, progress < 1 else { return nil } diff --git a/ios/HouseScan/UI/Camera/WayfindingOverlay.swift b/ios/HouseScan/UI/Camera/WayfindingOverlay.swift index 21a69a476..d4835e151 100644 --- a/ios/HouseScan/UI/Camera/WayfindingOverlay.swift +++ b/ios/HouseScan/UI/Camera/WayfindingOverlay.swift @@ -150,13 +150,24 @@ struct WayfindingOverlay: View { if let band, let fitted = Self.fittedRadius(at: point, in: size, band: band), fitted < radius { return .onScreen(point, radius: fitted) } - // On screen but under the card or the actions: nothing, as before the chrome reported - // its open camera, when the ring was drawn there out of sight. The ring shows once - // the open camera reaches it. An arrow there pointed at a place the homeowner can't - // see, and failed the accessibility audit's contrast check over the path's dots at - // AX5 (run 36761500732). + // On screen but under the card or the actions: the arrow in the open camera points + // to where the spot is drawn, as the edge arrow does for a spot off screen, so the + // homeowner tilts toward it and the ring comes into the open. The camera's own + // direction to it starts at the optical axis, which can be the spot itself. An arrow + // here was taken out at 22f78017 on a guess about the audit; the audit's finding + // stayed without it (run 36768462318). if let band, point.y >= 0, point.y <= size.height, point.y < band.top || point.y > band.bottom { - return .hidden + let lane = Self.lane(in: size, band: band) + let offset = CGVector(dx: point.x - lane.midX, dy: point.y - lane.midY) + let length = (offset.dx * offset.dx + offset.dy * offset.dy).squareRoot() + guard length > 1 else { return .hidden } + let direction = CGVector(dx: offset.dx / length, dy: offset.dy / length) + // Stops a disc short of the spot, so it never lands past it pointing back. + let edge = Self.arrowPoint(toward: direction, in: size, band: band) + let toEdge = ((edge.x - lane.midX) * (edge.x - lane.midX) + (edge.y - lane.midY) * (edge.y - lane.midY)).squareRoot() + let reach = min(toEdge, max(length - Self.arrowDiameter, 0)) + let arrow = CGPoint(x: lane.midX + direction.dx * reach, y: lane.midY + direction.dy * reach) + return .offScreen(arrow, angle: .radians(atan2(direction.dy, direction.dx))) } } guard let direction = projection.screenDirection(toward: target) else { diff --git a/ios/HouseScan/UI/Components/CameraChrome.swift b/ios/HouseScan/UI/Components/CameraChrome.swift index 8e9eb9db6..2e5919009 100644 --- a/ios/HouseScan/UI/Components/CameraChrome.swift +++ b/ios/HouseScan/UI/Components/CameraChrome.swift @@ -16,15 +16,22 @@ struct CameraChrome: View { /// the window's global space). Nil when the screen has nothing to tap. var onCameraTap: ((CGPoint) -> Void)? /// A line under the card about something drawn on the camera: the aim ring's legend - /// (`CameraOverlays`). It is part of the chrome's stack, so it grows with the card and - /// scrolls with it at the largest text sizes, where the card covers most of the camera. + /// (`CameraOverlays`). On an aiming step at the accessibility sizes it folds under the + /// card's Details instead (`aims`). var legend: String? = nil /// Given the open camera between the card and the actions, for the aim ring to stay inside /// (`WayfindingOverlay`). It moves when the chrome scrolls. var cameraWindow: CameraWindow? = nil + /// Whether this step has the homeowner aim the camera, so at the accessibility text sizes + /// the card folds its detail and the legend under "Details" (`InstructionCard.foldsDetail`) + /// and leaves the camera open. Questions and refusals keep every word in view. + var aims = false @ViewBuilder var bottom: Bottom @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.dynamicTypeSize) private var typeSize + + private var folds: Bool { aims && typeSize.isAccessibilitySize } var body: some View { // One layout at every text size: the chrome fills the screen and, only when the largest @@ -60,16 +67,17 @@ struct CameraChrome: View { } } .frame(minHeight: 36) - InstructionCard(instruction: instruction, tone: tone, reply: reply, eyebrow: eyebrow) - if let legend { + InstructionCard( + instruction: instruction, tone: tone, reply: reply, eyebrow: eyebrow, + foldsDetail: folds, legend: folds ? legend : nil + ) + if let legend, !folds { legendLine(legend) } // The open camera between the card and the actions; the aim ring shows only inside - // it (B-36). At the largest text sizes it can be empty. A 180 pt minimum here, kept - // open below the card, failed the every-state audit's contrast check with no element - // on the four AX5 walking states whose card leaves it on screen (runs 36761500732, - // 36768462318, 36775276861); what the audit measured is not identified. - Spacer(minLength: 0) + // it (B-36). On an aiming step at the accessibility sizes it keeps at least + // `minCameraWindow`, and the actions below scroll into reach. + Spacer(minLength: folds ? CameraChromeLayout.minCameraWindow : 0) .onGeometryChange(for: CGRect.self, of: { $0.frame(in: .global) }) { window in if let cameraWindow, cameraWindow.frame != window { cameraWindow.frame = window } } @@ -109,6 +117,15 @@ final class CameraWindow { var frame: CGRect? } +/// `CameraChrome`'s layout constants, outside it because a generic type can't hold stored +/// static properties. +enum CameraChromeLayout { + /// The least open camera on an aiming step at the accessibility sizes: room for the largest + /// aim ring (64 pt radius, 108 percent at its pulse) with a margin. A layout choice checked + /// against the iPhone 17 frames, not measured on a phone. + static let minCameraWindow: CGFloat = 180 +} + /// Reads the full-screen camera view size so buttons can send "the reticle" (nil point) /// with the right `viewSize`. struct CameraSizeReader: View { diff --git a/ios/HouseScan/UI/Components/InstructionCard.swift b/ios/HouseScan/UI/Components/InstructionCard.swift index 9a3773c21..3b4bfc0ea 100644 --- a/ios/HouseScan/UI/Components/InstructionCard.swift +++ b/ios/HouseScan/UI/Components/InstructionCard.swift @@ -34,6 +34,15 @@ struct InstructionCard: View { /// A short line above the instruction that places it in the flow ("One more view to /// finish"). Read with the instruction as one VoiceOver element. var eyebrow: String? + /// Whether the detail, and `legend`, fold under a "Details" disclosure. `CameraChrome` sets + /// it at the accessibility text sizes on steps where the homeowner aims the camera: there + /// the full card filled the screen and covered the aim ring (B-36). The title, the eyebrow + /// and coaching riding along (`Instruction.note`) always show, since they say what to do + /// now; the detail and the legend say how. + var foldsDetail = false + /// The aim ring's legend, inside the folded details (`foldsDetail`). Otherwise the legend + /// is the chrome's own line under the card (`CameraChrome.legend`). + var legend: String? = nil /// How long a new card's reply ignores taps, so a tap meant for the card that just left /// can't answer the next one: in the 4.1 field test, quick taps on "Can't get there" each @@ -45,14 +54,22 @@ struct InstructionCard: View { /// The task whose reply has been on screen for `replyLock`. Compared with the current task /// in the same update that changes it, so a new reply is locked from its first frame. @State private var unlockedTask: Instruction? + /// Whether the folded details are open. Closed again for each new step (a new title), so the + /// camera opens up again; the distance to go and coaching change within a step and keep it. + @State private var detailsShown = false private var replyTask: Instruction { reply?.task ?? instruction } + private var hasDetails: Bool { foldsDetail && (instruction.detail != nil || legend != nil) } + var body: some View { VStack(alignment: .trailing, spacing: 0) { message .id(instruction) .transition(.identity) + if hasDetails { + details + } if let reply { Button(reply.title, action: reply.perform) .font(Typeface.caption.weight(.bold)) @@ -91,6 +108,7 @@ struct InstructionCard: View { .background(ScrimShape.rounded()) .animation(reduceMotion ? .easeOut(duration: 0.15) : Motion.text, value: instruction) .animation(Motion.text, value: reply == nil) + .onChange(of: instruction.title) { detailsShown = false } .task(id: replyTask) { // Cleared first, so a task that comes back within the lock (A, B, A) is locked again. let shown = replyTask @@ -122,8 +140,9 @@ struct InstructionCard: View { .font(Typeface.instruction) .foregroundStyle(Palette.chalk) .fixedSize(horizontal: false, vertical: true) - if let detail = instruction.detail { - Text(detail) + // Folded, only the coaching stays under the title; the detail is in `details`. + if let line = foldsDetail ? instruction.note : instruction.detailAndNote { + Text(line) .font(Typeface.hint) .foregroundStyle(Palette.chalk.opacity(0.92)) .fixedSize(horizontal: false, vertical: true) @@ -138,6 +157,48 @@ struct InstructionCard: View { .accessibilityIdentifier("instruction") } + /// The detail and the legend under "Details", the word and control the result screen + /// uses for its folded part. A DisclosureGroup, so VoiceOver reads it as collapsed or + /// expanded. The words appear at once, like the card's own: a fade spends its moments as + /// faint text, which the accessibility audit reports as low contrast. + private var details: some View { + DisclosureGroup(isExpanded: $detailsShown) { + VStack(alignment: .leading, spacing: 12) { + if let detail = instruction.detail { + Text(detail) + .font(Typeface.hint) + .foregroundStyle(Palette.chalk.opacity(0.92)) + .fixedSize(horizontal: false, vertical: true) + .accessibilityIdentifier("instruction.detail") + } + if let legend { + // One Text with the symbol inline, as the chrome's legend line is. + Text("\(Image(systemName: "scope")) \(legend)") + .font(Typeface.hint) + .foregroundStyle(Palette.chalk) + .fixedSize(horizontal: false, vertical: true) + .accessibilityLabel(legend) + .accessibilityIdentifier("aim.legend") + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 4) + .transition(.identity) + } label: { + Text(ScanCopy.details) + .font(Typeface.hint.weight(.semibold)) + .foregroundStyle(Palette.chalk) + .frame(minHeight: Metrics.minTarget) + .accessibilityIdentifier("instruction.details") + } + .tint(Palette.chalk) + .transaction { $0.animation = nil } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 18) + .padding(.top, -8) + .padding(.bottom, reply == nil ? 8 : 4) + } + private var iconName: String? { switch tone { case .normal: nil diff --git a/ios/HouseScan/UI/Copy/ScanCopy.swift b/ios/HouseScan/UI/Copy/ScanCopy.swift index c0b349af2..b2b65e12e 100644 --- a/ios/HouseScan/UI/Copy/ScanCopy.swift +++ b/ios/HouseScan/UI/Copy/ScanCopy.swift @@ -6,6 +6,16 @@ import HouseScanKit struct Instruction: Hashable { var title: String var detail: String? + /// Coaching riding along with the task (`ScanCopy.withCoaching`), such as "Slow down.". Kept + /// apart from `detail` so a card that folds its detail away at the largest text sizes still + /// shows it (`InstructionCard.foldsDetail`). + var note: String? = nil + + /// The second line as one text: the detail, then the note on a line of its own. + var detailAndNote: String? { + let lines = [detail, note].compactMap(\.self) + return lines.isEmpty ? nil : lines.joined(separator: "\n") + } } /// All user-facing words for engine values. The engine sends meanings (contract `GuidanceStep`, @@ -173,8 +183,7 @@ enum ScanCopy { /// coaching came up (#80), and the dark coaching can stay up for a whole night walk. static func withCoaching(_ task: Instruction, _ coaching: Coaching?) -> Instruction { guard let coaching else { return task } - let detail = [task.detail, coachingNote(coaching)].compactMap { $0 }.joined(separator: "\n") - return Instruction(title: task.title, detail: detail) + return Instruction(title: task.title, detail: task.detail, note: coachingNote(coaching)) } // MARK: Aim ring diff --git a/ios/HouseScan/UI/Screens/GapRequestScreen.swift b/ios/HouseScan/UI/Screens/GapRequestScreen.swift index fd7b012d5..92d2db5c5 100644 --- a/ios/HouseScan/UI/Screens/GapRequestScreen.swift +++ b/ios/HouseScan/UI/Screens/GapRequestScreen.swift @@ -45,7 +45,8 @@ struct GapRequestScreen: View { lastCaptureID: state.lastCapture?.id, isReplay: state.isReplay, isAutopilot: state.isAutopilot, - cameraWindow: cameraWindow + cameraWindow: cameraWindow, + aims: aims ) { VStack(spacing: 10) { if asking { @@ -124,6 +125,15 @@ struct GapRequestScreen: View { } } + /// An open request has the homeowner aim the camera, so its card folds its detail at the + /// largest text sizes (`CameraChrome.aims`). Not the overhead question, a request already + /// seen, or coaching that replaces the request. + private var aims: Bool { + if asking || state.gap?.isSatisfied == true { return false } + if let coaching, ScanCopy.coachingReplacesTask(coaching) { return false } + return true + } + /// As on the walk: tracking problems and standing past an end replace the request, and the /// capture gate's coaching rides along under it (`ScanCopy.withCoaching`). The dark coaching /// can stay up for a whole night request, and replacing the card hid the request and "Got it, diff --git a/ios/HouseScan/UI/Screens/WallWalkScreen.swift b/ios/HouseScan/UI/Screens/WallWalkScreen.swift index 9a84184b0..8163a0640 100644 --- a/ios/HouseScan/UI/Screens/WallWalkScreen.swift +++ b/ios/HouseScan/UI/Screens/WallWalkScreen.swift @@ -51,7 +51,8 @@ struct WallWalkScreen: View { actions.markFeaturePoint(at: point, viewSize: cameraSize) }, legend: cardLegend, - cameraWindow: cameraWindow + cameraWindow: cameraWindow, + aims: aims ) { VStack(spacing: 10) { controls @@ -124,6 +125,20 @@ struct WallWalkScreen: View { return .normal } + /// Steps where the homeowner aims the camera: walking, marking the wall's end or the next + /// wall, finishing. Their card folds its detail at the largest text sizes so the camera + /// stays open (`CameraChrome.aims`). Not a question, a mark, the tray, coaching that replaces + /// the task or a refusal: there every word on the card is what to do now. + private var aims: Bool { + switch controlsKey { + case .walking, .markEnd, .finish, .nextWall: + if let coaching, ScanCopy.coachingReplacesTask(coaching) { return false } + return tone != .refusal + case .marking, .endScanQuestion, .endQuestion, .overheadQuestion, .nextWallConfirm, .tray: + return false + } + } + // MARK: Controls private enum ControlsKey: Hashable { @@ -182,6 +197,7 @@ struct WallWalkScreen: View { actions.markFeaturePoint(at: nil, viewSize: cameraSize) } label: { Label("Mark", systemImage: "plus.viewfinder") + .spansStack(stacksActions) } .buttonStyle(.primary) .accessibilityHint("Marks the point under the circle in the middle of the screen") @@ -291,7 +307,7 @@ struct WallWalkScreen: View { actions.cancelNextWall() } label: { Label("Back", systemImage: "chevron.backward") - .frame(maxWidth: stacksActions ? .infinity : nil) + .spansStack(stacksActions) } .buttonStyle(.secondaryProminent) .accessibilityHint("Asks again what's at this end of the wall") @@ -300,6 +316,7 @@ struct WallWalkScreen: View { actions.markNextWall(at: nil, viewSize: cameraSize) } label: { Label("Mark next wall", systemImage: nextWallSymbol) + .spansStack(stacksActions) .frame(maxWidth: .infinity) } .buttonStyle(.primary) @@ -345,7 +362,7 @@ struct WallWalkScreen: View { actions.endWallHere() } label: { Label("Wall ends here", systemImage: "flag") - .frame(maxWidth: stacksActions ? .infinity : nil) + .spansStack(stacksActions) } .buttonStyle(.secondaryProminent) .accessibilityHint("Ends the wall where you're standing, at the dashed line on the map") @@ -438,8 +455,7 @@ struct WallWalkScreen: View { trayOpen = true } label: { Label("Mark something", systemImage: "mappin.and.ellipse") - .labelStyle(.titleAndIcon) - .frame(maxWidth: stacksActions ? .infinity : nil) + .spansStack(stacksActions) } .buttonStyle(.secondaryProminent) .accessibilityHint("Pin a gas meter, door, window, AC unit, driveway or fence") @@ -517,13 +533,16 @@ struct FeatureTray: View { } private extension View { - /// Full width when the actions stack, as wide as its words when they share a row. + /// Stacked, a wall-walk action spans the screen and shows its words alone; in a row it is + /// as wide as its words, with its icon. At the largest text sizes the icon took a third of + /// the width and broke "Mark something" into three lines ("some-", "thing"); the words say + /// what the button does without it. @ViewBuilder func spansStack(_ stacked: Bool) -> some View { if stacked { - frame(maxWidth: .infinity) + labelStyle(.titleOnly).frame(maxWidth: .infinity) } else { - self + labelStyle(.titleAndIcon) } } } diff --git a/ios/HouseScanUITests/ScreenStatesUITests.swift b/ios/HouseScanUITests/ScreenStatesUITests.swift index 624330a06..1aa8a0c8e 100644 --- a/ios/HouseScanUITests/ScreenStatesUITests.swift +++ b/ios/HouseScanUITests/ScreenStatesUITests.swift @@ -133,10 +133,9 @@ final class ScreenStatesUITests: XCTestCase { "resultAR": [("ar.overlay", "drawn on your wall")], // #81: the aim ring fills as its stretch is captured. "wallWalk-aim": [("aim.ring", "50 percent captured")], - // At AX5 the card and its legend fill the screen and the ring is not drawn under them - // (testAimRingIsNeverDrawnUnderTheChromeAtLargestTextSize); the legend still says what - // the ring is for. - "wallWalk-aim-AX5": [("aim.legend", "It fills as your phone captures this spot")], + // At AX5 the aim card folds its how-to words and the ring's legend under Details + // (testAimCameraStaysOpenAtLargestTextSize). + "wallWalk-aim-AX5": [("instruction", "Tilt down to show the ground"), ("instruction.details", "Details")], // #82: a second "Can't get there" soon after the first asks before ending the scan. "wallWalk-endScanQuestion": [("instruction", "End the scan here?")], "wallWalk-endScanTooShort": [("instruction", "You haven't walked enough of the wall")], @@ -562,11 +561,17 @@ final class ScreenStatesUITests: XCTestCase { /// A refused upload offers the review, not "Try again"; from the review the scan is sent /// again and reaches the result. + /// + /// The pass sample (`-uiDemoPass`) lists no view to take, so the resend goes straight to the + /// result. The review sample asked for one more, and one wait covered a second capture, two + /// uploads and the server's follow-up; on run 36736877861 it ran out with the app still + /// uploading at 60% (#191). Skipping the review's own view sends at once, so each wait below + /// covers one step. @MainActor func testRejectedUploadGoesBackToReview() throws { continueAfterFailure = false let app = XCUIApplication() - app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoPhase", "gapRequest", "-uiDemoRejected"] + app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoPhase", "gapRequest", "-uiDemoRejected", "-uiDemoPass"] app.launch() XCTAssertTrue(element(app, "action.backToReview").waitForExistence(timeout: 30)) XCTAssertFalse(element(app, "action.retryUpload").exists, "a refused scan must not offer Try again") @@ -577,7 +582,13 @@ final class ScreenStatesUITests: XCTestCase { tap(app, "action.confirmFeatures") XCTAssertTrue(element(app, "review.unanswered").waitForExistence(timeout: 5)) tap(app, "action.confirmFeatures") - tap(app, "action.spotClear", timeout: 40) + XCTAssertTrue(element(app, "screen.gapRequest").waitForExistence(timeout: 10), "the review must open its own view") + // The card's reply ignores taps for a moment after it appears (`InstructionCard.replyLock`). + let skip = element(app, "action.skipGap") + let ready = XCTNSPredicateExpectation(predicate: NSPredicate(format: "isEnabled == true AND isHittable == true"), object: skip) + XCTAssertEqual(XCTWaiter().wait(for: [ready], timeout: 10), .completed, "I can't get there never took taps") + skip.tap() + tap(app, "action.spotClear", timeout: 30) XCTAssertTrue(element(app, "screen.result").waitForExistence(timeout: 20)) } @@ -629,16 +640,17 @@ final class ScreenStatesUITests: XCTestCase { XCTAssertTrue(legend.label.contains("It fills as your phone captures this spot"), "legend reads \(legend.label)") let card = element(app, "instruction") XCTAssertFalse(legend.frame.intersects(card.frame), "the legend must keep clear of the card: \(legend.frame) vs \(card.frame)") + XCTAssertFalse(element(app, "instruction.details").exists, "at the default size every word stays on the card") app.terminate() - // At the largest text size the card fills most of the screen. The legend sits under it in - // the same stack, so it grows and scrolls with the card instead of going behind it or - // disappearing. + // At the largest text size the legend is folded under Details with the card's how-to + // words, and still reads in full once opened. app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoFreeze", "-uiDemoPhase", "wallWalk", "-uiDemoAim"] + Self.largestText app.launch() XCTAssertTrue(element(app, "screen.wallWalk").waitForExistence(timeout: 15)) + tap(app, "instruction.details") let largeLegend = element(app, "aim.legend") - XCTAssertTrue(largeLegend.waitForExistence(timeout: 5), "the legend must still show at the largest text size") + XCTAssertTrue(largeLegend.waitForExistence(timeout: 5), "the legend must show under Details at the largest text size") XCTAssertTrue(largeLegend.label.contains("It fills as your phone captures this spot"), "legend reads \(largeLegend.label)") let largeCard = element(app, "instruction") XCTAssertFalse(largeLegend.frame.intersects(largeCard.frame), "the legend must keep clear of the card: \(largeLegend.frame) vs \(largeCard.frame)") @@ -721,13 +733,13 @@ final class ScreenStatesUITests: XCTestCase { } } - /// B-36: at AX5 the aim card filled the screen and the ring was drawn under it, where it - /// couldn't be seen. The ring now shows only in the open camera between the card and the - /// actions. At every position while the chrome scrolls from its top to its end, a ring on - /// screen must be wholly on screen and clear of the words, the reply, the legend and the - /// first action. + /// B-36: at AX5 the aim card filled the screen and hid the aim ring under it. On a step that + /// has the homeowner aim, the card now keeps the task and its reply in view, folds the how-to + /// words and the ring's legend under Details, and the chrome leaves the camera open between + /// the card and the actions. The ring shows in that open camera, and at no scroll position is + /// it drawn under the words, Details, the reply or an action. @MainActor - func testAimRingIsNeverDrawnUnderTheChromeAtLargestTextSize() throws { + func testAimCameraStaysOpenAtLargestTextSize() throws { continueAfterFailure = false let app = XCUIApplication() app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoFreeze", "-uiDemoPhase", "wallWalk", "-uiDemoAim"] + Self.largestText @@ -735,12 +747,27 @@ final class ScreenStatesUITests: XCTestCase { defer { app.terminate() } XCTAssertTrue(element(app, "screen.wallWalk").waitForExistence(timeout: 15)) let window = app.windows.firstMatch.frame + let card = element(app, "instruction") + let details = element(app, "instruction.details") + let reply = element(app, "action.cannotAccess") + let mark = element(app, "action.markSomething") let ring = element(app, "aim.ring") - let covers = ["instruction", "action.cannotAccess", "aim.legend", "action.markSomething"].map { ($0, element(app, $0)) } - XCTAssertTrue(element(app, "action.cannotAccess").waitForExistence(timeout: 5)) - XCTAssertTrue(element(app, "aim.legend").waitForExistence(timeout: 5), "the legend must still show at the largest text size") - attach(app, name: "wallWalk-aim-AX5-top") - // Short drags, so the scroll passes through every position where a ring could show. + for (identifier, target) in [("instruction", card), ("instruction.details", details), ("action.cannotAccess", reply), ("action.markSomething", mark)] { + XCTAssertTrue(target.waitForExistence(timeout: 5), "missing \(identifier)") + } + let label = ElementRead.snapshot(card)?.label ?? "" + XCTAssertTrue(label.contains("Tilt down to show the ground"), "the task must stay on the card, got \(label)") + XCTAssertFalse(label.contains("1 ft 4 in right of your meter"), "the how-to words must fold under Details, got \(label)") + XCTAssertFalse(element(app, "aim.legend").exists, "the legend must fold under Details") + XCTAssertTrue(details.isHittable, "Details must be reachable without scrolling") + XCTAssertTrue(reply.isHittable, "the card's reply must be reachable without scrolling") + let cardBottom = max(card.frame.maxY, details.frame.maxY, reply.frame.maxY) + XCTAssertGreaterThanOrEqual(mark.frame.minY - cardBottom, 150, "the camera must stay open between the card (ends \(cardBottom)) and the actions (start \(mark.frame.minY))") + attach(app, name: "wallWalk-aim-AX5-folded") + + var sightings = 0 + let covers = ["instruction", "instruction.details", "action.cannotAccess", "action.markSomething"].map { ($0, element(app, $0)) } + // Short drags, so the scroll passes through every position where the ring could show. for step in 0..<30 { if ring.exists { let frame = ring.frame @@ -748,11 +775,73 @@ final class ScreenStatesUITests: XCTestCase { for (identifier, cover) in covers where cover.exists { XCTAssertFalse(frame.intersects(cover.frame), "step \(step): ring \(frame) is under \(identifier) \(cover.frame)") } + if sightings == 0 { attach(app, name: "wallWalk-aim-AX5-ring") } + sightings += 1 } let start = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.75)) start.press(forDuration: 0.1, thenDragTo: start.withOffset(CGVector(dx: 0, dy: -60)), withVelocity: .slow, thenHoldForDuration: 0.2) } - attach(app, name: "wallWalk-aim-AX5-end") + XCTAssertGreaterThan(sightings, 0, "the ring never showed in the open camera") + XCTAssertTrue(scrollUntilHittable(mark, in: app), "Mark something must be reachable by scrolling") + } + + /// At AX5, Details opens the folded how-to words and the ring's legend, and closes them again. + @MainActor + func testFoldedDetailsOpenAndCloseAtLargestTextSize() throws { + continueAfterFailure = false + let app = XCUIApplication() + app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoFreeze", "-uiDemoPhase", "wallWalk", "-uiDemoAim"] + Self.largestText + app.launch() + defer { app.terminate() } + XCTAssertTrue(element(app, "screen.wallWalk").waitForExistence(timeout: 15)) + tap(app, "instruction.details") + let detail = element(app, "instruction.detail") + XCTAssertTrue(detail.waitForExistence(timeout: 5), "Details must open the how-to words") + XCTAssertTrue(detail.label.contains("1 ft 4 in right of your meter"), "detail reads \(detail.label)") + let legend = element(app, "aim.legend") + XCTAssertTrue(legend.waitForExistence(timeout: 5), "Details must open the ring's legend") + XCTAssertTrue(legend.label.contains("It fills as your phone captures this spot"), "legend reads \(legend.label)") + attach(app, name: "wallWalk-aim-AX5-details") + tap(app, "instruction.details") + XCTAssertTrue(detail.waitForNonExistence(timeout: 5), "Details must close again") + XCTAssertFalse(legend.exists) + } + + /// Coaching that rides along with the task says what to do now, so the folded card at AX5 + /// keeps it in view with the task; only the how-to words fold. + @MainActor + func testCoachingStaysOnTheFoldedCardAtLargestTextSize() throws { + continueAfterFailure = false + let app = XCUIApplication() + for (coaching, words) in [("slowDown", "Slow down"), ("tooDark", "It's dark here")] { + app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoFreeze", "-uiDemoPhase", "wallWalk", "-uiDemoCoaching", coaching] + Self.largestText + app.launch() + XCTAssertTrue(element(app, "screen.wallWalk").waitForExistence(timeout: 15)) + let card = element(app, "instruction") + XCTAssertTrue(card.waitForExistence(timeout: 5)) + let label = ElementRead.snapshot(card)?.label ?? "" + XCTAssertTrue(label.contains("Walk slowly to your right"), "\(coaching): the task must stay, got \(label)") + XCTAssertTrue(label.contains(words), "\(coaching): the coaching must stay in view, got \(label)") + XCTAssertFalse(label.contains("Keep the wall and the ground in view"), "\(coaching): the how-to words must fold, got \(label)") + XCTAssertTrue(card.isHittable || card.frame.maxY <= app.windows.firstMatch.frame.maxY, "\(coaching): the card must be on screen") + XCTAssertTrue(element(app, "instruction.details").exists, "\(coaching): Details must hold the folded words") + attach(app, name: "wallWalk-\(coaching)-AX5-folded") + app.terminate() + } + } + + /// Temporary diagnostic for B-36: the audit on the aiming states at AX5 alone, so a change to + /// the open camera is judged without the full every-state run. Remove before the final run, + /// where the every-state audit covers these states. + @MainActor + func testAimingStatesPassTheAuditAtLargestTextSize() throws { + for name in ["wallWalk", "wallWalk-endPreview", "wallWalk-hidden", "wallWalk-fullLegend", "wallWalk-aim", "wallWalk-slowDown", "gapRequest"] { + guard let state = Self.states.first(where: { $0.name == name }) else { + XCTFail("no state named \(name)") + continue + } + try check("\(name)-AX5", arguments: state.arguments + Self.largestText, screen: state.screen) + } } /// At AX5 a scrolled onboarding page ran on under the page dots. On every page, before and From e06cec2e29f5a7823c3c0db0de7292e9aa6605aa Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Thu, 1 Oct 2026 00:32:36 -0500 Subject: [PATCH 16/48] test(ios): temporary native contrast audit on AX5 walking states Unhandled, XCTest attaches its own picture of each contrast issue, which the shared handler suppresses; this names the node behind the finding without an element. Removed before the final run. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../ScreenStatesUITests.swift | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/ios/HouseScanUITests/ScreenStatesUITests.swift b/ios/HouseScanUITests/ScreenStatesUITests.swift index 1aa8a0c8e..7caad47a0 100644 --- a/ios/HouseScanUITests/ScreenStatesUITests.swift +++ b/ios/HouseScanUITests/ScreenStatesUITests.swift @@ -830,6 +830,33 @@ final class ScreenStatesUITests: XCTestCase { } } + /// Temporary diagnostic for B-36: XCTest's own contrast audit, with no handler, on the four + /// AX5 walking states where a contrast finding without an element appeared whenever the camera + /// stayed open under a short card (runs 36761500732, 36768462318, 36775276861). Unhandled, + /// XCTest attaches its own picture of each issue, which the shared handler suppresses. Remove + /// with the test below. + @MainActor + func testWalkingStatesNativeContrastAuditAtLargestTextSize() throws { + continueAfterFailure = true + for name in ["wallWalk", "wallWalk-endPreview", "wallWalk-hidden", "wallWalk-fullLegend"] { + guard let state = Self.states.first(where: { $0.name == name }) else { continue } + let app = XCUIApplication() + app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoFreeze"] + state.arguments + Self.largestText + app.launch() + XCTAssertTrue(element(app, "screen.wallWalk").waitForExistence(timeout: 15)) + Thread.sleep(forTimeInterval: 1) + attach(app, name: "\(name)-AX5-native") + let notes = ["mark", "endHere", "markEnd"].map { "action.\($0)" } + ["action.markSomething", "instruction", "instruction.details", "action.cannotAccess"] + let frames = notes.map { "\($0): \(element(app, $0).exists ? String(describing: element(app, $0).frame) : "absent")" } + let note = XCTAttachment(string: (frames + ["window: \(app.windows.firstMatch.frame)"]).joined(separator: "\n")) + note.name = "\(name)-AX5-native-frames" + note.lifetime = .keepAlways + add(note) + try app.performAccessibilityAudit(for: .contrast) + app.terminate() + } + } + /// Temporary diagnostic for B-36: the audit on the aiming states at AX5 alone, so a change to /// the open camera is judged without the full every-state run. Remove before the final run, /// where the every-state audit covers these states. From 0228e0872758c0622b07a461d8291d0df65a8dd5 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Thu, 1 Oct 2026 01:23:39 -0500 Subject: [PATCH 17/48] ios: read the aim arrow's progress; wait for a laid-out frame before tapping At AX5 the aim step's spot sits under the folded card, so an arrow in the open camera stands in for the filling ring (run 36820279307). Hidden like other arrows, it left VoiceOver without the spot's progress; an arrow that stands in for a filling ring now reads "Spot to show, above" and the ring's percent. Other arrows stay hidden. The AX5 aim test now checks the ring or that arrow sits in the open camera and reads 50 percent, instead of expecting a ring that the frozen demo can't move into view. The shared tap helper waits for a finite frame before asking whether an element is hittable: result.details existed mid-transition with an infinite frame and XCTest failed on its activation point. The temporary aiming-state audits passed and are replaced by one on the AX5 aim state and the off-screen aim state, whose arrow is newly read. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../UI/Camera/WayfindingOverlay.swift | 29 +++++- .../ScreenStatesUITests.swift | 91 ++++++++----------- 2 files changed, 64 insertions(+), 56 deletions(-) diff --git a/ios/HouseScan/UI/Camera/WayfindingOverlay.swift b/ios/HouseScan/UI/Camera/WayfindingOverlay.swift index d4835e151..b7e3ca45b 100644 --- a/ios/HouseScan/UI/Camera/WayfindingOverlay.swift +++ b/ios/HouseScan/UI/Camera/WayfindingOverlay.swift @@ -267,9 +267,9 @@ struct TargetMarker: View { .background(Palette.signal, in: .circle) .overlay(Circle().strokeBorder(.white.opacity(0.85), lineWidth: 2)) .shadow(color: .black.opacity(0.3), radius: 6, y: 2) + .modifier(ArrowAccessibility(progress: progress, angle: angle)) .position(point) .transition(.opacity) - .accessibilityHidden(true) case .hidden: EmptyView() } @@ -329,3 +329,30 @@ struct TargetMarker: View { .accessibilityIdentifier("aim.ring") } } + +/// An arrow standing in for a filling ring reads as the ring does, with the way to turn: at the +/// largest text sizes the ring is often under the card, and hiding the arrow too left VoiceOver +/// without the spot's progress. Other arrows (a plain target, the AR result's spot) stay hidden. +private struct ArrowAccessibility: ViewModifier { + var progress: Double? + var angle: Angle + + func body(content: Content) -> some View { + if let progress { + content + .accessibilityElement() + .accessibilityLabel("Spot to show, \(way)") + .accessibilityValue(progress >= 1 ? "Captured" : "\(Int((min(max(progress, 0), 1) * 100).rounded())) percent captured") + .accessibilityIdentifier("aim.arrow") + } else { + content.accessibilityHidden(true) + } + } + + /// The arrow's direction in words, by its larger component. Screen y grows downward. + private var way: String { + let dx = cos(angle.radians), dy = sin(angle.radians) + if abs(dx) >= abs(dy) { return dx > 0 ? "to the right" : "to the left" } + return dy > 0 ? "below" : "above" + } +} diff --git a/ios/HouseScanUITests/ScreenStatesUITests.swift b/ios/HouseScanUITests/ScreenStatesUITests.swift index 7caad47a0..4ba4a6a72 100644 --- a/ios/HouseScanUITests/ScreenStatesUITests.swift +++ b/ios/HouseScanUITests/ScreenStatesUITests.swift @@ -736,8 +736,9 @@ final class ScreenStatesUITests: XCTestCase { /// B-36: at AX5 the aim card filled the screen and hid the aim ring under it. On a step that /// has the homeowner aim, the card now keeps the task and its reply in view, folds the how-to /// words and the ring's legend under Details, and the chrome leaves the camera open between - /// the card and the actions. The ring shows in that open camera, and at no scroll position is - /// it drawn under the words, Details, the reply or an action. + /// the card and the actions. The spot's marker is in that open camera: the ring, or, while + /// the spot is under the card, an arrow toward it that reads the ring's progress. At no + /// scroll position is either drawn under the words, Details, the reply or an action. @MainActor func testAimCameraStaysOpenAtLargestTextSize() throws { continueAfterFailure = false @@ -751,7 +752,6 @@ final class ScreenStatesUITests: XCTestCase { let details = element(app, "instruction.details") let reply = element(app, "action.cannotAccess") let mark = element(app, "action.markSomething") - let ring = element(app, "aim.ring") for (identifier, target) in [("instruction", card), ("instruction.details", details), ("action.cannotAccess", reply), ("action.markSomething", mark)] { XCTAssertTrue(target.waitForExistence(timeout: 5), "missing \(identifier)") } @@ -762,29 +762,43 @@ final class ScreenStatesUITests: XCTestCase { XCTAssertTrue(details.isHittable, "Details must be reachable without scrolling") XCTAssertTrue(reply.isHittable, "the card's reply must be reachable without scrolling") let cardBottom = max(card.frame.maxY, details.frame.maxY, reply.frame.maxY) - XCTAssertGreaterThanOrEqual(mark.frame.minY - cardBottom, 150, "the camera must stay open between the card (ends \(cardBottom)) and the actions (start \(mark.frame.minY))") + let openCamera = CGRect(x: window.minX, y: cardBottom, width: window.width, height: mark.frame.minY - cardBottom) + XCTAssertGreaterThanOrEqual(openCamera.height, 150, "the camera must stay open between the card and the actions: \(openCamera)") + + let ring = element(app, "aim.ring") + let arrow = element(app, "aim.arrow") + let marker = ring.exists ? ring : arrow + XCTAssertTrue(marker.waitForExistence(timeout: 5), "the spot needs its ring or an arrow toward it") + XCTAssertTrue(openCamera.contains(marker.frame), "the spot's marker \(marker.frame) must be in the open camera \(openCamera)") + XCTAssertEqual(ElementRead.snapshot(marker)?.value as? String, "50 percent captured", "the marker must read the spot's progress") attach(app, name: "wallWalk-aim-AX5-folded") - var sightings = 0 let covers = ["instruction", "instruction.details", "action.cannotAccess", "action.markSomething"].map { ($0, element(app, $0)) } - // Short drags, so the scroll passes through every position where the ring could show. - for step in 0..<30 { - if ring.exists { - let frame = ring.frame - XCTAssertTrue(window.contains(frame), "step \(step): ring \(frame) is not wholly on screen") + // Short drags through every scroll position; where the screen fits, nothing moves. + for step in 0..<12 { + for (name, shown) in [("ring", ring), ("arrow", arrow)] where shown.exists { + let frame = shown.frame + XCTAssertTrue(window.contains(frame), "step \(step): \(name) \(frame) is not wholly on screen") for (identifier, cover) in covers where cover.exists { - XCTAssertFalse(frame.intersects(cover.frame), "step \(step): ring \(frame) is under \(identifier) \(cover.frame)") + XCTAssertFalse(frame.intersects(cover.frame), "step \(step): \(name) \(frame) is under \(identifier) \(cover.frame)") } - if sightings == 0 { attach(app, name: "wallWalk-aim-AX5-ring") } - sightings += 1 } let start = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.75)) start.press(forDuration: 0.1, thenDragTo: start.withOffset(CGVector(dx: 0, dy: -60)), withVelocity: .slow, thenHoldForDuration: 0.2) } - XCTAssertGreaterThan(sightings, 0, "the ring never showed in the open camera") XCTAssertTrue(scrollUntilHittable(mark, in: app), "Mark something must be reachable by scrolling") } + /// Temporary: the audit on the AX5 aim state and the off-screen aim state, whose arrow now + /// reads the ring's progress to VoiceOver. Removed before the final every-state run. + @MainActor + func testAimArrowStatesPassTheAudit() throws { + for (name, ax5) in [("wallWalk-aim", true), ("wallWalk-aimOffScreen", false)] { + guard let state = Self.states.first(where: { $0.name == name }) else { continue } + try check(ax5 ? "\(name)-AX5" : name, arguments: state.arguments + (ax5 ? Self.largestText : []), screen: state.screen) + } + } + /// At AX5, Details opens the folded how-to words and the ring's legend, and closes them again. @MainActor func testFoldedDetailsOpenAndCloseAtLargestTextSize() throws { @@ -830,47 +844,6 @@ final class ScreenStatesUITests: XCTestCase { } } - /// Temporary diagnostic for B-36: XCTest's own contrast audit, with no handler, on the four - /// AX5 walking states where a contrast finding without an element appeared whenever the camera - /// stayed open under a short card (runs 36761500732, 36768462318, 36775276861). Unhandled, - /// XCTest attaches its own picture of each issue, which the shared handler suppresses. Remove - /// with the test below. - @MainActor - func testWalkingStatesNativeContrastAuditAtLargestTextSize() throws { - continueAfterFailure = true - for name in ["wallWalk", "wallWalk-endPreview", "wallWalk-hidden", "wallWalk-fullLegend"] { - guard let state = Self.states.first(where: { $0.name == name }) else { continue } - let app = XCUIApplication() - app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoFreeze"] + state.arguments + Self.largestText - app.launch() - XCTAssertTrue(element(app, "screen.wallWalk").waitForExistence(timeout: 15)) - Thread.sleep(forTimeInterval: 1) - attach(app, name: "\(name)-AX5-native") - let notes = ["mark", "endHere", "markEnd"].map { "action.\($0)" } + ["action.markSomething", "instruction", "instruction.details", "action.cannotAccess"] - let frames = notes.map { "\($0): \(element(app, $0).exists ? String(describing: element(app, $0).frame) : "absent")" } - let note = XCTAttachment(string: (frames + ["window: \(app.windows.firstMatch.frame)"]).joined(separator: "\n")) - note.name = "\(name)-AX5-native-frames" - note.lifetime = .keepAlways - add(note) - try app.performAccessibilityAudit(for: .contrast) - app.terminate() - } - } - - /// Temporary diagnostic for B-36: the audit on the aiming states at AX5 alone, so a change to - /// the open camera is judged without the full every-state run. Remove before the final run, - /// where the every-state audit covers these states. - @MainActor - func testAimingStatesPassTheAuditAtLargestTextSize() throws { - for name in ["wallWalk", "wallWalk-endPreview", "wallWalk-hidden", "wallWalk-fullLegend", "wallWalk-aim", "wallWalk-slowDown", "gapRequest"] { - guard let state = Self.states.first(where: { $0.name == name }) else { - XCTFail("no state named \(name)") - continue - } - try check("\(name)-AX5", arguments: state.arguments + Self.largestText, screen: state.screen) - } - } - /// At AX5 a scrolled onboarding page ran on under the page dots. On every page, before and /// after scrolling it to its end, the row the dots sit in must show only the background /// beside the dots. Read from the screenshot's pixels: the dots are hidden from @@ -1125,6 +1098,10 @@ final class ScreenStatesUITests: XCTestCase { let target = element(app, identifier) let deadline = Date().addingTimeInterval(timeout) XCTAssertTrue(target.waitForExistence(timeout: timeout), "missing \(identifier)") + // An element can exist before its screen is laid out, with an infinite frame, and asking + // whether it is hittable then fails the test outright ("Activation point invalid"; + // result.details in run 36820279307, mid-transition from the spot check). + while !Self.laidOut(target.frame), Date() < deadline { Thread.sleep(forTimeInterval: 0.1) } let settled = waitUntilHittable(target, timeout: min(3, max(0, deadline.timeIntervalSinceNow))) if settled || target.frame.maxY > app.windows.firstMatch.frame.maxY { target.tap() @@ -1134,6 +1111,10 @@ final class ScreenStatesUITests: XCTestCase { target.tap() } + private static func laidOut(_ frame: CGRect) -> Bool { + frame.minX.isFinite && frame.minY.isFinite && !frame.isEmpty + } + @MainActor private func waitUntilHittable(_ target: XCUIElement, timeout: TimeInterval) -> Bool { let hittable = XCTNSPredicateExpectation(predicate: NSPredicate(format: "isHittable == true"), object: target) From 1237ac992beeb3cbc9c554ebef55138acb85db20 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Thu, 1 Oct 2026 02:06:40 -0500 Subject: [PATCH 18/48] ios: keep action words unfolded at AX5; harden the tap helper The fold now hides only a second line that explains. It stays off where that line is what to do now: looking past an obstruction, a step back added to an aim step, marking the next wall, and gap requests for ground further out or a walk out, whose live "this needs" reading or "tap I can't get there" are the instruction. Found by the fresh audit. The aim arrow that reads progress is marked as updating frequently. The tap helper checks every frame component and fails loudly when an element is never laid out, instead of asking whether it is hittable anyway. The temporary audit test is removed; the every-state audit covers its states. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../UI/Camera/WayfindingOverlay.swift | 1 + .../UI/Screens/GapRequestScreen.swift | 15 ++++--- ios/HouseScan/UI/Screens/WallWalkScreen.swift | 16 +++++--- .../ScreenStatesUITests.swift | 39 +++++++++++++------ 4 files changed, 49 insertions(+), 22 deletions(-) diff --git a/ios/HouseScan/UI/Camera/WayfindingOverlay.swift b/ios/HouseScan/UI/Camera/WayfindingOverlay.swift index b7e3ca45b..5bb5196ad 100644 --- a/ios/HouseScan/UI/Camera/WayfindingOverlay.swift +++ b/ios/HouseScan/UI/Camera/WayfindingOverlay.swift @@ -343,6 +343,7 @@ private struct ArrowAccessibility: ViewModifier { .accessibilityElement() .accessibilityLabel("Spot to show, \(way)") .accessibilityValue(progress >= 1 ? "Captured" : "\(Int((min(max(progress, 0), 1) * 100).rounded())) percent captured") + .accessibilityAddTraits(.updatesFrequently) .accessibilityIdentifier("aim.arrow") } else { content.accessibilityHidden(true) diff --git a/ios/HouseScan/UI/Screens/GapRequestScreen.swift b/ios/HouseScan/UI/Screens/GapRequestScreen.swift index 92d2db5c5..76f968b6a 100644 --- a/ios/HouseScan/UI/Screens/GapRequestScreen.swift +++ b/ios/HouseScan/UI/Screens/GapRequestScreen.swift @@ -125,13 +125,18 @@ struct GapRequestScreen: View { } } - /// An open request has the homeowner aim the camera, so its card folds its detail at the - /// largest text sizes (`CameraChrome.aims`). Not the overhead question, a request already - /// seen, or coaching that replaces the request. + /// An open request has the homeowner aim the camera, so its card folds its second line at + /// the largest text sizes (`CameraChrome.aims`). Not where that line is what to do now: + /// stepping back for ground further out, the walk out with its live "this needs" reading, or + /// a walk the space can't hold, which points at "I can't get there". Nor the overhead + /// question, a request already seen, or coaching that replaces the request. private var aims: Bool { - if asking || state.gap?.isSatisfied == true { return false } + guard let gap = state.gap, !asking, !gap.isSatisfied else { return false } if let coaching, ScanCopy.coachingReplacesTask(coaching) { return false } - return true + switch gap.reason { + case .groundOut, .walkOut: return false + default: return true + } } /// As on the walk: tracking problems and standing past an end replace the request, and the diff --git a/ios/HouseScan/UI/Screens/WallWalkScreen.swift b/ios/HouseScan/UI/Screens/WallWalkScreen.swift index 8163a0640..17c38c082 100644 --- a/ios/HouseScan/UI/Screens/WallWalkScreen.swift +++ b/ios/HouseScan/UI/Screens/WallWalkScreen.swift @@ -125,16 +125,20 @@ struct WallWalkScreen: View { return .normal } - /// Steps where the homeowner aims the camera: walking, marking the wall's end or the next - /// wall, finishing. Their card folds its detail at the largest text sizes so the camera - /// stays open (`CameraChrome.aims`). Not a question, a mark, the tray, coaching that replaces - /// the task or a refusal: there every word on the card is what to do now. + /// Steps where the homeowner aims the camera and the card's second line only explains: + /// walking, aiming, marking the wall's end, finishing. Their card folds that line at the + /// largest text sizes so the camera stays open (`CameraChrome.aims`). Not where the second + /// line is itself what to do now: looking past an obstruction ("Look at it from the side"), + /// a step back added to an aim step, marking the next wall ("Put the circle on it"). Nor a + /// question, a mark, the tray, coaching that replaces the task, or a refusal. private var aims: Bool { switch controlsKey { - case .walking, .markEnd, .finish, .nextWall: + case .walking, .markEnd, .finish: if let coaching, ScanCopy.coachingReplacesTask(coaching) { return false } + if case .seeBehind = state.guidance { return false } + if state.guidanceHint?.stepBack == true { return false } return tone != .refusal - case .marking, .endScanQuestion, .endQuestion, .overheadQuestion, .nextWallConfirm, .tray: + case .nextWall, .marking, .endScanQuestion, .endQuestion, .overheadQuestion, .nextWallConfirm, .tray: return false } } diff --git a/ios/HouseScanUITests/ScreenStatesUITests.swift b/ios/HouseScanUITests/ScreenStatesUITests.swift index 4ba4a6a72..b297a1dbd 100644 --- a/ios/HouseScanUITests/ScreenStatesUITests.swift +++ b/ios/HouseScanUITests/ScreenStatesUITests.swift @@ -789,16 +789,6 @@ final class ScreenStatesUITests: XCTestCase { XCTAssertTrue(scrollUntilHittable(mark, in: app), "Mark something must be reachable by scrolling") } - /// Temporary: the audit on the AX5 aim state and the off-screen aim state, whose arrow now - /// reads the ring's progress to VoiceOver. Removed before the final every-state run. - @MainActor - func testAimArrowStatesPassTheAudit() throws { - for (name, ax5) in [("wallWalk-aim", true), ("wallWalk-aimOffScreen", false)] { - guard let state = Self.states.first(where: { $0.name == name }) else { continue } - try check(ax5 ? "\(name)-AX5" : name, arguments: state.arguments + (ax5 ? Self.largestText : []), screen: state.screen) - } - } - /// At AX5, Details opens the folded how-to words and the ring's legend, and closes them again. @MainActor func testFoldedDetailsOpenAndCloseAtLargestTextSize() throws { @@ -821,6 +811,29 @@ final class ScreenStatesUITests: XCTestCase { XCTAssertFalse(legend.exists) } + /// Where a card's second line is itself what to do now, it stays on the card at AX5: looking + /// past an obstruction, and the walk out with its live reading. + @MainActor + func testActionWordsStayOnTheCardAtLargestTextSize() throws { + continueAfterFailure = false + let app = XCUIApplication() + for (state, words) in [("wallWalk-seeBehind", "Look at it from the side"), ("gapRequest-walkOut", "Follow the dotted line")] { + guard let fixture = Self.states.first(where: { $0.name == state }) else { + XCTFail("no state named \(state)") + continue + } + app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoFreeze"] + fixture.arguments + Self.largestText + app.launch() + XCTAssertTrue(element(app, "screen.\(fixture.screen)").waitForExistence(timeout: 15)) + let card = element(app, "instruction") + XCTAssertTrue(card.waitForExistence(timeout: 5)) + let label = ElementRead.snapshot(card)?.label ?? "" + XCTAssertTrue(label.contains(words), "\(state): \"\(words)\" must stay on the card, got \(label)") + XCTAssertFalse(element(app, "instruction.details").exists, "\(state): nothing to fold") + app.terminate() + } + } + /// Coaching that rides along with the task says what to do now, so the folded card at AX5 /// keeps it in view with the task; only the how-to words fold. @MainActor @@ -1102,6 +1115,10 @@ final class ScreenStatesUITests: XCTestCase { // whether it is hittable then fails the test outright ("Activation point invalid"; // result.details in run 36820279307, mid-transition from the spot check). while !Self.laidOut(target.frame), Date() < deadline { Thread.sleep(forTimeInterval: 0.1) } + guard Self.laidOut(target.frame) else { + XCTFail("\(identifier) was never laid out: \(target.frame)") + return + } let settled = waitUntilHittable(target, timeout: min(3, max(0, deadline.timeIntervalSinceNow))) if settled || target.frame.maxY > app.windows.firstMatch.frame.maxY { target.tap() @@ -1112,7 +1129,7 @@ final class ScreenStatesUITests: XCTestCase { } private static func laidOut(_ frame: CGRect) -> Bool { - frame.minX.isFinite && frame.minY.isFinite && !frame.isEmpty + [frame.minX, frame.minY, frame.width, frame.height].allSatisfy(\.isFinite) && !frame.isEmpty } @MainActor From 35cc852123365fa19b537cf667fe32b88c7b025a Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Thu, 1 Oct 2026 02:46:07 -0500 Subject: [PATCH 19/48] ios: fold only a second line that explains The second fresh audit found the fold still hid what to do now on the wall's end ("Aim where the wall stops", under a title that only asks) and on gap requests whose second line is the action or its extent (wall above, overhead "up to the roof or the sky", a server's own words). The wall walk now folds only the walk, aim steps without a step back, tilt-up and finishing; gap requests fold only the phone's own request for ground by a likely spot, whose second line says why. The action-words test adds ground-out and overhead. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../UI/Screens/GapRequestScreen.swift | 18 +++++++++--------- ios/HouseScan/UI/Screens/WallWalkScreen.swift | 18 ++++++++++-------- ios/HouseScanUITests/ScreenStatesUITests.swift | 8 ++++++-- 3 files changed, 25 insertions(+), 19 deletions(-) diff --git a/ios/HouseScan/UI/Screens/GapRequestScreen.swift b/ios/HouseScan/UI/Screens/GapRequestScreen.swift index 76f968b6a..0bc7729e5 100644 --- a/ios/HouseScan/UI/Screens/GapRequestScreen.swift +++ b/ios/HouseScan/UI/Screens/GapRequestScreen.swift @@ -125,18 +125,18 @@ struct GapRequestScreen: View { } } - /// An open request has the homeowner aim the camera, so its card folds its second line at - /// the largest text sizes (`CameraChrome.aims`). Not where that line is what to do now: - /// stepping back for ground further out, the walk out with its live "this needs" reading, or - /// a walk the space can't hold, which points at "I can't get there". Nor the overhead - /// question, a request already seen, or coaching that replaces the request. + /// The phone's own request for ground by a likely spot has the homeowner aim the camera, and + /// its second line only says why ("This might be a spot for the battery"), so it folds at the + /// largest text sizes (`CameraChrome.aims`). Every other request's second line is the action + /// or its extent: stepping back for ground further out, the walk out with its live "this + /// needs" reading or "tap I can't get there", tilting up to the roof or sky, and a server's + /// own words, which this screen can't judge. Nor the overhead question, a request already + /// seen, or coaching that replaces the request. private var aims: Bool { guard let gap = state.gap, !asking, !gap.isSatisfied else { return false } if let coaching, ScanCopy.coachingReplacesTask(coaching) { return false } - switch gap.reason { - case .groundOut, .walkOut: return false - default: return true - } + if case .groundNearCandidate = gap.reason { return true } + return false } /// As on the walk: tracking problems and standing past an end replace the request, and the diff --git a/ios/HouseScan/UI/Screens/WallWalkScreen.swift b/ios/HouseScan/UI/Screens/WallWalkScreen.swift index 17c38c082..98848a3f9 100644 --- a/ios/HouseScan/UI/Screens/WallWalkScreen.swift +++ b/ios/HouseScan/UI/Screens/WallWalkScreen.swift @@ -125,20 +125,22 @@ struct WallWalkScreen: View { return .normal } - /// Steps where the homeowner aims the camera and the card's second line only explains: - /// walking, aiming, marking the wall's end, finishing. Their card folds that line at the - /// largest text sizes so the camera stays open (`CameraChrome.aims`). Not where the second - /// line is itself what to do now: looking past an obstruction ("Look at it from the side"), - /// a step back added to an aim step, marking the next wall ("Put the circle on it"). Nor a - /// question, a mark, the tray, coaching that replaces the task, or a refusal. + /// Steps where the homeowner aims the camera and the title says what to do, so the card's + /// second line (how to hold the phone, where along the wall, how far to go) can fold at the + /// largest text sizes and leave the camera open (`CameraChrome.aims`). The aim ring marks the + /// place, and a step to the side moves into the title when it is needed (`ScanCopy.aim`). + /// Not where the second line is the action: looking past an obstruction ("Look at it from the + /// side"), a step back added to an aim step, the wall's end, whose title only asks ("Aim + /// where the wall stops"), marking the next wall. Nor a question, a mark, the tray, coaching + /// that replaces the task, or a refusal. private var aims: Bool { switch controlsKey { - case .walking, .markEnd, .finish: + case .walking, .finish: if let coaching, ScanCopy.coachingReplacesTask(coaching) { return false } if case .seeBehind = state.guidance { return false } if state.guidanceHint?.stepBack == true { return false } return tone != .refusal - case .nextWall, .marking, .endScanQuestion, .endQuestion, .overheadQuestion, .nextWallConfirm, .tray: + case .markEnd, .nextWall, .marking, .endScanQuestion, .endQuestion, .overheadQuestion, .nextWallConfirm, .tray: return false } } diff --git a/ios/HouseScanUITests/ScreenStatesUITests.swift b/ios/HouseScanUITests/ScreenStatesUITests.swift index b297a1dbd..5e03f4f00 100644 --- a/ios/HouseScanUITests/ScreenStatesUITests.swift +++ b/ios/HouseScanUITests/ScreenStatesUITests.swift @@ -812,12 +812,16 @@ final class ScreenStatesUITests: XCTestCase { } /// Where a card's second line is itself what to do now, it stays on the card at AX5: looking - /// past an obstruction, and the walk out with its live reading. + /// past an obstruction, the walk out with its live reading, stepping back for ground further + /// out, and tilting up to the roof or the sky. @MainActor func testActionWordsStayOnTheCardAtLargestTextSize() throws { continueAfterFailure = false let app = XCUIApplication() - for (state, words) in [("wallWalk-seeBehind", "Look at it from the side"), ("gapRequest-walkOut", "Follow the dotted line")] { + for (state, words) in [ + ("wallWalk-seeBehind", "Look at it from the side"), ("gapRequest-walkOut", "Follow the dotted line"), + ("gapRequest-groundOut", "Step back and tilt down"), ("gapRequest-overhead", "up to the roof or the sky"), + ] { guard let fixture = Self.states.first(where: { $0.name == state }) else { XCTFail("no state named \(state)") continue From 57fd4e7aa180399e600aa73fb42bed822f7cff79 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Thu, 1 Oct 2026 03:56:07 -0500 Subject: [PATCH 20/48] ios: the instruction card snaps to its new height under Reduce Motion; correct the wayfinding accessibility comment Under Reduce Motion the card's scrim still eased to a new height over 150 ms on a new instruction, and over Motion.text when a reply arrived. Both are size changes, which Reduce Motion asks to drop; the words and the reply already appear at once. Normal motion is unchanged. The overlay's top comment still said every arrow is hidden, which #190 changed. Co-Authored-By: Claude Opus 5.5 (1M context) --- ios/HouseScan/UI/Camera/WayfindingOverlay.swift | 8 +++++--- ios/HouseScan/UI/Components/InstructionCard.swift | 5 +++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/ios/HouseScan/UI/Camera/WayfindingOverlay.swift b/ios/HouseScan/UI/Camera/WayfindingOverlay.swift index 5bb5196ad..07600b1e2 100644 --- a/ios/HouseScan/UI/Camera/WayfindingOverlay.swift +++ b/ios/HouseScan/UI/Camera/WayfindingOverlay.swift @@ -4,9 +4,11 @@ import SwiftUI /// Where to stand and where to aim: a blue dotted path on the ground toward the next place to /// stand, and a ring on the point to aim at (or an edge arrow when it is off screen). /// -/// Each part sets its own accessibility: the path and arrow are hidden, while the filling ring -/// is read (#81). A new part is read by VoiceOver unless it hides itself. The ring's legend is -/// drawn under the instruction card (`CameraChrome.legend`), not here. +/// Each part sets its own accessibility: the path is hidden, and the filling ring is read (#81). +/// An arrow standing in for a filling ring is read like the ring, with its direction ("Spot to +/// show, above"); other arrows are hidden (`ArrowAccessibility`). A new part is read by +/// VoiceOver unless it hides itself. The ring's legend is drawn under the instruction card +/// (`CameraChrome.legend`), not here. struct WayfindingOverlay: View { var projection: CameraProjection var wall: WallGeometry? diff --git a/ios/HouseScan/UI/Components/InstructionCard.swift b/ios/HouseScan/UI/Components/InstructionCard.swift index 3b4bfc0ea..c3e0f15bd 100644 --- a/ios/HouseScan/UI/Components/InstructionCard.swift +++ b/ios/HouseScan/UI/Components/InstructionCard.swift @@ -106,8 +106,9 @@ struct InstructionCard: View { .transition(.identity) .frame(maxWidth: .infinity) .background(ScrimShape.rounded()) - .animation(reduceMotion ? .easeOut(duration: 0.15) : Motion.text, value: instruction) - .animation(Motion.text, value: reply == nil) + // Only the scrim's height eases. Reduce Motion asks for no size changes, so it snaps. + .animation(reduceMotion ? nil : Motion.text, value: instruction) + .animation(reduceMotion ? nil : Motion.text, value: reply == nil) .onChange(of: instruction.title) { detailsShown = false } .task(id: replyTask) { // Cleared first, so a task that comes back within the lock (A, B, A) is locked again. From 3a7a29e0fd4c8db29267c4b398e10e47da973575 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Thu, 1 Oct 2026 04:00:46 -0500 Subject: [PATCH 21/48] ios: declare the required-reason APIs the app calls in a privacy manifest The app had no PrivacyInfo.xcprivacy, though it calls three required-reason API categories. This is a release-preparation gap, not an observed App Store rejection. Each reason follows the traced use: - UserDefaults, CA92.1: the Practice meter switch, the app's own key. - File timestamp, C617.1: ScanFolderCleanup reads scan.zip modification dates in the app's Caches. - System boot time, 35F9.1: time between "can't get there" taps, and the recorder's restart cutoff. 8FFB.1: the wall-clock time of the first recorded frame, the packet's capture.started_at. The file sits in the synced HouseScan folder, so it ships without a project change. It declares no tracking or collected data types; those are App Store privacy answers this change does not make. PrivacyManifestTests fails when shipped Swift uses a category the manifest lacks, or the manifest declares one nothing uses, and pins each reason. Co-Authored-By: Claude Opus 5.5 (1M context) --- ios/HouseScan/PrivacyInfo.xcprivacy | 47 +++++++++++ .../PrivacyManifestTests.swift | 79 +++++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 ios/HouseScan/PrivacyInfo.xcprivacy create mode 100644 ios/HouseScanKit/Tests/HouseScanKitTests/PrivacyManifestTests.swift diff --git a/ios/HouseScan/PrivacyInfo.xcprivacy b/ios/HouseScan/PrivacyInfo.xcprivacy new file mode 100644 index 000000000..9ec62bf71 --- /dev/null +++ b/ios/HouseScan/PrivacyInfo.xcprivacy @@ -0,0 +1,47 @@ + + + + + + NSPrivacyAccessedAPITypes + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + CA92.1 + + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryFileTimestamp + NSPrivacyAccessedAPITypeReasons + + C617.1 + + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategorySystemBootTime + NSPrivacyAccessedAPITypeReasons + + 35F9.1 + 8FFB.1 + + + + + diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/PrivacyManifestTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/PrivacyManifestTests.swift new file mode 100644 index 000000000..b9270b831 --- /dev/null +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/PrivacyManifestTests.swift @@ -0,0 +1,79 @@ +import Foundation +import Testing + +/// The app's privacy manifest (`ios/HouseScan/PrivacyInfo.xcprivacy`) against the code that ships +/// in the app: the HouseScan target's Swift files and HouseScanKit's sources. A required-reason +/// API added without its category, or a category left declared after its last use is removed, +/// fails here. Only the categories are checked against the code; each reason is pinned by hand, +/// because whether a use fits a reason depends on what the code does with the value. +@Suite struct PrivacyManifestTests { + private static let ios = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .appendingPathComponent("../../..") + .standardizedFileURL + + /// Apple's required-reason APIs by category, as Swift spells them. Disk space and active + /// keyboards are listed so a first use of them fails loudly too. + private static let apis: [String: String] = [ + "NSPrivacyAccessedAPICategoryUserDefaults": #"\bUserDefaults\b|@AppStorage|NSUbiquitousKeyValueStore"#, + "NSPrivacyAccessedAPICategoryFileTimestamp": #"\.creationDate\b|\.modificationDate\b|fileModificationDate|contentModificationDateKey|creationDateKey|\bgetattrlist|\bfgetattrlist|\bfstatat\(|\bl?stat\(|\bfstat\("#, + "NSPrivacyAccessedAPICategorySystemBootTime": #"\bsystemUptime\b|\bmach_absolute_time\b"#, + "NSPrivacyAccessedAPICategoryDiskSpace": #"volumeAvailableCapacity|volumeTotalCapacity|systemFreeSize|systemSize\b|\bstatv?fs\(|\bfstatv?fs\("#, + "NSPrivacyAccessedAPICategoryActiveKeyboards": #"\bactiveInputModes\b"#, + ] + + private static func manifest() throws -> [String: [String]] { + let url = ios.appendingPathComponent("HouseScan/PrivacyInfo.xcprivacy") + let plist = try PropertyListSerialization.propertyList(from: Data(contentsOf: url), format: nil) + let root = try #require(plist as? [String: Any]) + let types = try #require(root["NSPrivacyAccessedAPITypes"] as? [[String: Any]]) + var declared: [String: [String]] = [:] + for entry in types { + let category = try #require(entry["NSPrivacyAccessedAPIType"] as? String) + #expect(declared[category] == nil, "\(category) is declared twice") + declared[category] = try #require(entry["NSPrivacyAccessedAPITypeReasons"] as? [String]) + } + return declared + } + + /// Categories whose APIs appear in shipped Swift, with the first file that uses each. + private static func usedCategories() throws -> [String: String] { + var used: [String: String] = [:] + for folder in ["HouseScan", "HouseScanKit/Sources"] { + let root = ios.appendingPathComponent(folder) + let walker = try #require(FileManager.default.enumerator(at: root, includingPropertiesForKeys: nil)) + for case let file as URL in walker where file.pathExtension == "swift" { + // Comments name APIs without calling them. + let code = try String(contentsOf: file, encoding: .utf8) + .split(separator: "\n", omittingEmptySubsequences: false) + .map { line in line.range(of: "//").map { String(line[..<$0.lowerBound]) } ?? String(line) } + .joined(separator: "\n") + for (category, pattern) in apis where used[category] == nil { + if code.range(of: pattern, options: .regularExpression) != nil { + used[category] = file.path.replacingOccurrences(of: ios.path + "/", with: "") + } + } + } + } + return used + } + + @Test func everyUsedCategoryIsDeclaredAndNothingElse() throws { + let declared = try Self.manifest() + let used = try Self.usedCategories() + for (category, file) in used.sorted(by: { $0.key < $1.key }) { + #expect(declared[category] != nil, "\(file) uses \(category), which PrivacyInfo.xcprivacy does not declare") + } + for category in declared.keys.sorted() { + #expect(used[category] != nil, "PrivacyInfo.xcprivacy declares \(category), which no shipped code uses") + } + } + + @Test func reasonsMatchTheUsesTracedInTheManifestComments() throws { + #expect(try Self.manifest() == [ + "NSPrivacyAccessedAPICategoryUserDefaults": ["CA92.1"], + "NSPrivacyAccessedAPICategoryFileTimestamp": ["C617.1"], + "NSPrivacyAccessedAPICategorySystemBootTime": ["35F9.1", "8FFB.1"], + ]) + } +} From 31d4a34241f83126ee12dca2783a6d2aff5dfc8a Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Thu, 1 Oct 2026 04:09:38 -0500 Subject: [PATCH 22/48] ios: the privacy manifest check finds C calls with spaces, attributesOfItem, and skips only real comments A fresh review found that the source scan required '(' right after stat-family names, missed attributesOfItem and contentModificationDate, cut lines at '//' inside strings, and kept block comments. The scan now blanks only comments outside string literals, allows spaces before a C call's parenthesis, and covers those spellings. Isolated cases pin the scanner itself. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../PrivacyManifestTests.swift | 95 ++++++++++++++++--- 1 file changed, 80 insertions(+), 15 deletions(-) diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/PrivacyManifestTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/PrivacyManifestTests.swift index b9270b831..bbffef94f 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/PrivacyManifestTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/PrivacyManifestTests.swift @@ -12,16 +12,58 @@ import Testing .appendingPathComponent("../../..") .standardizedFileURL - /// Apple's required-reason APIs by category, as Swift spells them. Disk space and active - /// keyboards are listed so a first use of them fails loudly too. - private static let apis: [String: String] = [ - "NSPrivacyAccessedAPICategoryUserDefaults": #"\bUserDefaults\b|@AppStorage|NSUbiquitousKeyValueStore"#, - "NSPrivacyAccessedAPICategoryFileTimestamp": #"\.creationDate\b|\.modificationDate\b|fileModificationDate|contentModificationDateKey|creationDateKey|\bgetattrlist|\bfgetattrlist|\bfstatat\(|\bl?stat\(|\bfstat\("#, + /// Apple's required-reason APIs by category, as Swift can spell them. A C call may have + /// spaces before its parenthesis. Disk space and active keyboards are listed so a first use + /// of them fails loudly too. + static let apis: [String: String] = [ + "NSPrivacyAccessedAPICategoryUserDefaults": #"\bUserDefaults\b|@AppStorage\b|\bNSUbiquitousKeyValueStore\b"#, + "NSPrivacyAccessedAPICategoryFileTimestamp": #"\bcreationDate\b|\bmodificationDate\b|\bfileModificationDate\b|\bcontentModificationDate(Key)?\b|\bcreationDateKey\b|\battributesOfItem\b|\bfileAttributes\b|\b(f|l)?getattrlist(bulk|at)?\s*\(|\b(f|l)?stat(at)?\s*\("#, "NSPrivacyAccessedAPICategorySystemBootTime": #"\bsystemUptime\b|\bmach_absolute_time\b"#, - "NSPrivacyAccessedAPICategoryDiskSpace": #"volumeAvailableCapacity|volumeTotalCapacity|systemFreeSize|systemSize\b|\bstatv?fs\(|\bfstatv?fs\("#, + "NSPrivacyAccessedAPICategoryDiskSpace": #"\bvolumeAvailableCapacity\w*|\bvolumeTotalCapacity\w*|\bsystemFreeSize\b|\bsystemSize\b|\bf?statv?fs\s*\("#, "NSPrivacyAccessedAPICategoryActiveKeyboards": #"\bactiveInputModes\b"#, ] + /// Swift source with its comments blanked. Comment markers inside string literals stay code, + /// so a URL in a string can't hide the rest of its line. Block comments may nest, as in Swift. + static func code(_ source: String) -> String { + var out = "" + var chars = Array(source)[...] + var depth = 0 + var inString = false + while let c = chars.first { + let next = chars.dropFirst().first + if depth > 0 { + if c == "*", next == "/" { depth -= 1; chars = chars.dropFirst(2); continue } + if c == "/", next == "*" { depth += 1; chars = chars.dropFirst(2); continue } + if c == "\n" { out.append(c) } + chars = chars.dropFirst() + } else if inString { + out.append(c) + if c == "\\", let escaped = next { out.append(escaped); chars = chars.dropFirst(2); continue } + if c == "\"" || c == "\n" { inString = false } + chars = chars.dropFirst() + } else if c == "/", next == "/" { + chars = chars.drop { $0 != "\n" } + } else if c == "/", next == "*" { + depth = 1 + chars = chars.dropFirst(2) + } else { + if c == "\"" { inString = true } + out.append(c) + chars = chars.dropFirst() + } + } + return out + } + + /// The categories whose APIs appear in `source`'s code. + static func categories(in source: String) -> Set { + let code = code(source) + return Set(apis.compactMap { category, pattern in + code.range(of: pattern, options: .regularExpression) == nil ? nil : category + }) + } + private static func manifest() throws -> [String: [String]] { let url = ios.appendingPathComponent("HouseScan/PrivacyInfo.xcprivacy") let plist = try PropertyListSerialization.propertyList(from: Data(contentsOf: url), format: nil) @@ -43,15 +85,8 @@ import Testing let root = ios.appendingPathComponent(folder) let walker = try #require(FileManager.default.enumerator(at: root, includingPropertiesForKeys: nil)) for case let file as URL in walker where file.pathExtension == "swift" { - // Comments name APIs without calling them. - let code = try String(contentsOf: file, encoding: .utf8) - .split(separator: "\n", omittingEmptySubsequences: false) - .map { line in line.range(of: "//").map { String(line[..<$0.lowerBound]) } ?? String(line) } - .joined(separator: "\n") - for (category, pattern) in apis where used[category] == nil { - if code.range(of: pattern, options: .regularExpression) != nil { - used[category] = file.path.replacingOccurrences(of: ios.path + "/", with: "") - } + for category in categories(in: try String(contentsOf: file, encoding: .utf8)) where used[category] == nil { + used[category] = file.path.replacingOccurrences(of: ios.path + "/", with: "") } } } @@ -76,4 +111,34 @@ import Testing "NSPrivacyAccessedAPICategorySystemBootTime": ["35F9.1", "8FFB.1"], ]) } + + // The scanner on its own, so a gap in it can't hide behind today's sources. + + @Test(arguments: [ + ("statfs (path, &stats)", "NSPrivacyAccessedAPICategoryDiskSpace"), + ("let free = values.volumeAvailableCapacityForImportantUsage", "NSPrivacyAccessedAPICategoryDiskSpace"), + ("fstatvfs(fd, &info)", "NSPrivacyAccessedAPICategoryDiskSpace"), + ("let a = try fm.attributesOfItem(atPath: p)", "NSPrivacyAccessedAPICategoryFileTimestamp"), + ("let d = values.contentModificationDate", "NSPrivacyAccessedAPICategoryFileTimestamp"), + ("lstat (path, &s)", "NSPrivacyAccessedAPICategoryFileTimestamp"), + ("getattrlistbulk(fd, &list, buf, n, 0)", "NSPrivacyAccessedAPICategoryFileTimestamp"), + ("let t = mach_absolute_time()", "NSPrivacyAccessedAPICategorySystemBootTime"), + ("@AppStorage(\"k\") var on = false", "NSPrivacyAccessedAPICategoryUserDefaults"), + ("let url = \"https://example.com\"; let up = ProcessInfo.processInfo.systemUptime", "NSPrivacyAccessedAPICategorySystemBootTime"), + ("let s = \"/* not a comment\"; UserDefaults.standard.set(1, forKey: \"k\")", "NSPrivacyAccessedAPICategoryUserDefaults"), + ("UITextInputMode.activeInputModes", "NSPrivacyAccessedAPICategoryActiveKeyboards"), + ]) + func theScannerFindsAUse(source: String, category: String) { + #expect(Self.categories(in: source) == [category]) + } + + @Test(arguments: [ + "// UserDefaults.standard is not used here", + "/* systemUptime\n /* nested */ mach_absolute_time */", + "let status = statusText(code)", + "let restated = 1", + ]) + func theScannerIgnoresCommentsAndLookalikes(source: String) { + #expect(Self.categories(in: source).isEmpty) + } } From d88f7626df8fe5a134895e43f804572082d6c5af Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Thu, 1 Oct 2026 04:11:19 -0500 Subject: [PATCH 23/48] ios: a ground change on a failed or refused upload leaves that screen as it is After a ground resend failed or was refused, the one-resend flag was still set, so another ground change replaced the screen with the "still measuring" failure. An offline failure got the wrong reason. A refusal lost "Back to review" and gained "Try again", which would resend a scan the server refused. GroundFreshness now names that screen (.stopped): no answer is up and none is coming, so a ground change keeps it and the resend state. The homeowner's own choice sends the scan as it is then. This replaces 1f7e076b's engine-side special case with the rule's own state. Found by the product-description refresh of the uploading and flow documents. Co-Authored-By: Claude Opus 5.5 (1M context) --- ios/HouseScan/Runtime/ScanEngine.swift | 14 ++++++-------- .../HouseScanKit/Upload/GroundFreshness.swift | 6 +++++- .../GroundFreshnessTests.swift | 17 ++++++++++++++++- 3 files changed, 27 insertions(+), 10 deletions(-) diff --git a/ios/HouseScan/Runtime/ScanEngine.swift b/ios/HouseScan/Runtime/ScanEngine.swift index 25c3cae5e..9bd65efbe 100644 --- a/ios/HouseScan/Runtime/ScanEngine.swift +++ b/ios/HouseScan/Runtime/ScanEngine.swift @@ -1670,19 +1670,17 @@ final class ScanEngine { /// longer has (`GroundFreshness`), before `publishWall` can draw it again on the new one. private func answerAfter(_ change: GroundFreshness.Change) { let screen: GroundFreshness.Screen = switch state.phase { - case .uploading: .sending + case .uploading: + switch state.upload { + case .failed, .rejected: .stopped + case .idle, .packaging, .uploading, .analyzing, .done: .sending + } case .spotConfirm: .spotCheck case .result: .result case .resultAR: .resultInCamera default: .noAnswer } - // A failed or refused upload has no answer coming, so its scene no longer counts as - // packaged: the failure stays until "Try again", which packages the current ground. - let answerPending = switch state.upload { - case .failed, .rejected: false - case .idle, .packaging, .uploading, .analyzing, .done: scenePackaged - } - switch groundFreshness.after(change, on: screen, scenePackaged: answerPending) { + switch groundFreshness.after(change, on: screen, scenePackaged: scenePackaged) { case .keep: return case .sendAgain: diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Upload/GroundFreshness.swift b/ios/HouseScanKit/Sources/HouseScanKit/Upload/GroundFreshness.swift index 9ac8a4df8..5dd995f7a 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Upload/GroundFreshness.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Upload/GroundFreshness.swift @@ -30,6 +30,10 @@ public struct GroundFreshness: Equatable, Sendable { case noAnswer /// The upload screen: the scan is on its way, or its answer is being readied. case sending + /// The upload screen showing a failure or a refusal: no answer is up and none is coming. + /// The homeowner's own choice ("Try again", or back to the review after a refusal) sends + /// the scan as it is then, so a ground change leaves that screen as it is. + case stopped /// The spot check, which draws the answer's spot on a kept photo. case spotCheck /// The result screen and its 3D preview. @@ -60,7 +64,7 @@ public struct GroundFreshness: Equatable, Sendable { public mutating func after(_ change: Change, on screen: Screen, scenePackaged: Bool = false) -> Action { guard change == .ground else { return .keep } switch screen { - case .noAnswer: + case .noAnswer, .stopped: return .keep case .sending: if awaitingNewAnswer { return .fail } diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/GroundFreshnessTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/GroundFreshnessTests.swift index 69053ac27..26b0f6aed 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/GroundFreshnessTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/GroundFreshnessTests.swift @@ -63,7 +63,7 @@ import Testing for change in GroundFreshness.Change.allCases { var rule = awaiting ? Self.afterOneWithdrawal() : GroundFreshness() let expected: GroundFreshness.Action - if change == .anchorCorrection || screen == .noAnswer { + if change == .anchorCorrection || screen == .noAnswer || screen == .stopped { expected = .keep } else if awaiting { expected = .fail @@ -78,6 +78,21 @@ import Testing } } + /// A failed or refused upload keeps its own screen: an offline failure keeps its reason, and + /// a refusal keeps "Back to review" rather than offering to resend a refused scan. A resend's + /// failure stays protected, so its "Try again" still uses no second resend. + @Test(arguments: [false, true]) + func aStoppedUploadKeepsItsFailureAndItsResendState(packaged: Bool) { + var first = GroundFreshness() + #expect(first.after(.ground, on: .stopped, scenePackaged: packaged) == .keep) + #expect(first == GroundFreshness()) + + var resend = Self.afterOneWithdrawal() + #expect(resend.after(.ground, on: .stopped, scenePackaged: packaged) == .keep) + #expect(resend == Self.afterOneWithdrawal()) + #expect(resend.after(.ground, on: .sending, scenePackaged: true) == .fail) + } + @Test func aPackagedFirstUploadUsesOneResendUntilAnAnswerIsShown() { var rule = GroundFreshness() #expect(rule.after(.ground, on: .sending, scenePackaged: true) == .sendAgain) From 00644d86d2f33df2fe3879a8c831c892eaef4437 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Thu, 1 Oct 2026 04:14:22 -0500 Subject: [PATCH 24/48] ios: the ground freshness rule's summary names the packaged first upload and the stopped screen A fresh review noted the rule's opening still read as if every second change fails. Comment only. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Sources/HouseScanKit/Upload/GroundFreshness.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Upload/GroundFreshness.swift b/ios/HouseScanKit/Sources/HouseScanKit/Upload/GroundFreshness.swift index 5dd995f7a..4ffe7eab3 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Upload/GroundFreshness.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Upload/GroundFreshness.swift @@ -5,7 +5,9 @@ /// clearance against the ground the scan sent, so an answer drawn on a moved ground shows a /// spot nobody checked. A ground change therefore takes the answer down from the spot check, /// the result, its 3D preview and the camera view, and sends the scan again, built from the -/// wall as it is now. Only the server's answer to that scan comes back on screen. +/// wall as it is now. Only the server's answer to that scan comes back on screen. The same +/// holds once a first upload has packaged its scene, since its answer is already fixed to the +/// old ground. /// /// An anchor correction is different: it moves the wall, its corners, the kept cameras, the /// marks and the ground as one body, and the answer is in wall terms (spans along the wall and @@ -14,6 +16,10 @@ /// One resend per withdrawal. A ground that changes again before a new answer is shown means /// the phone is still settling, and sending again in a loop would only keep the homeowner /// waiting: the upload screen shows a failure with "Try again" instead. +/// +/// An upload that already failed or was refused has no answer up and none coming, so a ground +/// change leaves its screen alone (`Screen.stopped`); the homeowner's own retry or review sends +/// the scan as it is then, still within the same one resend. public struct GroundFreshness: Equatable, Sendable { /// What changed the wall. public enum Change: Equatable, Sendable, CaseIterable { From c23c26dfe8eaf3c598b89d30ba40304609e0324d Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Thu, 1 Oct 2026 04:14:45 -0500 Subject: [PATCH 25/48] ci: give the iOS job 75 minutes for the combined suite and its every-state audit #190's full run took 58 of 60 minutes, and the ground freshness UI tests add about 161 s, so the combined head would hit the limit. No test or assertion changes. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/ios.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index ae04760df..9c8f34a6a 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -38,8 +38,10 @@ jobs: # macos-26 (arm64) is the newest GA macOS image. The xcode-27 image is still a preview. runs-on: macos-26 # Beta run 36302099002 took 43 minutes before the spot-confirmation and review checks - # were added. Leave room for those checks without dropping the every-state audit. - timeout-minutes: 60 + # were added. Full run 36832282114 (#190) then took 58 of 60 minutes, and the ground + # freshness UI tests add about 161 s more (run 36744881974), so 60 would cut the + # every-state audit short. 75 leaves room without dropping any check. + timeout-minutes: 75 env: # Newest stable Xcode on macos-26 per images/macos/macos-26-arm64-Readme.md (image 20260907). DEVELOPER_DIR: /Applications/Xcode_26.6.app/Contents/Developer From e292494961ff2f9257d7c3c0487be02e74b60d39 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Thu, 1 Oct 2026 05:00:48 -0500 Subject: [PATCH 26/48] ios: at the largest text sizes the see-behind card leads with what to do and folds the rest Root's full-scale review of the AX5 frame at 35cc8521 found the see-behind card covering the camera, and the spot it asks the homeowner to look past, down to its reply. Its second line holds the action, so #190 kept it unfolded. The card now folds the other way when it folds: "Look at it from the side or step around it" leads, coaching and "Can't see past it" stay in view, and "Something is in front of the wall here, about 5 ft right of your meter." sits under Details. Instruction.folded carries those words; default sizes keep the title and detail unchanged. testSeeBehindLeadsWithItsActionAtLargestTextSize checks the folded words, the reply and Details without scrolling, 150 pt of open camera, the marker inside it, and Details opening the situation. The every-state audit gets the folded wording for its AX5 frame. testEndScanQuestionKeepsWalkingOrEnds gave its first tap 5 s, which also covered waiting for a laid-out, hittable frame. On run 36841096811 each hittability check took 1-2 s and the budget ran out; it now has the 15 s its second tap already uses. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../UI/Components/InstructionCard.swift | 10 +++- ios/HouseScan/UI/Copy/ScanCopy.swift | 18 +++++- ios/HouseScan/UI/Screens/WallWalkScreen.swift | 11 ++-- .../ScreenStatesUITests.swift | 56 +++++++++++++++++-- 4 files changed, 81 insertions(+), 14 deletions(-) diff --git a/ios/HouseScan/UI/Components/InstructionCard.swift b/ios/HouseScan/UI/Components/InstructionCard.swift index c3e0f15bd..8ba32e09a 100644 --- a/ios/HouseScan/UI/Components/InstructionCard.swift +++ b/ios/HouseScan/UI/Components/InstructionCard.swift @@ -60,7 +60,11 @@ struct InstructionCard: View { private var replyTask: Instruction { reply?.task ?? instruction } - private var hasDetails: Bool { foldsDetail && (instruction.detail != nil || legend != nil) } + private var hasDetails: Bool { foldsDetail && (foldedDetail != nil || legend != nil) } + + /// The words the card shows: folded, a step that leads with its action does (`Instruction.folded`). + private var title: String { foldsDetail ? (instruction.folded?.title ?? instruction.title) : instruction.title } + private var foldedDetail: String? { instruction.folded?.detail ?? instruction.detail } var body: some View { VStack(alignment: .trailing, spacing: 0) { @@ -137,7 +141,7 @@ struct InstructionCard: View { .fixedSize(horizontal: false, vertical: true) .padding(.bottom, 2) } - Text(instruction.title) + Text(title) .font(Typeface.instruction) .foregroundStyle(Palette.chalk) .fixedSize(horizontal: false, vertical: true) @@ -165,7 +169,7 @@ struct InstructionCard: View { private var details: some View { DisclosureGroup(isExpanded: $detailsShown) { VStack(alignment: .leading, spacing: 12) { - if let detail = instruction.detail { + if let detail = foldedDetail { Text(detail) .font(Typeface.hint) .foregroundStyle(Palette.chalk.opacity(0.92)) diff --git a/ios/HouseScan/UI/Copy/ScanCopy.swift b/ios/HouseScan/UI/Copy/ScanCopy.swift index b2b65e12e..725764282 100644 --- a/ios/HouseScan/UI/Copy/ScanCopy.swift +++ b/ios/HouseScan/UI/Copy/ScanCopy.swift @@ -10,6 +10,17 @@ struct Instruction: Hashable { /// apart from `detail` so a card that folds its detail away at the largest text sizes still /// shows it (`InstructionCard.foldsDetail`). var note: String? = nil + /// The card's words when it folds at the largest text sizes, where the title only names the + /// situation and the detail holds what to do: the doing leads, and the situation moves under + /// Details. Nil keeps the title and folds the detail. + var folded: Folded? = nil + + struct Folded: Hashable { + /// What to do now, in place of `title`. + var title: String + /// The situation and where it is, under Details in place of `detail`. + var detail: String + } /// The second line as one text: the detail, then the note on a line of its own. var detailAndNote: String? { @@ -84,7 +95,12 @@ enum ScanCopy { Instruction( title: "Something is in front of the wall here", // A place to look, not a measurement: "About 5 ft", not "4 ft 11 in". - detail: "\(Distance.aroundFromMeter(s...s).capitalizedFirst). Look at it from the side or step around it." + detail: "\(Distance.aroundFromMeter(s...s).capitalizedFirst). Look at it from the side or step around it.", + // At AX5 the unfolded card covered the camera and the spot it asks about. + folded: Instruction.Folded( + title: "Look at it from the side or step around it", + detail: "Something is in front of the wall here, \(Distance.aroundFromMeter(s...s))." + ) ) case .gap: Instruction(title: "One more view", detail: nil) diff --git a/ios/HouseScan/UI/Screens/WallWalkScreen.swift b/ios/HouseScan/UI/Screens/WallWalkScreen.swift index 98848a3f9..289089cbc 100644 --- a/ios/HouseScan/UI/Screens/WallWalkScreen.swift +++ b/ios/HouseScan/UI/Screens/WallWalkScreen.swift @@ -129,15 +129,16 @@ struct WallWalkScreen: View { /// second line (how to hold the phone, where along the wall, how far to go) can fold at the /// largest text sizes and leave the camera open (`CameraChrome.aims`). The aim ring marks the /// place, and a step to the side moves into the title when it is needed (`ScanCopy.aim`). - /// Not where the second line is the action: looking past an obstruction ("Look at it from the - /// side"), a step back added to an aim step, the wall's end, whose title only asks ("Aim - /// where the wall stops"), marking the next wall. Nor a question, a mark, the tray, coaching - /// that replaces the task, or a refusal. + /// Looking past an obstruction folds the other way: its action ("Look at it from the side") + /// leads and the situation goes under Details (`Instruction.folded`), since unfolded at AX5 + /// it covered the camera and the spot it asks about. Not where the second line is the action + /// and nothing leads with it: a step back added to an aim step, the wall's end, whose title + /// only asks ("Aim where the wall stops"), marking the next wall. Nor a question, a mark, the + /// tray, coaching that replaces the task, or a refusal. private var aims: Bool { switch controlsKey { case .walking, .finish: if let coaching, ScanCopy.coachingReplacesTask(coaching) { return false } - if case .seeBehind = state.guidance { return false } if state.guidanceHint?.stepBack == true { return false } return tone != .refusal case .markEnd, .nextWall, .marking, .endScanQuestion, .endQuestion, .overheadQuestion, .nextWallConfirm, .tray: diff --git a/ios/HouseScanUITests/ScreenStatesUITests.swift b/ios/HouseScanUITests/ScreenStatesUITests.swift index 5e03f4f00..87aaf31f2 100644 --- a/ios/HouseScanUITests/ScreenStatesUITests.swift +++ b/ios/HouseScanUITests/ScreenStatesUITests.swift @@ -103,6 +103,8 @@ final class ScreenStatesUITests: XCTestCase { "wallWalk-hidden": [("wallTape", "2 sections hidden behind something")], "wallWalk-fullLegend": [("wallTape", "2 sections hidden behind something")], "wallWalk-seeBehind": [("instruction", "Something is in front of the wall here")], + // At AX5 looking past it leads, and the situation folds under Details. + "wallWalk-seeBehind-AX5": [("instruction", "Look at it from the side or step around it"), ("instruction.details", "Details")], "gapRequest-followUp": [("instruction", "One more view to finish")], // #75: a server request's stretch by its two ends, not its middle. "gapRequest-groundOut": [("instruction", "From 4 ft to 7 ft right of your meter.")], @@ -431,7 +433,8 @@ final class ScreenStatesUITests: XCTestCase { app.launch() XCTAssertTrue(element(app, "action.keepWalking").waitForExistence(timeout: 15)) XCTAssertFalse(element(app, "action.cannotAccess").exists, "the card's reply must wait for the answer") - tap(app, "action.keepWalking", timeout: 5) + // Run 36841096811: on a slow runner each hittability check took 1-2 s and 5 s ran out. + tap(app, "action.keepWalking", timeout: 15) XCTAssertTrue(element(app, "action.keepWalking").waitForNonExistence(timeout: 5), "the answer must close the question") XCTAssertTrue(element(app, "screen.wallWalk").exists, "Keep walking must stay on the walk") app.terminate() @@ -811,15 +814,16 @@ final class ScreenStatesUITests: XCTestCase { XCTAssertFalse(legend.exists) } - /// Where a card's second line is itself what to do now, it stays on the card at AX5: looking - /// past an obstruction, the walk out with its live reading, stepping back for ground further - /// out, and tilting up to the roof or the sky. + /// Where a card's second line is itself what to do now, it stays on the card at AX5: the walk + /// out with its live reading, stepping back for ground further out, and tilting up to the roof + /// or the sky. Looking past an obstruction leads with its action instead + /// (`testSeeBehindLeadsWithItsActionAtLargestTextSize`). @MainActor func testActionWordsStayOnTheCardAtLargestTextSize() throws { continueAfterFailure = false let app = XCUIApplication() for (state, words) in [ - ("wallWalk-seeBehind", "Look at it from the side"), ("gapRequest-walkOut", "Follow the dotted line"), + ("gapRequest-walkOut", "Follow the dotted line"), ("gapRequest-groundOut", "Step back and tilt down"), ("gapRequest-overhead", "up to the roof or the sky"), ] { guard let fixture = Self.states.first(where: { $0.name == state }) else { @@ -838,6 +842,48 @@ final class ScreenStatesUITests: XCTestCase { } } + /// Root's review of the AX5 frame at 35cc8521: unfolded, the see-behind card covered the camera, + /// and the spot it asks the homeowner to look past, down to its reply. Folded, what to do leads, + /// the reply stays, the camera opens between the card and the actions, and Details holds the + /// situation and where it is. + @MainActor + func testSeeBehindLeadsWithItsActionAtLargestTextSize() throws { + continueAfterFailure = false + let app = XCUIApplication() + app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoFreeze", "-uiDemoPhase", "wallWalk", "-uiDemoSeeBehind"] + Self.largestText + app.launch() + defer { app.terminate() } + XCTAssertTrue(element(app, "screen.wallWalk").waitForExistence(timeout: 15)) + let window = app.windows.firstMatch.frame + let card = element(app, "instruction") + let details = element(app, "instruction.details") + let cantSee = reply(app, "Can't see past it") + let mark = element(app, "action.markSomething") + XCTAssertTrue(card.waitForExistence(timeout: 5), "missing instruction") + XCTAssertTrue(details.waitForExistence(timeout: 5), "the situation must fold under Details") + XCTAssertTrue(cantSee.waitForExistence(timeout: 5), "missing the reply Can't see past it") + XCTAssertTrue(mark.waitForExistence(timeout: 5), "missing action.markSomething") + let label = ElementRead.snapshot(card)?.label ?? "" + XCTAssertTrue(label.contains("Look at it from the side or step around it"), "what to do must lead, got \(label)") + XCTAssertFalse(label.contains("Something is in front of the wall here"), "the situation must fold under Details, got \(label)") + XCTAssertTrue(details.isHittable, "Details must be reachable without scrolling") + XCTAssertTrue(cantSee.isHittable, "Can't see past it must be reachable without scrolling") + let cardBottom = max(card.frame.maxY, details.frame.maxY, cantSee.frame.maxY) + let openCamera = CGRect(x: window.minX, y: cardBottom, width: window.width, height: mark.frame.minY - cardBottom) + XCTAssertGreaterThanOrEqual(openCamera.height, 150, "the camera must stay open between the card and the actions: \(openCamera)") + for marker in [element(app, "aim.ring"), element(app, "aim.arrow")] where marker.exists { + XCTAssertTrue(openCamera.contains(marker.frame), "the spot's marker \(marker.frame) must be in the open camera \(openCamera)") + } + attach(app, name: "wallWalk-seeBehind-AX5-folded") + + tap(app, "instruction.details") + let detail = element(app, "instruction.detail") + XCTAssertTrue(detail.waitForExistence(timeout: 5), "Details must open the situation") + XCTAssertTrue(detail.label.contains("Something is in front of the wall here, about 5 ft right of your meter"), "detail reads \(detail.label)") + XCTAssertTrue(scrollUntilHittable(cantSee, in: app), "Can't see past it must stay reachable with Details open") + attach(app, name: "wallWalk-seeBehind-AX5-details") + } + /// Coaching that rides along with the task says what to do now, so the folded card at AX5 /// keeps it in view with the task; only the how-to words fold. @MainActor From efb7ab61bbd41ee385f1a333d9d283fa8d707aa9 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Thu, 1 Oct 2026 05:13:05 -0500 Subject: [PATCH 27/48] ios: coaching keeps the see-behind card's folded words; its AX5 test measures the visible controls A fresh prep-for-prod review found withCoaching rebuilt the card from its title and detail, so with "Slow down" or "It's dark here" the folded see-behind card led with the situation again. It now copies the task and replaces only its note. The test read the open camera down to "Mark something", which this step hides at zero height; it now ends at the first control shown below the card, runs once with slow-down coaching, and leaves the ring's place to the attached frames, since a ring without progress is hidden from VoiceOver. Co-Authored-By: Claude Opus 5.5 (1M context) --- ios/HouseScan/UI/Copy/ScanCopy.swift | 5 +- .../ScreenStatesUITests.swift | 76 +++++++++++-------- 2 files changed, 47 insertions(+), 34 deletions(-) diff --git a/ios/HouseScan/UI/Copy/ScanCopy.swift b/ios/HouseScan/UI/Copy/ScanCopy.swift index 725764282..be4249c4d 100644 --- a/ios/HouseScan/UI/Copy/ScanCopy.swift +++ b/ios/HouseScan/UI/Copy/ScanCopy.swift @@ -199,7 +199,10 @@ enum ScanCopy { /// coaching came up (#80), and the dark coaching can stay up for a whole night walk. static func withCoaching(_ task: Instruction, _ coaching: Coaching?) -> Instruction { guard let coaching else { return task } - return Instruction(title: task.title, detail: task.detail, note: coachingNote(coaching)) + // A copy, so the task keeps everything else it carries, such as its folded words. + var card = task + card.note = coachingNote(coaching) + return card } // MARK: Aim ring diff --git a/ios/HouseScanUITests/ScreenStatesUITests.swift b/ios/HouseScanUITests/ScreenStatesUITests.swift index 87aaf31f2..c8e2c92a2 100644 --- a/ios/HouseScanUITests/ScreenStatesUITests.swift +++ b/ios/HouseScanUITests/ScreenStatesUITests.swift @@ -844,44 +844,54 @@ final class ScreenStatesUITests: XCTestCase { /// Root's review of the AX5 frame at 35cc8521: unfolded, the see-behind card covered the camera, /// and the spot it asks the homeowner to look past, down to its reply. Folded, what to do leads, - /// the reply stays, the camera opens between the card and the actions, and Details holds the - /// situation and where it is. + /// ride-along coaching and the reply stay, the camera opens between the card and the controls + /// below it, and Details holds the situation and where it is. The step's ring has no progress, + /// so it is hidden from VoiceOver and its place is checked in the attached frames. @MainActor func testSeeBehindLeadsWithItsActionAtLargestTextSize() throws { continueAfterFailure = false let app = XCUIApplication() - app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoFreeze", "-uiDemoPhase", "wallWalk", "-uiDemoSeeBehind"] + Self.largestText - app.launch() - defer { app.terminate() } - XCTAssertTrue(element(app, "screen.wallWalk").waitForExistence(timeout: 15)) - let window = app.windows.firstMatch.frame - let card = element(app, "instruction") - let details = element(app, "instruction.details") - let cantSee = reply(app, "Can't see past it") - let mark = element(app, "action.markSomething") - XCTAssertTrue(card.waitForExistence(timeout: 5), "missing instruction") - XCTAssertTrue(details.waitForExistence(timeout: 5), "the situation must fold under Details") - XCTAssertTrue(cantSee.waitForExistence(timeout: 5), "missing the reply Can't see past it") - XCTAssertTrue(mark.waitForExistence(timeout: 5), "missing action.markSomething") - let label = ElementRead.snapshot(card)?.label ?? "" - XCTAssertTrue(label.contains("Look at it from the side or step around it"), "what to do must lead, got \(label)") - XCTAssertFalse(label.contains("Something is in front of the wall here"), "the situation must fold under Details, got \(label)") - XCTAssertTrue(details.isHittable, "Details must be reachable without scrolling") - XCTAssertTrue(cantSee.isHittable, "Can't see past it must be reachable without scrolling") - let cardBottom = max(card.frame.maxY, details.frame.maxY, cantSee.frame.maxY) - let openCamera = CGRect(x: window.minX, y: cardBottom, width: window.width, height: mark.frame.minY - cardBottom) - XCTAssertGreaterThanOrEqual(openCamera.height, 150, "the camera must stay open between the card and the actions: \(openCamera)") - for marker in [element(app, "aim.ring"), element(app, "aim.arrow")] where marker.exists { - XCTAssertTrue(openCamera.contains(marker.frame), "the spot's marker \(marker.frame) must be in the open camera \(openCamera)") - } - attach(app, name: "wallWalk-seeBehind-AX5-folded") + for (coaching, name) in [(nil, "wallWalk-seeBehind-AX5-folded"), ("slowDown", "wallWalk-seeBehind-slowDown-AX5-folded")] { + app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoFreeze", "-uiDemoPhase", "wallWalk", "-uiDemoSeeBehind"] + + (coaching.map { ["-uiDemoCoaching", $0] } ?? []) + Self.largestText + app.launch() + XCTAssertTrue(element(app, "screen.wallWalk").waitForExistence(timeout: 15)) + let window = app.windows.firstMatch.frame + let card = element(app, "instruction") + let details = element(app, "instruction.details") + let cantSee = reply(app, "Can't see past it") + XCTAssertTrue(card.waitForExistence(timeout: 5), "missing instruction") + XCTAssertTrue(details.waitForExistence(timeout: 5), "\(name): the situation must fold under Details") + XCTAssertTrue(cantSee.waitForExistence(timeout: 5), "\(name): missing the reply Can't see past it") + let label = ElementRead.snapshot(card)?.label ?? "" + XCTAssertTrue(label.contains("Look at it from the side or step around it"), "\(name): what to do must lead, got \(label)") + XCTAssertFalse(label.contains("Something is in front of the wall here"), "\(name): the situation must fold under Details, got \(label)") + if coaching != nil { + XCTAssertTrue(label.contains("Slow down"), "\(name): the coaching must stay in view, got \(label)") + } + XCTAssertTrue(details.isHittable, "\(name): Details must be reachable without scrolling") + XCTAssertTrue(cantSee.isHittable, "\(name): Can't see past it must be reachable without scrolling") + // "Mark something" steps aside on this step, so the camera ends at the first control + // shown below the card, or at the bottom of the screen. + let cardBottom = max(card.frame.maxY, details.frame.maxY, cantSee.frame.maxY) + let below = ["action.endHere", "action.markEnd", "action.finishWalk", "wallTape"] + .map { element(app, $0) } + .filter { $0.exists && $0.frame.minY > cardBottom } + .map(\.frame.minY) + let openCamera = CGRect(x: window.minX, y: cardBottom, width: window.width, height: min(below.min() ?? window.maxY, window.maxY) - cardBottom) + XCTAssertGreaterThanOrEqual(openCamera.height, 150, "\(name): the camera must stay open between the card and the controls: \(openCamera)") + attach(app, name: name) - tap(app, "instruction.details") - let detail = element(app, "instruction.detail") - XCTAssertTrue(detail.waitForExistence(timeout: 5), "Details must open the situation") - XCTAssertTrue(detail.label.contains("Something is in front of the wall here, about 5 ft right of your meter"), "detail reads \(detail.label)") - XCTAssertTrue(scrollUntilHittable(cantSee, in: app), "Can't see past it must stay reachable with Details open") - attach(app, name: "wallWalk-seeBehind-AX5-details") + if coaching == nil { + tap(app, "instruction.details") + let detail = element(app, "instruction.detail") + XCTAssertTrue(detail.waitForExistence(timeout: 5), "Details must open the situation") + XCTAssertTrue(detail.label.contains("Something is in front of the wall here, about 5 ft right of your meter"), "detail reads \(detail.label)") + XCTAssertTrue(scrollUntilHittable(cantSee, in: app), "Can't see past it must stay reachable with Details open") + attach(app, name: "wallWalk-seeBehind-AX5-details") + } + app.terminate() + } } /// Coaching that rides along with the task says what to do now, so the folded card at AX5 From b6ee153b742db4144fec2bf89e11912fc7c3707d Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Thu, 1 Oct 2026 06:29:47 -0500 Subject: [PATCH 28/48] ios: the folded see-behind card leads with "Look around it" Run 36851155983 showed the folded card working without coaching, but with "Slow down" the four-line lead beside the coaching icon brought the card to 858 of 874 pt and closed the camera again. "Look around it" stays two lines beside the icon; Details now holds the full default words, the situation, where it is and both ways to look. The test attaches its frame before the open-camera check, so a failure keeps its evidence. Co-Authored-By: Claude Opus 5.5 (1M context) --- ios/HouseScan/UI/Copy/ScanCopy.swift | 7 ++++--- ios/HouseScan/UI/Screens/WallWalkScreen.swift | 6 +++--- ios/HouseScanUITests/ScreenStatesUITests.swift | 8 ++++---- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/ios/HouseScan/UI/Copy/ScanCopy.swift b/ios/HouseScan/UI/Copy/ScanCopy.swift index be4249c4d..8f3b348eb 100644 --- a/ios/HouseScan/UI/Copy/ScanCopy.swift +++ b/ios/HouseScan/UI/Copy/ScanCopy.swift @@ -96,10 +96,11 @@ enum ScanCopy { title: "Something is in front of the wall here", // A place to look, not a measurement: "About 5 ft", not "4 ft 11 in". detail: "\(Distance.aroundFromMeter(s...s).capitalizedFirst). Look at it from the side or step around it.", - // At AX5 the unfolded card covered the camera and the spot it asks about. + // At AX5 the unfolded card covered the camera and the spot it asks about. The lead + // stays two lines even beside a coaching icon; the full words are under Details. folded: Instruction.Folded( - title: "Look at it from the side or step around it", - detail: "Something is in front of the wall here, \(Distance.aroundFromMeter(s...s))." + title: "Look around it", + detail: "Something is in front of the wall here, \(Distance.aroundFromMeter(s...s)). Look at it from the side or step around it." ) ) case .gap: diff --git a/ios/HouseScan/UI/Screens/WallWalkScreen.swift b/ios/HouseScan/UI/Screens/WallWalkScreen.swift index 289089cbc..cefd9f266 100644 --- a/ios/HouseScan/UI/Screens/WallWalkScreen.swift +++ b/ios/HouseScan/UI/Screens/WallWalkScreen.swift @@ -129,9 +129,9 @@ struct WallWalkScreen: View { /// second line (how to hold the phone, where along the wall, how far to go) can fold at the /// largest text sizes and leave the camera open (`CameraChrome.aims`). The aim ring marks the /// place, and a step to the side moves into the title when it is needed (`ScanCopy.aim`). - /// Looking past an obstruction folds the other way: its action ("Look at it from the side") - /// leads and the situation goes under Details (`Instruction.folded`), since unfolded at AX5 - /// it covered the camera and the spot it asks about. Not where the second line is the action + /// Looking past an obstruction folds the other way: its action ("Look around it") leads and + /// the situation and how go under Details (`Instruction.folded`), since unfolded at AX5 it + /// covered the camera and the spot it asks about. Not where the second line is the action /// and nothing leads with it: a step back added to an aim step, the wall's end, whose title /// only asks ("Aim where the wall stops"), marking the next wall. Nor a question, a mark, the /// tray, coaching that replaces the task, or a refusal. diff --git a/ios/HouseScanUITests/ScreenStatesUITests.swift b/ios/HouseScanUITests/ScreenStatesUITests.swift index c8e2c92a2..1d8c5c033 100644 --- a/ios/HouseScanUITests/ScreenStatesUITests.swift +++ b/ios/HouseScanUITests/ScreenStatesUITests.swift @@ -104,7 +104,7 @@ final class ScreenStatesUITests: XCTestCase { "wallWalk-fullLegend": [("wallTape", "2 sections hidden behind something")], "wallWalk-seeBehind": [("instruction", "Something is in front of the wall here")], // At AX5 looking past it leads, and the situation folds under Details. - "wallWalk-seeBehind-AX5": [("instruction", "Look at it from the side or step around it"), ("instruction.details", "Details")], + "wallWalk-seeBehind-AX5": [("instruction", "Look around it"), ("instruction.details", "Details")], "gapRequest-followUp": [("instruction", "One more view to finish")], // #75: a server request's stretch by its two ends, not its middle. "gapRequest-groundOut": [("instruction", "From 4 ft to 7 ft right of your meter.")], @@ -864,7 +864,7 @@ final class ScreenStatesUITests: XCTestCase { XCTAssertTrue(details.waitForExistence(timeout: 5), "\(name): the situation must fold under Details") XCTAssertTrue(cantSee.waitForExistence(timeout: 5), "\(name): missing the reply Can't see past it") let label = ElementRead.snapshot(card)?.label ?? "" - XCTAssertTrue(label.contains("Look at it from the side or step around it"), "\(name): what to do must lead, got \(label)") + XCTAssertTrue(label.contains("Look around it"), "\(name): what to do must lead, got \(label)") XCTAssertFalse(label.contains("Something is in front of the wall here"), "\(name): the situation must fold under Details, got \(label)") if coaching != nil { XCTAssertTrue(label.contains("Slow down"), "\(name): the coaching must stay in view, got \(label)") @@ -879,14 +879,14 @@ final class ScreenStatesUITests: XCTestCase { .filter { $0.exists && $0.frame.minY > cardBottom } .map(\.frame.minY) let openCamera = CGRect(x: window.minX, y: cardBottom, width: window.width, height: min(below.min() ?? window.maxY, window.maxY) - cardBottom) - XCTAssertGreaterThanOrEqual(openCamera.height, 150, "\(name): the camera must stay open between the card and the controls: \(openCamera)") attach(app, name: name) + XCTAssertGreaterThanOrEqual(openCamera.height, 150, "\(name): the camera must stay open between the card and the controls: \(openCamera)") if coaching == nil { tap(app, "instruction.details") let detail = element(app, "instruction.detail") XCTAssertTrue(detail.waitForExistence(timeout: 5), "Details must open the situation") - XCTAssertTrue(detail.label.contains("Something is in front of the wall here, about 5 ft right of your meter"), "detail reads \(detail.label)") + XCTAssertTrue(detail.label.contains("Something is in front of the wall here, about 5 ft right of your meter. Look at it from the side or step around it."), "detail reads \(detail.label)") XCTAssertTrue(scrollUntilHittable(cantSee, in: app), "Can't see past it must stay reachable with Details open") attach(app, name: "wallWalk-seeBehind-AX5-details") } From 59ff63a26410596078a25be0d373454e2d8ef82e Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Thu, 1 Oct 2026 08:44:21 -0500 Subject: [PATCH 29/48] ios: at the largest text sizes the find-meter card folds and the reticle sits in open camera Root's manual pass at b6ee153b, right after "Allow camera" at AX5: the find-meter card filled the camera with its description, the reticle at the middle of the screen was under it, and "This is my meter" ran below the screen. The screen now marks itself as an aiming step, so the card keeps "Find your electric meter" (or "Tap a spot on a wall" on a practice scan) and folds the description under Details, and the chrome keeps at least 180 pt of camera open above the button. The middle of the screen is still under the card at that size, so the reticle moves to the middle of the open camera and the button marks there, the same mark a tap on that spot makes. Default sizes keep the reticle at the screen's middle and mark there as before. "Hold on, finding your wall" folds with its action leading, as see-behind does; coaching that replaces the task stays unfolded, since the engine refuses a meter mark until tracking recovers. New tests check both the ordinary and the practice step at AX5: the task stays, Details holds the description and opens and closes, the button is whole on screen without scrolling, at least 150 pt of camera stays open, and the button reaches the close-up. The every-state audit now covers find-meter at AX5. Co-Authored-By: Claude Opus 5.5 (1M context) --- ios/HouseScan/UI/Copy/ScanCopy.swift | 10 ++- .../UI/Screens/FindMeterScreen.swift | 57 +++++++++++--- .../ScreenStatesUITests.swift | 74 ++++++++++++++++++- 3 files changed, 130 insertions(+), 11 deletions(-) diff --git a/ios/HouseScan/UI/Copy/ScanCopy.swift b/ios/HouseScan/UI/Copy/ScanCopy.swift index 8f3b348eb..f5460028a 100644 --- a/ios/HouseScan/UI/Copy/ScanCopy.swift +++ b/ios/HouseScan/UI/Copy/ScanCopy.swift @@ -51,7 +51,15 @@ enum ScanCopy { detail: "A gray box with a round glass dial or a small screen, usually on an outside wall." ) case .aimAtWallForMeter: - Instruction(title: "Hold on, finding your wall", detail: "Move your phone slowly side to side, then aim at your meter again.") + Instruction( + title: "Hold on, finding your wall", + detail: "Move your phone slowly side to side, then aim at your meter again.", + // Folded at the largest text sizes, the doing leads, as on see-behind. + folded: Instruction.Folded( + title: "Move your phone side to side", + detail: "Hold on, finding your wall. Move your phone slowly side to side, then aim at your meter again." + ) + ) case .holdOnMeter: Instruction(title: "Hold your meter in the circle", detail: "Your phone takes the photo by itself.") case .walk(let side, let remaining): diff --git a/ios/HouseScan/UI/Screens/FindMeterScreen.swift b/ios/HouseScan/UI/Screens/FindMeterScreen.swift index a949545dd..4be163333 100644 --- a/ios/HouseScan/UI/Screens/FindMeterScreen.swift +++ b/ios/HouseScan/UI/Screens/FindMeterScreen.swift @@ -3,19 +3,24 @@ import SwiftUI /// "Find your electric meter": aim the reticle at the meter and tap the big button, or tap the /// meter on screen. A ring blooms where the homeowner tapped, on touch, before the engine /// answers; if the engine asks them to step closer, the instruction changes instead. +/// +/// At the largest text sizes the card folds its second line under Details and the chrome keeps +/// the camera open between the card and the button (`CameraChrome.aims`). The middle of the +/// screen is then under the card, so the reticle moves to the middle of that open camera and +/// the button marks there: the same mark a tap on that spot makes. struct FindMeterScreen: View { let state: ScanViewState let actions: any ScanActions @State private var cameraSize: CGSize = .zero @State private var taps: [TapRipple.Ripple] = [] + @State private var cameraWindow = CameraWindow() + @Environment(\.dynamicTypeSize) private var typeSize var body: some View { ZStack { CameraSizeReader(size: $cameraSize) - Reticle(diameter: 76) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .ignoresSafeArea() + reticle TapRipple(ripples: $taps) CameraChrome( instruction: instruction, @@ -26,23 +31,57 @@ struct FindMeterScreen: View { onCameraTap: { point in ripple(at: point) actions.markMeter(at: point, viewSize: cameraSize) - } + }, + cameraWindow: cameraWindow, + aims: aims ) { Button { - ripple(at: CGPoint(x: cameraSize.width / 2, y: cameraSize.height / 2)) - actions.markMeter(at: nil, viewSize: cameraSize) + ripple(at: aimPoint ?? CGPoint(x: cameraSize.width / 2, y: cameraSize.height / 2)) + actions.markMeter(at: aimPoint, viewSize: cameraSize) } label: { Label(state.isPracticeScan ? ScanCopy.practiceMarkMeter : "This is my meter", systemImage: "mappin.and.ellipse") } .buttonStyle(.primary) - .accessibilityHint(state.isPracticeScan - ? "Puts the sample meter on the wall at the circle in the middle of the screen" - : "Pins your electric meter at the circle in the middle of the screen") + .accessibilityHint(markHint) .accessibilityIdentifier("action.markMeter") } } } + /// The reticle at the middle of the screen, or of the open camera when the card folds. + private var reticle: some View { + GeometryReader { proxy in + let origin = proxy.frame(in: .global).origin + let center = aimPoint ?? CGPoint(x: proxy.size.width / 2 + origin.x, y: proxy.size.height / 2 + origin.y) + Reticle(diameter: 76) + .position(x: center.x - origin.x, y: center.y - origin.y) + } + .ignoresSafeArea() + } + + /// Whether the card folds at the largest text sizes: always, except for coaching that + /// replaces the task (lost tracking, starting up), whose words are the only thing to do and + /// during which the engine refuses a meter mark anyway. + private var aims: Bool { + guard let coaching = state.coaching else { return true } + return !ScanCopy.coachingReplacesTask(coaching) + } + + /// Where the reticle is and the button marks, in the camera view's (global) coordinates, when + /// the card folds: the middle of the open camera's part on screen. Nil means the middle of + /// the screen, as at every other size. + private var aimPoint: CGPoint? { + guard aims, typeSize.isAccessibilitySize, let window = cameraWindow.frame else { return nil } + let shown = window.intersection(CGRect(origin: .zero, size: cameraSize)) + guard !shown.isNull, shown.height > 0 else { return nil } + return CGPoint(x: shown.midX, y: shown.midY) + } + + private var markHint: String { + let place = aimPoint == nil ? "the circle in the middle of the screen" : "the circle below the instructions" + return state.isPracticeScan ? "Puts the sample meter on the wall at \(place)" : "Pins your electric meter at \(place)" + } + private var instruction: Instruction { if let coaching = state.coaching { return ScanCopy.coaching(coaching) } if state.guidance == .aimAtWallForMeter { return ScanCopy.guidance(.aimAtWallForMeter) } diff --git a/ios/HouseScanUITests/ScreenStatesUITests.swift b/ios/HouseScanUITests/ScreenStatesUITests.swift index 1d8c5c033..cb152649c 100644 --- a/ios/HouseScanUITests/ScreenStatesUITests.swift +++ b/ios/HouseScanUITests/ScreenStatesUITests.swift @@ -85,7 +85,7 @@ final class ScreenStatesUITests: XCTestCase { /// The screens with the most text, also checked at AX5. private static let largestTextStates: Set = [ - "onboarding", "onboarding-practice", "onboarding-moves", "onboarding-permissions", "wallWalk", "wallWalk-endQuestion", "wallWalk-endPreview", "wallWalk-endQuestionLeavesOut", "wallWalk-nextWallRefused", "wallWalk-overheadQuestion", "gapRequest-walkOut", "gapRequest-overheadQuestion", "meterCloseUp-cantGetClearShot", "meterCloseUp-chooseNumber", + "onboarding", "onboarding-practice", "onboarding-moves", "onboarding-permissions", "findMeter", "wallWalk", "wallWalk-endQuestion", "wallWalk-endPreview", "wallWalk-endQuestionLeavesOut", "wallWalk-nextWallRefused", "wallWalk-overheadQuestion", "gapRequest-walkOut", "gapRequest-overheadQuestion", "meterCloseUp-cantGetClearShot", "meterCloseUp-chooseNumber", "markFeatures", "gapRequest", "uploading-offline", "uploading-rejected", "result-review", "cameraDenied", "wallWalk-hidden", "wallWalk-seeBehind", "wallWalk-fullLegend", "gapRequest-followUp", "uploading-followUp", "markFeatures-groundQuestion", "markFeatures-groundAnswered", "markFeatures-lostPlace", @@ -105,6 +105,8 @@ final class ScreenStatesUITests: XCTestCase { "wallWalk-seeBehind": [("instruction", "Something is in front of the wall here")], // At AX5 looking past it leads, and the situation folds under Details. "wallWalk-seeBehind-AX5": [("instruction", "Look around it"), ("instruction.details", "Details")], + // At AX5 the meter's description folds under Details, and the camera stays open. + "findMeter-AX5": [("instruction", "Find your electric meter"), ("instruction.details", "Details")], "gapRequest-followUp": [("instruction", "One more view to finish")], // #75: a server request's stretch by its two ends, not its middle. "gapRequest-groundOut": [("instruction", "From 4 ft to 7 ft right of your meter.")], @@ -894,6 +896,76 @@ final class ScreenStatesUITests: XCTestCase { } } + /// Root's manual pass at b6ee153b: after "Allow camera" at AX5, the find-meter card filled the + /// camera with its description, hid the reticle at the middle of the screen, and pushed "This + /// is my meter" below the screen. Folded, the task stays, the description sits under Details, + /// the camera opens between the card and the button, and the button marks the meter. + @MainActor + func testFindMeterKeepsTheCameraOpenAtLargestTextSize() throws { + continueAfterFailure = false + let app = XCUIApplication() + app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoFreeze", "-uiDemoPhase", "findMeter"] + Self.largestText + app.launch() + defer { app.terminate() } + XCTAssertTrue(element(app, "screen.findMeter").waitForExistence(timeout: 15)) + assertFindMeterFolded(app, task: "Find your electric meter", description: "A gray box with a round glass dial", name: "findMeter-AX5-folded") + + let details = element(app, "instruction.details") + tap(app, "instruction.details") + let detail = element(app, "instruction.detail") + XCTAssertTrue(detail.waitForExistence(timeout: 5), "Details must open the meter's description") + XCTAssertTrue(detail.label.contains("A gray box with a round glass dial or a small screen, usually on an outside wall."), "detail reads \(detail.label)") + attach(app, name: "findMeter-AX5-details") + tap(app, "instruction.details") + XCTAssertTrue(detail.waitForNonExistence(timeout: 5), "Details must close again") + XCTAssertTrue(details.isHittable) + + tap(app, "action.markMeter") + XCTAssertTrue(element(app, "screen.meterCloseUp").waitForExistence(timeout: 10), "This is my meter must mark the meter") + } + + /// The practice scan's find-meter step folds the same way: "Tap a spot on a wall" stays, the + /// sample's explanation sits under Details, and "Put the sample meter here" is on screen. The + /// real engine on a replay, since only it starts a practice scan. + @MainActor + func testPracticeFindMeterKeepsTheCameraOpenAtLargestTextSize() throws { + continueAfterFailure = false + let app = XCUIApplication() + app.launchArguments = ["-replay", FullFlowUITests.fixture, "-sampleResult", "-practiceMeter", "YES"] + Self.largestText + app.launch() + defer { app.terminate() } + XCTAssertTrue(element(app, "screen.onboarding").waitForExistence(timeout: 30)) + if element(app, "action.onboardingSkip").waitForExistence(timeout: 5) { tap(app, "action.onboardingSkip") } + tap(app, "action.finishOnboarding", timeout: 15) + XCTAssertTrue(element(app, "screen.findMeter").waitForExistence(timeout: 15)) + XCTAssertEqual(element(app, "action.markMeter").label, "Put the sample meter here") + assertFindMeterFolded(app, task: "Tap a spot on a wall", description: "A sample meter goes there", name: "findMeter-practice-AX5-folded") + tap(app, "action.markMeter") + XCTAssertTrue(element(app, "screen.meterCloseUp").waitForExistence(timeout: 15), "Put the sample meter here must mark the meter") + } + + /// The folded find-meter card: the task in view, its description under Details, the button + /// whole on screen without scrolling, and open camera between them for the reticle (76 pt). + @MainActor + private func assertFindMeterFolded(_ app: XCUIApplication, task: String, description: String, name: String) { + let window = app.windows.firstMatch.frame + let card = element(app, "instruction") + let details = element(app, "instruction.details") + let mark = element(app, "action.markMeter") + XCTAssertTrue(card.waitForExistence(timeout: 5), "\(name): missing instruction") + XCTAssertTrue(details.waitForExistence(timeout: 5), "\(name): the description must fold under Details") + XCTAssertTrue(mark.waitForExistence(timeout: 5), "\(name): missing action.markMeter") + let label = ElementRead.snapshot(card)?.label ?? "" + XCTAssertTrue(label.contains(task), "\(name): the task must stay on the card, got \(label)") + XCTAssertFalse(label.contains(description), "\(name): the description must fold under Details, got \(label)") + let cardBottom = max(card.frame.maxY, details.frame.maxY) + let openCamera = CGRect(x: window.minX, y: cardBottom, width: window.width, height: mark.frame.minY - cardBottom) + attach(app, name: name) + XCTAssertTrue(details.isHittable, "\(name): Details must be reachable without scrolling") + XCTAssertTrue(window.contains(mark.frame) && mark.isHittable, "\(name): the mark button must be whole on screen without scrolling: \(mark.frame) in \(window)") + XCTAssertGreaterThanOrEqual(openCamera.height, 150, "\(name): the camera must stay open between the card and the button: \(openCamera)") + } + /// Coaching that rides along with the task says what to do now, so the folded card at AX5 /// keeps it in view with the task; only the how-to words fold. @MainActor From 97e6c05eb2a09aeb899db55af2ea6e5c3cacdc21 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Thu, 1 Oct 2026 09:00:08 -0500 Subject: [PATCH 30/48] ios: the folded find-meter aim is a tested pure rule A fresh review found the new UI tests checked folding and open camera but not where the reticle sits or where the button marks, so a centred reticle would still pass. HouseScanKit's OpenCameraAim now computes the point: nil unless the card folds and the open camera is measured and on screen, otherwise the middle of its part on screen as the chrome scrolls. Isolated tests pin each case. The reticle and the button read the one value, so they can't disagree. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../UI/Screens/FindMeterScreen.swift | 13 +++--- .../HouseScanKit/Overlay/OpenCameraAim.swift | 22 ++++++++++ .../OpenCameraAimTests.swift | 44 +++++++++++++++++++ 3 files changed, 72 insertions(+), 7 deletions(-) create mode 100644 ios/HouseScanKit/Sources/HouseScanKit/Overlay/OpenCameraAim.swift create mode 100644 ios/HouseScanKit/Tests/HouseScanKitTests/OpenCameraAimTests.swift diff --git a/ios/HouseScan/UI/Screens/FindMeterScreen.swift b/ios/HouseScan/UI/Screens/FindMeterScreen.swift index 4be163333..aa789fd66 100644 --- a/ios/HouseScan/UI/Screens/FindMeterScreen.swift +++ b/ios/HouseScan/UI/Screens/FindMeterScreen.swift @@ -1,3 +1,4 @@ +import HouseScanKit import SwiftUI /// "Find your electric meter": aim the reticle at the meter and tap the big button, or tap the @@ -67,14 +68,12 @@ struct FindMeterScreen: View { return !ScanCopy.coachingReplacesTask(coaching) } - /// Where the reticle is and the button marks, in the camera view's (global) coordinates, when - /// the card folds: the middle of the open camera's part on screen. Nil means the middle of - /// the screen, as at every other size. + /// Where the reticle is and the button marks (`OpenCameraAim`). Nil means the middle of the + /// screen, as at every size where the card doesn't fold. One value for both, so the button + /// always marks where the reticle is drawn. private var aimPoint: CGPoint? { - guard aims, typeSize.isAccessibilitySize, let window = cameraWindow.frame else { return nil } - let shown = window.intersection(CGRect(origin: .zero, size: cameraSize)) - guard !shown.isNull, shown.height > 0 else { return nil } - return CGPoint(x: shown.midX, y: shown.midY) + guard aims, typeSize.isAccessibilitySize else { return nil } + return OpenCameraAim.point(folds: true, window: cameraWindow.frame, camera: cameraSize) } private var markHint: String { diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Overlay/OpenCameraAim.swift b/ios/HouseScanKit/Sources/HouseScanKit/Overlay/OpenCameraAim.swift new file mode 100644 index 000000000..b511cc81f --- /dev/null +++ b/ios/HouseScanKit/Sources/HouseScanKit/Overlay/OpenCameraAim.swift @@ -0,0 +1,22 @@ +import CoreGraphics + +/// Where the find-meter reticle sits, and so where "This is my meter" marks, when the +/// instruction card folds at the largest text sizes. The middle of the screen is then under the +/// card, so the aim moves to the middle of the open camera between the card and the button. +public enum OpenCameraAim { + /// The aim point in the camera view's coordinates (the full screen, the window's global + /// space), or nil for the middle of the screen. Nil when the card doesn't fold, before the + /// chrome has measured its open camera, or when none of it is on screen. While the chrome + /// scrolls, the point follows the part of the open camera that is on screen. + /// - Parameters: + /// - folds: whether the card folds at this text size (`CameraChrome.aims` at an + /// accessibility size). + /// - window: the open camera between the card and the button, as the chrome measured it. + /// - camera: the camera view's size. + public static func point(folds: Bool, window: CGRect?, camera: CGSize) -> CGPoint? { + guard folds, let window else { return nil } + let shown = window.intersection(CGRect(origin: .zero, size: camera)) + guard !shown.isNull, shown.height > 0, shown.width > 0 else { return nil } + return CGPoint(x: shown.midX, y: shown.midY) + } +} diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/OpenCameraAimTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/OpenCameraAimTests.swift new file mode 100644 index 000000000..0e4f0f8ae --- /dev/null +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/OpenCameraAimTests.swift @@ -0,0 +1,44 @@ +import CoreGraphics +import HouseScanKit +import Testing + +/// The folded find-meter aim (`OpenCameraAim`): the middle of the open camera's part on screen, +/// or nil for the middle of the screen. +@Suite struct OpenCameraAimTests { + /// An iPhone 17's camera view, in points. + private static let camera = CGSize(width: 402, height: 874) + + @Test func anUnfoldedCardAimsAtTheMiddleOfTheScreen() { + let window = CGRect(x: 0, y: 420, width: 402, height: 220) + #expect(OpenCameraAim.point(folds: false, window: window, camera: Self.camera) == nil) + } + + @Test func noMeasuredWindowAimsAtTheMiddleOfTheScreen() { + #expect(OpenCameraAim.point(folds: true, window: nil, camera: Self.camera) == nil) + } + + @Test func aWindowWhollyOnScreenAimsAtItsMiddle() { + let window = CGRect(x: 16, y: 420, width: 370, height: 220) + #expect(OpenCameraAim.point(folds: true, window: window, camera: Self.camera) == CGPoint(x: 201, y: 530)) + } + + /// Scrolled down, the window's top leaves the screen and the aim follows what is left. + @Test func aWindowCutAtTheTopAimsAtItsPartOnScreen() { + let window = CGRect(x: 16, y: -100, width: 370, height: 300) + #expect(OpenCameraAim.point(folds: true, window: window, camera: Self.camera) == CGPoint(x: 201, y: 100)) + } + + @Test func aWindowCutAtTheBottomAimsAtItsPartOnScreen() { + let window = CGRect(x: 16, y: 774, width: 370, height: 300) + #expect(OpenCameraAim.point(folds: true, window: window, camera: Self.camera) == CGPoint(x: 201, y: 824)) + } + + @Test(arguments: [ + CGRect(x: 16, y: -400, width: 370, height: 300), + CGRect(x: 16, y: 874, width: 370, height: 200), + CGRect(x: 16, y: 500, width: 370, height: 0), + ]) + func noWindowOnScreenAimsAtTheMiddleOfTheScreen(window: CGRect) { + #expect(OpenCameraAim.point(folds: true, window: window, camera: Self.camera) == nil) + } +} From 0d4512b7827580fb532cf741521748ee6b47e173 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Thu, 1 Oct 2026 09:33:48 -0500 Subject: [PATCH 31/48] ios: the find-meter button drops its pin at the largest text sizes Run 36871032703: at AX5 the practice label "Put the sample meter here" wrapped to four lines beside the pin, and the button ended 12 pt below the screen. The walk's stacked actions already show their words alone at these sizes; this button does the same. Default sizes keep the pin. Co-Authored-By: Claude Opus 5.5 (1M context) --- ios/HouseScan/UI/Screens/FindMeterScreen.swift | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/ios/HouseScan/UI/Screens/FindMeterScreen.swift b/ios/HouseScan/UI/Screens/FindMeterScreen.swift index aa789fd66..d7dab2fd8 100644 --- a/ios/HouseScan/UI/Screens/FindMeterScreen.swift +++ b/ios/HouseScan/UI/Screens/FindMeterScreen.swift @@ -40,7 +40,15 @@ struct FindMeterScreen: View { ripple(at: aimPoint ?? CGPoint(x: cameraSize.width / 2, y: cameraSize.height / 2)) actions.markMeter(at: aimPoint, viewSize: cameraSize) } label: { - Label(state.isPracticeScan ? ScanCopy.practiceMarkMeter : "This is my meter", systemImage: "mappin.and.ellipse") + let label = Label(state.isPracticeScan ? ScanCopy.practiceMarkMeter : "This is my meter", systemImage: "mappin.and.ellipse") + // At the largest text sizes the pin took a fifth of the width and put "Put the + // sample meter here" on four lines, below the screen; the words say what the + // button does without it, as on the walk's stacked actions. + if typeSize.isAccessibilitySize { + label.labelStyle(.titleOnly) + } else { + label + } } .buttonStyle(.primary) .accessibilityHint(markHint) From 269526857b0c4a61a7028ab9ce6fee42a3902739 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Thu, 1 Oct 2026 14:46:08 -0500 Subject: [PATCH 32/48] ios: the find-meter aim accepts the chrome's zero-width spacer; the privacy scan skips string text Greptile (#191, P1): OpenCameraAim required a non-zero width, but the open camera the chrome measures is a Spacer that can be zero points wide, so at AX5 the rule returned nil and the reticle went back to the screen's middle under the card, as run 36877327250's frames show. Only the height is required now, and a test feeds the zero-width window the spacer reports. The button still marks where the reticle is drawn. Greptile (#191, P2): the privacy-manifest scan kept string text, so a message that names an API would count as calling it. Strings are now blanked except their interpolations, which are code. New fixtures cover a string that only names APIs and an interpolation that calls one. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../HouseScanKit/Overlay/OpenCameraAim.swift | 5 +- .../OpenCameraAimTests.swift | 7 ++ .../PrivacyManifestTests.swift | 78 +++++++++++++------ 3 files changed, 67 insertions(+), 23 deletions(-) diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Overlay/OpenCameraAim.swift b/ios/HouseScanKit/Sources/HouseScanKit/Overlay/OpenCameraAim.swift index b511cc81f..c2f5f447a 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Overlay/OpenCameraAim.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Overlay/OpenCameraAim.swift @@ -16,7 +16,10 @@ public enum OpenCameraAim { public static func point(folds: Bool, window: CGRect?, camera: CGSize) -> CGPoint? { guard folds, let window else { return nil } let shown = window.intersection(CGRect(origin: .zero, size: camera)) - guard !shown.isNull, shown.height > 0, shown.width > 0 else { return nil } + // Only the height matters. The chrome measures the open camera from a Spacer, which + // can be zero points wide; requiring a width put the reticle back under the card at AX5 + // (run 36877327250). + guard !shown.isNull, shown.height > 0 else { return nil } return CGPoint(x: shown.midX, y: shown.midY) } } diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/OpenCameraAimTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/OpenCameraAimTests.swift index 0e4f0f8ae..45cfb6a72 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/OpenCameraAimTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/OpenCameraAimTests.swift @@ -22,6 +22,13 @@ import Testing #expect(OpenCameraAim.point(folds: true, window: window, camera: Self.camera) == CGPoint(x: 201, y: 530)) } + /// What the chrome measures: its open camera is a Spacer, which can be zero points wide. + /// Rejecting that put the reticle back under the card at AX5 (run 36877327250). + @Test func theChromesZeroWidthSpacerAimsAtItsMiddle() { + let window = CGRect(x: 201, y: 420, width: 0, height: 220) + #expect(OpenCameraAim.point(folds: true, window: window, camera: Self.camera) == CGPoint(x: 201, y: 530)) + } + /// Scrolled down, the window's top leaves the screen and the aim follows what is left. @Test func aWindowCutAtTheTopAimsAtItsPartOnScreen() { let window = CGRect(x: 16, y: -100, width: 370, height: 300) diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/PrivacyManifestTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/PrivacyManifestTests.swift index bbffef94f..ba1fe3913 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/PrivacyManifestTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/PrivacyManifestTests.swift @@ -23,34 +23,64 @@ import Testing "NSPrivacyAccessedAPICategoryActiveKeyboards": #"\bactiveInputModes\b"#, ] - /// Swift source with its comments blanked. Comment markers inside string literals stay code, - /// so a URL in a string can't hide the rest of its line. Block comments may nest, as in Swift. + /// Swift source with its comments and string text blanked, so only code is searched: a + /// string or a comment that names an API doesn't count as calling it. An interpolation + /// inside a string (`"\\(UserDefaults.standard...)"`) is code and stays. Block comments may + /// nest, as in Swift. Multi-line and raw strings aren't parsed as such; their lines are read + /// as code, which can only add a category, never hide one. static func code(_ source: String) -> String { + enum Mode { case code(parens: Int, interpolation: Bool), string } var out = "" var chars = Array(source)[...] - var depth = 0 - var inString = false + var modes: [Mode] = [.code(parens: 0, interpolation: false)] + var commentDepth = 0 while let c = chars.first { let next = chars.dropFirst().first - if depth > 0 { - if c == "*", next == "/" { depth -= 1; chars = chars.dropFirst(2); continue } - if c == "/", next == "*" { depth += 1; chars = chars.dropFirst(2); continue } + if commentDepth > 0 { + if c == "*", next == "/" { commentDepth -= 1; chars = chars.dropFirst(2); continue } + if c == "/", next == "*" { commentDepth += 1; chars = chars.dropFirst(2); continue } if c == "\n" { out.append(c) } chars = chars.dropFirst() - } else if inString { - out.append(c) - if c == "\\", let escaped = next { out.append(escaped); chars = chars.dropFirst(2); continue } - if c == "\"" || c == "\n" { inString = false } - chars = chars.dropFirst() - } else if c == "/", next == "/" { - chars = chars.drop { $0 != "\n" } - } else if c == "/", next == "*" { - depth = 1 - chars = chars.dropFirst(2) - } else { - if c == "\"" { inString = true } - out.append(c) - chars = chars.dropFirst() + continue + } + switch modes[modes.count - 1] { + case .string: + if c == "\\", next == "(" { + modes.append(.code(parens: 1, interpolation: true)) + out.append("\\(") + chars = chars.dropFirst(2) + } else if c == "\\", next != nil { + out.append(" ") + chars = chars.dropFirst(2) + } else if c == "\"" || c == "\n" { + modes.removeLast() + out.append(c) + chars = chars.dropFirst() + } else { + out.append(" ") + chars = chars.dropFirst() + } + case .code(let parens, let interpolation): + if c == "/", next == "/" { + chars = chars.drop { $0 != "\n" } + } else if c == "/", next == "*" { + commentDepth = 1 + chars = chars.dropFirst(2) + } else { + if c == "\"" { + modes.append(.string) + } else if c == "(" { + modes[modes.count - 1] = .code(parens: parens + 1, interpolation: interpolation) + } else if c == ")", parens > 0 { + if interpolation, parens == 1 { + modes.removeLast() + } else { + modes[modes.count - 1] = .code(parens: parens - 1, interpolation: interpolation) + } + } + out.append(c) + chars = chars.dropFirst() + } } } return out @@ -127,6 +157,8 @@ import Testing ("let url = \"https://example.com\"; let up = ProcessInfo.processInfo.systemUptime", "NSPrivacyAccessedAPICategorySystemBootTime"), ("let s = \"/* not a comment\"; UserDefaults.standard.set(1, forKey: \"k\")", "NSPrivacyAccessedAPICategoryUserDefaults"), ("UITextInputMode.activeInputModes", "NSPrivacyAccessedAPICategoryActiveKeyboards"), + ("let on = \"\\(UserDefaults.standard.bool(forKey: \"k\"))\"", "NSPrivacyAccessedAPICategoryUserDefaults"), + ("log(\"saved \\(n) at \\(f(\")\"))\"); let t = ProcessInfo.processInfo.systemUptime", "NSPrivacyAccessedAPICategorySystemBootTime"), ]) func theScannerFindsAUse(source: String, category: String) { #expect(Self.categories(in: source) == [category]) @@ -137,8 +169,10 @@ import Testing "/* systemUptime\n /* nested */ mach_absolute_time */", "let status = statusText(code)", "let restated = 1", + "let note = \"UserDefaults and systemUptime are only named here\"", + "print(\"a quote \\\" then mach_absolute_time\")", ]) - func theScannerIgnoresCommentsAndLookalikes(source: String) { + func theScannerIgnoresCommentsStringsAndLookalikes(source: String) { #expect(Self.categories(in: source).isEmpty) } } From 4f587f9352360d39ee11b36aef0d53c6bd35b630 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Thu, 1 Oct 2026 15:02:13 -0500 Subject: [PATCH 33/48] ios: the privacy scan reads raw and multi-line strings by their own delimiters A fresh review of 26952685 found two false negatives: a raw string's interpolation (#"\#(...)"#) was blanked, and a quote inside a raw string ended it early, hiding the code after it. The scan now ends each string only at its own delimiter (one or three quotes plus the string's hashes) and keeps raw interpolations as code. Fixtures cover both misses, a multi-line string with code after its closing quotes, and text-only raw and multi-line strings. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../PrivacyManifestTests.swift | 93 +++++++++++++------ 1 file changed, 65 insertions(+), 28 deletions(-) diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/PrivacyManifestTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/PrivacyManifestTests.swift index ba1fe3913..527d9625d 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/PrivacyManifestTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/PrivacyManifestTests.swift @@ -24,52 +24,82 @@ import Testing ] /// Swift source with its comments and string text blanked, so only code is searched: a - /// string or a comment that names an API doesn't count as calling it. An interpolation - /// inside a string (`"\\(UserDefaults.standard...)"`) is code and stays. Block comments may - /// nest, as in Swift. Multi-line and raw strings aren't parsed as such; their lines are read - /// as code, which can only add a category, never hide one. + /// string or a comment that names an API doesn't count as calling it. Interpolations + /// (`\\(...)`, or `\\#(...)` in a raw string) are code and stay. Ordinary, multi-line + /// (`"""`) and raw (`#"..."#`) strings each end only at their own delimiter, so a quote + /// inside one can't hide the code after it. Block comments may nest, as in Swift. static func code(_ source: String) -> String { - enum Mode { case code(parens: Int, interpolation: Bool), string } + enum Mode { + case code(parens: Int, interpolation: Bool) + case string(hashes: Int, multiline: Bool) + } + let chars = Array(source) + func at(_ i: Int) -> Character? { i < chars.count ? chars[i] : nil } + func hashes(from i: Int) -> Int { + var n = 0 + while at(i + n) == "#" { n += 1 } + return n + } + /// Whether a string's closing delimiter starts at `i`: one quote, or three, then the + /// string's hashes. + func closes(at i: Int, hashes h: Int, multiline: Bool) -> Int? { + let quotes = multiline ? 3 : 1 + guard (0..= h else { return nil } + return quotes + h + } var out = "" - var chars = Array(source)[...] var modes: [Mode] = [.code(parens: 0, interpolation: false)] var commentDepth = 0 - while let c = chars.first { - let next = chars.dropFirst().first + var i = 0 + while let c = at(i) { + let next = at(i + 1) if commentDepth > 0 { - if c == "*", next == "/" { commentDepth -= 1; chars = chars.dropFirst(2); continue } - if c == "/", next == "*" { commentDepth += 1; chars = chars.dropFirst(2); continue } + if c == "*", next == "/" { commentDepth -= 1; i += 2; continue } + if c == "/", next == "*" { commentDepth += 1; i += 2; continue } if c == "\n" { out.append(c) } - chars = chars.dropFirst() + i += 1 continue } switch modes[modes.count - 1] { - case .string: - if c == "\\", next == "(" { - modes.append(.code(parens: 1, interpolation: true)) - out.append("\\(") - chars = chars.dropFirst(2) - } else if c == "\\", next != nil { - out.append(" ") - chars = chars.dropFirst(2) - } else if c == "\"" || c == "\n" { + case .string(let h, let multiline): + if let length = closes(at: i, hashes: h, multiline: multiline) { modes.removeLast() + out.append(contentsOf: chars[i..<(i + length)]) + i += length + } else if c == "\\", hashes(from: i + 1) >= h, at(i + 1 + h) == "(" { + modes.append(.code(parens: 1, interpolation: true)) + out.append(contentsOf: chars[i...(i + 1 + h)]) + i += 2 + h + } else if c == "\\", h == 0, next != nil { + // An escape in an ordinary string: the escaped character is text. + out.append(next == "\n" ? "\n" : " ") + out.append(" ") + i += 2 + } else if c == "\n" { + // A single-line string can't span lines; end it here rather than hide code. + if !multiline { modes.removeLast() } out.append(c) - chars = chars.dropFirst() + i += 1 } else { out.append(" ") - chars = chars.dropFirst() + i += 1 } case .code(let parens, let interpolation): + let h = c == "#" ? hashes(from: i) : 0 if c == "/", next == "/" { - chars = chars.drop { $0 != "\n" } + while let d = at(i), d != "\n" { i += 1 } } else if c == "/", next == "*" { commentDepth = 1 - chars = chars.dropFirst(2) + i += 2 + } else if c == "\"" || (h > 0 && at(i + h) == "\"") { + let quote = i + h + let multiline = at(quote + 1) == "\"" && at(quote + 2) == "\"" + let length = h + (multiline ? 3 : 1) + modes.append(.string(hashes: h, multiline: multiline)) + out.append(contentsOf: chars[i..<(i + length)]) + i += length } else { - if c == "\"" { - modes.append(.string) - } else if c == "(" { + if c == "(" { modes[modes.count - 1] = .code(parens: parens + 1, interpolation: interpolation) } else if c == ")", parens > 0 { if interpolation, parens == 1 { @@ -79,7 +109,7 @@ import Testing } } out.append(c) - chars = chars.dropFirst() + i += 1 } } } @@ -159,6 +189,11 @@ import Testing ("UITextInputMode.activeInputModes", "NSPrivacyAccessedAPICategoryActiveKeyboards"), ("let on = \"\\(UserDefaults.standard.bool(forKey: \"k\"))\"", "NSPrivacyAccessedAPICategoryUserDefaults"), ("log(\"saved \\(n) at \\(f(\")\"))\"); let t = ProcessInfo.processInfo.systemUptime", "NSPrivacyAccessedAPICategorySystemBootTime"), + // Raw strings: an interpolation is code, and a quote inside doesn't end the string. + ("let s = #\"\\#(ProcessInfo.processInfo.systemUptime)\"#", "NSPrivacyAccessedAPICategorySystemBootTime"), + ("let s = #\"a \" b\"#; let t = ProcessInfo.processInfo.systemUptime", "NSPrivacyAccessedAPICategorySystemBootTime"), + // A multi-line string ends at its own delimiter, with code after it on that line. + ("let s = \"\"\"\n \"quoted\" text\n \"\"\".count + Int(ProcessInfo.processInfo.systemUptime)", "NSPrivacyAccessedAPICategorySystemBootTime"), ]) func theScannerFindsAUse(source: String, category: String) { #expect(Self.categories(in: source) == [category]) @@ -171,6 +206,8 @@ import Testing "let restated = 1", "let note = \"UserDefaults and systemUptime are only named here\"", "print(\"a quote \\\" then mach_absolute_time\")", + "let s = #\"UserDefaults \\(not an interpolation in a raw string)\"#", + "let s = \"\"\"\n systemUptime \"in\" a multi-line string\n \"\"\"", ]) func theScannerIgnoresCommentsStringsAndLookalikes(source: String) { #expect(Self.categories(in: source).isEmpty) From a9695be80454ce444ddb6f7271a5133b84b95c6f Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Fri, 2 Oct 2026 04:51:53 -0500 Subject: [PATCH 34/48] ios: clear the live dots on a spatial reset (#195) A relocalization timeout (ScanEngine.swift:1500) and camera access after a failure (:477) call resetSpatialState, which restarts ARKit's world frame but kept the SurfaceDots field, so depth from the new frame fused into dots from the old one. resetAll already clears both (:2211-2212); the spatial reset now does the same. These are #195's three lines (010f3227), handed over by the legacy caretaker as c6184ff2 and accepted by root; nothing from #158 comes with them. Co-Authored-By: Claude Code --- ios/HouseScan/Runtime/ScanEngine.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ios/HouseScan/Runtime/ScanEngine.swift b/ios/HouseScan/Runtime/ScanEngine.swift index 9bd65efbe..8a10419f8 100644 --- a/ios/HouseScan/Runtime/ScanEngine.swift +++ b/ios/HouseScan/Runtime/ScanEngine.swift @@ -1578,6 +1578,9 @@ final class ScanEngine { // A fresh map: the old world frame is gone, so its anchors and planes are meaningless. live?.restart() coverage = nil + // The dot field lives in the old world frame too; kept, new depth would fuse into it. + liveDots.reset() + state.liveDots = .empty meterAnchorID.map { live?.removeAnchor($0) } meterAnchorID = nil meterTracking = nil From f9f59355299978cdc568796ce6938f7a2d4abd9b Mon Sep 17 00:00:00 2001 From: AidenJohnston Date: Sun, 27 Sep 2026 05:49:52 -0500 Subject: [PATCH 35/48] ios: aiming builds no walking-speed spell, and the dark note says what helps Moving fast while aiming now drops the walking-speed spell instead of keeping it hidden, so the next walking step can't open with "Slow down" on a calm frame. The ride-along dark coaching keeps its advice (flashlight, daylight), since it can stay up for a whole night walk. Co-Authored-By: Claude Opus 5.5 (1M context) (cherry picked from commit 040d9a61cd8c6fd43784c3d7575c00fd991cf182) Picked into the #191 candidate on root's decision after a fresh Sol review. #104's a097c669 (blur reference) is left out: it can freeze a pre-blur reference across a lower-texture scene and seed a stale median from expired samples. Blur coaching stays parked. Co-Authored-By: Claude Code --- ios/HouseScan/UI/Copy/ScanCopy.swift | 13 ++++++++++--- .../HouseScanKit/Guidance/CoachingDebouncer.swift | 8 ++++++-- .../HouseScanKitTests/CoachingDebouncerTests.swift | 13 +++++++++++++ ios/HouseScanUITests/ScreenStatesUITests.swift | 2 ++ 4 files changed, 31 insertions(+), 5 deletions(-) diff --git a/ios/HouseScan/UI/Copy/ScanCopy.swift b/ios/HouseScan/UI/Copy/ScanCopy.swift index f5460028a..5c407f974 100644 --- a/ios/HouseScan/UI/Copy/ScanCopy.swift +++ b/ios/HouseScan/UI/Copy/ScanCopy.swift @@ -186,10 +186,17 @@ enum ScanCopy { /// One short line for coaching that rides along with the task instead of replacing it (the /// walk's capture-gate coaching): the task's title and second line stay, and this goes under - /// them. Only the coaching's title, so the task's own words stay the bigger part of the card. + /// them. Mostly only the coaching's title, so the task's own words stay the bigger part of the + /// card. The dark coaching keeps what to do about it: it can stay up for a whole night walk, and + /// "It's dark here" alone doesn't say what would help. static func coachingNote(_ coaching: Coaching) -> String { - let title = ScanCopy.coaching(coaching).title - return title.hasSuffix(".") ? title : "\(title)." + switch coaching { + case .tooDark: return "It's dark here. Try your phone's flashlight, or come back in daylight." + case .tooDarkToMeasure: return "It's too dark to measure here. Try in daylight." + default: + let title = ScanCopy.coaching(coaching).title + return title.hasSuffix(".") ? title : "\(title)." + } } /// Tracking problems and standing past the end replace the task on the card: nothing the task diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Guidance/CoachingDebouncer.swift b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/CoachingDebouncer.swift index 5bc135fd4..eac8462ac 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Guidance/CoachingDebouncer.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/CoachingDebouncer.swift @@ -17,8 +17,9 @@ import Foundation /// - Moving fast, turning fast and blur each show after their own spell of frames that have /// them, and clear a moment after the last such frame. A spell that ends before it shows says /// nothing. -/// - Walking too fast is not raised while the homeowner is asked to stand and aim (`aiming`): -/// the motion there is turning the phone, and "walk slower" is the wrong advice. +/// - Walking too fast is not raised, or counted toward a spell, while the homeowner is asked to +/// stand and aim (`aiming`): the motion there is turning the phone, and "walk slower" is the +/// wrong advice. /// /// Every threshold is a guess to try on a phone at night, not measured. public struct CoachingDebouncer: Sendable { @@ -118,6 +119,9 @@ public struct CoachingDebouncer: Sendable { lastTime = time updateDarkness(time: time, meanLuma: meanLuma) updateMotion(time: time, skip: skip) + // Walking speed isn't coached while aiming, so a spell there mustn't build up either: it + // would say "Slow down" on the first frames of the next walking step, however calm. + if aiming { spells[.movingFast] = nil } if darkSince != nil { return persistentDark == .showing ? .persistentlyDark : .tooDark } if isShown(.turningFast, at: time) { return .turningFast } if !aiming, isShown(.movingFast, at: time) { return .movingFast } diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/CoachingDebouncerTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/CoachingDebouncerTests.swift index 24189fc0f..d383f01dd 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/CoachingDebouncerTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/CoachingDebouncerTests.swift @@ -103,6 +103,19 @@ import Testing #expect(Self.run(walking).last! == .movingFast) } + /// Moving fast while aiming builds no spell, so the next walking step starts clean: a calm + /// frame says nothing, and walking fast again needs its own 0.7 s before "Slow down". + @Test func movingFastWhileAimingDoesntCarryIntoTheNextStep() { + var debouncer = CoachingDebouncer() + let aiming = Self.times(for: 2).map { Frame(time: $0, skip: .movingFast, aiming: true) } + #expect(Self.run(aiming, on: &debouncer).allSatisfy { $0 == nil }) + #expect(debouncer.update(time: 2, skip: .redundant, meanLuma: 128, aiming: false) == nil) + let walking = Self.times(from: 2.1, for: 1).map { Frame(time: $0, skip: .movingFast) } + let said = Self.run(walking, on: &debouncer) + #expect(said.prefix(7).allSatisfy { $0 == nil }) + #expect(said.last! == .movingFast) + } + /// A gap request to walk the stretch far enough out asks for walking, so hurrying along it is /// still told to slow down. The other requests ask the homeowner to stand and aim. @Test func aWalkOutRequestStillSaysSlowDown() { diff --git a/ios/HouseScanUITests/ScreenStatesUITests.swift b/ios/HouseScanUITests/ScreenStatesUITests.swift index cb152649c..878ec5a03 100644 --- a/ios/HouseScanUITests/ScreenStatesUITests.swift +++ b/ios/HouseScanUITests/ScreenStatesUITests.swift @@ -476,6 +476,7 @@ final class ScreenStatesUITests: XCTestCase { XCTAssertTrue(label.contains("Walk slowly to your right"), "the task must stay on the card, got \(label)") XCTAssertTrue(label.contains("Keep the wall and the ground in view"), "the task's second line must stay on the card, got \(label)") XCTAssertTrue(label.contains("It's dark here"), "the coaching must show on the card, got \(label)") + XCTAssertTrue(label.contains("flashlight"), "the dark coaching must say what would help, got \(label)") } /// #80, as on the walk: the capture gate's coaching keeps a gap request on the card. The dark @@ -493,6 +494,7 @@ final class ScreenStatesUITests: XCTestCase { XCTAssertTrue(label.contains("Show the ground"), "the request must stay on the card, got \(label)") XCTAssertTrue(label.contains("a clear look from two places"), "the request's second line must stay on the card, got \(label)") XCTAssertTrue(label.contains("It's dark here"), "the coaching must show on the card, got \(label)") + XCTAssertTrue(label.contains("flashlight"), "the dark coaching must say what would help, got \(label)") } /// B-09: "Add something" on the review opens the camera with the marking prompt, and the From 68a509e701f79b904b3bfb99a848a30e07787e2e Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Fri, 2 Oct 2026 05:24:50 -0500 Subject: [PATCH 36/48] ios: an answer that settles the walk's task keeps what stalled earlier (#56) Answering a wall end, "Can't get there", an overhead question, following a corner or a refused finish all call resetGuidanceAfterSkip, which called GuidancePlanner.reset(). That cleared the deferred stretches along with the current task, so a request that had just stalled (say the ground by the meter, with the camera still near it) came straight back with a fresh 20 s clock instead of the walk going on. The planner now has two operations: settleCurrentTask() drops only the current task and is what those actions call; reset() still clears the deferred stretches too, and stays for the start of the walk, a new scan (resetAll) and a spatial reset (resetSpatialState), whose new world frame makes old stretches meaningless. The two walk entries come from the close-up, before anything can be deferred. settlingATaskKeepsWhatStalledButANewScanForgetsIt stalls the ground by the meter, marks the left end blocked at s = -0.6 and puts the same planner through both operations: settled, the ground isn't asked again through 60 s; reset as for a new scan, it is asked at once. The existing full-reset test is unchanged; cantGetThereOnTheGroundByTheMeterMovesOn now calls the engine's actual operation. Focused run: GuidancePlannerTests, 25 tests passed (job app-pkg-planner-56). Co-Authored-By: Claude Code --- ios/HouseScan/Runtime/ScanEngine.swift | 4 +- .../Guidance/GuidancePlanner.swift | 17 ++++++-- .../GuidancePlannerTests.swift | 39 +++++++++++++++++-- 3 files changed, 50 insertions(+), 10 deletions(-) diff --git a/ios/HouseScan/Runtime/ScanEngine.swift b/ios/HouseScan/Runtime/ScanEngine.swift index 8a10419f8..2d14adca3 100644 --- a/ios/HouseScan/Runtime/ScanEngine.swift +++ b/ios/HouseScan/Runtime/ScanEngine.swift @@ -1209,9 +1209,9 @@ final class ScanEngine { } /// "I can't get there" or an answered end question settled the current task: choose the next - /// one now. + /// one now. Stretches that stalled earlier stay deferred (`GuidancePlanner.settleCurrentTask`). func resetGuidanceAfterSkip(camera: CameraFrame, time: Double) { - planner.reset() + planner.settleCurrentTask() updateGuidance(camera: camera, time: time) } diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GuidancePlanner.swift b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GuidancePlanner.swift index 756a09391..3437d5458 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GuidancePlanner.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GuidancePlanner.swift @@ -103,8 +103,8 @@ public struct GuidancePlanner: Sendable { private var lastFraction: Double = 0 private var lastProgressAt: Double = 0 /// Aim stretches that stalled. The requests chosen from the camera (the ground by the meter - /// and a lagging band) leave them out until `reset()`; `firstHole` still asks for them once - /// both ends are marked. + /// and a lagging band) leave them out for the rest of the scan, until `reset()`; `firstHole` + /// still asks for them once both ends are marked. private var deferred: [DeferredStretch] = [] private struct DeferredStretch: Sendable { @@ -116,8 +116,17 @@ public struct GuidancePlanner: Sendable { self.config = config } - /// Forgets the current task and the deferred stretches. The engine calls it when an action - /// settled the task ("Can't get there", an answered end question). + /// Drops the current task, so the next update chooses afresh, and keeps the stretches already + /// deferred. The engine calls it when an action settled the task ("Can't get there", an + /// answered end or overhead question, a followed corner, a refused finish). Clearing the + /// deferred stretches there too asked again, with a fresh stall clock, for a request that had + /// just stalled, instead of going on with the walk (#56). + public mutating func settleCurrentTask() { + current = nil + } + + /// Forgets the current task and the deferred stretches. For a new scan, and for a spatial + /// reset, whose new world frame makes the old stretches meaningless. public mutating func reset() { current = nil deferred = [] diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/GuidancePlannerTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/GuidancePlannerTests.swift index 63e1deb99..bfb4da719 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/GuidancePlannerTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/GuidancePlannerTests.swift @@ -66,16 +66,17 @@ import Testing #expect(next.switched == .satisfied) } - /// "Can't get there" on it marks the ground skipped, as the engine does, and resets the planner - /// (`ScanEngine.resetGuidanceAfterSkip`): the walk moves on and doesn't ask for it again. + /// "Can't get there" on it marks the ground skipped, as the engine does, and settles the + /// planner's task (`ScanEngine.resetGuidanceAfterSkip`): the walk moves on and doesn't ask for + /// it again. @Test func cantGetThereOnTheGroundByTheMeterMovesOn() { var map = CoverageMap(wall: standardWall()) var planner = GuidancePlanner() #expect(planner.update(coverage: map, camera: Self.homeowner(), time: 0).task == .aimAtGround(s: 0)) map.markSkipped(.ground, -0.5...0.5) - // Skipped is not covered: without the reset the request holds for its dwell. + // Skipped is not covered: without settling the request holds for its dwell. #expect(planner.update(coverage: map, camera: Self.homeowner(), time: 0.5).task == .aimAtGround(s: 0)) - planner.reset() + planner.settleCurrentTask() #expect(planner.update(coverage: map, camera: Self.homeowner(), time: 0.6).task != .aimAtGround(s: 0)) // Away from the meter nothing lags, and the walk goes left. #expect(planner.update(coverage: map, camera: Self.homeowner(x: -3), time: 10).task == .walk(.left)) @@ -420,6 +421,36 @@ import Testing #expect(planner.update(coverage: map, camera: Self.homeowner(), time: 41).task == .aimAtGround(s: 0)) } + /// #56: answering something else doesn't bring back a request that stalled. The ground by the + /// meter stalls and is deferred (as in the test above); the camera stays near the meter, and + /// the left end is marked at s = -0.6 and answered blocked while the right stays open. The + /// engine then settles the planner's task (`ScanEngine.resetGuidanceAfterSkip`): the deferred + /// ground stays deferred, rather than coming back with a fresh 20 s clock. The same planner + /// reset for a new scan (or a spatial reset) forgets it and asks again. + @Test func settlingATaskKeepsWhatStalledButANewScanForgetsIt() { + var map = CoverageMap(wall: standardWall()) + var planner = GuidancePlanner() + #expect(planner.update(coverage: map, camera: Self.homeowner(), time: 0).task == .aimAtGround(s: 0)) + map.observe(groundCamera(s: -0.45), trackingNormal: true) + map.observe(groundCamera(s: -0.15), trackingNormal: true) + #expect(planner.update(coverage: map, camera: Self.homeowner(out: 1), time: 8).task == .aimAtGround(s: 0)) + let stalled = planner.update(coverage: map, camera: Self.homeowner(), time: 28) + #expect(stalled.stalled == .aimAtGround(s: 0)) + #expect(stalled.task != .aimAtGround(s: 0)) + + map.setEnd(.left, at: -0.6) + map.setEndIsLimit(.left, true) + var newScan = planner + + planner.settleCurrentTask() + for time in [28.5, 29.0, 40.0, 60.0] { + #expect(planner.update(coverage: map, camera: Self.homeowner(), time: time).task != .aimAtGround(s: 0), "settled at \(time) s") + } + + newScan.reset() + #expect(newScan.update(coverage: map, camera: Self.homeowner(), time: 28.5).task == .aimAtGround(s: 0)) + } + /// Review of #120: a lagging-band request that stalls in the middle of the window is not asked /// for again at once. Walking right (the left end marked) with the phone at s = 2, the window /// is [1.7, 3.0], cells 11 ... 19, and the ground lags the (skipped) wall in all of them: the From d837c4e47067bd123b0f614d18f679aa15e9516b Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Fri, 2 Oct 2026 05:28:43 -0500 Subject: [PATCH 37/48] ios: a request stays available whenever progress still calls its evidence missing (#49) GapPlan.asksForSameView suppressed a request already asked, skipped or earlier in the same answer when its span and reach were within 0.1 ft of the other's, measured on the Float-meter span. Progress measures the server's exact span_ft and counts any uncovered edge of 0.01 ft or more as missing, so a request for 2...8.09 ft after 2...8 ft disappeared though its last 0.09 ft could never be met; after a skip the result also marked it not capturable (ScanEngine+Export.swift:198). Same-view now decides as progress does: the same band and kind of need; a reach no higher than the other's, compared exactly in requested feet (requestedOutFt, or the need's meters converted when unset); and no span edge running past the other's by the amount progress counts as missing. GapPlanner.countsAsMissing is that one comparison, now shared by GapPlanner.fraction and the same-view rule, and GapPlan.requestedSpanInFeet is the one span both read (requestedSpanFt, or the span at the export's four decimals when unset). Equal and contained requests are still the same view, an overhead request without a height still asks no more than one with, and the request limit and "Show my result" are unchanged. The guessed 0.1 ft sameViewTolerance is gone. Tests: an 8-case sweep moves one edge outward by 0.009, 0.010, 0.011 and 0.09 ft on each side, after asked, after skipped and within one answer, with the server's exact span_ft. In doubles 8.01 - 8.0 is just under 0.01 and 2.0 - 1.99 just over it, so 0.010 is rounding on the high edge and missing on the low edge, for progress and this rule alike; each case is also checked against GapPlanner.fraction over the old span. A reach test covers stricter, equal and lower reaches and requests built in meters. Two older expectations encoded the 0.1 ft guess and are changed: the refused view that came back at 4.9 ft after 4.833334 ft, and a "jitter" item 0.05 ft wider with a farther reach, both now ask for evidence progress requires. Focused run: ServerGapTests and its suite, 27 tests passed (job app-pkg-servergap-49). Co-Authored-By: Claude Code --- .../HouseScanKit/Guidance/GapPlanner.swift | 90 ++++++++++++------- .../HouseScanKitTests/ServerGapTests.swift | 70 +++++++++++++-- 2 files changed, 122 insertions(+), 38 deletions(-) diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GapPlanner.swift b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GapPlanner.swift index 66df57883..d3d014426 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GapPlanner.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GapPlanner.swift @@ -175,9 +175,7 @@ public struct GapPlanner: Sendable { case .overhead(let height): spans = reaching(Self.exported(coverage.overheadSpans(), "overhead", coverage), height) case .wallUp(let height): spans = reaching(Self.exported(coverage.wallSeenSpans(), "wall", coverage), height) } - let feet = { (meters: Float) in SceneExport.round4(Double(meters) * SceneUnits.feetPerMeter) } - let requested = gap.requestedSpanFt ?? feet(gap.span.lowerBound)...feet(gap.span.upperBound) - return Self.fraction(of: requested, coveredBy: spans) + return Self.fraction(of: gap.requestedSpanInFeet, coveredBy: spans) } /// A server request is met only over its whole span: the server settles it only when observed @@ -283,20 +281,28 @@ public struct GapPlanner: Sendable { let high = requested.upperBound guard high > low else { return 0 } let eps = 1e-9 - let tolerance = 0.01 var cursor = low var missing = 0.0 let written = spans.compactMap { SceneExport.spanInward($0) }.map { ($0[0], $0[1]) } for (a, b) in written.sorted(by: { $0 < $1 }) { if b <= cursor + eps { continue } if a >= high - eps { break } - if a > cursor + eps, min(a, high) - cursor >= tolerance { missing += min(a, high) - cursor } + if a > cursor + eps, countsAsMissing(from: cursor, to: min(a, high)) { missing += min(a, high) - cursor } cursor = max(cursor, b) } - if cursor < high - eps, high - cursor >= tolerance { missing += high - cursor } + if cursor < high - eps, countsAsMissing(from: cursor, to: high) { missing += high - cursor } return max(0, 1 - missing / (high - low)) } + /// Whether an unseen stretch from `start` to `end`, feet, counts as missing rather than as + /// rounding: at least the server's COVERAGE_TOLERANCE_FT, 0.01 ft (server/scene.py `missing` + /// at t3/server 930e8e5). Progress (`fraction`) and the same-view rule + /// (`GapPlan.asksForSameView`) both decide by this one comparison, so a request stays + /// available exactly when it asks for evidence progress would still call missing. + static func countsAsMissing(from start: Double, to end: Double) -> Bool { + end - start >= 0.01 + } + private func distanceToMeter(_ run: ClosedRange) -> Float { run.contains(0) ? 0 : min(abs(run.lowerBound), abs(run.upperBound)) } @@ -419,40 +425,60 @@ extension GapPlanner { } extension GapPlan { - /// How far a request's span or reach may move and still ask for the same view: 0.1 ft - /// (0.03 m), meters. Numerical jitter from the server working the request out again is a few - /// hundredths of a foot; a guess, not measured. - public static let sameViewTolerance: Float = 0.03048 - - /// Whether this request asks for no view beyond `other`'s, allowing for the server working it - /// out again from the next upload: the same band and kind of need, a reach no more than - /// `sameViewTolerance` past the other's, and a span inside the other's give or take the same - /// tolerance. After "I can't get there" the skipped stretch goes to review and the spot or its - /// error margins can move, so the same view comes back as, say, 2.41...7.9 ft instead of - /// 2.4...7.9 ft. A request that asks for more is a new view, even when it mostly overlaps: a - /// span of 2...9 ft after 2...8 ft, or ground out to 5.10 ft after 4.83 ft (issue #39). A met - /// past_end request moves its end on 2 m, so the next one asks for new ground and is a + /// The span progress measures, feet: `requestedSpanFt` as the server sent it, or for a request + /// built in meters, `span` at the export's four decimals. + public var requestedSpanInFeet: ClosedRange { + if let requestedSpanFt { return requestedSpanFt } + let feet = { (meters: Float) in SceneExport.round4(Double(meters) * SceneUnits.feetPerMeter) } + return feet(span.lowerBound)...feet(span.upperBound) + } + + /// The reach progress requires, feet: `requestedOutFt` as the server sent it, or the need's + /// meters converted, as `GapPlanner.progress` compares it. Nil for a need with no reach: cells, + /// or an overhead request that any recorded view meets. + var requestedReachInFeet: Double? { + let meters: Float? + switch need { + case .cells: return nil + case .groundOut(let out), .walkOut(let out), .wallUp(let out): meters = out + case .overhead(let height): meters = height + } + guard let meters else { return nil } + return requestedOutFt ?? Double(meters) * SceneUnits.feetPerMeter + } + + /// Whether this request asks for no evidence beyond what `other` asks for, measured as + /// progress measures it: the same band and kind of need, a reach no higher than the other's + /// (exactly, as progress compares a reach), and no edge of the span running past the other's + /// far enough that progress would call the difference missing + /// (`GapPlanner.countsAsMissing`, 0.01 ft). An equal or contained request is the same view: + /// after "I can't get there" the skipped stretch goes to review, and the next answer can + /// come back as 2.41...7.9 ft after 2.4...7.9 ft. One that asks for more is a new view, even + /// when it mostly overlaps: 2...8.09 ft after 2...8 ft, or ground out to 4.833335 ft after + /// 4.833334 ft (issue #39, #49). The 0.1 ft allowance this replaced hid requests that + /// progress still called missing, and after a skip the result marked them not capturable. + /// A met past_end request moves its end on 2 m, so the next one asks for new ground and is a /// different view. public func asksForSameView(as other: GapPlan) -> Bool { - let tolerance = Self.sameViewTolerance - guard band == other.band, need.asksNoMore(than: other.need, within: tolerance) else { return false } - return span.lowerBound >= other.span.lowerBound - tolerance && span.upperBound <= other.span.upperBound + tolerance + guard band == other.band, need.isSameKind(as: other.need) else { return false } + switch (requestedReachInFeet, other.requestedReachInFeet) { + case (nil, _): break + case (_?, nil): return false + case let (mine?, theirs?): guard mine <= theirs else { return false } + } + let mine = requestedSpanInFeet + let theirs = other.requestedSpanInFeet + return !GapPlanner.countsAsMissing(from: mine.lowerBound, to: theirs.lowerBound) + && !GapPlanner.countsAsMissing(from: theirs.upperBound, to: mine.upperBound) } } extension GapPlan.Need { - /// The same kind of need, with any reach no more than `tolerance` meters past the other's. An - /// overhead need without a height (any recorded view) asks no more than one with a height. - func asksNoMore(than other: GapPlan.Need, within tolerance: Float) -> Bool { + /// The same kind of need, whatever its reach. + func isSameKind(as other: GapPlan.Need) -> Bool { switch (self, other) { - case (.cells, .cells): + case (.cells, .cells), (.groundOut, .groundOut), (.walkOut, .walkOut), (.wallUp, .wallUp), (.overhead, .overhead): return true - case let (.groundOut(a), .groundOut(b)), let (.walkOut(a), .walkOut(b)), let (.wallUp(a), .wallUp(b)): - return a <= b + tolerance - case let (.overhead(a), .overhead(b)): - guard let a else { return true } - guard let b else { return false } - return a <= b + tolerance default: return false } diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/ServerGapTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/ServerGapTests.swift index 0c7143c31..4b6b9590d 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/ServerGapTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/ServerGapTests.swift @@ -277,13 +277,14 @@ import Testing } /// The next answer works a refused view out again from the new scene, so it can come back with - /// its span or reach moved by a few hundredths of a foot. It is still the view the homeowner - /// said they can't get to, and isn't raised again; another stretch of the band is a new view. + /// its span moved inside the old one. That asks for nothing new and isn't raised again; + /// another stretch of the band is a new view. (#49: the reach that came back here at 4.9 ft + /// after 4.833334 ft is now a new view, since progress requires it.) @Test func aRefusedViewWithSlightlyDifferentNumbersIsNotRaisedAgain() throws { let planner = GapPlanner() let refused = try item(#"{"kind":"band","band":"ground","span_ft":[2.4,7.9],"out_ft":4.833334,"message":"m"}"#) let plan = try #require(planner.plan(for: refused, leftEnd: -3, rightEnd: 4)) - let moved = try item(#"{"kind":"band","band":"ground","span_ft":[2.41,7.9],"out_ft":4.9,"message":"m"}"#) + let moved = try item(#"{"kind":"band","band":"ground","span_ft":[2.41,7.9],"out_ft":4.833334,"message":"m"}"#) #expect(planner.nextServerRequest(in: [moved], leftEnd: -3, rightEnd: 4, limitEnds: [], asked: [plan], skipped: [plan])?.item == nil) let elsewhere = try item(#"{"kind":"band","band":"ground","span_ft":[-7.9,-2.4],"out_ft":4.833334,"message":"m"}"#) #expect(planner.nextServerRequest( @@ -314,14 +315,15 @@ import Testing /// A request that asks for more than one already raised is a new view, even when it mostly /// overlaps: a longer stretch (2 to 9 ft after 2 to 8 ft, 6/7 of it already asked for) or - /// ground farther out (5.10 ft after 4.83 ft, 0.08 m more). Only jitter within 0.1 ft is the - /// same view. This holds for earlier requests, skipped ones and items in the same answer. + /// ground farther out (5.10 ft after 4.83 ft, 0.08 m more). An edge moved by less than the + /// 0.01 ft progress reads as rounding, with the same reach, is the same view. This holds for + /// earlier requests, skipped ones and items in the same answer. @Test func aRequestAskingForMoreIsANewView() throws { let planner = GapPlanner() let first = try item(#"{"kind":"band","band":"ground","span_ft":[2.0,8.0],"out_ft":4.83,"message":"m"}"#) let longer = try item(#"{"kind":"band","band":"ground","span_ft":[2.0,9.0],"out_ft":4.83,"message":"m"}"#) let farther = try item(#"{"kind":"band","band":"ground","span_ft":[2.0,8.0],"out_ft":5.10,"message":"m"}"#) - let jitter = try item(#"{"kind":"band","band":"ground","span_ft":[1.95,8.05],"out_ft":4.88,"message":"m"}"#) + let jitter = try item(#"{"kind":"band","band":"ground","span_ft":[1.995,8.005],"out_ft":4.83,"message":"m"}"#) let asked = try #require(planner.plan(for: first, leftEnd: -3, rightEnd: 4)) // After the first was raised, or skipped. @@ -342,6 +344,62 @@ import Testing in: [farther, first], leftEnd: -3, rightEnd: 4, limitEnds: [], asked: [], skipped: [], limit: 5).map { $0.item } == [farther]) } + /// #49: a request stays available exactly when it asks for evidence progress still calls + /// missing. One edge of the server's exact span_ft runs outward past an asked or skipped + /// request [2.0, 8.0] (or the item before it in the same answer) by 0.009, 0.010, 0.011 or + /// 0.09 ft. The boundaries are doubles, as in progress and on the server: 8.01 - 8.0 is + /// 0.0099999999999998, under the 0.01 ft counted as missing, while 2.0 - 1.99 is + /// 0.0100000000000000, at it. So 0.010 reads as rounding on the high edge and as missing on + /// the low edge, for progress and for this rule alike. Each case is checked against its + /// explicit expectation and against `GapPlanner.fraction` measuring the new request over + /// exactly the old one's span. + @Test(arguments: [ + ("[2.0,8.009]", false), ("[2.0,8.01]", false), ("[2.0,8.011]", true), ("[2.0,8.09]", true), + ("[1.991,8.0]", false), ("[1.99,8.0]", true), ("[1.989,8.0]", true), ("[1.91,8.0]", true), + ]) + func aRequestStaysAvailableExactlyWhenProgressStillCallsItMissing(spanFt: String, newView: Bool) throws { + let planner = GapPlanner() + let old = try item(#"{"kind":"band","band":"ground","span_ft":[2.0,8.0],"out_ft":4.833334,"message":"m"}"#) + let oldPlan = try #require(planner.plan(for: old, leftEnd: -3, rightEnd: 4)) + let extended = try item(#"{"kind":"band","band":"ground","span_ft":\#(spanFt),"out_ft":4.833334,"message":"m"}"#) + let plan = try #require(planner.plan(for: extended, leftEnd: -3, rightEnd: 4)) + let span = try #require(extended.spanFt) + #expect(plan.requestedSpanFt == min(span.x, span.y)...max(span.x, span.y), "the request keeps the server's exact span_ft") + // Progress over exactly the old request's span: below 1 means it still calls part missing. + let feetToMeters = 1 / SceneUnits.feetPerMeter + let oldSpan = Float(2.0 * feetToMeters)...Float(8.0 * feetToMeters) + let progressCallsMissing = GapPlanner.fraction(of: plan.requestedSpanInFeet, coveredBy: [oldSpan]) < 1 + #expect(progressCallsMissing == newView, "progress for \(spanFt)") + #expect(plan.asksForSameView(as: oldPlan) == !newView, "same view for \(spanFt)") + let afterAsked = planner.serverRequests(in: [extended], leftEnd: -3, rightEnd: 4, limitEnds: [], asked: [oldPlan], skipped: [], limit: 5) + let afterSkipped = planner.serverRequests(in: [extended], leftEnd: -3, rightEnd: 4, limitEnds: [], asked: [oldPlan], skipped: [oldPlan], limit: 5) + let oneAnswer = planner.serverRequests(in: [old, extended], leftEnd: -3, rightEnd: 4, limitEnds: [], asked: [], skipped: [], limit: 5) + #expect(afterAsked.map { $0.item } == (newView ? [extended] : []), "after asked for \(spanFt)") + #expect(afterSkipped.map { $0.item } == (newView ? [extended] : []), "after skipped for \(spanFt)") + #expect(oneAnswer.map { $0.item } == (newView ? [old, extended] : [old]), "one answer for \(spanFt)") + // The limit and the homeowner's stop are unchanged: no room left raises nothing. + #expect(planner.serverRequests(in: [extended], leftEnd: -3, rightEnd: 4, limitEnds: [], asked: [oldPlan], skipped: [], limit: 0).isEmpty) + } + + /// #49: a reach is compared exactly, as progress compares it. Any reach above the old one is + /// new evidence; an equal or lower one isn't. Requests built in meters (no requestedOutFt or + /// requestedSpanFt) compare their meters converted, as progress reads them. + @Test func aStricterReachIsANewViewAndAnEqualOneIsNot() throws { + let planner = GapPlanner() + let old = try #require(planner.plan(for: item(#"{"kind":"band","band":"ground","span_ft":[2.0,8.0],"out_ft":4.833334,"message":"m"}"#), leftEnd: -3, rightEnd: 4)) + let stricter = try #require(planner.plan(for: item(#"{"kind":"band","band":"ground","span_ft":[2.0,8.0],"out_ft":4.833335,"message":"m"}"#), leftEnd: -3, rightEnd: 4)) + let equal = try #require(planner.plan(for: item(#"{"kind":"band","band":"ground","span_ft":[2.0,8.0],"out_ft":4.833334,"message":"m"}"#), leftEnd: -3, rightEnd: 4)) + let lower = try #require(planner.plan(for: item(#"{"kind":"band","band":"ground","span_ft":[2.0,8.0],"out_ft":4.8,"message":"m"}"#), leftEnd: -3, rightEnd: 4)) + #expect(!stricter.asksForSameView(as: old)) + #expect(equal.asksForSameView(as: old)) + #expect(lower.asksForSameView(as: old)) + let inMeters = GapPlan(band: .ground, span: 0.6...2.4, reason: .server, need: .groundOut(1.5)) + #expect(inMeters.requestedSpanInFeet == SceneExport.round4(Double(Float(0.6)) * SceneUnits.feetPerMeter)...SceneExport.round4(Double(Float(2.4)) * SceneUnits.feetPerMeter)) + #expect(inMeters.asksForSameView(as: inMeters)) + #expect(!GapPlan(band: .ground, span: 0.6...2.4, reason: .server, need: .groundOut(1.51)).asksForSameView(as: inMeters)) + #expect(!GapPlan(band: .ground, span: 0.6...2.4, reason: .server, need: .walkOut(1.5)).asksForSameView(as: inMeters), "another kind of need") + } + /// An overhead request without a height takes any tilt-up view, so it asks for no more than /// one with a height; one with a height asks for more than one without. @Test func anOverheadRequestWithAHeightAsksForMoreThanOneWithout() throws { From bd6b933120ac3967235f70883b6f028c53a5fe21 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Fri, 2 Oct 2026 06:14:07 -0500 Subject: [PATCH 38/48] ios: same-view compares requested spans by exact containment; tolerance stays in progress (#49) Root's fresh review of d837c4e4 found the 0.01 ft allowance could stack. Coverage seen to exactly [2, 8] ft meets a request for [2, 8.0085] ft, because progress reads its 0.0085 ft tail as rounding. The next request, [2, 8.0175] ft, ran only 0.009 ft past that one, so the same-view rule dropped it, though against the coverage 0.0175 ft is missing and progress is about 0.997. asksForSameView now requires the span to lie inside the other's, compared exactly in requested feet, as the reach already was. Rounding tolerance is applied only by progress, against the coverage actually seen (GapPlanner.countsAsMissing, now used there alone). The server lists only what its own reading of the uploaded scene still lacks, so an exactly contained request asks for nothing new. Equal and contained requests stay the same view; the limit and "Show my result" are unchanged. Tests: aRoundingAllowanceUsedOnceIsNotUsedAgain is root's exact case (old request met with progress 1, next request progress 1 - 0.0175/6.0175, raised after asked and after skipped). The 8-case sweep now expects every outward edge (0.009, 0.010, 0.011, 0.09 ft, each side) to be a new view after asked, after skipped and in one answer; anEqualOrContainedRequestIsTheSameView covers equal spans, edges moved inward by 0.009 ft and a contained span. Focused run: ServerGapTests and its suite, 29 tests passed (job app-pkg-servergap-49b). Co-Authored-By: Claude Code --- .../HouseScanKit/Guidance/GapPlanner.swift | 21 ++--- .../HouseScanKitTests/ServerGapTests.swift | 77 ++++++++++++------- 2 files changed, 61 insertions(+), 37 deletions(-) diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GapPlanner.swift b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GapPlanner.swift index d3d014426..c90e98838 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GapPlanner.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GapPlanner.swift @@ -296,9 +296,8 @@ public struct GapPlanner: Sendable { /// Whether an unseen stretch from `start` to `end`, feet, counts as missing rather than as /// rounding: at least the server's COVERAGE_TOLERANCE_FT, 0.01 ft (server/scene.py `missing` - /// at t3/server 930e8e5). Progress (`fraction`) and the same-view rule - /// (`GapPlan.asksForSameView`) both decide by this one comparison, so a request stays - /// available exactly when it asks for evidence progress would still call missing. + /// at t3/server 930e8e5). Only progress applies it, against the coverage actually seen; the + /// same-view rule (`GapPlan.asksForSameView`) compares requests exactly. static func countsAsMissing(from start: Double, to end: Double) -> Bool { end - start >= 0.01 } @@ -447,11 +446,14 @@ extension GapPlan { return requestedOutFt ?? Double(meters) * SceneUnits.feetPerMeter } - /// Whether this request asks for no evidence beyond what `other` asks for, measured as - /// progress measures it: the same band and kind of need, a reach no higher than the other's - /// (exactly, as progress compares a reach), and no edge of the span running past the other's - /// far enough that progress would call the difference missing - /// (`GapPlanner.countsAsMissing`, 0.01 ft). An equal or contained request is the same view: + /// Whether this request asks for no evidence beyond what `other` asks for: the same band and + /// kind of need, a reach no higher than the other's, and a span inside the other's, all + /// compared exactly in the requested feet progress measures. Rounding tolerance belongs to + /// progress alone, against the coverage actually seen: allowing it here too stacked a second + /// allowance on one already used, so 2...8.0175 ft after 2...8.0085 ft (met by coverage to + /// 8.0 ft) was dropped though progress still found 0.0175 ft missing. The server lists only + /// what its own reading of the uploaded scene still lacks, so an exactly contained request + /// asks for nothing new. An equal or contained request is the same view: /// after "I can't get there" the skipped stretch goes to review, and the next answer can /// come back as 2.41...7.9 ft after 2.4...7.9 ft. One that asks for more is a new view, even /// when it mostly overlaps: 2...8.09 ft after 2...8 ft, or ground out to 4.833335 ft after @@ -468,8 +470,7 @@ extension GapPlan { } let mine = requestedSpanInFeet let theirs = other.requestedSpanInFeet - return !GapPlanner.countsAsMissing(from: mine.lowerBound, to: theirs.lowerBound) - && !GapPlanner.countsAsMissing(from: theirs.upperBound, to: mine.upperBound) + return mine.lowerBound >= theirs.lowerBound && mine.upperBound <= theirs.upperBound } } diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/ServerGapTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/ServerGapTests.swift index 4b6b9590d..d479ca93b 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/ServerGapTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/ServerGapTests.swift @@ -315,15 +315,15 @@ import Testing /// A request that asks for more than one already raised is a new view, even when it mostly /// overlaps: a longer stretch (2 to 9 ft after 2 to 8 ft, 6/7 of it already asked for) or - /// ground farther out (5.10 ft after 4.83 ft, 0.08 m more). An edge moved by less than the - /// 0.01 ft progress reads as rounding, with the same reach, is the same view. This holds for - /// earlier requests, skipped ones and items in the same answer. + /// ground farther out (5.10 ft after 4.83 ft, 0.08 m more). A span inside the earlier one, with + /// the same reach, is the same view. This holds for earlier requests, skipped ones and items + /// in the same answer. @Test func aRequestAskingForMoreIsANewView() throws { let planner = GapPlanner() let first = try item(#"{"kind":"band","band":"ground","span_ft":[2.0,8.0],"out_ft":4.83,"message":"m"}"#) let longer = try item(#"{"kind":"band","band":"ground","span_ft":[2.0,9.0],"out_ft":4.83,"message":"m"}"#) let farther = try item(#"{"kind":"band","band":"ground","span_ft":[2.0,8.0],"out_ft":5.10,"message":"m"}"#) - let jitter = try item(#"{"kind":"band","band":"ground","span_ft":[1.995,8.005],"out_ft":4.83,"message":"m"}"#) + let jitter = try item(#"{"kind":"band","band":"ground","span_ft":[2.005,7.995],"out_ft":4.83,"message":"m"}"#) let asked = try #require(planner.plan(for: first, leftEnd: -3, rightEnd: 4)) // After the first was raised, or skipped. @@ -344,20 +344,12 @@ import Testing in: [farther, first], leftEnd: -3, rightEnd: 4, limitEnds: [], asked: [], skipped: [], limit: 5).map { $0.item } == [farther]) } - /// #49: a request stays available exactly when it asks for evidence progress still calls - /// missing. One edge of the server's exact span_ft runs outward past an asked or skipped - /// request [2.0, 8.0] (or the item before it in the same answer) by 0.009, 0.010, 0.011 or - /// 0.09 ft. The boundaries are doubles, as in progress and on the server: 8.01 - 8.0 is - /// 0.0099999999999998, under the 0.01 ft counted as missing, while 2.0 - 1.99 is - /// 0.0100000000000000, at it. So 0.010 reads as rounding on the high edge and as missing on - /// the low edge, for progress and for this rule alike. Each case is checked against its - /// explicit expectation and against `GapPlanner.fraction` measuring the new request over - /// exactly the old one's span. - @Test(arguments: [ - ("[2.0,8.009]", false), ("[2.0,8.01]", false), ("[2.0,8.011]", true), ("[2.0,8.09]", true), - ("[1.991,8.0]", false), ("[1.99,8.0]", true), ("[1.989,8.0]", true), ("[1.91,8.0]", true), - ]) - func aRequestStaysAvailableExactlyWhenProgressStillCallsItMissing(spanFt: String, newView: Bool) throws { + /// #49: a request whose span runs past an asked or skipped one (or the item before it in the + /// same answer) by any amount is a new view: 0.009, 0.010, 0.011 or 0.09 ft past either edge, + /// with the server's exact span_ft. Spans are compared exactly; rounding tolerance is for + /// progress against real coverage, not for comparing two requests. + @Test(arguments: ["[2.0,8.009]", "[2.0,8.01]", "[2.0,8.011]", "[2.0,8.09]", "[1.991,8.0]", "[1.99,8.0]", "[1.989,8.0]", "[1.91,8.0]"]) + func aRequestRunningPastAnEarlierOneIsANewView(spanFt: String) throws { let planner = GapPlanner() let old = try item(#"{"kind":"band","band":"ground","span_ft":[2.0,8.0],"out_ft":4.833334,"message":"m"}"#) let oldPlan = try #require(planner.plan(for: old, leftEnd: -3, rightEnd: 4)) @@ -365,22 +357,53 @@ import Testing let plan = try #require(planner.plan(for: extended, leftEnd: -3, rightEnd: 4)) let span = try #require(extended.spanFt) #expect(plan.requestedSpanFt == min(span.x, span.y)...max(span.x, span.y), "the request keeps the server's exact span_ft") - // Progress over exactly the old request's span: below 1 means it still calls part missing. - let feetToMeters = 1 / SceneUnits.feetPerMeter - let oldSpan = Float(2.0 * feetToMeters)...Float(8.0 * feetToMeters) - let progressCallsMissing = GapPlanner.fraction(of: plan.requestedSpanInFeet, coveredBy: [oldSpan]) < 1 - #expect(progressCallsMissing == newView, "progress for \(spanFt)") - #expect(plan.asksForSameView(as: oldPlan) == !newView, "same view for \(spanFt)") + #expect(!plan.asksForSameView(as: oldPlan), "\(spanFt)") let afterAsked = planner.serverRequests(in: [extended], leftEnd: -3, rightEnd: 4, limitEnds: [], asked: [oldPlan], skipped: [], limit: 5) let afterSkipped = planner.serverRequests(in: [extended], leftEnd: -3, rightEnd: 4, limitEnds: [], asked: [oldPlan], skipped: [oldPlan], limit: 5) let oneAnswer = planner.serverRequests(in: [old, extended], leftEnd: -3, rightEnd: 4, limitEnds: [], asked: [], skipped: [], limit: 5) - #expect(afterAsked.map { $0.item } == (newView ? [extended] : []), "after asked for \(spanFt)") - #expect(afterSkipped.map { $0.item } == (newView ? [extended] : []), "after skipped for \(spanFt)") - #expect(oneAnswer.map { $0.item } == (newView ? [old, extended] : [old]), "one answer for \(spanFt)") + #expect(afterAsked.map { $0.item } == [extended], "after asked for \(spanFt)") + #expect(afterSkipped.map { $0.item } == [extended], "after skipped for \(spanFt)") + #expect(oneAnswer.map { $0.item } == [old, extended], "one answer for \(spanFt)") // The limit and the homeowner's stop are unchanged: no room left raises nothing. #expect(planner.serverRequests(in: [extended], leftEnd: -3, rightEnd: 4, limitEnds: [], asked: [oldPlan], skipped: [], limit: 0).isEmpty) } + /// Root's review of d837c4e4: the rounding allowance must not stack. Coverage seen to exactly + /// [2, 8] ft meets [2, 8.0085] ft, since progress reads its 0.0085 ft tail as rounding. The + /// next request, [2, 8.0175] ft, runs only 0.009 ft past that one, but 0.0175 ft past the + /// coverage: progress is about 0.997, so it stays available. + @Test func aRoundingAllowanceUsedOnceIsNotUsedAgain() throws { + let planner = GapPlanner() + let old = try item(#"{"kind":"band","band":"ground","span_ft":[2.0,8.0085],"out_ft":4.833334,"message":"m"}"#) + let next = try item(#"{"kind":"band","band":"ground","span_ft":[2.0,8.0175],"out_ft":4.833334,"message":"m"}"#) + let oldPlan = try #require(planner.plan(for: old, leftEnd: -3, rightEnd: 4)) + let nextPlan = try #require(planner.plan(for: next, leftEnd: -3, rightEnd: 4)) + let feetToMeters = 1 / SceneUnits.feetPerMeter + let seen = Float(2.0 * feetToMeters)...Float(8.0 * feetToMeters) + #expect(GapPlanner.fraction(of: oldPlan.requestedSpanInFeet, coveredBy: [seen]) == 1) + let progress = GapPlanner.fraction(of: nextPlan.requestedSpanInFeet, coveredBy: [seen]) + #expect(abs(progress - (1 - 0.0175 / 6.0175)) < 1e-9, "progress \(progress)") + #expect(!nextPlan.asksForSameView(as: oldPlan)) + for skipped in [[GapPlan](), [oldPlan]] { + #expect(planner.serverRequests(in: [next], leftEnd: -3, rightEnd: 4, limitEnds: [], asked: [oldPlan], skipped: skipped, limit: 5).map { $0.item } == [next]) + } + } + + /// Equal and contained requests stay the same view, after asked, after skipped and within one + /// answer, including an edge moved inward by 0.009 ft. + @Test func anEqualOrContainedRequestIsTheSameView() throws { + let planner = GapPlanner() + let old = try item(#"{"kind":"band","band":"ground","span_ft":[2.0,8.0],"out_ft":4.833334,"message":"m"}"#) + let oldPlan = try #require(planner.plan(for: old, leftEnd: -3, rightEnd: 4)) + for json in ["[2.0,8.0]", "[2.009,8.0]", "[2.0,7.991]", "[3.0,7.0]"] { + let inside = try item(#"{"kind":"band","band":"ground","span_ft":\#(json),"out_ft":4.833334,"message":"m"}"#) + let plan = try #require(planner.plan(for: inside, leftEnd: -3, rightEnd: 4)) + #expect(plan.asksForSameView(as: oldPlan), "\(json)") + #expect(planner.serverRequests(in: [inside], leftEnd: -3, rightEnd: 4, limitEnds: [], asked: [oldPlan], skipped: [oldPlan], limit: 5).isEmpty, "\(json)") + #expect(planner.serverRequests(in: [old, inside], leftEnd: -3, rightEnd: 4, limitEnds: [], asked: [], skipped: [], limit: 5).map { $0.item } == [old], "\(json)") + } + } + /// #49: a reach is compared exactly, as progress compares it. Any reach above the old one is /// new evidence; an equal or lower one isn't. Requests built in meters (no requestedOutFt or /// requestedSpanFt) compare their meters converted, as progress reads them. From 737d9211f556fc50338abe1339760901e7e122ea Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Fri, 2 Oct 2026 08:16:13 -0500 Subject: [PATCH 39/48] ios: an upload sends one captured scene and packet, measured against its own wall (#191) The upload measured the LiDAR mesh against the wall as it was before the measurement's await, then built scene.json and the capture packet from the engine's live wall after it. A ground refinement or an anchor correction during the measurement put clearances measured against the old wall into a scene and packet describing the new one (CodeRabbit on bd6b9331, ScanEngine.swift:2034). The upload now captures the scene (SceneInput, without mesh fields), the wall in world meters, the mesh and the packet's inputs in one main-actor turn (captureUpload) and marks the scene packaged there, before the await. The new UploadPackaging.package measures the captured mesh against the captured world wall and span, checks once more that the upload is current (same scan generation, not cancelled), and only then serializes the captured scene with those measurements and attaches the same bytes to the captured packet. saveBundle takes those inputs and rereads nothing. A ground change during the measurement now falls under the existing packaged-upload rule: the first withdraws and sends again, a second fails, and an anchor correction keeps the captured upload. A superseded upload serializes nothing, writes no bundle and submits nothing. After "It's clear" the bundle is written again from the upload's captured packet, with only the guidance log reread and placed in that packet's wall and meter frame, instead of a fresh mesh and the current wall around the old scene. The recorder's streams (trajectory, depth, motion) are still read when the packet is written; this guarantees one wall and frame across the scene and the packet's geometry, not a byte-for-byte archive of one instant. Co-Authored-By: Claude Code --- .../Runtime/ScanEngine+Confirm.swift | 12 +- ios/HouseScan/Runtime/ScanEngine+Export.swift | 44 ++- ios/HouseScan/Runtime/ScanEngine+Packet.swift | 21 +- ios/HouseScan/Runtime/ScanEngine.swift | 49 ++- .../HouseScanKit/Scene/UploadPackaging.swift | 94 +++++ .../UploadPackagingTests.swift | 360 ++++++++++++++++++ 6 files changed, 536 insertions(+), 44 deletions(-) create mode 100644 ios/HouseScanKit/Sources/HouseScanKit/Scene/UploadPackaging.swift create mode 100644 ios/HouseScanKit/Tests/HouseScanKitTests/UploadPackagingTests.swift diff --git a/ios/HouseScan/Runtime/ScanEngine+Confirm.swift b/ios/HouseScan/Runtime/ScanEngine+Confirm.swift index 38045a056..14761faa6 100644 --- a/ios/HouseScan/Runtime/ScanEngine+Confirm.swift +++ b/ios/HouseScan/Runtime/ScanEngine+Confirm.swift @@ -11,6 +11,9 @@ struct SpotConfirmState { /// The last upload's scene.json, and the sha256 of it and of the answer to it: what a check /// asked now is bound to, and the scene the bundle is written again with after "It's clear". var lastScene: Data? + /// The capture packet's inputs from that same upload, with `lastScene` attached: the packet + /// written again after "It's clear" (`answerSpotCheck`). + var lastPacket: ScanEngine.PacketInputs? var sceneSHA256: String? var answerSHA256: String? /// The check on screen: its area, the photo shown and the exchange it came from. @@ -50,8 +53,9 @@ extension ScanEngine { /// Records the upload's scene and the answer to it, which a check asked about this answer /// is bound to. Call it once the answer has decoded. - func noteExchange(scene: Data, answer: Data) { + func noteExchange(scene: Data, answer: Data, packet: PacketInputs?) { spotConfirm.lastScene = scene + spotConfirm.lastPacket = packet spotConfirm.sceneSHA256 = PacketFiles.sha256(scene) spotConfirm.answerSHA256 = PacketFiles.sha256(answer) } @@ -125,8 +129,10 @@ extension ScanEngine { return } // The bundle was written at the upload, before this answer: write it again so its - // guidance log holds the answer. - if let scene = spotConfirm.lastScene { saveBundle(scene: scene, mesh: liveCapture?.meshSnapshot()) } + // guidance log holds the answer. The upload's captured packet is reused, with only + // the guidance log read again and placed in that packet's frame, so the packet keeps + // the submitted scene's geometry, mesh and photos rather than mixing in a later wall. + if let packet = spotConfirm.lastPacket { saveBundle(withCurrentGuidance(packet)) } go(.result) } } diff --git a/ios/HouseScan/Runtime/ScanEngine+Export.swift b/ios/HouseScan/Runtime/ScanEngine+Export.swift index 4a7d0b397..2047ffb97 100644 --- a/ios/HouseScan/Runtime/ScanEngine+Export.swift +++ b/ios/HouseScan/Runtime/ScanEngine+Export.swift @@ -4,13 +4,19 @@ import OSLog import simd extension ScanEngine { - /// scene.json for the current scan (contract C1), with the LiDAR mesh's measurements on - /// phones that have one. + /// scene.json for the current scan (contract C1), without mesh measurements: the autopilot's + /// gate copy. The upload captures the scene once and measures the mesh against that capture + /// (`captureUpload`, `UploadPackaging`). + func sceneJSON() throws -> Data { + try SceneExport.jsonData(sceneInput()) + } + + /// The scan's scene as it stands now, without mesh measurements, read in this one turn. /// /// The scene frame is the AR world frame moved down so the ground at the wall is y = 0: the /// server reads heights (meter.pos y, pose translations) as height above that ground, and /// scene.json has no field for the ground's height otherwise. - func sceneJSON(mesh: MeshMeasurements = MeshMeasurements()) throws -> Data { + func sceneInput() throws -> SceneInput { guard let map = coverage else { throw ExportError.noWall } let wall = map.wall let drop = SIMD3(0, wall.groundY, 0) @@ -74,13 +80,23 @@ extension ScanEngine { ), keyframes: keyframes, stills: store.stills, - // Written without plus_minus_ft: the server takes its mesh error for both. - meshFacing: mesh.facing, - meshOverheads: mesh.overheads, + // The mesh's measurements are filled in once measured (`UploadPackaging`). Written + // without plus_minus_ft: the server takes its mesh error for both. // Unanswered exports like "Not sure": no patch, and the server reports the surface unknown. groundType: state.groundAnswer.flatMap(Self.sceneGroundType) ) - return try SceneExport.jsonData(input) + return input + } + + /// Everything one upload sends, read in one main-actor turn: the scene, the wall in world + /// meters, the LiDAR mesh and the capture packet's inputs. Nothing after this rereads the + /// engine's geometry for this upload, so a ground or anchor correction while the mesh is + /// measured can't mix two walls into its scene or packet. + func captureUpload() throws -> UploadPackaging { + let scene = try sceneInput() + guard let map = coverage else { throw ExportError.noWall } + let meshSnapshot = liveCapture?.meshSnapshot() + return UploadPackaging(scene: scene, worldWall: map.wall, mesh: meshSnapshot?.mesh, packet: packetInputs(mesh: meshSnapshot)) } static func sceneGroundType(_ answer: GroundAnswer) -> SceneGroundType? { @@ -132,20 +148,6 @@ extension ScanEngine { return low...high } - /// What the LiDAR mesh measured over the exported stretch: the gap from the wall out to - /// whatever faces it, and the clear height under anything overhead, each per stretch of s in - /// meters. Empty without a mesh. - struct MeshMeasurements: Sendable { - var facing: [ObservedSpan] = [] - var overheads: [ObservedSpan] = [] - } - - /// Measures a world-space mesh (meters) against the wall (world meters) over `span`, the - /// exported stretch of s. - nonisolated static func measure(_ mesh: TriangleMesh, wall: WallFrame, over span: ClosedRange) -> MeshMeasurements { - MeshMeasurements(facing: mesh.facingSpans(wall: wall, over: span), overheads: mesh.overheadSpans(wall: wall, over: span)) - } - /// Error of the chest-height ground guess (camera height minus 1.4 m), meters. Phones held /// for scanning sit roughly 1.1 to 1.7 m up, so ±0.3 m. A hypothesis; no measured spread exists. static let estimatedGroundError: Float = 0.3 diff --git a/ios/HouseScan/Runtime/ScanEngine+Packet.swift b/ios/HouseScan/Runtime/ScanEngine+Packet.swift index 382423027..1e515631b 100644 --- a/ios/HouseScan/Runtime/ScanEngine+Packet.swift +++ b/ios/HouseScan/Runtime/ScanEngine+Packet.swift @@ -32,6 +32,10 @@ extension ScanEngine { /// `photoIDs` hold store ids ("meter_close"); `writePacket` swaps in packet ids. var marks: [PacketMark] var guidance: [PacketGuidanceEntry] + /// The wall the marks and guidance were placed with, in world meters, captured with them, + /// so a later write that adds guidance places it in the same frame (`withCurrentGuidance`). + var sceneWall: SceneWall + /// The scene.json the upload sent, attached once serialized (`UploadPackaging`). var scene: Data /// The camera's frame rate live; nil on a replay, whose rate comes from its frames. var trajectoryRate: Double? @@ -49,8 +53,9 @@ extension ScanEngine { var frames: [ReplayFrame] } - /// Nil before there is a wall: the meter frame is built from it. - func packetInputs(scene: Data, mesh: LiveCapture.MeshSnapshot?) -> PacketInputs? { + /// Nil before there is a wall: the meter frame is built from it. The scene is attached by the + /// upload once it is serialized from the same capture (`captureUpload`). + func packetInputs(mesh: LiveCapture.MeshSnapshot?) -> PacketInputs? { guard let map = coverage else { return nil } let wall = map.wall // The wall in world meters, as scene.json's wall type describes it, for the marks. @@ -93,12 +98,22 @@ extension ScanEngine { }, marks: packetMarks(map, wall: sceneWall, frame: frame), guidance: guidanceLog.entries.map { Self.packetEntry($0, wall: sceneWall, frame: frame) }, - scene: scene, + sceneWall: sceneWall, + scene: Data(), trajectoryRate: settings.map { Double($0.framesPerSecond) }, motionStreams: motionRunsLive ? motionAvailable : [] ) } + /// `inputs` with the guidance log as it is now, placed with the wall and meter frame the + /// inputs were captured with: what the spot check's answer adds to an upload's packet, without + /// rereading the engine's geometry since. + func withCurrentGuidance(_ inputs: PacketInputs) -> PacketInputs { + var inputs = inputs + inputs.guidance = guidanceLog.entries.map { Self.packetEntry($0, wall: inputs.sceneWall, frame: inputs.meterFrame) } + return inputs + } + /// `utsname.machine`, such as "iPhone16,1": the hardware, never the phone's name. "arm64" in /// the Simulator. nonisolated static func hardwareModel() -> String { diff --git a/ios/HouseScan/Runtime/ScanEngine.swift b/ios/HouseScan/Runtime/ScanEngine.swift index 2d14adca3..388f6e516 100644 --- a/ios/HouseScan/Runtime/ScanEngine.swift +++ b/ios/HouseScan/Runtime/ScanEngine.swift @@ -2017,29 +2017,43 @@ final class ScanEngine { try? await Task.sleep(for: .milliseconds(50)) } guard scan == generation, !Task.isCancelled else { return } - // LiDAR phones: the mesh ARKit built, measured for what faces the wall and what is - // overhead, off the main actor since ray casts over a whole mesh take a while. - let meshSnapshot = live?.meshSnapshot() - var measured = MeshMeasurements() - if let mesh = meshSnapshot?.mesh, let map = coverage { - let wall = map.wall - let span = Self.exportSpan(map) - measured = await Task.detached(priority: .userInitiated) { Self.measure(mesh, wall: wall, over: span) }.value - guard scan == generation, !Task.isCancelled else { return } - RuntimeLog.engine.info("mesh: \(mesh.vertices.count) vertices, \(mesh.indices.count / 3) triangles; \(measured.facing.count) facing and \(measured.overheads.count) overhead measurements") + // The scene, the wall in world meters, the LiDAR mesh and the packet's inputs, read in + // this one turn (`captureUpload`). From here the upload's ground is fixed: a ground change + // while the mesh is measured withdraws this upload and sends again, once, and an anchor + // correction leaves it as captured (`GroundFreshness`, `answerAfter`). + let capture: UploadPackaging + do { + capture = try captureUpload() + } catch { + RuntimeLog.engine.error("scene.json export failed: \(String(describing: error), privacy: .public)") + state.upload = UploadFailure.packaging(error) + updateRecording() + return } - let scene: Data + scenePackaged = true + // LiDAR phones: the mesh measured against the captured wall for what faces it and what is + // overhead, off the main actor, then the captured scene serialized with those measurements + // and the same bytes given to the captured packet. A withdrawn, cancelled or reset upload + // comes back with nothing, so it schedules no bundle and no submit. + let packaged: UploadPackaging.Packaged do { - scene = try sceneJSON(mesh: measured) - scenePackaged = true + guard let done = try await capture.package( + attachScene: { $0.scene = $1 }, + isCurrent: { scan == generation && !Task.isCancelled } + ) else { return } + packaged = done } catch { RuntimeLog.engine.error("scene.json export failed: \(String(describing: error), privacy: .public)") state.upload = UploadFailure.packaging(error) updateRecording() return } + if let mesh = capture.mesh { + RuntimeLog.engine.info("mesh: \(mesh.vertices.count) vertices, \(mesh.indices.count / 3) triangles; \(packaged.measurement.facing.count) facing and \(packaged.measurement.overheads.count) overhead measurements") + } + let scene = packaged.scene writeScanStamp(answer: placement) - saveBundle(scene: scene, mesh: meshSnapshot) + saveBundle(packaged.packet) guard !Task.isCancelled else { return } state.upload = .uploading(fraction: 0) analyzingSince = nil @@ -2066,7 +2080,7 @@ final class ScanEngine { guard scan == generation, state.phase == .uploading, !Task.isCancelled else { return } } placement = result - noteExchange(scene: scene, answer: data) + noteExchange(scene: scene, answer: data, packet: packaged.packet) writeScanStamp(answer: result) state.result = presentation(of: result, isSample: resultClient.isSample) state.followUps = automaticGapQueue(result).count @@ -2167,13 +2181,14 @@ final class ScanEngine { /// packet, and the upload never waits for it or fails because of it. The zip is rewritten in /// place, so it is not offered while a write is under way, and writes run one after another: /// a retry's write waits for the last one, and a write already superseded is skipped. - func saveBundle(scene: Data, mesh: LiveCapture.MeshSnapshot?) { + /// `inputs` were captured with the upload's scene (`captureUpload`); this rereads nothing. + func saveBundle(_ inputs: PacketInputs?) { state.shareableScan = nil bundleSerial += 1 let serial = bundleSerial let scan = generation let previous = bundleTask - guard let inputs = packetInputs(scene: scene, mesh: mesh) else { + guard let inputs else { RuntimeLog.engine.error("scan bundle not written: no wall") return } diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Scene/UploadPackaging.swift b/ios/HouseScanKit/Sources/HouseScanKit/Scene/UploadPackaging.swift new file mode 100644 index 000000000..c7f75c25f --- /dev/null +++ b/ios/HouseScanKit/Sources/HouseScanKit/Scene/UploadPackaging.swift @@ -0,0 +1,94 @@ +import Foundation + +/// What an upload sends, built from one captured basis. The engine captures the scene (without +/// its mesh measurements), the wall in world meters, the LiDAR mesh and the packet's inputs in one +/// main-actor turn; the mesh is then measured against that captured wall and span while the main +/// actor is free, and the scene is serialized from the capture and handed, as the same bytes, to +/// the captured packet. +/// +/// Before this, the mesh was measured against the wall as it was before the wait, and the scene +/// and packet were built from the engine's live wall after it. A ground refinement or anchor +/// correction landing during the measurement put clearances measured against the old wall into a +/// scene describing the new one (#191 review of bd6b9331). Now a change during the measurement +/// can't reach this upload's scene or packet; the engine's ground-freshness rule decides whether +/// the upload stands (`GroundFreshness`), and a superseded upload serializes nothing. +public struct UploadPackaging: Sendable { + /// The scene to send, without its mesh measurements, which `package` fills in. + public var scene: SceneInput + /// The wall in world meters, as captured. The mesh is in world meters too, so it is measured + /// against this, not `scene.wall`, whose ground has been moved to height zero. + public var worldWall: WallFrame + /// The LiDAR mesh ARKit had built at the capture; nil without LiDAR or before any mesh. + public var mesh: TriangleMesh? + /// The capture packet's inputs, read in the same turn; nil when there is none to write. Its + /// scene is attached by `package`. + public var packet: Packet? + + public init(scene: SceneInput, worldWall: WallFrame, mesh: TriangleMesh?, packet: Packet?) { + self.scene = scene + self.worldWall = worldWall + self.mesh = mesh + self.packet = packet + } + + /// The stretch the mesh is measured over: the scene's own exported stretch. + public var span: ClosedRange { scene.baselineS } + + /// The mesh's facing gaps and headroom along the wall (`TriangleMesh.facingSpans`, + /// `overheadSpans`). + public struct MeshMeasurement: Sendable, Equatable { + public var facing: [ObservedSpan] + public var overheads: [ObservedSpan] + + public init(facing: [ObservedSpan] = [], overheads: [ObservedSpan] = []) { + self.facing = facing + self.overheads = overheads + } + } + + /// Measures `mesh` against a wall over a span. Injected so a test can hold the measurement + /// at the point where the engine's main actor is free. + public typealias Measure = @Sendable (_ mesh: TriangleMesh, _ wall: WallFrame, _ span: ClosedRange) async -> MeshMeasurement + + /// The real measurement, off the caller's actor: ray casts over a whole mesh take a while. + public static var measureOffActor: Measure { + { mesh, wall, span in + await Task.detached(priority: .userInitiated) { + MeshMeasurement(facing: mesh.facingSpans(wall: wall, over: span), overheads: mesh.overheadSpans(wall: wall, over: span)) + }.value + } + } + + public struct Packaged: Sendable { + /// The serialized scene. + public var scene: Data + public var measurement: MeshMeasurement + /// The captured packet, with `scene` attached. + public var packet: Packet? + } + + /// Measures the captured mesh against the captured wall and span, then serializes the + /// captured scene with those measurements and attaches the same bytes to the captured packet. + /// `isCurrent` is asked once the measurement is back, on the main actor: false means the + /// upload was superseded (cancelled, withdrawn for a ground change, or the scan reset), and + /// nothing is serialized or returned, so the caller schedules no bundle and no submit. + @MainActor + public func package( + measure: Measure = Self.measureOffActor, + attachScene: (inout Packet, Data) -> Void, + isCurrent: () -> Bool + ) async throws -> Packaged? { + var measurement = MeshMeasurement() + if let mesh { + measurement = await measure(mesh, worldWall, span) + } + guard isCurrent() else { return nil } + var input = scene + input.meshFacing = measurement.facing + input.meshOverheads = measurement.overheads + let data = try SceneExport.jsonData(input) + var packet = packet + if packet != nil { attachScene(&packet!, data) } + return Packaged(scene: data, measurement: measurement, packet: packet) + } +} diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/UploadPackagingTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/UploadPackagingTests.swift new file mode 100644 index 000000000..c9172291c --- /dev/null +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/UploadPackagingTests.swift @@ -0,0 +1,360 @@ +import Foundation +import HouseScanKit +import simd +import Testing + +// The upload's packaging step (`UploadPackaging.package`), the function `ScanEngine.upload` calls +// between capturing its scene and packet (`captureUpload`) and writing the bundle and submitting. +// The measurement is held at the point where the engine's main actor is free, the source is +// changed while it is held, and the result is checked once it resumes. +// +// Boundary: these tests run the package's real `package` with the real mesh measurement behind a +// gate. The app target has no unit tests, so `Source` below stands in for the engine: its +// `capture` reads the same kinds of state the engine's `captureUpload` does in one synchronous +// turn, and its `upload` keeps the engine's order (capture, mark the scene packaged, package, then +// bundle and submit). The engine's own wiring of that order, and `answerAfter`'s use of +// `GroundFreshness`, are covered by review, not by these tests; the policy itself is covered by +// `GroundFreshnessTests`. + +/// What the tests' packet carries: the geometry the engine's `PacketInputs` captures with the +/// scene, and the scene bytes attached by `package`. +struct TestPacket: Sendable, Equatable { + var wall: SceneWall + var groundWorldY: Float + var marks: [SIMD3] + var scene = Data() +} + +/// Holds a measurement until the test releases it, and records what it was asked to measure. +actor MeasurementGate { + private(set) var walls: [WallFrame] = [] + private(set) var spans: [ClosedRange] = [] + private var arrival: CheckedContinuation? + private var held: CheckedContinuation? + private var released = false + + private func hold(_ wall: WallFrame, _ span: ClosedRange) async { + walls.append(wall) + spans.append(span) + arrival?.resume() + arrival = nil + guard !released else { return } + await withCheckedContinuation { held = $0 } + } + + /// Returns once a measurement is held. + func arrived() async { + guard walls.isEmpty else { return } + await withCheckedContinuation { arrival = $0 } + } + + func release() { + released = true + held?.resume() + held = nil + } + + /// The real measurement, held first. + nonisolated var measure: UploadPackaging.Measure { + { mesh, wall, span in + await self.hold(wall, span) + return await UploadPackaging.measureOffActor(mesh, wall, span) + } + } +} + +/// The scan the engine would hold: a coverage map, the exported stretch, the marks (a gas meter's +/// tap, world meters) and the LiDAR mesh; and the upload state `upload` keeps as the engine does. +@MainActor +final class Source { + var map: CoverageMap + var span: ClosedRange = -3...3 + var marks: [SIMD3] + var mesh: TriangleMesh? + var hasPacket = true + + var generation = 0 + var freshness = GroundFreshness() + var scenePackaged = false + var uploadTask: Task? + var failed = false + /// Scenes attached to a packet, bundles written and scenes submitted, in order. + var attached = 0 + var bundles: [TestPacket] = [] + var submits: [Data] = [] + + init(groundY: Float, mesh: TriangleMesh?) { + map = CoverageMap(wall: WallFrame(meter: SIMD3(0, 1.5 + groundY, 0), outward: SIMD3(0, 0, 1), groundY: groundY)!) + marks = [SIMD3(1.5, 1.0 + groundY, 0)] + self.mesh = mesh + } + + /// Everything one upload sends, read in this one turn, as `ScanEngine.captureUpload` does: the + /// scene with its ground moved to zero, the wall in world meters, the mesh and the packet. + func capture() -> UploadPackaging { + let wall = map.wall + let drop = SIMD3(0, wall.groundY, 0) + let worldWall = SceneWall(meter: wall.meter, outward: wall.outward, groundY: wall.groundY) + let scene = SceneInput( + wall: SceneWall(meter: wall.meter - drop, outward: wall.outward, groundY: 0), + baselineS: span, + features: marks.map { .pointObject(kind: .gasMeter, tap: $0 - drop, bottom: nil, top: nil) }, + coverage: SceneCoverage(map, leftEndMarked: false, rightEndMarked: false)) + let packet = hasPacket ? TestPacket(wall: worldWall, groundWorldY: wall.groundY, marks: marks) : nil + return UploadPackaging(scene: scene, worldWall: wall, mesh: mesh, packet: packet) + } + + /// `ScanEngine.upload` from its capture on: mark the scene packaged, package, and only a + /// current upload writes its bundle and submits. + func upload(_ gate: MeasurementGate) async { + let scan = generation + guard !Task.isCancelled else { return } + let capture = capture() + scenePackaged = true + let packaged = try? await capture.package( + measure: gate.measure, + attachScene: { packet, data in + self.attached += 1 + packet.scene = data + }, + isCurrent: { scan == self.generation && !Task.isCancelled }) + guard let packaged else { return } + if let packet = packaged.packet { bundles.append(packet) } + submits.append(packaged.scene) + } + + func startUpload(_ gate: MeasurementGate) { + scenePackaged = false + uploadTask?.cancel() + uploadTask = Task { await upload(gate) } + } + + /// `ScanEngine.answerAfter` on the upload screen. + @discardableResult + func changed(_ change: GroundFreshness.Change, replacement: MeasurementGate?) -> GroundFreshness.Action { + let action = freshness.after(change, on: .sending, scenePackaged: scenePackaged) + switch action { + case .keep: + break + case .sendAgain: + startUpload(replacement ?? MeasurementGate()) + case .fail: + uploadTask?.cancel() + uploadTask = nil + failed = true + } + return action + } + + /// The ground refined: same meter and axes, the ground `by` higher. + func raiseGround(by rise: Float) { + let wall = map.wall + map.updateWall(WallFrame(meter: wall.meter, outward: wall.outward, groundY: wall.groundY + rise)!) + } +} + +/// The standard wall's surroundings with the ground `groundY` up: the wall face z = 0, the ground, +/// a fence 2.0 m out over s in [-1, 1] and an eave 2.5 m above the ground over the same stretch. +func raisedScene(groundY g: Float) -> TriangleMesh { + var builder = MeshBuilder() + builder.quad(SIMD3(-10, g, 0), SIMD3(10, g, 0), SIMD3(10, g + 4, 0), SIMD3(-10, g + 4, 0)) + builder.quad(SIMD3(-10, g, 0), SIMD3(-10, g, 10), SIMD3(10, g, 10), SIMD3(10, g, 0)) + builder.box(SIMD3(-1, g, 2.0), SIMD3(1, g + 1.5, 2.2)) + builder.box(SIMD3(-1, g + 2.5, 0), SIMD3(1, g + 2.7, 1.0)) + return builder.mesh +} + +@MainActor +@Suite struct UploadPackagingTests { + /// The world's ground is 0.5 m up, so a wall with its ground moved to zero would put every + /// overhead ray's start 0.5 m under the mesh's ground. + static let groundY: Float = 0.5 + + /// The captured scene serialized with the mesh measured against the captured world wall and + /// span: what an upload of `capture` must send, byte for byte. + static func expected(_ capture: UploadPackaging) throws -> Data { + var input = capture.scene + if let mesh = capture.mesh { + input.meshFacing = mesh.facingSpans(wall: capture.worldWall, over: capture.span) + input.meshOverheads = mesh.overheadSpans(wall: capture.worldWall, over: capture.span) + } + return try SceneExport.jsonData(input) + } + + /// Boundary: `package` with the real measurement. Nothing changes while it runs; the fence + /// and eave come back measured from the world's ground (6.5 ft and 8.2 ft, rounded down to + /// 0.1 ft), in the scene and in the packet's copy of it. + @Test func anUnchangedScanKeepsItsFacingAndOverheadMeasurements() async throws { + let source = Source(groundY: Self.groundY, mesh: raisedScene(groundY: Self.groundY)) + let gate = MeasurementGate() + await gate.release() + let capture = source.capture() + let packaged = try #require(try await capture.package(measure: gate.measure, attachScene: { $0.scene = $1 }, isCurrent: { true })) + + #expect(await gate.walls == [source.map.wall]) + #expect(await gate.walls.first?.groundY == Self.groundY) + // Measured against a wall with its ground at zero, the overhead rays would start under + // the mesh's ground and meet it 0.5 m (1.6 ft) up. + #expect(packaged.measurement.facing.contains { nearlyEqual($0.out, 6.5 * 0.3048) }) + #expect(packaged.measurement.overheads.contains { nearlyEqual($0.out, 8.2 * 0.3048) }) + #expect(packaged.measurement.overheads.allSatisfy { $0.out > 2 }) + #expect(packaged.scene == (try Self.expected(capture))) + #expect(packaged.packet?.scene == packaged.scene) + } + + /// Boundary: `package` held mid-measurement while the source's ground, wall, span and marks + /// all change. What it sends is the capture, measured against the captured wall: the scene's + /// bytes, the packet's geometry and the packet's copy of the scene, which a capture taken + /// after the change would not match. + @Test func changesWhileTheMeshIsMeasuredDontReachTheUpload() async throws { + let source = Source(groundY: Self.groundY, mesh: raisedScene(groundY: Self.groundY)) + let gate = MeasurementGate() + let capture = source.capture() + let worldWall = source.map.wall + let task = Task { try await capture.package(measure: gate.measure, attachScene: { $0.scene = $1 }, isCurrent: { true }) } + await gate.arrived() + + source.raiseGround(by: 0.12) + source.map.updateWall(WallFrame(meter: source.map.wall.meter + SIMD3(0.3, 0, 0), outward: SIMD3(0, 0, 1), groundY: source.map.wall.groundY)!) + source.span = -2...4 + source.marks = [SIMD3(-1, 1.4, 0)] + let later = source.capture() + await gate.release() + let packaged = try #require(try await task.value) + + #expect(await gate.walls == [worldWall]) + #expect(await gate.spans == [-3...3]) + #expect(packaged.scene == (try Self.expected(capture))) + #expect(packaged.scene != (try Self.expected(later))) + let packet = try #require(packaged.packet) + #expect(packet.wall == SceneWall(meter: worldWall.meter, outward: worldWall.outward, groundY: worldWall.groundY)) + #expect(packet.groundWorldY == Self.groundY) + #expect(packet.marks == [SIMD3(1.5, 1.0 + Self.groundY, 0)]) + #expect(packet.scene == packaged.scene) + } + + /// Boundary: `Source.upload` around `package`, held mid-measurement while an anchor correction + /// moves the wall, its ground and the marks as one body. The rule keeps the upload, nothing is + /// sent again, and the upload completes with the geometry it captured. + @Test func anAnchorCorrectionKeepsTheCapturedUploadWithoutResending() async throws { + let source = Source(groundY: Self.groundY, mesh: raisedScene(groundY: Self.groundY)) + let gate = MeasurementGate() + let capture = source.capture() + source.startUpload(gate) + let first = try #require(source.uploadTask) + await gate.arrived() + + let correction = YawCorrection(yaw: 0.05, translation: SIMD3(0.2, 0.04, -0.1)) + source.map.apply(correction) + source.marks = source.marks.map { correction.direction($0) + correction.translation } + #expect(source.changed(.anchorCorrection, replacement: nil) == .keep) + #expect(source.uploadTask == first) + #expect(!source.freshness.awaitingNewAnswer) + await gate.release() + await first.value + + #expect(source.submits == [try Self.expected(capture)]) + #expect(source.bundles.map(\.wall) == [capture.packet?.wall]) + #expect(source.bundles.map(\.marks) == [capture.packet?.marks]) + #expect(source.bundles.map(\.scene) == source.submits) + #expect(!source.failed) + } + + /// Boundary: `Source.upload` around `package` with `GroundFreshness` deciding. A ground change + /// mid-measurement withdraws the first upload, whose completion then sends nothing, and the + /// replacement, captured on the new ground, is what is bundled and submitted. + @Test func aGroundChangeRejectsTheOldUploadAndItsReplacementIsSent() async throws { + let source = Source(groundY: Self.groundY, mesh: raisedScene(groundY: Self.groundY)) + let firstGate = MeasurementGate() + source.startUpload(firstGate) + let first = try #require(source.uploadTask) + await firstGate.arrived() + + source.raiseGround(by: 0.12) + let secondGate = MeasurementGate() + #expect(source.changed(.ground, replacement: secondGate) == .sendAgain) + let second = try #require(source.uploadTask) + await secondGate.arrived() + let replacement = source.capture() + await firstGate.release() + await first.value + #expect(source.submits.isEmpty) + #expect(source.bundles.isEmpty) + #expect(source.attached == 0) + + await secondGate.release() + await second.value + #expect(await secondGate.walls.first?.groundY == Self.groundY + 0.12) + #expect(source.submits == [try Self.expected(replacement)]) + #expect(source.bundles.map(\.groundWorldY) == [Self.groundY + 0.12]) + #expect(source.bundles.map(\.scene) == source.submits) + } + + /// Boundary: as above, with a second ground change while the replacement is measured. The + /// rule fails the upload, neither completion sends anything, and a further change keeps the + /// failure rather than sending again. + @Test func aSecondGroundChangeKeepsTheFailure() async throws { + let source = Source(groundY: Self.groundY, mesh: raisedScene(groundY: Self.groundY)) + let firstGate = MeasurementGate() + source.startUpload(firstGate) + let first = try #require(source.uploadTask) + await firstGate.arrived() + source.raiseGround(by: 0.12) + let secondGate = MeasurementGate() + #expect(source.changed(.ground, replacement: secondGate) == .sendAgain) + let second = try #require(source.uploadTask) + await secondGate.arrived() + + source.raiseGround(by: 0.05) + #expect(source.changed(.ground, replacement: nil) == .fail) + #expect(source.uploadTask == nil) + await firstGate.release() + await secondGate.release() + await first.value + await second.value + #expect(source.failed) + #expect(source.submits.isEmpty) + #expect(source.bundles.isEmpty) + #expect(source.attached == 0) + source.raiseGround(by: 0.05) + #expect(source.changed(.ground, replacement: nil) == .fail) + #expect(source.uploadTask == nil) + } + + /// Boundary: `Source.upload` around `package`. A reset (a new scan generation) or a + /// cancelled task while the mesh is measured: the completion serializes nothing into a + /// packet, and nothing is bundled or submitted. + @Test(arguments: [false, true]) + func aResetOrCancelledUploadSendsNothing(cancel: Bool) async throws { + let source = Source(groundY: Self.groundY, mesh: raisedScene(groundY: Self.groundY)) + let gate = MeasurementGate() + source.startUpload(gate) + let task = try #require(source.uploadTask) + await gate.arrived() + if cancel { task.cancel() } else { source.generation += 1 } + await gate.release() + await task.value + #expect(source.attached == 0) + #expect(source.bundles.isEmpty) + #expect(source.submits.isEmpty) + } + + /// Boundary: `package` alone. Without a mesh nothing is measured, and the scene still goes, + /// with its packet's copy; without a packet the scene still goes on its own. + @Test func noMeshOrNoPacketStillSendsTheScene() async throws { + let source = Source(groundY: Self.groundY, mesh: nil) + let gate = MeasurementGate() + let capture = source.capture() + let packaged = try #require(try await capture.package(measure: gate.measure, attachScene: { $0.scene = $1 }, isCurrent: { true })) + #expect(await gate.walls.isEmpty) + #expect(packaged.measurement == .init()) + #expect(packaged.scene == (try Self.expected(capture))) + #expect(packaged.packet?.scene == packaged.scene) + + source.hasPacket = false + let alone = source.capture() + let sent = try #require(try await alone.package(measure: gate.measure, attachScene: { $0.scene = $1 }, isCurrent: { true })) + #expect(sent.packet == nil) + #expect(sent.scene == (try Self.expected(alone))) + } +} From b926c925b0e6ad59b05de70339016b4a13f4470c Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Fri, 2 Oct 2026 07:51:38 -0500 Subject: [PATCH 40/48] ios: coverage rebuilds reuse each kept frame's views while their inputs hold A far-surface change rebuilds the coverage map from every kept frame on the main actor, and ARKit's plane refinement can change the far surface often. Most of a rebuild works out what each frame shows, which reads no recorded sightings. The map now keeps those views per frame and reuses them while the frame's camera and depth, the wall chain, heightError, the ends and limit ends, and the far-surface lookup at every cell its depth readings consulted (where the sample lies and where the reading met something, absent values included) are unchanged. Any change to the basis drops every kept view. The merge still runs in capture order on every rebuild, so the trigger, the recorded sightings, the skipped and withdrawn shift, end clipping, the past-limit replay and the revision count are what they were. recordDepth and recordPastLimits split into a view part and their unchanged merge; observe calls both as before. RebuildViewCacheTests runs a cached and a full-rebuild map through the same changes and compares their whole stored state, the public readers, and the state after later depthless views. (cherry picked from commit 745a00ed95786c4dbb5dccdec82d7aaf7fc7eb4d) Co-Authored-By: Claude Code --- .../HouseScanKit/Coverage/CoverageMap.swift | 336 +++++++++++++++--- .../RebuildViewCacheTests.swift | 320 +++++++++++++++++ 2 files changed, 614 insertions(+), 42 deletions(-) create mode 100644 ios/HouseScanKit/Tests/HouseScanKitTests/RebuildViewCacheTests.swift diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Coverage/CoverageMap.swift b/ios/HouseScanKit/Sources/HouseScanKit/Coverage/CoverageMap.swift index a4f76aee2..963e513f0 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Coverage/CoverageMap.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Coverage/CoverageMap.swift @@ -257,7 +257,7 @@ public struct CoverageMap: Sendable { public private(set) var withdrawnCells: Set = [] /// One camera position that saw a sample row, and whether that frame's depth confirmed it. - private struct Sight: Sendable { + private struct Sight: Sendable, Equatable { var position: SIMD3 var depthVerified: Bool } @@ -279,7 +279,7 @@ public struct CoverageMap: Sendable { return row.count != before } - private struct Cell: Sendable { + private struct Cell: Sendable, Equatable { /// Per sample row, its sightings, pairwise at least `coveringBaseline` apart. Two are /// enough, so a row stops collecting at two. var rows: [[Sight]] @@ -428,10 +428,15 @@ public struct CoverageMap: Sendable { /// Every cell a frame sees or finds hidden at least one row of, before the marked ends clip /// anything that isn't allowed. Without `depth` nothing is hidden. public func visibleCells(from camera: CameraFrame, depth: DepthImage? = nil) -> [Sighting] { + visibleCells(from: camera, depth: depth, reads: nil) + } + + /// `visibleCells(from:depth:)`, noting in `reads` where the far-surface lookup was consulted. + private func visibleCells(from camera: CameraFrame, depth: DepthImage?, reads: FarSurfaceReadLog?) -> [Sighting] { var seen: [Sighting] = [] for band in SurfaceBand.allCases { for index in candidateIndices(for: camera) { - let views = rowViews(band, index, from: camera, depth: depth, among: Array(rowOffsets(band).indices)) + let views = rowViews(band, index, from: camera, depth: depth, among: Array(rowOffsets(band).indices), reads: reads) if !views.seen.isEmpty || !views.hidden.isEmpty || !views.pastSpace.isEmpty { seen.append(Sighting(band: band, index: index, rows: views.seen, hiddenRows: views.hidden, pastSpaceRows: views.pastSpace)) } @@ -563,15 +568,17 @@ public struct CoverageMap: Sendable { rowViews(band, index, from: camera, depth: nil, among: Array(rowOffsets(band).indices)).seen } - private func rowViews(_ band: SurfaceBand, _ index: Int, from camera: CameraFrame, depth: DepthImage?, among rows: [Int]) -> RowViews { + private func rowViews( + _ band: SurfaceBand, _ index: Int, from camera: CameraFrame, depth: DepthImage?, among rows: [Int], reads: FarSurfaceReadLog? = nil + ) -> RowViews { let offsets = rowOffsets(band) - return sampledRows(index, from: camera, depth: depth, rows: rows, onWallFace: band == .wall) { s, row in + return sampledRows(index, from: camera, depth: depth, rows: rows, onWallFace: band == .wall, reads: reads) { s, row in band == .wall ? wall.world(s: s, height: offsets[row]) : wall.world(s: s, height: 0, out: offsets[row]) } } /// What one frame showed of a cell's rows. - private struct RowViews { + private struct RowViews: Sendable { var seen: Set = [] var hidden: Set = [] /// Rows past where the space ends (`DepthEvidence.pastSpace`): neither seen nor hidden. @@ -592,12 +599,12 @@ public struct CoverageMap: Sendable { case none } - private func depthEvidence(_ point: SIMD3, camera: CameraFrame, depth: DepthImage) -> DepthEvidence { + private func depthEvidence(_ point: SIMD3, camera: CameraFrame, depth: DepthImage, reads: FarSurfaceReadLog? = nil) -> DepthEvidence { guard let projected = depth.projection(of: point, pose: camera), let reading = depth.meters(atPixel: projected.pixel, minimumConfidence: config.minimumDepthConfidence) else { return .none } let tolerance = config.depthTolerance + config.depthTolerancePerMeter * projected.depth if reading < projected.depth - tolerance { - return endsSpace(point, reading: reading, depth: projected.depth, camera: camera) ? .pastSpace : .nearer + return endsSpace(point, reading: reading, depth: projected.depth, camera: camera, reads: reads) ? .pastSpace : .nearer } return reading <= projected.depth + tolerance ? .matches : .none } @@ -613,16 +620,27 @@ public struct CoverageMap: Sendable { /// or a parked car's side, can qualify as the far surface (`FarSurface.spans`). The wall and /// ground behind it then read unseen rather than hidden: the walk stops asking to look past /// it, but nothing behind it is claimed seen, so a check there stays unsure. - private func endsSpace(_ point: SIMD3, reading: Float, depth: Float, camera: CameraFrame) -> Bool { + /// + /// With `reads`, both cells this could consult are noted with what the lookup holds there, + /// before the empty-lookup return and the short circuit: under another lookup the answer can + /// turn on either of them (`RebuildViewCache`). + private func endsSpace(_ point: SIMD3, reading: Float, depth: Float, camera: CameraFrame, reads: FarSurfaceReadLog? = nil) -> Bool { + // z-depth grows in proportion along a ray from the camera, so what the reading met lies + // that fraction of the way to the point. + let met = camera.position + (point - camera.position) * (reading / depth) + if let reads { + for world in [point, met] { + let cell = cellIndex(forS: wall.wallPoint(world).s) + reads.note(cell, farSurfaceByCell[cell]) + } + } guard !farSurfaceByCell.isEmpty else { return false } func pastSpace(_ world: SIMD3) -> Bool { let at = wall.wallPoint(world) guard let far = farSurface(atS: at.s) else { return false } return at.out >= far - config.farSurfaceTolerance } - // z-depth grows in proportion along a ray from the camera, so what the reading met lies - // that fraction of the way to the point. - return pastSpace(point) || pastSpace(camera.position + (point - camera.position) * (reading / depth)) + return pastSpace(point) || pastSpace(met) } /// The rows of a cell whose samples, a quarter and three quarters along it, are all in view: @@ -631,7 +649,7 @@ public struct CoverageMap: Sendable { /// the wall face, up for the ground. With `depth`, a row in view is seen only when depth /// matches both samples, and hidden when it finds either behind something nearer. private func sampledRows( - _ index: Int, from camera: CameraFrame, depth: DepthImage?, rows: [Int], onWallFace: Bool, + _ index: Int, from camera: CameraFrame, depth: DepthImage?, rows: [Int], onWallFace: Bool, reads: FarSurfaceReadLog? = nil, point: (_ s: Float, _ row: Int) -> SIMD3 ) -> RowViews { let range = cellRange(index) @@ -659,7 +677,7 @@ public struct CoverageMap: Sendable { views.seen.insert(row) continue } - let evidence = points.map { depthEvidence($0, camera: camera, depth: depth) } + let evidence = points.map { depthEvidence($0, camera: camera, depth: depth, reads: reads) } if evidence.allSatisfy({ $0 == .matches }) { views.seen.insert(row) } else if evidence.contains(.nearer) { @@ -721,13 +739,29 @@ public struct CoverageMap: Sendable { depthRowViews(index, from: camera, depth: depth).seen } - private func depthRowViews(_ index: Int, from camera: CameraFrame, depth: DepthImage?) -> RowViews { + private func depthRowViews(_ index: Int, from camera: CameraFrame, depth: DepthImage?, reads: FarSurfaceReadLog? = nil) -> RowViews { let rows = groundDepthRows - return sampledRows(index, from: camera, depth: depth, rows: Array(rows.indices), onWallFace: false) { s, row in + return sampledRows(index, from: camera, depth: depth, rows: Array(rows.indices), onWallFace: false, reads: reads) { s, row in wall.world(s: s, height: 0, out: rows[row]) } } + /// What one frame showed of one cell's ground depth rows. + private struct DepthRowView: Sendable { + var index: Int + var views: RowViews + } + + /// A frame's view of the ground depth rows of every cell it shows any of: the part of + /// `recordDepth` that reads no recorded state. + private func depthRowViews(from camera: CameraFrame, depth: DepthImage?, reads: FarSurfaceReadLog? = nil) -> [DepthRowView] { + candidateIndices(for: camera).compactMap { index in + let views = depthRowViews(index, from: camera, depth: depth, reads: reads) + guard !views.seen.isEmpty || !views.hidden.isEmpty || !views.pastSpace.isEmpty else { return nil } + return DepthRowView(index: index, views: views) + } + } + /// Adds a kept frame's view of the ground depth rows; true when any row gained a position. /// Hidden rows gain nothing, so the reach stops at the first row something stood in front of, /// and a frame without depth adds nothing to a row an earlier depth frame found hidden. Rows @@ -735,11 +769,17 @@ public struct CoverageMap: Sendable { /// (`depthPastSpace`), and a frame without depth adds nothing to them either. @discardableResult private mutating func recordDepth(from camera: CameraFrame, depth: DepthImage?) -> Bool { + recordDepth(depthRowViews(from: camera, depth: depth), from: camera.position, depthChecked: depth != nil) + } + + /// `recordDepth(from:depth:)` given the frame's views, from `position`; `depthChecked` when the + /// frame had depth. + @discardableResult + private mutating func recordDepth(_ found: [DepthRowView], from position: SIMD3, depthChecked: Bool) -> Bool { let rowCount = groundDepthRows.count var changed = false - for index in candidateIndices(for: camera) { - let views = depthRowViews(index, from: camera, depth: depth) - guard !views.seen.isEmpty || !views.hidden.isEmpty || !views.pastSpace.isEmpty else { continue } + for item in found { + let index = item.index, views = item.views var rows = depthCells[index] ?? Array(repeating: [], count: rowCount) for row in views.hidden { if Self.dropUnverified(&rows[row]) { changed = true } @@ -751,9 +791,9 @@ public struct CoverageMap: Sendable { if Self.dropUnverified(&rows[row]) { changed = true } if rows[row].count < 2 { depthPastSpace[index, default: []].insert(row) } } - let hidden = depth == nil ? (depthHidden[index] ?? []).union(depthPastSpace[index] ?? []) : [] + let hidden = depthChecked ? [] : (depthHidden[index] ?? []).union(depthPastSpace[index] ?? []) for row in views.seen where !hidden.contains(row) { - if add(&rows[row], camera.position, verified: depth != nil) { changed = true } + if add(&rows[row], position, verified: depthChecked) { changed = true } } depthCells[index] = rows } @@ -836,8 +876,31 @@ public struct CoverageMap: Sendable { /// is anything standing on the ground (see the type's comment). @discardableResult private mutating func recordPastLimits(from camera: CameraFrame, depth: DepthImage?) -> Bool { - guard wall.wallPoint(camera.position).out > 0 else { return false } - var changed = false + recordPastLimits(pastLimitViews(from: camera, depth: depth), from: camera.position, depthChecked: depth != nil) + } + + /// What one frame showed of a sample row past a limit end. + private enum PastLimitRow: UInt8, Sendable { + /// Not both samples in view, or in view without matching: adds nothing. + case none + /// Depth found something nearer, or the surface where the space ends, at either sample. + case blocked + /// Both samples in view and, with depth, matched. + case matches + } + + /// One frame's view of the rows of one cell past a limit end (`pastLimitCells`). + private struct PastLimitCellView: Sendable { + var side: WalkSide + var cell: Int + var rows: [PastLimitRow] + } + + /// A frame's view of the ground past each limit end: the part of `recordPastLimits` that reads + /// no recorded state. Cells it shows nothing of are left out. + private func pastLimitViews(from camera: CameraFrame, depth: DepthImage?, reads: FarSurfaceReadLog? = nil) -> [PastLimitCellView] { + guard wall.wallPoint(camera.position).out > 0 else { return [] } + var found: [PastLimitCellView] = [] for side in limitEnds { guard let end = end(side) else { continue } let piece = endPiece(side, end: end) @@ -869,34 +932,55 @@ public struct CoverageMap: Sendable { let pixel = camera.pixel(of: target), camera.contains(pixel: pixel, margin: config.imageMargin) else { return nil } guard let depth else { return .matches } - return depthEvidence(target, camera: camera, depth: depth) + return depthEvidence(target, camera: camera, depth: depth, reads: reads) } for cell in first...last { let range = pastCellRange(side, cell, end: end) let width = range.upperBound - range.lowerBound let alongs = [range.lowerBound + width * 0.25, range.lowerBound + width * 0.75] - var rows = pastLimitCells[side]?[cell] ?? Array(repeating: [], count: 2 * depths.count) - var added = false + var rows = [PastLimitRow](repeating: .none, count: 2 * depths.count) for row in rows.indices { let out = row < depths.count ? depths[row] : -depths[row - depths.count] - let found = alongs.map { evidence($0, out) } - guard found.allSatisfy({ $0 != nil }) else { continue } + let samples = alongs.map { evidence($0, out) } + guard samples.allSatisfy({ $0 != nil }) else { continue } // Past a limit end nothing is asked to be looked past, so the surface where // the space ends blocks a row just as anything nearer does. - if depth != nil, found.contains(where: { $0 == .nearer || $0 == .pastSpace }) { - if Self.dropUnverified(&rows[row]) { added = true } - if rows[row].count < 2 { pastLimitHidden[side, default: [:]][cell, default: []].insert(row) } - continue + if depth != nil, samples.contains(where: { $0 == .nearer || $0 == .pastSpace }) { + rows[row] = .blocked + } else if samples.allSatisfy({ $0 == .matches }) { + rows[row] = .matches } - guard found.allSatisfy({ $0 == .matches }), - depth != nil || !(pastLimitHidden[side]?[cell]?.contains(row) ?? false) else { continue } - if add(&rows[row], camera.position, verified: depth != nil) { added = true } } - if added { - pastLimitCells[side, default: [:]][cell] = rows - changed = true + if rows.contains(where: { $0 != .none }) { found.append(PastLimitCellView(side: side, cell: cell, rows: rows)) } + } + } + return found + } + + /// `recordPastLimits(from:depth:)` given the frame's views, from `position`; `depthChecked` + /// when the frame had depth. + @discardableResult + private mutating func recordPastLimits(_ found: [PastLimitCellView], from position: SIMD3, depthChecked: Bool) -> Bool { + var changed = false + for view in found { + var rows = pastLimitCells[view.side]?[view.cell] ?? Array(repeating: [], count: view.rows.count) + var added = false + for (row, kind) in view.rows.enumerated() { + switch kind { + case .none: + continue + case .blocked: + if Self.dropUnverified(&rows[row]) { added = true } + if rows[row].count < 2 { pastLimitHidden[view.side, default: [:]][view.cell, default: []].insert(row) } + case .matches: + guard depthChecked || !(pastLimitHidden[view.side]?[view.cell]?.contains(row) ?? false) else { continue } + if add(&rows[row], position, verified: depthChecked) { added = true } } } + if added { + pastLimitCells[view.side, default: [:]][view.cell] = rows + changed = true + } } return changed } @@ -917,7 +1001,11 @@ public struct CoverageMap: Sendable { pastLimitCells = [:] pastLimitHidden = [:] guard !limitEnds.isEmpty else { return } - for index in captureOrder { recordPastLimits(from: observedCameras[index], depth: observedDepths[index]) } + prepareRebuildViews() + for index in captureOrder { + let views = rebuildPastLimitViews(index) + recordPastLimits(views, from: observedCameras[index].position, depthChecked: observedDepths[index] != nil) + } } // MARK: Where the space ends @@ -1387,10 +1475,13 @@ public struct CoverageMap: Sendable { depthCells = [:] depthHidden = [:] depthPastSpace = [:] + prepareRebuildViews() for index in captureOrder { - let camera = observedCameras[index], depth = observedDepths[index] - recordDepth(from: camera, depth: depth) - recordSightings(visibleCells(from: camera, depth: depth), from: camera.position, depthChecked: depth != nil) + let position = observedCameras[index].position, depthChecked = observedDepths[index] != nil + let depthRows = rebuildDepthRowViews(index) + recordDepth(depthRows, from: position, depthChecked: depthChecked) + let bands = rebuildBandViews(index) + recordSightings(bands, from: position, depthChecked: depthChecked) } replayPastLimits() revision += 1 @@ -1473,6 +1564,167 @@ public struct CoverageMap: Sendable { public var coveredCount: Int { cells.values.reduce(0) { $0 + $1.filter { $0.value.covered && !withdrawnCells.contains($0.key) }.count } } + + // MARK: Rebuild view cache + + /// A rebuild (`replayObservedCameras`, `replayPastLimits`) replays every kept frame, and most + /// of its time goes to working out what each frame shows. That part reads no recorded + /// sightings, so it is kept per frame and used again while nothing it was worked out from has + /// changed: the frame's camera and depth, the wall chain with its ground, `heightError`, the + /// ends and which of them are limits (`RebuildViewBasis`), and the far-surface lookup at every + /// cell the frame's depth readings consulted (`FarSurfaceReads`). `config` is fixed. Any + /// change to the basis (a wall update, a corner, an anchor correction, a measured ground, an + /// end) drops every kept view. The merge, which reads and writes the recorded sightings in + /// capture order, runs in full every time, so a rebuild records what it would without the + /// cache. Only a far-surface rebuild can reuse views: every other rebuild changes the basis. + private var rebuildViews = RebuildViewCache() + + /// Test and measurement seam: false makes every rebuild work out every view again, so a test + /// can hold a cached map against a full rebuild. Nothing in the app sets it. + var reusesRebuildViews = true + + /// How many per-frame views rebuilds took from the cache and worked out, for tests and the + /// probe. A frame has up to three: the bands, the ground depth rows, the ground past limits. + private(set) var rebuildViewCounts: (reused: Int, computed: Int) = (0, 0) + + private struct RebuildViewBasis: Equatable, Sendable { + var wall: WallFrame + var leftEnd: Float? + var rightEnd: Float? + var limitEnds: Set + var heightError: Float + } + + private struct RebuildViewCache: Sendable { + var basis: RebuildViewBasis? + /// By index into `observedCameras`. + var frames: [Int: FrameViews] = [:] + } + + /// The kept views of one frame, with the camera and depth they were worked out from. + private struct FrameViews: Sendable { + var camera: CameraFrame + var depth: DepthImage? + var bands: KeptViews<[Sighting]>? + var depthRows: KeptViews<[DepthRowView]>? + var pastLimits: KeptViews<[PastLimitCellView]>? + } + + private struct KeptViews: Sendable { + var value: Value + var reads: FarSurfaceReads + } + + /// The far-surface lookup's cells a frame's views consulted, and what each held there (absent + /// for no surface). The views hold while the lookup holds the same at every one of them. + private struct FarSurfaceReads: Sendable { + var cells: Set + var values: [Int: Float] + + func agree(with lookup: [Int: Float]) -> Bool { + cells.allSatisfy { lookup[$0] == values[$0] } + } + } + + /// Collects `FarSurfaceReads` while views are worked out (`endsSpace`). + private final class FarSurfaceReadLog { + private(set) var cells: Set = [] + private(set) var values: [Int: Float] = [:] + + func note(_ cell: Int, _ value: Float?) { + guard cells.insert(cell).inserted else { return } + values[cell] = value + } + + var reads: FarSurfaceReads { FarSurfaceReads(cells: cells, values: values) } + } + + /// Drops every kept view unless the basis they were worked out on is the current one. + private mutating func prepareRebuildViews() { + let basis = RebuildViewBasis(wall: wall, leftEnd: leftEnd, rightEnd: rightEnd, limitEnds: limitEnds, heightError: heightError) + guard !reusesRebuildViews || rebuildViews.basis != basis else { return } + rebuildViews = RebuildViewCache(basis: reusesRebuildViews ? basis : nil) + } + + private mutating func rebuildBandViews(_ index: Int) -> [Sighting] { + if let kept = keptViews(index, \.bands) { return kept } + let log = reusesRebuildViews ? FarSurfaceReadLog() : nil + let views = visibleCells(from: observedCameras[index], depth: observedDepths[index], reads: log) + keep(views, reads: log, at: index, \.bands) + return views + } + + private mutating func rebuildDepthRowViews(_ index: Int) -> [DepthRowView] { + if let kept = keptViews(index, \.depthRows) { return kept } + let log = reusesRebuildViews ? FarSurfaceReadLog() : nil + let views = depthRowViews(from: observedCameras[index], depth: observedDepths[index], reads: log) + keep(views, reads: log, at: index, \.depthRows) + return views + } + + private mutating func rebuildPastLimitViews(_ index: Int) -> [PastLimitCellView] { + if let kept = keptViews(index, \.pastLimits) { return kept } + let log = reusesRebuildViews ? FarSurfaceReadLog() : nil + let views = pastLimitViews(from: observedCameras[index], depth: observedDepths[index], reads: log) + keep(views, reads: log, at: index, \.pastLimits) + return views + } + + /// The kept views of the frame at `index`, when they were worked out from its current camera + /// and depth and the far-surface lookup agrees at every cell they consulted. + private mutating func keptViews(_ index: Int, _ part: KeyPath?>) -> Value? { + guard reusesRebuildViews, let frame = rebuildViews.frames[index], + frame.camera == observedCameras[index], frame.depth == observedDepths[index], + let kept = frame[keyPath: part], kept.reads.agree(with: farSurfaceByCell) else { return nil } + rebuildViewCounts.reused += 1 + return kept.value + } + + private mutating func keep( + _ value: Value, reads log: FarSurfaceReadLog?, at index: Int, _ part: WritableKeyPath?> + ) { + rebuildViewCounts.computed += 1 + guard let log else { return } + let camera = observedCameras[index], depth = observedDepths[index] + var frame = rebuildViews.frames[index].flatMap { $0.camera == camera && $0.depth == depth ? $0 : nil } + ?? FrameViews(camera: camera, depth: depth) + frame[keyPath: part] = KeptViews(value: value, reads: log.reads) + rebuildViews.frames[index] = frame + } + + /// Test seam: the stored fields in which this map's state differs from `other`'s, leaving out + /// the rebuild view cache, its counts and `reusesRebuildViews`. Empty when every reader of + /// either map, now or after any later change, gets the same answers. + func stateDifferences(from other: CoverageMap) -> [String] { + var differing: [String] = [] + func compare(_ name: String, _ a: T, _ b: T) { + if a != b { differing.append(name) } + } + compare("wall", wall, other.wall) + compare("config", config, other.config) + compare("leftEnd", leftEnd, other.leftEnd) + compare("rightEnd", rightEnd, other.rightEnd) + compare("limitEnds", limitEnds, other.limitEnds) + compare("revision", revision, other.revision) + compare("heightError", heightError, other.heightError) + compare("cells", cells, other.cells) + compare("depthCells", depthCells, other.depthCells) + compare("pastLimitCells", pastLimitCells, other.pastLimitCells) + compare("pendingShift", pendingShift, other.pendingShift) + compare("observedCameras", observedCameras, other.observedCameras) + compare("observedDepths", observedDepths, other.observedDepths) + compare("overheadCameras", overheadCameras, other.overheadCameras) + compare("observedTimes", observedTimes, other.observedTimes) + compare("observedSegments", observedSegments, other.observedSegments) + compare("pathSegment", pathSegment, other.pathSegment) + compare("depthHidden", depthHidden, other.depthHidden) + compare("depthPastSpace", depthPastSpace, other.depthPastSpace) + compare("pastLimitHidden", pastLimitHidden, other.pastLimitHidden) + compare("farSurfaceByCell", farSurfaceByCell, other.farSurfaceByCell) + compare("farSurface", farSurface, other.farSurface) + compare("withdrawnCells", withdrawnCells, other.withdrawnCells) + return differing + } } /// A stretch of wall (s, meters) and how far the view of it reached, meters: out from the wall diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/RebuildViewCacheTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/RebuildViewCacheTests.swift new file mode 100644 index 000000000..183165da0 --- /dev/null +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/RebuildViewCacheTests.swift @@ -0,0 +1,320 @@ +@testable import HouseScanKit +import simd +import Testing + +// A rebuild that reuses kept views (`CoverageMap.rebuildViews`) must leave the map exactly as one +// that works every view out again. Each test runs the same changes on two copies of a map, one +// with `reusesRebuildViews` off, and after every change compares their whole stored state +// (`stateDifferences`), what the public readers return, and both again after depthless views +// taken on top: the order a row's sightings were kept in decides what a later depthless view adds. +@Suite struct RebuildViewCacheTests { + /// The far wall of the corridor scenes, 2 m out (`FarSurfaceTests.farWall`). + static let scene = standardScene(boxes: [FarSurfaceTests.farWall(at: 2.0)]) + + /// Frames with depth: the corridor cameras facing the far wall, oblique ones looking along the + /// corridor both ways, wall cameras and a ground camera. + static let depthFrames: [(camera: CameraFrame, depth: DepthImage)] = { + let pitch = Float.pi / 6 + var cameras = FarSurfaceTests.corridorCameras + for (s, yaw) in [(Float(-1), Float(0.6)), (0.5, -0.6), (1.5, 0.6), (2.5, -0.6)] { + cameras.append(portraitCamera(at: SIMD3(s, 1.4, 0.8), forward: SIMD3(sin(yaw) * cos(pitch), -sin(pitch), cos(yaw) * cos(pitch)))) + } + cameras += [wallCamera(s: -0.5), wallCamera(s: 0.4), wallCamera(s: 1.2), groundCamera(s: 0.2)] + return cameras.map { ($0, renderDepth(RebuildViewCacheTests.scene, from: $0)) } + }() + + /// Frames without depth. + static let depthlessFrames = [wallCamera(s: 0.1), groundCamera(s: -0.3), wallCamera(s: 0.9)] + + /// Far surfaces the lookup moves between, over s -3...4 m. + static let surfaces: [String: [ObservedSpan]] = { + let wall = standardWall() + func spans(_ planes: [WallPlaneEvidence]) -> [ObservedSpan] { FarSurface.spans(planes: planes, wall: wall, over: -3...4) } + return [ + "none": [], + "far wall": spans([FarSurfaceTests.plane(at: 2.0)]), + // 4 mm nearer, still in the same 0.1 ft step (65.6 steps at 2 m): the same spans, so + // no rebuild. + "far wall, jittered within its step": spans([FarSurfaceTests.plane(at: 1.996)]), + // Across the next step down (2.0 to 1.9812 m): every cell's value changes. + "far wall, one step nearer": spans([FarSurfaceTests.plane(at: 1.97)]), + // A nearer plane over part of the stretch: the overlap's minimum changes there only. + "far wall with a nearer plane": spans([FarSurfaceTests.plane(at: 2.0), FarSurfaceTests.plane(at: 1.4, from: 0, to: 1.5)]), + "far wall over part of the stretch": spans([FarSurfaceTests.plane(at: 2.0, from: -1, to: 0.6)]), + // Only where no camera looks: against the far wall, the wall's cells go absent. + "far away": [ObservedSpan(span: 50...51, out: 2)], + // The far wall and a surface where no camera looks: from the far wall alone this + // changes the lookup, and so rebuilds, at no cell a frame consulted. + "far wall and far away": spans([FarSurfaceTests.plane(at: 2.0)]) + [ObservedSpan(span: 50...51, out: 2)], + ] + }() + + /// What the public readers return, over cells -40...40 and 30 cells past each end. + struct Readout: Equatable { + var revision: Int + var levels: [[CoverageLevel]] + var groundDepths: [Float?] + var hiddenDepthRows: [Set] + var wallHeights: [Float?] + var walked: [Float?] + var pastLimits: [Float?] + var spans: [[ObservedSpan]] + var seen: ClosedRange? + var viewed: ClosedRange? + + init(_ map: CoverageMap) { + let cells = -40...40 + revision = map.revision + levels = SurfaceBand.allCases.map { band in cells.map { map.level(band, $0) } } + groundDepths = cells.map(map.groundDepth(at:)) + hiddenDepthRows = cells.map(map.groundDepthHiddenRows(at:)) + wallHeights = cells.map(map.wallSeenHeight(at:)) + walked = cells.map(map.walkedClearance(at:)) + pastLimits = WalkSide.allCases.flatMap { side in (0..<30).map { map.groundDepthPastLimit(side, $0) } } + spans = [map.wallSeenSpans(), map.groundDepthSpans(), map.facingSpans(), map.overheadSpans()] + seen = map.seenExtent + viewed = map.viewedExtent + } + } + + /// A map reusing kept views and one working them all out, changed in step. + struct Twin { + var cached: CoverageMap + var full: CoverageMap + + init(_ map: CoverageMap = CoverageMap(wall: standardWall())) { + cached = map + full = map + full.reusesRebuildViews = false + } + + mutating func step(_ name: String, _ change: (inout CoverageMap) throws -> Void) rethrows { + try change(&cached) + try change(&full) + #expect(cached.stateDifferences(from: full) == [], "state after \(name)") + #expect(Readout(cached) == Readout(full), "readers after \(name)") + var later = (cached, full) + for (index, camera) in RebuildViewCacheTests.depthlessFrames.enumerated() { + later.0.observe(camera, trackingNormal: true, time: 1000 + Double(index)) + later.1.observe(camera, trackingNormal: true, time: 1000 + Double(index)) + } + #expect(later.0.stateDifferences(from: later.1) == [], "depthless views after \(name)") + #expect(full.rebuildViewCounts.reused == 0) + } + + mutating func observeAll(times: [Double]) { + for (index, frame) in RebuildViewCacheTests.depthFrames.enumerated() { + step("observing frame \(index)") { $0.observe(frame.camera, trackingNormal: true, time: times[index], depth: frame.depth) } + } + } + + mutating func setSurface(_ name: String) { + let spans = RebuildViewCacheTests.surfaces[name]! + step("far surface \(name)") { $0.setFarSurface(spans) } + } + } + + /// Store-completion order: the frames are observed in an order other than their times'. + static func shuffledTimes() -> [Double] { + depthFrames.indices.map { Double(($0 * 7) % depthFrames.count) } + } + + @Test func farSurfaceTransitionsMatchAFullRebuild() { + var twin = Twin() + twin.observeAll(times: Self.shuffledTimes()) + for name in [ + "far wall", "far wall and far away", "far wall, jittered within its step", "far wall, one step nearer", + "far wall with a nearer plane", "far wall over part of the stretch", "far away", "none", "far wall", "none", + ] { + twin.setSurface(name) + } + twin.step("a depthless frame") { $0.observe(Self.depthlessFrames[0], trackingNormal: true, time: 50) } + twin.setSurface("far wall") + } + + @Test func wallMovesShiftSkippedAndWithdrawnCellsAsAFullRebuild() { + var twin = Twin() + twin.observeAll(times: Self.shuffledTimes()) + twin.setSurface("far wall") + twin.step("skipping a cell") { $0.markSkipped(.wall, $0.cellRange(5)) } + twin.step("withdrawing a cell") { $0.withdrawClaims(over: $0.cellRange(2)) } + twin.setSurface("far wall, one step nearer") + let width = CoverageConfig().cellWidth + // A whole cell, then two moves of 0.4 cells: the second carries `pendingShift` over a + // whole cell. + for (index, shift) in [width, 0.4 * width, 0.4 * width].enumerated() { + twin.step("meter move \(index)") { map in + var moved = map.wall + moved.meter.x -= shift + map.updateWall(moved) + } + twin.setSurface(index % 2 == 0 ? "far wall" : "far wall with a nearer plane") + } + twin.step("a measured ground") { map in + var moved = map.wall + moved.groundY += 0.02 + map.updateWall(moved) + } + twin.setSurface("far away") + } + + @Test func limitsCornerCorrectionAndGroundMatchAFullRebuild() throws { + var twin = Twin() + twin.observeAll(times: Self.depthFrames.indices.map(Double.init)) + twin.setSurface("far wall") + twin.step("right end") { $0.setEnd(.right, at: 0.6) } + twin.step("right end a limit") { $0.setEndIsLimit(.right, true) } + twin.setSurface("far wall, one step nearer") + twin.setSurface("far away") + twin.step("left end a limit") { map in + map.setEnd(.left, at: -1.2) + map.setEndIsLimit(.left, true) + } + twin.setSurface("far wall") + try twin.step("corner after the limit end") { map in + _ = try map.turnCorner(.right, meeting: SIMD3(0.6, 0, -2), outward: SIMD3(1, 0, 0), source: .plane) + } + twin.setSurface("far wall over part of the stretch") + twin.step("anchor correction") { $0.apply(YawCorrection(yaw: 0.005, translation: SIMD3(0.01, 0, 0.004))) } + twin.setSurface("far wall") + twin.step("guessed ground") { $0.heightError = 0.3 } + twin.setSurface("far wall, jittered within its step") + twin.step("measured ground") { $0.heightError = 0 } + twin.step("wall line source") { $0.setWallLineSource(.plane) } + twin.step("overhead view") { _ = $0.recordOverhead(portraitCamera(at: SIMD3(0, 1.4, 2.6), forward: SIMD3(0, 0.5, -1)), trackingNormal: true) } + twin.step("walked path break") { $0.breakWalkedPath() } + twin.step("clearing the left end") { $0.clearEnd(.left) } + twin.setSurface("none") + } + + /// A frame's views depend on the lookup where its depth readings met something, not only where + /// its samples lie. From 3 m out, beyond a 1.1 m fence 2 m out, a phone turned along the wall + /// sees the ground behind the fence blocked by it. A sample there lies short of the fence's + /// surface, so whether it is past the space turns on the lookup at the cell where the reading + /// met the fence, nearer the phone along the wall. Cutting the fence's surface back over those + /// cells only must work the views out again. A cache keyed on the samples' cells alone keeps + /// the old answer. + @Test func viewsFollowTheLookupWhereReadingsMetTheFence() { + let scene = standardScene(boxes: [(SIMD3(-10, 0, 2.0), SIMD3(10, 1.1, 2.1))]) + let pitch: Float = 25 * .pi / 180 + var twin = Twin() + var time = 0.0 + for s in [Float(2), 3, 4] { + for yaw in [Float(0.9), -0.9] { + let camera = portraitCamera(at: SIMD3(s, 1.4, 3.0), forward: SIMD3(sin(yaw) * cos(pitch), -sin(pitch), -cos(yaw) * cos(pitch))) + let depth = renderDepth(scene, from: camera) + time += 1 + let t = time + twin.step("fence frame at s \(s), yaw \(yaw)") { $0.observe(camera, trackingNormal: true, time: t, depth: depth) } + } + } + func fence(from low: Float, to high: Float) -> [ObservedSpan] { + let plane = WallPlaneEvidence( + id: "fence", kind: .wall, center: SIMD3((low + high) / 2, 0.55, 2.0), normal: SIMD3(0, 0, -1), + boundary: [SIMD3(low, 0, 2.0), SIMD3(high, 0, 2.0), SIMD3(high, 1.1, 2.0), SIMD3(low, 1.1, 2.0)]) + return FarSurface.spans(planes: [plane], wall: standardWall(), over: -4...8) + } + let whole = fence(from: -4, to: 8) + twin.step("the whole fence") { $0.setFarSurface(whole) } + for cut in [Float(0.5), 1.0, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0, 4.5, 5.0, 5.5] { + for (low, high) in [(Float(-4), cut), (cut, Float(8))] { + let part = fence(from: low, to: high) + twin.step("fence over s \(low)...\(high)") { $0.setFarSurface(part) } + twin.step("the whole fence again") { $0.setFarSurface(whole) } + } + } + #expect(twin.cached.rebuildViewCounts.reused > 0) + } + + /// The counts show views reused while their inputs hold and worked out again when any changes. + @Test func keptViewsAreReusedOnlyWhileTheirInputsHold() { + var twin = Twin() + twin.observeAll(times: Self.depthFrames.indices.map(Double.init)) + let frames = Self.depthFrames.count + twin.setSurface("far wall") + // The first rebuild works out the bands and the depth rows of every frame. + #expect(twin.cached.rebuildViewCounts.reused == 0) + #expect(twin.cached.rebuildViewCounts.computed == 2 * frames) + twin.setSurface("far wall and far away") + #expect(twin.cached.rebuildViewCounts.reused == 2 * frames) + #expect(twin.cached.rebuildViewCounts.computed == 2 * frames) + // A value change at cells the corridor frames consulted: those work theirs out again. + let before = twin.cached.rebuildViewCounts + twin.setSurface("far wall, one step nearer") + let after = twin.cached.rebuildViewCounts + #expect(after.computed > before.computed) + #expect(after.reused > before.reused) + // An end changes the basis: every view is worked out again. + twin.step("right end") { $0.setEnd(.right, at: 3) } + twin.setSurface("far wall") + #expect(twin.cached.rebuildViewCounts.computed == after.computed + 2 * frames) + #expect(twin.cached.rebuildViewCounts.reused == after.reused) + } + + /// Seeded runs of every change the map takes, in random order. + @Test(arguments: [UInt64(1), 2, 3]) + func seededSequencesMatchAFullRebuild(seed: UInt64) { + var random = SplitMix(state: seed) + var twin = Twin() + let surfaces = Self.surfaces.keys.sorted() + var time = 0.0 + for index in 0..<60 { + time += 1 + let at = time + switch random.below(12) { + case 0, 1: + let frame = Self.depthFrames[random.below(Self.depthFrames.count)] + // Times out of order now and then, as photos finish storing. + let t = random.below(4) == 0 ? at - 3.5 : at + twin.step("\(index): observe with depth") { $0.observe(frame.camera, trackingNormal: true, time: t, depth: frame.depth) } + case 2: + let camera = Self.depthlessFrames[random.below(Self.depthlessFrames.count)] + twin.step("\(index): observe without depth") { $0.observe(camera, trackingNormal: true, time: at) } + case 3, 4, 5: + twin.setSurface(surfaces[random.below(surfaces.count)]) + case 6: + let side: WalkSide = random.below(2) == 0 ? .left : .right + let s = side == .left ? -0.5 - Float(random.below(10)) * 0.2 : 0.4 + Float(random.below(10)) * 0.2 + let limit = random.below(2) == 0 + twin.step("\(index): end \(side) at \(s), limit \(limit)") { map in + map.setEnd(side, at: s) + map.setEndIsLimit(side, limit) + } + case 7: + let side: WalkSide = random.below(2) == 0 ? .left : .right + twin.step("\(index): clear \(side) end") { $0.clearEnd(side) } + case 8: + let shift = Float(random.below(9)) * 0.05 - 0.2 + twin.step("\(index): meter move \(shift)") { map in + var moved = map.wall + moved.meter.x += shift + map.updateWall(moved) + } + case 9: + let yaw = Float(random.below(5)) * 0.002 - 0.004 + twin.step("\(index): anchor correction \(yaw)") { $0.apply(YawCorrection(yaw: yaw, translation: SIMD3(yaw, 0, -yaw))) } + case 10: + let cell = random.below(20) - 10 + if random.below(2) == 0 { + twin.step("\(index): skip cell \(cell)") { $0.markSkipped(.ground, $0.cellRange(cell)) } + } else { + twin.step("\(index): withdraw cell \(cell)") { $0.withdrawClaims(over: $0.cellRange(cell)) } + } + default: + let error: Float = random.below(2) == 0 ? 0.3 : 0 + twin.step("\(index): height error \(error)") { $0.heightError = error } + } + } + } + + struct SplitMix { + var state: UInt64 + mutating func below(_ bound: Int) -> Int { + state &+= 0x9E37_79B9_7F4A_7C15 + var z = state + z = (z ^ (z >> 30)) &* 0xBF58_476D_1CE4_E5B9 + z = (z ^ (z >> 27)) &* 0x94D0_49BB_1331_11EB + return Int((z ^ (z >> 31)) % UInt64(bound)) + } + } +} From 0b35f65c16654262e18f08265c76b2323b1b29bd Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Fri, 2 Oct 2026 09:45:05 -0500 Subject: [PATCH 41/48] ios: the refused-upload test lets the demo finish its gap; skipping is checked frozen (#191) testRejectedUploadGoesBackToReview tapped "I can't get there" on the review's own gap request while the demo's gap script was covering that request and sending by itself, about 3.9 s after the request opened. The tap's event came 3.2 to 3.5 s into the wait when the test passed (runs 37001843593, 37001782118). On runs 37013846307 and 37013940503 at b926c925 it came after about 4.3 s, the button had gone, and the tap failed. The test now lets the script finish and goes on to the spot check, keeping its refused-upload, no "Try again", back-to-review, unanswered-ground, resend, spot-check and result assertions with the same waits. testSkippingTheGapRequestSends checks the skip on its own: frozen (-uiDemoFreeze), the gap script never runs, and "I can't get there" must open the upload screen. No other UI test tapped it; ScreenStatesUITests' controls table only checks that the button is offered. Test-only; no app code changes. Co-Authored-By: Claude Code --- .../ScreenStatesUITests.swift | 28 ++++++++++++++++--- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/ios/HouseScanUITests/ScreenStatesUITests.swift b/ios/HouseScanUITests/ScreenStatesUITests.swift index 878ec5a03..c91c21793 100644 --- a/ios/HouseScanUITests/ScreenStatesUITests.swift +++ b/ios/HouseScanUITests/ScreenStatesUITests.swift @@ -572,8 +572,15 @@ final class ScreenStatesUITests: XCTestCase { /// The pass sample (`-uiDemoPass`) lists no view to take, so the resend goes straight to the /// result. The review sample asked for one more, and one wait covered a second capture, two /// uploads and the server's follow-up; on run 36736877861 it ran out with the app still - /// uploading at 60% (#191). Skipping the review's own view sends at once, so each wait below - /// covers one step. + /// uploading at 60% (#191). + /// + /// The review's own view is left to the demo, which covers it and sends by itself: 1.2 s, then + /// six 0.45 s steps to covered, then 1.6 s (`DemoEngine.gapScript`). This test used to tap + /// "I can't get there" first, which raced that script. Counted from the start of the wait for + /// the view, the tap's event came 3.2 to 3.5 s in when it passed (runs 37001843593 and + /// 37001782118). On run 37013846307 it came at 4.3 s, and on 37013940503 the tap began at + /// 4.5 s; both times the button had gone. Skipping is checked frozen in + /// `testSkippingTheGapRequestSends`. @MainActor func testRejectedUploadGoesBackToReview() throws { continueAfterFailure = false @@ -590,13 +597,26 @@ final class ScreenStatesUITests: XCTestCase { XCTAssertTrue(element(app, "review.unanswered").waitForExistence(timeout: 5)) tap(app, "action.confirmFeatures") XCTAssertTrue(element(app, "screen.gapRequest").waitForExistence(timeout: 10), "the review must open its own view") + tap(app, "action.spotClear", timeout: 30) + XCTAssertTrue(element(app, "screen.result").waitForExistence(timeout: 20)) + } + + /// "I can't get there" on a gap request sends the scan. Frozen, the demo's gap script never + /// runs, so the request can't finish itself before the tap. + @MainActor + func testSkippingTheGapRequestSends() throws { + continueAfterFailure = false + let app = XCUIApplication() + app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoFreeze", "-uiDemoPhase", "gapRequest"] + app.launch() + XCTAssertTrue(element(app, "screen.gapRequest").waitForExistence(timeout: 15)) // The card's reply ignores taps for a moment after it appears (`InstructionCard.replyLock`). let skip = element(app, "action.skipGap") let ready = XCTNSPredicateExpectation(predicate: NSPredicate(format: "isEnabled == true AND isHittable == true"), object: skip) XCTAssertEqual(XCTWaiter().wait(for: [ready], timeout: 10), .completed, "I can't get there never took taps") skip.tap() - tap(app, "action.spotClear", timeout: 30) - XCTAssertTrue(element(app, "screen.result").waitForExistence(timeout: 20)) + XCTAssertTrue(element(app, "screen.uploading").waitForExistence(timeout: 10), "skipping must send the scan") + XCTAssertFalse(element(app, "screen.gapRequest").exists) } /// #65 soft gate: with the ground or a window's question unanswered, the first "Looks From 7c617dc7623674911bf51b17a79a8e1e9c788da0 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Fri, 2 Oct 2026 13:25:36 -0500 Subject: [PATCH 42/48] ios: a placement answer is shown only when it names the scene that was sent The placement client decoded the answer's stats.input_sha256 but never compared it with what it sent (#144). The server hashes the exact scene bytes it parses: for a bare scene.json that is the request body (server/api.py _parse, server/scene.py). The result schema requires the field as 64 lowercase hex digits. HTTPResultClient now runs ResultBinding.check on every 2xx answer before returning it. The check reads stats.input_sha256 with JSONDecoder, requires the schema's form, and compares it with the SHA-256 of the bytes sent. It fails closed: - an answer it can't read, or one without the field, is refused as noInputHash; - a value that isn't 64 lowercase hex digits is refused as malformedInputHash; - a hash for another scene is refused as mismatch. Each refusal reaches the existing "We couldn't read the server's answer" recovery and isn't retried. SampleResultClient answers no particular scene (its hash is all zeros), so it stays explicitly unchecked. The general PlacementResult decoder and the test fixtures are unchanged. This is defence in depth. Nothing shows the current server sending an answer for another scene. ResultBindingTests is built from a real server answer to the synthetic replay. It covers a matching answer, a mismatch, an absent, null or numeric hash, an uppercase, short or non-hex hash, a body that isn't JSON, and a mismatched answer carrying an unparseable number elsewhere. Co-Authored-By: Claude Code --- ios/HouseScan/Runtime/ResultClient.swift | 9 +- .../HouseScanKit/Scene/ResultBinding.swift | 56 ++++++++++++ .../ResultBindingTests.swift | 87 +++++++++++++++++++ 3 files changed, 150 insertions(+), 2 deletions(-) create mode 100644 ios/HouseScanKit/Sources/HouseScanKit/Scene/ResultBinding.swift create mode 100644 ios/HouseScanKit/Tests/HouseScanKitTests/ResultBindingTests.swift diff --git a/ios/HouseScan/Runtime/ResultClient.swift b/ios/HouseScan/Runtime/ResultClient.swift index 09a00d3f2..567884fc2 100644 --- a/ios/HouseScan/Runtime/ResultClient.swift +++ b/ios/HouseScan/Runtime/ResultClient.swift @@ -13,7 +13,9 @@ protocol ResultClient: AnyObject { /// the body and `Content-Type: application/json`. Only the JSON goes: the server's solver reads no /// photos and skips the image check for a bare scene.json, so the keyframes stay on the phone /// (and in the scan folder's `scan.zip`, which leaves only if the homeowner shares it). The -/// response body is the result JSON. +/// response body is the result JSON, and it is returned only when it names the scene sent: its +/// `stats.input_sha256` must be present and be the hash of these exact bytes (`ResultBinding`). +/// Any other answer throws, and the upload screen treats it as an answer it couldn't read. @MainActor final class HTTPResultClient: ResultClient { let serverURL: URL @@ -42,6 +44,7 @@ final class HTTPResultClient: ResultClient { status: http.statusCode, body: String(decoding: data.prefix(300), as: UTF8.self), retryAfter: http.value(forHTTPHeaderField: "Retry-After")) } + try ResultBinding.check(answer: data, submittedScene: scene) progress(1) return data } @@ -137,7 +140,9 @@ final class UploadProgressDelegate: NSObject, URLSessionTaskDelegate, Sendable { } /// Answers with the bundled SampleResult.json, for tests and demos without a server. The result -/// is flagged `isSample` so the screen says it is not a real analysis. +/// is flagged `isSample` so the screen says it is not a real analysis. It answers no particular +/// scene (its `input_sha256` is all zeros), so it is deliberately not checked with +/// `ResultBinding`. @MainActor final class SampleResultClient: ResultClient { let isSample = true diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Scene/ResultBinding.swift b/ios/HouseScanKit/Sources/HouseScanKit/Scene/ResultBinding.swift new file mode 100644 index 000000000..a80830ab9 --- /dev/null +++ b/ios/HouseScanKit/Sources/HouseScanKit/Scene/ResultBinding.swift @@ -0,0 +1,56 @@ +import Foundation + +/// Ties a placement answer to the scene this phone sent. The server writes the SHA-256 of the +/// scene bytes it parsed into `stats.input_sha256`: for a bare scene.json that is the request body +/// itself (server/api.py `_parse` passes the body to `parse_scene`, which hashes it). The result +/// schema requires the field as 64 lowercase hex digits (server/schemas/result.schema.json). +/// +/// An answer is accepted only when it carries that hash and the hash is of the bytes sent. An +/// answer for another scene, or one whose hash is missing or unreadable, is never shown. This is +/// a defence against a mismatched response; nothing shows the current server sending one. +public enum ResultBinding { + public enum Refusal: Error, Equatable, CustomStringConvertible { + /// The answer has no `stats.input_sha256` string, or isn't JSON this check can read. + case noInputHash + /// `stats.input_sha256` isn't 64 lowercase hex digits. + case malformedInputHash(String) + /// The answer names another scene. Both hashes are lowercase hex. + case mismatch(submitted: String, declared: String) + + public var description: String { + switch self { + case .noInputHash: "answer has no readable stats.input_sha256" + case .malformedInputHash(let value): "answer's stats.input_sha256 is malformed: \(value.prefix(80))" + case .mismatch(let submitted, let declared): "answer is for scene \(declared), not the scene sent (\(submitted))" + } + } + } + + /// Throws a `Refusal` unless `answer` declares `stats.input_sha256` as the SHA-256 of `scene`. + /// Anything this can't read refuses the answer: a check that skipped unreadable metadata would + /// let a mismatched answer through whenever the general decoder is more lenient than this one. + public static func check(answer: Data, submittedScene scene: Data) throws { + let declared: String + do { + declared = try JSONDecoder().decode(Answer.self, from: answer).stats.inputSHA256 + } catch { + throw Refusal.noInputHash + } + guard isSHA256Hex(declared) else { throw Refusal.malformedInputHash(declared) } + let submitted = PacketFiles.sha256(scene) + guard declared == submitted else { throw Refusal.mismatch(submitted: submitted, declared: declared) } + } + + /// The schema's form for the field, `^[0-9a-f]{64}$`. + static func isSHA256Hex(_ text: String) -> Bool { + text.utf8.count == 64 && text.utf8.allSatisfy { (0x30...0x39).contains($0) || (0x61...0x66).contains($0) } + } + + private struct Answer: Decodable { + struct Stats: Decodable { + var inputSHA256: String + enum CodingKeys: String, CodingKey { case inputSHA256 = "input_sha256" } + } + var stats: Stats + } +} diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/ResultBindingTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/ResultBindingTests.swift new file mode 100644 index 000000000..d8e6134b2 --- /dev/null +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/ResultBindingTests.swift @@ -0,0 +1,87 @@ +import Foundation +import HouseScanKit +import Testing + +/// A placement answer from the server is shown only for the scene that was sent: its +/// `stats.input_sha256` must be present, in the schema's form, and the SHA-256 of those exact +/// bytes. Built from a real server answer to the synthetic replay. +@Suite struct ResultBindingTests { + static let scene = Data(#"{"note":"the bytes this phone sent"}"#.utf8) + static let fixtureHash = "2cb165cd4d4e9340496d90a589678ccbbe8ae6720d9d73bcae427a6b22075cb7" + static let hashLine = "\"input_sha256\": \"\(fixtureHash)\"," + + static func answer() throws -> String { + let url = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + .appendingPathComponent("Schemas/server-answer-synthetic-wall.json") + let text = String(decoding: try Data(contentsOf: url), as: UTF8.self) + #expect(text.contains(hashLine)) + return text + } + + /// The real answer with its `input_sha256` line replaced by `line` (empty removes it). + static func answer(replacingHashLineWith line: String) throws -> Data { + Data(try answer().replacingOccurrences(of: hashLine, with: line).utf8) + } + + static func answer(declaring hash: String) throws -> Data { + try answer(replacingHashLineWith: "\"input_sha256\": \"\(hash)\",") + } + + static func refusal(_ data: Data) throws -> ResultBinding.Refusal { + let refusal = try #require(throws: ResultBinding.Refusal.self) { + try ResultBinding.check(answer: data, submittedScene: Self.scene) + } + // Every refusal is the "couldn't read the answer" failure: back to the review, no retry. + #expect(UploadFailureKind.classify(refusal) == .unreadableAnswer) + #expect(!UploadFailureKind.classify(refusal).retryable) + return refusal + } + + @Test func anAnswerForTheSceneSentPassesAndDecodes() throws { + let data = try Self.answer(declaring: PacketFiles.sha256(Self.scene)) + try ResultBinding.check(answer: data, submittedScene: Self.scene) + #expect(try PlacementResult.decode(data).decision == .manualReview) + } + + @Test func anAnswerForAnotherSceneIsRefused() throws { + let other = PacketFiles.sha256(Data("another scene".utf8)) + #expect(try Self.refusal(Self.answer(declaring: other)) == .mismatch(submitted: PacketFiles.sha256(Self.scene), declared: other)) + } + + /// No readable hash refuses the answer, whatever the general decoder would make of it. + @Test(arguments: [ + ("absent", ""), + ("null", "\"input_sha256\": null,"), + ("a number", "\"input_sha256\": 7,"), + ]) + func anAnswerWithoutAHashIsRefused(_ kind: String, line: String) throws { + #expect(try Self.refusal(Self.answer(replacingHashLineWith: line)) == .noInputHash) + } + + /// The schema's form is 64 lowercase hex digits; the right digits in capitals, or too few, + /// are refused rather than compared. + @Test(arguments: ["UPPER", "short", "notHex"]) + func aMalformedHashIsRefused(_ kind: String) throws { + let good = PacketFiles.sha256(Self.scene) + let value = switch kind { + case "UPPER": good.uppercased() + case "short": String(good.dropLast()) + default: String(good.dropLast()) + "z" + } + #expect(try Self.refusal(Self.answer(declaring: value)) == .malformedInputHash(value)) + } + + @Test func anAnswerThatIsNotJSONIsRefused() throws { + #expect(try Self.refusal(Data("not json".utf8)) == .noInputHash) + } + + /// A field this check doesn't read can't make it skip the comparison: an answer for another + /// scene that also carries a number too large for a Double is still refused. + @Test func anUnusualIgnoredFieldDoesNotSkipTheCheck() throws { + let other = PacketFiles.sha256(Data("another scene".utf8)) + let text = try Self.answer().replacingOccurrences(of: Self.hashLine, with: "\"input_sha256\": \"\(other)\", \"extra\": 1e400,") + #expect(throws: ResultBinding.Refusal.self) { + try ResultBinding.check(answer: Data(text.utf8), submittedScene: Self.scene) + } + } +} From 8c47d6789d4b5a962776515f276e0b76ce6f5a6f Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Fri, 2 Oct 2026 13:25:10 -0500 Subject: [PATCH 43/48] ios: results say they need an installer's review, never that one is coming The app shows the server's answer and contacts nobody, yet the result and the scan's skip hints promised an installer's action: "An installer will confirm", "An installer will check this", "An installer confirms this on site", "an installer reviews every result for now", "The spot an installer will check" and "An installer will look at it instead". Nothing sends a scan to an installer or queues a review. The result now says what it needs: - The headline for a result a person has to settle reads "Needs an installer's review"; the other three answers are unchanged. - An unsure check that no photo can settle, and a "Still needed" view the phone can't capture, read "Needs an installer to check" (one constant). - The footnotes read "An installer has to confirm the spot on site before a battery goes in." and "The placement rules aren't final yet, so every result needs an installer's review for now." - The AR view says "The spot an installer needs to check". - Skip hints on the walk, the gap request, the close-up and the spot check say an installer "would need to" look, read or check instead. - The offline sample's summary says the spot "needs an installer's review". FullFlowUITests.testFullFlowFromReplay, which ends on the bundled sample (a manual review under unapproved rules), now asserts the rendered headline and both installer footnotes word for word. It skips them against a real server, whose answer can be any decision. The server's own summary and reasons aren't covered. Co-Authored-By: Claude Code --- ios/HouseScan/UI/Copy/ScanCopy.swift | 22 +++++++++++-------- ios/HouseScan/UI/Copy/SpotCheckCopy.swift | 6 ++--- ios/HouseScan/UI/Preview/DemoEngine.swift | 2 +- .../UI/Screens/GapRequestScreen.swift | 8 +++---- .../UI/Screens/MeterCloseUpScreen.swift | 2 +- ios/HouseScan/UI/Screens/ResultARScreen.swift | 4 ++-- ios/HouseScan/UI/Screens/ResultScreen.swift | 2 +- ios/HouseScanUITests/FullFlowUITests.swift | 20 ++++++++++++++++- 8 files changed, 44 insertions(+), 22 deletions(-) diff --git a/ios/HouseScan/UI/Copy/ScanCopy.swift b/ios/HouseScan/UI/Copy/ScanCopy.swift index 5c407f974..8c83adfa0 100644 --- a/ios/HouseScan/UI/Copy/ScanCopy.swift +++ b/ios/HouseScan/UI/Copy/ScanCopy.swift @@ -487,15 +487,15 @@ enum ScanCopy { static func reply(for step: GuidanceStep) -> (title: String, hint: String)? { switch step { case .aimAtGround, .aimAtWall: - (title: "Skip this spot", hint: "An installer will look at it instead.") + (title: "Skip this spot", hint: "An installer would need to look at it instead.") case .tiltUp: - (title: "Skip this", hint: "Skips the view above this part of the wall. An installer will look at it instead.") + (title: "Skip this", hint: "Skips the view above this part of the wall. An installer would need to look at it instead.") case .walk: - (title: "Can't get there", hint: "Ends the wall at the dashed line on the map. An installer will look at what's past it.") + (title: "Can't get there", hint: "Ends the wall at the dashed line on the map. An installer would need to look at what's past it.") case .markNextWall: - (title: "Can't get there", hint: "Skips this part of the wall. An installer will look at it instead.") + (title: "Can't get there", hint: "Skips this part of the wall. An installer would need to look at it instead.") case .seeBehind: - (title: cannotSeeBehind, hint: "Skips the part behind it. An installer will look at it instead.") + (title: cannotSeeBehind, hint: "Skips the part behind it. An installer would need to look at it instead.") case .findMeter, .aimAtWallForMeter, .holdOnMeter, .markEnd, .stepBack, .walkComplete, .gap: nil } @@ -598,7 +598,7 @@ enum ScanCopy { switch answer { case .fits: "A battery fits here" case .oneMoreLook: "One more look" - case .installer: "An installer will confirm" + case .installer: "Needs an installer's review" case .notHere: "Not on this wall" } } @@ -667,8 +667,8 @@ enum ScanCopy { static let wallNotMeasuredDetail = "The scan stopped before you walked along the wall on either side of your meter, so we can't tell where a battery would fit. Scan again and walk a few steps each way." static let scanAgain = "Scan again" - static let installerConfirms = "An installer confirms this on site." - static let rulesNotFinal = "The placement rules aren't final yet, so an installer reviews every result for now." + static let installerConfirms = "An installer has to confirm the spot on site before a battery goes in." + static let rulesNotFinal = "The placement rules aren't final yet, so every result needs an installer's review for now." // The server's result covers where the battery goes, not the panel itself. static let panelReview = "Your electrical panel still needs an electrician's review. This scan only covers where the battery can go." @@ -690,8 +690,12 @@ enum ScanCopy { static let shareScan = "Share scan" static let shareScanContents = "Your photos and measurements, for the House Scan team" + /// A check or a view no photo can settle. It states what the result needs, never that a + /// review was sent: the app only shows the server's answer and contacts nobody. + static let needsInstaller = "Needs an installer to check" + static func unsureNote(_ row: CheckRow) -> String { - row.needsPerson ? "An installer will check this" : "One more photo would settle this" + row.needsPerson ? Self.needsInstaller : "One more photo would settle this" } /// "Measured 3 ft 2 in. The rule is at least 3 ft, and the measurement can be off by about 4 in." diff --git a/ios/HouseScan/UI/Copy/SpotCheckCopy.swift b/ios/HouseScan/UI/Copy/SpotCheckCopy.swift index 26bf3de72..9640f394a 100644 --- a/ios/HouseScan/UI/Copy/SpotCheckCopy.swift +++ b/ios/HouseScan/UI/Copy/SpotCheckCopy.swift @@ -10,17 +10,17 @@ extension ScanCopy { static let spotClear = "It's clear" static let spotSomethingThere = "Something's there" static let spotClearHint = "Shows your result." - static let spotSomethingThereHint = "Leaves this area out of your scan and checks your wall again. An installer will look at it." + static let spotSomethingThereHint = "Leaves this area out of your scan and checks your wall again. An installer would need to look at it." static func spotAnswered(_ answer: SpotCheckAnswer) -> Instruction { switch answer { case .clear: Instruction(title: "Thanks, it's clear", detail: "Showing your result.") - case .somethingThere: Instruction(title: "Thanks, we'll leave that area out", detail: "Checking your wall again. An installer will look at what's there.") + case .somethingThere: Instruction(title: "Thanks, we'll leave that area out", detail: "Checking your wall again. An installer would need to look at what's there.") } } /// Said on the result when the homeowner said something stands where its spot is. - static let spotRefused = "You said something stands where this spot is. Your scan leaves that area out, and an installer will check it." + static let spotRefused = "You said something stands where this spot is. Your scan leaves that area out, and an installer would need to check it." /// "From 3 ft to 8 ft 6 in right of your meter": where the area runs. `spoken` spells the /// units out for VoiceOver. diff --git a/ios/HouseScan/UI/Preview/DemoEngine.swift b/ios/HouseScan/UI/Preview/DemoEngine.swift index ccd671705..d5f913929 100644 --- a/ios/HouseScan/UI/Preview/DemoEngine.swift +++ b/ios/HouseScan/UI/Preview/DemoEngine.swift @@ -1135,7 +1135,7 @@ final class DemoEngine: ScanActions { /// The offline sample result. Placeholder rules, so the decision is manual review. static let reviewSample = ResultPresentation( decision: .manualReview, - summary: "A spot 3 ft right of your meter fits, but it's close to a window, so an installer will confirm it.", + summary: "A spot 3 ft right of your meter fits, but it's close to a window, so it needs an installer's review.", policyApproved: false, spot: BatterySpot(span: 0.55...1.34, depth: 0.56, height: 1.1, offsetFromWall: 0.03), cableRoute: [SIMD2(0.16, 1.45), SIMD2(0.95, 1.45), SIMD2(0.95, 1.1)], diff --git a/ios/HouseScan/UI/Screens/GapRequestScreen.swift b/ios/HouseScan/UI/Screens/GapRequestScreen.swift index 0bc7729e5..f97618264 100644 --- a/ios/HouseScan/UI/Screens/GapRequestScreen.swift +++ b/ios/HouseScan/UI/Screens/GapRequestScreen.swift @@ -97,7 +97,7 @@ struct GapRequestScreen: View { Label("Show my result", systemImage: "checkmark.circle") } .buttonStyle(.secondary) - .accessibilityHint("Stops asking for views and shows your result. An installer will look at the parts you skip.") + .accessibilityHint("Stops asking for views and shows your result. An installer would need to look at the parts you skip.") .accessibilityIdentifier("action.showResult") .transition(.opacity) } @@ -119,9 +119,9 @@ struct GapRequestScreen: View { /// so the hint doesn't promise the result; "Show my result" does. private var skipHint: String { switch followUps { - case 0: "Skips this view. An installer will look at this part instead." - case 1: "Skips this view and checks your scan again. An installer will look at this part instead." - default: "Skips this view and goes on to the next one. An installer will look at this part instead." + case 0: "Skips this view. An installer would need to look at this part instead." + case 1: "Skips this view and checks your scan again. An installer would need to look at this part instead." + default: "Skips this view and goes on to the next one. An installer would need to look at this part instead." } } diff --git a/ios/HouseScan/UI/Screens/MeterCloseUpScreen.swift b/ios/HouseScan/UI/Screens/MeterCloseUpScreen.swift index 44df73a80..beba67873 100644 --- a/ios/HouseScan/UI/Screens/MeterCloseUpScreen.swift +++ b/ios/HouseScan/UI/Screens/MeterCloseUpScreen.swift @@ -74,7 +74,7 @@ struct MeterCloseUpScreen: View { if offerSkip { Button("Can't get a clear shot") { actions.skipCloseUp() } .buttonStyle(.secondaryProminent) - .accessibilityHint("Skips the close-up. An installer will read the meter instead.") + .accessibilityHint("Skips the close-up. An installer would need to read the meter instead.") .accessibilityIdentifier("action.skipCloseUp") .transition(.identity) } diff --git a/ios/HouseScan/UI/Screens/ResultARScreen.swift b/ios/HouseScan/UI/Screens/ResultARScreen.swift index d043f4316..ef697d885 100644 --- a/ios/HouseScan/UI/Screens/ResultARScreen.swift +++ b/ios/HouseScan/UI/Screens/ResultARScreen.swift @@ -57,7 +57,7 @@ struct ResultARScreen: View { guard result.spot != nil else { return "The cable run and clearances, drawn on your wall" } return result.spotIsClean ? "The battery, drawn on your wall at its spot" - : "An outline of the spot an installer will check, drawn on your wall" + : "An outline of the spot an installer needs to check, drawn on your wall" } private var instruction: Instruction { @@ -71,7 +71,7 @@ struct ResultARScreen: View { // check says so, as the result screen does. let title = result.decision == .pass && result.policyApproved && result.spotIsClean ? "Your battery could go here" - : "The spot an installer will check" + : "The spot an installer needs to check" let placement = ScanCopy.placement(result) guard !result.isSample else { // A sample spot drawn on the homeowner's real wall must not pass for their result. diff --git a/ios/HouseScan/UI/Screens/ResultScreen.swift b/ios/HouseScan/UI/Screens/ResultScreen.swift index 3e90850b5..57e1c27a1 100644 --- a/ios/HouseScan/UI/Screens/ResultScreen.swift +++ b/ios/HouseScan/UI/Screens/ResultScreen.swift @@ -553,7 +553,7 @@ private struct MissingList: View { .buttonStyle(TextActionStyle()) .accessibilityIdentifier("action.captureMissing") } else { - Label("An installer will check this", systemImage: "person.fill") + Label(ScanCopy.needsInstaller, systemImage: "person.fill") .font(.footnote.weight(.semibold)) .foregroundStyle(Palette.muted) } diff --git a/ios/HouseScanUITests/FullFlowUITests.swift b/ios/HouseScanUITests/FullFlowUITests.swift index 19b8de04d..9a1519faf 100644 --- a/ios/HouseScanUITests/FullFlowUITests.swift +++ b/ios/HouseScanUITests/FullFlowUITests.swift @@ -50,7 +50,25 @@ final class FullFlowUITests: XCTestCase { @MainActor func testFullFlowFromReplay() throws { let replay = Self.environment["HOUSESCAN_REPLAY"].flatMap { $0.isEmpty ? nil : $0 } ?? Self.fixture - try runFlow(replay: replay) + // A real server's answer can be any decision; the bundled sample is always a manual review. + let sample = Self.environment["HOUSESCAN_SERVER_URL"]?.isEmpty ?? true + try runFlow(replay: replay) { app, phase in + if sample, phase == "result" { Self.assertInstallerReviewCopy(app) } + } + } + + /// The sample result needs a person to settle it (`SampleResult.json`). The app only shows + /// the answer and contacts nobody, so its words say what the result needs, never that an + /// installer will confirm or review it. + @MainActor + private static func assertInstallerReviewCopy(_ app: XCUIApplication) { + func label(_ identifier: String) -> String? { + let element = app.descendants(matching: .any)[identifier].firstMatch + return element.exists ? element.label : nil + } + XCTAssertEqual(label("result.headline"), "Needs an installer's review") + XCTAssertEqual(label("result.installerConfirms"), "An installer has to confirm the spot on site before a battery goes in.") + XCTAssertEqual(label("result.rulesNotFinal"), "The placement rules aren't final yet, so every result needs an installer's review for now.") } /// The flow from the LiDAR fixture. Depth must show the bin in front of the wall: the wall map From 032eea5d29a6ac70f1751bc1d774e94fc0bd9d64 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Fri, 2 Oct 2026 14:07:59 -0500 Subject: [PATCH 44/48] ios: say why House Scan asks for local network access The developer flow in ios/README.md points the app at a House Scan server on a laptop on the same Wi-Fi. Info.plist already allows plain http to local-network addresses (NSAllowsLocalNetworking), but it had no NSLocalNetworkUsageDescription. iOS asks for local network access in that flow, so the prompt had no purpose text (#144, second half). The new string says the app connects to a House Scan server on the local network only when a developer has set one up for testing. It claims no Wi-Fi scanning. Homeowners use the hosted https server and never reach this. Checked with plutil -lint and a PlistBuddy read-back. Info.plist is hand-maintained (INFOPLIST_FILE in project.yml), so the project file is unchanged. The prompt on a physical phone is not yet checked. Co-Authored-By: Claude Code --- ios/Config/Info.plist | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ios/Config/Info.plist b/ios/Config/Info.plist index 1b5953a5b..d9fbfcb67 100644 --- a/ios/Config/Info.plist +++ b/ios/Config/Info.plist @@ -35,6 +35,8 @@ NSCameraUsageDescription House Scan uses the camera to measure the outside wall around your electric meter. + NSLocalNetworkUsageDescription + House Scan connects to a House Scan server on your local network only when a developer has set it up for testing. NSMotionUsageDescription House Scan records your phone's motion and air pressure while you scan and saves them with the scan. The readings stay on this phone unless you share the scan. UIApplicationSceneManifest From c73aa469158c8c45cca0d16959eba6d699e2a3e3 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Fri, 2 Oct 2026 14:13:03 -0500 Subject: [PATCH 45/48] ios: the on-site footnote names no spot; the unsure note is checked in Details (#199) "An installer has to confirm the spot on site before a battery goes in." shows on every result, including "Not on this wall" and "We couldn't measure your wall", which have no spot (Codex review on #199). It now reads "Before any battery goes in, an installer has to confirm where it goes on site." InstallerReviewUITests holds the demo's review sample still, opens Details and checks that the window check, which needs a person, reads "Needs an installer to check" while the ground check, which needs a photo, keeps "One more photo would settle this" (Greptile review on #199). XCUITest can't read accessibility hints, so the skip hints remain checked by review only. Co-Authored-By: Claude Code --- ios/HouseScan/UI/Copy/ScanCopy.swift | 4 +- ios/HouseScanUITests/FullFlowUITests.swift | 2 +- .../InstallerReviewUITests.swift | 42 +++++++++++++++++++ 3 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 ios/HouseScanUITests/InstallerReviewUITests.swift diff --git a/ios/HouseScan/UI/Copy/ScanCopy.swift b/ios/HouseScan/UI/Copy/ScanCopy.swift index 8c83adfa0..904b82fc8 100644 --- a/ios/HouseScan/UI/Copy/ScanCopy.swift +++ b/ios/HouseScan/UI/Copy/ScanCopy.swift @@ -667,7 +667,9 @@ enum ScanCopy { static let wallNotMeasuredDetail = "The scan stopped before you walked along the wall on either side of your meter, so we can't tell where a battery would fit. Scan again and walk a few steps each way." static let scanAgain = "Scan again" - static let installerConfirms = "An installer has to confirm the spot on site before a battery goes in." + /// Shown on every result, with or without a spot ("Not on this wall" has none), so it names + /// no spot. + static let installerConfirms = "Before any battery goes in, an installer has to confirm where it goes on site." static let rulesNotFinal = "The placement rules aren't final yet, so every result needs an installer's review for now." // The server's result covers where the battery goes, not the panel itself. static let panelReview = "Your electrical panel still needs an electrician's review. This scan only covers where the battery can go." diff --git a/ios/HouseScanUITests/FullFlowUITests.swift b/ios/HouseScanUITests/FullFlowUITests.swift index 9a1519faf..a1c3be375 100644 --- a/ios/HouseScanUITests/FullFlowUITests.swift +++ b/ios/HouseScanUITests/FullFlowUITests.swift @@ -67,7 +67,7 @@ final class FullFlowUITests: XCTestCase { return element.exists ? element.label : nil } XCTAssertEqual(label("result.headline"), "Needs an installer's review") - XCTAssertEqual(label("result.installerConfirms"), "An installer has to confirm the spot on site before a battery goes in.") + XCTAssertEqual(label("result.installerConfirms"), "Before any battery goes in, an installer has to confirm where it goes on site.") XCTAssertEqual(label("result.rulesNotFinal"), "The placement rules aren't final yet, so every result needs an installer's review for now.") } diff --git a/ios/HouseScanUITests/InstallerReviewUITests.swift b/ios/HouseScanUITests/InstallerReviewUITests.swift new file mode 100644 index 000000000..2604914d2 --- /dev/null +++ b/ios/HouseScanUITests/InstallerReviewUITests.swift @@ -0,0 +1,42 @@ +import XCTest + +/// The result says what it needs from a person, never that one was contacted: the app only shows +/// the server's answer. Held still on the demo's review sample (`DemoEngine.reviewSample`), whose +/// window check needs a person and whose ground check needs a photo. +final class InstallerReviewUITests: XCTestCase { + override func setUp() { + continueAfterFailure = false + } + + @MainActor + func testTheResultSaysAnInstallerIsNeededNotArranged() throws { + let app = XCUIApplication() + app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoFreeze", "-uiDemoPhase", "result"] + app.launch() + XCTAssertTrue(element(app, "screen.result").waitForExistence(timeout: 15)) + XCTAssertEqual(element(app, "result.headline").label, "Needs an installer's review") + XCTAssertEqual(element(app, "result.installerConfirms").label, "Before any battery goes in, an installer has to confirm where it goes on site.") + + // The full list of checks sits under Details, below the footnotes. + let details = element(app, "result.details") + XCTAssertTrue(details.waitForExistence(timeout: 10)) + for _ in 0..<4 where !details.isHittable { app.swipeUp() } + details.tap() + let window = element(app, "detail.check.window") + let ground = element(app, "detail.check.ground") + XCTAssertTrue(window.waitForExistence(timeout: 10), "the window check never appeared under Details") + // The two unsure checks keep their difference: a person settles one, a photo the other. + XCTAssertTrue(value(window).contains("Needs an installer to check"), "window check reads: \(value(window))") + XCTAssertTrue(value(ground).contains("One more photo would settle this"), "ground check reads: \(value(ground))") + } + + @MainActor + private func element(_ app: XCUIApplication, _ identifier: String) -> XCUIElement { + app.descendants(matching: .any)[identifier].firstMatch + } + + @MainActor + private func value(_ element: XCUIElement) -> String { + element.value as? String ?? "" + } +} From 6ec691228c43b00354088d8fac867f4ac5c4271b Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Fri, 2 Oct 2026 14:21:34 -0500 Subject: [PATCH 46/48] ios: a refused placement answer's log line carries no scene hash ScanEngine logs an upload failure's description with public privacy. ResultBinding.Refusal's description included the full SHA-256 of the scene sent, and for a malformed value the server's declared text (Greptile on #198). A scene's hash fingerprints the homeowner's scan, so it shouldn't be in the device log. The description now says what went wrong and, for a malformed value, its length. The hashes stay in the enum cases for callers and tests. aRefusalsDescriptionNamesNoHash checks all three refusal kinds. Co-Authored-By: Claude Code --- .../HouseScanKit/Scene/ResultBinding.swift | 6 ++++-- .../HouseScanKitTests/ResultBindingTests.swift | 15 +++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Scene/ResultBinding.swift b/ios/HouseScanKit/Sources/HouseScanKit/Scene/ResultBinding.swift index a80830ab9..869c59000 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Scene/ResultBinding.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Scene/ResultBinding.swift @@ -17,11 +17,13 @@ public enum ResultBinding { /// The answer names another scene. Both hashes are lowercase hex. case mismatch(submitted: String, declared: String) + /// Goes to the device log, so it names what went wrong but never a hash: a scene's hash + /// fingerprints a homeowner's scan. The values stay in the cases for callers and tests. public var description: String { switch self { case .noInputHash: "answer has no readable stats.input_sha256" - case .malformedInputHash(let value): "answer's stats.input_sha256 is malformed: \(value.prefix(80))" - case .mismatch(let submitted, let declared): "answer is for scene \(declared), not the scene sent (\(submitted))" + case .malformedInputHash(let value): "answer's stats.input_sha256 is malformed (\(value.utf8.count) bytes)" + case .mismatch: "answer's stats.input_sha256 is not the hash of the scene sent" } } } diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/ResultBindingTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/ResultBindingTests.swift index d8e6134b2..93b764da9 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/ResultBindingTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/ResultBindingTests.swift @@ -48,6 +48,21 @@ import Testing #expect(try Self.refusal(Self.answer(declaring: other)) == .mismatch(submitted: PacketFiles.sha256(Self.scene), declared: other)) } + /// The upload logs a refusal's description publicly, so it carries no scene hash. + @Test func aRefusalsDescriptionNamesNoHash() throws { + let sent = PacketFiles.sha256(Self.scene) + let other = PacketFiles.sha256(Data("another scene".utf8)) + let refusals: [ResultBinding.Refusal] = [ + try Self.refusal(Self.answer(declaring: other)), + try Self.refusal(Self.answer(declaring: String(other.dropLast()))), + try Self.refusal(Self.answer(replacingHashLineWith: "")), + ] + for refusal in refusals { + let text = String(describing: refusal) + #expect(!text.contains(sent) && !text.contains(other) && !text.contains(String(other.dropLast()))) + } + } + /// No readable hash refuses the answer, whatever the general decoder would make of it. @Test(arguments: [ ("absent", ""), From 94f18ff7ba3eed59f2ddd0456648ee63a7609f57 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Fri, 2 Oct 2026 15:06:16 -0500 Subject: [PATCH 47/48] ios: the frozen review-sample test drops its headline check (#199) The demo's review sample still offers a view to capture, so its headline is "One more look", not the installer answer (hosted run 37052663637). The installer headline stays checked on the bundled sample in testFullFlowFromReplay, which passed in that run. This test keeps the footnote and the two unsure notes. Co-Authored-By: Claude Code --- ios/HouseScanUITests/InstallerReviewUITests.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/ios/HouseScanUITests/InstallerReviewUITests.swift b/ios/HouseScanUITests/InstallerReviewUITests.swift index 2604914d2..d9eafe245 100644 --- a/ios/HouseScanUITests/InstallerReviewUITests.swift +++ b/ios/HouseScanUITests/InstallerReviewUITests.swift @@ -2,7 +2,9 @@ import XCTest /// The result says what it needs from a person, never that one was contacted: the app only shows /// the server's answer. Held still on the demo's review sample (`DemoEngine.reviewSample`), whose -/// window check needs a person and whose ground check needs a photo. +/// window check needs a person and whose ground check needs a photo. Its headline is "One more +/// look", since a view can still be captured; `FullFlowUITests.testFullFlowFromReplay` checks the +/// installer headline on the bundled sample. final class InstallerReviewUITests: XCTestCase { override func setUp() { continueAfterFailure = false @@ -14,7 +16,6 @@ final class InstallerReviewUITests: XCTestCase { app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoFreeze", "-uiDemoPhase", "result"] app.launch() XCTAssertTrue(element(app, "screen.result").waitForExistence(timeout: 15)) - XCTAssertEqual(element(app, "result.headline").label, "Needs an installer's review") XCTAssertEqual(element(app, "result.installerConfirms").label, "Before any battery goes in, an installer has to confirm where it goes on site.") // The full list of checks sits under Details, below the footnotes. From b3c117d160c3a5b7da6025a1c24014bc510ab97c Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Fri, 2 Oct 2026 15:41:26 -0500 Subject: [PATCH 48/48] test(ios): check geometry before tap hit points Read layout and enabled state from one snapshot per poll. Let real taps scroll bottom-edge controls before hit-point queries, and stop issuing reads when the original readiness deadline is exhausted. Co-Authored-By: Claude Code --- .../ScreenStatesUITests.swift | 80 ++++++--- ios/HouseScanUITests/TapReadiness.swift | 165 ++++++++++++++++++ 2 files changed, 217 insertions(+), 28 deletions(-) create mode 100644 ios/HouseScanUITests/TapReadiness.swift diff --git a/ios/HouseScanUITests/ScreenStatesUITests.swift b/ios/HouseScanUITests/ScreenStatesUITests.swift index c91c21793..3002e501d 100644 --- a/ios/HouseScanUITests/ScreenStatesUITests.swift +++ b/ios/HouseScanUITests/ScreenStatesUITests.swift @@ -519,6 +519,41 @@ final class ScreenStatesUITests: XCTestCase { XCTAssertEqual(app.buttons.matching(identifier: "action.deleteFeature").count, rowsBefore + 1) } + /// AX5 puts the review chips below the window. The helper must let the real tap scroll + /// before XCTest computes a hit point, then Cancel must return to the unchanged review. + @MainActor + func testTapHelperReachesAnOffscreenReviewChip() throws { + continueAfterFailure = false + let app = XCUIApplication() + app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoFreeze", "-uiDemoPhase", "markFeatures"] + Self.largestText + app.launch() + defer { app.terminate() } + XCTAssertTrue(element(app, "screen.markFeatures").waitForExistence(timeout: 15)) + let chip = app.buttons.matching(identifier: "feature.ac").firstMatch + let frame = try chip.snapshot().frame + let window = try app.windows.firstMatch.snapshot().frame + XCTAssertGreaterThan(frame.maxY, window.maxY, "the regression needs a chip below the window") + let rowsBefore = app.buttons.matching(identifier: "action.deleteFeature").count + tap(app, "feature.ac") + XCTAssertTrue(element(app, "action.markPoint").waitForExistence(timeout: 5), "the tap must open the marking camera") + tap(app, "action.cancelMarking") + XCTAssertTrue(element(app, "action.confirmFeatures").waitForExistence(timeout: 5)) + XCTAssertEqual(app.buttons.matching(identifier: "action.deleteFeature").count, rowsBefore) + } + + @MainActor + func testTapHelperClosesARThroughTheDoneButton() throws { + continueAfterFailure = false + let app = XCUIApplication() + app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoFreeze", "-uiDemoPhase", "resultAR"] + app.launch() + defer { app.terminate() } + XCTAssertTrue(element(app, "screen.resultAR").waitForExistence(timeout: 15)) + tap(app, "action.closeAR") + XCTAssertTrue(element(app, "screen.result").waitForExistence(timeout: 10), "Done must return to the result") + XCTAssertTrue(element(app, "screen.resultAR").waitForNonExistence(timeout: 5)) + } + /// While the phone has lost its place the review can't start a mark, which taps into the /// scene: the chips give way to a line saying so, and "Looks complete" still sends the scan. @MainActor @@ -1257,41 +1292,30 @@ final class ScreenStatesUITests: XCTestCase { /// and a tap there misses without an error (the button flow at 577acc4 never opened the mark /// tray). /// - /// A control below the bottom edge of a scrolling screen (the review's "Add something" chips, - /// the result's Details) is never hittable where it is. `tap()` scrolls to it and the wait - /// doesn't, so once it has had a moment to settle it is tapped where it is. + /// The wait reads each button's frame and enabled state from one fresh snapshot. The old + /// separate existence/layout/hittability waits repeatedly resolved the AR Done button, + /// consuming its 20 s budget in PR run 37022177313 attempt 1. One wait keeps that budget. + /// Bottom-edge controls use XCTest's ordinary scroll-to-visible tap, before hit-point queries. @MainActor private func tap(_ app: XCUIApplication, _ identifier: String, timeout: TimeInterval = 20) { - let target = element(app, identifier) - let deadline = Date().addingTimeInterval(timeout) - XCTAssertTrue(target.waitForExistence(timeout: timeout), "missing \(identifier)") - // An element can exist before its screen is laid out, with an infinite frame, and asking - // whether it is hittable then fails the test outright ("Activation point invalid"; - // result.details in run 36820279307, mid-transition from the spot check). - while !Self.laidOut(target.frame), Date() < deadline { Thread.sleep(forTimeInterval: 0.1) } - guard Self.laidOut(target.frame) else { - XCTFail("\(identifier) was never laid out: \(target.frame)") + let target = app.buttons.matching(identifier: identifier).firstMatch + let deadline = ProcessInfo.processInfo.systemUptime + timeout + var state = TapReadiness.State.missing + var lastObservation = state + let ready = XCTNSPredicateExpectation(predicate: NSPredicate { _, _ in + state = TapReadiness.evaluate(deadline: deadline, now: { ProcessInfo.processInfo.systemUptime }, read: { + ElementRead.snapshot(target).map { .init(frame: $0.frame, isEnabled: $0.isEnabled) } + }, window: { ElementRead.snapshot(app.windows.firstMatch)?.frame }, hittable: { target.isHittable }) + if state != .expired { lastObservation = state } + return state == .ready + }, object: nil) + guard XCTWaiter().wait(for: [ready], timeout: timeout) == .completed else { + XCTFail("\(identifier) not ready for tap: \(state.rawValue); last observation: \(lastObservation.rawValue)") return } - let settled = waitUntilHittable(target, timeout: min(3, max(0, deadline.timeIntervalSinceNow))) - if settled || target.frame.maxY > app.windows.firstMatch.frame.maxY { - target.tap() - return - } - XCTAssertTrue(waitUntilHittable(target, timeout: max(1, deadline.timeIntervalSinceNow)), "missing or not tappable: \(identifier)") target.tap() } - private static func laidOut(_ frame: CGRect) -> Bool { - [frame.minX, frame.minY, frame.width, frame.height].allSatisfy(\.isFinite) && !frame.isEmpty - } - - @MainActor - private func waitUntilHittable(_ target: XCUIElement, timeout: TimeInterval) -> Bool { - let hittable = XCTNSPredicateExpectation(predicate: NSPredicate(format: "isHittable == true"), object: target) - return XCTWaiter().wait(for: [hittable], timeout: timeout) == .completed - } - /// The card's reply with these words. @MainActor private func reply(_ app: XCUIApplication, _ title: String) -> XCUIElement { diff --git a/ios/HouseScanUITests/TapReadiness.swift b/ios/HouseScanUITests/TapReadiness.swift new file mode 100644 index 000000000..07da6bc84 --- /dev/null +++ b/ios/HouseScanUITests/TapReadiness.swift @@ -0,0 +1,165 @@ +import XCTest + +/// Decides which live reads are safe before a real element tap. The clock and reads are +/// explicit so a slow AX query exhausting the budget can be tested without a simulator stall. +enum TapReadiness { + struct Read { + var frame: CGRect + var isEnabled: Bool + } + + enum State: String { + case ready + case missing = "button missing or snapshot unreadable" + case invalidFrame = "button frame not laid out" + case disabled = "button disabled" + case invalidWindow = "window missing or frame not laid out" + case outsideWindow = "button outside window above or beside it" + case notHittable = "button not hittable" + case expired = "readiness deadline exhausted" + } + + static func evaluate( + deadline: TimeInterval, + now: () -> TimeInterval, + read: () -> Read?, + window: () -> CGRect?, + hittable: () -> Bool + ) -> State { + guard now() < deadline else { return .expired } + let target = read() + guard now() < deadline else { return .expired } + guard let target else { return .missing } + guard laidOut(target.frame) else { return .invalidFrame } + guard target.isEnabled else { return .disabled } + let bounds = window() + guard now() < deadline else { return .expired } + guard let bounds, laidOut(bounds) else { return .invalidWindow } + + // PR run 37022177313 attempt 2 failed computing an offscreen AC chip's hit point. + // The successful b926 run scrolled that chip during target.tap(). Decide this before + // isHittable, which computes a hit point but does not scroll. Keep the bottom-edge + // case only; a control translated above or beside the window must finish entering. + if !bounds.contains(target.frame) { + let below = target.frame.minY >= bounds.minY && target.frame.maxY > bounds.maxY + && target.frame.minX >= bounds.minX && target.frame.maxX <= bounds.maxX + return below ? .ready : .outsideWindow + } + let reached = hittable() + guard now() < deadline else { return .expired } + return reached ? .ready : .notHittable + } + + private static func laidOut(_ frame: CGRect) -> Bool { + // CGRect.infinite uses finite sentinel coordinates. The isolated XCTest run caught + // it passing isFinite. CGRect.width/height also normalize negative stored sizes, + // so validate the stored size before using the standardized edges. + !frame.isNull && !frame.isInfinite && frame.size.width > 0 && frame.size.height > 0 + && [frame.origin.x, frame.origin.y, frame.size.width, frame.size.height, + frame.origin.x + frame.size.width, frame.origin.y + frame.size.height, + frame.minX, frame.minY, frame.maxX, frame.maxY].allSatisfy(\.isFinite) + } +} + +final class TapReadinessTests: XCTestCase { + private let bounds = CGRect(x: 0, y: 0, width: 400, height: 800) + private let visible = CGRect(x: 20, y: 700, width: 360, height: 44) + + func testInvalidOrMissingTargetsNeverAskForAWindowOrHitPoint() { + let frames: [CGRect?] = [nil, .zero, .null, .infinite, + CGRect(x: CGFloat.nan, y: 0, width: 44, height: 44), + CGRect(x: 0, y: 0, width: CGFloat.nan, height: 44), + CGRect(x: 0, y: 0, width: 44, height: -44), + CGRect(x: 0, y: 0, width: -44, height: 44), + CGRect(x: CGFloat.greatestFiniteMagnitude, y: 0, + width: CGFloat.greatestFiniteMagnitude, height: 44)] + for frame in frames { + let result = TapReadiness.evaluate(deadline: 20, now: { 0 }, read: { + frame.map { TapReadiness.Read(frame: $0, isEnabled: true) } + }, window: { XCTFail("an unreadable frame must not query the window"); return self.bounds }, + hittable: { XCTFail("an unreadable frame must not compute a hit point"); return true }) + XCTAssertEqual(result, frame == nil ? .missing : .invalidFrame, "invalid frame: \(String(describing: frame))") + } + } + + func testDisabledOffscreenButtonIsNotReady() { + let result = TapReadiness.evaluate(deadline: 20, now: { 0 }, read: { + .init(frame: CGRect(x: 20, y: 900, width: 360, height: 44), isEnabled: false) + }, window: { XCTFail("a disabled button must not query the window"); return self.bounds }, + hittable: { XCTFail("a disabled button must not compute a hit point"); return true }) + XCTAssertEqual(result, .disabled) + } + + func testBottomEdgeButtonsUseTapScrollingWithoutComputingAHitPoint() { + for y in [CGFloat(780), 900] { + let result = TapReadiness.evaluate(deadline: 20, now: { 0 }, read: { + .init(frame: CGRect(x: 20, y: y, width: 360, height: 44), isEnabled: true) + }, window: { self.bounds }, + hittable: { XCTFail("the tap must scroll before computing a hit point"); return false }) + XCTAssertEqual(result, .ready) + } + } + + func testOtherOffscreenGeometryDoesNotPermitATap() { + for frame in [CGRect(x: 20, y: -10, width: 360, height: 44), + CGRect(x: -10, y: 780, width: 360, height: 44), + CGRect(x: 390, y: 700, width: 44, height: 44)] { + let result = TapReadiness.evaluate(deadline: 20, now: { 0 }, read: { + .init(frame: frame, isEnabled: true) + }, window: { self.bounds }, + hittable: { XCTFail("outside geometry must not compute a hit point"); return true }) + XCTAssertEqual(result, .outsideWindow) + } + } + + func testInvalidWindowDoesNotPermitATap() { + for frame in [nil, .zero, .infinite] as [CGRect?] { + let result = TapReadiness.evaluate(deadline: 20, now: { 0 }, read: { + .init(frame: self.visible, isEnabled: true) + }, window: { frame }, + hittable: { XCTFail("an invalid window must not compute a hit point"); return true }) + XCTAssertEqual(result, .invalidWindow) + } + } + + func testVisibleButtonReadsEachInputOnceAndRequiresHittability() { + for reached in [false, true] { + var reads = 0, windows = 0, hits = 0 + let result = TapReadiness.evaluate(deadline: 20, now: { 0 }, read: { + reads += 1 + return .init(frame: self.visible, isEnabled: true) + }, window: { windows += 1; return self.bounds }, hittable: { hits += 1; return reached }) + XCTAssertEqual(result, reached ? .ready : .notHittable) + XCTAssertEqual(reads, 1) + XCTAssertEqual(windows, 1) + XCTAssertEqual(hits, 1) + } + } + + func testSlowReadsExhaustOneDeadlineWithoutIssuingMoreQueries() { + for exhaustedStage in 0...2 { + var time: TimeInterval = 0 + var reads = 0, windows = 0, hits = 0 + func evaluate() -> TapReadiness.State { + TapReadiness.evaluate(deadline: 20, now: { time }, read: { + reads += 1 + if exhaustedStage == 0 { time = 21 } + return .init(frame: self.visible, isEnabled: true) + }, window: { + windows += 1 + if exhaustedStage == 1 { time = 21 } + return self.bounds + }, hittable: { + hits += 1 + time = 21 + return true + }) + } + XCTAssertEqual(evaluate(), .expired) + XCTAssertEqual(evaluate(), .expired, "the next poll must not renew the deadline") + XCTAssertEqual(reads, 1) + XCTAssertEqual(windows, exhaustedStage >= 1 ? 1 : 0) + XCTAssertEqual(hits, exhaustedStage == 2 ? 1 : 0) + } + } +}