Duplicate's own posture — a faithful copy, `.git` the sole exclusion, attachments/comments/trash carried verbatim — staged to a temp directory (BoardShareStager, ditto-zipped via DittoZipArchiver) and presented through NSSharingServicePicker (BoardSharePresentation), anchored to the board window's toolbar or its center. Follows Duplicate/Save as Template's flush-then- cancellable-copy sequence under the banner's in-progress row (ShareBoardCommand, AppCommands.swift), menu-validated on focus alone rather than the read-only lock (a share is a read, Print's own posture) with the one carve-out an open inline title editor still needs. WriteOperation gains .shareBoard for the banner vocabulary; 11-command-nexus.md's File menu table gains the row. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
309 lines
13 KiB
Swift
309 lines
13 KiB
Swift
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**, `"<Board 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<String> {
|
|
var paths: Set<String> = []
|
|
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))
|
|
}
|
|
}
|