Files
lanework/KanbanTests/CardAttachmentsTests.swift
T
rzen 46397c740e Build the attachments sidebar section
The card's complete file inventory: compact QuickLook-thumbnail rows
over Card.attachments — no reference tracking, subfolders tolerated
and unsurfaced — with a quiet header add affordance and the drop hint
empty state. The whole window is the file-drop surface, Edit mode
included (the editor's drag types were already filtered; now tested),
sharing the board's folder-refusal semantics literally: FinderDrop
moved verbatim into its own file so both windows run the same
partition and loss row. Dragged text still lands at the caret and is
inert elsewhere — the window delegate accepts file payloads only.
Rows open on double-click or Return, drag out their file URL, and
Remove is a bracketed write through FileManager.trashItem — the system
Trash, never a hard delete, returning the in-Trash URL so the promise
is testable; the attachment listing is the guard, so traversal and
subfolder names refuse in one line. Keyboard-native per 05: the
section is one Tab stop, arrows walk rows by name, Space toggles the
shared QuickLook panel, Backspace removes. File > Add Attachment
(shift-cmd-A) comes alive through the same import path.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-28 11:52:37 -04:00

453 lines
22 KiB
Swift

import AppKit
import Foundation
import Testing
import UniformTypeIdentifiers
@testable import Kanban
/// The card window's **Attachments** section (05-card-window.md ▸ Attachments) — everything about it
/// that is not a pixel.
///
/// Four things are covered here and the split is deliberate:
///
/// - **The remove write**, end to end: a file leaves `attachments/` and arrives in the *system*
/// Trash, the store's version rides the write bracket, and a failure banners with the Finder
/// phrasing the naming constraint reserves for exactly this operation.
/// - **Add Attachment's target resolution** — the seam `CardWindowHost` fills in, driven as the real
/// wiring rather than a re-typed copy of it, exactly as `RawSourceTests` drives
/// `configureRawSource`.
/// - **The payload split**, as the pure predicate it is: file drags are the window's, text drags are
/// the editor's, folders refuse.
/// - **The section's pure rules** — arrow movement, what a selection does when the listing changes
/// under it, and what Reveal in Finder points at.
///
/// What is deliberately *not* here: the import path itself (already covered board-side —
/// `FileDropWriteTests`, including the Finder-style collision rename this section reuses verbatim),
/// the listing (already covered loader-side — `BoardLoaderTests` ▸ Card attachments, and the section
/// reads that very field rather than a seam of its own), and the view layer's keyboard and QuickLook,
/// which need a window.
// MARK: - Fixtures
@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"))
return fixture
}
private let lane1 = ItemID(rawValue: Ident.lane1)
private let card1 = ItemID(rawValue: Ident.card1)
private let card2 = ItemID(rawValue: Ident.card2)
private func cardFolder(_ fixture: WriterFixture, _ card: String = Ident.card1) -> URL {
fixture.url("\(Ident.lane1)/\(card)")
}
/// A name nothing else in the user's Trash can already be called.
///
/// Every test below trashes into the **real** `~/.Trash`: `trashItem` has nowhere else to put a file
/// that lives in a temp directory on the boot volume, and faking it would be faking the one thing
/// these tests exist to prove. Unique names mean the tidy-up can only ever remove this run's own
/// residue, and a Trash that already held a `shot.png` is never touched.
private func uniqueName(_ stem: String, _ ext: String) -> String {
"\(stem)-\(UUID().uuidString).\(ext)"
}
/// Takes a trashed file back out of `~/.Trash`, and answers whether it was there — **the assertion
/// and the tidy-up in one call**, because they want the same lookup: a file that can be purged is a
/// file that reached the Trash rather than being hard-deleted.
@discardableResult
private func purgeFromTrash(_ url: URL?) -> Bool {
guard let url, FileManager.default.fileExists(atPath: url.path) else { return false }
try? FileManager.default.removeItem(at: url)
return true
}
/// The same, by name, for the paths that do not hand the resulting URL back (the store's).
@discardableResult
private func purgeFromTrash(named name: String) -> Bool {
guard let trash = try? FileManager.default.url(
for: .trashDirectory, in: .userDomainMask, appropriateFor: nil, create: false
) else { return false }
return purgeFromTrash(trash.appendingPathComponent(name))
}
// MARK: - The remove write
@MainActor
@Suite("BoardWriter ▸ removeAttachment(named:fromCard:)")
struct RemoveAttachmentWriterTests {
/// The promise in one test: the file leaves `attachments/`, its siblings do not, and it is
/// **still on disk** — in the Trash, where the user can put it back without this app's help
/// (05-card-window.md ▸ Attachments: "moves to the **system** Trash, never hard-deletes").
@Test("The file leaves attachments/ and arrives in the system Trash")
func movesToTheSystemTrash() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let doomed = uniqueName("shot", "png")
try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/\(doomed)", Data([0x01]))
try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/notes.txt", Data([0x02]))
let trashed = try BoardWriter.removeAttachment(named: doomed, fromCard: cardFolder(fixture))
#expect(try fixture.entryNames("\(Ident.lane1)/\(Ident.card1)/attachments") == ["notes.txt"])
#expect(purgeFromTrash(trashed), "moved to the Trash, never hard-deleted")
}
/// **The listing is the guard**, and this is the whole class of things it refuses in one test:
/// a subfolder, a file inside one, a hidden file, a path, and a name that is simply not there.
/// None of them is a failure — the reload is the authority on what a card has — so nothing is
/// thrown, nothing is written, and the card's own `index.md` is never opened.
@Test("Anything the listing does not show is a silent no-op")
func onlyTopLevelFilesAreRemovable() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let card = cardFolder(fixture)
let attachments = "\(Ident.lane1)/\(Ident.card1)/attachments"
try fixture.file("\(attachments)/keep.png", Data([0x01]))
try fixture.file("\(attachments)/sub/nested.txt", Data([0x02]))
try fixture.file("\(attachments)/.DS_Store", Data([0x03]))
let before = try fixture.indexData("\(Ident.lane1)/\(Ident.card1)")
for name in ["sub", "sub/nested.txt", ".DS_Store", "gone.png", "", "../index.md"] {
#expect(try BoardWriter.removeAttachment(named: name, fromCard: card) == nil)
}
#expect(try fixture.entryNames(attachments).sorted() == [".DS_Store", "keep.png", "sub"])
#expect(try fixture.data("\(attachments)/sub/nested.txt") == Data([0x02]))
#expect(try fixture.indexData("\(Ident.lane1)/\(Ident.card1)") == before)
}
/// Attachments belong to cards: a lane folder, a board root and a folder that is not there at
/// all are refused before anything is looked at — `importAttachments`' own guards, since this is
/// its inverse.
@Test("A folder that is not a card is refused")
func refusesNonCards() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let atRoot = writeFailure {
_ = try BoardWriter.removeAttachment(named: "shot.png", fromCard: fixture.root)
}
#expect(atRoot?.operation == .removeAttachment(filename: "shot.png"))
let missing = writeFailure {
_ = try BoardWriter.removeAttachment(
named: "shot.png",
fromCard: fixture.url("\(Ident.lane1)/\(Ident.indexless)-nope")
)
}
#expect(missing != nil)
}
}
// MARK: - The store's half
@MainActor
@Suite("BoardStore ▸ removeAttachment(named:fromCard:)")
struct RemoveAttachmentStoreTests {
/// **A bracketed write like every other**: it goes out through `performWrite`, so the churn
/// rounds back as one app-mediated reload, and a success says nothing at all.
@Test("Removing rides the write bracket and posts nothing on success")
func removesThroughTheBracket() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let doomed = uniqueName("shot", "png")
try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/\(doomed)", Data([0x01]))
let store = try BoardStore(rootURL: fixture.root)
store.removeAttachment(named: doomed, fromCard: card1)
#expect(try fixture.entryNames("\(Ident.lane1)/\(Ident.card1)/attachments").isEmpty)
#expect(purgeFromTrash(named: doomed), "the store's remove is the Trash's, not a delete")
#expect(store.banners.oneShots.isEmpty)
#expect(store.banners.losses.isEmpty)
}
/// The vanished-target guard every gesture in the store makes, ancestor-walked: a tombstoned
/// card, a live card under a tombstoned lane, a card that isn't there, a *lane* id, and an empty
/// name all write nothing and say nothing.
@Test("A tombstoned, ancestor-tombstoned, or vanished target writes nothing")
func inertTargets() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.item("\(Ident.lane1)/\(Ident.card2)", """
---
schema: 1
title: Second
order: 2048
created: 2026-01-01T09:00:00Z
deleted: 2026-03-03T09:00:00Z
---
Second body.
""")
try fixture.file("\(Ident.lane1)/\(Ident.card2)/attachments/shot.png", Data([0x01]))
try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/shot.png", Data([0x02]))
let store = try BoardStore(rootURL: fixture.root)
store.removeAttachment(named: "shot.png", fromCard: card2) // tombstoned
store.removeAttachment(named: "shot.png", fromCard: ItemID(rawValue: Ident.indexless)) // no such card
store.removeAttachment(named: "shot.png", fromCard: lane1) // a lane
store.removeAttachment(named: "", fromCard: card1) // no name
#expect(try fixture.entryNames("\(Ident.lane1)/\(Ident.card1)/attachments") == ["shot.png"])
#expect(try fixture.entryNames("\(Ident.lane1)/\(Ident.card2)/attachments") == ["shot.png"])
#expect(store.banners.oneShots.isEmpty, "a vanished target is a silent no-op, not a failure")
}
/// A genuine failure banners, in **Finder's** words — the one operation in this app allowed
/// "move to the Trash", because it is the one that uses the system Trash (03-board-ui.md
/// § Trash's naming constraint). The file stays exactly where it was.
@Test("A remove that cannot happen banners with the system-Trash phrasing")
func aFailingRemoveBanners() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let attachments = "\(Ident.lane1)/\(Ident.card1)/attachments"
try fixture.file("\(attachments)/shot.png", Data([0x01]))
let store = try BoardStore(rootURL: fixture.root)
// Unlinking is the *parent* directory's permission, so this is what a folder the user
// cannot write to looks like from here.
try FileManager.default.setAttributes([.posixPermissions: 0o555], ofItemAtPath: fixture.url(attachments).path)
store.removeAttachment(named: "shot.png", fromCard: card1)
#expect(try fixture.entryNames(attachments) == ["shot.png"], "a failed remove leaves the file")
#expect(store.banners.oneShots.count == 1)
#expect(store.banners.oneShots.first?.error.operation == .removeAttachment(filename: "shot.png"))
#expect(
BannerCenter.headline(for: BoardWriteError(
operation: .removeAttachment(filename: "shot.png"),
path: "/x",
reason: .io(message: "permission denied")
)).hasPrefix("Couldn't move 'shot.png' to the Trash")
)
}
}
// MARK: - Add Attachment's target resolution
@MainActor
@Suite("CardWindowHost ▸ configureAttachments")
struct AddAttachmentTargetTests {
/// **The window's card, and no other** — the whole of the wiring's job. Driven through the real
/// `configureAttachments`, so the test breaks if the two seams are ever crossed or the id is
/// captured from the wrong place.
@Test("Adding imports into the window's own card, through the board's import path")
func importsIntoItsOwnCard() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let sources = try WriterFixture()
defer { sources.tearDown() }
let shot = try sources.file("shot.png", Data([0x89, 0x50]))
let store = try BoardStore(rootURL: fixture.root)
let attachments = CardAttachments()
CardWindowHost.configureAttachments(attachments, store: store, cardID: card1)
attachments.importFiles?([shot])
#expect(try fixture.data("\(Ident.lane1)/\(Ident.card1)/attachments/shot.png") == Data([0x89, 0x50]))
#expect(!fixture.exists("\(Ident.lane1)/\(Ident.card2)/attachments"))
#expect(store.banners.oneShots.isEmpty)
}
@Test("Removing takes the file out of the window's own card")
func removesFromItsOwnCard() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let doomed = uniqueName("shot", "png")
try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/\(doomed)", Data([0x01]))
try fixture.file("\(Ident.lane1)/\(Ident.card2)/attachments/\(doomed)", Data([0x02]))
let store = try BoardStore(rootURL: fixture.root)
let attachments = CardAttachments()
CardWindowHost.configureAttachments(attachments, store: store, cardID: card1)
attachments.removeFile?(doomed)
#expect(try fixture.entryNames("\(Ident.lane1)/\(Ident.card1)/attachments").isEmpty)
#expect(try fixture.entryNames("\(Ident.lane1)/\(Ident.card2)/attachments") == [doomed])
purgeFromTrash(named: doomed)
}
/// The handle's own gates, in front of the seams: under the read-only lock and before the window
/// has a folder, `remove` does nothing at all — 02-architecture.md's every-entry-point predicate,
/// which is also `AddAttachmentCommand`'s validation.
@Test("The lock disables the section's writes, and the menu row with them")
func theLockDisablesTheSectionsWrites() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/shot.png", Data([0x01]))
let store = try BoardStore(rootURL: fixture.root)
let attachments = CardAttachments()
CardWindowHost.configureAttachments(attachments, store: store, cardID: card1)
attachments.cardFolder = cardFolder(fixture)
attachments.names = ["shot.png"]
attachments.isEditable = false
attachments.remove("shot.png")
#expect(try fixture.entryNames("\(Ident.lane1)/\(Ident.card1)/attachments") == ["shot.png"])
#expect(!AddAttachmentCommand.isEnabled(attachments))
attachments.isEditable = true
#expect(AddAttachmentCommand.isEnabled(attachments))
#expect(!AddAttachmentCommand.isEnabled(nil), "no card window in front, no row")
// A window with no folder yet has nothing to add *to*.
attachments.cardFolder = nil
#expect(!AddAttachmentCommand.isEnabled(attachments))
}
}
// MARK: - The payload split
@Suite("CardWindowDrop ▸ the text/file payload split")
struct CardWindowDropPayloadTests {
/// A Finder drag: the file URL beside the concrete type. The window's.
@Test("A file drag is the window's, whatever it is a file of")
func fileDragsAreTheWindows() {
#expect(CardWindowDrop.isFilePayload(typeIdentifiers: ["public.file-url", "public.png"]))
#expect(CardWindowDrop.accepts(payloads: [["public.file-url", "public.png"]]))
#expect(CardWindowDrop.accepts(payloads: [["public.file-url", "public.plain-text"]]))
}
/// **Dragged text is never the window's** — it belongs to the Edit editor at the caret, and is
/// inert elsewhere (05-card-window.md ▸ Attachments). A link dragged out of a browser is
/// `public.url`, which does *not* conform to `public.file-url`, so it stays text.
@Test("Text and browser links are not file payloads")
func textIsNeverTheWindows() {
#expect(!CardWindowDrop.isFilePayload(typeIdentifiers: ["public.utf8-plain-text"]))
#expect(!CardWindowDrop.isFilePayload(typeIdentifiers: ["public.url", "public.utf8-plain-text"]))
#expect(!CardWindowDrop.isFilePayload(typeIdentifiers: ["public.rtf"]))
#expect(!CardWindowDrop.isFilePayload(typeIdentifiers: []))
#expect(!CardWindowDrop.accepts(payloads: [["public.utf8-plain-text"], ["public.url"]]))
}
/// The board-side folder refusal, reused rather than restated (`FinderDrop.isDirectory`): a
/// folders-only drag never engages, and a package is a folder.
@Test("A folders-only drag refuses; a mixed drag engages for its files")
func foldersRefuse() {
#expect(!CardWindowDrop.accepts(payloads: [["public.file-url", "public.folder"]]))
#expect(!CardWindowDrop.accepts(payloads: [["public.file-url", "com.apple.application-bundle"]]))
#expect(CardWindowDrop.accepts(payloads: [
["public.file-url", "public.folder"],
["public.file-url", "public.png"]
]))
}
/// The other half of the split, at the editor's end: **the text view declines the file types**,
/// which is what lets a file drag fall through to the window-level target above it. A property
/// read, so it is checkable without a drag.
@MainActor
@Test("The body editor never registers for file drags, and still takes text")
func theEditorDeclinesFiles() {
let textView = CardBodyTextView()
textView.isRichText = true
textView.isEditable = true
let types = textView.acceptableDragTypes
#expect(!types.contains(.fileURL))
#expect(!types.contains(NSPasteboard.PasteboardType("NSFilenamesPboardType")))
// What the text view *keeps* is the whole other half of the rule, and it keeps it under
// AppKit's own spelling: an editable `NSTextView` registers the legacy `NSStringPboardType`
// (not `public.utf8-plain-text`) and `public.url`, so both a plain-text drag and a link
// dragged out of a browser still land at the caret as ordinary insertions.
#expect(types.contains(NSPasteboard.PasteboardType("NSStringPboardType")))
#expect(types.contains(.URL), "a dragged link is the editor's, not the window's")
}
}
// MARK: - The section's pure rules
@Suite("CardAttachments ▸ selection and reveal")
struct CardAttachmentsRulesTests {
private let names = ["a.png", "b.png", "c.png"]
/// ↑/↓ **clamp rather than wrap**: a list is not a carousel.
@Test("Arrows clamp at both ends")
func arrowsClamp() {
#expect(CardAttachments.moved("a.png", by: 1, in: names) == "b.png")
#expect(CardAttachments.moved("c.png", by: 1, in: names) == "c.png")
#expect(CardAttachments.moved("a.png", by: -1, in: names) == "a.png")
#expect(CardAttachments.moved("c.png", by: -1, in: names) == "b.png")
}
/// With nothing selected an arrow enters the list from the edge it came from, so the section is
/// usable the instant it takes focus.
@Test("An arrow with nothing selected enters from the edge")
func arrowsEnterFromTheEdge() {
#expect(CardAttachments.moved(nil, by: 1, in: names) == "a.png")
#expect(CardAttachments.moved(nil, by: -1, in: names) == "c.png")
#expect(CardAttachments.moved("gone.png", by: 1, in: names) == "a.png")
#expect(CardAttachments.moved("a.png", by: 1, in: []) == nil)
}
/// After a remove the selection lands on **the row that took its place** — every macOS list's
/// behaviour, and what makes ⌫ ⌫ ⌫ work without reaching for the mouse between presses.
@Test("A removed selection lands on the row that took its place")
func selectionSettlesAfterARemoval() {
#expect(CardAttachments.settle("b.png", was: names, is: ["a.png", "c.png"]) == "c.png")
#expect(CardAttachments.settle("c.png", was: names, is: ["a.png", "b.png"]) == "b.png")
#expect(CardAttachments.settle("b.png", was: names, is: []) == nil)
}
/// A selection that survived keeps its row — the common case, another window's import landing
/// somewhere else in the list — and a reload never invents one.
@Test("A surviving selection keeps its row, and none is never invented")
func selectionSurvivesUnrelatedChanges() {
#expect(CardAttachments.settle("b.png", was: names, is: ["a.png", "b.png", "c.png", "d.png"]) == "b.png")
#expect(CardAttachments.settle(nil, was: names, is: names) == nil)
#expect(CardAttachments.settle("gone.png", was: [], is: names) == nil)
}
/// The observable half of the same rule: assigning a new listing settles the selection with it,
/// which is what keeps a snapshot from leaving the keyboard pointing at a file that is gone.
@MainActor
@Test("Republishing the listing settles the selection")
func republishingSettles() {
let attachments = CardAttachments()
attachments.names = names
attachments.selected = "b.png"
attachments.names = ["a.png", "c.png"]
#expect(attachments.selected == "c.png")
}
/// File ▸ Reveal in Finder's card-window scope: the selected attachment when the section has
/// focus, the card's folder otherwise, and nothing at all for a window with no card left.
@Test("Reveal points at the selected attachment only while the section is focused")
func revealScope() {
let folder = URL(fileURLWithPath: "/b/lane/card", isDirectory: true)
let file = folder.appendingPathComponent("attachments/shot.png")
#expect(CardAttachments.revealURLs(cardFolder: folder, selectedURL: file, isSectionFocused: true) == [file])
#expect(CardAttachments.revealURLs(cardFolder: folder, selectedURL: file, isSectionFocused: false) == [folder])
#expect(CardAttachments.revealURLs(cardFolder: folder, selectedURL: nil, isSectionFocused: true) == [folder])
#expect(CardAttachments.revealURLs(cardFolder: nil, selectedURL: nil, isSectionFocused: false).isEmpty)
}
/// A row's URL comes from the *listing*, never from the name alone: a name the section is not
/// showing resolves to nothing, so Open, Reveal and the drag-out all refuse together.
@MainActor
@Test("Only a listed name resolves to a file")
func onlyListedNamesResolve() {
let attachments = CardAttachments()
attachments.cardFolder = URL(fileURLWithPath: "/b/lane/card", isDirectory: true)
attachments.names = ["shot.png"]
#expect(attachments.url(for: "shot.png")?.lastPathComponent == "shot.png")
#expect(attachments.url(for: "shot.png")?.deletingLastPathComponent().lastPathComponent == "attachments")
#expect(attachments.url(for: "gone.png") == nil)
#expect(attachments.url(for: "sub/nested.txt") == nil)
}
}