Phase 2 swaps every consumer: Liveness and its ancestor walk are gone, replaced by ItemContainer — a UUID set plus the container side it lives on, presence the whole test, one selection boundary instead of the old liveness law. Deletion stages by place: board cards move to the trash at a store-minted head rank, trash-side delete is permanent behind its confirmation, Delete Immediately skips the trash from anywhere, lane delete captures the subtree and removes the folder. Restore has no method at all — moveCards resolves members in either container, so drag-out and cut-paste are the ordinary moves 13 calls them, registering ordinary Move steps. The delete inverse moves the card back to its captured lane and rank; redo replays the captured trash rank, a value the gesture actually wrote; lane undo recreates the subtree byte-faithfully in session. Purges register nothing — where 13's trash section contradicts its own Rules on that, Rules wins, filed for ruling. Staleness collapsed to present-or-absent: a container is a path, so a foreign restore fails the delete step's expectation structurally. Legacy tombstones migrate on the loose-file tail hook, cards oldest-first so minting above top reproduces the retired newest-first column, lanes returning live, one folded loss row naming both directions. Put Back, restoreByDrag, receiveRestoredCards, TrashEntry, and the kind machinery are deleted; the trash column renders the container correctly with its full face rework left to phase 3. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
194 lines
9.2 KiB
Swift
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"))
|
|
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], container: .board))
|
|
== NewCardTarget.Resolution(laneID: lane1, anchorCardID: card1))
|
|
#expect(resolve(snapshot, selection: ItemReferenceSet(ids: [card3], container: .board))
|
|
== 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], container: .board))
|
|
== 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], container: .board))
|
|
== 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 lane that has been deleted is not a target either — a lane delete is physical, so the
|
|
// memory simply names nothing.
|
|
#expect(resolve(snapshot, lastActive: lane3)
|
|
== NewCardTarget.Resolution(laneID: lane1, anchorCardID: nil))
|
|
}
|
|
|
|
@Test("A trash selection never anchors creation — it behaves as nothing selected")
|
|
func aTrashSelectionNeverAnchors() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
|
|
|
|
// 04 ▸ The map, on the ⌘N rule's own wording: "with nothing selected — or a **trash**
|
|
// selection, which never anchors creation". A trashed card's board-side lane must not leak
|
|
// in as a target.
|
|
let inTrash = ItemReferenceSet(ids: [card1], container: .trash)
|
|
#expect(resolve(snapshot, selection: inTrash, lastActive: lane2)
|
|
== NewCardTarget.Resolution(laneID: lane2, anchorCardID: nil))
|
|
#expect(resolve(snapshot, selection: inTrash)
|
|
== 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], container: .board)) == nil)
|
|
#expect(resolve(empty, lastActive: lane1) == nil)
|
|
}
|
|
|
|
@Test("A board whose every lane has been deleted is a zero-lane board")
|
|
func everyLaneDeletedIsAlsoZeroLane() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
for lane in [Ident.lane1, Ident.lane2, Ident.lane3] where fixture.exists(lane) {
|
|
try FileManager.default.removeItem(at: fixture.url(lane))
|
|
}
|
|
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
|
|
|
|
// "Every lane deleted" is the design's own second reading of the zero-lane case — and a lane
|
|
// delete is physical now, so it is literally the same board as the hand-made one above.
|
|
#expect(snapshot.lanes.isEmpty)
|
|
#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], container: .board), 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], container: .board), 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], container: .board))
|
|
== 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)], container: .board))
|
|
== NewCardTarget.Resolution(laneID: lane1, anchorCardID: nil))
|
|
}
|
|
}
|