Make the card window the commit unit on Pro boards
Phase C of the two-level undo card: the committer stages around the
whole open card folder — comments included — so gestures in an open
window never land in interim commits; window close flushes the session
as one semantically-named commit ("Edit card 'X'" with the thread as
body bullets, "Mixed update — N changes to card 'X'" when events mix),
with the two-commit foreign/user split preserved and the
comments/.trash purge riding the same bracket. Comment gestures lose
their per-gesture commits structurally (they write inside the held
folder). Branch-switch settle releases every window's staging before
checkout and re-arms on resume.
Fixes two latent pro-m1 defects: the committer was composed without
the store's EchoLedger, so every production commit classified foreign
and was authored Lanework External; and interim flushes dropped
harvest receipts they had not spent, unvouching the session's own
writes at close. Also lands 06's mixed-subject re-ruling (the retired
"Update board" fallback) and phase B's two files missed by the
previous commit's pathspec.
2444 tests in 422 suites green.
Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
@@ -439,7 +439,7 @@ struct AutoCommitAttributionTests {
|
||||
// MARK: - The stage-around
|
||||
|
||||
@MainActor
|
||||
@Suite("Auto-commit ▸ staging around open Edit sessions")
|
||||
@Suite("Auto-commit ▸ staging around open card windows")
|
||||
struct AutoCommitStageAroundTests {
|
||||
|
||||
@Test("A lane move mid-session commits the move without touching the session card's folder")
|
||||
@@ -449,7 +449,7 @@ struct AutoCommitStageAroundTests {
|
||||
let committer = try quickCommitter(git)
|
||||
|
||||
let sessionFolder = fixture.url("\(Ident.lane1)/\(Ident.card1)")
|
||||
committer.beginEditSession(UUID()) { sessionFolder }
|
||||
committer.beginCardSession(UUID()) { sessionFolder }
|
||||
|
||||
// The editor's ~700 ms save lands on disk, uncommitted…
|
||||
try fixture.item("\(Ident.lane1)/\(Ident.card1)", plain(order: "1024", title: "First", body: "half-typed"))
|
||||
@@ -474,7 +474,7 @@ struct AutoCommitStageAroundTests {
|
||||
let committer = try quickCommitter(git)
|
||||
|
||||
let sessionFolder = fixture.url("\(Ident.lane1)/\(Ident.card1)")
|
||||
committer.beginEditSession(UUID()) { sessionFolder }
|
||||
committer.beginCardSession(UUID()) { sessionFolder }
|
||||
|
||||
try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/diagram.txt", Data("x\n".utf8))
|
||||
try fixture.file("elsewhere.txt", Data("y\n".utf8))
|
||||
@@ -486,7 +486,7 @@ struct AutoCommitStageAroundTests {
|
||||
.contains("\(Ident.lane1)/\(Ident.card1)/attachments/diagram.txt"))
|
||||
}
|
||||
|
||||
@Test("Ending the session produces exactly one body commit for it")
|
||||
@Test("Ending the session produces exactly one commit for it")
|
||||
func endingTheSessionCommitsOnce() async throws {
|
||||
let (fixture, git, _) = try await makeGitBoard()
|
||||
defer { fixture.tearDown() }
|
||||
@@ -494,7 +494,7 @@ struct AutoCommitStageAroundTests {
|
||||
|
||||
let token = UUID()
|
||||
let sessionFolder = fixture.url("\(Ident.lane1)/\(Ident.card1)")
|
||||
committer.beginEditSession(token) { sessionFolder }
|
||||
committer.beginCardSession(token) { sessionFolder }
|
||||
|
||||
// Three debounced saves inside one session — each a real write, none of them a commit
|
||||
// ("the body editor's ~700 ms disk saves … stay uncommitted").
|
||||
@@ -506,11 +506,12 @@ struct AutoCommitStageAroundTests {
|
||||
}
|
||||
#expect(committer.commitCount == 0, "no save tick may become a commit")
|
||||
|
||||
// The Edit→Preview flip — "the effective Save button".
|
||||
committer.endEditSession(token)
|
||||
// The window close — "window close flushes the session as one commit" (06 ▸ Rules
|
||||
// ▸ Auto-commit, widened 2026-07-31: the unit is the window, not the Edit→Preview flip).
|
||||
committer.endCardSession(token)
|
||||
try await waitUntil { committer.commitCount == 1 }
|
||||
|
||||
#expect(committer.commitCount == 1, "exactly one body commit per session")
|
||||
#expect(committer.commitCount == 1, "exactly one commit per card-window session")
|
||||
#expect(isClean(at: fixture.root))
|
||||
}
|
||||
|
||||
@@ -523,7 +524,7 @@ struct AutoCommitStageAroundTests {
|
||||
// The registry holds a resolver, not a URL, so a lane move under an open session keeps the
|
||||
// right folder excluded rather than the one Edit was entered in.
|
||||
var lane = Ident.lane1
|
||||
committer.beginEditSession(UUID()) { fixture.url("\(lane)/\(Ident.card1)") }
|
||||
committer.beginCardSession(UUID()) { fixture.url("\(lane)/\(Ident.card1)") }
|
||||
|
||||
try fixture.item(Ident.lane2, plain(order: "2048", title: "Doing"))
|
||||
try fixture.moveFolder("\(Ident.lane1)/\(Ident.card1)", to: "\(Ident.lane2)/\(Ident.card1)")
|
||||
@@ -544,7 +545,7 @@ struct AutoCommitStageAroundTests {
|
||||
defer { fixture.tearDown() }
|
||||
let committer = try quickCommitter(git)
|
||||
|
||||
committer.beginEditSession(UUID()) { fixture.url("\(Ident.lane1)/\(Ident.card1)") }
|
||||
committer.beginCardSession(UUID()) { fixture.url("\(Ident.lane1)/\(Ident.card1)") }
|
||||
try fixture.item("\(Ident.lane1)/\(Ident.card1)", plain(order: "1024", title: "First", body: "typing"))
|
||||
committer.noteWriteBracketClosed()
|
||||
await committer.flushNow()
|
||||
|
||||
@@ -0,0 +1,570 @@
|
||||
import Foundation
|
||||
import SwiftGitX
|
||||
import Testing
|
||||
@testable import Kanban
|
||||
|
||||
/// **The card window's session as the commit unit** (06-history-undo.md ▸ Rules ▸ Auto-commit,
|
||||
/// widened 2026-07-31; 13-native-undo.md ▸ Interaction with the trash; 05-card-window.md ▸ The
|
||||
/// comments column).
|
||||
///
|
||||
/// > Board history sees **card-window sessions, not gestures** … while a card's window is open,
|
||||
/// > everything happening inside it … stays **uncommitted**, and the committer **stages around the
|
||||
/// > whole open card folder** … **window close flushes the session as one commit**.
|
||||
///
|
||||
/// The claims here are all about *when* a commit exists, which is exactly the class of thing that
|
||||
/// looks right in a running app and is wrong: a comment post that quietly landed its own commit, a
|
||||
/// session's body arriving under `Lanework External` because an interim flush spent its receipt, a
|
||||
/// `comments/.trash/` purge that committed separately from the delete it belongs to. So every test
|
||||
/// runs a **real** repository over bundled libgit2, drives the window through the same seams
|
||||
/// `CardWindowHost` wires, and reads every commit back through libgit2 rather than through the engine
|
||||
/// that made it. Nothing shells out to `git` (`AutoCommitTests`' rule, kept).
|
||||
|
||||
// MARK: - Fixtures
|
||||
|
||||
private let cardID = ItemID(rawValue: Ident.card1)
|
||||
private let cardPath = "\(Ident.lane1)/\(Ident.card1)"
|
||||
private let earlierComment = CommentIdent.one
|
||||
|
||||
/// One card window on a Pro git board — the store, the committer, and the session, wired to each
|
||||
/// other exactly as `AppModel.beginSession` and `CardWindowHost` wire them.
|
||||
///
|
||||
/// The stage-around is opened and closed through `open()` / `close()` below, which spell what
|
||||
/// `AppModel.setCardSession(_:for:)` does; that method's *own* wiring — that a card window's
|
||||
/// registration is what opens it — is pinned separately in `CardSessionStagingWiringTests`, over a
|
||||
/// real `AppModel`.
|
||||
@MainActor
|
||||
private final class Window {
|
||||
|
||||
let fixture: WriterFixture
|
||||
let store: BoardStore
|
||||
let git: HistoryStore
|
||||
let committer: GitAutoCommitter
|
||||
let session = CardWindowSession()
|
||||
|
||||
/// Commits the board already had when the window opened — every assertion here is a delta, so a
|
||||
/// board-open heal landing in the setup cannot be mistaken for a session's commit.
|
||||
private(set) var baseline = 0
|
||||
|
||||
private var token: UUID?
|
||||
|
||||
init(fixture: WriterFixture, store: BoardStore, git: HistoryStore, committer: GitAutoCommitter) {
|
||||
self.fixture = fixture
|
||||
self.store = store
|
||||
self.git = git
|
||||
self.committer = committer
|
||||
}
|
||||
|
||||
var comments: CardComments { session.comments }
|
||||
var body: CardBodyEditSession { session.body }
|
||||
|
||||
/// Commits landed since the window opened.
|
||||
var commits: Int { committer.commitCount - baseline }
|
||||
|
||||
func recordBaseline() {
|
||||
baseline = committer.commitCount
|
||||
}
|
||||
|
||||
/// The window joins its board — `AppModel.registerCardWindow`, whose one git consequence is this
|
||||
/// exclusion.
|
||||
func open() {
|
||||
let token = UUID()
|
||||
self.token = token
|
||||
committer.beginCardSession(token) { [weak store] in
|
||||
guard let store,
|
||||
let path = BoardStore.cardBodyTarget(cardID, in: store.snapshot) else { return nil }
|
||||
return path.folder(under: store.rootURL)
|
||||
}
|
||||
session.comments.open()
|
||||
}
|
||||
|
||||
/// The close, in the order production runs it: the session's own writes land, *then* the folder is
|
||||
/// released, *then* the store settles, *then* the pipeline flushes (`CardWindowHost.finish`,
|
||||
/// `CloseFlushCoordinator.flushPendingWork` — "the store's pipeline, then the editor saves, then
|
||||
/// the pending commit").
|
||||
///
|
||||
/// The quiescence matters to the *message*, not to the commit: the composer diffs the store's
|
||||
/// snapshot against HEAD's tree, so a flush that raced the session's own reload would describe the
|
||||
/// window by its comment events alone. Production gets the same ordering from the committer's
|
||||
/// two-second debounce outliving the watcher's.
|
||||
func close() async {
|
||||
await session.endSession()
|
||||
if let token { committer.endCardSession(token) }
|
||||
token = nil
|
||||
await settle()
|
||||
await committer.flushNow()
|
||||
}
|
||||
|
||||
/// Brings the store's snapshot up to what the session wrote, then waits for it to settle — the
|
||||
/// close flush's own first step (`CloseFlushCoordinator.flushPendingWork`: "the store's pipeline,
|
||||
/// then the editor saves, then the pending commit").
|
||||
///
|
||||
/// The reload is delivered by hand because this store has no watcher: the registry is what wires
|
||||
/// `FolderWatcher` to `handleWatcherEvent(_:)` in production, and a suite that acquired one would
|
||||
/// be testing FSEvents. What matters here is the *ordering* — the composer diffs the store's
|
||||
/// snapshot against HEAD's tree, so a flush that ran ahead of the session's own reload would
|
||||
/// describe the window by its comment events alone and lose the body edit.
|
||||
func settle() async {
|
||||
store.handleWatcherEvent(.treeChanged(.appMediated))
|
||||
await store.awaitQuiescence()
|
||||
}
|
||||
|
||||
/// The half of the close that happens before the release — used to prove the release is what
|
||||
/// unblocks the commit rather than the passage of time.
|
||||
func endSessionOnly() async {
|
||||
await session.endSession()
|
||||
}
|
||||
|
||||
func releaseAndFlush() async {
|
||||
if let token { committer.endCardSession(token) }
|
||||
token = nil
|
||||
await settle()
|
||||
await committer.flushNow()
|
||||
}
|
||||
}
|
||||
|
||||
/// A board with a card, one already-posted comment, a repository, and a root commit that has all of
|
||||
/// it — the state a card window opens over.
|
||||
@MainActor
|
||||
private func makeWindow() async throws -> Window {
|
||||
let fixture = try WriterFixture()
|
||||
try fixture.item("", Item.board)
|
||||
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
||||
try fixture.item(cardPath, Item.rich(order: "1024", title: "Fix login"))
|
||||
try fixture.item(commentPath(earlierComment, inCard: cardPath), commentText(body: "posted earlier\n"))
|
||||
|
||||
// The store first, and settled, so the board-open heals (the agent guide) are on disk *before*
|
||||
// the root commit rather than arriving as a mystery commit in the middle of a test.
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
await store.awaitQuiescence()
|
||||
|
||||
let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro, ledger: store.echoes))
|
||||
#expect(await git.addGit())
|
||||
|
||||
let committer = try #require(git.committer)
|
||||
// Long enough that **only** an explicit `flushNow()` commits: "zero commits until close" has to be
|
||||
// a fact about the stage-around, not about a debounce that had not fired yet.
|
||||
committer.debounceInterval = .seconds(60)
|
||||
committer.lockRetryDelay = .milliseconds(5)
|
||||
committer.currentSnapshot = { [weak store] in store?.snapshot }
|
||||
store.commitSeam = .binding(to: committer)
|
||||
|
||||
let window = Window(fixture: fixture, store: store, git: git, committer: committer)
|
||||
|
||||
// The window's own seams, `CardWindowHost.configureSession`'s three lines.
|
||||
CardWindowHost.configureUndo(window.session, store: store, cardID: cardID)
|
||||
CardWindowHost.configureComments(window.session.comments, store: store, cardID: cardID, on: window.session.undo)
|
||||
window.session.comments.isEditable = true
|
||||
window.session.comments.cardFolder = fixture.url(cardPath)
|
||||
window.session.body.save = { [weak store] text in
|
||||
store?.writeCardBody(inCard: cardID, body: text) ?? .vanished
|
||||
}
|
||||
window.session.body.adopt(diskBody: try FrontmatterDocument.parse(fixture.indexText(cardPath)).body)
|
||||
|
||||
// One reconciling reload lands the board-open heals (the agent guide), and whatever the setup
|
||||
// left dirty commits now — so every assertion below is about the session and nothing else.
|
||||
await window.settle()
|
||||
await committer.flushNow()
|
||||
#expect(isClean(at: fixture.root), "the window opens over a settled tree")
|
||||
window.recordBaseline()
|
||||
return window
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func editBody(_ window: Window, to text: String) {
|
||||
window.body.beginEditSession()
|
||||
window.body.edited(text)
|
||||
window.body.endEditSession()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@discardableResult
|
||||
private func postComment(_ window: Window, body: String) -> ItemID? {
|
||||
window.comments.composer.edited(body)
|
||||
_ = window.comments.composer.flush()
|
||||
return window.comments.composer.postNow()
|
||||
}
|
||||
|
||||
// MARK: Reading the repository back
|
||||
|
||||
private struct Landed: Equatable {
|
||||
let subject: String
|
||||
let message: String
|
||||
let authorEmail: String
|
||||
}
|
||||
|
||||
/// HEAD's first-parent ancestry, newest first — read through SwiftGitX, never through the committer.
|
||||
private func landed(at boardRoot: URL, limit: Int = 32) throws -> [Landed] {
|
||||
let repository = try Repository.open(at: boardRoot)
|
||||
guard !repository.isHEADUnborn, let tip = try repository.HEAD.target as? Commit else { return [] }
|
||||
var records: [Landed] = []
|
||||
var current: Commit? = tip
|
||||
while let commit = current, records.count < limit {
|
||||
records.append(Landed(
|
||||
subject: commit.summary,
|
||||
message: commit.message,
|
||||
authorEmail: commit.author.email
|
||||
))
|
||||
current = (try? commit.parents)?.first
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
private func isClean(at boardRoot: URL) -> Bool {
|
||||
GitCommitOperation.changedPaths(at: boardRoot).isEmpty
|
||||
}
|
||||
|
||||
private func tracked(at boardRoot: URL) -> Set<String> {
|
||||
Set(GitRepository.trackedPaths(at: boardRoot))
|
||||
}
|
||||
|
||||
// MARK: - The close flush
|
||||
|
||||
@MainActor
|
||||
@Suite("Card session commits ▸ the close flush")
|
||||
struct CardSessionCloseFlushTests {
|
||||
|
||||
@Test("A body edit, a comment post and a comment delete commit nothing until the window closes")
|
||||
func theSessionIsTheCommitUnit() async throws {
|
||||
let window = try await makeWindow()
|
||||
defer { window.fixture.tearDown() }
|
||||
window.open()
|
||||
|
||||
editBody(window, to: "Edited in the window.\n")
|
||||
let posted = try #require(postComment(window, body: "A remark.\n"))
|
||||
#expect(window.comments.deleteComment?(ItemID(rawValue: earlierComment)) == true)
|
||||
|
||||
// Not "the debounce has not fired": the flush runs, sees the whole card folder staged around,
|
||||
// and commits nothing.
|
||||
await window.committer.flushNow()
|
||||
#expect(window.commits == 0, "no gesture inside an open card window is a commit")
|
||||
#expect(!isClean(at: window.fixture.root), "the session's writes are on disk, uncommitted")
|
||||
#expect(window.fixture.exists("\(cardPath)/comments/.trash/\(earlierComment)"),
|
||||
"and the purge has not run: it belongs inside the close flush")
|
||||
|
||||
await window.close()
|
||||
|
||||
#expect(window.commits == 1, "window close flushes the session as one commit")
|
||||
#expect(isClean(at: window.fixture.root))
|
||||
|
||||
// One commit, three changes: "'Update card 'Fix login''-shaped, the composer folding the
|
||||
// card-scoped diff, body bullets carrying the events" (06 ▸ Rules ▸ Auto-commit) — the model
|
||||
// event keeps the subject, the thread rides in the body.
|
||||
let head = try #require(try landed(at: window.fixture.root).first)
|
||||
#expect(head.subject == "Edit card 'Fix login'")
|
||||
// A set, because the thread's two events sort by comment id and the posted one's is minted
|
||||
// fresh every run — the *events* are the claim, not their order among themselves.
|
||||
#expect(Set(head.message.split(separator: "\n").filter { $0.hasPrefix("- ") }) == [
|
||||
"- Edit card 'Fix login'",
|
||||
"- Comment on 'Fix login'",
|
||||
"- Delete comment on 'Fix login'",
|
||||
])
|
||||
|
||||
let paths = tracked(at: window.fixture.root)
|
||||
#expect(paths.contains("\(cardPath)/comments/\(posted.rawValue)/index.md"),
|
||||
"the post is in the commit")
|
||||
#expect(!paths.contains("\(cardPath)/comments/\(earlierComment)/index.md"),
|
||||
"so is the delete")
|
||||
#expect(!paths.contains { $0.hasPrefix("\(cardPath)/comments/.trash/") },
|
||||
"and the purge — delete plus purge net to a removal (13 ▸ Interaction with the trash)")
|
||||
#expect(!window.fixture.exists("\(cardPath)/comments/.trash/\(earlierComment)"))
|
||||
}
|
||||
|
||||
@Test("The release is what unblocks the commit, not the end of the session's writes")
|
||||
func theReleaseIsTheGate() async throws {
|
||||
let window = try await makeWindow()
|
||||
defer { window.fixture.tearDown() }
|
||||
window.open()
|
||||
|
||||
editBody(window, to: "Edited in the window.\n")
|
||||
|
||||
// Everything the session owed disk is written, and the folder is still held.
|
||||
await window.endSessionOnly()
|
||||
await window.committer.flushNow()
|
||||
#expect(window.commits == 0)
|
||||
|
||||
await window.releaseAndFlush()
|
||||
#expect(window.commits == 1)
|
||||
}
|
||||
|
||||
@Test("A session with no net change registers no commit at all")
|
||||
func anEmptySessionCommitsNothing() async throws {
|
||||
let window = try await makeWindow()
|
||||
defer { window.fixture.tearDown() }
|
||||
window.open()
|
||||
|
||||
await window.close()
|
||||
|
||||
#expect(window.commits == 0, "a window that was only read is not an event")
|
||||
#expect(window.committer.lastFailure == nil, "an empty window is a no-op, never a failure")
|
||||
#expect(isClean(at: window.fixture.root))
|
||||
}
|
||||
|
||||
@Test("A session mixing two model events keeps the card's name in the subject")
|
||||
func aMixedSessionNamesItsCard() async throws {
|
||||
let window = try await makeWindow()
|
||||
defer { window.fixture.tearDown() }
|
||||
window.open()
|
||||
|
||||
// Two *model* kinds — an edit and a restyle — so no single verb can head the window. 06's
|
||||
// retired "Update board" is exactly the subject that could not say which card this was.
|
||||
editBody(window, to: "Edited in the window.\n")
|
||||
window.store.applyStyle(to: .items([cardID]), background: .set("#334455"), on: window.session.undo)
|
||||
postComment(window, body: "A remark.\n")
|
||||
|
||||
await window.close()
|
||||
|
||||
#expect(window.commits == 1)
|
||||
let head = try #require(try landed(at: window.fixture.root).first)
|
||||
#expect(head.subject == "Mixed update — 3 changes to card 'Fix login'")
|
||||
}
|
||||
|
||||
@Test("A draft the session never posted rides the close flush too, as one commit")
|
||||
func theDraftRidesTheClose() async throws {
|
||||
let window = try await makeWindow()
|
||||
defer { window.fixture.tearDown() }
|
||||
window.open()
|
||||
|
||||
window.comments.composer.edited("half a thought\n")
|
||||
_ = window.comments.composer.flush()
|
||||
await window.committer.flushNow()
|
||||
#expect(window.commits == 0, "the draft-save cadence never becomes a commit stream")
|
||||
|
||||
await window.close()
|
||||
#expect(window.commits == 1)
|
||||
#expect(tracked(at: window.fixture.root).contains("\(cardPath)/comments/.draft/index.md"))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Board-side work, and the split
|
||||
|
||||
@MainActor
|
||||
@Suite("Card session commits ▸ what an open window does not hold back")
|
||||
struct CardSessionInterimCommitTests {
|
||||
|
||||
@Test("Board-side changes commit normally while a card window is open")
|
||||
func theRestOfTheBoardIsUnaffected() async throws {
|
||||
let window = try await makeWindow()
|
||||
defer { window.fixture.tearDown() }
|
||||
window.open()
|
||||
|
||||
editBody(window, to: "Edited in the window.\n")
|
||||
try window.fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
|
||||
window.committer.noteReloadLanded(sawForeignChange: true)
|
||||
await window.committer.flushNow()
|
||||
|
||||
#expect(window.commits == 1, "the board's own change is not held by somebody's card window")
|
||||
#expect(tracked(at: window.fixture.root).contains("\(Ident.lane2)/\(BoardLoader.indexFileName)"))
|
||||
#expect(!isClean(at: window.fixture.root), "and the session folder is still held back")
|
||||
|
||||
await window.close()
|
||||
#expect(window.commits == 2)
|
||||
#expect(isClean(at: window.fixture.root))
|
||||
}
|
||||
|
||||
@Test("A held window mixing foreign work with the session's splits into two commits at close")
|
||||
func theTwoCommitSplitSurvivesTheHeldWindow() async throws {
|
||||
let window = try await makeWindow()
|
||||
defer { window.fixture.tearDown() }
|
||||
window.open()
|
||||
|
||||
// The app's own gesture, vouched for by a receipt in the store's ledger.
|
||||
postComment(window, body: "Mine.\n")
|
||||
|
||||
// Somebody else's, inside the same card folder — an agent dropping a file the app never
|
||||
// witnessed. It is held back by the same exclusion, so the close flush is the first moment it
|
||||
// can land, and the split is what keeps it out of the user's commit.
|
||||
try window.fixture.file("\(cardPath)/attachments/notes.txt", Data("theirs\n".utf8))
|
||||
window.committer.noteReloadLanded(sawForeignChange: true)
|
||||
|
||||
// An interim flush that commits nothing must not spend the session's receipts — this is the
|
||||
// line the whole split depends on.
|
||||
await window.committer.flushNow()
|
||||
#expect(window.commits == 0)
|
||||
|
||||
await window.close()
|
||||
|
||||
#expect(window.commits == 2, "foreign and app-mediated never mix in one commit")
|
||||
let trail = try landed(at: window.fixture.root)
|
||||
let user = GitCommitOperation.userIdentity(at: window.fixture.root).email
|
||||
#expect(trail.first?.authorEmail == user, "the user's overwrite lands after the foreign version")
|
||||
#expect(trail.dropFirst().first?.authorEmail == CommitAttribution.externalAuthorEmail)
|
||||
#expect(trail.first?.subject.contains("Comment on 'Fix login'") == true)
|
||||
#expect(isClean(at: window.fixture.root))
|
||||
}
|
||||
|
||||
@Test("A second card window's session is held independently of the first's")
|
||||
func sessionsAreHeldPerCard() async throws {
|
||||
let window = try await makeWindow()
|
||||
defer { window.fixture.tearDown() }
|
||||
try window.fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second"))
|
||||
window.committer.noteReloadLanded(sawForeignChange: true)
|
||||
await window.committer.flushNow()
|
||||
window.recordBaseline()
|
||||
|
||||
window.open()
|
||||
let other = UUID()
|
||||
window.committer.beginCardSession(other) { window.fixture.url("\(Ident.lane1)/\(Ident.card2)") }
|
||||
|
||||
editBody(window, to: "Edited in the window.\n")
|
||||
try window.fixture.file("\(Ident.lane1)/\(Ident.card2)/attachments/theirs.txt", Data("typing\n".utf8))
|
||||
window.committer.noteReloadLanded(sawForeignChange: true)
|
||||
await window.committer.flushNow()
|
||||
#expect(window.commits == 0, "two held folders, nothing to commit")
|
||||
|
||||
await window.close()
|
||||
#expect(window.commits == 1, "the first window's session, and only it")
|
||||
#expect(!isClean(at: window.fixture.root), "the second card is still somebody's open session")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Crossing the session commit
|
||||
|
||||
@MainActor
|
||||
@Suite("Card session commits ▸ undo crosses the session")
|
||||
struct CardSessionRestoreTests {
|
||||
|
||||
@Test("Board ⌘Z after the close crosses the session commit and restores the deleted comment")
|
||||
func theSessionCommitIsOneUndoStep() async throws {
|
||||
let window = try await makeWindow()
|
||||
defer { window.fixture.tearDown() }
|
||||
|
||||
let provider = GitHistoryProvider(boardRoot: window.fixture.root)
|
||||
provider.flushPendingCommit = { [weak committer = window.committer] in await committer?.flushNow() }
|
||||
provider.isHeld = { [weak committer = window.committer] in committer?.pause != nil }
|
||||
provider.suspendCommitting = { [weak committer = window.committer] in committer?.stop() }
|
||||
provider.resumeCommitting = { [weak committer = window.committer] in committer?.start() }
|
||||
window.committer.reportLanded = { [weak provider] landed in provider?.noteLanded(landed) }
|
||||
await provider.reseed()
|
||||
|
||||
window.open()
|
||||
editBody(window, to: "Edited in the window.\n")
|
||||
postComment(window, body: "A remark.\n")
|
||||
#expect(window.comments.deleteComment?(ItemID(rawValue: earlierComment)) == true)
|
||||
await window.close()
|
||||
await provider.settled()
|
||||
|
||||
#expect(window.commits == 1)
|
||||
#expect(provider.canUndo, "the close commit is an ordinary step on the board's stack")
|
||||
|
||||
await provider.cross(.undo)
|
||||
|
||||
// A forward restore, never a rewrite (14-git-operations.md ▸ The forward-restore model).
|
||||
let trail = try landed(at: window.fixture.root)
|
||||
#expect(trail.first?.subject.hasPrefix("Undo: ") == true)
|
||||
#expect(try FrontmatterDocument.parse(window.fixture.indexText(cardPath)).body
|
||||
!= "Edited in the window.\n", "the session's body edit is undone")
|
||||
#expect(window.fixture.exists("\(cardPath)/comments/\(earlierComment)"),
|
||||
"and the purged comment came back out of history — the whole session, in one step")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The staging wiring
|
||||
|
||||
/// What `AppModel` itself owes the rule: which moment opens the exclusion, which closes it, and the
|
||||
/// settle step's release. Over a real model and a real board session, because every one of these is a
|
||||
/// claim about production wiring rather than about the committer's own grammar.
|
||||
@MainActor
|
||||
@Suite("Card session commits ▸ the staging wiring")
|
||||
struct CardSessionStagingWiringTests {
|
||||
|
||||
private func makeModel() throws -> (model: AppModel, tearDown: () -> Void) {
|
||||
let folder = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("CardSessionCommitTests-\(UUID().uuidString)", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
|
||||
let model = AppModel(
|
||||
registryStorageURL: folder.appendingPathComponent("board-registry.json"),
|
||||
clipboardStagingRoot: folder.appendingPathComponent("Clipboard", isDirectory: true)
|
||||
)
|
||||
model.currentTier = { .pro }
|
||||
return (model, { try? FileManager.default.removeItem(at: folder) })
|
||||
}
|
||||
|
||||
private func openBoard(_ model: AppModel, at url: URL) throws -> BoardWindowRef {
|
||||
let ref = BoardWindowRef(url: url)
|
||||
let recordID = model.boardRegistry.recordOpen(of: url)
|
||||
let store = try model.storeRegistry.acquire(url)
|
||||
model.beginSession(ref: ref, store: store, recordID: recordID, access: nil)
|
||||
return ref
|
||||
}
|
||||
|
||||
/// A Pro git board, opened through the model — so the committer under test is the one production
|
||||
/// composes, ledger and all.
|
||||
private func makeBoard(_ model: AppModel) async throws -> (fixture: WriterFixture, ref: BoardWindowRef) {
|
||||
let fixture = try WriterFixture()
|
||||
try fixture.item("", Item.board)
|
||||
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
||||
try fixture.item(cardPath, Item.rich(order: "1024", title: "Fix login"))
|
||||
let seed = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
|
||||
#expect(await seed.addGit())
|
||||
return (fixture, try openBoard(model, at: fixture.root))
|
||||
}
|
||||
|
||||
@Test("Registering a card window opens the stage-around; unregistering releases it")
|
||||
func theWindowIsTheUnit() async throws {
|
||||
let (model, tearDown) = try makeModel()
|
||||
defer { tearDown() }
|
||||
let (fixture, ref) = try await makeBoard(model)
|
||||
defer { fixture.tearDown() }
|
||||
let committer = try #require(model.session(for: ref)?.git?.committer)
|
||||
|
||||
let card = CardWindowRef(board: ref, cardID: cardID)
|
||||
model.registerCardWindow(card, session: CardWindowSession())
|
||||
#expect(committer.stagedAroundFolders.map(\.lastPathComponent) == [Ident.card1],
|
||||
"the whole open card folder, from the moment the window joins its board")
|
||||
|
||||
model.unregisterCardWindow(card)
|
||||
#expect(committer.stagedAroundFolders.isEmpty)
|
||||
|
||||
model.storeRegistry.release(try #require(model.session(for: ref)?.store))
|
||||
}
|
||||
|
||||
@Test("The settle step releases every open session's staging, and the operation's end restores it")
|
||||
func theSettleReleasesTheStaging() async throws {
|
||||
let (model, tearDown) = try makeModel()
|
||||
defer { tearDown() }
|
||||
let (fixture, ref) = try await makeBoard(model)
|
||||
defer { fixture.tearDown() }
|
||||
let committer = try #require(model.session(for: ref)?.git?.committer)
|
||||
let switcher = try #require(model.session(for: ref)?.git?.switcher)
|
||||
|
||||
let card = CardWindowRef(board: ref, cardID: cardID)
|
||||
model.registerCardWindow(card, session: CardWindowSession())
|
||||
#expect(!committer.stagedAroundFolders.isEmpty)
|
||||
|
||||
// A window that is merely *open* answers `needsSettling` with `false` — no dirty buffer, no
|
||||
// raw source — so no modal is presented and the gate proceeds. Its folder is still held, and
|
||||
// a checkout over a held folder is the dirty tree the settle exists to prevent.
|
||||
#expect(await switcher.settleSessions?() == .proceed)
|
||||
#expect(committer.stagedAroundFolders.isEmpty,
|
||||
"the widened stage-around releases at settle, modal or no modal")
|
||||
|
||||
switcher.resumeCommitting?()
|
||||
#expect(committer.stagedAroundFolders.map(\.lastPathComponent) == [Ident.card1],
|
||||
"and the still-open window is a session again on the other side")
|
||||
|
||||
model.unregisterCardWindow(card)
|
||||
model.storeRegistry.release(try #require(model.session(for: ref)?.store))
|
||||
}
|
||||
|
||||
@Test("The board's close flush releases each session before the pipeline flushes")
|
||||
func theCloseFlushReleasesBeforeItCommits() async throws {
|
||||
let (model, tearDown) = try makeModel()
|
||||
defer { tearDown() }
|
||||
let (fixture, ref) = try await makeBoard(model)
|
||||
defer { fixture.tearDown() }
|
||||
let committer = try #require(model.session(for: ref)?.git?.committer)
|
||||
let store = try #require(model.session(for: ref)?.store)
|
||||
|
||||
let card = CardWindowRef(board: ref, cardID: cardID)
|
||||
model.registerCardWindow(card, session: CardWindowSession())
|
||||
// The session's uncommitted work — the state a quit must not leave behind.
|
||||
_ = store.writeCardBody(inCard: cardID, body: "Typed and never committed.\n")
|
||||
await committer.flushNow()
|
||||
#expect(!isClean(at: fixture.root), "held, as an open window's folder should be")
|
||||
|
||||
await model.closeBoard(ref: ref, cause: .quit)
|
||||
|
||||
#expect(isClean(at: fixture.root),
|
||||
"nothing settled is left uncommitted by closing (06 ▸ Rules ▸ Auto-commit)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,616 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Kanban
|
||||
|
||||
/// **The two-level undo model** — a card window's own stack, and the one coarse step its close
|
||||
/// registers on the board's (13-native-undo.md ▸ Rules, re-ruled 2026-07-31; 05-card-window.md ▸ The
|
||||
/// comments column; 01-storage-format.md § Enhanced schema ▸ the deferred purge).
|
||||
///
|
||||
/// The claims here are all about *which stack* and *when*, which is exactly the class of thing that
|
||||
/// looks right in a running window and is wrong: a step on the board's stack while a window is still
|
||||
/// open, a purge that ran while an undo still needed the folder it removed, a session that registered
|
||||
/// a step for a card that had left the board. So every test drives the production wiring
|
||||
/// (`CardWindowHost.configureUndo` / `.configureComments`, both `static` for this reason) over a real
|
||||
/// store and asserts against the two stacks and the bytes on disk.
|
||||
///
|
||||
/// The fine steps' own round trips are `UndoWriteTests`' and `CommentWriteTests`'; the provider
|
||||
/// grammar is `HistoryProviderTests`'.
|
||||
|
||||
// MARK: - The window under test
|
||||
|
||||
@MainActor
|
||||
private struct Window {
|
||||
let store: BoardStore
|
||||
/// The **board's** stack — what must stay empty while the window is open.
|
||||
let board: NativeHistoryProvider
|
||||
let session: CardWindowSession
|
||||
|
||||
var window: CardWindowUndo { session.undo }
|
||||
var comments: CardComments { session.comments }
|
||||
var body: CardBodyEditSession { session.body }
|
||||
}
|
||||
|
||||
private let cardID = ItemID(rawValue: Ident.card1)
|
||||
private let cardPath = "\(Ident.lane1)/\(Ident.card1)"
|
||||
|
||||
/// A card window over a board with one card, wired exactly as `CardWindowHost` wires one.
|
||||
@MainActor
|
||||
private func makeWindow(_ fixture: WriterFixture) throws -> Window {
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let board = NativeHistoryProvider()
|
||||
store.history = board
|
||||
|
||||
let session = CardWindowSession()
|
||||
CardWindowHost.configureUndo(session, store: store, cardID: cardID)
|
||||
CardWindowHost.configureComments(session.comments, store: store, cardID: cardID, on: session.undo)
|
||||
session.comments.isEditable = true
|
||||
session.comments.cardFolder = fixture.url(cardPath)
|
||||
// The one seam the host wires beside the undo ones and this suite is not testing — the buffer's
|
||||
// write target (`configureSession`), spelled the same way.
|
||||
session.body.save = { [weak store] text in
|
||||
store?.writeCardBody(inCard: cardID, body: text) ?? .vanished
|
||||
}
|
||||
session.comments.open()
|
||||
session.body.adopt(diskBody: try FrontmatterDocument.parse(fixture.indexText(cardPath)).body)
|
||||
return Window(store: store, board: board, session: session)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func editBody(_ window: Window, to text: String) {
|
||||
window.body.edited(text)
|
||||
window.body.endEditSession()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func postComment(_ window: Window, body: String) -> ItemID? {
|
||||
window.comments.composer.edited(body)
|
||||
_ = window.comments.composer.flush()
|
||||
return window.comments.composer.postNow()
|
||||
}
|
||||
|
||||
private func body(_ fixture: WriterFixture, _ path: String) throws -> String {
|
||||
try FrontmatterDocument.parse(fixture.indexText(path)).body
|
||||
}
|
||||
|
||||
// MARK: - Routing
|
||||
|
||||
@MainActor
|
||||
@Suite("Card session undo ▸ routing")
|
||||
struct CardSessionRoutingTests {
|
||||
|
||||
@Test("A body edit and a comment post register on the window's stack, never on the board's")
|
||||
func windowGesturesStayOffTheBoardStack() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
_ = try makeCommentBoard(fixture)
|
||||
let window = try makeWindow(fixture)
|
||||
|
||||
editBody(window, to: "Edited in the window.\n")
|
||||
#expect(postComment(window, body: "A remark.\n") != nil)
|
||||
|
||||
#expect(!window.board.canUndo, "board ⌘Z never sees mid-session card steps")
|
||||
#expect(window.window.stack.canUndo)
|
||||
#expect(window.window.stack.undoActionName == "Comment")
|
||||
}
|
||||
|
||||
@Test("⌘Z in the window walks its own gestures, newest first")
|
||||
func theWindowStackWalksItsOwnGestures() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let card = try makeCommentBoard(fixture)
|
||||
let window = try makeWindow(fixture)
|
||||
let original = try body(fixture, cardPath)
|
||||
|
||||
editBody(window, to: "Edited in the window.\n")
|
||||
let posted = try #require(postComment(window, body: "A remark.\n"))
|
||||
|
||||
// Newest first: the post, then the body edit.
|
||||
window.window.stack.undo()
|
||||
#expect(!fixture.exists("\(card)/comments/\(posted.rawValue)"))
|
||||
#expect(fixture.exists("\(card)/comments/.draft"))
|
||||
#expect(try body(fixture, cardPath) == "Edited in the window.\n")
|
||||
|
||||
window.window.stack.undo()
|
||||
#expect(try body(fixture, cardPath) == original)
|
||||
#expect(!window.window.stack.canUndo)
|
||||
#expect(!window.board.canUndo, "and the board's stack was never involved")
|
||||
}
|
||||
|
||||
@Test("A comment's inline edit session is one window step, at its commit point")
|
||||
func inlineEditRegistersOneWindowStep() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let card = try makeCommentBoard(fixture)
|
||||
let path = commentPath(CommentIdent.one, inCard: card)
|
||||
try fixture.item(path, commentText(body: "original\n"))
|
||||
let window = try makeWindow(fixture)
|
||||
window.comments.reload()
|
||||
|
||||
window.comments.beginEdit(ItemID(rawValue: CommentIdent.one))
|
||||
window.comments.editing?.edited("first pass\n")
|
||||
window.comments.editing?.flush()
|
||||
window.comments.editing?.edited("second pass\n")
|
||||
window.comments.editing?.flush()
|
||||
#expect(!window.window.stack.canUndo, "a save tick is not a step")
|
||||
|
||||
window.comments.commitEdit()
|
||||
#expect(window.window.stack.undoActionName == "Edit Comment")
|
||||
#expect(!window.board.canUndo)
|
||||
|
||||
window.window.stack.undo()
|
||||
#expect(try body(fixture, path) == "original\n", "back to the bytes the session opened on")
|
||||
}
|
||||
|
||||
@Test("An inline session's Cancel registers nothing — its net effect is nothing")
|
||||
func cancelRegistersNothing() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let card = try makeCommentBoard(fixture)
|
||||
try fixture.item(commentPath(CommentIdent.one, inCard: card), commentText(body: "original\n"))
|
||||
let window = try makeWindow(fixture)
|
||||
window.comments.reload()
|
||||
|
||||
window.comments.beginEdit(ItemID(rawValue: CommentIdent.one))
|
||||
window.comments.editing?.edited("rewritten\n")
|
||||
window.comments.editing?.flush()
|
||||
window.comments.cancelEdit()
|
||||
|
||||
#expect(!window.window.stack.canUndo)
|
||||
#expect(!window.board.canUndo)
|
||||
}
|
||||
|
||||
@Test("The window's manager answers for the window's stack, and never falls through")
|
||||
func theWindowManagerNeverFallsThrough() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
_ = try makeCommentBoard(fixture)
|
||||
let window = try makeWindow(fixture)
|
||||
|
||||
// A board step exists — a rename made from the board window, say.
|
||||
window.store.renameBoard("Renamed")
|
||||
#expect(window.board.canUndo)
|
||||
|
||||
// The window's own stack is empty, so its manager says no: "exhausting the window stack
|
||||
// beeps; it never reaches board history" (06-history-undo.md ▸ Undo routing).
|
||||
#expect(!window.window.manager.canUndo)
|
||||
#expect(!window.window.manager.canRedo)
|
||||
#expect(window.window.manager.undoMenuItemTitle == "Undo")
|
||||
|
||||
editBody(window, to: "Edited.\n")
|
||||
#expect(window.window.manager.canUndo)
|
||||
#expect(window.window.manager.undoMenuItemTitle == "Undo Edit Card")
|
||||
}
|
||||
|
||||
@Test("The read-only lock disables the window's pair, and the stack survives it")
|
||||
func theLockDisablesTheWindowsPair() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
_ = try makeCommentBoard(fixture)
|
||||
let window = try makeWindow(fixture)
|
||||
editBody(window, to: "Edited.\n")
|
||||
#expect(window.window.manager.canUndo)
|
||||
|
||||
window.store.enterVanishedRootLock()
|
||||
#expect(!window.window.manager.canUndo, "13 ▸ Rules ▸ locks, one level down")
|
||||
#expect(window.window.stack.canUndo, "the stack itself is untouched")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The coarse close step
|
||||
|
||||
@MainActor
|
||||
@Suite("Card session undo ▸ the coarse close step")
|
||||
struct CardSessionCloseTests {
|
||||
|
||||
@Test("A session of three gestures closes as exactly one board step, and undo restores all three")
|
||||
func oneStepForTheWholeSession() async throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let card = try makeCommentBoard(fixture)
|
||||
let deleted = commentPath(CommentIdent.one, inCard: card)
|
||||
try fixture.item(deleted, commentText(body: "kept somewhere\n"))
|
||||
let window = try makeWindow(fixture)
|
||||
window.comments.reload()
|
||||
let original = try body(fixture, cardPath)
|
||||
|
||||
editBody(window, to: "Edited in the window.\n")
|
||||
let posted = try #require(postComment(window, body: "A remark.\n"))
|
||||
window.comments.delete(ItemID(rawValue: CommentIdent.one))
|
||||
|
||||
await window.session.endSession()
|
||||
|
||||
// One step, named for the session rather than for any gesture inside it.
|
||||
#expect(window.board.canUndo)
|
||||
#expect(window.board.undoActionName == "Edit Card")
|
||||
|
||||
window.board.undo()
|
||||
#expect(!window.board.canUndo, "exactly one")
|
||||
#expect(try body(fixture, cardPath) == original)
|
||||
#expect(!fixture.exists("\(card)/comments/\(posted.rawValue)"), "the post is unposted")
|
||||
#expect(fixture.exists("\(card)/comments/.draft"))
|
||||
#expect(fixture.exists(deleted), "the deleted comment is back — restored from comments/.trash/")
|
||||
#expect(try body(fixture, deleted) == "kept somewhere\n")
|
||||
|
||||
window.board.redo()
|
||||
#expect(try body(fixture, cardPath) == "Edited in the window.\n")
|
||||
#expect(fixture.exists("\(card)/comments/\(posted.rawValue)"))
|
||||
#expect(!fixture.exists(deleted))
|
||||
#expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"))
|
||||
}
|
||||
|
||||
@Test("A session with no net change registers nothing")
|
||||
func noNetChangeRegistersNothing() async throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
_ = try makeCommentBoard(fixture)
|
||||
let window = try makeWindow(fixture)
|
||||
let original = try body(fixture, cardPath)
|
||||
|
||||
// Two Edit sessions whose net effect on the file is nothing at all.
|
||||
editBody(window, to: "A detour.\n")
|
||||
editBody(window, to: original)
|
||||
#expect(window.window.stack.canUndo, "two real gestures, on the window's stack")
|
||||
|
||||
await window.session.endSession()
|
||||
#expect(!window.board.canUndo, "a session with no net change registers nothing")
|
||||
#expect(try body(fixture, cardPath) == original)
|
||||
}
|
||||
|
||||
@Test("A gesture undone inside the window is not part of the session's net effect")
|
||||
func anUndoneGestureLeavesTheFold() async throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let card = try makeCommentBoard(fixture)
|
||||
let path = commentPath(CommentIdent.one, inCard: card)
|
||||
try fixture.item(path, commentText())
|
||||
let window = try makeWindow(fixture)
|
||||
window.comments.reload()
|
||||
|
||||
window.comments.delete(ItemID(rawValue: CommentIdent.one))
|
||||
window.window.stack.undo()
|
||||
#expect(fixture.exists(path), "the window's own ⌘Z put it back")
|
||||
|
||||
await window.session.endSession()
|
||||
#expect(!window.board.canUndo, "nothing is left of the session to coarsen")
|
||||
}
|
||||
|
||||
@Test("A comment edited and then deleted in one session folds to where it actually is")
|
||||
func editThenDeleteFoldsToTheTrash() async throws {
|
||||
// The fold's move case: the edit's expectations name the comment's live path, and the delete
|
||||
// then empties that path. A fold that kept both would be stale at the moment it registered.
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let card = try makeCommentBoard(fixture)
|
||||
let path = commentPath(CommentIdent.one, inCard: card)
|
||||
try fixture.item(path, commentText(body: "original\n"))
|
||||
let window = try makeWindow(fixture)
|
||||
window.comments.reload()
|
||||
|
||||
window.comments.beginEdit(ItemID(rawValue: CommentIdent.one))
|
||||
window.comments.editing?.edited("rewritten\n")
|
||||
window.comments.commitEdit()
|
||||
window.comments.delete(ItemID(rawValue: CommentIdent.one))
|
||||
await window.session.endSession()
|
||||
#expect(window.board.undoActionName == "Edit Card")
|
||||
|
||||
window.board.undo()
|
||||
#expect(window.store.banners.signposts.isEmpty, "the session's own step is never stale on arrival")
|
||||
#expect(fixture.exists(path))
|
||||
#expect(try body(fixture, path) == "original\n", "restored to the bytes the session opened on")
|
||||
#expect(!fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"))
|
||||
}
|
||||
|
||||
@Test("A comment posted and then deleted in one session unwinds to the draft it came from")
|
||||
func postThenDeleteUnwindsToTheDraft() async throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let card = try makeCommentBoard(fixture)
|
||||
let window = try makeWindow(fixture)
|
||||
|
||||
let posted = try #require(postComment(window, body: "A remark.\n"))
|
||||
window.comments.reload()
|
||||
window.comments.delete(posted)
|
||||
await window.session.endSession()
|
||||
|
||||
window.board.undo()
|
||||
#expect(window.store.banners.signposts.isEmpty)
|
||||
#expect(fixture.exists("\(card)/comments/.draft"))
|
||||
#expect(try body(fixture, "\(card)/comments/.draft") == "A remark.\n")
|
||||
#expect(!fixture.exists("\(card)/comments/\(posted.rawValue)"))
|
||||
#expect(try fixture.entryNames("\(card)/comments/.trash").isEmpty)
|
||||
}
|
||||
|
||||
@Test("A window whose card left the board registers nothing")
|
||||
func aVanishedCardRegistersNoSessionStep() async throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
_ = try makeCommentBoard(fixture)
|
||||
let window = try makeWindow(fixture)
|
||||
editBody(window, to: "Edited.\n")
|
||||
|
||||
// The card window's own Actions ▸ Delete — a board gesture, on the board's stack, which
|
||||
// dismisses this window (05-card-window.md ▸ Deletion & lifecycle).
|
||||
window.store.deleteCard(cardID)
|
||||
window.store.handleWatcherEvent(.treeChanged(.appMediated))
|
||||
await window.store.awaitQuiescence()
|
||||
#expect(window.board.undoActionName == "Delete Card")
|
||||
|
||||
await window.session.endSession()
|
||||
#expect(window.board.undoActionName == "Delete Card", "no session step for a card that has gone")
|
||||
}
|
||||
|
||||
@Test("A style change made in the window's sidebar joins the session, not the board")
|
||||
func sidebarStylingIsASessionGesture() async throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
_ = try makeCommentBoard(fixture)
|
||||
let window = try makeWindow(fixture)
|
||||
|
||||
// The sidebar anchor's write, exactly as `CardStyleSection` makes it.
|
||||
window.store.applyStyle(
|
||||
to: CardStyleSection.target(forCard: cardID),
|
||||
background: .set("blue"),
|
||||
on: window.window
|
||||
)
|
||||
#expect(window.window.stack.undoActionName == "Restyle Card")
|
||||
#expect(!window.board.canUndo)
|
||||
|
||||
await window.session.endSession()
|
||||
#expect(window.board.undoActionName == "Edit Card")
|
||||
|
||||
window.board.undo()
|
||||
let document = try FrontmatterDocument.parse(fixture.indexText(cardPath))
|
||||
#expect(document.background.isMissing, "the session's net effect, walked back")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Transactional staleness
|
||||
|
||||
@MainActor
|
||||
@Suite("Card session undo ▸ transactional staleness")
|
||||
struct CardSessionStalenessTests {
|
||||
|
||||
@Test("One stale component skips the whole step — nothing is partially reverted")
|
||||
func anyStaleComponentSkipsTheWholeStep() async throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let card = try makeCommentBoard(fixture)
|
||||
let deleted = commentPath(CommentIdent.one, inCard: card)
|
||||
try fixture.item(deleted, commentText())
|
||||
let window = try makeWindow(fixture)
|
||||
window.comments.reload()
|
||||
|
||||
editBody(window, to: "Edited in the window.\n")
|
||||
window.comments.delete(ItemID(rawValue: CommentIdent.one))
|
||||
await window.session.endSession()
|
||||
#expect(window.board.canUndo)
|
||||
|
||||
// Somebody else rewrites the card's body after the window closed — one component of the
|
||||
// session's step, and only one.
|
||||
_ = try BoardWriter.writeBody(inItemFolder: fixture.url(cardPath), body: "Somebody else.\n")
|
||||
|
||||
window.board.undo()
|
||||
|
||||
#expect(try body(fixture, cardPath) == "Somebody else.\n", "never applied over a newer write")
|
||||
#expect(!fixture.exists(deleted), "and the comment half did not half-happen either")
|
||||
#expect(!window.board.canUndo, "the stale step was popped")
|
||||
#expect(window.store.banners.signposts.map(\.message)
|
||||
== ["Undo skipped — 'Fix login' changed outside Lanework"])
|
||||
}
|
||||
|
||||
@Test("A foreign change to a field the session never wrote leaves the step alone")
|
||||
func anUnrelatedForeignChangeDoesNotSkip() async throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
_ = try makeCommentBoard(fixture)
|
||||
let window = try makeWindow(fixture)
|
||||
let original = try body(fixture, cardPath)
|
||||
|
||||
editBody(window, to: "Edited in the window.\n")
|
||||
await window.session.endSession()
|
||||
|
||||
// A foreign styling of the same card: the session wrote the body and nothing else, so the
|
||||
// step names no style field to be stale against (13 ▸ Rules, the field-level predicate).
|
||||
try BoardWriter.updateIndex(inItemFolder: fixture.url(cardPath), operation: .style(title: nil)) {
|
||||
$0.set(FrontmatterKeys.background, to: .string("blue"))
|
||||
}
|
||||
|
||||
window.board.undo()
|
||||
#expect(try body(fixture, cardPath) == original)
|
||||
#expect(window.store.banners.signposts.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The deferred purge
|
||||
|
||||
@MainActor
|
||||
@Suite("Card session undo ▸ the deferred purge")
|
||||
struct CardSessionPurgeTests {
|
||||
|
||||
/// A window whose session deleted one comment and closed — the state every test below starts in.
|
||||
@MainActor
|
||||
private func closedWithADeletedComment(_ fixture: WriterFixture) async throws -> (Window, String) {
|
||||
let card = try makeCommentBoard(fixture)
|
||||
let path = commentPath(CommentIdent.one, inCard: card)
|
||||
try fixture.item(path, commentText())
|
||||
let window = try makeWindow(fixture)
|
||||
window.comments.reload()
|
||||
window.comments.delete(ItemID(rawValue: CommentIdent.one))
|
||||
await window.session.endSession()
|
||||
return (window, card)
|
||||
}
|
||||
|
||||
@Test("comments/.trash survives the close while the coarse step lives")
|
||||
func theTrashOutlivesTheClose() async throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let (window, card) = try await closedWithADeletedComment(fixture)
|
||||
|
||||
#expect(window.board.canUndo)
|
||||
#expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"),
|
||||
"the step's undo restores from here — the purge waits for it")
|
||||
}
|
||||
|
||||
@Test("The board session's end purges what the step was holding")
|
||||
func theBoardSessionsEndPurges() async throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let (window, card) = try await closedWithADeletedComment(fixture)
|
||||
|
||||
// `AppModel`'s teardown, and the add-git swap, both do exactly this.
|
||||
window.board.clear()
|
||||
#expect(try fixture.entryNames("\(card)/comments/.trash").isEmpty)
|
||||
}
|
||||
|
||||
@Test("A stale step's skip purges too — the step is gone, so its backing is not needed")
|
||||
func aSkippedStepPurges() async throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let (window, card) = try await closedWithADeletedComment(fixture)
|
||||
|
||||
// A foreign delete of the trashed folder is not the interesting collision; a foreign body
|
||||
// rewrite is — it makes the step stale without touching what the purge would remove.
|
||||
_ = try BoardWriter.writeBody(inItemFolder: fixture.url(cardPath), body: "Somebody else.\n")
|
||||
// The session wrote the body too, so the step names it.
|
||||
window.board.undo()
|
||||
|
||||
#expect(!window.board.canUndo)
|
||||
#expect(try fixture.entryNames("\(card)/comments/.trash").isEmpty)
|
||||
}
|
||||
|
||||
@Test("A session that registers no step purges at the close, as it always did")
|
||||
func aSessionWithNoStepPurgesAtOnce() async throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let card = try makeCommentBoard(fixture)
|
||||
// Residue with no session behind it: nothing this close does could need it back.
|
||||
try fixture.item("\(card)/comments/.trash/\(CommentIdent.two)", commentText())
|
||||
let window = try makeWindow(fixture)
|
||||
|
||||
await window.session.endSession()
|
||||
#expect(!window.board.canUndo)
|
||||
#expect(try fixture.entryNames("\(card)/comments/.trash").isEmpty)
|
||||
}
|
||||
|
||||
@Test("A substrate that keeps no steps purges at the close — Pro's rule, structurally")
|
||||
func aSubstrateThatKeepsNoStepsPurgesAtOnce() async throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let card = try makeCommentBoard(fixture)
|
||||
let path = commentPath(CommentIdent.one, inCard: card)
|
||||
try fixture.item(path, commentText())
|
||||
let window = try makeWindow(fixture)
|
||||
window.comments.reload()
|
||||
// The git provider drops every registration (its substrate is the commit trail) and retires
|
||||
// it on the way past — which is what makes "purge rides the close flush" true on Pro with no
|
||||
// tier check at any call site. Bound directly here: `register` reads no repository.
|
||||
window.store.history = GitHistoryProvider(boardRoot: fixture.root)
|
||||
|
||||
window.comments.delete(ItemID(rawValue: CommentIdent.one))
|
||||
await window.session.endSession()
|
||||
|
||||
#expect(try fixture.entryNames("\(card)/comments/.trash").isEmpty)
|
||||
}
|
||||
|
||||
@Test("Undone-and-superseded releases the work the step was holding")
|
||||
func supersedingAnUndoneStepRetiresIt() {
|
||||
// The one release condition disk cannot show, because by the time it fires the coarse undo
|
||||
// has already emptied the trash it was holding: a step undone and then superseded by a new
|
||||
// gesture leaves history for good, and its retirement runs (13 ▸ Interaction with the trash).
|
||||
let provider = NativeHistoryProvider()
|
||||
let retirement = HistoryStep.Retirement {}
|
||||
provider.register(HistoryStep(name: "Edit Card", retirement: retirement, undo: { _ in .applied }, redo: { _ in .applied }))
|
||||
#expect(retirement.isOwed)
|
||||
|
||||
provider.undo()
|
||||
#expect(retirement.isOwed, "an undone step is still crossable — redo would need its backing")
|
||||
|
||||
provider.register(HistoryStep(name: "Move Card", undo: { _ in .applied }, redo: { _ in .applied }))
|
||||
#expect(!retirement.isOwed, "the redo stack cleared, so the step is gone for good")
|
||||
}
|
||||
|
||||
@Test("A retirement runs once, whatever a provider does to the step")
|
||||
func aRetirementRunsOnce() {
|
||||
var runs = 0
|
||||
let retirement = HistoryStep.Retirement { runs += 1 }
|
||||
let provider = NativeHistoryProvider()
|
||||
provider.register(HistoryStep(name: "Edit Card", retirement: retirement, undo: { _ in .applied }, redo: { _ in .applied }))
|
||||
|
||||
provider.clear()
|
||||
provider.clear()
|
||||
#expect(runs == 1)
|
||||
}
|
||||
|
||||
@Test("A board with no substrate at all owes the purge immediately")
|
||||
func noProviderRunsTheRetirementAtOnce() throws {
|
||||
// Repo-nested boards bind no provider (06-history-undo.md ▸ Rules), so nothing could ever
|
||||
// report the step's death — the work is owed at registration or never.
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let card = try makeCommentBoard(fixture)
|
||||
try fixture.item("\(card)/comments/.trash/\(CommentIdent.two)", commentText())
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let session = CardWindowSession()
|
||||
CardWindowHost.configureUndo(session, store: store, cardID: cardID)
|
||||
CardWindowHost.configureComments(session.comments, store: store, cardID: cardID, on: session.undo)
|
||||
session.comments.isEditable = true
|
||||
session.comments.cardFolder = fixture.url(cardPath)
|
||||
|
||||
var purged = false
|
||||
_ = store.registerCardSession(session.undo, inCard: cardID, retiring: { purged = true })
|
||||
#expect(purged == false, "no net effect — nothing was registered, and the caller still owes it")
|
||||
|
||||
session.body.save = { [weak store] text in store?.writeCardBody(inCard: cardID, body: text) ?? .vanished }
|
||||
session.body.adopt(diskBody: try body(fixture, cardPath))
|
||||
session.body.edited("Edited.\n")
|
||||
session.body.endEditSession()
|
||||
_ = store.registerCardSession(session.undo, inCard: cardID, retiring: { purged = true })
|
||||
#expect(purged, "a step nothing keeps is a step nothing can retire later")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The fold
|
||||
|
||||
@MainActor
|
||||
@Suite("Card session undo ▸ the fold")
|
||||
struct CardSessionFoldTests {
|
||||
|
||||
private let folder = URL(fileURLWithPath: "/board/lane/card", isDirectory: true)
|
||||
|
||||
@Test("Merging is per target and per field, later entries winning")
|
||||
func laterEntriesWin() {
|
||||
let other = URL(fileURLWithPath: "/board/lane/card/comments/one", isDirectory: true)
|
||||
let fold = CardWindowUndo.fold([
|
||||
[.present(folder, .background("blue"), .body("first\n"))],
|
||||
[.present(folder, .background("green")), .present(other)],
|
||||
])
|
||||
|
||||
#expect(fold.expectations == [
|
||||
.present(folder, .background("green"), .body("first\n")),
|
||||
.present(other),
|
||||
])
|
||||
}
|
||||
|
||||
@Test("Two folds of the same writes, read from both ends, are equal only when nothing changed")
|
||||
func equalityIsTheNoNetChangeTest() {
|
||||
let there = CardWindowUndo.fold([[.present(folder, .body("a\n"))], [.present(folder, .body("b\n"))]])
|
||||
let back = CardWindowUndo.fold([[.present(folder, .body("b\n"))], [.present(folder, .body("a\n"))]])
|
||||
#expect(there != back)
|
||||
|
||||
let round = CardWindowUndo.fold([[.present(folder, .body("a\n"))], [.present(folder, .body("a\n"))]])
|
||||
#expect(round == CardWindowUndo.fold([[.present(folder, .body("a\n"))]]))
|
||||
}
|
||||
|
||||
@Test("Presence takes the later answer, and an absence clears what was said about the path")
|
||||
func presenceTakesTheLaterAnswer() {
|
||||
let live = URL(fileURLWithPath: "/board/lane/card/comments/one", isDirectory: true)
|
||||
// An edit, then the delete that emptied the path: a fold still expecting the edited bytes
|
||||
// there would be stale on arrival.
|
||||
let fold = CardWindowUndo.fold([[.present(live, .body("rewritten\n"))], [.absent(live)]])
|
||||
#expect(fold.expectations == [.absent(live)])
|
||||
|
||||
// And back again — a restore names the path present, which is the ordinary later-wins.
|
||||
let restored = CardWindowUndo.fold([[.absent(live)], [.present(live, .body("original\n"))]])
|
||||
#expect(restored.expectations == [.present(live, .body("original\n"))])
|
||||
}
|
||||
}
|
||||
@@ -329,13 +329,15 @@ struct CommitMessageFoldingTests {
|
||||
#expect(subject(of: message) == "Move 2 cards")
|
||||
}
|
||||
|
||||
@Test("A genuinely mixed window is 'Update board' — with every event in the body")
|
||||
@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) == CommitMessageEngine.mixedSubject)
|
||||
#expect(subject(of: message) == "Mixed update — 2 changes")
|
||||
#expect(body(of: message) == [
|
||||
"- Add card 'Write tests' to Doing",
|
||||
"- Rename card 'Fix login' → 'Fix logout'",
|
||||
@@ -419,7 +421,7 @@ struct CommitMessageBookkeepingTests {
|
||||
modified: "2026-07-31T12:00:00Z"
|
||||
)
|
||||
}
|
||||
#expect(message == CommitMessageEngine.mixedSubject)
|
||||
#expect(message == CommitMessageEngine.unnamedSubject)
|
||||
}
|
||||
|
||||
@Test("A renumber's rescale preserves sequence and composes nothing")
|
||||
@@ -430,7 +432,7 @@ struct CommitMessageBookkeepingTests {
|
||||
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)
|
||||
#expect(message == CommitMessageEngine.unnamedSubject)
|
||||
}
|
||||
|
||||
@Test("An on-touch heal's backfilled kind is not an event")
|
||||
@@ -441,7 +443,7 @@ struct CommitMessageBookkeepingTests {
|
||||
"---\nschema: 1\ntitle: Fix login\norder: 1024\nkind: card\n---\n\n"
|
||||
)
|
||||
}
|
||||
#expect(message == CommitMessageEngine.mixedSubject)
|
||||
#expect(message == CommitMessageEngine.unnamedSubject)
|
||||
}
|
||||
|
||||
@Test("A renumber batches with the insert that triggered it")
|
||||
@@ -721,7 +723,7 @@ struct CommitMessageCommentTests {
|
||||
let message = try compose { fixture in
|
||||
try fixture.file(Self.thread(Ident.card1, Self.commentA), Self.commentText("Note."))
|
||||
}
|
||||
#expect(message != CommitMessageEngine.mixedSubject)
|
||||
#expect(message != CommitMessageEngine.unnamedSubject)
|
||||
#expect(!message.contains("comments/"))
|
||||
}
|
||||
|
||||
|
||||
@@ -815,7 +815,7 @@ struct GitUndoSessionTests {
|
||||
// would otherwise bury — and the reason the step exists at all.
|
||||
let cardFolder = fixture.root.appendingPathComponent("\(Ident.lane1)/\(Ident.card1)")
|
||||
let token = UUID()
|
||||
committer.beginEditSession(token) { cardFolder }
|
||||
committer.beginCardSession(token) { cardFolder }
|
||||
try fixture.item("\(Ident.lane1)/\(Ident.card1)", plain(order: "1024", title: "Half-typed"))
|
||||
committer.noteReloadLanded(sawForeignChange: false)
|
||||
await commitAndSettle(committer, provider)
|
||||
@@ -828,7 +828,7 @@ struct GitUndoSessionTests {
|
||||
// what `AppModel`'s gate hands over) goes to the plan to reconcile against the working
|
||||
// tree. Passing the `<lane>/<card>` path here instead is what once made this test pass
|
||||
// over a rule that did not work at all — see `discardReconcilesACardIdentifiedByName`.
|
||||
committer.endEditSession(token)
|
||||
committer.endCardSession(token)
|
||||
provider.noteDiscarded(cardFolderName: Ident.card1)
|
||||
return .proceed
|
||||
}
|
||||
@@ -872,13 +872,13 @@ struct GitUndoSessionTests {
|
||||
// the card's own folder) — which no commit anywhere has ever seen.
|
||||
let cardFolder = fixture.root.appendingPathComponent("\(Ident.lane1)/\(Ident.card1)")
|
||||
let token = UUID()
|
||||
committer.beginEditSession(token) { cardFolder }
|
||||
committer.beginCardSession(token) { cardFolder }
|
||||
try fixture.item("\(Ident.lane1)/\(Ident.card1)", plain(order: "1024", title: "Renamed", body: "Half-typed."))
|
||||
let stray = "\(Ident.lane1)/\(Ident.card1)/attachments/sketch.txt"
|
||||
try fixture.file(stray, Data("dropped mid-session".utf8))
|
||||
|
||||
provider.settleSessions = { _ in
|
||||
committer.endEditSession(token)
|
||||
committer.endCardSession(token)
|
||||
// The bare id — never the path. This is the whole regression.
|
||||
provider.noteDiscarded(cardFolderName: Ident.card1)
|
||||
return .proceed
|
||||
|
||||
Reference in New Issue
Block a user