The new CI runs cargo fmt --check. QuantityInput also reported its validity only when it changed, so removing a line whose quantity box held bad text left that box counted as invalid and blocked issuing; it now reports valid when it unmounts, and no longer writes a ref during render. Claude-Session: https://claude.ai/code/session_01PZypiWDfMkDTeEPeXjRhW5
130 lines
3.8 KiB
Rust
130 lines
3.8 KiB
Rust
//! Units of measure for rate × quantity lines. Mirrors src/lib/units.ts: the ids below are stored in
|
||
//! invoices and presets, and any other text that passes `validate` is a custom unit stored as typed.
|
||
|
||
/// Built-in unit ids with the ERPNext UOM each one maps to by default. Whole-number UOMs ("Nos", "Set")
|
||
/// reject fractional quantities, so units that are usually fractional map to UOMs that allow them.
|
||
pub const KNOWN: &[(&str, &str)] = &[
|
||
("unit", "Nos"),
|
||
("piece", "Nos"),
|
||
("set", "Set"),
|
||
("session", "Nos"),
|
||
("second", "Second"),
|
||
("minute", "Minute"),
|
||
("hour", "Hour"),
|
||
("day", "Day"),
|
||
("week", "Week"),
|
||
("month", "Month"),
|
||
("km", "Kilometer"),
|
||
("m", "Meter"),
|
||
("sqft", "Square Foot"),
|
||
("sqm", "Square Meter"),
|
||
("kg", "Kg"),
|
||
("litre", "Litre"),
|
||
];
|
||
|
||
/// Longest custom unit; it lands in a measured PDF column.
|
||
pub const MAX_CUSTOM_LEN: usize = 12;
|
||
|
||
pub fn is_known(unit: &str) -> bool {
|
||
KNOWN.iter().any(|(id, _)| *id == unit)
|
||
}
|
||
|
||
/// The ERPNext UOM that suits a built-in unit.
|
||
pub fn default_uom(unit: &str) -> Option<&'static str> {
|
||
KNOWN
|
||
.iter()
|
||
.find(|(id, _)| *id == unit)
|
||
.map(|(_, uom)| *uom)
|
||
}
|
||
|
||
fn custom_problem(text: &str) -> Option<String> {
|
||
let t = text.trim();
|
||
if t.is_empty() {
|
||
return Some("a unit is required".into());
|
||
}
|
||
if t.chars().count() > MAX_CUSTOM_LEN {
|
||
return Some(format!(
|
||
"a custom unit has at most {MAX_CUSTOM_LEN} characters"
|
||
));
|
||
}
|
||
let mut chars = t.chars();
|
||
let first_ok = chars.next().is_some_and(|c| c.is_ascii_alphanumeric());
|
||
if !first_ok || !chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, ' ' | '.' | '/' | '-'))
|
||
{
|
||
return Some("a unit may use letters, digits, spaces, . / and - only".into());
|
||
}
|
||
None
|
||
}
|
||
|
||
/// The unit as it is stored: trimmed, and a built-in id in lower case. An empty value is the generic unit.
|
||
pub fn normalize(unit: &str) -> String {
|
||
let t = unit.trim();
|
||
if t.is_empty() {
|
||
return "unit".to_string();
|
||
}
|
||
let lower = t.to_ascii_lowercase();
|
||
if is_known(&lower) {
|
||
lower
|
||
} else {
|
||
t.to_string()
|
||
}
|
||
}
|
||
|
||
/// Checks a unit that is about to be stored. Errors read as `Unit "x": <problem>`.
|
||
pub fn validate(unit: &str) -> Result<(), String> {
|
||
if unit.trim().is_empty() {
|
||
return Err("A unit is required".to_string());
|
||
}
|
||
if is_known(&normalize(unit)) {
|
||
return Ok(());
|
||
}
|
||
match custom_problem(unit) {
|
||
None => Ok(()),
|
||
Some(p) => Err(format!("Unit \"{}\": {p}", unit.trim())),
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn known_units_are_accepted_in_any_case() {
|
||
for (id, _) in KNOWN {
|
||
assert!(validate(id).is_ok(), "{id}");
|
||
}
|
||
assert_eq!(normalize(" HOUR "), "hour");
|
||
assert_eq!(normalize(""), "unit");
|
||
}
|
||
|
||
#[test]
|
||
fn custom_units_follow_the_same_rules_as_the_ui() {
|
||
assert!(validate("bundle").is_ok());
|
||
assert!(validate("sq.ft/day").is_ok());
|
||
assert_eq!(normalize(" bundle "), "bundle");
|
||
assert!(validate("a".repeat(13).as_str()).is_err());
|
||
assert!(validate("₹").is_err());
|
||
assert!(validate("-x").is_err());
|
||
assert!(validate("").is_err());
|
||
assert!(validate(" ").is_err());
|
||
assert!(validate("bad;drop").is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn the_original_five_ids_are_unchanged() {
|
||
for id in ["second", "minute", "hour", "session", "unit"] {
|
||
assert!(is_known(id), "{id}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn fractional_units_do_not_map_to_whole_number_uoms() {
|
||
for id in [
|
||
"second", "minute", "hour", "day", "week", "month", "km", "m", "sqft", "sqm", "kg",
|
||
"litre",
|
||
] {
|
||
assert_ne!(default_uom(id), Some("Nos"), "{id}");
|
||
}
|
||
}
|
||
}
|