From 11878ecad55afc3f63b4a53e296b26741fa77454 Mon Sep 17 00:00:00 2001 From: AidenJohnston Date: Sat, 26 Sep 2026 17:04:49 -0500 Subject: [PATCH 1/4] Result: say whether a rule's limit is a minimum or a maximum (B-14) "Measured 3 ft. The rule is 20 ft" read the cable run's maximum as a minimum. The server sends each check's comparison (at_least / at_most); the result row now carries it and the measurement line reads "The rule is at least 3 ft" or "at most 20 ft". A check without a comparison keeps the bare distance. The wording lives in the app (ScanCopy), not HouseScanKit, so the check is a UI-test assertion on the demo result's window row; the kit already tests decoding `comparison` (PlacementResultTests). Co-Authored-By: Claude Opus 5.5 (1M context) --- ios/HouseScan/Contract/ScanContract.swift | 11 ++++++++++- ios/HouseScan/Runtime/ScanEngine+Export.swift | 10 +++++++++- ios/HouseScan/UI/Copy/ScanCopy.swift | 19 ++++++++++++++++--- ios/HouseScan/UI/Preview/DemoEngine.swift | 2 +- .../ScreenStatesUITests.swift | 5 +++++ 5 files changed, 41 insertions(+), 6 deletions(-) diff --git a/ios/HouseScan/Contract/ScanContract.swift b/ios/HouseScan/Contract/ScanContract.swift index c5ccc8104..896b2f5f4 100644 --- a/ios/HouseScan/Contract/ScanContract.swift +++ b/ios/HouseScan/Contract/ScanContract.swift @@ -516,6 +516,13 @@ enum CheckOutcome: String, Equatable, Sendable { case unsure } +/// Which side of a rule's limit passes: a clearance the spot must keep (at least) or a length it +/// must stay within (at most). +enum RuleComparison: Equatable, Sendable { + case atLeast + case atMost +} + struct CheckRow: Identifiable, Equatable, Sendable { let id: String var title: String @@ -528,10 +535,12 @@ struct CheckRow: Identifiable, Equatable, Sendable { var needsPerson: Bool = false /// The deciding measurement, the rule's limit and the measurement's error, in meters, when the /// server gave them. A borderline result shows all three ("3 ft 2 in from the gas meter; the - /// rule is 3 ft and our measurement can be off by about 4 in"). + /// rule is at least 3 ft and our measurement can be off by about 4 in"). var measured: Float? = nil var threshold: Float? = nil var plusMinus: Float? = nil + /// Whether `threshold` is a minimum or a maximum, when the server said which. + var comparison: RuleComparison? = nil } struct MissingEvidence: Identifiable, Equatable, Sendable { diff --git a/ios/HouseScan/Runtime/ScanEngine+Export.swift b/ios/HouseScan/Runtime/ScanEngine+Export.swift index 4ada1b6ec..5fc56fc41 100644 --- a/ios/HouseScan/Runtime/ScanEngine+Export.swift +++ b/ios/HouseScan/Runtime/ScanEngine+Export.swift @@ -249,7 +249,8 @@ extension ScanEngine { id: check.id, title: check.label, outcome: Self.outcome(check.outcome), reason: check.reason, // An UNSURE with no cause is unexplained, so a person has to look at it. needsPerson: check.outcome == .unsure && (check.unsureCause.map { [.margin, .unknownAttribute, .ruleRequiresReview].contains($0) } ?? true), - measured: check.measuredFt.map(meters), threshold: check.thresholdFt.map(meters), plusMinus: check.plusMinusFt.map(meters) + measured: check.measuredFt.map(meters), threshold: check.thresholdFt.map(meters), plusMinus: check.plusMinusFt.map(meters), + comparison: check.comparison.map(Self.comparison) ) } @@ -315,6 +316,13 @@ extension ScanEngine { } } + static func comparison(_ comparison: PlacementComparison) -> RuleComparison { + switch comparison { + case .atLeast: .atLeast + case .atMost: .atMost + } + } + static func decision(_ decision: PlacementDecision) -> ResultPresentation.Decision { switch decision { case .pass: .pass diff --git a/ios/HouseScan/UI/Copy/ScanCopy.swift b/ios/HouseScan/UI/Copy/ScanCopy.swift index 4598cc081..12e00c560 100644 --- a/ios/HouseScan/UI/Copy/ScanCopy.swift +++ b/ios/HouseScan/UI/Copy/ScanCopy.swift @@ -354,20 +354,33 @@ enum ScanCopy { row.needsPerson ? "An installer will check this" : "One more photo would settle this" } - /// "Measured 3 ft 2 in. The rule is 3 ft, and the measurement can be off by about 4 in." + /// "Measured 3 ft 2 in. The rule is at least 3 ft, and the measurement can be off by about 4 in." + /// 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". static func measurement(_ row: CheckRow) -> String? { guard let measured = row.measured else { return nil } var parts = ["Measured \(Distance.feetAndInches(measured))."] if let threshold = row.threshold { + let limit = ruleLimit(threshold, row.comparison) if let plusMinus = row.plusMinus, plusMinus > 0 { - parts.append("The rule is \(Distance.feetAndInches(threshold)), and the measurement can be off by about \(Distance.feetAndInches(plusMinus)).") + parts.append("The rule is \(limit), and the measurement can be off by about \(Distance.feetAndInches(plusMinus)).") } else { - parts.append("The rule is \(Distance.feetAndInches(threshold)).") + parts.append("The rule is \(limit).") } } return parts.joined(separator: " ") } + /// "at least 3 ft", "at most 20 ft", or the bare distance when the server didn't say which. + static func ruleLimit(_ threshold: Float, _ comparison: RuleComparison?) -> String { + let distance = Distance.feetAndInches(threshold) + guard let comparison else { return distance } + switch comparison { + case .atLeast: return "at least \(distance)" + case .atMost: return "at most \(distance)" + } + } + static func outcomeWord(_ outcome: CheckOutcome) -> String { switch outcome { case .pass: "Looks good" diff --git a/ios/HouseScan/UI/Preview/DemoEngine.swift b/ios/HouseScan/UI/Preview/DemoEngine.swift index 940467423..9c94566a9 100644 --- a/ios/HouseScan/UI/Preview/DemoEngine.swift +++ b/ios/HouseScan/UI/Preview/DemoEngine.swift @@ -895,7 +895,7 @@ final class DemoEngine: ScanActions { reason: "The gas meter is well to the left of the spot."), CheckRow(id: "window", title: "Distance from the window", outcome: .unsure, reason: "The window is close to the spot's right edge.", - needsPerson: true, measured: 0.86, threshold: 0.91, plusMinus: 0.1), + needsPerson: true, measured: 0.86, threshold: 0.91, plusMinus: 0.1, comparison: .atLeast), CheckRow(id: "ground", title: "Ground under the spot", outcome: .unsure, reason: "Part of the ground was only seen from one place.", needsPerson: false), CheckRow(id: "ac", title: "Distance from the AC unit", outcome: .pass, diff --git a/ios/HouseScanUITests/ScreenStatesUITests.swift b/ios/HouseScanUITests/ScreenStatesUITests.swift index 342677abf..6611f974a 100644 --- a/ios/HouseScanUITests/ScreenStatesUITests.swift +++ b/ios/HouseScanUITests/ScreenStatesUITests.swift @@ -138,6 +138,11 @@ final class ScreenStatesUITests: XCTestCase { XCTAssertTrue(element(app, "screen.result").waitForExistence(timeout: 30)) XCTAssertTrue(element(app, "result.sampleBadge").exists, "a sample result must say so") XCTAssertTrue(element(app, "result.rulesNotFinal").exists, "placeholder rules must be disclosed") + // B-14: a limit says whether it is a minimum or a maximum. + let window = element(app, "check.window") + XCTAssertTrue(window.exists, "missing check.window") + XCTAssertTrue((window.value as? String)?.contains("The rule is at least 3 ft") == true, + "the window rule must read as a minimum, got \(String(describing: window.value))") // The result reveal slides its content in; a tap while it moves can miss (one failure in // three local runs), so wait until the button takes taps. let showAR = element(app, "action.showAR") From 21e21ceabdc98018a3fea3d8cc48c9298d4c82b1 Mon Sep 17 00:00:00 2001 From: AidenJohnston Date: Sat, 26 Sep 2026 17:13:33 -0500 Subject: [PATCH 2/4] test(ios): check the minimum's wording, not its unit (B-14) The window-row assertion read "at least 3 ft" from the VoiceOver value. The B-16 fix (#34) spells lengths out there ("3 feet"), so the test would fail once both land. It now checks "The rule is at least 3". Co-Authored-By: Claude Opus 5.5 (1M context) --- ios/HouseScanUITests/ScreenStatesUITests.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/ios/HouseScanUITests/ScreenStatesUITests.swift b/ios/HouseScanUITests/ScreenStatesUITests.swift index 9daa9d4e6..4ff5e511c 100644 --- a/ios/HouseScanUITests/ScreenStatesUITests.swift +++ b/ios/HouseScanUITests/ScreenStatesUITests.swift @@ -140,10 +140,11 @@ final class ScreenStatesUITests: XCTestCase { XCTAssertTrue(element(app, "screen.result").waitForExistence(timeout: 30)) XCTAssertTrue(element(app, "result.sampleBadge").exists, "a sample result must say so") XCTAssertTrue(element(app, "result.rulesNotFinal").exists, "placeholder rules must be disclosed") - // B-14: a limit says whether it is a minimum or a maximum. + // B-14: a limit says whether it is a minimum or a maximum. The unit is left off: VoiceOver + // text spells lengths out ("3 feet") once B-16 lands, the screen text says "3 ft". let window = element(app, "check.window") XCTAssertTrue(window.exists, "missing check.window") - XCTAssertTrue((window.value as? String)?.contains("The rule is at least 3 ft") == true, + XCTAssertTrue((window.value as? String)?.contains("The rule is at least 3") == true, "the window rule must read as a minimum, got \(String(describing: window.value))") // The result reveal slides its content in; a tap while it moves can miss (one failure in // three local runs), so wait until the button takes taps. From 792a900e485f20753086466d31023fde010bf436 Mon Sep 17 00:00:00 2001 From: AidenJohnston Date: Sat, 26 Sep 2026 17:21:55 -0500 Subject: [PATCH 3/4] Result: an overlap reads as one, not as clearance (#40) Field test run 2's working-space line read "Measured 1 ft 3 in. The rule is 0 in" for a spot 1 ft 3 in inside the meter's working space: the server's measured_ft was -1.25 and Distance.feetAndInches drops the sign. A measurement below zero now reads "Overlaps by 1 ft 3 in", and a minimum of 0 reads "The rule is no overlap". Less than half an inch of overlap still reads "Measured 0 in". The app target has no unit-test bundle (adding one means regenerating the project with XcodeGen), so the check is a screen state: -uiDemoOverlap puts run 2's line in the sample result, and result-overlap asserts the row's VoiceOver value and runs the accessibility audit on it. Co-Authored-By: Claude Opus 5.5 (1M context) --- ios/HouseScan/UI/Copy/ScanCopy.swift | 16 ++++++++++++++-- ios/HouseScan/UI/Preview/DemoEngine.swift | 17 +++++++++++++++++ ios/HouseScan/UI/Preview/UIDemo.swift | 1 + ios/HouseScanUITests/ScreenStatesUITests.swift | 3 +++ 4 files changed, 35 insertions(+), 2 deletions(-) diff --git a/ios/HouseScan/UI/Copy/ScanCopy.swift b/ios/HouseScan/UI/Copy/ScanCopy.swift index 9eed1e677..c9db524a7 100644 --- a/ios/HouseScan/UI/Copy/ScanCopy.swift +++ b/ios/HouseScan/UI/Copy/ScanCopy.swift @@ -370,7 +370,7 @@ enum ScanCopy { /// like a minimum under "Measured 3 ft". static func measurement(_ row: CheckRow) -> String? { guard let measured = row.measured else { return nil } - var parts = ["Measured \(Distance.feetAndInches(measured))."] + var parts = [measuredLine(measured)] if let threshold = row.threshold { let limit = ruleLimit(threshold, row.comparison) if let plusMinus = row.plusMinus, plusMinus > 0 { @@ -382,12 +382,24 @@ enum ScanCopy { return parts.joined(separator: " ") } + /// "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 + /// half an inch of overlap stays "Measured 0 in.", not "Overlaps by 0 in.". + static func measuredLine(_ measured: Float) -> String { + if measured <= -Distance.metersPerInch / 2 { + return "Overlaps by \(Distance.feetAndInches(measured))." + } + return "Measured \(Distance.feetAndInches(measured))." + } + /// "at least 3 ft", "at most 20 ft", or the bare distance when the server didn't say which. + /// A minimum of 0 reads "no overlap": "at least 0 in" says the same thing less plainly. static func ruleLimit(_ threshold: Float, _ comparison: RuleComparison?) -> String { let distance = Distance.feetAndInches(threshold) guard let comparison else { return distance } switch comparison { - case .atLeast: return "at least \(distance)" + case .atLeast: return threshold < Distance.metersPerInch / 2 ? "no overlap" : "at least \(distance)" case .atMost: return "at most \(distance)" } } diff --git a/ios/HouseScan/UI/Preview/DemoEngine.swift b/ios/HouseScan/UI/Preview/DemoEngine.swift index feefc2478..80a969076 100644 --- a/ios/HouseScan/UI/Preview/DemoEngine.swift +++ b/ios/HouseScan/UI/Preview/DemoEngine.swift @@ -11,6 +11,8 @@ final class DemoEngine: ScanActions { private let noFeed: Bool private let offline: Bool private let passResult: Bool + /// `-uiDemoOverlap`: the sample's spot overlaps the meter's working space (#40). + private let overlapResult: Bool private let rejectUpload: Bool /// Which request the gap screen shows (`-uiDemoGap`); the phone's ground request by default. private let gapKind: String? @@ -55,6 +57,7 @@ final class DemoEngine: ScanActions { noFeed = arguments.contains("-uiDemoNoFeed") offline = arguments.contains("-uiDemoOffline") passResult = arguments.contains("-uiDemoPass") + overlapResult = arguments.contains("-uiDemoOverlap") rejectUpload = arguments.contains("-uiDemoRejected") gapKind = value("-uiDemoGap") state.feed = DemoScene.image.map(CameraFeed.still) ?? .none @@ -463,6 +466,7 @@ final class DemoEngine: ScanActions { /// The check's answer, before or after its follow-up view. private var sample: ResultPresentation { if passResult { return Self.passSample } + if overlapResult { return Self.overlapSample } guard followedUp else { return Self.reviewSample } return followUpSkipped ? Self.reviewSample.withFollowUpSkipped : Self.reviewSample.withFollowUpTaken } @@ -948,6 +952,19 @@ final class DemoEngine: ScanActions { isSample: true ) + /// Field test run 2's working-space line: the spot overlaps the meter's working space by + /// 1 ft 3 in (measured_ft -1.25), within the measurement's 1 ft 6 in error (#40). + static let overlapSample: ResultPresentation = { + var sample = reviewSample + sample.checks.insert( + CheckRow(id: "meter_working_space", title: "Clear of the meter's working space", outcome: .unsure, + reason: "The battery is within measurement error of the meter's 2 ft 6 in wide by 3 ft 0 in deep working space.", + needsPerson: true, measured: -0.381, threshold: 0, plusMinus: 0.4572, comparison: .atLeast), + at: 0 + ) + return sample + }() + static let passSample: ResultPresentation = { var sample = reviewSample sample.decision = .pass diff --git a/ios/HouseScan/UI/Preview/UIDemo.swift b/ios/HouseScan/UI/Preview/UIDemo.swift index 264e7a217..053d4e2df 100644 --- a/ios/HouseScan/UI/Preview/UIDemo.swift +++ b/ios/HouseScan/UI/Preview/UIDemo.swift @@ -18,6 +18,7 @@ import SwiftUI /// - `-uiDemoFailure `: open on the /// unsupported screen. /// - `-uiDemoPass`: the sample result is a pass with approved rules. +/// - `-uiDemoOverlap`: the sample result's spot overlaps the meter's working space. /// - `-uiDemoNoFeed`: no camera picture, to look at the chrome alone. /// - `-uiDemoEndQuestion`: the walk asks what is at the left end of the wall. /// - `-uiDemoEndPreview`: the homeowner walked back 1.5 m, so the wall map says ending the wall diff --git a/ios/HouseScanUITests/ScreenStatesUITests.swift b/ios/HouseScanUITests/ScreenStatesUITests.swift index 4ff5e511c..35bac6d1c 100644 --- a/ios/HouseScanUITests/ScreenStatesUITests.swift +++ b/ios/HouseScanUITests/ScreenStatesUITests.swift @@ -48,6 +48,7 @@ final class ScreenStatesUITests: XCTestCase { ("uploading-followUp", ["-uiDemoPhase", "uploading", "-uiDemoFollowUp"], "uploading"), ("result-review", ["-uiDemoPhase", "result"], "result"), ("result-pass", ["-uiDemoPhase", "result", "-uiDemoPass"], "result"), + ("result-overlap", ["-uiDemoPhase", "result", "-uiDemoOverlap"], "result"), ("resultAR", ["-uiDemoPhase", "resultAR"], "resultAR"), ("cameraDenied", ["-uiDemoFailure", "cameraDenied"], "unsupported"), ("arUnsupported", ["-uiDemoFailure", "arUnsupported"], "unsupported"), @@ -73,6 +74,8 @@ final class ScreenStatesUITests: XCTestCase { "markFeatures-groundQuestion": (nil, "What's on the ground along this wall?"), "markFeatures-groundAnswered": ("ground.answered", "Mulch"), "markFeatures-lostPlace": ("review.lostPlace", "Your phone lost its place"), + // #40: an overlap reads as one, not as clearance. + "result-overlap": ("check.meter_working_space", "Overlaps by 1 ft 3 in. The rule is no overlap"), ] /// States where the scan is packaged, so "Share scan" must show. From 3b27aaf96d965d6f6081e49beea7b1cff8129527 Mon Sep 17 00:00:00 2001 From: AidenJohnston Date: Sat, 26 Sep 2026 17:23:39 -0500 Subject: [PATCH 4/4] test(ios): refresh the vendored scene schema to t3/server 074bf1d vendoredCopiesMatchServer compares the kit's copy with origin/t3/server, which gained the `battery` object type at 074bf1d (#27). Every iOS run from then on fails in HouseScanKit (run 36275541111 on this PR). The copy is byte for byte from 074bf1d, and its recorded hash and the provenance follow; the other two vendored files are unchanged there. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../JSONSchemaValidatorTests.swift | 13 +++++++------ .../HouseScanKitTests/Schemas/scene.schema.json | 2 +- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/JSONSchemaValidatorTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/JSONSchemaValidatorTests.swift index 904e286bf..5599f3385 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/JSONSchemaValidatorTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/JSONSchemaValidatorTests.swift @@ -2,16 +2,17 @@ import CryptoKit import Foundation import Testing -/// Vendored copies of the server's contracts, byte for byte from origin/t3/server at 930e8e5 +/// Vendored copies of the server's contracts, byte for byte from origin/t3/server at 074bf1d /// (server/schemas/*.schema.json and server/tests/fixtures/example-scene.json). Since e0ee8d3 /// requests carry `out_ft` and facing and overhead coverage may too; 737bf75 adds /// `walls[].source`; 930e8e5 defines a wall entry's `out_ft` as the height seen above the -/// ground. `vendoredCopiesMatchServer` fails if the server's files change and these are not -/// refreshed; `vendoredCopiesAreTheRecordedRevision` fails if a copy is edited by hand. +/// ground; 074bf1d adds the `battery` object type. `vendoredCopiesMatchServer` fails if the +/// server's files change and these are not refreshed; `vendoredCopiesAreTheRecordedRevision` +/// fails if a copy is edited by hand. enum SceneSchemas { static let vendored: [(name: String, serverPath: String, sha256: String)] = [ ("scene.schema.json", "server/schemas/scene.schema.json", - "e07f20f0487c9c9f890842230ea87114b4b385a43744c7144d881b4bdd6cbf26"), + "62bde2215141858cc4cc33c2206d929c37423a6638daf9dffffd48657918e4d0"), ("result.schema.json", "server/schemas/result.schema.json", "ddc7cc486945e18eaae714b4a6c29249cbd86f24960fa5df640bb5bbc340049c"), ("example-scene.json", "server/tests/fixtures/example-scene.json", @@ -186,12 +187,12 @@ enum SceneSchemas { } } - /// The hashes are of `git show 930e8e5:`, so a copy edited by hand (or refreshed + /// The hashes are of `git show 074bf1d:`, so a copy edited by hand (or refreshed /// without updating the provenance above) fails here even where the server tree is absent. @Test func vendoredCopiesAreTheRecordedRevision() throws { for (name, _, sha256) in SceneSchemas.vendored { let digest = SHA256.hash(data: try SceneSchemas.data(name)).map { String(format: "%02x", $0) }.joined() - #expect(digest == sha256, "Schemas/\(name) is not the copy taken from origin/t3/server 930e8e5") + #expect(digest == sha256, "Schemas/\(name) is not the copy taken from origin/t3/server 074bf1d") } } diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/Schemas/scene.schema.json b/ios/HouseScanKit/Tests/HouseScanKitTests/Schemas/scene.schema.json index d8146e73b..695e5e4a9 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/Schemas/scene.schema.json +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/Schemas/scene.schema.json @@ -54,7 +54,7 @@ "additionalProperties": false, "required": ["type", "wall_id", "span_ft", "source"], "properties": { - "type": {"enum": ["window", "door", "garage_door", "ac", "gas_meter", "elec_box", "vent", "downspout", "pool"]}, + "type": {"enum": ["window", "door", "garage_door", "ac", "gas_meter", "elec_box", "vent", "downspout", "pool", "battery"], "description": "battery: a battery already installed, which a new one keeps clearances.battery_ft from. Disconnects, sub-panels, EV chargers and solar equipment are elec_box."}, "wall_id": {"type": "string", "minLength": 1}, "span_ft": {"$ref": "#/$defs/span", "description": "Stretch of the wall the object covers, in s."}, "bottom_ft": {"type": "number", "minimum": 0, "description": "Height of the object's lowest edge above the ground."},