use metering::{IntervalResolution, ObisCode};
use proptest::prelude::*;
prop_compose! {
fn arb_obis()(
a in 0u8..=255, b in 0u8..=255, c in 0u8..=255,
d in 0u8..=255, e in 0u8..=255, f in 0u8..=255,
) -> ObisCode {
ObisCode { a, b, c, d, e, f }
}
}
proptest! {
#[test]
fn obis_display_round_trips(code in arb_obis()) {
prop_assert_eq!(code.to_string().parse::<ObisCode>(), Ok(code));
prop_assert_eq!(code.to_full_string().parse::<ObisCode>(), Ok(code));
}
#[test]
fn obis_canonical_form_is_stable(code in arb_obis()) {
let canonical = code.to_string();
prop_assert_eq!(ObisCode::normalize(&canonical), Ok(canonical.clone()));
}
#[test]
fn obis_both_spellings_normalise_alike(code in arb_obis()) {
let canonical = code.to_string();
prop_assert_eq!(ObisCode::normalize(&code.to_full_string()), Ok(canonical));
}
#[test]
fn obis_strings_are_injective(x in arb_obis(), y in arb_obis()) {
prop_assert_eq!(x == y, x.to_string() == y.to_string());
}
#[test]
fn max_len_bounds_every_rendering(code in arb_obis()) {
prop_assert!(code.to_string().len() <= ObisCode::MAX_LEN);
prop_assert!(code.to_full_string().len() <= ObisCode::MAX_LEN);
prop_assert!(code.to_string().is_ascii());
}
#[test]
fn display_honours_width_and_alignment(code in arb_obis()) {
let plain = code.to_string();
let width = ObisCode::MAX_LEN + 2;
let right = format!("{code:>width$}");
prop_assert_eq!(right.len(), width);
prop_assert!(right.ends_with(&plain));
prop_assert_eq!(format!("{code:-<width$}").len(), width);
prop_assert_eq!(format!("{code}"), plain);
}
#[test]
fn obis_tolerates_padding_and_leading_zeros(code in arb_obis()) {
let padded = format!(
" {:03}-{:03}:{:03}.{:03}.{:03}*{:03} ",
code.a, code.b, code.c, code.d, code.e, code.f
);
prop_assert_eq!(padded.parse::<ObisCode>(), Ok(code));
prop_assert_eq!(ObisCode::normalize(&padded), Ok(code.to_string()));
}
}
#[test]
fn obis_correction_supersedes_the_reading_it_corrects() {
let reading_key = ObisCode::normalize("1-0:1.8.0").unwrap(); let correction_key = ObisCode::STROM_BEZUG_TOTAL.to_string();
assert_eq!(
reading_key, correction_key,
"one channel must produce one merge key, whichever path wrote it"
);
}
fn arb_resolution() -> impl Strategy<Value = IntervalResolution> {
prop_oneof![
Just(IntervalResolution::QuarterHour),
Just(IntervalResolution::HalfHour),
Just(IntervalResolution::Hour),
Just(IntervalResolution::Day),
Just(IntervalResolution::Month),
Just(IntervalResolution::Year),
(1u32..1_000_000).prop_map(|n| IntervalResolution::from_seconds(n).unwrap()),
]
}
proptest! {
#[test]
fn resolution_display_round_trips(r in arb_resolution()) {
prop_assert_eq!(r.to_string().parse::<IntervalResolution>(), Ok(r));
prop_assert_eq!(r.to_iso8601().parse::<IntervalResolution>(), Ok(r));
}
#[test]
fn resolution_display_is_the_iso_form(r in arb_resolution()) {
prop_assert_eq!(r.to_string(), r.to_iso8601());
}
#[test]
fn resolution_strings_are_injective(x in arb_resolution(), y in arb_resolution()) {
prop_assert_eq!(x == y, x.to_string() == y.to_string());
}
#[test]
fn one_fixed_length_is_one_value(x in arb_resolution(), y in arb_resolution()) {
if let (Some(a), Some(b)) = (x.fixed_seconds(), y.fixed_seconds()) {
prop_assert_eq!(a == b, x == y);
}
}
#[test]
fn from_seconds_is_the_normal_form(secs in 1u32..1_000_000) {
let r = IntervalResolution::from_seconds(secs).expect("nonzero");
prop_assert_eq!(r.fixed_seconds(), Some(secs));
prop_assert_eq!(IntervalResolution::from_seconds(secs), Some(r));
prop_assert_eq!(r.to_string().parse::<IntervalResolution>(), Ok(r));
}
}
#[test]
fn closed_vocabularies_round_trip_and_stay_injective() {
use metering::LoadProfile;
use metering::lifecycle::{MeterLifecycleEventType, MeterStatus};
use std::collections::BTreeSet;
macro_rules! check {
($ty:ty) => {{
let all = <$ty>::ALL;
assert_eq!(all.len(), <$ty>::CODES.len(), stringify!($ty));
let mut seen = BTreeSet::new();
for (v, code) in all.iter().zip(<$ty>::CODES) {
assert_eq!(v.as_str(), *code, "{v:?}");
assert_eq!(&v.to_string(), *code, "{v:?}");
assert_eq!(&v.to_string().parse::<$ty>().unwrap(), v, "{v:?}");
assert_eq!(
&format!(" {} ", code.to_lowercase())
.parse::<$ty>()
.unwrap(),
v,
"{v:?}"
);
assert!(seen.insert(*code), "duplicate code {code}");
}
assert!("NOT_A_CODE".parse::<$ty>().is_err(), stringify!($ty));
}};
}
check!(LoadProfile);
check!(MeterStatus);
check!(MeterLifecycleEventType);
}
mod market_ids {
use metering::{MaloId, MeloId};
use proptest::prelude::*;
pub fn arb_malo() -> impl Strategy<Value = MaloId> {
("[1-9][0-9]{9}").prop_map(|body| {
let check = MaloId::compute_check_digit(&body).expect("ten digits");
format!("{body}{check}").parse().expect("constructed valid")
})
}
proptest! {
#[test]
fn malo_display_round_trips(id in arb_malo()) {
let s = id.to_string();
prop_assert_eq!(s.len(), MaloId::LEN);
prop_assert_eq!(s.parse::<MaloId>(), Ok(id));
}
#[test]
fn malo_single_digit_corruption_is_caught(id in arb_malo(), pos in 0usize..10, bump in 1u8..10) {
let mut bytes = id.to_string().into_bytes();
bytes[pos] = b'0' + ((bytes[pos] - b'0') + bump) % 10;
let corrupted = String::from_utf8(bytes).unwrap();
let blind_spot = pos % 2 == 1 && bump == 5;
if blind_spot {
prop_assert!(corrupted.parse::<MaloId>().is_ok(), "{}", corrupted);
} else {
prop_assert!(corrupted.parse::<MaloId>().is_err(), "{}", corrupted);
}
}
#[test]
fn melo_round_trips_and_uppercases(tail in "[A-Z0-9]{25}") {
let canonical = format!("DE000562{tail}");
let id: MeloId = canonical.parse().unwrap();
prop_assert_eq!(id.as_str(), canonical.as_str());
let relaxed: MeloId = canonical.to_lowercase().parse().unwrap();
prop_assert_eq!(relaxed, id);
}
}
}
mod eic {
use metering::ids::{Eic, EicType};
use proptest::prelude::*;
const ALPHABET: &[u8] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ-";
pub fn arb_eic() -> impl Strategy<Value = Eic> {
("[0-9]{2}", 0usize..EicType::ALL.len(), "[0-9A-Z-]{12}").prop_map(
|(office, type_index, body)| {
let letter = EicType::ALL[type_index].as_str();
let head = format!("{office}{letter}{body}");
ALPHABET
.iter()
.find_map(|c| {
let mut bytes = head.clone().into_bytes();
bytes[14] = *c;
let candidate = String::from_utf8(bytes).expect("ascii");
let check = Eic::compute_check_character(&candidate)?;
format!("{candidate}{check}").parse::<Eic>().ok()
})
.expect("every residue is reachable from the last body character")
},
)
}
proptest! {
#[test]
fn eic_round_trips(id in arb_eic()) {
let s = id.to_string();
prop_assert_eq!(s.len(), Eic::LEN);
prop_assert_eq!(s.parse::<Eic>(), Ok(id));
}
#[test]
fn eics_are_injective(x in arb_eic(), y in arb_eic()) {
prop_assert_eq!(x == y, x.to_string() == y.to_string());
}
#[test]
fn eic_normalises_case_and_padding(id in arb_eic()) {
let messy = format!(" {}\t", id.to_string().to_lowercase());
prop_assert_eq!(messy.parse::<Eic>(), Ok(id));
}
#[test]
fn eic_single_character_corruption_is_caught(
id in arb_eic(),
pos in 0usize..15,
bump in 1u32..37,
) {
let mut bytes = id.to_string().into_bytes();
let current = ALPHABET.iter().position(|c| *c == bytes[pos]).expect("in alphabet");
let replacement = ALPHABET[(current + bump as usize) % ALPHABET.len()];
if replacement == bytes[pos] {
return Ok(());
}
bytes[pos] = replacement;
let corrupted = String::from_utf8(bytes).expect("ascii");
prop_assert!(
corrupted.parse::<Eic>().is_err(),
"{corrupted} was accepted",
);
}
#[test]
fn the_object_type_is_the_third_character(id in arb_eic()) {
let letter = &id.to_string()[2..3];
prop_assert_eq!(
id.object_type().map(|t| t.as_str().to_owned()),
Some(letter.to_owned()),
);
}
}
}
mod marktpartner_id {
use metering::{BdewCode, CodeVergabestelle};
use proptest::prelude::*;
pub fn arb_bdew_code() -> impl Strategy<Value = BdewCode> {
"[0-9]{13}".prop_map(|s| s.parse().expect("thirteen digits"))
}
proptest! {
#[test]
fn bdew_code_round_trips(id in arb_bdew_code()) {
let s = id.to_string();
prop_assert_eq!(s.len(), BdewCode::LEN);
prop_assert!(s.bytes().all(|b| b.is_ascii_digit()));
prop_assert_eq!(s.parse::<BdewCode>(), Ok(id));
}
#[test]
fn bdew_codes_are_injective(x in arb_bdew_code(), y in arb_bdew_code()) {
prop_assert_eq!(x == y, x.to_string() == y.to_string());
}
#[test]
fn bdew_code_tolerates_padding(id in arb_bdew_code()) {
let padded = format!(" {id}\t");
prop_assert_eq!(padded.parse::<BdewCode>(), Ok(id));
}
#[test]
fn the_vergabestelle_is_the_leading_pair(id in arb_bdew_code()) {
let expected = match &id.to_string()[..2] {
"99" => CodeVergabestelle::BdewStrom,
"98" => CodeVergabestelle::DvgwGas,
_ => CodeVergabestelle::Gs1OrOther,
};
prop_assert_eq!(id.vergabestelle(), expected);
}
#[test]
fn the_check_digit_is_advisory_but_correct(body in "[0-9]{12}") {
let check = BdewCode::compute_check_digit(&body).expect("twelve digits");
let consistent: BdewCode = format!("{body}{check}").parse().unwrap();
prop_assert!(consistent.has_bdew_check_digit());
prop_assert_eq!(consistent.check_digit(), check);
let wrong = (check + 1) % 10;
let mismatched: BdewCode = format!("{body}{wrong}").parse().unwrap();
prop_assert!(!mismatched.has_bdew_check_digit());
}
#[test]
fn malformed_codes_are_refused(s in "[0-9]{0,12}|[0-9]{14,20}|[A-Za-z0-9]{13}") {
let ok = s.len() == BdewCode::LEN && s.bytes().all(|b| b.is_ascii_digit());
prop_assert_eq!(s.parse::<BdewCode>().is_ok(), ok, "{}", s);
}
}
}
#[cfg(feature = "serde")]
mod serde_agrees_with_display {
use super::market_ids::arb_malo;
use super::marktpartner_id::arb_bdew_code;
use super::{arb_obis, arb_resolution};
use metering::{BdewCode, IntervalResolution, MaloId, MeloId, ObisCode};
use proptest::prelude::*;
proptest! {
#[test]
fn obis_serde_form_is_the_display_form(code in arb_obis()) {
let encoded = serde_json::to_string(&code).unwrap();
prop_assert_eq!(&encoded, &format!("\"{code}\""));
prop_assert_eq!(serde_json::from_str::<ObisCode>(&encoded).unwrap(), code);
}
#[test]
fn resolution_serde_form_is_the_display_form(r in arb_resolution()) {
let encoded = serde_json::to_string(&r).unwrap();
prop_assert_eq!(&encoded, &format!("\"{r}\""));
prop_assert_eq!(serde_json::from_str::<IntervalResolution>(&encoded).unwrap(), r);
}
#[test]
fn malo_serde_form_is_the_display_form(id in arb_malo()) {
let encoded = serde_json::to_string(&id).unwrap();
prop_assert_eq!(&encoded, &format!("\"{id}\""));
prop_assert_eq!(serde_json::from_str::<MaloId>(&encoded).unwrap(), id);
}
#[test]
fn bdew_code_serde_form_is_the_display_form(id in arb_bdew_code()) {
let encoded = serde_json::to_string(&id).unwrap();
prop_assert_eq!(&encoded, &format!("\"{id}\""));
prop_assert_eq!(serde_json::from_str::<BdewCode>(&encoded).unwrap(), id);
}
#[test]
fn melo_serde_form_is_the_display_form(
nb in "[0-9]{6}",
tail in "[A-Za-z0-9]{25}",
) {
let raw = format!("DE{nb}{tail}");
let id: MeloId = raw.parse().unwrap();
let encoded = serde_json::to_string(&id).unwrap();
prop_assert_eq!(&encoded, &format!("\"{id}\""));
prop_assert_eq!(serde_json::from_str::<MeloId>(&encoded).unwrap(), id);
}
}
#[test]
fn obis_deserialises_from_either_spelling() {
let short: ObisCode = serde_json::from_str("\"1-0:1.8.0\"").unwrap();
let long: ObisCode = serde_json::from_str("\"1-0:1.8.0*255\"").unwrap();
assert_eq!(short, long);
assert_eq!(short, ObisCode::STROM_BEZUG_TOTAL);
}
#[test]
fn obis_deserialises_from_a_non_borrowing_deserialiser() {
let json = r#"["1-0:1.8.0","7-0:3.0.0","1-0:1.8.0*1"]"#;
let decoded: Vec<ObisCode> = serde_json::from_reader(std::io::Cursor::new(json))
.expect("a reader-backed deserialiser must work");
assert_eq!(
decoded,
vec![
ObisCode::STROM_BEZUG_TOTAL,
ObisCode::GAS_VOLUME_M3,
"1-0:1.8.0*1".parse().unwrap(),
]
);
}
#[test]
fn resolution_deserialises_from_a_non_borrowing_deserialiser() {
let json = r#"["PT15M","P1D","PT300S"]"#;
let decoded: Vec<IntervalResolution> =
serde_json::from_reader(std::io::Cursor::new(json)).expect("reader-backed");
assert_eq!(
decoded,
vec![
IntervalResolution::QuarterHour,
IntervalResolution::Day,
IntervalResolution::from_seconds(300).unwrap(),
]
);
}
#[test]
fn malformed_strings_are_rejected() {
assert!(serde_json::from_str::<ObisCode>("\"1-0:1.8\"").is_err());
assert!(serde_json::from_str::<ObisCode>("\"\"").is_err());
assert!(serde_json::from_str::<IntervalResolution>("\"hourly\"").is_err());
assert!(serde_json::from_str::<IntervalResolution>("\"QuarterHour\"").is_err());
assert!(serde_json::from_str::<IntervalResolution>(r#"{"Custom":300}"#).is_err());
}
}