//! Byte-for-byte comparison against Python brother_ql output. //! //! The golden data is produced by `tools/golden.py` (brother_ql_next 0.12 + //! Pillow): each manifest line names the input PNG(s), model, label and //! `convert()` keyword arguments; `.bin` is what Python produced. use std::collections::HashMap; use std::fs::File; use std::path::{Path, PathBuf}; use brother_ql::{Bitmap, ConvertOptions, Label, Model, Rotate, convert}; fn golden_dir() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")) .join("tests") .join("golden") } fn load_png(path: &Path) -> Bitmap { let mut decoder = png::Decoder::new(File::open(path).unwrap()); decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16); let mut reader = decoder.read_info().unwrap(); let mut buf = vec![0; reader.output_buffer_size()]; let info = reader.next_frame(&mut buf).unwrap(); let channels = match info.color_type { png::ColorType::Grayscale => 1, png::ColorType::GrayscaleAlpha => 2, png::ColorType::Rgb => 3, png::ColorType::Rgba => 4, png::ColorType::Indexed => panic!("palette not expanded"), }; let (w, h) = (info.width, info.height); Bitmap::from_fn(w, h, |x, y| { let i = y as usize * info.line_size + x as usize * channels; buf[i] < 128 }) } fn parse_bool(v: &str) -> bool { match v { "True" | "true" | "1" => true, "False" | "false" | "0" => false, _ => panic!("bad bool {v}"), } } fn parse_options(tokens: &[&str]) -> ConvertOptions { let mut o = ConvertOptions::default(); for t in tokens { let (k, v) = t.split_once('=').unwrap(); match k { "cut" => o.cut = parse_bool(v), "compress" => o.compress = parse_bool(v), "dpi_600" => o.dpi_600 = parse_bool(v), "hq" => o.hq = parse_bool(v), "dither" => o.dither = parse_bool(v), "threshold" => o.threshold = v.parse().unwrap(), "rotate" => { o.rotate = match v { "auto" => Rotate::Auto, "0" => Rotate::R0, "90" => Rotate::R90, "180" => Rotate::R180, "270" => Rotate::R270, _ => panic!("bad rotate {v}"), } } _ => panic!("unknown option {k}"), } } o } fn first_difference(a: &[u8], b: &[u8]) -> Option { a.iter() .zip(b) .position(|(x, y)| x != y) .or_else(|| (a.len() != b.len()).then_some(a.len().min(b.len()))) } #[test] fn convert_matches_python_byte_for_byte() { let dir = golden_dir(); let manifest = std::fs::read_to_string(dir.join("manifest.txt")) .expect("golden data missing: run tools/golden.py"); let mut images: HashMap = HashMap::new(); let mut failures = Vec::new(); let mut passed = 0; let mut label_cases = 0; for line in manifest.lines() { let line = line.trim(); if line.is_empty() || line.starts_with('#') { continue; } let tokens: Vec<&str> = line.split_whitespace().collect(); let (name, model, label, pngs) = (tokens[0], tokens[1], tokens[2], tokens[3]); let opts = parse_options(&tokens[4..]); let bitmaps: Vec = pngs .split(',') .map(|p| { images .entry(p.to_string()) .or_insert_with(|| load_png(&dir.join(p))) .clone() }) .collect(); let model = Model::lookup(model).unwrap(); let label = Label::lookup(label).unwrap(); let expected = std::fs::read(dir.join(format!("{name}.bin"))).unwrap(); let actual = convert(model, label, &bitmaps, &opts) .unwrap_or_else(|e| panic!("{name}: convert failed: {e}")); match first_difference(&actual, &expected) { None => { passed += 1; if name.starts_with("label_L") { label_cases += 1; } } Some(at) => failures.push(format!( "{name}: differs at byte {at} (rust {} bytes, python {} bytes): rust {:02X?} python {:02X?}", actual.len(), expected.len(), &actual[at..(at + 8).min(actual.len())], &expected[at..(at + 8).min(expected.len())], )), } } println!("{passed} golden cases byte-identical ({label_cases} lamp-label cases)"); assert!( failures.is_empty(), "{} mismatches:\n{}", failures.len(), failures.join("\n") ); assert!(passed >= 50, "too few golden cases ({passed})"); } #[test] fn convert_rejects_like_python() { let model = Model::lookup("QL-820NWB").unwrap(); // d12 has 94 printable rows, below the QL-820NWB minimum of 150 let d12 = Label::lookup("d12").unwrap(); let err = convert( model, d12, &[Bitmap::new(94, 94)], &ConvertOptions::default(), ) .unwrap_err(); assert!( matches!( err, brother_ql::Error::InvalidRasterHeight { height: 94, .. } ), "{err}" ); let l = Label::lookup("23x23").unwrap(); let err = convert( model, l, &[Bitmap::new(202, 201)], &ConvertOptions::default(), ) .unwrap_err(); assert_eq!( err.to_string(), "bad image dimensions: 202x201, expecting 202x202" ); assert!(Model::lookup("QL-9").is_err()); assert!(Label::lookup("23").is_err()); }