Skip to main content

openbim_bcf/
markup.rs

1//! The BCF markup model: topics, comments, and viewpoint references.
2//!
3//! # Verbatim by default
4//!
5//! Status, type, priority, stage, and user identifiers are `String`, not
6//! enums. The corpus settles it: `TopicStatus` occurs as `Open`, `OPEN`,
7//! `Offen`, `Active`, `ReOpened`, and `In Progress`; `TopicType` as `Error`,
8//! `ERROR`, `formale Prüfung`, `Sichprüfung`, and `Clash`. Those are not typos
9//! — BCF 2.x defines the vocabulary in a per-project `extensions.xsd`, so the
10//! valid set is a property of the *project*, not of the format. Mapping them
11//! onto a fixed enum would either reject valid files or silently rewrite them.
12//!
13//! Dates are also kept as written. A caller that needs `chrono`/`time` can
14//! parse them; the reader reports non-conforming values as
15//! [`Tolerance::UnparseableDateTime`] instead of dropping the field.
16
17use crate::diagnostic::{Diagnostic, Tolerance};
18use crate::version::BcfVersion;
19use crate::xml::Node;
20
21/// A file referenced by a topic header — the model the issue is about.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct HeaderFile {
24    /// `IfcProject` GUID of the referenced project, when given.
25    pub ifc_project: Option<String>,
26    /// `IfcSpatialStructureElement` GUID, when given.
27    pub ifc_spatial_structure_element: Option<String>,
28    /// The file name as written.
29    pub filename: Option<String>,
30    /// The file's date, verbatim.
31    pub date: Option<String>,
32    /// A URL or path locating the file, when given.
33    pub reference: Option<String>,
34    /// Whether the file lives outside the BCF archive. Absent means the
35    /// schema default, `true`.
36    pub is_external: Option<bool>,
37}
38
39/// A viewpoint attached to a topic: the camera and visibility state, plus its
40/// snapshot.
41///
42/// The markup side of the reference, plus the component selection of the
43/// `.bcfv` document it names. Camera, visibility, and colouring geometry are
44/// not read; see the crate-level status section.
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct ViewPointRef {
47    /// The viewpoint's GUID.
48    pub guid: Option<String>,
49    /// Archive entry name of the `.bcfv` document.
50    pub viewpoint: Option<String>,
51    /// Archive entry name of the snapshot image.
52    pub snapshot: Option<String>,
53    /// Sort order among a topic's viewpoints, when given (BCF 3.0).
54    pub index: Option<i32>,
55    /// What the referenced `.bcfv` document says, when it could be read.
56    ///
57    /// `None` when the markup names no `.bcfv`, names one the archive lacks
58    /// ([`Tolerance::DanglingReference`]), or names one that cannot be parsed
59    /// ([`Tolerance::UnreadableViewpoint`]).
60    pub visualization: Option<Visualization>,
61}
62
63/// The parts of a `.bcfv` viewpoint document this crate reads.
64#[derive(Debug, Clone, PartialEq, Eq)]
65#[non_exhaustive]
66pub struct Visualization {
67    /// `VisualizationInfo/@Guid`, verbatim.
68    pub guid: Option<String>,
69    /// Components the viewpoint highlights, in document order.
70    ///
71    /// 2.1 and 3.0 list them under `Components/Selection`; 2.0 flags them with
72    /// `Selected="true"` directly under `Components`. Both are read.
73    pub selection: Vec<Component>,
74}
75
76/// One model element referenced by a viewpoint.
77///
78/// The schema makes every member optional; a component usually carries an
79/// `IfcGuid`, and sometimes only a tool-specific `AuthoringToolId`.
80#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
81pub struct Component {
82    /// The IFC `GlobalId`: 22 characters of IFC base64.
83    pub ifc_guid: Option<String>,
84    /// Name of the system the component originates from.
85    pub originating_system: Option<String>,
86    /// The authoring tool's own identifier for the component.
87    pub authoring_tool_id: Option<String>,
88}
89
90impl Component {
91    /// A component identified only by its IFC `GlobalId`.
92    #[must_use]
93    pub fn ifc(ifc_guid: impl Into<String>) -> Self {
94        Self {
95            ifc_guid: Some(ifc_guid.into()),
96            ..Self::default()
97        }
98    }
99}
100
101/// One comment on a topic.
102#[derive(Debug, Clone, PartialEq, Eq)]
103pub struct Comment {
104    /// The comment's GUID.
105    pub guid: Option<String>,
106    /// Creation timestamp, verbatim.
107    pub date: Option<String>,
108    /// Author identifier, verbatim.
109    pub author: Option<String>,
110    /// The comment text.
111    pub comment: Option<String>,
112    /// GUID of the viewpoint this comment is anchored to.
113    pub viewpoint: Option<String>,
114    /// Last-modified timestamp, verbatim.
115    pub modified_date: Option<String>,
116    /// Last-modifying author, verbatim.
117    pub modified_author: Option<String>,
118}
119
120/// One BCF topic — a single issue.
121#[derive(Debug, Clone, PartialEq, Eq)]
122pub struct Topic {
123    /// The topic GUID. Required by the schema; `None` is recorded as
124    /// [`Tolerance::TopicWithoutGuid`].
125    pub guid: Option<String>,
126    /// Topic type, verbatim and project-defined.
127    pub topic_type: Option<String>,
128    /// Topic status, verbatim and project-defined.
129    pub topic_status: Option<String>,
130    /// An identifier assigned by a BCF server, when present (BCF 3.0).
131    pub server_assigned_id: Option<String>,
132    /// The topic title.
133    pub title: Option<String>,
134    /// Priority, verbatim and project-defined.
135    pub priority: Option<String>,
136    /// Project-defined index for ordering.
137    pub index: Option<i32>,
138    /// Free-form labels.
139    pub labels: Vec<String>,
140    /// Creation date, verbatim.
141    pub creation_date: Option<String>,
142    /// Creation author, verbatim.
143    pub creation_author: Option<String>,
144    /// Last-modified date, verbatim.
145    pub modified_date: Option<String>,
146    /// Last-modifying author, verbatim.
147    pub modified_author: Option<String>,
148    /// Due date, verbatim.
149    pub due_date: Option<String>,
150    /// Assignee, verbatim.
151    pub assigned_to: Option<String>,
152    /// Free-form description.
153    pub description: Option<String>,
154    /// Project-defined stage.
155    pub stage: Option<String>,
156    /// GUIDs of related topics.
157    pub related_topics: Vec<String>,
158    /// External reference links (BCF 3.0).
159    pub reference_links: Vec<String>,
160}
161
162/// A parsed BCF markup document: one topic with its comments and viewpoints.
163#[derive(Debug, Clone, PartialEq, Eq)]
164pub struct Markup {
165    /// The archive entry this markup was read from.
166    pub entry: String,
167    /// Files the topic refers to.
168    pub header_files: Vec<HeaderFile>,
169    /// The topic itself.
170    pub topic: Topic,
171    /// Comments, in document order.
172    pub comments: Vec<Comment>,
173    /// Viewpoint references, in document order.
174    pub viewpoints: Vec<ViewPointRef>,
175}
176
177impl Markup {
178    /// The topic title, or an empty string when the file omits it.
179    #[must_use]
180    pub fn title(&self) -> &str {
181        self.topic.title.as_deref().unwrap_or("")
182    }
183
184    /// The topic status exactly as the file spells it.
185    #[must_use]
186    pub fn status(&self) -> Option<&str> {
187        self.topic.topic_status.as_deref()
188    }
189}
190
191/// Which BCF version's shape a markup tree looks like, from evidence only.
192///
193/// Only markers verified against the official `markup.xsd` of each version are
194/// used. In particular `TopicStatus`/`TopicType` are **attributes in 2.0 too**
195/// — a widely repeated belief that they moved in 2.1 is wrong, and keying
196/// detection on it manufactures conflicts on buildingSMART's own test cases.
197///
198/// Returns `None` when nothing distinguishes the versions: a topic with no
199/// comments and no viewpoints is valid in all three, and inventing a version
200/// for it would fabricate evidence.
201pub(crate) fn observe_version(markup: &Node) -> Option<BcfVersion> {
202    let topic = markup.child("Topic")?;
203
204    // --- 3.0 markers -------------------------------------------------------
205    // 3.0 nests collections inside Topic and wraps their members:
206    //   Topic/Comments/Comment, Topic/Viewpoints/ViewPoint,
207    //   Topic/Labels/Label, Topic/DocumentReferences/DocumentReference,
208    //   Topic/ReferenceLinks/ReferenceLink.
209    // In 2.x, Comment and Viewpoints are siblings of Topic; Labels is a
210    // repeated string; ReferenceLink is a bare element. DocumentReferences
211    // exists in 2.0 too — but there it directly holds ReferencedDocument, with
212    // no DocumentReference child. So the *wrapper-with-typed-member* shape is
213    // the discriminator, never the container name alone.
214    let nests_collections = topic.child("Comments").is_some()
215        || topic.child("Viewpoints").is_some()
216        || topic
217            .child("Labels")
218            .is_some_and(|l| l.child("Label").is_some())
219        || topic
220            .child("DocumentReferences")
221            .is_some_and(|d| d.child("DocumentReference").is_some())
222        || topic
223            .child("ReferenceLinks")
224            .is_some_and(|r| r.child("ReferenceLink").is_some());
225    if nests_collections || topic.attr("ServerAssignedId").is_some() {
226        return Some(BcfVersion::V3_0);
227    }
228    // A root-level Viewpoints element wrapping ViewPoint children is 3.0
229    // shaped too. A *bare* Markup/Viewpoints is not: in 2.x that element **is**
230    // the viewpoint, so the name alone proves nothing.
231    if markup
232        .children_named("Viewpoints")
233        .any(|v| v.child("ViewPoint").is_some())
234    {
235        return Some(BcfVersion::V3_0);
236    }
237
238    // --- 2.0 markers -------------------------------------------------------
239    // 2.0's Comment type carries Status, VerbalStatus, ReplyToComment, and a
240    // mandatory back-reference Topic. 2.1 removed all four. Any one of them
241    // inside a Comment is decisive; nothing else distinguishes 2.0 from 2.1.
242    let comments_are_2_0 = markup.children_named("Comment").any(|c| {
243        c.child("Topic").is_some()
244            || c.child("Status").is_some()
245            || c.child("VerbalStatus").is_some()
246            || c.child("ReplyToComment").is_some()
247    });
248    if comments_are_2_0 {
249        return Some(BcfVersion::V2_0);
250    }
251
252    // --- 2.x, version indeterminate ---------------------------------------
253    // A 2.x file with no comments carries no evidence separating 2.0 from 2.1:
254    // both spell Topic, its attributes, and Viewpoints identically. Claiming
255    // either would be a guess dressed as an observation, so observe nothing and
256    // let a declared bcf.version stand unchallenged.
257    None
258}
259
260/// Interpret a markup tree, appending a diagnostic for each tolerated
261/// deviation.
262pub(crate) fn interpret(
263    entry: &str,
264    root: &Node,
265    diagnostics: &mut Vec<Diagnostic>,
266) -> Option<Markup> {
267    let Some(topic_node) = root.child("Topic") else {
268        diagnostics.push(Diagnostic::in_entry(entry, Tolerance::MarkupWithoutTopic));
269        return None;
270    };
271
272    let topic = read_topic(entry, topic_node, diagnostics);
273
274    let header_files = root
275        .child("Header")
276        .into_iter()
277        .flat_map(|h| h.children_named("File"))
278        .map(read_header_file)
279        .collect();
280
281    // Viewpoint references live in three shapes across versions:
282    //   2.x   Markup/Viewpoints            (the element *is* the ViewPoint)
283    //   3.0   Topic/Viewpoints/ViewPoint   (wrapper inside Topic)
284    //   ---   Markup/Viewpoints/ViewPoint  (wrapper at root; emitted by some
285    //         writers migrating to 3.0)
286    // All three are read, so a hybrid file still yields all its viewpoints.
287    let mut viewpoints: Vec<ViewPointRef> = Vec::new();
288    for wrapper in root
289        .children_named("Viewpoints")
290        .chain(topic_node.children_named("Viewpoints"))
291    {
292        if wrapper.child("ViewPoint").is_some() {
293            viewpoints.extend(wrapper.children_named("ViewPoint").map(read_viewpoint));
294        } else if !wrapper.children.is_empty() || wrapper.attr("Guid").is_some() {
295            // An empty <Viewpoints/> in 3.0 means "no viewpoints", not "one
296            // blank viewpoint"; only a populated 2.x element is a reference.
297            viewpoints.push(read_viewpoint(wrapper));
298        }
299    }
300
301    // Comments: 2.x lists them under Markup, 3.0 nests them in Topic/Comments.
302    let comments = root
303        .children_named("Comment")
304        .chain(
305            topic_node
306                .child("Comments")
307                .into_iter()
308                .flat_map(|c| c.children_named("Comment")),
309        )
310        .map(|c| read_comment(entry, c, diagnostics))
311        .collect();
312
313    Some(Markup {
314        entry: entry.to_string(),
315        header_files,
316        topic,
317        comments,
318        viewpoints,
319    })
320}
321
322fn owned(v: Option<&str>) -> Option<String> {
323    v.map(str::to_string)
324}
325
326fn read_topic(entry: &str, node: &Node, diagnostics: &mut Vec<Diagnostic>) -> Topic {
327    let guid = owned(node.attr("Guid").map(str::trim).filter(|g| !g.is_empty()));
328    if guid.is_none() {
329        diagnostics.push(Diagnostic::in_entry(entry, Tolerance::TopicWithoutGuid));
330    }
331    let title = owned(node.child_text("Title"));
332    if title.is_none() {
333        diagnostics.push(Diagnostic::in_entry(entry, Tolerance::TopicWithoutTitle));
334    }
335
336    Topic {
337        guid,
338        topic_type: owned(node.attr_or_child_text("TopicType")),
339        topic_status: owned(node.attr_or_child_text("TopicStatus")),
340        server_assigned_id: owned(node.attr_or_child_text("ServerAssignedId")),
341        title,
342        priority: owned(node.child_text("Priority")),
343        index: node.child_text("Index").and_then(|i| i.parse().ok()),
344        // 2.x: repeated <Labels>text</Labels>. 3.0: <Labels><Label>text</Label></Labels>.
345        labels: node
346            .children_named("Labels")
347            .flat_map(|l| {
348                let nested: Vec<&str> = l
349                    .children_named("Label")
350                    .map(|n| n.text.trim())
351                    .filter(|t| !t.is_empty())
352                    .collect();
353                if nested.is_empty() {
354                    let own = l.text.trim();
355                    if own.is_empty() {
356                        Vec::new()
357                    } else {
358                        vec![own]
359                    }
360                } else {
361                    nested
362                }
363            })
364            .map(str::to_string)
365            .collect(),
366        creation_date: owned(node.child_text("CreationDate")),
367        creation_author: owned(node.child_text("CreationAuthor")),
368        modified_date: owned(node.child_text("ModifiedDate")),
369        modified_author: owned(node.child_text("ModifiedAuthor")),
370        due_date: owned(node.child_text("DueDate")),
371        assigned_to: owned(node.child_text("AssignedTo")),
372        description: owned(node.child_text("Description")),
373        stage: owned(node.child_text("Stage")),
374        related_topics: node
375            .children_named("RelatedTopic")
376            .chain(
377                node.child("RelatedTopics")
378                    .into_iter()
379                    .flat_map(|r| r.children_named("RelatedTopic")),
380            )
381            .filter_map(|r| owned(r.attr("Guid")))
382            .collect(),
383        reference_links: node
384            .children_named("ReferenceLink")
385            .chain(
386                node.child("ReferenceLinks")
387                    .into_iter()
388                    .flat_map(|r| r.children_named("ReferenceLink")),
389            )
390            .filter_map(|r| {
391                let t = r.text.trim();
392                (!t.is_empty()).then(|| t.to_string())
393            })
394            .collect(),
395    }
396}
397
398fn read_header_file(node: &Node) -> HeaderFile {
399    HeaderFile {
400        ifc_project: owned(node.attr("IfcProject")),
401        ifc_spatial_structure_element: owned(node.attr("IfcSpatialStructureElement")),
402        filename: owned(node.child_text("Filename")),
403        date: owned(node.child_text("Date")),
404        reference: owned(node.child_text("Reference")),
405        is_external: node.attr("IsExternal").and_then(parse_bool),
406    }
407}
408
409fn read_viewpoint(node: &Node) -> ViewPointRef {
410    ViewPointRef {
411        guid: owned(node.attr("Guid")),
412        viewpoint: owned(node.child_text("Viewpoint")),
413        snapshot: owned(node.child_text("Snapshot")),
414        index: node.child_text("Index").and_then(|i| i.parse().ok()),
415        // Filled in by the archive once the `.bcfv` entry has been resolved.
416        visualization: None,
417    }
418}
419
420/// Interpret a `.bcfv` tree.
421///
422/// # Errors
423///
424/// Returns a description when the root is not `VisualizationInfo`: such a
425/// document is not a viewpoint, and reading a selection out of it would be a
426/// guess.
427pub(crate) fn interpret_visualization(root: &Node) -> Result<Visualization, String> {
428    if root.name != "VisualizationInfo" {
429        return Err(format!(
430            "root element is <{}>, not <VisualizationInfo>",
431            root.name
432        ));
433    }
434    let components = root.child("Components");
435    // 2.1/3.0: Components/Selection/Component.
436    // 2.0:     Components/Component with Selected="true"; the others are
437    //          merely visible or coloured, which is not a selection.
438    let selection = components
439        .into_iter()
440        .flat_map(|c| c.children_named("Selection"))
441        .flat_map(|s| s.children_named("Component"))
442        .chain(
443            components
444                .into_iter()
445                .flat_map(|c| c.children_named("Component"))
446                .filter(|c| c.attr("Selected").and_then(parse_bool) == Some(true)),
447        )
448        .map(read_component)
449        .collect();
450    Ok(Visualization {
451        guid: owned(root.attr("Guid").map(str::trim).filter(|g| !g.is_empty())),
452        selection,
453    })
454}
455
456fn read_component(node: &Node) -> Component {
457    Component {
458        ifc_guid: owned(
459            node.attr("IfcGuid")
460                .map(str::trim)
461                .filter(|g| !g.is_empty()),
462        ),
463        originating_system: owned(node.child_text("OriginatingSystem")),
464        authoring_tool_id: owned(node.child_text("AuthoringToolId")),
465    }
466}
467
468fn read_comment(entry: &str, node: &Node, diagnostics: &mut Vec<Diagnostic>) -> Comment {
469    let guid = owned(node.attr("Guid").map(str::trim).filter(|g| !g.is_empty()));
470    if guid.is_none() {
471        diagnostics.push(Diagnostic::in_entry(entry, Tolerance::CommentWithoutGuid));
472    }
473    Comment {
474        guid,
475        date: owned(node.child_text("Date")),
476        author: owned(node.child_text("Author")),
477        comment: owned(node.child_text("Comment")),
478        // 2.0 writes <Viewpoint Guid="..."/>; 2.1+ keeps the same shape.
479        viewpoint: node
480            .child("Viewpoint")
481            .and_then(|v| owned(v.attr("Guid")))
482            .or_else(|| owned(node.child_text("Viewpoint"))),
483        modified_date: owned(node.child_text("ModifiedDate")),
484        modified_author: owned(node.child_text("ModifiedAuthor")),
485    }
486}
487
488/// `xs:boolean` accepts `true`/`false`/`1`/`0`. Writers in the field also emit
489/// `True`/`False`, so matching is case-insensitive.
490fn parse_bool(raw: &str) -> Option<bool> {
491    match raw.trim().to_ascii_lowercase().as_str() {
492        "true" | "1" => Some(true),
493        "false" | "0" => Some(false),
494        _ => None,
495    }
496}
497
498#[cfg(test)]
499mod tests {
500    use super::*;
501    use crate::xml::parse;
502
503    fn tree(src: &str) -> Node {
504        parse(src.as_bytes()).unwrap()
505    }
506
507    #[test]
508    fn detects_2_0_from_a_comment_back_reference() {
509        let t = tree(
510            r#"<Markup><Topic Guid="a"/><Comment Guid="c"><Topic Guid="a"/></Comment></Markup>"#,
511        );
512        assert_eq!(observe_version(&t), Some(BcfVersion::V2_0));
513    }
514
515    /// The other three 2.0-only `Comment` members are equally decisive.
516    #[test]
517    fn detects_2_0_from_comment_only_members() {
518        for child in [
519            "<Status>Open</Status>",
520            "<VerbalStatus>Open</VerbalStatus>",
521            r#"<ReplyToComment Guid="x"/>"#,
522        ] {
523            let t = tree(&format!(
524                r#"<Markup><Topic Guid="a" TopicStatus="Open"/><Comment Guid="c">{child}</Comment></Markup>"#
525            ));
526            assert_eq!(observe_version(&t), Some(BcfVersion::V2_0), "for {child}");
527        }
528    }
529
530    /// Regression against a myth: `TopicStatus`/`TopicType` are `Topic`
531    /// **attributes** in BCF 2.0 and 2.1 alike — verified against both official
532    /// `markup.xsd` files. Treating the attribute form as 2.1-only reported
533    /// three of buildingSMART's own v2.0 test cases as version conflicts.
534    #[test]
535    fn attribute_status_is_not_evidence_against_2_0() {
536        let t = tree(
537            r#"<Markup><Topic Guid="a" TopicType="Information" TopicStatus="Open"><Title>T</Title></Topic></Markup>"#,
538        );
539        assert_eq!(observe_version(&t), None);
540        // A file declaring 2.0 must therefore stay Declared, not Conflict.
541        assert_eq!(
542            BcfVersion::reconcile(Some(BcfVersion::V2_0), observe_version(&t)),
543            openbim_core::Detected::Declared(BcfVersion::V2_0)
544        );
545    }
546
547    /// `DocumentReferences` exists in 2.0 as well; only the 3.0 wrapper shape
548    /// (a `DocumentReference` child) is evidence.
549    #[test]
550    fn a_2_0_document_references_element_is_not_a_3_0_wrapper() {
551        let t = tree(
552            r#"<Markup><Topic Guid="a" TopicStatus="Open"><DocumentReferences Guid="d"><ReferencedDocument>a.pdf</ReferencedDocument></DocumentReferences></Topic></Markup>"#,
553        );
554        assert_eq!(observe_version(&t), None);
555    }
556
557    #[test]
558    fn detects_3_0_from_viewpoints_wrapper_or_server_id() {
559        let wrapped = tree(
560            r#"<Markup><Topic Guid="a" TopicStatus="Open"/><Viewpoints><ViewPoint Guid="v"/></Viewpoints></Markup>"#,
561        );
562        assert_eq!(observe_version(&wrapped), Some(BcfVersion::V3_0));
563
564        let server = tree(r#"<Markup><Topic Guid="a" ServerAssignedId="7"/></Markup>"#);
565        assert_eq!(observe_version(&server), Some(BcfVersion::V3_0));
566    }
567
568    /// Regression: BCF 2.1's `<Viewpoints>` *is* the viewpoint, while 3.0's is
569    /// a wrapper around `<ViewPoint>`. Keying detection on the element name
570    /// alone misread every 2.1 file with a viewpoint as 3.0 — which then
571    /// manufactured a spurious version conflict.
572    #[test]
573    fn a_2_x_viewpoints_element_is_not_a_3_0_wrapper() {
574        let t = tree(
575            r#"<Markup><Topic Guid="a" TopicStatus="Open"/><Viewpoints Guid="v"><Viewpoint>v.bcfv</Viewpoint><Snapshot>s.png</Snapshot></Viewpoints></Markup>"#,
576        );
577        // 2.0 and 2.1 spell this identically, so no version is observable.
578        assert_eq!(observe_version(&t), None);
579    }
580
581    /// Regression, caught on 4 official buildingSMART 3.0 test cases: 3.0 moves
582    /// `Comments`, `Viewpoints`, `Labels`, and `DocumentReferences` *inside*
583    /// `Topic`. Looking only at `Markup` children read those files as 2.1 and
584    /// reported a false conflict against their own `bcf.version`.
585    #[test]
586    fn detects_3_0_from_collections_nested_inside_topic() {
587        for child in [
588            "<Comments/>",
589            "<Comments><Comment Guid=\"c\"/></Comments>",
590            "<Viewpoints/>",
591            "<Viewpoints><ViewPoint Guid=\"v\"/></Viewpoints>",
592            "<Labels><Label>x</Label></Labels>",
593            "<DocumentReferences><DocumentReference Guid=\"d\"/></DocumentReferences>",
594            "<ReferenceLinks><ReferenceLink>http://x</ReferenceLink></ReferenceLinks>",
595        ] {
596            let t = tree(&format!(
597                r#"<Markup><Topic Guid="a" TopicStatus="OPEN" TopicType="ERROR"><Title>T</Title>{child}</Topic></Markup>"#
598            ));
599            assert_eq!(observe_version(&t), Some(BcfVersion::V3_0), "for {child}");
600        }
601    }
602
603    /// The 2.x `<Labels>text</Labels>` shape must not be mistaken for 3.0.
604    #[test]
605    fn bare_2_x_labels_are_not_evidence_of_3_0() {
606        let t = tree(
607            r#"<Markup><Topic Guid="a" TopicStatus="Open"><Labels>Architecture</Labels></Topic></Markup>"#,
608        );
609        assert_eq!(observe_version(&t), None);
610    }
611
612    #[test]
613    fn reads_comments_and_viewpoints_nested_in_a_3_0_topic() {
614        let mut d = Vec::new();
615        let m = interpret(
616            "e",
617            &tree(
618                r#"<Markup><Topic Guid="a"><Title>T</Title><Labels><Label>L1</Label><Label>L2</Label></Labels><Comments><Comment Guid="c1"><Comment>hello</Comment></Comment></Comments><Viewpoints><ViewPoint Guid="v1"><Viewpoint>v.bcfv</Viewpoint></ViewPoint></Viewpoints></Topic></Markup>"#,
619            ),
620            &mut d,
621        )
622        .unwrap();
623        assert_eq!(m.comments.len(), 1);
624        assert_eq!(m.comments[0].comment.as_deref(), Some("hello"));
625        assert_eq!(m.viewpoints.len(), 1);
626        assert_eq!(m.viewpoints[0].viewpoint.as_deref(), Some("v.bcfv"));
627        assert_eq!(m.topic.labels, ["L1", "L2"]);
628        assert!(d.is_empty(), "{d:?}");
629    }
630
631    /// An empty `<Viewpoints/>` in 3.0 means "none", not one blank viewpoint.
632    #[test]
633    fn an_empty_viewpoints_wrapper_yields_no_viewpoints() {
634        let mut d = Vec::new();
635        let m = interpret(
636            "e",
637            &tree(r#"<Markup><Topic Guid="a"><Title>T</Title><Viewpoints/><Comments/></Topic></Markup>"#),
638            &mut d,
639        )
640        .unwrap();
641        assert!(m.viewpoints.is_empty(), "{:?}", m.viewpoints);
642        assert!(m.comments.is_empty());
643    }
644
645    #[test]
646    fn indistinguishable_markup_observes_nothing() {
647        let t = tree(r#"<Markup><Topic Guid="a"><Title>x</Title></Topic></Markup>"#);
648        assert_eq!(observe_version(&t), None);
649        assert_eq!(observe_version(&tree("<Markup/>")), None);
650    }
651
652    #[test]
653    fn status_and_type_are_kept_verbatim_not_normalised() {
654        let mut d = Vec::new();
655        let t = tree(
656            r#"<Markup><Topic Guid="a" TopicStatus="Offen" TopicType="formale Prüfung"><Title>T</Title></Topic></Markup>"#,
657        );
658        let m = interpret("x/markup.bcf", &t, &mut d).unwrap();
659        assert_eq!(m.status(), Some("Offen"));
660        assert_eq!(m.topic.topic_type.as_deref(), Some("formale Prüfung"));
661        assert!(d.is_empty(), "{d:?}");
662    }
663
664    #[test]
665    fn missing_guid_and_title_are_diagnosed_but_not_fatal() {
666        let mut d = Vec::new();
667        let m = interpret("x/markup.bcf", &tree("<Markup><Topic/></Markup>"), &mut d).unwrap();
668        assert_eq!(m.topic.guid, None);
669        assert_eq!(m.title(), "");
670        let kinds: Vec<_> = d.iter().map(|x| x.tolerance.clone()).collect();
671        assert!(kinds.contains(&Tolerance::TopicWithoutGuid), "{kinds:?}");
672        assert!(kinds.contains(&Tolerance::TopicWithoutTitle), "{kinds:?}");
673    }
674
675    #[test]
676    fn markup_without_a_topic_yields_nothing_and_says_so() {
677        let mut d = Vec::new();
678        assert!(interpret("x", &tree("<Markup/>"), &mut d).is_none());
679        assert_eq!(d[0].tolerance, Tolerance::MarkupWithoutTopic);
680    }
681
682    #[test]
683    fn reads_both_wrapped_and_unwrapped_viewpoint_references() {
684        let mut d = Vec::new();
685        let v3 = interpret(
686            "e",
687            &tree(
688                r#"<Markup><Topic Guid="a"/><Viewpoints><ViewPoint Guid="v1"><Viewpoint>v1.bcfv</Viewpoint><Snapshot>s1.png</Snapshot><Index>2</Index></ViewPoint></Viewpoints></Markup>"#,
689            ),
690            &mut d,
691        )
692        .unwrap();
693        assert_eq!(v3.viewpoints.len(), 1);
694        assert_eq!(v3.viewpoints[0].guid.as_deref(), Some("v1"));
695        assert_eq!(v3.viewpoints[0].snapshot.as_deref(), Some("s1.png"));
696        assert_eq!(v3.viewpoints[0].index, Some(2));
697
698        let v2 = interpret(
699            "e",
700            &tree(
701                r#"<Markup><Topic Guid="a"/><Viewpoints Guid="v1"><Viewpoint>v1.bcfv</Viewpoint></Viewpoints><Viewpoints Guid="v2"><Viewpoint>v2.bcfv</Viewpoint></Viewpoints></Markup>"#,
702            ),
703            &mut d,
704        )
705        .unwrap();
706        assert_eq!(v2.viewpoints.len(), 2);
707        assert_eq!(v2.viewpoints[1].viewpoint.as_deref(), Some("v2.bcfv"));
708    }
709
710    #[test]
711    fn comments_keep_order_and_anchor_guid() {
712        let mut d = Vec::new();
713        let m = interpret(
714            "e",
715            &tree(
716                r#"<Markup><Topic Guid="a"/><Comment Guid="c1"><Comment>first</Comment><Viewpoint Guid="v1"/></Comment><Comment Guid="c2"><Comment>second</Comment></Comment></Markup>"#,
717            ),
718            &mut d,
719        )
720        .unwrap();
721        assert_eq!(m.comments.len(), 2);
722        assert_eq!(m.comments[0].comment.as_deref(), Some("first"));
723        assert_eq!(m.comments[0].viewpoint.as_deref(), Some("v1"));
724        assert_eq!(m.comments[1].comment.as_deref(), Some("second"));
725    }
726
727    #[test]
728    fn header_files_read_attributes_and_boolean_spellings() {
729        let mut d = Vec::new();
730        let m = interpret(
731            "e",
732            &tree(
733                r#"<Markup><Header><File IfcProject="p1" IsExternal="False"><Filename>a.ifc</Filename><Date>2015-04-14T15:51:25Z</Date></File><File IsExternal="1"><Filename>b.ifc</Filename></File></Header><Topic Guid="a"/></Markup>"#,
734            ),
735            &mut d,
736        )
737        .unwrap();
738        assert_eq!(m.header_files.len(), 2);
739        assert_eq!(m.header_files[0].ifc_project.as_deref(), Some("p1"));
740        assert_eq!(m.header_files[0].is_external, Some(false));
741        assert_eq!(
742            m.header_files[0].date.as_deref(),
743            Some("2015-04-14T15:51:25Z")
744        );
745        assert_eq!(m.header_files[1].is_external, Some(true));
746    }
747
748    #[test]
749    fn related_topics_and_labels_read_in_both_2_x_and_3_0_shapes() {
750        let mut d = Vec::new();
751        let m = interpret(
752            "e",
753            &tree(
754                r#"<Markup><Topic Guid="a"><Labels>L1</Labels><Labels>L2</Labels><RelatedTopic Guid="r1"/><RelatedTopics><RelatedTopic Guid="r2"/></RelatedTopics><ReferenceLinks><ReferenceLink>http://x/1</ReferenceLink></ReferenceLinks></Topic></Markup>"#,
755            ),
756            &mut d,
757        )
758        .unwrap();
759        assert_eq!(m.topic.labels, ["L1", "L2"]);
760        assert_eq!(m.topic.related_topics, ["r1", "r2"]);
761        assert_eq!(m.topic.reference_links, ["http://x/1"]);
762    }
763
764    #[test]
765    fn reads_a_2_1_and_3_0_component_selection() {
766        let v = interpret_visualization(&tree(
767            r#"<VisualizationInfo Guid="v"><Components><Selection><Component IfcGuid="0fXw$sQh19ixbI4tZgfkXu"><OriginatingSystem>Revit</OriginatingSystem><AuthoringToolId>42</AuthoringToolId></Component><Component IfcGuid="1Qj3z6Wtb5WwCLdW5ctWxe"/></Selection><Visibility DefaultVisibility="true"><Exceptions><Component IfcGuid="3cUkl32yn9qRSPvBJVyWYp"/></Exceptions></Visibility></Components></VisualizationInfo>"#,
768        ))
769        .unwrap();
770        assert_eq!(v.guid.as_deref(), Some("v"));
771        assert_eq!(
772            v.selection.len(),
773            2,
774            "visibility exceptions are not a selection"
775        );
776        assert_eq!(
777            v.selection[0],
778            Component {
779                ifc_guid: Some("0fXw$sQh19ixbI4tZgfkXu".into()),
780                originating_system: Some("Revit".into()),
781                authoring_tool_id: Some("42".into()),
782            }
783        );
784        assert_eq!(v.selection[1], Component::ifc("1Qj3z6Wtb5WwCLdW5ctWxe"));
785    }
786
787    /// 2.0 has no `Selection` wrapper: selected components are flagged.
788    #[test]
789    fn reads_a_2_0_selection_from_the_selected_flag() {
790        let v = interpret_visualization(&tree(
791            r#"<VisualizationInfo><Components><Component IfcGuid="0fXw$sQh19ixbI4tZgfkXu" Selected="true"/><Component IfcGuid="1Qj3z6Wtb5WwCLdW5ctWxe" Visible="false"/><Component IfcGuid="3cUkl32yn9qRSPvBJVyWYp" Selected="false"/></Components></VisualizationInfo>"#,
792        ))
793        .unwrap();
794        assert_eq!(v.selection, [Component::ifc("0fXw$sQh19ixbI4tZgfkXu")]);
795    }
796
797    #[test]
798    fn a_non_viewpoint_document_is_refused() {
799        assert!(interpret_visualization(&tree("<Markup/>")).is_err());
800    }
801
802    #[test]
803    fn boolean_parsing_rejects_nonsense_rather_than_defaulting() {
804        assert_eq!(parse_bool("TRUE"), Some(true));
805        assert_eq!(parse_bool(" 0 "), Some(false));
806        assert_eq!(parse_bool("yes"), None);
807        assert_eq!(parse_bool(""), None);
808    }
809}