Redraw a copy's originals at the source — the resting layout follows the operation
The modifier flip (⌥ copy / ⌘ move) now reflows the source board once: a copy re-admits the dragged originals into the resting layout, standing dimmed in place, and a move lifts them out as before. This retires DRAG-REORDER.md's operation-blind carve-out and makes 04-interactions.md's "originals stay" true in flight, not just at echo. The one seam is DragSession.hiddenMembers reading the observed operation. Consequences carried honestly: copyCards now takes its index in the lane's full rendered space (the zones counted the originals, so the geometry's number is the writer's number — the old neighbour remap is deleted); resolveOperation freezes under the committed hold exactly as propose does, fixing a real bug where a settled within-board ⌥-copy drew the move arrangement until the echo and visibly re-shuffled. Stationary flips still wait for the next dropUpdated (their own Backlog card). Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
+9
-3
@@ -23,7 +23,13 @@ While a drag is in flight, the proposal (an insertion index) is computed geometr
|
|||||||
|
|
||||||
The proposal is a pure function of the cursor over these fixed zones. This is deliberately **not** derived from per-item hover events, which feed back off the very reflow they cause (items move under the cursor → a different item fires → the proposal moves again) and jitter. Because the zones are reconstructed analytically rather than measured, they do not move when the placeholder does.
|
The proposal is a pure function of the cursor over these fixed zones. This is deliberately **not** derived from per-item hover events, which feed back off the very reflow they cause (items move under the cursor → a different item fires → the proposal moves again) and jitter. Because the zones are reconstructed analytically rather than measured, they do not move when the placeholder does.
|
||||||
|
|
||||||
**The resting layout excludes the dragged run whatever the effective operation is.** A within-board ⌥-drag copies, and the originals really do stay — but ⌥ can be pressed and released mid-drag, and a layout that re-admitted the originals on every modifier flip would flap the whole board under the cursor. The dragged items are lifted out at pickup and stay out until release; the copy's originals reappear when the write lands. This is why the drop index means the same thing for `moveCards` and `copyCards` (see **The drop commits** below).
|
**The resting layout follows the effective operation: a move lifts the dragged run out, a copy leaves it in.** 04-interactions.md says the originals stay for both copies — the within-board ⌥-drag and the cross-board default — so the source board draws what it will still hold: the originals stand in place, dimmed (`ClipboardTreatment.dimmedOpacity`, the same treatment a trash row being dragged out wears), and the shadow run opens beside them. A move's originals are lifted out at pickup and stay out until the echo lands. The single expression is `DragSession.hiddenMembers(onBoardRooted:)`, which every surface that builds a resting layout goes through.
|
||||||
|
|
||||||
|
Pressing or releasing ⌥/⌘ mid-drag therefore reflows the source board **once**, and that one-shot reflow is the point rather than a cost: the modifier's whole job is to change what the drop will leave behind, and the board answering the question is the feedback being asked for. (This retires an earlier carve-out that lifted the run out for every operation to keep the board still. The stillness was real; it was bought by never answering the question.) The flip lands on the next `dropUpdated`, because that is where the operation is re-resolved — a modifier pressed with the mouse perfectly still waits for the next motion. Once a release has settled, the operation is frozen for the duration of the committed hold (`DragSession.resolveOperation` guards on `hold == nil`, exactly as `propose` does), so a settled copy keeps its originals on screen and a settled move keeps them lifted until the echo snapshot lands.
|
||||||
|
|
||||||
|
The index-space consequence is real and is carried by the writers rather than papered over: **the drop index means different things for `moveCards` and `copyCards`**, and each one is counted in the layout its own gesture was showing. A move's index counts slots among the cards the run vacates; a copy's counts them among the lane's full rendered set, originals included. Neither writer has to guess — the operation picks the writer and the operation picked the layout — and a within-board ⌥-copy therefore lands exactly at the shadow's slot, including when the shadow sits below the dragged run in the same lane (see **The drop commits** below).
|
||||||
|
|
||||||
|
Lanes get this for free and the invariant is worth stating: a within-board lane drag never resolves to `.copy` (⌥ is ignored there), so the strip's zones never see a re-admitted lane under the cursor; a **cross-board** lane copy re-admits the lane into the *source* strip while the cursor is over the foreign board, where none of the source strip's zones are being consulted. `moveLanes` and `receiveLanes` keep the index space they always had. The strip's lane rendering has no dim of its own, so a re-admitted lane reads as an ordinary lane rather than as the source of the drag — the card face and the trash rows are the only surfaces wearing that treatment today.
|
||||||
|
|
||||||
Two stability rules on top:
|
Two stability rules on top:
|
||||||
|
|
||||||
@@ -108,7 +114,7 @@ Geometry does not change across the boundary. The destination board's own restin
|
|||||||
|
|
||||||
## The drop commits
|
## The drop commits
|
||||||
|
|
||||||
The commit is the store's, and it is one `performWrite` bracket per gesture whatever the set's size: one app-mediated reload, and (on git boards) one commit rather than N. Every one of them takes an index counted **against the destination's rendered items with the dragged run removed** — the resting layout's own convention, so the number the geometry produced is the number the writer consumes, unrewritten.
|
The commit is the store's, and it is one `performWrite` bracket per gesture whatever the set's size: one app-mediated reload, and (on git boards) one commit rather than N. Every one of them takes an index counted **against the destination's rendered items as the resting layout showed them** — so the number the geometry produced is the number the writer consumes, unrewritten. For every move that means "with the dragged run removed"; for the within-board ⌥-copy it means "with the originals still there", because a copy leaves them there and the zones counted them (see **Resting-layout zones**). Cross-board arrivals never face the question: the destination never held the originals.
|
||||||
|
|
||||||
| Gesture | Store method | Writer |
|
| Gesture | Store method | Writer |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
@@ -123,7 +129,7 @@ The commit is the store's, and it is one `performWrite` bracket per gesture what
|
|||||||
Three properties of that table are load-bearing:
|
Three properties of that table are load-bearing:
|
||||||
|
|
||||||
- **Ranks are inserted, never permuted.** A drop writes only the dragged items' `order` — the siblings' files are not touched, so `modified` (and a git commit) stays honest about what actually moved. `Ranks.insertionRanks(amongVisible:at:count:)` produces the N ranks the contiguous run needs; `nil` from it is the renumber trigger, exactly as an exhausted midpoint is everywhere else, and the fallback compacts the destination and places again (`moveLane`'s and `sortSelection`'s pattern).
|
- **Ranks are inserted, never permuted.** A drop writes only the dragged items' `order` — the siblings' files are not touched, so `modified` (and a git commit) stays honest about what actually moved. `Ranks.insertionRanks(amongVisible:at:count:)` produces the N ranks the contiguous run needs; `nil` from it is the renumber trigger, exactly as an exhausted midpoint is everywhere else, and the fallback compacts the destination and places again (`moveLane`'s and `sortSelection`'s pattern).
|
||||||
- **A copy's ranks are computed against the destination's *full* rendered set**, because the originals stay and a rank chosen in the gap the lifted originals vacated would collide with them. A move's are computed against the set the moving members vacate. One expression covers both: the ranks are placed among the rendered cards minus whatever will actually leave.
|
- **A copy's ranks are computed against the destination's *full* rendered set**, because the originals stay and are still on disk holding their ranks. A move's are computed against the set the moving members vacate. One expression covers both: the ranks are placed among the rendered cards minus whatever will actually leave — which is also, exactly, the layout each gesture's zones were built over, so the index needs no translation on the way in.
|
||||||
- **Cross-board writes are executed by the destination store**, inside *its* bracket. The source board's tree changes outside its own store's bracket, which is correct and needs no coordination: the source store's watcher sees a foreign change and reloads, which is what a foreign change is.
|
- **Cross-board writes are executed by the destination store**, inside *its* bracket. The source board's tree changes outside its own store's bracket, which is correct and needs no coordination: the source store's watcher sees a foreign change and reloads, which is what a foreign change is.
|
||||||
|
|
||||||
Identity follows 01-storage-format.md exactly. A copy mints fresh UUIDs at every level and keeps `created` (a copy is a fork). A move keeps the UUID; only the **import boundary** remints, per folder, at the finest grain — a lane arriving with one colliding card is still a lane move with one reminted card.
|
Identity follows 01-storage-format.md exactly. A copy mints fresh UUIDs at every level and keeps `created` (a copy is a fork). A move keeps the UUID; only the **import boundary** remints, per folder, at the finest grain — a lane arriving with one colliding card is still a lane move with one reminted card.
|
||||||
|
|||||||
@@ -2704,26 +2704,23 @@ public final class BoardStore: HealHost {
|
|||||||
/// "originals stay, cursor shows the copy badge, fresh-GUID duplicates land at the drop").
|
/// "originals stay, cursor shows the copy badge, fresh-GUID duplicates land at the drop").
|
||||||
/// `created` survives because a copy is a fork — `CopyStamps.fork`, the same stamps paste uses.
|
/// `created` survives because a copy is a fork — `CopyStamps.fork`, the same stamps paste uses.
|
||||||
///
|
///
|
||||||
/// **The ranks are placed among the lane's *full* rendered set**, not among the set with the
|
/// **`index` is counted among the lane's *full* rendered cards — the originals included** — and
|
||||||
/// dragged members removed — the one place a copy's arithmetic differs from a move's. The
|
/// that is the one place a copy's index space differs from a move's (`moveCards` counts among
|
||||||
/// originals are lifted out of the layout for the duration of the drag whatever the effective
|
/// the cards its run vacates). It is not a special case bolted on here: a copy's originals stay
|
||||||
/// operation is (⌥ can be pressed and released mid-drag; a layout that re-admitted them on every
|
/// in the source board's resting layout for the whole drag (`DragSession.hiddenMembers`, ruled
|
||||||
/// flip would flap the whole board), but they are still *on disk* holding their ranks, and a
|
/// 2026-08-01), so the zones the shadow was resolved against already counted them, and the
|
||||||
/// rank chosen in the gap they appear to have vacated would collide with them the instant they
|
/// geometry's number is this method's number unrewritten.
|
||||||
/// reappear. So the drop's index is mapped through to the neighbour it names — the card the run
|
///
|
||||||
/// lands in front of — and the rank is taken there.
|
/// The ranks fall out of the same fact. The originals are still on disk holding their ranks, so
|
||||||
|
/// a rank chosen in a gap they had apparently vacated would collide with them the instant they
|
||||||
|
/// reappeared — and with the index counted among them there is no such gap to choose in.
|
||||||
public func copyCards(_ ids: Set<ItemID>, toLane laneID: ItemID, at index: Int) {
|
public func copyCards(_ ids: Set<ItemID>, toLane laneID: ItemID, at index: Int) {
|
||||||
guard let destination = snapshot.lanes.first(where: { $0.id == laneID }) else { return }
|
guard let destination = snapshot.lanes.first(where: { $0.id == laneID }) else { return }
|
||||||
let members = draggedCards(ids)
|
let members = draggedCards(ids)
|
||||||
guard !members.isEmpty else { return }
|
guard !members.isEmpty else { return }
|
||||||
|
|
||||||
let rendered = destination.cards
|
let rendered = destination.cards
|
||||||
let remaining = rendered.filter { !ids.contains($0.id) }
|
let placement = min(max(0, index), rendered.count)
|
||||||
let target = min(max(0, index), remaining.count)
|
|
||||||
// The resting-layout index, re-read against the layout the originals are still part of.
|
|
||||||
let placement = target < remaining.count
|
|
||||||
? (rendered.firstIndex { $0.id == remaining[target].id } ?? rendered.count)
|
|
||||||
: rendered.count
|
|
||||||
|
|
||||||
let root = rootURL
|
let root = rootURL
|
||||||
let laneFolder = ItemPath.lane(laneID).folder(under: root)
|
let laneFolder = ItemPath.lane(laneID).folder(under: root)
|
||||||
|
|||||||
@@ -168,6 +168,15 @@ struct BoardDropContext {
|
|||||||
/// slot's uncapped reach past the last real lane lands *before* it. The delete a lane drag can
|
/// slot's uncapped reach past the last real lane lands *before* it. The delete a lane drag can
|
||||||
/// propose over the column is not a slot at all — it is the column's own target answering
|
/// propose over the column is not a slot at all — it is the column's own target answering
|
||||||
/// (`retargetTrash`), and it names `.trash` rather than an index in this list.
|
/// (`retargetTrash`), and it names `.trash` rather than an index in this list.
|
||||||
|
///
|
||||||
|
/// **These zones never see a re-admitted lane, and that is an invariant rather than an
|
||||||
|
/// accident.** `hiddenMembers` now leaves a copy's originals in the resting layout, but a lane
|
||||||
|
/// can only be re-admitted into the strip it was picked up from, and a lane drag over its own
|
||||||
|
/// board never resolves to `.copy` (`DragLocality.operation`'s first carve-out). A cross-board
|
||||||
|
/// lane copy does re-admit — into the **source** strip, while the cursor is over the foreign
|
||||||
|
/// board, where no zone of this board's is being consulted at all. So the strip's slot list is
|
||||||
|
/// still "the lanes minus the run" every time this function actually runs, and `moveLanes` /
|
||||||
|
/// `receiveLanes` keep the index space they always had.
|
||||||
func retargetLanes() {
|
func retargetLanes() {
|
||||||
guard session.isDraggingLanes, let cursor = stripCursor() else { return }
|
guard session.isDraggingLanes, let cursor = stripCursor() else { return }
|
||||||
let hidden = session.hiddenMembers(onBoardRooted: store.rootURL)
|
let hidden = session.hiddenMembers(onBoardRooted: store.rootURL)
|
||||||
@@ -191,6 +200,13 @@ struct BoardDropContext {
|
|||||||
/// The single answer the lane's own drop delegate, the strip's fall-through, and the autoscroll
|
/// The single answer the lane's own drop delegate, the strip's fall-through, and the autoscroll
|
||||||
/// driver all go through — "the lane's drop delegate and the autoscroll driver must go through
|
/// driver all go through — "the lane's drop delegate and the autoscroll driver must go through
|
||||||
/// one shared retarget, so they can never disagree" (DRAG-REORDER.md § Edge autoscroll).
|
/// one shared retarget, so they can never disagree" (DRAG-REORDER.md § Edge autoscroll).
|
||||||
|
///
|
||||||
|
/// **The zones are built over whatever `hiddenMembers` leaves standing**, which is what makes
|
||||||
|
/// the proposal's index space follow the operation for free: a within-board move counts slots in
|
||||||
|
/// the lane minus the dragged run, a within-board ⌥-copy counts them in the lane *including* the
|
||||||
|
/// originals, because that is the layout the user is looking at. `DropSlotMath` has no opinion
|
||||||
|
/// on the matter — the caller decides what the resting layout contains — and the commit's two
|
||||||
|
/// writers are counted in exactly these two spaces (`BoardStore.moveCards` / `copyCards`).
|
||||||
func retargetCards(inLane laneID: ItemID) {
|
func retargetCards(inLane laneID: ItemID) {
|
||||||
guard session.isDraggingCards, let cursor = globalCursor() else { return }
|
guard session.isDraggingCards, let cursor = globalCursor() else { return }
|
||||||
guard let lane = store.snapshot.lanes.first(where: { $0.id == laneID }) else {
|
guard let lane = store.snapshot.lanes.first(where: { $0.id == laneID }) else {
|
||||||
@@ -644,6 +660,12 @@ struct BoardDropContext {
|
|||||||
// restore *is* the within-board move and a cross-board restore *is* the ordinary
|
// restore *is* the within-board move and a cross-board restore *is* the ordinary
|
||||||
// arrival — which is exactly what retiring the restore-specific machinery bought.
|
// arrival — which is exactly what retiring the restore-specific machinery bought.
|
||||||
if within {
|
if within {
|
||||||
|
// **Each writer is counted in the layout its own gesture was showing.** The zones
|
||||||
|
// that produced `target.index` were built over the resting layout for *this*
|
||||||
|
// operation (`retargetCards` → `DragSession.hiddenMembers`): a move's excludes the
|
||||||
|
// dragged run, a copy's includes the originals, because a copy leaves them there.
|
||||||
|
// So the number goes to each writer unrewritten and the drop lands where the shadow
|
||||||
|
// was — the two index spaces differ, and neither writer has to guess which it got.
|
||||||
if operation == .copy {
|
if operation == .copy {
|
||||||
store.copyCards(Set(ids), toLane: laneID, at: target.index)
|
store.copyCards(Set(ids), toLane: laneID, at: target.index)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -428,9 +428,11 @@ struct BoardView: View {
|
|||||||
/// The outer frame is always the snapped slot width, so the `HStack` lays the other lanes out
|
/// The outer frame is always the snapped slot width, so the `HStack` lays the other lanes out
|
||||||
/// off the tidy snapped layout regardless of the live overflow.
|
/// off the tidy snapped layout regardless of the live overflow.
|
||||||
///
|
///
|
||||||
/// A lane being **dragged** is simply absent from the strip: it is lifted out of the resting
|
/// A lane being **dragged as a move** is simply absent from the strip: it is lifted out of the
|
||||||
/// layout at pickup and stays out until release, whatever the effective operation is
|
/// resting layout at pickup and stays out until release, while the system drag session carries
|
||||||
/// (DRAG-REORDER.md § Resting-layout zones), while the system drag session carries its replica.
|
/// its replica. A lane being dragged as a **copy** stays in the strip instead, because the copy
|
||||||
|
/// leaves it there (DRAG-REORDER.md § Resting-layout zones) — which on this board means the
|
||||||
|
/// cross-board case alone, ⌥ being ignored by a within-board lane drag.
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func laneSlot(_ lane: Lane, standard: CGFloat) -> some View {
|
private func laneSlot(_ lane: Lane, standard: CGFloat) -> some View {
|
||||||
let resizing = resize.isResizing(lane.id)
|
let resizing = resize.isResizing(lane.id)
|
||||||
@@ -628,8 +630,9 @@ struct BoardView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What the strip lays out: the resting lanes — the dragged run lifted out, whatever the
|
/// What the strip lays out: the resting lanes — the dragged run lifted out when the release
|
||||||
/// effective operation is — with the shadows opened at the proposal.
|
/// would be a move, left standing when it would be a copy (`DragSession.hiddenMembers`) — with
|
||||||
|
/// the shadows opened at the proposal.
|
||||||
private var stripSlots: [StripSlot] {
|
private var stripSlots: [StripSlot] {
|
||||||
let session = appModel.dragSession
|
let session = appModel.dragSession
|
||||||
let hidden = session.hiddenMembers(onBoardRooted: store.rootURL)
|
let hidden = session.hiddenMembers(onBoardRooted: store.rootURL)
|
||||||
|
|||||||
@@ -212,10 +212,14 @@ struct CardFaceView: View {
|
|||||||
// genuinely fires on the trash side too: "⌘X works — cut in the trash, paste into a lane is
|
// genuinely fires on the trash side too: "⌘X works — cut in the trash, paste into a lane is
|
||||||
// the keyboard-native restore" (04 ▸ The trash, resettled 2026-07-28).
|
// the keyboard-native restore" (04 ▸ The trash, resettled 2026-07-28).
|
||||||
.cutTreatment(of: card.id, in: store)
|
.cutTreatment(of: card.id, in: store)
|
||||||
// The face being dragged out dims the same way while the session is in flight on the trash
|
// The face being dragged out dims the same way while the session is in flight, wherever the
|
||||||
// side, where the source stays visible: a restore is not a removal until the write lands.
|
// source stays visible — which is now two places rather than one. On the **trash** side a
|
||||||
// (On the board a dragged card is lifted out of the resting layout entirely, so this never
|
// restore is not a removal until the write lands, so the row never leaves. On the **board**
|
||||||
// has anything to act on there — `LaneView.renderedCards`.)
|
// side a drag whose effective operation is `.copy` leaves its originals in the resting
|
||||||
|
// layout, because the copy leaves them there (`DragSession.hiddenMembers`), and this is what
|
||||||
|
// marks them as the source of the drag rather than as ordinary neighbours. A `.move`'s
|
||||||
|
// originals are lifted out entirely and this has nothing to act on
|
||||||
|
// (`LaneView.renderedCards`).
|
||||||
.opacity(drops.session.isDragging(card.id) ? ClipboardTreatment.dimmedOpacity : 1)
|
.opacity(drops.session.isDragging(card.id) ? ClipboardTreatment.dimmedOpacity : 1)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
// **A card is one flattened accessibility element** (10-accessibility.md ▸ The board through
|
// **A card is one flattened accessibility element** (10-accessibility.md ▸ The board through
|
||||||
|
|||||||
@@ -379,18 +379,33 @@ final class DragSession {
|
|||||||
///
|
///
|
||||||
/// Only the source board hides anything, and only for a board-side session: a trash card's drag
|
/// Only the source board hides anything, and only for a board-side session: a trash card's drag
|
||||||
/// carries items that render in the trash column, not in any lane's masonry, so no lane loses a
|
/// carries items that render in the trash column, not in any lane's masonry, so no lane loses a
|
||||||
/// card to it.
|
/// card to it. Those rows dim in place instead (`CardFaceView`, `TrashLaneRowView`), which is
|
||||||
|
/// the same treatment a re-admitted board original now wears — one vocabulary for "this is the
|
||||||
|
/// source of the drag", wherever the source stays visible.
|
||||||
///
|
///
|
||||||
/// **The dragged run is lifted out whatever the effective operation is** (DRAG-REORDER.md §
|
/// **A move lifts the run out; a copy leaves it in** (04-interactions.md ▸ Drag and drop:
|
||||||
/// Resting-layout zones): ⌥ can be pressed and released mid-drag, and a layout that re-admitted
|
/// "originals stay", for the within-board ⌥-drag and for the cross-board default alike — ruled
|
||||||
/// the originals on every modifier flip would flap the whole board under the cursor. The copy's
|
/// 2026-08-01, retiring the operation-blind carve-out this used to be). The layout on screen is
|
||||||
/// originals reappear when the write lands — the hold keeps them lifted for that round trip, so
|
/// therefore always the arrangement the release would produce: a copy's source board shows what
|
||||||
/// the arrangement on screen is the one the release proposed and stays still until the echo.
|
/// it will still hold, a move's shows what it will have given away. Flipping ⌥ or ⌘ mid-drag
|
||||||
|
/// reflows the source board once, and that one-shot reflow *is* the feedback the modifier is
|
||||||
|
/// asking for — the previous rule's fear of "flapping" traded the answer to "what will this
|
||||||
|
/// drop leave behind?" for a stillness nobody asked for.
|
||||||
|
///
|
||||||
|
/// Reading `operation` here is what makes the flip visible at all: the property is observed, so
|
||||||
|
/// every surface that builds a resting layout off this method re-renders when it changes. The
|
||||||
|
/// flip lands on the next `dropUpdated`, since that is where the operation is re-resolved; a
|
||||||
|
/// modifier pressed with the mouse perfectly still waits for the next motion (Backlog ▸
|
||||||
|
/// Stationary modifier flips).
|
||||||
|
///
|
||||||
|
/// **The hold freezes the answer**, because `resolveOperation` refuses to move once a release
|
||||||
|
/// has settled: a settled copy keeps its originals on screen and a settled move keeps them
|
||||||
|
/// lifted, in both cases until the echo snapshot lands and the real faces take over.
|
||||||
func hiddenMembers(onBoardRooted root: URL) -> Set<ItemID> {
|
func hiddenMembers(onBoardRooted root: URL) -> Set<ItemID> {
|
||||||
guard isActive, container == .board, let sourceRoot,
|
guard isActive, container == .board, let sourceRoot,
|
||||||
DragLocality.isSameBoard(root, sourceRoot)
|
DragLocality.isSameBoard(root, sourceRoot)
|
||||||
else { return [] }
|
else { return [] }
|
||||||
return memberSet
|
return operation == .copy ? [] : memberSet
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The proposal's index when it names this board's strip, else `nil` — the lane strip's shadow
|
/// The proposal's index when it names this board's strip, else `nil` — the lane strip's shadow
|
||||||
@@ -575,14 +590,27 @@ final class DragSession {
|
|||||||
|
|
||||||
/// Re-resolves the effective operation against the board under the cursor and the modifiers
|
/// Re-resolves the effective operation against the board under the cursor and the modifiers
|
||||||
/// **right now**, and hands it back for the `DropProposal` the badge tracks.
|
/// **right now**, and hands it back for the `DropProposal` the badge tracks.
|
||||||
|
///
|
||||||
|
/// **A settled release freezes it**, `propose`'s guard for a reason that is now the same one:
|
||||||
|
/// the operation decides what the source board's resting layout holds (`hiddenMembers`) as well
|
||||||
|
/// as which write went out, so a stray `dropUpdated` arriving after the commit — with the user's
|
||||||
|
/// finger already off ⌥ — would re-lift originals the write is about to leave in place. The
|
||||||
|
/// write named an operation; the overlay draws that operation until the echo lands.
|
||||||
|
///
|
||||||
|
/// `modifiers` defaults to the live flags, which is the only thing the app ever passes; it is a
|
||||||
|
/// parameter so the resolution is checkable without a keyboard, exactly as `DragLocality`'s own
|
||||||
|
/// pure function is.
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func resolveOperation(destinationRoot: URL) -> TransferOperation {
|
func resolveOperation(
|
||||||
guard let kind, let sourceRoot else { return operation }
|
destinationRoot: URL,
|
||||||
|
modifiers: NSEvent.ModifierFlags = NSEvent.modifierFlags
|
||||||
|
) -> TransferOperation {
|
||||||
|
guard let kind, let sourceRoot, hold == nil else { return operation }
|
||||||
let resolved = DragLocality.operation(
|
let resolved = DragLocality.operation(
|
||||||
kind: kind,
|
kind: kind,
|
||||||
container: container,
|
container: container,
|
||||||
isWithinBoard: DragLocality.isSameBoard(sourceRoot, destinationRoot),
|
isWithinBoard: DragLocality.isSameBoard(sourceRoot, destinationRoot),
|
||||||
modifiers: NSEvent.modifierFlags
|
modifiers: modifiers
|
||||||
)
|
)
|
||||||
if resolved != operation { operation = resolved }
|
if resolved != operation { operation = resolved }
|
||||||
return resolved
|
return resolved
|
||||||
@@ -609,10 +637,13 @@ final class DragSession {
|
|||||||
/// Enters the committed phase: the arrangement the session was showing stays on screen until
|
/// Enters the committed phase: the arrangement the session was showing stays on screen until
|
||||||
/// `store` applies its next snapshot (`CommittedHold`).
|
/// `store` applies its next snapshot (`CommittedHold`).
|
||||||
///
|
///
|
||||||
/// Everything that drives the rendering — the members, the proposal, the source root — is kept
|
/// Everything that drives the rendering — the members, the proposal, the source root, the
|
||||||
/// exactly as it was, so "keeps rendering the arrangement it was showing" needs no second
|
/// operation — is kept exactly as it was, so "keeps rendering the arrangement it was showing"
|
||||||
/// mechanism: the shadows stay at the landing slots and the originals stay lifted out until the
|
/// needs no second mechanism: the shadows stay at the landing slots and the originals stay
|
||||||
/// snapshot carrying the write arrives and the real faces take their place.
|
/// exactly where the operation put them — lifted out for a move, standing dimmed in place for a
|
||||||
|
/// copy — until the snapshot carrying the write arrives and the real faces take their place.
|
||||||
|
/// Freezing the operation is `resolveOperation`'s own guard; the hold this method arms is what
|
||||||
|
/// that guard reads.
|
||||||
func commit(into store: BoardStore) {
|
func commit(into store: BoardStore) {
|
||||||
guard isActive else { return }
|
guard isActive else { return }
|
||||||
sourceStore?.transient.dragMembers = .empty
|
sourceStore?.transient.dragMembers = .empty
|
||||||
|
|||||||
@@ -859,11 +859,13 @@ struct LaneView: View {
|
|||||||
/// `lane.cards` and the trash column renders it instead. The tombstone era's ancestor walk and
|
/// `lane.cards` and the trash column renders it instead. The tombstone era's ancestor walk and
|
||||||
/// effective-liveness predicate are retired with the flag they read.
|
/// effective-liveness predicate are retired with the flag they read.
|
||||||
///
|
///
|
||||||
/// **A dragged card renders nowhere either, for as long as the session lasts.** It is lifted out
|
/// **A card being dragged *as a move* renders nowhere, for as long as the session lasts.** It is
|
||||||
/// of the resting layout at pickup and stays out until release *whatever the effective operation
|
/// lifted out of the resting layout at pickup and stays out until release. A card being dragged
|
||||||
/// is* — a ⌥-copy's originals really do stay, but ⌥ can be pressed and released mid-drag, and a
|
/// as a **copy** stays exactly where it is and dims (`CardFaceView`), because that is what the
|
||||||
/// layout that re-admitted them on every flip would flap the board under the cursor
|
/// copy will leave behind — so flipping ⌥ or ⌘ mid-drag reflows this lane once, which is the
|
||||||
/// (DRAG-REORDER.md § Resting-layout zones). The copy's originals reappear when the write lands.
|
/// feedback the modifier is asking for (DRAG-REORDER.md § Resting-layout zones, ruled
|
||||||
|
/// 2026-08-01). Either way the drop's index is counted in the layout on screen, which is why
|
||||||
|
/// `copyCards` and `moveCards` take their index in different spaces.
|
||||||
///
|
///
|
||||||
/// **A card the live search filter hides renders nowhere either** (04-interactions.md § Search):
|
/// **A card the live search filter hides renders nowhere either** (04-interactions.md § Search):
|
||||||
/// "cards whose title *and* body both miss the query animate out". This is the one collection
|
/// "cards whose title *and* body both miss the query animate out". This is the one collection
|
||||||
|
|||||||
@@ -654,3 +654,206 @@ struct DropSettleTests {
|
|||||||
#expect(session.hold == nil)
|
#expect(session.hold == nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - What the source board's resting layout holds
|
||||||
|
|
||||||
|
/// **The resting layout follows the effective operation** (DRAG-REORDER.md § Resting-layout zones,
|
||||||
|
/// ruled 2026-08-01; 04-interactions.md ▸ Drag and drop: "originals stay" for both copies): a move
|
||||||
|
/// lifts the dragged run out of the source board, a copy leaves it standing there, because that is
|
||||||
|
/// what the release will actually leave behind. The whole rule is `hiddenMembers(onBoardRooted:)`,
|
||||||
|
/// and every surface that builds a resting layout — the lane masonries, the strip, the drop zones —
|
||||||
|
/// reads it, so pinning it here pins all of them.
|
||||||
|
///
|
||||||
|
/// The modifiers are passed to `resolveOperation` rather than held down: it is the same seam
|
||||||
|
/// `DragLocalityTests` uses one level down, and it is what makes the flip checkable without a
|
||||||
|
/// keyboard.
|
||||||
|
@MainActor
|
||||||
|
@Suite("The source board's resting layout")
|
||||||
|
struct DragRestingLayoutTests {
|
||||||
|
|
||||||
|
private static let lane1 = ItemID(rawValue: Ident.lane1)
|
||||||
|
private static let card1 = ItemID(rawValue: Ident.card1)
|
||||||
|
private static let card2 = ItemID(rawValue: Ident.card2)
|
||||||
|
|
||||||
|
private let none: NSEvent.ModifierFlags = []
|
||||||
|
private let option: NSEvent.ModifierFlags = [.option]
|
||||||
|
private let command: NSEvent.ModifierFlags = [.command]
|
||||||
|
|
||||||
|
/// Another board entirely — the right-hand side of every cross-board resolution below. It needs
|
||||||
|
/// no fixture: locality compares paths, and nothing here reads the foreign board's contents.
|
||||||
|
private let elsewhere = URL(fileURLWithPath: "/Boards/Elsewhere.kanban", isDirectory: true)
|
||||||
|
|
||||||
|
/// One lane holding two cards, plus a trashed card so a `.trash`-container session has something
|
||||||
|
/// real to name.
|
||||||
|
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(".trash/\(Ident.card3)", Item.rich(order: "1024", title: "Trashed"))
|
||||||
|
return fixture
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A card session over `members`, picked up out of `lane1`.
|
||||||
|
private func cardSession(
|
||||||
|
_ store: BoardStore,
|
||||||
|
members: [ItemID] = [card1],
|
||||||
|
container: ItemContainer = .board
|
||||||
|
) -> DragSession {
|
||||||
|
let session = DragSession()
|
||||||
|
session.beginCards(
|
||||||
|
members,
|
||||||
|
folders: members.map {
|
||||||
|
store.rootURL
|
||||||
|
.appendingPathComponent(Ident.lane1, isDirectory: true)
|
||||||
|
.appendingPathComponent($0.rawValue, isDirectory: true)
|
||||||
|
},
|
||||||
|
heights: members.map { _ in 44 },
|
||||||
|
container: container,
|
||||||
|
source: store
|
||||||
|
)
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: The two layouts
|
||||||
|
|
||||||
|
@Test("A move lifts the dragged run out — the source board shows what it is giving away")
|
||||||
|
func aMoveLiftsTheRunOut() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
let session = cardSession(store, members: [Self.card1, Self.card2])
|
||||||
|
|
||||||
|
#expect(session.resolveOperation(destinationRoot: store.rootURL, modifiers: none) == .move)
|
||||||
|
#expect(session.hiddenMembers(onBoardRooted: store.rootURL) == [Self.card1, Self.card2])
|
||||||
|
|
||||||
|
// ⌘ over a foreign board is the cross-board move, and it lifts them out just the same.
|
||||||
|
#expect(session.resolveOperation(destinationRoot: elsewhere, modifiers: command) == .move)
|
||||||
|
#expect(session.hiddenMembers(onBoardRooted: store.rootURL) == [Self.card1, Self.card2])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The card the user filed: ⌥ says "leave the originals here", so the originals are drawn here.
|
||||||
|
@Test("A copy hides nothing — the originals re-admit into the source board's layout")
|
||||||
|
func aCopyReAdmitsTheOriginals() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
let session = cardSession(store, members: [Self.card1, Self.card2])
|
||||||
|
|
||||||
|
// The within-board ⌥-copy.
|
||||||
|
#expect(session.resolveOperation(destinationRoot: store.rootURL, modifiers: option) == .copy)
|
||||||
|
#expect(session.hiddenMembers(onBoardRooted: store.rootURL).isEmpty)
|
||||||
|
|
||||||
|
// And the cross-board default, which is a copy with no modifier at all.
|
||||||
|
#expect(session.resolveOperation(destinationRoot: elsewhere, modifiers: none) == .copy)
|
||||||
|
#expect(session.hiddenMembers(onBoardRooted: store.rootURL).isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The flip is a deliberate user action and the one-shot reflow is its feedback — which means
|
||||||
|
/// the layout has to answer *both* ways, as many times as the user asks.
|
||||||
|
@Test("Flipping the modifier mid-drag re-admits and re-lifts, every time")
|
||||||
|
func theFlipGoesBothWays() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
let session = cardSession(store)
|
||||||
|
|
||||||
|
for modifiers in [none, option, none, option] {
|
||||||
|
session.resolveOperation(destinationRoot: store.rootURL, modifiers: modifiers)
|
||||||
|
let hidden = session.hiddenMembers(onBoardRooted: store.rootURL)
|
||||||
|
#expect(hidden == (modifiers == option ? [] : [Self.card1]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: The two things the rule does not reach
|
||||||
|
|
||||||
|
/// The trash column's rows never lift, whatever the operation: they render in the column rather
|
||||||
|
/// than in a lane's masonry, so there is no resting layout of a lane's for them to leave. They
|
||||||
|
/// dim in place instead (`CardFaceView`, `TrashLaneRowView`).
|
||||||
|
@Test("A trash-container session hides nothing, under either operation")
|
||||||
|
func trashSessionsAreUnchanged() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
let session = cardSession(store, members: [ItemID(rawValue: Ident.card3)], container: .trash)
|
||||||
|
|
||||||
|
#expect(session.resolveOperation(destinationRoot: store.rootURL, modifiers: none) == .move)
|
||||||
|
#expect(session.hiddenMembers(onBoardRooted: store.rootURL).isEmpty)
|
||||||
|
|
||||||
|
#expect(session.resolveOperation(destinationRoot: store.rootURL, modifiers: option) == .copy)
|
||||||
|
#expect(session.hiddenMembers(onBoardRooted: store.rootURL).isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Only the source board hides anything, whatever the operation")
|
||||||
|
func onlyTheSourceBoardHides() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
let session = cardSession(store)
|
||||||
|
|
||||||
|
session.resolveOperation(destinationRoot: elsewhere, modifiers: command)
|
||||||
|
#expect(session.hiddenMembers(onBoardRooted: elsewhere).isEmpty)
|
||||||
|
#expect(session.hiddenMembers(onBoardRooted: store.rootURL) == [Self.card1])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The lane carve-out, read as a layout claim: a within-board lane drag never resolves to
|
||||||
|
/// `.copy`, so the strip's zones under the cursor never see a re-admitted lane and `moveLanes`
|
||||||
|
/// keeps the index space it always had. A **cross-board** lane copy does re-admit — into the
|
||||||
|
/// source strip, while the cursor is over the foreign board.
|
||||||
|
@Test("A within-board lane drag never re-admits; a cross-board lane copy does")
|
||||||
|
func laneSessionsFollowTheCarveOut() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
let session = DragSession()
|
||||||
|
session.beginLanes(
|
||||||
|
[Self.lane1],
|
||||||
|
folders: [store.rootURL.appendingPathComponent(Ident.lane1, isDirectory: true)],
|
||||||
|
units: [1],
|
||||||
|
source: store
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(session.resolveOperation(destinationRoot: store.rootURL, modifiers: option) == .move)
|
||||||
|
#expect(session.hiddenMembers(onBoardRooted: store.rootURL) == [Self.lane1])
|
||||||
|
|
||||||
|
#expect(session.resolveOperation(destinationRoot: elsewhere, modifiers: none) == .copy)
|
||||||
|
#expect(session.hiddenMembers(onBoardRooted: store.rootURL).isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: The hold freezes it
|
||||||
|
|
||||||
|
/// A release settles the arrangement, and the operation is part of the arrangement now: the
|
||||||
|
/// write that went out named one, and a stray `dropUpdated` sampling a modifier the user has
|
||||||
|
/// already let go of must not redraw the board against the other one.
|
||||||
|
@Test("A settled copy keeps its originals on screen, whatever the modifiers do next")
|
||||||
|
func aSettledCopyStaysReAdmitted() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
let session = cardSession(store)
|
||||||
|
|
||||||
|
session.resolveOperation(destinationRoot: store.rootURL, modifiers: option)
|
||||||
|
session.commit(into: store)
|
||||||
|
|
||||||
|
#expect(session.resolveOperation(destinationRoot: store.rootURL, modifiers: none) == .copy)
|
||||||
|
#expect(session.resolveOperation(destinationRoot: elsewhere, modifiers: command) == .copy)
|
||||||
|
#expect(session.operation == .copy)
|
||||||
|
#expect(session.hiddenMembers(onBoardRooted: store.rootURL).isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A settled move keeps its originals lifted, whatever the modifiers do next")
|
||||||
|
func aSettledMoveStaysLifted() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
let session = cardSession(store)
|
||||||
|
|
||||||
|
session.resolveOperation(destinationRoot: store.rootURL, modifiers: none)
|
||||||
|
session.commit(into: store)
|
||||||
|
|
||||||
|
#expect(session.resolveOperation(destinationRoot: store.rootURL, modifiers: option) == .move)
|
||||||
|
#expect(session.operation == .move)
|
||||||
|
#expect(session.hiddenMembers(onBoardRooted: store.rootURL) == [Self.card1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -231,6 +231,12 @@ struct MoveCardsTests {
|
|||||||
|
|
||||||
// MARK: - Within-board ⌥-copies
|
// MARK: - Within-board ⌥-copies
|
||||||
|
|
||||||
|
/// **A copy's index is counted among the lane's full rendered cards, originals included** — unlike
|
||||||
|
/// `moveCards`', which is counted among the cards its run vacates (DRAG-REORDER.md § Resting-layout
|
||||||
|
/// zones, ruled 2026-08-01). That is not an arbitrary split: a copy leaves its originals in the
|
||||||
|
/// source board's resting layout for the whole drag (`DragSession.hiddenMembers`), so the zones that
|
||||||
|
/// produced the number counted them too, and each writer consumes the number in the space its own
|
||||||
|
/// gesture was showing. `DragRestingLayoutTests` pins the layout half; these pin the write half.
|
||||||
@MainActor
|
@MainActor
|
||||||
@Suite("BoardStore ▸ copyCards")
|
@Suite("BoardStore ▸ copyCards")
|
||||||
struct CopyCardsTests {
|
struct CopyCardsTests {
|
||||||
@@ -242,8 +248,9 @@ struct CopyCardsTests {
|
|||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
let stamp = try stat(fixture, "\(Ident.lane1)/\(Ident.card1)")
|
let stamp = try stat(fixture, "\(Ident.lane1)/\(Ident.card1)")
|
||||||
|
|
||||||
// With First lifted the lane reads [Second, Third]; 1 is "before Third".
|
// The lane the user is looking at reads [First, Second, Third] — the ⌥-copy's originals
|
||||||
store.copyCards([card1], toLane: lane1, at: 1)
|
// stay in it — so 2 is "before Third".
|
||||||
|
store.copyCards([card1], toLane: lane1, at: 2)
|
||||||
|
|
||||||
#expect(try titles(lane1, in: fixture) == ["First", "Second", "First", "Third"])
|
#expect(try titles(lane1, in: fixture) == ["First", "Second", "First", "Third"])
|
||||||
let landed = try ids(lane1, in: fixture)
|
let landed = try ids(lane1, in: fixture)
|
||||||
@@ -253,6 +260,41 @@ struct CopyCardsTests {
|
|||||||
#expect(store.banners.oneShots.isEmpty)
|
#expect(store.banners.oneShots.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **The drop lands exactly where the shadow showed, below the dragged run included.** This is
|
||||||
|
/// the case the index space is actually about: the shadow sits *after* the original, so the
|
||||||
|
/// slot number the geometry produced counts the original on its way there, and a writer that
|
||||||
|
/// re-read the number against the originals-removed layout would push the copy one slot too far
|
||||||
|
/// — off the end of a three-card lane, in this fixture.
|
||||||
|
@Test("A copy below the dragged run lands at the shadow's slot, not past it")
|
||||||
|
func landsBelowTheRun() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
|
// Dragging First, ⌥ held, with the shadow between Second and Third: the drawn lane is
|
||||||
|
// [First, Second, ▮, Third], so the proposal is slot 2.
|
||||||
|
store.copyCards([card1], toLane: lane1, at: 2)
|
||||||
|
|
||||||
|
#expect(try titles(lane1, in: fixture) == ["First", "Second", "First", "Third"])
|
||||||
|
let landed = try ids(lane1, in: fixture)[2]
|
||||||
|
#expect(try order(fixture, "\(Ident.lane1)/\(landed)") == .valid(2560),
|
||||||
|
"between Second and Third, where the shadow was")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The end slot, in the same space: one past the last card of the layout on screen appends.
|
||||||
|
@Test("The terminal slot appends, counted among the originals like every other slot")
|
||||||
|
func theTerminalSlotAppends() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
|
store.copyCards([card1], toLane: lane1, at: 3)
|
||||||
|
|
||||||
|
#expect(try titles(lane1, in: fixture) == ["First", "Second", "Third", "First"])
|
||||||
|
let landed = try ids(lane1, in: fixture)[3]
|
||||||
|
#expect(try order(fixture, "\(Ident.lane1)/\(landed)") == .valid(4096))
|
||||||
|
}
|
||||||
|
|
||||||
@Test("A copy keeps created — a copy is a fork")
|
@Test("A copy keeps created — a copy is a fork")
|
||||||
func createdIsKept() throws {
|
func createdIsKept() throws {
|
||||||
let fixture = try makeBoard()
|
let fixture = try makeBoard()
|
||||||
@@ -274,14 +316,36 @@ struct CopyCardsTests {
|
|||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
// Index 0 in the resting layout means "before Second" — the layout the originals are lifted
|
// Index 1 is "between First and Second" in the lane as drawn, and the original First is
|
||||||
// out of. The rank has to sit between First and Second, not at First's apparently vacated
|
// genuinely still sitting at 1024 — so the rank has to land strictly inside that gap. There
|
||||||
// 1024, because First reappears the instant the write lands.
|
// is no apparently-vacated slot to choose in, which is the whole point of counting the
|
||||||
store.copyCards([card1], toLane: lane1, at: 0)
|
// index among the originals rather than around them.
|
||||||
|
store.copyCards([card1], toLane: lane1, at: 1)
|
||||||
|
|
||||||
#expect(try titles(lane1, in: fixture) == ["First", "First", "Second", "Third"])
|
#expect(try titles(lane1, in: fixture) == ["First", "First", "Second", "Third"])
|
||||||
let landed = try ids(lane1, in: fixture)[1]
|
let landed = try ids(lane1, in: fixture)[1]
|
||||||
#expect(try order(fixture, "\(Ident.lane1)/\(landed)") == .valid(1536))
|
#expect(try order(fixture, "\(Ident.lane1)/\(landed)") == .valid(1536))
|
||||||
|
#expect(try order(fixture, "\(Ident.lane1)/\(Ident.card1)") == .valid(1024),
|
||||||
|
"the original keeps its rank — nothing was written to it")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Slot 0 in the copy's space is genuinely *before* the original, which the old
|
||||||
|
/// originals-removed space could not name at all: its 0 meant "before the first survivor",
|
||||||
|
/// i.e. after the original.
|
||||||
|
@Test("Slot 0 lands the copy in front of its own original")
|
||||||
|
func slotZeroLandsInFront() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
|
store.copyCards([card1], toLane: lane1, at: 0)
|
||||||
|
|
||||||
|
#expect(try titles(lane1, in: fixture) == ["First", "First", "Second", "Third"])
|
||||||
|
let landed = try ids(lane1, in: fixture)[0]
|
||||||
|
#expect(landed != Ident.card1, "the copy is the one in front")
|
||||||
|
let rank = try #require(order(fixture, "\(Ident.lane1)/\(landed)").value)
|
||||||
|
#expect(rank < 1024, "a whole gap below the original, which keeps its own 1024")
|
||||||
|
#expect(try order(fixture, "\(Ident.lane1)/\(Ident.card1)") == .valid(1024))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("A multi-copy lands the run contiguously, in flatten order")
|
@Test("A multi-copy lands the run contiguously, in flatten order")
|
||||||
|
|||||||
Reference in New Issue
Block a user