From 6483e93906abe8ec4dfbd85c74ac728aef194542 Mon Sep 17 00:00:00 2001 From: Sam Gu <127461594+SamGu-NRX@users.noreply.github.com> Date: Thu, 15 Oct 2026 01:20:47 -0500 Subject: [PATCH] feat(ios): suppress automatic views for an unmeasured wall Implements DECISIONS D6 option A as recommended; close if you choose otherwise. --- ios/HouseScan/Runtime/ScanEngine.swift | 20 +++++++++++++++++++ .../EngineGuidanceTests.swift | 1 + 2 files changed, 21 insertions(+) diff --git a/ios/HouseScan/Runtime/ScanEngine.swift b/ios/HouseScan/Runtime/ScanEngine.swift index a1e5dab66..82161e68b 100644 --- a/ios/HouseScan/Runtime/ScanEngine.swift +++ b/ios/HouseScan/Runtime/ScanEngine.swift @@ -2297,6 +2297,9 @@ final class ScanEngine { 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, !state.tracking.hasLostItsPlace, let map = coverage else { return [] } + // D6 option A: a refused, unmeasured wall goes straight to its result. More views + // cannot repair the walk the homeowner already declined. + guard !presentation(of: result, isSample: resultClient.isSample).wallNotMeasured else { return [] } let asked = automaticGaps + (asking.map { [$0] } ?? []) // A new view cannot settle an area the homeowner has already said is obstructed, or // couldn't check. Filter before the request limit so those areas do not consume the @@ -2705,6 +2708,23 @@ extension ScanEngine { guard engine.planner.current == .aimAtGround(s: 0) else { return "fixture did not select meter aim" } now = 5 switch scenario { + case "unmeasuredFollowUps": + guard let url = Bundle.main.url(forResource: "SampleResult", withExtension: "json"), + let data = try? Data(contentsOf: url), + var json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return "missing result fixture" } + json["missing_evidence"] = [["kind": "band", "band": "ground", "span_ft": [-0.5, 0.5], "message": "Show the ground"]] + guard let answer = try? JSONSerialization.data(withJSONObject: json), + let result = try? PlacementResult.decode(answer) else { return "invalid result fixture" } + engine.coverage?.setEnd(.left, at: -0.3) + engine.coverage?.setEnd(.right, at: 0.3) + engine.state.tracking = .normal + guard !engine.automaticGapQueue(result).isEmpty else { return "fixture has no automatic view" } + engine.walkRefusals.ended(.left, at: -0.3, walked: 0, time: 0) + engine.walkRefusals.ended(.right, at: 0.3, walked: 0, time: 6) + guard engine.presentation(of: result, isSample: true).wallNotMeasured else { return "fixture wall is measured" } + guard engine.automaticGapQueue(result).isEmpty else { return "unmeasured wall asked for more views" } + engine.walkRefusals = WalkRefusals() + return engine.automaticGapQueue(result).isEmpty ? "measured wall lost its follow-up" : "passed" case "staleBeforeEnd": engine.handle(.interrupted) now = 30 diff --git a/ios/HouseScanUITests/EngineGuidanceTests.swift b/ios/HouseScanUITests/EngineGuidanceTests.swift index 6dd1f4511..938c3be2e 100644 --- a/ios/HouseScanUITests/EngineGuidanceTests.swift +++ b/ios/HouseScanUITests/EngineGuidanceTests.swift @@ -12,6 +12,7 @@ final class EngineGuidanceTests: XCTestCase { app.terminate() } + @MainActor func testAnUnmeasuredWallDoesNotAutomaticallyAskForMoreViews() { check("unmeasuredFollowUps") } @MainActor func testAnOldFrameCannotUndoAnInterruption() { check("staleBeforeEnd") } @MainActor func testAnOldFrameCannotEndRecoveryAfterTheInterruptionEnds() { check("staleAfterEnd") } @MainActor func testAnInterruptionPausesWithoutAnotherGuidanceUpdate() { check("interruptionRecord") }