diff --git a/crates/oa-core/tests/mixed_source8_read.rs b/crates/oa-core/tests/mixed_source8_read.rs new file mode 100644 index 00000000..95f2ef42 --- /dev/null +++ b/crates/oa-core/tests/mixed_source8_read.rs @@ -0,0 +1,812 @@ +//! External consumer: the linked oa-core library is not built with cfg(test). +//! Expected models use authored public values, never a parser or formatter oracle. +use oa_core::analysis as a; +use oa_core::analysis_numeric::Exact; +use oa_core::language::{self, ParseLimits, Span}; +use oa_core::source_project::{ProjectLimits, SourceBundle, SourceDiagnostic}; +pub use oa_core::*; +use std::collections::BTreeMap; + +// This existing helper constructs independent public model values only. It does +// not import private parser/index/writer code or enable cfg(test) in oa-core. +#[path = "../src/language/analysis_source/model_oracle.rs"] +mod independent_model; + +fn id(n: u128) -> Id { + Id::from_u128(n) +} + +fn empty_project() -> Project { + let mut p = Project::empty(); + p.id = id(1); + p.alias = "p".into(); + p.name = "Project".into(); + p +} + +fn document(body: &str) -> String { + format!( + "arcadia 8;\nproject p \"Project\" id \"{}\" {{\n{body}\n}}\n", + id(1) + ) +} + +fn single(body: &str) -> SourceBundle { + SourceBundle { + entry: "main.arcadia".into(), + files: BTreeMap::from([("main.arcadia".into(), document(body))]), + } +} + +fn succeeds( + bundle: &SourceBundle, + limits: ProjectLimits, + expected: &Project, + warnings: &[SourceDiagnostic], +) { + let original = bundle.clone(); + let checked = language::check_mixed_bundle(bundle, limits) + .unwrap_or_else(|errors| panic!("expected complete read: {errors:#?}")); + assert_eq!(checked.project(), expected); + assert_eq!(checked.warnings(), warnings); + assert_eq!(bundle, &original, "read success preserves every input byte"); + drop(checked); + assert_eq!(bundle, &original); +} + +fn fails(bundle: &SourceBundle, limits: ProjectLimits) -> Vec { + let original = bundle.clone(); + let errors = match language::check_mixed_bundle(bundle, limits) { + Ok(_) => panic!("invalid input returned a successful read witness"), + Err(errors) => errors, + }; + assert!(!errors.is_empty()); + assert_eq!(bundle, &original, "read refusal preserves every input byte"); + errors +} + +fn authored_span(source: &str, literal: &str) -> Span { + assert_eq!( + source.matches(literal).count(), + 1, + "ambiguous authored span" + ); + let start = source.find(literal).unwrap(); + Span { + start, + end: start + literal.len(), + } +} + +fn error_at( + bundle: &SourceBundle, + errors: &[SourceDiagnostic], + path: &str, + span: Span, + code: &str, + message: &str, +) { + assert_eq!(errors.len(), 1, "{errors:#?}"); + let error = &errors[0]; + assert_eq!(error.path, path); + assert_eq!(error.diagnostic.severity, validation::Severity::Error); + assert_eq!(error.diagnostic.code, code); + assert_eq!(error.diagnostic.message, message); + assert_eq!(error.diagnostic.span, span); + let source = bundle.files.get(path).map_or("", String::as_str); + let prefix = &source[..span.start]; + assert_eq!( + error.diagnostic.line, + prefix.bytes().filter(|b| *b == b'\n').count() + 1 + ); + assert_eq!( + error.diagnostic.column, + prefix.rsplit('\n').next().unwrap().chars().count() + 1 + ); +} + +fn rich(layer: Layer, crlf: bool) -> (SourceBundle, Project, Vec) { + let (ordinary, typed, work) = match layer { + Layer::Oa => ( + include_str!("../src/language/analysis_source/lower/fixtures/ordinary-oa.arcadia"), + include_str!("../src/language/analysis_source/lower/fixtures/typed-oa.arcadia"), + "oa_activity", + ), + Layer::Sa => ( + include_str!("../src/language/analysis_source/lower/fixtures/ordinary-sa.arcadia"), + include_str!("../src/language/analysis_source/lower/fixtures/typed-sa.arcadia"), + "sa_function", + ), + }; + let mut expected = independent_model::project(layer); + let mut main = ordinary + .replace("\r\n", "\n") + .replacen("arcadia 7;", "arcadia 8;", 1); + assert!(main.starts_with("arcadia 8;")); + // Forward typed uses refer back into nested ordinary Work in this file. + main = main.replacen("{\n", "{\n include \"typed.arcadia\";\n", 1); + if layer == Layer::Sa { + let system = Element { + id: id(999), + alias: "system".into(), + name: "System".into(), + description: String::new(), + parent: None, + body: ElementBody::Sa(SaBody::System), + }; + let at = main.rfind('}').unwrap(); + main.insert_str( + at, + &format!( + " sa_system system \"System\" id \"{}\" {{}}\n", + system.id + ), + ); + assert!(expected.elements.insert(system.id, system).is_none()); + } + let mut typed = typed.replace("\r\n", "\n"); + // Textual argument order is independent of the model's parameter-ID map. + assert!(typed.contains("@combine(a: @receive_time, b: @dispatch_time)")); + typed = typed.replace( + "@combine(a: @receive_time, b: @dispatch_time)", + "@combine(b: @dispatch_time, a: @receive_time)", + ); + if crlf { + main = format!("// 港🌊\r\n{}", main.replace('\n', "\r\n")); + typed = typed.replace('\n', "\r\n"); + } + let bundle = SourceBundle { + entry: "main.arcadia".into(), + files: BTreeMap::from([ + ("main.arcadia".into(), main), + ("typed.arcadia".into(), typed), + ]), + }; + // Two nested leaves, so four warnings, unlike the earlier one-Work fixture. + // Existing validation orders both allocation warnings before coverage warnings. + let mut warnings = Vec::new(); + for allocation in [true, false] { + for (alias, display) in [("receive", "Receive"), ("dispatch", "Dispatch")] { + let element = expected + .elements + .values() + .find(|e| e.alias == alias) + .unwrap(); + let literal = format!("{work} {alias} \"{display}\" id \"{}\" {{}}", element.id); + let source = &bundle.files["main.arcadia"]; + let span = authored_span(source, &literal); + let prefix = &source[..span.start]; + warnings.push(SourceDiagnostic { + path: "main.arcadia".into(), related: Vec::new(), + diagnostic: language::Diagnostic { + severity: validation::Severity::Warning, + code: if allocation { "unallocated_leaf" } else { "work_without_behavior_context" }.into(), + object_id: Some(element.id), + field: allocation.then(|| "allocation".into()), + message: if allocation { format!("Allocate leaf '{display}' to a performer.") } else { + "Include this leaf activity/function in a process/chain or scenario through a work action or exchange occurrence.".into() + }, + span, line: prefix.bytes().filter(|b| *b == b'\n').count() + 1, + column: prefix.rsplit('\n').next().unwrap().chars().count() + 1, + }, + }); + } + } + (bundle, expected, warnings) +} + +#[test] +fn public_mixed_read_complete_oa_sa_models_and_all_located_warnings() { + for layer in [Layer::Oa, Layer::Sa] { + for crlf in [false, true] { + let (bundle, expected, warnings) = rich(layer, crlf); + assert_eq!(expected.definitions.len(), 4); + assert_eq!(warnings.len(), 4); + succeeds(&bundle, ProjectLimits::default(), &expected, &warnings); + } + } +} + +fn calc(alias: &str, number: u128, body: &str) -> String { + format!( + "calculation {alias} \"Calculation\" id \"{}\" {{ input x id \"{}\" : Number; result Number; body {body}; }}", + id(number), + id(1) + ) +} + +fn calculation_model(alias: &str, number: u128, body: a::Expr) -> a::Definition { + a::Definition { + id: id(number), + alias: alias.into(), + name: "Calculation".into(), + description: String::new(), + body: a::DefinitionBody::Calculation(a::CalculationDefinition { + parameters: vec![a::Parameter { + id: a::ParameterId(id(1)), + name: "x".into(), + value_type: a::AnalysisType::Scalar(a::ScalarType::Number), + }], + result: a::AnalysisType::Scalar(a::ScalarType::Number), + body: a::Binding::Expression(body), + }), + } +} + +#[test] +fn public_mixed_read_scoped_uuid_reuse_and_cross_file_call_are_not_global_collisions() { + let mut bundle = single("include \"f.arcadia\"; include \"g.arcadia\";"); + bundle + .files + .insert("f.arcadia".into(), calc("f", 2, "@g(x: $x)")); + bundle.files.insert("g.arcadia".into(), calc("g", 3, "$x")); + let mut expected = empty_project(); + expected.definitions.insert( + id(2), + calculation_model( + "f", + 2, + a::Expr::Call { + definition: a::DefinitionRef::Local(id(3)), + arguments: BTreeMap::from([( + a::ParameterId(id(1)), + a::Expr::Parameter(a::ParameterId(id(1))), + )]), + }, + ), + ); + expected.definitions.insert( + id(3), + calculation_model("g", 3, a::Expr::Parameter(a::ParameterId(id(1)))), + ); + succeeds(&bundle, ProjectLimits::default(), &expected, &[]); +} + +#[test] +fn public_mixed_read_explicit_identity_classes_versions_and_long_forms() { + let empty = single(""); + succeeds(&empty, ProjectLimits::default(), &empty_project(), &[]); + let ordinary = format!("oa_entity e \"Entity\" id \"{}\" {{}}", id(2)); + let typed = calc("f", 3, "$x"); + let valid = single(&format!("{ordinary}\n{typed}")); + assert!(language::check_mixed_bundle(&valid, ProjectLimits::default()).is_ok()); + // Each deletion starts from the same valid complete input. + for (literal, expected_start) in [ + (format!("id \"{}\"", id(1)), None), + (format!("id \"{}\"", id(2)), Some("oa_entity")), + (format!("id \"{}\"", id(3)), Some("calculation")), + ] { + let mut missing = valid.clone(); + let text = missing.files.get_mut("main.arcadia").unwrap(); + *text = text.replacen(&literal, "", 1); + let start = match expected_start { + None => text.find('{').unwrap(), + Some("oa_entity") => { + text.find("oa_entity").unwrap() + + text[text.find("oa_entity").unwrap()..].find('{').unwrap() + } + Some(word) => text.find(word).unwrap(), + }; + let errors = fails(&missing, ProjectLimits::default()); + error_at( + &missing, + &errors, + "main.arcadia", + Span { + start, + end: start + 1, + }, + "invalid_source", + "Mixed source8 requires explicit UUIDs.", + ); + } + let mut parameter = valid.clone(); + let text = parameter.files.get_mut("main.arcadia").unwrap(); + *text = text.replace(&format!("input x id \"{}\"", id(1)), "input x"); + let start = text.find("calculation").unwrap(); + error_at( + ¶meter, + &fails(¶meter, ProjectLimits::default()), + "main.arcadia", + Span { + start, + end: start + 1, + }, + "invalid_source", + "Mixed source8 requires explicit UUIDs.", + ); + for version in ["3", "7", "9"] { + let mut bad = empty.clone(); + *bad.files.get_mut("main.arcadia").unwrap() = + document("").replacen("arcadia 8", &format!("arcadia {version}"), 1); + error_at( + &bad, + &fails(&bad, ProjectLimits::default()), + "main.arcadia", + Span { start: 8, end: 9 }, + "invalid_source", + "Mixed source requires arcadia 8.", + ); + } + for word in [ + "oa_engineering_property", + "sa_engineering_property", + "oa_analysis_check", + "sa_analysis_check", + "oa_analysis_case", + "sa_analysis_case", + ] { + let bad = single(&format!("{word} v \"Value\" id \"{}\" {{}}", id(2))); + let errors = fails(&bad, ProjectLimits::default()); + let start = bad.files["main.arcadia"].find(word).unwrap(); + assert_eq!(errors[0].path, "main.arcadia"); + assert_eq!(errors[0].diagnostic.code, "invalid_source"); + assert_eq!( + errors[0].diagnostic.span, + Span { + start, + end: start + 1 + } + ); + assert!( + errors[0].diagnostic.message.contains("short"), + "{errors:#?}" + ); + } +} + +fn chain() -> SourceBundle { + let mut bundle = single("include \"part.arcadia\";"); + bundle.files.insert( + "part.arcadia".into(), + "include \"last.arcadia\";\r\n".into(), + ); + bundle.files.insert("last.arcadia".into(), "//é\r\n".into()); + bundle +} + +#[test] +fn public_mixed_read_exact_input_file_edge_and_depth_boundaries() { + let empty = single(""); + assert_eq!(empty.files["main.arcadia"].len(), 78); + for cap in [78, 77] { + let limits = ProjectLimits { + model: ParseLimits { + max_source_bytes: cap, + ..ParseLimits::UNLIMITED + }, + ..ProjectLimits::default() + }; + if cap == 78 { + succeeds(&empty, limits, &empty_project(), &[]); + } else { + error_at( + &empty, + &fails(&empty, limits), + "main.arcadia", + Span { start: 0, end: 1 }, + "invalid_source", + "Source8 index/output byte limit exceeded.", + ); + } + } + let bundle = chain(); + assert_eq!(bundle.files.values().map(String::len).sum::(), 132); + let fits = ProjectLimits { + max_files: 3, + max_include_edges: 2, + max_include_depth: 2, + model: ParseLimits { + max_source_bytes: 132, + ..ParseLimits::UNLIMITED + }, + }; + succeeds(&bundle, fits, &empty_project(), &[]); + for (limits, path, span, code, message) in [ + ( + ProjectLimits { + max_files: 2, + ..fits + }, + "main.arcadia", + Span { start: 0, end: 0 }, + "project_limit", + "Source project exceeds 2 files.", + ), + ( + ProjectLimits { + max_include_edges: 1, + ..fits + }, + "part.arcadia", + Span { start: 0, end: 1 }, + "project_limit", + "Include graph exceeds 1 edges.", + ), + ( + ProjectLimits { + max_include_depth: 1, + ..fits + }, + "part.arcadia", + Span { start: 0, end: 23 }, + "project_limit", + "Include depth exceeds 1.", + ), + ( + ProjectLimits { + model: ParseLimits { + max_source_bytes: 131, + ..fits.model + }, + ..fits + }, + "main.arcadia", + Span { start: 0, end: 1 }, + "invalid_source", + "Source8 index/output byte limit exceeded.", + ), + ] { + error_at(&bundle, &fails(&bundle, limits), path, span, code, message); + } +} + +#[test] +fn public_mixed_read_paths_missing_cycles_and_detached_files_are_errors() { + let good = chain(); + succeeds(&good, ProjectLimits::default(), &empty_project(), &[]); + let mut missing = good.clone(); + missing.files.remove("last.arcadia"); + error_at( + &missing, + &fails(&missing, ProjectLimits::default()), + "part.arcadia", + Span { start: 0, end: 23 }, + "missing_include", + "Included file 'last.arcadia' is missing.", + ); + let mut detached = good.clone(); + detached + .files + .insert("unused.arcadia".into(), "// valid but detached\n".into()); + error_at( + &detached, + &fails(&detached, ProjectLimits::default()), + "unused.arcadia", + Span { start: 0, end: 0 }, + "unreachable_file", + "Supplied source file is not reachable from the entry file.", + ); + let mut cycle = good.clone(); + cycle + .files + .insert("last.arcadia".into(), "include \"part.arcadia\";".into()); + error_at( + &cycle, + &fails(&cycle, ProjectLimits::default()), + "last.arcadia", + Span { start: 0, end: 23 }, + "include_cycle", + "Include cycle: part.arcadia -> last.arcadia -> part.arcadia.", + ); + let mut invalid = good.clone(); + invalid + .files + .insert("../escape.arcadia".into(), String::new()); + error_at( + &invalid, + &fails(&invalid, ProjectLimits::default()), + "main.arcadia", + Span { start: 0, end: 0 }, + "invalid_path", + "Non-canonical source path '../escape.arcadia'.", + ); + let mut collision = good; + collision + .files + .insert("PART.arcadia".into(), "// detached uppercase\n".into()); + let errors = fails(&collision, ProjectLimits::default()); + error_at( + &collision, + &errors, + "main.arcadia", + Span { start: 0, end: 0 }, + "duplicate_path", + "Case-colliding source paths 'PART.arcadia' and 'part.arcadia'.", + ); +} + +#[test] +fn public_mixed_read_final_nested_model_limit_is_distinct_from_syntax_entries() { + let bundle = single(&calc("f", 2, "$x")); + let mut expected = empty_project(); + expected.definitions.insert( + id(2), + calculation_model("f", 2, a::Expr::Parameter(a::ParameterId(id(1)))), + ); + // Two declarations fit both policies. Parameter and expression make four + // final records; this does not pretend to set the internal entry quota. + let limits = ProjectLimits { + model: ParseLimits { + max_records: 4, + max_graph_objects: 1, + ..ParseLimits::UNLIMITED + }, + ..ProjectLimits::default() + }; + succeeds(&bundle, limits, &expected, &[]); + let errors = fails( + &bundle, + ProjectLimits { + model: ParseLimits { + max_records: 3, + ..limits.model + }, + ..limits + }, + ); + error_at( + &bundle, + &errors, + "main.arcadia", + Span { start: 0, end: 1 }, + "model_limit", + &format!( + "Model exceeds admission limits (1 graph objects, 3 total records, {} ordered references).", + usize::MAX + ), + ); + let errors = fails( + &bundle, + ProjectLimits { + model: ParseLimits { + max_graph_objects: 0, + ..limits.model + }, + ..limits + }, + ); + assert_eq!(errors[0].diagnostic.code, "model_limit"); + let start = bundle.files["main.arcadia"].find("calculation").unwrap(); + assert_eq!( + errors[0].diagnostic.span, + Span { + start, + end: start + 1 + } + ); +} + +#[test] +fn public_mixed_read_typed_reference_caller_allowance_has_a_real_positive_control() { + let bundle = single(&calc("f", 2, "$x")); + let mut expected = empty_project(); + expected.definitions.insert( + id(2), + calculation_model("f", 2, a::Expr::Parameter(a::ParameterId(id(1)))), + ); + let fits = ProjectLimits { + model: ParseLimits { + max_step_references: 1, + ..ParseLimits::UNLIMITED + }, + ..ProjectLimits::default() + }; + succeeds(&bundle, fits, &expected, &[]); + let start = bundle.files["main.arcadia"].find("$x").unwrap() + 1; + error_at( + &bundle, + &fails( + &bundle, + ProjectLimits { + model: ParseLimits { + max_step_references: 0, + ..fits.model + }, + ..fits + }, + ), + "main.arcadia", + Span { + start, + end: start + 1, + }, + "invalid_source", + "Source8 references limit exceeded.", + ); +} + +#[test] +fn public_mixed_read_malformed_duplicate_and_wrong_domain_references() { + let calculation = calc("f", 2, "1"); + let valid = single(&calculation); + let mut expected = empty_project(); + expected.definitions.insert( + id(2), + calculation_model("f", 2, a::Expr::Number(Exact::from_integer(1))), + ); + succeeds(&valid, ProjectLimits::default(), &expected, &[]); + + let mut malformed = valid.clone(); + *malformed.files.get_mut("main.arcadia").unwrap() = document(&calc("f", 2, "")); + let start = malformed.files["main.arcadia"].find("body ;").unwrap() + 5; + error_at( + &malformed, + &fails(&malformed, ProjectLimits::default()), + "main.arcadia", + Span { + start, + end: start + 1, + }, + "invalid_source", + "Expected an analysis expression.", + ); + + let mut unresolved = single("include \"typed.arcadia\";"); + unresolved + .files + .insert("typed.arcadia".into(), calc("f", 2, "@missing(x: 1)")); + let start = unresolved.files["typed.arcadia"].find("@missing").unwrap() + 1; + error_at( + &unresolved, + &fails(&unresolved, ProjectLimits::default()), + "typed.arcadia", + Span { + start, + end: start + 7, + }, + "invalid_source", + "Unknown global reference '@missing'.", + ); + + let ordinary = format!("oa_entity q \"Entity\" id \"{}\" {{}}", id(3)); + let wrong = single(&format!("{ordinary}\n{}", calc("f", 2, "@q(x: 1)"))); + let start = wrong.files["main.arcadia"].find("@q").unwrap() + 1; + error_at( + &wrong, + &fails(&wrong, ProjectLimits::default()), + "main.arcadia", + Span { + start, + end: start + 1, + }, + "invalid_source", + "Wrong reference domain for '@q': Element(Entity, Oa).", + ); + + let duplicate = single(&format!("{calculation}\n{}", calc("f", 3, "1"))); + let errors = fails(&duplicate, ProjectLimits::default()); + let second = calc("f", 3, "1"); + let span = authored_span(&duplicate.files["main.arcadia"], &second); + error_at( + &duplicate, + &errors, + "main.arcadia", + span, + "invalid_source", + "Duplicate global alias.", + ); + assert_eq!(errors[0].related.len(), 1); + assert_eq!(errors[0].related[0].path, "main.arcadia"); + assert_eq!( + errors[0].related[0].span, + authored_span(&duplicate.files["main.arcadia"], &calculation) + ); + + let second = calc("g", 2, "1"); + let duplicate_id = single(&format!("{calculation}\n{second}")); + error_at( + &duplicate_id, + &fails(&duplicate_id, ProjectLimits::default()), + "main.arcadia", + authored_span(&duplicate_id.files["main.arcadia"], &second), + "invalid_source", + "Duplicate global identity.", + ); + + let duplicate_parameter = calc("f", 2, "$x").replace( + "result Number;", + &format!("input x id \"{}\" : Number; result Number;", id(4)), + ); + let bad = single(&duplicate_parameter); + let at = bad.files["main.arcadia"].rfind("input x").unwrap() + 6; + error_at( + &bad, + &fails(&bad, ProjectLimits::default()), + "main.arcadia", + Span { + start: at, + end: at + 1, + }, + "invalid_source", + "Duplicate parameter name.", + ); + let second_parameter = format!("input y id \"{}\" : Number;", id(1)); + let duplicate_parameter_id = calc("f", 2, "$x").replace( + "result Number;", + &format!("{second_parameter} result Number;"), + ); + let bad = single(&duplicate_parameter_id); + let at = authored_span(&bad.files["main.arcadia"], &second_parameter).start + 11; + error_at( + &bad, + &fails(&bad, ProjectLimits::default()), + "main.arcadia", + Span { + start: at, + end: at + 1, + }, + "invalid_source", + "Duplicate parameter identity.", + ); +} + +#[test] +fn public_mixed_read_unicode_columns_are_not_utf8_byte_columns() { + let source = "arcadia 8;\r\nproject p \"港🌊\" id \"00000000-0000-0000-0000-000000000001\" { oa_activity w \"Work\" id \"00000000-0000-0000-0000-000000000002\" {} }\r\n"; + let bundle = SourceBundle { + entry: "main.arcadia".into(), + files: BTreeMap::from([("main.arcadia".into(), source.into())]), + }; + let mut expected = empty_project(); + expected.name = "港🌊".into(); + expected.elements.insert( + id(2), + Element { + id: id(2), + alias: "w".into(), + name: "Work".into(), + description: String::new(), + parent: None, + body: ElementBody::Oa(OaBody::Activity), + }, + ); + let warnings = [ + ("unallocated_leaf", Some("allocation"), "Allocate leaf 'Work' to a performer."), + ("work_without_behavior_context", None, "Include this leaf activity/function in a process/chain or scenario through a work action or exchange occurrence."), + ].map(|(code, field, message)| SourceDiagnostic { + path: "main.arcadia".into(), related: Vec::new(), + diagnostic: language::Diagnostic { severity: validation::Severity::Warning, code: code.into(), object_id: Some(id(2)), field: field.map(str::to_owned), message: message.into(), span: Span { start: 76, end: 141 }, line: 2, column: 60 }, + }); + succeeds(&bundle, ProjectLimits::default(), &expected, &warnings); +} + +#[test] +fn public_mixed_read_ordinary_parity_uses_an_independent_complete_model() { + let b = single(&format!( + "oa_entity team \"Team\" id \"{}\" {{ oa_actor sailor \"Sailor\" id \"{}\" {{ description \"海\"; }} }}", + id(2), + id(3) + )); + let mut expected = empty_project(); + for (n, alias, name, description, parent, body) in [ + (2, "team", "Team", "", None, ElementBody::Oa(OaBody::Entity)), + ( + 3, + "sailor", + "Sailor", + "海", + Some(id(2)), + ElementBody::Oa(OaBody::Actor), + ), + ] { + expected.elements.insert( + id(n), + Element { + id: id(n), + alias: alias.into(), + name: name.into(), + description: description.into(), + parent, + body, + }, + ); + } + succeeds(&b, ProjectLimits::default(), &expected, &[]); + let ordinary = language::parse_with_report(&b.files["main.arcadia"]).unwrap(); + assert!(ordinary.migration.is_none()); + assert_eq!(ordinary.project, expected); + let compiled = source_project::compile_bundle(&b, ProjectLimits::default()).unwrap(); + assert_eq!(compiled.decoded.project, expected); + assert_eq!(compiled.bundle, b); +}