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.
No files matched your search
@@ -0,0 +1,171 @@
|
||||
//! 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());
|
||||
}
|
||||
|
After Width: | Height: | Size: 85 B |
|
After Width: | Height: | Size: 136 B |
|
After Width: | Height: | Size: 129 B |
|
After Width: | Height: | Size: 128 B |
|
After Width: | Height: | Size: 131 B |
|
After Width: | Height: | Size: 129 B |
|
After Width: | Height: | Size: 516 B |
|
After Width: | Height: | Size: 574 B |
|
After Width: | Height: | Size: 614 B |
|
After Width: | Height: | Size: 651 B |
|
After Width: | Height: | Size: 506 B |
@@ -0,0 +1,60 @@
|
||||
# name model label images options... (generated by tools/golden.py)
|
||||
label_L0001_x1 QL-820NWB 23x23 label_L0001.png cut=True
|
||||
label_L0001_x2 QL-820NWB 23x23 label_L0001.png,label_L0001.png cut=True
|
||||
label_L0006_x1 QL-820NWB 23x23 label_L0006.png cut=True
|
||||
label_L0006_x2 QL-820NWB 23x23 label_L0006.png,label_L0006.png cut=True
|
||||
label_L0123_x1 QL-820NWB 23x23 label_L0123.png cut=True
|
||||
label_L0123_x2 QL-820NWB 23x23 label_L0123.png,label_L0123.png cut=True
|
||||
label_L12345_x1 QL-820NWB 23x23 label_L12345.png cut=True
|
||||
label_L12345_x2 QL-820NWB 23x23 label_L12345.png,label_L12345.png cut=True
|
||||
label_L9_x1 QL-820NWB 23x23 label_L9.png cut=True
|
||||
label_L9_x2 QL-820NWB 23x23 label_L9.png,label_L9.png cut=True
|
||||
white_202_x1 QL-820NWB 23x23 white_202.png cut=True
|
||||
white_202_x2 QL-820NWB 23x23 white_202.png,white_202.png cut=True
|
||||
black_202_x1 QL-820NWB 23x23 black_202.png cut=True
|
||||
black_202_x2 QL-820NWB 23x23 black_202.png,black_202.png cut=True
|
||||
checker_202_x1 QL-820NWB 23x23 checker_202.png cut=True
|
||||
checker_202_x2 QL-820NWB 23x23 checker_202.png,checker_202.png cut=True
|
||||
corner_tl_x1 QL-820NWB 23x23 corner_tl.png cut=True
|
||||
corner_tl_x2 QL-820NWB 23x23 corner_tl.png,corner_tl.png cut=True
|
||||
corner_tr_x1 QL-820NWB 23x23 corner_tr.png cut=True
|
||||
corner_tr_x2 QL-820NWB 23x23 corner_tr.png,corner_tr.png cut=True
|
||||
corner_bl_x1 QL-820NWB 23x23 corner_bl.png cut=True
|
||||
corner_bl_x2 QL-820NWB 23x23 corner_bl.png,corner_bl.png cut=True
|
||||
corner_br_x1 QL-820NWB 23x23 corner_br.png cut=True
|
||||
corner_br_x2 QL-820NWB 23x23 corner_br.png,corner_br.png cut=True
|
||||
random_202_x1 QL-820NWB 23x23 random_202.png cut=True
|
||||
random_202_x2 QL-820NWB 23x23 random_202.png,random_202.png cut=True
|
||||
label_L0006_nocut QL-820NWB 23x23 label_L0006.png cut=False
|
||||
label_L0006_compress_x2 QL-820NWB 23x23 label_L0006.png,label_L0006.png compress=True
|
||||
random_202_compress QL-820NWB 23x23 random_202.png compress=True
|
||||
checker_202_compress QL-820NWB 23x23 checker_202.png compress=True
|
||||
black_202_compress QL-820NWB 23x23 black_202.png compress=True
|
||||
white_202_compress QL-820NWB 23x23 white_202.png compress=True
|
||||
corner_tl_rot0 QL-820NWB 23x23 corner_tl.png rotate=0
|
||||
corner_tl_rot90 QL-820NWB 23x23 corner_tl.png rotate=90
|
||||
corner_tl_rot180 QL-820NWB 23x23 corner_tl.png rotate=180
|
||||
corner_tl_rot270 QL-820NWB 23x23 corner_tl.png rotate=270
|
||||
corner_br_rot0 QL-820NWB 23x23 corner_br.png rotate=0
|
||||
random_202_thr100 QL-820NWB 23x23 random_202.png threshold=100
|
||||
random_202_thr0 QL-820NWB 23x23 random_202.png threshold=0
|
||||
random_202_thr50 QL-820NWB 23x23 random_202.png threshold=50
|
||||
random_202_lq QL-820NWB 23x23 random_202.png hq=False
|
||||
random_202_dither QL-820NWB 23x23 random_202.png dither=True
|
||||
random_202_dither_thr100 QL-820NWB 23x23 random_202.png dither=True threshold=100
|
||||
multi_labels QL-820NWB 23x23 label_L0001.png,label_L0006.png,label_L12345.png cut=True
|
||||
random_404_dpi600 QL-820NWB 23x23 random_404.png dpi_600=True
|
||||
random_404_dpi600_compress QL-820NWB 23x23 random_404.png dpi_600=True compress=True
|
||||
endless62_696 QL-820NWB 62 random_696x300.png
|
||||
endless62_resize500 QL-820NWB 62 random_500x200.png
|
||||
endless62_rot90 QL-820NWB 62 random_300x696.png rotate=90
|
||||
endless62_dpi600 QL-820NWB 62 random_1392x150.png dpi_600=True
|
||||
endless29_306 QL-820NWB 29 random_306x300.png compress=True
|
||||
diecut62x29_auto_rotate QL-820NWB 62x29 random_271x696.png
|
||||
round_d24 QL-820NWB d24 random_236.png
|
||||
ql1100_102 QL-1100 102 sparse_1164x301.png compress=True
|
||||
ql1100_62 QL-1100 62 sparse_696x301.png compress=True
|
||||
ql700_62_compress_ignored QL-700 62 random_696x300.png compress=True
|
||||
ql500_29 QL-500 29 random_306x300.png
|
||||
ql570_62x29 QL-570 62x29 random_696x271.png cut=False
|
||||
ql800_23x23_x2 QL-800 23x23 label_L0006.png,label_L0006.png compress=True
|
||||
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 126 B |
@@ -0,0 +1,187 @@
|
||||
//! `send_network` against a fake printer on 127.0.0.1.
|
||||
//!
|
||||
//! The Python network backend never reads from the socket (helpers.send
|
||||
//! returns right after the write, even with `blocking=True`), so the fake
|
||||
//! printers here check: the exact bytes arrive, the client closes the
|
||||
//! connection on its own (EOF) right after writing, and it does not wait for
|
||||
//! the status replies a real QL printer sends.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::net::{Shutdown, TcpListener};
|
||||
use std::path::Path;
|
||||
use std::sync::mpsc;
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use brother_ql::{Error, Outcome, SendStatus, send_network};
|
||||
|
||||
fn job() -> Vec<u8> {
|
||||
// a real two-label job for L0006 as produced by Python brother_ql
|
||||
let p = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/golden/label_L0006_x2.bin");
|
||||
std::fs::read(p).expect("golden data")
|
||||
}
|
||||
|
||||
const SENT: SendStatus = SendStatus {
|
||||
instructions_sent: true,
|
||||
outcome: Outcome::Sent,
|
||||
did_print: false,
|
||||
ready_for_next_job: false,
|
||||
};
|
||||
|
||||
/// 32-byte QL status reply (80 20 42 ...): model QL-820NWB (0x41), 23 mm
|
||||
/// die-cut media, given status/phase type.
|
||||
fn status_reply(status_type: u8, phase_type: u8) -> [u8; 32] {
|
||||
let mut r = [0u8; 32];
|
||||
r[0] = 0x80;
|
||||
r[1] = 0x20;
|
||||
r[2] = 0x42;
|
||||
r[3] = 0x34;
|
||||
r[4] = 0x41;
|
||||
r[5] = 0x30;
|
||||
r[10] = 23; // media width
|
||||
r[11] = 0x0B; // die-cut
|
||||
r[17] = 23; // media length
|
||||
r[18] = status_type;
|
||||
r[19] = phase_type;
|
||||
r
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sends_exact_bytes_and_closes() {
|
||||
let data = job();
|
||||
for prefix in ["tcp://", ""] {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let server = thread::spawn(move || {
|
||||
let (mut s, _) = listener.accept().unwrap();
|
||||
let mut got = Vec::new();
|
||||
s.read_to_end(&mut got).unwrap(); // returns only once the client closed
|
||||
got
|
||||
});
|
||||
let status = send_network(
|
||||
&format!("{prefix}127.0.0.1:{port}"),
|
||||
&data,
|
||||
true,
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(status, SENT);
|
||||
let got = server.join().unwrap();
|
||||
assert_eq!(got.len(), data.len());
|
||||
assert!(got == data, "received bytes differ");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocking_does_not_wait_for_status_replies() {
|
||||
let data = job();
|
||||
let n = data.len();
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let server = thread::spawn(move || {
|
||||
let (mut s, _) = listener.accept().unwrap();
|
||||
let mut got = vec![0u8; n];
|
||||
s.read_exact(&mut got).unwrap();
|
||||
// A real printer answers ESC i S and later reports "printing
|
||||
// completed" (0x01) and "phase change: waiting to receive" (0x06/0x00).
|
||||
// Hold them back for a while: the client must not wait for them.
|
||||
let eof = {
|
||||
let mut buf = [0u8; 1];
|
||||
s.set_read_timeout(Some(Duration::from_secs(3))).unwrap();
|
||||
matches!(s.read(&mut buf), Ok(0))
|
||||
};
|
||||
tx.send(()).unwrap();
|
||||
let _ = s.write_all(&status_reply(0x01, 0x00));
|
||||
let _ = s.write_all(&status_reply(0x06, 0x00));
|
||||
let _ = s.shutdown(Shutdown::Both);
|
||||
(got, eof)
|
||||
});
|
||||
let t0 = Instant::now();
|
||||
let status = send_network(
|
||||
&format!("tcp://127.0.0.1:{port}"),
|
||||
&data,
|
||||
true,
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
.unwrap();
|
||||
let elapsed = t0.elapsed();
|
||||
assert_eq!(status, SENT, "network backend never reports printed/ready");
|
||||
let (got, eof) = server.join().unwrap();
|
||||
rx.recv().unwrap();
|
||||
assert!(got == data);
|
||||
assert!(eof, "client did not close the connection after writing");
|
||||
assert!(
|
||||
elapsed < Duration::from_secs(2),
|
||||
"send_network waited {elapsed:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_blocking_is_identical() {
|
||||
let data = b"\x1B\x40hello".to_vec();
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let server = thread::spawn(move || {
|
||||
let (mut s, _) = listener.accept().unwrap();
|
||||
let mut got = Vec::new();
|
||||
s.read_to_end(&mut got).unwrap();
|
||||
got
|
||||
});
|
||||
let status = send_network(
|
||||
&format!("127.0.0.1:{port}"),
|
||||
&data,
|
||||
false,
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(status, SENT);
|
||||
assert_eq!(server.join().unwrap(), data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connection_refused_is_an_error() {
|
||||
let port = {
|
||||
let l = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
l.local_addr().unwrap().port()
|
||||
};
|
||||
let err = send_network(
|
||||
&format!("tcp://127.0.0.1:{port}"),
|
||||
b"x",
|
||||
true,
|
||||
Duration::from_secs(2),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, Error::Connect { .. }), "{err}");
|
||||
let err = send_network("tcp://", b"x", true, Duration::from_secs(2)).unwrap_err();
|
||||
assert!(matches!(err, Error::InvalidAddress(_)), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_times_out_when_printer_stalls() {
|
||||
// The fake printer accepts but never reads, so the socket buffers fill up.
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let (done_tx, done_rx) = mpsc::channel::<()>();
|
||||
let server = thread::spawn(move || {
|
||||
let (s, _) = listener.accept().unwrap();
|
||||
let _ = done_rx.recv_timeout(Duration::from_secs(30));
|
||||
drop(s);
|
||||
});
|
||||
let data = vec![0u8; 64 << 20];
|
||||
let t0 = Instant::now();
|
||||
let res = send_network(
|
||||
&format!("127.0.0.1:{port}"),
|
||||
&data,
|
||||
true,
|
||||
Duration::from_millis(500),
|
||||
);
|
||||
let elapsed = t0.elapsed();
|
||||
done_tx.send(()).unwrap();
|
||||
server.join().unwrap();
|
||||
match res {
|
||||
Err(Error::Write(e)) => assert_eq!(e.kind(), std::io::ErrorKind::TimedOut, "{e}"),
|
||||
other => panic!("expected a write timeout, got {other:?}"),
|
||||
}
|
||||
assert!(elapsed < Duration::from_secs(5), "timeout took {elapsed:?}");
|
||||
}
|
||||