import CoreGraphics import Foundation import Testing @testable import Kanban /// The comments pane's **pure** seams: where it mounts, which way the thread runs, what a comment's /// author line says, and which folder a dropped file lands in (05-card-window.md ▸ Composition, ▸ The /// comments column; ▸ Attachments' carve-out). /// /// Every one of them is a rule that fails silently in a running window — a 3:2 split that is 2:3 /// looks deliberate, a composer at the wrong end looks like a design choice, and a file that landed /// in the card's folder instead of the draft's is not visible at all until someone opens Finder. So /// they are values and functions, and this is their suite; the views that arrange them are the /// manual-verification list's. // MARK: - The mount @Suite("Comments ▸ where the pane mounts") struct CommentsMountTests { @Test("The menu row's bit reads one way: checked is beside") func theBitReadsOneWay() { #expect(CommentsMount(besideBody: true) == .beside) #expect(CommentsMount(besideBody: false) == .stacked) } @Test("Stacked splits three parts body to two parts comments") func stackedSplitsThreeToTwo() { let mount = CommentsMount.stacked #expect(mount.bodyHeight(in: 500) == 300) #expect(mount.commentsHeight(in: 500) == 200) } @Test("The two heights always add up to the window — no gap, no overlap") func theHeightsSum() { for height in [0.0, 1.0, 37.0, 500.0, 1013.5] as [CGFloat] { let mount = CommentsMount.stacked #expect(mount.bodyHeight(in: height) + mount.commentsHeight(in: height) == height) } } @Test("Beside, the body owns the full height and the comments pane divides none of it") func besideDividesNoHeight() { #expect(CommentsMount.beside.bodyHeight(in: 500) == 500) #expect(CommentsMount.beside.commentsHeight(in: 500) == 0) } @Test("A negative proposed height is clamped rather than laid out") func negativeHeightsClamp() { #expect(CommentsMount.stacked.bodyHeight(in: -10) == 0) #expect(CommentsMount.beside.bodyHeight(in: -10) == 0) } @Test("Only the beside mount widens the window") func onlyBesideWidens() { // "The window's minimum width grows only while the column is shown side-by-side" (05 ▸ // Composition) — which is the entire reason the stacked mount exists. #expect(CommentsMount.beside.widensWindow) #expect(!CommentsMount.stacked.widensWindow) } } // MARK: - The window's minimum @Suite("Comments ▸ the window minimum") struct CommentsWindowMinimumTests { @Test("The minimum grows by the comments floor only with the column beside the body") func theMinimumGrowsOnlyBeside() { let without = CardWindowMetrics.minimumSize(bodyPointSize: 13) let with = CardWindowMetrics.minimumSize(bodyPointSize: 13, commentsColumn: true) #expect(with.width == without.width + CardWindowMetrics.commentsMinimumWidth(bodyPointSize: 13)) #expect(with.height == without.height, "a stacked pane divides height rather than demanding more") } @Test("Every comments measurement scales with the body font") func measurementsScale() { // 10-accessibility.md ▸ Text: "metrics derive from font metrics, so layout survives the // largest system text sizes". The pane's numbers are no exception to the window's rule. #expect( CardWindowMetrics.commentsColumnWidth(bodyPointSize: 18) > CardWindowMetrics.commentsColumnWidth(bodyPointSize: 11) ) #expect( CardWindowMetrics.composerHeight(bodyPointSize: 18) > CardWindowMetrics.composerHeight(bodyPointSize: 11) ) #expect( CardWindowMetrics.inlineEditorHeight(bodyPointSize: 13) > CardWindowMetrics.composerHeight(bodyPointSize: 13), "an inline editor opens over text that already exists" ) } } // MARK: - The sort direction @Suite("Comments ▸ the sort direction") struct CommentSortDirectionTests { private func comments(_ ids: [String]) -> [Kanban.Comment] { ids.map { id in Kanban.Comment( id: ItemID(rawValue: id), schema: .valid(1), author: .missing, created: .missing, modified: .missing, modifiedBy: .missing, attachments: [], document: FrontmatterDocument(body: "") ) } } @Test("Ascending is the default, and the control's bit reads one way") func ascendingIsTheDefault() { #expect(CommentSortDirection(newestFirst: false) == .ascending) #expect(CommentSortDirection(newestFirst: true) == .descending) #expect(!CommentSortDirection.ascending.isNewestFirst) } @Test("Descending is the loader's order reversed, never a second sort") func descendingReverses() { let thread = comments([CommentIdent.one, CommentIdent.two, CommentIdent.three]) #expect(CommentSortDirection.ascending.apply(to: thread).map(\.id) == thread.map(\.id)) #expect(CommentSortDirection.descending.apply(to: thread).map(\.id) == thread.reversed().map(\.id)) } @Test("An empty thread reverses to an empty thread") func emptyReverses() { #expect(CommentSortDirection.descending.apply(to: []).isEmpty) } @Test("The composer sits at the newest end — bottom ascending, top descending") func theComposerSitsAtTheNewestEnd() { #expect(!CommentSortDirection.ascending.placesComposerFirst) #expect(CommentSortDirection.descending.placesComposerFirst) } @Test("The control names the order it would give, and names it once") func theControlLabelIsOneString() { #expect(CommentSortDirection.ascending.controlLabel == "Oldest First") #expect(CommentSortDirection.descending.controlLabel == "Newest First") } } // MARK: - The header @Suite("Comments ▸ the header count") struct CommentsHeaderTests { @Test("An empty thread still counts — the pane is an invitation, not an absence") func zeroIsACount() { #expect(CommentsHeader.title(count: 0) == "Comments · 0") #expect(CommentsHeader.title(count: 3) == "Comments · 3") } } // MARK: - The author line @Suite("Comments ▸ the author line") struct CommentAuthorLineTests { @Test("Name and timestamp join with the window's own separator") func nameAndTimestamp() { #expect( CommentAuthorLine.text(author: "Ada Lovelace", timestamp: "1 Jan 2026", isEdited: false) == "Ada Lovelace · 1 Jan 2026" ) } @Test("A missing author renders without a name — never a placeholder string") func missingAuthorRendersNothingInItsPlace() { // "Missing renders unattributed" (`Comment.author`), and unattributed is the absence of a // name: a placeholder there would be the app inventing an identity for a file that carries // none, which is the same reason there are no avatars. #expect(CommentAuthorLine.text(author: nil, timestamp: "1 Jan 2026", isEdited: false) == "1 Jan 2026") } @Test("An empty author string is absent too — the Writer never writes one") func blankAuthorIsAbsent() { #expect(CommentAuthorLine.text(author: "", timestamp: "1 Jan 2026", isEdited: false) == "1 Jan 2026") #expect(CommentAuthorLine.text(author: " ", timestamp: "1 Jan 2026", isEdited: false) == "1 Jan 2026") } @Test("'edited' appends when modified differs from created") func editedAppends() { #expect( CommentAuthorLine.text(author: "Ada", timestamp: "1 Jan 2026", isEdited: true) == "Ada · 1 Jan 2026 · edited" ) #expect(CommentAuthorLine.text(author: nil, timestamp: "1 Jan 2026", isEdited: true) == "1 Jan 2026 · edited") } @Test("A comment with neither a name nor a date renders no line at all") func nothingToSayIsNoLine() { #expect(CommentAuthorLine.text(author: nil, timestamp: nil, isEdited: false) == nil) // Not even for the edit marker: "· edited" hangs off something, and a row saying only // "edited" would be a row about nothing. #expect(CommentAuthorLine.text(author: nil, timestamp: nil, isEdited: true) == nil) } } // MARK: - The drop carve-out @Suite("Comments ▸ the drop carve-out") struct CommentDropCarveOutTests { @Test("Everywhere else in the window is the card's — the default stands") func theWindowDefaultStands() { #expect(CommentDropCarveOut.landing(for: CommentDropCarveOut.Hover()) == .card) } @Test("Within the composer's bounds, files land in the draft") func composerLandsInTheDraft() { #expect( CommentDropCarveOut.landing(for: CommentDropCarveOut.Hover(isOverComposer: true)) == .comment(.draft) ) } @Test("Within an inline edit session's bounds, files land in that comment") func inlineEditLandsInItsComment() { let id = ItemID(rawValue: CommentIdent.one) #expect( CommentDropCarveOut.landing(for: CommentDropCarveOut.Hover(inlineEdit: id)) == .comment(.comment(id)) ) } @Test("The inline session outranks the composer where they would ever overlap") func theInlineSessionWins() { // Moot today — an inline session opens over a comment row and the composer is a separate // surface — and fixed anyway, because the case where it stops being moot is a layout change // and a layout change must not silently move a user's files into the wrong folder. let id = ItemID(rawValue: CommentIdent.one) let hover = CommentDropCarveOut.Hover(isOverComposer: true, inlineEdit: id) #expect(CommentDropCarveOut.landing(for: hover) == .comment(.comment(id))) } } // MARK: - Where a target lives @Suite("Comments ▸ the target's folder") struct CommentTargetTests { @Test("The draft and a posted comment resolve to their own folders, and the thread's rule owns both") func targetsResolve() { let card = URL(fileURLWithPath: "/board/lane/card", isDirectory: true) let id = ItemID(rawValue: CommentIdent.one) #expect(CommentTarget.draft.folder(inCard: card) == CommentThread.draftFolder(inCard: card)) #expect( CommentTarget.comment(id).folder(inCard: card) == CommentThread.commentFolder(id, inCard: card) ) } }