mod common;
use std::collections::BTreeSet;
use std::path::PathBuf;
use en16931::codes::generated::*;
const CODES_SCH: &str = "eInvoicing-EN16931/ubl/schematron/codelist/EN16931-UBL-codes.sch";
fn artefact() -> Option<PathBuf> {
let p = common::require("code-list verification")?.join(CODES_SCH);
p.exists().then_some(p)
}
fn literals_of(xml: &str, id: &str) -> Vec<Vec<String>> {
let mut out = Vec::new();
let needle = format!("id=\"{id}\"");
for (idx, _) in xml.match_indices(&needle) {
let head = &xml[..idx];
let Some(t0) = head.rfind("test=\"") else {
continue;
};
let body = &xml[t0 + 6..];
let Some(t1) = body.find('"') else { continue };
let expr = &body[..t1];
assert!(
!expr.contains("""),
"{id}: escaped quote in test; the naive slice above is unsafe"
);
for lit in expr.split('\'').skip(1).step_by(2) {
let codes: Vec<String> = lit.split_whitespace().map(str::to_owned).collect();
if !codes.is_empty() {
out.push(codes);
}
}
}
out
}
fn assert_table_matches(xml: &str, name: &str, rule: &str, table: &[&str]) {
let lists = literals_of(xml, rule);
assert!(
!lists.is_empty(),
"{rule}: no code list found in the artefact"
);
let want: BTreeSet<&str> = table.iter().copied().collect();
let found = lists
.iter()
.any(|l| l.iter().map(String::as_str).collect::<BTreeSet<_>>() == want);
assert!(
found,
"{name} does not match any list in {rule}.\n \
committed: {} values\n artefact: {:?} values\n \
Regenerate with `cargo xtask codegen` and review the diff.",
table.len(),
lists.iter().map(Vec::len).collect::<Vec<_>>()
);
}
#[test]
fn committed_tables_match_the_artefacts() {
let Some(path) = artefact() else {
eprintln!("skipping: {CODES_SCH} not present — run `cargo xtask fetch`");
return;
};
let xml = std::fs::read_to_string(&path).expect("read artefact");
for (name, rule, table) in [
("INVOICE_TYPE_CODES", "BR-CL-01", INVOICE_TYPE_CODES),
("CREDIT_NOTE_TYPE_CODES", "BR-CL-01", CREDIT_NOTE_TYPE_CODES),
("CURRENCY_CODES", "BR-CL-04", CURRENCY_CODES),
("VAT_POINT_DATE_CODES", "BR-CL-06", VAT_POINT_DATE_CODES),
("REFERENCE_QUALIFIERS", "BR-CL-07", REFERENCE_QUALIFIERS),
("ICD_SCHEMES", "BR-CL-10", ICD_SCHEMES),
(
"ITEM_CLASSIFICATION_SCHEMES",
"BR-CL-13",
ITEM_CLASSIFICATION_SCHEMES,
),
("COUNTRY_CODES", "BR-CL-14", COUNTRY_CODES),
("PAYMENT_MEANS_CODES", "BR-CL-16", PAYMENT_MEANS_CODES),
("VAT_CATEGORY_CODES", "BR-CL-17", VAT_CATEGORY_CODES),
("ALLOWANCE_REASON_CODES", "BR-CL-19", ALLOWANCE_REASON_CODES),
("CHARGE_REASON_CODES", "BR-CL-20", CHARGE_REASON_CODES),
("VATEX_CODES", "BR-CL-22", VATEX_CODES),
("UNIT_CODES", "BR-CL-23", UNIT_CODES),
("EAS_SCHEMES", "BR-CL-25", EAS_SCHEMES),
] {
assert_table_matches(&xml, name, rule, table);
}
}
#[test]
fn the_multi_list_rules_still_have_the_shape_we_assume() {
let Some(path) = artefact() else {
eprintln!("skipping: artefacts not present");
return;
};
let xml = std::fs::read_to_string(&path).expect("read artefact");
let mut sizes: Vec<usize> = literals_of(&xml, "BR-CL-01").iter().map(Vec::len).collect();
sizes.sort_unstable();
assert_eq!(
sizes,
vec![13, 50],
"BR-CL-01 is no longer two lists of 13 and 50"
);
let l10 = literals_of(&xml, "BR-CL-10");
let mut sizes: Vec<usize> = l10.iter().map(Vec::len).collect();
sizes.sort_unstable();
assert_eq!(
sizes,
vec![1, 243],
"BR-CL-10 is no longer ICD + one literal"
);
assert!(
l10.iter().any(|l| l == &["SEPA"]),
"BR-CL-10's contextual literal is no longer SEPA"
);
}
#[test]
fn sepa_is_not_in_the_icd_table() {
assert!(
!ICD_SCHEMES.contains(&"SEPA"),
"SEPA is contextual — only under AccountingSupplierParty or PayeeParty — \
so it belongs in the rule, not in a flat lookup table"
);
}
fn artefact_rule_ids() -> Option<BTreeSet<String>> {
let root = common::spec_root()?;
let files = [
"eInvoicing-EN16931/ubl/schematron/abstract/EN16931-model.sch",
CODES_SCH,
];
let mut out = BTreeSet::new();
for f in files {
let p = root.join(f);
if !p.exists() {
return None;
}
let xml = std::fs::read_to_string(&p).expect("read artefact");
for (idx, _) in xml.match_indices("id=\"BR-") {
let rest = &xml[idx + 4..];
let end = rest.find('"').expect("unterminated id");
out.insert(normalise(&rest[..end]));
}
}
Some(out)
}
fn normalise(id: &str) -> String {
match id.rsplit_once('-') {
Some((head, tail)) if tail.len() < 2 && tail.chars().all(|c| c.is_ascii_digit()) => {
format!("{head}-0{tail}")
}
_ => id.to_owned(),
}
}
#[test]
fn the_registry_covers_the_syntax_independent_artefacts() {
let Some(artefact) = artefact_rule_ids() else {
eprintln!("skipping: artefacts not present");
return;
};
let implemented: BTreeSet<String> = en16931::validation::rules::CORE
.iter()
.map(|r| normalise(r.id.as_str()))
.collect();
let missing: Vec<_> = artefact.difference(&implemented).cloned().collect();
eprintln!(
"syntax-independent artefact rules: {} / {} registered",
artefact.len() - missing.len(),
artefact.len()
);
assert!(
missing.is_empty(),
"{} artefact rule(s) are not in the registry:\n {}",
missing.len(),
missing.join("\n ")
);
assert_eq!(
artefact.len(),
223,
"the pinned artefacts carry 223 syntax-independent rules; if this \
changed, the artefact revision moved and everything here needs a look"
);
}
#[test]
fn the_profile_rule_sets_cover_their_artefacts() {
let Some(root) = common::spec_root() else {
return;
};
type ProfileArtefact = (&'static str, &'static str, fn(&str) -> bool);
let cases: &[ProfileArtefact] = &[
(
"XRechnung 3.0",
"xrechnung-schematron/src/validation/schematron/ubl/XRechnung-UBL-validation.sch",
|_| true,
),
(
"XRechnung 3.0 (merged Peppol rules)",
"xrechnung-schematron/src/xsl/rule-list.xml",
|id| id.starts_with("PEPPOL-EN16931-"),
),
(
"Peppol BIS Billing 3.0",
"peppol-bis-invoice-3/rules/sch/PEPPOL-EN16931-UBL.sch",
|id| id.starts_with("PEPPOL-EN16931-") && !id.contains("COMMON"),
),
];
let mut known: BTreeSet<String> = en16931::validation::rules::CORE
.iter()
.map(|r| normalise(r.id.as_str()))
.collect();
for p in en16931::profiles::ALL {
known.extend(p.extra_rules.iter().map(|r| normalise(r.id.as_str())));
known.extend(p.restrictions.iter().map(|r| normalise(r.id())));
}
for (name, file, in_scope) in cases {
let p = root.join(file);
if !p.exists() {
eprintln!("skipping {name}: artefacts not present");
continue;
}
let xml = std::fs::read_to_string(&p).expect("read artefact");
let mut want = BTreeSet::new();
if file.ends_with("rule-list.xml") {
let doc = roxmltree::Document::parse(&xml).expect("parse rule-list");
for e in doc.root_element().children().filter(|n| n.is_element()) {
if let Some(t) = e.text().map(str::trim).filter(|t| !t.is_empty())
&& in_scope(t)
{
want.insert(normalise(t));
}
}
let missing: Vec<_> = want.difference(&known).cloned().collect();
eprintln!(
"{name}: {} / {} in-scope rules registered",
want.len() - missing.len(),
want.len()
);
assert!(
missing.is_empty(),
"{name} is missing {} rule(s):\n {}",
missing.len(),
missing.join("\n ")
);
continue;
}
let marker = "<assert";
for (idx, _) in xml.match_indices(marker) {
let id = if marker == "<r:rule" {
let rest = &xml[idx..];
let start = rest.find('>').expect("unclosed element") + 1;
let end = rest[start..].find('<').expect("unterminated text") + start;
rest[start..end].trim()
} else {
let Some(rel) = xml[idx..].find("id=\"") else {
continue;
};
let rest = &xml[idx + rel + 4..];
let end = rest.find('"').expect("unterminated id");
&rest[..end]
};
if in_scope(id) {
want.insert(normalise(id));
}
}
let missing: Vec<_> = want.difference(&known).cloned().collect();
eprintln!(
"{name}: {} / {} in-scope rules registered",
want.len() - missing.len(),
want.len()
);
assert!(
missing.is_empty(),
"{name} is missing {} rule(s):\n {}",
missing.len(),
missing.join("\n ")
);
}
}
#[test]
fn the_syntax_rule_count_is_measured() {
let Some(root) = common::spec_root() else {
return;
};
let mut ids = BTreeSet::new();
for syntax in ["ubl", "cii"] {
let dir = root
.join("eInvoicing-EN16931")
.join(syntax)
.join("schematron");
let Ok(walk) = std::fs::read_dir(&dir) else {
eprintln!("skipping: artefacts not present");
return;
};
for sub in walk.flatten() {
if sub.file_name() == "preprocessed" {
continue;
}
let Ok(files) = std::fs::read_dir(sub.path()) else {
continue;
};
for f in files
.flatten()
.filter(|f| f.path().extension().is_some_and(|e| e == "sch"))
{
let xml = std::fs::read_to_string(f.path()).expect("read");
for (idx, _) in xml.match_indices("<assert") {
let Some(rel) = xml[idx..].find("id=\"") else {
continue;
};
let rest = &xml[idx + rel + 4..];
let end = rest.find('"').expect("unterminated id");
let id = &rest[..end];
if id.starts_with("UBL-") || id.starts_with("CII-") {
ids.insert(id.to_owned());
}
}
}
}
}
if ids.is_empty() {
eprintln!("skipping: artefacts not present");
return;
}
eprintln!("syntax rules (`en16931-formats`' job): {}", ids.len());
assert_eq!(
ids.len(),
1339,
"the documented syntax-rule count must match the artefacts"
);
}
#[test]
fn cen_unit_tests_agree_with_our_dispositions() {
let Some(root) = common::spec_root() else {
return;
};
let mut tested = BTreeSet::new();
let mut found_any = false;
for dir in ["Invoice-unit-UBL", "CreditNote-unit-UBL"] {
let d = root.join("eInvoicing-EN16931/test").join(dir);
let Ok(entries) = std::fs::read_dir(&d) else {
continue;
};
found_any = true;
for e in entries.flatten() {
let name = e.file_name();
let Some(stem) = name
.to_string_lossy()
.strip_suffix(".xml")
.map(str::to_owned)
else {
continue;
};
let base = match stem.rsplit_once('-') {
Some((head, tail))
if tail.len() == 1 && tail.chars().all(|c| c.is_ascii_digit()) =>
{
head.to_owned()
}
_ => stem,
};
tested.insert(en16931::validation::RuleId::new(Box::leak(
base.into_boxed_str(),
)));
}
}
if !found_any {
eprintln!("skipping: artefacts not present");
return;
}
let has_test = |id: &str| tested.iter().any(|t| t.matches(id));
for undecidable in ["BR-CO-05", "BR-CO-06", "BR-CO-07", "BR-CO-08"] {
assert!(
!has_test(undecidable),
"{undecidable} is registered as undecidable because CEN binds it to \
true() — but CEN ships a unit test for it, so one of the two \
readings is wrong"
);
}
let dec_with_tests: Vec<_> = en16931::validation::rules::CORE
.iter()
.filter(|r| r.id.as_str().starts_with("BR-DEC-"))
.filter(|r| has_test(r.id.as_str()))
.map(|r| r.id.as_str())
.collect();
assert!(
dec_with_tests.is_empty(),
"these BR-DEC-* rules are retired by the type system but CEN exercises \
them, which is worth a look: {dec_with_tests:?}"
);
assert!(
has_test("BR-AF-01") && has_test("BR-AG-01"),
"CEN's suite spells these BR-IG-01 / BR-IP-01; RuleId must alias them"
);
eprintln!("CEN unit-test instances: {} rules exercised", tested.len());
}