Files
lanework/KanbanTests/CardCommentsLayoutTests.swift
T
rzen 0a01405ced The comments column gets a handle — the beside-mount divider is now user-draggable
Kanban.xcodeproj must be regenerated (xcodegen generate) before building.

CardWindowMetrics.commentsColumnWidth was a fixed figure between the body
column and the comments pane in the side-by-side layout; it is now only the
resting default. A new CommentsColumnDivider replaces the plain Divider()
between the two panes with a real HStack element carrying a resize-left-right
cursor and a drag gesture, clamped through a new pure seam,
CardWindowMetrics.clampedCommentsColumnWidth — never narrower than the
existing commentsMinimumWidth floor, never wide enough to push the body under
its own bodyMinimumWidth. The drag tracks live in memory
(commentsColumnWidthOverride) and writes AppPreferences.commentsColumnWidthKey
exactly once, on release, mirroring LaneResizeSession's live-track/write-once
split rather than hammering UserDefaults per tick.

Persistence is app-wide via @AppStorage, matching showComments and
commentsBesideBody — the pane's other two layout bits — rather than the
per-card BoardRegistry.cardWindowFrames: this is "how the pane is arranged,"
the same kind of fact those two already are, not a per-card window geometry.
Flagged on the card thread as a call worth owner review.

Six new unit tests cover the clamp's two floors, the degenerate case where a
container is too narrow for both, and its agreement with
CardWindowMetrics.minimumSize at the window's own floor.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-09 08:38:07 -04:00

343 lines
14 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 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)
)
}
}