Root cause of the owner's repro (board window frontmost, File ▸ Print… enabled, chosen from the menu, alert appears anyway): Kanban.entitlements carried no com.apple.security.print key. The app is sandboxed, and a sandboxed NSPrintOperation is denied by the sandbox with exactly this wording — "This application does not support printing. Please contact the application's developer." — regardless of which code path invokes it. Fix: add the entitlement. Alongside it, hardening for a separate, narrower failure mode that happens to produce the identical alert text by a different mechanism: PrintCommand used to disable itself over a window that published neither a board nor a printable card (welcome, the template chooser, Settings, the restore-bootstrap window, a card window whose board hasn't joined). A disabled SwiftUI Button still owns its .keyboardShortcut, so the unclaimed ⌘P chord fell through to AppKit's own nil-target printDocument: action, whose stock failure is the same system alert. The row now claims ⌘P unconditionally in every window; scope resolves at the moment of the action instead (board, then card, then a polite "Nothing to Print" / "Open a board or a card to print it." refusal in the app's own voice). The boolean isEnabled(hasBoard: hasPrintableCard:) becomes a three-way PrintCommand.resolveScope(...) -> Scope pure function. Also implements AppDelegate's application(_:printFiles:withSettings: showPrintPanels:) — Finder's own File ▸ Print… / drag-to-printer / print-and-open path was previously unhandled, its own separate route to the same stock alert. PrintCoordinator.printFiles loads each path headless through BoardLoader (no store, no window) and either prints it or gives the same one-sentence refusal; the operation-building code shared with the in-app path is factored out of run(_:) into makeOperation(for:showsPrintPanel:) and runOperation(_:session:). Docs: 11-command-nexus.md's Print row, PrintCommand's and PrintCoordinator's doc comments, KanbanApp.swift's CommandGroup comment, and project.yml's entitlements comment all narrate the entitlement as the actual fix and the scope work as hardening beside it. Tests: PrintCommandValidationTests now exercises resolveScope's three arms in place of the old boolean. A new PrintFinderResolutionTests suite covers PrintCoordinator.resolveFinderPrint(atPath:) — the one piece of the Finder half a test can drive without handing AppKit a real print job — against a real board, an empty non-board folder, a plain file, and an unsupported schema. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
1202 lines
51 KiB
Swift
1202 lines
51 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")
|
|
}
|
|
}
|