#![allow(
clippy::indexing_slicing,
clippy::unwrap_in_result,
reason = "unwraps and indexing are allowed anywhere in tests"
)]
use std::assert_matches;
use chrono::{DateTime, NaiveDate, NaiveTime, TimeDelta, Utc};
use rust_decimal::Decimal;
use rust_decimal_macros::dec;
use super::{Element, PriceComponent, ReservationRestrictionType, Restrictions, Tariff};
use crate::{
country, currency, json,
money::VatOrigin,
schema::{v221, Integrity, Warning as SchemaWarning},
tariff::{v2x::DimensionType, Warning},
warning, FromSchema as _, Weekday,
};
const VALID: &str = r#"{
"country_code": "NL",
"party_id": "TNM",
"currency": "EUR",
"id": "ID",
"last_updated": "2024-01-01T00:00:00Z",
"elements": [
{"price_components": [{"price": 0.25, "step_size": 1, "type": "ENERGY", "vat": 21.0}]}
]
}"#;
const VALID_RESTRICTIONS: &str = r#"{
"country_code": "NL",
"party_id": "TNM",
"currency": "EUR",
"id": "ID",
"last_updated": "2024-01-01T00:00:00Z",
"elements": [
{
"price_components": [{"price": 0.25, "step_size": 1, "type": "ENERGY", "vat": 21.0}],
"restrictions": {
"start_time": "09:00",
"end_time": "18:00",
"start_date": "2024-01-01",
"end_date": "2024-12-31",
"min_kwh": 1.0,
"max_kwh": 50.0,
"min_current": 6.0,
"max_current": 32.0,
"min_power": 3.0,
"max_power": 22.0,
"min_duration": 60,
"max_duration": 3600,
"day_of_week": ["MONDAY", "TUESDAY"],
"reservation": "RESERVATION"
}
}
]
}"#;
const VALID_FULL: &str = r#"{
"country_code": "NL",
"party_id": "TNM",
"currency": "EUR",
"id": "ID",
"last_updated": "2024-01-01T00:00:00Z",
"min_price": {"excl_vat": 1.0, "incl_vat": 1.21},
"max_price": {"excl_vat": 100.0, "incl_vat": 121.0},
"start_date_time": "2024-01-01T00:00:00Z",
"end_date_time": "2024-12-31T23:59:59Z",
"elements": [
{"price_components": [{"price": 0.25, "step_size": 1, "type": "ENERGY", "vat": 21.0}]}
]
}"#;
fn build_tariff<'buf>(doc: &json::Document<'buf>) -> v221::Tariff<'buf> {
v221::build_tariff(doc).ignore_warnings()
}
fn element<'a, 'buf>(tariff: &'a v221::Tariff<'buf>) -> &'a v221::Element<'buf> {
let Integrity::Ok(elements) = &tariff.elements else {
panic!("elements should be built: {:?}", tariff.elements);
};
let Integrity::Ok(element) = &elements[0] else {
panic!("the element should be built");
};
element
}
fn component<'a, 'buf>(tariff: &'a v221::Tariff<'buf>) -> &'a v221::PriceComponent<'buf> {
let Integrity::Ok(components) = &element(tariff).price_components else {
panic!("price_components should be built");
};
let Integrity::Ok(comp) = &components[0] else {
panic!("the price component should be built");
};
comp
}
fn restrictions<'a, 'buf>(tariff: &'a v221::Tariff<'buf>) -> &'a v221::Restrictions<'buf> {
let element = element(tariff);
let Integrity::Ok(Some(restrictions)) = &element.restrictions else {
panic!("restrictions should be built: {:?}", element.restrictions);
};
restrictions
}
fn all_warnings(warnings: &warning::Set<Warning>) -> Vec<&Warning> {
warnings.path_map().into_values().flatten().collect()
}
#[test]
fn price_component_lowers_from_schema() {
let doc = json::parse(VALID.into()).unwrap();
let tariff = build_tariff(&doc);
let (comp, warnings) = Option::<PriceComponent>::from_schema(component(&tariff))
.unwrap()
.into_parts();
let comp = comp.expect("a valid price component should build");
assert_eq!(comp.dimension_type, DimensionType::Energy);
assert_eq!(Decimal::from(comp.price), dec!(0.25));
assert_eq!(comp.step_size, 1);
assert_matches!(comp.vat, VatOrigin::Provided(_));
assert!(all_warnings(&warnings).is_empty());
}
#[test]
fn absent_vat_is_not_provided() {
let src = VALID.replace(r#", "vat": 21.0"#, "");
let doc = json::parse(src.as_str().into()).unwrap();
let tariff = build_tariff(&doc);
let comp = Option::<PriceComponent>::from_schema(component(&tariff))
.unwrap()
.unwrap();
let comp = comp.expect("a price component without VAT should still build");
assert_matches!(comp.vat, VatOrigin::NotProvided);
}
#[test]
fn unknown_dimension_type_drops_component() {
let src = VALID.replace(r#""type": "ENERGY""#, r#""type": "FOO""#);
let doc = json::parse(src.as_str().into()).unwrap();
let tariff = build_tariff(&doc);
let comp = Option::<PriceComponent>::from_schema(component(&tariff))
.unwrap()
.unwrap();
assert!(comp.is_none());
}
#[test]
fn fractional_step_size_is_rejected() {
let src = VALID.replace(r#""step_size": 1"#, r#""step_size": 1.5"#);
let doc = json::parse(src.as_str().into()).unwrap();
let tariff = build_tariff(&doc);
let err = Option::<PriceComponent>::from_schema(component(&tariff)).unwrap_err();
let error = err.unwrap();
assert_matches!(
error.into_warning(),
Warning::FieldInvalidValue { value, .. } if value == "1.5"
);
}
#[test]
fn missing_price_is_rejected() {
let src = VALID.replace(r#""price": 0.25, "#, "");
let doc = json::parse(src.as_str().into()).unwrap();
let tariff = build_tariff(&doc);
let err = Option::<PriceComponent>::from_schema(component(&tariff)).unwrap_err();
let error = err.unwrap();
assert_matches!(error.into_warning(), Warning::Rejected);
}
#[test]
fn restrictions_lower_from_schema() {
let doc = json::parse(VALID_RESTRICTIONS.into()).unwrap();
let tariff = build_tariff(&doc);
let (res, warnings) = Restrictions::from_schema(restrictions(&tariff))
.unwrap()
.into_parts();
assert_eq!(res.start_time, NaiveTime::from_hms_opt(9, 0, 0));
assert_eq!(res.end_time, NaiveTime::from_hms_opt(18, 0, 0));
assert_eq!(res.start_date, NaiveDate::from_ymd_opt(2024, 1, 1));
assert_eq!(res.end_date, NaiveDate::from_ymd_opt(2024, 12, 31));
assert_eq!(res.min_kwh.map(Decimal::from), Some(dec!(1.0)));
assert_eq!(res.max_kwh.map(Decimal::from), Some(dec!(50.0)));
assert_eq!(res.min_current.map(Decimal::from), Some(dec!(6.0)));
assert_eq!(res.max_current.map(Decimal::from), Some(dec!(32.0)));
assert_eq!(res.min_power.map(Decimal::from), Some(dec!(3.0)));
assert_eq!(res.max_power.map(Decimal::from), Some(dec!(22.0)));
assert_eq!(res.min_duration, Some(TimeDelta::seconds(60)));
assert_eq!(res.max_duration, Some(TimeDelta::seconds(3600)));
assert_eq!(
res.day_of_week,
Some(vec![Weekday::Monday, Weekday::Tuesday])
);
assert_matches!(
res.reservation,
Some(ReservationRestrictionType::Reservation)
);
assert!(all_warnings(&warnings).is_empty());
}
#[test]
fn absent_restriction_fields_are_none() {
let src = VALID.replace(
r#""price_components": ["#,
r#""restrictions": {}, "price_components": ["#,
);
let doc = json::parse(src.as_str().into()).unwrap();
let tariff = build_tariff(&doc);
let res = Restrictions::from_schema(restrictions(&tariff))
.unwrap()
.unwrap();
assert_eq!(res.start_time, None);
assert_eq!(res.end_time, None);
assert_eq!(res.start_date, None);
assert_eq!(res.end_date, None);
assert_eq!(res.min_kwh, None);
assert_eq!(res.max_kwh, None);
assert_eq!(res.min_current, None);
assert_eq!(res.max_current, None);
assert_eq!(res.min_power, None);
assert_eq!(res.max_power, None);
assert_eq!(res.min_duration, None);
assert_eq!(res.max_duration, None);
assert_eq!(res.day_of_week, None);
assert_matches!(res.reservation, None);
}
#[test]
fn null_restriction_fields_are_none() {
const ALL_NULL: &str = r#"{
"start_time": null,
"end_time": null,
"start_date": null,
"end_date": null,
"min_kwh": null,
"max_kwh": null,
"min_current": null,
"max_current": null,
"min_power": null,
"max_power": null,
"min_duration": null,
"max_duration": null,
"day_of_week": null,
"reservation": null
}"#;
let src = VALID.replace(
r#""price_components": ["#,
&format!(r#""restrictions": {ALL_NULL}, "price_components": ["#),
);
let doc = json::parse(src.as_str().into()).unwrap();
let tariff = build_tariff(&doc);
let (res, warnings) = Restrictions::from_schema(restrictions(&tariff))
.unwrap()
.into_parts();
assert_eq!(res.start_time, None);
assert_eq!(res.end_time, None);
assert_eq!(res.start_date, None);
assert_eq!(res.end_date, None);
assert_eq!(res.min_kwh, None);
assert_eq!(res.max_kwh, None);
assert_eq!(res.min_current, None);
assert_eq!(res.max_current, None);
assert_eq!(res.min_power, None);
assert_eq!(res.max_power, None);
assert_eq!(res.min_duration, None);
assert_eq!(res.max_duration, None);
assert_eq!(res.day_of_week, None);
assert_matches!(res.reservation, None);
assert!(all_warnings(&warnings).is_empty());
}
#[test]
fn unusable_restriction_field_is_rejected() {
let src = VALID_RESTRICTIONS.replace(r#""min_kwh": 1.0"#, r#""min_kwh": true"#);
let doc = json::parse(src.as_str().into()).unwrap();
let tariff = build_tariff(&doc);
let err = Restrictions::from_schema(restrictions(&tariff)).unwrap_err();
let error = err.unwrap();
assert_matches!(error.into_warning(), Warning::Rejected);
}
#[test]
fn unknown_weekday_entry_is_rejected() {
let src = VALID_RESTRICTIONS.replace(r#""TUESDAY""#, r#""FUNDAY""#);
let doc = json::parse(src.as_str().into()).unwrap();
let tariff = build_tariff(&doc);
let err = Restrictions::from_schema(restrictions(&tariff)).unwrap_err();
let error = err.unwrap();
assert_matches!(error.into_warning(), Warning::Rejected);
}
#[test]
fn wrong_kind_day_of_week_is_rejected() {
let src = VALID_RESTRICTIONS.replace(r#"["MONDAY", "TUESDAY"]"#, "5");
let doc = json::parse(src.as_str().into()).unwrap();
let tariff = build_tariff(&doc);
let err = Restrictions::from_schema(restrictions(&tariff)).unwrap_err();
let error = err.unwrap();
assert_matches!(error.into_warning(), Warning::Rejected);
}
#[test]
fn element_lowers_from_schema() {
let doc = json::parse(VALID.into()).unwrap();
let tariff = build_tariff(&doc);
let (elem, warnings) = Element::from_schema(element(&tariff)).unwrap().into_parts();
assert_eq!(elem.price_components.len(), 1);
assert_eq!(
elem.price_components[0].dimension_type,
DimensionType::Energy
);
assert!(elem.restrictions.is_none());
assert!(all_warnings(&warnings).is_empty());
}
#[test]
fn element_lowers_its_restrictions() {
let doc = json::parse(VALID_RESTRICTIONS.into()).unwrap();
let tariff = build_tariff(&doc);
let elem = Element::from_schema(element(&tariff)).unwrap().unwrap();
let restrictions = elem.restrictions.expect("the restrictions should build");
assert_eq!(restrictions.start_time, NaiveTime::from_hms_opt(9, 0, 0));
}
#[test]
fn null_restrictions_leaves_the_element_unrestricted() {
let src = VALID.replace(
r#""price_components": ["#,
r#""restrictions": null, "price_components": ["#,
);
let doc = json::parse(src.as_str().into()).unwrap();
let tariff = build_tariff(&doc);
let elem = Element::from_schema(element(&tariff)).unwrap().unwrap();
assert!(elem.restrictions.is_none());
}
#[test]
fn unknown_dimension_type_drops_the_component_from_the_element() {
let src = VALID.replace(r#""type": "ENERGY""#, r#""type": "FOO""#);
let doc = json::parse(src.as_str().into()).unwrap();
let tariff = build_tariff(&doc);
let elem = Element::from_schema(element(&tariff)).unwrap().unwrap();
assert!(elem.price_components.is_empty());
}
#[test]
fn missing_price_components_is_rejected() {
let src = VALID.replace(
r#""price_components": [{"price": 0.25, "step_size": 1, "type": "ENERGY", "vat": 21.0}]"#,
"",
);
let doc = json::parse(src.as_str().into()).unwrap();
let tariff = build_tariff(&doc);
let err = Element::from_schema(element(&tariff)).unwrap_err();
let error = err.unwrap();
assert_matches!(error.into_warning(), Warning::Rejected);
}
#[test]
fn unbuildable_price_component_entry_is_rejected() {
let src = VALID.replace(
r#"{"price": 0.25, "step_size": 1, "type": "ENERGY", "vat": 21.0}"#,
"5",
);
let doc = json::parse(src.as_str().into()).unwrap();
let tariff = build_tariff(&doc);
let err = Element::from_schema(element(&tariff)).unwrap_err();
let error = err.unwrap();
assert_matches!(error.into_warning(), Warning::Rejected);
}
#[test]
fn wrong_kind_restrictions_is_rejected() {
let src = VALID.replace(
r#""price_components": ["#,
r#""restrictions": 5, "price_components": ["#,
);
let doc = json::parse(src.as_str().into()).unwrap();
let tariff = build_tariff(&doc);
let err = Element::from_schema(element(&tariff)).unwrap_err();
let error = err.unwrap();
assert_matches!(error.into_warning(), Warning::Rejected);
}
#[test]
fn tariff_lowers_from_schema() {
let doc = json::parse(VALID.into()).unwrap();
let built = build_tariff(&doc);
let (tariff, warnings) = Tariff::from_schema(&built).unwrap().into_parts();
let party_id = tariff.party_id.expect("the CPO ID should be built");
assert_eq!(party_id.country_code, country::Code::Nl);
assert_eq!(&*party_id.id, "TNM");
assert_eq!(&*tariff.id, "ID");
assert_eq!(tariff.currency, currency::Code::Eur);
assert!(tariff.min_price.is_none());
assert!(tariff.max_price.is_none());
assert_eq!(tariff.start_date_time, None);
assert_eq!(tariff.end_date_time, None);
assert_eq!(tariff.elements.len(), 1);
assert_eq!(
tariff.elements[0].price_components[0].dimension_type,
DimensionType::Energy
);
assert!(all_warnings(&warnings).is_empty());
}
#[test]
fn tariff_lowers_its_optional_fields() {
let doc = json::parse(VALID_FULL.into()).unwrap();
let built = build_tariff(&doc);
let (tariff, warnings) = Tariff::from_schema(&built).unwrap().into_parts();
let min_price = tariff.min_price.expect("min_price should be built");
assert_eq!(Decimal::from(min_price.excl_vat), dec!(1.0));
assert_eq!(min_price.incl_vat.map(Decimal::from), Some(dec!(1.21)));
let max_price = tariff.max_price.expect("max_price should be built");
assert_eq!(Decimal::from(max_price.excl_vat), dec!(100.0));
assert_eq!(max_price.incl_vat.map(Decimal::from), Some(dec!(121.0)));
assert_eq!(
tariff.start_date_time,
Some("2024-01-01T00:00:00Z".parse::<DateTime<Utc>>().unwrap())
);
assert_eq!(
tariff.end_date_time,
Some("2024-12-31T23:59:59Z".parse::<DateTime<Utc>>().unwrap())
);
assert!(all_warnings(&warnings).is_empty());
}
#[test]
fn bare_number_price_lowers_as_excl_vat() {
let src = VALID_FULL.replace(r#"{"excl_vat": 1.0, "incl_vat": 1.21}"#, "1.5");
let doc = json::parse(src.as_str().into()).unwrap();
let built = build_tariff(&doc);
let tariff = Tariff::from_schema(&built).unwrap().unwrap();
let min_price = tariff.min_price.expect("min_price should be built");
assert_eq!(Decimal::from(min_price.excl_vat), dec!(1.5));
assert_eq!(min_price.incl_vat, None);
}
#[test]
fn null_optional_fields_are_none() {
let src = VALID_FULL
.replace(r#"{"excl_vat": 1.0, "incl_vat": 1.21}"#, "null")
.replace(r#"{"excl_vat": 100.0, "incl_vat": 121.0}"#, "null")
.replace(
r#""start_date_time": "2024-01-01T00:00:00Z""#,
r#""start_date_time": null"#,
)
.replace(
r#""end_date_time": "2024-12-31T23:59:59Z""#,
r#""end_date_time": null"#,
);
let doc = json::parse(src.as_str().into()).unwrap();
let built = build_tariff(&doc);
let tariff = Tariff::from_schema(&built).unwrap().unwrap();
assert!(tariff.min_price.is_none());
assert!(tariff.max_price.is_none());
assert_eq!(tariff.start_date_time, None);
assert_eq!(tariff.end_date_time, None);
}
#[test]
fn alpha3_country_code_lowers_to_the_same_code() {
let src = VALID.replace(r#""country_code": "NL""#, r#""country_code": "NLD""#);
let doc = json::parse(src.as_str().into()).unwrap();
let built = build_tariff(&doc);
let tariff = Tariff::from_schema(&built).unwrap().unwrap();
let party_id = tariff.party_id.expect("the CPO ID should be built");
assert_eq!(party_id.country_code, country::Code::Nl);
}
#[test]
fn each_missing_required_field_is_rejected() {
for field in [
r#""country_code": "NL","#,
r#""party_id": "TNM","#,
r#""currency": "EUR","#,
r#""id": "ID","#,
] {
let src = VALID.replace(field, "");
let doc = json::parse(src.as_str().into()).unwrap();
let built = build_tariff(&doc);
let err = Tariff::from_schema(&built)
.err()
.unwrap_or_else(|| panic!("a tariff without `{field}` should be rejected"));
let error = err.unwrap();
assert_matches!(error.into_warning(), Warning::Rejected);
}
}
#[test]
fn missing_elements_is_rejected() {
const NO_ELEMENTS: &str = r#"{
"country_code": "NL",
"party_id": "TNM",
"currency": "EUR",
"id": "ID",
"last_updated": "2024-01-01T00:00:00Z"
}"#;
let doc = json::parse(NO_ELEMENTS.into()).unwrap();
let built = build_tariff(&doc);
let err = Tariff::from_schema(&built).unwrap_err();
let error = err.unwrap();
assert_matches!(error.into_warning(), Warning::Rejected);
}
#[test]
fn unbuildable_element_entry_is_rejected() {
let src = VALID.replace(
r#"{"price_components": [{"price": 0.25, "step_size": 1, "type": "ENERGY", "vat": 21.0}]}"#,
"5",
);
let doc = json::parse(src.as_str().into()).unwrap();
let built = build_tariff(&doc);
let err = Tariff::from_schema(&built).unwrap_err();
let error = err.unwrap();
assert_matches!(error.into_warning(), Warning::Rejected);
}
#[test]
fn unusable_min_price_is_rejected() {
let src = VALID_FULL.replace(r#"{"excl_vat": 1.0, "incl_vat": 1.21}"#, "true");
let doc = json::parse(src.as_str().into()).unwrap();
let built = build_tariff(&doc);
let err = Tariff::from_schema(&built).unwrap_err();
let error = err.unwrap();
assert_matches!(error.into_warning(), Warning::Rejected);
}
#[test]
fn unparsable_start_date_time_is_rejected() {
let src = VALID_FULL.replace(
r#""start_date_time": "2024-01-01T00:00:00Z""#,
r#""start_date_time": "not a date""#,
);
let doc = json::parse(src.as_str().into()).unwrap();
let built = build_tariff(&doc);
let err = Tariff::from_schema(&built).unwrap_err();
let error = err.unwrap();
assert_matches!(error.into_warning(), Warning::DateTime(_));
}
#[test]
fn empty_elements_lowers_to_no_elements() {
let src = VALID.replace(
r#"[
{"price_components": [{"price": 0.25, "step_size": 1, "type": "ENERGY", "vat": 21.0}]}
]"#,
"[]",
);
let doc = json::parse(src.as_str().into()).unwrap();
let (built, schema_warnings) = v221::build_tariff(&doc).into_parts();
let cardinality_warnings = schema_warnings
.path_map()
.into_values()
.flatten()
.filter(|warning| matches!(warning, SchemaWarning::Cardinality { .. }))
.count();
assert_eq!(cardinality_warnings, 1);
let tariff = Tariff::from_schema(&built).unwrap().unwrap();
assert!(tariff.elements.is_empty());
}