test: harden pallet card layout parsing
Some checks failed
Desktop Release / Bootstrap updater signing key (push) Has been cancelled
Desktop Release / Windows x64 NSIS (push) Has been cancelled
Desktop Release / Linux x64 DEB + AppImage (push) Has been cancelled
Desktop Release / Publish public desktop release (push) Has been cancelled

This commit is contained in:
Admin 2026-09-25 21:04:19 +00:00
parent 288ad0b3a5
commit ba534cfdb7

View File

@ -27,6 +27,99 @@ fn canonical(value: &str) -> String {
.to_ascii_uppercase()
}
fn find_ci(line: &str, needle: &str) -> Option<usize> {
line.to_ascii_uppercase().find(&needle.to_ascii_uppercase())
}
fn column_slice(line: &str, start: usize, end: Option<usize>) -> &str {
let start = start.min(line.len());
let end = end.unwrap_or(line.len()).min(line.len());
if start >= end {
""
} else {
line.get(start..end).unwrap_or("")
}
}
fn parse_layout_tables(raw_text: &str, snapshot: &mut PalletSnapshot) {
let lines: Vec<&str> = raw_text.lines().collect();
let right_col = lines
.iter()
.filter_map(|line| find_ci(line, "Pallet:").or_else(|| find_ci(line, "Grower")))
.min();
if let Some((index, header)) = lines.iter().enumerate().find(|(_, line)| {
find_ci(line, "ORIGIN").is_some()
&& find_ci(line, "VAR").is_some()
&& find_ci(line, "BRAND").is_some()
&& find_ci(line, "CLASS").is_some()
}) {
let start = find_ci(header, "ORIGIN").unwrap_or(0);
for line in lines.iter().skip(index + 1).take(4) {
let tokens: Vec<&str> = column_slice(line, start, right_col)
.split_whitespace()
.collect();
if tokens.len() >= 5
&& (2..=3).contains(&tokens[0].len())
&& tokens[0].chars().all(|ch| ch.is_ascii_alphabetic())
&& tokens[3].eq_ignore_ascii_case("CLASS")
{
snapshot.origin = clean(tokens[0]);
snapshot.variety = clean(tokens[1]);
snapshot.brand = clean(tokens[2]);
snapshot.class = clean(&format!("{} {}", tokens[3], tokens[4]));
break;
}
}
}
if let Some((index, header)) = lines.iter().enumerate().find(|(_, line)| {
find_ci(line, "BASE").is_some() && find_ci(line, "PACK").is_some()
}) {
let start = find_ci(header, "BASE").unwrap_or(0);
for line in lines.iter().skip(index + 1).take(4) {
let tokens: Vec<&str> = column_slice(line, start, right_col)
.split_whitespace()
.collect();
if tokens.len() >= 2
&& tokens[0].len() >= 2
&& tokens[0].chars().all(|ch| ch.is_ascii_alphabetic())
&& tokens[1].chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '-')
{
snapshot.base = clean(tokens[0]);
snapshot.pack = clean(tokens[1]);
break;
}
}
}
if let Some((index, header)) = lines.iter().enumerate().find(|(_, line)| {
find_ci(line, "Grower").is_some()
&& find_ci(line, "Packrun").is_some()
&& find_ci(line, "Size").is_some()
&& find_ci(line, "Qty").is_some()
}) {
let start = find_ci(header, "Grower").unwrap_or_else(|| right_col.unwrap_or(0));
for line in lines.iter().skip(index + 1).take(5) {
let tokens: Vec<&str> = column_slice(line, start, None)
.split_whitespace()
.collect();
if tokens.len() >= 4
&& tokens[0].chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '-')
&& tokens[1].contains('/')
&& tokens[2].chars().all(|ch| ch.is_ascii_digit())
&& tokens[3].chars().all(|ch| ch.is_ascii_digit())
{
snapshot.grower = clean(tokens[0]);
snapshot.packrun = clean(tokens[1]);
snapshot.size = clean(tokens[2]);
snapshot.qty = clean(tokens[3]);
break;
}
}
}
}
pub fn parse_pallet_snapshot(raw_text: &str, pallet_id: &str) -> PalletSnapshot {
let flat = raw_text.split_whitespace().collect::<Vec<_>>().join(" ");
let mut snapshot = PalletSnapshot {
@ -51,25 +144,31 @@ pub fn parse_pallet_snapshot(raw_text: &str, pallet_id: &str) -> PalletSnapshot
);
snapshot.total = capture(&flat, r"(?i)\bTotal\s+([0-9]{1,5})\b", 1);
if let Ok(regex) = Regex::new(
r"(?i)\bGrower\s+Packrun\s+Size\s+Qty\s+([A-Z0-9-]+)\s+([A-Z0-9/._-]+)\s+([0-9]{1,3})\s+([0-9]{1,5})\b",
) {
if let Some(captures) = regex.captures(&flat) {
snapshot.grower = captures.get(1).and_then(|m| clean(m.as_str()));
snapshot.packrun = captures.get(2).and_then(|m| clean(m.as_str()));
snapshot.size = captures.get(3).and_then(|m| clean(m.as_str()));
snapshot.qty = captures.get(4).and_then(|m| clean(m.as_str()));
parse_layout_tables(raw_text, &mut snapshot);
if snapshot.grower.is_none() {
if let Ok(regex) = Regex::new(
r"(?i)\bGrower\s+Packrun\s+Size\s+Qty\s+([A-Z0-9-]+)\s+([A-Z0-9/._-]+)\s+([0-9]{1,3})\s+([0-9]{1,5})\b",
) {
if let Some(captures) = regex.captures(&flat) {
snapshot.grower = captures.get(1).and_then(|m| clean(m.as_str()));
snapshot.packrun = captures.get(2).and_then(|m| clean(m.as_str()));
snapshot.size = captures.get(3).and_then(|m| clean(m.as_str()));
snapshot.qty = captures.get(4).and_then(|m| clean(m.as_str()));
}
}
}
if let Ok(regex) = Regex::new(
r"(?i)\bORIGIN\s+VAR\s+BRAND\s+CLASS(?:\s+Pallet\s*:\s*[A-Z0-9-]+)?\s+([A-Z]{2,3})\s+([A-Z][A-Z -]{1,20}?)\s+([A-Z0-9]{2,12})\s+(CLASS\s*\.?\s*[0-9]+\.?)(?:\s|$)",
) {
if let Some(captures) = regex.captures(&flat) {
snapshot.origin = captures.get(1).and_then(|m| clean(m.as_str()));
snapshot.variety = captures.get(2).and_then(|m| clean(m.as_str()));
snapshot.brand = captures.get(3).and_then(|m| clean(m.as_str()));
snapshot.class = captures.get(4).and_then(|m| clean(m.as_str()));
if snapshot.origin.is_none() {
if let Ok(regex) = Regex::new(
r"(?i)\bORIGIN\s+VAR\s+BRAND\s+CLASS(?:\s+Pallet\s*:\s*[A-Z0-9-]+)?\s+([A-Z]{2,3})\s+([A-Z][A-Z -]{1,20}?)\s+([A-Z0-9]{2,12})\s+(CLASS\s*\.?\s*[0-9]+\.?)(?:\s|$)",
) {
if let Some(captures) = regex.captures(&flat) {
snapshot.origin = captures.get(1).and_then(|m| clean(m.as_str()));
snapshot.variety = captures.get(2).and_then(|m| clean(m.as_str()));
snapshot.brand = captures.get(3).and_then(|m| clean(m.as_str()));
snapshot.class = captures.get(4).and_then(|m| clean(m.as_str()));
}
}
}
@ -84,12 +183,14 @@ pub fn parse_pallet_snapshot(raw_text: &str, pallet_id: &str) -> PalletSnapshot
}
}
if let Ok(regex) = Regex::new(
r"(?i)\bBASE\s+PACK\s+([A-Z]{2,12})\s+([A-Z0-9-]{2,12})\b",
) {
if let Some(captures) = regex.captures(&flat) {
snapshot.base = captures.get(1).and_then(|m| clean(m.as_str()));
snapshot.pack = captures.get(2).and_then(|m| clean(m.as_str()));
if snapshot.base.is_none() {
if let Ok(regex) = Regex::new(
r"(?i)\bBASE\s+PACK\s+([A-Z]{2,12})\s+([A-Z0-9-]{2,12})\b",
) {
if let Some(captures) = regex.captures(&flat) {
snapshot.base = captures.get(1).and_then(|m| clean(m.as_str()));
snapshot.pack = captures.get(2).and_then(|m| clean(m.as_str()));
}
}
}
@ -164,6 +265,18 @@ mod tests {
Total 30
"#;
const LAYOUT_CARD: &str = r#"
Avocados - Hass
ORIGIN VAR BRAND CLASS Pallet: 40595983
NZ Hass NZAG CLASS 3. Grower Packrun Size Qty
BASE PACK 89930 89930/16/17/26 20 30
CHEP B37
Pick Date: 16/09/2026
Pack Date: 17/09/2026
Time: 14:46:10
Total 30
"#;
#[test]
fn parses_export_card_fields() {
let snapshot = parse_pallet_snapshot(CARD, "40595983");
@ -182,6 +295,19 @@ mod tests {
assert_eq!(snapshot.printed_time.as_deref(), Some("14:46:10"));
}
#[test]
fn parses_interleaved_pdf_layout_columns() {
let snapshot = parse_pallet_snapshot(LAYOUT_CARD, "40595983");
assert_eq!(snapshot.origin.as_deref(), Some("NZ"));
assert_eq!(snapshot.brand.as_deref(), Some("NZAG"));
assert_eq!(snapshot.base.as_deref(), Some("CHEP"));
assert_eq!(snapshot.pack.as_deref(), Some("B37"));
assert_eq!(snapshot.grower.as_deref(), Some("89930"));
assert_eq!(snapshot.packrun.as_deref(), Some("89930/16/17/26"));
assert_eq!(snapshot.size.as_deref(), Some("20"));
assert_eq!(snapshot.qty.as_deref(), Some("30"));
}
#[test]
fn ignores_print_time_but_reports_business_field_changes() {
let previous = parse_pallet_snapshot(CARD, "40595983");