diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml
index ae04760df..9c8f34a6a 100644
--- a/.github/workflows/ios.yml
+++ b/.github/workflows/ios.yml
@@ -38,8 +38,10 @@ jobs:
# macos-26 (arm64) is the newest GA macOS image. The xcode-27 image is still a preview.
runs-on: macos-26
# Beta run 36302099002 took 43 minutes before the spot-confirmation and review checks
- # were added. Leave room for those checks without dropping the every-state audit.
- timeout-minutes: 60
+ # were added. Full run 36832282114 (#190) then took 58 of 60 minutes, and the ground
+ # freshness UI tests add about 161 s more (run 36744881974), so 60 would cut the
+ # every-state audit short. 75 leaves room without dropping any check.
+ timeout-minutes: 75
env:
# Newest stable Xcode on macos-26 per images/macos/macos-26-arm64-Readme.md (image 20260907).
DEVELOPER_DIR: /Applications/Xcode_26.6.app/Contents/Developer
diff --git a/ios/Config/Info.plist b/ios/Config/Info.plist
index 1b5953a5b..d9fbfcb67 100644
--- a/ios/Config/Info.plist
+++ b/ios/Config/Info.plist
@@ -35,6 +35,8 @@
NSCameraUsageDescription
House Scan uses the camera to measure the outside wall around your electric meter.
+ NSLocalNetworkUsageDescription
+ House Scan connects to a House Scan server on your local network only when a developer has set it up for testing.
NSMotionUsageDescription
House Scan records your phone's motion and air pressure while you scan and saves them with the scan. The readings stay on this phone unless you share the scan.
UIApplicationSceneManifest
diff --git a/ios/HouseScan/PrivacyInfo.xcprivacy b/ios/HouseScan/PrivacyInfo.xcprivacy
new file mode 100644
index 000000000..9ec62bf71
--- /dev/null
+++ b/ios/HouseScan/PrivacyInfo.xcprivacy
@@ -0,0 +1,47 @@
+
+
+
+
+
+ NSPrivacyAccessedAPITypes
+
+
+
+ NSPrivacyAccessedAPIType
+ NSPrivacyAccessedAPICategoryUserDefaults
+ NSPrivacyAccessedAPITypeReasons
+
+ CA92.1
+
+
+
+
+ NSPrivacyAccessedAPIType
+ NSPrivacyAccessedAPICategoryFileTimestamp
+ NSPrivacyAccessedAPITypeReasons
+
+ C617.1
+
+
+
+
+ NSPrivacyAccessedAPIType
+ NSPrivacyAccessedAPICategorySystemBootTime
+ NSPrivacyAccessedAPITypeReasons
+
+ 35F9.1
+ 8FFB.1
+
+
+
+
+
diff --git a/ios/HouseScan/Runtime/LaunchOptions.swift b/ios/HouseScan/Runtime/LaunchOptions.swift
index 403b14fe8..3d659c6ed 100644
--- a/ios/HouseScan/Runtime/LaunchOptions.swift
+++ b/ios/HouseScan/Runtime/LaunchOptions.swift
@@ -23,7 +23,7 @@ import OSLog
/// - `-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.
/// - `-injectGroundRise `: with `-replay`, `-autopilot` and `-autopilotGate`, once the
-/// first result shows, each time a file named `inject-ground` appears in the gate folder the
+/// first upload starts, each time a file named `inject-ground` appears in the gate folder the
/// app deletes it and hands the engine a detected floor that many meters above its current
/// ground, as ARKit refining the ground would (`ScanEngine.injectGroundForTest`). Replays carry
/// no plane evidence, so this is the only way a UI test reaches that path. Meters must be over
diff --git a/ios/HouseScan/Runtime/MeterReader/VisionMeterNumberReader.swift b/ios/HouseScan/Runtime/MeterReader/VisionMeterNumberReader.swift
index 6a1c9c495..90dc14b56 100644
--- a/ios/HouseScan/Runtime/MeterReader/VisionMeterNumberReader.swift
+++ b/ios/HouseScan/Runtime/MeterReader/VisionMeterNumberReader.swift
@@ -2,6 +2,7 @@ import CoreGraphics
import Foundation
import HouseScanKit
import ImageIO
+import OSLog
import Vision
/// Reads the meter number from a close-up with Apple Vision, configured as the close-up eval
@@ -46,6 +47,13 @@ struct VisionMeterNumberReader: MeterNumberReader {
do {
try VNImageRequestHandler(cgImage: image, orientation: orientation).perform([text, barcodes])
} catch {
+ // The homeowner sees this as "no number", so the log is the only place that says Vision
+ // itself failed (e.g. com.apple.Vision 9, "Could not create inference context", in one
+ // Mac's simulator). Domain and code are framework identifiers and stay public. The
+ // message is Vision's free text, so it stays private: nothing vouches that it never
+ // names a file or carries content.
+ let failure = error as NSError
+ RuntimeLog.engine.error("meter number: Vision failed, reading as no number: \(failure.domain, privacy: .public) \(failure.code, privacy: .public) \(failure.localizedDescription, privacy: .private)")
return retakeNoNumber
}
diff --git a/ios/HouseScan/Runtime/ResultClient.swift b/ios/HouseScan/Runtime/ResultClient.swift
index 09a00d3f2..567884fc2 100644
--- a/ios/HouseScan/Runtime/ResultClient.swift
+++ b/ios/HouseScan/Runtime/ResultClient.swift
@@ -13,7 +13,9 @@ protocol ResultClient: AnyObject {
/// the body and `Content-Type: application/json`. Only the JSON goes: the server's solver reads no
/// photos and skips the image check for a bare scene.json, so the keyframes stay on the phone
/// (and in the scan folder's `scan.zip`, which leaves only if the homeowner shares it). The
-/// response body is the result JSON.
+/// response body is the result JSON, and it is returned only when it names the scene sent: its
+/// `stats.input_sha256` must be present and be the hash of these exact bytes (`ResultBinding`).
+/// Any other answer throws, and the upload screen treats it as an answer it couldn't read.
@MainActor
final class HTTPResultClient: ResultClient {
let serverURL: URL
@@ -42,6 +44,7 @@ final class HTTPResultClient: ResultClient {
status: http.statusCode, body: String(decoding: data.prefix(300), as: UTF8.self),
retryAfter: http.value(forHTTPHeaderField: "Retry-After"))
}
+ try ResultBinding.check(answer: data, submittedScene: scene)
progress(1)
return data
}
@@ -137,7 +140,9 @@ final class UploadProgressDelegate: NSObject, URLSessionTaskDelegate, Sendable {
}
/// Answers with the bundled SampleResult.json, for tests and demos without a server. The result
-/// is flagged `isSample` so the screen says it is not a real analysis.
+/// is flagged `isSample` so the screen says it is not a real analysis. It answers no particular
+/// scene (its `input_sha256` is all zeros), so it is deliberately not checked with
+/// `ResultBinding`.
@MainActor
final class SampleResultClient: ResultClient {
let isSample = true
diff --git a/ios/HouseScan/Runtime/ScanEngine+Confirm.swift b/ios/HouseScan/Runtime/ScanEngine+Confirm.swift
index 38045a056..14761faa6 100644
--- a/ios/HouseScan/Runtime/ScanEngine+Confirm.swift
+++ b/ios/HouseScan/Runtime/ScanEngine+Confirm.swift
@@ -11,6 +11,9 @@ struct SpotConfirmState {
/// The last upload's scene.json, and the sha256 of it and of the answer to it: what a check
/// asked now is bound to, and the scene the bundle is written again with after "It's clear".
var lastScene: Data?
+ /// The capture packet's inputs from that same upload, with `lastScene` attached: the packet
+ /// written again after "It's clear" (`answerSpotCheck`).
+ var lastPacket: ScanEngine.PacketInputs?
var sceneSHA256: String?
var answerSHA256: String?
/// The check on screen: its area, the photo shown and the exchange it came from.
@@ -50,8 +53,9 @@ extension ScanEngine {
/// Records the upload's scene and the answer to it, which a check asked about this answer
/// is bound to. Call it once the answer has decoded.
- func noteExchange(scene: Data, answer: Data) {
+ func noteExchange(scene: Data, answer: Data, packet: PacketInputs?) {
spotConfirm.lastScene = scene
+ spotConfirm.lastPacket = packet
spotConfirm.sceneSHA256 = PacketFiles.sha256(scene)
spotConfirm.answerSHA256 = PacketFiles.sha256(answer)
}
@@ -125,8 +129,10 @@ extension ScanEngine {
return
}
// The bundle was written at the upload, before this answer: write it again so its
- // guidance log holds the answer.
- if let scene = spotConfirm.lastScene { saveBundle(scene: scene, mesh: liveCapture?.meshSnapshot()) }
+ // guidance log holds the answer. The upload's captured packet is reused, with only
+ // the guidance log read again and placed in that packet's frame, so the packet keeps
+ // the submitted scene's geometry, mesh and photos rather than mixing in a later wall.
+ if let packet = spotConfirm.lastPacket { saveBundle(withCurrentGuidance(packet)) }
go(.result)
}
}
diff --git a/ios/HouseScan/Runtime/ScanEngine+Export.swift b/ios/HouseScan/Runtime/ScanEngine+Export.swift
index 4a7d0b397..2047ffb97 100644
--- a/ios/HouseScan/Runtime/ScanEngine+Export.swift
+++ b/ios/HouseScan/Runtime/ScanEngine+Export.swift
@@ -4,13 +4,19 @@ import OSLog
import simd
extension ScanEngine {
- /// scene.json for the current scan (contract C1), with the LiDAR mesh's measurements on
- /// phones that have one.
+ /// scene.json for the current scan (contract C1), without mesh measurements: the autopilot's
+ /// gate copy. The upload captures the scene once and measures the mesh against that capture
+ /// (`captureUpload`, `UploadPackaging`).
+ func sceneJSON() throws -> Data {
+ try SceneExport.jsonData(sceneInput())
+ }
+
+ /// The scan's scene as it stands now, without mesh measurements, read in this one turn.
///
/// The scene frame is the AR world frame moved down so the ground at the wall is y = 0: the
/// server reads heights (meter.pos y, pose translations) as height above that ground, and
/// scene.json has no field for the ground's height otherwise.
- func sceneJSON(mesh: MeshMeasurements = MeshMeasurements()) throws -> Data {
+ func sceneInput() throws -> SceneInput {
guard let map = coverage else { throw ExportError.noWall }
let wall = map.wall
let drop = SIMD3(0, wall.groundY, 0)
@@ -74,13 +80,23 @@ extension ScanEngine {
),
keyframes: keyframes,
stills: store.stills,
- // Written without plus_minus_ft: the server takes its mesh error for both.
- meshFacing: mesh.facing,
- meshOverheads: mesh.overheads,
+ // The mesh's measurements are filled in once measured (`UploadPackaging`). Written
+ // without plus_minus_ft: the server takes its mesh error for both.
// Unanswered exports like "Not sure": no patch, and the server reports the surface unknown.
groundType: state.groundAnswer.flatMap(Self.sceneGroundType)
)
- return try SceneExport.jsonData(input)
+ return input
+ }
+
+ /// Everything one upload sends, read in one main-actor turn: the scene, the wall in world
+ /// meters, the LiDAR mesh and the capture packet's inputs. Nothing after this rereads the
+ /// engine's geometry for this upload, so a ground or anchor correction while the mesh is
+ /// measured can't mix two walls into its scene or packet.
+ func captureUpload() throws -> UploadPackaging {
+ let scene = try sceneInput()
+ guard let map = coverage else { throw ExportError.noWall }
+ let meshSnapshot = liveCapture?.meshSnapshot()
+ return UploadPackaging(scene: scene, worldWall: map.wall, mesh: meshSnapshot?.mesh, packet: packetInputs(mesh: meshSnapshot))
}
static func sceneGroundType(_ answer: GroundAnswer) -> SceneGroundType? {
@@ -132,20 +148,6 @@ extension ScanEngine {
return low...high
}
- /// What the LiDAR mesh measured over the exported stretch: the gap from the wall out to
- /// whatever faces it, and the clear height under anything overhead, each per stretch of s in
- /// meters. Empty without a mesh.
- struct MeshMeasurements: Sendable {
- var facing: [ObservedSpan] = []
- var overheads: [ObservedSpan] = []
- }
-
- /// Measures a world-space mesh (meters) against the wall (world meters) over `span`, the
- /// exported stretch of s.
- nonisolated static func measure(_ mesh: TriangleMesh, wall: WallFrame, over span: ClosedRange) -> MeshMeasurements {
- MeshMeasurements(facing: mesh.facingSpans(wall: wall, over: span), overheads: mesh.overheadSpans(wall: wall, over: span))
- }
-
/// Error of the chest-height ground guess (camera height minus 1.4 m), meters. Phones held
/// for scanning sit roughly 1.1 to 1.7 m up, so ±0.3 m. A hypothesis; no measured spread exists.
static let estimatedGroundError: Float = 0.3
diff --git a/ios/HouseScan/Runtime/ScanEngine+Packet.swift b/ios/HouseScan/Runtime/ScanEngine+Packet.swift
index 382423027..1e515631b 100644
--- a/ios/HouseScan/Runtime/ScanEngine+Packet.swift
+++ b/ios/HouseScan/Runtime/ScanEngine+Packet.swift
@@ -32,6 +32,10 @@ extension ScanEngine {
/// `photoIDs` hold store ids ("meter_close"); `writePacket` swaps in packet ids.
var marks: [PacketMark]
var guidance: [PacketGuidanceEntry]
+ /// The wall the marks and guidance were placed with, in world meters, captured with them,
+ /// so a later write that adds guidance places it in the same frame (`withCurrentGuidance`).
+ var sceneWall: SceneWall
+ /// The scene.json the upload sent, attached once serialized (`UploadPackaging`).
var scene: Data
/// The camera's frame rate live; nil on a replay, whose rate comes from its frames.
var trajectoryRate: Double?
@@ -49,8 +53,9 @@ extension ScanEngine {
var frames: [ReplayFrame]
}
- /// Nil before there is a wall: the meter frame is built from it.
- func packetInputs(scene: Data, mesh: LiveCapture.MeshSnapshot?) -> PacketInputs? {
+ /// Nil before there is a wall: the meter frame is built from it. The scene is attached by the
+ /// upload once it is serialized from the same capture (`captureUpload`).
+ func packetInputs(mesh: LiveCapture.MeshSnapshot?) -> PacketInputs? {
guard let map = coverage else { return nil }
let wall = map.wall
// The wall in world meters, as scene.json's wall type describes it, for the marks.
@@ -93,12 +98,22 @@ extension ScanEngine {
},
marks: packetMarks(map, wall: sceneWall, frame: frame),
guidance: guidanceLog.entries.map { Self.packetEntry($0, wall: sceneWall, frame: frame) },
- scene: scene,
+ sceneWall: sceneWall,
+ scene: Data(),
trajectoryRate: settings.map { Double($0.framesPerSecond) },
motionStreams: motionRunsLive ? motionAvailable : []
)
}
+ /// `inputs` with the guidance log as it is now, placed with the wall and meter frame the
+ /// inputs were captured with: what the spot check's answer adds to an upload's packet, without
+ /// rereading the engine's geometry since.
+ func withCurrentGuidance(_ inputs: PacketInputs) -> PacketInputs {
+ var inputs = inputs
+ inputs.guidance = guidanceLog.entries.map { Self.packetEntry($0, wall: inputs.sceneWall, frame: inputs.meterFrame) }
+ return inputs
+ }
+
/// `utsname.machine`, such as "iPhone16,1": the hardware, never the phone's name. "arm64" in
/// the Simulator.
nonisolated static func hardwareModel() -> String {
diff --git a/ios/HouseScan/Runtime/ScanEngine.swift b/ios/HouseScan/Runtime/ScanEngine.swift
index d9131a954..388f6e516 100644
--- a/ios/HouseScan/Runtime/ScanEngine.swift
+++ b/ios/HouseScan/Runtime/ScanEngine.swift
@@ -190,6 +190,8 @@ final class ScanEngine {
// Upload
let resultClient: any ResultClient
private var uploadTask: Task?
+ /// The current upload's scene has fixed its ground; keep this true through answer pacing.
+ private var scenePackaged = false
private(set) var placement: PlacementResult?
/// Whether a ground change took the answer down and no answer has been shown since
/// (`answerAfter(_:)`).
@@ -384,7 +386,7 @@ final class ScanEngine {
}
}
- /// The watch behind `-injectGroundRise`, started at the first result of a scan.
+ /// The watch behind `-injectGroundRise`, started at the first upload of a scan.
private var groundInjection: Task?
/// With `-injectGroundRise`, feeds the ground refine a detected floor above the current
@@ -1207,9 +1209,9 @@ final class ScanEngine {
}
/// "I can't get there" or an answered end question settled the current task: choose the next
- /// one now.
+ /// one now. Stretches that stalled earlier stay deferred (`GuidancePlanner.settleCurrentTask`).
func resetGuidanceAfterSkip(camera: CameraFrame, time: Double) {
- planner.reset()
+ planner.settleCurrentTask()
updateGuidance(camera: camera, time: time)
}
@@ -1576,6 +1578,9 @@ final class ScanEngine {
// A fresh map: the old world frame is gone, so its anchors and planes are meaningless.
live?.restart()
coverage = nil
+ // The dot field lives in the old world frame too; kept, new depth would fuse into it.
+ liveDots.reset()
+ state.liveDots = .empty
meterAnchorID.map { live?.removeAnchor($0) }
meterAnchorID = nil
meterTracking = nil
@@ -1668,13 +1673,17 @@ final class ScanEngine {
/// longer has (`GroundFreshness`), before `publishWall` can draw it again on the new one.
private func answerAfter(_ change: GroundFreshness.Change) {
let screen: GroundFreshness.Screen = switch state.phase {
- case .uploading: .sending
+ case .uploading:
+ switch state.upload {
+ case .failed, .rejected: .stopped
+ case .idle, .packaging, .uploading, .analyzing, .done: .sending
+ }
case .spotConfirm: .spotCheck
case .result: .result
case .resultAR: .resultInCamera
default: .noAnswer
}
- switch groundFreshness.after(change, on: screen) {
+ switch groundFreshness.after(change, on: screen, scenePackaged: scenePackaged) {
case .keep:
return
case .sendAgain:
@@ -1989,7 +1998,9 @@ final class ScanEngine {
// MARK: Upload
func startUpload() {
+ scenePackaged = false
go(.uploading)
+ injectGroundForTest()
uploadTask?.cancel()
uploadTask = Task { await upload() }
}
@@ -2006,28 +2017,43 @@ final class ScanEngine {
try? await Task.sleep(for: .milliseconds(50))
}
guard scan == generation, !Task.isCancelled else { return }
- // LiDAR phones: the mesh ARKit built, measured for what faces the wall and what is
- // overhead, off the main actor since ray casts over a whole mesh take a while.
- let meshSnapshot = live?.meshSnapshot()
- var measured = MeshMeasurements()
- if let mesh = meshSnapshot?.mesh, let map = coverage {
- let wall = map.wall
- let span = Self.exportSpan(map)
- measured = await Task.detached(priority: .userInitiated) { Self.measure(mesh, wall: wall, over: span) }.value
- guard scan == generation, !Task.isCancelled else { return }
- RuntimeLog.engine.info("mesh: \(mesh.vertices.count) vertices, \(mesh.indices.count / 3) triangles; \(measured.facing.count) facing and \(measured.overheads.count) overhead measurements")
+ // The scene, the wall in world meters, the LiDAR mesh and the packet's inputs, read in
+ // this one turn (`captureUpload`). From here the upload's ground is fixed: a ground change
+ // while the mesh is measured withdraws this upload and sends again, once, and an anchor
+ // correction leaves it as captured (`GroundFreshness`, `answerAfter`).
+ let capture: UploadPackaging
+ do {
+ capture = try captureUpload()
+ } catch {
+ RuntimeLog.engine.error("scene.json export failed: \(String(describing: error), privacy: .public)")
+ state.upload = UploadFailure.packaging(error)
+ updateRecording()
+ return
}
- let scene: Data
+ scenePackaged = true
+ // LiDAR phones: the mesh measured against the captured wall for what faces it and what is
+ // overhead, off the main actor, then the captured scene serialized with those measurements
+ // and the same bytes given to the captured packet. A withdrawn, cancelled or reset upload
+ // comes back with nothing, so it schedules no bundle and no submit.
+ let packaged: UploadPackaging.Packaged
do {
- scene = try sceneJSON(mesh: measured)
+ guard let done = try await capture.package(
+ attachScene: { $0.scene = $1 },
+ isCurrent: { scan == generation && !Task.isCancelled }
+ ) else { return }
+ packaged = done
} catch {
RuntimeLog.engine.error("scene.json export failed: \(String(describing: error), privacy: .public)")
state.upload = UploadFailure.packaging(error)
updateRecording()
return
}
+ if let mesh = capture.mesh {
+ RuntimeLog.engine.info("mesh: \(mesh.vertices.count) vertices, \(mesh.indices.count / 3) triangles; \(packaged.measurement.facing.count) facing and \(packaged.measurement.overheads.count) overhead measurements")
+ }
+ let scene = packaged.scene
writeScanStamp(answer: placement)
- saveBundle(scene: scene, mesh: meshSnapshot)
+ saveBundle(packaged.packet)
guard !Task.isCancelled else { return }
state.upload = .uploading(fraction: 0)
analyzingSince = nil
@@ -2054,7 +2080,7 @@ final class ScanEngine {
guard scan == generation, state.phase == .uploading, !Task.isCancelled else { return }
}
placement = result
- noteExchange(scene: scene, answer: data)
+ noteExchange(scene: scene, answer: data, packet: packaged.packet)
writeScanStamp(answer: result)
state.result = presentation(of: result, isSample: resultClient.isSample)
state.followUps = automaticGapQueue(result).count
@@ -2155,13 +2181,14 @@ final class ScanEngine {
/// packet, and the upload never waits for it or fails because of it. The zip is rewritten in
/// place, so it is not offered while a write is under way, and writes run one after another:
/// a retry's write waits for the last one, and a write already superseded is skipped.
- func saveBundle(scene: Data, mesh: LiveCapture.MeshSnapshot?) {
+ /// `inputs` were captured with the upload's scene (`captureUpload`); this rereads nothing.
+ func saveBundle(_ inputs: PacketInputs?) {
state.shareableScan = nil
bundleSerial += 1
let serial = bundleSerial
let scan = generation
let previous = bundleTask
- guard let inputs = packetInputs(scene: scene, mesh: mesh) else {
+ guard let inputs else {
RuntimeLog.engine.error("scan bundle not written: no wall")
return
}
diff --git a/ios/HouseScan/UI/Camera/CameraOverlays.swift b/ios/HouseScan/UI/Camera/CameraOverlays.swift
index 10b8fb1c6..22ecd5db0 100644
--- a/ios/HouseScan/UI/Camera/CameraOverlays.swift
+++ b/ios/HouseScan/UI/Camera/CameraOverlays.swift
@@ -7,6 +7,9 @@ struct CameraOverlays: View {
/// Set to the aim ring's legend while it shows, for the screen to draw under its card
/// (`CameraChrome.legend`). Nil on screens with no legend.
var cardLegend: Binding? = nil
+ /// The open camera between the card and the actions (`CameraChrome.cameraWindow`), for the
+ /// aim ring to stay inside. Passed through unread, so its changes don't redraw the fog.
+ var cameraWindow: CameraWindow? = nil
/// The step the aim ring's legend first showed with. The legend explains the first ring that
/// fills and retires once that step ends (#81); it isn't needed on every ring after.
@@ -82,7 +85,8 @@ struct CameraOverlays: View {
target: state.target,
progress: state.aimProgress,
completed: heldTarget,
- onFillingRingShown: { shown in fillingRingShown = shown }
+ onFillingRingShown: { shown in fillingRingShown = shown },
+ cameraWindow: cameraWindow
)
.transition(.opacity)
}
@@ -137,8 +141,8 @@ struct CameraOverlays: View {
/// It goes under the instruction card, in the chrome's own stack, rather than beside the
/// ring: beside it, it went behind the card at accessibility text sizes (the card grows
/// and is drawn over the camera layers), and any layout that dropped it where there was no
- /// room left the people with the largest text without it. In the stack it grows with the
- /// card and scrolls with it at any size.
+ /// room left the people with the largest text without it. At the accessibility sizes it
+ /// folds under the card's Details with the card's other how-to words (`CameraChrome.aims`).
private var shownLegend: String? {
guard fillingRingShown, !legendRetired, state.marking == nil, heldTarget == nil,
let progress = state.aimProgress, progress < 1 else { return nil }
diff --git a/ios/HouseScan/UI/Camera/WayfindingOverlay.swift b/ios/HouseScan/UI/Camera/WayfindingOverlay.swift
index 129093187..07600b1e2 100644
--- a/ios/HouseScan/UI/Camera/WayfindingOverlay.swift
+++ b/ios/HouseScan/UI/Camera/WayfindingOverlay.swift
@@ -4,9 +4,11 @@ import SwiftUI
/// Where to stand and where to aim: a blue dotted path on the ground toward the next place to
/// stand, and a ring on the point to aim at (or an edge arrow when it is off screen).
///
-/// Each part sets its own accessibility: the path and arrow are hidden, while the filling ring
-/// is read (#81). A new part is read by VoiceOver unless it hides itself. The ring's legend is
-/// drawn under the instruction card (`CameraChrome.legend`), not here.
+/// Each part sets its own accessibility: the path is hidden, and the filling ring is read (#81).
+/// An arrow standing in for a filling ring is read like the ring, with its direction ("Spot to
+/// show, above"); other arrows are hidden (`ArrowAccessibility`). A new part is read by
+/// VoiceOver unless it hides itself. The ring's legend is drawn under the instruction card
+/// (`CameraChrome.legend`), not here.
struct WayfindingOverlay: View {
var projection: CameraProjection
var wall: WallGeometry?
@@ -18,13 +20,22 @@ struct WayfindingOverlay: View {
/// An aim step's target that was just completed, drawn as a full ring in place of `target`
/// while it is on screen (`CameraOverlays` holds it for a moment).
var completed: SIMD3? = nil
- /// Called with whether a filling ring is on screen, each time that changes (`CameraOverlays`
- /// shows the ring's legend under the card only while it is).
+ /// Called with whether an aim step's filling ring has its target on screen, each time that
+ /// changes (`CameraOverlays` shows the ring's legend once one has). On screen, not in the
+ /// open camera: at the largest text sizes the card can cover the ring's place until the
+ /// chrome scrolls, and the legend under the card still says what the ring is for.
var onFillingRingShown: ((Bool) -> Void)? = nil
+ /// The open camera between the instruction card and the actions (`CameraChrome`). The ring
+ /// shows only inside it and the edge arrows keep to it. Until the chrome has been laid out,
+ /// fixed bands stand in.
+ var cameraWindow: CameraWindow? = nil
var body: some View {
+ // Read here, in this view's body, so a change redraws this overlay alone.
+ let clearArea = cameraWindow?.frame
GeometryReader { proxy in
let size = proxy.size
+ let band = Self.verticalBand(clearArea, origin: proxy.frame(in: .global).origin, height: size.height)
ZStack {
Canvas { context, size in
drawPath(in: &context, size: size)
@@ -32,14 +43,14 @@ struct WayfindingOverlay: View {
.accessibilityHidden(true)
// One marker either way, so a ring that completes keeps its identity and animates
// from its last fill to green.
- if let shown = marker(in: size) {
+ if let shown = marker(in: size, band: band) {
TargetMarker(placement: shown.placement, progress: shown.progress)
- if progress != nil, completed == nil, case .onScreen = shown.placement {
- Color.clear
- .frame(width: 0, height: 0)
- .onAppear { onFillingRingShown?(true) }
- .onDisappear { onFillingRingShown?(false) }
- }
+ }
+ if fillingTargetOnScreen(in: size) {
+ Color.clear
+ .frame(width: 0, height: 0)
+ .onAppear { onFillingRingShown?(true) }
+ .onDisappear { onFillingRingShown?(false) }
}
}
.frame(width: size.width, height: size.height)
@@ -50,10 +61,26 @@ struct WayfindingOverlay: View {
// MARK: Lane
- /// The band clear of the instruction card above and the buttons and map below, which are
- /// drawn over this layer. The off-screen arrows keep to it.
- private static let laneTop: CGFloat = 260
- private static let laneBottomInset: CGFloat = 300
+ /// The top and bottom of the camera left open by the instruction card above and the
+ /// buttons and map below, which are drawn over this layer, in this view's coordinates.
+ struct Band: Equatable {
+ var top: CGFloat
+ var bottom: CGFloat
+ }
+
+ /// The edge arrow's disc (`TargetMarker`).
+ static let arrowDiameter: CGFloat = 52
+ /// How far inside the band the edge arrows sit: half the arrow's disc and a margin.
+ static let arrowInset: CGFloat = 32
+
+ /// The measured window in this view's coordinates, cut to the part on screen: scrolling the
+ /// chrome at large text sizes can carry the window partly past an edge.
+ private static func verticalBand(_ clearArea: CGRect?, origin: CGPoint, height: CGFloat) -> Band? {
+ clearArea.map {
+ let top = min(max($0.minY - origin.y, 0), height)
+ return Band(top: top, bottom: max(min($0.maxY - origin.y, height), top))
+ }
+ }
// MARK: Path
@@ -92,43 +119,114 @@ struct WayfindingOverlay: View {
// MARK: Target
+ /// Whether an aim step's target is where a filling ring would show without the card: the
+ /// fixed rectangle used before the chrome reported its open camera.
+ private func fillingTargetOnScreen(in size: CGSize) -> Bool {
+ guard progress != nil, completed == nil, let target, let point = projection.viewPoint(for: target, in: size) else {
+ return false
+ }
+ return Self.ringBounds(in: size, band: nil, radius: 0).contains(point)
+ }
+
/// The completed ring while it is on screen, else the target's ring or arrow.
- private func marker(in size: CGSize) -> (placement: TargetMarker.Placement, progress: Double?)? {
+ private func marker(in size: CGSize, band: Band?) -> (placement: TargetMarker.Placement, progress: Double?)? {
if let completed {
- let held = placement(for: completed, in: size)
+ let held = placement(for: completed, in: size, band: band)
if case .onScreen = held { return (placement: held, progress: 1.0) }
}
guard let target else { return nil }
- return (placement: placement(for: target, in: size), progress: progress)
+ return (placement: placement(for: target, in: size, band: band), progress: progress)
}
- private func placement(for target: SIMD3, in size: CGSize) -> TargetMarker.Placement {
- // The ring shows while its center is comfortably on screen; the chevron takes over
- // near the edges, where a half-visible ring would be ambiguous.
- let bounds = CGRect(x: 36, y: 150, width: size.width - 72, height: size.height - 330)
- if let point = projection.viewPoint(for: target, in: size), bounds.contains(point) {
+ private func placement(for target: SIMD3, in size: CGSize, band: Band?) -> TargetMarker.Placement {
+ // Less of the open camera on screen than an arrow's disc: at the largest text sizes the
+ // chrome has scrolled it away, and anything drawn would sit on the card or the actions.
+ if let band, band.bottom - band.top < Self.arrowDiameter { return .hidden }
+ if let point = projection.viewPoint(for: target, in: size) {
let scale = wall.flatMap { WallProjection(projection: projection, wall: $0, size: size).pointsPerMeter(at: target) }
let radius = min(64, max(30, (scale ?? 90) * 0.28))
- return .onScreen(point, radius: radius)
+ if Self.ringBounds(in: size, band: band, radius: radius).contains(point) {
+ return .onScreen(point, radius: radius)
+ }
+ // Near the card or the actions a smaller ring can still fit whole.
+ if let band, let fitted = Self.fittedRadius(at: point, in: size, band: band), fitted < radius {
+ return .onScreen(point, radius: fitted)
+ }
+ // On screen but under the card or the actions: the arrow in the open camera points
+ // to where the spot is drawn, as the edge arrow does for a spot off screen, so the
+ // homeowner tilts toward it and the ring comes into the open. The camera's own
+ // direction to it starts at the optical axis, which can be the spot itself. An arrow
+ // here was taken out at 22f78017 on a guess about the audit; the audit's finding
+ // stayed without it (run 36768462318).
+ if let band, point.y >= 0, point.y <= size.height, point.y < band.top || point.y > band.bottom {
+ let lane = Self.lane(in: size, band: band)
+ let offset = CGVector(dx: point.x - lane.midX, dy: point.y - lane.midY)
+ let length = (offset.dx * offset.dx + offset.dy * offset.dy).squareRoot()
+ guard length > 1 else { return .hidden }
+ let direction = CGVector(dx: offset.dx / length, dy: offset.dy / length)
+ // Stops a disc short of the spot, so it never lands past it pointing back.
+ let edge = Self.arrowPoint(toward: direction, in: size, band: band)
+ let toEdge = ((edge.x - lane.midX) * (edge.x - lane.midX) + (edge.y - lane.midY) * (edge.y - lane.midY)).squareRoot()
+ let reach = min(toEdge, max(length - Self.arrowDiameter, 0))
+ let arrow = CGPoint(x: lane.midX + direction.dx * reach, y: lane.midY + direction.dy * reach)
+ return .offScreen(arrow, angle: .radians(atan2(direction.dy, direction.dx)))
+ }
}
guard let direction = projection.screenDirection(toward: target) else {
return .hidden
}
- // Arrows keep to the lane, clear of the card and the controls.
- let lane = CGRect(
- x: 40,
- y: Self.laneTop,
- width: size.width - 80,
- height: max(size.height - Self.laneTop - Self.laneBottomInset, 80)
+ return .offScreen(Self.arrowPoint(toward: direction, in: size, band: band), angle: .radians(atan2(direction.dy, direction.dx)))
+ }
+
+ /// Where a ring's centre may be for the ring to show: with the whole ring, at the largest
+ /// point of its pulse, on screen and in the open camera. Near the screen's edges the arrow
+ /// takes over, since a half-visible ring would be ambiguous. Without a band, the fixed
+ /// rectangle used before the chrome reported one, sized for the default text.
+ static func ringBounds(in size: CGSize, band: Band?, radius: CGFloat) -> CGRect {
+ guard let band else {
+ return CGRect(x: 36, y: 150, width: size.width - 72, height: size.height - 330)
+ }
+ // `TargetMarker`'s plain ring pulses to 108 percent.
+ let reach = radius * 1.08
+ let inset = max(36, reach)
+ return CGRect(
+ x: inset,
+ y: band.top + reach,
+ width: max(size.width - 2 * inset, 0),
+ height: max(band.bottom - band.top - 2 * reach, 0)
)
+ }
+
+ /// The largest ring radius, down to the smallest ring's 30 pt, that fits whole at `point` in
+ /// the band and on screen, pulse included. Nil when not even that fits.
+ static func fittedRadius(at point: CGPoint, in size: CGSize, band: Band) -> CGFloat? {
+ let clearance = min(point.y - band.top, band.bottom - point.y, point.x, size.width - point.x)
+ let radius = clearance / 1.08
+ return radius >= 30 ? radius : nil
+ }
+
+ /// Where the edge arrows keep: the band inset by `arrowInset`. A band too short for that
+ /// leaves a lane of no height on the band's middle line, where the arrow overlaps the band
+ /// least. Without a band, the fixed lane used before the chrome reported one.
+ static func lane(in size: CGSize, band: Band?) -> CGRect {
+ guard let band else {
+ return CGRect(x: 40, y: 260, width: size.width - 80, height: max(size.height - 560, 80))
+ }
+ let middle = (band.top + band.bottom) / 2
+ let top = min(band.top + arrowInset, middle)
+ let bottom = max(band.bottom - arrowInset, middle)
+ return CGRect(x: 40, y: top, width: max(size.width - 80, 0), height: bottom - top)
+ }
+
+ /// The edge arrow's centre: where a line from the middle of the lane toward the target
+ /// leaves the lane.
+ static func arrowPoint(toward direction: CGVector, in size: CGSize, band: Band?) -> CGPoint {
+ let lane = lane(in: size, band: band)
let center = CGPoint(x: lane.midX, y: lane.midY)
- let halfWidth = lane.width / 2
- let halfHeight = lane.height / 2
- let tx = direction.dx == 0 ? CGFloat.infinity : halfWidth / abs(direction.dx)
- let ty = direction.dy == 0 ? CGFloat.infinity : halfHeight / abs(direction.dy)
+ let tx = direction.dx == 0 ? CGFloat.infinity : (lane.width / 2) / abs(direction.dx)
+ let ty = direction.dy == 0 ? CGFloat.infinity : (lane.height / 2) / abs(direction.dy)
let t = min(tx, ty)
- let edge = CGPoint(x: center.x + direction.dx * t, y: center.y + direction.dy * t)
- return .offScreen(edge, angle: .radians(atan2(direction.dy, direction.dx)))
+ return CGPoint(x: center.x + direction.dx * t, y: center.y + direction.dy * t)
}
}
@@ -171,9 +269,9 @@ struct TargetMarker: View {
.background(Palette.signal, in: .circle)
.overlay(Circle().strokeBorder(.white.opacity(0.85), lineWidth: 2))
.shadow(color: .black.opacity(0.3), radius: 6, y: 2)
+ .modifier(ArrowAccessibility(progress: progress, angle: angle))
.position(point)
.transition(.opacity)
- .accessibilityHidden(true)
case .hidden:
EmptyView()
}
@@ -233,3 +331,31 @@ struct TargetMarker: View {
.accessibilityIdentifier("aim.ring")
}
}
+
+/// An arrow standing in for a filling ring reads as the ring does, with the way to turn: at the
+/// largest text sizes the ring is often under the card, and hiding the arrow too left VoiceOver
+/// without the spot's progress. Other arrows (a plain target, the AR result's spot) stay hidden.
+private struct ArrowAccessibility: ViewModifier {
+ var progress: Double?
+ var angle: Angle
+
+ func body(content: Content) -> some View {
+ if let progress {
+ content
+ .accessibilityElement()
+ .accessibilityLabel("Spot to show, \(way)")
+ .accessibilityValue(progress >= 1 ? "Captured" : "\(Int((min(max(progress, 0), 1) * 100).rounded())) percent captured")
+ .accessibilityAddTraits(.updatesFrequently)
+ .accessibilityIdentifier("aim.arrow")
+ } else {
+ content.accessibilityHidden(true)
+ }
+ }
+
+ /// The arrow's direction in words, by its larger component. Screen y grows downward.
+ private var way: String {
+ let dx = cos(angle.radians), dy = sin(angle.radians)
+ if abs(dx) >= abs(dy) { return dx > 0 ? "to the right" : "to the left" }
+ return dy > 0 ? "below" : "above"
+ }
+}
diff --git a/ios/HouseScan/UI/Components/CameraChrome.swift b/ios/HouseScan/UI/Components/CameraChrome.swift
index 003cbb99a..2e5919009 100644
--- a/ios/HouseScan/UI/Components/CameraChrome.swift
+++ b/ios/HouseScan/UI/Components/CameraChrome.swift
@@ -16,11 +16,23 @@ struct CameraChrome: View {
/// the window's global space). Nil when the screen has nothing to tap.
var onCameraTap: ((CGPoint) -> Void)?
/// A line under the card about something drawn on the camera: the aim ring's legend
- /// (`CameraOverlays`). It is part of the chrome's stack, so it grows with the card and
- /// scrolls with it at the largest text sizes, where the card covers most of the camera.
+ /// (`CameraOverlays`). On an aiming step at the accessibility sizes it folds under the
+ /// card's Details instead (`aims`).
var legend: String? = nil
+ /// Given the open camera between the card and the actions, for the aim ring to stay inside
+ /// (`WayfindingOverlay`). It moves when the chrome scrolls.
+ var cameraWindow: CameraWindow? = nil
+ /// Whether this step has the homeowner aim the camera, so at the accessibility text sizes
+ /// the card folds its detail and the legend under "Details" (`InstructionCard.foldsDetail`)
+ /// and leaves the camera open. Questions and refusals keep every word in view.
+ var aims = false
@ViewBuilder var bottom: Bottom
+ @Environment(\.accessibilityReduceMotion) private var reduceMotion
+ @Environment(\.dynamicTypeSize) private var typeSize
+
+ private var folds: Bool { aims && typeSize.isAccessibilitySize }
+
var body: some View {
// One layout at every text size: the chrome fills the screen and, only when the largest
// text makes it taller than the screen, scrolls instead of squeezing the words. The
@@ -55,14 +67,25 @@ struct CameraChrome: View {
}
}
.frame(minHeight: 36)
- InstructionCard(instruction: instruction, tone: tone, reply: reply, eyebrow: eyebrow)
- if let legend {
+ InstructionCard(
+ instruction: instruction, tone: tone, reply: reply, eyebrow: eyebrow,
+ foldsDetail: folds, legend: folds ? legend : nil
+ )
+ if let legend, !folds {
legendLine(legend)
}
- Spacer(minLength: 0)
+ // The open camera between the card and the actions; the aim ring shows only inside
+ // it (B-36). On an aiming step at the accessibility sizes it keeps at least
+ // `minCameraWindow`, and the actions below scroll into reach.
+ Spacer(minLength: folds ? CameraChromeLayout.minCameraWindow : 0)
+ .onGeometryChange(for: CGRect.self, of: { $0.frame(in: .global) }) { window in
+ if let cameraWindow, cameraWindow.frame != window { cameraWindow.frame = window }
+ }
bottom
}
- .animation(.easeOut(duration: 0.2), value: legend)
+ // The legend's arrival resizes the stack. With Reduce Motion it lands at once, since a
+ // shorter resize still moves everything under it.
+ .animation(reduceMotion ? nil : .easeOut(duration: 0.2), value: legend)
.padding(.horizontal, Metrics.edge)
.padding(.top, 4)
.padding(.bottom, 8)
@@ -85,6 +108,24 @@ struct CameraChrome: View {
}
}
+/// The open camera between the instruction card and the actions, in global coordinates.
+/// `CameraChrome` writes it and only `WayfindingOverlay` reads it, so scrolling the chrome
+/// redraws the aim overlay, not the whole screen.
+@MainActor
+@Observable
+final class CameraWindow {
+ var frame: CGRect?
+}
+
+/// `CameraChrome`'s layout constants, outside it because a generic type can't hold stored
+/// static properties.
+enum CameraChromeLayout {
+ /// The least open camera on an aiming step at the accessibility sizes: room for the largest
+ /// aim ring (64 pt radius, 108 percent at its pulse) with a margin. A layout choice checked
+ /// against the iPhone 17 frames, not measured on a phone.
+ static let minCameraWindow: CGFloat = 180
+}
+
/// Reads the full-screen camera view size so buttons can send "the reticle" (nil point)
/// with the right `viewSize`.
struct CameraSizeReader: View {
diff --git a/ios/HouseScan/UI/Components/InstructionCard.swift b/ios/HouseScan/UI/Components/InstructionCard.swift
index 9a3773c21..8ba32e09a 100644
--- a/ios/HouseScan/UI/Components/InstructionCard.swift
+++ b/ios/HouseScan/UI/Components/InstructionCard.swift
@@ -34,6 +34,15 @@ struct InstructionCard: View {
/// A short line above the instruction that places it in the flow ("One more view to
/// finish"). Read with the instruction as one VoiceOver element.
var eyebrow: String?
+ /// Whether the detail, and `legend`, fold under a "Details" disclosure. `CameraChrome` sets
+ /// it at the accessibility text sizes on steps where the homeowner aims the camera: there
+ /// the full card filled the screen and covered the aim ring (B-36). The title, the eyebrow
+ /// and coaching riding along (`Instruction.note`) always show, since they say what to do
+ /// now; the detail and the legend say how.
+ var foldsDetail = false
+ /// The aim ring's legend, inside the folded details (`foldsDetail`). Otherwise the legend
+ /// is the chrome's own line under the card (`CameraChrome.legend`).
+ var legend: String? = nil
/// How long a new card's reply ignores taps, so a tap meant for the card that just left
/// can't answer the next one: in the 4.1 field test, quick taps on "Can't get there" each
@@ -45,14 +54,26 @@ struct InstructionCard: View {
/// The task whose reply has been on screen for `replyLock`. Compared with the current task
/// in the same update that changes it, so a new reply is locked from its first frame.
@State private var unlockedTask: Instruction?
+ /// Whether the folded details are open. Closed again for each new step (a new title), so the
+ /// camera opens up again; the distance to go and coaching change within a step and keep it.
+ @State private var detailsShown = false
private var replyTask: Instruction { reply?.task ?? instruction }
+ private var hasDetails: Bool { foldsDetail && (foldedDetail != nil || legend != nil) }
+
+ /// The words the card shows: folded, a step that leads with its action does (`Instruction.folded`).
+ private var title: String { foldsDetail ? (instruction.folded?.title ?? instruction.title) : instruction.title }
+ private var foldedDetail: String? { instruction.folded?.detail ?? instruction.detail }
+
var body: some View {
VStack(alignment: .trailing, spacing: 0) {
message
.id(instruction)
.transition(.identity)
+ if hasDetails {
+ details
+ }
if let reply {
Button(reply.title, action: reply.perform)
.font(Typeface.caption.weight(.bold))
@@ -89,8 +110,10 @@ struct InstructionCard: View {
.transition(.identity)
.frame(maxWidth: .infinity)
.background(ScrimShape.rounded())
- .animation(reduceMotion ? .easeOut(duration: 0.15) : Motion.text, value: instruction)
- .animation(Motion.text, value: reply == nil)
+ // Only the scrim's height eases. Reduce Motion asks for no size changes, so it snaps.
+ .animation(reduceMotion ? nil : Motion.text, value: instruction)
+ .animation(reduceMotion ? nil : Motion.text, value: reply == nil)
+ .onChange(of: instruction.title) { detailsShown = false }
.task(id: replyTask) {
// Cleared first, so a task that comes back within the lock (A, B, A) is locked again.
let shown = replyTask
@@ -118,12 +141,13 @@ struct InstructionCard: View {
.fixedSize(horizontal: false, vertical: true)
.padding(.bottom, 2)
}
- Text(instruction.title)
+ Text(title)
.font(Typeface.instruction)
.foregroundStyle(Palette.chalk)
.fixedSize(horizontal: false, vertical: true)
- if let detail = instruction.detail {
- Text(detail)
+ // Folded, only the coaching stays under the title; the detail is in `details`.
+ if let line = foldsDetail ? instruction.note : instruction.detailAndNote {
+ Text(line)
.font(Typeface.hint)
.foregroundStyle(Palette.chalk.opacity(0.92))
.fixedSize(horizontal: false, vertical: true)
@@ -138,6 +162,48 @@ struct InstructionCard: View {
.accessibilityIdentifier("instruction")
}
+ /// The detail and the legend under "Details", the word and control the result screen
+ /// uses for its folded part. A DisclosureGroup, so VoiceOver reads it as collapsed or
+ /// expanded. The words appear at once, like the card's own: a fade spends its moments as
+ /// faint text, which the accessibility audit reports as low contrast.
+ private var details: some View {
+ DisclosureGroup(isExpanded: $detailsShown) {
+ VStack(alignment: .leading, spacing: 12) {
+ if let detail = foldedDetail {
+ Text(detail)
+ .font(Typeface.hint)
+ .foregroundStyle(Palette.chalk.opacity(0.92))
+ .fixedSize(horizontal: false, vertical: true)
+ .accessibilityIdentifier("instruction.detail")
+ }
+ if let legend {
+ // One Text with the symbol inline, as the chrome's legend line is.
+ Text("\(Image(systemName: "scope")) \(legend)")
+ .font(Typeface.hint)
+ .foregroundStyle(Palette.chalk)
+ .fixedSize(horizontal: false, vertical: true)
+ .accessibilityLabel(legend)
+ .accessibilityIdentifier("aim.legend")
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.top, 4)
+ .transition(.identity)
+ } label: {
+ Text(ScanCopy.details)
+ .font(Typeface.hint.weight(.semibold))
+ .foregroundStyle(Palette.chalk)
+ .frame(minHeight: Metrics.minTarget)
+ .accessibilityIdentifier("instruction.details")
+ }
+ .tint(Palette.chalk)
+ .transaction { $0.animation = nil }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.horizontal, 18)
+ .padding(.top, -8)
+ .padding(.bottom, reply == nil ? 8 : 4)
+ }
+
private var iconName: String? {
switch tone {
case .normal: nil
diff --git a/ios/HouseScan/UI/Copy/ScanCopy.swift b/ios/HouseScan/UI/Copy/ScanCopy.swift
index c0b349af2..904b82fc8 100644
--- a/ios/HouseScan/UI/Copy/ScanCopy.swift
+++ b/ios/HouseScan/UI/Copy/ScanCopy.swift
@@ -6,6 +6,27 @@ import HouseScanKit
struct Instruction: Hashable {
var title: String
var detail: String?
+ /// Coaching riding along with the task (`ScanCopy.withCoaching`), such as "Slow down.". Kept
+ /// apart from `detail` so a card that folds its detail away at the largest text sizes still
+ /// shows it (`InstructionCard.foldsDetail`).
+ var note: String? = nil
+ /// The card's words when it folds at the largest text sizes, where the title only names the
+ /// situation and the detail holds what to do: the doing leads, and the situation moves under
+ /// Details. Nil keeps the title and folds the detail.
+ var folded: Folded? = nil
+
+ struct Folded: Hashable {
+ /// What to do now, in place of `title`.
+ var title: String
+ /// The situation and where it is, under Details in place of `detail`.
+ var detail: String
+ }
+
+ /// The second line as one text: the detail, then the note on a line of its own.
+ var detailAndNote: String? {
+ let lines = [detail, note].compactMap(\.self)
+ return lines.isEmpty ? nil : lines.joined(separator: "\n")
+ }
}
/// All user-facing words for engine values. The engine sends meanings (contract `GuidanceStep`,
@@ -30,7 +51,15 @@ enum ScanCopy {
detail: "A gray box with a round glass dial or a small screen, usually on an outside wall."
)
case .aimAtWallForMeter:
- Instruction(title: "Hold on, finding your wall", detail: "Move your phone slowly side to side, then aim at your meter again.")
+ Instruction(
+ title: "Hold on, finding your wall",
+ detail: "Move your phone slowly side to side, then aim at your meter again.",
+ // Folded at the largest text sizes, the doing leads, as on see-behind.
+ folded: Instruction.Folded(
+ title: "Move your phone side to side",
+ detail: "Hold on, finding your wall. Move your phone slowly side to side, then aim at your meter again."
+ )
+ )
case .holdOnMeter:
Instruction(title: "Hold your meter in the circle", detail: "Your phone takes the photo by itself.")
case .walk(let side, let remaining):
@@ -74,7 +103,13 @@ enum ScanCopy {
Instruction(
title: "Something is in front of the wall here",
// A place to look, not a measurement: "About 5 ft", not "4 ft 11 in".
- detail: "\(Distance.aroundFromMeter(s...s).capitalizedFirst). Look at it from the side or step around it."
+ detail: "\(Distance.aroundFromMeter(s...s).capitalizedFirst). Look at it from the side or step around it.",
+ // At AX5 the unfolded card covered the camera and the spot it asks about. The lead
+ // stays two lines even beside a coaching icon; the full words are under Details.
+ folded: Instruction.Folded(
+ title: "Look around it",
+ detail: "Something is in front of the wall here, \(Distance.aroundFromMeter(s...s)). Look at it from the side or step around it."
+ )
)
case .gap:
Instruction(title: "One more view", detail: nil)
@@ -151,10 +186,17 @@ enum ScanCopy {
/// One short line for coaching that rides along with the task instead of replacing it (the
/// walk's capture-gate coaching): the task's title and second line stay, and this goes under
- /// them. Only the coaching's title, so the task's own words stay the bigger part of the card.
+ /// them. Mostly only the coaching's title, so the task's own words stay the bigger part of the
+ /// card. The dark coaching keeps what to do about it: it can stay up for a whole night walk, and
+ /// "It's dark here" alone doesn't say what would help.
static func coachingNote(_ coaching: Coaching) -> String {
- let title = ScanCopy.coaching(coaching).title
- return title.hasSuffix(".") ? title : "\(title)."
+ switch coaching {
+ case .tooDark: return "It's dark here. Try your phone's flashlight, or come back in daylight."
+ case .tooDarkToMeasure: return "It's too dark to measure here. Try in daylight."
+ default:
+ let title = ScanCopy.coaching(coaching).title
+ return title.hasSuffix(".") ? title : "\(title)."
+ }
}
/// Tracking problems and standing past the end replace the task on the card: nothing the task
@@ -173,8 +215,10 @@ enum ScanCopy {
/// coaching came up (#80), and the dark coaching can stay up for a whole night walk.
static func withCoaching(_ task: Instruction, _ coaching: Coaching?) -> Instruction {
guard let coaching else { return task }
- let detail = [task.detail, coachingNote(coaching)].compactMap { $0 }.joined(separator: "\n")
- return Instruction(title: task.title, detail: detail)
+ // A copy, so the task keeps everything else it carries, such as its folded words.
+ var card = task
+ card.note = coachingNote(coaching)
+ return card
}
// MARK: Aim ring
@@ -443,15 +487,15 @@ enum ScanCopy {
static func reply(for step: GuidanceStep) -> (title: String, hint: String)? {
switch step {
case .aimAtGround, .aimAtWall:
- (title: "Skip this spot", hint: "An installer will look at it instead.")
+ (title: "Skip this spot", hint: "An installer would need to look at it instead.")
case .tiltUp:
- (title: "Skip this", hint: "Skips the view above this part of the wall. An installer will look at it instead.")
+ (title: "Skip this", hint: "Skips the view above this part of the wall. An installer would need to look at it instead.")
case .walk:
- (title: "Can't get there", hint: "Ends the wall at the dashed line on the map. An installer will look at what's past it.")
+ (title: "Can't get there", hint: "Ends the wall at the dashed line on the map. An installer would need to look at what's past it.")
case .markNextWall:
- (title: "Can't get there", hint: "Skips this part of the wall. An installer will look at it instead.")
+ (title: "Can't get there", hint: "Skips this part of the wall. An installer would need to look at it instead.")
case .seeBehind:
- (title: cannotSeeBehind, hint: "Skips the part behind it. An installer will look at it instead.")
+ (title: cannotSeeBehind, hint: "Skips the part behind it. An installer would need to look at it instead.")
case .findMeter, .aimAtWallForMeter, .holdOnMeter, .markEnd, .stepBack, .walkComplete, .gap:
nil
}
@@ -554,7 +598,7 @@ enum ScanCopy {
switch answer {
case .fits: "A battery fits here"
case .oneMoreLook: "One more look"
- case .installer: "An installer will confirm"
+ case .installer: "Needs an installer's review"
case .notHere: "Not on this wall"
}
}
@@ -623,8 +667,10 @@ enum ScanCopy {
static let wallNotMeasuredDetail = "The scan stopped before you walked along the wall on either side of your meter, so we can't tell where a battery would fit. Scan again and walk a few steps each way."
static let scanAgain = "Scan again"
- static let installerConfirms = "An installer confirms this on site."
- static let rulesNotFinal = "The placement rules aren't final yet, so an installer reviews every result for now."
+ /// Shown on every result, with or without a spot ("Not on this wall" has none), so it names
+ /// no spot.
+ static let installerConfirms = "Before any battery goes in, an installer has to confirm where it goes on site."
+ static let rulesNotFinal = "The placement rules aren't final yet, so every result needs an installer's review for now."
// The server's result covers where the battery goes, not the panel itself.
static let panelReview = "Your electrical panel still needs an electrician's review. This scan only covers where the battery can go."
@@ -646,8 +692,12 @@ enum ScanCopy {
static let shareScan = "Share scan"
static let shareScanContents = "Your photos and measurements, for the House Scan team"
+ /// A check or a view no photo can settle. It states what the result needs, never that a
+ /// review was sent: the app only shows the server's answer and contacts nobody.
+ static let needsInstaller = "Needs an installer to check"
+
static func unsureNote(_ row: CheckRow) -> String {
- row.needsPerson ? "An installer will check this" : "One more photo would settle this"
+ row.needsPerson ? Self.needsInstaller : "One more photo would settle this"
}
/// "Measured 3 ft 2 in. The rule is at least 3 ft, and the measurement can be off by about 4 in."
diff --git a/ios/HouseScan/UI/Copy/SpotCheckCopy.swift b/ios/HouseScan/UI/Copy/SpotCheckCopy.swift
index 26bf3de72..9640f394a 100644
--- a/ios/HouseScan/UI/Copy/SpotCheckCopy.swift
+++ b/ios/HouseScan/UI/Copy/SpotCheckCopy.swift
@@ -10,17 +10,17 @@ extension ScanCopy {
static let spotClear = "It's clear"
static let spotSomethingThere = "Something's there"
static let spotClearHint = "Shows your result."
- static let spotSomethingThereHint = "Leaves this area out of your scan and checks your wall again. An installer will look at it."
+ static let spotSomethingThereHint = "Leaves this area out of your scan and checks your wall again. An installer would need to look at it."
static func spotAnswered(_ answer: SpotCheckAnswer) -> Instruction {
switch answer {
case .clear: Instruction(title: "Thanks, it's clear", detail: "Showing your result.")
- case .somethingThere: Instruction(title: "Thanks, we'll leave that area out", detail: "Checking your wall again. An installer will look at what's there.")
+ case .somethingThere: Instruction(title: "Thanks, we'll leave that area out", detail: "Checking your wall again. An installer would need to look at what's there.")
}
}
/// Said on the result when the homeowner said something stands where its spot is.
- static let spotRefused = "You said something stands where this spot is. Your scan leaves that area out, and an installer will check it."
+ static let spotRefused = "You said something stands where this spot is. Your scan leaves that area out, and an installer would need to check it."
/// "From 3 ft to 8 ft 6 in right of your meter": where the area runs. `spoken` spells the
/// units out for VoiceOver.
diff --git a/ios/HouseScan/UI/Preview/DemoEngine.swift b/ios/HouseScan/UI/Preview/DemoEngine.swift
index ccd671705..d5f913929 100644
--- a/ios/HouseScan/UI/Preview/DemoEngine.swift
+++ b/ios/HouseScan/UI/Preview/DemoEngine.swift
@@ -1135,7 +1135,7 @@ final class DemoEngine: ScanActions {
/// The offline sample result. Placeholder rules, so the decision is manual review.
static let reviewSample = ResultPresentation(
decision: .manualReview,
- summary: "A spot 3 ft right of your meter fits, but it's close to a window, so an installer will confirm it.",
+ summary: "A spot 3 ft right of your meter fits, but it's close to a window, so it needs an installer's review.",
policyApproved: false,
spot: BatterySpot(span: 0.55...1.34, depth: 0.56, height: 1.1, offsetFromWall: 0.03),
cableRoute: [SIMD2(0.16, 1.45), SIMD2(0.95, 1.45), SIMD2(0.95, 1.1)],
diff --git a/ios/HouseScan/UI/Screens/FindMeterScreen.swift b/ios/HouseScan/UI/Screens/FindMeterScreen.swift
index a949545dd..d7dab2fd8 100644
--- a/ios/HouseScan/UI/Screens/FindMeterScreen.swift
+++ b/ios/HouseScan/UI/Screens/FindMeterScreen.swift
@@ -1,21 +1,27 @@
+import HouseScanKit
import SwiftUI
/// "Find your electric meter": aim the reticle at the meter and tap the big button, or tap the
/// meter on screen. A ring blooms where the homeowner tapped, on touch, before the engine
/// answers; if the engine asks them to step closer, the instruction changes instead.
+///
+/// At the largest text sizes the card folds its second line under Details and the chrome keeps
+/// the camera open between the card and the button (`CameraChrome.aims`). The middle of the
+/// screen is then under the card, so the reticle moves to the middle of that open camera and
+/// the button marks there: the same mark a tap on that spot makes.
struct FindMeterScreen: View {
let state: ScanViewState
let actions: any ScanActions
@State private var cameraSize: CGSize = .zero
@State private var taps: [TapRipple.Ripple] = []
+ @State private var cameraWindow = CameraWindow()
+ @Environment(\.dynamicTypeSize) private var typeSize
var body: some View {
ZStack {
CameraSizeReader(size: $cameraSize)
- Reticle(diameter: 76)
- .frame(maxWidth: .infinity, maxHeight: .infinity)
- .ignoresSafeArea()
+ reticle
TapRipple(ripples: $taps)
CameraChrome(
instruction: instruction,
@@ -26,23 +32,63 @@ struct FindMeterScreen: View {
onCameraTap: { point in
ripple(at: point)
actions.markMeter(at: point, viewSize: cameraSize)
- }
+ },
+ cameraWindow: cameraWindow,
+ aims: aims
) {
Button {
- ripple(at: CGPoint(x: cameraSize.width / 2, y: cameraSize.height / 2))
- actions.markMeter(at: nil, viewSize: cameraSize)
+ ripple(at: aimPoint ?? CGPoint(x: cameraSize.width / 2, y: cameraSize.height / 2))
+ actions.markMeter(at: aimPoint, viewSize: cameraSize)
} label: {
- Label(state.isPracticeScan ? ScanCopy.practiceMarkMeter : "This is my meter", systemImage: "mappin.and.ellipse")
+ let label = Label(state.isPracticeScan ? ScanCopy.practiceMarkMeter : "This is my meter", systemImage: "mappin.and.ellipse")
+ // At the largest text sizes the pin took a fifth of the width and put "Put the
+ // sample meter here" on four lines, below the screen; the words say what the
+ // button does without it, as on the walk's stacked actions.
+ if typeSize.isAccessibilitySize {
+ label.labelStyle(.titleOnly)
+ } else {
+ label
+ }
}
.buttonStyle(.primary)
- .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")
+ .accessibilityHint(markHint)
.accessibilityIdentifier("action.markMeter")
}
}
}
+ /// The reticle at the middle of the screen, or of the open camera when the card folds.
+ private var reticle: some View {
+ GeometryReader { proxy in
+ let origin = proxy.frame(in: .global).origin
+ let center = aimPoint ?? CGPoint(x: proxy.size.width / 2 + origin.x, y: proxy.size.height / 2 + origin.y)
+ Reticle(diameter: 76)
+ .position(x: center.x - origin.x, y: center.y - origin.y)
+ }
+ .ignoresSafeArea()
+ }
+
+ /// Whether the card folds at the largest text sizes: always, except for coaching that
+ /// replaces the task (lost tracking, starting up), whose words are the only thing to do and
+ /// during which the engine refuses a meter mark anyway.
+ private var aims: Bool {
+ guard let coaching = state.coaching else { return true }
+ return !ScanCopy.coachingReplacesTask(coaching)
+ }
+
+ /// Where the reticle is and the button marks (`OpenCameraAim`). Nil means the middle of the
+ /// screen, as at every size where the card doesn't fold. One value for both, so the button
+ /// always marks where the reticle is drawn.
+ private var aimPoint: CGPoint? {
+ guard aims, typeSize.isAccessibilitySize else { return nil }
+ return OpenCameraAim.point(folds: true, window: cameraWindow.frame, camera: cameraSize)
+ }
+
+ private var markHint: String {
+ let place = aimPoint == nil ? "the circle in the middle of the screen" : "the circle below the instructions"
+ return state.isPracticeScan ? "Puts the sample meter on the wall at \(place)" : "Pins your electric meter at \(place)"
+ }
+
private var instruction: Instruction {
if let coaching = state.coaching { return ScanCopy.coaching(coaching) }
if state.guidance == .aimAtWallForMeter { return ScanCopy.guidance(.aimAtWallForMeter) }
diff --git a/ios/HouseScan/UI/Screens/GapRequestScreen.swift b/ios/HouseScan/UI/Screens/GapRequestScreen.swift
index 29fcdcd74..f97618264 100644
--- a/ios/HouseScan/UI/Screens/GapRequestScreen.swift
+++ b/ios/HouseScan/UI/Screens/GapRequestScreen.swift
@@ -13,13 +13,15 @@ struct GapRequestScreen: View {
let actions: any ScanActions
@State private var cameraSize: CGSize = .zero
+ /// The open camera between the card and the actions, for the aim ring (`CameraChrome`).
+ @State private var cameraWindow = CameraWindow()
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@Environment(\.dynamicTypeSize) private var typeSize
var body: some View {
ZStack {
CameraSizeReader(size: $cameraSize)
- CameraOverlays(state: state, highlight: state.gap)
+ CameraOverlays(state: state, highlight: state.gap, cameraWindow: cameraWindow)
if state.gap?.isSatisfied == true {
SuccessBadge()
.frame(maxWidth: .infinity, maxHeight: .infinity)
@@ -42,7 +44,9 @@ struct GapRequestScreen: View {
photoCount: state.captureCount,
lastCaptureID: state.lastCapture?.id,
isReplay: state.isReplay,
- isAutopilot: state.isAutopilot
+ isAutopilot: state.isAutopilot,
+ cameraWindow: cameraWindow,
+ aims: aims
) {
VStack(spacing: 10) {
if asking {
@@ -93,7 +97,7 @@ struct GapRequestScreen: View {
Label("Show my result", systemImage: "checkmark.circle")
}
.buttonStyle(.secondary)
- .accessibilityHint("Stops asking for views and shows your result. An installer will look at the parts you skip.")
+ .accessibilityHint("Stops asking for views and shows your result. An installer would need to look at the parts you skip.")
.accessibilityIdentifier("action.showResult")
.transition(.opacity)
}
@@ -115,12 +119,26 @@ struct GapRequestScreen: View {
/// so the hint doesn't promise the result; "Show my result" does.
private var skipHint: String {
switch followUps {
- case 0: "Skips this view. An installer will look at this part instead."
- case 1: "Skips this view and checks your scan again. An installer will look at this part instead."
- default: "Skips this view and goes on to the next one. An installer will look at this part instead."
+ case 0: "Skips this view. An installer would need to look at this part instead."
+ case 1: "Skips this view and checks your scan again. An installer would need to look at this part instead."
+ default: "Skips this view and goes on to the next one. An installer would need to look at this part instead."
}
}
+ /// The phone's own request for ground by a likely spot has the homeowner aim the camera, and
+ /// its second line only says why ("This might be a spot for the battery"), so it folds at the
+ /// largest text sizes (`CameraChrome.aims`). Every other request's second line is the action
+ /// or its extent: stepping back for ground further out, the walk out with its live "this
+ /// 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.
+ private var aims: Bool {
+ guard let gap = state.gap, !asking, !gap.isSatisfied else { return false }
+ if let coaching, ScanCopy.coachingReplacesTask(coaching) { return false }
+ if case .groundNearCandidate = gap.reason { return true }
+ return false
+ }
+
/// As on the walk: tracking problems and standing past an end replace the request, and the
/// capture gate's coaching rides along under it (`ScanCopy.withCoaching`). The dark coaching
/// can stay up for a whole night request, and replacing the card hid the request and "Got it,
diff --git a/ios/HouseScan/UI/Screens/MeterCloseUpScreen.swift b/ios/HouseScan/UI/Screens/MeterCloseUpScreen.swift
index 44df73a80..beba67873 100644
--- a/ios/HouseScan/UI/Screens/MeterCloseUpScreen.swift
+++ b/ios/HouseScan/UI/Screens/MeterCloseUpScreen.swift
@@ -74,7 +74,7 @@ struct MeterCloseUpScreen: View {
if offerSkip {
Button("Can't get a clear shot") { actions.skipCloseUp() }
.buttonStyle(.secondaryProminent)
- .accessibilityHint("Skips the close-up. An installer will read the meter instead.")
+ .accessibilityHint("Skips the close-up. An installer would need to read the meter instead.")
.accessibilityIdentifier("action.skipCloseUp")
.transition(.identity)
}
diff --git a/ios/HouseScan/UI/Screens/OnboardingScreen.swift b/ios/HouseScan/UI/Screens/OnboardingScreen.swift
index e7d2e5cf8..5a935c8f0 100644
--- a/ios/HouseScan/UI/Screens/OnboardingScreen.swift
+++ b/ios/HouseScan/UI/Screens/OnboardingScreen.swift
@@ -78,6 +78,9 @@ struct OnboardingScreen: View {
}
}
.tabViewStyle(.page(indexDisplayMode: .never))
+ // At AX5 a scrolled page's text showed under the page dots (screenshots at e7031990).
+ // Clipped here and covered by the footer's background below, a page can't draw there.
+ .clipped()
VStack(spacing: 16) {
PageDots(count: pages.count, current: page)
@@ -110,7 +113,13 @@ struct OnboardingScreen: View {
}
.animation(Motion.screen, value: page)
.padding(.horizontal, 24)
+ // Space between the last line of a scrolled page and the dots.
+ .padding(.top, 12)
.padding(.bottom, 12)
+ // Opaque, so nothing drawn past the page view's edge shows under the dots.
+ .background(Palette.canvas)
+ .accessibilityElement(children: .contain)
+ .accessibilityIdentifier("onboarding.footer")
}
.background(Palette.canvas.ignoresSafeArea())
}
@@ -219,6 +228,7 @@ private struct OnboardingPageView: View {
.frame(maxWidth: .infinity)
}
.scrollBounceBehavior(.basedOnSize)
+ .accessibilityIdentifier("onboarding.page")
}
private var text: some View {
@@ -341,6 +351,10 @@ private struct WalkArt: View {
.position(x: size.width * (0.14 + 0.72 * phase), y: size.height * 0.72)
Text("About 2 min")
.font(Typeface.caption)
+ // The drawing is a fixed 260 pt and hidden from VoiceOver, and the page's
+ // first sentence says "about 2 minutes" at full size. Past this size the
+ // caption covered the meter it is drawn beside.
+ .dynamicTypeSize(...DynamicTypeSize.xxxLarge)
.foregroundStyle(.white)
.padding(.horizontal, 10)
.padding(.vertical, 5)
diff --git a/ios/HouseScan/UI/Screens/ResultARScreen.swift b/ios/HouseScan/UI/Screens/ResultARScreen.swift
index d043f4316..ef697d885 100644
--- a/ios/HouseScan/UI/Screens/ResultARScreen.swift
+++ b/ios/HouseScan/UI/Screens/ResultARScreen.swift
@@ -57,7 +57,7 @@ struct ResultARScreen: View {
guard result.spot != nil else { return "The cable run and clearances, drawn on your wall" }
return result.spotIsClean
? "The battery, drawn on your wall at its spot"
- : "An outline of the spot an installer will check, drawn on your wall"
+ : "An outline of the spot an installer needs to check, drawn on your wall"
}
private var instruction: Instruction {
@@ -71,7 +71,7 @@ struct ResultARScreen: View {
// check says so, as the result screen does.
let title = result.decision == .pass && result.policyApproved && result.spotIsClean
? "Your battery could go here"
- : "The spot an installer will check"
+ : "The spot an installer needs to check"
let placement = ScanCopy.placement(result)
guard !result.isSample else {
// A sample spot drawn on the homeowner's real wall must not pass for their result.
diff --git a/ios/HouseScan/UI/Screens/ResultScreen.swift b/ios/HouseScan/UI/Screens/ResultScreen.swift
index 12b72c262..57e1c27a1 100644
--- a/ios/HouseScan/UI/Screens/ResultScreen.swift
+++ b/ios/HouseScan/UI/Screens/ResultScreen.swift
@@ -10,6 +10,7 @@ struct ResultScreen: View {
@State private var revealed = false
@State private var detailsExpanded = false
+ @Environment(\.dynamicTypeSize) private var typeSize
var body: some View {
if let result = state.result {
@@ -31,6 +32,9 @@ struct ResultScreen: View {
VStack(alignment: .leading, spacing: 0) {
diorama(result)
VStack(alignment: .leading, spacing: 20) {
+ if typeSize.isAccessibilitySize {
+ badges(result)
+ }
AnswerCard(result: result, sourceAvailable: state.spatialResultAvailable, revealed: revealed, actions: actions)
if result.spot != nil, state.spotCheck?.answer == .somethingThere {
Notice(symbol: "exclamationmark.triangle.fill", text: ScanCopy.spotRefused)
@@ -68,30 +72,39 @@ struct ResultScreen: View {
} else {
Palette.canvas
}
- VStack(alignment: .leading, spacing: 6) {
- ModeBadge(isReplay: state.isReplay, isAutopilot: state.isAutopilot)
- if result.isSample {
- // The same quiet pill as ModeBadge: the answer, not the test mode, is the
- // loudest thing on this screen.
- // One Text with the flask inline, built as ModeBadge is. As a Label with
- // fixedSize, the audit reported its Dynamic Type as partially unsupported.
- Text("\(Image(systemName: "flask.fill")) Sample result, not from the server")
- .font(.caption2.weight(.medium))
- .foregroundStyle(Palette.chalk)
- .padding(.horizontal, 8)
- .padding(.vertical, 4)
- .background(Palette.ink.opacity(0.7), in: .capsule)
- .accessibilityLabel("Sample result, not from the server")
- .accessibilityIdentifier("result.sampleBadge")
- }
+ if !typeSize.isAccessibilitySize {
+ badges(result)
+ .padding(14)
}
- .padding(14)
}
.frame(height: 340)
.clipShape(.rect(bottomLeadingRadius: 28, bottomTrailingRadius: 28, style: .continuous))
.ignoresSafeArea(edges: .top)
}
+ /// Over the model at the default sizes. At the accessibility sizes the two pills covered
+ /// most of it, so they sit above the answer card instead.
+ private func badges(_ result: ResultPresentation) -> some View {
+ VStack(alignment: .leading, spacing: 6) {
+ ModeBadge(isReplay: state.isReplay, isAutopilot: state.isAutopilot)
+ if result.isSample {
+ // The same quiet pill as ModeBadge: the answer, not the test mode, is the
+ // loudest thing on this screen.
+ // One Text with the flask inline, built as ModeBadge is. As a Label with
+ // fixedSize, the audit reported its Dynamic Type as partially unsupported.
+ Text("\(Image(systemName: "flask.fill")) Sample result, not from the server")
+ .font(.caption2.weight(.medium))
+ .foregroundStyle(Palette.chalk)
+ .padding(.horizontal, typeSize.isAccessibilitySize ? 14 : 8)
+ .padding(.vertical, typeSize.isAccessibilitySize ? 8 : 4)
+ // Wrapped onto two lines, a capsule's round ends cut into the text's corners.
+ .background(Palette.ink.opacity(0.7), in: typeSize.isAccessibilitySize ? AnyShape(.rect(cornerRadius: 16, style: .continuous)) : AnyShape(.capsule))
+ .accessibilityLabel("Sample result, not from the server")
+ .accessibilityIdentifier("result.sampleBadge")
+ }
+ }
+ }
+
/// Plain small print, never boxes: none of it changes what the homeowner does next.
private func footnotes(_ result: ResultPresentation) -> some View {
VStack(alignment: .leading, spacing: 6) {
@@ -540,7 +553,7 @@ private struct MissingList: View {
.buttonStyle(TextActionStyle())
.accessibilityIdentifier("action.captureMissing")
} else {
- Label("An installer will check this", systemImage: "person.fill")
+ Label(ScanCopy.needsInstaller, systemImage: "person.fill")
.font(.footnote.weight(.semibold))
.foregroundStyle(Palette.muted)
}
diff --git a/ios/HouseScan/UI/Screens/WallWalkScreen.swift b/ios/HouseScan/UI/Screens/WallWalkScreen.swift
index 0d3eb55a0..cefd9f266 100644
--- a/ios/HouseScan/UI/Screens/WallWalkScreen.swift
+++ b/ios/HouseScan/UI/Screens/WallWalkScreen.swift
@@ -12,6 +12,8 @@ struct WallWalkScreen: View {
let actions: any ScanActions
@State private var cameraSize: CGSize = .zero
+ /// The open camera between the card and the actions, for the aim ring (`CameraChrome`).
+ @State private var cameraWindow = CameraWindow()
@State private var trayOpen = false
@State private var taps: [TapRipple.Ripple] = []
/// The aim ring's legend while it shows, drawn under the card (#81).
@@ -22,7 +24,7 @@ struct WallWalkScreen: View {
var body: some View {
ZStack {
CameraSizeReader(size: $cameraSize)
- CameraOverlays(state: state, highlight: nil, cardLegend: $cardLegend)
+ CameraOverlays(state: state, highlight: nil, cardLegend: $cardLegend, cameraWindow: cameraWindow)
if state.coaching == .relocalizing, let meterPhoto {
// "Point at the meter like this.": the saved close-up shows what to aim at.
SavedMeterPhoto(image: meterPhoto)
@@ -48,7 +50,9 @@ struct WallWalkScreen: View {
taps.append(.init(point: point))
actions.markFeaturePoint(at: point, viewSize: cameraSize)
},
- legend: cardLegend
+ legend: cardLegend,
+ cameraWindow: cameraWindow,
+ aims: aims
) {
VStack(spacing: 10) {
controls
@@ -121,6 +125,27 @@ struct WallWalkScreen: View {
return .normal
}
+ /// Steps where the homeowner aims the camera and the title says what to do, so the card's
+ /// second line (how to hold the phone, where along the wall, how far to go) can fold at the
+ /// largest text sizes and leave the camera open (`CameraChrome.aims`). The aim ring marks the
+ /// 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.
+ 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:
+ return false
+ }
+ }
+
// MARK: Controls
private enum ControlsKey: Hashable {
@@ -165,15 +190,21 @@ struct WallWalkScreen: View {
private var controls: some View {
switch controlsKey {
case .marking:
- HStack(spacing: 10) {
- Button("Cancel") { actions.cancelMarking() }
- .buttonStyle(.secondaryProminent)
- .accessibilityIdentifier("action.cancelMarking")
+ actionRow {
+ Button {
+ actions.cancelMarking()
+ } label: {
+ Text("Cancel")
+ .spansStack(stacksActions)
+ }
+ .buttonStyle(.secondaryProminent)
+ .accessibilityIdentifier("action.cancelMarking")
Button {
taps.append(.init(point: CGPoint(x: cameraSize.width / 2, y: cameraSize.height / 2)))
actions.markFeaturePoint(at: nil, viewSize: cameraSize)
} label: {
Label("Mark", systemImage: "plus.viewfinder")
+ .spansStack(stacksActions)
}
.buttonStyle(.primary)
.accessibilityHint("Marks the point under the circle in the middle of the screen")
@@ -278,13 +309,12 @@ struct WallWalkScreen: View {
.transition(.opacity)
case .nextWall:
// Back asks about the end again (#70); marking something waits for the walk.
- let layout = typeSize.isAccessibilitySize ? AnyLayout(VStackLayout(spacing: 8)) : AnyLayout(HStackLayout(spacing: 10))
- layout {
+ actionRow {
Button {
actions.cancelNextWall()
} label: {
Label("Back", systemImage: "chevron.backward")
- .frame(maxWidth: typeSize.isAccessibilitySize ? .infinity : nil)
+ .spansStack(stacksActions)
}
.buttonStyle(.secondaryProminent)
.accessibilityHint("Asks again what's at this end of the wall")
@@ -293,6 +323,7 @@ struct WallWalkScreen: View {
actions.markNextWall(at: nil, viewSize: cameraSize)
} label: {
Label("Mark next wall", systemImage: nextWallSymbol)
+ .spansStack(stacksActions)
.frame(maxWidth: .infinity)
}
.buttonStyle(.primary)
@@ -309,13 +340,16 @@ struct WallWalkScreen: View {
case .markEnd, .finish, .walking:
// One row for all three, so "Mark something" stays the same view while the button
// beside it changes. Rebuilt per case, it crossfaded out as a frozen copy that the
- // accessibility audit reported as not following Dynamic Type.
- HStack(spacing: 10) {
+ // accessibility audit reported as not following Dynamic Type. `actionRow` swaps
+ // the row for a stack at accessibility sizes without rebuilding it.
+ actionRow {
// While the walk asks to look past an obstruction, that step has one way out
// ("Can't see past it" in the card), so "Mark something" steps aside without
- // leaving the row: it keeps its place and stays the same view.
+ // leaving the row: it keeps its place and stays the same view. Stacked, an
+ // empty full-width slot would push the map up, so it also gives up its height.
markSomethingButton
.opacity(isSeeingBehind ? 0 : 1)
+ .frame(height: isSeeingBehind && stacksActions ? 0 : nil)
.allowsHitTesting(!isSeeingBehind)
.accessibilityHidden(isSeeingBehind)
switch controlsKey {
@@ -324,6 +358,7 @@ struct WallWalkScreen: View {
actions.markWallEnd(at: nil, viewSize: cameraSize)
} label: {
Label("Wall ends here", systemImage: "flag.fill")
+ .spansStack(stacksActions)
}
.buttonStyle(.primary)
.accessibilityHint("Marks the end of the wall at the circle in the middle of the screen")
@@ -334,7 +369,7 @@ struct WallWalkScreen: View {
actions.endWallHere()
} label: {
Label("Wall ends here", systemImage: "flag")
- .frame(maxWidth: typeSize.isAccessibilitySize ? .infinity : nil)
+ .spansStack(stacksActions)
}
.buttonStyle(.secondaryProminent)
.accessibilityHint("Ends the wall where you're standing, at the dashed line on the map")
@@ -345,18 +380,36 @@ struct WallWalkScreen: View {
actions.finishWalk()
} label: {
Label("Done with this wall", systemImage: "checkmark")
+ .spansStack(stacksActions)
}
.buttonStyle(.primary)
.accessibilityIdentifier("action.finishWalk")
.transition(.opacity)
default:
- Spacer(minLength: 0)
+ // Keeps "Mark something" to its own width in the row; stacked, it already
+ // spans the screen and a spacer would only add height.
+ if !stacksActions { Spacer(minLength: 0) }
}
}
.transition(.opacity)
}
}
+ /// Two actions side by side at the default sizes, one above the other at the accessibility
+ /// sizes, where a side-by-side pair broke "Mark something" and "Wall ends here" into one or
+ /// two letters a line (B-28). The default row is the same `HStack` as before; the buttons
+ /// are rebuilt only when the text size crosses into the accessibility sizes.
+ private var stacksActions: Bool { typeSize.isAccessibilitySize }
+
+ @ViewBuilder
+ private func actionRow(@ViewBuilder _ content: () -> Content) -> some View {
+ if stacksActions {
+ VStack(spacing: 8, content: content)
+ } else {
+ HStack(spacing: 10, content: content)
+ }
+ }
+
/// The capture gate's coaching (slow down, texture, light, hold steady) comes and goes within
/// a second while the homeowner walks, and the task under it stays the same, so the card's
/// reply stays put through it: hiding it each time made the button fade in and out under the
@@ -409,8 +462,7 @@ struct WallWalkScreen: View {
trayOpen = true
} label: {
Label("Mark something", systemImage: "mappin.and.ellipse")
- .labelStyle(.titleAndIcon)
- .frame(maxWidth: typeSize.isAccessibilitySize ? .infinity : nil)
+ .spansStack(stacksActions)
}
.buttonStyle(.secondaryProminent)
.accessibilityHint("Pin a gas meter, door, window, AC unit, driveway or fence")
@@ -486,3 +538,18 @@ struct FeatureTray: View {
.background(ScrimShape.rounded())
}
}
+
+private extension View {
+ /// Stacked, a wall-walk action spans the screen and shows its words alone; in a row it is
+ /// as wide as its words, with its icon. At the largest text sizes the icon took a third of
+ /// the width and broke "Mark something" into three lines ("some-", "thing"); the words say
+ /// what the button does without it.
+ @ViewBuilder
+ func spansStack(_ stacked: Bool) -> some View {
+ if stacked {
+ labelStyle(.titleOnly).frame(maxWidth: .infinity)
+ } else {
+ labelStyle(.titleAndIcon)
+ }
+ }
+}
diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Coverage/CoverageMap.swift b/ios/HouseScanKit/Sources/HouseScanKit/Coverage/CoverageMap.swift
index a4f76aee2..963e513f0 100644
--- a/ios/HouseScanKit/Sources/HouseScanKit/Coverage/CoverageMap.swift
+++ b/ios/HouseScanKit/Sources/HouseScanKit/Coverage/CoverageMap.swift
@@ -257,7 +257,7 @@ public struct CoverageMap: Sendable {
public private(set) var withdrawnCells: Set = []
/// One camera position that saw a sample row, and whether that frame's depth confirmed it.
- private struct Sight: Sendable {
+ private struct Sight: Sendable, Equatable {
var position: SIMD3
var depthVerified: Bool
}
@@ -279,7 +279,7 @@ public struct CoverageMap: Sendable {
return row.count != before
}
- private struct Cell: Sendable {
+ private struct Cell: Sendable, Equatable {
/// Per sample row, its sightings, pairwise at least `coveringBaseline` apart. Two are
/// enough, so a row stops collecting at two.
var rows: [[Sight]]
@@ -428,10 +428,15 @@ public struct CoverageMap: Sendable {
/// Every cell a frame sees or finds hidden at least one row of, before the marked ends clip
/// anything that isn't allowed. Without `depth` nothing is hidden.
public func visibleCells(from camera: CameraFrame, depth: DepthImage? = nil) -> [Sighting] {
+ visibleCells(from: camera, depth: depth, reads: nil)
+ }
+
+ /// `visibleCells(from:depth:)`, noting in `reads` where the far-surface lookup was consulted.
+ private func visibleCells(from camera: CameraFrame, depth: DepthImage?, reads: FarSurfaceReadLog?) -> [Sighting] {
var seen: [Sighting] = []
for band in SurfaceBand.allCases {
for index in candidateIndices(for: camera) {
- let views = rowViews(band, index, from: camera, depth: depth, among: Array(rowOffsets(band).indices))
+ let views = rowViews(band, index, from: camera, depth: depth, among: Array(rowOffsets(band).indices), reads: reads)
if !views.seen.isEmpty || !views.hidden.isEmpty || !views.pastSpace.isEmpty {
seen.append(Sighting(band: band, index: index, rows: views.seen, hiddenRows: views.hidden, pastSpaceRows: views.pastSpace))
}
@@ -563,15 +568,17 @@ public struct CoverageMap: Sendable {
rowViews(band, index, from: camera, depth: nil, among: Array(rowOffsets(band).indices)).seen
}
- private func rowViews(_ band: SurfaceBand, _ index: Int, from camera: CameraFrame, depth: DepthImage?, among rows: [Int]) -> RowViews {
+ private func rowViews(
+ _ band: SurfaceBand, _ index: Int, from camera: CameraFrame, depth: DepthImage?, among rows: [Int], reads: FarSurfaceReadLog? = nil
+ ) -> RowViews {
let offsets = rowOffsets(band)
- return sampledRows(index, from: camera, depth: depth, rows: rows, onWallFace: band == .wall) { s, row in
+ return sampledRows(index, from: camera, depth: depth, rows: rows, onWallFace: band == .wall, reads: reads) { s, row in
band == .wall ? wall.world(s: s, height: offsets[row]) : wall.world(s: s, height: 0, out: offsets[row])
}
}
/// What one frame showed of a cell's rows.
- private struct RowViews {
+ private struct RowViews: Sendable {
var seen: Set = []
var hidden: Set = []
/// Rows past where the space ends (`DepthEvidence.pastSpace`): neither seen nor hidden.
@@ -592,12 +599,12 @@ public struct CoverageMap: Sendable {
case none
}
- private func depthEvidence(_ point: SIMD3, camera: CameraFrame, depth: DepthImage) -> DepthEvidence {
+ private func depthEvidence(_ point: SIMD3, camera: CameraFrame, depth: DepthImage, reads: FarSurfaceReadLog? = nil) -> DepthEvidence {
guard let projected = depth.projection(of: point, pose: camera),
let reading = depth.meters(atPixel: projected.pixel, minimumConfidence: config.minimumDepthConfidence) else { return .none }
let tolerance = config.depthTolerance + config.depthTolerancePerMeter * projected.depth
if reading < projected.depth - tolerance {
- return endsSpace(point, reading: reading, depth: projected.depth, camera: camera) ? .pastSpace : .nearer
+ return endsSpace(point, reading: reading, depth: projected.depth, camera: camera, reads: reads) ? .pastSpace : .nearer
}
return reading <= projected.depth + tolerance ? .matches : .none
}
@@ -613,16 +620,27 @@ public struct CoverageMap: Sendable {
/// or a parked car's side, can qualify as the far surface (`FarSurface.spans`). The wall and
/// ground behind it then read unseen rather than hidden: the walk stops asking to look past
/// it, but nothing behind it is claimed seen, so a check there stays unsure.
- private func endsSpace(_ point: SIMD3, reading: Float, depth: Float, camera: CameraFrame) -> Bool {
+ ///
+ /// With `reads`, both cells this could consult are noted with what the lookup holds there,
+ /// before the empty-lookup return and the short circuit: under another lookup the answer can
+ /// turn on either of them (`RebuildViewCache`).
+ private func endsSpace(_ point: SIMD3, reading: Float, depth: Float, camera: CameraFrame, reads: FarSurfaceReadLog? = nil) -> Bool {
+ // z-depth grows in proportion along a ray from the camera, so what the reading met lies
+ // that fraction of the way to the point.
+ let met = camera.position + (point - camera.position) * (reading / depth)
+ if let reads {
+ for world in [point, met] {
+ let cell = cellIndex(forS: wall.wallPoint(world).s)
+ reads.note(cell, farSurfaceByCell[cell])
+ }
+ }
guard !farSurfaceByCell.isEmpty else { return false }
func pastSpace(_ world: SIMD3) -> Bool {
let at = wall.wallPoint(world)
guard let far = farSurface(atS: at.s) else { return false }
return at.out >= far - config.farSurfaceTolerance
}
- // z-depth grows in proportion along a ray from the camera, so what the reading met lies
- // that fraction of the way to the point.
- return pastSpace(point) || pastSpace(camera.position + (point - camera.position) * (reading / depth))
+ return pastSpace(point) || pastSpace(met)
}
/// The rows of a cell whose samples, a quarter and three quarters along it, are all in view:
@@ -631,7 +649,7 @@ public struct CoverageMap: Sendable {
/// the wall face, up for the ground. With `depth`, a row in view is seen only when depth
/// matches both samples, and hidden when it finds either behind something nearer.
private func sampledRows(
- _ index: Int, from camera: CameraFrame, depth: DepthImage?, rows: [Int], onWallFace: Bool,
+ _ index: Int, from camera: CameraFrame, depth: DepthImage?, rows: [Int], onWallFace: Bool, reads: FarSurfaceReadLog? = nil,
point: (_ s: Float, _ row: Int) -> SIMD3
) -> RowViews {
let range = cellRange(index)
@@ -659,7 +677,7 @@ public struct CoverageMap: Sendable {
views.seen.insert(row)
continue
}
- let evidence = points.map { depthEvidence($0, camera: camera, depth: depth) }
+ let evidence = points.map { depthEvidence($0, camera: camera, depth: depth, reads: reads) }
if evidence.allSatisfy({ $0 == .matches }) {
views.seen.insert(row)
} else if evidence.contains(.nearer) {
@@ -721,13 +739,29 @@ public struct CoverageMap: Sendable {
depthRowViews(index, from: camera, depth: depth).seen
}
- private func depthRowViews(_ index: Int, from camera: CameraFrame, depth: DepthImage?) -> RowViews {
+ private func depthRowViews(_ index: Int, from camera: CameraFrame, depth: DepthImage?, reads: FarSurfaceReadLog? = nil) -> RowViews {
let rows = groundDepthRows
- return sampledRows(index, from: camera, depth: depth, rows: Array(rows.indices), onWallFace: false) { s, row in
+ return sampledRows(index, from: camera, depth: depth, rows: Array(rows.indices), onWallFace: false, reads: reads) { s, row in
wall.world(s: s, height: 0, out: rows[row])
}
}
+ /// What one frame showed of one cell's ground depth rows.
+ private struct DepthRowView: Sendable {
+ var index: Int
+ var views: RowViews
+ }
+
+ /// A frame's view of the ground depth rows of every cell it shows any of: the part of
+ /// `recordDepth` that reads no recorded state.
+ private func depthRowViews(from camera: CameraFrame, depth: DepthImage?, reads: FarSurfaceReadLog? = nil) -> [DepthRowView] {
+ candidateIndices(for: camera).compactMap { index in
+ let views = depthRowViews(index, from: camera, depth: depth, reads: reads)
+ guard !views.seen.isEmpty || !views.hidden.isEmpty || !views.pastSpace.isEmpty else { return nil }
+ return DepthRowView(index: index, views: views)
+ }
+ }
+
/// Adds a kept frame's view of the ground depth rows; true when any row gained a position.
/// Hidden rows gain nothing, so the reach stops at the first row something stood in front of,
/// and a frame without depth adds nothing to a row an earlier depth frame found hidden. Rows
@@ -735,11 +769,17 @@ public struct CoverageMap: Sendable {
/// (`depthPastSpace`), and a frame without depth adds nothing to them either.
@discardableResult
private mutating func recordDepth(from camera: CameraFrame, depth: DepthImage?) -> Bool {
+ recordDepth(depthRowViews(from: camera, depth: depth), from: camera.position, depthChecked: depth != nil)
+ }
+
+ /// `recordDepth(from:depth:)` given the frame's views, from `position`; `depthChecked` when the
+ /// frame had depth.
+ @discardableResult
+ private mutating func recordDepth(_ found: [DepthRowView], from position: SIMD3, depthChecked: Bool) -> Bool {
let rowCount = groundDepthRows.count
var changed = false
- for index in candidateIndices(for: camera) {
- let views = depthRowViews(index, from: camera, depth: depth)
- guard !views.seen.isEmpty || !views.hidden.isEmpty || !views.pastSpace.isEmpty else { continue }
+ for item in found {
+ let index = item.index, views = item.views
var rows = depthCells[index] ?? Array(repeating: [], count: rowCount)
for row in views.hidden {
if Self.dropUnverified(&rows[row]) { changed = true }
@@ -751,9 +791,9 @@ public struct CoverageMap: Sendable {
if Self.dropUnverified(&rows[row]) { changed = true }
if rows[row].count < 2 { depthPastSpace[index, default: []].insert(row) }
}
- let hidden = depth == nil ? (depthHidden[index] ?? []).union(depthPastSpace[index] ?? []) : []
+ let hidden = depthChecked ? [] : (depthHidden[index] ?? []).union(depthPastSpace[index] ?? [])
for row in views.seen where !hidden.contains(row) {
- if add(&rows[row], camera.position, verified: depth != nil) { changed = true }
+ if add(&rows[row], position, verified: depthChecked) { changed = true }
}
depthCells[index] = rows
}
@@ -836,8 +876,31 @@ public struct CoverageMap: Sendable {
/// is anything standing on the ground (see the type's comment).
@discardableResult
private mutating func recordPastLimits(from camera: CameraFrame, depth: DepthImage?) -> Bool {
- guard wall.wallPoint(camera.position).out > 0 else { return false }
- var changed = false
+ recordPastLimits(pastLimitViews(from: camera, depth: depth), from: camera.position, depthChecked: depth != nil)
+ }
+
+ /// What one frame showed of a sample row past a limit end.
+ private enum PastLimitRow: UInt8, Sendable {
+ /// Not both samples in view, or in view without matching: adds nothing.
+ case none
+ /// Depth found something nearer, or the surface where the space ends, at either sample.
+ case blocked
+ /// Both samples in view and, with depth, matched.
+ case matches
+ }
+
+ /// One frame's view of the rows of one cell past a limit end (`pastLimitCells`).
+ private struct PastLimitCellView: Sendable {
+ var side: WalkSide
+ var cell: Int
+ var rows: [PastLimitRow]
+ }
+
+ /// A frame's view of the ground past each limit end: the part of `recordPastLimits` that reads
+ /// no recorded state. Cells it shows nothing of are left out.
+ private func pastLimitViews(from camera: CameraFrame, depth: DepthImage?, reads: FarSurfaceReadLog? = nil) -> [PastLimitCellView] {
+ guard wall.wallPoint(camera.position).out > 0 else { return [] }
+ var found: [PastLimitCellView] = []
for side in limitEnds {
guard let end = end(side) else { continue }
let piece = endPiece(side, end: end)
@@ -869,34 +932,55 @@ public struct CoverageMap: Sendable {
let pixel = camera.pixel(of: target),
camera.contains(pixel: pixel, margin: config.imageMargin) else { return nil }
guard let depth else { return .matches }
- return depthEvidence(target, camera: camera, depth: depth)
+ return depthEvidence(target, camera: camera, depth: depth, reads: reads)
}
for cell in first...last {
let range = pastCellRange(side, cell, end: end)
let width = range.upperBound - range.lowerBound
let alongs = [range.lowerBound + width * 0.25, range.lowerBound + width * 0.75]
- var rows = pastLimitCells[side]?[cell] ?? Array(repeating: [], count: 2 * depths.count)
- var added = false
+ var rows = [PastLimitRow](repeating: .none, count: 2 * depths.count)
for row in rows.indices {
let out = row < depths.count ? depths[row] : -depths[row - depths.count]
- let found = alongs.map { evidence($0, out) }
- guard found.allSatisfy({ $0 != nil }) else { continue }
+ let samples = alongs.map { evidence($0, out) }
+ guard samples.allSatisfy({ $0 != nil }) else { continue }
// Past a limit end nothing is asked to be looked past, so the surface where
// the space ends blocks a row just as anything nearer does.
- if depth != nil, found.contains(where: { $0 == .nearer || $0 == .pastSpace }) {
- if Self.dropUnverified(&rows[row]) { added = true }
- if rows[row].count < 2 { pastLimitHidden[side, default: [:]][cell, default: []].insert(row) }
- continue
+ if depth != nil, samples.contains(where: { $0 == .nearer || $0 == .pastSpace }) {
+ rows[row] = .blocked
+ } else if samples.allSatisfy({ $0 == .matches }) {
+ rows[row] = .matches
}
- guard found.allSatisfy({ $0 == .matches }),
- depth != nil || !(pastLimitHidden[side]?[cell]?.contains(row) ?? false) else { continue }
- if add(&rows[row], camera.position, verified: depth != nil) { added = true }
}
- if added {
- pastLimitCells[side, default: [:]][cell] = rows
- changed = true
+ if rows.contains(where: { $0 != .none }) { found.append(PastLimitCellView(side: side, cell: cell, rows: rows)) }
+ }
+ }
+ return found
+ }
+
+ /// `recordPastLimits(from:depth:)` given the frame's views, from `position`; `depthChecked`
+ /// when the frame had depth.
+ @discardableResult
+ private mutating func recordPastLimits(_ found: [PastLimitCellView], from position: SIMD3, depthChecked: Bool) -> Bool {
+ var changed = false
+ for view in found {
+ var rows = pastLimitCells[view.side]?[view.cell] ?? Array(repeating: [], count: view.rows.count)
+ var added = false
+ for (row, kind) in view.rows.enumerated() {
+ switch kind {
+ case .none:
+ continue
+ case .blocked:
+ if Self.dropUnverified(&rows[row]) { added = true }
+ if rows[row].count < 2 { pastLimitHidden[view.side, default: [:]][view.cell, default: []].insert(row) }
+ case .matches:
+ guard depthChecked || !(pastLimitHidden[view.side]?[view.cell]?.contains(row) ?? false) else { continue }
+ if add(&rows[row], position, verified: depthChecked) { added = true }
}
}
+ if added {
+ pastLimitCells[view.side, default: [:]][view.cell] = rows
+ changed = true
+ }
}
return changed
}
@@ -917,7 +1001,11 @@ public struct CoverageMap: Sendable {
pastLimitCells = [:]
pastLimitHidden = [:]
guard !limitEnds.isEmpty else { return }
- for index in captureOrder { recordPastLimits(from: observedCameras[index], depth: observedDepths[index]) }
+ prepareRebuildViews()
+ for index in captureOrder {
+ let views = rebuildPastLimitViews(index)
+ recordPastLimits(views, from: observedCameras[index].position, depthChecked: observedDepths[index] != nil)
+ }
}
// MARK: Where the space ends
@@ -1387,10 +1475,13 @@ public struct CoverageMap: Sendable {
depthCells = [:]
depthHidden = [:]
depthPastSpace = [:]
+ prepareRebuildViews()
for index in captureOrder {
- let camera = observedCameras[index], depth = observedDepths[index]
- recordDepth(from: camera, depth: depth)
- recordSightings(visibleCells(from: camera, depth: depth), from: camera.position, depthChecked: depth != nil)
+ let position = observedCameras[index].position, depthChecked = observedDepths[index] != nil
+ let depthRows = rebuildDepthRowViews(index)
+ recordDepth(depthRows, from: position, depthChecked: depthChecked)
+ let bands = rebuildBandViews(index)
+ recordSightings(bands, from: position, depthChecked: depthChecked)
}
replayPastLimits()
revision += 1
@@ -1473,6 +1564,167 @@ public struct CoverageMap: Sendable {
public var coveredCount: Int {
cells.values.reduce(0) { $0 + $1.filter { $0.value.covered && !withdrawnCells.contains($0.key) }.count }
}
+
+ // MARK: Rebuild view cache
+
+ /// A rebuild (`replayObservedCameras`, `replayPastLimits`) replays every kept frame, and most
+ /// of its time goes to working out what each frame shows. That part reads no recorded
+ /// sightings, so it is kept per frame and used again while nothing it was worked out from has
+ /// changed: the frame's camera and depth, the wall chain with its ground, `heightError`, the
+ /// ends and which of them are limits (`RebuildViewBasis`), and the far-surface lookup at every
+ /// cell the frame's depth readings consulted (`FarSurfaceReads`). `config` is fixed. Any
+ /// change to the basis (a wall update, a corner, an anchor correction, a measured ground, an
+ /// end) drops every kept view. The merge, which reads and writes the recorded sightings in
+ /// capture order, runs in full every time, so a rebuild records what it would without the
+ /// cache. Only a far-surface rebuild can reuse views: every other rebuild changes the basis.
+ private var rebuildViews = RebuildViewCache()
+
+ /// Test and measurement seam: false makes every rebuild work out every view again, so a test
+ /// can hold a cached map against a full rebuild. Nothing in the app sets it.
+ var reusesRebuildViews = true
+
+ /// How many per-frame views rebuilds took from the cache and worked out, for tests and the
+ /// probe. A frame has up to three: the bands, the ground depth rows, the ground past limits.
+ private(set) var rebuildViewCounts: (reused: Int, computed: Int) = (0, 0)
+
+ private struct RebuildViewBasis: Equatable, Sendable {
+ var wall: WallFrame
+ var leftEnd: Float?
+ var rightEnd: Float?
+ var limitEnds: Set
+ var heightError: Float
+ }
+
+ private struct RebuildViewCache: Sendable {
+ var basis: RebuildViewBasis?
+ /// By index into `observedCameras`.
+ var frames: [Int: FrameViews] = [:]
+ }
+
+ /// The kept views of one frame, with the camera and depth they were worked out from.
+ private struct FrameViews: Sendable {
+ var camera: CameraFrame
+ var depth: DepthImage?
+ var bands: KeptViews<[Sighting]>?
+ var depthRows: KeptViews<[DepthRowView]>?
+ var pastLimits: KeptViews<[PastLimitCellView]>?
+ }
+
+ private struct KeptViews: Sendable {
+ var value: Value
+ var reads: FarSurfaceReads
+ }
+
+ /// The far-surface lookup's cells a frame's views consulted, and what each held there (absent
+ /// for no surface). The views hold while the lookup holds the same at every one of them.
+ private struct FarSurfaceReads: Sendable {
+ var cells: Set
+ var values: [Int: Float]
+
+ func agree(with lookup: [Int: Float]) -> Bool {
+ cells.allSatisfy { lookup[$0] == values[$0] }
+ }
+ }
+
+ /// Collects `FarSurfaceReads` while views are worked out (`endsSpace`).
+ private final class FarSurfaceReadLog {
+ private(set) var cells: Set = []
+ private(set) var values: [Int: Float] = [:]
+
+ func note(_ cell: Int, _ value: Float?) {
+ guard cells.insert(cell).inserted else { return }
+ values[cell] = value
+ }
+
+ var reads: FarSurfaceReads { FarSurfaceReads(cells: cells, values: values) }
+ }
+
+ /// Drops every kept view unless the basis they were worked out on is the current one.
+ private mutating func prepareRebuildViews() {
+ let basis = RebuildViewBasis(wall: wall, leftEnd: leftEnd, rightEnd: rightEnd, limitEnds: limitEnds, heightError: heightError)
+ guard !reusesRebuildViews || rebuildViews.basis != basis else { return }
+ rebuildViews = RebuildViewCache(basis: reusesRebuildViews ? basis : nil)
+ }
+
+ private mutating func rebuildBandViews(_ index: Int) -> [Sighting] {
+ if let kept = keptViews(index, \.bands) { return kept }
+ let log = reusesRebuildViews ? FarSurfaceReadLog() : nil
+ let views = visibleCells(from: observedCameras[index], depth: observedDepths[index], reads: log)
+ keep(views, reads: log, at: index, \.bands)
+ return views
+ }
+
+ private mutating func rebuildDepthRowViews(_ index: Int) -> [DepthRowView] {
+ if let kept = keptViews(index, \.depthRows) { return kept }
+ let log = reusesRebuildViews ? FarSurfaceReadLog() : nil
+ let views = depthRowViews(from: observedCameras[index], depth: observedDepths[index], reads: log)
+ keep(views, reads: log, at: index, \.depthRows)
+ return views
+ }
+
+ private mutating func rebuildPastLimitViews(_ index: Int) -> [PastLimitCellView] {
+ if let kept = keptViews(index, \.pastLimits) { return kept }
+ let log = reusesRebuildViews ? FarSurfaceReadLog() : nil
+ let views = pastLimitViews(from: observedCameras[index], depth: observedDepths[index], reads: log)
+ keep(views, reads: log, at: index, \.pastLimits)
+ return views
+ }
+
+ /// The kept views of the frame at `index`, when they were worked out from its current camera
+ /// and depth and the far-surface lookup agrees at every cell they consulted.
+ private mutating func keptViews(_ index: Int, _ part: KeyPath?>) -> Value? {
+ guard reusesRebuildViews, let frame = rebuildViews.frames[index],
+ frame.camera == observedCameras[index], frame.depth == observedDepths[index],
+ let kept = frame[keyPath: part], kept.reads.agree(with: farSurfaceByCell) else { return nil }
+ rebuildViewCounts.reused += 1
+ return kept.value
+ }
+
+ private mutating func keep(
+ _ value: Value, reads log: FarSurfaceReadLog?, at index: Int, _ part: WritableKeyPath?>
+ ) {
+ rebuildViewCounts.computed += 1
+ guard let log else { return }
+ let camera = observedCameras[index], depth = observedDepths[index]
+ var frame = rebuildViews.frames[index].flatMap { $0.camera == camera && $0.depth == depth ? $0 : nil }
+ ?? FrameViews(camera: camera, depth: depth)
+ frame[keyPath: part] = KeptViews(value: value, reads: log.reads)
+ rebuildViews.frames[index] = frame
+ }
+
+ /// Test seam: the stored fields in which this map's state differs from `other`'s, leaving out
+ /// the rebuild view cache, its counts and `reusesRebuildViews`. Empty when every reader of
+ /// either map, now or after any later change, gets the same answers.
+ func stateDifferences(from other: CoverageMap) -> [String] {
+ var differing: [String] = []
+ func compare(_ name: String, _ a: T, _ b: T) {
+ if a != b { differing.append(name) }
+ }
+ compare("wall", wall, other.wall)
+ compare("config", config, other.config)
+ compare("leftEnd", leftEnd, other.leftEnd)
+ compare("rightEnd", rightEnd, other.rightEnd)
+ compare("limitEnds", limitEnds, other.limitEnds)
+ compare("revision", revision, other.revision)
+ compare("heightError", heightError, other.heightError)
+ compare("cells", cells, other.cells)
+ compare("depthCells", depthCells, other.depthCells)
+ compare("pastLimitCells", pastLimitCells, other.pastLimitCells)
+ compare("pendingShift", pendingShift, other.pendingShift)
+ compare("observedCameras", observedCameras, other.observedCameras)
+ compare("observedDepths", observedDepths, other.observedDepths)
+ compare("overheadCameras", overheadCameras, other.overheadCameras)
+ compare("observedTimes", observedTimes, other.observedTimes)
+ compare("observedSegments", observedSegments, other.observedSegments)
+ compare("pathSegment", pathSegment, other.pathSegment)
+ compare("depthHidden", depthHidden, other.depthHidden)
+ compare("depthPastSpace", depthPastSpace, other.depthPastSpace)
+ compare("pastLimitHidden", pastLimitHidden, other.pastLimitHidden)
+ compare("farSurfaceByCell", farSurfaceByCell, other.farSurfaceByCell)
+ compare("farSurface", farSurface, other.farSurface)
+ compare("withdrawnCells", withdrawnCells, other.withdrawnCells)
+ return differing
+ }
}
/// A stretch of wall (s, meters) and how far the view of it reached, meters: out from the wall
diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Guidance/CoachingDebouncer.swift b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/CoachingDebouncer.swift
index 5bc135fd4..eac8462ac 100644
--- a/ios/HouseScanKit/Sources/HouseScanKit/Guidance/CoachingDebouncer.swift
+++ b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/CoachingDebouncer.swift
@@ -17,8 +17,9 @@ import Foundation
/// - Moving fast, turning fast and blur each show after their own spell of frames that have
/// them, and clear a moment after the last such frame. A spell that ends before it shows says
/// nothing.
-/// - Walking too fast is not raised while the homeowner is asked to stand and aim (`aiming`):
-/// the motion there is turning the phone, and "walk slower" is the wrong advice.
+/// - Walking too fast is not raised, or counted toward a spell, while the homeowner is asked to
+/// stand and aim (`aiming`): the motion there is turning the phone, and "walk slower" is the
+/// wrong advice.
///
/// Every threshold is a guess to try on a phone at night, not measured.
public struct CoachingDebouncer: Sendable {
@@ -118,6 +119,9 @@ public struct CoachingDebouncer: Sendable {
lastTime = time
updateDarkness(time: time, meanLuma: meanLuma)
updateMotion(time: time, skip: skip)
+ // Walking speed isn't coached while aiming, so a spell there mustn't build up either: it
+ // would say "Slow down" on the first frames of the next walking step, however calm.
+ if aiming { spells[.movingFast] = nil }
if darkSince != nil { return persistentDark == .showing ? .persistentlyDark : .tooDark }
if isShown(.turningFast, at: time) { return .turningFast }
if !aiming, isShown(.movingFast, at: time) { return .movingFast }
diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GapPlanner.swift b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GapPlanner.swift
index 66df57883..c90e98838 100644
--- a/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GapPlanner.swift
+++ b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GapPlanner.swift
@@ -175,9 +175,7 @@ public struct GapPlanner: Sendable {
case .overhead(let height): spans = reaching(Self.exported(coverage.overheadSpans(), "overhead", coverage), height)
case .wallUp(let height): spans = reaching(Self.exported(coverage.wallSeenSpans(), "wall", coverage), height)
}
- let feet = { (meters: Float) in SceneExport.round4(Double(meters) * SceneUnits.feetPerMeter) }
- let requested = gap.requestedSpanFt ?? feet(gap.span.lowerBound)...feet(gap.span.upperBound)
- return Self.fraction(of: requested, coveredBy: spans)
+ return Self.fraction(of: gap.requestedSpanInFeet, coveredBy: spans)
}
/// A server request is met only over its whole span: the server settles it only when observed
@@ -283,20 +281,27 @@ public struct GapPlanner: Sendable {
let high = requested.upperBound
guard high > low else { return 0 }
let eps = 1e-9
- let tolerance = 0.01
var cursor = low
var missing = 0.0
let written = spans.compactMap { SceneExport.spanInward($0) }.map { ($0[0], $0[1]) }
for (a, b) in written.sorted(by: { $0 < $1 }) {
if b <= cursor + eps { continue }
if a >= high - eps { break }
- if a > cursor + eps, min(a, high) - cursor >= tolerance { missing += min(a, high) - cursor }
+ if a > cursor + eps, countsAsMissing(from: cursor, to: min(a, high)) { missing += min(a, high) - cursor }
cursor = max(cursor, b)
}
- if cursor < high - eps, high - cursor >= tolerance { missing += high - cursor }
+ if cursor < high - eps, countsAsMissing(from: cursor, to: high) { missing += high - cursor }
return max(0, 1 - missing / (high - low))
}
+ /// Whether an unseen stretch from `start` to `end`, feet, counts as missing rather than as
+ /// rounding: at least the server's COVERAGE_TOLERANCE_FT, 0.01 ft (server/scene.py `missing`
+ /// at t3/server 930e8e5). Only progress applies it, against the coverage actually seen; the
+ /// same-view rule (`GapPlan.asksForSameView`) compares requests exactly.
+ static func countsAsMissing(from start: Double, to end: Double) -> Bool {
+ end - start >= 0.01
+ }
+
private func distanceToMeter(_ run: ClosedRange) -> Float {
run.contains(0) ? 0 : min(abs(run.lowerBound), abs(run.upperBound))
}
@@ -419,40 +424,62 @@ extension GapPlanner {
}
extension GapPlan {
- /// How far a request's span or reach may move and still ask for the same view: 0.1 ft
- /// (0.03 m), meters. Numerical jitter from the server working the request out again is a few
- /// hundredths of a foot; a guess, not measured.
- public static let sameViewTolerance: Float = 0.03048
-
- /// Whether this request asks for no view beyond `other`'s, allowing for the server working it
- /// out again from the next upload: the same band and kind of need, a reach no more than
- /// `sameViewTolerance` past the other's, and a span inside the other's give or take the same
- /// tolerance. After "I can't get there" the skipped stretch goes to review and the spot or its
- /// error margins can move, so the same view comes back as, say, 2.41...7.9 ft instead of
- /// 2.4...7.9 ft. A request that asks for more is a new view, even when it mostly overlaps: a
- /// span of 2...9 ft after 2...8 ft, or ground out to 5.10 ft after 4.83 ft (issue #39). A met
- /// past_end request moves its end on 2 m, so the next one asks for new ground and is a
+ /// The span progress measures, feet: `requestedSpanFt` as the server sent it, or for a request
+ /// built in meters, `span` at the export's four decimals.
+ public var requestedSpanInFeet: ClosedRange {
+ if let requestedSpanFt { return requestedSpanFt }
+ let feet = { (meters: Float) in SceneExport.round4(Double(meters) * SceneUnits.feetPerMeter) }
+ return feet(span.lowerBound)...feet(span.upperBound)
+ }
+
+ /// The reach progress requires, feet: `requestedOutFt` as the server sent it, or the need's
+ /// meters converted, as `GapPlanner.progress` compares it. Nil for a need with no reach: cells,
+ /// or an overhead request that any recorded view meets.
+ var requestedReachInFeet: Double? {
+ let meters: Float?
+ switch need {
+ case .cells: return nil
+ case .groundOut(let out), .walkOut(let out), .wallUp(let out): meters = out
+ case .overhead(let height): meters = height
+ }
+ guard let meters else { return nil }
+ return requestedOutFt ?? Double(meters) * SceneUnits.feetPerMeter
+ }
+
+ /// Whether this request asks for no evidence beyond what `other` asks for: the same band and
+ /// kind of need, a reach no higher than the other's, and a span inside the other's, all
+ /// compared exactly in the requested feet progress measures. Rounding tolerance belongs to
+ /// progress alone, against the coverage actually seen: allowing it here too stacked a second
+ /// allowance on one already used, so 2...8.0175 ft after 2...8.0085 ft (met by coverage to
+ /// 8.0 ft) was dropped though progress still found 0.0175 ft missing. The server lists only
+ /// what its own reading of the uploaded scene still lacks, so an exactly contained request
+ /// asks for nothing new. An equal or contained request is the same view:
+ /// after "I can't get there" the skipped stretch goes to review, and the next answer can
+ /// come back as 2.41...7.9 ft after 2.4...7.9 ft. One that asks for more is a new view, even
+ /// when it mostly overlaps: 2...8.09 ft after 2...8 ft, or ground out to 4.833335 ft after
+ /// 4.833334 ft (issue #39, #49). The 0.1 ft allowance this replaced hid requests that
+ /// progress still called missing, and after a skip the result marked them not capturable.
+ /// A met past_end request moves its end on 2 m, so the next one asks for new ground and is a
/// different view.
public func asksForSameView(as other: GapPlan) -> Bool {
- let tolerance = Self.sameViewTolerance
- guard band == other.band, need.asksNoMore(than: other.need, within: tolerance) else { return false }
- return span.lowerBound >= other.span.lowerBound - tolerance && span.upperBound <= other.span.upperBound + tolerance
+ guard band == other.band, need.isSameKind(as: other.need) else { return false }
+ switch (requestedReachInFeet, other.requestedReachInFeet) {
+ case (nil, _): break
+ case (_?, nil): return false
+ case let (mine?, theirs?): guard mine <= theirs else { return false }
+ }
+ let mine = requestedSpanInFeet
+ let theirs = other.requestedSpanInFeet
+ return mine.lowerBound >= theirs.lowerBound && mine.upperBound <= theirs.upperBound
}
}
extension GapPlan.Need {
- /// The same kind of need, with any reach no more than `tolerance` meters past the other's. An
- /// overhead need without a height (any recorded view) asks no more than one with a height.
- func asksNoMore(than other: GapPlan.Need, within tolerance: Float) -> Bool {
+ /// The same kind of need, whatever its reach.
+ func isSameKind(as other: GapPlan.Need) -> Bool {
switch (self, other) {
- case (.cells, .cells):
+ case (.cells, .cells), (.groundOut, .groundOut), (.walkOut, .walkOut), (.wallUp, .wallUp), (.overhead, .overhead):
return true
- case let (.groundOut(a), .groundOut(b)), let (.walkOut(a), .walkOut(b)), let (.wallUp(a), .wallUp(b)):
- return a <= b + tolerance
- case let (.overhead(a), .overhead(b)):
- guard let a else { return true }
- guard let b else { return false }
- return a <= b + tolerance
default:
return false
}
diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GuidancePlanner.swift b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GuidancePlanner.swift
index 756a09391..3437d5458 100644
--- a/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GuidancePlanner.swift
+++ b/ios/HouseScanKit/Sources/HouseScanKit/Guidance/GuidancePlanner.swift
@@ -103,8 +103,8 @@ public struct GuidancePlanner: Sendable {
private var lastFraction: Double = 0
private var lastProgressAt: Double = 0
/// Aim stretches that stalled. The requests chosen from the camera (the ground by the meter
- /// and a lagging band) leave them out until `reset()`; `firstHole` still asks for them once
- /// both ends are marked.
+ /// and a lagging band) leave them out for the rest of the scan, until `reset()`; `firstHole`
+ /// still asks for them once both ends are marked.
private var deferred: [DeferredStretch] = []
private struct DeferredStretch: Sendable {
@@ -116,8 +116,17 @@ public struct GuidancePlanner: Sendable {
self.config = config
}
- /// Forgets the current task and the deferred stretches. The engine calls it when an action
- /// settled the task ("Can't get there", an answered end question).
+ /// Drops the current task, so the next update chooses afresh, and keeps the stretches already
+ /// deferred. The engine calls it when an action settled the task ("Can't get there", an
+ /// answered end or overhead question, a followed corner, a refused finish). Clearing the
+ /// deferred stretches there too asked again, with a fresh stall clock, for a request that had
+ /// just stalled, instead of going on with the walk (#56).
+ public mutating func settleCurrentTask() {
+ current = nil
+ }
+
+ /// Forgets the current task and the deferred stretches. For a new scan, and for a spatial
+ /// reset, whose new world frame makes the old stretches meaningless.
public mutating func reset() {
current = nil
deferred = []
diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Overlay/OpenCameraAim.swift b/ios/HouseScanKit/Sources/HouseScanKit/Overlay/OpenCameraAim.swift
new file mode 100644
index 000000000..c2f5f447a
--- /dev/null
+++ b/ios/HouseScanKit/Sources/HouseScanKit/Overlay/OpenCameraAim.swift
@@ -0,0 +1,25 @@
+import CoreGraphics
+
+/// Where the find-meter reticle sits, and so where "This is my meter" marks, when the
+/// instruction card folds at the largest text sizes. The middle of the screen is then under the
+/// card, so the aim moves to the middle of the open camera between the card and the button.
+public enum OpenCameraAim {
+ /// The aim point in the camera view's coordinates (the full screen, the window's global
+ /// space), or nil for the middle of the screen. Nil when the card doesn't fold, before the
+ /// chrome has measured its open camera, or when none of it is on screen. While the chrome
+ /// scrolls, the point follows the part of the open camera that is on screen.
+ /// - Parameters:
+ /// - folds: whether the card folds at this text size (`CameraChrome.aims` at an
+ /// accessibility size).
+ /// - window: the open camera between the card and the button, as the chrome measured it.
+ /// - camera: the camera view's size.
+ public static func point(folds: Bool, window: CGRect?, camera: CGSize) -> CGPoint? {
+ guard folds, let window else { return nil }
+ let shown = window.intersection(CGRect(origin: .zero, size: camera))
+ // Only the height matters. The chrome measures the open camera from a Spacer, which
+ // can be zero points wide; requiring a width put the reticle back under the card at AX5
+ // (run 36877327250).
+ guard !shown.isNull, shown.height > 0 else { return nil }
+ return CGPoint(x: shown.midX, y: shown.midY)
+ }
+}
diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Scene/ResultBinding.swift b/ios/HouseScanKit/Sources/HouseScanKit/Scene/ResultBinding.swift
new file mode 100644
index 000000000..869c59000
--- /dev/null
+++ b/ios/HouseScanKit/Sources/HouseScanKit/Scene/ResultBinding.swift
@@ -0,0 +1,58 @@
+import Foundation
+
+/// Ties a placement answer to the scene this phone sent. The server writes the SHA-256 of the
+/// scene bytes it parsed into `stats.input_sha256`: for a bare scene.json that is the request body
+/// itself (server/api.py `_parse` passes the body to `parse_scene`, which hashes it). The result
+/// schema requires the field as 64 lowercase hex digits (server/schemas/result.schema.json).
+///
+/// An answer is accepted only when it carries that hash and the hash is of the bytes sent. An
+/// answer for another scene, or one whose hash is missing or unreadable, is never shown. This is
+/// a defence against a mismatched response; nothing shows the current server sending one.
+public enum ResultBinding {
+ public enum Refusal: Error, Equatable, CustomStringConvertible {
+ /// The answer has no `stats.input_sha256` string, or isn't JSON this check can read.
+ case noInputHash
+ /// `stats.input_sha256` isn't 64 lowercase hex digits.
+ case malformedInputHash(String)
+ /// The answer names another scene. Both hashes are lowercase hex.
+ case mismatch(submitted: String, declared: String)
+
+ /// Goes to the device log, so it names what went wrong but never a hash: a scene's hash
+ /// fingerprints a homeowner's scan. The values stay in the cases for callers and tests.
+ public var description: String {
+ switch self {
+ case .noInputHash: "answer has no readable stats.input_sha256"
+ case .malformedInputHash(let value): "answer's stats.input_sha256 is malformed (\(value.utf8.count) bytes)"
+ case .mismatch: "answer's stats.input_sha256 is not the hash of the scene sent"
+ }
+ }
+ }
+
+ /// Throws a `Refusal` unless `answer` declares `stats.input_sha256` as the SHA-256 of `scene`.
+ /// Anything this can't read refuses the answer: a check that skipped unreadable metadata would
+ /// let a mismatched answer through whenever the general decoder is more lenient than this one.
+ public static func check(answer: Data, submittedScene scene: Data) throws {
+ let declared: String
+ do {
+ declared = try JSONDecoder().decode(Answer.self, from: answer).stats.inputSHA256
+ } catch {
+ throw Refusal.noInputHash
+ }
+ guard isSHA256Hex(declared) else { throw Refusal.malformedInputHash(declared) }
+ let submitted = PacketFiles.sha256(scene)
+ guard declared == submitted else { throw Refusal.mismatch(submitted: submitted, declared: declared) }
+ }
+
+ /// The schema's form for the field, `^[0-9a-f]{64}$`.
+ static func isSHA256Hex(_ text: String) -> Bool {
+ text.utf8.count == 64 && text.utf8.allSatisfy { (0x30...0x39).contains($0) || (0x61...0x66).contains($0) }
+ }
+
+ private struct Answer: Decodable {
+ struct Stats: Decodable {
+ var inputSHA256: String
+ enum CodingKeys: String, CodingKey { case inputSHA256 = "input_sha256" }
+ }
+ var stats: Stats
+ }
+}
diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Scene/UploadPackaging.swift b/ios/HouseScanKit/Sources/HouseScanKit/Scene/UploadPackaging.swift
new file mode 100644
index 000000000..c7f75c25f
--- /dev/null
+++ b/ios/HouseScanKit/Sources/HouseScanKit/Scene/UploadPackaging.swift
@@ -0,0 +1,94 @@
+import Foundation
+
+/// What an upload sends, built from one captured basis. The engine captures the scene (without
+/// its mesh measurements), the wall in world meters, the LiDAR mesh and the packet's inputs in one
+/// main-actor turn; the mesh is then measured against that captured wall and span while the main
+/// actor is free, and the scene is serialized from the capture and handed, as the same bytes, to
+/// the captured packet.
+///
+/// Before this, the mesh was measured against the wall as it was before the wait, and the scene
+/// and packet were built from the engine's live wall after it. A ground refinement or anchor
+/// correction landing during the measurement put clearances measured against the old wall into a
+/// scene describing the new one (#191 review of bd6b9331). Now a change during the measurement
+/// can't reach this upload's scene or packet; the engine's ground-freshness rule decides whether
+/// the upload stands (`GroundFreshness`), and a superseded upload serializes nothing.
+public struct UploadPackaging: Sendable {
+ /// The scene to send, without its mesh measurements, which `package` fills in.
+ public var scene: SceneInput
+ /// The wall in world meters, as captured. The mesh is in world meters too, so it is measured
+ /// against this, not `scene.wall`, whose ground has been moved to height zero.
+ public var worldWall: WallFrame
+ /// The LiDAR mesh ARKit had built at the capture; nil without LiDAR or before any mesh.
+ public var mesh: TriangleMesh?
+ /// The capture packet's inputs, read in the same turn; nil when there is none to write. Its
+ /// scene is attached by `package`.
+ public var packet: Packet?
+
+ public init(scene: SceneInput, worldWall: WallFrame, mesh: TriangleMesh?, packet: Packet?) {
+ self.scene = scene
+ self.worldWall = worldWall
+ self.mesh = mesh
+ self.packet = packet
+ }
+
+ /// The stretch the mesh is measured over: the scene's own exported stretch.
+ public var span: ClosedRange { scene.baselineS }
+
+ /// The mesh's facing gaps and headroom along the wall (`TriangleMesh.facingSpans`,
+ /// `overheadSpans`).
+ public struct MeshMeasurement: Sendable, Equatable {
+ public var facing: [ObservedSpan]
+ public var overheads: [ObservedSpan]
+
+ public init(facing: [ObservedSpan] = [], overheads: [ObservedSpan] = []) {
+ self.facing = facing
+ self.overheads = overheads
+ }
+ }
+
+ /// Measures `mesh` against a wall over a span. Injected so a test can hold the measurement
+ /// at the point where the engine's main actor is free.
+ public typealias Measure = @Sendable (_ mesh: TriangleMesh, _ wall: WallFrame, _ span: ClosedRange) async -> MeshMeasurement
+
+ /// The real measurement, off the caller's actor: ray casts over a whole mesh take a while.
+ public static var measureOffActor: Measure {
+ { mesh, wall, span in
+ await Task.detached(priority: .userInitiated) {
+ MeshMeasurement(facing: mesh.facingSpans(wall: wall, over: span), overheads: mesh.overheadSpans(wall: wall, over: span))
+ }.value
+ }
+ }
+
+ public struct Packaged: Sendable {
+ /// The serialized scene.
+ public var scene: Data
+ public var measurement: MeshMeasurement
+ /// The captured packet, with `scene` attached.
+ public var packet: Packet?
+ }
+
+ /// Measures the captured mesh against the captured wall and span, then serializes the
+ /// captured scene with those measurements and attaches the same bytes to the captured packet.
+ /// `isCurrent` is asked once the measurement is back, on the main actor: false means the
+ /// upload was superseded (cancelled, withdrawn for a ground change, or the scan reset), and
+ /// nothing is serialized or returned, so the caller schedules no bundle and no submit.
+ @MainActor
+ public func package(
+ measure: Measure = Self.measureOffActor,
+ attachScene: (inout Packet, Data) -> Void,
+ isCurrent: () -> Bool
+ ) async throws -> Packaged? {
+ var measurement = MeshMeasurement()
+ if let mesh {
+ measurement = await measure(mesh, worldWall, span)
+ }
+ guard isCurrent() else { return nil }
+ var input = scene
+ input.meshFacing = measurement.facing
+ input.meshOverheads = measurement.overheads
+ let data = try SceneExport.jsonData(input)
+ var packet = packet
+ if packet != nil { attachScene(&packet!, data) }
+ return Packaged(scene: data, measurement: measurement, packet: packet)
+ }
+}
diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Upload/GroundFreshness.swift b/ios/HouseScanKit/Sources/HouseScanKit/Upload/GroundFreshness.swift
index 39667b8cf..4ffe7eab3 100644
--- a/ios/HouseScanKit/Sources/HouseScanKit/Upload/GroundFreshness.swift
+++ b/ios/HouseScanKit/Sources/HouseScanKit/Upload/GroundFreshness.swift
@@ -5,7 +5,9 @@
/// clearance against the ground the scan sent, so an answer drawn on a moved ground shows a
/// spot nobody checked. A ground change therefore takes the answer down from the spot check,
/// the result, its 3D preview and the camera view, and sends the scan again, built from the
-/// wall as it is now. Only the server's answer to that scan comes back on screen.
+/// wall as it is now. Only the server's answer to that scan comes back on screen. The same
+/// holds once a first upload has packaged its scene, since its answer is already fixed to the
+/// old ground.
///
/// An anchor correction is different: it moves the wall, its corners, the kept cameras, the
/// marks and the ground as one body, and the answer is in wall terms (spans along the wall and
@@ -14,6 +16,10 @@
/// One resend per withdrawal. A ground that changes again before a new answer is shown means
/// the phone is still settling, and sending again in a loop would only keep the homeowner
/// waiting: the upload screen shows a failure with "Try again" instead.
+///
+/// An upload that already failed or was refused has no answer up and none coming, so a ground
+/// change leaves its screen alone (`Screen.stopped`); the homeowner's own retry or review sends
+/// the scan as it is then, still within the same one resend.
public struct GroundFreshness: Equatable, Sendable {
/// What changed the wall.
public enum Change: Equatable, Sendable, CaseIterable {
@@ -30,6 +36,10 @@ public struct GroundFreshness: Equatable, Sendable {
case noAnswer
/// The upload screen: the scan is on its way, or its answer is being readied.
case sending
+ /// The upload screen showing a failure or a refusal: no answer is up and none is coming.
+ /// The homeowner's own choice ("Try again", or back to the review after a refusal) sends
+ /// the scan as it is then, so a ground change leaves that screen as it is.
+ case stopped
/// The spot check, which draws the answer's spot on a kept photo.
case spotCheck
/// The result screen and its 3D preview.
@@ -54,15 +64,19 @@ public struct GroundFreshness: Equatable, Sendable {
public init() {}
/// The decision for one change on one screen, recording a withdrawal.
- public mutating func after(_ change: Change, on screen: Screen) -> Action {
+ /// `scenePackaged` means the upload's ground is fixed, even while its answer is readied.
+ /// Before packaging, a first upload still reads the current ground; after it, the answer
+ /// needs the same withdrawal and single resend as an answer already on screen.
+ public mutating func after(_ change: Change, on screen: Screen, scenePackaged: Bool = false) -> Action {
guard change == .ground else { return .keep }
switch screen {
- case .noAnswer:
+ case .noAnswer, .stopped:
return .keep
case .sending:
- // A first upload is built from the scan at the moment it is packaged; only an
- // upload that is itself a resend has already used its one try.
- return awaitingNewAnswer ? .fail : .keep
+ if awaitingNewAnswer { return .fail }
+ guard scenePackaged else { return .keep }
+ awaitingNewAnswer = true
+ return .sendAgain
case .spotCheck, .result, .resultInCamera:
if awaitingNewAnswer { return .fail }
awaitingNewAnswer = true
diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/CoachingDebouncerTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/CoachingDebouncerTests.swift
index 24189fc0f..d383f01dd 100644
--- a/ios/HouseScanKit/Tests/HouseScanKitTests/CoachingDebouncerTests.swift
+++ b/ios/HouseScanKit/Tests/HouseScanKitTests/CoachingDebouncerTests.swift
@@ -103,6 +103,19 @@ import Testing
#expect(Self.run(walking).last! == .movingFast)
}
+ /// Moving fast while aiming builds no spell, so the next walking step starts clean: a calm
+ /// frame says nothing, and walking fast again needs its own 0.7 s before "Slow down".
+ @Test func movingFastWhileAimingDoesntCarryIntoTheNextStep() {
+ var debouncer = CoachingDebouncer()
+ let aiming = Self.times(for: 2).map { Frame(time: $0, skip: .movingFast, aiming: true) }
+ #expect(Self.run(aiming, on: &debouncer).allSatisfy { $0 == nil })
+ #expect(debouncer.update(time: 2, skip: .redundant, meanLuma: 128, aiming: false) == nil)
+ let walking = Self.times(from: 2.1, for: 1).map { Frame(time: $0, skip: .movingFast) }
+ let said = Self.run(walking, on: &debouncer)
+ #expect(said.prefix(7).allSatisfy { $0 == nil })
+ #expect(said.last! == .movingFast)
+ }
+
/// A gap request to walk the stretch far enough out asks for walking, so hurrying along it is
/// still told to slow down. The other requests ask the homeowner to stand and aim.
@Test func aWalkOutRequestStillSaysSlowDown() {
diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/GroundFreshnessTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/GroundFreshnessTests.swift
index fda9ac6cd..26b0f6aed 100644
--- a/ios/HouseScanKit/Tests/HouseScanKitTests/GroundFreshnessTests.swift
+++ b/ios/HouseScanKit/Tests/HouseScanKitTests/GroundFreshnessTests.swift
@@ -57,6 +57,52 @@ import Testing
#expect(!rule.awaitingNewAnswer)
}
+ @Test(arguments: GroundFreshness.Screen.allCases, [false, true])
+ func packagingAndAwaitingMatrix(screen: GroundFreshness.Screen, packaged: Bool) {
+ for awaiting in [false, true] {
+ for change in GroundFreshness.Change.allCases {
+ var rule = awaiting ? Self.afterOneWithdrawal() : GroundFreshness()
+ let expected: GroundFreshness.Action
+ if change == .anchorCorrection || screen == .noAnswer || screen == .stopped {
+ expected = .keep
+ } else if awaiting {
+ expected = .fail
+ } else if screen == .sending && !packaged {
+ expected = .keep
+ } else {
+ expected = .sendAgain
+ }
+ #expect(rule.after(change, on: screen, scenePackaged: packaged) == expected)
+ #expect(rule.awaitingNewAnswer == (awaiting || expected == .sendAgain))
+ }
+ }
+ }
+
+ /// A failed or refused upload keeps its own screen: an offline failure keeps its reason, and
+ /// a refusal keeps "Back to review" rather than offering to resend a refused scan. A resend's
+ /// failure stays protected, so its "Try again" still uses no second resend.
+ @Test(arguments: [false, true])
+ func aStoppedUploadKeepsItsFailureAndItsResendState(packaged: Bool) {
+ var first = GroundFreshness()
+ #expect(first.after(.ground, on: .stopped, scenePackaged: packaged) == .keep)
+ #expect(first == GroundFreshness())
+
+ var resend = Self.afterOneWithdrawal()
+ #expect(resend.after(.ground, on: .stopped, scenePackaged: packaged) == .keep)
+ #expect(resend == Self.afterOneWithdrawal())
+ #expect(resend.after(.ground, on: .sending, scenePackaged: true) == .fail)
+ }
+
+ @Test func aPackagedFirstUploadUsesOneResendUntilAnAnswerIsShown() {
+ var rule = GroundFreshness()
+ #expect(rule.after(.ground, on: .sending, scenePackaged: true) == .sendAgain)
+ #expect(rule.awaitingNewAnswer)
+ #expect(rule.after(.ground, on: .sending, scenePackaged: false) == .fail)
+ #expect(rule.awaitingNewAnswer)
+ rule.answerShown()
+ #expect(rule.after(.ground, on: .sending, scenePackaged: true) == .sendAgain)
+ }
+
@Test(arguments: answerScreens)
func anAnswerShownStartsANewResend(screen: GroundFreshness.Screen) {
var rule = Self.afterOneWithdrawal()
diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/GuidancePlannerTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/GuidancePlannerTests.swift
index 63e1deb99..bfb4da719 100644
--- a/ios/HouseScanKit/Tests/HouseScanKitTests/GuidancePlannerTests.swift
+++ b/ios/HouseScanKit/Tests/HouseScanKitTests/GuidancePlannerTests.swift
@@ -66,16 +66,17 @@ import Testing
#expect(next.switched == .satisfied)
}
- /// "Can't get there" on it marks the ground skipped, as the engine does, and resets the planner
- /// (`ScanEngine.resetGuidanceAfterSkip`): the walk moves on and doesn't ask for it again.
+ /// "Can't get there" on it marks the ground skipped, as the engine does, and settles the
+ /// planner's task (`ScanEngine.resetGuidanceAfterSkip`): the walk moves on and doesn't ask for
+ /// it again.
@Test func cantGetThereOnTheGroundByTheMeterMovesOn() {
var map = CoverageMap(wall: standardWall())
var planner = GuidancePlanner()
#expect(planner.update(coverage: map, camera: Self.homeowner(), time: 0).task == .aimAtGround(s: 0))
map.markSkipped(.ground, -0.5...0.5)
- // Skipped is not covered: without the reset the request holds for its dwell.
+ // Skipped is not covered: without settling the request holds for its dwell.
#expect(planner.update(coverage: map, camera: Self.homeowner(), time: 0.5).task == .aimAtGround(s: 0))
- planner.reset()
+ planner.settleCurrentTask()
#expect(planner.update(coverage: map, camera: Self.homeowner(), time: 0.6).task != .aimAtGround(s: 0))
// Away from the meter nothing lags, and the walk goes left.
#expect(planner.update(coverage: map, camera: Self.homeowner(x: -3), time: 10).task == .walk(.left))
@@ -420,6 +421,36 @@ import Testing
#expect(planner.update(coverage: map, camera: Self.homeowner(), time: 41).task == .aimAtGround(s: 0))
}
+ /// #56: answering something else doesn't bring back a request that stalled. The ground by the
+ /// meter stalls and is deferred (as in the test above); the camera stays near the meter, and
+ /// the left end is marked at s = -0.6 and answered blocked while the right stays open. The
+ /// engine then settles the planner's task (`ScanEngine.resetGuidanceAfterSkip`): the deferred
+ /// ground stays deferred, rather than coming back with a fresh 20 s clock. The same planner
+ /// reset for a new scan (or a spatial reset) forgets it and asks again.
+ @Test func settlingATaskKeepsWhatStalledButANewScanForgetsIt() {
+ var map = CoverageMap(wall: standardWall())
+ var planner = GuidancePlanner()
+ #expect(planner.update(coverage: map, camera: Self.homeowner(), time: 0).task == .aimAtGround(s: 0))
+ map.observe(groundCamera(s: -0.45), trackingNormal: true)
+ map.observe(groundCamera(s: -0.15), trackingNormal: true)
+ #expect(planner.update(coverage: map, camera: Self.homeowner(out: 1), time: 8).task == .aimAtGround(s: 0))
+ let stalled = planner.update(coverage: map, camera: Self.homeowner(), time: 28)
+ #expect(stalled.stalled == .aimAtGround(s: 0))
+ #expect(stalled.task != .aimAtGround(s: 0))
+
+ map.setEnd(.left, at: -0.6)
+ map.setEndIsLimit(.left, true)
+ var newScan = planner
+
+ planner.settleCurrentTask()
+ for time in [28.5, 29.0, 40.0, 60.0] {
+ #expect(planner.update(coverage: map, camera: Self.homeowner(), time: time).task != .aimAtGround(s: 0), "settled at \(time) s")
+ }
+
+ newScan.reset()
+ #expect(newScan.update(coverage: map, camera: Self.homeowner(), time: 28.5).task == .aimAtGround(s: 0))
+ }
+
/// Review of #120: a lagging-band request that stalls in the middle of the window is not asked
/// for again at once. Walking right (the left end marked) with the phone at s = 2, the window
/// is [1.7, 3.0], cells 11 ... 19, and the ground lags the (skipped) wall in all of them: the
diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/OpenCameraAimTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/OpenCameraAimTests.swift
new file mode 100644
index 000000000..45cfb6a72
--- /dev/null
+++ b/ios/HouseScanKit/Tests/HouseScanKitTests/OpenCameraAimTests.swift
@@ -0,0 +1,51 @@
+import CoreGraphics
+import HouseScanKit
+import Testing
+
+/// The folded find-meter aim (`OpenCameraAim`): the middle of the open camera's part on screen,
+/// or nil for the middle of the screen.
+@Suite struct OpenCameraAimTests {
+ /// An iPhone 17's camera view, in points.
+ private static let camera = CGSize(width: 402, height: 874)
+
+ @Test func anUnfoldedCardAimsAtTheMiddleOfTheScreen() {
+ let window = CGRect(x: 0, y: 420, width: 402, height: 220)
+ #expect(OpenCameraAim.point(folds: false, window: window, camera: Self.camera) == nil)
+ }
+
+ @Test func noMeasuredWindowAimsAtTheMiddleOfTheScreen() {
+ #expect(OpenCameraAim.point(folds: true, window: nil, camera: Self.camera) == nil)
+ }
+
+ @Test func aWindowWhollyOnScreenAimsAtItsMiddle() {
+ let window = CGRect(x: 16, y: 420, width: 370, height: 220)
+ #expect(OpenCameraAim.point(folds: true, window: window, camera: Self.camera) == CGPoint(x: 201, y: 530))
+ }
+
+ /// What the chrome measures: its open camera is a Spacer, which can be zero points wide.
+ /// Rejecting that put the reticle back under the card at AX5 (run 36877327250).
+ @Test func theChromesZeroWidthSpacerAimsAtItsMiddle() {
+ let window = CGRect(x: 201, y: 420, width: 0, height: 220)
+ #expect(OpenCameraAim.point(folds: true, window: window, camera: Self.camera) == CGPoint(x: 201, y: 530))
+ }
+
+ /// Scrolled down, the window's top leaves the screen and the aim follows what is left.
+ @Test func aWindowCutAtTheTopAimsAtItsPartOnScreen() {
+ let window = CGRect(x: 16, y: -100, width: 370, height: 300)
+ #expect(OpenCameraAim.point(folds: true, window: window, camera: Self.camera) == CGPoint(x: 201, y: 100))
+ }
+
+ @Test func aWindowCutAtTheBottomAimsAtItsPartOnScreen() {
+ let window = CGRect(x: 16, y: 774, width: 370, height: 300)
+ #expect(OpenCameraAim.point(folds: true, window: window, camera: Self.camera) == CGPoint(x: 201, y: 824))
+ }
+
+ @Test(arguments: [
+ CGRect(x: 16, y: -400, width: 370, height: 300),
+ CGRect(x: 16, y: 874, width: 370, height: 200),
+ CGRect(x: 16, y: 500, width: 370, height: 0),
+ ])
+ func noWindowOnScreenAimsAtTheMiddleOfTheScreen(window: CGRect) {
+ #expect(OpenCameraAim.point(folds: true, window: window, camera: Self.camera) == nil)
+ }
+}
diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/PrivacyManifestTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/PrivacyManifestTests.swift
new file mode 100644
index 000000000..527d9625d
--- /dev/null
+++ b/ios/HouseScanKit/Tests/HouseScanKitTests/PrivacyManifestTests.swift
@@ -0,0 +1,215 @@
+import Foundation
+import Testing
+
+/// The app's privacy manifest (`ios/HouseScan/PrivacyInfo.xcprivacy`) against the code that ships
+/// in the app: the HouseScan target's Swift files and HouseScanKit's sources. A required-reason
+/// API added without its category, or a category left declared after its last use is removed,
+/// fails here. Only the categories are checked against the code; each reason is pinned by hand,
+/// because whether a use fits a reason depends on what the code does with the value.
+@Suite struct PrivacyManifestTests {
+ private static let ios = URL(fileURLWithPath: #filePath)
+ .deletingLastPathComponent()
+ .appendingPathComponent("../../..")
+ .standardizedFileURL
+
+ /// Apple's required-reason APIs by category, as Swift can spell them. A C call may have
+ /// spaces before its parenthesis. Disk space and active keyboards are listed so a first use
+ /// of them fails loudly too.
+ static let apis: [String: String] = [
+ "NSPrivacyAccessedAPICategoryUserDefaults": #"\bUserDefaults\b|@AppStorage\b|\bNSUbiquitousKeyValueStore\b"#,
+ "NSPrivacyAccessedAPICategoryFileTimestamp": #"\bcreationDate\b|\bmodificationDate\b|\bfileModificationDate\b|\bcontentModificationDate(Key)?\b|\bcreationDateKey\b|\battributesOfItem\b|\bfileAttributes\b|\b(f|l)?getattrlist(bulk|at)?\s*\(|\b(f|l)?stat(at)?\s*\("#,
+ "NSPrivacyAccessedAPICategorySystemBootTime": #"\bsystemUptime\b|\bmach_absolute_time\b"#,
+ "NSPrivacyAccessedAPICategoryDiskSpace": #"\bvolumeAvailableCapacity\w*|\bvolumeTotalCapacity\w*|\bsystemFreeSize\b|\bsystemSize\b|\bf?statv?fs\s*\("#,
+ "NSPrivacyAccessedAPICategoryActiveKeyboards": #"\bactiveInputModes\b"#,
+ ]
+
+ /// Swift source with its comments and string text blanked, so only code is searched: a
+ /// string or a comment that names an API doesn't count as calling it. Interpolations
+ /// (`\\(...)`, or `\\#(...)` in a raw string) are code and stay. Ordinary, multi-line
+ /// (`"""`) and raw (`#"..."#`) strings each end only at their own delimiter, so a quote
+ /// inside one can't hide the code after it. Block comments may nest, as in Swift.
+ static func code(_ source: String) -> String {
+ enum Mode {
+ case code(parens: Int, interpolation: Bool)
+ case string(hashes: Int, multiline: Bool)
+ }
+ let chars = Array(source)
+ func at(_ i: Int) -> Character? { i < chars.count ? chars[i] : nil }
+ func hashes(from i: Int) -> Int {
+ var n = 0
+ while at(i + n) == "#" { n += 1 }
+ return n
+ }
+ /// Whether a string's closing delimiter starts at `i`: one quote, or three, then the
+ /// string's hashes.
+ func closes(at i: Int, hashes h: Int, multiline: Bool) -> Int? {
+ let quotes = multiline ? 3 : 1
+ guard (0..= h else { return nil }
+ return quotes + h
+ }
+ var out = ""
+ var modes: [Mode] = [.code(parens: 0, interpolation: false)]
+ var commentDepth = 0
+ var i = 0
+ while let c = at(i) {
+ let next = at(i + 1)
+ if commentDepth > 0 {
+ if c == "*", next == "/" { commentDepth -= 1; i += 2; continue }
+ if c == "/", next == "*" { commentDepth += 1; i += 2; continue }
+ if c == "\n" { out.append(c) }
+ i += 1
+ continue
+ }
+ switch modes[modes.count - 1] {
+ case .string(let h, let multiline):
+ if let length = closes(at: i, hashes: h, multiline: multiline) {
+ modes.removeLast()
+ out.append(contentsOf: chars[i..<(i + length)])
+ i += length
+ } else if c == "\\", hashes(from: i + 1) >= h, at(i + 1 + h) == "(" {
+ modes.append(.code(parens: 1, interpolation: true))
+ out.append(contentsOf: chars[i...(i + 1 + h)])
+ i += 2 + h
+ } else if c == "\\", h == 0, next != nil {
+ // An escape in an ordinary string: the escaped character is text.
+ out.append(next == "\n" ? "\n" : " ")
+ out.append(" ")
+ i += 2
+ } else if c == "\n" {
+ // A single-line string can't span lines; end it here rather than hide code.
+ if !multiline { modes.removeLast() }
+ out.append(c)
+ i += 1
+ } else {
+ out.append(" ")
+ i += 1
+ }
+ case .code(let parens, let interpolation):
+ let h = c == "#" ? hashes(from: i) : 0
+ if c == "/", next == "/" {
+ while let d = at(i), d != "\n" { i += 1 }
+ } else if c == "/", next == "*" {
+ commentDepth = 1
+ i += 2
+ } else if c == "\"" || (h > 0 && at(i + h) == "\"") {
+ let quote = i + h
+ let multiline = at(quote + 1) == "\"" && at(quote + 2) == "\""
+ let length = h + (multiline ? 3 : 1)
+ modes.append(.string(hashes: h, multiline: multiline))
+ out.append(contentsOf: chars[i..<(i + length)])
+ i += length
+ } else {
+ if c == "(" {
+ modes[modes.count - 1] = .code(parens: parens + 1, interpolation: interpolation)
+ } else if c == ")", parens > 0 {
+ if interpolation, parens == 1 {
+ modes.removeLast()
+ } else {
+ modes[modes.count - 1] = .code(parens: parens - 1, interpolation: interpolation)
+ }
+ }
+ out.append(c)
+ i += 1
+ }
+ }
+ }
+ return out
+ }
+
+ /// The categories whose APIs appear in `source`'s code.
+ static func categories(in source: String) -> Set {
+ let code = code(source)
+ return Set(apis.compactMap { category, pattern in
+ code.range(of: pattern, options: .regularExpression) == nil ? nil : category
+ })
+ }
+
+ private static func manifest() throws -> [String: [String]] {
+ let url = ios.appendingPathComponent("HouseScan/PrivacyInfo.xcprivacy")
+ let plist = try PropertyListSerialization.propertyList(from: Data(contentsOf: url), format: nil)
+ let root = try #require(plist as? [String: Any])
+ let types = try #require(root["NSPrivacyAccessedAPITypes"] as? [[String: Any]])
+ var declared: [String: [String]] = [:]
+ for entry in types {
+ let category = try #require(entry["NSPrivacyAccessedAPIType"] as? String)
+ #expect(declared[category] == nil, "\(category) is declared twice")
+ declared[category] = try #require(entry["NSPrivacyAccessedAPITypeReasons"] as? [String])
+ }
+ return declared
+ }
+
+ /// Categories whose APIs appear in shipped Swift, with the first file that uses each.
+ private static func usedCategories() throws -> [String: String] {
+ var used: [String: String] = [:]
+ for folder in ["HouseScan", "HouseScanKit/Sources"] {
+ let root = ios.appendingPathComponent(folder)
+ let walker = try #require(FileManager.default.enumerator(at: root, includingPropertiesForKeys: nil))
+ for case let file as URL in walker where file.pathExtension == "swift" {
+ for category in categories(in: try String(contentsOf: file, encoding: .utf8)) where used[category] == nil {
+ used[category] = file.path.replacingOccurrences(of: ios.path + "/", with: "")
+ }
+ }
+ }
+ return used
+ }
+
+ @Test func everyUsedCategoryIsDeclaredAndNothingElse() throws {
+ let declared = try Self.manifest()
+ let used = try Self.usedCategories()
+ for (category, file) in used.sorted(by: { $0.key < $1.key }) {
+ #expect(declared[category] != nil, "\(file) uses \(category), which PrivacyInfo.xcprivacy does not declare")
+ }
+ for category in declared.keys.sorted() {
+ #expect(used[category] != nil, "PrivacyInfo.xcprivacy declares \(category), which no shipped code uses")
+ }
+ }
+
+ @Test func reasonsMatchTheUsesTracedInTheManifestComments() throws {
+ #expect(try Self.manifest() == [
+ "NSPrivacyAccessedAPICategoryUserDefaults": ["CA92.1"],
+ "NSPrivacyAccessedAPICategoryFileTimestamp": ["C617.1"],
+ "NSPrivacyAccessedAPICategorySystemBootTime": ["35F9.1", "8FFB.1"],
+ ])
+ }
+
+ // The scanner on its own, so a gap in it can't hide behind today's sources.
+
+ @Test(arguments: [
+ ("statfs (path, &stats)", "NSPrivacyAccessedAPICategoryDiskSpace"),
+ ("let free = values.volumeAvailableCapacityForImportantUsage", "NSPrivacyAccessedAPICategoryDiskSpace"),
+ ("fstatvfs(fd, &info)", "NSPrivacyAccessedAPICategoryDiskSpace"),
+ ("let a = try fm.attributesOfItem(atPath: p)", "NSPrivacyAccessedAPICategoryFileTimestamp"),
+ ("let d = values.contentModificationDate", "NSPrivacyAccessedAPICategoryFileTimestamp"),
+ ("lstat (path, &s)", "NSPrivacyAccessedAPICategoryFileTimestamp"),
+ ("getattrlistbulk(fd, &list, buf, n, 0)", "NSPrivacyAccessedAPICategoryFileTimestamp"),
+ ("let t = mach_absolute_time()", "NSPrivacyAccessedAPICategorySystemBootTime"),
+ ("@AppStorage(\"k\") var on = false", "NSPrivacyAccessedAPICategoryUserDefaults"),
+ ("let url = \"https://example.com\"; let up = ProcessInfo.processInfo.systemUptime", "NSPrivacyAccessedAPICategorySystemBootTime"),
+ ("let s = \"/* not a comment\"; UserDefaults.standard.set(1, forKey: \"k\")", "NSPrivacyAccessedAPICategoryUserDefaults"),
+ ("UITextInputMode.activeInputModes", "NSPrivacyAccessedAPICategoryActiveKeyboards"),
+ ("let on = \"\\(UserDefaults.standard.bool(forKey: \"k\"))\"", "NSPrivacyAccessedAPICategoryUserDefaults"),
+ ("log(\"saved \\(n) at \\(f(\")\"))\"); let t = ProcessInfo.processInfo.systemUptime", "NSPrivacyAccessedAPICategorySystemBootTime"),
+ // Raw strings: an interpolation is code, and a quote inside doesn't end the string.
+ ("let s = #\"\\#(ProcessInfo.processInfo.systemUptime)\"#", "NSPrivacyAccessedAPICategorySystemBootTime"),
+ ("let s = #\"a \" b\"#; let t = ProcessInfo.processInfo.systemUptime", "NSPrivacyAccessedAPICategorySystemBootTime"),
+ // A multi-line string ends at its own delimiter, with code after it on that line.
+ ("let s = \"\"\"\n \"quoted\" text\n \"\"\".count + Int(ProcessInfo.processInfo.systemUptime)", "NSPrivacyAccessedAPICategorySystemBootTime"),
+ ])
+ func theScannerFindsAUse(source: String, category: String) {
+ #expect(Self.categories(in: source) == [category])
+ }
+
+ @Test(arguments: [
+ "// UserDefaults.standard is not used here",
+ "/* systemUptime\n /* nested */ mach_absolute_time */",
+ "let status = statusText(code)",
+ "let restated = 1",
+ "let note = \"UserDefaults and systemUptime are only named here\"",
+ "print(\"a quote \\\" then mach_absolute_time\")",
+ "let s = #\"UserDefaults \\(not an interpolation in a raw string)\"#",
+ "let s = \"\"\"\n systemUptime \"in\" a multi-line string\n \"\"\"",
+ ])
+ func theScannerIgnoresCommentsStringsAndLookalikes(source: String) {
+ #expect(Self.categories(in: source).isEmpty)
+ }
+}
diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/RebuildViewCacheTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/RebuildViewCacheTests.swift
new file mode 100644
index 000000000..183165da0
--- /dev/null
+++ b/ios/HouseScanKit/Tests/HouseScanKitTests/RebuildViewCacheTests.swift
@@ -0,0 +1,320 @@
+@testable import HouseScanKit
+import simd
+import Testing
+
+// A rebuild that reuses kept views (`CoverageMap.rebuildViews`) must leave the map exactly as one
+// that works every view out again. Each test runs the same changes on two copies of a map, one
+// with `reusesRebuildViews` off, and after every change compares their whole stored state
+// (`stateDifferences`), what the public readers return, and both again after depthless views
+// taken on top: the order a row's sightings were kept in decides what a later depthless view adds.
+@Suite struct RebuildViewCacheTests {
+ /// The far wall of the corridor scenes, 2 m out (`FarSurfaceTests.farWall`).
+ static let scene = standardScene(boxes: [FarSurfaceTests.farWall(at: 2.0)])
+
+ /// Frames with depth: the corridor cameras facing the far wall, oblique ones looking along the
+ /// corridor both ways, wall cameras and a ground camera.
+ static let depthFrames: [(camera: CameraFrame, depth: DepthImage)] = {
+ let pitch = Float.pi / 6
+ var cameras = FarSurfaceTests.corridorCameras
+ for (s, yaw) in [(Float(-1), Float(0.6)), (0.5, -0.6), (1.5, 0.6), (2.5, -0.6)] {
+ cameras.append(portraitCamera(at: SIMD3(s, 1.4, 0.8), forward: SIMD3(sin(yaw) * cos(pitch), -sin(pitch), cos(yaw) * cos(pitch))))
+ }
+ cameras += [wallCamera(s: -0.5), wallCamera(s: 0.4), wallCamera(s: 1.2), groundCamera(s: 0.2)]
+ return cameras.map { ($0, renderDepth(RebuildViewCacheTests.scene, from: $0)) }
+ }()
+
+ /// Frames without depth.
+ static let depthlessFrames = [wallCamera(s: 0.1), groundCamera(s: -0.3), wallCamera(s: 0.9)]
+
+ /// Far surfaces the lookup moves between, over s -3...4 m.
+ static let surfaces: [String: [ObservedSpan]] = {
+ let wall = standardWall()
+ func spans(_ planes: [WallPlaneEvidence]) -> [ObservedSpan] { FarSurface.spans(planes: planes, wall: wall, over: -3...4) }
+ return [
+ "none": [],
+ "far wall": spans([FarSurfaceTests.plane(at: 2.0)]),
+ // 4 mm nearer, still in the same 0.1 ft step (65.6 steps at 2 m): the same spans, so
+ // no rebuild.
+ "far wall, jittered within its step": spans([FarSurfaceTests.plane(at: 1.996)]),
+ // Across the next step down (2.0 to 1.9812 m): every cell's value changes.
+ "far wall, one step nearer": spans([FarSurfaceTests.plane(at: 1.97)]),
+ // A nearer plane over part of the stretch: the overlap's minimum changes there only.
+ "far wall with a nearer plane": spans([FarSurfaceTests.plane(at: 2.0), FarSurfaceTests.plane(at: 1.4, from: 0, to: 1.5)]),
+ "far wall over part of the stretch": spans([FarSurfaceTests.plane(at: 2.0, from: -1, to: 0.6)]),
+ // Only where no camera looks: against the far wall, the wall's cells go absent.
+ "far away": [ObservedSpan(span: 50...51, out: 2)],
+ // The far wall and a surface where no camera looks: from the far wall alone this
+ // changes the lookup, and so rebuilds, at no cell a frame consulted.
+ "far wall and far away": spans([FarSurfaceTests.plane(at: 2.0)]) + [ObservedSpan(span: 50...51, out: 2)],
+ ]
+ }()
+
+ /// What the public readers return, over cells -40...40 and 30 cells past each end.
+ struct Readout: Equatable {
+ var revision: Int
+ var levels: [[CoverageLevel]]
+ var groundDepths: [Float?]
+ var hiddenDepthRows: [Set]
+ var wallHeights: [Float?]
+ var walked: [Float?]
+ var pastLimits: [Float?]
+ var spans: [[ObservedSpan]]
+ var seen: ClosedRange?
+ var viewed: ClosedRange?
+
+ init(_ map: CoverageMap) {
+ let cells = -40...40
+ revision = map.revision
+ levels = SurfaceBand.allCases.map { band in cells.map { map.level(band, $0) } }
+ groundDepths = cells.map(map.groundDepth(at:))
+ hiddenDepthRows = cells.map(map.groundDepthHiddenRows(at:))
+ wallHeights = cells.map(map.wallSeenHeight(at:))
+ walked = cells.map(map.walkedClearance(at:))
+ pastLimits = WalkSide.allCases.flatMap { side in (0..<30).map { map.groundDepthPastLimit(side, $0) } }
+ spans = [map.wallSeenSpans(), map.groundDepthSpans(), map.facingSpans(), map.overheadSpans()]
+ seen = map.seenExtent
+ viewed = map.viewedExtent
+ }
+ }
+
+ /// A map reusing kept views and one working them all out, changed in step.
+ struct Twin {
+ var cached: CoverageMap
+ var full: CoverageMap
+
+ init(_ map: CoverageMap = CoverageMap(wall: standardWall())) {
+ cached = map
+ full = map
+ full.reusesRebuildViews = false
+ }
+
+ mutating func step(_ name: String, _ change: (inout CoverageMap) throws -> Void) rethrows {
+ try change(&cached)
+ try change(&full)
+ #expect(cached.stateDifferences(from: full) == [], "state after \(name)")
+ #expect(Readout(cached) == Readout(full), "readers after \(name)")
+ var later = (cached, full)
+ for (index, camera) in RebuildViewCacheTests.depthlessFrames.enumerated() {
+ later.0.observe(camera, trackingNormal: true, time: 1000 + Double(index))
+ later.1.observe(camera, trackingNormal: true, time: 1000 + Double(index))
+ }
+ #expect(later.0.stateDifferences(from: later.1) == [], "depthless views after \(name)")
+ #expect(full.rebuildViewCounts.reused == 0)
+ }
+
+ mutating func observeAll(times: [Double]) {
+ for (index, frame) in RebuildViewCacheTests.depthFrames.enumerated() {
+ step("observing frame \(index)") { $0.observe(frame.camera, trackingNormal: true, time: times[index], depth: frame.depth) }
+ }
+ }
+
+ mutating func setSurface(_ name: String) {
+ let spans = RebuildViewCacheTests.surfaces[name]!
+ step("far surface \(name)") { $0.setFarSurface(spans) }
+ }
+ }
+
+ /// Store-completion order: the frames are observed in an order other than their times'.
+ static func shuffledTimes() -> [Double] {
+ depthFrames.indices.map { Double(($0 * 7) % depthFrames.count) }
+ }
+
+ @Test func farSurfaceTransitionsMatchAFullRebuild() {
+ var twin = Twin()
+ twin.observeAll(times: Self.shuffledTimes())
+ for name in [
+ "far wall", "far wall and far away", "far wall, jittered within its step", "far wall, one step nearer",
+ "far wall with a nearer plane", "far wall over part of the stretch", "far away", "none", "far wall", "none",
+ ] {
+ twin.setSurface(name)
+ }
+ twin.step("a depthless frame") { $0.observe(Self.depthlessFrames[0], trackingNormal: true, time: 50) }
+ twin.setSurface("far wall")
+ }
+
+ @Test func wallMovesShiftSkippedAndWithdrawnCellsAsAFullRebuild() {
+ var twin = Twin()
+ twin.observeAll(times: Self.shuffledTimes())
+ twin.setSurface("far wall")
+ twin.step("skipping a cell") { $0.markSkipped(.wall, $0.cellRange(5)) }
+ twin.step("withdrawing a cell") { $0.withdrawClaims(over: $0.cellRange(2)) }
+ twin.setSurface("far wall, one step nearer")
+ let width = CoverageConfig().cellWidth
+ // A whole cell, then two moves of 0.4 cells: the second carries `pendingShift` over a
+ // whole cell.
+ for (index, shift) in [width, 0.4 * width, 0.4 * width].enumerated() {
+ twin.step("meter move \(index)") { map in
+ var moved = map.wall
+ moved.meter.x -= shift
+ map.updateWall(moved)
+ }
+ twin.setSurface(index % 2 == 0 ? "far wall" : "far wall with a nearer plane")
+ }
+ twin.step("a measured ground") { map in
+ var moved = map.wall
+ moved.groundY += 0.02
+ map.updateWall(moved)
+ }
+ twin.setSurface("far away")
+ }
+
+ @Test func limitsCornerCorrectionAndGroundMatchAFullRebuild() throws {
+ var twin = Twin()
+ twin.observeAll(times: Self.depthFrames.indices.map(Double.init))
+ twin.setSurface("far wall")
+ twin.step("right end") { $0.setEnd(.right, at: 0.6) }
+ twin.step("right end a limit") { $0.setEndIsLimit(.right, true) }
+ twin.setSurface("far wall, one step nearer")
+ twin.setSurface("far away")
+ twin.step("left end a limit") { map in
+ map.setEnd(.left, at: -1.2)
+ map.setEndIsLimit(.left, true)
+ }
+ twin.setSurface("far wall")
+ try twin.step("corner after the limit end") { map in
+ _ = try map.turnCorner(.right, meeting: SIMD3(0.6, 0, -2), outward: SIMD3(1, 0, 0), source: .plane)
+ }
+ twin.setSurface("far wall over part of the stretch")
+ twin.step("anchor correction") { $0.apply(YawCorrection(yaw: 0.005, translation: SIMD3(0.01, 0, 0.004))) }
+ twin.setSurface("far wall")
+ twin.step("guessed ground") { $0.heightError = 0.3 }
+ twin.setSurface("far wall, jittered within its step")
+ twin.step("measured ground") { $0.heightError = 0 }
+ twin.step("wall line source") { $0.setWallLineSource(.plane) }
+ twin.step("overhead view") { _ = $0.recordOverhead(portraitCamera(at: SIMD3(0, 1.4, 2.6), forward: SIMD3(0, 0.5, -1)), trackingNormal: true) }
+ twin.step("walked path break") { $0.breakWalkedPath() }
+ twin.step("clearing the left end") { $0.clearEnd(.left) }
+ twin.setSurface("none")
+ }
+
+ /// A frame's views depend on the lookup where its depth readings met something, not only where
+ /// its samples lie. From 3 m out, beyond a 1.1 m fence 2 m out, a phone turned along the wall
+ /// sees the ground behind the fence blocked by it. A sample there lies short of the fence's
+ /// surface, so whether it is past the space turns on the lookup at the cell where the reading
+ /// met the fence, nearer the phone along the wall. Cutting the fence's surface back over those
+ /// cells only must work the views out again. A cache keyed on the samples' cells alone keeps
+ /// the old answer.
+ @Test func viewsFollowTheLookupWhereReadingsMetTheFence() {
+ let scene = standardScene(boxes: [(SIMD3(-10, 0, 2.0), SIMD3(10, 1.1, 2.1))])
+ let pitch: Float = 25 * .pi / 180
+ var twin = Twin()
+ var time = 0.0
+ for s in [Float(2), 3, 4] {
+ for yaw in [Float(0.9), -0.9] {
+ let camera = portraitCamera(at: SIMD3(s, 1.4, 3.0), forward: SIMD3(sin(yaw) * cos(pitch), -sin(pitch), -cos(yaw) * cos(pitch)))
+ let depth = renderDepth(scene, from: camera)
+ time += 1
+ let t = time
+ twin.step("fence frame at s \(s), yaw \(yaw)") { $0.observe(camera, trackingNormal: true, time: t, depth: depth) }
+ }
+ }
+ func fence(from low: Float, to high: Float) -> [ObservedSpan] {
+ let plane = WallPlaneEvidence(
+ id: "fence", kind: .wall, center: SIMD3((low + high) / 2, 0.55, 2.0), normal: SIMD3(0, 0, -1),
+ boundary: [SIMD3(low, 0, 2.0), SIMD3(high, 0, 2.0), SIMD3(high, 1.1, 2.0), SIMD3(low, 1.1, 2.0)])
+ return FarSurface.spans(planes: [plane], wall: standardWall(), over: -4...8)
+ }
+ let whole = fence(from: -4, to: 8)
+ twin.step("the whole fence") { $0.setFarSurface(whole) }
+ for cut in [Float(0.5), 1.0, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0, 4.5, 5.0, 5.5] {
+ for (low, high) in [(Float(-4), cut), (cut, Float(8))] {
+ let part = fence(from: low, to: high)
+ twin.step("fence over s \(low)...\(high)") { $0.setFarSurface(part) }
+ twin.step("the whole fence again") { $0.setFarSurface(whole) }
+ }
+ }
+ #expect(twin.cached.rebuildViewCounts.reused > 0)
+ }
+
+ /// The counts show views reused while their inputs hold and worked out again when any changes.
+ @Test func keptViewsAreReusedOnlyWhileTheirInputsHold() {
+ var twin = Twin()
+ twin.observeAll(times: Self.depthFrames.indices.map(Double.init))
+ let frames = Self.depthFrames.count
+ twin.setSurface("far wall")
+ // The first rebuild works out the bands and the depth rows of every frame.
+ #expect(twin.cached.rebuildViewCounts.reused == 0)
+ #expect(twin.cached.rebuildViewCounts.computed == 2 * frames)
+ twin.setSurface("far wall and far away")
+ #expect(twin.cached.rebuildViewCounts.reused == 2 * frames)
+ #expect(twin.cached.rebuildViewCounts.computed == 2 * frames)
+ // A value change at cells the corridor frames consulted: those work theirs out again.
+ let before = twin.cached.rebuildViewCounts
+ twin.setSurface("far wall, one step nearer")
+ let after = twin.cached.rebuildViewCounts
+ #expect(after.computed > before.computed)
+ #expect(after.reused > before.reused)
+ // An end changes the basis: every view is worked out again.
+ twin.step("right end") { $0.setEnd(.right, at: 3) }
+ twin.setSurface("far wall")
+ #expect(twin.cached.rebuildViewCounts.computed == after.computed + 2 * frames)
+ #expect(twin.cached.rebuildViewCounts.reused == after.reused)
+ }
+
+ /// Seeded runs of every change the map takes, in random order.
+ @Test(arguments: [UInt64(1), 2, 3])
+ func seededSequencesMatchAFullRebuild(seed: UInt64) {
+ var random = SplitMix(state: seed)
+ var twin = Twin()
+ let surfaces = Self.surfaces.keys.sorted()
+ var time = 0.0
+ for index in 0..<60 {
+ time += 1
+ let at = time
+ switch random.below(12) {
+ case 0, 1:
+ let frame = Self.depthFrames[random.below(Self.depthFrames.count)]
+ // Times out of order now and then, as photos finish storing.
+ let t = random.below(4) == 0 ? at - 3.5 : at
+ twin.step("\(index): observe with depth") { $0.observe(frame.camera, trackingNormal: true, time: t, depth: frame.depth) }
+ case 2:
+ let camera = Self.depthlessFrames[random.below(Self.depthlessFrames.count)]
+ twin.step("\(index): observe without depth") { $0.observe(camera, trackingNormal: true, time: at) }
+ case 3, 4, 5:
+ twin.setSurface(surfaces[random.below(surfaces.count)])
+ case 6:
+ let side: WalkSide = random.below(2) == 0 ? .left : .right
+ let s = side == .left ? -0.5 - Float(random.below(10)) * 0.2 : 0.4 + Float(random.below(10)) * 0.2
+ let limit = random.below(2) == 0
+ twin.step("\(index): end \(side) at \(s), limit \(limit)") { map in
+ map.setEnd(side, at: s)
+ map.setEndIsLimit(side, limit)
+ }
+ case 7:
+ let side: WalkSide = random.below(2) == 0 ? .left : .right
+ twin.step("\(index): clear \(side) end") { $0.clearEnd(side) }
+ case 8:
+ let shift = Float(random.below(9)) * 0.05 - 0.2
+ twin.step("\(index): meter move \(shift)") { map in
+ var moved = map.wall
+ moved.meter.x += shift
+ map.updateWall(moved)
+ }
+ case 9:
+ let yaw = Float(random.below(5)) * 0.002 - 0.004
+ twin.step("\(index): anchor correction \(yaw)") { $0.apply(YawCorrection(yaw: yaw, translation: SIMD3(yaw, 0, -yaw))) }
+ case 10:
+ let cell = random.below(20) - 10
+ if random.below(2) == 0 {
+ twin.step("\(index): skip cell \(cell)") { $0.markSkipped(.ground, $0.cellRange(cell)) }
+ } else {
+ twin.step("\(index): withdraw cell \(cell)") { $0.withdrawClaims(over: $0.cellRange(cell)) }
+ }
+ default:
+ let error: Float = random.below(2) == 0 ? 0.3 : 0
+ twin.step("\(index): height error \(error)") { $0.heightError = error }
+ }
+ }
+ }
+
+ struct SplitMix {
+ var state: UInt64
+ mutating func below(_ bound: Int) -> Int {
+ state &+= 0x9E37_79B9_7F4A_7C15
+ var z = state
+ z = (z ^ (z >> 30)) &* 0xBF58_476D_1CE4_E5B9
+ z = (z ^ (z >> 27)) &* 0x94D0_49BB_1331_11EB
+ return Int((z ^ (z >> 31)) % UInt64(bound))
+ }
+ }
+}
diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/ResultBindingTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/ResultBindingTests.swift
new file mode 100644
index 000000000..93b764da9
--- /dev/null
+++ b/ios/HouseScanKit/Tests/HouseScanKitTests/ResultBindingTests.swift
@@ -0,0 +1,102 @@
+import Foundation
+import HouseScanKit
+import Testing
+
+/// A placement answer from the server is shown only for the scene that was sent: its
+/// `stats.input_sha256` must be present, in the schema's form, and the SHA-256 of those exact
+/// bytes. Built from a real server answer to the synthetic replay.
+@Suite struct ResultBindingTests {
+ static let scene = Data(#"{"note":"the bytes this phone sent"}"#.utf8)
+ static let fixtureHash = "2cb165cd4d4e9340496d90a589678ccbbe8ae6720d9d73bcae427a6b22075cb7"
+ static let hashLine = "\"input_sha256\": \"\(fixtureHash)\","
+
+ static func answer() throws -> String {
+ let url = URL(fileURLWithPath: #filePath).deletingLastPathComponent()
+ .appendingPathComponent("Schemas/server-answer-synthetic-wall.json")
+ let text = String(decoding: try Data(contentsOf: url), as: UTF8.self)
+ #expect(text.contains(hashLine))
+ return text
+ }
+
+ /// The real answer with its `input_sha256` line replaced by `line` (empty removes it).
+ static func answer(replacingHashLineWith line: String) throws -> Data {
+ Data(try answer().replacingOccurrences(of: hashLine, with: line).utf8)
+ }
+
+ static func answer(declaring hash: String) throws -> Data {
+ try answer(replacingHashLineWith: "\"input_sha256\": \"\(hash)\",")
+ }
+
+ static func refusal(_ data: Data) throws -> ResultBinding.Refusal {
+ let refusal = try #require(throws: ResultBinding.Refusal.self) {
+ try ResultBinding.check(answer: data, submittedScene: Self.scene)
+ }
+ // Every refusal is the "couldn't read the answer" failure: back to the review, no retry.
+ #expect(UploadFailureKind.classify(refusal) == .unreadableAnswer)
+ #expect(!UploadFailureKind.classify(refusal).retryable)
+ return refusal
+ }
+
+ @Test func anAnswerForTheSceneSentPassesAndDecodes() throws {
+ let data = try Self.answer(declaring: PacketFiles.sha256(Self.scene))
+ try ResultBinding.check(answer: data, submittedScene: Self.scene)
+ #expect(try PlacementResult.decode(data).decision == .manualReview)
+ }
+
+ @Test func anAnswerForAnotherSceneIsRefused() throws {
+ let other = PacketFiles.sha256(Data("another scene".utf8))
+ #expect(try Self.refusal(Self.answer(declaring: other)) == .mismatch(submitted: PacketFiles.sha256(Self.scene), declared: other))
+ }
+
+ /// The upload logs a refusal's description publicly, so it carries no scene hash.
+ @Test func aRefusalsDescriptionNamesNoHash() throws {
+ let sent = PacketFiles.sha256(Self.scene)
+ let other = PacketFiles.sha256(Data("another scene".utf8))
+ let refusals: [ResultBinding.Refusal] = [
+ try Self.refusal(Self.answer(declaring: other)),
+ try Self.refusal(Self.answer(declaring: String(other.dropLast()))),
+ try Self.refusal(Self.answer(replacingHashLineWith: "")),
+ ]
+ for refusal in refusals {
+ let text = String(describing: refusal)
+ #expect(!text.contains(sent) && !text.contains(other) && !text.contains(String(other.dropLast())))
+ }
+ }
+
+ /// No readable hash refuses the answer, whatever the general decoder would make of it.
+ @Test(arguments: [
+ ("absent", ""),
+ ("null", "\"input_sha256\": null,"),
+ ("a number", "\"input_sha256\": 7,"),
+ ])
+ func anAnswerWithoutAHashIsRefused(_ kind: String, line: String) throws {
+ #expect(try Self.refusal(Self.answer(replacingHashLineWith: line)) == .noInputHash)
+ }
+
+ /// The schema's form is 64 lowercase hex digits; the right digits in capitals, or too few,
+ /// are refused rather than compared.
+ @Test(arguments: ["UPPER", "short", "notHex"])
+ func aMalformedHashIsRefused(_ kind: String) throws {
+ let good = PacketFiles.sha256(Self.scene)
+ let value = switch kind {
+ case "UPPER": good.uppercased()
+ case "short": String(good.dropLast())
+ default: String(good.dropLast()) + "z"
+ }
+ #expect(try Self.refusal(Self.answer(declaring: value)) == .malformedInputHash(value))
+ }
+
+ @Test func anAnswerThatIsNotJSONIsRefused() throws {
+ #expect(try Self.refusal(Data("not json".utf8)) == .noInputHash)
+ }
+
+ /// A field this check doesn't read can't make it skip the comparison: an answer for another
+ /// scene that also carries a number too large for a Double is still refused.
+ @Test func anUnusualIgnoredFieldDoesNotSkipTheCheck() throws {
+ let other = PacketFiles.sha256(Data("another scene".utf8))
+ let text = try Self.answer().replacingOccurrences(of: Self.hashLine, with: "\"input_sha256\": \"\(other)\", \"extra\": 1e400,")
+ #expect(throws: ResultBinding.Refusal.self) {
+ try ResultBinding.check(answer: Data(text.utf8), submittedScene: Self.scene)
+ }
+ }
+}
diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/ServerGapTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/ServerGapTests.swift
index 0c7143c31..d479ca93b 100644
--- a/ios/HouseScanKit/Tests/HouseScanKitTests/ServerGapTests.swift
+++ b/ios/HouseScanKit/Tests/HouseScanKitTests/ServerGapTests.swift
@@ -277,13 +277,14 @@ import Testing
}
/// The next answer works a refused view out again from the new scene, so it can come back with
- /// its span or reach moved by a few hundredths of a foot. It is still the view the homeowner
- /// said they can't get to, and isn't raised again; another stretch of the band is a new view.
+ /// its span moved inside the old one. That asks for nothing new and isn't raised again;
+ /// another stretch of the band is a new view. (#49: the reach that came back here at 4.9 ft
+ /// after 4.833334 ft is now a new view, since progress requires it.)
@Test func aRefusedViewWithSlightlyDifferentNumbersIsNotRaisedAgain() throws {
let planner = GapPlanner()
let refused = try item(#"{"kind":"band","band":"ground","span_ft":[2.4,7.9],"out_ft":4.833334,"message":"m"}"#)
let plan = try #require(planner.plan(for: refused, leftEnd: -3, rightEnd: 4))
- let moved = try item(#"{"kind":"band","band":"ground","span_ft":[2.41,7.9],"out_ft":4.9,"message":"m"}"#)
+ let moved = try item(#"{"kind":"band","band":"ground","span_ft":[2.41,7.9],"out_ft":4.833334,"message":"m"}"#)
#expect(planner.nextServerRequest(in: [moved], leftEnd: -3, rightEnd: 4, limitEnds: [], asked: [plan], skipped: [plan])?.item == nil)
let elsewhere = try item(#"{"kind":"band","band":"ground","span_ft":[-7.9,-2.4],"out_ft":4.833334,"message":"m"}"#)
#expect(planner.nextServerRequest(
@@ -314,14 +315,15 @@ import Testing
/// A request that asks for more than one already raised is a new view, even when it mostly
/// overlaps: a longer stretch (2 to 9 ft after 2 to 8 ft, 6/7 of it already asked for) or
- /// ground farther out (5.10 ft after 4.83 ft, 0.08 m more). Only jitter within 0.1 ft is the
- /// same view. This holds for earlier requests, skipped ones and items in the same answer.
+ /// ground farther out (5.10 ft after 4.83 ft, 0.08 m more). A span inside the earlier one, with
+ /// the same reach, is the same view. This holds for earlier requests, skipped ones and items
+ /// in the same answer.
@Test func aRequestAskingForMoreIsANewView() throws {
let planner = GapPlanner()
let first = try item(#"{"kind":"band","band":"ground","span_ft":[2.0,8.0],"out_ft":4.83,"message":"m"}"#)
let longer = try item(#"{"kind":"band","band":"ground","span_ft":[2.0,9.0],"out_ft":4.83,"message":"m"}"#)
let farther = try item(#"{"kind":"band","band":"ground","span_ft":[2.0,8.0],"out_ft":5.10,"message":"m"}"#)
- let jitter = try item(#"{"kind":"band","band":"ground","span_ft":[1.95,8.05],"out_ft":4.88,"message":"m"}"#)
+ let jitter = try item(#"{"kind":"band","band":"ground","span_ft":[2.005,7.995],"out_ft":4.83,"message":"m"}"#)
let asked = try #require(planner.plan(for: first, leftEnd: -3, rightEnd: 4))
// After the first was raised, or skipped.
@@ -342,6 +344,85 @@ import Testing
in: [farther, first], leftEnd: -3, rightEnd: 4, limitEnds: [], asked: [], skipped: [], limit: 5).map { $0.item } == [farther])
}
+ /// #49: a request whose span runs past an asked or skipped one (or the item before it in the
+ /// same answer) by any amount is a new view: 0.009, 0.010, 0.011 or 0.09 ft past either edge,
+ /// with the server's exact span_ft. Spans are compared exactly; rounding tolerance is for
+ /// progress against real coverage, not for comparing two requests.
+ @Test(arguments: ["[2.0,8.009]", "[2.0,8.01]", "[2.0,8.011]", "[2.0,8.09]", "[1.991,8.0]", "[1.99,8.0]", "[1.989,8.0]", "[1.91,8.0]"])
+ func aRequestRunningPastAnEarlierOneIsANewView(spanFt: String) throws {
+ let planner = GapPlanner()
+ let old = try item(#"{"kind":"band","band":"ground","span_ft":[2.0,8.0],"out_ft":4.833334,"message":"m"}"#)
+ let oldPlan = try #require(planner.plan(for: old, leftEnd: -3, rightEnd: 4))
+ let extended = try item(#"{"kind":"band","band":"ground","span_ft":\#(spanFt),"out_ft":4.833334,"message":"m"}"#)
+ let plan = try #require(planner.plan(for: extended, leftEnd: -3, rightEnd: 4))
+ let span = try #require(extended.spanFt)
+ #expect(plan.requestedSpanFt == min(span.x, span.y)...max(span.x, span.y), "the request keeps the server's exact span_ft")
+ #expect(!plan.asksForSameView(as: oldPlan), "\(spanFt)")
+ let afterAsked = planner.serverRequests(in: [extended], leftEnd: -3, rightEnd: 4, limitEnds: [], asked: [oldPlan], skipped: [], limit: 5)
+ let afterSkipped = planner.serverRequests(in: [extended], leftEnd: -3, rightEnd: 4, limitEnds: [], asked: [oldPlan], skipped: [oldPlan], limit: 5)
+ let oneAnswer = planner.serverRequests(in: [old, extended], leftEnd: -3, rightEnd: 4, limitEnds: [], asked: [], skipped: [], limit: 5)
+ #expect(afterAsked.map { $0.item } == [extended], "after asked for \(spanFt)")
+ #expect(afterSkipped.map { $0.item } == [extended], "after skipped for \(spanFt)")
+ #expect(oneAnswer.map { $0.item } == [old, extended], "one answer for \(spanFt)")
+ // The limit and the homeowner's stop are unchanged: no room left raises nothing.
+ #expect(planner.serverRequests(in: [extended], leftEnd: -3, rightEnd: 4, limitEnds: [], asked: [oldPlan], skipped: [], limit: 0).isEmpty)
+ }
+
+ /// Root's review of d837c4e4: the rounding allowance must not stack. Coverage seen to exactly
+ /// [2, 8] ft meets [2, 8.0085] ft, since progress reads its 0.0085 ft tail as rounding. The
+ /// next request, [2, 8.0175] ft, runs only 0.009 ft past that one, but 0.0175 ft past the
+ /// coverage: progress is about 0.997, so it stays available.
+ @Test func aRoundingAllowanceUsedOnceIsNotUsedAgain() throws {
+ let planner = GapPlanner()
+ let old = try item(#"{"kind":"band","band":"ground","span_ft":[2.0,8.0085],"out_ft":4.833334,"message":"m"}"#)
+ let next = try item(#"{"kind":"band","band":"ground","span_ft":[2.0,8.0175],"out_ft":4.833334,"message":"m"}"#)
+ let oldPlan = try #require(planner.plan(for: old, leftEnd: -3, rightEnd: 4))
+ let nextPlan = try #require(planner.plan(for: next, leftEnd: -3, rightEnd: 4))
+ let feetToMeters = 1 / SceneUnits.feetPerMeter
+ let seen = Float(2.0 * feetToMeters)...Float(8.0 * feetToMeters)
+ #expect(GapPlanner.fraction(of: oldPlan.requestedSpanInFeet, coveredBy: [seen]) == 1)
+ let progress = GapPlanner.fraction(of: nextPlan.requestedSpanInFeet, coveredBy: [seen])
+ #expect(abs(progress - (1 - 0.0175 / 6.0175)) < 1e-9, "progress \(progress)")
+ #expect(!nextPlan.asksForSameView(as: oldPlan))
+ for skipped in [[GapPlan](), [oldPlan]] {
+ #expect(planner.serverRequests(in: [next], leftEnd: -3, rightEnd: 4, limitEnds: [], asked: [oldPlan], skipped: skipped, limit: 5).map { $0.item } == [next])
+ }
+ }
+
+ /// Equal and contained requests stay the same view, after asked, after skipped and within one
+ /// answer, including an edge moved inward by 0.009 ft.
+ @Test func anEqualOrContainedRequestIsTheSameView() throws {
+ let planner = GapPlanner()
+ let old = try item(#"{"kind":"band","band":"ground","span_ft":[2.0,8.0],"out_ft":4.833334,"message":"m"}"#)
+ let oldPlan = try #require(planner.plan(for: old, leftEnd: -3, rightEnd: 4))
+ for json in ["[2.0,8.0]", "[2.009,8.0]", "[2.0,7.991]", "[3.0,7.0]"] {
+ let inside = try item(#"{"kind":"band","band":"ground","span_ft":\#(json),"out_ft":4.833334,"message":"m"}"#)
+ let plan = try #require(planner.plan(for: inside, leftEnd: -3, rightEnd: 4))
+ #expect(plan.asksForSameView(as: oldPlan), "\(json)")
+ #expect(planner.serverRequests(in: [inside], leftEnd: -3, rightEnd: 4, limitEnds: [], asked: [oldPlan], skipped: [oldPlan], limit: 5).isEmpty, "\(json)")
+ #expect(planner.serverRequests(in: [old, inside], leftEnd: -3, rightEnd: 4, limitEnds: [], asked: [], skipped: [], limit: 5).map { $0.item } == [old], "\(json)")
+ }
+ }
+
+ /// #49: a reach is compared exactly, as progress compares it. Any reach above the old one is
+ /// new evidence; an equal or lower one isn't. Requests built in meters (no requestedOutFt or
+ /// requestedSpanFt) compare their meters converted, as progress reads them.
+ @Test func aStricterReachIsANewViewAndAnEqualOneIsNot() throws {
+ let planner = GapPlanner()
+ let old = try #require(planner.plan(for: item(#"{"kind":"band","band":"ground","span_ft":[2.0,8.0],"out_ft":4.833334,"message":"m"}"#), leftEnd: -3, rightEnd: 4))
+ let stricter = try #require(planner.plan(for: item(#"{"kind":"band","band":"ground","span_ft":[2.0,8.0],"out_ft":4.833335,"message":"m"}"#), leftEnd: -3, rightEnd: 4))
+ let equal = try #require(planner.plan(for: item(#"{"kind":"band","band":"ground","span_ft":[2.0,8.0],"out_ft":4.833334,"message":"m"}"#), leftEnd: -3, rightEnd: 4))
+ let lower = try #require(planner.plan(for: item(#"{"kind":"band","band":"ground","span_ft":[2.0,8.0],"out_ft":4.8,"message":"m"}"#), leftEnd: -3, rightEnd: 4))
+ #expect(!stricter.asksForSameView(as: old))
+ #expect(equal.asksForSameView(as: old))
+ #expect(lower.asksForSameView(as: old))
+ let inMeters = GapPlan(band: .ground, span: 0.6...2.4, reason: .server, need: .groundOut(1.5))
+ #expect(inMeters.requestedSpanInFeet == SceneExport.round4(Double(Float(0.6)) * SceneUnits.feetPerMeter)...SceneExport.round4(Double(Float(2.4)) * SceneUnits.feetPerMeter))
+ #expect(inMeters.asksForSameView(as: inMeters))
+ #expect(!GapPlan(band: .ground, span: 0.6...2.4, reason: .server, need: .groundOut(1.51)).asksForSameView(as: inMeters))
+ #expect(!GapPlan(band: .ground, span: 0.6...2.4, reason: .server, need: .walkOut(1.5)).asksForSameView(as: inMeters), "another kind of need")
+ }
+
/// An overhead request without a height takes any tilt-up view, so it asks for no more than
/// one with a height; one with a height asks for more than one without.
@Test func anOverheadRequestWithAHeightAsksForMoreThanOneWithout() throws {
diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/UploadPackagingTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/UploadPackagingTests.swift
new file mode 100644
index 000000000..c9172291c
--- /dev/null
+++ b/ios/HouseScanKit/Tests/HouseScanKitTests/UploadPackagingTests.swift
@@ -0,0 +1,360 @@
+import Foundation
+import HouseScanKit
+import simd
+import Testing
+
+// The upload's packaging step (`UploadPackaging.package`), the function `ScanEngine.upload` calls
+// between capturing its scene and packet (`captureUpload`) and writing the bundle and submitting.
+// The measurement is held at the point where the engine's main actor is free, the source is
+// changed while it is held, and the result is checked once it resumes.
+//
+// Boundary: these tests run the package's real `package` with the real mesh measurement behind a
+// gate. The app target has no unit tests, so `Source` below stands in for the engine: its
+// `capture` reads the same kinds of state the engine's `captureUpload` does in one synchronous
+// turn, and its `upload` keeps the engine's order (capture, mark the scene packaged, package, then
+// bundle and submit). The engine's own wiring of that order, and `answerAfter`'s use of
+// `GroundFreshness`, are covered by review, not by these tests; the policy itself is covered by
+// `GroundFreshnessTests`.
+
+/// What the tests' packet carries: the geometry the engine's `PacketInputs` captures with the
+/// scene, and the scene bytes attached by `package`.
+struct TestPacket: Sendable, Equatable {
+ var wall: SceneWall
+ var groundWorldY: Float
+ var marks: [SIMD3]
+ var scene = Data()
+}
+
+/// Holds a measurement until the test releases it, and records what it was asked to measure.
+actor MeasurementGate {
+ private(set) var walls: [WallFrame] = []
+ private(set) var spans: [ClosedRange] = []
+ private var arrival: CheckedContinuation?
+ private var held: CheckedContinuation?
+ private var released = false
+
+ private func hold(_ wall: WallFrame, _ span: ClosedRange) async {
+ walls.append(wall)
+ spans.append(span)
+ arrival?.resume()
+ arrival = nil
+ guard !released else { return }
+ await withCheckedContinuation { held = $0 }
+ }
+
+ /// Returns once a measurement is held.
+ func arrived() async {
+ guard walls.isEmpty else { return }
+ await withCheckedContinuation { arrival = $0 }
+ }
+
+ func release() {
+ released = true
+ held?.resume()
+ held = nil
+ }
+
+ /// The real measurement, held first.
+ nonisolated var measure: UploadPackaging.Measure {
+ { mesh, wall, span in
+ await self.hold(wall, span)
+ return await UploadPackaging.measureOffActor(mesh, wall, span)
+ }
+ }
+}
+
+/// The scan the engine would hold: a coverage map, the exported stretch, the marks (a gas meter's
+/// tap, world meters) and the LiDAR mesh; and the upload state `upload` keeps as the engine does.
+@MainActor
+final class Source {
+ var map: CoverageMap
+ var span: ClosedRange = -3...3
+ var marks: [SIMD3]
+ var mesh: TriangleMesh?
+ var hasPacket = true
+
+ var generation = 0
+ var freshness = GroundFreshness()
+ var scenePackaged = false
+ var uploadTask: Task?
+ var failed = false
+ /// Scenes attached to a packet, bundles written and scenes submitted, in order.
+ var attached = 0
+ var bundles: [TestPacket] = []
+ var submits: [Data] = []
+
+ init(groundY: Float, mesh: TriangleMesh?) {
+ map = CoverageMap(wall: WallFrame(meter: SIMD3(0, 1.5 + groundY, 0), outward: SIMD3(0, 0, 1), groundY: groundY)!)
+ marks = [SIMD3(1.5, 1.0 + groundY, 0)]
+ self.mesh = mesh
+ }
+
+ /// Everything one upload sends, read in this one turn, as `ScanEngine.captureUpload` does: the
+ /// scene with its ground moved to zero, the wall in world meters, the mesh and the packet.
+ func capture() -> UploadPackaging {
+ let wall = map.wall
+ let drop = SIMD3(0, wall.groundY, 0)
+ let worldWall = SceneWall(meter: wall.meter, outward: wall.outward, groundY: wall.groundY)
+ let scene = SceneInput(
+ wall: SceneWall(meter: wall.meter - drop, outward: wall.outward, groundY: 0),
+ baselineS: span,
+ features: marks.map { .pointObject(kind: .gasMeter, tap: $0 - drop, bottom: nil, top: nil) },
+ coverage: SceneCoverage(map, leftEndMarked: false, rightEndMarked: false))
+ let packet = hasPacket ? TestPacket(wall: worldWall, groundWorldY: wall.groundY, marks: marks) : nil
+ return UploadPackaging(scene: scene, worldWall: wall, mesh: mesh, packet: packet)
+ }
+
+ /// `ScanEngine.upload` from its capture on: mark the scene packaged, package, and only a
+ /// current upload writes its bundle and submits.
+ func upload(_ gate: MeasurementGate) async {
+ let scan = generation
+ guard !Task.isCancelled else { return }
+ let capture = capture()
+ scenePackaged = true
+ let packaged = try? await capture.package(
+ measure: gate.measure,
+ attachScene: { packet, data in
+ self.attached += 1
+ packet.scene = data
+ },
+ isCurrent: { scan == self.generation && !Task.isCancelled })
+ guard let packaged else { return }
+ if let packet = packaged.packet { bundles.append(packet) }
+ submits.append(packaged.scene)
+ }
+
+ func startUpload(_ gate: MeasurementGate) {
+ scenePackaged = false
+ uploadTask?.cancel()
+ uploadTask = Task { await upload(gate) }
+ }
+
+ /// `ScanEngine.answerAfter` on the upload screen.
+ @discardableResult
+ func changed(_ change: GroundFreshness.Change, replacement: MeasurementGate?) -> GroundFreshness.Action {
+ let action = freshness.after(change, on: .sending, scenePackaged: scenePackaged)
+ switch action {
+ case .keep:
+ break
+ case .sendAgain:
+ startUpload(replacement ?? MeasurementGate())
+ case .fail:
+ uploadTask?.cancel()
+ uploadTask = nil
+ failed = true
+ }
+ return action
+ }
+
+ /// The ground refined: same meter and axes, the ground `by` higher.
+ func raiseGround(by rise: Float) {
+ let wall = map.wall
+ map.updateWall(WallFrame(meter: wall.meter, outward: wall.outward, groundY: wall.groundY + rise)!)
+ }
+}
+
+/// The standard wall's surroundings with the ground `groundY` up: the wall face z = 0, the ground,
+/// a fence 2.0 m out over s in [-1, 1] and an eave 2.5 m above the ground over the same stretch.
+func raisedScene(groundY g: Float) -> TriangleMesh {
+ var builder = MeshBuilder()
+ builder.quad(SIMD3(-10, g, 0), SIMD3(10, g, 0), SIMD3(10, g + 4, 0), SIMD3(-10, g + 4, 0))
+ builder.quad(SIMD3(-10, g, 0), SIMD3(-10, g, 10), SIMD3(10, g, 10), SIMD3(10, g, 0))
+ builder.box(SIMD3(-1, g, 2.0), SIMD3(1, g + 1.5, 2.2))
+ builder.box(SIMD3(-1, g + 2.5, 0), SIMD3(1, g + 2.7, 1.0))
+ return builder.mesh
+}
+
+@MainActor
+@Suite struct UploadPackagingTests {
+ /// The world's ground is 0.5 m up, so a wall with its ground moved to zero would put every
+ /// overhead ray's start 0.5 m under the mesh's ground.
+ static let groundY: Float = 0.5
+
+ /// The captured scene serialized with the mesh measured against the captured world wall and
+ /// span: what an upload of `capture` must send, byte for byte.
+ static func expected(_ capture: UploadPackaging) throws -> Data {
+ var input = capture.scene
+ if let mesh = capture.mesh {
+ input.meshFacing = mesh.facingSpans(wall: capture.worldWall, over: capture.span)
+ input.meshOverheads = mesh.overheadSpans(wall: capture.worldWall, over: capture.span)
+ }
+ return try SceneExport.jsonData(input)
+ }
+
+ /// Boundary: `package` with the real measurement. Nothing changes while it runs; the fence
+ /// and eave come back measured from the world's ground (6.5 ft and 8.2 ft, rounded down to
+ /// 0.1 ft), in the scene and in the packet's copy of it.
+ @Test func anUnchangedScanKeepsItsFacingAndOverheadMeasurements() async throws {
+ let source = Source(groundY: Self.groundY, mesh: raisedScene(groundY: Self.groundY))
+ let gate = MeasurementGate()
+ await gate.release()
+ let capture = source.capture()
+ let packaged = try #require(try await capture.package(measure: gate.measure, attachScene: { $0.scene = $1 }, isCurrent: { true }))
+
+ #expect(await gate.walls == [source.map.wall])
+ #expect(await gate.walls.first?.groundY == Self.groundY)
+ // Measured against a wall with its ground at zero, the overhead rays would start under
+ // the mesh's ground and meet it 0.5 m (1.6 ft) up.
+ #expect(packaged.measurement.facing.contains { nearlyEqual($0.out, 6.5 * 0.3048) })
+ #expect(packaged.measurement.overheads.contains { nearlyEqual($0.out, 8.2 * 0.3048) })
+ #expect(packaged.measurement.overheads.allSatisfy { $0.out > 2 })
+ #expect(packaged.scene == (try Self.expected(capture)))
+ #expect(packaged.packet?.scene == packaged.scene)
+ }
+
+ /// Boundary: `package` held mid-measurement while the source's ground, wall, span and marks
+ /// all change. What it sends is the capture, measured against the captured wall: the scene's
+ /// bytes, the packet's geometry and the packet's copy of the scene, which a capture taken
+ /// after the change would not match.
+ @Test func changesWhileTheMeshIsMeasuredDontReachTheUpload() async throws {
+ let source = Source(groundY: Self.groundY, mesh: raisedScene(groundY: Self.groundY))
+ let gate = MeasurementGate()
+ let capture = source.capture()
+ let worldWall = source.map.wall
+ let task = Task { try await capture.package(measure: gate.measure, attachScene: { $0.scene = $1 }, isCurrent: { true }) }
+ await gate.arrived()
+
+ source.raiseGround(by: 0.12)
+ source.map.updateWall(WallFrame(meter: source.map.wall.meter + SIMD3(0.3, 0, 0), outward: SIMD3(0, 0, 1), groundY: source.map.wall.groundY)!)
+ source.span = -2...4
+ source.marks = [SIMD3(-1, 1.4, 0)]
+ let later = source.capture()
+ await gate.release()
+ let packaged = try #require(try await task.value)
+
+ #expect(await gate.walls == [worldWall])
+ #expect(await gate.spans == [-3...3])
+ #expect(packaged.scene == (try Self.expected(capture)))
+ #expect(packaged.scene != (try Self.expected(later)))
+ let packet = try #require(packaged.packet)
+ #expect(packet.wall == SceneWall(meter: worldWall.meter, outward: worldWall.outward, groundY: worldWall.groundY))
+ #expect(packet.groundWorldY == Self.groundY)
+ #expect(packet.marks == [SIMD3(1.5, 1.0 + Self.groundY, 0)])
+ #expect(packet.scene == packaged.scene)
+ }
+
+ /// Boundary: `Source.upload` around `package`, held mid-measurement while an anchor correction
+ /// moves the wall, its ground and the marks as one body. The rule keeps the upload, nothing is
+ /// sent again, and the upload completes with the geometry it captured.
+ @Test func anAnchorCorrectionKeepsTheCapturedUploadWithoutResending() async throws {
+ let source = Source(groundY: Self.groundY, mesh: raisedScene(groundY: Self.groundY))
+ let gate = MeasurementGate()
+ let capture = source.capture()
+ source.startUpload(gate)
+ let first = try #require(source.uploadTask)
+ await gate.arrived()
+
+ let correction = YawCorrection(yaw: 0.05, translation: SIMD3(0.2, 0.04, -0.1))
+ source.map.apply(correction)
+ source.marks = source.marks.map { correction.direction($0) + correction.translation }
+ #expect(source.changed(.anchorCorrection, replacement: nil) == .keep)
+ #expect(source.uploadTask == first)
+ #expect(!source.freshness.awaitingNewAnswer)
+ await gate.release()
+ await first.value
+
+ #expect(source.submits == [try Self.expected(capture)])
+ #expect(source.bundles.map(\.wall) == [capture.packet?.wall])
+ #expect(source.bundles.map(\.marks) == [capture.packet?.marks])
+ #expect(source.bundles.map(\.scene) == source.submits)
+ #expect(!source.failed)
+ }
+
+ /// Boundary: `Source.upload` around `package` with `GroundFreshness` deciding. A ground change
+ /// mid-measurement withdraws the first upload, whose completion then sends nothing, and the
+ /// replacement, captured on the new ground, is what is bundled and submitted.
+ @Test func aGroundChangeRejectsTheOldUploadAndItsReplacementIsSent() async throws {
+ let source = Source(groundY: Self.groundY, mesh: raisedScene(groundY: Self.groundY))
+ let firstGate = MeasurementGate()
+ source.startUpload(firstGate)
+ let first = try #require(source.uploadTask)
+ await firstGate.arrived()
+
+ source.raiseGround(by: 0.12)
+ let secondGate = MeasurementGate()
+ #expect(source.changed(.ground, replacement: secondGate) == .sendAgain)
+ let second = try #require(source.uploadTask)
+ await secondGate.arrived()
+ let replacement = source.capture()
+ await firstGate.release()
+ await first.value
+ #expect(source.submits.isEmpty)
+ #expect(source.bundles.isEmpty)
+ #expect(source.attached == 0)
+
+ await secondGate.release()
+ await second.value
+ #expect(await secondGate.walls.first?.groundY == Self.groundY + 0.12)
+ #expect(source.submits == [try Self.expected(replacement)])
+ #expect(source.bundles.map(\.groundWorldY) == [Self.groundY + 0.12])
+ #expect(source.bundles.map(\.scene) == source.submits)
+ }
+
+ /// Boundary: as above, with a second ground change while the replacement is measured. The
+ /// rule fails the upload, neither completion sends anything, and a further change keeps the
+ /// failure rather than sending again.
+ @Test func aSecondGroundChangeKeepsTheFailure() async throws {
+ let source = Source(groundY: Self.groundY, mesh: raisedScene(groundY: Self.groundY))
+ let firstGate = MeasurementGate()
+ source.startUpload(firstGate)
+ let first = try #require(source.uploadTask)
+ await firstGate.arrived()
+ source.raiseGround(by: 0.12)
+ let secondGate = MeasurementGate()
+ #expect(source.changed(.ground, replacement: secondGate) == .sendAgain)
+ let second = try #require(source.uploadTask)
+ await secondGate.arrived()
+
+ source.raiseGround(by: 0.05)
+ #expect(source.changed(.ground, replacement: nil) == .fail)
+ #expect(source.uploadTask == nil)
+ await firstGate.release()
+ await secondGate.release()
+ await first.value
+ await second.value
+ #expect(source.failed)
+ #expect(source.submits.isEmpty)
+ #expect(source.bundles.isEmpty)
+ #expect(source.attached == 0)
+ source.raiseGround(by: 0.05)
+ #expect(source.changed(.ground, replacement: nil) == .fail)
+ #expect(source.uploadTask == nil)
+ }
+
+ /// Boundary: `Source.upload` around `package`. A reset (a new scan generation) or a
+ /// cancelled task while the mesh is measured: the completion serializes nothing into a
+ /// packet, and nothing is bundled or submitted.
+ @Test(arguments: [false, true])
+ func aResetOrCancelledUploadSendsNothing(cancel: Bool) async throws {
+ let source = Source(groundY: Self.groundY, mesh: raisedScene(groundY: Self.groundY))
+ let gate = MeasurementGate()
+ source.startUpload(gate)
+ let task = try #require(source.uploadTask)
+ await gate.arrived()
+ if cancel { task.cancel() } else { source.generation += 1 }
+ await gate.release()
+ await task.value
+ #expect(source.attached == 0)
+ #expect(source.bundles.isEmpty)
+ #expect(source.submits.isEmpty)
+ }
+
+ /// Boundary: `package` alone. Without a mesh nothing is measured, and the scene still goes,
+ /// with its packet's copy; without a packet the scene still goes on its own.
+ @Test func noMeshOrNoPacketStillSendsTheScene() async throws {
+ let source = Source(groundY: Self.groundY, mesh: nil)
+ let gate = MeasurementGate()
+ let capture = source.capture()
+ let packaged = try #require(try await capture.package(measure: gate.measure, attachScene: { $0.scene = $1 }, isCurrent: { true }))
+ #expect(await gate.walls.isEmpty)
+ #expect(packaged.measurement == .init())
+ #expect(packaged.scene == (try Self.expected(capture)))
+ #expect(packaged.packet?.scene == packaged.scene)
+
+ source.hasPacket = false
+ let alone = source.capture()
+ let sent = try #require(try await alone.package(measure: gate.measure, attachScene: { $0.scene = $1 }, isCurrent: { true }))
+ #expect(sent.packet == nil)
+ #expect(sent.scene == (try Self.expected(alone)))
+ }
+}
diff --git a/ios/HouseScanUITests/FullFlowUITests.swift b/ios/HouseScanUITests/FullFlowUITests.swift
index 19b8de04d..a1c3be375 100644
--- a/ios/HouseScanUITests/FullFlowUITests.swift
+++ b/ios/HouseScanUITests/FullFlowUITests.swift
@@ -50,7 +50,25 @@ final class FullFlowUITests: XCTestCase {
@MainActor
func testFullFlowFromReplay() throws {
let replay = Self.environment["HOUSESCAN_REPLAY"].flatMap { $0.isEmpty ? nil : $0 } ?? Self.fixture
- try runFlow(replay: replay)
+ // A real server's answer can be any decision; the bundled sample is always a manual review.
+ let sample = Self.environment["HOUSESCAN_SERVER_URL"]?.isEmpty ?? true
+ try runFlow(replay: replay) { app, phase in
+ if sample, phase == "result" { Self.assertInstallerReviewCopy(app) }
+ }
+ }
+
+ /// The sample result needs a person to settle it (`SampleResult.json`). The app only shows
+ /// the answer and contacts nobody, so its words say what the result needs, never that an
+ /// installer will confirm or review it.
+ @MainActor
+ private static func assertInstallerReviewCopy(_ app: XCUIApplication) {
+ func label(_ identifier: String) -> String? {
+ let element = app.descendants(matching: .any)[identifier].firstMatch
+ return element.exists ? element.label : nil
+ }
+ XCTAssertEqual(label("result.headline"), "Needs an installer's review")
+ XCTAssertEqual(label("result.installerConfirms"), "Before any battery goes in, an installer has to confirm where it goes on site.")
+ XCTAssertEqual(label("result.rulesNotFinal"), "The placement rules aren't final yet, so every result needs an installer's review for now.")
}
/// The flow from the LiDAR fixture. Depth must show the bin in front of the wall: the wall map
diff --git a/ios/HouseScanUITests/GroundFreshnessUITests.swift b/ios/HouseScanUITests/GroundFreshnessUITests.swift
index c3b99f194..3d21cdb0b 100644
--- a/ios/HouseScanUITests/GroundFreshnessUITests.swift
+++ b/ios/HouseScanUITests/GroundFreshnessUITests.swift
@@ -87,6 +87,48 @@ final class GroundFreshnessUITests: XCTestCase {
XCTAssertTrue(app.buttons["action.retryUpload"].exists)
}
+ @MainActor
+ func testAGroundRefineAfterFirstPackagingResendsBeforeShowingTheAnswer() throws {
+ let files = FileManager.default
+ let gate = URL(fileURLWithPath: NSTemporaryDirectory()).appending(path: "housescan-gate-\(UUID().uuidString)", directoryHint: .isDirectory)
+ try files.createDirectory(at: gate, withIntermediateDirectories: true)
+ defer { try? files.removeItem(at: gate) }
+ // Hold the first answer before it can open a spot check or an automatic server request.
+ for phase in ["onboarding", "findMeter", "meterCloseUp", "wallWalk", "markFeatures", "gapRequest", "spotConfirm"] {
+ try Data().write(to: gate.appending(path: phase))
+ }
+ let app = XCUIApplication()
+ app.launchArguments = [
+ "-replay", FullFlowUITests.fixture, "-autopilot", "-autopilotHold", "1.5", "-autopilotGate", gate.path,
+ "-practiceMeter", "NO", "-sampleResult", "-injectGroundRise", "0.05",
+ ]
+ app.launch()
+ let any = app.descendants(matching: .any)
+ let result = any["screen.result"]
+ let spotCheck = any["screen.spotConfirm"]
+ let uploading = any["screen.uploading"]
+ let held = gate.appending(path: "uploading.held")
+ XCTAssertTrue(waitUntil(timeout: 300) { files.fileExists(atPath: held.path) }, "the first packaged upload never held its answer")
+ XCTAssertTrue(uploading.exists)
+ XCTAssertFalse(result.exists)
+ XCTAssertFalse(spotCheck.exists)
+
+ // The first upload is still awaiting presentation. A new held marker can only come
+ // from another completed upload, not from the first upload's already-waiting gate.
+ try files.removeItem(at: held)
+ let trigger = gate.appending(path: "inject-ground")
+ try Data().write(to: trigger)
+ XCTAssertTrue(waitUntil(timeout: 10) { !files.fileExists(atPath: trigger.path) }, "the first upload never took the injected ground")
+ XCTAssertTrue(waitUntil(timeout: 60) { files.fileExists(atPath: held.path) }, "a ground change after first packaging did not resend")
+ XCTAssertTrue(uploading.exists)
+ XCTAssertFalse(result.exists, "the withdrawn first answer reached the result")
+ XCTAssertFalse(spotCheck.exists, "the withdrawn first answer reached the spot check")
+ XCTAssertFalse(app.buttons["action.retryUpload"].exists, "the first change failed instead of using its one resend")
+
+ try Data().write(to: gate.appending(path: "uploading"))
+ XCTAssertTrue(result.waitForExistence(timeout: 120), "the resend's answer never reached the result")
+ }
+
private func waitUntil(timeout: TimeInterval, _ condition: () -> Bool) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
repeat {
diff --git a/ios/HouseScanUITests/InstallerReviewUITests.swift b/ios/HouseScanUITests/InstallerReviewUITests.swift
new file mode 100644
index 000000000..d9eafe245
--- /dev/null
+++ b/ios/HouseScanUITests/InstallerReviewUITests.swift
@@ -0,0 +1,43 @@
+import XCTest
+
+/// The result says what it needs from a person, never that one was contacted: the app only shows
+/// the server's answer. Held still on the demo's review sample (`DemoEngine.reviewSample`), whose
+/// window check needs a person and whose ground check needs a photo. Its headline is "One more
+/// look", since a view can still be captured; `FullFlowUITests.testFullFlowFromReplay` checks the
+/// installer headline on the bundled sample.
+final class InstallerReviewUITests: XCTestCase {
+ override func setUp() {
+ continueAfterFailure = false
+ }
+
+ @MainActor
+ func testTheResultSaysAnInstallerIsNeededNotArranged() throws {
+ let app = XCUIApplication()
+ app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoFreeze", "-uiDemoPhase", "result"]
+ app.launch()
+ XCTAssertTrue(element(app, "screen.result").waitForExistence(timeout: 15))
+ XCTAssertEqual(element(app, "result.installerConfirms").label, "Before any battery goes in, an installer has to confirm where it goes on site.")
+
+ // The full list of checks sits under Details, below the footnotes.
+ let details = element(app, "result.details")
+ XCTAssertTrue(details.waitForExistence(timeout: 10))
+ for _ in 0..<4 where !details.isHittable { app.swipeUp() }
+ details.tap()
+ let window = element(app, "detail.check.window")
+ let ground = element(app, "detail.check.ground")
+ XCTAssertTrue(window.waitForExistence(timeout: 10), "the window check never appeared under Details")
+ // The two unsure checks keep their difference: a person settles one, a photo the other.
+ XCTAssertTrue(value(window).contains("Needs an installer to check"), "window check reads: \(value(window))")
+ XCTAssertTrue(value(ground).contains("One more photo would settle this"), "ground check reads: \(value(ground))")
+ }
+
+ @MainActor
+ private func element(_ app: XCUIApplication, _ identifier: String) -> XCUIElement {
+ app.descendants(matching: .any)[identifier].firstMatch
+ }
+
+ @MainActor
+ private func value(_ element: XCUIElement) -> String {
+ element.value as? String ?? ""
+ }
+}
diff --git a/ios/HouseScanUITests/ScreenStatesUITests.swift b/ios/HouseScanUITests/ScreenStatesUITests.swift
index c0f589fe3..3002e501d 100644
--- a/ios/HouseScanUITests/ScreenStatesUITests.swift
+++ b/ios/HouseScanUITests/ScreenStatesUITests.swift
@@ -1,3 +1,4 @@
+import UIKit
import XCTest
/// Every screen state, including ones a replay never reaches (camera denied, offline upload,
@@ -84,7 +85,7 @@ final class ScreenStatesUITests: XCTestCase {
/// The screens with the most text, also checked at AX5.
private static let largestTextStates: Set = [
- "onboarding", "onboarding-practice", "onboarding-moves", "onboarding-permissions", "wallWalk", "wallWalk-endQuestion", "wallWalk-endPreview", "wallWalk-endQuestionLeavesOut", "wallWalk-nextWallRefused", "wallWalk-overheadQuestion", "gapRequest-walkOut", "gapRequest-overheadQuestion", "meterCloseUp-cantGetClearShot", "meterCloseUp-chooseNumber",
+ "onboarding", "onboarding-practice", "onboarding-moves", "onboarding-permissions", "findMeter", "wallWalk", "wallWalk-endQuestion", "wallWalk-endPreview", "wallWalk-endQuestionLeavesOut", "wallWalk-nextWallRefused", "wallWalk-overheadQuestion", "gapRequest-walkOut", "gapRequest-overheadQuestion", "meterCloseUp-cantGetClearShot", "meterCloseUp-chooseNumber",
"markFeatures", "gapRequest", "uploading-offline", "uploading-rejected", "result-review", "cameraDenied",
"wallWalk-hidden", "wallWalk-seeBehind", "wallWalk-fullLegend", "gapRequest-followUp", "uploading-followUp",
"markFeatures-groundQuestion", "markFeatures-groundAnswered", "markFeatures-lostPlace",
@@ -102,6 +103,10 @@ final class ScreenStatesUITests: XCTestCase {
"wallWalk-hidden": [("wallTape", "2 sections hidden behind something")],
"wallWalk-fullLegend": [("wallTape", "2 sections hidden behind something")],
"wallWalk-seeBehind": [("instruction", "Something is in front of the wall here")],
+ // At AX5 looking past it leads, and the situation folds under Details.
+ "wallWalk-seeBehind-AX5": [("instruction", "Look around it"), ("instruction.details", "Details")],
+ // At AX5 the meter's description folds under Details, and the camera stays open.
+ "findMeter-AX5": [("instruction", "Find your electric meter"), ("instruction.details", "Details")],
"gapRequest-followUp": [("instruction", "One more view to finish")],
// #75: a server request's stretch by its two ends, not its middle.
"gapRequest-groundOut": [("instruction", "From 4 ft to 7 ft right of your meter.")],
@@ -132,6 +137,9 @@ final class ScreenStatesUITests: XCTestCase {
"resultAR": [("ar.overlay", "drawn on your wall")],
// #81: the aim ring fills as its stretch is captured.
"wallWalk-aim": [("aim.ring", "50 percent captured")],
+ // At AX5 the aim card folds its how-to words and the ring's legend under Details
+ // (testAimCameraStaysOpenAtLargestTextSize).
+ "wallWalk-aim-AX5": [("instruction", "Tilt down to show the ground"), ("instruction.details", "Details")],
// #82: a second "Can't get there" soon after the first asks before ending the scan.
"wallWalk-endScanQuestion": [("instruction", "End the scan here?")],
"wallWalk-endScanTooShort": [("instruction", "You haven't walked enough of the wall")],
@@ -427,7 +435,8 @@ final class ScreenStatesUITests: XCTestCase {
app.launch()
XCTAssertTrue(element(app, "action.keepWalking").waitForExistence(timeout: 15))
XCTAssertFalse(element(app, "action.cannotAccess").exists, "the card's reply must wait for the answer")
- tap(app, "action.keepWalking", timeout: 5)
+ // Run 36841096811: on a slow runner each hittability check took 1-2 s and 5 s ran out.
+ tap(app, "action.keepWalking", timeout: 15)
XCTAssertTrue(element(app, "action.keepWalking").waitForNonExistence(timeout: 5), "the answer must close the question")
XCTAssertTrue(element(app, "screen.wallWalk").exists, "Keep walking must stay on the walk")
app.terminate()
@@ -467,6 +476,7 @@ final class ScreenStatesUITests: XCTestCase {
XCTAssertTrue(label.contains("Walk slowly to your right"), "the task must stay on the card, got \(label)")
XCTAssertTrue(label.contains("Keep the wall and the ground in view"), "the task's second line must stay on the card, got \(label)")
XCTAssertTrue(label.contains("It's dark here"), "the coaching must show on the card, got \(label)")
+ XCTAssertTrue(label.contains("flashlight"), "the dark coaching must say what would help, got \(label)")
}
/// #80, as on the walk: the capture gate's coaching keeps a gap request on the card. The dark
@@ -484,6 +494,7 @@ final class ScreenStatesUITests: XCTestCase {
XCTAssertTrue(label.contains("Show the ground"), "the request must stay on the card, got \(label)")
XCTAssertTrue(label.contains("a clear look from two places"), "the request's second line must stay on the card, got \(label)")
XCTAssertTrue(label.contains("It's dark here"), "the coaching must show on the card, got \(label)")
+ XCTAssertTrue(label.contains("flashlight"), "the dark coaching must say what would help, got \(label)")
}
/// B-09: "Add something" on the review opens the camera with the marking prompt, and the
@@ -508,6 +519,41 @@ final class ScreenStatesUITests: XCTestCase {
XCTAssertEqual(app.buttons.matching(identifier: "action.deleteFeature").count, rowsBefore + 1)
}
+ /// AX5 puts the review chips below the window. The helper must let the real tap scroll
+ /// before XCTest computes a hit point, then Cancel must return to the unchanged review.
+ @MainActor
+ func testTapHelperReachesAnOffscreenReviewChip() throws {
+ continueAfterFailure = false
+ let app = XCUIApplication()
+ app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoFreeze", "-uiDemoPhase", "markFeatures"] + Self.largestText
+ app.launch()
+ defer { app.terminate() }
+ XCTAssertTrue(element(app, "screen.markFeatures").waitForExistence(timeout: 15))
+ let chip = app.buttons.matching(identifier: "feature.ac").firstMatch
+ let frame = try chip.snapshot().frame
+ let window = try app.windows.firstMatch.snapshot().frame
+ XCTAssertGreaterThan(frame.maxY, window.maxY, "the regression needs a chip below the window")
+ let rowsBefore = app.buttons.matching(identifier: "action.deleteFeature").count
+ tap(app, "feature.ac")
+ XCTAssertTrue(element(app, "action.markPoint").waitForExistence(timeout: 5), "the tap must open the marking camera")
+ tap(app, "action.cancelMarking")
+ XCTAssertTrue(element(app, "action.confirmFeatures").waitForExistence(timeout: 5))
+ XCTAssertEqual(app.buttons.matching(identifier: "action.deleteFeature").count, rowsBefore)
+ }
+
+ @MainActor
+ func testTapHelperClosesARThroughTheDoneButton() throws {
+ continueAfterFailure = false
+ let app = XCUIApplication()
+ app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoFreeze", "-uiDemoPhase", "resultAR"]
+ app.launch()
+ defer { app.terminate() }
+ XCTAssertTrue(element(app, "screen.resultAR").waitForExistence(timeout: 15))
+ tap(app, "action.closeAR")
+ XCTAssertTrue(element(app, "screen.result").waitForExistence(timeout: 10), "Done must return to the result")
+ XCTAssertTrue(element(app, "screen.resultAR").waitForNonExistence(timeout: 5))
+ }
+
/// While the phone has lost its place the review can't start a mark, which taps into the
/// scene: the chips give way to a line saying so, and "Looks complete" still sends the scan.
@MainActor
@@ -557,11 +603,24 @@ final class ScreenStatesUITests: XCTestCase {
/// A refused upload offers the review, not "Try again"; from the review the scan is sent
/// again and reaches the result.
+ ///
+ /// The pass sample (`-uiDemoPass`) lists no view to take, so the resend goes straight to the
+ /// result. The review sample asked for one more, and one wait covered a second capture, two
+ /// uploads and the server's follow-up; on run 36736877861 it ran out with the app still
+ /// uploading at 60% (#191).
+ ///
+ /// The review's own view is left to the demo, which covers it and sends by itself: 1.2 s, then
+ /// six 0.45 s steps to covered, then 1.6 s (`DemoEngine.gapScript`). This test used to tap
+ /// "I can't get there" first, which raced that script. Counted from the start of the wait for
+ /// the view, the tap's event came 3.2 to 3.5 s in when it passed (runs 37001843593 and
+ /// 37001782118). On run 37013846307 it came at 4.3 s, and on 37013940503 the tap began at
+ /// 4.5 s; both times the button had gone. Skipping is checked frozen in
+ /// `testSkippingTheGapRequestSends`.
@MainActor
func testRejectedUploadGoesBackToReview() throws {
continueAfterFailure = false
let app = XCUIApplication()
- app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoPhase", "gapRequest", "-uiDemoRejected"]
+ app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoPhase", "gapRequest", "-uiDemoRejected", "-uiDemoPass"]
app.launch()
XCTAssertTrue(element(app, "action.backToReview").waitForExistence(timeout: 30))
XCTAssertFalse(element(app, "action.retryUpload").exists, "a refused scan must not offer Try again")
@@ -572,10 +631,29 @@ final class ScreenStatesUITests: XCTestCase {
tap(app, "action.confirmFeatures")
XCTAssertTrue(element(app, "review.unanswered").waitForExistence(timeout: 5))
tap(app, "action.confirmFeatures")
- tap(app, "action.spotClear", timeout: 40)
+ XCTAssertTrue(element(app, "screen.gapRequest").waitForExistence(timeout: 10), "the review must open its own view")
+ tap(app, "action.spotClear", timeout: 30)
XCTAssertTrue(element(app, "screen.result").waitForExistence(timeout: 20))
}
+ /// "I can't get there" on a gap request sends the scan. Frozen, the demo's gap script never
+ /// runs, so the request can't finish itself before the tap.
+ @MainActor
+ func testSkippingTheGapRequestSends() throws {
+ continueAfterFailure = false
+ let app = XCUIApplication()
+ app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoFreeze", "-uiDemoPhase", "gapRequest"]
+ app.launch()
+ XCTAssertTrue(element(app, "screen.gapRequest").waitForExistence(timeout: 15))
+ // The card's reply ignores taps for a moment after it appears (`InstructionCard.replyLock`).
+ let skip = element(app, "action.skipGap")
+ let ready = XCTNSPredicateExpectation(predicate: NSPredicate(format: "isEnabled == true AND isHittable == true"), object: skip)
+ XCTAssertEqual(XCTWaiter().wait(for: [ready], timeout: 10), .completed, "I can't get there never took taps")
+ skip.tap()
+ XCTAssertTrue(element(app, "screen.uploading").waitForExistence(timeout: 10), "skipping must send the scan")
+ XCTAssertFalse(element(app, "screen.gapRequest").exists)
+ }
+
/// #65 soft gate: with the ground or a window's question unanswered, the first "Looks
/// complete" stays on the review and says so; answering ("Not sure" counts) clears the line,
/// and the next tap sends. Unanswered, the second tap sends anyway.
@@ -624,16 +702,17 @@ final class ScreenStatesUITests: XCTestCase {
XCTAssertTrue(legend.label.contains("It fills as your phone captures this spot"), "legend reads \(legend.label)")
let card = element(app, "instruction")
XCTAssertFalse(legend.frame.intersects(card.frame), "the legend must keep clear of the card: \(legend.frame) vs \(card.frame)")
+ XCTAssertFalse(element(app, "instruction.details").exists, "at the default size every word stays on the card")
app.terminate()
- // At the largest text size the card fills most of the screen. The legend sits under it in
- // the same stack, so it grows and scrolls with the card instead of going behind it or
- // disappearing.
+ // At the largest text size the legend is folded under Details with the card's how-to
+ // words, and still reads in full once opened.
app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoFreeze", "-uiDemoPhase", "wallWalk", "-uiDemoAim"] + Self.largestText
app.launch()
XCTAssertTrue(element(app, "screen.wallWalk").waitForExistence(timeout: 15))
+ tap(app, "instruction.details")
let largeLegend = element(app, "aim.legend")
- XCTAssertTrue(largeLegend.waitForExistence(timeout: 5), "the legend must still show at the largest text size")
+ XCTAssertTrue(largeLegend.waitForExistence(timeout: 5), "the legend must show under Details at the largest text size")
XCTAssertTrue(largeLegend.label.contains("It fills as your phone captures this spot"), "legend reads \(largeLegend.label)")
let largeCard = element(app, "instruction")
XCTAssertFalse(largeLegend.frame.intersects(largeCard.frame), "the legend must keep clear of the card: \(largeLegend.frame) vs \(largeCard.frame)")
@@ -646,6 +725,430 @@ final class ScreenStatesUITests: XCTestCase {
XCTAssertFalse(element(app, "aim.legend").exists, "the legend goes with the ring")
}
+ // MARK: Largest text layout
+
+ /// The content width inside a camera screen's side margins (`Metrics.edge`, 16 pt), less a
+ /// point for rounding.
+ private static func contentWidth(_ window: CGRect) -> CGFloat { window.width - 2 * 16 - 1 }
+
+ /// B-28: side by side at AX5, "Mark something" and "Wall ends here" broke into one or two
+ /// letters a line. Every pair of wall-walk actions must stack there, each spanning the
+ /// content width. A button taller than a quarter of the screen is a label broken a letter a
+ /// line; a stacked two-line label is about 130 pt on an iPhone 17. At the default size the
+ /// walk's pair stays on one row.
+ @MainActor
+ func testWallWalkActionsStackAtLargestTextSize() throws {
+ continueAfterFailure = false
+ let app = XCUIApplication()
+ func launch(_ extra: [String], textSize: [String] = Self.largestText) {
+ app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoFreeze", "-uiDemoPhase", "wallWalk"] + extra + textSize
+ app.launch()
+ XCTAssertTrue(element(app, "screen.wallWalk").waitForExistence(timeout: 15))
+ }
+
+ launch([], textSize: [])
+ let mark = element(app, "action.markSomething"), end = element(app, "action.endHere")
+ XCTAssertTrue(mark.waitForExistence(timeout: 5) && end.waitForExistence(timeout: 5))
+ XCTAssertEqual(mark.frame.midY, end.frame.midY, accuracy: 1, "at the default size the walk's two actions share a row")
+ app.terminate()
+
+ launch([])
+ assertStacked(app, "action.markSomething", "action.endHere", name: "wallWalk-walking-AX5-stacked")
+ // The walk's reply ends the side here, which asks for the wall's end.
+ scrollToTop(app)
+ tapReply(app, "Can't get there")
+ assertStacked(app, "action.markSomething", "action.markEnd", name: "wallWalk-markEnd-AX5-stacked")
+ app.terminate()
+
+ launch(["-uiDemoTiltUp"])
+ assertStacked(app, "action.markSomething", "action.finishWalk", name: "wallWalk-finish-AX5-stacked")
+ app.terminate()
+
+ launch(["-uiDemoMarking", "window"])
+ assertStacked(app, "action.cancelMarking", "action.markPoint", name: "wallWalk-marking-AX5-stacked")
+ app.terminate()
+ }
+
+ @MainActor
+ private func assertStacked(_ app: XCUIApplication, _ upper: String, _ lower: String, name: String) {
+ let window = app.windows.firstMatch.frame
+ let first = element(app, upper), second = element(app, lower)
+ XCTAssertTrue(first.waitForExistence(timeout: 5), "\(name): missing \(upper)")
+ XCTAssertTrue(second.waitForExistence(timeout: 5), "\(name): missing \(lower)")
+ for (identifier, target) in [(upper, first), (lower, second)] {
+ let frame = target.frame
+ XCTAssertGreaterThanOrEqual(frame.width, Self.contentWidth(window), "\(name): \(identifier) is \(frame.width) pt wide in a \(window.width) pt window; beside another action its words break apart")
+ XCTAssertLessThanOrEqual(frame.height, window.height / 4, "\(name): \(identifier) is \(frame.height) pt tall; its label breaks a letter or two a line")
+ }
+ XCTAssertLessThanOrEqual(first.frame.maxY, second.frame.minY + 0.5, "\(name): \(upper) \(first.frame) must sit above \(lower) \(second.frame)")
+ XCTAssertTrue(scrollUntilHittable(second, in: app), "\(name): \(lower) can't be reached")
+ attach(app, name: name)
+ }
+
+ /// Drags the screen down until its top shows, for a control that scrolled out of view.
+ @MainActor
+ private func scrollToTop(_ app: XCUIApplication) {
+ let step = app.windows.firstMatch.frame.height * 0.4
+ for _ in 0..<3 {
+ let start = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.6))
+ start.press(forDuration: 0.1, thenDragTo: start.withOffset(CGVector(dx: 0, dy: step)), withVelocity: .slow, thenHoldForDuration: 0.3)
+ }
+ }
+
+ /// B-36: at AX5 the aim card filled the screen and hid the aim ring under it. On a step that
+ /// has the homeowner aim, the card now keeps the task and its reply in view, folds the how-to
+ /// words and the ring's legend under Details, and the chrome leaves the camera open between
+ /// the card and the actions. The spot's marker is in that open camera: the ring, or, while
+ /// the spot is under the card, an arrow toward it that reads the ring's progress. At no
+ /// scroll position is either drawn under the words, Details, the reply or an action.
+ @MainActor
+ func testAimCameraStaysOpenAtLargestTextSize() throws {
+ continueAfterFailure = false
+ let app = XCUIApplication()
+ app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoFreeze", "-uiDemoPhase", "wallWalk", "-uiDemoAim"] + Self.largestText
+ app.launch()
+ defer { app.terminate() }
+ XCTAssertTrue(element(app, "screen.wallWalk").waitForExistence(timeout: 15))
+ let window = app.windows.firstMatch.frame
+ let card = element(app, "instruction")
+ let details = element(app, "instruction.details")
+ let reply = element(app, "action.cannotAccess")
+ let mark = element(app, "action.markSomething")
+ for (identifier, target) in [("instruction", card), ("instruction.details", details), ("action.cannotAccess", reply), ("action.markSomething", mark)] {
+ XCTAssertTrue(target.waitForExistence(timeout: 5), "missing \(identifier)")
+ }
+ let label = ElementRead.snapshot(card)?.label ?? ""
+ XCTAssertTrue(label.contains("Tilt down to show the ground"), "the task must stay on the card, got \(label)")
+ XCTAssertFalse(label.contains("1 ft 4 in right of your meter"), "the how-to words must fold under Details, got \(label)")
+ XCTAssertFalse(element(app, "aim.legend").exists, "the legend must fold under Details")
+ XCTAssertTrue(details.isHittable, "Details must be reachable without scrolling")
+ XCTAssertTrue(reply.isHittable, "the card's reply must be reachable without scrolling")
+ let cardBottom = max(card.frame.maxY, details.frame.maxY, reply.frame.maxY)
+ let openCamera = CGRect(x: window.minX, y: cardBottom, width: window.width, height: mark.frame.minY - cardBottom)
+ XCTAssertGreaterThanOrEqual(openCamera.height, 150, "the camera must stay open between the card and the actions: \(openCamera)")
+
+ let ring = element(app, "aim.ring")
+ let arrow = element(app, "aim.arrow")
+ let marker = ring.exists ? ring : arrow
+ XCTAssertTrue(marker.waitForExistence(timeout: 5), "the spot needs its ring or an arrow toward it")
+ XCTAssertTrue(openCamera.contains(marker.frame), "the spot's marker \(marker.frame) must be in the open camera \(openCamera)")
+ XCTAssertEqual(ElementRead.snapshot(marker)?.value as? String, "50 percent captured", "the marker must read the spot's progress")
+ attach(app, name: "wallWalk-aim-AX5-folded")
+
+ let covers = ["instruction", "instruction.details", "action.cannotAccess", "action.markSomething"].map { ($0, element(app, $0)) }
+ // Short drags through every scroll position; where the screen fits, nothing moves.
+ for step in 0..<12 {
+ for (name, shown) in [("ring", ring), ("arrow", arrow)] where shown.exists {
+ let frame = shown.frame
+ XCTAssertTrue(window.contains(frame), "step \(step): \(name) \(frame) is not wholly on screen")
+ for (identifier, cover) in covers where cover.exists {
+ XCTAssertFalse(frame.intersects(cover.frame), "step \(step): \(name) \(frame) is under \(identifier) \(cover.frame)")
+ }
+ }
+ let start = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.75))
+ start.press(forDuration: 0.1, thenDragTo: start.withOffset(CGVector(dx: 0, dy: -60)), withVelocity: .slow, thenHoldForDuration: 0.2)
+ }
+ XCTAssertTrue(scrollUntilHittable(mark, in: app), "Mark something must be reachable by scrolling")
+ }
+
+ /// At AX5, Details opens the folded how-to words and the ring's legend, and closes them again.
+ @MainActor
+ func testFoldedDetailsOpenAndCloseAtLargestTextSize() throws {
+ continueAfterFailure = false
+ let app = XCUIApplication()
+ app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoFreeze", "-uiDemoPhase", "wallWalk", "-uiDemoAim"] + Self.largestText
+ app.launch()
+ defer { app.terminate() }
+ XCTAssertTrue(element(app, "screen.wallWalk").waitForExistence(timeout: 15))
+ tap(app, "instruction.details")
+ let detail = element(app, "instruction.detail")
+ XCTAssertTrue(detail.waitForExistence(timeout: 5), "Details must open the how-to words")
+ XCTAssertTrue(detail.label.contains("1 ft 4 in right of your meter"), "detail reads \(detail.label)")
+ let legend = element(app, "aim.legend")
+ XCTAssertTrue(legend.waitForExistence(timeout: 5), "Details must open the ring's legend")
+ XCTAssertTrue(legend.label.contains("It fills as your phone captures this spot"), "legend reads \(legend.label)")
+ attach(app, name: "wallWalk-aim-AX5-details")
+ tap(app, "instruction.details")
+ XCTAssertTrue(detail.waitForNonExistence(timeout: 5), "Details must close again")
+ XCTAssertFalse(legend.exists)
+ }
+
+ /// Where a card's second line is itself what to do now, it stays on the card at AX5: the walk
+ /// out with its live reading, stepping back for ground further out, and tilting up to the roof
+ /// or the sky. Looking past an obstruction leads with its action instead
+ /// (`testSeeBehindLeadsWithItsActionAtLargestTextSize`).
+ @MainActor
+ func testActionWordsStayOnTheCardAtLargestTextSize() throws {
+ continueAfterFailure = false
+ let app = XCUIApplication()
+ for (state, words) in [
+ ("gapRequest-walkOut", "Follow the dotted line"),
+ ("gapRequest-groundOut", "Step back and tilt down"), ("gapRequest-overhead", "up to the roof or the sky"),
+ ] {
+ guard let fixture = Self.states.first(where: { $0.name == state }) else {
+ XCTFail("no state named \(state)")
+ continue
+ }
+ app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoFreeze"] + fixture.arguments + Self.largestText
+ app.launch()
+ XCTAssertTrue(element(app, "screen.\(fixture.screen)").waitForExistence(timeout: 15))
+ let card = element(app, "instruction")
+ XCTAssertTrue(card.waitForExistence(timeout: 5))
+ let label = ElementRead.snapshot(card)?.label ?? ""
+ XCTAssertTrue(label.contains(words), "\(state): \"\(words)\" must stay on the card, got \(label)")
+ XCTAssertFalse(element(app, "instruction.details").exists, "\(state): nothing to fold")
+ app.terminate()
+ }
+ }
+
+ /// Root's review of the AX5 frame at 35cc8521: unfolded, the see-behind card covered the camera,
+ /// and the spot it asks the homeowner to look past, down to its reply. Folded, what to do leads,
+ /// ride-along coaching and the reply stay, the camera opens between the card and the controls
+ /// below it, and Details holds the situation and where it is. The step's ring has no progress,
+ /// so it is hidden from VoiceOver and its place is checked in the attached frames.
+ @MainActor
+ func testSeeBehindLeadsWithItsActionAtLargestTextSize() throws {
+ continueAfterFailure = false
+ let app = XCUIApplication()
+ for (coaching, name) in [(nil, "wallWalk-seeBehind-AX5-folded"), ("slowDown", "wallWalk-seeBehind-slowDown-AX5-folded")] {
+ app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoFreeze", "-uiDemoPhase", "wallWalk", "-uiDemoSeeBehind"]
+ + (coaching.map { ["-uiDemoCoaching", $0] } ?? []) + Self.largestText
+ app.launch()
+ XCTAssertTrue(element(app, "screen.wallWalk").waitForExistence(timeout: 15))
+ let window = app.windows.firstMatch.frame
+ let card = element(app, "instruction")
+ let details = element(app, "instruction.details")
+ let cantSee = reply(app, "Can't see past it")
+ XCTAssertTrue(card.waitForExistence(timeout: 5), "missing instruction")
+ XCTAssertTrue(details.waitForExistence(timeout: 5), "\(name): the situation must fold under Details")
+ XCTAssertTrue(cantSee.waitForExistence(timeout: 5), "\(name): missing the reply Can't see past it")
+ let label = ElementRead.snapshot(card)?.label ?? ""
+ XCTAssertTrue(label.contains("Look around it"), "\(name): what to do must lead, got \(label)")
+ XCTAssertFalse(label.contains("Something is in front of the wall here"), "\(name): the situation must fold under Details, got \(label)")
+ if coaching != nil {
+ XCTAssertTrue(label.contains("Slow down"), "\(name): the coaching must stay in view, got \(label)")
+ }
+ XCTAssertTrue(details.isHittable, "\(name): Details must be reachable without scrolling")
+ XCTAssertTrue(cantSee.isHittable, "\(name): Can't see past it must be reachable without scrolling")
+ // "Mark something" steps aside on this step, so the camera ends at the first control
+ // shown below the card, or at the bottom of the screen.
+ let cardBottom = max(card.frame.maxY, details.frame.maxY, cantSee.frame.maxY)
+ let below = ["action.endHere", "action.markEnd", "action.finishWalk", "wallTape"]
+ .map { element(app, $0) }
+ .filter { $0.exists && $0.frame.minY > cardBottom }
+ .map(\.frame.minY)
+ let openCamera = CGRect(x: window.minX, y: cardBottom, width: window.width, height: min(below.min() ?? window.maxY, window.maxY) - cardBottom)
+ attach(app, name: name)
+ XCTAssertGreaterThanOrEqual(openCamera.height, 150, "\(name): the camera must stay open between the card and the controls: \(openCamera)")
+
+ if coaching == nil {
+ tap(app, "instruction.details")
+ let detail = element(app, "instruction.detail")
+ XCTAssertTrue(detail.waitForExistence(timeout: 5), "Details must open the situation")
+ XCTAssertTrue(detail.label.contains("Something is in front of the wall here, about 5 ft right of your meter. Look at it from the side or step around it."), "detail reads \(detail.label)")
+ XCTAssertTrue(scrollUntilHittable(cantSee, in: app), "Can't see past it must stay reachable with Details open")
+ attach(app, name: "wallWalk-seeBehind-AX5-details")
+ }
+ app.terminate()
+ }
+ }
+
+ /// Root's manual pass at b6ee153b: after "Allow camera" at AX5, the find-meter card filled the
+ /// camera with its description, hid the reticle at the middle of the screen, and pushed "This
+ /// is my meter" below the screen. Folded, the task stays, the description sits under Details,
+ /// the camera opens between the card and the button, and the button marks the meter.
+ @MainActor
+ func testFindMeterKeepsTheCameraOpenAtLargestTextSize() throws {
+ continueAfterFailure = false
+ let app = XCUIApplication()
+ app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoFreeze", "-uiDemoPhase", "findMeter"] + Self.largestText
+ app.launch()
+ defer { app.terminate() }
+ XCTAssertTrue(element(app, "screen.findMeter").waitForExistence(timeout: 15))
+ assertFindMeterFolded(app, task: "Find your electric meter", description: "A gray box with a round glass dial", name: "findMeter-AX5-folded")
+
+ let details = element(app, "instruction.details")
+ tap(app, "instruction.details")
+ let detail = element(app, "instruction.detail")
+ XCTAssertTrue(detail.waitForExistence(timeout: 5), "Details must open the meter's description")
+ XCTAssertTrue(detail.label.contains("A gray box with a round glass dial or a small screen, usually on an outside wall."), "detail reads \(detail.label)")
+ attach(app, name: "findMeter-AX5-details")
+ tap(app, "instruction.details")
+ XCTAssertTrue(detail.waitForNonExistence(timeout: 5), "Details must close again")
+ XCTAssertTrue(details.isHittable)
+
+ tap(app, "action.markMeter")
+ XCTAssertTrue(element(app, "screen.meterCloseUp").waitForExistence(timeout: 10), "This is my meter must mark the meter")
+ }
+
+ /// The practice scan's find-meter step folds the same way: "Tap a spot on a wall" stays, the
+ /// sample's explanation sits under Details, and "Put the sample meter here" is on screen. The
+ /// real engine on a replay, since only it starts a practice scan.
+ @MainActor
+ func testPracticeFindMeterKeepsTheCameraOpenAtLargestTextSize() throws {
+ continueAfterFailure = false
+ let app = XCUIApplication()
+ app.launchArguments = ["-replay", FullFlowUITests.fixture, "-sampleResult", "-practiceMeter", "YES"] + Self.largestText
+ app.launch()
+ defer { app.terminate() }
+ XCTAssertTrue(element(app, "screen.onboarding").waitForExistence(timeout: 30))
+ if element(app, "action.onboardingSkip").waitForExistence(timeout: 5) { tap(app, "action.onboardingSkip") }
+ tap(app, "action.finishOnboarding", timeout: 15)
+ XCTAssertTrue(element(app, "screen.findMeter").waitForExistence(timeout: 15))
+ XCTAssertEqual(element(app, "action.markMeter").label, "Put the sample meter here")
+ assertFindMeterFolded(app, task: "Tap a spot on a wall", description: "A sample meter goes there", name: "findMeter-practice-AX5-folded")
+ tap(app, "action.markMeter")
+ XCTAssertTrue(element(app, "screen.meterCloseUp").waitForExistence(timeout: 15), "Put the sample meter here must mark the meter")
+ }
+
+ /// The folded find-meter card: the task in view, its description under Details, the button
+ /// whole on screen without scrolling, and open camera between them for the reticle (76 pt).
+ @MainActor
+ private func assertFindMeterFolded(_ app: XCUIApplication, task: String, description: String, name: String) {
+ let window = app.windows.firstMatch.frame
+ let card = element(app, "instruction")
+ let details = element(app, "instruction.details")
+ let mark = element(app, "action.markMeter")
+ XCTAssertTrue(card.waitForExistence(timeout: 5), "\(name): missing instruction")
+ XCTAssertTrue(details.waitForExistence(timeout: 5), "\(name): the description must fold under Details")
+ XCTAssertTrue(mark.waitForExistence(timeout: 5), "\(name): missing action.markMeter")
+ let label = ElementRead.snapshot(card)?.label ?? ""
+ XCTAssertTrue(label.contains(task), "\(name): the task must stay on the card, got \(label)")
+ XCTAssertFalse(label.contains(description), "\(name): the description must fold under Details, got \(label)")
+ let cardBottom = max(card.frame.maxY, details.frame.maxY)
+ let openCamera = CGRect(x: window.minX, y: cardBottom, width: window.width, height: mark.frame.minY - cardBottom)
+ attach(app, name: name)
+ XCTAssertTrue(details.isHittable, "\(name): Details must be reachable without scrolling")
+ XCTAssertTrue(window.contains(mark.frame) && mark.isHittable, "\(name): the mark button must be whole on screen without scrolling: \(mark.frame) in \(window)")
+ XCTAssertGreaterThanOrEqual(openCamera.height, 150, "\(name): the camera must stay open between the card and the button: \(openCamera)")
+ }
+
+ /// Coaching that rides along with the task says what to do now, so the folded card at AX5
+ /// keeps it in view with the task; only the how-to words fold.
+ @MainActor
+ func testCoachingStaysOnTheFoldedCardAtLargestTextSize() throws {
+ continueAfterFailure = false
+ let app = XCUIApplication()
+ for (coaching, words) in [("slowDown", "Slow down"), ("tooDark", "It's dark here")] {
+ app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoFreeze", "-uiDemoPhase", "wallWalk", "-uiDemoCoaching", coaching] + Self.largestText
+ app.launch()
+ XCTAssertTrue(element(app, "screen.wallWalk").waitForExistence(timeout: 15))
+ let card = element(app, "instruction")
+ XCTAssertTrue(card.waitForExistence(timeout: 5))
+ let label = ElementRead.snapshot(card)?.label ?? ""
+ XCTAssertTrue(label.contains("Walk slowly to your right"), "\(coaching): the task must stay, got \(label)")
+ XCTAssertTrue(label.contains(words), "\(coaching): the coaching must stay in view, got \(label)")
+ XCTAssertFalse(label.contains("Keep the wall and the ground in view"), "\(coaching): the how-to words must fold, got \(label)")
+ XCTAssertTrue(card.isHittable || card.frame.maxY <= app.windows.firstMatch.frame.maxY, "\(coaching): the card must be on screen")
+ XCTAssertTrue(element(app, "instruction.details").exists, "\(coaching): Details must hold the folded words")
+ attach(app, name: "wallWalk-\(coaching)-AX5-folded")
+ app.terminate()
+ }
+ }
+
+ /// At AX5 a scrolled onboarding page ran on under the page dots. On every page, before and
+ /// after scrolling it to its end, the row the dots sit in must show only the background
+ /// beside the dots. Read from the screenshot's pixels: the dots are hidden from
+ /// accessibility, and a page's frame alone can't show what it paints past its edge.
+ @MainActor
+ func testOnboardingTextStaysClearOfThePageDotsAtLargestTextSize() throws {
+ continueAfterFailure = false
+ let app = XCUIApplication()
+ app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoFreeze"] + Self.largestText
+ app.launch()
+ defer { app.terminate() }
+ XCTAssertTrue(element(app, "screen.onboarding").waitForExistence(timeout: 15))
+ let footer = element(app, "onboarding.footer")
+ XCTAssertTrue(footer.waitForExistence(timeout: 5))
+ // Each page's last element: the page is at its end once its bottom shows above the footer.
+ let ends: [XCUIElement] = [
+ app.staticTexts.matching(NSPredicate(format: "label BEGINSWITH %@", "Walk along the wall by your electric meter")).firstMatch,
+ element(app, "onboarding.move.5"),
+ element(app, "onboarding.privacy"),
+ element(app, "onboarding.permissions"),
+ ]
+ for (index, last) in ends.enumerated() {
+ let page = index + 1
+ // Let the page slide settle.
+ Thread.sleep(forTimeInterval: 0.8)
+ assertDotsRowClear(app, name: "onboarding-page\(page)-AX5-dots")
+ XCTAssertTrue(last.waitForExistence(timeout: 5), "page \(page): missing its last element")
+ XCTAssertTrue(scrollPageToEnd(app, last: last, footer: footer), "page \(page): the bottom of its last element \(last.frame) never came above the footer \(footer.frame)")
+ assertDotsRowClear(app, name: "onboarding-page\(page)-AX5-dots-end")
+ if page < ends.count { tap(app, "action.onboardingNext") }
+ }
+ }
+
+ /// Swipes the page on screen up, at most twelve times, until the bottom of its last element
+ /// shows above the footer. At AX5 a single move can be taller than the page's viewport, so
+ /// only its bottom edge marks the end. The swipe goes to the page's own scroll view: a drag
+ /// at a screen point left the second page where it was (run 36743027718).
+ @MainActor
+ private func scrollPageToEnd(_ app: XCUIApplication, last: XCUIElement, footer: XCUIElement) -> Bool {
+ let window = app.windows.firstMatch.frame
+ func atEnd() -> Bool {
+ let frame = last.frame
+ return frame.maxY <= footer.frame.minY + 1 && frame.maxY > window.minY
+ }
+ // The page on screen, not its neighbours in the pager.
+ guard let page = app.scrollViews.matching(identifier: "onboarding.page").allElementsBoundByIndex
+ .first(where: { abs($0.frame.minX - window.minX) < 1 && $0.frame.width > 0 }) else {
+ return false
+ }
+ for _ in 0..<12 where !atEnd() {
+ page.swipeUp(velocity: .slow)
+ }
+ return atEnd()
+ }
+
+ /// The band from the footer's top edge to the button's, where the dots sit, must be the
+ /// background colour except for the middle 120 pt, which holds the dots.
+ @MainActor
+ private func assertDotsRowClear(_ app: XCUIApplication, name: String) {
+ let footer = element(app, "onboarding.footer").frame
+ let button = app.buttons.matching(NSPredicate(format: "identifier IN %@", ["action.onboardingNext", "action.finishOnboarding"])).firstMatch
+ XCTAssertTrue(button.waitForExistence(timeout: 5), "\(name): missing the footer's button")
+ let band = CGRect(x: footer.minX, y: footer.minY, width: footer.width, height: button.frame.minY - footer.minY)
+ XCTAssertGreaterThan(band.height, 8, "\(name): no room for the dots above the button: \(footer) vs \(button.frame)")
+ let screenshot = app.screenshot()
+ attach(app, name: name)
+ guard let pixels = ScreenPixels(screenshot.image) else {
+ XCTFail("\(name): cannot read the screenshot's pixels")
+ return
+ }
+ if let stray = pixels.firstMismatch(in: band, excludingMiddle: 120) {
+ XCTFail("\(name): something other than the background is drawn beside the page dots at \(stray) in \(band)")
+ }
+ }
+
+ /// At AX5 the Replay and sample-result badges covered most of the result's 3D view. There
+ /// they sit clear of it, in full on screen; at the default size they stay on the view.
+ @MainActor
+ func testResultBadgesKeepOffTheModelAtLargestTextSize() throws {
+ continueAfterFailure = false
+ let app = XCUIApplication()
+ for (textSize, name) in [([String](), "result-badges"), (Self.largestText, "result-badges-AX5")] {
+ app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoFreeze", "-uiDemoPhase", "result"] + textSize
+ app.launch()
+ XCTAssertTrue(element(app, "screen.result").waitForExistence(timeout: 15))
+ let window = app.windows.firstMatch.frame
+ let model = app.descendants(matching: .any).matching(NSPredicate(format: "label == %@", "3D view of your wall")).firstMatch
+ XCTAssertTrue(model.waitForExistence(timeout: 5), "\(name): missing the 3D view")
+ for identifier in ["modeBadge", "result.sampleBadge"] {
+ let badge = element(app, identifier)
+ XCTAssertTrue(badge.waitForExistence(timeout: 5), "\(name): missing \(identifier)")
+ XCTAssertTrue(window.contains(badge.frame), "\(name): \(identifier) \(badge.frame) runs off screen")
+ if textSize.isEmpty {
+ XCTAssertTrue(model.frame.contains(badge.frame), "\(name): \(identifier) \(badge.frame) must stay on the 3D view \(model.frame)")
+ } else {
+ XCTAssertFalse(model.frame.intersects(badge.frame), "\(name): \(identifier) \(badge.frame) covers the 3D view \(model.frame)")
+ }
+ }
+ attach(app, name: name)
+ app.terminate()
+ }
+ }
+
/// "Share scan" opens the system share sheet with the scan file.
@MainActor
func testShareScanOpensTheShareSheet() throws {
@@ -708,7 +1211,8 @@ final class ScreenStatesUITests: XCTestCase {
if screen == "result" { tap(app, "result.details") }
XCTAssertTrue(element(app, "action.shareScan").waitForExistence(timeout: 5), "\(name): Share scan is missing")
}
- for expected in Self.expectations[name.hasSuffix("-AX5") ? String(name.dropLast(4)) : name] ?? [] {
+ // An AX5 state takes its own entry where it has one, else the default size's.
+ for expected in Self.expectations[name] ?? Self.expectations[name.hasSuffix("-AX5") ? String(name.dropLast(4)) : name] ?? [] {
let found: Bool
if let identifier = expected.identifier {
let target = ElementRead.snapshot(element(app, identifier))
@@ -788,41 +1292,30 @@ final class ScreenStatesUITests: XCTestCase {
/// and a tap there misses without an error (the button flow at 577acc4 never opened the mark
/// tray).
///
- /// A control below the bottom edge of a scrolling screen (the review's "Add something" chips,
- /// the result's Details) is never hittable where it is. `tap()` scrolls to it and the wait
- /// doesn't, so once it has had a moment to settle it is tapped where it is.
+ /// The wait reads each button's frame and enabled state from one fresh snapshot. The old
+ /// separate existence/layout/hittability waits repeatedly resolved the AR Done button,
+ /// consuming its 20 s budget in PR run 37022177313 attempt 1. One wait keeps that budget.
+ /// Bottom-edge controls use XCTest's ordinary scroll-to-visible tap, before hit-point queries.
@MainActor
private func tap(_ app: XCUIApplication, _ identifier: String, timeout: TimeInterval = 20) {
- let target = element(app, identifier)
- let deadline = Date().addingTimeInterval(timeout)
- XCTAssertTrue(target.waitForExistence(timeout: timeout), "missing \(identifier)")
- // An element can exist before its screen is laid out, with an infinite frame, and asking
- // whether it is hittable then fails the test outright ("Activation point invalid";
- // result.details in run 36820279307, mid-transition from the spot check).
- while !Self.laidOut(target.frame), Date() < deadline { Thread.sleep(forTimeInterval: 0.1) }
- guard Self.laidOut(target.frame) else {
- XCTFail("\(identifier) was never laid out: \(target.frame)")
- return
- }
- let settled = waitUntilHittable(target, timeout: min(3, max(0, deadline.timeIntervalSinceNow)))
- if settled || target.frame.maxY > app.windows.firstMatch.frame.maxY {
- target.tap()
+ let target = app.buttons.matching(identifier: identifier).firstMatch
+ let deadline = ProcessInfo.processInfo.systemUptime + timeout
+ var state = TapReadiness.State.missing
+ var lastObservation = state
+ let ready = XCTNSPredicateExpectation(predicate: NSPredicate { _, _ in
+ state = TapReadiness.evaluate(deadline: deadline, now: { ProcessInfo.processInfo.systemUptime }, read: {
+ ElementRead.snapshot(target).map { .init(frame: $0.frame, isEnabled: $0.isEnabled) }
+ }, window: { ElementRead.snapshot(app.windows.firstMatch)?.frame }, hittable: { target.isHittable })
+ if state != .expired { lastObservation = state }
+ return state == .ready
+ }, object: nil)
+ guard XCTWaiter().wait(for: [ready], timeout: timeout) == .completed else {
+ XCTFail("\(identifier) not ready for tap: \(state.rawValue); last observation: \(lastObservation.rawValue)")
return
}
- XCTAssertTrue(waitUntilHittable(target, timeout: max(1, deadline.timeIntervalSinceNow)), "missing or not tappable: \(identifier)")
target.tap()
}
- private static func laidOut(_ frame: CGRect) -> Bool {
- [frame.minX, frame.minY, frame.width, frame.height].allSatisfy(\.isFinite) && !frame.isEmpty
- }
-
- @MainActor
- private func waitUntilHittable(_ target: XCUIElement, timeout: TimeInterval) -> Bool {
- let hittable = XCTNSPredicateExpectation(predicate: NSPredicate(format: "isHittable == true"), object: target)
- return XCTWaiter().wait(for: [hittable], timeout: timeout) == .completed
- }
-
/// The card's reply with these words.
@MainActor
private func reply(_ app: XCUIApplication, _ title: String) -> XCUIElement {
@@ -840,3 +1333,59 @@ final class ScreenStatesUITests: XCTestCase {
target.tap()
}
}
+
+/// A screenshot's pixels, read in the screen's points.
+private struct ScreenPixels {
+ private let data: [UInt8]
+ private let width: Int
+ private let height: Int
+ private let scale: CGFloat
+
+ init?(_ image: UIImage) {
+ guard let cgImage = image.cgImage, image.size.width > 0 else { return nil }
+ let w = cgImage.width, h = cgImage.height
+ var bytes = [UInt8](repeating: 0, count: w * h * 4)
+ // Drawn upright into a bitmap context, the first row in memory is the image's top row.
+ let drawn = bytes.withUnsafeMutableBytes { buffer -> Bool in
+ guard let context = CGContext(
+ data: buffer.baseAddress, width: w, height: h, bitsPerComponent: 8, bytesPerRow: w * 4,
+ space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
+ ) else { return false }
+ context.draw(cgImage, in: CGRect(x: 0, y: 0, width: w, height: h))
+ return true
+ }
+ guard drawn else { return nil }
+ data = bytes
+ width = w
+ height = h
+ scale = CGFloat(w) / image.size.width
+ }
+
+ private func pixel(_ x: Int, _ y: Int) -> (Int, Int, Int) {
+ let i = (y * width + x) * 4
+ return (Int(data[i]), Int(data[i + 1]), Int(data[i + 2]))
+ }
+
+ /// The first pixel in `rect`, outside `middle` points around its centre line, that differs
+ /// from the pixel 2 pt inside the rect's left edge on the same row by more than a rendering
+ /// tolerance. Nil when every pixel matches.
+ func firstMismatch(in rect: CGRect, excludingMiddle middle: CGFloat) -> CGPoint? {
+ let tolerance = 24
+ let top = max(Int((rect.minY * scale).rounded(.up)), 0), bottom = min(Int((rect.maxY * scale).rounded(.down)), height)
+ let left = max(Int((rect.minX * scale).rounded(.up)), 0), right = min(Int((rect.maxX * scale).rounded(.down)), width)
+ guard top < bottom, left < right else { return nil }
+ let rows = top.. tolerance || abs(g - background.1) > tolerance || abs(b - background.2) > tolerance {
+ return CGPoint(x: CGFloat(x) / scale, y: CGFloat(y) / scale)
+ }
+ }
+ }
+ return nil
+ }
+}
diff --git a/ios/HouseScanUITests/TapReadiness.swift b/ios/HouseScanUITests/TapReadiness.swift
new file mode 100644
index 000000000..07da6bc84
--- /dev/null
+++ b/ios/HouseScanUITests/TapReadiness.swift
@@ -0,0 +1,165 @@
+import XCTest
+
+/// Decides which live reads are safe before a real element tap. The clock and reads are
+/// explicit so a slow AX query exhausting the budget can be tested without a simulator stall.
+enum TapReadiness {
+ struct Read {
+ var frame: CGRect
+ var isEnabled: Bool
+ }
+
+ enum State: String {
+ case ready
+ case missing = "button missing or snapshot unreadable"
+ case invalidFrame = "button frame not laid out"
+ case disabled = "button disabled"
+ case invalidWindow = "window missing or frame not laid out"
+ case outsideWindow = "button outside window above or beside it"
+ case notHittable = "button not hittable"
+ case expired = "readiness deadline exhausted"
+ }
+
+ static func evaluate(
+ deadline: TimeInterval,
+ now: () -> TimeInterval,
+ read: () -> Read?,
+ window: () -> CGRect?,
+ hittable: () -> Bool
+ ) -> State {
+ guard now() < deadline else { return .expired }
+ let target = read()
+ guard now() < deadline else { return .expired }
+ guard let target else { return .missing }
+ guard laidOut(target.frame) else { return .invalidFrame }
+ guard target.isEnabled else { return .disabled }
+ let bounds = window()
+ guard now() < deadline else { return .expired }
+ guard let bounds, laidOut(bounds) else { return .invalidWindow }
+
+ // PR run 37022177313 attempt 2 failed computing an offscreen AC chip's hit point.
+ // The successful b926 run scrolled that chip during target.tap(). Decide this before
+ // isHittable, which computes a hit point but does not scroll. Keep the bottom-edge
+ // case only; a control translated above or beside the window must finish entering.
+ if !bounds.contains(target.frame) {
+ let below = target.frame.minY >= bounds.minY && target.frame.maxY > bounds.maxY
+ && target.frame.minX >= bounds.minX && target.frame.maxX <= bounds.maxX
+ return below ? .ready : .outsideWindow
+ }
+ let reached = hittable()
+ guard now() < deadline else { return .expired }
+ return reached ? .ready : .notHittable
+ }
+
+ private static func laidOut(_ frame: CGRect) -> Bool {
+ // CGRect.infinite uses finite sentinel coordinates. The isolated XCTest run caught
+ // it passing isFinite. CGRect.width/height also normalize negative stored sizes,
+ // so validate the stored size before using the standardized edges.
+ !frame.isNull && !frame.isInfinite && frame.size.width > 0 && frame.size.height > 0
+ && [frame.origin.x, frame.origin.y, frame.size.width, frame.size.height,
+ frame.origin.x + frame.size.width, frame.origin.y + frame.size.height,
+ frame.minX, frame.minY, frame.maxX, frame.maxY].allSatisfy(\.isFinite)
+ }
+}
+
+final class TapReadinessTests: XCTestCase {
+ private let bounds = CGRect(x: 0, y: 0, width: 400, height: 800)
+ private let visible = CGRect(x: 20, y: 700, width: 360, height: 44)
+
+ func testInvalidOrMissingTargetsNeverAskForAWindowOrHitPoint() {
+ let frames: [CGRect?] = [nil, .zero, .null, .infinite,
+ CGRect(x: CGFloat.nan, y: 0, width: 44, height: 44),
+ CGRect(x: 0, y: 0, width: CGFloat.nan, height: 44),
+ CGRect(x: 0, y: 0, width: 44, height: -44),
+ CGRect(x: 0, y: 0, width: -44, height: 44),
+ CGRect(x: CGFloat.greatestFiniteMagnitude, y: 0,
+ width: CGFloat.greatestFiniteMagnitude, height: 44)]
+ for frame in frames {
+ let result = TapReadiness.evaluate(deadline: 20, now: { 0 }, read: {
+ frame.map { TapReadiness.Read(frame: $0, isEnabled: true) }
+ }, window: { XCTFail("an unreadable frame must not query the window"); return self.bounds },
+ hittable: { XCTFail("an unreadable frame must not compute a hit point"); return true })
+ XCTAssertEqual(result, frame == nil ? .missing : .invalidFrame, "invalid frame: \(String(describing: frame))")
+ }
+ }
+
+ func testDisabledOffscreenButtonIsNotReady() {
+ let result = TapReadiness.evaluate(deadline: 20, now: { 0 }, read: {
+ .init(frame: CGRect(x: 20, y: 900, width: 360, height: 44), isEnabled: false)
+ }, window: { XCTFail("a disabled button must not query the window"); return self.bounds },
+ hittable: { XCTFail("a disabled button must not compute a hit point"); return true })
+ XCTAssertEqual(result, .disabled)
+ }
+
+ func testBottomEdgeButtonsUseTapScrollingWithoutComputingAHitPoint() {
+ for y in [CGFloat(780), 900] {
+ let result = TapReadiness.evaluate(deadline: 20, now: { 0 }, read: {
+ .init(frame: CGRect(x: 20, y: y, width: 360, height: 44), isEnabled: true)
+ }, window: { self.bounds },
+ hittable: { XCTFail("the tap must scroll before computing a hit point"); return false })
+ XCTAssertEqual(result, .ready)
+ }
+ }
+
+ func testOtherOffscreenGeometryDoesNotPermitATap() {
+ for frame in [CGRect(x: 20, y: -10, width: 360, height: 44),
+ CGRect(x: -10, y: 780, width: 360, height: 44),
+ CGRect(x: 390, y: 700, width: 44, height: 44)] {
+ let result = TapReadiness.evaluate(deadline: 20, now: { 0 }, read: {
+ .init(frame: frame, isEnabled: true)
+ }, window: { self.bounds },
+ hittable: { XCTFail("outside geometry must not compute a hit point"); return true })
+ XCTAssertEqual(result, .outsideWindow)
+ }
+ }
+
+ func testInvalidWindowDoesNotPermitATap() {
+ for frame in [nil, .zero, .infinite] as [CGRect?] {
+ let result = TapReadiness.evaluate(deadline: 20, now: { 0 }, read: {
+ .init(frame: self.visible, isEnabled: true)
+ }, window: { frame },
+ hittable: { XCTFail("an invalid window must not compute a hit point"); return true })
+ XCTAssertEqual(result, .invalidWindow)
+ }
+ }
+
+ func testVisibleButtonReadsEachInputOnceAndRequiresHittability() {
+ for reached in [false, true] {
+ var reads = 0, windows = 0, hits = 0
+ let result = TapReadiness.evaluate(deadline: 20, now: { 0 }, read: {
+ reads += 1
+ return .init(frame: self.visible, isEnabled: true)
+ }, window: { windows += 1; return self.bounds }, hittable: { hits += 1; return reached })
+ XCTAssertEqual(result, reached ? .ready : .notHittable)
+ XCTAssertEqual(reads, 1)
+ XCTAssertEqual(windows, 1)
+ XCTAssertEqual(hits, 1)
+ }
+ }
+
+ func testSlowReadsExhaustOneDeadlineWithoutIssuingMoreQueries() {
+ for exhaustedStage in 0...2 {
+ var time: TimeInterval = 0
+ var reads = 0, windows = 0, hits = 0
+ func evaluate() -> TapReadiness.State {
+ TapReadiness.evaluate(deadline: 20, now: { time }, read: {
+ reads += 1
+ if exhaustedStage == 0 { time = 21 }
+ return .init(frame: self.visible, isEnabled: true)
+ }, window: {
+ windows += 1
+ if exhaustedStage == 1 { time = 21 }
+ return self.bounds
+ }, hittable: {
+ hits += 1
+ time = 21
+ return true
+ })
+ }
+ XCTAssertEqual(evaluate(), .expired)
+ XCTAssertEqual(evaluate(), .expired, "the next poll must not renew the deadline")
+ XCTAssertEqual(reads, 1)
+ XCTAssertEqual(windows, exhaustedStage >= 1 ? 1 : 0)
+ XCTAssertEqual(hits, exhaustedStage == 2 ? 1 : 0)
+ }
+ }
+}