Files
lanework/KanbanTests/PrintTests.swift
T
rzen 7651e40318 Print boards and cards with configurable components and named print profiles
⌘P had no story: KanbanApp removed the platform's Print row outright on
11-command-nexus.md's "No Print story in v1 (⌘P unused)" line. That line
retires. File ▸ Print… now prints the board in front — lanes left to right,
each lane's cards top to bottom, as a linear document rather than a picture
of the strip — or, from a card window, that card. The trash is unreachable
by construction: it is a sibling container of `lanes`, not a lane.

The rules live in a pure layer nothing AppKit can reach. `PrintOptions` is
one Codable value carrying the printing card's five bullets — which
components (title, icon+labels line, rendered body, comments off by default
with either reading order), page breaks, one base face and size every other
size derives from, and a toggleable running head and foot. `PrintSource` is
what is being printed, frozen at ⌘P so the panel's repeated relayouts and a
board reloading underneath cannot disagree. `PrintDocumentBuilder` turns the
pair into a block list, which is where every decision a rendered page hides
becomes something a test can hold: component order, comment ordering, and
page-break markers that are markers rather than whitespace. Empty is empty
all the way up — a card with nothing to print consumes no page break, and a
lane whose cards all dropped out takes its heading with it.

A page break is a pagination fact, not a spacing one. TextKit has no
page-break character, so `PrintDocumentView` splits the document into
sections at its breaks and flows each into as many page-sized text
containers as it needs: a container boundary *is* a sheet boundary, at any
paper size with any margins. Bodies come from the app's one Markdown pass —
`BodyMarkup.parse` into `BodyMarkupRenderer` — re-faced run by run so the
chosen family reaches the text and fixed-pitch code keeps its own, and drawn
under a forced light appearance so the card window's dynamic label colours
do not print white.

Options ride in the print panel's own accessory rather than a pre-flight
sheet of ours, which buys the system's live preview of the real paginated
document; the preview refreshes through one KVO revision counter rather than
thirteen mirrored properties. Profiles persist app-side in UserDefaults,
never in board files — a print profile is how this user likes to read, not
what a board is (`BoardZoomStore`'s argument). A name is a profile's
identity, folded case-insensitively; "Last Used" is reserved in every
spelling, kept out of the stored list, and captured when an operation
actually ran, so a cancelled print rewrites nothing. Both decoders are
total: one unrecognized key must not cost a user every profile they saved.

DESIGN/11-command-nexus.md gains the Print row and loses the sentence
saying it would never have one.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-08 22:42:11 -04:00

1127 lines
47 KiB
Swift

import AppKit
import Foundation
import Testing
@testable import Kanban
/// Printing, reduced to the rules a unit test can hold (11-command-nexus.md ▸ File ▸ Print…).
///
/// The typography is not the point and is not tested — that is `PrintDocumentRenderer`'s and it is
/// legitimately unverifiable without eyes, the same standing `BodyMarkupRenderer` has. What *is* tested is
/// every layer under it, each of which fails silently:
///
/// - **Profiles** round-trip through a preferences plist. A field that stopped encoding, or a decoder that
/// threw on a shape a future build wrote, costs the user every profile they saved — and nothing on screen
/// would say so until they looked for a profile that had gone.
/// - **The document's structure** is where every option actually lands: which components appear, in what
/// order, which end of a thread comes first, and where a page break falls. All four are invisible in a
/// rendered page and obvious in a block list.
/// - **The `labels` reading** interprets a key 01-storage-format.md reserves and this version otherwise
/// leaves inert, so printing is the one surface that can be wrong about it.
/// - **The trash exclusion** is the one rule whose failure would print deleted cards.
// MARK: - Helpers
/// A profile store on a scratch defaults domain — profiles are app-wide and persisted, so a suite that used
/// `.standard` would rewrite the developer's own (`BoardZoomTests`' arrangement, verbatim in intent).
@MainActor
private func makeStore() -> (PrintProfileStore, UserDefaults, () -> Void) {
let name = "dev.rzen.indie.Kanban.print-tests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: name)!
return (PrintProfileStore(defaults: defaults), defaults, {
UserDefaults.standard.removePersistentDomain(forName: name)
})
}
/// Options with every field moved off its default, so a round-trip that dropped one is visible.
private func exoticOptions() -> PrintOptions {
var options = PrintOptions()
options.includesTitle = false
options.includesLabels = false
options.includesBody = false
options.includesComments = true
options.commentSort = .newestFirst
options.pageBreaks = .betweenCards
options.fontFamily = "Palatino"
options.fontSize = 13.5
options.headerShowsBoardTitle = false
options.headerShowsPrintDate = false
options.footerShowsPageNumbers = false
options.footerShowsCustomLine = true
options.footerCustomLine = "Confidential"
return options
}
private func card(
_ title: String?,
body: String = "",
icon: String? = nil,
labels: [String] = [],
comments: [PrintComment] = []
) -> PrintCard {
PrintCard(title: title, icon: icon, labels: labels, body: body, comments: comments)
}
private func board(_ lanes: [PrintLane], titled title: String = "Roadmap") -> PrintSource {
PrintSource(scope: .board, boardTitle: title, lanes: lanes)
}
/// A date some fixed distance from a base, so comment ordering is a fact about the sort rather than about
/// the clock.
private func stamp(_ minutes: Int) -> Date {
Date(timeIntervalSince1970: 1_760_000_000).addingTimeInterval(TimeInterval(minutes * 60))
}
// MARK: - Options ▸ Codable
@Suite("Print ▸ options round-trip")
struct PrintOptionsCodableTests {
@Test("Every field survives an encode and a decode")
func roundTrips() throws {
let options = exoticOptions()
let decoded = try JSONDecoder().decode(PrintOptions.self, from: try JSONEncoder().encode(options))
#expect(decoded == options)
}
@Test("The defaults round-trip too — the state a first print is configured in")
func defaultsRoundTrip() throws {
let options = PrintOptions()
let decoded = try JSONDecoder().decode(PrintOptions.self, from: try JSONEncoder().encode(options))
#expect(decoded == options)
#expect(decoded.includesComments == false, "comments are off by default")
#expect(decoded.pageBreaks == .flow)
#expect(decoded.fontFamily == nil, "the system font is the absence of a family, not a spelling of one")
}
/// The failure mode the lenient decoder exists to rule out: one unrecognized shape must not cost the
/// whole value.
@Test("A stored shape from another build decodes to the defaults rather than throwing")
func toleratesForeignShapes() throws {
let json = """
{"includesTitle": false, "pageBreaks": "betweenParagraphs", "commentSort": 7,
"fontSize": "large", "somethingNew": {"a": 1}}
"""
let decoded = try JSONDecoder().decode(PrintOptions.self, from: Data(json.utf8))
#expect(decoded.includesTitle == false, "the field it did understand is honoured")
#expect(decoded.pageBreaks == .flow, "an unknown page-break mode reads as the default")
#expect(decoded.commentSort == .oldestFirst, "a wrongly-typed sort reads as the default")
#expect(decoded.fontSize == PrintOptions().fontSize, "a wrongly-typed size reads as the default")
#expect(decoded.includesBody, "every absent field keeps its default")
}
@Test("A stored size outside the legal range is clamped, never drawn")
func clampsSize() throws {
for (stored, expected) in [(0.0, PrintOptions.fontSizeRange.lowerBound),
(-4.0, PrintOptions.fontSizeRange.lowerBound),
(900.0, PrintOptions.fontSizeRange.upperBound)] {
let decoded = try JSONDecoder().decode(
PrintOptions.self,
from: Data("{\"fontSize\": \(stored)}".utf8)
)
#expect(decoded.fontSize == expected)
}
}
@Test("Normalizing is a reading, not a rewrite — it never breaks the round-trip")
func normalizingIsARead() throws {
var options = PrintOptions()
options.footerShowsCustomLine = true
options.footerCustomLine = " "
// The stored value keeps the toggle: a banner emptied for one print and typed back in for the next
// must not lose it.
let decoded = try JSONDecoder().decode(PrintOptions.self, from: try JSONEncoder().encode(options))
#expect(decoded == options)
// The render's reading drops it.
#expect(decoded.normalized.footerShowsCustomLine == false)
}
@Test("Three components off and comments off is 'nothing to print'")
func describesAnyContent() {
var options = PrintOptions()
#expect(options.describesAnyContent)
options.includesTitle = false
options.includesLabels = false
options.includesBody = false
#expect(!options.describesAnyContent)
options.includesComments = true
#expect(options.describesAnyContent)
}
}
// MARK: - The catalog's rules
@Suite("Print ▸ the profile catalog")
struct PrintProfileCatalogTests {
@Test("A save appends; a save over the same name overwrites in place")
func savingAndOverwriting() {
var catalog = PrintProfileCatalog()
catalog.save(PrintOptions(), as: "Handout")
var second = PrintOptions()
second.fontSize = 20
catalog.save(second, as: "Archive")
#expect(catalog.names == ["Handout", "Archive"], "list order is the order they were saved in")
var replacement = PrintOptions()
replacement.pageBreaks = .betweenLanes
catalog.save(replacement, as: "Handout")
#expect(catalog.names == ["Handout", "Archive"], "an overwrite does not move the row")
#expect(catalog.options(named: "Handout")?.pageBreaks == .betweenLanes)
}
@Test("Names compare case-insensitively, and the last spelling typed wins")
func namesFoldCase() {
var catalog = PrintProfileCatalog()
catalog.save(PrintOptions(), as: "Handout")
catalog.save(PrintOptions(), as: " handout ")
#expect(catalog.names == ["handout"], "one profile, spelled the way it was last saved")
#expect(catalog.contains("HANDOUT"))
}
@Test("The reserved name cannot be claimed, in any spelling")
func reservedNameRefused() {
var catalog = PrintProfileCatalog()
let reserved = catalog.save(PrintOptions(), as: PrintProfile.lastUsedName)
let folded = catalog.save(PrintOptions(), as: "last used")
let blank = catalog.save(PrintOptions(), as: " ")
#expect(reserved == false)
#expect(folded == false)
#expect(blank == false, "a blank name is not a name")
#expect(catalog.names.isEmpty)
}
@Test("A rename keeps position and options; a collision is refused")
func renaming() {
var catalog = PrintProfileCatalog()
var handout = PrintOptions()
handout.fontSize = 15
catalog.save(handout, as: "Handout")
catalog.save(PrintOptions(), as: "Archive")
let renamed = catalog.rename("Handout", to: "Standup")
#expect(renamed)
#expect(catalog.names == ["Standup", "Archive"], "position survives the rename")
#expect(catalog.options(named: "Standup")?.fontSize == 15)
let collision = catalog.rename("Standup", to: "Archive")
let reserved = catalog.rename("Standup", to: PrintProfile.lastUsedName)
let missing = catalog.rename("Nothing", to: "Something")
let recased = catalog.rename("Standup", to: "STANDUP")
#expect(collision == false, "a rename never swallows a sibling")
#expect(reserved == false)
#expect(missing == false)
#expect(recased, "re-casing its own name is allowed")
#expect(catalog.names == ["STANDUP", "Archive"])
}
@Test("A delete of a name that matches nothing is a no-op")
func deleting() {
var catalog = PrintProfileCatalog()
catalog.save(PrintOptions(), as: "Handout")
catalog.delete("Nothing")
#expect(catalog.names == ["Handout"])
catalog.delete("handout")
#expect(catalog.names.isEmpty)
}
/// The bytes come out of a preferences plist a human may have edited, so the sanitizing rule is what a
/// menu can render rather than a guarantee about the writer.
@Test("Construction drops blank, reserved and duplicate entries")
func sanitizesOnConstruction() {
let catalog = PrintProfileCatalog(profiles: [
PrintProfile(name: " Handout ", options: PrintOptions()),
PrintProfile(name: "", options: PrintOptions()),
PrintProfile(name: PrintProfile.lastUsedName, options: PrintOptions()),
PrintProfile(name: "handout", options: PrintOptions())
])
#expect(catalog.names == ["Handout"], "trimmed, and the first of two spellings kept")
}
@Test("A catalog round-trips, and one malformed entry does not cost the rest")
func catalogCodable() throws {
var catalog = PrintProfileCatalog()
catalog.save(exoticOptions(), as: "Archive")
catalog.save(PrintOptions(), as: "Handout")
let decoded = try JSONDecoder().decode(PrintProfileCatalog.self, from: try JSONEncoder().encode(catalog))
#expect(decoded == catalog)
let partial = """
{"profiles": [{"name": "Kept"}, {"options": {"fontSize": 12}}, {"name": "Also kept", "options": {}}]}
"""
let lenient = try JSONDecoder().decode(PrintProfileCatalog.self, from: Data(partial.utf8))
#expect(lenient.names == ["Kept", "Also kept"], "the nameless entry is dropped, the rest survive")
#expect(lenient.options(named: "Kept") == PrintOptions())
}
}
// MARK: - The store's persistence
@Suite("Print ▸ profile persistence")
@MainActor
struct PrintProfileStoreTests {
@Test("A saved profile survives a fresh store over the same domain")
func savePersists() {
let (store, defaults, teardown) = makeStore()
defer { teardown() }
#expect(store.catalog.names.isEmpty, "a first launch has no profiles")
#expect(store.save(exoticOptions(), as: "Archive"))
let reopened = PrintProfileStore(defaults: defaults)
#expect(reopened.catalog.names == ["Archive"])
#expect(reopened.options(named: "Archive") == exoticOptions())
}
@Test("Renames and deletes persist too")
func managementPersists() {
let (store, defaults, teardown) = makeStore()
defer { teardown() }
store.save(PrintOptions(), as: "Handout")
store.save(exoticOptions(), as: "Archive")
#expect(store.rename("Handout", to: "Standup"))
store.delete("Archive")
#expect(PrintProfileStore(defaults: defaults).catalog.names == ["Standup"])
}
@Test("Last Used captures the options a print ran with, and survives a relaunch")
func lastUsedCaptures() {
let (store, defaults, teardown) = makeStore()
defer { teardown() }
#expect(store.lastUsed == nil, "nothing has been printed yet")
#expect(store.options(named: PrintProfile.lastUsedName) == PrintOptions(),
"the reserved row reads as the factory defaults on a first launch")
store.captureLastUsed(exoticOptions())
#expect(store.lastUsed == exoticOptions())
let reopened = PrintProfileStore(defaults: defaults)
#expect(reopened.lastUsed == exoticOptions())
#expect(reopened.options(named: PrintProfile.lastUsedName) == exoticOptions())
}
@Test("The reserved row leads the menu, then the named ones in save order")
func menuOrder() {
let (store, _, teardown) = makeStore()
defer { teardown() }
store.save(PrintOptions(), as: "Handout")
store.save(PrintOptions(), as: "Archive")
#expect(store.menuNames == [PrintProfile.lastUsedName, "Handout", "Archive"])
}
@Test("A garbage preference reads as nothing stored rather than taking the surface down")
func toleratesGarbage() throws {
let name = "dev.rzen.indie.Kanban.print-garbage.\(UUID().uuidString)"
let defaults = try #require(UserDefaults(suiteName: name))
defer { UserDefaults.standard.removePersistentDomain(forName: name) }
defaults.set(42, forKey: AppPreferences.printProfilesKey)
defaults.set(Data("not json".utf8), forKey: AppPreferences.printLastUsedKey)
let store = PrintProfileStore(defaults: defaults)
#expect(store.catalog.names.isEmpty)
#expect(store.lastUsed == nil)
}
@Test("A refused name writes nothing at all")
func refusalWritesNothing() {
let (store, defaults, teardown) = makeStore()
defer { teardown() }
#expect(store.save(PrintOptions(), as: PrintProfile.lastUsedName) == false)
#expect(defaults.data(forKey: AppPreferences.printProfilesKey) == nil)
}
}
// MARK: - The document
@Suite("Print ▸ document assembly")
struct PrintDocumentBuilderTests {
private static let source = board([
PrintLane(title: "Doing", cards: [
card("Fix login", body: "Some **words**.", icon: "flag", labels: ["bug", "ui"]),
card("Ship it", body: "More words.")
]),
PrintLane(title: "Done", cards: [
card("Old thing", body: "Done words.")
])
])
@Test("The default document is board, lane, card, meta, body — in that order")
func defaultOrder() {
let blocks = PrintDocumentBuilder.blocks(from: Self.source, options: PrintOptions())
#expect(blocks == [
.boardHeading("Roadmap"),
.laneHeading("Doing"),
.cardTitle("Fix login"),
.cardMeta(icon: "flag", labels: ["bug", "ui"]),
.cardBody("Some **words**."),
.cardTitle("Ship it"),
.cardBody("More words."),
.laneHeading("Done"),
.cardTitle("Old thing"),
.cardBody("Done words.")
])
}
@Test("A card with no chosen icon and no labels contributes no meta line")
func metaOmittedWhenEmpty() {
let blocks = PrintDocumentBuilder.blocks(from: Self.source, options: PrintOptions())
let metas = blocks.filter { if case .cardMeta = $0 { return true } else { return false } }
#expect(metas.count == 1, "only the card that has something to say gets the line")
}
@Test("Each component toggle removes exactly its own block")
func componentToggles() {
var options = PrintOptions()
options.includesTitle = false
var blocks = PrintDocumentBuilder.blocks(from: Self.source, options: options)
#expect(!blocks.contains { if case .cardTitle = $0 { return true } else { return false } })
#expect(blocks.contains { if case .cardBody = $0 { return true } else { return false } })
options = PrintOptions()
options.includesLabels = false
blocks = PrintDocumentBuilder.blocks(from: Self.source, options: options)
#expect(!blocks.contains { if case .cardMeta = $0 { return true } else { return false } })
options = PrintOptions()
options.includesBody = false
blocks = PrintDocumentBuilder.blocks(from: Self.source, options: options)
#expect(!blocks.contains { if case .cardBody = $0 { return true } else { return false } })
#expect(blocks.contains { if case .cardTitle = $0 { return true } else { return false } })
}
@Test("Every component off prints nothing — not a page of running heads")
func nothingIncludedIsAnEmptyDocument() {
var options = PrintOptions()
options.includesTitle = false
options.includesLabels = false
options.includesBody = false
#expect(PrintDocumentBuilder.blocks(from: Self.source, options: options).isEmpty)
}
@Test("An untitled card and an untitled lane print the placeholder, never a blank line")
func untitledPlaceholder() {
let source = board([PrintLane(title: nil, cards: [card(nil, body: "Words.")])])
let blocks = PrintDocumentBuilder.blocks(from: source, options: PrintOptions())
#expect(blocks == [
.boardHeading("Roadmap"),
.laneHeading(PrintDocumentBuilder.untitled),
.cardTitle(PrintDocumentBuilder.untitled),
.cardBody("Words.")
])
}
/// The whitespace case is the card window's own emptiness rule (`BodyMarkup.isEmpty`), reused so a body
/// of one newline does not print a blank paragraph.
@Test("A whitespace-only body is no body")
func whitespaceBodyOmitted() {
let source = board([PrintLane(title: "Doing", cards: [card("Titled", body: "\n \n")])])
let blocks = PrintDocumentBuilder.blocks(from: source, options: PrintOptions())
#expect(blocks == [.boardHeading("Roadmap"), .laneHeading("Doing"), .cardTitle("Titled")])
}
@Test("An empty lane is omitted, and so is a card with nothing to show")
func emptiesDropOut() {
let source = board([
PrintLane(title: "Empty", cards: []),
PrintLane(title: "All blank", cards: [card(nil), card(nil)]),
PrintLane(title: "Real", cards: [card("Kept", body: "Words.")])
])
var options = PrintOptions()
// With titles off, the two blank cards have nothing left at all — which must take their lane with
// them rather than leaving a heading over nothing.
options.includesTitle = false
let blocks = PrintDocumentBuilder.blocks(from: source, options: options)
#expect(blocks == [.boardHeading("Roadmap"), .laneHeading("Real"), .cardBody("Words.")])
}
@Test("A card print is the card — no board heading, no lane heading")
func cardScope() {
let source = PrintSource(
scope: .card,
boardTitle: "Roadmap",
lanes: [PrintLane(title: "Doing", cards: [card("Fix login", body: "Words.")])]
)
var options = PrintOptions()
options.pageBreaks = .betweenCards
#expect(PrintDocumentBuilder.blocks(from: source, options: options) == [
.cardTitle("Fix login"),
.cardBody("Words.")
])
}
}
// MARK: - Page breaks
@Suite("Print ▸ page breaks")
struct PrintPageBreakTests {
private static let source = board([
PrintLane(title: "Doing", cards: [card("A", body: "a"), card("B", body: "b")]),
PrintLane(title: "Done", cards: [card("C", body: "c")])
])
@Test("Continuous emits no break at all")
func flow() {
let blocks = PrintDocumentBuilder.blocks(from: Self.source, options: PrintOptions())
#expect(!blocks.contains(.pageBreak))
}
@Test("Between lanes breaks before each lane after the first, and nowhere else")
func betweenLanes() {
var options = PrintOptions()
options.pageBreaks = .betweenLanes
#expect(PrintDocumentBuilder.blocks(from: Self.source, options: options) == [
.boardHeading("Roadmap"),
.laneHeading("Doing"),
.cardTitle("A"), .cardBody("a"),
.cardTitle("B"), .cardBody("b"),
.pageBreak,
.laneHeading("Done"),
.cardTitle("C"), .cardBody("c")
])
}
@Test("Between cards breaks before every card but the document's first")
func betweenCards() {
var options = PrintOptions()
options.pageBreaks = .betweenCards
#expect(PrintDocumentBuilder.blocks(from: Self.source, options: options) == [
.boardHeading("Roadmap"),
.laneHeading("Doing"),
.cardTitle("A"), .cardBody("a"),
.pageBreak,
.cardTitle("B"), .cardBody("b"),
.pageBreak,
.laneHeading("Done"),
.cardTitle("C"), .cardBody("c")
])
}
/// The board's name is the first lane's running-in title, not a title page — the one thing that would
/// otherwise put a lone heading on sheet one of every print with breaks on.
@Test("The board heading never earns a break of its own")
func boardHeadingIsNotATitlePage() {
for mode in [PrintPageBreaks.betweenLanes, .betweenCards] {
var options = PrintOptions()
options.pageBreaks = mode
let blocks = PrintDocumentBuilder.blocks(from: Self.source, options: options)
#expect(blocks.first == .boardHeading("Roadmap"))
#expect(blocks.dropFirst().first == .laneHeading("Doing"), "no break between the two")
}
}
@Test("A break is never leading, never trailing, and never doubled")
func breaksAreWellFormed() {
let source = board([
PrintLane(title: "Empty", cards: []),
PrintLane(title: "One", cards: [card("A", body: "a")]),
PrintLane(title: "Blank", cards: [card(nil)]),
PrintLane(title: "Two", cards: [card("B", body: "b")])
])
var options = PrintOptions()
options.pageBreaks = .betweenCards
options.includesTitle = false
let blocks = PrintDocumentBuilder.blocks(from: source, options: options)
#expect(blocks.first != .pageBreak)
#expect(blocks.last != .pageBreak)
for (left, right) in zip(blocks, blocks.dropFirst()) {
#expect(!(left == .pageBreak && right == .pageBreak))
}
#expect(blocks.filter { $0 == .pageBreak }.count == 1, "the two dropped lanes take their breaks too")
}
@Test("A single-card board never breaks, whatever the mode")
func oneCardNeverBreaks() {
let source = board([PrintLane(title: "Doing", cards: [card("A", body: "a")])])
for mode in PrintPageBreaks.allCases {
var options = PrintOptions()
options.pageBreaks = mode
#expect(!PrintDocumentBuilder.blocks(from: source, options: options).contains(.pageBreak))
}
}
}
// MARK: - Comments
@Suite("Print ▸ comments")
struct PrintCommentTests {
private static let thread = [
PrintComment(author: "ada", created: stamp(0), body: "first"),
PrintComment(author: "grace", created: stamp(10), body: "second"),
PrintComment(author: nil, created: stamp(20), body: "third")
]
private static let source = board([
PrintLane(title: "Doing", cards: [card("A", body: "a", comments: Self.thread)])
])
@Test("Comments are off by default and print nothing")
func offByDefault() {
let blocks = PrintDocumentBuilder.blocks(from: Self.source, options: PrintOptions())
#expect(!blocks.contains { if case .comment = $0 { return true } else { return false } })
#expect(!blocks.contains { if case .commentsHeading = $0 { return true } else { return false } })
}
@Test("Oldest first walks the thread's own chronology, heading first")
func oldestFirst() {
var options = PrintOptions()
options.includesComments = true
#expect(PrintDocumentBuilder.blocks(from: Self.source, options: options) == [
.boardHeading("Roadmap"),
.laneHeading("Doing"),
.cardTitle("A"),
.cardBody("a"),
.commentsHeading(count: 3),
.comment(author: "ada", created: stamp(0), body: "first"),
.comment(author: "grace", created: stamp(10), body: "second"),
.comment(author: nil, created: stamp(20), body: "third")
])
}
@Test("Newest first reverses that order and nothing else")
func newestFirst() {
var options = PrintOptions()
options.includesComments = true
options.commentSort = .newestFirst
let bodies = PrintDocumentBuilder.blocks(from: Self.source, options: options).compactMap { block -> String? in
guard case let .comment(_, _, body) = block else { return nil }
return body
}
#expect(bodies == ["third", "second", "first"])
}
@Test("A card with no comments gets no heading, even with comments on")
func emptyThread() {
var options = PrintOptions()
options.includesComments = true
let source = board([PrintLane(title: "Doing", cards: [card("A", body: "a")])])
#expect(!PrintDocumentBuilder.blocks(from: source, options: options)
.contains { if case .commentsHeading = $0 { return true } else { return false } })
}
/// A thread is the only content a card may have — with the title, the labels and the body all off, the
/// comments still print.
@Test("Comments alone are enough to keep a card in the document")
func commentsAloneKeepACard() {
var options = PrintOptions()
options.includesTitle = false
options.includesLabels = false
options.includesBody = false
options.includesComments = true
#expect(PrintDocumentBuilder.blocks(from: Self.source, options: options) == [
.boardHeading("Roadmap"),
.laneHeading("Doing"),
.commentsHeading(count: 3),
.comment(author: "ada", created: stamp(0), body: "first"),
.comment(author: "grace", created: stamp(10), body: "second"),
.comment(author: nil, created: stamp(20), body: "third")
])
}
@Test("A thread flattens in the loader's order — printing never re-sorts")
func flatteningKeepsThreadOrder() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", "---\nschema: 1\ntitle: Board\n---\n")
let lane = "11111111-1111-4111-8111-111111111111"
let cardID = "22222222-2222-4222-8222-222222222222"
try fixture.item(lane, "---\nschema: 1\ntitle: Doing\norder: 1024\n---\n")
try fixture.item("\(lane)/\(cardID)", "---\nschema: 1\ntitle: A\norder: 1024\n---\nBody.\n")
// Deliberately written out of chronological order, so an order that came out right could only have
// come from the sort.
try fixture.item(
"\(lane)/\(cardID)/comments/33333333-3333-4333-8333-333333333333",
"---\nschema: 1\nkind: comment\nauthor: grace\ncreated: 2026-02-02T09:00:00Z\n---\nsecond\n"
)
try fixture.item(
"\(lane)/\(cardID)/comments/44444444-4444-4444-8444-444444444444",
"---\nschema: 1\nkind: comment\nauthor: ada\ncreated: 2026-01-01T09:00:00Z\n---\nfirst\n"
)
let cardFolder = fixture.url("\(lane)/\(cardID)")
let comments = PrintComment.list(of: CommentThread.load(inCard: cardFolder, path: "\(lane)/\(cardID)"))
#expect(comments.map(\.body) == ["first\n", "second\n"])
#expect(comments.map(\.author) == ["ada", "grace"])
}
}
// MARK: - Extraction from a real board
@Suite("Print ▸ what a board contributes")
@MainActor
struct PrintSourceTests {
/// A board with two lanes, a trashed card and a trashed lane — the one arrangement whose failure would
/// print deleted cards.
private func fixture() throws -> WriterFixture {
let fixture = try WriterFixture()
try fixture.item("", "---\nschema: 1\ntitle: Roadmap\n---\n")
try fixture.item("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", "---\nschema: 1\ntitle: Doing\norder: 1024\n---\n")
try fixture.item("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", "---\nschema: 1\ntitle: Done\norder: 2048\n---\n")
try fixture.item(
"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/cccccccc-cccc-4ccc-8ccc-cccccccccccc",
"---\nschema: 1\ntitle: Second\norder: 2048\nicon: flag\nlabels: [bug, ui]\n---\nSecond body.\n"
)
try fixture.item(
"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/dddddddd-dddd-4ddd-8ddd-dddddddddddd",
"---\nschema: 1\ntitle: First\norder: 1024\n---\nFirst body.\n"
)
try fixture.item(
"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb/eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee",
"---\nschema: 1\ntitle: Shipped\norder: 1024\n---\nShipped body.\n"
)
// The trash: one card and one lane, both of which a board print must not reach.
try fixture.item(
".trash/ffffffff-ffff-4fff-8fff-ffffffffffff",
"---\nschema: 1\nkind: card\ntitle: Deleted card\nmodified: 2026-03-03T09:00:00Z\n---\nGone.\n"
)
try fixture.item(
".trash/99999999-9999-4999-8999-999999999999",
"---\nschema: 1\nkind: lane\ntitle: Deleted lane\nmodified: 2026-03-04T09:00:00Z\n---\n"
)
return fixture
}
@Test("Lanes and cards arrive in display order, and the trash is not reachable")
func laneAndCardOrder() throws {
let fixture = try self.fixture()
defer { fixture.tearDown() }
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
// The fixture really does have a trash — otherwise the exclusion below proves nothing.
#expect(snapshot.trash.count == 1)
#expect(snapshot.trashedLanes.count == 1)
let source = PrintSource.board(snapshot, titled: "Roadmap")
#expect(source.scope == .board)
#expect(source.lanes.map(\.title) == ["Doing", "Done"])
#expect(source.lanes[0].cards.map(\.title) == ["First", "Second"], "by rank, not by folder name")
#expect(source.lanes[1].cards.map(\.title) == ["Shipped"])
let printed = PrintDocumentBuilder.blocks(from: source, options: PrintOptions())
#expect(!printed.contains(.cardTitle("Deleted card")))
#expect(!printed.contains(.laneHeading("Deleted lane")))
}
@Test("A card's icon, labels and body cross over; an unknown icon does not")
func cardFields() throws {
let fixture = try self.fixture()
defer { fixture.tearDown() }
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
let source = PrintSource.board(snapshot, titled: "Roadmap")
let second = try #require(source.lanes.first?.cards.last)
#expect(second.title == "Second")
#expect(second.icon == "flag")
#expect(second.labels == ["bug", "ui"])
#expect(second.body == "Second body.\n")
let first = try #require(source.lanes.first?.cards.first)
#expect(first.icon == nil, "a card with no chosen icon prints none — the level default stays on screen")
#expect(first.labels.isEmpty)
}
@Test("A card print carries its lane and its board without printing headings for them")
func cardScopeExtraction() throws {
let fixture = try self.fixture()
defer { fixture.tearDown() }
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
let lane = try #require(snapshot.lanes.first)
let card = try #require(lane.cards.first)
let source = PrintSource.card(card, laneTitle: lane.title.value, boardTitle: "Roadmap", comments: [])
#expect(source.scope == .card)
#expect(source.boardTitle == "Roadmap")
#expect(source.lanes.map(\.title) == ["Doing"])
#expect(PrintDocumentBuilder.blocks(from: source, options: PrintOptions()) == [
.cardTitle("First"),
.cardBody("First body.\n")
])
}
}
// MARK: - The labels reading
@Suite("Print ▸ the reserved labels key")
struct PrintLabelsTests {
private func labels(_ frontmatter: String) throws -> [String] {
PrintCard.labels(of: try FrontmatterDocument.parse("---\nschema: 1\n\(frontmatter)---\nBody.\n"))
}
@Test("A sequence is its scalar members, in order")
func sequence() throws {
#expect(try labels("labels: [bug, ui, p1]\n") == ["bug", "ui", "p1"])
#expect(try labels("labels:\n - bug\n - ui\n") == ["bug", "ui"])
}
@Test("A comma-separated scalar is the pair a human meant to type")
func commaSeparatedScalar() throws {
#expect(try labels("labels: bug, ui\n") == ["bug", "ui"])
#expect(try labels("labels: bug\n") == ["bug"])
}
@Test("Non-string scalars read as the engine renders them")
func nonStringScalars() throws {
#expect(try labels("labels: [1, 2]\n") == ["1", "2"])
#expect(try labels("labels: [true]\n") == ["true"])
}
@Test("Shapes that are not a label row contribute nothing, and never an error")
func exoticShapes() throws {
#expect(try labels("labels:\n") == [], "an explicit null")
#expect(try labels("labels: ''\n") == [])
#expect(try labels("labels: {a: 1}\n") == [], "a mapping is not a label row")
#expect(try labels("labels: [[a, b], c]\n") == ["c"], "a nested list is skipped, not flattened")
#expect(try labels("labels: [bug, '', ui]\n") == ["bug", "ui"], "blank members drop out")
#expect(try labels("title: No labels here\n") == [], "an absent key")
}
}
// MARK: - Header and footer
@Suite("Print ▸ the running head and foot")
struct PrintRunningHeadTests {
@Test("Each toggle contributes exactly its own end")
func toggles() {
var options = PrintOptions()
var header = PrintRunningHead.header(options: options, boardTitle: "Roadmap", dateText: "9 Aug 2026")
#expect(header == PrintRunningHead.Line(leading: "Roadmap", trailing: "9 Aug 2026"))
options.headerShowsBoardTitle = false
header = PrintRunningHead.header(options: options, boardTitle: "Roadmap", dateText: "9 Aug 2026")
#expect(header == PrintRunningHead.Line(leading: "", trailing: "9 Aug 2026"))
options.headerShowsPrintDate = false
header = PrintRunningHead.header(options: options, boardTitle: "Roadmap", dateText: "9 Aug 2026")
#expect(header.isEmpty, "an empty line takes no paper at all")
}
@Test("The folio trails, the custom line leads")
func footer() {
var options = PrintOptions()
options.footerShowsCustomLine = true
options.footerCustomLine = "Confidential"
#expect(PrintRunningHead.footer(options: options, pageText: "Page 2 of 7")
== PrintRunningHead.Line(leading: "Confidential", trailing: "Page 2 of 7"))
options.footerShowsPageNumbers = false
#expect(PrintRunningHead.footer(options: options, pageText: "Page 2 of 7")
== PrintRunningHead.Line(leading: "Confidential", trailing: ""))
}
@Test("A toggle left on over an emptied field prints nothing rather than an indent of air")
func blankCustomLine() {
var options = PrintOptions()
options.footerShowsCustomLine = true
options.footerCustomLine = " "
options.footerShowsPageNumbers = false
#expect(PrintRunningHead.footer(options: options, pageText: "Page 1 of 1").isEmpty)
}
@Test("The folio's wording")
func pageText() {
#expect(PrintRunningHead.pageText(page: 3, of: 7) == "Page 3 of 7")
}
}
// MARK: - The menu row
@Suite("Print ▸ menu validation")
struct PrintCommandValidationTests {
/// Two disjuncts and nothing else — a print is a read, so neither the read-only lock nor the
/// focused-editor rule closes the row (`PrintCommand`).
@Test("Scope alone enables the row, and a card window with no card does not")
func validation() {
#expect(PrintCommand.isEnabled(hasBoard: true, hasPrintableCard: false))
#expect(PrintCommand.isEnabled(hasBoard: false, hasPrintableCard: true))
#expect(PrintCommand.isEnabled(hasBoard: true, hasPrintableCard: true))
#expect(!PrintCommand.isEnabled(hasBoard: false, hasPrintableCard: false), "welcome, or nothing at all")
}
}
// MARK: - Rendering and pagination, smoke-tested
@Suite("Print ▸ rendering and pagination")
@MainActor
struct PrintRenderingTests {
private func session(_ source: PrintSource, options: PrintOptions) -> PrintSession {
let (store, _, _) = makeStore()
store.captureLastUsed(options)
return PrintSession(
provider: PrintSourceProvider(complete: source),
profiles: store,
cardFolder: nil,
jobTitle: source.boardTitle,
boardTitle: source.boardTitle
)
}
private static func longBody(paragraphs: Int) -> String {
(0 ..< paragraphs)
.map { "Paragraph \($0). " + String(repeating: "Words that fill a line of a printed page. ", count: 6) }
.joined(separator: "\n\n")
}
@Test("A page break really becomes a separately paginated section")
func breaksSplitSections() {
var options = PrintOptions()
options.pageBreaks = .betweenLanes
let source = board([
PrintLane(title: "One", cards: [card("A", body: "a")]),
PrintLane(title: "Two", cards: [card("B", body: "b")]),
PrintLane(title: "Three", cards: [card("C", body: "c")])
])
let blocks = PrintDocumentBuilder.blocks(from: source, options: options)
#expect(PrintDocumentRenderer.sections(for: blocks, options: options).count == 3)
// …and continuously, one section.
var flowing = options
flowing.pageBreaks = .flow
let flowingBlocks = PrintDocumentBuilder.blocks(from: source, options: flowing)
#expect(PrintDocumentRenderer.sections(for: flowingBlocks, options: flowing).count == 1)
}
@Test("An empty document renders no sections")
func emptyDocument() {
#expect(PrintDocumentRenderer.sections(for: [], options: PrintOptions()).isEmpty)
}
@Test("Between-lanes really costs a sheet per lane")
func pageCountPerLane() {
var options = PrintOptions()
options.pageBreaks = .betweenLanes
let source = board((1 ... 4).map { PrintLane(title: "Lane \($0)", cards: [card("Card \($0)", body: "words")]) })
let view = PrintDocumentView(session: session(source, options: options), printInfo: NSPrintInfo())
#expect(view.pageCount() == 4, "four short lanes, four sheets — the break is pagination, not spacing")
}
@Test("A long body paginates rather than clipping")
func longBodyPaginates() {
let options = PrintOptions()
let short = board([PrintLane(title: "One", cards: [card("A", body: "one line")])])
let long = board([PrintLane(title: "One", cards: [card("A", body: Self.longBody(paragraphs: 80))])])
#expect(PrintDocumentView(session: session(short, options: options), printInfo: NSPrintInfo()).pageCount() == 1)
let pages = PrintDocumentView(session: session(long, options: options), printInfo: NSPrintInfo()).pageCount()
#expect(pages > 1, "eighty paragraphs do not fit on one sheet")
}
@Test("A byline reads as a sentence in all four states")
func bylines() {
#expect(PrintDocumentRenderer.byline(author: "ada", created: nil) == "ada")
#expect(PrintDocumentRenderer.byline(author: nil, created: nil) == "Comment")
#expect(PrintDocumentRenderer.byline(author: " ", created: nil) == "Comment", "a blank author is no author")
let dated = PrintDocumentRenderer.byline(author: "ada", created: stamp(0))
#expect(dated.hasPrefix("ada — "))
#expect(PrintDocumentRenderer.byline(author: nil, created: stamp(0)) == String(dated.dropFirst("ada — ".count)))
}
@Test("The thread's heading counts, and says 'comment' once")
func commentsHeading() {
#expect(PrintDocumentRenderer.commentsHeadingText(count: 1) == "1 comment")
#expect(PrintDocumentRenderer.commentsHeadingText(count: 3) == "3 comments")
#expect(PrintDocumentRenderer.commentsHeadingText(count: 0) == "0 comments")
}
@Test("The chosen face reaches the text, and code keeps its own")
func faceRemap() {
// Courier is on every Mac; asserting a family that might not be installed would be asserting a
// fixture about the machine.
let family = "Times New Roman"
guard PrintTypography.families().contains(family) else { return }
var options = PrintOptions()
options.fontFamily = family
let blocks: [PrintBlock] = [.cardBody("Words, and `code`.")]
let section = try? #require(PrintDocumentRenderer.sections(for: blocks, options: options).first)
guard let section else { return }
var sawFace = false
var sawMono = false
section.enumerateAttribute(.font, in: NSRange(location: 0, length: section.length)) { value, _, _ in
guard let font = value as? NSFont else { return }
if font.fontDescriptor.symbolicTraits.contains(.monoSpace) {
sawMono = true
} else if font.familyName == family {
sawFace = true
}
}
#expect(sawFace, "the body is set in the chosen family")
#expect(sawMono, "inline code stays monospaced — a fenced block in Palatino is nobody's intent")
}
@Test("A summary line describes what the panel is about to print")
func summary() {
var options = PrintOptions()
#expect(PrintOptionsSummary.includes(options) == "Title, labels, body")
#expect(PrintOptionsSummary.pageBreaks(options) == "Continuous")
#expect(PrintOptionsSummary.type(options) == "System 11 pt")
options.includesComments = true
options.commentSort = .newestFirst
options.fontFamily = "Palatino"
options.fontSize = 13.5
options.pageBreaks = .betweenCards
#expect(PrintOptionsSummary.includes(options) == "Title, labels, body, comments (newest first)")
#expect(PrintOptionsSummary.pageBreaks(options) == "Between cards")
#expect(PrintOptionsSummary.type(options) == "Palatino 13.5 pt")
options.includesTitle = false
options.includesLabels = false
options.includesBody = false
options.includesComments = false
#expect(PrintOptionsSummary.includes(options) == "Nothing")
}
}
// MARK: - The session
@Suite("Print ▸ the panel's session")
@MainActor
struct PrintSessionTests {
private func session(profiles: PrintProfileStore) -> PrintSession {
PrintSession(
provider: PrintSourceProvider(complete: board([PrintLane(title: "One", cards: [card("A", body: "a")])])),
profiles: profiles,
cardFolder: nil,
jobTitle: "Roadmap",
boardTitle: "Roadmap"
)
}
@Test("A session opens on Last Used, which is the defaults on a first print")
func opensOnLastUsed() {
let (store, _, teardown) = makeStore()
defer { teardown() }
#expect(session(profiles: store).options == PrintOptions())
store.captureLastUsed(exoticOptions())
let second = session(profiles: store)
#expect(second.selectedProfileName == PrintProfile.lastUsedName)
#expect(second.options == exoticOptions())
}
@Test("Choosing a profile copies its options in; editing them afterwards does not write back")
func selectingIsACopy() {
let (store, _, teardown) = makeStore()
defer { teardown() }
store.save(exoticOptions(), as: "Archive")
let session = self.session(profiles: store)
session.selectProfile(named: "Archive")
#expect(session.options == exoticOptions())
#expect(!session.isModified)
session.options.fontSize = 9
#expect(session.isModified, "the popup says modified")
#expect(store.options(named: "Archive")?.fontSize == exoticOptions().fontSize, "the profile is untouched")
#expect(session.saveProfile(named: "Archive"))
#expect(store.options(named: "Archive")?.fontSize == 9)
#expect(!session.isModified)
}
@Test("The reserved row is never 'modified' — drifting from it is what it is for")
func reservedRowIsNeverModified() {
let (store, _, teardown) = makeStore()
defer { teardown() }
let session = self.session(profiles: store)
session.options.fontSize = 30
#expect(!session.isModified)
#expect(!session.canManageSelection, "the reserved row cannot be renamed or deleted")
}
@Test("Deleting a profile keeps the settings in front of the user")
func deleteKeepsOptions() {
let (store, _, teardown) = makeStore()
defer { teardown() }
store.save(exoticOptions(), as: "Archive")
let session = self.session(profiles: store)
session.selectProfile(named: "Archive")
session.deleteSelectedProfile()
#expect(store.catalog.names.isEmpty)
#expect(session.selectedProfileName == PrintProfile.lastUsedName)
#expect(session.options == exoticOptions(), "a management gesture is not a reset")
}
@Test("Selecting a name that resolves to nothing changes nothing")
func selectingAGhost() {
let (store, _, teardown) = makeStore()
defer { teardown() }
let session = self.session(profiles: store)
session.options.fontSize = 20
session.selectProfile(named: "Deleted elsewhere")
#expect(session.selectedProfileName == PrintProfile.lastUsedName)
#expect(session.options.fontSize == 20)
}
@Test("The comment-bearing source is read only when the options ask for it")
func commentsAreReadLazily() {
let (store, _, teardown) = makeStore()
defer { teardown() }
var reads = 0
let withComments = board([PrintLane(title: "One", cards: [
card("A", body: "a", comments: [PrintComment(author: "ada", created: stamp(0), body: "hi")])
])])
let provider = PrintSourceProvider(
withoutComments: board([PrintLane(title: "One", cards: [card("A", body: "a")])]),
withComments: {
reads += 1
return withComments
}
)
let session = PrintSession(
provider: provider,
profiles: store,
cardFolder: nil,
jobTitle: "Roadmap",
boardTitle: "Roadmap"
)
_ = session.blocks()
_ = session.blocks()
#expect(reads == 0, "comments are off by default, so no thread is read")
session.options.includesComments = true
_ = session.blocks()
_ = session.blocks()
#expect(reads == 1, "and once the user asks, exactly one read serves every relayout")
}
}