import CoreGraphics import Foundation import Testing @testable import Kanban /// The keyboard grammar's pure halves — 04-interactions.md ▸ Grammar's spatial navigation /// (`NavigationMath`), ▸ The map's within-lane sort (`SortMath`) and its successor-on-delete rule /// (`SelectionGrammar.successor`), plus the navigation head the arrows step from /// (`TransientBoardState.selectionHead`). /// /// The arrow *handlers* are deliberately absent: they are dispatch over these functions and a /// registry of drawn frames, so everything with a rule in it is here and the views hold nothing that /// could be asserted without a window. /// /// The board-level suites drive a **real `BoardStore` over a real temp tree** and read the result /// back off disk, the write suites' rule — a sort is only correct if the bytes say so. /// `WriterFixture`, `Ident` and `Item` live in `WriterTestSupport.swift`. // MARK: - Identities /// Two more card identities than `Ident` offers: the successor rule needs a *second* multi-card lane /// to prove it reads the last selected member's lane rather than the first's. private enum More { static let card5 = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" static let card6 = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" } 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 card6 = ItemID(rawValue: More.card6) /// Four cards in one lane, two in the next, and an empty third — the shapes every rule below needs: /// a block with room on both sides, a second container to be redirected into, and a lane that /// contributes nothing to card navigation. @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.lane1)/\(Ident.card3)", Item.rich(order: "3072", title: "Third")) try fixture.item("\(Ident.lane1)/\(Ident.card4)", Item.rich(order: "4096", title: "Fourth")) try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing")) try fixture.item("\(Ident.lane2)/\(More.card5)", Item.rich(order: "1024", title: "Fifth")) try fixture.item("\(Ident.lane2)/\(More.card6)", Item.rich(order: "2048", title: "Sixth")) try fixture.item(Ident.lane3, Item.rich(order: "3072", title: "Done")) return fixture } private func load(_ fixture: WriterFixture) throws -> BoardModel { try BoardLoader.load(boardRoot: fixture.root).model } /// The ids a lane renders, top to bottom, as they are **on disk right now**. private func cardOrder(_ laneID: String, in fixture: WriterFixture) throws -> [ItemID] { let model = try load(fixture) let lane = try #require(model.lanes.first { $0.id.rawValue == laneID }) return lane.cards.filter { !$0.isDeleted }.map(\.id) } // MARK: - Frames /// A drawn frame, with the two axes the score reads spelled out at the call site. private func target( _ id: ItemID, x: CGFloat, y: CGFloat, width: CGFloat = 100, height: CGFloat = 100, kind: SelectionKind = .card, container: ItemContainer = .board ) -> MarqueeTarget { MarqueeTarget(id: id, kind: kind, container: container, frame: CGRect(x: x, y: y, width: width, height: height)) } /// A two-by-two grid: `card1` `card3` on the top row, `card2` `card4` beneath them — the smallest /// board shape with an interior column *and* a lane boundary to cross. private let grid: [MarqueeTarget] = [ target(card1, x: 0, y: 0), target(card2, x: 0, y: 120), target(card3, x: 120, y: 0), target(card4, x: 120, y: 120) ] private let originFrame = CGRect(x: 0, y: 0, width: 100, height: 100) // MARK: - NavigationMath @Suite("NavigationMath ▸ nearest in the direction") struct NavigationMathTests { @Test("Each direction picks its own neighbour") func fourDirections() { #expect(NavigationMath.nearest(from: grid[0].frame, direction: .down, among: grid) == card2) #expect(NavigationMath.nearest(from: grid[1].frame, direction: .up, among: grid) == card1) #expect(NavigationMath.nearest(from: grid[0].frame, direction: .right, among: grid) == card3) #expect(NavigationMath.nearest(from: grid[2].frame, direction: .left, among: grid) == card1) } @Test("A card straight ahead beats a nearer one off to the side — orthogonal drift costs double") func orthogonalDriftIsPenalised() { // Straight down at 100pt of primary distance (score 100) versus 40pt down but 200pt across // (score 40 + 400). Without the penalty the second would win and ↓ would wander out of the // column instead of walking it (04-interactions.md ▸ Grammar). let straight = target(card2, x: 0, y: 100) let sideways = target(card3, x: 400, y: 40) #expect(NavigationMath.nearest(from: originFrame, direction: .down, among: [straight, sideways]) == card2) #expect(NavigationMath.nearest(from: originFrame, direction: .down, among: [sideways, straight]) == card2) } @Test("A tie is broken by position, then identity — and the input order never decides") func tiesAreDeterministic() { // Both score 50 + 2 × 50: same primary distance, same drift, opposite sides. let right = target(card2, x: 50, y: 50) let left = target(card3, x: -50, y: 50) #expect(NavigationMath.nearest(from: originFrame, direction: .down, among: [right, left]) == card3) #expect( NavigationMath.nearest(from: originFrame, direction: .down, among: [left, right]) == card3, "reversing the candidate list must not change the answer" ) // Same frame twice: position cannot separate them, so identity does. let low = target(ItemID(rawValue: "aaaa"), x: 0, y: 200) let high = target(ItemID(rawValue: "zzzz"), x: 0, y: 200) #expect(NavigationMath.nearest(from: originFrame, direction: .down, among: [high, low])?.rawValue == "aaaa") #expect(NavigationMath.nearest(from: originFrame, direction: .down, among: [low, high])?.rawValue == "aaaa") } @Test("Nothing beyond the origin in that direction is nil, and the origin never picks itself") func noCandidate() { #expect(NavigationMath.nearest(from: grid[0].frame, direction: .up, among: grid) == nil) #expect(NavigationMath.nearest(from: grid[0].frame, direction: .left, among: grid) == nil) #expect(NavigationMath.nearest(from: originFrame, direction: .down, among: []) == nil) // A candidate level with the origin is not beyond it: the 1pt threshold excludes the origin // itself and its exact row-mates. #expect(NavigationMath.nearest(from: originFrame, direction: .down, among: [target(card2, x: 300, y: 0)]) == nil) } @Test("The predicate is the ⇧-arrow's restriction — the trash side is simply not a candidate") func predicateRestrictsCandidates() { let trashed = target(card2, x: 0, y: 100, container: .trash) let live = target(card3, x: 0, y: 400) let all = [trashed, live] #expect( NavigationMath.nearest(from: originFrame, direction: .down, among: all) == card2, "a plain arrow walks across the boundary" ) #expect( NavigationMath.nearest(from: originFrame, direction: .down, among: all, where: { $0.container == .board }) == card3 ) } } // MARK: - SortMath @Suite("SortMath ▸ within-lane sort") struct SortMathTests { private let ordered = [card1, card2, card3, card4] @Test("A contiguous block steps one position, hopping its neighbour") func stepsOnePosition() { #expect(SortMath.reordered(ordered, moving: [card3], .up) == [card1, card3, card2, card4]) #expect(SortMath.reordered(ordered, moving: [card2], .down) == [card1, card3, card2, card4]) #expect(SortMath.reordered(ordered, moving: [card2, card3], .up) == [card2, card3, card1, card4]) #expect(SortMath.reordered(ordered, moving: [card2, card3], .down) == [card1, card4, card2, card3]) } @Test("A non-contiguous selection gathers behind its first card, relative order preserved") func gathersOnTheFirstPress() { // "Anchored at the first selected card (first = lowest logical order; the rest follow in // preserved relative order)" — and the press that gathers does not also step, which is why // both directions give the same answer. #expect(SortMath.reordered(ordered, moving: [card2, card4], .up) == [card1, card2, card4, card3]) #expect(SortMath.reordered(ordered, moving: [card2, card4], .down) == [card1, card2, card4, card3]) #expect(SortMath.reordered(ordered, moving: [card1, card3], .up) == [card1, card3, card2, card4]) #expect( SortMath.reordered(ordered, moving: [card1, card4], .down) == [card1, card4, card2, card3], "the unselected cards keep their relative order around the block" ) } @Test("At the ladder's end, and with nothing to move, the answer is nil rather than a no-op write") func edgesAndEmptyAreNil() { #expect(SortMath.reordered(ordered, moving: [card1], .up) == nil) #expect(SortMath.reordered(ordered, moving: [card4], .down) == nil) #expect(SortMath.reordered(ordered, moving: [card1, card2], .up) == nil) #expect(SortMath.reordered(ordered, moving: Set(ordered), .up) == nil) #expect(SortMath.reordered(ordered, moving: Set(ordered), .down) == nil) #expect(SortMath.reordered(ordered, moving: [], .up) == nil) #expect(SortMath.reordered([card1], moving: [card1], .down) == nil, "a lane of one has nowhere to go") #expect( SortMath.reordered(ordered, moving: [card5], .up) == nil, "ids the lane does not render are ignored, so a stale selection moves nothing" ) } } // MARK: - The successor rule @MainActor @Suite("SelectionGrammar ▸ successor on delete") struct SuccessorTests { @Test("The next card in the lane, so repeated ⌫ walks down it") func nextCardInTheLane() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let snapshot = try load(fixture) #expect(SelectionGrammar.successor(afterDeleting: [card2], snapshot: snapshot) == card3) #expect(SelectionGrammar.successor(afterDeleting: [card1], snapshot: snapshot) == card2) #expect( SelectionGrammar.successor(afterDeleting: [card1, card2], snapshot: snapshot) == card3, "a block's successor is the first survivor after its last member" ) } @Test("The last sibling falls back to its predecessor") func predecessorFallback() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let snapshot = try load(fixture) #expect(SelectionGrammar.successor(afterDeleting: [card4], snapshot: snapshot) == card3) #expect(SelectionGrammar.successor(afterDeleting: [card3, card4], snapshot: snapshot) == card2) } @Test("A survivor between the members is found forwards first") func forwardSearchWinsOverBackward() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let snapshot = try load(fixture) // Doomed at positions 0 and 2: forward from the last one finds card4, which is what makes // repeated ⌫ keep moving down rather than bouncing back up the lane. #expect(SelectionGrammar.successor(afterDeleting: [card1, card3], snapshot: snapshot) == card4) } @Test("An emptied container selects nothing") func emptiedContainerIsNil() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let snapshot = try load(fixture) #expect(SelectionGrammar.successor(afterDeleting: [card1, card2, card3, card4], snapshot: snapshot) == nil) #expect(SelectionGrammar.successor(afterDeleting: [], snapshot: snapshot) == nil) #expect( SelectionGrammar.successor(afterDeleting: [ItemID(rawValue: "nobody")], snapshot: snapshot) == nil, "ids naming nothing name no container either" ) } @Test("A cross-lane selection is answered in its last member's lane, in flatten order") func crossLaneUsesTheLastMembersLane() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let snapshot = try load(fixture) // card5 is later than card2 in flatten order (lane `order`, then card `order`), so the // container is lane2 — the same "last member" anchor ⌘N and paste already share. #expect(SelectionGrammar.successor(afterDeleting: [card2, card5], snapshot: snapshot) == card6) } @Test("Lanes follow the same rule in the live lane order") func laneSuccessors() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let snapshot = try load(fixture) #expect(SelectionGrammar.successor(afterDeleting: [lane1], snapshot: snapshot) == lane2) #expect(SelectionGrammar.successor(afterDeleting: [lane3], snapshot: snapshot) == lane2, "the last lane's predecessor") #expect(SelectionGrammar.successor(afterDeleting: [lane1, lane2, lane3], snapshot: snapshot) == nil) } @Test("⌫ selects the successor immediately, before the reload echoes the tombstone back") func deleteSelectsTheSuccessor() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) store.select([card2], in: .board) store.deleteSelection() #expect(store.selection.ids == [card3]) #expect(store.transient.selectionAnchor == card3, "the successor is a legitimate range origin") #expect(store.transient.selectionHead == card3, "and the place the next arrow steps from") // Repeated ⌫ walks down the lane — the whole point of the rule. The store's snapshot has not // reloaded, so card2 is still in it and card3's successor is card4. store.deleteSelection() #expect(store.selection.ids == [card4]) } @Test("An emptied lane clears the selection instead of inventing one") func deleteClearsWhenNothingSurvives() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) store.select([card5, card6], in: .board) store.deleteSelection() #expect(store.selection.isEmpty) #expect(store.transient.selectionHead == nil) } } // MARK: - The navigation head @MainActor @Suite("TransientBoardState ▸ the navigation head") struct SelectionHeadTests { @Test("A sole member is its own head; any other count leaves none") func headDefaults() throws { let state = TransientBoardState() state.select([card1], in: .board) #expect(state.selectionHead == card1) state.select([card1, card2], in: .board) #expect(state.selectionHead == nil, "a set with no gesture behind it names no cursor") state.select([card1, card2], in: .board, anchor: card1, head: card2) #expect(state.selectionAnchor == card1) #expect(state.selectionHead == card2, "an explicit head is kept whatever the count") state.clearSelection() #expect(state.selectionHead == nil) #expect(state.selectionAnchor == nil) } @Test("A ⇧-gesture moves the head and leaves the anchor — that asymmetry is why both exist") func shiftMovesOnlyTheHead() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let snapshot = try load(fixture) let outcome = SelectionGrammar.click( SelectionTarget(id: card3, kind: .card, container: .board), modifier: .shift, selection: ItemReferenceSet(ids: [card1], container: .board), anchor: card1, snapshot: snapshot ) #expect(outcome.selection.ids == [card1, card2, card3]) #expect(outcome.anchor == card1, "the range origin stays put") #expect(outcome.head == card3, "the cursor walks to what was clicked") } @Test("A vanished head is dropped by the reload, like every other item reference") func resolveDropsAVanishedHead() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) store.select([card1, card2], in: .board, anchor: card1, head: card2) try FileManager.default.removeItem(at: fixture.url("\(Ident.lane1)/\(Ident.card2)")) store.handleWatcherEvent(.treeChanged(.foreign)) await store.awaitQuiescence() #expect(store.selection.ids == [card1]) #expect(store.transient.selectionHead == nil) #expect(store.transient.selectionAnchor == card1, "the anchor survived — it is still in the tree") } @Test("A container crossing is a vanish for the head too") func resolveDropsAContainerCrossedHead() async throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) store.select([card1], in: .board) #expect(store.transient.selectionHead == card1) try fixture.move("\(Ident.lane1)/\(Ident.card1)", toTrash: Ident.card1) store.handleWatcherEvent(.treeChanged(.foreign)) await store.awaitQuiescence() #expect(store.selection.isEmpty) #expect(store.transient.selectionHead == nil) } } // MARK: - The sort's write @MainActor @Suite("BoardStore ▸ sortSelection") struct SortWriteTests { @Test("A step rewrites the two cards that swapped and nothing else") func stepWritesTheMinimum() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let untouchedFirst = try fixture.indexText("\(Ident.lane1)/\(Ident.card1)") let untouchedFourth = try fixture.indexText("\(Ident.lane1)/\(Ident.card4)") store.select([card3], in: .board) store.sortSelection(.up) #expect(try cardOrder(Ident.lane1, in: fixture) == [card1, card3, card2, card4]) #expect( try fixture.indexText("\(Ident.lane1)/\(Ident.card1)") == untouchedFirst, "a card whose position did not change keeps its bytes — no stamp, no commit" ) #expect(try fixture.indexText("\(Ident.lane1)/\(Ident.card4)") == untouchedFourth) #expect(store.selection.ids == [card3], "the ids all survive, so the selection is left alone") #expect(store.banners.oneShots.isEmpty) } @Test("A gather collects the block behind its first card, on disk") func gatherWrites() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) store.select([card2, card4], in: .board) store.sortSelection(.up) #expect(try cardOrder(Ident.lane1, in: fixture) == [card1, card2, card4, card3]) } @Test("A step down moves the block past its following sibling") func stepDownWrites() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) store.select([card1, card2], in: .board) store.sortSelection(.down) #expect(try cardOrder(Ident.lane1, in: fixture) == [card3, card1, card2, card4]) } @Test("Duplicate ranks are compacted first, because a permutation cannot outrank a name tie-break") func duplicateOrdersRenumberFirst() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try fixture.item("", Item.board) try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) // Two cards sharing one rank: display order falls to the folder-name tie-break // (`Ranks.isOrderedForDisplay`), which card1's `5555…` wins over card2's `6666…`. try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First")) try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "1024", title: "Second")) let store = try BoardStore(rootURL: fixture.root) #expect(try cardOrder(Ident.lane1, in: fixture) == [card1, card2]) store.select([card2], in: .board) store.sortSelection(.up) #expect(try cardOrder(Ident.lane1, in: fixture) == [card2, card1]) #expect(store.banners.oneShots.isEmpty) } @Test("The plan refuses every case the design calls inert") func planRefusals() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) #expect(store.sortPlan(.up) == nil, "nothing selected") store.select([lane1], in: .board) #expect(store.sortPlan(.up) == nil, "a lane selection — ⌥⌘↑/⌥⌘↓ are inert on lanes") store.select([card2, card5], in: .board) #expect(store.sortPlan(.up) == nil, "a card selection spanning lanes — cards never change lanes by ⌘-arrow") store.select([card1], in: .trash) #expect(store.sortPlan(.up) == nil, "a tombstoned selection") store.select([card1], in: .board) #expect(store.sortPlan(.up) == nil, "already at the top") #expect(store.sortPlan(.down) != nil, "but the other direction is live") } } // MARK: - The lane move's index convention @MainActor @Suite("BoardStore ▸ moveLane's one-slot convention") struct MoveLaneConventionTests { /// The index `MoveLaneCommands` passes: the lane's display position among the live lanes, plus /// or minus one. `moveLane` counts that position **with the moved lane already removed**, which /// is exactly what makes `from ± 1` one slot — and is easy enough to get backwards that it is /// pinned here rather than left to the drag path's coverage. @Test("from − 1 moves one slot left, from + 1 moves one slot right") func oneSlotEachWay() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let lanes = SelectionGrammar.lanes(in: store.snapshot) #expect(lanes == [lane1, lane2, lane3]) let from = try #require(lanes.firstIndex(of: lane2)) store.moveLane(lane2, toIndex: from - 1) #expect(try load(fixture).lanes.map(\.id) == [lane2, lane1, lane3]) } @Test("A step right hops exactly one lane, never to the end") func stepRightHopsOne() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let lanes = SelectionGrammar.lanes(in: store.snapshot) let from = try #require(lanes.firstIndex(of: lane1)) store.moveLane(lane1, toIndex: from + 1) #expect(try load(fixture).lanes.map(\.id) == [lane2, lane1, lane3]) } }