Land Finder file drops positionally, header release topmost
04's settled clauses were mostly shipped already — the create landing resolved through DropSlotMath.cardSlot with one nominal shadow per importable file — but a release on the lane header fell through to the card zones, which clamp inward, so a scrolled lane could propose behind the header stripe. FileDropZones now folds header, attach hit-test, and card-slot resolution into one pure seam asked in that order, the header answering topmost per the ruling; lane headers register their frames for it. FinderDrop.shadowCount names the floor-at-one rule. New tests pin the header boundary, a differential against cardSlot's own zones (same zones, not similar), and a store-level differential proving a file landing takes the very ranks a card move there takes. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -468,6 +468,128 @@ struct CardSlotTests {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - A Finder file drag's zones
|
||||
|
||||
/// `FileDropZones` — **"created cards land at the drop position"** (04-interactions.md ▸ Drag and
|
||||
/// drop, settled 2026-07-28), pinned on the same fixture the card zones are pinned on, because that
|
||||
/// is the claim: a file drop resolves through *the same card-grid zones an ordinary card drag uses*.
|
||||
///
|
||||
/// The lane, exactly as `CardSlotTests` reads it — 2 columns, 100pt wide, 8pt spacing, origin (0, 0):
|
||||
/// column 0 (x 0…100): card 0 [0, 40] · card 2 [48, 78] · card 4 [86, 136]
|
||||
/// column 1 (x 108…208): card 1 [0, 60] · card 3 [68, 88]
|
||||
/// Column bands meet at 104. The incoming cards have no measured height, so the zones are capped at
|
||||
/// the nominal one — `LaneDropRegistry.nominalCardHeight`, the same stand-in a cross-board arrival
|
||||
/// gets.
|
||||
/// `@MainActor` for one reason: `LaneDropRegistry.nominalCardHeight` is the app's own answer to "how
|
||||
/// tall is a card nobody has measured", and reading it here — rather than repeating the number — is
|
||||
/// what keeps these zones pinned to the height the shadows actually draw at.
|
||||
@MainActor
|
||||
@Suite("FileDropZones ▸ a Finder file drag's landing")
|
||||
struct FileDropZoneTests {
|
||||
private let placement = MasonryPlacement(columnCount: 2, columnWidth: 100, spacing: 8)
|
||||
private let heights: [CGFloat] = [40, 60, 30, 20, 50]
|
||||
private let nominal = LaneDropRegistry.nominalCardHeight
|
||||
|
||||
private func landing(
|
||||
_ x: CGFloat, _ y: CGFloat, headerBottom: CGFloat? = nil, current: Int? = nil,
|
||||
heights: [CGFloat]? = nil
|
||||
) -> FileDropZones.Landing {
|
||||
FileDropZones.landing(
|
||||
cursor: CGPoint(x: x, y: y), headerBottom: headerBottom, placement: placement,
|
||||
heights: heights ?? self.heights, nominalHeight: nominal, current: current)
|
||||
}
|
||||
|
||||
/// Every probe below that is **not** over a card, so the create branch is the one answering.
|
||||
private let emptyProbes: [(CGFloat, CGFloat, Int)] = [
|
||||
(20, 150, 5), // below column 0's last card — the end slot
|
||||
(150, 150, 5), // below column 1's last card — the end slot too
|
||||
(20, 82, 4), // the gap between card 2 and card 4, in column 0
|
||||
(150, 64, 3), // the gap between card 1 and card 3, in column 1
|
||||
(-60, 20, 0), // the lane's leading padding: still column 0
|
||||
(400, 20, 1), // and its trailing padding: column 1
|
||||
(104, 30, 1), // the gutter between the columns, which the band rule gives to column 1
|
||||
]
|
||||
|
||||
@Test("The create slot is the card-drag zone, at the incoming run's nominal footprint")
|
||||
func createIsTheCardZone() {
|
||||
for (x, y, expected) in emptyProbes {
|
||||
#expect(landing(x, y) == .create(index: expected), "(\(x), \(y))")
|
||||
// The claim itself: not "an index like the card zones'" but *the card zones'* answer.
|
||||
let card = DropSlotMath.cardSlot(
|
||||
cursor: CGPoint(x: x, y: y), placement: placement, heights: heights,
|
||||
draggedHeight: nominal, current: nil)
|
||||
#expect(landing(x, y) == .create(index: card ?? -1),
|
||||
"(\(x), \(y)) must be exactly what an ordinary card drag proposes")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("A card under the cursor attaches — anywhere on its bounds, closed at the edges")
|
||||
func attachBeatsCreate() {
|
||||
let probes: [(CGFloat, CGFloat, Int)] = [
|
||||
(20, 20, 0), (150, 20, 1), (20, 60, 2), (150, 75, 3), (20, 100, 4),
|
||||
]
|
||||
for (x, y, expected) in probes {
|
||||
for current in [nil, 0, 1, 2, 3, 4, 5] {
|
||||
#expect(landing(x, y, current: current) == .attach(index: expected),
|
||||
"(\(x), \(y)) with current \(String(describing: current))")
|
||||
}
|
||||
}
|
||||
// Closed containment: a cursor exactly on a shared edge still counts, and the first match
|
||||
// wins, so the answer is deterministic however the frames abut.
|
||||
#expect(landing(100, 40) == .attach(index: 0))
|
||||
#expect(landing(108, 0) == .attach(index: 1))
|
||||
}
|
||||
|
||||
/// **"A release on the lane header resolves to the topmost position"** (04-interactions.md,
|
||||
/// settled 2026-07-28) — forgiving beats a dead stripe.
|
||||
@Test("The lane header is the topmost position, whatever column the cursor is over")
|
||||
func headerIsTheTopmostPosition() {
|
||||
for x: CGFloat in [-60, 20, 104, 150, 400] {
|
||||
for y: CGFloat in [-500, -40, -20] {
|
||||
#expect(landing(x, y, headerBottom: -20) == .create(index: 0), "(\(x), \(y))")
|
||||
}
|
||||
}
|
||||
// The edge is the header's own, and one point below it the masonry answers again — which is
|
||||
// column 1's first row, the very reading the rule exists to override.
|
||||
#expect(landing(150, -20, headerBottom: -20) == .create(index: 0))
|
||||
#expect(landing(150, -19, headerBottom: -20) == .create(index: 1))
|
||||
}
|
||||
|
||||
@Test("Without the header rule the stripe reads as a column, which is why the rule exists")
|
||||
func noHeaderFrameLetsTheMasonryAnswer() {
|
||||
// A lane whose header has not laid out yet: the masonry clamps inward to the nearest column,
|
||||
// so the same cursor proposes column 1's first row rather than the top of the lane.
|
||||
#expect(landing(150, -40, headerBottom: nil) == .create(index: 1))
|
||||
#expect(landing(20, -40, headerBottom: nil) == .create(index: 0))
|
||||
}
|
||||
|
||||
@Test("The header is asked first, so a scrolled masonry cannot hide the stripe behind a card")
|
||||
func headerWinsOverACardBehindIt() {
|
||||
// The masonry is scroll-view content: scrolled down, a card's resting frame can compute to a
|
||||
// y the header stripe occupies. The ruling admits no exception, so the header answers.
|
||||
#expect(landing(150, 20) == .attach(index: 1), "with no header the card takes it")
|
||||
#expect(landing(150, 20, headerBottom: 50) == .create(index: 0))
|
||||
#expect(landing(20, 20, headerBottom: 50) == .create(index: 0))
|
||||
}
|
||||
|
||||
@Test("A dead region holds, and with nothing to hold the containing zone answers")
|
||||
func deadRegionHolds() {
|
||||
// A 200pt card in column 1 and the cursor in the gutter beside its far side: the nominal
|
||||
// footprint (44) does not reach there, so the zone is dead.
|
||||
let tall: [CGFloat] = [40, 200]
|
||||
#expect(landing(104, 150, current: 1, heights: tall) == .hold)
|
||||
#expect(landing(104, 150, current: nil, heights: tall) == .create(index: 1),
|
||||
"a fresh entry must still have a landing spot")
|
||||
}
|
||||
|
||||
@Test("An empty lane takes the drop at its only position, header or not")
|
||||
func emptyLane() {
|
||||
#expect(landing(10, 10, heights: []) == .create(index: 0))
|
||||
#expect(landing(400, 900, heights: []) == .create(index: 0))
|
||||
#expect(landing(150, -40, headerBottom: -20, heights: []) == .create(index: 0))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Applying a proposal
|
||||
|
||||
@Suite("DropSlotMath ▸ applying a proposal")
|
||||
|
||||
@@ -278,6 +278,42 @@ struct CreateCardsFromFilesTests {
|
||||
}
|
||||
}
|
||||
|
||||
/// **"Created cards land at the drop position — resolved through the same card-grid zones an
|
||||
/// ordinary card drag uses"** (04-interactions.md ▸ Drag and drop, settled 2026-07-28). The
|
||||
/// gesture's half of that is `FileDropZones`; the *write*'s half is this: landing at index N must
|
||||
/// produce the ranks a card dropped at index N would have produced, not merely an order that
|
||||
/// happens to read right.
|
||||
@Test("A file landing at index N takes the very rank a card dropped at index N would take")
|
||||
func ranksMatchACardDropAtTheSameIndex() throws {
|
||||
for index in 0...3 {
|
||||
// The file drop: one card minted at `index` in Todo.
|
||||
let dropped = try makeBoard()
|
||||
defer { dropped.tearDown() }
|
||||
let sources = try DropSources()
|
||||
defer { sources.tearDown() }
|
||||
let fileStore = try BoardStore(rootURL: dropped.root)
|
||||
fileStore.createCards(fromFiles: [try sources.file("dropped.txt")], inLane: lane1, at: index)
|
||||
|
||||
// The card drop: Fourth dragged out of Doing into the same slot of the same lane. Its
|
||||
// neighbours are identical, so a shared rank arithmetic must answer identically.
|
||||
let moved = try makeBoard()
|
||||
defer { moved.tearDown() }
|
||||
let cardStore = try BoardStore(rootURL: moved.root)
|
||||
cardStore.moveCards([ItemID(rawValue: Ident.card4)], toLane: lane1, at: index)
|
||||
|
||||
let droppedCards = try cards(lane1, in: dropped)
|
||||
let movedCards = try cards(lane1, in: moved)
|
||||
#expect(droppedCards.map(\.title.value)
|
||||
== movedCards.map { $0.title.value == "Fourth" ? "dropped" : $0.title.value },
|
||||
"index \(index): the run lands in the same position")
|
||||
#expect(droppedCards.map(\.order) == movedCards.map(\.order),
|
||||
"index \(index): and takes the same ranks")
|
||||
#expect(droppedCards[index].order == movedCards[index].order)
|
||||
#expect(fileStore.banners.oneShots.isEmpty)
|
||||
#expect(cardStore.banners.oneShots.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("An empty lane takes the drop at its only position")
|
||||
func emptyLane() throws {
|
||||
let fixture = try makeBoard()
|
||||
@@ -500,6 +536,18 @@ struct FinderDropFolderTests {
|
||||
@Suite("Finder drop ▸ what the drag is carrying")
|
||||
struct FinderDropPayloadTests {
|
||||
|
||||
/// A provider announcing one declared type and nothing else — the hover read's whole input.
|
||||
@MainActor
|
||||
private func provider(_ type: UTType) -> NSItemProvider {
|
||||
let provider = NSItemProvider()
|
||||
provider.registerDataRepresentation(forTypeIdentifier: type.identifier, visibility: .all) {
|
||||
completion in
|
||||
completion(Data(), nil)
|
||||
return nil
|
||||
}
|
||||
return provider
|
||||
}
|
||||
|
||||
@Test("Conformance to public.directory is the test — folders and packages alike")
|
||||
func directoryTypes() {
|
||||
#expect(FinderDrop.isDirectory(typeIdentifiers: ["public.folder", "public.file-url"]))
|
||||
@@ -526,16 +574,6 @@ struct FinderDropPayloadTests {
|
||||
@MainActor
|
||||
@Test("The importable count is the file count — folders are not counted, so they draw no shadow")
|
||||
func importableCountCountsFilesOnly() {
|
||||
func provider(_ type: UTType) -> NSItemProvider {
|
||||
let provider = NSItemProvider()
|
||||
provider.registerDataRepresentation(forTypeIdentifier: type.identifier, visibility: .all) {
|
||||
completion in
|
||||
completion(Data(), nil)
|
||||
return nil
|
||||
}
|
||||
return provider
|
||||
}
|
||||
|
||||
#expect(FinderDrop.importableCount([provider(.png), provider(.folder)]) == 1)
|
||||
#expect(FinderDrop.importableCount([provider(.png), provider(.plainText)]) == 2)
|
||||
// Zero is the refusal itself: `acceptsFileDrop` is false, so the drag never engages.
|
||||
@@ -543,6 +581,30 @@ struct FinderDropPayloadTests {
|
||||
#expect(FinderDrop.importableCount([]) == 0)
|
||||
}
|
||||
|
||||
/// **"One nominal-height shadow per incoming file"** (04-interactions.md ▸ Drag and drop, settled
|
||||
/// 2026-07-28 — the multi-drag precedent), **"and when macOS withholds item counts during hover
|
||||
/// the count floors at one shadow, the commit unaffected"**.
|
||||
@MainActor
|
||||
@Test("The shadow run is one shadow per importable file, floored at one")
|
||||
func shadowCountIsTheImportableCountFlooredAtOne() {
|
||||
#expect(FinderDrop.shadowCount([provider(.png)]) == 1)
|
||||
#expect(FinderDrop.shadowCount([provider(.png), provider(.plainText)]) == 2)
|
||||
#expect(FinderDrop.shadowCount([provider(.png), provider(.plainText), provider(.pdf)]) == 3)
|
||||
// A mixed drag's shadows agree with what will actually be minted: files only.
|
||||
#expect(FinderDrop.shadowCount([provider(.png), provider(.folder), provider(.folder)]) == 1)
|
||||
|
||||
// The floor. A drag whose providers the system will not count still opens a slot the user can
|
||||
// aim at; the write counts resolved URLs, so a lone shadow standing in for a whole drag costs
|
||||
// the drop nothing.
|
||||
#expect(FinderDrop.shadowCount([]) == 1)
|
||||
#expect(FinderDrop.shadowCount([provider(.folder), provider(.folder)]) == 1)
|
||||
|
||||
// Everywhere the count is real, it is exactly the importable count.
|
||||
for payload in [[provider(.png)], [provider(.png), provider(.folder), provider(.plainText)]] {
|
||||
#expect(FinderDrop.shadowCount(payload) == FinderDrop.importableCount(payload))
|
||||
}
|
||||
}
|
||||
|
||||
@Test("The drop reads the filesystem, which is the authority a declared type is not")
|
||||
func filesystemIsAuthoritative() throws {
|
||||
let sources = try DropSources()
|
||||
|
||||
Reference in New Issue
Block a user