1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
//! Gate: a generated sample message must satisfy the rulebook it was generated for.
//!
//! The samples under `fixtures/generated/` are produced **through the mapping**.
//! A field no rule writes therefore never appears in a sample, and the sample
//! then agrees that the field was not needed — the corpus cannot find a gap, it
//! can only confirm whatever the mapping already does. That is not theoretical:
//! in #126 a rule was added for a qualifier the rulebook marks mandatory with a
//! single permitted code, and the roundtrip failed because the sample lacked
//! the qualifier it had been generated without.
//!
//! This points the validator the web interface already uses at the samples, so
//! the loop is broken from outside: the rulebook judges the sample, not the
//! mapping.
//!
//! The baseline is a ratchet. A sample that gains a violation fails the gate,
//! and one that loses its last violation fails too, so the list cannot rot.
//!
//! ## Why 56 samples carry a `CodeNotAllowedForPid` at `SG4/SG8/SG10/CAV`
//!
//! Those entries are not a mapping defect and cannot be generated away. The
//! AHB leaves `CAV.C889.D_7111` unenumerated there and delegates the admissible
//! values to a list it names but does not contain — "die vom BDEW
//! veröffentlichte Codeliste der TUM-Profile". That list is not in this repo,
//! so the generator has no legal value to put at that position.
//!
//! It used to render the MIG's `<Code Name="Beispielcode">XYZ` and the gate saw
//! nothing, because the PID schema carried that same placeholder as the sole
//! permitted code — the sample and the rulebook agreed on a value that is not
//! real (issue #154). With the schema no longer imposing it, the position is
//! unconstrained for validation and the sample fills it with a data filler,
//! which the rulebook then rejects as a code.
//!
//! So these 56 record a true limitation rather than a regression: the samples
//! were already wrong at that position, and the placeholder was what kept the
//! gate from saying so. They clear when the BDEW list is available to seed a
//! real value from, not before.
//!
//! Regenerate: `UPDATE_SAMPLE_CONFORMANCE=1 cargo test -p edifact-mapper --test sample_conformance_gate`
use edifact_mapper::{DataDir, Mapper, ValidationLevel};
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
fn repo_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.and_then(|p| p.parent())
.expect("the crate sits two levels below the workspace root")
.to_path_buf()
}
fn baseline_path() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/data/known_sample_violations.txt")
}
/// `<format version> <message type> <identifier> <kind> <path>`, deduplicated.
fn violations() -> BTreeSet<String> {
let root = repo_root();
let mapper = match Mapper::from_data_dir(DataDir::path(root.join("dist"))) {
Ok(m) => m,
Err(e) => panic!("the data bundles are a build artefact — run `bundle-data` first: {e}"),
};
let mut out = BTreeSet::new();
let Ok(fvs) = std::fs::read_dir(root.join("fixtures/generated")) else {
return out;
};
for fv_dir in fvs.flatten() {
if !fv_dir.path().is_dir() {
continue;
}
let fv = fv_dir.file_name().to_string_lossy().to_uppercase();
let Ok(msgs) = std::fs::read_dir(fv_dir.path()) else {
continue;
};
for msg_dir in msgs.flatten() {
if !msg_dir.path().is_dir() {
continue;
}
let msg = msg_dir.file_name().to_string_lossy().to_uppercase();
let Ok(files) = std::fs::read_dir(msg_dir.path()) else {
continue;
};
for f in files.flatten() {
let p = f.path();
if p.extension().and_then(|e| e.to_str()) != Some("edi") {
continue;
}
// A branch sample (`55001__ZAP`) is a sample of its PID; its
// findings are keyed by the file.
let stem = p
.file_stem()
.unwrap_or_default()
.to_string_lossy()
.into_owned();
let pid = mig_types::paths::sample_pid(&stem)
.unwrap_or(&stem)
.to_string();
let Ok(edi) = std::fs::read_to_string(&p) else {
continue;
};
// UTILMD is the one message type split by division; try both.
let variants: Vec<String> = if msg == "UTILMD" {
vec![format!("{msg}_Strom"), format!("{msg}_Gas")]
} else {
vec![msg.clone()]
};
let mut report = None;
for v in &variants {
if let Ok(r) =
mapper.validate_edifact_for_pid(&edi, &fv, v, &pid, ValidationLevel::Full)
{
report = Some(r);
break;
}
}
let Some(report) = report else { continue };
for issue in &report.issues {
if format!("{:?}", issue.severity) != "Error" {
continue;
}
let kind = format!("{:?}", issue.kind);
let kind = kind.split(['{', ' ']).next().unwrap_or("?");
let path = issue.field_path.as_deref().unwrap_or("-");
out.insert(format!("{fv} {msg} {stem} {kind} {path}"));
}
}
}
}
out
}
#[test]
fn every_generated_sample_satisfies_its_rulebook() {
let found = violations();
if std::env::var("UPDATE_SAMPLE_CONFORMANCE").is_ok() {
let body: String = found.iter().map(|l| format!("{l}\n")).collect();
std::fs::write(baseline_path(), body).expect("write baseline");
eprintln!("wrote {} sample violation(s)", found.len());
return;
}
let baseline: BTreeSet<String> = std::fs::read_to_string(baseline_path())
.unwrap_or_default()
.lines()
.map(str::trim)
.filter(|l| !l.is_empty() && !l.starts_with('#'))
.map(str::to_owned)
.collect();
let new: Vec<&String> = found.difference(&baseline).collect();
let fixed: Vec<&String> = baseline.difference(&found).collect();
assert!(
new.is_empty(),
"{} generated sample message(s) gained a violation of their own rulebook. \
A sample is produced through the mapping, so this usually means a rule \
started writing something the rulebook does not allow there:\n{}",
new.len(),
new.iter()
.take(30)
.map(|l| format!(" {l}"))
.collect::<Vec<_>>()
.join("\n")
);
assert!(
fixed.is_empty(),
"{} listed violation(s) are gone — drop them with \
UPDATE_SAMPLE_CONFORMANCE=1 so the list cannot rot:\n{}",
fixed.len(),
fixed
.iter()
.take(30)
.map(|l| format!(" {l}"))
.collect::<Vec<_>>()
.join("\n")
);
}