426 lines
16 KiB
Rust
426 lines
16 KiB
Rust
use oa_cli::{LIMITS, Operation, Prepared, prepare};
|
|
use oa_core::{Id, Project, source_files, source_project};
|
|
use serde_json::Value;
|
|
use std::{
|
|
collections::BTreeMap,
|
|
fs,
|
|
path::{Path, PathBuf},
|
|
process::Command,
|
|
};
|
|
|
|
struct Fixture(PathBuf);
|
|
impl Fixture {
|
|
fn new() -> Self {
|
|
let path = std::env::temp_dir().join(format!("arcadia-cli-test-{}", Id::new_v4()));
|
|
fs::create_dir(&path).unwrap();
|
|
Self(path)
|
|
}
|
|
fn put(&self, name: &str, text: &str) {
|
|
let path = self.0.join(name);
|
|
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
|
fs::write(path, text).unwrap();
|
|
}
|
|
fn entry(&self) -> PathBuf {
|
|
self.0.join("project.arcadia")
|
|
}
|
|
fn read(&self, name: &str) -> String {
|
|
fs::read_to_string(self.0.join(name)).unwrap()
|
|
}
|
|
fn prepare(&self, op: Operation) -> Prepared {
|
|
prepare(&self.entry(), op).unwrap_or_else(|report| panic!("{report:#?}"))
|
|
}
|
|
fn model(&self) -> Project {
|
|
let disk = source_files::load_project(&self.entry(), LIMITS).unwrap();
|
|
source_project::compile_bundle(&disk.reachable_bundle(), LIMITS)
|
|
.unwrap()
|
|
.decoded
|
|
.project
|
|
}
|
|
fn cli(&self, args: &[&str]) -> (i32, Value) {
|
|
let mut command = Command::new(env!("CARGO_BIN_EXE_arcadia"));
|
|
command
|
|
.arg(args[0])
|
|
.arg(self.entry())
|
|
.args(&args[1..])
|
|
.arg("--json");
|
|
let output = command.output().unwrap();
|
|
assert!(
|
|
output.stderr.is_empty(),
|
|
"{}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
(
|
|
output.status.code().unwrap(),
|
|
serde_json::from_slice(&output.stdout).unwrap(),
|
|
)
|
|
}
|
|
}
|
|
impl Drop for Fixture {
|
|
fn drop(&mut self) {
|
|
let _ = fs::remove_dir_all(&self.0);
|
|
}
|
|
}
|
|
|
|
const SIMPLE: &str = "// 雨: authored defaults stay omitted\r\narcadia 7;\r\nproject rescue \"Rescue\" {\r\n oa_entity team \"Team\" {}\r\n}\r\n";
|
|
|
|
#[test]
|
|
fn complete_alarm_example_preview_then_write_and_fixed_point() {
|
|
let fixture = Fixture::new();
|
|
let source = include_str!(
|
|
"../../../harness/experiments/textual-authoring-20260911/alarm-response.arcadia"
|
|
);
|
|
fixture.put("project.arcadia", source);
|
|
let (code, check) = fixture.cli(&["check"]);
|
|
assert_eq!(code, 0);
|
|
assert_eq!(check["diagnostics"], serde_json::json!([]));
|
|
let (code, preview) = fixture.cli(&["stabilize"]);
|
|
assert_eq!(code, 0);
|
|
assert_eq!(preview["identities_to_stabilize"], 40);
|
|
assert_eq!(preview["status"], "preview");
|
|
assert_eq!(fixture.read("project.arcadia"), source);
|
|
assert!(
|
|
preview["changes"][0]["diff"]
|
|
.as_str()
|
|
.unwrap()
|
|
.contains("+project alarm_response")
|
|
);
|
|
let (code, written) = fixture.cli(&["stabilize", "--write"]);
|
|
assert_eq!(code, 0);
|
|
let saved = fixture.read("project.arcadia");
|
|
assert_eq!(written["changes"][0]["after"], saved);
|
|
let project = fixture.model();
|
|
assert_eq!(project.identities().len(), 40);
|
|
let mut stripped = saved.clone();
|
|
for (id, _) in project.identities() {
|
|
stripped = stripped.replace(&format!(" id \"{id}\""), "");
|
|
}
|
|
assert_eq!(stripped, source, "only identity clauses may be inserted");
|
|
let (code, again) = fixture.cli(&["stabilize", "--write"]);
|
|
assert_eq!(code, 0);
|
|
assert_eq!(again["changes"], serde_json::json!([]));
|
|
assert_eq!(again["status"], "unchanged");
|
|
assert_eq!(fixture.model(), project);
|
|
assert_eq!(fixture.read("project.arcadia"), saved);
|
|
}
|
|
|
|
#[test]
|
|
fn captured_candidate_exact_write_preserves_crlf_comments_and_noop_metadata() {
|
|
let fixture = Fixture::new();
|
|
fixture.put("project.arcadia", SIMPLE);
|
|
let candidate = fixture.prepare(Operation::Stabilize);
|
|
let expected = candidate.report().changes[0].after.clone();
|
|
assert_eq!(candidate.report().identities_to_stabilize, 2);
|
|
assert_eq!(fixture.read("project.arcadia"), SIMPLE);
|
|
assert_eq!(candidate.write().exit_code, 0);
|
|
assert_eq!(fixture.read("project.arcadia"), expected);
|
|
assert!(expected.contains("// 雨: authored defaults stay omitted\r\n"));
|
|
assert!(!expected.contains("description"));
|
|
let metadata = fs::metadata(fixture.entry()).unwrap().modified().unwrap();
|
|
let report = fixture.prepare(Operation::Stabilize).write();
|
|
assert_eq!(report.status, "unchanged");
|
|
assert!(!report.written);
|
|
assert_eq!(
|
|
fs::metadata(fixture.entry()).unwrap().modified().unwrap(),
|
|
metadata
|
|
);
|
|
assert_eq!(fs::read_dir(&fixture.0).unwrap().count(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn alias_rename_across_modules_changes_only_live_tokens_and_retains_all_identity() {
|
|
let fixture = Fixture::new();
|
|
fixture.put("project.arcadia", "arcadia 7; project rescue \"Rescue\" { include \"participants.arcadia\"; include \"work.arcadia\"; }\n");
|
|
fixture.put(
|
|
"participants.arcadia",
|
|
"// @team is quoted documentation\noa_entity team \"Team @team\" {}\n",
|
|
);
|
|
fixture.put("work.arcadia", "oa_activity assess \"Assess\" {}\noa_activity report \"Report\" {}\nallocation assigned \"Assigned\" { source @team; target @assess; }\ninteraction transfer \"Transfer\" { source @assess; target @report; }\n");
|
|
assert_eq!(fixture.prepare(Operation::Stabilize).write().exit_code, 0);
|
|
let before = fixture.model();
|
|
let original: BTreeMap<_, _> = ["project.arcadia", "participants.arcadia", "work.arcadia"]
|
|
.into_iter()
|
|
.map(|p| (p, fixture.read(p)))
|
|
.collect();
|
|
let team = before
|
|
.identities()
|
|
.into_iter()
|
|
.find(|(_, alias)| *alias == "team")
|
|
.unwrap()
|
|
.0;
|
|
let candidate = fixture.prepare(Operation::RenameAlias {
|
|
old: "team".into(),
|
|
new: "rescue_team".into(),
|
|
});
|
|
assert_eq!(candidate.report().changes.len(), 2);
|
|
assert_eq!(fixture.model(), before, "preview cannot mutate");
|
|
assert_eq!(candidate.write().exit_code, 0);
|
|
let mut expected = before;
|
|
expected.elements.get_mut(&team).unwrap().alias = "rescue_team".into();
|
|
assert_eq!(fixture.model(), expected);
|
|
assert_eq!(fixture.read("project.arcadia"), original["project.arcadia"]);
|
|
assert_eq!(
|
|
fixture.read("participants.arcadia"),
|
|
original["participants.arcadia"].replacen("oa_entity team ", "oa_entity rescue_team ", 1)
|
|
);
|
|
assert_eq!(
|
|
fixture.read("work.arcadia"),
|
|
original["work.arcadia"].replace("source @team;", "source @rescue_team;")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_unicode_raw_edit_is_located_and_never_stabilized_or_written() {
|
|
let fixture = Fixture::new();
|
|
let raw = "arcadia 7; project p \"雨\" {\n oa_activity a \"A\" {}\n allocation alloc \"Alloc\" { source @missing; target @a; }\n}\n";
|
|
fixture.put("project.arcadia", raw);
|
|
for command in [
|
|
vec!["check"],
|
|
vec!["stabilize", "--write"],
|
|
vec!["rename-alias", "a", "b", "--write"],
|
|
] {
|
|
let (code, report) = fixture.cli(&command);
|
|
assert_eq!(code, 1);
|
|
assert_eq!(fixture.read("project.arcadia"), raw);
|
|
let diagnostics = report["diagnostics"].as_array().unwrap();
|
|
assert!(!diagnostics.is_empty());
|
|
for d in diagnostics {
|
|
assert_eq!(d["coordinate_space"], "input");
|
|
let at = &d["location"];
|
|
let start = at["start"].as_u64().unwrap() as usize;
|
|
assert!(raw.is_char_boundary(start));
|
|
assert_eq!(
|
|
at["line"].as_u64().unwrap() as usize,
|
|
raw[..start].bytes().filter(|b| *b == b'\n').count() + 1
|
|
);
|
|
let line = raw[..start]
|
|
.rsplit_once('\n')
|
|
.map_or(&raw[..start], |(_, tail)| tail);
|
|
assert_eq!(
|
|
at["column"].as_u64().unwrap() as usize,
|
|
line.chars().count() + 1
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn missing_id_warning_uses_raw_location_and_never_exposes_ephemeral_lint_id() {
|
|
let fixture = Fixture::new();
|
|
let raw = "arcadia 7;\nproject p \"雨\" {\n oa_activity work \"Work\" {}\n}\n";
|
|
fixture.put("project.arcadia", raw);
|
|
let (code, result) = fixture.cli(&["lint"]);
|
|
assert_eq!(code, 0, "engineering warnings do not reject admission");
|
|
let warning = result["diagnostics"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.find(|d| d["code"] == "work_without_behavior_context")
|
|
.unwrap();
|
|
assert_eq!(warning["severity"], "warning");
|
|
assert_eq!(
|
|
warning["location"]["start"],
|
|
raw.find("oa_activity").unwrap()
|
|
);
|
|
assert_eq!(warning["location"]["line"], 3);
|
|
assert_eq!(warning["location"]["column"], 5);
|
|
assert!(warning["object_id"].is_null());
|
|
assert_eq!(fixture.read("project.arcadia"), raw);
|
|
}
|
|
|
|
#[test]
|
|
fn captured_dependency_change_refuses_all_writes_even_on_noop() {
|
|
let fixture = Fixture::new();
|
|
fixture.put("project.arcadia", SIMPLE);
|
|
let pending = fixture.prepare(Operation::Stabilize);
|
|
let external = format!("{SIMPLE}// editor changed this\n");
|
|
fixture.put("project.arcadia", &external);
|
|
let result = pending.write();
|
|
assert_eq!(result.exit_code, 3);
|
|
assert_eq!(result.diagnostics.last().unwrap().code, "external_change");
|
|
assert_eq!(fixture.read("project.arcadia"), external);
|
|
assert_eq!(fixture.prepare(Operation::Stabilize).write().exit_code, 0);
|
|
let noop = fixture.prepare(Operation::Stabilize);
|
|
assert!(noop.report().changes.is_empty());
|
|
let latest = format!("{}// another edit\n", fixture.read("project.arcadia"));
|
|
fixture.put("project.arcadia", &latest);
|
|
assert_eq!(noop.write().exit_code, 3);
|
|
assert_eq!(fixture.read("project.arcadia"), latest);
|
|
}
|
|
|
|
#[test]
|
|
fn pending_journal_refuses_read_preview_and_write_without_recovery() {
|
|
let fixture = Fixture::new();
|
|
fixture.put("project.arcadia", SIMPLE);
|
|
assert_eq!(fixture.prepare(Operation::Stabilize).write().exit_code, 0);
|
|
let noop = fixture.prepare(Operation::Stabilize);
|
|
let source = fixture.read("project.arcadia");
|
|
// An opaque incomplete journal must never be interpreted/recovered by CLI.
|
|
let journal = fixture.0.join(".arcadia-save-project.arcadia.journal");
|
|
fs::create_dir(&journal).unwrap();
|
|
fs::write(journal.join("sentinel"), b"retain").unwrap();
|
|
for op in [Operation::Check, Operation::Stabilize] {
|
|
let report = prepare(&fixture.entry(), op).err().unwrap();
|
|
assert_eq!(report.exit_code, 3);
|
|
assert!(report.diagnostics.iter().any(|d| d.code == "pending_save"));
|
|
}
|
|
assert_eq!(
|
|
noop.write().exit_code,
|
|
3,
|
|
"saver must recheck after preview"
|
|
);
|
|
assert_eq!(fixture.read("project.arcadia"), source);
|
|
assert_eq!(fs::read(journal.join("sentinel")).unwrap(), b"retain");
|
|
}
|
|
|
|
#[test]
|
|
fn rejected_alias_and_same_alias_preserve_source() {
|
|
let fixture = Fixture::new();
|
|
fixture.put("project.arcadia", SIMPLE);
|
|
assert_eq!(fixture.prepare(Operation::Stabilize).write().exit_code, 0);
|
|
let source = fixture.read("project.arcadia");
|
|
for (old, new) in [
|
|
("team", "rescue"),
|
|
("team", "invalid alias"),
|
|
("absent", "valid"),
|
|
] {
|
|
let (code, result) = fixture.cli(&["rename-alias", old, new, "--write"]);
|
|
assert_eq!(code, 1);
|
|
assert_eq!(result["status"], "rejected");
|
|
assert_eq!(fixture.read("project.arcadia"), source);
|
|
}
|
|
let (code, result) = fixture.cli(&["rename-alias", "team", "team", "--write"]);
|
|
assert_eq!(code, 0);
|
|
assert_eq!(result["status"], "unchanged");
|
|
assert_eq!(fixture.read("project.arcadia"), source);
|
|
}
|
|
|
|
#[test]
|
|
fn rename_shows_missing_identity_and_separate_source_migration() {
|
|
let fixture = Fixture::new();
|
|
let old = "// retain\narcadia 5; project p \"P\" { oa_entity team \"Team\" {} }\n";
|
|
fixture.put("project.arcadia", old);
|
|
let candidate = fixture.prepare(Operation::RenameAlias {
|
|
old: "team".into(),
|
|
new: "crew".into(),
|
|
});
|
|
let report = candidate.report();
|
|
assert_eq!(report.identities_to_stabilize, 2);
|
|
assert_eq!(report.migration.as_ref().unwrap().from_schema, 5);
|
|
assert_eq!(report.migration.as_ref().unwrap().to_schema, 7);
|
|
assert!(report.changes[0].diff.contains("-arcadia 5;"));
|
|
assert!(report.changes[0].diff.contains("+arcadia 7;"));
|
|
assert!(report.changes[0].after.contains("oa_entity crew"));
|
|
assert_eq!(fixture.read("project.arcadia"), old);
|
|
assert_eq!(candidate.write().exit_code, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn entry_is_explicit_and_machine_readable_usage_errors_do_not_write() {
|
|
let fixture = Fixture::new();
|
|
fixture.put("project.arcadia", SIMPLE);
|
|
let output = Command::new(env!("CARGO_BIN_EXE_arcadia"))
|
|
.current_dir(&fixture.0)
|
|
.args(["check", "--json"])
|
|
.output()
|
|
.unwrap();
|
|
assert_eq!(output.status.code(), Some(2));
|
|
let error: Value = serde_json::from_slice(&output.stdout).unwrap();
|
|
assert_eq!(error["diagnostics"][0]["code"], "usage");
|
|
assert_eq!(fixture.read("project.arcadia"), SIMPLE);
|
|
let (code, _) = fixture.cli(&["check", "--write"]);
|
|
assert_eq!(code, 2);
|
|
assert!(Path::new(env!("CARGO_BIN_EXE_arcadia")).is_file());
|
|
}
|
|
|
|
#[test]
|
|
fn loader_encoding_missing_dependency_and_invalid_old_source_keep_original_bytes() {
|
|
let fixture = Fixture::new();
|
|
let entry = "arcadia 7; project p \"P\" { include \"part.arcadia\"; }";
|
|
fixture.put("project.arcadia", entry);
|
|
let (code, report) = fixture.cli(&["stabilize", "--write"]);
|
|
assert_eq!(code, 3, "a disk dependency could not be read");
|
|
assert!(
|
|
report["diagnostics"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.any(|d| d["code"] == "missing_include")
|
|
);
|
|
fs::write(fixture.0.join("part.arcadia"), [0xff, 0xfe]).unwrap();
|
|
let (code, report) = fixture.cli(&["stabilize", "--write"]);
|
|
assert_eq!(code, 1, "invalid encoding is invalid raw source");
|
|
assert!(
|
|
report["diagnostics"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.any(|d| d["code"] == "source_encoding")
|
|
);
|
|
assert_eq!(fixture.read("project.arcadia"), entry);
|
|
assert_eq!(
|
|
fs::read(fixture.0.join("part.arcadia")).unwrap(),
|
|
[0xff, 0xfe]
|
|
);
|
|
for raw in [
|
|
"arcadia 5; project p \"P\" { oa_entity a \"A\" { ??? } }",
|
|
"arcadia 2; project p \"P\" {}",
|
|
] {
|
|
fixture.put("project.arcadia", raw);
|
|
let (code, _) = fixture.cli(&["stabilize", "--write"]);
|
|
assert_eq!(code, 1);
|
|
assert_eq!(fixture.read("project.arcadia"), raw);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn non_element_alias_detached_file_and_unchanged_dependency_conflict() {
|
|
let fixture = Fixture::new();
|
|
fixture.put("project.arcadia", "arcadia 7; project p \"P\" { include \"part.arcadia\"; oa_activity a \"A\" {} allocation responsible \"Responsible\" { source @ // keep edge comment\nteam; target @a; } }");
|
|
fixture.put("part.arcadia", "oa_entity team \"Team\" {}");
|
|
fixture.put(
|
|
"detached.arcadia",
|
|
"invalid detached draft must stay exact 雨",
|
|
);
|
|
assert_eq!(fixture.prepare(Operation::Stabilize).write().exit_code, 0);
|
|
let before = fixture.model();
|
|
let relation = before
|
|
.identities()
|
|
.into_iter()
|
|
.find(|(_, alias)| *alias == "responsible")
|
|
.unwrap()
|
|
.0;
|
|
let (code, _) = fixture.cli(&["rename-alias", "responsible", "performs", "--write"]);
|
|
assert_eq!(code, 0);
|
|
let mut expected = before;
|
|
expected.relations.get_mut(&relation).unwrap().alias = "performs".into();
|
|
assert_eq!(fixture.model(), expected);
|
|
assert!(
|
|
fixture
|
|
.read("project.arcadia")
|
|
.contains("@ // keep edge comment\nteam")
|
|
);
|
|
let captured = fixture.prepare(Operation::RenameAlias {
|
|
old: "p".into(),
|
|
new: "rescue".into(),
|
|
});
|
|
assert!(
|
|
captured
|
|
.report()
|
|
.changes
|
|
.iter()
|
|
.all(|c| c.path != "part.arcadia")
|
|
);
|
|
let root_before = fixture.read("project.arcadia");
|
|
fixture.put("part.arcadia", "// external unchanged-dependency edit\n");
|
|
assert_eq!(captured.write().exit_code, 3);
|
|
assert_eq!(fixture.read("project.arcadia"), root_before);
|
|
assert_eq!(
|
|
fixture.read("part.arcadia"),
|
|
"// external unchanged-dependency edit\n"
|
|
);
|
|
assert_eq!(
|
|
fixture.read("detached.arcadia"),
|
|
"invalid detached draft must stay exact 雨"
|
|
);
|
|
}
|