1use 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#[derive(Debug, Clone, Default)]
28pub struct StepCodec {
29 pub options: StepWriteOptions,
31}
32
33impl EncoderBackend for StepCodec {
34 const FORMAT: FormatId = <Self as CodecBackend>::FORMAT;
35 const NEEDS: DepthNeed = DepthNeed::AtLeast(DecodeDepth::Geometry);
38 type Target = Catalog;
39 const TARGET: Catalog = Catalog::new(StepSchema::TARGETS, Some(2));
40
41 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
130struct 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
150fn 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 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 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 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 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}