import Foundation import Testing @testable import Kanban /// Board ▸ Copy Link's target predicate (`BoardStore.copyLinkTarget`) and its pasteboard write /// (`BoardStore.copyCardLink`, `FolderLinkPasteboard`) — design ruling 2026-08-09, card 737a949f /// "Add an option to card context menu to copy a link to the card folder". /// /// The context menu's own click-scoped enablement (`CardFaceView.copyLinkEnabled`, which disables on /// a multi-selection even when the clicked card was never the reader of `copyLinkTarget`) is not /// pinned here — no sibling row (`styleTarget`/`targetIDs`) is either, per this suite's own /// `TrashWriteTests.contextMenuDeleteIgnoresTheSelection` precedent for *why* the two answers must /// differ. What is pinned here is the menu-bar row's answer and the actual bytes a write produces, /// which is what the ruling's own test list asks for: "pasteboard contents … enablement (sole vs /// multi selection)". // MARK: - Fixture @MainActor private func makeCopyLinkBoard() throws -> WriterFixture { let fixture = try WriterFixture() try fixture.board() try fixture.lane(Ident.lane1, order: "1024", title: "Todo") try fixture.card(Ident.card1, in: Ident.lane1, order: "1024", title: "First") try fixture.card(Ident.card2, in: Ident.lane1, order: "2048", title: "Second") try fixture.lane(Ident.lane2, order: "2048", title: "Doing") try fixture.trashCard(Ident.indexless, order: "1024", title: "Trashed") return fixture } private let copyLinkLane1 = ItemID(rawValue: Ident.lane1) private let copyLinkCard1 = ItemID(rawValue: Ident.card1) private let copyLinkCard2 = ItemID(rawValue: Ident.card2) private let copyLinkTrashed = ItemID(rawValue: Ident.indexless) /// A pasteboard double that records exactly what was written — `FakePasteboard`'s shape /// (`ClipboardTests.swift`), applied to Copy Link's smaller payload, so a test never races the /// machine's one real pasteboard or every other test in the run. @MainActor private final class FakeFolderLinkPasteboard: FolderLinkPasteboard { private(set) var writeCount = 0 private(set) var fileURL: URL? private(set) var path: String? func write(fileURL: URL, path: String) { writeCount += 1 self.fileURL = fileURL self.path = path } } // MARK: - The target predicate @MainActor @Suite("Copy Link's target and enablement") struct CopyLinkTargetTests { @Test("A sole selected live board card answers its own folder URL") func soleCardAnswersItsFolder() throws { let fixture = try makeCopyLinkBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) store.select([copyLinkCard1], in: .board) let expected = fixture.root .appendingPathComponent(Ident.lane1, isDirectory: true) .appendingPathComponent(Ident.card1, isDirectory: true) #expect(store.copyLinkTarget == expected) } @Test("A multi-selection disables it — a link is singular") func multiSelectionDisables() throws { let fixture = try makeCopyLinkBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) store.select([copyLinkCard1, copyLinkCard2], in: .board) #expect(store.copyLinkTarget == nil) } @Test("A lane selection, an empty selection, and a trash selection all disable it") func onlyALiveBoardCardEnablesIt() throws { let fixture = try makeCopyLinkBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) store.select([copyLinkLane1], in: .board) #expect(store.copyLinkTarget == nil, "a lane has no singular card folder to link") store.clearSelection() #expect(store.copyLinkTarget == nil, "nothing selected has no target either — no board fallback") store.select([copyLinkTrashed], in: .trash) #expect(store.copyLinkTarget == nil, "\"sole selected live card\" — a trashed card is not one") } /// The ruling's explicit gate ("disabled … wherever edit-shaped actions already disable"), even /// though the write itself mutates nothing on disk — `copyLinkTarget`'s own doc comment flags /// this as implemented-as-written rather than re-derived. @Test("An open inline editor disables it, exactly as the ruling asks") func inlineEditingDisablesIt() throws { let fixture = try makeCopyLinkBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) store.select([copyLinkCard1], in: .board) #expect(store.copyLinkTarget != nil, "sanity: the fixture must actually enable it first") store.transient.beginPlaceholder(inLane: copyLinkLane1) #expect(!store.acceptsBoardMutations, "sanity: the fixture must actually be refusing mutations") #expect(store.copyLinkTarget == nil) } } // MARK: - The write @MainActor @Suite("Copy Link's pasteboard write") struct CopyLinkWriteTests { @Test("Both representations land, and the folder URL is exact") func writesBothRepresentations() throws { let fixture = try makeCopyLinkBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let pasteboard = FakeFolderLinkPasteboard() store.select([copyLinkCard1], in: .board) store.copyCardLink(to: pasteboard) let expectedFolder = fixture.root .appendingPathComponent(Ident.lane1, isDirectory: true) .appendingPathComponent(Ident.card1, isDirectory: true) #expect(pasteboard.writeCount == 1) #expect(pasteboard.fileURL == expectedFolder) #expect(pasteboard.fileURL?.isFileURL == true) #expect(pasteboard.path == expectedFolder.path) } @Test("A disabled target writes nothing") func disabledTargetWritesNothing() throws { let fixture = try makeCopyLinkBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let pasteboard = FakeFolderLinkPasteboard() store.select([copyLinkCard1, copyLinkCard2], in: .board) store.copyCardLink(to: pasteboard) #expect(pasteboard.writeCount == 0) } } // MARK: - The seam's shared write(link:) helper @MainActor @Suite("FolderLinkPasteboard's default write(link:)") struct FolderLinkPasteboardTests { @Test("write(link:) derives the plain path from the very same URL it writes as the file URL") func writeLinkDerivesPathFromTheSameURL() { let pasteboard = FakeFolderLinkPasteboard() let folder = URL(fileURLWithPath: "/tmp/some board/lane/card", isDirectory: true) pasteboard.write(link: folder) #expect(pasteboard.fileURL == folder) #expect(pasteboard.path == folder.path) } }