Files
lanework/KanbanTests/CommitMessageTests.swift
T
rzen d5ad21c3da The board wears a picture — background becomes a mapping, and the window chrome follows it under a thin frost
background is {color:, image:} and only a mapping at every level; the board's image paints the full window under a transparent title bar, with a thin-material frost strip keeping the chrome legible and the standard accommodations intact.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-07 10:15:03 -04:00

999 lines
44 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
import Testing
@testable import Kanban
/// **The semantic commit-message engine** (06-history-undo.md ▸ Commit messages) — the vocabulary,
/// the folding, the trash pair, the bookkeeping silence, the path-shaped events, and the external
/// gap the section exists to close.
///
/// **No repository anywhere in this file.** The composer is "a pure, testable function": two board
/// snapshots and a changed-path list in, one message out. Both snapshots are written as bytes and
/// read back through the real `BoardLoader` (`WriterFixture.snapshot()`'s reason, restated: a
/// hand-assembled `BoardModel` would be a value the loader can never produce), and the changed-path
/// list is derived by comparing the two trees — which is what git would have reported, arrived at
/// without git. The engine-level half — that a real commit carries this message, by the real author —
/// lives in `AutoCommitTests`.
// MARK: - Fixtures
/// The board both snapshots start as: three lanes, two cards in the first.
private func baseBoard(_ fixture: WriterFixture) throws {
try fixture.board(title: "Board")
try fixture.lane(Ident.lane1, order: "1024", title: "Todo")
try fixture.lane(Ident.lane2, order: "2048", title: "Doing")
try fixture.lane(Ident.lane3, order: "3072", title: "Done")
try fixture.card(Ident.card1, in: Ident.lane1, order: "1024", title: "Fix login")
try fixture.card(Ident.card2, in: Ident.lane1, order: "2048", title: "Ship it")
}
/// A UUID-shaped name `Ident` does not already spend on the base board — the trash suites need one
/// more identity than the fixture offers, and reusing a live card's would be a duplicate the loader
/// would rightly withhold.
private let spareIdentity = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
/// Every file under a root, keyed by its board-root-relative path in git's spelling.
private func files(under root: URL) -> [String: Data] {
var found: [String: Data] = [:]
let manager = FileManager.default
guard let walker = manager.enumerator(atPath: root.path) else { return found }
for case let relative as String in walker {
let url = root.appendingPathComponent(relative)
var isDirectory: ObjCBool = false
guard manager.fileExists(atPath: url.path, isDirectory: &isDirectory), !isDirectory.boolValue
else { continue }
found[relative] = (try? Data(contentsOf: url)) ?? Data()
}
return found
}
/// What `GitCommitOperation.surveyChangedPaths` would have reported for these two trees — a plain
/// content comparison, since nothing here has a repository to ask.
private func changedPaths(from before: URL, to after: URL) -> [GitChangedPath] {
let old = files(under: before)
let new = files(under: after)
var paths: [GitChangedPath] = []
for (path, data) in new where old[path] != data {
// `isArrival` is git's own `GIT_DELTA_ADDED`, which here is simply "HEAD did not have it".
paths.append(GitChangedPath(
path: path,
isDeletion: false,
isRename: false,
isArrival: old[path] == nil
))
}
for path in old.keys where new[path] == nil {
paths.append(GitChangedPath(path: path, isDeletion: true, isRename: false))
}
return paths.sorted { $0.path < $1.path }
}
/// Builds the same board twice, changes one copy, and composes the message for the difference.
///
/// - Parameters:
/// - board: the state both snapshots start in.
/// - change: what happened — applied to the "after" copy only.
/// - authorship: the commit's class. **Defaults to `.user`, and almost every test leaves it there**
/// because 06 rules that it must not matter: "a foreign move reads 'Move card …' exactly like an
/// app-mediated one". The one suite that varies it proves exactly that.
/// - renames: paths the survey would have paired as renames, for the shapes that turn on it.
private func compose(
board: (WriterFixture) throws -> Void = baseBoard,
change: (WriterFixture) throws -> Void,
authorship: CommitAuthorship = .user,
renames: Set<String> = [],
guideText: String? = nil
) throws -> String {
let before = try WriterFixture()
defer { before.tearDown() }
let after = try WriterFixture()
defer { after.tearDown() }
try board(before)
try board(after)
try change(after)
let paths = changedPaths(from: before.root, to: after.root).map { path in
renames.contains(path.path)
? GitChangedPath(
path: path.path,
isDeletion: path.isDeletion,
isRename: true,
isArrival: path.isArrival
)
: path
}
return CommitMessageEngine.message(for: CommitMessageRequest(
boardRoot: after.root,
changedPaths: paths,
authorship: authorship,
isRootCommit: false,
snapshot: try after.snapshot(),
previousSnapshot: try before.snapshot(),
agentGuideText: guideText,
// Resolved the way a flush resolves it — off the "after" tree, through the committer's own
// reader — rather than hand-assembled, for the same reason both snapshots are loaded rather
// than built: a map the flush could never produce would prove nothing about the flush.
commentTimestamps: GitAutoCommitter.commentTimestamps(for: paths, boardRoot: after.root)
))
}
private func subject(of message: String) -> String {
String(message.split(separator: "\n", omittingEmptySubsequences: false).first ?? "")
}
private func body(of message: String) -> [String] {
let lines = message.split(separator: "\n", omittingEmptySubsequences: false).map(String.init)
guard lines.count > 2 else { return [] }
return Array(lines.dropFirst(2))
}
// MARK: - One event, one subject
@Suite("Commit messages ▸ a single event is the subject")
struct CommitMessageSingleEventTests {
@Test("Adding a card names the card, with its destination as the detail")
func addingACard() throws {
let message = try compose { fixture in
try fixture.card(Ident.card3, in: Ident.lane2, order: "1024", title: "Write tests")
}
#expect(subject(of: message) == "Add card 'Write tests'")
#expect(body(of: message) == ["to Doing"])
}
@Test("A cross-lane move reads as a move — never delete plus add")
func movingACard() throws {
// The whole-board id match: the folder left one lane and arrived in another, and the
// composer is asked to tell that from a deletion beside an arrival.
let message = try compose { fixture in
try fixture.moveFolder("\(Ident.lane1)/\(Ident.card1)", to: "\(Ident.lane3)/\(Ident.card1)")
}
#expect(subject(of: message) == "Move card 'Fix login' to Done")
#expect(body(of: message) == ["Todo → Done"])
}
@Test("A rename carries both names in the subject — 06's own form")
func renamingALane() throws {
let message = try compose { fixture in
try fixture.lane(Ident.lane1, order: "1024", title: "Backlog")
}
#expect(message == "Rename lane 'Todo' → 'Backlog'")
}
@Test("An edited body is an Edit, and a restyle is a Restyle")
func editingAndRestyling() throws {
let edited = try compose { fixture in
try fixture.card(Ident.card1, in: Ident.lane1, order: "1024", title: "Fix login", body: "Now with detail.")
}
#expect(edited == "Edit card 'Fix login'")
let restyled = try compose { fixture in
try fixture.item(
"\(Ident.lane1)/\(Ident.card1)",
"---\nschema: 1\ntitle: Fix login\norder: 1024\nbackground: {color: blue}\n---\n\n"
)
}
#expect(restyled == "Restyle card 'Fix login'")
}
@Test("A widened lane is a Resize")
func resizingALane() throws {
let message = try compose { fixture in
try fixture.lane(Ident.lane1, order: "1024", title: "Todo", width: 2)
}
#expect(message == "Resize lane 'Todo' to 2×")
}
@Test("An attachment composes Attach, named by the card it landed on")
func attachingAFile() throws {
let message = try compose { fixture in
try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/spec.pdf", Data("pdf".utf8))
}
#expect(message == "Attach 'spec.pdf' to card 'Fix login'")
}
@Test("A removed attachment composes Remove")
func removingAFile() throws {
let message = try compose { fixture in
try baseBoard(fixture)
try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/spec.pdf", Data("pdf".utf8))
} change: { fixture in
try FileManager.default.removeItem(
at: fixture.url("\(Ident.lane1)/\(Ident.card1)/attachments").appendingPathComponent("spec.pdf")
)
}
#expect(message == "Remove 'spec.pdf' from card 'Fix login'")
}
@Test("A rewritten attachment composes Replace — never the anonymous path generic")
func replacingAFile() throws {
// Added 2026-07-31: "a changed file under a card's `attachments/` with an unchanged listing is
// a content replacement, named from the path alone". The listing is unchanged here — same
// name, new bytes — so the snapshot diff has nothing to say and the path says it instead.
let message = try compose { fixture in
try baseBoard(fixture)
try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/photo.png", Data("old".utf8))
} change: { fixture in
try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/photo.png", Data("new".utf8))
}
#expect(message == "Replace attachment 'photo.png' — card 'Fix login'")
}
@Test("Two replaced attachments on one card fold plural, still naming the card")
func replacingSeveralFiles() throws {
let message = try compose { fixture in
try baseBoard(fixture)
try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/photo.png", Data("old".utf8))
try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/spec.pdf", Data("old".utf8))
} change: { fixture in
try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/photo.png", Data("new".utf8))
try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/spec.pdf", Data("new".utf8))
}
#expect(subject(of: message) == "Replace 2 attachments — card 'Fix login'")
#expect(body(of: message) == [
"- Replace attachment 'photo.png' — card 'Fix login'",
"- Replace attachment 'spec.pdf' — card 'Fix login'",
])
}
@Test("A repositioned card composes Reorder, naming its lane")
func reorderingCards() throws {
// A *foreign* single-file reorder: one card's rank crosses its sibling's, nothing else
// changes. "An order change that repositions an item among its siblings composes Reorder."
let message = try compose { fixture in
try fixture.card(Ident.card1, in: Ident.lane1, order: "4096", title: "Fix login")
}
#expect(message == "Reorder cards in Todo")
}
@Test("The board's own fields compose board events")
func boardLevelEvents() throws {
let renamed = try compose { fixture in
try fixture.board(title: "Q3 Plan")
}
#expect(renamed == "Rename board 'Board' → 'Q3 Plan'")
let described = try compose { fixture in
try fixture.board(title: "Board", body: "What this board is for.")
}
#expect(described == "Edit board description")
}
}
// MARK: - The trash pair
@Suite("Commit messages ▸ the trash pair, by diff shape alone")
struct CommitMessageTrashTests {
@Test("A move into .trash/ is Delete")
func intoTheTrashIsDelete() throws {
let message = try compose { fixture in
try fixture.move("\(Ident.lane1)/\(Ident.card1)", toTrash: Ident.card1)
}
#expect(message == "Delete card 'Fix login'")
}
@Test("A move out of .trash/ is Restore")
func outOfTheTrashIsRestore() throws {
let message = try compose { fixture in
try baseBoard(fixture)
try fixture.trashCard(Ident.card3, order: "1024", title: "Old idea")
} change: { fixture in
try fixture.move(".trash/\(Ident.card3)", toLane: Ident.lane2, card: Ident.card3)
}
#expect(message == "Restore card 'Old idea'")
}
@Test("Leaving the tree entirely is Permanently delete")
func leavingTheTreeIsPermanent() throws {
let message = try compose { fixture in
try baseBoard(fixture)
try fixture.trashCard(Ident.card3, order: "1024", title: "Old idea")
} change: { fixture in
try FileManager.default.removeItem(at: fixture.url(".trash/\(Ident.card3)"))
}
#expect(message == "Permanently delete card 'Old idea'")
}
@Test("Empty Trash folds — 'Permanently delete 3 cards', distinct from a multi-select delete")
func emptyTrashFolds() throws {
let purge = try compose { fixture in
try baseBoard(fixture)
try fixture.trashCard(Ident.card3, order: "1024", title: "One")
try fixture.trashCard(Ident.card4, order: "2048", title: "Two")
try fixture.trashCard(spareIdentity, order: "3072", title: "Three")
} change: { fixture in
try FileManager.default.removeItem(at: fixture.url(".trash"))
}
#expect(subject(of: purge) == "Permanently delete 3 cards")
let delete = try compose { fixture in
try baseBoard(fixture)
try fixture.card(Ident.card3, in: Ident.lane1, order: "3072", title: "Third")
} change: { fixture in
for card in [Ident.card1, Ident.card2, Ident.card3] {
try fixture.move("\(Ident.lane1)/\(card)", toTrash: card)
}
}
#expect(subject(of: delete) == "Delete 3 cards")
}
@Test("A trashed lane takes its cards with it — 'Delete lane', cards as bullets")
func aTrashedLaneKeepsTheSubject() throws {
// "Implied events don't steal the subject: deleting a lane with five cards reads 'Delete lane
// 'X'' with the card deletions as body bullets — not 'Update board'." And the cards are
// *deleted*, not permanently deleted: they went into the trash inside their lane's folder.
let message = try compose { fixture in
try fixture.moveFolder(Ident.lane1, to: ".trash/\(Ident.lane1)")
}
#expect(subject(of: message) == "Delete lane 'Todo'")
#expect(body(of: message) == [
"- Delete lane 'Todo'",
"- Delete card 'Fix login'",
"- Delete card 'Ship it'",
])
}
}
// MARK: - Folding
@Suite("Commit messages ▸ folding")
struct CommitMessageFoldingTests {
@Test("Several of one kind fold, and a shared destination survives into the subject")
func sharedDestinationsFold() throws {
let message = try compose { fixture in
try baseBoard(fixture)
try fixture.card(Ident.card3, in: Ident.lane1, order: "3072", title: "Third")
} change: { fixture in
for card in [Ident.card1, Ident.card2, Ident.card3] {
try fixture.moveFolder("\(Ident.lane1)/\(card)", to: "\(Ident.lane3)/\(card)")
}
}
#expect(subject(of: message) == "Move 3 cards to Done")
#expect(body(of: message).count == 3)
#expect(body(of: message).first == "- Move card 'Fix login' from Todo to Done")
}
@Test("Destinations that disagree drop out of the subject rather than lying")
func disagreeingDestinationsDropOut() throws {
let message = try compose(change: { fixture in
try fixture.moveFolder("\(Ident.lane1)/\(Ident.card1)", to: "\(Ident.lane2)/\(Ident.card1)")
try fixture.moveFolder("\(Ident.lane1)/\(Ident.card2)", to: "\(Ident.lane3)/\(Ident.card2)")
})
#expect(subject(of: message) == "Move 2 cards")
}
@Test("A genuinely mixed window says so — with every event in the body")
func mixedWindowsFallBack() throws {
// Re-ruled 2026-07-31: the bare "Update board" is retired. Two cards, so no shared item and
// no name to keep — "Mixed update — N changes", never a shrug dressed as one thing.
let message = try compose { fixture in
try fixture.card(Ident.card1, in: Ident.lane1, order: "1024", title: "Fix logout")
try fixture.card(Ident.card3, in: Ident.lane2, order: "1024", title: "Write tests")
}
#expect(subject(of: message) == "Mixed update — 2 changes")
#expect(body(of: message) == [
"- Add card 'Write tests' to Doing",
"- Rename card 'Fix login' → 'Fix logout'",
])
}
@Test("A deleted lane's cards are body bullets, never the subject")
func impliedEventsDoNotStealTheSubject() throws {
let message = try compose { fixture in
try FileManager.default.removeItem(at: fixture.url(Ident.lane1))
}
#expect(subject(of: message) == "Permanently delete lane 'Todo'")
#expect(body(of: message) == [
"- Permanently delete lane 'Todo'",
"- Permanently delete card 'Fix login'",
"- Permanently delete card 'Ship it'",
])
}
}
// MARK: - The external gap
@Suite("Commit messages ▸ the external gap, closed")
struct CommitMessageExternalSurfaceTests {
@Test("Labels, assignees and due dates each compose a named subject")
func theReservedTrioComposes() throws {
func withKey(_ line: String) throws -> String {
try compose { fixture in
try fixture.item(
"\(Ident.lane1)/\(Ident.card1)",
"---\nschema: 1\ntitle: Fix login\norder: 1024\n\(line)\n---\n\n"
)
}
}
#expect(try withKey("labels: [bug, urgent]") == "Relabel card 'Fix login'")
#expect(try withKey("assignees: [ada]") == "Assign card 'Fix login'")
#expect(try withKey("due: 2026-08-31") == "Set due date on card 'Fix login'")
}
@Test("An unmodeled custom key says what it is, with its values in the body")
func customKeysSayWhatTheyAre() throws {
// Re-ruled 2026-07-31: the named generic ("Update card 'X'") is retired here — "first lines
// self-describe; generics are a last resort". One key, so the singular.
let message = try compose { fixture in
try fixture.item(
"\(Ident.lane1)/\(Ident.card1)",
"---\nschema: 1\ntitle: Fix login\norder: 1024\nsprint: 42\n---\n\n"
)
}
#expect(subject(of: message) == "Change custom key on card 'Fix login'")
#expect(body(of: message) == ["sprint: (none) → 42"])
}
@Test("Several custom keys fold plural on one item, each named with its old → new values")
func customKeysFoldPlural() throws {
let message = try compose { fixture in
try baseBoard(fixture)
try fixture.item(
"\(Ident.lane1)/\(Ident.card1)",
"---\nschema: 1\ntitle: Fix login\norder: 1024\nsprint: 41\nestimate: 3\n---\n\n"
)
} change: { fixture in
try fixture.item(
"\(Ident.lane1)/\(Ident.card1)",
"---\nschema: 1\ntitle: Fix login\norder: 1024\nsprint: 42\n---\n\n"
)
}
// Two keys, one event — the item is still what the subject names — and the body carries both
// sides of each, a removal reading as a move to absence.
#expect(subject(of: message) == "Change 2 custom keys on card 'Fix login'")
#expect(body(of: message) == ["estimate: 3 → (none)", "sprint: 41 → 42"])
}
@Test("A lane's and the board's custom keys name their item too — never a board-level shrug")
func customKeysNameLanesAndTheBoard() throws {
let lane = try compose { fixture in
try fixture.item(
Ident.lane1,
"---\nschema: 1\ntitle: Todo\norder: 1024\nwip-limit: 5\n---\n\n"
)
}
#expect(subject(of: lane) == "Change custom key on lane 'Todo'")
// The board is the case 06 calls out by name: "never a board-level shrug when the touched item
// is identifiable" — and the board is identifiable, by its own title.
let board = try compose { fixture in
try fixture.item("", "---\nschema: 1\ntitle: Board\nsprint-length: 2w\n---\nBoard description.\n")
}
#expect(subject(of: board) == "Change custom key on board 'Board'")
}
@Test("A foreign change composes identically to an app-mediated one")
func originIsNotInTheProse() throws {
// "Origin lives in the author field (structural attribution), not in message prose — a foreign
// move reads 'Move card …' exactly like an app-mediated one." Same change, all three classes.
func move(_ authorship: CommitAuthorship) throws -> String {
try compose(change: { fixture in
try fixture.moveFolder("\(Ident.lane1)/\(Ident.card1)", to: "\(Ident.lane2)/\(Ident.card1)")
}, authorship: authorship)
}
let app = try move(.user)
let foreign = try move(.foreign(GitIdentity(name: "Lanework External", email: "[email protected]")))
let heal = try move(.heal)
#expect(app == "Move card 'Fix login' to Doing\n\nTodo → Doing")
#expect(foreign == app)
#expect(heal == app)
}
}
// MARK: - Bookkeeping
@Suite("Commit messages ▸ bookkeeping composes nothing")
struct CommitMessageBookkeepingTests {
@Test("A bumped modified stamp is not an event")
func stampsAreSilent() throws {
let message = try compose { fixture in
try fixture.card(
Ident.card1, in: Ident.lane1, order: "1024", title: "Fix login",
modified: "2026-07-31T12:00:00Z"
)
}
#expect(message == CommitMessageEngine.unnamedSubject)
}
@Test("A renumber's rescale preserves sequence and composes nothing")
func rescalesAreSilent() throws {
// Every rank multiplied, nobody repositioned — 01-storage-format.md's renumber. "Sequence is
// what the diff compares, not raw `order` values."
let message = try compose { fixture in
try fixture.card(Ident.card1, in: Ident.lane1, order: "16384", title: "Fix login")
try fixture.card(Ident.card2, in: Ident.lane1, order: "32768", title: "Ship it")
}
#expect(message == CommitMessageEngine.unnamedSubject)
}
@Test("An on-touch heal's backfilled kind is not an event")
func theBackfilledKindIsSilent() throws {
let message = try compose { fixture in
try fixture.item(
"\(Ident.lane1)/\(Ident.card1)",
"---\nschema: 1\ntitle: Fix login\norder: 1024\nkind: card\n---\n\n"
)
}
#expect(message == CommitMessageEngine.unnamedSubject)
}
@Test("A renumber batches with the insert that triggered it")
func aRenumberRidesWithItsInsert() throws {
// Midpoint exhaustion: the insert forces every sibling's rank to be rewritten. The commit
// reads as the insert, because the *sequence* of the cards that were already there is
// unchanged.
let message = try compose { fixture in
try fixture.card(Ident.card1, in: Ident.lane1, order: "1024", title: "Fix login")
try fixture.card(Ident.card3, in: Ident.lane1, order: "2048", title: "Wedged in")
try fixture.card(Ident.card2, in: Ident.lane1, order: "3072", title: "Ship it")
}
#expect(subject(of: message) == "Add card 'Wedged in'")
}
}
// MARK: - Titles
@Suite("Commit messages ▸ titles")
struct CommitMessageTitleTests {
@Test("Titles truncate in subjects only; bodies carry them whole")
func truncationIsSubjectOnly() throws {
let long = String(repeating: "A", count: 60)
let message = try compose { fixture in
try fixture.card(Ident.card3, in: Ident.lane2, order: "1024", title: long)
try fixture.card(Ident.card4, in: Ident.lane2, order: "2048", title: long)
}
#expect(subject(of: message) == "Add 2 cards to Doing")
#expect(body(of: message).allSatisfy { $0.contains(long) })
let sole = try compose { fixture in
try fixture.card(Ident.card3, in: Ident.lane2, order: "1024", title: long)
}
#expect(subject(of: sole) == "Add card '\(String(repeating: "A", count: 40))…'")
}
@Test("An untitled item reads (untitled), never a bare pair of quotes")
func untitledIsRendered() throws {
let message = try compose { fixture in
try fixture.item("\(Ident.lane2)/\(Ident.card3)", "---\nschema: 1\norder: 1024\n---\n\n")
}
#expect(message == "Add card '(untitled)'\n\nto Doing")
#expect(!message.contains("''"))
}
}
// MARK: - Non-snapshot paths
@Suite("Commit messages ▸ non-snapshot files")
struct CommitMessagePathEventTests {
@Test("A stray composes its own path-shaped event, and several fold")
func straysComposePathEvents() throws {
let one = try compose { fixture in
try fixture.file("notes.txt", Data("hello".utf8))
}
#expect(one == "Update 'notes.txt'")
let several = try compose { fixture in
try fixture.file("notes.txt", Data("hello".utf8))
try fixture.file(".gitignore", Data("*.tmp\n".utf8))
try fixture.file("\(Ident.lane1)/\(Ident.card1)/scratch.md", Data("scratch".utf8))
}
#expect(subject(of: several) == "Update 3 files")
#expect(body(of: several) == [
"- Update '.gitignore'",
"- Update '\(Ident.lane1)/\(Ident.card1)/scratch.md'",
"- Update 'notes.txt'",
])
}
@Test("The agent guide composes its version, read from the marker line")
func theAgentGuideComposesItsVersion() throws {
let guide = "<!-- lanework-agent-guide v7 -->\nHow to work in this board.\n"
let message = try compose(change: { fixture in
try fixture.file(AgentGuide.filename, Data(guide.utf8))
}, guideText: guide)
#expect(message == "Update agent guide (v7)")
}
@Test("A markerless CLAUDE.md is somebody else's file, and composes as the path it is")
func amarkerlessGuideIsJustAFile() throws {
let text = "My own notes for agents.\n"
let message = try compose(change: { fixture in
try fixture.file(AgentGuide.filename, Data(text.utf8))
}, guideText: text)
#expect(message == "Update 'CLAUDE.md'")
}
@Test("Model events keep the subject; a stray rides along as a bullet")
func modelEventsKeepTheSubject() throws {
let message = try compose { fixture in
try fixture.card(Ident.card3, in: Ident.lane2, order: "1024", title: "Write tests")
try fixture.file("notes.txt", Data("hello".utf8))
}
#expect(subject(of: message) == "Add card 'Write tests'")
#expect(body(of: message) == [
"- Add card 'Write tests' to Doing",
"- Update 'notes.txt'",
])
}
@Test("A path this commit does not stage composes nothing — the stage-around, in the message")
func pathsOutsideTheCommitAreNotDescribed() throws {
// The split window's rule, and the open-Edit-session exclusion's: the composer describes the
// commit it is composing for, not everything that differs from HEAD.
let before = try WriterFixture()
defer { before.tearDown() }
let after = try WriterFixture()
defer { after.tearDown() }
try baseBoard(before)
try baseBoard(after)
try after.card(Ident.card1, in: Ident.lane1, order: "1024", title: "Fix login", body: "Half-typed.")
try after.card(Ident.card3, in: Ident.lane2, order: "1024", title: "Write tests")
let staged = changedPaths(from: before.root, to: after.root)
.filter { !$0.path.hasPrefix("\(Ident.lane1)/\(Ident.card1)/") }
let message = CommitMessageEngine.message(for: CommitMessageRequest(
boardRoot: after.root,
changedPaths: staged,
authorship: .user,
isRootCommit: false,
snapshot: try after.snapshot(),
previousSnapshot: try before.snapshot()
))
#expect(message == "Add card 'Write tests'\n\nto Doing")
}
}
// MARK: - The comment verb family
@Suite("Commit messages ▸ the comment verb family")
struct CommitMessageCommentTests {
private static let commentA = "cccccccc-0000-4000-8000-000000000001"
private static let commentB = "cccccccc-0000-4000-8000-000000000002"
private static let commentC = "cccccccc-0000-4000-8000-000000000003"
/// A comment's `index.md` — never parsed by the composer, which reads the family off path shape
/// alone ("comments are window-scoped, outside the board snapshot" — 01-storage-format.md).
private static func commentText(_ body: String) -> Data {
Data("---\nschema: 1\nkind: comment\nauthor: Ada\n---\n\(body)\n".utf8)
}
private static func thread(_ card: String, _ entry: String) -> String {
"\(Ident.lane1)/\(card)/comments/\(entry)/index.md"
}
@Test("Posting — the .draft rename — composes 'Comment on ⟨card⟩'")
func postingComposesComment() throws {
// The app's post is one rename: `comments/.draft/` → `comments/<uuid>/`, restamped in the
// same bracket. The departing `.draft` end is silent under the rename rule; the arrival is
// the event.
let draft = Self.thread(Ident.card1, ".draft")
let posted = Self.thread(Ident.card1, Self.commentA)
let message = try compose(board: { fixture in
try baseBoard(fixture)
try fixture.file(draft, Self.commentText("Half a thought."))
}, change: { fixture in
try fixture.file(posted, Self.commentText("Half a thought."))
try FileManager.default.removeItem(
at: fixture.root.appendingPathComponent("\(Ident.lane1)/\(Ident.card1)/comments/.draft")
)
}, renames: [draft, posted])
#expect(message == "Comment on 'Fix login'")
}
@Test("A foreign writer's new comment folder composes the same words")
func foreignArrivalsComposeTheSame() throws {
// No rename to read: an agent simply wrote a folder. Origin-agnostic one level down.
let message = try compose { fixture in
try fixture.file(Self.thread(Ident.card1, Self.commentA), Self.commentText("From an agent."))
}
#expect(message == "Comment on 'Fix login'")
}
@Test("Rewriting an existing comment composes 'Edit comment on ⟨card⟩'")
func editsComposeEditComment() throws {
let message = try compose { fixture in
try baseBoard(fixture)
try fixture.file(Self.thread(Ident.card1, Self.commentA), Self.commentText("First draft."))
} change: { fixture in
try fixture.file(Self.thread(Ident.card1, Self.commentA), Self.commentText("Second thoughts."))
}
#expect(message == "Edit comment on 'Fix login'")
}
@Test("A move into comments/.trash/ composes one Delete, not a departure plus an arrival")
func deletionComposesDeleteComment() throws {
let message = try compose { fixture in
try baseBoard(fixture)
try fixture.file(Self.thread(Ident.card1, Self.commentA), Self.commentText("Regretted."))
} change: { fixture in
try fixture.moveFolder(
"\(Ident.lane1)/\(Ident.card1)/comments/\(Self.commentA)",
to: "\(Ident.lane1)/\(Ident.card1)/comments/.trash/\(Self.commentA)"
)
}
// Both halves are in the window and git paired neither of them; the arriving end speaks.
#expect(message == "Delete comment on 'Fix login'")
}
@Test("The close purge composes 'Permanently delete comment on ⟨card⟩'")
func purgeComposesPermanentDelete() throws {
// `comments/.trash/` is undo's backing store, purged when the card window closes. Unruled in
// DESIGN; composed by the trash pair's own symmetry rather than left as a silent shrug.
let message = try compose { fixture in
try baseBoard(fixture)
try fixture.file(
"\(Ident.lane1)/\(Ident.card1)/comments/.trash/\(Self.commentA)/index.md",
Self.commentText("Deleted a while ago.")
)
} change: { fixture in
try FileManager.default.removeItem(
at: fixture.root.appendingPathComponent("\(Ident.lane1)/\(Ident.card1)/comments/.trash")
)
}
#expect(message == "Permanently delete comment on 'Fix login'")
}
@Test("A draft save composes the quiet 'Draft comment on ⟨card⟩'")
func draftSavesComposeDraftComment() throws {
let message = try compose { fixture in
try baseBoard(fixture)
try fixture.file(Self.thread(Ident.card1, ".draft"), Self.commentText("Still typing"))
} change: { fixture in
try fixture.file(Self.thread(Ident.card1, ".draft"), Self.commentText("Still typing, more"))
}
#expect(message == "Draft comment on 'Fix login'")
}
@Test("Every file in one comment folder is one event")
func oneCommentIsOneEvent() throws {
let message = try compose { fixture in
try fixture.file(Self.thread(Ident.card1, Self.commentA), Self.commentText("With a file."))
try fixture.file(
"\(Ident.lane1)/\(Ident.card1)/comments/\(Self.commentA)/attachments/shot.png",
Data("png".utf8)
)
}
#expect(message == "Comment on 'Fix login'")
}
@Test("Several comments fold on the card they landed on")
func commentsFoldOnTheirCard() throws {
let sameCard = try compose { fixture in
for comment in [Self.commentA, Self.commentB, Self.commentC] {
try fixture.file(Self.thread(Ident.card1, comment), Self.commentText("Note."))
}
}
#expect(subject(of: sameCard) == "3 comments on 'Fix login'")
let twoCards = try compose { fixture in
try fixture.file(Self.thread(Ident.card1, Self.commentA), Self.commentText("Note."))
try fixture.file(Self.thread(Ident.card2, Self.commentB), Self.commentText("Note."))
}
#expect(subject(of: twoCards) == "Comment on 2 cards")
}
@Test("The card's title is resolved from the snapshot, untitled included")
func titlesResolveFromTheSnapshot() throws {
let message = try compose { fixture in
try baseBoard(fixture)
try fixture.item("\(Ident.lane2)/\(Ident.card3)", "---\nschema: 1\norder: 1024\n---\n\n")
} change: { fixture in
try fixture.file(
"\(Ident.lane2)/\(Ident.card3)/comments/\(Self.commentA)/index.md",
Self.commentText("On a nameless card.")
)
}
#expect(message == "Comment on '(untitled)'")
}
@Test("A comment-only window never shrugs and never shows a raw path")
func aCommentOnlyWindowGetsItsOwnSubject() throws {
let message = try compose { fixture in
try fixture.file(Self.thread(Ident.card1, Self.commentA), Self.commentText("Note."))
}
#expect(message != CommitMessageEngine.unnamedSubject)
#expect(!message.contains("comments/"))
}
@Test("Model events keep the subject; a comment rides along as a bullet")
func modelEventsOutrankComments() throws {
let message = try compose { fixture in
try fixture.moveFolder("\(Ident.lane1)/\(Ident.card2)", to: "\(Ident.lane3)/\(Ident.card2)")
try fixture.file(Self.thread(Ident.card1, Self.commentA), Self.commentText("Note."))
}
#expect(subject(of: message) == "Move card 'Ship it' to Done")
#expect(body(of: message).contains("- Comment on 'Fix login'"))
}
@Test("A card carrying its thread into the trash is one event, not one per comment")
func aMovedCardSwallowsItsThread() throws {
// The implied-events rule, one level down: the comment files travelled because the card did.
let message = try compose { fixture in
try baseBoard(fixture)
for comment in [Self.commentA, Self.commentB] {
try fixture.file(Self.thread(Ident.card1, comment), Self.commentText("Note."))
}
} change: { fixture in
try fixture.move("\(Ident.lane1)/\(Ident.card1)", toTrash: Ident.card1)
}
#expect(message == "Delete card 'Fix login'")
}
// MARK: The chronology
/// A comment's `index.md` with a `created` stamp — the one field the ordering below reads, written
/// in the YAML 1.1 timestamp grammar the loader accepts (`FrontmatterFields.date`).
private static func datedComment(_ body: String, created: String) -> Data {
Data("---\nschema: 1\nkind: comment\nauthor: Ada\ncreated: \(created)\n---\n\(body)\n".utf8)
}
/// **"A commit's comment bullets sort chronologically … never UUID-arbitrary"** (06 ▸ Rules
/// ▸ Auto-commit, blessed 2026-07-31).
///
/// Three comments on one card, each with a *different verb* so the bullets are distinguishable,
/// and identities deliberately ordered against their chronology: the earliest comment carries the
/// middle UUID and the latest carries the smallest. Folder-name order would read Edit, Comment,
/// Delete; the conversation happened in the other order, and that is what the body says.
@Test("Comment bullets read in the order the conversation did, not in UUID order")
func commentBulletsSortByCreated() throws {
let card = "\(Ident.lane1)/\(Ident.card1)"
let message = try compose { fixture in
try baseBoard(fixture)
// Posted first, and edited in this window: the latest `created`, the smallest UUID.
try fixture.file(
Self.thread(Ident.card1, Self.commentA),
Self.datedComment("First draft.", created: "2026-07-31T12:00:00Z")
)
// Deleted in this window, so it exists before and moves into `comments/.trash/`.
try fixture.file(
Self.thread(Ident.card1, Self.commentC),
Self.datedComment("Regretted.", created: "2026-07-31T11:00:00Z")
)
} change: { fixture in
try fixture.file(
Self.thread(Ident.card1, Self.commentA),
Self.datedComment("Second thoughts.", created: "2026-07-31T12:00:00Z")
)
// Posted in this window — the earliest `created`, the middle UUID.
try fixture.file(
Self.thread(Ident.card1, Self.commentB),
Self.datedComment("Said hours ago.", created: "2026-07-31T10:00:00Z")
)
try fixture.moveFolder(
"\(card)/comments/\(Self.commentC)",
to: "\(card)/comments/.trash/\(Self.commentC)"
)
}
#expect(body(of: message) == [
"- Comment on 'Fix login'",
"- Delete comment on 'Fix login'",
"- Edit comment on 'Fix login'",
])
}
/// "**folder name on ties**" — and the name that breaks the tie is the *comment's* folder, not the
/// composite key the groups are gathered under. Two cards, one timestamp: the comment named
/// `…0001` speaks first even though its card sorts second.
@Test("Comments created at the same moment fall back to folder name, never to the card's path")
func tiesFallBackToTheFolderName() throws {
let stamp = "2026-07-31T09:30:00Z"
let message = try compose { fixture in
// On the *first* card, the larger identity.
try fixture.file(
Self.thread(Ident.card1, Self.commentC),
Self.datedComment("On Fix login.", created: stamp)
)
// On the second card, the smaller one.
try fixture.file(
Self.thread(Ident.card2, Self.commentA),
Self.datedComment("On Ship it.", created: stamp)
)
}
#expect(body(of: message) == [
"- Comment on 'Ship it'",
"- Comment on 'Fix login'",
])
}
/// The undated sort **after** the dated — `CommentThread.sorted`'s own fallback, applied one layer
/// up. The undated comment here carries the smallest identity, so folder-name order alone would
/// have put it first.
@Test("A comment with no readable created sorts after its dated siblings")
func undatedCommentsSortLast() throws {
let message = try compose { fixture in
try fixture.file(Self.thread(Ident.card1, Self.commentA), Self.commentText("No stamp at all."))
try fixture.file(
Self.thread(Ident.card2, Self.commentB),
Self.datedComment("Stamped.", created: "2026-07-31T08:00:00Z")
)
}
#expect(body(of: message) == [
"- Comment on 'Ship it'",
"- Comment on 'Fix login'",
])
}
}
// MARK: - Repair
@Suite("Commit messages ▸ Repair")
struct CommitMessageRepairTests {
/// The remint's exact shape, assembled rather than provoked.
///
/// What `BoardWriter.remintDuplicateIdentity` does on disk is rename the losing folder in place to
/// a fresh identity, so the survey reports a rename pair. What the *snapshots* show is only the
/// arrival — because the previous load withheld the duplicate (`BoardLoader.dedupeIdentities`),
/// its id was never in a board model at all. Provoking that by writing two folders with one id
/// would make the test depend on which twin the dedupe happened to keep; the shape the composer
/// is asked about is this, and it is stated directly.
private func remintMessage(authorship: CommitAuthorship) throws -> String {
let before = try WriterFixture()
defer { before.tearDown() }
let after = try WriterFixture()
defer { after.tearDown() }
try baseBoard(before)
try baseBoard(after)
try after.card(Ident.card4, in: Ident.lane1, order: "1024", title: "Fix login")
let departed = "\(Ident.lane1)/\(Ident.card3)/index.md"
let arrived = "\(Ident.lane1)/\(Ident.card4)/index.md"
let paths = changedPaths(from: before.root, to: after.root).map { path in
path.path == arrived
? GitChangedPath(path: arrived, isDeletion: false, isRename: true, isArrival: true)
: path
} + [GitChangedPath(path: departed, isDeletion: true, isRename: true)]
return CommitMessageEngine.message(for: CommitMessageRequest(
boardRoot: after.root,
changedPaths: paths,
authorship: authorship,
isRootCommit: false,
snapshot: try after.snapshot(),
previousSnapshot: try before.snapshot()
))
}
@Test("A heal window's folder remint reads as Repair, not as an arrival")
func theRemintComposesRepair() throws {
#expect(try remintMessage(authorship: .heal) == "Repair duplicate of 'Fix login'")
}
@Test("The same shape outside a heal window is an ordinary arrival")
func repairIsHealOnly() throws {
#expect(subject(of: try remintMessage(authorship: .user)) == "Add card 'Fix login'")
}
}
// MARK: - The root commit
@Suite("Commit messages ▸ the root commit")
struct CommitMessageRootCommitTests {
@Test("The repository's first commit has the one fixed subject")
func theRootCommitIsFixed() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try baseBoard(fixture)
let message = CommitMessageEngine.message(for: CommitMessageRequest(
boardRoot: fixture.root,
changedPaths: changedPaths(from: fixture.root, to: fixture.root),
authorship: .user,
isRootCommit: true,
snapshot: try fixture.snapshot(),
previousSnapshot: nil
))
#expect(message == GitRepository.initialCommitSubject)
}
}