From 21ac61ec5776bae4081d2031c649d394318ee57b Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 3 Oct 2026 21:17:08 -0500 Subject: [PATCH 1/5] ios: at the largest text sizes, the wall-end card folds so the circle stays in view UNVALIDATED: not compiled; no package, app or UI tests have run. CI run 37165788075 on 15cf658e (iPhone 17) showed the card covering the circle in the middle of the camera at AX5: on the walk's "Is this the right end of the wall?" (`wallWalk-markEnd-AX5-stacked`) and on a past_end request's offer and refusal (`gapRequest-pastEnd-AX5`, `-refused-AX5`). "Wall ends here" was reachable, but the circle it marks at was hidden. Folding alone didn't fix it: in the folded walk frame a three-line title, Details and the card's reply still ended at 490 pt, past the circle's top at 409 pt (874 pt screen, 56 pt circle at its middle). So, on those two steps at the accessibility sizes, using each screen's existing `aims` and `Instruction.folded`, with no change to CameraChrome or InstructionCard: - The card leads with two lines: "Aim at the right end"; "Keep walking right" on a past_end request; and a refusal's correction: "Aim at the wall", "Turn to the right end", "Wait a moment", "Too short to use". Every word of the unfolded card is under Details (`ScanCopy.endAimFold`). - The reply ("The wall keeps going", "I can't get there") moves from the card to the actions under "Wall ends here". A past_end request's "One more view to finish" leads the words under Details. - Refusals fold too: before, they kept every word and covered the circle. - The default sizes are unchanged. The circle is an accessibility element ("Aiming circle", `aim.circle`), at the call site rather than in `Reticle`. UI tests read its real frame and check that it is the middle of the window, where `circleEnd`'s ray goes; that it is wholly on screen; and that it is clear of the card and every action wherever "Wall ends here" can be pressed. They cover default and AX5 on the walk's end and the past_end offer and refusal, a left past_end request (`-uiDemoGap pastEndLeft`), Details opening every word and closing back to an open circle, the decline's new place, and the end question keeping its three answers in order with no circle. The demo's past_end request now carries its answer and eyebrow, as the real one does. Not tested: Reduce Motion (no hook can force it in a UI test; the moved decline joins the end controls' existing animation, which is off under Reduce Motion), VoiceOver speech, physical aiming, and screens other than the iPhone 17 at AX5. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../UI/Components/EndAimCircle.swift | 16 +++ ios/HouseScan/UI/Copy/ScanCopy.swift | 44 ++++++-- ios/HouseScan/UI/Preview/DemoEngine.swift | 31 ++++-- .../UI/Screens/GapRequestScreen.swift | 51 ++++++++- ios/HouseScan/UI/Screens/WallWalkScreen.swift | 37 +++++-- .../EndAimCircleAssertions.swift | 36 +++++++ .../PastEndRecoveryUITests.swift | 101 ++++++++++++++++-- .../WalkRecoveryUITests.swift | 85 +++++++++++++++ 8 files changed, 367 insertions(+), 34 deletions(-) create mode 100644 ios/HouseScan/UI/Components/EndAimCircle.swift create mode 100644 ios/HouseScanUITests/EndAimCircleAssertions.swift diff --git a/ios/HouseScan/UI/Components/EndAimCircle.swift b/ios/HouseScan/UI/Components/EndAimCircle.swift new file mode 100644 index 000000000..810649c08 --- /dev/null +++ b/ios/HouseScan/UI/Components/EndAimCircle.swift @@ -0,0 +1,16 @@ +import SwiftUI + +extension View { + /// The circle a mark lands under (`Reticle`), as one accessibility element: "Aiming circle". + /// The reticle itself is hidden from VoiceOver as decoration. Here it says where "Wall ends + /// here" and the other marks land, and UI tests read its real frame to check that nothing + /// covers it (`PastEndRecoveryUITests`, `ScreenStatesUITests`). Apply it before the reticle + /// is stretched to the screen, so the element's frame is the circle's. `shown` false while + /// the circle is faded out, so VoiceOver doesn't stop on what isn't there. + func endAimCircle(shown: Bool = true) -> some View { + accessibilityElement(children: .ignore) + .accessibilityLabel("Aiming circle") + .accessibilityIdentifier("aim.circle") + .accessibilityHidden(!shown) + } +} diff --git a/ios/HouseScan/UI/Copy/ScanCopy.swift b/ios/HouseScan/UI/Copy/ScanCopy.swift index b981b38c6..a2dea8332 100644 --- a/ios/HouseScan/UI/Copy/ScanCopy.swift +++ b/ios/HouseScan/UI/Copy/ScanCopy.swift @@ -71,7 +71,8 @@ enum ScanCopy { case .markEnd(let side): Instruction( title: "Is this the \(side.rawValue) end of the wall?", - detail: "Aim where it stops or turns a corner and tap Wall ends here. If it goes on, tap The wall keeps going." + detail: "Aim where it stops or turns a corner and tap Wall ends here. If it goes on, tap The wall keeps going.", + folded: endAimFold("Aim at the \(side.rawValue) end", "Is this the \(side.rawValue) end of the wall? Aim where it stops or turns a corner and tap Wall ends here. If it goes on, tap The wall keeps going.") ) case .aimAtGround(let s): // A cell counts once seen from two places at least 0.25 m apart (`coveringBaseline`), @@ -389,23 +390,46 @@ enum ScanCopy { static let wallKeepsGoing = "The wall keeps going" /// Why "Wall ends here" marked nothing, and what to do instead (`EndMarkRefusal`). + /// Folded at the largest text sizes, each refusal leads with what to do about it, and its + /// reason and the full words go under Details (`endAimFold`). static func endMarkRefusal(_ refusal: EndMarkRefusal, asked side: WallSide) -> Instruction { switch refusal { case .noWall: - Instruction(title: "The circle isn't on the wall", detail: "Aim it at the wall where it stops or turns, then tap Wall ends here.") + Instruction( + title: "The circle isn't on the wall", detail: "Aim it at the wall where it stops or turns, then tap Wall ends here.", + folded: endAimFold("Aim at the wall", "The circle isn't on the wall. Aim it at the wall where it stops or turns, then tap Wall ends here.")) case .otherSide(let landed): - Instruction(title: "That's the \(landed.rawValue) side of your meter", detail: "Turn to the \(side.rawValue) end, then tap Wall ends here.") + Instruction( + title: "That's the \(landed.rawValue) side of your meter", detail: "Turn to the \(side.rawValue) end, then tap Wall ends here.", + folded: endAimFold("Turn to the \(side.rawValue) end", "That's the \(landed.rawValue) side of your meter. Turn to the \(side.rawValue) end, then tap Wall ends here.")) case .trackingNotReady: - Instruction(title: "One moment, your phone is still finding its place", detail: "Then aim at the \(side.rawValue) end.") + Instruction( + title: "One moment, your phone is still finding its place", detail: "Then aim at the \(side.rawValue) end.", + folded: endAimFold("Wait a moment", "Your phone is still finding its place. Then aim at the \(side.rawValue) end.")) case .tooLittleWall: // Says what the app can't do, not where to aim: the homeowner must not be nudged to // mark an end farther than the wall really goes. "I can't get there" stays the way on. // The app's own minimum (`WallFrame.minWallLength`), not a measured installation rule: - // no reason is given for it, and rule distances belong to the server. - Instruction(title: "House Scan can't use a wall that short", detail: "If this is where the wall ends, tap I can't get there to continue.") + // no reason is given for it, and rule distances belong to the server. Folded, the lead + // says only that; aiming elsewhere is right only if the circle was off the real end, + // so it doesn't lead. + Instruction( + title: "House Scan can't use a wall that short", detail: "If this is where the wall ends, tap I can't get there to continue.", + folded: endAimFold("Too short to use", "House Scan can't use a wall that short. If this is where the wall ends, tap I can't get there to continue.")) } } + /// The words of a step that marks the wall's end at the circle, folded at the largest text + /// sizes (`InstructionCard.foldsDetail`): a lead short enough to stay two lines, even beside + /// the refusal triangle, and every word of the unfolded card under Details. Unfolded, the + /// card covered the circle in the middle of the camera at AX5 (CI run 37165788075, iPhone + /// 17): the circle's top is 28 pt above the middle, and a two-line card without its reply + /// ends above it, where one with a third line or the reply doesn't (`GapRequestScreen`, + /// `WallWalkScreen` move the reply to their actions at those sizes). + static func endAimFold(_ lead: String, _ everything: String) -> Instruction.Folded { + Instruction.Folded(title: lead, detail: everything) + } + static func nextWallRefusal(_ refusal: NextWallRefusal) -> Instruction { switch refusal { case .noSurface: Instruction(title: "No wall under the circle", detail: "Step closer and aim at the next wall.") @@ -462,8 +486,12 @@ enum ScanCopy { let title = gap.band == .ground ? "Show the ground \(stretch)" : "Show the wall \(stretch)" // A past_end request asks to walk on past an end; the wall may really stop before // that, and the screen offers the walk's own "Wall ends here" for it (B-12). - guard gap.pastEndSide != nil else { return Instruction(title: title, detail: detail) } - return Instruction(title: title, detail: "\(detail) \(pastEndAlternative)") + guard let side = gap.pastEndSide else { return Instruction(title: title, detail: detail) } + // Folded, walking on leads, as the request asks; marking the end is under Details, + // with the circle and "Wall ends here" in view. + return Instruction( + title: title, detail: "\(detail) \(pastEndAlternative)", + folded: endAimFold("Keep walking \(side.rawValue)", "\(title). \(detail) \(pastEndAlternative)")) case .groundOut(let out): return Instruction( title: "Show the ground out to about \(Distance.feetAtLeast(out)) from the wall", diff --git a/ios/HouseScan/UI/Preview/DemoEngine.swift b/ios/HouseScan/UI/Preview/DemoEngine.swift index 77d39f6db..2a943ef2e 100644 --- a/ios/HouseScan/UI/Preview/DemoEngine.swift +++ b/ios/HouseScan/UI/Preview/DemoEngine.swift @@ -26,7 +26,8 @@ final class DemoEngine: ScanActions { /// `-uiDemoUnusableAnswer `: how many answers in a row House Scan can't use; 0 when off. private let unusableAnswers: Int /// Which request the gap screen shows (`-uiDemoGap`); the phone's ground request by default. - /// `pastEnd` is a server past_end request on the right, with "Wall ends here" (B-12). + /// `pastEnd` is a server past_end request on the right, with "Wall ends here" (B-12); + /// `pastEndLeft` the same on the left. private let gapKind: String? /// `-uiDemoEndMarkRefusal`: "Wall ends here" was pressed with the circle off the wall, and the /// demo's circle stays off it, so pressing again refuses again. @@ -422,25 +423,33 @@ final class DemoEngine: ScanActions { state.gap?.band = .wall state.target = DemoScene.wall.world(s: 1.7, height: 3.0) state.path = [] - case "pastEnd": - // The check asks to walk on past the right end, which it clears, as the real engine's + case "pastEnd", "pastEndLeft": + // The check asks to walk on past an end, which it clears, as the real engine's // `beginServerGap` does; the wall may stop sooner, so "Wall ends here" is offered. - let end = demoRightEnd - let past: ClosedRange = end...(end + 0.6) - state.wall?.rightEnd = nil + let side: WallSide = gapKind == "pastEndLeft" ? .left : .right + let sign: Float = side == .right ? 1 : -1 + let end = side == .right ? demoRightEnd : demoLeftEnd + let past: ClosedRange = side == .right ? end...(end + 0.6) : (end - 0.6)...end + if side == .right { state.wall?.rightEnd = nil } else { state.wall?.leftEnd = nil } state.gap?.origin = .server - state.gap?.reason = .server(detail: "Keep walking past the right end of the scan (14 ft 1 in right of the meter): a spot within reach may be there.") + state.gap?.reason = .server(detail: side == .right + ? "Keep walking past the right end of the scan (14 ft 1 in right of the meter): a spot within reach may be there." + : "Keep walking past the left end of the scan (9 ft 6 in left of the meter): a spot within reach may be there.") state.gap?.span = past - state.gap?.pastEndSide = .right - state.target = DemoScene.wall.world(s: end + 0.3, height: 0, out: 0.5) - state.path = DemoScene.path(toward: end + 0.3) + state.gap?.pastEndSide = side + // The check sends a past_end request with its answer, so the card says "One more view + // to finish" over it, as the real engine's does (`GapRequestScreen.followUps`). + state.result = sample + state.followUps = 1 + state.target = DemoScene.wall.world(s: end + sign * 0.3, height: 0, out: 0.5) + state.path = DemoScene.path(toward: end + sign * 0.3) if endMarkRefused { // As in the real engine, a circle off the wall has no end preview (`circleEnd`). state.endMarkRefusal = .noWall state.endPreview = nil } else { // The circle on the wall a little before the old end: where the wall really stops. - state.endPreview = EndPreview(side: .right, s: end - 0.4, atReticle: true) + state.endPreview = EndPreview(side: side, s: end - sign * 0.4, atReticle: true) } return default: diff --git a/ios/HouseScan/UI/Screens/GapRequestScreen.swift b/ios/HouseScan/UI/Screens/GapRequestScreen.swift index 39d635e00..d0aa2b79e 100644 --- a/ios/HouseScan/UI/Screens/GapRequestScreen.swift +++ b/ios/HouseScan/UI/Screens/GapRequestScreen.swift @@ -38,6 +38,7 @@ struct GapRequestScreen: View { // Motion only its opacity changes, briefly: nothing moves. if state.gap?.pastEndSide != nil { Reticle(diameter: 56) + .endAimCircle(shown: offersEndMark) .frame(maxWidth: .infinity, maxHeight: .infinity) .ignoresSafeArea() .opacity(offersEndMark ? 1 : 0) @@ -52,7 +53,7 @@ struct GapRequestScreen: View { CameraChrome( instruction: instruction, tone: tone, - reply: state.gap?.isSatisfied == true || asking || askingEnd ? nil : InstructionCard.Reply( + reply: state.gap?.isSatisfied == true || asking || askingEnd || endAimFolds ? nil : InstructionCard.Reply( title: "I can't get there", identifier: "action.skipGap", hint: skipHint, @@ -61,7 +62,7 @@ struct GapRequestScreen: View { // as the phone moves: neither locks the reply again (InstructionCard.replyLock). task: state.gap.map { ScanCopy.gapTask($0) } ), - eyebrow: followUps > 0 && state.gap?.isSatisfied != true ? ScanCopy.followUp(remaining: followUps) : nil, + eyebrow: followUps > 0 && state.gap?.isSatisfied != true && !endAimFolds ? ScanCopy.followUp(remaining: followUps) : nil, photoCount: state.captureCount, lastCaptureID: state.lastCapture?.id, isReplay: state.isReplay, @@ -85,6 +86,9 @@ struct GapRequestScreen: View { if offersEndMark { markEndButton } + if endAimFolds { + declineButton + } } .animation(reduceMotion ? nil : Motion.settle, value: askingEnd) .animation(reduceMotion ? nil : Motion.settle, value: offersEndMark) @@ -174,6 +178,32 @@ struct GapRequestScreen: View { .transition(.opacity) } + /// "I can't get there" among the actions, under "Wall ends here", while the card folds around + /// the circle (`endAimFolds`): the same words, hint and action as the card's reply. + private var declineButton: some View { + Button { + actions.skipGap() + } label: { + Text("I can't get there") + .frame(maxWidth: .infinity) + } + .buttonStyle(.secondary) + .accessibilityHint(skipHint) + .accessibilityIdentifier("action.skipGap") + .transition(.opacity) + } + + /// At the largest text sizes while "Wall ends here" is offered, the card folds to a two-line + /// lead, as on the walk's end, so the circle in the middle of the camera stays open: the + /// request's words and its "One more view to finish" go under Details, and "I can't get + /// there" moves from the card to the actions. With either still on the card it reached past + /// the circle (`ScanCopy.endAimFold`). Not while coaching replaces the request: then the card + /// says why nothing can be aimed yet. + private var endAimFolds: Bool { + guard offersEndMark, typeSize.isAccessibilitySize else { return false } + return !(coaching.map(ScanCopy.coachingReplacesTask) ?? false) + } + /// A refusal on "Wall ends here" keeps its red triangle, as on the walk; otherwise coaching /// marks the card. private var tone: InstructionCard.Tone { @@ -212,9 +242,13 @@ struct GapRequestScreen: View { /// needs" reading or "tap I can't get there", tilting up to the roof or sky, and a server's /// own words, which this screen can't judge. Nor the overhead question, a request already /// seen, or coaching that replaces the request. + /// + /// A past_end request offering "Wall ends here" aims too, refusal or not (`endAimFolds`). private var aims: Bool { - guard let gap = state.gap, !asking, !askingEnd, refusal == nil, !gap.isSatisfied else { return false } + guard let gap = state.gap, !asking, !askingEnd, !gap.isSatisfied else { return false } if let coaching, ScanCopy.coachingReplacesTask(coaching) { return false } + if offersEndMark { return true } + guard refusal == nil else { return false } if case .groundNearCandidate = gap.reason { return true } return false } @@ -224,6 +258,17 @@ struct GapRequestScreen: View { /// can stay up for a whole night request, and replacing the card hid the request and "Got it, /// thanks" for as long as it did (field test 4.1, run 3). private var instruction: Instruction { + var card = requestCard + // Folded around the circle, "One more view to finish" leaves the card's top (`endAimFolds`) + // and leads the words under Details. + if endAimFolds, followUps > 0, let folded = card.folded { + card.folded?.detail = "\(ScanCopy.followUp(remaining: followUps)). \(folded.detail)" + } + return card + } + + /// The card's words before folding around the circle moves the eyebrow (`instruction`). + private var requestCard: Instruction { if asking { return ScanCopy.overheadQuestion } if askingEnd, let side = state.endQuestion { return ScanCopy.endQuestion(side) } if let coaching, ScanCopy.coachingReplacesTask(coaching) { return ScanCopy.coaching(coaching, meterPhoto: meterPhoto != nil) } diff --git a/ios/HouseScan/UI/Screens/WallWalkScreen.swift b/ios/HouseScan/UI/Screens/WallWalkScreen.swift index ae42499a6..7c29e652f 100644 --- a/ios/HouseScan/UI/Screens/WallWalkScreen.swift +++ b/ios/HouseScan/UI/Screens/WallWalkScreen.swift @@ -34,6 +34,7 @@ struct WallWalkScreen: View { // asks for it) lands under the circle. if state.marking != nil || controlsKey == .nextWall || controlsKey == .markEnd { Reticle(diameter: 56) + .endAimCircle() .frame(maxWidth: .infinity, maxHeight: .infinity) .ignoresSafeArea() TapRipple(ripples: $taps) @@ -136,21 +137,32 @@ struct WallWalkScreen: View { /// place, and a step to the side moves into the title when it is needed (`ScanCopy.aim`). /// Looking past an obstruction folds the other way: its action ("Look around it") leads and /// the situation and how go under Details (`Instruction.folded`), since unfolded at AX5 it - /// covered the camera and the spot it asks about. Not where the second line is the action - /// and nothing leads with it: a step back added to an aim step, the wall's end, whose title - /// only asks ("Aim where the wall stops"), marking the next wall. Nor a question, a mark, the - /// tray, coaching that replaces the task, or a refusal. + /// covered the camera and the spot it asks about. The wall's end folds the same way, around + /// the circle (`endAimFolds`). Not where the second line is the action and nothing leads with + /// it: a step back added to an aim step, marking the next wall. Nor a question, a mark, the + /// tray, coaching that replaces the task, or a refusal other than the end's. private var aims: Bool { switch controlsKey { case .walking, .finish: if let coaching, ScanCopy.coachingReplacesTask(coaching) { return false } if state.guidanceHint?.stepBack == true { return false } return tone != .refusal - case .markEnd, .nextWall, .marking, .endScanQuestion, .endQuestion, .overheadQuestion, .nextWallConfirm, .tray: + case .markEnd: + // Folds around the circle, refusal or not (`endAimFolds`). + if let coaching, ScanCopy.coachingReplacesTask(coaching) { return false } + return true + case .nextWall, .marking, .endScanQuestion, .endQuestion, .overheadQuestion, .nextWallConfirm, .tray: return false } } + /// At the largest text sizes on "Is this the right end of the wall?", the card folds to a + /// two-line lead ("Aim at the right end", or a refusal's correction) so the circle in the + /// middle of the camera stays open, and "The wall keeps going" moves from the card to the + /// actions under "Wall ends here": with it on the card, or the unfolded words, the card + /// reached past the circle (`ScanCopy.endAimFold`). As on a past_end request. + private var endAimFolds: Bool { controlsKey == .markEnd && aims && typeSize.isAccessibilitySize } + // MARK: Controls private enum ControlsKey: Hashable { @@ -338,6 +350,19 @@ struct WallWalkScreen: View { .accessibilityHint("Marks the end of the wall at the circle in the middle of the screen") .accessibilityIdentifier("action.markEnd") .transition(.opacity) + if endAimFolds, let copy = ScanCopy.reply(for: state.guidance) { + // The card's reply, moved here while the card folds around the circle. + Button { + actions.cannotAccessArea() + } label: { + Text(copy.title) + .spansStack(stacksActions) + } + .buttonStyle(.secondary) + .accessibilityHint(copy.hint) + .accessibilityIdentifier("action.cannotAccess") + .transition(.opacity) + } case .walking where offersEndHere: Button { actions.endWallHere() @@ -403,7 +428,7 @@ struct WallWalkScreen: View { /// every step. private var reply: InstructionCard.Reply? { guard state.marking == nil, !state.endScanQuestion, state.endQuestion == nil, !state.overheadQuestion, state.nextWallConfirm == nil, !coachingHidesReply, !trayOpen, - let copy = ScanCopy.reply(for: state.guidance) else { return nil } + !endAimFolds, let copy = ScanCopy.reply(for: state.guidance) else { return nil } return InstructionCard.Reply( title: copy.title, identifier: "action.cannotAccess", diff --git a/ios/HouseScanUITests/EndAimCircleAssertions.swift b/ios/HouseScanUITests/EndAimCircleAssertions.swift new file mode 100644 index 000000000..28d02767c --- /dev/null +++ b/ios/HouseScanUITests/EndAimCircleAssertions.swift @@ -0,0 +1,36 @@ +import XCTest + +/// The circle "Wall ends here" marks at (`endAimCircle`, identifier `aim.circle`), read from the +/// production layout: the app's own view, as VoiceOver sees it, not a test-only overlay. +extension XCTestCase { + /// Checks the circle is drawn in the middle of the window, where the ray "Wall ends here" uses + /// goes through (`ScanEngine.circleEnd`: the middle of the sensor image, which the camera view + /// fills and centres), is wholly on screen, and is under none of `covers`: the card's words, + /// its Details, and the actions. The card's scrim runs `cardPadding` below the last of its + /// parts, so the circle must clear that too. + @MainActor + func assertEndCircleOpen( + _ app: XCUIApplication, covers: [String], _ context: String, + file: StaticString = #filePath, line: UInt = #line + ) { + let window = app.windows.firstMatch.frame + let circle = app.descendants(matching: .any)["aim.circle"].firstMatch + XCTAssertTrue(circle.waitForExistence(timeout: 5), "\(context): no aiming circle", file: file, line: line) + let frame = circle.frame + XCTAssertEqual(frame.midX, window.midX, accuracy: 1, "\(context): the circle \(frame) must be the middle of the camera", file: file, line: line) + XCTAssertEqual(frame.midY, window.midY, accuracy: 1, "\(context): the circle \(frame) must be the middle of the camera", file: file, line: line) + XCTAssertTrue(window.contains(frame), "\(context): the circle \(frame) is not wholly on screen", file: file, line: line) + let card = ["instruction", "instruction.details"].map { app.descendants(matching: .any)[$0].firstMatch }.filter(\.exists) + if let cardBottom = card.map(\.frame.maxY).max() { + XCTAssertGreaterThanOrEqual(frame.minY, cardBottom + Self.cardPadding, "\(context): the circle \(frame) is under the card, which ends at \(cardBottom + Self.cardPadding)", file: file, line: line) + } + for identifier in covers { + let cover = app.descendants(matching: .any)[identifier].firstMatch + guard cover.exists else { continue } + XCTAssertFalse(frame.intersects(cover.frame), "\(context): the circle \(frame) is under \(identifier) \(cover.frame)", file: file, line: line) + } + } + + /// The card's scrim below Details without a reply (`InstructionCard.details`, bottom padding). + static var cardPadding: CGFloat { 8 } +} diff --git a/ios/HouseScanUITests/PastEndRecoveryUITests.swift b/ios/HouseScanUITests/PastEndRecoveryUITests.swift index ccd9a7894..c683efba1 100644 --- a/ios/HouseScanUITests/PastEndRecoveryUITests.swift +++ b/ios/HouseScanUITests/PastEndRecoveryUITests.swift @@ -32,21 +32,74 @@ final class PastEndRecoveryUITests: XCTestCase { try offersWallEndsHere(textSize: Self.largestText, name: "gapRequest-pastEnd-AX5") } + @MainActor + func testAPastEndRequestOnTheLeftKeepsTheCircleOpenAtLargestTextSize() throws { + let app = launch(["-uiDemoGap", "pastEndLeft"] + Self.largestText) + let card = label(app, "instruction") + XCTAssertTrue(card.contains("Keep walking left"), "the folded lead names the side: \(card)") + let mark = app.buttons["action.markEnd"] + XCTAssertTrue(mark.waitForExistence(timeout: 5)) + scrollIntoView(mark, in: app) + XCTAssertTrue(mark.isHittable, "Wall ends here can't be reached") + assertEndCircleOpen(app, covers: Self.endCovers, "left past-end, AX5") + snap(app, "gapRequest-pastEndLeft-AX5") + } + @MainActor private func offersWallEndsHere(textSize: [String], name: String) throws { + let folded = !textSize.isEmpty let app = launch(["-uiDemoGap", "pastEnd"] + textSize) let card = label(app, "instruction") - XCTAssertTrue(card.contains("Keep walking past the right end"), "the request's own words: \(card)") - XCTAssertTrue(card.contains("If the wall stops sooner, aim where it stops and tap Wall ends here."), "card reads: \(card)") + if folded { + // Folded around the circle: walking on leads; the request's words, the eyebrow and + // the way to mark the end are under Details. + XCTAssertTrue(card.contains("Keep walking right"), "card reads: \(card)") + XCTAssertFalse(card.contains("If the wall stops sooner"), "the how-to words must fold under Details: \(card)") + XCTAssertFalse(card.contains("One more view to finish"), "the eyebrow must fold under Details: \(card)") + } else { + XCTAssertTrue(card.contains("One more view to finish"), "card reads: \(card)") + XCTAssertTrue(card.contains("Keep walking past the right end"), "the request's own words: \(card)") + XCTAssertTrue(card.contains("If the wall stops sooner, aim where it stops and tap Wall ends here."), "card reads: \(card)") + } XCTAssertTrue(element(app, "action.skipGap").exists, "\"I can't get there\" stays the decline") let mark = app.buttons["action.markEnd"] XCTAssertTrue(mark.waitForExistence(timeout: 5), "a past-end request must offer Wall ends here") XCTAssertEqual(mark.label, "Wall ends here") scrollIntoView(mark, in: app) XCTAssertTrue(mark.isHittable, "Wall ends here can't be reached") + // Where "Wall ends here" can be pressed, the circle it marks at is in view. + assertEndCircleOpen(app, covers: Self.endCovers, name) snap(app, name) + guard folded else { return } + + // Details opens every word of the unfolded card, and closes back to the open circle. + scrollToTop(app) + let details = app.buttons["instruction.details"] + XCTAssertTrue(details.waitForExistence(timeout: 5), "the folded card needs Details") + tapWhenReady(details) + let detail = element(app, "instruction.detail") + XCTAssertTrue(detail.waitForExistence(timeout: 5), "Details must open the folded words") + for words in ["One more view to finish.", "Show the ground", "Keep walking past the right end", "If the wall stops sooner, aim where it stops and tap Wall ends here."] { + XCTAssertTrue(detail.label.contains(words), "Details must hold \"\(words)\": \(detail.label)") + } + snap(app, "\(name)-details") + tapWhenReady(details) + XCTAssertTrue(waitForAbsence(detail), "Details must close again") + scrollIntoView(mark, in: app) + XCTAssertTrue(mark.isHittable) + assertEndCircleOpen(app, covers: Self.endCovers, "\(name), Details closed") + // The decline moved from the card to the actions, and stays within reach. + let decline = app.buttons["action.skipGap"] + scrollIntoView(decline, in: app) + XCTAssertTrue(decline.isHittable, "I can't get there can't be reached") + XCTAssertEqual(decline.label, "I can't get there") + XCTAssertGreaterThan(decline.frame.minY, mark.frame.maxY - 0.5, "the decline sits under Wall ends here") } + /// What may cover the circle: the actions and the card's reply. The card itself is checked by + /// its bottom edge (`assertEndCircleOpen`). + private static let endCovers = ["action.markEnd", "action.skipGap", "action.showResult", "action.endCorner", "action.endBlocked", "action.endEnds"] + /// The circle off the wall: the card says why and what to do, the decline stays, and pressing /// again with the circle still off the wall refuses again rather than marking anything. @MainActor @@ -61,18 +114,33 @@ final class PastEndRecoveryUITests: XCTestCase { @MainActor private func refusal(textSize: [String], name: String) throws { + let folded = !textSize.isEmpty let app = launch(["-uiDemoGap", "pastEnd", "-uiDemoEndMarkRefusal"] + textSize) + // Folded, the correction leads and the reason goes under Details. + let says = folded ? "Aim at the wall" : "The circle isn't on the wall" var card = label(app, "instruction") - XCTAssertTrue(card.contains("The circle isn't on the wall"), "card reads: \(card)") - XCTAssertTrue(card.contains("Aim it at the wall where it stops or turns"), "card reads: \(card)") + XCTAssertTrue(card.contains(says), "card reads: \(card)") + if folded { + XCTAssertFalse(card.contains("The circle isn't on the wall"), "the reason must fold under Details: \(card)") + } else { + XCTAssertTrue(card.contains("Aim it at the wall where it stops or turns"), "card reads: \(card)") + } XCTAssertTrue(element(app, "action.skipGap").exists) - snap(app, name) let mark = app.buttons["action.markEnd"] scrollIntoView(mark, in: app) + XCTAssertTrue(mark.isHittable) + assertEndCircleOpen(app, covers: Self.endCovers, name) + snap(app, name) tapWhenReady(mark) card = label(app, "instruction") - XCTAssertTrue(card.contains("The circle isn't on the wall"), "a second press marked something: \(card)") + XCTAssertTrue(card.contains(says), "a second press marked something: \(card)") XCTAssertFalse(element(app, "action.endBlocked").exists, "no end was marked, so nothing to ask") + guard folded else { return } + scrollToTop(app) + tapWhenReady(app.buttons["instruction.details"]) + let detail = element(app, "instruction.detail") + XCTAssertTrue(detail.waitForExistence(timeout: 5)) + XCTAssertTrue(detail.label.contains("The circle isn't on the wall. Aim it at the wall where it stops or turns, then tap Wall ends here."), "Details reads: \(detail.label)") } /// "Wall ends here" asks the walk's end question; it replaces the request and its decline @@ -101,6 +169,10 @@ final class PastEndRecoveryUITests: XCTestCase { for answer in ["action.endCorner", "action.endBlocked", "action.endEnds"] { XCTAssertTrue(app.buttons[answer].exists, "\(answer) missing") } + // In their order, and the question needs no circle. + let tops = ["action.endCorner", "action.endBlocked", "action.endEnds"].map { app.buttons[$0].frame.minY } + XCTAssertEqual(tops, tops.sorted(), "the answers moved out of order: \(tops)") + XCTAssertFalse(element(app, "aim.circle").exists, "the question shows no circle") snap(app, name) let blocked = app.buttons["action.endBlocked"] scrollIntoView(blocked, in: app) @@ -385,6 +457,23 @@ final class PastEndRecoveryUITests: XCTestCase { } } + /// Drags the screen down until its top shows. + @MainActor + private func scrollToTop(_ app: XCUIApplication) { + let scroll = app.scrollViews.firstMatch + guard scroll.exists else { return } + let start = scroll.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.3)) + for _ in 0..<4 { + start.press(forDuration: 0.05, thenDragTo: start.withOffset(CGVector(dx: 0, dy: app.windows.firstMatch.frame.height * 0.4))) + } + } + + @MainActor + private func waitForAbsence(_ target: XCUIElement) -> Bool { + let gone = XCTNSPredicateExpectation(predicate: NSPredicate(format: "exists == false"), object: target) + return XCTWaiter().wait(for: [gone], timeout: 5) == .completed + } + @MainActor private func snap(_ app: XCUIApplication, _ name: String) { let shot = XCTAttachment(screenshot: app.screenshot()) diff --git a/ios/HouseScanUITests/WalkRecoveryUITests.swift b/ios/HouseScanUITests/WalkRecoveryUITests.swift index 3f2b45f07..9e7278af7 100644 --- a/ios/HouseScanUITests/WalkRecoveryUITests.swift +++ b/ios/HouseScanUITests/WalkRecoveryUITests.swift @@ -16,6 +16,7 @@ final class WalkRecoveryUITests: XCTestCase { func testTheEndCardAnswersThatTheWallKeepsGoing() throws { let app = launch(["-uiDemoPhase", "wallWalk", "-uiDemoMarkEnd"]) XCTAssertTrue(label(app, "instruction").contains("Is this the right end of the wall?")) + assertEndCircleOpen(app, covers: Self.endCovers, "walk mark-end") snap(app, "wallWalk-markEnd-keepsGoing") XCTAssertTrue(element(app, "action.markEnd").exists, "Wall ends here must stay on offer") let keepsGoing = reply(app, "The wall keeps going") @@ -39,6 +40,90 @@ final class WalkRecoveryUITests: XCTestCase { XCTAssertTrue(card.contains("Aim it at the wall where it stops or turns"), "card reads: \(card)") XCTAssertTrue(element(app, "action.markEnd").exists) XCTAssertTrue(reply(app, "The wall keeps going").waitForExistence(timeout: 5)) + assertEndCircleOpen(app, covers: Self.endCovers, "walk mark-end refused") + } + + /// At AX5 the card covered the circle "Wall ends here" marks at (CI run 37165788075, + /// `wallWalk-markEnd-AX5-stacked`). Folded, it leads with where to aim, every word is under + /// Details, and "The wall keeps going" moves to the actions under "Wall ends here", so with + /// "Wall ends here" in reach the circle is in view. + @MainActor + func testTheEndCircleStaysOpenAtLargestTextSize() throws { + let app = launch(["-uiDemoPhase", "wallWalk", "-uiDemoMarkEnd"] + Self.largestText) + let card = label(app, "instruction") + XCTAssertTrue(card.contains("Aim at the right end"), "card reads: \(card)") + XCTAssertFalse(card.contains("Is this the right end of the wall?"), "the question folds under Details: \(card)") + let mark = element(app, "action.markEnd") + scrollIntoView(mark, in: app) + XCTAssertTrue(mark.isHittable, "Wall ends here can't be reached") + assertEndCircleOpen(app, covers: Self.endCovers, "walk mark-end, AX5") + snap(app, "wallWalk-markEnd-AX5") + + scrollToTop(app) + let details = element(app, "instruction.details") + tapWhenReady(details) + let detail = element(app, "instruction.detail") + XCTAssertTrue(detail.waitForExistence(timeout: 5), "Details must open the folded words") + XCTAssertTrue( + detail.label.contains("Is this the right end of the wall? Aim where it stops or turns a corner and tap Wall ends here. If it goes on, tap The wall keeps going."), + "Details reads: \(detail.label)") + snap(app, "wallWalk-markEnd-AX5-details") + tapWhenReady(details) + let gone = XCTNSPredicateExpectation(predicate: NSPredicate(format: "exists == false"), object: detail) + XCTAssertEqual(XCTWaiter().wait(for: [gone], timeout: 5), .completed, "Details must close again") + scrollIntoView(mark, in: app) + assertEndCircleOpen(app, covers: Self.endCovers, "walk mark-end, AX5, Details closed") + + let keepsGoing = reply(app, "The wall keeps going") + XCTAssertTrue(keepsGoing.waitForExistence(timeout: 5), "the wall that goes on still needs its answer") + scrollIntoView(keepsGoing, in: app) + XCTAssertTrue(keepsGoing.isHittable, "The wall keeps going can't be reached") + XCTAssertGreaterThan(keepsGoing.frame.minY, mark.frame.maxY - 0.5, "the answer sits under Wall ends here") + } + + /// Folded, a refusal leads with what to do, and keeps the circle in view. + @MainActor + func testARefusedWallEndKeepsTheCircleOpenAtLargestTextSize() throws { + let app = launch(["-uiDemoPhase", "wallWalk", "-uiDemoMarkEnd", "-uiDemoEndMarkRefusal"] + Self.largestText) + let card = label(app, "instruction") + XCTAssertTrue(card.contains("Aim at the wall"), "card reads: \(card)") + let mark = element(app, "action.markEnd") + scrollIntoView(mark, in: app) + XCTAssertTrue(mark.isHittable) + assertEndCircleOpen(app, covers: Self.endCovers, "walk mark-end refused, AX5") + snap(app, "wallWalk-markEnd-refused-AX5") + } + + private static let largestText = ["-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryAccessibilityXXXL"] + /// What may cover the circle on the walk's end: its actions and the card's reply. + private static let endCovers = ["action.markEnd", "action.markSomething", "action.cannotAccess"] + + /// Scrolls in measured steps until the whole element is in the window: at the largest text + /// sizes the actions sit below the card, reached by scrolling. + @MainActor + private func scrollIntoView(_ target: XCUIElement, in app: XCUIApplication) { + let window = app.windows.firstMatch.frame + let scroll = app.scrollViews.firstMatch + guard scroll.exists else { return } + let start = scroll.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.6)) + for _ in 0..<12 where !window.contains(target.frame) { + let frame = target.frame + let shift: CGFloat = frame.maxY > window.maxY + ? -min(frame.maxY - window.maxY + 24, window.height / 3) + : min(window.minY - frame.minY + 24, window.height / 3) + start.press(forDuration: 0.05, thenDragTo: start.withOffset(CGVector(dx: 0, dy: shift))) + } + } + + /// Drags the screen down until its top shows. + @MainActor + private func scrollToTop(_ app: XCUIApplication) { + let scroll = app.scrollViews.firstMatch + guard scroll.exists else { return } + let start = scroll.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.3)) + for _ in 0..<4 { + start.press(forDuration: 0.05, thenDragTo: start.withOffset(CGVector(dx: 0, dy: app.windows.firstMatch.frame.height * 0.4))) + } } /// B-23: "Point at the meter like this." needs the photo it points to. The follow-up view From 254a4566d3443e140fff8847bc304ed231f2db38 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 3 Oct 2026 21:27:18 -0500 Subject: [PATCH 2/5] ios: folded end cards keep coaching off the circle, and the moved reply keeps its lock UNVALIDATED: not compiled; no package, app or UI tests have run. Fixes from the prep-for-prod review of 21ac61ec: - Coaching riding along kept its note under the folded lead, and the dark note runs several lines at AX5. By the frames' measurements, even one line uses the room left above the circle. Folded around the circle, the coaching now leads in a few words beside its symbol ("It's dark here", `ScanCopy.coachingLead`), and its whole note opens Details (`ScanCopy.foldedAroundCircle`). With a refusal, the correction keeps the lead and the coaching opens Details. - The reply moved to the actions lost the card's 600 ms input lock (#82). `MovedReplyButton` keeps it, keyed to the same task as the card's reply. - Under Reduce Motion the walk's controls eased their swap over 0.15 s, which moved the map as the end step's added reply grew the stack. They now swap at once. - The test helper measured the card's end by its words and Details with 8 pt, but unfolded the card ends at its reply plus 12 pt. It now takes whether the card is folded, and it also checks the circle is the 56 pt reticle. - "Turn to the right end" would likely break to three lines beside the refusal triangle; it is now "Face the right end". Tests added: coached folded cards on both screens (`-uiDemoCoaching tooDark`), and the other-side refusal on both sides (`-uiDemoEndMarkOtherSide`, new in the demo). Co-Authored-By: Claude Opus 5.5 (1M context) --- .../UI/Components/MovedReplyButton.swift | 37 +++++++++++++ ios/HouseScan/UI/Copy/ScanCopy.swift | 31 ++++++++++- ios/HouseScan/UI/Preview/DemoEngine.swift | 24 ++++++--- .../UI/Screens/GapRequestScreen.swift | 23 ++++---- ios/HouseScan/UI/Screens/WallWalkScreen.swift | 25 +++++---- .../EndAimCircleAssertions.swift | 33 +++++++----- .../PastEndRecoveryUITests.swift | 52 +++++++++++++++++-- .../WalkRecoveryUITests.swift | 25 +++++++-- 8 files changed, 197 insertions(+), 53 deletions(-) create mode 100644 ios/HouseScan/UI/Components/MovedReplyButton.swift diff --git a/ios/HouseScan/UI/Components/MovedReplyButton.swift b/ios/HouseScan/UI/Components/MovedReplyButton.swift new file mode 100644 index 000000000..312cc0d48 --- /dev/null +++ b/ios/HouseScan/UI/Components/MovedReplyButton.swift @@ -0,0 +1,37 @@ +import SwiftUI + +/// A card's reply ("I can't get there", "The wall keeps going") shown among the actions instead, +/// while the card folds around the circle at the largest text sizes (`GapRequestScreen`, +/// `WallWalkScreen`). It keeps the card reply's input lock: after its task changes it takes no +/// taps for `InstructionCard.replyLock`, so a tap meant for the step that just left can't answer +/// this one (#82). Coaching or a refusal within the same task keeps it unlocked, as on the card. +/// The secondary style doesn't read `isEnabled`, so the lock doesn't change how it looks; +/// VoiceOver and UI tests read it as dimmed. +struct MovedReplyButton: View { + let title: String + let hint: String + let identifier: String + /// The step the reply answers; a new one locks the button again. + let task: Instruction + let perform: () -> Void + + @State private var unlockedTask: Instruction? + + var body: some View { + Button(action: perform) { + Text(title) + .frame(maxWidth: .infinity) + } + .buttonStyle(.secondary) + .disabled(unlockedTask != task) + .accessibilityHint(hint) + .accessibilityIdentifier(identifier) + .task(id: task) { + // Cleared first, so a task that comes back within the lock is locked again. + let shown = task + unlockedTask = nil + do { try await Task.sleep(for: InstructionCard.replyLock) } catch { return } + unlockedTask = shown + } + } +} diff --git a/ios/HouseScan/UI/Copy/ScanCopy.swift b/ios/HouseScan/UI/Copy/ScanCopy.swift index a2dea8332..7f017fddc 100644 --- a/ios/HouseScan/UI/Copy/ScanCopy.swift +++ b/ios/HouseScan/UI/Copy/ScanCopy.swift @@ -401,7 +401,7 @@ enum ScanCopy { case .otherSide(let landed): Instruction( title: "That's the \(landed.rawValue) side of your meter", detail: "Turn to the \(side.rawValue) end, then tap Wall ends here.", - folded: endAimFold("Turn to the \(side.rawValue) end", "That's the \(landed.rawValue) side of your meter. Turn to the \(side.rawValue) end, then tap Wall ends here.")) + folded: endAimFold("Face the \(side.rawValue) end", "That's the \(landed.rawValue) side of your meter. Turn to the \(side.rawValue) end, then tap Wall ends here.")) case .trackingNotReady: Instruction( title: "One moment, your phone is still finding its place", detail: "Then aim at the \(side.rawValue) end.", @@ -430,6 +430,35 @@ enum ScanCopy { Instruction.Folded(title: lead, detail: everything) } + /// A folded end-aim card with coaching riding along (`withCoaching`). Its note under the lead + /// would reach the circle again: the long dark note is several lines at AX5, and even one + /// line uses the room above the circle. So the coaching leads instead, in a few words beside + /// its symbol, and its whole note opens Details. With a refusal, the refusal's correction + /// keeps the lead and the coaching opens Details. Only for a card that folds; coaching that + /// replaces the task isn't folded at all. + static func foldedAroundCircle(_ card: Instruction, coaching: Coaching?, refused: Bool) -> Instruction { + guard let coaching, !coachingReplacesTask(coaching), var folded = card.folded else { return card } + var out = card + out.note = nil + folded.detail = "\(coachingNote(coaching)) \(folded.detail)" + if !refused { folded.title = coachingLead(coaching) } + out.folded = folded + return out + } + + /// Coaching in a few words, for a lead that must stay two lines beside its symbol at AX5. + static func coachingLead(_ coaching: Coaching) -> String { + switch coaching { + case .slowDown: "Slow down" + case .needsTexture: "Find more texture" + case .tooDark: "It's dark here" + case .tooDarkToMeasure: "Too dark to measure" + case .holdSteady: "Hold steady" + case .turnSlowly: "Turn more slowly" + case .initializing, .relocalizing, .trackingLost, .pastWallEnd: ScanCopy.coaching(coaching).title + } + } + static func nextWallRefusal(_ refusal: NextWallRefusal) -> Instruction { switch refusal { case .noSurface: Instruction(title: "No wall under the circle", detail: "Step closer and aim at the next wall.") diff --git a/ios/HouseScan/UI/Preview/DemoEngine.swift b/ios/HouseScan/UI/Preview/DemoEngine.swift index 2a943ef2e..0ca546ba1 100644 --- a/ios/HouseScan/UI/Preview/DemoEngine.swift +++ b/ios/HouseScan/UI/Preview/DemoEngine.swift @@ -32,6 +32,9 @@ final class DemoEngine: ScanActions { /// `-uiDemoEndMarkRefusal`: "Wall ends here" was pressed with the circle off the wall, and the /// demo's circle stays off it, so pressing again refuses again. private let endMarkRefused: Bool + /// `-uiDemoEndMarkOtherSide`: as `-uiDemoEndMarkRefusal`, but the circle is on the wall on + /// the meter's other side (`EndMarkRefusal.otherSide`). + private let endMarkOtherSide: Bool /// The tilt-up step was answered or skipped. private var tiltUpSettled = false /// Walk-script ticks spent on the tilt-up step, standing in for the phone being tilted up. @@ -89,7 +92,8 @@ final class DemoEngine: ScanActions { rejectUpload = arguments.contains("-uiDemoRejected") unusableAnswers = arguments.contains("-uiDemoUnusableAnswer") ? max(value("-uiDemoUnusableAnswer").flatMap(Int.init) ?? 1, 1) : 0 gapKind = value("-uiDemoGap") - endMarkRefused = arguments.contains("-uiDemoEndMarkRefusal") + endMarkOtherSide = arguments.contains("-uiDemoEndMarkOtherSide") + endMarkRefused = arguments.contains("-uiDemoEndMarkRefusal") || endMarkOtherSide state.feed = DemoScene.image.map(CameraFeed.still) ?? .none state.isReplay = true state.tracking = .normal @@ -129,9 +133,9 @@ final class DemoEngine: ScanActions { reachedRight = demoRightEnd refreshCoverage() refreshGuidance() - if arguments.contains("-uiDemoEndMarkRefusal") { - // As in the real engine, a circle off the wall has no end preview (`circleEnd`). - state.endMarkRefusal = .noWall + if endMarkRefused { + // As in the real engine, a refused circle has no end preview (`circleEnd`). + state.endMarkRefusal = demoRefusal(asked: .right) state.endPreview = nil } } @@ -444,8 +448,8 @@ final class DemoEngine: ScanActions { state.target = DemoScene.wall.world(s: end + sign * 0.3, height: 0, out: 0.5) state.path = DemoScene.path(toward: end + sign * 0.3) if endMarkRefused { - // As in the real engine, a circle off the wall has no end preview (`circleEnd`). - state.endMarkRefusal = .noWall + // As in the real engine, a refused circle has no end preview (`circleEnd`). + state.endMarkRefusal = demoRefusal(asked: side) state.endPreview = nil } else { // The circle on the wall a little before the old end: where the wall really stops. @@ -889,10 +893,16 @@ final class DemoEngine: ScanActions { } } + /// Why the demo's "Wall ends here" marks nothing: the circle off the wall, or on the wall on + /// the other side of the meter from the end `asked` (`-uiDemoEndMarkOtherSide`). + private func demoRefusal(asked: WallSide) -> EndMarkRefusal { + endMarkOtherSide ? .otherSide(asked == .right ? .left : .right) : .noWall + } + func markWallEnd(at point: CGPoint?, viewSize: CGSize) { if state.phase == .gapRequest, let side = state.gap?.pastEndSide, state.gap?.isSatisfied == false, state.endQuestion == nil { if endMarkRefused { - state.endMarkRefusal = .noWall + state.endMarkRefusal = demoRefusal(asked: side) state.endPreview = nil return } diff --git a/ios/HouseScan/UI/Screens/GapRequestScreen.swift b/ios/HouseScan/UI/Screens/GapRequestScreen.swift index d0aa2b79e..e251018f9 100644 --- a/ios/HouseScan/UI/Screens/GapRequestScreen.swift +++ b/ios/HouseScan/UI/Screens/GapRequestScreen.swift @@ -180,17 +180,13 @@ struct GapRequestScreen: View { /// "I can't get there" among the actions, under "Wall ends here", while the card folds around /// the circle (`endAimFolds`): the same words, hint and action as the card's reply. - private var declineButton: some View { - Button { - actions.skipGap() - } label: { - Text("I can't get there") - .frame(maxWidth: .infinity) + @ViewBuilder private var declineButton: some View { + if let gap = state.gap { + MovedReplyButton( + title: "I can't get there", hint: skipHint, identifier: "action.skipGap", + task: ScanCopy.gapTask(gap), perform: { actions.skipGap() }) + .transition(.opacity) } - .buttonStyle(.secondary) - .accessibilityHint(skipHint) - .accessibilityIdentifier("action.skipGap") - .transition(.opacity) } /// At the largest text sizes while "Wall ends here" is offered, the card folds to a two-line @@ -259,9 +255,12 @@ struct GapRequestScreen: View { /// thanks" for as long as it did (field test 4.1, run 3). private var instruction: Instruction { var card = requestCard - // Folded around the circle, "One more view to finish" leaves the card's top (`endAimFolds`) + guard endAimFolds else { return card } + // Folded around the circle, ride-along coaching leads or opens Details + // (`ScanCopy.foldedAroundCircle`), and "One more view to finish" leaves the card's top // and leads the words under Details. - if endAimFolds, followUps > 0, let folded = card.folded { + card = ScanCopy.foldedAroundCircle(card, coaching: coaching, refused: refusal != nil) + if followUps > 0, let folded = card.folded { card.folded?.detail = "\(ScanCopy.followUp(remaining: followUps)). \(folded.detail)" } return card diff --git a/ios/HouseScan/UI/Screens/WallWalkScreen.swift b/ios/HouseScan/UI/Screens/WallWalkScreen.swift index 7c29e652f..7ff4b685e 100644 --- a/ios/HouseScan/UI/Screens/WallWalkScreen.swift +++ b/ios/HouseScan/UI/Screens/WallWalkScreen.swift @@ -69,7 +69,10 @@ struct WallWalkScreen: View { ) } } - .animation(reduceMotion ? .easeOut(duration: 0.15) : Motion.settle, value: controlsKey) + // Under Reduce Motion the controls swap at once: the swap changes the stack's height + // (the end's step adds its reply at the largest sizes), and an eased resize still + // moves the map under it. + .animation(reduceMotion ? nil : Motion.settle, value: controlsKey) } } .animation(.easeOut(duration: 0.2), value: showsMeterPhoto) @@ -81,6 +84,14 @@ struct WallWalkScreen: View { // MARK: Instruction private var instruction: Instruction { + // Folded around the circle, ride-along coaching leads or opens Details instead of sitting + // under the lead (`ScanCopy.foldedAroundCircle`). + guard endAimFolds else { return stepCard } + return ScanCopy.foldedAroundCircle(stepCard, coaching: coaching, refused: state.endMarkRefusal != nil) + } + + /// The card's words for the step, before folding around the circle (`instruction`). + private var stepCard: Instruction { if let marking = state.marking { let prompt = ScanCopy.markingPrompt(marking) if let refusal = marking.refusal { @@ -352,15 +363,9 @@ struct WallWalkScreen: View { .transition(.opacity) if endAimFolds, let copy = ScanCopy.reply(for: state.guidance) { // The card's reply, moved here while the card folds around the circle. - Button { - actions.cannotAccessArea() - } label: { - Text(copy.title) - .spansStack(stacksActions) - } - .buttonStyle(.secondary) - .accessibilityHint(copy.hint) - .accessibilityIdentifier("action.cannotAccess") + MovedReplyButton( + title: copy.title, hint: copy.hint, identifier: "action.cannotAccess", + task: replyTask, perform: { actions.cannotAccessArea() }) .transition(.opacity) } case .walking where offersEndHere: diff --git a/ios/HouseScanUITests/EndAimCircleAssertions.swift b/ios/HouseScanUITests/EndAimCircleAssertions.swift index 28d02767c..01c9a1e0a 100644 --- a/ios/HouseScanUITests/EndAimCircleAssertions.swift +++ b/ios/HouseScanUITests/EndAimCircleAssertions.swift @@ -3,34 +3,39 @@ import XCTest /// The circle "Wall ends here" marks at (`endAimCircle`, identifier `aim.circle`), read from the /// production layout: the app's own view, as VoiceOver sees it, not a test-only overlay. extension XCTestCase { - /// Checks the circle is drawn in the middle of the window, where the ray "Wall ends here" uses - /// goes through (`ScanEngine.circleEnd`: the middle of the sensor image, which the camera view - /// fills and centres), is wholly on screen, and is under none of `covers`: the card's words, - /// its Details, and the actions. The card's scrim runs `cardPadding` below the last of its - /// parts, so the circle must clear that too. + /// Checks the circle is the 56 pt reticle drawn in the middle of the window, where the ray + /// "Wall ends here" uses goes through (`ScanEngine.circleEnd`: the middle of the sensor image, + /// which the camera view fills and centres), is wholly on screen, and is under neither the + /// card nor any of `covers`. + /// + /// The card ends at its last part plus the scrim's padding under it (`InstructionCard`): + /// unfolded, the reply (`reply`), 12 pt above the scrim's edge; folded around the circle, + /// Details, 8 pt above it, since the reply has moved to the actions. @MainActor func assertEndCircleOpen( - _ app: XCUIApplication, covers: [String], _ context: String, + _ app: XCUIApplication, folded: Bool, reply: String, covers: [String], _ context: String, file: StaticString = #filePath, line: UInt = #line ) { + func element(_ identifier: String) -> XCUIElement { app.descendants(matching: .any)[identifier].firstMatch } let window = app.windows.firstMatch.frame - let circle = app.descendants(matching: .any)["aim.circle"].firstMatch + let circle = element("aim.circle") XCTAssertTrue(circle.waitForExistence(timeout: 5), "\(context): no aiming circle", file: file, line: line) let frame = circle.frame + XCTAssertEqual(frame.width, 56, accuracy: 1, "\(context): the aiming circle is \(frame.size), not the 56 pt reticle", file: file, line: line) + XCTAssertEqual(frame.height, 56, accuracy: 1, "\(context): the aiming circle is \(frame.size), not the 56 pt reticle", file: file, line: line) XCTAssertEqual(frame.midX, window.midX, accuracy: 1, "\(context): the circle \(frame) must be the middle of the camera", file: file, line: line) XCTAssertEqual(frame.midY, window.midY, accuracy: 1, "\(context): the circle \(frame) must be the middle of the camera", file: file, line: line) XCTAssertTrue(window.contains(frame), "\(context): the circle \(frame) is not wholly on screen", file: file, line: line) - let card = ["instruction", "instruction.details"].map { app.descendants(matching: .any)[$0].firstMatch }.filter(\.exists) - if let cardBottom = card.map(\.frame.maxY).max() { - XCTAssertGreaterThanOrEqual(frame.minY, cardBottom + Self.cardPadding, "\(context): the circle \(frame) is under the card, which ends at \(cardBottom + Self.cardPadding)", file: file, line: line) + + let parts = (folded ? ["instruction", "instruction.details"] : ["instruction", reply]).map(element).filter(\.exists) + let padding: CGFloat = folded ? 8 : 12 + if let last = parts.map(\.frame.maxY).max() { + XCTAssertGreaterThanOrEqual(frame.minY, last + padding, "\(context): the circle \(frame) is under the card, which ends at \(last + padding)", file: file, line: line) } for identifier in covers { - let cover = app.descendants(matching: .any)[identifier].firstMatch + let cover = element(identifier) guard cover.exists else { continue } XCTAssertFalse(frame.intersects(cover.frame), "\(context): the circle \(frame) is under \(identifier) \(cover.frame)", file: file, line: line) } } - - /// The card's scrim below Details without a reply (`InstructionCard.details`, bottom padding). - static var cardPadding: CGFloat { 8 } } diff --git a/ios/HouseScanUITests/PastEndRecoveryUITests.swift b/ios/HouseScanUITests/PastEndRecoveryUITests.swift index c683efba1..0ec6a0685 100644 --- a/ios/HouseScanUITests/PastEndRecoveryUITests.swift +++ b/ios/HouseScanUITests/PastEndRecoveryUITests.swift @@ -41,7 +41,7 @@ final class PastEndRecoveryUITests: XCTestCase { XCTAssertTrue(mark.waitForExistence(timeout: 5)) scrollIntoView(mark, in: app) XCTAssertTrue(mark.isHittable, "Wall ends here can't be reached") - assertEndCircleOpen(app, covers: Self.endCovers, "left past-end, AX5") + assertEndCircleOpen(app, folded: true, reply: "action.skipGap", covers: Self.endCovers, "left past-end, AX5") snap(app, "gapRequest-pastEndLeft-AX5") } @@ -68,7 +68,7 @@ final class PastEndRecoveryUITests: XCTestCase { scrollIntoView(mark, in: app) XCTAssertTrue(mark.isHittable, "Wall ends here can't be reached") // Where "Wall ends here" can be pressed, the circle it marks at is in view. - assertEndCircleOpen(app, covers: Self.endCovers, name) + assertEndCircleOpen(app, folded: folded, reply: "action.skipGap", covers: Self.endCovers, name) snap(app, name) guard folded else { return } @@ -87,7 +87,7 @@ final class PastEndRecoveryUITests: XCTestCase { XCTAssertTrue(waitForAbsence(detail), "Details must close again") scrollIntoView(mark, in: app) XCTAssertTrue(mark.isHittable) - assertEndCircleOpen(app, covers: Self.endCovers, "\(name), Details closed") + assertEndCircleOpen(app, folded: true, reply: "action.skipGap", covers: Self.endCovers, "\(name), Details closed") // The decline moved from the card to the actions, and stays within reach. let decline = app.buttons["action.skipGap"] scrollIntoView(decline, in: app) @@ -96,6 +96,50 @@ final class PastEndRecoveryUITests: XCTestCase { XCTAssertGreaterThan(decline.frame.minY, mark.frame.maxY - 0.5, "the decline sits under Wall ends here") } + /// Coaching riding along leads the folded card in a few words, and its whole note opens + /// Details: under the lead, the dark note's lines reached the circle. + @MainActor + func testCoachingOnAFoldedPastEndCardKeepsTheCircleOpen() throws { + let app = launch(["-uiDemoGap", "pastEnd", "-uiDemoCoaching", "tooDark"] + Self.largestText) + let card = label(app, "instruction") + XCTAssertTrue(card.contains("It's dark here"), "card reads: \(card)") + XCTAssertFalse(card.contains("flashlight"), "the coaching's note must fold under Details: \(card)") + let mark = app.buttons["action.markEnd"] + scrollIntoView(mark, in: app) + XCTAssertTrue(mark.isHittable) + assertEndCircleOpen(app, folded: true, reply: "action.skipGap", covers: Self.endCovers, "coached past-end, AX5") + snap(app, "gapRequest-pastEnd-tooDark-AX5") + scrollToTop(app) + tapWhenReady(app.buttons["instruction.details"]) + let detail = element(app, "instruction.detail") + XCTAssertTrue(detail.waitForExistence(timeout: 5)) + for words in ["Try your phone's flashlight", "Keep walking past the right end", "If the wall stops sooner"] { + XCTAssertTrue(detail.label.contains(words), "Details must hold \"\(words)\": \(detail.label)") + } + } + + /// The circle on the other side of the meter, on either side: the folded card leads with + /// which end to face, and the circle stays open. + @MainActor + func testTheOtherSideRefusalFoldsOnBothSides() throws { + for (gap, asked, landed) in [("pastEnd", "right", "left"), ("pastEndLeft", "left", "right")] { + let app = launch(["-uiDemoGap", gap, "-uiDemoEndMarkOtherSide"] + Self.largestText) + let card = label(app, "instruction") + XCTAssertTrue(card.contains("Face the \(asked) end"), "\(gap): card reads: \(card)") + let mark = app.buttons["action.markEnd"] + scrollIntoView(mark, in: app) + XCTAssertTrue(mark.isHittable) + assertEndCircleOpen(app, folded: true, reply: "action.skipGap", covers: Self.endCovers, "\(gap) other side, AX5") + snap(app, "gapRequest-\(gap)-otherSide-AX5") + scrollToTop(app) + tapWhenReady(app.buttons["instruction.details"]) + let detail = element(app, "instruction.detail") + XCTAssertTrue(detail.waitForExistence(timeout: 5)) + XCTAssertTrue(detail.label.contains("That's the \(landed) side of your meter. Turn to the \(asked) end"), "\(gap): Details reads: \(detail.label)") + app.terminate() + } + } + /// What may cover the circle: the actions and the card's reply. The card itself is checked by /// its bottom edge (`assertEndCircleOpen`). private static let endCovers = ["action.markEnd", "action.skipGap", "action.showResult", "action.endCorner", "action.endBlocked", "action.endEnds"] @@ -129,7 +173,7 @@ final class PastEndRecoveryUITests: XCTestCase { let mark = app.buttons["action.markEnd"] scrollIntoView(mark, in: app) XCTAssertTrue(mark.isHittable) - assertEndCircleOpen(app, covers: Self.endCovers, name) + assertEndCircleOpen(app, folded: folded, reply: "action.skipGap", covers: Self.endCovers, name) snap(app, name) tapWhenReady(mark) card = label(app, "instruction") diff --git a/ios/HouseScanUITests/WalkRecoveryUITests.swift b/ios/HouseScanUITests/WalkRecoveryUITests.swift index 9e7278af7..03888733a 100644 --- a/ios/HouseScanUITests/WalkRecoveryUITests.swift +++ b/ios/HouseScanUITests/WalkRecoveryUITests.swift @@ -16,7 +16,7 @@ final class WalkRecoveryUITests: XCTestCase { func testTheEndCardAnswersThatTheWallKeepsGoing() throws { let app = launch(["-uiDemoPhase", "wallWalk", "-uiDemoMarkEnd"]) XCTAssertTrue(label(app, "instruction").contains("Is this the right end of the wall?")) - assertEndCircleOpen(app, covers: Self.endCovers, "walk mark-end") + assertEndCircleOpen(app, folded: false, reply: "action.cannotAccess", covers: Self.endCovers, "walk mark-end") snap(app, "wallWalk-markEnd-keepsGoing") XCTAssertTrue(element(app, "action.markEnd").exists, "Wall ends here must stay on offer") let keepsGoing = reply(app, "The wall keeps going") @@ -40,7 +40,7 @@ final class WalkRecoveryUITests: XCTestCase { XCTAssertTrue(card.contains("Aim it at the wall where it stops or turns"), "card reads: \(card)") XCTAssertTrue(element(app, "action.markEnd").exists) XCTAssertTrue(reply(app, "The wall keeps going").waitForExistence(timeout: 5)) - assertEndCircleOpen(app, covers: Self.endCovers, "walk mark-end refused") + assertEndCircleOpen(app, folded: false, reply: "action.cannotAccess", covers: Self.endCovers, "walk mark-end refused") } /// At AX5 the card covered the circle "Wall ends here" marks at (CI run 37165788075, @@ -56,7 +56,7 @@ final class WalkRecoveryUITests: XCTestCase { let mark = element(app, "action.markEnd") scrollIntoView(mark, in: app) XCTAssertTrue(mark.isHittable, "Wall ends here can't be reached") - assertEndCircleOpen(app, covers: Self.endCovers, "walk mark-end, AX5") + assertEndCircleOpen(app, folded: true, reply: "action.cannotAccess", covers: Self.endCovers, "walk mark-end, AX5") snap(app, "wallWalk-markEnd-AX5") scrollToTop(app) @@ -72,7 +72,7 @@ final class WalkRecoveryUITests: XCTestCase { let gone = XCTNSPredicateExpectation(predicate: NSPredicate(format: "exists == false"), object: detail) XCTAssertEqual(XCTWaiter().wait(for: [gone], timeout: 5), .completed, "Details must close again") scrollIntoView(mark, in: app) - assertEndCircleOpen(app, covers: Self.endCovers, "walk mark-end, AX5, Details closed") + assertEndCircleOpen(app, folded: true, reply: "action.cannotAccess", covers: Self.endCovers, "walk mark-end, AX5, Details closed") let keepsGoing = reply(app, "The wall keeps going") XCTAssertTrue(keepsGoing.waitForExistence(timeout: 5), "the wall that goes on still needs its answer") @@ -90,10 +90,25 @@ final class WalkRecoveryUITests: XCTestCase { let mark = element(app, "action.markEnd") scrollIntoView(mark, in: app) XCTAssertTrue(mark.isHittable) - assertEndCircleOpen(app, covers: Self.endCovers, "walk mark-end refused, AX5") + assertEndCircleOpen(app, folded: true, reply: "action.cannotAccess", covers: Self.endCovers, "walk mark-end refused, AX5") snap(app, "wallWalk-markEnd-refused-AX5") } + /// The walk's end with coaching riding along: the coaching leads in a few words, its note + /// opens Details, and the circle stays open. + @MainActor + func testCoachingOnTheFoldedEndCardKeepsTheCircleOpen() throws { + let app = launch(["-uiDemoPhase", "wallWalk", "-uiDemoMarkEnd", "-uiDemoCoaching", "tooDark"] + Self.largestText) + let card = label(app, "instruction") + XCTAssertTrue(card.contains("It's dark here"), "card reads: \(card)") + XCTAssertFalse(card.contains("flashlight"), "the coaching's note must fold under Details: \(card)") + let mark = element(app, "action.markEnd") + scrollIntoView(mark, in: app) + XCTAssertTrue(mark.isHittable) + assertEndCircleOpen(app, folded: true, reply: "action.cannotAccess", covers: Self.endCovers, "coached walk mark-end, AX5") + snap(app, "wallWalk-markEnd-tooDark-AX5") + } + private static let largestText = ["-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryAccessibilityXXXL"] /// What may cover the circle on the walk's end: its actions and the card's reply. private static let endCovers = ["action.markEnd", "action.markSomething", "action.cannotAccess"] From b1dc25251c3ba10ddc18cb836c17d990529f3849 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 3 Oct 2026 21:37:33 -0500 Subject: [PATCH 3/5] ios: folded end cards lead with the remedy for darkness, and with the step for a too-short wall UNVALIDATED: not compiled; not run. Greptile on #214: folded around the circle, darkness coaching led with "It's dark here" and left the remedy under Details, though darkness can last a whole walk. The coaching leads are now what to do ("Try your flashlight", "Try in daylight"); the moon symbol already says what is wrong, and the whole note still opens Details. The walk can ask for the end again after too-close ends were cleared, with `wallTooShort` still set. Folded, its card kept the full title, "The ends were too close. Walk along the wall first.", several lines at AX5 beside the triangle, which would cover the circle again. It now leads with "Walk the wall first", and the rest goes under Details. Coaching then opens Details too, as with a refusal. Co-Authored-By: Claude Opus 5.5 (1M context) --- ios/HouseScan/UI/Copy/ScanCopy.swift | 10 +++++++--- ios/HouseScan/UI/Screens/WallWalkScreen.swift | 8 ++++++-- ios/HouseScanUITests/PastEndRecoveryUITests.swift | 4 ++-- ios/HouseScanUITests/WalkRecoveryUITests.swift | 2 +- 4 files changed, 16 insertions(+), 8 deletions(-) diff --git a/ios/HouseScan/UI/Copy/ScanCopy.swift b/ios/HouseScan/UI/Copy/ScanCopy.swift index 7f017fddc..d4cff3aad 100644 --- a/ios/HouseScan/UI/Copy/ScanCopy.swift +++ b/ios/HouseScan/UI/Copy/ScanCopy.swift @@ -252,6 +252,9 @@ enum ScanCopy { /// Over the walk's own prompt after "Done with this wall" was refused and the ends cleared /// (`ScanViewState.wallTooShort`). static let wallTooShort = "The ends were too close. Walk along the wall first." + /// `wallTooShort` on a card folded around the circle, as the walk's end can be when both ends + /// were cleared: two lines beside the refusal triangle, the rest under Details. + static let wallTooShortLead = "Walk the wall first" // MARK: Close-up @@ -446,13 +449,14 @@ enum ScanCopy { return out } - /// Coaching in a few words, for a lead that must stay two lines beside its symbol at AX5. + /// Coaching in a few words, for a lead that must stay two lines beside its symbol at AX5: what + /// to do, since the symbol already says what is wrong and darkness can last a whole walk. static func coachingLead(_ coaching: Coaching) -> String { switch coaching { case .slowDown: "Slow down" case .needsTexture: "Find more texture" - case .tooDark: "It's dark here" - case .tooDarkToMeasure: "Too dark to measure" + case .tooDark: "Try your flashlight" + case .tooDarkToMeasure: "Try in daylight" case .holdSteady: "Hold steady" case .turnSlowly: "Turn more slowly" case .initializing, .relocalizing, .trackingLost, .pastWallEnd: ScanCopy.coaching(coaching).title diff --git a/ios/HouseScan/UI/Screens/WallWalkScreen.swift b/ios/HouseScan/UI/Screens/WallWalkScreen.swift index 7ff4b685e..699259dd0 100644 --- a/ios/HouseScan/UI/Screens/WallWalkScreen.swift +++ b/ios/HouseScan/UI/Screens/WallWalkScreen.swift @@ -87,7 +87,7 @@ struct WallWalkScreen: View { // Folded around the circle, ride-along coaching leads or opens Details instead of sitting // under the lead (`ScanCopy.foldedAroundCircle`). guard endAimFolds else { return stepCard } - return ScanCopy.foldedAroundCircle(stepCard, coaching: coaching, refused: state.endMarkRefusal != nil) + return ScanCopy.foldedAroundCircle(stepCard, coaching: coaching, refused: state.endMarkRefusal != nil || state.wallTooShort) } /// The card's words for the step, before folding around the circle (`instruction`). @@ -111,7 +111,11 @@ struct WallWalkScreen: View { // Coaching about how the photos come out (the capture gate's, and too little texture) // rides along with the task (`ScanCopy.withCoaching`), and its symbol marks the card (`tone`). if state.wallTooShort { - return ScanCopy.withCoaching(Instruction(title: ScanCopy.wallTooShort, detail: ScanCopy.guidance(state.guidance).title), coaching) + let step = ScanCopy.guidance(state.guidance).title + // Folded around the circle (the walk can ask for the end again after the too-close + // ends were cleared), the long title would cover it: the correction leads instead. + let folded = Instruction.Folded(title: ScanCopy.wallTooShortLead, detail: "\(ScanCopy.wallTooShort) \(step)") + return ScanCopy.withCoaching(Instruction(title: ScanCopy.wallTooShort, detail: step, folded: folded), coaching) } return ScanCopy.withCoaching(ScanCopy.guidance(state.guidance, hint: state.guidanceHint), coaching) } diff --git a/ios/HouseScanUITests/PastEndRecoveryUITests.swift b/ios/HouseScanUITests/PastEndRecoveryUITests.swift index 0ec6a0685..87b568069 100644 --- a/ios/HouseScanUITests/PastEndRecoveryUITests.swift +++ b/ios/HouseScanUITests/PastEndRecoveryUITests.swift @@ -102,7 +102,7 @@ final class PastEndRecoveryUITests: XCTestCase { func testCoachingOnAFoldedPastEndCardKeepsTheCircleOpen() throws { let app = launch(["-uiDemoGap", "pastEnd", "-uiDemoCoaching", "tooDark"] + Self.largestText) let card = label(app, "instruction") - XCTAssertTrue(card.contains("It's dark here"), "card reads: \(card)") + XCTAssertTrue(card.contains("Try your flashlight"), "card reads: \(card)") XCTAssertFalse(card.contains("flashlight"), "the coaching's note must fold under Details: \(card)") let mark = app.buttons["action.markEnd"] scrollIntoView(mark, in: app) @@ -113,7 +113,7 @@ final class PastEndRecoveryUITests: XCTestCase { tapWhenReady(app.buttons["instruction.details"]) let detail = element(app, "instruction.detail") XCTAssertTrue(detail.waitForExistence(timeout: 5)) - for words in ["Try your phone's flashlight", "Keep walking past the right end", "If the wall stops sooner"] { + for words in ["It's dark here. Try your phone's flashlight", "Keep walking past the right end", "If the wall stops sooner"] { XCTAssertTrue(detail.label.contains(words), "Details must hold \"\(words)\": \(detail.label)") } } diff --git a/ios/HouseScanUITests/WalkRecoveryUITests.swift b/ios/HouseScanUITests/WalkRecoveryUITests.swift index 03888733a..1d802449a 100644 --- a/ios/HouseScanUITests/WalkRecoveryUITests.swift +++ b/ios/HouseScanUITests/WalkRecoveryUITests.swift @@ -100,7 +100,7 @@ final class WalkRecoveryUITests: XCTestCase { func testCoachingOnTheFoldedEndCardKeepsTheCircleOpen() throws { let app = launch(["-uiDemoPhase", "wallWalk", "-uiDemoMarkEnd", "-uiDemoCoaching", "tooDark"] + Self.largestText) let card = label(app, "instruction") - XCTAssertTrue(card.contains("It's dark here"), "card reads: \(card)") + XCTAssertTrue(card.contains("Try your flashlight"), "card reads: \(card)") XCTAssertFalse(card.contains("flashlight"), "the coaching's note must fold under Details: \(card)") let mark = element(app, "action.markEnd") scrollIntoView(mark, in: app) From 55449e33254d3069a4e8603804fe4c39e1925fe0 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 3 Oct 2026 23:01:20 -0500 Subject: [PATCH 4/5] ios: the past-end circle is in the view only while offered, so VoiceOver can't stop on it under the question CI run 37171342665 on 254a4566: the circle's geometry held in every new test. Each one measured it 56x56, at the window's exact centre, and clear of the card and the actions: walk and past-end, default and AX5, the left side, coached and other-side refusals. But testMarkingTheEndAsksWhatIsThere(AndSettlesTheRequest|AtLargestTextSize) failed on "the question shows no circle". The recording shows the circle faded out under the question, yet its `aim.circle` element was still in the tree: `.accessibilityHidden` on the element `.accessibilityElement(children: .ignore)` made didn't remove it. So VoiceOver could stop on an "Aiming circle" that isn't on screen. The circle is now inserted and removed with an opacity transition instead of kept at zero opacity. The fade is unchanged: Motion.settle with "Wall ends here", and a brief ease under Reduce Motion. The test's assertion is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../UI/Components/EndAimCircle.swift | 10 +++++----- .../UI/Screens/GapRequestScreen.swift | 20 ++++++++++++------- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/ios/HouseScan/UI/Components/EndAimCircle.swift b/ios/HouseScan/UI/Components/EndAimCircle.swift index 810649c08..7cef095aa 100644 --- a/ios/HouseScan/UI/Components/EndAimCircle.swift +++ b/ios/HouseScan/UI/Components/EndAimCircle.swift @@ -4,13 +4,13 @@ extension View { /// The circle a mark lands under (`Reticle`), as one accessibility element: "Aiming circle". /// The reticle itself is hidden from VoiceOver as decoration. Here it says where "Wall ends /// here" and the other marks land, and UI tests read its real frame to check that nothing - /// covers it (`PastEndRecoveryUITests`, `ScreenStatesUITests`). Apply it before the reticle - /// is stretched to the screen, so the element's frame is the circle's. `shown` false while - /// the circle is faded out, so VoiceOver doesn't stop on what isn't there. - func endAimCircle(shown: Bool = true) -> some View { + /// covers it (`PastEndRecoveryUITests`, `WalkRecoveryUITests`). Apply it before the reticle + /// is stretched to the screen, so the element's frame is the circle's. Show the circle only + /// while it is offered: `.accessibilityHidden` on this element left it in the tree at zero + /// opacity (CI run 37171342665), so VoiceOver would stop on a circle that isn't there. + func endAimCircle() -> some View { accessibilityElement(children: .ignore) .accessibilityLabel("Aiming circle") .accessibilityIdentifier("aim.circle") - .accessibilityHidden(!shown) } } diff --git a/ios/HouseScan/UI/Screens/GapRequestScreen.swift b/ios/HouseScan/UI/Screens/GapRequestScreen.swift index e251018f9..63577b486 100644 --- a/ios/HouseScan/UI/Screens/GapRequestScreen.swift +++ b/ios/HouseScan/UI/Screens/GapRequestScreen.swift @@ -35,14 +35,20 @@ struct GapRequestScreen: View { // The end lands under the circle, as on the walk's "Is this the right end?". It fades // with "Wall ends here" on the same spring, so the pair comes and goes together; // scoped to the circle, so nothing else on the camera animates with it. Under Reduce - // Motion only its opacity changes, briefly: nothing moves. + // Motion only its opacity changes, briefly: nothing moves. It is there only while + // offered, rather than kept at zero opacity: kept, its "Aiming circle" stayed in the + // accessibility tree under the end question, hidden or not (CI run 37171342665). if state.gap?.pastEndSide != nil { - Reticle(diameter: 56) - .endAimCircle(shown: offersEndMark) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .ignoresSafeArea() - .opacity(offersEndMark ? 1 : 0) - .animation(reduceMotion ? .easeOut(duration: 0.15) : Motion.settle, value: offersEndMark) + ZStack { + if offersEndMark { + Reticle(diameter: 56) + .endAimCircle() + .transition(.opacity) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .ignoresSafeArea() + .animation(reduceMotion ? .easeOut(duration: 0.15) : Motion.settle, value: offersEndMark) } if state.gap?.isSatisfied == true { SuccessBadge() From 6a5b24018fce045061dd4a7a584a4a3566be71c4 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 3 Oct 2026 23:05:17 -0500 Subject: [PATCH 5/5] ios: the coached fold tests look for the note's words, not the lead's Greptile on #214: since b1dc2525 the folded lead is "Try your flashlight", and the tests asserted both that the card shows it and that the card has no "flashlight". They now check that the note's own words, "Try your phone's flashlight", are under Details, not on the card. Co-Authored-By: Claude Opus 5.5 (1M context) --- ios/HouseScanUITests/PastEndRecoveryUITests.swift | 2 +- ios/HouseScanUITests/WalkRecoveryUITests.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ios/HouseScanUITests/PastEndRecoveryUITests.swift b/ios/HouseScanUITests/PastEndRecoveryUITests.swift index 87b568069..14d2250ab 100644 --- a/ios/HouseScanUITests/PastEndRecoveryUITests.swift +++ b/ios/HouseScanUITests/PastEndRecoveryUITests.swift @@ -103,7 +103,7 @@ final class PastEndRecoveryUITests: XCTestCase { let app = launch(["-uiDemoGap", "pastEnd", "-uiDemoCoaching", "tooDark"] + Self.largestText) let card = label(app, "instruction") XCTAssertTrue(card.contains("Try your flashlight"), "card reads: \(card)") - XCTAssertFalse(card.contains("flashlight"), "the coaching's note must fold under Details: \(card)") + XCTAssertFalse(card.contains("Try your phone's flashlight"), "the coaching's note must fold under Details: \(card)") let mark = app.buttons["action.markEnd"] scrollIntoView(mark, in: app) XCTAssertTrue(mark.isHittable) diff --git a/ios/HouseScanUITests/WalkRecoveryUITests.swift b/ios/HouseScanUITests/WalkRecoveryUITests.swift index 1d802449a..e13626a39 100644 --- a/ios/HouseScanUITests/WalkRecoveryUITests.swift +++ b/ios/HouseScanUITests/WalkRecoveryUITests.swift @@ -101,7 +101,7 @@ final class WalkRecoveryUITests: XCTestCase { let app = launch(["-uiDemoPhase", "wallWalk", "-uiDemoMarkEnd", "-uiDemoCoaching", "tooDark"] + Self.largestText) let card = label(app, "instruction") XCTAssertTrue(card.contains("Try your flashlight"), "card reads: \(card)") - XCTAssertFalse(card.contains("flashlight"), "the coaching's note must fold under Details: \(card)") + XCTAssertFalse(card.contains("Try your phone's flashlight"), "the coaching's note must fold under Details: \(card)") let mark = element(app, "action.markEnd") scrollIntoView(mark, in: app) XCTAssertTrue(mark.isHittable)