Files
lanework/KanbanTests/PrintTests.swift
T
rzen 05bbf78926 Printed symbols become real glyphs — template images resolved before they meet the PDF context
The owner's 2026-08-08 report ("SF symbols don't render well in the PDF output
of File ▸ Print…") photographed solid dark rectangles where the card icons
belong. The cause is not typography and not the renderer's layout: an
`NSImage(systemSymbolName:)` is a *template* image, a shape meant to be tinted
by the AppKit machinery that draws it. A print/PDF context has none of that
machinery, so the tint lands on the image's whole box instead of through its
coverage — a filled rectangle, measured at 1.000 ink coverage through a real
`NSPrintOperation`.

A second failure hid behind the first: a PDF context is a 1× device, so even a
non-template symbol rasterized at 72 ppi on the way onto the page (13 × 12
pixels for an 11 pt icon) and blurred at any zoom.

Both are the same mistake — leaving work for a context that cannot do it — so
`PrintSymbol` does the work first: the symbol is inked in the line's own colour
(resolved against the paper appearance, since a dynamic colour resolves at draw
time and this drawing happens long before the page exists), drawn into a bitmap
at eight times the point box, and handed over as ordinary non-template artwork.
The page now carries a 576 ppi glyph at 0.277 coverage. True vector was
measured and is not available: `NSSymbolImageRep` rasterizes into whatever
context draws it, the symbols are not reachable as font glyphs by name, and
re-wrapping the image in a PDF representation only embeds the same raster one
level down.

While in there, the attachment's baseline stops being a guess. It was
`font.descender * 0.5` — a constant that knew nothing about which symbol it was
placing, so every icon floated by a different amount. It is now the symbol's own
`alignmentRect`, which is Apple's metric for exactly this: the rect's height is
the font's cap height and its origin is the symbol's baseline within its box.

The forced light appearance moves to `PrintTypography.paper` because two places
now depend on it and must not drift: the page view pins it, and the symbol
raster draws under it.

Lane headings were checked and need nothing — `PrintLane` carries no icon, so
card meta lines are the only symbols a printed document has.

Tests drive the real pipeline: `PrintDocumentBuilder` → `PrintDocumentRenderer`
→ a real `NSPrintOperation` to PDF, then measure the ink on a sheet whose only
content is one icon. The coverage assertion fails at 1.000 on the shipped build.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-09 01:52:26 -04:00

1442 lines
62 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 {
/// **The row never disables now** (revised 2026-08-09) — a print is a read, so neither the
/// read-only lock nor the focused-editor rule ever closed it, and the old third state
/// ("nothing published, so grey out") turned out to leave the ⌘P chord live anyway, falling
/// through to AppKit's own stock print handling (`PrintCommand`'s doc comment tells the whole
/// story). `resolveScope` is what the row switches on instead: three answers, no `disabled` left
/// to fall through.
@Test("The board wins over a card, and neither published is a polite refusal — not a dead key")
func resolution() {
#expect(PrintCommand.resolveScope(hasBoard: true, hasPrintableCard: false) == .board)
#expect(PrintCommand.resolveScope(hasBoard: false, hasPrintableCard: true) == .card)
#expect(PrintCommand.resolveScope(hasBoard: true, hasPrintableCard: true) == .board, "the board in front wins")
#expect(
PrintCommand.resolveScope(hasBoard: false, hasPrintableCard: false) == .refuse,
"welcome, or nothing at all — answered with a sentence, never silence"
)
}
}
// MARK: - Finder's half: the `printFiles` Apple Event
/// `PrintCoordinator.resolveFinderPrint(atPath:)` — the one part of the Finder-print path a test can
/// call without handing AppKit a real print job (`resolveFinderPrint`'s own doc comment). Everything
/// past this point (`printFiles`, `printHeadlessBoard`) drives a real `NSPrintOperation`, the same
/// AppKit boundary `PrintCoordinator.run`/`refuse` already sit past untested — this suite pins the
/// board-or-refuse *decision*, not what AppKit does with it.
@Suite("Print ▸ Finder printFiles resolution")
@MainActor
struct PrintFinderResolutionTests {
@Test("A board's path resolves to its snapshot, extension-less exactly like every other board open")
func resolvesABoard() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.board(title: "Roadmap")
try fixture.lane(Ident.lane1, order: "1024", title: "Doing")
try fixture.card(Ident.card1, in: Ident.lane1, order: "1024", title: "A card")
// `fixture.root` itself carries no `.kanban` extension — the "both open" half of
// `BoardModel`'s own doc comment, exercised by construction rather than by a second fixture.
guard case let .board(model) = PrintCoordinator.resolveFinderPrint(atPath: fixture.root.path) else {
Issue.record("expected the path to resolve as a board")
return
}
#expect(model.title.value == "Roadmap")
#expect(model.lanes.map(\.title.value) == ["Doing"])
#expect(model.lanes.first?.cards.map(\.title.value) == ["A card"])
}
@Test("A folder with no root index.md refuses rather than throwing")
func refusesANonBoard() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
// No `board()` call — an ordinary empty folder, the shape of "not a board at all".
guard case .refuse = PrintCoordinator.resolveFinderPrint(atPath: fixture.root.path) else {
Issue.record("expected the path to refuse")
return
}
}
@Test("A plain file refuses rather than throwing")
func refusesAFile() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let fileURL = fixture.root.appendingPathComponent("not-a-board.txt")
try Data("hello".utf8).write(to: fileURL)
guard case .refuse = PrintCoordinator.resolveFinderPrint(atPath: fileURL.path) else {
Issue.record("expected the path to refuse")
return
}
}
@Test("A board whose root schema is newer than this build refuses, the same fail-fast every open gives")
func refusesAnUnsupportedSchema() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", "---\nschema: 999\ntitle: Future\n---\n")
guard case .refuse = PrintCoordinator.resolveFinderPrint(atPath: fixture.root.path) else {
Issue.record("expected the path to refuse")
return
}
}
}
// 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")
}
}
// MARK: - Symbols on paper
/// **The printed-symbol regression** (owner report, 2026-08-08: "SF symbols don't render well in the PDF
/// output of File ▸ Print…", with a screenshot of solid dark rectangles where the card icons belong).
///
/// The typography around it is still legitimately untested — see this file's own note — but this failure is
/// not typography. It is a drawing fact with two halves, and both are assertable:
///
/// - an `NSImage(systemSymbolName:)` is a **template**, and a print/PDF context tints one across its whole
/// box rather than through its coverage, which is the black rectangle the owner photographed;
/// - a PDF context is a 1× device, so even a non-template symbol image lands as a 13-pixel bitmap and
/// blurs at any zoom.
///
/// So the suite asserts what `PrintSymbol` hands over (concrete artwork, at paper resolution, on the
/// symbol's own baseline) and then prints a page whose only ink is one icon and measures it: a glyph
/// covers a fraction of its own bounding box, a template box covers all of it. The second test would have
/// failed on the shipped build at ~1.0 coverage, which is the whole point of writing it that way.
@Suite("Print ▸ symbols on paper")
@MainActor
struct PrintSymbolTests {
/// A symbol every macOS has had for years, and one whose artwork is plainly not a rectangle.
private static let symbol = "lightbulb"
// MARK: The image handed to the page
@Test("A printed symbol is concrete artwork, not a template to be tinted by machinery that is not there")
func artworkNotTemplate() throws {
let rendered = try #require(PrintSymbol.rendered(Self.symbol, pointSize: 11, ink: PrintTypography.secondaryInk))
#expect(!rendered.image.isTemplate, "a template image is what drew the black boxes")
let rep = try #require(rendered.image.representations.first as? NSBitmapImageRep)
#expect(rep.size == rendered.image.size, "the extra pixels are resolution, not a bigger picture")
#expect(
CGFloat(rep.pixelsWide) >= rep.size.width * 4,
"a page wants far more than the 72 ppi a PDF context would rasterize at"
)
}
@Test("The artwork is a glyph — most of its box is paper")
func artworkIsNotFilled() throws {
let rendered = try #require(PrintSymbol.rendered(Self.symbol, pointSize: 11, ink: PrintTypography.secondaryInk))
let rep = try #require(rendered.image.representations.first as? NSBitmapImageRep)
let bytes = try #require(rep.bitmapData)
var inked = 0
for y in 0 ..< rep.pixelsHigh {
for x in 0 ..< rep.pixelsWide {
let alpha = bytes[y * rep.bytesPerRow + x * rep.samplesPerPixel + 3]
if alpha > 12 { inked += 1 }
}
}
let coverage = Double(inked) / Double(rep.pixelsWide * rep.pixelsHigh)
#expect(coverage > 0.02, "a symbol that drew nothing at all is the other way to fail")
#expect(coverage < 0.75, "a filled box is the template bug; a lightbulb is an outline")
}
@Test("An unknown name draws nothing rather than a box — `ItemSymbol`'s promise, kept on paper")
func unknownNameIsOmitted() {
#expect(PrintSymbol.rendered("not.a.symbol.anybody.ships", pointSize: 11, ink: .black) == nil)
}
// MARK: The baseline
@Test("The offset is the symbol's own baseline, per symbol and scaling with the size")
func baselineComesFromTheSymbol() throws {
let ink = PrintTypography.secondaryInk
let bulb = try #require(PrintSymbol.rendered(Self.symbol, pointSize: 11, ink: ink))
let text = try #require(PrintSymbol.rendered("textformat", pointSize: 11, ink: ink))
let larger = try #require(PrintSymbol.rendered(Self.symbol, pointSize: 22, ink: ink))
// Below the baseline, always — the old constant was `font.descender * 0.5`, which knew nothing
// about which symbol it was placing.
#expect(bulb.baselineOffset < 0)
#expect(
bulb.baselineOffset < text.baselineOffset,
"a lightbulb's base sits under the baseline; `textformat` sits on it"
)
#expect(larger.baselineOffset < bulb.baselineOffset, "and the offset is a length, so it scales")
let source = try #require(NSImage(systemSymbolName: Self.symbol, accessibilityDescription: nil)?
.withSymbolConfiguration(NSImage.SymbolConfiguration(pointSize: 11, weight: .regular)))
#expect(bulb.baselineOffset == -source.alignmentRect.origin.y, "which is Apple's own metric, not a guess")
}
// MARK: The renderer's line
@Test("The icon reaches the page as an attachment carrying that artwork")
func theLineCarriesTheArtwork() throws {
let blocks: [PrintBlock] = [.cardMeta(icon: Self.symbol, labels: ["idea"])]
let section = try #require(PrintDocumentRenderer.sections(for: blocks, options: PrintOptions()).first)
var found: NSImage?
section.enumerateAttribute(.attachment, in: NSRange(location: 0, length: section.length)) { value, _, _ in
if let attachment = value as? NSTextAttachment { found = attachment.image }
}
let image = try #require(found, "the icon is a text attachment on the labels line")
#expect(!image.isTemplate)
#expect(section.string.contains("idea"), "and the labels are still on the line beside it")
}
@Test("A symbol this system cannot draw still prints its labels")
func aMissingSymbolKeepsTheLabels() throws {
let blocks: [PrintBlock] = [.cardMeta(icon: "not.a.symbol.anybody.ships", labels: ["idea"])]
let section = try #require(PrintDocumentRenderer.sections(for: blocks, options: PrintOptions()).first)
#expect(section.string.contains("idea"))
}
// MARK: What actually lands on paper
/// A page whose only ink is one icon: a card print, every component but the labels line switched off,
/// a card with an icon and no labels, and no running head or foot. Whatever is dark on that sheet is
/// the symbol and nothing else, so it can be measured rather than eyeballed.
private func iconOnlyOptions() -> PrintOptions {
var options = PrintOptions()
options.includesTitle = false
options.includesBody = false
options.includesComments = false
options.includesLabels = true
options.headerShowsBoardTitle = false
options.headerShowsPrintDate = false
options.footerShowsPageNumbers = false
options.footerShowsCustomLine = false
return options
}
private func iconOnlyPDF(icon: String) -> Data {
let (store, _, teardown) = makeStore()
defer { teardown() }
let options = iconOnlyOptions()
store.captureLastUsed(options)
let source = PrintSource(
scope: .card,
boardTitle: "Roadmap",
lanes: [PrintLane(title: "One", cards: [PrintCard(title: "A", icon: icon)])]
)
let session = PrintSession(
provider: PrintSourceProvider(complete: source),
profiles: store,
cardFolder: nil,
jobTitle: "A",
boardTitle: "Roadmap"
)
let info = NSPrintInfo()
let view = PrintDocumentView(session: session, printInfo: info)
_ = view.pageCount()
let data = NSMutableData()
let operation = NSPrintOperation.pdfOperation(with: view, inside: view.bounds, to: data, printInfo: info)
operation.showsPrintPanel = false
operation.showsProgressPanel = false
operation.run()
return data as Data
}
/// The document's first page, rasterized onto white at `scale` times its point size — what a reader
/// with a magnifying glass would see.
private func firstPage(of pdf: Data, scale: CGFloat) -> NSBitmapImageRep? {
guard let page = NSPDFImageRep(data: pdf) else { return nil }
page.currentPage = 0
let box = page.size
guard let rep = NSBitmapImageRep(
bitmapDataPlanes: nil,
pixelsWide: Int(box.width * scale), pixelsHigh: Int(box.height * scale),
bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0
) else { return nil }
rep.size = box
NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: rep)
NSColor.white.setFill()
CGRect(origin: .zero, size: box).fill()
page.draw(in: CGRect(origin: .zero, size: box))
NSGraphicsContext.restoreGraphicsState()
return rep
}
/// The bounding box of the ink on a page, and how much of that box the ink fills — in points, and as a
/// fraction. A glyph fills a fraction of its box; the template bug filled all of it.
private func ink(on rep: NSBitmapImageRep, scale: CGFloat) -> (box: CGRect, coverage: Double)? {
guard let bytes = rep.bitmapData else { return nil }
var minX = rep.pixelsWide, maxX = -1, minY = rep.pixelsHigh, maxY = -1
var inked = 0
for y in 0 ..< rep.pixelsHigh {
let row = y * rep.bytesPerRow
for x in 0 ..< rep.pixelsWide where bytes[row + x * rep.samplesPerPixel] < 220 {
inked += 1
minX = min(minX, x); maxX = max(maxX, x)
minY = min(minY, y); maxY = max(maxY, y)
}
}
guard maxX >= minX, maxY >= minY else { return nil }
let width = maxX - minX + 1
let height = maxY - minY + 1
return (
CGRect(x: CGFloat(minX) / scale, y: CGFloat(minY) / scale,
width: CGFloat(width) / scale, height: CGFloat(height) / scale),
Double(inked) / Double(width * height)
)
}
@Test("A printed page draws the symbol as a glyph, at the size the type scale asked for")
func thePageDrawsAGlyph() throws {
let scale: CGFloat = 4
let pdf = iconOnlyPDF(icon: Self.symbol)
#expect(!pdf.isEmpty, "the operation produced a document")
let page = try #require(firstPage(of: pdf, scale: scale))
let measured = try #require(ink(on: page, scale: scale), "the icon is the only ink on the sheet")
// The line's own size, which is what the icon is built at.
let expected = try #require(PrintSymbol.rendered(
Self.symbol,
pointSize: PrintTypography.secondary(iconOnlyOptions()).pointSize,
ink: PrintTypography.secondaryInk
)).image.size
#expect(
measured.coverage < 0.75,
"coverage \(measured.coverage) — a filled box is the template bug the owner photographed"
)
#expect(measured.coverage > 0.05, "and something was drawn")
// The ink sits *inside* the symbol's box and fills most of it: a lightbulb is narrower than the
// box it is drawn in, but an icon at the wrong size — the whole page, or a stray point size —
// would miss either bound by a mile.
#expect(
measured.box.width <= expected.width + 1 && measured.box.height <= expected.height + 1,
"ink measured \(measured.box.size) against a symbol of \(expected)"
)
#expect(
measured.box.width >= expected.width * 0.5 && measured.box.height >= expected.height * 0.5,
"ink measured \(measured.box.size) against a symbol of \(expected)"
)
}
}