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 = [], 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 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 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: "external@lanework.invalid"))) 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 = "\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//`, 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: - 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) } }