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)) } }