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.
172 lines
5.6 KiB
Rust
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());
|
|
}
|