#[path = "../examples/write-samples/fixture.rs"]
mod fixture;
use openbim_bcf::write::{
self, Camera, Comment, Compression, Document, Extensions, Invalid, Options, Projection,
TargetVersion, Topic, Vector3, Viewpoint, WriteError,
};
use openbim_bcf::{BcfVersion, Component, Markup};
use openbim_core::Detected;
use std::path::PathBuf;
fn golden_path(stem: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/golden")
.join(format!("{stem}.bcfzip"))
}
#[test]
fn output_is_byte_identical_to_the_golden_files() {
let bless = std::env::var_os("BCF_BLESS").is_some();
for (stem, doc) in fixture::samples() {
let bytes = write::to_vec(&doc).unwrap();
let path = golden_path(stem);
if bless {
std::fs::write(&path, &bytes).unwrap();
continue;
}
let golden = std::fs::read(&path)
.unwrap_or_else(|e| panic!("{}: {e}; run with BCF_BLESS=1", path.display()));
assert!(
bytes == golden,
"{stem}: output differs from {}",
path.display()
);
}
}
#[test]
fn every_sink_receives_the_same_bytes() {
let dir = std::env::temp_dir().join(format!("openbim-bcf-write-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
for (stem, doc) in fixture::samples() {
let in_memory = write::to_vec(&doc).unwrap();
assert_eq!(in_memory, write::to_vec(&doc.clone()).unwrap(), "{stem}");
let path = dir.join(format!("{stem}.bcfzip"));
write::to_path(&doc, &path).unwrap();
assert_eq!(std::fs::read(&path).unwrap(), in_memory, "{stem}");
}
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn zip_metadata_is_fixed() {
for (stem, doc) in fixture::samples() {
let bytes = write::to_vec(&doc).unwrap();
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
for i in 0..zip.len() {
let f = zip.by_index(i).unwrap();
assert_eq!(f.compression(), zip::CompressionMethod::Stored, "{stem}");
assert_eq!(
f.last_modified(),
Some(zip::DateTime::default()),
"{stem}: {}",
f.name()
);
assert_eq!(f.unix_mode(), Some(0o100_644), "{stem}: {}", f.name());
}
}
}
#[test]
fn written_archives_round_trip_through_the_reader() {
for (stem, doc) in fixture::samples() {
let archive = openbim_bcf::read_slice(&write::to_vec(&doc).unwrap()).unwrap();
assert!(
archive.diagnostics().is_empty(),
"{stem}: {:?}",
archive.diagnostics()
);
assert_eq!(
*archive.version(),
Detected::Declared(BcfVersion::from(doc.version)),
"{stem}"
);
assert_read_back(stem, &doc, &archive.topics().collect::<Vec<_>>());
}
}
fn assert_read_back(stem: &str, doc: &Document, read: &[&Markup]) {
assert_eq!(read.len(), doc.topics.len(), "{stem}");
for (w, r) in doc.topics.iter().zip(read) {
let t = &r.topic;
assert_eq!(t.guid.as_deref(), Some(w.guid.as_str()), "{stem}");
assert_eq!(t.title.as_deref(), Some(w.title.as_str()), "{stem}");
assert_eq!(t.description, w.description, "{stem}");
assert_eq!(t.topic_type, w.topic_type, "{stem}");
assert_eq!(t.topic_status, w.topic_status, "{stem}");
assert_eq!(t.priority, w.priority, "{stem}");
assert_eq!(t.labels, w.labels, "{stem}");
assert_eq!(t.creation_date.as_deref(), Some(w.creation_date.as_str()));
assert_eq!(
t.creation_author.as_deref(),
Some(w.creation_author.as_str())
);
assert_eq!(r.comments.len(), w.comments.len(), "{stem}");
for (wc, rc) in w.comments.iter().zip(&r.comments) {
assert_eq!(rc.guid.as_deref(), Some(wc.guid.as_str()), "{stem}");
assert_eq!(rc.date.as_deref(), Some(wc.date.as_str()), "{stem}");
assert_eq!(rc.author.as_deref(), Some(wc.author.as_str()), "{stem}");
assert_eq!(rc.comment.as_deref(), Some(wc.comment.as_str()), "{stem}");
assert_eq!(rc.viewpoint, wc.viewpoint, "{stem}");
}
assert_eq!(r.viewpoints.len(), w.viewpoints.len(), "{stem}");
for (wv, rv) in w.viewpoints.iter().zip(&r.viewpoints) {
assert_eq!(rv.guid.as_deref(), Some(wv.guid.as_str()), "{stem}");
let vis = rv
.visualization
.as_ref()
.unwrap_or_else(|| panic!("{stem}: viewpoint {} unread", wv.guid));
assert_eq!(vis.guid.as_deref(), Some(wv.guid.as_str()), "{stem}");
assert_eq!(vis.selection, wv.selection, "{stem}");
}
}
}
#[test]
fn a_3_0_archive_without_extensions_gets_the_vocabulary_it_uses() {
let (_, doc) = fixture::samples()
.into_iter()
.find(|(s, _)| *s == "sample-3.0-derived-extensions")
.unwrap();
let bytes = write::to_vec(&doc).unwrap();
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut xml = String::new();
std::io::Read::read_to_string(&mut zip.by_name("extensions.xml").unwrap(), &mut xml).unwrap();
let pos = |s: &str| xml.find(s).unwrap_or_else(|| panic!("{s} missing:\n{xml}"));
assert!(pos("<TopicType>Clash</TopicType>") < pos("<TopicType>formale Prüfung</TopicType>"));
assert!(pos("<TopicStatus>Open</TopicStatus>") < pos("<TopicStatus>Offen</TopicStatus>"));
pos("<Priority>High</Priority>");
assert!(pos("<TopicLabel>MEP</TopicLabel>") < pos("<TopicLabel>Struktur</TopicLabel>"));
assert!(
!xml.contains("Info"),
"unused values are not invented:\n{xml}"
);
assert!(xml.contains("<Users/>"), "{xml}");
}
#[test]
fn a_2_1_archive_has_no_extensions_xml() {
let (_, doc) = fixture::samples().into_iter().next().unwrap();
assert_eq!(doc.version, TargetVersion::V2_1);
let archive = openbim_bcf::read_slice(&write::to_vec(&doc).unwrap()).unwrap();
assert!(!archive.entries().iter().any(|e| e.contains("extensions")));
}
const T: &str = "3f2504e0-4f89-41d3-9a0c-0305e82c3301";
const C: &str = "0b7c3c1e-9d0a-4d2b-8f55-1a2b3c4d5e6f";
const V: &str = "7e1d2c3b-4a59-4687-9a1b-2c3d4e5f6a7b";
fn camera() -> Camera {
Camera {
projection: Projection::Perspective {
field_of_view: 50.0,
},
view_point: Vector3::new(1.0, 2.0, 3.0),
direction: Vector3::new(0.0, 1.0, 0.0),
up_vector: Vector3::new(0.0, 0.0, 1.0),
aspect_ratio: None,
}
}
fn minimal(version: TargetVersion) -> Document {
let v3 = version == TargetVersion::V3_0;
Document {
version,
extensions: None,
topics: vec![Topic {
guid: T.into(),
title: "Title".into(),
topic_type: Some("Clash".into()),
topic_status: Some("Open".into()),
creation_date: "2026-09-26T10:00:00Z".into(),
creation_author: "a@example.com".into(),
comments: vec![Comment {
guid: C.into(),
date: "2026-09-26T10:00:00Z".into(),
author: "a@example.com".into(),
comment: "text".into(),
viewpoint: Some(V.into()),
}],
viewpoints: vec![Viewpoint {
guid: V.into(),
selection: vec![Component::ifc("0fXw$sQh19ixbI4tZgfkXu")],
camera: Some(Camera {
aspect_ratio: v3.then_some(1.5),
..camera()
}),
}],
..Topic::default()
}],
}
}
fn refusal(doc: &Document) -> (String, Invalid) {
match write::to_vec(doc) {
Err(WriteError::Invalid { at, problem }) => (at, problem),
Err(other) => panic!("expected Invalid, got {other:?}"),
Ok(_) => panic!("document was accepted"),
}
}
#[test]
fn the_minimal_documents_are_themselves_valid() {
for v in [TargetVersion::V2_1, TargetVersion::V3_0] {
let archive = openbim_bcf::read_slice(&write::to_vec(&minimal(v)).unwrap()).unwrap();
assert!(
archive.diagnostics().is_empty(),
"{:?}",
archive.diagnostics()
);
}
}
type RefusalCase = (
&'static str,
TargetVersion,
fn(&mut Document),
&'static str,
fn(&Invalid) -> bool,
);
#[allow(clippy::too_many_lines)]
#[test]
fn malformed_values_are_refused_with_their_location() {
let cases: Vec<RefusalCase> = vec![
(
"topic GUID without dashes",
TargetVersion::V2_1,
|d| d.topics[0].guid = "3f2504e04f8941d39a0c0305e82c3301".into(),
"topics[0].guid",
|p| matches!(p, Invalid::Guid { .. }),
),
(
"uppercase GUID in 3.0",
TargetVersion::V3_0,
|d| d.topics[0].comments[0].guid = C.to_uppercase(),
"topics[0].comments[0].guid",
|p| matches!(p, Invalid::Guid { .. }),
),
(
"IfcGuid of 21 characters",
TargetVersion::V2_1,
|d| d.topics[0].viewpoints[0].selection[0] = Component::ifc("0fXw$sQh19ixbI4tZgfkX"),
"topics[0].viewpoints[0].selection[0].ifc_guid",
|p| matches!(p, Invalid::IfcGuid { .. }),
),
(
"IfcGuid outside the IFC alphabet",
TargetVersion::V3_0,
|d| d.topics[0].viewpoints[0].selection[0] = Component::ifc("0fXw-sQh19ixbI4tZgfkXu"),
"topics[0].viewpoints[0].selection[0].ifc_guid",
|p| matches!(p, Invalid::IfcGuid { .. }),
),
(
"component identifying nothing",
TargetVersion::V2_1,
|d| {
d.topics[0].viewpoints[0].selection[0] = Component {
originating_system: Some("Revit".into()),
..Component::default()
};
},
"topics[0].viewpoints[0].selection[0]",
|p| matches!(p, Invalid::UnidentifiedComponent),
),
(
"date without time",
TargetVersion::V2_1,
|d| d.topics[0].creation_date = "2026-09-26".into(),
"topics[0].creation_date",
|p| matches!(p, Invalid::DateTime { .. }),
),
(
"impossible comment date",
TargetVersion::V3_0,
|d| d.topics[0].comments[0].date = "2026-02-30T00:00:00Z".into(),
"topics[0].comments[0].date",
|p| matches!(p, Invalid::DateTime { .. }),
),
(
"blank title",
TargetVersion::V2_1,
|d| d.topics[0].title = " ".into(),
"topics[0].title",
|p| matches!(p, Invalid::Blank),
),
(
"trailing whitespace that the reader would trim",
TargetVersion::V2_1,
|d| d.topics[0].description = Some("text\n".into()),
"topics[0].description",
|p| matches!(p, Invalid::SurroundingWhitespace { .. }),
),
(
"control character XML cannot carry",
TargetVersion::V3_0,
|d| d.topics[0].comments[0].comment = "bell\u{7}".into(),
"topics[0].comments[0].comment",
|p| matches!(p, Invalid::ForbiddenCharacter { ch: '\u{7}' }),
),
(
"3.0 topic without a type",
TargetVersion::V3_0,
|d| d.topics[0].topic_type = None,
"topics[0].topic_type",
|p| matches!(p, Invalid::Missing),
),
(
"3.0 topic without a status",
TargetVersion::V3_0,
|d| d.topics[0].topic_status = None,
"topics[0].topic_status",
|p| matches!(p, Invalid::Missing),
),
(
"3.0 viewpoint without a camera",
TargetVersion::V3_0,
|d| d.topics[0].viewpoints[0].camera = None,
"topics[0].viewpoints[0].camera",
|p| matches!(p, Invalid::Missing),
),
(
"3.0 camera without an aspect ratio",
TargetVersion::V3_0,
|d| d.topics[0].viewpoints[0].camera = Some(camera()),
"topics[0].viewpoints[0].camera.aspect_ratio",
|p| matches!(p, Invalid::Missing),
),
(
"2.1 camera with an aspect ratio it cannot hold",
TargetVersion::V2_1,
|d| {
d.topics[0].viewpoints[0].camera = Some(Camera {
aspect_ratio: Some(1.5),
..camera()
});
},
"topics[0].viewpoints[0].camera.aspect_ratio",
|p| {
matches!(
p,
Invalid::NotInVersion {
version: TargetVersion::V2_1
}
)
},
),
(
"2.1 field of view outside 45..=60",
TargetVersion::V2_1,
|d| {
d.topics[0].viewpoints[0].camera = Some(Camera {
projection: Projection::Perspective {
field_of_view: 90.0,
},
..camera()
});
},
"topics[0].viewpoints[0].camera.projection.field_of_view",
|p| matches!(p, Invalid::Number { .. }),
),
(
"non-finite camera coordinate",
TargetVersion::V2_1,
|d| {
d.topics[0].viewpoints[0].camera = Some(Camera {
view_point: Vector3::new(f64::NAN, 0.0, 0.0),
..camera()
});
},
"topics[0].viewpoints[0].camera.view_point.x",
|p| matches!(p, Invalid::Number { .. }),
),
(
"zero viewing direction",
TargetVersion::V2_1,
|d| {
d.topics[0].viewpoints[0].camera = Some(Camera {
direction: Vector3::default(),
..camera()
});
},
"topics[0].viewpoints[0].camera.direction",
|p| matches!(p, Invalid::Number { .. }),
),
(
"non-positive orthogonal scale",
TargetVersion::V2_1,
|d| {
d.topics[0].viewpoints[0].camera = Some(Camera {
projection: Projection::Orthogonal {
view_to_world_scale: 0.0,
},
..camera()
});
},
"topics[0].viewpoints[0].camera.projection.view_to_world_scale",
|p| matches!(p, Invalid::Number { .. }),
),
(
"GUID reused across topic and viewpoint",
TargetVersion::V2_1,
|d| d.topics[0].viewpoints[0].guid = T.to_uppercase(),
"topics[0].viewpoints[0].guid",
|p| matches!(p, Invalid::DuplicateGuid { .. }),
),
(
"two topics with one GUID",
TargetVersion::V3_0,
|d| {
let mut twin = d.topics[0].clone();
twin.comments.clear();
twin.viewpoints.clear();
d.topics.push(twin);
},
"topics[1].guid",
|p| matches!(p, Invalid::DuplicateGuid { .. }),
),
(
"comment anchored to a missing viewpoint",
TargetVersion::V2_1,
|d| {
d.topics[0].comments[0].viewpoint =
Some("8f2e3d4c-5b6a-4798-8b2c-3d4e5f6a7b8c".into());
},
"topics[0].comments[0].viewpoint",
|p| matches!(p, Invalid::UnknownViewpoint { .. }),
),
(
"no topics at all",
TargetVersion::V3_0,
|d| d.topics.clear(),
"topics",
|p| matches!(p, Invalid::NoTopics),
),
];
for (label, version, mutate, at, expected) in cases {
let mut doc = minimal(version);
mutate(&mut doc);
let (got_at, problem) = refusal(&doc);
assert_eq!(got_at, at, "{label}: {problem}");
assert!(expected(&problem), "{label}: unexpected {problem:?}");
}
}
fn vocabulary() -> Extensions {
Extensions {
topic_types: vec!["Clash".into()],
topic_statuses: vec!["Open".into()],
priorities: vec!["High".into()],
topic_labels: vec!["MEP".into()],
..Extensions::default()
}
}
#[test]
fn values_missing_from_supplied_extensions_are_refused() {
type Mutation = fn(&mut Topic);
let cases: [(&str, Mutation, &str); 4] = [
(
"type",
|t| t.topic_type = Some("Error".into()),
"topics[0].topic_type",
),
(
"status",
|t| t.topic_status = Some("open".into()),
"topics[0].topic_status",
),
(
"priority",
|t| t.priority = Some("Low".into()),
"topics[0].priority",
),
(
"label",
|t| t.labels = vec!["MEP".into(), "ARC".into()],
"topics[0].labels[1]",
),
];
for version in [TargetVersion::V2_1, TargetVersion::V3_0] {
let mut ok = minimal(version);
ok.extensions = Some(vocabulary());
ok.topics[0].priority = Some("High".into());
ok.topics[0].labels = vec!["MEP".into()];
write::to_vec(&ok).unwrap();
for (label, mutate, at) in cases {
let mut doc = ok.clone();
mutate(&mut doc.topics[0]);
let (got_at, problem) = refusal(&doc);
assert_eq!(got_at, at, "{label}");
assert!(
matches!(problem, Invalid::NotInExtensions { .. }),
"{label}: {problem:?}"
);
}
}
}
#[test]
fn blank_extension_values_are_refused() {
let mut doc = minimal(TargetVersion::V3_0);
let mut ext = vocabulary();
ext.users = vec!["ok@example.com".into(), String::new()];
doc.extensions = Some(ext);
assert_eq!(
refusal(&doc),
("extensions.users[1]".into(), Invalid::Blank)
);
}
#[test]
fn an_invalid_document_leaves_the_filesystem_untouched() {
let path =
std::env::temp_dir().join(format!("openbim-bcf-refused-{}.bcfzip", std::process::id()));
let mut doc = minimal(TargetVersion::V2_1);
doc.topics[0].guid = "nope".into();
assert!(write::to_path(&doc, &path).is_err());
assert!(!path.exists(), "{} was created", path.display());
}
#[test]
fn refusal_messages_name_the_location_and_value() {
let mut doc = minimal(TargetVersion::V2_1);
doc.topics[0].creation_date = "yesterday".into();
let msg = write::to_vec(&doc).unwrap_err().to_string();
assert!(msg.contains("topics[0].creation_date"), "{msg}");
assert!(msg.contains("\"yesterday\""), "{msg}");
}
#[test]
fn deflated_archives_round_trip_and_are_deterministic_within_a_build() {
let options = Options::default().compression(Compression::Deflated { level: 9 });
for (stem, doc) in fixture::samples() {
let stored = write::to_vec(&doc).unwrap();
let deflated = write::to_vec_with(&doc, options).unwrap();
assert_eq!(
deflated,
write::to_vec_with(&doc, options).unwrap(),
"{stem}"
);
assert!(
deflated.len() < stored.len(),
"{stem}: deflate did not shrink"
);
let archive = openbim_bcf::read_slice(&deflated).unwrap();
assert!(
archive.diagnostics().is_empty(),
"{stem}: {:?}",
archive.diagnostics()
);
assert_read_back(stem, &doc, &archive.topics().collect::<Vec<_>>());
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(deflated)).unwrap();
for i in 0..zip.len() {
let f = zip.by_index(i).unwrap();
assert_eq!(f.compression(), zip::CompressionMethod::Deflated, "{stem}");
assert_eq!(f.last_modified(), Some(zip::DateTime::default()), "{stem}");
}
}
}
#[test]
fn the_deflate_level_changes_the_output() {
let (_, doc) = fixture::samples().into_iter().next().unwrap();
let fast = write::to_vec_with(
&doc,
Options::default().compression(Compression::Deflated { level: 1 }),
);
let small = write::to_vec_with(
&doc,
Options::default().compression(Compression::Deflated { level: 9 }),
);
assert_ne!(fast.unwrap(), small.unwrap());
}
#[test]
fn the_default_options_are_the_stored_golden_output() {
for (stem, doc) in fixture::samples() {
assert_eq!(
write::to_vec_with(&doc, Options::default()).unwrap(),
write::to_vec(&doc).unwrap(),
"{stem}"
);
}
}
#[test]
fn out_of_range_deflate_levels_are_refused_before_anything_is_written() {
let doc = minimal(TargetVersion::V2_1);
for level in [0, 10, 255] {
let options = Options::default().compression(Compression::Deflated { level });
match write::to_vec_with(&doc, options) {
Err(WriteError::Invalid { at, problem }) => {
assert_eq!(at, "options.compression.level", "{level}");
assert!(
matches!(problem, Invalid::Number { .. }),
"{level}: {problem:?}"
);
}
other => panic!("level {level}: expected Invalid, got {other:?}"),
}
let path = std::env::temp_dir().join(format!(
"openbim-bcf-level-{level}-{}.bcfzip",
std::process::id()
));
assert!(write::to_path_with(&doc, &path, options).is_err());
assert!(!path.exists(), "{} was created", path.display());
}
}