use metering::{ObisCode, Resolution};
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!(format!("{code:#}").parse::<ObisCode>(), Ok(code));
}
#[test]
fn obis_canonical_form_is_stable(code in arb_obis()) {
let canonical = code.to_string();
prop_assert_eq!(canonical.parse::<ObisCode>().map(|c| c.to_string()), Ok(canonical.clone()));
}
#[test]
fn obis_both_spellings_normalise_alike(code in arb_obis()) {
let canonical = code.to_string();
prop_assert_eq!(format!("{code:#}").parse::<ObisCode>().map(|c| c.to_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);
let full = format!("{code:#}");
prop_assert!(full.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!(padded.parse::<ObisCode>().map(|c| c.to_string()), Ok(code.to_string()));
}
}
#[test]
fn obis_correction_supersedes_the_reading_it_corrects() {
let reading_key = "1-0:1.8.0".parse::<ObisCode>().unwrap().to_string(); 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 = Resolution> {
prop_oneof![
Just(Resolution::QUARTER_HOUR),
Just(Resolution::HALF_HOUR),
Just(Resolution::Hour),
Just(Resolution::Day),
Just(Resolution::Month),
Just(Resolution::Year),
prop::sample::select(vec![1u8, 2, 3, 4, 5, 6, 10, 12, 15, 20, 30])
.prop_map(|m| Resolution::minutes(m).unwrap()),
]
}
proptest! {
#[test]
fn resolution_display_round_trips(r in arb_resolution()) {
prop_assert_eq!(r.to_string().parse::<Resolution>(), Ok(r));
}
#[test]
fn resolution_parse_never_panics(s in "\\PC{0,12}") {
let _ = s.parse::<Resolution>();
}
#[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) {
match Resolution::from_seconds(secs) {
Some(r) => {
prop_assert_eq!(r.fixed_seconds(), Some(secs));
prop_assert_eq!(r.to_string().parse::<Resolution>(), Ok(r));
}
None => prop_assert!(secs % 60 != 0 || secs > 3600 || 3600 % secs != 0),
}
}
}
#[test]
fn closed_vocabularies_round_trip_and_stay_injective() {
use metering::slp::strom::LoadProfile;
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);
}
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::ids::{BdewCode, Issuer};
use proptest::prelude::*;
pub fn arb_bdew_code() -> impl Strategy<Value = BdewCode> {
"[0-9]{12}".prop_map(|body| {
let check = BdewCode::compute_check_digit(&body).expect("twelve digits");
format!("{body}{check}")
.parse()
.expect("a valid check digit")
})
}
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_issuer_is_the_leading_pair(id in arb_bdew_code()) {
let expected = match &id.to_string()[..2] {
"99" => Issuer::Bdew,
"98" => Issuer::Dvgw,
_ => Issuer::Gs1,
};
prop_assert_eq!(id.issuer(), 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;
if let Ok(mismatched) = format!("{body}{wrong}").parse::<BdewCode>() {
prop_assert!(!mismatched.has_bdew_check_digit());
prop_assert_eq!(mismatched.issuer(), Issuer::Gs1);
}
}
#[test]
fn malformed_codes_are_refused(s in "[0-9]{0,12}|[0-9]{14,20}|[A-Za-z][A-Za-z0-9]{12}") {
prop_assert!(s.parse::<BdewCode>().is_err(), "{}", 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::{MaloId, MeloId, ObisCode, Resolution, ids::BdewCode};
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::<Resolution>(&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","PT5M"]"#;
let decoded: Vec<Resolution> =
serde_json::from_reader(std::io::Cursor::new(json)).expect("reader-backed");
assert_eq!(
decoded,
vec![
Resolution::QUARTER_HOUR,
Resolution::Day,
Resolution::minutes(5).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::<Resolution>("\"hourly\"").is_err());
assert!(serde_json::from_str::<Resolution>("\"QuarterHour\"").is_err());
assert!(serde_json::from_str::<Resolution>(r#"{"Custom":300}"#).is_err());
}
}