Files
ARCADIA-Studio/crates/oa-core/tests/source_project.rs
T

1205 lines
44 KiB
Rust

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);
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 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::<usize>() - 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(&current, &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(&current, &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(&current.decoded.project, "send"))
.unwrap()
.parent = Some(Id::new_v4());
assert!(update_bundle(&current, &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, &current.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::<SourceBundle>(&json).unwrap(),
generated
);
}
#[test]
fn json_bundle_rejects_duplicate_file_keys_and_unknown_fields() {
assert!(serde_json::from_str::<SourceBundle>(r#"{"entry":"project.arcadia","files":{"project.arcadia":"first","project.arcadia":"second"}}"#).unwrap_err().to_string().contains("Duplicate source path"));
assert!(
serde_json::from_str::<SourceBundle>(
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<String> {
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(&current.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(&current, &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(&current.decoded.project, "root_work"))
.unwrap()
.alias = "renamed_work".into();
let result = update_bundle(&current, &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(&current.decoded.project, "work"))
.unwrap()
.description = "New documentation".into();
let result = update_bundle(&current, &project, ProjectLimits::default()).unwrap();
let before = &current.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(&current.decoded.project, "item"))
.unwrap()
.body = ElementBody::new(Kind::Entity, Layer::Oa).unwrap();
let result = update_bundle(&current, &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(&current.bundle.files["item.arcadia"])
);
assert!(!significant_words(after).contains(&"Information".into()));
}
fn significant_words(source: &str) -> Vec<String> {
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(&current.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(&current, &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(&current.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(&current.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(&current, &project, ProjectLimits::default()).unwrap();
assert_eq!(
comments(&result.bundle.files["ports.arcadia"]),
comments(&current.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(&current, &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(&current.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(&current, &project, ProjectLimits::default()).unwrap();
let mut before = comments(&current.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(&current, &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(&current.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 &current.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);
}