Files
lanework/KanbanTests/CommitMessageTests.swift
T
rzen 563999655f Build the semantic commit-message engine
CommitMessageEngine replaces the interim composer as the wired
default: a pure total function from two snapshots + changed paths to
a message. Full vocabulary — Add / Delete / Move / Rename / Edit /
Restyle / Resize / Reorder over cards, lanes, board; Attach / Remove;
Repair for the duplicate remint (detected as a heal-classed
rename-paired arrival whose id the previous snapshot never held —
the loader withholds duplicates, so the shape is a bare arrival);
the trash triple by diff shape alone (into .trash = Delete, out =
Restore, leaving the tree = Permanently delete); Relabel / Assign /
Set due date plus the named generic for custom keys. Plural folding
with shared destinations, implied events as body bullets never
subjects, ~40-char subject truncation, "(untitled)". Bookkeeping
(sequence-preserving renumbers, stamps, backfilled kind) composes
nothing. Non-snapshot paths compose path-shaped events — CLAUDE.md
reads "Update agent guide (vN)" via the marker line (the m10 card's
deferred bullet lands here), everything else "Update '<path>'".

The comment verb family per 01's ruling (comments shipped, so 06
gains the verbs): Comment on / Edit comment on / Delete comment on /
Draft comment on / Permanently delete comment on '<card>', grouped
one event per comment folder, classified ahead of the model-silence
rules, card title resolved from either snapshot. GIT_DELTA_ADDED is
surfaced as GitChangedPath.isArrival — post vs edit is unanswerable
from snapshots that exclude comments by ruling. A card moving with
its thread swallows the comment events (implied-events one level
down).

The previous snapshot is HEAD's tree, materialized per flush into a
temp dir (index.md blobs in full, other blobs zero-byte — the model
reads attachment names, never bytes) and re-parsed through the one
BoardLoader; never a value carried forward. changedPaths is a hard
filter per split commit, which also earns the stage-around and kills
phantom events. Launch catch-up and foreign windows compose through
the same engine.

48 new tests (35 pure + comment family + engine-level); 2293 tests /
394 suites green; InertGitTests untouched.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-31 14:54:17 -04:00

825 lines
35 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
))
}
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: 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 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 is 'Update board' — with every event in the body")
func mixedWindowsFallBack() throws {
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) == CommitMessageEngine.mixedSubject)
#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 composes a named generic — never a board-level shrug")
func customKeysComposeANamedGeneric() throws {
let message = try compose { fixture in
try fixture.item(
"\(Ident.lane1)/\(Ident.card1)",
"---\nschema: 1\ntitle: Fix login\norder: 1024\nsprint: 42\nestimate: 3\n---\n\n"
)
}
// Two custom keys, one event: the item is what is named, not the keys.
#expect(message == "Update card 'Fix login'")
}
@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.mixedSubject)
}
@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.mixedSubject)
}
@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.mixedSubject)
}
@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.mixedSubject)
#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: - 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)
}
}