diff --git a/Kanban/UI/Board/NewCardTarget.swift b/Kanban/UI/Board/NewCardTarget.swift index 13060e7..55db848 100644 --- a/Kanban/UI/Board/NewCardTarget.swift +++ b/Kanban/UI/Board/NewCardTarget.swift @@ -5,15 +5,18 @@ /// /// > with a card selected, the new card is created in that card's lane, immediately after it /// > (paste-anchor consistency); with a lane selected, appended at its bottom (Return consistency); -/// > … with nothing selected — or a **tombstoned** selection, which never anchors creation — the -/// > **last-active lane** — the lane that most recently held selection or a creation in this window -/// > session — falling back to the first lane. … **Zero-lane board**: card creation … disable[s] via -/// > menu validation until a lane exists. +/// > a multi-selection anchors at its last member in flatten order (lane `order`, then card +/// > `order`, the multi-drag order; the same anchor serves paste): creation follows the last +/// > selected card, or appends to the last selected lane; … with nothing selected — or a +/// > **tombstoned** selection, which never anchors creation — the **last-active lane** — the lane +/// > that most recently held selection or a creation in this window session — falling back to the +/// > first lane. … **Zero-lane board**: card creation … disable[s] via menu validation until a +/// > lane exists. /// /// **A pure function rather than a method on the store** for the reason every rule in this codebase -/// that can be one is: the five branches are five lines of test rather than five UI states to drive, -/// and the menu item's `disabled` and its action then read the *same* answer instead of two -/// hand-kept-in-sync conditions. +/// that can be one is: the branches are lines of test rather than UI states to drive, and the menu +/// item's `disabled` and its action then read the *same* answer instead of two hand-kept-in-sync +/// conditions. /// /// ### What it deliberately does not decide /// @@ -22,10 +25,6 @@ /// call site passes its own lane and never comes here. /// - **Return on a selected lane** is the same target as this rule's lane branch, but it is reached /// by grammar rather than by the menu; it also passes its lane directly. -/// - **Multi-selections.** The rule speaks of "a card"/"a lane", singular, and a multi-selection has -/// no "it" to be immediately after. Anything but a sole selection falls through to the -/// last-active lane, which is the same answer an empty selection gets — the honest reading, and -/// the one m5's selection-model card can refine if the design ever grows a plural case. enum NewCardTarget { /// Where a new card goes: which lane, and which card it lands immediately after (`nil` = the @@ -57,23 +56,33 @@ enum NewCardTarget { let lanes = snapshot.lanes.filter { !$0.isDeleted } guard !lanes.isEmpty else { return nil } - if selection.liveness == .live, selection.ids.count == 1, let id = selection.ids.first { + if selection.liveness == .live, !selection.ids.isEmpty { + // The last selected member in flatten order — lane `order`, then card `order`, the + // multi-drag order (04, settled; the same anchor serves paste). The snapshot's lanes + // and cards are already in display order, so the flatten order is one walk, and the + // *last* hit is the anchor. Selection is homogeneous (cards XOR lanes), so only one of + // the two branches ever fires within a walk; a sole selection is simply the degenerate + // one-member case of the same rule. + var anchor: Resolution? for lane in lanes { - // A lane selected: appended at its bottom, Return consistency. - if lane.id == id { return Resolution(laneID: lane.id, anchorCardID: nil) } - // A card selected: its lane, immediately after it — paste-anchor consistency. - if lane.cards.contains(where: { $0.id == id && !$0.isDeleted }) { - return Resolution(laneID: lane.id, anchorCardID: id) + // A selected lane: creation appends at its bottom, Return consistency. + if selection.ids.contains(lane.id) { + anchor = Resolution(laneID: lane.id, anchorCardID: nil) + } + // A selected card: its lane, immediately after it — paste-anchor consistency. + for card in lane.cards where !card.isDeleted && selection.ids.contains(card.id) { + anchor = Resolution(laneID: lane.id, anchorCardID: card.id) } } - // The id names nothing the board renders — a selection the next reload will drop. + if let anchor { return anchor } + // The ids name nothing the board renders — a selection the next reload will drop. // Falls through to the last-active lane rather than refusing: the user pressed ⌘N and // the board has lanes. } - // Nothing selected, a tombstoned selection, a multi-selection, or a stale one: the lane that - // most recently held selection or a creation, and the first lane when there is no such lane - // (or it has since gone). + // Nothing selected, a tombstoned selection, or a stale one: the lane that most recently + // held selection or a creation, and the first lane when there is no such lane (or it has + // since gone). if let lastActiveLaneID, let lane = lanes.first(where: { $0.id == lastActiveLaneID }) { return Resolution(laneID: lane.id, anchorCardID: nil) } diff --git a/KanbanTests/NewCardTargetTests.swift b/KanbanTests/NewCardTargetTests.swift index da48989..212ea90 100644 --- a/KanbanTests/NewCardTargetTests.swift +++ b/KanbanTests/NewCardTargetTests.swift @@ -158,16 +158,32 @@ struct NewCardTargetTests { #expect(resolve(snapshot) == nil) } - @Test("A multi-selection and a stale one both fall through rather than guessing") - func pluralAndStaleSelectionsFallThrough() throws { + @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 - // The rule speaks of "a card"/"a lane", singular; a multi-selection has no "it" to be - // immediately after, so it gets the same answer as no selection at all. + // "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], liveness: .live), 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], liveness: .live), 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], liveness: .live)) == 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.