Skip to main content

dpp_domain/domain/
validation.rs

1//! JSON Schema + cross-field validation for sector-specific DPP data.
2//!
3//! The schema step routes through the shared [`VersionedSchemaRegistry`] at the
4//! version the [`SectorCatalog`] marks current for the sector — there are no
5//! per-sector validators and no hardcoded versions here. Cross-field regulatory
6//! rules (which JSON Schema cannot express, e.g. "fibre percentages sum to
7//! ~100%") come from `dpp-rules` via the `dpp-domain` adapters.
8//! See `docs/architecture/SECTOR-MODEL-CONSOLIDATION.md` (step C2).
9//!
10//! **Note**: excluded from wasm32 builds since jsonschema depends on reqwest's
11//! blocking API.
12
13#![cfg(not(target_arch = "wasm32"))]
14
15use std::sync::OnceLock;
16
17use semver::Version;
18
19use crate::catalog::SectorCatalog;
20use crate::domain::field_error::{FieldError, ValidationErrors};
21use crate::domain::sector::{
22    SectorData, SvhcSubstance, battery_recycled_chemistry_conflicts,
23    validate_battery_operating_temp, validate_fibre_composition, validate_surfactants,
24    validate_svhc_substances,
25};
26use crate::schemas::VersionedSchemaRegistry;
27
28// ─── Extensibility: runtime sector validators ─────────────────────────────────
29
30/// Trait for runtime-registered sector validators.
31///
32/// Register an implementation in [`SectorValidatorRegistry`] to provide JSON
33/// Schema + cross-field validation for sectors that are not known to this crate
34/// at compile time (e.g., plugin-defined sectors carrying `SectorData::Other`).
35pub trait SectorValidator: Send + Sync {
36    /// Validate the sector payload (the inner data, without the `"sector"` tag key).
37    fn validate(&self, data: &serde_json::Value) -> Result<(), Vec<FieldError>>;
38}
39
40/// Registry of runtime sector validators, keyed by catalog sector key.
41///
42/// An empty registry (the default) causes `SectorData::Other` to fail
43/// validation with an "unknown sector" error — silent pass-through is not safe.
44#[derive(Default)]
45pub struct SectorValidatorRegistry {
46    validators: std::collections::HashMap<String, std::sync::Arc<dyn SectorValidator>>,
47}
48
49impl SectorValidatorRegistry {
50    pub fn new() -> Self {
51        Self::default()
52    }
53
54    pub fn register(
55        &mut self,
56        key: impl Into<String>,
57        validator: std::sync::Arc<dyn SectorValidator>,
58    ) {
59        self.validators.insert(key.into(), validator);
60    }
61
62    fn get(&self, key: &str) -> Option<&dyn SectorValidator> {
63        self.validators.get(key).map(std::sync::Arc::as_ref)
64    }
65}
66
67// ─── Process-wide defaults ─────────────────────────────────────────────────────
68
69/// The embedded schema registry, built once.
70fn default_registry() -> &'static VersionedSchemaRegistry {
71    static REGISTRY: OnceLock<VersionedSchemaRegistry> = OnceLock::new();
72    REGISTRY.get_or_init(VersionedSchemaRegistry::new)
73}
74
75/// The embedded sector catalog, built once.
76fn default_catalog() -> &'static SectorCatalog {
77    static CATALOG: OnceLock<SectorCatalog> = OnceLock::new();
78    CATALOG.get_or_init(SectorCatalog::new)
79}
80
81// `FieldError` and `ValidationErrors` moved to `crate::domain::field_error`
82// (wasm-safe) so `DppError` can carry structured validation detail. Imported above.
83
84// ─── Public API ─────────────────────────────────────────────────────────────────
85
86/// Validate `sector_data` against the appropriate JSON Schema and any
87/// sector-specific cross-field rules (e.g. fibre composition sum).
88///
89/// `SectorData::Other` is a **hard error** here — pass a
90/// [`SectorValidatorRegistry`] via [`validate_sector_data_with_registry`] to
91/// handle runtime-registered sectors.
92///
93/// # Errors
94///
95/// Returns `ValidationErrors` listing every failing field when validation
96/// fails. The `Ok(())` path means the data is structurally valid.
97pub fn validate_sector_data(sector_data: &SectorData) -> Result<(), ValidationErrors> {
98    validate_sector_data_with_registry(sector_data, &SectorValidatorRegistry::default())
99}
100
101/// Like [`validate_sector_data`] but accepts a runtime validator registry.
102///
103/// For `SectorData::Other(v)`, dispatches to the validator registered under
104/// key `"other"` in `registry`. Returns a hard error if no validator is
105/// registered for that key.
106pub fn validate_sector_data_with_registry(
107    sector_data: &SectorData,
108    registry: &SectorValidatorRegistry,
109) -> Result<(), ValidationErrors> {
110    let mut errors: Vec<FieldError> = Vec::new();
111    if let SectorData::Other(_) = sector_data {
112        match registry.get("other") {
113            Some(v) => {
114                if let Err(field_errors) = v.validate(&sector_data_instance(sector_data)) {
115                    errors.extend(field_errors);
116                }
117            }
118            None => errors.push(FieldError {
119                field: "/sector".to_owned(),
120                message: "SectorData::Other cannot be validated without a registered validator; \
121                          pass a SectorValidatorRegistry with an \"other\" entry"
122                    .to_owned(),
123            }),
124        }
125    } else {
126        schema_errors(sector_data, &mut errors);
127        cross_field_errors(sector_data, &mut errors);
128    }
129    if errors.is_empty() {
130        Ok(())
131    } else {
132        Err(ValidationErrors { errors })
133    }
134}
135
136/// Validate raw sector JSON using the embedded schema registry and any
137/// runtime cross-field validator.
138///
139/// This is the extension point for the plugin host: when a plugin produces a
140/// DPP with a sector key not present in the compile-time `SectorData` enum,
141/// pass the raw JSON through this function with an appropriate
142/// `SectorValidatorRegistry`.
143///
144/// Validation steps:
145/// 1. JSON Schema — resolved via the embedded [`SectorCatalog`] and
146///    [`VersionedSchemaRegistry`] (for sectors with a registered schema).
147/// 2. Cross-field — dispatched to `registry.get(sector_key)` when present.
148/// 3. Hard error — if neither a schema nor a registered validator exists for
149///    `sector_key`.
150pub fn validate_raw_sector_data(
151    sector_key: &str,
152    data: &serde_json::Value,
153    registry: &SectorValidatorRegistry,
154) -> Result<(), ValidationErrors> {
155    let mut errors: Vec<FieldError> = Vec::new();
156    let catalog = default_catalog();
157    let has_schema = catalog.current_schema_version(sector_key).is_some();
158
159    if let Some(version_str) = catalog.current_schema_version(sector_key)
160        && let Ok(version) = version_str.parse::<semver::Version>()
161        && let Err(ve) = default_registry().validate(sector_key, &version, data)
162    {
163        errors.extend(ve.errors);
164    }
165
166    match registry.get(sector_key) {
167        Some(v) => {
168            if let Err(field_errors) = v.validate(data) {
169                errors.extend(field_errors);
170            }
171        }
172        None if !has_schema => {
173            errors.push(FieldError {
174                field: "/sector".to_owned(),
175                message: format!(
176                    "unknown sector \"{sector_key}\": no JSON schema or cross-field validator registered"
177                ),
178            });
179        }
180        None => {}
181    }
182
183    if errors.is_empty() {
184        Ok(())
185    } else {
186        Err(ValidationErrors { errors })
187    }
188}
189
190/// Schema validation via the registry at the catalog-resolved current version.
191fn schema_errors(sector_data: &SectorData, errors: &mut Vec<FieldError>) {
192    let key = sector_data.sector().catalog_key();
193    // No catalog entry (e.g. `Other`) → no schema to validate against.
194    let Some(version_str) = default_catalog().current_schema_version(key) else {
195        return;
196    };
197    let Ok(version) = version_str.parse::<Version>() else {
198        return;
199    };
200    let instance = sector_data_instance(sector_data);
201    if let Err(ve) = default_registry().validate(key, &version, &instance) {
202        errors.extend(ve.errors);
203    }
204}
205
206/// The JSON the schema expects: the inner sector fields without the `"sector"`
207/// discriminant tag that `SectorData` serialises (schemas forbid extra props).
208fn sector_data_instance(sector_data: &SectorData) -> serde_json::Value {
209    let mut value = serde_json::to_value(sector_data).expect("SectorData serializes to Value");
210    if let Some(obj) = value.as_object_mut() {
211        obj.remove("sector");
212    }
213    value
214}
215
216/// Cross-field regulatory rules that JSON Schema cannot express, delegated to
217/// `dpp-rules` through the `dpp-domain` adapters.
218fn cross_field_errors(sector_data: &SectorData, errors: &mut Vec<FieldError>) {
219    match sector_data {
220        SectorData::Battery(d) => {
221            // Operating temperature range must be physically coherent (min < max).
222            if let Err(msg) =
223                validate_battery_operating_temp(d.operating_temp_min_c, d.operating_temp_max_c)
224            {
225                errors.push(FieldError {
226                    field: "/operatingTempMinC".to_owned(),
227                    message: msg,
228                });
229            }
230            // Recycled content declared for a metal the chemistry does not contain
231            // is a data-integrity contradiction (e.g. cobalt on LFP).
232            let chemistry = serde_json::to_value(&d.battery_chemistry)
233                .ok()
234                .and_then(|v| v.as_str().map(str::to_owned))
235                .unwrap_or_default();
236            for metal in battery_recycled_chemistry_conflicts(
237                &chemistry,
238                d.recycled_content_cobalt_pct,
239                d.recycled_content_lithium_pct,
240                d.recycled_content_nickel_pct,
241                d.recycled_content_lead_pct,
242            ) {
243                let field = match metal {
244                    "cobalt" => "/recycledContentCobaltPct",
245                    "lithium" => "/recycledContentLithiumPct",
246                    "nickel" => "/recycledContentNickelPct",
247                    "lead" => "/recycledContentLeadPct",
248                    _ => "/recycledContent",
249                };
250                errors.push(FieldError {
251                    field: field.to_owned(),
252                    message: format!(
253                        "{metal} recycled content declared for a {chemistry} battery, \
254                         which contains no {metal}"
255                    ),
256                });
257            }
258        }
259        SectorData::Textile(d) => {
260            if let Err(msg) = validate_fibre_composition(&d.fibre_composition) {
261                errors.push(FieldError {
262                    field: "/fibreComposition".to_owned(),
263                    message: msg,
264                });
265            }
266            push_svhc(d.svhc_substances.as_deref(), errors);
267            if let Some(ds) = d.durability_score
268                && !(0.0..=10.0).contains(&ds)
269            {
270                errors.push(FieldError {
271                    field: "/durabilityScore".to_owned(),
272                    message: format!("durability_score {ds} must be 0.0–10.0"),
273                });
274            }
275        }
276        SectorData::Electronics(d) => push_svhc(d.svhc_substances.as_deref(), errors),
277        SectorData::Toy(d) => push_svhc(d.svhc_substances.as_deref(), errors),
278        SectorData::Furniture(d) => push_svhc(d.svhc_substances.as_deref(), errors),
279        SectorData::Detergent(d) => {
280            if let Err(msg) = validate_surfactants(&d.surfactants) {
281                errors.push(FieldError {
282                    field: "/surfactants".to_owned(),
283                    message: msg,
284                });
285            }
286        }
287        _ => {}
288    }
289}
290
291fn push_svhc(substances: Option<&[SvhcSubstance]>, errors: &mut Vec<FieldError>) {
292    if let Some(s) = substances
293        && let Err(msg) = validate_svhc_substances(s)
294    {
295        errors.push(FieldError {
296            field: "/svhcSubstances".to_owned(),
297            message: msg,
298        });
299    }
300}
301
302// ─── Batch validation ────────────────────────────────────────────────────────
303
304/// Result of validating a single item in a batch.
305#[derive(Debug, Clone)]
306pub struct BatchValidationItem {
307    /// Zero-based index in the input slice.
308    pub index: usize,
309    /// Validation result: `Ok(())` if valid, `Err` with field-level errors otherwise.
310    pub result: Result<(), ValidationErrors>,
311}
312
313/// Validate a batch of sector data items, collecting all errors per item.
314///
315/// The returned `Vec` has the same length and order as the input.
316pub fn validate_sector_data_batch(items: &[SectorData]) -> Vec<BatchValidationItem> {
317    items
318        .iter()
319        .enumerate()
320        .map(|(index, data)| BatchValidationItem {
321            index,
322            result: validate_sector_data(data),
323        })
324        .collect()
325}
326
327/// Returns only the failures from a batch validation run.
328pub fn batch_errors(results: &[BatchValidationItem]) -> Vec<&BatchValidationItem> {
329    results.iter().filter(|item| item.result.is_err()).collect()
330}
331
332// ─── Tests ────────────────────────────────────────────────────────────────────
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337    use crate::domain::gtin::Gtin;
338    use crate::domain::sector::{BatteryChemistry, BatteryData, FibreEntry, TextileData};
339
340    fn valid_battery() -> SectorData {
341        SectorData::Battery(BatteryData {
342            gtin: Gtin::parse("09506000134352").unwrap(),
343            battery_chemistry: BatteryChemistry::Lfp,
344            nominal_voltage_v: 48.0,
345            nominal_capacity_ah: 100.0,
346            expected_lifetime_cycles: 3000,
347            co2e_per_unit_kg: 85.4,
348            recycled_content_cobalt_pct: None,
349            recycled_content_lithium_pct: None,
350            recycled_content_nickel_pct: None,
351            state_of_health_pct: None,
352            rated_capacity_kwh: None,
353            carbon_footprint_class: None,
354            due_diligence_url: None,
355            cathode_material: None,
356            anode_material: None,
357            electrolyte_material: None,
358            critical_raw_materials: None,
359            disassembly_instructions_url: None,
360            soh_methodology: None,
361            operating_temp_min_c: None,
362            operating_temp_max_c: None,
363            rated_energy_wh: None,
364            recycled_content_lead_pct: None,
365            battery_weight_kg: None,
366            battery_type: None,
367            round_trip_efficiency_pct: None,
368            internal_resistance_mohm: None,
369            manufacturing_date: None,
370            manufacturing_place: None,
371            battery_model_id: None,
372            battery_passport_number: None,
373        })
374    }
375
376    fn valid_textile() -> SectorData {
377        SectorData::Textile(TextileData {
378            fibre_composition: vec![
379                FibreEntry {
380                    fibre: "cotton".into(),
381                    pct: 60.0,
382                    country_of_origin: None,
383                },
384                FibreEntry {
385                    fibre: "polyester".into(),
386                    pct: 40.0,
387                    country_of_origin: None,
388                },
389            ],
390            country_of_manufacturing: "BD".into(),
391            care_instructions: "30°C machine wash".into(),
392            chemical_compliance_standard: "OEKO-TEX 100".into(),
393            recycled_content_pct: None,
394            carbon_footprint_kg_co2e: None,
395            water_use_litres: None,
396            microplastic_shedding_mg_per_wash: None,
397            repair_score: None,
398            durability_score: None,
399            expected_wash_cycles: None,
400            country_of_raw_material_origin: None,
401            svhc_substances: None,
402            allergens: None,
403            substances_of_concern: None,
404            recyclability_class: None,
405            end_of_life_instructions: None,
406            reuse_condition: None,
407            prior_use_cycles: None,
408            disassembly_instructions: None,
409            spare_parts_available: None,
410            product_weight_grams: None,
411            repair_history_url: None,
412            repair_count: None,
413            pef_score: None,
414        })
415    }
416
417    #[test]
418    fn valid_battery_passes() {
419        // Routed through the registry at the catalog's current battery version (v2.0.0).
420        assert!(validate_sector_data(&valid_battery()).is_ok());
421    }
422
423    fn battery_inner() -> BatteryData {
424        match valid_battery() {
425            SectorData::Battery(b) => b,
426            _ => unreachable!("valid_battery is Battery"),
427        }
428    }
429
430    #[test]
431    fn battery_positive_cobalt_on_lfp_fails_cross_field() {
432        let mut b = battery_inner(); // chemistry = LFP (no cobalt)
433        b.recycled_content_cobalt_pct = Some(5.0);
434        let err = validate_sector_data(&SectorData::Battery(b)).unwrap_err();
435        assert!(
436            err.errors
437                .iter()
438                .any(|e| e.field == "/recycledContentCobaltPct"),
439            "expected cobalt-on-LFP conflict, got: {err:?}"
440        );
441    }
442
443    #[test]
444    fn battery_zero_cobalt_on_lfp_passes() {
445        let mut b = battery_inner();
446        b.recycled_content_cobalt_pct = Some(0.0); // "no recycled cobalt" — not a conflict
447        b.recycled_content_lithium_pct = Some(12.5);
448        assert!(validate_sector_data(&SectorData::Battery(b)).is_ok());
449    }
450
451    #[test]
452    fn battery_inverted_operating_temp_fails_cross_field() {
453        let mut b = battery_inner();
454        b.operating_temp_min_c = Some(60.0);
455        b.operating_temp_max_c = Some(-20.0);
456        let err = validate_sector_data(&SectorData::Battery(b)).unwrap_err();
457        assert!(
458            err.errors.iter().any(|e| e.field == "/operatingTempMinC"),
459            "expected operating-temp conflict, got: {err:?}"
460        );
461    }
462
463    #[test]
464    fn valid_textile_passes() {
465        assert!(validate_sector_data(&valid_textile()).is_ok());
466    }
467
468    // The following exercise the schema layer directly through the registry,
469    // crafting structurally invalid instances the type system would otherwise
470    // prevent.
471
472    #[test]
473    fn battery_missing_required_field_fails() {
474        let reg = VersionedSchemaRegistry::new();
475        let v: Version = "1.0.0".parse().unwrap();
476        let instance = serde_json::json!({
477            "batteryChemistry": "LFP",
478            "nominalVoltageV": 48.0,
479            "nominalCapacityAh": 100.0,
480            "expectedLifetimeCycles": 3000,
481            "co2ePerUnitKg": 85.4
482            // "gtin" intentionally missing
483        });
484        let err = reg.validate("battery", &v, &instance).unwrap_err();
485        assert!(
486            err.errors.iter().any(|e| e.message.contains("gtin")),
487            "expected gtin error, got: {err:?}"
488        );
489    }
490
491    #[test]
492    fn battery_invalid_gtin_pattern_fails() {
493        let reg = VersionedSchemaRegistry::new();
494        let v: Version = "1.0.0".parse().unwrap();
495        let instance = serde_json::json!({
496            "gtin": "123", // too short
497            "batteryChemistry": "LFP",
498            "nominalVoltageV": 48.0,
499            "nominalCapacityAh": 100.0,
500            "expectedLifetimeCycles": 3000,
501            "co2ePerUnitKg": 85.4
502        });
503        assert!(reg.validate("battery", &v, &instance).is_err());
504    }
505
506    #[test]
507    fn textile_missing_care_instructions_fails() {
508        let reg = VersionedSchemaRegistry::new();
509        let v: Version = "1.1.0".parse().unwrap();
510        let instance = serde_json::json!({
511            "fibreComposition": [{"fibre": "cotton", "pct": 100}],
512            "countryOfManufacturing": "BD",
513            // "careInstructions" intentionally missing
514            "chemicalComplianceStandard": "REACH"
515        });
516        let err = reg.validate("textile", &v, &instance).unwrap_err();
517        assert!(
518            err.errors
519                .iter()
520                .any(|e| e.message.contains("careInstructions")),
521            "expected careInstructions error, got: {err:?}"
522        );
523    }
524
525    #[test]
526    fn textile_empty_fibre_composition_fails() {
527        let reg = VersionedSchemaRegistry::new();
528        let v: Version = "1.1.0".parse().unwrap();
529        let instance = serde_json::json!({
530            "fibreComposition": [], // minItems: 1
531            "countryOfManufacturing": "DE",
532            "careInstructions": "dry clean only",
533            "chemicalComplianceStandard": "GOTS"
534        });
535        assert!(reg.validate("textile", &v, &instance).is_err());
536    }
537
538    #[test]
539    fn textile_fibre_sum_not_100_fails() {
540        // Schema passes (pct 0–100 individually); the cross-field rule fails.
541        let data = SectorData::Textile(TextileData {
542            fibre_composition: vec![
543                FibreEntry {
544                    fibre: "cotton".into(),
545                    pct: 60.0,
546                    country_of_origin: None,
547                },
548                FibreEntry {
549                    fibre: "polyester".into(),
550                    pct: 30.0, // sums to 90
551                    country_of_origin: None,
552                },
553            ],
554            country_of_manufacturing: "PT".into(),
555            care_instructions: "Hand wash only".into(),
556            chemical_compliance_standard: "REACH".into(),
557            recycled_content_pct: None,
558            carbon_footprint_kg_co2e: None,
559            water_use_litres: None,
560            microplastic_shedding_mg_per_wash: None,
561            repair_score: None,
562            durability_score: None,
563            expected_wash_cycles: None,
564            country_of_raw_material_origin: None,
565            svhc_substances: None,
566            allergens: None,
567            substances_of_concern: None,
568            recyclability_class: None,
569            end_of_life_instructions: None,
570            reuse_condition: None,
571            prior_use_cycles: None,
572            disassembly_instructions: None,
573            spare_parts_available: None,
574            product_weight_grams: None,
575            repair_history_url: None,
576            repair_count: None,
577            pef_score: None,
578        });
579        let err = validate_sector_data(&data).unwrap_err();
580        assert!(
581            err.errors.iter().any(|e| e.field == "/fibreComposition"),
582            "expected /fibreComposition error, got: {err:?}"
583        );
584    }
585
586    // ── SectorValidatorRegistry / validate_raw_sector_data tests ─────────────
587
588    #[test]
589    fn other_sector_data_fails_without_registry() {
590        let data = SectorData::Other(serde_json::json!({"field": "value"}));
591        let err = validate_sector_data(&data).unwrap_err();
592        assert!(
593            err.errors.iter().any(|e| e.field == "/sector"),
594            "expected /sector error for Other without registry"
595        );
596    }
597
598    #[test]
599    fn other_sector_data_passes_with_registered_validator() {
600        use std::sync::Arc;
601
602        struct AlwaysOkValidator;
603        impl SectorValidator for AlwaysOkValidator {
604            fn validate(&self, _: &serde_json::Value) -> Result<(), Vec<FieldError>> {
605                Ok(())
606            }
607        }
608
609        let mut registry = SectorValidatorRegistry::new();
610        registry.register("other", Arc::new(AlwaysOkValidator));
611
612        let data = SectorData::Other(serde_json::json!({"field": "value"}));
613        assert!(
614            validate_sector_data_with_registry(&data, &registry).is_ok(),
615            "registered AlwaysOkValidator must allow Other sector"
616        );
617    }
618
619    #[test]
620    fn other_sector_data_validator_errors_propagate() {
621        use std::sync::Arc;
622
623        struct AlwaysFailValidator;
624        impl SectorValidator for AlwaysFailValidator {
625            fn validate(&self, _: &serde_json::Value) -> Result<(), Vec<FieldError>> {
626                Err(vec![FieldError {
627                    field: "/field".to_owned(),
628                    message: "injected failure".to_owned(),
629                }])
630            }
631        }
632
633        let mut registry = SectorValidatorRegistry::new();
634        registry.register("other", Arc::new(AlwaysFailValidator));
635
636        let data = SectorData::Other(serde_json::json!({"field": "bad"}));
637        let err = validate_sector_data_with_registry(&data, &registry).unwrap_err();
638        assert!(
639            err.errors
640                .iter()
641                .any(|e| e.message.contains("injected failure")),
642            "validator errors must propagate"
643        );
644    }
645
646    #[test]
647    fn validate_raw_sector_data_known_sector_succeeds() {
648        // "battery" has an embedded schema — validate known-good raw JSON.
649        let data = serde_json::json!({
650            "gtin": "09506000134352",
651            "batteryChemistry": "LFP",
652            "nominalVoltageV": 48.0,
653            "nominalCapacityAh": 100.0,
654            "expectedLifetimeCycles": 3000,
655            "co2ePerUnitKg": 85.4
656        });
657        let registry = SectorValidatorRegistry::default();
658        assert!(validate_raw_sector_data("battery", &data, &registry).is_ok());
659    }
660
661    #[test]
662    fn validate_raw_sector_data_unknown_sector_fails() {
663        let data = serde_json::json!({"field": "value"});
664        let registry = SectorValidatorRegistry::default();
665        let err = validate_raw_sector_data("nonexistent-sector", &data, &registry).unwrap_err();
666        assert!(
667            err.errors
668                .iter()
669                .any(|e| e.message.contains("nonexistent-sector")),
670            "expected error naming the unknown sector key"
671        );
672    }
673
674    #[test]
675    fn batch_validation_mixed_results() {
676        let items = vec![
677            valid_battery(),
678            valid_textile(),
679            // Invalid: fibre sum != 100
680            SectorData::Textile(TextileData {
681                fibre_composition: vec![FibreEntry {
682                    fibre: "cotton".into(),
683                    pct: 50.0,
684                    country_of_origin: None,
685                }],
686                country_of_manufacturing: "PT".into(),
687                care_instructions: "Hand wash".into(),
688                chemical_compliance_standard: "REACH".into(),
689                recycled_content_pct: None,
690                carbon_footprint_kg_co2e: None,
691                water_use_litres: None,
692                microplastic_shedding_mg_per_wash: None,
693                repair_score: None,
694                durability_score: None,
695                expected_wash_cycles: None,
696                country_of_raw_material_origin: None,
697                svhc_substances: None,
698                allergens: None,
699                substances_of_concern: None,
700                recyclability_class: None,
701                end_of_life_instructions: None,
702                reuse_condition: None,
703                prior_use_cycles: None,
704                disassembly_instructions: None,
705                spare_parts_available: None,
706                product_weight_grams: None,
707                repair_history_url: None,
708                repair_count: None,
709                pef_score: None,
710            }),
711        ];
712
713        let results = validate_sector_data_batch(&items);
714        assert_eq!(results.len(), 3);
715        assert!(results[0].result.is_ok());
716        assert!(results[1].result.is_ok());
717        assert!(results[2].result.is_err());
718
719        let errors = batch_errors(&results);
720        assert_eq!(errors.len(), 1);
721        assert_eq!(errors[0].index, 2);
722    }
723}