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
This commit is contained in:
@@ -0,0 +1,452 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user