import Foundation import Testing @testable import Kanban /// **The pre-snapshot loading state** (02-architecture.md § Launch and window lifecycle, ruled /// 2026-07-29): the board window appears immediately, wearing the registry record's cached name, /// and its content area stays empty until a short grace has passed — "so ordinary fast opens never /// flash it". /// /// Both halves are rules about *state*, not about rendering, and both are extracted so they can be /// asked without a window: `BoardLoadingIndicator` is the grace's state machine and /// `BoardWindowHost.loadingTitle` is the title rule. What SwiftUI does with either — a `ProgressView` /// in a `ZStack`, a `navigationTitle` — is one line each and is not what could go quietly wrong. /// /// **No test here waits the real grace out.** The figure is injectable for exactly that reason /// (`DragSession.holdTimeout`'s precedent), and the "after" half is also pinned directly through the /// body the clock runs, so the rule is checkable with no clock at all. // MARK: - Helpers /// Polls until `condition` holds or the deadline passes — the file's only wait, and it waits for a /// *fact* (the spinner arrived) rather than for an interval. @MainActor private func waitUntil(_ deadline: Duration = .seconds(5), _ condition: () -> Bool) async { let start = ContinuousClock.now while ContinuousClock.now - start < deadline { if condition() { return } try? await Task.sleep(for: .milliseconds(5)) } } @MainActor private func makeBoard() throws -> WriterFixture { let fixture = try WriterFixture() try fixture.item("", Item.board) try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) return fixture } /// A registry file in temp — app-side state, never inside a board folder. @MainActor private func makeRegistry() throws -> (registry: BoardRegistry, tearDown: () -> Void) { let folder = FileManager.default.temporaryDirectory .appendingPathComponent("BoardLoadingTests-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) let registry = BoardRegistry(storageURL: folder.appendingPathComponent("board-registry.json")) return (registry, { try? FileManager.default.removeItem(at: folder) }) } // MARK: - Tests @MainActor @Suite("Board loading state") struct BoardLoadingTests { // MARK: The grace @Test("Nothing shows before the grace elapses") func theSurfaceIsEmptyDuringTheGrace() async { let indicator = BoardLoadingIndicator() indicator.grace = .seconds(30) #expect(!indicator.showsSpinner, "at rest") indicator.begin() #expect(!indicator.showsSpinner, "the grace has been armed, not elapsed") } @Test("The spinner appears once the grace elapses") func theSpinnerArrivesAfterTheGrace() async { let indicator = BoardLoadingIndicator() indicator.grace = .milliseconds(20) indicator.begin() await waitUntil { indicator.showsSpinner } #expect(indicator.showsSpinner) } @Test("The grace's body is the whole of the spinner's arrival") func graceElapsedIsThePinnableHalf() { // The clock-free half of the rule above: whatever the duration, *this* is what the sleep // ends in, so a suite can assert the "after" state without a clock (`DragSession.expire`'s // precedent). let indicator = BoardLoadingIndicator() indicator.graceElapsed() #expect(indicator.showsSpinner) } @Test("A board that lands inside the grace never flashes the spinner") func fastOpenNeverFlashes() async { let indicator = BoardLoadingIndicator() indicator.grace = .milliseconds(20) // The ordinary open: the snapshot arrives before the grace is up. indicator.begin() indicator.end() #expect(!indicator.showsSpinner) // And it stays away — the disarmed grace must not fire into a window that has moved on. try? await Task.sleep(for: .milliseconds(60)) #expect(!indicator.showsSpinner) } @Test("Ending the surface clears a spinner that had already appeared") func endClearsTheSpinner() async { let indicator = BoardLoadingIndicator() indicator.grace = .milliseconds(20) indicator.begin() await waitUntil { indicator.showsSpinner } indicator.end() #expect(!indicator.showsSpinner, "the snapshot replaced the surface in place") } @Test("Arming twice does not restart the clock") func beginIsIdempotent() async { let indicator = BoardLoadingIndicator() indicator.grace = .milliseconds(20) indicator.begin() // A body that evaluates again, or a host that configures itself twice, must not push the // spinner back by another grace. indicator.begin() await waitUntil { indicator.showsSpinner } #expect(indicator.showsSpinner) } // MARK: The loading window's title @Test("A first-ever open wears the record's provisional folder name") func loadingTitleIsTheFolderNameOnAFirstOpen() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (registry, tearDown) = try makeRegistry() defer { tearDown() } // Exactly what `BoardWindowHost.start()` does before the walk: record, then read the record // back for the title. The folder name arrives as the record's own provisional display name, // not as a second rule the window applies for itself. let recordID = registry.recordOpen(of: fixture.root) #expect( BoardWindowHost.loadingTitle(record: registry.record(id: recordID), url: fixture.root) == AppModel.folderDisplayName(of: fixture.root) ) } @Test("A board that has opened before wears its cached title") func loadingTitleIsTheCachedTitle() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let (registry, tearDown) = try makeRegistry() defer { tearDown() } // The previous session's successful load, which is what stamps the cached title. let first = registry.recordOpen(of: fixture.root) registry.syncDisplayState(id: first, title: "Roadmap", icon: nil, iconColor: nil) // This session's open: the record is found again by file identity, and its cached title is // what the window is called while it walks — never the folder name it happens to sit in. let recordID = registry.recordOpen(of: fixture.root) #expect(recordID == first, "the same board must find the record it already has") #expect(BoardWindowHost.loadingTitle(record: registry.record(id: recordID), url: fixture.root) == "Roadmap") } @Test("With no record yet the title is still a no-scan name") func loadingTitleFallsBackToTheFolderName() throws { let fixture = try makeBoard() defer { fixture.tearDown() } // The one body evaluation that precedes `recordOpen`. It must not be blank, and it must not // cost a look inside the board. #expect( BoardWindowHost.loadingTitle(record: nil, url: fixture.root) == AppModel.folderDisplayName(of: fixture.root) ) } }