Skip to main content

ifc_cost/
view.rs

1//! The cost view: the entry point for reading cost data out of a model.
2//!
3//! Holds a borrow and nothing else. Constructing one is free, several can
4//! coexist over the same model, and dropping one cannot lose data — which is
5//! what makes `ifc-cost` safe to leave uncompiled.
6
7use crate::item::CostItem;
8use crate::schedule::CostSchedule;
9use crate::value::CostValue;
10use ifc_model::Model;
11
12/// A borrowed cost interpretation of a model.
13#[derive(Debug, Clone, Copy)]
14pub struct CostView<'m> {
15    model: &'m Model,
16}
17
18impl<'m> CostView<'m> {
19    /// Create a cost view over `model`.
20    pub fn new(model: &'m Model) -> Self {
21        Self { model }
22    }
23
24    /// Every cost schedule in the file.
25    pub fn schedules(&self) -> impl Iterator<Item = CostSchedule<'m>> + '_ {
26        self.model
27            .of_type("IFCCOSTSCHEDULE")
28            .map(|(id, entity)| CostSchedule::new(id, entity))
29            .collect::<Vec<_>>()
30            .into_iter()
31    }
32
33    /// Every cost item in the file.
34    pub fn items(&self) -> impl Iterator<Item = CostItem<'m>> + '_ {
35        self.model
36            .of_type("IFCCOSTITEM")
37            .map(|(id, entity)| CostItem::new(id, entity))
38            .collect::<Vec<_>>()
39            .into_iter()
40    }
41
42    /// The model underneath, for callers that need to resolve references.
43    pub fn model(&self) -> &'m Model {
44        self.model
45    }
46
47    /// Resolve the cost values attached to an item.
48    ///
49    /// A reference that does not resolve is skipped rather than erroring:
50    /// dangling references are common in real files, and one bad link should
51    /// not make the whole cost tree unreadable. Use
52    /// [`Model::dangling_references`] when you need to know about them.
53    pub fn values_of(&self, item: &CostItem<'m>) -> Vec<CostValue<'m>> {
54        item.value_refs()
55            .into_iter()
56            .filter_map(|id| self.model.get(id).map(|e| CostValue::new(id, e)))
57            .collect()
58    }
59}
60
61#[cfg(test)]
62mod tests {
63    use super::*;
64    use ifc_model::{Entity, EntityId, Value};
65
66    fn model_with_cost() -> Model {
67        let mut model = Model::new();
68        model.insert(
69            EntityId(1),
70            Entity::new(
71                "IFCCOSTVALUE",
72                vec![
73                    Value::Text("Estimate".into()),
74                    Value::Null,
75                    Value::Typed {
76                        type_name: "IFCMONETARYMEASURE".into(),
77                        value: Box::new(Value::Real(1500.50)),
78                    },
79                ],
80            ),
81        );
82        model.insert(
83            EntityId(2),
84            Entity::new(
85                "IFCCOSTITEM",
86                vec![
87                    Value::Text("3vB2Y0dTv1LhX9ZzQqFbcd".into()), // 0 GlobalId
88                    Value::Null,                                  // 1 OwnerHistory
89                    Value::Text("Excavation".into()),             // 2 Name
90                    Value::Null,                                  // 3 Description
91                    Value::Null,                                  // 4 ObjectType
92                    Value::Text("A.1".into()),                    // 5 Identification
93                    Value::Enum("MATERIAL".into()),               // 6 PredefinedType
94                    Value::List(vec![Value::Ref(EntityId(1))]),   // 7 CostValues
95                ],
96            ),
97        );
98        model
99    }
100
101    #[test]
102    fn finds_cost_items_without_the_model_knowing_what_cost_is() {
103        let model = model_with_cost();
104        let view = CostView::new(&model);
105        let items: Vec<_> = view.items().collect();
106        assert_eq!(items.len(), 1);
107        assert_eq!(items[0].name(), Some("Excavation"));
108    }
109
110    #[test]
111    fn resolves_values_through_references() {
112        let model = model_with_cost();
113        let view = CostView::new(&model);
114        let item = view.items().next().unwrap();
115        let values = view.values_of(&item);
116        assert_eq!(values.len(), 1);
117        assert_eq!(values[0].amount(), Some(1500.50));
118    }
119
120    /// The view is a lens, not storage: dropping it cannot lose data.
121    #[test]
122    fn view_owns_nothing_and_model_is_unchanged() {
123        let model = model_with_cost();
124        let before = model.len();
125        {
126            let view = CostView::new(&model);
127            let _ = view.items().count();
128        }
129        assert_eq!(model.len(), before);
130    }
131
132    /// Slots follow the real IfcControl inheritance chain.
133    ///
134    /// This is a regression test for a shipped defect: the slot constants
135    /// omitted `ObjectType` (IfcObject) and `PredefinedType`, so every
136    /// attribute from `Identification` onward was read one or more positions
137    /// early. It passed only because the hand-built test fixtures encoded the
138    /// same wrong layout, which is exactly how a slot bug survives review.
139    #[test]
140    fn cost_item_slots_match_the_inheritance_chain() {
141        let model = model_with_cost();
142        let view = CostView::new(&model);
143        let item = view
144            .items()
145            .find(|i| i.name() == Some("Excavation"))
146            .expect("item present");
147
148        assert_eq!(
149            item.identification(),
150            Some("A.1"),
151            "Identification is slot 5"
152        );
153        assert_eq!(
154            item.predefined_type(),
155            Some("MATERIAL"),
156            "PredefinedType is slot 6"
157        );
158        assert_eq!(item.value_refs(), vec![EntityId(1)], "CostValues is slot 7");
159    }
160
161    /// A dangling cost value is skipped, not fatal.
162    #[test]
163    fn unresolvable_value_references_are_skipped() {
164        let mut model = model_with_cost();
165        model.insert(
166            EntityId(3),
167            Entity::new(
168                "IFCCOSTITEM",
169                vec![
170                    Value::Text("broken".into()),
171                    Value::Null,
172                    Value::Text("Dangling".into()),
173                    Value::Null,
174                    Value::Null,
175                    Value::Null,
176                    Value::Null,
177                    Value::List(vec![Value::Ref(EntityId(999))]), // 7 CostValues
178                ],
179            ),
180        );
181
182        let view = CostView::new(&model);
183        let item = view
184            .items()
185            .find(|i| i.name() == Some("Dangling"))
186            .expect("item present");
187        assert!(view.values_of(&item).is_empty(), "missing target skipped");
188    }
189}