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 6dc83eefb..29b418e78 100644 --- a/ios/HouseScan/Contract/ScanContract.swift +++ b/ios/HouseScan/Contract/ScanContract.swift @@ -688,6 +688,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 @@ -808,6 +813,14 @@ 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 + /// 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 @@ -906,6 +919,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 ffb98cc9c..25112c763 100644 --- a/ios/HouseScan/Runtime/Autopilot.swift +++ b/ios/HouseScan/Runtime/Autopilot.swift @@ -270,6 +270,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 467fc31c7..58cd22b15 100644 --- a/ios/HouseScan/Runtime/ScanEngine+Actions.swift +++ b/ios/HouseScan/Runtime/ScanEngine+Actions.swift @@ -430,8 +430,22 @@ 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, + state.nextWallConfirm == nil, 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") + 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 + } switch state.guidance { case .aimAtGround, .aimAtWall, .seeBehind, .walk, .markEnd, .tiltUp, .markNextWall: resolveGuidance(.cannotReach) @@ -473,6 +487,37 @@ 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() + 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() } @@ -497,7 +542,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 8db43a1fd..9b9c53f91 100644 --- a/ios/HouseScan/Runtime/ScanEngine+Ends.swift +++ b/ios/HouseScan/Runtime/ScanEngine+Ends.swift @@ -115,13 +115,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 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))") + } } + /// 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 why (#71): the phone's place, the kept-photo cap, whether the cap /// decided it, tracking, how many meters of cells the strip showed past the end, and what the /// old rule, the unbroken covered reach, would have said. diff --git a/ios/HouseScan/Runtime/ScanEngine+Export.swift b/ios/HouseScan/Runtime/ScanEngine+Export.swift index 93b2630f9..4a7d0b397 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) { [self, gapPlanner, coverage, skippedGaps] item in + let shown = Self.presentation(of: result, isSample: isSample, wall: sceneWall) { [self, 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) } && captureCanSettle(plan) } ?? 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 63b075158..658facea7 100644 --- a/ios/HouseScan/Runtime/ScanEngine.swift +++ b/ios/HouseScan/Runtime/ScanEngine.swift @@ -83,6 +83,9 @@ final class ScanEngine { didSet { if nextWallSide == nil { pendingNextWall = nil } } } 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() /// The wall marked as the next one, waiting for "Is this the next wall?" (#70); cleared with /// `nextWallSide`. Published as `ScanViewState.nextWallConfirm`. var pendingNextWall: PendingNextWall? { @@ -274,6 +277,9 @@ 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.endScanTooShort = false let previous = state.phase state.phase = phase RuntimeLog.state.info("STATE=\(phase.rawValue, privacy: .public)") @@ -1413,6 +1419,7 @@ final class ScanEngine { state.wallTooShort = false nextWallSide = nil nextWallRefusal = nil + walkRefusals = WalkRefusals() resetTiltUp() resetSpotChecks() // An answer describes a scan that no longer exists; the next upload brings a new one. @@ -1448,6 +1455,7 @@ final class ScanEngine { state.wallTooShort = false nextWallSide = nil nextWallRefusal = nil + walkRefusals = WalkRefusals() resetTiltUp() publishWall() publishCoverage() @@ -1969,6 +1977,7 @@ final class ScanEngine { state.wallTooShort = false nextWallSide = nil nextWallRefusal = nil + walkRefusals = WalkRefusals() resetTiltUp() resetSpotChecks() placement = nil diff --git a/ios/HouseScan/UI/Copy/ScanCopy.swift b/ios/HouseScan/UI/Copy/ScanCopy.swift index de5312482..50249a3a4 100644 --- a/ios/HouseScan/UI/Copy/ScanCopy.swift +++ b/ios/HouseScan/UI/Copy/ScanCopy.swift @@ -468,6 +468,22 @@ 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." + ) + /// 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. /// `followUps` is how many views the finished check still wants from the camera. static func upload(_ upload: UploadState, sample: Bool, followUps: Int = 0) -> Instruction { @@ -576,6 +592,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 b098cb374..81c4be83c 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? @@ -66,6 +69,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 @@ -119,6 +123,12 @@ 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 + // `-uiDemoEndScanTooShort`: too little walked to finish, so it offers "Start over". + state.endScanTooShort = arguments.contains("-uiDemoEndScanTooShort") + } if arguments.contains("-uiDemoNextWall") { state.wall?.rightEnd = demoRightEnd reachedRight = demoRightEnd @@ -553,6 +563,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 } @@ -909,6 +920,18 @@ 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 + state.endScanTooShort = false + guard end else { return } + finishedWalkState() + finishWalk() + } + func confirmFeatures() { enterGap() } @@ -980,6 +1003,7 @@ final class DemoEngine: ScanActions { } func showAR() { + guard state.result?.wallNotMeasured != true else { return } state.phase = .resultAR } @@ -1016,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/ResultScreen.swift b/ios/HouseScan/UI/Screens/ResultScreen.swift index 4b88d3925..f2c8e61d7 100644 --- a/ios/HouseScan/UI/Screens/ResultScreen.swift +++ b/ios/HouseScan/UI/Screens/ResultScreen.swift @@ -142,7 +142,8 @@ struct ResultScreen: View { } // Keep starting another scan after the current scan's review and export. // Completed scans stay on disk under ScanFolderCleanup's retention policy. - if result.answer != .notHere { + // The card's button already starts over after a reject or a wall not measured. + if result.answer != .notHere, !result.wallNotMeasured { Button("Start over") { actions.startOver() } .buttonStyle(TextActionStyle()) .accessibilityHint("Starts a new scan and keeps your two most recent completed scans on this phone.") @@ -207,9 +208,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("Starts a new scan and keeps your two most recent completed scans on this phone.") + .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 48e5f2752..d23fd2b89 100644 --- a/ios/HouseScan/UI/Screens/WallWalkScreen.swift +++ b/ios/HouseScan/UI/Screens/WallWalkScreen.swift @@ -86,6 +86,7 @@ struct WallWalkScreen: View { } return prompt } + 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) } @@ -113,7 +114,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 && state.nextWallConfirm == nil + let coachingShows = state.marking == nil && !state.endScanQuestion && state.endQuestion == nil && !state.overheadQuestion && state.nextWallConfirm == nil if coachingShows, let coaching, ScanCopy.coachingReplacesTask(coaching) { return .coaching(symbol: ScanCopy.coachingSymbol(coaching)) } @@ -126,11 +127,12 @@ struct WallWalkScreen: View { // MARK: Controls private enum ControlsKey: Hashable { - case marking, endQuestion, overheadQuestion, nextWallConfirm, tray, nextWall, markEnd, finish, walking + case marking, endScanQuestion, endQuestion, overheadQuestion, nextWallConfirm, 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 state.nextWallConfirm != nil { return .nextWallConfirm } @@ -181,6 +183,43 @@ 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. + // 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) { + 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") + } + 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) { @@ -339,7 +378,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, state.nextWallConfirm == nil, !coachingHidesReply, !trayOpen, + guard state.marking == nil, !state.endScanQuestion, state.endQuestion == nil, !state.overheadQuestion, state.nextWallConfirm == nil, !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..b84ca70e1 --- /dev/null +++ b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/WalkRefusals.swift @@ -0,0 +1,73 @@ +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`) 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. + 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 + /// (`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 + 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. + 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 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 { + 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..cbd47cfd4 --- /dev/null +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/WalkRefusalsTests.swift @@ -0,0 +1,108 @@ +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)) + } + + /// 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) + #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)) + } + + /// 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() { + 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 c464055cb..fd345ba0d 100644 --- a/ios/HouseScanUITests/ScreenStatesUITests.swift +++ b/ios/HouseScanUITests/ScreenStatesUITests.swift @@ -25,6 +25,8 @@ 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-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"), @@ -60,6 +62,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"), @@ -118,12 +121,21 @@ 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?")], + "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")], ] /// 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"], + // 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"], ] /// States where the scan is packaged, so "Share scan" must show. @@ -300,6 +312,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