Files
lanework/KanbanTests/InlineEditWriteTests.swift
T
rzen b35566e0fe Build lane chrome — title bar, badge, inline rename
The lane title bar becomes real: leading SF Symbol (hand-written names
render leniently, unknown ones fall back to the level default), title
or secondary untitled placeholder, a quiet count badge that counts
exactly the cards the body renders (so the m5 search filter is
followed by construction), and a new-card button. The whole bar is
the reorder drag surface — no grip — with click-vs-movement splitting
select from drag; a pure proposal function maps the drag to an
insertion index and release commits through the Writer's same-parent
degenerate reorder, compacting and retrying when midpoint precision
runs out. Clicking never edits: inline rename is Return on the sole
selected card or Board > Rename for either kind, a third transient
editor beside the placeholder that tracks its target by UUID, commits
on focus loss, discards silently when the target vanishes, and
removes the title key on an empty commit. The new-card placeholder
renders at last — the settled Cmd-N target rule (pure, tested) files
it after the anchor card, at a selected lane's bottom, or into the
last-active lane; Return commits and re-selects the lane, Cmd-Return
also opens the card window, and a failed create discards the overlay.
New Card / New Lane / Rename land in the menus with focused-editor
and read-only validation; rename gets its own WriteOperation case in
the banner vocabulary. 59 new tests.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-27 08:52:24 -04:00

659 lines
28 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
import Testing
@testable import Kanban
/// The two inline editors' **write** paths — `BoardStore.commitRename` and
/// `BoardStore.commitPlaceholder` — plus the lane drag's `moveLane`.
///
/// 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)
}
}