diff --git a/ios/HouseScan/Contract/ScanContract.swift b/ios/HouseScan/Contract/ScanContract.swift index 71b42801e..89c86913f 100644 --- a/ios/HouseScan/Contract/ScanContract.swift +++ b/ios/HouseScan/Contract/ScanContract.swift @@ -540,6 +540,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 @@ -552,10 +559,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 d18729e0f..d0434ca09 100644 --- a/ios/HouseScan/UI/Copy/ScanCopy.swift +++ b/ios/HouseScan/UI/Copy/ScanCopy.swift @@ -374,24 +374,47 @@ 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". /// `spoken` spells out feet and inches for VoiceOver, which reads "ft" and "in" as letters. static func measurement(_ row: CheckRow, spoken: Bool = false) -> String? { guard let measured = row.measured else { return nil } - func length(_ meters: Float) -> String { - spoken ? Distance.spoken(meters) : Distance.feetAndInches(meters) - } - var parts = ["Measured \(length(measured))."] + 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 \(length(threshold)), and the measurement can be off by about \(length(plusMinus)).") + parts.append("The rule is \(limit), and the measurement can be off by about \(length(plusMinus)).") } else { - parts.append("The rule is \(length(threshold)).") + parts.append("The rule is \(limit).") } } 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, length: (Float) -> String = Distance.feetAndInches) -> String { + if measured <= -Distance.metersPerInch / 2 { + return "Overlaps by \(length(measured))." + } + return "Measured \(length(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?, length: (Float) -> String = Distance.feetAndInches) -> String { + let distance = length(threshold) + guard let comparison else { return distance } + switch comparison { + case .atLeast: return threshold < Distance.metersPerInch / 2 ? "no overlap" : "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 1c0f0e4df..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 } @@ -931,7 +935,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, @@ -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 f6ace0523..17715b7f5 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 foot 3 inches. The rule is no overlap"), ] /// States where the scan is packaged, so "Share scan" must show. @@ -140,6 +143,12 @@ 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. 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") == 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")