//! Actual executable tests. Expected values are authored independently of CLI output. use oa_core::language::Span; use oa_core::source_project::{SourceBundle, SourceDiagnostic}; pub use oa_core::*; use serde_json::{Value, json}; use std::{ collections::BTreeMap, fs, path::{Path, PathBuf}, process::{Command as ProcessCommand, Output}, }; #[path = "../../oa-core/src/language/analysis_source/model_oracle.rs"] mod independent_model; fn id(n: u128) -> Id { Id::from_u128(n) } fn document(body: &str) -> String { format!( "arcadia 8;\nproject p \"Project\" id \"{}\" {{\n{body}\n}}\n", id(1) ) } fn authored_span(source: &str, literal: &str) -> Span { assert_eq!(source.matches(literal).count(), 1); let start = source.find(literal).unwrap(); Span { start, end: start + literal.len(), } } struct Fixture(PathBuf); impl Fixture { fn new() -> Self { let p = std::env::temp_dir().join(format!("arcadia-mixed-cli-{}", Id::new_v4())); fs::create_dir(&p).unwrap(); Self(p) } fn put(&self, name: &str, bytes: impl AsRef<[u8]>) { let p = self.0.join(name); fs::create_dir_all(p.parent().unwrap()).unwrap(); fs::write(p, bytes).unwrap(); } fn entry(&self) -> PathBuf { self.0.join("main.arcadia") } fn inventory(&self) -> BTreeMap>> { fn walk(root: &Path, dir: &Path, out: &mut BTreeMap>>) { for item in fs::read_dir(dir).unwrap() { let p = item.unwrap().path(); if p.is_dir() { out.insert(p.strip_prefix(root).unwrap().into(), None); walk(root, &p, out); } else { out.insert( p.strip_prefix(root).unwrap().into(), Some(fs::read(p).unwrap()), ); } } } let mut out = BTreeMap::new(); walk(&self.0, &self.0, &mut out); out } fn run(&self, options: &[&str]) -> Output { let before = self.inventory(); let result = ProcessCommand::new(env!("CARGO_BIN_EXE_arcadia")) .arg("check-mixed") .arg(self.entry()) .args(options) .output() .unwrap(); assert_eq!( self.inventory(), before, "all file bytes and directory entries preserved" ); result } fn expect(&self, exit: i32, diagnostics: Vec) { let output = self.run(&["--json"]); assert_eq!( output.status.code(), Some(exit), "{}", String::from_utf8_lossy(&output.stdout) ); assert!( output.stderr.is_empty(), "{}", String::from_utf8_lossy(&output.stderr) ); assert_eq!( serde_json::from_slice::(&output.stdout).unwrap(), self.report(exit, diagnostics) ); } fn report(&self, exit: i32, diagnostics: Vec) -> Value { json!({"format_version":1,"entry":self.entry().to_string_lossy(),"operation":"check-mixed", "status":if exit==0 {"checked"}else{"invalid"},"exit_code":exit,"written":false, "diagnostics":diagnostics,"changes":[],"identities_to_stabilize":0,"migration":null,"notes":[]}) } } impl Drop for Fixture { fn drop(&mut self) { fs::remove_dir_all(&self.0).unwrap(); } } fn diagnostic(source: &str, path: &str, span: Span, code: &str, message: &str) -> Value { let prefix = &source[..span.start]; json!({"severity":"error","code":code,"message":message,"coordinate_space":"input", "location":{"path":path,"start":span.start,"end":span.end, "line":prefix.bytes().filter(|b|*b==b'\n').count()+1, "column":prefix.rsplit('\n').next().unwrap().chars().count()+1}, "related":[],"object_id":null,"field":null}) } fn warning(d: &SourceDiagnostic) -> Value { let at = &d.diagnostic; assert!(d.related.is_empty()); json!({"severity":"warning","code":at.code,"message":at.message,"coordinate_space":"input", "location":{"path":d.path,"start":at.span.start,"end":at.span.end,"line":at.line,"column":at.column}, "related":[],"object_id":at.object_id.unwrap().to_string(),"field":at.field}) } fn rich(layer: Layer, crlf: bool) -> (SourceBundle, Project, Vec) { let (ordinary, typed, work) = match layer { Layer::Oa => ( include_str!( "../../oa-core/src/language/analysis_source/lower/fixtures/ordinary-oa.arcadia" ), include_str!( "../../oa-core/src/language/analysis_source/lower/fixtures/typed-oa.arcadia" ), "oa_activity", ), Layer::Sa => ( include_str!( "../../oa-core/src/language/analysis_source/lower/fixtures/ordinary-sa.arcadia" ), include_str!( "../../oa-core/src/language/analysis_source/lower/fixtures/typed-sa.arcadia" ), "sa_function", ), }; let mut expected = independent_model::project(layer); let mut main = ordinary .replace("\r\n", "\n") .replacen("arcadia 7;", "arcadia 8;", 1); assert!(main.starts_with("arcadia 8;")); // Forward typed uses refer back into nested ordinary Work in this file. main = main.replacen("{\n", "{\n include \"typed.arcadia\";\n", 1); if layer == Layer::Sa { let system = Element { id: id(999), alias: "system".into(), name: "System".into(), description: String::new(), parent: None, body: ElementBody::Sa(SaBody::System), }; let at = main.rfind('}').unwrap(); main.insert_str( at, &format!( " sa_system system \"System\" id \"{}\" {{}}\n", system.id ), ); assert!(expected.elements.insert(system.id, system).is_none()); } let mut typed = typed.replace("\r\n", "\n"); // Textual argument order is independent of the model's parameter-ID map. assert!(typed.contains("@combine(a: @receive_time, b: @dispatch_time)")); typed = typed.replace( "@combine(a: @receive_time, b: @dispatch_time)", "@combine(b: @dispatch_time, a: @receive_time)", ); if crlf { main = format!("// 港🌊\r\n{}", main.replace('\n', "\r\n")); typed = typed.replace('\n', "\r\n"); } let bundle = SourceBundle { entry: "main.arcadia".into(), files: BTreeMap::from([ ("main.arcadia".into(), main), ("typed.arcadia".into(), typed), ]), }; // Two nested leaves, so four warnings, unlike the earlier one-Work fixture. // Existing validation orders both allocation warnings before coverage warnings. let mut warnings = Vec::new(); for allocation in [true, false] { for (alias, display) in [("receive", "Receive"), ("dispatch", "Dispatch")] { let element = expected .elements .values() .find(|e| e.alias == alias) .unwrap(); let literal = format!("{work} {alias} \"{display}\" id \"{}\" {{}}", element.id); let source = &bundle.files["main.arcadia"]; let span = authored_span(source, &literal); let prefix = &source[..span.start]; warnings.push(SourceDiagnostic { path: "main.arcadia".into(), related: Vec::new(), diagnostic: language::Diagnostic { severity: validation::Severity::Warning, code: if allocation { "unallocated_leaf" } else { "work_without_behavior_context" }.into(), object_id: Some(element.id), field: allocation.then(|| "allocation".into()), message: if allocation { format!("Allocate leaf '{display}' to a performer.") } else { "Include this leaf activity/function in a process/chain or scenario through a work action or exchange occurrence.".into() }, span, line: prefix.bytes().filter(|b| *b == b'\n').count() + 1, column: prefix.rsplit('\n').next().unwrap().chars().count() + 1, }, }); } } (bundle, expected, warnings) } #[test] fn mixed_cli_complete_oa_sa_models_reports_and_unchanged_bytes() { for layer in [Layer::Oa, Layer::Sa] { for crlf in [false, true] { let (mut bundle, expected, _) = rich(layer, crlf); // Escaped include path and a decoy in a comment exercise real grammar discovery. let main = bundle.files.get_mut("main.arcadia").unwrap(); *main = main.replace("typed.arcadia", "typed\\u002earcadia"); let comment = if crlf { "// include \"missing.arcadia\"; 港🌊\r\n" } else { "// include \"missing.arcadia\"; 港🌊\n" }; main.insert_str(0, comment); // Rebuild only warning locations from unique authored declaration literals, // never from parsed diagnostics or emitted reports. let (_, _, mut warnings) = rich(layer, crlf); for d in &mut warnings { let e = &expected.elements[&d.diagnostic.object_id.unwrap()]; let work = if layer == Layer::Oa { "oa_activity" } else { "sa_function" }; let literal = format!("{work} {} \"{}\" id \"{}\" {{}}", e.alias, e.name, e.id); let span = authored_span(main, &literal); let prefix = &main[..span.start]; d.diagnostic.span = span; d.diagnostic.line = prefix.bytes().filter(|b| *b == b'\n').count() + 1; d.diagnostic.column = prefix.rsplit('\n').next().unwrap().chars().count() + 1; } let f = Fixture::new(); for (path, text) in &bundle.files { f.put(path, text); } f.put("unrelated/keep.bin", [0, 255, 13, 10]); let snapshot = source_files::load_mixed_source(&f.entry(), oa_cli::LIMITS).unwrap(); assert!(snapshot.diagnostics().is_empty()); assert_eq!(snapshot.bundle(), &bundle); let checked = language::check_mixed_bundle(snapshot.bundle(), oa_cli::LIMITS) .unwrap_or_else(|e| panic!("{e:#?}")); assert_eq!(checked.project(), &expected); assert_eq!(checked.warnings(), warnings); f.expect(0, warnings.iter().map(warning).collect()); } } } #[test] fn mixed_cli_missing_id_classes_and_schema_are_exact_refusals() { let ordinary = format!("oa_entity e \"Entity\" id \"{}\" {{}}", id(2)); let typed = format!( "calculation f \"Calculation\" id \"{}\" {{ input x id \"{}\" : Number; result Number; body $x; }}", id(3), id(1) ); let valid = document(&format!("{ordinary}\n{typed}")); let f = Fixture::new(); f.put("main.arcadia", &valid); f.expect(0, vec![]); for (needle, replacement, anchor) in [ (format!("id \"{}\"", id(1)), String::new(), "project"), (format!("id \"{}\"", id(2)), String::new(), "oa_entity"), (format!("id \"{}\"", id(3)), String::new(), "calculation"), ( format!("input x id \"{}\"", id(1)), "input x".into(), "calculation", ), ] { let source = valid.replacen(&needle, &replacement, 1); let begin = source.find(anchor).unwrap(); let start = if anchor == "calculation" { begin } else { begin + source[begin..].find('{').unwrap() }; f.put("main.arcadia", &source); f.expect( 1, vec![diagnostic( &source, "main.arcadia", Span { start, end: start + 1, }, "invalid_source", "Mixed source8 requires explicit UUIDs.", )], ); } for schema in [3, 7, 9] { let source = document("").replacen("arcadia 8", &format!("arcadia {schema}"), 1); f.put("main.arcadia", &source); f.expect( 1, vec![diagnostic( &source, "main.arcadia", Span { start: 8, end: 9 }, "invalid_source", "Mixed source requires arcadia 8.", )], ); } } #[test] fn mixed_cli_final_semantic_refusal_keeps_original_cross_file_coordinates() { let f = Fixture::new(); f.put("main.arcadia", document("include \"part.arcadia\";")); let part = format!( "// 港🌊\r\ncalculation f \"Calculation\" id \"{}\" {{ input x id \"{}\" : Number; result Number; body @missing(x: $x); }}\r\n", id(2), id(1) ); let start = part.find("@missing").unwrap() + 1; f.put("part.arcadia", &part); f.expect( 1, vec![diagnostic( &part, "part.arcadia", Span { start, end: start + 7, }, "invalid_source", "Unknown global reference '@missing'.", )], ); } #[test] fn mixed_cli_disk_discovery_and_cycle_refusals_preserve_every_entry() { let f = Fixture::new(); let source = document("include \"part.arcadia\";"); let span = authored_span(&source, "include \"part.arcadia\";"); f.put("main.arcadia", &source); let missing = f.0.canonicalize().unwrap().join("part.arcadia"); let os_error = fs::symlink_metadata(&missing).unwrap_err(); f.expect( 3, vec![diagnostic( &source, "main.arcadia", span, "missing_include", &format!( "Cannot load 'part.arcadia': Source file operation failed at {}: {os_error}", missing.display() ), )], ); f.put("part.arcadia", [0xff]); f.expect( 1, vec![diagnostic( &source, "main.arcadia", span, "source_encoding", "'part.arcadia' is not UTF-8 source.", )], ); let part = "include \"main.arcadia\";"; f.put("part.arcadia", part); f.expect( 1, vec![diagnostic( part, "part.arcadia", Span { start: 0, end: 23 }, "include_cycle", "Include cycle: main.arcadia -> part.arcadia -> main.arcadia.", )], ); let escape = document("include \"../escape.arcadia\";"); f.put("main.arcadia", &escape); f.expect( 3, vec![diagnostic( &escape, "main.arcadia", authored_span(&escape, "include \"../escape.arcadia\";"), "invalid_path", "Include escapes the source project root.", )], ); f.put("main.arcadia", document("")); let journal = f.0.join(".arcadia-save-main.arcadia.journal"); fs::create_dir(&journal).unwrap(); fs::write(journal.join("sentinel"), b"keep").unwrap(); let physical = f.0.canonicalize() .unwrap() .join(".arcadia-save-main.arcadia.journal"); f.expect( 3, vec![diagnostic( &document(""), "main.arcadia", Span { start: 0, end: 0 }, "pending_save", &format!( "A save was interrupted. Review and recover {} before saving this project.", physical.display() ), )], ); } #[test] fn mixed_cli_usage_and_human_output_never_suggest_an_edit_candidate() { let f = Fixture::new(); f.put("main.arcadia", document("")); let output = f.run(&[]); assert!(output.status.success()); assert!(output.stdout.is_empty()); assert_eq!( output.stderr, b"checked: mixed source8 check; no files changed\n" ); for options in [ vec!["--write", "--json"], vec!["--unknown", "--json"], vec!["--json", "--json"], ] { let output = f.run(&options); assert_eq!(output.status.code(), Some(2)); assert!(output.stderr.is_empty()); let message = if options[0] == "--write" { oa_cli::HELP.to_string() } else if options[0] == "--unknown" { "Unknown or repeated option: --unknown".into() } else { "Unknown or repeated option: --json".into() }; assert_eq!( serde_json::from_slice::(&output.stdout).unwrap(), json!({"format_version":1,"entry":"","operation":"usage","status":"rejected","exit_code":2,"written":false, "diagnostics":[{"severity":"error","code":"usage","message":message,"coordinate_space":"operation","location":null,"related":[],"object_id":null,"field":null}],"changes":[],"identities_to_stabilize":0,"migration":null,"notes":[]}) ); } } #[test] fn mixed_cli_direct_disk_error_uses_existing_operation_error_envelope() { let f = Fixture::new(); f.put(".arcadia-save-reserved.arcadia", document("")); let before = f.inventory(); let entry = f.0.join(".arcadia-save-reserved.arcadia"); let output = ProcessCommand::new(env!("CARGO_BIN_EXE_arcadia")) .arg("check-mixed") .arg(&entry) .arg("--json") .output() .unwrap(); assert_eq!(f.inventory(), before); assert_eq!(output.status.code(), Some(3)); assert!(output.stderr.is_empty()); assert_eq!( serde_json::from_slice::(&output.stdout).unwrap(), json!({"format_version":1,"entry":entry.to_string_lossy(),"operation":"check-mixed","status":"rejected","exit_code":3,"written":false, "diagnostics":[{"severity":"error","code":"unsafe_path","message":"Unsafe source path: Non-canonical, reserved or overlong path '.arcadia-save-reserved.arcadia'.","coordinate_space":"operation","location":null,"related":[],"object_id":null,"field":null}],"changes":[],"identities_to_stabilize":0,"migration":null,"notes":[]}) ); }