The pickup seeds its own slot — the lift and the shadow become one transaction
"At drag start it replaces the item's original space" is now true from the very first frame: begin takes the run's own resting slot as a seed, set in the same transaction that lifts the run out. Without it the vacated gap closed un-animated and sprang back open at the first dropUpdated — a shuffle carrying no information. The seed bypasses propose deliberately (a pickup is not a new landing spot, so no alignment tick), and the first real sample's re-propose of the same slot is the early-out's ordinary silence. A ⌥-pickup seeds nothing — a copy's resting layout keeps the originals in place, so there is no vacated space to hold. Card pickups compute the seed through the same LaneView.rendered rules the retargets count in; lane pickups seed their strip slot the same way. DRAG-REORDER.md's own-slot paragraph records the rule, and SeededPickupTests pins all four corners. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
@@ -1047,3 +1047,116 @@ struct HapticTickTests {
|
||||
#expect(ticks() == afterFirstLanding + 1, "slot A to slot B is one new landing spot")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The seeded pickup
|
||||
|
||||
/// **The shadow replaces the item's original space at drag start** (DRAG-REORDER.md § The pieces):
|
||||
/// `begin` takes the pickup's own slot as a seed, so the lift-out and the shadow's arrival are one
|
||||
/// transaction with identical geometry instead of a gap that closes un-animated and springs back
|
||||
/// open at the first `dropUpdated`.
|
||||
///
|
||||
/// The seed bypasses `propose` deliberately — a pickup is not a *new landing spot*, so the
|
||||
/// alignment tick stays reserved for genuine retargets, and the first real sample's re-propose of
|
||||
/// the same slot is `propose`'s ordinary silent early-out.
|
||||
@MainActor
|
||||
@Suite("The seeded pickup")
|
||||
struct SeededPickupTests {
|
||||
|
||||
private static let lane1 = ItemID(rawValue: Ident.lane1)
|
||||
private static let card1 = ItemID(rawValue: Ident.card1)
|
||||
|
||||
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"))
|
||||
return fixture
|
||||
}
|
||||
|
||||
private func cardFolder(_ store: BoardStore) -> URL {
|
||||
store.rootURL
|
||||
.appendingPathComponent(Ident.lane1, isDirectory: true)
|
||||
.appendingPathComponent(Ident.card1, isDirectory: true)
|
||||
}
|
||||
|
||||
@Test("A seeded pickup proposes the own slot from the first frame")
|
||||
func seedIsTheProposal() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let session = DragSession()
|
||||
let seed = DropTarget(boardRoot: store.rootKey, container: .lane(Self.lane1), index: 0)
|
||||
|
||||
session.beginCards(
|
||||
[Self.card1], folders: [cardFolder(store)], heights: [44],
|
||||
container: .board, source: store, seed: seed
|
||||
)
|
||||
|
||||
#expect(session.proposal == seed, "the shadow holds the vacated space before any sample")
|
||||
#expect(
|
||||
session.laneProposal(onBoardRooted: store.rootKey, laneID: Self.lane1) == 0,
|
||||
"the lane reads the seed through the same accessor every retargeted proposal uses"
|
||||
)
|
||||
}
|
||||
|
||||
@Test("The seed is silent, and the first sample's re-propose stays silent")
|
||||
func seedDoesNotTick() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let session = DragSession()
|
||||
var count = 0
|
||||
session.hapticTick = { count += 1 }
|
||||
let seed = DropTarget(boardRoot: store.rootKey, container: .lane(Self.lane1), index: 0)
|
||||
|
||||
session.beginCards(
|
||||
[Self.card1], folders: [cardFolder(store)], heights: [44],
|
||||
container: .board, source: store, seed: seed
|
||||
)
|
||||
#expect(count == 0, "a pickup is not a new landing spot")
|
||||
|
||||
session.propose(seed)
|
||||
#expect(count == 0, "the first dropUpdated re-proposes the seeded slot — the early-out holds")
|
||||
|
||||
session.propose(DropTarget(boardRoot: store.rootKey, container: .lane(Self.lane1), index: 1))
|
||||
#expect(count == 1, "a genuine retarget still ticks exactly as before")
|
||||
}
|
||||
|
||||
@Test("An unseeded pickup still begins with no proposal")
|
||||
func noSeedIsTheOldContract() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let session = DragSession()
|
||||
let seed = DropTarget(boardRoot: store.rootKey, container: .lane(Self.lane1), index: 0)
|
||||
|
||||
session.beginCards(
|
||||
[Self.card1], folders: [cardFolder(store)], heights: [44],
|
||||
container: .board, source: store, seed: seed
|
||||
)
|
||||
// A second begin without a seed — the ⌥-pickup's shape, and every trash pickup's — must not
|
||||
// inherit the previous session's proposal.
|
||||
session.beginCards(
|
||||
[Self.card1], folders: [cardFolder(store)], heights: [44],
|
||||
container: .trash, source: store
|
||||
)
|
||||
|
||||
#expect(session.proposal == nil, "a fresh session before the first sample, exactly as documented")
|
||||
}
|
||||
|
||||
@Test("A lane pickup seeds its strip slot the same way")
|
||||
func laneSeedNamesTheStrip() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let session = DragSession()
|
||||
let seed = DropTarget(boardRoot: store.rootKey, container: .strip, index: 0)
|
||||
|
||||
session.beginLanes(
|
||||
[Self.lane1], folders: [store.rootURL.appendingPathComponent(Ident.lane1, isDirectory: true)],
|
||||
units: [1], source: store, seed: seed
|
||||
)
|
||||
|
||||
#expect(session.stripProposal(onBoardRooted: store.rootKey) == 0)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user