Files
lanework/KanbanTests/CardCommentsLayoutTests.swift
T
rzen 6bf3308915 The stacked mount reads as one document in view mode — title, body and the thread share a single scroll
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
2026-08-09 12:30:41 -04:00

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)
)
}
}