From e4ce5031605a8a6bb8d363ccd67edaf5b76bbe2d Mon Sep 17 00:00:00 2001 From: AidenJohnston Date: Sat, 26 Sep 2026 17:24:52 -0500 Subject: [PATCH 1/5] 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 9b5348bce98f7b08dcedebf2956b670939196810 Mon Sep 17 00:00:00 2001 From: AidenJohnston Date: Sun, 27 Sep 2026 00:10:29 -0500 Subject: [PATCH 2/5] 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 a041f1800cb76b8945fe9164f42270ef92442fbb Mon Sep 17 00:00:00 2001 From: AidenJohnston Date: Sun, 27 Sep 2026 05:50:43 -0500 Subject: [PATCH 3/5] 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 88bca30510a77bde858811b23802d9a686ecc5cf Mon Sep 17 00:00:00 2001 From: AidenJohnston Date: Sun, 27 Sep 2026 08:13:58 -0500 Subject: [PATCH 4/5] 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 5/5] 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