Files
ARCADIA-Studio/crates/oa-cli/tests/mixed_cli.rs

491 lines
18 KiB
Rust

//! 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<PathBuf, Option<Vec<u8>>> {
fn walk(root: &Path, dir: &Path, out: &mut BTreeMap<PathBuf, Option<Vec<u8>>>) {
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<Value>) {
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::<Value>(&output.stdout).unwrap(),
self.report(exit, diagnostics)
);
}
fn report(&self, exit: i32, diagnostics: Vec<Value>) -> 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<SourceDiagnostic>) {
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::<Value>(&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::<Value>(&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":[]})
);
}