Files
lanework/KanbanTests/FileDropWriteTests.swift
T
rzen 524488122f 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
2026-07-28 08:28:28 -04:00

654 lines
33 KiB
Swift

import Foundation
import Testing
import UniformTypeIdentifiers
@testable import Kanban
/// `BoardStore`'s Finder file drops — the writes an external file drag performs
/// (04-interactions.md ▸ Drag and drop, "Files from Finder"): onto a card the files join its
/// `attachments/`, onto lane empty space they become one card each, and **folders are refused**.
///
/// Like every other write suite here these drive a **real store over a real temp board** and read
/// back through the loader or the raw bytes, never through a snapshot the store handed out: the
/// interesting claims are about the files — which name a colliding attachment landed under, which
/// rank a created card took, which card was removed again when a batch failed, and which folders were
/// never attempted at all. `WriterFixture`, `Ident` and `Item` come from `WriterTestSupport.swift`.
///
/// The gesture's half — which card is under the cursor, which slot the shadows show — is
/// `BoardDropContext`'s and is not unit-testable without a live window; here the target and the index
/// are simply given, which is the same split `DragWriteTests` makes.
// MARK: - Fixtures
private func tombstoned(order: String, title: String) -> String {
"""
---
schema: 1
title: \(title)
order: \(order)
created: 2026-01-01T09:00:00Z
deleted: 2026-03-03T09:00:00Z
---
\(title) body.
"""
}
/// Three cards in the first lane, one in the second — room for a run to land at the head, between
/// siblings, or at the end without either extreme being the only answer.
@MainActor
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("\(Ident.lane1)/\(Ident.card3)", Item.rich(order: "3072", title: "Third"))
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
try fixture.item("\(Ident.lane2)/\(Ident.card4)", Item.rich(order: "1024", title: "Fourth"))
return fixture
}
/// The files being dropped, in a directory of their own **outside the board** — a Finder drag comes
/// from somewhere else by definition, and a source sitting inside the board would make "the original
/// is never a casualty" untestable.
private struct DropSources {
let root: URL
init() throws {
root = FileManager.default.temporaryDirectory
.appendingPathComponent("FileDropTests-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
}
func tearDown() {
try? FileManager.default.removeItem(at: root)
}
/// A file at `relativePath`, creating its folder.
@discardableResult
func file(_ relativePath: String, _ bytes: Data = Data([0x01])) throws -> URL {
let url = root.appendingPathComponent(relativePath)
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
try bytes.write(to: url)
return url
}
/// A *folder* — what a Finder drag of a directory hands over, which the drop refuses.
@discardableResult
func folder(_ relativePath: String) throws -> URL {
let url = root.appendingPathComponent(relativePath, isDirectory: true)
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
return url
}
/// A path with nothing at it — a **genuine** import failure, the kind the folder refusal is
/// deliberately not: the source is a file as far as anyone can tell and the copy simply fails.
func missing(_ relativePath: String) -> URL {
root.appendingPathComponent(relativePath)
}
}
private let lane1 = ItemID(rawValue: Ident.lane1)
private let lane2 = ItemID(rawValue: Ident.lane2)
private let lane3 = ItemID(rawValue: Ident.lane3)
private let card1 = ItemID(rawValue: Ident.card1)
private let card2 = ItemID(rawValue: Ident.card2)
/// The board as the loader sees it — never the store's snapshot, which a drop deliberately does not
/// touch (the one-way flow: the write lands, the watcher reloads).
private func loaded(_ fixture: WriterFixture) throws -> BoardModel {
try BoardLoader.load(boardRoot: fixture.root).model
}
private func lane(_ id: ItemID, in fixture: WriterFixture) throws -> Lane? {
try loaded(fixture).lanes.first { $0.id == id }
}
/// A lane's rendered card titles, in display order — `nil` for a card carrying no `title` key.
private func titles(_ laneID: ItemID, in fixture: WriterFixture) throws -> [String?] {
guard let lane = try lane(laneID, in: fixture) else { return [] }
return lane.cards.filter { !$0.isDeleted }.map(\.title.value)
}
/// A lane's rendered cards, in display order.
private func cards(_ laneID: ItemID, in fixture: WriterFixture) throws -> [Card] {
guard let lane = try lane(laneID, in: fixture) else { return [] }
return lane.cards.filter { !$0.isDeleted }
}
// MARK: - Attaching to a card
@MainActor
@Suite("BoardStore ▸ importAttachments(toCard:)")
struct ImportAttachmentsToCardTests {
@Test("A multi-file drop lands every file in the card's attachments, originals untouched")
func multipleFilesLand() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let sources = try DropSources()
defer { sources.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let shot = try sources.file("shot.png", Data([0x89, 0x50]))
let notes = try sources.file("notes.txt", Data([0x41, 0x42]))
store.importAttachments([shot, notes], toCard: card1)
#expect(try fixture.data("\(Ident.lane1)/\(Ident.card1)/attachments/shot.png") == Data([0x89, 0x50]))
#expect(try fixture.data("\(Ident.lane1)/\(Ident.card1)/attachments/notes.txt") == Data([0x41, 0x42]))
// The face's indicator reads the same listing the importer wrote.
#expect(try cards(lane1, in: fixture).first?.attachments == ["notes.txt", "shot.png"])
#expect(try Data(contentsOf: shot) == Data([0x89, 0x50]), "a copy's origin, never its casualty")
#expect(store.banners.oneShots.isEmpty)
}
@Test("A name already taken is renamed Finder-style rather than overwritten")
func collisionsRename() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let sources = try DropSources()
defer { sources.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let first = try sources.file("a/shot.png", Data([0x01]))
let second = try sources.file("b/shot.png", Data([0x02]))
let third = try sources.file("c/shot.png", Data([0x03]))
// Two drops, and a two-file drop whose members collide with each other — the rename counts up
// against what is on disk at decision time either way.
store.importAttachments([first], toCard: card1)
store.importAttachments([second, third], toCard: card1)
let attachments = "\(Ident.lane1)/\(Ident.card1)/attachments"
#expect(try fixture.entryNames(attachments) == ["shot 2.png", "shot 3.png", "shot.png"])
#expect(try fixture.data("\(attachments)/shot.png") == Data([0x01]))
#expect(try fixture.data("\(attachments)/shot 2.png") == Data([0x02]))
#expect(try fixture.data("\(attachments)/shot 3.png") == Data([0x03]))
#expect(store.banners.oneShots.isEmpty)
}
@Test("A tombstoned, ancestor-tombstoned, or vanished target writes nothing")
func inertTargets() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let sources = try DropSources()
defer { sources.tearDown() }
// A tombstoned card, and a live card under a tombstoned lane — effective liveness is
// ancestor-walked, so both render nowhere and both are inert.
try fixture.item("\(Ident.lane1)/\(Ident.card2)", tombstoned(order: "2048", title: "Second"))
try fixture.item(Ident.lane3, tombstoned(order: "3072", title: "Gone"))
try fixture.item("\(Ident.lane3)/\(Ident.card4)", Item.rich(order: "1024", title: "Buried"))
let store = try BoardStore(rootURL: fixture.root)
let shot = try sources.file("shot.png")
store.importAttachments([shot], toCard: card2) // tombstoned card
store.importAttachments([shot], toCard: ItemID(rawValue: Ident.card4)) // under a tombstoned lane
store.importAttachments([shot], toCard: ItemID(rawValue: Ident.indexless)) // no such card
store.importAttachments([shot], toCard: lane1) // a lane, not a card
store.importAttachments([], toCard: card1) // nothing dropped
#expect(!fixture.exists("\(Ident.lane1)/\(Ident.card2)/attachments"))
#expect(!fixture.exists("\(Ident.lane3)/\(Ident.card4)/attachments"))
#expect(!fixture.exists("\(Ident.lane1)/attachments"))
#expect(!fixture.exists("\(Ident.lane1)/\(Ident.card1)/attachments"))
#expect(store.banners.oneShots.isEmpty, "a vanished target is a silent no-op, not a failure")
}
/// The *genuine* failure path, which the folder ruling deliberately leaves alone: a source that
/// cannot be read is a write that did not happen, so it banners as a one-shot and stops the batch
/// — 04's folder refusal happens a layer above this and never reaches it (`FinderDrop`).
@Test("A source that cannot be imported banners and leaves nothing half-copied")
func aFailingSourceBanners() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let sources = try DropSources()
defer { sources.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let shot = try sources.file("shot.png")
let gone = sources.missing("gone.png")
store.importAttachments([shot, gone], toCard: card1)
// The batch stops at the unreadable source, and what landed before it stays landed.
#expect(try fixture.entryNames("\(Ident.lane1)/\(Ident.card1)/attachments") == ["shot.png"])
#expect(store.banners.oneShots.count == 1)
#expect(store.banners.oneShots.first?.error.operation == .importAttachment(filename: "gone.png"))
#expect(store.banners.losses.isEmpty, "a failure is a one-shot; a loss row says nothing failed")
}
}
// MARK: - Creating cards from files
@MainActor
@Suite("BoardStore ▸ createCards(fromFiles:inLane:at:)")
struct CreateCardsFromFilesTests {
@Test("One card per file, titled without its extension, each carrying its own file")
func oneCardPerFile() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let sources = try DropSources()
defer { sources.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let shot = try sources.file("shot.png", Data([0x01]))
let notes = try sources.file("meeting notes.txt", Data([0x02]))
// Between First and Second: the drop position, the same index the card zones produce.
store.createCards(fromFiles: [shot, notes], inLane: lane1, at: 1)
#expect(try titles(lane1, in: fixture) == ["First", "shot", "meeting notes", "Second", "Third"])
let landed = try cards(lane1, in: fixture)
#expect(landed.count == 5)
// Fresh GUIDs — nothing was reused from the board's own identities.
let created = [landed[1], landed[2]]
#expect(!created.contains { [Ident.card1, Ident.card2, Ident.card3].contains($0.id.rawValue) })
#expect(created[0].attachments == ["shot.png"])
#expect(created[1].attachments == ["meeting notes.txt"])
#expect(try fixture.data("\(Ident.lane1)/\(created[0].id.rawValue)/attachments/shot.png") == Data([0x01]))
#expect(try fixture.data("\(Ident.lane1)/\(created[1].id.rawValue)/attachments/meeting notes.txt") == Data([0x02]))
// Ranks are inserted, never permuted: the run sits strictly between its neighbours.
#expect(created.allSatisfy { $0.order > 1024 && $0.order < 2048 })
#expect(store.banners.oneShots.isEmpty)
}
/// One board per case, deliberately: a drop does not touch the snapshot (the one-way flow — the
/// write lands, the watcher reloads), so two drops against one store would both be placed against
/// the board as it was before either of them.
@Test("The index is the drop position — head, interior, end, and past the end all land where they name")
func indexPositioning() throws {
let cases: [(index: Int, expected: [String?])] = [
(0, ["dropped", "First", "Second", "Third"]),
(2, ["First", "Second", "dropped", "Third"]),
(3, ["First", "Second", "Third", "dropped"]),
// A proposal computed against a snapshot one reload old must not trap: it clamps.
(99, ["First", "Second", "Third", "dropped"]),
]
for (index, expected) in cases {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let sources = try DropSources()
defer { sources.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.createCards(fromFiles: [try sources.file("dropped.txt")], inLane: lane1, at: index)
#expect(try titles(lane1, in: fixture) == expected, "index \(index)")
#expect(store.banners.oneShots.isEmpty)
}
}
/// **"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()
defer { fixture.tearDown() }
let sources = try DropSources()
defer { sources.tearDown() }
try fixture.item(Ident.lane3, Item.rich(order: "3072", title: "Done"))
let store = try BoardStore(rootURL: fixture.root)
store.createCards(fromFiles: [try sources.file("only.md")], inLane: lane3, at: 0)
#expect(try titles(lane3, in: fixture) == ["only"])
#expect(try cards(lane3, in: fixture).first?.order == 1024, "the board convention")
#expect(store.banners.oneShots.isEmpty)
}
@Test("A filename that trims to nothing writes no title key at all")
func blankTitleOmitsTheKey() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let sources = try DropSources()
defer { sources.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.createCards(fromFiles: [try sources.file(" .png")], inLane: lane2, at: 1)
#expect(try titles(lane2, in: fixture) == ["Fourth", nil], "a missing key, never an empty string")
let created = try #require(try cards(lane2, in: fixture).last)
#expect(!(try fixture.indexText("\(Ident.lane2)/\(created.id.rawValue)").contains("title:")))
#expect(created.attachments == [" .png"])
#expect(store.banners.oneShots.isEmpty)
}
@Test("Exhausted midpoint precision compacts the lane, then places against the fresh ranks")
func renumberFallback() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let sources = try DropSources()
defer { sources.tearDown() }
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
// Two cards sharing one rank — the duplicate-order tie, where no midpoint exists. Display
// order falls to the folder-name tie-break, so card1 renders before card2.
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "1024", title: "Second"))
let store = try BoardStore(rootURL: fixture.root)
store.createCards(fromFiles: [try sources.file("between.txt")], inLane: lane1, at: 1)
#expect(try titles(lane1, in: fixture) == ["First", "between", "Second"])
#expect(try order(fixture, "\(Ident.lane1)/\(Ident.card1)") == .valid(1024))
#expect(try order(fixture, "\(Ident.lane1)/\(Ident.card2)") == .valid(2048), "the lane was compacted first")
#expect(store.banners.oneShots.isEmpty)
}
@Test("A tombstoned, vanished, or empty destination writes nothing")
func inertDestinations() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let sources = try DropSources()
defer { sources.tearDown() }
try fixture.item(Ident.lane3, tombstoned(order: "3072", title: "Gone"))
let store = try BoardStore(rootURL: fixture.root)
let shot = try sources.file("shot.png")
store.createCards(fromFiles: [shot], inLane: lane3, at: 0) // tombstoned
store.createCards(fromFiles: [shot], inLane: ItemID(rawValue: Ident.indexless), at: 0) // no such lane
store.createCards(fromFiles: [], inLane: lane1, at: 0) // nothing dropped
#expect(try fixture.entryNames(Ident.lane3) == ["index.md"])
#expect(try titles(lane1, in: fixture) == ["First", "Second", "Third"])
#expect(store.banners.oneShots.isEmpty)
}
/// The mint-fail-remove dance, on the one path that still needs it: a *genuine* mid-batch failure.
/// Folders never get this far any more (the drop partitions them out — `FinderDrop`), but a source
/// that vanished between the drag and the drop still can, and the card minted for it must not
/// survive its own import.
@Test("A file that fails mid-batch banners, keeps the cards already made, and leaves no half-made one")
func partialFailureLeavesNoHalfMadeCard() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let sources = try DropSources()
defer { sources.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let shot = try sources.file("shot.png")
let gone = sources.missing("gone.png")
let after = try sources.file("after.txt")
store.createCards(fromFiles: [shot, gone, after], inLane: lane1, at: 3)
// The first file's card stands, the failed one's card was removed again, and the batch
// stopped before the third — so the lane grew by exactly one.
#expect(try titles(lane1, in: fixture) == ["First", "Second", "Third", "shot"])
let landed = try cards(lane1, in: fixture)
#expect(landed.count == 4)
#expect(try fixture.entryNames(Ident.lane1).count == 5, "index.md, three cards, one new one")
#expect(landed.last?.attachments == ["shot.png"])
#expect(store.banners.oneShots.count == 1)
#expect(store.banners.oneShots.first?.error.operation == .importAttachment(filename: "gone.png"))
}
}
// MARK: - Folders, refused
/// **"Folders are refused at hover"** (04-interactions.md ▸ Drag and drop, settled 2026-07-28): the
/// attachment model is flat top-level files, so a drag of folders is an incompatible payload rather
/// than a failed import. The hover half — a folders-only drag never engaging — is
/// `BoardDropContext.acceptsFileDrop`'s and needs a live window; what is testable here is the drop
/// itself: `FinderDrop.land`, the write half `commitFileDrop` runs once the URLs have resolved.
///
/// The claim every case below shares: **the files land, the folders are named, and nothing failed** —
/// a loss row (warning tone), never an error one-shot.
@MainActor
@Suite("Finder drop ▸ folders are refused")
struct FinderDropFolderTests {
@Test("A mixed drop on a card imports its file and names the folder it skipped")
func mixedAttachDrop() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let sources = try DropSources()
defer { sources.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let shot = try sources.file("shot.png", Data([0x89, 0x50]))
let directory = try sources.folder("Project")
FinderDrop.land([shot, directory], landing: .attach(cardID: card1), into: store)
// The file imported whole — the folder did not abort the batch, it was never in it.
#expect(try fixture.entryNames("\(Ident.lane1)/\(Ident.card1)/attachments") == ["shot.png"])
#expect(try fixture.data("\(Ident.lane1)/\(Ident.card1)/attachments/shot.png") == Data([0x89, 0x50]))
#expect(!fixture.exists("\(Ident.lane1)/\(Ident.card1)/attachments/Project"))
#expect(store.banners.oneShots.isEmpty, "nothing failed: the folder was never attempted")
#expect(store.banners.losses.map(\.message) == ["Folders can't be attached — 1 skipped"])
}
@Test("A mixed drop on lane empty space makes one card per file and none for the folder")
func mixedCreateDrop() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let sources = try DropSources()
defer { sources.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let shot = try sources.file("shot.png")
let directory = try sources.folder("Project")
let after = try sources.file("after.txt")
FinderDrop.land([shot, directory, after], landing: .create(laneID: lane1, index: 3), into: store)
// Two cards, in drop order, and no half-made third: the file *after* the folder imports,
// which is the whole difference from the old abort-at-the-folder behaviour.
#expect(try titles(lane1, in: fixture) == ["First", "Second", "Third", "shot", "after"])
let landed = try cards(lane1, in: fixture)
#expect(landed.count == 5)
#expect(try fixture.entryNames(Ident.lane1).count == 6, "index.md, three cards, two new ones")
#expect(landed[3].attachments == ["shot.png"])
#expect(landed[4].attachments == ["after.txt"])
#expect(store.banners.oneShots.isEmpty)
#expect(store.banners.losses.map(\.message) == ["Folders can't be attached — 1 skipped"])
}
/// Unreachable through the gesture — a folders-only drag never engages — but reachable when the
/// hover read could not classify the payload (a provider carrying only `public.file-url`), so it
/// has a defined answer: no write at all, just the loss row.
@Test("A folders-only drop writes nothing at all and only names the loss")
func foldersOnly() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let sources = try DropSources()
defer { sources.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let first = try sources.folder("Project")
let second = try sources.folder("Archive")
FinderDrop.land([first, second], landing: .create(laneID: lane1, index: 0), into: store)
#expect(try titles(lane1, in: fixture) == ["First", "Second", "Third"], "no card was minted")
#expect(try fixture.entryNames(Ident.lane1).count == 4, "index.md and the three cards")
#expect(store.banners.oneShots.isEmpty)
#expect(store.banners.losses.map(\.message) == ["Folders can't be attached — 2 skipped"])
}
@Test("A folders-only drop on a card leaves it without an attachments folder at all")
func foldersOnlyOnACard() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let sources = try DropSources()
defer { sources.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
FinderDrop.land([try sources.folder("Project")], landing: .attach(cardID: card1), into: store)
#expect(!fixture.exists("\(Ident.lane1)/\(Ident.card1)/attachments"), "no write, not an empty one")
#expect(store.banners.oneShots.isEmpty)
#expect(store.banners.losses.map(\.message) == ["Folders can't be attached — 1 skipped"])
}
@Test("An all-files drop says nothing — a drop that lost nothing has nothing to report")
func allFilesIsSilent() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let sources = try DropSources()
defer { sources.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
FinderDrop.land([try sources.file("shot.png")], landing: .attach(cardID: card1), into: store)
#expect(try fixture.entryNames("\(Ident.lane1)/\(Ident.card1)/attachments") == ["shot.png"])
#expect(store.banners.losses.isEmpty)
#expect(store.banners.oneShots.isEmpty)
}
}
// MARK: - What the drag is carrying
/// The two reads `FinderDrop` makes of a Finder drag: the **declared types** at hover, which decide
/// whether the drag engages at all, and the **filesystem** at the drop, which decides what is
/// written. 04's refusal rule is a hover rule, so the first one is what gives it its cursor.
@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"]))
// A package is a directory, and the flat attachment model has no more room for one than for
// a plain folder: a .app, a bundle, an .rtfd.
#expect(FinderDrop.isDirectory(typeIdentifiers: ["com.apple.application-bundle", "public.file-url"]))
#expect(FinderDrop.isDirectory(typeIdentifiers: ["com.apple.package"]))
#expect(!FinderDrop.isDirectory(typeIdentifiers: ["public.png", "public.file-url"]))
#expect(!FinderDrop.isDirectory(typeIdentifiers: ["public.data", "public.item"]))
}
@Test("An unclassifiable payload reads as a file — optimistic at hover, sorted out at the drop")
func unknownTypesAreOptimistic() {
// The synthetic shape: a provider registering the URL type and nothing concrete.
#expect(!FinderDrop.isDirectory(typeIdentifiers: ["public.file-url", "public.url"]))
#expect(!FinderDrop.isDirectory(typeIdentifiers: []))
#expect(!FinderDrop.isDirectory(typeIdentifiers: ["not.a.real.type"]))
// `NSItemProvider(contentsOf:)` gives a folder a *dynamic* type rather than public.folder,
// which is exactly the case the optimistic read exists for: such a drag still engages, and
// the drop's own read is what refuses it.
#expect(!FinderDrop.isDirectory(typeIdentifiers: ["dyn.age8u"]))
}
@MainActor
@Test("The importable count is the file count — folders are not counted, so they draw no shadow")
func importableCountCountsFilesOnly() {
#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.
#expect(FinderDrop.importableCount([provider(.folder), provider(.folder)]) == 0)
#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()
defer { sources.tearDown() }
let shot = try sources.file("shot.png")
let notes = try sources.file("notes.txt")
let project = try sources.folder("Project")
let bundle = try sources.folder("Thing.app") // a package: a directory like any other
#expect(FinderDrop.isDirectory(at: project))
#expect(FinderDrop.isDirectory(at: bundle))
#expect(!FinderDrop.isDirectory(at: shot))
let (files, folders) = FinderDrop.partition([shot, project, notes, bundle])
#expect(files == [shot, notes], "input order survives — the create path mints in drop order")
#expect(folders == [project, bundle])
}
}
// MARK: - The title rule
@Suite("BoardStore ▸ cardTitle(forFile:)")
struct CardTitleForFileTests {
@Test("The filename without its extension — URL's split, which is Finder's")
func titles() {
func title(_ name: String) -> String? {
BoardStore.cardTitle(forFile: URL(fileURLWithPath: "/sources/\(name)"))
}
#expect(title("shot.png") == "shot")
#expect(title("meeting notes.txt") == "meeting notes")
// A multi-dot name loses only its last component, exactly as the collision rename splits it.
#expect(title("archive.tar.gz") == "archive.tar")
#expect(title("notes") == "notes", "an extension-less name keeps all of itself")
#expect(title(".gitignore") == ".gitignore", "a dotfile is a name, not an extension")
#expect(title(" spaced .png") == "spaced", "trimmed, like every other title the app commits")
#expect(title(" .png") == nil, "trims to nothing: no title key, never an empty string")
}
}
// MARK: - Raw reads
private func order(_ fixture: WriterFixture, _ relativePath: String) throws -> FieldValue<Double> {
try FrontmatterDocument.parse(fixture.indexText(relativePath)).order
}