Skip to main content

automapper_validation/eval/
format_validators.rs

1//! Format validation helpers for AHB 900-series conditions.
2//!
3//! These validate the FORMAT of data element values (decimal places, numeric ranges,
4//! time patterns, ID formats, etc.). They operate on string values extracted from
5//! EDIFACT segments and return `ConditionResult`.
6
7use super::evaluator::ConditionResult;
8
9// Re-export timezone helpers so generated code can use `use crate::eval::format_validators::*`
10pub use super::timezone::{dst_transitions_between, is_mesz_utc, is_mez_utc, parse_dtm303};
11pub use super::werktag::{
12    compute_easter, is_german_holiday, is_werktag, parse_ccyymmdd_prefix, werktage_between,
13};
14
15// --- Decimal/digit place validation ---
16
17/// Validate that a numeric string has at most `max` decimal places.
18///
19/// Returns `True` if the value has <= max decimal places (or no decimal point),
20/// `False` if it has more, `Unknown` if the value is empty.
21///
22/// Example: `validate_max_decimal_places("123.45", 2)` → True
23/// Example: `validate_max_decimal_places("123.456", 2)` → False
24/// Example: `validate_max_decimal_places("123", 2)` → True (no decimal → 0 places)
25pub fn validate_max_decimal_places(value: &str, max: usize) -> ConditionResult {
26    if value.is_empty() {
27        return ConditionResult::Unknown;
28    }
29    let decimal_places = match value.find('.') {
30        Some(pos) => value.len() - pos - 1,
31        None => 0,
32    };
33    ConditionResult::from(decimal_places <= max)
34}
35
36/// Validate that a numeric string has at most `max` integer digits (before decimal point).
37///
38/// Ignores leading minus sign.
39pub fn validate_max_integer_digits(value: &str, max: usize) -> ConditionResult {
40    if value.is_empty() {
41        return ConditionResult::Unknown;
42    }
43    let s = value.strip_prefix('-').unwrap_or(value);
44    let integer_part = match s.find('.') {
45        Some(pos) => &s[..pos],
46        None => s,
47    };
48    ConditionResult::from(integer_part.len() <= max)
49}
50
51// --- Numeric range validation ---
52
53/// Validate a numeric value against a comparison.
54///
55/// `op` is one of: "==", "!=", ">", ">=", "<", "<="
56/// Returns `Unknown` if the value cannot be parsed as a number.
57///
58/// Example: `validate_numeric(value, ">=", 0.0)` for "Wert >= 0"
59/// Example: `validate_numeric(value, "==", 1.0)` for "Wert = 1"
60pub fn validate_numeric(value: &str, op: &str, threshold: f64) -> ConditionResult {
61    let parsed = match value.parse::<f64>() {
62        Ok(v) => v,
63        Err(_) => return ConditionResult::Unknown,
64    };
65    let result = match op {
66        "==" => (parsed - threshold).abs() < f64::EPSILON,
67        "!=" => (parsed - threshold).abs() >= f64::EPSILON,
68        ">" => parsed > threshold,
69        ">=" => parsed >= threshold,
70        "<" => parsed < threshold,
71        "<=" => parsed <= threshold,
72        _ => return ConditionResult::Unknown,
73    };
74    ConditionResult::from(result)
75}
76
77// --- DTM time/timezone validation ---
78
79/// Validate that a DTM value's HHMM portion equals the expected value.
80///
81/// DTM format 303 is CCYYMMDDHHMM (12 chars) or CCYYMMDDHHMMZZZ (15 chars with timezone).
82/// Extracts characters at positions 8..12 (HHMM) for comparison.
83///
84/// Example: `validate_hhmm_equals("202601012200+00", "2200")` → True
85pub fn validate_hhmm_equals(dtm_value: &str, expected_hhmm: &str) -> ConditionResult {
86    if dtm_value.len() < 12 {
87        // Value too short — no HHMM component present, condition is not met
88        return ConditionResult::False;
89    }
90    ConditionResult::from(&dtm_value[8..12] == expected_hhmm)
91}
92
93/// Validate that a DTM value's HHMM portion is within a range (inclusive).
94///
95/// Example: `validate_hhmm_range("202601011530+00", "0000", "2359")` → True
96pub fn validate_hhmm_range(dtm_value: &str, min: &str, max: &str) -> ConditionResult {
97    if dtm_value.len() < 12 {
98        return ConditionResult::False;
99    }
100    let hhmm = &dtm_value[8..12];
101    ConditionResult::from(hhmm >= min && hhmm <= max)
102}
103
104/// Validate that a DTM value's MMDDHHMM portion equals the expected value.
105///
106/// Extracts characters at positions 4..12 for comparison.
107///
108/// Example: `validate_mmddhhmm_equals("202612312300+00", "12312300")` → True
109pub fn validate_mmddhhmm_equals(dtm_value: &str, expected: &str) -> ConditionResult {
110    if dtm_value.len() < 12 {
111        return ConditionResult::False;
112    }
113    ConditionResult::from(&dtm_value[4..12] == expected)
114}
115
116/// Validate that a DTM value's timezone portion is "+00" (UTC).
117///
118/// DTM format 303 with timezone: CCYYMMDDHHMM+ZZ or CCYYMMDDHHMM-ZZ (15 chars).
119/// Checks that the last 3 characters are "+00".
120///
121/// Example: `validate_timezone_utc("202601012200+00")` → True
122/// Example: `validate_timezone_utc("202601012200+01")` → False
123pub fn validate_timezone_utc(dtm_value: &str) -> ConditionResult {
124    if dtm_value.len() < 15 {
125        // Value too short — no timezone suffix present, condition is not met
126        return ConditionResult::False;
127    }
128    ConditionResult::from(&dtm_value[12..] == "+00")
129}
130
131// --- Contact format validation ---
132
133/// Validate email format: must contain both '@' and '.'.
134///
135/// Example: `validate_email("user@example.com")` → True
136pub fn validate_email(value: &str) -> ConditionResult {
137    if value.is_empty() {
138        return ConditionResult::Unknown;
139    }
140    ConditionResult::from(value.contains('@') && value.contains('.'))
141}
142
143/// Validate phone format: must start with '+' followed by only digits.
144///
145/// Example: `validate_phone("+4930123456")` → True
146/// Example: `validate_phone("030123456")` → False
147pub fn validate_phone(value: &str) -> ConditionResult {
148    if value.is_empty() {
149        return ConditionResult::Unknown;
150    }
151    if !value.starts_with('+') || value.len() < 2 {
152        return ConditionResult::from(false);
153    }
154    ConditionResult::from(value[1..].chars().all(|c| c.is_ascii_digit()))
155}
156
157// --- ID format validation ---
158
159/// Validate Marktlokations-ID (MaLo-ID): exactly 11 digits with BDEW check digit.
160///
161/// This is **not** standard Luhn — per the BDEW "Anwendungshilfe MaLo-ID v1.0" spec
162/// and the Hochfrequenz BO4E-dotnet reference implementation
163/// (`BO4E/BO/Marktlokation.cs::GetChecksum`), digits 1–10 are numbered left-to-right
164/// (1-based), digits at even positions are multiplied by 2 and **added as the full
165/// value** (no digit-sum, no mod-by-9 step), digits at odd positions are added as-is.
166/// Check digit = `(10 - (sum mod 10)) mod 10`.
167///
168/// Example: `"51238696781"` — odd (1,3,5,7,9): 5+2+8+9+7=31; even doubled
169/// (2,4,6,8,10): 2*(1+3+6+6+8)=48; sum=79; check = (10-79%10)%10 = 1 ✓.
170pub fn validate_malo_id(value: &str) -> ConditionResult {
171    if value.len() != 11 {
172        return ConditionResult::from(false);
173    }
174    if !value.chars().all(|c| c.is_ascii_digit()) {
175        return ConditionResult::from(false);
176    }
177    let digits: Vec<u32> = value.chars().filter_map(|c| c.to_digit(10)).collect();
178    let check = digits[10];
179    let mut sum = 0u32;
180    for (i, &d) in digits[..10].iter().enumerate() {
181        // 1-based position is i+1; even positions are doubled, odd positions as-is.
182        if (i + 1) % 2 == 0 {
183            sum += 2 * d;
184        } else {
185            sum += d;
186        }
187    }
188    let expected = (10 - (sum % 10)) % 10;
189    ConditionResult::from(check == expected)
190}
191
192/// Validate Transaktionsreferenz-ID (TR-ID): 1-35 alphanumeric characters.
193pub fn validate_tr_id(value: &str) -> ConditionResult {
194    if value.is_empty() {
195        return ConditionResult::Unknown;
196    }
197    ConditionResult::from(value.len() <= 35 && value.chars().all(|c| c.is_ascii_alphanumeric()))
198}
199
200/// A BDEW location ID of the lettered kind: `prefix` followed by ten ASCII
201/// uppercase letters or digits (11 characters).
202fn validate_prefixed_location_id(value: &str, prefix: char) -> ConditionResult {
203    let mut chars = value.chars();
204    ConditionResult::from(
205        value.len() == 11
206            && chars.next() == Some(prefix)
207            && chars.all(|c| c.is_ascii_digit() || c.is_ascii_uppercase()),
208    )
209}
210
211/// Validate Steuerbare-Ressource-ID (SR-ID): "C" + ten alphanumerics
212/// (`C816417ST77`).
213pub fn validate_sr_id(value: &str) -> ConditionResult {
214    validate_prefixed_location_id(value, 'C')
215}
216
217/// Validate Netzlokations-ID: "E" + ten alphanumerics (`E1688117482`).
218pub fn validate_nelo_id(value: &str) -> ConditionResult {
219    validate_prefixed_location_id(value, 'E')
220}
221
222// --- OBIS code pattern matching ---
223
224/// Parse an OBIS code of the form `A-B:C.D.E[*F]` and return `(C, D)`.
225///
226/// OBIS structure (IEC 62056-61, BDEW/DVGW usage):
227/// - `A` — medium (1 = electricity, 7 = gas, 6 = heat, …)
228/// - `B` — channel
229/// - `C` — measurement type (1 = +A active energy import, 2 = -A active energy
230///   export, 3 = +R reactive import, 4 = -R reactive export, …)
231/// - `D` — measurement mode (8 = total time integral, 9 = billing-period
232///   integral, 29 = previous-billing-period partial value for interval
233///   measurements, 5 = actual/instantaneous value, …)
234/// - `E` — rate/tariff
235/// - `F` — optional billing period marker
236///
237/// Returns `None` when the value doesn't fit the shape. Whitespace-tolerant.
238pub fn parse_obis(value: &str) -> Option<(u32, u32)> {
239    let value = value.trim();
240    // Split at the colon between `A-B` and `C.D.E`.
241    let (_ab, cde) = value.split_once(':')?;
242    // Drop optional `*F` suffix.
243    let cde = cde.split('*').next()?;
244    let mut parts = cde.split('.');
245    let c = parts.next()?.parse::<u32>().ok()?;
246    let d = parts.next()?.parse::<u32>().ok()?;
247    Some((c, d))
248}
249
250/// Is the OBIS code "Wirkarbeit kumuliert" — active energy as a cumulated
251/// time integral (total or per billing period)?
252///
253/// Matches BDEW convention: `C ∈ {1, 2}` (active energy import/export) and
254/// `D ∈ {8, 9}` (total time integral / billing-period integral).
255pub fn is_obis_wirkarbeit_kumuliert(value: &str) -> bool {
256    matches!(parse_obis(value), Some((1 | 2, 8 | 9)))
257}
258
259/// Is the OBIS code "Wirkarbeit 1/4 Stunde" — active energy 15-minute
260/// interval (Lastgang) measurement?
261///
262/// Matches BDEW convention for quarter-hour Lastgang: `C ∈ {1, 2}` and
263/// `D = 29` (partial value for previous billing period, the standard marker
264/// for interval-measured energy in German energy market MSCONS/UTILMD).
265pub fn is_obis_wirkarbeit_quarter_hour(value: &str) -> bool {
266    matches!(parse_obis(value), Some((1 | 2, 29)))
267}
268
269/// Validate an X.509 certificate body per BSI TR-03109-4.
270///
271/// In EDIFACT messages (e.g. ORDERS/REQOTE for Smartmeter gateway config) the
272/// certificate body is transmitted as base64-encoded DER — the PEM armoring
273/// (`-----BEGIN CERTIFICATE-----` / `-----END CERTIFICATE-----` lines) may or
274/// may not be present depending on the profile.
275///
276/// This validator performs a structural check only:
277/// - non-empty
278/// - composed exclusively of base64 characters (`A-Za-z0-9+/=`) plus
279///   whitespace (`\n\r\t` and space — PEM lines wrap at 64 chars)
280/// - decoded length plausible for a DER-encoded X.509 certificate (we allow a
281///   loose lower bound — a minimal self-signed cert is ~400 bytes, so the
282///   base64 form is ≥ ~540 chars; we use 100 as a sanity floor)
283///
284/// Cryptographic validation (signature, CA chain, TR-03109 certificate
285/// extensions) is out of scope for static AHB validation — those require a
286/// trust store and runtime context.
287pub fn validate_x509_cert_body(value: &str) -> ConditionResult {
288    let trimmed = value.trim();
289    if trimmed.is_empty() {
290        return ConditionResult::Unknown;
291    }
292    // Strip optional PEM armoring so the charset check operates on the
293    // encoded body alone.
294    let body = trimmed
295        .trim_start_matches("-----BEGIN CERTIFICATE-----")
296        .trim_end_matches("-----END CERTIFICATE-----")
297        .trim();
298    let chars_ok = body.chars().all(|c| {
299        c.is_ascii_alphanumeric() || matches!(c, '+' | '/' | '=' | '\n' | '\r' | '\t' | ' ')
300    });
301    // Minimum plausible length for a base64-encoded X.509 body. Shorter
302    // values are almost certainly not a real certificate.
303    let len_ok = body.chars().filter(|c| !c.is_whitespace()).count() >= 100;
304    ConditionResult::from(chars_ok && len_ok)
305}
306
307/// Validate Zahlpunktbezeichnung: exactly 33 alphanumeric characters.
308pub fn validate_zahlpunkt(value: &str) -> ConditionResult {
309    if value.len() != 33 {
310        return ConditionResult::from(false);
311    }
312    ConditionResult::from(value.chars().all(|c| c.is_ascii_alphanumeric()))
313}
314
315/// Validate either MaLo-ID or Zahlpunktbezeichnung format.
316pub fn validate_malo_or_zahlpunkt(value: &str) -> ConditionResult {
317    if value.len() == 11 && validate_malo_id(value).is_true() {
318        return ConditionResult::True;
319    }
320    if value.len() == 33 && validate_zahlpunkt(value).is_true() {
321        return ConditionResult::True;
322    }
323    ConditionResult::False
324}
325
326// --- Artikelnummer pattern validation ---
327
328/// Validate a dash-separated digit pattern like "n1-n2-n1-n3".
329///
330/// `segment_lengths` defines expected digit counts per dash-separated segment.
331///
332/// Example: `validate_artikel_pattern("1-23-4-567", &[1, 2, 1, 3])` → True
333/// Example: `validate_artikel_pattern("1-23-4", &[1, 2, 1])` → True
334pub fn validate_artikel_pattern(value: &str, segment_lengths: &[usize]) -> ConditionResult {
335    if value.is_empty() {
336        return ConditionResult::Unknown;
337    }
338    let parts: Vec<&str> = value.split('-').collect();
339    if parts.len() != segment_lengths.len() {
340        return ConditionResult::from(false);
341    }
342    let valid = parts
343        .iter()
344        .zip(segment_lengths.iter())
345        .all(|(part, &expected_len)| {
346            part.len() == expected_len && part.chars().all(|c| c.is_ascii_digit())
347        });
348    ConditionResult::from(valid)
349}
350
351// --- General string validation ---
352
353/// Validate exact character length.
354pub fn validate_exact_length(value: &str, expected: usize) -> ConditionResult {
355    if value.is_empty() {
356        return ConditionResult::Unknown;
357    }
358    ConditionResult::from(value.len() == expected)
359}
360
361/// Validate maximum character length.
362pub fn validate_max_length(value: &str, max: usize) -> ConditionResult {
363    if value.is_empty() {
364        return ConditionResult::Unknown;
365    }
366    ConditionResult::from(value.len() <= max)
367}
368
369/// Validate that a string contains only digits (positive integer check).
370pub fn validate_all_digits(value: &str) -> ConditionResult {
371    if value.is_empty() {
372        return ConditionResult::Unknown;
373    }
374    ConditionResult::from(value.chars().all(|c| c.is_ascii_digit()))
375}
376
377/// Current UTC date+time as a CCYYMMDDHHMM string (12 chars).
378pub fn utc_now_ccyymmddhhmm() -> String {
379    chrono::Utc::now().format("%Y%m%d%H%M").to_string()
380}
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385
386    // --- Decimal places ---
387
388    #[test]
389    fn test_max_decimal_places() {
390        assert_eq!(
391            validate_max_decimal_places("123.45", 2),
392            ConditionResult::True
393        );
394        assert_eq!(
395            validate_max_decimal_places("123.456", 2),
396            ConditionResult::False
397        );
398        assert_eq!(validate_max_decimal_places("123", 2), ConditionResult::True);
399        assert_eq!(validate_max_decimal_places("0.1", 3), ConditionResult::True);
400        assert_eq!(validate_max_decimal_places("", 2), ConditionResult::Unknown);
401    }
402
403    #[test]
404    fn test_no_decimal_places() {
405        assert_eq!(validate_max_decimal_places("100", 0), ConditionResult::True);
406        assert_eq!(
407            validate_max_decimal_places("100.5", 0),
408            ConditionResult::False
409        );
410    }
411
412    #[test]
413    fn test_max_integer_digits() {
414        assert_eq!(
415            validate_max_integer_digits("1234", 4),
416            ConditionResult::True
417        );
418        assert_eq!(
419            validate_max_integer_digits("12345", 4),
420            ConditionResult::False
421        );
422        assert_eq!(
423            validate_max_integer_digits("-123.45", 4),
424            ConditionResult::True
425        );
426        assert_eq!(validate_max_integer_digits("", 4), ConditionResult::Unknown);
427    }
428
429    // --- Numeric range ---
430
431    #[test]
432    fn test_validate_numeric() {
433        assert_eq!(validate_numeric("5.0", ">=", 0.0), ConditionResult::True);
434        assert_eq!(validate_numeric("-1.0", ">=", 0.0), ConditionResult::False);
435        assert_eq!(validate_numeric("1", "==", 1.0), ConditionResult::True);
436        assert_eq!(validate_numeric("2", "==", 1.0), ConditionResult::False);
437        assert_eq!(validate_numeric("0", ">", 0.0), ConditionResult::False);
438        assert_eq!(validate_numeric("1", ">", 0.0), ConditionResult::True);
439        assert_eq!(validate_numeric("abc", ">=", 0.0), ConditionResult::Unknown);
440    }
441
442    // --- DTM validation ---
443
444    #[test]
445    fn test_hhmm_equals() {
446        assert_eq!(
447            validate_hhmm_equals("202601012200+00", "2200"),
448            ConditionResult::True
449        );
450        assert_eq!(
451            validate_hhmm_equals("202601012300+00", "2200"),
452            ConditionResult::False
453        );
454        assert_eq!(
455            validate_hhmm_equals("short", "2200"),
456            ConditionResult::False
457        );
458    }
459
460    #[test]
461    fn test_hhmm_range() {
462        assert_eq!(
463            validate_hhmm_range("202601011530+00", "0000", "2359"),
464            ConditionResult::True
465        );
466        assert_eq!(
467            validate_hhmm_range("202601010000+00", "0000", "2359"),
468            ConditionResult::True
469        );
470        assert_eq!(
471            validate_hhmm_range("202601012359+00", "0000", "2359"),
472            ConditionResult::True
473        );
474    }
475
476    #[test]
477    fn test_mmddhhmm_equals() {
478        assert_eq!(
479            validate_mmddhhmm_equals("202612312300+00", "12312300"),
480            ConditionResult::True
481        );
482        assert_eq!(
483            validate_mmddhhmm_equals("202601012200+00", "12312300"),
484            ConditionResult::False
485        );
486    }
487
488    #[test]
489    fn test_timezone_utc() {
490        assert_eq!(
491            validate_timezone_utc("202601012200+00"),
492            ConditionResult::True
493        );
494        assert_eq!(
495            validate_timezone_utc("202601012200+01"),
496            ConditionResult::False
497        );
498        assert_eq!(
499            validate_timezone_utc("202601012200"),
500            ConditionResult::False
501        );
502    }
503
504    // --- Contact validation ---
505
506    #[test]
507    fn test_email() {
508        assert_eq!(validate_email("user@example.com"), ConditionResult::True);
509        assert_eq!(validate_email("nope"), ConditionResult::False);
510        assert_eq!(validate_email("has@but-no-dot"), ConditionResult::False);
511        assert_eq!(validate_email(""), ConditionResult::Unknown);
512    }
513
514    #[test]
515    fn test_phone() {
516        assert_eq!(validate_phone("+4930123456"), ConditionResult::True);
517        assert_eq!(validate_phone("030123456"), ConditionResult::False);
518        assert_eq!(validate_phone("+"), ConditionResult::False);
519        assert_eq!(validate_phone("+49 30 123"), ConditionResult::False); // spaces not allowed
520        assert_eq!(validate_phone(""), ConditionResult::Unknown);
521    }
522
523    // --- ID validation ---
524
525    #[test]
526    fn test_malo_id() {
527        // Valid MaLo-IDs — taken directly from the BO4E-dotnet reference test
528        // suite (TestBO4E/TestMaLoMeLoId.cs), which uses the BDEW MaLo-ID spec
529        // (no digit-sum on doubled products; see `validate_malo_id` docstring).
530        assert_eq!(validate_malo_id("51238696781"), ConditionResult::True);
531        assert_eq!(validate_malo_id("41373559241"), ConditionResult::True);
532        assert_eq!(validate_malo_id("56789012345"), ConditionResult::True);
533        assert_eq!(validate_malo_id("52935155442"), ConditionResult::True);
534
535        // Negative cases from the same reference tests.
536        assert_eq!(validate_malo_id("41373559240"), ConditionResult::False); // wrong check digit
537        assert_eq!(validate_malo_id("512386967890"), ConditionResult::False); // 12 digits
538        assert_eq!(validate_malo_id("1234567890"), ConditionResult::False); // too short
539        assert_eq!(validate_malo_id("abcdefghijk"), ConditionResult::False); // not digits
540    }
541
542    #[test]
543    fn test_zahlpunkt() {
544        let valid = "DE0001234567890123456789012345678";
545        assert_eq!(valid.len(), 33);
546        assert_eq!(validate_zahlpunkt(valid), ConditionResult::True);
547        assert_eq!(validate_zahlpunkt("tooshort"), ConditionResult::False);
548    }
549
550    // --- Artikelnummer pattern ---
551
552    #[test]
553    fn test_artikel_pattern() {
554        assert_eq!(
555            validate_artikel_pattern("1-23-4", &[1, 2, 1]),
556            ConditionResult::True
557        );
558        assert_eq!(
559            validate_artikel_pattern("1-23-4-567", &[1, 2, 1, 3]),
560            ConditionResult::True
561        );
562        assert_eq!(
563            validate_artikel_pattern("1-23-4-56", &[1, 2, 1, 3]),
564            ConditionResult::False
565        );
566        assert_eq!(
567            validate_artikel_pattern("1-AB-4", &[1, 2, 1]),
568            ConditionResult::False
569        );
570        assert_eq!(
571            validate_artikel_pattern("", &[1, 2, 1]),
572            ConditionResult::Unknown
573        );
574    }
575
576    // --- TR-ID / SR-ID validation ---
577
578    #[test]
579    fn test_tr_id() {
580        assert_eq!(validate_tr_id("ABC123"), ConditionResult::True);
581        assert_eq!(validate_tr_id("A"), ConditionResult::True);
582        assert_eq!(validate_tr_id(&"A".repeat(35)), ConditionResult::True);
583        assert_eq!(validate_tr_id(&"A".repeat(36)), ConditionResult::False);
584        assert_eq!(validate_tr_id("has spaces"), ConditionResult::False);
585        assert_eq!(validate_tr_id("has-dash"), ConditionResult::False);
586        assert_eq!(validate_tr_id(""), ConditionResult::Unknown);
587    }
588
589    #[test]
590    fn test_parse_obis() {
591        assert_eq!(parse_obis("1-1:1.8.0"), Some((1, 8)));
592        assert_eq!(parse_obis("1-1:2.29.0"), Some((2, 29)));
593        assert_eq!(parse_obis("1-0:1.8.0*255"), Some((1, 8))); // star suffix
594        assert_eq!(parse_obis(" 1-1:1.8.0 "), Some((1, 8))); // whitespace
595        assert_eq!(parse_obis("no-colon"), None);
596        assert_eq!(parse_obis("1-1:abc.8.0"), None);
597        assert_eq!(parse_obis(""), None);
598    }
599
600    #[test]
601    fn test_obis_wirkarbeit_kumuliert() {
602        // Active energy import/export, total or billing-period integral.
603        assert!(is_obis_wirkarbeit_kumuliert("1-1:1.8.0"));
604        assert!(is_obis_wirkarbeit_kumuliert("1-1:2.8.0"));
605        assert!(is_obis_wirkarbeit_kumuliert("1-1:1.9.0"));
606        assert!(is_obis_wirkarbeit_kumuliert("1-1:2.9.0"));
607        assert!(is_obis_wirkarbeit_kumuliert("1-0:1.8.0*255"));
608        // Reactive energy — not Wirkarbeit.
609        assert!(!is_obis_wirkarbeit_kumuliert("1-1:3.8.0"));
610        assert!(!is_obis_wirkarbeit_kumuliert("1-1:4.8.0"));
611        // 15-min partial value — not kumuliert.
612        assert!(!is_obis_wirkarbeit_kumuliert("1-1:1.29.0"));
613        // Actual value — not kumuliert.
614        assert!(!is_obis_wirkarbeit_kumuliert("1-1:1.5.0"));
615    }
616
617    #[test]
618    fn test_obis_wirkarbeit_quarter_hour() {
619        // Active energy 15-min Lastgang.
620        assert!(is_obis_wirkarbeit_quarter_hour("1-1:1.29.0"));
621        assert!(is_obis_wirkarbeit_quarter_hour("1-1:2.29.0"));
622        assert!(is_obis_wirkarbeit_quarter_hour("1-0:1.29.0*255"));
623        // Cumulated — not 1/4h.
624        assert!(!is_obis_wirkarbeit_quarter_hour("1-1:1.8.0"));
625        assert!(!is_obis_wirkarbeit_quarter_hour("1-1:1.9.0"));
626        // Reactive.
627        assert!(!is_obis_wirkarbeit_quarter_hour("1-1:3.29.0"));
628    }
629
630    #[test]
631    fn test_x509_cert_body() {
632        // Valid: 600-char base64 body (well above 100-char floor).
633        let body = "A".repeat(600);
634        assert_eq!(validate_x509_cert_body(&body), ConditionResult::True);
635
636        // Valid with base64 padding and newlines (PEM-style wrapping).
637        let pem_body = "A".repeat(64) + "\n" + &"B".repeat(64) + "\n" + &"C".repeat(64);
638        assert_eq!(validate_x509_cert_body(&pem_body), ConditionResult::True);
639
640        // Valid with explicit PEM armoring — stripped before charset check.
641        let armored = format!(
642            "-----BEGIN CERTIFICATE-----\n{}\n-----END CERTIFICATE-----",
643            "A".repeat(200)
644        );
645        assert_eq!(validate_x509_cert_body(&armored), ConditionResult::True);
646
647        // Valid: base64 chars include +, /, =
648        let with_punct = "AB+/==".to_string() + &"A".repeat(200);
649        assert_eq!(validate_x509_cert_body(&with_punct), ConditionResult::True);
650
651        // Too short — not a plausible cert.
652        assert_eq!(validate_x509_cert_body("QUJD"), ConditionResult::False);
653
654        // Non-base64 characters.
655        let invalid_chars = "A".repeat(200) + "!@#";
656        assert_eq!(
657            validate_x509_cert_body(&invalid_chars),
658            ConditionResult::False
659        );
660
661        // Empty is Unknown (consistent with other format validators).
662        assert_eq!(validate_x509_cert_body(""), ConditionResult::Unknown);
663        assert_eq!(validate_x509_cert_body("   \n  "), ConditionResult::Unknown);
664    }
665
666    /// SR-IDs are "C" + ten alphanumerics, not MaLo-shaped: every SR-ID in
667    /// `example_market_communication_bo4e_transactions/` looks like this
668    /// (`LOC+Z19+C816417ST77`), and a MaLo-ID check rejected all of them.
669    #[test]
670    fn test_sr_id() {
671        for real in ["C816417ST77", "CZZ6M5DGSS5", "COPFWS2EX96"] {
672            assert_eq!(validate_sr_id(real), ConditionResult::True, "{real}");
673        }
674        assert_eq!(
675            validate_sr_id("51238696781"),
676            ConditionResult::False,
677            "a MaLo-ID"
678        );
679        assert_eq!(validate_sr_id("C12345"), ConditionResult::False);
680        assert_eq!(validate_sr_id(""), ConditionResult::False);
681    }
682
683    /// Netzlokations-IDs are "E" + ten alphanumerics (`LOC+Z18+E1688117482`,
684    /// `EU5UQAWW8D0` in the example transactions). The generated evaluators
685    /// checked them as MaLo-IDs, so every real one failed `[960]`.
686    #[test]
687    fn test_nelo_id() {
688        for real in ["E1688117482", "EU5UQAWW8D0", "EEW7TM7V906"] {
689            assert_eq!(validate_nelo_id(real), ConditionResult::True, "{real}");
690        }
691        assert_eq!(
692            validate_nelo_id("51238696781"),
693            ConditionResult::False,
694            "a MaLo-ID"
695        );
696        assert_eq!(validate_nelo_id("E123"), ConditionResult::False);
697        assert_eq!(validate_nelo_id("e1688117482"), ConditionResult::False);
698    }
699
700    // --- String validation ---
701
702    #[test]
703    fn test_exact_length() {
704        assert_eq!(
705            validate_exact_length("1234567890123456", 16),
706            ConditionResult::True
707        );
708        assert_eq!(validate_exact_length("123", 16), ConditionResult::False);
709        assert_eq!(validate_exact_length("", 16), ConditionResult::Unknown);
710    }
711
712    #[test]
713    fn test_all_digits() {
714        assert_eq!(validate_all_digits("12345"), ConditionResult::True);
715        assert_eq!(validate_all_digits("123a5"), ConditionResult::False);
716        assert_eq!(validate_all_digits(""), ConditionResult::Unknown);
717    }
718}