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/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/Contract/ScanContract.swift b/ios/HouseScan/Contract/ScanContract.swift index 705c3e30e..9d6528f4b 100644 --- a/ios/HouseScan/Contract/ScanContract.swift +++ b/ios/HouseScan/Contract/ScanContract.swift @@ -665,6 +665,13 @@ 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 + /// 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 2047ffb97..1c1832946 100644 --- a/ios/HouseScan/Runtime/ScanEngine+Export.swift +++ b/ios/HouseScan/Runtime/ScanEngine+Export.swift @@ -252,26 +252,28 @@ 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), reviewBandApplies: check.reviewBandApplies, settledBy: result.evidenceIndex(settling: check.id).map(Self.missingID) ) } - // 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/HouseScan/UI/Copy/ScanCopy.swift b/ios/HouseScan/UI/Copy/ScanCopy.swift index 904b82fc8..0374f36ed 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,22 +698,39 @@ 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." /// 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 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. 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 +738,19 @@ enum ScanCopy { return parts.joined(separator: " ") } + /// "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 { + // 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" + } + } + /// "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/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/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/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/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/Sources/HouseScanKit/Scene/ResultReading.swift b/ios/HouseScanKit/Sources/HouseScanKit/Scene/ResultReading.swift index 4479dddea..8330d35cd 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. @@ -115,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/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/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/HouseScanKit/Tests/HouseScanKitTests/ResultReadingTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/ResultReadingTests.swift index a3807f77e..3f525a0ef 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), ("view-not-offered", .oneMoreLook), ]) 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..09d74ecb8 --- /dev/null +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/ReviewBandTests.swift @@ -0,0 +1,142 @@ +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 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 + .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 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 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 new file mode 100644 index 000000000..ebfc196b6 --- /dev/null +++ b/ios/HouseScanUITests/ResultCardUITests.swift @@ -0,0 +1,199 @@ +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. The tests +/// run at both sizes keep 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 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, + /// 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: 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, + /// 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. 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 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") + } + + 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) + } +}