Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion ios/HouseScan/Contract/ScanContract.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down
10 changes: 9 additions & 1 deletion ios/HouseScan/Runtime/ScanEngine+Export.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
)
}

Expand Down Expand Up @@ -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
Expand Down
37 changes: 30 additions & 7 deletions ios/HouseScan/UI/Copy/ScanCopy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
19 changes: 18 additions & 1 deletion ios/HouseScan/UI/Preview/DemoEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions ios/HouseScan/UI/Preview/UIDemo.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import SwiftUI
/// - `-uiDemoFailure <cameraDenied|arUnsupported|sessionFailed|replayUnreadable>`: 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
Expand Down
9 changes: 9 additions & 0 deletions ios/HouseScanUITests/ScreenStatesUITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -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.
Expand Down Expand Up @@ -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")
Expand Down
Loading