diff --git a/ios/HouseScan/UI/Copy/ScanCopy.swift b/ios/HouseScan/UI/Copy/ScanCopy.swift index 5c407f974..904b82fc8 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,10 @@ 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." + /// 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." @@ -690,8 +692,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..a1c3be375 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"), "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.") } /// The flow from the LiDAR fixture. Depth must show the bin in front of the wall: the wall map diff --git a/ios/HouseScanUITests/InstallerReviewUITests.swift b/ios/HouseScanUITests/InstallerReviewUITests.swift new file mode 100644 index 000000000..d9eafe245 --- /dev/null +++ b/ios/HouseScanUITests/InstallerReviewUITests.swift @@ -0,0 +1,43 @@ +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. 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 + } + + @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.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 ?? "" + } +}