From 1e45abaef82456140274f8e7b0145826b6b1fe45 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 3 Oct 2026 05:13:27 -0500 Subject: [PATCH 1/7] ios: a result with no spot and no nearest spot draws no sweep zones The sweep's start_ft is a range of battery left edges, not a footprint, and the answer sizes a battery only on spot or nearest_considered. With neither, the app drew each run with a width of 0 and a made-up 0.3 m depth. ResultMarkLayout.sweepZones now gives no zones without a size, and gives the same zones as before with one. The wall, summary and reasons stay. Co-Authored-By: Claude Opus 5.5 (1M context) --- ios/HouseScan/Runtime/ScanEngine+Export.swift | 25 +++--- .../HouseScanKit/Scene/ResultMarkLayout.swift | 41 ++++++++- .../ResultMarkLayoutTests.swift | 62 ++++++++++++++ .../Fixtures/results/no-spot-no-nearest.json | 84 +++++++++++++++++++ 4 files changed, 197 insertions(+), 15 deletions(-) create mode 100644 ios/HouseScanUITests/Fixtures/results/no-spot-no-nearest.json diff --git a/ios/HouseScan/Runtime/ScanEngine+Export.swift b/ios/HouseScan/Runtime/ScanEngine+Export.swift index 2047ffb97..1d23b7654 100644 --- a/ios/HouseScan/Runtime/ScanEngine+Export.swift +++ b/ios/HouseScan/Runtime/ScanEngine+Export.swift @@ -256,22 +256,23 @@ extension ScanEngine { ) } - // A run's start_ft is a range of battery LEFT edges, so the wall it describes reaches one - // battery width past its last start. The spot carries the size, or after a reject the - // nearest spot, which the server sized the same way. With neither, the zone is drawn over - // the starts alone at 0.3 m deep, which understates it. - let sized = spot ?? nearestSpot - let depth = sized?.depth ?? 0.3 - let width = sized.map { $0.span.upperBound - $0.span.lowerBound } ?? 0 - let clearances = result.sweep.enumerated().map { index, run in - let first = meters(min(run.startFt.x, run.startFt.y)) - let last = meters(max(run.startFt.x, run.startFt.y)) + // The spot carries the battery's size, or without one the nearest spot, which the server + // sized the same way. With neither, `sweepZones` gives no zones: the result keeps its + // wall, summary and reasons, and no view draws floor the server never sized. + let sized = (spot ?? nearestSpot).map { + ResultMarkLayout.BatterySize(width: $0.span.upperBound - $0.span.lowerBound, depth: $0.depth) + } + let zones = ResultMarkLayout.sweepZones( + starts: result.sweep.map { SIMD2(meters($0.startFt.x), meters($0.startFt.y)) }, battery: sized) + // One zone per run, or none. + let clearances = zip(result.sweep, zones).enumerated().map { index, pair in + let (run, zone) = pair return ClearanceZone( id: "sweep-\(index)", label: (run.failing + run.unsure).joined(separator: ", "), outcome: Self.outcome(run.outcome), - span: first...(last + width), - depth: depth + span: zone.span, + depth: zone.depth ) } diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Scene/ResultMarkLayout.swift b/ios/HouseScanKit/Sources/HouseScanKit/Scene/ResultMarkLayout.swift index b0abc2e66..90dc57eb8 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Scene/ResultMarkLayout.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Scene/ResultMarkLayout.swift @@ -1,9 +1,9 @@ import Foundation // Where the result's marks go, shared by the result card's 3D model (`ResultScene3D`) and the AR -// view (`ResultARModel`, `BatteryOverlay`): which spot the camera faces, how the clearance zones -// stack, where a footprint outline sits above them and how it is dashed. Display only; nothing -// here changes where the server put the spot or what it decided. +// view (`ResultARModel`, `BatteryOverlay`): which spot the camera faces, what floor the sweep's +// clearance zones cover and how they stack, where a footprint outline sits above them and how it +// is dashed. Display only; nothing here changes where the server put the spot or what it decided. public enum ResultMarkLayout { /// What stands at the spot. Only a clean fit (`ResultReading.spotIsClean`) gets a battery; a @@ -35,6 +35,41 @@ public enum ResultMarkLayout { return (span.lowerBound + span.upperBound) / 2 } + /// A battery's footprint size, meters: along the wall and out from it. + public struct BatterySize: Equatable, Sendable { + public var width: Float + public var depth: Float + + public init(width: Float, depth: Float) { + self.width = width + self.depth = depth + } + } + + /// The floor a sweep run covers, meters: along the wall and out from it. + public struct SweepZone: Equatable, Sendable { + public var span: ClosedRange + public var depth: Float + + public init(span: ClosedRange, depth: Float) { + self.span = span + self.depth = depth + } + } + + /// One zone per sweep run, in order, or none. `starts` are each run's `start_ft` in meters of + /// s: a range of battery LEFT edges, so the floor it describes reaches one battery width past + /// the larger start and one battery deep. The answer sizes a battery only on `spot` and + /// `nearest_considered` (result.schema.json), so `battery` is the size of one of those. With + /// neither there is no zone at all: the starts alone are not a footprint, and drawing them + /// with a made-up width and depth showed floor the server never sized. + public static func sweepZones(starts: [SIMD2], battery: BatterySize?) -> [SweepZone] { + guard let battery else { return [] } + return starts.map { start in + SweepZone(span: min(start.x, start.y)...(max(start.x, start.y) + battery.width), depth: battery.depth) + } + } + /// How far apart stacked clearance zones sit, in meters, so overlapping ones don't flicker. public static let zoneStep: Float = 0.003 diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/ResultMarkLayoutTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/ResultMarkLayoutTests.swift index 553f0f3c6..6550169ae 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/ResultMarkLayoutTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/ResultMarkLayoutTests.swift @@ -1,3 +1,4 @@ +import Foundation import HouseScanKit import Testing @@ -69,4 +70,65 @@ import Testing #expect(dashes.length == 0.13) #expect(ResultMarkLayout.dashes(along: 0, dash: 0.1, gap: 0.06).starts.isEmpty) } + + // MARK: Sweep zones + + private static let battery = ResultMarkLayout.BatterySize(width: 0.75, depth: 0.5) + + /// With no battery size (no spot and no nearest spot) there is no zone, however many runs + /// the sweep has: a run's starts are not a footprint, and the app has no size of its own. + @Test func noBatterySizeDrawsNoZone() { + let starts: [SIMD2] = [SIMD2(-0.3, -0.3), SIMD2(6.4, 10.2), SIMD2(1, 2.5)] + #expect(ResultMarkLayout.sweepZones(starts: starts, battery: nil).isEmpty) + #expect(ResultMarkLayout.sweepZones(starts: [], battery: nil).isEmpty) + } + + /// A run's starts are battery left edges, so its zone reaches one battery width past the + /// larger start and is one battery deep: the drawing a dimensioned spot or nearest spot got + /// before, value for value. + @Test func aSizedRunReachesOneBatteryPastItsLastStart() { + let zones = ResultMarkLayout.sweepZones(starts: [SIMD2(1, 2.5)], battery: Self.battery) + #expect(zones == [ResultMarkLayout.SweepZone(span: 1...3.25, depth: 0.5)]) + } + + /// Either order of a run's two starts gives the same zone, and a run of one start is one + /// battery wide. + @Test func aRunsStartsMayComeInEitherOrder() { + let zones = ResultMarkLayout.sweepZones(starts: [SIMD2(2.5, 1), SIMD2(-1, -1)], battery: Self.battery) + #expect(zones == [ + ResultMarkLayout.SweepZone(span: 1...3.25, depth: 0.5), + ResultMarkLayout.SweepZone(span: -1...(-0.25), depth: 0.5), + ]) + } + + /// One zone per run, in the sweep's order, so the caller can pair them by position. + @Test func zonesFollowTheRunsInOrder() { + let starts: [SIMD2] = [SIMD2(-2, -1), SIMD2(0, 0.5), SIMD2(3, 4)] + let zones = ResultMarkLayout.sweepZones(starts: starts, battery: Self.battery) + #expect(zones.map(\.span.lowerBound) == [-2, 0, 3]) + #expect(zones.map(\.span.upperBound) == [-0.25, 1.25, 4.75]) + #expect(zones.allSatisfy { $0.depth == 0.5 }) + } + + /// The UI tests' answer with neither a spot nor a nearest spot still has sweep runs, which + /// is the case the zones above must not be drawn for. The reject's nearest spot has a size. + @Test func theUIResultFilesCoverBothCases() throws { + let unsized = try PlacementResult.decode(Data(contentsOf: Self.uiResultFile("no-spot-no-nearest"))) + #expect(unsized.spot == nil) + #expect(unsized.nearestConsidered == nil) + #expect(!unsized.sweep.isEmpty) + let sized = try PlacementResult.decode(Data(contentsOf: Self.uiResultFile("reject-nearest"))) + #expect(sized.spot == nil) + #expect(sized.nearestConsidered != nil) + #expect(!sized.sweep.isEmpty) + } + + private static func uiResultFile(_ name: String, file: String = #filePath) -> URL { + URL(fileURLWithPath: file) + .deletingLastPathComponent() // HouseScanKitTests + .deletingLastPathComponent() // Tests + .deletingLastPathComponent() // HouseScanKit + .deletingLastPathComponent() // ios + .appendingPathComponent("HouseScanUITests/Fixtures/results/\(name).json") + } } diff --git a/ios/HouseScanUITests/Fixtures/results/no-spot-no-nearest.json b/ios/HouseScanUITests/Fixtures/results/no-spot-no-nearest.json new file mode 100644 index 000000000..5ac9b8ba0 --- /dev/null +++ b/ios/HouseScanUITests/Fixtures/results/no-spot-no-nearest.json @@ -0,0 +1,84 @@ +{ + "schema_version": "1.0", + "decision": "reject", + "summary": "Test answer: no spot within reach works: every spot fails route length, wall backing. Demo rules: public values and placeholders (pool 10 ft, drive 5 ft), not Base's.", + "reasons": [ + { + "code": "all_spots_fail", + "checks": [ + "route_length", + "wall_backing" + ], + "message": "Every spot on the walls scanned fails at least one check by a clear margin." + } + ], + "policy": { + "id": "demo", + "version": "2026-09-26", + "auto_approve": true, + "sources": [ + "public" + ], + "rules_sha256": "5a1e0c7d00000000000000000000000000000000000000000000000000000000", + "notice": "Demo rules: public values and placeholders (pool 10 ft, drive 5 ft), not Base's." + }, + "spot": null, + "route": null, + "checks": [], + "nearest_considered": null, + "missing_evidence": [], + "ends": { + "left": { + "kind": "limit", + "s_ft": -1.0, + "point": [ + -1.0, + 0.0 + ] + }, + "right": { + "kind": "limit", + "s_ft": 36.0, + "point": [ + 36.0, + 0.0 + ] + } + }, + "sweep": [ + { + "wall_id": "wall", + "segment": 0, + "start_ft": [ + -1.0, + -1.0 + ], + "outcome": "fail", + "failing": [ + "wall_backing" + ], + "unsure": [] + }, + { + "wall_id": "wall", + "segment": 1, + "start_ft": [ + 21.0, + 33.416667 + ], + "outcome": "fail", + "failing": [ + "route_length" + ], + "unsure": [] + } + ], + "stats": { + "candidates": 0, + "pass": 0, + "unsure": 0, + "fail": 0, + "elapsed_ms": 0, + "input_sha256": "0000000000000000000000000000000000000000000000000000000000000000" + } +} \ No newline at end of file From 2378edc6c16b0ce248b2fcc62391e44fe37e43aa Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 3 Oct 2026 05:14:15 -0500 Subject: [PATCH 2/7] ios: the card says a cable run past the review line needs an installer's review The answer's optional review_threshold_ft was dropped at decoding, so a run of 16 ft read "The rule is at most 20 ft" under "Not sure yet", with nothing about the 15 ft line that made it unsure. PlacementCheck now decodes the line and writes it back only when present, CheckRow carries it, and the measurement sentence names it when ResultReading.reviewBandApplies says it explains an unsure outcome. The card line and its spoken value come from the same function. The sentence says what the rule needs and never that a review was sent. Co-Authored-By: Claude Opus 5.5 (1M context) --- ios/HouseScan/Contract/ScanContract.swift | 4 + ios/HouseScan/Runtime/ScanEngine+Export.swift | 1 + ios/HouseScan/UI/Copy/ScanCopy.swift | 40 ++- .../HouseScanKit/Scene/PlacementResult.swift | 10 +- .../HouseScanKit/Scene/ResultReading.swift | 22 ++ .../ResultReadingTests.swift | 1 + .../HouseScanKitTests/ReviewBandTests.swift | 125 +++++++++ .../Fixtures/results/review-band.json | 242 ++++++++++++++++++ 8 files changed, 442 insertions(+), 3 deletions(-) create mode 100644 ios/HouseScanKit/Tests/HouseScanKitTests/ReviewBandTests.swift create mode 100644 ios/HouseScanUITests/Fixtures/results/review-band.json diff --git a/ios/HouseScan/Contract/ScanContract.swift b/ios/HouseScan/Contract/ScanContract.swift index 705c3e30e..cfa02a84b 100644 --- a/ios/HouseScan/Contract/ScanContract.swift +++ b/ios/HouseScan/Contract/ScanContract.swift @@ -665,6 +665,10 @@ struct CheckRow: Identifiable, Equatable, Sendable { var plusMinus: Float? = nil /// Whether `threshold` is a minimum or a maximum, when the server said which. var comparison: RuleComparison? = nil + /// A stricter line inside `threshold`, meters, when the server sent one (`review_threshold_ft`, + /// on the cable run): a measurement that doesn't clear it needs review even within the limit. + /// Nil when the answer has none. + var reviewThreshold: Float? = nil /// The `MissingEvidence.id` of the first view that would settle this check, when the server /// named one. var settledBy: String? = nil diff --git a/ios/HouseScan/Runtime/ScanEngine+Export.swift b/ios/HouseScan/Runtime/ScanEngine+Export.swift index 1d23b7654..6f8496f23 100644 --- a/ios/HouseScan/Runtime/ScanEngine+Export.swift +++ b/ios/HouseScan/Runtime/ScanEngine+Export.swift @@ -252,6 +252,7 @@ extension ScanEngine { needsPerson: check.needsPerson, measured: check.measuredFt.map(meters), threshold: check.thresholdFt.map(meters), plusMinus: check.plusMinusFt.map(meters), comparison: check.comparison.map(Self.comparison), + reviewThreshold: check.reviewThresholdFt.map(meters), settledBy: result.evidenceIndex(settling: check.id).map(Self.missingID) ) } diff --git a/ios/HouseScan/UI/Copy/ScanCopy.swift b/ios/HouseScan/UI/Copy/ScanCopy.swift index 904b82fc8..b49f35d2d 100644 --- a/ios/HouseScan/UI/Copy/ScanCopy.swift +++ b/ios/HouseScan/UI/Copy/ScanCopy.swift @@ -704,14 +704,26 @@ enum ScanCopy { /// The limit says whether it is a minimum or a maximum: without it, the 20 ft cable limit read /// like a minimum under "Measured 3 ft". /// `spoken` spells out feet and inches for VoiceOver, which reads "ft" and "in" as letters. + /// + /// When the server's review line explains an unsure check (`reviewBand`), the rule names it too: + /// "Measured 16 ft. The rule is at most 20 ft, and anything over 15 ft needs an installer's + /// review. The measurement can be off by about 6 in." Without it, a cable run inside the + /// maximum read as passing under "Not sure yet". It says what the rule needs, never that a + /// review was sent: the app contacts nobody. static func measurement(_ row: CheckRow, spoken: Bool = false) -> String? { guard let measured = row.measured else { return nil } let length = spoken ? Distance.spoken : Distance.feetAndInches var parts = [measuredLine(measured, length: length)] if let threshold = row.threshold { let limit = ruleLimit(threshold, row.comparison, length: length) - if let plusMinus = row.plusMinus, plusMinus > 0 { - parts.append("The rule is \(limit), and the measurement can be off by about \(length(plusMinus)).") + let error = row.plusMinus.flatMap { $0 > 0 ? length($0) : nil } + if let band = reviewBand(row, length: length) { + parts.append("The rule is \(limit), and \(band) needs an installer's review.") + if let error { + parts.append("The measurement can be off by about \(error).") + } + } else if let error { + parts.append("The rule is \(limit), and the measurement can be off by about \(error).") } else { parts.append("The rule is \(limit).") } @@ -719,6 +731,30 @@ enum ScanCopy { return parts.joined(separator: " ") } + /// "anything over 15 ft" under a maximum, "anything under 4 ft" over a minimum: the side of the + /// check's review line that needs review. Nil unless `ResultReading.reviewBandApplies` says + /// the line explains this check's outcome. + static func reviewBand(_ row: CheckRow, length: (Float) -> String = Distance.feetAndInches) -> String? { + guard let review = row.reviewThreshold, let comparison = row.comparison else { return nil } + let outcome: PlacementOutcome = switch row.outcome { + case .pass: .pass + case .fail: .fail + case .unsure: .unsure + } + let direction: PlacementComparison = switch comparison { + case .atLeast: .atLeast + case .atMost: .atMost + } + guard ResultReading.reviewBandApplies( + outcome: outcome, measured: row.measured.map(Double.init), plusMinus: row.plusMinus.map(Double.init), + threshold: row.threshold.map(Double.init), reviewThreshold: Double(review), comparison: direction) + else { return nil } + return switch comparison { + case .atMost: "anything over \(length(review))" + case .atLeast: "anything under \(length(review))" + } + } + /// "Measured 3 ft 2 in.", or "Overlaps by 1 ft 3 in." below zero. A clearance the server /// measures to an area (the meter's working space, a box on the wall above) goes negative /// once the battery is inside it, and the bare magnitude read as clearance (#40). Less than diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Scene/PlacementResult.swift b/ios/HouseScanKit/Sources/HouseScanKit/Scene/PlacementResult.swift index ac5ac8455..e17efbf92 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Scene/PlacementResult.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Scene/PlacementResult.swift @@ -7,7 +7,8 @@ import Foundation // // Unknown keys are ignored. The server adds optional fields within schema 1.0 without changing // schema_version: `checks[].review_threshold_ft` and `sweep[].segment` arrived that way on -// 2026-09-26, and rejecting them stopped every real upload from reaching the result screen. An +// 2026-09-26, and rejecting them stopped every real upload from reaching the result screen +// (`review_threshold_ft` is now read, and stays optional). An // unknown value of an enum the app presents (decision, outcome, unsure_cause, and the others // below) still fails, because the app can't show a decision or outcome it doesn't know. // `reasons[].code`, `policy.sources` and `route.crossings[].effect` are plain strings: the app @@ -372,6 +373,10 @@ public struct PlacementCheck: Codable, Sendable, Equatable { public var measuredFt: Double? public var plusMinusFt: Double? public var thresholdFt: Double? + /// A second, stricter line on the passing side of `thresholdFt`, on checks with a review band + /// (route_length): a measurement that doesn't clear it is at best UNSURE even when it clears + /// `thresholdFt` (result.schema.json). Optional in the schema; nil when the answer has none. + public var reviewThresholdFt: Double? public var comparison: PlacementComparison? public var subject: String? public var rule: PlacementRule @@ -382,6 +387,7 @@ public struct PlacementCheck: Codable, Sendable, Equatable { case measuredFt = "measured_ft" case plusMinusFt = "plus_minus_ft" case thresholdFt = "threshold_ft" + case reviewThresholdFt = "review_threshold_ft" } public init(from decoder: any Decoder) throws { @@ -394,6 +400,7 @@ public struct PlacementCheck: Codable, Sendable, Equatable { measuredFt = try c.decode(Double?.self, forKey: .measuredFt) plusMinusFt = try c.decode(Double?.self, forKey: .plusMinusFt) thresholdFt = try c.decode(Double?.self, forKey: .thresholdFt) + reviewThresholdFt = try c.decodeIfPresent(Double.self, forKey: .reviewThresholdFt) comparison = try c.decode(PlacementComparison?.self, forKey: .comparison) subject = try c.decodeIfPresent(String.self, forKey: .subject) rule = try c.decode(PlacementRule.self, forKey: .rule) @@ -409,6 +416,7 @@ public struct PlacementCheck: Codable, Sendable, Equatable { try c.encode(measuredFt, forKey: .measuredFt) try c.encode(plusMinusFt, forKey: .plusMinusFt) try c.encode(thresholdFt, forKey: .thresholdFt) + try c.encodeIfPresent(reviewThresholdFt, forKey: .reviewThresholdFt) try c.encode(comparison, forKey: .comparison) try c.encodeIfPresent(subject, forKey: .subject) try c.encode(rule, forKey: .rule) diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Scene/ResultReading.swift b/ios/HouseScanKit/Sources/HouseScanKit/Scene/ResultReading.swift index 4479dddea..d922a5738 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Scene/ResultReading.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Scene/ResultReading.swift @@ -83,6 +83,28 @@ public enum ResultReading { return Array((fails + unsure + passes).prefix(limit)) } + /// Whether a check's review line (`PlacementCheck.reviewThresholdFt`) explains its outcome, so + /// the card can say that past it the check needs review. Reads the server's outcome and never + /// replaces it. True only when all of these hold: + /// - the outcome is UNSURE: a PASS cleared the line, and a FAIL is past the limit itself; + /// - the check has a measurement, a limit, a review line and a direction; + /// - the review line lies on the passing side of the limit (under a maximum, over a + /// minimum), so there is a band between them; + /// - the measurement doesn't clear the review line by the schema's own test (at_most clears + /// when measured + error < review line; at_least when measured - error > review line). + /// A missing or negative error counts as none. + public static func reviewBandApplies( + outcome: PlacementOutcome, measured: Double?, plusMinus: Double?, threshold: Double?, + reviewThreshold: Double?, comparison: PlacementComparison? + ) -> Bool { + guard outcome == .unsure, let measured, let threshold, let reviewThreshold, let comparison else { return false } + let error = max(plusMinus ?? 0, 0) + switch comparison { + case .atMost: return reviewThreshold < threshold && !(measured + error < reviewThreshold) + case .atLeast: return reviewThreshold > threshold && !(measured - error > reviewThreshold) + } + } + /// How far `measured` clears `threshold` on the passing side, in units of `plusMinus` when it /// is positive, else in the measurement's own units. Negative when it misses. Without a /// comparison the side is unknown, so the distance to the limit either way. diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/ResultReadingTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/ResultReadingTests.swift index a3807f77e..052d0cbb7 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/ResultReadingTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/ResultReadingTests.swift @@ -210,6 +210,7 @@ import simd @Test(arguments: [ ("pass", ResultReading.Answer.fits), ("reject-nearest", .notHere), ("unsure-view", .oneMoreLook), ("no-clean-spot", .installer), + ("no-spot-no-nearest", .notHere), ("review-band", .installer), ]) func uiResultFilesReadAsNamed(file: String, answer: ResultReading.Answer) throws { let url = URL(fileURLWithPath: #filePath) diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/ReviewBandTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/ReviewBandTests.swift new file mode 100644 index 000000000..ad09543a9 --- /dev/null +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/ReviewBandTests.swift @@ -0,0 +1,125 @@ +import Foundation +import HouseScanKit +import Testing + +/// A check's review line (`review_threshold_ft`): decoded only when the answer sends one, and +/// read by `ResultReading.reviewBandApplies` only when it explains an UNSURE outcome. +@Suite struct ReviewBandTests { + // MARK: Decoding + + /// The UI tests' review-band answer: the cable run measures 16 ft (± 6 in), past the 15 ft + /// review line and under the 20 ft maximum, as server/tests/test_review_regressions.py's + /// start at 16 ft gives it. + @Test func theReviewLineSurvivesDecoding() throws { + let result = try PlacementResult.decode(Data(contentsOf: Self.uiResultFile("review-band"))) + let route = try #require(result.checks.first { $0.id == "route_length" }) + #expect(route.outcome == .unsure) + #expect(route.unsureCause == .ruleRequiresReview) + #expect(route.measuredFt == 16) + #expect(route.plusMinusFt == 0.5) + #expect(route.thresholdFt == 20) + #expect(route.reviewThresholdFt == 15) + #expect(route.comparison == .atMost) + // The other checks have no review line, and stay without one. + #expect(result.checks.filter { $0.id != "route_length" }.allSatisfy { $0.reviewThresholdFt == nil }) + } + + /// The hosted server's own answer carries the line on its cable run check. + @Test func aRealServerAnswerKeepsItsReviewLine() throws { + let url = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + .appendingPathComponent("Schemas/server-answer-synthetic-wall.json") + let result = try PlacementResult.decode(Data(contentsOf: url)) + #expect(result.checks.first { $0.id == "route_length" }?.reviewThresholdFt == 15) + } + + /// Encoding writes the line back only where the answer had one: an absent line stays absent + /// rather than becoming null, which the schema doesn't allow. + @Test func encodingKeepsAnAbsentLineAbsent() throws { + let result = try PlacementResult.decode(Data(contentsOf: Self.uiResultFile("review-band"))) + let encoded = try JSONEncoder().encode(result) + #expect(try SceneSchemas.result().validate(encoded) == []) + #expect(try PlacementResult.decode(encoded) == result) + let checks = try #require( + (try JSONSerialization.jsonObject(with: encoded) as? [String: Any])?["checks"] as? [[String: Any]]) + for check in checks { + let id = try #require(check["id"] as? String) + #expect((check["review_threshold_ft"] != nil) == (id == "route_length"), "\(id)") + } + } + + // MARK: When the line explains the outcome + + struct Case: CustomTestStringConvertible, Sendable { + var name: String + var outcome: PlacementOutcome = .unsure + var measured: Double? = 16 + var plusMinus: Double? = 0.5 + var threshold: Double? = 20 + var review: Double? = 15 + var comparison: PlacementComparison? = .atMost + var applies: Bool + + var testDescription: String { name } + } + + @Test(arguments: [ + // The solver's review band: past the confident reach, under the maximum. + Case(name: "16 ft between 15 ft and 20 ft", applies: true), + // Within its error of the review line, which the solver calls a margin: still past it. + Case(name: "14 ft 10 in, error reaches 15 ft", measured: 14.8, applies: true), + Case(name: "exactly reaching the line", measured: 14.5, applies: true), + // Within its error of the maximum: still unsure, and the line still applies. + Case(name: "20 ft 4 in, within error of the maximum", measured: 20.3, applies: true), + // The server's outcome decides; the app never reads the band into a pass or a fail. + Case(name: "a pass", outcome: .pass, applies: false), + Case(name: "a fail", outcome: .fail, measured: 24, applies: false), + // Clears the line by the schema's test: unsure for some other reason. + Case(name: "clears the line", measured: 14, applies: false), + Case(name: "no error, under the line", measured: 14.9, plusMinus: nil, applies: false), + Case(name: "a negative error counts as none", measured: 14.9, plusMinus: -1, applies: false), + // Anything missing: nothing to explain. + Case(name: "no review line", review: nil, applies: false), + Case(name: "no limit", threshold: nil, applies: false), + Case(name: "no measurement", measured: nil, applies: false), + Case(name: "no direction", comparison: nil, applies: false), + // A line on the failing side of the limit, or on it, leaves no band. + Case(name: "review line past the maximum", review: 25, applies: false), + Case(name: "review line on the maximum", review: 20, applies: false), + // A minimum mirrors it: the review line sits above the limit. + Case(name: "at least: between 3 ft and 4 ft", measured: 3.5, plusMinus: 0.2, threshold: 3, review: 4, + comparison: .atLeast, applies: true), + Case(name: "at least: clears the line", measured: 4.5, plusMinus: 0.2, threshold: 3, review: 4, + comparison: .atLeast, applies: false), + Case(name: "at least: review line under the minimum", measured: 3.5, plusMinus: 0.2, threshold: 3, review: 2, + comparison: .atLeast, applies: false), + ]) + func reviewBandApplies(_ c: Case) { + #expect(ResultReading.reviewBandApplies( + outcome: c.outcome, measured: c.measured, plusMinus: c.plusMinus, threshold: c.threshold, + reviewThreshold: c.review, comparison: c.comparison) == c.applies) + } + + /// The UI tests' answers: the line explains the review band's cable run, and not the passing + /// cable run in no-clean-spot, which carries the same line. + @Test func theUIResultFilesReadAsExpected() throws { + func applies(_ file: String) throws -> Bool { + let result = try PlacementResult.decode(Data(contentsOf: Self.uiResultFile(file))) + let route = try #require(result.checks.first { $0.id == "route_length" }) + #expect(route.reviewThresholdFt == 15, "\(file)") + return ResultReading.reviewBandApplies( + outcome: route.outcome, measured: route.measuredFt, plusMinus: route.plusMinusFt, + threshold: route.thresholdFt, reviewThreshold: route.reviewThresholdFt, comparison: route.comparison) + } + #expect(try applies("review-band")) + #expect(try !applies("no-clean-spot")) + } + + private static func uiResultFile(_ name: String, file: String = #filePath) -> URL { + URL(fileURLWithPath: file) + .deletingLastPathComponent() // HouseScanKitTests + .deletingLastPathComponent() // Tests + .deletingLastPathComponent() // HouseScanKit + .deletingLastPathComponent() // ios + .appendingPathComponent("HouseScanUITests/Fixtures/results/\(name).json") + } +} diff --git a/ios/HouseScanUITests/Fixtures/results/review-band.json b/ios/HouseScanUITests/Fixtures/results/review-band.json new file mode 100644 index 000000000..4bde361c2 --- /dev/null +++ b/ios/HouseScanUITests/Fixtures/results/review-band.json @@ -0,0 +1,242 @@ +{ + "schema_version": "1.0", + "decision": "manual_review", + "summary": "Test answer: a person needs to check the best spot, 15 ft 3 in right of the meter: cable run length. Demo rules: public values and placeholders (pool 10 ft, drive 5 ft), not Base's.", + "reasons": [ + { + "code": "unsure_checks", + "checks": [ + "route_length" + ], + "message": "The best spot has checks nobody can settle from this scan: cable run length." + } + ], + "policy": { + "id": "demo", + "version": "2026-09-26", + "auto_approve": true, + "sources": [ + "public" + ], + "rules_sha256": "5a1e0c7d00000000000000000000000000000000000000000000000000000000", + "notice": "Demo rules: public values and placeholders (pool 10 ft, drive 5 ft), not Base's." + }, + "spot": { + "outcome": "unsure", + "wall_id": "wall", + "segment": 0, + "span_ft": [ + 14.0, + 16.583333333333 + ], + "width_ft": 2.583333333333, + "depth_ft": 1.833333333333, + "height_ft": 3.291666666667, + "footprint": [ + [ + 14.0, + 0.0 + ], + [ + 16.583333333333, + 0.0 + ], + [ + 16.583333333333, + 1.833333333333 + ], + [ + 14.0, + 1.833333333333 + ] + ], + "center": [ + 15.2916666666665, + 0.9166666666665 + ], + "along": [ + 1.0, + 0.0 + ], + "outward": [ + 0.0, + 1.0 + ], + "meter_offset_ft": [ + 15.2916666666665, + 0.9166666666665 + ], + "route_length_ft": 16.0 + }, + "route": { + "outcome": "unsure", + "length_ft": 16.0, + "plus_minus_ft": 0.5, + "height_ft": 1.5, + "polyline": [ + [ + 0.0, + 0.0 + ], + [ + 14.0, + 0.0 + ] + ], + "detours": [], + "crossings": [] + }, + "checks": [ + { + "id": "wall_backing", + "label": "Flush against one straight wall", + "outcome": "pass", + "reason": "Test: seen flat behind the whole spot.", + "measured_ft": null, + "plus_minus_ft": null, + "threshold_ft": null, + "comparison": null, + "subject": null, + "rule": { + "key": "wall_backing_ft", + "source": "Made-up test value", + "placeholder": true + } + }, + { + "id": "gas_clearance", + "label": "Distance from gas equipment", + "outcome": "pass", + "reason": "Test: well clear.", + "measured_ft": 6.2, + "plus_minus_ft": 0.3, + "threshold_ft": 3.0, + "comparison": "at_least", + "subject": null, + "rule": { + "key": "gas_clearance_ft", + "source": "Made-up test value", + "placeholder": true + } + }, + { + "id": "opening_clearance", + "label": "Distance from doors and windows", + "outcome": "pass", + "reason": "Test: clear of the window.", + "measured_ft": 3.9, + "plus_minus_ft": 0.3, + "threshold_ft": 3.0, + "comparison": "at_least", + "subject": null, + "rule": { + "key": "opening_clearance_ft", + "source": "Made-up test value", + "placeholder": true + } + }, + { + "id": "meter_working_space", + "label": "Clear of the meter's working space", + "outcome": "pass", + "reason": "Test: clear of the working space.", + "measured_ft": 1.8, + "plus_minus_ft": 0.3, + "threshold_ft": 0.0, + "comparison": "at_least", + "subject": null, + "rule": { + "key": "meter_working_space_ft", + "source": "Made-up test value", + "placeholder": true + } + }, + { + "id": "route_length", + "label": "Cable run length", + "outcome": "unsure", + "reason": "The cable run is 16 ft 0 in (± 0 ft 6 in): past the 15 ft 0 in confident reach, so the policy sends it to a person, though under the 20 ft 0 in maximum.", + "measured_ft": 16.0, + "plus_minus_ft": 0.5, + "threshold_ft": 20.0, + "comparison": "at_most", + "subject": null, + "rule": { + "key": "route.max_ft", + "source": "Made-up test value. Review past 15 ft 0 in: made-up test value", + "placeholder": true + }, + "unsure_cause": "rule_requires_review", + "review_threshold_ft": 15.0 + } + ], + "nearest_considered": null, + "missing_evidence": [], + "ends": { + "left": { + "kind": "limit", + "s_ft": -8.0, + "point": [ + -8.0, + 0.0 + ] + }, + "right": { + "kind": "limit", + "s_ft": 24.0, + "point": [ + 24.0, + 0.0 + ] + } + }, + "sweep": [ + { + "wall_id": "wall", + "segment": 0, + "start_ft": [ + -8.0, + 12.416667 + ], + "outcome": "fail", + "failing": [ + "opening_clearance" + ], + "unsure": [] + }, + { + "wall_id": "wall", + "segment": 0, + "start_ft": [ + 12.5, + 18.0 + ], + "outcome": "unsure", + "failing": [], + "unsure": [ + "route_length" + ] + }, + { + "wall_id": "wall", + "segment": 0, + "start_ft": [ + 18.083333, + 21.416667 + ], + "outcome": "fail", + "failing": [ + "route_length" + ], + "unsure": [] + } + ], + "stats": { + "candidates": 36, + "pass": 0, + "unsure": 7, + "fail": 29, + "elapsed_ms": 0, + "input_sha256": "0000000000000000000000000000000000000000000000000000000000000000" + } +} \ No newline at end of file From 23d446a822f8dda138ef89c59396f6d757529e3a Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 3 Oct 2026 05:16:17 -0500 Subject: [PATCH 3/7] ios: UI tests for the result card's review band and an answer with no spot ResultCardUITests reads review-band, unsure-view and no-spot-no-nearest from Fixtures/results through the demo. It checks the cable run line's spoken sentence, that a check without a review line keeps its old sentence, and that an answer with neither spot shows no spot, no check lines and its summary under Details. The review band and the no-spot answer run at the default and the largest text size, with screenshots kept. Co-Authored-By: Claude Opus 5.5 (1M context) --- ios/HouseScanUITests/ResultCardUITests.swift | 154 +++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 ios/HouseScanUITests/ResultCardUITests.swift diff --git a/ios/HouseScanUITests/ResultCardUITests.swift b/ios/HouseScanUITests/ResultCardUITests.swift new file mode 100644 index 000000000..b8ac18956 --- /dev/null +++ b/ios/HouseScanUITests/ResultCardUITests.swift @@ -0,0 +1,154 @@ +import XCTest + +/// The result card on server answers in Fixtures/results, read by the demo in debug builds +/// (`-uiDemoResultFile`), at the default text size and the largest accessibility size. Each test +/// keeps a screenshot of the card. +final class ResultCardUITests: XCTestCase { + private static let largestText = ["-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryAccessibilityXXXL"] + + /// What the review-band line says aloud. The visible line is the same sentence with "ft" and + /// "in" (`ScanCopy.cardLine`), so the two can't disagree. + private static let reviewBandSpoken = "Measured 16 feet. The rule is at most 20 feet, and anything over 15 feet " + + "needs an installer's review. The measurement can be off by about 6 inches." + + /// Words that would say a person was contacted or a review arranged. The app contacts nobody, + /// and the server's own reason ("the policy sends it to a person") stays under Details. + private static let handoffWords = ["sent", "sends", "send ", "notified", "scheduled", "will review", "is reviewing", "contact"] + + override func setUp() { + continueAfterFailure = false + } + + // MARK: A run past the review line + + /// review-band.json: a 16 ft cable run, under the 20 ft maximum and past the 15 ft review line, + /// which the server marks unsure. The card says why, and says only that review is needed. + @MainActor + func testACableRunPastTheReviewLineSaysWhy() throws { + try checkReviewBand(textSize: [], name: "result-reviewBand") + } + + @MainActor + func testACableRunPastTheReviewLineSaysWhyAtLargestTextSize() throws { + try checkReviewBand(textSize: Self.largestText, name: "result-reviewBand-AX5") + } + + @MainActor + private func checkReviewBand(textSize: [String], name: String) throws { + let app = launch("review-band", textSize: textSize) + defer { app.terminate() } + XCTAssertEqual(element(app, "result.headline").label, "Needs an installer's review") + let line = element(app, "check.route_length") + XCTAssertTrue(line.waitForExistence(timeout: 10), "the cable run is not on the card") + scrollIntoView(line, in: app) + XCTAssertEqual(line.label, "Cable run length: Not sure yet") + XCTAssertEqual(value(line), Self.reviewBandSpoken) + assertNoHandoff(line.label + " " + value(line)) + attach(app, name: name) + } + + // MARK: A line without a review line + + /// unsure-view.json's window check has no review line: its sentence is the plain measurement + /// against the rule, with nothing about review added. + @MainActor + func testACheckWithoutAReviewLineKeepsItsSentence() throws { + let app = launch("unsure-view", textSize: []) + defer { app.terminate() } + let line = element(app, "check.window_clearance") + XCTAssertTrue(line.waitForExistence(timeout: 10), "the window check is not on the card") + XCTAssertEqual(value(line), "Measured 3 feet 1 inch. The rule is at least 3 feet, and the measurement can be off by about 4 inches.") + } + + // MARK: No spot and no closest spot + + /// no-spot-no-nearest.json: a reject with sweep runs but neither a spot nor a closest spot, + /// so nothing has a battery's size. The card answers without a spot, a closest spot or check + /// lines, the model of the wall shows no spot, and the summary stays under Details. + @MainActor + func testAnAnswerWithNoSpotShowsTheWallAndSummaryOnly() throws { + try checkNoSpot(textSize: [], name: "result-noSpotNoNearest") + } + + @MainActor + func testAnAnswerWithNoSpotShowsTheWallAndSummaryOnlyAtLargestTextSize() throws { + try checkNoSpot(textSize: Self.largestText, name: "result-noSpotNoNearest-AX5") + } + + @MainActor + private func checkNoSpot(textSize: [String], name: String) throws { + let app = launch("no-spot-no-nearest", textSize: textSize) + defer { app.terminate() } + XCTAssertEqual(element(app, "result.headline").label, "Not on this wall") + XCTAssertFalse(element(app, "result.placement").exists) + XCTAssertFalse(element(app, "result.nearest").exists) + let checkLines = app.descendants(matching: .any).matching(NSPredicate(format: "identifier BEGINSWITH 'check.'")) + XCTAssertEqual(checkLines.count, 0, "an answer without checks put check lines on the card") + let model = app.descendants(matching: .any).matching(NSPredicate(format: "label == '3D view of your wall'")).firstMatch + XCTAssertTrue(model.waitForExistence(timeout: 10), "the model of the wall is missing") + XCTAssertEqual(value(model), "No battery spot shown") + attach(app, name: name) + + let details = element(app, "result.details") + scrollIntoView(details, in: app) + details.tap() + let summary = element(app, "result.summary") + XCTAssertTrue(summary.waitForExistence(timeout: 10), "the summary is missing under Details") + XCTAssertTrue(summary.label.contains("every spot fails route length, wall backing"), summary.label) + scrollIntoView(summary, in: app) + attach(app, name: "\(name)-details") + } + + // MARK: Helpers + + @MainActor + private func launch(_ resultFile: String, textSize: [String]) -> XCUIApplication { + let app = XCUIApplication() + app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoFreeze", "-uiDemoPhase", "result", + "-uiDemoResultFile", Self.resultFile(resultFile)] + textSize + app.launch() + XCTAssertTrue(element(app, "screen.result").waitForExistence(timeout: 15)) + return app + } + + /// A server answer in Fixtures/results. + private static func resultFile(_ name: String, file: String = #filePath) -> String { + URL(fileURLWithPath: file).deletingLastPathComponent().appending(path: "Fixtures/results/\(name).json").path + } + + /// Scrolls until the whole element is inside the window, so the screenshot shows it. + @MainActor + private func scrollIntoView(_ target: XCUIElement, in app: XCUIApplication) { + let window = app.windows.firstMatch.frame + let scroll = app.scrollViews.firstMatch + for _ in 0..<10 where !window.contains(target.frame) { + scroll.swipeUp(velocity: .slow) + } + XCTAssertTrue(window.contains(target.frame), "\(target.identifier) can't be scrolled fully into view") + } + + private func assertNoHandoff(_ text: String, file: StaticString = #filePath, line: UInt = #line) { + let lowered = text.lowercased() + for word in Self.handoffWords { + XCTAssertFalse(lowered.contains(word), "the card says \"\(word)\": \(text)", file: file, line: line) + } + } + + @MainActor + private func element(_ app: XCUIApplication, _ identifier: String) -> XCUIElement { + app.descendants(matching: .any)[identifier].firstMatch + } + + @MainActor + private func value(_ element: XCUIElement) -> String { + element.value as? String ?? "" + } + + @MainActor + private func attach(_ app: XCUIApplication, name: String) { + let shot = XCTAttachment(screenshot: app.screenshot()) + shot.name = name + shot.lifetime = .keepAlways + add(shot) + } +} From 23e05a68571accc3d840b6230d1a5b6258c32d00 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 3 Oct 2026 05:23:22 -0500 Subject: [PATCH 4/7] ios: decide whether the review line applies in the server's feet ScanCopy decided it from CheckRow's Float meters, and narrowing can move a value across the line: 14.5 ft plus 6 in reaches 15 ft exactly, but the same sum in Float meters fell just under it, so the card would have left the review line out. PlacementCheck.reviewBandApplies now decides on the decoded feet, and the mapping carries the answer on CheckRow. Co-Authored-By: Claude Opus 5.5 (1M context) --- ios/HouseScan/Contract/ScanContract.swift | 3 +++ ios/HouseScan/Runtime/ScanEngine+Export.swift | 2 +- ios/HouseScan/UI/Copy/ScanCopy.swift | 19 +++------------ .../HouseScanKit/Scene/ResultReading.swift | 9 ++++++++ .../HouseScanKitTests/ReviewBandTests.swift | 23 ++++++++++++++++--- 5 files changed, 36 insertions(+), 20 deletions(-) diff --git a/ios/HouseScan/Contract/ScanContract.swift b/ios/HouseScan/Contract/ScanContract.swift index cfa02a84b..9d6528f4b 100644 --- a/ios/HouseScan/Contract/ScanContract.swift +++ b/ios/HouseScan/Contract/ScanContract.swift @@ -669,6 +669,9 @@ struct CheckRow: Identifiable, Equatable, Sendable { /// on the cable run): a measurement that doesn't clear it needs review even within the limit. /// Nil when the answer has none. var reviewThreshold: Float? = nil + /// True when `reviewThreshold` explains this unsure check, so the card names it + /// (`PlacementCheck.reviewBandApplies`, decided in the server's feet before conversion). + var reviewBandApplies = false /// The `MissingEvidence.id` of the first view that would settle this check, when the server /// named one. var settledBy: String? = nil diff --git a/ios/HouseScan/Runtime/ScanEngine+Export.swift b/ios/HouseScan/Runtime/ScanEngine+Export.swift index 6f8496f23..1c1832946 100644 --- a/ios/HouseScan/Runtime/ScanEngine+Export.swift +++ b/ios/HouseScan/Runtime/ScanEngine+Export.swift @@ -252,7 +252,7 @@ extension ScanEngine { needsPerson: check.needsPerson, measured: check.measuredFt.map(meters), threshold: check.thresholdFt.map(meters), plusMinus: check.plusMinusFt.map(meters), comparison: check.comparison.map(Self.comparison), - reviewThreshold: check.reviewThresholdFt.map(meters), + reviewThreshold: check.reviewThresholdFt.map(meters), reviewBandApplies: check.reviewBandApplies, settledBy: result.evidenceIndex(settling: check.id).map(Self.missingID) ) } diff --git a/ios/HouseScan/UI/Copy/ScanCopy.swift b/ios/HouseScan/UI/Copy/ScanCopy.swift index b49f35d2d..744025384 100644 --- a/ios/HouseScan/UI/Copy/ScanCopy.swift +++ b/ios/HouseScan/UI/Copy/ScanCopy.swift @@ -732,23 +732,10 @@ enum ScanCopy { } /// "anything over 15 ft" under a maximum, "anything under 4 ft" over a minimum: the side of the - /// check's review line that needs review. Nil unless `ResultReading.reviewBandApplies` says - /// the line explains this check's outcome. + /// check's review line that needs review. Nil unless the line explains this check's outcome + /// (`CheckRow.reviewBandApplies`). static func reviewBand(_ row: CheckRow, length: (Float) -> String = Distance.feetAndInches) -> String? { - guard let review = row.reviewThreshold, let comparison = row.comparison else { return nil } - let outcome: PlacementOutcome = switch row.outcome { - case .pass: .pass - case .fail: .fail - case .unsure: .unsure - } - let direction: PlacementComparison = switch comparison { - case .atLeast: .atLeast - case .atMost: .atMost - } - guard ResultReading.reviewBandApplies( - outcome: outcome, measured: row.measured.map(Double.init), plusMinus: row.plusMinus.map(Double.init), - threshold: row.threshold.map(Double.init), reviewThreshold: Double(review), comparison: direction) - else { return nil } + guard row.reviewBandApplies, let review = row.reviewThreshold, let comparison = row.comparison else { return nil } return switch comparison { case .atMost: "anything over \(length(review))" case .atLeast: "anything under \(length(review))" diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Scene/ResultReading.swift b/ios/HouseScanKit/Sources/HouseScanKit/Scene/ResultReading.swift index d922a5738..8330d35cd 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Scene/ResultReading.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Scene/ResultReading.swift @@ -137,6 +137,15 @@ extension PlacementCheck { guard let unsureCause else { return true } return unsureCause != .unobserved } + + /// `ResultReading.reviewBandApplies` on this check's values as the server sent them, in feet. + /// Read it here, before any conversion: a run of 14.5 ft ± 6 in reaches the 15 ft line + /// exactly, and the same values narrowed to Float meters fell just short of it. + public var reviewBandApplies: Bool { + ResultReading.reviewBandApplies( + outcome: outcome, measured: measuredFt, plusMinus: plusMinusFt, threshold: thresholdFt, + reviewThreshold: reviewThresholdFt, comparison: comparison) + } } extension PlacementResult { diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/ReviewBandTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/ReviewBandTests.swift index ad09543a9..09d74ecb8 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/ReviewBandTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/ReviewBandTests.swift @@ -106,14 +106,31 @@ import Testing let result = try PlacementResult.decode(Data(contentsOf: Self.uiResultFile(file))) let route = try #require(result.checks.first { $0.id == "route_length" }) #expect(route.reviewThresholdFt == 15, "\(file)") - return ResultReading.reviewBandApplies( - outcome: route.outcome, measured: route.measuredFt, plusMinus: route.plusMinusFt, - threshold: route.thresholdFt, reviewThreshold: route.reviewThresholdFt, comparison: route.comparison) + return route.reviewBandApplies } #expect(try applies("review-band")) #expect(try !applies("no-clean-spot")) } + /// A decoded check decides in the server's feet. 14.5 ft ± 6 in reaches the 15 ft line + /// exactly; narrowed to Float meters first, the sum fell just under the line and the card + /// left the review line out. + @Test func aCheckDecidesInTheServersFeet() throws { + var object = try #require( + try JSONSerialization.jsonObject(with: Data(contentsOf: Self.uiResultFile("review-band"))) as? [String: Any]) + var checks = try #require(object["checks"] as? [[String: Any]]) + let index = try #require(checks.firstIndex { $0["id"] as? String == "route_length" }) + checks[index]["measured_ft"] = 14.5 + object["checks"] = checks + let data = try JSONSerialization.data(withJSONObject: object) + #expect(try SceneSchemas.result().validate(data) == []) + let route = try #require(try PlacementResult.decode(data).checks.first { $0.id == "route_length" }) + #expect(route.reviewBandApplies) + // The Float meters the app shows would not have reached it. + let meters = { (feet: Double) in Double(Float(feet * 0.3048)) } + #expect(meters(14.5) + meters(0.5) < meters(15)) + } + private static func uiResultFile(_ name: String, file: String = #filePath) -> URL { URL(fileURLWithPath: file) .deletingLastPathComponent() // HouseScanKitTests From d6729733caa1e7348451fa767625119419c38215 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 3 Oct 2026 05:28:12 -0500 Subject: [PATCH 5/7] ios: an unsure line promises a photo only when it offers the camera for one On the spot-unknown run, "Clear space in front" read "One more photo would settle this" after its request was withdrawn, with no way to take the photo. The note was keyed on needsPerson alone. ResultCardActions.lineOffersView now decides whether a line offers its settling view, and ResultPresentation.offeredView applies it. The line's "Show me", its words on the card and its note under Details all read that one fact. A line with no view to take says "Needs an installer to check". Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Contract/ResultPresentation+Reading.swift | 18 +- ios/HouseScan/UI/Copy/ScanCopy.swift | 15 +- ios/HouseScan/UI/Screens/ResultScreen.swift | 25 +- .../Scene/ResultCardActions.swift | 13 + .../ResultCardActionsTests.swift | 38 +++ .../ResultReadingTests.swift | 2 +- .../Fixtures/results/view-not-offered.json | 269 ++++++++++++++++++ ios/HouseScanUITests/ResultCardUITests.swift | 41 ++- 8 files changed, 402 insertions(+), 19 deletions(-) create mode 100644 ios/HouseScanUITests/Fixtures/results/view-not-offered.json diff --git a/ios/HouseScan/Contract/ResultPresentation+Reading.swift b/ios/HouseScan/Contract/ResultPresentation+Reading.swift index a763b90fb..19750bb65 100644 --- a/ios/HouseScan/Contract/ResultPresentation+Reading.swift +++ b/ios/HouseScan/Contract/ResultPresentation+Reading.swift @@ -47,11 +47,23 @@ extension ResultPresentation { ResultReading.cardLines(readingChecks).map { checks[$0] } } + /// The view `row`'s line on the card offers to take, or nil: the view the server named to + /// settle it (`settledBy`), when `ResultCardActions.lineOffersView` says the line offers it. + /// The line's "Show me" and its words (`ScanCopy.cardLine`, `ScanCopy.unsureNote`) both read + /// this, so the words never promise a photo the line doesn't let the homeowner take. + func offeredView(for row: CheckRow, sourceAvailable: Bool) -> MissingEvidence? { + let view = row.settledBy.flatMap { id in missing.first { $0.id == id } } + guard ResultCardActions.lineOffersView( + outcome: row.outcome.placementOutcome, needsPerson: row.needsPerson, + settlingViewCapturable: view?.capturable, sourceAvailable: sourceAvailable) + else { return nil } + return view + } + /// The view that would settle `row` and that the camera can take now: only for an unsure - /// check a person needn't judge. + /// check a person needn't judge. `offeredView` with the camera up. func viewToTake(for row: CheckRow) -> MissingEvidence? { - guard row.outcome == .unsure, !row.needsPerson, let id = row.settledBy else { return nil } - return missing.first { $0.id == id && $0.capturable } + offeredView(for: row, sourceAvailable: true) } /// The first view in `missing` that settles an unsure check and can be taken now. diff --git a/ios/HouseScan/UI/Copy/ScanCopy.swift b/ios/HouseScan/UI/Copy/ScanCopy.swift index 744025384..540fcc9bb 100644 --- a/ios/HouseScan/UI/Copy/ScanCopy.swift +++ b/ios/HouseScan/UI/Copy/ScanCopy.swift @@ -635,11 +635,13 @@ enum ScanCopy { /// The sentence under a failed or unsure line on the result card: the measurement against the /// rule, or without a measurement, the server's reason (fail) or who settles it (unsure). - static func cardLine(_ row: CheckRow, spoken: Bool = false) -> String? { + /// `photoOffered` is whether the line offers the camera for the view that settles it + /// (`ResultPresentation.offeredView`). + static func cardLine(_ row: CheckRow, photoOffered: Bool, spoken: Bool = false) -> String? { switch row.outcome { case .pass: nil case .fail: measurement(row, spoken: spoken) ?? row.reason - case .unsure: measurement(row, spoken: spoken) ?? unsureNote(row) + case .unsure: measurement(row, spoken: spoken) ?? unsureNote(photoOffered: photoOffered) } } @@ -696,8 +698,13 @@ enum ScanCopy { /// review was sent: the app only shows the server's answer and contacts nobody. static let needsInstaller = "Needs an installer to check" - static func unsureNote(_ row: CheckRow) -> String { - row.needsPerson ? Self.needsInstaller : "One more photo would settle this" + static let onePhoto = "One more photo would settle this" + + /// Who settles an unsure check. One more photo only when the app offers the camera for it + /// (`ResultPresentation.offeredView`): keyed on the check alone, a view the homeowner + /// couldn't get to, or one the app can't plan, still promised a photo nothing would take. + static func unsureNote(photoOffered: Bool) -> String { + photoOffered ? Self.onePhoto : Self.needsInstaller } /// "Measured 3 ft 2 in. The rule is at least 3 ft, and the measurement can be off by about 4 in." diff --git a/ios/HouseScan/UI/Screens/ResultScreen.swift b/ios/HouseScan/UI/Screens/ResultScreen.swift index 57e1c27a1..4024959ba 100644 --- a/ios/HouseScan/UI/Screens/ResultScreen.swift +++ b/ios/HouseScan/UI/Screens/ResultScreen.swift @@ -133,6 +133,11 @@ struct ResultScreen: View { .accessibilityIdentifier(id) } + /// The checks whose card line offers the camera, so Details says the same as the card. + private func photoOffered(_ result: ResultPresentation) -> Set { + Set(result.checks.filter { result.offeredView(for: $0, sourceAvailable: state.spatialResultAvailable) != nil }.map(\.id)) + } + private func details(_ result: ResultPresentation) -> some View { DisclosureGroup(isExpanded: $detailsExpanded) { VStack(alignment: .leading, spacing: 22) { @@ -144,7 +149,7 @@ struct ResultScreen: View { .accessibilityIdentifier("result.summary") } if !result.checks.isEmpty { - ChecksList(checks: result.checks) + ChecksList(checks: result.checks, photoOffered: photoOffered(result)) } if !result.missing.isEmpty { MissingList(missing: result.missing, checks: result.checks, sourceAvailable: state.spatialResultAvailable, actions: actions) @@ -310,13 +315,12 @@ private struct AnswerCard: View { private func line(_ row: CheckRow) -> some View { // After a reject, the line above already gave the nearest spot's failing measurement. let repeatsNearest = row.id == result.nearestFailingCheck && result.nearestSpot != nil - let view = result.viewToTake(for: row).flatMap { view in - ResultCardActions.offersView(capturable: view.capturable, sourceAvailable: sourceAvailable) ? view : nil - } + // One fact for the line's "Show me" and its words, so they can't disagree. + let view = result.offeredView(for: row, sourceAvailable: sourceAvailable) return CardCheckLine( row: row, - sentence: repeatsNearest ? nil : ScanCopy.cardLine(row), - spokenSentence: repeatsNearest ? nil : ScanCopy.cardLine(row, spoken: true), + sentence: repeatsNearest ? nil : ScanCopy.cardLine(row, photoOffered: view != nil), + spokenSentence: repeatsNearest ? nil : ScanCopy.cardLine(row, photoOffered: view != nil, spoken: true), showMe: view.map { view in { actions.captureMissing(view.id) } } ) } @@ -459,6 +463,8 @@ private enum CheckSymbol { private struct ChecksList: View { var checks: [CheckRow] + /// The ids of the checks whose card line offers the camera (`ResultPresentation.offeredView`). + var photoOffered: Set var body: some View { VStack(alignment: .leading, spacing: 12) { @@ -467,7 +473,7 @@ private struct ChecksList: View { .accessibilityAddTraits(.isHeader) VStack(spacing: 0) { ForEach(Array(checks.enumerated()), id: \.element.id) { index, row in - CheckRowView(row: row) + CheckRowView(row: row, photoOffered: photoOffered.contains(row.id)) if index < checks.count - 1 { Divider().padding(.leading, 52) } @@ -480,6 +486,7 @@ private struct ChecksList: View { private struct CheckRowView: View { var row: CheckRow + var photoOffered: Bool var body: some View { HStack(alignment: .top, spacing: 12) { @@ -501,7 +508,7 @@ private struct CheckRowView: View { .fixedSize(horizontal: false, vertical: true) } if row.outcome == .unsure { - Label(ScanCopy.unsureNote(row), systemImage: row.needsPerson ? "person.fill" : "camera.fill") + Label(ScanCopy.unsureNote(photoOffered: photoOffered), systemImage: photoOffered ? "camera.fill" : "person.fill") .font(.footnote.weight(.semibold)) .foregroundStyle(Palette.reviewInk) } @@ -511,7 +518,7 @@ private struct CheckRowView: View { .padding(14) .accessibilityElement(children: .combine) .accessibilityLabel("\(row.title): \(ScanCopy.outcomeWord(row.outcome))") - .accessibilityValue([row.reason, ScanCopy.measurement(row, spoken: true), row.outcome == .unsure ? ScanCopy.unsureNote(row) : nil] + .accessibilityValue([row.reason, ScanCopy.measurement(row, spoken: true), row.outcome == .unsure ? ScanCopy.unsureNote(photoOffered: photoOffered) : nil] .compactMap(\.self).joined(separator: ". ")) // The card shows the deciding checks as `check.`; this is the full list. .accessibilityIdentifier("detail.check.\(row.id)") diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Scene/ResultCardActions.swift b/ios/HouseScanKit/Sources/HouseScanKit/Scene/ResultCardActions.swift index cbc8cadc4..1a77f1aad 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Scene/ResultCardActions.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Scene/ResultCardActions.swift @@ -41,4 +41,17 @@ public enum ResultCardActions { public static func offersView(capturable: Bool, sourceAvailable: Bool) -> Bool { capturable && sourceAvailable } + + /// True when a check's line offers the camera for the view that settles it: an UNSURE check + /// no person has to judge, whose settling view the server named (`settlingViewCapturable` is + /// nil when it named none) and the app can take now (`offersView`). The line's "Show me" and + /// its words both read this, so a line says one more photo would settle it only when it lets + /// the homeowner take that photo. A view the homeowner couldn't get to, or a request the app + /// can't plan, is not capturable. + public static func lineOffersView( + outcome: PlacementOutcome, needsPerson: Bool, settlingViewCapturable: Bool?, sourceAvailable: Bool + ) -> Bool { + outcome == .unsure && !needsPerson + && offersView(capturable: settlingViewCapturable == true, sourceAvailable: sourceAvailable) + } } diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/ResultCardActionsTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/ResultCardActionsTests.swift index 98d652a5d..9a1cd9a53 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/ResultCardActionsTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/ResultCardActionsTests.swift @@ -43,4 +43,42 @@ import Testing #expect(!ResultCardActions.offersView(capturable: false, sourceAvailable: true)) #expect(!ResultCardActions.offersView(capturable: false, sourceAvailable: false)) } + + /// A check line offers its view, and may say one more photo would settle it, only for an + /// unsure check no person has to judge, whose settling view was named and can be taken now + /// with the camera up. On the spot-unknown run a withdrawn "Clear space in front" request + /// (not capturable) still read "One more photo would settle this". Every combination. + @Test(arguments: [PlacementOutcome.pass, .fail, .unsure], [false, true]) + func aLineOffersItsViewOnlyWhenTheCameraCanTakeIt(outcome: PlacementOutcome, needsPerson: Bool) { + let capturable: [Bool?] = [nil, false, true] + for settling in capturable { + for sourceAvailable in [false, true] { + let offered = ResultCardActions.lineOffersView( + outcome: outcome, needsPerson: needsPerson, settlingViewCapturable: settling, + sourceAvailable: sourceAvailable) + let expected = outcome == .unsure && !needsPerson && settling == true && sourceAvailable + #expect(offered == expected, "settling view \(String(describing: settling)), camera up \(sourceAvailable)") + } + } + } + + /// The cases by name: the one line that offers a photo, and each reason another doesn't. + @Test func theLinesThatOfferAPhoto() { + let offers = { (outcome: PlacementOutcome, person: Bool, capturable: Bool?, camera: Bool) in + ResultCardActions.lineOffersView(outcome: outcome, needsPerson: person, settlingViewCapturable: capturable, + sourceAvailable: camera) + } + #expect(offers(.unsure, false, true, true)) + // A view the homeowner couldn't get to, or one the app can't plan. + #expect(!offers(.unsure, false, false, true)) + // No view named for it. + #expect(!offers(.unsure, false, nil, true)) + // The camera failed after the scan was sent. + #expect(!offers(.unsure, false, true, false)) + // A person judges it, whatever view is named. + #expect(!offers(.unsure, true, true, true)) + // Settled checks offer nothing. + #expect(!offers(.pass, false, true, true)) + #expect(!offers(.fail, false, true, true)) + } } diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/ResultReadingTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/ResultReadingTests.swift index 052d0cbb7..3f525a0ef 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/ResultReadingTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/ResultReadingTests.swift @@ -210,7 +210,7 @@ import simd @Test(arguments: [ ("pass", ResultReading.Answer.fits), ("reject-nearest", .notHere), ("unsure-view", .oneMoreLook), ("no-clean-spot", .installer), - ("no-spot-no-nearest", .notHere), ("review-band", .installer), + ("no-spot-no-nearest", .notHere), ("review-band", .installer), ("view-not-offered", .oneMoreLook), ]) func uiResultFilesReadAsNamed(file: String, answer: ResultReading.Answer) throws { let url = URL(fileURLWithPath: #filePath) diff --git a/ios/HouseScanUITests/Fixtures/results/view-not-offered.json b/ios/HouseScanUITests/Fixtures/results/view-not-offered.json new file mode 100644 index 000000000..abe890b33 --- /dev/null +++ b/ios/HouseScanUITests/Fixtures/results/view-not-offered.json @@ -0,0 +1,269 @@ +{ + "schema_version": "1.0", + "decision": "manual_review", + "summary": "Test answer: two checks at the spot depend on areas the scan did not see; one view can be taken now and one can't.", + "reasons": [ + { + "code": "unsure_checks", + "message": "Test: two checks at this spot are unsure.", + "checks": [ + "ground_surface", + "front_clearance" + ] + }, + { + "code": "unobserved_area", + "message": "Test: part of the area the checks need was not seen.", + "checks": [ + "ground_surface", + "front_clearance" + ] + } + ], + "policy": { + "id": null, + "version": null, + "auto_approve": false, + "sources": [ + "public" + ], + "rules_sha256": "0000000000000000000000000000000000000000000000000000000000000000" + }, + "spot": { + "outcome": "unsure", + "wall_id": "wall", + "segment": 0, + "span_ft": [ + 3.0, + 5.5 + ], + "width_ft": 2.5, + "depth_ft": 1.0, + "height_ft": 4.5, + "footprint": [ + [ + 3.0, + 0.0 + ], + [ + 5.5, + 0.0 + ], + [ + 5.5, + 1.0 + ], + [ + 3.0, + 1.0 + ] + ], + "center": [ + 4.25, + 0.5 + ], + "along": [ + 1.0, + 0.0 + ], + "outward": [ + 0.0, + 1.0 + ], + "meter_offset_ft": [ + 4.25, + 0.5 + ], + "route_length_ft": 3.0 + }, + "route": { + "outcome": "pass", + "length_ft": 3.0, + "plus_minus_ft": 0.3, + "height_ft": 1.5, + "polyline": [ + [ + 0.0, + 0.0 + ], + [ + 3.0, + 0.0 + ] + ], + "detours": [], + "crossings": [] + }, + "checks": [ + { + "id": "ground_surface", + "label": "Ground under the spot", + "outcome": "unsure", + "unsure_cause": "unobserved", + "reason": "Test: the ground under the spot was seen from one place only.", + "measured_ft": null, + "plus_minus_ft": null, + "threshold_ft": null, + "comparison": null, + "subject": null, + "rule": { + "key": "ground_surface_ft", + "source": "Made-up test value", + "placeholder": true + } + }, + { + "id": "front_clearance", + "label": "Clear space in front", + "outcome": "unsure", + "unsure_cause": "unobserved", + "reason": "Test: the space in front of the spot was not seen.", + "measured_ft": null, + "plus_minus_ft": null, + "threshold_ft": null, + "comparison": null, + "subject": null, + "rule": { + "key": "front_clearance_ft", + "source": "Made-up test value", + "placeholder": true + } + }, + { + "id": "gas_clearance", + "label": "Distance from the gas meter", + "outcome": "pass", + "reason": "Sample: the gas meter is well clear of the spot.", + "measured_ft": 6.2, + "plus_minus_ft": 0.3, + "threshold_ft": 3.0, + "comparison": "at_least", + "subject": "objects[0] gas_meter", + "rule": { + "key": "sample_gas_clearance_ft", + "source": "Sample value, not a rule citation", + "placeholder": true + } + }, + { + "id": "route_length", + "label": "Cable run from the meter", + "outcome": "pass", + "reason": "Sample: the cable run is short.", + "measured_ft": 3.0, + "plus_minus_ft": 0.3, + "threshold_ft": 20.0, + "comparison": "at_most", + "subject": null, + "rule": { + "key": "sample_route_length_ft", + "source": "Sample value, not a rule citation", + "placeholder": true + } + } + ], + "nearest_considered": null, + "missing_evidence": [ + { + "kind": "band", + "band": "ground", + "span_ft": [ + 3.0, + 5.5 + ], + "checks": [ + "ground_surface" + ], + "message": "Test: film the ground under the spot." + }, + { + "kind": "band", + "band": "facing", + "span_ft": [ + 3.0, + 5.5 + ], + "checks": [ + "front_clearance" + ], + "message": "Test: show what stands in front of the spot." + } + ], + "ends": { + "left": { + "kind": "unexplored", + "s_ft": -8.0, + "point": [ + -8.0, + 0.0 + ] + }, + "right": { + "kind": "limit", + "s_ft": 12.0, + "point": [ + 12.0, + 0.0 + ] + } + }, + "sweep": [ + { + "wall_id": "wall", + "start_ft": [ + -8.0, + -1.5 + ], + "outcome": "fail", + "failing": [ + "gas_clearance" + ], + "unsure": [] + }, + { + "wall_id": "wall", + "start_ft": [ + -1.0, + 2.5 + ], + "outcome": "fail", + "failing": [ + "meter_clearance" + ], + "unsure": [] + }, + { + "wall_id": "wall", + "start_ft": [ + 3.0, + 6.0 + ], + "outcome": "unsure", + "failing": [], + "unsure": [ + "window_clearance", + "front_clearance" + ] + }, + { + "wall_id": "wall", + "start_ft": [ + 6.5, + 9.5 + ], + "outcome": "unsure", + "failing": [], + "unsure": [ + "front_clearance" + ] + } + ], + "stats": { + "candidates": 36, + "pass": 0, + "unsure": 14, + "fail": 22, + "elapsed_ms": 0, + "input_sha256": "0000000000000000000000000000000000000000000000000000000000000000" + } +} \ No newline at end of file diff --git a/ios/HouseScanUITests/ResultCardUITests.swift b/ios/HouseScanUITests/ResultCardUITests.swift index b8ac18956..018d6f7fe 100644 --- a/ios/HouseScanUITests/ResultCardUITests.swift +++ b/ios/HouseScanUITests/ResultCardUITests.swift @@ -1,8 +1,8 @@ import XCTest /// The result card on server answers in Fixtures/results, read by the demo in debug builds -/// (`-uiDemoResultFile`), at the default text size and the largest accessibility size. Each test -/// keeps a screenshot of the card. +/// (`-uiDemoResultFile`), at the default text size and the largest accessibility size. The tests +/// run at both sizes keep a screenshot of the card. final class ResultCardUITests: XCTestCase { private static let largestText = ["-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryAccessibilityXXXL"] @@ -60,6 +60,43 @@ final class ResultCardUITests: XCTestCase { XCTAssertEqual(value(line), "Measured 3 feet 1 inch. The rule is at least 3 feet, and the measurement can be off by about 4 inches.") } + // MARK: A photo only when the line offers one + + /// view-not-offered.json: two unsure checks that views would settle. The ground's view can be + /// taken now, so its line is a "Show me" button that says a photo would settle it. The space + /// in front's view can't be planned, as after a withdrawn request, so its line promises no + /// photo and offers no button. The spot-unknown run showed "One more photo would settle this" + /// on such a line. + @MainActor + func testOnlyALineThatOffersThePhotoPromisesIt() throws { + try checkPhotoOffer(textSize: [], name: "result-viewNotOffered") + } + + @MainActor + func testOnlyALineThatOffersThePhotoPromisesItAtLargestTextSize() throws { + try checkPhotoOffer(textSize: Self.largestText, name: "result-viewNotOffered-AX5") + } + + @MainActor + private func checkPhotoOffer(textSize: [String], name: String) throws { + let app = launch("view-not-offered", textSize: textSize) + defer { app.terminate() } + let offered = element(app, "check.ground_surface") + XCTAssertTrue(offered.waitForExistence(timeout: 10), "the ground check is not on the card") + XCTAssertEqual(offered.elementType, .button, "the ground's line should open the camera") + XCTAssertEqual(offered.label, "Ground under the spot: Not sure yet") + XCTAssertEqual(value(offered), "One more photo would settle this") + + let notOffered = element(app, "check.front_clearance") + XCTAssertTrue(notOffered.exists, "the space in front is not on the card") + XCTAssertNotEqual(notOffered.elementType, .button, "a line with no view to take must not be a button") + XCTAssertEqual(notOffered.label, "Clear space in front: Not sure yet") + XCTAssertEqual(value(notOffered), "Needs an installer to check") + assertNoHandoff(notOffered.label + " " + value(notOffered)) + scrollIntoView(notOffered, in: app) + attach(app, name: name) + } + // MARK: No spot and no closest spot /// no-spot-no-nearest.json: a reject with sweep runs but neither a spot nor a closest spot, From f0c271cfb6068c07e2e29f208b0bb0844b4380b2 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 3 Oct 2026 05:47:12 -0500 Subject: [PATCH 6/7] ios: regenerate the project for the new result fixtures; scroll the card in measured steps Review of d6729733: XcodeGen lists every result fixture as a membership exception, so the three new fixtures changed the generated project and CI's drift check failed (run 37117085378). Regenerated with XcodeGen 2.46.0, checksum-verified against ios.yml. `scrollIntoView` swiped up a fixed amount until the line fit. At the largest text size a tall line could jump from below the window to above it, and further swipes up could never bring it back. Each drag now moves the content by what is missing, at most a third of the window, toward the line. Co-Authored-By: Claude Opus 5.5 (1M context) --- ios/HouseScan.xcodeproj/project.pbxproj | 3 +++ ios/HouseScanUITests/ResultCardUITests.swift | 16 ++++++++++++---- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/ios/HouseScan.xcodeproj/project.pbxproj b/ios/HouseScan.xcodeproj/project.pbxproj index f623d8f14..21f28f85e 100644 --- a/ios/HouseScan.xcodeproj/project.pbxproj +++ b/ios/HouseScan.xcodeproj/project.pbxproj @@ -34,9 +34,12 @@ membershipExceptions = ( Fixtures/results, "Fixtures/results/no-clean-spot.json", + "Fixtures/results/no-spot-no-nearest.json", Fixtures/results/pass.json, "Fixtures/results/reject-nearest.json", + "Fixtures/results/review-band.json", "Fixtures/results/unsure-view.json", + "Fixtures/results/view-not-offered.json", "Fixtures/synthetic-wall", "Fixtures/synthetic-wall-lidar", "Fixtures/synthetic-wall-lidar/keyframes", diff --git a/ios/HouseScanUITests/ResultCardUITests.swift b/ios/HouseScanUITests/ResultCardUITests.swift index 018d6f7fe..4849d8a2f 100644 --- a/ios/HouseScanUITests/ResultCardUITests.swift +++ b/ios/HouseScanUITests/ResultCardUITests.swift @@ -153,13 +153,21 @@ final class ResultCardUITests: XCTestCase { URL(fileURLWithPath: file).deletingLastPathComponent().appending(path: "Fixtures/results/\(name).json").path } - /// Scrolls until the whole element is inside the window, so the screenshot shows it. + /// Scrolls until the whole element is inside the window, so the screenshot shows it. Each + /// drag moves the content by what is missing, at most a third of the window, toward the + /// target: a fixed swipe could carry a tall line from below the window to above it, and + /// swiping on in one direction would never bring it back. @MainActor private func scrollIntoView(_ target: XCUIElement, in app: XCUIApplication) { let window = app.windows.firstMatch.frame - let scroll = app.scrollViews.firstMatch - for _ in 0..<10 where !window.contains(target.frame) { - scroll.swipeUp(velocity: .slow) + let margin: CGFloat = 24 + let start = app.scrollViews.firstMatch.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.6)) + for _ in 0..<12 where !window.contains(target.frame) { + let frame = target.frame + let shift: CGFloat = frame.maxY > window.maxY + ? -min(frame.maxY - window.maxY + margin, window.height / 3) + : min(window.minY - frame.minY + margin, window.height / 3) + start.press(forDuration: 0.05, thenDragTo: start.withOffset(CGVector(dx: 0, dy: shift))) } XCTAssertTrue(window.contains(target.frame), "\(target.identifier) can't be scrolled fully into view") } From 55fb4b4f9afed5b3639fa42919699a7e549a29c3 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 3 Oct 2026 08:29:12 -0500 Subject: [PATCH 7/7] ios: the review line's wording includes the line itself Review of #209 (Codex): a confident pass needs measured + error < review_threshold_ft for an at-most check (result.schema.json, solver.py reach_outcome), so a run landing exactly on 15 ft already needs review, and "anything over 15 ft" left that case out. The card now says "anything 15 ft or more" under a maximum and "anything 4 ft or less" over a minimum. The decision (`reviewBandApplies`) was already inclusive; only the words change. Co-Authored-By: Claude Opus 5.5 (1M context) --- ios/HouseScan/UI/Copy/ScanCopy.swift | 10 ++++++---- ios/HouseScanUITests/ResultCardUITests.swift | 2 +- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/ios/HouseScan/UI/Copy/ScanCopy.swift b/ios/HouseScan/UI/Copy/ScanCopy.swift index 540fcc9bb..0374f36ed 100644 --- a/ios/HouseScan/UI/Copy/ScanCopy.swift +++ b/ios/HouseScan/UI/Copy/ScanCopy.swift @@ -713,7 +713,7 @@ enum ScanCopy { /// `spoken` spells out feet and inches for VoiceOver, which reads "ft" and "in" as letters. /// /// When the server's review line explains an unsure check (`reviewBand`), the rule names it too: - /// "Measured 16 ft. The rule is at most 20 ft, and anything over 15 ft needs an installer's + /// "Measured 16 ft. The rule is at most 20 ft, and anything 15 ft or more needs an installer's /// review. The measurement can be off by about 6 in." Without it, a cable run inside the /// maximum read as passing under "Not sure yet". It says what the rule needs, never that a /// review was sent: the app contacts nobody. @@ -738,14 +738,16 @@ enum ScanCopy { return parts.joined(separator: " ") } - /// "anything over 15 ft" under a maximum, "anything under 4 ft" over a minimum: the side of the + /// "anything 15 ft or more" under a maximum, "anything 4 ft or less" over a minimum: the side of the /// check's review line that needs review. Nil unless the line explains this check's outcome /// (`CheckRow.reviewBandApplies`). static func reviewBand(_ row: CheckRow, length: (Float) -> String = Distance.feetAndInches) -> String? { guard row.reviewBandApplies, let review = row.reviewThreshold, let comparison = row.comparison else { return nil } return switch comparison { - case .atMost: "anything over \(length(review))" - case .atLeast: "anything under \(length(review))" + // Inclusive: a confident pass needs measured + error < review_threshold_ft (at_most), so a + // value landing exactly on the line already needs review (result.schema.json). + case .atMost: "anything \(length(review)) or more" + case .atLeast: "anything \(length(review)) or less" } } diff --git a/ios/HouseScanUITests/ResultCardUITests.swift b/ios/HouseScanUITests/ResultCardUITests.swift index 4849d8a2f..ebfc196b6 100644 --- a/ios/HouseScanUITests/ResultCardUITests.swift +++ b/ios/HouseScanUITests/ResultCardUITests.swift @@ -8,7 +8,7 @@ final class ResultCardUITests: XCTestCase { /// What the review-band line says aloud. The visible line is the same sentence with "ft" and /// "in" (`ScanCopy.cardLine`), so the two can't disagree. - private static let reviewBandSpoken = "Measured 16 feet. The rule is at most 20 feet, and anything over 15 feet " + private static let reviewBandSpoken = "Measured 16 feet. The rule is at most 20 feet, and anything 15 feet or more " + "needs an installer's review. The measurement can be off by about 6 inches." /// Words that would say a person was contacted or a review arranged. The app contacts nobody,