The app-owned agent guide at every board root (DESIGN/08 ▸ The agent guide): version-gated by a first-line marker (v5, superseding the pathfinder's v4 guides on real boards), rewritten when missing or older, byte-for-byte untouched when current or newer. A markerless CLAUDE.md is displaced to CLAUDE.user.md when that name is free — never clobbered — and the guide write is skipped with a log when it isn't. Symlinks, folders, and read-only volumes are skipped in silence; the write rides performWrite's bracket as an app-mediated Writer operation (new WriteOperation.agentGuide), so the echo lands appMediated and the Pro-era committer can attribute it honestly later. Hooked at store acquire (beside the loose-file relocation, after the watcher exists) and on every successful reload — the guide self-heals from foreign deletion or rollback, pre-wiring 06's acknowledged undo bounce. The refresh memo arms before each attempt and clears on a successful write, so a failing write can't hot-loop and a foreign deletion stays healable. First-line-only marker parsing (no Regex); guide content is one swappable literal, finalized under the next card. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
786 lines
36 KiB
Swift
786 lines
36 KiB
Swift
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()
|
|
|
|
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"])
|
|
}
|
|
}
|