From 6af4d8f3c521963594c2e9479ee3ecf1d2fe699d Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sun, 27 Sep 2026 03:27:38 -0500 Subject: [PATCH 1/8] ios: practice meter, a drawn sample meter for scanning without one A developer switch, offered only on Debug and TestFlight installs, turns a scan into practice: the homeowner taps any wall, a sample meter is drawn there on the meter's anchor, and the close-up stores a photo of the sample (made-up maker, fake number) under the live frame's pose, which the real reader reads. Everything after the close-up runs as usual. Screens show a "Practice meter" badge and scan-stamp.json records practice: true. The switch lives in a developer sheet on the first screen, not a Settings.bundle: TestFlight and the App Store ship the same binary, so a settings bundle would show in App Store installs. StoreKit's AppTransaction environment decides at run time; no answer counts as the App Store. Co-Authored-By: Claude Opus 5.5 (1M context) --- ios/HouseScan/Contract/ScanContract.swift | 4 + ios/HouseScan/Runtime/LaunchOptions.swift | 5 + .../Runtime/Practice/DeveloperSettings.swift | 66 ++++++ .../Runtime/Practice/SampleMeterArt.swift | 198 ++++++++++++++++++ .../Runtime/ScanEngine+Actions.swift | 1 + .../Runtime/ScanEngine+Practice.swift | 36 ++++ ios/HouseScan/Runtime/ScanEngine+Stamp.swift | 3 +- ios/HouseScan/Runtime/ScanEngine.swift | 4 +- .../UI/Components/PhotoCounter.swift | 42 +++- ios/HouseScan/UI/Copy/ScanCopy.swift | 7 + .../UI/Practice/DeveloperOptions.swift | 87 ++++++++ .../UI/Practice/PracticeMeterOverlay.swift | 58 +++++ ios/HouseScan/UI/ScanRootView.swift | 4 + .../UI/Screens/FindMeterScreen.swift | 8 +- .../UI/Screens/OnboardingScreen.swift | 1 + .../Geometry/QuadHomography.swift | 54 +++++ .../HouseScanKit/Practice/PracticeMeter.swift | 74 +++++++ .../HouseScanKit/Scene/ScanStamp.swift | 10 +- .../PracticeMeterTests.swift | 120 +++++++++++ ios/HouseScanUITests/FullFlowUITests.swift | 70 ++++++- .../PracticeMeterUITests.swift | 44 ++++ .../SourceRestartUITests.swift | 4 +- ios/README.md | 4 + 23 files changed, 885 insertions(+), 19 deletions(-) create mode 100644 ios/HouseScan/Runtime/Practice/DeveloperSettings.swift create mode 100644 ios/HouseScan/Runtime/Practice/SampleMeterArt.swift create mode 100644 ios/HouseScan/Runtime/ScanEngine+Practice.swift create mode 100644 ios/HouseScan/UI/Practice/DeveloperOptions.swift create mode 100644 ios/HouseScan/UI/Practice/PracticeMeterOverlay.swift create mode 100644 ios/HouseScanKit/Sources/HouseScanKit/Geometry/QuadHomography.swift create mode 100644 ios/HouseScanKit/Sources/HouseScanKit/Practice/PracticeMeter.swift create mode 100644 ios/HouseScanKit/Tests/HouseScanKitTests/PracticeMeterTests.swift create mode 100644 ios/HouseScanUITests/PracticeMeterUITests.swift diff --git a/ios/HouseScan/Contract/ScanContract.swift b/ios/HouseScan/Contract/ScanContract.swift index 801525c49..fab4f6f9a 100644 --- a/ios/HouseScan/Contract/ScanContract.swift +++ b/ios/HouseScan/Contract/ScanContract.swift @@ -752,6 +752,10 @@ final class ScanViewState { var isReplay = false /// True when the autopilot is driving the intents (UI tests, demos). Show a small badge. var isAutopilot = false + /// True for a practice scan (HouseScanKit `PracticeMeter`): a drawn sample meter stands in + /// for the electric meter and its close-up photo. Every screen that could pass for a real scan + /// shows a "Practice meter" badge. Set when the scan starts, from the developer options. + var isPracticeScan = false /// True when no server is configured and the result will be the bundled sample: nothing is /// sent, and every screen that talks about the upload or shows the spot must say so. var usesSampleResult = false diff --git a/ios/HouseScan/Runtime/LaunchOptions.swift b/ios/HouseScan/Runtime/LaunchOptions.swift index e5deeb9c6..58e7c8f4d 100644 --- a/ios/HouseScan/Runtime/LaunchOptions.swift +++ b/ios/HouseScan/Runtime/LaunchOptions.swift @@ -20,6 +20,9 @@ import OSLog /// marking the ends (`Autopilot.endWalkByCantGetThere`). /// - `-autopilotSomethingThere`: the autopilot answers the first spot check "Something's there" /// instead of "It's clear", so the scan is checked again without that area. +/// - `-simulateAppStore`: run as an App Store install would, so the developer options and practice +/// meter are unavailable whatever the stored switch says (`DeveloperSettings`). It can only take +/// the switch away, never offer it. struct LaunchOptions: Equatable { var replayFolder: URL? var autopilot = false @@ -29,6 +32,7 @@ struct LaunchOptions: Equatable { var autopilotGate: URL? var autopilotCantGetThere = false var autopilotSomethingThere = false + var simulateAppStore = false init( arguments: [String] = ProcessInfo.processInfo.arguments, @@ -44,6 +48,7 @@ struct LaunchOptions: Equatable { autopilot = arguments.contains("-autopilot") autopilotCantGetThere = arguments.contains("-autopilotCantGetThere") autopilotSomethingThere = arguments.contains("-autopilotSomethingThere") + simulateAppStore = arguments.contains("-simulateAppStore") serverURL = (value(after: "-serverURL") ?? defaultServerURL).flatMap(Self.serverURL) sampleResult = arguments.contains("-sampleResult") if let gate = value(after: "-autopilotGate") { autopilotGate = URL(fileURLWithPath: gate, isDirectory: true) } diff --git a/ios/HouseScan/Runtime/Practice/DeveloperSettings.swift b/ios/HouseScan/Runtime/Practice/DeveloperSettings.swift new file mode 100644 index 000000000..5e2423867 --- /dev/null +++ b/ios/HouseScan/Runtime/Practice/DeveloperSettings.swift @@ -0,0 +1,66 @@ +import Foundation +import HouseScanKit +import Observation +import StoreKit + +/// The developer options sheet's settings, and whether this install may show them. +/// +/// The switch lives in the app, not in the iOS Settings app: a Settings.bundle is a file in the +/// app bundle, and a TestFlight build is the same binary the App Store later ships, so its +/// switch would show in App Store installs too. Here the sheet and the switch appear only where +/// `PracticeMeter.isAvailable` says, decided at run time from where the install came from. +@MainActor +@Observable +final class DeveloperSettings { + static let shared = DeveloperSettings(simulateAppStore: LaunchOptions().simulateAppStore) + + /// The UserDefaults key the sheet's switch writes. A launch argument `-practiceMeter NO` + /// overrides it for one run (UserDefaults' argument domain), which the UI tests use. + static let practiceMeterKey = "practiceMeter" + + /// Where this install came from; `.unknown` until StoreKit answers. + private(set) var environment: PracticeMeter.InstallEnvironment + + init(simulateAppStore: Bool) { + if simulateAppStore { + environment = .appStore + return + } + #if DEBUG + environment = .development + #else + environment = .unknown + Task { [weak self] in + let found = await Self.storeEnvironment() + self?.environment = found + RuntimeLog.engine.info("install environment: \(found.rawValue, privacy: .public)") + } + #endif + } + + /// Whether the developer options are offered at all. + var isAvailable: Bool { PracticeMeter.isAvailable(in: environment) } + + /// Whether a scan started now is a practice scan. + var practiceMeterOn: Bool { + PracticeMeter.isOn(requested: UserDefaults.standard.bool(forKey: Self.practiceMeterKey), in: environment) + } + + /// StoreKit's signed app transaction says which server signed it: the sandbox for TestFlight, + /// production for the App Store, Xcode for StoreKit testing. Anything unverified or missing is + /// `.unknown`, which offers nothing. + nonisolated private static func storeEnvironment() async -> PracticeMeter.InstallEnvironment { + do { + guard case .verified(let transaction) = try await AppTransaction.shared else { return .unknown } + switch transaction.environment { + case .sandbox: return .testFlight + case .production: return .appStore + case .xcode: return .development + default: return .unknown + } + } catch { + RuntimeLog.engine.error("install environment unknown: \(String(describing: error), privacy: .public)") + return .unknown + } + } +} diff --git a/ios/HouseScan/Runtime/Practice/SampleMeterArt.swift b/ios/HouseScan/Runtime/Practice/SampleMeterArt.swift new file mode 100644 index 000000000..07d78cfab --- /dev/null +++ b/ios/HouseScan/Runtime/Practice/SampleMeterArt.swift @@ -0,0 +1,198 @@ +import CoreGraphics +import CoreText +import Foundation +import HouseScanKit +import ImageIO +import UniformTypeIdentifiers + +/// The practice meter's picture, drawn in code: a gray socket-meter box with a glass dome, the +/// made-up maker and the fake number (`PracticeMeter.printedLines`). The camera view draws it on +/// the wall at the tap (`PracticeMeterOverlay`), and a practice close-up stores a photo of it on a +/// sided wall (`closeUpJPEG`), which the meter-number reader then reads. +/// +/// Only Core Graphics, Core Text and ImageIO, with fonts both iOS and macOS ship, so the same +/// drawing renders on either. +enum SampleMeterArt { + /// Width over height of the drawn box, as `PracticeMeter.plateSize`. + static let aspect = CGFloat(PracticeMeter.plateSize.x / PracticeMeter.plateSize.y) + + /// The box alone on a clear background, upright, for the camera view. 600 px wide: sharp at + /// the size the close-up holds it on screen, about half the screen's width. + static let face: CGImage? = { + let width = 600 + let height = Int((CGFloat(width) / aspect).rounded()) + guard let context = uprightContext(width: width, height: height, opaque: false) else { return nil } + drawMeter(in: context, rect: CGRect(x: 0, y: 0, width: width, height: height)) + return context.makeImage() + }() + + /// A close-up photo of the sample on a sided wall, as the camera stores one: the landscape + /// sensor image of `width` by `height` pixels, unrotated, whose upright view is it turned 90° + /// clockwise (`ImageWork.uprightThumbnail`, and the reader's EXIF `.right`). The practice + /// close-up passes the live frame's size, so the stored photo's pixels still match the + /// frame's intrinsics. Nil when a context can't be made. + static func closeUpJPEG(width: Int, height: Int) -> Data? { + // Upright, the photo is portrait: the sensor's height across, its width down. + let uprightWidth = height + let uprightHeight = width + guard width > 0, height > 0, let upright = uprightContext(width: uprightWidth, height: uprightHeight, opaque: true) else { return nil } + let size = CGSize(width: uprightWidth, height: uprightHeight) + drawWall(in: upright, size: size) + // The box fills 78% of the photo's height, as a close-up held at the gate's distance does. + let boxHeight = size.height * 0.78 + let box = CGRect(x: (size.width - boxHeight * aspect) / 2, y: size.height * 0.47 - boxHeight / 2, width: boxHeight * aspect, height: boxHeight) + drawConduit(in: upright, below: box, size: size) + upright.saveGState() + upright.setShadow(offset: CGSize(width: 0, height: box.height * 0.02), blur: box.height * 0.05, color: CGColor(gray: 0, alpha: 0.35)) + upright.addPath(boxPath(box)) + upright.setFillColor(gray: 0.6, alpha: 1) + upright.fillPath() + upright.restoreGState() + drawMeter(in: upright, rect: box) + guard let image = upright.makeImage(), + let sensor = CGContext( + data: nil, width: width, height: height, bitsPerComponent: 8, bytesPerRow: 0, + space: CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue + ) else { return nil } + // Turned 90° counterclockwise: the inverse of `ImageWork.rotatedClockwise`, so turning the + // stored image clockwise gives the upright photo back. + sensor.translateBy(x: CGFloat(width), y: 0) + sensor.rotate(by: .pi / 2) + sensor.draw(image, in: CGRect(x: 0, y: 0, width: image.width, height: image.height)) + guard let stored = sensor.makeImage() else { return nil } + return jpeg(stored) + } + + // MARK: Drawing + + /// An sRGB context whose user space runs y down from the top left, as the layout below is + /// written, with Core Text set to draw upright glyphs in it. + private static func uprightContext(width: Int, height: Int, opaque: Bool) -> CGContext? { + guard let context = CGContext( + data: nil, width: width, height: height, bitsPerComponent: 8, bytesPerRow: 0, + space: CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB(), + bitmapInfo: (opaque ? CGImageAlphaInfo.noneSkipLast : CGImageAlphaInfo.premultipliedLast).rawValue + ) else { return nil } + context.translateBy(x: 0, y: CGFloat(height)) + context.scaleBy(x: 1, y: -1) + context.textMatrix = CGAffineTransform(scaleX: 1, y: -1) + return context + } + + private static func boxPath(_ box: CGRect) -> CGPath { + CGPath(roundedRect: box, cornerWidth: box.width * 0.08, cornerHeight: box.width * 0.08, transform: nil) + } + + /// Beige lap siding: a board every 4.5% of the height, each with a shadow line at its lower + /// edge. + private static func drawWall(in context: CGContext, size: CGSize) { + context.setFillColor(red: 0.86, green: 0.83, blue: 0.77, alpha: 1) + context.fill(CGRect(origin: .zero, size: size)) + let board = size.height * 0.045 + var y = board + while y < size.height { + context.setFillColor(red: 0.74, green: 0.71, blue: 0.65, alpha: 1) + context.fill(CGRect(x: 0, y: y - board * 0.08, width: size.width, height: board * 0.08)) + context.setFillColor(red: 0.91, green: 0.89, blue: 0.84, alpha: 1) + context.fill(CGRect(x: 0, y: y, width: size.width, height: board * 0.06)) + y += board + } + } + + /// The conduit that feeds the meter, from under the box to the bottom of the photo. + private static func drawConduit(in context: CGContext, below box: CGRect, size: CGSize) { + let pipe = CGRect(x: box.midX - box.width * 0.07, y: box.maxY - box.height * 0.05, width: box.width * 0.14, height: size.height - box.maxY + box.height * 0.05) + context.setFillColor(gray: 0.55, alpha: 1) + context.fill(pipe) + context.setFillColor(gray: 0.68, alpha: 1) + context.fill(CGRect(x: pipe.minX + pipe.width * 0.2, y: pipe.minY, width: pipe.width * 0.18, height: pipe.height)) + } + + /// The meter box filling `rect` (2:3), in an upright context. + static func drawMeter(in context: CGContext, rect box: CGRect) { + let w = box.width + let h = box.height + func point(_ x: CGFloat, _ y: CGFloat) -> CGPoint { CGPoint(x: box.minX + x * w, y: box.minY + y * h) } + let space = CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB() + + // Box: brushed gray, lighter at the top, with a dark edge and a pale inner bevel. + context.saveGState() + context.addPath(boxPath(box)) + context.clip() + if let gradient = CGGradient(colorsSpace: space, colors: [CGColor(gray: 0.80, alpha: 1), CGColor(gray: 0.62, alpha: 1)] as CFArray, locations: [0, 1]) { + context.drawLinearGradient(gradient, start: point(0.5, 0), end: point(0.5, 1), options: []) + } + context.restoreGState() + context.addPath(boxPath(box.insetBy(dx: w * 0.03, dy: w * 0.03))) + context.setStrokeColor(gray: 1, alpha: 0.35) + context.setLineWidth(w * 0.008) + context.strokePath() + context.addPath(boxPath(box.insetBy(dx: w * 0.006, dy: w * 0.006))) + context.setStrokeColor(gray: 0.38, alpha: 1) + context.setLineWidth(w * 0.012) + context.strokePath() + for (x, y) in [(0.09, 0.06), (0.91, 0.06), (0.09, 0.94), (0.91, 0.94)] { + let center = point(x, y) + let r = w * 0.025 + context.setFillColor(gray: 0.5, alpha: 1) + context.fillEllipse(in: CGRect(x: center.x - r, y: center.y - r, width: 2 * r, height: 2 * r)) + context.setStrokeColor(gray: 0.3, alpha: 1) + context.setLineWidth(r * 0.35) + context.strokeLineSegments(between: [CGPoint(x: center.x - r * 0.6, y: center.y), CGPoint(x: center.x + r * 0.6, y: center.y)]) + } + + // Glass dome on a metal collar. + let dome = point(0.5, 0.36) + let collar = w * 0.44 + let glass = w * 0.40 + context.setFillColor(gray: 0.5, alpha: 1) + context.fillEllipse(in: CGRect(x: dome.x - collar, y: dome.y - collar, width: 2 * collar, height: 2 * collar)) + context.saveGState() + context.addEllipse(in: CGRect(x: dome.x - glass, y: dome.y - glass, width: 2 * glass, height: 2 * glass)) + context.clip() + if let gradient = CGGradient(colorsSpace: space, colors: [CGColor(gray: 0.99, alpha: 1), CGColor(gray: 0.86, alpha: 1)] as CFArray, locations: [0, 1]) { + context.drawRadialGradient(gradient, startCenter: CGPoint(x: dome.x - glass * 0.3, y: dome.y - glass * 0.35), startRadius: 0, endCenter: dome, endRadius: glass, options: []) + } + context.restoreGState() + + // The face: maker, practice warning, display and ratings. + let lines = PracticeMeter.printedLines + drawText(lines[0], font: "HelveticaNeue-Bold", size: w * 0.07, color: CGColor(gray: 0.12, alpha: 1), centeredAt: point(0.5, 0.228), in: context) + drawText(lines[1], font: "HelveticaNeue-Bold", size: w * 0.045, color: CGColor(srgbRed: 0.78, green: 0.30, blue: 0.05, alpha: 1), centeredAt: point(0.5, 0.27), in: context) + let display = CGRect(x: box.minX + w * 0.25, y: box.minY + h * 0.3, width: w * 0.5, height: h * 0.07) + context.setFillColor(red: 0.16, green: 0.20, blue: 0.18, alpha: 1) + context.fill(display) + drawText(lines[2], font: "Menlo-Bold", size: w * 0.055, color: CGColor(srgbRed: 0.62, green: 0.93, blue: 0.70, alpha: 1), centeredAt: CGPoint(x: display.midX, y: display.midY), in: context) + drawText(lines[3], font: "HelveticaNeue", size: w * 0.045, color: CGColor(gray: 0.25, alpha: 1), centeredAt: point(0.5, 0.435), in: context) + + // The number on its own white label under the dome, as utilities stick it on. + let label = CGRect(x: box.minX + w * 0.05, y: box.minY + h * 0.74, width: w * 0.9, height: h * 0.13) + context.addPath(CGPath(roundedRect: label, cornerWidth: w * 0.02, cornerHeight: w * 0.02, transform: nil)) + context.setFillColor(gray: 0.98, alpha: 1) + context.fillPath() + drawText(lines[4], font: "Menlo-Bold", size: w * 0.115, color: CGColor(gray: 0.08, alpha: 1), centeredAt: CGPoint(x: label.midX, y: label.midY), in: context) + } + + /// One line centered on `center`, its baseline placed so the line's ink box is centered. + private static func drawText(_ text: String, font name: String, size: CGFloat, color: CGColor, centeredAt center: CGPoint, in context: CGContext) { + let font = CTFontCreateWithName(name as CFString, size, nil) + let attributes: [NSAttributedString.Key: Any] = [ + NSAttributedString.Key(kCTFontAttributeName as String): font, + NSAttributedString.Key(kCTForegroundColorAttributeName as String): color, + ] + let line = CTLineCreateWithAttributedString(NSAttributedString(string: text, attributes: attributes)) + var ascent: CGFloat = 0 + var descent: CGFloat = 0 + let width = CGFloat(CTLineGetTypographicBounds(line, &ascent, &descent, nil)) + context.textPosition = CGPoint(x: center.x - width / 2, y: center.y + (ascent - descent) / 2) + CTLineDraw(line, context) + } + + private static func jpeg(_ image: CGImage) -> Data? { + let data = NSMutableData() + guard let destination = CGImageDestinationCreateWithData(data, UTType.jpeg.identifier as CFString, 1, nil) else { return nil } + CGImageDestinationAddImage(destination, image, [kCGImageDestinationLossyCompressionQuality: 0.9] as CFDictionary) + return CGImageDestinationFinalize(destination) ? data as Data : nil + } +} diff --git a/ios/HouseScan/Runtime/ScanEngine+Actions.swift b/ios/HouseScan/Runtime/ScanEngine+Actions.swift index 845c7535f..932007f18 100644 --- a/ios/HouseScan/Runtime/ScanEngine+Actions.swift +++ b/ios/HouseScan/Runtime/ScanEngine+Actions.swift @@ -8,6 +8,7 @@ import SwiftUI extension ScanEngine: ScanActions { func finishOnboarding() { guard state.phase == .onboarding else { return } + startPracticeIfOn() go(.findMeter) } diff --git a/ios/HouseScan/Runtime/ScanEngine+Practice.swift b/ios/HouseScan/Runtime/ScanEngine+Practice.swift new file mode 100644 index 000000000..d1f2396ab --- /dev/null +++ b/ios/HouseScan/Runtime/ScanEngine+Practice.swift @@ -0,0 +1,36 @@ +import Foundation +import HouseScanKit + +/// Practice meter (HouseScanKit `PracticeMeter`). The meter tap is the usual one: the same raycast +/// and refusals, since they guard the wall's geometry, and the tapped spot becomes the meter's +/// anchor, where the camera view draws the sample (`PracticeMeterOverlay`). Only the close-up's +/// photo changes; its pose, depth and the view coverage takes from it stay the live frame's. +extension ScanEngine { + /// Reads the developer switch when a scan starts; the scan keeps the answer to its end. + func startPracticeIfOn() { + state.isPracticeScan = DeveloperSettings.shared.practiceMeterOn + guard state.isPracticeScan else { return } + RuntimeLog.engine.info("practice meter: a drawn sample stands in for the meter and its close-up photo") + } + + /// The frame whose photo the close-up stores and the reader reads: `frame` itself, or on a + /// practice scan `frame` with a photo of the sample in place of the camera's, at the camera + /// image's size so the frame's intrinsics still fit it. Without a photo when the sample can't + /// be drawn, so the save fails and the close-up asks for a retake rather than storing a photo + /// of whatever the camera saw. + func closeUpPhoto(_ frame: SourceFrame) async -> SourceFrame { + guard state.isPracticeScan else { return frame } + let size = frame.camera.imageSize + let jpeg = await Task.detached(priority: .userInitiated) { + SampleMeterArt.closeUpJPEG(width: Int(size.x), height: Int(size.y)) + }.value + var photo = frame + if let jpeg { + photo.jpeg = .data(jpeg) + } else { + RuntimeLog.engine.error("practice meter: the sample photo could not be drawn at \(Int(size.x))x\(Int(size.y))") + photo.jpeg = .none + } + return photo + } +} diff --git a/ios/HouseScan/Runtime/ScanEngine+Stamp.swift b/ios/HouseScan/Runtime/ScanEngine+Stamp.swift index 8bff50086..2effa67f4 100644 --- a/ios/HouseScan/Runtime/ScanEngine+Stamp.swift +++ b/ios/HouseScan/Runtime/ScanEngine+Stamp.swift @@ -16,7 +16,8 @@ extension ScanEngine { // Written into the built Info.plist by the "Stamp the git commit" build phase. commit: info["HouseScanGitCommit"] as? String ?? "unknown"), server: .init(url: (resultClient as? HTTPResultClient)?.serverURL.absoluteString), - answer: answer.map { ScanStamp.Answer($0, sample: resultClient.isSample) }) + answer: answer.map { ScanStamp.Answer($0, sample: resultClient.isSample) }, + practice: state.isPracticeScan) let file = store.directory.appending(path: ScanStamp.fileName) do { try stamp.jsonData().write(to: file, options: .atomic) diff --git a/ios/HouseScan/Runtime/ScanEngine.swift b/ios/HouseScan/Runtime/ScanEngine.swift index de568c086..a2fe82af6 100644 --- a/ios/HouseScan/Runtime/ScanEngine.swift +++ b/ios/HouseScan/Runtime/ScanEngine.swift @@ -494,7 +494,8 @@ final class ScanEngine { Task { // The task can start after a reset or after the flow left the close-up. guard scan == generation, state.phase == .meterCloseUp else { return } - let saved = await store.saveStill(frame, name: "meter_close.jpg") + let photo = await closeUpPhoto(frame) + let saved = await store.saveStill(photo, name: "meter_close.jpg") guard scan == generation, state.phase == .meterCloseUp else { return } if !saved { retakeCloseUp(.blurry) @@ -1594,6 +1595,7 @@ final class ScanEngine { state.meterNumber = nil closeUpRetake = nil meterReadout = nil + state.isPracticeScan = false go(.onboarding) replay?.show(index: 0) } diff --git a/ios/HouseScan/UI/Components/PhotoCounter.swift b/ios/HouseScan/UI/Components/PhotoCounter.swift index 8a5b0eb3f..7cdf0cb46 100644 --- a/ios/HouseScan/UI/Components/PhotoCounter.swift +++ b/ios/HouseScan/UI/Components/PhotoCounter.swift @@ -35,26 +35,48 @@ struct PhotoCounter: View { } } -/// "Replay" or "Autopilot": tells a watcher that the camera or the taps are not live. +/// "Replay" or "Autopilot": tells a watcher that the camera or the taps are not live. "Practice +/// meter" beside it: the meter is a drawn sample, not a real one. struct ModeBadge: View { var isReplay: Bool var isAutopilot: Bool + /// Set once for every screen by `ScanRootView`, so each badge says a practice scan is one. + @Environment(\.isPracticeScan) private var isPracticeScan var body: some View { + // Side by side, or stacked when the largest text sizes leave no room for both. + ViewThatFits(in: .horizontal) { + HStack(spacing: 6) { capsules } + VStack(alignment: .leading, spacing: 4) { capsules } + } + } + + @ViewBuilder + private var capsules: some View { + if isPracticeScan { + // Its own capsule, first: of everything on the screen, this is what says the meter + // and its number aren't real. + capsule("Practice meter") + .accessibilityIdentifier("practiceBadge") + } if let text { - Text(text) - .font(.caption2.weight(.bold)) - .textCase(.uppercase) - .tracking(0.6) - .foregroundStyle(Palette.ink) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(Palette.caution, in: .capsule) - .accessibilityLabel(text) + capsule(text) .accessibilityIdentifier("modeBadge") } } + private func capsule(_ text: String) -> some View { + Text(text) + .font(.caption2.weight(.bold)) + .textCase(.uppercase) + .tracking(0.6) + .foregroundStyle(Palette.ink) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Palette.caution, in: .capsule) + .accessibilityLabel(text) + } + private var text: String? { switch (isReplay, isAutopilot) { case (true, true): "Replay · Autopilot" diff --git a/ios/HouseScan/UI/Copy/ScanCopy.swift b/ios/HouseScan/UI/Copy/ScanCopy.swift index d806b34c4..4e6d7c73e 100644 --- a/ios/HouseScan/UI/Copy/ScanCopy.swift +++ b/ios/HouseScan/UI/Copy/ScanCopy.swift @@ -13,6 +13,13 @@ struct Instruction: Hashable { enum ScanCopy { // MARK: Guidance + /// Finding the meter on a practice scan (`ScanViewState.isPracticeScan`): any wall will do. + static let practiceFindMeter = Instruction( + title: "Tap a spot on a wall", + detail: "A sample meter goes there, so you can practice the scan without a real one." + ) + static let practiceMarkMeter = "Put the sample meter here" + static func guidance(_ step: GuidanceStep) -> Instruction { switch step { case .findMeter: diff --git a/ios/HouseScan/UI/Practice/DeveloperOptions.swift b/ios/HouseScan/UI/Practice/DeveloperOptions.swift new file mode 100644 index 000000000..b41eef011 --- /dev/null +++ b/ios/HouseScan/UI/Practice/DeveloperOptions.swift @@ -0,0 +1,87 @@ +import HouseScanKit +import SwiftUI + +/// The way into the developer options, on the first screen. Only development and TestFlight +/// installs show it (`DeveloperSettings.isAvailable`); an App Store install shows nothing here. +/// While practice meter is on it says so, so nobody starts a scan without knowing. +struct DeveloperOptionsButton: View { + @AppStorage(DeveloperSettings.practiceMeterKey) private var practiceMeter = false + @State private var showing = false + private let settings = DeveloperSettings.shared + + var body: some View { + if settings.isAvailable { + Button { + showing = true + } label: { + // The words give way to the icon alone at the largest text sizes; the spoken + // label still says the switch is on. + ViewThatFits(in: .horizontal) { + HStack(spacing: 6) { + Image(systemName: "wrench.and.screwdriver.fill") + if practiceMeter { + Text("Practice meter on") + } + } + Image(systemName: "wrench.and.screwdriver.fill") + } + .font(Typeface.caption) + .foregroundStyle(practiceMeter ? Palette.ink : Palette.muted) + .padding(.horizontal, practiceMeter ? 10 : 0) + .padding(.vertical, 5) + .background { + if practiceMeter { Capsule().fill(Palette.caution) } + } + .frame(minWidth: Metrics.minTarget, minHeight: Metrics.minTarget) + .contentShape(.rect) + } + .buttonStyle(PressableStyle()) + .accessibilityLabel(practiceMeter ? "Developer options. Practice meter is on." : "Developer options") + .accessibilityIdentifier("action.developerOptions") + .sheet(isPresented: $showing) { + DeveloperOptionsSheet(environment: settings.environment) + } + } + } +} + +/// One switch for now: practice meter. +private struct DeveloperOptionsSheet: View { + var environment: PracticeMeter.InstallEnvironment + @AppStorage(DeveloperSettings.practiceMeterKey) private var practiceMeter = false + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + Form { + Section { + Toggle("Practice meter", isOn: $practiceMeter) + .accessibilityIdentifier("developer.practiceMeter") + } footer: { + Text("For trying the scan where there's no electric meter. Tap any spot on a wall and a sample meter is drawn there. A photo of it stands in for the meter close-up, and the rest of the scan runs for real. Each screen and the scan's stamp say it's practice. Starts with the next scan.") + } + Section { + LabeledContent("Build", value: Self.name(environment)) + } + } + .navigationTitle("Developer options") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() } + .accessibilityIdentifier("action.closeDeveloperOptions") + } + } + } + .presentationDetents([.medium, .large]) + } + + private static func name(_ environment: PracticeMeter.InstallEnvironment) -> String { + switch environment { + case .development: "Development" + case .testFlight: "TestFlight" + case .appStore: "App Store" + case .unknown: "Unknown" + } + } +} diff --git a/ios/HouseScan/UI/Practice/PracticeMeterOverlay.swift b/ios/HouseScan/UI/Practice/PracticeMeterOverlay.swift new file mode 100644 index 000000000..465f882a1 --- /dev/null +++ b/ios/HouseScan/UI/Practice/PracticeMeterOverlay.swift @@ -0,0 +1,58 @@ +import HouseScanKit +import simd +import SwiftUI + +/// On a practice scan, the sample meter drawn on the wall at the tapped spot, in perspective, on +/// every camera screen after the tap. It follows `state.wall.meter`, which the engine moves with +/// the meter's anchor as ARKit corrects it, so the drawing stays where the anchor is, as the fog +/// and marks do. Painted just above the camera image, so the fog and marks draw over it as they +/// would over a real meter. +struct PracticeMeterOverlay: View { + let state: ScanViewState + + var body: some View { + GeometryReader { proxy in + // Hidden while tracking isn't normal, like the other overlays: from a pose the phone + // doesn't trust, it would sit in the wrong place. + if state.tracking == .normal, let face = SampleMeterArt.face, let transform = transform(in: proxy.size) { + Image(decorative: face, scale: 1) + .resizable() + .frame(width: CGFloat(face.width), height: CGFloat(face.height)) + .projectionEffect(transform) + .frame(width: proxy.size.width, height: proxy.size.height, alignment: .topLeading) + } + } + .ignoresSafeArea() + .allowsHitTesting(false) + .accessibilityHidden(true) + } + + /// Takes the face image's rectangle onto the sample's four corners on screen; nil when the + /// wall isn't set yet or any corner is behind the camera. + private func transform(in size: CGSize) -> ProjectionTransform? { + guard let wall = state.wall, let projection = state.projection, let face = SampleMeterArt.face else { return nil } + var quad: [SIMD2] = [] + for corner in PracticeMeter.plateCorners(meter: wall.meter, along: wall.along, outward: wall.outward) { + guard let point = projection.viewPoint(for: corner, in: size) else { return nil } + quad.append(SIMD2(Double(point.x), Double(point.y))) + } + guard let m = QuadHomography.mapping(width: Double(face.width), height: Double(face.height), to: quad) else { return nil } + // `m[column][row]`; ProjectionTransform names its entries row first. + var transform = ProjectionTransform() + transform.m11 = m[0][0] + transform.m12 = m[1][0] + transform.m13 = m[2][0] + transform.m21 = m[0][1] + transform.m22 = m[1][1] + transform.m23 = m[2][1] + transform.m31 = m[0][2] + transform.m32 = m[1][2] + transform.m33 = m[2][2] + return transform + } +} + +extension EnvironmentValues { + /// True on every screen of a practice scan (`ScanViewState.isPracticeScan`). + @Entry var isPracticeScan = false +} diff --git a/ios/HouseScan/UI/ScanRootView.swift b/ios/HouseScan/UI/ScanRootView.swift index 2d58932cc..9cc143054 100644 --- a/ios/HouseScan/UI/ScanRootView.swift +++ b/ios/HouseScan/UI/ScanRootView.swift @@ -21,6 +21,7 @@ struct ScanRootView: View { .animation(reduceMotion ? .easeOut(duration: 0.15) : Motion.screen, value: state.phase) .preferredColorScheme(Self.showsCamera(state.phase) ? .dark : nil) .modifier(ScanHaptics(state: state)) + .environment(\.isPracticeScan, state.isPracticeScan) } private var stack: some View { @@ -28,6 +29,9 @@ struct ScanRootView: View { if Self.showsCamera(state.phase) { CameraBackdrop(feed: state.feed, actions: actions) .transition(.opacity) + if state.isPracticeScan { + PracticeMeterOverlay(state: state) + } CameraEdgeShade() } // Camera screens crossfade over the live feed. The light screens cut: a crossfade diff --git a/ios/HouseScan/UI/Screens/FindMeterScreen.swift b/ios/HouseScan/UI/Screens/FindMeterScreen.swift index ccaf76cba..a949545dd 100644 --- a/ios/HouseScan/UI/Screens/FindMeterScreen.swift +++ b/ios/HouseScan/UI/Screens/FindMeterScreen.swift @@ -32,10 +32,12 @@ struct FindMeterScreen: View { ripple(at: CGPoint(x: cameraSize.width / 2, y: cameraSize.height / 2)) actions.markMeter(at: nil, viewSize: cameraSize) } label: { - Label("This is my meter", systemImage: "mappin.and.ellipse") + Label(state.isPracticeScan ? ScanCopy.practiceMarkMeter : "This is my meter", systemImage: "mappin.and.ellipse") } .buttonStyle(.primary) - .accessibilityHint("Pins your electric meter at the circle in the middle of the screen") + .accessibilityHint(state.isPracticeScan + ? "Puts the sample meter on the wall at the circle in the middle of the screen" + : "Pins your electric meter at the circle in the middle of the screen") .accessibilityIdentifier("action.markMeter") } } @@ -44,7 +46,7 @@ struct FindMeterScreen: View { private var instruction: Instruction { if let coaching = state.coaching { return ScanCopy.coaching(coaching) } if state.guidance == .aimAtWallForMeter { return ScanCopy.guidance(.aimAtWallForMeter) } - return ScanCopy.guidance(.findMeter) + return state.isPracticeScan ? ScanCopy.practiceFindMeter : ScanCopy.guidance(.findMeter) } private var tone: InstructionCard.Tone { diff --git a/ios/HouseScan/UI/Screens/OnboardingScreen.swift b/ios/HouseScan/UI/Screens/OnboardingScreen.swift index fcce56e6e..76330596e 100644 --- a/ios/HouseScan/UI/Screens/OnboardingScreen.swift +++ b/ios/HouseScan/UI/Screens/OnboardingScreen.swift @@ -34,6 +34,7 @@ struct OnboardingScreen: View { VStack(spacing: 0) { HStack { ModeBadge(isReplay: state.isReplay, isAutopilot: state.isAutopilot) + DeveloperOptionsButton() Spacer() if page < pages.count - 1 { Button("Skip") { diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Geometry/QuadHomography.swift b/ios/HouseScanKit/Sources/HouseScanKit/Geometry/QuadHomography.swift new file mode 100644 index 000000000..1b9a19059 --- /dev/null +++ b/ios/HouseScanKit/Sources/HouseScanKit/Geometry/QuadHomography.swift @@ -0,0 +1,54 @@ +import Foundation +import simd + +/// The perspective transform that takes a `width` by `height` rectangle onto a quadrilateral, +/// for drawing a flat picture onto a wall seen at an angle. Heckbert's square-to-quad mapping +/// ("Fundamentals of Texture Mapping and Image Warping", 1989, section 2.2.3) with the rectangle +/// scaled to the unit square first. +/// +/// The matrix is for row vectors, as SwiftUI's `ProjectionTransform` and `CGAffineTransform` +/// use: `[x y 1] * m` gives `[X Y W]`, and the point is `(X / W, Y / W)`. Its columns are +/// `m.columns.0` = (m11, m21, m31) and so on, so `m[column][row]`. +public enum QuadHomography { + /// Corners are the rectangle's top left (0, 0), top right (width, 0), bottom right + /// (width, height) and bottom left (0, height), in that order. Nil when the rectangle is empty + /// or the quad is degenerate (three corners in a line), where no such transform exists. + public static func mapping(width: Double, height: Double, to quad: [SIMD2]) -> simd_double3x3? { + guard quad.count == 4, width > 0, height > 0 else { return nil } + let (p0, p1, p2, p3) = (quad[0], quad[1], quad[2], quad[3]) + let sum = p0 - p1 + p2 - p3 + var g = 0.0 + var h = 0.0 + if abs(sum.x) > 1e-12 || abs(sum.y) > 1e-12 { + let d1 = p1 - p2 + let d2 = p3 - p2 + let det = d1.x * d2.y - d2.x * d1.y + guard abs(det) > 1e-12 else { return nil } + g = (sum.x * d2.y - d2.x * sum.y) / det + h = (d1.x * sum.y - sum.x * d1.y) / det + } + // Unit square (u, v) to the quad: X = a u + b v + c, Y = d u + e v + f, W = g u + h v + 1. + let a = p1.x - p0.x + g * p1.x + let b = p3.x - p0.x + h * p3.x + let d = p1.y - p0.y + g * p1.y + let e = p3.y - p0.y + h * p3.y + let unit = simd_double3x3(rows: [ + SIMD3(a, d, g), + SIMD3(b, e, h), + SIMD3(p0.x, p0.y, 1), + ]) + guard abs(unit.determinant) > 1e-12 else { return nil } + // u = x / width, v = y / height, applied first. + let scale = simd_double3x3(diagonal: SIMD3(1 / width, 1 / height, 1)) + return scale * unit + } + + /// Where `m` sends the point (x, y); nil when it goes to infinity. + public static func apply(_ m: simd_double3x3, to point: SIMD2) -> SIMD2? { + let row = SIMD3(point.x, point.y, 1) + // Row vector times matrix: component i is the dot product with column i. + let out = SIMD3(simd_dot(row, m.columns.0), simd_dot(row, m.columns.1), simd_dot(row, m.columns.2)) + guard abs(out.z) > 1e-12 else { return nil } + return SIMD2(out.x / out.z, out.y / out.z) + } +} diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Practice/PracticeMeter.swift b/ios/HouseScanKit/Sources/HouseScanKit/Practice/PracticeMeter.swift new file mode 100644 index 000000000..1b1ebdcf2 --- /dev/null +++ b/ios/HouseScanKit/Sources/HouseScanKit/Practice/PracticeMeter.swift @@ -0,0 +1,74 @@ +import Foundation +import simd + +/// Practice meter: a scan with a drawn sample meter standing in for the electric meter, so the +/// whole flow can be tried indoors, where there is no meter. The homeowner taps any spot on a +/// wall, the sample is drawn there, and the close-up's photo is a picture of the sample while its +/// pose stays the live camera's. Everything after the close-up runs as in a real scan. +/// +/// Only development and TestFlight installs may turn it on. An App Store install never can, even +/// when the stored setting says so: a scan of a fake meter must not reach a real homeowner. +public enum PracticeMeter { + /// Where this copy of the app came from, which decides whether practice meter is offered. + public enum InstallEnvironment: String, Sendable, Equatable, CaseIterable { + /// A Debug build, or one Xcode installed with StoreKit testing. + case development + /// A TestFlight build: StoreKit's app transaction comes from the sandbox. + case testFlight + /// An App Store build: the app transaction comes from production. + case appStore + /// Not determined yet, or StoreKit couldn't say. Treated like the App Store. + case unknown + } + + /// Whether the switch is shown at all. + public static func isAvailable(in environment: InstallEnvironment) -> Bool { + switch environment { + case .development, .testFlight: true + case .appStore, .unknown: false + } + } + + /// Whether a scan started now is a practice scan: the stored switch, and only where the + /// switch is offered. A setting left on by a TestFlight build is ignored once the same install + /// is updated from the App Store. + public static func isOn(requested: Bool, in environment: InstallEnvironment) -> Bool { + requested && isAvailable(in: environment) + } + + // MARK: The sample meter + + /// The drawn meter's size on the wall, meters: about a US socket meter's box, 8 by 12 in. The + /// close-up gate's framing checks were set for a real meter, so the stand-in is the same size. + public static let plateSize = SIMD2(0.20, 0.30) + /// How far in front of the wall the drawing sits: 5 mm, so the fog and the wall's own + /// outline don't cut through it. The server never sees it. + public static let plateStandOff: Float = 0.005 + + /// The drawn meter's corners in world meters, centered on the tapped point and upright on the + /// wall: top left, top right, bottom right, bottom left, as someone facing the wall sees them. + /// `along` is +x of the meter frame (to the right when facing the wall) and `outward` its +z. + public static func plateCorners(meter: SIMD3, along: SIMD3, outward: SIMD3) -> [SIMD3] { + let up = SIMD3(0, 1, 0) + let right = simd_normalize(along) * plateSize.x / 2 + let top = up * plateSize.y / 2 + let center = meter + simd_normalize(outward) * plateStandOff + return [center - right + top, center + right + top, center + right - top, center - right - top] + } + + /// The made-up maker printed on the sample. No real maker's name is one edit from it, so the + /// brand reader names none (`MeterBrand.read`). + public static let brand = "SAMPLEWORKS" + /// The sample's meter number: plainly not a real one. + public static let number = "12345678" + + /// Every line of text on the sample, top to bottom, as the close-up photo prints it. The + /// drawing prints exactly these strings; a test runs them through the reader's ranking. + public static let printedLines: [String] = [ + brand, + "PRACTICE ONLY", + "0042.7 kWh", + "CL200 240V FORM 2S", + "No. \(number)", + ] +} diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Scene/ScanStamp.swift b/ios/HouseScanKit/Sources/HouseScanKit/Scene/ScanStamp.swift index dadd0ce5a..cdcf8d0c6 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Scene/ScanStamp.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Scene/ScanStamp.swift @@ -61,13 +61,18 @@ public struct ScanStamp: Codable, Sendable, Equatable { public var server: Server /// The latest answer for this scan; nil before one arrives. public var answer: Answer? + /// True for a practice scan (`PracticeMeter`): a drawn sample stood in for the electric + /// meter and its close-up photo, so the meter position and number describe no real meter. + /// The packet and scene.json don't say so; this file does. + public var practice: Bool public static let fileName = "scan-stamp.json" - public init(app: App, server: Server, answer: Answer? = nil) { + public init(app: App, server: Server, answer: Answer? = nil, practice: Bool = false) { self.app = app self.server = server self.answer = answer + self.practice = practice } /// Sorted keys, and a missing answer, URL or version written as null, so the file always has @@ -83,10 +88,11 @@ public struct ScanStamp: Codable, Sendable, Equatable { try c.encode(app, forKey: .app) try c.encode(server, forKey: .server) try c.encode(answer, forKey: .answer) + try c.encode(practice, forKey: .practice) } enum CodingKeys: String, CodingKey { - case app, server, answer + case app, server, answer, practice } } diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/PracticeMeterTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/PracticeMeterTests.swift new file mode 100644 index 000000000..e9f72cfc7 --- /dev/null +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/PracticeMeterTests.swift @@ -0,0 +1,120 @@ +import Foundation +import simd +import Testing +@testable import HouseScanKit + +@Suite struct PracticeMeterTests { + // MARK: Who may turn it on + + /// Only development and TestFlight installs offer the switch; an App Store install, or one + /// StoreKit couldn't place, never does. + @Test(arguments: PracticeMeter.InstallEnvironment.allCases) + func onlyDevelopmentAndTestFlightOfferTheSwitch(environment: PracticeMeter.InstallEnvironment) { + let offered: Set = [.development, .testFlight] + #expect(PracticeMeter.isAvailable(in: environment) == offered.contains(environment)) + } + + /// A switch left on by a TestFlight build does nothing once the install comes from the App + /// Store, and nothing while the environment is still unknown. + @Test func aStoredSwitchIsIgnoredOutsideDevelopmentAndTestFlight() { + #expect(!PracticeMeter.isOn(requested: true, in: .appStore)) + #expect(!PracticeMeter.isOn(requested: true, in: .unknown)) + #expect(PracticeMeter.isOn(requested: true, in: .testFlight)) + #expect(PracticeMeter.isOn(requested: true, in: .development)) + #expect(!PracticeMeter.isOn(requested: false, in: .development)) + } + + // MARK: The drawn meter + + /// Centered on the tap, upright, the size of a meter box, and just in front of the wall. + @Test func theSampleStandsUprightOnTheWallAtTheTap() throws { + let meter = SIMD3(1, 1.5, -2) + // A wall facing +x: someone facing it looks along -x, and their right is -z. + let outward = SIMD3(1, 0, 0) + let along = simd_normalize(simd_cross(-outward, SIMD3(0, 1, 0))) + #expect(simd_distance(along, SIMD3(0, 0, -1)) < 1e-6) + let corners = PracticeMeter.plateCorners(meter: meter, along: along, outward: outward) + try #require(corners.count == 4) + let (topLeft, topRight, bottomRight, bottomLeft) = (corners[0], corners[1], corners[2], corners[3]) + let center = corners.reduce(SIMD3.zero, +) / 4 + #expect(simd_distance(center, meter + outward * PracticeMeter.plateStandOff) < 1e-6) + // Top above bottom by the plate's height; right is +along from left by its width. + #expect(abs((topLeft.y - bottomLeft.y) - PracticeMeter.plateSize.y) < 1e-6) + #expect(simd_distance(topRight - topLeft, along * PracticeMeter.plateSize.x) < 1e-6) + #expect(simd_distance(bottomRight - bottomLeft, along * PracticeMeter.plateSize.x) < 1e-6) + // Every corner sits the stand-off in front of the wall's plane through the tap. + for corner in corners { + #expect(abs(simd_dot(corner - meter, outward) - PracticeMeter.plateStandOff) < 1e-6) + } + } + + // MARK: What the reader makes of it + + /// The sample's printed lines as Vision would return them, top to bottom, with the number on + /// its own label: the ranking offers the sample's number first, the size check passes it on a + /// replay-sized photo (640 px tall upright), and no real maker is named. + @Test func theReaderOffersTheSampleNumberFirstAndNamesNoMaker() throws { + let heights: [Double] = [0.045, 0.025, 0.035, 0.022, 0.070] + try #require(heights.count == PracticeMeter.printedLines.count) + var y = 0.15 + let lines = zip(PracticeMeter.printedLines, heights).map { text, height -> MeterTextLine in + defer { y += height + 0.04 } + return MeterTextLine(text: text, box: MeterBox(x: 0.25, y: y, width: 0.5, height: height)) + } + let found = MeterNumberRanking.candidates(lines: lines, barcodePayloads: []) + let choices = try #require(MeterPhotoChecks.choices(from: found, photoHeight: 640)) + #expect(choices.candidates.first?.core == PracticeMeter.number) + #expect(!choices.numberTooSmall) + #expect(MeterBrand.read(lines) == nil) + #expect(MeterBrand.match(PracticeMeter.brand) == nil) + } + + // MARK: Drawing it in perspective + + @Test func theHomographySendsTheRectangleCornersToTheQuad() throws { + let quad: [SIMD2] = [SIMD2(120, 80), SIMD2(310, 110), SIMD2(290, 420), SIMD2(100, 380)] + let m = try #require(QuadHomography.mapping(width: 400, height: 600, to: quad)) + let rectangle: [SIMD2] = [SIMD2(0, 0), SIMD2(400, 0), SIMD2(400, 600), SIMD2(0, 600)] + for (corner, target) in zip(rectangle, quad) { + let mapped = try #require(QuadHomography.apply(m, to: corner)) + #expect(simd_distance(mapped, target) < 1e-9, "\(corner) went to \(mapped), not \(target)") + } + // A perspective map sends the rectangle's center to where the quad's diagonals cross. + let center = try #require(QuadHomography.apply(m, to: SIMD2(200, 300))) + let crossing = try #require(Self.intersection(quad[0], quad[2], quad[1], quad[3])) + #expect(simd_distance(center, crossing) < 1e-9) + } + + @Test func aParallelogramNeedsNoPerspective() throws { + let quad: [SIMD2] = [SIMD2(10, 10), SIMD2(110, 30), SIMD2(130, 230), SIMD2(30, 210)] + let m = try #require(QuadHomography.mapping(width: 100, height: 200, to: quad)) + #expect(abs(m.columns.2.x) < 1e-12 && abs(m.columns.2.y) < 1e-12 && m.columns.2.z == 1) + #expect(try simd_distance(#require(QuadHomography.apply(m, to: SIMD2(100, 200))), quad[2]) < 1e-9) + } + + @Test func aFlattenedQuadHasNoMapping() { + let line: [SIMD2] = [SIMD2(0, 0), SIMD2(1, 1), SIMD2(2, 2), SIMD2(3, 3)] + #expect(QuadHomography.mapping(width: 1, height: 1, to: line) == nil) + #expect(QuadHomography.mapping(width: 0, height: 1, to: [SIMD2(0, 0), SIMD2(1, 0), SIMD2(1, 1), SIMD2(0, 1)]) == nil) + } + + // MARK: The stamp + + /// A practice scan says so in its stamp, and every stamp has the field. + @Test func theStampSaysWhetherTheScanWasPractice() throws { + let app = ScanStamp.App(version: "0.1.0", build: "1", commit: "abc123def456") + let practice = try JSONSchemaValidator.Value.parse(ScanStamp(app: app, server: .init(url: nil), practice: true).jsonData()) + #expect(practice["practice"] == .bool(true)) + let real = try JSONSchemaValidator.Value.parse(ScanStamp(app: app, server: .init(url: nil)).jsonData()) + #expect(real["practice"] == .bool(false)) + } + + private static func intersection(_ a: SIMD2, _ b: SIMD2, _ c: SIMD2, _ d: SIMD2) -> SIMD2? { + let r = b - a + let s = d - c + let denominator = r.x * s.y - r.y * s.x + guard abs(denominator) > 1e-12 else { return nil } + let t = ((c - a).x * s.y - (c - a).y * s.x) / denominator + return a + r * t + } +} diff --git a/ios/HouseScanUITests/FullFlowUITests.swift b/ios/HouseScanUITests/FullFlowUITests.swift index 6f3d2ec1d..beb581d03 100644 --- a/ios/HouseScanUITests/FullFlowUITests.swift +++ b/ios/HouseScanUITests/FullFlowUITests.swift @@ -164,6 +164,68 @@ final class FullFlowUITests: XCTestCase { } } + /// Practice meter on the synthetic replay: the switch is turned on in the developer options on + /// the first screen, the close-up stores the drawn sample's photo and the app's own reader + /// reads its number, which the autopilot confirms, and the rest of the flow runs to the AR + /// result. Every screen after the first carries the "Practice meter" badge. Afterwards Start + /// over clears the badge and the switch is turned off again for the tests that follow. + @MainActor + func testPracticeMeterFromReplay() throws { + var readNumber: String? + var unbadged: [String] = [] + var app: XCUIApplication? + try runFlow(replay: Self.fixture, practice: true, beforeLeaving: { running, phase in + app = running + if phase == "onboarding" { + Self.setPracticeMeter(true, in: running) + return + } + if !running.descendants(matching: .any)["practiceBadge"].exists { unbadged.append(phase) } + if phase == "meterCloseUp" { + // Offered as a candidate, or already confirmed ("Meter number saved"): either way + // the reader read it from the sample's photo. + let number = running.descendants(matching: .any) + .matching(NSPredicate(format: "label CONTAINS %@", Self.sampleNumber)).firstMatch + readNumber = number.waitForExistence(timeout: 30) ? number.label : nil + } + }) + XCTAssertNotNil(readNumber, "the reader never offered the sample meter's number \(Self.sampleNumber)") + XCTAssertEqual(unbadged, [], "screens without the Practice meter badge") + + let running = try XCTUnwrap(app) + running.buttons["action.startOver"].firstMatch.tap() + XCTAssertTrue(running.descendants(matching: .any)["screen.onboarding"].waitForExistence(timeout: 15)) + XCTAssertFalse(running.descendants(matching: .any)["practiceBadge"].exists, "the badge outlived the practice scan") + Self.setPracticeMeter(false, in: running) + } + + /// `PracticeMeter.number` in HouseScanKit, which the test bundle doesn't link. + static let sampleNumber = "12345678" + + /// Opens the developer options from the first screen and sets the practice meter switch. + @MainActor + static func setPracticeMeter(_ on: Bool, in app: XCUIApplication) { + let open = app.buttons["action.developerOptions"] + XCTAssertTrue(open.waitForExistence(timeout: 10), "no developer options on the first screen") + open.tap() + let row = app.switches["developer.practiceMeter"] + XCTAssertTrue(row.waitForExistence(timeout: 10), "the developer options have no practice meter switch") + let wanted = on ? "1" : "0" + if row.value as? String != wanted { + // A Form row's switch is the row; the control inside it takes the tap. + let control = row.switches.firstMatch + (control.exists ? control : row).tap() + } + let set = NSPredicate(format: "value == %@", wanted) + XCTAssertEqual(XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: set, object: row)], timeout: 5), .completed, "the switch didn't turn \(on ? "on" : "off")") + let shot = XCTAttachment(screenshot: app.screenshot()) + shot.name = "developerOptions-\(on ? "on" : "off")" + shot.lifetime = .keepAlways + XCTContext.runActivity(named: shot.name ?? "") { $0.add(shot) } + app.buttons["action.closeDeveloperOptions"].tap() + XCTAssertTrue(app.descendants(matching: .any)["screen.onboarding"].waitForExistence(timeout: 10)) + } + /// Waits for the wall map's accessibility summary to mention hidden cells; false on timeout. @MainActor private static func wallTapeShowsHidden(_ app: XCUIApplication, timeout: TimeInterval) -> Bool { @@ -220,9 +282,14 @@ final class FullFlowUITests: XCTestCase { /// `beforeLeaving` runs on each screen after its screenshot and audit, while the app still /// waits to leave it. `onScene` gets the scene.json the autopilot leaves in the gate folder /// once the result shows, and the gate folder for the other files it leaves there. + /// + /// The practice meter switch is stored in the app's settings, which outlive a run in the + /// Simulator. Unless `practice` is set, `-practiceMeter NO` holds it off for this run whatever + /// an earlier test left there; with it, the test sets the switch itself. @MainActor private func runFlow( - replay: String, extraArguments: [String] = [], beforeLeaving: (XCUIApplication, String) -> Void = { _, _ in }, + replay: String, extraArguments: [String] = [], practice: Bool = false, + beforeLeaving: (XCUIApplication, String) -> Void = { _, _ in }, onScene: ((Data, URL) throws -> Void)? = nil ) throws { let app = XCUIApplication() @@ -232,6 +299,7 @@ final class FullFlowUITests: XCTestCase { try FileManager.default.createDirectory(at: gate, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: gate) } var arguments = ["-replay", replay, "-autopilot", "-autopilotHold", "1.5", "-autopilotGate", gate.path] + extraArguments + if !practice { arguments += ["-practiceMeter", "NO"] } if let server = Self.environment["HOUSESCAN_SERVER_URL"], !server.isEmpty { arguments += ["-serverURL", server] } else { diff --git a/ios/HouseScanUITests/PracticeMeterUITests.swift b/ios/HouseScanUITests/PracticeMeterUITests.swift new file mode 100644 index 000000000..2c0b067d1 --- /dev/null +++ b/ios/HouseScanUITests/PracticeMeterUITests.swift @@ -0,0 +1,44 @@ +import XCTest + +/// An App Store install can't reach practice meter. `-simulateAppStore` runs the Debug build as +/// an App Store install would, and `-practiceMeter YES` stands in for a switch a TestFlight build +/// left on before the App Store update. The first screen offers no developer options, and the +/// scan is a real one: no badge, the real meter's words, and the close-up reads the replay's own +/// photo, whose painted meter has no number, so it asks for a retake instead of offering the +/// sample's number. +final class PracticeMeterUITests: XCTestCase { + override func setUp() { + continueAfterFailure = false + } + + @MainActor + func testAppStoreInstallHasNoPracticeMeter() throws { + let app = XCUIApplication() + app.launchArguments = ["-replay", FullFlowUITests.fixture, "-sampleResult", "-simulateAppStore", "-practiceMeter", "YES"] + app.launch() + let any = app.descendants(matching: .any) + + XCTAssertTrue(any["screen.onboarding"].waitForExistence(timeout: 30)) + // The Debug build shows the entry at once; give an App Store one the same chance. + XCTAssertFalse(app.buttons["action.developerOptions"].waitForExistence(timeout: 3), "an App Store install offers the developer options") + let shot = XCTAttachment(screenshot: app.screenshot()) + shot.name = "appStore-onboarding" + shot.lifetime = .keepAlways + add(shot) + + if app.buttons["action.onboardingSkip"].waitForExistence(timeout: 5) { app.buttons["action.onboardingSkip"].tap() } + XCTAssertTrue(app.buttons["action.finishOnboarding"].waitForExistence(timeout: 10)) + app.buttons["action.finishOnboarding"].tap() + XCTAssertTrue(any["screen.findMeter"].waitForExistence(timeout: 10)) + XCTAssertTrue(app.buttons["action.markMeter"].waitForExistence(timeout: 10)) + XCTAssertEqual(app.buttons["action.markMeter"].label, "This is my meter") + XCTAssertFalse(any["practiceBadge"].exists, "the find-meter screen says practice meter") + + app.buttons["action.markMeter"].tap() + XCTAssertTrue(any["screen.meterCloseUp"].waitForExistence(timeout: 15)) + XCTAssertTrue(any["closeUp.problem"].waitForExistence(timeout: 60), "the close-up never asked for a retake of the replay's unreadable meter") + let sample = any.matching(NSPredicate(format: "label CONTAINS %@", FullFlowUITests.sampleNumber)).firstMatch + XCTAssertFalse(sample.exists, "the close-up read the sample meter's number on an App Store install") + XCTAssertFalse(any["practiceBadge"].exists, "the close-up says practice meter") + } +} diff --git a/ios/HouseScanUITests/SourceRestartUITests.swift b/ios/HouseScanUITests/SourceRestartUITests.swift index a11f0db2e..da86e7d43 100644 --- a/ios/HouseScanUITests/SourceRestartUITests.swift +++ b/ios/HouseScanUITests/SourceRestartUITests.swift @@ -19,7 +19,9 @@ final class SourceRestartUITests: XCTestCase { try Data("{}".utf8).write(to: folder.appending(path: "session.json")) let app = XCUIApplication() - app.launchArguments = ["-replay", folder.path, "-sampleResult"] + // The practice meter switch can be left on by an earlier test; this one needs the real + // close-up, whose unreadable photo asks for a retake. + app.launchArguments = ["-replay", folder.path, "-sampleResult", "-practiceMeter", "NO"] app.launch() let any = app.descendants(matching: .any) XCTAssertTrue(any["screen.unsupported"].waitForExistence(timeout: 30), "the unreadable replay never showed the failure") diff --git a/ios/README.md b/ios/README.md index 90a5e9628..32970e3f2 100644 --- a/ios/README.md +++ b/ios/README.md @@ -20,10 +20,14 @@ Native iPhone app for the AR capture walk. The homeowner marks the electric mete | `-autopilotCantGetThere` | With `-autopilot`, ends the walk the way device run 1 did. It answers "Can't get there" to the walk's first request, the ground in front of the meter, before the replay has shown it. Then, instead of marking the ends, it plays again the frame where the walk went farthest on each side and answers "Can't get there" when the walk asks to go on that way, so the end lands where the phone stands. `FullFlowUITests.testCantGetThereEndsWhereThePhoneIs` runs it. | | `-autopilotSomethingThere` | With `-autopilot`, answers the first spot check "Something's there" instead of "It's clear". The scan stops claiming that area, is uploaded again, and later checks get "It's clear". The stretch taken out goes to the `-autopilotGate` folder as `spot-refusal.json` (`withdrawn_span_ft`, feet along the wall as in scene.json). `FullFlowUITests.testSomethingThereChecksTheWallAgain` runs it. | | `-autopilotGate ` | Used by the UI tests. Before leaving a screen, the app waits for a file named after that phase in this folder. Once the result shows, the autopilot writes the scan's scene.json there. | +| `-simulateAppStore` | Runs as an App Store install would: no developer options, and practice meter stays off whatever the stored switch says. `PracticeMeterUITests` uses it. | +| `-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`. +**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`. + Every screen change is logged as `STATE=` under subsystem `dev.housescanning.housescan`, category `state`. ## Layout From 7f3c486c284651a435e105938bac790939e0e3de Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sun, 27 Sep 2026 03:31:34 -0500 Subject: [PATCH 2/8] ios: recheck the scan after drawing the practice close-up photo A spatial reset while the sample photo was being drawn let saveStill start in the new world frame's epoch and keep the old frame's pose. Co-Authored-By: Claude Opus 5.5 (1M context) --- ios/HouseScan/Runtime/ScanEngine.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ios/HouseScan/Runtime/ScanEngine.swift b/ios/HouseScan/Runtime/ScanEngine.swift index a2fe82af6..22b8721ff 100644 --- a/ios/HouseScan/Runtime/ScanEngine.swift +++ b/ios/HouseScan/Runtime/ScanEngine.swift @@ -495,6 +495,8 @@ final class ScanEngine { // The task can start after a reset or after the flow left the close-up. guard scan == generation, state.phase == .meterCloseUp else { return } let photo = await closeUpPhoto(frame) + // Drawing a practice photo suspends: a reset meanwhile must not save into the new scan. + guard scan == generation, state.phase == .meterCloseUp else { return } let saved = await store.saveStill(photo, name: "meter_close.jpg") guard scan == generation, state.phase == .meterCloseUp else { return } if !saved { From 10b04e89819c4e450a722f9c41511bbd7a84f6aa Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sun, 27 Sep 2026 04:18:42 -0500 Subject: [PATCH 3/8] ios: name the drawn practice meter for VoiceOver, and test it is drawn The flow test's captures never showed the sample: by the time each screen settled, the replay had moved past the frames that aim at the meter. The drawn meter is now an image element, "Sample meter", placed where it is drawn, and the test checks it sits mid-screen as soon as the close-up opens. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../UI/Practice/PracticeMeterOverlay.swift | 37 +++++++++++++------ ios/HouseScanUITests/FullFlowUITests.swift | 31 +++++++++++++++- 2 files changed, 56 insertions(+), 12 deletions(-) diff --git a/ios/HouseScan/UI/Practice/PracticeMeterOverlay.swift b/ios/HouseScan/UI/Practice/PracticeMeterOverlay.swift index 465f882a1..6d63127a6 100644 --- a/ios/HouseScan/UI/Practice/PracticeMeterOverlay.swift +++ b/ios/HouseScan/UI/Practice/PracticeMeterOverlay.swift @@ -14,22 +14,34 @@ struct PracticeMeterOverlay: View { GeometryReader { proxy in // Hidden while tracking isn't normal, like the other overlays: from a pose the phone // doesn't trust, it would sit in the wrong place. - if state.tracking == .normal, let face = SampleMeterArt.face, let transform = transform(in: proxy.size) { - Image(decorative: face, scale: 1) - .resizable() - .frame(width: CGFloat(face.width), height: CGFloat(face.height)) - .projectionEffect(transform) - .frame(width: proxy.size.width, height: proxy.size.height, alignment: .topLeading) + if state.tracking == .normal, let face = SampleMeterArt.face, let placed = placement(in: proxy.size) { + ZStack(alignment: .topLeading) { + Image(decorative: face, scale: 1) + .resizable() + .frame(width: CGFloat(face.width), height: CGFloat(face.height)) + .projectionEffect(placed.transform) + .accessibilityHidden(true) + // The camera shows a real meter to anyone who can see it; this one exists only + // in the app, so VoiceOver finds it where it is drawn. + Color.clear + .frame(width: placed.bounds.width, height: placed.bounds.height) + .position(x: placed.bounds.midX, y: placed.bounds.midY) + .accessibilityElement() + .accessibilityLabel("Sample meter") + .accessibilityAddTraits(.isImage) + .accessibilityIdentifier("practiceMeter") + } + .frame(width: proxy.size.width, height: proxy.size.height, alignment: .topLeading) } } .ignoresSafeArea() .allowsHitTesting(false) - .accessibilityHidden(true) } - /// Takes the face image's rectangle onto the sample's four corners on screen; nil when the - /// wall isn't set yet or any corner is behind the camera. - private func transform(in size: CGSize) -> ProjectionTransform? { + /// The transform that takes the face image's rectangle onto the sample's four corners on + /// screen, and the corners' bounding box; nil when the wall isn't set yet or any corner is + /// behind the camera. + private func placement(in size: CGSize) -> (transform: ProjectionTransform, bounds: CGRect)? { guard let wall = state.wall, let projection = state.projection, let face = SampleMeterArt.face else { return nil } var quad: [SIMD2] = [] for corner in PracticeMeter.plateCorners(meter: wall.meter, along: wall.along, outward: wall.outward) { @@ -48,7 +60,10 @@ struct PracticeMeterOverlay: View { transform.m31 = m[0][2] transform.m32 = m[1][2] transform.m33 = m[2][2] - return transform + let xs = quad.map(\.x) + let ys = quad.map(\.y) + let bounds = CGRect(x: xs.min() ?? 0, y: ys.min() ?? 0, width: (xs.max() ?? 0) - (xs.min() ?? 0), height: (ys.max() ?? 0) - (ys.min() ?? 0)) + return (transform, bounds) } } diff --git a/ios/HouseScanUITests/FullFlowUITests.swift b/ios/HouseScanUITests/FullFlowUITests.swift index beb581d03..089ef33f1 100644 --- a/ios/HouseScanUITests/FullFlowUITests.swift +++ b/ios/HouseScanUITests/FullFlowUITests.swift @@ -173,8 +173,28 @@ final class FullFlowUITests: XCTestCase { func testPracticeMeterFromReplay() throws { var readNumber: String? var unbadged: [String] = [] + var drawn: CGRect? var app: XCUIApplication? - try runFlow(replay: Self.fixture, practice: true, beforeLeaving: { running, phase in + try runFlow(replay: Self.fixture, practice: true, onAppear: { running, phase in + // The close-up replays the recording from its first frames, which aim at the meter for + // about a second at the autopilot's 3x: the drawn sample must be on screen then, over + // the tapped spot at the middle of the view. + guard phase == "meterCloseUp" else { return } + let sample = running.descendants(matching: .any)["practiceMeter"] + let screen = running.frame + let deadline = Date().addingTimeInterval(10) + repeat { + if let frame = ElementRead.snapshot(sample)?.frame, frame.width > 40, screen.contains(CGPoint(x: frame.midX, y: frame.midY)) { + drawn = frame + let shot = XCTAttachment(screenshot: running.screenshot()) + shot.name = "meterCloseUp-sampleMeter" + shot.lifetime = .keepAlways + self.add(shot) + break + } + Thread.sleep(forTimeInterval: 0.1) + } while Date() < deadline + }, beforeLeaving: { running, phase in app = running if phase == "onboarding" { Self.setPracticeMeter(true, in: running) @@ -191,6 +211,10 @@ final class FullFlowUITests: XCTestCase { }) XCTAssertNotNil(readNumber, "the reader never offered the sample meter's number \(Self.sampleNumber)") XCTAssertEqual(unbadged, [], "screens without the Practice meter badge") + let sample = try XCTUnwrap(drawn, "the sample meter was never drawn on screen at the close-up") + let middle = try XCTUnwrap(app).frame + XCTAssertLessThan(abs(sample.midX - middle.midX), middle.width / 4, "sample meter at \(sample), far from the middle of \(middle)") + XCTAssertLessThan(abs(sample.midY - middle.midY), middle.height / 4, "sample meter at \(sample), far from the middle of \(middle)") let running = try XCTUnwrap(app) running.buttons["action.startOver"].firstMatch.tap() @@ -283,12 +307,16 @@ final class FullFlowUITests: XCTestCase { /// waits to leave it. `onScene` gets the scene.json the autopilot leaves in the gate folder /// once the result shows, and the gate folder for the other files it leaves there. /// + /// `onAppear` runs as soon as a screen appears, before it settles, for what only its first + /// moments show. + /// /// The practice meter switch is stored in the app's settings, which outlive a run in the /// Simulator. Unless `practice` is set, `-practiceMeter NO` holds it off for this run whatever /// an earlier test left there; with it, the test sets the switch itself. @MainActor private func runFlow( replay: String, extraArguments: [String] = [], practice: Bool = false, + onAppear: (XCUIApplication, String) -> Void = { _, _ in }, beforeLeaving: (XCUIApplication, String) -> Void = { _, _ in }, onScene: ((Data, URL) throws -> Void)? = nil ) throws { @@ -314,6 +342,7 @@ final class FullFlowUITests: XCTestCase { // The walk replays the whole recording; everything else takes seconds. let timeout: TimeInterval = phase == "markFeatures" || phase == "result" ? 150 : 60 XCTAssertTrue(screen.waitForExistence(timeout: timeout), "screen.\(phase) never appeared") + onAppear(app, phase) // Let the entrance animation finish so the screenshot and audit see the settled screen. Thread.sleep(forTimeInterval: 1.0) let shot = XCTAttachment(screenshot: app.screenshot()) From 786bcf256afe2379e9403274190ffc893a1db83d Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sun, 27 Sep 2026 05:39:43 -0500 Subject: [PATCH 4/8] ios: practice test survives its own failure; developer button text wraps Run 36310614834 found the drawn meter on the walk's first frame, pitched 20 degrees down, so it sat high on screen; only its horizontal place is checked now. That failure skipped turning the switch off, and the audit then flagged the button, which swapped its text for an icon at large sizes. The switch is now turned off in a teardown block, and the text wraps instead. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../UI/Practice/DeveloperOptions.swift | 15 ++++++-------- ios/HouseScanUITests/FullFlowUITests.swift | 20 +++++++++++++------ 2 files changed, 20 insertions(+), 15 deletions(-) diff --git a/ios/HouseScan/UI/Practice/DeveloperOptions.swift b/ios/HouseScan/UI/Practice/DeveloperOptions.swift index b41eef011..6eb0dacb0 100644 --- a/ios/HouseScan/UI/Practice/DeveloperOptions.swift +++ b/ios/HouseScan/UI/Practice/DeveloperOptions.swift @@ -14,16 +14,13 @@ struct DeveloperOptionsButton: View { Button { showing = true } label: { - // The words give way to the icon alone at the largest text sizes; the spoken - // label still says the switch is on. - ViewThatFits(in: .horizontal) { - HStack(spacing: 6) { - Image(systemName: "wrench.and.screwdriver.fill") - if practiceMeter { - Text("Practice meter on") - } - } + HStack(spacing: 6) { Image(systemName: "wrench.and.screwdriver.fill") + if practiceMeter { + // Wraps at the largest text sizes rather than dropping out. + Text("Practice meter on") + .fixedSize(horizontal: false, vertical: true) + } } .font(Typeface.caption) .foregroundStyle(practiceMeter ? Palette.ink : Palette.muted) diff --git a/ios/HouseScanUITests/FullFlowUITests.swift b/ios/HouseScanUITests/FullFlowUITests.swift index 089ef33f1..6ce4eb792 100644 --- a/ios/HouseScanUITests/FullFlowUITests.swift +++ b/ios/HouseScanUITests/FullFlowUITests.swift @@ -168,17 +168,27 @@ final class FullFlowUITests: XCTestCase { /// the first screen, the close-up stores the drawn sample's photo and the app's own reader /// reads its number, which the autopilot confirms, and the rest of the flow runs to the AR /// result. Every screen after the first carries the "Practice meter" badge. Afterwards Start - /// over clears the badge and the switch is turned off again for the tests that follow. + /// over clears the badge. The switch is turned off again for the tests that follow, also when + /// this one fails partway. @MainActor func testPracticeMeterFromReplay() throws { + addTeardownBlock { @MainActor in + let demo = XCUIApplication() + demo.launchArguments = ["-uiDemo"] + demo.launch() + XCTAssertTrue(demo.descendants(matching: .any)["screen.onboarding"].waitForExistence(timeout: 15)) + Self.setPracticeMeter(false, in: demo) + demo.terminate() + } var readNumber: String? var unbadged: [String] = [] var drawn: CGRect? var app: XCUIApplication? try runFlow(replay: Self.fixture, practice: true, onAppear: { running, phase in - // The close-up replays the recording from its first frames, which aim at the meter for - // about a second at the autopilot's 3x: the drawn sample must be on screen then, over - // the tapped spot at the middle of the view. + // The close-up replays the recording from the start: frames aimed at the meter from 1 to + // 2.6 m, then the walk's first frame at the meter, pitched 20 degrees down. The drawn + // sample must show on one of them, across the middle of the view; how high depends on + // which frame is up when it is found (run 36310614834 found it on the pitched one). guard phase == "meterCloseUp" else { return } let sample = running.descendants(matching: .any)["practiceMeter"] let screen = running.frame @@ -214,13 +224,11 @@ final class FullFlowUITests: XCTestCase { let sample = try XCTUnwrap(drawn, "the sample meter was never drawn on screen at the close-up") let middle = try XCTUnwrap(app).frame XCTAssertLessThan(abs(sample.midX - middle.midX), middle.width / 4, "sample meter at \(sample), far from the middle of \(middle)") - XCTAssertLessThan(abs(sample.midY - middle.midY), middle.height / 4, "sample meter at \(sample), far from the middle of \(middle)") let running = try XCTUnwrap(app) running.buttons["action.startOver"].firstMatch.tap() XCTAssertTrue(running.descendants(matching: .any)["screen.onboarding"].waitForExistence(timeout: 15)) XCTAssertFalse(running.descendants(matching: .any)["practiceBadge"].exists, "the badge outlived the practice scan") - Self.setPracticeMeter(false, in: running) } /// `PracticeMeter.number` in HouseScanKit, which the test bundle doesn't link. From d298dfbeb66743e45d71bb58aa641f2d5fcb7662 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sun, 27 Sep 2026 08:01:41 -0500 Subject: [PATCH 5/8] ios: practice test taps the developer switch once it can take the tap Run 36315397676 tapped the switch as the sheet slid up and it stayed off. The test now waits for the row to be tappable, taps the switch at the row's trailing end, and tries once more if the value didn't change. Co-Authored-By: Claude Opus 5.5 (1M context) --- ios/HouseScanUITests/FullFlowUITests.swift | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/ios/HouseScanUITests/FullFlowUITests.swift b/ios/HouseScanUITests/FullFlowUITests.swift index 0c0983e49..46271afbc 100644 --- a/ios/HouseScanUITests/FullFlowUITests.swift +++ b/ios/HouseScanUITests/FullFlowUITests.swift @@ -295,13 +295,17 @@ final class FullFlowUITests: XCTestCase { let row = app.switches["developer.practiceMeter"] XCTAssertTrue(row.waitForExistence(timeout: 10), "the developer options have no practice meter switch") let wanted = on ? "1" : "0" - if row.value as? String != wanted { - // A Form row's switch is the row; the control inside it takes the tap. - let control = row.switches.firstMatch - (control.exists ? control : row).tap() - } let set = NSPredicate(format: "value == %@", wanted) - XCTAssertEqual(XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: set, object: row)], timeout: 5), .completed, "the switch didn't turn \(on ? "on" : "off")") + // The sheet slides up first; a tap while it moves can miss. The row's own center is its + // label, which doesn't flip it, so the tap goes to the switch at the row's trailing end, + // and once more if the first didn't take (run 36315397676 missed one). + let settled = XCTNSPredicateExpectation(predicate: NSPredicate(format: "isHittable == true"), object: row) + XCTAssertEqual(XCTWaiter.wait(for: [settled], timeout: 10), .completed, "the practice meter switch never became tappable") + for _ in 0..<2 where row.value as? String != wanted { + row.coordinate(withNormalizedOffset: CGVector(dx: 0.92, dy: 0.5)).tap() + _ = XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: set, object: row)], timeout: 3) + } + XCTAssertEqual(row.value as? String, wanted, "the switch didn't turn \(on ? "on" : "off")") let shot = XCTAttachment(screenshot: app.screenshot()) shot.name = "developerOptions-\(on ? "on" : "off")" shot.lifetime = .keepAlways From dd219a05072cff50439f5644705246af635db39f Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sun, 27 Sep 2026 09:42:45 -0500 Subject: [PATCH 6/8] ios: practice test checks the drawn meter's shape, not where it catches it Which replay frame shows the sample first depends on timing: two runs found it on walk frames, once high and once off to the right. The test now checks the drawing is on screen with the plate's 2:3 shape; the corners' placement is covered by HouseScanKit's PracticeMeterTests. Co-Authored-By: Claude Opus 5.5 (1M context) --- ios/HouseScanUITests/FullFlowUITests.swift | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/ios/HouseScanUITests/FullFlowUITests.swift b/ios/HouseScanUITests/FullFlowUITests.swift index 46271afbc..5cb285282 100644 --- a/ios/HouseScanUITests/FullFlowUITests.swift +++ b/ios/HouseScanUITests/FullFlowUITests.swift @@ -237,10 +237,11 @@ final class FullFlowUITests: XCTestCase { var drawn: CGRect? var app: XCUIApplication? try runFlow(replay: Self.fixture, practice: true, onAppear: { running, phase in - // The close-up replays the recording from the start: frames aimed at the meter from 1 to - // 2.6 m, then the walk's first frame at the meter, pitched 20 degrees down. The drawn - // sample must show on one of them, across the middle of the view; how high depends on - // which frame is up when it is found (run 36310614834 found it on the pitched one). + // The close-up replays the recording from the start, and the replay runs on at 3x + // while this polls, so which frame shows the sample first varies from run to run (runs + // 36310614834 and 36320980419 caught it on walk frames, high and off to the right). The + // check is that it is drawn on screen, the plate's shape; where the corners land is + // HouseScanKit's PracticeMeterTests. guard phase == "meterCloseUp" else { return } let sample = running.descendants(matching: .any)["practiceMeter"] let screen = running.frame @@ -274,8 +275,8 @@ final class FullFlowUITests: XCTestCase { XCTAssertNotNil(readNumber, "the reader never offered the sample meter's number \(Self.sampleNumber)") XCTAssertEqual(unbadged, [], "screens without the Practice meter badge") let sample = try XCTUnwrap(drawn, "the sample meter was never drawn on screen at the close-up") - let middle = try XCTUnwrap(app).frame - XCTAssertLessThan(abs(sample.midX - middle.midX), middle.width / 4, "sample meter at \(sample), far from the middle of \(middle)") + // A 0.20 by 0.30 m plate seen nearly square on: about 2:3, whatever the distance. + XCTAssertEqual(sample.width / sample.height, 2.0 / 3.0, accuracy: 0.12, "sample meter drawn \(sample.width) by \(sample.height)") let running = try XCTUnwrap(app) running.buttons["action.startOver"].firstMatch.tap() From e0e0fbf09d43c3de30d3c111a6bd1dfc215e9ba9 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sun, 27 Sep 2026 10:27:45 -0500 Subject: [PATCH 7/8] ios: practice test finds Start over under the result's Details #91's result card, merged from t3/ios-mvf, moved Start over under a Details disclosure; the test opens it first, as ScreenStatesUITests does. Co-Authored-By: Claude Opus 5.5 (1M context) --- ios/HouseScanUITests/FullFlowUITests.swift | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/ios/HouseScanUITests/FullFlowUITests.swift b/ios/HouseScanUITests/FullFlowUITests.swift index 5cb285282..8e3627f29 100644 --- a/ios/HouseScanUITests/FullFlowUITests.swift +++ b/ios/HouseScanUITests/FullFlowUITests.swift @@ -279,7 +279,15 @@ final class FullFlowUITests: XCTestCase { XCTAssertEqual(sample.width / sample.height, 2.0 / 3.0, accuracy: 0.12, "sample meter drawn \(sample.width) by \(sample.height)") let running = try XCTUnwrap(app) - running.buttons["action.startOver"].firstMatch.tap() + // Start over sits under Details, last, as ScreenStatesUITests reaches it. + let details = running.descendants(matching: .any)["result.details"] + XCTAssertTrue(details.waitForExistence(timeout: 20), "no Details on the result") + details.tap() + let startOver = running.descendants(matching: .any)["action.startOver"] + XCTAssertTrue(startOver.waitForExistence(timeout: 10), "no Start over under Details") + running.swipeUp() + running.swipeUp() + startOver.tap() XCTAssertTrue(running.descendants(matching: .any)["screen.onboarding"].waitForExistence(timeout: 15)) XCTAssertFalse(running.descendants(matching: .any)["practiceBadge"].exists, "the badge outlived the practice scan") } From 0e9ff0cb86fd9fc491079f3e8df4b0b64f184a36 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sun, 27 Sep 2026 11:49:03 -0500 Subject: [PATCH 8/8] ios: practice test takes the first Details match, as the screen-state tests do Co-Authored-By: Claude Opus 5.5 (1M context) --- ios/HouseScanUITests/FullFlowUITests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ios/HouseScanUITests/FullFlowUITests.swift b/ios/HouseScanUITests/FullFlowUITests.swift index 8e3627f29..582b549e7 100644 --- a/ios/HouseScanUITests/FullFlowUITests.swift +++ b/ios/HouseScanUITests/FullFlowUITests.swift @@ -280,10 +280,10 @@ final class FullFlowUITests: XCTestCase { let running = try XCTUnwrap(app) // Start over sits under Details, last, as ScreenStatesUITests reaches it. - let details = running.descendants(matching: .any)["result.details"] + let details = running.descendants(matching: .any)["result.details"].firstMatch XCTAssertTrue(details.waitForExistence(timeout: 20), "no Details on the result") details.tap() - let startOver = running.descendants(matching: .any)["action.startOver"] + let startOver = running.descendants(matching: .any)["action.startOver"].firstMatch XCTAssertTrue(startOver.waitForExistence(timeout: 10), "no Start over under Details") running.swipeUp() running.swipeUp()