Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion Sources/SpaceMatters/ViewModel/ScanController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
}
Expand Down
18 changes: 16 additions & 2 deletions Tests/SpaceMattersTests/CleanupTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 11 additions & 2 deletions Tests/SpaceMattersTests/IncrementalRefreshTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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)
Expand Down
Loading