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. /// /// 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 open that follows) is the command's, /// not this type's. // 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 } // 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 error = writeFailure { _ = try BoardDuplicator.duplicate(boardAt: missing, titled: "Never") } #expect(error?.operation == .duplicateBoard(title: "Never"), "the user pressed Duplicate; the banner must say so") #expect(BannerCenter.headline(for: try #require(error)).hasPrefix("Couldn't duplicate 'Never'")) } }