smart-package-tracker 0.4.0

Generate package tracking IDs, render them as Code 128 barcodes (PNG and SVG), and read them back out of images
Documentation
//! End-to-end scanning tests.
//!
//! Every case here starts from the crate's own renderer and ends at a decoded
//! payload, because that is the loop a caller actually runs: print a label,
//! read it back. Nothing asserts on the scanner's internals — a change that
//! keeps these passing has kept the promise the README makes.
//!
//! The images are deliberately degraded in the ways real ones are: rotated a
//! quarter turn, inverted, buried in a larger scene, speckled, blurred. A
//! scanner that only reads its own pristine output has not been tested.

#![cfg(all(feature = "scan", feature = "code128", feature = "png"))]

use smart_package_tracker::render::{Color, Length, QuietZone};
use smart_package_tracker::scan::{scan_png, GrayImage, Scanner};
use smart_package_tracker::{Barcode, Error, RenderOptions, SymbologyKind, TrackingId};

/// Render a payload to PNG with the crate's defaults.
fn render(payload: &str) -> Vec<u8> {
    Barcode::code128(payload)
        .unwrap()
        .to_png(&RenderOptions::default())
        .unwrap()
}

/// Render, then decode straight back to luminance.
fn render_gray(payload: &str, options: &RenderOptions) -> GrayImage {
    let png = Barcode::code128(payload).unwrap().to_png(options).unwrap();
    GrayImage::from_png(&png).unwrap()
}

/// A deterministic noise source. A test that depends on the seed of the day
/// is a test that fails in CI and not on the desk it was written at.
struct Lcg(u64);

impl Lcg {
    fn next(&mut self) -> u32 {
        self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1);
        (self.0 >> 33) as u32
    }
}

#[test]
fn a_rendered_label_reads_back() {
    let png = render("PKG-9ED9285C");
    let found = scan_png(&png).unwrap();
    assert_eq!(found.payload(), "PKG-9ED9285C");
    assert_eq!(found.kind(), SymbologyKind::Code128);
}

#[test]
fn reads_back_at_every_module_width_a_printer_would_use() {
    // 13 mil at 300 dpi is the shipping-label default; the extremes bracket
    // what a thermal printer and a high-resolution press produce.
    for (mils, dpi) in [
        (7.5, 203.0),
        (10.0, 203.0),
        (13.0, 300.0),
        (13.0, 600.0),
        (20.0, 600.0),
        (40.0, 300.0),
    ] {
        let options = RenderOptions::builder()
            .module_width(Length::Mils(mils))
            .dpi(dpi as u32)
            .build()
            .unwrap();
        let image = render_gray("PKG-9ED9285C", &options);
        let found = Scanner::new()
            .scan(&image)
            .unwrap_or_else(|e| panic!("{mils} mil at {dpi} dpi: {e}"));
        assert_eq!(found.payload(), "PKG-9ED9285C");
    }
}

#[test]
fn a_one_pixel_module_still_reads() {
    // The narrowest a symbol can legally be rendered. There is no slack left
    // for a resampling error here.
    let options = RenderOptions::builder()
        .module_width(Length::Px(1.0))
        .height(Length::Px(40.0))
        .human_readable(false)
        .build()
        .unwrap();
    let image = render_gray("PKG-9ED9285C", &options);
    assert_eq!(
        Scanner::new().scan(&image).unwrap().payload(),
        "PKG-9ED9285C"
    );
}

#[test]
fn the_human_readable_line_does_not_fool_the_scanner() {
    // The HRI text is a row of dark runs sitting under the symbol. A scanner
    // that took the first line with bars on it would read the text as a
    // barcode, or fail on it and give up.
    let options = RenderOptions::builder()
        .human_readable(true)
        .build()
        .unwrap();
    let image = render_gray("PKG-9ED9285C", &options);
    assert_eq!(
        Scanner::new().scan(&image).unwrap().payload(),
        "PKG-9ED9285C"
    );
}

#[test]
fn reads_a_label_printed_on_its_side() {
    // Labels get applied rotated a quarter turn as often as not.
    let image = render_gray("PKG-9ED9285C", &RenderOptions::default());
    let (w, h) = (image.width(), image.height());
    let mut rotated = Vec::with_capacity((w * h) as usize);
    for x in 0..w {
        for y in (0..h).rev() {
            rotated.push(image.pixel(x, y));
        }
    }
    let rotated = GrayImage::from_luma(h, w, rotated).unwrap();
    assert_eq!(
        Scanner::new().scan(&rotated).unwrap().payload(),
        "PKG-9ED9285C"
    );
}

#[test]
fn reads_light_bars_on_a_dark_background() {
    let options = RenderOptions::builder()
        .colors(Color::WHITE, Color::BLACK)
        .build()
        .unwrap();
    let image = render_gray("PKG-9ED9285C", &options);
    assert_eq!(
        Scanner::new().scan(&image).unwrap().payload(),
        "PKG-9ED9285C"
    );
}

#[test]
fn a_transparent_background_reads_as_paper() {
    // Black ink over nothing composites to black ink over white, not black on
    // black. Getting this wrong makes every transparent PNG unreadable.
    let options = RenderOptions::builder()
        .colors(Color::BLACK, Color::TRANSPARENT)
        .build()
        .unwrap();
    let png = Barcode::code128("PKG-9ED9285C")
        .unwrap()
        .to_png(&options)
        .unwrap();
    assert_eq!(scan_png(&png).unwrap().payload(), "PKG-9ED9285C");
}

#[test]
fn finds_a_barcode_placed_inside_a_larger_scene() {
    // The label pasted onto a page, off-centre, with a rule drawn across it —
    // roughly what a flatbed scan of a packing slip looks like.
    let label = render_gray("PKG-9ED9285C", &RenderOptions::default());
    let (lw, lh) = (label.width(), label.height());
    let (pw, ph) = (lw + 400, lh + 300);

    let mut page = vec![255u8; (pw * ph) as usize];
    let (ox, oy) = (137u32, 211u32);
    for y in 0..lh {
        for x in 0..lw {
            page[((y + oy) * pw + (x + ox)) as usize] = label.pixel(x, y);
        }
    }
    // A horizontal rule crossing the whole page, above the barcode.
    for x in 0..pw {
        page[(40 * pw + x) as usize] = 0;
    }

    let image = GrayImage::from_luma(pw, ph, page).unwrap();
    assert_eq!(
        Scanner::new().scan(&image).unwrap().payload(),
        "PKG-9ED9285C"
    );
}

#[test]
fn survives_speckle_and_blur() {
    let image = render_gray("PKG-9ED9285C", &RenderOptions::default());
    let (w, h) = (image.width(), image.height());

    // Salt and pepper on 2% of pixels, then a 3x3 box blur — a photocopy of a
    // photocopy.
    let mut rng = Lcg(0x5EED);
    let mut luma: Vec<u8> = image.luma().to_vec();
    for px in luma.iter_mut() {
        if rng.next() % 100 < 2 {
            *px = if rng.next() % 2 == 0 { 0 } else { 255 };
        }
    }
    let blurred: Vec<u8> = (0..h)
        .flat_map(|y| {
            let luma = &luma;
            (0..w).map(move |x| {
                let mut sum = 0u32;
                let mut n = 0u32;
                for dy in -1i64..=1 {
                    for dx in -1i64..=1 {
                        let (nx, ny) = (x as i64 + dx, y as i64 + dy);
                        if (0..w as i64).contains(&nx) && (0..h as i64).contains(&ny) {
                            sum += u32::from(luma[(ny as u32 * w + nx as u32) as usize]);
                            n += 1;
                        }
                    }
                }
                (sum / n) as u8
            })
        })
        .collect();

    let image = GrayImage::from_luma(w, h, blurred).unwrap();
    assert_eq!(
        Scanner::new().scan(&image).unwrap().payload(),
        "PKG-9ED9285C"
    );
}

#[test]
fn reads_a_symbol_cropped_flush_to_its_bars() {
    // No quiet zone at all. Conformance is about a scanner finding the symbol
    // in a scene; once it has been cropped for you, the margin is optional.
    let options = RenderOptions::builder()
        .quiet_zone(QuietZone::None)
        .human_readable(false)
        .build()
        .unwrap();
    let image = render_gray("PKG-9ED9285C", &options);
    assert_eq!(
        Scanner::new().scan(&image).unwrap().payload(),
        "PKG-9ED9285C"
    );
}

#[test]
fn reads_the_payload_shapes_a_carrier_actually_uses() {
    for payload in [
        "PKG-9ED9285C",
        "PKG-0123456789ABCDEF3",
        "1Z999AA10123456784",
        "9400111899223197428490",
        "00123456789012345675",
        "A",
        "Mixed 123 Case!",
    ] {
        let png = render(payload);
        let found = scan_png(&png).unwrap_or_else(|e| panic!("{payload}: {e}"));
        assert_eq!(found.payload(), payload);
    }
}

#[test]
fn a_generated_tracking_id_survives_the_whole_loop() {
    // Mint, print, scan, parse — the round trip the crate exists for.
    let id = TrackingId::generate().unwrap();
    let png = render(id.as_str());
    let scanned = scan_png(&png).unwrap();
    let recovered = TrackingId::parse(scanned.payload()).unwrap();
    assert_eq!(recovered, id);
}

#[test]
fn a_file_on_disk_reads_back() {
    let dir = std::env::temp_dir().join("spt-scan-test");
    std::fs::create_dir_all(&dir).unwrap();
    let path = dir.join("label.png");
    Barcode::code128("PKG-9ED9285C")
        .unwrap()
        .to_png_file(&path, &RenderOptions::default())
        .unwrap();

    let found = smart_package_tracker::scan::scan_png_file(&path).unwrap();
    assert_eq!(found.payload(), "PKG-9ED9285C");
    std::fs::remove_file(&path).ok();
}

#[test]
fn an_image_with_no_barcode_reports_that_rather_than_inventing_one() {
    // A false positive is worse than a miss: it routes a parcel to the wrong
    // place and nothing looks wrong. Blank paper, noise, and a QR code — none
    // of which this scanner can read — must all come back empty-handed.
    let blank = GrayImage::from_luma(400, 200, vec![255; 400 * 200]).unwrap();
    assert!(matches!(
        Scanner::new().scan(&blank),
        Err(Error::NoSymbolFound(_))
    ));

    let mut rng = Lcg(1);
    let noise: Vec<u8> = (0..400 * 200).map(|_| (rng.next() & 0xFF) as u8).collect();
    let noise = GrayImage::from_luma(400, 200, noise).unwrap();
    assert!(matches!(
        Scanner::new().scan(&noise),
        Err(Error::NoSymbolFound(_))
    ));
}

#[test]
#[cfg(feature = "qr")]
fn a_qr_code_is_a_clean_miss_not_a_wrong_answer() {
    // There is no QR decoder here. The scanner must say so rather than
    // producing whatever a line through a QR grid happens to look like.
    let png = Barcode::qr("PKG-9ED9285C")
        .unwrap()
        .to_png(&RenderOptions::default())
        .unwrap();
    assert!(matches!(scan_png(&png), Err(Error::NoSymbolFound(_))));
}

#[test]
fn malformed_image_bytes_are_an_error_not_a_panic() {
    assert!(matches!(
        scan_png(b"not a png"),
        Err(Error::InvalidImage(_))
    ));
    assert!(matches!(scan_png(&[]), Err(Error::InvalidImage(_))));
    // A valid PNG header followed by rubbish.
    let mut truncated = render("PKG-9ED9285C");
    truncated.truncate(40);
    assert!(matches!(scan_png(&truncated), Err(Error::InvalidImage(_))));
}