The parallel design session settled the tombstoned-ancestor question: a card counts as trashed if its own flag or its lane's says so, so tombstoning a lane ejects its cards from a live selection — they render nowhere once the lane collapses to a single trash entry, and nothing invisible may stay selected, drag-included, or pending-cut. One-line predicate change in Selection.resolved(against:), plus the test. Full BoardStore suite green (15 tests). Claude-Session: https://claude.ai/code/session_018BjQRYBR6jQja3jCRi5S3A
507 lines
22 KiB
Swift
507 lines
22 KiB
Swift
import Foundation
|
|
import Testing
|
|
@testable import Kanban
|
|
|
|
/// `BoardStore`'s correctness is almost entirely about *what survives what*: a failed reload must
|
|
/// not eat a good snapshot, a selection must not survive a liveness flip, a wholesale operation's
|
|
/// expectation must not be consumed by a walk that predates it. So these tests drive the store
|
|
/// through its one inbound door — `handleWatcherEvent(_:)` — against real boards in real temp
|
|
/// directories, and never through a `FolderWatcher` (which has its own suite, and whose FSEvents
|
|
/// timing would turn every assertion here into a race).
|
|
///
|
|
/// **No sleeps stand in for ordering.** `awaitQuiescence()` is how a test waits for the pipeline,
|
|
/// and the one test that needs a load pinned *open* mid-flight uses `BoardStore.loadBarrier` to pin
|
|
/// it rather than guessing at a duration. The single polling helper (`waitUntil`) waits for an
|
|
/// actor to report that a load has arrived at that barrier — a fact, not an elapsed time.
|
|
|
|
// MARK: - Fixtures
|
|
|
|
/// `WriterFixture`, `Ident` and `Item` live in `WriterTestSupport.swift`; a board store needs
|
|
/// exactly what the writer suites needed — hand-written `index.md` files in a temp tree — so this
|
|
/// file borrows them rather than growing a third copy.
|
|
|
|
/// Frontmatter that opens and closes correctly but does not parse: an unclosed flow sequence. The
|
|
/// shape a non-atomic external write leaves behind when a reload catches it mid-flight, which is the
|
|
/// case 02-architecture.md § Live-reload resilience is written about.
|
|
private let brokenIndex = "---\nschema: 1\norder: 1024\nlabels: [a, b\n---\nbody\n"
|
|
|
|
/// A card whose `deleted:` key is present — the tombstone an agent or a hand-edit adds to a file the
|
|
/// user currently has selected.
|
|
private func tombstoned(order: String, title: String) -> String {
|
|
"""
|
|
---
|
|
schema: 1
|
|
title: \(title)
|
|
order: \(order)
|
|
deleted: 2026-03-03T09:00:00Z
|
|
---
|
|
\(title) body.
|
|
|
|
"""
|
|
}
|
|
|
|
/// The board every test starts from: two lanes, two cards in the first, and one stray folder so
|
|
/// `loadWarnings` has something real to carry.
|
|
@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"))
|
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
|
|
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second"))
|
|
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
|
|
try fixture.item("notes", "not a board item at all\n")
|
|
return fixture
|
|
}
|
|
|
|
private func lane(_ id: String, in snapshot: BoardModel) -> Lane? {
|
|
snapshot.lanes.first { $0.id.rawValue == id }
|
|
}
|
|
|
|
private func cardTitles(inLane id: String, of snapshot: BoardModel) -> [String] {
|
|
(lane(id, in: snapshot)?.cards ?? []).compactMap(\.title.value)
|
|
}
|
|
|
|
/// Relative paths as `BoardLoadError` reports them — root-relative, `/`-joined.
|
|
private func indexPath(_ components: String...) -> String {
|
|
(components + ["index.md"]).joined(separator: "/")
|
|
}
|
|
|
|
// MARK: - Assertion helpers
|
|
|
|
/// The refusal-side twin of `WriterTestSupport.writeFailure`: runs `operation` expecting the store
|
|
/// to turn it away, and hands back the refusal for inspection.
|
|
@MainActor
|
|
@discardableResult
|
|
private func refusal(_ operation: () throws -> Void) -> BoardStoreWriteRefusal? {
|
|
do {
|
|
try operation()
|
|
Issue.record("expected the store to refuse the write, but it ran")
|
|
return nil
|
|
} catch let error as BoardStoreWriteRefusal {
|
|
return error
|
|
} catch {
|
|
Issue.record("expected a BoardStoreWriteRefusal, got \(error)")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
/// Counts the bracket calls a store makes, standing in for the watcher the registry will wire up.
|
|
@MainActor
|
|
private final class BracketLog {
|
|
private(set) var begins = 0
|
|
private(set) var ends = 0
|
|
|
|
/// Wired into a store the way the registry will wire its watcher.
|
|
func attach(to store: BoardStore) {
|
|
store.watcherBrackets = (begin: { self.begins += 1 }, end: { self.ends += 1 })
|
|
}
|
|
}
|
|
|
|
/// Holds a finished tree walk open until a test says otherwise, and reports when one has arrived.
|
|
///
|
|
/// This is what makes the "an in-flight walk never consumes the wholesale expectation" test a proof
|
|
/// rather than a coin flip: without it, whether the walk in question had already applied would
|
|
/// depend on how fast the disk was.
|
|
private actor LoadGate {
|
|
private(set) var arrivals = 0
|
|
private var isOpen = false
|
|
private var waiters: [CheckedContinuation<Void, Never>] = []
|
|
|
|
func arrive() async {
|
|
arrivals += 1
|
|
guard !isOpen else { return }
|
|
await withCheckedContinuation { waiters.append($0) }
|
|
}
|
|
|
|
func open() {
|
|
isOpen = true
|
|
let pending = waiters
|
|
waiters.removeAll()
|
|
for waiter in pending {
|
|
waiter.resume()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Polls until `condition` holds or the deadline passes. Used only to notice that a load has reached
|
|
/// the gate — the ordering itself is enforced by the gate, never by the interval.
|
|
private func waitUntil(_ deadline: Duration = .seconds(5), _ condition: @Sendable () async -> Bool) async {
|
|
let start = ContinuousClock.now
|
|
while ContinuousClock.now - start < deadline {
|
|
if await condition() { return }
|
|
try? await Task.sleep(for: .milliseconds(5))
|
|
}
|
|
}
|
|
|
|
// MARK: - Tests
|
|
|
|
@MainActor
|
|
@Suite("BoardStore")
|
|
struct BoardStoreTests {
|
|
|
|
// MARK: Opening
|
|
|
|
@Test("A valid board loads its snapshot and its warnings at init")
|
|
func initialLoadSucceeds() async throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
|
|
#expect(store.rootURL == fixture.root)
|
|
#expect(store.snapshot.lanes.count == 2)
|
|
#expect(cardTitles(inLane: Ident.lane1, of: store.snapshot) == ["First", "Second"])
|
|
#expect(store.loadWarnings.contains(.nonUUIDFolderIgnored(path: "notes")))
|
|
#expect(store.reloadFailure == nil)
|
|
#expect(store.readOnlyLock == nil)
|
|
#expect(!store.isReadOnly)
|
|
#expect(store.selection == .empty)
|
|
}
|
|
|
|
@Test("A broken board throws at init rather than constructing a store — fail-fast")
|
|
func initialLoadFailsFast() async throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
try fixture.item(Ident.lane1, brokenIndex)
|
|
|
|
// There is nothing to fall back on before the first load, so every later rule — the banner,
|
|
// the lock, "a failed reload never replaces a good snapshot" — has no meaning here.
|
|
do throws(BoardLoadError) {
|
|
_ = try BoardStore(rootURL: fixture.root)
|
|
Issue.record("expected the initial load to fail")
|
|
} catch {
|
|
#expect(error.path == indexPath(Ident.lane1))
|
|
if case .unparseableYAML = error.reason {} else {
|
|
Issue.record("expected unparseable YAML, got \(error.reason)")
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: Reloading
|
|
|
|
@Test("A foreign tree change reloads and the snapshot shows the external edit")
|
|
func foreignChangeReloads() async throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
|
|
// Exactly what an agent or an editor does: a card folder appears, with no Writer and no
|
|
// bracket anywhere near it.
|
|
try fixture.item("\(Ident.lane1)/\(Ident.card3)", Item.rich(order: "3072", title: "Third"))
|
|
store.handleWatcherEvent(.treeChanged(.foreign))
|
|
await store.awaitQuiescence()
|
|
|
|
#expect(cardTitles(inLane: Ident.lane1, of: store.snapshot) == ["First", "Second", "Third"])
|
|
#expect(store.reloadFailure == nil)
|
|
}
|
|
|
|
@Test("A failed reload keeps the last good snapshot, and the next success heals it")
|
|
func failedReloadKeepsTheSnapshot() async throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
let lastGood = store.snapshot
|
|
|
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)", brokenIndex)
|
|
store.handleWatcherEvent(.treeChanged(.foreign))
|
|
await store.awaitQuiescence()
|
|
|
|
#expect(store.snapshot == lastGood, "a failed reload never replaces a good snapshot")
|
|
#expect(store.reloadFailure?.path == indexPath(Ident.lane1, Ident.card1))
|
|
#expect(store.loadWarnings.contains(.nonUUIDFolderIgnored(path: "notes")), "warnings describe the snapshot on screen, so they stay with it")
|
|
|
|
// Transient breakage self-heals: the watcher kept watching and the fix arrives as an
|
|
// ordinary reload.
|
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Fixed"))
|
|
store.handleWatcherEvent(.treeChanged(.foreign))
|
|
await store.awaitQuiescence()
|
|
|
|
#expect(store.reloadFailure == nil)
|
|
#expect(cardTitles(inLane: Ident.lane1, of: store.snapshot) == ["Fixed", "Second"])
|
|
}
|
|
|
|
@Test("An ordinary failed reload does not lock the board — editing continues around the breakage")
|
|
func ordinaryFailureDoesNotLock() async throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
|
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)", brokenIndex)
|
|
store.handleWatcherEvent(.treeChanged(.foreign))
|
|
await store.awaitQuiescence()
|
|
#expect(store.reloadFailure != nil)
|
|
#expect(store.readOnlyLock == nil)
|
|
#expect(!store.isReadOnly)
|
|
|
|
// Per-file breakage: the snapshot still describes the tree, so a real write through the
|
|
// Writer is safe and must go through.
|
|
// The explicit closure signature is `performWrite`'s one ergonomic wart: with a non-`Void`
|
|
// result, `T` and the closure's thrown type cannot both be inferred, and the thrown type
|
|
// widens to `any Error`. See `performWrite`'s doc comment.
|
|
let created = try store.performWrite { () throws(BoardWriteError) -> ItemID in
|
|
try BoardWriter.createCard(inLane: fixture.url(Ident.lane2), title: "Filed anyway")
|
|
}
|
|
#expect(fixture.exists("\(Ident.lane2)/\(created.rawValue)/index.md"))
|
|
}
|
|
|
|
// MARK: Wholesale operations
|
|
|
|
@Test("A wholesale operation whose reload succeeds leaves no lock and no leaked expectation")
|
|
func wholesaleSuccessConsumesTheExpectation() async throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
let brackets = BracketLog()
|
|
brackets.attach(to: store)
|
|
|
|
try store.performWholesale {
|
|
try fixture.item(Ident.lane3, Item.rich(order: "3072", title: "Done"))
|
|
}
|
|
#expect(brackets.begins == 1)
|
|
#expect(brackets.ends == 1)
|
|
|
|
store.handleWatcherEvent(.treeChanged(.appMediated))
|
|
await store.awaitQuiescence()
|
|
|
|
#expect(store.readOnlyLock == nil)
|
|
#expect(store.reloadFailure == nil)
|
|
#expect(store.snapshot.lanes.count == 3)
|
|
|
|
// The expectation was consumed by that reload rather than left armed: an ordinary failure
|
|
// afterwards is per-file breakage again, and must not lock.
|
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)", brokenIndex)
|
|
store.handleWatcherEvent(.treeChanged(.foreign))
|
|
await store.awaitQuiescence()
|
|
|
|
#expect(store.reloadFailure != nil)
|
|
#expect(store.readOnlyLock == nil, "the wholesale expectation must not outlive the reload that consumed it")
|
|
}
|
|
|
|
@Test("A failed reload after a wholesale operation locks the board, refuses writes, and clears on the next success")
|
|
func wholesaleFailureLocksAndHeals() async throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
let brackets = BracketLog()
|
|
brackets.attach(to: store)
|
|
let lastGood = store.snapshot
|
|
|
|
// The shape of a branch switch that lands a tree this app cannot read: the snapshot on
|
|
// screen now describes something that is not there any more.
|
|
try store.performWholesale {
|
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)", brokenIndex)
|
|
}
|
|
store.handleWatcherEvent(.treeChanged(.appMediated))
|
|
await store.awaitQuiescence()
|
|
|
|
#expect(store.readOnlyLock == .bracketedReloadFailed)
|
|
#expect(store.isReadOnly)
|
|
#expect(store.reloadFailure?.path == indexPath(Ident.lane1, Ident.card1))
|
|
#expect(store.snapshot == lastGood)
|
|
|
|
// Writes are refused, and refused *before* the bracket opens — a refusal must not leave the
|
|
// watcher suspended.
|
|
var ran = false
|
|
let refused = refusal { try store.performWrite { ran = true } }
|
|
#expect(refused == .readOnlyLocked(.bracketedReloadFailed))
|
|
#expect(!ran)
|
|
#expect(brackets.begins == 1, "the refusal opened no bracket")
|
|
#expect(brackets.ends == 1)
|
|
|
|
// A locked board refuses to *start* wholesale work too.
|
|
refusal { try store.performWholesale { ran = true } }
|
|
#expect(!ran)
|
|
|
|
// Reading stays live throughout — keeping the last-good snapshot is the point of the lock.
|
|
store.select([ItemID(rawValue: Ident.card2)], liveness: .live)
|
|
#expect(store.selection.ids.count == 1)
|
|
|
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Repaired"))
|
|
store.handleWatcherEvent(.treeChanged(.foreign))
|
|
await store.awaitQuiescence()
|
|
|
|
#expect(store.readOnlyLock == nil, "the next successful reload clears both the banner and the lock")
|
|
#expect(store.reloadFailure == nil)
|
|
#expect(cardTitles(inLane: Ident.lane1, of: store.snapshot) == ["Repaired", "Second"])
|
|
|
|
try store.performWrite { ran = true }
|
|
#expect(ran)
|
|
}
|
|
|
|
@Test("The wholesale expectation binds to the walk started after it, never to one already in flight")
|
|
func wholesaleExpectationSurvivesAnInFlightLoad() async throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
|
|
let gate = LoadGate()
|
|
store.loadBarrier = { await gate.arrive() }
|
|
|
|
// Walk 1 reads the tree while it is still valid, then parks before applying.
|
|
store.handleWatcherEvent(.treeChanged(.foreign))
|
|
await waitUntil { await gate.arrivals >= 1 }
|
|
|
|
// Broken only now — walk 1 is past its read, walk 2 is not.
|
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)", brokenIndex)
|
|
|
|
// Armed while walk 1 is demonstrably still in flight. A boolean flag would be consumed by
|
|
// walk 1's *successful* application and would leave walk 2's failure unlocked.
|
|
try store.performWholesale {}
|
|
store.handleWatcherEvent(.treeChanged(.appMediated))
|
|
|
|
await gate.open()
|
|
await store.awaitQuiescence()
|
|
|
|
#expect(store.reloadGeneration == 2, "two walks: the one in flight, then the one the operation owed")
|
|
#expect(store.reloadFailure?.path == indexPath(Ident.lane1, Ident.card1))
|
|
#expect(store.readOnlyLock == .bracketedReloadFailed)
|
|
}
|
|
|
|
// MARK: Selection across reloads
|
|
|
|
@Test("A selected item that vanished from the tree leaves the selection; the survivor stays")
|
|
func selectionDropsVanishedMembers() async throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
store.select([ItemID(rawValue: Ident.card1), ItemID(rawValue: Ident.card2)], liveness: .live)
|
|
|
|
try FileManager.default.removeItem(at: fixture.url("\(Ident.lane1)/\(Ident.card1)"))
|
|
store.handleWatcherEvent(.treeChanged(.foreign))
|
|
await store.awaitQuiescence()
|
|
|
|
#expect(store.selection.ids == [ItemID(rawValue: Ident.card2)])
|
|
#expect(store.selection.liveness == .live)
|
|
}
|
|
|
|
@Test("A liveness flip is a vanish: a selected card tombstoned externally leaves the selection")
|
|
func selectionEjectsALivenessFlip() async throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
store.select([ItemID(rawValue: Ident.card1), ItemID(rawValue: Ident.card2)], liveness: .live)
|
|
|
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)", tombstoned(order: "1024", title: "First"))
|
|
store.handleWatcherEvent(.treeChanged(.foreign))
|
|
await store.awaitQuiescence()
|
|
|
|
// Still in the snapshot — the trash renders it — but no longer on the selection's side of
|
|
// the boundary, so the homogeneous-by-liveness invariant survives a foreign edit.
|
|
let flipped = lane(Ident.lane1, in: store.snapshot)?.cards.first { $0.id.rawValue == Ident.card1 }
|
|
#expect(flipped?.isDeleted == true)
|
|
#expect(store.selection.ids == [ItemID(rawValue: Ident.card2)])
|
|
}
|
|
|
|
@Test("Tombstoning a lane ejects its cards from a live selection — liveness is effective")
|
|
func selectionEjectsCardsUnderATombstonedLane() async throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
store.select([ItemID(rawValue: Ident.card1), ItemID(rawValue: Ident.card2)], liveness: .live)
|
|
|
|
try fixture.item(Ident.lane1, tombstoned(order: "1024", title: "Lane one"))
|
|
store.handleWatcherEvent(.treeChanged(.foreign))
|
|
await store.awaitQuiescence()
|
|
|
|
// The cards' own flags never changed, but their lane's did — and liveness is
|
|
// ancestor-walked (02, settled): the cards render nowhere once 03 collapses the lane to
|
|
// a single trash entry, and nothing invisible may stay selected.
|
|
let survivor = lane(Ident.lane1, in: store.snapshot)?.cards.first { $0.id.rawValue == Ident.card1 }
|
|
#expect(survivor?.isDeleted == false, "the card's own flag is untouched")
|
|
#expect(store.selection.ids.isEmpty)
|
|
}
|
|
|
|
@Test("A selection can resolve to nothing, and nothing is invented to replace it")
|
|
func selectionCanResolveToNothing() async throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
store.select([ItemID(rawValue: Ident.card1), ItemID(rawValue: Ident.card2)], liveness: .live)
|
|
|
|
try FileManager.default.removeItem(at: fixture.url("\(Ident.lane1)/\(Ident.card1)"))
|
|
try FileManager.default.removeItem(at: fixture.url("\(Ident.lane1)/\(Ident.card2)"))
|
|
store.handleWatcherEvent(.treeChanged(.foreign))
|
|
await store.awaitQuiescence()
|
|
|
|
#expect(store.selection.ids.isEmpty)
|
|
#expect(store.selection.liveness == .live, "the side survives even when the membership does not")
|
|
|
|
store.select([ItemID(rawValue: Ident.lane1)], liveness: .live)
|
|
store.clearSelection()
|
|
#expect(store.selection == .empty)
|
|
}
|
|
|
|
// MARK: Coalescing
|
|
|
|
@Test("A burst of signals during one walk coalesces into exactly one follow-up")
|
|
func signalBurstCoalescesIntoOneFollowUp() async throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
|
|
try fixture.item("\(Ident.lane1)/\(Ident.card3)", Item.rich(order: "3072", title: "Third"))
|
|
// No `await` between them, so the main actor never yields and no result can land in the
|
|
// middle: the first signal starts a walk, the other four fold into one pending reload.
|
|
for _ in 0..<5 {
|
|
store.handleWatcherEvent(.treeChanged(.foreign))
|
|
}
|
|
await store.awaitQuiescence()
|
|
|
|
#expect(store.reloadGeneration == 2, "five signals, one walk plus one follow-up")
|
|
#expect(cardTitles(inLane: Ident.lane1, of: store.snapshot) == ["First", "Second", "Third"])
|
|
#expect(store.reloadFailure == nil)
|
|
}
|
|
|
|
// MARK: Brackets
|
|
|
|
@Test("performWrite brackets exactly once, whether the operation returns or throws")
|
|
func performWriteBracketsExactlyOnce() async throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
let brackets = BracketLog()
|
|
brackets.attach(to: store)
|
|
|
|
var ran = false
|
|
try store.performWrite { ran = true }
|
|
#expect(ran)
|
|
#expect(brackets.begins == 1)
|
|
#expect(brackets.ends == 1)
|
|
|
|
// A Writer operation that fails partway has still touched disk, so the bracket has to close
|
|
// on the throwing path too — an unbalanced one would suspend the watcher for the session.
|
|
let boom = BoardWriteError(operation: "probe", path: "/nowhere", reason: .io(message: "disk full"))
|
|
do {
|
|
try store.performWrite { () throws(BoardWriteError) -> Void in throw boom }
|
|
Issue.record("expected the operation's own error to propagate")
|
|
} catch let error as BoardWriteError {
|
|
#expect(error == boom, "the store rethrows the Writer's error untouched")
|
|
} catch {
|
|
Issue.record("expected a BoardWriteError, got \(error)")
|
|
}
|
|
#expect(brackets.begins == 2)
|
|
#expect(brackets.ends == 2)
|
|
}
|
|
|
|
// MARK: Root changes
|
|
|
|
@Test("A root change is accepted and leaves the last good snapshot alone")
|
|
func rootChangeIsAStubToday() async throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
let lastGood = store.snapshot
|
|
|
|
// Documented no-op until the registry's bookmark arrives: re-resolve-or-lock needs an
|
|
// identity this store does not own yet, and guessing would lock a board that merely moved.
|
|
store.handleWatcherEvent(.rootChanged)
|
|
await store.awaitQuiescence()
|
|
|
|
#expect(store.snapshot == lastGood)
|
|
#expect(store.reloadFailure == nil)
|
|
#expect(store.readOnlyLock == nil)
|
|
#expect(store.reloadGeneration == 0, "a root change schedules no reload today")
|
|
}
|
|
}
|