From c13cf30944213e5a8cb97ae6eeba5998b73d86d7 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 3 Oct 2026 02:35:02 -0500 Subject: [PATCH 1/5] fix(ios): describe the spot check's area in words when no outline is drawn When no kept photo showed the spot, or there was no wall to draw on, the spot check still asked about "the blue outline", gave only the distance along the wall, and VoiceOver described the missing outline. The answer decides which clearance claims survive, so the homeowner has to know what space they are judging. One decision now drives the question, the picture and VoiceOver: the outline is drawn only with both a photo and a wall. Without one, the screen shows no photo, asks "Is anything standing in this space?", and lists the area's three extents: where it runs beside the meter, how far out from the wall (areaDepth) and how high up (spotHeight). The outlined photo's VoiceOver value now reads all three too. The words-only lines use the primary color, since Ink on Surface measured 1.06:1 in dark mode. An end of the span that rounds to 0 in now reads as the meter. Demo flags -uiDemoSpotNoPhoto and -uiDemoSpotNoWall reach those states; SpotConfirmUITests covers photo, no photo (default and AX5), no wall, left and straddling spans, and answering without a photo. Co-Authored-By: Claude Code --- ios/HouseScan/UI/Copy/SpotCheckCopy.swift | 51 ++++- ios/HouseScan/UI/Preview/DemoEngine.swift | 8 + .../UI/Screens/SpotConfirmScreen.swift | 126 +++++++---- .../Fixtures/results/spot-left.json | 203 ++++++++++++++++++ .../Fixtures/results/spot-straddle.json | 203 ++++++++++++++++++ ios/HouseScanUITests/SpotConfirmUITests.swift | 136 ++++++++++++ ios/README.md | 2 +- 7 files changed, 676 insertions(+), 53 deletions(-) create mode 100644 ios/HouseScanUITests/Fixtures/results/spot-left.json create mode 100644 ios/HouseScanUITests/Fixtures/results/spot-straddle.json create mode 100644 ios/HouseScanUITests/SpotConfirmUITests.swift diff --git a/ios/HouseScan/UI/Copy/SpotCheckCopy.swift b/ios/HouseScan/UI/Copy/SpotCheckCopy.swift index 9640f394a..cbb52ab49 100644 --- a/ios/HouseScan/UI/Copy/SpotCheckCopy.swift +++ b/ios/HouseScan/UI/Copy/SpotCheckCopy.swift @@ -2,11 +2,21 @@ 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. The guidance log records this text for every check. 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" static let spotClear = "It's clear" static let spotSomethingThere = "Something's there" static let spotClearHint = "Shows your result." @@ -22,19 +32,48 @@ 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 that rounds to 0 in is the meter itself, so -1...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 area's three extents, each readable on its own: along the wall from the meter, out + /// from the wall over the ground (`SpotCheck.areaDepth`, the deepest clearance zone holding + /// the spot), and up the wall face to the battery's height (`SpotCheck.spotHeight`). These are + /// the same edges `SpotOutline` draws on a photo. + 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..556fa6320 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 { @@ -22,16 +27,25 @@ struct SpotConfirmScreen: View { } } + /// The photo and the wall to outline the area on it with, or nil when either is missing. + /// The question, the picture and what VoiceOver reads all follow this one answer. + private func outlinable(_ check: SpotCheck) -> (photo: SpotCheck.Photo, wall: WallGeometry)? { + guard let photo = check.photo, let wall = state.wall else { return nil } + return (photo, wall) + } + private func content(_ check: SpotCheck) -> some View { - ScrollView { + let outline = outlinable(check) + let question = outline == nil ? ScanCopy.spotQuestionInPerson : ScanCopy.spotQuestion + 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 +54,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 +122,61 @@ 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. + private func space(_ check: SpotCheck) -> some View { + let extents = ScanCopy.spotSpace(check) + return VStack(alignment: .leading, spacing: 10) { + Label(ScanCopy.spotSpaceTitle, systemImage: "eye") + .font(Typeface.caption) + .foregroundStyle(Palette.muted) + VStack(alignment: .leading, spacing: 6) { + spaceLine(extents.along) + spaceLine(extents.out) + spaceLine(extents.up) } } + .padding(18) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Palette.surface, in: .rect(cornerRadius: Metrics.cardRadius, style: .continuous)) .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") + .accessibilityLabel(ScanCopy.spotSpaceTitle) + .accessibilityValue(ScanCopy.spotSpaceSpoken(check)) + .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) -> some View { + Text(text) + .font(Typeface.hint) + .fixedSize(horizontal: false, vertical: true) } /// The two answers, or once answered, what happens next. 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..5deeca66c --- /dev/null +++ b/ios/HouseScanUITests/SpotConfirmUITests.swift @@ -0,0 +1,136 @@ +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 review sample's area, read aloud. + 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.sampleSpace) + try checkInWords(["-uiDemoSpotNoPhoto"] + Self.largestText, name: "spotConfirm-noPhoto-AX5", spoken: Self.sampleSpace) + } + + /// 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.sampleSpace) + } + + @MainActor + func testTheAreaInWordsNamesTheSideOfTheMeter() throws { + try checkInWords(["-uiDemoResultFile", Self.resultFile("spot-left"), "-uiDemoSpotNoPhoto"], name: "spotConfirm-noPhoto-left", + spoken: "From 3 feet 6 inches to 6 feet left of your meter, from the wall out to 2 feet, and from the ground up to 3 feet 6 inches.") + try checkInWords(["-uiDemoResultFile", Self.resultFile("spot-straddle"), "-uiDemoSpotNoPhoto"], name: "spotConfirm-noPhoto-straddle", + spoken: "From 1 foot 3 inches left to 1 foot 6 inches right of your meter, from the wall out to 2 feet, and 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, the area + /// read aloud in full, both answers present, and a clean accessibility audit. + @MainActor + private func checkInWords(_ arguments: [String], name: String, spoken: String) throws { + let app = launch(arguments) + defer { app.terminate() } + let area = element(app, "spot.area") + guard 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") + let read = ElementRead.snapshot(area) + XCTAssertEqual(read?.label, "Where to look", name) + XCTAssertEqual(read?.value as? String, spoken, name) + 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, and the area's value is checked in full above. + 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 + } + + /// 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`. From e1bc23a0ee0fa9eff66c544daf574ffd444e92e0 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 3 Oct 2026 02:43:46 -0500 Subject: [PATCH 2/5] docs(ios): correct the spot area's rounding example and scope A -1 in end rounds to 1 in, not 0, so the example contradicted the rule. The extents comment now says it describes the outlined area only, not every clearance the server checks. Co-Authored-By: Claude Code --- ios/HouseScan/UI/Copy/SpotCheckCopy.swift | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/ios/HouseScan/UI/Copy/SpotCheckCopy.swift b/ios/HouseScan/UI/Copy/SpotCheckCopy.swift index cbb52ab49..7dcce9445 100644 --- a/ios/HouseScan/UI/Copy/SpotCheckCopy.swift +++ b/ios/HouseScan/UI/Copy/SpotCheckCopy.swift @@ -33,9 +33,9 @@ extension ScanCopy { 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 along the wall, nearer - /// end first. An end that rounds to 0 in is the meter itself, so -1...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. + /// 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" } @@ -52,10 +52,11 @@ extension ScanCopy { return "From \(length(low)) left to \(length(high)) right of your meter" } - /// The area's three extents, each readable on its own: along the wall from the meter, out - /// from the wall over the ground (`SpotCheck.areaDepth`, the deepest clearance zone holding - /// the spot), and up the wall face to the battery's height (`SpotCheck.spotHeight`). These are - /// the same edges `SpotOutline` draws on a 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 ( From c1b2566c2417773ce232fc8d66e5973f9cb68a48 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 3 Oct 2026 03:10:36 -0500 Subject: [PATCH 3/5] build(ios): exclude the two new spot fixtures from the UI test bundle The synced HouseScanUITests folder lists each excluded fixture by name, so XcodeGen 2.46.0 adds spot-left.json and spot-straddle.json. These are the exact lines CI generated in run 37107514444; the resulting blob is 21c9c0a. Co-Authored-By: Claude Code --- ios/HouseScan.xcodeproj/project.pbxproj | 2 ++ 1 file changed, 2 insertions(+) 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", From 659f668c0b24d3d5c036b09cdc687db64533b54f Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 3 Oct 2026 03:10:36 -0500 Subject: [PATCH 4/5] fix(ios): log the spot question shown, and expose each area line The guidance log always recorded the outline question, and built the request before the photo loaded, so a check asked in words was logged as "inside the blue outline", and a photo that failed to load was logged as shown. The request and pending record are now built after the photo loads, from SpotCheck.outline(on:), the same decision the screen uses: the question shown, and a photo ID only when that photo is outlined. Each of the words-only card's three lines is now its own accessibility element with a spoken label, so VoiceOver steps through them and the UI tests and audit see the rendered text. The tests check each line's label and that it is on screen, scrolling to it at AX5. Co-Authored-By: Claude Code --- .../Runtime/ScanEngine+Confirm.swift | 25 ++++++--- ios/HouseScan/UI/Copy/SpotCheckCopy.swift | 7 ++- .../UI/Screens/SpotConfirmScreen.swift | 44 +++++++++------- ios/HouseScanUITests/SpotConfirmUITests.swift | 51 +++++++++++++------ 4 files changed, 85 insertions(+), 42 deletions(-) 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 7dcce9445..3aa9398f5 100644 --- a/ios/HouseScan/UI/Copy/SpotCheckCopy.swift +++ b/ios/HouseScan/UI/Copy/SpotCheckCopy.swift @@ -3,7 +3,7 @@ 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, when the area is - /// outlined on a photo. The guidance log records this text for every check. + /// 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." @@ -17,6 +17,11 @@ extension ScanCopy { 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." diff --git a/ios/HouseScan/UI/Screens/SpotConfirmScreen.swift b/ios/HouseScan/UI/Screens/SpotConfirmScreen.swift index 556fa6320..51de92335 100644 --- a/ios/HouseScan/UI/Screens/SpotConfirmScreen.swift +++ b/ios/HouseScan/UI/Screens/SpotConfirmScreen.swift @@ -27,16 +27,9 @@ struct SpotConfirmScreen: View { } } - /// The photo and the wall to outline the area on it with, or nil when either is missing. - /// The question, the picture and what VoiceOver reads all follow this one answer. - private func outlinable(_ check: SpotCheck) -> (photo: SpotCheck.Photo, wall: WallGeometry)? { - guard let photo = check.photo, let wall = state.wall else { return nil } - return (photo, wall) - } - private func content(_ check: SpotCheck) -> some View { - let outline = outlinable(check) - let question = outline == nil ? ScanCopy.spotQuestionInPerson : ScanCopy.spotQuestion + let outline = check.outline(on: state.wall) + let question = ScanCopy.spotQuestionShown(outlined: outline != nil) return ScrollView { VStack(alignment: .leading, spacing: 20) { badges(check) @@ -149,34 +142,39 @@ struct SpotConfirmScreen: View { .accessibilityIdentifier("spot.photo") } - /// The area in words, in the photo's place: the three edges the outline would draw. + /// 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 extents = ScanCopy.spotSpace(check) + let shown = ScanCopy.spotSpace(check) + let spoken = ScanCopy.spotSpace(check, spoken: true) return VStack(alignment: .leading, spacing: 10) { Label(ScanCopy.spotSpaceTitle, systemImage: "eye") .font(Typeface.caption) .foregroundStyle(Palette.muted) + .accessibilityAddTraits(.isHeader) + .accessibilityIdentifier("spot.area.title") VStack(alignment: .leading, spacing: 6) { - spaceLine(extents.along) - spaceLine(extents.out) - spaceLine(extents.up) + spaceLine(shown.along, spoken: spoken.along, id: "along") + spaceLine(shown.out, spoken: spoken.out, id: "out") + spaceLine(shown.up, spoken: spoken.up, id: "up") } } .padding(18) .frame(maxWidth: .infinity, alignment: .leading) .background(Palette.surface, in: .rect(cornerRadius: Metrics.cardRadius, style: .continuous)) - .accessibilityElement(children: .ignore) - .accessibilityLabel(ScanCopy.spotSpaceTitle) - .accessibilityValue(ScanCopy.spotSpaceSpoken(check)) + .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) -> some View { + 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. @@ -206,6 +204,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/SpotConfirmUITests.swift b/ios/HouseScanUITests/SpotConfirmUITests.swift index 5deeca66c..e17879e35 100644 --- a/ios/HouseScanUITests/SpotConfirmUITests.swift +++ b/ios/HouseScanUITests/SpotConfirmUITests.swift @@ -12,7 +12,17 @@ import XCTest final class SpotConfirmUITests: XCTestCase { private static let largestText = ["-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryAccessibilityXXXL"] - /// The review sample's area, read aloud. + /// 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() { @@ -37,23 +47,25 @@ final class SpotConfirmUITests: XCTestCase { @MainActor func testWithoutAPhotoTheAreaIsGivenInWords() throws { - try checkInWords(["-uiDemoSpotNoPhoto"], name: "spotConfirm-noPhoto", spoken: Self.sampleSpace) - try checkInWords(["-uiDemoSpotNoPhoto"] + Self.largestText, name: "spotConfirm-noPhoto-AX5", spoken: Self.sampleSpace) + 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.sampleSpace) + 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: "From 3 feet 6 inches to 6 feet left of your meter, from the wall out to 2 feet, and from the ground up to 3 feet 6 inches.") + 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: "From 1 foot 3 inches left to 1 foot 6 inches right of your meter, from the wall out to 2 feet, and from the ground up to 3 feet 6 inches.") + 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. @@ -74,27 +86,36 @@ final class SpotConfirmUITests: XCTestCase { // MARK: - Helpers - /// The words-only spot check: no photo element, a question that names no outline, the area - /// read aloud in full, both answers present, and a clean accessibility audit. + /// 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: String) throws { + private func checkInWords(_ arguments: [String], name: String, spoken: Lines) throws { let app = launch(arguments) defer { app.terminate() } - let area = element(app, "spot.area") - guard area.waitForExistence(timeout: 10) else { + 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") - let read = ElementRead.snapshot(area) - XCTAssertEqual(read?.label, "Where to look", name) - XCTAssertEqual(read?.value as? String, spoken, name) + 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. Slowly, as + // ScreenStatesUITests does, so momentum doesn't carry the line past the top. + for _ in 0..<6 where !line.isHittable { app.scrollViews.firstMatch.swipeUp(velocity: .slow) } + 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, and the area's value is checked in full above. + // 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") From 803535db077999938f651acf68a9c9ee810849fd Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 3 Oct 2026 04:18:29 -0500 Subject: [PATCH 5/5] fix(ios): read the area card's title as one element, and scroll each line fully on screen CI run 37108821815 failed SpotConfirmUITests in two ways. The "Where to look" title read as "Show": the identifier on Label(_:systemImage:) reached the "eye" symbol, whose VoiceOver name is "Show". The symbol is now hidden and the label combined, so the title is one header element read as its words. At AX5 the area lines failed the whole-line visibility check: the test scrolled until isHittable, which needs only the element's middle on screen, and a 249 pt line stopped with its bottom off the window. The test now drags by the part off screen until the whole frame is inside the window, the condition it asserts. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../UI/Screens/SpotConfirmScreen.swift | 17 +++++++---- ios/HouseScanUITests/SpotConfirmUITests.swift | 28 +++++++++++++++++-- 2 files changed, 37 insertions(+), 8 deletions(-) diff --git a/ios/HouseScan/UI/Screens/SpotConfirmScreen.swift b/ios/HouseScan/UI/Screens/SpotConfirmScreen.swift index 51de92335..3233b0c16 100644 --- a/ios/HouseScan/UI/Screens/SpotConfirmScreen.swift +++ b/ios/HouseScan/UI/Screens/SpotConfirmScreen.swift @@ -149,11 +149,18 @@ struct SpotConfirmScreen: View { let shown = ScanCopy.spotSpace(check) let spoken = ScanCopy.spotSpace(check, spoken: true) return VStack(alignment: .leading, spacing: 10) { - Label(ScanCopy.spotSpaceTitle, systemImage: "eye") - .font(Typeface.caption) - .foregroundStyle(Palette.muted) - .accessibilityAddTraits(.isHeader) - .accessibilityIdentifier("spot.area.title") + // 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") diff --git a/ios/HouseScanUITests/SpotConfirmUITests.swift b/ios/HouseScanUITests/SpotConfirmUITests.swift index e17879e35..4f2083ef5 100644 --- a/ios/HouseScanUITests/SpotConfirmUITests.swift +++ b/ios/HouseScanUITests/SpotConfirmUITests.swift @@ -103,9 +103,8 @@ final class SpotConfirmUITests: XCTestCase { 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. Slowly, as - // ScreenStatesUITests does, so momentum doesn't carry the line past the top. - for _ in 0..<6 where !line.isHittable { app.scrollViews.firstMatch.swipeUp(velocity: .slow) } + // 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 @@ -137,6 +136,29 @@ final class SpotConfirmUITests: XCTestCase { 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