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
This commit is contained in:
@@ -51,6 +51,7 @@ private let everyOperation: [WriteOperation] = [
|
||||
.purge(title: "Fix login"),
|
||||
.style(title: "Fix login"),
|
||||
.resize(title: "Fix login"),
|
||||
.rename(title: "Fix login"),
|
||||
.importAttachment(filename: "photo.png"),
|
||||
.listAttachments,
|
||||
.renumberChildren,
|
||||
@@ -66,6 +67,7 @@ private let titledOperations: [(with: WriteOperation, without: WriteOperation)]
|
||||
(.purge(title: "Fix login"), .purge(title: nil)),
|
||||
(.style(title: "Fix login"), .style(title: nil)),
|
||||
(.resize(title: "Fix login"), .resize(title: nil)),
|
||||
(.rename(title: "Fix login"), .rename(title: nil)),
|
||||
]
|
||||
|
||||
// MARK: - Ordering
|
||||
@@ -359,6 +361,20 @@ struct BannerCenterPhrasingTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Rename says the design's own sentence, and never borrows styling's")
|
||||
func renameHasItsOwnVerb() {
|
||||
// 02-architecture.md § Write-failure surfacing names this line verbatim when it settles
|
||||
// that "the vocabulary grows with the surfaces": inline rename gets its own case rather
|
||||
// than folding into the generic frontmatter bucket, so a failed rename must not tell the
|
||||
// user the app could not *restyle* anything.
|
||||
#expect(BannerCenter.headline(for: error(.rename(title: "Fix login"), .io(message: "the disk is full")))
|
||||
== "Couldn't rename 'Fix login' — the disk is full")
|
||||
#expect(BannerCenter.headline(for: error(.rename(title: nil), .io(message: "the disk is full")))
|
||||
== "Couldn't rename the item — the disk is full")
|
||||
#expect(BannerCenter.headline(for: error(.rename(title: "Fix login")))
|
||||
!= BannerCenter.headline(for: error(.style(title: "Fix login"))))
|
||||
}
|
||||
|
||||
@Test("The cause tail comes from the error's reason and nowhere else")
|
||||
func causeTailCarriesTheDiagnosis() {
|
||||
#expect(BannerCenter.headline(for: error(.move(title: "Fix login"), .io(message: "the disk is full")))
|
||||
|
||||
@@ -0,0 +1,658 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import CoreGraphics
|
||||
import Testing
|
||||
@testable import Kanban
|
||||
|
||||
/// `LaneReorderMath` — the lane drag's proposal, as arithmetic.
|
||||
///
|
||||
/// The board these numbers describe: `standard = 100`, `gap = 10`, so a 1× slot is 100pt wide, a 2×
|
||||
/// slot is 210 (two standards plus the interior gap it swallows) and a 3× is 320. The strip's outer
|
||||
/// margin is one gap, so the first slot starts at x = 10.
|
||||
|
||||
private let standard: CGFloat = 100
|
||||
private let gap: CGFloat = 10
|
||||
|
||||
private func proposal(_ units: [Int], dragging index: Int, centre: CGFloat) -> Int {
|
||||
LaneReorderMath.proposedIndex(
|
||||
unitCounts: units,
|
||||
draggedIndex: index,
|
||||
dragCentreX: centre,
|
||||
standard: standard,
|
||||
gap: gap
|
||||
)
|
||||
}
|
||||
|
||||
@Suite("LaneReorderMath")
|
||||
struct LaneReorderMathTests {
|
||||
|
||||
// MARK: Resting geometry
|
||||
|
||||
@Test("A lane's resting centre is its slot's midpoint, gaps and wide lanes counted")
|
||||
func restingCentres() {
|
||||
// Four 1× lanes: slots at [10, 110), [120, 220), [230, 330), [340, 440).
|
||||
let uniform = [1, 1, 1, 1]
|
||||
#expect(LaneReorderMath.centre(ofLaneAt: 0, unitCounts: uniform, standard: standard, gap: gap) == 60)
|
||||
#expect(LaneReorderMath.centre(ofLaneAt: 1, unitCounts: uniform, standard: standard, gap: gap) == 170)
|
||||
#expect(LaneReorderMath.centre(ofLaneAt: 3, unitCounts: uniform, standard: standard, gap: gap) == 390)
|
||||
|
||||
// A 3× lane in the middle: slots at [10, 110), [120, 440), [450, 550).
|
||||
let mixed = [1, 3, 1]
|
||||
#expect(LaneReorderMath.centre(ofLaneAt: 1, unitCounts: mixed, standard: standard, gap: gap) == 280)
|
||||
#expect(LaneReorderMath.centre(ofLaneAt: 2, unitCounts: mixed, standard: standard, gap: gap) == 500)
|
||||
|
||||
// An index past the end yields the position the next slot would start at, rather than
|
||||
// trapping: the drag's lane can vanish between a render and a gesture callback.
|
||||
#expect(LaneReorderMath.centre(ofLaneAt: 9, unitCounts: mixed, standard: standard, gap: gap) == 560)
|
||||
}
|
||||
|
||||
// MARK: The proposal
|
||||
|
||||
@Test("A lane that has not moved proposes its own index")
|
||||
func restingDragProposesNoChange() {
|
||||
// Dragging lane 1 of four: with it removed the remaining centres are 60, 170, 280. Its own
|
||||
// resting centre is 170, which has passed exactly one of them.
|
||||
#expect(proposal([1, 1, 1, 1], dragging: 1, centre: 170) == 1)
|
||||
#expect(proposal([1, 1, 1, 1], dragging: 0, centre: 60) == 0)
|
||||
#expect(proposal([1, 1, 1, 1], dragging: 3, centre: 390) == 3)
|
||||
}
|
||||
|
||||
@Test("The proposal steps once the cursor passes a remaining lane's centre, and not before")
|
||||
func theThresholdIsTheNeighboursCentre() {
|
||||
// Dragging lane 0 out of four. Remaining slots are the other three, laid out from x = 10:
|
||||
// centres 60, 170, 280.
|
||||
#expect(proposal([1, 1, 1, 1], dragging: 0, centre: 59) == 0)
|
||||
#expect(proposal([1, 1, 1, 1], dragging: 0, centre: 60) == 0, "the boundary itself does not step — strictly past")
|
||||
#expect(proposal([1, 1, 1, 1], dragging: 0, centre: 61) == 1)
|
||||
#expect(proposal([1, 1, 1, 1], dragging: 0, centre: 171) == 2)
|
||||
#expect(proposal([1, 1, 1, 1], dragging: 0, centre: 281) == 3)
|
||||
}
|
||||
|
||||
@Test("A far drag in either direction clamps to the ends")
|
||||
func farDragsClampToTheEnds() {
|
||||
#expect(proposal([1, 1, 1, 1], dragging: 2, centre: -5000) == 0)
|
||||
#expect(proposal([1, 1, 1, 1], dragging: 2, centre: 5000) == 3, "the last index with the lane itself removed")
|
||||
#expect(proposal([1, 1, 1], dragging: 0, centre: 5000) == 2)
|
||||
}
|
||||
|
||||
@Test("Width-aware: a wide neighbour has to be crossed, not merely touched")
|
||||
func wideNeighboursDemandRealTravel() {
|
||||
// Lanes [1, 3, 1] with the 1× at index 0 dragged. The remaining pair is the 3× then the 1×:
|
||||
// slots [10, 330) and [340, 440), centres 170 and 390.
|
||||
//
|
||||
// "No reflow until the cursor reaches where the dragged lane would actually land": at 200 the
|
||||
// cursor is well inside the wide lane but has passed its centre, so the step is honest; at
|
||||
// 150 it has not, and proposing a swap there would reorder the board under a cursor still
|
||||
// sitting over the lane it started left of.
|
||||
#expect(proposal([1, 3, 1], dragging: 0, centre: 150) == 0)
|
||||
#expect(proposal([1, 3, 1], dragging: 0, centre: 200) == 1)
|
||||
#expect(proposal([1, 3, 1], dragging: 0, centre: 400) == 2)
|
||||
}
|
||||
|
||||
@Test("The proposal is monotone in the cursor — it never oscillates")
|
||||
func theProposalIsMonotone() {
|
||||
// One threshold per remaining slot, crossed once, is what makes the shadow stable rather
|
||||
// than jittery (04-interactions.md ▸ Drag and drop). Sweeping the whole strip must therefore
|
||||
// produce a non-decreasing sequence.
|
||||
let units = [2, 1, 3, 1, 2]
|
||||
var last = 0
|
||||
for x in stride(from: CGFloat(-200), through: 1200, by: 1) {
|
||||
let next = proposal(units, dragging: 2, centre: x)
|
||||
#expect(next >= last, "the proposal went backwards as the cursor moved right, at x = \(x)")
|
||||
last = next
|
||||
}
|
||||
#expect(last == units.count - 1)
|
||||
}
|
||||
|
||||
@Test("A single-lane board proposes the only index there is")
|
||||
func singleLaneBoard() {
|
||||
#expect(proposal([1], dragging: 0, centre: -900) == 0)
|
||||
#expect(proposal([1], dragging: 0, centre: 900) == 0)
|
||||
}
|
||||
|
||||
@Test("An out-of-range dragged index yields zero rather than trapping")
|
||||
func vanishedLaneDoesNotTrap() {
|
||||
// The lane vanished under the drag; the caller's release-with-no-valid-proposal rule cancels
|
||||
// anyway, so the only contract here is totality.
|
||||
#expect(proposal([1, 1], dragging: 7, centre: 100) == 0)
|
||||
#expect(proposal([], dragging: 0, centre: 100) == 0)
|
||||
}
|
||||
|
||||
// MARK: Applying a proposal
|
||||
|
||||
@Test("Reordering applies the proposal's own index convention")
|
||||
func reorderedAppliesTheConvention() {
|
||||
let lanes = ["a", "b", "c", "d"]
|
||||
|
||||
// `to` counts positions with the item already removed, which is what `proposedIndex`
|
||||
// returns — so `to == from` must be the identity.
|
||||
#expect(LaneReorderMath.reordered(lanes, from: 1, to: 1) == lanes)
|
||||
#expect(LaneReorderMath.reordered(lanes, from: 0, to: 0) == lanes)
|
||||
|
||||
#expect(LaneReorderMath.reordered(lanes, from: 0, to: 1) == ["b", "a", "c", "d"])
|
||||
#expect(LaneReorderMath.reordered(lanes, from: 0, to: 3) == ["b", "c", "d", "a"])
|
||||
#expect(LaneReorderMath.reordered(lanes, from: 3, to: 0) == ["d", "a", "b", "c"])
|
||||
#expect(LaneReorderMath.reordered(lanes, from: 2, to: 1) == ["a", "c", "b", "d"])
|
||||
}
|
||||
|
||||
@Test("Reordering is total: out-of-range indices clamp or pass through")
|
||||
func reorderedIsTotal() {
|
||||
let lanes = ["a", "b", "c"]
|
||||
#expect(LaneReorderMath.reordered(lanes, from: 9, to: 0) == lanes)
|
||||
#expect(LaneReorderMath.reordered(lanes, from: 0, to: 99) == ["b", "c", "a"])
|
||||
#expect(LaneReorderMath.reordered(lanes, from: 2, to: -5) == ["c", "a", "b"])
|
||||
}
|
||||
|
||||
@Test("A dragged lane parked over each slot in turn lands exactly there")
|
||||
func aRoundTripThroughEverySlot() {
|
||||
// The end-to-end claim the two halves compose into: park the dragged lane on top of a
|
||||
// sibling's resting centre and the proposal, applied, puts it in that sibling's place.
|
||||
let units = [1, 2, 1, 3]
|
||||
let lanes = ["a", "b", "c", "d"]
|
||||
let from = 0
|
||||
var remaining = units
|
||||
remaining.remove(at: from)
|
||||
|
||||
for slot in remaining.indices {
|
||||
let centre = LaneReorderMath.centre(ofLaneAt: slot, unitCounts: remaining, standard: standard, gap: gap)
|
||||
// A hair past the centre is what "passed it" means; sitting exactly on it holds.
|
||||
let landed = proposal(units, dragging: from, centre: centre + 1)
|
||||
#expect(landed == slot + 1)
|
||||
#expect(LaneReorderMath.reordered(lanes, from: from, to: landed).firstIndex(of: "a") == slot + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Kanban
|
||||
|
||||
/// 04-interactions.md's **⌘N target rule**, branch by branch.
|
||||
///
|
||||
/// The rule is written as a pure function precisely so it can be tested like one: every branch is a
|
||||
/// selection plus a snapshot in, a lane-and-anchor (or nothing) out — no menu, no window, no
|
||||
/// gesture. The board underneath is a real load off a real temp tree, because the rule reads
|
||||
/// `isDeleted` and card ordering and a hand-built `BoardModel` would let those drift from what the
|
||||
/// loader actually produces.
|
||||
|
||||
// MARK: - Fixtures
|
||||
|
||||
/// `WriterFixture`, `Ident` and `Item` live in `WriterTestSupport.swift`.
|
||||
|
||||
private func tombstoned(order: String, title: String) -> String {
|
||||
"""
|
||||
---
|
||||
schema: 1
|
||||
title: \(title)
|
||||
order: \(order)
|
||||
deleted: 2026-03-03T09:00:00Z
|
||||
---
|
||||
\(title) body.
|
||||
|
||||
"""
|
||||
}
|
||||
|
||||
/// Two live lanes (three cards between them) and one tombstoned lane, so every branch has something
|
||||
/// to point at and the trash side has a member of its own.
|
||||
@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, tombstoned(order: "3072", title: "Archive"))
|
||||
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)
|
||||
|
||||
private func resolve(
|
||||
_ snapshot: BoardModel,
|
||||
selection: ItemReferenceSet = .empty,
|
||||
lastActive: ItemID? = nil
|
||||
) -> NewCardTarget.Resolution? {
|
||||
NewCardTarget.resolve(selection: selection, lastActiveLaneID: lastActive, snapshot: snapshot)
|
||||
}
|
||||
|
||||
// MARK: - Tests
|
||||
|
||||
@MainActor
|
||||
@Suite("NewCardTarget ▸ the ⌘N target rule")
|
||||
struct NewCardTargetTests {
|
||||
|
||||
@Test("A sole selected card targets its own lane, immediately after it")
|
||||
func aSelectedCardAnchorsInItsLane() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
|
||||
|
||||
// "With a card selected, the new card is created in that card's lane, immediately after it
|
||||
// (paste-anchor consistency)."
|
||||
#expect(resolve(snapshot, selection: ItemReferenceSet(ids: [card1], liveness: .live))
|
||||
== NewCardTarget.Resolution(laneID: lane1, anchorCardID: card1))
|
||||
#expect(resolve(snapshot, selection: ItemReferenceSet(ids: [card3], liveness: .live))
|
||||
== NewCardTarget.Resolution(laneID: lane2, anchorCardID: card3))
|
||||
|
||||
// The last card in a lane is still an anchor here — "after the last card" and "at the
|
||||
// bottom" coincide, and it is the commit that notices (`BoardStore.insertionIndex`).
|
||||
#expect(resolve(snapshot, selection: ItemReferenceSet(ids: [card2], liveness: .live))
|
||||
== NewCardTarget.Resolution(laneID: lane1, anchorCardID: card2))
|
||||
}
|
||||
|
||||
@Test("A sole selected lane targets its bottom, with no anchor")
|
||||
func aSelectedLaneTargetsItsBottom() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
|
||||
|
||||
// "With a lane selected, appended at its bottom (Return consistency)."
|
||||
#expect(resolve(snapshot, selection: ItemReferenceSet(ids: [lane2], liveness: .live))
|
||||
== NewCardTarget.Resolution(laneID: lane2, anchorCardID: nil))
|
||||
}
|
||||
|
||||
@Test("Nothing selected falls to the last-active lane")
|
||||
func nothingSelectedUsesTheLastActiveLane() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
|
||||
|
||||
#expect(resolve(snapshot, lastActive: lane2)
|
||||
== NewCardTarget.Resolution(laneID: lane2, anchorCardID: nil))
|
||||
|
||||
// And the last resort when there is no memory to consult, or the lane it names is gone:
|
||||
// "falling back to the first lane".
|
||||
#expect(resolve(snapshot) == NewCardTarget.Resolution(laneID: lane1, anchorCardID: nil))
|
||||
#expect(resolve(snapshot, lastActive: ItemID(rawValue: Ident.indexless))
|
||||
== NewCardTarget.Resolution(laneID: lane1, anchorCardID: nil))
|
||||
// A *tombstoned* lane is not a target either — it renders nowhere, and the trash is never a
|
||||
// creation destination.
|
||||
#expect(resolve(snapshot, lastActive: lane3)
|
||||
== NewCardTarget.Resolution(laneID: lane1, anchorCardID: nil))
|
||||
}
|
||||
|
||||
@Test("A tombstoned selection never anchors creation — it behaves as nothing selected")
|
||||
func aTombstonedSelectionNeverAnchors() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
|
||||
|
||||
// Settled in 04 ▸ The map, on the ⌘N rule's own wording: "a **tombstoned** selection, which
|
||||
// never anchors creation". The trash-side lane is a real lane on disk with a live sibling
|
||||
// list — the rule must not let its identity leak in as a target.
|
||||
let trashed = ItemReferenceSet(ids: [lane3], liveness: .trashed)
|
||||
#expect(resolve(snapshot, selection: trashed, lastActive: lane2)
|
||||
== NewCardTarget.Resolution(laneID: lane2, anchorCardID: nil))
|
||||
#expect(resolve(snapshot, selection: trashed)
|
||||
== NewCardTarget.Resolution(laneID: lane1, anchorCardID: nil))
|
||||
}
|
||||
|
||||
@Test("A zero-lane board has no target at all — the menu item's disabled condition")
|
||||
func aZeroLaneBoardHasNoTarget() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.item("", Item.board)
|
||||
let empty = try BoardLoader.load(boardRoot: fixture.root).model
|
||||
|
||||
// "Zero-lane board (hand-made, or every lane deleted): card creation and card paste have no
|
||||
// target — New Card, Return-creation, and Paste with a card payload disable via menu
|
||||
// validation until a lane exists."
|
||||
#expect(resolve(empty) == nil)
|
||||
#expect(resolve(empty, selection: ItemReferenceSet(ids: [card1], liveness: .live)) == nil)
|
||||
#expect(resolve(empty, lastActive: lane1) == nil)
|
||||
}
|
||||
|
||||
@Test("A board whose every lane is tombstoned is a zero-lane board")
|
||||
func everyLaneTombstonedIsAlsoZeroLane() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.item("", Item.board)
|
||||
try fixture.item(Ident.lane1, tombstoned(order: "1024", title: "Todo"))
|
||||
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
|
||||
|
||||
// The lanes are still in the snapshot — the trash renders them — but none is on the board,
|
||||
// and "every lane deleted" is the design's own second reading of the zero-lane case.
|
||||
#expect(snapshot.lanes.count == 1)
|
||||
#expect(resolve(snapshot) == nil)
|
||||
}
|
||||
|
||||
@Test("A multi-selection and a stale one both fall through rather than guessing")
|
||||
func pluralAndStaleSelectionsFallThrough() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
|
||||
|
||||
// The rule speaks of "a card"/"a lane", singular; a multi-selection has no "it" to be
|
||||
// immediately after, so it gets the same answer as no selection at all.
|
||||
#expect(resolve(snapshot, selection: ItemReferenceSet(ids: [card1, card2], liveness: .live), lastActive: lane2)
|
||||
== NewCardTarget.Resolution(laneID: lane2, anchorCardID: nil))
|
||||
|
||||
// A selection naming something the board does not render — the reload that drops it has not
|
||||
// landed yet — must not refuse the creation the user just asked for.
|
||||
#expect(resolve(snapshot, selection: ItemReferenceSet(ids: [ItemID(rawValue: Ident.indexless)], liveness: .live))
|
||||
== NewCardTarget.Resolution(laneID: lane1, anchorCardID: nil))
|
||||
}
|
||||
}
|
||||
@@ -126,6 +126,45 @@ struct RanksTests {
|
||||
#expect(Ranks.insertAtHead(ofVisible: items) == 1024)
|
||||
}
|
||||
|
||||
// MARK: - Insertion at a display position
|
||||
|
||||
@Test func insertionRankDispatchesOnPosition() throws {
|
||||
let orders = [1024.0, 2048.0, 3072.0]
|
||||
|
||||
// The three cases every insertion gesture has, behind one call.
|
||||
#expect(Ranks.insertionRank(amongVisible: orders, at: 0) == 0, "head: min − 1024")
|
||||
#expect(Ranks.insertionRank(amongVisible: orders, at: 1) == 1536, "between: the midpoint")
|
||||
#expect(Ranks.insertionRank(amongVisible: orders, at: 2) == 2560)
|
||||
#expect(Ranks.insertionRank(amongVisible: orders, at: 3) == 4096, "end: max + 1024")
|
||||
|
||||
// The result is always strictly inside the gap it names, which is what makes the display
|
||||
// order the caller asked for the one it gets.
|
||||
let placed = try #require(Ranks.insertionRank(amongVisible: orders, at: 1))
|
||||
#expect(placed > orders[0] && placed < orders[1])
|
||||
}
|
||||
|
||||
@Test func insertionRankIsTotalOnEdgeInputs() {
|
||||
// An empty parent's first child lands at the board convention, whatever index is asked for.
|
||||
#expect(Ranks.insertionRank(amongVisible: [], at: 0) == 1024)
|
||||
#expect(Ranks.insertionRank(amongVisible: [], at: 7) == 1024)
|
||||
|
||||
// Out-of-range indices clamp to the two ends rather than trapping: an index arrives from a
|
||||
// drag's geometry, and geometry can outrun a snapshot.
|
||||
#expect(Ranks.insertionRank(amongVisible: [1024], at: -3) == 0)
|
||||
#expect(Ranks.insertionRank(amongVisible: [1024], at: 99) == 2048)
|
||||
}
|
||||
|
||||
@Test func insertionRankReportsAnExhaustedGapRatherThanInventingOne() {
|
||||
// `nil` is the renumber trigger, not a refusal — and it must fire for the duplicate-order
|
||||
// tie as well as for adjacent Doubles, since neither admits a rank between.
|
||||
#expect(Ranks.insertionRank(amongVisible: [1024, 1024], at: 1) == nil)
|
||||
#expect(Ranks.insertionRank(amongVisible: [1024, 1024.0000000000002], at: 1) == nil)
|
||||
|
||||
// The ends never exhaust: append and head-insert always have room.
|
||||
#expect(Ranks.insertionRank(amongVisible: [1024, 1024], at: 0) == 0)
|
||||
#expect(Ranks.insertionRank(amongVisible: [1024, 1024], at: 2) == 2048)
|
||||
}
|
||||
|
||||
// MARK: - Precision exhaustion → renumber, deterministically
|
||||
|
||||
@Test func precisionExhaustionThenRenumberIsDeterministic() {
|
||||
|
||||
@@ -262,6 +262,187 @@ struct TransientBoardStateTests {
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: The rename editor
|
||||
|
||||
@Test("The rename editor seeds from the current title and records what is typed")
|
||||
func renameEditorSeedsAndTracks() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
|
||||
store.transient.beginRename(of: card1, currentTitle: "First")
|
||||
#expect(store.transient.renameEditor == RenameEditor(targetID: card1, draftTitle: "First"))
|
||||
#expect(store.transient.isEditingInline)
|
||||
|
||||
store.transient.updateRenameDraft("First, renamed")
|
||||
#expect(store.transient.renameEditor?.draftTitle == "First, renamed")
|
||||
|
||||
// An untitled item seeds *empty*, never with the word the face renders: "Untitled" is a
|
||||
// rendering, not a value (03-board-ui.md § Card face), and typing it into the file would
|
||||
// turn a missing key into a real title.
|
||||
store.transient.beginRename(of: lane2, currentTitle: nil)
|
||||
#expect(store.transient.renameEditor?.draftTitle.isEmpty == true)
|
||||
|
||||
store.transient.discardRename()
|
||||
#expect(store.transient.renameEditor == nil)
|
||||
#expect(!store.transient.isEditingInline)
|
||||
}
|
||||
|
||||
@Test("One focus, one editor — beginning either kind ends the other")
|
||||
func theTwoEditorsAreMutuallyExclusive() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
|
||||
store.transient.beginPlaceholder(inLane: lane1)
|
||||
store.transient.updateDraft("half typed")
|
||||
store.transient.beginRename(of: card1, currentTitle: "First")
|
||||
|
||||
// The draft is discarded per the placeholder's own click-away rule — 02-architecture.md's
|
||||
// "starting a new creation while a placeholder is open is a click-away for the draft",
|
||||
// read in the other direction.
|
||||
#expect(store.transient.newCardPlaceholder == nil)
|
||||
#expect(store.transient.renameEditor?.targetID == card1)
|
||||
|
||||
store.transient.beginPlaceholder(inLane: lane2)
|
||||
#expect(store.transient.renameEditor == nil)
|
||||
#expect(store.transient.newCardPlaceholder?.laneID == lane2)
|
||||
}
|
||||
|
||||
@Test("A rename whose target is deleted from the tree is discarded")
|
||||
func renameDiscardedWhenItsTargetIsRemoved() 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("Never lands")
|
||||
|
||||
try FileManager.default.removeItem(at: fixture.url("\(Ident.lane1)/\(Ident.card1)"))
|
||||
await reload(store)
|
||||
|
||||
// "A target that is tombstoned, deleted, or gone at commit time discards the editor and its
|
||||
// keystrokes silently" (04-interactions.md ▸ Grammar).
|
||||
#expect(store.transient.renameEditor == nil)
|
||||
}
|
||||
|
||||
@Test("A rename whose target is tombstoned is discarded — a liveness flip is a vanish")
|
||||
func renameDiscardedWhenItsTargetIsTombstoned() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
|
||||
store.transient.beginRename(of: card1, currentTitle: "First")
|
||||
|
||||
try fixture.item("\(Ident.lane1)/\(Ident.card1)", tombstoned(order: "1024", title: "First"))
|
||||
await reload(store)
|
||||
|
||||
#expect(store.snapshot.lanes.first { $0.id == lane1 }?.cards.first { $0.id == card1 }?.isDeleted == true)
|
||||
#expect(store.transient.renameEditor == nil)
|
||||
}
|
||||
|
||||
@Test("A rename under a tombstoned lane is discarded too — liveness is effective")
|
||||
func renameDiscardedWhenItsLaneIsTombstoned() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
|
||||
store.transient.beginRename(of: card1, currentTitle: "First")
|
||||
|
||||
try fixture.item(Ident.lane1, tombstoned(order: "1024", title: "Todo"))
|
||||
await reload(store)
|
||||
|
||||
// The card's own flag never changed; its lane's did. The ancestor walk is absolute — the
|
||||
// card renders nowhere, so the editor sitting on it has no target
|
||||
// (`CardWindowHost.cardWindowFate`'s rule, applied to the third inline editor).
|
||||
let card = store.snapshot.lanes.first { $0.id == lane1 }?.cards.first { $0.id == card1 }
|
||||
#expect(card?.isDeleted == false)
|
||||
#expect(store.transient.renameEditor == nil)
|
||||
}
|
||||
|
||||
@Test("A rename survives a foreign move — the editor follows the UUID, not the position")
|
||||
func renameSurvivesAForeignMove() 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("Still mine")
|
||||
|
||||
// An agent files the card into the other lane mid-typing. "A foreign *move* mid-rename is
|
||||
// invisible — the editor follows the UUID and the commit writes the title wherever the card
|
||||
// now lives."
|
||||
try FileManager.default.moveItem(
|
||||
at: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
|
||||
to: fixture.url("\(Ident.lane2)/\(Ident.card1)")
|
||||
)
|
||||
await reload(store)
|
||||
|
||||
#expect(store.snapshot.lanes.first { $0.id == lane2 }?.cards.contains { $0.id == card1 } == true)
|
||||
#expect(store.transient.renameEditor == RenameEditor(targetID: card1, draftTitle: "Still mine"))
|
||||
}
|
||||
|
||||
@Test("A rename of a lane survives an unrelated reload, draft intact")
|
||||
func laneRenameSurvivesAnUnrelatedReload() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
|
||||
store.transient.beginRename(of: lane1, currentTitle: "Todo")
|
||||
store.transient.updateRenameDraft("To d")
|
||||
|
||||
try fixture.item("\(Ident.lane2)/\(Ident.card4)", Item.rich(order: "2048", title: "Filed by an agent"))
|
||||
await reload(store)
|
||||
|
||||
#expect(store.transient.renameEditor == RenameEditor(targetID: lane1, draftTitle: "To d"))
|
||||
}
|
||||
|
||||
// MARK: The last-active lane
|
||||
|
||||
@Test("Selecting a lane or one of its cards marks it active; clearing the selection does not forget it")
|
||||
func selectionMarksTheActiveLane() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
|
||||
#expect(store.transient.lastActiveLaneID == nil, "a fresh board has no history to remember")
|
||||
|
||||
store.select([lane2], liveness: .live)
|
||||
#expect(store.transient.lastActiveLaneID == lane2)
|
||||
|
||||
// A *card* selection is its lane holding selection too — 04's "the lane that most recently
|
||||
// held selection or a creation".
|
||||
store.select([card1], liveness: .live)
|
||||
#expect(store.transient.lastActiveLaneID == lane1)
|
||||
|
||||
// A cross-lane selection names no single lane, so it leaves the memory alone rather than
|
||||
// guessing at one of the two.
|
||||
store.select([card1, card3], liveness: .live)
|
||||
#expect(store.transient.lastActiveLaneID == lane1)
|
||||
|
||||
// Deselecting does not un-happen where the user was working: ⌘N with nothing selected is
|
||||
// exactly the case the memory exists to answer.
|
||||
store.clearSelection()
|
||||
#expect(store.transient.lastActiveLaneID == lane1)
|
||||
}
|
||||
|
||||
@Test("Creating into a lane marks it active, and a vanished lane is forgotten on reload")
|
||||
func creationMarksTheActiveLaneAndAVanishClearsIt() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
|
||||
store.transient.beginPlaceholder(inLane: lane2)
|
||||
#expect(store.transient.lastActiveLaneID == lane2)
|
||||
|
||||
try fixture.item(Ident.lane2, tombstoned(order: "2048", title: "Doing"))
|
||||
await reload(store)
|
||||
|
||||
// A lane that renders nowhere is no target at all; `NewCardTarget` then falls through to
|
||||
// the first lane rather than proposing the trash.
|
||||
#expect(store.transient.lastActiveLaneID == nil)
|
||||
}
|
||||
|
||||
// MARK: Per-open values
|
||||
|
||||
@Test("Trash visibility and the search query default per-open and pass through a reload untouched")
|
||||
|
||||
Reference in New Issue
Block a user