From 458745adece7e9564d1fed839785485036517264 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sun, 27 Sep 2026 05:38:13 -0500 Subject: [PATCH 1/3] Wall map legend wraps instead of overlapping; picker wait allows a stalled Simulator CI run 36307476187 on b90fb8e: testFullFlowFromLidarReplay failed contrast on the walk for a node with no element. Its recording shows the map's legend with three entries (the end preview, hidden sections, Depth-checked) squeezed into one row and laid over each other, and a leaving entry fading over the one taking its place: the walk's controls animation (controlsKey) reached into the map. The legend now takes one row only when every entry fits on a line and stacks otherwise, and its entries change without animating. A new audited state, wallWalk-fullLegend, shows all three at once. testWholeFlowThroughTheButtons missed meter.candidate.0. The picker was never refused: the app's log shows its main thread took no work for 14.6 s while the Simulator's push daemon spun in a reconnect loop, and the close-up script resumed where it stopped. The picker would have come about 17.3 s after the close-up opened, against a 15 s wait; the wait is now 30 s. Co-Authored-By: Claude Opus 5.5 (1M context) (cherry picked from commit 1faa79b48834c740799f61ade3e957d9c10a53f0) --- ios/HouseScan/UI/Camera/WallTape.swift | 64 +++++++++++++------ .../ScreenStatesUITests.swift | 12 +++- 2 files changed, 54 insertions(+), 22 deletions(-) diff --git a/ios/HouseScan/UI/Camera/WallTape.swift b/ios/HouseScan/UI/Camera/WallTape.swift index eef76b0aa..ea671a87a 100644 --- a/ios/HouseScan/UI/Camera/WallTape.swift +++ b/ios/HouseScan/UI/Camera/WallTape.swift @@ -90,30 +90,54 @@ struct WallTape: View { private var leavesOutWalked: Float? { endPreview?.leavesOutWalked } + /// The legend under the strip. One row when every entry fits on one line; otherwise, and at + /// the accessibility sizes, one entry per line. As one squeezed row, the end preview, the + /// hidden sections and "Depth-checked" were laid over each other on the LiDAR replay, and the + /// audit failed their contrast (PR run 36307476187). + /// + /// Entries come and go without a fade. They change with the walk, and the walk's controls + /// animate their own changes (`WallWalkScreen`, `controlsKey`); inherited here, that animation + /// faded a leaving entry over the one taking its place, text over text again. private var footer: some View { - let layout = typeSize.isAccessibilitySize - ? AnyLayout(VStackLayout(alignment: .leading, spacing: 6)) - : AnyLayout(HStackLayout(spacing: 12)) - return layout { - if let leavesOutWalked { - LegendEntry(title: ScanCopy.endLeavesOut(leavesOutWalked, seen: endPreview?.leavesOutSeen == true)) { EndPreviewSwatch() } - } - if !hiddenSections.isEmpty { - LegendEntry(title: "Hidden behind something") { HiddenSwatch() } - } - if !skippedSections.isEmpty { - LegendEntry(title: "Skipped") { SkippedSwatch() } - } - if !typeSize.isAccessibilitySize { - Spacer(minLength: 0) - } - if depthChecked { - Label("Depth-checked", systemImage: "cube.transparent") - .font(Typeface.caption) - .foregroundStyle(Palette.chalk) + Group { + if typeSize.isAccessibilitySize { + VStack(alignment: .leading, spacing: 6) { legendEntries; depthLabel } + } else { + ViewThatFits(in: .horizontal) { + // "Depth-checked" says how the whole strip was measured, so it sits apart, right. + HStack(spacing: 12) { + legendEntries + Spacer(minLength: 0) + depthLabel + } + VStack(alignment: .leading, spacing: 6) { legendEntries; depthLabel } + } } } .frame(maxWidth: .infinity, alignment: .leading) + .transaction { $0.animation = nil } + } + + @ViewBuilder + private var legendEntries: some View { + if let leavesOutWalked { + LegendEntry(title: ScanCopy.endLeavesOut(leavesOutWalked, seen: endPreview?.leavesOutSeen == true)) { EndPreviewSwatch() } + } + if !hiddenSections.isEmpty { + LegendEntry(title: "Hidden behind something") { HiddenSwatch() } + } + if !skippedSections.isEmpty { + LegendEntry(title: "Skipped") { SkippedSwatch() } + } + } + + @ViewBuilder + private var depthLabel: some View { + if depthChecked { + Label("Depth-checked", systemImage: "cube.transparent") + .font(Typeface.caption) + .foregroundStyle(Palette.chalk) + } } // MARK: Layout diff --git a/ios/HouseScanUITests/ScreenStatesUITests.swift b/ios/HouseScanUITests/ScreenStatesUITests.swift index 787f4754e..62bb46697 100644 --- a/ios/HouseScanUITests/ScreenStatesUITests.swift +++ b/ios/HouseScanUITests/ScreenStatesUITests.swift @@ -41,6 +41,9 @@ final class ScreenStatesUITests: XCTestCase { ("wallWalk-seeBehind", ["-uiDemoPhase", "wallWalk", "-uiDemoSeeBehind"], "wallWalk"), ("wallWalk-aim", ["-uiDemoPhase", "wallWalk", "-uiDemoAim"], "wallWalk"), ("wallWalk-aimOffScreen", ["-uiDemoPhase", "wallWalk", "-uiDemoAimOffScreen"], "wallWalk"), + // All three legend entries under the map at once (end preview, hidden, depth): they + // overlapped in one row on CI's LiDAR walk (run 36307476187). + ("wallWalk-fullLegend", ["-uiDemoPhase", "wallWalk", "-uiDemoEndPreview", "-uiDemoHidden"], "wallWalk"), ("markFeatures", ["-uiDemoPhase", "markFeatures"], "markFeatures"), ("markFeatures-marking", ["-uiDemoPhase", "markFeatures", "-uiDemoMarking", "door"], "markFeatures"), ("markFeatures-lostPlace", ["-uiDemoPhase", "markFeatures", "-uiDemoCoaching", "relocalizing"], "markFeatures"), @@ -82,7 +85,7 @@ final class ScreenStatesUITests: XCTestCase { private static let largestTextStates: Set = [ "onboarding", "onboarding-practice", "onboarding-moves", "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", "gapRequest-followUp", "uploading-followUp", + "wallWalk-hidden", "wallWalk-seeBehind", "wallWalk-fullLegend", "gapRequest-followUp", "uploading-followUp", "markFeatures-groundQuestion", "markFeatures-groundAnswered", "markFeatures-lostPlace", // The card's reply under the aim step's words and under coaching. "wallWalk-aim", "wallWalk-slowDown", @@ -94,6 +97,7 @@ final class ScreenStatesUITests: XCTestCase { private static let expectations: [String: [(identifier: String?, text: String)]] = [ "onboarding-practice": [("action.developerOptions", "Practice meter is on.")], "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")], "gapRequest-followUp": [("instruction", "One more view to finish")], // #75: a server request's stretch by its two ends, not its middle. @@ -210,7 +214,11 @@ final class ScreenStatesUITests: XCTestCase { app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() XCTAssertTrue(element(app, "screen.meterCloseUp").waitForExistence(timeout: 10)) // Nothing is filled in: the homeowner picks the reading that matches the meter. - tap(app, "meter.candidate.0", timeout: 15) + // The demo's close-up shows the picker 4.5 s after it opens. 30 s, not 15: on CI run + // 36307476187 the Simulator's push daemon spun in a reconnect loop and the app's main + // thread got no time for 14.6 s (09:42:19.6 to 09:42:34.2 in its log), so the picker + // would have come at about 17.3 s, just after the old limit. + tap(app, "meter.candidate.0", timeout: 30) XCTAssertTrue(element(app, "screen.wallWalk").waitForExistence(timeout: 15)) // A window takes two taps on the camera: bottom-left corner, then top-right. tap(app, "action.markSomething") From d1f2039b05313af61630f6a8150c02d5fb4d8433 Mon Sep 17 00:00:00 2001 From: AidenJohnston Date: Sun, 27 Sep 2026 08:21:27 -0500 Subject: [PATCH 2/3] ios tests: audit the walk in motion too, to report The walk's audit in motion caught real bugs that only show mid-transition: the map legend's entries drawn over each other (1faa79b), the two reply titles half-drawn over each other (b1a2444) and the translucent reply fill (397b6d6). Auditing only the held walk would have hidden them. The walk is now audited on its first appearance as well, report-only: findings are attached as audit-wallWalk-moving and printed, as with HOUSESCAN_AUDIT_REPORT_ONLY, and the strict audit stays on the held walk. Also: LaunchOptions documents .held, and the LiDAR test's comment no longer times the walk. Co-Authored-By: Claude Opus 5.5 (1M context) (cherry picked from commit 32f6e710b0c562f169c805a3ca0dcdebc8c9421d) --- ios/HouseScan/Runtime/LaunchOptions.swift | 6 ++- ios/HouseScanUITests/FullFlowUITests.swift | 54 +++++++++++++++------- 2 files changed, 41 insertions(+), 19 deletions(-) diff --git a/ios/HouseScan/Runtime/LaunchOptions.swift b/ios/HouseScan/Runtime/LaunchOptions.swift index 58e7c8f4d..d719047ec 100644 --- a/ios/HouseScan/Runtime/LaunchOptions.swift +++ b/ios/HouseScan/Runtime/LaunchOptions.swift @@ -14,8 +14,10 @@ import OSLog /// UI tests raise it so each screen stays long enough to screenshot and audit. /// - `-autopilotGate `: before the flow leaves a screen, wait until a file named after /// that phase exists in the folder. UI tests write it once they have screenshotted and audited -/// the screen, so a slow audit can never miss a screen. After the result shows, the autopilot -/// also writes the scan's scene.json there, for the test to check. +/// the screen, so a slow audit can never miss a screen. While it waits, the app leaves +/// `.held` there, so a test can tell when the autopilot has finished with a screen and +/// holds it still. After the result shows, the autopilot also writes the scan's scene.json +/// there, for the test to check. /// - `-autopilotCantGetThere`: the autopilot ends the walk with "Can't get there" instead of /// marking the ends (`Autopilot.endWalkByCantGetThere`). /// - `-autopilotSomethingThere`: the autopilot answers the first spot check "Something's there" diff --git a/ios/HouseScanUITests/FullFlowUITests.swift b/ios/HouseScanUITests/FullFlowUITests.swift index 093cc6611..19b8de04d 100644 --- a/ios/HouseScanUITests/FullFlowUITests.swift +++ b/ios/HouseScanUITests/FullFlowUITests.swift @@ -14,15 +14,18 @@ final class FullFlowUITests: XCTestCase { /// Screens in the order the flow must show them. static let flow = ["onboarding", "findMeter", "meterCloseUp", "wallWalk", "markFeatures", "gapRequest", "uploading", "spotConfirm", "result", "resultAR"] - /// Screens audited only once the autopilot has finished with them and holds them still - /// (`.held` in the gate folder). On the walk the autopilot marks features, sets the - /// ends and tilts up while the replay plays, so the screen changes several times a second: - /// an audit started when the walk appeared read cards and buttons mid-crossfade, each pass a - /// different transition, and a copy fading out has no identifier, so two passes' transient - /// issues matched on the label or the "no element" key and failed as one lasting issue (CI - /// runs 36304552823, 36305050676, 36307476187). Held, the walk stays in its last state, both - /// ends set and "Done with this wall" on offer, and the states it passed through are audited - /// frozen by ScreenStatesUITests. + /// Screens audited strictly only once the autopilot has finished with them and holds them + /// still (`.held` in the gate folder). On the walk the autopilot marks features, sets + /// the ends and tilts up while the replay plays, so the screen changes several times a second + /// and two audit passes read two different moments. A finding that isn't the same element in + /// both passes can still match: a view fading out has no identifier, so it is keyed by its + /// label, or by "no element". The walk is therefore also audited on its first appearance, in + /// motion, in report-only mode (`audit-wallWalk-moving` attachments, and `AUDIT` lines in the + /// log), because that audit is what caught real bugs that only show mid-transition: the map's + /// legend entries drawn over each other (1faa79b, run 36307476187), "Can't get there" and + /// "Can't see past it" half-drawn over each other (b1a2444), and the reply's translucent fill + /// (397b6d6). Read those attachments when the walk's chrome or its animations change. The + /// strict audit then checks the held walk: both ends set and "Done with this wall" on offer. static let auditedOnceHeld: Set = ["wallWalk"] override func setUp() { @@ -59,8 +62,9 @@ final class FullFlowUITests: XCTestCase { try runFlow(replay: Self.lidarFixture) { app, phase in guard !showedHidden else { return } switch phase { - // At the autopilot's 3x the walk plays in about 6 s. No other frame sees the wall - // behind the bin, so its cells stay hidden once the bin has been in view. + // Read once the autopilot holds the walk, after the whole replay has played. No + // other frame sees the wall behind the bin, so its cells stay hidden once the bin has + // been in view. case "wallWalk": showedHidden = Self.wallTapeShowsHidden(app, timeout: 60) case "gapRequest": showedHidden = Self.wallTapeShowsHidden(app, timeout: 30) default: break @@ -313,12 +317,14 @@ final class FullFlowUITests: XCTestCase { /// Replay screens can still be taking photos while they are audited, so the photo count's /// number is often mid-roll, and the result's text is still fading in when the screen appears. /// A single audit reports those passing frames as contrast failures; a real contrast or - /// clipping problem is still there later. The walk is audited once the autopilot holds it - /// (`auditedOnceHeld`). A screen that rebuilds parts of itself while it is audited can lose - /// an issue's element before it is read: that one is attached, not failed on. Every screen - /// state is also audited frozen by ScreenStatesUITests. + /// clipping problem is still there later. The walk is audited strictly once the autopilot + /// holds it, and in motion only to report (`auditedOnceHeld`). With `reportOnly`, or + /// HOUSESCAN_AUDIT_REPORT_ONLY=1, the issues found twice are attached as `audit-` + /// and printed, not failed on. A screen that rebuilds parts of itself while it is audited can + /// lose an issue's element before it is read: that one is attached, not failed on. Every + /// screen state is also audited frozen by ScreenStatesUITests. @MainActor - private func audit(_ app: XCUIApplication, screen: String) throws { + private func audit(_ app: XCUIApplication, screen: String, reportOnly: Bool = false) throws { // Six seconds: long enough for a system notification banner to leave. On CI one slid in // over the find-meter screen and failed its contrast check twice a second apart (run // 36264789032), as ScreenStatesUITests' audit also allows for. @@ -331,7 +337,7 @@ final class FullFlowUITests: XCTestCase { } for (_, finding) in outcome.persistent { let text = "screen.\(screen): \(finding.message)" - if Self.environment["HOUSESCAN_AUDIT_REPORT_ONLY"] == "1" { + if reportOnly || Self.environment["HOUSESCAN_AUDIT_REPORT_ONLY"] == "1" { let note = XCTAttachment(string: text) note.name = "audit-\(screen)" note.lifetime = .keepAlways @@ -379,6 +385,20 @@ final class FullFlowUITests: XCTestCase { let timeout: TimeInterval = phase == "markFeatures" || phase == "result" ? 150 : 60 XCTAssertTrue(screen.waitForExistence(timeout: timeout), "screen.\(phase) never appeared") if Self.auditedOnceHeld.contains(phase) { + // In motion, report-only: see `auditedOnceHeld`. + Thread.sleep(forTimeInterval: 1.0) + let moving = XCTAttachment(screenshot: app.screenshot()) + moving.name = "\(phase)-moving" + moving.lifetime = .keepAlways + add(moving) + do { + try audit(app, screen: "\(phase)-moving", reportOnly: true) + } catch { + let note = XCTAttachment(string: "the audit couldn't read the screen in motion: \(error)") + note.name = "audit-\(phase)-moving-unread" + note.lifetime = .keepAlways + add(note) + } let held = gate.appending(path: "\(phase).held") let deadline = Date().addingTimeInterval(150) while !FileManager.default.fileExists(atPath: held.path), Date() < deadline { Thread.sleep(forTimeInterval: 0.2) } From c3aeabea41a557d5a50cfedae4b3b07fd52d9687 Mon Sep 17 00:00:00 2001 From: AidenJohnston Date: Sun, 27 Sep 2026 08:23:52 -0500 Subject: [PATCH 3/3] ios: the autopilot's gate waits four minutes, past the test's work on a held screen A test audits a held screen only once .held appears, and the walk's map check after it can wait 90 s; the two-minute gate could expire first on a slow runner and let the app leave mid-audit (Greptile on #169). Co-Authored-By: Claude Opus 5.5 (1M context) --- ios/HouseScan/Runtime/ScanEngine.swift | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/ios/HouseScan/Runtime/ScanEngine.swift b/ios/HouseScan/Runtime/ScanEngine.swift index 562ec83be..4bd4cf743 100644 --- a/ios/HouseScan/Runtime/ScanEngine.swift +++ b/ios/HouseScan/Runtime/ScanEngine.swift @@ -350,16 +350,19 @@ final class ScanEngine { private var autoAdvanceDelay: Double { options.autopilot ? max(1.2, options.autopilotHold) : 1.2 } /// With `-autopilotGate`, waits until the UI test has finished with `phase` (its file exists), - /// for at most two minutes so a lost gate file can't hang the app. While it waits, the file + /// for at most four minutes so a lost gate file can't hang the app. While it waits, the file /// `.held` says the autopilot has finished with the screen and holds it still, for a UI - /// test that must audit it settled rather than while the autopilot drives it. + /// test that must audit it settled rather than while the autopilot drives it. A test starts + /// that audit only once `.held` appears, then reads the screen (the walk's map check can wait + /// up to 90 s), so the wait must outlast all of that; at two minutes a slow runner could have + /// let the app leave mid-audit. func waitForGate(_ phase: ScanPhase) async { guard options.autopilot, let gate = options.autopilotGate else { return } let file = gate.appending(path: phase.rawValue) if !FileManager.default.fileExists(atPath: file.path) { try? Data().write(to: gate.appending(path: "\(phase.rawValue).held")) } - let deadline = ContinuousClock.now + .seconds(120) + let deadline = ContinuousClock.now + .seconds(240) while ContinuousClock.now < deadline, !FileManager.default.fileExists(atPath: file.path) { try? await Task.sleep(for: .milliseconds(100)) }