#![cfg(all(feature = "v2_2_1", feature = "v2_3_0", feature = "convert"))]
use std::path::Path;
use ocpi_kit::convert::{Downgrade, Lossy, Upgrade};
use ocpi_kit::types::Validate;
use ocpi_kit::{v2_2_1, v2_3_0};
fn fixture(name: &str) -> String {
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("fixtures/2.2.1").join(name);
std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display()))
}
macro_rules! round_trip {
($name:expr, $old:ty, $new:ty) => {{
let original: $old =
serde_json::from_str(&fixture($name)).unwrap_or_else(|e| panic!("{}: {e}", $name));
original.validate().unwrap_or_else(|v| panic!("{} is not conformant: {v}", $name));
let up = Upgrade::<$new>::upgrade(original.clone());
up.value.validate().unwrap_or_else(|v| panic!("{} upgraded is not conformant 2.3.0: {v}", $name));
let down = Downgrade::<$old>::downgrade(up.value.clone());
down.value
.validate()
.unwrap_or_else(|v| panic!("{} round-tripped is not conformant 2.2.1: {v}", $name));
assert_eq!(down.value, original, "{} did not survive a 2.2.1 -> 2.3.0 -> 2.2.1 round trip", $name);
(up.lossy, down.lossy)
}};
}
#[test]
fn every_2_2_1_location_example_survives_a_round_trip_through_2_3_0() {
for name in [
"location_example.json",
"location_example_parking_garage_opening_hours.json",
"location_example_uc2_destination_charger.json",
"location_example_uc3_destination_charger_not_published.json",
"location_example_uc4_limited_visibility.json",
"location_example_uc5_home_charge_point.json",
] {
let (up, down) = round_trip!(name, v2_2_1::locations::Location, v2_3_0::locations::Location);
assert!(up.is_empty(), "{name}: upgrading a Location loses nothing");
assert!(down.is_empty(), "{name}: and neither does coming back");
}
}
#[test]
fn every_2_2_1_tariff_example_survives_a_round_trip_through_2_3_0() {
for name in [
"tariff_1_simple_2hour.json",
"tariff_2_alt_text.json",
"tariff_3_alt_url.json",
"tariff_4_complex.json",
"tariff_5_free_of_charge.json",
"tariff_6_025kwh_start_max_price.json",
"tariff_8_simple_025kwh.json",
"tariff_9_025kwh_start.json",
"tariff_10_025kwh_parking_start.json",
"tariff_11_not_possible_alt_text.json",
"tariff_12_025kwh_min_price.json",
"tariff_13_simple_3hour_5parking.json",
"tariff_14_step_size.json",
"tariff_15_reservation_5_euro_per_hour.json",
"tariff_16_reservation_2_euro_fee_5_euro_per_hour.json",
"tariff_17_reservation_with_expire_fee.json",
"tariff_18_reservation_with_expire_time.json",
"tariffrestriction_example_max_duration.json",
"tariffrestriction_example_max_power.json",
] {
let (up, _down) = round_trip!(name, v2_2_1::tariffs::Tariff, v2_3_0::tariffs::Tariff);
assert!(up.is_empty(), "{name}: upgrading a Tariff loses nothing");
}
}
#[test]
fn a_downgraded_tariff_reports_the_tax_information_it_cannot_carry() {
let (_, down) =
round_trip!("tariff_1_simple_2hour.json", v2_2_1::tariffs::Tariff, v2_3_0::tariffs::Tariff);
assert!(down.is_empty(), "NO is exactly what a 2.2.1 Tariff means");
let mut inclusive: v2_3_0::tariffs::Tariff = Upgrade::<v2_3_0::tariffs::Tariff>::upgrade(
serde_json::from_str::<v2_2_1::tariffs::Tariff>(&fixture("tariff_1_simple_2hour.json"))
.expect("decodes"),
)
.value;
inclusive.tax_included = v2_3_0::tariffs::TaxIncluded::Yes;
let converted = Downgrade::<v2_2_1::tariffs::Tariff>::downgrade(inclusive);
assert_reports(&converted.lossy, "/tax_included");
}
#[test]
fn every_2_2_1_session_and_cdr_example_survives_a_round_trip() {
for name in ["session_example_1_simple_start.json", "session_example_2_short_finished.json"] {
let (up, down) = round_trip!(name, v2_2_1::sessions::Session, v2_3_0::sessions::Session);
assert!(up.is_empty() && down.is_empty(), "{name}");
}
let (up, _) = round_trip!("cdr_example.json", v2_2_1::cdrs::Cdr, v2_3_0::cdrs::Cdr);
assert!(up.is_empty(), "upgrading a CDR loses nothing");
}
#[test]
fn every_2_2_1_token_example_survives_a_round_trip() {
for name in ["token_example_1_app_user.json", "token_example_2_full_rfid.json", "token_put_example.json"]
{
let (up, down) = round_trip!(name, v2_2_1::tokens::Token, v2_3_0::tokens::Token);
assert!(up.is_empty() && down.is_empty(), "{name}");
}
}
#[test]
fn credentials_carry_the_hub_role_across_in_both_directions() {
for name in [
"credentials_example.json",
"credentials_example2.json",
"credentials_example3.json",
"credentials_example4.json",
] {
let original: v2_2_1::credentials::Credentials =
serde_json::from_str(&fixture(name)).unwrap_or_else(|e| panic!("{name}: {e}"));
let up = original.clone().upgrade();
assert!(up.lossy.is_empty(), "{name}: no example uses the HUB role");
let down = Downgrade::<v2_2_1::credentials::Credentials>::downgrade(up.value);
assert_eq!(down.value, original, "{name}");
assert!(down.lossy.is_empty(), "{name}");
}
}
#[test]
fn a_2_3_0_location_downgrades_and_says_what_it_left_behind() {
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("fixtures/2.3.0/location_example.json");
let source = std::fs::read_to_string(path).expect("readable");
let modern: v2_3_0::locations::Location = serde_json::from_str(&source).expect("decodes");
modern.validate().expect("the spec example is conformant");
let converted = Downgrade::<v2_2_1::locations::Location>::downgrade(modern.clone());
converted.value.validate().expect("the downgraded object is conformant 2.2.1");
assert_reports(&converted.lossy, "/parking_places");
assert_reports(&converted.lossy, "/evses/0/parking");
let back = Upgrade::<v2_3_0::locations::Location>::upgrade(converted.value);
assert_eq!(back.value.id, modern.id);
assert_eq!(back.value.evses.len(), modern.evses.len());
assert_eq!(back.value.help_phone, None, "help_phone genuinely did not survive");
}
#[test]
fn connector_standards_added_after_2_2_1_keep_their_wire_value_through_a_downgrade() {
let mut modern: v2_3_0::locations::Connector = serde_json::from_str(
r#"{"id":"1","standard":"IEC_62196_T2","format":"SOCKET","power_type":"DC","max_voltage":920,"max_amperage":400,"last_updated":"2024-01-01T00:00:00Z"}"#,
)
.expect("decodes");
modern.standard = v2_3_0::locations::ConnectorType::Mcs;
let converted = Downgrade::<v2_2_1::locations::Connector>::downgrade(modern);
assert!(converted.lossy.is_empty(), "the value crossed intact");
let json = serde_json::to_string(&converted.value).expect("encodes");
assert!(json.contains(r#""standard":"MCS""#), "{json}");
assert!(converted.value.validate().is_err());
}
#[test]
#[cfg(feature = "tariffs")]
fn a_2_2_1_cdr_prices_the_same_directly_as_it_does_after_an_upgrade() {
use ocpi_kit::tariffs::{PricedSession, PricingEngine, TimeZone};
let old: v2_2_1::cdrs::Cdr = serde_json::from_str(&fixture("cdr_example.json")).expect("decodes");
let tariff: v2_3_0::tariffs::Tariff = Upgrade::<v2_3_0::tariffs::Tariff>::upgrade(
old.tariffs.first().expect("the example embeds its tariff").clone(),
)
.value;
let zone = TimeZone::named("Europe/Amsterdam").expect("a real zone");
let direct = PricedSession::from_cdr_v2_2_1(&old, zone.clone());
let upgraded = Upgrade::<v2_3_0::cdrs::Cdr>::upgrade(old).value;
let bridged = PricedSession::from_cdr(&upgraded, zone);
assert_eq!(direct.periods, bridged.periods, "the periods are the same objects in both versions");
let engine = PricingEngine::new();
let a = engine.price(&direct, std::slice::from_ref(&tariff)).expect("prices");
let b = engine.price(&bridged, &[tariff]).expect("prices");
assert_eq!(a.total_excl_vat, b.total_excl_vat, "a version boundary cannot change a cost");
assert_eq!(a.total_incl_vat, b.total_incl_vat);
assert!(a.total_excl_vat > "0".parse().expect("a number"), "the example is not free");
}
#[test]
fn no_field_outside_the_declared_divergences_moves_across_a_version_boundary() {
use ocpi_kit::VersionNumber;
use ocpi_kit::convert::wire::ObjectKind;
let corpus: &[(&str, ObjectKind)] = &[
("location_example.json", ObjectKind::Location),
("location_example_parking_garage_opening_hours.json", ObjectKind::Location),
("location_example_uc2_destination_charger.json", ObjectKind::Location),
("location_example_uc5_home_charge_point.json", ObjectKind::Location),
("tariff_4_complex.json", ObjectKind::Tariff),
("tariff_6_025kwh_start_max_price.json", ObjectKind::Tariff),
("tariff_12_025kwh_min_price.json", ObjectKind::Tariff),
("cdr_example.json", ObjectKind::Cdr),
("session_example_2_short_finished.json", ObjectKind::Session),
("token_example_2_full_rfid.json", ObjectKind::Token),
("credentials_example.json", ObjectKind::Credentials),
];
for (name, kind) in corpus {
let original: serde_json::Value =
serde_json::from_str(&fixture(name)).unwrap_or_else(|e| panic!("{name}: {e}"));
let up = kind
.bridge(&VersionNumber::V2_2_1, &VersionNumber::V2_3_0, original)
.unwrap_or_else(|e| panic!("{name}: {e}"));
let back = kind
.bridge(&VersionNumber::V2_3_0, &VersionNumber::V2_2_1, up.value.clone())
.unwrap_or_else(|e| panic!("{name}: {e}"));
let declared = kind.divergent_fields();
let before = back.value.as_object().expect("an object");
let after = up.value.as_object().expect("an object");
let keys: std::collections::BTreeSet<&String> = before.keys().chain(after.keys()).collect();
for key in keys {
if declared.contains(&key.as_str()) {
continue;
}
assert_eq!(
before.get(key),
after.get(key),
"{name}: `{key}` changed crossing between the versions, but \
{kind}::divergent_fields does not say so — a merge patch writing it would be let \
through and mean something else at the far end",
);
}
}
}
#[track_caller]
fn assert_reports(lossy: &Lossy, pointer: &str) {
assert!(
lossy.as_slice().iter().any(|l| l.pointer == pointer),
"expected a loss at {pointer}, got: {lossy}"
);
}