import Foundation import Testing @testable import Kanban /// The loose-file carve-out, end to end (01-storage-format.md § Fractal layout ▸ Rules, settled /// 2026-07-28, "Lanework-owns-the-board"; 04-interactions.md ▸ Clipboard for the paste boundary). /// /// The rule is one sentence with four halves, and this file is organized as those four: /// /// 1. **The loader notices, and only notices** — a regular file beside a card's `index.md` is /// reported on its own channel, and the walk that reported it changed nothing on disk. /// 2. **The Writer moves it** — into `attachments/`, Finder-renamed on collision, byte-faithfully, /// without opening `index.md`. /// 3. **The store schedules that write** — one bracket, one loss row, deferred under the read-only /// lock, and never hot-looping on a failure. /// 4. **A paste normalizes at the boundary** — the pasted card lands already tidy. /// /// Like every other write suite here these read back through the loader or through raw bytes, never /// through a snapshot the store handed out: the claims are about the files. `WriterFixture`, `Ident` /// and `Item` come from `WriterTestSupport.swift`; `FakePasteboard` and `ClipboardHarness` from /// `ClipboardTests.swift`. // MARK: - Shared fixtures /// A one-lane, one-card board, ready for whatever the test wants to leave beside `index.md`. /// /// It carries a **current agent guide**, which is what any board the app has opened once looks like /// (08-agent-integration.md ▸ The agent guide). Without it the store's own guide refresh — which /// runs on every successful reload, beside this file's relocation — would write a `CLAUDE.md` on /// the first reload and open a bracket of its own, and the bracket counts below would stop being /// claims about the relocation. private func makeCardBoard() throws -> WriterFixture { let fixture = try WriterFixture() try fixture.item("", Item.board) try fixture.file(AgentGuide.filename, Data(AgentGuide.content.utf8)) try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Fix login")) return fixture } private let cardPath = "\(Ident.lane1)/\(Ident.card1)" private func cardFolder(in fixture: WriterFixture) -> URL { fixture.url(cardPath) } /// The loader's own reading of the tree — the detection channel, read fresh from disk. private func looseFiles(in fixture: WriterFixture) throws -> [LooseCardFiles] { try BoardLoader.load(boardRoot: fixture.root).looseCardFiles } /// A file's bytes and mtime — "this file was not rewritten", stated the way `WriteFidelityTests` /// states it. private func stat(_ url: URL) throws -> (bytes: Data, modified: Date) { let attributes = try FileManager.default.attributesOfItem(atPath: url.path) guard let modified = attributes[.modificationDate] as? Date else { throw NSError(domain: "LooseFileRelocationTests", code: 1) } return (try Data(contentsOf: url), modified) } private func symlink(_ name: String, to destination: String, in folder: URL) throws { try FileManager.default.createSymbolicLink( atPath: folder.appendingPathComponent(name).path, withDestinationPath: destination ) } // MARK: - 1. Detection (read-only, in the loader) @Suite("Loose files ▸ detection") struct LooseFileDetectionTests { @Test("A regular file beside a card's index.md is reported — and nothing else is") func looseFileIsReported() throws { let fixture = try makeCardBoard() defer { fixture.tearDown() } try fixture.file("\(cardPath)/notes.txt", Data("notes".utf8)) let result = try BoardLoader.load(boardRoot: fixture.root) #expect(result.looseCardFiles == [ LooseCardFiles( laneID: ItemID(rawValue: Ident.lane1), cardID: ItemID(rawValue: Ident.card1), title: "Fix login", fileNames: ["notes.txt"] ), ]) // It is not a *stray* — the tolerance vocabulary says nothing about it either way. #expect(result.warnings.isEmpty) } /// Detection is read-only: "the Repair precedent". The walk that noticed the file must leave it /// exactly where it was and must not mint the `attachments/` it is destined for. @Test("Loading does not move the file, and does not create attachments/") func detectionMutatesNothing() throws { let fixture = try makeCardBoard() defer { fixture.tearDown() } let loose = try fixture.file("\(cardPath)/notes.txt", Data("notes".utf8)) let before = try stat(loose) _ = try BoardLoader.load(boardRoot: fixture.root) _ = try BoardLoader.load(boardRoot: fixture.root) let after = try stat(loose) #expect(after.bytes == before.bytes) #expect(after.modified == before.modified) #expect(!fixture.exists("\(cardPath)/attachments")) } /// "The carve-out is exactly that narrow." A stray *folder* in a card keeps the verbatim /// posture — relocating a directory into the flat attachment model would be wrong — whether it /// is UUID-shaped (a nested clone) or not (a hand-made subfolder). @Test("Stray folders in a card are not loose files") func strayFoldersAreNotFlagged() throws { let fixture = try makeCardBoard() defer { fixture.tearDown() } try fixture.file("\(cardPath)/scratch/inside.txt", Data("nested".utf8)) try fixture.item("\(cardPath)/\(Ident.card2)", Item.rich(order: "1024", title: "Nested clone")) #expect(try looseFiles(in: fixture).isEmpty) } /// Symlinks are never touched and never traversed (§ Rules) — including one whose name would /// otherwise read as an ordinary loose file, and one pointing at a directory. @Test("Symlinks beside index.md are never loose files") func symlinksAreNotFlagged() throws { let fixture = try makeCardBoard() defer { fixture.tearDown() } try fixture.file("\(cardPath)/attachments/real.png", Data("png".utf8)) try symlink("notes.txt", to: "attachments/real.png", in: cardFolder(in: fixture)) try symlink("elsewhere", to: "attachments", in: cardFolder(in: fixture)) #expect(try looseFiles(in: fixture).isEmpty) } /// "Everything at board or lane level keeps the verbatim posture" — `CLAUDE.user.md` and a /// hand-made `notes/` folder are legitimate residents. @Test("Board-level and lane-level files are untouched strays, not loose files") func boardAndLaneLevelFilesAreNotFlagged() throws { let fixture = try makeCardBoard() defer { fixture.tearDown() } try fixture.file("CLAUDE.user.md", Data("mine".utf8)) try fixture.file("\(Ident.lane1)/notes.txt", Data("lane".utf8)) let result = try BoardLoader.load(boardRoot: fixture.root) #expect(result.looseCardFiles.isEmpty) #expect(result.warnings.isEmpty) } /// "Reserved card-level names (`attachments/`, `comments/`, `index.md`) untouched" — and the /// reservation is case-insensitive, because on this filesystem `Index.md` *is* the card's index /// and relocating it would move a card's content into its own attachments. @Test("index.md, attachments/, comments/ and their case-spellings are never loose") func reservedNamesAreNotFlagged() throws { let fixture = try makeCardBoard() defer { fixture.tearDown() } try fixture.file("\(cardPath)/attachments/real.png", Data("png".utf8)) try fixture.file("\(cardPath)/comments/note.md", Data("comment".utf8)) #expect(try looseFiles(in: fixture).isEmpty) // A *file* by a reserved name is malformed rather than loose: still not relocated. let bare = try WriterFixture() defer { bare.tearDown() } try bare.item("", Item.board) try bare.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) try bare.item(cardPath, Item.rich(order: "1024", title: "Fix login")) try bare.file("\(cardPath)/attachments", Data("not a folder".utf8)) try bare.file("\(cardPath)/comments", Data("not a folder either".utf8)) #expect(try looseFiles(in: bare).isEmpty) } /// `.DS_Store` and friends are not the user's files; relocating one would surface it as an /// attachment, which is the loudest possible way to be wrong about a file nobody wrote. @Test("Hidden files are not loose files") func hiddenFilesAreNotFlagged() throws { let fixture = try makeCardBoard() defer { fixture.tearDown() } try fixture.file("\(cardPath)/.DS_Store", Data("finder".utf8)) try fixture.file("\(cardPath)/.hidden-draft.md", Data("hidden".utf8)) #expect(try looseFiles(in: fixture).isEmpty) } @Test("Several files on several cards come back in Finder order, one entry per card") func severalFilesAndCards() throws { let fixture = try makeCardBoard() defer { fixture.tearDown() } try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second")) try fixture.file("\(cardPath)/shot 10.png", Data("ten".utf8)) try fixture.file("\(cardPath)/shot 2.png", Data("two".utf8)) try fixture.file("\(Ident.lane1)/\(Ident.card2)/notes.txt", Data("notes".utf8)) let found = try looseFiles(in: fixture) #expect(found.count == 2) #expect(found.first?.fileNames == ["shot 2.png", "shot 10.png"]) #expect(found.last?.fileNames == ["notes.txt"]) #expect(found.last?.title == "Second") } /// A tombstoned card's files are the tree's business too: where a file belongs on disk is not a /// question about what the board is currently rendering. @Test("A tombstoned card's loose file is still reported") func tombstonedCardIsStillReported() throws { let fixture = try makeCardBoard() defer { fixture.tearDown() } try fixture.item("\(Ident.lane1)/\(Ident.card2)", """ --- schema: 1 title: Trashed order: 2048 deleted: 2026-03-03T09:00:00Z --- """) try fixture.file("\(Ident.lane1)/\(Ident.card2)/notes.txt", Data("notes".utf8)) #expect(try looseFiles(in: fixture).map(\.title) == ["Trashed"]) } } // MARK: - 2. The write (BoardWriter) @Suite("Loose files ▸ the relocation write") struct LooseFileWriteTests { @Test("The file moves into attachments/, byte for byte, and index.md is not touched") func movesByteFaithfullyWithoutTouchingIndex() throws { let fixture = try makeCardBoard() defer { fixture.tearDown() } let bytes = Data([0x00, 0xFF, 0x10, 0x89, 0x50, 0x4E, 0x47]) try fixture.file("\(cardPath)/photo.png", bytes) let indexBefore = try stat(cardFolder(in: fixture).appendingPathComponent("index.md")) let moved = try BoardWriter.relocateLooseFiles(["photo.png"], inCard: cardFolder(in: fixture)) #expect(moved.map(\.fileName) == ["photo.png"]) #expect(try fixture.data("\(cardPath)/attachments/photo.png") == bytes) #expect(!fixture.exists("\(cardPath)/photo.png")) // A relocation says nothing about the card's content: no `modified` stamp, no rewrite. let indexAfter = try stat(cardFolder(in: fixture).appendingPathComponent("index.md")) #expect(indexAfter.bytes == indexBefore.bytes) #expect(indexAfter.modified == indexBefore.modified) } @Test("A name already taken in attachments/ is Finder-renamed, never overwritten") func collisionIsFinderRenamed() throws { let fixture = try makeCardBoard() defer { fixture.tearDown() } try fixture.file("\(cardPath)/attachments/notes.txt", Data("the attachment".utf8)) try fixture.file("\(cardPath)/attachments/notes 2.txt", Data("the second".utf8)) try fixture.file("\(cardPath)/notes.txt", Data("the loose one".utf8)) let moved = try BoardWriter.relocateLooseFiles(["notes.txt"], inCard: cardFolder(in: fixture)) #expect(moved.map(\.fileName) == ["notes 3.txt"]) // The reported source keeps the ORIGINAL name — the one the user wrote. #expect(moved.map { $0.sourceURL.lastPathComponent } == ["notes.txt"]) #expect(try fixture.data("\(cardPath)/attachments/notes.txt") == Data("the attachment".utf8)) #expect(try fixture.data("\(cardPath)/attachments/notes 2.txt") == Data("the second".utf8)) #expect(try fixture.data("\(cardPath)/attachments/notes 3.txt") == Data("the loose one".utf8)) } /// The Writer re-checks every name against disk, so the narrowness of the carve-out is enforced /// where the filesystem is touched rather than trusted to the caller's list. @Test("Folders, symlinks, hidden and reserved names are skipped even when named explicitly") func nonRelocatableNamesAreSkipped() throws { let fixture = try makeCardBoard() defer { fixture.tearDown() } try fixture.file("\(cardPath)/scratch/inside.txt", Data("nested".utf8)) try fixture.file("\(cardPath)/attachments/real.png", Data("png".utf8)) try fixture.file("\(cardPath)/.DS_Store", Data("finder".utf8)) try symlink("link.txt", to: "attachments/real.png", in: cardFolder(in: fixture)) let moved = try BoardWriter.relocateLooseFiles( ["scratch", "link.txt", ".DS_Store", "index.md", "attachments", "comments", "gone.txt", "../escape.txt"], inCard: cardFolder(in: fixture) ) #expect(moved.isEmpty) #expect(fixture.exists("\(cardPath)/scratch")) #expect(fixture.exists("\(cardPath)/.DS_Store")) #expect(fixture.exists("\(cardPath)/index.md")) #expect(try fixture.entryNames("\(cardPath)/attachments") == ["real.png"]) let link = try FileManager.default .attributesOfItem(atPath: cardFolder(in: fixture).appendingPathComponent("link.txt").path) #expect(link[.type] as? FileAttributeType == .typeSymbolicLink) } /// Nothing to move means nothing is made: a card whose loose files all vanished under the write /// is left exactly as it was, with no empty `attachments/` minted for it. @Test("attachments/ is not created when nothing is relocatable") func noEmptyAttachmentsFolder() throws { let fixture = try makeCardBoard() defer { fixture.tearDown() } #expect(try BoardWriter.relocateLooseFiles(["gone.txt"], inCard: cardFolder(in: fixture)).isEmpty) #expect(try BoardWriter.relocateLooseFiles([], inCard: cardFolder(in: fixture)).isEmpty) #expect(!fixture.exists("\(cardPath)/attachments")) } /// The carve-out is card-level, so the guard has to be card-level too: a lane is UUID-shaped /// exactly like a card, and only its *parent* tells them apart. A lane's `notes.txt` and a /// board's `CLAUDE.user.md` are legitimate residents. @Test("A lane folder or a board root is refused") func onlyCardsAreValidTargets() throws { let fixture = try makeCardBoard() defer { fixture.tearDown() } try fixture.file("\(Ident.lane1)/notes.txt", Data("lane".utf8)) try fixture.file("notes.txt", Data("board".utf8)) let laneFailure = writeFailure { _ = try BoardWriter.relocateLooseFiles(["notes.txt"], inCard: fixture.url(Ident.lane1)) } #expect(laneFailure?.operation == .relocateLooseFile(filename: "notes.txt")) #expect(fixture.exists("\(Ident.lane1)/notes.txt")) // The board root's folder name is never UUID-shaped, so the same guard covers it. #expect(writeFailure { _ = try BoardWriter.relocateLooseFiles(["notes.txt"], inCard: fixture.root) } != nil) #expect(fixture.exists("notes.txt")) } @Test("normalizeLooseFiles discovers the card's own loose files") func normalizeDiscoversForItself() throws { let fixture = try makeCardBoard() defer { fixture.tearDown() } try fixture.file("\(cardPath)/a.txt", Data("a".utf8)) try fixture.file("\(cardPath)/b.txt", Data("b".utf8)) try fixture.file("\(cardPath)/scratch/inside.txt", Data("nested".utf8)) let moved = try BoardWriter.normalizeLooseFiles(inCard: cardFolder(in: fixture)) #expect(moved.map(\.fileName) == ["a.txt", "b.txt"]) #expect(try fixture.entryNames("\(cardPath)").sorted() == ["attachments", "index.md", "scratch"]) } @Test("normalizeLooseFiles(inLane:) reaches every card and nothing else") func normalizeALaneReachesItsCards() throws { let fixture = try makeCardBoard() defer { fixture.tearDown() } try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second")) try fixture.file("\(cardPath)/a.txt", Data("a".utf8)) try fixture.file("\(Ident.lane1)/\(Ident.card2)/b.txt", Data("b".utf8)) try fixture.file("\(Ident.lane1)/lane-level.txt", Data("lane".utf8)) let moved = try BoardWriter.normalizeLooseFiles(inLane: fixture.url(Ident.lane1)) #expect(moved.count == 2) #expect(try fixture.data("\(cardPath)/attachments/a.txt") == Data("a".utf8)) #expect(try fixture.data("\(Ident.lane1)/\(Ident.card2)/attachments/b.txt") == Data("b".utf8)) // The lane's own stray keeps the verbatim posture. #expect(fixture.exists("\(Ident.lane1)/lane-level.txt")) } } // MARK: - 3. The store's scheduling /// Counts the bracket calls a store makes, standing in for the watcher the registry wires up — /// `BoardStoreTests`' own helper, which is `private` there. @MainActor private final class RelocationBracketLog { private(set) var begins = 0 func attach(to store: BoardStore) { store.watcherBrackets = (begin: { self.begins += 1 }, end: {}) } } @MainActor @Suite("Loose files ▸ the store's relocation") struct LooseFileStoreTests { @Test("The relocation moves the file and posts the ruling's notice") func relocatesAndPostsTheNotice() throws { let fixture = try makeCardBoard() defer { fixture.tearDown() } try fixture.file("\(cardPath)/notes.txt", Data("notes".utf8)) let store = try BoardStore(rootURL: fixture.root) let brackets = RelocationBracketLog() brackets.attach(to: store) store.relocateLooseCardFiles() #expect(try fixture.data("\(cardPath)/attachments/notes.txt") == Data("notes".utf8)) #expect(!fixture.exists("\(cardPath)/notes.txt")) #expect(store.banners.losses.map(\.message) == ["Moved 'notes.txt' into attachments — 'Fix login'"]) #expect(store.banners.oneShots.isEmpty) // One gesture, one bracket — one app-mediated reload and, on git boards, one commit. #expect(brackets.begins == 1) } @Test("A collision is Finder-renamed, and the notice still names the file the user wrote") func collisionIsRenamedThroughTheStore() throws { let fixture = try makeCardBoard() defer { fixture.tearDown() } try fixture.file("\(cardPath)/attachments/notes.txt", Data("the attachment".utf8)) try fixture.file("\(cardPath)/notes.txt", Data("the loose one".utf8)) let store = try BoardStore(rootURL: fixture.root) store.relocateLooseCardFiles() #expect(try fixture.data("\(cardPath)/attachments/notes.txt") == Data("the attachment".utf8)) #expect(try fixture.data("\(cardPath)/attachments/notes 2.txt") == Data("the loose one".utf8)) #expect(store.banners.losses.map(\.message) == ["Moved 'notes.txt' into attachments — 'Fix login'"]) } @Test("A reload is what fires it — detection rides the snapshot") func aReloadFiresIt() async throws { let fixture = try makeCardBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) #expect(store.looseCardFiles.isEmpty) try fixture.file("\(cardPath)/notes.txt", Data("notes".utf8)) store.handleWatcherEvent(.treeChanged(.foreign)) await store.awaitQuiescence() #expect(try fixture.data("\(cardPath)/attachments/notes.txt") == Data("notes".utf8)) #expect(store.banners.losses.count == 1) // The echo reload finds nothing loose, so nothing fires again and no second row appears. store.handleWatcherEvent(.treeChanged(.appMediated)) await store.awaitQuiescence() #expect(store.looseCardFiles.isEmpty) #expect(store.banners.losses.count == 1) } /// "The relocation … waits out any read-only lock — strays stay tolerated until it clears." /// `.unwritableLocation` is the lock a reload does not disprove by itself, so an ordinary reload /// under it proves the deferral rather than racing it. @Test("A locked board writes nothing, and relocates when the lock clears") func lockedBoardDefers() async throws { let fixture = try makeCardBoard() defer { fixture.tearDown() } try fixture.file("\(cardPath)/notes.txt", Data("notes".utf8)) let store = try BoardStore(rootURL: fixture.root) let brackets = RelocationBracketLog() brackets.attach(to: store) store.enterUnwritableLock(.permissionDenied) store.relocateLooseCardFiles() store.handleWatcherEvent(.treeChanged(.foreign)) await store.awaitQuiescence() // Tolerated, exactly as before the carve-out existed: still beside index.md, nothing said. #expect(fixture.exists("\(cardPath)/notes.txt")) #expect(!fixture.exists("\(cardPath)/attachments")) #expect(store.banners.losses.isEmpty) #expect(brackets.begins == 0) #expect(store.isReadOnly) // A reconciling reload re-probes writability, the lock clears — and the same reload // performs the relocation it had been holding back. store.handleWatcherEvent(.treeChanged(.reconciling)) await store.awaitQuiescence() #expect(!store.isReadOnly) #expect(try fixture.data("\(cardPath)/attachments/notes.txt") == Data("notes".utf8)) #expect(store.banners.losses.map(\.message) == ["Moved 'notes.txt' into attachments — 'Fix login'"]) #expect(brackets.begins == 1) } /// The loop the guard exists for: a relocation that fails leaves the same files on disk, so the /// next walk hands back the same work. One failure, one row, then silence. @Test("A failing relocation is attempted once, not forever") func repeatedFailureDoesNotHotLoop() async throws { let fixture = try makeCardBoard() defer { fixture.tearDown() } try fixture.file("\(cardPath)/notes.txt", Data("notes".utf8)) let store = try BoardStore(rootURL: fixture.root) let brackets = RelocationBracketLog() brackets.attach(to: store) // Readable (so the walk still sees the file) but not writable (so the move cannot land). try FileManager.default.setAttributes([.posixPermissions: 0o500], ofItemAtPath: cardFolder(in: fixture).path) store.relocateLooseCardFiles() #expect(store.banners.oneShots.count == 1) #expect(store.banners.oneShots.first?.error.operation == .relocateLooseFile(filename: "notes.txt")) #expect(store.banners.losses.isEmpty) #expect(brackets.begins == 1) for _ in 0 ..< 3 { store.handleWatcherEvent(.treeChanged(.foreign)) await store.awaitQuiescence() } // Same picture on disk, so no second attempt and no second row. #expect(store.looseCardFiles.count == 1) #expect(store.banners.oneShots.count == 1) #expect(brackets.begins == 1) // A picture that actually changed is a fresh attempt. try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: cardFolder(in: fixture).path) try fixture.file("\(cardPath)/second.txt", Data("second".utf8)) store.handleWatcherEvent(.treeChanged(.foreign)) await store.awaitQuiescence() #expect(brackets.begins == 2) #expect(try fixture.data("\(cardPath)/attachments/notes.txt") == Data("notes".utf8)) #expect(try fixture.data("\(cardPath)/attachments/second.txt") == Data("second".utf8)) } @Test("Several cards fold into one bracket and one row") func severalCardsFold() throws { let fixture = try makeCardBoard() defer { fixture.tearDown() } try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second")) try fixture.file("\(cardPath)/a.txt", Data("a".utf8)) try fixture.file("\(cardPath)/b.txt", Data("b".utf8)) try fixture.file("\(Ident.lane1)/\(Ident.card2)/c.txt", Data("c".utf8)) let store = try BoardStore(rootURL: fixture.root) let brackets = RelocationBracketLog() brackets.attach(to: store) store.relocateLooseCardFiles() #expect(brackets.begins == 1) #expect(store.banners.losses.map(\.message) == ["Moved 3 files into attachments — 2 cards"]) #expect(try fixture.entryNames("\(cardPath)/attachments") == ["a.txt", "b.txt"]) #expect(try fixture.entryNames("\(Ident.lane1)/\(Ident.card2)/attachments") == ["c.txt"]) } @Test("A board with nothing loose writes nothing and says nothing") func cleanBoardIsSilent() throws { let fixture = try makeCardBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let brackets = RelocationBracketLog() brackets.attach(to: store) store.relocateLooseCardFiles() #expect(brackets.begins == 0) #expect(store.banners.losses.isEmpty) #expect(store.banners.oneShots.isEmpty) } } // MARK: - The phrasing (BannerCenter owns every word) @Suite("Loose files ▸ phrasing") struct LooseFileMessageTests { private func relocation(_ title: String?, _ names: String...) -> BannerCenter.Relocation { BannerCenter.Relocation(title: title, fileNames: names) } @Test("One card, one file names both — the design's own sentence") func oneFileOneCard() { #expect( BannerCenter.relocatedLooseFilesMessage(for: [relocation("Fix login", "notes.txt")]) == "Moved 'notes.txt' into attachments — 'Fix login'" ) } @Test("Several files on one card fold to a count, keeping the card named") func severalFilesOneCard() { #expect( BannerCenter.relocatedLooseFilesMessage( for: [relocation("Fix login", "a.txt", "b.txt", "c.txt")] ) == "Moved 3 files into attachments — 'Fix login'" ) } @Test("Several cards fold to two counts") func severalCards() { #expect( BannerCenter.relocatedLooseFilesMessage(for: [ relocation("Fix login", "a.txt", "b.txt"), relocation("Ship it", "c.txt"), relocation(nil, "d.txt", "e.txt"), ]) == "Moved 5 files into attachments — 3 cards" ) } @Test("An untitled card is named as one — 'Untitled' is a rendering, never a value") func untitledCard() { #expect( BannerCenter.relocatedLooseFilesMessage(for: [relocation(nil, "notes.txt")]) == "Moved 'notes.txt' into attachments — an untitled card" ) } @Test("Nothing moved says nothing") func nothingMoved() { #expect(BannerCenter.relocatedLooseFilesMessage(for: []) == nil) #expect(BannerCenter.relocatedLooseFilesMessage(for: [relocation("Fix login")]) == nil) } /// The row is a **loss row** — warning tone, dismissable, and ranked above the ambient notices /// rather than at the bottom of the strip with them. @Test("It rides the loss-row class") @MainActor func ridesTheLossRowClass() { let center = BannerCenter() center.postRelocatedLooseFiles([relocation("Fix login", "notes.txt")]) let rows = BannerCenter.rows( lock: nil, breakage: nil, oneShots: [], losses: center.losses, suspension: nil, operations: [], signposts: [InfoSignpost(message: "elsewhere")] ) #expect(rows.first?.tone == .warning) #expect(rows.first?.dismissID == center.losses.first?.id) #expect(rows.count == 2) } /// A failed relocation is a one-shot write failure, and the banner owns its words too. @Test("A failed relocation says so in the relocation's own verb") func failureHeadline() { let error = BoardWriteError( operation: .relocateLooseFile(filename: "notes.txt"), path: "/tmp/card/notes.txt", reason: .io(message: "disk full") ) #expect(BannerCenter.headline(for: error) == "Couldn't move 'notes.txt' into attachments — disk full") } } // MARK: - 4. The paste boundary (04-interactions.md ▸ Clipboard) /// A source board whose card carries an attachment, two loose files — one of them colliding with /// that attachment — and a symlink stray. @MainActor private func makeLooseFileClipboardBoard() throws -> WriterFixture { let fixture = try WriterFixture() try fixture.item("", Item.board) try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) try fixture.item(cardPath, Item.rich(order: "1024", title: "Fix login")) try fixture.file("\(cardPath)/attachments/notes.txt", Data("the attachment".utf8)) try fixture.file("\(cardPath)/notes.txt", Data("the loose one".utf8)) try fixture.file("\(cardPath)/draft.md", Data("draft".utf8)) try symlink("link.txt", to: "attachments/notes.txt", in: fixture.url(cardPath)) return fixture } /// The destination: one lane holding one resident card, so an arrival has neighbours. @MainActor private func makePasteDestination() throws -> WriterFixture { let fixture = try WriterFixture() try fixture.item("", Item.board) try fixture.item(Ident.lane4, Item.rich(order: "1024", title: "Inbox")) try fixture.item("\(Ident.lane4)/\(Ident.indexless)", Item.rich(order: "1024", title: "Resident")) return fixture } /// The folder name of the card that just arrived in the destination's lane. private func arrivedCard(in fixture: WriterFixture) throws -> String { let lane = try #require( try BoardLoader.load(boardRoot: fixture.root).model.lanes.first { $0.id.rawValue == Ident.lane4 } ) return try #require(lane.cards.filter { !$0.isDeleted }.last?.id.rawValue) } @MainActor @Suite("Loose files ▸ the paste boundary") struct LooseFilePasteTests { @Test("A pasted card lands already normalized, its collision Finder-renamed") func pasteNormalizes() async throws { let harness = try ClipboardHarness(fixture: try makeLooseFileClipboardBoard()) defer { harness.tearDown() } let destination = try makePasteDestination() defer { destination.tearDown() } let target = try BoardStore(rootURL: destination.root) harness.store.select([ItemID(rawValue: Ident.card1)], in: .board) harness.clipboard.copy(from: harness.store) target.select([ItemID(rawValue: Ident.lane4)], in: .board) await harness.clipboard.paste(into: target)?.value let arrived = try arrivedCard(in: destination) let path = "\(Ident.lane4)/\(arrived)" // Nothing the snapshot preserved is dropped on arrival — and nothing arrives out of place. #expect(try destination.data("\(path)/attachments/notes.txt") == Data("the attachment".utf8)) #expect(try destination.data("\(path)/attachments/notes 2.txt") == Data("the loose one".utf8)) #expect(try destination.data("\(path)/attachments/draft.md") == Data("draft".utf8)) #expect(!destination.exists("\(path)/notes.txt")) #expect(!destination.exists("\(path)/draft.md")) // A normalized arrival is not news: the user asked for this paste. #expect(target.banners.losses.isEmpty) #expect(target.banners.oneShots.isEmpty) } /// "Symlinks (never touched/traversed)" — a paste copies one verbatim, as the stray it is, and /// the destination board's own loader agrees it is nothing to relocate. @Test("A symlink stray pastes verbatim, beside index.md") func symlinkStrayPastesVerbatim() async throws { let harness = try ClipboardHarness(fixture: try makeLooseFileClipboardBoard()) defer { harness.tearDown() } let destination = try makePasteDestination() defer { destination.tearDown() } let target = try BoardStore(rootURL: destination.root) harness.store.select([ItemID(rawValue: Ident.card1)], in: .board) harness.clipboard.copy(from: harness.store) target.select([ItemID(rawValue: Ident.lane4)], in: .board) await harness.clipboard.paste(into: target)?.value let arrived = try arrivedCard(in: destination) let link = destination.url("\(Ident.lane4)/\(arrived)").appendingPathComponent("link.txt") let attributes = try FileManager.default.attributesOfItem(atPath: link.path) #expect(attributes[.type] as? FileAttributeType == .typeSymbolicLink) #expect( try FileManager.default.destinationOfSymbolicLink(atPath: link.path) == "attachments/notes.txt" ) #expect(try BoardLoader.load(boardRoot: destination.root).looseCardFiles.isEmpty) } /// The source is untouched by a copy-paste — the loose files it still holds are its own board's /// carve-out to handle, on its own reload. @Test("The source board's loose files are left where they are") func sourceIsUntouched() async throws { let harness = try ClipboardHarness(fixture: try makeLooseFileClipboardBoard()) defer { harness.tearDown() } let destination = try makePasteDestination() defer { destination.tearDown() } let target = try BoardStore(rootURL: destination.root) harness.store.select([ItemID(rawValue: Ident.card1)], in: .board) harness.clipboard.copy(from: harness.store) target.select([ItemID(rawValue: Ident.lane4)], in: .board) await harness.clipboard.paste(into: target)?.value #expect(harness.fixture.exists("\(cardPath)/notes.txt")) #expect(harness.fixture.exists("\(cardPath)/draft.md")) } @Test("A pasted lane normalizes every card it carries") func lanePasteNormalizesItsCards() async throws { let harness = try ClipboardHarness(fixture: try makeLooseFileClipboardBoard()) defer { harness.tearDown() } let destination = try makePasteDestination() defer { destination.tearDown() } let target = try BoardStore(rootURL: destination.root) harness.store.select([ItemID(rawValue: Ident.lane1)], in: .board) harness.clipboard.copy(from: harness.store) await harness.clipboard.paste(into: target)?.value let model = try BoardLoader.load(boardRoot: destination.root).model let arrivedLane = try #require(model.lanes.first { $0.id.rawValue != Ident.lane4 }) let arrivedCard = try #require(arrivedLane.cards.first) let path = "\(arrivedLane.id.rawValue)/\(arrivedCard.id.rawValue)" #expect(try destination.data("\(path)/attachments/notes 2.txt") == Data("the loose one".utf8)) #expect(try destination.data("\(path)/attachments/draft.md") == Data("draft".utf8)) #expect(!destination.exists("\(path)/notes.txt")) #expect(try BoardLoader.load(boardRoot: destination.root).looseCardFiles.isEmpty) } /// The staging-less fallback carries only `index.md`, so there is nothing to normalize and the /// normalization must not invent an `attachments/` for a card that has none. @Test("A degraded paste normalizes nothing and mints no attachments folder") func degradedPasteIsUnaffected() async throws { let harness = try ClipboardHarness(fixture: try makeLooseFileClipboardBoard()) defer { harness.tearDown() } let destination = try makePasteDestination() defer { destination.tearDown() } let target = try BoardStore(rootURL: destination.root) harness.store.select([ItemID(rawValue: Ident.card1)], in: .board) harness.clipboard.copy(from: harness.store) await harness.clipboard.stagingSettled() // The snapshot goes missing between the copy and the paste — 04's degraded paste. for staged in try harness.stagedCopyIDs() { try FileManager.default.removeItem(at: harness.staging.appendingPathComponent(staged)) } target.select([ItemID(rawValue: Ident.lane4)], in: .board) await harness.clipboard.paste(into: target)?.value let arrived = try arrivedCard(in: destination) #expect(!destination.exists("\(Ident.lane4)/\(arrived)/attachments")) #expect(try destination.entryNames("\(Ident.lane4)/\(arrived)") == ["index.md"]) } }