Files
lamp-testbench/crates/brother-ql/tests/golden.rs
T
grabowski 2a3f28001c Lamp testbench in Rust around the HPCS 6500
lampbench station / export / label / tm30 / calibrate replace the Python
lamp_station.py, lamp_export.py, label_printer.py, tm30.py and
calibration.py that lived in HPCS6500-py. The driver is a submodule
(HPCS6500/, like mppt-testbench). Workspace crates: tm30 (TM-30-18 port of
colour-science 0.4.7), brother-ql (raster protocol, byte-identical to
brother_ql) and lamp-label (QR + ID labels for the QL-820NWB).

Bundles are byte-identical to lamp_export.py's (golden tests); the station
runs end to end in a test against the simulated instrument.
2026-10-22 17:26:14 +07:00

172 lines
5.6 KiB
Rust

//! 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; `<name>.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<usize> {
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<String, Bitmap> = 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<Bitmap> = 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());
}