Files from Finder land on the board per 04-interactions.md § Drag & drop: - Dropped on a card, they copy into its attachments/ (any type, multi-file), the face highlighting while hovered; the hovered card is resolved by hit-testing the same analytic masonry frames the card zones are built from, so attach-beats-create adds no drop region and cannot drift from the dispatch. - Dropped on lane empty space, one card per file — filename minus extension as the title (a blank stem omits the key), fresh GUID, rank at the drop position through the ordinary insertion machinery, the file attached — all in one bracket; a failed import removes the just-minted card, so creating-then-abandoning never leaves an empty card behind. - Every board drop surface now declares .fileURL beside the two board types (the single-target-dispatch rule); tombstoned surfaces are inert; file sessions ride a distinct session mode with their own watchdog and no hysteresis, leaving the board-drag machinery untouched. - Also: four empty fixture directories pinned with .keep files so git preserves them, and a test-only visibility fix in DragSessionTests. 811 unit tests (12 new). Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
396 lines
19 KiB
Swift
396 lines
19 KiB
Swift
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<Double> {
|
|
try FrontmatterDocument.parse(fixture.indexText(relativePath)).order
|
|
}
|