Repo-nested boards bind native undo in every tier (25d2513): the no-undo case is gone, makeHistoryProvider answers git or native, and the native path provably never touches the enclosing repository's .git. Session undo steps anchor by card identity, never by path (9119aa1): HistoryAnchor carries the card UUID (plus comment/draft vocabulary) and apply-time validation resolves the current folder via the same both-container walk writeCardBody uses — a board-side lane or trash move no longer stales the coarse close step, while a genuine field collision still skips it whole. 2448 tests in 423 suites green. Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
814 lines
38 KiB
Swift
814 lines
38 KiB
Swift
import Foundation
|
|
import Testing
|
|
@testable import Kanban
|
|
|
|
/// The **write** half of the comment thread: the five Writer primitives, the two move-based inverses
|
|
/// registered at the store boundary, and the copy-boundary matrix (01-storage-format.md § Enhanced
|
|
/// schema; 05-card-window.md ▸ The comments column; 13-native-undo.md).
|
|
///
|
|
/// Assertions are against the **bytes on disk**, like every other write suite here — a comment that
|
|
/// is merely equivalent in a model is not the claim. `WriterFixture`, `Ident` and `Item` come from
|
|
/// `WriterTestSupport.swift`; `CommentIdent`, `commentText` and `makeCommentBoard` from
|
|
/// `CommentThreadTests.swift`.
|
|
|
|
// MARK: - Helpers
|
|
|
|
private let cardTitle = "Fix login"
|
|
|
|
private func document(_ fixture: WriterFixture, _ relativePath: String) throws -> FrontmatterDocument {
|
|
try FrontmatterDocument.parse(fixture.indexText(relativePath))
|
|
}
|
|
|
|
/// The one comment folder in a thread, as a path — the posted identity is minted, so a test that
|
|
/// posts has to find it rather than name it.
|
|
private func postedNames(_ fixture: WriterFixture, inCard card: String) throws -> [String] {
|
|
try fixture.entryNames("\(card)/comments")
|
|
.filter { IntegrityRules.isIdentityShaped($0) }
|
|
.sorted()
|
|
}
|
|
|
|
@MainActor
|
|
private func makeStore(_ fixture: WriterFixture) throws -> (store: BoardStore, history: NativeHistoryProvider) {
|
|
let store = try BoardStore(rootURL: fixture.root)
|
|
let history = NativeHistoryProvider()
|
|
store.history = history
|
|
return (store, history)
|
|
}
|
|
|
|
// MARK: - The draft
|
|
|
|
@Suite("Comments ▸ draft lifecycle")
|
|
struct CommentDraftTests {
|
|
|
|
@Test("The first save creates .draft with the comment schema, no title and no order")
|
|
func createStampsTheSchema() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
let card = try makeCommentBoard(fixture)
|
|
|
|
let outcome = try BoardWriter.saveCommentDraft(
|
|
inCard: fixture.url(card), body: "Half a thought", cardTitle: cardTitle
|
|
)
|
|
#expect(outcome == .created)
|
|
|
|
let draft = "\(card)/comments/.draft"
|
|
let text = try fixture.indexText(draft)
|
|
let parsed = try FrontmatterDocument.parse(text)
|
|
#expect(parsed.schema.value == 1)
|
|
#expect(parsed.kind.value == "comment")
|
|
#expect(parsed.author.value == NSFullUserName())
|
|
#expect(parsed.created.value != nil)
|
|
// One `Date` for both stamps, so a draft never reads as edited before it is posted.
|
|
#expect(parsed.created.value == parsed.modified.value)
|
|
#expect(parsed.title.isMissing, "a comment has no title")
|
|
#expect(parsed.order.isMissing, "a comment has no order")
|
|
#expect(parsed.body == "Half a thought")
|
|
#expect(text.hasPrefix("---\nschema: 1\n"))
|
|
}
|
|
|
|
@Test("An update replaces the body span and stamps modified; author and created stay")
|
|
func updateWritesTheBody() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
let card = try makeCommentBoard(fixture)
|
|
let draft = "\(card)/comments/.draft"
|
|
try fixture.item(draft, commentText())
|
|
|
|
let outcome = try BoardWriter.saveCommentDraft(
|
|
inCard: fixture.url(card), body: "Second thought\n", cardTitle: cardTitle
|
|
)
|
|
#expect(outcome == .updated)
|
|
|
|
let parsed = try document(fixture, draft)
|
|
#expect(parsed.body == "Second thought\n")
|
|
#expect(parsed.author.value == "Ada Lovelace")
|
|
#expect(parsed.created.value == parsed.created.value)
|
|
#expect(parsed.modified.value != parsed.created.value)
|
|
// The unknown key, its inline comment and its position are untouched.
|
|
#expect(try fixture.indexText(draft).contains("project: lanework # agent overlay"))
|
|
}
|
|
|
|
@Test("Identical bytes write nothing at all — the ~30 s tick must not stamp")
|
|
func unchangedWritesNothing() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
let card = try makeCommentBoard(fixture)
|
|
let draft = "\(card)/comments/.draft"
|
|
try fixture.item(draft, commentText(body: "Same\n"))
|
|
let before = try fixture.indexData(draft)
|
|
let stamp = try FileManager.default
|
|
.attributesOfItem(atPath: fixture.url(draft).appendingPathComponent("index.md").path)[.modificationDate] as? Date
|
|
|
|
let outcome = try BoardWriter.saveCommentDraft(
|
|
inCard: fixture.url(card), body: "Same\n", cardTitle: cardTitle
|
|
)
|
|
#expect(outcome == .unchanged)
|
|
#expect(try fixture.indexData(draft) == before)
|
|
let after = try FileManager.default
|
|
.attributesOfItem(atPath: fixture.url(draft).appendingPathComponent("index.md").path)[.modificationDate] as? Date
|
|
#expect(after == stamp)
|
|
}
|
|
|
|
@Test("A draft emptied of text with no attachments deletes its folder — never litter")
|
|
func emptiedDraftIsDeleted() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
let card = try makeCommentBoard(fixture)
|
|
try fixture.item("\(card)/comments/.draft", commentText(body: "typed\n"))
|
|
|
|
let outcome = try BoardWriter.saveCommentDraft(
|
|
inCard: fixture.url(card), body: " \n\n", cardTitle: cardTitle
|
|
)
|
|
#expect(outcome == .deleted)
|
|
#expect(!fixture.exists("\(card)/comments/.draft"))
|
|
#expect(fixture.exists("\(card)/comments"), "the container stays — the next draft mints into it")
|
|
}
|
|
|
|
@Test("A draft emptied of text but holding attachments survives")
|
|
func emptiedDraftWithAttachmentsSurvives() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
let card = try makeCommentBoard(fixture)
|
|
let draft = "\(card)/comments/.draft"
|
|
try fixture.item(draft, commentText(body: "typed\n"))
|
|
try fixture.file("\(draft)/attachments/shot.png", Data("png".utf8))
|
|
|
|
let outcome = try BoardWriter.saveCommentDraft(inCard: fixture.url(card), body: "", cardTitle: cardTitle)
|
|
#expect(outcome == .updated)
|
|
#expect(try document(fixture, draft).body == "")
|
|
#expect(fixture.exists("\(draft)/attachments/shot.png"))
|
|
}
|
|
|
|
@Test("An empty save against no draft creates nothing")
|
|
func emptySaveCreatesNothing() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
let card = try makeCommentBoard(fixture)
|
|
|
|
let outcome = try BoardWriter.saveCommentDraft(inCard: fixture.url(card), body: "", cardTitle: cardTitle)
|
|
#expect(outcome == .unchanged)
|
|
#expect(!fixture.exists("\(card)/comments"))
|
|
}
|
|
|
|
@Test("An interrupted create — a draft folder with no index.md — is filled in, not refused")
|
|
func indexlessDraftIsFilledIn() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
let card = try makeCommentBoard(fixture)
|
|
try FileManager.default.createDirectory(
|
|
at: fixture.url("\(card)/comments/.draft"), withIntermediateDirectories: true
|
|
)
|
|
|
|
#expect(try BoardWriter.saveCommentDraft(
|
|
inCard: fixture.url(card), body: "recovered\n", cardTitle: cardTitle
|
|
) == .created)
|
|
#expect(try document(fixture, "\(card)/comments/.draft").body == "recovered\n")
|
|
}
|
|
|
|
@Test("A draft is not a card write: only a card folder takes one")
|
|
func refusesNonCards() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
try makeCommentBoard(fixture)
|
|
|
|
let failure = writeFailure {
|
|
try BoardWriter.saveCommentDraft(inCard: fixture.url(Ident.lane1), body: "x", cardTitle: nil)
|
|
}
|
|
#expect(failure?.operation == .saveCommentDraft(title: nil))
|
|
}
|
|
}
|
|
|
|
// MARK: - Posting
|
|
|
|
@Suite("Comments ▸ post")
|
|
struct CommentPostTests {
|
|
|
|
@Test("Posting renames the draft to a fresh lowercase identity and restamps both dates")
|
|
func postRenamesAndRestamps() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
let card = try makeCommentBoard(fixture)
|
|
let draft = "\(card)/comments/.draft"
|
|
try fixture.item(draft, commentText(created: "2020-01-01T09:00:00Z", modified: "2020-01-01T09:00:00Z"))
|
|
try fixture.file("\(draft)/attachments/shot.png", Data("png".utf8))
|
|
let draftBody = try document(fixture, draft).body
|
|
|
|
let posted = try BoardWriter.postComment(inCard: fixture.url(card), cardTitle: cardTitle)
|
|
|
|
#expect(!fixture.exists(draft))
|
|
#expect(posted.id.rawValue == posted.id.rawValue.lowercased())
|
|
#expect(IntegrityRules.isIdentityShaped(posted.id.rawValue))
|
|
#expect(try postedNames(fixture, inCard: card) == [posted.id.rawValue])
|
|
|
|
let path = "\(card)/comments/\(posted.id.rawValue)"
|
|
let parsed = try document(fixture, path)
|
|
#expect(parsed.created.value == parsed.modified.value, "post time is one instant, not two")
|
|
#expect(parsed.created.value.map { abs($0.timeIntervalSince(posted.posted)) < 1 } == true)
|
|
#expect(parsed.created.value != nil && parsed.created.value! > Date(timeIntervalSince1970: 1_700_000_000))
|
|
// Everything else is the draft's own bytes: the body, the author, the unknown key and its
|
|
// inline comment, and the attachment that rode the rename.
|
|
#expect(parsed.body == draftBody)
|
|
#expect(parsed.author.value == "Ada Lovelace")
|
|
#expect(try fixture.indexText(path).contains("project: lanework # agent overlay"))
|
|
#expect(fixture.exists("\(path)/attachments/shot.png"))
|
|
}
|
|
|
|
@Test("A posted comment joins the thread; the draft no longer does")
|
|
func postedCommentIsInTheThread() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
let card = try makeCommentBoard(fixture)
|
|
try fixture.item("\(card)/comments/.draft", commentText())
|
|
|
|
let posted = try BoardWriter.postComment(inCard: fixture.url(card), cardTitle: cardTitle)
|
|
let thread = CommentThread.load(inCard: fixture.url(card), path: card)
|
|
#expect(thread.comments.map(\.id) == [posted.id])
|
|
#expect(!thread.hasDraft)
|
|
}
|
|
|
|
@Test("Posting nothing refuses, naming the card")
|
|
func postWithoutADraftRefuses() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
let card = try makeCommentBoard(fixture)
|
|
|
|
let failure = writeFailure { _ = try BoardWriter.postComment(inCard: fixture.url(card), cardTitle: cardTitle) }
|
|
#expect(failure?.operation == .postComment(title: cardTitle))
|
|
}
|
|
|
|
@Test("A second post mints a second identity beside the first")
|
|
func twoPostsAreTwoComments() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
let card = try makeCommentBoard(fixture)
|
|
|
|
try fixture.item("\(card)/comments/.draft", commentText(body: "one\n"))
|
|
let first = try BoardWriter.postComment(inCard: fixture.url(card), cardTitle: cardTitle)
|
|
try fixture.item("\(card)/comments/.draft", commentText(body: "two\n"))
|
|
let second = try BoardWriter.postComment(inCard: fixture.url(card), cardTitle: cardTitle)
|
|
|
|
#expect(first.id != second.id)
|
|
#expect(try postedNames(fixture, inCard: card).count == 2)
|
|
}
|
|
}
|
|
|
|
// MARK: - Editing
|
|
|
|
@Suite("Comments ▸ edit")
|
|
struct CommentEditTests {
|
|
|
|
@Test("An edit writes the body and stamps modified — '· edited' falls out of the pair")
|
|
func editStampsModified() 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())
|
|
|
|
#expect(try BoardWriter.editComment(at: fixture.url(path), body: "revised\n", cardTitle: cardTitle))
|
|
|
|
let parsed = try document(fixture, path)
|
|
#expect(parsed.body == "revised\n")
|
|
#expect(parsed.created.value != parsed.modified.value)
|
|
let thread = CommentThread.load(inCard: fixture.url(card), path: card)
|
|
#expect(thread.comments.first?.isEdited == true)
|
|
}
|
|
|
|
@Test("An unchanged body writes nothing")
|
|
func unchangedEditWritesNothing() 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: "same\n"))
|
|
let before = try fixture.indexData(path)
|
|
|
|
#expect(try BoardWriter.editComment(at: fixture.url(path), body: "same\n", cardTitle: cardTitle) == false)
|
|
#expect(try fixture.indexData(path) == before)
|
|
}
|
|
|
|
@Test("Only a posted comment is editable — never the draft, never a trashed one")
|
|
func refusesTheTwoDotFolders() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
let card = try makeCommentBoard(fixture)
|
|
try fixture.item("\(card)/comments/.draft", commentText())
|
|
try fixture.item("\(card)/comments/.trash/\(CommentIdent.one)", commentText())
|
|
|
|
for path in ["\(card)/comments/.draft", "\(card)/comments/.trash/\(CommentIdent.one)"] {
|
|
let failure = writeFailure {
|
|
_ = try BoardWriter.editComment(at: fixture.url(path), body: "x", cardTitle: cardTitle)
|
|
}
|
|
#expect(failure?.operation == .editComment(title: cardTitle))
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Delete, restore, purge
|
|
|
|
@Suite("Comments ▸ delete and purge")
|
|
struct CommentDeleteTests {
|
|
|
|
@Test("A delete is a move into comments/.trash that stamps and clears modified-by")
|
|
func deleteMovesAndStamps() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
let card = try makeCommentBoard(fixture)
|
|
let path = commentPath(CommentIdent.one, inCard: card)
|
|
try fixture.item(path, "---\nschema: 1\nauthor: Ada\nmodified-by: claude\ncreated: 2026-01-01T09:00:00Z\nmodified: 2026-01-01T09:00:00Z\nkind: comment\n---\nbody\n")
|
|
|
|
let id = try BoardWriter.deleteComment(at: fixture.url(path), cardTitle: cardTitle)
|
|
#expect(id.rawValue == CommentIdent.one)
|
|
#expect(!fixture.exists(path))
|
|
|
|
let trashed = "\(card)/comments/.trash/\(CommentIdent.one)"
|
|
let parsed = try document(fixture, trashed)
|
|
#expect(parsed.modified.value != parsed.created.value, "a container change stamps")
|
|
#expect(parsed.modifiedBy.isMissing)
|
|
#expect(parsed.author.value == "Ada", "author is content and survives even this")
|
|
#expect(parsed.body == "body\n")
|
|
}
|
|
|
|
@Test("A deleted comment leaves the thread")
|
|
func deletedIsExcluded() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
let card = try makeCommentBoard(fixture)
|
|
try fixture.item(commentPath(CommentIdent.one, inCard: card), commentText())
|
|
try fixture.item(commentPath(CommentIdent.two, inCard: card), commentText(created: "2026-02-02T09:00:00Z"))
|
|
|
|
try BoardWriter.deleteComment(at: fixture.url(commentPath(CommentIdent.one, inCard: card)), cardTitle: cardTitle)
|
|
let thread = CommentThread.load(inCard: fixture.url(card), path: card)
|
|
#expect(thread.comments.map(\.id.rawValue) == [CommentIdent.two])
|
|
}
|
|
|
|
@Test("Restoring is the ordinary move back out, and stamps again")
|
|
func restoreMovesBack() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
let card = try makeCommentBoard(fixture)
|
|
try fixture.item("\(card)/comments/.trash/\(CommentIdent.one)", commentText())
|
|
|
|
try BoardWriter.restoreComment(
|
|
ItemID(rawValue: CommentIdent.one), inCard: fixture.url(card), cardTitle: cardTitle
|
|
)
|
|
#expect(fixture.exists(commentPath(CommentIdent.one, inCard: card)))
|
|
#expect(!fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"))
|
|
#expect(try document(fixture, commentPath(CommentIdent.one, inCard: card)).body
|
|
== "A comment body — with *markdown*.\n")
|
|
}
|
|
|
|
@Test("The purge removes the entries, keeps strays, and leaves the container")
|
|
func purgeRemovesEntriesOnly() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
let card = try makeCommentBoard(fixture)
|
|
try fixture.item("\(card)/comments/.trash/\(CommentIdent.one)", commentText())
|
|
try fixture.item("\(card)/comments/.trash/\(CommentIdent.two)", commentText())
|
|
try fixture.file("\(card)/comments/.trash/notes.txt", Data("mine".utf8))
|
|
|
|
let purged = try BoardWriter.purgeCommentTrash(inCard: fixture.url(card))
|
|
#expect(Set(purged.map(\.rawValue)) == [CommentIdent.one, CommentIdent.two])
|
|
#expect(fixture.exists("\(card)/comments/.trash"))
|
|
#expect(try fixture.data("\(card)/comments/.trash/notes.txt") == Data("mine".utf8))
|
|
}
|
|
|
|
@Test("A card with no thread trash purges nothing")
|
|
func purgeIsTotal() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
let card = try makeCommentBoard(fixture)
|
|
#expect(try BoardWriter.purgeCommentTrash(inCard: fixture.url(card)).isEmpty)
|
|
}
|
|
}
|
|
|
|
// MARK: - The crash-residue memo
|
|
|
|
@MainActor
|
|
@Suite("Comments ▸ crash residue")
|
|
struct CommentResidueTests {
|
|
|
|
@Test("Residue left by a crashed session sweeps at the next open, and the memo clears")
|
|
func residueSweeps() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
let card = try makeCommentBoard(fixture)
|
|
try fixture.item("\(card)/comments/.trash/\(CommentIdent.one)", commentText())
|
|
let (store, _) = try makeStore(fixture)
|
|
|
|
store.sweepCommentTrashResidue(inCard: ItemID(rawValue: Ident.card1))
|
|
|
|
#expect(!fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"))
|
|
#expect(store.heals.memo(for: .commentTrashResidue) == nil, "cleared on success")
|
|
}
|
|
|
|
@Test("An open with nothing to sweep rests — no bracket, no memo")
|
|
func cleanOpenRests() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
try makeCommentBoard(fixture)
|
|
let (store, _) = try makeStore(fixture)
|
|
|
|
store.sweepCommentTrashResidue(inCard: ItemID(rawValue: Ident.card1))
|
|
#expect(store.heals.memo(for: .commentTrashResidue) == nil)
|
|
}
|
|
|
|
@Test("The close purge and the residue sweep converge on the same disk state")
|
|
func purgeAndSweepAgree() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
let card = try makeCommentBoard(fixture)
|
|
try fixture.item("\(card)/comments/.trash/\(CommentIdent.one)", commentText())
|
|
let (store, _) = try makeStore(fixture)
|
|
|
|
store.purgeCommentTrash(inCard: ItemID(rawValue: Ident.card1))
|
|
#expect(try fixture.entryNames("\(card)/comments/.trash").isEmpty)
|
|
}
|
|
}
|
|
|
|
// MARK: - Undo
|
|
|
|
@MainActor
|
|
@Suite("Comments ▸ undo")
|
|
struct CommentUndoTests {
|
|
|
|
private func board() throws -> (fixture: WriterFixture, card: String) {
|
|
let fixture = try WriterFixture()
|
|
let card = try makeCommentBoard(fixture)
|
|
return (fixture, card)
|
|
}
|
|
|
|
@Test("Posting registers one step, named for the 06 verb family")
|
|
func postRegistersOneStep() throws {
|
|
let (fixture, card) = try board()
|
|
defer { fixture.tearDown() }
|
|
try fixture.item("\(card)/comments/.draft", commentText())
|
|
let (store, history) = try makeStore(fixture)
|
|
|
|
_ = store.postComment(inCard: ItemID(rawValue: Ident.card1))
|
|
#expect(history.canUndo)
|
|
#expect(history.undoActionName == "Comment")
|
|
}
|
|
|
|
@Test("Undoing a post renames back to .draft; redo replays the same identity and instant")
|
|
func postRoundTrip() throws {
|
|
let (fixture, card) = try board()
|
|
defer { fixture.tearDown() }
|
|
try fixture.item("\(card)/comments/.draft", commentText(body: "drafted\n"))
|
|
let (store, history) = try makeStore(fixture)
|
|
|
|
let posted = store.postComment(inCard: ItemID(rawValue: Ident.card1))
|
|
let id = try #require(posted)
|
|
let postedText = try fixture.indexText("\(card)/comments/\(id.rawValue)")
|
|
|
|
history.undo()
|
|
#expect(!fixture.exists("\(card)/comments/\(id.rawValue)"))
|
|
#expect(fixture.exists("\(card)/comments/.draft"))
|
|
// A rename stamps nothing: the un-posted draft carries the post's own bytes, untouched.
|
|
#expect(try fixture.indexText("\(card)/comments/.draft") == postedText)
|
|
|
|
history.redo()
|
|
#expect(try postedNames(fixture, inCard: card) == [id.rawValue], "the same identity, not a fresh mint")
|
|
#expect(try fixture.indexText("\(card)/comments/\(id.rawValue)") == postedText, "the same instant, restamped")
|
|
}
|
|
|
|
@Test("A .draft typed since makes the post's undo stale — it skips, it never clobbers")
|
|
func postUndoSkipsWhenADraftIsBack() throws {
|
|
let (fixture, card) = try board()
|
|
defer { fixture.tearDown() }
|
|
try fixture.item("\(card)/comments/.draft", commentText(body: "first\n"))
|
|
let (store, history) = try makeStore(fixture)
|
|
|
|
let id = try #require(store.postComment(inCard: ItemID(rawValue: Ident.card1)))
|
|
// A new draft, the way the composer would leave one.
|
|
try fixture.item("\(card)/comments/.draft", commentText(body: "second\n"))
|
|
let untouched = try fixture.indexText("\(card)/comments/.draft")
|
|
|
|
history.undo()
|
|
|
|
#expect(try fixture.indexText("\(card)/comments/.draft") == untouched, "the new draft is not overwritten")
|
|
#expect(fixture.exists("\(card)/comments/\(id.rawValue)"), "and the posted comment stays posted")
|
|
#expect(!history.canUndo, "the stale step was popped")
|
|
#expect(store.banners.signposts.map(\.message) == ["Undo skipped — '\(cardTitle)' changed outside Lanework"])
|
|
}
|
|
|
|
@Test("Deleting registers one step; undo moves it back and redo moves it in")
|
|
func deleteRoundTrip() throws {
|
|
let (fixture, card) = try board()
|
|
defer { fixture.tearDown() }
|
|
let path = commentPath(CommentIdent.one, inCard: card)
|
|
try fixture.item(path, commentText())
|
|
let (store, history) = try makeStore(fixture)
|
|
|
|
#expect(store.deleteComment(ItemID(rawValue: CommentIdent.one), inCard: ItemID(rawValue: Ident.card1)))
|
|
#expect(history.undoActionName == "Delete Comment")
|
|
#expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"))
|
|
|
|
history.undo()
|
|
#expect(fixture.exists(path))
|
|
#expect(!fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"))
|
|
#expect(try document(fixture, path).body == "A comment body — with *markdown*.\n")
|
|
|
|
history.redo()
|
|
#expect(!fixture.exists(path))
|
|
#expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"))
|
|
}
|
|
|
|
@Test("A purge leaves the delete step stale — it skips with a banner, resurrecting nothing")
|
|
func purgeMakesDeleteStepsStale() throws {
|
|
let (fixture, card) = try board()
|
|
defer { fixture.tearDown() }
|
|
try fixture.item(commentPath(CommentIdent.one, inCard: card), commentText())
|
|
let (store, history) = try makeStore(fixture)
|
|
|
|
#expect(store.deleteComment(ItemID(rawValue: CommentIdent.one), inCard: ItemID(rawValue: Ident.card1)))
|
|
store.purgeCommentTrash(inCard: ItemID(rawValue: Ident.card1))
|
|
#expect(history.canUndo, "invalidation is lazy — the stack still looks full")
|
|
|
|
history.undo()
|
|
#expect(!fixture.exists(commentPath(CommentIdent.one, inCard: card)))
|
|
#expect(!history.canUndo)
|
|
#expect(store.banners.signposts.map(\.message) == ["Undo skipped — '\(cardTitle)' changed outside Lanework"])
|
|
}
|
|
|
|
@Test("The draft save, the inline edit and the purge register nothing")
|
|
func theThreeSilentOperations() throws {
|
|
let (fixture, card) = try board()
|
|
defer { fixture.tearDown() }
|
|
try fixture.item(commentPath(CommentIdent.one, inCard: card), commentText())
|
|
try fixture.item("\(card)/comments/.trash/\(CommentIdent.two)", commentText())
|
|
let (store, history) = try makeStore(fixture)
|
|
let cardID = ItemID(rawValue: Ident.card1)
|
|
|
|
store.saveCommentDraft(inCard: cardID, body: "typing\n")
|
|
#expect(store.editComment(ItemID(rawValue: CommentIdent.one), inCard: cardID, body: "revised\n"))
|
|
store.purgeCommentTrash(inCard: cardID)
|
|
|
|
#expect(!history.canUndo, "no byte capture in any tier — 13's rule")
|
|
}
|
|
|
|
@Test("Comment expectations validate disk, which is why comments need no snapshot")
|
|
func expectationsReadDisk() throws {
|
|
let (fixture, card) = try board()
|
|
defer { fixture.tearDown() }
|
|
let path = commentPath(CommentIdent.one, inCard: card)
|
|
try fixture.item(path, commentText())
|
|
|
|
// Path anchors resolve to themselves — the resolver a caller with no board hands in
|
|
// (`HistoryAnchor.literalPath`); the card-anchored halves are `CardSessionUndoTests`'.
|
|
#expect(HistoryStaleness.isCurrent([.present(fixture.url(path))], resolvedBy: \.literalPath))
|
|
#expect(HistoryStaleness.isCurrent([.absent(fixture.url("\(card)/comments/.draft"))], resolvedBy: \.literalPath))
|
|
try FileManager.default.removeItem(at: fixture.url(path))
|
|
#expect(!HistoryStaleness.isCurrent([.present(fixture.url(path))], resolvedBy: \.literalPath))
|
|
}
|
|
}
|
|
|
|
// MARK: - Copy boundaries
|
|
|
|
@Suite("Comments ▸ copy boundaries")
|
|
struct CommentCopyTests {
|
|
|
|
/// A card with a full thread: one posted comment carrying a tracker key and an attachment, a
|
|
/// draft, and one comment sitting in the thread's trash.
|
|
private func threadedCard(_ fixture: WriterFixture, in lane: String, card: String) throws -> String {
|
|
let path = "\(lane)/\(card)"
|
|
try fixture.item(path, Item.rich(order: "1024", title: "Fix login"))
|
|
try fixture.item("\(path)/comments/\(CommentIdent.one)", commentText(remote: "gitea#42"))
|
|
try fixture.file("\(path)/comments/\(CommentIdent.one)/attachments/shot.png", Data("png".utf8))
|
|
try fixture.item("\(path)/comments/.draft", commentText(body: "unposted\n"))
|
|
try fixture.item("\(path)/comments/.trash/\(CommentIdent.two)", commentText())
|
|
return path
|
|
}
|
|
|
|
@Test("An item-level copy carries the thread, remints it, severs remote, and strips the trash")
|
|
func itemCopyMatrix() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
try fixture.item("", Item.board)
|
|
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
|
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
|
|
try threadedCard(fixture, in: Ident.lane1, card: Ident.card1)
|
|
|
|
let copy = try BoardWriter.copyItem(
|
|
at: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
|
|
toParent: fixture.url(Ident.lane2),
|
|
order: 1024,
|
|
stamps: .fork
|
|
)
|
|
let copied = "\(Ident.lane2)/\(copy.rawValue)"
|
|
|
|
// The thread came, reminted.
|
|
let names = try postedNames(fixture, inCard: copied)
|
|
#expect(names.count == 1)
|
|
#expect(names[0] != CommentIdent.one, "comment folders remint like every copied folder")
|
|
#expect(names[0] == names[0].lowercased())
|
|
|
|
// The contract applied at comment depth.
|
|
let parsed = try document(fixture, "\(copied)/comments/\(names[0])")
|
|
#expect(parsed.value(for: FrontmatterKeys.remote) == nil, "the tracker claim is severed")
|
|
#expect(parsed.author.value == "Ada Lovelace")
|
|
#expect(parsed.created.value != nil, ".fork keeps created")
|
|
#expect(fixture.exists("\(copied)/comments/\(names[0])/attachments/shot.png"))
|
|
|
|
// The draft carries, fork-lossless; the trash does not travel.
|
|
#expect(try document(fixture, "\(copied)/comments/.draft").body == "unposted\n")
|
|
#expect(!fixture.exists("\(copied)/comments/.trash"))
|
|
|
|
// The source is untouched, thread trash included.
|
|
#expect(fixture.exists("\(Ident.lane1)/\(Ident.card1)/comments/.trash/\(CommentIdent.two)"))
|
|
#expect(fixture.exists("\(Ident.lane1)/\(Ident.card1)/comments/\(CommentIdent.one)"))
|
|
}
|
|
|
|
@Test("A copied lane's cards' threads are reminted too — arbitrary depth")
|
|
func laneCopyReachesCommentDepth() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
try fixture.item("", Item.board)
|
|
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
|
try threadedCard(fixture, in: Ident.lane1, card: Ident.card1)
|
|
|
|
let copy = try BoardWriter.copyItem(
|
|
at: fixture.url(Ident.lane1), toParent: fixture.root, order: 4096, stamps: .fork
|
|
)
|
|
let cards = try fixture.entryNames(copy.rawValue).filter { IntegrityRules.isIdentityShaped($0) }
|
|
#expect(cards.count == 1)
|
|
let copiedCard = "\(copy.rawValue)/\(cards[0])"
|
|
let comments = try postedNames(fixture, inCard: copiedCard)
|
|
#expect(comments.count == 1)
|
|
#expect(comments[0] != CommentIdent.one)
|
|
#expect(try document(fixture, "\(copiedCard)/comments/\(comments[0])").value(for: FrontmatterKeys.remote) == nil)
|
|
#expect(!fixture.exists("\(copiedCard)/comments/.trash"))
|
|
}
|
|
|
|
/// **The preflight reaches comment depth** (01-storage-format.md § Identity lifecycle and
|
|
/// § Enhanced schema, ruled 2026-07-31 — reversing the 2026-07-30 carve-out this suite used to
|
|
/// pin): "a comment whose frontmatter cannot take the stamp refuses the copy exactly like a card
|
|
/// or lane — never-refuse … is a *load* posture, and a user-initiated copy is a transaction, not
|
|
/// a load".
|
|
///
|
|
/// The board still loads with this comment on it — that is the other test in this file — and it
|
|
/// is the gesture that refuses. Whole, and with nothing materialized: a partial copy is the one
|
|
/// outcome the transaction rule exists to prevent.
|
|
@Test("A comment nobody can stamp refuses the whole copy, and nothing is materialized")
|
|
func brokenCommentRefusesACopy() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
try fixture.item("", Item.board)
|
|
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
|
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
|
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Fix login"))
|
|
// Readable, uneditable — the shape that refuses a copy at card level, now at comment depth.
|
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)/comments/\(CommentIdent.one)", Item.uneditable)
|
|
|
|
var thrown: BoardWriteError?
|
|
do {
|
|
_ = try BoardWriter.copyItem(
|
|
at: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
|
|
toParent: fixture.url(Ident.lane2),
|
|
order: 1024,
|
|
stamps: .fork
|
|
)
|
|
} catch {
|
|
thrown = error
|
|
}
|
|
|
|
let error = try #require(thrown)
|
|
if case .uneditableFrontmatter = error.reason {} else {
|
|
Issue.record("expected an uneditable-frontmatter refusal, got \(error.reason)")
|
|
}
|
|
#expect(error.path.hasSuffix("comments/\(CommentIdent.one)/index.md"),
|
|
"the path points at the annotation, which is where the fix is")
|
|
// Nothing landed, and the source is untouched.
|
|
#expect(try fixture.entryNames(Ident.lane2).filter { IntegrityRules.isIdentityShaped($0) }.isEmpty)
|
|
#expect(try fixture.indexText("\(Ident.lane1)/\(Ident.card1)/comments/\(CommentIdent.one)")
|
|
== Item.uneditable)
|
|
}
|
|
|
|
/// The other side of "load-scoped": the very same board **loads**, and its card window's thread
|
|
/// read tolerates the annotation. Only the copy gesture refuses.
|
|
@Test("The same broken comment never refuses the board")
|
|
func brokenCommentNeverRefusesTheBoard() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
try fixture.item("", Item.board)
|
|
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Fix login"))
|
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)/comments/\(CommentIdent.one)", Item.uneditable)
|
|
|
|
let model = try BoardLoader.load(boardRoot: fixture.root).model
|
|
#expect(model.lanes.first?.cards.map(\.id.rawValue) == [Ident.card1])
|
|
}
|
|
|
|
@Test("Template instantiation is born-today at comment depth too")
|
|
func instantiationRestampsComments() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
try fixture.item("", Item.board)
|
|
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
|
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
|
|
try threadedCard(fixture, in: Ident.lane1, card: Ident.card1)
|
|
|
|
let copy = try BoardWriter.copyItem(
|
|
at: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
|
|
toParent: fixture.url(Ident.lane2),
|
|
order: 1024,
|
|
stamps: .born
|
|
)
|
|
let copied = "\(Ident.lane2)/\(copy.rawValue)"
|
|
let names = try postedNames(fixture, inCard: copied)
|
|
let created = try #require(document(fixture, "\(copied)/comments/\(names[0])").created.value)
|
|
#expect(created.timeIntervalSinceNow > -60, ".born restamps created")
|
|
}
|
|
|
|
@Test("A whole-board fork carries the thread verbatim — but never its trash")
|
|
func wholeBoardForkCarriesVerbatim() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
try fixture.item("", Item.board)
|
|
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
|
try threadedCard(fixture, in: Ident.lane1, card: Ident.card1)
|
|
|
|
let destination = fixture.root.deletingLastPathComponent()
|
|
.appendingPathComponent("Fork-\(UUID().uuidString)", isDirectory: true)
|
|
defer { try? FileManager.default.removeItem(at: destination) }
|
|
try BoardTreeCopy.createDirectory(at: destination)
|
|
try BoardTreeCopy.copy(contentsOf: fixture.root, into: destination, isCancelled: { false })
|
|
|
|
let thread = destination
|
|
.appendingPathComponent(Ident.lane1).appendingPathComponent(Ident.card1)
|
|
.appendingPathComponent("comments")
|
|
// GUIDs kept, tracker keys kept — a fork is a fork.
|
|
let forked = thread.appendingPathComponent(CommentIdent.one).appendingPathComponent("index.md")
|
|
#expect(FileManager.default.fileExists(atPath: forked.path))
|
|
let text = try String(decoding: Data(contentsOf: forked), as: UTF8.self)
|
|
#expect(text.contains("remote: gitea#42"))
|
|
// The draft too.
|
|
#expect(FileManager.default.fileExists(
|
|
atPath: thread.appendingPathComponent(".draft").appendingPathComponent("index.md").path
|
|
))
|
|
// The thread trash, never.
|
|
#expect(!FileManager.default.fileExists(atPath: thread.appendingPathComponent(".trash").path))
|
|
}
|
|
|
|
@Test("The board's own .trash is untouched by the comment strip")
|
|
func theBoardTrashIsNotTheThreadTrash() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
try fixture.item("", Item.board)
|
|
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
|
try fixture.item(".trash/\(Ident.card4)", Item.rich(order: "1024", title: "Trashed"))
|
|
|
|
let destination = fixture.root.deletingLastPathComponent()
|
|
.appendingPathComponent("Fork-\(UUID().uuidString)", isDirectory: true)
|
|
defer { try? FileManager.default.removeItem(at: destination) }
|
|
try BoardTreeCopy.createDirectory(at: destination)
|
|
try BoardTreeCopy.copy(contentsOf: fixture.root, into: destination, isCancelled: { false })
|
|
|
|
#expect(FileManager.default.fileExists(
|
|
atPath: destination.appendingPathComponent(".trash/\(Ident.card4)/index.md").path
|
|
))
|
|
}
|
|
|
|
@Test("A trashed card carries its thread — and the trash interplay costs nothing")
|
|
func trashedCardsCarryTheirThread() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
try fixture.item("", Item.board)
|
|
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
|
let card = try threadedCard(fixture, in: Ident.lane1, card: Ident.card1)
|
|
|
|
try BoardWriter.deleteCardToTrash(at: fixture.url(card), inBoard: fixture.root)
|
|
|
|
let trashedCard = ".trash/\(Ident.card1)"
|
|
#expect(fixture.exists("\(trashedCard)/comments/\(CommentIdent.one)"))
|
|
#expect(fixture.exists("\(trashedCard)/comments/.draft"))
|
|
#expect(fixture.exists("\(trashedCard)/comments/.trash/\(CommentIdent.two)"), "a move carries everything")
|
|
|
|
let thread = CommentThread.load(inCard: fixture.url(trashedCard), path: trashedCard)
|
|
#expect(thread.comments.map(\.id.rawValue) == [CommentIdent.one])
|
|
|
|
// Restoring is the ordinary move back, thread intact.
|
|
_ = try BoardWriter.moveItem(
|
|
at: fixture.url(trashedCard),
|
|
toParent: fixture.url(Ident.lane1),
|
|
sourceBoardRoot: fixture.root,
|
|
destinationBoardRoot: fixture.root,
|
|
order: 1024
|
|
)
|
|
#expect(fixture.exists("\(card)/comments/\(CommentIdent.one)"))
|
|
}
|
|
|
|
@Test("The purge takes the whole card, thread and all")
|
|
func purgeTakesTheThread() throws {
|
|
let fixture = try WriterFixture()
|
|
defer { fixture.tearDown() }
|
|
try fixture.item("", Item.board)
|
|
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
|
try threadedCard(fixture, in: Ident.lane1, card: Ident.card1)
|
|
try fixture.moveFolder("\(Ident.lane1)/\(Ident.card1)", to: ".trash/\(Ident.card1)")
|
|
|
|
try BoardWriter.purgeTrashEntry(at: fixture.url(".trash/\(Ident.card1)"), inBoard: fixture.root)
|
|
#expect(!fixture.exists(".trash/\(Ident.card1)"))
|
|
}
|
|
}
|