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
178 lines
8.3 KiB
Swift
178 lines
8.3 KiB
Swift
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))
|
|
}
|
|
}
|