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}