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 } fn typed() -> SourceBundle { bundle( "arcadia 3; 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); assert!(result.decoded.migration.is_none()); 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 source_errors_keep_exact_fragment_unicode_positions() { let input = bundle( "arcadia 3; 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 3; 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 3; 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 3; 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 3; 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 3; 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 3; 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 3;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 3; 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 3; 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 3; 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"); }