Skip to main content

teamcraft/services/
bom_references.rs

1//! A BOM from the CAD references the server found.
2//!
3//! The scan of the CAD files (`cadrefs`, matched by
4//! [`references::files_of_revision`]) knows which part files an assembly
5//! uses. This module turns that into a **plan** (what the BOM of the revision
6//! would be, with quantities) and applies the lines the user ticked.
7//!
8//! # Which references count
9//!
10//! * Files of an **assembly** format (SOLIDWORKS `.SLDASM`, Inventor, …) and
11//!   NX files **that have component instances** (an NX `.prt` is a part or an
12//!   assembly by its content, not by its extension), and STEP assemblies
13//!   whose occurrences lead to **external files**. Drawings and every other
14//!   file do not count: a drawing's link to its model is a *drawing of*
15//!   reference.
16//! * **Quantity:** NX – the placed instances of the component (hidden layers
17//!   left out, as in the preview); SOLIDWORKS – the instances of the
18//!   component tree without `swExcludeFromBOM` (a component with only
19//!   excluded instances is left out); STEP – the assembly occurrences of the
20//!   external file; Inventor – the occurrences in the file descriptor table
21//!   (suppressed ones left out; a part with only suppressed ones is left
22//!   out); otherwise 1, flagged *assumed*.
23//! * A child is listed once per item. Unmatched names are listed as *not in
24//!   TeamCraft* (never created). Children in project groups the actor may not
25//!   see are only counted.
26//!
27//! # Invariants of [`apply`]
28//!
29//! * **Permission:** the revision is visible ("not found" otherwise); it is
30//!   checked out by the actor and editable (`revisions::ensure_editable`, 409
31//!   otherwise).
32//! * **Lines:** only children of the plan can be chosen; an existing line is
33//!   never duplicated or replaced, its quantity is changed only when
34//!   `update_quantities` is set; a child that would make the BOM circular is
35//!   refused per line.
36//! * **Locks:** the parent revision row.
37//! * **Audit:** `bom.add` per line (as `bom::add_tx`), `bom.update` per
38//!   changed quantity and one `bom.from_references` with the counts.
39//! * **Atomic:** one transaction.
40
41use std::collections::{HashMap, HashSet};
42
43use serde::{Deserialize, Serialize};
44use serde_json::json;
45use sqlx::PgPool;
46use uuid::Uuid;
47
48use crate::{
49    auth::Actor,
50    cadformat::FormatKind,
51    error::{AppError, AppResult},
52    services::{
53        audit, bom,
54        references::{self, FileWithReferences},
55        revisions,
56    },
57    util, AppState,
58};
59
60/// Note of a line whose quantity the CAD file does not tell.
61pub const ASSUMED_NOTE: &str = "quantity not known from the CAD file – check it";
62
63#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
64#[serde(rename_all = "snake_case")]
65pub enum QuantitySource {
66    /// Instances counted in the CAD file.
67    CadInstances,
68    /// The file does not tell: 1.
69    Assumed,
70}
71
72impl QuantitySource {
73    pub fn label(self) -> &'static str {
74        match self {
75            QuantitySource::CadInstances => "CAD instances",
76            QuantitySource::Assumed => "assumed 1",
77        }
78    }
79}
80
81#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
82#[serde(rename_all = "snake_case")]
83pub enum ChildStatus {
84    /// Not in the BOM yet.
85    New,
86    /// A line with the same quantity exists.
87    AlreadyInBom,
88    /// A line exists with another quantity.
89    Differs,
90    /// Adding it would make the BOM circular.
91    Circular,
92}
93
94impl ChildStatus {
95    pub fn label(self) -> &'static str {
96        match self {
97            ChildStatus::New => "new line",
98            ChildStatus::AlreadyInBom => "already in the BOM",
99            ChildStatus::Differs => "differs",
100            ChildStatus::Circular => "circular",
101        }
102    }
103}
104
105/// A child found in the CAD references.
106#[derive(Clone, Debug, Serialize)]
107pub struct PlannedChild {
108    pub item_id: Uuid,
109    pub item_number: String,
110    pub item_name: String,
111    pub quantity: f64,
112    pub quantity_source: QuantitySource,
113    pub quantity_label: &'static str,
114    /// The quantities as shown (`4`, not `4.0`).
115    pub quantity_text: String,
116    pub bom_quantity_text: Option<String>,
117    /// The CAD file that references it and the referenced file.
118    pub via: String,
119    pub status: ChildStatus,
120    pub status_label: &'static str,
121    /// The quantity of the existing BOM line.
122    pub bom_quantity: Option<f64>,
123    /// Ticked by default (new lines).
124    pub selected: bool,
125}
126
127/// A referenced file TeamCraft does not have (or not visible to the actor).
128#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
129pub struct Unmatched {
130    pub name: String,
131    pub via: String,
132}
133
134#[derive(Clone, Debug, Default, Serialize)]
135pub struct Plan {
136    pub revision_id: Uuid,
137    pub children: Vec<PlannedChild>,
138    pub not_in_teamcraft: Vec<Unmatched>,
139    /// Referenced items in project groups the actor may not see (counted,
140    /// not named).
141    pub hidden: i64,
142    /// Components of the CAD files that are excluded from the BOM there.
143    pub excluded: Vec<String>,
144}
145
146/// A child before it is compared with the BOM.
147#[derive(Clone, Debug, PartialEq)]
148pub(crate) struct Candidate {
149    pub item_id: Uuid,
150    pub item_number: String,
151    pub item_name: String,
152    pub quantity: f64,
153    pub source: QuantitySource,
154    pub via: String,
155}
156
157/// One CAD file of the revision with what is known about its components.
158pub(crate) struct SourceFile<'a> {
159    pub file: &'a FileWithReferences,
160    /// Format id (`nx-part`, …).
161    pub format: &'a str,
162    /// Instance counts by lower case file name (`None`: the file tells none).
163    pub counts: Option<&'a HashMap<String, u32>>,
164}
165
166#[derive(Debug, Default, PartialEq)]
167pub(crate) struct Collected {
168    pub children: Vec<Candidate>,
169    pub unmatched: Vec<Unmatched>,
170    pub excluded: Vec<String>,
171}
172
173/// Does this file's references describe the structure of the revision?
174fn is_structure(src: &SourceFile<'_>) -> bool {
175    match src.file.format_kind {
176        Some(FormatKind::Assembly) => true,
177        // NX: an assembly by content (it has component instances)
178        Some(FormatKind::Model) if src.format == "nx-part" => {
179            src.counts.is_some_and(|c| !c.is_empty())
180        }
181        // STEP: an assembly with external references (occurrences counted)
182        Some(FormatKind::Exchange) if src.format == "step" => {
183            src.counts.is_some_and(|c| !c.is_empty())
184        }
185        _ => false,
186    }
187}
188
189/// The children of the structure files: the first match of every referenced
190/// name that is another item, once per item.
191pub(crate) fn collect(files: &[SourceFile<'_>]) -> Collected {
192    let mut out = Collected::default();
193    let mut seen: HashSet<Uuid> = HashSet::new();
194    let mut seen_unmatched: HashSet<String> = HashSet::new();
195    for src in files.iter().filter(|s| is_structure(s)) {
196        for name in &src.file.references {
197            let key = name.name.to_lowercase();
198            let counted = src.counts.and_then(|c| c.get(&key)).copied();
199            if counted == Some(0) {
200                if !out.excluded.contains(&name.name) {
201                    out.excluded.push(name.name.clone());
202                }
203                continue;
204            }
205            if name.matches.is_empty() {
206                if seen_unmatched.insert(key) {
207                    out.unmatched.push(Unmatched {
208                        name: name.name.clone(),
209                        via: src.file.filename.clone(),
210                    });
211                }
212                continue;
213            }
214            // the revision's own files are no children
215            let Some(m) = name.matches.iter().find(|m| !m.same_revision) else {
216                continue;
217            };
218            if !seen.insert(m.item_id) {
219                continue;
220            }
221            let (quantity, source) = match counted {
222                Some(n) => (f64::from(n), QuantitySource::CadInstances),
223                None => (1.0, QuantitySource::Assumed),
224            };
225            out.children.push(Candidate {
226                item_id: m.item_id,
227                item_number: m.item_number.clone(),
228                item_name: m.item_name.clone(),
229                quantity,
230                source,
231                via: format!("{} → {}", src.file.filename, m.filename),
232            });
233        }
234    }
235    out
236}
237
238/// Compares a candidate with the existing BOM line (`bom_quantity`) and the
239/// cycle check.
240pub(crate) fn status_of(bom_quantity: Option<f64>, quantity: f64, circular: bool) -> ChildStatus {
241    match bom_quantity {
242        Some(q) if (q - quantity).abs() < 1e-9 => ChildStatus::AlreadyInBom,
243        Some(_) => ChildStatus::Differs,
244        None if circular => ChildStatus::Circular,
245        None => ChildStatus::New,
246    }
247}
248
249/// The note of a line created from a candidate.
250pub(crate) fn line_note(c: &Candidate) -> String {
251    match c.source {
252        QuantitySource::CadInstances => format!(
253            "From the CAD references ({} instance{})",
254            util::format_number(c.quantity, 4),
255            if (c.quantity - 1.0).abs() < 1e-9 {
256                ""
257            } else {
258                "s"
259            }
260        ),
261        QuantitySource::Assumed => ASSUMED_NOTE.to_string(),
262    }
263}
264
265#[derive(sqlx::FromRow)]
266struct FileRow {
267    id: Uuid,
268    sha256: String,
269    format: String,
270}
271
272/// Instance counts of the components of one file, read off the async runtime.
273async fn read_counts(
274    st: &AppState,
275    sha256: &str,
276    format: &str,
277) -> AppResult<Option<HashMap<String, u32>>> {
278    let path = st.vault.object_path(sha256)?;
279    let format = format.to_string();
280    // the same counts the reference scan stores (`file_references.instances`)
281    tokio::task::spawn_blocking(move || crate::cadrefs::instance_counts(&path, &format))
282        .await
283        .map_err(|e| AppError::Other(anyhow::anyhow!("reading the CAD file failed: {e}")))
284}
285
286/// Hidden items (project groups the actor may not see) among the names.
287async fn hidden_names(
288    pool: &PgPool,
289    actor: &Actor,
290    revision_id: Uuid,
291    names: &[String],
292) -> AppResult<Vec<(String, Uuid)>> {
293    if names.is_empty() || actor.hidden_projects.is_empty() {
294        return Ok(Vec::new());
295    }
296    let keys: Vec<String> = names.iter().map(|n| n.to_lowercase()).collect();
297    Ok(sqlx::query_as::<_, (String, Uuid)>(
298        "SELECT DISTINCT lower(f.filename), i.id
299         FROM files f
300         JOIN revisions r ON r.id = f.revision_id
301         JOIN items i ON i.id = r.item_id
302         WHERE lower(f.filename) = ANY($1)
303           AND r.item_id <> (SELECT item_id FROM revisions WHERE id = $2)
304           AND r.seq = (SELECT MAX(x.seq) FROM revisions x WHERE x.item_id = r.item_id)
305           AND i.project_id = ANY($3)",
306    )
307    .bind(&keys)
308    .bind(revision_id)
309    .bind(&actor.hidden_projects)
310    .fetch_all(pool)
311    .await?)
312}
313
314/// What the BOM of the revision would be, from the CAD references.
315///
316/// Read-only; the actor must be able to read the revision.
317pub async fn plan(st: &AppState, actor: &Actor, revision_id: Uuid) -> AppResult<Plan> {
318    let rev = revisions::get(&st.db, actor, revision_id).await?;
319    let files = references::files_of_revision(&st.db, actor, revision_id).await?;
320    let ids: Vec<Uuid> = files.iter().map(|f| f.file_id).collect();
321    let rows = sqlx::query_as::<_, FileRow>(
322        "SELECT id, sha256, COALESCE(format, '') AS format FROM files WHERE id = ANY($1)",
323    )
324    .bind(&ids)
325    .fetch_all(&st.db)
326    .await?;
327    let meta: HashMap<Uuid, &FileRow> = rows.iter().map(|r| (r.id, r)).collect();
328    let mut counts: HashMap<Uuid, HashMap<String, u32>> = HashMap::new();
329    for f in &files {
330        if let Some(row) = meta.get(&f.file_id) {
331            if let Some(c) = read_counts(st, &row.sha256, &row.format).await? {
332                counts.insert(f.file_id, c);
333            }
334        }
335    }
336    let sources: Vec<SourceFile<'_>> = files
337        .iter()
338        .map(|f| SourceFile {
339            file: f,
340            format: meta.get(&f.file_id).map_or("", |r| r.format.as_str()),
341            counts: counts.get(&f.file_id),
342        })
343        .collect();
344    let mut collected = collect(&sources);
345
346    let existing: HashMap<Uuid, f64> = bom::lines(&st.db, actor, revision_id)
347        .await?
348        .into_iter()
349        .map(|l| (l.child_item_id, l.quantity))
350        .collect();
351    let mut conn = st.db.acquire().await?;
352    let mut children = Vec::new();
353    for c in collected.children {
354        let bom_quantity = existing.get(&c.item_id).copied();
355        let circular =
356            bom_quantity.is_none() && bom::creates_cycle(&mut conn, rev.item_id, c.item_id).await?;
357        let status = status_of(bom_quantity, c.quantity, circular);
358        children.push(PlannedChild {
359            item_id: c.item_id,
360            item_number: c.item_number,
361            item_name: c.item_name,
362            quantity: c.quantity,
363            quantity_source: c.source,
364            quantity_label: c.source.label(),
365            quantity_text: util::format_number(c.quantity, 4),
366            bom_quantity_text: bom_quantity.map(|q| util::format_number(q, 4)),
367            via: c.via,
368            status,
369            status_label: status.label(),
370            bom_quantity,
371            selected: status == ChildStatus::New,
372        });
373    }
374    drop(conn);
375
376    // names no visible item has: some may belong to hidden items
377    let names: Vec<String> = collected.unmatched.iter().map(|u| u.name.clone()).collect();
378    let hidden = hidden_names(&st.db, actor, revision_id, &names).await?;
379    let hidden_keys: HashSet<&str> = hidden.iter().map(|(k, _)| k.as_str()).collect();
380    collected
381        .unmatched
382        .retain(|u| !hidden_keys.contains(u.name.to_lowercase().as_str()));
383    let hidden_items: HashSet<Uuid> = hidden.iter().map(|(_, id)| *id).collect();
384    Ok(Plan {
385        revision_id,
386        children,
387        not_in_teamcraft: collected.unmatched,
388        hidden: hidden_items.len() as i64,
389        excluded: collected.excluded,
390    })
391}
392
393#[derive(Clone, Debug, Default, Deserialize)]
394pub struct ApplyRequest {
395    /// Item numbers of the children to add.
396    #[serde(default)]
397    pub children: Vec<String>,
398    /// Also change the quantity of existing lines that differ.
399    #[serde(default)]
400    pub update_quantities: bool,
401}
402
403#[derive(Clone, Debug, Default, Serialize)]
404pub struct Refused {
405    pub child: String,
406    pub reason: String,
407}
408
409#[derive(Clone, Debug, Default, Serialize)]
410pub struct ApplyResult {
411    pub added: Vec<String>,
412    pub updated: Vec<String>,
413    /// Chosen but left as they are (already in the BOM, quantity differs
414    /// without `update_quantities`).
415    pub skipped: Vec<String>,
416    pub refused: Vec<Refused>,
417}
418
419/// Adds the chosen children of the plan as BOM lines (see the module docs).
420pub async fn apply(
421    st: &AppState,
422    actor: &Actor,
423    revision_id: Uuid,
424    req: &ApplyRequest,
425) -> AppResult<ApplyResult> {
426    let rev = revisions::get(&st.db, actor, revision_id).await?;
427    revisions::ensure_editable(&rev, actor)?;
428    if req.children.is_empty() {
429        return Err(AppError::BadRequest(
430            "Choose at least one child to add.".into(),
431        ));
432    }
433    let plan = plan(st, actor, revision_id).await?;
434    let by_number: HashMap<&str, &PlannedChild> = plan
435        .children
436        .iter()
437        .map(|c| (c.item_number.as_str(), c))
438        .collect();
439    let mut chosen: Vec<&PlannedChild> = Vec::new();
440    let mut seen = HashSet::new();
441    for number in &req.children {
442        let number = number.trim();
443        let Some(child) = by_number.get(number) else {
444            return Err(AppError::BadRequest(format!(
445                "{number} is not a part the CAD references of this revision lead to."
446            )));
447        };
448        if seen.insert(number.to_string()) {
449            chosen.push(child);
450        }
451    }
452
453    let mut tx = st.db.begin().await?;
454    let parent = revisions::lock(&mut tx, revision_id).await?;
455    revisions::ensure_editable(&parent, actor)?;
456    // decided under the lock: another session may have added lines meanwhile
457    let existing: HashMap<Uuid, (Uuid, f64)> = sqlx::query_as::<_, (Uuid, Uuid, f64)>(
458        "SELECT child_item_id, id, quantity FROM bom_lines WHERE parent_revision_id = $1",
459    )
460    .bind(parent.id)
461    .fetch_all(&mut *tx)
462    .await?
463    .into_iter()
464    .map(|(child, id, q)| (child, (id, q)))
465    .collect();
466    let mut result = ApplyResult::default();
467    for child in chosen {
468        let candidate = Candidate {
469            item_id: child.item_id,
470            item_number: child.item_number.clone(),
471            item_name: child.item_name.clone(),
472            quantity: child.quantity,
473            source: child.quantity_source,
474            via: child.via.clone(),
475        };
476        match existing.get(&child.item_id) {
477            Some((line_id, q)) if (q - child.quantity).abs() >= 1e-9 && req.update_quantities => {
478                sqlx::query("UPDATE bom_lines SET quantity = $2 WHERE id = $1")
479                    .bind(line_id)
480                    .bind(child.quantity)
481                    .execute(&mut *tx)
482                    .await?;
483                bom::touch(&mut tx, parent.id).await?;
484                audit::record_related(
485                    &mut tx,
486                    Some(actor),
487                    "bom.update",
488                    "revision",
489                    Some(parent.id),
490                    Some(child.item_id),
491                    format!(
492                        "Changed the quantity of {} in {} from {} to {} (CAD references)",
493                        child.item_number,
494                        revisions::label(&parent),
495                        util::format_number(*q, 4),
496                        util::format_number(child.quantity, 4)
497                    ),
498                    json!({ "item_id": parent.item_id, "line_id": line_id }),
499                )
500                .await?;
501                result.updated.push(child.item_number.clone());
502            }
503            Some(_) => result.skipped.push(child.item_number.clone()),
504            None => {
505                match bom::add_tx(
506                    &mut tx,
507                    actor,
508                    &parent,
509                    child.item_id,
510                    &child.item_number,
511                    child.quantity,
512                    None,
513                    &line_note(&candidate),
514                )
515                .await
516                {
517                    Ok(_) => result.added.push(child.item_number.clone()),
518                    Err(AppError::Conflict(reason)) if child.status == ChildStatus::Circular => {
519                        result.refused.push(Refused {
520                            child: child.item_number.clone(),
521                            reason,
522                        })
523                    }
524                    Err(e) => return Err(e),
525                }
526            }
527        }
528    }
529    audit::record_related(
530        &mut tx,
531        Some(actor),
532        "bom.from_references",
533        "revision",
534        Some(parent.id),
535        None,
536        format!(
537            "Created BOM lines of {} from the CAD references: {} added, {} updated",
538            revisions::label(&parent),
539            result.added.len(),
540            result.updated.len()
541        ),
542        json!({
543            "item_id": parent.item_id,
544            "source": "cad_references",
545            "added": result.added.len(),
546            "updated": result.updated.len(),
547            "skipped": result.skipped.len(),
548            "refused": result.refused.len(),
549        }),
550    )
551    .await?;
552    tx.commit().await?;
553    Ok(result)
554}
555
556/// Number of matched, not yet used structure children (for the hint on the
557/// revision page): children found in the CAD references with the status new.
558pub fn new_children(plan: &Plan) -> usize {
559    plan.children
560        .iter()
561        .filter(|c| c.status == ChildStatus::New)
562        .count()
563}
564
565#[cfg(test)]
566mod tests {
567    use super::*;
568    use crate::services::references::{FileMatch, ReferencedName};
569
570    fn m(item: u128, number: &str, filename: &str, same: bool) -> FileMatch {
571        FileMatch {
572            key: filename.to_lowercase(),
573            item_id: Uuid::from_u128(item),
574            item_number: number.into(),
575            item_name: format!("{number} name"),
576            revision_id: Uuid::from_u128(item + 1000),
577            revision_code: "A".into(),
578            revision_state: "in_work".into(),
579            file_id: Uuid::from_u128(item + 2000),
580            filename: filename.into(),
581            same_revision: same,
582            export_name: false,
583        }
584    }
585
586    fn file(name: &str, kind: FormatKind, refs: Vec<(&str, Vec<FileMatch>)>) -> FileWithReferences {
587        FileWithReferences {
588            file_id: Uuid::from_u128(9),
589            filename: name.into(),
590            format_kind: Some(kind),
591            references: refs
592                .into_iter()
593                .map(|(n, matches)| ReferencedName {
594                    name: n.into(),
595                    instances: None,
596                    matches,
597                })
598                .collect(),
599        }
600    }
601
602    #[test]
603    fn dedupes_skips_self_and_lists_unmatched() {
604        let asm = file(
605            "A.SLDASM",
606            FormatKind::Assembly,
607            vec![
608                ("bolt.sldprt", vec![m(1, "PRT-1", "bolt.sldprt", false)]),
609                // the same item through a second name
610                ("bolt2.sldprt", vec![m(1, "PRT-1", "bolt2.sldprt", false)]),
611                // only the revision's own file
612                ("own.sldprt", vec![m(2, "ASM-1", "own.sldprt", true)]),
613                ("ghost.sldprt", vec![]),
614                ("Ghost.SLDPRT", vec![]),
615            ],
616        );
617        let c = collect(&[SourceFile {
618            file: &asm,
619            format: "solidworks-assembly",
620            counts: None,
621        }]);
622        assert_eq!(c.children.len(), 1);
623        assert_eq!(c.children[0].item_number, "PRT-1");
624        assert_eq!(c.children[0].quantity, 1.0);
625        assert_eq!(c.children[0].source, QuantitySource::Assumed);
626        assert_eq!(c.unmatched.len(), 1, "unmatched names once, any case");
627        assert_eq!(c.unmatched[0].name, "ghost.sldprt");
628        assert!(line_note(&c.children[0]).contains("check it"));
629    }
630
631    #[test]
632    fn quantities_come_from_the_instance_counts() {
633        let asm = file(
634            "A.SLDASM",
635            FormatKind::Assembly,
636            vec![
637                ("bolt.sldprt", vec![m(1, "PRT-1", "bolt.sldprt", false)]),
638                ("glue.sldprt", vec![m(3, "PRT-3", "glue.sldprt", false)]),
639                ("nut.sldprt", vec![m(4, "PRT-4", "nut.sldprt", false)]),
640            ],
641        );
642        let counts: HashMap<String, u32> = [
643            ("bolt.sldprt".to_string(), 4),
644            ("glue.sldprt".to_string(), 0),
645        ]
646        .into();
647        let c = collect(&[SourceFile {
648            file: &asm,
649            format: "solidworks-assembly",
650            counts: Some(&counts),
651        }]);
652        let numbers: Vec<_> = c.children.iter().map(|c| c.item_number.as_str()).collect();
653        assert_eq!(numbers, ["PRT-1", "PRT-4"]);
654        assert_eq!(c.children[0].quantity, 4.0);
655        assert_eq!(c.children[0].source, QuantitySource::CadInstances);
656        assert_eq!(
657            line_note(&c.children[0]),
658            "From the CAD references (4 instances)"
659        );
660        assert_eq!(c.children[1].source, QuantitySource::Assumed);
661        assert_eq!(c.excluded, ["glue.sldprt"]);
662    }
663
664    #[test]
665    fn drawings_and_parts_do_not_count_and_nx_needs_instances() {
666        let refs = || vec![("bolt.prt", vec![m(1, "PRT-1", "bolt.prt", false)])];
667        let drawing = file("D.SLDDRW", FormatKind::Drawing, refs());
668        let part = file("P.SLDPRT", FormatKind::Part, refs());
669        let nx = file("N.prt", FormatKind::Model, refs());
670        let sources = |nx_counts| {
671            vec![
672                SourceFile {
673                    file: &drawing,
674                    format: "solidworks-drawing",
675                    counts: None,
676                },
677                SourceFile {
678                    file: &part,
679                    format: "solidworks-part",
680                    counts: None,
681                },
682                SourceFile {
683                    file: &nx,
684                    format: "nx-part",
685                    counts: nx_counts,
686                },
687            ]
688        };
689        assert!(collect(&sources(None)).children.is_empty());
690        let empty = HashMap::new();
691        assert!(collect(&sources(Some(&empty))).children.is_empty());
692        let counts: HashMap<String, u32> = [("bolt.prt".to_string(), 6)].into();
693        let c = collect(&sources(Some(&counts)));
694        assert_eq!(c.children.len(), 1);
695        assert_eq!(c.children[0].quantity, 6.0);
696    }
697
698    #[test]
699    fn step_files_count_only_with_external_occurrences() {
700        let step = file(
701            "pump.stp",
702            FormatKind::Exchange,
703            vec![("bolt.stp", vec![m(1, "PRT-1", "bolt.stp", false)])],
704        );
705        let source = |counts| SourceFile {
706            file: &step,
707            format: "step",
708            counts,
709        };
710        // a STEP file that only names other files is no structure
711        assert!(collect(&[source(None)]).children.is_empty());
712        let counts: HashMap<String, u32> = [("bolt.stp".to_string(), 4)].into();
713        let c = collect(&[source(Some(&counts))]);
714        assert_eq!(c.children.len(), 1);
715        assert_eq!(c.children[0].quantity, 4.0);
716        assert_eq!(c.children[0].source, QuantitySource::CadInstances);
717    }
718
719    #[test]
720    fn status_compares_with_the_bom() {
721        assert_eq!(status_of(None, 2.0, false), ChildStatus::New);
722        assert_eq!(status_of(None, 2.0, true), ChildStatus::Circular);
723        assert_eq!(status_of(Some(2.0), 2.0, false), ChildStatus::AlreadyInBom);
724        assert_eq!(status_of(Some(2.0), 4.0, false), ChildStatus::Differs);
725        // an existing line is never "circular"
726        assert_eq!(status_of(Some(2.0), 2.0, true), ChildStatus::AlreadyInBom);
727    }
728}