diff --git a/ios/HouseScan.xcodeproj/project.pbxproj b/ios/HouseScan.xcodeproj/project.pbxproj index f623d8f14..21c9c0ac2 100644 --- a/ios/HouseScan.xcodeproj/project.pbxproj +++ b/ios/HouseScan.xcodeproj/project.pbxproj @@ -36,6 +36,8 @@ "Fixtures/results/no-clean-spot.json", Fixtures/results/pass.json, "Fixtures/results/reject-nearest.json", + "Fixtures/results/spot-left.json", + "Fixtures/results/spot-straddle.json", "Fixtures/results/unsure-view.json", "Fixtures/synthetic-wall", "Fixtures/synthetic-wall-lidar", diff --git a/ios/HouseScan/Runtime/ScanEngine+Confirm.swift b/ios/HouseScan/Runtime/ScanEngine+Confirm.swift index 14761faa6..367ff3162 100644 --- a/ios/HouseScan/Runtime/ScanEngine+Confirm.swift +++ b/ios/HouseScan/Runtime/ScanEngine+Confirm.swift @@ -84,9 +84,6 @@ extension ScanEngine { let candidates = store.keyframes.map { SpotPhotoCandidate(id: $0.id, camera: $0.camera, trackingNormal: $0.tracking == .normal) } let choice = SpotPhoto.best(candidates, area: area, wall: wall) let stored = choice.flatMap { choice in store.keyframes.first { $0.id == choice.id } } - // `answer` is replaced by the homeowner's (`answerSpotCheck`) before it is recorded. - spotConfirm.pending = SpotConfirmation(area: area, answerSHA256: answerSHA256, sceneSHA256: sceneSHA256, photoID: stored?.id, answer: .clear) - spotConfirm.request = spotCheckRequest(area: area, photoID: stored?.id, answerSHA256: answerSHA256, sceneSHA256: sceneSHA256, id: id) let file = stored.map { store.directory.appending(path: $0.fileName) } if let choice { RuntimeLog.engine.info("spot check: photo \(choice.id, privacy: .public) shows \(Int(choice.footprintInView * 100))% of the spot and \(Int(choice.areaInView * 100))% of s=\(area.span.lowerBound)...\(area.span.upperBound)") @@ -100,8 +97,20 @@ extension ScanEngine { if let image, let camera = stored?.camera { photo = SpotCheck.Photo(image: image, projection: CameraProjection( cameraToWorld: camera.cameraToWorld, intrinsics: camera.intrinsics, imageSize: camera.imageSize)) + } else if let stored { + RuntimeLog.engine.error("spot check: keyframe \(stored.id, privacy: .public) didn't load; asking without a photo") } - state.spotCheck = Self.spotCheck(id: id, area: area, spot: spot, photo: photo, answer: nil, isSample: result.isSample) + let check = Self.spotCheck(id: id, area: area, spot: spot, photo: photo, answer: nil, isSample: result.isSample) + // Recorded once the photo has loaded, from what the screen shows: the photo only when + // it is outlined on screen, and the question asked with or without the outline. + let outlined = check.outline(on: state.wall) != nil + let shownID = outlined ? stored?.id : nil + // `answer` is replaced by the homeowner's (`answerSpotCheck`) before it is recorded. + spotConfirm.pending = SpotConfirmation(area: area, answerSHA256: answerSHA256, sceneSHA256: sceneSHA256, photoID: shownID, answer: .clear) + spotConfirm.request = spotCheckRequest( + area: area, question: ScanCopy.spotQuestionShown(outlined: outlined), photoID: shownID, + answerSHA256: answerSHA256, sceneSHA256: sceneSHA256, id: id) + state.spotCheck = check go(.spotConfirm) } } @@ -155,15 +164,15 @@ extension ScanEngine { var spotCheckGuidance: GuidanceLog.Request? { spotConfirm.request } /// A gap_band request on the ground over the area. scene.json and the packet have no field - /// for the check, so the message carries what binds it: the photo shown and the sha256 of the - /// answer and scene it was about. - private func spotCheckRequest(area: SpotArea, photoID: String?, answerSHA256: String, sceneSHA256: String, id: Int) -> GuidanceLog.Request { + /// for the check, so the message carries what binds it: the question and photo shown and the + /// sha256 of the answer and scene it was about. + private func spotCheckRequest(area: SpotArea, question: Instruction, photoID: String?, answerSHA256: String, sceneSHA256: String, id: Int) -> GuidanceLog.Request { let format = { (value: Float) in String(format: "%.2f", value) } let binding = "Spot check \(id): keyframe \(photoID ?? "none") shown; spot s \(format(area.spot.lowerBound)) to \(format(area.spot.upperBound)) m, " + "area s \(format(area.span.lowerBound)) to \(format(area.span.upperBound)) m out to \(format(area.depth)) m; " + "answer sha256 \(answerSHA256), scene sha256 \(sceneSHA256)." return GuidanceLog.Request( - topic: .spotCheck(id: id), kind: .gapBand, origin: .phone, message: Self.text(ScanCopy.spotQuestion) + " " + binding, + topic: .spotCheck(id: id), kind: .gapBand, origin: .phone, message: Self.text(question) + " " + binding, band: .ground, span: area.span) } diff --git a/ios/HouseScan/UI/Copy/SpotCheckCopy.swift b/ios/HouseScan/UI/Copy/SpotCheckCopy.swift index 9640f394a..3aa9398f5 100644 --- a/ios/HouseScan/UI/Copy/SpotCheckCopy.swift +++ b/ios/HouseScan/UI/Copy/SpotCheckCopy.swift @@ -2,11 +2,26 @@ import Foundation /// The spot check's words (`SpotConfirmScreen`, and the guidance log's record of the question). extension ScanCopy { - /// One question about the wall and the ground, with the reason for asking. + /// One question about the wall and the ground, with the reason for asking, when the area is + /// outlined on a photo. static let spotQuestion = Instruction( title: "Is anything standing in the marked area?", detail: "Check the wall and the ground inside the blue outline. Photos can miss a low bush, a bin or a step." ) + + /// The same question when no outline is drawn: no kept photo shows the area, or there is no + /// wall to draw it with. The area is then given in words (`spotSpace`), so nothing points + /// at an outline that isn't on screen. + static let spotQuestionInPerson = Instruction( + title: "Is anything standing in this space?", + detail: "We couldn't mark this space on a photo, so take a look yourself. Check the wall and the ground for a low bush, a bin, a step or anything else." + ) + static let spotSpaceTitle = "Where to look" + + /// The question as shown (`SpotCheck.outline(on:)`), on screen and in the guidance log. + static func spotQuestionShown(outlined: Bool) -> Instruction { + outlined ? spotQuestion : spotQuestionInPerson + } static let spotClear = "It's clear" static let spotSomethingThere = "Something's there" static let spotClearHint = "Shows your result." @@ -22,19 +37,49 @@ extension ScanCopy { /// Said on the result when the homeowner said something stands where its spot is. static let spotRefused = "You said something stands where this spot is. Your scan leaves that area out, and an installer would need to check it." - /// "From 3 ft to 8 ft 6 in right of your meter": where the area runs. `spoken` spells the - /// units out for VoiceOver. + /// "From 3 ft to 8 ft 6 in right of your meter": where the area runs along the wall, nearer + /// end first. An end less than half an inch from the meter rounds to 0 in and is read as the + /// meter itself, so -0.3...24 in reads "From your meter to 2 ft right of it", not "From 0 in + /// left to 2 ft right". `spoken` spells the units out for VoiceOver. static func spotArea(_ span: ClosedRange, spoken: Bool = false) -> String { let length = { (meters: Float) in spoken ? Distance.spoken(meters) : Distance.feetAndInches(meters) } + let side = { (s: Float) in s < 0 ? "left" : "right" } + let atMeter = { (s: Float) in Int((abs(s) / Distance.metersPerInch).rounded()) == 0 } let low = span.lowerBound, high = span.upperBound + switch (atMeter(low), atMeter(high)) { + case (true, true): return "At your meter" + case (true, false): return "From your meter to \(length(high)) \(side(high)) of it" + case (false, true): return "From your meter to \(length(low)) \(side(low)) of it" + case (false, false): break + } if low >= 0 { return "From \(length(low)) to \(length(high)) right of your meter" } if high <= 0 { return "From \(length(high)) to \(length(low)) left of your meter" } return "From \(length(low)) left to \(length(high)) right of your meter" } - /// What VoiceOver reads for the photo. + /// The asked-about area's three extents, each readable on its own: along the wall from the + /// meter (`SpotCheck.area`), out from the wall over the ground (`SpotCheck.areaDepth`), and up + /// the wall face to the battery's height (`SpotCheck.spotHeight`). These are the edges + /// `SpotOutline` draws on a photo, and no more: the words describe the area the question is + /// about, not every clearance the server checks. + static func spotSpace(_ check: SpotCheck, spoken: Bool = false) -> (along: String, out: String, up: String) { + let length = { (meters: Float) in spoken ? Distance.spoken(meters) : Distance.feetAndInches(meters) } + return ( + along: spotArea(check.area, spoken: spoken), + out: "From the wall out to \(length(check.areaDepth))", + up: "From the ground up to \(length(check.spotHeight))" + ) + } + + /// `spotSpace` as one spoken sentence, for VoiceOver. + static func spotSpaceSpoken(_ check: SpotCheck) -> String { + let space = spotSpace(check, spoken: true) + return "\(space.along), \(space.out.lowercasedFirst), and \(space.up.lowercasedFirst)." + } + + /// What VoiceOver reads for the outlined photo: what the outline marks, and where, since a + /// VoiceOver user can't see the outline's edges. static func spotPhotoDescription(_ check: SpotCheck) -> String { - let place = spotArea(check.area, spoken: true) - return "A blue outline marks where the battery would stand and the space around it. \(place)." + "A blue outline marks where the battery would stand and the space around it. \(spotSpaceSpoken(check))" } } diff --git a/ios/HouseScan/UI/Preview/DemoEngine.swift b/ios/HouseScan/UI/Preview/DemoEngine.swift index d5f913929..6c4f38df2 100644 --- a/ios/HouseScan/UI/Preview/DemoEngine.swift +++ b/ios/HouseScan/UI/Preview/DemoEngine.swift @@ -182,6 +182,14 @@ final class DemoEngine: ScanActions { if let raw = value("-uiDemoSpotAnswered"), state.phase == .spotConfirm { state.spotCheck?.answer = raw == "somethingThere" ? .somethingThere : .clear } + // The spot check without an outline: no kept photo shows the area (`-uiDemoSpotNoPhoto`), + // or a photo but no wall to draw the area on it with (`-uiDemoSpotNoWall`). + if arguments.contains("-uiDemoSpotNoPhoto"), state.phase == .spotConfirm { + state.spotCheck?.photo = nil + } + if arguments.contains("-uiDemoSpotNoWall"), state.phase == .spotConfirm { + state.wall = nil + } if arguments.contains("-uiDemoFollowUp") { enterFollowUp(at: state.phase) } diff --git a/ios/HouseScan/UI/Screens/SpotConfirmScreen.swift b/ios/HouseScan/UI/Screens/SpotConfirmScreen.swift index 079ba7c36..3233b0c16 100644 --- a/ios/HouseScan/UI/Screens/SpotConfirmScreen.swift +++ b/ios/HouseScan/UI/Screens/SpotConfirmScreen.swift @@ -4,6 +4,11 @@ import SwiftUI /// outlined, and one question with two equal answers. A photo can claim wall and ground behind a /// bush, so the homeowner, who is standing there, says whether anything is in the way. /// +/// Without an outline (no kept photo shows the area, or there is no wall to draw it with) the +/// area is given in words instead: where it runs beside the meter, how far out from the wall and +/// how high up it. The question then names no outline, and no photo is shown, since a photo with +/// nothing marked on it would leave the homeowner guessing which part of it is meant. +/// /// The answer replaces the two buttons and stays up a moment, saying what happens next, before /// the engine moves on (to the result, or to checking the wall again). struct SpotConfirmScreen: View { @@ -23,15 +28,17 @@ struct SpotConfirmScreen: View { } private func content(_ check: SpotCheck) -> some View { - ScrollView { + let outline = check.outline(on: state.wall) + let question = ScanCopy.spotQuestionShown(outlined: outline != nil) + return ScrollView { VStack(alignment: .leading, spacing: 20) { badges(check) VStack(alignment: .leading, spacing: 8) { - Text(ScanCopy.spotQuestion.title) + Text(question.title) .font(Typeface.screenTitle) .fixedSize(horizontal: false, vertical: true) .accessibilityAddTraits(.isHeader) - if let detail = ScanCopy.spotQuestion.detail { + if let detail = question.detail { Text(detail) .font(Typeface.hint) .foregroundStyle(Palette.muted) @@ -40,13 +47,17 @@ struct SpotConfirmScreen: View { } .accessibilityElement(children: .combine) .accessibilityIdentifier("spot.question") - VStack(alignment: .leading, spacing: 10) { - photo(check) - Text(ScanCopy.spotArea(check.area)) - .font(.subheadline.weight(.medium)) - .foregroundStyle(Palette.muted) - .fixedSize(horizontal: false, vertical: true) - .accessibilityHidden(true) + if let outline { + VStack(alignment: .leading, spacing: 10) { + outlinedPhoto(outline.photo, wall: outline.wall, check: check) + Text(ScanCopy.spotArea(check.area)) + .font(.subheadline.weight(.medium)) + .foregroundStyle(Palette.muted) + .fixedSize(horizontal: false, vertical: true) + .accessibilityHidden(true) + } + } else { + space(check) } if typeSize.isAccessibilitySize { answerArea(check) @@ -104,45 +115,73 @@ struct SpotConfirmScreen: View { } } - @ViewBuilder - private func photo(_ check: SpotCheck) -> some View { - Group { - if let photo = check.photo, let wall = state.wall { - let size = photo.projection.imageSize - // The sensor image shown upright (rotated 90° clockwise) at its own aspect, so - // filling the frame crops nothing and the projection's fill mapping holds. - Color.clear - .aspectRatio(CGFloat(size.y / size.x), contentMode: .fit) - .overlay { - Image(decorative: photo.image, scale: 1, orientation: .right) - .resizable() - .scaledToFill() - } - .overlay { - SpotOutline(photo: photo, wall: wall, check: check) - .opacity(outlineShown ? 1 : 0) - } - .clipShape(.rect(cornerRadius: Metrics.cardRadius, style: .continuous)) - // Tall enough to judge, short enough that the question and the photo share - // the screen with the answers on a 6.1 in phone. - .frame(maxHeight: 400) - .frame(maxWidth: .infinity) - } else { - // No kept photo shows the spot from the front: the homeowner looks at the wall. - Label("No photo shows this spot well. Take a look at the wall itself.", systemImage: "photo") - .font(Typeface.hint) - .foregroundStyle(Palette.ink) - .fixedSize(horizontal: false, vertical: true) - .padding(18) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Palette.surface, in: .rect(cornerRadius: Metrics.cardRadius, style: .continuous)) + private func outlinedPhoto(_ photo: SpotCheck.Photo, wall: WallGeometry, check: SpotCheck) -> some View { + let size = photo.projection.imageSize + // The sensor image shown upright (rotated 90° clockwise) at its own aspect, so filling + // the frame crops nothing and the projection's fill mapping holds. + return Color.clear + .aspectRatio(CGFloat(size.y / size.x), contentMode: .fit) + .overlay { + Image(decorative: photo.image, scale: 1, orientation: .right) + .resizable() + .scaledToFill() + } + .overlay { + SpotOutline(photo: photo, wall: wall, check: check) + .opacity(outlineShown ? 1 : 0) + } + .clipShape(.rect(cornerRadius: Metrics.cardRadius, style: .continuous)) + // Tall enough to judge, short enough that the question and the photo share the + // screen with the answers on a 6.1 in phone. + .frame(maxHeight: 400) + .frame(maxWidth: .infinity) + .accessibilityElement(children: .ignore) + .accessibilityLabel("Photo of your wall") + .accessibilityValue(ScanCopy.spotPhotoDescription(check)) + .accessibilityAddTraits(.isImage) + .accessibilityIdentifier("spot.photo") + } + + /// The area in words, in the photo's place: the three edges the outline would draw. Each + /// line is its own VoiceOver element, read with the units spelled out, so the rendered lines + /// stay visible to the accessibility audit and the UI tests. + private func space(_ check: SpotCheck) -> some View { + let shown = ScanCopy.spotSpace(check) + let spoken = ScanCopy.spotSpace(check, spoken: true) + return VStack(alignment: .leading, spacing: 10) { + // One element read as the title alone: VoiceOver names the "eye" symbol "Show", and + // the identifier landed on it (CI run 37108821815 read the title as "Show"). + Label { + Text(ScanCopy.spotSpaceTitle) + } icon: { + Image(systemName: "eye").accessibilityHidden(true) + } + .font(Typeface.caption) + .foregroundStyle(Palette.muted) + .accessibilityElement(children: .combine) + .accessibilityAddTraits(.isHeader) + .accessibilityIdentifier("spot.area.title") + VStack(alignment: .leading, spacing: 6) { + spaceLine(shown.along, spoken: spoken.along, id: "along") + spaceLine(shown.out, spoken: spoken.out, id: "out") + spaceLine(shown.up, spoken: spoken.up, id: "up") } } - .accessibilityElement(children: .ignore) - .accessibilityLabel(check.photo == nil ? "No photo of this spot" : "Photo of your wall") - .accessibilityValue(ScanCopy.spotPhotoDescription(check)) - .accessibilityAddTraits(check.photo == nil ? [] : .isImage) - .accessibilityIdentifier("spot.photo") + .padding(18) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Palette.surface, in: .rect(cornerRadius: Metrics.cardRadius, style: .continuous)) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("spot.area") + } + + /// In the primary color, as the question: Ink stays near-black in dark mode, where Surface + /// turns dark, and this screen follows the phone's appearance. + private func spaceLine(_ text: String, spoken: String, id: String) -> some View { + Text(text) + .font(Typeface.hint) + .fixedSize(horizontal: false, vertical: true) + .accessibilityLabel(spoken) + .accessibilityIdentifier("spot.area.\(id)") } /// The two answers, or once answered, what happens next. @@ -172,6 +211,16 @@ struct SpotConfirmScreen: View { } } +extension SpotCheck { + /// The photo and the wall to outline the area on it with, or nil when either is missing. + /// The screen's question, picture and VoiceOver text follow this one answer, and so does + /// the guidance log's record of what was shown (`ScanEngine.presentAnswer`). + func outline(on wall: WallGeometry?) -> (photo: Photo, wall: WallGeometry)? { + guard let photo = self.photo, let wall else { return nil } + return (photo, wall) + } +} + /// The answer given, and what happens next. private struct Answered: View { var answer: SpotCheckAnswer diff --git a/ios/HouseScanUITests/Fixtures/results/spot-left.json b/ios/HouseScanUITests/Fixtures/results/spot-left.json new file mode 100644 index 000000000..704c3fa01 --- /dev/null +++ b/ios/HouseScanUITests/Fixtures/results/spot-left.json @@ -0,0 +1,203 @@ +{ + "schema_version": "1.0", + "decision": "pass", + "summary": "Test answer for the spot check's wording: a spot 3 ft 6 in to 6 ft left of the meter. Demo rules: public values and placeholders (pool 10 ft, drive 5 ft), not Base's.", + "reasons": [], + "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": "pass", + "wall_id": "wall", + "segment": 0, + "span_ft": [ + -6.0, + -3.5 + ], + "width_ft": 2.5, + "depth_ft": 2.0, + "height_ft": 3.5, + "footprint": [ + [ + -6.0, + 0.0 + ], + [ + -3.5, + 0.0 + ], + [ + -3.5, + 2.0 + ], + [ + -6.0, + 2.0 + ] + ], + "center": [ + -4.75, + 1.0 + ], + "along": [ + 1.0, + 0.0 + ], + "outward": [ + 0.0, + 1.0 + ], + "meter_offset_ft": [ + -4.75, + 1.0 + ], + "route_length_ft": 3.5 + }, + "route": { + "outcome": "pass", + "length_ft": 3.5, + "plus_minus_ft": 0.3, + "height_ft": 1.5, + "polyline": [ + [ + 0.0, + 0.0 + ], + [ + -3.5, + 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": "pass", + "reason": "Test: short run.", + "measured_ft": 3.5, + "plus_minus_ft": 0.3, + "threshold_ft": 20.0, + "comparison": "at_most", + "subject": null, + "rule": { + "key": "route_length_ft", + "source": "Made-up test value", + "placeholder": true + } + } + ], + "nearest_considered": null, + "missing_evidence": [], + "ends": { + "left": { + "kind": "limit", + "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": [ + -6.0, + -5.0 + ], + "outcome": "pass", + "failing": [], + "unsure": [] + } + ], + "stats": { + "candidates": 36, + "pass": 4, + "unsure": 0, + "fail": 32, + "elapsed_ms": 0, + "input_sha256": "0000000000000000000000000000000000000000000000000000000000000000" + } +} diff --git a/ios/HouseScanUITests/Fixtures/results/spot-straddle.json b/ios/HouseScanUITests/Fixtures/results/spot-straddle.json new file mode 100644 index 000000000..3758eb4a4 --- /dev/null +++ b/ios/HouseScanUITests/Fixtures/results/spot-straddle.json @@ -0,0 +1,203 @@ +{ + "schema_version": "1.0", + "decision": "pass", + "summary": "Test answer for the spot check's wording: a spot from 1 ft 3 in left to 1 ft 6 in right of the meter. Demo rules: public values and placeholders (pool 10 ft, drive 5 ft), not Base's.", + "reasons": [], + "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": "pass", + "wall_id": "wall", + "segment": 0, + "span_ft": [ + -1.25, + 1.5 + ], + "width_ft": 2.75, + "depth_ft": 2.0, + "height_ft": 3.5, + "footprint": [ + [ + -1.25, + 0.0 + ], + [ + 1.5, + 0.0 + ], + [ + 1.5, + 2.0 + ], + [ + -1.25, + 2.0 + ] + ], + "center": [ + 0.125, + 1.0 + ], + "along": [ + 1.0, + 0.0 + ], + "outward": [ + 0.0, + 1.0 + ], + "meter_offset_ft": [ + 0.125, + 1.0 + ], + "route_length_ft": 0.125 + }, + "route": { + "outcome": "pass", + "length_ft": 0.125, + "plus_minus_ft": 0.3, + "height_ft": 1.5, + "polyline": [ + [ + 0.0, + 0.0 + ], + [ + 0.125, + 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": "pass", + "reason": "Test: short run.", + "measured_ft": 0.125, + "plus_minus_ft": 0.3, + "threshold_ft": 20.0, + "comparison": "at_most", + "subject": null, + "rule": { + "key": "route_length_ft", + "source": "Made-up test value", + "placeholder": true + } + } + ], + "nearest_considered": null, + "missing_evidence": [], + "ends": { + "left": { + "kind": "limit", + "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": [ + -1.25, + -0.25 + ], + "outcome": "pass", + "failing": [], + "unsure": [] + } + ], + "stats": { + "candidates": 36, + "pass": 4, + "unsure": 0, + "fail": 32, + "elapsed_ms": 0, + "input_sha256": "0000000000000000000000000000000000000000000000000000000000000000" + } +} diff --git a/ios/HouseScanUITests/SpotConfirmUITests.swift b/ios/HouseScanUITests/SpotConfirmUITests.swift new file mode 100644 index 000000000..4f2083ef5 --- /dev/null +++ b/ios/HouseScanUITests/SpotConfirmUITests.swift @@ -0,0 +1,179 @@ +import XCTest + +/// The spot check names the space the homeowner judges, with or without an outline to show it. +/// +/// With a kept photo and a wall, the area is outlined on the photo and the question points at the +/// outline. Without either, nothing is drawn, so the question must not mention an outline and the +/// area is given in words: along the wall from the meter, out from the wall, and up it. Held still +/// on the demo (`-uiDemoFreeze`), whose review sample's spot runs 0.55 to 1.34 m right of the +/// meter, stands 0.03 m off the wall and 0.56 m deep (out to 0.59 m), and is 1.1 m high. The demo +/// asks about the footprint alone, so its area is the footprint. Fixtures/results/spot-left.json +/// and spot-straddle.json put the spot left of the meter and across it. +final class SpotConfirmUITests: XCTestCase { + private static let largestText = ["-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryAccessibilityXXXL"] + + /// The words-only card's three lines as VoiceOver reads them (units spelled out). + private struct Lines { + var along: String + var out: String + var up: String + } + + /// The review sample's area, read aloud: one sentence on the photo, three lines in words. + private static let sampleLines = Lines( + along: "From 1 foot 10 inches to 4 feet 5 inches right of your meter", + out: "From the wall out to 1 foot 11 inches", up: "From the ground up to 3 feet 7 inches") + private static let sampleSpace = "From 1 foot 10 inches to 4 feet 5 inches right of your meter, from the wall out to 1 foot 11 inches, and from the ground up to 3 feet 7 inches." + + override func setUp() { + continueAfterFailure = true + } + + @MainActor + func testTheOutlinedPhotoNamesTheOutlineAndWhereItRuns() throws { + let app = launch([]) + defer { app.terminate() } + let photo = element(app, "spot.photo") + XCTAssertTrue(photo.waitForExistence(timeout: 10), "the demo's spot check shows its photo") + let read = ElementRead.snapshot(photo) + XCTAssertEqual(read?.label, "Photo of your wall") + XCTAssertEqual(read?.value as? String, "A blue outline marks where the battery would stand and the space around it. \(Self.sampleSpace)") + let question = ElementRead.snapshot(element(app, "spot.question"))?.label ?? "" + XCTAssertTrue(question.contains("Is anything standing in the marked area?"), question) + XCTAssertTrue(question.contains("inside the blue outline"), question) + XCTAssertFalse(element(app, "spot.area").exists, "the words-only area shows only without an outline") + attach(app, name: "spotConfirm-photo") + } + + @MainActor + func testWithoutAPhotoTheAreaIsGivenInWords() throws { + try checkInWords(["-uiDemoSpotNoPhoto"], name: "spotConfirm-noPhoto", spoken: Self.sampleLines) + try checkInWords(["-uiDemoSpotNoPhoto"] + Self.largestText, name: "spotConfirm-noPhoto-AX5", spoken: Self.sampleLines) + } + + /// A photo with no wall to draw the area with is shown as no photo: VoiceOver used to call + /// the words-only card "Photo of your wall" and describe an outline that wasn't drawn. + @MainActor + func testWithoutAWallThePhotoIsNotShownUnmarked() throws { + try checkInWords(["-uiDemoSpotNoWall"], name: "spotConfirm-noWall", spoken: Self.sampleLines) + } + + @MainActor + func testTheAreaInWordsNamesTheSideOfTheMeter() throws { + try checkInWords(["-uiDemoResultFile", Self.resultFile("spot-left"), "-uiDemoSpotNoPhoto"], name: "spotConfirm-noPhoto-left", + spoken: Lines(along: "From 3 feet 6 inches to 6 feet left of your meter", + out: "From the wall out to 2 feet", up: "From the ground up to 3 feet 6 inches")) + try checkInWords(["-uiDemoResultFile", Self.resultFile("spot-straddle"), "-uiDemoSpotNoPhoto"], name: "spotConfirm-noPhoto-straddle", + spoken: Lines(along: "From 1 foot 3 inches left to 1 foot 6 inches right of your meter", + out: "From the wall out to 2 feet", up: "From the ground up to 3 feet 6 inches")) + } + + /// Answering works the same without a photo: "Something's there" is taken and acknowledged. + @MainActor + func testTheAnswerIsTakenWithoutAPhoto() throws { + let app = launch(["-uiDemoSpotNoPhoto"]) + defer { app.terminate() } + let answer = app.buttons["action.spotSomethingThere"] + XCTAssertTrue(answer.waitForExistence(timeout: 10)) + let hittable = XCTNSPredicateExpectation(predicate: NSPredicate(format: "isHittable == true"), object: answer) + XCTAssertEqual(XCTWaiter().wait(for: [hittable], timeout: 10), .completed, "Something's there can't be tapped") + answer.tap() + let answered = element(app, "spot.answered") + XCTAssertTrue(answered.waitForExistence(timeout: 10)) + let label = ElementRead.snapshot(answered)?.label ?? "" + XCTAssertTrue(label.contains("Thanks, we'll leave that area out"), label) + } + + // MARK: - Helpers + + /// The words-only spot check: no photo element, a question that names no outline, each of + /// the area's three lines rendered on screen with its spoken label, both answers present, and + /// a clean accessibility audit, which also checks those lines for contrast and clipping. + @MainActor + private func checkInWords(_ arguments: [String], name: String, spoken: Lines) throws { + let app = launch(arguments) + defer { app.terminate() } + guard element(app, "spot.area").waitForExistence(timeout: 10) else { + XCTFail("\(name): spot.area never appeared") + return + } + attach(app, name: name) + XCTAssertFalse(element(app, "spot.photo").exists, "\(name): no photo is shown without an outline on it") + XCTAssertEqual(ElementRead.snapshot(element(app, "spot.area.title"))?.label, "Where to look", name) + let window = app.windows.firstMatch.frame + for (id, expected) in [("along", spoken.along), ("out", spoken.out), ("up", spoken.up)] { + let line = element(app, "spot.area.\(id)") + // At AX5 the card can sit below the fold; the homeowner scrolls to it. + Self.scrollOnScreen(line, in: app) + let read = ElementRead.snapshot(line) + XCTAssertEqual(read?.label, expected, "\(name): spot.area.\(id)") + let frame = read?.frame ?? .zero + XCTAssertTrue(!frame.isEmpty && window.contains(frame), "\(name): spot.area.\(id) at \(frame) isn't on screen in \(window)") + } + let question = ElementRead.snapshot(element(app, "spot.question"))?.label ?? "" + XCTAssertTrue(question.contains("Is anything standing in this space?"), "\(name): \(question)") + XCTAssertTrue(question.contains("take a look yourself"), "\(name): \(question)") + // Nothing on screen may point at an outline that isn't drawn. Labels only: some elements' + // values aren't strings. + let outline = NSPredicate(format: "label CONTAINS[c] 'outline' OR label CONTAINS[c] 'marked area'") + XCTAssertEqual(app.descendants(matching: .any).matching(outline).count, 0, "\(name): something still mentions the outline") + XCTAssertTrue(app.buttons["action.spotClear"].exists, "\(name): It's clear is missing") + XCTAssertTrue(app.buttons["action.spotSomethingThere"].exists, "\(name): Something's there is missing") + // As ScreenStatesUITests: an issue fails only when a second pass, after a system banner + // has had time to leave, finds it again. + let outcome = try AccessibilityAudit.run(app) { _ in Thread.sleep(forTimeInterval: 6) } + for (_, finding) in outcome.persistent { + XCTFail("\(name): \(finding.message)") + } + } + + @MainActor + private func launch(_ arguments: [String]) -> XCUIApplication { + let app = XCUIApplication() + app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoFreeze", "-uiDemoPhase", "spotConfirm"] + arguments + app.launch() + XCTAssertTrue(element(app, "screen.spotConfirm").waitForExistence(timeout: 15), "the spot check never appeared") + return app + } + + /// Scrolls the screen's scroll view until the whole of `element` is inside the window, the + /// same test the visibility assertions make. `isHittable` only needs the element's middle on + /// screen: at AX5 a line 249 pt tall passed it with its bottom 176 pt below the window (CI run + /// 37108821815). Each drag moves by the part that is off screen plus a margin, at most 60% of + /// the window, and holds at the end so no momentum carries the element past the other edge. + @MainActor + static func scrollOnScreen(_ element: XCUIElement, in app: XCUIApplication) { + let window = app.windows.firstMatch.frame + let scroll = app.scrollViews.firstMatch + let margin: CGFloat = 24 + let most = window.height * 0.6 + for _ in 0..<10 { + let frame = element.frame + guard !frame.isEmpty, !window.contains(frame) else { return } + // Negative drags move the content up, bringing what is below the window into view. + let drag = frame.maxY > window.maxY + ? -min(frame.maxY - window.maxY + margin, most) + : min(window.minY - frame.minY + margin, most) + let start = scroll.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)) + start.press(forDuration: 0.05, thenDragTo: start.withOffset(CGVector(dx: 0, dy: drag)), withVelocity: .slow, thenHoldForDuration: 0.3) + } + } + + /// A server answer in Fixtures/results, which the demo reads in debug builds. + private static func resultFile(_ name: String, file: String = #filePath) -> String { + URL(fileURLWithPath: file).deletingLastPathComponent().appending(path: "Fixtures/results/\(name).json").path + } + + @MainActor + private func element(_ app: XCUIApplication, _ identifier: String) -> XCUIElement { + app.descendants(matching: .any)[identifier].firstMatch + } + + @MainActor + private func attach(_ app: XCUIApplication, name: String) { + let shot = XCTAttachment(screenshot: app.screenshot()) + shot.name = name + shot.lifetime = .keepAlways + add(shot) + } +} diff --git a/ios/README.md b/ios/README.md index f8323e881..4b22c328b 100644 --- a/ios/README.md +++ b/ios/README.md @@ -24,7 +24,7 @@ Native iPhone app for the AR capture walk. The homeowner marks the electric mete | `-practiceMeter YES` or `NO` | Overrides the stored practice meter switch for one run (UserDefaults' argument domain). The full-flow UI tests pass `NO`, since the switch outlives a run in the Simulator. | | `-uiDemo` | Runs the screens on a scripted fake engine instead of the capture engine, for design work and for auditing states a replay can't reach. The arguments it takes are listed in `HouseScan/UI/Preview/UIDemo.swift`; for example `-uiDemoGroundQuestion` opens the feature review with the ground question showing, `-uiDemoPhase meterCloseUp -uiDemoMeterChoose` shows the meter number picker, and `-uiDemoPhase result -uiDemoCorner` shows the result model of a wall that turns a corner. | -**Spot check.** Before the result, the app shows the kept photo that best sees the answer's spot, with the spot and its clearance area outlined, and asks whether anything stands in front of the wall or on the ground there (`STATE=spotConfirm`). Photos without depth and the walked path claim wall, ground and clear space they never saw; "It's clear" backs those claims for that spot, and "Something's there" takes them back over the area, so scene.json reports it unseen, and sends the scan again. The rules are in `Runtime/ScanEngine+Confirm.swift` and HouseScanKit's `Confirm/SpotConfirmation.swift`. +**Spot check.** Before the result, the app shows the kept photo that best sees the answer's spot, with the spot and its clearance area outlined, and asks whether anything stands in front of the wall or on the ground there (`STATE=spotConfirm`). When no kept photo shows the area, it shows no photo and gives the area in words: where it runs beside the meter, how far out from the wall, and how high up the wall. Photos without depth and the walked path claim wall, ground and clear space they never saw; "It's clear" backs those claims for that spot, and "Something's there" takes them back over the area, so scene.json reports it unseen, and sends the scan again. The rules are in `Runtime/ScanEngine+Confirm.swift` and HouseScanKit's `Confirm/SpotConfirmation.swift`. **Practice meter.** For trying the whole scan where there is no electric meter, such as indoors. Debug and TestFlight builds show a wrench button at the top of the first screen. Tap it, turn on **Practice meter**, tap Done, then start the scan. At "find the meter", tap any spot on a wall: the usual raycast and refusals apply, and a drawn sample meter appears there, fixed to the meter's anchor, on every camera screen after that. The close-up keeps the live camera's pose and depth, but stores a photo of the sample (made-up maker SAMPLEWORKS, number 12345678), and the real reader reads it. The walk, fog, marks, requests, spot check, upload, result and AR all run for real. Every screen shows a "Practice meter" badge, and `scan-stamp.json` has `"practice": true`; the packet and scene.json are unchanged. The switch is kept until turned off, and the button reads "Practice meter on" while it is. App Store installs never show the button and ignore a stored switch: the app asks StoreKit's `AppTransaction` which server signed the install (sandbox means TestFlight) and treats no answer as the App Store. It is not in the iOS Settings app, because a Settings.bundle ships in the same binary that TestFlight later promotes to the App Store. The code is in `Runtime/Practice/`, `UI/Practice/` and HouseScanKit's `Practice/PracticeMeter.swift`.