Closes the gap found at m10: enterUnwritableLock existed with zero call
sites. WritabilityProbe classifies the cause volume-first - a board on a
read-only DMG is also permission-denied by access(2), and "you don't
have permission" would send the user to a Get Info panel that cannot
help - with a pure classify(volumeIsReadOnly:isWritable:) truth table
and a two-syscall probe that rebuilds its URL to defeat NSURL resource
caching. ReadOnlyLockReason.unwritableLocation now carries the cause;
BannerCenter phrases the two ("this board's volume is read-only" vs
"you don't have permission to change this folder").
The probe wires once in BoardStoreRegistry.acquire, immediately after
the store loads - every open path funnels through it, and running
before the loose-file relocation and agent-guide hooks makes the
skipped-with-log guide write true by construction (its isWritableFile
pre-check demotes to second line of defense). The board still opens:
lock, not refusal.
The reconciling re-probe is now symmetric per 02's settled text - a
volume gone read-only mid-session raises the lock at the next probe
(sibling locks settle first, so a root returning read-only lands the
honest lock); the stale "deliberately one-way" comment and its pinning
test are gone. Save as Template's carve-out predicate extracted to a
testable allowsSave (behavior unchanged); Duplicate stays disabled.
11 tests added. 1649 green on both schemes.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
339 lines
12 KiB
Swift
339 lines
12 KiB
Swift
import Foundation
|
|
import Testing
|
|
@testable import Kanban
|
|
|
|
/// The Edit buffer's state machine (05-card-window.md ▸ Edit, ▸ Write rules): the three write gates,
|
|
/// the ~700 ms debounce, and dirty-buffer-wins.
|
|
///
|
|
/// The session is deliberately a buffer and a clock with a *closure* for its destination, which is
|
|
/// what lets this suite be about the rules rather than about files: the fake below records every
|
|
/// save it is asked for, so "writes nothing" is an assertion about a count rather than an inference
|
|
/// from an `mtime`. The bytes those saves put on disk are `BodyWriteTests`'.
|
|
|
|
// MARK: - The fake destination
|
|
|
|
/// A stand-in for `BoardStore.writeCardBody`, recording what it was asked to write and answering
|
|
/// with whatever outcome the test wants.
|
|
@MainActor
|
|
private final class SaveSpy {
|
|
private(set) var written: [String] = []
|
|
var outcome: CardBodyWriteOutcome = .written
|
|
|
|
var count: Int { written.count }
|
|
var last: String? { written.last }
|
|
|
|
func save(_ text: String) -> CardBodyWriteOutcome {
|
|
written.append(text)
|
|
return outcome
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private func makeSession(_ spy: SaveSpy, body: String = "original\n") -> CardBodyEditSession {
|
|
let session = CardBodyEditSession()
|
|
// Fast enough that a test never waits on the real 700 ms, slow enough that a keystroke arriving
|
|
// right after another can still cancel it — the `DragSession.holdTimeout` precedent.
|
|
session.debounceInterval = .milliseconds(30)
|
|
session.save = { [spy] text in spy.save(text) }
|
|
session.adopt(diskBody: body)
|
|
return session
|
|
}
|
|
|
|
/// Polls until `condition` holds or the deadline passes — the suite's shape for anything the
|
|
/// debounce has to actually elapse for.
|
|
@MainActor
|
|
private func waitUntil(_ deadline: Duration = .seconds(2), _ condition: () -> Bool) async {
|
|
let start = ContinuousClock.now
|
|
while !condition() {
|
|
guard ContinuousClock.now - start < deadline else { return }
|
|
try? await Task.sleep(for: .milliseconds(5))
|
|
}
|
|
}
|
|
|
|
// MARK: - The three gates
|
|
|
|
@MainActor
|
|
@Suite("Card body ▸ the write gates")
|
|
struct CardBodyWriteGateTests {
|
|
|
|
@Test("An untouched session writes nothing, ever")
|
|
func anUntouchedSessionWritesNothing() async {
|
|
let spy = SaveSpy()
|
|
let session = makeSession(spy)
|
|
|
|
// Opened, read, and left — the flush that leaving Edit performs, on a buffer nobody typed
|
|
// into. "An untouched body is never rewritten" (05 ▸ Write rules).
|
|
#expect(session.flush() == .unchanged)
|
|
await waitUntil { spy.count > 0 }
|
|
#expect(spy.count == 0)
|
|
#expect(session.saveAttempts == 0)
|
|
}
|
|
|
|
@Test("A typed-then-reverted edit writes nothing, and leaves no timer standing")
|
|
func aRevertedEditWritesNothing() async {
|
|
let spy = SaveSpy()
|
|
let session = makeSession(spy)
|
|
|
|
session.edited("original\nand more")
|
|
#expect(session.isDirty)
|
|
// Undone before the debounce could fire.
|
|
session.edited("original\n")
|
|
#expect(!session.isDirty)
|
|
|
|
// The pending save was cancelled by the revert rather than firing on a no-op — a write that
|
|
// landed here would stamp `modified` and mint a commit for nothing.
|
|
await waitUntil { spy.count > 0 }
|
|
#expect(spy.count == 0)
|
|
#expect(session.flush() == .unchanged)
|
|
#expect(spy.count == 0)
|
|
}
|
|
|
|
@Test("The echo of the app's own save is not written back")
|
|
func anEchoIsNotWrittenBack() async {
|
|
let spy = SaveSpy()
|
|
let session = makeSession(spy)
|
|
|
|
session.edited("typed\n")
|
|
#expect(session.flush() == .written)
|
|
#expect(spy.written == ["typed\n"])
|
|
|
|
// The watcher's reload arrives carrying what we just wrote. The buffer is clean against it,
|
|
// so nothing is written back — which is what stops a save ringing forever round the one-way
|
|
// flow.
|
|
session.adopt(diskBody: "typed\n")
|
|
#expect(!session.isDirty)
|
|
#expect(session.flush() == .unchanged)
|
|
await waitUntil { spy.count > 1 }
|
|
#expect(spy.count == 1)
|
|
}
|
|
|
|
@Test("An external edit under a clean buffer is not written back either")
|
|
func aForeignEditUnderACleanBufferIsNotWrittenBack() async {
|
|
let spy = SaveSpy()
|
|
let session = makeSession(spy)
|
|
|
|
// Somebody else rewrote the file while the window sat there reading it. "A clean buffer
|
|
// follows disk" (05 ▸ Write rules) — and following disk is not a reason to write to it.
|
|
session.adopt(diskBody: "theirs\n")
|
|
|
|
#expect(session.text == "theirs\n")
|
|
#expect(!session.isDirty)
|
|
#expect(session.flush() == .unchanged)
|
|
await waitUntil { spy.count > 0 }
|
|
#expect(spy.count == 0)
|
|
}
|
|
}
|
|
|
|
// MARK: - Dirty-buffer-wins
|
|
|
|
@MainActor
|
|
@Suite("Card body ▸ dirty-buffer-wins")
|
|
struct CardBodyDirtyBufferTests {
|
|
|
|
@Test("A snapshot never reloads a dirty buffer under the cursor")
|
|
func aDirtyBufferKeepsItsText() {
|
|
let spy = SaveSpy()
|
|
let session = makeSession(spy)
|
|
|
|
session.edited("mine, unsaved\n")
|
|
// The board, Preview and every other window take the new snapshot; this buffer does not.
|
|
session.adopt(diskBody: "theirs\n")
|
|
|
|
#expect(session.text == "mine, unsaved\n")
|
|
#expect(session.disk == "theirs\n", "the buffer knows what disk says — it just isn't showing it")
|
|
#expect(session.isDirty)
|
|
}
|
|
|
|
@Test("The buffer's own save then lands over the foreign edit — last writer wins")
|
|
func theFlushOverwritesTheForeignEdit() {
|
|
let spy = SaveSpy()
|
|
let session = makeSession(spy)
|
|
|
|
session.edited("mine, unsaved\n")
|
|
session.adopt(diskBody: "theirs\n")
|
|
|
|
#expect(session.flush() == .written)
|
|
#expect(spy.written == ["mine, unsaved\n"], "deliberate last-writer-wins (05 ▸ Write rules)")
|
|
#expect(!session.isDirty)
|
|
}
|
|
|
|
@Test("A foreign edit that happens to match the buffer settles it clean, writing nothing")
|
|
func aConvergentForeignEditNeedsNoWrite() async {
|
|
let spy = SaveSpy()
|
|
let session = makeSession(spy)
|
|
|
|
session.edited("same text\n")
|
|
// An agent wrote exactly what the user was typing. The file already says what they mean, so
|
|
// re-writing it would only stamp `modified`.
|
|
session.adopt(diskBody: "same text\n")
|
|
|
|
#expect(!session.isDirty)
|
|
await waitUntil { spy.count > 0 }
|
|
#expect(spy.count == 0)
|
|
}
|
|
|
|
@Test("A failed save keeps the buffer dirty, and the text")
|
|
func aFailureKeepsTheText() {
|
|
let spy = SaveSpy()
|
|
let session = makeSession(spy)
|
|
spy.outcome = .failed(BoardWriteError(
|
|
operation: .editBody(title: "Notes"),
|
|
path: "/x/index.md",
|
|
reason: .io(message: "the disk is full")
|
|
))
|
|
|
|
session.edited("precious\n")
|
|
let outcome = session.flush()
|
|
|
|
guard case .failed = outcome else {
|
|
Issue.record("expected the failure to be reported, got \(outcome)")
|
|
return
|
|
}
|
|
// "Nothing is lost while the window stays open" (02 § Write-failure surfacing): the text is
|
|
// still here, still dirty, and the next flush will try again.
|
|
#expect(session.text == "precious\n")
|
|
#expect(session.isDirty)
|
|
}
|
|
|
|
@Test("A suspended save — the read-only lock — also keeps the buffer, and does not throw a close")
|
|
func aSuspendedSaveKeepsTheBuffer() throws {
|
|
let spy = SaveSpy()
|
|
let session = makeSession(spy)
|
|
spy.outcome = .suspended(.unwritableLocation(.permissionDenied))
|
|
|
|
session.edited("held\n")
|
|
#expect(session.flush() == .suspended(.unwritableLocation(.permissionDenied)))
|
|
#expect(session.isDirty)
|
|
// The close-time guard treats it as a non-failure: no write was attempted, the lock row has
|
|
// been standing all along, and a modal offering Try Again could only fail again.
|
|
try session.flushOrThrow()
|
|
}
|
|
|
|
@Test("A real failure is what the close-time modal is raised on")
|
|
func aFailureThrowsForTheGuard() {
|
|
let spy = SaveSpy()
|
|
let session = makeSession(spy)
|
|
spy.outcome = .failed(BoardWriteError(operation: .editBody(title: nil), path: "/x", reason: .io(message: "nope")))
|
|
session.edited("unsaved\n")
|
|
|
|
#expect(throws: BoardWriteError.self) { try session.flushOrThrow() }
|
|
}
|
|
|
|
@Test("A session with nowhere to write keeps its text rather than reporting success")
|
|
func aSessionWithNoDestinationHoldsOn() {
|
|
let session = CardBodyEditSession()
|
|
session.adopt(diskBody: "start\n")
|
|
session.edited("typed\n")
|
|
|
|
#expect(session.flush() == .vanished)
|
|
#expect(session.isDirty)
|
|
#expect(session.text == "typed\n")
|
|
}
|
|
}
|
|
|
|
// MARK: - The debounce
|
|
|
|
@MainActor
|
|
@Suite("Card body ▸ the debounce")
|
|
struct CardBodyDebounceTests {
|
|
|
|
@Test("Typing saves once the keystrokes stop")
|
|
func typingSavesAfterTheInterval() async {
|
|
let spy = SaveSpy()
|
|
let session = makeSession(spy)
|
|
|
|
session.edited("t\n")
|
|
#expect(spy.count == 0, "not on the keystroke itself")
|
|
|
|
await waitUntil { spy.count == 1 }
|
|
#expect(spy.written == ["t\n"])
|
|
#expect(!session.isDirty)
|
|
}
|
|
|
|
@Test("A burst of keystrokes is one save, of the last text")
|
|
func aBurstCoalesces() async {
|
|
let spy = SaveSpy()
|
|
let session = makeSession(spy)
|
|
|
|
for text in ["a", "ab", "abc", "abcd"] {
|
|
session.edited(text)
|
|
}
|
|
|
|
await waitUntil { spy.count >= 1 }
|
|
// Trailing debounce: each keystroke restarts the clock, so the run costs one write rather
|
|
// than one per character.
|
|
#expect(spy.written == ["abcd"])
|
|
}
|
|
|
|
@Test("A flush does not wait for the debounce, and the debounce does not fire behind it")
|
|
func aFlushPreemptsThePendingSave() async {
|
|
let spy = SaveSpy()
|
|
let session = makeSession(spy)
|
|
|
|
session.edited("typed\n")
|
|
#expect(session.flush() == .written)
|
|
#expect(spy.count == 1, "the flush wrote immediately — no flush lag on a mode exit (05)")
|
|
|
|
// And the timer it cancelled does not come back to write the same text a second time.
|
|
await waitUntil { spy.count > 1 }
|
|
#expect(spy.count == 1)
|
|
}
|
|
|
|
@Test("The production interval is ~700 ms")
|
|
func theDefaultIntervalIsTheDesignsNumber() {
|
|
// The only thing the seam must not do is quietly change the number the design settled
|
|
// (05 ▸ Edit: "Saved on a ~700 ms debounce").
|
|
#expect(CardBodyEditSession().debounceInterval == .milliseconds(700))
|
|
}
|
|
|
|
@Test("Ending the session flushes — the Edit→Preview flip is the effective Save")
|
|
func endingTheSessionFlushes() {
|
|
let spy = SaveSpy()
|
|
let session = makeSession(spy)
|
|
|
|
session.edited("typed on the way out\n")
|
|
#expect(session.endEditSession() == .written)
|
|
#expect(spy.written == ["typed on the way out\n"])
|
|
}
|
|
}
|
|
|
|
// MARK: - The mode flip
|
|
|
|
@MainActor
|
|
@Suite("Card body ▸ leaving Edit flushes")
|
|
struct CardBodyModeFlushTests {
|
|
|
|
@Test("Every way out of Edit flushes first")
|
|
func leavingEditFlushes() {
|
|
let presentation = CardBodyPresentation()
|
|
var flushes = 0
|
|
presentation.flushEdits = { flushes += 1 }
|
|
presentation.openIfNeeded(body: "")
|
|
#expect(presentation.mode == .edit)
|
|
|
|
// ⌘E / the menu item's toggle …
|
|
presentation.toggleMode()
|
|
#expect(presentation.mode == .preview)
|
|
#expect(flushes == 1)
|
|
|
|
// … Escape in the editor, which is the same flip through the same door …
|
|
presentation.setMode(.edit)
|
|
#expect(flushes == 1, "entering Edit flushes nothing — there is nothing to flush yet")
|
|
presentation.setMode(.preview)
|
|
#expect(flushes == 2)
|
|
}
|
|
|
|
@Test("Setting the mode it already has flushes nothing")
|
|
func aRedundantSetIsNotAFlush() {
|
|
let presentation = CardBodyPresentation()
|
|
var flushes = 0
|
|
presentation.flushEdits = { flushes += 1 }
|
|
presentation.setMode(.edit)
|
|
|
|
presentation.setMode(.edit)
|
|
presentation.setMode(.edit)
|
|
|
|
#expect(flushes == 0, "a re-published focus value must not commit an untouched buffer")
|
|
}
|
|
}
|