#![forbid(unsafe_code)] //! A deterministic, resolver-independent LDraw syntax and semantic core. mod adapter; mod bfc; mod cache; mod geom; mod model; mod mpd; mod parser; mod scanner; mod semantic; mod texmap; mod traversal; mod types; pub use adapter::*; pub use bfc::*; pub use cache::*; pub use geom::*; pub use model::*; pub use mpd::*; pub use parser::*; pub use scanner::*; pub use semantic::*; pub use texmap::*; pub use types::*; #[cfg(test)] mod tests { use super::*; fn options<'a>() -> ParseOptions<'a> { ParseOptions::new() } #[derive(Default)] struct RecordingSink { geometry: std::collections::HashMap>, } impl SemanticSink for RecordingSink { fn on_event(&mut self, event: &SemanticEvent<'_>, _context: &TraversalContext) { let SemanticEvent::GeometryEmitted { primitive_id, source, span, line_type, colour, vertices, bfc, texmap, inverted, } = event else { return; }; self.geometry .entry(source.clone()) .or_default() .push(GeometryRecord { primitive_id: *primitive_id, source: source.clone(), span: *span, line_type: *line_type, colour: *colour, vertices: vertices.to_vec(), bfc: *bfc, texmap: texmap.cloned(), inverted: *inverted, }); } } #[derive(Debug)] struct TestParseResult { result: OwnedParseResult, geometry: std::collections::HashMap>, } impl std::ops::Deref for TestParseResult { type Target = OwnedParseResult; fn deref(&self) -> &Self::Target { &self.result } } trait LdrawParserTestExt { fn parse_bytes<'src, 'a>( &self, bytes: &'src [u8], options: ParseOptions<'a>, materializations: &[Materialization], ) -> Result; } impl LdrawParserTestExt for LdrawParser { fn parse_bytes<'src, 'a>( &self, bytes: &'src [u8], options: ParseOptions<'a>, materializations: &[Materialization], ) -> Result { let mut sink = RecordingSink::default(); let result = self.parse_bytes_with_sink(bytes, options, materializations, &mut sink)?; Ok(TestParseResult { result, geometry: sink.geometry, }) } } fn adapter_parse<'src>(request: AdapterRequest<'src>) -> Result { let mut sink = RecordingSink::default(); let result = InProcessRustAdapter::parse_with_limits_and_sink( request, LdrawLimits::default(), &mut sink, )?; Ok(TestParseResult { result, geometry: sink.geometry, }) } fn adapter_parse_fixture<'a>( fixture_id: &'a str, bytes: &'a [u8], profile: ParserProfile, ) -> Result { adapter_parse(AdapterRequest { bytes, profile, provenance_id: fixture_id, root_name: fixture_id, root: RootId::UploadedManifest, materializations: &[], target_selections: &[], cancellation: CancellationPolicy::default(), }) } #[test] fn normalized_paths_reject_rooted_include_forms() { for path in [ "/parts/a.dat", "\\\\server\\share\\a.dat", "C:/parts/a.dat", "z:\\parts\\a.dat", ] { assert!( matches!(NormalizedPath::new(path), Err(ResolverPathError::Absolute)), "path should be rejected: {path}" ); } assert_eq!( NormalizedPath::new("parts/./a.dat").unwrap().as_str(), "parts/a.dat" ); } #[test] fn cancellation_zero_targets_next_checkpoint_and_default_is_disabled() { let cancellation = CancellationPolicy::default(); assert!(!cancellation.checkpoint()); cancellation.cancel_after_checkpoints(0); assert!(cancellation.checkpoint()); let cancellation = CancellationPolicy::default(); cancellation.cancel_after_checkpoints(2); assert!(!cancellation.checkpoint()); assert!(cancellation.checkpoint()); } #[test] fn scanner_preserves_case_quotes_and_endings() { let lines = scan_lines( b"0 STEP\r\n0 step\n0 \"quoted identifier\"", &LdrawLimits::default(), ) .unwrap(); assert_eq!(lines[0].tokens[1].kind, TokenKind::Step); assert_eq!(lines[1].tokens[1].kind, TokenKind::Identifier); let rotstep = scan_lines(b"0 ROTSTEP 90 0 0", &LdrawLimits::default()).unwrap(); assert_eq!(rotstep[0].tokens[1].kind, TokenKind::Rotstep); assert_eq!(lines[2].tokens[1].kind, TokenKind::QuotedIdentifier); assert_eq!(lines[0].ending, LineEnding::CrLf); } #[test] fn mpd_discards_outside_file_content() { let lines = scan_lines( b"garbage\n0 FILE root.ldr\n3 16 0 0 0 1 0 0 0 1 0\n0 NOFILE\nignored", &LdrawLimits::default(), ) .unwrap(); let files = split_mpd(&lines, &LdrawLimits::default()).unwrap(); assert_eq!(files.len(), 1); assert_eq!(files[0].lines.len(), 1); } #[test] fn unterminated_quotes_are_rejected() { let error = scan_lines(b"0 FILE \"root.ldr\n", &LdrawLimits::default()) .expect_err("unterminated quote must not become a token"); assert!(matches!( error, LdrawError::Diagnostic(Diagnostic { message, .. }) if message.contains("unterminated quoted token") )); } #[test] fn line_limit_is_per_physical_line() { let limits = LdrawLimits { line_bytes: 3, ..LdrawLimits::default() }; assert!(scan_lines(b"abc\ndef", &limits).is_ok()); assert!(scan_lines(b"abcd\n", &limits).is_err()); } #[test] fn invalid_geometry_colour_does_not_publish_geometry_or_counts() { let mut parse_options = options(); parse_options.profile = ParserProfile::Compatibility; let result = LdrawParser .parse_bytes( b"3 invalid 0 0 0 1 0 0 0 1 0\n3 16 0 0 0 1 0 0 0 1 0\n", parse_options, &[], ) .expect("compatibility profile should continue after invalid colour"); assert_eq!(event_geometry(&result.geometry, &result.models[0]).len(), 1); assert_eq!(result.models[0].triangles, 1); assert_eq!(result.models[0].quads, 0); } #[test] fn caller_materialization_controls_external_model_identity() { let bytes = b"0 FILE root.ldr\n1 16 0 0 0 1 0 0 0 1 0 0 0 0 child.dat\n0 NOFILE\n"; let child = b"3 16 0 0 0 1 0 0 0 1 0\n"; let child_path = NormalizedPath::new("child.dat").unwrap(); let _materialization_key = CacheKey::new(child_path.clone(), RootId::OfficialLibrary, child); let owned_materialization = Materialization::new( child_path.clone(), RootId::OfficialLibrary, child.to_vec(), true, Some("caller-materialization".into()), ); let result = adapter_parse(AdapterRequest { bytes, profile: ParserProfile::Compatibility, provenance_id: "caller-provenance", root_name: "root.ldr", root: RootId::UploadedManifest, materializations: std::slice::from_ref(&owned_materialization), target_selections: &[], cancellation: CancellationPolicy::default(), }) .unwrap(); assert!(result.models.iter().any(|model| model.path == "child.dat")); assert_eq!(result.provenance_id, "caller-provenance"); } #[test] fn semantic_events_are_emitted_and_include_context() { let bytes = b"0 FILE root.ldr\n1 4 1 2 3 1 0 0 0 1 0 0 0 1 child.dat\n3 16 0 0 0 1 0 0 0 1 0\n0 STEP\n"; let child = b"3 16 0 0 0 1 0 0 0 1 0\n"; let child_path = NormalizedPath::new("child.dat").unwrap(); let materialization = Materialization::new( child_path, RootId::UploadedLdraw, child.to_vec(), true, None, ); #[derive(Default)] struct RecordingSink { geometry: usize, includes: Vec<(Transform, Option)>, } impl SemanticSink for RecordingSink { fn on_event(&mut self, event: &SemanticEvent<'_>, context: &TraversalContext) { match event { SemanticEvent::GeometryEmitted { .. } => self.geometry += 1, SemanticEvent::IncludeInstantiated { transform, .. } => { self.includes.push((*transform, context.source_span)) } _ => {} } } } let mut sink = RecordingSink::default(); LdrawParser .parse_bytes_with_sink(bytes, options(), &[materialization], &mut sink) .expect("semantic event fixture should parse"); assert_eq!(sink.geometry, 2); assert_eq!(sink.includes.len(), 1); let include = sink .includes .first() .copied() .expect("include event should be present"); assert_eq!(include.0.translation, Point3::new(1.0, 2.0, 3.0)); assert!(include.1.is_some()); } #[test] fn type1_instance_budget_accepts_many_instances() { const N: usize = 128; let bytes = (0..N) .map(|index| format!("1 16 0 0 0 1 0 0 0 1 0 0 0 0 missing-{index:03}.dat\n")) .collect::(); let parse_options = options(); let result = LdrawParser .parse_bytes(bytes.as_bytes(), parse_options, &[]) .expect("type-1 instances should parse within the semantic budget"); assert_eq!(result.models[0].includes.len(), N); } #[test] fn conditional_geometry_exposes_endpoints_and_controls() { let result = LdrawParser .parse_bytes(b"5 16 0 0 0 1 0 0 0 1 0 2 0 0\n", options(), &[]) .unwrap(); let model_geometry = event_geometry(&result.geometry, &result.models[0]); let geometry = &model_geometry[0]; let (endpoints, controls) = geometry .conditional_endpoints() .expect("type-5 geometry should expose its point roles"); assert_eq!(endpoints[0], Point3::new(0.0, 0.0, 0.0)); assert_eq!(endpoints[1], Point3::new(1.0, 0.0, 0.0)); assert_eq!(controls[0], Point3::new(0.0, 1.0, 0.0)); assert_eq!(controls[1], Point3::new(2.0, 0.0, 0.0)); let mut ordinary_line = geometry.clone(); ordinary_line.line_type = LineType::Two; ordinary_line.vertices.clear(); assert!(ordinary_line.conditional_endpoints().is_none()); } #[test] fn bfc_state_machine_certify_then_nocertify() { let fixture = corpus_fixture("bfc-directives"); let bytes = fixture.bytes; let nocertify = b"0 BFC NOCERTIFY\n"; let nocertify_start = bytes .windows(nocertify.len()) .position(|window| window == nocertify) .expect("BFC fixture NOCERTIFY line"); let certified = LdrawParser .parse_bytes(&bytes[..nocertify_start], options(), &[]) .expect("CERTIFY should certify the model before NOCERTIFY"); assert_eq!(certified.semantic.bfc_state, BfcState::Certified); let result = LdrawParser .parse_bytes(bytes, options(), &[]) .expect("complete BFC fixture should parse"); assert_eq!(result.semantic.bfc_state, BfcState::Uncertified); assert_eq!(result.scene.triangles, 2); assert_eq!(result.scene.lines, 0); assert_eq!(result.semantic.steps.len(), 1); assert!(result.scene.instance_ids.is_empty()); assert!( result .diagnostics .iter() .any(|diagnostic| diagnostic.code == DiagnosticCode::UnresolvedReference) ); } #[test] fn graph_ids_are_stable() { let bytes = b"0 FILE root.ldr\n1 16 0 0 0 1 0 0 0 1 0 0 0 0 child.dat\n0 NOFILE\n0 FILE child.dat\n3 16 0 0 0 1 0 0 0 1 0\n0 NOFILE\n"; let first = LdrawParser.parse_bytes(bytes, options(), &[]).unwrap(); assert_eq!(first.scene.model_id, "model-00000001"); assert_eq!(first.scene.instance_ids, vec!["instance-00000001"]); assert_eq!( first .models .iter() .map(|model| model.id.as_str()) .collect::>(), vec!["model-00000001", "model-00000002"] ); let second = LdrawParser.parse_bytes(bytes, options(), &[]).unwrap(); assert_eq!(second.scene.model_id, first.scene.model_id); assert_eq!(second.scene.instance_ids, first.scene.instance_ids); } #[test] fn repeated_instances_expand_subtrees() { let bytes = b"0 FILE root.ldr\n1 16 0 0 0 1 0 0 0 1 0 0 0 0 child.dat\n1 16 4 0 0 -1 0 0 0 1 0 0 0 1 child.dat\n0 NOFILE\n0 FILE child.dat\n1 16 0 0 0 1 0 0 0 1 0 0 0 0 grand.dat\n0 NOFILE\n0 FILE grand.dat\n3 16 0 0 0 1 0 0 0 1 0\n0 NOFILE\n"; let result = LdrawParser .parse_bytes(bytes, options(), &[]) .expect("repeated child instances should parse"); assert_eq!(result.scene.triangles, 2); assert_eq!( result.scene.instance_ids, vec![ "instance-00000001", "instance-00000002", "instance-00000003", "instance-00000004", ] ); assert_eq!(result.scene.instances.len(), 4); assert_eq!( result.models.len(), 3, "each cache key is materialized once" ); assert!(result.scene.reflection); assert_eq!( result.scene.bounds, Some(Bounds3 { min: Point3::new(0.0, 0.0, 0.0), max: Point3::new(4.0, 1.0, 0.0), }) ); assert!(!result.scene.instances[0].inverted); assert!(result.scene.instances[1].inverted); assert!(!result.scene.instances[2].inverted); assert!(result.scene.instances[3].inverted); } #[test] fn materialization_selection_preserves_exact_root_and_hash() { let bytes = b"0 FILE root.ldr\n1 16 0 0 0 1 0 0 0 1 0 0 0 0 child.dat\n0 NOFILE\n"; let child_path = NormalizedPath::new("child.dat").unwrap(); let wrong_root = b"2 16 0 0 0 1 0 0\n"; let selected = b"3 16 0 0 0 1 0 0 0 1 0\n"; let materializations = [ Materialization::new( child_path.clone(), RootId::UploadedManifest, wrong_root.to_vec(), true, Some("wrong-root".into()), ), Materialization::new( child_path.clone(), RootId::OfficialLibrary, selected.to_vec(), true, Some("selected-root".into()), ), ]; let mut parse_options = options(); parse_options.resolved_root = RootId::OfficialLibrary; let result = LdrawParser .parse_bytes(bytes, parse_options, &materializations) .expect("root-preferred materialization should parse"); assert_eq!(result.scene.triangles, 1); assert_eq!(result.scene.quads, 0); let selected_key = CacheKey::new(child_path, RootId::OfficialLibrary, selected); let child_summary = result .models .iter() .find(|model| model.path == "child.dat") .expect("selected child should be traversed"); assert_eq!(child_summary.cache_key, selected_key); assert_eq!( child_summary.cache_key.resolved_root, RootId::OfficialLibrary ); assert_eq!( child_summary.cache_key.content_hash, selected_key.content_hash ); } #[test] fn limits_reject_before_publication() { let mut o = options(); o.limits.triangles = 0; let error = match LdrawParser.parse_bytes(b"3 16 0 0 0 1 0 0 0 1 0\n", o, &[]) { Ok(_) => panic!("triangle limit should reject before publication"), Err(error) => error, }; assert!(matches!( error, LdrawError::Diagnostic(Diagnostic { code: DiagnosticCode::TrianglesLimit, .. }) )); } #[test] fn quad_geometry_counts_as_two_triangles_for_limits() { let mut parse_options = options(); parse_options.limits.triangles = 1; let error = LdrawParser .parse_bytes(b"4 16 0 0 0 1 0 0 0 1 0 0 0 1\n", parse_options, &[]) .expect_err("a quad must consume two triangle-limit units"); assert!(matches!( error, LdrawError::Diagnostic(Diagnostic { code: DiagnosticCode::TrianglesLimit, .. }) )); } #[test] fn repeated_instances_charge_only_rendered_geometry_against_limits() { let bytes = b"0 FILE root.ldr\n1 16 0 0 0 1 0 0 0 1 0 0 0 0 child.dat\n1 16 2 0 0 1 0 0 0 1 0 0 0 0 child.dat\n0 NOFILE\n0 FILE child.dat\n3 16 0 0 0 1 0 0 0 1 0\n0 NOFILE\n"; let mut passing_options = options(); passing_options.limits.triangles = 2; LdrawParser .parse_bytes(bytes, passing_options, &[]) .expect("two child occurrences render two triangles"); let mut failing_options = options(); failing_options.limits.triangles = 1; let error = LdrawParser .parse_bytes(bytes, failing_options, &[]) .expect_err("two rendered occurrences must exceed a one-triangle limit"); assert!(matches!( error, LdrawError::Diagnostic(Diagnostic { code: DiagnosticCode::TrianglesLimit, .. }) )); } #[test] fn colour_scope_commit_rollback_and_edge_lookup_are_transactional() { let mut scope = ColourScope::new(); let base = Colour { code: ColourCode(700), name: Some("base".into()), data: ColourData { r: 1, g: 2, b: 3, alpha: None, luminance: None, }, edge: ColourEdge::Data(ColourData { r: 4, g: 5, b: 6, alpha: None, luminance: None, }), finishes: Vec::new(), material: None, }; let base_slot = scope.record(base.clone()).expect("first local slot"); assert_eq!(base_slot, 512); assert!( scope.get(700_u16).is_none(), "active definitions are hidden" ); scope.commit(); assert_eq!(scope.get(700_u16).unwrap().data.r, 1); assert_eq!(scope.find(700_u16, true).unwrap().r, 4); let staged = Colour { code: ColourCode(701), ..base }; assert_eq!(scope.record(staged).unwrap(), 513); assert!( scope.get(700_u16).is_none(), "lookups stay isolated during a transaction" ); scope.rollback(); assert!(scope.get(701_u16).is_none()); let next = Colour { code: ColourCode(702), name: Some("next".into()), data: ColourData { r: 7, g: 8, b: 9, alpha: None, luminance: None, }, edge: ColourEdge::Code(ColourCode::from(700_u16)), finishes: Vec::new(), material: None, }; assert_eq!( scope.record(next).unwrap(), 513, "rollback must not leak a slot" ); scope.commit(); assert_eq!(scope.find(702_u16, true).unwrap().r, 1); } #[test] fn colour_scope_has_deterministic_512_slot_boundary_and_exhaustion() { let mut scope = ColourScope::new(); for index in 0..65_023_u16 { let colour = Colour { code: ColourCode(700_u32 + u32::from(index)), name: None, data: ColourData { r: 1, g: 2, b: 3, alpha: None, luminance: None, }, edge: ColourEdge::Code(ColourCode::from(16_u16)), finishes: Vec::new(), material: None, }; let expected_slot = 512_u32 + u32::from(index); assert_eq!(scope.record(colour).unwrap(), expected_slot as u16); } assert_eq!(scope.local_slot(700_u16), Some(512)); assert_eq!(scope.local_slot(ColourCode(65_722)), Some(65_534)); scope.commit(); assert!( scope .record(Colour { code: ColourCode(65_723), name: None, data: ColourData { r: 1, g: 2, b: 3, alpha: None, luminance: None, }, edge: ColourEdge::Code(ColourCode::from(16_u16)), finishes: Vec::new(), material: None, }) .is_err() ); let mut rolled_back = ColourScope::new(); rolled_back .record(Colour { code: ColourCode(900), name: None, data: ColourData { r: 1, g: 2, b: 3, alpha: None, luminance: None, }, edge: ColourEdge::Code(ColourCode::from(16_u16)), finishes: Vec::new(), material: None, }) .unwrap(); rolled_back.rollback(); assert_eq!( rolled_back .record(Colour { code: ColourCode(901), name: None, data: ColourData { r: 1, g: 2, b: 3, alpha: None, luminance: None, }, edge: ColourEdge::Code(ColourCode::from(16_u16)), finishes: Vec::new(), material: None, }) .unwrap(), 512 ); } #[test] fn same_root_hash_requires_and_honours_exact_selection() { let bytes = b"0 FILE root.ldr\n1 16 0 0 0 1 0 0 0 1 0 0 0 0 child.dat\n0 NOFILE\n"; let path = NormalizedPath::new("child.dat").unwrap(); let first = b"3 16 0 0 0 1 0 0 0 1 0\n"; let second = b"4 16 0 0 0 1 0 0 0 1 0 0 1 0\n"; let materializations = [ Materialization::new( path.clone(), RootId::OfficialLibrary, first.to_vec(), true, Some("first".into()), ), Materialization::new( path.clone(), RootId::OfficialLibrary, second.to_vec(), true, Some("second".into()), ), ]; let error = LdrawParser .parse_bytes(bytes, options(), &materializations) .expect_err("same-root candidates must not be ordered implicitly"); assert!(matches!( error, ParseError::Diagnostic(Diagnostic { code: DiagnosticCode::AmbiguousMaterialization, .. }) )); let source = CacheKey::new_from_parts( NormalizedPath::new("root.ldr").unwrap(), RootId::UploadedManifest, [b"1 16 0 0 0 1 0 0 0 1 0 0 0 0 child.dat\n".as_slice()], ); let selected_key = CacheKey::new(path, RootId::OfficialLibrary, second); let selection = TargetSelection { source, span: Span { start: 16, end: 54, line: 2, column: 1, }, target: selected_key.clone(), }; let mut parse_options = options(); parse_options.target_selections = std::slice::from_ref(&selection); let result = LdrawParser .parse_bytes(bytes, parse_options, &materializations) .expect("exact selected same-root target should parse"); assert_eq!(result.scene.instances[0].target_key, Some(selected_key)); assert_eq!(result.scene.quads, 1); } #[test] fn cancellation_unwinds_partial_graph() { let bytes = b"0 FILE root.ldr\n1 16 0 0 0 1 0 0 0 1 0 0 0 0 child.dat\n0 NOFILE\n0 FILE child.dat\n1 16 0 0 0 1 0 0 0 1 0 0 0 0 grand.dat\n0 NOFILE\n0 FILE grand.dat\n3 16 0 0 0 1 0 0 0 1 0\n0 NOFILE\n"; let cancellation = CancellationPolicy::new(); cancellation.cancel_after_checkpoints(1); let mut parse_options = options(); parse_options.cancellation = cancellation; assert!(matches!( LdrawParser.parse_bytes(bytes, parse_options, &[]), Err(ParseError::Cancelled) )); } #[test] fn include_occurrences_snapshot_parent_semantic_state() { let bytes = b"0 FILE root.ldr\n0 BFC CERTIFY CW\n0 !COLOUR Red CODE 4 VALUE #ff0000 EDGE #333333\n0 !TEXMAP START PLANAR 0 0 0 1 0 0 0 1 0 red.png\n1 16 0 0 0 1 0 0 0 1 0 0 0 0 child.dat\n0 !TEXMAP END\n0 !COLOUR Blue CODE 2 VALUE #0000ff EDGE #333333\n0 BFC NOCERTIFY\n1 16 2 0 0 1 0 0 0 1 0 0 0 0 child.dat\n0 NOFILE\n"; let child = Materialization::new( NormalizedPath::new("child.dat").unwrap(), RootId::OfficialLibrary, b"3 16 0 0 0 1 0 0 0 1 0\n".to_vec(), true, None, ); let result = adapter_parse(AdapterRequest { bytes, profile: ParserProfile::Compatibility, provenance_id: "occurrence-state", root_name: "root.ldr", root: RootId::UploadedManifest, materializations: std::slice::from_ref(&child), target_selections: &[], cancellation: CancellationPolicy::default(), }) .expect("occurrence state fixture should parse"); let instances = &result.scene.instances; assert_eq!(instances.len(), 2); assert_eq!(instances[0].colour, Some(ColourCode::from(4_u16))); assert_eq!(instances[1].colour, Some(ColourCode::from(2_u16))); assert_eq!(instances[0].texmap_state, Some(TexmapState::Active)); assert_eq!(instances[0].texmap_reference.as_deref(), Some("red.png")); assert_eq!(instances[1].texmap_state, Some(TexmapState::Inactive)); assert_ne!(instances[0].bfc, instances[1].bfc); assert_ne!(instances[0].transform, instances[1].transform); } #[test] fn corpus_fixtures_follow_declared_outcomes() { for fixture in polymodel_ldraw_testkit::inventory() { let profile = match fixture.expected.profile { polymodel_ldraw_testkit::Profile::Strict => ParserProfile::Strict, polymodel_ldraw_testkit::Profile::Compatibility => ParserProfile::Compatibility, polymodel_ldraw_testkit::Profile::Lossless => ParserProfile::Lossless, }; let result = adapter_parse_fixture(fixture.id, fixture.bytes, profile); match fixture.expected.outcome { polymodel_ldraw_testkit::ExpectedOutcome::Rejected => { assert_eq!( fixture.expected.oracle_expected.status, "error", "{} rejected fixture must have an oracle error projection", fixture.id ); let error = result.err().unwrap_or_else(|| { panic!("{} rejected fixture unexpectedly published", fixture.id) }); match error { LdrawError::Diagnostic(_) => {} other => panic!("{} rejected with unexpected error: {other}", fixture.id), } } polymodel_ldraw_testkit::ExpectedOutcome::Accepted | polymodel_ldraw_testkit::ExpectedOutcome::Preserved | polymodel_ldraw_testkit::ExpectedOutcome::Limited | polymodel_ldraw_testkit::ExpectedOutcome::Cancelled => { let parsed = result.unwrap_or_else(|error| { panic!("{} expected a published parse result: {error}", fixture.id) }); assert_eq!( parsed.provenance_id, fixture.id, "fixture provenance must survive adapter parsing" ); let projected = parsed.project(); assert_eq!(projected.schema_version, SCHEMA_VERSION); assert_eq!( projected.provenance_id, fixture.id, "{} provenance", fixture.id ); if fixture.expected.syntax_expected.is_empty() { assert!( !projected.syntax.is_empty(), "{} accepted fixture should produce syntax records", fixture.id ); } else { let actual_syntax = projected .syntax .iter() .map(|record| { serde_json::to_value(record).expect("syntax records serialize") }) .collect::>(); polymodel_ldraw_testkit::compare_syntax_records( fixture.id, &fixture.expected.syntax_expected, &actual_syntax, ) .unwrap_or_else(|error| panic!("{error}")); } let expected_policy = &fixture.expected.polymodel_policy_expected; let actual_events = serde_json::Value::Array( projected .semantic .texmap_events .iter() .map(|event| { serde_json::json!({ "kind": event.kind, "mode": event.mode, "parameters": event.parameters, "reference": event.reference, "state": event.state, }) }) .collect(), ); let actual_geometry = serde_json::Value::Array( projected .semantic .texmap_geometry .iter() .map(|geometry| { serde_json::json!({ "line_type": geometry.line_type, "association": geometry.association, }) }) .collect(), ); let actual_data = serde_json::to_value(&projected.semantic.data_payloads) .expect("DATA payloads should serialize"); if fixture.id.starts_with("pm82-") { assert_eq!( actual_events, serde_json::Value::Array(expected_policy.texmap_events.clone()), "{} TEXMAP events", fixture.id ); assert_eq!( actual_geometry, serde_json::Value::Array(expected_policy.texmap_geometry.clone()), "{} TEXMAP geometry", fixture.id ); assert_eq!( actual_data, serde_json::Value::Array(expected_policy.data_payloads.clone()), "{} DATA payloads", fixture.id ); assert_eq!( projected .semantic .limits .iter() .map(ToString::to_string) .collect::>(), expected_policy.limits, "{} policy limits", fixture.id ); } if fixture.expected.outcome == polymodel_ldraw_testkit::ExpectedOutcome::Cancelled { assert!( !parsed.diagnostics.is_empty(), "cancelled fixture must retain its diagnostic evidence" ); } } } } } #[test] fn syntax_projection_comparison_rejects_empty_and_mutated_records() { let fixture = corpus_fixture("syntax-types"); let parsed = adapter_parse_fixture(fixture.id, fixture.bytes, ParserProfile::Strict) .expect("syntax fixture should parse"); let projected = parsed.project(); let empty = Vec::new(); let error = polymodel_ldraw_testkit::compare_syntax_records( fixture.id, &fixture.expected.syntax_expected, &empty, ) .expect_err("empty candidate syntax projection must fail"); assert!(error.contains("record count mismatch")); let mut expected = fixture.expected.syntax_expected.clone(); expected[1].fields[1].push_str("-tampered"); let actual_syntax = projected .syntax .iter() .map(|record| serde_json::to_value(record).expect("syntax records serialize")) .collect::>(); let error = polymodel_ldraw_testkit::compare_syntax_records(fixture.id, &expected, &actual_syntax) .expect_err("mutated syntax field must fail"); assert!(error.contains("fields mismatch")); } #[test] fn syntax_comparison_rejects_non_string_fields() { let malformed = vec![serde_json::json!({ "line_type": 1u8, "raw_tail": " data", "fields": ["ok", 42, "bad"] })]; let expected = vec![polymodel_ldraw_testkit::ExpectedSyntaxRecord { line_type: 1u8, raw_tail: " data".to_owned(), fields: vec!["ok".to_owned(), "42".to_owned(), "bad".to_owned()], }]; let error = polymodel_ldraw_testkit::compare_syntax_records("test", &expected, &malformed) .expect_err("non-string field element must be rejected"); assert!(error.contains("not a string")); } fn corpus_fixture(id: &str) -> polymodel_ldraw_testkit::FixtureCase { polymodel_ldraw_testkit::inventory() .into_iter() .find(|fixture| fixture.id == id) .unwrap_or_else(|| panic!("missing corpus fixture {id}")) } fn assert_geometry(geometry: &[GeometryRecord], expected: &[(u8, u16, &[Point3])]) { const EPSILON: f64 = 1e-9; assert_eq!(geometry.len(), expected.len(), "geometry record count"); for (index, (record, (line_type, colour, vertices))) in geometry.iter().zip(expected).enumerate() { let line_type = LineType::from(*line_type); assert_eq!(record.line_type, line_type, "geometry[{index}] line type"); assert_eq!( record.colour, ColourCode::from(*colour), "geometry[{index}] colour" ); assert_eq!( record.vertices.len(), vertices.len(), "geometry[{index}] vertex count" ); for (vertex_index, (actual, expected)) in record.vertices.iter().zip(*vertices).enumerate() { for (axis, actual, expected) in [ ("x", actual.x, expected.x), ("y", actual.y, expected.y), ("z", actual.z, expected.z), ] { assert!( (actual - expected).abs() <= EPSILON, "geometry[{index}].vertices[{vertex_index}].{axis}: got {actual}, expected {expected}" ); } } } } fn event_geometry( geometry: &std::collections::HashMap>, model: &ModelSummary, ) -> Vec { geometry.get(&model.cache_key).cloned().unwrap_or_default() } fn assert_scene_counts( result: &ParseResult<'_>, triangles: u64, quads: u64, lines: u64, conditional_lines: u64, ) { assert_eq!(result.scene.triangles, triangles, "triangle count"); assert_eq!(result.scene.quads, quads, "quad count"); assert_eq!(result.scene.lines, lines, "line count"); assert_eq!( result.scene.conditional_lines, conditional_lines, "conditional line count" ); } fn assert_sticker_geometry(result: &TestParseResult) { let sticker = result .models .iter() .find(|model| model.path == "sticker.ldr") .expect("sticker sub-model"); let sticker_geometry = event_geometry(&result.geometry, sticker); assert_geometry( &sticker_geometry, &[( 4, 16, &[ Point3::new(-20.0, -0.25, 30.0), Point3::new(-20.0, -0.25, -30.0), Point3::new(20.0, -0.25, -30.0), Point3::new(20.0, -0.25, 30.0), ], )], ); for model in &result.models { if model.path != "sticker.ldr" { assert!( event_geometry(&result.geometry, model).is_empty(), "non-sticker model {} unexpectedly contains geometry", model.path ); } } } #[test] fn corpus_bfc_transitions_from_certified_to_uncertified() { let fixture = corpus_fixture("bfc-invertnext"); let result = adapter_parse_fixture(fixture.id, fixture.bytes, ParserProfile::Strict).unwrap(); assert_eq!(result.models.len(), 1); assert_eq!(result.models[0].bfc, BfcState::Uncertified); assert_eq!(result.semantic.bfc_state, BfcState::Uncertified); assert_eq!(result.scene.triangles, 0); } #[test] fn corpus_bfc_directives_have_oracle_geometry() { let fixture = corpus_fixture("bfc-directives"); let result = LdrawParser .parse_bytes(fixture.bytes, options(), &[]) .expect("BFC directives fixture should parse"); assert_scene_counts(&result, 2, 0, 0, 0); assert_geometry( &event_geometry(&result.geometry, &result.models[0]), &[ ( 3, 16, &[ Point3::new(0.0, 0.0, 0.0), Point3::new(1.0, 0.0, 0.0), Point3::new(0.0, 1.0, 0.0), ], ), ( 3, 16, &[ Point3::new(0.0, 0.0, 0.0), Point3::new(2.0, 0.0, 0.0), Point3::new(0.0, 2.0, 0.0), ], ), ], ); } #[test] fn corpus_mpd_contains_two_virtual_files() { let fixture = corpus_fixture("mpd-data"); let result = adapter_parse_fixture(fixture.id, fixture.bytes, ParserProfile::Strict).unwrap(); assert_eq!(result.files.len(), 3); assert_eq!( result .files .iter() .map(|file| file.name.as_str()) .collect::>(), vec!["main.ldr", "house.ldr", "sticker.ldr"] ); assert_scene_counts(&result, 0, 5, 0, 0); assert_sticker_geometry(&result); } #[test] fn literal_stop_is_not_rewritten_to_end() { let mut parse_options = options(); parse_options.profile = ParserProfile::Compatibility; let result = LdrawParser .parse_bytes( b"0 !TEXMAP START PLANAR 0 0 0 1 0 0 0 1 0 sticker.png\n0 !TEXMAP STOP\n", parse_options, &[], ) .expect("compatibility profile should preserve unknown STOP"); assert_eq!( result .semantic .texmap_events .iter() .map(|event| event.kind) .collect::>(), vec![TexmapKind::Start] ); assert!( result .diagnostics .iter() .any(|diagnostic| diagnostic.code == DiagnosticCode::TexmapUnknownMetaStop) ); } #[test] fn corpus_texmap_emits_ordered_events() { let fixture = corpus_fixture("texmap-events"); let bytes = fixture.bytes; let result = LdrawParser .parse_bytes(bytes, options(), &[]) .expect("TEXMAP fixture should parse"); assert_eq!(result.semantic.texmap_events.len(), 4); assert_eq!( result .semantic .texmap_events .iter() .map(|event| event.kind) .collect::>(), vec![ TexmapKind::Start, TexmapKind::Next, TexmapKind::Fallback, TexmapKind::End ] ); let start = &result.semantic.texmap_events[0]; assert_eq!(start.mode, Some(TexmapProjectionMode::Planar)); assert_eq!(start.reference.as_deref(), Some("sticker.png")); assert_scene_counts(&result, 2, 1, 0, 0); assert_geometry( &event_geometry(&result.geometry, &result.models[0]), &[ ( 3, 16, &[ Point3::new(0.0, 0.0, 0.0), Point3::new(10.0, 0.0, 0.0), Point3::new(0.0, 10.0, 0.0), ], ), ( 3, 16, &[ Point3::new(0.0, 0.0, 1.0), Point3::new(10.0, 0.0, 1.0), Point3::new(0.0, 10.0, 1.0), ], ), ( 4, 16, &[ Point3::new(0.0, 0.0, 0.0), Point3::new(10.0, 0.0, 0.0), Point3::new(10.0, 0.0, 10.0), Point3::new(10.0, 0.0, 0.0), ], ), ], ); } #[test] fn texmap_bang_colon_projects_geometry_ownership() { let cases = [ ("1 16 0 0 0 1 0 0 0 1 0 0 0 0 child.dat", LineType::One), ("2 16 0 0 0 10 0 0", LineType::Two), ("3 16 0 0 0 10 0 0 0 10 0", LineType::Three), ("4 16 0 0 0 10 0 0 0 10 0 0 0 10", LineType::Four), ("5 16 0 0 0 10 0 0 0 10 0 0 0 10", LineType::Five), ]; for (embedded, expected_type) in cases { let bytes = format!( "0 !TEXMAP START PLANAR 0 0 0 10 0 0 0 10 0 sticker.png\n0 !: {embedded}\n0 !TEXMAP END\n" ); let result = LdrawParser .parse_bytes(bytes.as_bytes(), options(), &[]) .expect("valid bang-colon geometry should parse"); let model_geometry = event_geometry(&result.geometry, &result.models[0]); assert_eq!( model_geometry.len(), usize::from(expected_type != LineType::One) ); if expected_type == LineType::One { assert_eq!(result.models[0].includes.len(), 1); assert!(result.scene.instances.is_empty()); assert!( result .diagnostics .iter() .any(|diagnostic| diagnostic.code == DiagnosticCode::UnresolvedReference) ); } else { let geometry = model_geometry.last().unwrap(); assert_eq!(geometry.line_type, expected_type); assert_eq!( geometry.texmap.as_ref().map(|value| value.pngfile.as_str()), Some("sticker.png") ); assert_eq!(result.semantic.texmap_geometry.len(), 1); assert_eq!(result.semantic.texmap_geometry[0].line_type, expected_type); } } let result = LdrawParser .parse_bytes( b"0 !TEXMAP START PLANAR 0 0 0 10 0 0 0 10 0 sticker.png\n0 !TEXMAP FALLBACK\n0 !: 3 16 0 0 0 10 0 0 0 10 0\n0 !TEXMAP END\n", options(), &[], ) .expect("fallback bang-colon geometry should parse"); let model_geometry = event_geometry(&result.geometry, &result.models[0]); assert!(model_geometry[0].texmap.as_ref().unwrap().fallback); } #[test] fn texmap_bang_colon_rejects_invalid_without_partial_geometry() { let inputs = [ b"0 !: 3 16 0 0 0 1 0 0 0 1 0\n".as_slice(), b"0 !TEXMAP START PLANAR 0 0 0 1 0 0 0 1 0 sticker.png\n0 !: 3 16 0 0 0\n0 !TEXMAP END\n".as_slice(), b"0 !TEXMAP START PLANAR 0 0 0 1 0 0 0 1 0 sticker.png\n0 !: 6 16 0 0 0\n0 !TEXMAP END\n".as_slice(), b"0 !TEXMAP START PLANAR 0 0 0 1 0 0 0 1 0 sticker.png\n0 !: 3 16 0 0 0 !: 1\n0 !TEXMAP END\n".as_slice(), ]; let expected = [ DiagnosticCode::TexmapBangColonOutOfScope, DiagnosticCode::TexmapInvalidBangColonGeometry, DiagnosticCode::TexmapInvalidBangColonGeometry, DiagnosticCode::TexmapNestedBangColon, ]; for (bytes, code) in inputs.into_iter().zip(expected) { let mut parse_options = options(); parse_options.profile = ParserProfile::Compatibility; let result = LdrawParser .parse_bytes(bytes, parse_options, &[]) .expect("compatibility should retain typed diagnostics"); assert!( result .diagnostics .iter() .any(|diagnostic| diagnostic.code == code) ); assert!(event_geometry(&result.geometry, &result.models[0]).is_empty()); } } #[test] fn texmap_next_requires_parameters_and_is_transactional() { let bare = b"0 !TEXMAP START PLANAR 0 0 0 1 0 0 0 1 0 sticker.png 0 !TEXMAP NEXT "; let mut parse_options = options(); parse_options.profile = ParserProfile::Compatibility; let result = LdrawParser .parse_bytes(bare, parse_options, &[]) .expect("compatibility should preserve bare NEXT diagnostic"); assert!( result .diagnostics .iter() .any(|diagnostic| diagnostic.code == DiagnosticCode::TexmapNextNested) ); assert_eq!(result.semantic.texmap_events.len(), 1); let nested = b"0 !TEXMAP START PLANAR 0 0 0 1 0 0 0 1 0 sticker.png 0 !TEXMAP NEXT "; let mut parse_options = options(); parse_options.profile = ParserProfile::Compatibility; let result = LdrawParser .parse_bytes(nested, parse_options, &[]) .expect("compatibility should preserve nested NEXT diagnostic"); assert!( result .diagnostics .iter() .any(|diagnostic| diagnostic.code == DiagnosticCode::TexmapNextNested) ); assert_eq!(result.semantic.texmap_events.len(), 1); assert_eq!( result.semantic.texmap_events[0].reference.as_deref(), Some("sticker.png") ); let nested_parameterized = b"0 !TEXMAP START PLANAR 0 0 0 1 0 0 0 1 0 sticker.png 0 !TEXMAP NEXT CYLINDRICAL 0 0 0 0 1 0 0 0 1 360 other.png "; let result = LdrawParser .parse_bytes(nested_parameterized, options(), &[]) .expect("parameterized nested NEXT should replace the active mapping"); assert!(result.diagnostics.is_empty()); assert_eq!(result.semantic.texmap_events.len(), 2); assert_eq!( result.semantic.texmap_events[1].reference.as_deref(), Some("other.png") ); for (method, parameters) in [ ("PLANAR", "0 0 0 1 0 0 0 1 0"), ("CYLINDRICAL", "0 0 0 0 1 0 0 0 1 360"), ("SPHERICAL", "0 0 0 0 1 0 0 0 1 360 180"), ] { let bytes = format!( "0 !TEXMAP NEXT {method} {parameters} next.png GLOSSMAP gloss.png\n3 16 0 0 0 10 0 0 0 10 0\n" ); let result = LdrawParser .parse_bytes(bytes.as_bytes(), options(), &[]) .expect("parameterized NEXT should parse"); assert_eq!(result.semantic.texmap_events[0].kind, TexmapKind::Next); assert_eq!( result.semantic.texmap_events[0].reference.as_deref(), Some("next.png") ); let model_geometry = event_geometry(&result.geometry, &result.models[0]); assert_eq!( model_geometry[0] .texmap .as_ref() .and_then(|value| value.glossmap.as_deref()), Some("gloss.png") ); assert_eq!( result.semantic.texmap_geometry[0] .association .glossmap .as_deref(), Some("gloss.png") ); } let mut parse_options = options(); parse_options.profile = ParserProfile::Compatibility; let result = LdrawParser .parse_bytes( b"0 !TEXMAP NEXT PLANAR 0 0 0 1 0 0 0 1 0 next.png\n3 invalid 0 0 0 10 0 0 0 10 0\n3 16 0 0 0 10 0 0 0 10 0\n", parse_options, &[], ) .expect("compatibility should recover a malformed NEXT geometry line"); assert!( result .diagnostics .iter() .any(|diagnostic| diagnostic.code == DiagnosticCode::InvalidColour) ); let model_geometry = event_geometry(&result.geometry, &result.models[0]); assert_eq!(model_geometry.len(), 1); assert_eq!( model_geometry[0] .texmap .as_ref() .map(|value| value.pngfile.as_str()), Some("next.png") ); } #[test] fn corpus_syntax_recognizes_all_line_types() { let fixture = corpus_fixture("syntax-all-types"); let bytes = fixture.bytes; let result = LdrawParser .parse_bytes(bytes, options(), &[]) .expect("all-types fixture should parse"); assert_eq!(result.syntax.len(), 6); assert_eq!( result .syntax .iter() .map(|line| line.line_type) .collect::>(), vec![ Some(LineType::Zero), Some(LineType::One), Some(LineType::Two), Some(LineType::Three), Some(LineType::Four), Some(LineType::Five), ] ); assert_scene_counts(&result, 1, 1, 1, 1); assert_geometry( &event_geometry(&result.geometry, &result.models[0]), &[ ( 2, 24, &[Point3::new(0.0, 0.0, 0.0), Point3::new(10.0, 0.0, 0.0)], ), ( 3, 4, &[ Point3::new(0.0, 0.0, 0.0), Point3::new(10.0, 0.0, 0.0), Point3::new(0.0, 10.0, 0.0), ], ), ( 4, 2, &[ Point3::new(0.0, 0.0, 0.0), Point3::new(10.0, 0.0, 0.0), Point3::new(10.0, 0.0, 10.0), Point3::new(10.0, 0.0, 0.0), ], ), ( 5, 16, &[ Point3::new(0.0, 0.0, 0.0), Point3::new(10.0, 0.0, 0.0), Point3::new(0.0, 10.0, 0.0), Point3::new(0.0, 10.0, 0.0), ], ), ], ); assert_eq!(result.models[0].includes.len(), 1); assert!( result .diagnostics .iter() .any(|diagnostic| diagnostic.code == DiagnosticCode::UnresolvedReference) ); } #[test] fn colour_finish_and_material_forms_are_typed_and_malformed_forms_reject() { let valid = [ ("CHROME", "finishes", ""), ("PEARLESCENT", "finishes", ""), ("RUBBER", "finishes", ""), ("MATTE_METALLIC", "finishes", ""), ("METAL", "finishes", ""), ( "MATERIAL GLITTER VALUE #010203 FRACTION .5 VFRACTION .25 SIZE 2", "material", "", ), ( "MATERIAL SPECKLE VALUE #010203 FRACTION .5 MINSIZE 1 MAXSIZE 2", "material", "", ), ("MATERIAL FABRIC VELVET", "material", ""), ("MATERIAL FABRIC", "material", ""), ]; for (suffix, _, _) in valid { let bytes = format!("0 !COLOUR Test CODE 700 VALUE #112233 EDGE #445566 {suffix}\n"); let result = LdrawParser .parse_bytes(bytes.as_bytes(), options(), &[]) .unwrap_or_else(|error| panic!("valid colour form {suffix:?} rejected: {error}")); let colour = result.colours.get(700_u16).unwrap(); assert!(colour.material.is_some() || !colour.finishes.is_empty()); match suffix { "MATERIAL GLITTER VALUE #010203 FRACTION .5 VFRACTION .25 SIZE 2" => { let material = colour.material.as_ref().unwrap(); assert_eq!(material.typed_kind, ColourMaterialKind::Glitter); assert!(material.vfraction.is_some()); assert!(material.size.is_some()); assert!(material.min_size.is_none()); assert!(material.max_size.is_none()); } "MATERIAL SPECKLE VALUE #010203 FRACTION .5 MINSIZE 1 MAXSIZE 2" => { let material = colour.material.as_ref().unwrap(); assert_eq!(material.typed_kind, ColourMaterialKind::Speckle); assert!(material.vfraction.is_none()); assert!(material.size.is_none()); assert!(material.min_size.is_some()); assert!(material.max_size.is_some()); } _ => {} } } for suffix in [ "CHROME METAL", "MATERIAL GLITTER VALUE #010203 FRACTION .5 SIZE 2", "MATERIAL SPECKLE VALUE #010203 FRACTION .5 VFRACTION .25 SIZE 2", "MATERIAL GLITTER VALUE #010203 FRACTION .5 VFRACTION .25 MINSIZE 1", "MATERIAL FABRIC UNKNOWN", ] { let bytes = format!("0 !COLOUR Test CODE 700 VALUE #112233 EDGE #445566 {suffix}\n"); let mut parse_options = options(); parse_options.profile = ParserProfile::Strict; let error = match LdrawParser.parse_bytes(bytes.as_bytes(), parse_options, &[]) { Ok(result) => { panic!("malformed material/finish {suffix:?} was accepted: {result:?}") } Err(error) => error, }; assert!(matches!( error, ParseError::Diagnostic(Diagnostic { code: DiagnosticCode::InvalidColour, .. }) )); } } #[test] fn data_payloads_decode_and_invalid_payloads_diagnose() { let result = LdrawParser .parse_bytes( b"0 !DATA sample.bin\n0 !: aGVsbG8=\n0 STEP\n", options(), &[], ) .expect("valid DATA should parse"); assert_eq!(result.semantic.data_payloads.len(), 1); assert_eq!(result.semantic.data_payloads[0].filename, "sample.bin"); assert_eq!(result.semantic.data_payloads[0].bytes, b"hello"); let error = match LdrawParser.parse_bytes(b"0 !DATA sample.bin\n", options(), &[]) { Ok(_) => panic!("truncated DATA should reject in strict mode"), Err(error) => error, }; assert!(matches!( error, LdrawError::Diagnostic(Diagnostic { code: DiagnosticCode::DataTruncated, .. }) )); } #[test] fn corpus_colours_registers_ldconfig_code_300() { let fixture = corpus_fixture("colour-definitions"); let bytes = fixture.bytes; let result = LdrawParser .parse_bytes(bytes, options(), &[]) .expect("colour definitions fixture should parse"); let bright_red = result.colours.get(300u16).expect("colour code 300"); assert_eq!(bright_red.data.r, 0xC9); assert_eq!(bright_red.data.g, 0x1A); assert_eq!(bright_red.data.b, 0x09); assert_eq!(bright_red.data.alpha, Some(128)); assert_eq!(bright_red.data.luminance, Some(50)); assert_eq!(bright_red.edge, ColourEdge::Code(ColourCode(0))); let edge_data = result.colours.get(301u16).expect("colour code 301"); assert_eq!(edge_data.data.r, 0x12); assert_eq!(edge_data.data.g, 0x34); assert_eq!(edge_data.data.b, 0x56); assert_eq!(edge_data.data.alpha, Some(200)); assert_eq!(edge_data.data.luminance, Some(7)); assert_eq!( edge_data.edge, ColourEdge::Data(ColourData { r: 0x65, g: 0x43, b: 0x21, alpha: None, luminance: None, }) ); assert_eq!(result.semantic.colour_state, crate::ColourCode(301)); assert_scene_counts(&result, 2, 0, 0, 0); assert_geometry( &event_geometry(&result.geometry, &result.models[0]), &[ ( 3, 300, &[ Point3::new(0.0, 0.0, 0.0), Point3::new(10.0, 0.0, 0.0), Point3::new(0.0, 10.0, 0.0), ], ), ( 3, 301, &[ Point3::new(0.0, 0.0, 1.0), Point3::new(10.0, 0.0, 1.0), Point3::new(0.0, 10.0, 1.0), ], ), ], ); } #[test] fn external_ldr_tools_step_ends_match_oracle() { let fixture = corpus_fixture("external-ldr-tools"); let result = adapter_parse_fixture(fixture.id, fixture.bytes, ParserProfile::Compatibility) .expect("external-ldr-tools should parse"); let oracle = &fixture.expected.oracle_expected; assert_eq!(result.models.len(), oracle.models.len()); let root = oracle.root_model.as_ref().expect("oracle root model"); assert_eq!(result.models[0].step_ends, root.step_ends); for expected in &oracle.models { let actual = result .models .iter() .find(|model| model.path.eq_ignore_ascii_case(&expected.name)) .unwrap_or_else(|| panic!("missing model {}", expected.name)); assert_eq!( actual.step_ends, expected.step_ends, "{} actual={:?} expected={:?}", expected.name, actual.step_ends, expected.step_ends ); } let submodels_total: usize = oracle .models .iter() .skip(1) .map(|model| model.step_ends.len()) .sum(); assert_eq!(root.step_ends.len(), 52); assert_eq!(submodels_total, 57); assert_eq!(root.step_ends.len() + submodels_total, 109); } #[test] fn real_models_match_oracle_ground_truth() { let cases: &[(&str, &str, usize, usize, usize, u64, u64)] = &[ // (fixture_id, oracle_root, file_count, min_includes, min_steps, triangles, quads) // Oracle: tri=N quad=M → rust triangles=N, quads=M ("graph-instances", "ViperMain.ldr", 9, 250, 0, 0, 0), ("external-thomas", "ViperMain.ldr", 9, 250, 0, 0, 0), ("external-weldr", "main.ldr", 3, 5, 0, 0, 5), ( "external-ldr-tools", "hatching-grounds.ldr", 17, 553, 12, 0, 0, ), ("mpd-data", "main.ldr", 3, 7, 0, 0, 5), ]; for ( fixture_id, oracle_root, expected_files, expected_includes, expected_steps, expected_tri, expected_quads, ) in cases { let fixture = corpus_fixture(fixture_id); let result = adapter_parse_fixture(fixture.id, fixture.bytes, ParserProfile::Compatibility) .unwrap_or_else(|e| panic!("{fixture_id} should parse: {e}")); let target = result .semantic .target_identity .as_ref() .map(|s| s.as_ref().to_ascii_lowercase()) .unwrap_or_default(); assert!( target.contains(&oracle_root.to_ascii_lowercase()) || oracle_root.to_ascii_lowercase().contains(&target) || target.is_empty(), "{fixture_id}: root identity '{target}' should relate to oracle root '{oracle_root}'" ); assert_eq!( result.files.len(), *expected_files, "{fixture_id}: file count" ); let total_includes: usize = result.models.iter().map(|m| m.includes.len()).sum(); assert!( total_includes >= *expected_includes, "{fixture_id}: expected >={expected_includes} includes, got {total_includes}" ); if *expected_steps > 0 { assert!( result.semantic.steps.len() >= *expected_steps, "{fixture_id}: expected >={expected_steps} steps, got {}", result.semantic.steps.len() ); } assert_eq!( result.scene.triangles, *expected_tri, "{fixture_id}: triangle count" ); assert_eq!( result.scene.quads, *expected_quads, "{fixture_id}: quad count" ); } } }