import Foundation import Testing @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. /// /// 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, and which folder was removed again when a batch failed. `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 import machinery 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 } } 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") } @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 directory = try sources.folder("Project") store.importAttachments([shot, directory], toCard: card1) // The batch stops at the folder, 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: "Project")) } } // 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) } } @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) } @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 directory = try sources.folder("Project") let after = try sources.file("after.txt") store.createCards(fromFiles: [shot, directory, after], inLane: lane1, at: 3) // The first file's card stands, the folder'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: "Project")) } } // 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 }