#![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 tracing::debug;
use crate::{
assert_approx_eq,
duration::ToHoursDecimal,
json, number,
test::{self, ApproxEq, ExpectFile, Expectation},
timezone,
warning::{self, Warning as _},
Caveat, Kwh, ObjectType, Price,
};
use super::{Report, TariffReport, Total, Warning};
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 {
Ok(v) => v,
Err(set) => {
let (error, _warnings) = set.into_parts();
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)
);
}
}
}
}
#[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_parse_report(
object_type: ObjectType,
unexpected_fields: json::UnexpectedFields<'_>,
expect: ExpectFile<ParseExpect>,
) {
let ExpectFile {
value,
expect_file_name,
} = expect;
let unexpected_fields_expect = value
.map(|exp| exp.unexpected_fields)
.unwrap_or(Expectation::Absent);
if let Expectation::Present(expectation) = unexpected_fields_expect {
let unexpected_fields_expect = expectation.expect_value();
for field in unexpected_fields {
assert!(
unexpected_fields_expect.contains(&field.to_string()),
"The {object_type} has an unexpected field that's not expected in `{expect_file_name}`: `{field}`"
);
}
} else {
assert!(
unexpected_fields.is_empty(),
"The {object_type} has unexpected fields but the expect file doesn't `{expect_file_name}`; {unexpected_fields:#}",
);
}
}
#[track_caller]
pub(crate) fn assert_price_report(
report: Caveat<Report, Warning>,
expect: ExpectFile<PriceExpect>,
) {
let (report, warnings) = report.into_parts();
let Report {
mut tariff_reports,
periods: _,
tariff_used,
timezone: _,
billed_energy: _,
billed_parking_time: _,
billed_charging_time: _,
total_charging_time: _,
total_cost,
total_fixed_cost,
total_time,
total_time_cost,
total_energy,
total_energy_cost,
total_parking_time,
total_parking_cost,
total_reservation_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_time_expectation,
total_time_cost_expectation,
total_energy_expectation,
total_energy_cost_expectation,
total_parking_time_expectation,
total_parking_cost_expectation,
total_reservation_cost_expectation,
) = expect
.map(|exp| {
let PriceExpect {
warnings,
tariff_index,
tariff_id,
tariff_reports,
total_cost,
total_fixed_cost,
total_time,
total_time_cost,
total_energy,
total_energy_cost,
total_parking_time,
total_parking_cost,
total_reservation_cost,
} = exp;
(
warnings,
tariff_index,
tariff_id,
tariff_reports,
total_cost,
total_fixed_cost,
total_time,
total_time_cost,
total_energy,
total_energy_cost,
total_parking_time,
total_parking_cost,
total_reservation_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,
Expectation::Absent,
));
if let Expectation::Present(expectation) = warnings_expect {
let warnings_expect = expectation.expect_value();
debug!("{warnings_expect:?}");
for group in &warnings {
let (element, warnings) = group.to_parts();
let Some(warnings_expect) = warnings_expect.get(element.path().as_str()) else {
let warning_ids = warnings
.iter()
.map(|k| format!(" \"{}\",", k.id()))
.collect::<Vec<_>>()
.join("\n");
panic!("No warnings expected `{expect_file_name}` for `Element` at `{}` but {} warnings were reported:\n[\n{}\n]", element.path(), warnings.len(), warning_ids);
};
let warnings_expect = warnings_expect
.iter()
.map(|s| &**s)
.collect::<BTreeSet<_>>();
for warning_kind in warnings {
let id = warning_kind.id();
assert!(
warnings_expect.contains(id.as_str()),
"Unexpected warning `{id}` for `Element` at `{}`",
element.path()
);
}
}
} else {
assert!(
warnings.is_empty(),
"The expectation file at `{expect_file_name}` did not expect warnings, but the CDR has warnings:\n\
{:#?}\n\
These warnings have the messages:\n\
{:#?}",
warnings.path_id_map(),
warnings.path_msg_map()
);
}
if let Expectation::Present(expectation) = tariff_reports_expect {
let tariff_reports_expect: BTreeMap<_, _> = expectation
.expect_value()
.into_iter()
.map(|TariffReportExpect { id, warnings }| (id, warnings))
.collect();
for report in &mut tariff_reports {
let TariffReport { origin, warnings } = report;
let id = &origin.id;
let Some(warnings_expect) = tariff_reports_expect.get(id) else {
panic!("A tariff with {id} is not expected `{expect_file_name}`");
};
debug!("{warnings_expect:?}");
for (elem_path, warnings) in warnings {
let Some(warnings_expect) = warnings_expect.get(elem_path.as_str()) else {
let warning_ids = warnings
.iter()
.map(|k| format!(" \"{}\",", k.id()))
.collect::<Vec<_>>()
.join("\n");
panic!("No warnings expected for `Element` at `{elem_path}` but {} warnings were reported:\n[\n{}\n]", warnings.len(), warning_ids);
};
let warnings_expect = warnings_expect
.iter()
.map(|s| &**s)
.collect::<BTreeSet<_>>();
for warning_kind in warnings {
let id = warning_kind.id();
assert!(
warnings_expect.contains(id.as_str()),
"Unexpected warning `{id}` for `Element` at `{elem_path}`"
);
}
}
}
} else {
for report in &tariff_reports {
let TariffReport { origin, warnings } = report;
let id = &origin.id;
assert!(
warnings.is_empty(),
"The tariff with id `{id}` has warnings but the expect file `{expect_file_name}` has none in the `tariff_reports` map.\n {warnings:#?}"
);
}
}
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_time_expectation.expect_duration("total_time", &total_time);
total_time_cost_expectation.expect_opt_price("total_time_cost", &total_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_parking_time_expectation.expect_opt_duration("total_parking_time", &total_parking_time);
total_parking_cost_expectation.expect_opt_price("total_parking_cost", &total_parking_cost);
total_reservation_cost_expectation
.expect_opt_price("total_reservation_cost", &total_reservation_cost);
}
#[derive(serde::Deserialize)]
pub struct ParseExpect {
#[serde(default)]
unexpected_fields: Expectation<Vec<String>>,
}
#[derive(serde::Deserialize)]
pub struct PriceExpect {
#[serde(default)]
warnings: Expectation<BTreeMap<String, Vec<String>>>,
#[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_time: Expectation<HoursDecimal>,
#[serde(default)]
total_time_cost: Expectation<Price>,
#[serde(default)]
total_energy: Expectation<Kwh>,
#[serde(default)]
total_energy_cost: Expectation<Price>,
#[serde(default)]
total_parking_time: Expectation<HoursDecimal>,
#[serde(default)]
total_parking_cost: Expectation<Price>,
#[serde(default)]
total_reservation_cost: Expectation<Price>,
}
#[derive(Debug, Deserialize)]
struct TariffReportExpect {
id: String,
#[serde(default)]
warnings: BTreeMap<String, Vec<String>>,
}
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"
);
}
}
}