#![cfg(feature = "schema")]
#[path = "support/xsd_models.rs"]
mod support;
use ifc_model::{Codec, EntityId, Model, ModelError, Value};
use ifc_xml::{XmlCodec, XmlError, XmlProfile};
use std::sync::Arc;
use support::{model, same_model, GEOMETRY, SPATIAL};
const NAMESPACE: &str = "https://standards.buildingsmart.org/IFC/RELEASE/IFC4/ADD2_TC1/XML";
fn codec() -> XmlCodec {
XmlCodec::xsd(
Arc::new(ifc_schema::ifc4().clone()),
XmlProfile::Ifc4Add2Tc1,
)
}
fn write(model: &Model) -> Result<String, XmlError> {
ifc_xml::writer::write(&codec(), model).map(|bytes| String::from_utf8(bytes).unwrap())
}
fn round_trip(model: &Model) -> String {
let text = write(model).unwrap_or_else(|error| panic!("write refused: {error}"));
let read = ifc_xml::reader::read(&codec(), text.as_bytes())
.unwrap_or_else(|error| panic!("read back refused: {error}\n{text}"));
if let Err(difference) = same_model(ifc_schema::ifc4(), model, &read) {
panic!("{difference}\n{text}");
}
text
}
fn refused(model: &Model) -> XmlError {
let error = write(model).expect_err("written, expected a refusal");
assert!(error.path().is_some(), "refusal without a path: {error}");
error
}
fn assert_contains(text: &str, expected: &[&str]) {
for snippet in expected {
assert!(text.contains(snippet), "missing {snippet:?} in\n{text}");
}
}
fn replace(model: &mut Model, id: u64, slot: usize, value: Value) {
let mut entity = model.get(EntityId(id)).unwrap().clone();
entity.attributes[slot] = value;
model.insert(EntityId(id), entity);
}
fn spatial_with(id: u64, slot: usize, value: Value) -> Model {
let mut model = model(SPATIAL);
replace(&mut model, id, slot, value);
model
}
#[test]
fn every_construct_model_round_trips() {
for (name, model) in support::models() {
assert!(write(&model).is_ok(), "{name}");
round_trip(&model);
}
}
#[test]
fn the_root_and_header_are_the_xsd_s() {
let text = round_trip(&model(SPATIAL));
assert_contains(
&text,
&[
&format!("<ifcXML xmlns=\"{NAMESPACE}\" xmlns:ifc=\"{NAMESPACE}\""),
"<header>\n <name>model.ifc</name>\n \
<time_stamp>2026-10-03T12:00:00+02:00</time_stamp>\n \
<author>Author</author>\n <organization>Organisation</organization>\n \
<preprocessor_version>preprocessor</preprocessor_version>\n \
<originating_system>system</originating_system>\n \
<authorization>authorization</authorization>\n \
<documentation>ViewDefinition [CoordinationView]</documentation>\n </header>",
],
);
assert!(!text.contains(" schema="), "{text}");
assert!(!text.contains("<IFC"), "{text}");
}
#[test]
fn entities_are_top_level_elements_named_as_the_schema_spells_them() {
let text = round_trip(&model(SPATIAL));
assert_contains(
&text,
&[
"<IfcProject id=\"i1\" GlobalId=\"0YvctVUKr0kugbFTf53O9L\" Name=\"Project\">",
"<IfcSIUnit id=\"i31\" UnitType=\"lengthunit\" Prefix=\"milli\" Name=\"metre\"/>",
],
);
}
#[test]
fn simple_values_are_xml_attributes_in_their_xsd_lexical_form() {
let text = round_trip(&model(SPATIAL));
assert_contains(
&text,
&[
"Precision=\"1e-5\"",
"Coordinates=\"1.25e20 -3.5e-7\"",
"OverallHeight=\"2.1\"",
"RefLatitude=\"51 30 0 0\"",
"PredefinedType=\"standard\"",
"Name=\"W & <wall> "quoted" 'single'\"",
],
);
let text = round_trip(&model(GEOMETRY));
assert_contains(
&text,
&[
"CoordList=\"0.0 0.0 0.0 1.0 0.0 0.0 0.0 1.0 0.0 1.0 1.0 1.0\"",
"MiddleNames=\"Middle Other\"",
"Parameter=\"a b\"",
"RepeatS=\"true\" RepeatT=\"false\"",
"SelfIntersect=\"unknown\"",
],
);
}
#[test]
fn entity_attributes_are_nil_references_typed_when_a_subtype() {
let text = round_trip(&model(SPATIAL));
assert_contains(
&text,
&[
"<Location ref=\"i21\" xsi:nil=\"true\"/>",
"<ObjectPlacement xsi:type=\"IfcLocalPlacement\" ref=\"i52\" xsi:nil=\"true\"/>",
],
);
}
#[test]
fn selects_hold_entity_elements_or_typed_wrappers() {
let text = round_trip(&model(SPATIAL));
assert_contains(
&text,
&[
"<NominalValue>\n <IfcLabel-wrapper>x y </IfcLabel-wrapper>\n </NominalValue>",
"<IfcComplexNumber-wrapper>1.5 -2.0</IfcComplexNumber-wrapper>",
"<RelativePlacement>\n <IfcAxis2Placement3D ref=\"i51\" xsi:nil=\"true\"/>",
"<IfcPropertySetDefinitionSet-wrapper>\n \
<IfcPropertySet ref=\"i16\" xsi:nil=\"true\"/>\n \
</IfcPropertySetDefinitionSet-wrapper>",
],
);
}
#[test]
fn aggregates_are_containers_of_their_items() {
let text = round_trip(&model(GEOMETRY));
assert_contains(
&text,
&[
"<ControlPointsList ifc:arraySize=\"2 2\">\n \
<IfcCartesianPoint ref=\"i10\" xsi:nil=\"true\"/>",
"<WeightsData ifc:arraySize=\"2 2\">\n <IfcReal-wrapper>1.0</IfcReal-wrapper>",
"<AddressLines>\n <IfcLabel-wrapper>Line 1</IfcLabel-wrapper>\n \
<IfcLabel-wrapper>Line two</IfcLabel-wrapper>",
"<InnerCoordIndices>\n \
<Seq-IfcPositiveInteger-wrapper>2 3 4</Seq-IfcPositiveInteger-wrapper>\n \
<Seq-IfcPositiveInteger-wrapper>1 2 4 3</Seq-IfcPositiveInteger-wrapper>",
"<IfcLineIndex-wrapper>1 2</IfcLineIndex-wrapper>",
"<IfcArcIndex-wrapper>1 2 3</IfcArcIndex-wrapper>",
"<RasterCode>89504E47</RasterCode>",
],
);
}
#[test]
fn omitted_attributes_are_written_through_inverses() {
let text = round_trip(&model(SPATIAL));
assert_contains(
&text,
&[
"<IfcRelAggregates id=\"i3\" GlobalId=\"2YvctVUKr0kugbFTf53O9L\">\n \
<RelatedObjects>",
"<IsDecomposedBy>\n <IfcRelAggregates ref=\"i3\" xsi:nil=\"true\"/>\n </IsDecomposedBy>",
"<ContainsElements>\n <IfcRelContainedInSpatialStructure ref=\"i7\"",
"<IsTypedBy ref=\"i9\" xsi:nil=\"true\"/>",
"<HasOpenings ref=\"i11\" xsi:nil=\"true\"/>",
"<HasFillings ref=\"i13\" xsi:nil=\"true\"/>",
"<HasCoordinateOperation xsi:type=\"IfcMapConversion\" ref=\"i41\" xsi:nil=\"true\"/>",
"<HasSubContexts>\n <IfcGeometricRepresentationSubContext ref=\"i23\"",
"<IsDefinedBy>\n <IfcRelDefinesByProperties ref=\"i17\"",
],
);
assert!(!text.contains("RelatingObject"), "{text}");
let text = round_trip(&model(GEOMETRY));
assert_contains(&text, &["<StyledByItem ref=\"i21\" xsi:nil=\"true\"/>"]);
assert!(!text.contains("<Item"), "{text}");
}
#[test]
fn a_model_numbered_in_order_reads_back_with_its_ids() {
let source = model("#1=IFCCARTESIANPOINT((1.,2.));\n#2=IFCAXIS2PLACEMENT2D(#1,$);\n");
let read = codec()
.read_bytes(&codec().write_bytes(&source).unwrap())
.unwrap();
for (id, entity) in source.iter() {
assert_eq!(read.get(id).unwrap().attributes, entity.attributes, "{id}");
}
}
#[test]
fn the_codec_reports_refusals_as_write_errors() {
let model = spatial_with(14, 2, Value::Text("text".into()));
assert!(matches!(
codec().write_bytes(&model),
Err(ModelError::Write(message)) if message.contains("NominalValue")
));
}
#[test]
fn values_the_reader_would_read_differently_are_refused() {
let error = refused(&spatial_with(12, 8, Value::Integer(2)));
assert!(
matches!(error.root_cause(), XmlError::Unrepresentable { .. }),
"{error}"
);
assert_eq!(
error.path().unwrap().as_str(),
"/ifcXML/IfcDoor[@id='i12']/@OverallHeight"
);
let mut model = model(GEOMETRY);
replace(
&mut model,
31,
3,
Value::List(vec![Value::Text("van der".into())]),
);
let error = refused(&model);
assert!(
error.to_string().contains("whitespace-separated list"),
"{error}"
);
let mut model = support::model(GEOMETRY);
replace(&mut model, 20, 6, Value::Binary("1ABC".into()));
let error = refused(&model);
assert!(
error.to_string().contains("whole number of bytes"),
"{error}"
);
}
#[test]
fn values_the_xsd_refuses_are_refused() {
let error = refused(&spatial_with(6, 2, Value::Text("two\nlines".into())));
assert!(error.to_string().contains("normalizedString"), "{error}");
let error = refused(&spatial_with(6, 2, Value::Text("x".repeat(256).into())));
assert!(error.to_string().contains("256 characters"), "{error}");
let error = refused(&spatial_with(6, 2, Value::Text("bell\u{7}".into())));
assert!(error.to_string().contains("U+0007"), "{error}");
let error = refused(&spatial_with(52, 1, Value::Null));
assert!(
error.to_string().contains("unset `RelativePlacement`"),
"{error}"
);
let error = refused(&spatial_with(16, 4, Value::List(Vec::new())));
assert!(error.to_string().contains("[1:?]"), "{error}");
let error = refused(&spatial_with(12, 8, Value::Real(f64::NAN)));
assert!(
matches!(error.root_cause(), XmlError::InvalidScalar { .. }),
"{error}"
);
let mut model = model(SPATIAL);
model.header_mut().time_stamp = "2026-10-03 12:00".into();
assert!(matches!(
refused(&model).root_cause(),
XmlError::InvalidScalar { .. }
));
}
#[test]
fn relationships_the_configuration_has_no_place_for_are_refused() {
let mut model = model(SPATIAL);
let mut second = model.get(EntityId(11)).unwrap().clone();
second.attributes[0] = Value::Text("GYvctVUKr0kugbFTf53O9L".into());
model.insert(EntityId(60), second);
let error = refused(&model);
assert!(error.to_string().contains("HasOpenings"), "{error}");
let model = support::model(&format!(
"{SPATIAL}#61=IFCRELDEFINESBYOBJECT('HYvctVUKr0kugbFTf53O9L',$,$,$,(#6),#12);\n"
));
let error = refused(&model);
assert!(error.to_string().contains("RelatingObject"), "{error}");
let model = support::model(
"#1=IFCCARTESIANPOINT((0.,0.,0.));\n#2=IFCVERTEXPOINT(#1);\n\
#3=IFCEDGE(#2,#2);\n#4=IFCORIENTEDEDGE(*,*,#3,.T.);\n\
#5=IFCSTYLEDITEM(#4,(#6),$);\n#6=IFCSURFACESTYLE($,.BOTH.,(#7));\n\
#7=IFCSURFACESTYLESHADING(#8,$);\n#8=IFCCOLOURRGB($,1.,1.,1.);\n",
);
let error = refused(&model);
assert!(error.to_string().contains("no inverse element"), "{error}");
}
#[test]
fn models_the_schema_does_not_describe_are_refused() {
let mut unknown = model(SPATIAL);
unknown.insert(
EntityId(70),
ifc_model::Entity::new("IFCNOTANENTITY", Vec::new()),
);
assert!(matches!(
refused(&unknown).root_cause(),
XmlError::UnknownEntity { .. }
));
let dangling = spatial_with(22, 0, Value::Ref(EntityId(99)));
assert!(matches!(
refused(&dangling).root_cause(),
XmlError::UnresolvedReference { id } if id == "i99"
));
let wrong = spatial_with(22, 0, Value::Ref(EntityId(31)));
assert!(matches!(
refused(&wrong).root_cause(),
XmlError::TypeMismatch { .. }
));
let derived = spatial_with(23, 2, Value::Integer(3));
assert!(refused(&derived).to_string().contains("derives"));
}
#[test]
fn the_profile_and_schema_must_match_the_model() {
let mut model = model(SPATIAL);
model.header_mut().schema = vec!["IFC4X3_ADD2".into()];
assert!(matches!(
write(&model).unwrap_err(),
XmlError::Profile {
expected: "IFC4",
..
}
));
let mismatched = XmlCodec::xsd(
Arc::new(ifc_schema::ifc4x3().clone()),
XmlProfile::Ifc4Add2Tc1,
);
assert!(matches!(
ifc_xml::writer::write(&mismatched, &support::model(SPATIAL)).unwrap_err(),
XmlError::SchemaMismatch { .. }
));
let mut authors = support::model(SPATIAL);
authors.header_mut().author = vec!["One".into(), "Two".into()];
assert!(refused(&authors)
.to_string()
.contains("2 header `author` entries"));
}