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) } /// **Attaching is not creating, so the query stands** (04-interactions.md § Search): the /// carve-out is for an item that would otherwise be born invisible, and a drop on a card mints /// nothing. Its twin — the *create* half clearing — is `creatingFromFilesClearsTheSearch` below. @Test("A drop that only attaches leaves the search exactly where it was") func attachingLeavesTheSearch() 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") store.searchQuery = "first" store.importAttachments([shot], toCard: card1) #expect(store.searchQuery == "first") } @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 trashed or vanished target writes nothing") func inertTargets() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let sources = try DropSources() defer { sources.tearDown() } // "Finder file drops on trash cards are inert" (04 ▸ The trash), and a card whose lane was // deleted is simply not there. try fixture.move("\(Ident.lane1)/\(Ident.card2)", toTrash: Ident.card2) let store = try BoardStore(rootURL: fixture.root) let shot = try sources.file("shot.png") store.importAttachments([shot], toCard: card2) // a trash card store.importAttachments([shot], toCard: ItemID(rawValue: Ident.card4)) // its lane is gone 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(".trash/\(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 deleted, vanished, or empty destination writes nothing") func inertDestinations() 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") store.createCards(fromFiles: [shot], inLane: lane3, at: 0) // no such lane store.createCards(fromFiles: [shot], inLane: ItemID(rawValue: Ident.indexless), at: 0) // no such lane store.createCards(fromFiles: [], inLane: lane1, at: 0) // nothing dropped #expect(!fixture.exists(Ident.lane3)) #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")) } /// **The create half is a user-initiated creation, so it clears the query** (04-interactions.md /// § Search, stated by mechanism: "⌘N, Return-creation, the header button, empty-space /// double-click, paste, and Finder file drops alike"). The card is titled `shot`, which the /// standing query would hide — the whole point of the carve-out. @Test("A file drop that creates cards clears the search — the same rule ⌘N obeys") func creatingFromFilesClearsTheSearch() 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") store.searchQuery = "first" store.createCards(fromFiles: [shot], inLane: lane1, at: 1) #expect(store.searchQuery.isEmpty) } /// The other side of the same rule: a drop that creates nothing clears nothing. A destination the /// reload took away is not a creation either — nothing was minted, so nothing could be born /// invisible. @Test("A drop with no destination creates nothing and leaves the query standing") func aRefusedCreateLeavesTheSearch() 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") store.searchQuery = "first" store.createCards(fromFiles: [shot], inLane: ItemID(rawValue: "no-such-lane"), at: 0) #expect(store.searchQuery == "first") } } // 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 { try FrontmatterDocument.parse(fixture.indexText(relativePath)).order }