Phase B of the two-level undo card: every card-window gesture — comment
post/delete/edit, body Edit sessions, style and details changes —
registers fine-grained on the window's own stack (window.undoManager
answers with it; board ⌘Z never sees mid-session card steps; an empty
window stack beeps, never falls through). Window close folds the stack
into one coarse values-based board step ("Edit card 'X'") — per-target
per-field later-wins merge, so foreign mid-session writes stay out by
construction, a no-net-change session registers nothing, and any stale
component skips the whole step. The comments/.trash purge defers with
the coarse step via a step-retirement seam on the providers: it runs
when the step leaves the board stack or the board session ends; the git
provider retires dropped steps on register, which keeps Pro's
purge-at-close-flush structural with no tier check. Interim on git
boards: gestures still auto-commit per debounce until phase C's
close-flush commit.
2432 tests in 418 suites green.
Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
668 lines
25 KiB
Swift
668 lines
25 KiB
Swift
import Foundation
|
|
import Testing
|
|
@testable import Kanban
|
|
|
|
/// The card window's comments **handle** — the object every menu row, every pane view and the close
|
|
/// flush actually talk to (05-card-window.md ▸ The comments column).
|
|
///
|
|
/// What it owns is orderings and lifecycles, which is exactly the class of thing that looks right in
|
|
/// a running window and is wrong: the sweep-before-read at open, the saves-before-purge at close, a
|
|
/// session that must be dropped when its comment vanishes underneath it. So the seams are closures,
|
|
/// this suite fills them with a log, and the assertions are about *sequence*.
|
|
///
|
|
/// The bytes are `CommentWriteTests`'; the buffers are `CommentSessionTests`'; the pure layout is
|
|
/// `CardCommentsLayoutTests`'.
|
|
|
|
// MARK: - The log
|
|
|
|
/// Every seam a `CardComments` can reach, recorded in the order it was reached.
|
|
@MainActor
|
|
private final class CommentsSpy {
|
|
enum Event: Equatable {
|
|
case sweep
|
|
case readThread
|
|
case readDraft
|
|
case purge
|
|
case displace([String])
|
|
case delete(String)
|
|
case edit(String, String)
|
|
case saveDraft(String)
|
|
case post
|
|
case importFiles([String], CommentTarget)
|
|
case removeFile(String, CommentTarget)
|
|
}
|
|
|
|
private(set) var events: [Event] = []
|
|
|
|
var thread: CommentThread = .empty
|
|
var draft: CommentDraft?
|
|
var deleteLands = true
|
|
var editLands = true
|
|
var postedID: ItemID? = ItemID(rawValue: CommentIdent.two)
|
|
|
|
func install(on comments: CardComments) {
|
|
comments.readThread = { [self] in events.append(.readThread); return thread }
|
|
comments.readDraft = { [self] in events.append(.readDraft); return draft }
|
|
comments.sweepTrashResidue = { [self] in events.append(.sweep) }
|
|
comments.purgeTrash = { [self] in events.append(.purge) }
|
|
comments.displaceSquatters = { [self] squatters in
|
|
events.append(.displace(squatters.map(\.name)))
|
|
}
|
|
comments.deleteComment = { [self] id in
|
|
events.append(.delete(id.rawValue))
|
|
return deleteLands
|
|
}
|
|
comments.editComment = { [self] id, body in
|
|
events.append(.edit(id.rawValue, body))
|
|
return editLands
|
|
}
|
|
comments.importAttachments = { [self] urls, target in
|
|
events.append(.importFiles(urls.map(\.lastPathComponent), target))
|
|
}
|
|
comments.removeAttachment = { [self] name, target in
|
|
events.append(.removeFile(name, target))
|
|
}
|
|
comments.composer.save = { [self] text in
|
|
events.append(.saveDraft(text))
|
|
return .updated
|
|
}
|
|
comments.composer.post = { [self] in
|
|
events.append(.post)
|
|
return postedID
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A comment value with only the fields this suite reads — the loader builds the real ones
|
|
/// (`CommentThreadTests`), and a thread assembled here is only ever a stand-in for one it produced.
|
|
private func comment(_ id: String, body: String = "text\n", attachments: [String] = []) -> Kanban.Comment {
|
|
Kanban.Comment(
|
|
id: ItemID(rawValue: id),
|
|
schema: .valid(1),
|
|
author: .missing,
|
|
created: .missing,
|
|
modified: .missing,
|
|
modifiedBy: .missing,
|
|
attachments: attachments,
|
|
document: FrontmatterDocument(body: body)
|
|
)
|
|
}
|
|
|
|
private func thread(_ comments: [Kanban.Comment], defects: [IntegrityRules.Defect] = []) -> CommentThread {
|
|
CommentThread(comments: comments, strays: [], defects: defects, hasDraft: false)
|
|
}
|
|
|
|
@MainActor
|
|
private func makeComments(_ spy: CommentsSpy) -> CardComments {
|
|
let comments = CardComments()
|
|
comments.isEditable = true
|
|
comments.cardFolder = URL(fileURLWithPath: "/board/lane/card", isDirectory: true)
|
|
spy.install(on: comments)
|
|
return comments
|
|
}
|
|
|
|
// MARK: - Opening
|
|
|
|
@MainActor
|
|
@Suite("Card comments ▸ opening")
|
|
struct CardCommentsOpenTests {
|
|
|
|
@Test("The residue sweep runs before the thread read")
|
|
func sweepPrecedesTheRead() {
|
|
// The sweep *removes* folders, so a read taken before it would describe a
|
|
// `comments/.trash/` that is about to stop existing (01-storage-format.md § Enhanced schema).
|
|
let spy = CommentsSpy()
|
|
let comments = makeComments(spy)
|
|
|
|
comments.open()
|
|
#expect(spy.events.prefix(2) == [.sweep, .readThread])
|
|
}
|
|
|
|
@Test("Opening reads the draft too — restore-on-reopen is just the read")
|
|
func openRestoresTheDraft() {
|
|
let spy = CommentsSpy()
|
|
spy.draft = CommentDraft(body: "half a thought\n", attachments: ["shot.png"])
|
|
let comments = makeComments(spy)
|
|
|
|
comments.open()
|
|
#expect(comments.composer.text == "half a thought\n")
|
|
#expect(comments.composer.attachments == ["shot.png"])
|
|
#expect(!comments.composer.isDirty)
|
|
}
|
|
|
|
@Test("A pane with no store reads nothing rather than showing an empty thread it invented")
|
|
func noSeamsIsNoRead() {
|
|
let comments = CardComments()
|
|
comments.reload()
|
|
#expect(comments.thread == .empty)
|
|
}
|
|
}
|
|
|
|
// MARK: - Reloading
|
|
|
|
@MainActor
|
|
@Suite("Card comments ▸ the live reload")
|
|
struct CardCommentsReloadTests {
|
|
|
|
@Test("A reload replaces the thread")
|
|
func reloadReplacesTheThread() {
|
|
let spy = CommentsSpy()
|
|
let comments = makeComments(spy)
|
|
spy.thread = thread([comment(CommentIdent.one)])
|
|
|
|
comments.reload()
|
|
#expect(comments.thread.comments.map(\.id.rawValue) == [CommentIdent.one])
|
|
}
|
|
|
|
@Test("Claimed-name squatters the read found are routed to the displacement")
|
|
func squattersAreDisplaced() {
|
|
let spy = CommentsSpy()
|
|
let comments = makeComments(spy)
|
|
spy.thread = thread([], defects: [.claimedNameSquatted(ClaimedNameSquatter(
|
|
name: ".draft",
|
|
found: .file,
|
|
expected: .directory,
|
|
location: .commentThread(cardPath: "lane/card")
|
|
))])
|
|
|
|
comments.reload()
|
|
#expect(spy.events.contains(.displace([".draft"])))
|
|
}
|
|
|
|
@Test("A clean read displaces nothing — no bracket for a thread with no work in it")
|
|
func aCleanReadDisplacesNothing() {
|
|
let spy = CommentsSpy()
|
|
let comments = makeComments(spy)
|
|
spy.thread = thread([comment(CommentIdent.one)])
|
|
|
|
comments.reload()
|
|
#expect(!spy.events.contains { if case .displace = $0 { true } else { false } })
|
|
}
|
|
|
|
@Test("An open session follows disk while clean, and keeps its keystrokes while dirty")
|
|
func theSessionObeysDirtyBufferWins() {
|
|
let spy = CommentsSpy()
|
|
let comments = makeComments(spy)
|
|
spy.thread = thread([comment(CommentIdent.one, body: "original\n")])
|
|
comments.reload()
|
|
comments.beginEdit(ItemID(rawValue: CommentIdent.one))
|
|
|
|
spy.thread = thread([comment(CommentIdent.one, body: "an agent rewrote it\n")])
|
|
comments.reload()
|
|
#expect(comments.editing?.text == "an agent rewrote it\n")
|
|
|
|
comments.editing?.edited("mine\n")
|
|
spy.thread = thread([comment(CommentIdent.one, body: "and again\n")])
|
|
comments.reload()
|
|
#expect(comments.editing?.text == "mine\n")
|
|
}
|
|
|
|
@Test("A session whose comment vanished is dropped, with no error UI of its own")
|
|
func aVanishedCommentDropsItsSession() {
|
|
// 05 ▸ Deletion & lifecycle's "nowhere left to write", applied one level down: the store
|
|
// answers `false`/`nil` for a target that is gone, and there is nothing here to tell the user
|
|
// that `performWrite` has not already said.
|
|
let spy = CommentsSpy()
|
|
let comments = makeComments(spy)
|
|
spy.thread = thread([comment(CommentIdent.one)])
|
|
comments.reload()
|
|
comments.beginEdit(ItemID(rawValue: CommentIdent.one))
|
|
#expect(comments.editing != nil)
|
|
|
|
spy.thread = .empty
|
|
comments.reload()
|
|
#expect(comments.editing == nil)
|
|
}
|
|
}
|
|
|
|
// MARK: - The inline session
|
|
|
|
@MainActor
|
|
@Suite("Card comments ▸ the inline edit session")
|
|
struct CardCommentsEditTests {
|
|
|
|
private func opened(_ spy: CommentsSpy) -> CardComments {
|
|
let comments = makeComments(spy)
|
|
spy.thread = thread([
|
|
comment(CommentIdent.one, body: "first\n"),
|
|
comment(CommentIdent.two, body: "second\n")
|
|
])
|
|
comments.reload()
|
|
return comments
|
|
}
|
|
|
|
@Test("Edit opens a session over the comment's current body")
|
|
func editOpensASession() {
|
|
let spy = CommentsSpy()
|
|
let comments = opened(spy)
|
|
|
|
comments.beginEdit(ItemID(rawValue: CommentIdent.one))
|
|
#expect(comments.editing?.commentID.rawValue == CommentIdent.one)
|
|
#expect(comments.editing?.text == "first\n")
|
|
#expect(comments.editing?.sessionStart == "first\n")
|
|
}
|
|
|
|
@Test("One session at a time — opening a second commits the first")
|
|
func oneSessionAtATime() {
|
|
let spy = CommentsSpy()
|
|
let comments = opened(spy)
|
|
|
|
comments.beginEdit(ItemID(rawValue: CommentIdent.one))
|
|
comments.editing?.edited("first, revised\n")
|
|
comments.beginEdit(ItemID(rawValue: CommentIdent.two))
|
|
|
|
#expect(spy.events.contains(.edit(CommentIdent.one, "first, revised\n")),
|
|
"the user asked to edit another comment — not to throw this one away")
|
|
#expect(comments.editing?.commentID.rawValue == CommentIdent.two)
|
|
}
|
|
|
|
@Test("Re-editing the comment already open is a no-op, not a restarted session")
|
|
func reEditingIsANoOp() {
|
|
let spy = CommentsSpy()
|
|
let comments = opened(spy)
|
|
|
|
comments.beginEdit(ItemID(rawValue: CommentIdent.one))
|
|
comments.editing?.edited("typed\n")
|
|
comments.beginEdit(ItemID(rawValue: CommentIdent.one))
|
|
|
|
#expect(comments.editing?.text == "typed\n", "a restart would lose the start-of-session bytes")
|
|
#expect(comments.editing?.sessionStart == "first\n")
|
|
}
|
|
|
|
@Test("Save flushes and closes the session")
|
|
func saveCommits() {
|
|
let spy = CommentsSpy()
|
|
let comments = opened(spy)
|
|
|
|
comments.beginEdit(ItemID(rawValue: CommentIdent.one))
|
|
comments.editing?.edited("revised\n")
|
|
comments.commitEdit()
|
|
|
|
#expect(spy.events.contains(.edit(CommentIdent.one, "revised\n")))
|
|
#expect(comments.editing == nil)
|
|
}
|
|
|
|
@Test("Cancel writes the session-start bytes back and closes the session")
|
|
func cancelReverts() {
|
|
let spy = CommentsSpy()
|
|
let comments = opened(spy)
|
|
|
|
comments.beginEdit(ItemID(rawValue: CommentIdent.one))
|
|
comments.editing?.edited("half a rewrite\n")
|
|
comments.editing?.flush()
|
|
comments.cancelEdit()
|
|
|
|
#expect(spy.events.contains(.edit(CommentIdent.one, "first\n")))
|
|
#expect(comments.editing == nil)
|
|
}
|
|
|
|
@Test("Under the read-only lock no session opens at all")
|
|
func theLockRefusesASession() {
|
|
let spy = CommentsSpy()
|
|
let comments = opened(spy)
|
|
comments.isEditable = false
|
|
|
|
comments.beginEdit(ItemID(rawValue: CommentIdent.one))
|
|
#expect(comments.editing == nil)
|
|
}
|
|
|
|
@Test("Editing a comment the read never found opens nothing")
|
|
func editingAVanishedCommentOpensNothing() {
|
|
let spy = CommentsSpy()
|
|
let comments = opened(spy)
|
|
|
|
comments.beginEdit(ItemID(rawValue: CommentIdent.three))
|
|
#expect(comments.editing == nil)
|
|
}
|
|
}
|
|
|
|
// MARK: - Delete
|
|
|
|
@MainActor
|
|
@Suite("Card comments ▸ delete")
|
|
struct CardCommentsDeleteTests {
|
|
|
|
@Test("Delete is immediate — no confirm, and the store's own step")
|
|
func deleteIsImmediate() {
|
|
let spy = CommentsSpy()
|
|
let comments = makeComments(spy)
|
|
spy.thread = thread([comment(CommentIdent.one)])
|
|
comments.reload()
|
|
|
|
comments.delete(ItemID(rawValue: CommentIdent.one))
|
|
#expect(spy.events.contains(.delete(CommentIdent.one)))
|
|
}
|
|
|
|
@Test("Deleting the comment being edited commits the session first")
|
|
func deleteCommitsTheOpenSession() {
|
|
// The user's last keystrokes belong in the file that is about to move into
|
|
// `comments/.trash/`, so an undo brings back what they wrote.
|
|
let spy = CommentsSpy()
|
|
let comments = makeComments(spy)
|
|
spy.thread = thread([comment(CommentIdent.one, body: "first\n")])
|
|
comments.reload()
|
|
comments.beginEdit(ItemID(rawValue: CommentIdent.one))
|
|
comments.editing?.edited("last words\n")
|
|
|
|
comments.delete(ItemID(rawValue: CommentIdent.one))
|
|
|
|
let edit = spy.events.firstIndex(of: .edit(CommentIdent.one, "last words\n"))
|
|
let delete = spy.events.firstIndex(of: .delete(CommentIdent.one))
|
|
#expect(edit != nil)
|
|
#expect(delete != nil)
|
|
#expect((edit ?? 0) < (delete ?? 0))
|
|
#expect(comments.editing == nil)
|
|
}
|
|
|
|
@Test("Under the read-only lock nothing is deleted")
|
|
func theLockRefusesADelete() {
|
|
let spy = CommentsSpy()
|
|
let comments = makeComments(spy)
|
|
comments.isEditable = false
|
|
|
|
comments.delete(ItemID(rawValue: CommentIdent.one))
|
|
#expect(!spy.events.contains(.delete(CommentIdent.one)))
|
|
}
|
|
}
|
|
|
|
// MARK: - Attachments on the authoring surfaces
|
|
|
|
@MainActor
|
|
@Suite("Card comments ▸ authoring attachments")
|
|
struct CardCommentsAttachmentTests {
|
|
|
|
@Test("A drop on the composer imports into the draft; one on an inline session, into its comment")
|
|
func importsAimAtTheirSurface() {
|
|
let spy = CommentsSpy()
|
|
let comments = makeComments(spy)
|
|
let file = URL(fileURLWithPath: "/tmp/shot.png")
|
|
let id = ItemID(rawValue: CommentIdent.one)
|
|
|
|
comments.importFiles([file], to: .draft)
|
|
#expect(spy.events.contains(.importFiles(["shot.png"], .draft)))
|
|
|
|
comments.importFiles([file], to: .comment(id))
|
|
#expect(spy.events.contains(.importFiles(["shot.png"], .comment(id))))
|
|
}
|
|
|
|
@Test("An authoring chip's Remove aims at its own surface")
|
|
func removesAimAtTheirSurface() {
|
|
let spy = CommentsSpy()
|
|
let comments = makeComments(spy)
|
|
|
|
comments.removeFile(named: "shot.png", from: .draft)
|
|
#expect(spy.events.contains(.removeFile("shot.png", .draft)))
|
|
}
|
|
|
|
@Test("Under the read-only lock neither import nor remove happens")
|
|
func theLockRefusesBoth() {
|
|
let spy = CommentsSpy()
|
|
let comments = makeComments(spy)
|
|
comments.isEditable = false
|
|
|
|
comments.importFiles([URL(fileURLWithPath: "/tmp/shot.png")], to: .draft)
|
|
comments.removeFile(named: "shot.png", from: .draft)
|
|
#expect(spy.events.isEmpty)
|
|
}
|
|
|
|
@Test("A chip resolves against its own surface's attachments folder")
|
|
func chipURLsResolve() throws {
|
|
let spy = CommentsSpy()
|
|
let comments = makeComments(spy)
|
|
let id = ItemID(rawValue: CommentIdent.one)
|
|
|
|
let draft = try #require(comments.attachmentURL("shot.png", in: .draft))
|
|
#expect(draft.path.hasSuffix("/comments/.draft/attachments/shot.png"))
|
|
|
|
let posted = try #require(comments.attachmentURL("shot.png", in: .comment(id)))
|
|
#expect(posted.path.hasSuffix("/comments/\(CommentIdent.one)/attachments/shot.png"))
|
|
}
|
|
}
|
|
|
|
// MARK: - The close flush
|
|
|
|
@MainActor
|
|
@Suite("Card comments ▸ the close flush")
|
|
struct CardCommentsCloseTests {
|
|
|
|
@Test("The saves land, and the purge is not the pane's to run")
|
|
func savesLandAndThePurgeDefers() {
|
|
// Realigned 2026-07-31 with the session-coarsening model (13-native-undo.md ▸ Interaction
|
|
// with the trash): the ordering this pinned — every save before anything empties
|
|
// `comments/.trash/` — still holds and is now the window session's to keep, because only
|
|
// that level knows whether a coarse close step took the purge on (`CardWindowSession`).
|
|
// What the pane owes the close is its two saves, in order.
|
|
let spy = CommentsSpy()
|
|
let comments = makeComments(spy)
|
|
spy.thread = thread([comment(CommentIdent.one, body: "first\n")])
|
|
comments.reload()
|
|
comments.beginEdit(ItemID(rawValue: CommentIdent.one))
|
|
comments.editing?.edited("mid-sentence\n")
|
|
comments.composer.edited("a draft\n")
|
|
|
|
comments.endSession()
|
|
|
|
let edit = try? #require(spy.events.firstIndex(of: .edit(CommentIdent.one, "mid-sentence\n")))
|
|
let draft = try? #require(spy.events.firstIndex(of: .saveDraft("a draft\n")))
|
|
#expect((edit ?? 0) < (draft ?? 1))
|
|
#expect(!spy.events.contains(.purge), "the trash outlives the pane's own close")
|
|
|
|
// And it is still one call away, for whoever ends up owing it.
|
|
comments.purgeTrashNow()
|
|
#expect(spy.events.last == .purge)
|
|
}
|
|
|
|
@Test("The close flushes the session — it never reverts it")
|
|
func theCloseIsNotACancel() {
|
|
let spy = CommentsSpy()
|
|
let comments = makeComments(spy)
|
|
spy.thread = thread([comment(CommentIdent.one, body: "first\n")])
|
|
comments.reload()
|
|
comments.beginEdit(ItemID(rawValue: CommentIdent.one))
|
|
comments.editing?.edited("kept\n")
|
|
|
|
comments.endSession()
|
|
#expect(spy.events.contains(.edit(CommentIdent.one, "kept\n")))
|
|
#expect(!spy.events.contains(.edit(CommentIdent.one, "first\n")), "a close is not an abandon")
|
|
}
|
|
|
|
@Test("Ending twice does the work once")
|
|
func endingTwiceIsIdempotent() {
|
|
let spy = CommentsSpy()
|
|
let comments = makeComments(spy)
|
|
comments.composer.edited("a draft\n")
|
|
|
|
comments.endSession()
|
|
comments.endSession()
|
|
|
|
#expect(spy.events.filter { $0 == .saveDraft("a draft\n") }.count == 1)
|
|
}
|
|
|
|
@Test("A clean pane's close writes nothing and purges nothing")
|
|
func aCleanPaneClosesQuietly() {
|
|
// The trash is still the app's leftovers rather than the user's work — but who empties it,
|
|
// and when, is the window session's question now (`CardSessionUndoTests`).
|
|
let spy = CommentsSpy()
|
|
let comments = makeComments(spy)
|
|
|
|
comments.endSession()
|
|
#expect(spy.events.isEmpty)
|
|
}
|
|
}
|
|
|
|
// MARK: - Unsaved content
|
|
|
|
@MainActor
|
|
@Suite("Card comments ▸ what counts as unsaved")
|
|
struct CardCommentsUnsavedTests {
|
|
|
|
@Test("An inline edit session's dirty buffer counts")
|
|
func theInlineSessionCounts() {
|
|
let spy = CommentsSpy()
|
|
let comments = makeComments(spy)
|
|
spy.thread = thread([comment(CommentIdent.one, body: "first\n")])
|
|
comments.reload()
|
|
comments.beginEdit(ItemID(rawValue: CommentIdent.one))
|
|
|
|
#expect(!comments.holdsUnsavedContent)
|
|
comments.editing?.edited("typed\n")
|
|
#expect(comments.holdsUnsavedContent)
|
|
}
|
|
|
|
@Test("The composer's draft deliberately does not")
|
|
func theDraftDoesNotCount() {
|
|
// "Close and quit just proceed — no DirtyBufferGuard, nothing to lose" (05 ▸ The comments
|
|
// column): the draft is a durable file being edited in place, not unsaved work, and this
|
|
// property's one consumer is File ▸ Save as Template's carve-out.
|
|
let spy = CommentsSpy()
|
|
let comments = makeComments(spy)
|
|
|
|
comments.composer.edited("half a thought\n")
|
|
#expect(comments.composer.isDirty)
|
|
#expect(!comments.holdsUnsavedContent)
|
|
}
|
|
}
|
|
|
|
// MARK: - Add Comment
|
|
|
|
@MainActor
|
|
@Suite("Card comments ▸ File ▸ Add Comment")
|
|
struct AddCommentCommandTests {
|
|
|
|
@Test("The row turns Show Comments on and focuses the composer, whichever state it was in")
|
|
func theRowDoesBoth() {
|
|
// "File ▸ Add Comment flips the bit on when it's off (the gesture *is* the user choosing to
|
|
// see comments — same persistence) and focuses the composer in one gesture" — and it focuses
|
|
// *either way*, which is the clause that regresses silently.
|
|
#expect(AddCommentCommand.effect(isShown: false) == (isShown: true, focusesComposer: true))
|
|
#expect(AddCommentCommand.effect(isShown: true) == (isShown: true, focusesComposer: true))
|
|
}
|
|
|
|
@Test("Each request is its own — the counter never coalesces two")
|
|
func requestsAreCounted() {
|
|
let comments = CardComments()
|
|
#expect(comments.focusComposerRequests == 0)
|
|
comments.focusComposer()
|
|
comments.focusComposer()
|
|
#expect(comments.focusComposerRequests == 2)
|
|
}
|
|
|
|
@Test("All three comment rows are card-window-scoped and nothing else")
|
|
func theRowsAreScopedToACardWindow() {
|
|
#expect(!AddCommentCommand.isEnabled(nil))
|
|
#expect(!ShowCommentsCommand.isEnabled(nil))
|
|
#expect(!CommentsBesideBodyCommand.isEnabled(nil))
|
|
|
|
let comments = CardComments()
|
|
#expect(AddCommentCommand.isEnabled(comments))
|
|
#expect(ShowCommentsCommand.isEnabled(comments))
|
|
#expect(CommentsBesideBodyCommand.isEnabled(comments))
|
|
}
|
|
|
|
@Test("The lock does not disable them — showing a pane and focusing a buffer are not mutations")
|
|
func theLockLeavesThemAlone() {
|
|
let comments = CardComments()
|
|
comments.isEditable = false
|
|
#expect(AddCommentCommand.isEnabled(comments))
|
|
#expect(ShowCommentsCommand.isEnabled(comments))
|
|
}
|
|
}
|
|
|
|
// MARK: - The real wiring
|
|
|
|
@MainActor
|
|
@Suite("Card comments ▸ the store wiring")
|
|
struct CardCommentsWiringTests {
|
|
|
|
/// The **real** seams, over a real store — `CardWindowHost.configureComments` is `static` and
|
|
/// takes its collaborators precisely so this can drive the production wiring rather than a
|
|
/// re-typed copy of it (`configureRawSource`'s precedent).
|
|
@Test("A draft composed, posted and re-read round-trips through the wired seams")
|
|
func theWiringRoundTrips() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
let card = try makeCommentBoard(fixture)
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
let cardID = ItemID(rawValue: Ident.card1)
|
|
|
|
let comments = CardComments()
|
|
comments.isEditable = true
|
|
comments.cardFolder = fixture.url(card)
|
|
CardWindowHost.configureComments(comments, store: store, cardID: cardID, on: CardWindowUndo())
|
|
comments.open()
|
|
|
|
comments.composer.edited("A remark.\n")
|
|
#expect(comments.composer.flush() == .created)
|
|
#expect(fixture.exists("\(card)/comments/.draft"))
|
|
|
|
// Re-reading is restore-on-reopen, and it is only the read.
|
|
comments.reload()
|
|
#expect(comments.composer.text == "A remark.\n")
|
|
|
|
#expect(comments.composer.postNow() != nil)
|
|
comments.reload()
|
|
#expect(comments.thread.comments.count == 1)
|
|
#expect(comments.thread.comments[0].body == "A remark.\n")
|
|
#expect(comments.composer.text == "", "the draft is gone — it is a comment now")
|
|
#expect(!fixture.exists("\(card)/comments/.draft"))
|
|
}
|
|
|
|
@Test("Delete moves into comments/.trash/, and the deferred purge empties it")
|
|
func deleteThenPurge() throws {
|
|
// Realigned 2026-07-31 (13-native-undo.md ▸ Interaction with the trash): the pane's own
|
|
// close no longer purges — `comments/.trash/` is what the window's coarse close step
|
|
// restores from, so emptying it is the purge the window session owns and hands out
|
|
// (`CardWindowSession.endSession`; `CardSessionUndoTests` drives the deferral itself).
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
let card = try makeCommentBoard(fixture)
|
|
try fixture.item(commentPath(CommentIdent.one, inCard: card), commentText())
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
|
|
let comments = CardComments()
|
|
comments.isEditable = true
|
|
comments.cardFolder = fixture.url(card)
|
|
CardWindowHost.configureComments(
|
|
comments, store: store, cardID: ItemID(rawValue: Ident.card1), on: CardWindowUndo()
|
|
)
|
|
comments.open()
|
|
#expect(comments.thread.comments.count == 1)
|
|
|
|
comments.delete(ItemID(rawValue: CommentIdent.one))
|
|
#expect(comments.thread.comments.isEmpty)
|
|
#expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"))
|
|
|
|
comments.endSession()
|
|
#expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"),
|
|
"the saves land; the trash is not the pane's to empty any more")
|
|
|
|
comments.purgeTrashNow()
|
|
#expect(try fixture.entryNames("\(card)/comments/.trash").isEmpty)
|
|
}
|
|
|
|
@Test("An inline session's Cancel puts the file back through the wired save")
|
|
func cancelWritesThroughTheStore() 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 store = try BoardStore(rootURL: fixture.root)
|
|
|
|
let comments = CardComments()
|
|
comments.isEditable = true
|
|
comments.cardFolder = fixture.url(card)
|
|
CardWindowHost.configureComments(
|
|
comments, store: store, cardID: ItemID(rawValue: Ident.card1), on: CardWindowUndo()
|
|
)
|
|
comments.open()
|
|
|
|
comments.beginEdit(ItemID(rawValue: CommentIdent.one))
|
|
comments.editing?.edited("rewritten\n")
|
|
comments.editing?.flush()
|
|
#expect(try fixture.indexText(commentPath(CommentIdent.one, inCard: card)).hasSuffix("rewritten\n"))
|
|
|
|
comments.cancelEdit()
|
|
#expect(try fixture.indexText(commentPath(CommentIdent.one, inCard: card)).hasSuffix("original\n"))
|
|
}
|
|
}
|