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
1442 lines
62 KiB
Swift
1442 lines
62 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 {
|
||
|
||
/// **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)"
|
||
)
|
||
}
|
||
}
|