feat: add exact saved-view multiselection and atomic group movement
This commit is contained in:
@@ -0,0 +1,716 @@
|
||||
//! Native evidence fixture/checker; install as a temporary oa-core example.
|
||||
//! This invariant evaluator derives delta from saved A, NOT from pointer input.
|
||||
use oa_core::{source_project::*, *};
|
||||
use std::{fs, io::Write, path::Path};
|
||||
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
|
||||
|
||||
fn id(alias: &str) -> Id {
|
||||
// Make the intended OA view first for default UUID-ordered view selection.
|
||||
if alias == "oa_view" {
|
||||
return Id::from_u128(0x29_0001);
|
||||
}
|
||||
Id::new_v5(
|
||||
&Id::from_u128(0xc1192026_0923_5000_8000_000000000029),
|
||||
alias.as_bytes(),
|
||||
)
|
||||
}
|
||||
fn point(x: f32, y: f32) -> Position {
|
||||
Position { x, y }
|
||||
}
|
||||
fn element(p: &mut Project, alias: &str, name: &str, kind: Kind, layer: Layer, parent: Option<Id>) {
|
||||
p.elements.insert(
|
||||
id(alias),
|
||||
Element {
|
||||
id: id(alias),
|
||||
alias: alias.into(),
|
||||
name: name.into(),
|
||||
description: format!("Preserve engineering record {alias}."),
|
||||
parent,
|
||||
body: ElementBody::new(kind, layer).unwrap(),
|
||||
},
|
||||
);
|
||||
}
|
||||
fn diagram(p: &mut Project, alias: &str, name: &str, layer: Layer) {
|
||||
p.diagrams.insert(
|
||||
id(alias),
|
||||
Diagram {
|
||||
id: id(alias),
|
||||
alias: alias.into(),
|
||||
name: name.into(),
|
||||
description: "Selection changes placement geometry only.".into(),
|
||||
layer,
|
||||
projection: ProjectionKind::Overview,
|
||||
view_options: Default::default(),
|
||||
nodes: Default::default(),
|
||||
routes: vec![],
|
||||
},
|
||||
);
|
||||
}
|
||||
fn place(p: &mut Project, view: &str, alias: &str, object: &str, position: Position, size: Size) {
|
||||
p.diagrams.get_mut(&id(view)).unwrap().nodes.insert(
|
||||
id(alias),
|
||||
NodePlacement {
|
||||
id: id(alias),
|
||||
alias: alias.into(),
|
||||
element: id(object),
|
||||
position,
|
||||
size,
|
||||
collapsed: false,
|
||||
port_anchors: vec![],
|
||||
},
|
||||
);
|
||||
}
|
||||
fn route(edge: &str, pins: Vec<Position>) -> EdgeRoute {
|
||||
EdgeRoute {
|
||||
edge: id(edge),
|
||||
waypoints: pins,
|
||||
source_attachment: Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Right,
|
||||
offset: 0.25,
|
||||
}),
|
||||
target_attachment: Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Left,
|
||||
offset: 0.7,
|
||||
}),
|
||||
label: Some(EdgeLabel {
|
||||
fraction: 0.45,
|
||||
offset: point(7., -19.),
|
||||
}),
|
||||
}
|
||||
}
|
||||
fn fixture() -> Result<Project> {
|
||||
let mut p = Project::empty();
|
||||
p.id = id("project");
|
||||
p.alias = "multiselect_native".into();
|
||||
p.name = "Multiselect native".into();
|
||||
p.description =
|
||||
"Shift-select A and B; move together, retain C and engineering semantics.".into();
|
||||
for (alias, name, parent) in [
|
||||
("activity_a", "A - receive request", None),
|
||||
("activity_b", "B - assess request", None),
|
||||
("activity_c", "C - dispatch response", None),
|
||||
(
|
||||
"activity_child",
|
||||
"A.1 - classify request",
|
||||
Some(id("activity_a")),
|
||||
),
|
||||
] {
|
||||
element(&mut p, alias, name, Kind::Activity, Layer::Oa, parent);
|
||||
}
|
||||
element(
|
||||
&mut p,
|
||||
"sa_function",
|
||||
"Technical request processing",
|
||||
Kind::Function,
|
||||
Layer::Sa,
|
||||
None,
|
||||
);
|
||||
for (alias, name, source, target) in [
|
||||
("internal", "Request assessment", "activity_a", "activity_b"),
|
||||
("external", "Response decision", "activity_b", "activity_c"),
|
||||
] {
|
||||
let old = p.relate(RelationKind::Interaction, name, id(source), id(target));
|
||||
reidentify(&mut p, old, id(alias))?;
|
||||
p.relations.get_mut(&id(alias)).unwrap().alias = alias.into();
|
||||
}
|
||||
diagram(
|
||||
&mut p,
|
||||
"oa_view",
|
||||
"Multiselect native - OA overview",
|
||||
Layer::Oa,
|
||||
);
|
||||
diagram(&mut p, "oa_other", "Other OA view - preserve", Layer::Oa);
|
||||
diagram(&mut p, "sa_view", "SA engineering - preserve", Layer::Sa);
|
||||
let size = Size {
|
||||
width: 224.,
|
||||
height: 94.,
|
||||
};
|
||||
place(
|
||||
&mut p,
|
||||
"oa_view",
|
||||
"node_a",
|
||||
"activity_a",
|
||||
point(80., 80.),
|
||||
Size {
|
||||
width: 310.,
|
||||
height: 260.,
|
||||
},
|
||||
);
|
||||
place(
|
||||
&mut p,
|
||||
"oa_view",
|
||||
"node_b",
|
||||
"activity_b",
|
||||
point(470., 120.),
|
||||
size,
|
||||
);
|
||||
place(
|
||||
&mut p,
|
||||
"oa_view",
|
||||
"node_c",
|
||||
"activity_c",
|
||||
point(780., 270.),
|
||||
size,
|
||||
);
|
||||
place(
|
||||
&mut p,
|
||||
"oa_view",
|
||||
"node_child",
|
||||
"activity_child",
|
||||
point(120., 210.),
|
||||
size,
|
||||
);
|
||||
place(
|
||||
&mut p,
|
||||
"oa_other",
|
||||
"other_a",
|
||||
"activity_a",
|
||||
point(40., 60.),
|
||||
size,
|
||||
);
|
||||
place(
|
||||
&mut p,
|
||||
"sa_view",
|
||||
"sa_node",
|
||||
"sa_function",
|
||||
point(100., 110.),
|
||||
size,
|
||||
);
|
||||
p.diagrams.get_mut(&id("oa_view")).unwrap().routes = vec![
|
||||
route("internal", vec![point(420., 145.), point(420., 186.)]),
|
||||
route("external", vec![point(725., 150.), point(725., 336.)]),
|
||||
];
|
||||
p.traces.insert(
|
||||
id("trace"),
|
||||
Trace {
|
||||
id: id("trace"),
|
||||
alias: "preserved_refinement".into(),
|
||||
name: "Reviewed request refinement".into(),
|
||||
description: "Group geometry and Hide preserve this trace and both endpoints.".into(),
|
||||
oa: id("activity_a"),
|
||||
sa: id("sa_function"),
|
||||
kind: TraceKind::Refinement,
|
||||
baseline: Some(TraceBaseline {
|
||||
oa: p.properties(id("activity_a")).unwrap(),
|
||||
sa: p.properties(id("sa_function")).unwrap(),
|
||||
semantic: None,
|
||||
}),
|
||||
},
|
||||
);
|
||||
validate_candidate(&p)?;
|
||||
Ok(p)
|
||||
}
|
||||
fn load(path: &Path) -> Result<Project> {
|
||||
if path
|
||||
.extension()
|
||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("arcadia"))
|
||||
{
|
||||
let disk = source_files::load_project(path, ProjectLimits::default())?;
|
||||
// This deliberately partial engineering fixture has completeness
|
||||
// warnings (allocation/behavior context). They are not source errors.
|
||||
// Compilation, candidate validation and whole-Project equality still
|
||||
// reject malformed source or any unexpected model change.
|
||||
if disk
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|d| d.diagnostic.severity == validation::Severity::Error)
|
||||
{
|
||||
return Err(format!("Source discovery diagnostics: {:?}", disk.diagnostics).into());
|
||||
}
|
||||
let compiled = compile_bundle(&disk.reachable_bundle(), ProjectLimits::default())
|
||||
.map_err(|e| format!("Source compile diagnostics: {e:?}"))?;
|
||||
Ok(compiled.decoded.project)
|
||||
} else {
|
||||
Ok(persistence::load(path)?)
|
||||
}
|
||||
}
|
||||
fn equal(expected: &Project, actual: &Project) -> Result<()> {
|
||||
if expected == actual {
|
||||
return Ok(());
|
||||
}
|
||||
let e = serde_json::to_value(expected)?;
|
||||
let a = serde_json::to_value(actual)?;
|
||||
let fields: Vec<_> = e
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.keys()
|
||||
.filter(|key| e[*key] != a[*key])
|
||||
.cloned()
|
||||
.collect();
|
||||
Err(format!("Whole Project mismatch in {fields:?}; no partial PASS").into())
|
||||
}
|
||||
fn roundtrip(p: &Project) -> Result<SourceBundle> {
|
||||
validate_candidate(p)?;
|
||||
equal(p, &persistence::decode(&serde_json::to_vec(p)?)?)?;
|
||||
let bundle = format_bundle(p);
|
||||
let compiled = compile_bundle(&bundle, ProjectLimits::default())
|
||||
.map_err(|e| format!("Roundtrip: {e:?}"))?;
|
||||
equal(p, &compiled.decoded.project)?;
|
||||
Ok(bundle)
|
||||
}
|
||||
fn write_new(path: &Path, bytes: &[u8]) -> Result<()> {
|
||||
fs::create_dir_all(path.parent().ok_or("Missing parent directory")?)?;
|
||||
fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(path)?
|
||||
.write_all(bytes)?;
|
||||
Ok(())
|
||||
}
|
||||
fn generate(out: &Path) -> Result<()> {
|
||||
if out.exists() {
|
||||
return Err("Output directory already exists; choose a fresh path".into());
|
||||
}
|
||||
let p = fixture()?;
|
||||
let bundle = roundtrip(&p)?;
|
||||
write_new(&out.join("baseline.json"), &serde_json::to_vec_pretty(&p)?)?;
|
||||
for (path, source) in &bundle.files {
|
||||
write_new(&out.join("source").join(path), source.as_bytes())?;
|
||||
}
|
||||
let names = [
|
||||
"project",
|
||||
"oa_view",
|
||||
"oa_other",
|
||||
"sa_view",
|
||||
"activity_a",
|
||||
"activity_b",
|
||||
"activity_c",
|
||||
"activity_child",
|
||||
"node_a",
|
||||
"node_b",
|
||||
"node_c",
|
||||
"node_child",
|
||||
"internal",
|
||||
"external",
|
||||
"trace",
|
||||
"sa_function",
|
||||
"sa_node",
|
||||
"other_a",
|
||||
];
|
||||
let ids: std::collections::BTreeMap<_, _> =
|
||||
names.into_iter().map(|name| (name, id(name))).collect();
|
||||
write_new(
|
||||
&out.join("manifest.json"),
|
||||
&serde_json::to_vec_pretty(&serde_json::json!({
|
||||
"fixture": "multiselect-native-20260923", "ids": ids, "source_entry": bundle.entry,
|
||||
"move_members": ["node_a", "node_b", "node_child"], "fixed_member": "node_c",
|
||||
"evaluation": "Whole Project invariant comparison; saved A supplies bounded shared delta, not an exact pointer oracle",
|
||||
"hide": "Remove node_a/node_b only; retain child, stored routes, all model records and other views",
|
||||
}))?,
|
||||
)?;
|
||||
equal(&p, &load(&out.join("baseline.json"))?)?;
|
||||
equal(&p, &load(&out.join("source/project.arcadia"))?)?;
|
||||
println!("PASS generated JSON and source bundle: {}", out.display());
|
||||
Ok(())
|
||||
}
|
||||
fn check(mode: &str, baseline: &Path, saved: &Path) -> Result<()> {
|
||||
let before = load(baseline)?;
|
||||
let actual = load(saved)?;
|
||||
validate_candidate(&before)?;
|
||||
validate_candidate(&actual)?;
|
||||
if before.id != id("project") {
|
||||
return Err("Not this fixture's baseline project".into());
|
||||
}
|
||||
let mut expected = before.clone();
|
||||
let mut delta = None;
|
||||
match mode {
|
||||
"unchanged" => {}
|
||||
"hide" => {
|
||||
let view = expected
|
||||
.diagrams
|
||||
.get_mut(&id("oa_view"))
|
||||
.ok_or("Missing OA view")?;
|
||||
for alias in ["node_a", "node_b"] {
|
||||
view.nodes
|
||||
.remove(&id(alias))
|
||||
.ok_or("Baseline selected placement missing")?;
|
||||
}
|
||||
// references::remove_objects removes routes only when their semantic
|
||||
// edge is deleted. Hide deletes placements; both routes MUST survive.
|
||||
}
|
||||
"moved" => {
|
||||
let old = before
|
||||
.diagrams
|
||||
.get(&id("oa_view"))
|
||||
.ok_or("Missing baseline OA view")?;
|
||||
let new = actual
|
||||
.diagrams
|
||||
.get(&id("oa_view"))
|
||||
.ok_or("Missing saved OA view")?;
|
||||
let a = old
|
||||
.nodes
|
||||
.get(&id("node_a"))
|
||||
.ok_or("Missing baseline A")?
|
||||
.position;
|
||||
let moved_a = new
|
||||
.nodes
|
||||
.get(&id("node_a"))
|
||||
.ok_or("Missing saved A")?
|
||||
.position;
|
||||
let d = point(moved_a.x - a.x, moved_a.y - a.y);
|
||||
if !d.x.is_finite()
|
||||
|| !d.y.is_finite()
|
||||
|| d.x.abs().max(d.y.abs()) > 500.
|
||||
|| d.x.hypot(d.y) < 10.
|
||||
{
|
||||
return Err(format!(
|
||||
"Move delta must have length >=10 and each axis <=500 world units: {d:?}"
|
||||
)
|
||||
.into());
|
||||
}
|
||||
if moved_a.x % 10. != 0. || moved_a.y % 10. != 0. {
|
||||
return Err("Saved anchor is not on 10-unit grid".into());
|
||||
}
|
||||
let view = expected.diagrams.get_mut(&id("oa_view")).unwrap();
|
||||
// Independent explicit fixture enumeration: no group/descendant/
|
||||
// routing planner is called to predict the result.
|
||||
for alias in ["node_a", "node_b", "node_child"] {
|
||||
let node = view
|
||||
.nodes
|
||||
.get_mut(&id(alias))
|
||||
.ok_or("Missing expected moved placement")?;
|
||||
node.position.x += d.x;
|
||||
node.position.y += d.y;
|
||||
}
|
||||
let internal = view
|
||||
.routes
|
||||
.iter_mut()
|
||||
.find(|route| route.edge == id("internal"))
|
||||
.ok_or("Missing internal pins")?;
|
||||
for pin in &mut internal.waypoints {
|
||||
pin.x += d.x;
|
||||
pin.y += d.y;
|
||||
}
|
||||
delta = Some(d);
|
||||
}
|
||||
_ => return Err("Mode must be moved, hide, or unchanged".into()),
|
||||
}
|
||||
equal(&expected, &actual)?;
|
||||
roundtrip(&actual)?;
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&serde_json::json!({
|
||||
"result": "PASS", "mode": mode, "baseline": baseline, "saved": saved,
|
||||
"comparison": "complete Project equality", "inferred_common_delta": delta,
|
||||
"limits": "Invariant evaluator only; no pointer/action/focus/Undo/source-byte acceptance claim",
|
||||
}))?
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
fn main() -> Result<()> {
|
||||
let args: Vec<_> = std::env::args().skip(1).collect();
|
||||
match args.as_slice() {
|
||||
[command, out] if command == "generate" => generate(Path::new(out)),
|
||||
[command, mode, baseline, saved] if command == "check" => check(mode, Path::new(baseline), Path::new(saved)),
|
||||
_ => Err("Usage: multiselect-native-producer generate DIRECTORY | check moved|hide|unchanged BASELINE SAVED".into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
// Produce evidence through real validated commands; the checker itself uses
|
||||
// explicit fixture enumeration and never calls this movement implementation.
|
||||
fn moved(before: &Project, delta: Position) -> Project {
|
||||
let command = diagram_edit::translate_placements(
|
||||
before,
|
||||
id("oa_view"),
|
||||
&BTreeSet::from([id("node_a"), id("node_b")]),
|
||||
delta,
|
||||
)
|
||||
.unwrap();
|
||||
let mut session = Session::new(before.clone());
|
||||
session.execute(command).unwrap();
|
||||
session.project().clone()
|
||||
}
|
||||
fn hidden(before: &Project) -> Project {
|
||||
let mut session = Session::new(before.clone());
|
||||
session
|
||||
.execute(Command::Batch(vec![
|
||||
Command::Delete(id("node_a")),
|
||||
Command::Delete(id("node_b")),
|
||||
]))
|
||||
.unwrap();
|
||||
session.project().clone()
|
||||
}
|
||||
fn through_saved_json(mode: &str, baseline: &Project, actual: &Project) -> Result<()> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
let before = dir.path().join("before.json");
|
||||
let saved = dir.path().join("saved.json");
|
||||
// These assertions distinguish meaningful invariant corruptions from
|
||||
// malformed projects rejected before the evaluator even sees them.
|
||||
validate_candidate(baseline).expect("test baseline must be valid");
|
||||
validate_candidate(actual).expect("corruption must remain a valid model");
|
||||
persistence::save(&before, baseline)?;
|
||||
persistence::save(&saved, actual)?;
|
||||
check(mode, &before, &saved)
|
||||
}
|
||||
fn mismatch(mode: &str, before: &Project, actual: &Project, case: &str) {
|
||||
let error = through_saved_json(mode, before, actual)
|
||||
.expect_err(case)
|
||||
.to_string();
|
||||
assert!(
|
||||
error.contains("Whole Project mismatch"),
|
||||
"{case}: unexpected rejection: {error}"
|
||||
);
|
||||
}
|
||||
fn edge<'a>(p: &'a mut Project, alias: &str) -> &'a mut EdgeRoute {
|
||||
p.diagrams
|
||||
.get_mut(&id("oa_view"))
|
||||
.unwrap()
|
||||
.routes
|
||||
.iter_mut()
|
||||
.find(|route| route.edge == id(alias))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_json_and_real_source_bundle_are_accepted_unchanged() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let out = dir.path().join("fixture");
|
||||
generate(&out).unwrap();
|
||||
let baseline = out.join("baseline.json");
|
||||
let saved = out.join("saved.json");
|
||||
persistence::save(&saved, &fixture().unwrap()).unwrap();
|
||||
check("unchanged", &baseline, &saved).unwrap();
|
||||
check("unchanged", &baseline, &out.join("source/project.arcadia")).unwrap();
|
||||
let bytes = fs::read(&baseline).unwrap();
|
||||
assert!(
|
||||
generate(&out).is_err(),
|
||||
"generation cannot overwrite evidence"
|
||||
);
|
||||
assert_eq!(fs::read(&baseline).unwrap(), bytes);
|
||||
let invalid = dir.path().join("invalid.arcadia");
|
||||
fs::write(&invalid, "project {").unwrap();
|
||||
assert!(load(&invalid).is_err(), "source errors must still reject");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_group_command_outputs_pass_and_are_not_unchanged() {
|
||||
let before = fixture().unwrap();
|
||||
for delta in [point(40., 60.), point(-20., 30.)] {
|
||||
let actual = moved(&before, delta);
|
||||
through_saved_json("moved", &before, &actual).unwrap();
|
||||
mismatch(
|
||||
"unchanged",
|
||||
&before,
|
||||
&actual,
|
||||
"real movement cannot count as unchanged",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_hide_command_outputs_pass_before_and_after_movement() {
|
||||
let initial = fixture().unwrap();
|
||||
for before in [initial.clone(), moved(&initial, point(40., 60.))] {
|
||||
let actual = hidden(&before);
|
||||
through_saved_json("hide", &before, &actual).unwrap();
|
||||
mismatch(
|
||||
"unchanged",
|
||||
&before,
|
||||
&actual,
|
||||
"real Hide cannot count as unchanged",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omitted_and_double_moved_child_are_rejected() {
|
||||
let before = fixture().unwrap();
|
||||
let delta = point(40., 60.);
|
||||
let correct = moved(&before, delta);
|
||||
let initial = before.diagrams[&id("oa_view")].nodes[&id("node_child")].position;
|
||||
for position in [
|
||||
initial,
|
||||
point(initial.x + 2. * delta.x, initial.y + 2. * delta.y),
|
||||
] {
|
||||
let mut bad = correct.clone();
|
||||
bad.diagrams
|
||||
.get_mut(&id("oa_view"))
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&id("node_child"))
|
||||
.unwrap()
|
||||
.position = position;
|
||||
mismatch("moved", &before, &bad, "child did not move exactly once");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn internal_pins_left_behind_and_external_pins_moved_are_rejected() {
|
||||
let mut before = fixture().unwrap();
|
||||
let original_pins = edge(&mut before, "internal").waypoints.clone();
|
||||
let correct = moved(&before, point(40., 60.));
|
||||
let mut bad = correct.clone();
|
||||
edge(&mut bad, "internal").waypoints = original_pins;
|
||||
mismatch("moved", &before, &bad, "internal pins left behind");
|
||||
let mut bad = correct;
|
||||
for pin in &mut edge(&mut bad, "external").waypoints {
|
||||
pin.x += 40.;
|
||||
pin.y += 60.;
|
||||
}
|
||||
mismatch("moved", &before, &bad, "one-ended pins moved");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_attachments_and_label_data_must_remain_exact() {
|
||||
let before = fixture().unwrap();
|
||||
let correct = moved(&before, point(40., 60.));
|
||||
let mutations: [fn(&mut EdgeRoute); 4] = [
|
||||
|r| r.source_attachment.as_mut().unwrap().offset = 0.65,
|
||||
|r| r.target_attachment.as_mut().unwrap().side = PortAnchorSide::Top,
|
||||
|r| r.label.as_mut().unwrap().offset.x += 40.,
|
||||
|r| r.label.as_mut().unwrap().fraction = 0.75,
|
||||
];
|
||||
for mutate in mutations {
|
||||
let mut bad = correct.clone();
|
||||
mutate(edge(&mut bad, "internal"));
|
||||
mismatch("moved", &before, &bad, "relative route metadata changed");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn semantic_other_view_fixed_node_and_trace_drift_are_rejected() {
|
||||
let before = fixture().unwrap();
|
||||
let correct = moved(&before, point(40., 60.));
|
||||
let mutations: [fn(&mut Project); 5] = [
|
||||
|p| {
|
||||
p.elements
|
||||
.get_mut(&id("activity_a"))
|
||||
.unwrap()
|
||||
.description
|
||||
.push_str(" drift")
|
||||
},
|
||||
|p| {
|
||||
p.diagrams
|
||||
.get_mut(&id("oa_other"))
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&id("other_a"))
|
||||
.unwrap()
|
||||
.position
|
||||
.x += 40.
|
||||
},
|
||||
|p| {
|
||||
p.diagrams
|
||||
.get_mut(&id("sa_view"))
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&id("sa_node"))
|
||||
.unwrap()
|
||||
.position
|
||||
.y += 60.
|
||||
},
|
||||
|p| {
|
||||
p.diagrams
|
||||
.get_mut(&id("oa_view"))
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&id("node_c"))
|
||||
.unwrap()
|
||||
.position
|
||||
.x += 40.
|
||||
},
|
||||
|p| {
|
||||
p.traces
|
||||
.get_mut(&id("trace"))
|
||||
.unwrap()
|
||||
.description
|
||||
.push_str(" drift")
|
||||
},
|
||||
];
|
||||
for mutate in mutations {
|
||||
let mut bad = correct.clone();
|
||||
mutate(&mut bad);
|
||||
mismatch(
|
||||
"moved",
|
||||
&before,
|
||||
&bad,
|
||||
"record outside permitted movement changed",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_small_excessive_and_offgrid_moves_cannot_satisfy_move_evidence() {
|
||||
let before = fixture().unwrap();
|
||||
for (delta, reason) in [
|
||||
(point(0., 0.), "length >=10"),
|
||||
(point(3., 0.), "length >=10"),
|
||||
(point(510., 0.), "each axis <=500"),
|
||||
(point(20.5, 0.), "10-unit grid"),
|
||||
] {
|
||||
let actual = moved(&before, delta);
|
||||
let error = through_saved_json("moved", &before, &actual)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains(reason), "{delta:?}: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hide_must_preserve_child_routes_and_semantic_objects() {
|
||||
let before = fixture().unwrap();
|
||||
let correct = hidden(&before);
|
||||
let mut missing_child = correct.clone();
|
||||
missing_child
|
||||
.diagrams
|
||||
.get_mut(&id("oa_view"))
|
||||
.unwrap()
|
||||
.nodes
|
||||
.remove(&id("node_child"));
|
||||
mismatch(
|
||||
"hide",
|
||||
&before,
|
||||
&missing_child,
|
||||
"Hide removed unselected child placement",
|
||||
);
|
||||
let mut missing_routes = correct;
|
||||
missing_routes
|
||||
.diagrams
|
||||
.get_mut(&id("oa_view"))
|
||||
.unwrap()
|
||||
.routes
|
||||
.clear();
|
||||
mismatch(
|
||||
"hide",
|
||||
&before,
|
||||
&missing_routes,
|
||||
"Hide removed persistent route records",
|
||||
);
|
||||
let mut semantic_delete = Session::new(before.clone());
|
||||
semantic_delete
|
||||
.execute(Command::Batch(vec![
|
||||
Command::Delete(id("activity_a")),
|
||||
Command::Delete(id("activity_b")),
|
||||
]))
|
||||
.unwrap();
|
||||
mismatch(
|
||||
"hide",
|
||||
&before,
|
||||
semantic_delete.project(),
|
||||
"Hide became semantic deletion",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_rejects_valid_semantic_drift_without_geometry_changes() {
|
||||
let before = fixture().unwrap();
|
||||
let mut actual = before.clone();
|
||||
actual
|
||||
.elements
|
||||
.get_mut(&id("activity_b"))
|
||||
.unwrap()
|
||||
.name
|
||||
.push_str(" edited");
|
||||
mismatch(
|
||||
"unchanged",
|
||||
&before,
|
||||
&actual,
|
||||
"geometry equality is not complete Project equality",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
//! Pure diagram edits shared by movement previews and committed transactions.
|
||||
use crate::{Command, Id, MAX_COORDINATE, ModelError, Position, Project, projection};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
/// Translate selected placements and their projected descendants simultaneously.
|
||||
///
|
||||
/// Membership and geometry are read from one snapshot, including folded children.
|
||||
/// Overlapping roots move each placement once. Route pins follow the combined
|
||||
/// movement of their actual endpoint owners (and State boundaries). Semantic
|
||||
/// ownership and other views are untouched. Apply the returned batch through the
|
||||
/// normal validated command path; its selected identity is not a primary selection.
|
||||
pub fn translate_placements(
|
||||
project: &Project,
|
||||
diagram: Id,
|
||||
selected: &BTreeSet<Id>,
|
||||
delta: Position,
|
||||
) -> Result<Command, ModelError> {
|
||||
let view = project
|
||||
.diagrams
|
||||
.get(&diagram)
|
||||
.ok_or_else(|| ModelError("Movement requires an existing diagram.".into()))?;
|
||||
if selected.is_empty() {
|
||||
return Err(ModelError(
|
||||
"Movement requires at least one placement.".into(),
|
||||
));
|
||||
}
|
||||
if selected.iter().any(|id| !view.nodes.contains_key(id)) {
|
||||
return Err(ModelError(
|
||||
"Every selected placement must belong to the diagram.".into(),
|
||||
));
|
||||
}
|
||||
if !delta.x.is_finite() || !delta.y.is_finite() {
|
||||
return Err(ModelError("Movement displacement must be finite.".into()));
|
||||
}
|
||||
if delta.x == 0. && delta.y == 0. {
|
||||
return Ok(Command::Batch(vec![]));
|
||||
}
|
||||
let mut children: BTreeMap<Id, Vec<Id>> = BTreeMap::new();
|
||||
for (child, parent) in projection::parents(project, view) {
|
||||
if let Some(parent) = parent {
|
||||
children.entry(parent).or_default().push(child);
|
||||
}
|
||||
}
|
||||
let mut moved = BTreeSet::new();
|
||||
let mut pending: Vec<_> = selected.iter().copied().collect();
|
||||
while let Some(id) = pending.pop() {
|
||||
if moved.insert(id) {
|
||||
pending.extend(children.get(&id).into_iter().flatten().copied());
|
||||
}
|
||||
}
|
||||
let deltas: BTreeMap<_, _> = moved.iter().map(|id| (*id, delta)).collect();
|
||||
let routes = projection::translated_routes(project, view, &deltas);
|
||||
let bounded = |value: f32| value.is_finite() && value.abs() <= MAX_COORDINATE;
|
||||
let mut commands = Vec::with_capacity(moved.len() + routes.len());
|
||||
for id in moved {
|
||||
let mut node = view.nodes[&id].clone();
|
||||
node.position.x += delta.x;
|
||||
node.position.y += delta.y;
|
||||
if ![
|
||||
node.position.x,
|
||||
node.position.y,
|
||||
node.size.width,
|
||||
node.size.height,
|
||||
]
|
||||
.into_iter()
|
||||
.all(bounded)
|
||||
|| node.size.width <= 0.
|
||||
|| node.size.height <= 0.
|
||||
{
|
||||
return Err(ModelError(
|
||||
"Movement produces invalid placement bounds.".into(),
|
||||
));
|
||||
}
|
||||
commands.push(Command::EditNode {
|
||||
diagram,
|
||||
original: id,
|
||||
node,
|
||||
});
|
||||
}
|
||||
for route in routes {
|
||||
if route
|
||||
.waypoints
|
||||
.iter()
|
||||
.any(|point| !bounded(point.x) || !bounded(point.y))
|
||||
{
|
||||
return Err(ModelError("Movement produces invalid route bounds.".into()));
|
||||
}
|
||||
commands.push(Command::SetEdgeRoute { diagram, route });
|
||||
}
|
||||
Ok(Command::Batch(commands))
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Framework-independent OA/SA model, transactional commands and editing services.
|
||||
pub mod analysis_numeric;
|
||||
pub mod data;
|
||||
pub mod diagram_edit;
|
||||
pub mod language;
|
||||
pub mod model;
|
||||
pub mod operations;
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
use oa_core::{diagram_edit::translate_placements, source_project::*, *};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
fn point(x: f32, y: f32) -> Position {
|
||||
Position { x, y }
|
||||
}
|
||||
fn route(edge: Id, x: f32, y: f32) -> EdgeRoute {
|
||||
EdgeRoute {
|
||||
edge,
|
||||
source_attachment: Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Bottom,
|
||||
offset: 0.2,
|
||||
}),
|
||||
target_attachment: Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Top,
|
||||
offset: 0.8,
|
||||
}),
|
||||
waypoints: vec![point(x, y)],
|
||||
label: Some(EdgeLabel {
|
||||
fraction: 0.4,
|
||||
offset: point(3., -9.),
|
||||
}),
|
||||
}
|
||||
}
|
||||
struct Fixture {
|
||||
project: Project,
|
||||
diagram: Id,
|
||||
a: Id,
|
||||
b: Id,
|
||||
child: Id,
|
||||
foreign: Id,
|
||||
}
|
||||
fn fixture() -> Fixture {
|
||||
let mut p = Project::empty();
|
||||
let a = p.add_element(Kind::Activity, Layer::Oa, "A", None).unwrap();
|
||||
let b = p.add_element(Kind::Activity, Layer::Oa, "B", None).unwrap();
|
||||
let c = p.add_element(Kind::Activity, Layer::Oa, "C", None).unwrap();
|
||||
let child = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "Child", Some(a))
|
||||
.unwrap();
|
||||
let internal = p.relate(RelationKind::Interaction, "Internal", a, b);
|
||||
let external = p.relate(RelationKind::Interaction, "External", b, c);
|
||||
let d = p.ensure_diagram(Layer::Oa);
|
||||
let na = p.place(d, a, point(10., 20.));
|
||||
let nb = p.place(d, b, point(400., 20.));
|
||||
let nc = p.place(d, child, point(30., 80.));
|
||||
p.place(d, c, point(800., 20.));
|
||||
p.diagrams.get_mut(&d).unwrap().routes =
|
||||
vec![route(internal, 320., 150.), route(external, 720., 170.)];
|
||||
p.diagrams
|
||||
.get_mut(&d)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&na)
|
||||
.unwrap()
|
||||
.collapsed = true;
|
||||
let other = Id::new_v4();
|
||||
let mut other_view = p.diagrams[&d].clone();
|
||||
other_view.id = other;
|
||||
other_view.alias = "other_view".into();
|
||||
other_view.nodes.clear();
|
||||
other_view.routes.clear();
|
||||
p.diagrams.insert(other, other_view);
|
||||
let foreign = p.place(other, a, point(-40., -50.));
|
||||
validate_candidate(&p).unwrap();
|
||||
Fixture {
|
||||
project: p,
|
||||
diagram: d,
|
||||
a: na,
|
||||
b: nb,
|
||||
child: nc,
|
||||
foreign,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simultaneous_siblings_translate_internal_pins_once_and_preserve_complete_project() {
|
||||
let f = fixture();
|
||||
let before = f.project.clone();
|
||||
let command = translate_placements(
|
||||
&before,
|
||||
f.diagram,
|
||||
&BTreeSet::from([f.a, f.b]),
|
||||
point(40., 60.),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
before, f.project,
|
||||
"command construction must not mutate input"
|
||||
);
|
||||
// Independently enumerated expected edits, without the projection helpers.
|
||||
let mut expected = before.clone();
|
||||
let view = expected.diagrams.get_mut(&f.diagram).unwrap();
|
||||
view.nodes.get_mut(&f.a).unwrap().position = point(50., 80.);
|
||||
view.nodes.get_mut(&f.b).unwrap().position = point(440., 80.);
|
||||
view.nodes.get_mut(&f.child).unwrap().position = point(70., 140.);
|
||||
view.routes[0].waypoints = vec![point(360., 210.)];
|
||||
let mut session = Session::new(before.clone());
|
||||
session.execute(command.clone()).unwrap();
|
||||
assert_eq!(session.project(), &expected);
|
||||
session.undo();
|
||||
assert_eq!(session.project(), &before);
|
||||
assert!(
|
||||
!session.can_undo(),
|
||||
"one movement must be one history entry"
|
||||
);
|
||||
session.redo();
|
||||
assert_eq!(session.project(), &expected);
|
||||
assert!(!session.can_redo());
|
||||
|
||||
// Independent one-node moves cannot see the shared endpoint displacement.
|
||||
let mut sequential = Session::new(before.clone());
|
||||
for (node, position) in [(f.a, point(50., 80.)), (f.b, point(440., 80.))] {
|
||||
sequential
|
||||
.execute(Command::MoveNode {
|
||||
diagram: f.diagram,
|
||||
node,
|
||||
position,
|
||||
with_descendants: true,
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(
|
||||
sequential.project().diagrams[&f.diagram].routes[0],
|
||||
before.diagrams[&f.diagram].routes[0]
|
||||
);
|
||||
assert_ne!(sequential.project(), &expected);
|
||||
|
||||
let json = serde_json::to_string(&expected).unwrap();
|
||||
assert_eq!(serde_json::from_str::<Project>(&json).unwrap(), expected);
|
||||
let mut bundle = format_bundle(&before);
|
||||
bundle
|
||||
.files
|
||||
.get_mut(&bundle.entry.clone())
|
||||
.unwrap()
|
||||
.insert_str(0, "// preserve movement rationale\n");
|
||||
let original = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
|
||||
let prepared = prepare_command(&before, &command).unwrap();
|
||||
let updated = update_bundle_with_identity_map(
|
||||
&original,
|
||||
&prepared.project,
|
||||
&prepared.identity_map,
|
||||
ProjectLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(updated.decoded.project, expected);
|
||||
assert!(updated.bundle.files[&bundle.entry].starts_with("// preserve movement rationale\n"));
|
||||
let reread = compile_bundle(&updated.bundle, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(reread.decoded.project, expected);
|
||||
assert_eq!(reread.bundle, updated.bundle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selecting_folded_ancestor_and_hidden_descendant_moves_each_once() {
|
||||
let f = fixture();
|
||||
assert!(
|
||||
!projection::project_view(&f.project, f.diagram)
|
||||
.nodes
|
||||
.iter()
|
||||
.any(|n| n.id == f.child)
|
||||
);
|
||||
let ancestor = translate_placements(
|
||||
&f.project,
|
||||
f.diagram,
|
||||
&BTreeSet::from([f.a]),
|
||||
point(-15., 25.),
|
||||
)
|
||||
.unwrap();
|
||||
let overlap = translate_placements(
|
||||
&f.project,
|
||||
f.diagram,
|
||||
&BTreeSet::from([f.a, f.child]),
|
||||
point(-15., 25.),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(overlap, ancestor);
|
||||
let after = prepare_command(&f.project, &overlap).unwrap().project;
|
||||
assert_eq!(
|
||||
after.diagrams[&f.diagram].nodes[&f.child].position,
|
||||
point(15., 105.)
|
||||
);
|
||||
assert_eq!(
|
||||
after.diagrams[&f.diagram].routes,
|
||||
f.project.diagrams[&f.diagram].routes
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_movements_refuse_atomically_and_zero_has_no_history() {
|
||||
let f = fixture();
|
||||
let selected = BTreeSet::from([f.a, f.b]);
|
||||
for (diagram, ids, delta) in [
|
||||
(Id::new_v4(), selected.clone(), point(1., 1.)),
|
||||
(f.diagram, BTreeSet::new(), point(1., 1.)),
|
||||
(
|
||||
f.diagram,
|
||||
BTreeSet::from([f.a, Id::new_v4()]),
|
||||
point(1., 1.),
|
||||
),
|
||||
(f.diagram, BTreeSet::from([f.a, f.foreign]), point(0., 0.)),
|
||||
(f.diagram, selected.clone(), point(f32::NAN, 1.)),
|
||||
(f.diagram, selected.clone(), point(1., f32::INFINITY)),
|
||||
(f.diagram, selected.clone(), point(MAX_COORDINATE, 0.)),
|
||||
(f.diagram, selected.clone(), point(f32::MAX, f32::MAX)),
|
||||
] {
|
||||
let before = f.project.clone();
|
||||
assert!(translate_placements(&f.project, diagram, &ids, delta).is_err());
|
||||
assert_eq!(f.project, before);
|
||||
}
|
||||
let mut bad_route = f.project.clone();
|
||||
bad_route.diagrams.get_mut(&f.diagram).unwrap().routes[0].waypoints =
|
||||
vec![point(MAX_COORDINATE, 0.)];
|
||||
validate_candidate(&bad_route).unwrap();
|
||||
let snapshot = bad_route.clone();
|
||||
assert!(translate_placements(&bad_route, f.diagram, &selected, point(1., 0.)).is_err());
|
||||
assert_eq!(bad_route, snapshot);
|
||||
let zero = translate_placements(&f.project, f.diagram, &selected, point(0., -0.)).unwrap();
|
||||
assert_eq!(zero, Command::Batch(vec![]));
|
||||
let mut session = Session::new(f.project.clone());
|
||||
session.execute(zero).unwrap();
|
||||
assert_eq!(session.project(), &f.project);
|
||||
assert!(!session.can_undo());
|
||||
assert_eq!(session.revision(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_component_port_endpoints_follow_their_owner_placements() {
|
||||
let mut p = Project::empty();
|
||||
let a = p
|
||||
.add_element(Kind::System, Layer::Sa, "System", None)
|
||||
.unwrap();
|
||||
let b = p
|
||||
.add_element(Kind::SystemActor, Layer::Sa, "Actor", None)
|
||||
.unwrap();
|
||||
let output = Id::new_v4();
|
||||
let input = Id::new_v4();
|
||||
for (id, owner, name, orientation) in [
|
||||
(output, a, "Output", ComponentPortOrientation::Out),
|
||||
(input, b, "Input", ComponentPortOrientation::In),
|
||||
] {
|
||||
let port = make_port(
|
||||
&p,
|
||||
id,
|
||||
owner,
|
||||
name,
|
||||
PortContract::Component {
|
||||
kind: ComponentPortKind::Flow,
|
||||
orientation,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
p.elements.insert(id, port);
|
||||
}
|
||||
let edge = p.relate(RelationKind::BoundaryExchange, "Boundary", output, input);
|
||||
let d = p.ensure_diagram(Layer::Sa);
|
||||
let na = p.place(d, a, point(0., 0.));
|
||||
let nb = p.place(d, b, point(400., 0.));
|
||||
for (node, port, side, offset) in [
|
||||
(na, output, PortAnchorSide::Bottom, 0.3),
|
||||
(nb, input, PortAnchorSide::Top, 0.7),
|
||||
] {
|
||||
p.diagrams
|
||||
.get_mut(&d)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&node)
|
||||
.unwrap()
|
||||
.port_anchors
|
||||
.push(PortAnchor { port, side, offset });
|
||||
}
|
||||
p.diagrams
|
||||
.get_mut(&d)
|
||||
.unwrap()
|
||||
.routes
|
||||
.push(route(edge, 300., 100.));
|
||||
validate_candidate(&p).unwrap();
|
||||
let command = translate_placements(&p, d, &BTreeSet::from([na, nb]), point(10., -20.)).unwrap();
|
||||
let after = prepare_command(&p, &command).unwrap().project;
|
||||
let mut expected = p.clone();
|
||||
let view = expected.diagrams.get_mut(&d).unwrap();
|
||||
view.nodes.get_mut(&na).unwrap().position = point(10., -20.);
|
||||
view.nodes.get_mut(&nb).unwrap().position = point(410., -20.);
|
||||
view.routes[0].waypoints = vec![point(310., 80.)];
|
||||
assert_eq!(after, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allocation_movement_follows_visual_performers_and_same_performer_work_groups() {
|
||||
let mut p = Project::empty();
|
||||
let performer_a = p
|
||||
.add_element(Kind::Entity, Layer::Oa, "Performer A", None)
|
||||
.unwrap();
|
||||
let performer_b = p
|
||||
.add_element(Kind::Entity, Layer::Oa, "Performer B", None)
|
||||
.unwrap();
|
||||
let mixed = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "Mixed work", None)
|
||||
.unwrap();
|
||||
let branch = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "A work", Some(mixed))
|
||||
.unwrap();
|
||||
let leaf_a = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "A leaf", Some(branch))
|
||||
.unwrap();
|
||||
let leaf_b = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "B leaf", Some(mixed))
|
||||
.unwrap();
|
||||
p.relate(RelationKind::Allocation, "A performs", performer_a, leaf_a);
|
||||
p.relate(RelationKind::Allocation, "B performs", performer_b, leaf_b);
|
||||
let d = p.ensure_diagram(Layer::Oa);
|
||||
p.diagrams.get_mut(&d).unwrap().projection = ProjectionKind::Allocation;
|
||||
let na = p.place(d, performer_a, point(0., 0.));
|
||||
p.place(d, performer_b, point(600., 0.));
|
||||
let nm = p.place(d, mixed, point(1000., 0.));
|
||||
let branch_node = p.place(d, branch, point(20., 80.));
|
||||
let leaf_node = p.place(d, leaf_a, point(40., 160.));
|
||||
p.place(d, leaf_b, point(620., 80.));
|
||||
validate_candidate(&p).unwrap();
|
||||
|
||||
// Mixed work retains semantic ownership of both branches, but its leaves
|
||||
// have different performers. A work is visually nested under Performer A,
|
||||
// with its own same-performer leaf nested inside it.
|
||||
let command =
|
||||
translate_placements(&p, d, &BTreeSet::from([na, branch_node]), point(15., -25.)).unwrap();
|
||||
let after = prepare_command(&p, &command).unwrap().project;
|
||||
let mut expected = p.clone();
|
||||
let view = expected.diagrams.get_mut(&d).unwrap();
|
||||
view.nodes.get_mut(&na).unwrap().position = point(15., -25.);
|
||||
view.nodes.get_mut(&branch_node).unwrap().position = point(35., 55.);
|
||||
view.nodes.get_mut(&leaf_node).unwrap().position = point(55., 135.);
|
||||
assert_eq!(after, expected);
|
||||
|
||||
// Moving the semantic parent does not move children displayed elsewhere.
|
||||
let command = translate_placements(&p, d, &BTreeSet::from([nm]), point(15., -25.)).unwrap();
|
||||
let after = prepare_command(&p, &command).unwrap().project;
|
||||
let mut expected = p.clone();
|
||||
expected
|
||||
.diagrams
|
||||
.get_mut(&d)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&nm)
|
||||
.unwrap()
|
||||
.position = point(1015., -25.);
|
||||
assert_eq!(after, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn traceability_movement_is_flat_even_for_a_semantic_parent() {
|
||||
let mut f = fixture();
|
||||
f.project.diagrams.get_mut(&f.diagram).unwrap().projection = ProjectionKind::Traceability;
|
||||
validate_candidate(&f.project).unwrap();
|
||||
let command = translate_placements(
|
||||
&f.project,
|
||||
f.diagram,
|
||||
&BTreeSet::from([f.a]),
|
||||
point(40., 60.),
|
||||
)
|
||||
.unwrap();
|
||||
let after = prepare_command(&f.project, &command).unwrap().project;
|
||||
let mut expected = f.project.clone();
|
||||
expected
|
||||
.diagrams
|
||||
.get_mut(&f.diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&f.a)
|
||||
.unwrap()
|
||||
.position = point(50., 80.);
|
||||
assert_eq!(
|
||||
after, expected,
|
||||
"flat view movement must preserve semantic descendants, routes, other views and all semantics"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_entry_pins_follow_the_boundary_only_when_it_also_moves() {
|
||||
let mut p = Project::empty();
|
||||
let machine = p
|
||||
.add_element(Kind::StateMachine, Layer::Oa, "Machine", None)
|
||||
.unwrap();
|
||||
let region = p
|
||||
.add_element(Kind::Region, Layer::Oa, "Region", Some(machine))
|
||||
.unwrap();
|
||||
let composite = p
|
||||
.add_element(Kind::State, Layer::Oa, "Composite", Some(region))
|
||||
.unwrap();
|
||||
let inner = p
|
||||
.add_element(Kind::Region, Layer::Oa, "Inner", Some(composite))
|
||||
.unwrap();
|
||||
let entry = p
|
||||
.add_element(Kind::EntryPoint, Layer::Oa, "Entry", Some(inner))
|
||||
.unwrap();
|
||||
let leaf = p
|
||||
.add_element(Kind::State, Layer::Oa, "Leaf", Some(inner))
|
||||
.unwrap();
|
||||
let edge = p.relate(RelationKind::StateTransition, "Enter", entry, leaf);
|
||||
if let RelationBody::StateTransition {
|
||||
transition_kind, ..
|
||||
} = &mut p.relations.get_mut(&edge).unwrap().body
|
||||
{
|
||||
*transition_kind = TransitionKind::Local;
|
||||
}
|
||||
let d = p.ensure_diagram(Layer::Oa);
|
||||
p.diagrams.get_mut(&d).unwrap().projection = ProjectionKind::StateMachine { machine };
|
||||
let boundary = p.place(d, composite, point(0., 0.));
|
||||
let ne = p.place(d, entry, point(20., 80.));
|
||||
let nl = p.place(d, leaf, point(100., 80.));
|
||||
p.diagrams
|
||||
.get_mut(&d)
|
||||
.unwrap()
|
||||
.routes
|
||||
.push(route(edge, 90., 100.));
|
||||
validate_candidate(&p).unwrap();
|
||||
let endpoints =
|
||||
translate_placements(&p, d, &BTreeSet::from([ne, nl]), point(10., 20.)).unwrap();
|
||||
let after = prepare_command(&p, &endpoints).unwrap().project;
|
||||
assert_eq!(after.diagrams[&d].routes, p.diagrams[&d].routes);
|
||||
let with_boundary =
|
||||
translate_placements(&p, d, &BTreeSet::from([boundary, ne, nl]), point(10., 20.)).unwrap();
|
||||
let after = prepare_command(&p, &with_boundary).unwrap().project;
|
||||
assert_eq!(
|
||||
after.diagrams[&d].routes[0].waypoints,
|
||||
vec![point(100., 120.)]
|
||||
);
|
||||
assert_eq!(after.diagrams[&d].nodes[&ne].position, point(30., 100.));
|
||||
}
|
||||
Reference in New Issue
Block a user