The card window recomposes into three componentized panes (body, comments, attributes) with two mounts — beside or body-over-comments at ~3:2 — behind View ▸ Comments Beside Body. View ▸ Show Comments is one persisted app-wide bit, no content-derived auto-show; File ▸ Add Comment flips it on and focuses the composer. The thread renders author lines, edited markers, card-subset Markdown bodies, and read-only Quick Look chips under a count header with the sort- direction control. The composer edits comments/.draft/ on the slow cadence (blur, close, quit, ~30s interval), Escape only moves focus, ⌘↩ posts. Inline edit is a body-edit session in miniature: 700ms debounce, Save/⌘↩ commits, Cancel and Escape revert to session-start bytes, close flushes. File drops within either authoring surface carve out of the window-wide card default into that surface's attachments/; paperclips cover the no-drag path. Close flush runs inline flush, then draft save, then the comments/.trash purge; open sweeps crash residue. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
262 lines
10 KiB
Swift
262 lines
10 KiB
Swift
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)
|
|
)
|
|
}
|
|
}
|