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() || matches!(c, '+' | '/' | '=' | '\n' | '\r' | '\t' | ' ')
283    });
284    // Minimum plausible length for a base64-encoded X.509 body. Shorter
285    // values are almost certainly not a real certificate.
286    let len_ok = body.chars().filter(|c| !c.is_whitespace()).count() >= 100;
287    ConditionResult::from(chars_ok && len_ok)
288}
289
290/// Validate Zahlpunktbezeichnung: exactly 33 alphanumeric characters.
291pub fn validate_zahlpunkt(value: &str) -> ConditionResult {
292    if value.len() != 33 {
293        return ConditionResult::from(false);
294    }
295    ConditionResult::from(value.chars().all(|c| c.is_ascii_alphanumeric()))
296}
297
298/// Validate either MaLo-ID or Zahlpunktbezeichnung format.
299pub fn validate_malo_or_zahlpunkt(value: &str) -> ConditionResult {
300    if value.len() == 11 && validate_malo_id(value).is_true() {
301        return ConditionResult::True;
302    }
303    if value.len() == 33 && validate_zahlpunkt(value).is_true() {
304        return ConditionResult::True;
305    }
306    ConditionResult::False
307}
308
309// --- Artikelnummer pattern validation ---
310
311/// Validate a dash-separated digit pattern like "n1-n2-n1-n3".
312///
313/// `segment_lengths` defines expected digit counts per dash-separated segment.
314///
315/// Example: `validate_artikel_pattern("1-23-4-567", &[1, 2, 1, 3])` → True
316/// Example: `validate_artikel_pattern("1-23-4", &[1, 2, 1])` → True
317pub fn validate_artikel_pattern(value: &str, segment_lengths: &[usize]) -> ConditionResult {
318    if value.is_empty() {
319        return ConditionResult::Unknown;
320    }
321    let parts: Vec<&str> = value.split('-').collect();
322    if parts.len() != segment_lengths.len() {
323        return ConditionResult::from(false);
324    }
325    let valid = parts
326        .iter()
327        .zip(segment_lengths.iter())
328        .all(|(part, &expected_len)| {
329            part.len() == expected_len && part.chars().all(|c| c.is_ascii_digit())
330        });
331    ConditionResult::from(valid)
332}
333
334// --- General string validation ---
335
336/// Validate exact character length.
337pub fn validate_exact_length(value: &str, expected: usize) -> ConditionResult {
338    if value.is_empty() {
339        return ConditionResult::Unknown;
340    }
341    ConditionResult::from(value.len() == expected)
342}
343
344/// Validate maximum character length.
345pub fn validate_max_length(value: &str, max: usize) -> ConditionResult {
346    if value.is_empty() {
347        return ConditionResult::Unknown;
348    }
349    ConditionResult::from(value.len() <= max)
350}
351
352/// Validate that a string contains only digits (positive integer check).
353pub fn validate_all_digits(value: &str) -> ConditionResult {
354    if value.is_empty() {
355        return ConditionResult::Unknown;
356    }
357    ConditionResult::from(value.chars().all(|c| c.is_ascii_digit()))
358}
359
360/// Current UTC date+time as a CCYYMMDDHHMM string (12 chars).
361pub fn utc_now_ccyymmddhhmm() -> String {
362    chrono::Utc::now().format("%Y%m%d%H%M").to_string()
363}
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368
369    // --- Decimal places ---
370
371    #[test]
372    fn test_max_decimal_places() {
373        assert_eq!(
374            validate_max_decimal_places("123.45", 2),
375            ConditionResult::True
376        );
377        assert_eq!(
378            validate_max_decimal_places("123.456", 2),
379            ConditionResult::False
380        );
381        assert_eq!(validate_max_decimal_places("123", 2), ConditionResult::True);
382        assert_eq!(validate_max_decimal_places("0.1", 3), ConditionResult::True);
383        assert_eq!(validate_max_decimal_places("", 2), ConditionResult::Unknown);
384    }
385
386    #[test]
387    fn test_no_decimal_places() {
388        assert_eq!(validate_max_decimal_places("100", 0), ConditionResult::True);
389        assert_eq!(
390            validate_max_decimal_places("100.5", 0),
391            ConditionResult::False
392        );
393    }
394
395    #[test]
396    fn test_max_integer_digits() {
397        assert_eq!(
398            validate_max_integer_digits("1234", 4),
399            ConditionResult::True
400        );
401        assert_eq!(
402            validate_max_integer_digits("12345", 4),
403            ConditionResult::False
404        );
405        assert_eq!(
406            validate_max_integer_digits("-123.45", 4),
407            ConditionResult::True
408        );
409        assert_eq!(validate_max_integer_digits("", 4), ConditionResult::Unknown);
410    }
411
412    // --- Numeric range ---
413
414    #[test]
415    fn test_validate_numeric() {
416        assert_eq!(validate_numeric("5.0", ">=", 0.0), ConditionResult::True);
417        assert_eq!(validate_numeric("-1.0", ">=", 0.0), ConditionResult::False);
418        assert_eq!(validate_numeric("1", "==", 1.0), ConditionResult::True);
419        assert_eq!(validate_numeric("2", "==", 1.0), ConditionResult::False);
420        assert_eq!(validate_numeric("0", ">", 0.0), ConditionResult::False);
421        assert_eq!(validate_numeric("1", ">", 0.0), ConditionResult::True);
422        assert_eq!(validate_numeric("abc", ">=", 0.0), ConditionResult::Unknown);
423    }
424
425    // --- DTM validation ---
426
427    #[test]
428    fn test_hhmm_equals() {
429        assert_eq!(
430            validate_hhmm_equals("202601012200+00", "2200"),
431            ConditionResult::True
432        );
433        assert_eq!(
434            validate_hhmm_equals("202601012300+00", "2200"),
435            ConditionResult::False
436        );
437        assert_eq!(
438            validate_hhmm_equals("short", "2200"),
439            ConditionResult::False
440        );
441    }
442
443    #[test]
444    fn test_hhmm_range() {
445        assert_eq!(
446            validate_hhmm_range("202601011530+00", "0000", "2359"),
447            ConditionResult::True
448        );
449        assert_eq!(
450            validate_hhmm_range("202601010000+00", "0000", "2359"),
451            ConditionResult::True
452        );
453        assert_eq!(
454            validate_hhmm_range("202601012359+00", "0000", "2359"),
455            ConditionResult::True
456        );
457    }
458
459    #[test]
460    fn test_mmddhhmm_equals() {
461        assert_eq!(
462            validate_mmddhhmm_equals("202612312300+00", "12312300"),
463            ConditionResult::True
464        );
465        assert_eq!(
466            validate_mmddhhmm_equals("202601012200+00", "12312300"),
467            ConditionResult::False
468        );
469    }
470
471    #[test]
472    fn test_timezone_utc() {
473        assert_eq!(
474            validate_timezone_utc("202601012200+00"),
475            ConditionResult::True
476        );
477        assert_eq!(
478            validate_timezone_utc("202601012200+01"),
479            ConditionResult::False
480        );
481        assert_eq!(
482            validate_timezone_utc("202601012200"),
483            ConditionResult::False
484        );
485    }
486
487    // --- Contact validation ---
488
489    #[test]
490    fn test_email() {
491        assert_eq!(validate_email("user@example.com"), ConditionResult::True);
492        assert_eq!(validate_email("nope"), ConditionResult::False);
493        assert_eq!(validate_email("has@but-no-dot"), ConditionResult::False);
494        assert_eq!(validate_email(""), ConditionResult::Unknown);
495    }
496
497    #[test]
498    fn test_phone() {
499        assert_eq!(validate_phone("+4930123456"), ConditionResult::True);
500        assert_eq!(validate_phone("030123456"), ConditionResult::False);
501        assert_eq!(validate_phone("+"), ConditionResult::False);
502        assert_eq!(validate_phone("+49 30 123"), ConditionResult::False); // spaces not allowed
503        assert_eq!(validate_phone(""), ConditionResult::Unknown);
504    }
505
506    // --- ID validation ---
507
508    #[test]
509    fn test_malo_id() {
510        // Valid MaLo-IDs — taken directly from the BO4E-dotnet reference test
511        // suite (TestBO4E/TestMaLoMeLoId.cs), which uses the BDEW MaLo-ID spec
512        // (no digit-sum on doubled products; see `validate_malo_id` docstring).
513        assert_eq!(validate_malo_id("51238696781"), ConditionResult::True);
514        assert_eq!(validate_malo_id("41373559241"), ConditionResult::True);
515        assert_eq!(validate_malo_id("56789012345"), ConditionResult::True);
516        assert_eq!(validate_malo_id("52935155442"), ConditionResult::True);
517
518        // Negative cases from the same reference tests.
519        assert_eq!(validate_malo_id("41373559240"), ConditionResult::False); // wrong check digit
520        assert_eq!(validate_malo_id("512386967890"), ConditionResult::False); // 12 digits
521        assert_eq!(validate_malo_id("1234567890"), ConditionResult::False); // too short
522        assert_eq!(validate_malo_id("abcdefghijk"), ConditionResult::False); // not digits
523    }
524
525    #[test]
526    fn test_zahlpunkt() {
527        let valid = "DE0001234567890123456789012345678";
528        assert_eq!(valid.len(), 33);
529        assert_eq!(validate_zahlpunkt(valid), ConditionResult::True);
530        assert_eq!(validate_zahlpunkt("tooshort"), ConditionResult::False);
531    }
532
533    // --- Artikelnummer pattern ---
534
535    #[test]
536    fn test_artikel_pattern() {
537        assert_eq!(
538            validate_artikel_pattern("1-23-4", &[1, 2, 1]),
539            ConditionResult::True
540        );
541        assert_eq!(
542            validate_artikel_pattern("1-23-4-567", &[1, 2, 1, 3]),
543            ConditionResult::True
544        );
545        assert_eq!(
546            validate_artikel_pattern("1-23-4-56", &[1, 2, 1, 3]),
547            ConditionResult::False
548        );
549        assert_eq!(
550            validate_artikel_pattern("1-AB-4", &[1, 2, 1]),
551            ConditionResult::False
552        );
553        assert_eq!(
554            validate_artikel_pattern("", &[1, 2, 1]),
555            ConditionResult::Unknown
556        );
557    }
558
559    // --- TR-ID / SR-ID validation ---
560
561    #[test]
562    fn test_tr_id() {
563        assert_eq!(validate_tr_id("ABC123"), ConditionResult::True);
564        assert_eq!(validate_tr_id("A"), ConditionResult::True);
565        assert_eq!(validate_tr_id(&"A".repeat(35)), ConditionResult::True);
566        assert_eq!(validate_tr_id(&"A".repeat(36)), ConditionResult::False);
567        assert_eq!(validate_tr_id("has spaces"), ConditionResult::False);
568        assert_eq!(validate_tr_id("has-dash"), ConditionResult::False);
569        assert_eq!(validate_tr_id(""), ConditionResult::Unknown);
570    }
571
572    #[test]
573    fn test_parse_obis() {
574        assert_eq!(parse_obis("1-1:1.8.0"), Some((1, 8)));
575        assert_eq!(parse_obis("1-1:2.29.0"), Some((2, 29)));
576        assert_eq!(parse_obis("1-0:1.8.0*255"), Some((1, 8))); // star suffix
577        assert_eq!(parse_obis(" 1-1:1.8.0 "), Some((1, 8))); // whitespace
578        assert_eq!(parse_obis("no-colon"), None);
579        assert_eq!(parse_obis("1-1:abc.8.0"), None);
580        assert_eq!(parse_obis(""), None);
581    }
582
583    #[test]
584    fn test_obis_wirkarbeit_kumuliert() {
585        // Active energy import/export, total or billing-period integral.
586        assert!(is_obis_wirkarbeit_kumuliert("1-1:1.8.0"));
587        assert!(is_obis_wirkarbeit_kumuliert("1-1:2.8.0"));
588        assert!(is_obis_wirkarbeit_kumuliert("1-1:1.9.0"));
589        assert!(is_obis_wirkarbeit_kumuliert("1-1:2.9.0"));
590        assert!(is_obis_wirkarbeit_kumuliert("1-0:1.8.0*255"));
591        // Reactive energy — not Wirkarbeit.
592        assert!(!is_obis_wirkarbeit_kumuliert("1-1:3.8.0"));
593        assert!(!is_obis_wirkarbeit_kumuliert("1-1:4.8.0"));
594        // 15-min partial value — not kumuliert.
595        assert!(!is_obis_wirkarbeit_kumuliert("1-1:1.29.0"));
596        // Actual value — not kumuliert.
597        assert!(!is_obis_wirkarbeit_kumuliert("1-1:1.5.0"));
598    }
599
600    #[test]
601    fn test_obis_wirkarbeit_quarter_hour() {
602        // Active energy 15-min Lastgang.
603        assert!(is_obis_wirkarbeit_quarter_hour("1-1:1.29.0"));
604        assert!(is_obis_wirkarbeit_quarter_hour("1-1:2.29.0"));
605        assert!(is_obis_wirkarbeit_quarter_hour("1-0:1.29.0*255"));
606        // Cumulated — not 1/4h.
607        assert!(!is_obis_wirkarbeit_quarter_hour("1-1:1.8.0"));
608        assert!(!is_obis_wirkarbeit_quarter_hour("1-1:1.9.0"));
609        // Reactive.
610        assert!(!is_obis_wirkarbeit_quarter_hour("1-1:3.29.0"));
611    }
612
613    #[test]
614    fn test_x509_cert_body() {
615        // Valid: 600-char base64 body (well above 100-char floor).
616        let body = "A".repeat(600);
617        assert_eq!(validate_x509_cert_body(&body), ConditionResult::True);
618
619        // Valid with base64 padding and newlines (PEM-style wrapping).
620        let pem_body = "A".repeat(64) + "\n" + &"B".repeat(64) + "\n" + &"C".repeat(64);
621        assert_eq!(validate_x509_cert_body(&pem_body), ConditionResult::True);
622
623        // Valid with explicit PEM armoring — stripped before charset check.
624        let armored = format!(
625            "-----BEGIN CERTIFICATE-----\n{}\n-----END CERTIFICATE-----",
626            "A".repeat(200)
627        );
628        assert_eq!(validate_x509_cert_body(&armored), ConditionResult::True);
629
630        // Valid: base64 chars include +, /, =
631        let with_punct = "AB+/==".to_string() + &"A".repeat(200);
632        assert_eq!(validate_x509_cert_body(&with_punct), ConditionResult::True);
633
634        // Too short — not a plausible cert.
635        assert_eq!(validate_x509_cert_body("QUJD"), ConditionResult::False);
636
637        // Non-base64 characters.
638        let invalid_chars = "A".repeat(200) + "!@#";
639        assert_eq!(
640            validate_x509_cert_body(&invalid_chars),
641            ConditionResult::False
642        );
643
644        // Empty is Unknown (consistent with other format validators).
645        assert_eq!(validate_x509_cert_body(""), ConditionResult::Unknown);
646        assert_eq!(validate_x509_cert_body("   \n  "), ConditionResult::Unknown);
647    }
648
649    #[test]
650    fn test_sr_id() {
651        // SR-ID uses the same BDEW 11-digit check format as MaLo-ID; reuse
652        // the reference-suite valid example from BO4E-dotnet tests.
653        assert_eq!(validate_sr_id("51238696781"), ConditionResult::True);
654        assert_eq!(validate_sr_id("41373559240"), ConditionResult::False);
655        assert_eq!(validate_sr_id("1234567890"), ConditionResult::False);
656        assert_eq!(validate_sr_id(""), ConditionResult::False);
657    }
658
659    // --- String validation ---
660
661    #[test]
662    fn test_exact_length() {
663        assert_eq!(
664            validate_exact_length("1234567890123456", 16),
665            ConditionResult::True
666        );
667        assert_eq!(validate_exact_length("123", 16), ConditionResult::False);
668        assert_eq!(validate_exact_length("", 16), ConditionResult::Unknown);
669    }
670
671    #[test]
672    fn test_all_digits() {
673        assert_eq!(validate_all_digits("12345"), ConditionResult::True);
674        assert_eq!(validate_all_digits("123a5"), ConditionResult::False);
675        assert_eq!(validate_all_digits(""), ConditionResult::Unknown);
676    }
677}