diff --git a/Sources/SpaceMatters/ViewModel/ScanController.swift b/Sources/SpaceMatters/ViewModel/ScanController.swift index 7945773..b29ae17 100644 --- a/Sources/SpaceMatters/ViewModel/ScanController.swift +++ b/Sources/SpaceMatters/ViewModel/ScanController.swift @@ -1370,9 +1370,24 @@ final class ScanController { // MARK: Live disk watching (SPEC-04) + /// Live watching pinned off. The incremental-refresh tests forge FSEvents + /// batches straight into `handleDiskChanges` to assert *exactly* which + /// reconcile decision a batch produces; a real stream on the same fixture + /// echoes their own writes back one `FSWatcher` latency later and adds + /// re-stats nobody asked for. A flag rather than a bare `stopWatching()` + /// because a rescan re-arms the stream. + @ObservationIgnored private(set) var liveWatchingSuspended = false + + /// Detach from the disk for good — tests that drive `handleDiskChanges` + /// themselves need the forged batches to be the only input. + func suspendLiveWatching() { + liveWatchingSuspended = true + stopWatching() + } + private func startWatching() { stopWatching() - guard isHostScan, !watchPaths.isEmpty else { return } + guard !liveWatchingSuspended, isHostScan, !watchPaths.isEmpty else { return } let w = FSWatcher(paths: watchPaths) { [weak self] changes in Task { @MainActor in self?.handleDiskChanges(changes) } } diff --git a/Tests/SpaceMattersTests/CleanupTests.swift b/Tests/SpaceMattersTests/CleanupTests.swift index 35c1ddf..685f5cf 100644 --- a/Tests/SpaceMattersTests/CleanupTests.swift +++ b/Tests/SpaceMattersTests/CleanupTests.swift @@ -25,19 +25,33 @@ import Foundation icon: "shippingbox", note: "fixture", paths: paths) } - static func waitForReady(_ c: CleanupController, timeout: TimeInterval = 5) async { + /// Sizing is a real scanner walk, and the poll below pumps the *shared* main + /// run loop — every other `@MainActor` suite running in parallel pumps it + /// too, so each round costs far more than its 20 ms slice on a loaded + /// runner. The deadline is only there to stop a hang, so it is generous; + /// what matters is that expiring is *reported*. Giving up silently is what + /// turned one slow CI sizing into five bogus failures further down + /// `toggleAllCyclesTriState` — a row that was merely still `.pending` reads + /// exactly like a row that was never there. + static func waitForReady(_ c: CleanupController, timeout: TimeInterval = 60, + sourceLocation: SourceLocation = #_sourceLocation) async { let deadline = Date().addingTimeInterval(timeout) while c.state != .ready && Date() < deadline { RunLoop.main.run(until: Date().addingTimeInterval(0.02)) await Task.yield() } + #expect(c.state == .ready, "timed out waiting for .ready, still \(c.state)", + sourceLocation: sourceLocation) } - static func waitForCleaning(_ c: CleanupController, timeout: TimeInterval = 5) async { + static func waitForCleaning(_ c: CleanupController, timeout: TimeInterval = 60, + sourceLocation: SourceLocation = #_sourceLocation) async { let deadline = Date().addingTimeInterval(timeout) while c.state != .cleaning && Date() < deadline { await Task.yield() } + #expect(c.state == .cleaning, "timed out waiting for .cleaning, still \(c.state)", + sourceLocation: sourceLocation) } // MARK: Catalog diff --git a/Tests/SpaceMattersTests/IncrementalRefreshTests.swift b/Tests/SpaceMattersTests/IncrementalRefreshTests.swift index 5814aca..9b6b891 100644 --- a/Tests/SpaceMattersTests/IncrementalRefreshTests.swift +++ b/Tests/SpaceMattersTests/IncrementalRefreshTests.swift @@ -24,6 +24,14 @@ import Foundation let c = ScanController() c.scan(url: root) await NavigationTests.waitForScan(c) + // The forged batches must be the *only* input. A real FSWatcher is armed + // on this same fixture, and it echoes the writes these tests make back + // one second later — a second batch for paths already reconciled, which + // lands as an extra re-stat or subtree pass. Harmless to the tree (both + // are idempotent), fatal to any assertion that counts them: it flaked + // `autoRestatCount == 1` on a loaded runner, where a cycle outlives the + // watcher's latency. + c.suspendLiveWatching() return c } @@ -125,8 +133,9 @@ import Foundation #expect(c.dirtyNeeds[alphaPath] == .restat) await c.refreshDirty() - // NB: no `dirtyPaths.isEmpty` assert — the *real* FSWatcher also runs on - // this temp dir and its echo of our write may legitimately re-mark it. + // The watcher is suspended, so nothing re-marks the path behind us: the + // dirty set must drain completely. + #expect(c.dirtyPaths.isEmpty) #expect(alpha.directFileCount == 2) // Identity preserved — the precise path never went through invalidate. #expect(NavigationTests.child(c.root!, "alpha") === alpha)