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:
@@ -654,3 +654,206 @@ struct DropSettleTests {
|
||||
#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
|
||||
|
||||
/// **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")
|
||||
|
||||
Reference in New Issue
Block a user