Skip to main content

cadmpeg_codec_step/
codec.rs

1// SPDX-License-Identifier: Apache-2.0
2//! STEP codec backend and encoder.
3
4use cadmpeg_core::container::{ContainerRole, EntryStorage, VerbatimLabel};
5
6use std::collections::{btree_map::Entry, BTreeMap};
7use std::fmt;
8
9use cadmpeg_core::decode::DecodeDepth;
10use cadmpeg_core::dialect::{DialectLayers, DialectMatch};
11use cadmpeg_core::{CodecError, ContainerEntry};
12use cadmpeg_ir::codec::write::{
13    target::{Catalog, ResolvedWrite},
14    DepthNeed, EncodeInput, EncoderBackend, ExportBody,
15};
16use cadmpeg_ir::codec::{CodecBackend, Confidence, Decoded, FormatId};
17use cadmpeg_ir::report::loss::LossNote;
18use cadmpeg_ir::ContainerSummary;
19
20use crate::archive;
21use crate::dialect::refuse_alternate_encoding;
22use crate::options::{StepSchema, StepWriteOptions};
23use crate::parse;
24use crate::reader;
25
26/// STEP encoder with per-export header options.
27#[derive(Debug, Clone, Default)]
28pub struct StepCodec {
29    /// Header metadata and deterministic writer options.
30    pub options: StepWriteOptions,
31}
32
33impl EncoderBackend for StepCodec {
34    const FORMAT: FormatId = <Self as CodecBackend>::FORMAT;
35    /// Writes B-rep, product structure, presentation and PMI; design arenas are
36    /// reported as omitted, not read.
37    const NEEDS: DepthNeed = DepthNeed::AtLeast(DecodeDepth::Geometry);
38    type Target = Catalog;
39    const TARGET: Catalog = Catalog::new(StepSchema::TARGETS, Some(2));
40
41    /// Synthesis-only encoder. An off-catalog STEP source cannot be reproduced
42    /// because every emitted schema stamps object-identifier arcs.
43    fn plan_resolved(
44        &self,
45        input: EncodeInput<'_>,
46        target: ResolvedWrite<'_>,
47    ) -> Result<ExportBody, CodecError> {
48        crate::writer::target::plan(self, input, &target)
49    }
50}
51
52impl CodecBackend for StepCodec {
53    const FORMAT: FormatId = FormatId::new(crate::dialect::FORMAT);
54
55    fn detect_impl(
56        &self,
57        ctx: &cadmpeg_core::decode::DecodeContext<'_>,
58        prefix: cadmpeg_core::decode::View<'_>,
59    ) -> Result<Confidence, cadmpeg_core::CodecError> {
60        let view = prefix;
61        let prefix = prefix.window();
62
63        if starts_with_step_magic(prefix) {
64            return Ok(Confidence::High);
65        }
66        if archive::has_root_marker(ctx, view)? {
67            return Ok(Confidence::Medium);
68        }
69
70        if is_part26_hdf5(prefix) {
71            return Ok(Confidence::Medium);
72        }
73        if is_part28_xml(prefix) {
74            return Ok(Confidence::Medium);
75        }
76
77        if is_ap242_bo_model_xml(prefix) {
78            return Ok(Confidence::Medium);
79        }
80
81        Ok(if archive::has_zip_magic(prefix) {
82            Confidence::Low
83        } else {
84            Confidence::No
85        })
86    }
87
88    fn inspect_impl(
89        &self,
90        ctx: &cadmpeg_core::decode::DecodeContext<'_>,
91        root: cadmpeg_core::decode::View<'_>,
92    ) -> Result<ContainerSummary, CodecError> {
93        let bytes = root.window();
94        if archive::has_zip_magic(bytes)
95            || (!starts_with_step_magic(bytes)
96                && cadmpeg_container::ArchiveSnapshot::has_footer(bytes))
97        {
98            return inspect_zip(ctx, root);
99        }
100        let inspected = inspect_exchange(self, ctx, root)?;
101        Ok(ContainerSummary::classified(
102            DialectLayers::of(inspected.matched),
103            cadmpeg_ir::ContainerKind::Iso10303ClearText,
104            inspected.entries,
105            inspected.losses,
106            inspected.notes,
107        ))
108    }
109
110    fn decode_impl(
111        &self,
112        ctx: &cadmpeg_core::decode::DecodeContext<'_>,
113        root: cadmpeg_core::decode::View<'_>,
114    ) -> Result<Decoded, CodecError> {
115        let bytes = root.window();
116        if archive::has_zip_magic(bytes)
117            || (!starts_with_step_magic(bytes)
118                && cadmpeg_container::ArchiveSnapshot::has_footer(bytes))
119        {
120            return decode_zip(ctx, root);
121        }
122        refuse_alternate_encoding(bytes)?;
123        if self.detect_impl(ctx, root)? == Confidence::No {
124            return Err(CodecError::WrongFormat("missing ISO-10303-21 magic".into()));
125        }
126        reader::decode(bytes, ctx, reader::Packaging::Bare)
127    }
128}
129
130/// One Part 21 exchange as `inspect` sees it: its single classification and
131/// the container facts every packaging of it reports.
132struct InspectedExchange {
133    matched: DialectMatch,
134    entries: Vec<ContainerEntry>,
135    losses: Vec<LossNote>,
136    notes: Vec<String>,
137}
138
139struct CountItem<'a> {
140    name: &'a str,
141    count: usize,
142}
143
144impl fmt::Display for CountItem<'_> {
145    fn fmt(&self, output: &mut fmt::Formatter<'_>) -> fmt::Result {
146        write!(output, "{}:{}", self.name, self.count)
147    }
148}
149
150/// Deep semantic analysis of a STEP exchange, exposed as container inspect.
151///
152/// Runs the semantic decode path to populate `unknown_entities` and related
153/// attributes. Not a cheap syntactic census; see `docs/formats/step-inspect.md`.
154fn inspect_exchange(
155    codec: &StepCodec,
156    ctx: &cadmpeg_core::decode::DecodeContext<'_>,
157    root: cadmpeg_core::decode::View<'_>,
158) -> Result<InspectedExchange, CodecError> {
159    let bytes = root.window();
160    refuse_alternate_encoding(bytes)?;
161    if codec.detect_impl(ctx, root)? == Confidence::No {
162        return Err(CodecError::WrongFormat("missing ISO-10303-21 magic".into()));
163    }
164    let (mut exchange, diagnostics) = parse::parse(bytes, ctx)?;
165    inspect_parsed_exchange(bytes, ctx, &mut exchange, &diagnostics)
166}
167
168fn inspect_parsed_exchange(
169    bytes: &[u8],
170    ctx: &cadmpeg_core::decode::DecodeContext<'_>,
171    exchange: &mut parse::Exchange,
172    diagnostics: &[parse::ParseDiagnostic],
173) -> Result<InspectedExchange, CodecError> {
174    let reader::AnalyzedExchange {
175        decoded,
176        matched,
177        opaque_offsets,
178    } = reader::analyze_exchange(bytes, exchange, diagnostics, ctx)?;
179    let mut entries = Vec::new();
180    entries.push(ContainerEntry {
181        name: "HEADER".to_owned(),
182        role: ContainerRole::Metadata,
183        storage: EntryStorage::unreported(VerbatimLabel::None),
184        attributes: BTreeMap::default(),
185    });
186    if !exchange.anchors().is_empty() {
187        let mut attributes = std::collections::BTreeMap::new();
188        attributes.insert(
189            "anchor_count".to_owned(),
190            format!("{}", exchange.anchors().len()),
191        );
192        entries.push(ContainerEntry {
193            name: "ANCHOR".to_owned(),
194            role: ContainerRole::InFileAnchors,
195            storage: EntryStorage::unreported(VerbatimLabel::None),
196            attributes,
197        });
198    }
199    if !exchange.references().is_empty() {
200        let mut attributes = std::collections::BTreeMap::new();
201        attributes.insert(
202            "external_count".to_owned(),
203            format!("{}", exchange.references().len()),
204        );
205        attributes.insert(
206            "external_uris".to_owned(),
207            exchange
208                .references()
209                .iter()
210                .map(|entry| entry.uri.as_str())
211                .collect::<Vec<_>>()
212                .join(","),
213        );
214        entries.push(ContainerEntry {
215            name: "REFERENCE".to_owned(),
216            role: ContainerRole::ExternalReferences,
217            storage: EntryStorage::unreported(VerbatimLabel::None),
218            attributes,
219        });
220    }
221    let mut section_counts = ctx.alloc_filled(
222        exchange.data().len(),
223        (0_usize, BTreeMap::<&str, usize>::new()),
224        "step_inspect_data_counts",
225    )?;
226    for record in exchange.records().values() {
227        let index = exchange
228            .data()
229            .partition_point(|section| section.span.end <= record.span.start);
230        let (entity_count, counts) = section_counts
231            .get_mut(index)
232            .ok_or_else(|| CodecError::malformed("STEP instance has no DATA section"))?;
233        *entity_count += 1;
234        if !opaque_offsets.contains(&record.span.start) {
235            continue;
236        }
237        for partial in &record.partials {
238            let name = partial.name.as_str();
239
240            match counts.entry(name) {
241                Entry::Occupied(mut entry) => *entry.get_mut() += 1,
242                Entry::Vacant(entry) => {
243                    entry.insert(1);
244                }
245            }
246        }
247    }
248    for (index, (entity_count, counts)) in section_counts.into_iter().enumerate() {
249        let unknown = counts
250            .iter()
251            .map(|(&name, &count)| CountItem { name, count }.to_string())
252            .collect::<Vec<_>>()
253            .join(",");
254        let mut attributes = std::collections::BTreeMap::new();
255        attributes.insert("entity_count".to_owned(), format!("{entity_count}"));
256        attributes.insert("unknown_entities".to_owned(), unknown);
257        entries.push(ContainerEntry {
258            name: format!("DATA[{index}]"),
259            role: ContainerRole::EntityRecords,
260            storage: EntryStorage::unreported(VerbatimLabel::None),
261            attributes,
262        });
263    }
264    let external_dependencies = decoded.body.notes.iter().filter(|note| {
265        note.starts_with("external document ") || note.starts_with("external source ")
266    });
267    let dependency_count = external_dependencies.clone().count();
268    if dependency_count > 0 {
269        let mut attributes = std::collections::BTreeMap::new();
270        attributes.insert("dependency_count".to_owned(), format!("{dependency_count}"));
271        attributes.insert(
272            "dependencies".to_owned(),
273            external_dependencies
274                .map(String::as_str)
275                .collect::<Vec<_>>()
276                .join(","),
277        );
278        entries.push(ContainerEntry {
279            name: "EXTERNAL_DEPENDENCIES".to_owned(),
280            role: ContainerRole::ExternalReferences,
281            storage: EntryStorage::unreported(VerbatimLabel::None),
282            attributes,
283        });
284    }
285    for (index, signature) in exchange.signatures().iter().enumerate() {
286        entries.push(ContainerEntry {
287            name: if index == 0 {
288                "SIGNATURE".to_owned()
289            } else {
290                format!("SIGNATURE[{index}]")
291            },
292            role: ContainerRole::Signature,
293            storage: EntryStorage::verbatim(
294                VerbatimLabel::None,
295                cadmpeg_core::decode::u64_from_index(signature.len()),
296            ),
297            attributes: BTreeMap::default(),
298        });
299    }
300    let identifiers = exchange.joined_schema_identifiers();
301    let schema = if identifiers.is_empty() {
302        "unspecified".into()
303    } else {
304        identifiers
305    };
306    let dialect = matched.dialect();
307    let mut notes = Vec::new();
308    notes.push(format!("schema {schema}; dialect {dialect}"));
309    for diagnostic in diagnostics {
310        let note = diagnostic.message.clone();
311        notes.push(note);
312    }
313    Ok(InspectedExchange {
314        matched,
315        entries,
316        losses: decoded.body.losses,
317        notes,
318    })
319}
320
321fn starts_with_step_magic(bytes: &[u8]) -> bool {
322    starts_with_exchange_magic(bytes, b"ISO-10303-21;", false)
323        || draft_exchange_offset(bytes).is_some()
324}
325
326pub(crate) fn draft_exchange_offset(bytes: &[u8]) -> Option<usize> {
327    if starts_with_exchange_magic(bytes, b"STEP;", true) {
328        return Some(0);
329    }
330    // A leading identifier ends at a comment delimiter. A complete comment
331    // followed by the draft magic supplies a known exchange boundary.
332    let prefix_end = bytes
333        .iter()
334        .take_while(|byte| byte.is_ascii_alphanumeric() || **byte == b'_')
335        .count();
336    (bytes.first().is_some_and(u8::is_ascii_alphabetic)
337        && matches!(bytes.get(prefix_end..prefix_end + 2), Some(b"/*" | b"!*"))
338        && starts_with_exchange_magic(&bytes[prefix_end..], b"STEP;", true))
339    .then_some(prefix_end)
340}
341
342fn starts_with_exchange_magic(bytes: &[u8], magic: &[u8], draft: bool) -> bool {
343    let mut at = 0;
344    loop {
345        while bytes
346            .get(at)
347            .is_some_and(|byte| byte.is_ascii_control() || *byte == b' ')
348        {
349            at += 1;
350        }
351        if bytes.get(at..at + 2) == Some(b"/*") || (draft && bytes.get(at..at + 2) == Some(b"!*")) {
352            let close = if bytes[at] == b'/' { b"*/" } else { b"*!" };
353            at += 2;
354            let Some(relative_end) = bytes[at..].windows(2).position(|window| window == close)
355            else {
356                return false;
357            };
358            at += relative_end + 2;
359            continue;
360        }
361        if bytes
362            .get(at..at + 3)
363            .is_some_and(|prefix| prefix == b"\\N\\" || prefix == b"\\F\\")
364        {
365            at += 3;
366            continue;
367        }
368        break;
369    }
370    for &expected_byte in magic {
371        while bytes.get(at).is_some_and(u8::is_ascii_control) {
372            at += 1;
373        }
374        if !bytes
375            .get(at)
376            .is_some_and(|byte| byte.eq_ignore_ascii_case(&expected_byte))
377        {
378            return false;
379        }
380        at += 1;
381    }
382    true
383}
384
385fn inspect_zip(
386    ctx: &cadmpeg_core::decode::DecodeContext<'_>,
387    root: cadmpeg_core::decode::View<'_>,
388) -> Result<ContainerSummary, CodecError> {
389    let archive::OpenedRoot {
390        archive,
391        view: root_view,
392        data_start: root_data_offset,
393    } = archive::open_root(ctx, root)?;
394    let root_bytes = root_view.window();
395    refuse_alternate_encoding(root_bytes)?;
396    if StepCodec::default().detect_impl(ctx, root_view)? == Confidence::No {
397        return Err(CodecError::WrongFormat("missing ISO-10303-21 magic".into()));
398    }
399    let (mut exchange, diagnostics) = parse::parse(root_bytes, ctx)?;
400    let resource_notes = archive::root_reference_notes(&archive, &exchange);
401    let mut inspected = inspect_parsed_exchange(root_bytes, ctx, &mut exchange, &diagnostics)?;
402    let resource_notes = resource_notes?;
403    let entry_count = archive.entries().len();
404    let logical_entries = inspected
405        .entries
406        .iter()
407        .map(|entry| entry.name.as_str())
408        .collect::<Vec<_>>()
409        .join(",");
410    let mut entries = archive.container_entries(archive::classify_entry);
411    if let Some(root_entry) = entries
412        .iter_mut()
413        .find(|entry| entry.name == archive::ROOT_NAME)
414    {
415        root_entry
416            .attributes
417            .insert("logical_sections".to_owned(), logical_entries);
418    }
419    let mut notes = Vec::new();
420    notes.push(format!("root {}", archive::ROOT_NAME));
421    notes.push(format!(
422        "archive entries={entry_count}; root data offset={root_data_offset}"
423    ));
424    notes.extend(std::mem::take(&mut inspected.notes));
425    let mut losses = std::mem::take(&mut inspected.losses);
426    losses.extend(archive::recovery::losses(&archive));
427    notes.extend(resource_notes);
428    // ZIP packaging is a container fact, not an identity axis: the
429    // `ISO-10303.p21` root carries the FILE_SCHEMA that classifies the
430    // document, so the root's own match is this summary's match.
431    Ok(ContainerSummary::classified(
432        DialectLayers::of(inspected.matched),
433        cadmpeg_ir::ContainerKind::Iso10303Zip,
434        entries,
435        losses,
436        notes,
437    ))
438}
439
440fn decode_zip(
441    ctx: &cadmpeg_core::decode::DecodeContext<'_>,
442    root: cadmpeg_core::decode::View<'_>,
443) -> Result<Decoded, CodecError> {
444    let archive::OpenedRoot {
445        archive,
446        view: root_view,
447        data_start: root_data_offset,
448    } = archive::open_root(ctx, root)?;
449    let (exchange, diagnostics) = parse::parse(root_view.window(), ctx)?;
450    let resource_notes = archive::root_reference_notes(&archive, &exchange)?;
451    let entry_count = archive.entries().len();
452    let mut decoded = reader::decode_exchange(
453        root_view.window(),
454        exchange,
455        &diagnostics,
456        ctx,
457        reader::Packaging::Zip {
458            entry_count,
459            root_data_offset,
460        },
461    )?;
462    decoded.body.notes.push(format!(
463        "container root {}; archive entries={entry_count}",
464        archive::ROOT_NAME
465    ));
466    decoded.body.notes.extend(resource_notes);
467    decoded
468        .body
469        .losses
470        .extend(archive::recovery::losses(&archive));
471    archive::recovery::retain(ctx, &archive, root, &mut decoded)?;
472    Ok(decoded)
473}
474
475pub(crate) fn is_part26_hdf5(bytes: &[u8]) -> bool {
476    const SIGNATURE: &[u8] = b"\x89HDF\r\n\x1a\n";
477    if bytes.starts_with(SIGNATURE) {
478        return true;
479    }
480
481    let mut offset = 512;
482    while offset < bytes.len() {
483        if bytes[offset..].starts_with(SIGNATURE) {
484            return true;
485        }
486        let Some(next) = offset.checked_mul(2) else {
487            break;
488        };
489        offset = next;
490    }
491    false
492}
493
494pub(crate) fn is_part28_xml(bytes: &[u8]) -> bool {
495    let bytes = &bytes[..bytes.len().min(4096)];
496    let Some((name, attributes)) = xml_root_start_tag(bytes) else {
497        return false;
498    };
499    let local_name = name
500        .iter()
501        .rposition(|byte| *byte == b':')
502        .map_or(name, |separator| &name[separator + 1..]);
503    if local_name.eq_ignore_ascii_case(b"iso_10303_28")
504        || ascii_starts_with(local_name, b"iso_10303_28_")
505    {
506        return true;
507    }
508
509    // A configured UOS can use a local name other than the document marker.
510    // Its governing-schema namespace varies by AP, but the Part 28 common
511    // namespace remains the bounded admission marker. Schema selection and
512    // the derived XML Schema remain caller inputs.
513    PART28_COMMON_NAMESPACES
514        .iter()
515        .any(|namespace| has_namespace_value(attributes, namespace))
516}
517
518const PART28_COMMON_NAMESPACES: [&[u8]; 3] = [
519    b"urn:oid:1.0.10303.28.2.1.1",
520    b"urn:iso:std:iso:10303:-28:ed-2:tech:XMLschema:common",
521    b"urn:iso.org:standard:10303:part(28):version(2):xmlschema:common",
522];
523
524fn ascii_starts_with(value: &[u8], prefix: &[u8]) -> bool {
525    value.len() >= prefix.len()
526        && value[..prefix.len()]
527            .iter()
528            .zip(prefix)
529            .all(|(value, prefix)| value.eq_ignore_ascii_case(prefix))
530}
531
532fn xml_root_start_tag(bytes: &[u8]) -> Option<(&[u8], &[u8])> {
533    let mut cursor = if bytes.starts_with(b"\xef\xbb\xbf") {
534        3
535    } else {
536        0
537    };
538    loop {
539        while bytes.get(cursor).is_some_and(u8::is_ascii_whitespace) {
540            cursor += 1;
541        }
542        if bytes.get(cursor) != Some(&b'<') {
543            return None;
544        }
545        if bytes.get(cursor + 1) == Some(&b'?') {
546            let end = bytes
547                .get(cursor + 2..)?
548                .windows(2)
549                .position(|window| window == b"?>")?
550                + cursor
551                + 2;
552            cursor = end + 2;
553            continue;
554        }
555        if bytes.get(cursor + 1..cursor + 4) == Some(b"!--") {
556            let end = bytes
557                .get(cursor + 4..)?
558                .windows(3)
559                .position(|window| window == b"-->")?
560                + cursor
561                + 4;
562            cursor = end + 3;
563            continue;
564        }
565        if bytes.get(cursor + 1) == Some(&b'!') {
566            let end = bytes
567                .get(cursor + 2..)?
568                .iter()
569                .position(|byte| *byte == b'>')?
570                + cursor
571                + 2;
572            cursor = end + 1;
573            continue;
574        }
575        break;
576    }
577
578    let tag_end = find_xml_tag_end(bytes, cursor + 1)?;
579    let mut name_end = cursor + 1;
580    while bytes
581        .get(name_end)
582        .is_some_and(|byte| !byte.is_ascii_whitespace() && *byte != b'/' && *byte != b'>')
583    {
584        name_end += 1;
585    }
586    if name_end == cursor + 1 {
587        return None;
588    }
589    Some((&bytes[cursor + 1..name_end], &bytes[name_end..tag_end]))
590}
591
592fn find_xml_tag_end(bytes: &[u8], mut cursor: usize) -> Option<usize> {
593    let mut quote = None;
594    while let Some(byte) = bytes.get(cursor).copied() {
595        match quote {
596            Some(delimiter) if byte == delimiter => quote = None,
597            Some(_) => {}
598            None if byte == b'\'' || byte == b'"' => quote = Some(byte),
599            None if byte == b'>' => return Some(cursor),
600            None => {}
601        }
602        cursor += 1;
603    }
604    None
605}
606
607fn has_namespace_value(attributes: &[u8], expected: &[u8]) -> bool {
608    xml_attribute_value(attributes, |name, value| {
609        (name == b"xmlns" || name.starts_with(b"xmlns:")) && value == expected
610    })
611    .is_some()
612}
613
614fn xml_attribute_value(
615    attributes: &[u8],
616    mut matches: impl FnMut(&[u8], &[u8]) -> bool,
617) -> Option<&[u8]> {
618    let mut cursor = 0;
619    while cursor < attributes.len() {
620        while attributes.get(cursor).is_some_and(u8::is_ascii_whitespace) {
621            cursor += 1;
622        }
623        if attributes.get(cursor).is_none_or(|byte| *byte == b'/') {
624            break;
625        }
626        let name_start = cursor;
627        while attributes
628            .get(cursor)
629            .is_some_and(|byte| !byte.is_ascii_whitespace() && *byte != b'=' && *byte != b'/')
630        {
631            cursor += 1;
632        }
633        let name = &attributes[name_start..cursor];
634        while attributes.get(cursor).is_some_and(u8::is_ascii_whitespace) {
635            cursor += 1;
636        }
637        if attributes.get(cursor) != Some(&b'=') {
638            return None;
639        }
640        cursor += 1;
641        while attributes.get(cursor).is_some_and(u8::is_ascii_whitespace) {
642            cursor += 1;
643        }
644        let delimiter = *attributes.get(cursor)?;
645        if delimiter != b'\'' && delimiter != b'"' {
646            return None;
647        }
648        cursor += 1;
649        let value_start = cursor;
650        while attributes
651            .get(cursor)
652            .is_some_and(|byte| *byte != delimiter)
653        {
654            cursor += 1;
655        }
656        let value = &attributes[value_start..cursor];
657        cursor += 1;
658        if matches(name, value) {
659            return Some(value);
660        }
661    }
662    None
663}
664
665pub(crate) fn is_ap242_bo_model_xml(bytes: &[u8]) -> bool {
666    let bytes = &bytes[..bytes.len().min(4096)];
667    let Some((name, attributes)) = xml_root_start_tag(bytes) else {
668        return false;
669    };
670    let local_name = name
671        .iter()
672        .rposition(|byte| *byte == b':')
673        .map_or(name, |separator| &name[separator + 1..]);
674    // BM-03: the published namespace must be bound on the Uos document
675    // element. Text, comments, schemaLocation values, and local names do not
676    // identify the alternate encoding.
677    local_name == b"Uos"
678        && BO_MODEL_NAMESPACES
679            .iter()
680            .any(|namespace| has_namespace_value(attributes, namespace))
681}
682
683const BO_MODEL_NAMESPACES: [&[u8]; 2] = [
684    b"http://standards.iso.org/iso/ts/10303/-3001/-ed-1/tech/xml-schema/bo_model",
685    b"http://standards.iso.org/iso/ts/10303/-3001/-ed-2/tech/xml-schema/bo_model",
686];
687
688#[cfg(test)]
689mod tests {
690    use std::io::Cursor;
691
692    use cadmpeg_core::decode::InspectOptions;
693
694    use cadmpeg_ir::codec::{Codec, Confidence, DecodeOptions};
695
696    use super::{starts_with_step_magic, StepCodec};
697
698    #[test]
699    fn detects_magic_after_ignored_controls_and_inside_token() {
700        let source = b"\0 /* leading comment */ \\N\\ ISO-10303-\n21;HEADER;FILE_DESCRIPTION(('test'),'2;1');FILE_NAME('','',(''),(''),'','','');FILE_SCHEMA(('AP242'));ENDSEC;DATA;#1=ITEM();ENDSEC;END-ISO-10303-21;";
701        let codec = StepCodec::default();
702
703        assert!(starts_with_step_magic(source));
704        assert_eq!(
705            cadmpeg_test_support::detection::confidence(&codec, source),
706            Confidence::High
707        );
708        codec
709            .decode(&mut Cursor::new(source), &DecodeOptions::default())
710            .expect("decode Part 21 with ignored framing octets");
711
712        let with_bom = [b"\xEF\xBB\xBF".as_slice(), source].concat();
713        assert_eq!(
714            cadmpeg_test_support::detection::confidence(&codec, &with_bom),
715            Confidence::No
716        );
717        assert!(!starts_with_step_magic(b"/* incomplete ISO-10303-21;"));
718    }
719
720    #[test]
721    fn inspect_accepts_noncanonical_complex_partial_order_and_reports_a_note() {
722        let bytes = include_bytes!("../tests/fixtures/noncanonical_solid_angle.p21");
723        let summary = StepCodec::default()
724            .inspect(&mut Cursor::new(bytes), &InspectOptions::default())
725            .expect("inspection describes recoverable source order");
726
727        assert!(summary
728            .notes
729            .iter()
730            .any(|note| note.contains("complex partial records are not alphabetical")));
731    }
732    #[test]
733    fn inspection_counts_data_populations_by_source_extent() {
734        let source = b"ISO-10303-21;HEADER;FILE_DESCRIPTION(('test'),'4;3');FILE_NAME('','',(''),(''),'','','');FILE_SCHEMA(('AP242'));ENDSEC;DATA('first',('AP242'));#90=FIRST_ITEM();#3=SECOND_ITEM(#90);ENDSEC;DATA('second',('AP242'));#1=THIRD_ITEM();ENDSEC;END-ISO-10303-21;";
735        let summary = StepCodec::default()
736            .inspect(&mut Cursor::new(source), &InspectOptions::default())
737            .expect("named DATA sections retain their own populations");
738        let first = summary
739            .entries
740            .iter()
741            .find(|entry| entry.name == "DATA[0]")
742            .expect("first population");
743        let second = summary
744            .entries
745            .iter()
746            .find(|entry| entry.name == "DATA[1]")
747            .expect("second population");
748        assert_eq!(first.attributes["entity_count"], "2");
749        assert_eq!(
750            first.attributes["unknown_entities"],
751            "FIRST_ITEM:1,SECOND_ITEM:1"
752        );
753        assert_eq!(second.attributes["entity_count"], "1");
754        assert_eq!(second.attributes["unknown_entities"], "THIRD_ITEM:1");
755    }
756}