diff --git a/ios/HouseScan/Contract/ScanContract.swift b/ios/HouseScan/Contract/ScanContract.swift index 705c3e30e..4f22772ff 100644 --- a/ios/HouseScan/Contract/ScanContract.swift +++ b/ios/HouseScan/Contract/ScanContract.swift @@ -759,6 +759,10 @@ enum SpotCheckAnswer: Equatable, Sendable { case clear /// Something stands there: the scan stops claiming that area and is checked again. case somethingThere + /// The homeowner can't see or reach the area to say: it was not observed, which is neither + /// an obstruction nor clear. The scan stops claiming that area and is checked again, and the + /// result says nobody checked it. + case cannotCheck } /// The one question asked before an answer's spot is shown as the result: is anything standing @@ -891,6 +895,11 @@ final class ScanViewState { var result: ResultPresentation? /// The homeowner's check of the proposed spot, retained beside the result. var spotCheck: SpotCheck? + /// True when the homeowner answered "I can't check this area" about an area other than the + /// spot the result names, or while it names none. That area stays out of the scan, so the + /// result says so beside whatever it says about its own spot. Cleared with the scan's spot + /// checks. + var uncheckedAreaElsewhere = false /// True while the engine sees the AR scene drawing the result in the live camera /// (`ResultOverlayPolicy`). The AR screen then draws no overlay of its own; otherwise it /// draws `BatteryOverlay`. @@ -995,8 +1004,8 @@ protocol ScanActions: AnyObject { func retryUpload() /// After a rejected upload: back to the feature review, keeping the scan. func backToReview() - /// The answer to `ScanViewState.spotCheck`: true when nothing stands in the area. - func answerSpotCheck(clear: Bool) + /// The answer to `ScanViewState.spotCheck`. + func answerSpotCheck(_ answer: SpotCheckAnswer) /// Start a capture for a server-listed missing item. func captureMissing(_ id: String) func showAR() diff --git a/ios/HouseScan/Runtime/Autopilot.swift b/ios/HouseScan/Runtime/Autopilot.swift index edde51070..f2f73d310 100644 --- a/ios/HouseScan/Runtime/Autopilot.swift +++ b/ios/HouseScan/Runtime/Autopilot.swift @@ -17,10 +17,10 @@ final class Autopilot { /// back to pixels use the same size. private let viewSize = CGSize(width: 393, height: 852) private var hold: Double { engine.options.autopilotHold } - /// Set once a spot check was answered "Something's there" (`-autopilotSomethingThere`): the - /// checks after it are answered "It's clear", so a server that keeps moving the spot can't - /// keep the run going. - private var answeredSomethingThere = false + /// Set once a spot check was answered "Something's there" (`-autopilotSomethingThere`) or "I + /// can't check this area" (`-autopilotCannotCheck`): the checks after it are answered "It's + /// clear", so a server that keeps moving the spot can't keep the run going. + private var withheldClear = false init(engine: ScanEngine) { self.engine = engine @@ -399,18 +399,26 @@ final class Autopilot { return false } - /// "It's clear", or with `-autopilotSomethingThere` "Something's there" the first time. After - /// a refusal the stretch it withdrew goes to the gate folder as `spot-refusal.json` (the - /// stretch's s in feet, as scene.json's spans), for the UI test to check the scene against. + /// "It's clear", or the first time "Something's there" with `-autopilotSomethingThere` and "I + /// can't check this area" with `-autopilotCannotCheck`. After either, the stretch the answer + /// withdrew goes to the gate folder as `spot-refusal.json` (the stretch's s in feet, as + /// scene.json's spans), for the UI test to check the scene against. private func answerSpotCheck() async { guard let check = engine.state.spotCheck, check.answer == nil else { return } await pause(hold) await engine.waitForGate(.spotConfirm) - let refuse = engine.options.autopilotSomethingThere && !answeredSomethingThere - engine.answerSpotCheck(clear: !refuse) - log("spot check \(check.id) over \(format(check.area)): \(refuse ? "something's there" : "it's clear")") - if refuse { - answeredSomethingThere = true + let options = engine.options + let first: SpotCheckAnswer = options.autopilotCannotCheck ? .cannotCheck : options.autopilotSomethingThere ? .somethingThere : .clear + let answer: SpotCheckAnswer = withheldClear ? .clear : first + engine.answerSpotCheck(answer) + let said = switch answer { + case .clear: "it's clear" + case .somethingThere: "something's there" + case .cannotCheck: "I can't check this area" + } + log("spot check \(check.id) over \(format(check.area)): \(said)") + if answer != .clear { + withheldClear = true writeRefusalForTest() } _ = await waitUntil(timeout: 20) { self.engine.state.phase != .spotConfirm } diff --git a/ios/HouseScan/Runtime/LaunchOptions.swift b/ios/HouseScan/Runtime/LaunchOptions.swift index 3d659c6ed..9b93d3491 100644 --- a/ios/HouseScan/Runtime/LaunchOptions.swift +++ b/ios/HouseScan/Runtime/LaunchOptions.swift @@ -22,6 +22,12 @@ import OSLog /// marking the ends (`Autopilot.endWalkByCantGetThere`). /// - `-autopilotSomethingThere`: the autopilot answers the first spot check "Something's there" /// instead of "It's clear", so the scan is checked again without that area. +/// - `-autopilotCannotCheck`: the autopilot answers the first spot check "I can't check this +/// area" instead of "It's clear". It can't be combined with `-autopilotSomethingThere`. +/// - `-sampleResultAfterSpotAnswer ` (debug builds only): with the bundled sample, the +/// upload sent after a spot check answered "Something's there" or "I can't check this area", +/// and every later upload of that scan, is answered with the server answer in this JSON file, +/// such as one without a spot. Start over or a new wall goes back to the bundled sample. /// - `-injectGroundRise `: with `-replay`, `-autopilot` and `-autopilotGate`, once the /// first upload starts, each time a file named `inject-ground` appears in the gate folder the /// app deletes it and hands the engine a detected floor that many meters above its current @@ -40,6 +46,8 @@ struct LaunchOptions: Equatable { var autopilotGate: URL? var autopilotCantGetThere = false var autopilotSomethingThere = false + var autopilotCannotCheck = false + var sampleResultAfterSpotAnswer: URL? var simulateAppStore = false var injectGroundRise: Float? @@ -57,6 +65,11 @@ struct LaunchOptions: Equatable { autopilot = arguments.contains("-autopilot") autopilotCantGetThere = arguments.contains("-autopilotCantGetThere") autopilotSomethingThere = arguments.contains("-autopilotSomethingThere") + autopilotCannotCheck = arguments.contains("-autopilotCannotCheck") + precondition(!(autopilotSomethingThere && autopilotCannotCheck), "-autopilotSomethingThere and -autopilotCannotCheck each choose the first spot answer; pass one") + #if DEBUG + sampleResultAfterSpotAnswer = value(after: "-sampleResultAfterSpotAnswer").map { URL(fileURLWithPath: $0) } + #endif simulateAppStore = arguments.contains("-simulateAppStore") serverURL = (value(after: "-serverURL") ?? defaultServerURL).flatMap(Self.serverURL) sampleResult = arguments.contains("-sampleResult") diff --git a/ios/HouseScan/Runtime/ResultClient.swift b/ios/HouseScan/Runtime/ResultClient.swift index 567884fc2..90805058b 100644 --- a/ios/HouseScan/Runtime/ResultClient.swift +++ b/ios/HouseScan/Runtime/ResultClient.swift @@ -148,6 +148,10 @@ final class SampleResultClient: ResultClient { let isSample = true /// Seconds the fake upload takes, so each upload state is visible in demos and UI tests. let pace: Double + /// A server answer in a JSON file that replaces the bundled sample while it is set. Only the + /// engine sets it, from `-sampleResultAfterSpotAnswer` and the scan's spot answers + /// (`ScanEngine.spotConfirm`), for UI tests. + var answerFile: URL? init(pace: Double) { self.pace = pace @@ -159,6 +163,7 @@ final class SampleResultClient: ResultClient { progress(Double(step) / 4) } try await Task.sleep(for: .seconds(pace)) + if let answerFile { return try Data(contentsOf: answerFile) } guard let url = Bundle.main.url(forResource: "SampleResult", withExtension: "json") else { throw UploadError.missingSample } return try Data(contentsOf: url) } diff --git a/ios/HouseScan/Runtime/ScanEngine+Confirm.swift b/ios/HouseScan/Runtime/ScanEngine+Confirm.swift index 367ff3162..f29831003 100644 --- a/ios/HouseScan/Runtime/ScanEngine+Confirm.swift +++ b/ios/HouseScan/Runtime/ScanEngine+Confirm.swift @@ -22,6 +22,9 @@ struct SpotConfirmState { var request: GuidanceLog.Request? /// Checks asked in this scan, for their ids. var asked = 0 + /// The area of the spot the answer being shown names, nil while it names none: the result + /// speaks of an unchecked area apart from it (`ScanViewState.uncheckedAreaElsewhere`). + var shownArea: SpotArea? } /// The spot check: before an answer's spot is shown as the result, the homeowner is shown the @@ -30,11 +33,13 @@ struct SpotConfirmState { /// /// Camera-only coverage and the walked path claim wall, ground and clear space they never saw /// (HouseScanKit `CoverageMap`, "Bounded exceptions"). "It's clear" backs those claims for this -/// spot and shows the result. "Something's there" withdraws them over the area -/// (`CoverageMap.withdrawClaims`), so the scene reports it unseen, and uploads again, as a closed -/// gap does; the new answer's spot is asked about again unless an earlier answer already covers -/// it (`SpotConfirmations.settling`). Nothing new goes into scene.json: the answer shows only as -/// the stretch the scene no longer claims, and in the packet's guidance log. +/// spot and shows the result. "Something's there" and "I can't check this area" withdraw them +/// over the area (`CoverageMap.withdrawClaims`), so the scene reports it unseen, and upload +/// again, as a closed gap does; the new answer's spot is asked about again unless an earlier +/// answer already covers it (`SpotConfirmations.settling`), and then the result says which of +/// the two the homeowner gave. Nothing new goes into scene.json: the answer shows only as the +/// stretch the scene no longer claims, and in the packet's guidance log, where both close the +/// check as skipped. /// /// The bundled sample is checked like a server's answer, and the screen labels it a sample: the /// UI tests and the offline demo only ever see the sample, so skipping it would leave the step @@ -66,16 +71,19 @@ extension ScanEngine { guard let result = state.result, let spot = result.spot, let wall = coverage?.wall, let sceneSHA256 = spotConfirm.sceneSHA256, let answerSHA256 = spotConfirm.answerSHA256 else { state.spotCheck = nil + spotConfirm.shownArea = nil go(.result) return } let area = SpotArea( spot: spot.span, spotOut: spot.offsetFromWall...(spot.offsetFromWall + spot.depth), zones: result.clearances.map { (span: $0.span, depth: $0.depth) }) + spotConfirm.shownArea = area spotConfirm.asked += 1 let id = spotConfirm.asked if let settled = spotConfirm.confirmations.settling(area) { - // Kept for the result, which says when the homeowner said something stands there. + // Kept for the result, which says when the homeowner said something stands there or + // couldn't check the area. state.spotCheck = Self.spotCheck(id: id, area: area, spot: spot, photo: nil, answer: Self.answer(settled.answer), isSample: result.isSample) RuntimeLog.engine.info("spot check: s=\(area.spot.lowerBound)...\(area.spot.upperBound) was answered before (\(settled.answer.rawValue, privacy: .public)); showing the result") go(.result) @@ -116,23 +124,30 @@ extension ScanEngine { } /// The homeowner's answer to the spot check on screen. - func answerSpotCheck(clear: Bool) { + func answerSpotCheck(_ answer: SpotCheckAnswer) { guard state.phase == .spotConfirm, var check = state.spotCheck, check.answer == nil, var confirmation = spotConfirm.pending else { return } - confirmation.answer = clear ? .clear : .somethingThere + confirmation.answer = Self.confirmationAnswer(answer) spotConfirm.confirmations.record(confirmation) spotConfirm.pending = nil - check.answer = Self.answer(confirmation.answer) + check.answer = answer state.spotCheck = check - // A refusal sends the area to review without the claims, like an overhead answer. - resolveGuidance(clear ? .met : .skipped) + // An answer other than "It's clear" sends the area to review without the claims, like an + // overhead answer. + resolveGuidance(confirmation.answer.guidanceOutcome) let area = confirmation.area - if !clear { updateCoverage { $0.withdrawClaims(over: area.span) } } - RuntimeLog.engine.info("spot check \(check.id): \(clear ? "clear" : "something there, claims withdrawn", privacy: .public) over s=\(area.span.lowerBound)...\(area.span.upperBound), photo \(confirmation.photoID ?? "none", privacy: .public), answer \(confirmation.answerSHA256.prefix(12), privacy: .public), scene \(confirmation.sceneSHA256.prefix(12), privacy: .public)") + let keepsClaims = confirmation.answer.keepsClaims + if !keepsClaims { updateCoverage { $0.withdrawClaims(over: area.span) } } + let said = switch confirmation.answer { + case .clear: "clear" + case .somethingThere: "something there, claims withdrawn" + case .cannotCheck: "can't check, claims withdrawn" + } + RuntimeLog.engine.info("spot check \(check.id): \(said, privacy: .public) over s=\(area.span.lowerBound)...\(area.span.upperBound), photo \(confirmation.photoID ?? "none", privacy: .public), answer \(confirmation.answerSHA256.prefix(12), privacy: .public), scene \(confirmation.sceneSHA256.prefix(12), privacy: .public)") let id = check.id Task { try? await Task.sleep(for: .seconds(spotAnsweredHold)) guard state.phase == .spotConfirm, state.spotCheck?.id == id else { return } - guard clear else { + guard keepsClaims else { // The scan without those claims goes to the server again. startUpload() return @@ -147,8 +162,9 @@ extension ScanEngine { } /// Whether a capture can settle a gap request: not over a stretch whose claims the homeowner - /// withdrew, where a new view would claim the same thing past the same obstruction. Overhead - /// requests are not about that stretch's wall or ground. + /// withdrew, where a new view would claim the same thing past the same obstruction, or past + /// whatever kept the homeowner from checking it. Overhead requests are not about that + /// stretch's wall or ground. func captureCanSettle(_ plan: GapPlan) -> Bool { if case .overhead = plan.need { return true } return !(coverage?.hasWithdrawnClaims(overlapping: plan.span) ?? false) @@ -199,6 +215,15 @@ extension ScanEngine { switch answer { case .clear: .clear case .somethingThere: .somethingThere + case .cannotCheck: .cannotCheck + } + } + + private static func confirmationAnswer(_ answer: SpotCheckAnswer) -> SpotConfirmationAnswer { + switch answer { + case .clear: .clear + case .somethingThere: .somethingThere + case .cannotCheck: .cannotCheck } } } diff --git a/ios/HouseScan/Runtime/ScanEngine.swift b/ios/HouseScan/Runtime/ScanEngine.swift index 388f6e516..003c39044 100644 --- a/ios/HouseScan/Runtime/ScanEngine.swift +++ b/ios/HouseScan/Runtime/ScanEngine.swift @@ -215,8 +215,20 @@ final class ScanEngine { } /// Every request the homeowner was shown, for the packet. var guidanceLog = GuidanceLog() - /// The spot check (`ScanEngine+Confirm.swift`). - var spotConfirm = SpotConfirmState() + /// The spot check (`ScanEngine+Confirm.swift`). What follows from its records is read from + /// them on every change, so recording an answer sets it and a reset clears it: + /// `ScanViewState.uncheckedAreaElsewhere`, and the UI tests' answer file + /// (`-sampleResultAfterSpotAnswer`), which applies only once this scan has an answer other + /// than "It's clear", so a scan after Start over or a new wall starts on the bundled sample. + var spotConfirm = SpotConfirmState() { + didSet { + let elsewhere = spotConfirm.confirmations.leftUnchecked(besides: spotConfirm.shownArea) + if state.uncheckedAreaElsewhere != elsewhere { state.uncheckedAreaElsewhere = elsewhere } + if let file = options.sampleResultAfterSpotAnswer, let sample = resultClient as? SampleResultClient { + sample.answerFile = spotConfirm.confirmations.records.contains { !$0.answer.keepsClaims } ? file : nil + } + } + } /// When each mark was made, on the capture clock (`MarkKey`). var markTimes: [String: Double] = [:] /// The packet's clock for guidance and marks: the latest frame's time, ARFrame.timestamp @@ -1717,6 +1729,7 @@ final class ScanEngine { state.spotCheck = nil spotConfirm.pending = nil spotConfirm.request = nil + spotConfirm.shownArea = nil // A spot photo still loading for this answer (`presentAnswer`) must not open its check // once a retry brings back an equal answer. spotConfirm.asked += 1 @@ -2125,8 +2138,9 @@ final class ScanEngine { // A request raised while the phone has lost its place could only time out: show the result. guard !automaticGapsStopped, !state.tracking.hasLostItsPlace, let map = coverage else { return [] } let asked = automaticGaps + (asking.map { [$0] } ?? []) - // A new view cannot settle an area the homeowner has already said is obstructed. - // Filter before the request limit so refused areas do not consume the remaining slots. + // A new view cannot settle an area the homeowner has already said is obstructed, or + // couldn't check. Filter before the request limit so those areas do not consume the + // remaining slots. let capturable = result.missingEvidence.filter { item in gapPlanner.plan(for: item, leftEnd: map.leftEnd, rightEnd: map.rightEnd, limitEnds: map.limitEnds) .map { captureCanSettle($0) } ?? false diff --git a/ios/HouseScan/UI/Copy/SpotCheckCopy.swift b/ios/HouseScan/UI/Copy/SpotCheckCopy.swift index 3aa9398f5..0abd76894 100644 --- a/ios/HouseScan/UI/Copy/SpotCheckCopy.swift +++ b/ios/HouseScan/UI/Copy/SpotCheckCopy.swift @@ -24,18 +24,33 @@ extension ScanCopy { } static let spotClear = "It's clear" static let spotSomethingThere = "Something's there" + /// For a homeowner who can't see or reach the area, so they needn't guess between the other + /// two. It says nothing about what is there. + static let spotCannotCheck = "I can't check this area" static let spotClearHint = "Shows your result." static let spotSomethingThereHint = "Leaves this area out of your scan and checks your wall again. An installer would need to look at it." + static let spotCannotCheckHint = "Leaves this area out of your scan as not checked, and checks your wall again. Someone would need to check it in person." 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 would need to look at what's there.") + case .cannotCheck: Instruction(title: "Thanks, we'll mark that area not checked", detail: "Checking your wall again. Someone would need to check that area in person.") } } /// 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 would need to check it." + /// Said on the result when the homeowner couldn't check the spot's area. It says the area + /// went unchecked, never that something was seen there or that anyone has been asked to look. + static let spotNotChecked = "You couldn't check the area around this spot, so your scan leaves it out as not checked. Someone would need to check it in person." + /// Said on the result when an area the homeowner couldn't check is not this result's spot: the + /// answer after it names no spot, or a spot elsewhere. That area still counts as unseen. + /// Beside a notice about this spot it speaks of another area. + static func scanNotChecked(besideSpotNotice: Bool) -> String { + let which = besideSpotNotice ? "You also couldn't check another area along this wall" : "You couldn't check an area along this wall" + return "\(which), so your scan leaves it out as not checked. Someone would need to check it in person." + } /// "From 3 ft to 8 ft 6 in right of your meter": where the area runs along the wall, nearer /// end first. An end less than half an inch from the meter rounds to 0 in and is read as the diff --git a/ios/HouseScan/UI/Preview/DemoEngine.swift b/ios/HouseScan/UI/Preview/DemoEngine.swift index 6c4f38df2..2676f9555 100644 --- a/ios/HouseScan/UI/Preview/DemoEngine.swift +++ b/ios/HouseScan/UI/Preview/DemoEngine.swift @@ -179,8 +179,21 @@ final class DemoEngine: ScanActions { anchor: SIMD3(corner, 0, 0), anchorS: corner)] state.wall?.rightEnd = corner + 1.6 } - if let raw = value("-uiDemoSpotAnswered"), state.phase == .spotConfirm { - state.spotCheck?.answer = raw == "somethingThere" ? .somethingThere : .clear + if let raw = value("-uiDemoSpotAnswered") { + let answer = Self.spotAnswer(raw) + switch state.phase { + case .spotConfirm: + state.spotCheck?.answer = answer + case .result: + // The result after that answer, which the result's notice reads. With a + // `-uiDemoResultFile` answer that names no spot there is no check to keep, and + // only the wall-wide notice after "I can't check this area" remains. + // `-uiDemoUncheckedElsewhere` adds an earlier area left unchecked. + state.spotCheck = sampleSpotCheck(answer: answer) + state.uncheckedAreaElsewhere = (answer == .cannotCheck && state.spotCheck == nil) || arguments.contains("-uiDemoUncheckedElsewhere") + default: + break + } } // The spot check without an outline: no kept photo shows the area (`-uiDemoSpotNoPhoto`), // or a photo but no wall to draw the area on it with (`-uiDemoSpotNoWall`). @@ -404,17 +417,33 @@ final class DemoEngine: ScanActions { /// The spot check of the answer's spot, before the result. The made-up zones don't hold the /// spot, so the area is the footprint alone, as the engine would draw it. private func enterSpotCheck() { - let result = sample state.shareableScan = Self.demoScan state.upload = .done - state.result = result - guard let spot = result.spot else { return showResult() } + state.result = sample + guard let check = sampleSpotCheck(answer: nil) else { return showResult() } + state.spotCheck = check + state.phase = .spotConfirm + } + + /// The check of the sample's spot, nil when the sample names none. + private func sampleSpotCheck(answer: SpotCheckAnswer?) -> SpotCheck? { + let result = sample + guard let spot = result.spot else { return nil } let out = spot.offsetFromWall...(spot.offsetFromWall + spot.depth) - state.spotCheck = SpotCheck( + return SpotCheck( id: 1, spot: spot.span, spotOut: out, spotHeight: spot.height, area: spot.span, areaDepth: out.upperBound, photo: DemoScene.image.map { SpotCheck.Photo(image: $0, projection: DemoScene.projection) }, - answer: nil, isSample: result.isSample) - state.phase = .spotConfirm + answer: answer, isSample: result.isSample) + } + + /// `-uiDemoSpotAnswered`'s value. Anything else is a typo in a test's arguments. + private static func spotAnswer(_ raw: String) -> SpotCheckAnswer { + switch raw { + case "clear": .clear + case "somethingThere": .somethingThere + case "cannotCheck": .cannotCheck + default: preconditionFailure("-uiDemoSpotAnswered takes clear, somethingThere or cannotCheck, got \(raw)") + } } private func showResult() { @@ -1052,6 +1081,7 @@ final class DemoEngine: ScanActions { followUpSkipped = false spotChecked = false state.spotCheck = nil + state.uncheckedAreaElsewhere = false tiltUpSettled = false tiltUpTicks = 0 state.overheadQuestion = false @@ -1241,13 +1271,13 @@ private extension ResultPresentation { extension DemoEngine { /// Like the engine: the answer stays up a moment, then the result, or the check again. - func answerSpotCheck(clear: Bool) { + func answerSpotCheck(_ answer: SpotCheckAnswer) { guard state.phase == .spotConfirm, state.spotCheck?.answer == nil else { return } - state.spotCheck?.answer = clear ? .clear : .somethingThere + state.spotCheck?.answer = answer spotChecked = true run { engine in guard await engine.pause(1.2) else { return } - if clear { engine.showResult() } else { engine.enterUpload() } + if answer == .clear { engine.showResult() } else { engine.enterUpload() } } } diff --git a/ios/HouseScan/UI/Preview/UIDemo.swift b/ios/HouseScan/UI/Preview/UIDemo.swift index 3ee3c0a47..dd2f95b75 100644 --- a/ios/HouseScan/UI/Preview/UIDemo.swift +++ b/ios/HouseScan/UI/Preview/UIDemo.swift @@ -45,8 +45,11 @@ import SwiftUI /// followed it, so the window and part of its clearance zone are round the corner. For the /// result model: `-uiDemoPhase result -uiDemoCorner`. /// - `-uiDemoPhase spotConfirm`: the spot check before the result, on the made-up sample spot. -/// - `-uiDemoSpotAnswered `: with `-uiDemoPhase spotConfirm`, the check is -/// answered and says what happens next. +/// - `-uiDemoSpotAnswered `: with `-uiDemoPhase spotConfirm`, +/// the check is answered and says what happens next. With `-uiDemoPhase result`, the result +/// follows that answer, so it shows the answer's notice. +/// - `-uiDemoUncheckedElsewhere`: with `-uiDemoPhase result`, an earlier area of the wall was +/// answered "I can't check this area", so the result says so beside its spot's notice. /// - `-uiDemoFollowUp`: with `-uiDemoPhase uploading` or `gapRequest`, the check has answered /// and asked for one more view: the upload screen as it hands over, or the view itself. /// diff --git a/ios/HouseScan/UI/Screens/ResultScreen.swift b/ios/HouseScan/UI/Screens/ResultScreen.swift index 57e1c27a1..e9c989cb2 100644 --- a/ios/HouseScan/UI/Screens/ResultScreen.swift +++ b/ios/HouseScan/UI/Screens/ResultScreen.swift @@ -36,10 +36,7 @@ struct ResultScreen: View { badges(result) } AnswerCard(result: result, sourceAvailable: state.spatialResultAvailable, revealed: revealed, actions: actions) - if result.spot != nil, state.spotCheck?.answer == .somethingThere { - Notice(symbol: "exclamationmark.triangle.fill", text: ScanCopy.spotRefused) - .accessibilityIdentifier("result.spotRefused") - } + spotNotices(result) footnotes(result) .padding(.horizontal, 4) details(result) @@ -64,6 +61,28 @@ struct ResultScreen: View { // MARK: Parts + /// What the homeowner said about this result's spot, and, apart from it, any other area of the + /// wall they couldn't check. The second follows the scan's records, not the spot, so it stays + /// when the answer after "I can't check this area" names no spot or another one. + @ViewBuilder + private func spotNotices(_ result: ResultPresentation) -> some View { + let answer = result.spot != nil ? state.spotCheck?.answer : nil + switch answer { + case .somethingThere: + Notice(symbol: "exclamationmark.triangle.fill", text: ScanCopy.spotRefused) + .accessibilityIdentifier("result.spotRefused") + case .cannotCheck: + Notice(symbol: "eye.slash", text: ScanCopy.spotNotChecked) + .accessibilityIdentifier("result.spotNotChecked") + case .clear, nil: + EmptyView() + } + if state.uncheckedAreaElsewhere { + Notice(symbol: "eye.slash", text: ScanCopy.scanNotChecked(besideSpotNotice: answer == .somethingThere || answer == .cannotCheck)) + .accessibilityIdentifier("result.scanNotChecked") + } + } + @ViewBuilder private func diorama(_ result: ResultPresentation) -> some View { ZStack(alignment: .topLeading) { diff --git a/ios/HouseScan/UI/Screens/SpotConfirmScreen.swift b/ios/HouseScan/UI/Screens/SpotConfirmScreen.swift index 3233b0c16..e9e42e880 100644 --- a/ios/HouseScan/UI/Screens/SpotConfirmScreen.swift +++ b/ios/HouseScan/UI/Screens/SpotConfirmScreen.swift @@ -1,16 +1,17 @@ import SwiftUI /// The spot check before the result: one kept photo with the answer's spot and its clearance area -/// outlined, and one question with two equal answers. A photo can claim wall and ground behind a -/// bush, so the homeowner, who is standing there, says whether anything is in the way. +/// outlined, and one question with three equal answers. A photo can claim wall and ground behind +/// a bush, so the homeowner, who is standing there, says whether anything is in the way, or that +/// they can't see or reach the area to say. /// /// Without an outline (no kept photo shows the area, or there is no wall to draw it with) the /// area is given in words instead: where it runs beside the meter, how far out from the wall and /// how high up it. The question then names no outline, and no photo is shown, since a photo with /// nothing marked on it would leave the homeowner guessing which part of it is meant. /// -/// The answer replaces the two buttons and stays up a moment, saying what happens next, before -/// the engine moves on (to the result, or to checking the wall again). +/// The answer replaces the buttons and stays up a moment, saying what happens next, before the +/// engine moves on (to the result, or to checking the wall again). struct SpotConfirmScreen: View { let state: ScanViewState let actions: any ScanActions @@ -184,7 +185,7 @@ struct SpotConfirmScreen: View { .accessibilityIdentifier("spot.area.\(id)") } - /// The two answers, or once answered, what happens next. + /// The answers, or once answered, what happens next. @ViewBuilder private func answerArea(_ check: SpotCheck) -> some View { if let answer = check.answer { @@ -196,15 +197,20 @@ struct SpotConfirmScreen: View { } } - /// Two equal answers on a white card, so neither reads as the default. + /// Three equal answers on a white card, so none reads as the default. "I can't check this + /// area" has the same size and weight as the other two: a homeowner who can't see the area + /// should find saying so as easy as guessing. private var answers: some View { VStack(spacing: 10) { - AnswerButton(title: ScanCopy.spotClear, selected: false) { actions.answerSpotCheck(clear: true) } + AnswerButton(title: ScanCopy.spotClear, selected: false) { actions.answerSpotCheck(.clear) } .accessibilityHint(ScanCopy.spotClearHint) .accessibilityIdentifier("action.spotClear") - AnswerButton(title: ScanCopy.spotSomethingThere, selected: false) { actions.answerSpotCheck(clear: false) } + AnswerButton(title: ScanCopy.spotSomethingThere, selected: false) { actions.answerSpotCheck(.somethingThere) } .accessibilityHint(ScanCopy.spotSomethingThereHint) .accessibilityIdentifier("action.spotSomethingThere") + AnswerButton(title: ScanCopy.spotCannotCheck, selected: false) { actions.answerSpotCheck(.cannotCheck) } + .accessibilityHint(ScanCopy.spotCannotCheckHint) + .accessibilityIdentifier("action.spotCannotCheck") } .padding(12) .background(Palette.surface, in: .rect(cornerRadius: 18, style: .continuous)) @@ -228,7 +234,7 @@ private struct Answered: View { var body: some View { let copy = ScanCopy.spotAnswered(answer) HStack(alignment: .firstTextBaseline, spacing: 12) { - Image(systemName: answer == .clear ? "checkmark.circle.fill" : "arrow.triangle.2.circlepath") + Image(systemName: Self.symbol(answer)) .font(.title3.weight(.semibold)) .foregroundStyle(answer == .clear ? Palette.passInk : Palette.reviewInk) .accessibilityHidden(true) @@ -250,6 +256,16 @@ private struct Answered: View { .accessibilityElement(children: .combine) .accessibilityIdentifier("spot.answered") } + + /// A tick for clear, the check-again arrows for an obstruction, and an eye struck through for + /// an area nobody checked, as on the result's notice. + private static func symbol(_ answer: SpotCheckAnswer) -> String { + switch answer { + case .clear: "checkmark.circle.fill" + case .somethingThere: "arrow.triangle.2.circlepath" + case .cannotCheck: "eye.slash" + } + } } /// The spot's footprint and its clearance area drawn over the photo, on the ground and up the diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Confirm/SpotConfirmation.swift b/ios/HouseScanKit/Sources/HouseScanKit/Confirm/SpotConfirmation.swift index 1b26e4a85..8836f1e24 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Confirm/SpotConfirmation.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Confirm/SpotConfirmation.swift @@ -8,9 +8,10 @@ import simd // clear space in front of the wall even where something low stands under it (`CoverageMap`, // "Bounded exceptions"). Before the answer is shown, the app shows the homeowner one kept photo // of the spot and the area around it and asks whether anything stands there. "It's clear" keeps -// the claims; "Something's there" takes them back over that area (`CoverageMap.withdrawClaims`) -// and the scan is checked again. This file holds the parts with one correct answer: which area -// is asked about, which photo shows it, and when an answer already given settles a new answer. +// the claims; "Something's there" and "I can't check this area" take them back over that area +// (`CoverageMap.withdrawClaims`) and the scan is checked again. This file holds the parts with +// one correct answer: which area is asked about, which photo shows it, what each answer does to +// the claims, and when an answer already given settles a new answer. /// The area a spot check asks about, meters: the spot's footprint and the clearance zone the /// answer drew around it, as a stretch of wall and the ground in front of it. @@ -163,12 +164,25 @@ public enum SpotPhoto { // MARK: - Binding /// The homeowner's answer to a spot check. -public enum SpotConfirmationAnswer: String, Sendable, Equatable { +public enum SpotConfirmationAnswer: String, Sendable, Equatable, CaseIterable { /// Nothing stands in the area: the photo's claims over it stand, backed by this answer. case clear /// Something stands there: the claims over the area are withdrawn and the scan is checked /// again. case somethingThere + /// The homeowner can't see or reach the area to say: it was not observed. Neither a known + /// obstruction nor clear. The claims over the area are withdrawn, as for `somethingThere`, + /// because nothing backs them, and the scan is checked again. + case cannotCheck + + /// Whether the answer backs the scan's claims over the area. Only "It's clear" does: an + /// obstruction contradicts them, and an area nobody checked leaves them unbacked. + public var keepsClaims: Bool { self == .clear } + + /// How the check's guidance-log entry closes: met when the area is clear, otherwise the + /// packet's generic skipped. The packet has no outcome for "obstructed" or "not checked", + /// and skipped claims neither. + public var guidanceOutcome: PacketGuidanceEntry.Outcome { keepsClaims ? .met : .skipped } } /// One spot check and its answer, tied to what it was about: the area, the answer that named the @@ -197,8 +211,10 @@ public struct SpotConfirmation: Sendable, Equatable { /// holds the new one (`SpotArea.holds`): the same footprint, and an area at least as large. A /// spot that moved, or an area that grew, is asked about again. The homeowner's answer is about /// the place, not the photos, so a new scene of the same place (a re-upload after another view, -/// or after "Something's there") is settled by it; the answer and scene digests record which -/// exchange the homeowner answered. +/// or after "Something's there" or "I can't check this area") is settled by it, with the answer +/// as given; the answer and scene digests record which exchange the homeowner answered. Settling +/// a returned spot this way is also what keeps the homeowner from being asked the same question +/// after each re-upload. public struct SpotConfirmations: Sendable, Equatable { public private(set) var records: [SpotConfirmation] = [] @@ -212,4 +228,19 @@ public struct SpotConfirmations: Sendable, Equatable { public func settling(_ area: SpotArea) -> SpotConfirmation? { records.last { $0.area.holds(area) } } + + /// Whether this scan holds an "I can't check this area" answer that the result's notice about + /// the `shown` spot doesn't already speak for. Its claims stay withdrawn for the rest of the + /// scan (`CoverageMap.withdrawClaims`), so every later answer rests on a stretch nobody + /// checked, whatever spot it names. The spot's own notice speaks for answers about the shown + /// spot only when the answer that settles it (`settling`) is "I can't check this area"; when + /// a later "It's clear" about a larger area settles it instead, an earlier answer about the + /// same spot is still unchecked and still counts. With no spot shown (nil), every such answer + /// counts. + public func leftUnchecked(besides shown: SpotArea?) -> Bool { + let spokenFor = shown.flatMap { area in settling(area)?.answer == .cannotCheck ? area : nil } + return records.contains { record in + record.answer == .cannotCheck && !(spokenFor.map { record.area.holds($0) } ?? false) + } + } } diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Coverage/CoverageMap.swift b/ios/HouseScanKit/Sources/HouseScanKit/Coverage/CoverageMap.swift index 963e513f0..7462e2a61 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Coverage/CoverageMap.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Coverage/CoverageMap.swift @@ -178,9 +178,9 @@ public struct CoverageConfig: Sendable, Equatable { /// homeowner confirms the chosen spot before the result is shown: the spot check (`SpotPhoto`, /// `SpotConfirmations`; the app's `ScanEngine+Confirm.swift`) shows a kept photo of the spot and /// its clearance area and asks whether anything stands in front of the wall or on the ground -/// there. "It's clear" backs the claims over that area. "Something's there" withdraws them -/// (`withdrawClaims(over:)`): the area's wall, ground and walked-path claims export as unseen and -/// the scan is checked again. Claims away from the chosen spot are not confirmed; they decide only +/// there. "It's clear" backs the claims over that area. "Something's there" and "I can't check +/// this area" withdraw them (`withdrawClaims(over:)`): the area's wall, ground and walked-path +/// claims export as unseen and the scan is checked again. Claims away from the chosen spot are not confirmed; they decide only /// where the server looks for a spot, and any spot it chooses is checked in turn. public struct CoverageMap: Sendable { public private(set) var wall: WallFrame @@ -1338,8 +1338,9 @@ public struct CoverageMap: Sendable { /// ground, near band and depth rows alike, and the walked-path clearance. The cells read /// `.skipped` in both bands, and the export reports none of them (`wallSeenSpans`, /// `groundDepthSpans`, `facingSpans`), so the server treats the stretch as unseen. For the - /// homeowner's "Something's there" in the spot check: the camera-only and walked-path claims - /// there were wrong (see the type's "Bounded exceptions"). + /// homeowner's "Something's there" in the spot check, where the camera-only and walked-path + /// claims were wrong, and "I can't check this area", where nothing backs them (see the type's + /// "Bounded exceptions"). /// /// It lasts for the scan, through rebuilds, and later views add nothing there: a photo taken /// without depth would claim the same stretch past the same obstruction. Overhead views are diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/SpotConfirmationTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/SpotConfirmationTests.swift index de256cb7e..9acb36388 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/SpotConfirmationTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/SpotConfirmationTests.swift @@ -115,6 +115,137 @@ import Testing #expect(checks.records.count == 2) } + // MARK: I can't check this area + + /// Three answers, three meanings. Only "It's clear" keeps the claims over the area and closes + /// the check's guidance entry as met; "Something's there" and "I can't check this area" both + /// withdraw them and close it as the packet's generic skipped, yet stay different answers. + @Test func eachAnswerKeepsItsOwnMeaning() { + #expect(SpotConfirmationAnswer.allCases == [.clear, .somethingThere, .cannotCheck]) + #expect(SpotConfirmationAnswer.allCases.map(\.keepsClaims) == [true, false, false]) + #expect(SpotConfirmationAnswer.allCases.map(\.guidanceOutcome) == [.met, .skipped, .skipped]) + #expect(Set(SpotConfirmationAnswer.allCases.map(\.rawValue)).count == 3) + } + + /// "I can't check this area" is recorded as given and settles the same area, or a smaller one, + /// as itself: a returned spot shows the result saying the area went unchecked, never that + /// something was there or that it was clear, and the homeowner isn't asked again. + @Test func cannotCheckIsRecordedAndSettlesTheSameAreaAsItself() throws { + var checks = SpotConfirmations() + checks.record(Self.confirmation(Self.area, .cannotCheck)) + #expect(checks.records.map(\.answer) == [.cannotCheck]) + + var same = Self.area + same.spot = (Self.spot.lowerBound + 0.002)...(Self.spot.upperBound - 0.002) + var smaller = Self.area + smaller.span = Self.spot + for area in [Self.area, same, smaller] { + let settled = try #require(checks.settling(area)) + #expect(settled.answer == .cannotCheck) + #expect(settled == Self.confirmation(Self.area, .cannotCheck)) + } + } + + /// A spot that moved, or an area that grew, isn't settled by "I can't check this area" about + /// the old one: the homeowner is asked about the new area. An answer about it doesn't change + /// what settles the old area. + @Test func cannotCheckDoesNotSettleAMovedOrLargerArea() { + var checks = SpotConfirmations() + checks.record(Self.confirmation(Self.area, .cannotCheck)) + var moved = Self.area + moved.spot = 1.2...2.0 + var wider = Self.area + wider.span = 0.9...3.0 + var deeper = Self.area + deeper.depth = 0.6 + var outward = Self.area + outward.spotOut = 0.05...0.35 + for area in [moved, wider, deeper, outward] { + #expect(checks.settling(area) == nil) + } + checks.record(Self.confirmation(wider, .clear)) + #expect(checks.settling(wider)?.answer == .clear) + // The wider area holds the old one, and its answer is the latest about it. + #expect(checks.settling(Self.area)?.answer == .clear) + #expect(checks.settling(moved) == nil) + } + + /// An area left unchecked stays a fact of the scan, whatever is answered later: its claims + /// stay withdrawn, so a later answer about another area, or about a larger one holding it, + /// doesn't make it checked. Only "I can't check this area" leaves an area unchecked, and a new + /// record (the engine's reset, `ScanEngine.resetSpotChecks`) holds none. + @Test func anAreaLeftUncheckedStaysUncheckedForTheScan() { + var moved = Self.area + moved.spot = 1.2...2.0 + var wider = Self.area + wider.span = 0.9...3.0 + var checks = SpotConfirmations() + #expect(!checks.leftUnchecked(besides: nil)) + checks.record(Self.confirmation(Self.area, .clear)) + checks.record(Self.confirmation(Self.area, .somethingThere)) + #expect(!checks.leftUnchecked(besides: nil)) + #expect(!checks.leftUnchecked(besides: moved)) + + checks.record(Self.confirmation(Self.area, .cannotCheck)) + #expect(checks.leftUnchecked(besides: nil)) + #expect(checks.leftUnchecked(besides: moved)) + checks.record(Self.confirmation(moved, .clear)) + checks.record(Self.confirmation(wider, .clear)) + #expect(checks.settling(Self.area)?.answer == .clear) + #expect(checks.leftUnchecked(besides: nil)) + #expect(checks.leftUnchecked(besides: moved)) + + checks = SpotConfirmations() + #expect(!checks.leftUnchecked(besides: nil)) + } + + /// The answer about the spot shown isn't another area: the result speaks of it on its own. + /// "I can't check this area" about one spot and then about a second leaves the first + /// unchecked beside the second (Greptile on #206), and with no spot shown both count. + @Test func anUncheckedAreaIsElsewhereUnlessItHoldsTheSpotShown() { + var moved = Self.area + moved.spot = 1.2...2.0 + var smaller = Self.area + smaller.span = Self.spot + var checks = SpotConfirmations() + checks.record(Self.confirmation(Self.area, .cannotCheck)) + #expect(!checks.leftUnchecked(besides: Self.area)) + #expect(!checks.leftUnchecked(besides: smaller)) + #expect(checks.leftUnchecked(besides: moved)) + + checks.record(Self.confirmation(moved, .cannotCheck)) + #expect(checks.leftUnchecked(besides: moved)) + #expect(checks.leftUnchecked(besides: Self.area)) + #expect(checks.leftUnchecked(besides: nil)) + } + + /// "I can't check this area" about an area, then "It's clear" about a larger area round the + /// same footprint, then the original area shown again. The clear answer settles it, so the + /// result has no notice about the spot, and the earlier unchecked answer must still be said: + /// its claims stay withdrawn whatever is answered later (review of 6cd1248d). + @Test func aLaterClearAboutALargerAreaLeavesTheEarlierUncheckedOneToSay() { + var wider = Self.area + wider.span = 0.9...3.0 + var checks = SpotConfirmations() + checks.record(Self.confirmation(Self.area, .cannotCheck)) + #expect(!checks.leftUnchecked(besides: Self.area)) + checks.record(Self.confirmation(wider, .clear)) + #expect(checks.settling(Self.area)?.answer == .clear) + #expect(checks.leftUnchecked(besides: Self.area)) + #expect(checks.leftUnchecked(besides: wider)) + } + + /// The latest answer about an area wins whichever two it is, so "I can't check this area" + /// after "Something's there" isn't read as an obstruction, and the other way round. + @Test func cannotCheckAndSomethingThereReplaceEachOther() { + var checks = SpotConfirmations() + checks.record(Self.confirmation(Self.area, .somethingThere)) + checks.record(Self.confirmation(Self.area, .cannotCheck)) + #expect(checks.settling(Self.area)?.answer == .cannotCheck) + checks.record(Self.confirmation(Self.area, .somethingThere)) + #expect(checks.settling(Self.area)?.answer == .somethingThere) + } + // MARK: Something's there /// A walk 2.6 m out from 1 m left of the meter to 3.5 m right, a kept frame every 0.5 m and diff --git a/ios/HouseScanUITests/SpotCannotCheckUITests.swift b/ios/HouseScanUITests/SpotCannotCheckUITests.swift new file mode 100644 index 000000000..11b8c87b2 --- /dev/null +++ b/ios/HouseScanUITests/SpotCannotCheckUITests.swift @@ -0,0 +1,212 @@ +import XCTest + +/// "I can't check this area" through the real engine, on the synthetic wall with the bundled +/// sample answer (`-autopilotCannotCheck`). The answer must take back the scan's claims over the +/// spot's area, send the scan again and end on the result, which says the area went unchecked +/// rather than that something stands there. The sample names the same spot every time, so the +/// second answer is settled by the first; and after a ground refine takes the answer down +/// (`-injectGroundRise`, as GroundFreshnessUITests), the answer that comes back is settled the +/// same way, with the same notice and no second question. When the answer after it names no +/// spot, the result still says an area along the wall went unchecked; after "Something's there" +/// it doesn't. +final class SpotCannotCheckUITests: XCTestCase { + override func setUp() { + continueAfterFailure = false + } + + @MainActor + func testCannotCheckLeavesTheAreaUnseenAndTheResultSaysSo() throws { + let files = FileManager.default + let gate = URL(fileURLWithPath: NSTemporaryDirectory()).appending(path: "housescan-gate-\(UUID().uuidString)", directoryHint: .isDirectory) + try files.createDirectory(at: gate, withIntermediateDirectories: true) + defer { try? files.removeItem(at: gate) } + // The spot check and the result are held for the test; every other screen is let through. + for phase in ["onboarding", "findMeter", "meterCloseUp", "wallWalk", "markFeatures", "gapRequest", "uploading"] { + try Data().write(to: gate.appending(path: phase)) + } + let app = XCUIApplication() + app.launchArguments = [ + "-replay", FullFlowUITests.fixture, "-autopilot", "-autopilotHold", "1.5", "-autopilotGate", gate.path, + "-practiceMeter", "NO", "-sampleResult", "-autopilotCannotCheck", "-injectGroundRise", "0.05", + ] + app.launch() + let any = app.descendants(matching: .any) + + // 1. The engine's spot check offers all three answers. + XCTAssertTrue(waitUntil(timeout: 300) { files.fileExists(atPath: gate.appending(path: "spotConfirm.held").path) }, "the autopilot never reached the spot check") + XCTAssertTrue(any["screen.spotConfirm"].exists) + for id in ["action.spotClear", "action.spotSomethingThere", "action.spotCannotCheck"] { + XCTAssertTrue(app.buttons[id].exists, "the spot check has no \(id)") + } + XCTAssertEqual(app.buttons["action.spotCannotCheck"].label, "I can't check this area") + attach(app, name: "engine-spotConfirm") + try audit(app, "spotConfirm") + try Data().write(to: gate.appending(path: "spotConfirm")) + + // 2. The scan goes again and the result says the area wasn't checked. The acknowledgment + // in between is up for 1.5 s; SpotConfirmUITests reads it held still on the demo. + let result = any["screen.result"] + XCTAssertTrue(result.waitForExistence(timeout: 120), "the scan sent again never reached the result") + XCTAssertTrue(waitUntil(timeout: 30) { files.fileExists(atPath: gate.appending(path: "result.held").path) }, "the autopilot never finished with the result") + assertNotCheckedNotice(app) + attach(app, name: "engine-result-spotNotChecked") + + // 3. The scenes the last upload sent and the app exports claim nothing over the stretch + // the answer withdrew, and still report wall and ground elsewhere. + let refusal = try Data(contentsOf: gate.appending(path: "spot-refusal.json")) + let withdrawn = try XCTUnwrap((JSONSerialization.jsonObject(with: refusal) as? [String: Any])?["withdrawn_span_ft"] as? [Double]) + XCTAssertEqual(withdrawn.count, 2) + let low = try XCTUnwrap(withdrawn.first), high = try XCTUnwrap(withdrawn.last) + XCTAssertGreaterThan(high - low, 1, "withdrawn: \(withdrawn)") + for name in ["scene.json", "uploaded-scene.json"] { + let scene = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: gate.appending(path: name))) as? [String: Any]) + let coverage = try XCTUnwrap(scene["coverage"] as? [String: Any]) + let observed = try XCTUnwrap(coverage["observed"] as? [[String: Any]]) + for band in ["wall", "ground", "facing"] { + let spans = observed.filter { $0["band"] as? String == band }.compactMap { $0["span_ft"] as? [Double] } + for span in spans { + XCTAssertTrue(span[1] <= low + 1e-3 || span[0] >= high - 1e-3, "\(name): \(band) \(span) reaches into the withdrawn \(low)...\(high)") + } + if band != "facing" { + XCTAssertFalse(spans.isEmpty, "\(name): no \(band) observed at all") + } + } + } + + // 4. A ground refine takes the answer down and sends the scan again. The answer that comes + // back names the same spot: the earlier answer settles it, so the result returns with the + // same notice and the homeowner is not asked again. + let uploadGate = gate.appending(path: "uploading") + let uploadHeld = gate.appending(path: "uploading.held") + try files.removeItem(at: uploadGate) + try? files.removeItem(at: uploadHeld) + let trigger = gate.appending(path: "inject-ground") + try Data().write(to: trigger) + XCTAssertTrue(waitUntil(timeout: 10) { !files.fileExists(atPath: trigger.path) }, "the app never took the injected ground") + XCTAssertTrue(any["screen.uploading"].waitForExistence(timeout: 10), "a ground refine left the old answer on the result") + XCTAssertTrue(waitUntil(timeout: 60) { files.fileExists(atPath: uploadHeld.path) }, "the scan was not sent again") + try Data().write(to: uploadGate) + var askedAgain = false + XCTAssertTrue(waitUntil(timeout: 60) { + askedAgain = askedAgain || any["screen.spotConfirm"].exists + return result.exists + }, "the resend's answer never reached the result") + XCTAssertFalse(askedAgain, "the same area was asked about again after the answer was taken down") + assertNotCheckedNotice(app) + attach(app, name: "engine-result-spotNotChecked-afterRefine") + } + + /// "I can't check this area", then an answer that names no spot (`-sampleResultAfterSpotAnswer` + /// with Fixtures/results/reject-nearest.json). There is no spot to tie the answer to, but the + /// area stays out of the scan, so the result says an area along the wall went unchecked, and + /// neither speaks of "this spot" nor of something standing there. + @MainActor + func testCannotCheckThenNoSpotStillSaysAnAreaWasNotChecked() throws { + try runToResult(answer: "-autopilotCannotCheck", thenAnswerFile: "reject-nearest") { app in + let any = app.descendants(matching: .any) + let notice = any["result.scanNotChecked"] + XCTAssertTrue(notice.waitForExistence(timeout: 10), "a result without a spot lost the area the homeowner couldn't check") + XCTAssertEqual(ElementRead.snapshot(notice)?.label, "You couldn't check an area along this wall, so your scan leaves it out as not checked. Someone would need to check it in person.") + XCTAssertFalse(any["result.spotNotChecked"].exists, "a result without a spot speaks of this spot") + XCTAssertFalse(any["result.spotRefused"].exists, "the result says something stands there") + self.attach(app, name: "engine-result-noSpot-scanNotChecked") + } + } + + /// "I can't check this area", then an answer that names another spot (Fixtures/results/ + /// spot-left.json), which the autopilot answers "It's clear". The result's own spot is clear, + /// so it has no spot notice, but the first area is still out of the scan, and the result says + /// so. + @MainActor + func testCannotCheckThenAnotherSpotStillSaysAnAreaWasNotChecked() throws { + try runToResult(answer: "-autopilotCannotCheck", thenAnswerFile: "spot-left") { app in + let any = app.descendants(matching: .any) + let notice = any["result.scanNotChecked"] + XCTAssertTrue(notice.waitForExistence(timeout: 10), "a result for another spot lost the area the homeowner couldn't check") + XCTAssertEqual(ElementRead.snapshot(notice)?.label, "You couldn't check an area along this wall, so your scan leaves it out as not checked. Someone would need to check it in person.") + XCTAssertFalse(any["result.spotNotChecked"].exists, "the other spot, answered clear, reads as not checked") + XCTAssertFalse(any["result.spotRefused"].exists, "the result says something stands there") + self.attach(app, name: "engine-result-otherSpot-scanNotChecked") + } + } + + /// The same answer without a spot after "Something's there": nothing was left unchecked, so + /// the result says nothing about an unchecked area. + @MainActor + func testSomethingThereThenNoSpotLeavesNothingUnchecked() throws { + try runToResult(answer: "-autopilotSomethingThere", thenAnswerFile: "reject-nearest") { app in + let any = app.descendants(matching: .any) + XCTAssertTrue(any["result.headline"].waitForExistence(timeout: 10)) + for id in ["result.scanNotChecked", "result.spotNotChecked", "result.spotRefused"] { + XCTAssertFalse(any[id].exists, "\(id) on a result without a spot after Something's there") + } + } + } + + /// Runs the synthetic wall to the result with `answer` as the first spot answer and every + /// upload after it answered with Fixtures/results/`thenAnswerFile`.json + /// (`-sampleResultAfterSpotAnswer`), then hands the held result to `check`. Later spot checks + /// are answered "It's clear". + @MainActor + private func runToResult(answer: String, thenAnswerFile: String, check: (XCUIApplication) -> Void) throws { + let files = FileManager.default + let gate = URL(fileURLWithPath: NSTemporaryDirectory()).appending(path: "housescan-gate-\(UUID().uuidString)", directoryHint: .isDirectory) + try files.createDirectory(at: gate, withIntermediateDirectories: true) + defer { try? files.removeItem(at: gate) } + // Every screen but the result is let through; the result is held for the test. + for phase in ["onboarding", "findMeter", "meterCloseUp", "wallWalk", "markFeatures", "gapRequest", "uploading", "spotConfirm"] { + try Data().write(to: gate.appending(path: phase)) + } + let file = URL(fileURLWithPath: #filePath).deletingLastPathComponent().appending(path: "Fixtures/results/\(thenAnswerFile).json").path + let app = XCUIApplication() + app.launchArguments = [ + "-replay", FullFlowUITests.fixture, "-autopilot", "-autopilotHold", "1.5", "-autopilotGate", gate.path, + "-practiceMeter", "NO", "-sampleResult", answer, "-sampleResultAfterSpotAnswer", file, + ] + app.launch() + defer { app.terminate() } + XCTAssertTrue(waitUntil(timeout: 300) { files.fileExists(atPath: gate.appending(path: "result.held").path) }, "the autopilot never reached the result") + XCTAssertTrue(files.fileExists(atPath: gate.appending(path: "spot-refusal.json").path), "the spot check was never answered \(answer)") + XCTAssertTrue(app.descendants(matching: .any)["screen.result"].exists) + check(app) + } + + /// The result's notice says the area went unchecked, never that something stands there. The + /// spot's own notice replaces the scan-wide one. + @MainActor + private func assertNotCheckedNotice(_ app: XCUIApplication) { + let any = app.descendants(matching: .any) + let notice = any["result.spotNotChecked"] + XCTAssertTrue(notice.waitForExistence(timeout: 10), "the result doesn't say the area wasn't checked") + XCTAssertEqual(ElementRead.snapshot(notice)?.label, "You couldn't check the area around this spot, so your scan leaves it out as not checked. Someone would need to check it in person.") + XCTAssertFalse(any["result.spotRefused"].exists, "the result says something stands where the homeowner said they couldn't check") + XCTAssertFalse(any["result.scanNotChecked"].exists, "the result repeats the spot's notice for the whole wall") + } + + /// As FullFlowUITests: an issue fails only when a second pass, after a system banner has had + /// time to leave, finds it again. + @MainActor + private func audit(_ app: XCUIApplication, _ screen: String) throws { + let outcome = try AccessibilityAudit.run(app) { _ in Thread.sleep(forTimeInterval: 6) } + for (_, finding) in outcome.persistent { + XCTFail("screen.\(screen): \(finding.message)") + } + } + + @MainActor + private func attach(_ app: XCUIApplication, name: String) { + let shot = XCTAttachment(screenshot: app.screenshot()) + shot.name = name + shot.lifetime = .keepAlways + add(shot) + } + + private func waitUntil(timeout: TimeInterval, _ condition: () -> Bool) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + repeat { + if condition() { return true } + Thread.sleep(forTimeInterval: 0.2) + } while Date() < deadline + return condition() + } +} diff --git a/ios/HouseScanUITests/SpotConfirmUITests.swift b/ios/HouseScanUITests/SpotConfirmUITests.swift index 4f2083ef5..7cf469d40 100644 --- a/ios/HouseScanUITests/SpotConfirmUITests.swift +++ b/ios/HouseScanUITests/SpotConfirmUITests.swift @@ -84,8 +84,157 @@ final class SpotConfirmUITests: XCTestCase { XCTAssertTrue(label.contains("Thanks, we'll leave that area out"), label) } + // MARK: - I can't check this area + + /// The three answers, in order, as each button reads. + private static let answers = [ + ("action.spotClear", "It's clear"), + ("action.spotSomethingThere", "Something's there"), + ("action.spotCannotCheck", "I can't check this area"), + ] + + /// Every answer can be reached and tapped, with and without a photo, at the default text size + /// and the largest: pinned under the photo at the default sizes, after the area at the + /// accessibility sizes, where the homeowner scrolls to them. + @MainActor + func testEveryAnswerIsReachable() throws { + let variants: [(name: String, arguments: [String])] = [ + ("spotConfirm-answers-photo", []), + ("spotConfirm-answers-noPhoto", ["-uiDemoSpotNoPhoto"]), + ("spotConfirm-answers-photo-AX5", Self.largestText), + ("spotConfirm-answers-noPhoto-AX5", ["-uiDemoSpotNoPhoto"] + Self.largestText), + ] + for variant in variants { + let app = launch(variant.arguments) + let window = app.windows.firstMatch.frame + for (id, title) in Self.answers { + let button = app.buttons[id] + XCTAssertTrue(button.waitForExistence(timeout: 10), "\(variant.name): \(id) is missing") + XCTAssertEqual(button.label, title, "\(variant.name): \(id)") + Self.scrollOnScreen(button, in: app) + XCTAssertTrue(button.isHittable, "\(variant.name): \(id) can't be tapped") + XCTAssertTrue(window.contains(button.frame), "\(variant.name): \(id) at \(button.frame) isn't on screen in \(window)") + // Apple's minimum target, whatever the text size. + XCTAssertGreaterThanOrEqual(button.frame.height, 44, "\(variant.name): \(id) is \(button.frame.height) pt tall") + } + attach(app, name: variant.name) + app.terminate() + } + } + + /// "I can't check this area" is taken, with or without a photo, and acknowledged as not + /// checked: never as something seen there, and never as a review someone has arranged. + @MainActor + func testCannotCheckIsAcknowledgedAsNotChecked() throws { + for (name, arguments) in [("spotConfirm-cannotCheck-photo", [String]()), ("spotConfirm-cannotCheck-noPhoto", ["-uiDemoSpotNoPhoto"])] { + let app = launch(arguments) + let answer = app.buttons["action.spotCannotCheck"] + XCTAssertTrue(answer.waitForExistence(timeout: 10), name) + let hittable = XCTNSPredicateExpectation(predicate: NSPredicate(format: "isHittable == true"), object: answer) + XCTAssertEqual(XCTWaiter().wait(for: [hittable], timeout: 10), .completed, "\(name): I can't check this area can't be tapped") + answer.tap() + let answered = element(app, "spot.answered") + XCTAssertTrue(answered.waitForExistence(timeout: 10), name) + let label = ElementRead.snapshot(answered)?.label ?? "" + XCTAssertTrue(label.contains("Thanks, we'll mark that area not checked"), "\(name): \(label)") + XCTAssertTrue(label.contains("Someone would need to check that area in person."), "\(name): \(label)") + for claim in ["leave that area out", "what's there", "installer"] { + XCTAssertFalse(label.contains(claim), "\(name): \(label)") + } + for (id, _) in Self.answers { + XCTAssertFalse(app.buttons[id].exists, "\(name): \(id) is still offered after the answer") + } + attach(app, name: name) + app.terminate() + } + } + + /// The result after "I can't check this area" says the area went unchecked and needs a + /// person, not that something stands there; at the largest text size too, with the notice + /// reached by scrolling. The other answers' result keeps its own notice, or none. + @MainActor + func testTheResultSaysTheAreaWasNotChecked() throws { + let notChecked = "You couldn't check the area around this spot, so your scan leaves it out as not checked. Someone would need to check it in person." + for (name, extra) in [("result-spotNotChecked", [String]()), ("result-spotNotChecked-AX5", Self.largestText)] { + let app = launchResult(answer: "cannotCheck", extra) + let notice = element(app, "result.spotNotChecked") + XCTAssertTrue(notice.waitForExistence(timeout: 10), "\(name): no notice") + // At AX5 the notice sits below the answer card; the homeowner scrolls to read it. + XCTAssertTrue(Self.canBeReadByScrolling(notice, in: app), "\(name): the notice at \(notice.frame) can't be scrolled onto the screen in \(app.windows.firstMatch.frame)") + XCTAssertEqual(ElementRead.snapshot(notice)?.label, notChecked, name) + XCTAssertFalse(element(app, "result.spotRefused").exists, "\(name): the result says something stands there") + attach(app, name: name) + let outcome = try AccessibilityAudit.run(app) { _ in Thread.sleep(forTimeInterval: 6) } + for (_, finding) in outcome.persistent { + XCTFail("\(name): \(finding.message)") + } + app.terminate() + } + let refused = launchResult(answer: "somethingThere", []) + XCTAssertTrue(element(refused, "result.spotRefused").waitForExistence(timeout: 10), "Something's there lost its notice") + XCTAssertFalse(element(refused, "result.spotNotChecked").exists) + XCTAssertFalse(element(refused, "result.scanNotChecked").exists) + refused.terminate() + let clear = launchResult(answer: "clear", []) + XCTAssertTrue(element(clear, "result.headline").waitForExistence(timeout: 10)) + for id in ["result.spotRefused", "result.spotNotChecked", "result.scanNotChecked"] { + XCTAssertFalse(element(clear, id).exists, "It's clear shows \(id)") + } + clear.terminate() + } + + /// After "I can't check this area", an answer without a spot (Fixtures/results/ + /// reject-nearest.json) still says an area along the wall went unchecked, without "this spot"; + /// at the largest text size too. After "Something's there" it says nothing of the kind. + @MainActor + func testAResultWithoutASpotStillSaysAnAreaWasNotChecked() throws { + let noSpot = ["-uiDemoResultFile", Self.resultFile("reject-nearest")] + let text = "You couldn't check an area along this wall, so your scan leaves it out as not checked. Someone would need to check it in person." + for (name, extra) in [("result-noSpot-scanNotChecked", noSpot), ("result-noSpot-scanNotChecked-AX5", noSpot + Self.largestText)] { + let app = launchResult(answer: "cannotCheck", extra) + let notice = element(app, "result.scanNotChecked") + XCTAssertTrue(notice.waitForExistence(timeout: 10), "\(name): no notice") + XCTAssertTrue(Self.canBeReadByScrolling(notice, in: app), "\(name): the notice at \(notice.frame) can't be scrolled onto the screen") + XCTAssertEqual(ElementRead.snapshot(notice)?.label, text, name) + XCTAssertFalse(element(app, "result.spotNotChecked").exists, "\(name): a result without a spot speaks of this spot") + XCTAssertFalse(element(app, "result.spotRefused").exists, name) + attach(app, name: name) + let outcome = try AccessibilityAudit.run(app) { _ in Thread.sleep(forTimeInterval: 6) } + for (_, finding) in outcome.persistent { + XCTFail("\(name): \(finding.message)") + } + app.terminate() + } + // An earlier area left unchecked, beside this spot's own "I can't check this area": both + // are said, the second as another area (Greptile on #206). + let both = launchResult(answer: "cannotCheck", ["-uiDemoUncheckedElsewhere"]) + let spotNotice = element(both, "result.spotNotChecked"), wallNotice = element(both, "result.scanNotChecked") + XCTAssertTrue(spotNotice.waitForExistence(timeout: 10), "the spot's notice is missing beside another unchecked area") + XCTAssertTrue(Self.canBeReadByScrolling(wallNotice, in: both), "the other area's notice can't be scrolled onto the screen") + XCTAssertEqual(ElementRead.snapshot(wallNotice)?.label, "You also couldn't check another area along this wall, so your scan leaves it out as not checked. Someone would need to check it in person.") + attach(both, name: "result-spotAndScanNotChecked") + both.terminate() + + let refused = launchResult(answer: "somethingThere", noSpot) + XCTAssertTrue(element(refused, "result.headline").waitForExistence(timeout: 10)) + for id in ["result.spotRefused", "result.spotNotChecked", "result.scanNotChecked"] { + XCTAssertFalse(element(refused, id).exists, "Something's there without a spot shows \(id)") + } + refused.terminate() + } + // MARK: - Helpers + /// The demo's result after the spot check was answered `answer`. + @MainActor + private func launchResult(answer: String, _ arguments: [String]) -> XCUIApplication { + let app = XCUIApplication() + app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoFreeze", "-uiDemoPhase", "result", "-uiDemoSpotAnswered", answer] + arguments + app.launch() + XCTAssertTrue(element(app, "screen.result").waitForExistence(timeout: 15), "the result never appeared") + return app + } + /// The words-only spot check: no photo element, a question that names no outline, each of /// the area's three lines rendered on screen with its spoken label, both answers present, and /// a clean accessibility audit, which also checks those lines for contrast and clipping. @@ -119,6 +268,7 @@ final class SpotConfirmUITests: XCTestCase { XCTAssertEqual(app.descendants(matching: .any).matching(outline).count, 0, "\(name): something still mentions the outline") XCTAssertTrue(app.buttons["action.spotClear"].exists, "\(name): It's clear is missing") XCTAssertTrue(app.buttons["action.spotSomethingThere"].exists, "\(name): Something's there is missing") + XCTAssertTrue(app.buttons["action.spotCannotCheck"].exists, "\(name): I can't check this area is missing") // As ScreenStatesUITests: an issue fails only when a second pass, after a system banner // has had time to leave, finds it again. let outcome = try AccessibilityAudit.run(app) { _ in Thread.sleep(forTimeInterval: 6) } @@ -159,6 +309,33 @@ final class SpotConfirmUITests: XCTestCase { } } + /// Whether every line of `element` can be brought on screen. One that fits the window is + /// scrolled wholly onto it (`scrollOnScreen`). A taller one, such as a long notice at AX5, is + /// read the way a homeowner would: its top edge is scrolled on screen, then its bottom edge. + @MainActor + static func canBeReadByScrolling(_ element: XCUIElement, in app: XCUIApplication) -> Bool { + let window = app.windows.firstMatch.frame + guard element.frame.height > window.height else { + scrollOnScreen(element, in: app) + return window.contains(element.frame) + } + let scroll = app.scrollViews.firstMatch + func drag(_ distance: CGFloat) { + let start = scroll.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)) + start.press(forDuration: 0.05, thenDragTo: start.withOffset(CGVector(dx: 0, dy: distance)), withVelocity: .slow, thenHoldForDuration: 0.3) + } + let most = window.height * 0.6 + for _ in 0..<10 where element.frame.minY > window.maxY - 24 || element.frame.minY < window.minY { + let top = element.frame.minY + drag(top > window.minY ? -min(top - window.minY - 24, most) : min(window.minY - top + 24, most)) + } + guard window.minY...window.maxY ~= element.frame.minY else { return false } + for _ in 0..<10 where element.frame.maxY > window.maxY { + drag(-min(element.frame.maxY - window.maxY + 24, most)) + } + return window.minY...window.maxY ~= element.frame.maxY + } + /// A server answer in Fixtures/results, which the demo reads in debug builds. private static func resultFile(_ name: String, file: String = #filePath) -> String { URL(fileURLWithPath: file).deletingLastPathComponent().appending(path: "Fixtures/results/\(name).json").path diff --git a/ios/README.md b/ios/README.md index 4b22c328b..7498e1bba 100644 --- a/ios/README.md +++ b/ios/README.md @@ -19,12 +19,14 @@ Native iPhone app for the AR capture walk. The homeowner marks the electric mete | `-autopilotHold ` | How long the autopilot leaves each screen up (default 1.2 s). | | `-autopilotCantGetThere` | With `-autopilot`, ends the walk the way device run 1 did. It answers "Can't get there" to the walk's first request, the ground in front of the meter, before the replay has shown it. Then, instead of marking the ends, it plays again the frame where the walk went farthest on each side and answers "Can't get there" when the walk asks to go on that way, so the end lands where the phone stands. `FullFlowUITests.testCantGetThereEndsWhereThePhoneIs` runs it. | | `-autopilotSomethingThere` | With `-autopilot`, answers the first spot check "Something's there" instead of "It's clear". The scan stops claiming that area, is uploaded again, and later checks get "It's clear". The stretch taken out goes to the `-autopilotGate` folder as `spot-refusal.json` (`withdrawn_span_ft`, feet along the wall as in scene.json). `FullFlowUITests.testSomethingThereChecksTheWallAgain` runs it. | +| `-autopilotCannotCheck` | With `-autopilot`, answers the first spot check "I can't check this area" instead. As with `-autopilotSomethingThere`, the scan stops claiming that area, is uploaded again, and the withdrawn stretch goes to `spot-refusal.json`; the result says the area wasn't checked. `SpotCannotCheckUITests` runs it. The two flags can't be combined. | +| `-sampleResultAfterSpotAnswer ` | Debug builds, with the bundled sample. The upload sent after "Something's there" or "I can't check this area", and every later upload of that scan, is answered with the server answer in this JSON file, such as one without a spot. Start over or a new wall goes back to the bundled sample. `SpotCannotCheckUITests` uses it. | | `-autopilotGate ` | Used by the UI tests. Before leaving a screen, the app waits for a file named after that phase in this folder, and while it waits it leaves `.held` there, so a test can audit the screen once the autopilot has finished with it. Once the result shows, the autopilot writes the scan's scene.json there. | | `-simulateAppStore` | Runs as an App Store install would: no developer options, and practice meter stays off whatever the stored switch says. `PracticeMeterUITests` uses it. | | `-practiceMeter YES` or `NO` | Overrides the stored practice meter switch for one run (UserDefaults' argument domain). The full-flow UI tests pass `NO`, since the switch outlives a run in the Simulator. | | `-uiDemo` | Runs the screens on a scripted fake engine instead of the capture engine, for design work and for auditing states a replay can't reach. The arguments it takes are listed in `HouseScan/UI/Preview/UIDemo.swift`; for example `-uiDemoGroundQuestion` opens the feature review with the ground question showing, `-uiDemoPhase meterCloseUp -uiDemoMeterChoose` shows the meter number picker, and `-uiDemoPhase result -uiDemoCorner` shows the result model of a wall that turns a corner. | -**Spot check.** Before the result, the app shows the kept photo that best sees the answer's spot, with the spot and its clearance area outlined, and asks whether anything stands in front of the wall or on the ground there (`STATE=spotConfirm`). When no kept photo shows the area, it shows no photo and gives the area in words: where it runs beside the meter, how far out from the wall, and how high up the wall. Photos without depth and the walked path claim wall, ground and clear space they never saw; "It's clear" backs those claims for that spot, and "Something's there" takes them back over the area, so scene.json reports it unseen, and sends the scan again. The rules are in `Runtime/ScanEngine+Confirm.swift` and HouseScanKit's `Confirm/SpotConfirmation.swift`. +**Spot check.** Before the result, the app shows the kept photo that best sees the answer's spot, with the spot and its clearance area outlined, and asks whether anything stands in front of the wall or on the ground there (`STATE=spotConfirm`). When no kept photo shows the area, it shows no photo and gives the area in words: where it runs beside the meter, how far out from the wall, and how high up the wall. Photos without depth and the walked path claim wall, ground and clear space they never saw; "It's clear" backs those claims for that spot, and "Something's there" takes them back over the area, so scene.json reports it unseen, and sends the scan again. "I can't check this area", for a homeowner who can't see or reach it, does the same without saying anything stands there, and the result says the area wasn't checked and needs a person to check it. When the scan sent again names the same spot, the earlier answer settles it and the homeowner isn't asked again. When it names no spot, or another one, the result still says an area along the wall went unchecked, until Start over or a new wall clears the scan's spot checks. The rules are in `Runtime/ScanEngine+Confirm.swift` and HouseScanKit's `Confirm/SpotConfirmation.swift`. **Practice meter.** For trying the whole scan where there is no electric meter, such as indoors. Debug and TestFlight builds show a wrench button at the top of the first screen. Tap it, turn on **Practice meter**, tap Done, then start the scan. At "find the meter", tap any spot on a wall: the usual raycast and refusals apply, and a drawn sample meter appears there, fixed to the meter's anchor, on every camera screen after that. The close-up keeps the live camera's pose and depth, but stores a photo of the sample (made-up maker SAMPLEWORKS, number 12345678), and the real reader reads it. The walk, fog, marks, requests, spot check, upload, result and AR all run for real. Every screen shows a "Practice meter" badge, and `scan-stamp.json` has `"practice": true`; the packet and scene.json are unchanged. The switch is kept until turned off, and the button reads "Practice meter on" while it is. App Store installs never show the button and ignore a stored switch: the app asks StoreKit's `AppTransaction` which server signed the install (sandbox means TestFlight) and treats no answer as the App Store. It is not in the iOS Settings app, because a Settings.bundle ships in the same binary that TestFlight later promotes to the App Store. The code is in `Runtime/Practice/`, `UI/Practice/` and HouseScanKit's `Practice/PracticeMeter.swift`.