dpp-domain 0.21.0

EU Digital Product Passport domain types, port traits, and per-field disclosure policy
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
//! The publish gate on mandatory battery content: which fields block a publish,
//! for which battery types, and what the preview promises.

use super::*;
use crate::product_group::{BatteryData, ProductGroup, ProductGroupData};
use crate::status::PassportStatus;

// Blocking a publish is a serious act, so these cover the boundaries rather
// than the happy path: which category owes what, when the gate does *not* fire,
// and the one hole that is deliberate.

/// Every field the EV category makes mandatory, so a test can remove exactly
/// one and attribute the refusal to it.
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),
        // Guidance data points 1, 7, 8 and 9 — mandatory for every covered
        // category, and unrepresentable until v2.6.0 declared them.
        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}"
    );
    // A refused publish must leave nothing behind. Retention lock is permanent,
    // so setting it on a failed attempt would make the passport unrepairable.
    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() {
    // The gate is reachable without attempting the transition, and asking is
    // not declining: the preview returns the refusal verbatim, and the passport
    // is untouched either way.
    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");

    // Asking left no trace.
    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");
}

/// The identity data points the guidance marks mandatory for every covered
/// category, and which live in product group data, each block a publish on
/// their own.
///
/// These were absent from the requirements table *and* from every battery
/// schema property, so a passport could be published carrying none of
/// them: no model identification, and no record of where or when the battery
/// was made. The schema could not even store them — `additionalProperties:
/// false` rejected them — so this test is the guard on both halves of that
/// defect at once. It fails if either the requirements row or the schema
/// property is removed.
///
/// The fourth, the Art. 77(3) unique identifier, is not among them: it is the
/// passport's data carrier identifier, carried on the envelope, and
/// `the_art_77_3_identifier_is_the_carrier_not_a_payload_field` covers it.
#[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);
    }
}

/// Art. 77(3): the QR code links to the identifier the operator attributes,
/// which is the carrier identifier on the envelope. A superseded
/// `batteryPassportNumber` left empty therefore blocks nothing, because the
/// identifier it used to hold is always present — either attributed, or the
/// default derived from the passport id.
#[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() {
    // One-at-a-time reporting turns a single fix into N publish attempts.
    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() {
    // Annex XIII point 1(k): mandatory for EV, "not to be filled/displayed" for
    // LMT and industrial. The sharpest per-category split in the guidance and
    // the one most easily flattened by a careless edit.
    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() {
    // Point 1(j) reaches industrial batteries only "where lifetime can be
    // expressed in cycles". That carve-out is why the field became optional;
    // the gate must not quietly reintroduce the requirement.
    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() {
    // The guidance covers EV, LMT and industrial only. Blocking a portable
    // battery would invent a requirement the source declines to state — the
    // defect class this project keeps catching in other people's work. A real
    // hole, held open on purpose until a source covering them exists.
    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() {
    // `transition_to` also runs on Suspended → Published. Gating a republish
    // would let a later change to the requirements table strand a passport
    // published lawfully under the earlier one — and retention lock means the
    // operator could not repair it. Content is judged once, at first publish.
    let mut p = publishable_battery(crate::product_group::BatteryType::Ev);
    p.transition_to(PassportStatus::Published).unwrap();
    p.transition_to(PassportStatus::Suspended).unwrap();

    // Stand in for the table tightening under an already-published record.
    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() {
    // Only publish is judged. An incomplete draft must stay movable, or an
    // operator cannot abandon one they decided not to finish.
    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");
}

// ── Art. 77(1) scope: is a passport owed before asking what it must contain ──
//
// The content table is keyed on category alone, so on its own it holds every
// industrial battery to the full industrial list. Art. 77(1) reaches industrial
// batteries with "a capacity greater than 2 kWh", placed on the market from
// 18 February 2027. These cover both directions of each boundary, because a gate
// that exempts too much is the failure that does not announce itself.

/// Build an industrial battery missing one field the category makes mandatory,
/// so the only question left is whether Art. 77(1) reaches the record.
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() {
    // "greater than 2 kWh" is strict, so 2,0 kWh itself owes nothing.
    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() {
    // The one that matters most. `CapacityUnknown` is not an exemption: the
    // obligation turns on a number the record does not carry, and exempting on
    // a missing field is how a statutory gate switches itself off in silence.
    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() {
    // A draft for a product not yet on the market carries no date. Reading that
    // as "before 2027" would exempt every draft, which is every passport at the
    // moment the gate runs.
    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() {
    // A node holding a voluntary passport to its category's content has to be
    // able to ask, or this change removes a capability instead of correcting
    // one. The same record publishes and fails the strict check.
    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");
}

/// 🚨 Two placing dates that disagree must not buy an exemption.
///
/// `validate` refuses a record whose envelope and battery dates differ — but
/// `transition_to` never calls it, going straight to `check_mandatory_content`.
/// The exemption helper read `envelope.or(battery)`, so the envelope's date won
/// outright: a pre-2027 envelope date beside a 2030 battery date answered
/// `NotYetBinding`, and the content gate a published EV battery has to pass was
/// skipped. An exemption reached by the record contradicting itself.
///
/// Fail closed: disagreement is not an exemption, in either direction.
#[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"
        );
    }
}

/// The other half, so the fix above is not simply "always gate": when the two
/// dates agree and sit before 18 February 2027, the exemption still applies.
#[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");
}