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