The window-title widget arrives as a leading titlebar accessory — a quiet chevron on board windows only, installed and removed by the window controller's own attach lifecycle — anchoring the one board-level surface as a transient popover (the board window deliberately grows no toolbar item for it). Inside: board rename editing frontmatter title only (the folder is never renamed; an empty commit removes the key and the window title falls back to the folder name), the embedded shared style editor permanently targeting the board, and the labeled Git section that this milestone only reserves — a mode-none explanation and a disabled stub where m7's add-git, branch, remote, and authentication controls land. Cmd-I (File > Board Info) toggles it per window through a focused scene value, kept apart from board-scoped transient state since a titlebar popover belongs to one window, not to the board. Escape reverts a dirty rename field and falls through to dismiss otherwise; a foreign rename resyncs the field only while unfocused. 12 new tests. 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 under a tombstoned lane writes nothing — liveness is effective")
|
||
func targetUnderATombstonedLaneWritesNothing() throws {
|
||
let fixture = try makeBoard()
|
||
defer { fixture.tearDown() }
|
||
// The lane carries the tombstone; the card's own flag is untouched, and it renders nowhere
|
||
// regardless (03-board-ui.md collapses the lane to one trash entry).
|
||
try fixture.item(Ident.lane1, tombstoned(order: "1024", title: "Todo"))
|
||
let store = try BoardStore(rootURL: fixture.root)
|
||
let before = try fixture.indexData("\(Ident.lane1)/\(Ident.card1)")
|
||
|
||
store.transient.beginRename(of: card1, currentTitle: "First")
|
||
store.transient.updateRenameDraft("Never lands")
|
||
store.commitRename()
|
||
|
||
#expect(try fixture.indexData("\(Ident.lane1)/\(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()
|
||
}
|
||
}
|