use super::*;
use crate::product_group::{BatteryData, ProductGroup, ProductGroupData};
use crate::status::PassportStatus;
fn publishable_battery(battery_type: crate::product_group::BatteryType) -> Passport {
use crate::product_group::{
BatteryStatus, DynamicPerformance, HazardousSubstance, MaterialComposition, StateOfHealth,
TemperatureRange,
};
let range = TemperatureRange {
min_c: -20.0,
max_c: 60.0,
};
let mat = || {
Some(vec![MaterialComposition {
name: "LiFePO4".into(),
weight_pct: 100.0,
cas_number: None,
}])
};
let data = BatteryData {
battery_type,
battery_weight_kg: Some(400.0),
hazardous_substances: Some(vec![HazardousSubstance {
name: "Nickel sulfate".into(),
cas_number: None,
concentration_pct: None,
}]),
usable_extinguishing_agent: Some("Class D dry powder".into()),
critical_raw_materials: Some(vec![]),
recycled_content_cobalt_pct: Some(4.0),
recycled_content_lithium_pct: Some(4.0),
recycled_content_nickel_pct: Some(4.0),
recycled_content_lead_pct: Some(0.0),
renewable_content_pct: Some(10.0),
minimal_voltage_v: Some(2.5),
maximum_voltage_v: Some(4.2),
original_power_capability_w: Some(150_000.0),
power_limit_min_w: Some(1_000.0),
power_limit_max_w: Some(180_000.0),
expected_lifetime_cycles: Some(3000),
expected_lifetime_reference_test: Some("IEC 62660-1:2018".into()),
capacity_threshold_for_exhaustion_pct: Some(80.0),
not_in_use_temperature_range: Some(range),
not_in_use_temperature_reference_test: Some("IEC 62660-1:2018".into()),
initial_round_trip_efficiency_pct: Some(96.0),
round_trip_efficiency_at_half_cycle_life_pct: Some(92.0),
internal_cell_resistance_mohm: Some(1.2),
internal_pack_resistance_mohm: Some(30.0),
cycle_life_test_c_rate: Some(1.0),
marking_information: Some("Separate collection symbol".into()),
eu_declaration_of_conformity: Some("DoC-2027-0001".into()),
waste_battery_information: Some("https://example.invalid/waste".into()),
cathode_material: mat(),
anode_material: mat(),
electrolyte_material: mat(),
component_part_numbers: Some(vec!["PN-1".into()]),
spare_parts_contacts: Some("spares@example.invalid".into()),
disassembly_instructions_url: Some("https://example.invalid/disassembly".into()),
safety_measures: Some("Do not puncture".into()),
test_report_results: Some("Report 42: pass".into()),
dynamic_performance: Some(Box::new(DynamicPerformance::default())),
state_of_health: Some(Box::new(StateOfHealth::ElectricVehicle { soce_pct: 99.0 })),
battery_status: Some(BatteryStatus::Original),
battery_passport_number: Some("URN:UUID:6F1C9D2E-0000-4000-8000-000000000000".into()),
battery_model_id: Some("LFP-64-A".into()),
manufacturing_place: Some("PL:Wrocław".into()),
manufacturing_date: Some(
chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2026, 3, 1, 0, 0, 0).unwrap(),
),
..crate::test_support::sample_battery_data()
};
Passport {
product_group: ProductGroup::Battery,
product_group_data: Some(ProductGroupData::Battery(Box::new(data))),
..crate::test_support::sample_passport()
}
}
fn battery_field(p: &mut Passport, mutate: impl FnOnce(&mut BatteryData)) {
if let Some(ProductGroupData::Battery(b)) = p.product_group_data.as_mut() {
mutate(b);
}
}
#[test]
fn a_complete_ev_battery_publishes() {
let mut p = publishable_battery(crate::product_group::BatteryType::Ev);
p.transition_to(PassportStatus::Published)
.expect("a passport carrying every mandatory field must publish");
assert!(p.retention_locked);
assert!(p.published_at.is_some());
}
#[test]
fn a_missing_mandatory_field_blocks_names_it_and_leaves_no_lock() {
let mut p = publishable_battery(crate::product_group::BatteryType::Ev);
battery_field(&mut p, |b| b.usable_extinguishing_agent = None);
let err = p
.transition_to(PassportStatus::Published)
.expect_err("Annex VI Part A point 9 is mandatory for every covered category");
let msg = err.to_string();
assert!(
msg.contains("usableExtinguishingAgent"),
"the refusal must name the field: {msg}"
);
assert!(!p.retention_locked, "a refused publish must not lock");
assert!(p.published_at.is_none());
assert_eq!(p.status, PassportStatus::Draft);
}
#[test]
fn the_preview_gives_the_same_answer_as_the_attempt_and_changes_nothing() {
let mut p = publishable_battery(crate::product_group::BatteryType::Ev);
battery_field(&mut p, |b| b.usable_extinguishing_agent = None);
let previewed = p
.check_mandatory_content()
.expect_err("the preview must refuse what the transition refuses");
assert_eq!(p.status, PassportStatus::Draft);
assert!(!p.retention_locked);
assert!(p.published_at.is_none());
let attempted = p
.transition_to(PassportStatus::Published)
.expect_err("the transition must still refuse");
assert_eq!(
previewed.to_string(),
attempted.to_string(),
"a preview that does not render identically to the refusal is a second \
opinion, and the two would drift"
);
}
#[test]
fn the_preview_passes_for_a_passport_that_publishes() {
let mut p = publishable_battery(crate::product_group::BatteryType::Ev);
p.check_mandatory_content()
.expect("a complete passport must preview clean");
p.transition_to(PassportStatus::Published)
.expect("and must then actually publish");
}
#[test]
fn each_identity_data_point_blocks_publish_on_its_own() {
for (name, clear) in [
(
"batteryModelId",
(|b: &mut BatteryData| b.battery_model_id = None) as fn(&mut BatteryData),
),
("manufacturingPlace", |b: &mut BatteryData| {
b.manufacturing_place = None;
}),
("manufacturingDate", |b: &mut BatteryData| {
b.manufacturing_date = None;
}),
] {
let mut p = publishable_battery(crate::product_group::BatteryType::Ev);
battery_field(&mut p, clear);
let err = match p.transition_to(PassportStatus::Published) {
Err(e) => e,
Ok(()) => panic!(
"{name} is mandatory for every covered category, but the publish was allowed"
),
};
let msg = format!("{err:?}");
assert!(msg.contains(name), "the refusal must name {name}: {msg}");
assert!(!p.retention_locked, "a refused publish must not lock");
assert_eq!(p.status, PassportStatus::Draft);
}
}
#[test]
fn the_art_77_3_identifier_is_the_carrier_not_a_payload_field() {
let mut p = publishable_battery(crate::product_group::BatteryType::Ev);
battery_field(&mut p, |b| b.battery_passport_number = None);
assert!(!p.effective_carrier_serial().is_empty());
p.transition_to(PassportStatus::Published)
.expect("the carrier identifier is the Art. 77(3) identifier");
}
#[test]
fn every_missing_field_is_reported_at_once() {
let mut p = publishable_battery(crate::product_group::BatteryType::Ev);
battery_field(&mut p, |b| {
b.usable_extinguishing_agent = None;
b.marking_information = None;
});
let msg = p
.transition_to(PassportStatus::Published)
.unwrap_err()
.to_string();
assert!(msg.contains("usableExtinguishingAgent"), "{msg}");
assert!(msg.contains("markingInformation"), "{msg}");
}
#[test]
fn the_ev_only_field_is_demanded_of_ev_and_not_of_lmt() {
let mut ev = publishable_battery(crate::product_group::BatteryType::Ev);
battery_field(&mut ev, |b| b.capacity_threshold_for_exhaustion_pct = None);
assert!(
ev.transition_to(PassportStatus::Published).is_err(),
"1(k) is mandatory for EV"
);
let mut lmt = publishable_battery(crate::product_group::BatteryType::Lmt);
battery_field(&mut lmt, |b| b.capacity_threshold_for_exhaustion_pct = None);
lmt.transition_to(PassportStatus::Published)
.expect("1(k) is not applicable to LMT, so its absence cannot block");
}
#[test]
fn industrial_may_publish_without_a_cycle_lifetime() {
let mut p = publishable_battery(crate::product_group::BatteryType::Industrial);
battery_field(&mut p, |b| {
b.expected_lifetime_cycles = None;
b.expected_lifetime_reference_test = None;
b.cycle_life_test_c_rate = None;
b.initial_round_trip_efficiency_pct = None;
b.round_trip_efficiency_at_half_cycle_life_pct = None;
});
p.transition_to(PassportStatus::Published)
.expect("every field removed here is conditional for industrial batteries");
}
#[test]
fn portable_and_sli_are_ungated_and_that_is_deliberate() {
for t in [
crate::product_group::BatteryType::Portable,
crate::product_group::BatteryType::Sli,
] {
let mut p = publishable_battery(t);
battery_field(&mut p, |b| {
b.usable_extinguishing_agent = None;
b.marking_information = None;
b.eu_declaration_of_conformity = None;
});
p.transition_to(PassportStatus::Published)
.expect("no source covers this category, so nothing is gated");
}
}
#[test]
fn a_republish_is_not_re_gated() {
let mut p = publishable_battery(crate::product_group::BatteryType::Ev);
p.transition_to(PassportStatus::Published).unwrap();
p.transition_to(PassportStatus::Suspended).unwrap();
battery_field(&mut p, |b| b.usable_extinguishing_agent = None);
p.transition_to(PassportStatus::Published)
.expect("a republish must not be blocked by a rule that arrived after issuance");
}
#[test]
fn a_battery_passport_without_product_group_data_cannot_publish() {
let mut p = Passport {
product_group: ProductGroup::Battery,
product_group_data: None,
..crate::test_support::sample_passport()
};
let err = p.transition_to(PassportStatus::Published).unwrap_err();
assert!(err.to_string().contains("productGroupData"), "{err}");
}
#[test]
fn a_non_battery_product_group_is_untouched_by_the_gate() {
let mut p = crate::test_support::sample_passport();
p.product_group = ProductGroup::Textile;
p.product_group_data = None;
p.transition_to(PassportStatus::Published)
.expect("no requirements table exists for textile, so the gate is inert");
}
#[test]
fn a_non_publish_transition_is_not_gated() {
let mut p = publishable_battery(crate::product_group::BatteryType::Ev);
battery_field(&mut p, |b| b.usable_extinguishing_agent = None);
p.transition_to(PassportStatus::Retired)
.expect("retiring an incomplete draft is not a compliance claim");
}
fn deficient_industrial(capacity_kwh: Option<f64>) -> Passport {
let mut p = publishable_battery(crate::product_group::BatteryType::Industrial);
battery_field(&mut p, |b| {
b.rated_capacity_kwh = capacity_kwh;
b.usable_extinguishing_agent = None;
});
p
}
#[test]
fn an_industrial_battery_at_or_below_two_kwh_is_not_held_to_industrial_content() {
for kwh in [1.0, 2.0] {
let mut p = deficient_industrial(Some(kwh));
p.placed_on_market_date = Some(chrono::NaiveDate::from_ymd_opt(2030, 1, 1).unwrap());
p.transition_to(PassportStatus::Published)
.unwrap_or_else(|e| {
panic!("Art. 77(1) does not reach a {kwh} kWh industrial battery: {e}")
});
}
}
#[test]
fn an_industrial_battery_above_two_kwh_is_still_gated() {
let mut p = deficient_industrial(Some(2.000_001));
p.placed_on_market_date = Some(chrono::NaiveDate::from_ymd_opt(2030, 1, 1).unwrap());
let err = p
.transition_to(PassportStatus::Published)
.expect_err("just over the threshold is over it");
assert!(
err.to_string().contains("usableExtinguishingAgent"),
"{err}"
);
}
#[test]
fn an_industrial_battery_that_states_no_capacity_is_still_gated() {
let mut p = deficient_industrial(None);
p.placed_on_market_date = Some(chrono::NaiveDate::from_ymd_opt(2030, 1, 1).unwrap());
let err = p
.transition_to(PassportStatus::Published)
.expect_err("an unstated capacity must not exempt");
assert!(
err.to_string().contains("usableExtinguishingAgent"),
"{err}"
);
}
#[test]
fn the_eighteenth_of_february_2027_is_inside_the_obligation_and_the_day_before_is_not() {
for (date, gated) in [
(chrono::NaiveDate::from_ymd_opt(2027, 2, 17).unwrap(), false),
(chrono::NaiveDate::from_ymd_opt(2027, 2, 18).unwrap(), true),
] {
let mut p = publishable_battery(crate::product_group::BatteryType::Ev);
battery_field(&mut p, |b| b.usable_extinguishing_agent = None);
p.placed_on_market_date = Some(date);
let outcome = p.transition_to(PassportStatus::Published);
assert_eq!(
outcome.is_err(),
gated,
"Art. 77(1) applies from 18 February 2027; {date} answered {outcome:?}"
);
}
}
#[test]
fn a_record_stating_no_placing_date_is_still_gated() {
let mut p = publishable_battery(crate::product_group::BatteryType::Ev);
battery_field(&mut p, |b| b.usable_extinguishing_agent = None);
assert!(p.placed_on_market_date.is_none());
let err = p
.transition_to(PassportStatus::Published)
.expect_err("an unstated placing date must not exempt");
assert!(
err.to_string().contains("usableExtinguishingAgent"),
"{err}"
);
}
#[test]
fn the_strict_check_still_answers_for_a_record_art_77_1_exempts() {
let mut p = deficient_industrial(Some(1.0));
p.placed_on_market_date = Some(chrono::NaiveDate::from_ymd_opt(2030, 1, 1).unwrap());
let err = p
.check_category_content()
.expect_err("the strict check ignores scope, and the field is still absent");
assert!(
err.to_string().contains("usableExtinguishingAgent"),
"{err}"
);
p.transition_to(PassportStatus::Published)
.expect("and the publish gate still lets it through");
}
#[test]
fn placing_dates_that_disagree_do_not_exempt_the_content_gate() {
let before = chrono::NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
let after = chrono::NaiveDate::from_ymd_opt(2030, 1, 1).unwrap();
for (envelope, product_group) in [(before, after), (after, before)] {
let mut p = publishable_battery(crate::product_group::BatteryType::Ev);
battery_field(&mut p, |b| b.usable_extinguishing_agent = None);
battery_field(&mut p, |b| b.placed_on_market_date = Some(product_group));
p.placed_on_market_date = Some(envelope);
assert!(
p.transition_to(PassportStatus::Published).is_err(),
"envelope {envelope} against product-group {product_group} was \
exempted from the content gate — a record disagreeing with itself \
must not publish incomplete content"
);
}
}
#[test]
fn placing_dates_that_agree_before_the_date_still_exempt() {
let before = chrono::NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
let mut p = publishable_battery(crate::product_group::BatteryType::Ev);
battery_field(&mut p, |b| b.usable_extinguishing_agent = None);
battery_field(&mut p, |b| b.placed_on_market_date = Some(before));
p.placed_on_market_date = Some(before);
p.transition_to(PassportStatus::Published)
.expect("Art. 77(1) does not reach a battery placed before 18 February 2027");
}