From 8c47d6789d4b5a962776515f276e0b76ce6f5a6f Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Fri, 2 Oct 2026 13:25:10 -0500 Subject: [PATCH 1/3] 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 c73aa469158c8c45cca0d16959eba6d699e2a3e3 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Fri, 2 Oct 2026 14:13:03 -0500 Subject: [PATCH 2/3] 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 94f18ff7ba3eed59f2ddd0456648ee63a7609f57 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Fri, 2 Oct 2026 15:06:16 -0500 Subject: [PATCH 3/3] 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.