From e4ce5031605a8a6bb8d363ccd67edaf5b76bbe2d Mon Sep 17 00:00:00 2001 From: AidenJohnston Date: Sat, 26 Sep 2026 17:24:52 -0500 Subject: [PATCH 01/12] ios: "I can't get there" moves on to the next requested view (#39) After the check's answer asks for several views, "I can't get there" on one of them used to stop the loop: the next upload showed the result, so "2 more views to finish" delivered only one view. Now it skips only the current view: the next answer raises the next item it lists, and the result shows once none is left. Each item is raised at most once per pass (GapPlanner.nextServerRequest); a past-end item is known by its side, since raising it clears the end and it comes back with a different request. A new "Show my result" button on the follow-up request stops early, and the skip button's VoiceOver hint says where it leads. Co-Authored-By: Claude Opus 5.5 (1M context) --- ios/HouseScan/Contract/ScanContract.swift | 7 ++- .../Runtime/ScanEngine+Actions.swift | 5 +++ ios/HouseScan/Runtime/ScanEngine.swift | 45 +++++++++++-------- ios/HouseScan/UI/Preview/DemoEngine.swift | 5 +++ .../UI/Screens/GapRequestScreen.swift | 28 ++++++++++-- .../HouseScanKit/Guidance/GapPlanner.swift | 31 +++++++++++++ .../HouseScanKitTests/ServerGapTests.swift | 25 +++++++++++ 7 files changed, 122 insertions(+), 24 deletions(-) diff --git a/ios/HouseScan/Contract/ScanContract.swift b/ios/HouseScan/Contract/ScanContract.swift index 71b42801e..ec933d97c 100644 --- a/ios/HouseScan/Contract/ScanContract.swift +++ b/ios/HouseScan/Contract/ScanContract.swift @@ -723,8 +723,13 @@ protocol ScanActions: AnyObject { func finishWalk() /// Features confirmed; the engine runs the gap check, then uploads. func confirmFeatures() - /// "I can't get there": the gap is recorded for installer review. + /// "I can't get there": the gap is recorded for installer review. On a request the finished + /// check sent back, the check's next request follows; the result shows once none is left. func skipGap() + /// "Show my result" on a request the finished check sent back: the view is recorded for + /// installer review like a skipped one, and after one more upload the result shows instead + /// of the check's next request. + func showResultNow() /// "I can't get to this part of the wall", during the walk: the cells the guidance is asking /// for become `.skipped` and the guidance moves on to the next task. While the walk asks to /// walk a side, that side's end goes where `ScanViewState.endPreview` shows, as an unexplored diff --git a/ios/HouseScan/Runtime/ScanEngine+Actions.swift b/ios/HouseScan/Runtime/ScanEngine+Actions.swift index 98c11957b..e5aa30c09 100644 --- a/ios/HouseScan/Runtime/ScanEngine+Actions.swift +++ b/ios/HouseScan/Runtime/ScanEngine+Actions.swift @@ -326,6 +326,11 @@ extension ScanEngine: ScanActions { skipCurrentGap() } + func showResultNow() { + guard state.phase == .gapRequest else { return } + stopGapRequests() + } + func cannotAccessArea() { switch state.phase { case .gapRequest: diff --git a/ios/HouseScan/Runtime/ScanEngine.swift b/ios/HouseScan/Runtime/ScanEngine.swift index 270e4cfc5..a2191518a 100644 --- a/ios/HouseScan/Runtime/ScanEngine.swift +++ b/ios/HouseScan/Runtime/ScanEngine.swift @@ -79,11 +79,12 @@ final class ScanEngine { /// The side of a server past_end request being captured: that end was cleared, and marking /// it again settles the request (see `markWallEnd`). var pastEndSide: WallSide? - /// Server requests raised without a tap since the review was confirmed (`nextAutomaticGap`), - /// oldest first. Each is raised once; the result still offers it as a capture. - private var automaticGaps: [GapPlan] = [] - /// Set when the homeowner says they can't get to a server request's view: after the upload - /// that follows, the result shows instead of the next request. + /// The answer's items raised without a tap since the review was confirmed + /// (`nextAutomaticGap`), oldest first. Each is raised once, whether its view was taken or the + /// homeowner couldn't get there; the result still offers it as a capture. + private var automaticGaps: [PlacementMissingEvidence] = [] + /// Set when the homeowner taps "Show my result" on a server request (`stopGapRequests`): + /// after the upload that follows, the result shows instead of the next request. private var automaticGapsStopped = false /// At most this many server requests are raised without a tap per confirmed review, so a /// server that keeps finding new gaps can't hold the homeowner in the loop. A guess, not @@ -1293,15 +1294,14 @@ final class ScanEngine { RuntimeLog.engine.info("overhead: kept a view reaching \(reach.map(\.out).min() ?? 0) m over s=\(reach.first?.span.lowerBound ?? 0)...\(reach.last?.span.upperBound ?? 0)") } - /// Ends the current request without the view it asked for ("I can't get there", or something - /// overhead): recorded for installer review, then on to the upload. "I can't get there" on a - /// server request also ends the requests raised without a tap: after that upload the result - /// shows. Something overhead is an answer, not a refusal, so the loop goes on. + /// Ends the current request without the view it asked for ("I can't get there", something + /// overhead, or "Show my result"): recorded for installer review, then on to the upload. The + /// answer that follows raises the next item it lists, never this one again + /// (`nextAutomaticGap`); only "Show my result" (`stopGapRequests`) ends the requests. func skipCurrentGap(because reason: String = "the homeowner can't get there", refused: Bool = true) { guard let plan = gapPlan else { return } // Something overhead is an answer: the request goes to review without its view. resolveGuidance(refused ? .cannotReach : .skipped) - if refused, state.gap?.origin == .server { automaticGapsStopped = true } // Only a cell request marks cells: skipping a deeper, walked or overhead view says // nothing about the band the strip draws. if plan.need == .cells { coverage?.markSkipped(plan.band, plan.span) } @@ -1311,6 +1311,15 @@ final class ScanEngine { afterGapResolved() } + /// "Show my result" on a server request: this view goes to installer review like one the + /// homeowner can't get to, and after the upload that follows the result shows instead of + /// the answer's next request. + func stopGapRequests() { + guard gapPlan != nil, state.gap?.origin == .server else { return } + automaticGapsStopped = true + skipCurrentGap(because: "the homeowner asked for the result", refused: false) + } + // MARK: Upload func startUpload() { @@ -1373,7 +1382,7 @@ final class ScanEngine { // enough to read before the camera takes over. try await Task.sleep(for: .seconds(Self.followUpHold)) guard scan == generation, state.phase == .uploading else { return } - automaticGaps.append(next.plan) + automaticGaps.append(next.item) RuntimeLog.engine.info("answer lists capturable evidence: asking for it (\(self.automaticGaps.count) of at most \(Self.maxAutomaticGaps))") beginServerGap(next.item, plan: next.plan) return @@ -1391,18 +1400,16 @@ final class ScanEngine { } /// The first item of the answer's missing evidence a capture can settle (the result's - /// "capturable"), not skipped and not yet raised in this pass; nil once the homeowner said - /// they can't get to one, or after `maxAutomaticGaps` requests. + /// "capturable"), not skipped and not yet raised in this pass + /// (`GapPlanner.nextServerRequest`); nil once the homeowner tapped "Show my result", or + /// after `maxAutomaticGaps` requests. private func nextAutomaticGap(_ result: PlacementResult) -> (item: PlacementMissingEvidence, plan: GapPlan)? { // A request raised while the phone has lost its place could only time out: show the result. guard !automaticGapsStopped, automaticGaps.count < Self.maxAutomaticGaps, !state.tracking.hasLostItsPlace, let map = coverage else { return nil } - for item in result.missingEvidence { - guard let plan = gapPlanner.plan(for: item, leftEnd: map.leftEnd, rightEnd: map.rightEnd), - !skippedGaps.contains(plan), !automaticGaps.contains(plan) else { continue } - return (item, plan) - } - return nil + return gapPlanner.nextServerRequest( + in: result.missingEvidence, leftEnd: map.leftEnd, rightEnd: map.rightEnd, + asked: automaticGaps, skipped: skippedGaps) } /// Starts the capture for an item of the server's missing evidence, whether tapped on the diff --git a/ios/HouseScan/UI/Preview/DemoEngine.swift b/ios/HouseScan/UI/Preview/DemoEngine.swift index 1c0f0e4df..b140fbbdc 100644 --- a/ios/HouseScan/UI/Preview/DemoEngine.swift +++ b/ios/HouseScan/UI/Preview/DemoEngine.swift @@ -758,6 +758,11 @@ final class DemoEngine: ScanActions { enterUpload() } + /// The demo's check asks for one view at most, so stopping early is skipping it. + func showResultNow() { + skipGap() + } + func cannotAccessArea() { if case .seeBehind(let s) = state.guidance { obstructions = obstructions.map { (span: $0.span, skipped: $0.skipped || $0.span.contains(s)) } diff --git a/ios/HouseScan/UI/Screens/GapRequestScreen.swift b/ios/HouseScan/UI/Screens/GapRequestScreen.swift index eaf00f2e4..8d1e9f013 100644 --- a/ios/HouseScan/UI/Screens/GapRequestScreen.swift +++ b/ios/HouseScan/UI/Screens/GapRequestScreen.swift @@ -6,7 +6,8 @@ import SwiftUI /// /// A request the finished check sent back (the scan came here from the upload, not from the /// review) carries "One more view to finish" above the instruction, the words the upload screen -/// just said, and its way out says it leads to the result. +/// just said. "I can't get there" skips only this view and goes on to the check's next one; +/// "Show my result" stops asking and leads to the result. struct GapRequestScreen: View { let state: ScanViewState let actions: any ScanActions @@ -30,9 +31,7 @@ struct GapRequestScreen: View { reply: state.gap?.isSatisfied == true || asking ? nil : InstructionCard.Reply( title: "I can't get there", identifier: "action.skipGap", - hint: followUps > 0 - ? "Skips this view and shows your result. An installer will look at this part instead." - : "Skips this view. An installer will look at this part instead.", + hint: skipHint, perform: { actions.skipGap() } ), eyebrow: followUps > 0 && state.gap?.isSatisfied != true ? ScanCopy.followUp(remaining: followUps) : nil, @@ -46,6 +45,17 @@ struct GapRequestScreen: View { OverheadAnswers(actions: actions) .transition(.opacity) } + if followUps > 0, state.gap?.isSatisfied != true, !asking { + Button { + actions.showResultNow() + } label: { + Label("Show my result", systemImage: "checkmark.circle") + } + .buttonStyle(.secondary) + .accessibilityHint("Stops asking for views and shows your result. An installer will look at the parts you skip.") + .accessibilityIdentifier("action.showResult") + .transition(.opacity) + } if let gap = state.gap { GapProgress(gap: gap) } @@ -77,6 +87,16 @@ struct GapRequestScreen: View { return max(1, result.missing.filter(\.capturable).count) } + /// What "I can't get there" leads to: the check's next view while it wants more than this + /// one, else the result. + private var skipHint: String { + switch followUps { + case 0: "Skips this view. An installer will look at this part instead." + case 1: "Skips this view and shows your result. An installer will look at this part instead." + default: "Skips this view and goes on to the next one. An installer will look at this part instead." + } + } + private var instruction: Instruction { if asking { return ScanCopy.overheadQuestion } if let coaching = state.coaching { return ScanCopy.coaching(coaching) } diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GapPlanner.swift b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GapPlanner.swift index 4be5686b8..ac5be4362 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GapPlanner.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GapPlanner.swift @@ -287,4 +287,35 @@ extension GapPlanner { return nil } } + + /// The next item of the server's missing evidence to ask for without a tap, with its + /// request: the first a capture can settle whose request isn't in `skipped` and which asks + /// for no view already raised in this pass (`asked`, compared by + /// `PlacementMissingEvidence.asksForSameView(as:)`). Nil when none is left. A request the + /// homeowner couldn't get to is in both, so the answer that follows moves on to the next + /// item rather than raising it again. + public func nextServerRequest( + in missing: [PlacementMissingEvidence], leftEnd: Float?, rightEnd: Float?, + asked: [PlacementMissingEvidence], skipped: [GapPlan] + ) -> (item: PlacementMissingEvidence, plan: GapPlan)? { + for item in missing where !asked.contains(where: { $0.asksForSameView(as: item) }) { + guard let plan = self.plan(for: item, leftEnd: leftEnd, rightEnd: rightEnd), !skipped.contains(plan) else { continue } + return (item, plan) + } + return nil + } +} + +extension PlacementMissingEvidence { + /// Whether two items ask for the same view, whatever their wording or checks. A past-end + /// item is known by its side alone: its request is built from where that end is marked, and + /// raising it clears the end, so the same item can come back with a different request. + public func asksForSameView(as other: PlacementMissingEvidence) -> Bool { + guard kind == other.kind else { return false } + switch kind { + case .pastEnd: return side == other.side + case .band: return band == other.band && spanFt == other.spanFt && outFt == other.outFt + case .unknown: return self == other + } + } } diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/ServerGapTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/ServerGapTests.swift index 9ee85ee55..51de28449 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/ServerGapTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/ServerGapTests.swift @@ -221,6 +221,31 @@ import Testing #expect(right.span == 4...6) } + /// Issue #39: the answer asks for two views and the homeowner can't get to the first. The + /// next answer, which still lists it, moves on to the second instead of raising the first + /// again or stopping; once both were raised, none is left and the result shows. The past-end + /// item comes back with its end cleared, so a different request, and is still not raised twice. + @Test func eachServerItemIsAskedForOnceAndARefusalMovesOnToTheNext() throws { + let planner = GapPlanner() + let pastEnd = try item(#"{"kind":"past_end","side":"left","message":"Walk past the left end."}"#) + let ground = try item(#"{"kind":"band","band":"ground","span_ft":[3.0,5.5],"message":"Film the ground."}"#) + let first = try #require(planner.nextServerRequest(in: [pastEnd, ground], leftEnd: -3, rightEnd: 4, asked: [], skipped: [])) + #expect(first.item == pastEnd && first.plan.span == -5 ... -3) + + // "I can't get there": raised and skipped. The end it cleared stays unmarked. + let reworded = try item(#"{"kind":"past_end","side":"left","message":"Keep walking past the left end."}"#) + let second = try #require(planner.nextServerRequest( + in: [reworded, ground], leftEnd: nil, rightEnd: 4, asked: [first.item], skipped: [first.plan])) + #expect(second.item == ground) + + #expect(planner.nextServerRequest( + in: [reworded, ground], leftEnd: nil, rightEnd: 4, asked: [first.item, second.item], skipped: [first.plan])?.item == nil) + // A right past-end is a different view. + let right = try item(#"{"kind":"past_end","side":"right","message":"m"}"#) + #expect(planner.nextServerRequest( + in: [reworded, ground, right], leftEnd: nil, rightEnd: 4, asked: [first.item, second.item], skipped: [])?.item == right) + } + @Test func clearingAnEndLetsCoverageGrowPastIt() throws { let wall = try #require(WallFrame(meter: SIMD3(0, 1.5, 0), outward: SIMD3(0, 0, 1), groundY: 0)) var map = CoverageMap(wall: wall) From c07bafe6c5ecd936d042b51ffd8619828330b6e1 Mon Sep 17 00:00:00 2001 From: AidenJohnston Date: Sun, 27 Sep 2026 00:11:47 -0500 Subject: [PATCH 02/12] ios: hold "Check clearances" and its tick on the upload screen (#31) upload() set .analyzing, decoded the answer, set .done and went to the result in one turn of the main actor, so on an upload that goes straight to the result "Check clearances" never showed its spinner or its tick. - It records when the screen moved to "Check clearances" and, after decoding, waits out the rest of analyzingMinimum (0.6 s) before .done (UploadPacing.remaining, with tests). - Without a follow-up it waits resultHold (0.8 s) after the autopilot gate before going to the result. - After each wait it checks the scan and the phase again. Both times are guesses, like followUpHold. The demo engine holds its final upload the same way. Co-Authored-By: Claude Opus 5.5 (1M context) --- ios/HouseScan/Runtime/ScanEngine.swift | 24 +++++++++++++++++ ios/HouseScan/UI/Preview/DemoEngine.swift | 3 +++ .../HouseScanKit/Upload/UploadPacing.swift | 17 ++++++++++++ .../HouseScanKitTests/UploadPacingTests.swift | 27 +++++++++++++++++++ 4 files changed, 71 insertions(+) create mode 100644 ios/HouseScanKit/Sources/HouseScanKit/Upload/UploadPacing.swift create mode 100644 ios/HouseScanKit/Tests/HouseScanKitTests/UploadPacingTests.swift diff --git a/ios/HouseScan/Runtime/ScanEngine.swift b/ios/HouseScan/Runtime/ScanEngine.swift index 837c6c80f..8d70c125b 100644 --- a/ios/HouseScan/Runtime/ScanEngine.swift +++ b/ios/HouseScan/Runtime/ScanEngine.swift @@ -103,6 +103,16 @@ final class ScanEngine { /// the answer opens the camera: 1.5 s, about the time to read four words and see the new /// step appear, and what the UI lane asked for. Not measured with homeowners. static let followUpHold: Double = 1.5 + /// The least time the upload screen shows "Check clearances" at work before it ticks: 0.6 s, + /// enough to see the step start. The server answers in the request that carries the scene, + /// often a moment after the last byte, and the step was never drawn (issue #31). A guess + /// like `followUpHold`, not measured with homeowners. + static let analyzingMinimum: Double = 0.6 + /// How long the upload screen shows every step ticked before the result replaces it: 0.8 s, + /// about the time to see "Clearances checked" land. A guess like `followUpHold`, not measured. + static let resultHold: Double = 0.8 + /// When the upload screen moved to "Check clearances" in this upload, for `analyzingMinimum`. + private var analyzingSince: ContinuousClock.Instant? // LiDAR /// The bands the see-behind step is about, while `state.guidance` is `.seeBehind`: those with @@ -1408,16 +1418,26 @@ final class ScanEngine { saveBundle(scene: scene, mesh: meshSnapshot) guard !Task.isCancelled else { return } state.upload = .uploading(fraction: 0) + analyzingSince = nil do { let data = try await resultClient.submit(scene: scene) { [weak self] fraction in Task { @MainActor in guard let self, scan == self.generation, case .uploading = self.state.upload else { return } + if fraction >= 1 { self.analyzingSince = ContinuousClock.now } self.state.upload = fraction >= 1 ? .analyzing : .uploading(fraction: fraction) } } guard scan == generation else { return } + if analyzingSince == nil { analyzingSince = ContinuousClock.now } state.upload = .analyzing let result = try PlacementResult.decode(data) + // "Check clearances" stays up for `analyzingMinimum` however fast the answer came, + // so the step and then its tick are drawn (issue #31). + let dwell = UploadPacing.remaining(since: analyzingSince, minimum: .seconds(Self.analyzingMinimum), now: ContinuousClock.now) + if dwell > .zero { + try await Task.sleep(for: dwell) + guard scan == generation, state.phase == .uploading else { return } + } placement = result state.result = presentation(of: result, isSample: resultClient.isSample) state.followUps = automaticGapQueue(result).count @@ -1435,6 +1455,10 @@ final class ScanEngine { beginServerGap(next.item, plan: next.plan) return } + // Every step ticked, "Clearances checked" last, for `resultHold` before the result + // replaces the screen: going on in the same turn never drew the tick (issue #31). + try await Task.sleep(for: .seconds(Self.resultHold)) + guard scan == generation, state.phase == .uploading else { return } // The result appears only after the server answered (checklist R6). go(.result) } catch is CancellationError { diff --git a/ios/HouseScan/UI/Preview/DemoEngine.swift b/ios/HouseScan/UI/Preview/DemoEngine.swift index b6cec4ca0..57f3c7427 100644 --- a/ios/HouseScan/UI/Preview/DemoEngine.swift +++ b/ios/HouseScan/UI/Preview/DemoEngine.swift @@ -474,6 +474,9 @@ final class DemoEngine: ScanActions { enterGap(serverItem: item) return } + // Like the real engine's `resultHold`: every step ticked before the result (#31). + state.upload = .done + guard await pause(0.8) else { return } showResult() } diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Upload/UploadPacing.swift b/ios/HouseScanKit/Sources/HouseScanKit/Upload/UploadPacing.swift new file mode 100644 index 000000000..f8bedd1ae --- /dev/null +++ b/ios/HouseScanKit/Sources/HouseScanKit/Upload/UploadPacing.swift @@ -0,0 +1,17 @@ +import Foundation + +/// How long the upload screen still has to hold a step so the homeowner sees it (issue #31). +/// +/// The server works out the spot inside the same request that carries the scene, so the answer +/// can arrive a moment after the last byte is sent. The engine then went from "Check clearances" +/// to the result in one turn of the main actor, and neither that step's spinner nor its tick was +/// ever drawn. +public enum UploadPacing { + /// What is left of `minimum` since `start`, never below zero: all of it when the step hasn't + /// started (`start` nil), none once `minimum` has passed. + public static func remaining(since start: ContinuousClock.Instant?, minimum: Duration, now: ContinuousClock.Instant) -> Duration { + guard let start else { return minimum } + let left = minimum - (now - start) + return left > .zero ? left : .zero + } +} diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/UploadPacingTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/UploadPacingTests.swift new file mode 100644 index 000000000..67fa66416 --- /dev/null +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/UploadPacingTests.swift @@ -0,0 +1,27 @@ +import Foundation +import HouseScanKit +import Testing + +/// Issue #31: the upload screen holds "Check clearances" for a minimum time however fast the +/// answer comes. +@Suite struct UploadPacingTests { + private let start = ContinuousClock.now + + @Test func anAnswerThatCameAtOnceWaitsTheWholeMinimum() { + #expect(UploadPacing.remaining(since: start, minimum: .milliseconds(600), now: start) == .milliseconds(600)) + } + + @Test func anAnswerPartWayThroughWaitsTheRest() { + let now = start + .milliseconds(250) + #expect(UploadPacing.remaining(since: start, minimum: .milliseconds(600), now: now) == .milliseconds(350)) + } + + @Test func aSlowAnswerDoesNotWait() { + #expect(UploadPacing.remaining(since: start, minimum: .milliseconds(600), now: start + .milliseconds(600)) == .zero) + #expect(UploadPacing.remaining(since: start, minimum: .milliseconds(600), now: start + .seconds(3)) == .zero) + } + + @Test func aStepThatNeverStartedWaitsTheWholeMinimum() { + #expect(UploadPacing.remaining(since: nil, minimum: .milliseconds(800), now: start) == .milliseconds(800)) + } +} From 4ca919d40d848656fc8811b88cd66270bc3f7055 Mon Sep 17 00:00:00 2001 From: AidenJohnston Date: Sun, 27 Sep 2026 00:13:45 -0500 Subject: [PATCH 03/12] ios: name the unexplored end nearer the meter than the spot (#83) The result's "a closer spot may exist on the left" took its side from the first past_end request, which the server lists left first, and otherwise from any unexplored end. It never compared the end with the spot, so it named the left when the spot was already on the left and nearer than that end. PlacementResult.closerUnseenEnd() picks the end instead: an unexplored end within cable reach, nearer the meter than the spot's near edge (a spot over the meter leaves none), the nearest of those; without a spot, the nearest unexplored end. The result carries the end's side and where it is, and the notice says "The scan stopped 1 ft 9 in right of your meter. A closer spot may be past there." Tests use made-up numbers. The demo's review sample carries an end, and the screen-state test checks its copy. Co-Authored-By: Claude Opus 5.5 (1M context) --- ios/HouseScan/Contract/ScanContract.swift | 11 +++- ios/HouseScan/Runtime/ScanEngine+Export.swift | 15 ++--- ios/HouseScan/UI/Copy/ScanCopy.swift | 6 ++ ios/HouseScan/UI/Preview/DemoEngine.swift | 3 + ios/HouseScan/UI/Screens/ResultScreen.swift | 4 +- .../HouseScanKit/Scene/PlacementResult.swift | 37 ++++++++++++ .../PlacementResultTests.swift | 58 +++++++++++++++++++ .../ScreenStatesUITests.swift | 2 + 8 files changed, 123 insertions(+), 13 deletions(-) diff --git a/ios/HouseScan/Contract/ScanContract.swift b/ios/HouseScan/Contract/ScanContract.swift index 00d8f409c..0640ee5d3 100644 --- a/ios/HouseScan/Contract/ScanContract.swift +++ b/ios/HouseScan/Contract/ScanContract.swift @@ -593,6 +593,12 @@ struct ClearanceZone: Identifiable, Equatable, Sendable { var depth: Float } +/// An unexplored end of the walk: its side and where the scan stopped, meters of s. +struct UnseenEnd: Equatable, Sendable { + var side: WallSide + var s: Float +} + struct ResultPresentation: Equatable, Sendable { enum Decision: String, Equatable, Sendable { case pass @@ -613,8 +619,9 @@ struct ResultPresentation: Equatable, Sendable { var checks: [CheckRow] var clearances: [ClearanceZone] var missing: [MissingEvidence] - /// A side of the meter the walk didn't reach, so a closer spot may exist there. - var unseenSide: WallSide? = nil + /// 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 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 diff --git a/ios/HouseScan/Runtime/ScanEngine+Export.swift b/ios/HouseScan/Runtime/ScanEngine+Export.swift index bddbc8abf..748ff0888 100644 --- a/ios/HouseScan/Runtime/ScanEngine+Export.swift +++ b/ios/HouseScan/Runtime/ScanEngine+Export.swift @@ -283,14 +283,11 @@ extension ScanEngine { ) } - var unseen: WallSide? - if let pastEnd = result.missingEvidence.first(where: { $0.kind == .pastEnd })?.side { - unseen = pastEnd == .left ? .left : .right - } else if result.ends.left.kind == .unexplored, result.ends.left.beyondReach != true { - // An end beyond cable reach can't hold the battery whatever lies past it. - unseen = .left - } else if result.ends.right.kind == .unexplored, result.ends.right.beyondReach != true { - unseen = .right + // The unexplored end nearer the meter than the spot, if any, not the first past_end + // request: the server lists those left first, whether or not a spot past the end could + // beat the one it chose (issue #83). + let unseen = result.closerUnseenEnd().map { end in + UnseenEnd(side: end.side == .left ? .left : .right, s: meters(end.sFt)) } return ResultPresentation( @@ -303,7 +300,7 @@ extension ScanEngine { checks: checks, clearances: clearances, missing: missing, - unseenSide: unseen, + unseenEnd: unseen, isSample: isSample ) } diff --git a/ios/HouseScan/UI/Copy/ScanCopy.swift b/ios/HouseScan/UI/Copy/ScanCopy.swift index cc4e82396..1e938bec2 100644 --- a/ios/HouseScan/UI/Copy/ScanCopy.swift +++ b/ios/HouseScan/UI/Copy/ScanCopy.swift @@ -374,6 +374,12 @@ enum ScanCopy { static let rulesNotFinal = "The placement rules aren't final yet, so an installer reviews every result for now." + /// The note on an unexplored end nearer the meter than the spot (issue #83): where the scan + /// stopped, so the homeowner knows which end is meant. Without a spot, any spot past it. + static func unseenEnd(_ end: UnseenEnd, hasSpot: Bool) -> String { + "The scan stopped \(Distance.fromMeter(end.s)). \(hasSpot ? "A closer spot" : "A spot") may be past there." + } + static let shareScan = "Share scan" static let shareScanContents = "Your photos and measurements, for the House Scan team" diff --git a/ios/HouseScan/UI/Preview/DemoEngine.swift b/ios/HouseScan/UI/Preview/DemoEngine.swift index 57f3c7427..a6d05f61f 100644 --- a/ios/HouseScan/UI/Preview/DemoEngine.swift +++ b/ios/HouseScan/UI/Preview/DemoEngine.swift @@ -972,6 +972,8 @@ final class DemoEngine: ScanActions { MissingEvidence(id: "ground-right", text: "A second look at the ground just right of the spot.", capturable: true), MissingEvidence(id: "window-opens", text: "Whether the window next to the spot opens.", capturable: false), ], + // The walk stopped short on the left, nearer the meter than the spot (#83). + unseenEnd: UnseenEnd(side: .left, s: -0.4), isSample: true ) @@ -993,6 +995,7 @@ final class DemoEngine: ScanActions { sample.decision = .pass sample.policyApproved = true sample.summary = "The spot fits every check we could measure." + sample.unseenEnd = nil sample.checks = sample.checks.map { row in var row = row row.outcome = .pass diff --git a/ios/HouseScan/UI/Screens/ResultScreen.swift b/ios/HouseScan/UI/Screens/ResultScreen.swift index f47b7ccad..b52722322 100644 --- a/ios/HouseScan/UI/Screens/ResultScreen.swift +++ b/ios/HouseScan/UI/Screens/ResultScreen.swift @@ -49,8 +49,8 @@ struct ResultScreen: View { Notice(symbol: "info.circle.fill", text: ScanCopy.rulesNotFinal) .accessibilityIdentifier("result.rulesNotFinal") } - if let side = result.unseenSide { - Notice(symbol: "arrow.left.and.right", text: "A closer spot may exist on the \(side.rawValue) of your meter. The scan didn't reach that side.") + if let end = result.unseenEnd { + Notice(symbol: "arrow.left.and.right", text: ScanCopy.unseenEnd(end, hasSpot: result.spot != nil)) .accessibilityIdentifier("result.unseenSide") } // The server's result covers where the battery goes, not the panel itself. diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Scene/PlacementResult.swift b/ios/HouseScanKit/Sources/HouseScanKit/Scene/PlacementResult.swift index 7fd947e98..f1a560bfd 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Scene/PlacementResult.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Scene/PlacementResult.swift @@ -628,6 +628,43 @@ public struct PlacementResult: Codable, Sendable, Equatable { } } +/// An unexplored wall end past which a spot nearer the meter could lie +/// (`PlacementResult.closerUnseenEnd()`). +public struct PlacementUnseenEnd: Sendable, Equatable { + public var side: PlacementSide + /// Where the scan stopped, in s feet from the meter. + public var sFt: Double + + public init(side: PlacementSide, sFt: Double) { + self.side = side + self.sFt = sFt + } +} + +extension PlacementResult { + /// The end to name in "the scan stopped here, a closer spot may be past there", or nil when + /// no end calls for it (issue #83). + /// + /// Only an unexplored end within cable reach counts: a limit end has no wall past it, and + /// wall past an end beyond reach can't hold the battery. With a spot, the end must be nearer + /// the meter than the spot's near edge, so a spot past it could be closer; a spot over the + /// meter leaves none. Of the ends left, the one nearest the meter, whichever side it is on: + /// the server lists its past_end requests left first, which says nothing about distance. + public func closerUnseenEnd() -> PlacementUnseenEnd? { + let nearEdge: Double? = spot.map { spot in + let low = min(spot.spanFt.x, spot.spanFt.y), high = max(spot.spanFt.x, spot.spanFt.y) + return low <= 0 && high >= 0 ? 0 : min(abs(low), abs(high)) + } + var candidates: [PlacementUnseenEnd] = [] + for (side, end) in [(PlacementSide.left, ends.left), (PlacementSide.right, ends.right)] + where end.kind == .unexplored && end.beyondReach != true { + if let nearEdge, abs(end.sFt) >= nearEdge { continue } + candidates.append(PlacementUnseenEnd(side: side, sFt: end.sFt)) + } + return candidates.min { abs($0.sFt) < abs($1.sFt) } + } +} + // MARK: - Strict decoding helpers /// "checks[2].rule.key" style path for error messages. diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/PlacementResultTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/PlacementResultTests.swift index 1d3459947..ef0647716 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/PlacementResultTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/PlacementResultTests.swift @@ -163,4 +163,62 @@ import simd expectError(.wrongArrayLength(path: "spot.center", expected: 2, actual: 1), try Self.sample(replacing: #""center": [4.25, 0.5]"#, with: #""center": [4.25]"#)) } + + // MARK: Closer unseen end (issue #83). Made-up numbers. + + /// The sample with its spot over `spotFt` (nil: no spot) and these ends, in s feet. + private func result( + spotFt: SIMD2?, outcome: PlacementOutcome = .unsure, + left: (PlacementEndKind, Double, Bool?), right: (PlacementEndKind, Double, Bool?) + ) throws -> PlacementResult { + var result = try PlacementResult.decode(Self.sampleData()) + if let spotFt { + result.spot?.spanFt = spotFt + result.spot?.outcome = outcome + } else { + result.spot = nil + } + result.ends.left.kind = left.0 + result.ends.left.sFt = left.1 + result.ends.left.beyondReach = left.2 + result.ends.right.kind = right.0 + result.ends.right.sFt = right.1 + result.ends.right.beyondReach = right.2 + return result + } + + /// A spot left of the meter, a far unexplored left end and a near unexplored right one: only + /// past the right end could a spot be closer, though the server lists the left first. + @Test func theCloserUnseenEndIsTheOneNearerThanTheSpot() throws { + let answer = try result(spotFt: SIMD2(-5.9, -3.3), left: (.unexplored, -13.2, nil), right: (.unexplored, 1.75, nil)) + #expect(answer.closerUnseenEnd() == PlacementUnseenEnd(side: .right, sFt: 1.75)) + // A spot right of the meter, past a near left end: the left. + let right = try result(spotFt: SIMD2(12.0, 14.5), left: (.unexplored, -1.0, nil), right: (.limit, 20.0, nil)) + #expect(right.closerUnseenEnd() == PlacementUnseenEnd(side: .left, sFt: -1.0)) + } + + /// A spot over the meter: nothing past an end could be closer. + @Test func aSpotOverTheMeterLeavesNoCloserUnseenEnd() throws { + let answer = try result(spotFt: SIMD2(-1.0, 2.5), left: (.unexplored, -2.0, nil), right: (.unexplored, 3.0, nil)) + #expect(answer.closerUnseenEnd() == nil) + } + + /// A passing spot with the unexplored end farther out than it: nothing to say. + @Test func anEndFartherThanThePassingSpotIsNotNamed() throws { + let answer = try result(spotFt: SIMD2(3.0, 5.5), outcome: .pass, left: (.limit, -4.0, nil), right: (.unexplored, 20.0, nil)) + #expect(answer.closerUnseenEnd() == nil) + // The bundled sample: the spot 3 ft right, the left end unexplored 8 ft out. + #expect(try PlacementResult.decode(Self.sampleData()).closerUnseenEnd() == nil) + } + + /// Without a spot, the nearest unexplored end within cable reach; a limit end or one beyond + /// reach is never named. + @Test func withoutASpotTheNearestUnexploredEndWithinReach() throws { + let both = try result(spotFt: nil, left: (.unexplored, -6.0, nil), right: (.unexplored, 4.0, false)) + #expect(both.closerUnseenEnd() == PlacementUnseenEnd(side: .right, sFt: 4.0)) + let beyond = try result(spotFt: nil, left: (.unexplored, -6.0, nil), right: (.unexplored, 4.0, true)) + #expect(beyond.closerUnseenEnd() == PlacementUnseenEnd(side: .left, sFt: -6.0)) + let limits = try result(spotFt: nil, left: (.limit, -6.0, nil), right: (.limit, 4.0, nil)) + #expect(limits.closerUnseenEnd() == nil) + } } diff --git a/ios/HouseScanUITests/ScreenStatesUITests.swift b/ios/HouseScanUITests/ScreenStatesUITests.swift index 9319e4111..fae621352 100644 --- a/ios/HouseScanUITests/ScreenStatesUITests.swift +++ b/ios/HouseScanUITests/ScreenStatesUITests.swift @@ -77,6 +77,8 @@ final class ScreenStatesUITests: XCTestCase { "markFeatures-lostPlace": ("review.lostPlace", "Your phone lost its place"), // #40: an overlap reads as one, not as clearance. "result-overlap": ("check.meter_working_space", "Overlaps by 1 foot. The rule is no overlap"), + // #83: the unexplored end nearer the meter than the spot, named by where the scan stopped. + "result-review": ("result.unseenSide", "The scan stopped 1 ft 4 in left of your meter. A closer spot may be past there."), ] /// Controls a state must offer, by identifier. From 5c15da8705ef09bef33a71d1d1dca27deddb7480 Mon Sep 17 00:00:00 2001 From: AidenJohnston Date: Sun, 27 Sep 2026 00:14:40 -0500 Subject: [PATCH 04/12] ios: name a server request's stretch by its ends, clipped to the wall (#75, app half) The gap card named a server request's stretch by its middle (Distance.aroundFromMeter): "Around your meter" for a request over the whole wall, "About 19 ft right" for one from 4 ft to 33 ft. Server, ground-out and walk-out requests now say "from 4 ft to 20 ft right of your meter" or "from 13 ft left to 2 ft right of your meter" (Distance.range), clipped to the wall's marked ends. The phone's own short requests and overhead requests keep the middle. The guidance log records the same words as the card. The server half of #75 (placeholder-driven depth, saying which check a request is for) is not in this change. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Runtime/ScanEngine+GuidanceLog.swift | 2 +- ios/HouseScan/UI/Copy/Distance.swift | 32 +++++++++++++++++++ ios/HouseScan/UI/Copy/ScanCopy.swift | 12 ++++--- .../UI/Screens/GapRequestScreen.swift | 2 +- .../ScreenStatesUITests.swift | 2 ++ 5 files changed, 44 insertions(+), 6 deletions(-) diff --git a/ios/HouseScan/Runtime/ScanEngine+GuidanceLog.swift b/ios/HouseScan/Runtime/ScanEngine+GuidanceLog.swift index 05213f1c1..548bd5858 100644 --- a/ios/HouseScan/Runtime/ScanEngine+GuidanceLog.swift +++ b/ios/HouseScan/Runtime/ScanEngine+GuidanceLog.swift @@ -39,7 +39,7 @@ extension ScanEngine { } return GuidanceLog.Request( topic: .gap(id: gap.id), kind: pastEndSide == nil ? .gapBand : .gapPastEnd, origin: gap.origin == .server ? .server : .phone, - message: Self.text(ScanCopy.gap(gap)), band: pastEndSide == nil ? band : nil, span: plan.span + message: Self.text(ScanCopy.gap(gap, ends: (left: state.wall?.leftEnd, right: state.wall?.rightEnd))), band: pastEndSide == nil ? band : nil, span: plan.span ) case .onboarding, .findMeter, .markFeatures, .uploading, .result, .resultAR, .unsupported: return nil diff --git a/ios/HouseScan/UI/Copy/Distance.swift b/ios/HouseScan/UI/Copy/Distance.swift index 0966ecbc1..7349fe34a 100644 --- a/ios/HouseScan/UI/Copy/Distance.swift +++ b/ios/HouseScan/UI/Copy/Distance.swift @@ -66,6 +66,38 @@ enum Distance { return "\(feetAndInches(s)) \(s < 0 ? "left" : "right") of your meter" } + /// "from 4 ft to 19 ft right of your meter", or "from 3 ft left to 2 ft right of your meter": + /// a stretch by its two ends in whole feet, nearer end first, for a request that can run + /// along much of the wall (issue #75). It is clipped to the wall's marked ends, so a request + /// running past one names only the wall there is; one lying wholly past an end is named as + /// it is. Ends that round to the same foot read "about 5 ft right of your meter". + static func range(_ span: ClosedRange, clippedTo ends: (left: Float?, right: Float?) = (nil, nil)) -> String { + var low = max(span.lowerBound, ends.left ?? -.infinity) + var high = min(span.upperBound, ends.right ?? .infinity) + if low > high { + low = span.lowerBound + high = span.upperBound + } + let foot = metersPerInch * 12 + let lowFeet = Int((low / foot).rounded()), highFeet = Int((high / foot).rounded()) + switch (lowFeet, highFeet) { + case (0, 0): + return "around your meter" + case let (a, b) where a == b: + return "about \(abs(a)) ft \(a < 0 ? "left" : "right") of your meter" + case (0, let b) where b > 0: + return "from your meter to \(b) ft right of it" + case (let a, 0) where a < 0: + return "from your meter to \(-a) ft left of it" + case let (a, b) where a > 0: + return "from \(a) ft to \(b) ft right of your meter" + case let (a, b) where b < 0: + return "from \(-b) ft to \(-a) ft left of your meter" + case let (a, b): + return "from \(-a) ft left to \(b) ft right of your meter" + } + } + /// "about 5 ft right of your meter" for the middle of a span: a place to walk to, not a /// measurement to check. static func aroundFromMeter(_ span: ClosedRange) -> String { diff --git a/ios/HouseScan/UI/Copy/ScanCopy.swift b/ios/HouseScan/UI/Copy/ScanCopy.swift index 1e938bec2..af9a6453e 100644 --- a/ios/HouseScan/UI/Copy/ScanCopy.swift +++ b/ios/HouseScan/UI/Copy/ScanCopy.swift @@ -251,24 +251,28 @@ enum ScanCopy { // MARK: Gap - static func gap(_ gap: GapRequest) -> Instruction { + /// The card for a gap request. A server request can run along much of the wall, so its + /// stretch is named by its two ends, clipped to the wall's marked `ends` (issue #75); the + /// phone's own requests are short and named by their middle. + static func gap(_ gap: GapRequest, ends: (left: Float?, right: Float?) = (nil, nil)) -> Instruction { let place = Distance.aroundFromMeter(gap.span) + let stretch = Distance.range(gap.span, clippedTo: ends) switch gap.reason { case .groundNearCandidate: return Instruction(title: "Show the ground \(place)", detail: "This might be a spot for the battery, so the ground there needs a clear look from two places.") case .wallAboveCandidate: return Instruction(title: "Show the wall \(place)", detail: "Tilt up so the wall above this spot is in view.") case .server(let detail): - return Instruction(title: gap.band == .ground ? "Show the ground \(place)" : "Show the wall \(place)", detail: detail) + return Instruction(title: gap.band == .ground ? "Show the ground \(stretch)" : "Show the wall \(stretch)", detail: detail) case .groundOut(let out): return Instruction( title: "Show the ground out to about \(Distance.feetAtLeast(out)) from the wall", - detail: "\(place.capitalizedFirst). Step back and tilt down until that much ground is in view." + detail: "\(stretch.capitalizedFirst). Step back and tilt down until that much ground is in view." ) case .walkOut(let out): return Instruction( title: "Walk along this stretch about \(Distance.feetAtLeast(out)) out from the wall", - detail: "\(place.capitalizedFirst). Follow the dotted line. Walking there shows nothing stands in front of the wall." + detail: "\(stretch.capitalizedFirst). Follow the dotted line. Walking there shows nothing stands in front of the wall." ) case .overhead: return Instruction( diff --git a/ios/HouseScan/UI/Screens/GapRequestScreen.swift b/ios/HouseScan/UI/Screens/GapRequestScreen.swift index 4fb9e48e0..03e65cd4d 100644 --- a/ios/HouseScan/UI/Screens/GapRequestScreen.swift +++ b/ios/HouseScan/UI/Screens/GapRequestScreen.swift @@ -108,7 +108,7 @@ struct GapRequestScreen: View { if gap.isSatisfied { return Instruction(title: "Got it, thanks", detail: followUps > 0 ? "Updating your result." : "That's the view we needed.") } - return ScanCopy.gap(gap) + return ScanCopy.gap(gap, ends: (left: state.wall?.leftEnd, right: state.wall?.rightEnd)) } } diff --git a/ios/HouseScanUITests/ScreenStatesUITests.swift b/ios/HouseScanUITests/ScreenStatesUITests.swift index fae621352..681f3d0a9 100644 --- a/ios/HouseScanUITests/ScreenStatesUITests.swift +++ b/ios/HouseScanUITests/ScreenStatesUITests.swift @@ -71,6 +71,8 @@ final class ScreenStatesUITests: XCTestCase { "wallWalk-hidden": ("wallTape", "2 sections hidden behind something"), "wallWalk-seeBehind": ("instruction", "Something is in front of the wall here"), "gapRequest-followUp": ("instruction", "One more view to finish"), + // #75: a server request's stretch by its two ends, not its middle. + "gapRequest-groundOut": ("instruction", "From 4 ft to 7 ft right of your meter."), "uploading-followUp": (nil, "One more view to finish"), "markFeatures-groundQuestion": (nil, "What's on the ground along this wall?"), "markFeatures-groundAnswered": ("ground.answered", "Mulch"), From 9b5348bce98f7b08dcedebf2956b670939196810 Mon Sep 17 00:00:00 2001 From: AidenJohnston Date: Sun, 27 Sep 2026 00:10:29 -0500 Subject: [PATCH 05/12] ios: a follow-up asking for more is a new view; "Show my result" on every follow-up (#39) The two open review findings on #49: - GapPlan.asksForSameView suppressed a request whose span was 80 % inside an earlier one and whose reach was within 0.1 m, so 2-9 ft after 2-8 ft, or ground out to 5.10 ft after 4.83 ft, was dropped as a duplicate. It now matches only a request whose span lies inside the earlier one and whose reach is no more than the earlier one's, each give or take 0.1 ft. Tests cover both cases through serverRequests, after a raised and a skipped request, and as two items in one answer. - "Show my result" was hidden with one view left, and the skip hint said skipping shows the result. The next upload can still bring a new request, so the button now shows on every server follow-up and the one-view hint says the scan is checked again. Also deletes the empty ios/ServerGapTests.swift that #49 added by mistake. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../UI/Screens/GapRequestScreen.swift | 13 +++--- .../HouseScanKit/Guidance/GapPlanner.swift | 39 +++++++++++------- .../HouseScanKitTests/ServerGapTests.swift | 41 +++++++++++++++++++ .../ScreenStatesUITests.swift | 9 ++++ ios/ServerGapTests.swift | 0 5 files changed, 82 insertions(+), 20 deletions(-) delete mode 100644 ios/ServerGapTests.swift diff --git a/ios/HouseScan/UI/Screens/GapRequestScreen.swift b/ios/HouseScan/UI/Screens/GapRequestScreen.swift index e84223c08..4fb9e48e0 100644 --- a/ios/HouseScan/UI/Screens/GapRequestScreen.swift +++ b/ios/HouseScan/UI/Screens/GapRequestScreen.swift @@ -7,7 +7,7 @@ import SwiftUI /// A request the finished check sent back (the scan came here from the upload, not from the /// review) carries "One more view to finish" above the instruction, the words the upload screen /// just said. "I can't get there" skips only this view and goes on to the check's next one; -/// "Show my result" stops asking and leads to the result. +/// "Show my result", on every such request, stops asking and leads to the result. struct GapRequestScreen: View { let state: ScanViewState let actions: any ScanActions @@ -45,8 +45,10 @@ struct GapRequestScreen: View { OverheadAnswers(actions: actions) .transition(.opacity) } - // With one view left, "I can't get there" already leads to the result. - if followUps > 1, state.gap?.isSatisfied != true, !asking { + // On every request the check sent back, even with one view left: the upload + // after "I can't get there" can bring a new request in the next answer, and + // this is the one way to stop asking (issue #39). + if followUps > 0, state.gap?.isSatisfied != true, !asking { Button { actions.showResultNow() } label: { @@ -89,11 +91,12 @@ struct GapRequestScreen: View { } /// What "I can't get there" leads to: the check's next view while it wants more than this - /// one, else the result. + /// one. With one left the scan is sent again, and that answer can still ask for a new view, + /// so the hint doesn't promise the result; "Show my result" does. private var skipHint: String { switch followUps { case 0: "Skips this view. An installer will look at this part instead." - case 1: "Skips this view and shows your result. An installer will look at this part instead." + case 1: "Skips this view and checks your scan again. An installer will look at this part instead." default: "Skips this view and goes on to the next one. An installer will look at this part instead." } } diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GapPlanner.swift b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GapPlanner.swift index bdc9d413c..ce092f7b2 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GapPlanner.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GapPlanner.swift @@ -352,31 +352,40 @@ extension GapPlanner { } extension GapPlan { - /// Whether this request asks for the same view as `other`, allowing for the server working it - /// out again from the next upload: the same band and kind of need, a reach within 0.1 m (4 in) - /// of the other's, and at least 80 % of this span inside the other's. After "I can't get - /// there" the skipped stretch goes to review and the spot or its error margins can move, so the - /// same view comes back as, say, 2.41...7.9 ft instead of 2.4...7.9 ft. A met past_end request - /// moves its end on 2 m, so the next one asks for new ground and is a different view. + /// How far a request's span or reach may move and still ask for the same view: 0.1 ft + /// (0.03 m), meters. Numerical jitter from the server working the request out again is a few + /// hundredths of a foot; a guess, not measured. + public static let sameViewTolerance: Float = 0.03048 + + /// Whether this request asks for no view beyond `other`'s, allowing for the server working it + /// out again from the next upload: the same band and kind of need, a reach no more than + /// `sameViewTolerance` past the other's, and a span inside the other's give or take the same + /// tolerance. After "I can't get there" the skipped stretch goes to review and the spot or its + /// error margins can move, so the same view comes back as, say, 2.41...7.9 ft instead of + /// 2.4...7.9 ft. A request that asks for more is a new view, even when it mostly overlaps: a + /// span of 2...9 ft after 2...8 ft, or ground out to 5.10 ft after 4.83 ft (issue #39). A met + /// past_end request moves its end on 2 m, so the next one asks for new ground and is a + /// different view. public func asksForSameView(as other: GapPlan) -> Bool { - guard band == other.band, need.isNear(other.need, within: 0.1) else { return false } - let overlap = min(span.upperBound, other.span.upperBound) - max(span.lowerBound, other.span.lowerBound) - let length = span.upperBound - span.lowerBound - return length > 0 ? overlap >= 0.8 * length : overlap >= 0 + let tolerance = Self.sameViewTolerance + guard band == other.band, need.asksNoMore(than: other.need, within: tolerance) else { return false } + return span.lowerBound >= other.span.lowerBound - tolerance && span.upperBound <= other.span.upperBound + tolerance } } extension GapPlan.Need { - /// The same kind of need, with any reach within `tolerance` meters of the other's. - func isNear(_ other: GapPlan.Need, within tolerance: Float) -> Bool { + /// The same kind of need, with any reach no more than `tolerance` meters past the other's. An + /// overhead need without a height (any recorded view) asks no more than one with a height. + func asksNoMore(than other: GapPlan.Need, within tolerance: Float) -> Bool { switch (self, other) { case (.cells, .cells): return true case let (.groundOut(a), .groundOut(b)), let (.walkOut(a), .walkOut(b)), let (.wallUp(a), .wallUp(b)): - return abs(a - b) <= tolerance + return a <= b + tolerance case let (.overhead(a), .overhead(b)): - guard let a, let b else { return a == nil && b == nil } - return abs(a - b) <= tolerance + guard let a else { return true } + guard let b else { return false } + return a <= b + tolerance default: return false } diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/ServerGapTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/ServerGapTests.swift index 9f1c3f370..23dddbbbd 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/ServerGapTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/ServerGapTests.swift @@ -312,6 +312,47 @@ import Testing #expect(planner.serverRequests(in: all, leftEnd: -3, rightEnd: 4, limitEnds: [], asked: [], skipped: [], limit: 0).isEmpty) } + /// A request that asks for more than one already raised is a new view, even when it mostly + /// overlaps: a longer stretch (2 to 9 ft after 2 to 8 ft, 6/7 of it already asked for) or + /// ground farther out (5.10 ft after 4.83 ft, 0.08 m more). Only jitter within 0.1 ft is the + /// same view. This holds for earlier requests, skipped ones and items in the same answer. + @Test func aRequestAskingForMoreIsANewView() throws { + let planner = GapPlanner() + let first = try item(#"{"kind":"band","band":"ground","span_ft":[2.0,8.0],"out_ft":4.83,"message":"m"}"#) + let longer = try item(#"{"kind":"band","band":"ground","span_ft":[2.0,9.0],"out_ft":4.83,"message":"m"}"#) + let farther = try item(#"{"kind":"band","band":"ground","span_ft":[2.0,8.0],"out_ft":5.10,"message":"m"}"#) + let jitter = try item(#"{"kind":"band","band":"ground","span_ft":[1.95,8.05],"out_ft":4.88,"message":"m"}"#) + let asked = try #require(planner.plan(for: first, leftEnd: -3, rightEnd: 4)) + + // After the first was raised, or skipped. + for (raised, skipped) in [([asked], [GapPlan]()), ([asked], [asked])] { + let next = planner.serverRequests( + in: [first, jitter, longer, farther], leftEnd: -3, rightEnd: 4, limitEnds: [], asked: raised, skipped: skipped, limit: 5) + #expect(next.map { $0.item } == [longer, farther]) + } + // Two items in one answer: each asks for more than the one before it. + #expect(planner.serverRequests( + in: [first, longer], leftEnd: -3, rightEnd: 4, limitEnds: [], asked: [], skipped: [], limit: 5).map { $0.item } == [first, longer]) + #expect(planner.serverRequests( + in: [first, farther], leftEnd: -3, rightEnd: 4, limitEnds: [], asked: [], skipped: [], limit: 5).map { $0.item } == [first, farther]) + // Asking for less than an earlier item is no new view. + #expect(planner.serverRequests( + in: [longer, first, jitter], leftEnd: -3, rightEnd: 4, limitEnds: [], asked: [], skipped: [], limit: 5).map { $0.item } == [longer]) + #expect(planner.serverRequests( + in: [farther, first], leftEnd: -3, rightEnd: 4, limitEnds: [], asked: [], skipped: [], limit: 5).map { $0.item } == [farther]) + } + + /// An overhead request without a height takes any tilt-up view, so it asks for no more than + /// one with a height; one with a height asks for more than one without. + @Test func anOverheadRequestWithAHeightAsksForMoreThanOneWithout() throws { + let planner = GapPlanner() + let anyView = try #require(planner.plan(for: item(#"{"kind":"band","band":"overhead","span_ft":[0,3],"message":"m"}"#), leftEnd: nil, rightEnd: nil)) + let high = try #require(planner.plan(for: item(#"{"kind":"band","band":"overhead","span_ft":[0,3],"out_ft":7.0,"message":"m"}"#), leftEnd: nil, rightEnd: nil)) + #expect(anyView.asksForSameView(as: high)) + #expect(!high.asksForSameView(as: anyView)) + #expect(high.asksForSameView(as: high)) + } + /// A met past_end request moves its end on past the ground it showed, and the next one asks /// for the 2 m after that. With the end left cleared, the next request asked for the ground /// at the meter (issue #35). diff --git a/ios/HouseScanUITests/ScreenStatesUITests.swift b/ios/HouseScanUITests/ScreenStatesUITests.swift index 22ea26c38..9319e4111 100644 --- a/ios/HouseScanUITests/ScreenStatesUITests.swift +++ b/ios/HouseScanUITests/ScreenStatesUITests.swift @@ -79,6 +79,12 @@ final class ScreenStatesUITests: XCTestCase { "result-overlap": ("check.meter_working_space", "Overlaps by 1 foot. The rule is no overlap"), ] + /// Controls a state must offer, by identifier. + private static let controls: [String: [String]] = [ + // #39: "Show my result" on every request the check sent back, with one view left too. + "gapRequest-followUp": ["action.skipGap", "action.showResult"], + ] + /// States where the scan is packaged, so "Share scan" must show. private static let shareStates: Set = ["uploading-offline", "uploading-rejected", "result-review", "result-pass"] @@ -299,6 +305,9 @@ final class ScreenStatesUITests: XCTestCase { } XCTAssertTrue(found, "\(name): \"\(expected.text)\" is missing") } + for identifier in Self.controls[name.hasSuffix("-AX5") ? String(name.dropLast(4)) : name] ?? [] { + XCTAssertTrue(element(app, identifier).exists, "\(name): \(identifier) is missing") + } // A system banner can slide over the app mid-audit (CI's Simulator showed "Ready for Apple // Intelligence" over the photo count), so an issue fails the test only when a second // audit, after the banner's few seconds on screen, finds it again. diff --git a/ios/ServerGapTests.swift b/ios/ServerGapTests.swift deleted file mode 100644 index e69de29bb..000000000 From a44780a21d021b65515c61f93b6b84c1a3de97be Mon Sep 17 00:00:00 2001 From: AidenJohnston Date: Sun, 27 Sep 2026 01:53:24 -0500 Subject: [PATCH 06/12] ios: "Show my result" below the tape at accessibility sizes (#39) At AX5 the follow-up card reaches the bottom edge, and with "Show my result" directly under its "I can't get there" reply the audit failed the reply's contrast (CI run 36276179058). At accessibility sizes the button now goes below the tape, so the space under the card matches the layout that passed. At other sizes it stays above the progress, within thumb reach. The screen-state test now asserts the follow-up's two controls can be tapped: where they are at the default size, and after scrolling to them at AX5. The demo's unseen-end fixture is marked as fixed copy, not taken from the demo walk. Co-Authored-By: Claude Opus 5.5 (1M context) --- ios/HouseScan/UI/Preview/DemoEngine.swift | 4 +- .../UI/Screens/GapRequestScreen.swift | 43 +++++++++++++------ .../ScreenStatesUITests.swift | 23 ++++++++++ 3 files changed, 56 insertions(+), 14 deletions(-) diff --git a/ios/HouseScan/UI/Preview/DemoEngine.swift b/ios/HouseScan/UI/Preview/DemoEngine.swift index a6d05f61f..717b8df3c 100644 --- a/ios/HouseScan/UI/Preview/DemoEngine.swift +++ b/ios/HouseScan/UI/Preview/DemoEngine.swift @@ -972,7 +972,9 @@ final class DemoEngine: ScanActions { MissingEvidence(id: "ground-right", text: "A second look at the ground just right of the spot.", capturable: true), MissingEvidence(id: "window-opens", text: "Whether the window next to the spot opens.", capturable: false), ], - // The walk stopped short on the left, nearer the meter than the spot (#83). + // The walk stopped short on the left, nearer the meter than the spot (#83). Fixed copy + // for the result screen, not worked out from the demo walk: that walk marks its left end + // at -2.9 m, and the gas meter above is "well to the left". unseenEnd: UnseenEnd(side: .left, s: -0.4), isSample: true ) diff --git a/ios/HouseScan/UI/Screens/GapRequestScreen.swift b/ios/HouseScan/UI/Screens/GapRequestScreen.swift index 03e65cd4d..e4f0ab288 100644 --- a/ios/HouseScan/UI/Screens/GapRequestScreen.swift +++ b/ios/HouseScan/UI/Screens/GapRequestScreen.swift @@ -14,6 +14,7 @@ struct GapRequestScreen: View { @State private var cameraSize: CGSize = .zero @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.dynamicTypeSize) private var typeSize var body: some View { ZStack { @@ -45,19 +46,8 @@ struct GapRequestScreen: View { OverheadAnswers(actions: actions) .transition(.opacity) } - // On every request the check sent back, even with one view left: the upload - // after "I can't get there" can bring a new request in the next answer, and - // this is the one way to stop asking (issue #39). - if followUps > 0, state.gap?.isSatisfied != true, !asking { - Button { - actions.showResultNow() - } label: { - Label("Show my result", systemImage: "checkmark.circle") - } - .buttonStyle(.secondary) - .accessibilityHint("Stops asking for views and shows your result. An installer will look at the parts you skip.") - .accessibilityIdentifier("action.showResult") - .transition(.opacity) + if !typeSize.isAccessibilitySize { + showResult } if let gap = state.gap { GapProgress(gap: gap) @@ -72,6 +62,9 @@ struct GapRequestScreen: View { depthChecked: state.depthAvailable ) } + if typeSize.isAccessibilitySize { + showResult + } } .animation(reduceMotion ? .easeOut(duration: 0.15) : Motion.settle, value: asking) } @@ -79,6 +72,30 @@ struct GapRequestScreen: View { .animation(reduceMotion ? .easeOut(duration: 0.15) : Motion.pin, value: state.gap?.isSatisfied) } + /// "Show my result", on every request the check sent back, even with one view left: the + /// upload after "I can't get there" can bring a new request in the next answer, and this is + /// the one way to stop asking (issue #39). + /// + /// It sits above the progress, within thumb reach, except at the accessibility text sizes. + /// There the card reaches the bottom edge, with its "I can't get there" reply cut off by it. + /// With "Show my result" directly under that reply, the audit failed the reply's contrast at + /// AX5 (CI run 36276179058); with the progress under it, as before, it passed. So at those + /// sizes it goes below the tape, the last thing on the screen, reached by scrolling like + /// everything else below the card at that size. + @ViewBuilder private var showResult: some View { + if followUps > 0, state.gap?.isSatisfied != true, !asking { + Button { + actions.showResultNow() + } label: { + Label("Show my result", systemImage: "checkmark.circle") + } + .buttonStyle(.secondary) + .accessibilityHint("Stops asking for views and shows your result. An installer will look at the parts you skip.") + .accessibilityIdentifier("action.showResult") + .transition(.opacity) + } + } + /// The overhead question is up: it replaces the instruction, coaching and "I can't get there" /// until answered, as on the walk. private var asking: Bool { state.overheadQuestion && state.gap?.isSatisfied != true } diff --git a/ios/HouseScanUITests/ScreenStatesUITests.swift b/ios/HouseScanUITests/ScreenStatesUITests.swift index e5ec05315..248d49759 100644 --- a/ios/HouseScanUITests/ScreenStatesUITests.swift +++ b/ios/HouseScanUITests/ScreenStatesUITests.swift @@ -329,6 +329,29 @@ final class ScreenStatesUITests: XCTestCase { for (_, finding) in outcome.persistent { XCTFail("\(name): \(finding.message)") } + // After the audit, so its scrolling can't change what the audit saw: a control that + // exists can still sit past the bottom edge, out of the homeowner's reach. At the default + // size it must be tappable where it is; at AX5 the screen scrolls, so after scrolling to it + // (#39: "Show my result" sits below the tape there). + for identifier in Self.controls[name.hasSuffix("-AX5") ? String(name.dropLast(4)) : name] ?? [] { + let target = element(app, identifier) + guard target.exists else { continue } + let reached = name.hasSuffix("-AX5") ? scrollUntilHittable(target, in: app) : target.isHittable + XCTAssertTrue(reached, "\(name): \(identifier) can't be tapped") + } + } + + /// Drags the screen up, at most four times, until the control can be tapped. The same slow + /// drag as `revealCutOff`, so it scrolls without momentum. + @MainActor + private func scrollUntilHittable(_ target: XCUIElement, in app: XCUIApplication) -> Bool { + let step = app.windows.firstMatch.frame.height * 0.4 + for _ in 0..<4 { + if target.isHittable { return true } + let start = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.7)) + start.press(forDuration: 0.1, thenDragTo: start.withOffset(CGVector(dx: 0, dy: -step)), withVelocity: .slow, thenHoldForDuration: 0.3) + } + return target.isHittable } /// At the largest text sizes a camera screen scrolls, and a control cut off by the bottom edge From a041f1800cb76b8945fe9164f42270ef92442fbb Mon Sep 17 00:00:00 2001 From: AidenJohnston Date: Sun, 27 Sep 2026 05:50:43 -0500 Subject: [PATCH 07/12] ios: "Show my result" below the tape at accessibility sizes (#39) Ported from #109 (a44780a). With the button back on every follow-up, the one-view state has the extra row that failed the reply's contrast at AX5 (CI run 36276179058). At accessibility sizes the button now goes below the tape, so the space under the card matches the layout that passed. The screen-state test asserts the follow-up's two controls can be tapped, after scrolling to them at AX5. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../UI/Screens/GapRequestScreen.swift | 43 +++++++++++++------ .../ScreenStatesUITests.swift | 23 ++++++++++ 2 files changed, 53 insertions(+), 13 deletions(-) diff --git a/ios/HouseScan/UI/Screens/GapRequestScreen.swift b/ios/HouseScan/UI/Screens/GapRequestScreen.swift index 4fb9e48e0..2737a5c31 100644 --- a/ios/HouseScan/UI/Screens/GapRequestScreen.swift +++ b/ios/HouseScan/UI/Screens/GapRequestScreen.swift @@ -14,6 +14,7 @@ struct GapRequestScreen: View { @State private var cameraSize: CGSize = .zero @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.dynamicTypeSize) private var typeSize var body: some View { ZStack { @@ -45,19 +46,8 @@ struct GapRequestScreen: View { OverheadAnswers(actions: actions) .transition(.opacity) } - // On every request the check sent back, even with one view left: the upload - // after "I can't get there" can bring a new request in the next answer, and - // this is the one way to stop asking (issue #39). - if followUps > 0, state.gap?.isSatisfied != true, !asking { - Button { - actions.showResultNow() - } label: { - Label("Show my result", systemImage: "checkmark.circle") - } - .buttonStyle(.secondary) - .accessibilityHint("Stops asking for views and shows your result. An installer will look at the parts you skip.") - .accessibilityIdentifier("action.showResult") - .transition(.opacity) + if !typeSize.isAccessibilitySize { + showResult } if let gap = state.gap { GapProgress(gap: gap) @@ -72,6 +62,9 @@ struct GapRequestScreen: View { depthChecked: state.depthAvailable ) } + if typeSize.isAccessibilitySize { + showResult + } } .animation(reduceMotion ? .easeOut(duration: 0.15) : Motion.settle, value: asking) } @@ -79,6 +72,30 @@ struct GapRequestScreen: View { .animation(reduceMotion ? .easeOut(duration: 0.15) : Motion.pin, value: state.gap?.isSatisfied) } + /// "Show my result", on every request the check sent back, even with one view left: the + /// upload after "I can't get there" can bring a new request in the next answer, and this is + /// the one way to stop asking (issue #39). + /// + /// It sits above the progress, within thumb reach, except at the accessibility text sizes. + /// There the card reaches the bottom edge, with its "I can't get there" reply cut off by it. + /// With "Show my result" directly under that reply, the audit failed the reply's contrast at + /// AX5 (CI run 36276179058); with the progress under it, as before, it passed. So at those + /// sizes it goes below the tape, the last thing on the screen, reached by scrolling like + /// everything else below the card at that size. + @ViewBuilder private var showResult: some View { + if followUps > 0, state.gap?.isSatisfied != true, !asking { + Button { + actions.showResultNow() + } label: { + Label("Show my result", systemImage: "checkmark.circle") + } + .buttonStyle(.secondary) + .accessibilityHint("Stops asking for views and shows your result. An installer will look at the parts you skip.") + .accessibilityIdentifier("action.showResult") + .transition(.opacity) + } + } + /// The overhead question is up: it replaces the instruction, coaching and "I can't get there" /// until answered, as on the walk. private var asking: Bool { state.overheadQuestion && state.gap?.isSatisfied != true } diff --git a/ios/HouseScanUITests/ScreenStatesUITests.swift b/ios/HouseScanUITests/ScreenStatesUITests.swift index 58b03834f..28f8453f0 100644 --- a/ios/HouseScanUITests/ScreenStatesUITests.swift +++ b/ios/HouseScanUITests/ScreenStatesUITests.swift @@ -364,6 +364,29 @@ final class ScreenStatesUITests: XCTestCase { for (_, finding) in outcome.persistent { XCTFail("\(name): \(finding.message)") } + // After the audit, so its scrolling can't change what the audit saw: a control that + // exists can still sit past the bottom edge, out of the homeowner's reach. At the default + // size it must be tappable where it is; at AX5 the screen scrolls, so after scrolling to it + // (#39: "Show my result" sits below the tape there). + for identifier in Self.controls[name.hasSuffix("-AX5") ? String(name.dropLast(4)) : name] ?? [] { + let target = element(app, identifier) + guard target.exists else { continue } + let reached = name.hasSuffix("-AX5") ? scrollUntilHittable(target, in: app) : target.isHittable + XCTAssertTrue(reached, "\(name): \(identifier) can't be tapped") + } + } + + /// Drags the screen up, at most four times, until the control can be tapped. The same slow + /// drag as `revealCutOff`, so it scrolls without momentum. + @MainActor + private func scrollUntilHittable(_ target: XCUIElement, in app: XCUIApplication) -> Bool { + let step = app.windows.firstMatch.frame.height * 0.4 + for _ in 0..<4 { + if target.isHittable { return true } + let start = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.7)) + start.press(forDuration: 0.1, thenDragTo: start.withOffset(CGVector(dx: 0, dy: -step)), withVelocity: .slow, thenHoldForDuration: 0.3) + } + return target.isHittable } /// At the largest text sizes a camera screen scrolls, and a control cut off by the bottom edge From 5029039a25fd9d34bd6a71a22a504e3a8387cc0f Mon Sep 17 00:00:00 2001 From: AidenJohnston Date: Sun, 27 Sep 2026 05:56:20 -0500 Subject: [PATCH 08/12] ios: name a server request's whole stretch, ends rounded outward (#75) Distance.range clipped a request's span to the wall's marked ends and rounded each end to the nearest foot. GapPlanner raises a ground request that runs past a limit end, and meets it only over its whole requested span, so run 2's 4.17...33.17 ft request with a limit end at 19.75 ft read "from 4 ft to 20 ft right of your meter" and the bar could never finish. Nearest-foot rounding also dropped ends: 2.4...7.4 ft read "2 ft to 7 ft". RequestStretch.wholeFeet (HouseScanKit) now names the whole span, the lower end rounded down and the upper end up, within the server's 0.01 ft coverage tolerance. ScanCopy.gap no longer takes the wall's ends. Tests cover both review cases, all-left, all-right and straddling spans, whole feet, and a sweep that the named feet always cover the span. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Runtime/ScanEngine+GuidanceLog.swift | 2 +- ios/HouseScan/UI/Copy/Distance.swift | 23 +++---- ios/HouseScan/UI/Copy/ScanCopy.swift | 8 +-- .../UI/Screens/GapRequestScreen.swift | 2 +- .../Guidance/RequestStretch.swift | 25 ++++++++ .../RequestStretchTests.swift | 60 +++++++++++++++++++ 6 files changed, 100 insertions(+), 20 deletions(-) create mode 100644 ios/HouseScanKit/Sources/HouseScanKit/Guidance/RequestStretch.swift create mode 100644 ios/HouseScanKit/Tests/HouseScanKitTests/RequestStretchTests.swift diff --git a/ios/HouseScan/Runtime/ScanEngine+GuidanceLog.swift b/ios/HouseScan/Runtime/ScanEngine+GuidanceLog.swift index 8be271c7e..a54fc3b26 100644 --- a/ios/HouseScan/Runtime/ScanEngine+GuidanceLog.swift +++ b/ios/HouseScan/Runtime/ScanEngine+GuidanceLog.swift @@ -40,7 +40,7 @@ extension ScanEngine { } return GuidanceLog.Request( topic: .gap(id: gap.id), kind: pastEndSide == nil ? .gapBand : .gapPastEnd, origin: gap.origin == .server ? .server : .phone, - message: Self.text(ScanCopy.gap(gap, ends: (left: state.wall?.leftEnd, right: state.wall?.rightEnd))), band: pastEndSide == nil ? band : nil, span: plan.span + message: Self.text(ScanCopy.gap(gap)), band: pastEndSide == nil ? band : nil, span: plan.span ) case .spotConfirm: return spotCheckGuidance diff --git a/ios/HouseScan/UI/Copy/Distance.swift b/ios/HouseScan/UI/Copy/Distance.swift index 6faddac7f..f7ee8313a 100644 --- a/ios/HouseScan/UI/Copy/Distance.swift +++ b/ios/HouseScan/UI/Copy/Distance.swift @@ -1,4 +1,5 @@ import Foundation +import HouseScanKit /// Lengths in the words a homeowner uses: feet and inches, rounded to the nearest inch. enum Distance { @@ -66,21 +67,15 @@ enum Distance { return "\(feetAndInches(s)) \(s < 0 ? "left" : "right") of your meter" } - /// "from 4 ft to 19 ft right of your meter", or "from 3 ft left to 2 ft right of your meter": + /// "from 4 ft to 34 ft right of your meter", or "from 3 ft left to 2 ft right of your meter": /// a stretch by its two ends in whole feet, nearer end first, for a request that can run - /// along much of the wall (issue #75). It is clipped to the wall's marked ends, so a request - /// running past one names only the wall there is; one lying wholly past an end is named as - /// it is. Ends that round to the same foot read "about 5 ft right of your meter". - static func range(_ span: ClosedRange, clippedTo ends: (left: Float?, right: Float?) = (nil, nil)) -> String { - var low = max(span.lowerBound, ends.left ?? -.infinity) - var high = min(span.upperBound, ends.right ?? .infinity) - if low > high { - low = span.lowerBound - high = span.upperBound - } - let foot = metersPerInch * 12 - let lowFeet = Int((low / foot).rounded()), highFeet = Int((high / foot).rounded()) - switch (lowFeet, highFeet) { + /// along much of the wall (issue #75). A server request is met only over its whole span, so + /// the ends are rounded outward and never clipped to the wall's marked ends + /// (`RequestStretch.wholeFeet`): ground asked for past a limit end is part of the request. + /// Ends that round to the same foot read "about 5 ft right of your meter". + static func range(_ span: ClosedRange) -> String { + let feet = RequestStretch.wholeFeet(span) + switch (feet.lowerBound, feet.upperBound) { case (0, 0): return "around your meter" case let (a, b) where a == b: diff --git a/ios/HouseScan/UI/Copy/ScanCopy.swift b/ios/HouseScan/UI/Copy/ScanCopy.swift index 1c3522f99..392fd760b 100644 --- a/ios/HouseScan/UI/Copy/ScanCopy.swift +++ b/ios/HouseScan/UI/Copy/ScanCopy.swift @@ -256,11 +256,11 @@ enum ScanCopy { // MARK: Gap /// The card for a gap request. A server request can run along much of the wall, so its - /// stretch is named by its two ends, clipped to the wall's marked `ends` (issue #75); the - /// phone's own requests are short and named by their middle. - static func gap(_ gap: GapRequest, ends: (left: Float?, right: Float?) = (nil, nil)) -> Instruction { + /// stretch is named by its two ends, all of it (`Distance.range`, issue #75); the phone's own + /// requests are short and named by their middle. + static func gap(_ gap: GapRequest) -> Instruction { let place = Distance.aroundFromMeter(gap.span) - let stretch = Distance.range(gap.span, clippedTo: ends) + let stretch = Distance.range(gap.span) switch gap.reason { case .groundNearCandidate: return Instruction(title: "Show the ground \(place)", detail: "This might be a spot for the battery, so the ground there needs a clear look from two places.") diff --git a/ios/HouseScan/UI/Screens/GapRequestScreen.swift b/ios/HouseScan/UI/Screens/GapRequestScreen.swift index e4f0ab288..2737a5c31 100644 --- a/ios/HouseScan/UI/Screens/GapRequestScreen.swift +++ b/ios/HouseScan/UI/Screens/GapRequestScreen.swift @@ -125,7 +125,7 @@ struct GapRequestScreen: View { if gap.isSatisfied { return Instruction(title: "Got it, thanks", detail: followUps > 0 ? "Updating your result." : "That's the view we needed.") } - return ScanCopy.gap(gap, ends: (left: state.wall?.leftEnd, right: state.wall?.rightEnd)) + return ScanCopy.gap(gap) } } diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Guidance/RequestStretch.swift b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/RequestStretch.swift new file mode 100644 index 000000000..1c5d0abc3 --- /dev/null +++ b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/RequestStretch.swift @@ -0,0 +1,25 @@ +import Foundation + +/// A server request's stretch in whole feet, for the gap card to name by its two ends (issue #75). +/// +/// A server request is met only over its whole span: `GapPlanner.isSatisfied` needs progress 1 +/// over `GapPlan.requestedSpanFt`. So the stretch the card names must cover all of it. Each end +/// is rounded outward, and nothing is clipped to the wall's marked ends: a ground request can run +/// past a limit end (`GapPlanner.reachesPastEnd`), where ground is still recorded, and naming only +/// the wall's part of it left required ground unnamed. +public enum RequestStretch { + /// How far an end may lie past a whole foot and still round to it: the server's + /// COVERAGE_TOLERANCE_FT, under which a gap reads as rounding (`GapPlanner.fraction`). It also + /// keeps a span given in whole feet from gaining a foot on its way to meters and back. + public static let toleranceFt: Double = 0.01 + + /// `span`, meters along the wall, as whole feet rounded outward: the lower end down, the upper + /// end up. 2.4...7.4 ft is 2...8 ft, -7.4...-2.4 ft is -8...-2 ft, -2.4...7.4 ft is -3...8 ft. + public static func wholeFeet(_ span: ClosedRange) -> ClosedRange { + let low = Double(span.lowerBound) * SceneUnits.feetPerMeter + let high = Double(span.upperBound) * SceneUnits.feetPerMeter + let first = Int((low + toleranceFt).rounded(.down)) + let last = Int((high - toleranceFt).rounded(.up)) + return first...max(first, last) + } +} diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/RequestStretchTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/RequestStretchTests.swift new file mode 100644 index 000000000..ec5b35e25 --- /dev/null +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/RequestStretchTests.swift @@ -0,0 +1,60 @@ +import Foundation +@testable import HouseScanKit +import Testing + +/// Issue #75: the gap card names a server request's whole stretch, so a homeowner who shows +/// exactly what it names meets the request. +@Suite struct RequestStretchTests { + private let metersPerFoot: Float = 0.3048 + + private func feet(_ low: Float, _ high: Float) -> ClosedRange { + RequestStretch.wholeFeet((low * metersPerFoot)...(high * metersPerFoot)) + } + + /// Nearest-foot rounding named 2.4...7.4 ft as 2...7 ft and left the last 0.4 ft unnamed. + @Test func endsRoundOutward() { + #expect(feet(2.4, 7.4) == 2...8) + #expect(feet(-7.4, -2.4) == -8 ... -2) + #expect(feet(-2.4, 7.4) == -3...8) + #expect(feet(2.6, 7.6) == 2...8) + } + + /// A span that starts or ends on a whole foot keeps it, after the trip to meters and back, + /// and so does one within the server's 0.01 ft coverage tolerance of it. + @Test func wholeFeetStayWhole() { + #expect(feet(4, 7) == 4...7) + #expect(feet(-9, -3) == -9 ... -3) + #expect(feet(0, 5) == 0...5) + #expect(feet(4.005, 6.995) == 4...7) + #expect(feet(5.5, 5.5) == 5...6) + } + + /// Run 2's request (#75): ground 4 ft 2 in to 33 ft 2 in right of the meter, with the right + /// end marked as a limit at 19 ft 9 in. The planner raises it, because ground past a limit + /// end is recorded, and it is met only over the whole span. Clipped at the end, the card said + /// "from 4 ft to 20 ft" and the bar could never finish. + @Test func aGroundRequestPastALimitEndIsNamedToItsFarEnd() throws { + let item = try JSONDecoder().decode(PlacementMissingEvidence.self, from: Data( + #"{"kind":"band","band":"ground","span_ft":[4.17,33.17],"out_ft":15.33,"message":"m"}"#.utf8)) + let rightEnd: Float = 19.75 * metersPerFoot + let plan = try #require(GapPlanner().plan(for: item, leftEnd: -1, rightEnd: rightEnd, limitEnds: [.right])) + let named = RequestStretch.wholeFeet(plan.span) + #expect(named == 4...34) + let requested = try #require(plan.requestedSpanFt) + #expect(Double(named.lowerBound) <= requested.lowerBound) + #expect(Double(named.upperBound) >= requested.upperBound) + } + + /// Whatever the span, the whole feet named cover the feet the request needs, give or take + /// the server's coverage tolerance (and float error on the way to meters and back). + @Test func theNamedStretchCoversEveryRequestedSpan() { + let slack = RequestStretch.toleranceFt + 1e-4 + for low in stride(from: Float(-30), through: 30, by: 0.37) { + for length in [Float(0), 0.2, 1, 2.4, 9.99, 29.1] { + let named = feet(low, low + length) + #expect(Double(named.lowerBound) <= Double(low) + slack) + #expect(Double(named.upperBound) >= Double(low + length) - slack) + } + } + } +} From 1951a4485f5ccd437fa362484354efedfb32891c Mon Sep 17 00:00:00 2001 From: AidenJohnston Date: Sun, 27 Sep 2026 05:56:44 -0500 Subject: [PATCH 09/12] ios: say which #83 test case uses published run 3 figures Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Tests/HouseScanKitTests/PlacementResultTests.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/PlacementResultTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/PlacementResultTests.swift index ef0647716..88f6ddc32 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/PlacementResultTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/PlacementResultTests.swift @@ -164,7 +164,8 @@ import simd try Self.sample(replacing: #""center": [4.25, 0.5]"#, with: #""center": [4.25]"#)) } - // MARK: Closer unseen end (issue #83). Made-up numbers. + // MARK: Closer unseen end (issue #83). The first case uses run 3's figures as published in + // #83; the others are made up. /// The sample with its spot over `spotFt` (nil: no spot) and these ends, in s feet. private func result( From 88bca30510a77bde858811b23802d9a686ecc5cf Mon Sep 17 00:00:00 2001 From: AidenJohnston Date: Sun, 27 Sep 2026 08:13:58 -0500 Subject: [PATCH 10/12] ios: a follow-up asking for a farther reach, even 0.09 ft, is a new view (#39) asksForSameView let a reach move 0.1 ft like the span, so ground out to 6.59 ft after the same span out to 6.50 ft counted as the same view and was never raised, and after a skip its capture action stayed off, though progress needs the requested out_ft itself. The span keeps the 0.1 ft tolerance; a reach may be no farther than the earlier one's, compared on the server's out_ft when both have one, else in meters within float noise. Tests: 6.59 ft after 6.50 ft through serverRequests after a raised and a skipped request and as two items in one answer; the refused-view test now moves the span only, and a farther reach after a refusal is raised. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../HouseScanKit/Guidance/GapPlanner.swift | 27 +++++++----- .../HouseScanKitTests/ServerGapTests.swift | 44 ++++++++++++++++--- 2 files changed, 55 insertions(+), 16 deletions(-) diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GapPlanner.swift b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GapPlanner.swift index 3e7a8793a..d57a0cda3 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GapPlanner.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GapPlanner.swift @@ -374,21 +374,28 @@ extension GapPlanner { extension GapPlan { /// How far a request's span or reach may move and still ask for the same view: 0.1 ft /// (0.03 m), meters. Numerical jitter from the server working the request out again is a few - /// hundredths of a foot; a guess, not measured. + /// hundredths of a foot; a guess, not measured. This applies to the span only: a reach gets + /// no allowance beyond float noise (`reachNoise`). public static let sameViewTolerance: Float = 0.03048 + /// Float noise only, meters. A reach is compared as strictly as the server compares it: + /// `GapPlanner.progress` needs the requested out_ft itself, so a view out to 6.50 ft does not + /// meet a request out to 6.59 ft. + static let reachNoise: Float = 1e-5 + /// Whether this request asks for no view beyond `other`'s, allowing for the server working it - /// out again from the next upload: the same band and kind of need, a reach no more than - /// `sameViewTolerance` past the other's, and a span inside the other's give or take the same - /// tolerance. After "I can't get there" the skipped stretch goes to review and the spot or its - /// error margins can move, so the same view comes back as, say, 2.41...7.9 ft instead of - /// 2.4...7.9 ft. A request that asks for more is a new view, even when it mostly overlaps: a - /// span of 2...9 ft after 2...8 ft, or ground out to 5.10 ft after 4.83 ft (issue #39). A met - /// past_end request moves its end on 2 m, so the next one asks for new ground and is a - /// different view. + /// out again from the next upload: the same band and kind of need, a reach no farther than + /// the other's, and a span inside the other's give or take `sameViewTolerance`. After "I can't + /// get there" the skipped stretch goes to review and the spot or its error margins can move, + /// so the same view comes back as, say, 2.41...7.9 ft instead of 2.4...7.9 ft. A request that + /// asks for more is a new view, even when it mostly overlaps: a span of 2...9 ft after + /// 2...8 ft, or ground out to 6.59 ft after 6.50 ft (issue #39). A met past_end request moves + /// its end on 2 m, so the next one asks for new ground and is a different view. public func asksForSameView(as other: GapPlan) -> Bool { + guard band == other.band, need.asksNoMore(than: other.need, within: Self.reachNoise) else { return false } + // The server's own numbers when both have them: what `progress` compares against. + if let reach = requestedOutFt, let otherReach = other.requestedOutFt, reach > otherReach { return false } let tolerance = Self.sameViewTolerance - guard band == other.band, need.asksNoMore(than: other.need, within: tolerance) else { return false } return span.lowerBound >= other.span.lowerBound - tolerance && span.upperBound <= other.span.upperBound + tolerance } } diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/ServerGapTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/ServerGapTests.swift index 23dddbbbd..ff7397f9d 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/ServerGapTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/ServerGapTests.swift @@ -277,14 +277,20 @@ import Testing } /// The next answer works a refused view out again from the new scene, so it can come back with - /// its span or reach moved by a few hundredths of a foot. It is still the view the homeowner - /// said they can't get to, and isn't raised again; another stretch of the band is a new view. + /// its span moved by a few hundredths of a foot, or a reach no farther out. It is still the + /// view the homeowner said they can't get to, and isn't raised again; another stretch of the + /// band is a new view, and so is any reach farther out, which the refused view couldn't meet. @Test func aRefusedViewWithSlightlyDifferentNumbersIsNotRaisedAgain() throws { let planner = GapPlanner() let refused = try item(#"{"kind":"band","band":"ground","span_ft":[2.4,7.9],"out_ft":4.833334,"message":"m"}"#) let plan = try #require(planner.plan(for: refused, leftEnd: -3, rightEnd: 4)) - let moved = try item(#"{"kind":"band","band":"ground","span_ft":[2.41,7.9],"out_ft":4.9,"message":"m"}"#) + let moved = try item(#"{"kind":"band","band":"ground","span_ft":[2.41,7.9],"out_ft":4.833334,"message":"m"}"#) #expect(planner.nextServerRequest(in: [moved], leftEnd: -3, rightEnd: 4, limitEnds: [], asked: [plan], skipped: [plan])?.item == nil) + let nearer = try item(#"{"kind":"band","band":"ground","span_ft":[2.41,7.93],"out_ft":4.8,"message":"m"}"#) + #expect(planner.nextServerRequest(in: [nearer], leftEnd: -3, rightEnd: 4, limitEnds: [], asked: [plan], skipped: [plan])?.item == nil) + let fartherOut = try item(#"{"kind":"band","band":"ground","span_ft":[2.41,7.9],"out_ft":4.9,"message":"m"}"#) + #expect(planner.nextServerRequest( + in: [fartherOut], leftEnd: -3, rightEnd: 4, limitEnds: [], asked: [plan], skipped: [plan])?.item == fartherOut) let elsewhere = try item(#"{"kind":"band","band":"ground","span_ft":[-7.9,-2.4],"out_ft":4.833334,"message":"m"}"#) #expect(planner.nextServerRequest( in: [moved, elsewhere], leftEnd: -3, rightEnd: 4, limitEnds: [], asked: [plan], skipped: [plan])?.item == elsewhere) @@ -314,14 +320,15 @@ import Testing /// A request that asks for more than one already raised is a new view, even when it mostly /// overlaps: a longer stretch (2 to 9 ft after 2 to 8 ft, 6/7 of it already asked for) or - /// ground farther out (5.10 ft after 4.83 ft, 0.08 m more). Only jitter within 0.1 ft is the - /// same view. This holds for earlier requests, skipped ones and items in the same answer. + /// ground farther out (5.10 ft after 4.83 ft, 0.08 m more). Only span jitter within 0.1 ft, + /// with a reach no farther out, is the same view. This holds for earlier requests, skipped + /// ones and items in the same answer. @Test func aRequestAskingForMoreIsANewView() throws { let planner = GapPlanner() let first = try item(#"{"kind":"band","band":"ground","span_ft":[2.0,8.0],"out_ft":4.83,"message":"m"}"#) let longer = try item(#"{"kind":"band","band":"ground","span_ft":[2.0,9.0],"out_ft":4.83,"message":"m"}"#) let farther = try item(#"{"kind":"band","band":"ground","span_ft":[2.0,8.0],"out_ft":5.10,"message":"m"}"#) - let jitter = try item(#"{"kind":"band","band":"ground","span_ft":[1.95,8.05],"out_ft":4.88,"message":"m"}"#) + let jitter = try item(#"{"kind":"band","band":"ground","span_ft":[1.95,8.05],"out_ft":4.8,"message":"m"}"#) let asked = try #require(planner.plan(for: first, leftEnd: -3, rightEnd: 4)) // After the first was raised, or skipped. @@ -342,6 +349,31 @@ import Testing in: [farther, first], leftEnd: -3, rightEnd: 4, limitEnds: [], asked: [], skipped: [], limit: 5).map { $0.item } == [farther]) } + /// A reach only a little farther out is still more than was asked: ground out to 6.59 ft after + /// the same span out to 6.50 ft. `progress` needs the requested out_ft itself, so a view out + /// to 6.50 ft leaves it unmet, and it must be raised (Sam's review of e576190). A reach equal + /// to the earlier one is the same view. + @Test func aSlightlyFartherReachIsANewView() throws { + let planner = GapPlanner() + let first = try item(#"{"kind":"band","band":"ground","span_ft":[2.0,8.0],"out_ft":6.50,"message":"m"}"#) + let farther = try item(#"{"kind":"band","band":"ground","span_ft":[2.0,8.0],"out_ft":6.59,"message":"m"}"#) + let same = try item(#"{"kind":"band","band":"ground","span_ft":[2.0,8.0],"out_ft":6.50,"message":"again"}"#) + let asked = try #require(planner.plan(for: first, leftEnd: -3, rightEnd: 4)) + let fartherPlan = try #require(planner.plan(for: farther, leftEnd: -3, rightEnd: 4)) + #expect(!fartherPlan.asksForSameView(as: asked)) + #expect(asked.asksForSameView(as: fartherPlan)) + + // After the first was raised, or raised and skipped. + for (raised, skipped) in [([asked], [GapPlan]()), ([asked], [asked])] { + let next = planner.serverRequests( + in: [same, farther], leftEnd: -3, rightEnd: 4, limitEnds: [], asked: raised, skipped: skipped, limit: 5) + #expect(next.map { $0.item } == [farther]) + } + // Two items in one answer. + #expect(planner.serverRequests( + in: [first, farther], leftEnd: -3, rightEnd: 4, limitEnds: [], asked: [], skipped: [], limit: 5).map { $0.item } == [first, farther]) + } + /// An overhead request without a height takes any tilt-up view, so it asks for no more than /// one with a height; one with a height asks for more than one without. @Test func anOverheadRequestWithAHeightAsksForMoreThanOneWithout() throws { From 6c3ac6252f9738e94b6ab10b16f0cfc4ee4a5d0d Mon Sep 17 00:00:00 2001 From: AidenJohnston Date: Sun, 27 Sep 2026 08:13:58 -0500 Subject: [PATCH 11/12] ios: UI tests for skip moving on and "Show my result" stopping (#39) The autopilot writes how each request the server's answers raised ended (server-requests.json in the gate folder). testCantGetThereEndsWhereThePhoneIs now asserts a request is raised after a skipped one, and the new testShowMyResultEndsTheRequests taps "Show my result" on the first (-autopilotShowResult) and asserts no other request is raised before the result. "Show my result" appears and goes at once (.identity), like the card's reply, so no faded frame meets the contrast audit. Co-Authored-By: Claude Opus 5.5 (1M context) --- ios/HouseScan/Runtime/Autopilot.swift | 34 +++++++++++++++++-- ios/HouseScan/Runtime/LaunchOptions.swift | 4 +++ .../UI/Screens/GapRequestScreen.swift | 4 ++- ios/HouseScanUITests/FullFlowUITests.swift | 33 ++++++++++++++++++ 4 files changed, 71 insertions(+), 4 deletions(-) diff --git a/ios/HouseScan/Runtime/Autopilot.swift b/ios/HouseScan/Runtime/Autopilot.swift index 02590edb4..20501b086 100644 --- a/ios/HouseScan/Runtime/Autopilot.swift +++ b/ios/HouseScan/Runtime/Autopilot.swift @@ -434,18 +434,46 @@ final class Autopilot { /// for it; a replay shows only what it recorded, so when they don't settle the request the /// autopilot says "I can't get there", as a homeowner would. An overhead request is answered /// like the one before the upload. + /// With `-autopilotShowResult`, "Show my result" on the first one instead. private func driveServerRequest(_ replay: ReplayPlayer) async { guard let request = engine.state.gap else { return } log("server request \(request.id) over \(format(request.span))") - if request.reason == .overhead { - await answerOverheadGap() + let skippedBefore = engine.skippedGaps.count + let action: String + if engine.options.autopilotShowResult { + await pause(hold) + await engine.waitForGate(.gapRequest) + engine.showResultNow() + log("server request \(request.id): show my result") + action = "showResult" } else { - await playGapFrames(replay) + if request.reason == .overhead { + await answerOverheadGap() + } else { + await playGapFrames(replay) + } + action = engine.skippedGaps.count > skippedBefore ? "skipped" : "answered" } + recordServerRequestForTest(id: request.id, action: action) // A settled request stays on screen for a moment before the next upload. _ = await waitUntil(timeout: 10) { self.engine.state.phase != .gapRequest || self.engine.state.gap?.id != request.id } } + /// The requests the server's answers raised, in order, with how each ended ("answered", + /// "skipped" or "showResult"): with `-autopilotGate`, written to the gate folder as + /// `server-requests.json` after each, for the UI test to check the follow-up loop (issue #39). + private var serverRequestsForTest: [[String: Any]] = [] + + private func recordServerRequestForTest(id: Int, action: String) { + serverRequestsForTest.append(["id": id, "action": action]) + guard let gate = engine.options.autopilotGate else { return } + do { + try JSONSerialization.data(withJSONObject: serverRequestsForTest).write(to: gate.appending(path: "server-requests.json")) + } catch { + log("could not write server-requests.json to the gate folder: \(error)") + } + } + /// Waits while the engine plays the replay's frames for the current request. A replay shows /// only what it recorded, so when they don't settle the request the autopilot says "I can't /// get there", as a homeowner would. diff --git a/ios/HouseScan/Runtime/LaunchOptions.swift b/ios/HouseScan/Runtime/LaunchOptions.swift index e5deeb9c6..b7da29b74 100644 --- a/ios/HouseScan/Runtime/LaunchOptions.swift +++ b/ios/HouseScan/Runtime/LaunchOptions.swift @@ -20,6 +20,8 @@ import OSLog /// marking the ends (`Autopilot.endWalkByCantGetThere`). /// - `-autopilotSomethingThere`: the autopilot answers the first spot check "Something's there" /// instead of "It's clear", so the scan is checked again without that area. +/// - `-autopilotShowResult`: the autopilot taps "Show my result" on the first request the +/// server's answer raises, so no further request is raised (issue #39). struct LaunchOptions: Equatable { var replayFolder: URL? var autopilot = false @@ -29,6 +31,7 @@ struct LaunchOptions: Equatable { var autopilotGate: URL? var autopilotCantGetThere = false var autopilotSomethingThere = false + var autopilotShowResult = false init( arguments: [String] = ProcessInfo.processInfo.arguments, @@ -44,6 +47,7 @@ struct LaunchOptions: Equatable { autopilot = arguments.contains("-autopilot") autopilotCantGetThere = arguments.contains("-autopilotCantGetThere") autopilotSomethingThere = arguments.contains("-autopilotSomethingThere") + autopilotShowResult = arguments.contains("-autopilotShowResult") serverURL = (value(after: "-serverURL") ?? defaultServerURL).flatMap(Self.serverURL) sampleResult = arguments.contains("-sampleResult") if let gate = value(after: "-autopilotGate") { autopilotGate = URL(fileURLWithPath: gate, isDirectory: true) } diff --git a/ios/HouseScan/UI/Screens/GapRequestScreen.swift b/ios/HouseScan/UI/Screens/GapRequestScreen.swift index 2737a5c31..1c04c212e 100644 --- a/ios/HouseScan/UI/Screens/GapRequestScreen.swift +++ b/ios/HouseScan/UI/Screens/GapRequestScreen.swift @@ -92,7 +92,9 @@ struct GapRequestScreen: View { .buttonStyle(.secondary) .accessibilityHint("Stops asking for views and shows your result. An installer will look at the parts you skip.") .accessibilityIdentifier("action.showResult") - .transition(.opacity) + // Appears and goes at once, like the card's reply: a fading button is faint text for + // those moments, which the contrast audit fails (as `InstructionCard` found). + .transition(.identity) } } diff --git a/ios/HouseScanUITests/FullFlowUITests.swift b/ios/HouseScanUITests/FullFlowUITests.swift index afc9a8e38..03e31bcd9 100644 --- a/ios/HouseScanUITests/FullFlowUITests.swift +++ b/ios/HouseScanUITests/FullFlowUITests.swift @@ -113,6 +113,7 @@ final class FullFlowUITests: XCTestCase { var tape = "" var scene: [String: Any] = [:] var withdrawn: [Double]? + var requests: [String] = [] try runFlow(replay: Self.fixture, extraArguments: ["-autopilotCantGetThere"], beforeLeaving: { app, phase in guard phase == "wallWalk" else { return } // The value that matched, from one read: the map's element is rebuilt as the walk @@ -129,7 +130,13 @@ final class FullFlowUITests: XCTestCase { withdrawn = (try JSONSerialization.jsonObject(with: bytes) as? [String: Any])?["withdrawn_span_ft"] as? [Double] } scene = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + requests = try Self.serverRequests(gate: gate, test: self) }) + // #39: "I can't get there" on a request the answer raised skips only that view. The + // sample answer lists more than one view, so the upload after the skip raises the next + // one instead of showing the result. + let skippedThenMore = requests.dropLast().contains { $0 == "skipped" } + XCTAssertTrue(skippedThenMore, "no request raised after a skipped one: \(requests)") // The wall map says where each end is, in words VoiceOver reads. XCTAssertTrue(tape.contains("Left end ") && tape.contains("left of your meter") && tape.contains("Right end "), "wall map: \(tape)") @@ -163,6 +170,32 @@ final class FullFlowUITests: XCTestCase { add(attachment) } + /// #39: "Show my result" on the first request the answer raises (`-autopilotShowResult`) ends + /// the requests. The same run as `testCantGetThereEndsWhereThePhoneIs`, whose answers raise a + /// second request after a skip, here reaches the result after that one request. + @MainActor + func testShowMyResultEndsTheRequests() throws { + var requests: [String] = [] + try runFlow(replay: Self.fixture, extraArguments: ["-autopilotCantGetThere", "-autopilotShowResult"], onScene: { _, gate in + requests = try Self.serverRequests(gate: gate, test: self) + }) + XCTAssertEqual(requests, ["showResult"], "requests raised after the answer: \(requests)") + } + + /// How each request the server's answers raised ended, in order ("answered", "skipped", + /// "showResult"), from the autopilot's `server-requests.json` in the gate folder, attached + /// as `server-requests`. Empty when none was raised. + @MainActor + static func serverRequests(gate: URL, test: XCTestCase) throws -> [String] { + guard let bytes = try? Data(contentsOf: gate.appending(path: "server-requests.json")) else { return [] } + let attachment = XCTAttachment(data: bytes, uniformTypeIdentifier: "public.json") + attachment.name = "server-requests" + attachment.lifetime = .keepAlways + test.add(attachment) + let entries = try XCTUnwrap(JSONSerialization.jsonObject(with: bytes) as? [[String: Any]]) + return entries.compactMap { $0["action"] as? String } + } + /// "Something's in the way" on the spot check (`-autopilotSomethingThere`), or Continue when no /// kept photo shows the whole area: the scan stops claiming the area and goes to the server /// again, and the flow still ends on the result, which says the area was left out. The scene the last upload sent, and the one From ecc7a5768e7b8afedf66dbf8a4f65c1f5f78cd50 Mon Sep 17 00:00:00 2001 From: AidenJohnston Date: Sun, 27 Sep 2026 08:15:22 -0500 Subject: [PATCH 12/12] ios: one constant for the server's 0.01 ft coverage tolerance SceneUnits.coverageToleranceFt replaces the literal 0.01 in GapPlanner.fraction and reachesPastEnd, and RequestStretch.toleranceFt now reads it. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Sources/HouseScanKit/Guidance/GapPlanner.swift | 4 ++-- .../Sources/HouseScanKit/Guidance/RequestStretch.swift | 2 +- ios/HouseScanKit/Sources/HouseScanKit/Scene/SceneExport.swift | 4 ++++ 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GapPlanner.swift b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GapPlanner.swift index d57a0cda3..aaad4c9d7 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GapPlanner.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GapPlanner.swift @@ -236,7 +236,7 @@ public struct GapPlanner: Sendable { let high = requested.upperBound guard high > low else { return 0 } let eps = 1e-9 - let tolerance = 0.01 + let tolerance = SceneUnits.coverageToleranceFt var cursor = low var missing = 0.0 let written = spans.compactMap { SceneExport.spanInward($0) }.map { ($0[0], $0[1]) } @@ -277,7 +277,7 @@ extension GapPlanner { /// A span within the server's COVERAGE_TOLERANCE_FT (0.01 ft) of an end reaches it, not past. public func reachesPastEnd(_ item: PlacementMissingEvidence, leftEnd: Float?, rightEnd: Float?, limitEnds: Set) -> Bool { guard item.kind == .band, let span = item.spanFt else { return false } - let tolerance = 0.01 + let tolerance = SceneUnits.coverageToleranceFt let feet = { (meters: Float) in Double(meters) * SceneUnits.feetPerMeter } var past: [WalkSide] = [] if let leftEnd, min(span.x, span.y) < feet(leftEnd) - tolerance { past.append(.left) } diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Guidance/RequestStretch.swift b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/RequestStretch.swift index 1c5d0abc3..2e984360c 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Guidance/RequestStretch.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/RequestStretch.swift @@ -11,7 +11,7 @@ public enum RequestStretch { /// How far an end may lie past a whole foot and still round to it: the server's /// COVERAGE_TOLERANCE_FT, under which a gap reads as rounding (`GapPlanner.fraction`). It also /// keeps a span given in whole feet from gaining a foot on its way to meters and back. - public static let toleranceFt: Double = 0.01 + public static let toleranceFt: Double = SceneUnits.coverageToleranceFt /// `span`, meters along the wall, as whole feet rounded outward: the lower end down, the upper /// end up. 2.4...7.4 ft is 2...8 ft, -7.4...-2.4 ft is -8...-2 ft, -2.4...7.4 ft is -3...8 ft. diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Scene/SceneExport.swift b/ios/HouseScanKit/Sources/HouseScanKit/Scene/SceneExport.swift index 5e3442cf8..db95d71dd 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Scene/SceneExport.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Scene/SceneExport.swift @@ -8,6 +8,10 @@ import simd public enum SceneUnits { /// Exact by definition: the international foot is 0.3048 m. public static let feetPerMeter: Double = 1 / 0.3048 + /// The server's COVERAGE_TOLERANCE_FT (server/scene.py at t3/server 930e8e5): a gap or an + /// overrun this small reads as rounding. `GapPlanner.fraction`, `GapPlanner.reachesPastEnd` + /// and `RequestStretch` all compare with it. + public static let coverageToleranceFt: Double = 0.01 } /// The wall chain described around the electric meter: the meter's wall, plus one more straight