#![allow(clippy::missing_panics_doc, reason = "tests are allowed to panic")]
#![allow(clippy::panic, reason = "tests are allowed panic")]
use std::collections::{BTreeMap, BTreeSet};
use chrono::TimeDelta;
use rust_decimal::Decimal;
use serde::Deserialize;
use super::{Report, TariffReport, Total, Warning};
use crate::{
assert_approx_eq,
duration::ToHoursDecimal,
json,
number::{self, IsZero as _},
schema,
test::{self, ApproxEq as _, ExpectFile, ExpectValue, Expectation},
timezone,
warning::{self, VerdictExt as _},
Caveat, Kwh, Price,
};
const PRECISION: u32 = 2;
#[test]
const fn warning_kind_should_be_send_and_sync() {
const fn f<T: Send + Sync>() {}
f::<Warning>();
}
pub trait UnwrapReport {
#[track_caller]
fn unwrap_report(self, json_source: &str) -> Caveat<Report, Warning>;
}
impl UnwrapReport for super::Verdict<Report> {
fn unwrap_report(self, json_source: &str) -> Caveat<Report, Warning> {
match self.only_error() {
Ok(v) => v,
Err(error) => {
let warning::test::ErrorSourceContext {
context: _,
element_path,
element_position,
error,
} = error.into_context(json_source).unwrap();
let json_source = serde_json::from_str::<serde_json::Value>(json_source).unwrap();
panic!(
"Unable to price the CDR due to an error at path `{element_path}`:\n{error}\n{}",
json::test::LineHighlighter::from_value(&json_source, element_position)
);
}
}
}
}
#[test]
#[should_panic(expected = "Unable to price the CDR due to an error at path `$`")]
fn unwrap_report_reports_the_error_that_stopped_pricing() {
const CDR: &str = r#"{"currency": "EUR"}"#;
test::setup();
let doc = json::parse_object(CDR).unwrap();
let root = doc.root();
let mut warnings = warning::Set::new();
warnings.insert(root, Warning::NoPeriods);
let verdict: super::Verdict<Report> = warnings.bail(root, Warning::NoValidTariff);
let _report = verdict.unwrap_report(CDR);
}
#[derive(Debug, Default)]
pub(crate) struct HoursDecimal(Decimal);
impl ToHoursDecimal for HoursDecimal {
fn to_hours_dec(&self) -> Decimal {
self.0
}
}
fn decimal<'de, D>(deserializer: D) -> Result<Decimal, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::Deserialize;
let mut d = <Decimal as Deserialize>::deserialize(deserializer)?;
d.rescale(number::SCALE);
Ok(d)
}
impl<'de> Deserialize<'de> for HoursDecimal {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
decimal(deserializer).map(Self)
}
}
#[derive(serde::Deserialize)]
pub(crate) struct Expect {
pub timezone_find: Option<timezone::test::FindOrInferExpect>,
pub tariff_parse: Option<ParseExpect>,
pub cdr_parse: Option<ParseExpect>,
pub cdr_price: Option<PriceExpect>,
}
#[expect(
clippy::struct_field_names,
reason = "When deconstructed these fields will always be called *_expect. This avoids having to rename them in-place."
)]
pub(crate) struct ExpectFields {
pub timezone_find_expect: ExpectFile<timezone::test::FindOrInferExpect>,
pub tariff_parse_expect: ExpectFile<ParseExpect>,
pub cdr_parse_expect: ExpectFile<ParseExpect>,
pub cdr_price_expect: ExpectFile<PriceExpect>,
}
impl test::IntoFields<ExpectFields> for ExpectFile<Expect> {
fn into_fields(self) -> ExpectFields {
let ExpectFile {
value,
expect_file_name,
} = self;
match value {
Some(expect) => {
let Expect {
timezone_find,
tariff_parse,
cdr_parse,
cdr_price,
} = expect;
ExpectFields {
timezone_find_expect: ExpectFile::with_value(timezone_find, &expect_file_name),
tariff_parse_expect: ExpectFile::with_value(tariff_parse, &expect_file_name),
cdr_parse_expect: ExpectFile::with_value(cdr_parse, &expect_file_name),
cdr_price_expect: ExpectFile::with_value(cdr_price, &expect_file_name),
}
}
None => ExpectFields {
timezone_find_expect: ExpectFile::only_file_name(&expect_file_name),
tariff_parse_expect: ExpectFile::only_file_name(&expect_file_name),
cdr_parse_expect: ExpectFile::only_file_name(&expect_file_name),
cdr_price_expect: ExpectFile::only_file_name(&expect_file_name),
},
}
}
}
#[track_caller]
pub(crate) fn assert_schema_warnings(
expect_field: &str,
warnings: &warning::Set<schema::Warning>,
expect: ExpectFile<ParseExpect>,
) {
let ExpectFile {
value,
expect_file_name,
} = expect;
let warnings_schema = value
.map(|ParseExpect { warnings_schema }| warnings_schema)
.unwrap_or(Expectation::Absent);
warning::test::assert_warnings(
&format!("`{expect_field}` in the `{expect_file_name}` file"),
warnings,
warnings_schema,
);
}
#[track_caller]
fn assert_tariff_report(
expect_file_name: &str,
report: &TariffReport,
expected: Expectation<test::WarningMap>,
) {
let TariffReport { origin, warnings } = report;
let id = &origin.id;
warning::test::assert_path_map_warnings(
&format!(
"`cdr_price.tariff_reports` for the tariff with id `{id}` in the \
`{expect_file_name}` file"
),
warnings,
expected,
);
}
#[track_caller]
pub(crate) fn assert_price_report(
report: Caveat<Report, Warning>,
expect: ExpectFile<PriceExpect>,
) {
let (report, warnings) = report.into_parts();
let Report {
tariff_reports,
periods: _,
tariff_used,
timezone: _,
billed_energy: _,
billed_idle_time: _,
billed_charging_time: _,
total_charging_time: _,
total_cost,
total_fixed_cost,
total_time,
total_charging_time_cost,
total_energy,
total_energy_cost,
total_idle_time,
total_idle_cost,
} = report;
let ExpectFile {
value: expect,
expect_file_name,
} = expect;
let (
warnings_expect,
tariff_index_expect,
tariff_id_expect,
tariff_reports_expect,
total_cost_expectation,
total_fixed_cost_expectation,
total_charging_time_expectation,
total_charging_time_cost_expectation,
total_energy_expectation,
total_energy_cost_expectation,
total_idle_time_expectation,
total_idle_time_cost_expectation,
) = expect
.map(|exp| {
let PriceExpect {
warnings,
tariff_index,
tariff_id,
tariff_reports,
total_cost,
total_fixed_cost,
total_charging_time,
total_charging_time_cost,
total_energy,
total_energy_cost,
total_idle_time,
total_idle_time_cost,
} = exp;
(
warnings,
tariff_index,
tariff_id,
tariff_reports,
total_cost,
total_fixed_cost,
total_charging_time,
total_charging_time_cost,
total_energy,
total_energy_cost,
total_idle_time,
total_idle_time_cost,
)
})
.unwrap_or((
Expectation::Absent,
Expectation::Absent,
Expectation::Absent,
Expectation::Absent,
Expectation::Absent,
Expectation::Absent,
Expectation::Absent,
Expectation::Absent,
Expectation::Absent,
Expectation::Absent,
Expectation::Absent,
Expectation::Absent,
));
warning::test::assert_warnings(
&format!("`cdr_price.warnings` in the `{expect_file_name}` file"),
&warnings,
warnings_expect,
);
match tariff_reports_expect {
Expectation::Absent => {
for report in &tariff_reports {
assert_tariff_report(&expect_file_name, report, Expectation::Absent);
}
}
Expectation::Present(expectation) => {
let mut expected = BTreeMap::new();
for TariffReportExpect { id, warnings } in expectation.expect_value() {
assert!(
!expected.contains_key(&id),
"The `cdr_price.tariff_reports` of the `{expect_file_name}` file lists the \
tariff id `{id}` more than once"
);
expected.insert(id, warnings);
}
let mut ids_used = BTreeSet::new();
for report in &tariff_reports {
let id = &report.origin.id;
let Some(warnings) = expected.get(id) else {
panic!(
"The `cdr_price.tariff_reports` of the `{expect_file_name}` file has no \
entry for the tariff with id `{id}`"
);
};
ids_used.insert(id);
let warnings = Expectation::Present(ExpectValue::Some(warnings.clone()));
assert_tariff_report(&expect_file_name, report, warnings);
}
let ids_unused = expected
.keys()
.filter(|id| !ids_used.contains(id))
.collect::<Vec<_>>();
assert!(
ids_unused.is_empty(),
"The `cdr_price.tariff_reports` of the `{expect_file_name}` file has entries for \
tariffs that were not used to price this CDR:\n{ids_unused:#?}"
);
}
}
if let Expectation::Present(expectation) = tariff_id_expect {
assert_eq!(tariff_used.id, expectation.expect_value());
}
if let Expectation::Present(expectation) = tariff_index_expect {
assert_eq!(tariff_used.index, expectation.expect_value());
}
total_cost_expectation.expect_price("total_cost", &total_cost);
total_fixed_cost_expectation.expect_opt_price("total_fixed_cost", &total_fixed_cost);
total_charging_time_expectation.expect_duration("total_time", &total_time);
total_charging_time_cost_expectation
.expect_opt_price("total_charging_time_cost", &total_charging_time_cost);
total_energy_expectation.expect_opt_kwh("total_energy", &total_energy);
total_energy_cost_expectation.expect_opt_price("total_energy_cost", &total_energy_cost);
total_idle_time_expectation.expect_opt_duration("total_idle_time", &total_idle_time);
total_idle_time_cost_expectation.expect_opt_price("total_idle_cost", &total_idle_cost);
}
#[derive(serde::Deserialize)]
pub struct ParseExpect {
#[serde(default)]
warnings_schema: Expectation<test::WarningMap>,
}
#[derive(serde::Deserialize)]
pub struct PriceExpect {
#[serde(default)]
warnings: Expectation<test::WarningMap>,
#[serde(default)]
tariff_index: Expectation<usize>,
#[serde(default)]
tariff_id: Expectation<String>,
#[serde(default)]
tariff_reports: Expectation<Vec<TariffReportExpect>>,
#[serde(default)]
total_cost: Expectation<Price>,
#[serde(default)]
total_fixed_cost: Expectation<Price>,
#[serde(default)]
total_charging_time: Expectation<HoursDecimal>,
#[serde(default)]
total_charging_time_cost: Expectation<Price>,
#[serde(default)]
total_energy: Expectation<Kwh>,
#[serde(default)]
total_energy_cost: Expectation<Price>,
#[serde(default)]
total_idle_time: Expectation<HoursDecimal>,
#[serde(default)]
total_idle_time_cost: Expectation<Price>,
}
#[derive(Debug, Deserialize)]
struct TariffReportExpect {
id: String,
#[serde(default)]
warnings: test::WarningMap,
}
impl Expectation<Price> {
#[track_caller]
fn expect_opt_price(self, field_name: &str, total: &Total<Option<Price>>) {
if let Expectation::Present(expect_value) = self {
match (expect_value.into_option(), total.calculated) {
(Some(a), Some(b)) => assert!(
a.approx_eq(&b),
"Expected `{a}` but `{b}` was calculated for `{field_name}`"
),
(Some(a), None) => {
panic!("Expected `{a}`, but no price was calculated for `{field_name}`")
}
(None, Some(b)) => {
panic!("Expected no value, but `{b}` was calculated for `{field_name}`")
}
(None, None) => (),
}
} else {
match (total.cdr, total.calculated) {
(None, None) => (),
(None, Some(calculated)) => {
assert!(calculated.is_zero(), "The CDR field `{field_name}` doesn't have a value but a value was calculated; calculated: {calculated}");
}
(Some(cdr), None) => {
assert!(
cdr.is_zero(),
"The CDR field `{field_name}` has a value but the calculated value is none; cdr: {cdr}"
);
}
(Some(cdr), Some(calculated)) => {
assert!(
cdr.approx_eq(&calculated),
"Comparing `{field_name}` field with CDR"
);
}
}
}
}
#[track_caller]
fn expect_price(self, field_name: &str, total: &Total<Price, Option<Price>>) {
if let Expectation::Present(expect_value) = self {
match (expect_value.into_option(), total.calculated) {
(Some(a), Some(b)) => assert!(
a.approx_eq(&b),
"Expected `{a}` but `{b}` was calculated for `{field_name}`"
),
(Some(a), None) => {
panic!("Expected `{a}`, but no price was calculated for `{field_name}`")
}
(None, Some(b)) => {
panic!("Expected no value, but `{b}` was calculated for `{field_name}`")
}
(None, None) => (),
}
} else if let Some(calculated) = total.calculated {
assert!(
total.cdr.approx_eq(&calculated),
"CDR contains `{}` but `{}` was calculated for `{field_name}`",
total.cdr,
calculated
);
} else {
assert!(
total.cdr.is_zero(),
"The CDR field `{field_name}` has a value but the calculated value is none; cdr: {:?}",
total.cdr
);
}
}
}
impl Expectation<HoursDecimal> {
#[track_caller]
fn expect_duration(self, field_name: &str, total: &Total<TimeDelta>) {
if let Expectation::Present(expect_value) = self {
assert_approx_eq!(
expect_value.expect_value().to_hours_dec(),
total.calculated.to_hours_dec(),
"Comparing `{field_name}` field with expectation"
);
} else {
assert_approx_eq!(
total.cdr.to_hours_dec(),
total.calculated.to_hours_dec(),
"Comparing `{field_name}` field with CDR"
);
}
}
#[track_caller]
fn expect_opt_duration(
self,
field_name: &str,
total: &Total<Option<TimeDelta>, Option<TimeDelta>>,
) {
if let Expectation::Present(expect_value) = self {
assert_approx_eq!(
expect_value
.into_option()
.unwrap_or_default()
.to_hours_dec(),
&total
.calculated
.as_ref()
.map(ToHoursDecimal::to_hours_dec)
.unwrap_or_default(),
"Comparing `{field_name}` field with expectation"
);
} else {
assert_approx_eq!(
total.cdr.unwrap_or_default().to_hours_dec(),
total.calculated.unwrap_or_default().to_hours_dec(),
"Comparing `{field_name}` field with CDR"
);
}
}
}
impl Expectation<Kwh> {
#[track_caller]
fn expect_opt_kwh(self, field_name: &str, total: &Total<Kwh, Option<Kwh>>) {
if let Expectation::Present(expect_value) = self {
assert_eq!(
expect_value
.into_option()
.map(|kwh| kwh.round_dp(PRECISION)),
total
.calculated
.map(|kwh| kwh.rescale().round_dp(PRECISION)),
"Comparing `{field_name}` field with expectation"
);
} else {
assert_eq!(
total.cdr.round_dp(PRECISION),
total
.calculated
.map(|kwh| kwh.rescale().round_dp(PRECISION))
.unwrap_or_default(),
"Comparing `{field_name}` field with CDR"
);
}
}
}