From 6f2046856124ef0904e282f813db45ba692bdbec Mon Sep 17 00:00:00 2001 From: AidenJohnston Date: Sun, 27 Sep 2026 02:45:42 -0500 Subject: [PATCH 1/3] ios: ask before a second "Can't get there" ends the scan, and show no spot for a wall never walked A second "Can't get there" on a walk card within 5 s of the one that last ended a side now asks "End the scan here?" (Yes, end here / Keep walking) instead of silently ending the other side (#82, option C). Yes ends every open side where "Can't get there" would and finishes the walk; Keep walking dismisses it. The engine now records which ends "Can't get there" set before the homeowner had walked a battery's width on that side (WalkRefusals). When both ends are still those, the result says "We couldn't measure your wall" with no spot, no route and no "See it on your wall" (#76, option (a); app only, no schema change). Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Contract/ResultPresentation+Reading.swift | 20 ++++++ ios/HouseScan/Contract/ScanContract.swift | 14 ++++ ios/HouseScan/Runtime/Autopilot.swift | 8 +++ .../Runtime/ScanEngine+Actions.swift | 44 +++++++++++- ios/HouseScan/Runtime/ScanEngine+Ends.swift | 14 +++- ios/HouseScan/Runtime/ScanEngine+Export.swift | 7 +- ios/HouseScan/Runtime/ScanEngine.swift | 8 +++ ios/HouseScan/UI/Copy/ScanCopy.swift | 15 ++++ ios/HouseScan/UI/Preview/DemoEngine.swift | 21 ++++++ ios/HouseScan/UI/Screens/ResultScreen.swift | 44 +++++++++++- ios/HouseScan/UI/Screens/WallWalkScreen.swift | 31 +++++++- .../HouseScanKit/Guidance/WalkRefusals.swift | 61 ++++++++++++++++ .../HouseScanKitTests/WalkRefusalsTests.swift | 71 +++++++++++++++++++ .../ScreenStatesUITests.swift | 41 +++++++++++ 14 files changed, 389 insertions(+), 10 deletions(-) create mode 100644 ios/HouseScanKit/Sources/HouseScanKit/Guidance/WalkRefusals.swift create mode 100644 ios/HouseScanKit/Tests/HouseScanKitTests/WalkRefusalsTests.swift diff --git a/ios/HouseScan/Contract/ResultPresentation+Reading.swift b/ios/HouseScan/Contract/ResultPresentation+Reading.swift index 87654142e..32ac0295d 100644 --- a/ios/HouseScan/Contract/ResultPresentation+Reading.swift +++ b/ios/HouseScan/Contract/ResultPresentation+Reading.swift @@ -5,6 +5,26 @@ import HouseScanKit // answer means the engine's results and the UI demo's samples go through the same rules. extension ResultPresentation { + /// This answer for a wall neither side of which was walked (`wallNotMeasured`, #76): the + /// server still placed a spot on the tapped wall line, but nothing confirmed that line, so + /// the spot, the nearest spot, the cable route, the clearances and the checks and requests + /// made at them are dropped. The rules' notice and hash stay. + func withWallNotMeasured() -> ResultPresentation { + var shown = self + shown.wallNotMeasured = true + shown.summary = "" + shown.spot = nil + shown.nearestSpot = nil + shown.nearestFailingCheck = nil + shown.cableRoute = [] + shown.cableLength = nil + shown.checks = [] + shown.clearances = [] + shown.missing = [] + shown.unseenEnd = nil + return shown + } + /// True when there is a spot and the meter working-space check at it, if the server ran one, /// passed. var spotIsClean: Bool { diff --git a/ios/HouseScan/Contract/ScanContract.swift b/ios/HouseScan/Contract/ScanContract.swift index bf09ac721..3bb7b11c4 100644 --- a/ios/HouseScan/Contract/ScanContract.swift +++ b/ios/HouseScan/Contract/ScanContract.swift @@ -671,6 +671,11 @@ struct ResultPresentation: Equatable, Sendable { /// Where the scan stopped on a side it didn't finish, nearer the meter than the spot, so a /// closer spot may lie past it (`PlacementResult.closerUnseenEnd`). var unseenEnd: UnseenEnd? = nil + /// True when neither side of the meter was walked: "Can't get there" ended both before the + /// homeowner walked either (`WalkRefusals`, #76). The wall line then comes from the meter tap + /// alone, so the screen says the wall couldn't be measured and shows no spot, no route and no + /// "See it on your wall" (`withWallNotMeasured`). + var wallNotMeasured = false /// True when no server answered and the result is the offline sample used by tests and /// demos. The UI must say so on screen. var isSample: Bool @@ -744,6 +749,10 @@ final class ScanViewState { /// offer (a question or a mark is up, both ends are marked, or the walk is doing something /// else). "Wall ends here" shows only while it is set. var endPreview: EndPreview? + /// "Can't get there" came again on a walk card within `WalkRefusals.repeatWindow` of the one + /// that last ended a side: the walk asks "End the scan here?" instead of ending this side too + /// (#82). Answered by `answerEndScan`. + var endScanQuestion = false /// True after "Done with this wall" was refused because the ends were closer together than /// `WallFrame.minWallLength`; the ends were cleared. False again once an end is marked. var wallTooShort = false @@ -829,6 +838,11 @@ protocol ScanActions: AnyObject { /// Leave the walk for the feature review (allowed once both ends are marked). Ends closer /// together than `WallFrame.minWallLength` are cleared instead, and `wallTooShort` is set. func finishWalk() + /// The answer to `ScanViewState.endScanQuestion`. "Yes, end here" (`end`) ends every side + /// without an end where "Can't get there" would (`ScanViewState.endPreview`, unexplored) and + /// finishes the walk with what was walked, as `finishWalk` does. "Keep walking" dismisses the + /// question, and the next "Can't get there" ends its side without asking. + func answerEndScan(_ end: Bool) /// Features confirmed; the engine runs the gap check, then uploads. func confirmFeatures() /// "I can't get there": the gap is recorded for installer review. On a request the finished diff --git a/ios/HouseScan/Runtime/Autopilot.swift b/ios/HouseScan/Runtime/Autopilot.swift index 837842c92..d8e30d706 100644 --- a/ios/HouseScan/Runtime/Autopilot.swift +++ b/ios/HouseScan/Runtime/Autopilot.swift @@ -266,6 +266,14 @@ final class Autopilot { } await pause(hold) engine.cannotAccessArea() + if engine.state.endScanQuestion { + // Soon after the other side's: the walk asks whether to end the scan (#82). This + // homeowner walked both sides, so keeps walking and ends this side as before. + log("asked to end the scan on the \(side.rawValue); keeping walking") + await pause(hold) + engine.answerEndScan(false) + engine.cannotAccessArea() + } let end = side == .left ? engine.coverage?.leftEnd : engine.coverage?.rightEnd log("can't get there on the \(side.rawValue) with the phone at s=\(side.walk.sign * along(index)): end at s=\(end ?? .nan)") } diff --git a/ios/HouseScan/Runtime/ScanEngine+Actions.swift b/ios/HouseScan/Runtime/ScanEngine+Actions.swift index 846854b9b..d11688afd 100644 --- a/ios/HouseScan/Runtime/ScanEngine+Actions.swift +++ b/ios/HouseScan/Runtime/ScanEngine+Actions.swift @@ -370,8 +370,17 @@ extension ScanEngine: ScanActions { case .gapRequest: skipCurrentGap() case .wallWalk: - guard coverage != nil else { return } + guard coverage != nil, !state.endScanQuestion else { return } let task = ScanEngine.name(state.guidance) + if isWalkTask, state.endQuestion == nil, state.marking == nil, !state.overheadQuestion, + walkRefusals.asksToEndScan(at: ScanEngine.refusalClock) { + // A second "Can't get there" on a walk card soon after the last ended a side: + // the homeowner may be trying to stop, so ask before ending this side too (#82). + // The task stays unresolved until the answer. + RuntimeLog.engine.info("cannot access area again during \(task, privacy: .public): asking to end the scan") + state.endScanQuestion = true + return + } switch state.guidance { case .aimAtGround, .aimAtWall, .seeBehind, .walk, .markEnd, .tiltUp, .markNextWall: resolveGuidance(.cannotReach) @@ -413,6 +422,36 @@ extension ScanEngine: ScanActions { } } + func answerEndScan(_ end: Bool) { + guard state.phase == .wallWalk, state.endScanQuestion else { return } + state.endScanQuestion = false + guard end else { + RuntimeLog.engine.info("end the scan here? keep walking") + walkRefusals.keepWalking() + return + } + RuntimeLog.engine.info("end the scan here? yes") + // The walk task the second "Can't get there" answered is met as refused. + if isWalkTask { resolveGuidance(.cannotReach) } + // Each side without an end ends where "Can't get there" would put it (`WalkedEnd`). + for side in [WallSide.left, .right] where (side == .left ? coverage?.leftEnd : coverage?.rightEnd) == nil { + endWalkCannotGoOn(side) + } + walkRefusals.keepWalking() + // Ends closer than a battery is wide are refused here as after "Done with this wall", + // and the walk goes on with the card saying so. + finishWalk() + } + + /// The walk asks to walk a side or to mark its end: the steps whose "Can't get there" ends + /// the wall on that side (`endWalkCannotGoOn`). + private var isWalkTask: Bool { + switch state.guidance { + case .walk, .markEnd: true + default: false + } + } + func retryUpload() { guard state.phase == .uploading else { return } if case .failed = state.upload { startUpload() } @@ -437,7 +476,8 @@ extension ScanEngine: ScanActions { } func showAR() { - guard state.phase == .result, state.spatialResultAvailable else { return } + // A wall neither side of which was walked has no spot to show (#76). + guard state.phase == .result, state.spatialResultAvailable, state.result?.wallNotMeasured != true else { return } go(.resultAR) } diff --git a/ios/HouseScan/Runtime/ScanEngine+Ends.swift b/ios/HouseScan/Runtime/ScanEngine+Ends.swift index 6a1d45f1d..16dd29eb0 100644 --- a/ios/HouseScan/Runtime/ScanEngine+Ends.swift +++ b/ios/HouseScan/Runtime/ScanEngine+Ends.swift @@ -97,13 +97,23 @@ extension ScanEngine { } /// "Can't get there" while the walk asks to walk `side` or to mark its end: the end goes where - /// the preview showed, as an unexplored end. + /// the preview showed, as an unexplored end. `walkRefusals` notes when, and whether the side + /// had been walked (#82, #76). func endWalkCannotGoOn(_ side: WallSide) { - guard let s = walkedEnd(side) else { return } + guard let s = walkedEnd(side), let map = coverage else { return } logEnd("can't get there", side: side, at: s) + let walked = map.walkedFarthest(side.walk) setEnd(side, at: s, kind: .unexplored) + walkRefusals.ended(side.walk, at: s, walked: walked, time: ScanEngine.refusalClock) + if walked < WalkRefusals.walkedMinimum { + RuntimeLog.engine.info("the \(side.rawValue, privacy: .public) side ended before it was walked (\(walked) m)") + } } + /// Seconds for `WalkRefusals`: the time since boot, which a replay's frame clock doesn't + /// hold back while its playback is paused. + static var refusalClock: TimeInterval { ProcessInfo.processInfo.systemUptime } + /// Where the end went and what the old rule, the unbroken covered reach, would have said. private func logEnd(_ action: String, side: WallSide, at s: Float) { guard let map = coverage else { return } diff --git a/ios/HouseScan/Runtime/ScanEngine+Export.swift b/ios/HouseScan/Runtime/ScanEngine+Export.swift index f659d792a..a23fb2708 100644 --- a/ios/HouseScan/Runtime/ScanEngine+Export.swift +++ b/ios/HouseScan/Runtime/ScanEngine+Export.swift @@ -193,10 +193,15 @@ extension ScanEngine { // its span (and a facing item its out_ft, which a walk can reach), a past_end item its // side. Otherwise the button would do nothing. A request the homeowner already skipped // or answered with something overhead stays with the installer. - return Self.presentation(of: result, isSample: isSample, wall: sceneWall) { [gapPlanner, coverage, skippedGaps] item in + let shown = Self.presentation(of: result, isSample: isSample, wall: sceneWall) { [gapPlanner, coverage, skippedGaps] item in gapPlanner.plan(for: item, leftEnd: coverage?.leftEnd, rightEnd: coverage?.rightEnd, limitEnds: coverage?.limitEnds ?? []) .map { plan in !skippedGaps.contains { plan.asksForSameView(as: $0) } } ?? false } + // "Can't get there" ended both sides before either was walked (#76): the server placed + // the spot on the meter tap's wall line alone, which nothing confirmed. + guard walkRefusals.neitherSideWalked(leftEnd: coverage?.leftEnd, rightEnd: coverage?.rightEnd) else { return shown } + RuntimeLog.engine.info("result: neither side of the meter was walked; showing no spot") + return shown.withWallNotMeasured() } /// `presentation(of:isSample:)` without an engine, for the UI demo: `wall` is the scan's wall diff --git a/ios/HouseScan/Runtime/ScanEngine.swift b/ios/HouseScan/Runtime/ScanEngine.swift index 67f6c4221..817345570 100644 --- a/ios/HouseScan/Runtime/ScanEngine.swift +++ b/ios/HouseScan/Runtime/ScanEngine.swift @@ -78,6 +78,9 @@ final class ScanEngine { /// to be marked (`GuidanceStep.markNextWall`), and why the last mark was refused. var nextWallSide: WallSide? var nextWallRefusal: NextWallRefusal? + /// "Can't get there" on the walk's card: when it last ended a side, and which ends it set + /// before the homeowner walked that side (#82, #76). Reset with the wall. + var walkRefusals = WalkRefusals() // Gap loop private(set) var gapPlan: GapPlan? @@ -243,6 +246,8 @@ final class ScanEngine { breakWalkedPath(because: "the walk paused (\(state.phase.rawValue) -> \(phase.rawValue))") } if state.phase == .resultAR { hideResultInCamera() } + // "End the scan here?" belongs to the walk it was asked on. + state.endScanQuestion = false state.phase = phase RuntimeLog.state.info("STATE=\(phase.rawValue, privacy: .public)") switch phase { @@ -1319,6 +1324,7 @@ final class ScanEngine { state.wallTooShort = false nextWallSide = nil nextWallRefusal = nil + walkRefusals = WalkRefusals() resetTiltUp() // An answer describes a scan that no longer exists; the next upload brings a new one. uploadTask?.cancel() @@ -1353,6 +1359,7 @@ final class ScanEngine { state.wallTooShort = false nextWallSide = nil nextWallRefusal = nil + walkRefusals = WalkRefusals() resetTiltUp() publishWall() publishCoverage() @@ -1859,6 +1866,7 @@ final class ScanEngine { state.wallTooShort = false nextWallSide = nil nextWallRefusal = nil + walkRefusals = WalkRefusals() resetTiltUp() placement = nil // The bundle belongs to the scan being thrown away; `generation` stops a write in flight diff --git a/ios/HouseScan/UI/Copy/ScanCopy.swift b/ios/HouseScan/UI/Copy/ScanCopy.swift index b544aeb0a..565d1f873 100644 --- a/ios/HouseScan/UI/Copy/ScanCopy.swift +++ b/ios/HouseScan/UI/Copy/ScanCopy.swift @@ -425,6 +425,15 @@ enum ScanCopy { return Instruction(title: "What's at the \(side.rawValue) end?", detail: detail) } + /// Asked when "Can't get there" comes again soon after it ended a side + /// (`ScanViewState.endScanQuestion`, #82): the homeowner may be trying to stop. + static let endScanQuestion = Instruction( + title: "End the scan here?", + detail: "We'll check the part of the wall you've walked." + ) + static let endScanYes = "Yes, end here" + static let keepWalking = "Keep walking" + /// With no server connected nothing is sent, and the words must not say it is. /// `followUps` is how many views the finished check still wants from the camera. static func upload(_ upload: UploadState, sample: Bool, followUps: Int = 0) -> Instruction { @@ -533,6 +542,12 @@ enum ScanCopy { static let scanAnotherWall = "Scan another wall" static let details = "Details" + /// A result for a wall neither side of which was walked (`ResultPresentation.wallNotMeasured`, + /// #76): the spot would stand on the meter tap alone, so none is shown. + static let wallNotMeasured = "We couldn't measure your wall" + 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." // The server's result covers where the battery goes, not the panel itself. diff --git a/ios/HouseScan/UI/Preview/DemoEngine.swift b/ios/HouseScan/UI/Preview/DemoEngine.swift index 12f3da8e6..e041fd8c6 100644 --- a/ios/HouseScan/UI/Preview/DemoEngine.swift +++ b/ios/HouseScan/UI/Preview/DemoEngine.swift @@ -14,6 +14,9 @@ final class DemoEngine: ScanActions { private let passResult: Bool /// `-uiDemoOverlap`: the sample's spot overlaps the meter's working space (#40). private let overlapResult: Bool + /// `-uiDemoWallNotMeasured`: neither side of the meter was walked, so the result shows no + /// spot (#76). + private let wallNotMeasured: Bool /// `-uiDemoResultFile ` (debug builds only): a server answer in a JSON file, read /// through the engine's own mapping, in place of the hand-made samples. private let resultFile: String? @@ -63,6 +66,7 @@ final class DemoEngine: ScanActions { offline = arguments.contains("-uiDemoOffline") passResult = arguments.contains("-uiDemoPass") overlapResult = arguments.contains("-uiDemoOverlap") + wallNotMeasured = arguments.contains("-uiDemoWallNotMeasured") #if DEBUG resultFile = value("-uiDemoResultFile") #else @@ -116,6 +120,10 @@ final class DemoEngine: ScanActions { state.endQuestion = .left } } + if arguments.contains("-uiDemoEndScanQuestion") { + // "Can't get there" again soon after it ended the right side (#82). + state.endScanQuestion = true + } if arguments.contains("-uiDemoNextWall") { state.wall?.rightEnd = demoRightEnd reachedRight = demoRightEnd @@ -526,6 +534,7 @@ final class DemoEngine: ScanActions { /// The check's answer, before or after its follow-up view. private var sample: ResultPresentation { + if wallNotMeasured { return Self.reviewSample.withWallNotMeasured() } if passResult { return Self.passSample } if overlapResult { return Self.overlapSample } if let fileResult { return fileResult } @@ -855,6 +864,17 @@ final class DemoEngine: ScanActions { state.phase = .markFeatures } + /// The demo never asks on its own (its "Can't get there" ends the side at once); the question + /// shows only with `-uiDemoEndScanQuestion`. "Yes, end here" finishes the walk with the demo's + /// ends. + func answerEndScan(_ end: Bool) { + guard state.endScanQuestion else { return } + state.endScanQuestion = false + guard end else { return } + finishedWalkState() + finishWalk() + } + func confirmFeatures() { enterGap() } @@ -926,6 +946,7 @@ final class DemoEngine: ScanActions { } func showAR() { + guard state.result?.wallNotMeasured != true else { return } state.phase = .resultAR } diff --git a/ios/HouseScan/UI/Screens/ResultScreen.swift b/ios/HouseScan/UI/Screens/ResultScreen.swift index b4f1608cf..a478f8fb5 100644 --- a/ios/HouseScan/UI/Screens/ResultScreen.swift +++ b/ios/HouseScan/UI/Screens/ResultScreen.swift @@ -138,7 +138,7 @@ struct ResultScreen: View { } // Last, so the one action that throws the scan away is the farthest. The card's // button already starts over after a reject. - if result.answer != .notHere { + if result.answer != .notHere, !result.wallNotMeasured { Button("Start over") { actions.startOver() } .buttonStyle(TextActionStyle()) .accessibilityHint("Deletes this scan and its photos.") @@ -183,9 +183,49 @@ private struct AnswerCard: View { let actions: any ScanActions var body: some View { + if result.wallNotMeasured { + notMeasured + } else { + answerCard + } + } + + /// Neither side of the meter was walked (#76): no spot, no route, no checks, and the one + /// next step is a new scan. + private var notMeasured: some View { + VStack(alignment: .leading, spacing: 0) { + VStack(alignment: .leading, spacing: 8) { + Text(ScanCopy.wallNotMeasured) + .font(Typeface.screenTitle) + .fixedSize(horizontal: false, vertical: true) + .accessibilityAddTraits(.isHeader) + .accessibilityIdentifier("result.headline") + Text(ScanCopy.wallNotMeasuredDetail) + .font(Typeface.hint) + .fixedSize(horizontal: false, vertical: true) + .accessibilityIdentifier("result.wallNotMeasured") + } + .modifier(Reveal(revealed: revealed, order: 0)) + + Button { + actions.startOver() + } label: { + Label(ScanCopy.scanAgain, systemImage: "arrow.counterclockwise") + } + .buttonStyle(.primary) + .accessibilityHint("Deletes this scan and its photos.") + .accessibilityIdentifier("action.startOver") + .padding(.top, 18) + } + .padding(20) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Palette.surface, in: .rect(cornerRadius: Metrics.cardRadius, style: .continuous)) + } + + private var answerCard: some View { let answer = result.answer let lines = result.cardChecks - VStack(alignment: .leading, spacing: 0) { + return VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 8) { Text(ScanCopy.headline(answer)) .font(Typeface.screenTitle) diff --git a/ios/HouseScan/UI/Screens/WallWalkScreen.swift b/ios/HouseScan/UI/Screens/WallWalkScreen.swift index bf6f1b010..55138f74f 100644 --- a/ios/HouseScan/UI/Screens/WallWalkScreen.swift +++ b/ios/HouseScan/UI/Screens/WallWalkScreen.swift @@ -80,6 +80,7 @@ struct WallWalkScreen: View { } return prompt } + if state.endScanQuestion { return ScanCopy.endScanQuestion } if let side = state.endQuestion { return ScanCopy.endQuestion(side, leavesOut: state.endQuestionLeavesOut) } if state.overheadQuestion { return ScanCopy.overheadQuestion } if let coaching, ScanCopy.coachingReplacesTask(coaching) { return ScanCopy.coaching(coaching) } @@ -106,7 +107,7 @@ struct WallWalkScreen: View { /// that only rides along: the refusal's words are on the card, so its tone should match. private var tone: InstructionCard.Tone { if state.marking?.refusal != nil { return .refusal } - let coachingShows = state.marking == nil && state.endQuestion == nil && !state.overheadQuestion + let coachingShows = state.marking == nil && !state.endScanQuestion && state.endQuestion == nil && !state.overheadQuestion if coachingShows, let coaching, ScanCopy.coachingReplacesTask(coaching) { return .coaching(symbol: ScanCopy.coachingSymbol(coaching)) } @@ -119,11 +120,12 @@ struct WallWalkScreen: View { // MARK: Controls private enum ControlsKey: Hashable { - case marking, endQuestion, overheadQuestion, tray, nextWall, markEnd, finish, walking + case marking, endScanQuestion, endQuestion, overheadQuestion, tray, nextWall, markEnd, finish, walking } private var controlsKey: ControlsKey { if state.marking != nil { return .marking } + if state.endScanQuestion { return .endScanQuestion } if state.endQuestion != nil { return .endQuestion } if state.overheadQuestion { return .overheadQuestion } if trayOpen { return .tray } @@ -173,6 +175,29 @@ struct WallWalkScreen: View { .accessibilityIdentifier("action.markPoint") } .transition(.opacity) + case .endScanQuestion: + // "Can't get there" again soon after it ended a side (#82): two equal full-width + // answers, like the end question's. + VStack(spacing: 8) { + Button { + actions.answerEndScan(true) + } label: { + Label(ScanCopy.endScanYes, systemImage: "checkmark") + .frame(maxWidth: .infinity) + } + .buttonStyle(.secondaryProminent) + .accessibilityHint("Finishes the walk with the part of the wall you've walked") + .accessibilityIdentifier("action.endScan") + Button { + actions.answerEndScan(false) + } label: { + Label(ScanCopy.keepWalking, systemImage: "figure.walk") + .frame(maxWidth: .infinity) + } + .buttonStyle(.secondaryProminent) + .accessibilityIdentifier("action.keepWalking") + } + .transition(.opacity) case .endQuestion: // One question, three equal full-width answers that say what they mean (checklist I4). VStack(spacing: 8) { @@ -295,7 +320,7 @@ struct WallWalkScreen: View { /// aim step, "Can't get there" on the walk). Its identifier is `action.cannotAccess` on /// every step. private var reply: InstructionCard.Reply? { - guard state.marking == nil, state.endQuestion == nil, !state.overheadQuestion, !coachingHidesReply, !trayOpen, + guard state.marking == nil, !state.endScanQuestion, state.endQuestion == nil, !state.overheadQuestion, !coachingHidesReply, !trayOpen, let copy = ScanCopy.reply(for: state.guidance) else { return nil } return InstructionCard.Reply( title: copy.title, diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Guidance/WalkRefusals.swift b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/WalkRefusals.swift new file mode 100644 index 000000000..193350c80 --- /dev/null +++ b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/WalkRefusals.swift @@ -0,0 +1,61 @@ +import Foundation + +/// "Can't get there" on the walk's card, which ends the wall on the side being walked +/// (`WalkedEnd`), and what the scan remembers about it. +/// +/// - A second one soon after the last asks "End the scan here?" instead of ending the other side +/// too (#82, option C). On the build 4.1 field test a homeowner who wanted to stop tapped it +/// seven times in a row, and each tap silently skipped a task or set an end. +/// - A side it ended before the homeowner walked any of it counts as refused. When both sides +/// were refused, the wall line comes from the meter tap alone and nothing confirmed it, so the +/// result shows no spot (#76, option (a)). A side walked first and then ended with "Can't get +/// there", at a fence say, was walked: device run 1 ended both sides that way 16 ft out. +public struct WalkRefusals: Equatable, Sendable { + /// A second "Can't get there" on a walk card within this many seconds of the one that last + /// ended a side asks "End the scan here?" instead of ending another. + public static let repeatWindow: TimeInterval = 5 + + /// A side counts as refused when "Can't get there" ended it with less than this walked there + /// (`WalkedEnd.farthest`), meters: a battery's width (`WallFrame.minWallLength`), the least + /// wall the walk can finish with. + public static let walkedMinimum: Float = WallFrame.minWallLength + + /// When "Can't get there" last ended a side, seconds on the caller's clock. + private var lastEnded: TimeInterval? + /// The s of each end a refusal set. It counts only while that end is still there: an end + /// cleared and set again (a past-end request, a corner) is no longer the refused one. + private var refusedEnds: [WalkSide: Float] = [:] + + public init() {} + + /// Whether a "Can't get there" on a walk card at `time` should ask "End the scan here?" + /// rather than end the side: the last one ended a side at most `repeatWindow` seconds before. + public func asksToEndScan(at time: TimeInterval) -> Bool { + guard let lastEnded else { return false } + let since = time - lastEnded + return since >= 0 && since <= Self.repeatWindow + } + + /// "Can't get there" ended `side` at `s` at `time`, with `walked` meters walked on that side. + public mutating func ended(_ side: WalkSide, at s: Float, walked: Float, time: TimeInterval) { + lastEnded = time + refusedEnds[side] = walked < Self.walkedMinimum ? s : nil + } + + /// "Keep walking": the next "Can't get there" ends its side again without asking. + public mutating func keepWalking() { + lastEnded = nil + } + + /// Whether the end on `side`, at `end` (nil when unmarked), is one a refusal set. + public func wasRefused(_ side: WalkSide, end: Float?) -> Bool { + guard let end, let refused = refusedEnds[side] else { return false } + return refused == end + } + + /// True when both ends of the wall are ones a refusal set: neither side of the meter was + /// walked, and the result must not show a spot. + public func neitherSideWalked(leftEnd: Float?, rightEnd: Float?) -> Bool { + wasRefused(.left, end: leftEnd) && wasRefused(.right, end: rightEnd) + } +} diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/WalkRefusalsTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/WalkRefusalsTests.swift new file mode 100644 index 000000000..19a73b5a0 --- /dev/null +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/WalkRefusalsTests.swift @@ -0,0 +1,71 @@ +import Foundation +import HouseScanKit +import Testing + +/// "Can't get there" on the walk's card: a second one soon after asks to end the scan (#82), and +/// a wall neither side of which was walked shows no spot (#76). +@Suite struct WalkRefusalsTests { + @Test func firstRefusalNeverAsks() { + let refusals = WalkRefusals() + #expect(!refusals.asksToEndScan(at: 0)) + #expect(!refusals.asksToEndScan(at: 100)) + } + + @Test func secondRefusalWithinTheWindowAsks() { + var refusals = WalkRefusals() + refusals.ended(.left, at: -0.2, walked: 0, time: 10) + #expect(refusals.asksToEndScan(at: 10.6)) + #expect(refusals.asksToEndScan(at: 10 + WalkRefusals.repeatWindow)) + #expect(!refusals.asksToEndScan(at: 10 + WalkRefusals.repeatWindow + 0.1)) + // A clock that went backwards (a new scan's clock) doesn't count as soon. + #expect(!refusals.asksToEndScan(at: 9)) + } + + @Test func keepWalkingForgetsTheLastRefusal() { + var refusals = WalkRefusals() + refusals.ended(.left, at: -0.2, walked: 0, time: 10) + refusals.keepWalking() + #expect(!refusals.asksToEndScan(at: 11)) + // The side it ended is still refused. + #expect(refusals.wasRefused(.left, end: -0.2)) + } + + /// Run 3's shape: both sides ended by "Can't get there" before either was walked. + @Test func bothSidesRefusedMeansNeitherWasWalked() { + var refusals = WalkRefusals() + refusals.ended(.left, at: -0.3, walked: 0.2, time: 1) + refusals.ended(.right, at: 0.6, walked: 0, time: 2) + #expect(refusals.neitherSideWalked(leftEnd: -0.3, rightEnd: 0.6)) + } + + @Test func oneSideRefusedIsNotEnough() { + var refusals = WalkRefusals() + refusals.ended(.left, at: -0.3, walked: 0, time: 1) + #expect(!refusals.neitherSideWalked(leftEnd: -0.3, rightEnd: 4)) + #expect(!refusals.neitherSideWalked(leftEnd: -0.3, rightEnd: nil)) + } + + /// Device run 1's shape: each side walked 5 m, then ended with "Can't get there". + @Test func aSideWalkedBeforeTheRefusalCountsAsWalked() { + var refusals = WalkRefusals() + refusals.ended(.left, at: -5, walked: 5, time: 1) + refusals.ended(.right, at: 5, walked: 5, time: 2) + #expect(!refusals.wasRefused(.left, end: -5)) + #expect(!refusals.neitherSideWalked(leftEnd: -5, rightEnd: 5)) + // Just short of the minimum still counts as refused. + refusals.ended(.left, at: -0.7, walked: WalkRefusals.walkedMinimum - 0.01, time: 3) + #expect(refusals.wasRefused(.left, end: -0.7)) + refusals.ended(.left, at: -0.8, walked: WalkRefusals.walkedMinimum, time: 4) + #expect(!refusals.wasRefused(.left, end: -0.8)) + } + + /// An end moved or set again by something else (a past-end request that was met, a corner) + /// is no longer the one the refusal set. + @Test func anEndThatMovedIsNoLongerRefused() { + var refusals = WalkRefusals() + refusals.ended(.left, at: -0.3, walked: 0, time: 1) + refusals.ended(.right, at: 0.6, walked: 0, time: 2) + #expect(!refusals.neitherSideWalked(leftEnd: -2.4, rightEnd: 0.6)) + #expect(!refusals.neitherSideWalked(leftEnd: nil, rightEnd: 0.6)) + } +} diff --git a/ios/HouseScanUITests/ScreenStatesUITests.swift b/ios/HouseScanUITests/ScreenStatesUITests.swift index 48aa40048..8690bbbde 100644 --- a/ios/HouseScanUITests/ScreenStatesUITests.swift +++ b/ios/HouseScanUITests/ScreenStatesUITests.swift @@ -25,6 +25,7 @@ final class ScreenStatesUITests: XCTestCase { ("wallWalk-markingRefused", ["-uiDemoPhase", "wallWalk", "-uiDemoMarking", "window", "-uiDemoRefusal"], "wallWalk"), ("wallWalk-endQuestion", ["-uiDemoPhase", "wallWalk", "-uiDemoEndQuestion"], "wallWalk"), ("wallWalk-endPreview", ["-uiDemoPhase", "wallWalk", "-uiDemoEndPreview"], "wallWalk"), + ("wallWalk-endScanQuestion", ["-uiDemoPhase", "wallWalk", "-uiDemoEndScanQuestion"], "wallWalk"), ("wallWalk-endQuestionLeavesOut", ["-uiDemoPhase", "wallWalk", "-uiDemoEndPreview", "-uiDemoEndQuestion"], "wallWalk"), ("wallWalk-pastWallEnd", ["-uiDemoPhase", "wallWalk", "-uiDemoCoaching", "pastWallEnd"], "wallWalk"), ("wallWalk-nextWall", ["-uiDemoPhase", "wallWalk", "-uiDemoNextWall"], "wallWalk"), @@ -57,6 +58,7 @@ final class ScreenStatesUITests: XCTestCase { ("result-corner", ["-uiDemoPhase", "result", "-uiDemoCorner"], "result"), ("result-overlap", ["-uiDemoPhase", "result", "-uiDemoOverlap"], "result"), ("result-reject", ["-uiDemoPhase", "result", "-uiDemoResultFile", resultFile("reject-nearest")], "result"), + ("result-wallNotMeasured", ["-uiDemoPhase", "result", "-uiDemoWallNotMeasured"], "result"), ("resultAR", ["-uiDemoPhase", "resultAR"], "resultAR"), ("cameraDenied", ["-uiDemoFailure", "cameraDenied"], "unsupported"), ("arUnsupported", ["-uiDemoFailure", "arUnsupported"], "unsupported"), @@ -112,12 +114,18 @@ final class ScreenStatesUITests: XCTestCase { "resultAR": [("ar.overlay", "drawn on your wall")], // #81: the aim ring fills as its stretch is captured. "wallWalk-aim": [("aim.ring", "50 percent captured")], + // #82: a second "Can't get there" soon after the first asks before ending the scan. + "wallWalk-endScanQuestion": [("instruction", "End the scan here?")], + // #76: a wall never walked shows no spot. + "result-wallNotMeasured": [("result.headline", "We couldn't measure your wall")], ] /// Controls a state must offer, by identifier. private static let controls: [String: [String]] = [ // #39: stopping is available even when just one requested view remains. "gapRequest-followUp": ["action.skipGap", "action.showResult"], + "wallWalk-endScanQuestion": ["action.endScan", "action.keepWalking"], + "result-wallNotMeasured": ["action.startOver"], ] /// States where the scan is packaged, so "Share scan" must show. @@ -288,6 +296,39 @@ final class ScreenStatesUITests: XCTestCase { XCTAssertTrue(reply(app, "Can't get there").waitForExistence(timeout: 5), "the walk's reply must come back") } + /// #82: "End the scan here?" holds the card's reply back until it is answered. "Keep walking" + /// goes back to the walk; "Yes, end here" finishes it for the feature review. + @MainActor + func testEndScanQuestionKeepsWalkingOrEnds() throws { + continueAfterFailure = false + let app = XCUIApplication() + app.launchArguments = ["-uiDemo", "-uiDemoFreeze", "-uiDemoPhase", "wallWalk", "-uiDemoEndScanQuestion"] + app.launch() + XCTAssertTrue(element(app, "action.keepWalking").waitForExistence(timeout: 15)) + XCTAssertFalse(element(app, "action.cannotAccess").exists, "the card's reply must wait for the answer") + tap(app, "action.keepWalking", timeout: 5) + XCTAssertTrue(element(app, "action.keepWalking").waitForNonExistence(timeout: 5), "the answer must close the question") + XCTAssertTrue(element(app, "screen.wallWalk").exists, "Keep walking must stay on the walk") + app.terminate() + + app.launch() + tap(app, "action.endScan", timeout: 15) + XCTAssertTrue(element(app, "screen.markFeatures").waitForExistence(timeout: 5), "Yes, end here must finish the walk") + } + + /// #76: a wall neither side of which was walked offers a new scan, not the spot on the wall. + @MainActor + func testWallNotMeasuredShowsNoSpot() throws { + continueAfterFailure = false + let app = XCUIApplication() + app.launchArguments = ["-uiDemo", "-uiDemoFreeze", "-uiDemoPhase", "result", "-uiDemoWallNotMeasured"] + app.launch() + XCTAssertTrue(element(app, "result.wallNotMeasured").waitForExistence(timeout: 15)) + XCTAssertFalse(element(app, "action.showAR").exists, "no spot, so no See it on your wall") + XCTAssertFalse(element(app, "result.placement").exists) + XCTAssertFalse(element(app, "result.nearest").exists) + } + /// #80: the capture gate's coaching keeps the walk's task on the card and adds its own line, /// instead of replacing the card. @MainActor From 057cfd421ecee5872b8c527bddbbc77efe486a27 Mon Sep 17 00:00:00 2001 From: AidenJohnston Date: Sun, 27 Sep 2026 03:11:29 -0500 Subject: [PATCH 2/3] ios: an end at the phone out along the wall counts as walked A side now counts as refused only when both the walk and the end that "Can't get there" set stayed within a battery's width of the meter. Once ends land at the phone (#71), run 2's right end sits at the wall's physical end even with few views kept on the way, and its result must keep the spot. Co-Authored-By: Claude Opus 5.5 (1M context) --- ios/HouseScan/Runtime/ScanEngine+Ends.swift | 4 ++-- .../HouseScanKit/Guidance/WalkRefusals.swift | 12 ++++++++---- .../HouseScanKitTests/WalkRefusalsTests.swift | 15 +++++++++++++++ 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/ios/HouseScan/Runtime/ScanEngine+Ends.swift b/ios/HouseScan/Runtime/ScanEngine+Ends.swift index 16dd29eb0..eff47bc49 100644 --- a/ios/HouseScan/Runtime/ScanEngine+Ends.swift +++ b/ios/HouseScan/Runtime/ScanEngine+Ends.swift @@ -105,8 +105,8 @@ extension ScanEngine { let walked = map.walkedFarthest(side.walk) setEnd(side, at: s, kind: .unexplored) walkRefusals.ended(side.walk, at: s, walked: walked, time: ScanEngine.refusalClock) - if walked < WalkRefusals.walkedMinimum { - RuntimeLog.engine.info("the \(side.rawValue, privacy: .public) side ended before it was walked (\(walked) m)") + if walkRefusals.wasRefused(side.walk, end: s) { + RuntimeLog.engine.info("the \(side.rawValue, privacy: .public) side ended before it was walked (\(walked) m walked, end at s=\(s))") } } diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Guidance/WalkRefusals.swift b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/WalkRefusals.swift index 193350c80..7df5c6f1f 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Guidance/WalkRefusals.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/WalkRefusals.swift @@ -16,8 +16,8 @@ public struct WalkRefusals: Equatable, Sendable { public static let repeatWindow: TimeInterval = 5 /// A side counts as refused when "Can't get there" ended it with less than this walked there - /// (`WalkedEnd.farthest`), meters: a battery's width (`WallFrame.minWallLength`), the least - /// wall the walk can finish with. + /// (`WalkedEnd.farthest`) and the end itself less than this from the meter, meters: a + /// battery's width (`WallFrame.minWallLength`), the least wall the walk can finish with. public static let walkedMinimum: Float = WallFrame.minWallLength /// When "Can't get there" last ended a side, seconds on the caller's clock. @@ -36,10 +36,14 @@ public struct WalkRefusals: Equatable, Sendable { return since >= 0 && since <= Self.repeatWindow } - /// "Can't get there" ended `side` at `s` at `time`, with `walked` meters walked on that side. + /// "Can't get there" ended `side` at `s` at `time`, with `walked` meters walked on that side + /// (`WalkedEnd.farthest`). The side counts as walked when either the walk or the end reached + /// `walkedMinimum` from the meter: an end set at the phone's place out along the wall means + /// the homeowner walked there, even with few views kept on the way (#71, run 2). public mutating func ended(_ side: WalkSide, at s: Float, walked: Float, time: TimeInterval) { lastEnded = time - refusedEnds[side] = walked < Self.walkedMinimum ? s : nil + let reach = max(walked, side.sign * s) + refusedEnds[side] = reach < Self.walkedMinimum ? s : nil } /// "Keep walking": the next "Can't get there" ends its side again without asking. diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/WalkRefusalsTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/WalkRefusalsTests.swift index 19a73b5a0..e7bcf5eb6 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/WalkRefusalsTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/WalkRefusalsTests.swift @@ -59,6 +59,21 @@ import Testing #expect(!refusals.wasRefused(.left, end: -0.8)) } + /// Run 2's shape once ends land at the phone (#71): the right end at the wall's physical end, + /// 5.2 m out, even if few views were kept on the way, and the left end at the meter. The + /// right side was walked, so the result keeps its spot. + @Test func anEndAtThePhoneOutAlongTheWallCountsAsWalked() { + var refusals = WalkRefusals() + refusals.ended(.right, at: 5.2, walked: 0.3, time: 1) + refusals.ended(.left, at: 0, walked: 0, time: 20) + #expect(!refusals.wasRefused(.right, end: 5.2)) + #expect(refusals.wasRefused(.left, end: 0)) + #expect(!refusals.neitherSideWalked(leftEnd: 0, rightEnd: 5.2)) + // The same on the left: the end's distance from the meter counts, not its sign. + refusals.ended(.left, at: -1.5, walked: 0, time: 30) + #expect(!refusals.wasRefused(.left, end: -1.5)) + } + /// An end moved or set again by something else (a past-end request that was met, a corner) /// is no longer the one the refusal set. @Test func anEndThatMovedIsNoLongerRefused() { From de5357fe405a3c85beacfc6788d470a03d6f2615 Mon Sep 17 00:00:00 2001 From: AidenJohnston Date: Sun, 27 Sep 2026 04:30:04 -0500 Subject: [PATCH 3/3] ios: "End the scan here?" offers Start over when too little was walked; say run 3 isn't caught With too little walked, "Yes, end here" put the homeowner back on the walk: finishWalk refused the ends as too close, and the next two taps asked again. When ending the open sides now would leave ends closer than a battery is wide (WalkRefusals.endsTooClose), the question offers "Start over" in place of "Yes, end here". The #76 rule doesn't catch field test run 3: its kept views reached about 2 m on both sides. The test that claimed run 3's shape now uses honest synthetic numbers, and a new test records run 3's numbers as a case the rule does not catch. Co-Authored-By: Claude Opus 5.5 (1M context) --- ios/HouseScan/Contract/ScanContract.swift | 4 +++ .../Runtime/ScanEngine+Actions.swift | 6 ++++ ios/HouseScan/Runtime/ScanEngine.swift | 1 + ios/HouseScan/UI/Copy/ScanCopy.swift | 7 ++++ ios/HouseScan/UI/Preview/DemoEngine.swift | 5 +++ ios/HouseScan/UI/Screens/WallWalkScreen.swift | 32 +++++++++++++------ .../HouseScanKit/Guidance/WalkRefusals.swift | 8 +++++ .../HouseScanKitTests/WalkRefusalsTests.swift | 26 +++++++++++++-- .../ScreenStatesUITests.swift | 4 +++ 9 files changed, 82 insertions(+), 11 deletions(-) diff --git a/ios/HouseScan/Contract/ScanContract.swift b/ios/HouseScan/Contract/ScanContract.swift index 37a0fd963..29b418e78 100644 --- a/ios/HouseScan/Contract/ScanContract.swift +++ b/ios/HouseScan/Contract/ScanContract.swift @@ -817,6 +817,10 @@ final class ScanViewState { /// that last ended a side: the walk asks "End the scan here?" instead of ending this side too /// (#82). Answered by `answerEndScan`. var endScanQuestion = false + /// Set with `endScanQuestion` when ending the open sides now would leave ends closer than a + /// battery is wide (`WalkRefusals.endsTooClose`): "Done with this wall" would refuse them and + /// the walk would go on, so the question offers "Start over" instead of "Yes, end here". + var endScanTooShort = false /// True after "Done with this wall" was refused because the ends were closer together than /// `WallFrame.minWallLength`; the ends were cleared. False again once an end is marked. var wallTooShort = false diff --git a/ios/HouseScan/Runtime/ScanEngine+Actions.swift b/ios/HouseScan/Runtime/ScanEngine+Actions.swift index bae68b829..58cd22b15 100644 --- a/ios/HouseScan/Runtime/ScanEngine+Actions.swift +++ b/ios/HouseScan/Runtime/ScanEngine+Actions.swift @@ -438,6 +438,11 @@ extension ScanEngine: ScanActions { // the homeowner may be trying to stop, so ask before ending this side too (#82). // The task stays unresolved until the answer. RuntimeLog.engine.info("cannot access area again during \(task, privacy: .public): asking to end the scan") + if let map = coverage { + // With nothing walked the ends would land too close to finish, and "Yes, end + // here" would only put the homeowner back on the walk. + state.endScanTooShort = WalkRefusals.endsTooClose(left: map.leftEnd ?? walkedEnd(.left), right: map.rightEnd ?? walkedEnd(.right)) + } state.endScanQuestion = true return } @@ -485,6 +490,7 @@ extension ScanEngine: ScanActions { func answerEndScan(_ end: Bool) { guard state.phase == .wallWalk, state.endScanQuestion else { return } state.endScanQuestion = false + state.endScanTooShort = false guard end else { RuntimeLog.engine.info("end the scan here? keep walking") walkRefusals.keepWalking() diff --git a/ios/HouseScan/Runtime/ScanEngine.swift b/ios/HouseScan/Runtime/ScanEngine.swift index 5b28c083a..658facea7 100644 --- a/ios/HouseScan/Runtime/ScanEngine.swift +++ b/ios/HouseScan/Runtime/ScanEngine.swift @@ -279,6 +279,7 @@ final class ScanEngine { if state.phase == .resultAR { hideResultInCamera() } // "End the scan here?" belongs to the walk it was asked on. state.endScanQuestion = false + state.endScanTooShort = false let previous = state.phase state.phase = phase RuntimeLog.state.info("STATE=\(phase.rawValue, privacy: .public)") diff --git a/ios/HouseScan/UI/Copy/ScanCopy.swift b/ios/HouseScan/UI/Copy/ScanCopy.swift index c4a6eb0ac..50249a3a4 100644 --- a/ios/HouseScan/UI/Copy/ScanCopy.swift +++ b/ios/HouseScan/UI/Copy/ScanCopy.swift @@ -474,7 +474,14 @@ enum ScanCopy { title: "End the scan here?", detail: "We'll check the part of the wall you've walked." ) + /// The same question when too little of the wall was walked to check it + /// (`ScanViewState.endScanTooShort`): the way out is a new scan. + static let endScanTooShort = Instruction( + title: "End the scan here?", + detail: "You haven't walked enough of the wall to check it yet. Start over, or keep walking along the wall." + ) static let endScanYes = "Yes, end here" + static let endScanStartOver = "Start over" static let keepWalking = "Keep walking" /// With no server connected nothing is sent, and the words must not say it is. diff --git a/ios/HouseScan/UI/Preview/DemoEngine.swift b/ios/HouseScan/UI/Preview/DemoEngine.swift index 96c12626a..81c4be83c 100644 --- a/ios/HouseScan/UI/Preview/DemoEngine.swift +++ b/ios/HouseScan/UI/Preview/DemoEngine.swift @@ -126,6 +126,8 @@ final class DemoEngine: ScanActions { if arguments.contains("-uiDemoEndScanQuestion") { // "Can't get there" again soon after it ended the right side (#82). state.endScanQuestion = true + // `-uiDemoEndScanTooShort`: too little walked to finish, so it offers "Start over". + state.endScanTooShort = arguments.contains("-uiDemoEndScanTooShort") } if arguments.contains("-uiDemoNextWall") { state.wall?.rightEnd = demoRightEnd @@ -924,6 +926,7 @@ final class DemoEngine: ScanActions { func answerEndScan(_ end: Bool) { guard state.endScanQuestion else { return } state.endScanQuestion = false + state.endScanTooShort = false guard end else { return } finishedWalkState() finishWalk() @@ -1037,6 +1040,8 @@ final class DemoEngine: ScanActions { tiltUpSettled = false tiltUpTicks = 0 state.overheadQuestion = false + state.endScanQuestion = false + state.endScanTooShort = false state.groundAnswer = nil } diff --git a/ios/HouseScan/UI/Screens/WallWalkScreen.swift b/ios/HouseScan/UI/Screens/WallWalkScreen.swift index 345ce474e..d23fd2b89 100644 --- a/ios/HouseScan/UI/Screens/WallWalkScreen.swift +++ b/ios/HouseScan/UI/Screens/WallWalkScreen.swift @@ -86,7 +86,7 @@ struct WallWalkScreen: View { } return prompt } - if state.endScanQuestion { return ScanCopy.endScanQuestion } + if state.endScanQuestion { return state.endScanTooShort ? ScanCopy.endScanTooShort : ScanCopy.endScanQuestion } if let side = state.endQuestion { return ScanCopy.endQuestion(side, leavesOut: state.endQuestionLeavesOut) } if state.overheadQuestion { return ScanCopy.overheadQuestion } if let confirm = state.nextWallConfirm { return ScanCopy.nextWallConfirm(confirm) } @@ -186,16 +186,30 @@ struct WallWalkScreen: View { case .endScanQuestion: // "Can't get there" again soon after it ended a side (#82): two equal full-width // answers, like the end question's. + // With too little walked to finish, "Start over" takes the place of "Yes, end here", + // which would only put the homeowner back on the walk. VStack(spacing: 8) { - Button { - actions.answerEndScan(true) - } label: { - Label(ScanCopy.endScanYes, systemImage: "checkmark") - .frame(maxWidth: .infinity) + if state.endScanTooShort { + Button { + actions.startOver() + } label: { + Label(ScanCopy.endScanStartOver, systemImage: "arrow.counterclockwise") + .frame(maxWidth: .infinity) + } + .buttonStyle(.secondaryProminent) + .accessibilityHint("Starts a new scan and keeps your two most recent completed scans on this phone.") + .accessibilityIdentifier("action.endScanStartOver") + } else { + Button { + actions.answerEndScan(true) + } label: { + Label(ScanCopy.endScanYes, systemImage: "checkmark") + .frame(maxWidth: .infinity) + } + .buttonStyle(.secondaryProminent) + .accessibilityHint("Finishes the walk with the part of the wall you've walked") + .accessibilityIdentifier("action.endScan") } - .buttonStyle(.secondaryProminent) - .accessibilityHint("Finishes the walk with the part of the wall you've walked") - .accessibilityIdentifier("action.endScan") Button { actions.answerEndScan(false) } label: { diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Guidance/WalkRefusals.swift b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/WalkRefusals.swift index 7df5c6f1f..b84ca70e1 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Guidance/WalkRefusals.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/WalkRefusals.swift @@ -57,6 +57,14 @@ public struct WalkRefusals: Equatable, Sendable { return refused == end } + /// True when ends at `left` and `right` would be closer than `WallFrame.minWallLength`, so + /// finishing the walk with them is refused (`CoverageMap.endsTooClose`). A missing end counts + /// as too close: there is nothing to finish with. + public static func endsTooClose(left: Float?, right: Float?) -> Bool { + guard let left, let right else { return true } + return right - left < WallFrame.minWallLength + } + /// True when both ends of the wall are ones a refusal set: neither side of the meter was /// walked, and the result must not show a spot. public func neitherSideWalked(leftEnd: Float?, rightEnd: Float?) -> Bool { diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/WalkRefusalsTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/WalkRefusalsTests.swift index e7bcf5eb6..cbd47cfd4 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/WalkRefusalsTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/WalkRefusalsTests.swift @@ -30,14 +30,36 @@ import Testing #expect(refusals.wasRefused(.left, end: -0.2)) } - /// Run 3's shape: both sides ended by "Can't get there" before either was walked. - @Test func bothSidesRefusedMeansNeitherWasWalked() { + /// Synthetic: both sides ended by "Can't get there" with the walk and the end within a + /// battery's width of the meter on each side. This is the only case the rule catches. + @Test func bothSidesEndedNearTheMeterMeansNeitherWasWalked() { var refusals = WalkRefusals() refusals.ended(.left, at: -0.3, walked: 0.2, time: 1) refusals.ended(.right, at: 0.6, walked: 0, time: 2) #expect(refusals.neitherSideWalked(leftEnd: -0.3, rightEnd: 0.6)) } + /// The rule's known limit (#76): field test run 3 is NOT caught. Its kept views reached about + /// 2.0 m left and 1.8 m right of the meter before both walk cards were refused, and its ends + /// were recorded at -4.0 m and +0.5 m, so both sides count as walked and the spot still shows. + /// Run 3's spot inside the house came from a bad meter tap (#69). + @Test func run3IsNotCaughtBecauseItsViewsReachedPastTheMinimum() { + var refusals = WalkRefusals() + refusals.ended(.left, at: -4.0, walked: 2.04, time: 1) + refusals.ended(.right, at: 0.525, walked: 1.83, time: 2) + #expect(!refusals.neitherSideWalked(leftEnd: -4.0, rightEnd: 0.525)) + } + + /// "Yes, end here" can't finish a wall shorter than a battery's width, so the question + /// offers "Start over" then instead of looping back to the walk. + @Test func endsTooCloseToFinish() { + #expect(WalkRefusals.endsTooClose(left: -0.2, right: 0.3)) + #expect(WalkRefusals.endsTooClose(left: 0, right: 0)) + #expect(WalkRefusals.endsTooClose(left: nil, right: 2)) + #expect(!WalkRefusals.endsTooClose(left: -0.4, right: 0.4)) + #expect(!WalkRefusals.endsTooClose(left: -3, right: 0)) + } + @Test func oneSideRefusedIsNotEnough() { var refusals = WalkRefusals() refusals.ended(.left, at: -0.3, walked: 0, time: 1) diff --git a/ios/HouseScanUITests/ScreenStatesUITests.swift b/ios/HouseScanUITests/ScreenStatesUITests.swift index f7bff1283..fd345ba0d 100644 --- a/ios/HouseScanUITests/ScreenStatesUITests.swift +++ b/ios/HouseScanUITests/ScreenStatesUITests.swift @@ -26,6 +26,7 @@ final class ScreenStatesUITests: XCTestCase { ("wallWalk-endQuestion", ["-uiDemoPhase", "wallWalk", "-uiDemoEndQuestion"], "wallWalk"), ("wallWalk-endPreview", ["-uiDemoPhase", "wallWalk", "-uiDemoEndPreview"], "wallWalk"), ("wallWalk-endScanQuestion", ["-uiDemoPhase", "wallWalk", "-uiDemoEndScanQuestion"], "wallWalk"), + ("wallWalk-endScanTooShort", ["-uiDemoPhase", "wallWalk", "-uiDemoEndScanQuestion", "-uiDemoEndScanTooShort"], "wallWalk"), ("wallWalk-endQuestionLeavesOut", ["-uiDemoPhase", "wallWalk", "-uiDemoEndPreview", "-uiDemoEndQuestion"], "wallWalk"), ("wallWalk-pastWallEnd", ["-uiDemoPhase", "wallWalk", "-uiDemoCoaching", "pastWallEnd"], "wallWalk"), ("wallWalk-nextWall", ["-uiDemoPhase", "wallWalk", "-uiDemoNextWall"], "wallWalk"), @@ -122,6 +123,7 @@ final class ScreenStatesUITests: XCTestCase { "wallWalk-aim": [("aim.ring", "50 percent captured")], // #82: a second "Can't get there" soon after the first asks before ending the scan. "wallWalk-endScanQuestion": [("instruction", "End the scan here?")], + "wallWalk-endScanTooShort": [("instruction", "You haven't walked enough of the wall")], // #76: a wall never walked shows no spot. "result-wallNotMeasured": [("result.headline", "We couldn't measure your wall")], ] @@ -131,6 +133,8 @@ final class ScreenStatesUITests: XCTestCase { // #39: stopping is available even when just one requested view remains. "gapRequest-followUp": ["action.skipGap", "action.showResult"], "wallWalk-endScanQuestion": ["action.endScan", "action.keepWalking"], + // Too little walked to finish: a new scan, not a loop back to the walk. + "wallWalk-endScanTooShort": ["action.endScanStartOver", "action.keepWalking"], "result-wallNotMeasured": ["action.startOver"], ]