Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions ios/HouseScan/Runtime/LaunchOptions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@ import OSLog
/// UI tests raise it so each screen stays long enough to screenshot and audit.
/// - `-autopilotGate <folder>`: 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
/// `<phase>.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"
Expand Down
9 changes: 6 additions & 3 deletions ios/HouseScan/Runtime/ScanEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
/// `<phase>.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))
}
Expand Down
64 changes: 44 additions & 20 deletions ios/HouseScan/UI/Camera/WallTape.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
54 changes: 37 additions & 17 deletions ios/HouseScanUITests/FullFlowUITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
/// (`<phase>.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 (`<phase>.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<String> = ["wallWalk"]

override func setUp() {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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-<screen>`
/// 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.
Expand All @@ -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
Expand Down Expand Up @@ -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)
Comment thread
SamGu-NRX marked this conversation as resolved.
} 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) }
Expand Down
12 changes: 10 additions & 2 deletions ios/HouseScanUITests/ScreenStatesUITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -82,7 +85,7 @@ final class ScreenStatesUITests: XCTestCase {
private static let largestTextStates: Set<String> = [
"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",
Expand All @@ -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.
Expand Down Expand Up @@ -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")
Expand Down
Loading