Surface write failures — banners, locks, one modal
The banner surface as one vocabulary (Kanban/LiveStore/BannerCenter, Kanban/UI/BannerStripView): a pure precedence rule — in-progress pinned above the collapse (ratified mid-build), lock > breakage > one-shot write failures > commit+attachment, signposts last — with all user-facing phrasing owned here via exhaustive switches over the closed WriteOperation enum; free-form English survives only in diagnostics. performWrite posts its failures before rethrowing, so no one-shot can bypass the strip; refusals under lock post nothing. The lock vocabulary completes: vanishedRoot and unwritableLocation join bracketedReloadFailed, each with its own clearing rule (unwritable clears only on a reconciling reload's writability re-probe). The registry now owns root recovery: bookmark re-resolution absorbs renames transparently, a dead root locks read-only and re-arms FSEvents on the gone path so the root's return round-trips back through rootChanged, re-minting and re-keying on the way. DirtyBufferGuard is the one modal moment, retry / save a copy / discard, no fourth button. 36 new tests; full suite 333 tests in 62 suites green. Five findings filed on the Redesign board. Claude-Session: https://claude.ai/code/session_018BjQRYBR6jQja3jCRi5S3A
This commit is contained in:
@@ -0,0 +1,493 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Kanban
|
||||
|
||||
/// `BannerCenter` is two things, and this suite is two suites: an **ordering rule** that is a pure
|
||||
/// function over six row classes, and the **product's voice** — every sentence a user reads when a
|
||||
/// write fails. The first is tested the way pure functions are, with no store and no filesystem;
|
||||
/// the second is tested for the properties phrasing has to have (non-empty, distinct per
|
||||
/// operation, titles quoted when known and graceful when not) rather than by pinning literals that
|
||||
/// would turn every copy edit into a failing test.
|
||||
///
|
||||
/// The third part — that a failed write *reaches* the strip at all — needs a real store and a real
|
||||
/// Writer failure, because the claim is about `performWrite`'s wiring rather than about the center.
|
||||
/// Those tests are at the bottom, over `WriterTestSupport`'s fixtures like every other suite here.
|
||||
|
||||
// MARK: - Fixtures
|
||||
|
||||
/// `WriterFixture`, `Ident` and `Item` come from `WriterTestSupport.swift`.
|
||||
|
||||
/// A board with one lane whose frontmatter is readable but **uneditable** — the whole-frontmatter
|
||||
/// flow mapping. It loads and renders fine; any app write to it refuses loudly, which is the
|
||||
/// cheapest genuine Writer failure there is (no permissions to fiddle with, no disk to fill).
|
||||
@MainActor
|
||||
private func makeBoardWithUneditableLane() 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.lane2, Item.uneditable)
|
||||
return fixture
|
||||
}
|
||||
|
||||
private func error(
|
||||
_ operation: WriteOperation,
|
||||
_ reason: BoardWriteError.Reason = .io(message: "the disk is full")
|
||||
) -> BoardWriteError {
|
||||
BoardWriteError(operation: operation, path: "/Boards/Work/lane/index.md", reason: reason)
|
||||
}
|
||||
|
||||
/// One representative error per `WriteOperation` case. Spelled out rather than derived so that a
|
||||
/// new case added to the enum shows up here as a missing entry the moment anyone looks — the
|
||||
/// switch in `actionPhrase(for:)` is the compile-time guard; this is the reading guard.
|
||||
private let everyOperation: [WriteOperation] = [
|
||||
.createBoard,
|
||||
.createLane,
|
||||
.createCard,
|
||||
.move(title: "Fix login"),
|
||||
.reorder(title: "Fix login"),
|
||||
.copy(title: "Fix login"),
|
||||
.delete(title: "Fix login"),
|
||||
.restore(title: "Fix login"),
|
||||
.purge(title: "Fix login"),
|
||||
.style(title: "Fix login"),
|
||||
.importAttachment(filename: "photo.png"),
|
||||
.listAttachments,
|
||||
.renumberChildren,
|
||||
]
|
||||
|
||||
/// The titled cases, and only those: `withTitle(_:)`'s own list of what can carry one.
|
||||
private let titledOperations: [(with: WriteOperation, without: WriteOperation)] = [
|
||||
(.move(title: "Fix login"), .move(title: nil)),
|
||||
(.reorder(title: "Fix login"), .reorder(title: nil)),
|
||||
(.copy(title: "Fix login"), .copy(title: nil)),
|
||||
(.delete(title: "Fix login"), .delete(title: nil)),
|
||||
(.restore(title: "Fix login"), .restore(title: nil)),
|
||||
(.purge(title: "Fix login"), .purge(title: nil)),
|
||||
(.style(title: "Fix login"), .style(title: nil)),
|
||||
]
|
||||
|
||||
// MARK: - Ordering
|
||||
|
||||
@MainActor
|
||||
@Suite("BannerCenter ▸ ordering")
|
||||
struct BannerCenterOrderingTests {
|
||||
|
||||
@Test("Every class present produces the settled precedence order")
|
||||
func everyClassOrdersByPrecedence() {
|
||||
let move = OneShotBanner(error: error(.move(title: "Fix login")), occurredAt: Date(timeIntervalSince1970: 100))
|
||||
let attachment = OneShotBanner(
|
||||
error: error(.importAttachment(filename: "photo.png")),
|
||||
occurredAt: Date(timeIntervalSince1970: 200)
|
||||
)
|
||||
let operation = InProgressOperation(label: "Pulling…")
|
||||
let signpost = InfoSignpost(message: "This card changed on the remote")
|
||||
|
||||
let rows = BannerCenter.rows(
|
||||
lock: .vanishedRoot,
|
||||
breakage: BoardLoadError(path: "todo/index.md", reason: .missingOrder),
|
||||
oneShots: [attachment, move],
|
||||
suspension: HistorySuspension(reason: "disk full", since: Date(timeIntervalSince1970: 50)),
|
||||
operations: [operation],
|
||||
signposts: [signpost]
|
||||
)
|
||||
|
||||
// in-progress (pinned) > read-only lock > reload breakage > one-shot write failures >
|
||||
// commit and attachment failures > passive info rows. The two info classes sit at opposite
|
||||
// ends of the strip.
|
||||
#expect(rows.map(\.id) == [
|
||||
"operation:\(operation.id.uuidString)",
|
||||
"read-only-lock",
|
||||
"reload-breakage",
|
||||
"one-shot:\(move.id.uuidString)",
|
||||
"history-suspension",
|
||||
"one-shot:\(attachment.id.uuidString)",
|
||||
"signpost:\(signpost.id.uuidString)",
|
||||
])
|
||||
#expect(rows.map(\.tone) == [.info, .error, .error, .error, .warning, .error, .info])
|
||||
#expect(rows.map(\.isPinned) == [true, false, false, false, false, false, false],
|
||||
"a spinner may never hide behind '+N more' — nothing else is pinned")
|
||||
}
|
||||
|
||||
@Test("An attachment failure ranks below other one-shots even when it is newer")
|
||||
func attachmentFailuresRankLast() {
|
||||
let attachment = OneShotBanner(
|
||||
error: error(.importAttachment(filename: "photo.png")),
|
||||
occurredAt: Date(timeIntervalSince1970: 900)
|
||||
)
|
||||
let style = OneShotBanner(error: error(.style(title: "Old")), occurredAt: Date(timeIntervalSince1970: 1))
|
||||
|
||||
let rows = BannerCenter.rows(lock: nil, breakage: nil, oneShots: [attachment, style], suspension: nil, operations: [])
|
||||
|
||||
#expect(rows.map(\.id) == ["one-shot:\(style.id.uuidString)", "one-shot:\(attachment.id.uuidString)"],
|
||||
"precedence class outranks recency; recency only orders within a class")
|
||||
}
|
||||
|
||||
@Test("One-shots order newest first within their class")
|
||||
func oneShotsAreNewestFirst() {
|
||||
let oldest = OneShotBanner(error: error(.move(title: "A")), occurredAt: Date(timeIntervalSince1970: 1))
|
||||
let middle = OneShotBanner(error: error(.move(title: "B")), occurredAt: Date(timeIntervalSince1970: 2))
|
||||
let newest = OneShotBanner(error: error(.move(title: "C")), occurredAt: Date(timeIntervalSince1970: 3))
|
||||
|
||||
let rows = BannerCenter.rows(
|
||||
lock: nil,
|
||||
breakage: nil,
|
||||
oneShots: [middle, oldest, newest],
|
||||
suspension: nil,
|
||||
operations: []
|
||||
)
|
||||
|
||||
#expect(rows.map(\.id) == [
|
||||
"one-shot:\(newest.id.uuidString)",
|
||||
"one-shot:\(middle.id.uuidString)",
|
||||
"one-shot:\(oldest.id.uuidString)",
|
||||
])
|
||||
}
|
||||
|
||||
@Test("Failures sharing a timestamp keep the order they were posted in")
|
||||
func tiesAreStable() {
|
||||
let center = BannerCenter()
|
||||
// Posted in one run loop turn: `Date()` may well hand both the same value, and the strip
|
||||
// must not shuffle between renders because of it.
|
||||
center.post(error(.move(title: "First")))
|
||||
center.post(error(.move(title: "Second")))
|
||||
center.post(error(.move(title: "Third")))
|
||||
|
||||
let rows = BannerCenter.rows(
|
||||
lock: nil,
|
||||
breakage: nil,
|
||||
oneShots: center.oneShots,
|
||||
suspension: nil,
|
||||
operations: []
|
||||
)
|
||||
#expect(rows.map(\.headline).map { $0.contains("'Third'") } == [true, false, false])
|
||||
#expect(rows.count == 3)
|
||||
}
|
||||
|
||||
@Test("Nothing standing is an empty strip")
|
||||
func quietBoardHasNoRows() {
|
||||
#expect(BannerCenter.rows(lock: nil, breakage: nil, oneShots: [], suspension: nil, operations: []).isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Lifecycles
|
||||
|
||||
@MainActor
|
||||
@Suite("BannerCenter ▸ lifecycles")
|
||||
struct BannerCenterLifecycleTests {
|
||||
|
||||
@Test("One-shots dismiss individually; conditions carry no dismiss control at all")
|
||||
func oneShotsDismissIndividually() throws {
|
||||
let center = BannerCenter()
|
||||
center.post(error(.move(title: "First")))
|
||||
center.post(error(.style(title: "Second")))
|
||||
#expect(center.oneShots.count == 2)
|
||||
|
||||
let doomed = try #require(center.oneShots.first)
|
||||
center.dismiss(doomed.id)
|
||||
|
||||
#expect(center.oneShots.count == 1)
|
||||
#expect(center.oneShots.first?.id != doomed.id, "dismissing one must not take its neighbour")
|
||||
|
||||
// The condition rows have no id to dismiss with — the API shape *is* the rule ("conditions
|
||||
// heal", 02 § The banner surface), and this is where it is stated as a test.
|
||||
let rows = BannerCenter.rows(
|
||||
lock: .bracketedReloadFailed,
|
||||
breakage: BoardLoadError(path: ".", reason: .boardRootMissingIndex),
|
||||
oneShots: center.oneShots,
|
||||
suspension: HistorySuspension(reason: "disk full"),
|
||||
operations: [InProgressOperation(label: "Pulling…")]
|
||||
)
|
||||
#expect(rows.filter { $0.dismissID != nil }.count == 1, "only the one-shot may be dismissed")
|
||||
}
|
||||
|
||||
@Test("Dismissing an in-progress operation's id does nothing — dismiss is not cancel")
|
||||
func dismissDoesNotEndOperations() {
|
||||
let center = BannerCenter()
|
||||
let id = center.beginOperation(label: "Importing…", cancel: nil)
|
||||
center.dismiss(id)
|
||||
#expect(center.operations.count == 1)
|
||||
}
|
||||
|
||||
@Test("Suspending history raises a warning row; clearing it takes the row away")
|
||||
func historySuspensionIsAHealingCondition() throws {
|
||||
let center = BannerCenter()
|
||||
#expect(center.historySuspension == nil)
|
||||
|
||||
center.suspendHistory(reason: "the repository is corrupt")
|
||||
let suspension = try #require(center.historySuspension)
|
||||
#expect(suspension.reason == "the repository is corrupt")
|
||||
|
||||
let rows = BannerCenter.rows(lock: nil, breakage: nil, oneShots: [], suspension: center.historySuspension, operations: [])
|
||||
#expect(rows.count == 1)
|
||||
#expect(rows[0].tone == .warning, "the files are safe; only the undo trail is degraded")
|
||||
#expect(rows[0].headline.contains("history"))
|
||||
#expect(rows[0].dismissID == nil)
|
||||
|
||||
center.clearHistorySuspension()
|
||||
#expect(center.historySuspension == nil)
|
||||
#expect(BannerCenter.rows(lock: nil, breakage: nil, oneShots: [], suspension: center.historySuspension, operations: []).isEmpty)
|
||||
}
|
||||
|
||||
@Test("Re-suspending keeps the original start and takes the newer diagnosis")
|
||||
func resuspendingKeepsTheClock() throws {
|
||||
let center = BannerCenter()
|
||||
center.suspendHistory(reason: "disk full")
|
||||
let first = try #require(center.historySuspension)
|
||||
|
||||
center.suspendHistory(reason: "the repository is corrupt")
|
||||
let second = try #require(center.historySuspension)
|
||||
|
||||
#expect(second.since == first.since, "the condition never stopped being true")
|
||||
#expect(second.reason == "the repository is corrupt")
|
||||
}
|
||||
|
||||
@Test("Beginning an operation shows an info row; ending it clears the row")
|
||||
func operationsCompleteByLeaving() {
|
||||
let center = BannerCenter()
|
||||
let id = center.beginOperation(label: "Pulling…", cancel: nil)
|
||||
|
||||
var rows = BannerCenter.rows(lock: nil, breakage: nil, oneShots: [], suspension: nil, operations: center.operations)
|
||||
#expect(rows.count == 1)
|
||||
#expect(rows[0].tone == .info)
|
||||
#expect(rows[0].headline == "Pulling…")
|
||||
guard case let .inProgress(operation) = rows[0] else {
|
||||
Issue.record("expected an in-progress row")
|
||||
return
|
||||
}
|
||||
#expect(!operation.isCancelable, "git brackets get no Cancel — settled")
|
||||
|
||||
center.endOperation(id)
|
||||
rows = BannerCenter.rows(lock: nil, breakage: nil, oneShots: [], suspension: nil, operations: center.operations)
|
||||
#expect(rows.isEmpty)
|
||||
}
|
||||
|
||||
@Test("Copy-shaped work carries Cancel, and cancelling is the caller's closure")
|
||||
func cancelableOperationsRunTheirClosure() throws {
|
||||
let center = BannerCenter()
|
||||
let cancelled = Box()
|
||||
let id = center.beginOperation(label: "Importing 24 attachments…", cancel: { cancelled.value = true })
|
||||
|
||||
let operation = try #require(center.operations.first)
|
||||
#expect(operation.isCancelable)
|
||||
operation.cancel?()
|
||||
#expect(cancelled.value)
|
||||
|
||||
center.endOperation(id)
|
||||
#expect(center.operations.isEmpty)
|
||||
}
|
||||
|
||||
@Test("A signpost is a calm, dismissable row that ranks last and may collapse")
|
||||
func signpostsRankLastAndDismiss() throws {
|
||||
let center = BannerCenter()
|
||||
center.postSignpost("This card changed on the remote — your edits still win")
|
||||
center.post(error(.move(title: "Fix login")))
|
||||
|
||||
let rows = BannerCenter.rows(
|
||||
lock: nil,
|
||||
breakage: nil,
|
||||
oneShots: center.oneShots,
|
||||
suspension: nil,
|
||||
operations: center.operations,
|
||||
signposts: center.signposts
|
||||
)
|
||||
#expect(rows.count == 2)
|
||||
#expect(rows.last?.tone == .info, "same tone as an in-progress row, opposite end of the strip")
|
||||
#expect(rows.last?.isPinned == false, "calm by design — nothing is gated on seeing it instantly")
|
||||
|
||||
let dismissID = try #require(rows.last?.dismissID)
|
||||
center.dismiss(dismissID)
|
||||
#expect(center.signposts.isEmpty)
|
||||
#expect(center.oneShots.count == 1, "dismissing a signpost leaves the failure standing")
|
||||
}
|
||||
|
||||
@Test("A failed operation is an end plus a post — the row swaps for the error")
|
||||
func failureSwapsTheRow() {
|
||||
let center = BannerCenter()
|
||||
let id = center.beginOperation(label: "Importing 'photo.png'…", cancel: nil)
|
||||
center.endOperation(id)
|
||||
center.post(error(.importAttachment(filename: "photo.png"), .unreadable(message: "the source file could not be read")))
|
||||
|
||||
let rows = BannerCenter.rows(
|
||||
lock: nil,
|
||||
breakage: nil,
|
||||
oneShots: center.oneShots,
|
||||
suspension: nil,
|
||||
operations: center.operations
|
||||
)
|
||||
#expect(rows.count == 1)
|
||||
#expect(rows[0].tone == .error)
|
||||
#expect(rows[0].headline == "Couldn't import 'photo.png' — the source file could not be read")
|
||||
}
|
||||
}
|
||||
|
||||
/// A one-field reference cell, so a `@Sendable` cancel closure has somewhere to record that it ran.
|
||||
@MainActor
|
||||
private final class Box {
|
||||
var value = false
|
||||
}
|
||||
|
||||
// MARK: - Phrasing
|
||||
|
||||
@MainActor
|
||||
@Suite("BannerCenter ▸ phrasing")
|
||||
struct BannerCenterPhrasingTests {
|
||||
|
||||
@Test("Every write operation has its own non-empty headline")
|
||||
func everyOperationSaysSomethingDistinct() {
|
||||
let headlines = everyOperation.map { BannerCenter.headline(for: error($0)) }
|
||||
|
||||
for (operation, headline) in zip(everyOperation, headlines) {
|
||||
#expect(!headline.isEmpty, "\(operation) has no headline")
|
||||
#expect(headline.hasPrefix("Couldn't "), "\(operation) does not name what failed")
|
||||
#expect(!headline.contains("nil"), "\(operation) leaked an optional into the product's voice")
|
||||
}
|
||||
#expect(Set(headlines).count == headlines.count, "two operations share a sentence — one of them is wrong")
|
||||
}
|
||||
|
||||
@Test("Titles are quoted when known and the phrasing stays graceful when they are not")
|
||||
func titlesAreQuotedOrGracefullyAbsent() {
|
||||
for (withTitle, withoutTitle) in titledOperations {
|
||||
let named = BannerCenter.headline(for: error(withTitle))
|
||||
let anonymous = BannerCenter.headline(for: error(withoutTitle))
|
||||
|
||||
#expect(named.contains("'Fix login'"), "\(withTitle) does not quote the title it carries")
|
||||
#expect(!anonymous.contains("''"), "\(withoutTitle) rendered an empty pair of quotes")
|
||||
#expect(anonymous.contains("the item"), "\(withoutTitle) should fall back to the kind-free noun")
|
||||
#expect(named != anonymous)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("The cause tail comes from the error's reason and nowhere else")
|
||||
func causeTailCarriesTheDiagnosis() {
|
||||
#expect(BannerCenter.headline(for: error(.move(title: "Fix login"), .io(message: "the disk is full")))
|
||||
== "Couldn't move 'Fix login' — the disk is full")
|
||||
|
||||
// A trailing full stop in a diagnostic message must not survive into the middle of a line.
|
||||
#expect(BannerCenter.headline(for: error(.createCard, .io(message: "no space left on device.")))
|
||||
== "Couldn't create a card — no space left on device")
|
||||
|
||||
// The uneditable-frontmatter reason is the one whose own `description` is written for a
|
||||
// developer, so the banner translates it rather than quoting it.
|
||||
let uneditable = BannerCenter.headline(for: error(.style(title: "Odd"), .uneditableFrontmatter(.keyWithoutOwnLine)))
|
||||
#expect(uneditable.hasPrefix("Couldn't restyle 'Odd' — "))
|
||||
#expect(uneditable.contains("frontmatter"))
|
||||
}
|
||||
|
||||
@Test("Every lock reason says what is wrong and that the view is still the last good one")
|
||||
func lockHeadlinesReassure() {
|
||||
let reasons: [ReadOnlyLockReason] = [.bracketedReloadFailed, .vanishedRoot, .unwritableLocation]
|
||||
let headlines = reasons.map(BannerCenter.headline(for:))
|
||||
|
||||
for headline in headlines {
|
||||
#expect(headline.contains("last good view"))
|
||||
#expect(headline.contains("read-only"))
|
||||
}
|
||||
#expect(Set(headlines).count == reasons.count)
|
||||
}
|
||||
|
||||
@Test("Reload breakage carries fail-fast's specifics — the path and what is wrong with it")
|
||||
func breakageHeadlineNamesThePath() {
|
||||
let headline = BannerCenter.headline(
|
||||
for: BoardLoadError(path: "todo/fix-login/index.md", reason: .unparseableYAML(message: "unexpected end", line: 4))
|
||||
)
|
||||
#expect(headline.contains("'todo/fix-login/index.md'"))
|
||||
#expect(headline.contains("line 4"))
|
||||
#expect(headline.contains("showing the last good view"))
|
||||
|
||||
// The board's own index.md reports as "." — a lone dot in the product's voice would be a
|
||||
// bug report, not a sentence.
|
||||
let rootHeadline = BannerCenter.headline(for: BoardLoadError(path: ".", reason: .boardRootMissingIndex))
|
||||
#expect(!rootHeadline.contains("'.'"))
|
||||
#expect(rootHeadline.hasPrefix("This board isn't loading"))
|
||||
}
|
||||
|
||||
@Test("The suspended-history line names the consequence, then the diagnosis")
|
||||
func suspensionHeadlineNamesTheConsequence() {
|
||||
#expect(BannerCenter.headline(for: HistorySuspension(reason: "the disk is full"))
|
||||
== "Changes aren't being recorded to history — the disk is full")
|
||||
#expect(BannerCenter.headline(for: HistorySuspension(reason: ""))
|
||||
== "Changes aren't being recorded to history")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Store integration
|
||||
|
||||
@MainActor
|
||||
@Suite("BannerCenter ▸ store integration")
|
||||
struct BannerCenterStoreTests {
|
||||
|
||||
@Test("A failed write lands exactly one one-shot in the store's banners, and still throws")
|
||||
func failedWritePostsOnce() throws {
|
||||
let fixture = try makeBoardWithUneditableLane()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
|
||||
// A genuine Writer refusal: the lane's frontmatter is a whole-frontmatter flow mapping, so
|
||||
// the surgical editor cannot address it and the write fails loudly rather than corrupting
|
||||
// the file (01-storage-format.md § Frontmatter).
|
||||
var thrown: BoardWriteError?
|
||||
do {
|
||||
try store.performWrite { () throws(BoardWriteError) -> Void in
|
||||
try BoardWriter.updateIndex(inItemFolder: fixture.url(Ident.lane2), operation: .style(title: nil)) { _ in }
|
||||
}
|
||||
Issue.record("expected the write to fail")
|
||||
} catch let failure as BoardWriteError {
|
||||
thrown = failure
|
||||
}
|
||||
|
||||
let error = try #require(thrown)
|
||||
#expect(store.banners.oneShots.count == 1, "the banner is posted once, not per layer")
|
||||
#expect(store.banners.oneShots.first?.error == error, "the strip carries the same failure the caller saw")
|
||||
|
||||
// And it is the whole of what the strip shows: a healthy board with one unread failure.
|
||||
#expect(store.bannerRows.count == 1)
|
||||
#expect(store.bannerRows[0].tone == .error)
|
||||
#expect(store.bannerRows[0].headline == "Couldn't restyle 'Odd' — this file's frontmatter can't be edited in place (a top-level key has no line of its own)")
|
||||
#expect(store.bannerRows[0].dismissID != nil)
|
||||
}
|
||||
|
||||
@Test("A write refused by the read-only lock posts nothing — the lock row already stands")
|
||||
func refusalUnderLockPostsNothing() throws {
|
||||
let fixture = try makeBoardWithUneditableLane()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
store.enterVanishedRootLock()
|
||||
|
||||
do {
|
||||
try store.performWrite { () throws(BoardWriteError) -> Void in
|
||||
try BoardWriter.updateIndex(inItemFolder: fixture.url(Ident.lane1), operation: .style(title: nil)) { _ in }
|
||||
}
|
||||
Issue.record("expected the store to refuse the write")
|
||||
} catch let refusal as BoardStoreWriteRefusal {
|
||||
#expect(refusal == .readOnlyLocked(.vanishedRoot))
|
||||
}
|
||||
|
||||
#expect(store.banners.oneShots.isEmpty, "a refusal is not a failed write; the lock row is the message")
|
||||
#expect(store.bannerRows.count == 1)
|
||||
guard case .readOnlyLock(.vanishedRoot) = store.bannerRows[0] else {
|
||||
Issue.record("expected the lock row alone")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@Test("The store's rows compose its own conditions with the center's")
|
||||
func bannerRowsComposeBothHalves() throws {
|
||||
let fixture = try makeBoardWithUneditableLane()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
|
||||
store.enterUnwritableLock()
|
||||
store.banners.post(BoardWriteError(operation: .createCard, path: "/x", reason: .io(message: "the disk is full")))
|
||||
store.banners.suspendHistory(reason: "the disk is full")
|
||||
store.banners.beginOperation(label: "Duplicating…", cancel: nil)
|
||||
store.banners.postSignpost("This card changed on the remote")
|
||||
|
||||
#expect(store.bannerRows.map(\.id) == [
|
||||
"operation:\(store.banners.operations[0].id.uuidString)",
|
||||
"read-only-lock",
|
||||
"one-shot:\(store.banners.oneShots[0].id.uuidString)",
|
||||
"history-suspension",
|
||||
"signpost:\(store.banners.signposts[0].id.uuidString)",
|
||||
])
|
||||
}
|
||||
}
|
||||
@@ -486,21 +486,40 @@ struct BoardStoreTests {
|
||||
|
||||
// MARK: Root changes
|
||||
|
||||
@Test("A root change is accepted and leaves the last good snapshot alone")
|
||||
func rootChangeIsAStubToday() async throws {
|
||||
@Test("A root change with no delegate wired leaves the last good snapshot alone")
|
||||
func rootChangeWithoutADelegateIsANoOp() 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.
|
||||
// Re-resolve-or-lock needs a bookmark this store does not own, so the response is
|
||||
// delegated (the registry wires it — `RootRecoveryTests` drives the real thing). With no
|
||||
// delegate, the honest answer is the same one every failure path gives: keep the last good
|
||||
// snapshot. Guessing here would lock a board that had 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")
|
||||
#expect(store.reloadGeneration == 0, "a root change schedules no reload of its own")
|
||||
}
|
||||
|
||||
@Test("A root change with a delegate hands over and starts no reload of its own")
|
||||
func rootChangeIsDelegated() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
|
||||
var calls = 0
|
||||
store.rootChangeDelegate = { calls += 1 }
|
||||
store.handleWatcherEvent(.rootChanged)
|
||||
await store.awaitQuiescence()
|
||||
|
||||
#expect(calls == 1)
|
||||
// The delegate's two outcomes both end in a reload — at the re-resolved root, or on the
|
||||
// root's return. One fired from here would walk a path that just stopped being the board.
|
||||
#expect(store.reloadGeneration == 0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Kanban
|
||||
|
||||
/// The close-with-a-dirty-buffer state machine (02-architecture.md § Write-failure surfacing, "The
|
||||
/// one modal moment on the write-failure path").
|
||||
///
|
||||
/// Everything specific is a closure, so these tests are about one thing only: **whether the close
|
||||
/// may proceed**. Each of the four exits — the save that just works, the retry that works on the
|
||||
/// second try, the copy saved elsewhere, and the deliberate discard — has to leave the guard
|
||||
/// `.idle`, and the failing save has to leave it `.blocked` carrying the error the alert will
|
||||
/// phrase.
|
||||
|
||||
// MARK: - Support
|
||||
|
||||
/// Stands in for an editor's dirty buffer: some text, a save that can be made to fail, and a record
|
||||
/// of where things actually went.
|
||||
@MainActor
|
||||
private final class FakeBuffer {
|
||||
var text: String
|
||||
/// Non-`nil` makes the next save (and every save after it) fail.
|
||||
var saveFailure: BoardWriteError?
|
||||
/// Set when `writeCopy` should refuse — a save panel pointed at a full disk.
|
||||
var copyFails = false
|
||||
|
||||
private(set) var saveAttempts = 0
|
||||
private(set) var savedText: String?
|
||||
private(set) var copies: [URL: String] = [:]
|
||||
|
||||
init(text: String = "the paragraph that exists nowhere else") {
|
||||
self.text = text
|
||||
}
|
||||
|
||||
func makeGuard() -> DirtyBufferGuard {
|
||||
DirtyBufferGuard(
|
||||
attemptSave: { [self] () throws(BoardWriteError) in
|
||||
saveAttempts += 1
|
||||
if let saveFailure {
|
||||
throw saveFailure
|
||||
}
|
||||
savedText = text
|
||||
},
|
||||
writeCopy: { [self] url in
|
||||
if copyFails {
|
||||
throw CocoaError(.fileWriteOutOfSpace)
|
||||
}
|
||||
copies[url] = text
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private let diskFull = BoardWriteError(
|
||||
operation: .style(title: "Fix login"),
|
||||
path: "/Boards/Work/todo/fix-login/index.md",
|
||||
reason: .io(message: "the disk is full")
|
||||
)
|
||||
|
||||
private func copyDestination() -> URL {
|
||||
FileManager.default.temporaryDirectory.appendingPathComponent("dirty-buffer-\(UUID().uuidString).md")
|
||||
}
|
||||
|
||||
// MARK: - Tests
|
||||
|
||||
@MainActor
|
||||
@Suite("DirtyBufferGuard")
|
||||
struct DirtyBufferGuardTests {
|
||||
|
||||
@Test("A save that lands never blocks — the close proceeds with no modal at all")
|
||||
func successfulSaveNeverBlocks() {
|
||||
let buffer = FakeBuffer()
|
||||
let bufferGuard = buffer.makeGuard()
|
||||
|
||||
#expect(bufferGuard.beginClose())
|
||||
#expect(bufferGuard.phase == .idle)
|
||||
#expect(buffer.savedText == "the paragraph that exists nowhere else")
|
||||
#expect(buffer.saveAttempts == 1)
|
||||
}
|
||||
|
||||
@Test("A failing save blocks the close and carries the error the alert will phrase")
|
||||
func failingSaveBlocks() {
|
||||
let buffer = FakeBuffer()
|
||||
buffer.saveFailure = diskFull
|
||||
let bufferGuard = buffer.makeGuard()
|
||||
|
||||
#expect(!bufferGuard.beginClose())
|
||||
#expect(bufferGuard.phase == .blocked(diskFull))
|
||||
|
||||
guard case let .blocked(error) = bufferGuard.phase else {
|
||||
Issue.record("expected the blocked phase")
|
||||
return
|
||||
}
|
||||
#expect(BannerCenter.headline(for: error) == "Couldn't restyle 'Fix login' — the disk is full")
|
||||
}
|
||||
|
||||
@Test("Retrying after the cause is fixed unblocks the close")
|
||||
func retrySucceedsAndCloses() {
|
||||
let buffer = FakeBuffer()
|
||||
buffer.saveFailure = diskFull
|
||||
let bufferGuard = buffer.makeGuard()
|
||||
#expect(!bufferGuard.beginClose())
|
||||
|
||||
// A retry while the disk is still full stays blocked — the alert returns, which is the
|
||||
// honest outcome and the reason there is no "close anyway" button.
|
||||
#expect(!bufferGuard.retry())
|
||||
#expect(bufferGuard.phase == .blocked(diskFull))
|
||||
|
||||
buffer.saveFailure = nil
|
||||
#expect(bufferGuard.retry())
|
||||
#expect(bufferGuard.phase == .idle)
|
||||
#expect(buffer.savedText == "the paragraph that exists nowhere else")
|
||||
#expect(buffer.saveAttempts == 3)
|
||||
}
|
||||
|
||||
@Test("Saving a copy elsewhere writes the text and unblocks the close")
|
||||
func saveCopyWritesAndUnblocks() throws {
|
||||
let buffer = FakeBuffer()
|
||||
buffer.saveFailure = diskFull
|
||||
let bufferGuard = buffer.makeGuard()
|
||||
#expect(!bufferGuard.beginClose())
|
||||
|
||||
let destination = copyDestination()
|
||||
try bufferGuard.saveCopy(to: destination)
|
||||
|
||||
#expect(bufferGuard.phase == .idle, "the text is safe somewhere; the close may proceed")
|
||||
#expect(buffer.copies[destination] == "the paragraph that exists nowhere else")
|
||||
// The buffer's real home is still unwritten — that is the trade the user knowingly made.
|
||||
#expect(buffer.savedText == nil)
|
||||
}
|
||||
|
||||
@Test("A copy that itself fails leaves the close blocked")
|
||||
func failedCopyStaysBlocked() {
|
||||
let buffer = FakeBuffer()
|
||||
buffer.saveFailure = diskFull
|
||||
buffer.copyFails = true
|
||||
let bufferGuard = buffer.makeGuard()
|
||||
#expect(!bufferGuard.beginClose())
|
||||
|
||||
#expect(throws: (any Error).self) {
|
||||
try bufferGuard.saveCopy(to: copyDestination())
|
||||
}
|
||||
#expect(bufferGuard.phase == .blocked(diskFull), "the text is still nowhere but memory")
|
||||
}
|
||||
|
||||
@Test("Discarding unblocks the close and writes nothing anywhere")
|
||||
func discardUnblocks() {
|
||||
let buffer = FakeBuffer()
|
||||
buffer.saveFailure = diskFull
|
||||
let bufferGuard = buffer.makeGuard()
|
||||
#expect(!bufferGuard.beginClose())
|
||||
|
||||
bufferGuard.discard()
|
||||
|
||||
#expect(bufferGuard.phase == .idle)
|
||||
#expect(buffer.savedText == nil)
|
||||
#expect(buffer.copies.isEmpty)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Kanban
|
||||
|
||||
/// What happens to an open board when its folder moves, disappears, or comes back — the settled
|
||||
/// root-identity rules of 02-architecture.md § Write-failure surfacing, exercised end to end
|
||||
/// against a **real FSEvents stream, a real registry, and a real bookmark**.
|
||||
///
|
||||
/// A fake would prove nothing here. The three claims under test are all claims about the
|
||||
/// filesystem's actual behaviour: that a bookmark follows a rename, that FSEvents reports the
|
||||
/// *creation* of a path it was watching before that path existed, and that a `access(2)` probe is
|
||||
/// what distinguishes "this board loads fine" from "this board can be written to". Every one of
|
||||
/// them would be assumed rather than tested against a double.
|
||||
///
|
||||
/// The flakiness that buys is handled the way `FolderWatcherTests` handles it: **waiting for
|
||||
/// something is generous** (poll for seconds — a slow machine must not fail a correctness test)
|
||||
/// and nothing is asserted from an elapsed interval. The one test that needs no filesystem timing
|
||||
/// at all — the writability clearing rule — drives the store's inbound door directly instead, so
|
||||
/// its ordering is exact rather than merely likely.
|
||||
|
||||
// MARK: - Support
|
||||
|
||||
/// `WriterFixture`, `Ident` and `Item` come from `WriterTestSupport.swift`.
|
||||
|
||||
/// A small board: two lanes, one card. Enough that a reload landing at a new root has something
|
||||
/// recognisable in it.
|
||||
@MainActor
|
||||
private func makeBoard(at root: URL) throws {
|
||||
let manager = FileManager.default
|
||||
try manager.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
|
||||
func write(_ relativePath: String, _ text: String) throws {
|
||||
let folder = relativePath.isEmpty ? root : root.appendingPathComponent(relativePath, isDirectory: true)
|
||||
try manager.createDirectory(at: folder, withIntermediateDirectories: true)
|
||||
try Data(text.utf8).write(to: folder.appendingPathComponent("index.md"))
|
||||
}
|
||||
|
||||
try write("", Item.board)
|
||||
try write(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
||||
try write("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
|
||||
try write(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func makeBoard() throws -> WriterFixture {
|
||||
let fixture = try WriterFixture()
|
||||
try makeBoard(at: fixture.root)
|
||||
return fixture
|
||||
}
|
||||
|
||||
/// Adds a card by hand — a *foreign* write by construction: no Writer, no bracket, exactly what an
|
||||
/// agent or an editor does.
|
||||
private func writeCard(inBoard root: URL, lane: String, id: String, title: String, order: String) throws {
|
||||
let folder = root.appendingPathComponent(lane, isDirectory: true).appendingPathComponent(id, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
|
||||
try Data(Item.rich(order: order, title: title).utf8).write(to: folder.appendingPathComponent("index.md"))
|
||||
}
|
||||
|
||||
private func cardTitles(inLane id: String, of snapshot: BoardModel) -> [String] {
|
||||
(snapshot.lanes.first { $0.id.rawValue == id }?.cards ?? []).compactMap(\.title.value)
|
||||
}
|
||||
|
||||
/// The one shape a path comparison may take here. A bookmark resolves to the canonical location
|
||||
/// (`/private/var/...`) while `FileManager.temporaryDirectory` hands out the symlinked one
|
||||
/// (`/var/...`), so raw `URL` equality would fail on a board that relocated perfectly.
|
||||
private func canonical(_ url: URL) -> String {
|
||||
url.resolvingSymlinksInPath().standardizedFileURL.path
|
||||
}
|
||||
|
||||
/// Polls until `condition` holds or the deadline passes — generous, because FSEvents delivery is
|
||||
/// not a bounded-latency promise. A root change in particular can take several seconds to surface.
|
||||
@MainActor
|
||||
private func waitUntil(_ deadline: Duration = .seconds(15), _ condition: () -> Bool) async {
|
||||
let start = ContinuousClock.now
|
||||
while ContinuousClock.now - start < deadline {
|
||||
if condition() { return }
|
||||
try? await Task.sleep(for: .milliseconds(25))
|
||||
}
|
||||
}
|
||||
|
||||
/// Gives a freshly started stream a beat to register with `fseventsd`, so the first change a test
|
||||
/// makes cannot land in the window between `FSEventStreamStart` and the stream actually being live.
|
||||
@MainActor
|
||||
private func settle() async {
|
||||
try? await Task.sleep(for: .milliseconds(400))
|
||||
}
|
||||
|
||||
// MARK: - Tests
|
||||
|
||||
@MainActor
|
||||
@Suite("Root recovery")
|
||||
struct RootRecoveryTests {
|
||||
|
||||
// MARK: Rename absorption
|
||||
|
||||
@Test("A rename is absorbed transparently: new root, no banner, no lock, wiring intact")
|
||||
func renameIsAbsorbed() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let renamed = fixture.root
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("renamed-\(UUID().uuidString)", isDirectory: true)
|
||||
// Registered first so it runs *last*: the store is released — and its watcher stopped —
|
||||
// before the folder it is watching is removed.
|
||||
defer { try? FileManager.default.removeItem(at: renamed) }
|
||||
|
||||
let registry = BoardStoreRegistry()
|
||||
let store = try registry.acquire(fixture.root)
|
||||
defer { registry.release(store) }
|
||||
await settle()
|
||||
|
||||
// A Finder rename, which 01-storage-format.md calls ordinary. The board is the *file*, not
|
||||
// the string that names it.
|
||||
try FileManager.default.moveItem(at: fixture.root, to: renamed)
|
||||
|
||||
// The done-when: the store's URLs re-derived, and a reload actually ran at the new root
|
||||
// (`snapshot.rootURL` is the root the last successful walk used).
|
||||
await waitUntil { canonical(store.snapshot.rootURL) == canonical(renamed) }
|
||||
#expect(canonical(store.rootURL) == canonical(renamed))
|
||||
#expect(canonical(store.snapshot.rootURL) == canonical(renamed))
|
||||
|
||||
// Nothing was ever wrong, and the strip says so.
|
||||
#expect(store.readOnlyLock == nil)
|
||||
#expect(store.reloadFailure == nil)
|
||||
#expect(store.bannerRows.isEmpty)
|
||||
#expect(store.snapshot.lanes.count == 2, "the board is still the board")
|
||||
|
||||
// The entry followed too: identity did not change, so the same store answers for the new
|
||||
// path and a window opening it would share rather than duplicate.
|
||||
#expect(registry.liveStore(for: renamed) === store)
|
||||
#expect(registry.openBoardCount == 1)
|
||||
|
||||
// And the wiring survived the move: a foreign edit at the *new* path reloads.
|
||||
try writeCard(inBoard: renamed, lane: Ident.lane1, id: Ident.card2, title: "Second", order: "2048")
|
||||
await waitUntil { cardTitles(inLane: Ident.lane1, of: store.snapshot).contains("Second") }
|
||||
#expect(cardTitles(inLane: Ident.lane1, of: store.snapshot) == ["First", "Second"])
|
||||
#expect(store.bannerRows.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: Vanish and return
|
||||
|
||||
@Test("A vanished root locks the board read-only, and the root's return clears it")
|
||||
func vanishAndReturn() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let registry = BoardStoreRegistry()
|
||||
let store = try registry.acquire(fixture.root)
|
||||
defer { registry.release(store) }
|
||||
let lastGood = store.snapshot
|
||||
await settle()
|
||||
|
||||
// The folder is deleted in Finder while the board is open. Bookmark re-resolution finds
|
||||
// nothing that exists, and the last-known path is gone too.
|
||||
try FileManager.default.removeItem(at: fixture.root)
|
||||
|
||||
await waitUntil { store.readOnlyLock == .vanishedRoot }
|
||||
#expect(store.readOnlyLock == .vanishedRoot)
|
||||
|
||||
// Wait for the vanished state to *settle* before recreating anything, and not for tidiness:
|
||||
// entering the lock re-attaches the watcher at the missing path, and that re-attach owes a
|
||||
// debounced reconciling reload. Recreating the folder inside that 200 ms window would let
|
||||
// the reload find the root already back and clear the lock without a root change ever being
|
||||
// delivered — a legitimate recovery, but a different one from the one under test here.
|
||||
// Waiting for that reload to land and fail pins the sequence to the real-world shape: the
|
||||
// root comes back later, and its *creation* is what recovers the board.
|
||||
await waitUntil { store.reloadFailure != nil }
|
||||
await store.awaitQuiescence()
|
||||
#expect(store.reloadFailure != nil, "the re-attach's reconciling reload fails against the missing root")
|
||||
|
||||
// The last-good snapshot is still on screen — that is the whole point of the lock.
|
||||
#expect(store.snapshot == lastGood)
|
||||
#expect(store.readOnlyLock == .vanishedRoot, "a failed reload never lifts the lock")
|
||||
|
||||
// And the strip leads with the lock. (The re-attach's reconciling reload fails against the
|
||||
// missing root, so a breakage row stands behind it; the *lock* is what comes first.)
|
||||
guard case .readOnlyLock(.vanishedRoot) = store.bannerRows.first else {
|
||||
Issue.record("expected the lock row to lead, got \(store.bannerRows.map(\.id))")
|
||||
return
|
||||
}
|
||||
|
||||
// Every write is refused — nothing would land anywhere.
|
||||
do {
|
||||
try store.performWrite { () throws(BoardWriteError) -> Void in
|
||||
try BoardWriter.updateIndex(inItemFolder: fixture.url(Ident.lane1), operation: .style(title: nil)) { _ in }
|
||||
}
|
||||
Issue.record("expected the locked board to refuse the write")
|
||||
} catch let refusal as BoardStoreWriteRefusal {
|
||||
#expect(refusal == .readOnlyLocked(.vanishedRoot))
|
||||
}
|
||||
#expect(store.banners.oneShots.isEmpty, "a refusal is not a failed write")
|
||||
|
||||
// The root returns: a Finder undo, a remount, a folder recreated where the board was. Built
|
||||
// aside and moved into place in one step, so the path appears as a whole board rather than
|
||||
// as a directory that is filled in over several reload debounces.
|
||||
let staging = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("RootRecoveryTests-staging-\(UUID().uuidString)", isDirectory: true)
|
||||
try makeBoard(at: staging)
|
||||
try writeCard(inBoard: staging, lane: Ident.lane1, id: Ident.card3, title: "Third", order: "3072")
|
||||
try FileManager.default.moveItem(at: staging, to: fixture.root)
|
||||
|
||||
// FSEvents was left watching the path precisely so this creation would be reported.
|
||||
await waitUntil { store.readOnlyLock == nil }
|
||||
#expect(store.readOnlyLock == nil, "a successful reload proves the root came back")
|
||||
|
||||
await waitUntil { cardTitles(inLane: Ident.lane1, of: store.snapshot).contains("Third") }
|
||||
#expect(cardTitles(inLane: Ident.lane1, of: store.snapshot) == ["First", "Third"])
|
||||
#expect(store.reloadFailure == nil)
|
||||
#expect(store.bannerRows.isEmpty)
|
||||
|
||||
// The recreated folder is a different file than the one that was deleted, so the entry had
|
||||
// to be re-keyed — otherwise the next window to open this board would get a second store
|
||||
// over a board already on screen.
|
||||
await waitUntil { registry.liveStore(for: fixture.root) === store }
|
||||
#expect(registry.liveStore(for: fixture.root) === store)
|
||||
#expect(registry.openBoardCount == 1)
|
||||
|
||||
// And writes are live again.
|
||||
try store.performWrite { () throws(BoardWriteError) -> Void in
|
||||
try BoardWriter.updateIndex(inItemFolder: fixture.url(Ident.lane1), operation: .style(title: nil)) { _ in }
|
||||
}
|
||||
#expect(store.banners.oneShots.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: The writability clearing rule
|
||||
|
||||
@Test("The unwritable-location lock clears only on a reconciling reload whose probe passes")
|
||||
func unwritableLockClearsOnlyOnAReconcilingProbe() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
|
||||
// The probe has to be honest, so the root is made genuinely unwritable — `r-x`, which still
|
||||
// reads perfectly. That is the whole difficulty of this case: the board loads fine.
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o555], ofItemAtPath: fixture.root.path)
|
||||
store.enterUnwritableLock()
|
||||
#expect(store.readOnlyLock == .unwritableLocation)
|
||||
|
||||
// A foreign reload succeeds — and clears nothing. Loading proves nothing about writing,
|
||||
// which is exactly why this lock's clearing rule is not the other two's.
|
||||
store.handleWatcherEvent(.treeChanged(.foreign))
|
||||
await store.awaitQuiescence()
|
||||
#expect(store.reloadFailure == nil, "an unwritable root still reads")
|
||||
#expect(store.readOnlyLock == .unwritableLocation)
|
||||
|
||||
// Neither does a reconciling one while the permission is still what it was.
|
||||
store.handleWatcherEvent(.treeChanged(.reconciling))
|
||||
await store.awaitQuiescence()
|
||||
#expect(store.readOnlyLock == .unwritableLocation)
|
||||
|
||||
// The permission is fixed. Nothing announces that — a `chmod` in a terminal fires no event
|
||||
// the board would act on — so the lock stands until the next reconciling sweep (wake, app
|
||||
// activation) re-probes.
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: fixture.root.path)
|
||||
store.handleWatcherEvent(.treeChanged(.foreign))
|
||||
await store.awaitQuiescence()
|
||||
#expect(store.readOnlyLock == .unwritableLocation, "only a reconciling reload re-probes")
|
||||
|
||||
store.handleWatcherEvent(.treeChanged(.reconciling))
|
||||
await store.awaitQuiescence()
|
||||
#expect(store.readOnlyLock == nil, "a fixed permission clears the lock without ceremony")
|
||||
#expect(store.bannerRows.isEmpty)
|
||||
}
|
||||
|
||||
@Test("A reconciling reload that finds the root unwritable does not raise the lock by itself")
|
||||
func theProbeOnlyClears() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o555], ofItemAtPath: fixture.root.path)
|
||||
|
||||
store.handleWatcherEvent(.treeChanged(.reconciling))
|
||||
await store.awaitQuiescence()
|
||||
|
||||
// Arming the lock is the open flow's job (m4). Inferring it from a probe here would be a
|
||||
// policy decision this layer has not been asked to make — recorded as a test so the
|
||||
// asymmetry is deliberate rather than forgotten.
|
||||
#expect(store.readOnlyLock == nil)
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user