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:
2026-08-01 08:07:54 -04:00
parent adf4fc5973
commit 8206a78ecc
9 changed files with 383 additions and 51 deletions
+70 -6
View File
@@ -231,6 +231,12 @@ struct MoveCardsTests {
// 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
@Suite("BoardStore ▸ copyCards")
struct CopyCardsTests {
@@ -242,8 +248,9 @@ struct CopyCardsTests {
let store = try BoardStore(rootURL: fixture.root)
let stamp = try stat(fixture, "\(Ident.lane1)/\(Ident.card1)")
// With First lifted the lane reads [Second, Third]; 1 is "before Third".
store.copyCards([card1], toLane: lane1, at: 1)
// The lane the user is looking at reads [First, Second, Third] the -copy's originals
// 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"])
let landed = try ids(lane1, in: fixture)
@@ -253,6 +260,41 @@ struct CopyCardsTests {
#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")
func createdIsKept() throws {
let fixture = try makeBoard()
@@ -274,14 +316,36 @@ struct CopyCardsTests {
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
// Index 0 in the resting layout means "before Second" the layout the originals are lifted
// out of. The rank has to sit between First and Second, not at First's apparently vacated
// 1024, because First reappears the instant the write lands.
store.copyCards([card1], toLane: lane1, at: 0)
// Index 1 is "between First and Second" in the lane as drawn, and the original First is
// genuinely still sitting at 1024 so the rank has to land strictly inside that gap. There
// is no apparently-vacated slot to choose in, which is the whole point of counting the
// 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"])
let landed = try ids(lane1, in: fixture)[1]
#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")