Skip to main content

brep_render/engine_state/
bom.rs

1use super::*;
2use serde_json::Value;
3
4// ===========================================================================
5// BOM export (assemblies build-spec §9) — the parts list straight off the
6// MAIN-SIDE parts library + live component projection, both already warm via
7// the assembly sync (`sync_assembly` re-executes main-side after each applied
8// run). One row per parts-library entry: `{partName, sourceKey, quantity}`
9// with quantity = live ACOMP instance count. A sub-assembly is ONE row at
10// this level — its internal parts are its own document's business (the
11// rigid-nesting model). Exported as CSV and JSON through the file dialog's
12// Export modal; deliberately small: no per-configuration quantities, no
13// extra columns, no localization.
14// ===========================================================================
15
16/// One BOM row: a parts-library entry + its live instance count. Field order
17/// IS the exported JSON key order (serde serializes structs in declaration
18/// order), so the record shape stays `{partName, sourceKey, quantity}`.
19#[derive(serde::Serialize)]
20struct BomRow {
21    #[serde(rename = "partName")]
22    part_name: String,
23    #[serde(rename = "sourceKey")]
24    source_key: String,
25    quantity: usize,
26}
27
28/// Quote a CSV field only when it needs it (comma / quote / CR / LF),
29/// doubling embedded quotes — minimal RFC-4180 so a part name or store path
30/// with a comma can never shear a row.
31fn csv_field(text: &str) -> String {
32    if text.contains([',', '"', '\n', '\r']) {
33        format!("\"{}\"", text.replace('"', "\"\""))
34    } else {
35        text.to_string()
36    }
37}
38
39impl EngineState {
40    /// The BOM rows in parts-library order (BTreeMap ⇒ alphabetical part
41    /// name — deterministic). Quantity counts component RECORDS (instances),
42    /// not member solids, so a multi-body part is still one per placement.
43    /// Errs (`"no components in the assembly"`) on a componentless document.
44    fn bom_rows(&mut self) -> Result<Vec<BomRow>, String> {
45        self.ensure_assembly_synced();
46        if self.assembly_components.is_empty() {
47            return Err("no components in the assembly".into());
48        }
49        let library: std::collections::BTreeMap<String, Value> =
50            serde_json::from_str(&brep_kernel::parts_library_json())
51                .map_err(|error| format!("parts library unreadable: {error}"))?;
52        Ok(library
53            .into_iter()
54            .map(|(part_name, entry)| {
55                let quantity = self
56                    .assembly_components
57                    .iter()
58                    .filter(|record| record.part_name == part_name)
59                    .count();
60                BomRow {
61                    source_key: entry
62                        .get("sourceKey")
63                        .and_then(Value::as_str)
64                        .unwrap_or_default()
65                        .to_string(),
66                    part_name,
67                    quantity,
68                }
69            })
70            // The kernel GCs zero-instance entries at the end of every run;
71            // the filter keeps the export honest mid-mutation regardless.
72            .filter(|row| row.quantity > 0)
73            .collect())
74    }
75
76    /// Export the assembly BOM as CSV: the exact `partName,sourceKey,quantity`
77    /// header, one line per parts-library entry, LF endings.
78    pub fn export_bom_csv(&mut self) -> Result<String, String> {
79        let mut out = String::from("partName,sourceKey,quantity\n");
80        for row in self.bom_rows()? {
81            out.push_str(&format!(
82                "{},{},{}\n",
83                csv_field(&row.part_name),
84                csv_field(&row.source_key),
85                row.quantity
86            ));
87        }
88        Ok(out)
89    }
90
91    /// Export the assembly BOM as a JSON array of the same records — the CSV
92    /// sibling of [`Self::export_bom_csv`].
93    pub fn export_bom_json(&mut self) -> Result<String, String> {
94        serde_json::to_string(&self.bom_rows()?)
95            .map_err(|error| format!("BOM serialize: {error}"))
96    }
97
98    // ======================================================================
99    // BOM ATTRIBUTES — the editable columns behind the BOM panel
100    //
101    // Two stores, because the data has two lifetimes:
102    //
103    // * PART attributes (Part Number, Material, Mass…) describe the PART, so
104    //   they live on the part's OWN document, under the top-level
105    //   [`PART_ATTRIBUTES`] key: `partsLibrary[part].document.partAttributes`.
106    //   Being on the part document means they travel WITH the part — the
107    //   write-through lane saves that same document back to its `sourceKey`,
108    //   so opening the part standalone shows the same Part Number. `History`
109    //   keeps unknown top-level document keys verbatim (`from_request_json`
110    //   parses into a `Value` and only lifts out the keys it owns), so the key
111    //   round-trips through save/open with no format work.
112    //
113    // * OCCURRENCE attributes (Item Number, Reference Designator, Find
114    //   Number…) describe ONE PLACEMENT, so they live on the placing ACOMP
115    //   feature, under [`OCCURRENCE_ATTRIBUTES`] —
116    //   `feature.inputParams.bom`. NESTED rather than flat so a user-added
117    //   custom column can never collide with a schema param (`isFixed`,
118    //   `partName`, `transform`); the ACOMP builder reads its four keys by
119    //   name and ignores the rest, and the solver write-back fold
120    //   (`assembly_apply_document_json`) INSERTS `transform`/`isFixed` into the
121    //   existing params object rather than rebuilding it, so a solve never
122    //   strips this.
123    //
124    // Quantity is deliberately absent from both: it is DERIVED (how many
125    // occurrences a packed row rolls up), never stored, so it can never
126    // disagree with the model.
127    // ======================================================================
128
129    /// Read a part's attribute record (`{}` when the part has none / is
130    /// unknown). Never errs — a BOM row for a part mid-import simply shows
131    /// blanks.
132    pub fn part_attributes(&self, part_name: &str) -> Value {
133        self.history
134            .parts_library()
135            .get(part_name)
136            .and_then(|entry| entry.get("document"))
137            .and_then(|document| document.get(PART_ATTRIBUTES))
138            .filter(|value| value.is_object())
139            .cloned()
140            .unwrap_or_else(|| Value::Object(serde_json::Map::new()))
141    }
142
143    /// A part's `(sourceKey, sourceSignature)` — what the app's write-through
144    /// lane needs to decide whether the file on disk is still the one this
145    /// entry was built from. `None` for an unknown part; the key is returned
146    /// even when EMPTY (embedded-only), because "" is exactly what the
147    /// write-through lane checks for.
148    pub fn part_source(&self, part_name: &str) -> Option<(String, String)> {
149        let entry = self.history.parts_library().get(part_name)?;
150        Some((
151            entry
152                .get("sourceKey")
153                .and_then(Value::as_str)
154                .unwrap_or_default()
155                .to_string(),
156            entry
157                .get("sourceSignature")
158                .and_then(Value::as_str)
159                .unwrap_or_default()
160                .to_string(),
161        ))
162    }
163
164    /// A part's embedded document as text — the payload the app writes through
165    /// to the part's `sourceKey` after an attribute edit.
166    pub fn part_document_json(&self, part_name: &str) -> Option<String> {
167        self.history
168            .parts_library()
169            .get(part_name)
170            .and_then(|entry| entry.get("document"))
171            .map(|document| document.to_string())
172    }
173
174    /// Write ONE part attribute. `Value::Null` (or an empty string) REMOVES the
175    /// key, so clearing a cell leaves no `""` litter in the saved document.
176    ///
177    /// # Why this is not just a document edit
178    ///
179    /// The document's `partsLibrary` block is a MIRROR of the kernel's
180    /// main-side store, not the truth: `sync_assembly` re-serializes the store
181    /// over the block after every run, and the per-run request does not carry
182    /// the block at all ([`History::prefix_request`] omits it). So a change
183    /// written only into the block is erased by the next sync. This therefore
184    /// writes BOTH: the kernel store (via `refresh_library_entry`, which is the
185    /// same door edit-in-place and update-components use) and the document
186    /// block + undo checkpoint. [`Self::undo`] re-installs the rewound block
187    /// into the store, which is what makes the pair rewind together.
188    ///
189    /// The `snapshot` is deliberately KEPT: an attribute is not geometry, so
190    /// there is nothing to re-evaluate. `refresh_library_entry` still marks the
191    /// entry dirty (its contract), so the ACOMP self-heal re-derives it on the
192    /// next run — correct, just not free. That is the price of the attributes
193    /// living on the part document, and it is why the panel commits a text cell
194    /// on focus-loss rather than per keystroke.
195    pub fn set_part_attribute(
196        &mut self,
197        part_name: &str,
198        key: &str,
199        value: Value,
200    ) -> Result<(), String> {
201        if key.is_empty() {
202            return Err("part attribute: empty key".to_string());
203        }
204        let mut block = self.history.parts_library().clone();
205        let entry = block
206            .get_mut(part_name)
207            .ok_or_else(|| format!("no parts-library entry '{part_name}'"))?;
208        let document = entry
209            .get_mut("document")
210            .filter(|value| value.is_object())
211            .ok_or_else(|| format!("part '{part_name}': malformed document"))?;
212        write_attribute(document, PART_ATTRIBUTES, key, value)?;
213        let document_text = document.to_string();
214        let signature = super::document_signature(&document_text);
215        entry["sourceSignature"] = Value::String(signature.clone());
216
217        // The kernel store is the block's authority — write it there too, or
218        // the next `sync_assembly` mirrors the OLD entry back over this edit.
219        brep_kernel::refresh_library_entry(part_name, &signature, &document_text)
220            .map_err(|error| format!("part '{part_name}': {error:?}"))?;
221        self.history
222            .set_parts_library_edited(block, Some(&format!("partattr:{part_name}:{key}")));
223        self.rerun_history();
224        Ok(())
225    }
226
227    /// Read ONE occurrence's attribute record (`{}` when it has none).
228    pub fn occurrence_attributes(&self, component_id: &str) -> Value {
229        self.history
230            .index_of(component_id)
231            .and_then(|index| self.history.feature_params(index))
232            .and_then(|params| params.get(OCCURRENCE_ATTRIBUTES).cloned())
233            .filter(Value::is_object)
234            .unwrap_or_else(|| Value::Object(serde_json::Map::new()))
235    }
236
237    /// Write ONE occurrence attribute across `component_ids` — ONE undo step
238    /// however many ids there are.
239    ///
240    /// The fan-out lane: a PACKED BOM row rolls up every occurrence of a part
241    /// whose occurrence data matches, and editing that row's cell must apply to
242    /// all of them. One id (the unpacked case) is the same call with a
243    /// one-element slice, so there is no second code path to keep in step.
244    /// `Value::Null` / `""` removes the key.
245    pub fn set_occurrence_attribute(
246        &mut self,
247        component_ids: &[String],
248        key: &str,
249        value: Value,
250    ) -> Result<(), String> {
251        if key.is_empty() {
252            return Err("occurrence attribute: empty key".to_string());
253        }
254        let mut edits: Vec<(String, Value)> = Vec::with_capacity(component_ids.len());
255        for id in component_ids {
256            let index = self
257                .history
258                .index_of(id)
259                .ok_or_else(|| format!("no component feature '{id}'"))?;
260            let mut params = self
261                .history
262                .feature_params(index)
263                .unwrap_or_else(|| Value::Object(serde_json::Map::new()));
264            if !params.is_object() {
265                return Err(format!("component '{id}': malformed inputParams"));
266            }
267            write_attribute(&mut params, OCCURRENCE_ATTRIBUTES, key, value.clone())?;
268            edits.push((id.clone(), params));
269        }
270        self.update_many_feature_params(&edits)?;
271        Ok(())
272    }
273}
274
275/// The part document's attribute-record key (see the module's BOM-attributes
276/// block). A top-level document key, so it rides save/open untouched.
277pub const PART_ATTRIBUTES: &str = "partAttributes";
278
279/// The ACOMP `inputParams` attribute-record key.
280pub const OCCURRENCE_ATTRIBUTES: &str = "bom";
281
282/// Set (or, for a null/empty value, REMOVE) `record[key]` inside `owner`'s
283/// attribute record, creating the record on first write and dropping it again
284/// when the last attribute goes — so a document that was never annotated
285/// serializes exactly as it did before (the `metadata` field's convention).
286fn write_attribute(
287    owner: &mut Value,
288    record_key: &str,
289    key: &str,
290    value: Value,
291) -> Result<(), String> {
292    let object = owner
293        .as_object_mut()
294        .ok_or_else(|| "attribute owner is not an object".to_string())?;
295    let clearing = matches!(&value, Value::Null)
296        || matches!(&value, Value::String(text) if text.is_empty());
297    if clearing {
298        let mut empty = false;
299        if let Some(record) = object.get_mut(record_key).and_then(Value::as_object_mut) {
300            record.remove(key);
301            empty = record.is_empty();
302        }
303        if empty {
304            object.remove(record_key);
305        }
306        return Ok(());
307    }
308    let record = object
309        .entry(record_key.to_string())
310        .or_insert_with(|| Value::Object(serde_json::Map::new()));
311    if !record.is_object() {
312        *record = Value::Object(serde_json::Map::new());
313    }
314    record
315        .as_object_mut()
316        .expect("just normalized to an object")
317        .insert(key.to_string(), value);
318    Ok(())
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324
325    fn fresh_state() -> EngineState {
326        brep_kernel::clear_history_cache();
327        EngineState::new()
328    }
329
330    /// A one-cube part document with a distinguishing `size` (distinct content
331    /// per part so nothing dedups across entries).
332    fn part_document(size: f64) -> String {
333        component_fixtures::cube_part_document(size).to_string()
334    }
335
336    /// RIGID NESTING: an inserted ASSEMBLY (the embedded document is itself a
337    /// two-instance assembly over its own partsLibrary) is ONE row at this
338    /// level — its internal parts never leak into the parent's BOM.
339    #[test]
340    fn a_sub_assembly_is_one_row_at_this_level() {
341        let mut state = fresh_state();
342        let sub_assembly = component_fixtures::two_instance_assembly_json();
343        state
344            .insert_component(ComponentInsert::New {
345                name: "sub-asm",
346                source_key: "sub-asm",
347                source_signature: "sig-sub",
348                document_json: &sub_assembly,
349            })
350            .expect("sub-assembly inserts");
351        // The nested instance is fully resident (both internal cubes came
352        // along as members of the ONE parent record)…
353        let components = state.assembly_components();
354        assert_eq!(components.len(), 1, "one parent-level instance");
355        assert_eq!(components[0].solids.len(), 2, "…carrying two member solids");
356        // …yet the BOM stays one row.
357        assert_eq!(
358            state.export_bom_csv().unwrap(),
359            "partName,sourceKey,quantity\nsub-asm,sub-asm,1\n"
360        );
361    }
362
363    /// The lane's core fixture: two instances of one part + one of another →
364    /// rows (partA,2), (partB,1) — asserted as the EXACT CSV text (incl. the
365    /// header) and the EXACT JSON array.
366    #[test]
367    fn two_of_one_part_one_of_another_exact_csv_and_json() {
368        let mut state = fresh_state();
369        state
370            .insert_component(ComponentInsert::New {
371                name: "partA",
372                source_key: "partA-src",
373                source_signature: "sig-a",
374                document_json: &part_document(2.0),
375            })
376            .unwrap();
377        state
378            .insert_component(ComponentInsert::Existing { part_name: "partA" })
379            .unwrap();
380        state
381            .insert_component(ComponentInsert::New {
382                name: "partB",
383                source_key: "partB-src",
384                source_signature: "sig-b",
385                document_json: &part_document(3.0),
386            })
387            .unwrap();
388
389        assert_eq!(
390            state.export_bom_csv().unwrap(),
391            "partName,sourceKey,quantity\npartA,partA-src,2\npartB,partB-src,1\n"
392        );
393        assert_eq!(
394            state.export_bom_json().unwrap(),
395            r#"[{"partName":"partA","sourceKey":"partA-src","quantity":2},{"partName":"partB","sourceKey":"partB-src","quantity":1}]"#
396        );
397    }
398
399    /// A componentless document refuses both formats with the exact guard the
400    /// file dialog surfaces as status + toast.
401    #[test]
402    fn componentless_document_refuses_bom_export() {
403        let mut state = fresh_state();
404        assert_eq!(
405            state.export_bom_csv().unwrap_err(),
406            "no components in the assembly"
407        );
408        assert_eq!(
409            state.export_bom_json().unwrap_err(),
410            "no components in the assembly"
411        );
412    }
413
414    /// The minimal RFC-4180 quoting: plain fields pass through untouched;
415    /// commas/quotes trigger quoting with doubled quotes.
416    #[test]
417    fn csv_fields_quote_only_when_needed() {
418        assert_eq!(csv_field("plain-name"), "plain-name");
419        assert_eq!(csv_field("a,b"), "\"a,b\"");
420        assert_eq!(csv_field("say \"hi\""), "\"say \"\"hi\"\"\"");
421    }
422
423    // --- BOM attributes ---------------------------------------------------
424
425    /// Two instances of one part + one of another — the fixture every
426    /// attribute test edits (ACOMP1/ACOMP2 are `partA`, ACOMP3 is `partB`).
427    fn two_part_assembly() -> EngineState {
428        let mut state = fresh_state();
429        state
430            .insert_component(ComponentInsert::New {
431                name: "partA",
432                source_key: "partA-src",
433                source_signature: "sig-a",
434                document_json: &part_document(2.0),
435            })
436            .unwrap();
437        state
438            .insert_component(ComponentInsert::Existing { part_name: "partA" })
439            .unwrap();
440        state
441            .insert_component(ComponentInsert::New {
442                name: "partB",
443                source_key: "partB-src",
444                source_signature: "sig-b",
445                document_json: &part_document(3.0),
446            })
447            .unwrap();
448        state
449    }
450
451    /// A PART attribute lands on the part's own document (so it travels with
452    /// the part, not the assembly), is read back through the accessor, rides
453    /// the saved document, and rewinds in ONE undo — which only works because
454    /// undo re-installs the rewound library block into the kernel store.
455    #[test]
456    fn part_attribute_writes_the_part_document_and_undoes_in_one_step() {
457        let mut state = two_part_assembly();
458        assert_eq!(
459            state.part_attributes("partA"),
460            serde_json::json!({}),
461            "no attributes until one is written"
462        );
463
464        state
465            .set_part_attribute("partA", "Part_Number", Value::String("PN-1000".into()))
466            .expect("attribute writes");
467        assert_eq!(state.part_attributes("partA")["Part_Number"], "PN-1000");
468        // It is on the PART's document — the place that travels with the part
469        // through write-through — not on the assembly or the library entry.
470        let saved: Value = serde_json::from_str(&state.history_request_json()).unwrap();
471        assert_eq!(
472            saved["partsLibrary"]["partA"]["document"][PART_ATTRIBUTES]["Part_Number"],
473            "PN-1000"
474        );
475        assert!(
476            saved.get(PART_ATTRIBUTES).is_none(),
477            "the ASSEMBLY document must not carry the part's attributes"
478        );
479        // The sibling part is untouched.
480        assert_eq!(state.part_attributes("partB"), serde_json::json!({}));
481
482        // ONE undo takes it back — and it STAYS back after the re-run's
483        // `sync_assembly` re-mirrors the library block from the kernel store.
484        state.undo();
485        state.ensure_assembly_synced();
486        assert_eq!(
487            state.part_attributes("partA"),
488            serde_json::json!({}),
489            "one undo, and the re-mirror does not resurrect the edit"
490        );
491        state.redo();
492        state.ensure_assembly_synced();
493        assert_eq!(state.part_attributes("partA")["Part_Number"], "PN-1000");
494    }
495
496    /// The PACKED fan-out: one edit across the two occurrences of `partA` is
497    /// ONE undo step, not two — the whole reason
498    /// [`EngineState::update_many_feature_params`] exists.
499    #[test]
500    fn packed_occurrence_edit_fans_out_and_undoes_in_one_step() {
501        let mut state = two_part_assembly();
502        let packed = vec!["ACOMP1".to_string(), "ACOMP2".to_string()];
503
504        state
505            .set_occurrence_attribute(&packed, "Find_Number", Value::String("7".into()))
506            .expect("fan-out writes");
507        assert_eq!(state.occurrence_attributes("ACOMP1")["Find_Number"], "7");
508        assert_eq!(state.occurrence_attributes("ACOMP2")["Find_Number"], "7");
509        assert_eq!(
510            state.occurrence_attributes("ACOMP3"),
511            serde_json::json!({}),
512            "the other part's occurrence is untouched"
513        );
514
515        // ONE undo, both gone. (A `update_feature_params` loop would need two:
516        // its coalesce key is per feature INDEX, so the two edits never merge.)
517        state.undo();
518        assert_eq!(state.occurrence_attributes("ACOMP1"), serde_json::json!({}));
519        assert_eq!(state.occurrence_attributes("ACOMP2"), serde_json::json!({}));
520    }
521
522    /// Occurrence attributes ride the ACOMP's `inputParams` under a NESTED
523    /// key, so they survive the post-run solver write-back fold (which inserts
524    /// `transform`/`isFixed` into the existing params object) and cannot
525    /// collide with a schema param. Quantity is never stored.
526    #[test]
527    fn occurrence_attributes_are_nested_and_survive_the_solve_fold() {
528        let mut state = two_part_assembly();
529        state
530            .set_occurrence_attribute(
531                &["ACOMP3".to_string()],
532                "Reference_Designator",
533                Value::String("R7".into()),
534            )
535            .unwrap();
536        // Nested under the record key, alongside — never replacing — the
537        // schema params.
538        let index = state.history.index_of("ACOMP3").unwrap();
539        let params = state.history.feature_params(index).unwrap();
540        assert_eq!(params[OCCURRENCE_ATTRIBUTES]["Reference_Designator"], "R7");
541        assert_eq!(params["partName"], "partB", "schema params intact");
542        assert!(params.get("isFixed").is_some(), "schema params intact");
543        assert!(
544            params.get("quantity").is_none() && params[OCCURRENCE_ATTRIBUTES].get("Quantity").is_none(),
545            "quantity is DERIVED from the occurrence count, never stored"
546        );
547
548        // A re-run + assembly sync folds solved poses back onto the feature;
549        // the fold inserts keys rather than rebuilding the params object, so
550        // the record is still there.
551        state.ensure_assembly_synced();
552        assert_eq!(
553            state.occurrence_attributes("ACOMP3")["Reference_Designator"],
554            "R7"
555        );
556    }
557
558    /// Clearing a cell REMOVES the key (and the record with the last key), so
559    /// a document that was never annotated — or was annotated and cleared —
560    /// serializes without the record rather than carrying `""` litter.
561    #[test]
562    fn clearing_an_attribute_removes_the_key_and_the_empty_record() {
563        let mut state = two_part_assembly();
564        let one = vec!["ACOMP1".to_string()];
565        state
566            .set_occurrence_attribute(&one, "Notes", Value::String("check fit".into()))
567            .unwrap();
568        state
569            .set_occurrence_attribute(&one, "Find_Number", Value::String("3".into()))
570            .unwrap();
571        state
572            .set_occurrence_attribute(&one, "Notes", Value::String(String::new()))
573            .unwrap();
574        let attributes = state.occurrence_attributes("ACOMP1");
575        assert!(attributes.get("Notes").is_none(), "cleared key is gone");
576        assert_eq!(attributes["Find_Number"], "3", "siblings survive");
577
578        state
579            .set_occurrence_attribute(&one, "Find_Number", Value::Null)
580            .unwrap();
581        let index = state.history.index_of("ACOMP1").unwrap();
582        let params = state.history.feature_params(index).unwrap();
583        assert!(
584            params.get(OCCURRENCE_ATTRIBUTES).is_none(),
585            "the last attribute takes the empty record with it"
586        );
587    }
588
589    /// Non-string values keep their JSON type — a numeric column stores a
590    /// number, so sorting and export never have to re-parse text.
591    #[test]
592    fn numeric_attributes_keep_their_json_type() {
593        let mut state = two_part_assembly();
594        state
595            .set_part_attribute("partA", "Mass", serde_json::json!(12.5))
596            .unwrap();
597        assert_eq!(state.part_attributes("partA")["Mass"], 12.5);
598        assert!(state.part_attributes("partA")["Mass"].is_number());
599    }
600
601    /// The write-through inputs: a part's `(sourceKey, sourceSignature)` and
602    /// its document text, with the signature re-stamped over the EDITED
603    /// document so the app writes and hashes the same bytes.
604    #[test]
605    fn part_source_and_document_track_the_attribute_edit() {
606        let mut state = two_part_assembly();
607        state
608            .set_part_attribute("partA", "Material", Value::String("6061-T6".into()))
609            .unwrap();
610        let (key, signature) = state.part_source("partA").expect("partA has a source");
611        assert_eq!(key, "partA-src");
612        let document = state.part_document_json("partA").expect("document text");
613        assert!(document.contains("6061-T6"), "the edit is in the text");
614        assert_eq!(
615            signature,
616            super::super::document_signature(&document),
617            "the stamped signature hashes the very text the app writes through"
618        );
619        assert!(state.part_source("nope").is_none());
620    }
621
622    /// Unknown ids are refused BEFORE anything is written — a fan-out can
623    /// never half-apply.
624    #[test]
625    fn a_fan_out_over_an_unknown_id_writes_nothing() {
626        let mut state = two_part_assembly();
627        let ids = vec!["ACOMP1".to_string(), "NOPE".to_string()];
628        assert_eq!(
629            state
630                .set_occurrence_attribute(&ids, "Notes", Value::String("x".into()))
631                .unwrap_err(),
632            "no component feature 'NOPE'"
633        );
634        assert_eq!(
635            state.occurrence_attributes("ACOMP1"),
636            serde_json::json!({}),
637            "the good id was not written either"
638        );
639        assert!(!state.can_undo() || {
640            // Nothing was checkpointed for the refused batch: undoing here
641            // rewinds the INSERT, not a phantom attribute edit.
642            state.undo();
643            state.assembly_components().len() < 3
644        });
645    }
646}