Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a2b948447 | ||
|
|
e385c74298 | ||
|
|
5d150f2aa9 |
@@ -91,7 +91,18 @@ public enum CupsParsers {
|
|||||||
index = output.index(after: index)
|
index = output.index(after: index)
|
||||||
}
|
}
|
||||||
let key = String(output[tokenStart..<index])
|
let key = String(output[tokenStart..<index])
|
||||||
guard !key.isEmpty else { break }
|
// A token starting with `=` has no key — skip it (and its
|
||||||
|
// value) rather than truncating the whole parse.
|
||||||
|
guard !key.isEmpty else {
|
||||||
|
if index < output.endIndex && output[index] == "=" {
|
||||||
|
index = output.index(after: index)
|
||||||
|
while index < output.endIndex
|
||||||
|
&& !output[index].isWhitespace {
|
||||||
|
index = output.index(after: index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
if index < output.endIndex && output[index] == "=" {
|
if index < output.endIndex && output[index] == "=" {
|
||||||
index = output.index(after: index)
|
index = output.index(after: index)
|
||||||
if index < output.endIndex && output[index] == "'" {
|
if index < output.endIndex && output[index] == "'" {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import Foundation
|
|||||||
public enum CupsError: LocalizedError, Equatable {
|
public enum CupsError: LocalizedError, Equatable {
|
||||||
case toolFailed(tool: String, code: Int32, stderr: String)
|
case toolFailed(tool: String, code: Int32, stderr: String)
|
||||||
case tiffMissing(String)
|
case tiffMissing(String)
|
||||||
|
case noPrinterSelected
|
||||||
|
|
||||||
public var errorDescription: String? {
|
public var errorDescription: String? {
|
||||||
switch self {
|
switch self {
|
||||||
@@ -14,6 +15,8 @@ public enum CupsError: LocalizedError, Equatable {
|
|||||||
: "\(tool) failed (\(code)): \(detail)"
|
: "\(tool) failed (\(code)): \(detail)"
|
||||||
case .tiffMissing(let path):
|
case .tiffMissing(let path):
|
||||||
return "Target TIFF does not exist: \(path)"
|
return "Target TIFF does not exist: \(path)"
|
||||||
|
case .noPrinterSelected:
|
||||||
|
return "No printer selected."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,8 +48,9 @@ struct PrintPanelService {
|
|||||||
return UITestHooks.printPanelResult(forQueue: queue)
|
return UITestHooks.printPanelResult(forQueue: queue)
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
let display = displayName
|
// `??` rhs is a non-async @autoclosure — fetch first.
|
||||||
?? (try? await cupsService.displayName(for: queue))
|
let fetched = try? await cupsService.displayName(for: queue)
|
||||||
|
let display = displayName ?? fetched
|
||||||
// Layer ④ needs the queue's option keys (lpoptions -l) to pick
|
// Layer ④ needs the queue's option keys (lpoptions -l) to pick
|
||||||
// the driver colour-bypass before the panel opens.
|
// the driver colour-bypass before the panel opens.
|
||||||
let optionKeys = (try? await cupsService.optionKeys(for: queue))
|
let optionKeys = (try? await cupsService.optionKeys(for: queue))
|
||||||
@@ -81,7 +82,7 @@ struct PrintPanelService {
|
|||||||
|
|
||||||
let status = PMSessionSetCurrentPMPrinter(session, printer)
|
let status = PMSessionSetCurrentPMPrinter(session, printer)
|
||||||
if status != 0 {
|
if status != 0 {
|
||||||
PMRelease(pmObject(printer))
|
PMRelease(Self.pmObject(printer))
|
||||||
throw PrintPanelError.sessionBindingFailed(status)
|
throw PrintPanelError.sessionBindingFailed(status)
|
||||||
}
|
}
|
||||||
// Warn-only: defaults keep the panel consistent with the
|
// Warn-only: defaults keep the panel consistent with the
|
||||||
@@ -102,7 +103,7 @@ struct PrintPanelService {
|
|||||||
}
|
}
|
||||||
defer {
|
defer {
|
||||||
if let printer = pmPrinter {
|
if let printer = pmPrinter {
|
||||||
PMRelease(pmObject(printer))
|
PMRelease(Self.pmObject(printer))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,7 +129,7 @@ struct PrintPanelService {
|
|||||||
.showsOrientation, .showsScaling, .showsPrintSelection,
|
.showsOrientation, .showsScaling, .showsPrintSelection,
|
||||||
.showsPageSetupAccessory, .showsPreview,
|
.showsPageSetupAccessory, .showsPreview,
|
||||||
]
|
]
|
||||||
panel.defaultButtonTitle = "Use Settings"
|
panel.setDefaultButtonTitle("Use Settings")
|
||||||
|
|
||||||
let response = panel.runModal(with: printInfo)
|
let response = panel.runModal(with: printInfo)
|
||||||
guard response == NSApplication.ModalResponse.OK.rawValue else {
|
guard response == NSApplication.ModalResponse.OK.rawValue else {
|
||||||
|
|||||||
+112
-16
@@ -202,7 +202,7 @@ struct Stage2View: View {
|
|||||||
spacing: 12
|
spacing: 12
|
||||||
) {
|
) {
|
||||||
ForEach(result.pages) { page in
|
ForEach(result.pages) { page in
|
||||||
GalleryPageView(page: page)
|
GalleryPageView(page: page, workflow: workflow)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.accessibilityElement(children: .contain)
|
.accessibilityElement(children: .contain)
|
||||||
@@ -213,24 +213,111 @@ struct Stage2View: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Raw print panel (#rawPrintPanel) — stubbed until M3
|
// MARK: - Raw print panel (#rawPrintPanel) — unmanaged lp path
|
||||||
|
|
||||||
private var printPanel: some View {
|
private var printPanel: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
Text("Print").font(.headline).foregroundStyle(Theme.text)
|
Text("Print").font(.headline).foregroundStyle(Theme.text)
|
||||||
Text("Unmanaged printing (lp) lands in Milestone 3.")
|
if let notice = workflow.printNotice {
|
||||||
.font(.caption).foregroundStyle(.secondary)
|
Image(systemName: workflow.printNoticeIsError
|
||||||
|
? "xmark.circle.fill" : "info.circle.fill")
|
||||||
|
.foregroundStyle(workflow.printNoticeIsError
|
||||||
|
? .red : .blue)
|
||||||
|
.accessibilityIdentifier("printNotificationIcon")
|
||||||
|
Text(notice)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(workflow.printNoticeIsError
|
||||||
|
? .red : .secondary)
|
||||||
|
.accessibilityIdentifier("printNotificationText")
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.accessibilityElement(children: .contain)
|
||||||
.accessibilityIdentifier("printNotification")
|
.accessibilityIdentifier("printNotification")
|
||||||
HStack(spacing: 8) {
|
|
||||||
Button("Print All") {}
|
// Printer row: select + status + refresh + Preferences.
|
||||||
.accessibilityIdentifier("btnPrintAll")
|
HStack(spacing: 10) {
|
||||||
.disabled(true)
|
Picker("Printer", selection: $workflow.selectedPrinter) {
|
||||||
Button("Refresh Printers") {}
|
ForEach(workflow.printers, id: \.name) { printer in
|
||||||
|
Text(printer.displayName ?? printer.name)
|
||||||
|
.tag(printer.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: 320)
|
||||||
|
.accessibilityIdentifier("printerSelect")
|
||||||
|
.onChange(of: workflow.selectedPrinter) { _, _ in
|
||||||
|
workflow.selectedTray = nil
|
||||||
|
workflow.selectedMediaType = nil
|
||||||
|
Task { @MainActor in await workflow.reloadSelectedCapabilities() }
|
||||||
|
}
|
||||||
|
if let selected = workflow.printers
|
||||||
|
.first(where: { $0.name == workflow.selectedPrinter }) {
|
||||||
|
Text(selected.status.rawValue)
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
.padding(.horizontal, 8).padding(.vertical, 3)
|
||||||
|
.background(Theme.background)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
.accessibilityIdentifier("printerStatusBadge")
|
||||||
|
}
|
||||||
|
Button(action: workflow.refreshPrinters) {
|
||||||
|
Image(systemName: "arrow.clockwise")
|
||||||
|
}
|
||||||
|
.help("Refresh printer list")
|
||||||
.accessibilityIdentifier("btnRefreshPrinters")
|
.accessibilityIdentifier("btnRefreshPrinters")
|
||||||
.disabled(true)
|
Button(action: workflow.openPrinterPreferences) {
|
||||||
Button("Printer Properties") {}
|
Image(systemName: "gearshape")
|
||||||
|
}
|
||||||
|
.help("Printer properties — bound NSPrintPanel")
|
||||||
|
.disabled(workflow.selectedPrinter.isEmpty)
|
||||||
.accessibilityIdentifier("btnPrinterProperties")
|
.accessibilityIdentifier("btnPrinterProperties")
|
||||||
.disabled(true)
|
}
|
||||||
|
|
||||||
|
// Tray / media / orientation — from queue capabilities.
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
if !workflow.printerCaps.trays.isEmpty {
|
||||||
|
Picker("Tray", selection: $workflow.selectedTray) {
|
||||||
|
ForEach(workflow.printerCaps.trays, id: \.id) {
|
||||||
|
Text($0.name).tag(Optional($0.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: 200)
|
||||||
|
.accessibilityIdentifier("printerTraySelect")
|
||||||
|
}
|
||||||
|
if !workflow.printerCaps.mediaTypes.isEmpty {
|
||||||
|
Picker("Media", selection: $workflow.selectedMediaType) {
|
||||||
|
ForEach(workflow.printerCaps.mediaTypes, id: \.id) {
|
||||||
|
Text($0.name).tag(Optional($0.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: 240)
|
||||||
|
.accessibilityElement(children: .contain)
|
||||||
|
.accessibilityIdentifier("mediaTypeGroup")
|
||||||
|
.accessibilityIdentifier("printerMediaTypeSelect")
|
||||||
|
}
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
Button("Portrait") { workflow.printOrientation = "portrait" }
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.tint(workflow.printOrientation == "portrait" ? .accentColor : .gray)
|
||||||
|
.accessibilityIdentifier("btnOrientPortrait")
|
||||||
|
Button("Landscape") { workflow.printOrientation = "landscape" }
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.tint(workflow.printOrientation == "landscape" ? .accentColor : .gray)
|
||||||
|
.accessibilityIdentifier("btnOrientLandscape")
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Button(action: workflow.printAllPages) {
|
||||||
|
Label(workflow.isPrinting ? "Printing…" : "Print All",
|
||||||
|
systemImage: "printer")
|
||||||
|
}
|
||||||
|
.controlSize(.large)
|
||||||
|
.disabled(workflow.isPrinting
|
||||||
|
|| workflow.printtargResult == nil
|
||||||
|
|| workflow.selectedPrinter.isEmpty)
|
||||||
|
.accessibilityIdentifier("btnPrintAll")
|
||||||
Spacer()
|
Spacer()
|
||||||
Button("Advance to Stage 3") { workflow.advanceToStage3() }
|
Button("Advance to Stage 3") { workflow.advanceToStage3() }
|
||||||
.accessibilityIdentifier("btnAdvanceToStage3")
|
.accessibilityIdentifier("btnAdvanceToStage3")
|
||||||
@@ -243,12 +330,20 @@ struct Stage2View: View {
|
|||||||
.clipShape(RoundedRectangle(cornerRadius: Theme.Metrics.cornerMedium))
|
.clipShape(RoundedRectangle(cornerRadius: Theme.Metrics.cornerMedium))
|
||||||
.accessibilityElement(children: .contain)
|
.accessibilityElement(children: .contain)
|
||||||
.accessibilityIdentifier("rawPrintPanel")
|
.accessibilityIdentifier("rawPrintPanel")
|
||||||
|
.task(id: workflow.printtargResult?.pages.count) {
|
||||||
|
// Auto-enumerate once a manifest exists and whenever it
|
||||||
|
// changes (e.g. resume from .ti2).
|
||||||
|
if workflow.printers.isEmpty, workflow.printtargResult != nil {
|
||||||
|
workflow.refreshPrinters()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One gallery cell: PNG preview + per-page stubbed Print button.
|
/// One gallery cell: PNG preview + per-page Print button.
|
||||||
private struct GalleryPageView: View {
|
private struct GalleryPageView: View {
|
||||||
let page: GalleryPage
|
let page: GalleryPage
|
||||||
|
let workflow: TargetWorkflowViewModel
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 6) {
|
VStack(spacing: 6) {
|
||||||
@@ -269,8 +364,9 @@ private struct GalleryPageView: View {
|
|||||||
Text("\(page.page.patches) patches · " +
|
Text("\(page.page.patches) patches · " +
|
||||||
"\(Int(page.page.widthMm))×\(Int(page.page.heightMm)) mm")
|
"\(Int(page.page.widthMm))×\(Int(page.page.heightMm)) mm")
|
||||||
.font(.caption2).foregroundStyle(.secondary)
|
.font(.caption2).foregroundStyle(.secondary)
|
||||||
Button("Print") {}
|
Button("Print") { workflow.printPage(page) }
|
||||||
.disabled(true)
|
.disabled(workflow.isPrinting
|
||||||
|
|| workflow.selectedPrinter.isEmpty)
|
||||||
.accessibilityIdentifier("btnPrintPage-\(page.index)")
|
.accessibilityIdentifier("btnPrintPage-\(page.index)")
|
||||||
}
|
}
|
||||||
.padding(8)
|
.padding(8)
|
||||||
|
|||||||
@@ -89,6 +89,27 @@ final class TargetWorkflowViewModel {
|
|||||||
/// Stage 3 (`#stage3LoadedTargetBanner` data).
|
/// Stage 3 (`#stage3LoadedTargetBanner` data).
|
||||||
var resumedFromTi2 = false
|
var resumedFromTi2 = false
|
||||||
|
|
||||||
|
// MARK: - Print panel (issue 17)
|
||||||
|
|
||||||
|
/// CUPS destinations from `lpstat` (#printerSelect).
|
||||||
|
var printers: [Printer] = []
|
||||||
|
/// Selected queue name.
|
||||||
|
var selectedPrinter = ""
|
||||||
|
/// Capabilities of the selected queue (#printerTraySelect /
|
||||||
|
/// #printerMediaTypeSelect / PageSize source).
|
||||||
|
var printerCaps = PrinterCapabilities()
|
||||||
|
var selectedTray: Int?
|
||||||
|
var selectedMediaType: String?
|
||||||
|
/// "portrait" | "landscape" (#btnOrientPortrait/#btnOrientLandscape).
|
||||||
|
var printOrientation = "portrait"
|
||||||
|
/// Per-queue captured `key=value` strings from Preferences — replayed
|
||||||
|
/// on `lp` (session-only, docs/11 §capturedCupsOptions).
|
||||||
|
var capturedCupsOptions: [String: String] = [:]
|
||||||
|
/// In-panel notice (#printNotification) — cancel → info, not error.
|
||||||
|
var printNotice: String?
|
||||||
|
var printNoticeIsError = false
|
||||||
|
var isPrinting = false
|
||||||
|
|
||||||
// MARK: - Presets
|
// MARK: - Presets
|
||||||
|
|
||||||
var presets: [ProfilingPreset] = []
|
var presets: [ProfilingPreset] = []
|
||||||
@@ -184,7 +205,7 @@ final class TargetWorkflowViewModel {
|
|||||||
targenLog = []
|
targenLog = []
|
||||||
resumedFromTi2 = false
|
resumedFromTi2 = false
|
||||||
let runner = environment.runner
|
let runner = environment.runner
|
||||||
Task {
|
Task { @MainActor in
|
||||||
do {
|
do {
|
||||||
let url = try await runner.runTargen(config: config) { [weak self] batch in
|
let url = try await runner.runTargen(config: config) { [weak self] batch in
|
||||||
Task { @MainActor [weak self] in
|
Task { @MainActor [weak self] in
|
||||||
@@ -276,7 +297,7 @@ final class TargetWorkflowViewModel {
|
|||||||
printtargLog = []
|
printtargLog = []
|
||||||
printtargResult = nil
|
printtargResult = nil
|
||||||
let runner = environment.runner
|
let runner = environment.runner
|
||||||
Task {
|
Task { @MainActor in
|
||||||
do {
|
do {
|
||||||
let result = try await runner.runPrinttarg(config: config) { [weak self] batch in
|
let result = try await runner.runPrinttarg(config: config) { [weak self] batch in
|
||||||
Task { @MainActor [weak self] in
|
Task { @MainActor [weak self] in
|
||||||
@@ -303,6 +324,156 @@ final class TargetWorkflowViewModel {
|
|||||||
wizard.go(to: .measure)
|
wizard.go(to: .measure)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Print panel actions (issue 17)
|
||||||
|
|
||||||
|
/// `#btnRefreshPrinters` — re-enumerate CUPS destinations and load
|
||||||
|
/// capabilities for the selection. Auto-runs when the panel first
|
||||||
|
/// appears with a manifest.
|
||||||
|
func refreshPrinters() {
|
||||||
|
let cups = environment.cupsService
|
||||||
|
Task { @MainActor in
|
||||||
|
do {
|
||||||
|
let list = try await cups.listPrinters()
|
||||||
|
printers = list
|
||||||
|
if !list.contains(where: { $0.name == selectedPrinter }) {
|
||||||
|
selectedPrinter = list.first { $0.isDefault }?.name
|
||||||
|
?? list.first?.name ?? ""
|
||||||
|
}
|
||||||
|
await reloadSelectedCapabilities()
|
||||||
|
} catch {
|
||||||
|
printNotice = "Could not list printers: \(error.localizedDescription)"
|
||||||
|
printNoticeIsError = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Capabilities for `selectedPrinter` — trays / media / sizes feed
|
||||||
|
/// the selects.
|
||||||
|
func reloadSelectedCapabilities() async {
|
||||||
|
guard !selectedPrinter.isEmpty else {
|
||||||
|
printerCaps = PrinterCapabilities()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
printerCaps = try await environment.cupsService
|
||||||
|
.capabilities(for: selectedPrinter)
|
||||||
|
// Default selections only when the captured options didn't
|
||||||
|
// already pin them (Preferences round-trip wins).
|
||||||
|
if selectedMediaType == nil {
|
||||||
|
selectedMediaType = printerCaps.mediaTypes.first?.id
|
||||||
|
}
|
||||||
|
if selectedTray == nil {
|
||||||
|
selectedTray = printerCaps.trays.first?.id
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
printerCaps = PrinterCapabilities()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `#btnPrinterProperties` — bound NSPrintPanel ("Use Settings").
|
||||||
|
/// Cancel → info notice, never an error, cache untouched. On OK the
|
||||||
|
/// captured options are stored per-queue; a panel-side queue switch
|
||||||
|
/// updates `printerSelect` when the returned CUPS id is in the list.
|
||||||
|
func openPrinterPreferences() {
|
||||||
|
guard !selectedPrinter.isEmpty else { return }
|
||||||
|
let queue = selectedPrinter
|
||||||
|
let displayName = printers.first { $0.name == queue }?.displayName
|
||||||
|
let cups = environment.cupsService
|
||||||
|
Task { @MainActor in
|
||||||
|
do {
|
||||||
|
guard let result = try await PrintPanelService()
|
||||||
|
.showProperties(
|
||||||
|
queue: queue, displayName: displayName,
|
||||||
|
cupsService: cups)
|
||||||
|
else {
|
||||||
|
printNotice = "Printer properties dialog cancelled."
|
||||||
|
printNoticeIsError = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if let selected = result.selectedPrinter,
|
||||||
|
printers.contains(where: { $0.name == selected }),
|
||||||
|
selected != queue {
|
||||||
|
selectedPrinter = selected
|
||||||
|
await reloadSelectedCapabilities()
|
||||||
|
}
|
||||||
|
if let captured = result.options.cupsOptions {
|
||||||
|
capturedCupsOptions[selectedPrinter] = captured
|
||||||
|
}
|
||||||
|
if let media = result.options.mediaType {
|
||||||
|
selectedMediaType = media
|
||||||
|
}
|
||||||
|
printNotice = "Settings captured for \(selectedPrinter)."
|
||||||
|
printNoticeIsError = false
|
||||||
|
} catch {
|
||||||
|
printNotice = error.localizedDescription
|
||||||
|
printNoticeIsError = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `#btnPrintAll` — spool every gallery TIFF, sequentially. Stops on
|
||||||
|
/// the first failure so the user sees which page failed.
|
||||||
|
/// `#btnPrintAll` — spool every gallery TIFF, sequentially. Stops on
|
||||||
|
/// the first failure so the user sees which page failed.
|
||||||
|
func printAllPages() {
|
||||||
|
guard let result = printtargResult, !isPrinting else { return }
|
||||||
|
isPrinting = true
|
||||||
|
Task { @MainActor in
|
||||||
|
var printed = 0
|
||||||
|
for page in result.pages {
|
||||||
|
do {
|
||||||
|
try await spool(page, index: page.index)
|
||||||
|
printed += 1
|
||||||
|
} catch {
|
||||||
|
printNotice = "Print failed on \(page.page.filename): "
|
||||||
|
+ error.localizedDescription
|
||||||
|
printNoticeIsError = true
|
||||||
|
isPrinting = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
printNotice = "Sent \(printed) page(s) to \(selectedPrinter)."
|
||||||
|
printNoticeIsError = false
|
||||||
|
isPrinting = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `#btnPrintPage-N` — one TIFF.
|
||||||
|
func printPage(_ page: GalleryPage) {
|
||||||
|
guard !isPrinting else { return }
|
||||||
|
isPrinting = true
|
||||||
|
Task { @MainActor in
|
||||||
|
do {
|
||||||
|
try await spool(page, index: page.index)
|
||||||
|
printNotice = "Sent \(page.page.filename) to \(selectedPrinter)."
|
||||||
|
printNoticeIsError = false
|
||||||
|
} catch {
|
||||||
|
printNotice = "Print failed: \(error.localizedDescription)"
|
||||||
|
printNoticeIsError = true
|
||||||
|
}
|
||||||
|
isPrinting = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func spool(_ page: GalleryPage, index: Int) async throws {
|
||||||
|
guard !selectedPrinter.isEmpty else {
|
||||||
|
throw CupsError.noPrinterSelected
|
||||||
|
}
|
||||||
|
let options = PrintOptions(
|
||||||
|
orientation: printOrientation,
|
||||||
|
paperSize: pageSize == .custom ? nil : pageSize.rawValue,
|
||||||
|
mediaType: selectedMediaType,
|
||||||
|
ppdUncorrectedPassthrough: true,
|
||||||
|
cupsOptions: capturedCupsOptions[selectedPrinter])
|
||||||
|
try await environment.cupsService.printTarget(
|
||||||
|
queue: selectedPrinter,
|
||||||
|
tiffPath: page.fileURL.path,
|
||||||
|
options: options,
|
||||||
|
page: index)
|
||||||
|
// For Stage 5 history (#95): record which queue printed.
|
||||||
|
wizard.printerName = selectedPrinter
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Presets
|
// MARK: - Presets
|
||||||
|
|
||||||
func reloadPresets() {
|
func reloadPresets() {
|
||||||
|
|||||||
@@ -52,7 +52,9 @@ struct CupsOptionsFilterTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Issue 14 — the dlsym attempt order and first-success semantics.
|
/// Issue 14 — the dlsym attempt order and first-success semantics.
|
||||||
/// A fake resolver records every call; no private symbols are touched.
|
/// `@convention(c)` closures can't capture, so recording goes through
|
||||||
|
/// a file-scope recorder keyed by global state; no private symbols are
|
||||||
|
/// touched.
|
||||||
@Suite("ColorSyncSuppressor")
|
@Suite("ColorSyncSuppressor")
|
||||||
@MainActor
|
@MainActor
|
||||||
struct ColorSyncSuppressorTests {
|
struct ColorSyncSuppressorTests {
|
||||||
@@ -62,21 +64,27 @@ struct ColorSyncSuppressorTests {
|
|||||||
unsafeBitCast(UnsafeMutableRawPointer(bitPattern: 0xdead)!, to: PMPrintSession.self)
|
unsafeBitCast(UnsafeMutableRawPointer(bitPattern: 0xdead)!, to: PMPrintSession.self)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func suppressor(
|
/// Call log — static since `@convention(c)` can't capture. The
|
||||||
succeeding symbol: String? = nil,
|
/// resolver sets `currentSymbol` right before each call, so the C
|
||||||
mode: String = "AP_ApplicationColorMatching",
|
/// function records (symbol, mode) without capturing `name`.
|
||||||
calls: UnsafeMutablePointer<[(String, String)]>
|
private static var recorded: [(String, String)] = []
|
||||||
) -> ColorSyncSuppressor {
|
private static var currentSymbol = ""
|
||||||
|
private static var succeeding: (String, String)?
|
||||||
|
private static var missing: Set<String> = []
|
||||||
|
|
||||||
|
private func makeSuppressor() -> ColorSyncSuppressor {
|
||||||
var s = ColorSyncSuppressor()
|
var s = ColorSyncSuppressor()
|
||||||
s.log = { _ in }
|
s.log = { _ in }
|
||||||
s.modeResolver = { name in
|
s.modeResolver = { name in
|
||||||
// Missing symbol → nil (older macOS path).
|
if Self.missing.contains(name) { return nil }
|
||||||
if name == "PMSessionSetColorMatchingModeLock" && symbol == nil {
|
Self.currentSymbol = name
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return { _, modeArg in
|
return { _, modeArg in
|
||||||
calls.pointee.append((name, modeArg as String))
|
Self.recorded.append((Self.currentSymbol, modeArg as String))
|
||||||
return (name == symbol && (modeArg as String) == mode) ? 0 : 1
|
if let ok = Self.succeeding,
|
||||||
|
Self.currentSymbol == ok.0, (modeArg as String) == ok.1 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return s
|
return s
|
||||||
@@ -84,68 +92,53 @@ struct ColorSyncSuppressorTests {
|
|||||||
|
|
||||||
@Test("Attempt order: Lock → Mode → NoLock, AP_ prefix first")
|
@Test("Attempt order: Lock → Mode → NoLock, AP_ prefix first")
|
||||||
func attemptOrder() {
|
func attemptOrder() {
|
||||||
let calls = UnsafeMutablePointer<[(String, String)]>.allocate(capacity: 1)
|
Self.recorded = []
|
||||||
calls.initialize(to: [])
|
Self.succeeding = nil
|
||||||
defer { calls.deallocate() }
|
Self.missing = ["PMSessionSetColorMatchingModeLock"]
|
||||||
|
let s = makeSuppressor()
|
||||||
let s = suppressor(succeeding: nil, calls: calls)
|
|
||||||
#expect(s.applySPIMode(to: fakeSession) == false)
|
#expect(s.applySPIMode(to: fakeSession) == false)
|
||||||
#expect(calls.pointee == ColorMatchingAttempts.attempts
|
// Lock is unresolvable → skipped; the rest plays out in order.
|
||||||
.map { ($0.symbol, $0.mode) }
|
#expect(Self.recorded.map { "\($0.0)|\($0.1)" }
|
||||||
.filter { $0.0 != "PMSessionSetColorMatchingModeLock" })
|
== ColorMatchingAttempts.attempts
|
||||||
|
.filter { $0.symbol != "PMSessionSetColorMatchingModeLock" }
|
||||||
|
.map { "\($0.symbol)|\($0.mode)" })
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("First zero wins — later symbols not called")
|
@Test("First zero wins — later symbols/modes not called")
|
||||||
func firstZeroWins() {
|
func firstZeroWins() {
|
||||||
let calls = UnsafeMutablePointer<[(String, String)]>.allocate(capacity: 1)
|
Self.recorded = []
|
||||||
calls.initialize(to: [])
|
Self.succeeding = ("PMSessionSetColorMatchingModeLock",
|
||||||
defer { calls.deallocate() }
|
"AP_ApplicationColorMatching")
|
||||||
|
Self.missing = []
|
||||||
let s = suppressor(
|
let s = makeSuppressor()
|
||||||
succeeding: "PMSessionSetColorMatchingMode", calls: calls)
|
|
||||||
#expect(s.applySPIMode(to: fakeSession))
|
#expect(s.applySPIMode(to: fakeSession))
|
||||||
// Lock symbol missing → skipped; Mode tried AP_ then plain? No —
|
#expect(Self.recorded.map { "\($0.0)|\($0.1)" } == [
|
||||||
// Mode succeeds on the first mode → 2 calls total.
|
"PMSessionSetColorMatchingModeLock|AP_ApplicationColorMatching",
|
||||||
#expect(calls.pointee == [
|
|
||||||
("PMSessionSetColorMatchingMode", "AP_ApplicationColorMatching"),
|
|
||||||
])
|
])
|
||||||
// NoLock never attempted.
|
|
||||||
#expect(!calls.pointee.contains { $0.0 == "PMSessionSetColorMatchingModeNoLock" })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Mode fallback: AP_ rejected → ApplicationColorMatching tried")
|
@Test("Mode fallback: AP_ rejected → ApplicationColorMatching tried")
|
||||||
func modeFallback() {
|
func modeFallback() {
|
||||||
let calls = UnsafeMutablePointer<[(String, String)]>.allocate(capacity: 1)
|
Self.recorded = []
|
||||||
calls.initialize(to: [])
|
Self.succeeding = ("PMSessionSetColorMatchingModeLock",
|
||||||
defer { calls.deallocate() }
|
"ApplicationColorMatching")
|
||||||
|
Self.missing = []
|
||||||
var s = suppressor(
|
let s = makeSuppressor()
|
||||||
succeeding: "PMSessionSetColorMatchingModeLock",
|
|
||||||
mode: "ApplicationColorMatching",
|
|
||||||
calls: calls)
|
|
||||||
// Make the Lock symbol resolvable this time.
|
|
||||||
let record: (String) -> ColorMatchingModeFunction? = { name in
|
|
||||||
{ _, modeArg in
|
|
||||||
calls.pointee.append((name, modeArg as String))
|
|
||||||
return (modeArg as String) == "ApplicationColorMatching" ? 0 : 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
s.modeResolver = record
|
|
||||||
#expect(s.applySPIMode(to: fakeSession))
|
#expect(s.applySPIMode(to: fakeSession))
|
||||||
#expect(calls.pointee.first
|
#expect(Self.recorded[0].0 == "PMSessionSetColorMatchingModeLock")
|
||||||
== ("PMSessionSetColorMatchingModeLock", "AP_ApplicationColorMatching"))
|
#expect(Self.recorded[0].1 == "AP_ApplicationColorMatching")
|
||||||
#expect(calls.pointee.last
|
#expect(Self.recorded[1].0 == "PMSessionSetColorMatchingModeLock")
|
||||||
== ("PMSessionSetColorMatchingModeLock", "ApplicationColorMatching"))
|
#expect(Self.recorded[1].1 == "ApplicationColorMatching")
|
||||||
|
#expect(Self.recorded.count == 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("All symbols missing → false, no calls")
|
@Test("All symbols missing → false, no calls")
|
||||||
func allMissing() {
|
func allMissing() {
|
||||||
let calls = UnsafeMutablePointer<[(String, String)]>.allocate(capacity: 1)
|
Self.recorded = []
|
||||||
calls.initialize(to: [])
|
Self.succeeding = nil
|
||||||
defer { calls.deallocate() }
|
Self.missing = Set(ColorMatchingAttempts.symbols)
|
||||||
var s = suppressor(succeeding: nil, calls: calls)
|
let s = makeSuppressor()
|
||||||
s.modeResolver = { _ in nil }
|
|
||||||
#expect(s.applySPIMode(to: fakeSession) == false)
|
#expect(s.applySPIMode(to: fakeSession) == false)
|
||||||
#expect(calls.pointee.isEmpty)
|
#expect(Self.recorded.isEmpty)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,21 +129,17 @@ struct CupsParsersTests {
|
|||||||
|
|
||||||
@Test("Driver bypass: Canon Intent2 > Intent; Epson CCor > CMat")
|
@Test("Driver bypass: Canon Intent2 > Intent; Epson CCor > CMat")
|
||||||
func driverBypass() {
|
func driverBypass() {
|
||||||
#expect(CupsParsers.detectDriverColorBypass(
|
func pair(_ keys: Set<String>) -> String? {
|
||||||
optionKeys: ["CNIJIntent2", "CNIJIntent"])
|
CupsParsers.detectDriverColorBypass(optionKeys: keys)
|
||||||
== ("CNIJIntent2", "4"))
|
.map { "\($0.key)=\($0.value)" }
|
||||||
#expect(CupsParsers.detectDriverColorBypass(optionKeys: ["CNIJIntent"])
|
}
|
||||||
== ("CNIJIntent", "4"))
|
#expect(pair(["CNIJIntent2", "CNIJIntent"]) == "CNIJIntent2=4")
|
||||||
#expect(CupsParsers.detectDriverColorBypass(
|
#expect(pair(["CNIJIntent"]) == "CNIJIntent=4")
|
||||||
optionKeys: ["EPIJ_CCor", "EPIJ_CMat"]) == ("EPIJ_CCor", "0"))
|
#expect(pair(["EPIJ_CCor", "EPIJ_CMat"]) == "EPIJ_CCor=0")
|
||||||
#expect(CupsParsers.detectDriverColorBypass(optionKeys: ["EPIJ_CMat"])
|
#expect(pair(["EPIJ_CMat"]) == "EPIJ_CMat=3")
|
||||||
== ("EPIJ_CMat", "3"))
|
#expect(pair(["StpColorCorrection"]) == "StpColorCorrection=Uncorrected")
|
||||||
#expect(CupsParsers.detectDriverColorBypass(
|
#expect(pair(["ColorCorrection"]) == "ColorCorrection=Uncorrected")
|
||||||
optionKeys: ["StpColorCorrection"]) == ("StpColorCorrection", "Uncorrected"))
|
#expect(pair(["EpsonColorMode"]) == "EpsonColorMode=Off")
|
||||||
#expect(CupsParsers.detectDriverColorBypass(
|
#expect(pair(["PageSize"]) == nil)
|
||||||
optionKeys: ["ColorCorrection"]) == ("ColorCorrection", "Uncorrected"))
|
|
||||||
#expect(CupsParsers.detectDriverColorBypass(
|
|
||||||
optionKeys: ["EpsonColorMode"]) == ("EpsonColorMode", "Off"))
|
|
||||||
#expect(CupsParsers.detectDriverColorBypass(optionKeys: ["PageSize"]) == nil)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,19 +96,21 @@ struct LpArgsTests {
|
|||||||
.contains("orientation-requested=3"))
|
.contains("orientation-requested=3"))
|
||||||
#expect(try build(options: PrintOptions(orientation: "landscape"))
|
#expect(try build(options: PrintOptions(orientation: "landscape"))
|
||||||
.contains("orientation-requested=4"))
|
.contains("orientation-requested=4"))
|
||||||
#expect(!try build(options: PrintOptions(
|
let capturedOrients = try build(options: PrintOptions(
|
||||||
orientation: "landscape",
|
orientation: "landscape",
|
||||||
cupsOptions: "orientation-requested=5"))
|
cupsOptions: "orientation-requested=5"))
|
||||||
.contains("orientation-requested=4"))
|
#expect(!capturedOrients.contains("orientation-requested=4"))
|
||||||
|
#expect(capturedOrients.contains("orientation-requested=5"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("PageSize emitted unless captured")
|
@Test("PageSize emitted unless captured")
|
||||||
func pageSize() throws {
|
func pageSize() throws {
|
||||||
#expect(try build(options: PrintOptions(paperSize: "A4"))
|
#expect(try build(options: PrintOptions(paperSize: "A4"))
|
||||||
.contains("PageSize=A4"))
|
.contains("PageSize=A4"))
|
||||||
#expect(!try build(options: PrintOptions(
|
let capturedSize = try build(options: PrintOptions(
|
||||||
paperSize: "A4", cupsOptions: "PageSize=Letter"))
|
paperSize: "A4", cupsOptions: "PageSize=Letter"))
|
||||||
.contains("PageSize=A4"))
|
#expect(!capturedSize.contains("PageSize=A4"))
|
||||||
|
#expect(capturedSize.contains("PageSize=Letter"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Sanitise rejects `;`, newline, and shell metachars")
|
@Test("Sanitise rejects `;`, newline, and shell metachars")
|
||||||
|
|||||||
Executable
+14
@@ -0,0 +1,14 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Mock lp for Milestone3UITests. Appends its full argv to
|
||||||
|
# ICCERY_TEST_LP_ARGV so the test can assert flag order and option
|
||||||
|
# replay, then exits 0 (or ICCERY_MOCK_LP_EXIT for failure injection).
|
||||||
|
{
|
||||||
|
printf 'lp'
|
||||||
|
for arg in "$@"; do printf ' %s' "$arg"; done
|
||||||
|
printf '\n'
|
||||||
|
} >> "${ICCERY_TEST_LP_ARGV:-/dev/null}"
|
||||||
|
if [ "${ICCERY_MOCK_LP_EXIT:-0}" -ne 0 ]; then
|
||||||
|
echo "mock lp failure" >&2
|
||||||
|
exit "$ICCERY_MOCK_LP_EXIT"
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
Executable
+23
@@ -0,0 +1,23 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Mock lpoptions for Milestone3UITests. `-p <q>` prints printer-info;
|
||||||
|
# `-p <q> -l` prints Key/Label listings incl. Epson bypass keys.
|
||||||
|
queue=""
|
||||||
|
list=0
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
-p) shift_flag=1 ;;
|
||||||
|
-l) list=1 ;;
|
||||||
|
-*) ;;
|
||||||
|
*) queue="$arg" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
if [ "$list" = "1" ]; then
|
||||||
|
printf 'PageSize/Media Size: 4x6 5x7 *A4 Letter Legal\n'
|
||||||
|
printf 'InputSlot/Media Source: Auto *Main Rear\n'
|
||||||
|
printf 'MediaType/Media Type: *Stationery PhotographicGlossy PhotographicMatte\n'
|
||||||
|
printf 'EPIJ_CMat/Color Adjust: *0 1 2 3\n'
|
||||||
|
printf 'ColorModel/Output Mode: *RGB Gray\n'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
printf "printer-info='Mock %s' printer-type=42\n" "$queue"
|
||||||
|
exit 0
|
||||||
Executable
+19
@@ -0,0 +1,19 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Mock lpstat for Milestone3UITests. Emits two canned queues so the UI
|
||||||
|
# can exercise select/refresh/status-badge without real CUPS.
|
||||||
|
case "$1" in
|
||||||
|
-e)
|
||||||
|
printf 'Mock_Epson_7450\nMock_Canon_Pro\n'
|
||||||
|
;;
|
||||||
|
-p)
|
||||||
|
printf 'printer Mock_Epson_7450 is idle. enabled since Mon Sep 7 21:50:25 2026\n'
|
||||||
|
printf 'printer Mock_Canon_Pro disabled since Tue Sep 8 09:00:00 2026 -\n\tPaused\n'
|
||||||
|
;;
|
||||||
|
-d)
|
||||||
|
printf 'system default destination: Mock_Epson_7450\n'
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
exit 0
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import XCTest
|
||||||
|
|
||||||
|
/// Milestone 3 UI tests — issue #17 print panel end-to-end with mock
|
||||||
|
/// CUPS binaries and a stubbed `NSPrintPanel`. The real panel is a
|
||||||
|
/// system modal XCUITest cannot drive; `ICCERY_TEST_PRINT_PANEL`
|
||||||
|
/// returns a canned `PrintPropertiesResult` instead. Mock `lp` appends
|
||||||
|
/// its argv to `ICCERY_TEST_LP_ARGV` for assertions — that file is the
|
||||||
|
/// evidence that captured options are replayed (docs/11 §tests).
|
||||||
|
@MainActor
|
||||||
|
final class Milestone3UITests: XCTestCase {
|
||||||
|
|
||||||
|
private var app: XCUIApplication!
|
||||||
|
private var testRoot: URL!
|
||||||
|
private var binDir: URL!
|
||||||
|
private var workDir: URL!
|
||||||
|
private var lpArgvURL: URL!
|
||||||
|
|
||||||
|
override func setUp() async throws {
|
||||||
|
continueAfterFailure = false
|
||||||
|
testRoot = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("iccery-ui3-\(UUID().uuidString)")
|
||||||
|
binDir = URL(fileURLWithPath: #filePath)
|
||||||
|
.deletingLastPathComponent()
|
||||||
|
.appendingPathComponent("Fixtures/bin")
|
||||||
|
workDir = testRoot.appendingPathComponent("work")
|
||||||
|
lpArgvURL = testRoot.appendingPathComponent("lp-argv.log")
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: workDir, withIntermediateDirectories: true)
|
||||||
|
|
||||||
|
app = XCUIApplication()
|
||||||
|
app.launchEnvironment = [
|
||||||
|
"ICCERY_UI_TESTING": "1",
|
||||||
|
"ICCERY_TEST_ROOT": testRoot.path,
|
||||||
|
"ICCERY_ARGYLL_BINARY_DIR": binDir.path,
|
||||||
|
"ICCERY_CUPS_BIN_DIR": binDir.path,
|
||||||
|
"ICCERY_TEST_SAVE_TARGET":
|
||||||
|
workDir.appendingPathComponent("mytarget.ti1").path,
|
||||||
|
"ICCERY_TEST_WORKDIR": workDir.path,
|
||||||
|
"ICCERY_TEST_LP_ARGV": lpArgvURL.path,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tearDown() async throws {
|
||||||
|
app?.terminate()
|
||||||
|
app = nil
|
||||||
|
if let testRoot {
|
||||||
|
try? FileManager.default.removeItem(at: testRoot)
|
||||||
|
}
|
||||||
|
testRoot = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func launchApp() {
|
||||||
|
app.launch()
|
||||||
|
app.activate()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func element(_ id: String) -> XCUIElement {
|
||||||
|
let inApp = app.descendants(matching: .any)[id]
|
||||||
|
if inApp.exists { return inApp }
|
||||||
|
return app.sheets.firstMatch.descendants(matching: .any)[id]
|
||||||
|
}
|
||||||
|
|
||||||
|
private func waitFor(_ id: String, timeout: TimeInterval = 15) -> XCUIElement {
|
||||||
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
while Date() < deadline {
|
||||||
|
let el = element(id)
|
||||||
|
if el.exists { return el }
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||||
|
}
|
||||||
|
let el = element(id)
|
||||||
|
XCTAssertTrue(el.exists, "Expected element \(id)")
|
||||||
|
return el
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drive the app through targen + printtarg so the print panel is
|
||||||
|
/// live with a manifest.
|
||||||
|
private func reachPrintPanel() {
|
||||||
|
app.buttons["btnBrowse"].click()
|
||||||
|
app.buttons["btnGenerate"].click()
|
||||||
|
_ = waitFor("btnCreateLayout", timeout: 25)
|
||||||
|
app.buttons["btnCreateLayout"].click()
|
||||||
|
_ = waitFor("galleryPage-0", timeout: 25)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func recordedLpArgv() -> String {
|
||||||
|
(try? String(contentsOf: lpArgvURL, encoding: .utf8)) ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
private func waitForLpLine(_ timeout: TimeInterval = 10) -> String {
|
||||||
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
while Date() < deadline {
|
||||||
|
let out = recordedLpArgv()
|
||||||
|
if !out.isEmpty { return out }
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||||
|
}
|
||||||
|
return recordedLpArgv()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Tests
|
||||||
|
|
||||||
|
/// Panel appears after the manifest; refresh populates the printer
|
||||||
|
/// select with the mock queues and shows a status badge.
|
||||||
|
func testPrintPanelEnumeratesPrinters() throws {
|
||||||
|
launchApp()
|
||||||
|
reachPrintPanel()
|
||||||
|
|
||||||
|
XCTAssertTrue(waitFor("rawPrintPanel").exists)
|
||||||
|
// The panel auto-refreshes on appear; the default mock queue is
|
||||||
|
// selected and its status badge shows.
|
||||||
|
XCTAssertTrue(element("printerSelect").waitForExistence(timeout: 10))
|
||||||
|
XCTAssertTrue(element("printerStatusBadge")
|
||||||
|
.waitForExistence(timeout: 10))
|
||||||
|
XCTAssertTrue(element("printerTraySelect").exists)
|
||||||
|
XCTAssertTrue(element("printerMediaTypeSelect").exists)
|
||||||
|
XCTAssertTrue(element("btnOrientPortrait").exists)
|
||||||
|
XCTAssertTrue(element("btnOrientLandscape").exists)
|
||||||
|
XCTAssertTrue(app.buttons["btnPrintAll"].isEnabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Preferences cancel → info notice, no error, no cache mutation.
|
||||||
|
func testPreferencesCancelIsInfo() throws {
|
||||||
|
app.launchEnvironment["ICCERY_TEST_PRINT_PANEL"] = "cancel"
|
||||||
|
launchApp()
|
||||||
|
reachPrintPanel()
|
||||||
|
_ = waitFor("printerStatusBadge")
|
||||||
|
|
||||||
|
element("btnPrinterProperties").click()
|
||||||
|
let notice = element("printNotificationText")
|
||||||
|
XCTAssertTrue(notice.waitForExistence(timeout: 10))
|
||||||
|
XCTAssertTrue((notice.value as? String ?? "")
|
||||||
|
.contains("cancelled"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Preferences OK → captured options are replayed verbatim in the
|
||||||
|
/// `lp` argv alongside the two mandatory AP_* headers (issue 17's
|
||||||
|
/// acceptance test: "captured options replayed in argv").
|
||||||
|
func testCapturedOptionsReplayedInLpArgv() throws {
|
||||||
|
app.launchEnvironment["ICCERY_TEST_PRINT_PANEL"] = "ok"
|
||||||
|
app.launchEnvironment["ICCERY_TEST_PANEL_OPTIONS"] =
|
||||||
|
"InputSlot=Rear MediaType=PhotographicGlossy"
|
||||||
|
launchApp()
|
||||||
|
reachPrintPanel()
|
||||||
|
_ = waitFor("printerStatusBadge")
|
||||||
|
|
||||||
|
element("btnPrinterProperties").click()
|
||||||
|
let notice = element("printNotificationText")
|
||||||
|
XCTAssertTrue(notice.waitForExistence(timeout: 10))
|
||||||
|
XCTAssertTrue((notice.value as? String ?? "")
|
||||||
|
.contains("Settings captured"))
|
||||||
|
|
||||||
|
app.buttons["btnPrintAll"].click()
|
||||||
|
let argv = waitForLpLine()
|
||||||
|
XCTAssertTrue(argv.contains(
|
||||||
|
"AP_ColorMatchingMode=AP_ApplicationColorMatching"), argv)
|
||||||
|
XCTAssertTrue(argv.contains(
|
||||||
|
"AP.ColorMatchingMode=AP_ApplicationColorMatching"), argv)
|
||||||
|
XCTAssertTrue(argv.contains("InputSlot=Rear"), argv)
|
||||||
|
XCTAssertTrue(argv.contains("MediaType=PhotographicGlossy"), argv)
|
||||||
|
// Detected bypass for the mock queue (EPIJ_CMat present in
|
||||||
|
// lpoptions -l) is appended when not captured.
|
||||||
|
XCTAssertTrue(argv.contains("EPIJ_CMat=3"), argv)
|
||||||
|
XCTAssertTrue(argv.contains("orientation-requested=3"), argv)
|
||||||
|
// Last token is the TIFF.
|
||||||
|
XCTAssertTrue(argv.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
.hasSuffix("page1.tif"), argv)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-page print uses the same spool path (btnPrintPage-N).
|
||||||
|
func testPerPagePrint() throws {
|
||||||
|
launchApp()
|
||||||
|
reachPrintPanel()
|
||||||
|
_ = waitFor("printerStatusBadge")
|
||||||
|
|
||||||
|
app.buttons["btnPrintPage-0"].click()
|
||||||
|
let argv = waitForLpLine()
|
||||||
|
XCTAssertTrue(argv.contains("AP_ColorMatchingMode"), argv)
|
||||||
|
XCTAssertTrue(argv.contains("page1.tif"), argv)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// lp failure surfaces in the in-panel notice, not the wizard banner.
|
||||||
|
func testLpFailureShowsPrintNotice() throws {
|
||||||
|
app.launchEnvironment["ICCERY_MOCK_LP_EXIT"] = "1"
|
||||||
|
launchApp()
|
||||||
|
reachPrintPanel()
|
||||||
|
_ = waitFor("printerStatusBadge")
|
||||||
|
|
||||||
|
app.buttons["btnPrintAll"].click()
|
||||||
|
let notice = element("printNotificationText")
|
||||||
|
XCTAssertTrue(notice.waitForExistence(timeout: 10))
|
||||||
|
XCTAssertTrue((notice.value as? String ?? "")
|
||||||
|
.contains("Print failed"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// wizardState.printerName records the queue used for spooling (#95).
|
||||||
|
func testPrinterNamePersistedOnSpool() throws {
|
||||||
|
launchApp()
|
||||||
|
reachPrintPanel()
|
||||||
|
_ = waitFor("printerStatusBadge")
|
||||||
|
|
||||||
|
app.buttons["btnPrintAll"].click()
|
||||||
|
_ = waitForLpLine()
|
||||||
|
let stateURL = testRoot
|
||||||
|
.appendingPathComponent("AppData")
|
||||||
|
.appendingPathComponent("wizard_state.json")
|
||||||
|
XCTAssertTrue(waitForFile(stateURL))
|
||||||
|
let data = try Data(contentsOf: stateURL)
|
||||||
|
let state = String(data: data, encoding: .utf8) ?? ""
|
||||||
|
XCTAssertTrue(state.contains("Mock_Epson_7450"), state)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func waitForFile(_ url: URL, timeout: TimeInterval = 10) -> Bool {
|
||||||
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
while Date() < deadline {
|
||||||
|
if FileManager.default.fileExists(atPath: url.path) { return true }
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user