The full bullet list from Implementation card bf080d9a — both ruling batches, including the three appended mid-session by16ef377: - Restore subjects compose the inverse, never nest: crossing "Undo: S" emits "Redo: S" and vice versa; parity, not stack depth, reads a legacy double prefix (GitHistoryProvider.restoreSubject). - Git-operation failures join the one-shot failure banner tier: BannerCenter.GitFailureBanner (undo/redo/branchSwitch/addGit), error tone at failure rank merged with write one-shots by recency; the postLoss compromise is retired at both AppModel wirings. - order/schema optional below the board root: append-at-end reading (ordered siblings first, folder-name tie-break among the order-less), schema reads 1, both coerce-tier logged; the root keeps its requirements. Ranks.resolvedOrders materializes finite ranks so models and placement math stay untouched; first Writer rewrite stamps a real rank on touch, placement against an order-less sibling stamps that sibling inline in the same bracket. Agent guide v10 teaches optional keys and zero-read filing. Hostile-YAML order shapes become coercion tests; Fixtures/Valid/optional-keys.kanban replaces the four retired Malformed boards. - .gitignore is the relocation-heal noise gate: GitignoreRules pure matcher (standard semantics, board-root file only), loader consults it once per walk so matched loose files keep the stray posture; seeded (.DS_Store + .*.lanework-*) at board creation and template instantiation, healed in when missing at open — repo-nested included; empty file honored, existing files never edited; the committer's obedience via libgit2 status is pinned by test. - Comments crash-residue sweep gates on step ownership: HistoryStep derives backing from its own undo expectations, backedContent unions both stacks, the sweep purges per-entry only what no live step owns. - Skip-purge decoupled (16ef377): a stale-skipped coarse step strands whole in NativeHistoryProvider.strandedSteps — still backing, retired only at session end; clean exits purge as before. - Coarse close step named "Changes to '<card>'"; the fine body-edit wording never leaks onto the board menu. - Branch-switch settle clears every open card window's fine stack on Save All and Discard alike; the empty fold registers no coarse step. - Close flush awaits its covering snapshot (quiesce + one generation bump, 1s bound), and an explicit flush now queues behind an in-flight one instead of skipping — the audit-caught interleaving could lose a close flush permanently when the debounce fired inside the close sequence; regression tests force both races. - Commit comment bullets sort chronologically by created, not UUID. - The production-unwired CardBodyEditSession.editSessionDidChange seam is deleted with its seam-only tests. - Composition-root pins: beginSession composes the committer with the store's own EchoLedger and binds the announcer (the miswire class). - Deliberate 06 conformance pass over every 2026-07-31-tagged sentence: fixed Change-custom-key subjects (the retired named generic was the only producer), the unbuilt Replace attachment vocabulary, heal commits now authored Lanework Integrity, the config reader scopes identity to plain [user] sections, add-git re-runs detection at create (a stale mode-none could initialize inside the user's repo), and add-git failures answer at the form or the banner. Structural residue filed on the Redesign board. 2554 tests / 439 suites green. Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
999 lines
44 KiB
Swift
999 lines
44 KiB
Swift
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: 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)
|
||
}
|
||
}
|