Preview, stacked (over/under): title, the rendered body and the comment thread now stack in one continuous document with one scroll, instead of the fixed ≈3:2 split with each pane keeping its own. Edit mode keeps the split unchanged (an editor needs a stable scroll of its own), and the beside mount is untouched. CardBodySurface gains a `scrolls` flag: false switches off the hosted NSScrollView's scroller and elasticity and reports the NSTextView's own height for the proposed width via `sizeThatFits`, the layout-manager height-fit trick CommentBodyView already uses one level up. CardCommentsPane gains an `embeddedProxy`: supplied, it renders the same header, find bar, rows and composer without wrapping them in a second ScrollView, driving scrollTo off the outer document's proxy instead of its own. CardWindowView composes the two behind a new pure predicate, CommentsMount.showsContinuousDocument(mode:), tested in CardCommentsLayoutTests. The continuous↔split swap within stacked mount is a genuine remount of the body pane (two independent scrolls can't become one shared scroll by reconfiguration) — the same accepted cost the raw-source outlet already takes elsewhere in this window. No new animation on that swap, matching this file's existing precedent (the raw-source swap and the beside↔stacked mount switch are both instant cuts today). Decisions recorded on the card's thread, flagged for owner review where they're user-visible. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
354 lines
15 KiB
Swift
354 lines
15 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)
|
|
}
|
|
|
|
@Test("The continuous document arrangement is Preview-only, and stacked-only")
|
|
func continuousDocumentIsStackedPreviewOnly() {
|
|
// Title, body and the thread share one scroll only when the mount is stacked *and* the body
|
|
// is showing Preview — Edit needs its own stable scroll, and beside never had a split to
|
|
// fold away in the first place.
|
|
#expect(CommentsMount.stacked.showsContinuousDocument(mode: .preview))
|
|
#expect(!CommentsMount.stacked.showsContinuousDocument(mode: .edit))
|
|
#expect(!CommentsMount.beside.showsContinuousDocument(mode: .preview))
|
|
#expect(!CommentsMount.beside.showsContinuousDocument(mode: .edit))
|
|
}
|
|
}
|
|
|
|
// 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 column divider's clamp
|
|
|
|
@Suite("Comments ▸ the column divider")
|
|
struct CommentsColumnDividerClampTests {
|
|
|
|
@Test("A proposal between the two floors passes through untouched")
|
|
func withinBoundsPassesThrough() {
|
|
// A generous container — 1200pt — so neither floor is anywhere near the proposal.
|
|
#expect(
|
|
CardWindowMetrics.clampedCommentsColumnWidth(400, bodyPointSize: 13, containerWidth: 1200) == 400
|
|
)
|
|
}
|
|
|
|
@Test("A proposal narrower than the minimum clamps up to it")
|
|
func tooNarrowClampsToMinimum() {
|
|
let minimum = CardWindowMetrics.commentsMinimumWidth(bodyPointSize: 13)
|
|
#expect(
|
|
CardWindowMetrics.clampedCommentsColumnWidth(1, bodyPointSize: 13, containerWidth: 1200)
|
|
== minimum
|
|
)
|
|
// Zero and negative proposals — a drag that overshoots past the window's own edge — read the
|
|
// same way, never as a width smaller than the floor.
|
|
#expect(
|
|
CardWindowMetrics.clampedCommentsColumnWidth(-50, bodyPointSize: 13, containerWidth: 1200)
|
|
== minimum
|
|
)
|
|
}
|
|
|
|
@Test("A proposal that would starve the body clamps down to the body's own floor")
|
|
func tooWideClampsToLeaveTheBodyItsFloor() {
|
|
let bodyFloor = CardWindowMetrics.bodyMinimumWidth(bodyPointSize: 13)
|
|
let containerWidth: CGFloat = 700
|
|
let clamped = CardWindowMetrics.clampedCommentsColumnWidth(
|
|
10_000, bodyPointSize: 13, containerWidth: containerWidth
|
|
)
|
|
#expect(clamped == containerWidth - bodyFloor)
|
|
#expect(containerWidth - clamped == bodyFloor, "the body keeps exactly its floor, not less")
|
|
}
|
|
|
|
@Test("A container too narrow for both floors still answers the comments floor, never less")
|
|
func aStarvedContainerStillAnswersTheFloor() {
|
|
// A container narrower than `commentsMinimumWidth + bodyMinimumWidth` — the two floors
|
|
// cannot both be honored, and the comments floor wins rather than the arithmetic producing a
|
|
// maximum below the minimum (`min(max(proposed, minimum), maximum)` with a negative maximum).
|
|
let minimum = CardWindowMetrics.commentsMinimumWidth(bodyPointSize: 13)
|
|
#expect(
|
|
CardWindowMetrics.clampedCommentsColumnWidth(minimum, bodyPointSize: 13, containerWidth: 10)
|
|
== minimum
|
|
)
|
|
#expect(
|
|
CardWindowMetrics.clampedCommentsColumnWidth(10_000, bodyPointSize: 13, containerWidth: 10)
|
|
== minimum
|
|
)
|
|
}
|
|
|
|
@Test("At the window's own minimum size, the clamp forces exactly the comments floor")
|
|
func atTheWindowMinimumTheClampAgreesWithIt() {
|
|
// `CardWindowMetrics.minimumSize(commentsColumn: true)` is sidebar + body floor + comments
|
|
// floor. The beside `HStack`'s container at that window size is the window minus the sidebar
|
|
// — body floor plus comments floor — so a persisted width far larger than either floor still
|
|
// has to land on exactly the comments floor here, or the window's own minimum and the
|
|
// divider's drag floor would disagree about what "as small as this gets" means.
|
|
let bodyPointSize: CGFloat = 13
|
|
let minimum = CardWindowMetrics.minimumSize(bodyPointSize: bodyPointSize, commentsColumn: true)
|
|
let sidebarWidth = CardWindowMetrics.sidebarWidth(bodyPointSize: bodyPointSize)
|
|
let containerWidth = minimum.width - sidebarWidth
|
|
|
|
let clamped = CardWindowMetrics.clampedCommentsColumnWidth(
|
|
10_000, bodyPointSize: bodyPointSize, containerWidth: containerWidth
|
|
)
|
|
#expect(clamped == CardWindowMetrics.commentsMinimumWidth(bodyPointSize: bodyPointSize))
|
|
}
|
|
|
|
@Test("The floor scales with the body font, like every other measurement in this window")
|
|
func theFloorScalesWithTheBodyFont() {
|
|
let small = CardWindowMetrics.clampedCommentsColumnWidth(1, bodyPointSize: 11, containerWidth: 1200)
|
|
let large = CardWindowMetrics.clampedCommentsColumnWidth(1, bodyPointSize: 24, containerWidth: 1200)
|
|
#expect(large > small)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
)
|
|
}
|
|
}
|