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