From eab22a473421371a57999224051bc6362e523bac Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Fri, 2 Oct 2026 13:17:22 -0500 Subject: [PATCH 01/50] ios: a catalog lists the completed scans the cleanup keeps, and stages one for sharing SavedScanCatalog lists the folders ScanFolderCleanup keeps as completed, newest first, with each bundle's saved time, size and the stamp's practice flag when it is a JSON boolean. A bundle counts only when it was written to the end (PacketArchiveCheck): a quit during the in-place zip write leaves a partial scan.zip under the completed name. SavedScanStaging copies a bundle out of the scan folder for the share sheet, so a cleanup can't delete it mid-share, and removes only its own copies. Links are never scans. Co-Authored-By: Claude Code --- .../SavedScans/PacketArchiveCheck.swift | 116 ++++++++ .../HouseScanKit/SavedScans/SavedScan.swift | 45 +++ .../SavedScans/SavedScanCatalog.swift | 133 +++++++++ .../SavedScanCatalogTests.swift | 256 ++++++++++++++++++ 4 files changed, 550 insertions(+) create mode 100644 ios/HouseScanKit/Sources/HouseScanKit/SavedScans/PacketArchiveCheck.swift create mode 100644 ios/HouseScanKit/Sources/HouseScanKit/SavedScans/SavedScan.swift create mode 100644 ios/HouseScanKit/Sources/HouseScanKit/SavedScans/SavedScanCatalog.swift create mode 100644 ios/HouseScanKit/Tests/HouseScanKitTests/SavedScanCatalogTests.swift diff --git a/ios/HouseScanKit/Sources/HouseScanKit/SavedScans/PacketArchiveCheck.swift b/ios/HouseScanKit/Sources/HouseScanKit/SavedScans/PacketArchiveCheck.swift new file mode 100644 index 000000000..de51b2599 --- /dev/null +++ b/ios/HouseScanKit/Sources/HouseScanKit/SavedScans/PacketArchiveCheck.swift @@ -0,0 +1,116 @@ +import Foundation + +/// Whether a file is a whole capture-packet zip, 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 another zip under the name 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 is not a defense against a crafted +/// zip. Only the app writes into its scan folders. +/// +/// 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..52b75a017 --- /dev/null +++ b/ios/HouseScanKit/Sources/HouseScanKit/SavedScans/SavedScanCatalog.swift @@ -0,0 +1,133 @@ +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 with a +/// `scan.zip`, 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. On top of that rule a scan is listed only when its bundle is a whole capture +/// packet (`PacketArchiveCheck`), since a scan quit while its bundle was being written leaves a +/// partial `scan.zip` behind. +/// +/// 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. +/// On APFS the copy is a clone, so it costs no space until one side changes. Only this type +/// creates or deletes anything under `root`; the scan folders are never written. +public struct SavedScanStaging: Sendable { + public let root: URL + + public init(root: URL) { + self.root = root + } + + /// 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 = root.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 == root.standardizedFileURL.path else { return } + try? FileManager.default.removeItem(at: folder) + } + + /// Deletes every copy, for when no share sheet is open: copies a share left when the app was + /// quit with the sheet up. + public func removeAll() { + try? FileManager.default.removeItem(at: root) + } +} diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/SavedScanCatalogTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/SavedScanCatalogTests.swift new file mode 100644 index 000000000..a246abb5c --- /dev/null +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/SavedScanCatalogTests.swift @@ -0,0 +1,256 @@ +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; `removeAll` clears every copy and nothing else. + @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.removeAll() + #expect(!files.fileExists(atPath: staging.root.path)) + #expect(files.fileExists(atPath: folder.appending(path: ScanFolderCleanup.bundleName).path)) + } + + // 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") + } +} From 0d3ba37a034adc2ac8998ae649c5853951fb5533 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Fri, 2 Oct 2026 14:02:43 -0500 Subject: [PATCH 02/50] ios: Saved scans on the onboarding shares a kept scan after the app is reopened (#130) The onboarding's top bar opens Saved scans: the completed scans the cleanup keeps, each with its saved time, size and a Practice tag only when its stamp says so. Share copies the bundle to tmp and hands the copy to the system share sheet, which deletes it when it closes; a scan gone since the list was made gets an alert and drops out. Nothing is sent before Share, and a saved scan is never reopened or continued. The onboarding holds the sheet's state: the top bar swaps layouts at accessibility text sizes, and a sheet owned by the button closed when the text size changed. The empty state scrolls, so its text isn't cut off at large sizes in the half-height sheet. Co-Authored-By: Claude Code --- ios/HouseScan/Runtime/KeyframeStore.swift | 9 +- .../UI/SavedScans/ActivitySheet.swift | 23 ++ .../UI/SavedScans/SavedScanRow.swift | 97 +++++++++ .../UI/SavedScans/SavedScansButton.swift | 33 +++ .../UI/SavedScans/SavedScansCopy.swift | 62 ++++++ .../UI/SavedScans/SavedScansLocation.swift | 22 ++ .../UI/SavedScans/SavedScansSheet.swift | 177 ++++++++++++++++ .../UI/Screens/OnboardingScreen.swift | 8 +- ios/HouseScanUITests/SavedScansUITests.swift | 199 ++++++++++++++++++ ios/README.md | 3 +- 10 files changed, 629 insertions(+), 4 deletions(-) create mode 100644 ios/HouseScan/UI/SavedScans/ActivitySheet.swift create mode 100644 ios/HouseScan/UI/SavedScans/SavedScanRow.swift create mode 100644 ios/HouseScan/UI/SavedScans/SavedScansButton.swift create mode 100644 ios/HouseScan/UI/SavedScans/SavedScansCopy.swift create mode 100644 ios/HouseScan/UI/SavedScans/SavedScansLocation.swift create mode 100644 ios/HouseScan/UI/SavedScans/SavedScansSheet.swift create mode 100644 ios/HouseScanUITests/SavedScansUITests.swift diff --git a/ios/HouseScan/Runtime/KeyframeStore.swift b/ios/HouseScan/Runtime/KeyframeStore.swift index b081627b1..77df294d3 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 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..99700441a --- /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, so it says nothing about a + /// real wall. + static let practiceDetail = "Sample meter, not a real wall" + 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..c86fa965b --- /dev/null +++ b/ios/HouseScan/UI/SavedScans/SavedScansLocation.swift @@ -0,0 +1,22 @@ +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. + static let staging = SavedScanStaging( + root: FileManager.default.temporaryDirectory.appending(path: "SavedScanShares", directoryHint: .isDirectory)) +} diff --git a/ios/HouseScan/UI/SavedScans/SavedScansSheet.swift b/ios/HouseScan/UI/SavedScans/SavedScansSheet.swift new file mode 100644 index 000000000..ae6199a5e --- /dev/null +++ b/ios/HouseScan/UI/SavedScans/SavedScansSheet.swift @@ -0,0 +1,177 @@ +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 } + } + + var body: some View { + NavigationStack { + content + .navigationTitle(SavedScansCopy.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() } + .disabled(preparing != nil) + .accessibilityIdentifier("action.closeSavedScans") + } + } + } + // A copy being made belongs to the share sheet that follows it; closing now would leave + // the copy with no sheet to delete it. Making it takes a moment (a clone on APFS). + .interactiveDismissDisabled(preparing != nil) + .presentationDetents([.medium, .large]) + .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") + } + + @ViewBuilder + private var content: some View { + if let scans { + if scans.isEmpty { + // Not ContentUnavailableView: it doesn't scroll, so at large text sizes in the + // half-height sheet its description was cut off. + ScrollView { + VStack(spacing: 12) { + Image(systemName: "tray") + .font(.system(size: 44, weight: .regular)) + .foregroundStyle(Palette.muted) + .accessibilityHidden(true) + Text(SavedScansCopy.emptyTitle) + .font(Typeface.sectionTitle) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + .accessibilityAddTraits(.isHeader) + Text(SavedScansCopy.emptyDetail) + .font(Typeface.hint) + .foregroundStyle(Palette.muted) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.horizontal, 32) + .padding(.vertical, 40) + .frame(maxWidth: .infinity) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("savedScans.empty") + } + .scrollBounceBehavior(.basedOnSize) + } else { + List { + Section { + ForEach(scans) { scan in + SavedScanRow(scan: scan, isPreparing: preparing == scan.id, isBusy: preparing != nil) { + share(scan) + } + } + } header: { + Text(SavedScansCopy.intro) + .textCase(nil) + .font(.footnote) + .fixedSize(horizontal: false, vertical: true) + .foregroundStyle(Palette.muted) + .padding(.bottom, 4) + .accessibilityIdentifier("savedScans.intro") + } footer: { + Label(SavedScansCopy.shareFooter, systemImage: "lock.fill") + .font(.footnote) + .fixedSize(horizontal: false, vertical: true) + .foregroundStyle(Palette.muted) + .accessibilityIdentifier("savedScans.privacy") + } + } + .listStyle(.insetGrouped) + } + } else { + // A list read takes milliseconds; a spinner would only flash. + Color.clear + } + } + + /// No share sheet is open while this sheet opens, so copies a quit left behind go first. + private func open() async { + let catalog = catalog, staging = staging + let found = await Task.detached(priority: .userInitiated) { () -> [SavedScan] in + staging.removeAll() + 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/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/HouseScanUITests/SavedScansUITests.swift b/ios/HouseScanUITests/SavedScansUITests.swift new file mode 100644 index 000000000..fffd1ee9f --- /dev/null +++ b/ios/HouseScanUITests/SavedScansUITests.swift @@ -0,0 +1,199 @@ +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") + 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/README.md b/ios/README.md index f8323e881..3678de848 100644 --- a/ios/README.md +++ b/ios/README.md @@ -22,6 +22,7 @@ Native iPhone app for the AR capture walk. The homeowner marks the electric mete | `-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`. @@ -95,7 +96,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, as `ScanFolderCleanup` keeps them) with when each bundle was saved, its size and a Practice tag when its `scan-stamp.json` says so. 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 saved scan can only be shared, never reopened or continued. The code is in `UI/SavedScans/` and HouseScanKit's `SavedScans/`. ## Scan bundle From aeae9dcec3ce3331eea2ddec4b300a55e21f3c84 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Fri, 2 Oct 2026 14:09:06 -0500 Subject: [PATCH 03/50] ios: a practice scan's row says its meter was a sample, not that its wall wasn't real Practice mode draws a sample meter, and the walk scans a real wall, so "not a real wall" was false. The share copy is not assumed to be a free clone: it can copy every byte and fail on a full disk (copyFailed). Co-Authored-By: Claude Code --- ios/HouseScan/UI/SavedScans/SavedScansCopy.swift | 6 +++--- ios/HouseScan/UI/SavedScans/SavedScansSheet.swift | 2 +- .../Sources/HouseScanKit/SavedScans/SavedScanCatalog.swift | 3 ++- ios/HouseScanUITests/SavedScansUITests.swift | 7 +++++++ 4 files changed, 13 insertions(+), 5 deletions(-) diff --git a/ios/HouseScan/UI/SavedScans/SavedScansCopy.swift b/ios/HouseScan/UI/SavedScans/SavedScansCopy.swift index 99700441a..158394571 100644 --- a/ios/HouseScan/UI/SavedScans/SavedScansCopy.swift +++ b/ios/HouseScan/UI/SavedScans/SavedScansCopy.swift @@ -26,9 +26,9 @@ enum SavedScansCopy { static let share = "Share" static let practice = "Practice" - /// Under a practice scan: a drawn sample stood in for the meter, so it says nothing about a - /// real wall. - static let practiceDetail = "Sample meter, not a real wall" + /// 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 { diff --git a/ios/HouseScan/UI/SavedScans/SavedScansSheet.swift b/ios/HouseScan/UI/SavedScans/SavedScansSheet.swift index ae6199a5e..1f6d7def1 100644 --- a/ios/HouseScan/UI/SavedScans/SavedScansSheet.swift +++ b/ios/HouseScan/UI/SavedScans/SavedScansSheet.swift @@ -41,7 +41,7 @@ struct SavedScansSheet: View { } } // A copy being made belongs to the share sheet that follows it; closing now would leave - // the copy with no sheet to delete it. Making it takes a moment (a clone on APFS). + // the copy with no sheet to delete it. .interactiveDismissDisabled(preparing != nil) .presentationDetents([.medium, .large]) .task { await open() } diff --git a/ios/HouseScanKit/Sources/HouseScanKit/SavedScans/SavedScanCatalog.swift b/ios/HouseScanKit/Sources/HouseScanKit/SavedScans/SavedScanCatalog.swift index 52b75a017..1a54fd12f 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/SavedScans/SavedScanCatalog.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/SavedScans/SavedScanCatalog.swift @@ -76,7 +76,8 @@ public enum SavedScanShareError: Error, Equatable, Sendable { /// /// 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. -/// On APFS the copy is a clone, so it costs no space until one side changes. Only this type +/// 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 diff --git a/ios/HouseScanUITests/SavedScansUITests.swift b/ios/HouseScanUITests/SavedScansUITests.swift index fffd1ee9f..74b8bff59 100644 --- a/ios/HouseScanUITests/SavedScansUITests.swift +++ b/ios/HouseScanUITests/SavedScansUITests.swift @@ -34,6 +34,13 @@ final class SavedScansUITests: XCTestCase { 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). From 429448d7ecc4cf398a7d56673404f7e4dfa05422 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Fri, 2 Oct 2026 14:49:52 -0500 Subject: [PATCH 04/50] ios: Saved scans is an opaque full-height column in the app's own type, not a system list Hosted run 37051620836's audit failed the half-height sheet. The onboarding showed through its glass behind the intro and footer (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 bottom. The sheet is now a scrolling column on the canvas, with the rows on a surface card and the title and Done in the app's fonts, like the onboarding. Co-Authored-By: Claude Code --- .../UI/SavedScans/SavedScansSheet.swift | 142 ++++++++++-------- 1 file changed, 81 insertions(+), 61 deletions(-) diff --git a/ios/HouseScan/UI/SavedScans/SavedScansSheet.swift b/ios/HouseScan/UI/SavedScans/SavedScansSheet.swift index 1f6d7def1..864b30f8a 100644 --- a/ios/HouseScan/UI/SavedScans/SavedScansSheet.swift +++ b/ios/HouseScan/UI/SavedScans/SavedScansSheet.swift @@ -27,23 +27,30 @@ struct SavedScansSheet: View { 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 { - NavigationStack { - content - .navigationTitle(SavedScansCopy.title) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button("Done") { dismiss() } - .disabled(preparing != nil) - .accessibilityIdentifier("action.closeSavedScans") - } - } + 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) - .presentationDetents([.medium, .large]) .task { await open() } .sheet(item: $sharing, onDismiss: finishSharing) { copy in ActivitySheet(file: copy.url) { sharing = nil } @@ -62,66 +69,79 @@ struct SavedScansSheet: View { .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 { - // Not ContentUnavailableView: it doesn't scroll, so at large text sizes in the - // half-height sheet its description was cut off. - ScrollView { - VStack(spacing: 12) { - Image(systemName: "tray") - .font(.system(size: 44, weight: .regular)) - .foregroundStyle(Palette.muted) - .accessibilityHidden(true) - Text(SavedScansCopy.emptyTitle) - .font(Typeface.sectionTitle) - .multilineTextAlignment(.center) - .fixedSize(horizontal: false, vertical: true) - .accessibilityAddTraits(.isHeader) - Text(SavedScansCopy.emptyDetail) - .font(Typeface.hint) - .foregroundStyle(Palette.muted) - .multilineTextAlignment(.center) - .fixedSize(horizontal: false, vertical: true) - } - .padding(.horizontal, 32) - .padding(.vertical, 40) - .frame(maxWidth: .infinity) - .accessibilityElement(children: .contain) - .accessibilityIdentifier("savedScans.empty") + 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) } - .scrollBounceBehavior(.basedOnSize) + .padding(.horizontal, 12) + .padding(.top, 48) + .frame(maxWidth: .infinity) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("savedScans.empty") } else { - List { - Section { - ForEach(scans) { scan in - SavedScanRow(scan: scan, isPreparing: preparing == scan.id, isBusy: preparing != nil) { - share(scan) - } + 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) } - } header: { - Text(SavedScansCopy.intro) - .textCase(nil) - .font(.footnote) - .fixedSize(horizontal: false, vertical: true) - .foregroundStyle(Palette.muted) - .padding(.bottom, 4) - .accessibilityIdentifier("savedScans.intro") - } footer: { - Label(SavedScansCopy.shareFooter, systemImage: "lock.fill") - .font(.footnote) - .fixedSize(horizontal: false, vertical: true) - .foregroundStyle(Palette.muted) - .accessibilityIdentifier("savedScans.privacy") + .padding(.vertical, 8) } } - .listStyle(.insetGrouped) + .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") } - } else { - // A list read takes milliseconds; a spinner would only flash. - Color.clear } + // Until the first read finishes there is nothing: it takes milliseconds, and a spinner + // would only flash. } /// No share sheet is open while this sheet opens, so copies a quit left behind go first. From 769af49e6789cf29f2df15792e853c71a7522dd5 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Fri, 2 Oct 2026 16:30:53 -0500 Subject: [PATCH 05/50] ios: launch deletes share copies an earlier run left, keeping any made since A share sheet open when the app is killed never reports back, so its copy stayed in tmp until Saved scans was opened again. Launch now removes the copies made before it; a share started right after launch keeps its own. Co-Authored-By: Claude Code --- ios/HouseScan/HouseScanApp.swift | 5 +++++ .../SavedScans/SavedScanCatalog.swift | 12 ++++++++++++ .../SavedScanCatalogTests.swift | 18 ++++++++++++++++++ 3 files changed, 35 insertions(+) diff --git a/ios/HouseScan/HouseScanApp.swift b/ios/HouseScan/HouseScanApp.swift index a136ba964..fa4830ad5 100644 --- a/ios/HouseScan/HouseScanApp.swift +++ b/ios/HouseScan/HouseScanApp.swift @@ -13,6 +13,11 @@ struct HouseScanApp: App { } // Compiles the live fog's shaders in the background long before the walk needs them. .task { LiveFogSupport.shared.prepare() } + // Share copies an earlier run left behind (`SavedScanStaging.removeCopies`). + .task { + let launch = Date(), staging = SavedScansLocation.staging + await Task.detached(priority: .utility) { staging.removeCopies(madeBefore: launch) }.value + } } } } diff --git a/ios/HouseScanKit/Sources/HouseScanKit/SavedScans/SavedScanCatalog.swift b/ios/HouseScanKit/Sources/HouseScanKit/SavedScans/SavedScanCatalog.swift index 1a54fd12f..6315fc90d 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/SavedScans/SavedScanCatalog.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/SavedScans/SavedScanCatalog.swift @@ -131,4 +131,16 @@ public struct SavedScanStaging: Sendable { public func removeAll() { try? FileManager.default.removeItem(at: root) } + + /// Deletes the copies made before `date`, leaving any made since. At launch, with `date` the + /// launch time, that is every copy an earlier run left (a share sheet open when the app was + /// killed never reports back), while a share started right after launch keeps its copy. + public func removeCopies(madeBefore date: Date) { + let files = FileManager.default + guard let folders = try? files.contentsOfDirectory(at: root, includingPropertiesForKeys: [.creationDateKey]) else { return } + for folder in folders { + let made = (try? folder.resourceValues(forKeys: [.creationDateKey]))?.creationDate ?? .distantPast + if made < date { try? files.removeItem(at: folder) } + } + } } diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/SavedScanCatalogTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/SavedScanCatalogTests.swift index a246abb5c..1a41170b5 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/SavedScanCatalogTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/SavedScanCatalogTests.swift @@ -241,6 +241,24 @@ import Testing #expect(files.fileExists(atPath: folder.appending(path: ScanFolderCleanup.bundleName).path)) } + /// At launch, copies an earlier run left go, and one made since launch stays. + @Test func launchRemovesOnlyCopiesMadeBeforeIt() throws { + defer { cleanUp() } + try scan("one") + let staging = SavedScanStaging(root: root.appending(path: "share")) + let listed = try #require(SavedScanCatalog(root: root).scans().first) + let left = try staging.stage(listed) + try files.setAttributes([.creationDate: Date(timeIntervalSinceNow: -3600)], ofItemAtPath: left.deletingLastPathComponent().path) + let launch = Date(timeIntervalSinceNow: -60) + let current = try staging.stage(listed) + + staging.removeCopies(madeBefore: launch) + #expect(!files.fileExists(atPath: left.path)) + #expect(files.fileExists(atPath: current.path)) + #expect(files.fileExists(atPath: listed.archive.path)) + SavedScanStaging(root: root.appending(path: "absent")).removeCopies(madeBefore: launch) + } + // MARK: Names @Test func theShareNameSaysWhenAndWhetherPractice() { From 09ed76dd25e6c315da45a9417def5511ba8b61e6 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Fri, 2 Oct 2026 16:47:01 -0500 Subject: [PATCH 06/50] ios: the launch cutoff for old share copies is taken once per process, in the app's init A window's .task runs again for each window or reappearance, and a later cutoff could cover a copy this run is still sharing. Co-Authored-By: Claude Code --- ios/HouseScan/HouseScanApp.swift | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/ios/HouseScan/HouseScanApp.swift b/ios/HouseScan/HouseScanApp.swift index fa4830ad5..db0d4f728 100644 --- a/ios/HouseScan/HouseScanApp.swift +++ b/ios/HouseScan/HouseScanApp.swift @@ -2,6 +2,15 @@ import SwiftUI @main struct HouseScanApp: App { + /// Runs once per process, before any window exists: share copies made before this moment + /// belong to an earlier run, whose share sheet can't still be reading them. Not a view's + /// `.task`, which runs again for each window or reappearance and would take a later cutoff, + /// one that could cover a copy this run is sharing. + init() { + let launch = Date(), staging = SavedScansLocation.staging + Task.detached(priority: .utility) { staging.removeCopies(madeBefore: launch) } + } + var body: some Scene { WindowGroup { Group { @@ -13,11 +22,6 @@ struct HouseScanApp: App { } // Compiles the live fog's shaders in the background long before the walk needs them. .task { LiveFogSupport.shared.prepare() } - // Share copies an earlier run left behind (`SavedScanStaging.removeCopies`). - .task { - let launch = Date(), staging = SavedScansLocation.staging - await Task.detached(priority: .utility) { staging.removeCopies(madeBefore: launch) }.value - } } } } From 96abec13f842f8200d39487b793bdd4c9a975f96 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Fri, 2 Oct 2026 17:01:13 -0500 Subject: [PATCH 07/50] ios: a partial scan.zip no longer takes a kept place or gets an intact scan deleted ScanFolderCleanup counted any scan.zip as a completed scan, so a bundle cut short by a quit during its in-place write held one of the two kept places and an intact older scan was deleted instead. Completed now means PacketArchiveCheck finds a whole packet, still the same file after the check. A partial or unreadable bundle holds no place and its folder is left alone at any age: nothing proves its writer has stopped. Retention count, order, listing at construction and the current-folder guard are unchanged. Co-Authored-By: Claude Code --- .../SavedScans/SavedScanCatalog.swift | 11 +- .../Scene/ScanFolderCleanup.swift | 84 ++++++++++-- .../HouseScanKitTests/KeptScansTests.swift | 123 +++++++++++++++++- 3 files changed, 197 insertions(+), 21 deletions(-) diff --git a/ios/HouseScanKit/Sources/HouseScanKit/SavedScans/SavedScanCatalog.swift b/ios/HouseScanKit/Sources/HouseScanKit/SavedScans/SavedScanCatalog.swift index 6315fc90d..58486394b 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/SavedScans/SavedScanCatalog.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/SavedScans/SavedScanCatalog.swift @@ -2,12 +2,11 @@ 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 with a -/// `scan.zip`, 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. On top of that rule a scan is listed only when its bundle is a whole capture -/// packet (`PacketArchiveCheck`), since a scan quit while its bundle was being written leaves a -/// partial `scan.zip` behind. +/// 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 diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Scene/ScanFolderCleanup.swift b/ios/HouseScanKit/Sources/HouseScanKit/Scene/ScanFolderCleanup.swift index 3bd3bbd3b..4a12be079 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Scene/ScanFolderCleanup.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Scene/ScanFolderCleanup.swift @@ -2,11 +2,25 @@ 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 @@ -18,27 +32,73 @@ public struct ScanFolderCleanup: Sendable { /// 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] /// 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 + var completed: [(name: String, date: Date)] = [] + var incomplete: [String] = [] + for name in names { + // A plain file beside the scan folders holds no bundle; it goes, as before. + if (try? files.attributesOfItem(atPath: root.appending(path: name).path))?[.type] as? FileAttributeType == .typeRegular { continue } 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) + switch Self.bundleState(bundle) { + case .absent: + continue + case .complete(let date): + completed.append((name, date)) + case .incomplete: + incomplete.append(name) + } } - .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) } + 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) } obsolete = names.filter { !keep.contains($0) }.map { root.appending(path: $0) } } + private enum BundleState { + case absent + case complete(savedAt: Date) + 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. + private static func bundleState(_ bundle: URL) -> 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 + } + guard (try? PacketArchiveCheck.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) + } + /// Deletes the listed folders; returns each one that could not be deleted with the reason. A /// folder already gone is not an error. public func run() -> [(url: URL, error: any Error)] { diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/KeptScansTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/KeptScansTests.swift index 6f164478f..4d89ebaa9 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,112 @@ 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, holds no place + /// and is 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")) + + let cleanup = ScanFolderCleanup(root: root, keeping: "now") + #expect(Self.names(cleanup.keptCompleted) == ["intact1", "intact2"]) + #expect(Set(Self.names(cleanup.incompleteBundles)) == ["locked", "linked", "folderBundle"]) + // 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"]) + } + + /// 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"]) + } + + /// 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 { From 897ef2a615ada4852d1162da08c3f5d32420c253 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Fri, 2 Oct 2026 17:05:24 -0500 Subject: [PATCH 08/50] ios: bundleName's comment says a completed scan needs a whole packet, not just the file Co-Authored-By: Claude Code --- .../Sources/HouseScanKit/Scene/ScanFolderCleanup.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Scene/ScanFolderCleanup.swift b/ios/HouseScanKit/Sources/HouseScanKit/Scene/ScanFolderCleanup.swift index 4a12be079..a0b8cc787 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Scene/ScanFolderCleanup.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Scene/ScanFolderCleanup.swift @@ -27,7 +27,8 @@ import Foundation /// 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. From 362700f176941c53473bb7c4fcf45ebef1830c19 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Fri, 2 Oct 2026 17:52:31 -0500 Subject: [PATCH 09/50] ios: a test replaces scan.zip during the archive check and expects it not complete The file-number and size comparison had no test that swaps the file mid-check, so it could stop catching a retry's rewrite unnoticed. The check is injectable within the module for that test. Co-Authored-By: Claude Code --- .../Scene/ScanFolderCleanup.swift | 7 +++--- .../HouseScanKitTests/KeptScansTests.swift | 24 +++++++++++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Scene/ScanFolderCleanup.swift b/ios/HouseScanKit/Sources/HouseScanKit/Scene/ScanFolderCleanup.swift index a0b8cc787..e54028c78 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Scene/ScanFolderCleanup.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Scene/ScanFolderCleanup.swift @@ -67,7 +67,7 @@ public struct ScanFolderCleanup: Sendable { obsolete = names.filter { !keep.contains($0) }.map { root.appending(path: $0) } } - private enum BundleState { + enum BundleState: Equatable { case absent case complete(savedAt: Date) case incomplete @@ -82,7 +82,8 @@ public struct ScanFolderCleanup: Sendable { /// 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. - private static func bundleState(_ bundle: URL) -> BundleState { + /// `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 { @@ -92,7 +93,7 @@ public struct ScanFolderCleanup: Sendable { } catch { return .incomplete } - guard (try? PacketArchiveCheck.verify(bundle)) != nil, + guard (try? verify(bundle)) != nil, let after = try? files.attributesOfItem(atPath: bundle.path), let number = before[.systemFileNumber] as? UInt64, after[.systemFileNumber] as? UInt64 == number, diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/KeptScansTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/KeptScansTests.swift index 4d89ebaa9..ea792ce85 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/KeptScansTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/KeptScansTests.swift @@ -144,6 +144,30 @@ import Testing #expect(try Set(files.contentsOfDirectory(atPath: root.path)) == ["intact1", "intact2", "locked", "linked", "folderBundle"]) } + /// 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 { From 95eee1ed295751e1de28d815fd6b1af151dc7f40 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Fri, 2 Oct 2026 17:55:56 -0500 Subject: [PATCH 10/50] ios: each app run stages share copies in its own folder; launch deletes the others A launch-time cutoff depended on the clock: set back between runs, an earlier run's copy looked newer and stayed. Each run now stages under a session folder of its own, and launch, or opening Saved scans, deletes the other sessions' folders, so no copy a share in progress is reading can match. removeAll is gone with its last caller. Co-Authored-By: Claude Code --- ios/HouseScan/HouseScanApp.swift | 11 +++--- .../UI/SavedScans/SavedScansLocation.swift | 6 ++-- .../UI/SavedScans/SavedScansSheet.swift | 5 +-- .../SavedScans/SavedScanCatalog.swift | 35 ++++++++++--------- .../SavedScanCatalogTests.swift | 34 +++++++++++------- 5 files changed, 51 insertions(+), 40 deletions(-) diff --git a/ios/HouseScan/HouseScanApp.swift b/ios/HouseScan/HouseScanApp.swift index db0d4f728..1818e887c 100644 --- a/ios/HouseScan/HouseScanApp.swift +++ b/ios/HouseScan/HouseScanApp.swift @@ -2,13 +2,12 @@ import SwiftUI @main struct HouseScanApp: App { - /// Runs once per process, before any window exists: share copies made before this moment - /// belong to an earlier run, whose share sheet can't still be reading them. Not a view's - /// `.task`, which runs again for each window or reappearance and would take a later cutoff, - /// one that could cover a copy this run is sharing. + /// 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 launch = Date(), staging = SavedScansLocation.staging - Task.detached(priority: .utility) { staging.removeCopies(madeBefore: launch) } + let staging = SavedScansLocation.staging + Task.detached(priority: .utility) { staging.removeOtherSessions() } } var body: some Scene { diff --git a/ios/HouseScan/UI/SavedScans/SavedScansLocation.swift b/ios/HouseScan/UI/SavedScans/SavedScansLocation.swift index c86fa965b..508ba5bf9 100644 --- a/ios/HouseScan/UI/SavedScans/SavedScansLocation.swift +++ b/ios/HouseScan/UI/SavedScans/SavedScansLocation.swift @@ -16,7 +16,9 @@ enum SavedScansLocation { } /// 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. + /// 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)) + 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 index 864b30f8a..c8add1fb3 100644 --- a/ios/HouseScan/UI/SavedScans/SavedScansSheet.swift +++ b/ios/HouseScan/UI/SavedScans/SavedScansSheet.swift @@ -144,11 +144,12 @@ struct SavedScansSheet: View { // would only flash. } - /// No share sheet is open while this sheet opens, so copies a quit left behind go first. + /// 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.removeAll() + staging.removeOtherSessions() return catalog.scans() }.value scans = found diff --git a/ios/HouseScanKit/Sources/HouseScanKit/SavedScans/SavedScanCatalog.swift b/ios/HouseScanKit/Sources/HouseScanKit/SavedScans/SavedScanCatalog.swift index 6315fc90d..422ae9a87 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/SavedScans/SavedScanCatalog.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/SavedScans/SavedScanCatalog.swift @@ -81,9 +81,17 @@ public enum SavedScanShareError: Error, Equatable, Sendable { /// 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) { + 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()`. @@ -99,7 +107,7 @@ public struct SavedScanStaging: Sendable { } catch { throw failure(error) } - let folder = root.appending(path: UUID().uuidString, directoryHint: .isDirectory) + let folder = sessionRoot.appending(path: UUID().uuidString, directoryHint: .isDirectory) let copy = folder.appending(path: scan.shareFileName()) do { try files.createDirectory(at: folder, withIntermediateDirectories: true) @@ -122,25 +130,18 @@ public struct SavedScanStaging: Sendable { /// is left alone. public func remove(_ copy: URL) { let folder = copy.deletingLastPathComponent().standardizedFileURL - guard folder.deletingLastPathComponent().path == root.standardizedFileURL.path else { return } + guard folder.deletingLastPathComponent().path == sessionRoot.standardizedFileURL.path else { return } try? FileManager.default.removeItem(at: folder) } - /// Deletes every copy, for when no share sheet is open: copies a share left when the app was - /// quit with the sheet up. - public func removeAll() { - try? FileManager.default.removeItem(at: root) - } - - /// Deletes the copies made before `date`, leaving any made since. At launch, with `date` the - /// launch time, that is every copy an earlier run left (a share sheet open when the app was - /// killed never reports back), while a share started right after launch keeps its copy. - public func removeCopies(madeBefore date: Date) { + /// 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 folders = try? files.contentsOfDirectory(at: root, includingPropertiesForKeys: [.creationDateKey]) else { return } - for folder in folders { - let made = (try? folder.resourceValues(forKeys: [.creationDateKey]))?.creationDate ?? .distantPast - if made < date { try? files.removeItem(at: folder) } + 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/Tests/HouseScanKitTests/SavedScanCatalogTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/SavedScanCatalogTests.swift index 1a41170b5..cb7635da7 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/SavedScanCatalogTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/SavedScanCatalogTests.swift @@ -223,7 +223,7 @@ import Testing #expect((try? files.contentsOfDirectory(atPath: staging.root.path))?.isEmpty ?? true) } - /// `remove` deletes only folders it made; `removeAll` clears every copy and nothing else. + /// `remove` deletes only folders it made, and removing copies never touches a scan. @Test func removalStaysInsideTheStagingFolder() throws { defer { cleanUp() } let folder = try scan("keep") @@ -236,27 +236,35 @@ import Testing let first = try staging.stage(listed) let second = try staging.stage(listed) #expect(first != second) - staging.removeAll() - #expect(!files.fileExists(atPath: staging.root.path)) + 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, copies an earlier run left go, and one made since launch stays. - @Test func launchRemovesOnlyCopiesMadeBeforeIt() throws { + /// 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 staging = SavedScanStaging(root: root.appending(path: "share")) + 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 staging.stage(listed) - try files.setAttributes([.creationDate: Date(timeIntervalSinceNow: -3600)], ofItemAtPath: left.deletingLastPathComponent().path) - let launch = Date(timeIntervalSinceNow: -60) - let current = try staging.stage(listed) + 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) - staging.removeCopies(madeBefore: launch) + current.removeOtherSessions() #expect(!files.fileExists(atPath: left.path)) - #expect(files.fileExists(atPath: current.path)) + #expect(files.fileExists(atPath: mine.path)) #expect(files.fileExists(atPath: listed.archive.path)) - SavedScanStaging(root: root.appending(path: "absent")).removeCopies(madeBefore: launch) + // 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() } // MARK: Names From 74640d5bff8bbd7023345f2214104080413633c6 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Fri, 2 Oct 2026 18:56:38 -0500 Subject: [PATCH 11/50] ios: only a real scan folder is judged by its bundle; a link in its place holds no kept place A top-level link to a scan folder resolved to that scan's bundle, so the link and its target could take both kept places and an intact scan was deleted. A link, or an entry whose type can't be read, now holds no place and is left alone; a plain file still goes, as before. Co-Authored-By: Claude Code --- .../HouseScanKit/Scene/ScanFolderCleanup.swift | 13 +++++++++++-- .../Tests/HouseScanKitTests/KeptScansTests.swift | 10 ++++++---- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Scene/ScanFolderCleanup.swift b/ios/HouseScanKit/Sources/HouseScanKit/Scene/ScanFolderCleanup.swift index e54028c78..450c56d49 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Scene/ScanFolderCleanup.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Scene/ScanFolderCleanup.swift @@ -47,8 +47,17 @@ public struct ScanFolderCleanup: Sendable { var completed: [(name: String, date: Date)] = [] var incomplete: [String] = [] for name in names { - // A plain file beside the scan folders holds no bundle; it goes, as before. - if (try? files.attributesOfItem(atPath: root.appending(path: name).path))?[.type] as? FileAttributeType == .typeRegular { continue } + // Only a real folder is judged by its bundle. A plain file beside the scan folders holds + // no bundle and goes, as before. A link (attributesOfItem doesn't follow one) would + // reach another folder's bundle, so it and its target could take both kept places; + // it, and an entry whose type can't be read, hold no place and are left alone. + switch (try? files.attributesOfItem(atPath: root.appending(path: name).path))?[.type] as? FileAttributeType { + case .typeDirectory?: break + case .typeRegular?: continue + default: + incomplete.append(name) + continue + } let bundle = root.appending(path: name).appending(path: Self.bundleName) switch Self.bundleState(bundle) { case .absent: diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/KeptScansTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/KeptScansTests.swift index ea792ce85..084628d49 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/KeptScansTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/KeptScansTests.swift @@ -113,8 +113,8 @@ import Testing #expect(Self.names(later.obsolete) == ["intact2"]) } - /// A folder whose bundle can't be read, or whose bundle is a link or a folder, holds no place - /// and is not deleted. + /// 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 @@ -134,14 +134,16 @@ import Testing 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"]) + #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"]) + #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 From 60e11c20f5ce89d8d5dca9b6a06a88b7e39c4bfb Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 3 Oct 2026 02:25:34 -0500 Subject: [PATCH 12/50] ios: tests pin share-copy ownership across sheets in one run and across a new scan's cleanup Two sheets in one process share a staging session, so opening one never deletes the copy the other is sharing. A copy being shared keeps the bundle's bytes after a new scan's cleanup deletes its source folder. Co-Authored-By: Claude Code --- .../SavedScanCatalogTests.swift | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/SavedScanCatalogTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/SavedScanCatalogTests.swift index cb7635da7..27fcef093 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/SavedScanCatalogTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/SavedScanCatalogTests.swift @@ -267,6 +267,46 @@ import Testing 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() { From 9945acb88a57fb78871d74b724f35ae7123fb984 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 3 Oct 2026 02:26:56 -0500 Subject: [PATCH 13/50] ios: a failed upload's note says Saved scans can still share the saved file after the app closes With the scan packaged, the note now says that closing the app ends the retry but not the sharing: Saved scans on the first screen offers the same bundle. It promises no reopening or retry after close. The note without a bundle is unchanged. The test assertion moves with it. Written with the backend owner's handoff of these lines. Co-Authored-By: Claude Code --- ios/HouseScan/UI/Screens/UploadingScreen.swift | 8 +++++--- ios/HouseScanUITests/ScreenStatesUITests.swift | 10 +++++----- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/ios/HouseScan/UI/Screens/UploadingScreen.swift b/ios/HouseScan/UI/Screens/UploadingScreen.swift index 9cf8b9d92..9c535a3cf 100644 --- a/ios/HouseScan/UI/Screens/UploadingScreen.swift +++ b/ios/HouseScan/UI/Screens/UploadingScreen.swift @@ -48,11 +48,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) diff --git a/ios/HouseScanUITests/ScreenStatesUITests.swift b/ios/HouseScanUITests/ScreenStatesUITests.swift index 3002e501d..7bf03e93b 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) From cf30991ce8bdef598ee1f3847eeb69a51313e5ef Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 3 Oct 2026 02:26:57 -0500 Subject: [PATCH 14/50] ios: the walk's end card has a true answer for a wall that goes on, and refusals say what to do At 20 ft walked the card asks "Is this the right end of the wall?" and offered only "Wall ends here" at the circle. A wall running on had no true answer, and the card stayed as the homeowner kept walking. Its reply is now "The wall keeps going": the end goes where the phone stands, unexplored, through the same path as "Can't get there" on the walk, and nothing asks what is there (B-06). "Wall ends here" at the circle did nothing when the circle was off the wall or the phone had lost its place, and aiming across the meter ended the other side. The button now marks only the side the card names, and otherwise the card says why: the circle isn't on the wall, that's the other side of the meter, or the phone is still finding its place. A tap at a point (the autopilot) marks as before. A feature mark refused because the phone stands behind the wall's line blamed the tap; it now says to step out in front of the wall (B-27). "Point at the meter like this." shows only with the saved close-up, which the follow-up view now shows as the walk does (B-23). WalkRecoveryUITests covers the three cards on the demo engine. Co-Authored-By: Claude Code --- ios/HouseScan/Contract/ScanContract.swift | 15 ++++ .../Runtime/ScanEngine+Actions.swift | 21 ++++- ios/HouseScan/Runtime/ScanEngine+Ends.swift | 6 ++ ios/HouseScan/Runtime/ScanEngine.swift | 1 + ios/HouseScan/UI/Copy/ScanCopy.swift | 37 ++++++-- ios/HouseScan/UI/Preview/DemoEngine.swift | 26 +++++- ios/HouseScan/UI/Preview/UIDemo.swift | 3 + .../UI/Screens/GapRequestScreen.swift | 15 +++- ios/HouseScan/UI/Screens/WallWalkScreen.swift | 7 +- .../WalkRecoveryUITests.swift | 90 +++++++++++++++++++ 10 files changed, 211 insertions(+), 10 deletions(-) create mode 100644 ios/HouseScanUITests/WalkRecoveryUITests.swift diff --git a/ios/HouseScan/Contract/ScanContract.swift b/ios/HouseScan/Contract/ScanContract.swift index 705c3e30e..114222f23 100644 --- a/ios/HouseScan/Contract/ScanContract.swift +++ b/ios/HouseScan/Contract/ScanContract.swift @@ -553,6 +553,17 @@ 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: Equatable, Sendable { + /// The circle isn't on the wall within the distance a camera's view counts for + /// (`CoverageConfig.maxDistance`): on the ground, the sky, or far down a long wall. + case noWall + /// The circle is on the wall on this side of the meter, not the side the card asks about. + case otherSide(WallSide) + case trackingNotReady +} + enum MarkRefusal: Equatable, Sendable { case noSurface case wrongSide @@ -867,6 +878,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`. diff --git a/ios/HouseScan/Runtime/ScanEngine+Actions.swift b/ios/HouseScan/Runtime/ScanEngine+Actions.swift index a2f858dc2..e4afa8cf0 100644 --- a/ios/HouseScan/Runtime/ScanEngine+Actions.swift +++ b/ios/HouseScan/Runtime/ScanEngine+Actions.swift @@ -139,12 +139,26 @@ 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 } + var asked: WallSide? + if point == nil, state.phase == .wallWalk, case .markEnd(let side) = state.guidance { asked = side } + if asked != nil, frame.tracking != .normal { return refuseEndMark(.trackingNotReady) } + guard let hit = wallHit(point, viewSize: viewSize, frame: frame, wall: wall) else { + if asked != nil { refuseEndMark(.noWall) } + return + } let side: WallSide = hit.s < 0 ? .left : .right + if let asked, side != asked { return refuseEndMark(.otherSide(side)) } 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 +167,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 diff --git a/ios/HouseScan/Runtime/ScanEngine+Ends.swift b/ios/HouseScan/Runtime/ScanEngine+Ends.swift index 5fda30149..81d45aea1 100644 --- a/ios/HouseScan/Runtime/ScanEngine+Ends.swift +++ b/ios/HouseScan/Runtime/ScanEngine+Ends.swift @@ -104,6 +104,12 @@ extension ScanEngine { 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() { diff --git a/ios/HouseScan/Runtime/ScanEngine.swift b/ios/HouseScan/Runtime/ScanEngine.swift index 388f6e516..93eddd955 100644 --- a/ios/HouseScan/Runtime/ScanEngine.swift +++ b/ios/HouseScan/Runtime/ScanEngine.swift @@ -1844,6 +1844,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))") diff --git a/ios/HouseScan/UI/Copy/ScanCopy.swift b/ios/HouseScan/UI/Copy/ScanCopy.swift index 904b82fc8..dcbae5da5 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: @@ -361,7 +365,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 +385,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 +503,10 @@ 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 + /// phone stands, unexplored, exactly as "Can't get there" on the walk puts it. static func reply(for step: GuidanceStep) -> (title: String, hint: String)? { switch step { case .aimAtGround, .aimAtWall: @@ -496,7 +519,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 you're standing. An installer would need to look at the wall past it.") + case .findMeter, .aimAtWallForMeter, .holdOnMeter, .stepBack, .walkComplete, .gap: nil } } diff --git a/ios/HouseScan/UI/Preview/DemoEngine.swift b/ios/HouseScan/UI/Preview/DemoEngine.swift index d5f913929..4f9b882ef 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). @@ -66,6 +68,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") @@ -110,6 +113,14 @@ 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") { state.endMarkRefusal = .noWall } + } if arguments.contains("-uiDemoEndPreview") { walkedBack = 1.5 refreshGuidance() @@ -286,7 +297,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 +305,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 @@ -790,6 +804,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 +992,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) diff --git a/ios/HouseScan/UI/Preview/UIDemo.swift b/ios/HouseScan/UI/Preview/UIDemo.swift index 3ee3c0a47..4054fb811 100644 --- a/ios/HouseScan/UI/Preview/UIDemo.swift +++ b/ios/HouseScan/UI/Preview/UIDemo.swift @@ -23,6 +23,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 diff --git a/ios/HouseScan/UI/Screens/GapRequestScreen.swift b/ios/HouseScan/UI/Screens/GapRequestScreen.swift index f97618264..b73fb7af7 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 state.coaching == .relocalizing, 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: state.coaching == .relocalizing) } /// "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,13 @@ 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 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/WallWalkScreen.swift b/ios/HouseScan/UI/Screens/WallWalkScreen.swift index cefd9f266..405b0683e 100644 --- a/ios/HouseScan/UI/Screens/WallWalkScreen.swift +++ b/ios/HouseScan/UI/Screens/WallWalkScreen.swift @@ -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 } diff --git a/ios/HouseScanUITests/WalkRecoveryUITests.swift b/ios/HouseScanUITests/WalkRecoveryUITests.swift new file mode 100644 index 000000000..ce3006f58 --- /dev/null +++ b/ios/HouseScanUITests/WalkRecoveryUITests.swift @@ -0,0 +1,90 @@ +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?")) + 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") + 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.")) + gap.terminate() + + let walk = launch(["-uiDemoPhase", "wallWalk", "-uiDemoCoaching", "relocalizing", "-uiDemoCloseUpSkipped"]) + let card = label(walk, "instruction") + XCTAssertFalse(element(walk, "relocalize.meterPhoto").exists) + 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 + } + + @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() + } +} From 72e5d1b3e6acf0af1e1a5b05be557e41c63aee58 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 3 Oct 2026 02:34:36 -0500 Subject: [PATCH 15/50] Let the homeowner ask again after an answer House Scan can't use An answer that doesn't decode or doesn't name the scene sent (ResultBinding) used to show "We couldn't check this scan" with "Back to review", as if the marks were wrong, and offered no way to send the same scan again. It now has its own upload state: the answer is never shown, the scan is kept, and only the homeowner's "Try again" asks again. From the second such answer the screen says asking again might not help and points to sharing the scan or starting over. A 4xx refusal still goes back to the review. A reply that isn't HTTP counts as an unreachable server, so the words never say a server answered when none did. The transport moves into HouseScanKit (PlacementHTTPClient) unchanged, so the package tests drive it through URLSession against a server on 127.0.0.1. A UI test runs the real engine against the same kind of server: the first answer names another scene, and the test checks that nothing more is sent and nothing reaches a later screen until Try again. Co-Authored-By: Claude Opus 5.5 (1M context) --- ios/HouseScan/Contract/ScanContract.swift | 10 +- ios/HouseScan/Runtime/ResultClient.swift | 96 +++----- .../Runtime/ScanEngine+Actions.swift | 7 +- ios/HouseScan/Runtime/ScanEngine.swift | 12 +- ios/HouseScan/UI/Copy/ScanCopy.swift | 9 +- ios/HouseScan/UI/Preview/DemoEngine.swift | 14 +- ios/HouseScan/UI/Preview/UIDemo.swift | 2 + .../UI/Screens/UploadingScreen.swift | 64 +++++- .../Upload/PlacementHTTPClient.swift | 80 +++++++ .../Upload/UploadFailureKind.swift | 27 ++- .../LoopbackPlacementServer.swift | 83 +++++++ .../PlacementHTTPClientTests.swift | 120 ++++++++++ .../ResultBindingTests.swift | 4 +- .../UploadFailureKindTests.swift | 17 +- .../UploadRecoveryUITests.swift | 212 ++++++++++++++++++ 15 files changed, 661 insertions(+), 96 deletions(-) create mode 100644 ios/HouseScanKit/Sources/HouseScanKit/Upload/PlacementHTTPClient.swift create mode 100644 ios/HouseScanKit/Tests/HouseScanKitTests/LoopbackPlacementServer.swift create mode 100644 ios/HouseScanKit/Tests/HouseScanKitTests/PlacementHTTPClientTests.swift create mode 100644 ios/HouseScanUITests/UploadRecoveryUITests.swift diff --git a/ios/HouseScan/Contract/ScanContract.swift b/ios/HouseScan/Contract/ScanContract.swift index 705c3e30e..8b09e4391 100644 --- a/ios/HouseScan/Contract/ScanContract.swift +++ b/ios/HouseScan/Contract/ScanContract.swift @@ -627,10 +627,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 } diff --git a/ios/HouseScan/Runtime/ResultClient.swift b/ios/HouseScan/Runtime/ResultClient.swift index 567884fc2..e363889b8 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) { 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) } 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 @@ -159,7 +117,7 @@ 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 } + 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..144597dac 100644 --- a/ios/HouseScan/Runtime/ScanEngine+Actions.swift +++ b/ios/HouseScan/Runtime/ScanEngine+Actions.swift @@ -561,9 +561,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.swift b/ios/HouseScan/Runtime/ScanEngine.swift index 388f6e516..a52e61288 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(_:)`). @@ -514,7 +517,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 @@ -1675,7 +1678,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 @@ -1999,6 +2002,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 +2085,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 +2117,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() } } diff --git a/ios/HouseScan/UI/Copy/ScanCopy.swift b/ios/HouseScan/UI/Copy/ScanCopy.swift index 904b82fc8..3e438490f 100644 --- a/ios/HouseScan/UI/Copy/ScanCopy.swift +++ b/ios/HouseScan/UI/Copy/ScanCopy.swift @@ -563,7 +563,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 +586,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) } diff --git a/ios/HouseScan/UI/Preview/DemoEngine.swift b/ios/HouseScan/UI/Preview/DemoEngine.swift index d5f913929..70b79c0d9 100644 --- a/ios/HouseScan/UI/Preview/DemoEngine.swift +++ b/ios/HouseScan/UI/Preview/DemoEngine.swift @@ -21,6 +21,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 +53,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. @@ -76,6 +79,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 @@ -199,11 +203,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) } @@ -543,6 +549,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) { diff --git a/ios/HouseScan/UI/Preview/UIDemo.swift b/ios/HouseScan/UI/Preview/UIDemo.swift index 3ee3c0a47..ecdf9e551 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. diff --git a/ios/HouseScan/UI/Screens/UploadingScreen.swift b/ios/HouseScan/UI/Screens/UploadingScreen.swift index 9cf8b9d92..bee6c59de 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 @@ -65,6 +68,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 +99,52 @@ 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 steps down to a secondary button and the note points to sharing +/// the scan, so the homeowner isn't left pressing the same button. 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(.secondary) + } 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") + } + } + + private var note: String { + switch (repeated, shareableScan != nil) { + case (false, true): "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." + 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. House Scan can't reopen this scan after you close the app." + 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 +166,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 +179,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 +225,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/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/LoopbackPlacementServer.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/LoopbackPlacementServer.swift new file mode 100644 index 000000000..4c0f9d79d --- /dev/null +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/LoopbackPlacementServer.swift @@ -0,0 +1,83 @@ +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. +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 = { [queue] connection in + connection.start(queue: queue) + Self.receive(connection, buffer: Data()) { request in self.answer(request, on: connection) } + } + listener.start(queue: queue) + guard ready.wait(timeout: .now() + 5) == .success, listener.port != nil else { 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/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/UploadRecoveryUITests.swift b/ios/HouseScanUITests/UploadRecoveryUITests.swift new file mode 100644 index 000000000..6e6bc13f6 --- /dev/null +++ b/ios/HouseScanUITests/UploadRecoveryUITests.swift @@ -0,0 +1,212 @@ +import CryptoKit +import Foundation +import Network +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 against a placement server on 127.0.0.1. The first answer names another + /// scene; every later one names the scene it was sent. The synthetic wall plays to the upload, + /// the unbound answer shows nothing of itself, nothing is sent again until "Try again", and + /// the answer after that reaches the result. + @MainActor + func testAnAnswerForAnotherSceneIsHeldBackUntilTryAgain() throws { + let sample = try Data(contentsOf: Self.sampleResult) + let zeros = String(repeating: "0", count: 64) + XCTAssertTrue(String(decoding: sample, as: UTF8.self).contains("\"input_sha256\": \"\(zeros)\"")) + let server = try PlacementStub { request, index in + let named = index == 0 ? Self.sha256(Data("another scene".utf8)) : Self.sha256(request.body) + return Data(String(decoding: sample, as: UTF8.self).replacingOccurrences(of: zeros, with: named).utf8) + } + + 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 app = XCUIApplication() + app.launchArguments = [ + "-replay", FullFlowUITests.fixture, "-autopilot", "-autopilotHold", "1.5", "-autopilotGate", gate.path, + "-practiceMeter", "NO", "-serverURL", server.base.absoluteString, + ] + app.launch() + let any = app.descendants(matching: .any) + let note = any["upload.answerNote"] + XCTAssertTrue(note.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?.path, "/v1/placements") + XCTAssertTrue(any.matching(NSPredicate(format: "label == %@", "We couldn't use the server's answer")).firstMatch.exists) + XCTAssertFalse(any["action.backToReview"].exists, "an unusable answer must not send the homeowner back to their marks") + XCTAssertTrue(any["action.startOver"].exists) + + // Nothing asks again on its own, and nothing of the answer reaches a later screen. + Thread.sleep(forTimeInterval: 6) + XCTAssertEqual(server.requests.count, 1, "the app asked again without a tap") + XCTAssertTrue(note.exists) + XCTAssertFalse(any["screen.spotConfirm"].exists) + XCTAssertFalse(any["screen.result"].exists) + + let shot = XCTAttachment(screenshot: app.screenshot()) + shot.name = "uploading-unusableAnswer-engine" + shot.lifetime = .keepAlways + add(shot) + + tap(app, "action.retryUpload") + XCTAssertTrue(any["screen.result"].waitForExistence(timeout: 240), "the answer after Try again never reached the result") + XCTAssertGreaterThanOrEqual(server.requests.count, 2) + XCTAssertTrue(server.requests.allSatisfy { $0.method == "POST" && $0.path == "/v1/placements" }) + } + + /// 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)") + 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() + } +} + +/// A placement server on 127.0.0.1 that the app in the Simulator reaches like any other: every +/// request is answered 200 with the body `answer` builds from it and its index, and kept for the +/// test. It speaks only what the app sends: one request per connection with a `Content-Length` +/// body, and it closes the connection after replying. +final class PlacementStub: Sendable { + struct Request: Sendable { + var method: String + var path: String + var body: Data + } + + private let listener: NWListener + private let queue = DispatchQueue(label: "placement-stub") + private let answer: @Sendable (Request, Int) -> Data + 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(answer: @escaping @Sendable (Request, Int) -> Data) throws { + self.answer = answer + 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 = { [queue] connection in + connection.start(queue: queue) + Self.receive(connection, buffer: Data()) { request in self.reply(to: request, on: connection) } + } + listener.start(queue: queue) + guard ready.wait(timeout: .now() + 5) == .success, listener.port != nil else { throw URLError(.cannotConnectToHost) } + } + + deinit { listener.cancel() } + + private func reply(to request: Request, on connection: NWConnection) { + let index = log.withLock { log in + log.append(request) + return log.count - 1 + } + let body = answer(request, index) + let head = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: \(body.count)\r\nConnection: close\r\n\r\n" + connection.send(content: Data(head.utf8) + 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 << 22) { 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 length = 0 + for field in head.dropFirst() { + guard let colon = field.firstIndex(of: ":"), field[..= length else { return nil } + return Request(method: String(line[0]), path: String(line[1]), body: Data(body.prefix(length))) + } +} From 21abb9d650939dcc730e1581446a93a5521ad64d Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 3 Oct 2026 02:42:12 -0500 Subject: [PATCH 16/50] Close the test servers' listeners and test the engine's repeat count Both loopback servers retained themselves through their listener's connection handler, so a dropped server never closed its listener. The handlers now hold the server weakly, and the UI test's stub also closes on stop(). The engine test now sends two unusable answers before a usable one: the first names another scene, and the second names the scene sent but has a result schema House Scan doesn't read. That covers the engine's decode failure and its count. The test checks the second state's wording and that no request goes out between taps. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../LoopbackPlacementServer.swift | 16 +++- .../UploadRecoveryUITests.swift | 77 +++++++++++++------ 2 files changed, 64 insertions(+), 29 deletions(-) diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/LoopbackPlacementServer.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/LoopbackPlacementServer.swift index 4c0f9d79d..e795715b1 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/LoopbackPlacementServer.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/LoopbackPlacementServer.swift @@ -5,7 +5,8 @@ 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. +/// 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 @@ -36,12 +37,19 @@ final class LoopbackPlacementServer: Sendable { listener = try NWListener(using: parameters) let ready = DispatchSemaphore(value: 0) listener.stateUpdateHandler = { if case .ready = $0 { ready.signal() } } - listener.newConnectionHandler = { [queue] connection in + listener.newConnectionHandler = { [weak self, queue] connection in + guard self != nil else { return connection.cancel() } connection.start(queue: queue) - Self.receive(connection, buffer: Data()) { request in self.answer(request, on: connection) } + 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 { throw URLError(.cannotConnectToHost) } + guard ready.wait(timeout: .now() + 5) == .success, listener.port != nil else { + listener.cancel() + throw URLError(.cannotConnectToHost) + } } deinit { listener.cancel() } diff --git a/ios/HouseScanUITests/UploadRecoveryUITests.swift b/ios/HouseScanUITests/UploadRecoveryUITests.swift index 6e6bc13f6..f4dfcdd8a 100644 --- a/ios/HouseScanUITests/UploadRecoveryUITests.swift +++ b/ios/HouseScanUITests/UploadRecoveryUITests.swift @@ -13,18 +13,23 @@ final class UploadRecoveryUITests: XCTestCase { } /// The real engine against a placement server on 127.0.0.1. The first answer names another - /// scene; every later one names the scene it was sent. The synthetic wall plays to the upload, - /// the unbound answer shows nothing of itself, nothing is sent again until "Try again", and - /// the answer after that reaches the result. + /// 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 testAnAnswerForAnotherSceneIsHeldBackUntilTryAgain() throws { - let sample = try Data(contentsOf: Self.sampleResult) + func testUnusableAnswersAreHeldBackUntilTryAgain() throws { + let sample = String(decoding: try Data(contentsOf: Self.sampleResult), as: UTF8.self) let zeros = String(repeating: "0", count: 64) - XCTAssertTrue(String(decoding: sample, as: UTF8.self).contains("\"input_sha256\": \"\(zeros)\"")) + let schema = "\"schema_version\": \"1.0\"" + XCTAssertTrue(sample.contains("\"input_sha256\": \"\(zeros)\"")) + XCTAssertTrue(sample.contains(schema)) let server = try PlacementStub { request, index in - let named = index == 0 ? Self.sha256(Data("another scene".utf8)) : Self.sha256(request.body) - return Data(String(decoding: sample, as: UTF8.self).replacingOccurrences(of: zeros, with: named).utf8) + var answer = sample.replacingOccurrences(of: zeros, with: index == 0 ? Self.sha256(Data("another scene".utf8)) : Self.sha256(request.body)) + if index == 1 { answer = answer.replacingOccurrences(of: schema, with: "\"schema_version\": \"9.0\"") } + return Data(answer.utf8) } + defer { server.stop() } let files = FileManager.default let gate = URL(fileURLWithPath: NSTemporaryDirectory()).appending(path: "housescan-gate-\(UUID().uuidString)", directoryHint: .isDirectory) @@ -44,29 +49,41 @@ final class UploadRecoveryUITests: XCTestCase { app.launch() let any = app.descendants(matching: .any) let note = any["upload.answerNote"] - XCTAssertTrue(note.waitForExistence(timeout: 300), "the unbound answer never showed as an answer House Scan couldn't use") + 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?.path, "/v1/placements") - XCTAssertTrue(any.matching(NSPredicate(format: "label == %@", "We couldn't use the server's answer")).firstMatch.exists) + 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.method == "POST" && $0.path == "/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: PlacementStub, 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) - - // Nothing asks again on its own, and nothing of the answer reaches a later screen. Thread.sleep(forTimeInterval: 6) - XCTAssertEqual(server.requests.count, 1, "the app asked again without a tap") - XCTAssertTrue(note.exists) + 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 = "uploading-unusableAnswer-engine" + shot.name = name shot.lifetime = .keepAlways add(shot) - - tap(app, "action.retryUpload") - XCTAssertTrue(any["screen.result"].waitForExistence(timeout: 240), "the answer after Try again never reached the result") - XCTAssertGreaterThanOrEqual(server.requests.count, 2) - XCTAssertTrue(server.requests.allSatisfy { $0.method == "POST" && $0.path == "/v1/placements" }) } /// The first unusable answer leads with "Try again"; the second says asking again might not @@ -141,7 +158,8 @@ final class UploadRecoveryUITests: XCTestCase { /// A placement server on 127.0.0.1 that the app in the Simulator reaches like any other: every /// request is answered 200 with the body `answer` builds from it and its index, and kept for the /// test. It speaks only what the app sends: one request per connection with a `Content-Length` -/// body, and it closes the connection after replying. +/// body, and it closes the connection after replying. `stop()` closes the listener; the +/// listener's handlers hold the stub weakly, so dropping it closes the listener too. final class PlacementStub: Sendable { struct Request: Sendable { var method: String @@ -165,14 +183,23 @@ final class PlacementStub: Sendable { listener = try NWListener(using: parameters) let ready = DispatchSemaphore(value: 0) listener.stateUpdateHandler = { if case .ready = $0 { ready.signal() } } - listener.newConnectionHandler = { [queue] connection in + listener.newConnectionHandler = { [weak self, queue] connection in + guard self != nil else { return connection.cancel() } connection.start(queue: queue) - Self.receive(connection, buffer: Data()) { request in self.reply(to: request, on: connection) } + Self.receive(connection, buffer: Data()) { [weak self] request in + guard let self else { return connection.cancel() } + self.reply(to: request, on: connection) + } } listener.start(queue: queue) - guard ready.wait(timeout: .now() + 5) == .success, listener.port != nil else { throw URLError(.cannotConnectToHost) } + guard ready.wait(timeout: .now() + 5) == .success, listener.port != nil else { + listener.cancel() + throw URLError(.cannotConnectToHost) + } } + func stop() { listener.cancel() } + deinit { listener.cancel() } private func reply(to request: Request, on connection: NWConnection) { From c13cf30944213e5a8cb97ae6eeba5998b73d86d7 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 3 Oct 2026 02:35:02 -0500 Subject: [PATCH 17/50] fix(ios): describe the spot check's area in words when no outline is drawn When no kept photo showed the spot, or there was no wall to draw on, the spot check still asked about "the blue outline", gave only the distance along the wall, and VoiceOver described the missing outline. The answer decides which clearance claims survive, so the homeowner has to know what space they are judging. One decision now drives the question, the picture and VoiceOver: the outline is drawn only with both a photo and a wall. Without one, the screen shows no photo, asks "Is anything standing in this space?", and lists the area's three extents: where it runs beside the meter, how far out from the wall (areaDepth) and how high up (spotHeight). The outlined photo's VoiceOver value now reads all three too. The words-only lines use the primary color, since Ink on Surface measured 1.06:1 in dark mode. An end of the span that rounds to 0 in now reads as the meter. Demo flags -uiDemoSpotNoPhoto and -uiDemoSpotNoWall reach those states; SpotConfirmUITests covers photo, no photo (default and AX5), no wall, left and straddling spans, and answering without a photo. Co-Authored-By: Claude Code --- ios/HouseScan/UI/Copy/SpotCheckCopy.swift | 51 ++++- ios/HouseScan/UI/Preview/DemoEngine.swift | 8 + .../UI/Screens/SpotConfirmScreen.swift | 126 +++++++---- .../Fixtures/results/spot-left.json | 203 ++++++++++++++++++ .../Fixtures/results/spot-straddle.json | 203 ++++++++++++++++++ ios/HouseScanUITests/SpotConfirmUITests.swift | 136 ++++++++++++ ios/README.md | 2 +- 7 files changed, 676 insertions(+), 53 deletions(-) create mode 100644 ios/HouseScanUITests/Fixtures/results/spot-left.json create mode 100644 ios/HouseScanUITests/Fixtures/results/spot-straddle.json create mode 100644 ios/HouseScanUITests/SpotConfirmUITests.swift diff --git a/ios/HouseScan/UI/Copy/SpotCheckCopy.swift b/ios/HouseScan/UI/Copy/SpotCheckCopy.swift index 9640f394a..cbb52ab49 100644 --- a/ios/HouseScan/UI/Copy/SpotCheckCopy.swift +++ b/ios/HouseScan/UI/Copy/SpotCheckCopy.swift @@ -2,11 +2,21 @@ import Foundation /// The spot check's words (`SpotConfirmScreen`, and the guidance log's record of the question). extension ScanCopy { - /// One question about the wall and the ground, with the reason for asking. + /// One question about the wall and the ground, with the reason for asking, when the area is + /// outlined on a photo. The guidance log records this text for every check. static let spotQuestion = Instruction( title: "Is anything standing in the marked area?", detail: "Check the wall and the ground inside the blue outline. Photos can miss a low bush, a bin or a step." ) + + /// The same question when no outline is drawn: no kept photo shows the area, or there is no + /// wall to draw it with. The area is then given in words (`spotSpace`), so nothing points + /// at an outline that isn't on screen. + static let spotQuestionInPerson = Instruction( + title: "Is anything standing in this space?", + detail: "We couldn't mark this space on a photo, so take a look yourself. Check the wall and the ground for a low bush, a bin, a step or anything else." + ) + static let spotSpaceTitle = "Where to look" static let spotClear = "It's clear" static let spotSomethingThere = "Something's there" static let spotClearHint = "Shows your result." @@ -22,19 +32,48 @@ extension ScanCopy { /// Said on the result when the homeowner said something stands where its spot is. static let spotRefused = "You said something stands where this spot is. Your scan leaves that area out, and an installer would need to check it." - /// "From 3 ft to 8 ft 6 in right of your meter": where the area runs. `spoken` spells the - /// units out for VoiceOver. + /// "From 3 ft to 8 ft 6 in right of your meter": where the area runs along the wall, nearer + /// end first. An end that rounds to 0 in is the meter itself, so -1...24 in reads "From your + /// meter to 2 ft right of it", not "From 0 in left to 2 ft right". `spoken` spells the units + /// out for VoiceOver. static func spotArea(_ span: ClosedRange, spoken: Bool = false) -> String { let length = { (meters: Float) in spoken ? Distance.spoken(meters) : Distance.feetAndInches(meters) } + let side = { (s: Float) in s < 0 ? "left" : "right" } + let atMeter = { (s: Float) in Int((abs(s) / Distance.metersPerInch).rounded()) == 0 } let low = span.lowerBound, high = span.upperBound + switch (atMeter(low), atMeter(high)) { + case (true, true): return "At your meter" + case (true, false): return "From your meter to \(length(high)) \(side(high)) of it" + case (false, true): return "From your meter to \(length(low)) \(side(low)) of it" + case (false, false): break + } if low >= 0 { return "From \(length(low)) to \(length(high)) right of your meter" } if high <= 0 { return "From \(length(high)) to \(length(low)) left of your meter" } return "From \(length(low)) left to \(length(high)) right of your meter" } - /// What VoiceOver reads for the photo. + /// The area's three extents, each readable on its own: along the wall from the meter, out + /// from the wall over the ground (`SpotCheck.areaDepth`, the deepest clearance zone holding + /// the spot), and up the wall face to the battery's height (`SpotCheck.spotHeight`). These are + /// the same edges `SpotOutline` draws on a photo. + static func spotSpace(_ check: SpotCheck, spoken: Bool = false) -> (along: String, out: String, up: String) { + let length = { (meters: Float) in spoken ? Distance.spoken(meters) : Distance.feetAndInches(meters) } + return ( + along: spotArea(check.area, spoken: spoken), + out: "From the wall out to \(length(check.areaDepth))", + up: "From the ground up to \(length(check.spotHeight))" + ) + } + + /// `spotSpace` as one spoken sentence, for VoiceOver. + static func spotSpaceSpoken(_ check: SpotCheck) -> String { + let space = spotSpace(check, spoken: true) + return "\(space.along), \(space.out.lowercasedFirst), and \(space.up.lowercasedFirst)." + } + + /// What VoiceOver reads for the outlined photo: what the outline marks, and where, since a + /// VoiceOver user can't see the outline's edges. static func spotPhotoDescription(_ check: SpotCheck) -> String { - let place = spotArea(check.area, spoken: true) - return "A blue outline marks where the battery would stand and the space around it. \(place)." + "A blue outline marks where the battery would stand and the space around it. \(spotSpaceSpoken(check))" } } diff --git a/ios/HouseScan/UI/Preview/DemoEngine.swift b/ios/HouseScan/UI/Preview/DemoEngine.swift index d5f913929..6c4f38df2 100644 --- a/ios/HouseScan/UI/Preview/DemoEngine.swift +++ b/ios/HouseScan/UI/Preview/DemoEngine.swift @@ -182,6 +182,14 @@ final class DemoEngine: ScanActions { if let raw = value("-uiDemoSpotAnswered"), state.phase == .spotConfirm { state.spotCheck?.answer = raw == "somethingThere" ? .somethingThere : .clear } + // The spot check without an outline: no kept photo shows the area (`-uiDemoSpotNoPhoto`), + // or a photo but no wall to draw the area on it with (`-uiDemoSpotNoWall`). + if arguments.contains("-uiDemoSpotNoPhoto"), state.phase == .spotConfirm { + state.spotCheck?.photo = nil + } + if arguments.contains("-uiDemoSpotNoWall"), state.phase == .spotConfirm { + state.wall = nil + } if arguments.contains("-uiDemoFollowUp") { enterFollowUp(at: state.phase) } diff --git a/ios/HouseScan/UI/Screens/SpotConfirmScreen.swift b/ios/HouseScan/UI/Screens/SpotConfirmScreen.swift index 079ba7c36..556fa6320 100644 --- a/ios/HouseScan/UI/Screens/SpotConfirmScreen.swift +++ b/ios/HouseScan/UI/Screens/SpotConfirmScreen.swift @@ -4,6 +4,11 @@ import SwiftUI /// outlined, and one question with two equal answers. A photo can claim wall and ground behind a /// bush, so the homeowner, who is standing there, says whether anything is in the way. /// +/// Without an outline (no kept photo shows the area, or there is no wall to draw it with) the +/// area is given in words instead: where it runs beside the meter, how far out from the wall and +/// how high up it. The question then names no outline, and no photo is shown, since a photo with +/// nothing marked on it would leave the homeowner guessing which part of it is meant. +/// /// The answer replaces the two buttons and stays up a moment, saying what happens next, before /// the engine moves on (to the result, or to checking the wall again). struct SpotConfirmScreen: View { @@ -22,16 +27,25 @@ struct SpotConfirmScreen: View { } } + /// The photo and the wall to outline the area on it with, or nil when either is missing. + /// The question, the picture and what VoiceOver reads all follow this one answer. + private func outlinable(_ check: SpotCheck) -> (photo: SpotCheck.Photo, wall: WallGeometry)? { + guard let photo = check.photo, let wall = state.wall else { return nil } + return (photo, wall) + } + private func content(_ check: SpotCheck) -> some View { - ScrollView { + let outline = outlinable(check) + let question = outline == nil ? ScanCopy.spotQuestionInPerson : ScanCopy.spotQuestion + return ScrollView { VStack(alignment: .leading, spacing: 20) { badges(check) VStack(alignment: .leading, spacing: 8) { - Text(ScanCopy.spotQuestion.title) + Text(question.title) .font(Typeface.screenTitle) .fixedSize(horizontal: false, vertical: true) .accessibilityAddTraits(.isHeader) - if let detail = ScanCopy.spotQuestion.detail { + if let detail = question.detail { Text(detail) .font(Typeface.hint) .foregroundStyle(Palette.muted) @@ -40,13 +54,17 @@ struct SpotConfirmScreen: View { } .accessibilityElement(children: .combine) .accessibilityIdentifier("spot.question") - VStack(alignment: .leading, spacing: 10) { - photo(check) - Text(ScanCopy.spotArea(check.area)) - .font(.subheadline.weight(.medium)) - .foregroundStyle(Palette.muted) - .fixedSize(horizontal: false, vertical: true) - .accessibilityHidden(true) + if let outline { + VStack(alignment: .leading, spacing: 10) { + outlinedPhoto(outline.photo, wall: outline.wall, check: check) + Text(ScanCopy.spotArea(check.area)) + .font(.subheadline.weight(.medium)) + .foregroundStyle(Palette.muted) + .fixedSize(horizontal: false, vertical: true) + .accessibilityHidden(true) + } + } else { + space(check) } if typeSize.isAccessibilitySize { answerArea(check) @@ -104,45 +122,61 @@ struct SpotConfirmScreen: View { } } - @ViewBuilder - private func photo(_ check: SpotCheck) -> some View { - Group { - if let photo = check.photo, let wall = state.wall { - let size = photo.projection.imageSize - // The sensor image shown upright (rotated 90° clockwise) at its own aspect, so - // filling the frame crops nothing and the projection's fill mapping holds. - Color.clear - .aspectRatio(CGFloat(size.y / size.x), contentMode: .fit) - .overlay { - Image(decorative: photo.image, scale: 1, orientation: .right) - .resizable() - .scaledToFill() - } - .overlay { - SpotOutline(photo: photo, wall: wall, check: check) - .opacity(outlineShown ? 1 : 0) - } - .clipShape(.rect(cornerRadius: Metrics.cardRadius, style: .continuous)) - // Tall enough to judge, short enough that the question and the photo share - // the screen with the answers on a 6.1 in phone. - .frame(maxHeight: 400) - .frame(maxWidth: .infinity) - } else { - // No kept photo shows the spot from the front: the homeowner looks at the wall. - Label("No photo shows this spot well. Take a look at the wall itself.", systemImage: "photo") - .font(Typeface.hint) - .foregroundStyle(Palette.ink) - .fixedSize(horizontal: false, vertical: true) - .padding(18) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Palette.surface, in: .rect(cornerRadius: Metrics.cardRadius, style: .continuous)) + private func outlinedPhoto(_ photo: SpotCheck.Photo, wall: WallGeometry, check: SpotCheck) -> some View { + let size = photo.projection.imageSize + // The sensor image shown upright (rotated 90° clockwise) at its own aspect, so filling + // the frame crops nothing and the projection's fill mapping holds. + return Color.clear + .aspectRatio(CGFloat(size.y / size.x), contentMode: .fit) + .overlay { + Image(decorative: photo.image, scale: 1, orientation: .right) + .resizable() + .scaledToFill() + } + .overlay { + SpotOutline(photo: photo, wall: wall, check: check) + .opacity(outlineShown ? 1 : 0) + } + .clipShape(.rect(cornerRadius: Metrics.cardRadius, style: .continuous)) + // Tall enough to judge, short enough that the question and the photo share the + // screen with the answers on a 6.1 in phone. + .frame(maxHeight: 400) + .frame(maxWidth: .infinity) + .accessibilityElement(children: .ignore) + .accessibilityLabel("Photo of your wall") + .accessibilityValue(ScanCopy.spotPhotoDescription(check)) + .accessibilityAddTraits(.isImage) + .accessibilityIdentifier("spot.photo") + } + + /// The area in words, in the photo's place: the three edges the outline would draw. + private func space(_ check: SpotCheck) -> some View { + let extents = ScanCopy.spotSpace(check) + return VStack(alignment: .leading, spacing: 10) { + Label(ScanCopy.spotSpaceTitle, systemImage: "eye") + .font(Typeface.caption) + .foregroundStyle(Palette.muted) + VStack(alignment: .leading, spacing: 6) { + spaceLine(extents.along) + spaceLine(extents.out) + spaceLine(extents.up) } } + .padding(18) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Palette.surface, in: .rect(cornerRadius: Metrics.cardRadius, style: .continuous)) .accessibilityElement(children: .ignore) - .accessibilityLabel(check.photo == nil ? "No photo of this spot" : "Photo of your wall") - .accessibilityValue(ScanCopy.spotPhotoDescription(check)) - .accessibilityAddTraits(check.photo == nil ? [] : .isImage) - .accessibilityIdentifier("spot.photo") + .accessibilityLabel(ScanCopy.spotSpaceTitle) + .accessibilityValue(ScanCopy.spotSpaceSpoken(check)) + .accessibilityIdentifier("spot.area") + } + + /// In the primary color, as the question: Ink stays near-black in dark mode, where Surface + /// turns dark, and this screen follows the phone's appearance. + private func spaceLine(_ text: String) -> some View { + Text(text) + .font(Typeface.hint) + .fixedSize(horizontal: false, vertical: true) } /// The two answers, or once answered, what happens next. diff --git a/ios/HouseScanUITests/Fixtures/results/spot-left.json b/ios/HouseScanUITests/Fixtures/results/spot-left.json new file mode 100644 index 000000000..704c3fa01 --- /dev/null +++ b/ios/HouseScanUITests/Fixtures/results/spot-left.json @@ -0,0 +1,203 @@ +{ + "schema_version": "1.0", + "decision": "pass", + "summary": "Test answer for the spot check's wording: a spot 3 ft 6 in to 6 ft left of the meter. Demo rules: public values and placeholders (pool 10 ft, drive 5 ft), not Base's.", + "reasons": [], + "policy": { + "id": "demo", + "version": "2026-09-26", + "auto_approve": true, + "sources": [ + "public" + ], + "rules_sha256": "5a1e0c7d00000000000000000000000000000000000000000000000000000000", + "notice": "Demo rules: public values and placeholders (pool 10 ft, drive 5 ft), not Base's." + }, + "spot": { + "outcome": "pass", + "wall_id": "wall", + "segment": 0, + "span_ft": [ + -6.0, + -3.5 + ], + "width_ft": 2.5, + "depth_ft": 2.0, + "height_ft": 3.5, + "footprint": [ + [ + -6.0, + 0.0 + ], + [ + -3.5, + 0.0 + ], + [ + -3.5, + 2.0 + ], + [ + -6.0, + 2.0 + ] + ], + "center": [ + -4.75, + 1.0 + ], + "along": [ + 1.0, + 0.0 + ], + "outward": [ + 0.0, + 1.0 + ], + "meter_offset_ft": [ + -4.75, + 1.0 + ], + "route_length_ft": 3.5 + }, + "route": { + "outcome": "pass", + "length_ft": 3.5, + "plus_minus_ft": 0.3, + "height_ft": 1.5, + "polyline": [ + [ + 0.0, + 0.0 + ], + [ + -3.5, + 0.0 + ] + ], + "detours": [], + "crossings": [] + }, + "checks": [ + { + "id": "wall_backing", + "label": "Flush against one straight wall", + "outcome": "pass", + "reason": "Test: seen flat behind the whole spot.", + "measured_ft": null, + "plus_minus_ft": null, + "threshold_ft": null, + "comparison": null, + "subject": null, + "rule": { + "key": "wall_backing_ft", + "source": "Made-up test value", + "placeholder": true + } + }, + { + "id": "gas_clearance", + "label": "Distance from gas equipment", + "outcome": "pass", + "reason": "Test: well clear.", + "measured_ft": 6.2, + "plus_minus_ft": 0.3, + "threshold_ft": 3.0, + "comparison": "at_least", + "subject": null, + "rule": { + "key": "gas_clearance_ft", + "source": "Made-up test value", + "placeholder": true + } + }, + { + "id": "opening_clearance", + "label": "Distance from doors and windows", + "outcome": "pass", + "reason": "Test: clear of the window.", + "measured_ft": 3.9, + "plus_minus_ft": 0.3, + "threshold_ft": 3.0, + "comparison": "at_least", + "subject": null, + "rule": { + "key": "opening_clearance_ft", + "source": "Made-up test value", + "placeholder": true + } + }, + { + "id": "meter_working_space", + "label": "Clear of the meter's working space", + "outcome": "pass", + "reason": "Test: clear of the working space.", + "measured_ft": 1.8, + "plus_minus_ft": 0.3, + "threshold_ft": 0.0, + "comparison": "at_least", + "subject": null, + "rule": { + "key": "meter_working_space_ft", + "source": "Made-up test value", + "placeholder": true + } + }, + { + "id": "route_length", + "label": "Cable run length", + "outcome": "pass", + "reason": "Test: short run.", + "measured_ft": 3.5, + "plus_minus_ft": 0.3, + "threshold_ft": 20.0, + "comparison": "at_most", + "subject": null, + "rule": { + "key": "route_length_ft", + "source": "Made-up test value", + "placeholder": true + } + } + ], + "nearest_considered": null, + "missing_evidence": [], + "ends": { + "left": { + "kind": "limit", + "s_ft": -8.0, + "point": [ + -8.0, + 0.0 + ] + }, + "right": { + "kind": "limit", + "s_ft": 12.0, + "point": [ + 12.0, + 0.0 + ] + } + }, + "sweep": [ + { + "wall_id": "wall", + "start_ft": [ + -6.0, + -5.0 + ], + "outcome": "pass", + "failing": [], + "unsure": [] + } + ], + "stats": { + "candidates": 36, + "pass": 4, + "unsure": 0, + "fail": 32, + "elapsed_ms": 0, + "input_sha256": "0000000000000000000000000000000000000000000000000000000000000000" + } +} diff --git a/ios/HouseScanUITests/Fixtures/results/spot-straddle.json b/ios/HouseScanUITests/Fixtures/results/spot-straddle.json new file mode 100644 index 000000000..3758eb4a4 --- /dev/null +++ b/ios/HouseScanUITests/Fixtures/results/spot-straddle.json @@ -0,0 +1,203 @@ +{ + "schema_version": "1.0", + "decision": "pass", + "summary": "Test answer for the spot check's wording: a spot from 1 ft 3 in left to 1 ft 6 in right of the meter. Demo rules: public values and placeholders (pool 10 ft, drive 5 ft), not Base's.", + "reasons": [], + "policy": { + "id": "demo", + "version": "2026-09-26", + "auto_approve": true, + "sources": [ + "public" + ], + "rules_sha256": "5a1e0c7d00000000000000000000000000000000000000000000000000000000", + "notice": "Demo rules: public values and placeholders (pool 10 ft, drive 5 ft), not Base's." + }, + "spot": { + "outcome": "pass", + "wall_id": "wall", + "segment": 0, + "span_ft": [ + -1.25, + 1.5 + ], + "width_ft": 2.75, + "depth_ft": 2.0, + "height_ft": 3.5, + "footprint": [ + [ + -1.25, + 0.0 + ], + [ + 1.5, + 0.0 + ], + [ + 1.5, + 2.0 + ], + [ + -1.25, + 2.0 + ] + ], + "center": [ + 0.125, + 1.0 + ], + "along": [ + 1.0, + 0.0 + ], + "outward": [ + 0.0, + 1.0 + ], + "meter_offset_ft": [ + 0.125, + 1.0 + ], + "route_length_ft": 0.125 + }, + "route": { + "outcome": "pass", + "length_ft": 0.125, + "plus_minus_ft": 0.3, + "height_ft": 1.5, + "polyline": [ + [ + 0.0, + 0.0 + ], + [ + 0.125, + 0.0 + ] + ], + "detours": [], + "crossings": [] + }, + "checks": [ + { + "id": "wall_backing", + "label": "Flush against one straight wall", + "outcome": "pass", + "reason": "Test: seen flat behind the whole spot.", + "measured_ft": null, + "plus_minus_ft": null, + "threshold_ft": null, + "comparison": null, + "subject": null, + "rule": { + "key": "wall_backing_ft", + "source": "Made-up test value", + "placeholder": true + } + }, + { + "id": "gas_clearance", + "label": "Distance from gas equipment", + "outcome": "pass", + "reason": "Test: well clear.", + "measured_ft": 6.2, + "plus_minus_ft": 0.3, + "threshold_ft": 3.0, + "comparison": "at_least", + "subject": null, + "rule": { + "key": "gas_clearance_ft", + "source": "Made-up test value", + "placeholder": true + } + }, + { + "id": "opening_clearance", + "label": "Distance from doors and windows", + "outcome": "pass", + "reason": "Test: clear of the window.", + "measured_ft": 3.9, + "plus_minus_ft": 0.3, + "threshold_ft": 3.0, + "comparison": "at_least", + "subject": null, + "rule": { + "key": "opening_clearance_ft", + "source": "Made-up test value", + "placeholder": true + } + }, + { + "id": "meter_working_space", + "label": "Clear of the meter's working space", + "outcome": "pass", + "reason": "Test: clear of the working space.", + "measured_ft": 1.8, + "plus_minus_ft": 0.3, + "threshold_ft": 0.0, + "comparison": "at_least", + "subject": null, + "rule": { + "key": "meter_working_space_ft", + "source": "Made-up test value", + "placeholder": true + } + }, + { + "id": "route_length", + "label": "Cable run length", + "outcome": "pass", + "reason": "Test: short run.", + "measured_ft": 0.125, + "plus_minus_ft": 0.3, + "threshold_ft": 20.0, + "comparison": "at_most", + "subject": null, + "rule": { + "key": "route_length_ft", + "source": "Made-up test value", + "placeholder": true + } + } + ], + "nearest_considered": null, + "missing_evidence": [], + "ends": { + "left": { + "kind": "limit", + "s_ft": -8.0, + "point": [ + -8.0, + 0.0 + ] + }, + "right": { + "kind": "limit", + "s_ft": 12.0, + "point": [ + 12.0, + 0.0 + ] + } + }, + "sweep": [ + { + "wall_id": "wall", + "start_ft": [ + -1.25, + -0.25 + ], + "outcome": "pass", + "failing": [], + "unsure": [] + } + ], + "stats": { + "candidates": 36, + "pass": 4, + "unsure": 0, + "fail": 32, + "elapsed_ms": 0, + "input_sha256": "0000000000000000000000000000000000000000000000000000000000000000" + } +} diff --git a/ios/HouseScanUITests/SpotConfirmUITests.swift b/ios/HouseScanUITests/SpotConfirmUITests.swift new file mode 100644 index 000000000..5deeca66c --- /dev/null +++ b/ios/HouseScanUITests/SpotConfirmUITests.swift @@ -0,0 +1,136 @@ +import XCTest + +/// The spot check names the space the homeowner judges, with or without an outline to show it. +/// +/// With a kept photo and a wall, the area is outlined on the photo and the question points at the +/// outline. Without either, nothing is drawn, so the question must not mention an outline and the +/// area is given in words: along the wall from the meter, out from the wall, and up it. Held still +/// on the demo (`-uiDemoFreeze`), whose review sample's spot runs 0.55 to 1.34 m right of the +/// meter, stands 0.03 m off the wall and 0.56 m deep (out to 0.59 m), and is 1.1 m high. The demo +/// asks about the footprint alone, so its area is the footprint. Fixtures/results/spot-left.json +/// and spot-straddle.json put the spot left of the meter and across it. +final class SpotConfirmUITests: XCTestCase { + private static let largestText = ["-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryAccessibilityXXXL"] + + /// The review sample's area, read aloud. + private static let sampleSpace = "From 1 foot 10 inches to 4 feet 5 inches right of your meter, from the wall out to 1 foot 11 inches, and from the ground up to 3 feet 7 inches." + + override func setUp() { + continueAfterFailure = true + } + + @MainActor + func testTheOutlinedPhotoNamesTheOutlineAndWhereItRuns() throws { + let app = launch([]) + defer { app.terminate() } + let photo = element(app, "spot.photo") + XCTAssertTrue(photo.waitForExistence(timeout: 10), "the demo's spot check shows its photo") + let read = ElementRead.snapshot(photo) + XCTAssertEqual(read?.label, "Photo of your wall") + XCTAssertEqual(read?.value as? String, "A blue outline marks where the battery would stand and the space around it. \(Self.sampleSpace)") + let question = ElementRead.snapshot(element(app, "spot.question"))?.label ?? "" + XCTAssertTrue(question.contains("Is anything standing in the marked area?"), question) + XCTAssertTrue(question.contains("inside the blue outline"), question) + XCTAssertFalse(element(app, "spot.area").exists, "the words-only area shows only without an outline") + attach(app, name: "spotConfirm-photo") + } + + @MainActor + func testWithoutAPhotoTheAreaIsGivenInWords() throws { + try checkInWords(["-uiDemoSpotNoPhoto"], name: "spotConfirm-noPhoto", spoken: Self.sampleSpace) + try checkInWords(["-uiDemoSpotNoPhoto"] + Self.largestText, name: "spotConfirm-noPhoto-AX5", spoken: Self.sampleSpace) + } + + /// A photo with no wall to draw the area with is shown as no photo: VoiceOver used to call + /// the words-only card "Photo of your wall" and describe an outline that wasn't drawn. + @MainActor + func testWithoutAWallThePhotoIsNotShownUnmarked() throws { + try checkInWords(["-uiDemoSpotNoWall"], name: "spotConfirm-noWall", spoken: Self.sampleSpace) + } + + @MainActor + func testTheAreaInWordsNamesTheSideOfTheMeter() throws { + try checkInWords(["-uiDemoResultFile", Self.resultFile("spot-left"), "-uiDemoSpotNoPhoto"], name: "spotConfirm-noPhoto-left", + spoken: "From 3 feet 6 inches to 6 feet left of your meter, from the wall out to 2 feet, and from the ground up to 3 feet 6 inches.") + try checkInWords(["-uiDemoResultFile", Self.resultFile("spot-straddle"), "-uiDemoSpotNoPhoto"], name: "spotConfirm-noPhoto-straddle", + spoken: "From 1 foot 3 inches left to 1 foot 6 inches right of your meter, from the wall out to 2 feet, and from the ground up to 3 feet 6 inches.") + } + + /// Answering works the same without a photo: "Something's there" is taken and acknowledged. + @MainActor + func testTheAnswerIsTakenWithoutAPhoto() throws { + let app = launch(["-uiDemoSpotNoPhoto"]) + defer { app.terminate() } + let answer = app.buttons["action.spotSomethingThere"] + XCTAssertTrue(answer.waitForExistence(timeout: 10)) + let hittable = XCTNSPredicateExpectation(predicate: NSPredicate(format: "isHittable == true"), object: answer) + XCTAssertEqual(XCTWaiter().wait(for: [hittable], timeout: 10), .completed, "Something's there can't be tapped") + answer.tap() + let answered = element(app, "spot.answered") + XCTAssertTrue(answered.waitForExistence(timeout: 10)) + let label = ElementRead.snapshot(answered)?.label ?? "" + XCTAssertTrue(label.contains("Thanks, we'll leave that area out"), label) + } + + // MARK: - Helpers + + /// The words-only spot check: no photo element, a question that names no outline, the area + /// read aloud in full, both answers present, and a clean accessibility audit. + @MainActor + private func checkInWords(_ arguments: [String], name: String, spoken: String) throws { + let app = launch(arguments) + defer { app.terminate() } + let area = element(app, "spot.area") + guard area.waitForExistence(timeout: 10) else { + XCTFail("\(name): spot.area never appeared") + return + } + attach(app, name: name) + XCTAssertFalse(element(app, "spot.photo").exists, "\(name): no photo is shown without an outline on it") + let read = ElementRead.snapshot(area) + XCTAssertEqual(read?.label, "Where to look", name) + XCTAssertEqual(read?.value as? String, spoken, name) + let question = ElementRead.snapshot(element(app, "spot.question"))?.label ?? "" + XCTAssertTrue(question.contains("Is anything standing in this space?"), "\(name): \(question)") + XCTAssertTrue(question.contains("take a look yourself"), "\(name): \(question)") + // Nothing on screen may point at an outline that isn't drawn. Labels only: some elements' + // values aren't strings, and the area's value is checked in full above. + let outline = NSPredicate(format: "label CONTAINS[c] 'outline' OR label CONTAINS[c] 'marked area'") + XCTAssertEqual(app.descendants(matching: .any).matching(outline).count, 0, "\(name): something still mentions the outline") + XCTAssertTrue(app.buttons["action.spotClear"].exists, "\(name): It's clear is missing") + XCTAssertTrue(app.buttons["action.spotSomethingThere"].exists, "\(name): Something's there is missing") + // As ScreenStatesUITests: an issue fails only when a second pass, after a system banner + // has had time to leave, finds it again. + let outcome = try AccessibilityAudit.run(app) { _ in Thread.sleep(forTimeInterval: 6) } + for (_, finding) in outcome.persistent { + XCTFail("\(name): \(finding.message)") + } + } + + @MainActor + private func launch(_ arguments: [String]) -> XCUIApplication { + let app = XCUIApplication() + app.launchArguments = ["-practiceMeter", "NO", "-uiDemo", "-uiDemoFreeze", "-uiDemoPhase", "spotConfirm"] + arguments + app.launch() + XCTAssertTrue(element(app, "screen.spotConfirm").waitForExistence(timeout: 15), "the spot check never appeared") + return app + } + + /// A server answer in Fixtures/results, which the demo reads in debug builds. + private static func resultFile(_ name: String, file: String = #filePath) -> String { + URL(fileURLWithPath: file).deletingLastPathComponent().appending(path: "Fixtures/results/\(name).json").path + } + + @MainActor + private func element(_ app: XCUIApplication, _ identifier: String) -> XCUIElement { + app.descendants(matching: .any)[identifier].firstMatch + } + + @MainActor + private func attach(_ app: XCUIApplication, name: String) { + let shot = XCTAttachment(screenshot: app.screenshot()) + shot.name = name + shot.lifetime = .keepAlways + add(shot) + } +} diff --git a/ios/README.md b/ios/README.md index f8323e881..4b22c328b 100644 --- a/ios/README.md +++ b/ios/README.md @@ -24,7 +24,7 @@ Native iPhone app for the AR capture walk. The homeowner marks the electric mete | `-practiceMeter YES` or `NO` | Overrides the stored practice meter switch for one run (UserDefaults' argument domain). The full-flow UI tests pass `NO`, since the switch outlives a run in the Simulator. | | `-uiDemo` | Runs the screens on a scripted fake engine instead of the capture engine, for design work and for auditing states a replay can't reach. The arguments it takes are listed in `HouseScan/UI/Preview/UIDemo.swift`; for example `-uiDemoGroundQuestion` opens the feature review with the ground question showing, `-uiDemoPhase meterCloseUp -uiDemoMeterChoose` shows the meter number picker, and `-uiDemoPhase result -uiDemoCorner` shows the result model of a wall that turns a corner. | -**Spot check.** Before the result, the app shows the kept photo that best sees the answer's spot, with the spot and its clearance area outlined, and asks whether anything stands in front of the wall or on the ground there (`STATE=spotConfirm`). Photos without depth and the walked path claim wall, ground and clear space they never saw; "It's clear" backs those claims for that spot, and "Something's there" takes them back over the area, so scene.json reports it unseen, and sends the scan again. The rules are in `Runtime/ScanEngine+Confirm.swift` and HouseScanKit's `Confirm/SpotConfirmation.swift`. +**Spot check.** Before the result, the app shows the kept photo that best sees the answer's spot, with the spot and its clearance area outlined, and asks whether anything stands in front of the wall or on the ground there (`STATE=spotConfirm`). When no kept photo shows the area, it shows no photo and gives the area in words: where it runs beside the meter, how far out from the wall, and how high up the wall. Photos without depth and the walked path claim wall, ground and clear space they never saw; "It's clear" backs those claims for that spot, and "Something's there" takes them back over the area, so scene.json reports it unseen, and sends the scan again. The rules are in `Runtime/ScanEngine+Confirm.swift` and HouseScanKit's `Confirm/SpotConfirmation.swift`. **Practice meter.** For trying the whole scan where there is no electric meter, such as indoors. Debug and TestFlight builds show a wrench button at the top of the first screen. Tap it, turn on **Practice meter**, tap Done, then start the scan. At "find the meter", tap any spot on a wall: the usual raycast and refusals apply, and a drawn sample meter appears there, fixed to the meter's anchor, on every camera screen after that. The close-up keeps the live camera's pose and depth, but stores a photo of the sample (made-up maker SAMPLEWORKS, number 12345678), and the real reader reads it. The walk, fog, marks, requests, spot check, upload, result and AR all run for real. Every screen shows a "Practice meter" badge, and `scan-stamp.json` has `"practice": true`; the packet and scene.json are unchanged. The switch is kept until turned off, and the button reads "Practice meter on" while it is. App Store installs never show the button and ignore a stored switch: the app asks StoreKit's `AppTransaction` which server signed the install (sandbox means TestFlight) and treats no answer as the App Store. It is not in the iOS Settings app, because a Settings.bundle ships in the same binary that TestFlight later promotes to the App Store. The code is in `Runtime/Practice/`, `UI/Practice/` and HouseScanKit's `Practice/PracticeMeter.swift`. From e1bc23a0ee0fa9eff66c544daf574ffd444e92e0 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 3 Oct 2026 02:43:46 -0500 Subject: [PATCH 18/50] docs(ios): correct the spot area's rounding example and scope A -1 in end rounds to 1 in, not 0, so the example contradicted the rule. The extents comment now says it describes the outlined area only, not every clearance the server checks. Co-Authored-By: Claude Code --- ios/HouseScan/UI/Copy/SpotCheckCopy.swift | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/ios/HouseScan/UI/Copy/SpotCheckCopy.swift b/ios/HouseScan/UI/Copy/SpotCheckCopy.swift index cbb52ab49..7dcce9445 100644 --- a/ios/HouseScan/UI/Copy/SpotCheckCopy.swift +++ b/ios/HouseScan/UI/Copy/SpotCheckCopy.swift @@ -33,9 +33,9 @@ extension ScanCopy { static let spotRefused = "You said something stands where this spot is. Your scan leaves that area out, and an installer would need to check it." /// "From 3 ft to 8 ft 6 in right of your meter": where the area runs along the wall, nearer - /// end first. An end that rounds to 0 in is the meter itself, so -1...24 in reads "From your - /// meter to 2 ft right of it", not "From 0 in left to 2 ft right". `spoken` spells the units - /// out for VoiceOver. + /// end first. An end less than half an inch from the meter rounds to 0 in and is read as the + /// meter itself, so -0.3...24 in reads "From your meter to 2 ft right of it", not "From 0 in + /// left to 2 ft right". `spoken` spells the units out for VoiceOver. static func spotArea(_ span: ClosedRange, spoken: Bool = false) -> String { let length = { (meters: Float) in spoken ? Distance.spoken(meters) : Distance.feetAndInches(meters) } let side = { (s: Float) in s < 0 ? "left" : "right" } @@ -52,10 +52,11 @@ extension ScanCopy { return "From \(length(low)) left to \(length(high)) right of your meter" } - /// The area's three extents, each readable on its own: along the wall from the meter, out - /// from the wall over the ground (`SpotCheck.areaDepth`, the deepest clearance zone holding - /// the spot), and up the wall face to the battery's height (`SpotCheck.spotHeight`). These are - /// the same edges `SpotOutline` draws on a photo. + /// The asked-about area's three extents, each readable on its own: along the wall from the + /// meter (`SpotCheck.area`), out from the wall over the ground (`SpotCheck.areaDepth`), and up + /// the wall face to the battery's height (`SpotCheck.spotHeight`). These are the edges + /// `SpotOutline` draws on a photo, and no more: the words describe the area the question is + /// about, not every clearance the server checks. static func spotSpace(_ check: SpotCheck, spoken: Bool = false) -> (along: String, out: String, up: String) { let length = { (meters: Float) in spoken ? Distance.spoken(meters) : Distance.feetAndInches(meters) } return ( From b7223678e4e347774561b382985da325f7c08bac Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 3 Oct 2026 02:47:59 -0500 Subject: [PATCH 19/50] ios: "Wall ends here" and its preview share one check; "The wall keeps going" isn't a refusal From a fresh review of cf30991c: - The button and the tape's end preview now use one check (`circleEnd`), so the tape never shows an end the button would refuse, including one on the other side of the meter. - A circle aimed at the ground met the wall's plane below the ground and was taken as an end; it now gets the "circle isn't on the wall" refusal, by the same below-ground rule as object taps (`ObjectTap.belowGroundSlack`). - "The wall keeps going" answers the end card; it no longer counts as a "Can't get there" toward "End the scan here?" (#82), and nothing it does is timed as a refusal. - The demo's tilt-up state keeps a skipped close-up skipped. Co-Authored-By: Claude Code --- .../Runtime/ScanEngine+Actions.swift | 28 ++++++++++++------- ios/HouseScan/Runtime/ScanEngine+Ends.swift | 25 +++++++++++++---- ios/HouseScan/UI/Preview/DemoEngine.swift | 1 - 3 files changed, 37 insertions(+), 17 deletions(-) diff --git a/ios/HouseScan/Runtime/ScanEngine+Actions.swift b/ios/HouseScan/Runtime/ScanEngine+Actions.swift index e4afa8cf0..4692bbb6e 100644 --- a/ios/HouseScan/Runtime/ScanEngine+Actions.swift +++ b/ios/HouseScan/Runtime/ScanEngine+Actions.swift @@ -147,16 +147,18 @@ extension ScanEngine: ScanActions { /// 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 } - var asked: WallSide? - if point == nil, state.phase == .wallWalk, case .markEnd(let side) = state.guidance { asked = side } - if asked != nil, frame.tracking != .normal { return refuseEndMark(.trackingNotReady) } - guard let hit = wallHit(point, viewSize: viewSize, frame: frame, wall: wall) else { - if asked != nil { refuseEndMark(.noWall) } - 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 let asked, side != asked { return refuseEndMark(.otherSide(side)) } if state.phase == .gapRequest, side != pastEndSide { return } state.endMarkRefusal = nil // Unexplored until the homeowner says something blocks the wall there: an unanswered @@ -494,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). @@ -527,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: diff --git a/ios/HouseScan/Runtime/ScanEngine+Ends.swift b/ios/HouseScan/Runtime/ScanEngine+Ends.swift index 81d45aea1..a520a79e8 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,6 +98,20 @@ 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 (B-06): the phone's place unknown; the circle off the wall, with no hit within the + /// distance a view counts for or a hit below the ground, which is aiming at the ground; or the + /// circle on the other side of the meter. 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. + func circleEnd(asked: WallSide, frame: SourceFrame, map: CoverageMap) -> Result { + guard frame.tracking == .normal else { return .failure(.trackingNotReady) } + guard let hit = nearbyWallHit(frame.camera.ray(throughPixel: frame.camera.imageSize / 2), camera: frame.camera, wall: map.wall), + hit.height >= -(ObjectTap.belowGroundSlack + max(0, map.heightError)) else { return .failure(.noWall) } + let side: WallSide = hit.s < 0 ? .left : .right + return side == asked ? .success(hit) : .failure(.otherSide(side)) + } + /// Republishes the end preview; called with every guidance update, since the phone moves. func publishEndPreview() { let preview = currentEndPreview() @@ -132,11 +144,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/UI/Preview/DemoEngine.swift b/ios/HouseScan/UI/Preview/DemoEngine.swift index 4f9b882ef..288fee14d 100644 --- a/ios/HouseScan/UI/Preview/DemoEngine.swift +++ b/ios/HouseScan/UI/Preview/DemoEngine.swift @@ -327,7 +327,6 @@ final class DemoEngine: ScanActions { placeMeter() finishedWalkState() state.phase = .wallWalk - state.closeUp = .captured(DemoScene.meterThumbnail) refreshGuidance() run { engine in await engine.walkScript() } } From 4a7d4bfddb350f09447e2a37f1dd990aae29d2f8 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 3 Oct 2026 02:48:40 -0500 Subject: [PATCH 20/50] ios: the cleanup deletes an entry only if it is still what it was when listed The deletion runs after the listing, off the main actor. A folder listed as never packaged could gain a bundle, partial or whole, before it ran, and was deleted anyway; so was a kept-out bundle a retry had rewritten. run() now reads each obsolete entry again and leaves any that changed, by type, bundle state, save time or file number, for the next cleanup. PacketArchiveCheck's docs now say it detects a write that reached the end in the packet's layout, not that a file is a capture packet. Co-Authored-By: Claude Code --- .../SavedScans/PacketArchiveCheck.swift | 12 ++-- .../Scene/ScanFolderCleanup.swift | 62 +++++++++++++------ .../HouseScanKitTests/KeptScansTests.swift | 32 ++++++++++ 3 files changed, 82 insertions(+), 24 deletions(-) diff --git a/ios/HouseScanKit/Sources/HouseScanKit/SavedScans/PacketArchiveCheck.swift b/ios/HouseScanKit/Sources/HouseScanKit/SavedScans/PacketArchiveCheck.swift index de51b2599..8db0606e8 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/SavedScans/PacketArchiveCheck.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/SavedScans/PacketArchiveCheck.swift @@ -1,19 +1,23 @@ import Foundation -/// Whether a file is a whole capture-packet zip, read from its two ends without unpacking it. +/// 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 another zip under the name is not taken for a scan. +/// 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 is not a defense against a crafted -/// zip. Only the app writes into its scan folders. +/// 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 diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Scene/ScanFolderCleanup.swift b/ios/HouseScanKit/Sources/HouseScanKit/Scene/ScanFolderCleanup.swift index 450c56d49..3335f8361 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Scene/ScanFolderCleanup.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Scene/ScanFolderCleanup.swift @@ -39,6 +39,8 @@ public struct ScanFolderCleanup: Sendable { 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) { @@ -46,25 +48,16 @@ public struct ScanFolderCleanup: Sendable { let names = ((try? files.contentsOfDirectory(atPath: root.path)) ?? []).filter { $0 != kept }.sorted() var completed: [(name: String, date: Date)] = [] var incomplete: [String] = [] + var entries: [String: Entry] = [:] for name in names { - // Only a real folder is judged by its bundle. A plain file beside the scan folders holds - // no bundle and goes, as before. A link (attributesOfItem doesn't follow one) would - // reach another folder's bundle, so it and its target could take both kept places; - // it, and an entry whose type can't be read, hold no place and are left alone. - switch (try? files.attributesOfItem(atPath: root.appending(path: name).path))?[.type] as? FileAttributeType { - case .typeDirectory?: break - case .typeRegular?: continue - default: - incomplete.append(name) - continue - } - let bundle = root.appending(path: name).appending(path: Self.bundleName) - switch Self.bundleState(bundle) { - case .absent: + let entry = Self.entry(at: root.appending(path: name)) + entries[name] = entry + switch entry { + case .file, .folder(.absent): continue - case .complete(let date): + case .folder(.complete(let date, _)): completed.append((name, date)) - case .incomplete: + case .unjudged, .folder(.incomplete): incomplete.append(name) } } @@ -73,12 +66,35 @@ public struct ScanFolderCleanup: Sendable { let keep = Set(keptNames + incomplete) keptCompleted = keptNames.map { root.appending(path: $0) } incompleteBundles = incomplete.map { root.appending(path: $0) } - obsolete = names.filter { !keep.contains($0) }.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) } - enum BundleState: Equatable { + 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 - case complete(savedAt: Date) + /// `file` is the bundle's file number: a rewrite makes a new file, whatever its date. + case complete(savedAt: Date, file: UInt64) case incomplete } @@ -107,15 +123,21 @@ public struct ScanFolderCleanup: Sendable { 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) + 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/Tests/HouseScanKitTests/KeptScansTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/KeptScansTests.swift index 084628d49..521549436 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/KeptScansTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/KeptScansTests.swift @@ -187,6 +187,38 @@ import Testing #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() From d11fdc81766bceed2d22149ce8a1579b68a6c3f9 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 3 Oct 2026 02:48:40 -0500 Subject: [PATCH 21/50] test(ios): keep screenshots of the end card, its refusal and the lost-place cards Co-Authored-By: Claude Code --- ios/HouseScanUITests/WalkRecoveryUITests.swift | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/ios/HouseScanUITests/WalkRecoveryUITests.swift b/ios/HouseScanUITests/WalkRecoveryUITests.swift index ce3006f58..3f2b45f07 100644 --- a/ios/HouseScanUITests/WalkRecoveryUITests.swift +++ b/ios/HouseScanUITests/WalkRecoveryUITests.swift @@ -16,6 +16,7 @@ final class WalkRecoveryUITests: XCTestCase { func testTheEndCardAnswersThatTheWallKeepsGoing() throws { let app = launch(["-uiDemoPhase", "wallWalk", "-uiDemoMarkEnd"]) XCTAssertTrue(label(app, "instruction").contains("Is this the right end of the wall?")) + 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") @@ -33,6 +34,7 @@ final class WalkRecoveryUITests: XCTestCase { 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) @@ -46,11 +48,13 @@ final class WalkRecoveryUITests: XCTestCase { 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)") } @@ -65,6 +69,15 @@ final class WalkRecoveryUITests: XCTestCase { 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 From 041dae0e21ed0981ee644f5af831888252e8c717 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 3 Oct 2026 02:48:56 -0500 Subject: [PATCH 22/50] ios: the README says which scans Saved scans lists and what a partial bundle does Co-Authored-By: Claude Code --- ios/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ios/README.md b/ios/README.md index 3678de848..9f54635ea 100644 --- a/ios/README.md +++ b/ios/README.md @@ -96,7 +96,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. 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, as `ScanFolderCleanup` keeps them) with when each bundle was saved, its size and a Practice tag when its `scan-stamp.json` says so. 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 saved scan can only be shared, never reopened or continued. The code is in `UI/SavedScans/` and HouseScanKit's `SavedScans/`. +**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 From 08ed892a2f68b8dc64dc1878013aaa35e6587d04 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 3 Oct 2026 02:59:57 -0500 Subject: [PATCH 23/50] ios: EndMarkRefusal conforms to Error, as Result's failure type needs Co-Authored-By: Claude Code --- ios/HouseScan/Contract/ScanContract.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ios/HouseScan/Contract/ScanContract.swift b/ios/HouseScan/Contract/ScanContract.swift index 114222f23..661d0a8aa 100644 --- a/ios/HouseScan/Contract/ScanContract.swift +++ b/ios/HouseScan/Contract/ScanContract.swift @@ -555,7 +555,7 @@ enum NextWallRefusal: Equatable, Sendable { /// 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: Equatable, Sendable { +enum EndMarkRefusal: Error, Equatable, Sendable { /// The circle isn't on the wall within the distance a camera's view counts for /// (`CoverageConfig.maxDistance`): on the ground, the sky, or far down a long wall. case noWall From c1b2566c2417773ce232fc8d66e5973f9cb68a48 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 3 Oct 2026 03:10:36 -0500 Subject: [PATCH 24/50] build(ios): exclude the two new spot fixtures from the UI test bundle The synced HouseScanUITests folder lists each excluded fixture by name, so XcodeGen 2.46.0 adds spot-left.json and spot-straddle.json. These are the exact lines CI generated in run 37107514444; the resulting blob is 21c9c0a. Co-Authored-By: Claude Code --- ios/HouseScan.xcodeproj/project.pbxproj | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ios/HouseScan.xcodeproj/project.pbxproj b/ios/HouseScan.xcodeproj/project.pbxproj index f623d8f14..21c9c0ac2 100644 --- a/ios/HouseScan.xcodeproj/project.pbxproj +++ b/ios/HouseScan.xcodeproj/project.pbxproj @@ -36,6 +36,8 @@ "Fixtures/results/no-clean-spot.json", Fixtures/results/pass.json, "Fixtures/results/reject-nearest.json", + "Fixtures/results/spot-left.json", + "Fixtures/results/spot-straddle.json", "Fixtures/results/unsure-view.json", "Fixtures/synthetic-wall", "Fixtures/synthetic-wall-lidar", From 659f668c0b24d3d5c036b09cdc687db64533b54f Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 3 Oct 2026 03:10:36 -0500 Subject: [PATCH 25/50] fix(ios): log the spot question shown, and expose each area line The guidance log always recorded the outline question, and built the request before the photo loaded, so a check asked in words was logged as "inside the blue outline", and a photo that failed to load was logged as shown. The request and pending record are now built after the photo loads, from SpotCheck.outline(on:), the same decision the screen uses: the question shown, and a photo ID only when that photo is outlined. Each of the words-only card's three lines is now its own accessibility element with a spoken label, so VoiceOver steps through them and the UI tests and audit see the rendered text. The tests check each line's label and that it is on screen, scrolling to it at AX5. Co-Authored-By: Claude Code --- .../Runtime/ScanEngine+Confirm.swift | 25 ++++++--- ios/HouseScan/UI/Copy/SpotCheckCopy.swift | 7 ++- .../UI/Screens/SpotConfirmScreen.swift | 44 +++++++++------- ios/HouseScanUITests/SpotConfirmUITests.swift | 51 +++++++++++++------ 4 files changed, 85 insertions(+), 42 deletions(-) diff --git a/ios/HouseScan/Runtime/ScanEngine+Confirm.swift b/ios/HouseScan/Runtime/ScanEngine+Confirm.swift index 14761faa6..367ff3162 100644 --- a/ios/HouseScan/Runtime/ScanEngine+Confirm.swift +++ b/ios/HouseScan/Runtime/ScanEngine+Confirm.swift @@ -84,9 +84,6 @@ extension ScanEngine { let candidates = store.keyframes.map { SpotPhotoCandidate(id: $0.id, camera: $0.camera, trackingNormal: $0.tracking == .normal) } let choice = SpotPhoto.best(candidates, area: area, wall: wall) let stored = choice.flatMap { choice in store.keyframes.first { $0.id == choice.id } } - // `answer` is replaced by the homeowner's (`answerSpotCheck`) before it is recorded. - spotConfirm.pending = SpotConfirmation(area: area, answerSHA256: answerSHA256, sceneSHA256: sceneSHA256, photoID: stored?.id, answer: .clear) - spotConfirm.request = spotCheckRequest(area: area, photoID: stored?.id, answerSHA256: answerSHA256, sceneSHA256: sceneSHA256, id: id) let file = stored.map { store.directory.appending(path: $0.fileName) } if let choice { RuntimeLog.engine.info("spot check: photo \(choice.id, privacy: .public) shows \(Int(choice.footprintInView * 100))% of the spot and \(Int(choice.areaInView * 100))% of s=\(area.span.lowerBound)...\(area.span.upperBound)") @@ -100,8 +97,20 @@ extension ScanEngine { if let image, let camera = stored?.camera { photo = SpotCheck.Photo(image: image, projection: CameraProjection( cameraToWorld: camera.cameraToWorld, intrinsics: camera.intrinsics, imageSize: camera.imageSize)) + } else if let stored { + RuntimeLog.engine.error("spot check: keyframe \(stored.id, privacy: .public) didn't load; asking without a photo") } - state.spotCheck = Self.spotCheck(id: id, area: area, spot: spot, photo: photo, answer: nil, isSample: result.isSample) + let check = Self.spotCheck(id: id, area: area, spot: spot, photo: photo, answer: nil, isSample: result.isSample) + // Recorded once the photo has loaded, from what the screen shows: the photo only when + // it is outlined on screen, and the question asked with or without the outline. + let outlined = check.outline(on: state.wall) != nil + let shownID = outlined ? stored?.id : nil + // `answer` is replaced by the homeowner's (`answerSpotCheck`) before it is recorded. + spotConfirm.pending = SpotConfirmation(area: area, answerSHA256: answerSHA256, sceneSHA256: sceneSHA256, photoID: shownID, answer: .clear) + spotConfirm.request = spotCheckRequest( + area: area, question: ScanCopy.spotQuestionShown(outlined: outlined), photoID: shownID, + answerSHA256: answerSHA256, sceneSHA256: sceneSHA256, id: id) + state.spotCheck = check go(.spotConfirm) } } @@ -155,15 +164,15 @@ extension ScanEngine { var spotCheckGuidance: GuidanceLog.Request? { spotConfirm.request } /// A gap_band request on the ground over the area. scene.json and the packet have no field - /// for the check, so the message carries what binds it: the photo shown and the sha256 of the - /// answer and scene it was about. - private func spotCheckRequest(area: SpotArea, photoID: String?, answerSHA256: String, sceneSHA256: String, id: Int) -> GuidanceLog.Request { + /// for the check, so the message carries what binds it: the question and photo shown and the + /// sha256 of the answer and scene it was about. + private func spotCheckRequest(area: SpotArea, question: Instruction, photoID: String?, answerSHA256: String, sceneSHA256: String, id: Int) -> GuidanceLog.Request { let format = { (value: Float) in String(format: "%.2f", value) } let binding = "Spot check \(id): keyframe \(photoID ?? "none") shown; spot s \(format(area.spot.lowerBound)) to \(format(area.spot.upperBound)) m, " + "area s \(format(area.span.lowerBound)) to \(format(area.span.upperBound)) m out to \(format(area.depth)) m; " + "answer sha256 \(answerSHA256), scene sha256 \(sceneSHA256)." return GuidanceLog.Request( - topic: .spotCheck(id: id), kind: .gapBand, origin: .phone, message: Self.text(ScanCopy.spotQuestion) + " " + binding, + topic: .spotCheck(id: id), kind: .gapBand, origin: .phone, message: Self.text(question) + " " + binding, band: .ground, span: area.span) } diff --git a/ios/HouseScan/UI/Copy/SpotCheckCopy.swift b/ios/HouseScan/UI/Copy/SpotCheckCopy.swift index 7dcce9445..3aa9398f5 100644 --- a/ios/HouseScan/UI/Copy/SpotCheckCopy.swift +++ b/ios/HouseScan/UI/Copy/SpotCheckCopy.swift @@ -3,7 +3,7 @@ import Foundation /// The spot check's words (`SpotConfirmScreen`, and the guidance log's record of the question). extension ScanCopy { /// One question about the wall and the ground, with the reason for asking, when the area is - /// outlined on a photo. The guidance log records this text for every check. + /// outlined on a photo. static let spotQuestion = Instruction( title: "Is anything standing in the marked area?", detail: "Check the wall and the ground inside the blue outline. Photos can miss a low bush, a bin or a step." @@ -17,6 +17,11 @@ extension ScanCopy { detail: "We couldn't mark this space on a photo, so take a look yourself. Check the wall and the ground for a low bush, a bin, a step or anything else." ) static let spotSpaceTitle = "Where to look" + + /// The question as shown (`SpotCheck.outline(on:)`), on screen and in the guidance log. + static func spotQuestionShown(outlined: Bool) -> Instruction { + outlined ? spotQuestion : spotQuestionInPerson + } static let spotClear = "It's clear" static let spotSomethingThere = "Something's there" static let spotClearHint = "Shows your result." diff --git a/ios/HouseScan/UI/Screens/SpotConfirmScreen.swift b/ios/HouseScan/UI/Screens/SpotConfirmScreen.swift index 556fa6320..51de92335 100644 --- a/ios/HouseScan/UI/Screens/SpotConfirmScreen.swift +++ b/ios/HouseScan/UI/Screens/SpotConfirmScreen.swift @@ -27,16 +27,9 @@ struct SpotConfirmScreen: View { } } - /// The photo and the wall to outline the area on it with, or nil when either is missing. - /// The question, the picture and what VoiceOver reads all follow this one answer. - private func outlinable(_ check: SpotCheck) -> (photo: SpotCheck.Photo, wall: WallGeometry)? { - guard let photo = check.photo, let wall = state.wall else { return nil } - return (photo, wall) - } - private func content(_ check: SpotCheck) -> some View { - let outline = outlinable(check) - let question = outline == nil ? ScanCopy.spotQuestionInPerson : ScanCopy.spotQuestion + let outline = check.outline(on: state.wall) + let question = ScanCopy.spotQuestionShown(outlined: outline != nil) return ScrollView { VStack(alignment: .leading, spacing: 20) { badges(check) @@ -149,34 +142,39 @@ struct SpotConfirmScreen: View { .accessibilityIdentifier("spot.photo") } - /// The area in words, in the photo's place: the three edges the outline would draw. + /// The area in words, in the photo's place: the three edges the outline would draw. Each + /// line is its own VoiceOver element, read with the units spelled out, so the rendered lines + /// stay visible to the accessibility audit and the UI tests. private func space(_ check: SpotCheck) -> some View { - let extents = ScanCopy.spotSpace(check) + let shown = ScanCopy.spotSpace(check) + let spoken = ScanCopy.spotSpace(check, spoken: true) return VStack(alignment: .leading, spacing: 10) { Label(ScanCopy.spotSpaceTitle, systemImage: "eye") .font(Typeface.caption) .foregroundStyle(Palette.muted) + .accessibilityAddTraits(.isHeader) + .accessibilityIdentifier("spot.area.title") VStack(alignment: .leading, spacing: 6) { - spaceLine(extents.along) - spaceLine(extents.out) - spaceLine(extents.up) + spaceLine(shown.along, spoken: spoken.along, id: "along") + spaceLine(shown.out, spoken: spoken.out, id: "out") + spaceLine(shown.up, spoken: spoken.up, id: "up") } } .padding(18) .frame(maxWidth: .infinity, alignment: .leading) .background(Palette.surface, in: .rect(cornerRadius: Metrics.cardRadius, style: .continuous)) - .accessibilityElement(children: .ignore) - .accessibilityLabel(ScanCopy.spotSpaceTitle) - .accessibilityValue(ScanCopy.spotSpaceSpoken(check)) + .accessibilityElement(children: .contain) .accessibilityIdentifier("spot.area") } /// In the primary color, as the question: Ink stays near-black in dark mode, where Surface /// turns dark, and this screen follows the phone's appearance. - private func spaceLine(_ text: String) -> some View { + private func spaceLine(_ text: String, spoken: String, id: String) -> some View { Text(text) .font(Typeface.hint) .fixedSize(horizontal: false, vertical: true) + .accessibilityLabel(spoken) + .accessibilityIdentifier("spot.area.\(id)") } /// The two answers, or once answered, what happens next. @@ -206,6 +204,16 @@ struct SpotConfirmScreen: View { } } +extension SpotCheck { + /// The photo and the wall to outline the area on it with, or nil when either is missing. + /// The screen's question, picture and VoiceOver text follow this one answer, and so does + /// the guidance log's record of what was shown (`ScanEngine.presentAnswer`). + func outline(on wall: WallGeometry?) -> (photo: Photo, wall: WallGeometry)? { + guard let photo = self.photo, let wall else { return nil } + return (photo, wall) + } +} + /// The answer given, and what happens next. private struct Answered: View { var answer: SpotCheckAnswer diff --git a/ios/HouseScanUITests/SpotConfirmUITests.swift b/ios/HouseScanUITests/SpotConfirmUITests.swift index 5deeca66c..e17879e35 100644 --- a/ios/HouseScanUITests/SpotConfirmUITests.swift +++ b/ios/HouseScanUITests/SpotConfirmUITests.swift @@ -12,7 +12,17 @@ import XCTest final class SpotConfirmUITests: XCTestCase { private static let largestText = ["-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryAccessibilityXXXL"] - /// The review sample's area, read aloud. + /// The words-only card's three lines as VoiceOver reads them (units spelled out). + private struct Lines { + var along: String + var out: String + var up: String + } + + /// The review sample's area, read aloud: one sentence on the photo, three lines in words. + private static let sampleLines = Lines( + along: "From 1 foot 10 inches to 4 feet 5 inches right of your meter", + out: "From the wall out to 1 foot 11 inches", up: "From the ground up to 3 feet 7 inches") private static let sampleSpace = "From 1 foot 10 inches to 4 feet 5 inches right of your meter, from the wall out to 1 foot 11 inches, and from the ground up to 3 feet 7 inches." override func setUp() { @@ -37,23 +47,25 @@ final class SpotConfirmUITests: XCTestCase { @MainActor func testWithoutAPhotoTheAreaIsGivenInWords() throws { - try checkInWords(["-uiDemoSpotNoPhoto"], name: "spotConfirm-noPhoto", spoken: Self.sampleSpace) - try checkInWords(["-uiDemoSpotNoPhoto"] + Self.largestText, name: "spotConfirm-noPhoto-AX5", spoken: Self.sampleSpace) + try checkInWords(["-uiDemoSpotNoPhoto"], name: "spotConfirm-noPhoto", spoken: Self.sampleLines) + try checkInWords(["-uiDemoSpotNoPhoto"] + Self.largestText, name: "spotConfirm-noPhoto-AX5", spoken: Self.sampleLines) } /// A photo with no wall to draw the area with is shown as no photo: VoiceOver used to call /// the words-only card "Photo of your wall" and describe an outline that wasn't drawn. @MainActor func testWithoutAWallThePhotoIsNotShownUnmarked() throws { - try checkInWords(["-uiDemoSpotNoWall"], name: "spotConfirm-noWall", spoken: Self.sampleSpace) + try checkInWords(["-uiDemoSpotNoWall"], name: "spotConfirm-noWall", spoken: Self.sampleLines) } @MainActor func testTheAreaInWordsNamesTheSideOfTheMeter() throws { try checkInWords(["-uiDemoResultFile", Self.resultFile("spot-left"), "-uiDemoSpotNoPhoto"], name: "spotConfirm-noPhoto-left", - spoken: "From 3 feet 6 inches to 6 feet left of your meter, from the wall out to 2 feet, and from the ground up to 3 feet 6 inches.") + spoken: Lines(along: "From 3 feet 6 inches to 6 feet left of your meter", + out: "From the wall out to 2 feet", up: "From the ground up to 3 feet 6 inches")) try checkInWords(["-uiDemoResultFile", Self.resultFile("spot-straddle"), "-uiDemoSpotNoPhoto"], name: "spotConfirm-noPhoto-straddle", - spoken: "From 1 foot 3 inches left to 1 foot 6 inches right of your meter, from the wall out to 2 feet, and from the ground up to 3 feet 6 inches.") + spoken: Lines(along: "From 1 foot 3 inches left to 1 foot 6 inches right of your meter", + out: "From the wall out to 2 feet", up: "From the ground up to 3 feet 6 inches")) } /// Answering works the same without a photo: "Something's there" is taken and acknowledged. @@ -74,27 +86,36 @@ final class SpotConfirmUITests: XCTestCase { // MARK: - Helpers - /// The words-only spot check: no photo element, a question that names no outline, the area - /// read aloud in full, both answers present, and a clean accessibility audit. + /// The words-only spot check: no photo element, a question that names no outline, each of + /// the area's three lines rendered on screen with its spoken label, both answers present, and + /// a clean accessibility audit, which also checks those lines for contrast and clipping. @MainActor - private func checkInWords(_ arguments: [String], name: String, spoken: String) throws { + private func checkInWords(_ arguments: [String], name: String, spoken: Lines) throws { let app = launch(arguments) defer { app.terminate() } - let area = element(app, "spot.area") - guard area.waitForExistence(timeout: 10) else { + guard element(app, "spot.area").waitForExistence(timeout: 10) else { XCTFail("\(name): spot.area never appeared") return } attach(app, name: name) XCTAssertFalse(element(app, "spot.photo").exists, "\(name): no photo is shown without an outline on it") - let read = ElementRead.snapshot(area) - XCTAssertEqual(read?.label, "Where to look", name) - XCTAssertEqual(read?.value as? String, spoken, name) + XCTAssertEqual(ElementRead.snapshot(element(app, "spot.area.title"))?.label, "Where to look", name) + let window = app.windows.firstMatch.frame + for (id, expected) in [("along", spoken.along), ("out", spoken.out), ("up", spoken.up)] { + let line = element(app, "spot.area.\(id)") + // At AX5 the card can sit below the fold; the homeowner scrolls to it. Slowly, as + // ScreenStatesUITests does, so momentum doesn't carry the line past the top. + for _ in 0..<6 where !line.isHittable { app.scrollViews.firstMatch.swipeUp(velocity: .slow) } + let read = ElementRead.snapshot(line) + XCTAssertEqual(read?.label, expected, "\(name): spot.area.\(id)") + let frame = read?.frame ?? .zero + XCTAssertTrue(!frame.isEmpty && window.contains(frame), "\(name): spot.area.\(id) at \(frame) isn't on screen in \(window)") + } let question = ElementRead.snapshot(element(app, "spot.question"))?.label ?? "" XCTAssertTrue(question.contains("Is anything standing in this space?"), "\(name): \(question)") XCTAssertTrue(question.contains("take a look yourself"), "\(name): \(question)") // Nothing on screen may point at an outline that isn't drawn. Labels only: some elements' - // values aren't strings, and the area's value is checked in full above. + // values aren't strings. let outline = NSPredicate(format: "label CONTAINS[c] 'outline' OR label CONTAINS[c] 'marked area'") XCTAssertEqual(app.descendants(matching: .any).matching(outline).count, 0, "\(name): something still mentions the outline") XCTAssertTrue(app.buttons["action.spotClear"].exists, "\(name): It's clear is missing") From 4bc4ded44a7b7120dae72238574bb5ab2987b67f Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 3 Oct 2026 03:59:51 -0500 Subject: [PATCH 26/50] Answer the engine UI test's uploads through the gate folder On the hosted Simulator (Actions run 37107233020) the app's upload to the UI test's server on 127.0.0.1 failed as offline, so the test never saw an unusable answer. That server lived in the test runner, a background app while the app under test is open; the same listener code answers URLSession on a Mac at 200 B to 3 MB. Under -answersFromGate the app's real PlacementHTTPClient sends through a URLProtocol that writes each request into the -autopilotGate folder and answers HTTP 200 with the test's answer-n.json. The engine, the status check and ResultBinding run as against a server; only the socket is replaced. HouseScanKit's PlacementHTTPClientTests still cover the real loopback socket. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Runtime/GateAnswerProtocol.swift | 95 +++++++++++++ ios/HouseScan/Runtime/LaunchOptions.swift | 8 ++ ios/HouseScan/Runtime/ResultClient.swift | 4 +- ios/HouseScan/Runtime/ScanEngine.swift | 2 +- .../UploadRecoveryUITests.swift | 127 ++++++------------ 5 files changed, 147 insertions(+), 89 deletions(-) create mode 100644 ios/HouseScan/Runtime/GateAnswerProtocol.swift diff --git a/ios/HouseScan/Runtime/GateAnswerProtocol.swift b/ios/HouseScan/Runtime/GateAnswerProtocol.swift new file mode 100644 index 000000000..e35a7b9d6 --- /dev/null +++ b/ios/HouseScan/Runtime/GateAnswerProtocol.swift @@ -0,0 +1,95 @@ +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 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 Self.body(of: request).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/LaunchOptions.swift b/ios/HouseScan/Runtime/LaunchOptions.swift index 3d659c6ed..46c1bb834 100644 --- a/ios/HouseScan/Runtime/LaunchOptions.swift +++ b/ios/HouseScan/Runtime/LaunchOptions.swift @@ -28,6 +28,9 @@ import OSLog /// 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 `-serverURL` and `-autopilotGate`, uploads go through the gate +/// folder instead of the network: the UI test reads each request there and writes the server's +/// answer (`GateAnswerProtocol`). /// - `-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. @@ -42,6 +45,7 @@ struct LaunchOptions: Equatable { var autopilotSomethingThere = false var simulateAppStore = false var injectGroundRise: Float? + var answersFromGate = false init( arguments: [String] = ProcessInfo.processInfo.arguments, @@ -62,6 +66,10 @@ struct LaunchOptions: Equatable { 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 -serverURL") + } 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 e363889b8..c100b08be 100644 --- a/ios/HouseScan/Runtime/ResultClient.swift +++ b/ios/HouseScan/Runtime/ResultClient.swift @@ -19,9 +19,9 @@ final class HTTPResultClient: ResultClient { let isSample = false private let client: PlacementHTTPClient - init(serverURL: URL) { + init(serverURL: URL, session: URLSession = .shared) { self.serverURL = serverURL - client = PlacementHTTPClient(serverURL: serverURL) + client = PlacementHTTPClient(serverURL: serverURL, session: session) } func submit(scene: Data, progress: @escaping @Sendable (Double) -> Void) async throws -> Data { diff --git a/ios/HouseScan/Runtime/ScanEngine.swift b/ios/HouseScan/Runtime/ScanEngine.swift index a52e61288..874ae2930 100644 --- a/ios/HouseScan/Runtime/ScanEngine.swift +++ b/ios/HouseScan/Runtime/ScanEngine.swift @@ -246,7 +246,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) } diff --git a/ios/HouseScanUITests/UploadRecoveryUITests.swift b/ios/HouseScanUITests/UploadRecoveryUITests.swift index f4dfcdd8a..31f9b2fa0 100644 --- a/ios/HouseScanUITests/UploadRecoveryUITests.swift +++ b/ios/HouseScanUITests/UploadRecoveryUITests.swift @@ -1,6 +1,5 @@ import CryptoKit import Foundation -import Network import Synchronization import XCTest @@ -12,11 +11,12 @@ final class UploadRecoveryUITests: XCTestCase { continueAfterFailure = false } - /// The real engine against a placement server on 127.0.0.1. 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. + /// 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) @@ -24,12 +24,6 @@ final class UploadRecoveryUITests: XCTestCase { let schema = "\"schema_version\": \"1.0\"" XCTAssertTrue(sample.contains("\"input_sha256\": \"\(zeros)\"")) XCTAssertTrue(sample.contains(schema)) - let server = try PlacementStub { request, index in - var answer = sample.replacingOccurrences(of: zeros, with: index == 0 ? Self.sha256(Data("another scene".utf8)) : Self.sha256(request.body)) - if index == 1 { answer = answer.replacingOccurrences(of: schema, with: "\"schema_version\": \"9.0\"") } - return Data(answer.utf8) - } - defer { server.stop() } let files = FileManager.default let gate = URL(fileURLWithPath: NSTemporaryDirectory()).appending(path: "housescan-gate-\(UUID().uuidString)", directoryHint: .isDirectory) @@ -40,11 +34,17 @@ final class UploadRecoveryUITests: XCTestCase { 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", server.base.absoluteString, + "-practiceMeter", "NO", "-serverURL", "http://placement.invalid", "-answersFromGate", ] app.launch() let any = app.descendants(matching: .any) @@ -53,7 +53,7 @@ final class UploadRecoveryUITests: XCTestCase { 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?.path, "/v1/placements") + XCTAssertEqual(server.requests.first?.target, "POST /v1/placements") holdsWithoutAsking(app, requests: 1, server: server, shot: "uploading-unusableAnswer-engine-1") tap(app, "action.retryUpload") @@ -65,13 +65,13 @@ final class UploadRecoveryUITests: XCTestCase { 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.method == "POST" && $0.path == "/v1/placements" }) + 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: PlacementStub, shot name: String) { + 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) @@ -155,85 +155,40 @@ final class UploadRecoveryUITests: XCTestCase { } } -/// A placement server on 127.0.0.1 that the app in the Simulator reaches like any other: every -/// request is answered 200 with the body `answer` builds from it and its index, and kept for the -/// test. It speaks only what the app sends: one request per connection with a `Content-Length` -/// body, and it closes the connection after replying. `stop()` closes the listener; the -/// listener's handlers hold the stub weakly, so dropping it closes the listener too. -final class PlacementStub: Sendable { +/// 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. A background thread polls every 0.1 s until `stop()`. +final class GateServer: Sendable { struct Request: Sendable { - var method: String - var path: String + var target: String var body: Data } - private let listener: NWListener - private let queue = DispatchQueue(label: "placement-stub") - private let answer: @Sendable (Request, Int) -> Data private let log = Mutex<[Request]>([]) + private let stopped = Mutex(false) var requests: [Request] { log.withLock { $0 } } - var base: URL { URL(string: "http://127.0.0.1:\(listener.port?.rawValue ?? 0)")! } - - init(answer: @escaping @Sendable (Request, Int) -> Data) throws { - self.answer = answer - 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.reply(to: request, on: connection) - } - } - listener.start(queue: queue) - guard ready.wait(timeout: .now() + 5) == .success, listener.port != nil else { - listener.cancel() - throw URLError(.cannotConnectToHost) - } - } - - func stop() { listener.cancel() } - - deinit { listener.cancel() } - - private func reply(to request: Request, on connection: NWConnection) { - let index = log.withLock { log in - log.append(request) - return log.count - 1 - } - let body = answer(request, index) - let head = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: \(body.count)\r\nConnection: close\r\n\r\n" - connection.send(content: Data(head.utf8) + 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 << 22) { 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) + init(gate: URL, answer: @escaping @Sendable (Data, Int) -> Data) { + Thread.detachNewThread { [self] in + while !stopped.withLock({ $0 }) { + let index = log.withLock { $0.count } + let body = gate.appending(path: "request-\(index).json") + if let data = try? Data(contentsOf: body) { + let target = (try? String(contentsOf: gate.appending(path: "request-\(index).target"), encoding: .utf8)) ?? "" + log.withLock { $0.append(Request(target: target, body: data)) } + do { + try answer(data, index).write(to: gate.appending(path: "answer-\(index).json"), options: .atomic) + } catch { + XCTFail("couldn't write answer-\(index).json: \(error)") + } + } else { + Thread.sleep(forTimeInterval: 0.1) + } + } } } - 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 length = 0 - for field in head.dropFirst() { - guard let colon = field.firstIndex(of: ":"), field[..= length else { return nil } - return Request(method: String(line[0]), path: String(line[1]), body: Data(body.prefix(length))) - } + func stop() { stopped.withLock { $0 = true } } } From 11d02a3496e02973021c78e61bdcb5b5804f4b24 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 3 Oct 2026 04:10:59 -0500 Subject: [PATCH 27/50] Keep the gate server answering after a lost request, and stop it fully A fresh review found two fault paths in the test hook. The app numbered a request before reading its body, and the test's server waited for numbers in order, so one request the app failed to write left every later one unanswered. The app now numbers a request only once its body is read, and the server finds requests by number in the folder. stop() also returned while its thread could still be writing into the gate folder the test was about to delete. It now waits for the thread and reports any answer it couldn't write from the test's own thread. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Runtime/GateAnswerProtocol.swift | 11 +++- ios/HouseScan/Runtime/LaunchOptions.swift | 4 +- .../UploadRecoveryUITests.swift | 52 ++++++++++++++----- 3 files changed, 50 insertions(+), 17 deletions(-) diff --git a/ios/HouseScan/Runtime/GateAnswerProtocol.swift b/ios/HouseScan/Runtime/GateAnswerProtocol.swift index e35a7b9d6..34cc69e14 100644 --- a/ios/HouseScan/Runtime/GateAnswerProtocol.swift +++ b/ios/HouseScan/Runtime/GateAnswerProtocol.swift @@ -39,6 +39,15 @@ final class GateAnswerProtocol: URLProtocol, @unchecked Sendable { 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 @@ -46,7 +55,7 @@ final class GateAnswerProtocol: URLProtocol, @unchecked Sendable { do { let target = "\(request.httpMethod ?? "GET") \(url.path())" try Data(target.utf8).write(to: gate.appending(path: "request-\(index).target"), options: .atomic) - try Self.body(of: request).write(to: gate.appending(path: "request-\(index).json"), options: .atomic) + try body.write(to: gate.appending(path: "request-\(index).json"), options: .atomic) } catch { client?.urlProtocol(self, didFailWithError: error) return diff --git a/ios/HouseScan/Runtime/LaunchOptions.swift b/ios/HouseScan/Runtime/LaunchOptions.swift index 46c1bb834..af4774bfe 100644 --- a/ios/HouseScan/Runtime/LaunchOptions.swift +++ b/ios/HouseScan/Runtime/LaunchOptions.swift @@ -28,7 +28,7 @@ import OSLog /// 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 `-serverURL` and `-autopilotGate`, uploads go through the gate +/// - `-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`). /// - `-simulateAppStore`: run as an App Store install would, so the developer options and practice @@ -68,7 +68,7 @@ struct LaunchOptions: Equatable { 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 -serverURL") + 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 { diff --git a/ios/HouseScanUITests/UploadRecoveryUITests.swift b/ios/HouseScanUITests/UploadRecoveryUITests.swift index 31f9b2fa0..3ad6dedfc 100644 --- a/ios/HouseScanUITests/UploadRecoveryUITests.swift +++ b/ios/HouseScanUITests/UploadRecoveryUITests.swift @@ -158,37 +158,61 @@ final class UploadRecoveryUITests: XCTestCase { /// 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. A background thread polls every 0.1 s until `stop()`. +/// 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 let log = Mutex<[Request]>([]) - private let stopped = Mutex(false) + 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] { log.withLock { $0 } } + var requests: [Request] { state.withLock { $0.requests } } init(gate: URL, answer: @escaping @Sendable (Data, Int) -> Data) { Thread.detachNewThread { [self] in - while !stopped.withLock({ $0 }) { - let index = log.withLock { $0.count } - let body = gate.appending(path: "request-\(index).json") - if let data = try? Data(contentsOf: body) { + 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)) ?? "" - log.withLock { $0.append(Request(target: target, body: data)) } + state.withLock { $0.requests.append(Request(index: index, target: target, body: body)) } do { - try answer(data, index).write(to: gate.appending(path: "answer-\(index).json"), options: .atomic) + try answer(body, index).write(to: gate.appending(path: "answer-\(index).json"), options: .atomic) } catch { - XCTFail("couldn't write answer-\(index).json: \(error)") + state.withLock { $0.problems.append("couldn't write answer-\(index).json: \(error)") } } - } else { - Thread.sleep(forTimeInterval: 0.1) } } } } - func stop() { stopped.withLock { $0 = true } } + /// 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)) + } } From a7f99a57d231bbd81922b2309e948d08b791f323 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 3 Oct 2026 04:11:33 -0500 Subject: [PATCH 28/50] feat(ios): let the homeowner answer "I can't check this area" on the spot check A homeowner who can't see or reach the spot's area had to guess between "It's clear" and "Something's there". The spot check now offers a third answer, the same size as the other two, with and without a photo. The answer is typed end to end (ScanActions.answerSpotCheck(_:), SpotCheckAnswer, SpotConfirmationAnswer.cannotCheck). It means not observed: the area's wall, ground and walked-path claims are withdrawn and the scan is sent again, as for "Something's there", and the guidance log closes the check as skipped. When the returned answer names the same area, the earlier answer settles it as given, so the result says the area wasn't checked and needs a person, and nobody is asked again. A moved or larger area is asked about as before. Tests: HouseScanKit cases for the three meanings, recording, same-area settlement and changed areas; demo UI tests for reachability at default and AX5 sizes, the acknowledgment and the result notice; and an engine test on the synthetic replay (-autopilotCannotCheck) that checks the withdrawn stretch in both scenes and that the notice survives a ground refine without a second question. Co-Authored-By: Claude Opus 5.5 (1M context) --- ios/HouseScan/Contract/ScanContract.swift | 8 +- ios/HouseScan/Runtime/Autopilot.swift | 32 +++-- ios/HouseScan/Runtime/LaunchOptions.swift | 5 + .../Runtime/ScanEngine+Confirm.swift | 52 ++++--- ios/HouseScan/Runtime/ScanEngine.swift | 5 +- ios/HouseScan/UI/Copy/SpotCheckCopy.swift | 8 ++ ios/HouseScan/UI/Preview/DemoEngine.swift | 47 +++++-- ios/HouseScan/UI/Preview/UIDemo.swift | 5 +- ios/HouseScan/UI/Screens/ResultScreen.swift | 14 +- .../UI/Screens/SpotConfirmScreen.swift | 34 +++-- .../Confirm/SpotConfirmation.swift | 28 +++- .../HouseScanKit/Coverage/CoverageMap.swift | 11 +- .../SpotConfirmationTests.swift | 66 +++++++++ .../SpotCannotCheckUITests.swift | 133 ++++++++++++++++++ ios/HouseScanUITests/SpotConfirmUITests.swift | 110 +++++++++++++++ ios/README.md | 3 +- 16 files changed, 492 insertions(+), 69 deletions(-) create mode 100644 ios/HouseScanUITests/SpotCannotCheckUITests.swift diff --git a/ios/HouseScan/Contract/ScanContract.swift b/ios/HouseScan/Contract/ScanContract.swift index 705c3e30e..dfd678168 100644 --- a/ios/HouseScan/Contract/ScanContract.swift +++ b/ios/HouseScan/Contract/ScanContract.swift @@ -759,6 +759,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 @@ -995,8 +999,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/Runtime/Autopilot.swift b/ios/HouseScan/Runtime/Autopilot.swift index edde51070..f2f73d310 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 @@ -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/LaunchOptions.swift b/ios/HouseScan/Runtime/LaunchOptions.swift index 3d659c6ed..807870dfc 100644 --- a/ios/HouseScan/Runtime/LaunchOptions.swift +++ b/ios/HouseScan/Runtime/LaunchOptions.swift @@ -22,6 +22,8 @@ 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`. /// - `-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 @@ -40,6 +42,7 @@ struct LaunchOptions: Equatable { var autopilotGate: URL? var autopilotCantGetThere = false var autopilotSomethingThere = false + var autopilotCannotCheck = false var simulateAppStore = false var injectGroundRise: Float? @@ -57,6 +60,8 @@ 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") simulateAppStore = arguments.contains("-simulateAppStore") serverURL = (value(after: "-serverURL") ?? defaultServerURL).flatMap(Self.serverURL) sampleResult = arguments.contains("-sampleResult") diff --git a/ios/HouseScan/Runtime/ScanEngine+Confirm.swift b/ios/HouseScan/Runtime/ScanEngine+Confirm.swift index 367ff3162..016001551 100644 --- a/ios/HouseScan/Runtime/ScanEngine+Confirm.swift +++ b/ios/HouseScan/Runtime/ScanEngine+Confirm.swift @@ -30,11 +30,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 @@ -75,7 +77,8 @@ extension ScanEngine { 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) @@ -116,23 +119,30 @@ extension ScanEngine { } /// 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 @@ -147,8 +157,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) @@ -199,6 +210,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.swift b/ios/HouseScan/Runtime/ScanEngine.swift index 388f6e516..e766dd769 100644 --- a/ios/HouseScan/Runtime/ScanEngine.swift +++ b/ios/HouseScan/Runtime/ScanEngine.swift @@ -2125,8 +2125,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/SpotCheckCopy.swift b/ios/HouseScan/UI/Copy/SpotCheckCopy.swift index 3aa9398f5..8cabd9082 100644 --- a/ios/HouseScan/UI/Copy/SpotCheckCopy.swift +++ b/ios/HouseScan/UI/Copy/SpotCheckCopy.swift @@ -24,18 +24,26 @@ extension ScanCopy { } 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." /// "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 diff --git a/ios/HouseScan/UI/Preview/DemoEngine.swift b/ios/HouseScan/UI/Preview/DemoEngine.swift index 6c4f38df2..e5fb0acd0 100644 --- a/ios/HouseScan/UI/Preview/DemoEngine.swift +++ b/ios/HouseScan/UI/Preview/DemoEngine.swift @@ -179,8 +179,17 @@ 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. + state.spotCheck = sampleSpotCheck(answer: answer) + 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`). @@ -404,17 +413,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() { @@ -1241,13 +1266,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..02542825e 100644 --- a/ios/HouseScan/UI/Preview/UIDemo.swift +++ b/ios/HouseScan/UI/Preview/UIDemo.swift @@ -45,8 +45,9 @@ 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. /// - `-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/Screens/ResultScreen.swift b/ios/HouseScan/UI/Screens/ResultScreen.swift index 57e1c27a1..fbb9d11d8 100644 --- a/ios/HouseScan/UI/Screens/ResultScreen.swift +++ b/ios/HouseScan/UI/Screens/ResultScreen.swift @@ -36,9 +36,17 @@ 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") + if result.spot != nil { + switch state.spotCheck?.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() + } } footnotes(result) .padding(.horizontal, 4) diff --git a/ios/HouseScan/UI/Screens/SpotConfirmScreen.swift b/ios/HouseScan/UI/Screens/SpotConfirmScreen.swift index 51de92335..5d6775f97 100644 --- a/ios/HouseScan/UI/Screens/SpotConfirmScreen.swift +++ b/ios/HouseScan/UI/Screens/SpotConfirmScreen.swift @@ -1,16 +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. /// /// Without an outline (no kept photo shows the area, or there is no wall to draw it with) the /// area is given in words instead: where it runs beside the meter, how far out from the wall and /// how high up it. The question then names no outline, and no photo is shown, since a photo with /// nothing marked on it would leave the homeowner guessing which part of it is meant. /// -/// The answer replaces the two buttons and stays up a moment, saying what happens next, before -/// the engine moves on (to the result, or to checking the wall again). +/// 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 @@ -177,7 +178,7 @@ struct SpotConfirmScreen: View { .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 { @@ -189,15 +190,20 @@ 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)) @@ -221,7 +227,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) @@ -243,6 +249,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/HouseScanKit/Sources/HouseScanKit/Confirm/SpotConfirmation.swift b/ios/HouseScanKit/Sources/HouseScanKit/Confirm/SpotConfirmation.swift index 1b26e4a85..537002a39 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] = [] 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/Tests/HouseScanKitTests/SpotConfirmationTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/SpotConfirmationTests.swift index de256cb7e..d21b477f7 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/SpotConfirmationTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/SpotConfirmationTests.swift @@ -115,6 +115,72 @@ 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) + } + + /// 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/HouseScanUITests/SpotCannotCheckUITests.swift b/ios/HouseScanUITests/SpotCannotCheckUITests.swift new file mode 100644 index 000000000..cc99fbcb4 --- /dev/null +++ b/ios/HouseScanUITests/SpotCannotCheckUITests.swift @@ -0,0 +1,133 @@ +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. +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") + } + + /// The result's notice says the area went unchecked, never that something stands there. + @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") + } + + /// 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 index e17879e35..a9b4f3954 100644 --- a/ios/HouseScanUITests/SpotConfirmUITests.swift +++ b/ios/HouseScanUITests/SpotConfirmUITests.swift @@ -84,8 +84,117 @@ final class SpotConfirmUITests: XCTestCase { 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)") + for _ in 0..<8 where !button.isHittable { app.scrollViews.firstMatch.swipeUp(velocity: .slow) } + 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") + let window = app.windows.firstMatch.frame + for _ in 0..<10 where !window.contains(notice.frame) { app.scrollViews.firstMatch.swipeUp(velocity: .slow) } + let read = ElementRead.snapshot(notice) + XCTAssertEqual(read?.label, notChecked, name) + XCTAssertTrue(window.contains(read?.frame ?? .zero), "\(name): the notice at \(read?.frame ?? .zero) can't be scrolled onto the screen") + 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) + refused.terminate() + let clear = launchResult(answer: "clear", []) + XCTAssertTrue(element(clear, "result.headline").waitForExistence(timeout: 10)) + XCTAssertFalse(element(clear, "result.spotRefused").exists) + XCTAssertFalse(element(clear, "result.spotNotChecked").exists) + clear.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. @@ -120,6 +229,7 @@ final class SpotConfirmUITests: XCTestCase { 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) } diff --git a/ios/README.md b/ios/README.md index 4b22c328b..74c6581e9 100644 --- a/ios/README.md +++ b/ios/README.md @@ -19,12 +19,13 @@ 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. | | `-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. | | `-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`). When no kept photo shows the area, it shows no photo and gives the area in words: where it runs beside the meter, how far out from the wall, and how high up the wall. Photos without depth and the walked path claim wall, ground and clear space they never saw; "It's clear" backs those claims for that spot, and "Something's there" takes them back over the area, so scene.json reports it unseen, and sends the scan again. The rules are in `Runtime/ScanEngine+Confirm.swift` and HouseScanKit's `Confirm/SpotConfirmation.swift`. +**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. The rules are in `Runtime/ScanEngine+Confirm.swift` and HouseScanKit's `Confirm/SpotConfirmation.swift`. **Practice meter.** For trying the whole scan where there is no electric meter, such as indoors. Debug and TestFlight builds show a wrench button at the top of the first screen. Tap it, turn on **Practice meter**, tap Done, then start the scan. At "find the meter", tap any spot on a wall: the usual raycast and refusals apply, and a drawn sample meter appears there, fixed to the meter's anchor, on every camera screen after that. The close-up keeps the live camera's pose and depth, but stores a photo of the sample (made-up maker SAMPLEWORKS, number 12345678), and the real reader reads it. The walk, fog, marks, requests, spot check, upload, result and AR all run for real. Every screen shows a "Practice meter" badge, and `scan-stamp.json` has `"practice": true`; the packet and scene.json are unchanged. The switch is kept until turned off, and the button reads "Practice meter on" while it is. App Store installs never show the button and ignore a stored switch: the app asks StoreKit's `AppTransaction` which server signed the install (sandbox means TestFlight) and treats no answer as the App Store. It is not in the iOS Settings app, because a Settings.bundle ships in the same binary that TestFlight later promotes to the App Store. The code is in `Runtime/Practice/`, `UI/Practice/` and HouseScanKit's `Practice/PracticeMeter.swift`. From 0348628d13ec91fe8175f569963c25fde058ad66 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 3 Oct 2026 04:12:48 -0500 Subject: [PATCH 29/50] ios: a close-up the phone couldn't save says so, instead of "Hold still" A failed save of the meter close-up retook with `.blurry`, so the screen told the homeowner to hold still, which can't help a phone that can't write the photo (B-20). A photo that couldn't be read back for the meter number said "Couldn't read the number", blaming the shot the same way. `KeyframeStore.saveStill` now says what happened: saved, not written, or written in a world frame discarded meanwhile. Not written, and not read back, retake as the new `.photoNotSaved`: "Your phone couldn't save that photo. Hold on the meter to try again." It counts as a failed attempt, so "Can't get a clear shot" still follows on the next one. A shot from a discarded world frame is dropped without a retake: the discard already sent the flow back to finding the meter, and a new close-up starts its own count. No photo that failed to save is listed or accepted. `-failCloseUpSave` drops the photo before the real save on a replay, so CloseUpSaveFailureUITests reaches the message and the skip through the real engine. Co-Authored-By: Claude Opus 5.5 (1M context) --- ios/HouseScan/Contract/ScanContract.swift | 3 ++ ios/HouseScan/Runtime/Autopilot.swift | 2 +- ios/HouseScan/Runtime/KeyframeStore.swift | 45 +++++++++++++---- ios/HouseScan/Runtime/LaunchOptions.swift | 5 ++ ios/HouseScan/Runtime/ScanEngine.swift | 18 +++++-- ios/HouseScan/UI/Copy/ScanCopy.swift | 1 + .../CloseUpSaveFailureUITests.swift | 49 +++++++++++++++++++ 7 files changed, 108 insertions(+), 15 deletions(-) create mode 100644 ios/HouseScanUITests/CloseUpSaveFailureUITests.swift diff --git a/ios/HouseScan/Contract/ScanContract.swift b/ios/HouseScan/Contract/ScanContract.swift index 705c3e30e..08df68dff 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. diff --git a/ios/HouseScan/Runtime/Autopilot.swift b/ios/HouseScan/Runtime/Autopilot.swift index edde51070..67612b64e 100644 --- a/ios/HouseScan/Runtime/Autopilot.swift +++ b/ios/HouseScan/Runtime/Autopilot.swift @@ -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 } diff --git a/ios/HouseScan/Runtime/KeyframeStore.swift b/ios/HouseScan/Runtime/KeyframeStore.swift index b081627b1..a5aee22e8 100644 --- a/ios/HouseScan/Runtime/KeyframeStore.swift +++ b/ios/HouseScan/Runtime/KeyframeStore.swift @@ -162,26 +162,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 diff --git a/ios/HouseScan/Runtime/LaunchOptions.swift b/ios/HouseScan/Runtime/LaunchOptions.swift index 3d659c6ed..8c17e9fc6 100644 --- a/ios/HouseScan/Runtime/LaunchOptions.swift +++ b/ios/HouseScan/Runtime/LaunchOptions.swift @@ -28,6 +28,9 @@ import OSLog /// 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`. +/// - `-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. /// - `-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. @@ -42,6 +45,7 @@ struct LaunchOptions: Equatable { var autopilotSomethingThere = false var simulateAppStore = false var injectGroundRise: Float? + var failCloseUpSave = false init( arguments: [String] = ProcessInfo.processInfo.arguments, @@ -58,6 +62,7 @@ struct LaunchOptions: Equatable { autopilotCantGetThere = arguments.contains("-autopilotCantGetThere") autopilotSomethingThere = arguments.contains("-autopilotSomethingThere") 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) } diff --git a/ios/HouseScan/Runtime/ScanEngine.swift b/ios/HouseScan/Runtime/ScanEngine.swift index 388f6e516..04c9d4edc 100644 --- a/ios/HouseScan/Runtime/ScanEngine.swift +++ b/ios/HouseScan/Runtime/ScanEngine.swift @@ -827,13 +827,23 @@ 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") @@ -860,7 +870,7 @@ final class ScanEngine { 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) + retakeCloseUp(.photoNotSaved) return } guard !readout.candidates.isEmpty else { diff --git a/ios/HouseScan/UI/Copy/ScanCopy.swift b/ios/HouseScan/UI/Copy/ScanCopy.swift index 904b82fc8..b59b8b29d 100644 --- a/ios/HouseScan/UI/Copy/ScanCopy.swift +++ b/ios/HouseScan/UI/Copy/ScanCopy.swift @@ -260,6 +260,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." } } diff --git a/ios/HouseScanUITests/CloseUpSaveFailureUITests.swift b/ios/HouseScanUITests/CloseUpSaveFailureUITests.swift new file mode 100644 index 000000000..e3d953752 --- /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)) + + // The reason stays up for `ScanEngine.retakeNotice` (2 s) before the gate's own problems + // can replace it, longer than the query's polling. + 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) + } +} From 803535db077999938f651acf68a9c9ee810849fd Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 3 Oct 2026 04:18:29 -0500 Subject: [PATCH 30/50] fix(ios): read the area card's title as one element, and scroll each line fully on screen CI run 37108821815 failed SpotConfirmUITests in two ways. The "Where to look" title read as "Show": the identifier on Label(_:systemImage:) reached the "eye" symbol, whose VoiceOver name is "Show". The symbol is now hidden and the label combined, so the title is one header element read as its words. At AX5 the area lines failed the whole-line visibility check: the test scrolled until isHittable, which needs only the element's middle on screen, and a 249 pt line stopped with its bottom off the window. The test now drags by the part off screen until the whole frame is inside the window, the condition it asserts. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../UI/Screens/SpotConfirmScreen.swift | 17 +++++++---- ios/HouseScanUITests/SpotConfirmUITests.swift | 28 +++++++++++++++++-- 2 files changed, 37 insertions(+), 8 deletions(-) diff --git a/ios/HouseScan/UI/Screens/SpotConfirmScreen.swift b/ios/HouseScan/UI/Screens/SpotConfirmScreen.swift index 51de92335..3233b0c16 100644 --- a/ios/HouseScan/UI/Screens/SpotConfirmScreen.swift +++ b/ios/HouseScan/UI/Screens/SpotConfirmScreen.swift @@ -149,11 +149,18 @@ struct SpotConfirmScreen: View { let shown = ScanCopy.spotSpace(check) let spoken = ScanCopy.spotSpace(check, spoken: true) return VStack(alignment: .leading, spacing: 10) { - Label(ScanCopy.spotSpaceTitle, systemImage: "eye") - .font(Typeface.caption) - .foregroundStyle(Palette.muted) - .accessibilityAddTraits(.isHeader) - .accessibilityIdentifier("spot.area.title") + // One element read as the title alone: VoiceOver names the "eye" symbol "Show", and + // the identifier landed on it (CI run 37108821815 read the title as "Show"). + Label { + Text(ScanCopy.spotSpaceTitle) + } icon: { + Image(systemName: "eye").accessibilityHidden(true) + } + .font(Typeface.caption) + .foregroundStyle(Palette.muted) + .accessibilityElement(children: .combine) + .accessibilityAddTraits(.isHeader) + .accessibilityIdentifier("spot.area.title") VStack(alignment: .leading, spacing: 6) { spaceLine(shown.along, spoken: spoken.along, id: "along") spaceLine(shown.out, spoken: spoken.out, id: "out") diff --git a/ios/HouseScanUITests/SpotConfirmUITests.swift b/ios/HouseScanUITests/SpotConfirmUITests.swift index e17879e35..4f2083ef5 100644 --- a/ios/HouseScanUITests/SpotConfirmUITests.swift +++ b/ios/HouseScanUITests/SpotConfirmUITests.swift @@ -103,9 +103,8 @@ final class SpotConfirmUITests: XCTestCase { let window = app.windows.firstMatch.frame for (id, expected) in [("along", spoken.along), ("out", spoken.out), ("up", spoken.up)] { let line = element(app, "spot.area.\(id)") - // At AX5 the card can sit below the fold; the homeowner scrolls to it. Slowly, as - // ScreenStatesUITests does, so momentum doesn't carry the line past the top. - for _ in 0..<6 where !line.isHittable { app.scrollViews.firstMatch.swipeUp(velocity: .slow) } + // At AX5 the card can sit below the fold; the homeowner scrolls to it. + Self.scrollOnScreen(line, in: app) let read = ElementRead.snapshot(line) XCTAssertEqual(read?.label, expected, "\(name): spot.area.\(id)") let frame = read?.frame ?? .zero @@ -137,6 +136,29 @@ final class SpotConfirmUITests: XCTestCase { return app } + /// Scrolls the screen's scroll view until the whole of `element` is inside the window, the + /// same test the visibility assertions make. `isHittable` only needs the element's middle on + /// screen: at AX5 a line 249 pt tall passed it with its bottom 176 pt below the window (CI run + /// 37108821815). Each drag moves by the part that is off screen plus a margin, at most 60% of + /// the window, and holds at the end so no momentum carries the element past the other edge. + @MainActor + static func scrollOnScreen(_ element: XCUIElement, in app: XCUIApplication) { + let window = app.windows.firstMatch.frame + let scroll = app.scrollViews.firstMatch + let margin: CGFloat = 24 + let most = window.height * 0.6 + for _ in 0..<10 { + let frame = element.frame + guard !frame.isEmpty, !window.contains(frame) else { return } + // Negative drags move the content up, bringing what is below the window into view. + let drag = frame.maxY > window.maxY + ? -min(frame.maxY - window.maxY + margin, most) + : min(window.minY - frame.minY + margin, most) + let start = scroll.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)) + start.press(forDuration: 0.05, thenDragTo: start.withOffset(CGVector(dx: 0, dy: drag)), withVelocity: .slow, thenHoldForDuration: 0.3) + } + } + /// A server answer in Fixtures/results, which the demo reads in debug builds. private static func resultFile(_ name: String, file: String = #filePath) -> String { URL(fileURLWithPath: file).deletingLastPathComponent().appending(path: "Fixtures/results/\(name).json").path From 892acdc3f215c6d551e6fd7282bd3fb82790b2ed Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 3 Oct 2026 04:19:29 -0500 Subject: [PATCH 31/50] test(ios): scroll the new spot-check checks fully on screen The reachability and result-notice tests now use #204's whole-frame scrolling instead of isHittable, so they scroll to the condition they assert. A notice taller than the window is read top edge first, then bottom edge, as a homeowner would scroll through it. Co-Authored-By: Claude Opus 5.5 (1M context) --- ios/HouseScanUITests/SpotConfirmUITests.swift | 37 ++++++++++++++++--- 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/ios/HouseScanUITests/SpotConfirmUITests.swift b/ios/HouseScanUITests/SpotConfirmUITests.swift index 51e24f8bf..6133de53b 100644 --- a/ios/HouseScanUITests/SpotConfirmUITests.swift +++ b/ios/HouseScanUITests/SpotConfirmUITests.swift @@ -111,7 +111,7 @@ final class SpotConfirmUITests: XCTestCase { let button = app.buttons[id] XCTAssertTrue(button.waitForExistence(timeout: 10), "\(variant.name): \(id) is missing") XCTAssertEqual(button.label, title, "\(variant.name): \(id)") - for _ in 0..<8 where !button.isHittable { app.scrollViews.firstMatch.swipeUp(velocity: .slow) } + 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. @@ -159,11 +159,9 @@ final class SpotConfirmUITests: XCTestCase { let app = launchResult(answer: "cannotCheck", extra) let notice = element(app, "result.spotNotChecked") XCTAssertTrue(notice.waitForExistence(timeout: 10), "\(name): no notice") - let window = app.windows.firstMatch.frame - for _ in 0..<10 where !window.contains(notice.frame) { app.scrollViews.firstMatch.swipeUp(velocity: .slow) } - let read = ElementRead.snapshot(notice) - XCTAssertEqual(read?.label, notChecked, name) - XCTAssertTrue(window.contains(read?.frame ?? .zero), "\(name): the notice at \(read?.frame ?? .zero) can't be scrolled onto the screen") + // 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) } @@ -269,6 +267,33 @@ final class SpotConfirmUITests: XCTestCase { } } + /// 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 From f44f603736dcf8a98e9bfbbd74b1786adcdf1aed Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 3 Oct 2026 04:22:38 -0500 Subject: [PATCH 32/50] ios: a close-up is acknowledged only once it reads back; the hook holds its reason up Review of 0348628d: a photo that saved but couldn't be read back still flashed as a capture and counted, then asked for a retake. The close-up is now read back once, before the acknowledgment, and those bytes feed both the thumbnail and the meter reader, so `KeyframeStore.thumbnail(ofStill:)` goes. The replay is close to the meter for under a second, so after the 2 s notice the gate's "Move closer" replaced the save-failure reason before the UI test could be sure to see it. Under `-failCloseUpSave` only, the reason stays up 10 s. Co-Authored-By: Claude Opus 5.5 (1M context) --- ios/HouseScan/Runtime/KeyframeStore.swift | 8 ---- ios/HouseScan/Runtime/LaunchOptions.swift | 1 + ios/HouseScan/Runtime/ScanEngine.swift | 45 +++++++++++-------- .../CloseUpSaveFailureUITests.swift | 4 +- 4 files changed, 29 insertions(+), 29 deletions(-) diff --git a/ios/HouseScan/Runtime/KeyframeStore.swift b/ios/HouseScan/Runtime/KeyframeStore.swift index a5aee22e8..7bb4383df 100644 --- a/ios/HouseScan/Runtime/KeyframeStore.swift +++ b/ios/HouseScan/Runtime/KeyframeStore.swift @@ -222,14 +222,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 8c17e9fc6..884e380dd 100644 --- a/ios/HouseScan/Runtime/LaunchOptions.swift +++ b/ios/HouseScan/Runtime/LaunchOptions.swift @@ -31,6 +31,7 @@ import OSLog /// - `-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. diff --git a/ios/HouseScan/Runtime/ScanEngine.swift b/ios/HouseScan/Runtime/ScanEngine.swift index 04c9d4edc..cf1c55d35 100644 --- a/ios/HouseScan/Runtime/ScanEngine.swift +++ b/ios/HouseScan/Runtime/ScanEngine.swift @@ -756,7 +756,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 +786,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 } @@ -846,33 +850,36 @@ final class ScanEngine { 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(.photoNotSaved) - return - } + closeUpCredit.photoChecked(view, passed: readout.photoPassedChecks) guard !readout.candidates.isEmpty else { retakeCloseUp(readout.retake ?? .noNumber) return diff --git a/ios/HouseScanUITests/CloseUpSaveFailureUITests.swift b/ios/HouseScanUITests/CloseUpSaveFailureUITests.swift index e3d953752..308af7c9f 100644 --- a/ios/HouseScanUITests/CloseUpSaveFailureUITests.swift +++ b/ios/HouseScanUITests/CloseUpSaveFailureUITests.swift @@ -25,8 +25,8 @@ final class CloseUpSaveFailureUITests: XCTestCase { app.buttons["action.markMeter"].tap() XCTAssertTrue(any["screen.meterCloseUp"].waitForExistence(timeout: 15)) - // The reason stays up for `ScanEngine.retakeNotice` (2 s) before the gate's own problems - // can replace it, longer than the query's polling. + // 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") From d576a46997c83f09464c2ff100452c858b964595 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 3 Oct 2026 04:39:43 -0500 Subject: [PATCH 33/50] ios: the circle's end check is one tested decision, and lost place means lost place Review of #207 at 08ed892a: - The decision behind "Wall ends here" at the circle moves into HouseScanKit as `EndAim.verdict`, with strict isolated tests (Greptile: the UI tests drive the demo engine). It reuses `ObjectTap.refusal` for the below-ground and reach rules the engine already applied, so nothing about where an end lands changes. - The phone's place counts as unknown only when it is lost or ARKit is starting, as for marks (`hasLostItsPlace`). Requiring normal tracking refused the button while the phone was only moving fast, with words saying it was finding its place. - A hit above the wall stays accepted: the ray meets the plane in the column under the circle. The refusal's doc no longer says the sky is refused. - "The wall keeps going" says the side ends where the walk reached, since `WalkedEnd` can stop short of the phone. - The meter close-up shows only while the card says "Point at the meter like this.", on the walk and on the follow-up view. A question on the card outranks that coaching, and the photo sat under it. - The refusal demo clears the end preview, as the real engine does. Co-Authored-By: Claude Opus 5.5 (1M context) --- ios/HouseScan/Contract/ScanContract.swift | 6 +- ios/HouseScan/Runtime/ScanEngine+Ends.swift | 26 ++++--- ios/HouseScan/UI/Copy/ScanCopy.swift | 5 +- ios/HouseScan/UI/Preview/DemoEngine.swift | 6 +- .../UI/Screens/GapRequestScreen.swift | 10 ++- ios/HouseScan/UI/Screens/WallWalkScreen.swift | 11 ++- .../HouseScanKit/Geometry/EndAim.swift | 41 +++++++++++ .../Tests/HouseScanKitTests/EndAimTests.swift | 73 +++++++++++++++++++ 8 files changed, 159 insertions(+), 19 deletions(-) create mode 100644 ios/HouseScanKit/Sources/HouseScanKit/Geometry/EndAim.swift create mode 100644 ios/HouseScanKit/Tests/HouseScanKitTests/EndAimTests.swift diff --git a/ios/HouseScan/Contract/ScanContract.swift b/ios/HouseScan/Contract/ScanContract.swift index 661d0a8aa..b42ccd544 100644 --- a/ios/HouseScan/Contract/ScanContract.swift +++ b/ios/HouseScan/Contract/ScanContract.swift @@ -556,11 +556,13 @@ enum NextWallRefusal: Equatable, Sendable { /// 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 within the distance a camera's view counts for - /// (`CoverageConfig.maxDistance`): on the ground, the sky, or far down a long wall. + /// 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) + /// The phone has lost its place, or ARKit is still starting. case trackingNotReady } diff --git a/ios/HouseScan/Runtime/ScanEngine+Ends.swift b/ios/HouseScan/Runtime/ScanEngine+Ends.swift index a520a79e8..d83352af8 100644 --- a/ios/HouseScan/Runtime/ScanEngine+Ends.swift +++ b/ios/HouseScan/Runtime/ScanEngine+Ends.swift @@ -99,17 +99,23 @@ extension ScanEngine { } /// Where "Wall ends here" at the circle puts the end the card asks for on `asked`, or why it - /// can't (B-06): the phone's place unknown; the circle off the wall, with no hit within the - /// distance a view counts for or a hit below the ground, which is aiming at the ground; or the - /// circle on the other side of the meter. 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. + /// 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. The phone's place counts as unknown while it is lost + /// or ARKit is still starting; moving fast or a plain surface keeps it, as for marks. func circleEnd(asked: WallSide, frame: SourceFrame, map: CoverageMap) -> Result { - guard frame.tracking == .normal else { return .failure(.trackingNotReady) } - guard let hit = nearbyWallHit(frame.camera.ray(throughPixel: frame.camera.imageSize / 2), camera: frame.camera, wall: map.wall), - hit.height >= -(ObjectTap.belowGroundSlack + max(0, map.heightError)) else { return .failure(.noWall) } - let side: WallSide = hit.s < 0 ? .left : .right - return side == asked ? .success(hit) : .failure(.otherSide(side)) + var lostPlace = frame.tracking.hasLostItsPlace + if case .limited(.initializing) = frame.tracking { lostPlace = true } + 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, lostPlace: lostPlace + ) { + case .end(let point): return .success(point) + case .lostPlace: 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. diff --git a/ios/HouseScan/UI/Copy/ScanCopy.swift b/ios/HouseScan/UI/Copy/ScanCopy.swift index dcbae5da5..35ab4fa0d 100644 --- a/ios/HouseScan/UI/Copy/ScanCopy.swift +++ b/ios/HouseScan/UI/Copy/ScanCopy.swift @@ -506,7 +506,8 @@ enum ScanCopy { /// 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 - /// phone stands, unexplored, exactly as "Can't get there" on the walk puts it. + /// 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: @@ -520,7 +521,7 @@ enum ScanCopy { case .seeBehind: (title: cannotSeeBehind, hint: "Skips the part behind it. An installer would need to look at it instead.") case .markEnd: - (title: wallKeepsGoing, hint: "Ends this side where you're standing. An installer would need to look at the wall past it.") + (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 } diff --git a/ios/HouseScan/UI/Preview/DemoEngine.swift b/ios/HouseScan/UI/Preview/DemoEngine.swift index 288fee14d..c1ab6255d 100644 --- a/ios/HouseScan/UI/Preview/DemoEngine.swift +++ b/ios/HouseScan/UI/Preview/DemoEngine.swift @@ -119,7 +119,11 @@ final class DemoEngine: ScanActions { reachedRight = demoRightEnd refreshCoverage() refreshGuidance() - if arguments.contains("-uiDemoEndMarkRefusal") { state.endMarkRefusal = .noWall } + 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 diff --git a/ios/HouseScan/UI/Screens/GapRequestScreen.swift b/ios/HouseScan/UI/Screens/GapRequestScreen.swift index b73fb7af7..41417cb76 100644 --- a/ios/HouseScan/UI/Screens/GapRequestScreen.swift +++ b/ios/HouseScan/UI/Screens/GapRequestScreen.swift @@ -22,7 +22,7 @@ struct GapRequestScreen: View { ZStack { CameraSizeReader(size: $cameraSize) CameraOverlays(state: state, highlight: state.gap, cameraWindow: cameraWindow) - if state.coaching == .relocalizing, let meterPhoto { + 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) @@ -82,7 +82,7 @@ struct GapRequestScreen: View { } } .animation(reduceMotion ? .easeOut(duration: 0.15) : Motion.pin, value: state.gap?.isSatisfied) - .animation(.easeOut(duration: 0.2), value: state.coaching == .relocalizing) + .animation(.easeOut(duration: 0.2), value: showsMeterPhoto) } /// "Show my result", on every request the check sent back, even with one view left: the @@ -166,6 +166,12 @@ struct GapRequestScreen: View { 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/WallWalkScreen.swift b/ios/HouseScan/UI/Screens/WallWalkScreen.swift index 405b0683e..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 } } @@ -479,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/Geometry/EndAim.swift b/ios/HouseScanKit/Sources/HouseScanKit/Geometry/EndAim.swift new file mode 100644 index 000000000..75ea1fa3d --- /dev/null +++ b/ios/HouseScanKit/Sources/HouseScanKit/Geometry/EndAim.swift @@ -0,0 +1,41 @@ +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) + /// The phone doesn't know where it is yet: no hit can be trusted. + case lostPlace + /// 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`. `lostPlace` wins over everything: + /// a hit from a phone that has lost its place is somewhere else in the world. + public static func verdict( + hit: WallPoint?, camera: SIMD3, wall: WallFrame, reach: Float, groundError: Float, + askedLeft: Bool, lostPlace: Bool + ) -> Verdict { + if lostPlace { return .lostPlace } + 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/Tests/HouseScanKitTests/EndAimTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/EndAimTests.swift new file mode 100644 index 000000000..73ee5cbb9 --- /dev/null +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/EndAimTests.swift @@ -0,0 +1,73 @@ +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, lostPlace: Bool = false + ) -> 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, lostPlace: lostPlace) + } + + 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 aLostPlaceWinsOverAGoodHit() { + #expect(Self.aim(toward: SIMD3(2, 1, 0), lostPlace: true) == .lostPlace) + #expect(Self.aim(toward: SIMD3(1, 0, 1), lostPlace: true) == .lostPlace) + } +} From c6f1aed41b8477e337a3af5ad9e02175b10cbd4e Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 3 Oct 2026 04:51:59 -0500 Subject: [PATCH 34/50] ios: "Wall ends here" keeps requiring normal tracking, as feature marks do d576a469 let the circle mark an end under any tracking short of a lost place. A fresh review pointed out that feature marks and the next wall still require normal tracking, and that `WalkedEnd` caps an end measured under limited tracking to kept evidence, while a circle end has no such cap. ARKit's limited pose can be off, so the gate is back to normal tracking, with the refusal words those other marks already use. `EndAim` now takes `trackingNormal`, and a test pins the meter's own column to the right side. Co-Authored-By: Claude Opus 5.5 (1M context) --- ios/HouseScan/Contract/ScanContract.swift | 2 +- ios/HouseScan/Runtime/ScanEngine+Ends.swift | 9 +++------ .../Sources/HouseScanKit/Geometry/EndAim.swift | 13 +++++++------ .../Tests/HouseScanKitTests/EndAimTests.swift | 16 +++++++++++----- 4 files changed, 22 insertions(+), 18 deletions(-) diff --git a/ios/HouseScan/Contract/ScanContract.swift b/ios/HouseScan/Contract/ScanContract.swift index b42ccd544..e97868530 100644 --- a/ios/HouseScan/Contract/ScanContract.swift +++ b/ios/HouseScan/Contract/ScanContract.swift @@ -562,7 +562,7 @@ enum EndMarkRefusal: Error, Equatable, Sendable { case noWall /// The circle is on the wall on this side of the meter, not the side the card asks about. case otherSide(WallSide) - /// The phone has lost its place, or ARKit is still starting. + /// Tracking isn't normal, as for a feature mark (`MarkRefusal.trackingNotReady`). case trackingNotReady } diff --git a/ios/HouseScan/Runtime/ScanEngine+Ends.swift b/ios/HouseScan/Runtime/ScanEngine+Ends.swift index d83352af8..b61cf80cd 100644 --- a/ios/HouseScan/Runtime/ScanEngine+Ends.swift +++ b/ios/HouseScan/Runtime/ScanEngine+Ends.swift @@ -101,18 +101,15 @@ extension ScanEngine { /// 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. The phone's place counts as unknown while it is lost - /// or ARKit is still starting; moving fast or a plain surface keeps it, as for marks. + /// shows an end the button would refuse. Only normal tracking counts, as for feature marks. func circleEnd(asked: WallSide, frame: SourceFrame, map: CoverageMap) -> Result { - var lostPlace = frame.tracking.hasLostItsPlace - if case .limited(.initializing) = frame.tracking { lostPlace = true } 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, lostPlace: lostPlace + groundError: map.heightError, askedLeft: asked == .left, trackingNormal: frame.tracking == .normal ) { case .end(let point): return .success(point) - case .lostPlace: return .failure(.trackingNotReady) + case .trackingLimited: return .failure(.trackingNotReady) case .offWall: return .failure(.noWall) case .otherSide: return .failure(.otherSide(asked == .left ? .right : .left)) } diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Geometry/EndAim.swift b/ios/HouseScanKit/Sources/HouseScanKit/Geometry/EndAim.swift index 75ea1fa3d..63085b87f 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Geometry/EndAim.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Geometry/EndAim.swift @@ -15,8 +15,9 @@ public enum EndAim { public enum Verdict: Sendable, Equatable { /// The end goes here. case end(WallPoint) - /// The phone doesn't know where it is yet: no hit can be trusted. - case lostPlace + /// 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 @@ -26,13 +27,13 @@ public enum EndAim { /// 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`. `lostPlace` wins over everything: - /// a hit from a phone that has lost its place is somewhere else in the world. + /// `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, lostPlace: Bool + askedLeft: Bool, trackingNormal: Bool ) -> Verdict { - if lostPlace { return .lostPlace } + guard trackingNormal else { return .trackingLimited } guard let hit, ObjectTap.refusal(hit, camera: camera, wall: wall, reach: reach, groundError: groundError) == nil else { return .offWall } diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/EndAimTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/EndAimTests.swift index 73ee5cbb9..6ae3b5e5e 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/EndAimTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/EndAimTests.swift @@ -15,10 +15,10 @@ import Testing /// The verdict for the circle's ray from `phone` toward `target`. static func aim( - toward target: SIMD3, askedLeft: Bool = false, groundError: Float = 0, lostPlace: Bool = false + 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, lostPlace: lostPlace) + return EndAim.verdict(hit: hit, camera: phone, wall: wall, reach: reach, groundError: groundError, askedLeft: askedLeft, trackingNormal: trackingNormal) } static func endS(_ verdict: EndAim.Verdict) -> Float? { @@ -66,8 +66,14 @@ import Testing #expect(Self.aim(toward: SIMD3(0, 1.75, 5)) == .offWall) } - @Test func aLostPlaceWinsOverAGoodHit() { - #expect(Self.aim(toward: SIMD3(2, 1, 0), lostPlace: true) == .lostPlace) - #expect(Self.aim(toward: SIMD3(1, 0, 1), lostPlace: true) == .lostPlace) + @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) } } From a7458e7c570eaf905a0781f722885f75f8de5dd1 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 3 Oct 2026 04:57:38 -0500 Subject: [PATCH 35/50] Give the repeated Try again the light screens' quiet style The second unusable-answer state used the dark secondary style, which the app keeps for buttons over the camera. On this light screen it drew as a heavy dark pill, narrower than its neighbours and stronger than Share scan, the way out the note points to (CI screenshot uploading-unusableAnswer-2, run 37112184653). It now uses the quiet style the light screens use for a second choice. Co-Authored-By: Claude Opus 5.5 (1M context) --- ios/HouseScan/UI/Screens/UploadingScreen.swift | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/ios/HouseScan/UI/Screens/UploadingScreen.swift b/ios/HouseScan/UI/Screens/UploadingScreen.swift index bee6c59de..792a59cf3 100644 --- a/ios/HouseScan/UI/Screens/UploadingScreen.swift +++ b/ios/HouseScan/UI/Screens/UploadingScreen.swift @@ -100,9 +100,10 @@ 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 steps down to a secondary button and the note points to sharing -/// the scan, so the homeowner isn't left pressing the same button. Never "Back to review": the -/// marks aren't the problem. +/// 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? @@ -118,7 +119,7 @@ private struct UnusableAnswerActions: View { .accessibilityIdentifier("upload.answerNote") Group { if repeated { - Button("Try again") { actions.retryUpload() }.buttonStyle(.secondary) + Button("Try again") { actions.retryUpload() }.buttonStyle(.quiet) } else { Button("Try again") { actions.retryUpload() }.buttonStyle(.primary) } From 1e45abaef82456140274f8e7b0145826b6b1fe45 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 3 Oct 2026 05:13:27 -0500 Subject: [PATCH 36/50] ios: a result with no spot and no nearest spot draws no sweep zones The sweep's start_ft is a range of battery left edges, not a footprint, and the answer sizes a battery only on spot or nearest_considered. With neither, the app drew each run with a width of 0 and a made-up 0.3 m depth. ResultMarkLayout.sweepZones now gives no zones without a size, and gives the same zones as before with one. The wall, summary and reasons stay. Co-Authored-By: Claude Opus 5.5 (1M context) --- ios/HouseScan/Runtime/ScanEngine+Export.swift | 25 +++--- .../HouseScanKit/Scene/ResultMarkLayout.swift | 41 ++++++++- .../ResultMarkLayoutTests.swift | 62 ++++++++++++++ .../Fixtures/results/no-spot-no-nearest.json | 84 +++++++++++++++++++ 4 files changed, 197 insertions(+), 15 deletions(-) create mode 100644 ios/HouseScanUITests/Fixtures/results/no-spot-no-nearest.json diff --git a/ios/HouseScan/Runtime/ScanEngine+Export.swift b/ios/HouseScan/Runtime/ScanEngine+Export.swift index 2047ffb97..1d23b7654 100644 --- a/ios/HouseScan/Runtime/ScanEngine+Export.swift +++ b/ios/HouseScan/Runtime/ScanEngine+Export.swift @@ -256,22 +256,23 @@ extension ScanEngine { ) } - // 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/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/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/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 From 2378edc6c16b0ce248b2fcc62391e44fe37e43aa Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 3 Oct 2026 05:14:15 -0500 Subject: [PATCH 37/50] ios: the card says a cable run past the review line needs an installer's review The answer's optional review_threshold_ft was dropped at decoding, so a run of 16 ft read "The rule is at most 20 ft" under "Not sure yet", with nothing about the 15 ft line that made it unsure. PlacementCheck now decodes the line and writes it back only when present, CheckRow carries it, and the measurement sentence names it when ResultReading.reviewBandApplies says it explains an unsure outcome. The card line and its spoken value come from the same function. The sentence says what the rule needs and never that a review was sent. Co-Authored-By: Claude Opus 5.5 (1M context) --- ios/HouseScan/Contract/ScanContract.swift | 4 + ios/HouseScan/Runtime/ScanEngine+Export.swift | 1 + ios/HouseScan/UI/Copy/ScanCopy.swift | 40 ++- .../HouseScanKit/Scene/PlacementResult.swift | 10 +- .../HouseScanKit/Scene/ResultReading.swift | 22 ++ .../ResultReadingTests.swift | 1 + .../HouseScanKitTests/ReviewBandTests.swift | 125 +++++++++ .../Fixtures/results/review-band.json | 242 ++++++++++++++++++ 8 files changed, 442 insertions(+), 3 deletions(-) create mode 100644 ios/HouseScanKit/Tests/HouseScanKitTests/ReviewBandTests.swift create mode 100644 ios/HouseScanUITests/Fixtures/results/review-band.json diff --git a/ios/HouseScan/Contract/ScanContract.swift b/ios/HouseScan/Contract/ScanContract.swift index 705c3e30e..cfa02a84b 100644 --- a/ios/HouseScan/Contract/ScanContract.swift +++ b/ios/HouseScan/Contract/ScanContract.swift @@ -665,6 +665,10 @@ 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 /// The `MissingEvidence.id` of the first view that would settle this check, when the server /// named one. var settledBy: String? = nil diff --git a/ios/HouseScan/Runtime/ScanEngine+Export.swift b/ios/HouseScan/Runtime/ScanEngine+Export.swift index 1d23b7654..6f8496f23 100644 --- a/ios/HouseScan/Runtime/ScanEngine+Export.swift +++ b/ios/HouseScan/Runtime/ScanEngine+Export.swift @@ -252,6 +252,7 @@ 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), settledBy: result.evidenceIndex(settling: check.id).map(Self.missingID) ) } diff --git a/ios/HouseScan/UI/Copy/ScanCopy.swift b/ios/HouseScan/UI/Copy/ScanCopy.swift index 904b82fc8..b49f35d2d 100644 --- a/ios/HouseScan/UI/Copy/ScanCopy.swift +++ b/ios/HouseScan/UI/Copy/ScanCopy.swift @@ -704,14 +704,26 @@ enum ScanCopy { /// 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 over 15 ft 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 +731,30 @@ enum ScanCopy { return parts.joined(separator: " ") } + /// "anything over 15 ft" under a maximum, "anything under 4 ft" over a minimum: the side of the + /// check's review line that needs review. Nil unless `ResultReading.reviewBandApplies` says + /// the line explains this check's outcome. + static func reviewBand(_ row: CheckRow, length: (Float) -> String = Distance.feetAndInches) -> String? { + guard let review = row.reviewThreshold, let comparison = row.comparison else { return nil } + let outcome: PlacementOutcome = switch row.outcome { + case .pass: .pass + case .fail: .fail + case .unsure: .unsure + } + let direction: PlacementComparison = switch comparison { + case .atLeast: .atLeast + case .atMost: .atMost + } + guard ResultReading.reviewBandApplies( + outcome: outcome, measured: row.measured.map(Double.init), plusMinus: row.plusMinus.map(Double.init), + threshold: row.threshold.map(Double.init), reviewThreshold: Double(review), comparison: direction) + else { return nil } + return switch comparison { + case .atMost: "anything over \(length(review))" + case .atLeast: "anything under \(length(review))" + } + } + /// "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/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/ResultReading.swift b/ios/HouseScanKit/Sources/HouseScanKit/Scene/ResultReading.swift index 4479dddea..d922a5738 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. diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/ResultReadingTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/ResultReadingTests.swift index a3807f77e..052d0cbb7 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), ]) 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..ad09543a9 --- /dev/null +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/ReviewBandTests.swift @@ -0,0 +1,125 @@ +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 ResultReading.reviewBandApplies( + outcome: route.outcome, measured: route.measuredFt, plusMinus: route.plusMinusFt, + threshold: route.thresholdFt, reviewThreshold: route.reviewThresholdFt, comparison: route.comparison) + } + #expect(try applies("review-band")) + #expect(try !applies("no-clean-spot")) + } + + 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/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 From 23d446a822f8dda138ef89c59396f6d757529e3a Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 3 Oct 2026 05:16:17 -0500 Subject: [PATCH 38/50] ios: UI tests for the result card's review band and an answer with no spot ResultCardUITests reads review-band, unsure-view and no-spot-no-nearest from Fixtures/results through the demo. It checks the cable run line's spoken sentence, that a check without a review line keeps its old sentence, and that an answer with neither spot shows no spot, no check lines and its summary under Details. The review band and the no-spot answer run at the default and the largest text size, with screenshots kept. Co-Authored-By: Claude Opus 5.5 (1M context) --- ios/HouseScanUITests/ResultCardUITests.swift | 154 +++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 ios/HouseScanUITests/ResultCardUITests.swift diff --git a/ios/HouseScanUITests/ResultCardUITests.swift b/ios/HouseScanUITests/ResultCardUITests.swift new file mode 100644 index 000000000..b8ac18956 --- /dev/null +++ b/ios/HouseScanUITests/ResultCardUITests.swift @@ -0,0 +1,154 @@ +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. Each test +/// keeps 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 over 15 feet " + + "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: 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. + @MainActor + private func scrollIntoView(_ target: XCUIElement, in app: XCUIApplication) { + let window = app.windows.firstMatch.frame + let scroll = app.scrollViews.firstMatch + for _ in 0..<10 where !window.contains(target.frame) { + scroll.swipeUp(velocity: .slow) + } + 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) + } +} From 23e05a68571accc3d840b6230d1a5b6258c32d00 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 3 Oct 2026 05:23:22 -0500 Subject: [PATCH 39/50] ios: decide whether the review line applies in the server's feet ScanCopy decided it from CheckRow's Float meters, and narrowing can move a value across the line: 14.5 ft plus 6 in reaches 15 ft exactly, but the same sum in Float meters fell just under it, so the card would have left the review line out. PlacementCheck.reviewBandApplies now decides on the decoded feet, and the mapping carries the answer on CheckRow. Co-Authored-By: Claude Opus 5.5 (1M context) --- ios/HouseScan/Contract/ScanContract.swift | 3 +++ ios/HouseScan/Runtime/ScanEngine+Export.swift | 2 +- ios/HouseScan/UI/Copy/ScanCopy.swift | 19 +++------------ .../HouseScanKit/Scene/ResultReading.swift | 9 ++++++++ .../HouseScanKitTests/ReviewBandTests.swift | 23 ++++++++++++++++--- 5 files changed, 36 insertions(+), 20 deletions(-) diff --git a/ios/HouseScan/Contract/ScanContract.swift b/ios/HouseScan/Contract/ScanContract.swift index cfa02a84b..9d6528f4b 100644 --- a/ios/HouseScan/Contract/ScanContract.swift +++ b/ios/HouseScan/Contract/ScanContract.swift @@ -669,6 +669,9 @@ struct CheckRow: Identifiable, Equatable, Sendable { /// 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 diff --git a/ios/HouseScan/Runtime/ScanEngine+Export.swift b/ios/HouseScan/Runtime/ScanEngine+Export.swift index 6f8496f23..1c1832946 100644 --- a/ios/HouseScan/Runtime/ScanEngine+Export.swift +++ b/ios/HouseScan/Runtime/ScanEngine+Export.swift @@ -252,7 +252,7 @@ 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), + reviewThreshold: check.reviewThresholdFt.map(meters), reviewBandApplies: check.reviewBandApplies, settledBy: result.evidenceIndex(settling: check.id).map(Self.missingID) ) } diff --git a/ios/HouseScan/UI/Copy/ScanCopy.swift b/ios/HouseScan/UI/Copy/ScanCopy.swift index b49f35d2d..744025384 100644 --- a/ios/HouseScan/UI/Copy/ScanCopy.swift +++ b/ios/HouseScan/UI/Copy/ScanCopy.swift @@ -732,23 +732,10 @@ enum ScanCopy { } /// "anything over 15 ft" under a maximum, "anything under 4 ft" over a minimum: the side of the - /// check's review line that needs review. Nil unless `ResultReading.reviewBandApplies` says - /// the line explains this check's outcome. + /// 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 let review = row.reviewThreshold, let comparison = row.comparison else { return nil } - let outcome: PlacementOutcome = switch row.outcome { - case .pass: .pass - case .fail: .fail - case .unsure: .unsure - } - let direction: PlacementComparison = switch comparison { - case .atLeast: .atLeast - case .atMost: .atMost - } - guard ResultReading.reviewBandApplies( - outcome: outcome, measured: row.measured.map(Double.init), plusMinus: row.plusMinus.map(Double.init), - threshold: row.threshold.map(Double.init), reviewThreshold: Double(review), comparison: direction) - else { return nil } + guard row.reviewBandApplies, let review = row.reviewThreshold, let comparison = row.comparison else { return nil } return switch comparison { case .atMost: "anything over \(length(review))" case .atLeast: "anything under \(length(review))" diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Scene/ResultReading.swift b/ios/HouseScanKit/Sources/HouseScanKit/Scene/ResultReading.swift index d922a5738..8330d35cd 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Scene/ResultReading.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Scene/ResultReading.swift @@ -137,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/Tests/HouseScanKitTests/ReviewBandTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/ReviewBandTests.swift index ad09543a9..09d74ecb8 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/ReviewBandTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/ReviewBandTests.swift @@ -106,14 +106,31 @@ import Testing 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 ResultReading.reviewBandApplies( - outcome: route.outcome, measured: route.measuredFt, plusMinus: route.plusMinusFt, - threshold: route.thresholdFt, reviewThreshold: route.reviewThresholdFt, comparison: route.comparison) + 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 From 7071b9523d18de41d2224bc6dd7aa05f3e7bc25d Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 3 Oct 2026 05:26:29 -0500 Subject: [PATCH 40/50] fix(ios): keep "an area wasn't checked" on a result without that spot After "I can't check this area" the area's claims stay withdrawn for the scan, but the result only said so when the answer sent back named the same spot. An answer with no spot, or a spot elsewhere, dropped the context (Greptile on #206). The scan's confirmation records are the one source: SpotConfirmations.leftAreaUnchecked is true once any check was answered "I can't check this area". ScanEngine republishes it to ScanViewState.spotAreaUnchecked whenever spotConfirm changes, so recording sets it and the existing resets (Start over, a new wall) clear it. The result keeps the spot's own notice where it applies and otherwise shows a wall-wide one: "You couldn't check an area along this wall, ...", which names no spot and no obstruction. Tests: a kit case for leftAreaUnchecked through later answers and a new record; demo result without a spot at default and AX5; and two engine runs that answer the re-upload without a spot (-sampleResultAfterSpotAnswer, debug builds) after "I can't check this area" and after "Something's there". Co-Authored-By: Claude Opus 5.5 (1M context) --- ios/HouseScan/Contract/ScanContract.swift | 4 ++ ios/HouseScan/Runtime/LaunchOptions.swift | 8 +++ ios/HouseScan/Runtime/ResultClient.swift | 4 ++ .../Runtime/ScanEngine+Confirm.swift | 4 ++ ios/HouseScan/Runtime/ScanEngine.swift | 10 ++- ios/HouseScan/UI/Copy/SpotCheckCopy.swift | 3 + ios/HouseScan/UI/Preview/DemoEngine.swift | 7 +- ios/HouseScan/UI/Screens/ResultScreen.swift | 35 ++++++---- .../Confirm/SpotConfirmation.swift | 7 ++ .../SpotConfirmationTests.swift | 26 ++++++++ .../SpotCannotCheckUITests.swift | 64 ++++++++++++++++++- ios/HouseScanUITests/SpotConfirmUITests.swift | 36 ++++++++++- ios/README.md | 3 +- 13 files changed, 191 insertions(+), 20 deletions(-) diff --git a/ios/HouseScan/Contract/ScanContract.swift b/ios/HouseScan/Contract/ScanContract.swift index dfd678168..db5b26606 100644 --- a/ios/HouseScan/Contract/ScanContract.swift +++ b/ios/HouseScan/Contract/ScanContract.swift @@ -895,6 +895,10 @@ final class ScanViewState { var result: ResultPresentation? /// The homeowner's check of the proposed spot, retained beside the result. var spotCheck: SpotCheck? + /// True once the homeowner answered "I can't check this area" in this scan. That area stays + /// out of the scan, so the result says part of the wall went unchecked even when the answer + /// after it names no spot, or a spot elsewhere. Cleared with the scan's spot checks. + var spotAreaUnchecked = 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`. diff --git a/ios/HouseScan/Runtime/LaunchOptions.swift b/ios/HouseScan/Runtime/LaunchOptions.swift index 807870dfc..aa627fb2c 100644 --- a/ios/HouseScan/Runtime/LaunchOptions.swift +++ b/ios/HouseScan/Runtime/LaunchOptions.swift @@ -24,6 +24,10 @@ import OSLog /// 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 upload after it, is answered with the server answer in this JSON file, such as +/// one without a spot. /// - `-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 @@ -43,6 +47,7 @@ struct LaunchOptions: Equatable { var autopilotCantGetThere = false var autopilotSomethingThere = false var autopilotCannotCheck = false + var sampleResultAfterSpotAnswer: URL? var simulateAppStore = false var injectGroundRise: Float? @@ -62,6 +67,9 @@ struct LaunchOptions: Equatable { 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") serverURL = (value(after: "-serverURL") ?? defaultServerURL).flatMap(Self.serverURL) sampleResult = arguments.contains("-sampleResult") diff --git a/ios/HouseScan/Runtime/ResultClient.swift b/ios/HouseScan/Runtime/ResultClient.swift index 567884fc2..a6d368012 100644 --- a/ios/HouseScan/Runtime/ResultClient.swift +++ b/ios/HouseScan/Runtime/ResultClient.swift @@ -148,6 +148,9 @@ 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 from the next upload on. + /// Set only by `-sampleResultAfterSpotAnswer`, for UI tests. + var answerFile: URL? init(pace: Double) { self.pace = pace @@ -159,6 +162,7 @@ final class SampleResultClient: ResultClient { progress(Double(step) / 4) } try await Task.sleep(for: .seconds(pace)) + if let answerFile { return try Data(contentsOf: answerFile) } guard let url = Bundle.main.url(forResource: "SampleResult", withExtension: "json") else { throw UploadError.missingSample } return try Data(contentsOf: url) } diff --git a/ios/HouseScan/Runtime/ScanEngine+Confirm.swift b/ios/HouseScan/Runtime/ScanEngine+Confirm.swift index 016001551..ae2c334c7 100644 --- a/ios/HouseScan/Runtime/ScanEngine+Confirm.swift +++ b/ios/HouseScan/Runtime/ScanEngine+Confirm.swift @@ -143,6 +143,10 @@ extension ScanEngine { try? await Task.sleep(for: .seconds(spotAnsweredHold)) guard state.phase == .spotConfirm, state.spotCheck?.id == id else { return } guard keepsClaims else { + // UI tests: the answer to this upload comes from a file (`-sampleResultAfterSpotAnswer`). + if let file = options.sampleResultAfterSpotAnswer, let sample = resultClient as? SampleResultClient { + sample.answerFile = file + } // The scan without those claims goes to the server again. startUpload() return diff --git a/ios/HouseScan/Runtime/ScanEngine.swift b/ios/HouseScan/Runtime/ScanEngine.swift index e766dd769..512c64cf8 100644 --- a/ios/HouseScan/Runtime/ScanEngine.swift +++ b/ios/HouseScan/Runtime/ScanEngine.swift @@ -215,8 +215,14 @@ 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`). `ScanViewState.spotAreaUnchecked` is read from + /// its records on every change, so recording an answer sets it and a reset clears it. + var spotConfirm = SpotConfirmState() { + didSet { + let unchecked = spotConfirm.confirmations.leftAreaUnchecked + if state.spotAreaUnchecked != unchecked { state.spotAreaUnchecked = unchecked } + } + } /// 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 diff --git a/ios/HouseScan/UI/Copy/SpotCheckCopy.swift b/ios/HouseScan/UI/Copy/SpotCheckCopy.swift index 8cabd9082..4ce3a5593 100644 --- a/ios/HouseScan/UI/Copy/SpotCheckCopy.swift +++ b/ios/HouseScan/UI/Copy/SpotCheckCopy.swift @@ -44,6 +44,9 @@ extension ScanCopy { /// 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. + static let scanNotChecked = "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." /// "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 diff --git a/ios/HouseScan/UI/Preview/DemoEngine.swift b/ios/HouseScan/UI/Preview/DemoEngine.swift index e5fb0acd0..72bd3128b 100644 --- a/ios/HouseScan/UI/Preview/DemoEngine.swift +++ b/ios/HouseScan/UI/Preview/DemoEngine.swift @@ -185,8 +185,11 @@ final class DemoEngine: ScanActions { case .spotConfirm: state.spotCheck?.answer = answer case .result: - // The result after that answer, which the result's notice reads. + // 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 scan-wide notice after "I can't check this area" remains. state.spotCheck = sampleSpotCheck(answer: answer) + state.spotAreaUnchecked = answer == .cannotCheck default: break } @@ -1077,6 +1080,7 @@ final class DemoEngine: ScanActions { followUpSkipped = false spotChecked = false state.spotCheck = nil + state.spotAreaUnchecked = false tiltUpSettled = false tiltUpTicks = 0 state.overheadQuestion = false @@ -1269,6 +1273,7 @@ extension DemoEngine { func answerSpotCheck(_ answer: SpotCheckAnswer) { guard state.phase == .spotConfirm, state.spotCheck?.answer == nil else { return } state.spotCheck?.answer = answer + if answer == .cannotCheck { state.spotAreaUnchecked = true } spotChecked = true run { engine in guard await engine.pause(1.2) else { return } diff --git a/ios/HouseScan/UI/Screens/ResultScreen.swift b/ios/HouseScan/UI/Screens/ResultScreen.swift index fbb9d11d8..32916e0aa 100644 --- a/ios/HouseScan/UI/Screens/ResultScreen.swift +++ b/ios/HouseScan/UI/Screens/ResultScreen.swift @@ -36,18 +36,7 @@ struct ResultScreen: View { badges(result) } AnswerCard(result: result, sourceAvailable: state.spatialResultAvailable, revealed: revealed, actions: actions) - if result.spot != nil { - switch state.spotCheck?.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() - } - } + spotNotices(result) footnotes(result) .padding(.horizontal, 4) details(result) @@ -72,6 +61,28 @@ struct ResultScreen: View { // MARK: Parts + /// What the homeowner said about this result's spot, and, apart from it, an 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.spotAreaUnchecked, answer != .cannotCheck { + Notice(symbol: "eye.slash", text: ScanCopy.scanNotChecked) + .accessibilityIdentifier("result.scanNotChecked") + } + } + @ViewBuilder private func diorama(_ result: ResultPresentation) -> some View { ZStack(alignment: .topLeading) { diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Confirm/SpotConfirmation.swift b/ios/HouseScanKit/Sources/HouseScanKit/Confirm/SpotConfirmation.swift index 537002a39..32e71066d 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Confirm/SpotConfirmation.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Confirm/SpotConfirmation.swift @@ -228,4 +228,11 @@ public struct SpotConfirmations: Sendable, Equatable { public func settling(_ area: SpotArea) -> SpotConfirmation? { records.last { $0.area.holds(area) } } + + /// Whether any check of this scan was answered "I can't check this area". Its claims stay + /// withdrawn for the rest of the scan (`CoverageMap.withdrawClaims`), so every later answer, + /// with a spot or without one, rests on a stretch of wall nobody checked. + public var leftAreaUnchecked: Bool { + records.contains { $0.answer == .cannotCheck } + } } diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/SpotConfirmationTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/SpotConfirmationTests.swift index d21b477f7..59fbc8c2e 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/SpotConfirmationTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/SpotConfirmationTests.swift @@ -170,6 +170,32 @@ import Testing #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 checks = SpotConfirmations() + #expect(!checks.leftAreaUnchecked) + checks.record(Self.confirmation(Self.area, .clear)) + checks.record(Self.confirmation(Self.area, .somethingThere)) + #expect(!checks.leftAreaUnchecked) + + checks.record(Self.confirmation(Self.area, .cannotCheck)) + #expect(checks.leftAreaUnchecked) + var moved = Self.area + moved.spot = 1.2...2.0 + checks.record(Self.confirmation(moved, .clear)) + var wider = Self.area + wider.span = 0.9...3.0 + checks.record(Self.confirmation(wider, .clear)) + #expect(checks.settling(Self.area)?.answer == .clear) + #expect(checks.leftAreaUnchecked) + + checks = SpotConfirmations() + #expect(!checks.leftAreaUnchecked) + } + /// 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() { diff --git a/ios/HouseScanUITests/SpotCannotCheckUITests.swift b/ios/HouseScanUITests/SpotCannotCheckUITests.swift index cc99fbcb4..dfcc2b9ee 100644 --- a/ios/HouseScanUITests/SpotCannotCheckUITests.swift +++ b/ios/HouseScanUITests/SpotCannotCheckUITests.swift @@ -6,7 +6,9 @@ import XCTest /// 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. +/// 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 @@ -94,7 +96,64 @@ final class SpotCannotCheckUITests: XCTestCase { attach(app, name: "engine-result-spotNotChecked-afterRefine") } - /// The result's notice says the area went unchecked, never that something stands there. + /// "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 runToNoSpotResult(answer: "-autopilotCannotCheck") { 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") + } + } + + /// 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 runToNoSpotResult(answer: "-autopilotSomethingThere") { 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 without a spot, then hands the held result to `check`. + @MainActor + private func runToNoSpotResult(answer: 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 noSpot = URL(fileURLWithPath: #filePath).deletingLastPathComponent().appending(path: "Fixtures/results/reject-nearest.json").path + let app = XCUIApplication() + app.launchArguments = [ + "-replay", FullFlowUITests.fixture, "-autopilot", "-autopilotHold", "1.5", "-autopilotGate", gate.path, + "-practiceMeter", "NO", "-sampleResult", answer, "-sampleResultAfterSpotAnswer", noSpot, + ] + 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) @@ -102,6 +161,7 @@ final class SpotCannotCheckUITests: XCTestCase { 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 diff --git a/ios/HouseScanUITests/SpotConfirmUITests.swift b/ios/HouseScanUITests/SpotConfirmUITests.swift index 6133de53b..876129306 100644 --- a/ios/HouseScanUITests/SpotConfirmUITests.swift +++ b/ios/HouseScanUITests/SpotConfirmUITests.swift @@ -173,14 +173,46 @@ final class SpotConfirmUITests: XCTestCase { 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)) - XCTAssertFalse(element(clear, "result.spotRefused").exists) - XCTAssertFalse(element(clear, "result.spotNotChecked").exists) + 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() + } + 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`. diff --git a/ios/README.md b/ios/README.md index 74c6581e9..71bc032ff 100644 --- a/ios/README.md +++ b/ios/README.md @@ -20,12 +20,13 @@ Native iPhone app for the AR capture walk. The homeowner marks the electric mete | `-autopilotCantGetThere` | With `-autopilot`, ends the walk the way device run 1 did. It answers "Can't get there" to the walk's first request, the ground in front of the meter, before the replay has shown it. Then, instead of marking the ends, it plays again the frame where the walk went farthest on each side and answers "Can't get there" when the walk asks to go on that way, so the end lands where the phone stands. `FullFlowUITests.testCantGetThereEndsWhereThePhoneIs` runs it. | | `-autopilotSomethingThere` | With `-autopilot`, answers the first spot check "Something's there" instead of "It's clear". The scan stops claiming that area, is uploaded again, and later checks get "It's clear". The stretch taken out goes to the `-autopilotGate` folder as `spot-refusal.json` (`withdrawn_span_ft`, feet along the wall as in scene.json). `FullFlowUITests.testSomethingThereChecksTheWallAgain` runs it. | | `-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 upload after it, is answered with the server answer in this JSON file, such as one without a spot. `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. | | `-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`). 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. 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`. From d6729733caa1e7348451fa767625119419c38215 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 3 Oct 2026 05:28:12 -0500 Subject: [PATCH 41/50] ios: an unsure line promises a photo only when it offers the camera for one On the spot-unknown run, "Clear space in front" read "One more photo would settle this" after its request was withdrawn, with no way to take the photo. The note was keyed on needsPerson alone. ResultCardActions.lineOffersView now decides whether a line offers its settling view, and ResultPresentation.offeredView applies it. The line's "Show me", its words on the card and its note under Details all read that one fact. A line with no view to take says "Needs an installer to check". Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Contract/ResultPresentation+Reading.swift | 18 +- ios/HouseScan/UI/Copy/ScanCopy.swift | 15 +- ios/HouseScan/UI/Screens/ResultScreen.swift | 25 +- .../Scene/ResultCardActions.swift | 13 + .../ResultCardActionsTests.swift | 38 +++ .../ResultReadingTests.swift | 2 +- .../Fixtures/results/view-not-offered.json | 269 ++++++++++++++++++ ios/HouseScanUITests/ResultCardUITests.swift | 41 ++- 8 files changed, 402 insertions(+), 19 deletions(-) create mode 100644 ios/HouseScanUITests/Fixtures/results/view-not-offered.json 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/UI/Copy/ScanCopy.swift b/ios/HouseScan/UI/Copy/ScanCopy.swift index 744025384..540fcc9bb 100644 --- a/ios/HouseScan/UI/Copy/ScanCopy.swift +++ b/ios/HouseScan/UI/Copy/ScanCopy.swift @@ -635,11 +635,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,8 +698,13 @@ 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." diff --git a/ios/HouseScan/UI/Screens/ResultScreen.swift b/ios/HouseScan/UI/Screens/ResultScreen.swift index 57e1c27a1..4024959ba 100644 --- a/ios/HouseScan/UI/Screens/ResultScreen.swift +++ b/ios/HouseScan/UI/Screens/ResultScreen.swift @@ -133,6 +133,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 +149,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 +315,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 +463,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 +473,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 +486,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 +508,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 +518,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/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/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/ResultReadingTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/ResultReadingTests.swift index 052d0cbb7..3f525a0ef 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/ResultReadingTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/ResultReadingTests.swift @@ -210,7 +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), + ("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/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/ResultCardUITests.swift b/ios/HouseScanUITests/ResultCardUITests.swift index b8ac18956..018d6f7fe 100644 --- a/ios/HouseScanUITests/ResultCardUITests.swift +++ b/ios/HouseScanUITests/ResultCardUITests.swift @@ -1,8 +1,8 @@ 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. Each test -/// keeps a screenshot of the card. +/// (`-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"] @@ -60,6 +60,43 @@ final class ResultCardUITests: XCTestCase { 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, From 8e8d6e8fe74d5a10ab7b948cb42c076ff7d1326a Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 3 Oct 2026 05:31:52 -0500 Subject: [PATCH 42/50] fix(ios): end the UI tests' answer file with the scan's spot checks The engine set SampleResultClient.answerFile when a spot answer sent the scan again and never cleared it, so after Start over or a new wall the next scan's first upload could get the old file (review of 7071b952). The engine now derives it from the spot records in spotConfirm's didSet, beside spotAreaUnchecked: set while this scan has an answer other than "It's clear", nil once the records reset. Debug-only test hook; release builds never parse the option. Co-Authored-By: Claude Opus 5.5 (1M context) --- ios/HouseScan/Runtime/LaunchOptions.swift | 4 ++-- ios/HouseScan/Runtime/ResultClient.swift | 5 +++-- ios/HouseScan/Runtime/ScanEngine+Confirm.swift | 4 ---- ios/HouseScan/Runtime/ScanEngine.swift | 10 ++++++++-- ios/README.md | 2 +- 5 files changed, 14 insertions(+), 11 deletions(-) diff --git a/ios/HouseScan/Runtime/LaunchOptions.swift b/ios/HouseScan/Runtime/LaunchOptions.swift index aa627fb2c..9b93d3491 100644 --- a/ios/HouseScan/Runtime/LaunchOptions.swift +++ b/ios/HouseScan/Runtime/LaunchOptions.swift @@ -26,8 +26,8 @@ import OSLog /// 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 upload after it, is answered with the server answer in this JSON file, such as -/// one without a spot. +/// 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 diff --git a/ios/HouseScan/Runtime/ResultClient.swift b/ios/HouseScan/Runtime/ResultClient.swift index a6d368012..90805058b 100644 --- a/ios/HouseScan/Runtime/ResultClient.swift +++ b/ios/HouseScan/Runtime/ResultClient.swift @@ -148,8 +148,9 @@ 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 from the next upload on. - /// Set only by `-sampleResultAfterSpotAnswer`, for UI tests. + /// 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) { diff --git a/ios/HouseScan/Runtime/ScanEngine+Confirm.swift b/ios/HouseScan/Runtime/ScanEngine+Confirm.swift index ae2c334c7..016001551 100644 --- a/ios/HouseScan/Runtime/ScanEngine+Confirm.swift +++ b/ios/HouseScan/Runtime/ScanEngine+Confirm.swift @@ -143,10 +143,6 @@ extension ScanEngine { try? await Task.sleep(for: .seconds(spotAnsweredHold)) guard state.phase == .spotConfirm, state.spotCheck?.id == id else { return } guard keepsClaims else { - // UI tests: the answer to this upload comes from a file (`-sampleResultAfterSpotAnswer`). - if let file = options.sampleResultAfterSpotAnswer, let sample = resultClient as? SampleResultClient { - sample.answerFile = file - } // The scan without those claims goes to the server again. startUpload() return diff --git a/ios/HouseScan/Runtime/ScanEngine.swift b/ios/HouseScan/Runtime/ScanEngine.swift index 512c64cf8..0b5774115 100644 --- a/ios/HouseScan/Runtime/ScanEngine.swift +++ b/ios/HouseScan/Runtime/ScanEngine.swift @@ -215,12 +215,18 @@ final class ScanEngine { } /// Every request the homeowner was shown, for the packet. var guidanceLog = GuidanceLog() - /// The spot check (`ScanEngine+Confirm.swift`). `ScanViewState.spotAreaUnchecked` is read from - /// its records on every change, so recording an answer sets it and a reset clears it. + /// 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.spotAreaUnchecked`, 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 unchecked = spotConfirm.confirmations.leftAreaUnchecked if state.spotAreaUnchecked != unchecked { state.spotAreaUnchecked = unchecked } + 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`). diff --git a/ios/README.md b/ios/README.md index 71bc032ff..7498e1bba 100644 --- a/ios/README.md +++ b/ios/README.md @@ -20,7 +20,7 @@ Native iPhone app for the AR capture walk. The homeowner marks the electric mete | `-autopilotCantGetThere` | With `-autopilot`, ends the walk the way device run 1 did. It answers "Can't get there" to the walk's first request, the ground in front of the meter, before the replay has shown it. Then, instead of marking the ends, it plays again the frame where the walk went farthest on each side and answers "Can't get there" when the walk asks to go on that way, so the end lands where the phone stands. `FullFlowUITests.testCantGetThereEndsWhereThePhoneIs` runs it. | | `-autopilotSomethingThere` | With `-autopilot`, answers the first spot check "Something's there" instead of "It's clear". The scan stops claiming that area, is uploaded again, and later checks get "It's clear". The stretch taken out goes to the `-autopilotGate` folder as `spot-refusal.json` (`withdrawn_span_ft`, feet along the wall as in scene.json). `FullFlowUITests.testSomethingThereChecksTheWallAgain` runs it. | | `-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 upload after it, is answered with the server answer in this JSON file, such as one without a spot. `SpotCannotCheckUITests` uses it. | +| `-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. | From 6cd1248d1d13ef93ba7cc8c49c3f50e25a04724f Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 3 Oct 2026 05:35:02 -0500 Subject: [PATCH 43/50] fix(ios): name an earlier unchecked area beside the spot's own notice With "I can't check this area" answered about two different spots, the second result showed only the notice about "this spot" and hid the wall-wide one, so the first area went unmentioned (Greptile on #206). The records now answer the question the result asks: SpotConfirmations.leftUnchecked(besides:) is true when an area other than the shown spot's was answered "I can't check this area" (any such area with no spot shown). The engine keeps the shown spot's area in SpotConfirmState.shownArea and republishes the answer as ScanViewState.uncheckedAreaElsewhere from spotConfirm's didSet. The result shows the wall-wide notice whenever that is true, worded "You also couldn't check another area ..." beside a notice about the spot. Tests: kit cases for areas held by, and apart from, the shown spot; demo result with both notices; an engine run where the answer after "I can't check this area" names another spot, answered clear. Co-Authored-By: Claude Opus 5.5 (1M context) --- ios/HouseScan/Contract/ScanContract.swift | 9 ++-- .../Runtime/ScanEngine+Confirm.swift | 5 +++ ios/HouseScan/Runtime/ScanEngine.swift | 7 ++-- ios/HouseScan/UI/Copy/SpotCheckCopy.swift | 6 ++- ios/HouseScan/UI/Preview/DemoEngine.swift | 8 ++-- ios/HouseScan/UI/Preview/UIDemo.swift | 2 + ios/HouseScan/UI/Screens/ResultScreen.swift | 10 ++--- .../Confirm/SpotConfirmation.swift | 14 ++++--- .../SpotConfirmationTests.swift | 41 +++++++++++++++---- .../SpotCannotCheckUITests.swift | 31 +++++++++++--- ios/HouseScanUITests/SpotConfirmUITests.swift | 10 +++++ 11 files changed, 106 insertions(+), 37 deletions(-) diff --git a/ios/HouseScan/Contract/ScanContract.swift b/ios/HouseScan/Contract/ScanContract.swift index db5b26606..4f22772ff 100644 --- a/ios/HouseScan/Contract/ScanContract.swift +++ b/ios/HouseScan/Contract/ScanContract.swift @@ -895,10 +895,11 @@ final class ScanViewState { var result: ResultPresentation? /// The homeowner's check of the proposed spot, retained beside the result. var spotCheck: SpotCheck? - /// True once the homeowner answered "I can't check this area" in this scan. That area stays - /// out of the scan, so the result says part of the wall went unchecked even when the answer - /// after it names no spot, or a spot elsewhere. Cleared with the scan's spot checks. - var spotAreaUnchecked = false + /// 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`. diff --git a/ios/HouseScan/Runtime/ScanEngine+Confirm.swift b/ios/HouseScan/Runtime/ScanEngine+Confirm.swift index 016001551..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 @@ -68,12 +71,14 @@ 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) { diff --git a/ios/HouseScan/Runtime/ScanEngine.swift b/ios/HouseScan/Runtime/ScanEngine.swift index 0b5774115..003c39044 100644 --- a/ios/HouseScan/Runtime/ScanEngine.swift +++ b/ios/HouseScan/Runtime/ScanEngine.swift @@ -217,13 +217,13 @@ final class ScanEngine { var guidanceLog = GuidanceLog() /// 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.spotAreaUnchecked`, and the UI tests' answer file + /// `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 unchecked = spotConfirm.confirmations.leftAreaUnchecked - if state.spotAreaUnchecked != unchecked { state.spotAreaUnchecked = unchecked } + 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 } @@ -1729,6 +1729,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 diff --git a/ios/HouseScan/UI/Copy/SpotCheckCopy.swift b/ios/HouseScan/UI/Copy/SpotCheckCopy.swift index 4ce3a5593..0abd76894 100644 --- a/ios/HouseScan/UI/Copy/SpotCheckCopy.swift +++ b/ios/HouseScan/UI/Copy/SpotCheckCopy.swift @@ -46,7 +46,11 @@ extension ScanCopy { 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. - static let scanNotChecked = "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." + /// 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 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 diff --git a/ios/HouseScan/UI/Preview/DemoEngine.swift b/ios/HouseScan/UI/Preview/DemoEngine.swift index 72bd3128b..2676f9555 100644 --- a/ios/HouseScan/UI/Preview/DemoEngine.swift +++ b/ios/HouseScan/UI/Preview/DemoEngine.swift @@ -187,9 +187,10 @@ final class DemoEngine: ScanActions { 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 scan-wide notice after "I can't check this area" remains. + // 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.spotAreaUnchecked = answer == .cannotCheck + state.uncheckedAreaElsewhere = (answer == .cannotCheck && state.spotCheck == nil) || arguments.contains("-uiDemoUncheckedElsewhere") default: break } @@ -1080,7 +1081,7 @@ final class DemoEngine: ScanActions { followUpSkipped = false spotChecked = false state.spotCheck = nil - state.spotAreaUnchecked = false + state.uncheckedAreaElsewhere = false tiltUpSettled = false tiltUpTicks = 0 state.overheadQuestion = false @@ -1273,7 +1274,6 @@ extension DemoEngine { func answerSpotCheck(_ answer: SpotCheckAnswer) { guard state.phase == .spotConfirm, state.spotCheck?.answer == nil else { return } state.spotCheck?.answer = answer - if answer == .cannotCheck { state.spotAreaUnchecked = true } spotChecked = true run { engine in guard await engine.pause(1.2) else { return } diff --git a/ios/HouseScan/UI/Preview/UIDemo.swift b/ios/HouseScan/UI/Preview/UIDemo.swift index 02542825e..dd2f95b75 100644 --- a/ios/HouseScan/UI/Preview/UIDemo.swift +++ b/ios/HouseScan/UI/Preview/UIDemo.swift @@ -48,6 +48,8 @@ import SwiftUI /// - `-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/Screens/ResultScreen.swift b/ios/HouseScan/UI/Screens/ResultScreen.swift index 32916e0aa..e9c989cb2 100644 --- a/ios/HouseScan/UI/Screens/ResultScreen.swift +++ b/ios/HouseScan/UI/Screens/ResultScreen.swift @@ -61,9 +61,9 @@ struct ResultScreen: View { // MARK: Parts - /// What the homeowner said about this result's spot, and, apart from it, an 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. + /// 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 @@ -77,8 +77,8 @@ struct ResultScreen: View { case .clear, nil: EmptyView() } - if state.spotAreaUnchecked, answer != .cannotCheck { - Notice(symbol: "eye.slash", text: ScanCopy.scanNotChecked) + if state.uncheckedAreaElsewhere { + Notice(symbol: "eye.slash", text: ScanCopy.scanNotChecked(besideSpotNotice: answer == .somethingThere || answer == .cannotCheck)) .accessibilityIdentifier("result.scanNotChecked") } } diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Confirm/SpotConfirmation.swift b/ios/HouseScanKit/Sources/HouseScanKit/Confirm/SpotConfirmation.swift index 32e71066d..280c54f67 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Confirm/SpotConfirmation.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Confirm/SpotConfirmation.swift @@ -229,10 +229,14 @@ public struct SpotConfirmations: Sendable, Equatable { records.last { $0.area.holds(area) } } - /// Whether any check of this scan was answered "I can't check this area". Its claims stay - /// withdrawn for the rest of the scan (`CoverageMap.withdrawClaims`), so every later answer, - /// with a spot or without one, rests on a stretch of wall nobody checked. - public var leftAreaUnchecked: Bool { - records.contains { $0.answer == .cannotCheck } + /// Whether an area other than `shown` was answered "I can't check this area" in this scan. + /// 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. An answer whose + /// area holds `shown` is about the shown spot itself, which the result speaks of separately; + /// with no spot shown (nil), every such answer counts. + public func leftUnchecked(besides shown: SpotArea?) -> Bool { + records.contains { record in + record.answer == .cannotCheck && !(shown.map { record.area.holds($0) } ?? false) + } } } diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/SpotConfirmationTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/SpotConfirmationTests.swift index 59fbc8c2e..fe21d253a 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/SpotConfirmationTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/SpotConfirmationTests.swift @@ -175,25 +175,48 @@ import Testing /// 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.leftAreaUnchecked) + #expect(!checks.leftUnchecked(besides: nil)) checks.record(Self.confirmation(Self.area, .clear)) checks.record(Self.confirmation(Self.area, .somethingThere)) - #expect(!checks.leftAreaUnchecked) + #expect(!checks.leftUnchecked(besides: nil)) + #expect(!checks.leftUnchecked(besides: moved)) checks.record(Self.confirmation(Self.area, .cannotCheck)) - #expect(checks.leftAreaUnchecked) - var moved = Self.area - moved.spot = 1.2...2.0 + #expect(checks.leftUnchecked(besides: nil)) + #expect(checks.leftUnchecked(besides: moved)) checks.record(Self.confirmation(moved, .clear)) - var wider = Self.area - wider.span = 0.9...3.0 checks.record(Self.confirmation(wider, .clear)) #expect(checks.settling(Self.area)?.answer == .clear) - #expect(checks.leftAreaUnchecked) + #expect(checks.leftUnchecked(besides: nil)) + #expect(checks.leftUnchecked(besides: moved)) checks = SpotConfirmations() - #expect(!checks.leftAreaUnchecked) + #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)) } /// The latest answer about an area wins whichever two it is, so "I can't check this area" diff --git a/ios/HouseScanUITests/SpotCannotCheckUITests.swift b/ios/HouseScanUITests/SpotCannotCheckUITests.swift index dfcc2b9ee..11b8c87b2 100644 --- a/ios/HouseScanUITests/SpotCannotCheckUITests.swift +++ b/ios/HouseScanUITests/SpotCannotCheckUITests.swift @@ -102,7 +102,7 @@ final class SpotCannotCheckUITests: XCTestCase { /// neither speaks of "this spot" nor of something standing there. @MainActor func testCannotCheckThenNoSpotStillSaysAnAreaWasNotChecked() throws { - try runToNoSpotResult(answer: "-autopilotCannotCheck") { app in + 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") @@ -113,11 +113,28 @@ final class SpotCannotCheckUITests: XCTestCase { } } + /// "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 runToNoSpotResult(answer: "-autopilotSomethingThere") { app in + 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"] { @@ -127,9 +144,11 @@ final class SpotCannotCheckUITests: XCTestCase { } /// Runs the synthetic wall to the result with `answer` as the first spot answer and every - /// upload after it answered without a spot, then hands the held result to `check`. + /// 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 runToNoSpotResult(answer: String, check: (XCUIApplication) -> Void) throws { + 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) @@ -138,11 +157,11 @@ final class SpotCannotCheckUITests: XCTestCase { for phase in ["onboarding", "findMeter", "meterCloseUp", "wallWalk", "markFeatures", "gapRequest", "uploading", "spotConfirm"] { try Data().write(to: gate.appending(path: phase)) } - let noSpot = URL(fileURLWithPath: #filePath).deletingLastPathComponent().appending(path: "Fixtures/results/reject-nearest.json").path + 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", noSpot, + "-practiceMeter", "NO", "-sampleResult", answer, "-sampleResultAfterSpotAnswer", file, ] app.launch() defer { app.terminate() } diff --git a/ios/HouseScanUITests/SpotConfirmUITests.swift b/ios/HouseScanUITests/SpotConfirmUITests.swift index 876129306..7cf469d40 100644 --- a/ios/HouseScanUITests/SpotConfirmUITests.swift +++ b/ios/HouseScanUITests/SpotConfirmUITests.swift @@ -205,6 +205,16 @@ final class SpotConfirmUITests: XCTestCase { } 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"] { From e2ef03f7971f560ec427afb05b12fed9c6b36491 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 3 Oct 2026 05:40:45 -0500 Subject: [PATCH 44/50] fix(ios): let only the spot's own not-checked notice stand for an earlier answer leftUnchecked(besides:) left out every "I can't check this area" answer whose area held the shown spot. After that answer, "It's clear" about a larger area round the same footprint, and the original area shown again, the clear answer settled it: the result had no notice about the spot, and the earlier answer was left out by geometry alone, so nothing said the area was unchecked though its claims stay withdrawn (review of 6cd1248d). An answer about the shown spot is now left out only when the answer that settles the spot is "I can't check this area", which is when the result's own notice says it. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Confirm/SpotConfirmation.swift | 18 +++++++++++------- .../SpotConfirmationTests.swift | 16 ++++++++++++++++ 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Confirm/SpotConfirmation.swift b/ios/HouseScanKit/Sources/HouseScanKit/Confirm/SpotConfirmation.swift index 280c54f67..8836f1e24 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Confirm/SpotConfirmation.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Confirm/SpotConfirmation.swift @@ -229,14 +229,18 @@ public struct SpotConfirmations: Sendable, Equatable { records.last { $0.area.holds(area) } } - /// Whether an area other than `shown` was answered "I can't check this area" in this scan. - /// 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. An answer whose - /// area holds `shown` is about the shown spot itself, which the result speaks of separately; - /// with no spot shown (nil), every such answer counts. + /// 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 { - records.contains { record in - record.answer == .cannotCheck && !(shown.map { record.area.holds($0) } ?? false) + 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/Tests/HouseScanKitTests/SpotConfirmationTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/SpotConfirmationTests.swift index fe21d253a..9acb36388 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/SpotConfirmationTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/SpotConfirmationTests.swift @@ -219,6 +219,22 @@ import Testing #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() { From f0c271cfb6068c07e2e29f208b0bb0844b4380b2 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 3 Oct 2026 05:47:12 -0500 Subject: [PATCH 45/50] ios: regenerate the project for the new result fixtures; scroll the card in measured steps Review of d6729733: XcodeGen lists every result fixture as a membership exception, so the three new fixtures changed the generated project and CI's drift check failed (run 37117085378). Regenerated with XcodeGen 2.46.0, checksum-verified against ios.yml. `scrollIntoView` swiped up a fixed amount until the line fit. At the largest text size a tall line could jump from below the window to above it, and further swipes up could never bring it back. Each drag now moves the content by what is missing, at most a third of the window, toward the line. Co-Authored-By: Claude Opus 5.5 (1M context) --- ios/HouseScan.xcodeproj/project.pbxproj | 3 +++ ios/HouseScanUITests/ResultCardUITests.swift | 16 ++++++++++++---- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/ios/HouseScan.xcodeproj/project.pbxproj b/ios/HouseScan.xcodeproj/project.pbxproj index f623d8f14..21f28f85e 100644 --- a/ios/HouseScan.xcodeproj/project.pbxproj +++ b/ios/HouseScan.xcodeproj/project.pbxproj @@ -34,9 +34,12 @@ 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/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/HouseScanUITests/ResultCardUITests.swift b/ios/HouseScanUITests/ResultCardUITests.swift index 018d6f7fe..4849d8a2f 100644 --- a/ios/HouseScanUITests/ResultCardUITests.swift +++ b/ios/HouseScanUITests/ResultCardUITests.swift @@ -153,13 +153,21 @@ final class ResultCardUITests: XCTestCase { 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. + /// 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 scroll = app.scrollViews.firstMatch - for _ in 0..<10 where !window.contains(target.frame) { - scroll.swipeUp(velocity: .slow) + 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") } From ccb86b31599b2bd0dfcff680ea6c56ead4dbbfae Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 3 Oct 2026 06:10:06 -0500 Subject: [PATCH 46/50] ios: an unusable answer's note points to Saved scans once the scan is packaged #203 wrote its notes before #205's Saved scans existed, so a packaged scan's note still said House Scan can't reopen it after the app closes, with no mention that Saved scans on the first screen can share its file then. Now the first note says so and that it can't try again from there, and the repeated note adds that Saved scans can share it later. Without a packaged scan the notes are unchanged, as is the failed-upload note #205 already aligned. Co-Authored-By: Claude Opus 5.5 (1M context) --- ios/HouseScan/UI/Screens/UploadingScreen.swift | 7 +++++-- ios/HouseScanUITests/UploadRecoveryUITests.swift | 4 ++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/ios/HouseScan/UI/Screens/UploadingScreen.swift b/ios/HouseScan/UI/Screens/UploadingScreen.swift index 52ffc010a..6dfc9af78 100644 --- a/ios/HouseScan/UI/Screens/UploadingScreen.swift +++ b/ios/HouseScan/UI/Screens/UploadingScreen.swift @@ -138,11 +138,14 @@ private struct UnusableAnswerActions: View { } } + /// 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, or use Share scan to save a copy. House Scan can't reopen it after you close the app." + 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. House Scan can't reopen this scan 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." } } diff --git a/ios/HouseScanUITests/UploadRecoveryUITests.swift b/ios/HouseScanUITests/UploadRecoveryUITests.swift index 3ad6dedfc..c375d52a4 100644 --- a/ios/HouseScanUITests/UploadRecoveryUITests.swift +++ b/ios/HouseScanUITests/UploadRecoveryUITests.swift @@ -101,6 +101,10 @@ final class UploadRecoveryUITests: XCTestCase { 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) From 260fd67eba879722fda4c24bac1f8951b8de6063 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 3 Oct 2026 06:47:35 -0500 Subject: [PATCH 47/50] test(ios): the practice meter switch is tapped once its sheet can take the tap testPracticeMeterFromReplay failed twice on hosted runs with "the switch didn't turn on" (37114486663 attempt 1, 37117066081) and passed on rerun. The failing run's snapshot shows the switch found with value 0 while its list cells still had no frames: the developer options sheet was still presenting, and the tap changed nothing. `setPracticeMeter` now waits until the switch is hittable and taps again, at most three times, only while its value is still wrong. Co-Authored-By: Claude Opus 5.5 (1M context) --- ios/HouseScanUITests/FullFlowUITests.swift | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) 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 From 55fb4b4f9afed5b3639fa42919699a7e549a29c3 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 3 Oct 2026 08:29:12 -0500 Subject: [PATCH 48/50] ios: the review line's wording includes the line itself Review of #209 (Codex): a confident pass needs measured + error < review_threshold_ft for an at-most check (result.schema.json, solver.py reach_outcome), so a run landing exactly on 15 ft already needs review, and "anything over 15 ft" left that case out. The card now says "anything 15 ft or more" under a maximum and "anything 4 ft or less" over a minimum. The decision (`reviewBandApplies`) was already inclusive; only the words change. Co-Authored-By: Claude Opus 5.5 (1M context) --- ios/HouseScan/UI/Copy/ScanCopy.swift | 10 ++++++---- ios/HouseScanUITests/ResultCardUITests.swift | 2 +- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/ios/HouseScan/UI/Copy/ScanCopy.swift b/ios/HouseScan/UI/Copy/ScanCopy.swift index 540fcc9bb..0374f36ed 100644 --- a/ios/HouseScan/UI/Copy/ScanCopy.swift +++ b/ios/HouseScan/UI/Copy/ScanCopy.swift @@ -713,7 +713,7 @@ enum ScanCopy { /// `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 over 15 ft needs an installer's + /// "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. @@ -738,14 +738,16 @@ enum ScanCopy { return parts.joined(separator: " ") } - /// "anything over 15 ft" under a maximum, "anything under 4 ft" over a minimum: the side of the + /// "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 { - case .atMost: "anything over \(length(review))" - case .atLeast: "anything under \(length(review))" + // 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" } } diff --git a/ios/HouseScanUITests/ResultCardUITests.swift b/ios/HouseScanUITests/ResultCardUITests.swift index 4849d8a2f..ebfc196b6 100644 --- a/ios/HouseScanUITests/ResultCardUITests.swift +++ b/ios/HouseScanUITests/ResultCardUITests.swift @@ -8,7 +8,7 @@ final class ResultCardUITests: XCTestCase { /// 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 over 15 feet " + 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, From 613c79dbf2503cfc8ae633125a0cd34bf8f46651 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 3 Oct 2026 08:30:03 -0500 Subject: [PATCH 49/50] test(ios): the share sheet test closes its sheet and app before it ends testShareScanOpensTheShareSheet left the system share sheet open and never terminated the app. On run 37121918073 the sheet stayed over the next test's launch for about 16 s (screen recording frames), and testSkippingTheGapRequestSends never found "I can't get there". The test now terminates its app on the way out, which waits until the app and its sheet are gone, and swipes the sheet closed first. Its assertions are unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- ios/HouseScanUITests/ScreenStatesUITests.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/ios/HouseScanUITests/ScreenStatesUITests.swift b/ios/HouseScanUITests/ScreenStatesUITests.swift index 7bf03e93b..f1cc52f85 100644 --- a/ios/HouseScanUITests/ScreenStatesUITests.swift +++ b/ios/HouseScanUITests/ScreenStatesUITests.swift @@ -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 From 485f1a61ed28d5532cfc1e4deebb3b9d3c2c8fac Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 3 Oct 2026 09:50:06 -0500 Subject: [PATCH 50/50] ci: the iOS job gets 90 minutes for the combined candidate's UI suite Run 37121918073 attempt 2 on 9f14817c spent 10 min 34 s before the UI step and 64 min 39 s in it, and the 75-minute job cap stopped it after 76 of 86 UI cases with 0 failures. The cap is the only change; no test timeout or check moves. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/ios.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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