318 lines
12 KiB
Rust
318 lines
12 KiB
Rust
//! Saved-source Details comparator. No desktop renderer, controller or history inspection.
|
|
//! name BASELINE_SOURCE ACTUAL_SOURCE OUT ELEMENT NEW_NAME
|
|
//! typed EXPECTED_PROJECT_JSON ACTUAL_SOURCE OUT CHECKPOINT
|
|
//! self-check INCIDENT_SOURCE SERVICE_SOURCE OUT
|
|
use oa_core::{
|
|
DiagramLayout, Id, Project, execute_batch, source_files,
|
|
source_project::{ProjectLimits, compile_bundle},
|
|
validation::Severity,
|
|
};
|
|
use serde_json::{Value, json};
|
|
use std::{fs, io::Write, path::Path};
|
|
|
|
const INCIDENT: &str = include_str!("fixtures/incident-workspace.arcadia");
|
|
const SERVICE: &str = include_str!("fixtures/service-details.arcadia");
|
|
const EXPECTED: [(&str, &str); 6] = [
|
|
("baseline", include_str!("expected/baseline.project.json")),
|
|
("1", include_str!("expected/after-1.expected.project.json")),
|
|
(
|
|
"3a",
|
|
include_str!("expected/after-3a.expected.project.json"),
|
|
),
|
|
(
|
|
"3b",
|
|
include_str!("expected/after-3b.expected.project.json"),
|
|
),
|
|
(
|
|
"3c",
|
|
include_str!("expected/after-3c.expected.project.json"),
|
|
),
|
|
(
|
|
"3d",
|
|
include_str!("expected/after-3d.expected.project.json"),
|
|
),
|
|
];
|
|
fn incident(last: u128) -> Id {
|
|
Id::from_u128(0x70200000000040008000000000000000 | last)
|
|
}
|
|
fn service(last: u128) -> Id {
|
|
Id::from_u128(0x80300000000040008000000000000000 | last)
|
|
}
|
|
|
|
// Object order is irrelevant; array order and every serialized scalar remain exact.
|
|
// Scalar serialization distinguishes signed zero, unlike float PartialEq.
|
|
fn canonical(value: &Value) -> String {
|
|
match value {
|
|
Value::Object(map) => {
|
|
let mut keys: Vec<_> = map.keys().collect();
|
|
keys.sort();
|
|
format!(
|
|
"{{{}}}",
|
|
keys.into_iter()
|
|
.map(|k| format!(
|
|
"{}:{}",
|
|
serde_json::to_string(k).unwrap(),
|
|
canonical(&map[k])
|
|
))
|
|
.collect::<Vec<_>>()
|
|
.join(",")
|
|
)
|
|
}
|
|
Value::Array(a) => format!(
|
|
"[{}]",
|
|
a.iter().map(canonical).collect::<Vec<_>>().join(",")
|
|
),
|
|
_ => serde_json::to_string(value).unwrap(),
|
|
}
|
|
}
|
|
fn full(project: &Project) -> String {
|
|
canonical(&serde_json::to_value(project).unwrap())
|
|
}
|
|
fn put(path: &Path, bytes: &[u8]) {
|
|
let mut f = fs::OpenOptions::new()
|
|
.write(true)
|
|
.create_new(true)
|
|
.open(path)
|
|
.unwrap();
|
|
f.write_all(bytes).unwrap();
|
|
f.sync_all().unwrap();
|
|
}
|
|
fn load_source(path: &Path, project_id: Id) -> Project {
|
|
assert!(fs::symlink_metadata(path).unwrap().file_type().is_file());
|
|
let loaded = source_files::load_project(path, ProjectLimits::default()).unwrap();
|
|
assert!(
|
|
!loaded
|
|
.diagnostics
|
|
.iter()
|
|
.any(|d| d.diagnostic.severity == Severity::Error),
|
|
"source loader errors: {:?}",
|
|
loaded.diagnostics
|
|
);
|
|
let raw = loaded.reachable_bundle();
|
|
assert_eq!(
|
|
raw.files.len(),
|
|
1,
|
|
"Bound native fixtures require exactly one source file"
|
|
);
|
|
let compiled = compile_bundle(&raw, ProjectLimits::default()).unwrap();
|
|
assert_eq!(
|
|
compiled.bundle, raw,
|
|
"No migration, identity insertion or passive source rewrite is admitted"
|
|
);
|
|
let p = compiled.decoded.project;
|
|
execute_batch(&p, &[]).unwrap();
|
|
assert_eq!(p.id, project_id, "Wrong fixture Project identity");
|
|
p
|
|
}
|
|
fn renamed(base: &Project, selected: Id, name: &str) -> Result<Project, String> {
|
|
if base.id != incident(1) {
|
|
return Err("Name mode requires the incident fixture Project".into());
|
|
}
|
|
let mut p = base.clone();
|
|
let e = p
|
|
.elements
|
|
.get_mut(&selected)
|
|
.ok_or("Selected identity must be an existing element")?;
|
|
if name.is_empty() || e.name == name {
|
|
return Err("Name must be a predetermined nonempty change".into());
|
|
}
|
|
e.name = name.into();
|
|
execute_batch(&p, &[]).map_err(|e| format!("Invalid name candidate: {e:?}"))?;
|
|
Ok(p)
|
|
}
|
|
fn approved_expected(raw: &str, checkpoint: &str) -> Result<Project, String> {
|
|
let approved = EXPECTED
|
|
.iter()
|
|
.find(|(key, _)| *key == checkpoint)
|
|
.ok_or("Unknown approved checkpoint")?
|
|
.1;
|
|
let value: Value = serde_json::from_str(raw).map_err(|e| e.to_string())?;
|
|
let p: Project = serde_json::from_value(value.clone()).map_err(|e| e.to_string())?;
|
|
// Do not silently discard unknown/defaulted fields while decoding expected JSON.
|
|
if full(&p) != canonical(&value) {
|
|
return Err("Expected JSON does not round-trip as the complete Project".into());
|
|
}
|
|
if p.id != service(0)
|
|
|| !p.elements.contains_key(&service(0x50))
|
|
|| p.diagrams.len() != 1
|
|
|| p.diagrams
|
|
.get(&service(0x70))
|
|
.and_then(|d| d.projection.scenario())
|
|
!= Some(service(0x50))
|
|
{
|
|
return Err("Wrong expected service fixture identity".into());
|
|
}
|
|
let allowed: Value = serde_json::from_str(approved).map_err(|e| e.to_string())?;
|
|
if raw.as_bytes() != approved.as_bytes() || canonical(&value) != canonical(&allowed) {
|
|
return Err("Expected Project differs from independently approved checkpoint".into());
|
|
}
|
|
execute_batch(&p, &[]).map_err(|e| format!("Expected checkpoint is not admitted: {e:?}"))?;
|
|
Ok(p)
|
|
}
|
|
fn self_check(base: &Project, typed: &Project, out: &Path) {
|
|
assert_eq!(
|
|
base.elements[&incident(0x20)].name,
|
|
"Initial incident report"
|
|
);
|
|
let name = "Initial incident report [Details trial]";
|
|
let expected = renamed(base, incident(0x20), name).unwrap();
|
|
let expected_full = full(&expected);
|
|
assert_ne!(full(base), expected_full);
|
|
let wrong = renamed(base, incident(0x21), name).unwrap();
|
|
assert_ne!(
|
|
full(&wrong),
|
|
expected_full,
|
|
"Wrong occurrence must fail full comparison"
|
|
);
|
|
let mut alias = expected.clone();
|
|
alias
|
|
.elements
|
|
.get_mut(&incident(0x20))
|
|
.unwrap()
|
|
.alias
|
|
.push_str("_extra");
|
|
assert_ne!(
|
|
full(&alias),
|
|
expected_full,
|
|
"Extra alias change must not be ignored"
|
|
);
|
|
let mut reordered = serde_json::to_value(&expected).unwrap();
|
|
reordered
|
|
.pointer_mut(&format!(
|
|
"/elements/{}/body/Oa/Shared/Scenario/steps",
|
|
incident(0x10)
|
|
))
|
|
.unwrap()
|
|
.as_array_mut()
|
|
.unwrap()
|
|
.swap(0, 1);
|
|
let reordered: Project = serde_json::from_value(reordered).unwrap();
|
|
assert_ne!(
|
|
full(&reordered),
|
|
expected_full,
|
|
"Semantic order is not normalized away"
|
|
);
|
|
let mut geometry = expected.clone();
|
|
let Some(DiagramLayout::Scenario(layout)) =
|
|
&mut geometry.diagrams.get_mut(&incident(0x40)).unwrap().layout
|
|
else {
|
|
panic!("Scenario layout required")
|
|
};
|
|
layout.lanes[0].x += 1.;
|
|
assert_ne!(
|
|
full(&geometry),
|
|
expected_full,
|
|
"Extra layout mutation rejected"
|
|
);
|
|
let mut zeros = base.clone();
|
|
let Some(DiagramLayout::Scenario(layout)) =
|
|
&mut zeros.diagrams.get_mut(&incident(0x40)).unwrap().layout
|
|
else {
|
|
panic!("Scenario layout required")
|
|
};
|
|
layout.lanes[0].x = -0.;
|
|
let negative = full(&zeros);
|
|
let Some(DiagramLayout::Scenario(layout)) =
|
|
&mut zeros.diagrams.get_mut(&incident(0x40)).unwrap().layout
|
|
else {
|
|
unreachable!()
|
|
};
|
|
layout.lanes[0].x = 0.;
|
|
assert_ne!(negative, full(&zeros));
|
|
assert!(
|
|
renamed(base, incident(0x40), name).is_err(),
|
|
"Diagram identity is outside element-name mode"
|
|
);
|
|
assert!(renamed(base, incident(0x20), "Initial incident report").is_err());
|
|
let baseline = approved_expected(EXPECTED[0].1, "baseline").unwrap();
|
|
assert_eq!(
|
|
full(typed),
|
|
full(&baseline),
|
|
"Actual admitted fixture must equal approved baseline"
|
|
);
|
|
for (key, text) in EXPECTED {
|
|
assert!(approved_expected(text, key).is_ok());
|
|
}
|
|
assert!(
|
|
approved_expected(EXPECTED[1].1, "3a").is_err(),
|
|
"Wrong checkpoint rejected"
|
|
);
|
|
let mut wrong_id: Value = serde_json::from_str(EXPECTED[0].1).unwrap();
|
|
wrong_id["id"] = json!(incident(1));
|
|
assert!(approved_expected(&canonical(&wrong_id), "baseline").is_err());
|
|
let mut unknown: Value = serde_json::from_str(EXPECTED[0].1).unwrap();
|
|
unknown["unapproved_extra"] = json!(true);
|
|
assert!(approved_expected(&canonical(&unknown), "baseline").is_err());
|
|
put(
|
|
&out.join("incident.baseline.project.json"),
|
|
full(base).as_bytes(),
|
|
);
|
|
put(
|
|
&out.join("service.baseline.project.json"),
|
|
full(typed).as_bytes(),
|
|
);
|
|
put(&out.join("self-check.json"),serde_json::to_string_pretty(&json!({"status":"passed",
|
|
"checks":["name_only_candidate","wrong_occurrence","extra_alias","semantic_array_order","extra_layout",
|
|
"signed_zero","non_element_target","unchanged_name","admitted_service_baseline", "all_six_approved_checkpoints",
|
|
"wrong_checkpoint","wrong_expected_project_identity","unknown_expected_field"],
|
|
"scope":"Comparator self-check only; no saved native output or runtime/history acceptance"})).unwrap().as_bytes());
|
|
}
|
|
fn main() {
|
|
let args: Vec<String> = std::env::args().collect();
|
|
let mode = args.get(1).expect("Expected name, typed or self-check");
|
|
assert_eq!(
|
|
args.len(),
|
|
match mode.as_str() {
|
|
"name" => 7,
|
|
"typed" => 6,
|
|
"self-check" => 5,
|
|
_ => panic!("Unknown mode"),
|
|
}
|
|
);
|
|
let out = Path::new(&args[4]);
|
|
fs::create_dir(out).expect("Output must be fresh; preserve earlier evidence");
|
|
if mode == "self-check" {
|
|
assert_eq!(fs::read_to_string(&args[2]).unwrap(), INCIDENT);
|
|
assert_eq!(fs::read_to_string(&args[3]).unwrap(), SERVICE);
|
|
self_check(
|
|
&load_source(Path::new(&args[2]), incident(1)),
|
|
&load_source(Path::new(&args[3]), service(0)),
|
|
out,
|
|
);
|
|
println!("Details comparator self-check passed; no native result");
|
|
return;
|
|
}
|
|
let (expected, actual, change) = if mode == "name" {
|
|
let base = load_source(Path::new(&args[2]), incident(1));
|
|
put(&out.join("baseline.project.json"), full(&base).as_bytes());
|
|
let selected = args[5].parse::<Id>().unwrap();
|
|
let expected = renamed(&base, selected, &args[6]).unwrap();
|
|
let old = &base.elements[&selected].name;
|
|
(
|
|
expected,
|
|
load_source(Path::new(&args[3]), incident(1)),
|
|
json!({"element":selected,"before":old,"after":args[6]}),
|
|
)
|
|
} else {
|
|
let expected = approved_expected(&fs::read_to_string(&args[2]).unwrap(), &args[5]).unwrap();
|
|
(
|
|
expected,
|
|
load_source(Path::new(&args[3]), service(0)),
|
|
json!({"approved_checkpoint":args[5]}),
|
|
)
|
|
};
|
|
let expected = full(&expected);
|
|
let actual = full(&actual);
|
|
let equal = expected == actual;
|
|
put(&out.join("expected.project.json"), expected.as_bytes());
|
|
put(&out.join("actual.project.json"), actual.as_bytes());
|
|
put(&out.join("comparison.json"),serde_json::to_string_pretty(&json!({"status":if equal {"passed"}else{"failed"},
|
|
"mode":mode,"expected_change":change,"full_project_exact":equal,
|
|
"scope":"Complete admitted saved-source Project only; no runtime, editor-buffer, camera, Undo stack or native action claim"})).unwrap().as_bytes());
|
|
assert!(
|
|
equal,
|
|
"Complete Project differs; inspect preserved expected/actual outputs"
|
|
);
|
|
println!("Details saved-source comparison passed: {mode}");
|
|
}
|