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: ItemID(rawValue: Ident.lane2)) // a lane 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(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) } }