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 { // `kind:` joins the filtered keys as of 2026-07-29: a file without one gains it on the // first app write that rewrites it — the integrity service's on-touch heal, riding this // write's own atomic rewrite (01-storage-format.md § Validation and healing). !$0.hasPrefix("modified") && !$0.hasPrefix("title:") && !$0.hasPrefix("kind:") } } 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 { 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() } /// **The card that widened the widget from a chevron to the whole board-name area also hides the /// title bar's own title on board windows** (`BoardWindowHost.configureWindow`'s two calls, in the /// same order: install the widget, then `hideTitle()`) — the widget now says the board's name, so /// the system title display would only repeat it, exactly the card-window seam /// `CardWindowTitleVisibilityTests` pins below. This test cannot drive `BoardWindowHost.body` /// itself (it needs a live `AppModel` session), so it pins the two calls composed the same way, in /// the same order, against a real window — which is what would break silently if a later change /// separated them or reordered them. @Test("Installing the widget and hiding the title compose without one clobbering the other") func hidesTitleAlongsideTheAccessory() 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() window.title = "My Board" let controller = HostedWindowController() controller.attach(to: window) controller.installTitlebarAccessory( boardInfoTitlebarAccessory(store: store, recents: recents, presentation: BoardInfoPresentation()) ) controller.hideTitle() #expect(window.titlebarAccessoryViewControllers.count == 1) #expect(window.titleVisibility == .hidden, "the widget says the name now; the chrome shouldn't say it twice") #expect(window.title == "My Board", "the Window menu, Exposé and restoration still need the string") } } // MARK: - Card window chrome ▸ hidden title /// **Card and board windows show no title in the title bar** — a card's name is shown as part of its /// body instead (05-card-window.md ▸ Window: "card title is shown as part of card's body"), and a /// board's is said by the board-popover widget in the titlebar instead (03-board-ui.md ▸ Board /// popover) — while the toolbar renders exactly as it always has on both. `HostedWindowController /// .hideTitle()` is the seam both `CardWindowHost` and `BoardWindowHost` reach for; only the /// restore-bootstrap window calls no such thing and keeps AppKit's own default. Like /// `BoardInfoAccessoryTests` above, this cannot see what a user sees on screen — only that the /// mechanism this app owns (`NSWindow.titleVisibility`) lands correctly, survives the macOS 26 /// provisional-window swap (`HostedWindowController.detach`'s doc comment — chrome held by the /// controller reapplies to whichever window actually sticks around), and leaves `window.title` itself /// alone, since that string is what the Window menu, Mission Control/Exposé, VoiceOver and state /// restoration all read. @MainActor @Suite("Card window chrome ▸ hidden title") struct CardWindowTitleVisibilityTests { private func makeWindow(title: String = "Fix login") -> NSWindow { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 400, height: 300), styleMask: [.titled, .closable, .resizable], backing: .buffered, defer: false ) window.title = title return window } @Test("hideTitle() hides the title bar's title but leaves window.title alone") func hidesTitleLeavesTitleStringAlone() { let window = makeWindow() let controller = HostedWindowController() controller.attach(to: window) controller.hideTitle() #expect(window.titleVisibility == .hidden) #expect(window.title == "Fix login", "the Window menu, Exposé and restoration still need the string") } @Test("A window nobody calls hideTitle() on keeps AppKit's default — the restore-bootstrap window's posture") func unconfiguredWindowsKeepTheDefault() { let window = makeWindow() let controller = HostedWindowController() controller.attach(to: window) #expect(window.titleVisibility == .visible) } @Test("Hiding before the window exists still lands once one attaches") func hidesBeforeAttach() { let controller = HostedWindowController() controller.hideTitle() let window = makeWindow() controller.attach(to: window) #expect(window.titleVisibility == .hidden) } @Test("The setting survives the provisional-window swap: detach, then attach to the real window") func survivesDetachAttachCycle() { let provisional = makeWindow() let controller = HostedWindowController() controller.attach(to: provisional) controller.hideTitle() #expect(provisional.titleVisibility == .hidden) controller.detach() let real = makeWindow(title: "Fix login") controller.attach(to: real) #expect(real.titleVisibility == .hidden, "reapplied on the window that actually sticks around") #expect(real.title == "Fix login", "window.title still flows independently of the chrome swap") } }