mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-14 06:22:24 +00:00
* craft-print: ask macOS for Auto Select when a job names no tray
CUPS gives a job that names no tray the PPD's default tray. On a queue
whose default tray is empty (a Xerox AltaLink defaulting to Tray 1), the
printer holds every job ("Tray 1 is empty") while CUPS reports it
completed, so nothing prints. Preview prints because the macOS Print
dialog's Paper Feed: Auto Select sends AP_D_InputSlot with an empty value,
which drops the PPD's default tray (storytold/pdfcraft#3752).
- lp_args sends `-o AP_D_InputSlot=` on macOS when the job's options name
no InputSlot. A tray the job names wins; checked with cupsfilter on a
synthetic PPD: with both, CUPS emits the InputSlot's code.
- apply_queue_defaults (now shared with apps that read options
themselves, such as PdfCraft) gives the driver's tray option an
AUTO_SELECT choice, labelled "Auto Select", first and as the default,
so a dialog shows what lp_args sends. It is left out when the user's
lpoptions defaults name another tray, since lp adds that tray to every
job and it wins.
Linux jobs are unchanged: AP_D_InputSlot is Apple's.
* craft-print: match queue defaults to the PPD without regard to case
CUPS treats PPD option and choice names as case-insensitive. A queue
default spelled otherwise than the PPD (inputslot=tray2 for InputSlot
Tray2) was ignored, so on macOS apply_queue_defaults showed Auto Select
although lp would send Tray2. Keys and choices now match case-
insensitively and keep the PPD's spelling. lpoptions -l prints the PPD's
spelling, so this matters for callers passing defaults from elsewhere.
Review: storytold/craft-libs#232 (CodeRabbit).
* README: keep both the macOS tray and the Flatpak notes (the merge left the conflict markers in)
---------
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
436 lines
24 KiB
Rust
436 lines
24 KiB
Rust
use super::*;
|
|
use crate as spool;
|
|
|
|
mod windows_native;
|
|
|
|
#[test]
|
|
fn spooler_arguments_and_printer_list() {
|
|
let out = "printer Office_Laser is idle. enabled since Thu Oct 1 09:00:00 2026\nprinter Label_Writer disabled since …\nsystem default destination: Office_Laser\n";
|
|
assert_eq!(
|
|
parse_lpstat(out),
|
|
[
|
|
spool::Printer { name: "Office_Laser".into(), description: String::new(), default: true, two_sided: None },
|
|
spool::Printer { name: "Label_Writer".into(), description: String::new(), default: false, two_sided: None }
|
|
]
|
|
);
|
|
assert!(parse_lpstat("lpstat: No destinations added.\nno system default destination\n").is_empty());
|
|
let job = Job {
|
|
printer: Some("Office_Laser".into()),
|
|
copies: 3,
|
|
collate: false,
|
|
duplex: Some(Duplex::LongEdge),
|
|
grayscale: true,
|
|
title: "memo.pdf".into(),
|
|
options: Vec::new(),
|
|
..Job::default()
|
|
};
|
|
assert_eq!(
|
|
spool::lp_args_for(&job, false).join(" "),
|
|
"-d Office_Laser -n 3 -t memo.pdf -o collate=false -o sides=two-sided-long-edge -o print-color-mode=monochrome -o fit-to-page=false"
|
|
);
|
|
assert_eq!(lp_args(&Job::default())[0], "-n", "no -d: the default printer");
|
|
}
|
|
|
|
#[test]
|
|
fn lpstat_output_is_untranslated() {
|
|
// A localized lpstat (here Polish) is unreadable to parse_lpstat...
|
|
assert!(parse_lpstat("drukarka Office_Laser jest bezczynna.\ndomyślny cel systemowy: Office_Laser\n").is_empty());
|
|
// ...so the command must force the C locale, including the SOFTWARE switch macOS CUPS needs.
|
|
let cmd = spool::lpstat_command();
|
|
let envs: Vec<_> = cmd.get_envs().map(|(k, v)| (k.to_string_lossy().into_owned(), v.map(|v| v.to_string_lossy().into_owned()))).collect();
|
|
for key in ["LC_ALL", "LANG"] {
|
|
assert!(envs.contains(&(key.into(), Some("C".into()))), "{key}=C missing: {envs:?}");
|
|
}
|
|
assert!(envs.iter().any(|(k, v)| k == "SOFTWARE" && v.as_deref().is_some_and(|v| !v.is_empty())), "SOFTWARE missing: {envs:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn a_queue_keeps_its_human_name() {
|
|
// A CUPS queue name can't hold dots, so macOS adds the printer at 192.0.2.15 as the queue
|
|
// `_192_0_2_15`. The dialog showed that underscored name; `lpstat -l` also gives the name the
|
|
// user knows, as the queue's Description.
|
|
let out = "printer _192_0_2_15 is idle. enabled since Thu Oct 1 09:00:00 2026\n\tForm mounted:\n\tDescription: 192.0.2.15\n\tAlerts: none\n\tLocation: Description: not this one\nprinter Label_Writer disabled since Thu Oct 1 09:00:00 2026 -\n\treason unknown\n\tDescription:\nprinter Office_Laser is idle.\r\n\tDescription: Office Laser, 2nd floor\r\n\tDescription: a second one is ignored\nDescription: not indented, not a detail\nsystem default destination: _192_0_2_15\n";
|
|
assert_eq!(
|
|
parse_lpstat(out),
|
|
[
|
|
spool::Printer { name: "_192_0_2_15".into(), description: "192.0.2.15".into(), default: true, two_sided: None },
|
|
spool::Printer { name: "Label_Writer".into(), description: String::new(), default: false, two_sided: None },
|
|
spool::Printer { name: "Office_Laser".into(), description: "Office Laser, 2nd floor".into(), default: false, two_sided: None },
|
|
],
|
|
"each queue takes its own first Description; an empty one stays empty"
|
|
);
|
|
// A description before any queue belongs to none, and a huge one is cut to a name's length.
|
|
assert!(parse_lpstat("\tDescription: orphan\n").is_empty());
|
|
// An empty first description is still the first: a later one doesn't replace it.
|
|
assert_eq!(parse_lpstat("printer Q is idle.\n\tDescription:\n\tDescription: Office\n")[0].description, "");
|
|
let long = format!("printer Q is idle.\n\tDescription: {}\n", "é".repeat(10_000));
|
|
assert_eq!(parse_lpstat(&long)[0].description.chars().count(), 256);
|
|
// The queues come from `lpstat -l`, so the descriptions are there to read.
|
|
let args: Vec<_> = spool::lpstat_command().get_args().map(|a| a.to_string_lossy().into_owned()).collect();
|
|
assert_eq!(args, ["-l", "-p", "-d"]);
|
|
}
|
|
|
|
#[test]
|
|
fn driverless_printers_join_the_list_without_a_queue() {
|
|
// A driverless network printer (IPP, discovered on the LAN) has no queue, so `lpstat -p`
|
|
// never lists it — yet GTK's print dialog shows it and `lp` prints to it through a temporary
|
|
// queue. Only `lpstat -e` names it, and the Print dialog used to show Save as PDF alone.
|
|
let queues = "printer Office_Laser is idle. enabled since Thu Oct 1 09:00:00 2026\nsystem default destination: Office_Laser\n";
|
|
assert_eq!(
|
|
spool::printers_parsed(queues, Some("Basement_Color\nOffice_Laser\n")),
|
|
[
|
|
spool::Printer { name: "Office_Laser".into(), description: String::new(), default: true, two_sided: None },
|
|
spool::Printer { name: "Basement_Color".into(), description: String::new(), default: false, two_sided: None },
|
|
],
|
|
"queues first, then the driverless destination; the queue listed twice is not doubled"
|
|
);
|
|
// An old spooler without `-e` (CUPS < 1.7) keeps its queues; only the driverless names are lost.
|
|
assert_eq!(
|
|
spool::printers_parsed(queues, None),
|
|
[spool::Printer { name: "Office_Laser".into(), description: String::new(), default: true, two_sided: None }]
|
|
);
|
|
// The default can itself be driverless (`lpoptions -d` names a destination no queue exists
|
|
// for): it keeps its marker, so the dialog preselects the user's default, not the first queue.
|
|
assert_eq!(
|
|
spool::printers_parsed("system default destination: Basement_Color\n", Some("Basement_Color\n")),
|
|
[spool::Printer { name: "Basement_Color".into(), description: String::new(), default: true, two_sided: None }],
|
|
"a default destination without a queue is still the default"
|
|
);
|
|
assert!(spool::parse_lpstat_e("\n \n").is_empty(), "no destinations is empty, not [\"\"]");
|
|
assert_eq!(spool::parse_lpstat_e("A\r\nB \n"), ["A".to_string(), "B".to_string()], "CRLF and stray spaces are trimmed");
|
|
}
|
|
|
|
/// Set on the child process when a test runs this test binary as a stand-in for `lp`.
|
|
const STAND_IN_LP: &str = "CRAFT_STAND_IN_LP";
|
|
|
|
/// Not a test of its own: [`stand_in_lp`] re-runs this binary with only this test selected, and it
|
|
/// then plays `lp`. It records its arguments and stdin in the folder named by [`STAND_IN_LP`], or,
|
|
/// when the folder is `refuse`, exits without reading stdin, the way `lp` refuses an unknown
|
|
/// printer.
|
|
#[test]
|
|
fn stand_in_for_lp() {
|
|
let Some(record) = std::env::var_os(STAND_IN_LP) else { return };
|
|
if record == "refuse" {
|
|
eprintln!("lp: The printer or class does not exist.");
|
|
std::process::exit(1);
|
|
}
|
|
let record = std::path::PathBuf::from(record);
|
|
let args: Vec<String> = std::env::args().skip_while(|a| a != "--").skip(1).collect();
|
|
let mut stdin = Vec::new();
|
|
std::io::Read::read_to_end(&mut std::io::stdin(), &mut stdin).unwrap();
|
|
std::fs::write(record.join("args"), args.join(" ")).unwrap();
|
|
std::fs::write(record.join("stdin"), stdin).unwrap();
|
|
}
|
|
|
|
/// A spooler command that runs [`stand_in_for_lp`] with `record` (see there).
|
|
fn stand_in_lp(record: &std::ffi::OsStr) -> std::process::Command {
|
|
let mut c = std::process::Command::new(std::env::current_exe().unwrap());
|
|
c.args(["--exact", "tests::stand_in_for_lp", "--nocapture", "--"]).env(STAND_IN_LP, record);
|
|
c
|
|
}
|
|
|
|
#[test]
|
|
fn print_jobs_reach_lp_on_stdin_never_through_a_shared_temp_folder() {
|
|
// The job used to be written to `<temp>/craft-print-<pid>/job-<time>.pdf` and handed to `lp`
|
|
// by name. In a shared /tmp another local user can predict that folder, create it first (or
|
|
// plant a symlink there) and so read every printed document or swap the file before `lp`
|
|
// reads it. Piping the job to `lp` leaves nothing on disk.
|
|
static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
|
|
let record =
|
|
std::env::temp_dir().join(format!("craft-print-test-{}-{}", std::process::id(), N.fetch_add(1, std::sync::atomic::Ordering::Relaxed)));
|
|
std::fs::create_dir(&record).unwrap();
|
|
// Bigger than any pipe buffer, so the spooler must read while the job is still being written.
|
|
let pdf: Vec<u8> = b"%PDF-1.7 synthetic print job\n".iter().copied().cycle().take(3 << 20).collect();
|
|
let job = Job { title: "memo.pdf".into(), ..Job::default() };
|
|
let sent = spool::submit_via(stand_in_lp(record.as_os_str()), &pdf, &job, &Cancel::new());
|
|
let args = std::fs::read_to_string(record.join("args"));
|
|
let stdin = std::fs::read(record.join("stdin"));
|
|
let _ = std::fs::remove_dir_all(&record);
|
|
assert!(sent.is_ok(), "{sent:?}");
|
|
// On macOS the job, naming no tray, also asks for Auto Select (storytold/pdfcraft#3752).
|
|
let auto = if cfg!(target_os = "macos") { " -o AP_D_InputSlot=" } else { "" };
|
|
assert_eq!(
|
|
args.unwrap(),
|
|
format!("-n 1 -t memo.pdf -o collate=true -o fit-to-page=false{auto}"),
|
|
"no file argument: lp reads the job from stdin"
|
|
);
|
|
assert!(stdin.unwrap() == pdf, "lp receives the whole job on stdin");
|
|
let predictable = std::env::temp_dir().join(format!("craft-print-{}", std::process::id()));
|
|
assert!(!predictable.exists(), "nothing is created at {predictable:?}, a name other local users can predict");
|
|
}
|
|
|
|
#[test]
|
|
fn a_job_cancels_until_it_starts_reaching_the_spooler() {
|
|
// Pending: cancelling wins, and the job refuses to start sending; cancelling again agrees.
|
|
let pending = Cancel::new();
|
|
assert!(pending.cancel(), "a pending job is never sent");
|
|
assert_eq!(pending.start_sending(), Err(Error::Spool(CANCELLED_MESSAGE.into())));
|
|
assert!(pending.cancel(), "still cancelled");
|
|
// Sending: too late to cancel; a job sent as several copies keeps sending.
|
|
let sending = Cancel::new();
|
|
assert_eq!(sending.start_sending(), Ok(()));
|
|
assert!(!sending.cancel(), "a job being sent completes; the system's queue cancels it");
|
|
assert_eq!(sending.start_sending(), Ok(()), "the next copy of the same job");
|
|
// A clone is the same token: the UI keeps one while the worker submits with the other.
|
|
let worker = Cancel::new();
|
|
let ui = worker.clone();
|
|
assert!(ui.cancel());
|
|
assert!(worker.start_sending().is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn a_cancelled_job_never_starts_lp() {
|
|
// Cancelled before it was sent (as the UI does while a job waits): `lp` never starts, so
|
|
// nothing reaches the spooler, and the caller learns why. The spooler given can't be started,
|
|
// so any attempt to start it would answer with that failure instead.
|
|
let cancel = Cancel::new();
|
|
assert!(cancel.cancel());
|
|
let lp = std::process::Command::new(std::env::temp_dir().join("craft-print-no-such-lp").join("lp"));
|
|
let sent = spool::submit_via(lp, b"%PDF-1.7 cancelled", &Job::default(), &cancel);
|
|
assert_eq!(sent, Err(Error::Spool(CANCELLED_MESSAGE.into())), "lp was never started");
|
|
// The control: the same spooler, not cancelled, is tried and fails to start.
|
|
let lp = std::process::Command::new(std::env::temp_dir().join("craft-print-no-such-lp").join("lp"));
|
|
let tried = spool::submit_via(lp, b"%PDF-1.7", &Job::default(), &Cancel::new());
|
|
assert!(matches!(tried, Err(Error::Spool(ref m)) if m.starts_with("the print spooler is not available")), "{tried:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn a_refused_print_job_reports_the_spoolers_message() {
|
|
// `lp` refuses an unknown printer without reading the job; the user sees why, not a broken pipe.
|
|
let pdf = vec![b'%'; 3 << 20];
|
|
let err = spool::submit_via(stand_in_lp("refuse".as_ref()), &pdf, &Job::default(), &Cancel::new());
|
|
assert_eq!(err, Err(Error::Spool("lp: The printer or class does not exist.".into())));
|
|
}
|
|
|
|
#[test]
|
|
fn the_spoolers_reply_drops_the_file_count_of_a_job_sent_on_stdin() {
|
|
assert_eq!(
|
|
spool::job_message(
|
|
b"request id is Office_Laser-12 (0 file(s))
|
|
"
|
|
),
|
|
"request id is Office_Laser-12"
|
|
);
|
|
assert_eq!(
|
|
spool::job_message(
|
|
b"request id is Office_Laser-13 (1 file(s))
|
|
"
|
|
),
|
|
"request id is Office_Laser-13 (1 file(s))"
|
|
);
|
|
assert_eq!(spool::job_message(b""), "");
|
|
}
|
|
|
|
#[test]
|
|
fn windows_printers_carry_one_default_and_keep_their_names() {
|
|
// Windows marks the system default with a `*` no queue name may contain; names hold spaces,
|
|
// parentheses and non-ASCII letters, and the lines end with CRLF.
|
|
let printers = parse_windows_printers("*Microsoft Print to PDF\r\nHP LaserJet 400 (Copy 2)\r\nКопир Canon iR\r\n\r\n*XPS via USB\r\n");
|
|
assert_eq!(printers[0], Printer { name: "Microsoft Print to PDF".into(), description: String::new(), default: true, two_sided: None });
|
|
assert_eq!(printers[1], Printer { name: "HP LaserJet 400 (Copy 2)".into(), description: String::new(), default: false, two_sided: None });
|
|
assert_eq!(printers[2], Printer { name: "Копир Canon iR".into(), description: String::new(), default: false, two_sided: None });
|
|
// A spooler marking two defaults: the first still wins, so the dialog has one to preselect.
|
|
assert_eq!(printers[3], Printer { name: "XPS via USB".into(), description: String::new(), default: false, two_sided: None });
|
|
assert_eq!(parse_windows_printers("\r\n"), Vec::new());
|
|
let sided = parse_windows_printers("*Office PCL\t2\r\nOffice PS\t1\r\nTab\tName\t2\r\n");
|
|
assert_eq!(sided[0], Printer { name: "Office PCL".into(), description: String::new(), default: true, two_sided: Some(true) });
|
|
assert_eq!(sided[1], Printer { name: "Office PS".into(), description: String::new(), default: false, two_sided: Some(false) });
|
|
assert_eq!(sided[2], Printer { name: "Tab\tName".into(), description: String::new(), default: false, two_sided: Some(true) });
|
|
}
|
|
|
|
/// A PPD shaped like a Fiery's: installable options, multi-line PostScript in the choices, Latin-1
|
|
/// labels, options the Print dialog sets itself.
|
|
const FIERY_PPD: &[u8] = b"*PPD-Adobe: \"4.3\"\n\
|
|
*LanguageEncoding: ISOLatin1\n\
|
|
*OpenGroup: InstallableOptions/Installable Options\n\
|
|
*OpenUI *EFFinisher/Finisher option: PickOne\n\
|
|
*DefaultEFFinisher: False\n\
|
|
*EFFinisher False/Not installed: \"\"\n\
|
|
*EFFinisher SingleStapler/Single stapler: \"\"\n\
|
|
*CloseUI: *EFFinisher\n\
|
|
*CloseGroup: InstallableOptions\n\
|
|
*OpenGroup: FPPaperSource/Media\n\
|
|
*OpenUI *InputSlot/Paper tray: PickOne\n\
|
|
*OrderDependency: 20.0 AnySetup *InputSlot\n\
|
|
*DefaultInputSlot: AutoSelect\n\
|
|
*InputSlot AutoSelect/Auto tray select: \"\n\
|
|
userdict /XJXEFIsetpageproperties known\n\
|
|
{ << /XJXsettrayselV2 [ 7 ] >> XJXEFIsetpageproperties } if\"\n\
|
|
*End\n\
|
|
*InputSlot ManualFeed/Bypass tray: \"\n\
|
|
{ pop 2 XJXsettrayselV2 } if\"\n\
|
|
*End\n\
|
|
*InputSlot Tray2/Tray 2: \"\"\n\
|
|
*fr.InputSlot Tray2/Bac 2: \"\"\n\
|
|
*CloseUI: *InputSlot\n\
|
|
*OpenUI *EFMediaType/Paper type: PickOne\n\
|
|
*DefaultEFMediaType: Plain\n\
|
|
*EFMediaType Plain/Plain: \"\"\n\
|
|
*EFMediaType Heavy1/Thick 1 (106\xad163 g/m\xb2): \"\"\n\
|
|
*CloseUI: *EFMediaType\n\
|
|
*CloseGroup: FPPaperSource\n\
|
|
*OpenUI *PageSize/Page size: PickOne\n\
|
|
*DefaultPageSize: A4\n\
|
|
*PageSize A4/A4: \"\"\n\
|
|
*CloseUI: *PageSize\n\
|
|
*OpenUI *EFRaster/Print queue action: PickOne\n\
|
|
*DefaultEFRaster: Bogus\n\
|
|
*EFRaster False/Print: \"\"\n\
|
|
*EFRaster Hold: \"\"\n\
|
|
*CloseUI: *EFRaster\n";
|
|
|
|
#[test]
|
|
fn printer_options_come_from_the_ppd() {
|
|
let options = spool::parse_ppd(&spool::ppd_text(FIERY_PPD));
|
|
let keys: Vec<&str> = options.iter().map(|o| o.key.as_str()).collect();
|
|
assert_eq!(keys, ["InputSlot", "EFMediaType", "EFRaster"], "no installable options, no PageSize");
|
|
let tray = &options[0];
|
|
assert_eq!((tray.label.as_str(), tray.group.as_str(), tray.default.as_str()), ("Paper tray", "Media", "AutoSelect"));
|
|
assert_eq!(
|
|
tray.choices,
|
|
[("AutoSelect".into(), "Auto tray select".into()), ("ManualFeed".into(), "Bypass tray".into()), ("Tray2".into(), "Tray 2".into())],
|
|
"PostScript lines and translations (*fr.InputSlot) are not choices"
|
|
);
|
|
assert_eq!(options[1].choices[1].1, "Thick 1 (106\u{ad}163 g/m²)", "Latin-1 labels");
|
|
// A default that isn't one of the choices falls back to the first; a choice without a label shows its keyword.
|
|
assert_eq!((options[2].default.as_str(), options[2].choices[1].1.as_str()), ("False", "Hold"));
|
|
assert!(spool::parse_ppd("*OpenUI *Broken\n*Broken A/B\n").is_empty(), "unclosed or malformed blocks are skipped");
|
|
}
|
|
|
|
#[test]
|
|
fn ppd_labels_are_capped_before_every_option_copies_its_group() {
|
|
let long = "G".repeat(100_000);
|
|
let mut ppd = format!("*OpenGroup: Big/{long}\n");
|
|
for i in 0..300 {
|
|
ppd += &format!("*OpenUI *Opt{i}/{long}: PickOne\n*Opt{i} A/{long}: \"\"\n*CloseUI: *Opt{i}\n");
|
|
}
|
|
let options = spool::parse_ppd(&ppd);
|
|
assert_eq!(options.len(), 300);
|
|
assert!(options.iter().all(|o| o.group.len() <= 256 && o.label.len() <= 256 && o.choices[0].1.len() <= 256));
|
|
assert_eq!(options[0].group, "G".repeat(256));
|
|
}
|
|
|
|
#[test]
|
|
fn many_driverless_destinations_merge_in_linear_time() {
|
|
let names: String = (0..200_000).map(|i| format!("dest_{i}\n")).collect();
|
|
let started = std::time::Instant::now();
|
|
let printers = spool::printers_parsed("printer Office is idle.\n", Some(&format!("Office\n{names}dest_7\n")));
|
|
assert!(started.elapsed() < std::time::Duration::from_secs(10), "{:?}", started.elapsed());
|
|
assert_eq!(printers.len(), 200_001, "each destination once, queues first");
|
|
assert_eq!(printers[0].name, "Office");
|
|
}
|
|
|
|
#[test]
|
|
fn printer_option_defaults_and_job_arguments() {
|
|
let current =
|
|
spool::parse_lpoptions("InputSlot/Paper tray: AutoSelect *Tray2 ManualFeed\nEFRaster/Print queue action: *False Hold\nbroken line\n");
|
|
assert_eq!(current, [("InputSlot".to_string(), "Tray2".to_string()), ("EFRaster".to_string(), "False".to_string())]);
|
|
let job = Job {
|
|
options: vec![
|
|
("InputSlot".into(), "Tray2".into()),
|
|
("EFMediaType".into(), "Heavy1".into()),
|
|
("Duplex".into(), "DuplexTumble".into()),
|
|
("Bad Key".into(), "x".into()),
|
|
("EFRaster".into(), "a=b".into()),
|
|
],
|
|
..Job::default()
|
|
};
|
|
let args = lp_args(&job).join(" ");
|
|
assert!(args.ends_with("-o fit-to-page=false -o InputSlot=Tray2 -o EFMediaType=Heavy1"), "{args}");
|
|
assert!(spool::printer_options("../../etc/passwd").is_empty(), "not a queue name");
|
|
}
|
|
|
|
#[test]
|
|
fn a_job_naming_no_tray_asks_macos_for_auto_select() {
|
|
// storytold/pdfcraft#3752: CUPS gives a job that names no tray the PPD's default tray. A Xerox
|
|
// queue defaulting to an empty Tray 1 kept every job waiting at the printer ("Tray 1 is
|
|
// empty") while CUPS reported it completed; Preview printed, because the macOS Print dialog's
|
|
// Paper Feed: Auto Select sends `AP_D_InputSlot` empty, which drops the PPD's default tray.
|
|
let auto = "-o AP_D_InputSlot=";
|
|
let args = |options: &[(&str, &str)], apple: bool| {
|
|
let job = Job { options: options.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(), ..Job::default() };
|
|
spool::lp_args_for(&job, apple).join(" ")
|
|
};
|
|
assert_eq!(args(&[], true), format!("-n 1 -t Craft -o collate=true -o fit-to-page=false {auto}"));
|
|
assert!(args(&[("EFMediaType", "Heavy1")], true).ends_with(&format!("-o EFMediaType=Heavy1 {auto}")), "other options are not a tray");
|
|
// A tray the job names is the tray: CUPS would prefer it to Auto Select anyway.
|
|
assert!(args(&[("InputSlot", "Tray3")], true).ends_with("-o fit-to-page=false -o InputSlot=Tray3"));
|
|
assert!(!args(&[("inputslot", "Tray3")], true).contains("AP_D_InputSlot"), "CUPS option names ignore case");
|
|
assert_eq!(args(&[("AP_D_InputSlot", "Tray3")], true).matches("AP_D_InputSlot").count(), 1, "the caller's own is not doubled");
|
|
// Auto Select is no tray at all, and neither is a value lp can't be given.
|
|
for value in [AUTO_SELECT, "Tray 3"] {
|
|
let a = args(&[("InputSlot", value)], true);
|
|
assert!(a.ends_with(auto) && !a.contains("-o InputSlot="), "{value:?}: {a}");
|
|
}
|
|
// `AP_D_InputSlot` is Apple's: other CUPS gets nothing new.
|
|
assert!(!args(&[], false).contains("AP_D_InputSlot"));
|
|
assert!(!args(&[("InputSlot", AUTO_SELECT)], false).contains("InputSlot"));
|
|
assert_eq!(lp_args(&Job::default()), spool::lp_args_for(&Job::default(), cfg!(target_os = "macos")));
|
|
}
|
|
|
|
#[test]
|
|
fn macos_offers_auto_select_where_the_driver_would_pick_its_default_tray() {
|
|
// The Fiery PPD's own default tray is AutoSelect; its trays are AutoSelect, ManualFeed, Tray2.
|
|
let options = || spool::parse_ppd(&spool::ppd_text(FIERY_PPD));
|
|
let auto = (AUTO_SELECT.to_string(), "Auto Select".to_string());
|
|
let applied = |current: &str, apple: bool| {
|
|
let mut o = options();
|
|
spool::apply_queue_defaults_for(&mut o, &spool::parse_lpoptions(current), apple);
|
|
o
|
|
};
|
|
// The queue's tray is the driver's default: the job names none and lp_args asks for Auto
|
|
// Select, so the dialog shows that, first, and Tray 2 or the driver's default stays a choice.
|
|
for current in ["InputSlot/Paper tray: *AutoSelect ManualFeed Tray2\n", ""] {
|
|
let o = applied(current, true);
|
|
assert_eq!((o[0].default.as_str(), o[0].choices.first()), (AUTO_SELECT, Some(&auto)), "{current:?}");
|
|
assert_eq!(o[0].choices.len(), 4);
|
|
}
|
|
// The user's own default tray (`lpoptions -o InputSlot=Tray2`): lp adds it to every job and it
|
|
// beats Auto Select, so Auto Select would be a choice that does nothing.
|
|
let o = applied("InputSlot/Paper tray: AutoSelect *Tray2 ManualFeed\nEFRaster/Print queue action: *Hold False\n", true);
|
|
assert_eq!(o[0].default, "Tray2");
|
|
assert!(!o[0].choices.contains(&auto));
|
|
assert_eq!(o[2].default, "Hold", "the other options take the queue's defaults as before");
|
|
// A current choice the driver doesn't offer is skipped, so the driver's default stands.
|
|
assert_eq!(applied("InputSlot/Paper tray: *Tray9\n", true)[0].default, AUTO_SELECT);
|
|
// CUPS reads option and choice names without regard to case: a user default spelled otherwise
|
|
// is still Tray 2, kept in the driver's spelling, and still beats Auto Select.
|
|
let o = applied("inputslot/Paper tray: AutoSelect *tray2\nefraster/Print queue action: *HOLD\n", true);
|
|
assert_eq!((o[0].default.as_str(), o[0].choices.contains(&auto), o[2].default.as_str()), ("Tray2", false, "Hold"));
|
|
// Other platforms: the queue's defaults, nothing more.
|
|
assert_eq!(applied("", false)[0], options()[0]);
|
|
assert_eq!(applied("InputSlot/Paper tray: AutoSelect *Tray2\n", false)[0].default, "Tray2");
|
|
// Only the tray gets it, and a driver without one gets none.
|
|
let mut no_tray = spool::parse_ppd(
|
|
"*OpenUI *EFMediaType/Paper type: PickOne\n*DefaultEFMediaType: Plain\n*EFMediaType Plain/Plain: \"\"\n*CloseUI: *EFMediaType\n",
|
|
);
|
|
spool::apply_queue_defaults_for(&mut no_tray, &[], true);
|
|
assert_eq!(no_tray[0].choices, [("Plain".to_string(), "Plain".to_string())]);
|
|
}
|
|
|
|
#[test]
|
|
fn command_previews_do_not_require_pdf_files() {
|
|
let job = Job { printer: Some("Office (east)".into()), copies: 2, ..Job::default() };
|
|
let preview = command_preview(&job).unwrap();
|
|
if backend() == Some(Backend::Cups) {
|
|
assert_eq!(preview[0], "lp");
|
|
assert_eq!(preview[1..], lp_args(&job));
|
|
} else {
|
|
assert_eq!(
|
|
preview,
|
|
["xps-print", "printer=Office (east)", "copies=2", "collate=true", "duplex=(queue default)", "scaling=exact", "color=(driver default)"]
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn queue_duplex_defaults_and_explicit_scaling_reach_the_spooler() {
|
|
let default_args = lp_args(&Job::default());
|
|
assert!(!default_args.iter().any(|a| a.starts_with("sides=")));
|
|
let simplex = lp_args(&Job { duplex: Some(Duplex::Off), ..Job::default() });
|
|
assert!(simplex.iter().any(|a| a == "sides=one-sided"));
|
|
let shrink = lp_args(&Job { scaling: Scaling::ShrinkToFit, ..Job::default() });
|
|
assert!(shrink.iter().any(|a| a == "print-scaling=auto-fit"));
|
|
assert!(!shrink.iter().any(|a| a.starts_with("fit-to-page=")));
|
|
}
|