use super::*;
use crate::catalog::{Regime, RegulatoryStatus, SectorCatalog, SectorDescriptor};
use crate::domain::gtin::Gtin;
use crate::domain::identity::Audience;
use crate::domain::identity::Disclosure;
use crate::schemas::VersionedSchemaRegistry;
fn battery_descriptor_with_tiers() -> SectorDescriptor {
use std::collections::HashMap;
let mut disclosure = HashMap::new();
disclosure.insert("dueDiligenceUrl".into(), Disclosure::Restricted);
disclosure.insert("criticalRawMaterials".into(), Disclosure::Restricted);
disclosure.insert("disassemblyInstructionsUrl".into(), Disclosure::Restricted);
SectorDescriptor {
key: "battery".into(),
title: "Battery".into(),
status: RegulatoryStatus::InForce,
regime: Regime::BatteryRegulation,
legal_basis: vec!["EU 2023/1542".into()],
dpp_applies_from: None,
retention_years: 10,
schema_versions: vec!["2.0.0".into()],
current_schema_version: "2.0.0".into(),
product_categories: vec![],
disclosure,
plugin: None,
notes: None,
}
}
fn minimal_battery_data() -> SectorData {
SectorData::Battery(BatteryData {
due_diligence_url: Some("https://acme.example.com/due-diligence".into()),
disassembly_instructions_url: Some("https://acme.example.com/disassembly".into()),
..crate::test_support::sample_battery_data()
})
}
#[test]
fn public_viewer_sees_public_fields_only() {
let data = minimal_battery_data();
let descriptor = battery_descriptor_with_tiers();
let json = redact_sector_data(&data, Audience::Public, &descriptor);
assert!(json.get("batteryChemistry").is_some());
assert!(json.get("co2ePerUnitKg").is_some());
assert!(json.get("nominalVoltageV").is_some());
assert!(
json.get("dueDiligenceUrl").is_none(),
"due_diligence_url must be hidden from Public"
);
assert!(
json.get("disassemblyInstructionsUrl").is_none(),
"disassembly_url must be hidden from Public"
);
}
#[test]
fn professional_viewer_sees_gated_fields() {
let data = minimal_battery_data();
let descriptor = battery_descriptor_with_tiers();
let json = redact_sector_data(&data, Audience::LegitimateInterest, &descriptor);
assert!(
json.get("dueDiligenceUrl").is_some(),
"Professional must see due_diligence_url"
);
assert!(json.get("disassemblyInstructionsUrl").is_some());
assert!(json.get("batteryChemistry").is_some());
}
#[test]
fn empty_disclosure_retains_all_fields() {
let data = minimal_battery_data();
let descriptor = SectorDescriptor {
key: "battery".into(),
title: "Battery".into(),
status: RegulatoryStatus::InForce,
regime: Regime::BatteryRegulation,
legal_basis: vec!["EU 2023/1542".into()],
dpp_applies_from: None,
retention_years: 10,
schema_versions: vec!["2.0.0".into()],
current_schema_version: "2.0.0".into(),
product_categories: vec![],
disclosure: std::collections::HashMap::new(),
plugin: None,
notes: None,
};
let json = redact_sector_data(&data, Audience::Public, &descriptor);
assert!(json.get("dueDiligenceUrl").is_some());
assert!(json.get("disassemblyInstructionsUrl").is_some());
assert!(json.get("batteryChemistry").is_some());
}
fn cotton_fibre(pct: f64) -> FibreEntry {
FibreEntry {
fibre: "cotton".into(),
pct,
country_of_origin: None,
}
}
fn polyester_fibre(pct: f64) -> FibreEntry {
FibreEntry {
fibre: "polyester".into(),
pct,
country_of_origin: None,
}
}
fn test_textile_data() -> TextileData {
TextileData {
fibre_composition: vec![cotton_fibre(60.0), polyester_fibre(40.0)],
country_of_origin: "BD".into(),
care_instructions: "Machine wash 40°C".into(),
chemical_compliance_standard: "OEKO-TEX 100".into(),
..crate::test_support::sample_textile_data()
}
}
#[test]
fn fibre_sum_valid_passes() {
let fibres = vec![cotton_fibre(60.0), polyester_fibre(40.0)];
assert!(validate_fibre_composition(&fibres).is_ok());
}
#[test]
fn fibre_sum_invalid_rejects() {
let fibres = vec![cotton_fibre(60.0), polyester_fibre(30.0)];
let err = validate_fibre_composition(&fibres).unwrap_err();
assert!(err.contains("90.0"), "unexpected error: {err}");
}
#[test]
fn fibre_sum_within_tolerance_passes() {
let fibres = vec![
FibreEntry {
fibre: "cotton".into(),
pct: 98.5,
country_of_origin: None,
},
FibreEntry {
fibre: "elastane".into(),
pct: 1.0,
country_of_origin: None,
},
];
assert!(
validate_fibre_composition(&fibres).is_ok(),
"99.5% should pass ±2 tolerance"
);
}
#[test]
fn fibre_with_valid_country_of_origin_passes() {
let fibres = vec![
FibreEntry {
fibre: "cotton".into(),
pct: 70.0,
country_of_origin: Some("IN".into()),
},
FibreEntry {
fibre: "polyester".into(),
pct: 30.0,
country_of_origin: Some("CN".into()),
},
];
assert!(validate_fibre_composition(&fibres).is_ok());
}
#[test]
fn fibre_with_invalid_country_of_origin_rejects() {
let fibres = vec![FibreEntry {
fibre: "cotton".into(),
pct: 100.0,
country_of_origin: Some("india".into()), }];
let err = validate_fibre_composition(&fibres).unwrap_err();
assert!(
err.contains("country_of_origin"),
"expected country_of_origin error, got: {err}"
);
}
#[test]
fn svhc_valid_list_passes() {
let substances = vec![SvhcSubstance {
cas_number: "80-05-7".into(),
substance_name: "Bisphenol A".into(),
concentration_pct: 0.15,
location_in_product: Some("coating".into()),
scip_notification_id: None,
}];
assert!(validate_svhc_substances(&substances).is_ok());
}
#[test]
fn svhc_empty_cas_rejects() {
let substances = vec![SvhcSubstance {
cas_number: "".into(),
substance_name: "Unknown".into(),
concentration_pct: 0.5,
location_in_product: None,
scip_notification_id: None,
}];
assert!(validate_svhc_substances(&substances).is_err());
}
#[test]
fn svhc_invalid_concentration_rejects() {
let substances = vec![SvhcSubstance {
cas_number: "80-05-7".into(),
substance_name: "Bisphenol A".into(),
concentration_pct: -1.0, location_in_product: None,
scip_notification_id: None,
}];
assert!(validate_svhc_substances(&substances).is_err());
}
#[test]
fn svhc_empty_list_passes() {
assert!(validate_svhc_substances(&[]).is_ok());
}
#[test]
fn surfactants_valid_list_passes() {
let surfactants = vec![SurfactantEntry {
name: "Sodium laureth sulfate".into(),
biodegradable: true,
concentration_band: "5-15%".into(),
cas_number: Some("9004-82-4".into()),
}];
assert!(validate_surfactants(&surfactants).is_ok());
}
#[test]
fn surfactants_invalid_band_rejects() {
let surfactants = vec![SurfactantEntry {
name: "Mystery surfactant".into(),
biodegradable: true,
concentration_band: "lots".into(), cas_number: None,
}];
assert!(validate_surfactants(&surfactants).is_err());
}
#[test]
fn sector_data_battery_round_trip() {
let data = SectorData::Battery(BatteryData {
recycled_content_lithium_pct: Some(12.5),
rated_capacity_kwh: Some(32.0),
carbon_footprint_class: Some(CarbonFootprintClass::new("B").expect("valid label")),
carbon_footprint_class_ruleset_id: Some("test-cfb-classes".into()),
carbon_footprint_class_ruleset_version: Some("0.0.0-test".into()),
..crate::test_support::sample_battery_data()
});
let json = serde_json::to_value(&data).unwrap();
assert_eq!(json["sector"], "battery", "sector tag must be lowercase");
assert_eq!(json["batteryChemistry"], "LFP");
assert_eq!(json["gtin"], "09506000134352");
let back: SectorData = serde_json::from_value(json).unwrap();
assert_eq!(data, back);
}
#[test]
fn battery_enum_wire_values_match_schema() {
let cases: &[(BatteryType, &str)] = &[
(BatteryType::Portable, "portable"),
(BatteryType::Industrial, "industrial"),
(BatteryType::Ev, "ev"),
(BatteryType::Lmt, "lmt"),
(BatteryType::Sli, "starting-lighting-ignition"),
];
for (variant, expected) in cases {
let json = serde_json::to_value(variant).unwrap();
assert_eq!(
json.as_str().unwrap(),
*expected,
"BatteryType::{variant:?} must serialise as \"{expected}\""
);
let back: BatteryType = serde_json::from_str(&format!("\"{expected}\"")).unwrap();
assert_eq!(back, *variant, "round-trip failed for \"{expected}\"");
}
let chem_cases: &[(BatteryChemistry, &str)] = &[
(BatteryChemistry::Lfp, "LFP"),
(BatteryChemistry::Nmc, "NMC"),
(BatteryChemistry::Nca, "NCA"),
(BatteryChemistry::Lco, "LCO"),
(BatteryChemistry::NiMh, "NiMH"),
(BatteryChemistry::NiCd, "NiCd"),
(BatteryChemistry::LeadAcid, "lead-acid"),
(BatteryChemistry::SolidState, "solid-state"),
];
for (variant, expected) in chem_cases {
let json = serde_json::to_value(variant).unwrap();
assert_eq!(
json.as_str().unwrap(),
*expected,
"BatteryChemistry::{variant:?} must serialise as \"{expected}\""
);
}
}
#[test]
fn sector_data_textile_round_trip() {
let mut data = test_textile_data();
data.fibre_composition = vec![FibreEntry {
fibre: "cotton".into(),
pct: 100.0,
country_of_origin: Some("IN".into()),
}];
data.repair_score = Some(6.0);
data.carbon_footprint_kg_co2e = Some(8.5);
data.country_of_raw_material_origin = Some("IN".into());
data.durability_score = Some(7.5);
data.microplastic_shedding_mg_per_wash = Some(12.3);
data.svhc_substances = Some(vec![SvhcSubstance {
cas_number: "80-05-7".into(),
substance_name: "Bisphenol A".into(),
concentration_pct: 0.15,
location_in_product: Some("coating".into()),
scip_notification_id: Some("SCIP-12345".into()),
}]);
let sector = SectorData::Textile(data.clone());
let json = serde_json::to_value(§or).unwrap();
assert_eq!(json["sector"], "textile", "sector tag must be lowercase");
assert_eq!(json["countryOfOrigin"], "BD");
assert_eq!(json["durabilityScore"], 7.5);
assert_eq!(json["microplasticSheddingMgPerWash"], 12.3);
assert!(json["svhcSubstances"].is_array());
assert_eq!(json["svhcSubstances"][0]["casNumber"], "80-05-7");
assert_eq!(
json["fibreComposition"][0]["countryOfOrigin"], "IN",
"per-fibre origin must serialize"
);
let back: SectorData = serde_json::from_value(json).unwrap();
assert_eq!(SectorData::Textile(data), back);
}
#[test]
fn textile_none_fields_not_serialized() {
let data = SectorData::Textile(test_textile_data());
let json = serde_json::to_value(&data).unwrap();
assert!(
json.get("svhcSubstances").is_none(),
"None svhc should be absent"
);
assert!(
json.get("durabilityScore").is_none(),
"None durability should be absent"
);
assert!(json.get("disassemblyInstructions").is_none());
assert!(json.get("microplasticSheddingMgPerWash").is_none());
}
#[test]
fn textile_v1_data_deserializes_with_defaults() {
let v1_json = serde_json::json!({
"sector": "textile",
"gtin": "09506000134352",
"fibreComposition": [{"fibre": "cotton", "pct": 100.0}],
"countryOfOrigin": "PT",
"careInstructions": "Hand wash",
"chemicalComplianceStandard": "REACH"
});
let parsed: SectorData = serde_json::from_value(v1_json).unwrap();
if let SectorData::Textile(t) = parsed {
assert_eq!(t.country_of_origin, "PT");
assert!(t.svhc_substances.is_none());
assert!(t.durability_score.is_none());
assert!(t.microplastic_shedding_mg_per_wash.is_none());
assert!(t.fibre_composition[0].country_of_origin.is_none());
} else {
panic!("expected Textile variant");
}
}
#[test]
fn every_sector_declares_a_catalog_key() {
let all = [
(Sector::Battery, "battery"),
(Sector::Textile, "textile"),
(Sector::UnsoldGoods, "unsold-goods"),
(Sector::Steel, "steel"),
(Sector::Electronics, "electronics"),
(Sector::Construction, "construction"),
(Sector::Tyre, "tyre"),
(Sector::Toy, "toy"),
(Sector::Aluminium, "aluminium"),
(Sector::Furniture, "furniture"),
(Sector::Detergent, "detergent"),
(Sector::Other("other".into()), "other"),
];
for (sector, key) in all {
assert_eq!(sector.catalog_key(), key);
}
}
#[test]
fn sector_discriminant_matches_variant() {
let battery = SectorData::Battery(BatteryData {
gtin: Gtin::parse("00000000000000").unwrap(),
battery_chemistry: BatteryChemistry::Nmc,
nominal_voltage_v: 4.0,
nominal_capacity_ah: 50.0,
expected_lifetime_cycles: 1000,
co2e_per_unit_kg: 40.0,
..crate::test_support::sample_battery_data()
});
assert_eq!(battery.sector(), Sector::Battery);
}
fn sample_steel_data() -> SectorData {
SectorData::Steel(SteelData {
gtin: Gtin::parse("09506000134352").unwrap(),
co2e_per_tonne_steel: 1.8,
recycled_scrap_content_pct: 35.0,
product_category: "flat".into(),
country_of_origin: "DE".into(),
production_route: ProductionRoute::ElectricArc,
annual_production_tonnes: None,
})
}
fn sample_aluminium_data() -> SectorData {
SectorData::Aluminium(AluminiumData {
gtin: Gtin::parse("09506000134352").unwrap(),
alloy_grade: "6xxx".into(),
production_route: ProductionRoute::SecondaryRecycled,
co2e_per_tonne_kg: 1200.0,
recycled_content_pct: 60.0,
country_of_origin: "DE".into(),
annual_production_tonnes: None,
})
}
fn sample_electronics_data() -> SectorData {
SectorData::Electronics(ElectronicsData {
gtin: Gtin::parse("09506000134352").unwrap(),
product_category: "laptop".into(),
energy_efficiency_class: EnergyEfficiencyClass::B,
co2e_per_unit_kg: 120.0,
repairability_score: Some(RepairabilityScore {
overall: 7.5,
criteria: vec![RepairCriterion {
name: "spare-parts-availability".into(),
score: 8.0,
weight: 0.5,
}],
}),
spare_parts_available: Some(true),
repair_manual_url: None,
disassembly_instructions_url: None,
svhc_substances: None,
rohs_compliant: Some(true),
critical_raw_materials: None,
recycled_content_pct: None,
standby_power_w: None,
expected_lifetime_years: None,
firmware_update_until: None,
})
}
fn sample_construction_data() -> SectorData {
SectorData::Construction(ConstructionData {
gtin: Gtin::parse("09506000134352").unwrap(),
product_family: "cement".into(),
country_of_origin: "DE".into(),
co2e_per_functional_unit_kg: 0.8,
functional_unit: "per tonne".into(),
recycled_content_pct: None,
epd_url: None,
ce_marking: Some(true),
})
}
fn sample_tyre_data() -> SectorData {
SectorData::Tyre(TyreData {
gtin: Gtin::parse("09506000134352").unwrap(),
tyre_class: "C1".into(),
fuel_efficiency_class: "B".into(),
wet_grip_class: "A".into(),
external_rolling_noise_db: 68.0,
noise_performance_class: None,
rolling_resistance_n_per_kn: None,
recycled_rubber_pct: None,
co2e_per_tyre_kg: None,
})
}
fn sample_toy_data() -> SectorData {
SectorData::Toy(ToyData {
gtin: Gtin::parse("09506000134352").unwrap(),
age_group: "3-6".into(),
primary_material: "wood".into(),
ce_marking: true,
country_of_origin: "DE".into(),
svhc_substances: None,
contains_battery: Some(false),
repairability_info: None,
})
}
fn sample_furniture_data() -> SectorData {
SectorData::Furniture(FurnitureData {
gtin: Gtin::parse("09506000134352").unwrap(),
product_type: "chair".into(),
primary_material: "solid-wood".into(),
country_of_origin: "DE".into(),
co2e_per_unit_kg: None,
recycled_content_pct: None,
repairability_score: Some(7.0),
svhc_substances: None,
disassembly_instructions_url: None,
end_of_life_instructions: None,
})
}
fn sample_detergent_data() -> SectorData {
SectorData::Detergent(DetergentData {
gtin: Gtin::parse("09506000134352").unwrap(),
product_type: "laundry".into(),
format: "liquid".into(),
surfactants: vec![SurfactantEntry {
name: "Sodium Laureth Sulfate".into(),
biodegradable: true,
concentration_band: "5-15%".into(),
cas_number: None,
}],
country_of_origin: "DE".into(),
co2e_per_unit_kg: None,
packaging_recyclable: None,
recommended_dosage_ml: None,
biodegradable: None,
})
}
fn sample_unsold_goods_data() -> SectorData {
SectorData::UnsoldGoods(UnsoldGoodsReport {
reporting_period: "2026-Q3".into(),
volume_kg: 120.0,
product_category: "apparel".into(),
reason: UnsoldGoodsReason::EndOfSeason,
destination: UnsoldGoodsDestination::Donation,
destruction_justification: None,
country_of_disposal: "DE".into(),
operator_name: Some("Caritas Berlin".into()),
})
}
#[test]
fn every_sector_with_an_embedded_schema_round_trips_through_its_current_schema() {
let catalog = SectorCatalog::new();
let registry = VersionedSchemaRegistry::new();
let samples: Vec<SectorData> = vec![
minimal_battery_data(),
SectorData::Textile(test_textile_data()),
sample_unsold_goods_data(),
sample_steel_data(),
sample_electronics_data(),
sample_construction_data(),
sample_tyre_data(),
sample_toy_data(),
sample_aluminium_data(),
sample_furniture_data(),
sample_detergent_data(),
];
assert_eq!(
samples.len(),
11,
"expected one sample per non-Other sector"
);
for sample in samples {
let sector = sample.sector();
let key = sector.catalog_key();
let Some(version) = catalog.resolve_schema_version(key, None) else {
panic!("sector '{key}' has no catalog entry — add one or exclude it here");
};
let mut json = serde_json::to_value(&sample).expect("serialize sector data");
json.as_object_mut().unwrap().remove("sector");
registry
.validate_strict(key, &version, &json)
.unwrap_or_else(|e| {
panic!(
"sector '{key}' v{version}: a Rust-type-constructed valid \
instance failed its own embedded schema — type/schema \
drift: {e:?}"
)
});
}
}
#[test]
fn state_of_health_round_trips_both_annex_vii_parameter_sets() {
use crate::domain::sector::StateOfHealth;
let ev = StateOfHealth::ElectricVehicle { soce_pct: 92.5 };
let json = serde_json::to_value(&ev).unwrap();
assert_eq!(json["parameterSet"], "electricVehicle");
assert_eq!(json["socePct"], 92.5);
assert_eq!(json.as_object().unwrap().len(), 2);
assert_eq!(serde_json::from_value::<StateOfHealth>(json).unwrap(), ev);
let stationary = StateOfHealth::StationaryOrLmt {
remaining_capacity_pct: 88.0,
remaining_power_capability_pct: None,
remaining_round_trip_efficiency_pct: Some(94.0),
self_discharge_rate_pct_per_month: 1.5,
ohmic_resistance_mohm: None,
};
let json = serde_json::to_value(&stationary).unwrap();
assert_eq!(json["parameterSet"], "stationaryOrLmt");
assert_eq!(json["remainingCapacityPct"], 88.0);
assert_eq!(json["selfDischargeRatePctPerMonth"], 1.5);
assert!(json.get("remainingPowerCapabilityPct").is_none());
assert!(json.get("ohmicResistanceMohm").is_none());
assert_eq!(
serde_json::from_value::<StateOfHealth>(json).unwrap(),
stationary
);
}
#[test]
fn annex_vii_unconditional_parameters_are_required() {
use crate::domain::sector::StateOfHealth;
let missing_self_discharge = serde_json::json!({
"parameterSet": "stationaryOrLmt",
"remainingCapacityPct": 88.0,
});
assert!(serde_json::from_value::<StateOfHealth>(missing_self_discharge).is_err());
let missing_soce = serde_json::json!({ "parameterSet": "electricVehicle" });
assert!(serde_json::from_value::<StateOfHealth>(missing_soce).is_err());
let minimal = serde_json::json!({
"parameterSet": "stationaryOrLmt",
"remainingCapacityPct": 88.0,
"selfDischargeRatePctPerMonth": 1.5,
});
assert!(serde_json::from_value::<StateOfHealth>(minimal).is_ok());
}
#[test]
fn state_of_health_is_withheld_from_authorities() {
use crate::catalog::SectorCatalog;
use crate::domain::identity::{Audience, Disclosure};
let catalog = SectorCatalog::new();
let battery = catalog.get("battery").expect("battery in catalog");
for field in ["stateOfHealth", "stateOfHealthPct"] {
assert_eq!(
battery.disclosure.get(field),
Some(&Disclosure::Individual),
"{field} must be individual-battery data"
);
}
assert!(!Audience::Public.may_see(Disclosure::Individual));
assert!(!Audience::Authority.may_see(Disclosure::Individual));
assert!(Audience::LegitimateInterest.may_see(Disclosure::Individual));
}
#[test]
fn expected_lifetime_round_trips_with_illustrative_harmful_events() {
use crate::domain::sector::{ExpectedLifetime, HarmfulEvents};
let lifetime = ExpectedLifetime {
put_into_service_date: chrono::NaiveDate::from_ymd_opt(2027, 3, 1),
energy_throughput_kwh: 12_500.0,
capacity_throughput_ah: 48_000.0,
harmful_events: HarmfulEvents {
deep_discharge_events: Some(4),
hours_in_extreme_temperature: Some(31.5),
hours_charging_in_extreme_temperature: None,
},
full_equivalent_cycles: 812.0,
};
let json = serde_json::to_value(&lifetime).unwrap();
assert_eq!(json["energyThroughputKwh"], 12_500.0);
assert_eq!(json["capacityThroughputAh"], 48_000.0);
assert_eq!(json["fullEquivalentCycles"], 812.0);
assert_eq!(json["harmfulEvents"]["deepDischargeEvents"], 4);
assert!(
json["harmfulEvents"]
.get("hoursChargingInExtremeTemperature")
.is_none(),
"an untracked harmful event is omitted, not null"
);
assert_eq!(
serde_json::from_value::<ExpectedLifetime>(json).unwrap(),
lifetime
);
}
#[test]
fn part_b_unconditional_items_are_required() {
use crate::domain::sector::ExpectedLifetime;
let missing_throughput = serde_json::json!({
"capacityThroughputAh": 1.0,
"harmfulEvents": {},
"fullEquivalentCycles": 1.0,
});
assert!(serde_json::from_value::<ExpectedLifetime>(missing_throughput).is_err());
let minimal = serde_json::json!({
"energyThroughputKwh": 1.0,
"capacityThroughputAh": 1.0,
"harmfulEvents": {},
"fullEquivalentCycles": 1.0,
});
assert!(serde_json::from_value::<ExpectedLifetime>(minimal).is_ok());
}
#[test]
fn measured_lifetime_is_individual_but_the_design_figure_stays_public() {
use crate::catalog::SectorCatalog;
use crate::domain::identity::{Audience, Disclosure};
let catalog = SectorCatalog::new();
let battery = catalog.get("battery").expect("battery in catalog");
assert_eq!(
battery.disclosure.get("expectedLifetime"),
Some(&Disclosure::Individual),
"measured Part B data is Annex XIII point 4(d)"
);
assert_eq!(
battery.disclosure.get("expectedLifetimeCycles"),
None,
"the model-level design figure is public under Annex XIII point 1(j)"
);
assert!(!Audience::Authority.may_see(Disclosure::Individual));
assert!(Audience::LegitimateInterest.may_see(Disclosure::Individual));
}