A lane folds to a fixed slim vertical strip carrying its glyph, its card-count badge and its title turned on its side, and the strip is deliberately not part of the window's division: the expanded lanes' units divide what is left once each folded strip's fixed width has come off the top, so folding a lane is a re-divide trigger of the Show/Hide Trash family — the window never moves and the siblings grow into what the lane gave up. The state is a first-class lane frontmatter key, `collapsed: true`, and document state exactly as `width` is: the files are the board, so an agent folds a lane by writing one key. Absent means expanded, expanding removes the key rather than writing `false` (the remove-at-default family beside a one-unit `width`, the empty rename's `title` and the None well's `background`), and the lane's `width` rides along untouched so expanding restores the lane the user had. The read is `width`'s leniency one type over — a boolean scalar or a quoted boolean word reads as itself, everything else has no reading at all and renders as expanded, bytes preserved either way. Toggling is the header's always-visible collapse chevron, the lane context menu's single Collapse Lane / Expand Lane row, and a plain click anywhere on the strip; a modified click on the strip stays the ordinary selection grammar, so a folded lane is still selectable by pointer. The title reads bottom-up and is justified to the top of the room below the strip's chrome (owner ruling 2026-08-08), truncating against the strip's own height. While folded the lane draws no cards at all, which is what makes every exclusion true by construction rather than by a guard per gesture: no card face means no marquee target and no navigation frame, and no registered grid means the masonry's drop zones have nothing to resolve against. What did need code is the half that names absolute destinations — the option-arrow jumps and the arrow seed scan past a folded lane, the lane domain's down-arrow is inert on one, and New Card skips it (a selection inside one falls through to the last-active lane, the stale selection's rule). A drop on the strip appends at the lane's end, cards and Finder files alike, with an accent edge standing in for the shadow the strip has no masonry to open; there is no hover-to-auto- expand yet. Lane reorder works on the strip, and a dragged folded lane carries its fold, so its shadow and its replica are the strip rather than its units. The write is `writeLaneWidths` clause for clause — one `updateIndex` bracket, the same stamp behaviour, the same three do-nothing paths — with two new `WriteOperation` cases and two new undo verbs rather than one of each, because a banner or an Edit-menu row that said "resize" after Collapse Lane would name a control the user never touched. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
664 lines
31 KiB
Swift
664 lines
31 KiB
Swift
import CoreGraphics
|
||
import Foundation
|
||
import Observation
|
||
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)
|
||
}
|
||
|
||
/// **A collapsed lane is scanned past by the absolute destinations** (03-board-ui.md § Lane ▸
|
||
/// Collapsed lanes: "cards inside are not rendered — they are excluded from … keyboard spatial
|
||
/// navigation"), which is what ⌥←/⌥→ and the empty-selection seed land through.
|
||
///
|
||
/// The *relative* half — a plain or ⇧ arrow — needs nothing: a folded lane registers no card frame,
|
||
/// so `nearest` has no candidate to reject, exactly as the hidden trash has none.
|
||
/// `@MainActor` where its neighbours are not, and only because the fixture is: a board on disk is
|
||
/// the honest way to ask what a *read-side* fold reads as, and `WriterFixture` is main-actor bound.
|
||
@MainActor
|
||
@Test("The absolute destinations scan past a folded lane, an empty one, and one the query emptied")
|
||
func firstCardSkipsFoldedLanes() throws {
|
||
let fixture = try makeBoard()
|
||
defer { fixture.tearDown() }
|
||
// Lane 1 folded, lane 2 open: the first card the *board* is showing is lane 2's.
|
||
try fixture.item(Ident.lane1, "---\nschema: 1\ntitle: Todo\norder: 1024\ncollapsed: true\n---\n\n")
|
||
let lanes = try load(fixture).lanes
|
||
|
||
#expect(NavigationMath.firstCard(scanning: lanes) == card5)
|
||
// Reversed — ⌥→'s scan — the last showing lane is lane 2 as well, since lane 3 is empty and
|
||
// lane 1 is folded.
|
||
#expect(NavigationMath.firstCard(scanning: lanes.reversed()) == card5)
|
||
|
||
// The filter narrows the same scan: "Sixth" is in the open lane, "First" is in the folded one
|
||
// and stays unreachable however well it matches.
|
||
#expect(NavigationMath.firstCard(scanning: lanes, filter: SearchFilter(query: "Sixth")) == card6)
|
||
#expect(NavigationMath.firstCard(scanning: lanes, filter: SearchFilter(query: "First")) == nil)
|
||
|
||
// Every lane folded: there is nowhere to land at all, which is the same answer an empty board
|
||
// gives and leaves the press inert rather than selecting something drawn nowhere.
|
||
let allFolded = try WriterFixture()
|
||
defer { allFolded.tearDown() }
|
||
try allFolded.item("", Item.board)
|
||
try allFolded.item(Ident.lane1, "---\nschema: 1\ntitle: Todo\norder: 1024\ncollapsed: true\n---\n\n")
|
||
try allFolded.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
|
||
#expect(NavigationMath.firstCard(scanning: try load(allFolded).lanes) == nil)
|
||
|
||
// And unfolding restores it, key removal and all — nothing about the cards changed.
|
||
try fixture.item(Ident.lane1, "---\nschema: 1\ntitle: Todo\norder: 1024\n---\n\n")
|
||
#expect(NavigationMath.firstCard(scanning: try load(fixture).lanes) == card1)
|
||
}
|
||
}
|
||
|
||
// MARK: - The registry the arrows and the band read
|
||
|
||
/// The registry is the one piece of the marquee/arrow pair that is *written* from a view — every card
|
||
/// face keeps its drawn frame in it — and it is deliberately unobserved so those writes invalidate
|
||
/// nothing (`MarqueeTargetRegistry`, `LaneDropRegistry`'s rule). That is a property of the type rather
|
||
/// than of any call site, so it is pinned here: a drag's make-room reflow animates positions, and an
|
||
/// observed registry turns each sliding face into a stream of view invalidations at the display's
|
||
/// refresh rate for as long as the reflow runs.
|
||
@Suite("MarqueeTargetRegistry ▸ registering a frame invalidates nothing")
|
||
@MainActor
|
||
struct MarqueeTargetRegistryTests {
|
||
|
||
/// A box the tracking callback can flip — `withObservationTracking`'s `onChange` is `@Sendable`,
|
||
/// and Observation calls it from wherever the mutation happened.
|
||
private final class Tripwire: @unchecked Sendable {
|
||
var fired = false
|
||
}
|
||
|
||
@Test("Every reader the band and the arrows use registers no observation")
|
||
func writesAreNotObserved() {
|
||
let registry = MarqueeTargetRegistry()
|
||
registry.update(target(card1, x: 0, y: 0))
|
||
let tripwire = Tripwire()
|
||
|
||
withObservationTracking {
|
||
// Exactly the three reads the event-time callers make: the sample loop's `all`
|
||
// (`MarqueeControl.gesture`), the arrows' keyed lookup (`BoardView.step`/`.extend`), and
|
||
// the band's begin guard.
|
||
_ = registry.all
|
||
_ = registry.targets[card1]
|
||
_ = registry.contains(.zero)
|
||
} onChange: {
|
||
tripwire.fired = true
|
||
}
|
||
|
||
// A reflow's worth of re-registration: the same card at a new frame, a new card, a removal.
|
||
registry.update(target(card1, x: 0, y: 40))
|
||
registry.update(target(card2, x: 0, y: 200))
|
||
registry.remove(card2)
|
||
|
||
#expect(tripwire.fired == false, "MarqueeTargetRegistry must not be @Observable")
|
||
}
|
||
|
||
@Test("The frames it hands back are still live after those writes")
|
||
func readsStayCorrect() {
|
||
let registry = MarqueeTargetRegistry()
|
||
registry.update(target(card1, x: 0, y: 0))
|
||
registry.update(target(card2, x: 0, y: 120))
|
||
|
||
registry.update(target(card1, x: 0, y: 40))
|
||
#expect(registry.targets[card1]?.frame.minY == 40)
|
||
#expect(registry.all.count == 2)
|
||
// Inside the moved card1 (y 40…140), and above where it now starts.
|
||
#expect(registry.contains(CGPoint(x: 10, y: 50)))
|
||
#expect(!registry.contains(CGPoint(x: 10, y: 20)))
|
||
// Inside card2 (y 120…220) and nothing else.
|
||
#expect(registry.contains(CGPoint(x: 10, y: 200)))
|
||
|
||
registry.remove(card2)
|
||
#expect(registry.targets[card2] == nil)
|
||
#expect(!registry.contains(CGPoint(x: 10, y: 200)))
|
||
}
|
||
}
|
||
|
||
// 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])
|
||
}
|
||
}
|