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("Saves first, purge last") func savesPrecedeThePurge() { // The purge removes the folders a delete moved aside; running it before a session's save // could remove a folder that save was about to write into. 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"))) let purge = try? #require(spy.events.firstIndex(of: .purge)) #expect((edit ?? 0) < (purge ?? 0)) #expect((draft ?? 0) < (purge ?? 0)) } @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 still purges — the trash is the app's leftovers, not the user's work") func aCleanPaneStillPurges() { let spy = CommentsSpy() let comments = makeComments(spy) comments.endSession() #expect(spy.events == [.purge]) } } // 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) 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 close purge empties it") func deleteThenPurge() throws { 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)) 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(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)) 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")) } }