diff --git a/ios/HouseScan/Contract/ScanContract.swift b/ios/HouseScan/Contract/ScanContract.swift index 00f3e3819..c1c5e7cd6 100644 --- a/ios/HouseScan/Contract/ScanContract.swift +++ b/ios/HouseScan/Contract/ScanContract.swift @@ -604,6 +604,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 @@ -635,8 +641,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 @@ -790,6 +797,9 @@ final class ScanViewState { /// camera can't tell open sky from an eave, so the homeowner answers. var overheadQuestion = false var upload: UploadState = .idle + /// How many views the finished check's answer will still ask for without a tap, counting a + /// server request on screen: what "One more view to finish" and "2 more views to finish" promise. + var followUps = 0 var result: ResultPresentation? /// The spot check of the result's spot: the question during `.spotConfirm`, and the answer /// that stands for the spot on the result. @@ -864,8 +874,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/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/Runtime/ScanEngine+Actions.swift b/ios/HouseScan/Runtime/ScanEngine+Actions.swift index 3149a6303..c567d5ce9 100644 --- a/ios/HouseScan/Runtime/ScanEngine+Actions.swift +++ b/ios/HouseScan/Runtime/ScanEngine+Actions.swift @@ -355,6 +355,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+Export.swift b/ios/HouseScan/Runtime/ScanEngine+Export.swift index def58ab09..3c03b287c 100644 --- a/ios/HouseScan/Runtime/ScanEngine+Export.swift +++ b/ios/HouseScan/Runtime/ScanEngine+Export.swift @@ -193,7 +193,9 @@ extension ScanEngine { let plans = result.missingEvidence.map { item in gapPlanner.plan(for: item, leftEnd: coverage?.leftEnd, rightEnd: coverage?.rightEnd, limitEnds: coverage?.limitEnds ?? []) } - let settles = plans.map { plan in plan.map { !skippedGaps.contains($0) && captureCanSettle($0) } ?? false } + let settles = plans.map { plan in + plan.map { plan in !skippedGaps.contains { plan.asksForSameView(as: $0) } && captureCanSettle(plan) } ?? false + } return Self.presentation(of: result, isSample: isSample, wall: sceneWall) { item in result.missingEvidence.firstIndex(of: item).map { settles[$0] } ?? false } @@ -276,14 +278,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( @@ -301,7 +300,7 @@ extension ScanEngine { checks: checks, clearances: clearances, missing: missing, - unseenSide: unseen, + unseenEnd: unseen, isSample: isSample ) } diff --git a/ios/HouseScan/Runtime/ScanEngine.swift b/ios/HouseScan/Runtime/ScanEngine.swift index 60013075d..86b4b748d 100644 --- a/ios/HouseScan/Runtime/ScanEngine.swift +++ b/ios/HouseScan/Runtime/ScanEngine.swift @@ -101,11 +101,12 @@ final class ScanEngine { /// The end the past_end request cleared: where it was, its kind and when it was marked, put /// back or moved on when the request ends without it marked again (`settleClearedEnd`). private var clearedEnd: (s: Float, kind: EndKind, t: Double?)? - /// 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. + /// Server requests raised without a tap since the review was confirmed (`automaticGapQueue`), + /// 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: [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. + /// 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 @@ -116,6 +117,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 @@ -1394,15 +1405,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 + /// (`automaticGapQueue`); 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) } @@ -1412,6 +1422,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() { @@ -1467,16 +1486,20 @@ final class ScanEngine { saveBundle(scene: scene, snapshot: snapshot) guard !Task.isCancelled else { return } state.upload = .uploading(fraction: 0) + // Each attempt's "Check clearances" starts when its bytes are sent (issue #31). + analyzingSince = nil let result: PlacementResult let data: Data do { 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 result = try PlacementResult.decode(data) } catch is CancellationError { @@ -1510,12 +1533,19 @@ final class ScanEngine { break } guard let result = placement else { return } + // "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 { + guard (try? await Task.sleep(for: dwell)) != nil, scan == generation, state.phase == .uploading else { return } + } writeScanStamp(answer: result) state.result = presentation(of: result, isSample: resultClient.isSample) + state.followUps = automaticGapQueue(result).count state.upload = .done await waitForGate(.uploading) guard scan == generation else { return } - if let next = nextAutomaticGap(result) { + if let next = automaticGapQueue(result).first { // The upload screen says "One more view to finish" once the answer is in // (`state.result` set, upload `.done`, both kept through the request): long // enough to read before the camera takes over. @@ -1526,29 +1556,40 @@ 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). + guard (try? await Task.sleep(for: .seconds(Self.resultHold))) != nil, + scan == generation, state.phase == .uploading else { return } // The result appears only after the server answered (checklist R6), and after the // homeowner checked its spot (`presentAnswer`). presentAnswer() } - /// 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. - private func nextAutomaticGap(_ result: PlacementResult) -> (item: PlacementMissingEvidence, plan: GapPlan)? { + /// The answer's items this pass will still raise without a tap, in order, with their + /// requests: those a capture can settle (the result's "capturable"), not skipped and not yet + /// raised in this pass (`GapPlanner.serverRequests`), up to `maxAutomaticGaps` requests. + /// Empty once the homeowner tapped "Show my result". `asking`, a request about to be raised, + /// counts as raised. + private func automaticGapQueue(_ result: PlacementResult, asking: GapPlan? = nil) -> [(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, - sourceState.mayCapture, let map = coverage else { return nil } - for item in result.missingEvidence { - guard let plan = gapPlanner.plan(for: item, leftEnd: map.leftEnd, rightEnd: map.rightEnd, limitEnds: map.limitEnds), - !skippedGaps.contains(plan), !automaticGaps.contains(plan), captureCanSettle(plan) else { continue } - return (item, plan) + guard !automaticGapsStopped, !state.tracking.hasLostItsPlace, sourceState.mayCapture, let map = coverage else { return [] } + let asked = automaticGaps + (asking.map { [$0] } ?? []) + // A new view cannot settle an area the homeowner has already said is obstructed. + // Filter before the request limit so refused areas do not consume the remaining slots. + let capturable = result.missingEvidence.filter { item in + gapPlanner.plan(for: item, leftEnd: map.leftEnd, rightEnd: map.rightEnd, limitEnds: map.limitEnds) + .map { captureCanSettle($0) } ?? false } - return nil + return gapPlanner.serverRequests( + in: capturable, leftEnd: map.leftEnd, rightEnd: map.rightEnd, limitEnds: map.limitEnds, + asked: asked, skipped: skippedGaps, limit: Self.maxAutomaticGaps - automaticGaps.count) } /// Starts the capture for an item of the server's missing evidence, whether tapped on the /// result or raised after an upload. func beginServerGap(_ item: PlacementMissingEvidence, plan: GapPlan) { + // Counted before a past_end request clears its end, which would change the other requests. + state.followUps = 1 + (placement.map { automaticGapQueue($0, asking: plan).count } ?? 0) var pastEnd: WallSide? if item.kind == .pastEnd, let side = item.side { // The walk has to go past the end it stopped at; that end is no longer a limit. It diff --git a/ios/HouseScan/UI/Copy/Distance.swift b/ios/HouseScan/UI/Copy/Distance.swift index ed0cf3522..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,6 +67,32 @@ enum Distance { return "\(feetAndInches(s)) \(s < 0 ? "left" : "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). 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: + 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 725f7c823..bbd3bbaaa 100644 --- a/ios/HouseScan/UI/Copy/ScanCopy.swift +++ b/ios/HouseScan/UI/Copy/ScanCopy.swift @@ -247,24 +247,28 @@ 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, 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) 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( @@ -427,8 +431,10 @@ enum ScanCopy { "Rules \(hash)" } - static func unseenSide(_ side: WallSide) -> String { - "A closer spot may exist on the \(side.rawValue) of your meter. The scan didn't reach that side." + /// 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" diff --git a/ios/HouseScan/UI/Preview/DemoEngine.swift b/ios/HouseScan/UI/Preview/DemoEngine.swift index 367e2a1cc..e5b56abf8 100644 --- a/ios/HouseScan/UI/Preview/DemoEngine.swift +++ b/ios/HouseScan/UI/Preview/DemoEngine.swift @@ -313,6 +313,8 @@ final class DemoEngine: ScanActions { state.target = DemoScene.wall.world(s: 1.7, height: 0, out: 0.5) state.path = DemoScene.path(toward: 1.7) if let serverItem { + // The demo's check asks for one view at most. + state.followUps = 1 state.gap?.origin = .server state.gap?.reason = .server(detail: serverItem.text) run { engine in await engine.gapScript(span: span) } @@ -359,6 +361,7 @@ final class DemoEngine: ScanActions { state.path = [] state.target = nil state.upload = .packaging + state.followUps = 0 run { engine in await engine.uploadScript() } } @@ -507,12 +510,16 @@ final class DemoEngine: ScanActions { if !followedUp, let item = sample.missing.first(where: \.capturable) { state.shareableScan = Self.demoScan state.result = sample + state.followUps = 1 state.upload = .done followedUp = true guard await pause(1.6) else { return } 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 } if spotChecked { showResult() } else { enterSpotCheck() } } @@ -549,6 +556,7 @@ final class DemoEngine: ScanActions { script?.cancel() state.shareableScan = Self.demoScan state.result = Self.reviewSample + state.followUps = 1 state.upload = .done followedUp = true if phase == .gapRequest { @@ -838,6 +846,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)) } @@ -1040,6 +1053,10 @@ final class DemoEngine: ScanActions { MissingEvidence(id: "window-opens", text: "Whether the window next to the spot opens.", capturable: false, checkIDs: ["window"]), ], + // 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 ) @@ -1061,6 +1078,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/GapRequestScreen.swift b/ios/HouseScan/UI/Screens/GapRequestScreen.swift index eaf00f2e4..1c04c212e 100644 --- a/ios/HouseScan/UI/Screens/GapRequestScreen.swift +++ b/ios/HouseScan/UI/Screens/GapRequestScreen.swift @@ -6,13 +6,15 @@ 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", on every such request, stops asking and leads to the result. struct GapRequestScreen: View { let state: ScanViewState let actions: any ScanActions @State private var cameraSize: CGSize = .zero @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.dynamicTypeSize) private var typeSize var body: some View { ZStack { @@ -30,9 +32,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 +46,9 @@ struct GapRequestScreen: View { OverheadAnswers(actions: actions) .transition(.opacity) } + if !typeSize.isAccessibilitySize { + showResult + } if let gap = state.gap { GapProgress(gap: gap) } @@ -59,6 +62,9 @@ struct GapRequestScreen: View { depthChecked: state.depthAvailable ) } + if typeSize.isAccessibilitySize { + showResult + } } .animation(reduceMotion ? .easeOut(duration: 0.15) : Motion.settle, value: asking) } @@ -66,6 +72,32 @@ 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") + // 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) + } + } + /// 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 } @@ -73,8 +105,19 @@ struct GapRequestScreen: View { /// Views the finished check still wants, counting this one, when this request is one of /// them: a server request while the check's answer is in. Zero otherwise. private var followUps: Int { - guard state.gap?.origin == .server, let result = state.result else { return 0 } - return max(1, result.missing.filter(\.capturable).count) + guard state.gap?.origin == .server, state.result != nil else { return 0 } + return max(1, state.followUps) + } + + /// What "I can't get there" leads to: the check's next view while it wants more than this + /// 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 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." + } } private var instruction: Instruction { diff --git a/ios/HouseScan/UI/Screens/ResultScreen.swift b/ios/HouseScan/UI/Screens/ResultScreen.swift index bf5b21dc3..17228b4db 100644 --- a/ios/HouseScan/UI/Screens/ResultScreen.swift +++ b/ios/HouseScan/UI/Screens/ResultScreen.swift @@ -105,8 +105,8 @@ struct ResultScreen: View { if let hash = result.rulesHash { footnote(ScanCopy.rulesHash(hash), id: "result.rulesHash") } - if let side = result.unseenSide { - footnote(ScanCopy.unseenSide(side), id: "result.unseenSide") + if let end = result.unseenEnd { + footnote(ScanCopy.unseenEnd(end, hasSpot: result.spot != nil), id: "result.unseenSide") } footnote(ScanCopy.panelReview, id: "result.panelReview") } diff --git a/ios/HouseScan/UI/Screens/UploadingScreen.swift b/ios/HouseScan/UI/Screens/UploadingScreen.swift index 3accd333e..a4170835d 100644 --- a/ios/HouseScan/UI/Screens/UploadingScreen.swift +++ b/ios/HouseScan/UI/Screens/UploadingScreen.swift @@ -16,8 +16,7 @@ struct UploadingScreen: View { /// Views the finished check wants from the camera before the result. private var followUps: Int { - guard state.upload == .done else { return 0 } - return state.result?.missing.filter(\.capturable).count ?? 0 + state.upload == .done ? state.followUps : 0 } var body: some View { diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GapPlanner.swift b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GapPlanner.swift index d9ecc5237..aaad4c9d7 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GapPlanner.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GapPlanner.swift @@ -115,7 +115,7 @@ public struct GapPlanner: Sendable { /// It asks for nothing above the walking band. The server's answer to the first upload names /// the wall above it where a check needs that, over the stretch the check reads and to the /// height it needs (a wall request with `out_ft`, `GapPlan.Need.wallUp`), and the engine - /// raises it at once as the next view (`ScanEngine.nextAutomaticGap`). A request from here + /// raises it at once as the next view (`ScanEngine.automaticGapQueue`). A request from here /// would have to guess both the stretch and the height (7.5 ft where the public rules need /// just over 6.5), and when the guess missed the spot the server chose, the homeowner would /// be asked for the wall twice. @@ -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) } @@ -342,4 +342,79 @@ extension GapPlanner { public func endAfterPastEnd(_ plan: GapPlan, side: WalkSide, clearedAt old: Float) -> Float { side == .left ? min(plan.span.lowerBound, old) : max(plan.span.upperBound, old) } + + /// The items of the server's missing evidence still to ask for without a tap, in order, with + /// their requests, at most `limit` of them: each one a capture can settle whose request asks + /// for no view already raised in this pass (`asked`), skipped, or earlier in the list + /// (`GapPlan.asksForSameView(as:)`). 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 (issue #39). + public func serverRequests( + in missing: [PlacementMissingEvidence], leftEnd: Float?, rightEnd: Float?, limitEnds: Set, + asked: [GapPlan], skipped: [GapPlan], limit: Int + ) -> [(item: PlacementMissingEvidence, plan: GapPlan)] { + var requests: [(item: PlacementMissingEvidence, plan: GapPlan)] = [] + for item in missing where requests.count < limit { + guard let plan = self.plan(for: item, leftEnd: leftEnd, rightEnd: rightEnd, limitEnds: limitEnds) else { continue } + let seen = asked + skipped + requests.map { $0.plan } + guard !seen.contains(where: { plan.asksForSameView(as: $0) }) else { continue } + requests.append((item, plan)) + } + return requests + } + + /// The first of `serverRequests`: the next item to ask for without a tap. Nil when none is left. + public func nextServerRequest( + in missing: [PlacementMissingEvidence], leftEnd: Float?, rightEnd: Float?, limitEnds: Set, + asked: [GapPlan], skipped: [GapPlan] + ) -> (item: PlacementMissingEvidence, plan: GapPlan)? { + serverRequests(in: missing, leftEnd: leftEnd, rightEnd: rightEnd, limitEnds: limitEnds, asked: asked, skipped: skipped, limit: 1).first + } +} + +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. 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 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 + return span.lowerBound >= other.span.lowerBound - tolerance && span.upperBound <= other.span.upperBound + tolerance + } +} + +extension GapPlan.Need { + /// 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 a <= b + tolerance + case let (.overhead(a), .overhead(b)): + guard let a else { return true } + guard let b else { return false } + return a <= b + tolerance + default: + return false + } + } } diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Guidance/RequestStretch.swift b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/RequestStretch.swift new file mode 100644 index 000000000..2e984360c --- /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 = 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. + 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/Sources/HouseScanKit/Scene/PlacementResult.swift b/ios/HouseScanKit/Sources/HouseScanKit/Scene/PlacementResult.swift index 71bf56c44..ac5ac8455 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Scene/PlacementResult.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Scene/PlacementResult.swift @@ -633,6 +633,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/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 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/PlacementResultTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/PlacementResultTests.swift index 9f894385b..74bf2827b 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/PlacementResultTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/PlacementResultTests.swift @@ -163,4 +163,63 @@ 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). 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( + 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/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) + } + } + } +} diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/ServerGapTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/ServerGapTests.swift index 01a65c1c3..ff7397f9d 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/ServerGapTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/ServerGapTests.swift @@ -245,6 +245,146 @@ 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 skipped + /// past_end request gets its end back (`ScanEngine.settleClearedEnd`), so it comes back as + /// the same request; a met one moves its end on, and the next asks for new ground. + @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 requests = planner.serverRequests(in: [pastEnd, ground], leftEnd: -3, rightEnd: 4, limitEnds: [], asked: [], skipped: [], limit: 5) + #expect(requests.map { $0.item } == [pastEnd, ground]) + let first = try #require(requests.first) + #expect(first.plan.span == -5 ... -3) + + // "I can't get there": raised and skipped, with the end it cleared put back. + 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: -3, rightEnd: 4, limitEnds: [], asked: [first.plan], skipped: [first.plan])) + #expect(second.item == ground) + + #expect(planner.nextServerRequest( + in: [reworded, ground], leftEnd: -3, rightEnd: 4, limitEnds: [], asked: [first.plan, second.plan], 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: -3, rightEnd: 4, limitEnds: [], asked: [first.plan, second.plan], skipped: [])?.item == right) + // Met, the left end moved on to -5 m: the next past_end request is the 2 m after it. + #expect(planner.nextServerRequest( + in: [reworded], leftEnd: -5, rightEnd: 4, limitEnds: [], asked: [first.plan], skipped: [])?.plan.span == -7 ... -5) + } + + /// The next answer works a refused view out again from the new scene, so it can come back with + /// 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.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) + } + + /// Requests raised without a tap keep the past-end guard (issue #35): ground past a marked + /// unexplored end isn't raised, ground past a limit end is. + @Test func theAutomaticRequestsKeepThePastEndGuard() throws { + let planner = GapPlanner() + let ground = try item(#"{"kind":"band","band":"ground","span_ft":[2.0,4.0],"message":"m"}"#) + #expect(planner.nextServerRequest(in: [ground], leftEnd: -1, rightEnd: 1, limitEnds: [], asked: [], skipped: [])?.item == nil) + #expect(planner.nextServerRequest(in: [ground], leftEnd: -1, rightEnd: 1, limitEnds: [.right], asked: [], skipped: [])?.item != nil) + } + + /// "N more views to finish" counts what the answer will raise: two items asking for the same + /// view count once, and no more than the requests left in the pass. + @Test func theViewsLeftCountEachViewOnceUpToTheLimit() throws { + let planner = GapPlanner() + let ground = try item(#"{"kind":"band","band":"ground","span_ft":[3.0,5.5],"message":"Film the ground."}"#) + let sameView = try item(#"{"kind":"band","band":"ground","span_ft":[3.0,5.5],"message":"Another check wants it too."}"#) + let pastEnd = try item(#"{"kind":"past_end","side":"left","message":"m"}"#) + let all = [ground, sameView, pastEnd] + #expect(planner.serverRequests(in: all, leftEnd: -3, rightEnd: 4, limitEnds: [], asked: [], skipped: [], limit: 5).map { $0.item } == [ground, pastEnd]) + #expect(planner.serverRequests(in: all, leftEnd: -3, rightEnd: 4, limitEnds: [], asked: [], skipped: [], limit: 1).count == 1) + #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 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.8,"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]) + } + + /// 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 { + 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/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)) + } +} 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 diff --git a/ios/HouseScanUITests/ScreenStatesUITests.swift b/ios/HouseScanUITests/ScreenStatesUITests.swift index 53af035ab..3de73b2b7 100644 --- a/ios/HouseScanUITests/ScreenStatesUITests.swift +++ b/ios/HouseScanUITests/ScreenStatesUITests.swift @@ -81,26 +81,38 @@ final class ScreenStatesUITests: XCTestCase { /// Words a state must show: in the named element's label or value, or with no identifier, /// in any text on screen. - private static let expectations: [String: (identifier: String?, text: String)] = [ - "wallWalk-hidden": ("wallTape", "2 sections hidden behind something"), - "wallWalk-fullLegend": ("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"), - "uploading-followUp": (nil, "One more view to finish"), - "markFeatures-lostPlace": ("review.lostPlace", "Your phone lost its place"), + private static let expectations: [String: [(identifier: String?, text: String)]] = [ + "wallWalk-hidden": [("wallTape", "2 sections hidden behind something")], + "wallWalk-fullLegend": [("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-lostPlace": [("review.lostPlace", "Your phone lost its place")], // The spot check asks one question over a photo VoiceOver describes, then says the answer. - "spotConfirm": ("spot.question", "Is anything in the marked area?"), - "spotConfirm-which": ("spot.question", "Which one isn't marked?"), - "spotConfirm-ground": ("spot.question", "What's the ground where the battery would stand?"), - "spotConfirm-answered": ("spot.answered", "Thanks, it's mulch"), - "spotConfirm-cantMark": ("spot.answered", "can't be marked now"), - "spotConfirm-unconfirmable": ("spot.question", "Your photos don't show all of this area"), + "spotConfirm": [("spot.question", "Is anything in the marked area?")], + "spotConfirm-which": [("spot.question", "Which one isn't marked?")], + "spotConfirm-ground": [("spot.question", "What's the ground where the battery would stand?")], + "spotConfirm-answered": [("spot.answered", "Thanks, it's mulch")], + "spotConfirm-cantMark": [("spot.answered", "can't be marked now")], + "spotConfirm-unconfirmable": [("spot.question", "Your photos don't show all of this area")], // #40: an overlap reads as one, not as clearance. - "result-overlap": ("check.meter_working_space", "Overlaps by 1 foot. The rule is no overlap"), + "result-overlap": [("check.meter_working_space", "Overlaps by 1 foot. The rule is no overlap")], // The answer comes from the checks: an unsure ground check a view settles. - "result-review": ("result.headline", "One more look"), + "result-review": [ + ("result.headline", "One more look"), + // #83: the unexplored end nearer the meter than the spot, named by where the scan stopped. + ("result.unseenSide", "The scan stopped 1 ft 4 in left of your meter. A closer spot may be past there."), + ], // A reject names the closest spot and the check it fails. - "result-reject": ("result.nearest", "The closest spot"), + "result-reject": [("result.nearest", "The closest spot")], + ] + + /// 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. @@ -346,7 +358,7 @@ final class ScreenStatesUITests: XCTestCase { if screen == "result" { tap(app, "result.details") } XCTAssertTrue(element(app, "action.shareScan").waitForExistence(timeout: 5), "\(name): Share scan is missing") } - if let expected = Self.expectations[name.hasSuffix("-AX5") ? String(name.dropLast(4)) : name] { + for expected in Self.expectations[name.hasSuffix("-AX5") ? String(name.dropLast(4)) : name] ?? [] { let found: Bool if let identifier = expected.identifier { let target = ElementRead.snapshot(element(app, identifier)) @@ -356,6 +368,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. @@ -372,6 +387,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