Phase 2 completes the lanes-in-trash card. TrashEntry merges the trash's two kinds by rank in exactly ONE place (ItemPath.resolve's own merge deleted in favor of it — the three-merge-points finding shrinks instead of growing). TrashLaneRowView renders the opaque row — tertiary plate, level-default lane glyph never the lane's own icon, title + card count, no accents, no expansion; the column badge counts rendered rows. Selection grammar: kind-homogeneous trash selections — ranges skip the other kind, ⇧-extension stops at the kind boundary, plain arrows walk the merged order, marquee stays card-only (now load-bearing: rows register frames for arrows), Select All card-scoped; successor-on-purge crosses kinds like navigation as the interim for open Gap 7b5cbc90. Drag: TrashDrop accepts lane sessions (drop on shown trash deletes), restoreLanes routes a trash-sourced strip drop as an arrival-ranked within-board move with an undo step. Clipboard: ⌘X/⌘V lane restore via opaque lane subjects; fixed boardRoot(ofLaneFolder:) returning .trash as the root — a same-board restore looked like an import and would have reminted the lane it was restoring (pinned by test). A11y: row = one flattened "title, deleted lane, N cards" element with Delete/Reveal actions; BoardDiff crossings read lanes as deleted/restored, shown-trash churn digested at row level. Agent guide stays v7 — the literal already teaches lanes-trash-by-move and kind stamping; drift-guard pins those lines. README trash paragraph notes lanes. Both schemes 1893 tests / 322 suites green. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
558 lines
25 KiB
Swift
558 lines
25 KiB
Swift
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
|
||
)
|
||
}
|
||
|
||
/// **Inside the trash, plain arrows walk every row and extension stops at the kind boundary**
|
||
/// (04-interactions.md ▸ The trash: "plain arrows walk across, extension stops"), which is the two
|
||
/// halves of the arrow handler expressed over one registry of drawn frames — a lane row registers
|
||
/// like a card face precisely so ↓ can reach it.
|
||
@Test("A trash lane row is a plain arrow's neighbour, and an extension's dead end")
|
||
func trashLaneRowsAreNavigableButNotExtendable() {
|
||
let origin = target(card1, x: 0, y: 0, container: .trash)
|
||
let row = target(lane2, x: 0, y: 120, kind: .lane, container: .trash)
|
||
let below = target(card2, x: 0, y: 240, container: .trash)
|
||
let all = [origin, row, below]
|
||
|
||
// Plain: the next row down, whatever its kind.
|
||
#expect(NavigationMath.nearest(from: origin.frame, direction: .down, among: all) == lane2)
|
||
|
||
// ⇧: the handler takes the *same* unrestricted neighbour and then tests it, so a crossing
|
||
// row makes the press inert rather than being stepped over in search of a legal one — the
|
||
// rule exists so a held range is never silently widened past what the user asked for.
|
||
let next = NavigationMath.nearest(from: origin.frame, direction: .down, among: all)
|
||
#expect(next == lane2)
|
||
#expect(row.kind != origin.kind, "so the extension stops here")
|
||
|
||
// From the row itself, ↓ reaches the card below it: navigation crosses back.
|
||
#expect(NavigationMath.nearest(from: row.frame, direction: .down, among: all) == card2)
|
||
}
|
||
}
|
||
|
||
// 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()
|
||
|
||
// The crossing dropped the head, which is this test's rule. What stands afterwards is
|
||
// 10-accessibility.md's vanishing-focus recovery, layered on top: the selection had emptied
|
||
// and the cursor's card had gone, so focus lands on the card's lane and takes the head with
|
||
// it (`BoardAnnouncerStoreTests`, where the recovery itself is pinned). The card's own head
|
||
// reference is gone either way, which is what "a container crossing is a vanish" claims.
|
||
#expect(store.selection.ids == [lane1])
|
||
#expect(store.transient.selectionHead == lane1)
|
||
}
|
||
}
|
||
|
||
// 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])
|
||
}
|
||
}
|