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
453 lines
22 KiB
Swift
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)
|
|
}
|
|
}
|