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