Every open passes through a real loading window — the walk moves off the main actor
Phase 2 of the decision surface: the pre-snapshot loading state ruled 2026-07-29 (02 § Launch and window lifecycle), built. The board window appears immediately at its saved frame, titled with the registry record's cached name, its content a centered spinner behind an injectable ~200ms grace — no skeletons, and the first snapshot snaps in place. The tree walk runs off-main via BoardStoreRegistry.acquireOffMain (per-board single-flight keyed by file identity — concurrent opens of one root share a walk, restoration of many boards is genuinely parallel), landing in BoardStore's new designated init(rootURL:loaded:); the self-walking init survives as a convenience for its ~470 callers. ⌘W during the walk is real: configureWindow split into a loading half (frame restore, frame tracking, close interception — installed before the walk) and a store half (toolbar, widget, hideTitle, undo — installed at the snap), and the walk lives in an explicitly held BoardOpenWalk so the user's close and SwiftUI's teardown end in one cancel(). Cancellation is discard-on-completion: nil from acquireOffMain means nothing was built, nothing retained, and no open-now flag was ever set. Failure keeps today's sequence exactly: record the launch failure, welcome's row carries it, the window retires. Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -278,4 +278,123 @@ struct BoardStoreRegistryTests {
|
||||
}
|
||||
#expect(registry.openBoardCount == 0)
|
||||
}
|
||||
|
||||
// MARK: The off-main acquire
|
||||
//
|
||||
// The board window's open (02-architecture.md § Launch and window lifecycle, ruled 2026-07-29):
|
||||
// the walk runs off the main actor so the window can be on screen while it does, concurrent asks
|
||||
// for one board share it, and a cancelled open leaves nothing behind. These are claims about
|
||||
// *what is registered*, so they are asserted the way the rest of this file asserts — object
|
||||
// identity and the entry count, never a duration.
|
||||
|
||||
@Test("An already-open board answers the async acquire without a walk")
|
||||
func offMainAcquireHitsTheOpenBoard() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let registry = BoardStoreRegistry()
|
||||
|
||||
let first = try registry.acquire(fixture.root)
|
||||
let second = try await registry.acquireOffMain(fixture.root)
|
||||
|
||||
#expect(second === first, "an open board is the same store however it is asked for")
|
||||
#expect(registry.openBoardCount == 1)
|
||||
|
||||
// Two references, so the first release must not tear anything down.
|
||||
registry.release(first)
|
||||
#expect(registry.liveStore(for: fixture.root) === first)
|
||||
registry.release(first)
|
||||
#expect(registry.openBoardCount == 0)
|
||||
}
|
||||
|
||||
@Test("Concurrent async acquires of one board single-flight into one store")
|
||||
func offMainAcquiresSingleFlight() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let registry = BoardStoreRegistry()
|
||||
|
||||
// Both calls are in flight before either can finish: each hops to the main actor, and the
|
||||
// first one to get there suspends on its walk with the second right behind it.
|
||||
async let first = registry.acquireOffMain(fixture.root)
|
||||
async let second = registry.acquireOffMain(fixture.root)
|
||||
let stores = try await (first, second)
|
||||
|
||||
#expect(stores.0 != nil)
|
||||
#expect(stores.0 === stores.1, "a joined walk must produce one store, not two")
|
||||
#expect(registry.openBoardCount == 1, "two windows racing one board is still one open board")
|
||||
|
||||
// And both callers really are holding it: the refcount took both, so the first release
|
||||
// leaves the board standing.
|
||||
guard let store = stores.0 else { return }
|
||||
registry.release(store)
|
||||
#expect(registry.liveStore(for: fixture.root) === store)
|
||||
registry.release(store)
|
||||
#expect(registry.openBoardCount == 0)
|
||||
}
|
||||
|
||||
@Test("Two boards opening at once are two independent walks")
|
||||
func offMainAcquiresOfDistinctBoardsDoNotShare() async throws {
|
||||
let first = try makeBoard()
|
||||
defer { first.tearDown() }
|
||||
let second = try makeBoard()
|
||||
defer { second.tearDown() }
|
||||
let registry = BoardStoreRegistry()
|
||||
|
||||
// Restoration's shape: several windows opening together. The single flight is keyed by file
|
||||
// identity, so nothing here can queue behind anything else — a slow board holds its own key
|
||||
// and no other.
|
||||
async let one = registry.acquireOffMain(first.root)
|
||||
async let two = registry.acquireOffMain(second.root)
|
||||
let stores = try await (one, two)
|
||||
|
||||
#expect(stores.0 != nil)
|
||||
#expect(stores.1 != nil)
|
||||
#expect(stores.0 !== stores.1)
|
||||
#expect(registry.openBoardCount == 2)
|
||||
|
||||
if let store = stores.0 { registry.release(store) }
|
||||
if let store = stores.1 { registry.release(store) }
|
||||
#expect(registry.openBoardCount == 0)
|
||||
}
|
||||
|
||||
@Test("A cancelled async acquire builds no store and registers nothing")
|
||||
func cancelledOffMainAcquireLeavesNothingBehind() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let registry = BoardStoreRegistry()
|
||||
|
||||
// ⌘W during the walk. The walk itself is not cooperatively cancellable — it runs to
|
||||
// completion and its result is discarded — so what is asserted here is the discard: no
|
||||
// store, no entry, no watcher, no reference.
|
||||
let open = Task { try? await registry.acquireOffMain(fixture.root) }
|
||||
open.cancel()
|
||||
let store = await open.value
|
||||
|
||||
#expect(store == nil, "a cancelled open answers with nothing rather than a board")
|
||||
#expect(registry.openBoardCount == 0)
|
||||
#expect(registry.liveStore(for: fixture.root) == nil)
|
||||
|
||||
// And the board is still openable afterwards: a discarded walk must leave no half-state a
|
||||
// later open could trip over.
|
||||
let reopened = try await registry.acquireOffMain(fixture.root)
|
||||
#expect(reopened != nil)
|
||||
#expect(registry.openBoardCount == 1)
|
||||
if let reopened { registry.release(reopened) }
|
||||
}
|
||||
|
||||
@Test("The async acquire fails fail-fast like the synchronous one")
|
||||
func offMainAcquireOfABrokenBoardThrows() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.item(Ident.lane1, brokenIndex)
|
||||
let registry = BoardStoreRegistry()
|
||||
|
||||
do throws(BoardLoadFailure) {
|
||||
_ = try await registry.acquireOffMain(fixture.root)
|
||||
Issue.record("expected a broken board to fail")
|
||||
} catch {
|
||||
#expect(error.primary.path == "\(Ident.lane1)/index.md")
|
||||
}
|
||||
// A failed load leaves nothing behind, whichever actor walked it.
|
||||
#expect(registry.openBoardCount == 0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,6 +178,31 @@ struct BoardStoreTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test("A store built from a pre-walked result is the store the walking init would have built")
|
||||
func prewalkedInitMatchesTheWalkingInit() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
|
||||
// The two inits the board window's loading state put side by side: the walk on this actor,
|
||||
// and the same walk run somewhere else and handed over (`BoardStoreRegistry.acquireOffMain`
|
||||
// runs it on a detached task). If these ever disagreed, an open would show a different board
|
||||
// depending on which actor walked it.
|
||||
let walkedHere = try BoardStore(rootURL: fixture.root)
|
||||
let result = try BoardLoader.load(boardRoot: fixture.root)
|
||||
let walkedElsewhere = BoardStore(rootURL: fixture.root, loaded: result)
|
||||
|
||||
#expect(walkedElsewhere.rootURL == walkedHere.rootURL)
|
||||
#expect(walkedElsewhere.snapshot == walkedHere.snapshot)
|
||||
#expect(walkedElsewhere.loadWarnings == walkedHere.loadWarnings)
|
||||
#expect(walkedElsewhere.defects.count == walkedHere.defects.count)
|
||||
// The rest of the opening posture, which is what a second init could quietly get wrong.
|
||||
#expect(walkedElsewhere.reloadFailure == nil)
|
||||
#expect(walkedElsewhere.readOnlyLock == nil)
|
||||
#expect(!walkedElsewhere.isReadOnly)
|
||||
#expect(walkedElsewhere.selection == .empty)
|
||||
#expect(walkedElsewhere.reloadGeneration == 0)
|
||||
}
|
||||
|
||||
// MARK: Reloading
|
||||
|
||||
@Test("A foreign tree change reloads and the snapshot shows the external edit")
|
||||
|
||||
Reference in New Issue
Block a user