import Foundation import Testing @testable import Kanban /// File ▸ Duplicate's copy (03-board-ui.md § Welcome screen & templates). /// /// Three promises, and all three are about what the copy *doesn't* do: /// /// - it doesn't remint — "The copy keeps every GUID", 01-storage-format.md's whole-board carve-out, /// and the thing that keeps a copied `.git` history naming paths that still exist; /// - it doesn't drop the trash — "Tombstoned items are carried too (settled) ... the duplicate is /// born exactly matching its history"; /// - it doesn't overwrite — the Finder-style `copy` ladder renames instead. /// /// And a fourth, which the per-item walk exists for: **it doesn't leave residue** — a cancelled or /// failed copy removes its own partial sibling, "a cancelled duplicate never happened". /// /// Tested against real folders, because every one of those is a claim about bytes on disk. The /// window flow around the copy (the flush, the banner row, the save panel, the open that follows) is /// the command's, not this type's — what belongs here is the vocabulary that flow branches on: /// cancelled, refused, failed. // MARK: - Fixtures /// A board with a live lane, a tombstoned lane, a live card, a tombstoned card, and a stray file — /// everything a literal copy has to carry through untouched. @MainActor private func makeBoard(named name: String) throws -> (fixture: WriterFixture, root: URL) { let fixture = try WriterFixture() let root = fixture.url(name) try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) try fixture.item(name, Item.board) try fixture.item("\(name)/\(Ident.lane1)", Item.rich(order: "1024", title: "Todo")) try fixture.item("\(name)/\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First")) try fixture.item( "\(name)/\(Ident.lane1)/\(Ident.card2)", "---\nschema: 1\norder: 2048\ntitle: Gone\ndeleted: 2026-01-01T00:00:00Z\n---\nbody\n" ) try fixture.item( "\(name)/\(Ident.lane2)", "---\nschema: 1\norder: 2048\ntitle: Archive\ndeleted: 2026-01-01T00:00:00Z\n---\nbody\n" ) try fixture.item("\(name)/\(Ident.lane2)/\(Ident.card3)", Item.rich(order: "1024", title: "Buried")) try fixture.file("\(name)/CLAUDE.user.md", Data("board instructions\n".utf8)) return (fixture, root) } /// Every path under `root`, board-root-relative, hidden entries included — what "a literal copy" /// means as an assertion. private func tree(of root: URL) throws -> 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 } /// `writeFailure`'s twin for the duplicate's own three-outcome vocabulary: the copy answers /// `.cancelled`, `.refused` or `.failed`, and which one is the whole question every test below asks. private func duplicateFailure(_ operation: () throws -> Void) -> BoardDuplicator.Failure? { do { try operation() Issue.record("expected the duplicate to fail, but it succeeded") return nil } catch let failure as BoardDuplicator.Failure { return failure } catch { Issue.record("expected a BoardDuplicator.Failure, got \(error)") return nil } } // MARK: - Tests @MainActor @Suite("BoardDuplicator") struct BoardDuplicatorTests { // MARK: The name ladder @Test("The first duplicate is a 'copy' sibling, extension carried") func firstCopyIsNamedFinderStyle() throws { let board = try makeBoard(named: "Roadmap.kanban") defer { board.fixture.tearDown() } let destination = BoardDuplicator.copyDestination(for: board.root) #expect(destination.lastPathComponent == "Roadmap copy.kanban") #expect(destination.deletingLastPathComponent().path == board.root.deletingLastPathComponent().path, "a sibling — where the user is already looking") } @Test("An extension-less board has none to carry") func extensionlessBoardCopiesWithoutOne() throws { let board = try makeBoard(named: "Plain") defer { board.fixture.tearDown() } #expect(BoardDuplicator.copyDestination(for: board.root).lastPathComponent == "Plain copy") } @Test("The collision ladder counts up from 2, one collision at a time") func collisionLadderCountsUp() throws { let board = try makeBoard(named: "Roadmap.kanban") defer { board.fixture.tearDown() } let first = try BoardDuplicator.duplicate(boardAt: board.root, titled: "Roadmap") #expect(first.lastPathComponent == "Roadmap copy.kanban") let second = try BoardDuplicator.duplicate(boardAt: board.root, titled: "Roadmap") #expect(second.lastPathComponent == "Roadmap copy 2.kanban") let third = try BoardDuplicator.duplicate(boardAt: board.root, titled: "Roadmap") #expect(third.lastPathComponent == "Roadmap copy 3.kanban") // And duplicating a duplicate ladders off *its* name, exactly as Finder does. let ofACopy = try BoardDuplicator.duplicate(boardAt: first, titled: "Roadmap copy") #expect(ofACopy.lastPathComponent == "Roadmap copy copy.kanban") } @Test("Anything already wearing the name blocks it, folder or file alike") func anyExistingEntryBlocksTheName() throws { let board = try makeBoard(named: "Roadmap.kanban") defer { board.fixture.tearDown() } // Not a board — just a file in the way. A duplicate must rename around it, never through it. try board.fixture.file("Roadmap copy.kanban", Data("in the way".utf8)) #expect(BoardDuplicator.copyDestination(for: board.root).lastPathComponent == "Roadmap copy 2.kanban") } // MARK: What lands in the copy @Test("Every GUID is kept — the whole-board carve-out from the remint rule") func guidsArePreserved() throws { let board = try makeBoard(named: "Roadmap.kanban") defer { board.fixture.tearDown() } let copy = try BoardDuplicator.duplicate(boardAt: board.root, titled: "Roadmap") #expect(try tree(of: copy) == tree(of: board.root), "same identities, same nesting — a copied history keeps naming paths that exist") #expect(FileManager.default.fileExists(atPath: copy.appendingPathComponent(Ident.lane1).path)) #expect(FileManager.default.fileExists( atPath: copy.appendingPathComponent("\(Ident.lane1)/\(Ident.card1)").path )) } @Test("Tombstoned lanes and cards are carried, trash included") func tombstonesAreCarried() throws { let board = try makeBoard(named: "Roadmap.kanban") defer { board.fixture.tearDown() } let copy = try BoardDuplicator.duplicate(boardAt: board.root, titled: "Roadmap") let loaded = try BoardLoader.load(boardRoot: copy).model let archive = try #require(loaded.lanes.first { $0.id.rawValue == Ident.lane2 }) #expect(archive.isDeleted, "the tombstoned lane came along — Duplicate is a full fork") #expect(archive.cards.contains { $0.id.rawValue == Ident.card3 }, "and everything beneath it") let todo = try #require(loaded.lanes.first { $0.id.rawValue == Ident.lane1 }) #expect(todo.cards.contains { $0.id.rawValue == Ident.card2 && $0.isDeleted }) } @Test("The copy is byte-for-byte, strays and stale attribution included") func contentIsCopiedVerbatim() throws { let board = try makeBoard(named: "Roadmap.kanban") defer { board.fixture.tearDown() } let copy = try BoardDuplicator.duplicate(boardAt: board.root, titled: "Roadmap") func bytes(_ root: URL, _ relative: String) throws -> Data { try Data(contentsOf: root.appendingPathComponent(relative)) } #expect(try bytes(copy, "index.md") == bytes(board.root, "index.md")) #expect(try bytes(copy, "\(Ident.lane1)/\(Ident.card1)/index.md") == bytes(board.root, "\(Ident.lane1)/\(Ident.card1)/index.md"), "created, unknown keys and modified-by all survive — nothing here reads a board file") #expect(try bytes(copy, "CLAUDE.user.md") == bytes(board.root, "CLAUDE.user.md")) } @Test("The original is untouched by its own duplication") func originalSurvivesUnchanged() throws { let board = try makeBoard(named: "Roadmap.kanban") defer { board.fixture.tearDown() } let before = try tree(of: board.root) _ = try BoardDuplicator.duplicate(boardAt: board.root, titled: "Roadmap") #expect(try tree(of: board.root) == before) } // MARK: Failure @Test("A source that isn't there fails as a duplicate, naming the board") func missingSourceFailsInTheDuplicateVocabulary() throws { let board = try makeBoard(named: "Roadmap.kanban") defer { board.fixture.tearDown() } let missing = board.fixture.url("Never.kanban") let failure = duplicateFailure { _ = try BoardDuplicator.duplicate(boardAt: missing, titled: "Never") } // An ordinary failure, not a refusal: a source that isn't there is not a question about // where the copy should go, so no save panel could help it. guard case let .failed(error) = failure else { Issue.record("expected an ordinary failure, got \(String(describing: failure))") return } #expect(error.operation == .duplicateBoard(title: "Never"), "the user pressed Duplicate; the banner must say so") #expect(BannerCenter.headline(for: error).hasPrefix("Couldn't duplicate 'Never'")) } // MARK: - The per-item walk @Test("Hidden folders, empty ones and nested strays all ride along — the walk is not the loader") func theWalkCarriesEverythingATreeCanHold() throws { let board = try makeBoard(named: "Roadmap.kanban") defer { board.fixture.tearDown() } // A `.git`-shaped hidden folder with a file in it, an empty folder inside that (the shape a // fresh repository actually has), and a stray beside a card's index. try board.fixture.file("Roadmap.kanban/.git/HEAD", Data("ref: refs/heads/main\n".utf8)) try FileManager.default.createDirectory( at: board.root.appendingPathComponent(".git/objects", isDirectory: true), withIntermediateDirectories: true ) try board.fixture.file("Roadmap.kanban/\(Ident.lane1)/\(Ident.card1)/attachments/shot.png", Data([0x01, 0x02])) let copy = try BoardDuplicator.duplicate(boardAt: board.root, titled: "Roadmap") #expect(try tree(of: copy) == tree(of: board.root), "an item-by-item walk carries exactly what one copyItem would have") #expect(try Data(contentsOf: copy.appendingPathComponent(".git/HEAD")) == Data("ref: refs/heads/main\n".utf8)) var isDirectory: ObjCBool = false #expect(FileManager.default.fileExists( atPath: copy.appendingPathComponent(".git/objects").path, isDirectory: &isDirectory ) && isDirectory.boolValue, "an empty folder is a folder, not nothing") } // MARK: - Cancel @Test("Cancelling between items removes the partial sibling — a cancelled duplicate never happened") func cancellingRemovesThePartialSibling() throws { let board = try makeBoard(named: "Roadmap.kanban") defer { board.fixture.tearDown() } let before = try tree(of: board.root) // Trips on the fourth read: the root folder exists by then and the walk is two levels deep // into it, so there is a genuine partial tree to remove rather than nothing to clean up. var reads = 0 let failure = duplicateFailure { _ = try BoardDuplicator.duplicate(boardAt: board.root, titled: "Roadmap", isCancelled: { reads += 1 return reads > 3 }) } #expect(failure == BoardDuplicator.Failure.cancelled) #expect(try Set(board.fixture.entryNames("")) == ["Roadmap.kanban"], "no 'Roadmap copy.kanban' survives — the partial went with the cancellation") #expect(try tree(of: board.root) == before, "and the original was never touched") } @Test("A duplicate cancelled before it began never creates the destination at all") func cancellingBeforeTheFirstItemCreatesNothing() throws { let board = try makeBoard(named: "Roadmap.kanban") defer { board.fixture.tearDown() } let failure = duplicateFailure { _ = try BoardDuplicator.duplicate(boardAt: board.root, titled: "Roadmap", isCancelled: { true }) } #expect(failure == BoardDuplicator.Failure.cancelled) #expect(try Set(board.fixture.entryNames("")) == ["Roadmap.kanban"]) } @Test("A failure mid-walk removes the partial sibling too, and says what went wrong") func aFailedWalkRemovesThePartialSibling() throws { let board = try makeBoard(named: "Roadmap.kanban") defer { board.fixture.tearDown() } // A card folder the walk cannot list, standing in for any I/O that gives out part-way. let unreadable = board.root.appendingPathComponent("\(Ident.lane1)/\(Ident.card1)") try FileManager.default.setAttributes([.posixPermissions: 0o000], ofItemAtPath: unreadable.path) defer { try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: unreadable.path) } let failure = duplicateFailure { _ = try BoardDuplicator.duplicate(boardAt: board.root, titled: "Roadmap") } guard case let .failed(error) = failure else { Issue.record("expected an ordinary failure, got \(String(describing: failure))") return } #expect(error.operation == .duplicateBoard(title: "Roadmap")) #expect(error.path == unreadable.path, "fail-fast names the item it stopped on") #expect(try Set(board.fixture.entryNames("")) == ["Roadmap.kanban"], "a half-copied board is residue — it goes, and the banner is what remains") } // MARK: - Refusal and the panel's answer @Test("A sandbox refusal of the sibling is a refusal, not a failure — the save panel's cue") func anUnwritableParentIsARefusal() throws { let board = try makeBoard(named: "Roadmap.kanban") defer { board.fixture.tearDown() } // The board's *parent* is unwritable while the board itself is not: exactly the shape of a // bookmark that grants a subtree and not the folder above it. try FileManager.default.setAttributes([.posixPermissions: 0o555], ofItemAtPath: board.fixture.root.path) defer { try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: board.fixture.root.path) } let failure = duplicateFailure { _ = try BoardDuplicator.duplicate(boardAt: board.root, titled: "Roadmap") } guard case let .refused(error) = failure else { Issue.record("expected a refusal, got \(String(describing: failure))") return } #expect(error.operation == .duplicateBoard(title: "Roadmap")) } @Test("A refusal at a destination the user chose is an ordinary failure — the panel isn't asked twice") func aRefusalAtAnAnsweredDestinationIsAnOrdinaryFailure() throws { let board = try makeBoard(named: "Roadmap.kanban") defer { board.fixture.tearDown() } try FileManager.default.setAttributes([.posixPermissions: 0o555], ofItemAtPath: board.fixture.root.path) defer { try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: board.fixture.root.path) } let chosen = board.fixture.url("Chosen.kanban") let failure = duplicateFailure { _ = try BoardDuplicator.duplicate(boardAt: board.root, titled: "Roadmap", into: chosen) } guard case .failed = failure else { Issue.record("expected an ordinary failure, got \(String(describing: failure))") return } } @Test("A chosen destination is honored verbatim, name and location alike") func aChosenDestinationIsHonoredVerbatim() throws { let board = try makeBoard(named: "Roadmap.kanban") defer { board.fixture.tearDown() } let elsewhere = board.fixture.url("Elsewhere") try FileManager.default.createDirectory(at: elsewhere, withIntermediateDirectories: true) // Not the "copy" ladder's answer at all: another folder, another name, no extension. let chosen = elsewhere.appendingPathComponent("Fork", isDirectory: true) let copy = try BoardDuplicator.duplicate(boardAt: board.root, titled: "Roadmap", into: chosen) #expect(copy == chosen) #expect(try tree(of: copy) == tree(of: board.root)) } @Test("A chosen name already on disk fails, and is never overwritten or removed") func aChosenNameAlreadyTakenFails() throws { let board = try makeBoard(named: "Roadmap.kanban") defer { board.fixture.tearDown() } let occupied = try board.fixture.file("Taken.kanban", Data("not a board, and not yours to delete".utf8)) let failure = duplicateFailure { _ = try BoardDuplicator.duplicate(boardAt: board.root, titled: "Roadmap", into: occupied) } guard case .failed = failure else { Issue.record("expected an ordinary failure, got \(String(describing: failure))") return } #expect(try board.fixture.data("Taken.kanban") == Data("not a board, and not yours to delete".utf8), "the destination this call did not create is the one thing cleanup never removes") } // MARK: - Classifying a refusal @Test("Only a write-permission denial is the panel's question; everything else is a banner") func permissionRefusalsAreClassifiedNarrowly() { #expect(BoardDuplicator.isPermissionRefusal( NSError(domain: NSCocoaErrorDomain, code: NSFileWriteNoPermissionError) )) #expect(BoardDuplicator.isPermissionRefusal( NSError(domain: NSPOSIXErrorDomain, code: Int(EACCES)) )) #expect(BoardDuplicator.isPermissionRefusal(NSError( domain: "SomeWrapper", code: 1, userInfo: [NSUnderlyingErrorKey: NSError(domain: NSPOSIXErrorDomain, code: Int(EPERM))] )), "an unclassified wrapper is followed through to the refusal underneath") #expect(!BoardDuplicator.isPermissionRefusal( NSError(domain: NSCocoaErrorDomain, code: NSFileWriteOutOfSpaceError) ), "a full disk is a real failure — a save panel would be the app blaming the user") #expect(!BoardDuplicator.isPermissionRefusal( NSError(domain: NSCocoaErrorDomain, code: NSFileWriteVolumeReadOnlyError) )) #expect(!BoardDuplicator.isPermissionRefusal(NSError( domain: NSCocoaErrorDomain, code: NSFileReadNoPermissionError, userInfo: [NSUnderlyingErrorKey: NSError(domain: NSPOSIXErrorDomain, code: Int(EACCES))] )), """ a source we cannot read is a broken board, not a question about where the copy goes — and \ Foundation hangs the same EACCES under it, which is why the chain is not searched blindly """) } }