diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index 9c8f34a6a..6d1c66192 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -40,8 +40,10 @@ jobs: # Beta run 36302099002 took 43 minutes before the spot-confirmation and review checks # 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 + # every-state audit short. 75 left room until the 2026-10-03 candidate's 86 UI cases: + # run 37121918073 attempt 2 spent 10 min 34 s before the UI step and 64 min 39 s in it, + # and the cap stopped it after 76 cases with 0 failures. 90 runs all of them. + timeout-minutes: 90 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/HouseScan.xcodeproj/project.pbxproj b/ios/HouseScan.xcodeproj/project.pbxproj index f623d8f14..50ef090a2 100644 --- a/ios/HouseScan.xcodeproj/project.pbxproj +++ b/ios/HouseScan.xcodeproj/project.pbxproj @@ -34,9 +34,14 @@ membershipExceptions = ( Fixtures/results, "Fixtures/results/no-clean-spot.json", + "Fixtures/results/no-spot-no-nearest.json", Fixtures/results/pass.json, "Fixtures/results/reject-nearest.json", + "Fixtures/results/review-band.json", + "Fixtures/results/spot-left.json", + "Fixtures/results/spot-straddle.json", "Fixtures/results/unsure-view.json", + "Fixtures/results/view-not-offered.json", "Fixtures/synthetic-wall", "Fixtures/synthetic-wall-lidar", "Fixtures/synthetic-wall-lidar/keyframes", diff --git a/ios/HouseScan/Contract/ResultPresentation+Reading.swift b/ios/HouseScan/Contract/ResultPresentation+Reading.swift index a763b90fb..19750bb65 100644 --- a/ios/HouseScan/Contract/ResultPresentation+Reading.swift +++ b/ios/HouseScan/Contract/ResultPresentation+Reading.swift @@ -47,11 +47,23 @@ extension ResultPresentation { ResultReading.cardLines(readingChecks).map { checks[$0] } } + /// The view `row`'s line on the card offers to take, or nil: the view the server named to + /// settle it (`settledBy`), when `ResultCardActions.lineOffersView` says the line offers it. + /// The line's "Show me" and its words (`ScanCopy.cardLine`, `ScanCopy.unsureNote`) both read + /// this, so the words never promise a photo the line doesn't let the homeowner take. + func offeredView(for row: CheckRow, sourceAvailable: Bool) -> MissingEvidence? { + let view = row.settledBy.flatMap { id in missing.first { $0.id == id } } + guard ResultCardActions.lineOffersView( + outcome: row.outcome.placementOutcome, needsPerson: row.needsPerson, + settlingViewCapturable: view?.capturable, sourceAvailable: sourceAvailable) + else { return nil } + return view + } + /// The view that would settle `row` and that the camera can take now: only for an unsure - /// check a person needn't judge. + /// check a person needn't judge. `offeredView` with the camera up. func viewToTake(for row: CheckRow) -> MissingEvidence? { - guard row.outcome == .unsure, !row.needsPerson, let id = row.settledBy else { return nil } - return missing.first { $0.id == id && $0.capturable } + offeredView(for: row, sourceAvailable: true) } /// The first view in `missing` that settles an unsure check and can be taken now. diff --git a/ios/HouseScan/Contract/ScanContract.swift b/ios/HouseScan/Contract/ScanContract.swift index 705c3e30e..141a07113 100644 --- a/ios/HouseScan/Contract/ScanContract.swift +++ b/ios/HouseScan/Contract/ScanContract.swift @@ -443,6 +443,9 @@ enum CloseUpProblem: Equatable, Sendable { case numberTooSmall /// No number could be read at all: retake. case noNumber + /// The photo couldn't be written to the phone, or read back once written. Nothing about the + /// shot was wrong, so the words don't blame the homeowner's hands (B-20). + case photoNotSaved } /// One reading of the meter number from the close-up, for the homeowner to confirm. @@ -553,6 +556,19 @@ enum NextWallRefusal: Equatable, Sendable { case notAtCorner } +/// Why "Wall ends here" at the circle marked no end (`ScanViewState.endMarkRefusal`). Before, +/// the button did nothing at all in each case, and nothing said why (B-06). +enum EndMarkRefusal: Error, Equatable, Sendable { + /// The circle isn't on the wall: aimed at the ground, so the ray meets the wall's plane under + /// the floor, or past the distance a camera's view counts for (`CoverageConfig.maxDistance`) + /// far down a long wall. Aimed above the wall is kept (`EndAim`). + case noWall + /// The circle is on the wall on this side of the meter, not the side the card asks about. + case otherSide(WallSide) + /// Tracking isn't normal, as for a feature mark (`MarkRefusal.trackingNotReady`). + case trackingNotReady +} + enum MarkRefusal: Equatable, Sendable { case noSurface case wrongSide @@ -627,10 +643,14 @@ enum UploadState: Equatable, Sendable { /// A network failure or a server error (5xx): sending again can work. `offline` means no /// connection at all. case failed(message: String, offline: Bool) - /// The server refused the scan (4xx), its answer couldn't be read, or the scan couldn't be - /// packaged. Sending again would send the same thing, so the way on is back to the review - /// or start over, never "Try again". + /// The server refused the scan (4xx) or the scan couldn't be packaged. Sending again would send + /// the same thing, so the way on is back to the review or start over, never "Try again". case rejected(message: String) + /// The server answered, but House Scan couldn't use the answer: it didn't decode, or it didn't + /// name the scene sent. It isn't shown. The scan isn't at fault, so the way on is "Try again", + /// with sharing the scan or starting over for when asking again doesn't help. `attempts` counts + /// such answers since the scan was sent from the review or a gap, at least 1. + case unusableAnswer(attempts: Int) case done } @@ -665,6 +685,13 @@ struct CheckRow: Identifiable, Equatable, Sendable { var plusMinus: Float? = nil /// Whether `threshold` is a minimum or a maximum, when the server said which. var comparison: RuleComparison? = nil + /// A stricter line inside `threshold`, meters, when the server sent one (`review_threshold_ft`, + /// on the cable run): a measurement that doesn't clear it needs review even within the limit. + /// Nil when the answer has none. + var reviewThreshold: Float? = nil + /// True when `reviewThreshold` explains this unsure check, so the card names it + /// (`PlacementCheck.reviewBandApplies`, decided in the server's feet before conversion). + var reviewBandApplies = false /// The `MissingEvidence.id` of the first view that would settle this check, when the server /// named one. var settledBy: String? = nil @@ -759,6 +786,10 @@ enum SpotCheckAnswer: Equatable, Sendable { case clear /// Something stands there: the scan stops claiming that area and is checked again. case somethingThere + /// The homeowner can't see or reach the area to say: it was not observed, which is neither + /// an obstruction nor clear. The scan stops claiming that area and is checked again, and the + /// result says nobody checked it. + case cannotCheck } /// The one question asked before an answer's spot is shown as the result: is anything standing @@ -867,6 +898,10 @@ final class ScanViewState { /// offer (a question or a mark is up, both ends are marked, or the walk is doing something /// else). "Wall ends here" shows only while it is set. var endPreview: EndPreview? + /// Why the last "Wall ends here" at the circle marked nothing, while the walk still asks for + /// that end. Cleared once the circle is on the asked end (`EndPreview` at the reticle on that + /// side), when an end is set, or when the walk asks for something else. + var endMarkRefusal: EndMarkRefusal? /// "Can't get there" came again on a walk card within `WalkRefusals.repeatWindow` of the one /// that last ended a side: the walk asks "End the scan here?" instead of ending this side too /// (#82). Answered by `answerEndScan`. @@ -891,6 +926,11 @@ final class ScanViewState { var result: ResultPresentation? /// The homeowner's check of the proposed spot, retained beside the result. var spotCheck: SpotCheck? + /// True when the homeowner answered "I can't check this area" about an area other than the + /// spot the result names, or while it names none. That area stays out of the scan, so the + /// result says so beside whatever it says about its own spot. Cleared with the scan's spot + /// checks. + var uncheckedAreaElsewhere = false /// True while the engine sees the AR scene drawing the result in the live camera /// (`ResultOverlayPolicy`). The AR screen then draws no overlay of its own; otherwise it /// draws `BatteryOverlay`. @@ -995,8 +1035,8 @@ protocol ScanActions: AnyObject { func retryUpload() /// After a rejected upload: back to the feature review, keeping the scan. func backToReview() - /// The answer to `ScanViewState.spotCheck`: true when nothing stands in the area. - func answerSpotCheck(clear: Bool) + /// The answer to `ScanViewState.spotCheck`. + func answerSpotCheck(_ answer: SpotCheckAnswer) /// Start a capture for a server-listed missing item. func captureMissing(_ id: String) func showAR() diff --git a/ios/HouseScan/HouseScanApp.swift b/ios/HouseScan/HouseScanApp.swift index a136ba964..1818e887c 100644 --- a/ios/HouseScan/HouseScanApp.swift +++ b/ios/HouseScan/HouseScanApp.swift @@ -2,6 +2,14 @@ import SwiftUI @main struct HouseScanApp: App { + /// Runs once per process, before any window exists: deletes the share copies earlier runs + /// left, whose share sheets can't still be reading them. This run's copies live in its own + /// session folder (`SavedScansLocation.staging`), so they are never among them. + init() { + let staging = SavedScansLocation.staging + Task.detached(priority: .utility) { staging.removeOtherSessions() } + } + var body: some Scene { WindowGroup { Group { diff --git a/ios/HouseScan/Runtime/Autopilot.swift b/ios/HouseScan/Runtime/Autopilot.swift index edde51070..cfad487a3 100644 --- a/ios/HouseScan/Runtime/Autopilot.swift +++ b/ios/HouseScan/Runtime/Autopilot.swift @@ -17,10 +17,10 @@ final class Autopilot { /// back to pixels use the same size. private let viewSize = CGSize(width: 393, height: 852) private var hold: Double { engine.options.autopilotHold } - /// Set once a spot check was answered "Something's there" (`-autopilotSomethingThere`): the - /// checks after it are answered "It's clear", so a server that keeps moving the spot can't - /// keep the run going. - private var answeredSomethingThere = false + /// Set once a spot check was answered "Something's there" (`-autopilotSomethingThere`) or "I + /// can't check this area" (`-autopilotCannotCheck`): the checks after it are answered "It's + /// clear", so a server that keeps moving the spot can't keep the run going. + private var withheldClear = false init(engine: ScanEngine) { self.engine = engine @@ -119,7 +119,7 @@ final class Autopilot { let state = engine.state guard state.phase == .meterCloseUp else { return .left } if case .choose(let candidates) = state.meterNumber, let first = candidates.first { return .choose(first) } - if case .aiming(_, let problem?) = state.closeUp, problem == .noNumber || problem == .numberTooSmall { return .retake(problem) } + if case .aiming(_, let problem?) = state.closeUp, [.noNumber, .numberTooSmall, .photoNotSaved].contains(problem) { return .retake(problem) } return nil } @@ -399,18 +399,26 @@ final class Autopilot { return false } - /// "It's clear", or with `-autopilotSomethingThere` "Something's there" the first time. After - /// a refusal the stretch it withdrew goes to the gate folder as `spot-refusal.json` (the - /// stretch's s in feet, as scene.json's spans), for the UI test to check the scene against. + /// "It's clear", or the first time "Something's there" with `-autopilotSomethingThere` and "I + /// can't check this area" with `-autopilotCannotCheck`. After either, the stretch the answer + /// withdrew goes to the gate folder as `spot-refusal.json` (the stretch's s in feet, as + /// scene.json's spans), for the UI test to check the scene against. private func answerSpotCheck() async { guard let check = engine.state.spotCheck, check.answer == nil else { return } await pause(hold) await engine.waitForGate(.spotConfirm) - let refuse = engine.options.autopilotSomethingThere && !answeredSomethingThere - engine.answerSpotCheck(clear: !refuse) - log("spot check \(check.id) over \(format(check.area)): \(refuse ? "something's there" : "it's clear")") - if refuse { - answeredSomethingThere = true + let options = engine.options + let first: SpotCheckAnswer = options.autopilotCannotCheck ? .cannotCheck : options.autopilotSomethingThere ? .somethingThere : .clear + let answer: SpotCheckAnswer = withheldClear ? .clear : first + engine.answerSpotCheck(answer) + let said = switch answer { + case .clear: "it's clear" + case .somethingThere: "something's there" + case .cannotCheck: "I can't check this area" + } + log("spot check \(check.id) over \(format(check.area)): \(said)") + if answer != .clear { + withheldClear = true writeRefusalForTest() } _ = await waitUntil(timeout: 20) { self.engine.state.phase != .spotConfirm } diff --git a/ios/HouseScan/Runtime/GateAnswerProtocol.swift b/ios/HouseScan/Runtime/GateAnswerProtocol.swift new file mode 100644 index 000000000..34cc69e14 --- /dev/null +++ b/ios/HouseScan/Runtime/GateAnswerProtocol.swift @@ -0,0 +1,104 @@ +import Foundation +import Synchronization + +/// The placement server's side of an upload under `-answersFromGate`, played by a UI test through +/// the `-autopilotGate` folder. The app's real `PlacementHTTPClient` sends through `session`, so +/// the request, the answer's status check and `ResultBinding` all run as they do against a server; +/// only the socket is replaced. +/// +/// For the n-th request (from 0, in the order the app sends them) it writes `request-n.target` +/// ("POST /v1/placements"), then the exact body as `request-n.json`, then waits up to 60 seconds +/// for the test's `answer-n.json` and answers HTTP 200 with its bytes. With no answer by then the +/// request fails as timed out. +/// +/// The UI test used to run a server on 127.0.0.1 inside its runner. That server answered +/// URLSession on a Mac, but on the hosted Simulator the app's requests to it failed as offline +/// (Actions run 37107233020); the runner is a background app while the app under test is open. +/// HouseScanKit's `PlacementHTTPClientTests` still drive the client over a real loopback socket. +final class GateAnswerProtocol: URLProtocol, @unchecked Sendable { + static let session: URLSession = { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [GateAnswerProtocol.self] + return URLSession(configuration: configuration) + }() + + static let answerWait: TimeInterval = 60 + + /// Requests started in this process, which numbers the files. + private static let started = Mutex(0) + + /// Set and read only on the loading thread: `startLoading`, the timer and `stopLoading` all + /// run on the run loop URLSession called `startLoading` on. + private var poll: Timer? + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + guard let gate = LaunchOptions().autopilotGate, let url = request.url else { + client?.urlProtocol(self, didFailWithError: URLError(.badURL)) + return + } + let body: Data + do { + body = try Self.body(of: request) + } catch { + client?.urlProtocol(self, didFailWithError: error) + return + } + // Numbered only once the body is read. A write that fails after this leaves a gap in the + // numbers, which the test's server skips over. + let index = Self.started.withLock { count in + defer { count += 1 } + return count + } + do { + let target = "\(request.httpMethod ?? "GET") \(url.path())" + try Data(target.utf8).write(to: gate.appending(path: "request-\(index).target"), options: .atomic) + try body.write(to: gate.appending(path: "request-\(index).json"), options: .atomic) + } catch { + client?.urlProtocol(self, didFailWithError: error) + return + } + let answer = gate.appending(path: "answer-\(index).json") + let deadline = Date().addingTimeInterval(Self.answerWait) + let timer = Timer(timeInterval: 0.1, repeats: true) { [self] timer in + if let body = try? Data(contentsOf: answer) { + timer.invalidate() + let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: ["Content-Type": "application/json"])! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: body) + client?.urlProtocolDidFinishLoading(self) + } else if Date() >= deadline { + timer.invalidate() + RuntimeLog.engine.error("answers from gate: no \(answer.lastPathComponent, privacy: .public) within \(Self.answerWait, privacy: .public) s") + client?.urlProtocol(self, didFailWithError: URLError(.timedOut)) + } + } + RunLoop.current.add(timer, forMode: RunLoop.current.currentMode ?? .default) + poll = timer + } + + override func stopLoading() { + poll?.invalidate() + poll = nil + } + + /// An upload task hands its body over as a stream, not `httpBody`. A stream that fails to read + /// throws: a cut-short body would reach the test as an answer naming the wrong scene. + private static func body(of request: URLRequest) throws -> Data { + if let body = request.httpBody { return body } + guard let stream = request.httpBodyStream else { return Data() } + stream.open() + defer { stream.close() } + var data = Data() + var buffer = [UInt8](repeating: 0, count: 64 * 1024) + while true { + let read = stream.read(&buffer, maxLength: buffer.count) + if read < 0 { throw stream.streamError ?? URLError(.cannotDecodeRawData) } + guard read > 0 else { break } + data.append(buffer, count: read) + } + return data + } +} diff --git a/ios/HouseScan/Runtime/KeyframeStore.swift b/ios/HouseScan/Runtime/KeyframeStore.swift index b081627b1..c75157660 100644 --- a/ios/HouseScan/Runtime/KeyframeStore.swift +++ b/ios/HouseScan/Runtime/KeyframeStore.swift @@ -57,13 +57,18 @@ final class KeyframeStore { /// listed here, before a newer store can exist, and deleted later off the main actor /// (`ScanFolderCleanup`), so a deletion that runs late can't take a newer scan's folder. init() { - let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] - let scans = caches.appending(path: "Scans", directoryHint: .isDirectory) + let scans = Self.scansRoot directory = scans.appending(path: UUID().uuidString, directoryHint: .isDirectory) try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) Self.delete(ScanFolderCleanup(root: scans, keeping: directory.lastPathComponent)) } + /// Caches/Scans: one folder per scan. Saved scans lists the completed ones here. + nonisolated static var scansRoot: URL { + FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] + .appending(path: "Scans", directoryHint: .isDirectory) + } + func nextKeyframeIndex() -> Int { defer { nextIndex += 1 } return nextIndex @@ -162,26 +167,51 @@ final class KeyframeStore { case writeFailed = "write failed" } - /// Writes a still such as the meter close-up from `frame`. Returns false when there was - /// nothing to write. The still stands for its purpose only once accepted (`acceptStill`). - func saveStill(_ frame: SourceFrame, name: String) async -> Bool { + /// What became of a `saveStill`. The two failures need different words on screen: a photo + /// that couldn't be written is a phone problem the homeowner can retry or skip, while a + /// discarded world frame has already sent the flow back to finding the meter (B-20: both + /// used to read as a blurry photo). + enum StillSave: Equatable { + case saved + /// No image bytes to write, or the write failed (a full phone, say). Nothing new is + /// listed; a still saved earlier under the same name stays as it was. + case notWritten + /// Written, but in a world frame discarded meanwhile, so it isn't kept. + case worldDiscarded + } + + /// Writes a still such as the meter close-up from `frame`. The still stands for its purpose + /// only once accepted (`acceptStill`). + func saveStill(_ frame: SourceFrame, name: String) async -> StillSave { let directory = directory let id = Self.purpose(of: name) let startedIn = epoch - let written = await Task.detached(priority: .utility) { () -> StoredKeyframe? in - guard let data = Self.data(of: frame.jpeg), (try? data.write(to: directory.appending(path: name), options: .atomic)) != nil else { return nil } - return Self.stored(frame, id: id, jpeg: data, in: directory) + let written = await Task.detached(priority: .utility) { () -> Result in + guard let data = Self.data(of: frame.jpeg) else { return .failure(.noPhoto) } + do { + try data.write(to: directory.appending(path: name), options: .atomic) + } catch { + return .failure(.writeFailed) + } + return .success(Self.stored(frame, id: id, jpeg: data, in: directory)) }.value - guard let written else { return false } + let stored: StoredKeyframe + switch written { + case .success(let entry): + stored = entry + case .failure(let failure): + RuntimeLog.capture.error("still \(name, privacy: .public) not stored: \(failure.rawValue, privacy: .public)") + return .notWritten + } // Taken in a world frame that was discarded while the file was being written. The file is // left alone: a close-up in the new frame may already have written the same name, and // nothing reads a still that `stillFrames` doesn't list. guard startedIn == epoch else { RuntimeLog.capture.info("still \(name, privacy: .public) not kept: its world frame was discarded") - return false + return .worldDiscarded } - stillCatalog.save(written, purpose: id, fileName: name) - return true + stillCatalog.save(stored, purpose: id, fileName: name) + return .saved } /// The homeowner accepted the still saved as `name` (the meter number read from it was @@ -197,14 +227,6 @@ final class KeyframeStore { stillCatalog.withdraw(Self.purpose(of: name)) } - func thumbnail(ofStill name: String) async -> CGImage? { - let url = directory.appending(path: name) - return await Task.detached(priority: .utility) { () -> CGImage? in - guard let data = try? Data(contentsOf: url) else { return nil } - return ImageWork.uprightThumbnail(jpeg: data) - }.value - } - /// Forgets keyframes and stills taken in a world frame that no longer exists (after a failed /// relocalization), the meter close-up included: export and the packet read only what /// `keyframes`, `stills` and `stillFrames` list, so a close-up skipped in the new frame exports diff --git a/ios/HouseScan/Runtime/LaunchOptions.swift b/ios/HouseScan/Runtime/LaunchOptions.swift index 3d659c6ed..d0e023d60 100644 --- a/ios/HouseScan/Runtime/LaunchOptions.swift +++ b/ios/HouseScan/Runtime/LaunchOptions.swift @@ -22,12 +22,25 @@ import OSLog /// marking the ends (`Autopilot.endWalkByCantGetThere`). /// - `-autopilotSomethingThere`: the autopilot answers the first spot check "Something's there" /// instead of "It's clear", so the scan is checked again without that area. +/// - `-autopilotCannotCheck`: the autopilot answers the first spot check "I can't check this +/// area" instead of "It's clear". It can't be combined with `-autopilotSomethingThere`. +/// - `-sampleResultAfterSpotAnswer ` (debug builds only): with the bundled sample, the +/// upload sent after a spot check answered "Something's there" or "I can't check this area", +/// and every later upload of that scan, is answered with the server answer in this JSON file, +/// such as one without a spot. Start over or a new wall goes back to the bundled sample. /// - `-injectGroundRise `: with `-replay`, `-autopilot` and `-autopilotGate`, once 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 /// the engine's 1 cm refine threshold and at most `GroundPlaneChoice.maximumRaise`. +/// - `-answersFromGate`: with `-autopilotGate` and a server URL, uploads go through the gate +/// folder instead of the network: the UI test reads each request there and writes the server's +/// answer (`GateAnswerProtocol`). +/// - `-failCloseUpSave`: with `-replay`, every meter close-up's photo fails to save, as on a phone +/// with no space left, so a UI test reaches the save-failure retake and the skip after it. The +/// photo is dropped before `KeyframeStore.saveStill`, which then takes its own failure path. +/// The retake reason stays up 10 s instead of 2, so the test's query can't miss it. /// - `-simulateAppStore`: run as an App Store install would, so the developer options and practice /// meter are unavailable whatever the stored switch says (`DeveloperSettings`). It can only take /// the switch away, never offer it. @@ -40,8 +53,12 @@ struct LaunchOptions: Equatable { var autopilotGate: URL? var autopilotCantGetThere = false var autopilotSomethingThere = false + var autopilotCannotCheck = false + var sampleResultAfterSpotAnswer: URL? var simulateAppStore = false var injectGroundRise: Float? + var answersFromGate = false + var failCloseUpSave = false init( arguments: [String] = ProcessInfo.processInfo.arguments, @@ -57,11 +74,21 @@ struct LaunchOptions: Equatable { autopilot = arguments.contains("-autopilot") autopilotCantGetThere = arguments.contains("-autopilotCantGetThere") autopilotSomethingThere = arguments.contains("-autopilotSomethingThere") + autopilotCannotCheck = arguments.contains("-autopilotCannotCheck") + precondition(!(autopilotSomethingThere && autopilotCannotCheck), "-autopilotSomethingThere and -autopilotCannotCheck each choose the first spot answer; pass one") + #if DEBUG + sampleResultAfterSpotAnswer = value(after: "-sampleResultAfterSpotAnswer").map { URL(fileURLWithPath: $0) } + #endif simulateAppStore = arguments.contains("-simulateAppStore") + failCloseUpSave = arguments.contains("-failCloseUpSave") serverURL = (value(after: "-serverURL") ?? defaultServerURL).flatMap(Self.serverURL) sampleResult = arguments.contains("-sampleResult") if let gate = value(after: "-autopilotGate") { autopilotGate = URL(fileURLWithPath: gate, isDirectory: true) } if let hold = value(after: "-autopilotHold").flatMap(Double.init), hold > 0 { autopilotHold = hold } + answersFromGate = arguments.contains("-answersFromGate") + if answersFromGate, autopilotGate == nil || serverURL == nil { + preconditionFailure("-answersFromGate needs -autopilotGate and a server URL (-serverURL or the build's)") + } if let text = value(after: "-injectGroundRise") { guard let meters = Float(text), meters > 0.01, meters <= 0.1 else { preconditionFailure("-injectGroundRise takes meters over 0.01 and at most 0.1, got \(text)") diff --git a/ios/HouseScan/Runtime/ResultClient.swift b/ios/HouseScan/Runtime/ResultClient.swift index 567884fc2..b52dcacc8 100644 --- a/ios/HouseScan/Runtime/ResultClient.swift +++ b/ios/HouseScan/Runtime/ResultClient.swift @@ -9,59 +9,29 @@ protocol ResultClient: AnyObject { func submit(scene: Data, progress: @escaping @Sendable (Double) -> Void) async throws -> Data } -/// Posts scene.json to the placement server: `POST {serverURL}/v1/placements` with the JSON as -/// 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, 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. +/// Posts scene.json to the placement server and returns the answer only when it names the scene +/// sent (HouseScanKit's `PlacementHTTPClient`, which the package tests drive over real HTTP). Only +/// the JSON goes: the keyframes stay on the phone, and in the scan folder's `scan.zip`, which +/// leaves only if the homeowner shares it. @MainActor final class HTTPResultClient: ResultClient { let serverURL: URL let isSample = false + private let client: PlacementHTTPClient - init(serverURL: URL) { + init(serverURL: URL, session: URLSession = .shared) { self.serverURL = serverURL - } - - nonisolated static func makeRequest(serverURL: URL) -> URLRequest { - var request = URLRequest(url: serverURL.appending(path: "v1/placements")) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.setValue("application/json", forHTTPHeaderField: "Accept") - request.timeoutInterval = 120 - return request + client = PlacementHTTPClient(serverURL: serverURL, session: session) } func submit(scene: Data, progress: @escaping @Sendable (Double) -> Void) async throws -> Data { - let request = Self.makeRequest(serverURL: serverURL) - let delegate = UploadProgressDelegate(progress: progress) - let (data, response) = try await URLSession.shared.upload(for: request, from: scene, delegate: delegate) - guard let http = response as? HTTPURLResponse else { throw UploadError.notHTTP } - guard (200..<300).contains(http.statusCode) else { - throw UploadError.server( - 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 + try await client.submit(scene: scene, progress: progress) } } -enum UploadError: Error, CustomStringConvertible { - case notHTTP - case server(status: Int, body: String, retryAfter: String? = nil) - case missingSample - - var description: String { - switch self { - case .notHTTP: "The server's answer was not HTTP." - case .server(let status, let body, _): "The server answered \(status): \(body)" - case .missingSample: "SampleResult.json is missing from the app bundle." - } - } +/// The bundled sample is missing: a build problem, not something the server said. +struct MissingSampleResult: Error, CustomStringConvertible { + var description: String { "SampleResult.json is missing from the app bundle." } } /// What the homeowner reads about a failed upload. Which failures are worth sending again is @@ -69,13 +39,21 @@ enum UploadError: Error, CustomStringConvertible { /// log, and the screen never shows raw error text. enum UploadFailure { /// A failure while sending or reading the answer (not while packaging the scan). - static func state(for error: any Error) -> UploadState { - let kind: UploadFailureKind = if case .server(let status, _, let retryAfter) = error as? UploadError { - UploadFailureKind.classify(httpStatus: status, retryAfter: retryAfter) - } else { - UploadFailureKind.classify(error) + /// `unusableAnswers` counts the server's answers House Scan couldn't use since the scan was + /// sent from the review or a gap; this adds one when `error` is such an answer. + static func state(for error: any Error, sample: Bool, unusableAnswers: inout Int) -> UploadState { + // No server was asked: a missing or unreadable bundled sample is the build's problem, and + // the words must not say a server answered. + if sample { + return .rejected(message: "This build's sample result is missing or unreadable. Start over with a build that has a server.") } - return switch kind { + let kind = UploadFailureKind.classify(error) + if kind == .unreadableAnswer { unusableAnswers += 1 } + return state(for: kind, unusableAnswers: unusableAnswers) + } + + static func state(for kind: UploadFailureKind, unusableAnswers: Int) -> UploadState { + switch kind { case .offline: .failed(message: "Your phone isn't connected to the internet. Your scan is saved on this phone.", offline: true) case .unreachable: @@ -86,8 +64,9 @@ enum UploadFailure { .failed(message: busyMessage(retryAfter), offline: false) case .refused: .rejected(message: "The server couldn't use this scan. Go back to the review to check your marks, or start over.") + // The answer's problem, not the scan's: ask again, never "check your marks". case .unreadableAnswer: - .rejected(message: "We couldn't read the server's answer. Go back to the review and send it again, or start over.") + .unusableAnswer(attempts: max(unusableAnswers, 1)) } } @@ -118,27 +97,6 @@ enum UploadFailure { } } -final class UploadProgressDelegate: NSObject, URLSessionTaskDelegate, Sendable { - private let progress: @Sendable (Double) -> Void - - init(progress: @escaping @Sendable (Double) -> Void) { - self.progress = progress - } - - /// Once the whole body is sent the server is working out the spot in the same request, so - /// report 1 and let the screen move on to "Check clearances". Before that, stop at 0.99 so - /// rounding never shows 100%. Holding 0.99 until the answer left the screen on "Sending - /// measurements, 99%" for the whole analysis (field test run 1). - func urlSession(_ session: URLSession, task: URLSessionTask, didSendBodyData bytesSent: Int64, totalBytesSent: Int64, totalBytesExpectedToSend: Int64) { - guard totalBytesExpectedToSend > 0 else { return } - if totalBytesSent >= totalBytesExpectedToSend { - progress(1) - } else { - progress(min(0.99, Double(totalBytesSent) / Double(totalBytesExpectedToSend))) - } - } -} - /// 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. It answers no particular /// scene (its `input_sha256` is all zeros), so it is deliberately not checked with @@ -148,6 +106,10 @@ final class SampleResultClient: ResultClient { let isSample = true /// Seconds the fake upload takes, so each upload state is visible in demos and UI tests. let pace: Double + /// A server answer in a JSON file that replaces the bundled sample while it is set. Only the + /// engine sets it, from `-sampleResultAfterSpotAnswer` and the scan's spot answers + /// (`ScanEngine.spotConfirm`), for UI tests. + var answerFile: URL? init(pace: Double) { self.pace = pace @@ -159,7 +121,8 @@ final class SampleResultClient: ResultClient { progress(Double(step) / 4) } try await Task.sleep(for: .seconds(pace)) - guard let url = Bundle.main.url(forResource: "SampleResult", withExtension: "json") else { throw UploadError.missingSample } + if let answerFile { return try Data(contentsOf: answerFile) } + guard let url = Bundle.main.url(forResource: "SampleResult", withExtension: "json") else { throw MissingSampleResult() } return try Data(contentsOf: url) } } diff --git a/ios/HouseScan/Runtime/ScanEngine+Actions.swift b/ios/HouseScan/Runtime/ScanEngine+Actions.swift index a2f858dc2..100181f83 100644 --- a/ios/HouseScan/Runtime/ScanEngine+Actions.swift +++ b/ios/HouseScan/Runtime/ScanEngine+Actions.swift @@ -139,12 +139,28 @@ extension ScanEngine: ScanActions { /// Marks a wall end during the walk, or, during a server past_end request, marks that /// request's end again at wherever the wall is now seen to stop. + /// The end at `point`, or with no point at the circle in the middle of the view: the walk's + /// "Wall ends here" button. The button answers the card, which names a side, so on the walk + /// it marks only that side, only with the phone's place known, and only where the circle is + /// on the wall; otherwise the card says why (`EndMarkRefusal`, B-06). Before, each of those + /// did nothing, and aiming across the meter ended the other side. A tap at a point (the + /// autopilot's) marks the side it lands on, as before. func markWallEnd(at point: CGPoint?, viewSize: CGSize) { guard state.phase == .wallWalk || (state.phase == .gapRequest && pastEndSide != nil), - let wall = coverage?.wall, let frame = currentFrame else { return } - guard let hit = wallHit(point, viewSize: viewSize, frame: frame, wall: wall) else { return } + let map = coverage, let frame = currentFrame else { return } + let hit: WallPoint + if point == nil, state.phase == .wallWalk, case .markEnd(let asked) = state.guidance { + switch circleEnd(asked: asked, frame: frame, map: map) { + case .success(let found): hit = found + case .failure(let refusal): return refuseEndMark(refusal) + } + } else { + guard let found = wallHit(point, viewSize: viewSize, frame: frame, wall: map.wall) else { return } + hit = found + } let side: WallSide = hit.s < 0 ? .left : .right if state.phase == .gapRequest, side != pastEndSide { return } + state.endMarkRefusal = nil // Unexplored until the homeowner says something blocks the wall there: an unanswered // question must not tell the server the usable wall stops at this point. state.endQuestion = side @@ -153,6 +169,11 @@ extension ScanEngine: ScanActions { setEnd(side, at: hit.s, kind: .unexplored) } + private func refuseEndMark(_ refusal: EndMarkRefusal) { + state.endMarkRefusal = refusal + RuntimeLog.engine.info("wall ends here refused: \(String(describing: refusal), privacy: .public)") + } + func answerWallEnd(turnsCorner: Bool) { guard let side = state.endQuestion else { return } state.endQuestion = nil @@ -475,7 +496,9 @@ extension ScanEngine: ScanActions { case .wallWalk: guard coverage != nil, !state.endScanQuestion else { return } let task = ScanEngine.name(state.guidance) - if isWalkTask, state.endQuestion == nil, state.marking == nil, !state.overheadQuestion, + // Only the walk's own "Can't get there": "The wall keeps going" on the end card answers + // a question rather than refusing one, so it never asks to end the scan. + if case .walk = state.guidance, state.endQuestion == nil, state.marking == nil, !state.overheadQuestion, state.nextWallConfirm == nil, walkRefusals.asksToEndScan(at: ScanEngine.refusalClock) { // A second "Can't get there" on a walk card soon after the last ended a side: // the homeowner may be trying to stop, so ask before ending this side too (#82). @@ -508,10 +531,14 @@ extension ScanEngine: ScanActions { for index in ScanEngine.hiddenCells(map, band: band, around: s) { map.markSkipped(band, map.cellRange(index)) } } } - case .walk(let side, _), .markEnd(let side): + case .walk(let side, _): // The walk can't continue this way: stop the wall where the phone is, as an // unexplored end (`WalkedEnd`), where the strip's preview showed it. endWalkCannotGoOn(side) + case .markEnd(let side): + // "The wall keeps going": the same unexplored end where the phone is, but not a + // refusal, so it doesn't count toward "End the scan here?" (#82). + endWalkCannotGoOn(side, refused: false) case .tiltUp: settleTiltUp(clear: false) case .markNextWall: @@ -561,9 +588,14 @@ extension ScanEngine: ScanActions { } } + /// Sends the same scan again, only when the homeowner asks: after a network or server failure, + /// or an answer House Scan couldn't use. Never after a refusal, which the review has to fix. func retryUpload() { guard state.phase == .uploading else { return } - if case .failed = state.upload { startUpload() } + switch state.upload { + case .failed, .unusableAnswer: startUpload() + case .idle, .packaging, .uploading, .analyzing, .rejected, .done: break + } } /// Back to the feature review after a rejected upload. The scan (wall, coverage, keyframes, diff --git a/ios/HouseScan/Runtime/ScanEngine+Confirm.swift b/ios/HouseScan/Runtime/ScanEngine+Confirm.swift index 14761faa6..f29831003 100644 --- a/ios/HouseScan/Runtime/ScanEngine+Confirm.swift +++ b/ios/HouseScan/Runtime/ScanEngine+Confirm.swift @@ -22,6 +22,9 @@ struct SpotConfirmState { var request: GuidanceLog.Request? /// Checks asked in this scan, for their ids. var asked = 0 + /// The area of the spot the answer being shown names, nil while it names none: the result + /// speaks of an unchecked area apart from it (`ScanViewState.uncheckedAreaElsewhere`). + var shownArea: SpotArea? } /// The spot check: before an answer's spot is shown as the result, the homeowner is shown the @@ -30,11 +33,13 @@ struct SpotConfirmState { /// /// Camera-only coverage and the walked path claim wall, ground and clear space they never saw /// (HouseScanKit `CoverageMap`, "Bounded exceptions"). "It's clear" backs those claims for this -/// spot and shows the result. "Something's there" withdraws them over the area -/// (`CoverageMap.withdrawClaims`), so the scene reports it unseen, and uploads again, as a closed -/// gap does; the new answer's spot is asked about again unless an earlier answer already covers -/// it (`SpotConfirmations.settling`). Nothing new goes into scene.json: the answer shows only as -/// the stretch the scene no longer claims, and in the packet's guidance log. +/// spot and shows the result. "Something's there" and "I can't check this area" withdraw them +/// over the area (`CoverageMap.withdrawClaims`), so the scene reports it unseen, and upload +/// again, as a closed gap does; the new answer's spot is asked about again unless an earlier +/// answer already covers it (`SpotConfirmations.settling`), and then the result says which of +/// the two the homeowner gave. Nothing new goes into scene.json: the answer shows only as the +/// stretch the scene no longer claims, and in the packet's guidance log, where both close the +/// check as skipped. /// /// The bundled sample is checked like a server's answer, and the screen labels it a sample: the /// UI tests and the offline demo only ever see the sample, so skipping it would leave the step @@ -66,16 +71,19 @@ extension ScanEngine { guard let result = state.result, let spot = result.spot, let wall = coverage?.wall, let sceneSHA256 = spotConfirm.sceneSHA256, let answerSHA256 = spotConfirm.answerSHA256 else { state.spotCheck = nil + spotConfirm.shownArea = nil go(.result) return } let area = SpotArea( spot: spot.span, spotOut: spot.offsetFromWall...(spot.offsetFromWall + spot.depth), zones: result.clearances.map { (span: $0.span, depth: $0.depth) }) + spotConfirm.shownArea = area spotConfirm.asked += 1 let id = spotConfirm.asked if let settled = spotConfirm.confirmations.settling(area) { - // Kept for the result, which says when the homeowner said something stands there. + // Kept for the result, which says when the homeowner said something stands there or + // couldn't check the area. state.spotCheck = Self.spotCheck(id: id, area: area, spot: spot, photo: nil, answer: Self.answer(settled.answer), isSample: result.isSample) RuntimeLog.engine.info("spot check: s=\(area.spot.lowerBound)...\(area.spot.upperBound) was answered before (\(settled.answer.rawValue, privacy: .public)); showing the result") go(.result) @@ -84,9 +92,6 @@ extension ScanEngine { let candidates = store.keyframes.map { SpotPhotoCandidate(id: $0.id, camera: $0.camera, trackingNormal: $0.tracking == .normal) } let choice = SpotPhoto.best(candidates, area: area, wall: wall) let stored = choice.flatMap { choice in store.keyframes.first { $0.id == choice.id } } - // `answer` is replaced by the homeowner's (`answerSpotCheck`) before it is recorded. - spotConfirm.pending = SpotConfirmation(area: area, answerSHA256: answerSHA256, sceneSHA256: sceneSHA256, photoID: stored?.id, answer: .clear) - spotConfirm.request = spotCheckRequest(area: area, photoID: stored?.id, answerSHA256: answerSHA256, sceneSHA256: sceneSHA256, id: id) let file = stored.map { store.directory.appending(path: $0.fileName) } if let choice { RuntimeLog.engine.info("spot check: photo \(choice.id, privacy: .public) shows \(Int(choice.footprintInView * 100))% of the spot and \(Int(choice.areaInView * 100))% of s=\(area.span.lowerBound)...\(area.span.upperBound)") @@ -100,30 +105,49 @@ extension ScanEngine { if let image, let camera = stored?.camera { photo = SpotCheck.Photo(image: image, projection: CameraProjection( cameraToWorld: camera.cameraToWorld, intrinsics: camera.intrinsics, imageSize: camera.imageSize)) + } else if let stored { + RuntimeLog.engine.error("spot check: keyframe \(stored.id, privacy: .public) didn't load; asking without a photo") } - state.spotCheck = Self.spotCheck(id: id, area: area, spot: spot, photo: photo, answer: nil, isSample: result.isSample) + let check = Self.spotCheck(id: id, area: area, spot: spot, photo: photo, answer: nil, isSample: result.isSample) + // Recorded once the photo has loaded, from what the screen shows: the photo only when + // it is outlined on screen, and the question asked with or without the outline. + let outlined = check.outline(on: state.wall) != nil + let shownID = outlined ? stored?.id : nil + // `answer` is replaced by the homeowner's (`answerSpotCheck`) before it is recorded. + spotConfirm.pending = SpotConfirmation(area: area, answerSHA256: answerSHA256, sceneSHA256: sceneSHA256, photoID: shownID, answer: .clear) + spotConfirm.request = spotCheckRequest( + area: area, question: ScanCopy.spotQuestionShown(outlined: outlined), photoID: shownID, + answerSHA256: answerSHA256, sceneSHA256: sceneSHA256, id: id) + state.spotCheck = check go(.spotConfirm) } } /// The homeowner's answer to the spot check on screen. - func answerSpotCheck(clear: Bool) { + func answerSpotCheck(_ answer: SpotCheckAnswer) { guard state.phase == .spotConfirm, var check = state.spotCheck, check.answer == nil, var confirmation = spotConfirm.pending else { return } - confirmation.answer = clear ? .clear : .somethingThere + confirmation.answer = Self.confirmationAnswer(answer) spotConfirm.confirmations.record(confirmation) spotConfirm.pending = nil - check.answer = Self.answer(confirmation.answer) + check.answer = answer state.spotCheck = check - // A refusal sends the area to review without the claims, like an overhead answer. - resolveGuidance(clear ? .met : .skipped) + // An answer other than "It's clear" sends the area to review without the claims, like an + // overhead answer. + resolveGuidance(confirmation.answer.guidanceOutcome) let area = confirmation.area - if !clear { updateCoverage { $0.withdrawClaims(over: area.span) } } - RuntimeLog.engine.info("spot check \(check.id): \(clear ? "clear" : "something there, claims withdrawn", privacy: .public) over s=\(area.span.lowerBound)...\(area.span.upperBound), photo \(confirmation.photoID ?? "none", privacy: .public), answer \(confirmation.answerSHA256.prefix(12), privacy: .public), scene \(confirmation.sceneSHA256.prefix(12), privacy: .public)") + let keepsClaims = confirmation.answer.keepsClaims + if !keepsClaims { updateCoverage { $0.withdrawClaims(over: area.span) } } + let said = switch confirmation.answer { + case .clear: "clear" + case .somethingThere: "something there, claims withdrawn" + case .cannotCheck: "can't check, claims withdrawn" + } + RuntimeLog.engine.info("spot check \(check.id): \(said, privacy: .public) over s=\(area.span.lowerBound)...\(area.span.upperBound), photo \(confirmation.photoID ?? "none", privacy: .public), answer \(confirmation.answerSHA256.prefix(12), privacy: .public), scene \(confirmation.sceneSHA256.prefix(12), privacy: .public)") let id = check.id Task { try? await Task.sleep(for: .seconds(spotAnsweredHold)) guard state.phase == .spotConfirm, state.spotCheck?.id == id else { return } - guard clear else { + guard keepsClaims else { // The scan without those claims goes to the server again. startUpload() return @@ -138,8 +162,9 @@ extension ScanEngine { } /// Whether a capture can settle a gap request: not over a stretch whose claims the homeowner - /// withdrew, where a new view would claim the same thing past the same obstruction. Overhead - /// requests are not about that stretch's wall or ground. + /// withdrew, where a new view would claim the same thing past the same obstruction, or past + /// whatever kept the homeowner from checking it. Overhead requests are not about that + /// stretch's wall or ground. func captureCanSettle(_ plan: GapPlan) -> Bool { if case .overhead = plan.need { return true } return !(coverage?.hasWithdrawnClaims(overlapping: plan.span) ?? false) @@ -155,15 +180,15 @@ extension ScanEngine { var spotCheckGuidance: GuidanceLog.Request? { spotConfirm.request } /// A gap_band request on the ground over the area. scene.json and the packet have no field - /// for the check, so the message carries what binds it: the photo shown and the sha256 of the - /// answer and scene it was about. - private func spotCheckRequest(area: SpotArea, photoID: String?, answerSHA256: String, sceneSHA256: String, id: Int) -> GuidanceLog.Request { + /// for the check, so the message carries what binds it: the question and photo shown and the + /// sha256 of the answer and scene it was about. + private func spotCheckRequest(area: SpotArea, question: Instruction, photoID: String?, answerSHA256: String, sceneSHA256: String, id: Int) -> GuidanceLog.Request { let format = { (value: Float) in String(format: "%.2f", value) } let binding = "Spot check \(id): keyframe \(photoID ?? "none") shown; spot s \(format(area.spot.lowerBound)) to \(format(area.spot.upperBound)) m, " + "area s \(format(area.span.lowerBound)) to \(format(area.span.upperBound)) m out to \(format(area.depth)) m; " + "answer sha256 \(answerSHA256), scene sha256 \(sceneSHA256)." return GuidanceLog.Request( - topic: .spotCheck(id: id), kind: .gapBand, origin: .phone, message: Self.text(ScanCopy.spotQuestion) + " " + binding, + topic: .spotCheck(id: id), kind: .gapBand, origin: .phone, message: Self.text(question) + " " + binding, band: .ground, span: area.span) } @@ -190,6 +215,15 @@ extension ScanEngine { switch answer { case .clear: .clear case .somethingThere: .somethingThere + case .cannotCheck: .cannotCheck + } + } + + private static func confirmationAnswer(_ answer: SpotCheckAnswer) -> SpotConfirmationAnswer { + switch answer { + case .clear: .clear + case .somethingThere: .somethingThere + case .cannotCheck: .cannotCheck } } } diff --git a/ios/HouseScan/Runtime/ScanEngine+Ends.swift b/ios/HouseScan/Runtime/ScanEngine+Ends.swift index 5fda30149..b61cf80cd 100644 --- a/ios/HouseScan/Runtime/ScanEngine+Ends.swift +++ b/ios/HouseScan/Runtime/ScanEngine+Ends.swift @@ -67,10 +67,8 @@ extension ScanEngine { let s: Float var seen: ClosedRange? if offer.atReticle { - // `markWallEnd` with no point: the middle of the view, which is the sensor image's - // middle whatever the view's size. - guard frame.tracking == .normal, - let hit = nearbyWallHit(frame.camera.ray(throughPixel: frame.camera.imageSize / 2), camera: frame.camera, wall: map.wall) else { return nil } + // Where the button would put it: nothing when the button would refuse (`circleEnd`). + guard case .success(let hit) = circleEnd(asked: offer.side, frame: frame, map: map) else { return nil } side = hit.s < 0 ? .left : .right s = hit.s } else { @@ -100,10 +98,33 @@ extension ScanEngine { return EndPreview(side: side, s: s, atReticle: offer.atReticle, leavesOutWalked: leavesOut, leavesOutSeen: isSeen) } + /// Where "Wall ends here" at the circle puts the end the card asks for on `asked`, or why it + /// can't (`EndAim`, B-06). The circle is the middle of the view, which is the sensor image's + /// middle whatever the view's size. Shared by the button and its preview, so the tape never + /// shows an end the button would refuse. Only normal tracking counts, as for feature marks. + func circleEnd(asked: WallSide, frame: SourceFrame, map: CoverageMap) -> Result { + let hit = map.wall.intersectWall(frame.camera.ray(throughPixel: frame.camera.imageSize / 2)) + switch EndAim.verdict( + hit: hit, camera: frame.camera.position, wall: map.wall, reach: map.config.maxDistance, + groundError: map.heightError, askedLeft: asked == .left, trackingNormal: frame.tracking == .normal + ) { + case .end(let point): return .success(point) + case .trackingLimited: return .failure(.trackingNotReady) + case .offWall: return .failure(.noWall) + case .otherSide: return .failure(.otherSide(asked == .left ? .right : .left)) + } + } + /// Republishes the end preview; called with every guidance update, since the phone moves. func publishEndPreview() { let preview = currentEndPreview() if preview != state.endPreview { state.endPreview = preview } + // A refusal stands until the circle is on the end the card asks for, or the card moves on. + if state.endMarkRefusal != nil { + var fixed = true + if case .markEnd(let side) = state.guidance { fixed = preview?.atReticle == true && preview?.side == side } + if fixed { state.endMarkRefusal = nil } + } } func endWallHere() { @@ -126,11 +147,12 @@ extension ScanEngine { /// "Can't get there" while the walk asks to walk `side` or to mark its end: the end goes where /// the preview showed, as an unexplored end. `walkRefusals` notes when, and whether the side /// had been walked (#82, #76). - func endWalkCannotGoOn(_ side: WallSide) { + func endWalkCannotGoOn(_ side: WallSide, refused: Bool = true) { guard let s = walkedEnd(side), let map = coverage else { return } - logEnd("can't get there", side: side, at: s) + logEnd(refused ? "can't get there" : "the wall keeps going", side: side, at: s) let walked = map.walkedFarthest(side.walk) setEnd(side, at: s, kind: .unexplored) + guard refused else { return } walkRefusals.ended(side.walk, at: s, walked: walked, time: ScanEngine.refusalClock) if walkRefusals.wasRefused(side.walk, end: s) { RuntimeLog.engine.info("the \(side.rawValue, privacy: .public) side ended before it was walked (\(walked) m walked, end at s=\(s))") diff --git a/ios/HouseScan/Runtime/ScanEngine+Export.swift b/ios/HouseScan/Runtime/ScanEngine+Export.swift index 2047ffb97..1c1832946 100644 --- a/ios/HouseScan/Runtime/ScanEngine+Export.swift +++ b/ios/HouseScan/Runtime/ScanEngine+Export.swift @@ -252,26 +252,28 @@ extension ScanEngine { needsPerson: check.needsPerson, measured: check.measuredFt.map(meters), threshold: check.thresholdFt.map(meters), plusMinus: check.plusMinusFt.map(meters), comparison: check.comparison.map(Self.comparison), + reviewThreshold: check.reviewThresholdFt.map(meters), reviewBandApplies: check.reviewBandApplies, settledBy: result.evidenceIndex(settling: check.id).map(Self.missingID) ) } - // A run's start_ft is a range of battery LEFT edges, so the wall it describes reaches one - // battery width past its last start. The spot carries the size, or after a reject the - // nearest spot, which the server sized the same way. With neither, the zone is drawn over - // the starts alone at 0.3 m deep, which understates it. - let sized = spot ?? nearestSpot - let depth = sized?.depth ?? 0.3 - let width = sized.map { $0.span.upperBound - $0.span.lowerBound } ?? 0 - let clearances = result.sweep.enumerated().map { index, run in - let first = meters(min(run.startFt.x, run.startFt.y)) - let last = meters(max(run.startFt.x, run.startFt.y)) + // The spot carries the battery's size, or without one the nearest spot, which the server + // sized the same way. With neither, `sweepZones` gives no zones: the result keeps its + // wall, summary and reasons, and no view draws floor the server never sized. + let sized = (spot ?? nearestSpot).map { + ResultMarkLayout.BatterySize(width: $0.span.upperBound - $0.span.lowerBound, depth: $0.depth) + } + let zones = ResultMarkLayout.sweepZones( + starts: result.sweep.map { SIMD2(meters($0.startFt.x), meters($0.startFt.y)) }, battery: sized) + // One zone per run, or none. + let clearances = zip(result.sweep, zones).enumerated().map { index, pair in + let (run, zone) = pair return ClearanceZone( id: "sweep-\(index)", label: (run.failing + run.unsure).joined(separator: ", "), outcome: Self.outcome(run.outcome), - span: first...(last + width), - depth: depth + span: zone.span, + depth: zone.depth ) } diff --git a/ios/HouseScan/Runtime/ScanEngine.swift b/ios/HouseScan/Runtime/ScanEngine.swift index 388f6e516..d43244723 100644 --- a/ios/HouseScan/Runtime/ScanEngine.swift +++ b/ios/HouseScan/Runtime/ScanEngine.swift @@ -192,6 +192,9 @@ final class ScanEngine { private var uploadTask: Task? /// The current upload's scene has fixed its ground; keep this true through answer pacing. private var scenePackaged = false + /// The server's answers House Scan couldn't use since this scan was last sent from the review + /// or a gap; "Try again" keeps counting, and a usable answer starts again at 0. + private var unusableAnswers = 0 private(set) var placement: PlacementResult? /// Whether a ground change took the answer down and no answer has been shown since /// (`answerAfter(_:)`). @@ -215,8 +218,20 @@ final class ScanEngine { } /// Every request the homeowner was shown, for the packet. var guidanceLog = GuidanceLog() - /// The spot check (`ScanEngine+Confirm.swift`). - var spotConfirm = SpotConfirmState() + /// The spot check (`ScanEngine+Confirm.swift`). What follows from its records is read from + /// them on every change, so recording an answer sets it and a reset clears it: + /// `ScanViewState.uncheckedAreaElsewhere`, and the UI tests' answer file + /// (`-sampleResultAfterSpotAnswer`), which applies only once this scan has an answer other + /// than "It's clear", so a scan after Start over or a new wall starts on the bundled sample. + var spotConfirm = SpotConfirmState() { + didSet { + let elsewhere = spotConfirm.confirmations.leftUnchecked(besides: spotConfirm.shownArea) + if state.uncheckedAreaElsewhere != elsewhere { state.uncheckedAreaElsewhere = elsewhere } + if let file = options.sampleResultAfterSpotAnswer, let sample = resultClient as? SampleResultClient { + sample.answerFile = spotConfirm.confirmations.records.contains { !$0.answer.keepsClaims } ? file : nil + } + } + } /// When each mark was made, on the capture clock (`MarkKey`). var markTimes: [String: Double] = [:] /// The packet's clock for guidance and marks: the latest frame's time, ARFrame.timestamp @@ -243,7 +258,7 @@ final class ScanEngine { store = KeyframeStore() recorder = Self.makeRecorder(store) if let url = options.serverURL, !options.sampleResult { - resultClient = HTTPResultClient(serverURL: url) + resultClient = HTTPResultClient(serverURL: url, session: options.answersFromGate ? GateAnswerProtocol.session : .shared) } else { resultClient = SampleResultClient(pace: options.autopilot ? options.autopilotHold : 1.2) } @@ -514,7 +529,7 @@ final class ScanEngine { case .findMeter, .meterCloseUp, .wallWalk, .markFeatures, .gapRequest: true case .uploading: switch state.upload { - case .failed, .rejected: false + case .failed, .rejected, .unusableAnswer: false case .idle, .packaging, .uploading, .analyzing, .done: true } case .onboarding, .spotConfirm, .result, .resultAR, .unsupported: false @@ -756,7 +771,7 @@ final class ScanEngine { state.coaching = coaching(for: frame.tracking, skip: nil) // After a retake request the shutter waits long enough for the reason to be read (and, // for "move closer", acted on) before the hold can start again. - if let retake = closeUpRetake, screenTime - retake.since < Self.retakeNotice { + if let retake = closeUpRetake, screenTime - retake.since < currentRetakeNotice { state.closeUp = .aiming(hold: 0, problem: retake.problem) return } @@ -786,6 +801,10 @@ final class ScanEngine { /// Seconds a retake reason stays up before the next close-up can be taken. A guess to try /// on a phone, not measured: long enough to read one short line. private static let retakeNotice: Double = 2 + /// Under `-failCloseUpSave` the reason stays up 10 s, so the UI test's query can't miss it: + /// the replay is close to the meter for under a second, and after the 2 s notice the gate's + /// "Move closer" replaces it. + private var currentRetakeNotice: Double { options.failCloseUpSave && replay != nil ? 10 : Self.retakeNotice } private var screenTime: Double { Double(DispatchTime.now().uptimeNanoseconds) / 1_000_000_000 } @@ -827,42 +846,55 @@ final class ScanEngine { Task { // The task can start after a reset or after the flow left the close-up. guard scan == generation, state.phase == .meterCloseUp else { return } - let photo = await closeUpPhoto(frame) + var photo = await closeUpPhoto(frame) + if options.failCloseUpSave, replay != nil { photo.jpeg = .none } // Drawing a practice photo suspends: a reset meanwhile must not save into the new scan. guard scan == generation, state.phase == .meterCloseUp else { return } let saved = await store.saveStill(photo, name: "meter_close.jpg") guard scan == generation, state.phase == .meterCloseUp else { return } - if !saved { - retakeCloseUp(.blurry) + switch saved { + case .saved: + break + case .notWritten: + retakeCloseUp(.photoNotSaved) + return + case .worldDiscarded: + // The discard sent the flow back to finding the meter, so the phase guard above + // returns first. Past it, the meter was found again while this file was written: + // the shot belongs to the old frame, and the new close-up is left to take its own. + RuntimeLog.engine.info("close-up from a discarded world frame dropped") return } - let thumbnail = await store.thumbnail(ofStill: "meter_close.jpg") + // Read back once, before the capture is acknowledged: the thumbnail and the meter + // reader both use these bytes, and a photo that can't be read back wasn't kept. + let file = store.directory.appending(path: "meter_close.jpg") + let readBack = await Task.detached(priority: .userInitiated) { () -> (jpeg: Data, thumbnail: CGImage?)? in + guard let jpeg = try? Data(contentsOf: file) else { return nil } + return (jpeg, ImageWork.uprightThumbnail(jpeg: jpeg)) + }.value guard scan == generation, state.phase == .meterCloseUp else { return } - state.closeUp = .captured(thumbnail) + guard let readBack else { + RuntimeLog.engine.error("close-up photo could not be read back") + closeUpCredit.photoChecked(view, passed: false) + retakeCloseUp(.photoNotSaved) + return + } + state.closeUp = .captured(readBack.thumbnail) state.captureCount += 1 - state.lastCapture = CaptureEvent(id: state.captureCount, kind: .closeUp, thumbnail: thumbnail) - await readMeterNumber(scan: scan, view: view) + state.lastCapture = CaptureEvent(id: state.captureCount, kind: .closeUp, thumbnail: readBack.thumbnail) + await readMeterNumber(readBack.jpeg, scan: scan, view: view) } } - /// Reads the meter number from the saved close-up, off the main actor, then offers the - /// candidates for the homeowner to pick from (never filling one in) or asks for a retake. + /// Reads the meter number from the saved close-up's bytes, off the main actor, then offers + /// the candidates for the homeowner to pick from (never filling one in) or asks for a retake. /// `view` is the shot's, which coverage may take once the reader has passed its photo. - private func readMeterNumber(scan: Int, view: CloseUpView?) async { + private func readMeterNumber(_ jpeg: Data, scan: Int, view: CloseUpView?) async { state.meterNumber = .reading let reader = MeterNumberReaders.make() - let photo = store.directory.appending(path: "meter_close.jpg") - let readout = await Task.detached(priority: .userInitiated) { () -> MeterReadout? in - guard let jpeg = try? Data(contentsOf: photo) else { return nil } - return await reader.read(jpeg: jpeg) - }.value + let readout = await Task.detached(priority: .userInitiated) { await reader.read(jpeg: jpeg) }.value guard scan == generation, state.phase == .meterCloseUp, state.meterNumber == .reading else { return } - closeUpCredit.photoChecked(view, passed: readout?.photoPassedChecks == true) - guard let readout else { - RuntimeLog.engine.error("close-up photo could not be read back for the meter number") - retakeCloseUp(.noNumber) - return - } + closeUpCredit.photoChecked(view, passed: readout.photoPassedChecks) guard !readout.candidates.isEmpty else { retakeCloseUp(readout.retake ?? .noNumber) return @@ -1675,7 +1707,7 @@ final class ScanEngine { let screen: GroundFreshness.Screen = switch state.phase { case .uploading: switch state.upload { - case .failed, .rejected: .stopped + case .failed, .rejected, .unusableAnswer: .stopped case .idle, .packaging, .uploading, .analyzing, .done: .sending } case .spotConfirm: .spotCheck @@ -1717,6 +1749,7 @@ final class ScanEngine { state.spotCheck = nil spotConfirm.pending = nil spotConfirm.request = nil + spotConfirm.shownArea = nil // A spot photo still loading for this answer (`presentAnswer`) must not open its check // once a retry brings back an equal answer. spotConfirm.asked += 1 @@ -1844,6 +1877,7 @@ final class ScanEngine { coverage = map endKinds[side] = kind state.wallTooShort = false + state.endMarkRefusal = nil publishWall() publishCoverage() RuntimeLog.engine.info("end \(side.rawValue, privacy: .public) at s=\(s) (\(kind == .limit ? "limit" : "unexplored", privacy: .public))") @@ -1999,6 +2033,8 @@ final class ScanEngine { func startUpload() { scenePackaged = false + // "Try again" sends from the upload screen; anything else is a new send of this scan. + if state.phase != .uploading { unusableAnswers = 0 } go(.uploading) injectGroundForTest() uploadTask?.cancel() @@ -2080,6 +2116,7 @@ final class ScanEngine { guard scan == generation, state.phase == .uploading, !Task.isCancelled else { return } } placement = result + unusableAnswers = 0 noteExchange(scene: scene, answer: data, packet: packaged.packet) writeScanStamp(answer: result) state.result = presentation(of: result, isSample: resultClient.isSample) @@ -2111,7 +2148,7 @@ final class ScanEngine { // failure the canceller already shows. guard scan == generation, !Task.isCancelled else { return } RuntimeLog.engine.error("upload failed: \(String(describing: error), privacy: .public)") - state.upload = UploadFailure.state(for: error) + state.upload = UploadFailure.state(for: error, sample: resultClient.isSample, unusableAnswers: &unusableAnswers) updateRecording() } } @@ -2125,8 +2162,9 @@ final class ScanEngine { // A request raised while the phone has lost its place could only time out: show the result. guard !automaticGapsStopped, !state.tracking.hasLostItsPlace, let map = coverage else { return [] } let asked = automaticGaps + (asking.map { [$0] } ?? []) - // A new view cannot settle an area the homeowner has already said is obstructed. - // Filter before the request limit so refused areas do not consume the remaining slots. + // A new view cannot settle an area the homeowner has already said is obstructed, or + // couldn't check. Filter before the request limit so those areas do not consume the + // remaining slots. let capturable = result.missingEvidence.filter { item in gapPlanner.plan(for: item, leftEnd: map.leftEnd, rightEnd: map.rightEnd, limitEnds: map.limitEnds) .map { captureCanSettle($0) } ?? false diff --git a/ios/HouseScan/UI/Copy/ScanCopy.swift b/ios/HouseScan/UI/Copy/ScanCopy.swift index 904b82fc8..24f23609e 100644 --- a/ios/HouseScan/UI/Copy/ScanCopy.swift +++ b/ios/HouseScan/UI/Copy/ScanCopy.swift @@ -71,7 +71,7 @@ enum ScanCopy { case .markEnd(let side): Instruction( title: "Is this the \(side.rawValue) end of the wall?", - detail: "Aim where the wall stops or turns a corner, and tap Wall ends here." + detail: "Aim where it stops or turns a corner and tap Wall ends here. If it goes on, tap The wall keeps going." ) case .aimAtGround(let s): // A cell counts once seen from two places at least 0.25 m apart (`coveringBaseline`), @@ -145,7 +145,9 @@ enum ScanCopy { // MARK: Coaching - static func coaching(_ coaching: Coaching) -> Instruction { + /// `meterPhoto` says whether the screen shows the meter close-up beside the card + /// (`SavedMeterPhoto`): only then can it say "like this" (B-23). + static func coaching(_ coaching: Coaching, meterPhoto: Bool = false) -> Instruction { switch coaching { case .initializing: Instruction(title: "Move your phone slowly", detail: "It's getting its bearings.") @@ -162,7 +164,9 @@ enum ScanCopy { case .turnSlowly: Instruction(title: "Turn more slowly", detail: "Photos taken while turning come out blurred.") case .relocalizing: - Instruction(title: "Point at the meter like this.", detail: "Your phone lost its place for a moment.") + meterPhoto + ? Instruction(title: "Point at the meter like this.", detail: "Your phone lost its place for a moment.") + : Instruction(title: "Point back at your meter", detail: "Your phone lost its place for a moment.") case .trackingLost: Instruction(title: "Your phone lost its place", detail: "Aim back at your meter and move slowly.") case .pastWallEnd: @@ -260,6 +264,7 @@ enum ScanCopy { case .tracking: "Move slowly" case .numberTooSmall: "Move closer so the number looks bigger" case .noNumber: "Couldn't read the number. Hold still for another photo." + case .photoNotSaved: "Your phone couldn't save that photo. Hold on the meter to try again." } } @@ -361,7 +366,9 @@ enum ScanCopy { static func refusal(_ refusal: MarkRefusal) -> String { switch refusal { case .noSurface: "Nothing to pin there. Aim at the wall or the ground and try again." - case .wrongSide: "That spot is behind the wall. Tap something on this side." + // The check is on the phone, not the tap: from behind the wall's line no tap can land + // (B-27), so the words say where to stand. + case .wrongSide: "Your phone is behind the wall's line. Step out in front of the wall, then try again." case .tooFarFromWall: "That's too far from the wall to matter. Tap something closer." case .trackingNotReady: "One moment, your phone is still finding its place." } @@ -379,6 +386,20 @@ enum ScanCopy { } /// A refused mark of the next wall: what went wrong, then what to do. + static let wallKeepsGoing = "The wall keeps going" + + /// Why "Wall ends here" marked nothing, and what to do instead (`EndMarkRefusal`). + static func endMarkRefusal(_ refusal: EndMarkRefusal, asked side: WallSide) -> Instruction { + switch refusal { + case .noWall: + Instruction(title: "The circle isn't on the wall", detail: "Aim it at the wall where it stops or turns, then tap Wall ends here.") + case .otherSide(let landed): + Instruction(title: "That's the \(landed.rawValue) side of your meter", detail: "Turn to the \(side.rawValue) end, then tap Wall ends here.") + case .trackingNotReady: + Instruction(title: "One moment, your phone is still finding its place", detail: "Then aim at the \(side.rawValue) end.") + } + } + static func nextWallRefusal(_ refusal: NextWallRefusal) -> Instruction { switch refusal { case .noSurface: Instruction(title: "No wall under the circle", detail: "Step closer and aim at the next wall.") @@ -483,7 +504,11 @@ enum ScanCopy { /// what it does on `step`; nil on a step that offers none. On an aim or tilt step the /// homeowner is already at the spot and it's the view that can't be had, so "Can't get /// there" read as the wrong answer and testers kept tilting (#63). It stays on the steps - /// that ask to go somewhere. The wall's end (`markEnd`) has "Wall ends here" instead. + /// that ask to go somewhere. The wall's end (`markEnd`) asks whether the wall ends here, so + /// its reply is the other answer: the wall goes on. It had none, and a wall running past + /// 20 ft had no true answer to "Is this the end of the wall?" (B-06). The end goes where the + /// walk reached, unexplored, exactly as "Can't get there" on the walk puts it (`WalkedEnd`): + /// the phone's place, but no farther than a little past the last view of the wall. static func reply(for step: GuidanceStep) -> (title: String, hint: String)? { switch step { case .aimAtGround, .aimAtWall: @@ -496,7 +521,9 @@ enum ScanCopy { (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 would need to look at it instead.") - case .findMeter, .aimAtWallForMeter, .holdOnMeter, .markEnd, .stepBack, .walkComplete, .gap: + case .markEnd: + (title: wallKeepsGoing, hint: "Ends this side where your walk reached. An installer would need to look at the wall past it.") + case .findMeter, .aimAtWallForMeter, .holdOnMeter, .stepBack, .walkComplete, .gap: nil } } @@ -563,7 +590,7 @@ enum ScanCopy { switch upload { case .idle, .packaging, .uploading, .analyzing: return Instruction(title: "Making a sample result", detail: "No server is connected, so nothing leaves this phone. The result you'll see is an example, not a check of your wall.") - case .failed, .rejected, .done: + case .failed, .rejected, .unusableAnswer, .done: break } } @@ -586,6 +613,13 @@ enum ScanCopy { // The engine's message is already in the homeowner's words and says why. case .rejected(let message): Instruction(title: "We couldn't check this scan", detail: message) + // The server did answer; what failed is the answer, so the words never point at the marks. + case .unusableAnswer(let attempts) where attempts > 1: + Instruction(title: "The answer still couldn't be used", detail: "Asking again might not fix this. It isn't about what you marked.") + case .unusableAnswer: + Instruction( + title: "We couldn't use the server's answer", + detail: "The server answered, but House Scan couldn't read the answer or match it to your scan, so it isn't shown. It isn't about what you marked.") case .done: Instruction(title: "Done", detail: nil) } @@ -635,11 +669,13 @@ enum ScanCopy { /// The sentence under a failed or unsure line on the result card: the measurement against the /// rule, or without a measurement, the server's reason (fail) or who settles it (unsure). - static func cardLine(_ row: CheckRow, spoken: Bool = false) -> String? { + /// `photoOffered` is whether the line offers the camera for the view that settles it + /// (`ResultPresentation.offeredView`). + static func cardLine(_ row: CheckRow, photoOffered: Bool, spoken: Bool = false) -> String? { switch row.outcome { case .pass: nil case .fail: measurement(row, spoken: spoken) ?? row.reason - case .unsure: measurement(row, spoken: spoken) ?? unsureNote(row) + case .unsure: measurement(row, spoken: spoken) ?? unsureNote(photoOffered: photoOffered) } } @@ -696,22 +732,39 @@ enum ScanCopy { /// 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 ? Self.needsInstaller : "One more photo would settle this" + static let onePhoto = "One more photo would settle this" + + /// Who settles an unsure check. One more photo only when the app offers the camera for it + /// (`ResultPresentation.offeredView`): keyed on the check alone, a view the homeowner + /// couldn't get to, or one the app can't plan, still promised a photo nothing would take. + static func unsureNote(photoOffered: Bool) -> String { + photoOffered ? Self.onePhoto : Self.needsInstaller } /// "Measured 3 ft 2 in. The rule is at least 3 ft, and the measurement can be off by about 4 in." /// The limit says whether it is a minimum or a maximum: without it, the 20 ft cable limit read /// like a minimum under "Measured 3 ft". /// `spoken` spells out feet and inches for VoiceOver, which reads "ft" and "in" as letters. + /// + /// When the server's review line explains an unsure check (`reviewBand`), the rule names it too: + /// "Measured 16 ft. The rule is at most 20 ft, and anything 15 ft or more needs an installer's + /// review. The measurement can be off by about 6 in." Without it, a cable run inside the + /// maximum read as passing under "Not sure yet". It says what the rule needs, never that a + /// review was sent: the app contacts nobody. static func measurement(_ row: CheckRow, spoken: Bool = false) -> String? { guard let measured = row.measured else { return nil } let length = spoken ? Distance.spoken : Distance.feetAndInches var parts = [measuredLine(measured, length: length)] if let threshold = row.threshold { let limit = ruleLimit(threshold, row.comparison, length: length) - if let plusMinus = row.plusMinus, plusMinus > 0 { - parts.append("The rule is \(limit), and the measurement can be off by about \(length(plusMinus)).") + let error = row.plusMinus.flatMap { $0 > 0 ? length($0) : nil } + if let band = reviewBand(row, length: length) { + parts.append("The rule is \(limit), and \(band) needs an installer's review.") + if let error { + parts.append("The measurement can be off by about \(error).") + } + } else if let error { + parts.append("The rule is \(limit), and the measurement can be off by about \(error).") } else { parts.append("The rule is \(limit).") } @@ -719,6 +772,19 @@ enum ScanCopy { return parts.joined(separator: " ") } + /// "anything 15 ft or more" under a maximum, "anything 4 ft or less" over a minimum: the side of the + /// check's review line that needs review. Nil unless the line explains this check's outcome + /// (`CheckRow.reviewBandApplies`). + static func reviewBand(_ row: CheckRow, length: (Float) -> String = Distance.feetAndInches) -> String? { + guard row.reviewBandApplies, let review = row.reviewThreshold, let comparison = row.comparison else { return nil } + return switch comparison { + // Inclusive: a confident pass needs measured + error < review_threshold_ft (at_most), so a + // value landing exactly on the line already needs review (result.schema.json). + case .atMost: "anything \(length(review)) or more" + case .atLeast: "anything \(length(review)) or less" + } + } + /// "Measured 3 ft 2 in.", or "Overlaps by 1 ft 3 in." below zero. A clearance the server /// measures to an area (the meter's working space, a box on the wall above) goes negative /// once the battery is inside it, and the bare magnitude read as clearance (#40). Less than diff --git a/ios/HouseScan/UI/Copy/SpotCheckCopy.swift b/ios/HouseScan/UI/Copy/SpotCheckCopy.swift index 9640f394a..0abd76894 100644 --- a/ios/HouseScan/UI/Copy/SpotCheckCopy.swift +++ b/ios/HouseScan/UI/Copy/SpotCheckCopy.swift @@ -2,39 +2,99 @@ import Foundation /// The spot check's words (`SpotConfirmScreen`, and the guidance log's record of the question). extension ScanCopy { - /// One question about the wall and the ground, with the reason for asking. + /// One question about the wall and the ground, with the reason for asking, when the area is + /// outlined on a photo. static let spotQuestion = Instruction( title: "Is anything standing in the marked area?", detail: "Check the wall and the ground inside the blue outline. Photos can miss a low bush, a bin or a step." ) + + /// The same question when no outline is drawn: no kept photo shows the area, or there is no + /// wall to draw it with. The area is then given in words (`spotSpace`), so nothing points + /// at an outline that isn't on screen. + static let spotQuestionInPerson = Instruction( + title: "Is anything standing in this space?", + detail: "We couldn't mark this space on a photo, so take a look yourself. Check the wall and the ground for a low bush, a bin, a step or anything else." + ) + static let spotSpaceTitle = "Where to look" + + /// The question as shown (`SpotCheck.outline(on:)`), on screen and in the guidance log. + static func spotQuestionShown(outlined: Bool) -> Instruction { + outlined ? spotQuestion : spotQuestionInPerson + } static let spotClear = "It's clear" static let spotSomethingThere = "Something's there" + /// For a homeowner who can't see or reach the area, so they needn't guess between the other + /// two. It says nothing about what is there. + static let spotCannotCheck = "I can't check this area" static let spotClearHint = "Shows your result." static let spotSomethingThereHint = "Leaves this area out of your scan and checks your wall again. An installer would need to look at it." + static let spotCannotCheckHint = "Leaves this area out of your scan as not checked, and checks your wall again. Someone would need to check it in person." 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 would need to look at what's there.") + case .cannotCheck: Instruction(title: "Thanks, we'll mark that area not checked", detail: "Checking your wall again. Someone would need to check that area in person.") } } /// Said on the result when the homeowner said something stands where its spot is. static let spotRefused = "You said something stands where this spot is. Your scan leaves that area out, and an installer would need to check it." + /// Said on the result when the homeowner couldn't check the spot's area. It says the area + /// went unchecked, never that something was seen there or that anyone has been asked to look. + static let spotNotChecked = "You couldn't check the area around this spot, so your scan leaves it out as not checked. Someone would need to check it in person." + /// Said on the result when an area the homeowner couldn't check is not this result's spot: the + /// answer after it names no spot, or a spot elsewhere. That area still counts as unseen. + /// Beside a notice about this spot it speaks of another area. + static func scanNotChecked(besideSpotNotice: Bool) -> String { + let which = besideSpotNotice ? "You also couldn't check another area along this wall" : "You couldn't check an area along this wall" + return "\(which), so your scan leaves it out as not checked. Someone would need to check it in person." + } - /// "From 3 ft to 8 ft 6 in right of your meter": where the area runs. `spoken` spells the - /// units out for VoiceOver. + /// "From 3 ft to 8 ft 6 in right of your meter": where the area runs along the wall, nearer + /// end first. An end less than half an inch from the meter rounds to 0 in and is read as the + /// meter itself, so -0.3...24 in reads "From your meter to 2 ft right of it", not "From 0 in + /// left to 2 ft right". `spoken` spells the units out for VoiceOver. static func spotArea(_ span: ClosedRange, spoken: Bool = false) -> String { let length = { (meters: Float) in spoken ? Distance.spoken(meters) : Distance.feetAndInches(meters) } + let side = { (s: Float) in s < 0 ? "left" : "right" } + let atMeter = { (s: Float) in Int((abs(s) / Distance.metersPerInch).rounded()) == 0 } let low = span.lowerBound, high = span.upperBound + switch (atMeter(low), atMeter(high)) { + case (true, true): return "At your meter" + case (true, false): return "From your meter to \(length(high)) \(side(high)) of it" + case (false, true): return "From your meter to \(length(low)) \(side(low)) of it" + case (false, false): break + } if low >= 0 { return "From \(length(low)) to \(length(high)) right of your meter" } if high <= 0 { return "From \(length(high)) to \(length(low)) left of your meter" } return "From \(length(low)) left to \(length(high)) right of your meter" } - /// What VoiceOver reads for the photo. + /// The asked-about area's three extents, each readable on its own: along the wall from the + /// meter (`SpotCheck.area`), out from the wall over the ground (`SpotCheck.areaDepth`), and up + /// the wall face to the battery's height (`SpotCheck.spotHeight`). These are the edges + /// `SpotOutline` draws on a photo, and no more: the words describe the area the question is + /// about, not every clearance the server checks. + static func spotSpace(_ check: SpotCheck, spoken: Bool = false) -> (along: String, out: String, up: String) { + let length = { (meters: Float) in spoken ? Distance.spoken(meters) : Distance.feetAndInches(meters) } + return ( + along: spotArea(check.area, spoken: spoken), + out: "From the wall out to \(length(check.areaDepth))", + up: "From the ground up to \(length(check.spotHeight))" + ) + } + + /// `spotSpace` as one spoken sentence, for VoiceOver. + static func spotSpaceSpoken(_ check: SpotCheck) -> String { + let space = spotSpace(check, spoken: true) + return "\(space.along), \(space.out.lowercasedFirst), and \(space.up.lowercasedFirst)." + } + + /// What VoiceOver reads for the outlined photo: what the outline marks, and where, since a + /// VoiceOver user can't see the outline's edges. static func spotPhotoDescription(_ check: SpotCheck) -> String { - let place = spotArea(check.area, spoken: true) - return "A blue outline marks where the battery would stand and the space around it. \(place)." + "A blue outline marks where the battery would stand and the space around it. \(spotSpaceSpoken(check))" } } diff --git a/ios/HouseScan/UI/Preview/DemoEngine.swift b/ios/HouseScan/UI/Preview/DemoEngine.swift index d5f913929..506d3dae4 100644 --- a/ios/HouseScan/UI/Preview/DemoEngine.swift +++ b/ios/HouseScan/UI/Preview/DemoEngine.swift @@ -10,6 +10,8 @@ final class DemoEngine: ScanActions { private let freeze: Bool private let noFeed: Bool + /// `-uiDemoCloseUpSkipped`: the meter close-up was skipped. + private let closeUpSkipped: Bool private let offline: Bool private let passResult: Bool /// `-uiDemoOverlap`: the sample's spot overlaps the meter's working space (#40). @@ -21,6 +23,8 @@ final class DemoEngine: ScanActions { /// through the engine's own mapping, in place of the hand-made samples. private let resultFile: String? private let rejectUpload: Bool + /// `-uiDemoUnusableAnswer `: how many answers in a row House Scan can't use; 0 when off. + private let unusableAnswers: Int /// Which request the gap screen shows (`-uiDemoGap`); the phone's ground request by default. private let gapKind: String? /// The tilt-up step was answered or skipped. @@ -51,6 +55,7 @@ final class DemoEngine: ScanActions { private var spotChecked = false private var failedUploads = 0 private var rejectedUploads = 0 + private var answersUnused = 0 private static let cellWidth: Float = 0.1524 /// Where the demo wall ends on each side, meters of s. Following a corner moves the end on. @@ -66,6 +71,7 @@ final class DemoEngine: ScanActions { } freeze = arguments.contains("-uiDemoFreeze") noFeed = arguments.contains("-uiDemoNoFeed") + closeUpSkipped = arguments.contains("-uiDemoCloseUpSkipped") offline = arguments.contains("-uiDemoOffline") passResult = arguments.contains("-uiDemoPass") overlapResult = arguments.contains("-uiDemoOverlap") @@ -76,6 +82,7 @@ final class DemoEngine: ScanActions { resultFile = nil #endif rejectUpload = arguments.contains("-uiDemoRejected") + unusableAnswers = arguments.contains("-uiDemoUnusableAnswer") ? max(value("-uiDemoUnusableAnswer").flatMap(Int.init) ?? 1, 1) : 0 gapKind = value("-uiDemoGap") state.feed = DemoScene.image.map(CameraFeed.still) ?? .none state.isReplay = true @@ -110,6 +117,18 @@ final class DemoEngine: ScanActions { default: break } } + if arguments.contains("-uiDemoMarkEnd") { + // The walk has reached the right end and asks whether it ends there. With + // `-uiDemoEndMarkRefusal`, "Wall ends here" was just pressed with the circle off the wall. + reachedRight = demoRightEnd + refreshCoverage() + refreshGuidance() + if arguments.contains("-uiDemoEndMarkRefusal") { + // As in the real engine, a circle off the wall has no end preview (`circleEnd`). + state.endMarkRefusal = .noWall + state.endPreview = nil + } + } if arguments.contains("-uiDemoEndPreview") { walkedBack = 1.5 refreshGuidance() @@ -179,8 +198,29 @@ final class DemoEngine: ScanActions { anchor: SIMD3(corner, 0, 0), anchorS: corner)] state.wall?.rightEnd = corner + 1.6 } - if let raw = value("-uiDemoSpotAnswered"), state.phase == .spotConfirm { - state.spotCheck?.answer = raw == "somethingThere" ? .somethingThere : .clear + if let raw = value("-uiDemoSpotAnswered") { + let answer = Self.spotAnswer(raw) + switch state.phase { + case .spotConfirm: + state.spotCheck?.answer = answer + case .result: + // The result after that answer, which the result's notice reads. With a + // `-uiDemoResultFile` answer that names no spot there is no check to keep, and + // only the wall-wide notice after "I can't check this area" remains. + // `-uiDemoUncheckedElsewhere` adds an earlier area left unchecked. + state.spotCheck = sampleSpotCheck(answer: answer) + state.uncheckedAreaElsewhere = (answer == .cannotCheck && state.spotCheck == nil) || arguments.contains("-uiDemoUncheckedElsewhere") + default: + break + } + } + // The spot check without an outline: no kept photo shows the area (`-uiDemoSpotNoPhoto`), + // or a photo but no wall to draw the area on it with (`-uiDemoSpotNoWall`). + if arguments.contains("-uiDemoSpotNoPhoto"), state.phase == .spotConfirm { + state.spotCheck?.photo = nil + } + if arguments.contains("-uiDemoSpotNoWall"), state.phase == .spotConfirm { + state.wall = nil } if arguments.contains("-uiDemoFollowUp") { enterFollowUp(at: state.phase) @@ -199,11 +239,13 @@ final class DemoEngine: ScanActions { } // Frozen, the upload script never runs, so show where it would end. if freeze, state.phase == .uploading { - if rejectUpload || offline { + if rejectUpload || offline || unusableAnswers > 0 { state.shareableScan = Self.demoScan } if rejectUpload { state.upload = .rejected(message: Self.rejection) + } else if unusableAnswers > 0 { + state.upload = .unusableAnswer(attempts: unusableAnswers) } else if offline { state.upload = .failed(message: "No internet connection.", offline: true) } @@ -286,7 +328,7 @@ final class DemoEngine: ScanActions { private func enterWalk() { state.phase = .wallWalk - state.closeUp = .captured(DemoScene.meterThumbnail) + state.closeUp = closeUpSkipped ? .skipped : .captured(DemoScene.meterThumbnail) state.captureCount = max(state.captureCount, 6) refreshCoverage() refreshGuidance() @@ -294,6 +336,9 @@ final class DemoEngine: ScanActions { } private func finishedWalkState() { + // The walk came after the close-up, as in the real flow; `-uiDemoCloseUpSkipped` says + // the homeowner skipped it. + state.closeUp = closeUpSkipped ? .skipped : .captured(DemoScene.meterThumbnail) reachedLeft = -demoLeftEnd reachedRight = demoRightEnd state.wall?.leftEnd = demoLeftEnd @@ -313,7 +358,6 @@ final class DemoEngine: ScanActions { placeMeter() finishedWalkState() state.phase = .wallWalk - state.closeUp = .captured(DemoScene.meterThumbnail) refreshGuidance() run { engine in await engine.walkScript() } } @@ -396,17 +440,33 @@ final class DemoEngine: ScanActions { /// The spot check of the answer's spot, before the result. The made-up zones don't hold the /// spot, so the area is the footprint alone, as the engine would draw it. private func enterSpotCheck() { - let result = sample state.shareableScan = Self.demoScan state.upload = .done - state.result = result - guard let spot = result.spot else { return showResult() } + state.result = sample + guard let check = sampleSpotCheck(answer: nil) else { return showResult() } + state.spotCheck = check + state.phase = .spotConfirm + } + + /// The check of the sample's spot, nil when the sample names none. + private func sampleSpotCheck(answer: SpotCheckAnswer?) -> SpotCheck? { + let result = sample + guard let spot = result.spot else { return nil } let out = spot.offsetFromWall...(spot.offsetFromWall + spot.depth) - state.spotCheck = SpotCheck( + return SpotCheck( id: 1, spot: spot.span, spotOut: out, spotHeight: spot.height, area: spot.span, areaDepth: out.upperBound, photo: DemoScene.image.map { SpotCheck.Photo(image: $0, projection: DemoScene.projection) }, - answer: nil, isSample: result.isSample) - state.phase = .spotConfirm + answer: answer, isSample: result.isSample) + } + + /// `-uiDemoSpotAnswered`'s value. Anything else is a typo in a test's arguments. + private static func spotAnswer(_ raw: String) -> SpotCheckAnswer { + switch raw { + case "clear": .clear + case "somethingThere": .somethingThere + case "cannotCheck": .cannotCheck + default: preconditionFailure("-uiDemoSpotAnswered takes clear, somethingThere or cannotCheck, got \(raw)") + } } private func showResult() { @@ -543,6 +603,12 @@ final class DemoEngine: ScanActions { state.upload = .rejected(message: Self.rejection) return } + // An answer the engine couldn't use; "Try again" asks again. + if answersUnused < unusableAnswers { + answersUnused += 1 + state.upload = .unusableAnswer(attempts: answersUnused) + return + } // Like the real engine: an answer that lists a view the camera can take goes back to // the camera for it, after the upload screen has said so; the next answer is the result. if !followedUp, let item = sample.missing.first(where: \.capturable) { @@ -790,6 +856,7 @@ final class DemoEngine: ScanActions { func markWallEnd(at point: CGPoint?, viewSize: CGSize) { guard case .markEnd(let side) = state.guidance else { return } + state.endMarkRefusal = nil if side == .right { state.wall?.rightEnd = demoRightEnd } else { @@ -977,6 +1044,15 @@ final class DemoEngine: ScanActions { default: break } + if case .markEnd(let side) = state.guidance { + // "The wall keeps going": like the real engine, the end goes where the walk reached, + // unexplored, and nothing asks what is there. + if side == .right { state.wall?.rightEnd = reachedRight } else { state.wall?.leftEnd = -reachedLeft } + state.endMarkRefusal = nil + refreshCoverage() + refreshGuidance() + return + } guard case .walk(let side, _) = state.guidance else { return } if side == .right { skippedSpans.append((reachedRight + 0.1)...demoRightEnd) @@ -1044,6 +1120,7 @@ final class DemoEngine: ScanActions { followUpSkipped = false spotChecked = false state.spotCheck = nil + state.uncheckedAreaElsewhere = false tiltUpSettled = false tiltUpTicks = 0 state.overheadQuestion = false @@ -1233,13 +1310,13 @@ private extension ResultPresentation { extension DemoEngine { /// Like the engine: the answer stays up a moment, then the result, or the check again. - func answerSpotCheck(clear: Bool) { + func answerSpotCheck(_ answer: SpotCheckAnswer) { guard state.phase == .spotConfirm, state.spotCheck?.answer == nil else { return } - state.spotCheck?.answer = clear ? .clear : .somethingThere + state.spotCheck?.answer = answer spotChecked = true run { engine in guard await engine.pause(1.2) else { return } - if clear { engine.showResult() } else { engine.enterUpload() } + if answer == .clear { engine.showResult() } else { engine.enterUpload() } } } diff --git a/ios/HouseScan/UI/Preview/UIDemo.swift b/ios/HouseScan/UI/Preview/UIDemo.swift index 3ee3c0a47..fbcfed2e9 100644 --- a/ios/HouseScan/UI/Preview/UIDemo.swift +++ b/ios/HouseScan/UI/Preview/UIDemo.swift @@ -15,6 +15,8 @@ import SwiftUI /// question answered, so it shows as the folded row with Change. /// - `-uiDemoOffline`: uploads fail offline. /// - `-uiDemoRejected`: the server refuses the first upload; "Back to review" then sends it again. +/// - `-uiDemoUnusableAnswer `: the first `n` answers can't be used (1 if `n` is missing); +/// "Try again" sends the scan again, and the answer after them is the result. /// - `-uiDemoFailure `: open on the /// unsupported screen. /// - `-uiDemoPass`: the sample result is a pass with approved rules. @@ -23,6 +25,9 @@ import SwiftUI /// file, mapped as the engine maps one; the UI tests keep such files in `Fixtures/results/`. /// - `-uiDemoNoFeed`: no camera picture, to look at the chrome alone. /// - `-uiDemoEndQuestion`: the walk asks what is at the left end of the wall. +/// - `-uiDemoMarkEnd`: the walk has reached the right end and asks whether the wall ends there; +/// with `-uiDemoEndMarkRefusal`, "Wall ends here" was just refused (circle off the wall). +/// - `-uiDemoCloseUpSkipped`: the meter close-up was skipped, so no saved photo shows. /// - `-uiDemoEndPreview`: the homeowner walked back 1.5 m, so the wall map says ending the wall /// where they stand leaves part of the walk out. /// - `-uiDemoNextWall`: the right end turns a corner and the walk asks for the next wall; with @@ -45,8 +50,11 @@ import SwiftUI /// followed it, so the window and part of its clearance zone are round the corner. For the /// result model: `-uiDemoPhase result -uiDemoCorner`. /// - `-uiDemoPhase spotConfirm`: the spot check before the result, on the made-up sample spot. -/// - `-uiDemoSpotAnswered `: with `-uiDemoPhase spotConfirm`, the check is -/// answered and says what happens next. +/// - `-uiDemoSpotAnswered `: with `-uiDemoPhase spotConfirm`, +/// the check is answered and says what happens next. With `-uiDemoPhase result`, the result +/// follows that answer, so it shows the answer's notice. +/// - `-uiDemoUncheckedElsewhere`: with `-uiDemoPhase result`, an earlier area of the wall was +/// answered "I can't check this area", so the result says so beside its spot's notice. /// - `-uiDemoFollowUp`: with `-uiDemoPhase uploading` or `gapRequest`, the check has answered /// and asked for one more view: the upload screen as it hands over, or the view itself. /// diff --git a/ios/HouseScan/UI/SavedScans/ActivitySheet.swift b/ios/HouseScan/UI/SavedScans/ActivitySheet.swift new file mode 100644 index 000000000..70ec65a64 --- /dev/null +++ b/ios/HouseScan/UI/SavedScans/ActivitySheet.swift @@ -0,0 +1,23 @@ +import SwiftUI +import UIKit + +/// The system share sheet for one file, reporting when it is done with it. +/// +/// `ShareLink` needs its file to exist before it is tapped and never says when the sheet lets go +/// of it. Saved scans makes its copy only after the tap, and deletes the copy once the sheet +/// finishes, so it needs `UIActivityViewController`'s completion. +struct ActivitySheet: UIViewControllerRepresentable { + let file: URL + /// Called once the share completes or is cancelled; the file is no longer read after this. + let onFinish: @MainActor () -> Void + + func makeUIViewController(context: Context) -> UIActivityViewController { + let controller = UIActivityViewController(activityItems: [file], applicationActivities: nil) + // UIKit calls this on the main thread; the hop keeps it correct whatever the handler's + // declared isolation. + controller.completionWithItemsHandler = { _, _, _, _ in Task { @MainActor in onFinish() } } + return controller + } + + func updateUIViewController(_ controller: UIActivityViewController, context: Context) {} +} diff --git a/ios/HouseScan/UI/SavedScans/SavedScanRow.swift b/ios/HouseScan/UI/SavedScans/SavedScanRow.swift new file mode 100644 index 000000000..88dabc35a --- /dev/null +++ b/ios/HouseScan/UI/SavedScans/SavedScanRow.swift @@ -0,0 +1,97 @@ +import HouseScanKit +import SwiftUI + +/// One saved scan: when its bundle was saved, its size, a Practice tag when the stamp says so, and +/// its Share button. Nothing in the row reads as a result or a place on a wall. +struct SavedScanRow: View { + let scan: SavedScan + /// This scan's copy is being made for the share sheet. + let isPreparing: Bool + /// Some scan's copy is being made; Share waits for it. + let isBusy: Bool + let onShare: () -> Void + + @Environment(\.dynamicTypeSize) private var typeSize + + var body: some View { + let layout = typeSize.isAccessibilitySize + ? AnyLayout(VStackLayout(alignment: .leading, spacing: 12)) + : AnyLayout(HStackLayout(alignment: .center, spacing: 14)) + layout { + HStack(alignment: .top, spacing: 14) { + Image(systemName: scan.practice == true ? "wrench.and.screwdriver.fill" : "doc.zipper") + .font(.title3.weight(.semibold)) + .foregroundStyle(scan.practice == true ? Palette.ink : Palette.signal) + .frame(width: 44, height: 44) + .background(scan.practice == true ? Palette.caution : Palette.signal.opacity(0.1), in: .circle) + .accessibilityHidden(true) + details + } + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .combine) + shareButton + } + .padding(.vertical, 6) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("savedScan.\(scan.id)") + } + + private var details: some View { + VStack(alignment: .leading, spacing: 4) { + Text(scan.savedAt.map { SavedScansCopy.saved($0) } ?? SavedScansCopy.unknownTime) + .font(Typeface.hint.weight(.semibold)) + .foregroundStyle(Palette.ink) + .fixedSize(horizontal: false, vertical: true) + HStack(spacing: 6) { + if scan.practice == true { + Text(SavedScansCopy.practice) + .font(Typeface.caption) + .foregroundStyle(Palette.ink) + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background(Palette.caution, in: .capsule) + .accessibilityIdentifier("savedScan.practice") + } + Text(scan.byteCount, format: .byteCount(style: .file)) + .font(.footnote) + .fixedSize(horizontal: false, vertical: true) + .foregroundStyle(Palette.muted) + .monospacedDigit() + } + if scan.practice == true { + Text(SavedScansCopy.practiceDetail) + .font(.footnote) + .foregroundStyle(Palette.muted) + .fixedSize(horizontal: false, vertical: true) + } + } + } + + /// The quiet button's look (`QuietButtonStyle`) at row size: a tinted capsule, 44 pt tall. + private var shareButton: some View { + Button(action: onShare) { + HStack(spacing: 6) { + if isPreparing { + ProgressView() + .controlSize(.small) + .tint(Palette.signalText) + } else { + Image(systemName: "square.and.arrow.up") + } + Text(SavedScansCopy.share) + } + .font(Typeface.caption) + .foregroundStyle(Palette.signalText) + .padding(.horizontal, 16) + .frame(minHeight: Metrics.minTarget) + .background(Palette.signal.opacity(0.1), in: .capsule) + .contentShape(.capsule) + .opacity(isBusy && !isPreparing ? 0.45 : 1) + } + .buttonStyle(PressableStyle()) + .disabled(isBusy) + .accessibilityLabel(SavedScansCopy.share) + .accessibilityHint("Opens the share sheet for this scan") + .accessibilityIdentifier("action.shareSavedScan") + } +} diff --git a/ios/HouseScan/UI/SavedScans/SavedScansButton.swift b/ios/HouseScan/UI/SavedScans/SavedScansButton.swift new file mode 100644 index 000000000..2d3b33b90 --- /dev/null +++ b/ios/HouseScan/UI/SavedScans/SavedScansButton.swift @@ -0,0 +1,33 @@ +import SwiftUI + +/// The way into Saved scans, in the onboarding's top bar. Quiet like the Skip beside it: the +/// onboarding's one prominent action stays starting a new scan. +/// +/// The button only asks for the sheet; the screen holds it (`savedScansSheet(isPresented:)`). The +/// top bar swaps layouts at accessibility text sizes, which makes a new button, and a sheet owned +/// by the button closed whenever the text size changed under it. +struct SavedScansButton: View { + let action: () -> Void + + var body: some View { + Button(action: action) { + Label(SavedScansCopy.entry, systemImage: "tray.full") + .font(Typeface.hint.weight(.semibold)) + .foregroundStyle(Palette.signalText) + .fixedSize(horizontal: false, vertical: true) + .frame(minWidth: Metrics.minTarget, minHeight: Metrics.minTarget) + .contentShape(.rect) + } + .buttonStyle(PressableStyle()) + .accessibilityIdentifier("action.savedScans") + } +} + +extension View { + /// Saved scans, presented from a view that stays put while the text size changes. + func savedScansSheet(isPresented: Binding) -> some View { + sheet(isPresented: isPresented) { + SavedScansSheet(catalog: SavedScansLocation.catalog(), staging: SavedScansLocation.staging) + } + } +} diff --git a/ios/HouseScan/UI/SavedScans/SavedScansCopy.swift b/ios/HouseScan/UI/SavedScans/SavedScansCopy.swift new file mode 100644 index 000000000..158394571 --- /dev/null +++ b/ios/HouseScan/UI/SavedScans/SavedScansCopy.swift @@ -0,0 +1,62 @@ +import Foundation +import HouseScanKit + +/// The words of Saved scans. A saved scan can be shared and nothing else: none of these lines may +/// suggest it can be reopened, continued or rechecked. +enum SavedScansCopy { + static let title = "Saved scans" + static let entry = "Saved scans" + + /// How many completed scans the phone keeps, in words, from the cleanup's own count. + static var kept: String { + let count = ScanFolderCleanup.defaultKeepCompleted + return count == 1 ? "your most recent finished scan" : "your \(count) most recent finished scans" + } + + static var intro: String { + "This phone keeps \(kept). You can send one to the House Scan team. A saved scan can't be reopened or continued." + } + + static let shareFooter = "Sharing sends the scan's photos and measurements. Nothing leaves your phone until you choose where to send it." + + static let emptyTitle = "No saved scans" + static var emptyDetail: String { + "When you finish a scan, it's saved here so you can share it later. This phone keeps \(kept)." + } + + static let share = "Share" + static let practice = "Practice" + /// Under a practice scan: a drawn sample stood in for the meter and its close-up, so the + /// meter's place and number describe no real meter. The wall walk itself was real. + static let practiceDetail = "Practice scan with a sample meter" + static let unknownTime = "Saved time unknown" + + static func saved(_ date: Date, now: Date = .now, calendar: Calendar = .current) -> String { + let time = date.formatted(date: .omitted, time: .shortened) + if calendar.isDate(date, inSameDayAs: now) { return "Saved today at \(time)" } + if let yesterday = calendar.date(byAdding: .day, value: -1, to: now), calendar.isDate(date, inSameDayAs: yesterday) { + return "Saved yesterday at \(time)" + } + let sameYear = calendar.component(.year, from: date) == calendar.component(.year, from: now) + let day = sameYear + ? date.formatted(.dateTime.month(.abbreviated).day()) + : date.formatted(.dateTime.month(.abbreviated).day().year()) + return "Saved \(day) at \(time)" + } + + static func problemTitle(_ error: SavedScanShareError) -> String { + switch error { + case .missing: "This scan is no longer on this phone" + case .damaged: "This scan can't be shared" + case .copyFailed: "Couldn't get the scan ready" + } + } + + static func problemDetail(_ error: SavedScanShareError) -> String { + switch error { + case .missing: "A newer scan replaced it, or the phone cleared space. The list now shows what's left." + case .damaged: "Its file was not saved completely. The list now shows the scans that can be shared." + case .copyFailed: "Your phone may be low on storage. Free up some space, then try again." + } + } +} diff --git a/ios/HouseScan/UI/SavedScans/SavedScansLocation.swift b/ios/HouseScan/UI/SavedScans/SavedScansLocation.swift new file mode 100644 index 000000000..508ba5bf9 --- /dev/null +++ b/ios/HouseScan/UI/SavedScans/SavedScansLocation.swift @@ -0,0 +1,24 @@ +import Foundation +import HouseScanKit + +/// Where Saved scans reads scans from and stages the copies it shares. +enum SavedScansLocation { + /// The scan store's folder (`KeyframeStore.scansRoot`). Debug builds take + /// `-savedScansRoot ` instead, so a UI test can list synthetic scans it wrote; the + /// argument only changes which folder is listed, never what the store writes or deletes. + static func catalog(arguments: [String] = ProcessInfo.processInfo.arguments) -> SavedScanCatalog { + #if DEBUG + if let index = arguments.firstIndex(of: "-savedScansRoot"), index + 1 < arguments.count { + return SavedScanCatalog(root: URL(fileURLWithPath: arguments[index + 1], isDirectory: true)) + } + #endif + return SavedScanCatalog(root: KeyframeStore.scansRoot) + } + + /// In the app's temporary folder, apart from the scan folders, so the store's cleanup never + /// sees a copy and a copy never outlives the system's own tmp purge. One session per process: + /// a static is made once. + static let staging = SavedScanStaging( + root: FileManager.default.temporaryDirectory.appending(path: "SavedScanShares", directoryHint: .isDirectory), + session: UUID().uuidString) +} diff --git a/ios/HouseScan/UI/SavedScans/SavedScansSheet.swift b/ios/HouseScan/UI/SavedScans/SavedScansSheet.swift new file mode 100644 index 000000000..c8add1fb3 --- /dev/null +++ b/ios/HouseScan/UI/SavedScans/SavedScansSheet.swift @@ -0,0 +1,198 @@ +import HouseScanKit +import SwiftUI + +/// The completed scans still on the phone, each with a Share button. Opened from the onboarding. +/// +/// Export only: a saved scan is not reopened, its result is not shown, and nothing is sent until +/// the homeowner taps Share and then picks a destination in the system share sheet. Share copies +/// the bundle out of the scan folder first (`SavedScanStaging`), so a cleanup can't delete it +/// while the sheet reads it, and deletes the copy when the sheet closes. +struct SavedScansSheet: View { + let catalog: SavedScanCatalog + let staging: SavedScanStaging + + /// Nil until the first read finishes, so an empty list is never shown before it is known. + @State private var scans: [SavedScan]? + @State private var preparing: SavedScan.ID? + @State private var sharing: StagedCopy? + /// The copy being shared, kept past `sharing` going nil so `finishSharing` can delete it. + @State private var lastShared: URL? + @State private var problem: SavedScanShareError? + @State private var showsProblem = false + @Environment(\.dismiss) private var dismiss + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + struct StagedCopy: Identifiable { + let url: URL + var id: URL { url } + } + + // Built like the app's light screens rather than a system list in a half-height sheet. In + // that sheet the onboarding showed through the glass behind the text (contrast), the list's + // header and footer and the navigation bar's Done didn't fully follow Dynamic Type, and large + // text ran off the sheet's bottom (hosted run 37051620836). + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + header + content + } + .padding(.horizontal, 20) + .padding(.top, 20) + .padding(.bottom, 32) + .frame(maxWidth: 560) + .frame(maxWidth: .infinity) + } + .scrollBounceBehavior(.basedOnSize) + .background(Palette.canvas.ignoresSafeArea()) + .presentationBackground(Palette.canvas) + .presentationDetents([.large]) + .presentationDragIndicator(.visible) + // A copy being made belongs to the share sheet that follows it; closing now would leave + // the copy with no sheet to delete it. + .interactiveDismissDisabled(preparing != nil) + .task { await open() } + .sheet(item: $sharing, onDismiss: finishSharing) { copy in + ActivitySheet(file: copy.url) { sharing = nil } + .presentationDetents([.medium, .large]) + .ignoresSafeArea() + } + .alert( + problem.map(SavedScansCopy.problemTitle) ?? "", + isPresented: $showsProblem, + presenting: problem + ) { _ in + Button("OK") {} + } message: { error in + Text(SavedScansCopy.problemDetail(error)) + } + .accessibilityIdentifier("savedScans") + } + + private var header: some View { + HStack(alignment: .firstTextBaseline, spacing: 12) { + Text(SavedScansCopy.title) + .font(Typeface.instruction) + .foregroundStyle(Palette.ink) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityAddTraits(.isHeader) + Button("Done") { dismiss() } + .font(Typeface.hint.weight(.semibold)) + .foregroundStyle(Palette.signalText) + .frame(minWidth: Metrics.minTarget, minHeight: Metrics.minTarget) + .contentShape(.rect) + .buttonStyle(PressableStyle()) + .disabled(preparing != nil) + .opacity(preparing != nil ? 0.45 : 1) + .accessibilityIdentifier("action.closeSavedScans") + } + } + + @ViewBuilder + private var content: some View { + if let scans { + if scans.isEmpty { + VStack(spacing: 12) { + Image(systemName: "tray") + .font(.system(size: 44, weight: .regular)) + .foregroundStyle(Palette.muted) + .accessibilityHidden(true) + Text(SavedScansCopy.emptyTitle) + .font(Typeface.sectionTitle) + .foregroundStyle(Palette.ink) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + Text(SavedScansCopy.emptyDetail) + .font(Typeface.hint) + .foregroundStyle(Palette.muted) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.horizontal, 12) + .padding(.top, 48) + .frame(maxWidth: .infinity) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("savedScans.empty") + } else { + Text(SavedScansCopy.intro) + .font(Typeface.hint) + .foregroundStyle(Palette.muted) + .fixedSize(horizontal: false, vertical: true) + .accessibilityIdentifier("savedScans.intro") + VStack(spacing: 0) { + ForEach(scans) { scan in + if scan.id != scans.first?.id { + Divider().padding(.leading, 58) + } + SavedScanRow(scan: scan, isPreparing: preparing == scan.id, isBusy: preparing != nil) { + share(scan) + } + .padding(.vertical, 8) + } + } + .padding(.horizontal, 16) + .background(Palette.surface, in: .rect(cornerRadius: Metrics.cardRadius, style: .continuous)) + Label(SavedScansCopy.shareFooter, systemImage: "lock.fill") + .font(.footnote) + .foregroundStyle(Palette.muted) + .fixedSize(horizontal: false, vertical: true) + .accessibilityIdentifier("savedScans.privacy") + } + } + // Until the first read finishes there is nothing: it takes milliseconds, and a spinner + // would only flash. + } + + /// Copies earlier runs left go too, in case launch's pass hasn't run yet; this run's own copy + /// is deleted when its share sheet closes, so nothing here can touch a share in progress. + private func open() async { + let catalog = catalog, staging = staging + let found = await Task.detached(priority: .userInitiated) { () -> [SavedScan] in + staging.removeOtherSessions() + return catalog.scans() + }.value + scans = found + } + + private func reload() async { + let catalog = catalog + let found = await Task.detached(priority: .userInitiated) { catalog.scans() }.value + withAnimation(reduceMotion ? nil : Motion.settle) { scans = found } + } + + private func share(_ scan: SavedScan) { + guard preparing == nil else { return } + preparing = scan.id + let staging = staging + Task { + let staged = await Task.detached(priority: .userInitiated) { () -> Result in + do throws(SavedScanShareError) { + return .success(try staging.stage(scan)) + } catch { + return .failure(error) + } + }.value + preparing = nil + switch staged { + case .success(let url): + lastShared = url + sharing = StagedCopy(url: url) + case .failure(let error): + problem = error + showsProblem = true + // A missing or damaged scan drops out of the list; a failed copy leaves it to retry. + if error != .copyFailed { await reload() } + } + } + } + + /// The share sheet is closed, by finishing, cancelling or a swipe: its copy goes. + private func finishSharing() { + guard let copy = lastShared else { return } + lastShared = nil + let staging = staging + Task.detached(priority: .utility) { staging.remove(copy) } + } + +} diff --git a/ios/HouseScan/UI/Screens/GapRequestScreen.swift b/ios/HouseScan/UI/Screens/GapRequestScreen.swift index f97618264..41417cb76 100644 --- a/ios/HouseScan/UI/Screens/GapRequestScreen.swift +++ b/ios/HouseScan/UI/Screens/GapRequestScreen.swift @@ -22,6 +22,11 @@ struct GapRequestScreen: View { ZStack { CameraSizeReader(size: $cameraSize) CameraOverlays(state: state, highlight: state.gap, cameraWindow: cameraWindow) + if showsMeterPhoto, let meterPhoto { + // "Point at the meter like this.": the saved close-up, as on the walk (B-23). + SavedMeterPhoto(image: meterPhoto) + .transition(.opacity) + } if state.gap?.isSatisfied == true { SuccessBadge() .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -77,6 +82,7 @@ struct GapRequestScreen: View { } } .animation(reduceMotion ? .easeOut(duration: 0.15) : Motion.pin, value: state.gap?.isSatisfied) + .animation(.easeOut(duration: 0.2), value: showsMeterPhoto) } /// "Show my result", on every request the check sent back, even with one view left: the @@ -145,7 +151,7 @@ struct GapRequestScreen: View { /// thanks" for as long as it did (field test 4.1, run 3). private var instruction: Instruction { if asking { return ScanCopy.overheadQuestion } - if let coaching, ScanCopy.coachingReplacesTask(coaching) { return ScanCopy.coaching(coaching) } + if let coaching, ScanCopy.coachingReplacesTask(coaching) { return ScanCopy.coaching(coaching, meterPhoto: meterPhoto != nil) } guard let gap = state.gap else { return ScanCopy.withCoaching(ScanCopy.guidance(.gap), coaching) } if gap.isSatisfied { return Instruction(title: "Got it, thanks", detail: followUps > 0 ? "Updating your result." : "That's the view we needed.") @@ -153,6 +159,19 @@ struct GapRequestScreen: View { return ScanCopy.withCoaching(ScanCopy.gap(gap), coaching) } + /// The meter close-up, shown with "Point at the meter like this." while the phone finds its + /// place again; nil when the close-up was skipped. + private var meterPhoto: CGImage? { + if case .captured(let image) = state.closeUp { return image } + return nil + } + + /// The close-up shows only while the card says "Point at the meter like this.", not under + /// the overhead question, which outranks the coaching. + private var showsMeterPhoto: Bool { + meterPhoto != nil && instruction == ScanCopy.coaching(.relocalizing, meterPhoto: true) + } + /// The coaching on the card, marked with its symbol (`tone`). Once the view is in, the /// capture gate's coaching has nothing left to ask for, so only coaching that replaces the /// card still shows. diff --git a/ios/HouseScan/UI/Screens/OnboardingScreen.swift b/ios/HouseScan/UI/Screens/OnboardingScreen.swift index 5a935c8f0..bbf45605a 100644 --- a/ios/HouseScan/UI/Screens/OnboardingScreen.swift +++ b/ios/HouseScan/UI/Screens/OnboardingScreen.swift @@ -7,6 +7,7 @@ struct OnboardingScreen: View { let actions: any ScanActions @State private var page = 0 + @State private var showsSavedScans = false @Environment(\.accessibilityReduceMotion) private var reduceMotion @Environment(\.dynamicTypeSize) private var typeSize @@ -49,13 +50,16 @@ struct OnboardingScreen: View { Group { if typeSize.isAccessibilitySize { // The replay badge, Practice label and Skip squeezed the label into - // single syllables at AX5. Give Practice its own full-width row. + // single syllables at AX5. Give Practice its own full-width row, and Saved + // scans too. VStack(alignment: .leading, spacing: 4) { HStack { ModeBadge(isReplay: state.isReplay, isAutopilot: state.isAutopilot) Spacer() skipButton } + SavedScansButton { showsSavedScans = true } + .frame(maxWidth: .infinity, alignment: .leading) DeveloperOptionsButton() .frame(maxWidth: .infinity, alignment: .leading) } @@ -64,6 +68,7 @@ struct OnboardingScreen: View { ModeBadge(isReplay: state.isReplay, isAutopilot: state.isAutopilot) DeveloperOptionsButton() Spacer() + SavedScansButton { showsSavedScans = true } skipButton } } @@ -122,6 +127,7 @@ struct OnboardingScreen: View { .accessibilityIdentifier("onboarding.footer") } .background(Palette.canvas.ignoresSafeArea()) + .savedScansSheet(isPresented: $showsSavedScans) } @ViewBuilder diff --git a/ios/HouseScan/UI/Screens/ResultScreen.swift b/ios/HouseScan/UI/Screens/ResultScreen.swift index 57e1c27a1..9b8c88ae3 100644 --- a/ios/HouseScan/UI/Screens/ResultScreen.swift +++ b/ios/HouseScan/UI/Screens/ResultScreen.swift @@ -36,10 +36,7 @@ struct ResultScreen: View { 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) - .accessibilityIdentifier("result.spotRefused") - } + spotNotices(result) footnotes(result) .padding(.horizontal, 4) details(result) @@ -64,6 +61,28 @@ struct ResultScreen: View { // MARK: Parts + /// What the homeowner said about this result's spot, and, apart from it, any other area of the + /// wall they couldn't check. The second follows the scan's records, not the spot, so it stays + /// when the answer after "I can't check this area" names no spot or another one. + @ViewBuilder + private func spotNotices(_ result: ResultPresentation) -> some View { + let answer = result.spot != nil ? state.spotCheck?.answer : nil + switch answer { + case .somethingThere: + Notice(symbol: "exclamationmark.triangle.fill", text: ScanCopy.spotRefused) + .accessibilityIdentifier("result.spotRefused") + case .cannotCheck: + Notice(symbol: "eye.slash", text: ScanCopy.spotNotChecked) + .accessibilityIdentifier("result.spotNotChecked") + case .clear, nil: + EmptyView() + } + if state.uncheckedAreaElsewhere { + Notice(symbol: "eye.slash", text: ScanCopy.scanNotChecked(besideSpotNotice: answer == .somethingThere || answer == .cannotCheck)) + .accessibilityIdentifier("result.scanNotChecked") + } + } + @ViewBuilder private func diorama(_ result: ResultPresentation) -> some View { ZStack(alignment: .topLeading) { @@ -133,6 +152,11 @@ struct ResultScreen: View { .accessibilityIdentifier(id) } + /// The checks whose card line offers the camera, so Details says the same as the card. + private func photoOffered(_ result: ResultPresentation) -> Set { + Set(result.checks.filter { result.offeredView(for: $0, sourceAvailable: state.spatialResultAvailable) != nil }.map(\.id)) + } + private func details(_ result: ResultPresentation) -> some View { DisclosureGroup(isExpanded: $detailsExpanded) { VStack(alignment: .leading, spacing: 22) { @@ -144,7 +168,7 @@ struct ResultScreen: View { .accessibilityIdentifier("result.summary") } if !result.checks.isEmpty { - ChecksList(checks: result.checks) + ChecksList(checks: result.checks, photoOffered: photoOffered(result)) } if !result.missing.isEmpty { MissingList(missing: result.missing, checks: result.checks, sourceAvailable: state.spatialResultAvailable, actions: actions) @@ -310,13 +334,12 @@ private struct AnswerCard: View { private func line(_ row: CheckRow) -> some View { // After a reject, the line above already gave the nearest spot's failing measurement. let repeatsNearest = row.id == result.nearestFailingCheck && result.nearestSpot != nil - let view = result.viewToTake(for: row).flatMap { view in - ResultCardActions.offersView(capturable: view.capturable, sourceAvailable: sourceAvailable) ? view : nil - } + // One fact for the line's "Show me" and its words, so they can't disagree. + let view = result.offeredView(for: row, sourceAvailable: sourceAvailable) return CardCheckLine( row: row, - sentence: repeatsNearest ? nil : ScanCopy.cardLine(row), - spokenSentence: repeatsNearest ? nil : ScanCopy.cardLine(row, spoken: true), + sentence: repeatsNearest ? nil : ScanCopy.cardLine(row, photoOffered: view != nil), + spokenSentence: repeatsNearest ? nil : ScanCopy.cardLine(row, photoOffered: view != nil, spoken: true), showMe: view.map { view in { actions.captureMissing(view.id) } } ) } @@ -459,6 +482,8 @@ private enum CheckSymbol { private struct ChecksList: View { var checks: [CheckRow] + /// The ids of the checks whose card line offers the camera (`ResultPresentation.offeredView`). + var photoOffered: Set var body: some View { VStack(alignment: .leading, spacing: 12) { @@ -467,7 +492,7 @@ private struct ChecksList: View { .accessibilityAddTraits(.isHeader) VStack(spacing: 0) { ForEach(Array(checks.enumerated()), id: \.element.id) { index, row in - CheckRowView(row: row) + CheckRowView(row: row, photoOffered: photoOffered.contains(row.id)) if index < checks.count - 1 { Divider().padding(.leading, 52) } @@ -480,6 +505,7 @@ private struct ChecksList: View { private struct CheckRowView: View { var row: CheckRow + var photoOffered: Bool var body: some View { HStack(alignment: .top, spacing: 12) { @@ -501,7 +527,7 @@ private struct CheckRowView: View { .fixedSize(horizontal: false, vertical: true) } if row.outcome == .unsure { - Label(ScanCopy.unsureNote(row), systemImage: row.needsPerson ? "person.fill" : "camera.fill") + Label(ScanCopy.unsureNote(photoOffered: photoOffered), systemImage: photoOffered ? "camera.fill" : "person.fill") .font(.footnote.weight(.semibold)) .foregroundStyle(Palette.reviewInk) } @@ -511,7 +537,7 @@ private struct CheckRowView: View { .padding(14) .accessibilityElement(children: .combine) .accessibilityLabel("\(row.title): \(ScanCopy.outcomeWord(row.outcome))") - .accessibilityValue([row.reason, ScanCopy.measurement(row, spoken: true), row.outcome == .unsure ? ScanCopy.unsureNote(row) : nil] + .accessibilityValue([row.reason, ScanCopy.measurement(row, spoken: true), row.outcome == .unsure ? ScanCopy.unsureNote(photoOffered: photoOffered) : nil] .compactMap(\.self).joined(separator: ". ")) // The card shows the deciding checks as `check.`; this is the full list. .accessibilityIdentifier("detail.check.\(row.id)") diff --git a/ios/HouseScan/UI/Screens/SpotConfirmScreen.swift b/ios/HouseScan/UI/Screens/SpotConfirmScreen.swift index 079ba7c36..e9e42e880 100644 --- a/ios/HouseScan/UI/Screens/SpotConfirmScreen.swift +++ b/ios/HouseScan/UI/Screens/SpotConfirmScreen.swift @@ -1,11 +1,17 @@ import SwiftUI /// The spot check before the result: one kept photo with the answer's spot and its clearance area -/// outlined, and one question with two equal answers. A photo can claim wall and ground behind a -/// bush, so the homeowner, who is standing there, says whether anything is in the way. +/// outlined, and one question with three equal answers. A photo can claim wall and ground behind +/// a bush, so the homeowner, who is standing there, says whether anything is in the way, or that +/// they can't see or reach the area to say. /// -/// The answer replaces the two buttons and stays up a moment, saying what happens next, before -/// the engine moves on (to the result, or to checking the wall again). +/// Without an outline (no kept photo shows the area, or there is no wall to draw it with) the +/// area is given in words instead: where it runs beside the meter, how far out from the wall and +/// how high up it. The question then names no outline, and no photo is shown, since a photo with +/// nothing marked on it would leave the homeowner guessing which part of it is meant. +/// +/// The answer replaces the buttons and stays up a moment, saying what happens next, before the +/// engine moves on (to the result, or to checking the wall again). struct SpotConfirmScreen: View { let state: ScanViewState let actions: any ScanActions @@ -23,15 +29,17 @@ struct SpotConfirmScreen: View { } private func content(_ check: SpotCheck) -> some View { - ScrollView { + let outline = check.outline(on: state.wall) + let question = ScanCopy.spotQuestionShown(outlined: outline != nil) + return ScrollView { VStack(alignment: .leading, spacing: 20) { badges(check) VStack(alignment: .leading, spacing: 8) { - Text(ScanCopy.spotQuestion.title) + Text(question.title) .font(Typeface.screenTitle) .fixedSize(horizontal: false, vertical: true) .accessibilityAddTraits(.isHeader) - if let detail = ScanCopy.spotQuestion.detail { + if let detail = question.detail { Text(detail) .font(Typeface.hint) .foregroundStyle(Palette.muted) @@ -40,13 +48,17 @@ struct SpotConfirmScreen: View { } .accessibilityElement(children: .combine) .accessibilityIdentifier("spot.question") - VStack(alignment: .leading, spacing: 10) { - photo(check) - Text(ScanCopy.spotArea(check.area)) - .font(.subheadline.weight(.medium)) - .foregroundStyle(Palette.muted) - .fixedSize(horizontal: false, vertical: true) - .accessibilityHidden(true) + if let outline { + VStack(alignment: .leading, spacing: 10) { + outlinedPhoto(outline.photo, wall: outline.wall, check: check) + Text(ScanCopy.spotArea(check.area)) + .font(.subheadline.weight(.medium)) + .foregroundStyle(Palette.muted) + .fixedSize(horizontal: false, vertical: true) + .accessibilityHidden(true) + } + } else { + space(check) } if typeSize.isAccessibilitySize { answerArea(check) @@ -104,48 +116,76 @@ struct SpotConfirmScreen: View { } } - @ViewBuilder - private func photo(_ check: SpotCheck) -> some View { - Group { - if let photo = check.photo, let wall = state.wall { - let size = photo.projection.imageSize - // The sensor image shown upright (rotated 90° clockwise) at its own aspect, so - // filling the frame crops nothing and the projection's fill mapping holds. - Color.clear - .aspectRatio(CGFloat(size.y / size.x), contentMode: .fit) - .overlay { - Image(decorative: photo.image, scale: 1, orientation: .right) - .resizable() - .scaledToFill() - } - .overlay { - SpotOutline(photo: photo, wall: wall, check: check) - .opacity(outlineShown ? 1 : 0) - } - .clipShape(.rect(cornerRadius: Metrics.cardRadius, style: .continuous)) - // Tall enough to judge, short enough that the question and the photo share - // the screen with the answers on a 6.1 in phone. - .frame(maxHeight: 400) - .frame(maxWidth: .infinity) - } else { - // No kept photo shows the spot from the front: the homeowner looks at the wall. - Label("No photo shows this spot well. Take a look at the wall itself.", systemImage: "photo") - .font(Typeface.hint) - .foregroundStyle(Palette.ink) - .fixedSize(horizontal: false, vertical: true) - .padding(18) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Palette.surface, in: .rect(cornerRadius: Metrics.cardRadius, style: .continuous)) + private func outlinedPhoto(_ photo: SpotCheck.Photo, wall: WallGeometry, check: SpotCheck) -> some View { + let size = photo.projection.imageSize + // The sensor image shown upright (rotated 90° clockwise) at its own aspect, so filling + // the frame crops nothing and the projection's fill mapping holds. + return Color.clear + .aspectRatio(CGFloat(size.y / size.x), contentMode: .fit) + .overlay { + Image(decorative: photo.image, scale: 1, orientation: .right) + .resizable() + .scaledToFill() + } + .overlay { + SpotOutline(photo: photo, wall: wall, check: check) + .opacity(outlineShown ? 1 : 0) + } + .clipShape(.rect(cornerRadius: Metrics.cardRadius, style: .continuous)) + // Tall enough to judge, short enough that the question and the photo share the + // screen with the answers on a 6.1 in phone. + .frame(maxHeight: 400) + .frame(maxWidth: .infinity) + .accessibilityElement(children: .ignore) + .accessibilityLabel("Photo of your wall") + .accessibilityValue(ScanCopy.spotPhotoDescription(check)) + .accessibilityAddTraits(.isImage) + .accessibilityIdentifier("spot.photo") + } + + /// The area in words, in the photo's place: the three edges the outline would draw. Each + /// line is its own VoiceOver element, read with the units spelled out, so the rendered lines + /// stay visible to the accessibility audit and the UI tests. + private func space(_ check: SpotCheck) -> some View { + let shown = ScanCopy.spotSpace(check) + let spoken = ScanCopy.spotSpace(check, spoken: true) + return VStack(alignment: .leading, spacing: 10) { + // One element read as the title alone: VoiceOver names the "eye" symbol "Show", and + // the identifier landed on it (CI run 37108821815 read the title as "Show"). + Label { + Text(ScanCopy.spotSpaceTitle) + } icon: { + Image(systemName: "eye").accessibilityHidden(true) + } + .font(Typeface.caption) + .foregroundStyle(Palette.muted) + .accessibilityElement(children: .combine) + .accessibilityAddTraits(.isHeader) + .accessibilityIdentifier("spot.area.title") + VStack(alignment: .leading, spacing: 6) { + spaceLine(shown.along, spoken: spoken.along, id: "along") + spaceLine(shown.out, spoken: spoken.out, id: "out") + spaceLine(shown.up, spoken: spoken.up, id: "up") } } - .accessibilityElement(children: .ignore) - .accessibilityLabel(check.photo == nil ? "No photo of this spot" : "Photo of your wall") - .accessibilityValue(ScanCopy.spotPhotoDescription(check)) - .accessibilityAddTraits(check.photo == nil ? [] : .isImage) - .accessibilityIdentifier("spot.photo") + .padding(18) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Palette.surface, in: .rect(cornerRadius: Metrics.cardRadius, style: .continuous)) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("spot.area") + } + + /// In the primary color, as the question: Ink stays near-black in dark mode, where Surface + /// turns dark, and this screen follows the phone's appearance. + private func spaceLine(_ text: String, spoken: String, id: String) -> some View { + Text(text) + .font(Typeface.hint) + .fixedSize(horizontal: false, vertical: true) + .accessibilityLabel(spoken) + .accessibilityIdentifier("spot.area.\(id)") } - /// The two answers, or once answered, what happens next. + /// The answers, or once answered, what happens next. @ViewBuilder private func answerArea(_ check: SpotCheck) -> some View { if let answer = check.answer { @@ -157,21 +197,36 @@ struct SpotConfirmScreen: View { } } - /// Two equal answers on a white card, so neither reads as the default. + /// Three equal answers on a white card, so none reads as the default. "I can't check this + /// area" has the same size and weight as the other two: a homeowner who can't see the area + /// should find saying so as easy as guessing. private var answers: some View { VStack(spacing: 10) { - AnswerButton(title: ScanCopy.spotClear, selected: false) { actions.answerSpotCheck(clear: true) } + AnswerButton(title: ScanCopy.spotClear, selected: false) { actions.answerSpotCheck(.clear) } .accessibilityHint(ScanCopy.spotClearHint) .accessibilityIdentifier("action.spotClear") - AnswerButton(title: ScanCopy.spotSomethingThere, selected: false) { actions.answerSpotCheck(clear: false) } + AnswerButton(title: ScanCopy.spotSomethingThere, selected: false) { actions.answerSpotCheck(.somethingThere) } .accessibilityHint(ScanCopy.spotSomethingThereHint) .accessibilityIdentifier("action.spotSomethingThere") + AnswerButton(title: ScanCopy.spotCannotCheck, selected: false) { actions.answerSpotCheck(.cannotCheck) } + .accessibilityHint(ScanCopy.spotCannotCheckHint) + .accessibilityIdentifier("action.spotCannotCheck") } .padding(12) .background(Palette.surface, in: .rect(cornerRadius: 18, style: .continuous)) } } +extension SpotCheck { + /// The photo and the wall to outline the area on it with, or nil when either is missing. + /// The screen's question, picture and VoiceOver text follow this one answer, and so does + /// the guidance log's record of what was shown (`ScanEngine.presentAnswer`). + func outline(on wall: WallGeometry?) -> (photo: Photo, wall: WallGeometry)? { + guard let photo = self.photo, let wall else { return nil } + return (photo, wall) + } +} + /// The answer given, and what happens next. private struct Answered: View { var answer: SpotCheckAnswer @@ -179,7 +234,7 @@ private struct Answered: View { var body: some View { let copy = ScanCopy.spotAnswered(answer) HStack(alignment: .firstTextBaseline, spacing: 12) { - Image(systemName: answer == .clear ? "checkmark.circle.fill" : "arrow.triangle.2.circlepath") + Image(systemName: Self.symbol(answer)) .font(.title3.weight(.semibold)) .foregroundStyle(answer == .clear ? Palette.passInk : Palette.reviewInk) .accessibilityHidden(true) @@ -201,6 +256,16 @@ private struct Answered: View { .accessibilityElement(children: .combine) .accessibilityIdentifier("spot.answered") } + + /// A tick for clear, the check-again arrows for an obstruction, and an eye struck through for + /// an area nobody checked, as on the result's notice. + private static func symbol(_ answer: SpotCheckAnswer) -> String { + switch answer { + case .clear: "checkmark.circle.fill" + case .somethingThere: "arrow.triangle.2.circlepath" + case .cannotCheck: "eye.slash" + } + } } /// The spot's footprint and its clearance area drawn over the photo, on the ground and up the diff --git a/ios/HouseScan/UI/Screens/UploadingScreen.swift b/ios/HouseScan/UI/Screens/UploadingScreen.swift index 9cf8b9d92..6dfc9af78 100644 --- a/ios/HouseScan/UI/Screens/UploadingScreen.swift +++ b/ios/HouseScan/UI/Screens/UploadingScreen.swift @@ -2,9 +2,12 @@ import SwiftUI /// "Checking your wall": a calm wait. Three steps tick off in order; progress is shown /// separately from how complete the scan was (docs/05 section 2). A failure that sending again -/// can fix (offline, a server error) offers "Try again". A refused scan can't be fixed by sending -/// the same thing again, so it offers the review and a fresh start instead. Both offer "Share -/// scan" once the scan is packaged, so a scan the server never took can still reach the team. +/// can fix (offline, a server error) offers "Try again". So does an answer House Scan couldn't +/// use, which isn't the scan's fault; when that keeps happening the screen says asking again may +/// not help and points to sharing the scan or starting over. A refused scan can't be fixed by +/// sending the same thing again, so it offers the review and a fresh start instead. All of them +/// offer "Share scan" once the scan is packaged, so a scan the server never took can still reach +/// the team. /// /// When the check answers but still wants views the camera can take now, the engine goes /// straight back to the camera for them. This screen says so first ("One more view to @@ -48,11 +51,13 @@ struct UploadingScreen: View { switch state.upload { case .failed: VStack(spacing: 16) { - // Packets stay on disk, but the app has no way to reopen them after - // relaunch. Offer export only once the shareable packet exists. + // The app can't reopen a scan after relaunch, so retrying needs it open. + // Once packaged, its bundle stays among the kept scans, and Saved scans on + // the first screen can still share it. The Share scan button below covers + // sharing now. Text(state.shareableScan == nil ? "Keep this scan open to try again. House Scan can't reopen it after you close the app." - : "Keep this scan open to try again, or use Share scan to save a copy. House Scan can't reopen it after you close the app.") + : "Keep this scan open to try again. If you close the app, you can still share its saved file from Saved scans on the first screen.") .font(Typeface.hint) .foregroundStyle(Palette.muted) .multilineTextAlignment(.center) @@ -65,6 +70,8 @@ struct UploadingScreen: View { ShareScanButton(url: scan) } } + case .unusableAnswer(let attempts): + UnusableAnswerActions(repeated: attempts > 1, shareableScan: state.shareableScan, actions: actions) case .rejected: VStack(spacing: 16) { Button("Back to review") { actions.backToReview() } @@ -94,6 +101,56 @@ struct UploadingScreen: View { } } +/// The ways on after an answer House Scan couldn't use. "Try again" leads the first time. Once it +/// has already failed again, it takes the quiet style of the choices around it and the note points +/// to sharing the scan, so the homeowner isn't left pressing the same button. Not the dark +/// secondary style: that one is for camera screens, and on this light screen it outweighed the +/// buttons it was meant to sit beside. Never "Back to review": the marks aren't the problem. +private struct UnusableAnswerActions: View { + var repeated: Bool + var shareableScan: URL? + let actions: any ScanActions + + var body: some View { + VStack(spacing: 16) { + Text(note) + .font(Typeface.hint) + .foregroundStyle(Palette.muted) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + .accessibilityIdentifier("upload.answerNote") + Group { + if repeated { + Button("Try again") { actions.retryUpload() }.buttonStyle(.quiet) + } else { + Button("Try again") { actions.retryUpload() }.buttonStyle(.primary) + } + } + .accessibilityHint("Asks the server again for the same scan.") + .accessibilityIdentifier("action.retryUpload") + if let scan = shareableScan { + ShareScanButton(url: scan) + } + Button("Start over") { actions.startOver() } + .buttonStyle(.quiet) + .accessibilityHint("Starts a new scan and keeps your two most recent completed scans on this phone.") + .accessibilityIdentifier("action.startOver") + } + } + + /// Asking again needs the scan open: the app can't reopen a scan after it closes. A packaged + /// scan stays among the kept scans, and Saved scans on the first screen can still share its + /// file then, but not ask again (as for a failed upload, `upload.recoveryLimit`). + private var note: String { + switch (repeated, shareableScan != nil) { + case (false, true): "Keep this scan open to try again. If you close the app, you can still share its saved file from Saved scans on the first screen, but not try again." + case (false, false): "Keep this scan open to try again. House Scan can't reopen it after you close the app." + case (true, true): "Share scan saves a copy you can send to the House Scan team. You can also share it later from Saved scans on the first screen." + case (true, false): "House Scan can't reopen this scan after you close the app." + } + } +} + private struct UploadEmblem: View { var upload: UploadState var followsUp: Bool @@ -115,7 +172,7 @@ private struct UploadEmblem: View { private var isWorking: Bool { switch upload { - case .failed, .rejected, .done: false + case .failed, .rejected, .unusableAnswer, .done: false case .idle, .packaging, .uploading, .analyzing: true } } @@ -128,13 +185,14 @@ private struct UploadEmblem: View { case .analyzing: "ruler" case .failed(_, let offline): offline ? "wifi.slash" : "exclamationmark.triangle" case .rejected: "exclamationmark.triangle" + case .unusableAnswer: "exclamationmark.bubble" case .done: "checkmark.circle" } } private var tint: Color { switch upload { - case .failed, .rejected: Palette.caution + case .failed, .rejected, .unusableAnswer: Palette.caution case .idle, .packaging, .uploading, .analyzing, .done: Palette.signal } } @@ -173,7 +231,7 @@ private struct UploadSteps: View { case .uploading: 1 case .analyzing: 2 case .done: 3 - case .failed, .rejected: 1 + case .failed, .rejected, .unusableAnswer: 1 } } diff --git a/ios/HouseScan/UI/Screens/WallWalkScreen.swift b/ios/HouseScan/UI/Screens/WallWalkScreen.swift index cefd9f266..c030f71b9 100644 --- a/ios/HouseScan/UI/Screens/WallWalkScreen.swift +++ b/ios/HouseScan/UI/Screens/WallWalkScreen.swift @@ -25,7 +25,7 @@ struct WallWalkScreen: View { ZStack { CameraSizeReader(size: $cameraSize) CameraOverlays(state: state, highlight: nil, cardLegend: $cardLegend, cameraWindow: cameraWindow) - if state.coaching == .relocalizing, let meterPhoto { + if showsMeterPhoto, let meterPhoto { // "Point at the meter like this.": the saved close-up shows what to aim at. SavedMeterPhoto(image: meterPhoto) .transition(.opacity) @@ -71,7 +71,7 @@ struct WallWalkScreen: View { .animation(reduceMotion ? .easeOut(duration: 0.15) : Motion.settle, value: controlsKey) } } - .animation(.easeOut(duration: 0.2), value: state.coaching == .relocalizing) + .animation(.easeOut(duration: 0.2), value: showsMeterPhoto) .onChange(of: state.marking == nil) { _, notMarking in if !notMarking { trayOpen = false } } @@ -91,7 +91,11 @@ struct WallWalkScreen: View { if let side = state.endQuestion { return ScanCopy.endQuestion(side, leavesOut: state.endQuestionLeavesOut, seen: state.endQuestionLeavesOutSeen) } if state.overheadQuestion { return ScanCopy.overheadQuestion } if let confirm = state.nextWallConfirm { return ScanCopy.nextWallConfirm(confirm) } - if let coaching, ScanCopy.coachingReplacesTask(coaching) { return ScanCopy.coaching(coaching) } + if let coaching, ScanCopy.coachingReplacesTask(coaching) { return ScanCopy.coaching(coaching, meterPhoto: meterPhoto != nil) } + // "Wall ends here" marked nothing: why, and what to do (B-06). + if case .markEnd(let side) = state.guidance, let refusal = state.endMarkRefusal { + return ScanCopy.withCoaching(ScanCopy.endMarkRefusal(refusal, asked: side), coaching) + } // Coaching about how the photos come out (the capture gate's, and too little texture) // rides along with the task (`ScanCopy.withCoaching`), and its symbol marks the card (`tone`). if state.wallTooShort { @@ -121,6 +125,7 @@ struct WallWalkScreen: View { } if state.marking == nil, case .markNextWall(_, _?) = state.guidance { return .refusal } if coachingShows, state.wallTooShort { return .refusal } + if coachingShows, state.endMarkRefusal != nil, case .markEnd = state.guidance { return .refusal } if coachingShows, let coaching { return .coaching(symbol: ScanCopy.coachingSymbol(coaching)) } return .normal } @@ -474,6 +479,13 @@ struct WallWalkScreen: View { return nil } + /// The close-up shows only while the card says "Point at the meter like this.": a question + /// on the card (the end, overhead, the next wall) outranks the coaching, and the photo must + /// not sit under words about something else. + private var showsMeterPhoto: Bool { + meterPhoto != nil && instruction == ScanCopy.coaching(.relocalizing, meterPhoto: true) + } + private var cameraS: Float? { guard let projection = state.projection, let wall = state.wall else { return nil } return WallProjection(projection: projection, wall: wall, size: cameraSize).cameraS diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Confirm/SpotConfirmation.swift b/ios/HouseScanKit/Sources/HouseScanKit/Confirm/SpotConfirmation.swift index 1b26e4a85..8836f1e24 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Confirm/SpotConfirmation.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Confirm/SpotConfirmation.swift @@ -8,9 +8,10 @@ import simd // clear space in front of the wall even where something low stands under it (`CoverageMap`, // "Bounded exceptions"). Before the answer is shown, the app shows the homeowner one kept photo // of the spot and the area around it and asks whether anything stands there. "It's clear" keeps -// the claims; "Something's there" takes them back over that area (`CoverageMap.withdrawClaims`) -// and the scan is checked again. This file holds the parts with one correct answer: which area -// is asked about, which photo shows it, and when an answer already given settles a new answer. +// the claims; "Something's there" and "I can't check this area" take them back over that area +// (`CoverageMap.withdrawClaims`) and the scan is checked again. This file holds the parts with +// one correct answer: which area is asked about, which photo shows it, what each answer does to +// the claims, and when an answer already given settles a new answer. /// The area a spot check asks about, meters: the spot's footprint and the clearance zone the /// answer drew around it, as a stretch of wall and the ground in front of it. @@ -163,12 +164,25 @@ public enum SpotPhoto { // MARK: - Binding /// The homeowner's answer to a spot check. -public enum SpotConfirmationAnswer: String, Sendable, Equatable { +public enum SpotConfirmationAnswer: String, Sendable, Equatable, CaseIterable { /// Nothing stands in the area: the photo's claims over it stand, backed by this answer. case clear /// Something stands there: the claims over the area are withdrawn and the scan is checked /// again. case somethingThere + /// The homeowner can't see or reach the area to say: it was not observed. Neither a known + /// obstruction nor clear. The claims over the area are withdrawn, as for `somethingThere`, + /// because nothing backs them, and the scan is checked again. + case cannotCheck + + /// Whether the answer backs the scan's claims over the area. Only "It's clear" does: an + /// obstruction contradicts them, and an area nobody checked leaves them unbacked. + public var keepsClaims: Bool { self == .clear } + + /// How the check's guidance-log entry closes: met when the area is clear, otherwise the + /// packet's generic skipped. The packet has no outcome for "obstructed" or "not checked", + /// and skipped claims neither. + public var guidanceOutcome: PacketGuidanceEntry.Outcome { keepsClaims ? .met : .skipped } } /// One spot check and its answer, tied to what it was about: the area, the answer that named the @@ -197,8 +211,10 @@ public struct SpotConfirmation: Sendable, Equatable { /// holds the new one (`SpotArea.holds`): the same footprint, and an area at least as large. A /// spot that moved, or an area that grew, is asked about again. The homeowner's answer is about /// the place, not the photos, so a new scene of the same place (a re-upload after another view, -/// or after "Something's there") is settled by it; the answer and scene digests record which -/// exchange the homeowner answered. +/// or after "Something's there" or "I can't check this area") is settled by it, with the answer +/// as given; the answer and scene digests record which exchange the homeowner answered. Settling +/// a returned spot this way is also what keeps the homeowner from being asked the same question +/// after each re-upload. public struct SpotConfirmations: Sendable, Equatable { public private(set) var records: [SpotConfirmation] = [] @@ -212,4 +228,19 @@ public struct SpotConfirmations: Sendable, Equatable { public func settling(_ area: SpotArea) -> SpotConfirmation? { records.last { $0.area.holds(area) } } + + /// Whether this scan holds an "I can't check this area" answer that the result's notice about + /// the `shown` spot doesn't already speak for. Its claims stay withdrawn for the rest of the + /// scan (`CoverageMap.withdrawClaims`), so every later answer rests on a stretch nobody + /// checked, whatever spot it names. The spot's own notice speaks for answers about the shown + /// spot only when the answer that settles it (`settling`) is "I can't check this area"; when + /// a later "It's clear" about a larger area settles it instead, an earlier answer about the + /// same spot is still unchecked and still counts. With no spot shown (nil), every such answer + /// counts. + public func leftUnchecked(besides shown: SpotArea?) -> Bool { + let spokenFor = shown.flatMap { area in settling(area)?.answer == .cannotCheck ? area : nil } + return records.contains { record in + record.answer == .cannotCheck && !(spokenFor.map { record.area.holds($0) } ?? false) + } + } } diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Coverage/CoverageMap.swift b/ios/HouseScanKit/Sources/HouseScanKit/Coverage/CoverageMap.swift index 963e513f0..7462e2a61 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Coverage/CoverageMap.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Coverage/CoverageMap.swift @@ -178,9 +178,9 @@ public struct CoverageConfig: Sendable, Equatable { /// homeowner confirms the chosen spot before the result is shown: the spot check (`SpotPhoto`, /// `SpotConfirmations`; the app's `ScanEngine+Confirm.swift`) shows a kept photo of the spot and /// its clearance area and asks whether anything stands in front of the wall or on the ground -/// there. "It's clear" backs the claims over that area. "Something's there" withdraws them -/// (`withdrawClaims(over:)`): the area's wall, ground and walked-path claims export as unseen and -/// the scan is checked again. Claims away from the chosen spot are not confirmed; they decide only +/// there. "It's clear" backs the claims over that area. "Something's there" and "I can't check +/// this area" withdraw them (`withdrawClaims(over:)`): the area's wall, ground and walked-path +/// claims export as unseen and the scan is checked again. Claims away from the chosen spot are not confirmed; they decide only /// where the server looks for a spot, and any spot it chooses is checked in turn. public struct CoverageMap: Sendable { public private(set) var wall: WallFrame @@ -1338,8 +1338,9 @@ public struct CoverageMap: Sendable { /// ground, near band and depth rows alike, and the walked-path clearance. The cells read /// `.skipped` in both bands, and the export reports none of them (`wallSeenSpans`, /// `groundDepthSpans`, `facingSpans`), so the server treats the stretch as unseen. For the - /// homeowner's "Something's there" in the spot check: the camera-only and walked-path claims - /// there were wrong (see the type's "Bounded exceptions"). + /// homeowner's "Something's there" in the spot check, where the camera-only and walked-path + /// claims were wrong, and "I can't check this area", where nothing backs them (see the type's + /// "Bounded exceptions"). /// /// It lasts for the scan, through rebuilds, and later views add nothing there: a photo taken /// without depth would claim the same stretch past the same obstruction. Overhead views are diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Geometry/EndAim.swift b/ios/HouseScanKit/Sources/HouseScanKit/Geometry/EndAim.swift new file mode 100644 index 000000000..63085b87f --- /dev/null +++ b/ios/HouseScanKit/Sources/HouseScanKit/Geometry/EndAim.swift @@ -0,0 +1,42 @@ +import Foundation +import simd + +/// Whether "Wall ends here", pressed with the circle in the middle of the view, may put the end the +/// walk's card asks about where the circle's ray meets the wall (B-06). Before, the button marked +/// whatever side the hit fell on, did nothing for a hit it couldn't use, and said nothing either +/// way. The button and the tape's end preview both take this answer, so the tape never shows an +/// end the button would refuse. +/// +/// A hit below the ground is refused with the rule object taps use (`ObjectTap.refusal`). Aimed +/// at the ground, the ray passes the ground and meets the wall's plane under the floor, past the +/// column the circle shows. A hit above the wall is kept: aimed at the sky over the wall, the ray +/// meets the plane in the column under the circle, which is where the end goes. +public enum EndAim { + public enum Verdict: Sendable, Equatable { + /// The end goes here. + case end(WallPoint) + /// Tracking isn't normal: ARKit's pose may be off, so the hit can't be trusted. Feature + /// marks and the next wall refuse the same way, and `WalkedEnd` caps an end measured then. + case trackingLimited + /// The ray meets no wall, meets it below the ground, or farther along it than a view + /// counts for. + case offWall + /// The hit is on the other side of the meter from the end asked about. + case otherSide + } + + /// The verdict for `hit`, where the circle's ray meets `wall` (nil when it doesn't), with the + /// phone at `camera`. `askedLeft` is true when the card asks about the left end (negative s). + /// `reach` and `groundError` are as for `ObjectTap.refusal`. Limited tracking wins over + /// everything: the hit may be somewhere else in the world. + public static func verdict( + hit: WallPoint?, camera: SIMD3, wall: WallFrame, reach: Float, groundError: Float, + askedLeft: Bool, trackingNormal: Bool + ) -> Verdict { + guard trackingNormal else { return .trackingLimited } + guard let hit, ObjectTap.refusal(hit, camera: camera, wall: wall, reach: reach, groundError: groundError) == nil else { + return .offWall + } + return (hit.s < 0) == askedLeft ? .end(hit) : .otherSide + } +} diff --git a/ios/HouseScanKit/Sources/HouseScanKit/SavedScans/PacketArchiveCheck.swift b/ios/HouseScanKit/Sources/HouseScanKit/SavedScans/PacketArchiveCheck.swift new file mode 100644 index 000000000..8db0606e8 --- /dev/null +++ b/ios/HouseScanKit/Sources/HouseScanKit/SavedScans/PacketArchiveCheck.swift @@ -0,0 +1,120 @@ +import Foundation + +/// Whether a file is a zip written to the end in the capture packet's layout, read from its two +/// ends without unpacking it. +/// +/// `scan.zip` is written in place (`ZipWriter.write`), so a scan quit or crashed during the write, +/// or a bundle still being written when a list is made, leaves a partial file under the name that +/// marks a completed scan. The zip's last record (the end of central directory) is written last, +/// so a file whose tail holds that record, pointing at a central directory that ends right before +/// it, was written to the end. The first entry must be `manifest.json`, which the packet always +/// writes first (`KeyframeStore.zipPacket`), so a zip that doesn't start with it is not taken for a +/// scan. +/// The central directory's first record must name the same entry at offset 0, which ties the +/// footer, the directory and the first entry together. A symbolic link is refused, so a copy of +/// the file is always the bytes themselves. +/// +/// This detects a bundle that wasn't written to the end. It does not establish that a file is a +/// capture packet: any zip whose first entry is named `manifest.json` passes, whatever the +/// manifest says, and the entries' contents and CRCs are not read. Only the app writes into its +/// scan folders, which is what makes the name a sufficient marker there. +/// +/// The check reads at most 64 KiB from each end, whatever the archive's size. It does not check +/// each entry's CRC: that would read every photo, and a write that reached the last record wrote +/// every entry before it. +public enum PacketArchiveCheck { + public enum Failure: Error, Equatable, Sendable { + /// The file can't be opened or read. + case unreadable + /// The end of central directory is missing or points somewhere else: a partial write. + case incomplete + /// A whole zip, but not a capture packet: its first entry isn't manifest.json. + case notAPacket + /// A symbolic link, a folder or anything else that isn't a plain file. + case notAFile + } + + static let firstEntryName = "manifest.json" + private static let localHeaderSignature: UInt32 = 0x0403_4B50 + private static let centralHeaderSignature: UInt32 = 0x0201_4B50 + private static let endSignature: UInt32 = 0x0605_4B50 + private static let localHeaderSize = 30 + private static let endRecordSize = 22 + private static let centralHeaderSize = 46 + + /// Returns the archive's size in bytes when it is a whole capture packet. + public static func verify(_ url: URL) throws(Failure) -> Int64 { + // attributesOfItem doesn't follow a final symbolic link. + guard let attributes = try? FileManager.default.attributesOfItem(atPath: url.path) else { throw .unreadable } + guard attributes[.type] as? FileAttributeType == .typeRegular else { throw .notAFile } + guard let handle = try? FileHandle(forReadingFrom: url) else { throw .unreadable } + defer { try? handle.close() } + do { + let size = try handle.seekToEnd() + guard size >= UInt64(localHeaderSize + endRecordSize) else { throw Failure.incomplete } + + // The first local header, and its name. + try handle.seek(toOffset: 0) + let header = try handle.read(upToCount: localHeaderSize) ?? Data() + guard header.count == localHeaderSize, header.le(UInt32.self, at: 0) == localHeaderSignature else { + throw Failure.notAPacket + } + let nameLength = Int(header.le(UInt16.self, at: 26)) + let name = try handle.read(upToCount: nameLength) ?? Data() + guard name == Data(firstEntryName.utf8) else { throw Failure.notAPacket } + + // The end record sits in the last 22 bytes plus its comment, at most 65535 bytes. + let tailLength = min(size, UInt64(endRecordSize + 0xFFFF)) + let tailStart = size - tailLength + try handle.seek(toOffset: tailStart) + let tail = try handle.read(upToCount: Int(tailLength)) ?? Data() + guard tail.count == Int(tailLength), let end = endRecord(in: tail) else { throw Failure.incomplete } + let endOffset = tailStart + UInt64(end) + let entries = tail.le(UInt16.self, at: end + 10) + let directorySize = UInt64(tail.le(UInt32.self, at: end + 12)) + let directoryOffset = UInt64(tail.le(UInt32.self, at: end + 16)) + guard tail.le(UInt16.self, at: end + 4) == 0, tail.le(UInt16.self, at: end + 6) == 0, + tail.le(UInt16.self, at: end + 8) == entries, entries > 0, + directoryOffset + directorySize == endOffset else { throw Failure.incomplete } + + // The central directory starts where the record says, and its first record is the + // manifest at offset 0. + guard directorySize >= UInt64(centralHeaderSize) * UInt64(entries) else { throw Failure.incomplete } + try handle.seek(toOffset: directoryOffset) + let first = try handle.read(upToCount: centralHeaderSize) ?? Data() + guard first.count == centralHeaderSize, first.le(UInt32.self, at: 0) == centralHeaderSignature else { throw Failure.incomplete } + let firstName = try handle.read(upToCount: Int(first.le(UInt16.self, at: 28))) ?? Data() + guard firstName == Data(firstEntryName.utf8), first.le(UInt32.self, at: 42) == 0 else { throw Failure.incomplete } + return Int64(size) + } catch let failure as Failure { + throw failure + } catch { + throw .unreadable + } + } + + /// The offset in `tail` of the end record whose comment runs exactly to the end, searching + /// from the end; nil when there is none. + private static func endRecord(in tail: Data) -> Int? { + var offset = tail.count - endRecordSize + while offset >= 0 { + if tail.le(UInt32.self, at: offset) == endSignature, + Int(tail.le(UInt16.self, at: offset + 20)) == tail.count - offset - endRecordSize { + return offset + } + offset -= 1 + } + return nil + } +} + +private extension Data { + /// A little-endian integer at `offset` from the start of this data. + func le(_: T.Type, at offset: Int) -> T { + var value: T = 0 + for byte in 0...size { + value |= T(self[startIndex + offset + byte]) << (8 * byte) + } + return value + } +} diff --git a/ios/HouseScanKit/Sources/HouseScanKit/SavedScans/SavedScan.swift b/ios/HouseScanKit/Sources/HouseScanKit/SavedScans/SavedScan.swift new file mode 100644 index 000000000..84ce3c49d --- /dev/null +++ b/ios/HouseScanKit/Sources/HouseScanKit/SavedScans/SavedScan.swift @@ -0,0 +1,45 @@ +import Foundation + +/// A completed scan still on the phone, as the Saved scans list shows it: its bundle and what the +/// files say about it. Nothing here is read from the capture itself, so a field the files don't +/// carry is nil rather than guessed. +public struct SavedScan: Sendable, Equatable, Identifiable { + /// The scan folder's name, a UUID the store made. Not shown to the homeowner. + public let id: String + /// The bundle Share scan offers, `/scan.zip`. + public let archive: URL + /// When the bundle was last written: the file's modification time. The bundle is written when + /// the scan is uploaded and again on a retry, so this is when the scan was saved, not when its + /// photos were taken. Nil when the file system gives no date. + public let savedAt: Date? + /// The bundle's size in bytes. + public let byteCount: Int64 + /// `scan-stamp.json`'s `practice`: true for a practice scan, false for a real one, nil when + /// the folder has no readable stamp. Nil is "unknown", never "real". + public let practice: Bool? + + public init(id: String, archive: URL, savedAt: Date?, byteCount: Int64, practice: Bool?) { + self.id = id + self.archive = archive + self.savedAt = savedAt + self.byteCount = byteCount + self.practice = practice + } + + /// The name the shared copy carries, so a recipient with several scans can tell them apart: + /// "House Scan 2026-10-02 14.05.zip" in the phone's time zone, "House Scan practice …" for a + /// practice scan, and plain "House Scan.zip" without a date. No colons, which Files and + /// Windows refuse. + public func shareFileName(timeZone: TimeZone = .current) -> String { + var parts = ["House Scan"] + if practice == true { parts.append("practice") } + if let savedAt { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = timeZone + formatter.dateFormat = "yyyy-MM-dd HH.mm" + parts.append(formatter.string(from: savedAt)) + } + return parts.joined(separator: " ") + ".zip" + } +} diff --git a/ios/HouseScanKit/Sources/HouseScanKit/SavedScans/SavedScanCatalog.swift b/ios/HouseScanKit/Sources/HouseScanKit/SavedScans/SavedScanCatalog.swift new file mode 100644 index 000000000..a64b4cce5 --- /dev/null +++ b/ios/HouseScanKit/Sources/HouseScanKit/SavedScans/SavedScanCatalog.swift @@ -0,0 +1,146 @@ +import Foundation + +/// The completed scans a homeowner can still share, read from the scan folders on disk. +/// +/// A scan counts by the same rule the cleanup keeps it by (`ScanFolderCleanup`): a folder whose +/// `scan.zip` is a whole capture packet (`PacketArchiveCheck`), newest bundle first, at most the +/// `defaultKeepCompleted` the cleanup keeps. Listing the cleanup's own choice means the list never +/// shows an older scan that the next cleanup is about to delete. Each listed bundle is checked +/// again here, since it can change between the cleanup's reading and this one. +/// +/// The list is a reading of the folders at one moment, and a scan can still go after it. A new +/// scan's cleanup deletes what its store listed when it was made, a little later and off the main +/// actor, so a bundle an earlier scan finished writing in between can be listed here and then +/// deleted. Sharing it then reports it missing (`SavedScanShareError.missing`) rather than +/// failing silently; listing exactly the cleanup's pending set would mean sharing state with +/// the store's cleanup, which this feature leaves as it is. +/// +/// Everything here reads files synchronously; the app calls it off the main actor. +public struct SavedScanCatalog: Sendable { + /// The folder holding one folder per scan, Caches/Scans in the app. + public let root: URL + /// The largest stamp read for its `practice` flag. A stamp is a few hundred bytes; anything + /// past this is not one. + static let stampByteLimit = 64 * 1024 + + public init(root: URL) { + self.root = root + } + + /// The completed scans, newest first. Folders without a whole bundle are left out. + public func scans() -> [SavedScan] { + // No folder is the scan in use here: the cleanup's `keeping` excludes a name, and the + // empty name matches no folder, so every folder competes by the cleanup's rule. + ScanFolderCleanup(root: root, keeping: "").keptCompleted.compactMap(Self.scan(in:)) + } + + static func scan(in folder: URL) -> SavedScan? { + // The scan folders are the app's own; a link among them is not a scan. + guard (try? FileManager.default.attributesOfItem(atPath: folder.path))?[.type] as? FileAttributeType == .typeDirectory else { return nil } + let archive = folder.appending(path: ScanFolderCleanup.bundleName) + guard let size = try? PacketArchiveCheck.verify(archive) else { return nil } + let attributes = try? FileManager.default.attributesOfItem(atPath: archive.path) + return SavedScan( + id: folder.lastPathComponent, archive: archive, + savedAt: attributes?[.modificationDate] as? Date, + byteCount: size, + practice: practice(stamp: folder.appending(path: ScanStamp.fileName))) + } + + /// The stamp's `practice` flag when the stamp is readable and holds a JSON boolean there; + /// otherwise nil. A missing, oversized or corrupt stamp says nothing about the scan. + static func practice(stamp: URL) -> Bool? { + // Not a link (attributesOfItem doesn't follow one), and never more read than the limit. + guard (try? FileManager.default.attributesOfItem(atPath: stamp.path))?[.type] as? FileAttributeType == .typeRegular, + let handle = try? FileHandle(forReadingFrom: stamp) else { return nil } + defer { try? handle.close() } + guard let data = try? handle.read(upToCount: stampByteLimit + 1), data.count <= stampByteLimit else { return nil } + struct Flag: Decodable { let practice: Bool } + return (try? JSONDecoder().decode(Flag.self, from: data))?.practice + } +} + +/// Why a saved scan couldn't be handed to the share sheet. +public enum SavedScanShareError: Error, Equatable, Sendable { + /// The bundle is no longer on the phone: a newer scan's cleanup deleted it, or the system + /// cleared the app's caches. + case missing + /// The bundle is there but not a whole capture packet. + case damaged + /// The copy for the share sheet couldn't be made, for example with the phone's storage full. + case copyFailed +} + +/// Copies of saved scans made for the share sheet, each in a folder of its own under `root`. +/// +/// The share sheet reads the file it is given for as long as it is open, and a scan's own bundle +/// can be deleted under it by a new scan's cleanup. A copy outside the scan folders can't be. +/// The copy can be a full copy of the bundle's bytes, so it can fail on a full disk +/// (`SavedScanShareError.copyFailed`), and is deleted as soon as the sheet closes. Only this type +/// creates or deletes anything under `root`; the scan folders are never written. +public struct SavedScanStaging: Sendable { + public let root: URL + /// This app run's folder under `root`. Each run stages into its own, so a launch can delete + /// the copies earlier runs left without judging by time, which a clock change would upset. + public let session: String + + public init(root: URL, session: String = UUID().uuidString) { + self.root = root + self.session = session + } + + private var sessionRoot: URL { + root.appending(path: session, directoryHint: .isDirectory) + } + + /// A whole copy of `scan`'s bundle under `root`, named `scan.shareFileName()`. + public func stage(_ scan: SavedScan) throws(SavedScanShareError) -> URL { + let files = FileManager.default + let source = scan.archive + func failure(_ check: PacketArchiveCheck.Failure) -> SavedScanShareError { + guard files.fileExists(atPath: source.path) else { return .missing } + return check == .unreadable ? .copyFailed : .damaged + } + do { + _ = try PacketArchiveCheck.verify(source) + } catch { + throw failure(error) + } + let folder = sessionRoot.appending(path: UUID().uuidString, directoryHint: .isDirectory) + let copy = folder.appending(path: scan.shareFileName()) + do { + try files.createDirectory(at: folder, withIntermediateDirectories: true) + try files.copyItem(at: source, to: copy) + } catch { + try? files.removeItem(at: folder) + throw files.fileExists(atPath: source.path) ? .copyFailed : .missing + } + // The bundle could have been replaced or cut short while it was copied. + do { + _ = try PacketArchiveCheck.verify(copy) + } catch { + try? files.removeItem(at: folder) + throw failure(error) + } + return copy + } + + /// Deletes a copy `stage` made, with its folder. A URL that isn't one of this staging's copies + /// is left alone. + public func remove(_ copy: URL) { + let folder = copy.deletingLastPathComponent().standardizedFileURL + guard folder.deletingLastPathComponent().path == sessionRoot.standardizedFileURL.path else { return } + try? FileManager.default.removeItem(at: folder) + } + + /// Deletes the copies every other app run made, leaving this run's. At launch that is every + /// copy an earlier run left (a share sheet open when the app was killed never reports back), + /// while a share this run starts keeps its copy. + public func removeOtherSessions() { + let files = FileManager.default + guard let sessions = try? files.contentsOfDirectory(atPath: root.path) else { return } + for name in sessions where name != session { + try? files.removeItem(at: root.appending(path: name)) + } + } +} diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Scene/PlacementResult.swift b/ios/HouseScanKit/Sources/HouseScanKit/Scene/PlacementResult.swift index ac5ac8455..e17efbf92 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Scene/PlacementResult.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Scene/PlacementResult.swift @@ -7,7 +7,8 @@ import Foundation // // Unknown keys are ignored. The server adds optional fields within schema 1.0 without changing // schema_version: `checks[].review_threshold_ft` and `sweep[].segment` arrived that way on -// 2026-09-26, and rejecting them stopped every real upload from reaching the result screen. An +// 2026-09-26, and rejecting them stopped every real upload from reaching the result screen +// (`review_threshold_ft` is now read, and stays optional). An // unknown value of an enum the app presents (decision, outcome, unsure_cause, and the others // below) still fails, because the app can't show a decision or outcome it doesn't know. // `reasons[].code`, `policy.sources` and `route.crossings[].effect` are plain strings: the app @@ -372,6 +373,10 @@ public struct PlacementCheck: Codable, Sendable, Equatable { public var measuredFt: Double? public var plusMinusFt: Double? public var thresholdFt: Double? + /// A second, stricter line on the passing side of `thresholdFt`, on checks with a review band + /// (route_length): a measurement that doesn't clear it is at best UNSURE even when it clears + /// `thresholdFt` (result.schema.json). Optional in the schema; nil when the answer has none. + public var reviewThresholdFt: Double? public var comparison: PlacementComparison? public var subject: String? public var rule: PlacementRule @@ -382,6 +387,7 @@ public struct PlacementCheck: Codable, Sendable, Equatable { case measuredFt = "measured_ft" case plusMinusFt = "plus_minus_ft" case thresholdFt = "threshold_ft" + case reviewThresholdFt = "review_threshold_ft" } public init(from decoder: any Decoder) throws { @@ -394,6 +400,7 @@ public struct PlacementCheck: Codable, Sendable, Equatable { measuredFt = try c.decode(Double?.self, forKey: .measuredFt) plusMinusFt = try c.decode(Double?.self, forKey: .plusMinusFt) thresholdFt = try c.decode(Double?.self, forKey: .thresholdFt) + reviewThresholdFt = try c.decodeIfPresent(Double.self, forKey: .reviewThresholdFt) comparison = try c.decode(PlacementComparison?.self, forKey: .comparison) subject = try c.decodeIfPresent(String.self, forKey: .subject) rule = try c.decode(PlacementRule.self, forKey: .rule) @@ -409,6 +416,7 @@ public struct PlacementCheck: Codable, Sendable, Equatable { try c.encode(measuredFt, forKey: .measuredFt) try c.encode(plusMinusFt, forKey: .plusMinusFt) try c.encode(thresholdFt, forKey: .thresholdFt) + try c.encodeIfPresent(reviewThresholdFt, forKey: .reviewThresholdFt) try c.encode(comparison, forKey: .comparison) try c.encodeIfPresent(subject, forKey: .subject) try c.encode(rule, forKey: .rule) diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Scene/ResultCardActions.swift b/ios/HouseScanKit/Sources/HouseScanKit/Scene/ResultCardActions.swift index cbc8cadc4..1a77f1aad 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Scene/ResultCardActions.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Scene/ResultCardActions.swift @@ -41,4 +41,17 @@ public enum ResultCardActions { public static func offersView(capturable: Bool, sourceAvailable: Bool) -> Bool { capturable && sourceAvailable } + + /// True when a check's line offers the camera for the view that settles it: an UNSURE check + /// no person has to judge, whose settling view the server named (`settlingViewCapturable` is + /// nil when it named none) and the app can take now (`offersView`). The line's "Show me" and + /// its words both read this, so a line says one more photo would settle it only when it lets + /// the homeowner take that photo. A view the homeowner couldn't get to, or a request the app + /// can't plan, is not capturable. + public static func lineOffersView( + outcome: PlacementOutcome, needsPerson: Bool, settlingViewCapturable: Bool?, sourceAvailable: Bool + ) -> Bool { + outcome == .unsure && !needsPerson + && offersView(capturable: settlingViewCapturable == true, sourceAvailable: sourceAvailable) + } } diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Scene/ResultMarkLayout.swift b/ios/HouseScanKit/Sources/HouseScanKit/Scene/ResultMarkLayout.swift index b0abc2e66..90dc57eb8 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Scene/ResultMarkLayout.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Scene/ResultMarkLayout.swift @@ -1,9 +1,9 @@ import Foundation // Where the result's marks go, shared by the result card's 3D model (`ResultScene3D`) and the AR -// view (`ResultARModel`, `BatteryOverlay`): which spot the camera faces, how the clearance zones -// stack, where a footprint outline sits above them and how it is dashed. Display only; nothing -// here changes where the server put the spot or what it decided. +// view (`ResultARModel`, `BatteryOverlay`): which spot the camera faces, what floor the sweep's +// clearance zones cover and how they stack, where a footprint outline sits above them and how it +// is dashed. Display only; nothing here changes where the server put the spot or what it decided. public enum ResultMarkLayout { /// What stands at the spot. Only a clean fit (`ResultReading.spotIsClean`) gets a battery; a @@ -35,6 +35,41 @@ public enum ResultMarkLayout { return (span.lowerBound + span.upperBound) / 2 } + /// A battery's footprint size, meters: along the wall and out from it. + public struct BatterySize: Equatable, Sendable { + public var width: Float + public var depth: Float + + public init(width: Float, depth: Float) { + self.width = width + self.depth = depth + } + } + + /// The floor a sweep run covers, meters: along the wall and out from it. + public struct SweepZone: Equatable, Sendable { + public var span: ClosedRange + public var depth: Float + + public init(span: ClosedRange, depth: Float) { + self.span = span + self.depth = depth + } + } + + /// One zone per sweep run, in order, or none. `starts` are each run's `start_ft` in meters of + /// s: a range of battery LEFT edges, so the floor it describes reaches one battery width past + /// the larger start and one battery deep. The answer sizes a battery only on `spot` and + /// `nearest_considered` (result.schema.json), so `battery` is the size of one of those. With + /// neither there is no zone at all: the starts alone are not a footprint, and drawing them + /// with a made-up width and depth showed floor the server never sized. + public static func sweepZones(starts: [SIMD2], battery: BatterySize?) -> [SweepZone] { + guard let battery else { return [] } + return starts.map { start in + SweepZone(span: min(start.x, start.y)...(max(start.x, start.y) + battery.width), depth: battery.depth) + } + } + /// How far apart stacked clearance zones sit, in meters, so overlapping ones don't flicker. public static let zoneStep: Float = 0.003 diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Scene/ResultReading.swift b/ios/HouseScanKit/Sources/HouseScanKit/Scene/ResultReading.swift index 4479dddea..8330d35cd 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Scene/ResultReading.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Scene/ResultReading.swift @@ -83,6 +83,28 @@ public enum ResultReading { return Array((fails + unsure + passes).prefix(limit)) } + /// Whether a check's review line (`PlacementCheck.reviewThresholdFt`) explains its outcome, so + /// the card can say that past it the check needs review. Reads the server's outcome and never + /// replaces it. True only when all of these hold: + /// - the outcome is UNSURE: a PASS cleared the line, and a FAIL is past the limit itself; + /// - the check has a measurement, a limit, a review line and a direction; + /// - the review line lies on the passing side of the limit (under a maximum, over a + /// minimum), so there is a band between them; + /// - the measurement doesn't clear the review line by the schema's own test (at_most clears + /// when measured + error < review line; at_least when measured - error > review line). + /// A missing or negative error counts as none. + public static func reviewBandApplies( + outcome: PlacementOutcome, measured: Double?, plusMinus: Double?, threshold: Double?, + reviewThreshold: Double?, comparison: PlacementComparison? + ) -> Bool { + guard outcome == .unsure, let measured, let threshold, let reviewThreshold, let comparison else { return false } + let error = max(plusMinus ?? 0, 0) + switch comparison { + case .atMost: return reviewThreshold < threshold && !(measured + error < reviewThreshold) + case .atLeast: return reviewThreshold > threshold && !(measured - error > reviewThreshold) + } + } + /// How far `measured` clears `threshold` on the passing side, in units of `plusMinus` when it /// is positive, else in the measurement's own units. Negative when it misses. Without a /// comparison the side is unknown, so the distance to the limit either way. @@ -115,6 +137,15 @@ extension PlacementCheck { guard let unsureCause else { return true } return unsureCause != .unobserved } + + /// `ResultReading.reviewBandApplies` on this check's values as the server sent them, in feet. + /// Read it here, before any conversion: a run of 14.5 ft ± 6 in reaches the 15 ft line + /// exactly, and the same values narrowed to Float meters fell just short of it. + public var reviewBandApplies: Bool { + ResultReading.reviewBandApplies( + outcome: outcome, measured: measuredFt, plusMinus: plusMinusFt, threshold: thresholdFt, + reviewThreshold: reviewThresholdFt, comparison: comparison) + } } extension PlacementResult { diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Scene/ScanFolderCleanup.swift b/ios/HouseScanKit/Sources/HouseScanKit/Scene/ScanFolderCleanup.swift index 3bd3bbd3b..3335f8361 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Scene/ScanFolderCleanup.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Scene/ScanFolderCleanup.swift @@ -2,49 +2,142 @@ import Foundation /// The old scan folders a new scan's store deletes, and the ones it keeps. /// -/// Kept: the new scan's own folder, and the `keepCompleted` most recent completed scans (those -/// with a packaged bundle, `bundleName`, newest bundle first), until newer completed scans push -/// them out. Every other entry goes: scans never packaged, and older completed ones. A scan the -/// homeowner finished survives the app being quit and relaunched, which makes a new store; before, -/// every relaunch deleted it, and a field run's export was lost that way. +/// Kept: the new scan's own folder, the `keepCompleted` most recent completed scans (those whose +/// bundle, `bundleName`, is a whole capture packet, newest bundle first) until newer completed +/// scans push them out, and every folder whose bundle is there but not whole. Every other entry +/// goes: scans never packaged, and older completed ones. A scan the homeowner finished survives +/// the app being quit and relaunched, which makes a new store; before, every relaunch deleted it, +/// and a field run's export was lost that way. +/// +/// A bundle counts as completed only when `PacketArchiveCheck` finds it written to the end. The +/// zip is written in place, so a quit or crash during the write leaves a partial `scan.zip`. When +/// the file's presence alone counted, that partial bundle took one of the kept places and an +/// intact older scan was deleted to make room. +/// +/// A partial or unreadable bundle takes no kept place, and its folder is not deleted, however old +/// it is. Nothing here can tell an abandoned partial from one whose writer is still going: Start +/// over makes a new store while the previous scan's bundle may still be being written, and a +/// suspended phone can pause a write for any length of time, so the file's age proves nothing. +/// Deleting it could take the folder from under that write. The cost is that an abandoned partial +/// bundle stays on the phone. Reclaiming it needs a way to prove no writer holds it, which this +/// type does not have. /// /// The folders to delete are listed when the new scan's folder is made, before any newer scan /// can exist, and only those are deleted, whenever the deletion gets to run. Listing at deletion /// time instead let a cleanup that ran late delete a scan started after it: store A is made, /// Start over makes B, and A's cleanup then found B in the listing and deleted the scan in use. public struct ScanFolderCleanup: Sendable { - /// The file whose presence marks a completed scan: the bundle Share scan offers. + /// The bundle Share scan offers. A scan is completed when this file is a whole capture packet + /// (`PacketArchiveCheck`); the file being there is not enough. public static let bundleName = "scan.zip" /// The most recent completed scan, and one more: a homeowner who starts another scan still /// has the one before it. Each is a few tens of megabytes of photos. public static let defaultKeepCompleted = 2 - /// The folders beside the kept one when the cleanup was made, to delete. public let obsolete: [URL] /// The completed scans kept, newest first. public let keptCompleted: [URL] + /// Folders whose bundle is there but not whole: left alone, holding no kept place. + public let incompleteBundles: [URL] + /// What each obsolete entry was when listed. `run` deletes an entry only if it still is. + private let listedAs: [URL: Entry] /// Lists `root` now. An unreadable root lists nothing. public init(root: URL, keeping kept: String, keepCompleted: Int = defaultKeepCompleted) { let files = FileManager.default let names = ((try? files.contentsOfDirectory(atPath: root.path)) ?? []).filter { $0 != kept }.sorted() - let completed = names.compactMap { name -> (name: String, date: Date)? in - let bundle = root.appending(path: name).appending(path: Self.bundleName) - guard let attributes = try? files.attributesOfItem(atPath: bundle.path) else { return nil } - return (name, attributes[.modificationDate] as? Date ?? .distantPast) + var completed: [(name: String, date: Date)] = [] + var incomplete: [String] = [] + var entries: [String: Entry] = [:] + for name in names { + let entry = Self.entry(at: root.appending(path: name)) + entries[name] = entry + switch entry { + case .file, .folder(.absent): + continue + case .folder(.complete(let date, _)): + completed.append((name, date)) + case .unjudged, .folder(.incomplete): + incomplete.append(name) + } + } + completed.sort { ($0.date, $0.name) > ($1.date, $1.name) } + let keptNames = completed.prefix(max(0, keepCompleted)).map(\.name) + let keep = Set(keptNames + incomplete) + keptCompleted = keptNames.map { root.appending(path: $0) } + incompleteBundles = incomplete.map { root.appending(path: $0) } + let obsoleteNames = names.filter { !keep.contains($0) } + obsolete = obsoleteNames.map { root.appending(path: $0) } + listedAs = Dictionary(uniqueKeysWithValues: obsoleteNames.compactMap { name in entries[name].map { (root.appending(path: name), $0) } }) + } + + /// One entry beside the scan in use. + enum Entry: Equatable, Sendable { + /// A plain file: no bundle, so it goes, as before. + case file + /// A link or an entry whose type can't be read. A link (attributesOfItem doesn't follow + /// one) would reach another folder's bundle, so it and its target could take both kept + /// places; it holds no place and is left alone. + case unjudged + /// A real folder, judged by its bundle. + case folder(BundleState) + } + + static func entry(at url: URL) -> Entry { + switch (try? FileManager.default.attributesOfItem(atPath: url.path))?[.type] as? FileAttributeType { + case .typeDirectory?: .folder(bundleState(url.appending(path: bundleName))) + case .typeRegular?: .file + default: .unjudged + } + } + + enum BundleState: Equatable, Sendable { + case absent + /// `file` is the bundle's file number: a rewrite makes a new file, whatever its date. + case complete(savedAt: Date, file: UInt64) + case incomplete + } + + /// Only a bundle known to be missing is absent. A metadata read that fails for another reason + /// is incomplete: the folder may hold a bundle, so it is kept without a place. + /// + /// A whole bundle must still be the same file after the check. A retry rewrites `scan.zip` in + /// place (`ZipWriter.write` removes it and starts a new file), and a check that opened the old + /// file can finish on it after the new partial one has replaced it; the file number tells them + /// apart. A rewrite that starts after the second read is not caught: the folder then holds its + /// place while its bundle is partial, until the next cleanup. Closing that needs the writer to + /// replace the file atomically, which the packet code doesn't do. + /// `verify` is `PacketArchiveCheck.verify`; tests pass one that rewrites the file mid-check. + static func bundleState(_ bundle: URL, verify: (URL) throws -> Int64 = PacketArchiveCheck.verify) -> BundleState { + let files = FileManager.default + let before: [FileAttributeKey: Any] + do { + before = try files.attributesOfItem(atPath: bundle.path) + } catch let error as CocoaError where error.code == .fileReadNoSuchFile || error.code == .fileNoSuchFile { + return .absent + } catch { + return .incomplete } - .sorted { ($0.date, $0.name) > ($1.date, $1.name) } - let keep = Set(completed.prefix(max(0, keepCompleted)).map(\.name)) - keptCompleted = completed.prefix(max(0, keepCompleted)).map { root.appending(path: $0.name) } - obsolete = names.filter { !keep.contains($0) }.map { root.appending(path: $0) } + guard (try? verify(bundle)) != nil, + let after = try? files.attributesOfItem(atPath: bundle.path), + let number = before[.systemFileNumber] as? UInt64, + after[.systemFileNumber] as? UInt64 == number, + after[.size] as? UInt64 == before[.size] as? UInt64 else { return .incomplete } + return .complete(savedAt: before[.modificationDate] as? Date ?? .distantPast, file: number) } /// Deletes the listed folders; returns each one that could not be deleted with the reason. A /// folder already gone is not an error. + /// + /// The deletion runs later than the listing, off the main actor, and a folder can change in + /// between: a bundle write can start in a folder listed as never packaged, or finish in one. + /// Each entry is read again and deleted only if it is still what it was when listed; one that + /// changed is left for the next cleanup. A change after this second read is not caught. public func run() -> [(url: URL, error: any Error)] { let files = FileManager.default return obsolete.compactMap { url in guard files.fileExists(atPath: url.path) else { return nil } + guard let listed = listedAs[url], Self.entry(at: url) == listed else { return nil } do { try files.removeItem(at: url) return nil diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Upload/PlacementHTTPClient.swift b/ios/HouseScanKit/Sources/HouseScanKit/Upload/PlacementHTTPClient.swift new file mode 100644 index 000000000..b6ba2398a --- /dev/null +++ b/ios/HouseScanKit/Sources/HouseScanKit/Upload/PlacementHTTPClient.swift @@ -0,0 +1,80 @@ +import Foundation + +/// Sends a scan's scene.json to the placement server and returns the answer only when it is usable: +/// `POST {serverURL}/v1/placements` with the JSON as the body and `Content-Type: application/json`. +/// Only the JSON goes; the server's solver reads no photos for a bare scene.json, so the keyframes +/// stay on the phone. +/// +/// The answer is returned only when the reply is HTTP 2xx and names the scene sent: its +/// `stats.input_sha256` must be the hash of these exact bytes (`ResultBinding`). Every other +/// outcome throws, and `UploadFailureKind.classify` says what it means for the homeowner. +public struct PlacementHTTPClient: Sendable { + public let serverURL: URL + public let session: URLSession + + public init(serverURL: URL, session: URLSession = .shared) { + self.serverURL = serverURL + self.session = session + } + + public static func request(serverURL: URL) -> URLRequest { + var request = URLRequest(url: serverURL.appending(path: "v1/placements")) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.timeoutInterval = 120 + return request + } + + /// The answer's bytes. `progress` runs from 0 to 1 while the scene is sent, and reaches 1 once + /// the server has the whole body and is working out the spot. + public func submit(scene: Data, progress: @escaping @Sendable (Double) -> Void) async throws -> Data { + let delegate = PlacementUploadProgress(progress: progress) + let (data, response) = try await session.upload(for: Self.request(serverURL: serverURL), from: scene, delegate: delegate) + guard let http = response as? HTTPURLResponse else { throw PlacementHTTPError.notHTTP } + guard (200..<300).contains(http.statusCode) else { + throw PlacementHTTPError.server( + 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 + } +} + +/// How sending the scene failed before there was an answer to read. +public enum PlacementHTTPError: Error, Equatable, CustomStringConvertible { + /// The response wasn't HTTP, so no server answered. + case notHTTP + /// The server answered with a status outside 2xx. `body` is the first 300 bytes, for the log. + case server(status: Int, body: String, retryAfter: String? = nil) + + public var description: String { + switch self { + case .notHTTP: "the response was not HTTP" + case .server(let status, let body, _): "the server answered \(status): \(body)" + } + } +} + +/// Reports upload progress. Once the whole body is sent the server is working out the spot in the +/// same request, so it reports 1 and the screen can move on to "Check clearances". Before that it +/// stops at 0.99, so rounding never shows 100%. Holding 0.99 until the answer left the screen on +/// "Sending measurements, 99%" for the whole analysis (field test run 1). +final class PlacementUploadProgress: NSObject, URLSessionTaskDelegate, Sendable { + private let progress: @Sendable (Double) -> Void + + init(progress: @escaping @Sendable (Double) -> Void) { + self.progress = progress + } + + func urlSession(_ session: URLSession, task: URLSessionTask, didSendBodyData bytesSent: Int64, totalBytesSent: Int64, totalBytesExpectedToSend: Int64) { + guard totalBytesExpectedToSend > 0 else { return } + if totalBytesSent >= totalBytesExpectedToSend { + progress(1) + } else { + progress(min(0.99, Double(totalBytesSent) / Double(totalBytesExpectedToSend))) + } + } +} diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Upload/UploadFailureKind.swift b/ios/HouseScanKit/Sources/HouseScanKit/Upload/UploadFailureKind.swift index d7a19f338..efd14d850 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Upload/UploadFailureKind.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Upload/UploadFailureKind.swift @@ -1,10 +1,11 @@ import Foundation -/// Why sending the scene failed, which decides whether sending it again can help. +/// Why sending the scene failed, which decides what the homeowner can do next. /// /// A network failure, a server error (5xx) or a server that is busy or timed out (408, 429) may -/// go away, so the homeowner can try again. Any other refusal (4xx) or an answer that doesn't -/// decode would come back the same for the same scene, so the way on is back to the review or +/// go away, so the homeowner can try again. So can an answer House Scan couldn't use: the problem +/// is in the answer, not the scan, and asking again may get a usable one. A refusal (4xx) is the +/// server saying this scene can't be checked as it is, so the way on is back to the review or /// starting over. public enum UploadFailureKind: Sendable, Equatable { /// No connection at all. @@ -19,14 +20,16 @@ public enum UploadFailureKind: Sendable, Equatable { case busy(retryAfter: Int?) /// The server answered 4xx: it refused this scene. case refused - /// The answer wasn't HTTP or didn't decode as a result. + /// The server answered, but House Scan can't use the answer: a 2xx that doesn't decode as a + /// result or doesn't name the scene sent (`ResultBinding`), or a status outside 2xx–5xx. case unreadableAnswer - /// Sending the same scene again can succeed. + /// The homeowner may send the same scene again: it can succeed. The app never does it on its + /// own; it offers "Try again". public var retryable: Bool { switch self { - case .offline, .unreachable, .serverError, .busy: true - case .refused, .unreadableAnswer: false + case .offline, .unreachable, .serverError, .busy, .unreadableAnswer: true + case .refused: false } } @@ -34,7 +37,15 @@ public enum UploadFailureKind: Sendable, Equatable { if let urlError = error as? URLError { return offlineCodes.contains(urlError.code) ? .offline : .unreachable } - return .unreadableAnswer + switch error as? PlacementHTTPError { + case .server(let status, _, let retryAfter)?: + return classify(httpStatus: status, retryAfter: retryAfter) + case .notHTTP?: + // No server answered, so nothing was refused or unreadable. + return .unreachable + case nil: + return .unreadableAnswer + } } /// The kind for an HTTP status outside 200...299. `retryAfter` is the answer's Retry-After diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/EndAimTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/EndAimTests.swift new file mode 100644 index 000000000..6ae3b5e5e --- /dev/null +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/EndAimTests.swift @@ -0,0 +1,79 @@ +import Foundation +import HouseScanKit +import simd +import Testing + +// "Wall ends here" at the circle (B-06). The wall is z = 0 facing +z, so +s runs along +x, to the +// right as the phone faces the wall. The ground is y = 0 and the meter 1.5 m up. Synthetic +// coordinates chosen for the tests, not from a capture. + +@Suite struct EndAimTests { + static let wall = WallFrame(meter: SIMD3(0, 1.5, 0), outward: SIMD3(0, 0, 1), groundY: 0)! + /// A phone 1.75 m up, 2 m in front of the meter. + static let phone = SIMD3(0, 1.75, 2) + static let reach = CoverageConfig().maxDistance + + /// The verdict for the circle's ray from `phone` toward `target`. + static func aim( + toward target: SIMD3, askedLeft: Bool = false, groundError: Float = 0, trackingNormal: Bool = true + ) -> EndAim.Verdict { + let hit = wall.intersectWall(Ray(origin: phone, direction: simd_normalize(target - phone))) + return EndAim.verdict(hit: hit, camera: phone, wall: wall, reach: reach, groundError: groundError, askedLeft: askedLeft, trackingNormal: trackingNormal) + } + + static func endS(_ verdict: EndAim.Verdict) -> Float? { + if case .end(let point) = verdict { return point.s } + return nil + } + + @Test func theAskedEndOnTheWallIsMarkedThere() throws { + let right = try #require(Self.endS(Self.aim(toward: SIMD3(2, 1, 0)))) + #expect(abs(right - 2) < 1e-4) + let left = try #require(Self.endS(Self.aim(toward: SIMD3(-2, 1, 0), askedLeft: true))) + #expect(abs(left + 2) < 1e-4) + } + + @Test func theOtherSideOfTheMeterIsRefused() { + #expect(Self.aim(toward: SIMD3(2, 1, 0), askedLeft: true) == .otherSide) + #expect(Self.aim(toward: SIMD3(-2, 1, 0), askedLeft: false) == .otherSide) + } + + /// Aimed at the ground 1 m out, the ray meets the wall's plane 1.75 m under the floor. + @Test func aimingAtTheGroundIsOffTheWall() { + #expect(Self.aim(toward: SIMD3(1, 0, 1)) == .offWall) + } + + /// The wall's foot, a little under the ground: within `ObjectTap.belowGroundSlack` (0.15 m), + /// widened by the ground's own error. + @Test func theWallsFootCountsWithinTheGroundSlack() { + #expect(Self.endS(Self.aim(toward: SIMD3(1, -0.1, 0))) != nil) + #expect(Self.aim(toward: SIMD3(1, -0.3, 0)) == .offWall) + #expect(Self.endS(Self.aim(toward: SIMD3(1, -0.3, 0), groundError: 0.2)) != nil) + } + + /// Above the wall the ray meets the plane in the column under the circle: the end goes there. + @Test func aimingAboveTheWallKeepsTheColumnUnderTheCircle() throws { + let s = try #require(Self.endS(Self.aim(toward: SIMD3(1, 6, 0)))) + #expect(abs(s - 1) < 1e-4) + } + + @Test func farDownALongWallIsOffTheWall() { + #expect(Self.aim(toward: SIMD3(Self.reach + 1, 1, 0)) == .offWall) + #expect(Self.endS(Self.aim(toward: SIMD3(Self.reach - 0.5, 1, 0))) != nil) + } + + @Test func aRayAwayFromTheWallIsOffTheWall() { + #expect(Self.aim(toward: SIMD3(0, 1.75, 5)) == .offWall) + } + + @Test func limitedTrackingWinsOverAGoodHit() { + #expect(Self.aim(toward: SIMD3(2, 1, 0), trackingNormal: false) == .trackingLimited) + #expect(Self.aim(toward: SIMD3(1, 0, 1), trackingNormal: false) == .trackingLimited) + } + + /// The meter's own column belongs to the right, as `hit.s < 0` decides left everywhere else. + @Test func theMetersColumnIsTheRight() { + #expect(Self.endS(Self.aim(toward: SIMD3(0, 1, 0))) != nil) + #expect(Self.aim(toward: SIMD3(0, 1, 0), askedLeft: true) == .otherSide) + } +} diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/KeptScansTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/KeptScansTests.swift index 6f164478f..521549436 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/KeptScansTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/KeptScansTests.swift @@ -10,17 +10,28 @@ import Testing return root } - /// A scan folder, completed (with its bundle, written at `minutesAgo`) or not. - static func scan(_ name: String, in root: URL, bundledMinutesAgo minutesAgo: Double? = nil) throws { + /// A capture packet as the app zips it, manifest.json first. Synthetic, never a capture. + static let packet: Data = try! ZipWriter.archive([ + ZipEntry(name: "manifest.json", data: Data(#"{"version":"1.1"}"#.utf8)), + ZipEntry(name: "photos/p00001.jpg", data: Data((0..<4096).map { UInt8($0 % 251) })), + ]) + + /// A scan folder, completed (with its bundle, written at `minutesAgo`) or not. A `partial` + /// bundle is the packet cut short, as a quit during its write leaves it. + static func scan(_ name: String, in root: URL, bundledMinutesAgo minutesAgo: Double? = nil, partial: Bool = false) throws { let folder = root.appending(path: name) try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) try Data("photo".utf8).write(to: folder.appending(path: "k00001.jpg")) guard let minutesAgo else { return } let bundle = folder.appending(path: ScanFolderCleanup.bundleName) - try Data("zip".utf8).write(to: bundle) + try (partial ? packet.prefix(packet.count / 2) : packet).write(to: bundle) try FileManager.default.setAttributes([.modificationDate: Date(timeIntervalSinceNow: -minutesAgo * 60)], ofItemAtPath: bundle.path) } + static func names(_ urls: [URL]) -> [String] { + urls.map(\.lastPathComponent) + } + /// A relaunch makes a new store: the two most recent completed scans stay, with their /// bundles; an older completed scan and one never packaged go. Before, all of them went. @Test func aRelaunchKeepsTheLastCompletedScans() throws { @@ -58,6 +69,170 @@ import Testing #expect(ScanFolderCleanup(root: root, keeping: "later").keptCompleted.map(\.lastPathComponent) == ["run3", "run2"]) } + /// A partial bundle takes no kept place, however new: both intact scans stay. Its folder + /// is left alone too, however old, since nothing shows its writer has stopped. Before, + /// presence alone counted, so the newer partial kept a place and an intact scan was deleted. + @Test(arguments: [0.5, 20.0, 60.0 * 24 * 365]) + func aPartialBundleNeitherEvictsIntactScansNorIsDeleted(minutesAgo: Double) throws { + let root = try Self.makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + try Self.scan("intact1", in: root, bundledMinutesAgo: 40) + try Self.scan("intact2", in: root, bundledMinutesAgo: 90) + try Self.scan("intact3", in: root, bundledMinutesAgo: 120) + // Newer than every intact scan, unless it is the year-old one. + try Self.scan("partial", in: root, bundledMinutesAgo: minutesAgo, partial: true) + try Self.scan("unpackaged", in: root) + + let cleanup = ScanFolderCleanup(root: root, keeping: "now") + #expect(Self.names(cleanup.keptCompleted) == ["intact1", "intact2"]) + #expect(Self.names(cleanup.incompleteBundles) == ["partial"]) + #expect(Self.names(cleanup.obsolete) == ["intact3", "unpackaged"]) + #expect(cleanup.run().isEmpty) + #expect(try Set(FileManager.default.contentsOfDirectory(atPath: root.path)) == ["intact1", "intact2", "partial"]) + #expect(try Data(contentsOf: root.appending(path: "partial/\(ScanFolderCleanup.bundleName)")) == Self.packet.prefix(Self.packet.count / 2)) + } + + /// A bundle that fails the check for any reason (empty, not a packet) is treated like a + /// partial one; once its write finishes it competes like any completed scan. + @Test func anUnreadableBundleIsLeftAloneUntilItIsWhole() throws { + let root = try Self.makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + try Self.scan("intact1", in: root, bundledMinutesAgo: 40) + try Self.scan("intact2", in: root, bundledMinutesAgo: 90) + let empty = root.appending(path: "empty") + try FileManager.default.createDirectory(at: empty, withIntermediateDirectories: true) + try Data().write(to: empty.appending(path: ScanFolderCleanup.bundleName)) + + let cleanup = ScanFolderCleanup(root: root, keeping: "now") + #expect(Self.names(cleanup.incompleteBundles) == ["empty"]) + #expect(cleanup.obsolete.isEmpty) + + try Self.packet.write(to: empty.appending(path: ScanFolderCleanup.bundleName)) + let later = ScanFolderCleanup(root: root, keeping: "now") + #expect(Self.names(later.keptCompleted) == ["empty", "intact1"]) + #expect(Self.names(later.obsolete) == ["intact2"]) + } + + /// A folder whose bundle can't be read, or whose bundle is a link or a folder, and a link in + /// place of a scan folder hold no place and are not deleted. + @Test func bundlesThatCannotBeJudgedAreLeftAlone() throws { + let root = try Self.makeRoot() + let files = FileManager.default + try Self.scan("intact1", in: root, bundledMinutesAgo: 40) + try Self.scan("intact2", in: root, bundledMinutesAgo: 90) + try Self.scan("locked", in: root, bundledMinutesAgo: 1) + let locked = root.appending(path: "locked") + try files.setAttributes([.posixPermissions: 0o000], ofItemAtPath: locked.path) + defer { + try? files.setAttributes([.posixPermissions: 0o755], ofItemAtPath: locked.path) + try? files.removeItem(at: root) + } + try Self.scan("linked", in: root) + try files.createSymbolicLink(at: root.appending(path: "linked/\(ScanFolderCleanup.bundleName)"), + withDestinationURL: root.appending(path: "intact1/\(ScanFolderCleanup.bundleName)")) + try Self.scan("folderBundle", in: root) + try files.createDirectory(at: root.appending(path: "folderBundle/\(ScanFolderCleanup.bundleName)"), withIntermediateDirectories: true) + + try Data("stray".utf8).write(to: root.appending(path: "stray.txt")) + // A link to the newest intact scan, named to sort ahead of it: it must not take a place. + try files.createSymbolicLink(at: root.appending(path: "zalias"), withDestinationURL: root.appending(path: "intact1")) + + let cleanup = ScanFolderCleanup(root: root, keeping: "now") + #expect(Self.names(cleanup.keptCompleted) == ["intact1", "intact2"]) + #expect(Set(Self.names(cleanup.incompleteBundles)) == ["locked", "linked", "folderBundle", "zalias"]) + // A stray file is not a scan folder and goes, as before. + #expect(Self.names(cleanup.obsolete) == ["stray.txt"]) + #expect(cleanup.run().isEmpty) + #expect(try Set(files.contentsOfDirectory(atPath: root.path)) == ["intact1", "intact2", "locked", "linked", "folderBundle", "zalias"]) + } + + /// A retry rewrites scan.zip in place while the check may still be reading the old file. A + /// check that passes on the old file must not count the new, partial one as complete. + @Test func aBundleReplacedDuringTheCheckIsNotComplete() throws { + let root = try Self.makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + try Self.scan("rewritten", in: root, bundledMinutesAgo: 5) + let bundle = root.appending(path: "rewritten/\(ScanFolderCleanup.bundleName)") + + let state = ScanFolderCleanup.bundleState(bundle) { url in + let size = try PacketArchiveCheck.verify(url) + // As ZipWriter.write does: remove the file, then start a new one. + try FileManager.default.removeItem(at: url) + try Self.packet.prefix(Self.packet.count / 2).write(to: url) + return size + } + #expect(state == .incomplete) + + // Left alone, the same bundle is complete. + try Self.packet.write(to: bundle) + let settled = ScanFolderCleanup.bundleState(bundle) + #expect(settled != .incomplete && settled != .absent) + #expect(ScanFolderCleanup.bundleState(root.appending(path: "none/\(ScanFolderCleanup.bundleName)")) == .absent) + } + + /// The scan in use is never judged, whatever its bundle; a folder made after the listing is + /// never deleted by it. + @Test func theScanInUseAndLaterFoldersAreSafe() throws { + let root = try Self.makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + try Self.scan("current", in: root, bundledMinutesAgo: 60, partial: true) + try Self.scan("old", in: root) + let cleanup = ScanFolderCleanup(root: root, keeping: "current") + #expect(!(cleanup.obsolete + cleanup.keptCompleted + cleanup.incompleteBundles).map(\.lastPathComponent).contains("current")) + #expect(Self.names(cleanup.obsolete) == ["old"]) + + try Self.scan("later", in: root, bundledMinutesAgo: 1) + try Self.scan("laterPartial", in: root, bundledMinutesAgo: 60, partial: true) + #expect(cleanup.run().isEmpty) + #expect(try Set(FileManager.default.contentsOfDirectory(atPath: root.path)) == ["current", "later", "laterPartial"]) + } + + /// The deletion runs after the listing. A folder whose bundle appeared since, partial or + /// whole, is left for the next cleanup, and so is a bundle a retry rewrote; one still as + /// listed is deleted. + @Test func runDeletesOnlyEntriesStillAsListed() throws { + let root = try Self.makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + let files = FileManager.default + let bundle = { (name: String) in root.appending(path: "\(name)/\(ScanFolderCleanup.bundleName)") } + try Self.scan("intact1", in: root, bundledMinutesAgo: 40) + try Self.scan("intact2", in: root, bundledMinutesAgo: 90) + try Self.scan("intact3", in: root, bundledMinutesAgo: 120) + try Self.scan("rewritten", in: root, bundledMinutesAgo: 150) + // The same date before and after the rewrite: only the new file tells them apart. + let saved = Date(timeIntervalSinceReferenceDate: 800_000_000) + try files.setAttributes([.modificationDate: saved], ofItemAtPath: bundle("rewritten").path) + try Self.scan("startsWriting", in: root) + try Self.scan("finishesWriting", in: root) + try Self.scan("stillBare", in: root) + let cleanup = ScanFolderCleanup(root: root, keeping: "now") + #expect(Self.names(cleanup.obsolete) == ["finishesWriting", "intact3", "rewritten", "startsWriting", "stillBare"]) + + try Self.packet.prefix(Self.packet.count / 2).write(to: bundle("startsWriting")) + try Self.packet.write(to: bundle("finishesWriting")) + // A retry rewrites in place: the old file goes and a new one takes its name. + try files.removeItem(at: bundle("rewritten")) + try Self.packet.write(to: bundle("rewritten")) + try files.setAttributes([.modificationDate: saved], ofItemAtPath: bundle("rewritten").path) + + #expect(cleanup.run().isEmpty) + #expect(try Set(files.contentsOfDirectory(atPath: root.path)) == ["intact1", "intact2", "rewritten", "startsWriting", "finishesWriting"]) + } + + /// Bundles saved at the same moment keep the order by name, newest name first, as before. + @Test func equalSaveTimesFallBackToNameOrder() throws { + let root = try Self.makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + let bundled = Date(timeIntervalSinceNow: -30 * 60) + for name in ["a", "b", "c"] { + try Self.scan(name, in: root, bundledMinutesAgo: 30) + try FileManager.default.setAttributes([.modificationDate: bundled], ofItemAtPath: root.appending(path: "\(name)/\(ScanFolderCleanup.bundleName)").path) + } + let cleanup = ScanFolderCleanup(root: root, keeping: "new") + #expect(Self.names(cleanup.keptCompleted) == ["c", "b"]) + #expect(Self.names(cleanup.obsolete) == ["a"]) + } + /// The stamp carries the build, the server and the answer's rules, with nulls where there /// are none, so the file always has the same shape. @Test func theStampSaysWhatBuiltAndJudgedTheScan() throws { diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/LoopbackPlacementServer.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/LoopbackPlacementServer.swift new file mode 100644 index 000000000..e795715b1 --- /dev/null +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/LoopbackPlacementServer.swift @@ -0,0 +1,91 @@ +import Foundation +import Network +import Synchronization + +/// A placement server on 127.0.0.1 for client tests: real HTTP, so the tests drive the actual +/// `URLSession` transport. Each request gets the reply `respond` builds from it, and every request +/// is kept for the test to inspect. It speaks only what these tests need: one request per +/// connection, a `Content-Length` body, and the reply closes the connection. The listener's +/// handlers hold the server weakly, so the listener closes when the test drops the server. +final class LoopbackPlacementServer: Sendable { + struct Request: Sendable { + var method: String + var path: String + var headers: [String: String] + var body: Data + } + + struct Reply: Sendable { + var status: Int + var headers: [String: String] = [:] + var body: Data + } + + private let listener: NWListener + private let queue = DispatchQueue(label: "loopback-placement-server") + private let respond: @Sendable (Request) -> Reply + private let log = Mutex<[Request]>([]) + + var requests: [Request] { log.withLock { $0 } } + var base: URL { URL(string: "http://127.0.0.1:\(listener.port?.rawValue ?? 0)")! } + + init(respond: @escaping @Sendable (Request) -> Reply) throws { + self.respond = respond + let parameters = NWParameters.tcp + parameters.acceptLocalOnly = true + parameters.requiredLocalEndpoint = .hostPort(host: "127.0.0.1", port: .any) + listener = try NWListener(using: parameters) + let ready = DispatchSemaphore(value: 0) + listener.stateUpdateHandler = { if case .ready = $0 { ready.signal() } } + listener.newConnectionHandler = { [weak self, queue] connection in + guard self != nil else { return connection.cancel() } + connection.start(queue: queue) + Self.receive(connection, buffer: Data()) { [weak self] request in + guard let self else { return connection.cancel() } + self.answer(request, on: connection) + } + } + listener.start(queue: queue) + guard ready.wait(timeout: .now() + 5) == .success, listener.port != nil else { + listener.cancel() + throw URLError(.cannotConnectToHost) + } + } + + deinit { listener.cancel() } + + private func answer(_ request: Request, on connection: NWConnection) { + log.withLock { $0.append(request) } + let reply = respond(request) + var head = "HTTP/1.1 \(reply.status) Reply\r\nContent-Type: application/json\r\nContent-Length: \(reply.body.count)\r\nConnection: close\r\n" + for (name, value) in reply.headers { head += "\(name): \(value)\r\n" } + head += "\r\n" + connection.send(content: Data(head.utf8) + reply.body, completion: .contentProcessed { _ in connection.cancel() }) + } + + private static func receive(_ connection: NWConnection, buffer: Data, handler: @escaping @Sendable (Request) -> Void) { + connection.receive(minimumIncompleteLength: 1, maximumLength: 1 << 20) { data, _, done, error in + var buffer = buffer + if let data { buffer.append(data) } + if let request = parse(buffer) { return handler(request) } + if done || error != nil { return connection.cancel() } + receive(connection, buffer: buffer, handler: handler) + } + } + + private static func parse(_ data: Data) -> Request? { + guard let end = data.range(of: Data("\r\n\r\n".utf8)) else { return nil } + let head = String(decoding: data[..= 2 else { return nil } + var headers: [String: String] = [:] + for field in head.dropFirst() { + guard let colon = field.firstIndex(of: ":") else { continue } + headers[field[..= length else { return nil } + return Request(method: String(line[0]), path: String(line[1]), headers: headers, body: Data(body.prefix(length))) + } +} diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/PlacementHTTPClientTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/PlacementHTTPClientTests.swift new file mode 100644 index 000000000..f43282734 --- /dev/null +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/PlacementHTTPClientTests.swift @@ -0,0 +1,120 @@ +import Foundation +import HouseScanKit +import Synchronization +import Testing + +/// The placement client against a real HTTP server on 127.0.0.1, through `URLSession`: what it sends, +/// which replies it returns, and what each failure means for the homeowner +/// (`UploadFailureKind`). The answers are a real server answer to the synthetic replay, with its +/// input hash set per test. +@Suite struct PlacementHTTPClientTests { + static let scene = Data(#"{"note":"the scene this phone sent"}"#.utf8) + static let fixtureHash = "2cb165cd4d4e9340496d90a589678ccbbe8ae6720d9d73bcae427a6b22075cb7" + + static func answer(declaring hash: String?) throws -> Data { + let url = URL(fileURLWithPath: #filePath).deletingLastPathComponent().appendingPathComponent("Schemas/server-answer-synthetic-wall.json") + let text = String(decoding: try Data(contentsOf: url), as: UTF8.self) + let line = "\"input_sha256\": \"\(fixtureHash)\"," + #expect(text.contains(line)) + return Data(text.replacingOccurrences(of: line, with: hash.map { "\"input_sha256\": \"\($0)\"," } ?? "").utf8) + } + + static func client(_ server: LoopbackPlacementServer) -> PlacementHTTPClient { + let configuration = URLSessionConfiguration.ephemeral + configuration.timeoutIntervalForRequest = 10 + return PlacementHTTPClient(serverURL: server.base, session: URLSession(configuration: configuration)) + } + + /// Submits `scene` and returns the error it threw, failing the test if it returned an answer. + static func failure(of client: PlacementHTTPClient) async throws -> any Error { + do { + _ = try await client.submit(scene: scene) { _ in } + } catch { + return error + } + Issue.record("the client returned an answer") + throw CancellationError() + } + + /// An answer for the scene sent comes back byte for byte. The request is that scene, posted as + /// JSON to /v1/placements, and progress ends at 1. + @Test func anAnswerForTheSceneSentIsReturned() async throws { + let server = try LoopbackPlacementServer { request in + .init(status: 200, body: (try? Self.answer(declaring: PacketFiles.sha256(request.body))) ?? Data()) + } + let progress = Mutex<[Double]>([]) + let data = try await Self.client(server).submit(scene: Self.scene) { value in progress.withLock { $0.append(value) } } + + #expect(data == (try Self.answer(declaring: PacketFiles.sha256(Self.scene)))) + #expect(try PlacementResult.decode(data).decision == .manualReview) + let requests = server.requests + #expect(requests.count == 1) + #expect(requests.first?.method == "POST") + #expect(requests.first?.path == "/v1/placements") + #expect(requests.first?.headers["content-type"] == "application/json") + #expect(requests.first?.body == Self.scene) + #expect(progress.withLock { $0.last } == 1) + } + + /// An answer that names another scene is never returned. It is an answer House Scan couldn't + /// use, which the homeowner may ask for again, and the client doesn't ask again on its own. + @Test func anAnswerForAnotherSceneIsNotReturned() async throws { + let other = PacketFiles.sha256(Data("another scene".utf8)) + let server = try LoopbackPlacementServer { _ in .init(status: 200, body: (try? Self.answer(declaring: other)) ?? Data()) } + let error = try await Self.failure(of: Self.client(server)) + + #expect(error as? ResultBinding.Refusal == .mismatch(submitted: PacketFiles.sha256(Self.scene), declared: other)) + #expect(UploadFailureKind.classify(error) == .unreadableAnswer) + #expect(UploadFailureKind.classify(error).retryable) + #expect(server.requests.count == 1) + } + + /// A 2xx without a readable input hash, or one that isn't JSON at all, is also unusable. + @Test(arguments: ["no hash", "not JSON"]) + func anAnswerWithoutItsSceneIsNotReturned(_ kind: String) async throws { + let body = kind == "no hash" ? try Self.answer(declaring: nil) : Data("maintenance".utf8) + let server = try LoopbackPlacementServer { _ in .init(status: 200, body: body) } + let error = try await Self.failure(of: Self.client(server)) + + #expect(error as? ResultBinding.Refusal == .noInputHash) + #expect(UploadFailureKind.classify(error) == .unreadableAnswer) + #expect(server.requests.count == 1) + } + + /// A status outside 2xx is the server's own word: a refusal sends the homeowner to the review, + /// a server error or a busy server can be tried again. The body reaches the log, cut at 300 bytes. + @Test(arguments: [ + (422, nil, UploadFailureKind.refused), + (503, nil, UploadFailureKind.serverError), + (429, "30", UploadFailureKind.busy(retryAfter: 30)), + ] as [(Int, String?, UploadFailureKind)]) + func aStatusOutside2xxIsClassifiedByThatStatus(status: Int, retryAfter: String?, kind: UploadFailureKind) async throws { + let body = Data(String(repeating: "x", count: 1000).utf8) + let server = try LoopbackPlacementServer { _ in + .init(status: status, headers: retryAfter.map { ["Retry-After": $0] } ?? [:], body: body) + } + let error = try await Self.failure(of: Self.client(server)) + + guard case .server(let sentStatus, let sentBody, let sentRetryAfter)? = error as? PlacementHTTPError else { + Issue.record("expected a server error, got \(error)") + return + } + #expect(sentStatus == status) + #expect(sentBody.utf8.count == 300) + #expect(sentRetryAfter == retryAfter) + #expect(UploadFailureKind.classify(error) == kind) + #expect(server.requests.count == 1) + } + + /// Nothing listening: no server answered, so the homeowner can try again. + @Test func noServerIsUnreachable() async throws { + let configuration = URLSessionConfiguration.ephemeral + configuration.timeoutIntervalForRequest = 10 + // Port 9 (discard) has no listener on a test machine. + let client = PlacementHTTPClient(serverURL: URL(string: "http://127.0.0.1:9")!, session: URLSession(configuration: configuration)) + let error = try await Self.failure(of: client) + + #expect(error is URLError) + #expect(UploadFailureKind.classify(error) == .unreachable) + } +} diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/ResultBindingTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/ResultBindingTests.swift index 93b764da9..1e375902e 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/ResultBindingTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/ResultBindingTests.swift @@ -31,9 +31,9 @@ import Testing 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. + // Every refusal is an answer House Scan couldn't use: the homeowner may ask again. #expect(UploadFailureKind.classify(refusal) == .unreadableAnswer) - #expect(!UploadFailureKind.classify(refusal).retryable) + #expect(UploadFailureKind.classify(refusal).retryable) return refusal } diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/ResultCardActionsTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/ResultCardActionsTests.swift index 98d652a5d..9a1cd9a53 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/ResultCardActionsTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/ResultCardActionsTests.swift @@ -43,4 +43,42 @@ import Testing #expect(!ResultCardActions.offersView(capturable: false, sourceAvailable: true)) #expect(!ResultCardActions.offersView(capturable: false, sourceAvailable: false)) } + + /// A check line offers its view, and may say one more photo would settle it, only for an + /// unsure check no person has to judge, whose settling view was named and can be taken now + /// with the camera up. On the spot-unknown run a withdrawn "Clear space in front" request + /// (not capturable) still read "One more photo would settle this". Every combination. + @Test(arguments: [PlacementOutcome.pass, .fail, .unsure], [false, true]) + func aLineOffersItsViewOnlyWhenTheCameraCanTakeIt(outcome: PlacementOutcome, needsPerson: Bool) { + let capturable: [Bool?] = [nil, false, true] + for settling in capturable { + for sourceAvailable in [false, true] { + let offered = ResultCardActions.lineOffersView( + outcome: outcome, needsPerson: needsPerson, settlingViewCapturable: settling, + sourceAvailable: sourceAvailable) + let expected = outcome == .unsure && !needsPerson && settling == true && sourceAvailable + #expect(offered == expected, "settling view \(String(describing: settling)), camera up \(sourceAvailable)") + } + } + } + + /// The cases by name: the one line that offers a photo, and each reason another doesn't. + @Test func theLinesThatOfferAPhoto() { + let offers = { (outcome: PlacementOutcome, person: Bool, capturable: Bool?, camera: Bool) in + ResultCardActions.lineOffersView(outcome: outcome, needsPerson: person, settlingViewCapturable: capturable, + sourceAvailable: camera) + } + #expect(offers(.unsure, false, true, true)) + // A view the homeowner couldn't get to, or one the app can't plan. + #expect(!offers(.unsure, false, false, true)) + // No view named for it. + #expect(!offers(.unsure, false, nil, true)) + // The camera failed after the scan was sent. + #expect(!offers(.unsure, false, true, false)) + // A person judges it, whatever view is named. + #expect(!offers(.unsure, true, true, true)) + // Settled checks offer nothing. + #expect(!offers(.pass, false, true, true)) + #expect(!offers(.fail, false, true, true)) + } } diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/ResultMarkLayoutTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/ResultMarkLayoutTests.swift index 553f0f3c6..6550169ae 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/ResultMarkLayoutTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/ResultMarkLayoutTests.swift @@ -1,3 +1,4 @@ +import Foundation import HouseScanKit import Testing @@ -69,4 +70,65 @@ import Testing #expect(dashes.length == 0.13) #expect(ResultMarkLayout.dashes(along: 0, dash: 0.1, gap: 0.06).starts.isEmpty) } + + // MARK: Sweep zones + + private static let battery = ResultMarkLayout.BatterySize(width: 0.75, depth: 0.5) + + /// With no battery size (no spot and no nearest spot) there is no zone, however many runs + /// the sweep has: a run's starts are not a footprint, and the app has no size of its own. + @Test func noBatterySizeDrawsNoZone() { + let starts: [SIMD2] = [SIMD2(-0.3, -0.3), SIMD2(6.4, 10.2), SIMD2(1, 2.5)] + #expect(ResultMarkLayout.sweepZones(starts: starts, battery: nil).isEmpty) + #expect(ResultMarkLayout.sweepZones(starts: [], battery: nil).isEmpty) + } + + /// A run's starts are battery left edges, so its zone reaches one battery width past the + /// larger start and is one battery deep: the drawing a dimensioned spot or nearest spot got + /// before, value for value. + @Test func aSizedRunReachesOneBatteryPastItsLastStart() { + let zones = ResultMarkLayout.sweepZones(starts: [SIMD2(1, 2.5)], battery: Self.battery) + #expect(zones == [ResultMarkLayout.SweepZone(span: 1...3.25, depth: 0.5)]) + } + + /// Either order of a run's two starts gives the same zone, and a run of one start is one + /// battery wide. + @Test func aRunsStartsMayComeInEitherOrder() { + let zones = ResultMarkLayout.sweepZones(starts: [SIMD2(2.5, 1), SIMD2(-1, -1)], battery: Self.battery) + #expect(zones == [ + ResultMarkLayout.SweepZone(span: 1...3.25, depth: 0.5), + ResultMarkLayout.SweepZone(span: -1...(-0.25), depth: 0.5), + ]) + } + + /// One zone per run, in the sweep's order, so the caller can pair them by position. + @Test func zonesFollowTheRunsInOrder() { + let starts: [SIMD2] = [SIMD2(-2, -1), SIMD2(0, 0.5), SIMD2(3, 4)] + let zones = ResultMarkLayout.sweepZones(starts: starts, battery: Self.battery) + #expect(zones.map(\.span.lowerBound) == [-2, 0, 3]) + #expect(zones.map(\.span.upperBound) == [-0.25, 1.25, 4.75]) + #expect(zones.allSatisfy { $0.depth == 0.5 }) + } + + /// The UI tests' answer with neither a spot nor a nearest spot still has sweep runs, which + /// is the case the zones above must not be drawn for. The reject's nearest spot has a size. + @Test func theUIResultFilesCoverBothCases() throws { + let unsized = try PlacementResult.decode(Data(contentsOf: Self.uiResultFile("no-spot-no-nearest"))) + #expect(unsized.spot == nil) + #expect(unsized.nearestConsidered == nil) + #expect(!unsized.sweep.isEmpty) + let sized = try PlacementResult.decode(Data(contentsOf: Self.uiResultFile("reject-nearest"))) + #expect(sized.spot == nil) + #expect(sized.nearestConsidered != nil) + #expect(!sized.sweep.isEmpty) + } + + private static func uiResultFile(_ name: String, file: String = #filePath) -> URL { + URL(fileURLWithPath: file) + .deletingLastPathComponent() // HouseScanKitTests + .deletingLastPathComponent() // Tests + .deletingLastPathComponent() // HouseScanKit + .deletingLastPathComponent() // ios + .appendingPathComponent("HouseScanUITests/Fixtures/results/\(name).json") + } } diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/ResultReadingTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/ResultReadingTests.swift index a3807f77e..3f525a0ef 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/ResultReadingTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/ResultReadingTests.swift @@ -210,6 +210,7 @@ import simd @Test(arguments: [ ("pass", ResultReading.Answer.fits), ("reject-nearest", .notHere), ("unsure-view", .oneMoreLook), ("no-clean-spot", .installer), + ("no-spot-no-nearest", .notHere), ("review-band", .installer), ("view-not-offered", .oneMoreLook), ]) func uiResultFilesReadAsNamed(file: String, answer: ResultReading.Answer) throws { let url = URL(fileURLWithPath: #filePath) diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/ReviewBandTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/ReviewBandTests.swift new file mode 100644 index 000000000..09d74ecb8 --- /dev/null +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/ReviewBandTests.swift @@ -0,0 +1,142 @@ +import Foundation +import HouseScanKit +import Testing + +/// A check's review line (`review_threshold_ft`): decoded only when the answer sends one, and +/// read by `ResultReading.reviewBandApplies` only when it explains an UNSURE outcome. +@Suite struct ReviewBandTests { + // MARK: Decoding + + /// The UI tests' review-band answer: the cable run measures 16 ft (± 6 in), past the 15 ft + /// review line and under the 20 ft maximum, as server/tests/test_review_regressions.py's + /// start at 16 ft gives it. + @Test func theReviewLineSurvivesDecoding() throws { + let result = try PlacementResult.decode(Data(contentsOf: Self.uiResultFile("review-band"))) + let route = try #require(result.checks.first { $0.id == "route_length" }) + #expect(route.outcome == .unsure) + #expect(route.unsureCause == .ruleRequiresReview) + #expect(route.measuredFt == 16) + #expect(route.plusMinusFt == 0.5) + #expect(route.thresholdFt == 20) + #expect(route.reviewThresholdFt == 15) + #expect(route.comparison == .atMost) + // The other checks have no review line, and stay without one. + #expect(result.checks.filter { $0.id != "route_length" }.allSatisfy { $0.reviewThresholdFt == nil }) + } + + /// The hosted server's own answer carries the line on its cable run check. + @Test func aRealServerAnswerKeepsItsReviewLine() throws { + let url = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + .appendingPathComponent("Schemas/server-answer-synthetic-wall.json") + let result = try PlacementResult.decode(Data(contentsOf: url)) + #expect(result.checks.first { $0.id == "route_length" }?.reviewThresholdFt == 15) + } + + /// Encoding writes the line back only where the answer had one: an absent line stays absent + /// rather than becoming null, which the schema doesn't allow. + @Test func encodingKeepsAnAbsentLineAbsent() throws { + let result = try PlacementResult.decode(Data(contentsOf: Self.uiResultFile("review-band"))) + let encoded = try JSONEncoder().encode(result) + #expect(try SceneSchemas.result().validate(encoded) == []) + #expect(try PlacementResult.decode(encoded) == result) + let checks = try #require( + (try JSONSerialization.jsonObject(with: encoded) as? [String: Any])?["checks"] as? [[String: Any]]) + for check in checks { + let id = try #require(check["id"] as? String) + #expect((check["review_threshold_ft"] != nil) == (id == "route_length"), "\(id)") + } + } + + // MARK: When the line explains the outcome + + struct Case: CustomTestStringConvertible, Sendable { + var name: String + var outcome: PlacementOutcome = .unsure + var measured: Double? = 16 + var plusMinus: Double? = 0.5 + var threshold: Double? = 20 + var review: Double? = 15 + var comparison: PlacementComparison? = .atMost + var applies: Bool + + var testDescription: String { name } + } + + @Test(arguments: [ + // The solver's review band: past the confident reach, under the maximum. + Case(name: "16 ft between 15 ft and 20 ft", applies: true), + // Within its error of the review line, which the solver calls a margin: still past it. + Case(name: "14 ft 10 in, error reaches 15 ft", measured: 14.8, applies: true), + Case(name: "exactly reaching the line", measured: 14.5, applies: true), + // Within its error of the maximum: still unsure, and the line still applies. + Case(name: "20 ft 4 in, within error of the maximum", measured: 20.3, applies: true), + // The server's outcome decides; the app never reads the band into a pass or a fail. + Case(name: "a pass", outcome: .pass, applies: false), + Case(name: "a fail", outcome: .fail, measured: 24, applies: false), + // Clears the line by the schema's test: unsure for some other reason. + Case(name: "clears the line", measured: 14, applies: false), + Case(name: "no error, under the line", measured: 14.9, plusMinus: nil, applies: false), + Case(name: "a negative error counts as none", measured: 14.9, plusMinus: -1, applies: false), + // Anything missing: nothing to explain. + Case(name: "no review line", review: nil, applies: false), + Case(name: "no limit", threshold: nil, applies: false), + Case(name: "no measurement", measured: nil, applies: false), + Case(name: "no direction", comparison: nil, applies: false), + // A line on the failing side of the limit, or on it, leaves no band. + Case(name: "review line past the maximum", review: 25, applies: false), + Case(name: "review line on the maximum", review: 20, applies: false), + // A minimum mirrors it: the review line sits above the limit. + Case(name: "at least: between 3 ft and 4 ft", measured: 3.5, plusMinus: 0.2, threshold: 3, review: 4, + comparison: .atLeast, applies: true), + Case(name: "at least: clears the line", measured: 4.5, plusMinus: 0.2, threshold: 3, review: 4, + comparison: .atLeast, applies: false), + Case(name: "at least: review line under the minimum", measured: 3.5, plusMinus: 0.2, threshold: 3, review: 2, + comparison: .atLeast, applies: false), + ]) + func reviewBandApplies(_ c: Case) { + #expect(ResultReading.reviewBandApplies( + outcome: c.outcome, measured: c.measured, plusMinus: c.plusMinus, threshold: c.threshold, + reviewThreshold: c.review, comparison: c.comparison) == c.applies) + } + + /// The UI tests' answers: the line explains the review band's cable run, and not the passing + /// cable run in no-clean-spot, which carries the same line. + @Test func theUIResultFilesReadAsExpected() throws { + func applies(_ file: String) throws -> Bool { + let result = try PlacementResult.decode(Data(contentsOf: Self.uiResultFile(file))) + let route = try #require(result.checks.first { $0.id == "route_length" }) + #expect(route.reviewThresholdFt == 15, "\(file)") + return route.reviewBandApplies + } + #expect(try applies("review-band")) + #expect(try !applies("no-clean-spot")) + } + + /// A decoded check decides in the server's feet. 14.5 ft ± 6 in reaches the 15 ft line + /// exactly; narrowed to Float meters first, the sum fell just under the line and the card + /// left the review line out. + @Test func aCheckDecidesInTheServersFeet() throws { + var object = try #require( + try JSONSerialization.jsonObject(with: Data(contentsOf: Self.uiResultFile("review-band"))) as? [String: Any]) + var checks = try #require(object["checks"] as? [[String: Any]]) + let index = try #require(checks.firstIndex { $0["id"] as? String == "route_length" }) + checks[index]["measured_ft"] = 14.5 + object["checks"] = checks + let data = try JSONSerialization.data(withJSONObject: object) + #expect(try SceneSchemas.result().validate(data) == []) + let route = try #require(try PlacementResult.decode(data).checks.first { $0.id == "route_length" }) + #expect(route.reviewBandApplies) + // The Float meters the app shows would not have reached it. + let meters = { (feet: Double) in Double(Float(feet * 0.3048)) } + #expect(meters(14.5) + meters(0.5) < meters(15)) + } + + private static func uiResultFile(_ name: String, file: String = #filePath) -> URL { + URL(fileURLWithPath: file) + .deletingLastPathComponent() // HouseScanKitTests + .deletingLastPathComponent() // Tests + .deletingLastPathComponent() // HouseScanKit + .deletingLastPathComponent() // ios + .appendingPathComponent("HouseScanUITests/Fixtures/results/\(name).json") + } +} diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/SavedScanCatalogTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/SavedScanCatalogTests.swift new file mode 100644 index 000000000..27fcef093 --- /dev/null +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/SavedScanCatalogTests.swift @@ -0,0 +1,322 @@ +import Foundation +@testable import HouseScanKit +import Testing + +/// Saved scans: the completed scans the cleanup keeps, read from synthetic scan folders, and the +/// copies handed to the share sheet. Every archive here is made by `ZipWriter`, never a capture. +@Suite struct SavedScanCatalogTests { + let root: URL + let files = FileManager.default + + init() throws { + root = files.temporaryDirectory.appending(path: "saved-scans-\(UUID().uuidString)") + try files.createDirectory(at: root, withIntermediateDirectories: true) + } + + /// A packet as the app zips it: manifest.json first, then a photo. + static let packet: Data = { + // A fixed photo payload so every test archive has the same bytes. + let photo = Data((0..<4096).map { UInt8($0 % 251) }) + return try! ZipWriter.archive([ + ZipEntry(name: "manifest.json", data: Data(#"{"version":"1.1"}"#.utf8)), + ZipEntry(name: "photos/p00001.jpg", data: photo), + ]) + }() + + /// A scan folder with a keyframe, and optionally a bundle saved `minutesAgo` and a stamp. + @discardableResult + func scan(_ name: String, bundle: Data? = packet, minutesAgo: Double = 10, stamp: String? = nil) throws -> URL { + let folder = root.appending(path: name) + try files.createDirectory(at: folder, withIntermediateDirectories: true) + try Data("photo".utf8).write(to: folder.appending(path: "k00001.jpg")) + if let bundle { + let archive = folder.appending(path: ScanFolderCleanup.bundleName) + try bundle.write(to: archive) + try files.setAttributes([.modificationDate: Date(timeIntervalSinceNow: -minutesAgo * 60)], ofItemAtPath: archive.path) + } + if let stamp { + try Data(stamp.utf8).write(to: folder.appending(path: ScanStamp.fileName)) + } + return folder + } + + func cleanUp() { + try? files.removeItem(at: root) + } + + // MARK: Listing + + /// The list is the cleanup's kept set, newest first, with each bundle's saved time and size. + /// A folder never packaged is not a scan; an older completed one the cleanup will delete is + /// not offered. + @Test func listsTheCompletedScansTheCleanupKeeps() throws { + defer { cleanUp() } + try scan("unfinished", bundle: nil) + try scan("oldest", minutesAgo: 90) + try scan("older", minutesAgo: 30) + try scan("newest", minutesAgo: 5) + + let scans = SavedScanCatalog(root: root).scans() + #expect(scans.map(\.id) == ["newest", "older"]) + #expect(scans.map(\.id) == ScanFolderCleanup(root: root, keeping: "").keptCompleted.map(\.lastPathComponent)) + let newest = try #require(scans.first) + #expect(newest.byteCount == Int64(Self.packet.count)) + #expect(newest.archive == root.appending(path: "newest/\(ScanFolderCleanup.bundleName)")) + let saved = try #require(newest.savedAt) + #expect(abs(saved.timeIntervalSinceNow + 5 * 60) < 5) + } + + /// A relaunch makes a new store: its cleanup runs, and a fresh catalog over the same folders + /// still lists the completed scans. The new scan's empty folder is not one of them. + @Test func aCompletedScanIsListedAfterARelaunch() throws { + defer { cleanUp() } + try scan("finished", minutesAgo: 20) + try scan("quitMidWalk", bundle: nil) + try scan("relaunch", bundle: nil) + + let failed = ScanFolderCleanup(root: root, keeping: "relaunch").run() + #expect(failed.isEmpty) + #expect(SavedScanCatalog(root: root).scans().map(\.id) == ["finished"]) + } + + /// An empty or missing root lists nothing rather than failing. + @Test func noFoldersListNothing() { + defer { cleanUp() } + #expect(SavedScanCatalog(root: root).scans().isEmpty) + #expect(SavedScanCatalog(root: root.appending(path: "absent")).scans().isEmpty) + } + + /// A bundle cut short, as a quit during its write leaves it, is not a completed scan, wherever + /// it was cut. Nor is a zip that isn't a packet, or an empty file. + @Test(arguments: [1, 21, 22, 23, 200, SavedScanCatalogTests.packet.count / 2, SavedScanCatalogTests.packet.count - 31]) + func aPartialBundleIsLeftOut(bytesMissing: Int) throws { + defer { cleanUp() } + try scan("partial", bundle: Self.packet.dropLast(bytesMissing), minutesAgo: 1) + try scan("whole", minutesAgo: 30) + #expect(SavedScanCatalog(root: root).scans().map(\.id) == ["whole"]) + } + + @Test func aBundleThatIsNotAPacketIsLeftOut() throws { + defer { cleanUp() } + let other = try ZipWriter.archive([ZipEntry(name: "notes.txt", data: Data("hello".utf8))]) + try scan("other", bundle: other) + try scan("empty", bundle: Data()) + #expect(SavedScanCatalog(root: root).scans().isEmpty) + let otherURL = root.appending(path: "other/\(ScanFolderCleanup.bundleName)") + #expect(throws: PacketArchiveCheck.Failure.notAPacket) { try PacketArchiveCheck.verify(otherURL) } + let emptyURL = root.appending(path: "empty/\(ScanFolderCleanup.bundleName)") + #expect(throws: PacketArchiveCheck.Failure.incomplete) { try PacketArchiveCheck.verify(emptyURL) } + #expect(throws: PacketArchiveCheck.Failure.unreadable) { try PacketArchiveCheck.verify(root.appending(path: "none.zip")) } + } + + /// Bytes after the end record are a partial rewrite or something else, not a whole packet. + @Test func bytesAfterTheEndRecordAreRefused() throws { + defer { cleanUp() } + try scan("padded", bundle: Self.packet + Data(repeating: 0, count: 8)) + #expect(SavedScanCatalog(root: root).scans().isEmpty) + } + + /// A central directory whose first record doesn't point at the manifest at offset 0 doesn't + /// belong with the entries before it. + @Test func aDirectoryThatDisagreesWithTheFirstEntryIsRefused() throws { + defer { cleanUp() } + var bytes = Self.packet + let end = bytes.count - 22 + let directoryOffset = bytes[(end + 16)..<(end + 20)].enumerated().reduce(0) { $0 | Int($1.element) << (8 * $1.offset) } + bytes[directoryOffset + 42] = 1 + try scan("forged", bundle: bytes) + #expect(SavedScanCatalog(root: root).scans().isEmpty) + } + + /// A link is never listed, whether the bundle or the scan folder is the link, and a linked + /// stamp says nothing: a copy of a link would not be the bundle's bytes. + @Test func linksAreNotScans() throws { + defer { cleanUp() } + let real = try scan("real", minutesAgo: 30) + let elsewhere = root.appending(path: "elsewhere.zip") + try Self.packet.write(to: elsewhere) + let linked = try scan("linkedBundle", bundle: nil) + try files.createSymbolicLink(at: linked.appending(path: ScanFolderCleanup.bundleName), withDestinationURL: elsewhere) + try files.createSymbolicLink(at: root.appending(path: "linkedFolder"), withDestinationURL: real) + let stamp = root.appending(path: "stamp.json") + try Data(#"{"practice": true}"#.utf8).write(to: stamp) + try files.createSymbolicLink(at: real.appending(path: ScanStamp.fileName), withDestinationURL: stamp) + + let listed = SavedScanCatalog(root: root).scans() + #expect(listed.map(\.id) == ["real"]) + #expect(listed.first?.practice == nil) + let link = linked.appending(path: ScanFolderCleanup.bundleName) + #expect(throws: PacketArchiveCheck.Failure.notAFile) { try PacketArchiveCheck.verify(link) } + } + + // MARK: Practice + + /// The stamp's practice flag is passed through only when it is a JSON boolean. A missing, + /// corrupt or oddly typed stamp leaves it unknown, and the scan is still listed. + @Test(arguments: [ + (#"{"practice": true, "app": {}}"#, true as Bool?), + (#"{"practice": false}"#, false), + (#"{"app": {}}"#, nil), + (#"{"practice": 1}"#, nil), + (#"{"practice": "true"}"#, nil), + ("{not json", nil), + ("", nil), + ]) + func practiceComesOnlyFromAReadableStamp(stamp: String, practice: Bool?) throws { + defer { cleanUp() } + try scan("one", stamp: stamp) + let listed = try #require(SavedScanCatalog(root: root).scans().first) + #expect(listed.practice == practice) + } + + @Test func noStampAndAnOversizedStampSayNothing() throws { + defer { cleanUp() } + try scan("bare", minutesAgo: 5) + let padding = String(repeating: " ", count: SavedScanCatalog.stampByteLimit) + try scan("huge", minutesAgo: 10, stamp: #"{"practice": true}"# + padding) + let scans = SavedScanCatalog(root: root).scans() + #expect(scans.map(\.id) == ["bare", "huge"]) + #expect(scans.allSatisfy { $0.practice == nil }) + } + + // MARK: Sharing + + /// The share sheet gets a copy outside the scan folder with the bundle's exact bytes and a + /// name a recipient can tell apart. Removing it deletes only the copy. + @Test func stagingCopiesTheBundleExactly() throws { + defer { cleanUp() } + let folder = try scan("one", stamp: #"{"practice": false}"#) + let staging = SavedScanStaging(root: root.appending(path: "share")) + let listed = try #require(SavedScanCatalog(root: root).scans().first) + + let copy = try staging.stage(listed) + #expect(try Data(contentsOf: copy) == Self.packet) + #expect(copy.lastPathComponent == listed.shareFileName()) + #expect(!copy.path.hasPrefix(folder.path)) + + staging.remove(copy) + #expect(!files.fileExists(atPath: copy.deletingLastPathComponent().path)) + #expect(try Data(contentsOf: listed.archive) == Self.packet) + } + + /// A scan the cleanup deleted after the list was made is reported missing, and leaves no copy. + @Test func aBundleGoneSinceListingIsMissing() throws { + defer { cleanUp() } + let folder = try scan("gone") + let staging = SavedScanStaging(root: root.appending(path: "share")) + let listed = try #require(SavedScanCatalog(root: root).scans().first) + try files.removeItem(at: folder) + + #expect(throws: SavedScanShareError.missing) { try staging.stage(listed) } + #expect((try? files.contentsOfDirectory(atPath: staging.root.path))?.isEmpty ?? true) + } + + /// A bundle cut short after the list was made is reported damaged, and leaves no copy. + @Test func aBundleCutShortSinceListingIsDamaged() throws { + defer { cleanUp() } + try scan("cut") + let staging = SavedScanStaging(root: root.appending(path: "share")) + let listed = try #require(SavedScanCatalog(root: root).scans().first) + try Self.packet.prefix(100).write(to: listed.archive) + + #expect(throws: SavedScanShareError.damaged) { try staging.stage(listed) } + #expect((try? files.contentsOfDirectory(atPath: staging.root.path))?.isEmpty ?? true) + } + + /// `remove` deletes only folders it made, and removing copies never touches a scan. + @Test func removalStaysInsideTheStagingFolder() throws { + defer { cleanUp() } + let folder = try scan("keep") + let staging = SavedScanStaging(root: root.appending(path: "share")) + let listed = try #require(SavedScanCatalog(root: root).scans().first) + + staging.remove(listed.archive) + #expect(files.fileExists(atPath: listed.archive.path)) + + let first = try staging.stage(listed) + let second = try staging.stage(listed) + #expect(first != second) + staging.remove(first) + #expect(!files.fileExists(atPath: first.path) && files.fileExists(atPath: second.path)) + SavedScanStaging(root: staging.root).removeOtherSessions() + #expect(!files.fileExists(atPath: second.path)) + #expect(files.fileExists(atPath: folder.appending(path: ScanFolderCleanup.bundleName).path)) + } + + /// At launch, the copies earlier runs left go and this run's stay, whatever the clock says. + @Test func launchRemovesOnlyOtherRunsCopies() throws { + defer { cleanUp() } + try scan("one") + let shares = root.appending(path: "share") + let earlier = SavedScanStaging(root: shares, session: "earlier") + let current = SavedScanStaging(root: shares, session: "current") + let listed = try #require(SavedScanCatalog(root: root).scans().first) + let left = try earlier.stage(listed) + let mine = try current.stage(listed) + // A clock set back between runs: the earlier copy looks newer than this run's. + try files.setAttributes([.creationDate: Date(timeIntervalSinceNow: 3600)], ofItemAtPath: left.deletingLastPathComponent().path) + + current.removeOtherSessions() + #expect(!files.fileExists(atPath: left.path)) + #expect(files.fileExists(atPath: mine.path)) + #expect(files.fileExists(atPath: listed.archive.path)) + // A run removes only its own session's copies one by one. + current.remove(left) + earlier.remove(mine) + #expect(files.fileExists(atPath: mine.path)) + SavedScanStaging(root: root.appending(path: "absent")).removeOtherSessions() + } + + /// Two Saved scans sheets in one app run (two windows, if the app ever allows them) share a + /// session: opening one, which clears other runs' copies, never deletes the copy the other + /// is sharing, and each removes only the copy it made. + @Test func sheetsInOneRunNeverDeleteEachOthersCopies() throws { + defer { cleanUp() } + try scan("one") + let shares = root.appending(path: "share") + let first = SavedScanStaging(root: shares, session: "run") + let second = SavedScanStaging(root: shares, session: "run") + let listed = try #require(SavedScanCatalog(root: root).scans().first) + let sharing = try first.stage(listed) + + second.removeOtherSessions() + let other = try second.stage(listed) + second.remove(other) + #expect(try Data(contentsOf: sharing) == Self.packet) + #expect(!files.fileExists(atPath: other.path)) + } + + /// A share outlives a new scan: the new store's cleanup deletes the scan's own folder, and + /// the copy the share sheet is reading, in its own folder, keeps the bundle's bytes. + @Test func aCopyBeingSharedSurvivesANewScansCleanup() throws { + defer { cleanUp() } + try scan("old", minutesAgo: 90) + try scan("newer", minutesAgo: 30) + try scan("newest", minutesAgo: 5) + let staging = SavedScanStaging(root: files.temporaryDirectory.appending(path: "share-\(UUID().uuidString)")) + defer { try? files.removeItem(at: staging.root) } + let older = try #require(SavedScanCatalog(root: root).scans().last) + let copy = try staging.stage(older) + + // Two newer completed scans push it out: a new scan's store deletes its folder. + try scan("evenNewer", minutesAgo: 1) + let cleanup = ScanFolderCleanup(root: root, keeping: "fresh") + #expect(cleanup.obsolete.map(\.lastPathComponent).contains(older.id)) + #expect(cleanup.run().isEmpty) + #expect(!files.fileExists(atPath: older.archive.path)) + #expect(try Data(contentsOf: copy) == Self.packet) + } + + // MARK: Names + + @Test func theShareNameSaysWhenAndWhetherPractice() { + let archive = URL(fileURLWithPath: "/x/scan.zip") + let saved = Date(timeIntervalSince1970: 1_790_000_000) // 2026-09-21 14:13:20 UTC + let utc = TimeZone(identifier: "UTC")! + #expect(SavedScan(id: "a", archive: archive, savedAt: saved, byteCount: 1, practice: false).shareFileName(timeZone: utc) + == "House Scan 2026-09-21 14.13.zip") + #expect(SavedScan(id: "a", archive: archive, savedAt: saved, byteCount: 1, practice: true).shareFileName(timeZone: utc) + == "House Scan practice 2026-09-21 14.13.zip") + #expect(SavedScan(id: "a", archive: archive, savedAt: nil, byteCount: 1, practice: nil).shareFileName() == "House Scan.zip") + } +} diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/SpotConfirmationTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/SpotConfirmationTests.swift index de256cb7e..9acb36388 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/SpotConfirmationTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/SpotConfirmationTests.swift @@ -115,6 +115,137 @@ import Testing #expect(checks.records.count == 2) } + // MARK: I can't check this area + + /// Three answers, three meanings. Only "It's clear" keeps the claims over the area and closes + /// the check's guidance entry as met; "Something's there" and "I can't check this area" both + /// withdraw them and close it as the packet's generic skipped, yet stay different answers. + @Test func eachAnswerKeepsItsOwnMeaning() { + #expect(SpotConfirmationAnswer.allCases == [.clear, .somethingThere, .cannotCheck]) + #expect(SpotConfirmationAnswer.allCases.map(\.keepsClaims) == [true, false, false]) + #expect(SpotConfirmationAnswer.allCases.map(\.guidanceOutcome) == [.met, .skipped, .skipped]) + #expect(Set(SpotConfirmationAnswer.allCases.map(\.rawValue)).count == 3) + } + + /// "I can't check this area" is recorded as given and settles the same area, or a smaller one, + /// as itself: a returned spot shows the result saying the area went unchecked, never that + /// something was there or that it was clear, and the homeowner isn't asked again. + @Test func cannotCheckIsRecordedAndSettlesTheSameAreaAsItself() throws { + var checks = SpotConfirmations() + checks.record(Self.confirmation(Self.area, .cannotCheck)) + #expect(checks.records.map(\.answer) == [.cannotCheck]) + + var same = Self.area + same.spot = (Self.spot.lowerBound + 0.002)...(Self.spot.upperBound - 0.002) + var smaller = Self.area + smaller.span = Self.spot + for area in [Self.area, same, smaller] { + let settled = try #require(checks.settling(area)) + #expect(settled.answer == .cannotCheck) + #expect(settled == Self.confirmation(Self.area, .cannotCheck)) + } + } + + /// A spot that moved, or an area that grew, isn't settled by "I can't check this area" about + /// the old one: the homeowner is asked about the new area. An answer about it doesn't change + /// what settles the old area. + @Test func cannotCheckDoesNotSettleAMovedOrLargerArea() { + var checks = SpotConfirmations() + checks.record(Self.confirmation(Self.area, .cannotCheck)) + var moved = Self.area + moved.spot = 1.2...2.0 + var wider = Self.area + wider.span = 0.9...3.0 + var deeper = Self.area + deeper.depth = 0.6 + var outward = Self.area + outward.spotOut = 0.05...0.35 + for area in [moved, wider, deeper, outward] { + #expect(checks.settling(area) == nil) + } + checks.record(Self.confirmation(wider, .clear)) + #expect(checks.settling(wider)?.answer == .clear) + // The wider area holds the old one, and its answer is the latest about it. + #expect(checks.settling(Self.area)?.answer == .clear) + #expect(checks.settling(moved) == nil) + } + + /// An area left unchecked stays a fact of the scan, whatever is answered later: its claims + /// stay withdrawn, so a later answer about another area, or about a larger one holding it, + /// doesn't make it checked. Only "I can't check this area" leaves an area unchecked, and a new + /// record (the engine's reset, `ScanEngine.resetSpotChecks`) holds none. + @Test func anAreaLeftUncheckedStaysUncheckedForTheScan() { + var moved = Self.area + moved.spot = 1.2...2.0 + var wider = Self.area + wider.span = 0.9...3.0 + var checks = SpotConfirmations() + #expect(!checks.leftUnchecked(besides: nil)) + checks.record(Self.confirmation(Self.area, .clear)) + checks.record(Self.confirmation(Self.area, .somethingThere)) + #expect(!checks.leftUnchecked(besides: nil)) + #expect(!checks.leftUnchecked(besides: moved)) + + checks.record(Self.confirmation(Self.area, .cannotCheck)) + #expect(checks.leftUnchecked(besides: nil)) + #expect(checks.leftUnchecked(besides: moved)) + checks.record(Self.confirmation(moved, .clear)) + checks.record(Self.confirmation(wider, .clear)) + #expect(checks.settling(Self.area)?.answer == .clear) + #expect(checks.leftUnchecked(besides: nil)) + #expect(checks.leftUnchecked(besides: moved)) + + checks = SpotConfirmations() + #expect(!checks.leftUnchecked(besides: nil)) + } + + /// The answer about the spot shown isn't another area: the result speaks of it on its own. + /// "I can't check this area" about one spot and then about a second leaves the first + /// unchecked beside the second (Greptile on #206), and with no spot shown both count. + @Test func anUncheckedAreaIsElsewhereUnlessItHoldsTheSpotShown() { + var moved = Self.area + moved.spot = 1.2...2.0 + var smaller = Self.area + smaller.span = Self.spot + var checks = SpotConfirmations() + checks.record(Self.confirmation(Self.area, .cannotCheck)) + #expect(!checks.leftUnchecked(besides: Self.area)) + #expect(!checks.leftUnchecked(besides: smaller)) + #expect(checks.leftUnchecked(besides: moved)) + + checks.record(Self.confirmation(moved, .cannotCheck)) + #expect(checks.leftUnchecked(besides: moved)) + #expect(checks.leftUnchecked(besides: Self.area)) + #expect(checks.leftUnchecked(besides: nil)) + } + + /// "I can't check this area" about an area, then "It's clear" about a larger area round the + /// same footprint, then the original area shown again. The clear answer settles it, so the + /// result has no notice about the spot, and the earlier unchecked answer must still be said: + /// its claims stay withdrawn whatever is answered later (review of 6cd1248d). + @Test func aLaterClearAboutALargerAreaLeavesTheEarlierUncheckedOneToSay() { + var wider = Self.area + wider.span = 0.9...3.0 + var checks = SpotConfirmations() + checks.record(Self.confirmation(Self.area, .cannotCheck)) + #expect(!checks.leftUnchecked(besides: Self.area)) + checks.record(Self.confirmation(wider, .clear)) + #expect(checks.settling(Self.area)?.answer == .clear) + #expect(checks.leftUnchecked(besides: Self.area)) + #expect(checks.leftUnchecked(besides: wider)) + } + + /// The latest answer about an area wins whichever two it is, so "I can't check this area" + /// after "Something's there" isn't read as an obstruction, and the other way round. + @Test func cannotCheckAndSomethingThereReplaceEachOther() { + var checks = SpotConfirmations() + checks.record(Self.confirmation(Self.area, .somethingThere)) + checks.record(Self.confirmation(Self.area, .cannotCheck)) + #expect(checks.settling(Self.area)?.answer == .cannotCheck) + checks.record(Self.confirmation(Self.area, .somethingThere)) + #expect(checks.settling(Self.area)?.answer == .somethingThere) + } + // MARK: Something's there /// A walk 2.6 m out from 1 m left of the meter to 3.5 m right, a kept frame every 0.5 m and diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/UploadFailureKindTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/UploadFailureKindTests.swift index 1c903e31f..d8998e27c 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/UploadFailureKindTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/UploadFailureKindTests.swift @@ -46,11 +46,24 @@ import Testing #expect(!UploadFailureKind.classify(httpStatus: 422).retryable) } - @Test func anythingElseIsAnUnreadableAnswer() { + /// An answer House Scan couldn't use is the answer's problem, not the scan's: the homeowner may + /// ask again, unlike a refusal. + @Test func anythingElseIsAnUnreadableAnswerThatMayBeTriedAgain() { #expect(UploadFailureKind.classify(httpStatus: 302) == .unreadableAnswer) #expect(UploadFailureKind.classify(httpStatus: 600) == .unreadableAnswer) struct DecodeFailure: Error {} #expect(UploadFailureKind.classify(DecodeFailure()) == .unreadableAnswer) - #expect(!UploadFailureKind.unreadableAnswer.retryable) + #expect(UploadFailureKind.unreadableAnswer.retryable) + #expect(!UploadFailureKind.refused.retryable) + } + + /// The placement client's errors: a status is classified as that status, and a response that + /// wasn't HTTP means no server answered. + @Test func placementClientErrors() { + #expect(UploadFailureKind.classify(PlacementHTTPError.server(status: 422, body: "")) == .refused) + #expect(UploadFailureKind.classify(PlacementHTTPError.server(status: 503, body: "")) == .serverError) + #expect(UploadFailureKind.classify(PlacementHTTPError.server(status: 429, body: "", retryAfter: "30")) == .busy(retryAfter: 30)) + #expect(UploadFailureKind.classify(PlacementHTTPError.server(status: 302, body: "")) == .unreadableAnswer) + #expect(UploadFailureKind.classify(PlacementHTTPError.notHTTP) == .unreachable) } } diff --git a/ios/HouseScanUITests/CloseUpSaveFailureUITests.swift b/ios/HouseScanUITests/CloseUpSaveFailureUITests.swift new file mode 100644 index 000000000..308af7c9f --- /dev/null +++ b/ios/HouseScanUITests/CloseUpSaveFailureUITests.swift @@ -0,0 +1,49 @@ +import XCTest + +/// B-20: a meter close-up the phone couldn't save read "Hold still", as if the homeowner's hand +/// had blurred it, and retaking with a steadier hand could never help. `-failCloseUpSave` makes +/// every close-up's save fail on a replay, through the real engine and `KeyframeStore.saveStill`. +final class CloseUpSaveFailureUITests: XCTestCase { + static let notSaved = "Your phone couldn't save that photo. Hold on the meter to try again." + + override func setUp() { + continueAfterFailure = false + } + + @MainActor + func testAPhotoThatDidNotSaveSaysSoAndCanBeSkipped() throws { + let app = XCUIApplication() + app.launchArguments = ["-replay", FullFlowUITests.fixture, "-sampleResult", "-failCloseUpSave"] + app.launch() + let any = app.descendants(matching: .any) + + XCTAssertTrue(any["screen.onboarding"].waitForExistence(timeout: 30)) + if app.buttons["action.onboardingSkip"].waitForExistence(timeout: 5) { app.buttons["action.onboardingSkip"].tap() } + XCTAssertTrue(app.buttons["action.finishOnboarding"].waitForExistence(timeout: 10)) + app.buttons["action.finishOnboarding"].tap() + XCTAssertTrue(app.buttons["action.markMeter"].waitForExistence(timeout: 20)) + app.buttons["action.markMeter"].tap() + XCTAssertTrue(any["screen.meterCloseUp"].waitForExistence(timeout: 15)) + + // Under `-failCloseUpSave` the reason stays up 10 s rather than 2 before the gate's own + // "Move closer" replaces it, so this query can't miss it. + let saidNotSaved = any.matching(identifier: "closeUp.problem").matching(NSPredicate(format: "label == %@", Self.notSaved)).firstMatch + XCTAssertTrue(saidNotSaved.waitForExistence(timeout: 60), "a close-up that didn't save never said so") + snap(app, "meterCloseUp-photoNotSaved") + + // The save failure counted as one failed attempt; the next one (another failed save, or + // `CloseUpGate.failAfter` without a photo) brings the way out. + let skip = app.buttons["action.skipCloseUp"] + XCTAssertTrue(skip.waitForExistence(timeout: 90), "no way past a close-up that can't be saved") + skip.tap() + XCTAssertTrue(any["screen.wallWalk"].waitForExistence(timeout: 15), "skipping the unsaved close-up didn't lead on to the walk") + } + + @MainActor + private func snap(_ app: XCUIApplication, _ name: String) { + let shot = XCTAttachment(screenshot: app.screenshot()) + shot.name = name + shot.lifetime = .keepAlways + add(shot) + } +} diff --git a/ios/HouseScanUITests/Fixtures/results/no-spot-no-nearest.json b/ios/HouseScanUITests/Fixtures/results/no-spot-no-nearest.json new file mode 100644 index 000000000..5ac9b8ba0 --- /dev/null +++ b/ios/HouseScanUITests/Fixtures/results/no-spot-no-nearest.json @@ -0,0 +1,84 @@ +{ + "schema_version": "1.0", + "decision": "reject", + "summary": "Test answer: no spot within reach works: every spot fails route length, wall backing. Demo rules: public values and placeholders (pool 10 ft, drive 5 ft), not Base's.", + "reasons": [ + { + "code": "all_spots_fail", + "checks": [ + "route_length", + "wall_backing" + ], + "message": "Every spot on the walls scanned fails at least one check by a clear margin." + } + ], + "policy": { + "id": "demo", + "version": "2026-09-26", + "auto_approve": true, + "sources": [ + "public" + ], + "rules_sha256": "5a1e0c7d00000000000000000000000000000000000000000000000000000000", + "notice": "Demo rules: public values and placeholders (pool 10 ft, drive 5 ft), not Base's." + }, + "spot": null, + "route": null, + "checks": [], + "nearest_considered": null, + "missing_evidence": [], + "ends": { + "left": { + "kind": "limit", + "s_ft": -1.0, + "point": [ + -1.0, + 0.0 + ] + }, + "right": { + "kind": "limit", + "s_ft": 36.0, + "point": [ + 36.0, + 0.0 + ] + } + }, + "sweep": [ + { + "wall_id": "wall", + "segment": 0, + "start_ft": [ + -1.0, + -1.0 + ], + "outcome": "fail", + "failing": [ + "wall_backing" + ], + "unsure": [] + }, + { + "wall_id": "wall", + "segment": 1, + "start_ft": [ + 21.0, + 33.416667 + ], + "outcome": "fail", + "failing": [ + "route_length" + ], + "unsure": [] + } + ], + "stats": { + "candidates": 0, + "pass": 0, + "unsure": 0, + "fail": 0, + "elapsed_ms": 0, + "input_sha256": "0000000000000000000000000000000000000000000000000000000000000000" + } +} \ No newline at end of file diff --git a/ios/HouseScanUITests/Fixtures/results/review-band.json b/ios/HouseScanUITests/Fixtures/results/review-band.json new file mode 100644 index 000000000..4bde361c2 --- /dev/null +++ b/ios/HouseScanUITests/Fixtures/results/review-band.json @@ -0,0 +1,242 @@ +{ + "schema_version": "1.0", + "decision": "manual_review", + "summary": "Test answer: a person needs to check the best spot, 15 ft 3 in right of the meter: cable run length. Demo rules: public values and placeholders (pool 10 ft, drive 5 ft), not Base's.", + "reasons": [ + { + "code": "unsure_checks", + "checks": [ + "route_length" + ], + "message": "The best spot has checks nobody can settle from this scan: cable run length." + } + ], + "policy": { + "id": "demo", + "version": "2026-09-26", + "auto_approve": true, + "sources": [ + "public" + ], + "rules_sha256": "5a1e0c7d00000000000000000000000000000000000000000000000000000000", + "notice": "Demo rules: public values and placeholders (pool 10 ft, drive 5 ft), not Base's." + }, + "spot": { + "outcome": "unsure", + "wall_id": "wall", + "segment": 0, + "span_ft": [ + 14.0, + 16.583333333333 + ], + "width_ft": 2.583333333333, + "depth_ft": 1.833333333333, + "height_ft": 3.291666666667, + "footprint": [ + [ + 14.0, + 0.0 + ], + [ + 16.583333333333, + 0.0 + ], + [ + 16.583333333333, + 1.833333333333 + ], + [ + 14.0, + 1.833333333333 + ] + ], + "center": [ + 15.2916666666665, + 0.9166666666665 + ], + "along": [ + 1.0, + 0.0 + ], + "outward": [ + 0.0, + 1.0 + ], + "meter_offset_ft": [ + 15.2916666666665, + 0.9166666666665 + ], + "route_length_ft": 16.0 + }, + "route": { + "outcome": "unsure", + "length_ft": 16.0, + "plus_minus_ft": 0.5, + "height_ft": 1.5, + "polyline": [ + [ + 0.0, + 0.0 + ], + [ + 14.0, + 0.0 + ] + ], + "detours": [], + "crossings": [] + }, + "checks": [ + { + "id": "wall_backing", + "label": "Flush against one straight wall", + "outcome": "pass", + "reason": "Test: seen flat behind the whole spot.", + "measured_ft": null, + "plus_minus_ft": null, + "threshold_ft": null, + "comparison": null, + "subject": null, + "rule": { + "key": "wall_backing_ft", + "source": "Made-up test value", + "placeholder": true + } + }, + { + "id": "gas_clearance", + "label": "Distance from gas equipment", + "outcome": "pass", + "reason": "Test: well clear.", + "measured_ft": 6.2, + "plus_minus_ft": 0.3, + "threshold_ft": 3.0, + "comparison": "at_least", + "subject": null, + "rule": { + "key": "gas_clearance_ft", + "source": "Made-up test value", + "placeholder": true + } + }, + { + "id": "opening_clearance", + "label": "Distance from doors and windows", + "outcome": "pass", + "reason": "Test: clear of the window.", + "measured_ft": 3.9, + "plus_minus_ft": 0.3, + "threshold_ft": 3.0, + "comparison": "at_least", + "subject": null, + "rule": { + "key": "opening_clearance_ft", + "source": "Made-up test value", + "placeholder": true + } + }, + { + "id": "meter_working_space", + "label": "Clear of the meter's working space", + "outcome": "pass", + "reason": "Test: clear of the working space.", + "measured_ft": 1.8, + "plus_minus_ft": 0.3, + "threshold_ft": 0.0, + "comparison": "at_least", + "subject": null, + "rule": { + "key": "meter_working_space_ft", + "source": "Made-up test value", + "placeholder": true + } + }, + { + "id": "route_length", + "label": "Cable run length", + "outcome": "unsure", + "reason": "The cable run is 16 ft 0 in (± 0 ft 6 in): past the 15 ft 0 in confident reach, so the policy sends it to a person, though under the 20 ft 0 in maximum.", + "measured_ft": 16.0, + "plus_minus_ft": 0.5, + "threshold_ft": 20.0, + "comparison": "at_most", + "subject": null, + "rule": { + "key": "route.max_ft", + "source": "Made-up test value. Review past 15 ft 0 in: made-up test value", + "placeholder": true + }, + "unsure_cause": "rule_requires_review", + "review_threshold_ft": 15.0 + } + ], + "nearest_considered": null, + "missing_evidence": [], + "ends": { + "left": { + "kind": "limit", + "s_ft": -8.0, + "point": [ + -8.0, + 0.0 + ] + }, + "right": { + "kind": "limit", + "s_ft": 24.0, + "point": [ + 24.0, + 0.0 + ] + } + }, + "sweep": [ + { + "wall_id": "wall", + "segment": 0, + "start_ft": [ + -8.0, + 12.416667 + ], + "outcome": "fail", + "failing": [ + "opening_clearance" + ], + "unsure": [] + }, + { + "wall_id": "wall", + "segment": 0, + "start_ft": [ + 12.5, + 18.0 + ], + "outcome": "unsure", + "failing": [], + "unsure": [ + "route_length" + ] + }, + { + "wall_id": "wall", + "segment": 0, + "start_ft": [ + 18.083333, + 21.416667 + ], + "outcome": "fail", + "failing": [ + "route_length" + ], + "unsure": [] + } + ], + "stats": { + "candidates": 36, + "pass": 0, + "unsure": 7, + "fail": 29, + "elapsed_ms": 0, + "input_sha256": "0000000000000000000000000000000000000000000000000000000000000000" + } +} \ No newline at end of file diff --git a/ios/HouseScanUITests/Fixtures/results/spot-left.json b/ios/HouseScanUITests/Fixtures/results/spot-left.json new file mode 100644 index 000000000..704c3fa01 --- /dev/null +++ b/ios/HouseScanUITests/Fixtures/results/spot-left.json @@ -0,0 +1,203 @@ +{ + "schema_version": "1.0", + "decision": "pass", + "summary": "Test answer for the spot check's wording: a spot 3 ft 6 in to 6 ft left of the meter. Demo rules: public values and placeholders (pool 10 ft, drive 5 ft), not Base's.", + "reasons": [], + "policy": { + "id": "demo", + "version": "2026-09-26", + "auto_approve": true, + "sources": [ + "public" + ], + "rules_sha256": "5a1e0c7d00000000000000000000000000000000000000000000000000000000", + "notice": "Demo rules: public values and placeholders (pool 10 ft, drive 5 ft), not Base's." + }, + "spot": { + "outcome": "pass", + "wall_id": "wall", + "segment": 0, + "span_ft": [ + -6.0, + -3.5 + ], + "width_ft": 2.5, + "depth_ft": 2.0, + "height_ft": 3.5, + "footprint": [ + [ + -6.0, + 0.0 + ], + [ + -3.5, + 0.0 + ], + [ + -3.5, + 2.0 + ], + [ + -6.0, + 2.0 + ] + ], + "center": [ + -4.75, + 1.0 + ], + "along": [ + 1.0, + 0.0 + ], + "outward": [ + 0.0, + 1.0 + ], + "meter_offset_ft": [ + -4.75, + 1.0 + ], + "route_length_ft": 3.5 + }, + "route": { + "outcome": "pass", + "length_ft": 3.5, + "plus_minus_ft": 0.3, + "height_ft": 1.5, + "polyline": [ + [ + 0.0, + 0.0 + ], + [ + -3.5, + 0.0 + ] + ], + "detours": [], + "crossings": [] + }, + "checks": [ + { + "id": "wall_backing", + "label": "Flush against one straight wall", + "outcome": "pass", + "reason": "Test: seen flat behind the whole spot.", + "measured_ft": null, + "plus_minus_ft": null, + "threshold_ft": null, + "comparison": null, + "subject": null, + "rule": { + "key": "wall_backing_ft", + "source": "Made-up test value", + "placeholder": true + } + }, + { + "id": "gas_clearance", + "label": "Distance from gas equipment", + "outcome": "pass", + "reason": "Test: well clear.", + "measured_ft": 6.2, + "plus_minus_ft": 0.3, + "threshold_ft": 3.0, + "comparison": "at_least", + "subject": null, + "rule": { + "key": "gas_clearance_ft", + "source": "Made-up test value", + "placeholder": true + } + }, + { + "id": "opening_clearance", + "label": "Distance from doors and windows", + "outcome": "pass", + "reason": "Test: clear of the window.", + "measured_ft": 3.9, + "plus_minus_ft": 0.3, + "threshold_ft": 3.0, + "comparison": "at_least", + "subject": null, + "rule": { + "key": "opening_clearance_ft", + "source": "Made-up test value", + "placeholder": true + } + }, + { + "id": "meter_working_space", + "label": "Clear of the meter's working space", + "outcome": "pass", + "reason": "Test: clear of the working space.", + "measured_ft": 1.8, + "plus_minus_ft": 0.3, + "threshold_ft": 0.0, + "comparison": "at_least", + "subject": null, + "rule": { + "key": "meter_working_space_ft", + "source": "Made-up test value", + "placeholder": true + } + }, + { + "id": "route_length", + "label": "Cable run length", + "outcome": "pass", + "reason": "Test: short run.", + "measured_ft": 3.5, + "plus_minus_ft": 0.3, + "threshold_ft": 20.0, + "comparison": "at_most", + "subject": null, + "rule": { + "key": "route_length_ft", + "source": "Made-up test value", + "placeholder": true + } + } + ], + "nearest_considered": null, + "missing_evidence": [], + "ends": { + "left": { + "kind": "limit", + "s_ft": -8.0, + "point": [ + -8.0, + 0.0 + ] + }, + "right": { + "kind": "limit", + "s_ft": 12.0, + "point": [ + 12.0, + 0.0 + ] + } + }, + "sweep": [ + { + "wall_id": "wall", + "start_ft": [ + -6.0, + -5.0 + ], + "outcome": "pass", + "failing": [], + "unsure": [] + } + ], + "stats": { + "candidates": 36, + "pass": 4, + "unsure": 0, + "fail": 32, + "elapsed_ms": 0, + "input_sha256": "0000000000000000000000000000000000000000000000000000000000000000" + } +} diff --git a/ios/HouseScanUITests/Fixtures/results/spot-straddle.json b/ios/HouseScanUITests/Fixtures/results/spot-straddle.json new file mode 100644 index 000000000..3758eb4a4 --- /dev/null +++ b/ios/HouseScanUITests/Fixtures/results/spot-straddle.json @@ -0,0 +1,203 @@ +{ + "schema_version": "1.0", + "decision": "pass", + "summary": "Test answer for the spot check's wording: a spot from 1 ft 3 in left to 1 ft 6 in right of the meter. Demo rules: public values and placeholders (pool 10 ft, drive 5 ft), not Base's.", + "reasons": [], + "policy": { + "id": "demo", + "version": "2026-09-26", + "auto_approve": true, + "sources": [ + "public" + ], + "rules_sha256": "5a1e0c7d00000000000000000000000000000000000000000000000000000000", + "notice": "Demo rules: public values and placeholders (pool 10 ft, drive 5 ft), not Base's." + }, + "spot": { + "outcome": "pass", + "wall_id": "wall", + "segment": 0, + "span_ft": [ + -1.25, + 1.5 + ], + "width_ft": 2.75, + "depth_ft": 2.0, + "height_ft": 3.5, + "footprint": [ + [ + -1.25, + 0.0 + ], + [ + 1.5, + 0.0 + ], + [ + 1.5, + 2.0 + ], + [ + -1.25, + 2.0 + ] + ], + "center": [ + 0.125, + 1.0 + ], + "along": [ + 1.0, + 0.0 + ], + "outward": [ + 0.0, + 1.0 + ], + "meter_offset_ft": [ + 0.125, + 1.0 + ], + "route_length_ft": 0.125 + }, + "route": { + "outcome": "pass", + "length_ft": 0.125, + "plus_minus_ft": 0.3, + "height_ft": 1.5, + "polyline": [ + [ + 0.0, + 0.0 + ], + [ + 0.125, + 0.0 + ] + ], + "detours": [], + "crossings": [] + }, + "checks": [ + { + "id": "wall_backing", + "label": "Flush against one straight wall", + "outcome": "pass", + "reason": "Test: seen flat behind the whole spot.", + "measured_ft": null, + "plus_minus_ft": null, + "threshold_ft": null, + "comparison": null, + "subject": null, + "rule": { + "key": "wall_backing_ft", + "source": "Made-up test value", + "placeholder": true + } + }, + { + "id": "gas_clearance", + "label": "Distance from gas equipment", + "outcome": "pass", + "reason": "Test: well clear.", + "measured_ft": 6.2, + "plus_minus_ft": 0.3, + "threshold_ft": 3.0, + "comparison": "at_least", + "subject": null, + "rule": { + "key": "gas_clearance_ft", + "source": "Made-up test value", + "placeholder": true + } + }, + { + "id": "opening_clearance", + "label": "Distance from doors and windows", + "outcome": "pass", + "reason": "Test: clear of the window.", + "measured_ft": 3.9, + "plus_minus_ft": 0.3, + "threshold_ft": 3.0, + "comparison": "at_least", + "subject": null, + "rule": { + "key": "opening_clearance_ft", + "source": "Made-up test value", + "placeholder": true + } + }, + { + "id": "meter_working_space", + "label": "Clear of the meter's working space", + "outcome": "pass", + "reason": "Test: clear of the working space.", + "measured_ft": 1.8, + "plus_minus_ft": 0.3, + "threshold_ft": 0.0, + "comparison": "at_least", + "subject": null, + "rule": { + "key": "meter_working_space_ft", + "source": "Made-up test value", + "placeholder": true + } + }, + { + "id": "route_length", + "label": "Cable run length", + "outcome": "pass", + "reason": "Test: short run.", + "measured_ft": 0.125, + "plus_minus_ft": 0.3, + "threshold_ft": 20.0, + "comparison": "at_most", + "subject": null, + "rule": { + "key": "route_length_ft", + "source": "Made-up test value", + "placeholder": true + } + } + ], + "nearest_considered": null, + "missing_evidence": [], + "ends": { + "left": { + "kind": "limit", + "s_ft": -8.0, + "point": [ + -8.0, + 0.0 + ] + }, + "right": { + "kind": "limit", + "s_ft": 12.0, + "point": [ + 12.0, + 0.0 + ] + } + }, + "sweep": [ + { + "wall_id": "wall", + "start_ft": [ + -1.25, + -0.25 + ], + "outcome": "pass", + "failing": [], + "unsure": [] + } + ], + "stats": { + "candidates": 36, + "pass": 4, + "unsure": 0, + "fail": 32, + "elapsed_ms": 0, + "input_sha256": "0000000000000000000000000000000000000000000000000000000000000000" + } +} diff --git a/ios/HouseScanUITests/Fixtures/results/view-not-offered.json b/ios/HouseScanUITests/Fixtures/results/view-not-offered.json new file mode 100644 index 000000000..abe890b33 --- /dev/null +++ b/ios/HouseScanUITests/Fixtures/results/view-not-offered.json @@ -0,0 +1,269 @@ +{ + "schema_version": "1.0", + "decision": "manual_review", + "summary": "Test answer: two checks at the spot depend on areas the scan did not see; one view can be taken now and one can't.", + "reasons": [ + { + "code": "unsure_checks", + "message": "Test: two checks at this spot are unsure.", + "checks": [ + "ground_surface", + "front_clearance" + ] + }, + { + "code": "unobserved_area", + "message": "Test: part of the area the checks need was not seen.", + "checks": [ + "ground_surface", + "front_clearance" + ] + } + ], + "policy": { + "id": null, + "version": null, + "auto_approve": false, + "sources": [ + "public" + ], + "rules_sha256": "0000000000000000000000000000000000000000000000000000000000000000" + }, + "spot": { + "outcome": "unsure", + "wall_id": "wall", + "segment": 0, + "span_ft": [ + 3.0, + 5.5 + ], + "width_ft": 2.5, + "depth_ft": 1.0, + "height_ft": 4.5, + "footprint": [ + [ + 3.0, + 0.0 + ], + [ + 5.5, + 0.0 + ], + [ + 5.5, + 1.0 + ], + [ + 3.0, + 1.0 + ] + ], + "center": [ + 4.25, + 0.5 + ], + "along": [ + 1.0, + 0.0 + ], + "outward": [ + 0.0, + 1.0 + ], + "meter_offset_ft": [ + 4.25, + 0.5 + ], + "route_length_ft": 3.0 + }, + "route": { + "outcome": "pass", + "length_ft": 3.0, + "plus_minus_ft": 0.3, + "height_ft": 1.5, + "polyline": [ + [ + 0.0, + 0.0 + ], + [ + 3.0, + 0.0 + ] + ], + "detours": [], + "crossings": [] + }, + "checks": [ + { + "id": "ground_surface", + "label": "Ground under the spot", + "outcome": "unsure", + "unsure_cause": "unobserved", + "reason": "Test: the ground under the spot was seen from one place only.", + "measured_ft": null, + "plus_minus_ft": null, + "threshold_ft": null, + "comparison": null, + "subject": null, + "rule": { + "key": "ground_surface_ft", + "source": "Made-up test value", + "placeholder": true + } + }, + { + "id": "front_clearance", + "label": "Clear space in front", + "outcome": "unsure", + "unsure_cause": "unobserved", + "reason": "Test: the space in front of the spot was not seen.", + "measured_ft": null, + "plus_minus_ft": null, + "threshold_ft": null, + "comparison": null, + "subject": null, + "rule": { + "key": "front_clearance_ft", + "source": "Made-up test value", + "placeholder": true + } + }, + { + "id": "gas_clearance", + "label": "Distance from the gas meter", + "outcome": "pass", + "reason": "Sample: the gas meter is well clear of the spot.", + "measured_ft": 6.2, + "plus_minus_ft": 0.3, + "threshold_ft": 3.0, + "comparison": "at_least", + "subject": "objects[0] gas_meter", + "rule": { + "key": "sample_gas_clearance_ft", + "source": "Sample value, not a rule citation", + "placeholder": true + } + }, + { + "id": "route_length", + "label": "Cable run from the meter", + "outcome": "pass", + "reason": "Sample: the cable run is short.", + "measured_ft": 3.0, + "plus_minus_ft": 0.3, + "threshold_ft": 20.0, + "comparison": "at_most", + "subject": null, + "rule": { + "key": "sample_route_length_ft", + "source": "Sample value, not a rule citation", + "placeholder": true + } + } + ], + "nearest_considered": null, + "missing_evidence": [ + { + "kind": "band", + "band": "ground", + "span_ft": [ + 3.0, + 5.5 + ], + "checks": [ + "ground_surface" + ], + "message": "Test: film the ground under the spot." + }, + { + "kind": "band", + "band": "facing", + "span_ft": [ + 3.0, + 5.5 + ], + "checks": [ + "front_clearance" + ], + "message": "Test: show what stands in front of the spot." + } + ], + "ends": { + "left": { + "kind": "unexplored", + "s_ft": -8.0, + "point": [ + -8.0, + 0.0 + ] + }, + "right": { + "kind": "limit", + "s_ft": 12.0, + "point": [ + 12.0, + 0.0 + ] + } + }, + "sweep": [ + { + "wall_id": "wall", + "start_ft": [ + -8.0, + -1.5 + ], + "outcome": "fail", + "failing": [ + "gas_clearance" + ], + "unsure": [] + }, + { + "wall_id": "wall", + "start_ft": [ + -1.0, + 2.5 + ], + "outcome": "fail", + "failing": [ + "meter_clearance" + ], + "unsure": [] + }, + { + "wall_id": "wall", + "start_ft": [ + 3.0, + 6.0 + ], + "outcome": "unsure", + "failing": [], + "unsure": [ + "window_clearance", + "front_clearance" + ] + }, + { + "wall_id": "wall", + "start_ft": [ + 6.5, + 9.5 + ], + "outcome": "unsure", + "failing": [], + "unsure": [ + "front_clearance" + ] + } + ], + "stats": { + "candidates": 36, + "pass": 0, + "unsure": 14, + "fail": 22, + "elapsed_ms": 0, + "input_sha256": "0000000000000000000000000000000000000000000000000000000000000000" + } +} \ No newline at end of file diff --git a/ios/HouseScanUITests/FullFlowUITests.swift b/ios/HouseScanUITests/FullFlowUITests.swift index a1c3be375..9dce69cb4 100644 --- a/ios/HouseScanUITests/FullFlowUITests.swift +++ b/ios/HouseScanUITests/FullFlowUITests.swift @@ -296,13 +296,19 @@ final class FullFlowUITests: XCTestCase { let row = app.switches["developer.practiceMeter"] XCTAssertTrue(row.waitForExistence(timeout: 10), "the developer options have no practice meter switch") let wanted = on ? "1" : "0" - if row.value as? String != wanted { + let set = NSPredicate(format: "value == %@", wanted) + // The sheet can still be presenting when the switch first exists: on hosted runs its list + // cells had no frames yet and the tap changed nothing (runs 37114486663, 37117066081). So + // wait until the switch can be tapped, and tap again only while the value is still wrong, + // which a switch tolerates. + for _ in 0..<3 where row.value as? String != wanted { + _ = XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: NSPredicate(format: "isHittable == true"), object: row)], timeout: 5) // A Form row's switch is the row; the control inside it takes the tap. let control = row.switches.firstMatch (control.exists ? control : row).tap() + if XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: set, object: row)], timeout: 2) == .completed { break } } - let set = NSPredicate(format: "value == %@", wanted) - XCTAssertEqual(XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: set, object: row)], timeout: 5), .completed, "the switch didn't turn \(on ? "on" : "off")") + XCTAssertEqual(XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: set, object: row)], timeout: 3), .completed, "the switch didn't turn \(on ? "on" : "off")") let shot = XCTAttachment(screenshot: app.screenshot()) shot.name = "developerOptions-\(on ? "on" : "off")" shot.lifetime = .keepAlways diff --git a/ios/HouseScanUITests/ResultCardUITests.swift b/ios/HouseScanUITests/ResultCardUITests.swift new file mode 100644 index 000000000..ebfc196b6 --- /dev/null +++ b/ios/HouseScanUITests/ResultCardUITests.swift @@ -0,0 +1,199 @@ +import XCTest + +/// The result card on server answers in Fixtures/results, read by the demo in debug builds +/// (`-uiDemoResultFile`), at the default text size and the largest accessibility size. The tests +/// run at both sizes keep a screenshot of the card. +final class ResultCardUITests: XCTestCase { + private static let largestText = ["-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryAccessibilityXXXL"] + + /// What the review-band line says aloud. The visible line is the same sentence with "ft" and + /// "in" (`ScanCopy.cardLine`), so the two can't disagree. + private static let reviewBandSpoken = "Measured 16 feet. The rule is at most 20 feet, and anything 15 feet or more " + + "needs an installer's review. The measurement can be off by about 6 inches." + + /// Words that would say a person was contacted or a review arranged. The app contacts nobody, + /// and the server's own reason ("the policy sends it to a person") stays under Details. + private static let handoffWords = ["sent", "sends", "send ", "notified", "scheduled", "will review", "is reviewing", "contact"] + + override func setUp() { + continueAfterFailure = false + } + + // MARK: A run past the review line + + /// review-band.json: a 16 ft cable run, under the 20 ft maximum and past the 15 ft review line, + /// which the server marks unsure. The card says why, and says only that review is needed. + @MainActor + func testACableRunPastTheReviewLineSaysWhy() throws { + try checkReviewBand(textSize: [], name: "result-reviewBand") + } + + @MainActor + func testACableRunPastTheReviewLineSaysWhyAtLargestTextSize() throws { + try checkReviewBand(textSize: Self.largestText, name: "result-reviewBand-AX5") + } + + @MainActor + private func checkReviewBand(textSize: [String], name: String) throws { + let app = launch("review-band", textSize: textSize) + defer { app.terminate() } + XCTAssertEqual(element(app, "result.headline").label, "Needs an installer's review") + let line = element(app, "check.route_length") + XCTAssertTrue(line.waitForExistence(timeout: 10), "the cable run is not on the card") + scrollIntoView(line, in: app) + XCTAssertEqual(line.label, "Cable run length: Not sure yet") + XCTAssertEqual(value(line), Self.reviewBandSpoken) + assertNoHandoff(line.label + " " + value(line)) + attach(app, name: name) + } + + // MARK: A line without a review line + + /// unsure-view.json's window check has no review line: its sentence is the plain measurement + /// against the rule, with nothing about review added. + @MainActor + func testACheckWithoutAReviewLineKeepsItsSentence() throws { + let app = launch("unsure-view", textSize: []) + defer { app.terminate() } + let line = element(app, "check.window_clearance") + XCTAssertTrue(line.waitForExistence(timeout: 10), "the window check is not on the card") + XCTAssertEqual(value(line), "Measured 3 feet 1 inch. The rule is at least 3 feet, and the measurement can be off by about 4 inches.") + } + + // MARK: A photo only when the line offers one + + /// view-not-offered.json: two unsure checks that views would settle. The ground's view can be + /// taken now, so its line is a "Show me" button that says a photo would settle it. The space + /// in front's view can't be planned, as after a withdrawn request, so its line promises no + /// photo and offers no button. The spot-unknown run showed "One more photo would settle this" + /// on such a line. + @MainActor + func testOnlyALineThatOffersThePhotoPromisesIt() throws { + try checkPhotoOffer(textSize: [], name: "result-viewNotOffered") + } + + @MainActor + func testOnlyALineThatOffersThePhotoPromisesItAtLargestTextSize() throws { + try checkPhotoOffer(textSize: Self.largestText, name: "result-viewNotOffered-AX5") + } + + @MainActor + private func checkPhotoOffer(textSize: [String], name: String) throws { + let app = launch("view-not-offered", textSize: textSize) + defer { app.terminate() } + let offered = element(app, "check.ground_surface") + XCTAssertTrue(offered.waitForExistence(timeout: 10), "the ground check is not on the card") + XCTAssertEqual(offered.elementType, .button, "the ground's line should open the camera") + XCTAssertEqual(offered.label, "Ground under the spot: Not sure yet") + XCTAssertEqual(value(offered), "One more photo would settle this") + + let notOffered = element(app, "check.front_clearance") + XCTAssertTrue(notOffered.exists, "the space in front is not on the card") + XCTAssertNotEqual(notOffered.elementType, .button, "a line with no view to take must not be a button") + XCTAssertEqual(notOffered.label, "Clear space in front: Not sure yet") + XCTAssertEqual(value(notOffered), "Needs an installer to check") + assertNoHandoff(notOffered.label + " " + value(notOffered)) + scrollIntoView(notOffered, in: app) + attach(app, name: name) + } + + // MARK: No spot and no closest spot + + /// no-spot-no-nearest.json: a reject with sweep runs but neither a spot nor a closest spot, + /// so nothing has a battery's size. The card answers without a spot, a closest spot or check + /// lines, the model of the wall shows no spot, and the summary stays under Details. + @MainActor + func testAnAnswerWithNoSpotShowsTheWallAndSummaryOnly() throws { + try checkNoSpot(textSize: [], name: "result-noSpotNoNearest") + } + + @MainActor + func testAnAnswerWithNoSpotShowsTheWallAndSummaryOnlyAtLargestTextSize() throws { + try checkNoSpot(textSize: Self.largestText, name: "result-noSpotNoNearest-AX5") + } + + @MainActor + private func checkNoSpot(textSize: [String], name: String) throws { + let app = launch("no-spot-no-nearest", textSize: textSize) + defer { app.terminate() } + XCTAssertEqual(element(app, "result.headline").label, "Not on this wall") + XCTAssertFalse(element(app, "result.placement").exists) + XCTAssertFalse(element(app, "result.nearest").exists) + let checkLines = app.descendants(matching: .any).matching(NSPredicate(format: "identifier BEGINSWITH 'check.'")) + XCTAssertEqual(checkLines.count, 0, "an answer without checks put check lines on the card") + let model = app.descendants(matching: .any).matching(NSPredicate(format: "label == '3D view of your wall'")).firstMatch + XCTAssertTrue(model.waitForExistence(timeout: 10), "the model of the wall is missing") + XCTAssertEqual(value(model), "No battery spot shown") + attach(app, name: name) + + let details = element(app, "result.details") + scrollIntoView(details, in: app) + details.tap() + let summary = element(app, "result.summary") + XCTAssertTrue(summary.waitForExistence(timeout: 10), "the summary is missing under Details") + XCTAssertTrue(summary.label.contains("every spot fails route length, wall backing"), summary.label) + scrollIntoView(summary, in: app) + attach(app, name: "\(name)-details") + } + + // MARK: Helpers + + @MainActor + private func launch(_ resultFile: String, textSize: [String]) -> XCUIApplication { + let app = XCUIApplication() + app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoFreeze", "-uiDemoPhase", "result", + "-uiDemoResultFile", Self.resultFile(resultFile)] + textSize + app.launch() + XCTAssertTrue(element(app, "screen.result").waitForExistence(timeout: 15)) + return app + } + + /// A server answer in Fixtures/results. + private static func resultFile(_ name: String, file: String = #filePath) -> String { + URL(fileURLWithPath: file).deletingLastPathComponent().appending(path: "Fixtures/results/\(name).json").path + } + + /// Scrolls until the whole element is inside the window, so the screenshot shows it. Each + /// drag moves the content by what is missing, at most a third of the window, toward the + /// target: a fixed swipe could carry a tall line from below the window to above it, and + /// swiping on in one direction would never bring it back. + @MainActor + private func scrollIntoView(_ target: XCUIElement, in app: XCUIApplication) { + let window = app.windows.firstMatch.frame + let margin: CGFloat = 24 + let start = app.scrollViews.firstMatch.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.6)) + for _ in 0..<12 where !window.contains(target.frame) { + let frame = target.frame + let shift: CGFloat = frame.maxY > window.maxY + ? -min(frame.maxY - window.maxY + margin, window.height / 3) + : min(window.minY - frame.minY + margin, window.height / 3) + start.press(forDuration: 0.05, thenDragTo: start.withOffset(CGVector(dx: 0, dy: shift))) + } + XCTAssertTrue(window.contains(target.frame), "\(target.identifier) can't be scrolled fully into view") + } + + private func assertNoHandoff(_ text: String, file: StaticString = #filePath, line: UInt = #line) { + let lowered = text.lowercased() + for word in Self.handoffWords { + XCTAssertFalse(lowered.contains(word), "the card says \"\(word)\": \(text)", file: file, line: line) + } + } + + @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 ?? "" + } + + @MainActor + private func attach(_ app: XCUIApplication, name: String) { + let shot = XCTAttachment(screenshot: app.screenshot()) + shot.name = name + shot.lifetime = .keepAlways + add(shot) + } +} diff --git a/ios/HouseScanUITests/SavedScansUITests.swift b/ios/HouseScanUITests/SavedScansUITests.swift new file mode 100644 index 000000000..74b8bff59 --- /dev/null +++ b/ios/HouseScanUITests/SavedScansUITests.swift @@ -0,0 +1,206 @@ +import XCTest + +/// Saved scans from the onboarding, on synthetic scan folders the test writes and the app lists +/// through `-savedScansRoot` (Debug builds only). A completed scan is listed with its saved time +/// and a Practice tag only where its stamp says practice; an unfinished folder is not listed. +/// Share opens the system share sheet; a scan deleted since the list was made gets an error and +/// drops out; the list survives a relaunch; and a new scan still starts. +final class SavedScansUITests: XCTestCase { + private var root: URL! + + override func setUpWithError() throws { + continueAfterFailure = false + root = URL(fileURLWithPath: NSTemporaryDirectory()).appending(path: "housescan-saved-\(UUID().uuidString)", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: root) + } + + @MainActor + func testASavedScanSharesAfterARelaunchAndAMissingOneSaysSo() throws { + try scan("practice", minutesAgo: 5, stamp: #"{"practice": true}"#) + // A corrupt stamp says nothing: the scan is listed, untagged. + try scan("real", minutesAgo: 30, stamp: "{not json") + try scan("unfinished", minutesAgo: nil) + + let app = launch() + openSavedScans(app) + let practice = row(app, "practice") + let real = row(app, "real") + XCTAssertTrue(practice.waitForExistence(timeout: 10), "the practice scan is not listed") + XCTAssertTrue(real.exists, "the real scan is not listed") + XCTAssertFalse(row(app, "unfinished").exists, "a scan without a bundle is listed") + XCTAssertTrue(practice.descendants(matching: .any)["savedScan.practice"].exists, "the practice scan has no Practice tag") + XCTAssertFalse(real.descendants(matching: .any)["savedScan.practice"].exists, "a scan without a readable stamp is tagged Practice") + // A practice scan walked a real wall; only its meter was a sample. + // The row's text is one combined element, so match within labels. + func says(_ element: XCUIElement, _ text: String) -> Bool { + element.descendants(matching: .any).matching(NSPredicate(format: "label CONTAINS[c] %@", text)).firstMatch.exists + } + XCTAssertTrue(says(practice, "Practice scan with a sample meter"), "the practice scan doesn't say its meter was a sample") + XCTAssertFalse(says(app, "real wall"), "a row says a scan wasn't of a real wall") + attach(app, "savedScans-list") + // The audit's text-size check changes the onboarding's top bar layout; the sheet must + // stay open through it (it closed when the button owned it). + try audit(app) + + // Share opens the system sheet, and closing it returns to the list. + tapWhenReady(real.buttons["action.shareSavedScan"]) + let sheet = app.otherElements["ActivityListView"] + XCTAssertTrue(sheet.waitForExistence(timeout: 15), "the share sheet never appeared") + attach(app, "savedScans-shareSheet") + if app.buttons["Close"].waitForExistence(timeout: 3) { + app.buttons["Close"].tap() + } else { + sheet.swipeDown(velocity: .fast) + } + XCTAssertTrue(sheet.waitForNonExistence(timeout: 10), "the share sheet did not close") + + // The practice scan's folder goes after the list was made, as a cleanup would take it. + try FileManager.default.removeItem(at: root.appending(path: "practice")) + tapWhenReady(practice.buttons["action.shareSavedScan"]) + let alert = app.alerts["This scan is no longer on this phone"] + XCTAssertTrue(alert.waitForExistence(timeout: 10), "a missing scan gave no error") + attach(app, "savedScans-missing") + alert.buttons["OK"].tap() + XCTAssertTrue(practice.waitForNonExistence(timeout: 10), "the missing scan stayed in the list") + XCTAssertTrue(real.exists) + + // A relaunch lists the completed scan again. + app.terminate() + app.launch() + openSavedScans(app) + XCTAssertTrue(row(app, "real").waitForExistence(timeout: 10), "the saved scan is gone after a relaunch") + tapWhenReady(app.buttons["action.closeSavedScans"]) + + // A new scan starts as before. + if app.buttons["action.onboardingSkip"].waitForExistence(timeout: 5) { tapWhenReady(app.buttons["action.onboardingSkip"]) } + tapWhenReady(app.buttons["action.finishOnboarding"]) + XCTAssertTrue(app.descendants(matching: .any)["screen.findMeter"].waitForExistence(timeout: 15), "a new scan did not start") + } + + @MainActor + func testNoSavedScansSaysWhereTheyComeFrom() throws { + try scan("unfinished", minutesAgo: nil) + let app = launch() + openSavedScans(app) + let empty = app.descendants(matching: .any)["savedScans.empty"] + XCTAssertTrue(empty.waitForExistence(timeout: 10), "no empty state") + XCTAssertFalse(app.buttons["action.shareSavedScan"].exists) + attach(app, "savedScans-empty") + try audit(app) + } + + // MARK: Helpers + + @MainActor + private func launch() -> XCUIApplication { + let app = XCUIApplication() + app.launchArguments = ["-savedScansRoot", root.path, "-replay", FullFlowUITests.fixture, "-sampleResult", "-practiceMeter", "NO"] + app.launch() + XCTAssertTrue(app.descendants(matching: .any)["screen.onboarding"].waitForExistence(timeout: 30)) + return app + } + + @MainActor + private func openSavedScans(_ app: XCUIApplication) { + tapWhenReady(app.buttons["action.savedScans"]) + XCTAssertTrue(app.descendants(matching: .any)["savedScans"].waitForExistence(timeout: 10), "Saved scans did not open") + } + + @MainActor + private func row(_ app: XCUIApplication, _ id: String) -> XCUIElement { + app.descendants(matching: .any)["savedScan.\(id)"] + } + + /// Waits for the element to take a tap before tapping it, so a sheet still sliding in + /// doesn't swallow the tap. + @MainActor + private func tapWhenReady(_ element: XCUIElement, file: StaticString = #filePath, line: UInt = #line) { + XCTAssertTrue(element.waitForExistence(timeout: 10), "\(element) never appeared", file: file, line: line) + let hittable = expectation(for: NSPredicate(format: "isHittable == true"), evaluatedWith: element) + wait(for: [hittable], timeout: 10) + element.tap() + } + + /// The shared audit (`AccessibilityAudit`): an issue fails only when it shows in two passes. + @MainActor + private func audit(_ app: XCUIApplication, file: StaticString = #filePath, line: UInt = #line) throws { + let outcome = try AccessibilityAudit.run(app) { _ in Thread.sleep(forTimeInterval: 1) } + for unread in outcome.unread { add(XCTAttachment(string: unread)) } + XCTAssertTrue(outcome.persistent.isEmpty, outcome.persistent.map(\.finding.message).joined(separator: "\n"), file: file, line: line) + } + + @MainActor + private func attach(_ app: XCUIApplication, _ name: String) { + let shot = XCTAttachment(screenshot: app.screenshot()) + shot.name = name + shot.lifetime = .keepAlways + add(shot) + } + + /// A scan folder with a keyframe and, unless `minutesAgo` is nil, a bundle saved that long + /// ago: a synthetic packet whose first entry is manifest.json, as the app writes it. + private func scan(_ name: String, minutesAgo: Double?, stamp: String? = nil) throws { + let folder = root.appending(path: name) + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + try Data("photo".utf8).write(to: folder.appending(path: "k00001.jpg")) + if let stamp { try Data(stamp.utf8).write(to: folder.appending(path: "scan-stamp.json")) } + guard let minutesAgo else { return } + let bundle = folder.appending(path: "scan.zip") + try StoredZip.archive([ + ("manifest.json", Data(#"{"version":"1.1"}"#.utf8)), + ("photos/p00001.jpg", Data(repeating: 0xAB, count: 2048)), + ]).write(to: bundle) + try FileManager.default.setAttributes([.modificationDate: Date(timeIntervalSinceNow: -minutesAgo * 60)], ofItemAtPath: bundle.path) + } +} + +/// A stored (uncompressed) zip, enough for a synthetic packet. The UI tests don't link +/// HouseScanKit, whose `ZipWriter` the app uses. +private enum StoredZip { + static func archive(_ entries: [(name: String, data: Data)]) -> Data { + var out = Data() + var central = Data() + for entry in entries { + let name = Data(entry.name.utf8) + let crc = crc32(entry.data) + let offset = UInt32(out.count) + out.append(le: UInt32(0x0403_4B50)); out.append(le: UInt16(20)); out.append(le: UInt16(0)); out.append(le: UInt16(0)) + out.append(le: UInt16(0)); out.append(le: UInt16(0x21)) + out.append(le: crc); out.append(le: UInt32(entry.data.count)); out.append(le: UInt32(entry.data.count)) + out.append(le: UInt16(name.count)); out.append(le: UInt16(0)) + out.append(name); out.append(entry.data) + + central.append(le: UInt32(0x0201_4B50)); central.append(le: UInt16(20)); central.append(le: UInt16(20)) + central.append(le: UInt16(0)); central.append(le: UInt16(0)); central.append(le: UInt16(0)); central.append(le: UInt16(0x21)) + central.append(le: crc); central.append(le: UInt32(entry.data.count)); central.append(le: UInt32(entry.data.count)) + central.append(le: UInt16(name.count)); central.append(le: UInt16(0)); central.append(le: UInt16(0)) + central.append(le: UInt16(0)); central.append(le: UInt16(0)); central.append(le: UInt32(0)); central.append(le: offset) + central.append(name) + } + let directoryOffset = UInt32(out.count) + out.append(central) + out.append(le: UInt32(0x0605_4B50)); out.append(le: UInt16(0)); out.append(le: UInt16(0)) + out.append(le: UInt16(entries.count)); out.append(le: UInt16(entries.count)) + out.append(le: UInt32(central.count)); out.append(le: directoryOffset); out.append(le: UInt16(0)) + return out + } + + static func crc32(_ data: Data) -> UInt32 { + var crc: UInt32 = 0xFFFF_FFFF + for byte in data { + crc ^= UInt32(byte) + for _ in 0..<8 { crc = (crc & 1) != 0 ? 0xEDB8_8320 ^ (crc >> 1) : crc >> 1 } + } + return crc ^ 0xFFFF_FFFF + } +} + +private extension Data { + mutating func append(le value: T) { + Swift.withUnsafeBytes(of: value.littleEndian) { append(contentsOf: $0) } + } +} diff --git a/ios/HouseScanUITests/ScreenStatesUITests.swift b/ios/HouseScanUITests/ScreenStatesUITests.swift index 3002e501d..f1cc52f85 100644 --- a/ios/HouseScanUITests/ScreenStatesUITests.swift +++ b/ios/HouseScanUITests/ScreenStatesUITests.swift @@ -215,8 +215,9 @@ final class ScreenStatesUITests: XCTestCase { XCTAssertTrue(element(app, "screen.findMeter").waitForExistence(timeout: 5)) } - /// A failed upload says the app can't reopen the scan after it closes, and with the scan - /// packaged it points to Share scan. The note, Try again and Share scan are all reachable. + /// A failed upload says to keep the scan open to try again, and with the scan packaged that + /// Saved scans can still share its file after the app closes. The note, Try again and Share + /// scan are all reachable. @MainActor func testOfflineRecoveryActions() throws { try checkOfflineRecovery(textSize: [], name: "offline-recovery-polish") @@ -240,9 +241,8 @@ final class ScreenStatesUITests: XCTestCase { let note = element(app, "upload.recoveryLimit") for _ in 0..<8 where !note.isHittable { scroll.swipeUp(velocity: .slow) } XCTAssertTrue(note.isHittable) - // The demo scan is packaged, so the note names the export. - XCTAssertTrue(note.label.contains("use Share scan to save a copy"), note.label) - XCTAssertTrue(note.label.contains("can't reopen it after you close the app"), note.label) + // The demo scan is packaged, so the note points to Saved scans for after the app closes. + XCTAssertTrue(note.label.contains("If you close the app, you can still share its saved file from Saved scans on the first screen."), note.label) let retry = app.buttons["action.retryUpload"] for _ in 0..<8 where !retry.isHittable { scroll.swipeUp(velocity: .slow) } XCTAssertTrue(retry.isHittable) @@ -1156,6 +1156,10 @@ final class ScreenStatesUITests: XCTestCase { let app = XCUIApplication() app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoFreeze", "-uiDemoPhase", "uploading", "-uiDemoRejected"] app.launch() + // The sheet must not outlive this test: left open, it stayed over the next test's launch + // for about 16 s and that test never found its button (run 37121918073). Terminating waits + // until the app, and with it the sheet, is gone. + defer { app.terminate() } tap(app, "action.shareScan") let sheet = app.otherElements["ActivityListView"] let found = sheet.waitForExistence(timeout: 10) @@ -1167,6 +1171,9 @@ final class ScreenStatesUITests: XCTestCase { add(XCTAttachment(string: app.debugDescription)) } XCTAssertTrue(found, "the share sheet never appeared") + // Close it the way a person would before the app goes. + sheet.swipeDown() + _ = sheet.waitForNonExistence(timeout: 5) } @MainActor diff --git a/ios/HouseScanUITests/SpotCannotCheckUITests.swift b/ios/HouseScanUITests/SpotCannotCheckUITests.swift new file mode 100644 index 000000000..11b8c87b2 --- /dev/null +++ b/ios/HouseScanUITests/SpotCannotCheckUITests.swift @@ -0,0 +1,212 @@ +import XCTest + +/// "I can't check this area" through the real engine, on the synthetic wall with the bundled +/// sample answer (`-autopilotCannotCheck`). The answer must take back the scan's claims over the +/// spot's area, send the scan again and end on the result, which says the area went unchecked +/// rather than that something stands there. The sample names the same spot every time, so the +/// second answer is settled by the first; and after a ground refine takes the answer down +/// (`-injectGroundRise`, as GroundFreshnessUITests), the answer that comes back is settled the +/// same way, with the same notice and no second question. When the answer after it names no +/// spot, the result still says an area along the wall went unchecked; after "Something's there" +/// it doesn't. +final class SpotCannotCheckUITests: XCTestCase { + override func setUp() { + continueAfterFailure = false + } + + @MainActor + func testCannotCheckLeavesTheAreaUnseenAndTheResultSaysSo() 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) } + // The spot check and the result are held for the test; every other screen is let through. + for phase in ["onboarding", "findMeter", "meterCloseUp", "wallWalk", "markFeatures", "gapRequest", "uploading"] { + 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", "-autopilotCannotCheck", "-injectGroundRise", "0.05", + ] + app.launch() + let any = app.descendants(matching: .any) + + // 1. The engine's spot check offers all three answers. + XCTAssertTrue(waitUntil(timeout: 300) { files.fileExists(atPath: gate.appending(path: "spotConfirm.held").path) }, "the autopilot never reached the spot check") + XCTAssertTrue(any["screen.spotConfirm"].exists) + for id in ["action.spotClear", "action.spotSomethingThere", "action.spotCannotCheck"] { + XCTAssertTrue(app.buttons[id].exists, "the spot check has no \(id)") + } + XCTAssertEqual(app.buttons["action.spotCannotCheck"].label, "I can't check this area") + attach(app, name: "engine-spotConfirm") + try audit(app, "spotConfirm") + try Data().write(to: gate.appending(path: "spotConfirm")) + + // 2. The scan goes again and the result says the area wasn't checked. The acknowledgment + // in between is up for 1.5 s; SpotConfirmUITests reads it held still on the demo. + let result = any["screen.result"] + XCTAssertTrue(result.waitForExistence(timeout: 120), "the scan sent again never reached the result") + XCTAssertTrue(waitUntil(timeout: 30) { files.fileExists(atPath: gate.appending(path: "result.held").path) }, "the autopilot never finished with the result") + assertNotCheckedNotice(app) + attach(app, name: "engine-result-spotNotChecked") + + // 3. The scenes the last upload sent and the app exports claim nothing over the stretch + // the answer withdrew, and still report wall and ground elsewhere. + let refusal = try Data(contentsOf: gate.appending(path: "spot-refusal.json")) + let withdrawn = try XCTUnwrap((JSONSerialization.jsonObject(with: refusal) as? [String: Any])?["withdrawn_span_ft"] as? [Double]) + XCTAssertEqual(withdrawn.count, 2) + let low = try XCTUnwrap(withdrawn.first), high = try XCTUnwrap(withdrawn.last) + XCTAssertGreaterThan(high - low, 1, "withdrawn: \(withdrawn)") + for name in ["scene.json", "uploaded-scene.json"] { + let scene = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: gate.appending(path: name))) as? [String: Any]) + let coverage = try XCTUnwrap(scene["coverage"] as? [String: Any]) + let observed = try XCTUnwrap(coverage["observed"] as? [[String: Any]]) + for band in ["wall", "ground", "facing"] { + let spans = observed.filter { $0["band"] as? String == band }.compactMap { $0["span_ft"] as? [Double] } + for span in spans { + XCTAssertTrue(span[1] <= low + 1e-3 || span[0] >= high - 1e-3, "\(name): \(band) \(span) reaches into the withdrawn \(low)...\(high)") + } + if band != "facing" { + XCTAssertFalse(spans.isEmpty, "\(name): no \(band) observed at all") + } + } + } + + // 4. A ground refine takes the answer down and sends the scan again. The answer that comes + // back names the same spot: the earlier answer settles it, so the result returns with the + // same notice and the homeowner is not asked again. + let uploadGate = gate.appending(path: "uploading") + let uploadHeld = gate.appending(path: "uploading.held") + try files.removeItem(at: uploadGate) + try? files.removeItem(at: uploadHeld) + let trigger = gate.appending(path: "inject-ground") + try Data().write(to: trigger) + XCTAssertTrue(waitUntil(timeout: 10) { !files.fileExists(atPath: trigger.path) }, "the app never took the injected ground") + XCTAssertTrue(any["screen.uploading"].waitForExistence(timeout: 10), "a ground refine left the old answer on the result") + XCTAssertTrue(waitUntil(timeout: 60) { files.fileExists(atPath: uploadHeld.path) }, "the scan was not sent again") + try Data().write(to: uploadGate) + var askedAgain = false + XCTAssertTrue(waitUntil(timeout: 60) { + askedAgain = askedAgain || any["screen.spotConfirm"].exists + return result.exists + }, "the resend's answer never reached the result") + XCTAssertFalse(askedAgain, "the same area was asked about again after the answer was taken down") + assertNotCheckedNotice(app) + attach(app, name: "engine-result-spotNotChecked-afterRefine") + } + + /// "I can't check this area", then an answer that names no spot (`-sampleResultAfterSpotAnswer` + /// with Fixtures/results/reject-nearest.json). There is no spot to tie the answer to, but the + /// area stays out of the scan, so the result says an area along the wall went unchecked, and + /// neither speaks of "this spot" nor of something standing there. + @MainActor + func testCannotCheckThenNoSpotStillSaysAnAreaWasNotChecked() throws { + try runToResult(answer: "-autopilotCannotCheck", thenAnswerFile: "reject-nearest") { app in + let any = app.descendants(matching: .any) + let notice = any["result.scanNotChecked"] + XCTAssertTrue(notice.waitForExistence(timeout: 10), "a result without a spot lost the area the homeowner couldn't check") + XCTAssertEqual(ElementRead.snapshot(notice)?.label, "You couldn't check an area along this wall, so your scan leaves it out as not checked. Someone would need to check it in person.") + XCTAssertFalse(any["result.spotNotChecked"].exists, "a result without a spot speaks of this spot") + XCTAssertFalse(any["result.spotRefused"].exists, "the result says something stands there") + self.attach(app, name: "engine-result-noSpot-scanNotChecked") + } + } + + /// "I can't check this area", then an answer that names another spot (Fixtures/results/ + /// spot-left.json), which the autopilot answers "It's clear". The result's own spot is clear, + /// so it has no spot notice, but the first area is still out of the scan, and the result says + /// so. + @MainActor + func testCannotCheckThenAnotherSpotStillSaysAnAreaWasNotChecked() throws { + try runToResult(answer: "-autopilotCannotCheck", thenAnswerFile: "spot-left") { app in + let any = app.descendants(matching: .any) + let notice = any["result.scanNotChecked"] + XCTAssertTrue(notice.waitForExistence(timeout: 10), "a result for another spot lost the area the homeowner couldn't check") + XCTAssertEqual(ElementRead.snapshot(notice)?.label, "You couldn't check an area along this wall, so your scan leaves it out as not checked. Someone would need to check it in person.") + XCTAssertFalse(any["result.spotNotChecked"].exists, "the other spot, answered clear, reads as not checked") + XCTAssertFalse(any["result.spotRefused"].exists, "the result says something stands there") + self.attach(app, name: "engine-result-otherSpot-scanNotChecked") + } + } + + /// The same answer without a spot after "Something's there": nothing was left unchecked, so + /// the result says nothing about an unchecked area. + @MainActor + func testSomethingThereThenNoSpotLeavesNothingUnchecked() throws { + try runToResult(answer: "-autopilotSomethingThere", thenAnswerFile: "reject-nearest") { app in + let any = app.descendants(matching: .any) + XCTAssertTrue(any["result.headline"].waitForExistence(timeout: 10)) + for id in ["result.scanNotChecked", "result.spotNotChecked", "result.spotRefused"] { + XCTAssertFalse(any[id].exists, "\(id) on a result without a spot after Something's there") + } + } + } + + /// Runs the synthetic wall to the result with `answer` as the first spot answer and every + /// upload after it answered with Fixtures/results/`thenAnswerFile`.json + /// (`-sampleResultAfterSpotAnswer`), then hands the held result to `check`. Later spot checks + /// are answered "It's clear". + @MainActor + private func runToResult(answer: String, thenAnswerFile: String, check: (XCUIApplication) -> Void) 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) } + // Every screen but the result is let through; the result is held for the test. + for phase in ["onboarding", "findMeter", "meterCloseUp", "wallWalk", "markFeatures", "gapRequest", "uploading", "spotConfirm"] { + try Data().write(to: gate.appending(path: phase)) + } + let file = URL(fileURLWithPath: #filePath).deletingLastPathComponent().appending(path: "Fixtures/results/\(thenAnswerFile).json").path + let app = XCUIApplication() + app.launchArguments = [ + "-replay", FullFlowUITests.fixture, "-autopilot", "-autopilotHold", "1.5", "-autopilotGate", gate.path, + "-practiceMeter", "NO", "-sampleResult", answer, "-sampleResultAfterSpotAnswer", file, + ] + app.launch() + defer { app.terminate() } + XCTAssertTrue(waitUntil(timeout: 300) { files.fileExists(atPath: gate.appending(path: "result.held").path) }, "the autopilot never reached the result") + XCTAssertTrue(files.fileExists(atPath: gate.appending(path: "spot-refusal.json").path), "the spot check was never answered \(answer)") + XCTAssertTrue(app.descendants(matching: .any)["screen.result"].exists) + check(app) + } + + /// The result's notice says the area went unchecked, never that something stands there. The + /// spot's own notice replaces the scan-wide one. + @MainActor + private func assertNotCheckedNotice(_ app: XCUIApplication) { + let any = app.descendants(matching: .any) + let notice = any["result.spotNotChecked"] + XCTAssertTrue(notice.waitForExistence(timeout: 10), "the result doesn't say the area wasn't checked") + XCTAssertEqual(ElementRead.snapshot(notice)?.label, "You couldn't check the area around this spot, so your scan leaves it out as not checked. Someone would need to check it in person.") + XCTAssertFalse(any["result.spotRefused"].exists, "the result says something stands where the homeowner said they couldn't check") + XCTAssertFalse(any["result.scanNotChecked"].exists, "the result repeats the spot's notice for the whole wall") + } + + /// As FullFlowUITests: an issue fails only when a second pass, after a system banner has had + /// time to leave, finds it again. + @MainActor + private func audit(_ app: XCUIApplication, _ screen: String) throws { + let outcome = try AccessibilityAudit.run(app) { _ in Thread.sleep(forTimeInterval: 6) } + for (_, finding) in outcome.persistent { + XCTFail("screen.\(screen): \(finding.message)") + } + } + + @MainActor + private func attach(_ app: XCUIApplication, name: String) { + let shot = XCTAttachment(screenshot: app.screenshot()) + shot.name = name + shot.lifetime = .keepAlways + add(shot) + } + + private func waitUntil(timeout: TimeInterval, _ condition: () -> Bool) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + repeat { + if condition() { return true } + Thread.sleep(forTimeInterval: 0.2) + } while Date() < deadline + return condition() + } +} diff --git a/ios/HouseScanUITests/SpotConfirmUITests.swift b/ios/HouseScanUITests/SpotConfirmUITests.swift new file mode 100644 index 000000000..7cf469d40 --- /dev/null +++ b/ios/HouseScanUITests/SpotConfirmUITests.swift @@ -0,0 +1,356 @@ +import XCTest + +/// The spot check names the space the homeowner judges, with or without an outline to show it. +/// +/// With a kept photo and a wall, the area is outlined on the photo and the question points at the +/// outline. Without either, nothing is drawn, so the question must not mention an outline and the +/// area is given in words: along the wall from the meter, out from the wall, and up it. Held still +/// on the demo (`-uiDemoFreeze`), whose review sample's spot runs 0.55 to 1.34 m right of the +/// meter, stands 0.03 m off the wall and 0.56 m deep (out to 0.59 m), and is 1.1 m high. The demo +/// asks about the footprint alone, so its area is the footprint. Fixtures/results/spot-left.json +/// and spot-straddle.json put the spot left of the meter and across it. +final class SpotConfirmUITests: XCTestCase { + private static let largestText = ["-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryAccessibilityXXXL"] + + /// The words-only card's three lines as VoiceOver reads them (units spelled out). + private struct Lines { + var along: String + var out: String + var up: String + } + + /// The review sample's area, read aloud: one sentence on the photo, three lines in words. + private static let sampleLines = Lines( + along: "From 1 foot 10 inches to 4 feet 5 inches right of your meter", + out: "From the wall out to 1 foot 11 inches", up: "From the ground up to 3 feet 7 inches") + private static let sampleSpace = "From 1 foot 10 inches to 4 feet 5 inches right of your meter, from the wall out to 1 foot 11 inches, and from the ground up to 3 feet 7 inches." + + override func setUp() { + continueAfterFailure = true + } + + @MainActor + func testTheOutlinedPhotoNamesTheOutlineAndWhereItRuns() throws { + let app = launch([]) + defer { app.terminate() } + let photo = element(app, "spot.photo") + XCTAssertTrue(photo.waitForExistence(timeout: 10), "the demo's spot check shows its photo") + let read = ElementRead.snapshot(photo) + XCTAssertEqual(read?.label, "Photo of your wall") + XCTAssertEqual(read?.value as? String, "A blue outline marks where the battery would stand and the space around it. \(Self.sampleSpace)") + let question = ElementRead.snapshot(element(app, "spot.question"))?.label ?? "" + XCTAssertTrue(question.contains("Is anything standing in the marked area?"), question) + XCTAssertTrue(question.contains("inside the blue outline"), question) + XCTAssertFalse(element(app, "spot.area").exists, "the words-only area shows only without an outline") + attach(app, name: "spotConfirm-photo") + } + + @MainActor + func testWithoutAPhotoTheAreaIsGivenInWords() throws { + try checkInWords(["-uiDemoSpotNoPhoto"], name: "spotConfirm-noPhoto", spoken: Self.sampleLines) + try checkInWords(["-uiDemoSpotNoPhoto"] + Self.largestText, name: "spotConfirm-noPhoto-AX5", spoken: Self.sampleLines) + } + + /// A photo with no wall to draw the area with is shown as no photo: VoiceOver used to call + /// the words-only card "Photo of your wall" and describe an outline that wasn't drawn. + @MainActor + func testWithoutAWallThePhotoIsNotShownUnmarked() throws { + try checkInWords(["-uiDemoSpotNoWall"], name: "spotConfirm-noWall", spoken: Self.sampleLines) + } + + @MainActor + func testTheAreaInWordsNamesTheSideOfTheMeter() throws { + try checkInWords(["-uiDemoResultFile", Self.resultFile("spot-left"), "-uiDemoSpotNoPhoto"], name: "spotConfirm-noPhoto-left", + spoken: Lines(along: "From 3 feet 6 inches to 6 feet left of your meter", + out: "From the wall out to 2 feet", up: "From the ground up to 3 feet 6 inches")) + try checkInWords(["-uiDemoResultFile", Self.resultFile("spot-straddle"), "-uiDemoSpotNoPhoto"], name: "spotConfirm-noPhoto-straddle", + spoken: Lines(along: "From 1 foot 3 inches left to 1 foot 6 inches right of your meter", + out: "From the wall out to 2 feet", up: "From the ground up to 3 feet 6 inches")) + } + + /// Answering works the same without a photo: "Something's there" is taken and acknowledged. + @MainActor + func testTheAnswerIsTakenWithoutAPhoto() throws { + let app = launch(["-uiDemoSpotNoPhoto"]) + defer { app.terminate() } + let answer = app.buttons["action.spotSomethingThere"] + XCTAssertTrue(answer.waitForExistence(timeout: 10)) + let hittable = XCTNSPredicateExpectation(predicate: NSPredicate(format: "isHittable == true"), object: answer) + XCTAssertEqual(XCTWaiter().wait(for: [hittable], timeout: 10), .completed, "Something's there can't be tapped") + answer.tap() + let answered = element(app, "spot.answered") + XCTAssertTrue(answered.waitForExistence(timeout: 10)) + let label = ElementRead.snapshot(answered)?.label ?? "" + XCTAssertTrue(label.contains("Thanks, we'll leave that area out"), label) + } + + // MARK: - I can't check this area + + /// The three answers, in order, as each button reads. + private static let answers = [ + ("action.spotClear", "It's clear"), + ("action.spotSomethingThere", "Something's there"), + ("action.spotCannotCheck", "I can't check this area"), + ] + + /// Every answer can be reached and tapped, with and without a photo, at the default text size + /// and the largest: pinned under the photo at the default sizes, after the area at the + /// accessibility sizes, where the homeowner scrolls to them. + @MainActor + func testEveryAnswerIsReachable() throws { + let variants: [(name: String, arguments: [String])] = [ + ("spotConfirm-answers-photo", []), + ("spotConfirm-answers-noPhoto", ["-uiDemoSpotNoPhoto"]), + ("spotConfirm-answers-photo-AX5", Self.largestText), + ("spotConfirm-answers-noPhoto-AX5", ["-uiDemoSpotNoPhoto"] + Self.largestText), + ] + for variant in variants { + let app = launch(variant.arguments) + let window = app.windows.firstMatch.frame + for (id, title) in Self.answers { + let button = app.buttons[id] + XCTAssertTrue(button.waitForExistence(timeout: 10), "\(variant.name): \(id) is missing") + XCTAssertEqual(button.label, title, "\(variant.name): \(id)") + Self.scrollOnScreen(button, in: app) + XCTAssertTrue(button.isHittable, "\(variant.name): \(id) can't be tapped") + XCTAssertTrue(window.contains(button.frame), "\(variant.name): \(id) at \(button.frame) isn't on screen in \(window)") + // Apple's minimum target, whatever the text size. + XCTAssertGreaterThanOrEqual(button.frame.height, 44, "\(variant.name): \(id) is \(button.frame.height) pt tall") + } + attach(app, name: variant.name) + app.terminate() + } + } + + /// "I can't check this area" is taken, with or without a photo, and acknowledged as not + /// checked: never as something seen there, and never as a review someone has arranged. + @MainActor + func testCannotCheckIsAcknowledgedAsNotChecked() throws { + for (name, arguments) in [("spotConfirm-cannotCheck-photo", [String]()), ("spotConfirm-cannotCheck-noPhoto", ["-uiDemoSpotNoPhoto"])] { + let app = launch(arguments) + let answer = app.buttons["action.spotCannotCheck"] + XCTAssertTrue(answer.waitForExistence(timeout: 10), name) + let hittable = XCTNSPredicateExpectation(predicate: NSPredicate(format: "isHittable == true"), object: answer) + XCTAssertEqual(XCTWaiter().wait(for: [hittable], timeout: 10), .completed, "\(name): I can't check this area can't be tapped") + answer.tap() + let answered = element(app, "spot.answered") + XCTAssertTrue(answered.waitForExistence(timeout: 10), name) + let label = ElementRead.snapshot(answered)?.label ?? "" + XCTAssertTrue(label.contains("Thanks, we'll mark that area not checked"), "\(name): \(label)") + XCTAssertTrue(label.contains("Someone would need to check that area in person."), "\(name): \(label)") + for claim in ["leave that area out", "what's there", "installer"] { + XCTAssertFalse(label.contains(claim), "\(name): \(label)") + } + for (id, _) in Self.answers { + XCTAssertFalse(app.buttons[id].exists, "\(name): \(id) is still offered after the answer") + } + attach(app, name: name) + app.terminate() + } + } + + /// The result after "I can't check this area" says the area went unchecked and needs a + /// person, not that something stands there; at the largest text size too, with the notice + /// reached by scrolling. The other answers' result keeps its own notice, or none. + @MainActor + func testTheResultSaysTheAreaWasNotChecked() throws { + let notChecked = "You couldn't check the area around this spot, so your scan leaves it out as not checked. Someone would need to check it in person." + for (name, extra) in [("result-spotNotChecked", [String]()), ("result-spotNotChecked-AX5", Self.largestText)] { + let app = launchResult(answer: "cannotCheck", extra) + let notice = element(app, "result.spotNotChecked") + XCTAssertTrue(notice.waitForExistence(timeout: 10), "\(name): no notice") + // At AX5 the notice sits below the answer card; the homeowner scrolls to read it. + XCTAssertTrue(Self.canBeReadByScrolling(notice, in: app), "\(name): the notice at \(notice.frame) can't be scrolled onto the screen in \(app.windows.firstMatch.frame)") + XCTAssertEqual(ElementRead.snapshot(notice)?.label, notChecked, name) + XCTAssertFalse(element(app, "result.spotRefused").exists, "\(name): the result says something stands there") + attach(app, name: name) + let outcome = try AccessibilityAudit.run(app) { _ in Thread.sleep(forTimeInterval: 6) } + for (_, finding) in outcome.persistent { + XCTFail("\(name): \(finding.message)") + } + app.terminate() + } + let refused = launchResult(answer: "somethingThere", []) + XCTAssertTrue(element(refused, "result.spotRefused").waitForExistence(timeout: 10), "Something's there lost its notice") + XCTAssertFalse(element(refused, "result.spotNotChecked").exists) + XCTAssertFalse(element(refused, "result.scanNotChecked").exists) + refused.terminate() + let clear = launchResult(answer: "clear", []) + XCTAssertTrue(element(clear, "result.headline").waitForExistence(timeout: 10)) + for id in ["result.spotRefused", "result.spotNotChecked", "result.scanNotChecked"] { + XCTAssertFalse(element(clear, id).exists, "It's clear shows \(id)") + } + clear.terminate() + } + + /// After "I can't check this area", an answer without a spot (Fixtures/results/ + /// reject-nearest.json) still says an area along the wall went unchecked, without "this spot"; + /// at the largest text size too. After "Something's there" it says nothing of the kind. + @MainActor + func testAResultWithoutASpotStillSaysAnAreaWasNotChecked() throws { + let noSpot = ["-uiDemoResultFile", Self.resultFile("reject-nearest")] + let text = "You couldn't check an area along this wall, so your scan leaves it out as not checked. Someone would need to check it in person." + for (name, extra) in [("result-noSpot-scanNotChecked", noSpot), ("result-noSpot-scanNotChecked-AX5", noSpot + Self.largestText)] { + let app = launchResult(answer: "cannotCheck", extra) + let notice = element(app, "result.scanNotChecked") + XCTAssertTrue(notice.waitForExistence(timeout: 10), "\(name): no notice") + XCTAssertTrue(Self.canBeReadByScrolling(notice, in: app), "\(name): the notice at \(notice.frame) can't be scrolled onto the screen") + XCTAssertEqual(ElementRead.snapshot(notice)?.label, text, name) + XCTAssertFalse(element(app, "result.spotNotChecked").exists, "\(name): a result without a spot speaks of this spot") + XCTAssertFalse(element(app, "result.spotRefused").exists, name) + attach(app, name: name) + let outcome = try AccessibilityAudit.run(app) { _ in Thread.sleep(forTimeInterval: 6) } + for (_, finding) in outcome.persistent { + XCTFail("\(name): \(finding.message)") + } + app.terminate() + } + // An earlier area left unchecked, beside this spot's own "I can't check this area": both + // are said, the second as another area (Greptile on #206). + let both = launchResult(answer: "cannotCheck", ["-uiDemoUncheckedElsewhere"]) + let spotNotice = element(both, "result.spotNotChecked"), wallNotice = element(both, "result.scanNotChecked") + XCTAssertTrue(spotNotice.waitForExistence(timeout: 10), "the spot's notice is missing beside another unchecked area") + XCTAssertTrue(Self.canBeReadByScrolling(wallNotice, in: both), "the other area's notice can't be scrolled onto the screen") + XCTAssertEqual(ElementRead.snapshot(wallNotice)?.label, "You also couldn't check another area along this wall, so your scan leaves it out as not checked. Someone would need to check it in person.") + attach(both, name: "result-spotAndScanNotChecked") + both.terminate() + + let refused = launchResult(answer: "somethingThere", noSpot) + XCTAssertTrue(element(refused, "result.headline").waitForExistence(timeout: 10)) + for id in ["result.spotRefused", "result.spotNotChecked", "result.scanNotChecked"] { + XCTAssertFalse(element(refused, id).exists, "Something's there without a spot shows \(id)") + } + refused.terminate() + } + + // MARK: - Helpers + + /// The demo's result after the spot check was answered `answer`. + @MainActor + private func launchResult(answer: String, _ arguments: [String]) -> XCUIApplication { + let app = XCUIApplication() + app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoFreeze", "-uiDemoPhase", "result", "-uiDemoSpotAnswered", answer] + arguments + app.launch() + XCTAssertTrue(element(app, "screen.result").waitForExistence(timeout: 15), "the result never appeared") + return app + } + + /// The words-only spot check: no photo element, a question that names no outline, each of + /// the area's three lines rendered on screen with its spoken label, both answers present, and + /// a clean accessibility audit, which also checks those lines for contrast and clipping. + @MainActor + private func checkInWords(_ arguments: [String], name: String, spoken: Lines) throws { + let app = launch(arguments) + defer { app.terminate() } + guard element(app, "spot.area").waitForExistence(timeout: 10) else { + XCTFail("\(name): spot.area never appeared") + return + } + attach(app, name: name) + XCTAssertFalse(element(app, "spot.photo").exists, "\(name): no photo is shown without an outline on it") + XCTAssertEqual(ElementRead.snapshot(element(app, "spot.area.title"))?.label, "Where to look", name) + let window = app.windows.firstMatch.frame + for (id, expected) in [("along", spoken.along), ("out", spoken.out), ("up", spoken.up)] { + let line = element(app, "spot.area.\(id)") + // At AX5 the card can sit below the fold; the homeowner scrolls to it. + Self.scrollOnScreen(line, in: app) + let read = ElementRead.snapshot(line) + XCTAssertEqual(read?.label, expected, "\(name): spot.area.\(id)") + let frame = read?.frame ?? .zero + XCTAssertTrue(!frame.isEmpty && window.contains(frame), "\(name): spot.area.\(id) at \(frame) isn't on screen in \(window)") + } + let question = ElementRead.snapshot(element(app, "spot.question"))?.label ?? "" + XCTAssertTrue(question.contains("Is anything standing in this space?"), "\(name): \(question)") + XCTAssertTrue(question.contains("take a look yourself"), "\(name): \(question)") + // Nothing on screen may point at an outline that isn't drawn. Labels only: some elements' + // values aren't strings. + let outline = NSPredicate(format: "label CONTAINS[c] 'outline' OR label CONTAINS[c] 'marked area'") + XCTAssertEqual(app.descendants(matching: .any).matching(outline).count, 0, "\(name): something still mentions the outline") + XCTAssertTrue(app.buttons["action.spotClear"].exists, "\(name): It's clear is missing") + XCTAssertTrue(app.buttons["action.spotSomethingThere"].exists, "\(name): Something's there is missing") + XCTAssertTrue(app.buttons["action.spotCannotCheck"].exists, "\(name): I can't check this area is missing") + // As ScreenStatesUITests: an issue fails only when a second pass, after a system banner + // has had time to leave, finds it again. + let outcome = try AccessibilityAudit.run(app) { _ in Thread.sleep(forTimeInterval: 6) } + for (_, finding) in outcome.persistent { + XCTFail("\(name): \(finding.message)") + } + } + + @MainActor + private func launch(_ arguments: [String]) -> XCUIApplication { + let app = XCUIApplication() + app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoFreeze", "-uiDemoPhase", "spotConfirm"] + arguments + app.launch() + XCTAssertTrue(element(app, "screen.spotConfirm").waitForExistence(timeout: 15), "the spot check never appeared") + return app + } + + /// Scrolls the screen's scroll view until the whole of `element` is inside the window, the + /// same test the visibility assertions make. `isHittable` only needs the element's middle on + /// screen: at AX5 a line 249 pt tall passed it with its bottom 176 pt below the window (CI run + /// 37108821815). Each drag moves by the part that is off screen plus a margin, at most 60% of + /// the window, and holds at the end so no momentum carries the element past the other edge. + @MainActor + static func scrollOnScreen(_ element: XCUIElement, in app: XCUIApplication) { + let window = app.windows.firstMatch.frame + let scroll = app.scrollViews.firstMatch + let margin: CGFloat = 24 + let most = window.height * 0.6 + for _ in 0..<10 { + let frame = element.frame + guard !frame.isEmpty, !window.contains(frame) else { return } + // Negative drags move the content up, bringing what is below the window into view. + let drag = frame.maxY > window.maxY + ? -min(frame.maxY - window.maxY + margin, most) + : min(window.minY - frame.minY + margin, most) + let start = scroll.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)) + start.press(forDuration: 0.05, thenDragTo: start.withOffset(CGVector(dx: 0, dy: drag)), withVelocity: .slow, thenHoldForDuration: 0.3) + } + } + + /// Whether every line of `element` can be brought on screen. One that fits the window is + /// scrolled wholly onto it (`scrollOnScreen`). A taller one, such as a long notice at AX5, is + /// read the way a homeowner would: its top edge is scrolled on screen, then its bottom edge. + @MainActor + static func canBeReadByScrolling(_ element: XCUIElement, in app: XCUIApplication) -> Bool { + let window = app.windows.firstMatch.frame + guard element.frame.height > window.height else { + scrollOnScreen(element, in: app) + return window.contains(element.frame) + } + let scroll = app.scrollViews.firstMatch + func drag(_ distance: CGFloat) { + let start = scroll.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)) + start.press(forDuration: 0.05, thenDragTo: start.withOffset(CGVector(dx: 0, dy: distance)), withVelocity: .slow, thenHoldForDuration: 0.3) + } + let most = window.height * 0.6 + for _ in 0..<10 where element.frame.minY > window.maxY - 24 || element.frame.minY < window.minY { + let top = element.frame.minY + drag(top > window.minY ? -min(top - window.minY - 24, most) : min(window.minY - top + 24, most)) + } + guard window.minY...window.maxY ~= element.frame.minY else { return false } + for _ in 0..<10 where element.frame.maxY > window.maxY { + drag(-min(element.frame.maxY - window.maxY + 24, most)) + } + return window.minY...window.maxY ~= element.frame.maxY + } + + /// A server answer in Fixtures/results, which the demo reads in debug builds. + private static func resultFile(_ name: String, file: String = #filePath) -> String { + URL(fileURLWithPath: file).deletingLastPathComponent().appending(path: "Fixtures/results/\(name).json").path + } + + @MainActor + private func element(_ app: XCUIApplication, _ identifier: String) -> XCUIElement { + app.descendants(matching: .any)[identifier].firstMatch + } + + @MainActor + private func attach(_ app: XCUIApplication, name: String) { + let shot = XCTAttachment(screenshot: app.screenshot()) + shot.name = name + shot.lifetime = .keepAlways + add(shot) + } +} diff --git a/ios/HouseScanUITests/UploadRecoveryUITests.swift b/ios/HouseScanUITests/UploadRecoveryUITests.swift new file mode 100644 index 000000000..c375d52a4 --- /dev/null +++ b/ios/HouseScanUITests/UploadRecoveryUITests.swift @@ -0,0 +1,222 @@ +import CryptoKit +import Foundation +import Synchronization +import XCTest + +/// An answer House Scan can't use (it names another scene, or doesn't decode) is never shown, the +/// scan is kept, and only the homeowner's "Try again" asks again. A refused scan stays separate: +/// it goes back to the review (`ScreenStatesUITests.testRejectedUploadGoesBackToReview`). +final class UploadRecoveryUITests: XCTestCase { + override func setUp() { + continueAfterFailure = false + } + + /// The real engine and its HTTP client, with the test as the placement server + /// (`-answersFromGate`). The first answer names another scene. The second names the scene sent + /// but has a result schema House Scan doesn't read. Every later one is the bundled sample + /// naming the scene sent. The synthetic wall plays to the upload; neither unusable answer shows + /// anything of itself, nothing is sent again until "Try again", the second says asking again + /// might not help, and the third reaches the result. + @MainActor + func testUnusableAnswersAreHeldBackUntilTryAgain() throws { + let sample = String(decoding: try Data(contentsOf: Self.sampleResult), as: UTF8.self) + let zeros = String(repeating: "0", count: 64) + let schema = "\"schema_version\": \"1.0\"" + XCTAssertTrue(sample.contains("\"input_sha256\": \"\(zeros)\"")) + XCTAssertTrue(sample.contains(schema)) + + 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) } + // Every screen up to the result is let through; the result's gate stays shut, so the + // autopilot stops there. + for phase in ["onboarding", "findMeter", "meterCloseUp", "wallWalk", "markFeatures", "gapRequest", "uploading", "spotConfirm"] { + try Data().write(to: gate.appending(path: phase)) + } + let server = GateServer(gate: gate) { body, index in + var answer = sample.replacingOccurrences(of: zeros, with: index == 0 ? Self.sha256(Data("another scene".utf8)) : Self.sha256(body)) + if index == 1 { answer = answer.replacingOccurrences(of: schema, with: "\"schema_version\": \"9.0\"") } + return Data(answer.utf8) + } + defer { server.stop() } + + let app = XCUIApplication() + app.launchArguments = [ + "-replay", FullFlowUITests.fixture, "-autopilot", "-autopilotHold", "1.5", "-autopilotGate", gate.path, + "-practiceMeter", "NO", "-serverURL", "http://placement.invalid", "-answersFromGate", + ] + app.launch() + let any = app.descendants(matching: .any) + let note = any["upload.answerNote"] + let first = any.matching(NSPredicate(format: "label == %@", "We couldn't use the server's answer")).firstMatch + let second = any.matching(NSPredicate(format: "label == %@", "The answer still couldn't be used")).firstMatch + XCTAssertTrue(first.waitForExistence(timeout: 300), "the unbound answer never showed as an answer House Scan couldn't use") + XCTAssertEqual(server.requests.count, 1) + XCTAssertEqual(server.requests.first?.target, "POST /v1/placements") + holdsWithoutAsking(app, requests: 1, server: server, shot: "uploading-unusableAnswer-engine-1") + + tap(app, "action.retryUpload") + XCTAssertTrue(second.waitForExistence(timeout: 60), "the undecodable answer never showed as a second unusable answer") + XCTAssertTrue(note.exists) + XCTAssertEqual(server.requests.count, 2) + holdsWithoutAsking(app, requests: 2, server: server, shot: "uploading-unusableAnswer-engine-2") + + tap(app, "action.retryUpload") + XCTAssertTrue(any["screen.result"].waitForExistence(timeout: 240), "the answer after Try again never reached the result") + XCTAssertGreaterThanOrEqual(server.requests.count, 3) + XCTAssertTrue(server.requests.allSatisfy { $0.target == "POST /v1/placements" }) + } + + /// On an unusable answer: no "Back to review", a way to start over, and for six seconds no + /// request beyond `requests` and nothing of the answer on a later screen. + @MainActor + private func holdsWithoutAsking(_ app: XCUIApplication, requests: Int, server: GateServer, shot name: String) { + let any = app.descendants(matching: .any) + XCTAssertFalse(any["action.backToReview"].exists, "an unusable answer must not send the homeowner back to their marks") + XCTAssertTrue(any["action.startOver"].exists) + Thread.sleep(forTimeInterval: 6) + XCTAssertEqual(server.requests.count, requests, "the app asked again without a tap") + XCTAssertTrue(any["upload.answerNote"].exists) + XCTAssertFalse(any["screen.spotConfirm"].exists) + XCTAssertFalse(any["screen.result"].exists) + let shot = XCTAttachment(screenshot: app.screenshot()) + shot.name = name + shot.lifetime = .keepAlways + add(shot) + } + + /// The first unusable answer leads with "Try again"; the second says asking again might not + /// help and points to sharing the scan. Both pass the accessibility audit, and neither offers + /// the review. + @MainActor + func testRepeatedUnusableAnswersPointToAWayOut() throws { + for attempts in [1, 2] { + let app = XCUIApplication() + app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoFreeze", "-uiDemoPhase", "uploading", "-uiDemoUnusableAnswer", "\(attempts)"] + app.launch() + let any = app.descendants(matching: .any) + let note = any["upload.answerNote"] + XCTAssertTrue(note.waitForExistence(timeout: 15)) + let title = attempts == 1 ? "We couldn't use the server's answer" : "The answer still couldn't be used" + XCTAssertTrue(any.matching(NSPredicate(format: "label == %@", title)).firstMatch.exists, "attempt \(attempts): title") + XCTAssertEqual(note.label.contains("Share scan saves a copy"), attempts > 1, "attempt \(attempts): note reads \(note.label)") + // The frozen demo scan is packaged, so both notes point to Saved scans (#205), and the + // first still says closing the app ends trying again. + XCTAssertTrue(note.label.contains("from Saved scans on the first screen"), "attempt \(attempts): note reads \(note.label)") + XCTAssertEqual(note.label.contains("but not try again"), attempts == 1, "attempt \(attempts): note reads \(note.label)") + XCTAssertTrue(any["action.retryUpload"].exists) + XCTAssertTrue(any["action.startOver"].exists) + XCTAssertFalse(any["action.backToReview"].exists) + + Thread.sleep(forTimeInterval: 1) + let shot = XCTAttachment(screenshot: app.screenshot()) + shot.name = "uploading-unusableAnswer-\(attempts)" + shot.lifetime = .keepAlways + add(shot) + let outcome = try AccessibilityAudit.run(app) { _ in Thread.sleep(forTimeInterval: 2) } + XCTAssertTrue(outcome.persistent.isEmpty, "attempt \(attempts): \(outcome.persistent.map(\.finding.message))") + + tap(app, "action.retryUpload") + XCTAssertTrue(note.waitForNonExistence(timeout: 10), "Try again left the failure up") + app.terminate() + } + } + + /// The demo's answer after an unusable one is the result: "Try again" is the whole way on. + @MainActor + func testTryAgainAfterAnUnusableAnswerReachesTheResult() throws { + let app = XCUIApplication() + app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoPhase", "uploading", "-uiDemoUnusableAnswer", "-uiDemoPass"] + app.launch() + let any = app.descendants(matching: .any) + XCTAssertTrue(any["upload.answerNote"].waitForExistence(timeout: 30)) + tap(app, "action.retryUpload") + let spotClear = app.buttons["action.spotClear"] + let result = any["screen.result"] + let deadline = Date().addingTimeInterval(30) + while !result.exists, Date() < deadline { + if spotClear.exists, spotClear.isHittable { spotClear.tap() } + Thread.sleep(forTimeInterval: 0.5) + } + XCTAssertTrue(result.exists, "Try again never reached the result") + } + + static let sampleResult = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent().deletingLastPathComponent() + .appending(path: "HouseScan/Runtime/SampleResult.json") + + static func sha256(_ data: Data) -> String { + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } + + @MainActor + private func tap(_ app: XCUIApplication, _ identifier: String) { + let button = app.buttons[identifier] + let ready = XCTNSPredicateExpectation(predicate: NSPredicate(format: "isEnabled == true AND isHittable == true"), object: button) + XCTAssertEqual(XCTWaiter().wait(for: [ready], timeout: 20), .completed, "\(identifier) never became tappable") + button.tap() + } +} + +/// The placement server for `-answersFromGate`, answering through the gate folder +/// (`GateAnswerProtocol` in the app). For each `request-n.json` the app leaves there it writes +/// `answer-n.json`, built by `answer` from the request's body and n, and keeps the request for the +/// test. Requests are found by their numbers, not counted, so one the app failed to write doesn't +/// hold up the next. A background thread polls every 0.1 s until `stop()`. +final class GateServer: Sendable { + struct Request: Sendable { + var index: Int + var target: String + var body: Data + } + + private struct State { + var requests: [Request] = [] + var problems: [String] = [] + var stopped = false + } + + private let state = Mutex(State()) + private let finished = DispatchSemaphore(value: 0) + + var requests: [Request] { state.withLock { $0.requests } } + + init(gate: URL, answer: @escaping @Sendable (Data, Int) -> Data) { + Thread.detachNewThread { [self] in + defer { finished.signal() } + while !state.withLock({ $0.stopped }) { + let answered = Set(state.withLock { $0.requests.map(\.index) }) + let names = (try? FileManager.default.contentsOfDirectory(atPath: gate.path)) ?? [] + let waiting = names.compactMap(Self.requestIndex).filter { !answered.contains($0) }.sorted() + guard !waiting.isEmpty else { + Thread.sleep(forTimeInterval: 0.1) + continue + } + for index in waiting { + guard let body = try? Data(contentsOf: gate.appending(path: "request-\(index).json")) else { continue } + let target = (try? String(contentsOf: gate.appending(path: "request-\(index).target"), encoding: .utf8)) ?? "" + state.withLock { $0.requests.append(Request(index: index, target: target, body: body)) } + do { + try answer(body, index).write(to: gate.appending(path: "answer-\(index).json"), options: .atomic) + } catch { + state.withLock { $0.problems.append("couldn't write answer-\(index).json: \(error)") } + } + } + } + } + } + + /// Stops the thread and waits for it, so nothing is written into the gate folder once this + /// returns, then reports any answer it couldn't write as a failure of the calling test. + func stop() { + state.withLock { $0.stopped = true } + if finished.wait(timeout: .now() + 5) == .timedOut { XCTFail("the gate server's thread didn't stop") } + for problem in state.withLock({ $0.problems }) { XCTFail(problem) } + } + + private static func requestIndex(_ name: String) -> Int? { + guard name.hasPrefix("request-"), name.hasSuffix(".json") else { return nil } + return Int(name.dropFirst("request-".count).dropLast(".json".count)) + } +} diff --git a/ios/HouseScanUITests/WalkRecoveryUITests.swift b/ios/HouseScanUITests/WalkRecoveryUITests.swift new file mode 100644 index 000000000..3f2b45f07 --- /dev/null +++ b/ios/HouseScanUITests/WalkRecoveryUITests.swift @@ -0,0 +1,103 @@ +import XCTest + +/// Every step of the walk that asks something has a true answer, and a card that refuses says +/// what to do. Held still on the demo engine (`-uiDemoFreeze`), which mirrors the real engine's +/// choices; the real engine's own handling of each answer is covered by review and the replay +/// flows. +final class WalkRecoveryUITests: XCTestCase { + override func setUp() { + continueAfterFailure = false + } + + /// B-06: "Is this the right end of the wall?" had one answer, "Wall ends here", so a wall that + /// goes on had none. "The wall keeps going" ends the side where the walk reached without + /// asking what is there, and the walk goes on to the other side. + @MainActor + func testTheEndCardAnswersThatTheWallKeepsGoing() throws { + let app = launch(["-uiDemoPhase", "wallWalk", "-uiDemoMarkEnd"]) + XCTAssertTrue(label(app, "instruction").contains("Is this the right end of the wall?")) + snap(app, "wallWalk-markEnd-keepsGoing") + XCTAssertTrue(element(app, "action.markEnd").exists, "Wall ends here must stay on offer") + let keepsGoing = reply(app, "The wall keeps going") + XCTAssertTrue(keepsGoing.waitForExistence(timeout: 5), "the end card has no way to say the wall goes on") + tapWhenReady(keepsGoing) + let walkLeft = app.descendants(matching: .any) + .matching(NSPredicate(format: "identifier == 'instruction' AND label CONTAINS 'to your left'")).firstMatch + XCTAssertTrue(walkLeft.waitForExistence(timeout: 5), "the walk must go on to the left") + XCTAssertFalse(element(app, "action.endCorner").exists, "a wall that goes on has no end to ask about") + XCTAssertFalse(element(app, "action.markEnd").exists) + } + + /// B-06: "Wall ends here" with the circle off the wall did nothing. The card now says why and + /// what to do, and both answers stay. + @MainActor + func testARefusedWallEndSaysWhatToDo() throws { + let app = launch(["-uiDemoPhase", "wallWalk", "-uiDemoMarkEnd", "-uiDemoEndMarkRefusal"]) + let card = label(app, "instruction") + snap(app, "wallWalk-markEnd-refused") + XCTAssertTrue(card.contains("The circle isn't on the wall"), "card reads: \(card)") + XCTAssertTrue(card.contains("Aim it at the wall where it stops or turns"), "card reads: \(card)") + XCTAssertTrue(element(app, "action.markEnd").exists) + XCTAssertTrue(reply(app, "The wall keeps going").waitForExistence(timeout: 5)) + } + + /// B-23: "Point at the meter like this." needs the photo it points to. The follow-up view + /// shows the close-up as the walk does; without a close-up the card says it plainly. + @MainActor + func testLostPlaceSaysLikeThisOnlyWithThePhoto() throws { + let gap = launch(["-uiDemoPhase", "gapRequest", "-uiDemoCoaching", "relocalizing"], screen: "screen.gapRequest") + XCTAssertTrue(element(gap, "relocalize.meterPhoto").waitForExistence(timeout: 5), "the follow-up view must show the close-up") + XCTAssertTrue(label(gap, "instruction").contains("Point at the meter like this.")) + snap(gap, "gapRequest-relocalizing") + gap.terminate() + + let walk = launch(["-uiDemoPhase", "wallWalk", "-uiDemoCoaching", "relocalizing", "-uiDemoCloseUpSkipped"]) + let card = label(walk, "instruction") + XCTAssertFalse(element(walk, "relocalize.meterPhoto").exists) + snap(walk, "wallWalk-relocalizing-noCloseUp") + XCTAssertTrue(card.contains("Point back at your meter"), "card reads: \(card)") + XCTAssertFalse(card.contains("like this"), "no photo, so no \"like this\": \(card)") + } + + @MainActor + private func launch(_ arguments: [String], screen: String = "screen.wallWalk") -> XCUIApplication { + let app = XCUIApplication() + app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoFreeze"] + arguments + app.launch() + XCTAssertTrue(element(app, screen).waitForExistence(timeout: 15), "\(screen) never appeared") + XCTAssertTrue(element(app, "instruction").waitForExistence(timeout: 5)) + return app + } + + /// Kept in the result bundle for the PR's captures. + @MainActor + private func snap(_ app: XCUIApplication, _ name: String) { + let shot = XCTAttachment(screenshot: app.screenshot()) + shot.name = name + shot.lifetime = .keepAlways + add(shot) + } + + @MainActor + private func element(_ app: XCUIApplication, _ identifier: String) -> XCUIElement { + app.descendants(matching: .any)[identifier].firstMatch + } + + @MainActor + private func label(_ app: XCUIApplication, _ identifier: String) -> String { + ElementRead.snapshot(element(app, identifier))?.label ?? "" + } + + @MainActor + private func reply(_ app: XCUIApplication, _ title: String) -> XCUIElement { + app.buttons.matching(NSPredicate(format: "identifier == 'action.cannotAccess' AND label == %@", title)).firstMatch + } + + /// A new card's reply ignores taps for a moment (`InstructionCard.replyLock`). + @MainActor + private func tapWhenReady(_ target: XCUIElement) { + let ready = XCTNSPredicateExpectation(predicate: NSPredicate(format: "isEnabled == true AND isHittable == true"), object: target) + XCTAssertEqual(XCTWaiter().wait(for: [ready], timeout: 10), .completed, "\(target) never took taps") + target.tap() + } +} diff --git a/ios/README.md b/ios/README.md index f8323e881..601d2da9c 100644 --- a/ios/README.md +++ b/ios/README.md @@ -19,12 +19,15 @@ Native iPhone app for the AR capture walk. The homeowner marks the electric mete | `-autopilotHold ` | How long the autopilot leaves each screen up (default 1.2 s). | | `-autopilotCantGetThere` | With `-autopilot`, ends the walk the way device run 1 did. It answers "Can't get there" to the walk's first request, the ground in front of the meter, before the replay has shown it. Then, instead of marking the ends, it plays again the frame where the walk went farthest on each side and answers "Can't get there" when the walk asks to go on that way, so the end lands where the phone stands. `FullFlowUITests.testCantGetThereEndsWhereThePhoneIs` runs it. | | `-autopilotSomethingThere` | With `-autopilot`, answers the first spot check "Something's there" instead of "It's clear". The scan stops claiming that area, is uploaded again, and later checks get "It's clear". The stretch taken out goes to the `-autopilotGate` folder as `spot-refusal.json` (`withdrawn_span_ft`, feet along the wall as in scene.json). `FullFlowUITests.testSomethingThereChecksTheWallAgain` runs it. | +| `-autopilotCannotCheck` | With `-autopilot`, answers the first spot check "I can't check this area" instead. As with `-autopilotSomethingThere`, the scan stops claiming that area, is uploaded again, and the withdrawn stretch goes to `spot-refusal.json`; the result says the area wasn't checked. `SpotCannotCheckUITests` runs it. The two flags can't be combined. | +| `-sampleResultAfterSpotAnswer ` | Debug builds, with the bundled sample. The upload sent after "Something's there" or "I can't check this area", and every later upload of that scan, is answered with the server answer in this JSON file, such as one without a spot. Start over or a new wall goes back to the bundled sample. `SpotCannotCheckUITests` uses it. | | `-autopilotGate ` | Used by the UI tests. Before leaving a screen, the app waits for a file named after that phase in this folder, and while it waits it leaves `.held` there, so a test can audit the screen once the autopilot has finished with it. Once the result shows, the autopilot writes the scan's scene.json there. | | `-simulateAppStore` | Runs as an App Store install would: no developer options, and practice meter stays off whatever the stored switch says. `PracticeMeterUITests` uses it. | | `-practiceMeter YES` or `NO` | Overrides the stored practice meter switch for one run (UserDefaults' argument domain). The full-flow UI tests pass `NO`, since the switch outlives a run in the Simulator. | +| `-savedScansRoot ` | Debug builds only. Saved scans lists the scan folders in this folder instead of the app's Caches/Scans. It only changes what is listed; the scan store still writes and cleans up Caches/Scans. `SavedScansUITests` passes it with synthetic scans. | | `-uiDemo` | Runs the screens on a scripted fake engine instead of the capture engine, for design work and for auditing states a replay can't reach. The arguments it takes are listed in `HouseScan/UI/Preview/UIDemo.swift`; for example `-uiDemoGroundQuestion` opens the feature review with the ground question showing, `-uiDemoPhase meterCloseUp -uiDemoMeterChoose` shows the meter number picker, and `-uiDemoPhase result -uiDemoCorner` shows the result model of a wall that turns a corner. | -**Spot check.** Before the result, the app shows the kept photo that best sees the answer's spot, with the spot and its clearance area outlined, and asks whether anything stands in front of the wall or on the ground there (`STATE=spotConfirm`). Photos without depth and the walked path claim wall, ground and clear space they never saw; "It's clear" backs those claims for that spot, and "Something's there" takes them back over the area, so scene.json reports it unseen, and sends the scan again. The rules are in `Runtime/ScanEngine+Confirm.swift` and HouseScanKit's `Confirm/SpotConfirmation.swift`. +**Spot check.** Before the result, the app shows the kept photo that best sees the answer's spot, with the spot and its clearance area outlined, and asks whether anything stands in front of the wall or on the ground there (`STATE=spotConfirm`). When no kept photo shows the area, it shows no photo and gives the area in words: where it runs beside the meter, how far out from the wall, and how high up the wall. Photos without depth and the walked path claim wall, ground and clear space they never saw; "It's clear" backs those claims for that spot, and "Something's there" takes them back over the area, so scene.json reports it unseen, and sends the scan again. "I can't check this area", for a homeowner who can't see or reach it, does the same without saying anything stands there, and the result says the area wasn't checked and needs a person to check it. When the scan sent again names the same spot, the earlier answer settles it and the homeowner isn't asked again. When it names no spot, or another one, the result still says an area along the wall went unchecked, until Start over or a new wall clears the scan's spot checks. The rules are in `Runtime/ScanEngine+Confirm.swift` and HouseScanKit's `Confirm/SpotConfirmation.swift`. **Practice meter.** For trying the whole scan where there is no electric meter, such as indoors. Debug and TestFlight builds show a wrench button at the top of the first screen. Tap it, turn on **Practice meter**, tap Done, then start the scan. At "find the meter", tap any spot on a wall: the usual raycast and refusals apply, and a drawn sample meter appears there, fixed to the meter's anchor, on every camera screen after that. The close-up keeps the live camera's pose and depth, but stores a photo of the sample (made-up maker SAMPLEWORKS, number 12345678), and the real reader reads it. The walk, fog, marks, requests, spot check, upload, result and AR all run for real. Every screen shows a "Practice meter" badge, and `scan-stamp.json` has `"practice": true`; the packet and scene.json are unchanged. The switch is kept until turned off, and the button reads "Practice meter on" while it is. App Store installs never show the button and ignore a stored switch: the app asks StoreKit's `AppTransaction` which server signed the install (sandbox means TestFlight) and treats no answer as the App Store. It is not in the iOS Settings app, because a Settings.bundle ships in the same binary that TestFlight later promotes to the App Store. The code is in `Runtime/Practice/`, `UI/Practice/` and HouseScanKit's `Practice/PracticeMeter.swift`. @@ -95,7 +98,7 @@ Git ignores `Local.xcconfig`. Leave the team field in Xcode's Signing & Capabili **Logs.** Open Console.app on the Mac, select the iPhone, press Start and search for `subsystem:dev.housescanning.housescan`. Every screen change logs as `STATE=`; the `engine` category records the capture's decisions, the scan bundle and upload failures. The same lines show in Xcode's console while it runs the app. -**Getting a scan off the phone.** The result screen, and the screens for a failed or refused upload, have a Share scan button. It shares the scan bundle (below); AirDrop it to your Mac. It holds photos of a real home, so keep it out of git. +**Getting a scan off the phone.** The result screen, and the screens for a failed or refused upload, have a Share scan button. It shares the scan bundle (below); AirDrop it to your Mac. It holds photos of a real home, so keep it out of git. After the app is closed, **Saved scans** in the first screen's top bar lists the completed scans the phone still keeps (the two most recent whose `scan.zip` was written to the end, as `ScanFolderCleanup` keeps them) with when each bundle was saved, its size and a Practice tag when its `scan-stamp.json` says so. A bundle cut short by a quit during its write is not listed, holds no kept place and is not deleted. Share there sends the same bundle. It copies the bundle to the app's tmp folder first, so a new scan's cleanup can't delete it while the share sheet reads it, and deletes the copy when the sheet closes; a launch deletes copies earlier runs left. A saved scan can only be shared, never reopened or continued. The code is in `UI/SavedScans/` and HouseScanKit's `SavedScans/`. ## Scan bundle