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/HouseScan/Runtime/ScanEngine.swift b/ios/HouseScan/Runtime/ScanEngine.swift index 831682f49..7d9c24d9b 100644 --- a/ios/HouseScan/Runtime/ScanEngine.swift +++ b/ios/HouseScan/Runtime/ScanEngine.swift @@ -356,16 +356,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)) } 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/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) } 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")