1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use super::*;
#[test]
fn test_schema_geometry_categories() {
let schema = IfcSchema::new();
assert_eq!(
schema.geometry_category(&IfcType::IfcExtrudedAreaSolid),
Some(GeometryCategory::SweptSolid)
);
assert_eq!(
schema.geometry_category(&IfcType::IfcBooleanResult),
Some(GeometryCategory::Boolean)
);
assert_eq!(
schema.geometry_category(&IfcType::IfcTriangulatedFaceSet),
Some(GeometryCategory::ExplicitMesh)
);
assert_eq!(
schema.profile_category(&IfcType::IfcRoundedRectangleProfileDef),
Some(ProfileCategory::Parametric)
);
}
#[test]
fn test_parse_index_list_rejects_out_of_range() {
// A face whose indices are out of the valid u32 vertex range: too large
// (> u32::MAX), zero, and negative. Each must map to the u32::MAX
// sentinel (dropped downstream) instead of an `(i64 - 1) as u32`
// truncation/wrap to a valid-looking vertex.
let face = AttributeValue::List(vec![
AttributeValue::Integer(5_000_000_000), // > u32::MAX
AttributeValue::Integer(0), // non-positive
AttributeValue::Integer(-4), // negative
]);
let out = AttributeValue::parse_index_list(&[face]);
assert_eq!(out, vec![u32::MAX, u32::MAX, u32::MAX]);
// A well-formed face is still converted 1-based → 0-based.
let ok = AttributeValue::List(vec![
AttributeValue::Integer(1),
AttributeValue::Integer(2),
AttributeValue::Integer(3),
]);
assert_eq!(AttributeValue::parse_index_list(&[ok]), vec![0, 1, 2]);
}
#[test]
fn test_parse_index_list_extreme_i64_values() {
// i64::MIN: the old `(i - 1) as u32` would OVERFLOW i64 in the subtraction
// (debug panic) before even truncating. Must map to the sentinel.
// i64::MAX: far beyond u32, must map to the sentinel.
// 4294967297 (2^32 + 1): the old truncation wrapped it to vertex 0 —
// a valid-looking alias. Must map to the sentinel instead.
// 4294967295 (u32::MAX as a 1-based index): zero-based 4294967294 still
// fits in u32, so it converts normally (dropped later only if the mesh
// is smaller — which any real mesh is).
let face = AttributeValue::List(vec![
AttributeValue::Integer(i64::MIN),
AttributeValue::Integer(i64::MAX),
AttributeValue::Integer(4_294_967_297),
]);
let out = AttributeValue::parse_index_list(&[face]);
assert_eq!(out, vec![u32::MAX, u32::MAX, u32::MAX]);
let boundary = AttributeValue::List(vec![
AttributeValue::Integer(4_294_967_295), // u32::MAX 1-based → MAX-1 0-based
AttributeValue::Integer(4_294_967_296), // 2^32 1-based → u32::MAX 0-based (sentinel value, dropped)
AttributeValue::Integer(2),
]);
assert_eq!(
AttributeValue::parse_index_list(&[boundary]),
vec![u32::MAX - 1, u32::MAX, 1]
);
}
#[test]
fn test_attribute_value_conversion() {
let token = Token::EntityRef(123);
let attr = AttributeValue::from_token(&token);
assert_eq!(attr.as_entity_ref(), Some(123));
let token = Token::String(b"test");
let attr = AttributeValue::from_token(&token);
assert_eq!(attr.as_string(), Some("test"));
}
#[test]
fn test_decoded_entity() {
let entity = DecodedEntity::new(
1,
IfcType::IfcWall,
vec![
AttributeValue::EntityRef(2),
AttributeValue::String("Wall-001".to_string()),
AttributeValue::Float(3.5),
],
);
assert_eq!(entity.get_ref(0), Some(2));
assert_eq!(entity.get_string(1), Some("Wall-001"));
assert_eq!(entity.get_float(2), Some(3.5));
}
#[test]
fn test_as_float_with_typed_value() {
// Test plain float
let plain_float = AttributeValue::Float(0.5);
assert_eq!(plain_float.as_float(), Some(0.5));
// Test integer to float conversion
let integer = AttributeValue::Integer(42);
assert_eq!(integer.as_float(), Some(42.0));
// Test TypedValue wrapper like IFCNORMALISEDRATIOMEASURE(0.5)
// This is stored as List([String("IFCNORMALISEDRATIOMEASURE"), Float(0.5)])
let typed_value = AttributeValue::List(vec![
AttributeValue::String("IFCNORMALISEDRATIOMEASURE".to_string()),
AttributeValue::Float(0.5),
]);
assert_eq!(typed_value.as_float(), Some(0.5));
// Test TypedValue with integer
let typed_int = AttributeValue::List(vec![
AttributeValue::String("IFCINTEGER".to_string()),
AttributeValue::Integer(100),
]);
assert_eq!(typed_int.as_float(), Some(100.0));
// Test that non-typed lists return None
let regular_list =
AttributeValue::List(vec![AttributeValue::Float(1.0), AttributeValue::Float(2.0)]);
assert_eq!(regular_list.as_float(), None);
// Test that empty list returns None
let empty_list = AttributeValue::List(vec![]);
assert_eq!(empty_list.as_float(), None);
}
/// `IFC_TYPES` is the catalog the enum itself cannot give you: `Unknown(u32)`
/// makes `IfcType` open, and the CRC32 ids are sparse, so there is no way to
/// walk the schema from the type alone. Anything that has to reason about the
/// WHOLE schema — mapping every class to another vocabulary, auditing coverage,
/// generating a table — needs this or has to re-parse the EXPRESS file.
#[cfg(test)]
mod ifc_types_catalog {
use crate::{IfcType, IFC_TYPES};
/// Every entry must round-trip through the string form, which is the form a
/// STEP file carries. A generator emitting a name `from_str` cannot read
/// back would produce a catalog that silently omits those classes.
#[test]
fn every_entry_round_trips_through_its_name() {
for &t in IFC_TYPES {
let name = t.name();
assert_eq!(
IfcType::from_str(&name.to_uppercase()),
t,
"{name} did not round-trip"
);
assert_eq!(IfcType::from_id(t.id()), t, "{name} did not round-trip by id");
}
}
/// No repeats, and `Unknown` is not a schema entity.
#[test]
fn the_catalog_is_a_set_of_real_entities() {
let mut seen = std::collections::HashSet::new();
for &t in IFC_TYPES {
assert!(seen.insert(t.name()), "{} appears twice", t.name());
assert!(
!matches!(t, IfcType::Unknown(_)),
"Unknown is the absence of a type, not one of them"
);
}
assert_eq!(seen.len(), IFC_TYPES.len());
}
/// Every supertype named by a member is itself a member, so a caller can
/// walk `parent()` to the root without leaving the catalog. This is the
/// property that makes "map to the nearest mapped ancestor" a schema fact
/// rather than a guess about names.
#[test]
fn every_parent_is_itself_in_the_catalog() {
let known: std::collections::HashSet<&str> = IFC_TYPES.iter().map(|t| t.name()).collect();
for &t in IFC_TYPES {
let mut cur = t.parent();
while let Some(p) = cur {
assert!(
known.contains(p.name()),
"{} has an ancestor {} outside the catalog",
t.name(),
p.name()
);
cur = p.parent();
}
}
}
/// A spot-check that the catalog is the real schema and not a stub: the
/// classes a building model is mostly made of are present and concrete, and
/// the roots they hang from are abstract.
#[test]
fn it_holds_the_schema_and_not_a_sample_of_it() {
// Exact, not a floor. `> 800` would accept a generator regression that
// silently dropped 75 entities, and a schema update SHOULD be a
// deliberate edit here rather than something a loose bound absorbs.
assert_eq!(
IFC_TYPES.len(),
876,
"the IFC4X3 catalog has an unexpected entity count"
);
let by_name: std::collections::HashMap<&str, IfcType> =
IFC_TYPES.iter().map(|t| (t.name(), *t)).collect();
for name in ["IfcWall", "IfcSlab", "IfcDoor", "IfcWindow", "IfcBuildingStorey"] {
let t = by_name[name];
assert!(!t.is_abstract(), "{name} is instantiable");
assert!(t.is_subtype_of(by_name["IfcProduct"]), "{name} is a product");
}
assert!(by_name["IfcProduct"].is_abstract());
assert_eq!(
by_name["IfcWallStandardCase"].parent(),
Some(by_name["IfcWall"]),
"the supertype chain is what makes ancestor mapping a fact"
);
}
}
/// `attribute_names` exists so nothing has to hardcode a positional index with
/// a comment next to it, which is how every attribute read in this workspace is
/// written today and how one of them will eventually be wrong.
#[cfg(test)]
mod attribute_names {
use crate::{IfcType, IFC_TYPES};
/// The property the whole thing rests on: supertype attributes come FIRST,
/// so the position here is the position `DecodedEntity::get` indexes. A
/// generator using an entity's OWN attribute list would be wrong about
/// every index on every subtype, and wrong in a way that still returns a
/// value.
#[test]
fn inherited_attributes_come_first_and_in_root_to_leaf_order() {
// IfcRoot declares GlobalId, OwnerHistory, Name, Description; every
// rooted entity therefore starts with exactly those four.
for name in ["IfcWall", "IfcBuildingStorey", "IfcDoor", "IfcProject"] {
let t = IfcType::from_str(&name.to_uppercase());
assert_eq!(
&t.attribute_names()[..4],
&["GlobalId", "OwnerHistory", "Name", "Description"],
"{name} must inherit IfcRoot's four attributes at positions 0-3"
);
}
}
/// The indices the export path currently hardcodes, asserted against the
/// schema rather than against a comment. If these ever disagree, one of the
/// two is wrong and it is no longer a matter of opinion which.
#[test]
fn it_agrees_with_the_positions_the_workspace_already_reads() {
let wall = IfcType::from_str("IFCWALL");
for (name, want) in [
("GlobalId", 0),
("Name", 2),
("Description", 3),
("ObjectType", 4),
("ObjectPlacement", 5),
("Representation", 6),
] {
assert_eq!(
wall.attribute_index(name),
Some(want),
"IfcProduct attribute {name} is read at index {want} in this workspace"
);
}
}
/// The motivating case. `Elevation` is the attribute that makes an
/// IfcBuildingStorey placeable, and it is the last of ten.
#[test]
fn a_storeys_elevation_is_reachable_by_name() {
let storey = IfcType::from_str("IFCBUILDINGSTOREY");
assert_eq!(storey.attribute_index("Elevation"), Some(9));
assert_eq!(storey.attribute_names().len(), 10);
}
#[test]
fn an_unknown_name_is_none_and_the_lookup_is_case_sensitive() {
let wall = IfcType::from_str("IFCWALL");
assert_eq!(wall.attribute_index("NotAnAttribute"), None);
assert_eq!(
wall.attribute_index("globalid"),
None,
"EXPRESS names are PascalCase; a case-insensitive match would let a \
typo resolve to the wrong attribute on some other entity"
);
}
/// Every catalog entry answers, and no entry repeats a name — a duplicate
/// would make `attribute_index` return the first of two real positions.
#[test]
fn every_type_has_a_consistent_attribute_list() {
for &t in IFC_TYPES {
let names = t.attribute_names();
let mut seen = std::collections::HashSet::new();
for n in names {
assert!(
seen.insert(*n),
"{} lists {n} twice; attribute_index would answer with the \
first of two real positions",
t.name()
);
}
for (i, n) in names.iter().enumerate() {
assert_eq!(t.attribute_index(n), Some(i));
}
}
}
/// `Unknown` is the absence of a type, so it has no attributes to name —
/// and must not panic or answer with some other entity's list.
#[test]
fn unknown_names_nothing() {
let u = IfcType::from_str("NOT_AN_IFC_TYPE_AT_ALL");
assert!(matches!(u, IfcType::Unknown(_)));
assert!(u.attribute_names().is_empty());
assert_eq!(u.attribute_index("GlobalId"), None);
}
}