use ifc_cost::mutation::{
create_cost_schedule_with_owner_history, create_cost_value, create_currency_relationship,
create_monetary_unit, CostAuthoringError, CostScheduleDraft, CostScheduleType, CostValueDraft,
CostValueKind,
};
use ifc_cost::{
ArithmeticOperator, AuthoredDateTime, CalendarDate, CostError, CostSchedule, CostView,
DateTimeValue, LocalTime, SchemaVersion,
};
use ifc_model::{Codec, Entity, EntityId, Model, Transaction, Value};
use ifc_step::StepCodec;
const OWNER: EntityId = EntityId(5);
fn base(schema: &str) -> Model {
let text = format!(
"ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\n\
FILE_NAME('','',(''),(''),'','','');\nFILE_SCHEMA(('{schema}'));\nENDSEC;\nDATA;\n\
#1=IFCPERSON($,'Doe','Jane',$,$,$,$,$);\n\
#2=IFCORGANIZATION($,'Acme',$,$,$);\n\
#3=IFCPERSONANDORGANIZATION(#1,#2,$);\n\
#4=IFCAPPLICATION(#2,'1.0','Test','test');\n\
#5=IFCOWNERHISTORY(#3,#4,$,.NOCHANGE.,$,$,$,1700000000);\n\
ENDSEC;\nEND-ISO-10303-21;\n"
);
let model = StepCodec.read_bytes(text.as_bytes()).expect("parses");
assert!(model.diagnostics().is_empty(), "{:?}", model.diagnostics());
model
}
fn round_trip(model: &Model) -> Model {
let bytes = StepCodec.write_bytes(model).expect("written");
let back = StepCodec.read_bytes(&bytes).expect("read back");
assert!(back.diagnostics().is_empty(), "{:?}", back.diagnostics());
back
}
fn money(amount: f64) -> Value {
Value::Typed {
type_name: "IFCMONETARYMEASURE".into(),
value: Box::new(Value::Real(amount)),
}
}
fn text(s: &str) -> Value {
Value::Text(s.into())
}
#[test]
fn value_records_round_trip_in_their_release() {
for (schema, version) in [
("IFC2X3", SchemaVersion::Ifc2x3),
("IFC4", SchemaVersion::Ifc4),
("IFC4X3_ADD2", SchemaVersion::Ifc4x3),
] {
let ifc2x3 = version == SchemaVersion::Ifc2x3;
let mut model = base(schema);
let m = model.clone();
let mut tx = Transaction::new(&model);
let (applicable, rate_date): (DateTimeValue<'_>, DateTimeValue<'_>) = if ifc2x3 {
(
CalendarDate::new(2026, 9, 28).into(),
DateTimeValue::DateAndTime(CalendarDate::new(2026, 9, 22), LocalTime::new(9)),
)
} else {
("2026-09-28".into(), "2026-09-22T09:00:00".into())
};
let value = create_cost_value(
&mut tx,
&m,
CostValueDraft::monetary(125.5)
.name("Labour")
.category("Labour")
.condition("Day rate")
.applicable_date(applicable),
)
.expect("cost value");
let eur = create_monetary_unit(&mut tx, &m, "EUR").expect("eur");
let gbp = create_monetary_unit(&mut tx, &m, "GBP").expect("gbp");
let rate = create_currency_relationship(&mut tx, &m, eur, gbp, 0.85, Some(rate_date))
.expect("rate");
tx.commit(&mut model).expect("commit");
let back = round_trip(&model);
let record = |id: EntityId| back.get(id).expect("record").attributes.clone();
let referenced = |id: EntityId, slot: usize| match &record(id)[slot] {
Value::Ref(target) => back.get(*target).expect("date").type_name.to_string(),
other => panic!("{schema} #{} slot {slot}: {other:?}", id.0),
};
if ifc2x3 {
let cost = record(value);
assert_eq!(cost.len(), 8);
assert_eq!(cost[2], money(125.5));
assert_eq!(referenced(value, 4), "IFCCALENDARDATE");
assert_eq!(cost[6], text("Labour"), "CostType");
assert_eq!(cost[7], text("Day rate"), "Condition");
assert_eq!(record(eur), vec![Value::Enum("EUR".into())]);
let relationship = record(rate);
assert_eq!(relationship.len(), 5);
assert_eq!(
relationship[..3],
[Value::Ref(eur), Value::Ref(gbp), Value::Real(0.85)]
);
assert_eq!(referenced(rate, 3), "IFCDATEANDTIME");
} else {
assert_eq!(
record(value),
vec![
text("Labour"),
Value::Null,
money(125.5),
Value::Null,
text("2026-09-28"),
Value::Null,
text("Labour"),
text("Day rate"),
Value::Null,
Value::Null,
],
"{schema}"
);
assert_eq!(record(eur), vec![text("EUR")]);
assert_eq!(
record(rate),
vec![
Value::Null,
Value::Null,
Value::Ref(eur),
Value::Ref(gbp),
Value::Real(0.85),
text("2026-09-22T09:00:00"),
Value::Null,
],
"{schema}"
);
}
let view = CostView::new(&back);
let read = ifc_cost::value::CostValue::new(value, back.get(value).unwrap());
assert_eq!(read.amount(), Some(125.5), "{schema}");
assert_eq!(view.model().ids_of_type("IFCMONETARYUNIT").len(), 2);
}
}
fn refused(tx: &Transaction, result: Result<EntityId, CostAuthoringError>) -> CostAuthoringError {
assert!(tx.is_empty(), "a refusal staged {:?}", tx.edits());
result.expect_err("refused")
}
#[test]
fn ifc2x3_refuses_what_it_cannot_hold() {
let v = SchemaVersion::Ifc2x3;
let model = base("IFC2X3");
let tx = &mut Transaction::new(&model);
let date = CalendarDate::new(2026, 9, 28);
let result = create_cost_value(tx, &model, CostValueDraft::monetary(1.0));
assert_eq!(
refused(tx, result),
CostAuthoringError::AuthoringRequired {
entity: "IFCCOSTVALUE",
attribute: "CostType",
schema: v
}
);
let result = create_cost_value(
tx,
&model,
CostValueDraft::monetary(1.0)
.category("Labour")
.applicable_date("2026-09-28"),
);
assert!(matches!(
refused(tx, result),
CostAuthoringError::AuthoringValueType {
attribute: "ApplicableDate",
..
}
));
let mut staging = Transaction::new(&model);
let part = create_cost_value(
&mut staging,
&model,
CostValueDraft::monetary(1.0).category("Part"),
)
.expect("part");
let parts = [part];
let composed = CostValueDraft::default()
.category("Sum")
.kind(CostValueKind::Components {
operator: ArithmeticOperator::Add,
components: &parts,
});
let before = staging.edits().len();
let error = create_cost_value(&mut staging, &model, composed).expect_err("refused");
assert_eq!(staging.edits().len(), before);
assert!(matches!(
error,
CostAuthoringError::AuthoringNotInSchema {
attribute: "ArithmeticOperator" | "Components",
..
}
));
let result = create_monetary_unit(tx, &model, "Bitcoin");
assert!(matches!(
refused(tx, result),
CostAuthoringError::AuthoringValueType {
attribute: "Currency",
..
}
));
let mut units = Transaction::new(&model);
let eur = create_monetary_unit(&mut units, &model, "eur").expect("eur");
let gbp = create_monetary_unit(&mut units, &model, "GBP").expect("gbp");
let before = units.edits().len();
let missing = create_currency_relationship(&mut units, &model, eur, gbp, 1.1, None);
assert_eq!(
missing.expect_err("refused"),
CostAuthoringError::AuthoringRequired {
entity: "IFCCURRENCYRELATIONSHIP",
attribute: "RateDateTime",
schema: v
}
);
let bare = create_currency_relationship(&mut units, &model, eur, gbp, 1.1, Some(date.into()));
assert!(matches!(
bare.expect_err("refused"),
CostAuthoringError::AuthoringValueType {
attribute: "RateDateTime",
..
}
));
assert_eq!(units.edits().len(), before, "nothing staged");
let Some(ifc_model::Edit::Create { entity, .. }) = units.edits().first() else {
panic!("eur staged");
};
assert_eq!(entity.attributes, vec![Value::Enum("EUR".into())]);
}
#[test]
fn record_dates_and_unverified_releases_are_refused() {
let model = base("IFC4");
let tx = &mut Transaction::new(&model);
let result = create_cost_value(
tx,
&model,
CostValueDraft::monetary(1.0).applicable_date(CalendarDate::new(2026, 1, 1)),
);
assert!(matches!(
refused(tx, result),
CostAuthoringError::AuthoringValueType {
attribute: "ApplicableDate",
schema: SchemaVersion::Ifc4,
..
}
));
for schema in ["IFC4X1", "IFC4X2", "IFC5"] {
let model = base(schema);
let tx = &mut Transaction::new(&model);
let unsupported = CostAuthoringError::UnsupportedSchema {
schema: schema.into(),
};
let result = create_cost_value(tx, &model, CostValueDraft::monetary(1.0));
assert_eq!(refused(tx, result), unsupported);
let result = create_monetary_unit(tx, &model, "EUR");
assert_eq!(refused(tx, result), unsupported);
}
}
#[test]
fn the_schedule_reader_binds_the_declared_release() {
for (schema, version) in [
("IFC2X3", SchemaVersion::Ifc2x3),
("IFC4", SchemaVersion::Ifc4),
("IFC4X3_ADD2", SchemaVersion::Ifc4x3),
] {
let ifc2x3 = version == SchemaVersion::Ifc2x3;
let mut model = base(schema);
let m = model.clone();
let mut tx = Transaction::new(&model);
let submitted: DateTimeValue<'_> = if ifc2x3 {
CalendarDate::new(2026, 9, 28).into()
} else {
"2026-09-28T00:00:00".into()
};
let mut draft = CostScheduleDraft::new("0YvctVUKr0kugbFTf53O08")
.name("Estimate")
.identification("CS-1")
.predefined_type(CostScheduleType::Estimate)
.status("DRAFT");
draft.submitted_on = Some(submitted);
let id =
create_cost_schedule_with_owner_history(&mut tx, &m, draft, OWNER).expect("schedule");
tx.commit(&mut model).expect("commit");
let back = round_trip(&model);
let view = CostView::new(&back);
let schedule = view.schedules().expect("bound").next().expect("schedule");
assert_eq!(schedule.id(), id);
assert_eq!(schedule.release(), version);
assert_eq!(schedule.name(), Some("Estimate"), "{schema}");
assert_eq!(schedule.identification(), Some("CS-1"), "{schema}");
assert_eq!(schedule.predefined_type(), Some("ESTIMATE"), "{schema}");
assert_eq!(schedule.status(), Some("DRAFT"), "{schema}");
assert_eq!(schedule.update_date(), None, "{schema}");
match schedule.submitted_on().expect("submitted") {
AuthoredDateTime::Record(record) => {
assert!(ifc2x3, "{schema}");
assert_eq!(&*back.get(record).unwrap().type_name, "IFCCALENDARDATE");
}
AuthoredDateTime::Text(text) => {
assert!(!ifc2x3, "{schema}");
assert_eq!(text, "2026-09-28T00:00:00");
}
other => panic!("{other:?}"),
}
let entity = back.get(id).unwrap();
let explicit = CostSchedule::new(id, entity, version).expect("verified");
assert_eq!(explicit.identification(), Some("CS-1"));
let other = if ifc2x3 {
SchemaVersion::Ifc4
} else {
SchemaVersion::Ifc2x3
};
let misread = CostSchedule::new(id, entity, other).expect("verified");
assert_ne!(misread.status(), Some("DRAFT"), "{schema}");
}
}
#[test]
fn the_schedule_reader_refuses_unverified_releases() {
for schema in ["IFC4X1", "IFC4X2", "IFC5"] {
let model = base(schema);
assert!(
matches!(
CostView::new(&model).schedules().err(),
Some(CostError::UnsupportedSchema { schema: s }) if s == schema
),
"{schema}"
);
}
let mut model = base("IFC4");
model.header_mut().schema.push("IFC2X3".into());
assert!(matches!(
CostView::new(&model).schedules().err(),
Some(CostError::MultipleSchemas { schemas: 2 })
));
let entity = Entity::new("IFCCOSTSCHEDULE", vec![Value::Null; 10]);
for version in [SchemaVersion::Ifc4x1, SchemaVersion::Ifc4x2] {
assert!(matches!(
CostSchedule::new(EntityId(1), &entity, version),
Err(CostError::UnsupportedSchema { .. })
));
}
}