Files
lanework/KanbanTests/NewCardTargetTests.swift
T
rzen 3be38fcb47 Realign the Cmd-N target rule — a multi-selection anchors at its last member
The corpus just ratified the plural case: a multi-selection anchors
at its last member in flatten order (lane order, then card order —
the multi-drag order, the same anchor paste will use), so creation
follows the last selected card or appends to the last selected lane
instead of falling through to the last-active lane. One display-order
walk keeps the resolver pure; the sole selection is now just the
degenerate one-member case.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-27 15:38:34 -04:00

194 lines
9.2 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 anchors at its last member in flatten order")
func pluralSelectionsAnchorAtTheirLastMember() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
// "A multi-selection anchors at its last member in flatten order (lane `order`, then card
// `order`, the multi-drag order)": card3 lives in lane2, which sorts after lane1's pair, so
// creation follows card3 — the last-active lane never enters into it.
#expect(resolve(snapshot, selection: ItemReferenceSet(ids: [card1, card3], liveness: .live), lastActive: lane1)
== NewCardTarget.Resolution(laneID: lane2, anchorCardID: card3))
// Within one lane the flatten order is card order: card2 sorts after card1.
#expect(resolve(snapshot, selection: ItemReferenceSet(ids: [card1, card2], liveness: .live), lastActive: lane2)
== NewCardTarget.Resolution(laneID: lane1, anchorCardID: card2))
// A multi-LANE selection appends to the last selected lane's bottom.
#expect(resolve(snapshot, selection: ItemReferenceSet(ids: [lane1, lane2], liveness: .live))
== NewCardTarget.Resolution(laneID: lane2, anchorCardID: nil))
}
@Test("A stale selection falls through rather than guessing or refusing")
func staleSelectionsFallThrough() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
// 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))
}
}