import CoreGraphics import Foundation import Testing @testable import Kanban /// 04-interactions.md § Selection's pointer grammar and § The trash's extensions to it, branch by /// branch — plus the anchor that makes ⇧-click mean anything, and the rubber band's arithmetic. /// /// The grammar is written as a pure function precisely so it can be tested like one: a click, a /// selection, an anchor and a snapshot in, a selection and an anchor out — no window, no gesture, no /// modifier flags. The boards underneath are **real loads off real temp trees**, because every rule /// here reads `isDeleted`, card ordering, or `TrashModel`'s sort, and a hand-built `BoardModel` /// would let all three drift from what the loader actually produces. /// /// `WriterFixture`, `Ident` and `Item` live in `WriterTestSupport.swift`. // MARK: - Fixtures /// More literal identities than `Ident` offers: a three-lane range needs five cards, and the trash's /// interleaving needs entries whose folder names are distinguishable in the sort's tie-break. private enum More { static let card5 = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" static let laneX = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" static let laneY = "cccccccc-cccc-4ccc-8ccc-cccccccccccc" } private func tombstoned(order: String, title: String, deleted: String = "2026-03-03T09:00:00Z") -> String { """ --- schema: 1 title: \(title) order: \(order) deleted: \(deleted) --- \(title) body. """ } /// Three live lanes, five live cards and one tombstoned card — enough that a flatten-order range /// crosses two lane boundaries and has something to *skip* on the way. /// /// Flatten order of the live cards is `[card1, card2, card3, card5]`; `card4` carries its own /// `deleted:` and is in none of it. @MainActor private func makeLiveBoard() 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.lane2)/\(Ident.card4)", tombstoned(order: "2048", title: "Fourth")) try fixture.item(Ident.lane3, Item.rich(order: "3072", title: "Done")) try fixture.item("\(Ident.lane3)/\(More.card5)", Item.rich(order: "1024", title: "Fifth")) return fixture } /// A trash whose two row kinds **interleave**, which is the only shape that can prove a range skips. /// /// `TrashModel`'s sort is newest `deleted` first, so the entry order is /// `[card1, laneX, card2, laneY, card3]` — a card range from `card1` to `card3` has two lane rows /// sitting inside its span, and a lane range from `laneX` to `laneY` has a card row inside its own. @MainActor private func makeTrashBoard() 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)", tombstoned(order: "1024", title: "First", deleted: "2026-03-05T10:00:00Z")) try fixture.item("\(Ident.lane1)/\(Ident.card2)", tombstoned(order: "2048", title: "Second", deleted: "2026-03-05T08:00:00Z")) try fixture.item("\(Ident.lane1)/\(Ident.card3)", tombstoned(order: "3072", title: "Third", deleted: "2026-03-05T06:00:00Z")) try fixture.item(More.laneX, tombstoned(order: "2048", title: "Archive", deleted: "2026-03-05T09:00:00Z")) try fixture.item(More.laneY, tombstoned(order: "3072", title: "Old", deleted: "2026-03-05T07:00:00Z")) 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 let card4 = ItemID(rawValue: Ident.card4) private let card5 = ItemID(rawValue: More.card5) private let laneX = ItemID(rawValue: More.laneX) private let laneY = ItemID(rawValue: More.laneY) private func load(_ fixture: WriterFixture) throws -> BoardModel { try BoardLoader.load(boardRoot: fixture.root).model } private func target(_ id: ItemID, _ kind: SelectionKind, _ side: Liveness = .live) -> SelectionTarget { SelectionTarget(id: id, kind: kind, side: side) } private func set(_ ids: Set, _ side: Liveness = .live) -> ItemReferenceSet { ItemReferenceSet(ids: ids, liveness: side) } /// One click, with the grammar's own defaults filled in. private func click( _ target: SelectionTarget, _ modifier: ClickModifier, selection: ItemReferenceSet = .empty, anchor: ItemID? = nil, in snapshot: BoardModel, togglesOnRepeat: Bool = false ) -> SelectionGrammar.Outcome { SelectionGrammar.click( target, modifier: modifier, selection: selection, anchor: anchor, snapshot: snapshot, togglesOnRepeat: togglesOnRepeat ) } // MARK: - The order lists @MainActor @Suite("SelectionGrammar ▸ order") struct SelectionOrderTests { @Test("Live cards flatten lane order first, then card order — tombstones excluded") func flattenOrder() throws { let fixture = try makeLiveBoard() defer { fixture.tearDown() } let snapshot = try load(fixture) // "Lane `order` first, then card `order` (a cross-lane selection flattens left-to-right, // top-to-bottom)" — the multi-drag order (04-interactions.md ▸ Drag and drop). #expect(SelectionGrammar.liveCards(in: snapshot) == [card1, card2, card3, card5]) #expect(SelectionGrammar.liveLanes(in: snapshot) == [lane1, lane2, lane3]) } @Test("Trash order lists are one sort, filtered to one kind") func trashOrderPerKind() throws { let fixture = try makeTrashBoard() defer { fixture.tearDown() } let snapshot = try load(fixture) // The single ordering the quasi-lane shows, newest first. #expect(TrashModel.entries(of: snapshot).map(\.id) == [card1, laneX, card2, laneY, card3]) // Each kind's list is that ordering with the other kind's rows dropped — which is exactly // what makes a ⇧-range step over them (04-interactions.md ▸ The trash). #expect(SelectionGrammar.trashEntries(of: .card, in: snapshot) == [card1, card2, card3]) #expect(SelectionGrammar.trashEntries(of: .lane, in: snapshot) == [laneX, laneY]) } @Test("A selection's kind is derived from the snapshot, and a ghost selection has none") func kindDerivation() throws { let fixture = try makeLiveBoard() defer { fixture.tearDown() } let snapshot = try load(fixture) #expect(SelectionGrammar.kind(of: set([card1, card3]), in: snapshot) == .card) #expect(SelectionGrammar.kind(of: set([lane2]), in: snapshot) == .lane) #expect(SelectionGrammar.kind(of: .empty, in: snapshot) == nil) // A tombstoned card is on neither side's live list, and the live side is what this set says. #expect(SelectionGrammar.kind(of: set([card4]), in: snapshot) == nil) // Members that name nothing are ignored; one that names something still answers. #expect(SelectionGrammar.kind(of: set([card4, card1]), in: snapshot) == .card) } } // MARK: - Plain @MainActor @Suite("SelectionGrammar ▸ plain click") struct PlainClickTests { @Test("A plain click replaces the selection and becomes the anchor") func replacesAndAnchors() throws { let fixture = try makeLiveBoard() defer { fixture.tearDown() } let snapshot = try load(fixture) let outcome = click(target(card3, .card), .plain, selection: set([card1, card2]), anchor: card1, in: snapshot) #expect(outcome.selection == set([card3])) #expect(outcome.anchor == card3) } @Test("Click again unselects — but only where the design gives that behaviour, and only on a sole selection") func toggleOffIsSoleMembershipOnly() throws { let fixture = try makeLiveBoard() defer { fixture.tearDown() } let snapshot = try load(fixture) // "Single click selects the lane (click again to unselect)", and the header "toggles like // empty space (settled)". let off = click(target(lane1, .lane), .plain, selection: set([lane1]), anchor: lane1, in: snapshot, togglesOnRepeat: true) #expect(off.selection.isEmpty) #expect(off.anchor == nil) // A multi-lane selection containing this lane is *narrowed*, not wiped: the toggle is about // the lane the user already had alone, not about a set they built with ⌘. let narrowed = click(target(lane1, .lane), .plain, selection: set([lane1, lane2]), anchor: lane2, in: snapshot, togglesOnRepeat: true) #expect(narrowed.selection == set([lane1])) #expect(narrowed.anchor == lane1) // A card face never toggles off — Finder does not deselect a file by clicking it twice. let card = click(target(card1, .card), .plain, selection: set([card1]), anchor: card1, in: snapshot) #expect(card.selection == set([card1])) #expect(card.anchor == card1) // Nor does the toggle reach across the boundary: a live click on a trashed sole selection // of the same id is a replace. let crossed = click(target(lane1, .lane), .plain, selection: set([lane1], .trashed), anchor: lane1, in: snapshot, togglesOnRepeat: true) #expect(crossed.selection == set([lane1])) } } // MARK: - Command @MainActor @Suite("SelectionGrammar ▸ ⌘-click") struct CommandClickTests { @Test("⌘-click toggles within one kind, and the click is the new anchor either way") func togglesWithinKind() throws { let fixture = try makeLiveBoard() defer { fixture.tearDown() } let snapshot = try load(fixture) let added = click(target(card3, .card), .command, selection: set([card1]), anchor: card1, in: snapshot) #expect(added.selection == set([card1, card3])) #expect(added.anchor == card3) let removed = click(target(card1, .card), .command, selection: set([card1, card3]), anchor: card3, in: snapshot) #expect(removed.selection == set([card3])) #expect(removed.anchor == card1) } @Test("Toggling the last member out leaves nothing selected and nothing to range from") func emptyingClearsTheAnchor() throws { let fixture = try makeLiveBoard() defer { fixture.tearDown() } let snapshot = try load(fixture) let outcome = click(target(card1, .card), .command, selection: set([card1]), anchor: card1, in: snapshot) #expect(outcome.selection.isEmpty) #expect(outcome.anchor == nil) } @Test("⌘-click across the kind boundary replaces — a selection is never mixed") func acrossKindReplaces() throws { let fixture = try makeLiveBoard() defer { fixture.tearDown() } let snapshot = try load(fixture) // "Selection is homogeneous: cards XOR lanes" (04-interactions.md § Selection). let ontoLane = click(target(lane2, .lane), .command, selection: set([card1, card2]), anchor: card2, in: snapshot) #expect(ontoLane.selection == set([lane2])) #expect(ontoLane.anchor == lane2) let ontoCard = click(target(card1, .card), .command, selection: set([lane1, lane2]), anchor: lane2, in: snapshot) #expect(ontoCard.selection == set([card1])) #expect(ontoCard.anchor == card1) } @Test("⌘-click across the liveness boundary replaces too") func acrossSideReplaces() throws { let fixture = try makeTrashBoard() defer { fixture.tearDown() } let snapshot = try load(fixture) // "Selection is homogeneous by liveness … a selection never mixes live and tombstoned" // (04 ▸ The trash). The live lane1 is the only live thing on this board. let intoTrash = click(target(card1, .card, .trashed), .command, selection: set([lane1]), anchor: lane1, in: snapshot) #expect(intoTrash.selection == set([card1], .trashed)) let backOut = click(target(lane1, .lane), .command, selection: set([card1, card2], .trashed), anchor: card2, in: snapshot) #expect(backOut.selection == set([lane1])) // And within the trash, the second axis: card entries XOR lane entries. let ontoLaneEntry = click(target(laneX, .lane, .trashed), .command, selection: set([card1, card2], .trashed), anchor: card2, in: snapshot) #expect(ontoLaneEntry.selection == set([laneX], .trashed)) #expect(ontoLaneEntry.anchor == laneX) } @Test("⌘-click with nothing — or nothing real — selected replaces") func emptyOrGhostSelectionReplaces() throws { let fixture = try makeLiveBoard() defer { fixture.tearDown() } let snapshot = try load(fixture) let fromEmpty = click(target(card1, .card), .command, in: snapshot) #expect(fromEmpty.selection == set([card1])) #expect(fromEmpty.anchor == card1) // A selection whose members all name nothing the board renders counts as empty: a ⌘-click // after a foreign delete starts a fresh set rather than extending a ghost. let fromGhost = click(target(card1, .card), .command, selection: set([card4]), anchor: card4, in: snapshot) #expect(fromGhost.selection == set([card1])) } } // MARK: - Shift @MainActor @Suite("SelectionGrammar ▸ ⇧-click") struct ShiftClickTests { @Test("A ⇧-range spans the flatten order across lanes, skipping tombstones, and leaves the anchor put") func rangeAcrossLanes() throws { let fixture = try makeLiveBoard() defer { fixture.tearDown() } let snapshot = try load(fixture) let outcome = click(target(card5, .card), .shift, selection: set([card1]), anchor: card1, in: snapshot) #expect(outcome.selection == set([card1, card2, card3, card5])) // Finder-list style: the anchor is unchanged, so successive ⇧-clicks sweep from one origin. #expect(outcome.anchor == card1) // card4 is tombstoned and in no order list, so no range can pick it up. #expect(!outcome.selection.ids.contains(card4)) } @Test("Direction does not matter — the range is the span between anchor and target") func rangeIsDirectionless() throws { let fixture = try makeLiveBoard() defer { fixture.tearDown() } let snapshot = try load(fixture) let backwards = click(target(card1, .card), .shift, selection: set([card5]), anchor: card5, in: snapshot) #expect(backwards.selection == set([card1, card2, card3, card5])) #expect(backwards.anchor == card5) } @Test("Lanes range in their own order list") func laneRange() throws { let fixture = try makeLiveBoard() defer { fixture.tearDown() } let snapshot = try load(fixture) let outcome = click(target(lane3, .lane), .shift, selection: set([lane1]), anchor: lane1, in: snapshot) #expect(outcome.selection == set([lane1, lane2, lane3])) #expect(outcome.anchor == lane1) } @Test("An invalid anchor makes ⇧ a plain click — nil, vanished, or across a boundary") func invalidAnchorActsPlain() throws { let fixture = try makeLiveBoard() defer { fixture.tearDown() } let snapshot = try load(fixture) // Nothing to range from. let noAnchor = click(target(card3, .card), .shift, selection: set([card1]), in: snapshot) #expect(noAnchor.selection == set([card3])) #expect(noAnchor.anchor == card3) // An anchor that names nothing the board renders (card4 is tombstoned). let vanished = click(target(card3, .card), .shift, selection: set([card1]), anchor: card4, in: snapshot) #expect(vanished.selection == set([card3])) #expect(vanished.anchor == card3) // An anchor in the *other* kind's list: the two lists are disjoint, so the anchor is not // findable and the click degrades — never a mixed range. let acrossKind = click(target(card3, .card), .shift, selection: set([lane1]), anchor: lane1, in: snapshot) #expect(acrossKind.selection == set([card3])) #expect(acrossKind.anchor == card3) // Same for a side crossing: the live card list holds no trash row. let acrossSide = click(target(card3, .card, .trashed), .shift, selection: set([card1]), anchor: card1, in: snapshot) #expect(acrossSide.selection == set([card3], .trashed)) } @Test("A trash card range steps over the lane entries inside its span") func trashCardRangeSkipsLaneEntries() throws { let fixture = try makeTrashBoard() defer { fixture.tearDown() } let snapshot = try load(fixture) // Sorted order is [card1, laneX, card2, laneY, card3]; a card range collects card rows only. let outcome = click(target(card3, .card, .trashed), .shift, selection: set([card1], .trashed), anchor: card1, in: snapshot) #expect(outcome.selection == set([card1, card2, card3], .trashed)) #expect(outcome.anchor == card1) } @Test("A trash lane range steps over the card entries inside its span") func trashLaneRangeSkipsCardEntries() throws { let fixture = try makeTrashBoard() defer { fixture.tearDown() } let snapshot = try load(fixture) let outcome = click(target(laneY, .lane, .trashed), .shift, selection: set([laneX], .trashed), anchor: laneX, in: snapshot) #expect(outcome.selection == set([laneX, laneY], .trashed)) #expect(outcome.anchor == laneX) } } // MARK: - The rubber band @Suite("MarqueeMath") struct MarqueeMathTests { private static func card(_ id: ItemID, _ y: CGFloat, side: Liveness = .live) -> MarqueeTarget { MarqueeTarget(id: id, kind: .card, side: side, frame: CGRect(x: 0, y: y, width: 100, height: 40)) } private static func laneEntry(_ id: ItemID, _ y: CGFloat) -> MarqueeTarget { MarqueeTarget(id: id, kind: .lane, side: .trashed, frame: CGRect(x: 0, y: y, width: 100, height: 40)) } @Test("On the live side the band takes intersecting cards, and only cards") func liveSideTakesCards() { let targets = [ Self.card(card1, 0), Self.card(card2, 100), // A lane registered by mistake is still never swept: "click-drag rubber-bands across // lanes" (04-interactions.md § Selection) — across, not over. MarqueeTarget(id: lane1, kind: .lane, side: .live, frame: CGRect(x: 0, y: 0, width: 200, height: 400)), // A trash row cannot be reached by a band that began on the board. Self.card(card3, 10, side: .trashed) ] let ids = MarqueeMath.selection(rect: CGRect(x: 0, y: 0, width: 50, height: 120), targets: targets, side: .live) #expect(ids == [card1, card2]) } @Test("On the trash side the topmost intersecting row's kind wins") func trashSideIsHomogeneousByKind() { // Interleaved rows, the trash's own shape: card, lane, card. let targets = [ Self.card(card1, 0, side: .trashed), Self.laneEntry(laneX, 50), Self.card(card2, 100, side: .trashed) ] let all = CGRect(x: 0, y: 0, width: 50, height: 200) // Begun on a card row: the lane row between the two cards is stepped over, exactly as a // ⇧-range does (04-interactions.md ▸ The trash). #expect(MarqueeMath.selection(rect: all, targets: targets, side: .trashed) == [card1, card2]) // Begun below it, so the lane row is topmost: only lane entries come back. let lower = CGRect(x: 0, y: 60, width: 50, height: 200) #expect(MarqueeMath.selection(rect: lower, targets: targets, side: .trashed) == [laneX]) } @Test("A band touching nothing selects nothing") func emptyBand() { let targets = [Self.card(card1, 0), Self.card(card2, 100)] #expect(MarqueeMath.selection(rect: CGRect(x: 500, y: 500, width: 10, height: 10), targets: targets, side: .live).isEmpty) #expect(MarqueeMath.selection(rect: .zero, targets: [], side: .trashed).isEmpty) } } // MARK: - The anchor's storage and its reload rule /// One foreign reload, start to settled — `TransientBoardStateTests`' helper, borrowed for the one /// piece of transient state this card adds. @MainActor private func reload(_ store: BoardStore) async { store.handleWatcherEvent(.treeChanged(.foreign)) await store.awaitQuiescence() } @MainActor @Suite("TransientBoardState ▸ the selection anchor") struct SelectionAnchorTests { @Test("A sole selection anchors itself; a wholesale one does not; clearing drops it") func anchorDefaults() { let state = TransientBoardState() state.select([card1], liveness: .live) #expect(state.selectionAnchor == card1) // "A marquee and wholesale selections pass no anchor deliberately." state.select([card1, card2], liveness: .live) #expect(state.selectionAnchor == nil) // An explicit anchor wins over the default in both directions. state.select([card1, card2, card3], liveness: .live, anchor: card2) #expect(state.selectionAnchor == card2) state.clearSelection() #expect(state.selectionAnchor == nil) } @Test("A vanished anchor is dropped by the reload, and a surviving one is kept") func vanishedAnchorIsDropped() async throws { let fixture = try makeLiveBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) store.select([card1, card2], liveness: .live, anchor: card1) // A survivor of the same reload proves the rule is about the anchor, not about reloading. try FileManager.default.removeItem(at: fixture.url("\(Ident.lane1)/\(Ident.card2)")) await reload(store) #expect(store.selection.ids == [card1]) #expect(store.transient.selectionAnchor == card1) try FileManager.default.removeItem(at: fixture.url("\(Ident.lane1)/\(Ident.card1)")) await reload(store) #expect(store.selection.isEmpty) #expect(store.transient.selectionAnchor == nil) } @Test("A liveness flip is a vanish for the anchor too") func flippedAnchorIsDropped() async throws { let fixture = try makeLiveBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) store.select([card1, card2], liveness: .live, anchor: card1) try fixture.item("\(Ident.lane1)/\(Ident.card1)", tombstoned(order: "1024", title: "First")) await reload(store) // "A flip is a vanish from its side of the boundary" (02-architecture.md's reload rule). #expect(store.selection.ids == [card2]) #expect(store.transient.selectionAnchor == nil) } @Test("An anchor no longer in the selection still ranges — membership is not the rule") func anchorNeedNotBeSelected() throws { let fixture = try makeLiveBoard() defer { fixture.tearDown() } let snapshot = try load(fixture) let state = TransientBoardState() // A ⌘-click that toggled the anchor's own row out leaves the anchor standing. state.select([card2, card3], liveness: .live, anchor: card1) #expect(state.selectionAnchor == card1) let outcome = click(target(card3, .card), .shift, selection: state.selection, anchor: state.selectionAnchor, in: snapshot) #expect(outcome.selection == set([card1, card2, card3])) } } // MARK: - Select All @MainActor @Suite("BoardStore ▸ Select All") struct SelectAllTests { @Test("Select All takes every rendered card — tombstones excluded, lanes never") func liveBranch() throws { let fixture = try makeLiveBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) store.select([lane2], liveness: .live) store.selectAll() // "All visible cards on the board" (04-interactions.md ▸ The map). #expect(store.selection == set([card1, card2, card3, card5])) // The lane the anchor named is not in the new set, so the anchor goes with it. #expect(store.transient.selectionAnchor == nil) } @Test("An anchor inside the new set survives Select All") func anchorSurvivesWhenStillInside() throws { let fixture = try makeLiveBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) store.select([card3], liveness: .live) store.selectAll() #expect(store.transient.selectionAnchor == card3) } @Test("On the trash side Select All stays within the selection's kind") func trashBranchIsHomogeneousByKind() throws { let fixture = try makeTrashBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) store.transient.isTrashVisible = true store.select([card2], liveness: .trashed) store.selectAll() #expect(store.selection == set([card1, card2, card3], .trashed)) store.select([laneX], liveness: .trashed) store.selectAll() #expect(store.selection == set([laneX, laneY], .trashed)) } @Test("The trash branch is narrow: hidden, live, empty, or ghost selections take the board") func trashBranchFallsThrough() throws { let fixture = try makeTrashBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) // Hidden — the column is invisible to every gesture (04 ▸ The trash). store.select([card1], liveness: .trashed) store.selectAll() #expect(store.selection.liveness == .live) // Shown, but nothing tombstoned is selected. store.transient.isTrashVisible = true store.clearSelection() store.selectAll() #expect(store.selection.liveness == .live) // Shown, trashed side, but the ids name no row: a guess would be worse than the board. store.select([card5], liveness: .trashed) store.selectAll() #expect(store.selection.liveness == .live) } @Test("Select All on a board with no rendered cards clears rather than selecting an empty set") func emptyBoardClears() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try fixture.item("", Item.board) try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) let store = try BoardStore(rootURL: fixture.root) store.select([lane1], liveness: .live) store.selectAll() #expect(store.selection.isEmpty) #expect(store.transient.selectionAnchor == nil) } }