use oa_core::{language, source_project::*, *}; use std::collections::BTreeMap; fn bundle(entry: &str, fragments: &[(&str, &str)]) -> SourceBundle { let mut files = BTreeMap::from([("project.arcadia".into(), entry.into())]); files.extend( fragments .iter() .map(|(path, text)| ((*path).into(), (*text).into())), ); SourceBundle { entry: "project.arcadia".into(), files, } } fn compile(bundle: &SourceBundle) -> CompiledSourceProject { compile_bundle(bundle, ProjectLimits::default()).unwrap() } fn id(project: &Project, alias: &str) -> Id { project .identities() .into_iter() .find(|(_, candidate)| *candidate == alias) .unwrap() .0 } #[test] fn route_edits_keep_diagram_file_ownership_and_comments_with_edge_identity() { let input = bundle( "arcadia 5; project p \"Routing\" { include \"views.arcadia\"; include \"work.arcadia\"; }", &[ ( "work.arcadia", "oa_activity a \"A\" {}\noa_activity b \"B\" {}\ninteraction first \"First\" { source @a; target @b; }\ninteraction second \"Second\" { source @b; target @a; }\n", ), ( "views.arcadia", r#"// Diagram routing decisions stay here. diagram review "Review" { routes [ // First edge's detour, including unusual numeric spelling. { edge @ // stable edge first; waypoints [{ x 4.20e2; y // clearance 260; }]; label { fraction // near target 0.7; offset { x 0; y -16; }; }; }, // Second edge stays automatic. { edge @second; } ]; } "#, ), ], ); let original = compile(&input); let mut changed = original.decoded.project.clone(); let diagram = id(&changed, "review"); let first = id(&changed, "first"); let routes = &mut changed.diagrams.get_mut(&diagram).unwrap().routes; routes[0].waypoints[0].y = 300.; routes[0].label.as_mut().unwrap().fraction = 0.6; routes.swap(0, 1); changed.relations.get_mut(&first).unwrap().alias = "detour".into(); let updated = update_bundle(&original, &changed, ProjectLimits::default()).unwrap(); assert_eq!(updated.decoded.project, changed); assert_eq!(updated.index.declarations[&diagram].path, "views.arcadia"); assert_eq!(updated.bundle.files.len(), original.bundle.files.len()); let text = &updated.bundle.files["views.arcadia"]; assert!( text.contains("edge @ // stable edge\n detour;"), "{text}" ); assert!(text.contains("x 4.20e2; y // clearance\n 300"), "{text}"); assert!(text.contains("fraction // near target\n 0.6"), "{text}"); assert!( text.find("// Second edge stays automatic.").unwrap() < text.find("// First edge's detour").unwrap(), "{text}" ); assert!( text.find("// First edge's detour").unwrap() < text.find("edge @ // stable edge\n detour").unwrap(), "{text}" ); let mut old_comments = comments(&original.bundle.files["views.arcadia"]); let mut new_comments = comments(text); old_comments.sort(); new_comments.sort(); assert_eq!(new_comments, old_comments); } #[test] fn invalid_cross_file_route_references_and_geometry_locate_the_authored_view() { let mut input = bundle( "arcadia 5; project p \"Routing\" { include \"views.arcadia\"; include \"work.arcadia\"; }", &[ ( "work.arcadia", "oa_activity a \"A\" {}\noa_activity b \"B\" {}\ninteraction link \"Link\" { source @a; target @b; }\n", ), ( "views.arcadia", "// authored view\ndiagram review \"Review\" {\n routes [{ edge @absent; }];\n}\n", ), ], ); let errors = compile_bundle(&input, ProjectLimits::default()).unwrap_err(); assert!( errors.iter().any(|error| error.path == "views.arcadia" && error.diagnostic.line == 3 && error.diagnostic.message.contains("absent")), "{errors:?}" ); input.files.get_mut("views.arcadia").unwrap().clone_from(& "// authored view\ndiagram review \"Review\" {\n routes [{ edge @link; label { fraction -0.1; offset { x 0; y 0; }; }; }];\n}\n".into()); let errors = compile_bundle(&input, ProjectLimits::default()).unwrap_err(); assert!( errors.iter().any(|error| error.path == "views.arcadia" && error.diagnostic.line == 3 && error.diagnostic.field.as_deref() == Some("routes")), "{errors:?}" ); } fn typed() -> SourceBundle { bundle( "arcadia 5; project test_model \"航海\" {\n include \"exchanges.arcadia\";\n include \"work/send.arcadia\";\n include \"work/receive.arcadia\";\n include \"types.arcadia\";\n}", &[ ( "exchanges.arcadia", "// Boundary-independent functional exchange\nfunctional_exchange message \"Message\" { source @send; target @receive; source_port @output; target_port @input; items [@data]; }\n", ), ( "work/send.arcadia", "// keep this explanation\nsa_function send \"Send\" {}\nsa_port output \"Output\" { parent @send; contract { FunctionOutput { outgoing_items [@data]; }; }; }\n", ), ( "work/receive.arcadia", "sa_function receive \"Receive\" {}\nsa_port input \"Input\" { parent @receive; contract { FunctionInput { incoming_items [@data]; }; }; }\n", ), ("types.arcadia", "sa_exchange_item data \"Data\" {}\n"), ], ) } #[test] fn cross_file_typed_ports_global_forward_references_and_stable_ids() { let input = typed(); let original = input.clone(); let result = compile(&input); assert_eq!(input, original, "compilation must not mutate caller drafts"); assert_ne!(result.bundle, input); let again = compile(&result.bundle); assert_eq!(again.decoded.project, result.decoded.project); assert_eq!(again.bundle, result.bundle); assert_eq!(again.index, result.index); let migration = result.decoded.migration.as_ref().unwrap(); assert_eq!( (migration.from_schema, migration.to_schema), (5, oa_core::SCHEMA_VERSION) ); assert!(input.files[&input.entry].contains("arcadia 5;")); assert!( result.bundle.files[&result.bundle.entry].contains(&format!("arcadia {SCHEMA_VERSION};")) ); assert!( again.decoded.migration.is_none(), "stabilized current source must not migrate twice" ); let project = &result.decoded.project; assert_eq!( project.relations[&id(project, "message")].source(), id(project, "send") ); let port = id(project, "output"); assert_eq!(result.index.declarations[&port].path, "work/send.arcadia"); let field = &result.index.fields[&(port, "contract".into())]; assert_eq!( &result.bundle.files[&field.path][field.span.start..field.span.end], "{ FunctionOutput { outgoing_items [@data]; }; }" ); assert_eq!( language::parse(&language::format(project)).unwrap(), *project ); assert_eq!( persistence::decode(&serde_json::to_vec(project).unwrap()).unwrap(), *project ); assert!(result.bundle.files["work/send.arcadia"].starts_with("// keep this explanation\n")); let send = id(project, "send"); let warning = result .diagnostics .iter() .find(|issue| { issue.diagnostic.object_id == Some(send) && issue.diagnostic.code == "unallocated_leaf" }) .unwrap(); assert_eq!(warning.path, "work/send.arcadia"); assert_eq!(warning.diagnostic.line, 2); } #[test] fn advisory_lint_locations_refer_to_raw_drafts_before_identity_stabilization() { let input = bundle( "arcadia 5; project p \"P\" { include \"work.arcadia\"; }", &[( "work.arcadia", "sa_function first \"First\" {}\nsa_function second \"Second\" {}\n", )], ); let before = input.clone(); let raw = &input.files["work.arcadia"]; let warnings = lint_bundle(&input, ProjectLimits::default()); let second = warnings .iter() .find(|d| { d.path == "work.arcadia" && d.diagnostic.code == "unallocated_leaf" && d.diagnostic.line == 2 }) .unwrap(); assert_eq!( second.diagnostic.span.start, raw.find("sa_function second").unwrap() ); assert!(raw[second.diagnostic.span.start..second.diagnostic.span.end].contains("second")); assert_eq!(input, before); let compiled = compile(&input); assert_ne!(compiled.bundle, input); assert!(compiled.diagnostics.iter().any(|d| d.path == "work.arcadia" && d.diagnostic.code == "unallocated_leaf" && d.diagnostic.line == 2 && d.diagnostic.span.start > second.diagnostic.span.start)); } #[test] fn source_errors_keep_exact_fragment_unicode_positions() { let input = bundle( "arcadia 5; project p \"P\" { include \"child.arcadia\"; }", &[( "child.arcadia", "// Пример\nsa_function child \"航海\" { parent @missing; }", )], ); let error = compile_bundle(&input, ProjectLimits::default()) .unwrap_err() .remove(0); let text = &input.files["child.arcadia"]; assert_eq!(error.path, "child.arcadia"); assert_eq!(error.diagnostic.line, 2); let offset = text.find("@missing").unwrap(); assert_eq!(error.diagnostic.span.start, offset); assert_eq!( error.diagnostic.column, text[..offset].rsplit('\n').next().unwrap().chars().count() + 1 ); assert!(error.diagnostic.message.contains("@missing")); let cyclic_parent = SourceBundle { files: input .files .iter() .map(|(path, text)| (path.clone(), text.replace("@missing", "@child"))) .collect(), ..input }; let error = compile_bundle(&cyclic_parent, ProjectLimits::default()).unwrap_err(); let parent = error .iter() .find(|issue| issue.diagnostic.code == "invalid_parent") .unwrap(); assert_eq!(parent.path, "child.arcadia"); assert_eq!(parent.diagnostic.span.start, offset); } #[test] fn duplicate_alias_and_identity_report_both_files_including_project_identity() { for (left, right) in [ ("oa_activity same \"A\" {}", "oa_activity same \"B\" {}"), ( "oa_activity first \"A\" id \"00000000-0000-4000-8000-000000000001\" {}", "oa_activity second \"B\" id \"00000000-0000-4000-8000-000000000001\" {}", ), ] { let input = bundle( "arcadia 5; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }", &[("a.arcadia", left), ("b.arcadia", right)], ); let error = compile_bundle(&input, ProjectLimits::default()) .unwrap_err() .remove(0); assert_eq!(error.diagnostic.code, "duplicate_declaration"); assert_eq!(error.path, "b.arcadia"); assert_eq!(error.related[0].path, "a.arcadia"); } let input = bundle( "arcadia 5; project p \"P\" { include \"child.arcadia\"; }", &[("child.arcadia", "oa_activity p \"Collision\" {}")], ); let error = compile_bundle(&input, ProjectLimits::default()) .unwrap_err() .remove(0); assert_eq!(error.related[0].path, "project.arcadia"); } #[test] fn includes_are_once_only_and_cycles_missing_and_unreachable_files_are_located() { let mut input = bundle( "arcadia 5; project p \"P\" { include \"sub/a.arcadia\"; include \"sub/b.arcadia\"; }", &[ ("sub/a.arcadia", "include \"../shared.arcadia\";"), ("sub/b.arcadia", "include \"../shared.arcadia\";"), ("shared.arcadia", "oa_activity shared_work \"Shared\" {}"), ], ); assert_eq!(compile(&input).decoded.project.elements.len(), 1); input .files .insert("shared.arcadia".into(), "include \"sub/a.arcadia\";".into()); let error = compile_bundle(&input, ProjectLimits::default()) .unwrap_err() .remove(0); assert_eq!(error.path, "shared.arcadia"); assert_eq!(error.diagnostic.code, "include_cycle"); assert!( error .diagnostic .message .contains("sub/a.arcadia -> shared.arcadia -> sub/a.arcadia") ); input.files.remove("shared.arcadia"); let error = compile_bundle(&input, ProjectLimits::default()) .unwrap_err() .remove(0); assert_eq!(error.path, "sub/a.arcadia"); assert_eq!(error.diagnostic.code, "missing_include"); input.files.insert("shared.arcadia".into(), String::new()); input.files.insert("unused.arcadia".into(), String::new()); assert_eq!( compile_bundle(&input, ProjectLimits::default()).unwrap_err()[0] .diagnostic .code, "unreachable_file" ); } #[test] fn portable_paths_reject_escapes_devices_urls_and_case_collisions() { for path in [ "../escape.arcadia", "/abs.arcadia", "C:/drive.arcadia", "https://host/a.arcadia", "a\\b.arcadia", "a//b.arcadia", "con.arcadia", "dir./a.arcadia", "a.txt", ] { assert!(resolve_include("project.arcadia", path).is_err(), "{path}"); } assert_eq!( resolve_include("sub/a.arcadia", "../b.arcadia").unwrap(), "b.arcadia" ); assert!(resolve_include("../bad.arcadia", "a.arcadia").is_err()); let input = bundle( "arcadia 5; project p \"P\" {}", &[("A.arcadia", ""), ("a.arcadia", "")], ); assert_eq!( compile_bundle(&input, ProjectLimits::default()).unwrap_err()[0] .diagnostic .code, "duplicate_path" ); } #[test] fn project_admission_is_aggregate_and_precedes_global_resolution() { let input = bundle( "arcadia 5; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }", &[ ("a.arcadia", "oa_activity one \"One\" { parent @missing; }"), ("b.arcadia", "oa_activity two \"Two\" {}"), ], ); let cases = [ ProjectLimits { max_files: 2, ..ProjectLimits::default() }, ProjectLimits { max_include_depth: 0, ..ProjectLimits::default() }, ProjectLimits { max_include_edges: 1, ..ProjectLimits::default() }, ProjectLimits { model: language::ParseLimits { max_graph_objects: 1, ..language::ParseLimits::UNLIMITED }, ..ProjectLimits::default() }, ProjectLimits { model: language::ParseLimits { max_records: 2, ..language::ParseLimits::UNLIMITED }, ..ProjectLimits::default() }, ProjectLimits { model: language::ParseLimits { max_source_bytes: input.files.values().map(String::len).sum::() - 1, ..language::ParseLimits::UNLIMITED }, ..ProjectLimits::default() }, ]; for limits in cases { let error = compile_bundle(&input, limits).unwrap_err(); assert!(error[0].diagnostic.code.ends_with("limit"), "{error:?}"); assert!(!error[0].diagnostic.message.contains("@missing")); } } #[test] fn canonical_export_groups_semantic_roots_ports_and_placements_and_is_deterministic() { let mut project = compile(&typed()).decoded.project; let child = project .add_element( Kind::Function, Layer::Sa, "Child", Some(id(&project, "send")), ) .unwrap(); let diagram = project.ensure_diagram(Layer::Sa); let node = project.place(diagram, child, Position { x: 40., y: 80. }); let output = format_bundle(&project); assert_eq!(format_bundle(&project), output); let result = compile(&output); assert_eq!(result.decoded.project, project); assert!( result.index.declarations[&child] .path .contains("function_send.arcadia") ); assert_eq!( result.index.declarations[&child].path, result.index.declarations[&id(&project, "output")].path ); assert_eq!( result.index.declarations[&node].path, result.index.declarations[&diagram].path ); let work = &output.files[&result.index.declarations[&child].path]; assert!( work.find("sa_function send ").unwrap() < work .find(&format!("sa_function {} ", project.elements[&child].alias)) .unwrap() ); } #[test] fn moving_declarations_between_files_changes_no_semantics_or_ids() { let compiled = compile(&typed()); let mut moved = compiled.bundle.clone(); let port = id(&compiled.decoded.project, "output"); let location = &compiled.index.declarations[&port]; let declaration = moved.files[&location.path][location.span.start..location.span.end].to_owned(); moved .files .get_mut(&location.path) .unwrap() .replace_range(location.span.start..location.span.end, ""); moved .files .get_mut("exchanges.arcadia") .unwrap() .push_str(&format!("\n{declaration}\n")); let moved = compile(&moved); assert_eq!(moved.decoded.project, compiled.decoded.project); assert_eq!(moved.index.declarations[&port].path, "exchanges.arcadia"); } #[test] fn model_updates_rewrite_cross_file_alias_uses_preserve_ownership_and_untouched_comments() { let compiled = compile(&typed()); let mut project = compiled.decoded.project.clone(); let send = id(&project, "send"); project.elements.get_mut(&send).unwrap().alias = "transmit".into(); project.elements.get_mut(&send).unwrap().name = "Transmit data".into(); project.description = "Documented project".into(); let result = update_bundle(&compiled, &project, ProjectLimits::default()).unwrap(); assert_eq!(result.decoded.project, project); assert_eq!(result.index.declarations[&send].path, "work/send.arcadia"); assert!(result.bundle.files["exchanges.arcadia"].contains("source @transmit;")); assert!(result.bundle.files["work/send.arcadia"].contains("parent @transmit;")); assert!(result.bundle.files["work/send.arcadia"].starts_with("// keep this explanation\n")); assert_eq!( result.bundle.files["types.arcadia"], compiled.bundle.files["types.arcadia"] ); assert_eq!( result.bundle.files["work/receive.arcadia"], compiled.bundle.files["work/receive.arcadia"] ); assert_eq!( update_bundle(&result, &project, ProjectLimits::default()) .unwrap() .bundle, result.bundle ); } #[test] fn graphical_move_updates_only_its_owned_view_document() { let mut project = compile(&typed()).decoded.project; let diagram = project.ensure_diagram(Layer::Sa); let send = id(&project, "send"); let node = project.place(diagram, send, Position { x: 0., y: 0. }); let current = compile(&format_bundle(&project)); project .diagrams .get_mut(&diagram) .unwrap() .nodes .get_mut(&node) .unwrap() .position = Position { x: 142., y: 83. }; let result = update_bundle(¤t, &project, ProjectLimits::default()).unwrap(); let changed: Vec<_> = result .bundle .files .iter() .filter(|(path, text)| current.bundle.files[*path] != **text) .map(|(path, _)| path.clone()) .collect(); assert_eq!( changed, vec![current.index.declarations[&node].path.clone()] ); assert_eq!(result.decoded.project, project); } #[test] fn new_children_inherit_owner_file_new_roots_add_include_and_deletion_keeps_files() { let current = compile(&typed()); let mut project = current.decoded.project.clone(); let send = id(&project, "send"); let child = project .add_element(Kind::Function, Layer::Sa, "Nested work", Some(send)) .unwrap(); let root = project .add_element(Kind::Function, Layer::Sa, "Independent work", None) .unwrap(); let result = update_bundle(¤t, &project, ProjectLimits::default()).unwrap(); assert_eq!(result.index.declarations[&child].path, "work/send.arcadia"); let root_file = result.index.declarations[&root].path.clone(); assert!(root_file.contains("independent_work")); assert!(result.bundle.files["project.arcadia"].contains(&format!("include \"{root_file}\";"))); project.elements.remove(&root); let deleted = update_bundle(&result, &project, ProjectLimits::default()).unwrap(); assert!(deleted.bundle.files.contains_key(&root_file)); assert!(!deleted.index.declarations.contains_key(&root)); } #[test] fn failed_update_preserves_candidate_and_rejects_stale_index() { let current = compile(&typed()); let original = current.bundle.clone(); let mut project = current.decoded.project.clone(); project .elements .get_mut(&id(¤t.decoded.project, "send")) .unwrap() .parent = Some(Id::new_v4()); assert!(update_bundle(¤t, &project, ProjectLimits::default()).is_err()); assert_eq!(current.bundle, original); let mut stale = current.clone(); stale .bundle .files .get_mut("work/send.arcadia") .unwrap() .insert_str(0, "// external edit\n"); assert_eq!( update_bundle(&stale, ¤t.decoded.project, ProjectLimits::default()).unwrap_err()[0] .diagnostic .code, "stale_source_index" ); } #[test] fn single_string_and_legacy_apis_remain_explicit_and_filesystem_free() { let source = "arcadia 5; project p \"P\" { include \"missing.arcadia\"; }"; assert!( language::parse(source).unwrap_err()[0] .message .contains("requires a source bundle") ); let old = "arcadia 2; project p \"Legacy\" { oa_activity work \"Work\" {} }"; let decoded = language::parse_with_report(old).unwrap(); assert!(decoded.migration.is_some()); assert!(compile_bundle(&bundle(old, &[]), ProjectLimits::default()).is_err()); assert_eq!( compile(&format_bundle(&decoded.project)).decoded.project, decoded.project ); } #[test] fn exported_case_colliding_root_aliases_are_portable_and_large_root_sets_are_bounded() { let mut project = Project::empty(); let a = project .add_element(Kind::Activity, Layer::Oa, "Upper", None) .unwrap(); let b = project .add_element(Kind::Activity, Layer::Oa, "Lower", None) .unwrap(); project.elements.get_mut(&a).unwrap().alias = "Work".into(); project.elements.get_mut(&b).unwrap().alias = "work".into(); let result = compile(&format_bundle(&project)); assert_ne!( result.index.declarations[&a].path.to_lowercase(), result.index.declarations[&b].path.to_lowercase() ); for n in 0..260 { project .add_element(Kind::Activity, Layer::Oa, &format!("Activity {n}"), None) .unwrap(); } let generated = format_bundle(&project); assert!(generated.files.len() <= ProjectLimits::default().max_files); assert_eq!(compile(&generated).decoded.project, project); } #[test] fn complete_demo_preserves_behavior_artifacts_traces_and_every_placement() { let project = Project::demo(); let generated = format_bundle(&project); let result = compile(&generated); assert_eq!(result.decoded.project, project); for element in project .elements .values() .filter(|e| matches!(e.kind(), Kind::Scenario | Kind::StateMachine)) { assert!( result.index.declarations[&element.id] .path .contains("/behavior/") ); for child in project .elements .values() .filter(|child| child.parent == Some(element.id)) { assert_eq!( result.index.declarations[&child.id].path, result.index.declarations[&element.id].path ); } } let json = serde_json::to_string(&generated).unwrap(); assert_eq!( serde_json::from_str::(&json).unwrap(), generated ); } #[test] fn json_bundle_rejects_duplicate_file_keys_and_unknown_fields() { assert!(serde_json::from_str::(r#"{"entry":"project.arcadia","files":{"project.arcadia":"first","project.arcadia":"second"}}"#).unwrap_err().to_string().contains("Duplicate source path")); assert!( serde_json::from_str::( r#"{"entry":"project.arcadia","files":{},"root":"C:/host"}"# ) .is_err() ); } #[test] fn generated_id_insertion_handles_adjacent_header_tokens_and_remains_exactly_indexed() { let input = bundle( "arcadia 5;project p\"P\"{include \"child.arcadia\";}", &[( "child.arcadia", "oa_activity a\"A\"{}diagram d\"D\"{}node n{diagram @d;element @a;}", )], ); let result = compile(&input); for (id, location) in &result.index.declarations { assert!( result.bundle.files[&location.path][location.span.start..location.span.end] .contains(&id.to_string()) ); } assert_eq!( compile(&result.bundle).decoded.project, result.decoded.project ); } #[test] fn duplicate_include_edges_count_toward_admission_and_ordered_references_are_aggregate() { let input = bundle( "arcadia 5; project p \"P\" { include \"a.arcadia\"; include \"a.arcadia\"; }", &[("a.arcadia", "")], ); assert_eq!( compile_bundle( &input, ProjectLimits { max_include_edges: 1, ..ProjectLimits::default() } ) .unwrap_err()[0] .diagnostic .code, "project_limit" ); let input = bundle( "arcadia 5; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }", &[ ( "a.arcadia", "oa_process one \"One\" { steps [@step_one]; } oa_occurrence step_one \"Step one\" { parent @one; body { Control { kind Choice; }; }; }", ), ( "b.arcadia", "oa_process two \"Two\" { steps [@step_two]; } oa_occurrence step_two \"Step two\" { parent @two; body { Control { kind Choice; }; }; }", ), ], ); assert_eq!( compile_bundle( &input, ProjectLimits { model: language::ParseLimits { max_step_references: 1, ..language::ParseLimits::UNLIMITED }, ..ProjectLimits::default() } ) .unwrap_err()[0] .diagnostic .code, "model_limit" ); } #[test] fn include_inspection_reuses_strict_entry_fragment_syntax_and_real_spans() { let source = "// include \"fake.arcadia\";\noa_activity work \"航海\" { parent @external_work; description \"include ignored\"; }\ninclude \"../shared.arcadia\";"; let includes = inspect_includes("sub/work.arcadia", source, false, ProjectLimits::default()).unwrap(); assert_eq!(includes.len(), 1); assert_eq!(includes[0].target, "shared.arcadia"); assert_eq!(includes[0].location.path, "sub/work.arcadia"); let span = includes[0].location.span; assert_eq!( &source[span.start..span.end], "include \"../shared.arcadia\";" ); let entry = "arcadia 5; project p \"P\" { include \"sub/work.arcadia\"; }"; assert_eq!( inspect_includes("project.arcadia", entry, true, ProjectLimits::default()).unwrap()[0] .target, "sub/work.arcadia" ); assert!(inspect_includes("project.arcadia", entry, false, ProjectLimits::default()).is_err()); } #[test] fn include_inspection_returns_located_failure_without_partial_discovery_and_is_bounded() { let source = "include \"first.arcadia\";\noa_activity invalid \"航海\" { parent @; }"; let errors = inspect_includes("broken.arcadia", source, false, ProjectLimits::default()).unwrap_err(); assert_eq!(errors[0].path, "broken.arcadia"); assert_eq!(errors[0].diagnostic.line, 2); assert_eq!( errors[0].diagnostic.span.start, source.find("@;").unwrap() + 1 ); assert_eq!( inspect_includes( "broken.arcadia", source, false, ProjectLimits { model: language::ParseLimits { max_source_bytes: 10, ..language::ParseLimits::UNLIMITED }, ..ProjectLimits::default() } ) .unwrap_err()[0] .diagnostic .code, "source_limit" ); let source = "include \"first.arcadia\"; include \"second.arcadia\";"; let errors = inspect_includes( "project.arcadia", source, false, ProjectLimits { max_include_edges: 1, ..ProjectLimits::default() }, ) .unwrap_err(); assert_eq!( errors[0].diagnostic.span.start, source.find("include \"second").unwrap() ); assert_eq!(errors[0].diagnostic.code, "project_limit"); } fn comments(source: &str) -> Vec { language::tokenize(source) .into_iter() .filter(|token| token.class == language::TokenClass::Comment) .map(|token| source[token.span.start..token.span.end].to_owned()) .collect() } #[test] fn scalar_header_guard_and_project_edits_preserve_exact_crlf_trivia_and_escape_spelling() { let entry = "arcadia 5;\r\nproject p // project title\r\n \"Original project\" {\r\n description // project reason\r\n \"Before\";\r\n include \"behavior.arcadia\";\r\n}\r\n"; let source = "oa_state_machine machine \"Machine\" {}\r\noa_region region_one \"Region\" { parent @machine; }\r\noa_state first \"First\" { parent @region_one; }\r\noa_state second \"Second\" { parent @region_one; }\r\nstate_transition route // route title\r\n \"Original transition\" {\r\n\tguard // feasibility\r\n\t \"old guard\"; // retain explanation\r\n\tdescription \"Keep\\u0020spelling\";\r\n\tsource @ // endpoint proof\r\n first; target @second;\r\n}\r\n"; let current = compile(&bundle(entry, &[("behavior.arcadia", source)])); let mut project = current.decoded.project.clone(); project.name = "Renamed project".into(); project.description = "After".into(); let route = project .relations .get_mut(&id(¤t.decoded.project, "route")) .unwrap(); route.name = "Renamed transition".into(); if let RelationBody::StateTransition { guard, .. } = &mut route.body { *guard = "new guard".into(); } else { panic!("transition"); } let result = update_bundle(¤t, &project, ProjectLimits::default()).unwrap(); assert_eq!( result.bundle.files["project.arcadia"], current.bundle.files["project.arcadia"] .replace("\"Original project\"", "\"Renamed project\"") .replace("\"Before\"", "\"After\"") ); assert_eq!( result.bundle.files["behavior.arcadia"], current.bundle.files["behavior.arcadia"] .replace("\"Original transition\"", "\"Renamed transition\"") .replace("\"old guard\"", "\"new guard\"") ); assert_eq!(result.decoded.project, project); } #[test] fn alias_updates_touch_only_header_and_reference_tokens_even_with_comments_after_at() { let input = bundle( "arcadia 5; project p \"P\" { include \"work.arcadia\"; }", &[( "work.arcadia", "oa_activity root_work // root rationale\n \"Root\\u0020name\" { description \"root_work is prose\"; }\noa_activity child \"Child\" { parent @ // parent proof\n root_work; }\n", )], ); let current = compile(&input); let mut project = current.decoded.project.clone(); project .elements .get_mut(&id(¤t.decoded.project, "root_work")) .unwrap() .alias = "renamed_work".into(); let result = update_bundle(¤t, &project, ProjectLimits::default()).unwrap(); let expected = current.bundle.files["work.arcadia"] .replace("oa_activity root_work ", "oa_activity renamed_work ") .replace("\n root_work;", "\n renamed_work;"); assert_eq!(result.bundle.files["work.arcadia"], expected); } #[test] fn omitted_defaults_stay_omitted_and_new_fields_do_not_reformat_surviving_fields() { let input = bundle( "arcadia 5; project p \"P\" { include \"work.arcadia\"; }", &[( "work.arcadia", "oa_activity work \"Work\" {\r\n\t// owner rationale\r\n\tparent none ; // preserve spacing\r\n}\r\n", )], ); let current = compile(&input); let mut project = current.decoded.project.clone(); project .elements .get_mut(&id(¤t.decoded.project, "work")) .unwrap() .description = "New documentation".into(); let result = update_bundle(¤t, &project, ProjectLimits::default()).unwrap(); let before = ¤t.bundle.files["work.arcadia"]; let after = &result.bundle.files["work.arcadia"]; assert_eq!( after.replace("\tdescription \"New documentation\";\r\n", ""), *before ); assert_eq!(comments(after), comments(before)); } #[test] fn kind_change_removes_field_syntax_but_keeps_its_comments_and_other_fields() { let input = bundle( "arcadia 5; project p \"P\" { include \"item.arcadia\"; }", &[( "item.arcadia", "oa_exchange_item item \"Item\" {\n description \"Keep\\u0020this\" ; // description note\n kind // classification rationale\n Information ;\n parent none;\n}\n", )], ); let current = compile(&input); let mut project = current.decoded.project.clone(); project .elements .get_mut(&id(¤t.decoded.project, "item")) .unwrap() .body = ElementBody::new(Kind::Entity, Layer::Oa).unwrap(); let result = update_bundle(¤t, &project, ProjectLimits::default()).unwrap(); let after = &result.bundle.files["item.arcadia"]; assert!(after.contains("description \"Keep\\u0020this\" ; // description note")); assert!(after.contains("parent none;")); assert_eq!( comments(after), comments(¤t.bundle.files["item.arcadia"]) ); assert!(!significant_words(after).contains(&"Information".into())); } fn significant_words(source: &str) -> Vec { language::tokenize(source) .into_iter() .filter(|token| { !matches!( token.class, language::TokenClass::Whitespace | language::TokenClass::Comment ) }) .map(|token| source[token.span.start..token.span.end].to_owned()) .collect() } #[test] fn constructor_tag_changes_retain_shared_nested_fields_and_removed_field_comments() { let input = bundle( "arcadia 5; project p \"P\" { include \"event.arcadia\"; }", &[( "event.arcadia", "oa_event timer \"Timer\" { body { Time // time rationale\n { expression // expression rationale\n \"before\" ; kind After; // clock rationale\n }; }; }\n", )], ); let current = compile(&input); let mut project = current.decoded.project.clone(); let timer = project .elements .get_mut(&id(¤t.decoded.project, "timer")) .unwrap(); if let Some(SharedBody::Event { body }) = timer.shared_mut() { *body = EventBody::Change { expression: "after".into(), }; } else { panic!("event"); } let result = update_bundle(¤t, &project, ProjectLimits::default()).unwrap(); let after = &result.bundle.files["event.arcadia"]; assert!(after.contains("Change // time rationale")); assert!(after.contains("expression // expression rationale\n \"after\" ;")); assert_eq!( comments(after), comments(¤t.bundle.files["event.arcadia"]) ); assert!(!significant_words(after).contains(&"After".into())); } #[test] fn scalar_record_constructor_changes_keep_comments_inside_removed_values() { let input = bundle( "arcadia 5; project p \"P\" { include \"ports.arcadia\"; }", &[( "ports.arcadia", "sa_function work \"Work\" {}\nsa_port input \"Input\" { parent @work; contract Legacy; // contract rationale\n legacy { direction In; // direction rationale\n interface none; // interface rationale\n }; }\n", )], ); let current = compile(&input); let mut project = current.decoded.project.clone(); let port = project .elements .get_mut(&id(¤t.decoded.project, "input")) .unwrap(); if let Some(SharedBody::Port { contract, legacy }) = port.shared_mut() { *contract = PortContract::FunctionInput { incoming_items: vec![], }; *legacy = None; } else { panic!("port"); } let result = update_bundle(¤t, &project, ProjectLimits::default()).unwrap(); assert_eq!( comments(&result.bundle.files["ports.arcadia"]), comments(¤t.bundle.files["ports.arcadia"]) ); assert_eq!(result.decoded.project, project); } #[test] fn reference_list_reordering_carries_item_comments_and_alias_spelling_with_identity() { let input = bundle( "arcadia 5; project p \"P\" { include \"types.arcadia\"; }", &[( "types.arcadia", "sa_exchange_item a \"A\" {} sa_exchange_item b \"B\" {}\nsa_event service \"Service\" { body { Signal { items [\n // before A\n @ // A reference rationale\n a // after A\n ,\n // before B\n @b // after B\n ]; }; }; }\n", )], ); let current = compile(&input); let mut project = current.decoded.project.clone(); let a = id(&project, "a"); let b = id(&project, "b"); let service = id(&project, "service"); project.elements.get_mut(&a).unwrap().alias = "renamed_a".into(); if let Some(SharedBody::Event { body: EventBody::Signal { items }, }) = project.elements.get_mut(&service).unwrap().shared_mut() { *items = vec![b, a]; } else { panic!("interface"); } let result = update_bundle(¤t, &project, ProjectLimits::default()).unwrap(); let after = &result.bundle.files["types.arcadia"]; assert!(after.find("// before B").unwrap() < after.find("// before A").unwrap()); assert!(after.contains("@ // A reference rationale\n renamed_a // after A")); let mut old_comments = comments(¤t.bundle.files["types.arcadia"]); let mut new_comments = comments(after); old_comments.sort(); new_comments.sort(); assert_eq!(old_comments, new_comments); assert_eq!(result.decoded.project, project); } #[test] fn removed_list_items_and_declarations_keep_comments_as_enclosing_annotations() { let input = bundle( "arcadia 5; project p \"P\" { include \"types.arcadia\"; }", &[( "types.arcadia", "sa_exchange_item a \"A\" { // declaration explanation\n } sa_exchange_item b \"B\" {}\nsa_event service \"Service\" { body { Signal { items [\n // explanation for removed A\n @a,\n // explanation for retained B\n @b ]; }; }; }\n", )], ); let current = compile(&input); let mut project = current.decoded.project.clone(); let a = id(&project, "a"); let b = id(&project, "b"); let service = id(&project, "service"); project.elements.remove(&a); if let Some(SharedBody::Event { body: EventBody::Signal { items }, }) = project.elements.get_mut(&service).unwrap().shared_mut() { *items = vec![b]; } else { panic!("interface"); } let result = update_bundle(¤t, &project, ProjectLimits::default()).unwrap(); let mut before = comments(¤t.bundle.files["types.arcadia"]); let mut after = comments(&result.bundle.files["types.arcadia"]); before.sort(); after.sort(); assert_eq!(before, after); assert_eq!(result.decoded.project, project); } #[test] fn nested_message_value_edits_preserve_field_comments_and_reordering_uses_field_identity() { let source = r#"oa_activity send "Send" {} oa_activity receive "Receive" {} oa_exchange_item data "Data" {} oa_data_field first_value "First" { parent @data; } oa_data_field second_value "Second" { parent @data; } interaction transfer "Transfer" { source @send; target @receive; items [@data]; } oa_scenario conversation "Conversation" { viewpoint OperationalActivity; steps [@message]; } oa_lifeline sender_line "Sender" { parent @conversation; represented @send; } oa_lifeline receiver_line "Receiver" { parent @conversation; represented @receive; } oa_occurrence message "Message" { parent @conversation; body { Exchange { exchange @transfer; sender @sender_line; receiver @receiver_line; values [ // first annotation { field @ // first field proof first_value; expression // first expression proof "first expression"; }, // second annotation { expression "second expression"; field @second_value; } ]; }; }; } "#; let current = compile(&bundle( "arcadia 5; project p \"P\" { include \"conversation.arcadia\"; }", &[("conversation.arcadia", source)], )); let mut project = current.decoded.project.clone(); let message = id(&project, "message"); if let Some(SharedBody::Occurrence { body: OccurrenceBody::Exchange { values, .. }, .. }) = project.elements.get_mut(&message).unwrap().shared_mut() { values[0].expression = "changed expression".into(); } else { panic!("message"); } let changed = update_bundle(¤t, &project, ProjectLimits::default()).unwrap(); assert_eq!( changed.bundle.files["conversation.arcadia"], current.bundle.files["conversation.arcadia"] .replace("\"first expression\"", "\"changed expression\"") ); let first = id(&project, "first_value"); project.elements.get_mut(&first).unwrap().alias = "renamed_value".into(); if let Some(SharedBody::Occurrence { body: OccurrenceBody::Exchange { values, .. }, .. }) = project.elements.get_mut(&message).unwrap().shared_mut() { values.reverse(); } let reordered = update_bundle(&changed, &project, ProjectLimits::default()).unwrap(); let after = &reordered.bundle.files["conversation.arcadia"]; assert!(after.contains("field @ // first field proof\n renamed_value; expression // first expression proof\n \"changed expression\";")); assert!( after.find("// second annotation").unwrap() < after.find("// first annotation").unwrap() ); let mut old_comments = comments(¤t.bundle.files["conversation.arcadia"]); let mut new_comments = comments(after); old_comments.sort(); new_comments.sort(); assert_eq!(old_comments, new_comments); for (id, location) in ¤t.index.declarations { assert_eq!(reordered.index.declarations[id].path, location.path); } assert_eq!(reordered.decoded.project, project); } #[test] fn added_list_items_empty_lists_and_literal_changes_preserve_existing_annotations() { let input = bundle( "arcadia 5; project p \"P\" { include \"types.arcadia\"; }", &[( "types.arcadia", "oa_data_type values \"Values\" { definition { Enumeration { literals [ // enumeration rationale\n ]; }; }; }\n", )], ); let empty = compile(&input); let mut project = empty.decoded.project.clone(); let values = id(&project, "values"); if let Some(SharedBody::DataType { definition, .. }) = project.elements.get_mut(&values).unwrap().shared_mut() { *definition = DataTypeDefinition::Enumeration { literals: vec!["Alpha".into(), "Beta".into()], }; } else { panic!("enumeration"); } let added = update_bundle(&empty, &project, ProjectLimits::default()).unwrap(); assert_eq!( comments(&added.bundle.files["types.arcadia"]), comments(&empty.bundle.files["types.arcadia"]) ); let mut annotated = added.bundle.clone(); annotated .files .get_mut("types.arcadia") .unwrap() .clone_from( &added.bundle.files["types.arcadia"] .replace("\"Alpha\"", "\"Alpha\" // alpha explanation\n") .replace("\"Beta\"", "\"Beta\" // beta explanation\n"), ); let annotated = compile(&annotated); if let Some(SharedBody::DataType { definition, .. }) = project.elements.get_mut(&values).unwrap().shared_mut() { *definition = DataTypeDefinition::Enumeration { literals: vec!["Beta".into(), "Gamma".into()], }; } let changed = update_bundle(&annotated, &project, ProjectLimits::default()).unwrap(); let mut before = comments(&annotated.bundle.files["types.arcadia"]); let mut after = comments(&changed.bundle.files["types.arcadia"]); before.sort(); after.sort(); assert_eq!(before, after); assert_eq!(changed.decoded.project, project); }