Phase 2 swaps every consumer: Liveness and its ancestor walk are gone, replaced by ItemContainer — a UUID set plus the container side it lives on, presence the whole test, one selection boundary instead of the old liveness law. Deletion stages by place: board cards move to the trash at a store-minted head rank, trash-side delete is permanent behind its confirmation, Delete Immediately skips the trash from anywhere, lane delete captures the subtree and removes the folder. Restore has no method at all — moveCards resolves members in either container, so drag-out and cut-paste are the ordinary moves 13 calls them, registering ordinary Move steps. The delete inverse moves the card back to its captured lane and rank; redo replays the captured trash rank, a value the gesture actually wrote; lane undo recreates the subtree byte-faithfully in session. Purges register nothing — where 13's trash section contradicts its own Rules on that, Rules wins, filed for ruling. Staleness collapsed to present-or-absent: a container is a path, so a foreign restore fails the delete step's expectation structurally. Legacy tombstones migrate on the loose-file tail hook, cards oldest-first so minting above top reproduces the retired newest-first column, lanes returning live, one folded loss row naming both directions. Put Back, restoreByDrag, receiveRestoredCards, TrashEntry, and the kind machinery are deleted; the trash column renders the container correctly with its full face rework left to phase 3. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
973 lines
41 KiB
Swift
973 lines
41 KiB
Swift
import AppKit
|
||
import Foundation
|
||
import Testing
|
||
@testable import Kanban
|
||
|
||
/// The two inline editors' **write** paths — `BoardStore.commitRename` and
|
||
/// `BoardStore.commitPlaceholder` — plus the lane drag's `moveLane` and the board popover's
|
||
/// `renameBoard`, which is the same rename vocabulary aimed at the root.
|
||
///
|
||
/// Like `LaneWidthWriteTests`, these drive a real store over a real temp board and then read the
|
||
/// **raw bytes** back rather than the app's own read path: the interesting claims are about the
|
||
/// file — the key that lands or leaves, the stamps that follow, and everything else surviving
|
||
/// byte-for-byte. `WriterFixture`, `Ident` and `Item` come from `WriterTestSupport.swift`.
|
||
|
||
// MARK: - Fixtures
|
||
|
||
private func tombstoned(order: String, title: String) -> String {
|
||
"""
|
||
---
|
||
schema: 1
|
||
title: \(title)
|
||
order: \(order)
|
||
deleted: 2026-03-03T09:00:00Z
|
||
---
|
||
\(title) body.
|
||
|
||
"""
|
||
}
|
||
|
||
/// An item with no `title` key at all — the untitled state a rename can both start from and return
|
||
/// an item to.
|
||
private func untitled(order: String) -> String {
|
||
"""
|
||
---
|
||
schema: 1
|
||
order: \(order)
|
||
project: lanework # agent overlay
|
||
created: 2026-01-01T09:00:00Z
|
||
modified: 2026-02-02T09:00:00Z
|
||
---
|
||
Some body.
|
||
|
||
"""
|
||
}
|
||
|
||
/// Two lanes: `lane1` with two cards, `lane2` with one, plus a readable-but-uneditable lane.
|
||
@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("\(Ident.lane2)/\(Ident.card3)", Item.rich(order: "1024", title: "Third"))
|
||
try fixture.item(Ident.lane3, Item.uneditable)
|
||
return fixture
|
||
}
|
||
|
||
private let lane1 = ItemID(rawValue: Ident.lane1)
|
||
private let lane2 = ItemID(rawValue: Ident.lane2)
|
||
private let lane3 = ItemID(rawValue: Ident.lane3)
|
||
private let card1 = ItemID(rawValue: Ident.card1)
|
||
private let card2 = ItemID(rawValue: Ident.card2)
|
||
private let card3 = ItemID(rawValue: Ident.card3)
|
||
|
||
/// The file's lines minus the ones an app-mediated write is *supposed* to change — what must come
|
||
/// through a rename byte-for-byte, in order.
|
||
private func untouchedLines(_ text: String) -> [Substring] {
|
||
text.split(separator: "\n", omittingEmptySubsequences: false).filter {
|
||
!$0.hasPrefix("modified") && !$0.hasPrefix("title:")
|
||
}
|
||
}
|
||
|
||
private func load(_ fixture: WriterFixture) throws -> BoardModel {
|
||
try BoardLoader.load(boardRoot: fixture.root).model
|
||
}
|
||
|
||
private func card(_ id: ItemID, in model: BoardModel) -> Card? {
|
||
model.lanes.flatMap(\.cards).first { $0.id == id }
|
||
}
|
||
|
||
/// Every card folder under a lane, so a create can be spotted by what is newly there.
|
||
private func cardFolders(_ lane: String, in fixture: WriterFixture) throws -> Set<String> {
|
||
Set(try fixture.entryNames(lane).filter { BoardLoader.isUUIDShaped($0) })
|
||
}
|
||
|
||
// MARK: - Rename
|
||
|
||
@MainActor
|
||
@Suite("BoardStore ▸ inline rename")
|
||
struct InlineRenameWriteTests {
|
||
|
||
@Test("A non-empty commit writes the title, stamps modified, and touches nothing else")
|
||
func writesTheTitleAndStamps() throws {
|
||
let fixture = try makeBoard()
|
||
defer { fixture.tearDown() }
|
||
let store = try BoardStore(rootURL: fixture.root)
|
||
let before = try fixture.indexText("\(Ident.lane1)/\(Ident.card1)")
|
||
|
||
store.transient.beginRename(of: card1, currentTitle: "First")
|
||
store.transient.updateRenameDraft("Fix login")
|
||
store.commitRename()
|
||
|
||
let after = try fixture.indexText("\(Ident.lane1)/\(Ident.card1)")
|
||
#expect(after.contains("title: Fix login"))
|
||
#expect(!after.contains("modified-by"), "an app-mediated write clears an external writer's attribution")
|
||
#expect(!after.contains("modified: 2026-02-02T09:00:00Z"), "the stamp is fresh")
|
||
|
||
// Everything the write does not own survives exactly, in order: the unknown key with its
|
||
// inline comment, the reserved `labels`, the original `created`, the `order`, and the body.
|
||
#expect(untouchedLines(after) == untouchedLines(before))
|
||
|
||
let renamed = try #require(card(card1, in: load(fixture)))
|
||
#expect(renamed.title == .valid("Fix login"))
|
||
#expect(renamed.modifiedBy.isMissing)
|
||
let modified = try #require(renamed.modified.value)
|
||
#expect(abs(modified.timeIntervalSinceNow) < 60)
|
||
|
||
#expect(store.transient.renameEditor == nil, "the editor closes on commit")
|
||
#expect(store.banners.oneShots.isEmpty)
|
||
}
|
||
|
||
@Test("A lane renames through the same path")
|
||
func renamesALane() throws {
|
||
let fixture = try makeBoard()
|
||
defer { fixture.tearDown() }
|
||
let store = try BoardStore(rootURL: fixture.root)
|
||
|
||
store.transient.beginRename(of: lane2, currentTitle: "Doing")
|
||
store.transient.updateRenameDraft("In progress")
|
||
store.commitRename()
|
||
|
||
#expect(try fixture.indexText(Ident.lane2).contains("title: In progress"))
|
||
#expect(try load(fixture).lanes.first { $0.id == lane2 }?.title == .valid("In progress"))
|
||
}
|
||
|
||
@Test("An empty commit removes the title key, byte-faithfully")
|
||
func emptyCommitRemovesTheKey() throws {
|
||
let fixture = try makeBoard()
|
||
defer { fixture.tearDown() }
|
||
let store = try BoardStore(rootURL: fixture.root)
|
||
let before = try fixture.indexText("\(Ident.lane1)/\(Ident.card1)")
|
||
|
||
store.transient.beginRename(of: card1, currentTitle: "First")
|
||
store.transient.updateRenameDraft("")
|
||
store.commitRename()
|
||
|
||
let after = try fixture.indexText("\(Ident.lane1)/\(Ident.card1)")
|
||
// "Committing an empty rename on an existing item removes its `title` key" — the *key*, not
|
||
// an empty string: titles are optional at every level, and `title: ""` would be a real (if
|
||
// blank) title where the face should show the untitled placeholder.
|
||
#expect(!after.contains("title:"))
|
||
#expect(!after.contains("title: \"\""))
|
||
#expect(untouchedLines(after) == untouchedLines(before), "only the title line and the stamps moved")
|
||
|
||
let stripped = try #require(card(card1, in: load(fixture)))
|
||
#expect(stripped.title.isMissing)
|
||
#expect(stripped.order == 1024, "the card keeps its place")
|
||
}
|
||
|
||
@Test("Whitespace commits as empty — a title of three spaces is a slip, not a name")
|
||
func whitespaceIsEmpty() throws {
|
||
let fixture = try makeBoard()
|
||
defer { fixture.tearDown() }
|
||
let store = try BoardStore(rootURL: fixture.root)
|
||
|
||
store.transient.beginRename(of: card1, currentTitle: "First")
|
||
store.transient.updateRenameDraft(" ")
|
||
store.commitRename()
|
||
|
||
#expect(try #require(card(card1, in: load(fixture))).title.isMissing)
|
||
|
||
// And a title with edges is trimmed rather than quoted into the file with its padding.
|
||
store.transient.beginRename(of: card2, currentTitle: "Second")
|
||
store.transient.updateRenameDraft(" Fix login ")
|
||
store.commitRename()
|
||
#expect(try #require(card(card2, in: load(fixture))).title == .valid("Fix login"))
|
||
}
|
||
|
||
@Test("An unchanged title writes nothing at all")
|
||
func unchangedTitleIsANoOp() throws {
|
||
let fixture = try makeBoard()
|
||
defer { fixture.tearDown() }
|
||
try fixture.item("\(Ident.lane2)/\(Ident.card3)", untitled(order: "1024"))
|
||
let store = try BoardStore(rootURL: fixture.root)
|
||
let titled = try fixture.indexData("\(Ident.lane1)/\(Ident.card1)")
|
||
let untouched = try fixture.indexData("\(Ident.lane2)/\(Ident.card3)")
|
||
|
||
// An editor opened and dismissed with Return must not stamp `modified` or (on a git board)
|
||
// mint a commit — the lane-resize rule, for the same reason.
|
||
store.transient.beginRename(of: card1, currentTitle: "First")
|
||
store.commitRename()
|
||
#expect(try fixture.indexData("\(Ident.lane1)/\(Ident.card1)") == titled)
|
||
|
||
// Same for an untitled item committed still untitled: the key must not materialize and then
|
||
// vanish, nor the file be rewritten to say nothing new.
|
||
store.transient.beginRename(of: card3, currentTitle: nil)
|
||
store.commitRename()
|
||
#expect(try fixture.indexData("\(Ident.lane2)/\(Ident.card3)") == untouched)
|
||
#expect(try fixture.entryNames("\(Ident.lane2)/\(Ident.card3)") == ["index.md"], "no temp-file residue either")
|
||
}
|
||
|
||
@Test("A commit at a target the snapshot does not have writes nothing, silently")
|
||
func vanishedTargetWritesNothing() throws {
|
||
let fixture = try makeBoard()
|
||
defer { fixture.tearDown() }
|
||
let store = try BoardStore(rootURL: fixture.root)
|
||
let before = try fixture.entryNames("")
|
||
|
||
// "A target that is tombstoned, deleted, or gone at commit time discards the editor and its
|
||
// keystrokes silently" — silently being the operative word: nothing written, no banner.
|
||
store.transient.beginRename(of: ItemID(rawValue: Ident.indexless), currentTitle: "Ghost")
|
||
store.transient.updateRenameDraft("Never lands")
|
||
store.commitRename()
|
||
|
||
#expect(try fixture.entryNames("") == before)
|
||
#expect(!fixture.exists(Ident.indexless))
|
||
#expect(store.transient.renameEditor == nil)
|
||
#expect(store.banners.oneShots.isEmpty)
|
||
}
|
||
|
||
@Test("A commit at a card that entered the trash writes nothing")
|
||
func targetInTheTrashWritesNothing() throws {
|
||
let fixture = try makeBoard()
|
||
defer { fixture.tearDown() }
|
||
// "Entering the trash is a vanish from the board; nothing is ever written into a vanished
|
||
// folder" (04 ▸ Grammar).
|
||
try fixture.move("\(Ident.lane1)/\(Ident.card1)", toTrash: Ident.card1)
|
||
let store = try BoardStore(rootURL: fixture.root)
|
||
let before = try fixture.indexData(".trash/\(Ident.card1)")
|
||
|
||
store.transient.beginRename(of: card1, currentTitle: "First")
|
||
store.transient.updateRenameDraft("Never lands")
|
||
store.commitRename()
|
||
|
||
#expect(try fixture.indexData(".trash/\(Ident.card1)") == before)
|
||
#expect(store.banners.oneShots.isEmpty)
|
||
}
|
||
|
||
@Test("A rename follows a foreign move — the write lands wherever the card now lives")
|
||
func renameFollowsTheUUID() async throws {
|
||
let fixture = try makeBoard()
|
||
defer { fixture.tearDown() }
|
||
let store = try BoardStore(rootURL: fixture.root)
|
||
|
||
store.transient.beginRename(of: card1, currentTitle: "First")
|
||
store.transient.updateRenameDraft("Fix login")
|
||
|
||
try FileManager.default.moveItem(
|
||
at: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
|
||
to: fixture.url("\(Ident.lane2)/\(Ident.card1)")
|
||
)
|
||
store.handleWatcherEvent(.treeChanged(.foreign))
|
||
await store.awaitQuiescence()
|
||
|
||
store.commitRename()
|
||
|
||
#expect(try fixture.indexText("\(Ident.lane2)/\(Ident.card1)").contains("title: Fix login"))
|
||
#expect(store.banners.oneShots.isEmpty)
|
||
}
|
||
|
||
@Test("A readable-but-uneditable target refuses the write, banners it, and keeps its bytes")
|
||
func uneditableTargetBanners() throws {
|
||
let fixture = try makeBoard()
|
||
defer { fixture.tearDown() }
|
||
let store = try BoardStore(rootURL: fixture.root)
|
||
let before = try fixture.indexData(Ident.lane3)
|
||
|
||
store.transient.beginRename(of: lane3, currentTitle: "Odd")
|
||
store.transient.updateRenameDraft("Renamed")
|
||
store.commitRename()
|
||
|
||
#expect(try fixture.indexData(Ident.lane3) == before)
|
||
#expect(store.banners.oneShots.count == 1)
|
||
let posted = try #require(store.banners.oneShots.first)
|
||
// The title is enriched off the document the write refused, so the banner names the item by
|
||
// what it is still called rather than by the name that never landed.
|
||
#expect(posted.error.operation == .rename(title: "Odd"))
|
||
#expect(BannerCenter.headline(for: posted.error).hasPrefix("Couldn't rename 'Odd' — "))
|
||
}
|
||
|
||
@Test("A read-only board refuses the rename without a second banner")
|
||
func readOnlyBoardRefusesQuietly() throws {
|
||
let fixture = try makeBoard()
|
||
defer { fixture.tearDown() }
|
||
let store = try BoardStore(rootURL: fixture.root)
|
||
store.enterVanishedRootLock()
|
||
let before = try fixture.indexData("\(Ident.lane1)/\(Ident.card1)")
|
||
|
||
store.transient.beginRename(of: card1, currentTitle: "First")
|
||
store.transient.updateRenameDraft("Fix login")
|
||
store.commitRename()
|
||
|
||
#expect(try fixture.indexData("\(Ident.lane1)/\(Ident.card1)") == before)
|
||
#expect(store.banners.oneShots.isEmpty, "the lock row is already standing")
|
||
#expect(store.bannerRows.contains { $0.id == "read-only-lock" })
|
||
}
|
||
}
|
||
|
||
// MARK: - The new-card placeholder's commit
|
||
|
||
@MainActor
|
||
@Suite("BoardStore ▸ new-card placeholder commit")
|
||
struct NewCardCommitWriteTests {
|
||
|
||
@Test("A lane-bottom commit creates the card appended after the visible cards")
|
||
func createsAtTheLaneBottom() throws {
|
||
let fixture = try makeBoard()
|
||
defer { fixture.tearDown() }
|
||
let store = try BoardStore(rootURL: fixture.root)
|
||
let before = try cardFolders(Ident.lane1, in: fixture)
|
||
|
||
store.transient.beginPlaceholder(inLane: lane1)
|
||
store.transient.updateDraft("Fix login")
|
||
let created = try #require(store.commitPlaceholder())
|
||
|
||
let after = try cardFolders(Ident.lane1, in: fixture)
|
||
#expect(after.subtracting(before) == [created.rawValue], "exactly one new card folder")
|
||
|
||
let model = try load(fixture)
|
||
let made = try #require(card(created, in: model))
|
||
#expect(made.title == .valid("Fix login"))
|
||
// Appended after the visible siblings at 1024 and 2048: `Ranks.append` is max + 1024.
|
||
#expect(made.order == 3072)
|
||
#expect(model.lanes.first { $0.id == lane1 }?.cards.map(\.id) == [card1, card2, created],
|
||
"and it lands last in display order")
|
||
|
||
// The overlay stands in for the card until the watcher round-trips it — it does not vanish
|
||
// the instant the Writer returns (02-architecture.md § Layering).
|
||
#expect(store.transient.newCardPlaceholder?.phase == .awaitingArrival(created))
|
||
#expect(store.banners.oneShots.isEmpty)
|
||
}
|
||
|
||
@Test("An after-anchor commit lands the card between its anchor and the next card")
|
||
func createsAfterItsAnchor() throws {
|
||
let fixture = try makeBoard()
|
||
defer { fixture.tearDown() }
|
||
let store = try BoardStore(rootURL: fixture.root)
|
||
|
||
// ⌘N with `card1` selected: "in that card's lane, immediately after it".
|
||
store.transient.beginPlaceholder(inLane: lane1, after: card1)
|
||
store.transient.updateDraft("Fix login")
|
||
let created = try #require(store.commitPlaceholder())
|
||
|
||
let model = try load(fixture)
|
||
let made = try #require(card(created, in: model))
|
||
#expect(made.order == 1536, "the midpoint of 1024 and 2048")
|
||
#expect(model.lanes.first { $0.id == lane1 }?.cards.map(\.id) == [card1, created, card2])
|
||
#expect(made.title == .valid("Fix login"))
|
||
|
||
// The reposition rides the Writer's same-parent degenerate reorder, so exactly one file was
|
||
// rewritten past the create: the neighbours keep their ranks.
|
||
#expect(card(card1, in: model)?.order == 1024)
|
||
#expect(card(card2, in: model)?.order == 2048)
|
||
}
|
||
|
||
@Test("An anchor that is already last is the lane's bottom — no reposition")
|
||
func anchoringTheLastCardIsAnAppend() throws {
|
||
let fixture = try makeBoard()
|
||
defer { fixture.tearDown() }
|
||
let store = try BoardStore(rootURL: fixture.root)
|
||
|
||
store.transient.beginPlaceholder(inLane: lane1, after: card2)
|
||
store.transient.updateDraft("Fix login")
|
||
let created = try #require(store.commitPlaceholder())
|
||
|
||
#expect(try #require(card(created, in: load(fixture))).order == 3072)
|
||
}
|
||
|
||
@Test("An anchor that vanished degrades to the lane's bottom rather than refusing")
|
||
func aVanishedAnchorAppends() async throws {
|
||
let fixture = try makeBoard()
|
||
defer { fixture.tearDown() }
|
||
let store = try BoardStore(rootURL: fixture.root)
|
||
|
||
store.transient.beginPlaceholder(inLane: lane1, after: card1)
|
||
store.transient.updateDraft("Fix login")
|
||
|
||
// An agent deletes the anchor mid-typing. The lane — the anchor that actually matters — is
|
||
// still there, so the card the user is creating is theirs to keep.
|
||
try FileManager.default.removeItem(at: fixture.url("\(Ident.lane1)/\(Ident.card1)"))
|
||
store.handleWatcherEvent(.treeChanged(.foreign))
|
||
await store.awaitQuiescence()
|
||
|
||
let created = try #require(store.commitPlaceholder())
|
||
#expect(try #require(card(created, in: load(fixture))).order == 3072)
|
||
}
|
||
|
||
@Test("An empty commit discards the draft and writes nothing")
|
||
func emptyCommitCreatesNothing() throws {
|
||
let fixture = try makeBoard()
|
||
defer { fixture.tearDown() }
|
||
let store = try BoardStore(rootURL: fixture.root)
|
||
let before = try cardFolders(Ident.lane1, in: fixture)
|
||
|
||
// "Creating-then-abandoning never leaves an empty card behind" — untitled cards exist only
|
||
// when made deliberately (04-interactions.md ▸ Grammar).
|
||
store.transient.beginPlaceholder(inLane: lane1)
|
||
store.transient.updateDraft(" ")
|
||
#expect(store.commitPlaceholder() == nil)
|
||
|
||
#expect(try cardFolders(Ident.lane1, in: fixture) == before)
|
||
#expect(store.transient.newCardPlaceholder == nil)
|
||
#expect(store.banners.oneShots.isEmpty)
|
||
}
|
||
|
||
@Test("A commit into a lane the snapshot has lost discards the draft")
|
||
func vanishedLaneDiscards() throws {
|
||
let fixture = try makeBoard()
|
||
defer { fixture.tearDown() }
|
||
let store = try BoardStore(rootURL: fixture.root)
|
||
|
||
store.transient.beginPlaceholder(inLane: ItemID(rawValue: Ident.indexless))
|
||
store.transient.updateDraft("Fix login")
|
||
#expect(store.commitPlaceholder() == nil)
|
||
|
||
#expect(!fixture.exists(Ident.indexless))
|
||
#expect(store.transient.newCardPlaceholder == nil)
|
||
#expect(store.banners.oneShots.isEmpty)
|
||
}
|
||
|
||
@Test("A failed create discards the placeholder and banners the failure")
|
||
func aFailedCreateDiscards() throws {
|
||
let fixture = try makeBoard()
|
||
defer { fixture.tearDown() }
|
||
let store = try BoardStore(rootURL: fixture.root)
|
||
|
||
// Read-execute only: the lane loads and renders, but no folder can be minted inside it.
|
||
let laneFolder = fixture.url(Ident.lane2)
|
||
try FileManager.default.setAttributes([.posixPermissions: 0o555], ofItemAtPath: laneFolder.path)
|
||
defer { try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: laneFolder.path) }
|
||
|
||
store.transient.beginPlaceholder(inLane: lane2)
|
||
store.transient.updateDraft("Fix login")
|
||
#expect(store.commitPlaceholder() == nil)
|
||
|
||
// Settled in 02-architecture.md § Layering: "if the Writer create throws after the title
|
||
// commits, the create flow discards the placeholder … the overlay never waits for a card
|
||
// that cannot arrive". The failure is the banner's, not the overlay's.
|
||
#expect(store.transient.newCardPlaceholder == nil)
|
||
#expect(store.banners.oneShots.count == 1)
|
||
#expect(store.banners.oneShots.first?.error.operation == .createCard)
|
||
}
|
||
|
||
@Test("A read-only board refuses the create without a second banner")
|
||
func readOnlyBoardRefusesQuietly() throws {
|
||
let fixture = try makeBoard()
|
||
defer { fixture.tearDown() }
|
||
let store = try BoardStore(rootURL: fixture.root)
|
||
store.enterVanishedRootLock()
|
||
let before = try cardFolders(Ident.lane1, in: fixture)
|
||
|
||
store.transient.beginPlaceholder(inLane: lane1)
|
||
store.transient.updateDraft("Fix login")
|
||
#expect(store.commitPlaceholder() == nil)
|
||
|
||
#expect(try cardFolders(Ident.lane1, in: fixture) == before)
|
||
#expect(store.transient.newCardPlaceholder == nil)
|
||
#expect(store.banners.oneShots.isEmpty)
|
||
}
|
||
|
||
@Test("A committed placeholder is not committed twice")
|
||
func commitIsIdempotent() throws {
|
||
let fixture = try makeBoard()
|
||
defer { fixture.tearDown() }
|
||
let store = try BoardStore(rootURL: fixture.root)
|
||
|
||
store.transient.beginPlaceholder(inLane: lane1)
|
||
store.transient.updateDraft("Fix login")
|
||
let created = try #require(store.commitPlaceholder())
|
||
let after = try cardFolders(Ident.lane1, in: fixture)
|
||
|
||
// Return commits and the field's focus-loss handler fires an instant later; the second call
|
||
// must not file a duplicate.
|
||
#expect(store.commitPlaceholder() == nil)
|
||
#expect(try cardFolders(Ident.lane1, in: fixture) == after)
|
||
#expect(store.transient.newCardPlaceholder?.phase == .awaitingArrival(created))
|
||
}
|
||
}
|
||
|
||
// MARK: - Lane reorder
|
||
|
||
@MainActor
|
||
@Suite("BoardStore ▸ lane reorder")
|
||
struct LaneReorderWriteTests {
|
||
|
||
@Test("A lane moved to the head takes a rank before every sibling")
|
||
func movesToTheHead() throws {
|
||
let fixture = try makeBoard()
|
||
defer { fixture.tearDown() }
|
||
let store = try BoardStore(rootURL: fixture.root)
|
||
|
||
// lane1 @1024, lane2 @2048, lane3 @1024 (`Item.uneditable`'s own order; the folder-name
|
||
// tie-break puts it after lane1). Moving the last lane to index 0 puts it at min − 1024.
|
||
store.moveLane(lane2, toIndex: 0)
|
||
|
||
let model = try load(fixture)
|
||
#expect(model.lanes.map(\.id) == [lane2, lane1, lane3])
|
||
#expect(model.lanes.first { $0.id == lane2 }?.order == 0)
|
||
}
|
||
|
||
@Test("A lane moved between two siblings takes their midpoint")
|
||
func movesBetweenSiblings() throws {
|
||
let fixture = try WriterFixture()
|
||
defer { fixture.tearDown() }
|
||
try fixture.item("", Item.board)
|
||
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "One"))
|
||
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Two"))
|
||
try fixture.item(Ident.lane3, Item.rich(order: "3072", title: "Three"))
|
||
let store = try BoardStore(rootURL: fixture.root)
|
||
|
||
// With lane1 removed the remaining pair is [lane2 @2048, lane3 @3072]; landing at index 1
|
||
// is between them.
|
||
store.moveLane(lane1, toIndex: 1)
|
||
|
||
let model = try load(fixture)
|
||
#expect(model.lanes.map(\.id) == [lane2, lane1, lane3])
|
||
#expect(model.lanes.first { $0.id == lane1 }?.order == 2560)
|
||
// "A reorder rewrites only the moved item's `index.md`" — the neighbours keep their ranks.
|
||
#expect(model.lanes.first { $0.id == lane2 }?.order == 2048)
|
||
#expect(model.lanes.first { $0.id == lane3 }?.order == 3072)
|
||
}
|
||
|
||
@Test("A lane moved to the end is appended past every sibling")
|
||
func movesToTheEnd() throws {
|
||
let fixture = try WriterFixture()
|
||
defer { fixture.tearDown() }
|
||
try fixture.item("", Item.board)
|
||
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "One"))
|
||
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Two"))
|
||
let store = try BoardStore(rootURL: fixture.root)
|
||
|
||
store.moveLane(lane1, toIndex: 1)
|
||
|
||
let model = try load(fixture)
|
||
#expect(model.lanes.map(\.id) == [lane2, lane1])
|
||
#expect(model.lanes.first { $0.id == lane1 }?.order == 3072)
|
||
}
|
||
|
||
@Test("A drag that ends where it started writes nothing at all")
|
||
func unchangedIndexIsANoOp() throws {
|
||
let fixture = try makeBoard()
|
||
defer { fixture.tearDown() }
|
||
let store = try BoardStore(rootURL: fixture.root)
|
||
let before = try fixture.indexData(Ident.lane1)
|
||
|
||
// Index 0 is where lane1 already sits, counted with itself removed — the release must not
|
||
// stamp `modified` or mint a commit.
|
||
store.moveLane(lane1, toIndex: 0)
|
||
|
||
#expect(try fixture.indexData(Ident.lane1) == before)
|
||
// The writer's temp files are hidden, so only a listing that sees them can prove there is
|
||
// no residue from a write that should never have started.
|
||
#expect(try !fixture.entryNames(Ident.lane1).contains { $0.hasPrefix(".") })
|
||
#expect(store.banners.oneShots.isEmpty)
|
||
}
|
||
|
||
@Test("Exhausted precision compacts the board and then places the lane")
|
||
func exhaustedPrecisionRenumbers() throws {
|
||
let fixture = try WriterFixture()
|
||
defer { fixture.tearDown() }
|
||
try fixture.item("", Item.board)
|
||
// Two lanes on adjacent Doubles: no rank exists between them (01-storage-format.md §
|
||
// Ordering's renumber trigger), which is exactly what the compaction fallback is for.
|
||
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "One"))
|
||
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Two"))
|
||
try fixture.item(Ident.lane3, Item.rich(order: "2048.0000000000005", title: "Three"))
|
||
let store = try BoardStore(rootURL: fixture.root)
|
||
#expect(Ranks.midpoint(between: 2048, and: 2048.0000000000005) == nil, "the gap really is exhausted")
|
||
|
||
// lane1 to the slot between lane2 and lane3.
|
||
store.moveLane(lane1, toIndex: 1)
|
||
|
||
let model = try load(fixture)
|
||
#expect(model.lanes.map(\.id) == [lane2, lane1, lane3])
|
||
// The compaction runs first and is **sequence-preserving** — it rewrites the ladder to
|
||
// 1024/2048/3072 in the display order the board already had (lane1, lane2, lane3), so
|
||
// nothing visibly moves. Only then is the dragged lane placed, at the midpoint of the fresh
|
||
// gap between lane2 and lane3.
|
||
#expect(model.lanes.first { $0.id == lane2 }?.order == 2048)
|
||
#expect(model.lanes.first { $0.id == lane1 }?.order == 2560)
|
||
#expect(model.lanes.first { $0.id == lane3 }?.order == 3072,
|
||
"the exhausted rank was compacted away rather than worked around")
|
||
}
|
||
|
||
@Test("A lane that is not on the board is a no-op, and a read-only board refuses quietly")
|
||
func unknownLaneAndLockedBoard() throws {
|
||
let fixture = try makeBoard()
|
||
defer { fixture.tearDown() }
|
||
let store = try BoardStore(rootURL: fixture.root)
|
||
let before = try fixture.indexData(Ident.lane1)
|
||
|
||
store.moveLane(ItemID(rawValue: Ident.indexless), toIndex: 0)
|
||
#expect(try fixture.indexData(Ident.lane1) == before)
|
||
|
||
store.enterVanishedRootLock()
|
||
store.moveLane(lane1, toIndex: 2)
|
||
#expect(try fixture.indexData(Ident.lane1) == before)
|
||
#expect(store.banners.oneShots.isEmpty)
|
||
#expect(store.bannerRows.contains { $0.id == "read-only-lock" })
|
||
}
|
||
}
|
||
|
||
// MARK: - New lane
|
||
|
||
@MainActor
|
||
@Suite("BoardStore ▸ new lane")
|
||
struct NewLaneWriteTests {
|
||
|
||
@Test("New Lane appends an untitled lane at the board's right end")
|
||
func createsAnUntitledLane() throws {
|
||
let fixture = try WriterFixture()
|
||
defer { fixture.tearDown() }
|
||
try fixture.item("", Item.board)
|
||
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "One"))
|
||
let store = try BoardStore(rootURL: fixture.root)
|
||
|
||
store.createLane()
|
||
|
||
let model = try load(fixture)
|
||
#expect(model.lanes.count == 2)
|
||
let made = try #require(model.lanes.last)
|
||
#expect(made.id != lane1)
|
||
// No `title` key at all — the folder-name fallback is a board-level rule, and a lane with no
|
||
// title renders the untitled placeholder until Board ▸ Rename gives it one.
|
||
#expect(made.title.isMissing)
|
||
#expect(made.order == 2048)
|
||
#expect(store.banners.oneShots.isEmpty)
|
||
}
|
||
|
||
@Test("New Lane is the way out of a zero-lane board")
|
||
func createsTheFirstLane() throws {
|
||
let fixture = try WriterFixture()
|
||
defer { fixture.tearDown() }
|
||
try fixture.item("", Item.board)
|
||
let store = try BoardStore(rootURL: fixture.root)
|
||
#expect(store.snapshot.lanes.isEmpty)
|
||
|
||
store.createLane()
|
||
|
||
let model = try load(fixture)
|
||
#expect(model.lanes.count == 1)
|
||
#expect(model.lanes.first?.order == 1024, "an empty parent's first child lands at the board convention")
|
||
}
|
||
|
||
@Test("A read-only board refuses the create without a second banner")
|
||
func readOnlyBoardRefusesQuietly() throws {
|
||
let fixture = try WriterFixture()
|
||
defer { fixture.tearDown() }
|
||
try fixture.item("", Item.board)
|
||
let store = try BoardStore(rootURL: fixture.root)
|
||
store.enterVanishedRootLock()
|
||
|
||
store.createLane()
|
||
|
||
#expect(try load(fixture).lanes.isEmpty)
|
||
#expect(store.banners.oneShots.isEmpty)
|
||
}
|
||
}
|
||
|
||
// MARK: - The board rename
|
||
|
||
/// The board root's own `index.md` — richer than `Item.board` because a rename has to leave all of
|
||
/// it alone: an unknown key with its inline comment, a `created` from before today, a foreign
|
||
/// `modified-by`, and the board description body.
|
||
private let richBoardIndex = """
|
||
---
|
||
schema: 1
|
||
title: Roadmap
|
||
project: lanework # agent overlay
|
||
created: 2026-01-01T09:00:00Z
|
||
modified: 2026-02-02T09:00:00Z
|
||
modified-by: claude
|
||
---
|
||
Board description — with *markdown*.
|
||
|
||
"""
|
||
|
||
/// A board with no `title` key at all: the folder-name fallback state a rename both starts from and
|
||
/// returns a board to (01-storage-format.md § Board naming).
|
||
private let untitledBoardIndex = """
|
||
---
|
||
schema: 1
|
||
created: 2026-01-01T09:00:00Z
|
||
modified: 2026-02-02T09:00:00Z
|
||
---
|
||
Board description.
|
||
|
||
"""
|
||
|
||
/// Readable, uneditable, at board level: the whole-frontmatter flow mapping, which loads and renders
|
||
/// fine but has no line for the editor to key on.
|
||
private let uneditableBoardIndex = "---\n{schema: 1, title: Odd}\n---\nodd body\n"
|
||
|
||
/// A board root and one lane, so the fixture is a board the store will actually load.
|
||
@MainActor
|
||
private func makeBoardRoot(_ index: String) throws -> WriterFixture {
|
||
let fixture = try WriterFixture()
|
||
try fixture.item("", index)
|
||
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
||
return fixture
|
||
}
|
||
|
||
@MainActor
|
||
@Suite("BoardStore ▸ board rename")
|
||
struct BoardRenameWriteTests {
|
||
|
||
@Test("A non-empty commit writes the title, stamps modified, and touches nothing else")
|
||
func writesTheTitleAndStamps() throws {
|
||
let fixture = try makeBoardRoot(richBoardIndex)
|
||
defer { fixture.tearDown() }
|
||
let store = try BoardStore(rootURL: fixture.root)
|
||
let before = try fixture.indexText("")
|
||
|
||
store.renameBoard("Q3 plan")
|
||
|
||
let after = try fixture.indexText("")
|
||
#expect(after.contains("title: Q3 plan"))
|
||
#expect(!after.contains("modified-by"), "an app-mediated write clears an external writer's attribution")
|
||
#expect(!after.contains("modified: 2026-02-02T09:00:00Z"), "the stamp is fresh")
|
||
// The unknown key with its comment, the original `created`, and the description survive
|
||
// exactly, in order — the round-trip contract, at board level like every other.
|
||
#expect(untouchedLines(after) == untouchedLines(before))
|
||
|
||
let model = try load(fixture)
|
||
#expect(model.title == .valid("Q3 plan"))
|
||
#expect(model.modifiedBy.isMissing)
|
||
let modified = try #require(model.modified.value)
|
||
#expect(abs(modified.timeIntervalSinceNow) < 60)
|
||
#expect(store.banners.oneShots.isEmpty)
|
||
}
|
||
|
||
@Test("The folder is never renamed — only the frontmatter moves")
|
||
func theFolderIsLeftAlone() throws {
|
||
let fixture = try makeBoardRoot(richBoardIndex)
|
||
defer { fixture.tearDown() }
|
||
let name = fixture.root.lastPathComponent
|
||
let store = try BoardStore(rootURL: fixture.root)
|
||
|
||
store.renameBoard("Something else entirely")
|
||
|
||
// "The folder is never renamed by the app; the Finder document name is Finder's to change"
|
||
// (03-board-ui.md § Board popover) — the display name and the document name may diverge.
|
||
#expect(fixture.root.lastPathComponent == name)
|
||
#expect(fixture.exists(""))
|
||
#expect(store.rootURL == fixture.root)
|
||
}
|
||
|
||
@Test("An empty commit removes the title key and the board falls back to its folder name")
|
||
func emptyCommitRemovesTheKey() throws {
|
||
let fixture = try makeBoardRoot(richBoardIndex)
|
||
defer { fixture.tearDown() }
|
||
let store = try BoardStore(rootURL: fixture.root)
|
||
let before = try fixture.indexText("")
|
||
|
||
store.renameBoard("")
|
||
|
||
let after = try fixture.indexText("")
|
||
#expect(!after.contains("title:"))
|
||
#expect(!after.contains("title: \"\""), "the key leaves; it is never blanked")
|
||
#expect(untouchedLines(after) == untouchedLines(before), "only the title line and the stamps moved")
|
||
#expect(try load(fixture).title.isMissing)
|
||
|
||
// And the fallback is the *folder* name, never "Untitled" — 01-storage-format.md's
|
||
// board-naming rule, which is also what the empty field's placeholder promises.
|
||
let reopened = try BoardStore(rootURL: fixture.root)
|
||
#expect(AppModel.displayName(of: reopened) == fixture.root.deletingPathExtension().lastPathComponent)
|
||
}
|
||
|
||
@Test("nil commits as empty — the field's absent-title state and its cleared state agree")
|
||
func nilRemovesTheKeyToo() throws {
|
||
let fixture = try makeBoardRoot(richBoardIndex)
|
||
defer { fixture.tearDown() }
|
||
let store = try BoardStore(rootURL: fixture.root)
|
||
|
||
store.renameBoard(nil)
|
||
|
||
#expect(try !fixture.indexText("").contains("title:"))
|
||
#expect(try load(fixture).title.isMissing)
|
||
}
|
||
|
||
@Test("Whitespace commits as empty, and a padded title is trimmed rather than quoted")
|
||
func whitespaceIsEmpty() throws {
|
||
let fixture = try makeBoardRoot(richBoardIndex)
|
||
defer { fixture.tearDown() }
|
||
let store = try BoardStore(rootURL: fixture.root)
|
||
|
||
store.renameBoard(" ")
|
||
#expect(try load(fixture).title.isMissing)
|
||
|
||
let padded = try makeBoardRoot(richBoardIndex)
|
||
defer { padded.tearDown() }
|
||
let other = try BoardStore(rootURL: padded.root)
|
||
other.renameBoard(" Q3 plan ")
|
||
#expect(try load(padded).title == .valid("Q3 plan"))
|
||
}
|
||
|
||
@Test("An unchanged title writes nothing at all")
|
||
func unchangedTitleIsANoOp() throws {
|
||
let fixture = try makeBoardRoot(richBoardIndex)
|
||
defer { fixture.tearDown() }
|
||
let store = try BoardStore(rootURL: fixture.root)
|
||
let titled = try fixture.indexData("")
|
||
|
||
// A popover opened and dismissed with Return must not stamp `modified` or (on a git board)
|
||
// mint a commit — `commitRename`'s rule, for its reason.
|
||
store.renameBoard("Roadmap")
|
||
#expect(try fixture.indexData("") == titled)
|
||
|
||
// Same for an untitled board committed still untitled: the key must not materialize and
|
||
// then vanish, nor the file be rewritten to say nothing new.
|
||
let untitled = try makeBoardRoot(untitledBoardIndex)
|
||
defer { untitled.tearDown() }
|
||
let untouched = try untitled.indexData("")
|
||
let second = try BoardStore(rootURL: untitled.root)
|
||
|
||
second.renameBoard("")
|
||
#expect(try untitled.indexData("") == untouched)
|
||
second.renameBoard(nil)
|
||
#expect(try untitled.indexData("") == untouched)
|
||
#expect(try untitled.entryNames("").sorted() == [Ident.lane1, "index.md"], "no temp-file residue either")
|
||
}
|
||
|
||
@Test("A readable-but-uneditable board refuses the write, banners it, and keeps its bytes")
|
||
func uneditableBoardBanners() throws {
|
||
let fixture = try makeBoardRoot(uneditableBoardIndex)
|
||
defer { fixture.tearDown() }
|
||
let store = try BoardStore(rootURL: fixture.root)
|
||
let before = try fixture.indexData("")
|
||
|
||
store.renameBoard("Renamed")
|
||
|
||
#expect(try fixture.indexData("") == before)
|
||
#expect(store.banners.oneShots.count == 1)
|
||
let posted = try #require(store.banners.oneShots.first)
|
||
// Enriched off the document the write refused, so the banner names the board by what it is
|
||
// still called rather than by the name that never landed.
|
||
#expect(posted.error.operation == .rename(title: "Odd"))
|
||
#expect(BannerCenter.headline(for: posted.error).hasPrefix("Couldn't rename 'Odd' — "))
|
||
}
|
||
|
||
@Test("A read-only board refuses the rename without a second banner")
|
||
func readOnlyBoardRefusesQuietly() throws {
|
||
let fixture = try makeBoardRoot(richBoardIndex)
|
||
defer { fixture.tearDown() }
|
||
let store = try BoardStore(rootURL: fixture.root)
|
||
store.enterVanishedRootLock()
|
||
let before = try fixture.indexData("")
|
||
|
||
store.renameBoard("Q3 plan")
|
||
|
||
#expect(try fixture.indexData("") == before)
|
||
#expect(store.banners.oneShots.isEmpty, "the lock row is already standing")
|
||
#expect(store.bannerRows.contains { $0.id == "read-only-lock" })
|
||
}
|
||
}
|
||
|
||
// MARK: - The board popover's presentation
|
||
|
||
@MainActor
|
||
@Suite("Board popover ▸ presentation")
|
||
struct BoardInfoPresentationTests {
|
||
|
||
@Test("⌘I toggles: it opens a closed popover and closes an open one")
|
||
func toggles() {
|
||
let presentation = BoardInfoPresentation()
|
||
#expect(!presentation.isPresented, "a window opens with its popover closed")
|
||
|
||
presentation.toggle()
|
||
#expect(presentation.isPresented)
|
||
|
||
presentation.toggle()
|
||
#expect(!presentation.isPresented, "the shortcut is the keyboard's way back out")
|
||
}
|
||
|
||
@Test("Two board windows never fight over one flag")
|
||
func isPerWindow() {
|
||
let first = BoardInfoPresentation()
|
||
let second = BoardInfoPresentation()
|
||
|
||
first.toggle()
|
||
|
||
#expect(first.isPresented)
|
||
#expect(!second.isPresented)
|
||
}
|
||
}
|
||
|
||
// MARK: - The window-title widget
|
||
|
||
/// The AppKit half of the board popover: the titlebar accessory and the SwiftUI widget it hosts.
|
||
///
|
||
/// Deliberately **not** a test of what the widget looks like or of the popover's behaviour, neither
|
||
/// of which a unit test can see. It is a test that a window survives having one — an accessory is
|
||
/// installed during window setup, where a mistake crashes at open rather than misdraws, and this is
|
||
/// the cheapest place to find that out. `HostedWindowController`'s two promises about it (once per
|
||
/// window, off again on detach) are checked at the same time, because both are the kind of thing a
|
||
/// later refactor breaks silently.
|
||
@MainActor
|
||
@Suite("Board popover ▸ the window-title widget")
|
||
struct BoardInfoAccessoryTests {
|
||
|
||
/// A defaults domain of this suite's own — `StyleEditorView` reaches the recents list, and a
|
||
/// test that wrote into `UserDefaults.standard` would be editing the developer's quick-style row.
|
||
private func makeRecents() -> (recents: StyleRecents, teardown: () -> Void) {
|
||
let name = "BoardInfoAccessoryTests-\(UUID().uuidString)"
|
||
guard let defaults = UserDefaults(suiteName: name) else {
|
||
Issue.record("could not create a defaults suite")
|
||
return (StyleRecents(defaults: .standard), {})
|
||
}
|
||
return (StyleRecents(defaults: defaults), { defaults.removePersistentDomain(forName: name) })
|
||
}
|
||
|
||
private func makeWindow() -> NSWindow {
|
||
NSWindow(
|
||
contentRect: NSRect(x: 0, y: 0, width: 800, height: 500),
|
||
styleMask: [.titled, .closable, .resizable],
|
||
backing: .buffered,
|
||
defer: false
|
||
)
|
||
}
|
||
|
||
@Test("It installs on a real window, refuses a second, and comes back off on detach")
|
||
func installsOnceAndRemoves() throws {
|
||
let fixture = try makeBoardRoot(richBoardIndex)
|
||
defer { fixture.tearDown() }
|
||
let store = try BoardStore(rootURL: fixture.root)
|
||
let (recents, teardown) = makeRecents()
|
||
defer { teardown() }
|
||
|
||
let window = makeWindow()
|
||
let controller = HostedWindowController()
|
||
controller.attach(to: window)
|
||
|
||
controller.installTitlebarAccessory(
|
||
boardInfoTitlebarAccessory(store: store, recents: recents, presentation: BoardInfoPresentation())
|
||
)
|
||
#expect(window.titlebarAccessoryViewControllers.count == 1)
|
||
|
||
// A host that configures itself twice must not give the titlebar two chevrons — AppKit keeps
|
||
// accessories in an array and would happily hold both.
|
||
controller.installTitlebarAccessory(
|
||
boardInfoTitlebarAccessory(store: store, recents: recents, presentation: BoardInfoPresentation())
|
||
)
|
||
#expect(window.titlebarAccessoryViewControllers.count == 1)
|
||
|
||
controller.detach()
|
||
#expect(window.titlebarAccessoryViewControllers.isEmpty, "the window is SwiftUI's; what we hang on it comes off")
|
||
}
|
||
|
||
@Test("An accessory handed over before the window exists goes in when one arrives")
|
||
func installsAfterTheWindowAttaches() throws {
|
||
let fixture = try makeBoardRoot(richBoardIndex)
|
||
defer { fixture.tearDown() }
|
||
let store = try BoardStore(rootURL: fixture.root)
|
||
let (recents, teardown) = makeRecents()
|
||
defer { teardown() }
|
||
|
||
// The board window's real order: the load returns (and the accessory is built) before or
|
||
// after `viewDidMoveToWindow`, and neither ordering may drop it.
|
||
let controller = HostedWindowController()
|
||
controller.installTitlebarAccessory(
|
||
boardInfoTitlebarAccessory(store: store, recents: recents, presentation: BoardInfoPresentation())
|
||
)
|
||
|
||
let window = makeWindow()
|
||
controller.attach(to: window)
|
||
|
||
#expect(window.titlebarAccessoryViewControllers.count == 1)
|
||
controller.detach()
|
||
}
|
||
}
|