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
236 lines
12 KiB
Swift
236 lines
12 KiB
Swift
import Foundation
|
|
import Testing
|
|
@testable import Kanban
|
|
|
|
/// 04-interactions.md's **⌘N target rule**, branch by branch.
|
|
///
|
|
/// The rule is written as a pure function precisely so it can be tested like one: every branch is a
|
|
/// selection plus a snapshot in, a lane-and-anchor (or nothing) out — no menu, no window, no
|
|
/// gesture. The board underneath is a real load off a real temp tree, because the rule reads
|
|
/// `isDeleted` and card ordering and a hand-built `BoardModel` would let those drift from what the
|
|
/// loader actually produces.
|
|
|
|
// MARK: - Fixtures
|
|
|
|
/// `WriterFixture`, `Ident` and `Item` live in `WriterTestSupport.swift`.
|
|
|
|
private func tombstoned(order: String, title: String) -> String {
|
|
"""
|
|
---
|
|
schema: 1
|
|
title: \(title)
|
|
order: \(order)
|
|
deleted: 2026-03-03T09:00:00Z
|
|
---
|
|
\(title) body.
|
|
|
|
"""
|
|
}
|
|
|
|
/// Two live lanes (three cards between them) and one tombstoned lane, so every branch has something
|
|
/// to point at and the trash side has a member of its own.
|
|
@MainActor
|
|
private func makeBoard() throws -> WriterFixture {
|
|
let fixture = try WriterFixture()
|
|
try fixture.item("", Item.board)
|
|
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
|
|
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second"))
|
|
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
|
|
try fixture.item("\(Ident.lane2)/\(Ident.card3)", Item.rich(order: "1024", title: "Third"))
|
|
return fixture
|
|
}
|
|
|
|
private let lane1 = ItemID(rawValue: Ident.lane1)
|
|
private let lane2 = ItemID(rawValue: Ident.lane2)
|
|
private let lane3 = ItemID(rawValue: Ident.lane3)
|
|
private let card1 = ItemID(rawValue: Ident.card1)
|
|
private let card2 = ItemID(rawValue: Ident.card2)
|
|
private let card3 = ItemID(rawValue: Ident.card3)
|
|
|
|
private func resolve(
|
|
_ snapshot: BoardModel,
|
|
selection: ItemReferenceSet = .empty,
|
|
lastActive: ItemID? = nil
|
|
) -> NewCardTarget.Resolution? {
|
|
NewCardTarget.resolve(selection: selection, lastActiveLaneID: lastActive, snapshot: snapshot)
|
|
}
|
|
|
|
// MARK: - Tests
|
|
|
|
@MainActor
|
|
@Suite("NewCardTarget ▸ the ⌘N target rule")
|
|
struct NewCardTargetTests {
|
|
|
|
@Test("A sole selected card targets its own lane, immediately after it")
|
|
func aSelectedCardAnchorsInItsLane() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
|
|
|
|
// "With a card selected, the new card is created in that card's lane, immediately after it
|
|
// (paste-anchor consistency)."
|
|
#expect(resolve(snapshot, selection: ItemReferenceSet(ids: [card1], container: .board))
|
|
== NewCardTarget.Resolution(laneID: lane1, anchorCardID: card1))
|
|
#expect(resolve(snapshot, selection: ItemReferenceSet(ids: [card3], container: .board))
|
|
== NewCardTarget.Resolution(laneID: lane2, anchorCardID: card3))
|
|
|
|
// The last card in a lane is still an anchor here — "after the last card" and "at the
|
|
// bottom" coincide, and it is the commit that notices (`BoardStore.insertionIndex`).
|
|
#expect(resolve(snapshot, selection: ItemReferenceSet(ids: [card2], container: .board))
|
|
== NewCardTarget.Resolution(laneID: lane1, anchorCardID: card2))
|
|
}
|
|
|
|
@Test("A sole selected lane targets its bottom, with no anchor")
|
|
func aSelectedLaneTargetsItsBottom() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
|
|
|
|
// "With a lane selected, appended at its bottom (Return consistency)."
|
|
#expect(resolve(snapshot, selection: ItemReferenceSet(ids: [lane2], container: .board))
|
|
== NewCardTarget.Resolution(laneID: lane2, anchorCardID: nil))
|
|
}
|
|
|
|
@Test("Nothing selected falls to the last-active lane")
|
|
func nothingSelectedUsesTheLastActiveLane() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
|
|
|
|
#expect(resolve(snapshot, lastActive: lane2)
|
|
== NewCardTarget.Resolution(laneID: lane2, anchorCardID: nil))
|
|
|
|
// And the last resort when there is no memory to consult, or the lane it names is gone:
|
|
// "falling back to the first lane".
|
|
#expect(resolve(snapshot) == NewCardTarget.Resolution(laneID: lane1, anchorCardID: nil))
|
|
#expect(resolve(snapshot, lastActive: ItemID(rawValue: Ident.indexless))
|
|
== NewCardTarget.Resolution(laneID: lane1, anchorCardID: nil))
|
|
// A lane that has been deleted is not a target either — a lane delete is physical, so the
|
|
// memory simply names nothing.
|
|
#expect(resolve(snapshot, lastActive: lane3)
|
|
== NewCardTarget.Resolution(laneID: lane1, anchorCardID: nil))
|
|
}
|
|
|
|
@Test("A trash selection never anchors creation — it behaves as nothing selected")
|
|
func aTrashSelectionNeverAnchors() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
|
|
|
|
// 04 ▸ The map, on the ⌘N rule's own wording: "with nothing selected — or a **trash**
|
|
// selection, which never anchors creation". A trashed card's board-side lane must not leak
|
|
// in as a target.
|
|
let inTrash = ItemReferenceSet(ids: [card1], container: .trash)
|
|
#expect(resolve(snapshot, selection: inTrash, lastActive: lane2)
|
|
== NewCardTarget.Resolution(laneID: lane2, anchorCardID: nil))
|
|
#expect(resolve(snapshot, selection: inTrash)
|
|
== NewCardTarget.Resolution(laneID: lane1, anchorCardID: nil))
|
|
}
|
|
|
|
@Test("A zero-lane board has no target at all — the menu item's disabled condition")
|
|
func aZeroLaneBoardHasNoTarget() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
try fixture.item("", Item.board)
|
|
let empty = try BoardLoader.load(boardRoot: fixture.root).model
|
|
|
|
// "Zero-lane board (hand-made, or every lane deleted): card creation and card paste have no
|
|
// target — New Card, Return-creation, and Paste with a card payload disable via menu
|
|
// validation until a lane exists."
|
|
#expect(resolve(empty) == nil)
|
|
#expect(resolve(empty, selection: ItemReferenceSet(ids: [card1], container: .board)) == nil)
|
|
#expect(resolve(empty, lastActive: lane1) == nil)
|
|
}
|
|
|
|
@Test("A board whose every lane has been deleted is a zero-lane board")
|
|
func everyLaneDeletedIsAlsoZeroLane() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
for lane in [Ident.lane1, Ident.lane2, Ident.lane3] where fixture.exists(lane) {
|
|
try FileManager.default.removeItem(at: fixture.url(lane))
|
|
}
|
|
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
|
|
|
|
// "Every lane deleted" is the design's own second reading of the zero-lane case — and a lane
|
|
// delete is physical now, so it is literally the same board as the hand-made one above.
|
|
#expect(snapshot.lanes.isEmpty)
|
|
#expect(resolve(snapshot) == nil)
|
|
}
|
|
|
|
@Test("A multi-selection anchors at its last member in flatten order")
|
|
func pluralSelectionsAnchorAtTheirLastMember() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
|
|
|
|
// "A multi-selection anchors at its last member in flatten order (lane `order`, then card
|
|
// `order`, the multi-drag order)": card3 lives in lane2, which sorts after lane1's pair, so
|
|
// creation follows card3 — the last-active lane never enters into it.
|
|
#expect(resolve(snapshot, selection: ItemReferenceSet(ids: [card1, card3], container: .board), lastActive: lane1)
|
|
== NewCardTarget.Resolution(laneID: lane2, anchorCardID: card3))
|
|
|
|
// Within one lane the flatten order is card order: card2 sorts after card1.
|
|
#expect(resolve(snapshot, selection: ItemReferenceSet(ids: [card1, card2], container: .board), lastActive: lane2)
|
|
== NewCardTarget.Resolution(laneID: lane1, anchorCardID: card2))
|
|
|
|
// A multi-LANE selection appends to the last selected lane's bottom.
|
|
#expect(resolve(snapshot, selection: ItemReferenceSet(ids: [lane1, lane2], container: .board))
|
|
== NewCardTarget.Resolution(laneID: lane2, anchorCardID: nil))
|
|
}
|
|
|
|
@Test("A stale selection falls through rather than guessing or refusing")
|
|
func staleSelectionsFallThrough() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
|
|
|
|
// A selection naming something the board does not render — the reload that drops it has not
|
|
// landed yet — must not refuse the creation the user just asked for.
|
|
#expect(resolve(snapshot, selection: ItemReferenceSet(ids: [ItemID(rawValue: Ident.indexless)], container: .board))
|
|
== NewCardTarget.Resolution(laneID: lane1, anchorCardID: nil))
|
|
}
|
|
|
|
/// **⌘N skips collapsed lanes** (03-board-ui.md § Lane ▸ Collapsed lanes): the placeholder is a
|
|
/// pseudo-card drawn in the lane's masonry, and a folded lane draws none — so a creation there would
|
|
/// be a focused text field rendered nowhere.
|
|
@Test("A folded lane is never a creation target, in any branch")
|
|
func foldedLanesAreSkipped() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
try fixture.item(Ident.lane1, "---\nschema: 1\ntitle: Todo\norder: 1024\ncollapsed: true\n---\n\n")
|
|
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
|
|
|
|
// The default fall-through skips it: the first *open* lane is the target.
|
|
#expect(resolve(snapshot) == NewCardTarget.Resolution(laneID: lane2, anchorCardID: nil))
|
|
// And so does the last-active lane, which may well be the lane the user just folded.
|
|
#expect(resolve(snapshot, lastActive: lane1) == NewCardTarget.Resolution(laneID: lane2, anchorCardID: nil))
|
|
|
|
// A selection inside it **falls through** rather than refusing — the stale selection's rule,
|
|
// for its reason: the user pressed ⌘N and the board has lanes it can create into.
|
|
#expect(resolve(snapshot, selection: ItemReferenceSet(ids: [card1], container: .board))
|
|
== NewCardTarget.Resolution(laneID: lane2, anchorCardID: nil))
|
|
#expect(resolve(snapshot, selection: ItemReferenceSet(ids: [lane1], container: .board))
|
|
== NewCardTarget.Resolution(laneID: lane2, anchorCardID: nil))
|
|
|
|
// A selection in the open lane is untouched by any of it.
|
|
#expect(resolve(snapshot, selection: ItemReferenceSet(ids: [card3], container: .board))
|
|
== NewCardTarget.Resolution(laneID: lane2, anchorCardID: card3))
|
|
}
|
|
|
|
@Test("A board whose every lane is folded has no target at all — the zero-lane answer")
|
|
func everyLaneFoldedRefuses() throws {
|
|
let fixture = try makeBoard()
|
|
defer { fixture.tearDown() }
|
|
try fixture.item(Ident.lane1, "---\nschema: 1\ntitle: Todo\norder: 1024\ncollapsed: true\n---\n\n")
|
|
try fixture.item(Ident.lane2, "---\nschema: 1\ntitle: Doing\norder: 2048\ncollapsed: true\n---\n\n")
|
|
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
|
|
|
|
// `nil` is New Card's `disabled` condition as well as its refusal, so the item greys out
|
|
// rather than doing nothing when pressed.
|
|
#expect(resolve(snapshot) == nil)
|
|
#expect(resolve(snapshot, lastActive: lane1) == nil)
|
|
#expect(resolve(snapshot, selection: ItemReferenceSet(ids: [card1], container: .board)) == nil)
|
|
}
|
|
}
|