import Foundation import Testing @testable import Kanban /// File ▸ Share…'s staging half (`BoardShareStager`; design ruling 2026-08-09, card 72691b11). /// /// Three promises, and all three are about what lands in the staged `.zip`: /// /// - **`.git` is the sole exclusion** — attachments, comments and `.trash/` all carry through /// verbatim, "a faithful copy" in the ruling's own words; /// - **the filename is the sanitized title**, `".zip"`, never the on-disk folder name /// (the one place this app turns a freeform `title:` string into a path component); /// - **cancellation and failure leave no residue** — the staging directory this call created is /// the one it also removes, `BoardDuplicator`'s and `TemplateEngine`'s own rule at a third /// boundary. /// /// The picker (`BoardSharePresentation`) and the subprocess itself (`DittoZipArchiver`) are /// exercised elsewhere — this suite is `stageTree(boardAt:into:isCancelled:)`, the plain /// `FileManager` half, so the exclusion rule is provable without a subprocess anywhere in the /// loop, plus a handful of end-to-end `stage(boardAt:titled:)` runs that do call through to /// `ditto` (`DittoZipArchiverTests` is where that subprocess call gets its own, separate /// verification that it actually works inside this app's sandbox). // MARK: - Fixture /// A board carrying everything the exclusion rule has an opinion about: `.git`, `.trash/`, a /// comment thread with its own `.trash/`, an attachment, and a board-level stray. private struct ShareableBoard { let fixture: WriterFixture let root: URL static let name = "Roadmap.kanban" init() throws { fixture = try WriterFixture() root = fixture.url(Self.name) let path = Self.name try fixture.item(path, "---\nschema: 1\ntitle: Roadmap\n---\nThe board's description.\n") try fixture.file("\(path)/CLAUDE.user.md", Data("board instructions\n".utf8)) // The sole exclusion. try fixture.file("\(path)/.git/HEAD", Data("ref: refs/heads/main\n".utf8)) try fixture.file("\(path)/.git/objects/pack/pack-1.pack", Data([0x01, 0x02])) // Carried: the board's own trash. try fixture.item("\(path)/\(BoardLoader.trashFolderName)/\(Ident.card4)", Item.rich(order: "1024", title: "Thrown Away")) try fixture.item("\(path)/\(Ident.lane1)", Item.rich(order: "1024", title: "To Do")) try fixture.item("\(path)/\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Starter")) try fixture.file("\(path)/\(Ident.lane1)/\(Ident.card1)/attachments/shot.png", Data([0x01, 0x02, 0x03])) // Carried: a comment thread. Excluded regardless (unconditionally, by `BoardTreeCopy` // itself): the comment thread's own `.trash/`. try fixture.item( "\(path)/\(Ident.lane1)/\(Ident.card1)/comments/\(Ident.card2)", "---\nschema: 1\nkind: comment\nauthor: tester\n---\nA comment.\n" ) try fixture.item( "\(path)/\(Ident.lane1)/\(Ident.card1)/comments/.trash/\(Ident.card3)", "---\nschema: 1\nkind: comment\nauthor: tester\n---\nA deleted comment.\n" ) } func tearDown() { fixture.tearDown() } } /// Every path under `root`, root-relative, hidden entries included. private func tree(of root: URL) -> Set { var paths: Set = [] let walker = FileManager.default.enumerator(at: root, includingPropertiesForKeys: nil, options: []) while let url = walker?.nextObject() as? URL { paths.insert(url.path.replacingOccurrences(of: root.path + "/", with: "")) } return paths } private func shareFailure(_ operation: () throws -> Void) -> BoardShareStager.Failure? { do { try operation() Issue.record("expected the share to fail, but it succeeded") return nil } catch let failure as BoardShareStager.Failure { return failure } catch { Issue.record("expected a BoardShareStager.Failure, got \(error)") return nil } } // MARK: - Naming @Suite("BoardShareStager — the filename") struct BoardShareStagerNamingTests { @Test("An ordinary title needs no changes") func ordinaryTitlePassesThrough() { #expect(BoardShareStager.sanitizedFilenameComponent(from: "Roadmap") == "Roadmap") } @Test("A slash is the one byte the filesystem actually forbids, and it is replaced") func slashIsReplaced() { #expect(BoardShareStager.sanitizedFilenameComponent(from: "Q1/Q2 Plan") == "Q1-Q2 Plan") } @Test("A colon is legal on disk but reads as a path separator in Finder, so it goes too") func colonIsReplaced() { #expect(BoardShareStager.sanitizedFilenameComponent(from: "Roadmap: 2026") == "Roadmap- 2026") } @Test("Surrounding whitespace is trimmed") func whitespaceIsTrimmed() { #expect(BoardShareStager.sanitizedFilenameComponent(from: " Roadmap ") == "Roadmap") } @Test("An empty or all-whitespace title falls back to 'Board'") func emptyTitleFallsBack() { #expect(BoardShareStager.sanitizedFilenameComponent(from: "") == "Board") #expect(BoardShareStager.sanitizedFilenameComponent(from: " ") == "Board") // Made entirely of the one replaced character, and nothing left once it's swapped for '-'. #expect(BoardShareStager.sanitizedFilenameComponent(from: "/") == "-") } } // MARK: - The tree copy's exclusion rule @Suite("BoardShareStager — the tree copy") struct BoardShareStagerTreeTests { @Test("`.git` is the sole exclusion — everything else, trash and comments included, rides along") func onlyGitIsExcluded() throws { let board = try ShareableBoard() defer { board.tearDown() } let destination = board.fixture.url("staged") try BoardTreeCopy.createDirectory(at: destination) try BoardShareStager.stageTree(boardAt: board.root, into: destination, isCancelled: { false }) let paths = tree(of: destination) #expect(!paths.contains { $0 == ".git" || $0.hasPrefix(".git/") }, "inert history nobody receiving a shared board asked for") #expect(paths.contains { $0.hasPrefix("\(BoardLoader.trashFolderName)/") }, "a share is a faithful copy — Duplicate's posture, not Save as Template's") #expect(paths.contains { $0.hasSuffix("attachments/shot.png") }) #expect(paths.contains { $0.hasSuffix("comments/\(Ident.card2)/index.md") }) #expect(paths.contains("CLAUDE.user.md")) #expect(!paths.contains { $0.contains("comments/.trash") }, "the comment thread's own trash never travels — BoardTreeCopy's unconditional rule") } @Test("The copy is byte for byte") func contentIsVerbatim() throws { let board = try ShareableBoard() defer { board.tearDown() } let destination = board.fixture.url("staged") try BoardTreeCopy.createDirectory(at: destination) try BoardShareStager.stageTree(boardAt: board.root, into: destination, isCancelled: { false }) #expect(try Data(contentsOf: destination.appendingPathComponent("CLAUDE.user.md")) == Data("board instructions\n".utf8)) #expect(try Data(contentsOf: destination.appendingPathComponent( "\(Ident.lane1)/\(Ident.card1)/attachments/shot.png" )) == Data([0x01, 0x02, 0x03])) } @Test("The board being shared is never written to") func originalIsUntouched() throws { let board = try ShareableBoard() defer { board.tearDown() } let before = tree(of: board.root) let destination = board.fixture.url("staged") try BoardTreeCopy.createDirectory(at: destination) try BoardShareStager.stageTree(boardAt: board.root, into: destination, isCancelled: { false }) #expect(tree(of: board.root) == before) } } // MARK: - End to end @Suite("BoardShareStager — stage(boardAt:titled:)") struct BoardShareStagerEndToEndTests { @Test("The zip lands named from the title, not the folder") func zipIsNamedFromTheTitle() throws { let board = try ShareableBoard() defer { board.tearDown() } let archive = try BoardShareStager.stage(boardAt: board.root, titled: "Q1 Roadmap") defer { BoardShareStager.cleanup(archive) } #expect(archive.zipURL.lastPathComponent == "Q1 Roadmap.zip", "the board's own display title — the folder is 'Roadmap.kanban', the title is not") #expect(FileManager.default.fileExists(atPath: archive.zipURL.path)) } @Test("The decompressed tree does not survive the zip — one file is left for the picker") func onlyTheZipRemains() throws { let board = try ShareableBoard() defer { board.tearDown() } let archive = try BoardShareStager.stage(boardAt: board.root, titled: "Roadmap") defer { BoardShareStager.cleanup(archive) } let entries = try FileManager.default.contentsOfDirectory(atPath: archive.root.path) #expect( entries == ["Roadmap.zip"], "the intermediate decompressed copy — trash and attachments included — must not sit in temp storage for the picker's whole lifetime" ) } @Test("cleanup(_:) removes the whole staging directory") func cleanupRemovesEverything() throws { let board = try ShareableBoard() defer { board.tearDown() } let archive = try BoardShareStager.stage(boardAt: board.root, titled: "Roadmap") #expect(FileManager.default.fileExists(atPath: archive.root.path)) BoardShareStager.cleanup(archive) #expect(!FileManager.default.fileExists(atPath: archive.root.path)) } @Test("A share cancelled before it began leaves no staging directory at all") func cancellingBeforeTheFirstItemCreatesNothing() throws { let board = try ShareableBoard() defer { board.tearDown() } let before = try FileManager.default.contentsOfDirectory(atPath: board.fixture.root.path) let failure = shareFailure { _ = try BoardShareStager.stage(boardAt: board.root, titled: "Roadmap", isCancelled: { true }) } #expect(failure == .cancelled) #expect(try FileManager.default.contentsOfDirectory(atPath: board.fixture.root.path) == before, "no 'dev.rzen.indie.Kanban-share-…' folder survives — a cancelled share never happened") } @Test("Cancelling mid-walk removes the partial staging directory") func cancellingMidWalkRemovesThePartial() throws { let board = try ShareableBoard() defer { board.tearDown() } let before = try FileManager.default.contentsOfDirectory(atPath: board.fixture.root.path) var reads = 0 let failure = shareFailure { _ = try BoardShareStager.stage(boardAt: board.root, titled: "Roadmap", isCancelled: { reads += 1 return reads > 3 }) } #expect(failure == .cancelled) #expect(try FileManager.default.contentsOfDirectory(atPath: board.fixture.root.path) == before) } @Test("A board that isn't there fails as a share, naming the operation, and leaves nothing behind") func missingSourceFailsInTheShareVocabulary() throws { let board = try ShareableBoard() defer { board.tearDown() } let missing = board.fixture.url("Never.kanban") let before = try FileManager.default.contentsOfDirectory(atPath: board.fixture.root.path) let failure = shareFailure { _ = try BoardShareStager.stage(boardAt: missing, titled: "Never") } guard case let .failed(error) = failure else { Issue.record("expected an ordinary failure, got \(String(describing: failure))") return } #expect(error.operation == .shareBoard(title: "Never"), "the user pressed Share; the banner must say so") #expect(BannerCenter.headline(for: error).hasPrefix("Couldn't share 'Never'")) #expect(try FileManager.default.contentsOfDirectory(atPath: board.fixture.root.path) == before, "a half-staged share is residue — it goes, and the banner is what remains") } } // MARK: - Menu validation @Suite("ShareBoardCommand — validation") struct ShareBoardCommandValidationTests { @Test("Needs both a focused board window and its session ref") func needsStoreAndRef() { #expect(!ShareBoardCommand.isEnabled(hasStore: false, hasRef: false, isEditingInline: false)) #expect(!ShareBoardCommand.isEnabled(hasStore: true, hasRef: false, isEditingInline: false)) #expect(!ShareBoardCommand.isEnabled(hasStore: false, hasRef: true, isEditingInline: false)) #expect(ShareBoardCommand.isEnabled(hasStore: true, hasRef: true, isEditingInline: false)) } @Test("An open inline title editor holds a pending change no flush can reach") func inlineEditingDisablesIt() { #expect(!ShareBoardCommand.isEnabled(hasStore: true, hasRef: true, isEditingInline: true)) } @Test("Unlike Duplicate and Save as Template, the read-only lock never gates it — a share is a read") func theLockIsNeverConsulted() { // `isEnabled` takes no lock parameter at all — this test pins that omission as // deliberate: adding one back would be the change to catch here, not a passing assertion. #expect(ShareBoardCommand.isEnabled(hasStore: true, hasRef: true, isEditingInline: false)) } }