Support atomic Allocation group assignment and Arrange gestures
This commit is contained in:
@@ -0,0 +1,767 @@
|
||||
//! Deterministic native fixture and literal complete-Project Allocation oracle.
|
||||
//! No desktop planner, inferred output delta, or GUI input is used by this oracle.
|
||||
use oa_core::{source_project::*, *};
|
||||
use std::{collections::BTreeMap, fs, io::Write, path::Path};
|
||||
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
|
||||
const MODES: [&str; 6] = [
|
||||
"unchanged",
|
||||
"oa-assigned",
|
||||
"sa-assigned",
|
||||
"oa-arranged",
|
||||
"sa-arranged",
|
||||
"oa-performers",
|
||||
];
|
||||
|
||||
fn id(alias: &str) -> Id {
|
||||
if alias == "oa_assign" {
|
||||
return Id::from_u128(0x292609230001);
|
||||
}
|
||||
Id::new_v5(
|
||||
&Id::from_u128(0xc1192026_0923_5000_8000_000000002931),
|
||||
alias.as_bytes(),
|
||||
)
|
||||
}
|
||||
fn xy(x: f32, y: f32) -> Position {
|
||||
Position { x, y }
|
||||
}
|
||||
fn named(scope: &str, suffix: &str) -> String {
|
||||
format!("{scope}_{suffix}")
|
||||
}
|
||||
fn need(ok: bool, message: impl Into<String>) -> Result<()> {
|
||||
if ok {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(message.into().into())
|
||||
}
|
||||
}
|
||||
fn element(p: &mut Project, alias: &str, name: &str, kind: Kind, layer: Layer) {
|
||||
p.elements.insert(
|
||||
id(alias),
|
||||
Element {
|
||||
id: id(alias),
|
||||
alias: alias.into(),
|
||||
name: name.into(),
|
||||
description: format!("Preserve engineering record {alias}."),
|
||||
parent: None,
|
||||
body: ElementBody::new(kind, layer).unwrap(),
|
||||
},
|
||||
);
|
||||
}
|
||||
fn diagram(p: &mut Project, alias: &str, name: &str, layer: Layer, projection: ProjectionKind) {
|
||||
p.diagrams.insert(
|
||||
id(alias),
|
||||
Diagram {
|
||||
id: id(alias),
|
||||
alias: alias.into(),
|
||||
name: name.into(),
|
||||
description: "Native group fixture; preserve unrelated records exactly.".into(),
|
||||
layer,
|
||||
projection,
|
||||
view_options: Default::default(),
|
||||
nodes: Default::default(),
|
||||
routes: vec![],
|
||||
},
|
||||
);
|
||||
}
|
||||
fn place(p: &mut Project, scope: &str, suffix: &str, position: Position) {
|
||||
let alias = named(scope, &format!("node_{suffix}"));
|
||||
p.diagrams.get_mut(&id(scope)).unwrap().nodes.insert(
|
||||
id(&alias),
|
||||
NodePlacement {
|
||||
id: id(&alias),
|
||||
alias,
|
||||
element: id(&named(scope, suffix)),
|
||||
position,
|
||||
size: Size {
|
||||
width: 224.,
|
||||
height: 94.,
|
||||
},
|
||||
collapsed: false,
|
||||
port_anchors: vec![],
|
||||
},
|
||||
);
|
||||
}
|
||||
fn relation(
|
||||
p: &mut Project,
|
||||
scope: &str,
|
||||
suffix: &str,
|
||||
kind: RelationKind,
|
||||
from: &str,
|
||||
to: &str,
|
||||
) -> Result<()> {
|
||||
let alias = named(scope, suffix);
|
||||
let old = p.relate(
|
||||
kind,
|
||||
&format!("{scope} {suffix}"),
|
||||
id(&named(scope, from)),
|
||||
id(&named(scope, to)),
|
||||
);
|
||||
reidentify(p, old, id(&alias))?;
|
||||
let r = p.relations.get_mut(&id(&alias)).unwrap();
|
||||
r.alias = alias.clone();
|
||||
r.description = format!("Reviewed identity and provenance {alias} must survive.");
|
||||
// The actual constructor creates proper SA function contracts. Normalize
|
||||
// every generated identity/alias; no random UUID can leak into the fixture.
|
||||
let ports = match r.body {
|
||||
RelationBody::FunctionalExchange {
|
||||
source_port,
|
||||
target_port,
|
||||
..
|
||||
} => Some([source_port, target_port]),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(ports) = ports {
|
||||
for (old_port, suffix) in ports.into_iter().zip(["output", "input"]) {
|
||||
let alias = format!("{alias}_{suffix}");
|
||||
reidentify(p, old_port, id(&alias))?;
|
||||
let port = p.elements.get_mut(&id(&alias)).unwrap();
|
||||
port.alias = alias.clone();
|
||||
port.name = format!("{scope} {suffix}");
|
||||
port.description = format!("Preserved typed endpoint {alias}.");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn route(scope: &str, pins: [Position; 2]) -> EdgeRoute {
|
||||
EdgeRoute {
|
||||
edge: id(&named(scope, "exchange")),
|
||||
waypoints: pins.to_vec(),
|
||||
source_attachment: None,
|
||||
target_attachment: None,
|
||||
label: Some(EdgeLabel {
|
||||
fraction: 0.45,
|
||||
offset: xy(7., -19.),
|
||||
}),
|
||||
}
|
||||
}
|
||||
fn fixture() -> Result<Project> {
|
||||
let mut p = Project::empty();
|
||||
p.id = id("project");
|
||||
p.alias = "allocation_multiselect_native".into();
|
||||
p.name = "Allocation group native".into();
|
||||
p.description = "Four independent compact OA/SA assignment and Arrange trials; ordinary performers use fresh OA assign baseline.".into();
|
||||
for (layer, prefix, work, performer) in [
|
||||
(Layer::Oa, "oa", Kind::Activity, Kind::Entity),
|
||||
(Layer::Sa, "sa", Kind::Function, Kind::SystemActor),
|
||||
] {
|
||||
for arrange in [false, true] {
|
||||
let scope = format!("{prefix}_{}", if arrange { "arrange" } else { "assign" });
|
||||
let number = match (prefix, arrange) {
|
||||
("oa", false) => "01",
|
||||
("sa", false) => "02",
|
||||
("oa", true) => "03",
|
||||
_ => "04",
|
||||
};
|
||||
diagram(
|
||||
&mut p,
|
||||
&scope,
|
||||
&format!(
|
||||
"{number} {} {}",
|
||||
prefix.to_uppercase(),
|
||||
if arrange { "arrange" } else { "assign" }
|
||||
),
|
||||
layer,
|
||||
ProjectionKind::Allocation,
|
||||
);
|
||||
let records: Vec<_> = if arrange {
|
||||
vec![
|
||||
("p", "P - team", performer, xy(40., 40.)),
|
||||
("u", "U - contained work", work, xy(80., 140.)),
|
||||
("v", "V - independent work", work, xy(440., 140.)),
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
("a", "A - origin team", performer, xy(43., 47.)),
|
||||
// Leave room for SA Y's automatic top port stub below
|
||||
// B's 64-unit header plus 12-unit routing clearance.
|
||||
("b", "B - other team", performer, xy(43., 337.)),
|
||||
("c", "C - destination", performer, xy(500., 40.)),
|
||||
("x", "X - receive", work, xy(80., 140.)),
|
||||
("y", "Y - assess", work, xy(80., 440.)),
|
||||
]
|
||||
};
|
||||
for (suffix, label, kind, pos) in records {
|
||||
// SA admits one System for the entire project, not one per view.
|
||||
let kind = if layer == Layer::Sa && !arrange && suffix == "a" {
|
||||
Kind::System
|
||||
} else {
|
||||
kind
|
||||
};
|
||||
element(&mut p, &named(&scope, suffix), label, kind, layer);
|
||||
place(&mut p, &scope, suffix, pos);
|
||||
}
|
||||
let (from, to, pins) = if arrange {
|
||||
relation(&mut p, &scope, "pu", RelationKind::Allocation, "p", "u")?;
|
||||
("u", "v", [xy(360., 180.), xy(360., 230.)])
|
||||
} else {
|
||||
p.diagrams
|
||||
.get_mut(&id(&scope))
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&id(&named(&scope, "node_c")))
|
||||
.unwrap()
|
||||
.size = Size {
|
||||
width: 360.,
|
||||
height: 600.,
|
||||
};
|
||||
relation(&mut p, &scope, "ax", RelationKind::Allocation, "a", "x")?;
|
||||
relation(&mut p, &scope, "by", RelationKind::Allocation, "b", "y")?;
|
||||
("x", "y", [xy(340., 260.), xy(340., 400.)])
|
||||
};
|
||||
relation(
|
||||
&mut p,
|
||||
&scope,
|
||||
"exchange",
|
||||
if layer == Layer::Oa {
|
||||
RelationKind::Interaction
|
||||
} else {
|
||||
RelationKind::FunctionalExchange
|
||||
},
|
||||
from,
|
||||
to,
|
||||
)?;
|
||||
p.diagrams
|
||||
.get_mut(&id(&scope))
|
||||
.unwrap()
|
||||
.routes
|
||||
.push(route(&scope, pins));
|
||||
}
|
||||
}
|
||||
diagram(
|
||||
&mut p,
|
||||
"other_view",
|
||||
"05 Other saved view - preserve",
|
||||
Layer::Oa,
|
||||
ProjectionKind::Overview,
|
||||
);
|
||||
for (suffix, pos) in [("x", xy(40., 60.)), ("y", xy(410., 180.))] {
|
||||
let alias = named("other_view", suffix);
|
||||
p.diagrams.get_mut(&id("other_view")).unwrap().nodes.insert(
|
||||
id(&alias),
|
||||
NodePlacement {
|
||||
id: id(&alias),
|
||||
alias,
|
||||
element: id(&named("oa_assign", suffix)),
|
||||
position: pos,
|
||||
size: Size {
|
||||
width: 224.,
|
||||
height: 94.,
|
||||
},
|
||||
collapsed: false,
|
||||
port_anchors: vec![],
|
||||
},
|
||||
);
|
||||
}
|
||||
validate_candidate(&p)?;
|
||||
Ok(p)
|
||||
}
|
||||
fn node_mut<'a>(p: &'a mut Project, scope: &str, suffix: &str) -> &'a mut NodePlacement {
|
||||
p.diagrams
|
||||
.get_mut(&id(scope))
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&id(&named(scope, &format!("node_{suffix}"))))
|
||||
.unwrap()
|
||||
}
|
||||
fn expected(before: &Project, mode: &str) -> Result<Project> {
|
||||
let mut p = before.clone();
|
||||
match mode {
|
||||
"unchanged" => {}
|
||||
"oa-assigned" | "sa-assigned" => {
|
||||
let scope = if mode == "oa-assigned" {
|
||||
"oa_assign"
|
||||
} else {
|
||||
"sa_assign"
|
||||
};
|
||||
for (rel, work) in [("ax", "x"), ("by", "y")] {
|
||||
p.relations.get_mut(&id(&named(scope, rel))).unwrap().body =
|
||||
RelationBody::Allocation {
|
||||
source: id(&named(scope, "c")),
|
||||
target: id(&named(scope, work)),
|
||||
};
|
||||
}
|
||||
node_mut(&mut p, scope, "x").position = xy(560., 140.);
|
||||
node_mut(&mut p, scope, "y").position = xy(560., 440.);
|
||||
p.diagrams.get_mut(&id(scope)).unwrap().routes[0].waypoints =
|
||||
vec![xy(820., 260.), xy(820., 400.)];
|
||||
}
|
||||
"oa-arranged" | "sa-arranged" => {
|
||||
let scope = if mode == "oa-arranged" {
|
||||
"oa_arrange"
|
||||
} else {
|
||||
"sa_arrange"
|
||||
};
|
||||
for (suffix, pos) in [
|
||||
("p", xy(40., 280.)),
|
||||
("u", xy(80., 380.)),
|
||||
("v", xy(440., 380.)),
|
||||
] {
|
||||
node_mut(&mut p, scope, suffix).position = pos;
|
||||
}
|
||||
p.diagrams.get_mut(&id(scope)).unwrap().routes[0].waypoints =
|
||||
vec![xy(360., 420.), xy(360., 470.)];
|
||||
}
|
||||
"oa-performers" => {
|
||||
for (suffix, pos) in [
|
||||
("a", xy(120., 90.)),
|
||||
("b", xy(120., 380.)),
|
||||
("x", xy(157., 183.)),
|
||||
("y", xy(157., 483.)),
|
||||
] {
|
||||
node_mut(&mut p, "oa_assign", suffix).position = pos;
|
||||
}
|
||||
p.diagrams.get_mut(&id("oa_assign")).unwrap().routes[0].waypoints =
|
||||
vec![xy(417., 303.), xy(417., 443.)];
|
||||
}
|
||||
_ => return Err(format!("Unknown mode {mode}; expected {MODES:?}").into()),
|
||||
}
|
||||
Ok(p)
|
||||
}
|
||||
fn ancestor(view: &projection::ProjectedView, parent: Id, child: Id) -> bool {
|
||||
let mut next = view
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|n| n.id == child)
|
||||
.and_then(|n| n.visual_parent);
|
||||
for _ in 0..view.nodes.len() {
|
||||
let Some(current) = next else {
|
||||
return false;
|
||||
};
|
||||
if current == parent {
|
||||
return true;
|
||||
}
|
||||
next = view
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|n| n.id == current)
|
||||
.and_then(|n| n.visual_parent);
|
||||
}
|
||||
false
|
||||
}
|
||||
/// Fixture-usability preflight only: never calculates expected mutations.
|
||||
fn preflight(p: &Project) -> Result<serde_json::Value> {
|
||||
validate_candidate(p)?;
|
||||
let mut scenes = Vec::new();
|
||||
for scope in ["oa_assign", "sa_assign", "oa_arrange", "sa_arrange"] {
|
||||
let d = &p.diagrams[&id(scope)];
|
||||
let view = projection::project_view(p, d.id);
|
||||
need(
|
||||
view.nodes.len() == d.nodes.len(),
|
||||
format!("{scope}: hidden/missing fixture placement"),
|
||||
)?;
|
||||
for n in &view.nodes {
|
||||
need(
|
||||
n.position == d.nodes[&n.id].position,
|
||||
format!("{scope}: unexpectedly shifted projected origin {}", n.id),
|
||||
)?;
|
||||
need(
|
||||
n.position.x >= 0.
|
||||
&& n.position.y >= 0.
|
||||
&& n.position.x + n.size.width <= 900.
|
||||
&& n.position.y + n.size.height <= 750.,
|
||||
format!("{scope}: fixture exceeds declared compact world envelope: {n:?}"),
|
||||
)?;
|
||||
if p.elements[&n.element].kind().is_work() {
|
||||
let height = if d.layer == Layer::Sa { 132. } else { 94. };
|
||||
need(
|
||||
n.size
|
||||
== Size {
|
||||
width: 224.,
|
||||
height,
|
||||
},
|
||||
format!("{scope}: unexpected work/port extent {n:?}"),
|
||||
)?;
|
||||
}
|
||||
}
|
||||
for (i, a) in view.nodes.iter().enumerate() {
|
||||
for b in &view.nodes[i + 1..] {
|
||||
if ancestor(&view, a.id, b.id) || ancestor(&view, b.id, a.id) {
|
||||
continue;
|
||||
}
|
||||
let width = (a.position.x + a.size.width).min(b.position.x + b.size.width)
|
||||
- a.position.x.max(b.position.x);
|
||||
let height = (a.position.y + a.size.height).min(b.position.y + b.size.height)
|
||||
- a.position.y.max(b.position.y);
|
||||
need(
|
||||
width <= 0. || height <= 0.,
|
||||
format!("{scope}: unrelated projected overlap {} {}", a.id, b.id),
|
||||
)?;
|
||||
}
|
||||
}
|
||||
let routed = routing::route_diagram(p, d, &view);
|
||||
let edge = id(&named(scope, "exchange"));
|
||||
let r = routed
|
||||
.edges
|
||||
.get(&edge)
|
||||
.ok_or("Authored exchange route is missing")?;
|
||||
need(
|
||||
!r.custom_suspended && r.pins == d.routes[0].waypoints,
|
||||
format!("{scope}: authored pins are not active"),
|
||||
)?;
|
||||
need(
|
||||
r.warnings.is_empty(),
|
||||
format!(
|
||||
"{scope}: route warnings require fixture review: {:?}; ports={:?}; points={:?}; nodes={:?}",
|
||||
r.warnings, view.ports, r.points, view.nodes
|
||||
),
|
||||
)?;
|
||||
need(
|
||||
view.ports.len() == if d.layer == Layer::Sa { 2 } else { 0 },
|
||||
format!("{scope}: unexpected visible typed ports"),
|
||||
)?;
|
||||
scenes.push(serde_json::json!({"diagram":scope,"nodes":view.nodes,"ports":view.ports,"active_pins":r.pins,"route_warnings":r.warnings.iter().map(ToString::to_string).collect::<Vec<_>>()}));
|
||||
}
|
||||
Ok(serde_json::json!(scenes))
|
||||
}
|
||||
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())?;
|
||||
need(
|
||||
!disk
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|d| d.diagnostic.severity == validation::Severity::Error),
|
||||
format!("Source discovery errors: {:?}", disk.diagnostics),
|
||||
)?;
|
||||
let compiled = compile_bundle(&disk.reachable_bundle(), ProjectLimits::default())
|
||||
.map_err(|e| format!("Source compile: {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 mut bundle = format_bundle(p);
|
||||
for (path, text) in &mut bundle.files {
|
||||
*text = format!("// Native preservation sentinel: {path}\n{text}");
|
||||
}
|
||||
let parsed = compile_bundle(&bundle, ProjectLimits::default())
|
||||
.map_err(|e| format!("Source roundtrip: {e:?}"))?;
|
||||
equal(p, &parsed.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<()> {
|
||||
need(!out.exists(), "Output exists; choose a fresh directory")?;
|
||||
let p = fixture()?;
|
||||
let bundle = roundtrip(&p)?;
|
||||
let mut geometry = BTreeMap::new();
|
||||
// All literal candidates must satisfy the same compact-fixture prerequisites
|
||||
// before any bytes are published for a native run.
|
||||
for mode in MODES {
|
||||
let candidate = expected(&p, mode)?;
|
||||
roundtrip(&candidate)?;
|
||||
geometry.insert(mode, preflight(&candidate)?);
|
||||
}
|
||||
let mut ids = BTreeMap::from([(p.alias.clone(), p.id)]);
|
||||
ids.extend(p.elements.values().map(|v| (v.alias.clone(), v.id)));
|
||||
ids.extend(p.relations.values().map(|v| (v.alias.clone(), v.id)));
|
||||
ids.extend(p.diagrams.values().map(|v| (v.alias.clone(), v.id)));
|
||||
ids.extend(
|
||||
p.diagrams
|
||||
.values()
|
||||
.flat_map(|d| d.nodes.values().map(|v| (v.alias.clone(), v.id))),
|
||||
);
|
||||
write_new(&out.join("baseline.json"), &serde_json::to_vec_pretty(&p)?)?;
|
||||
for (path, text) in &bundle.files {
|
||||
write_new(&out.join("source").join(path), text.as_bytes())?;
|
||||
}
|
||||
let paths: BTreeMap<_, _> = ids
|
||||
.iter()
|
||||
.map(|(alias, id)| {
|
||||
let needle = id.to_string();
|
||||
let files: Vec<_> = bundle
|
||||
.files
|
||||
.iter()
|
||||
.filter(|(_, text)| text.contains(&needle))
|
||||
.map(|(path, _)| path.clone())
|
||||
.collect();
|
||||
(alias.clone(), files)
|
||||
})
|
||||
.collect();
|
||||
write_new(
|
||||
&out.join("manifest.json"),
|
||||
&serde_json::to_vec_pretty(&serde_json::json!({
|
||||
"fixture":"allocation-multiselect-native-20260923","source_entry":bundle.entry,"ids":ids,
|
||||
"source_files":bundle.files.keys().collect::<Vec<_>>(),"id_text_occurrence_files":paths,
|
||||
"modes":MODES,"geometry_preflight":geometry,
|
||||
"literal_deltas":{"oa-assigned":[480,0],"sa-assigned":[480,0],"oa-arranged":[0,240],"sa-arranged":[0,240],"oa-performers":[77,43]},
|
||||
"selection":{"assigned":["X","Y"],"arranged":["P","V"],"ordinary_performers":["A","B"]},
|
||||
"ordinary_anchor_target":[120,90],
|
||||
"evaluation":"Literal complete Project oracle, never inferred from saved geometry; baseline pinned to deterministic original",
|
||||
"limits":"Geometry preflight is not GUI/planner admission. Source comments/history bytes, focus, exact selection, pointer delivery and one Undo/Redo require separate observation/checks. Each mode is one operation from baseline."
|
||||
}))?,
|
||||
)?;
|
||||
equal(&p, &load(&out.join("baseline.json"))?)?;
|
||||
equal(&p, &load(&out.join("source").join(&bundle.entry))?)?;
|
||||
println!("PASS generated and reloaded fixture: {}", out.display());
|
||||
Ok(())
|
||||
}
|
||||
fn check(mode: &str, baseline: &Path, saved: &Path) -> Result<()> {
|
||||
let before = load(baseline)?;
|
||||
equal(&fixture()?, &before).map_err(|e| format!("Baseline is not immutable original: {e}"))?;
|
||||
let actual = load(saved)?;
|
||||
validate_candidate(&actual)?;
|
||||
equal(&expected(&before, mode)?, &actual)?;
|
||||
roundtrip(&actual)?;
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&serde_json::json!({
|
||||
"result":"PASS","mode":mode,"baseline":baseline,"saved":saved,
|
||||
"comparison":"complete Project equality; literal fixed record edits",
|
||||
"limits":"No pointer, selection, source-byte preservation, focus, Undo/Redo, or native 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(format!("Usage: allocation_multiselect_native generate DIRECTORY | check MODE BASELINE SAVED; modes={MODES:?}").into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn through_json(mode: &str, before: &Project, actual: &Project) -> Result<()> {
|
||||
validate_candidate(before).expect("baseline valid");
|
||||
validate_candidate(actual).expect("falsifier must remain core-valid");
|
||||
let dir = tempfile::tempdir()?;
|
||||
let baseline = dir.path().join("baseline.json");
|
||||
let saved = dir.path().join("saved.json");
|
||||
persistence::save(&baseline, before)?;
|
||||
persistence::save(&saved, actual)?;
|
||||
check(mode, &baseline, &saved)
|
||||
}
|
||||
fn mismatch(mode: &str, before: &Project, actual: &Project) {
|
||||
let error = through_json(mode, before, actual).unwrap_err().to_string();
|
||||
assert!(
|
||||
error.contains("Whole Project mismatch"),
|
||||
"wrong failure cause: {error}"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn deterministic_create_only_generation_and_all_literal_modes() {
|
||||
let p = fixture().unwrap();
|
||||
assert_eq!(p, fixture().unwrap());
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let out = dir.path().join("fixture");
|
||||
generate(&out).unwrap();
|
||||
let before = fs::read(out.join("baseline.json")).unwrap();
|
||||
assert!(generate(&out).is_err());
|
||||
assert_eq!(before, fs::read(out.join("baseline.json")).unwrap());
|
||||
for mode in MODES {
|
||||
let candidate = expected(&p, mode).unwrap();
|
||||
preflight(&candidate).unwrap();
|
||||
through_json(mode, &p, &candidate).unwrap();
|
||||
if mode != "unchanged" {
|
||||
mismatch(mode, &p, &p);
|
||||
}
|
||||
}
|
||||
assert!(expected(&p, "invented").is_err());
|
||||
let changed = expected(&p, "oa-assigned").unwrap();
|
||||
assert!(
|
||||
through_json("unchanged", &changed, &changed)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("immutable original")
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn actual_core_assignment_batch_matches_literal_oracle_and_one_step_history() {
|
||||
let before = fixture().unwrap();
|
||||
for (mode, scope) in [("oa-assigned", "oa_assign"), ("sa-assigned", "sa_assign")] {
|
||||
let mut commands = Vec::new();
|
||||
for (alias, work) in [("ax", "x"), ("by", "y")] {
|
||||
let relation = id(&named(scope, alias));
|
||||
let mut value = before.relations[&relation].clone();
|
||||
value.body = RelationBody::Allocation {
|
||||
source: id(&named(scope, "c")),
|
||||
target: id(&named(scope, work)),
|
||||
};
|
||||
commands.push(Command::EditRelation(relation, value));
|
||||
}
|
||||
for (suffix, position) in [("x", xy(560., 140.)), ("y", xy(560., 440.))] {
|
||||
let original = id(&named(scope, &format!("node_{suffix}")));
|
||||
let mut node = before.diagrams[&id(scope)].nodes[&original].clone();
|
||||
node.position = position;
|
||||
commands.push(Command::EditNode {
|
||||
diagram: id(scope),
|
||||
original,
|
||||
node,
|
||||
});
|
||||
}
|
||||
let mut route = before.diagrams[&id(scope)].routes[0].clone();
|
||||
route.waypoints = vec![xy(820., 260.), xy(820., 400.)];
|
||||
commands.push(Command::SetEdgeRoute {
|
||||
diagram: id(scope),
|
||||
route,
|
||||
});
|
||||
let mut session = Session::new(before.clone());
|
||||
session.execute(Command::Batch(commands)).unwrap();
|
||||
through_json(mode, &before, session.project()).unwrap();
|
||||
let after = session.project().clone();
|
||||
session.undo();
|
||||
assert_eq!(session.project(), &before);
|
||||
assert!(!session.can_undo());
|
||||
session.redo();
|
||||
assert_eq!(session.project(), &after);
|
||||
assert!(!session.can_redo());
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn assignment_rejects_singleton_semantic_identity_and_route_drift() {
|
||||
let before = fixture().unwrap();
|
||||
for mode in ["oa-assigned", "sa-assigned"] {
|
||||
let scope = if mode == "oa-assigned" {
|
||||
"oa_assign"
|
||||
} else {
|
||||
"sa_assign"
|
||||
};
|
||||
let good = expected(&before, mode).unwrap();
|
||||
let mut bad = good.clone();
|
||||
node_mut(&mut bad, scope, "y").position = xy(80., 440.);
|
||||
mismatch(mode, &before, &bad);
|
||||
let mut bad = good.clone();
|
||||
bad.relations.insert(
|
||||
id(&named(scope, "by")),
|
||||
before.relations[&id(&named(scope, "by"))].clone(),
|
||||
);
|
||||
mismatch(mode, &before, &bad);
|
||||
let mut bad = good.clone();
|
||||
reidentify(
|
||||
&mut bad,
|
||||
id(&named(scope, "ax")),
|
||||
id("replacement_allocation"),
|
||||
)
|
||||
.unwrap();
|
||||
mismatch(mode, &before, &bad);
|
||||
let mut bad = good.clone();
|
||||
bad.relations
|
||||
.get_mut(&id(&named(scope, "ax")))
|
||||
.unwrap()
|
||||
.description
|
||||
.push_str(" drift");
|
||||
mismatch(mode, &before, &bad);
|
||||
for pins in [
|
||||
vec![xy(340., 260.), xy(340., 400.)],
|
||||
vec![xy(1300., 260.), xy(1300., 400.)],
|
||||
] {
|
||||
let mut bad = good.clone();
|
||||
bad.diagrams.get_mut(&id(scope)).unwrap().routes[0].waypoints = pins;
|
||||
mismatch(mode, &before, &bad);
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn arrange_and_ordinary_closure_falsifiers_remain_core_valid() {
|
||||
let before = fixture().unwrap();
|
||||
for mode in ["oa-arranged", "sa-arranged"] {
|
||||
let scope = if mode == "oa-arranged" {
|
||||
"oa_arrange"
|
||||
} else {
|
||||
"sa_arrange"
|
||||
};
|
||||
let good = expected(&before, mode).unwrap();
|
||||
for (suffix, pos) in [("u", xy(80., 140.)), ("v", xy(440., 140.))] {
|
||||
let mut bad = good.clone();
|
||||
node_mut(&mut bad, scope, suffix).position = pos;
|
||||
mismatch(mode, &before, &bad);
|
||||
}
|
||||
let mut bad = good.clone();
|
||||
bad.relations.remove(&id(&named(scope, "pu")));
|
||||
mismatch(mode, &before, &bad);
|
||||
}
|
||||
let good = expected(&before, "oa-performers").unwrap();
|
||||
let mut bad = good.clone();
|
||||
node_mut(&mut bad, "oa_assign", "x").position = xy(80., 140.);
|
||||
mismatch("oa-performers", &before, &bad);
|
||||
let mut bad = good;
|
||||
bad.relations.get_mut(&id("oa_assign_ax")).unwrap().body = RelationBody::Allocation {
|
||||
source: id("oa_assign_c"),
|
||||
target: id("oa_assign_x"),
|
||||
};
|
||||
mismatch("oa-performers", &before, &bad);
|
||||
}
|
||||
#[test]
|
||||
fn whole_project_sentinels_and_real_saved_source_are_checked() {
|
||||
let before = fixture().unwrap();
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let baseline = dir.path().join("baseline.json");
|
||||
persistence::save(&baseline, &before).unwrap();
|
||||
for mode in MODES {
|
||||
let good = expected(&before, mode).unwrap();
|
||||
for corrupt in [false, true] {
|
||||
let mut actual = good.clone();
|
||||
if corrupt {
|
||||
actual
|
||||
.elements
|
||||
.get_mut(&id("sa_arrange_v"))
|
||||
.unwrap()
|
||||
.description
|
||||
.push_str(" drift");
|
||||
}
|
||||
let bundle = roundtrip(&actual).unwrap();
|
||||
let folder = dir.path().join(format!("{mode}-{corrupt}"));
|
||||
for (path, text) in &bundle.files {
|
||||
write_new(&folder.join(path), text.as_bytes()).unwrap();
|
||||
}
|
||||
let result = check(mode, &baseline, &folder.join(&bundle.entry));
|
||||
if corrupt {
|
||||
assert!(
|
||||
result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("Whole Project mismatch")
|
||||
);
|
||||
} else {
|
||||
result.unwrap();
|
||||
}
|
||||
}
|
||||
let mut bad = good;
|
||||
bad.diagrams
|
||||
.get_mut(&id("other_view"))
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&id("other_view_x"))
|
||||
.unwrap()
|
||||
.position
|
||||
.x += 10.;
|
||||
mismatch(mode, &before, &bad);
|
||||
}
|
||||
let invalid = dir.path().join("invalid.arcadia");
|
||||
write_new(&invalid, b"project {").unwrap();
|
||||
assert!(load(&invalid).is_err());
|
||||
}
|
||||
}
|
||||
@@ -1389,6 +1389,11 @@ impl App {
|
||||
if let Message::TextWorkspace(event) = message {
|
||||
return self.text_workspace_event(event);
|
||||
}
|
||||
// Allocation placement movement requires its captured Press/Active
|
||||
// lifetime, even after cancellation. A bare queued move has no epoch.
|
||||
if self.allocation_legacy_move(&message) {
|
||||
return Task::none();
|
||||
}
|
||||
// These handlers reject old owners/tokens without changing state. Apply
|
||||
// the same admission before shared camera and allocation boundaries.
|
||||
if !self.source_input_envelope(&message)
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
//! Allocation work-body input admission. Gesture authority lives here, while
|
||||
//! the canvas retains ordered native pointer positions and frozen hit geometry.
|
||||
use super::{App, Message, Page};
|
||||
use crate::{allocation_drag as plan, route_edit::Context};
|
||||
use crate::{
|
||||
allocation_drag as plan,
|
||||
diagram_selection::Snapshot,
|
||||
diagram_usage::{Surface, Target as SelectionTarget},
|
||||
route_edit::Context,
|
||||
};
|
||||
use iced::{Point, Rectangle, Task, Vector};
|
||||
use oa_core::{projection::ProjectedView, routing::RoutedView, *};
|
||||
use std::{collections::BTreeMap, sync::Arc};
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
pub const FINISH_EDIT: &str = "Finish or cancel the current edit before selecting or moving work.";
|
||||
pub const FINISH_EDIT: &str =
|
||||
"Finish or cancel the current edit before selecting or moving elements.";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Stamp {
|
||||
@@ -65,7 +74,8 @@ pub fn contains(r: Rectangle, p: Point) -> bool {
|
||||
impl Frozen {
|
||||
pub fn capture(
|
||||
project: &Project,
|
||||
scope: &plan::Scope,
|
||||
diagram: &Diagram,
|
||||
affected: &BTreeMap<Id, NodePlacement>,
|
||||
view: &ProjectedView,
|
||||
routed: &RoutedView,
|
||||
zoom: f32,
|
||||
@@ -73,7 +83,7 @@ impl Frozen {
|
||||
let mut targets = Vec::new();
|
||||
let mut cards = Vec::new();
|
||||
for n in &view.nodes {
|
||||
if scope.placements.contains_key(&n.id) {
|
||||
if affected.contains_key(&n.id) {
|
||||
continue;
|
||||
}
|
||||
let region = Rectangle::new(
|
||||
@@ -84,7 +94,7 @@ impl Frozen {
|
||||
cards.push(region);
|
||||
continue;
|
||||
};
|
||||
if e.kind().is_performer() && e.layer() == scope.diagram.layer {
|
||||
if e.kind().is_performer() && e.layer() == diagram.layer {
|
||||
let mut ancestors = Vec::new();
|
||||
let mut parent = e.parent;
|
||||
while let Some(id) = parent {
|
||||
@@ -95,7 +105,7 @@ impl Frozen {
|
||||
parent = project.elements.get(&id).and_then(|e| e.parent);
|
||||
}
|
||||
let name = project.named(e.id).to_owned();
|
||||
let collapsed = scope.diagram.nodes[&n.id].collapsed;
|
||||
let collapsed = diagram.nodes[&n.id].collapsed;
|
||||
targets.push(Target {
|
||||
element: e.id,
|
||||
placement: n.id,
|
||||
@@ -118,7 +128,7 @@ impl Frozen {
|
||||
let mut controls: Vec<_> = view
|
||||
.ports
|
||||
.iter()
|
||||
.filter(|port| !scope.placements.contains_key(&port.owner))
|
||||
.filter(|port| !affected.contains_key(&port.owner))
|
||||
.map(|port| {
|
||||
Rectangle::new(
|
||||
Point::new(port.position.x - radius, port.position.y - radius),
|
||||
@@ -224,17 +234,23 @@ impl Frozen {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Mode {
|
||||
Assign,
|
||||
Arrange,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Press {
|
||||
pub(crate) struct Press {
|
||||
pub stamp: Stamp,
|
||||
pub token: Id,
|
||||
pub start: Point,
|
||||
pub scope: plan::Scope,
|
||||
pub frozen: Frozen,
|
||||
pub arrange: bool,
|
||||
pub selection: Snapshot,
|
||||
pub target: SelectionTarget,
|
||||
pub mode: Mode,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Event {
|
||||
pub(crate) enum Event {
|
||||
Press(Arc<Press>),
|
||||
Preview(Stamp, Id, Option<Point>),
|
||||
Release(Stamp, Id, Option<Point>),
|
||||
@@ -244,23 +260,266 @@ pub enum Event {
|
||||
Unfocused,
|
||||
KeyboardBoundary,
|
||||
}
|
||||
pub struct PreviewPlacement {
|
||||
pub diagram: Diagram,
|
||||
pub view: ProjectedView,
|
||||
pub delta: Position,
|
||||
}
|
||||
|
||||
pub struct Preview {
|
||||
pub token: Id,
|
||||
pub semantic: Arc<plan::SemanticPlan>,
|
||||
pub placement: plan::PlacementPlan,
|
||||
pub candidate: Candidate,
|
||||
pub placement: PreviewPlacement,
|
||||
pub routed: Arc<RoutedView>,
|
||||
}
|
||||
impl Preview {
|
||||
pub fn project(&self) -> &Project {
|
||||
self.candidate.project()
|
||||
}
|
||||
#[cfg(test)]
|
||||
pub fn index(&self) -> &view_options::ViewIndex {
|
||||
self.candidate.index()
|
||||
}
|
||||
}
|
||||
|
||||
enum Prepared {
|
||||
Singleton(Box<plan::Scope>),
|
||||
Assignment(plan::assignment::Scope),
|
||||
Arrange(Arc<plan::arrange::Plan>),
|
||||
Performers(Arc<PerformerPlan>),
|
||||
Refused(String),
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum Candidate {
|
||||
Singleton(Arc<plan::SemanticPlan>),
|
||||
Assignment(Arc<plan::assignment::SemanticPlan>),
|
||||
Arrange(Arc<plan::arrange::Plan>),
|
||||
Performers(Arc<PerformerPlan>),
|
||||
}
|
||||
|
||||
enum Placed {
|
||||
Planned(plan::PlacementPlan),
|
||||
Performers {
|
||||
preview: PreviewPlacement,
|
||||
command: Box<Command>,
|
||||
requested: Position,
|
||||
},
|
||||
}
|
||||
|
||||
impl Placed {
|
||||
fn diagram(&self) -> &Diagram {
|
||||
match self {
|
||||
Self::Planned(p) => &p.diagram,
|
||||
Self::Performers { preview, .. } => &preview.diagram,
|
||||
}
|
||||
}
|
||||
fn view(&self) -> &ProjectedView {
|
||||
match self {
|
||||
Self::Planned(p) => &p.view,
|
||||
Self::Performers { preview, .. } => &preview.view,
|
||||
}
|
||||
}
|
||||
fn into_preview(self) -> PreviewPlacement {
|
||||
match self {
|
||||
Self::Planned(p) => PreviewPlacement {
|
||||
diagram: p.diagram,
|
||||
view: p.view,
|
||||
delta: p.delta,
|
||||
},
|
||||
Self::Performers { preview, .. } => preview,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ordinary performer movement preserves the established core translation policy.
|
||||
/// It shares input provenance, not the explicit Arrange geometry restrictions.
|
||||
pub struct PerformerPlan {
|
||||
accepted: Project,
|
||||
index: view_options::ViewIndex,
|
||||
diagram: Id,
|
||||
selected: BTreeSet<Id>,
|
||||
anchor: Id,
|
||||
affected: BTreeMap<Id, NodePlacement>,
|
||||
}
|
||||
impl PerformerPlan {
|
||||
fn place(&self, requested: Position) -> Result<Placed, plan::Refusal> {
|
||||
let original = &self.accepted.diagrams[&self.diagram];
|
||||
let anchor = original.nodes[&self.anchor].position;
|
||||
let delta = if requested == anchor {
|
||||
Position { x: 0., y: 0. }
|
||||
} else {
|
||||
Position {
|
||||
x: (requested.x / 10.).round() * 10. - anchor.x,
|
||||
y: (requested.y / 10.).round() * 10. - anchor.y,
|
||||
}
|
||||
};
|
||||
let command =
|
||||
diagram_edit::translate_placements(&self.accepted, self.diagram, &self.selected, delta)
|
||||
.map_err(|e| plan::Refusal::InvalidCandidate(e.to_string()))?;
|
||||
let mut diagram = original.clone();
|
||||
let Command::Batch(edits) = &command else {
|
||||
unreachable!("translation is an atomic batch")
|
||||
};
|
||||
for edit in edits {
|
||||
match edit {
|
||||
Command::EditNode {
|
||||
diagram: id,
|
||||
original,
|
||||
node,
|
||||
} if *id == self.diagram => {
|
||||
diagram.nodes.insert(*original, node.clone());
|
||||
}
|
||||
Command::SetEdgeRoute { diagram: id, route } if *id == self.diagram => {
|
||||
let stored = diagram
|
||||
.routes
|
||||
.iter_mut()
|
||||
.find(|r| r.edge == route.edge)
|
||||
.ok_or(plan::Refusal::StaleScope)?;
|
||||
*stored = route.clone();
|
||||
}
|
||||
_ => {
|
||||
return Err(plan::Refusal::InvalidCandidate(
|
||||
"Unexpected performer movement edit.".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
let view = projection::project_diagram_with_index(&self.accepted, &diagram, &self.index);
|
||||
Ok(Placed::Performers {
|
||||
preview: PreviewPlacement {
|
||||
diagram,
|
||||
view,
|
||||
delta,
|
||||
},
|
||||
command: Box::new(command),
|
||||
requested,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Candidate {
|
||||
pub fn project(&self) -> &Project {
|
||||
match self {
|
||||
Self::Singleton(p) => p.project(),
|
||||
Self::Assignment(p) => p.project(),
|
||||
Self::Arrange(p) => p.project(),
|
||||
Self::Performers(p) => &p.accepted,
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
pub fn index(&self) -> &view_options::ViewIndex {
|
||||
match self {
|
||||
Self::Singleton(p) => p.index(),
|
||||
Self::Assignment(p) => p.index(),
|
||||
Self::Arrange(p) => p.index(),
|
||||
Self::Performers(p) => &p.index,
|
||||
}
|
||||
}
|
||||
fn place(
|
||||
&self,
|
||||
requested: Position,
|
||||
presentation: plan::PresentationContext,
|
||||
) -> Result<Placed, plan::Refusal> {
|
||||
match self {
|
||||
Self::Singleton(p) => plan::place(p, requested, presentation).map(Placed::Planned),
|
||||
Self::Assignment(p) => {
|
||||
plan::assignment::place(p, requested, presentation).map(Placed::Planned)
|
||||
}
|
||||
Self::Arrange(p) => {
|
||||
plan::arrange::place(p, requested, presentation).map(Placed::Planned)
|
||||
}
|
||||
Self::Performers(p) => p.place(requested),
|
||||
}
|
||||
}
|
||||
fn finish(&self, live: &Project, placed: &Placed) -> Result<Option<Command>, plan::Refusal> {
|
||||
match (self, placed) {
|
||||
(Self::Singleton(p), Placed::Planned(result)) => plan::finish(p, live, result),
|
||||
(Self::Assignment(p), Placed::Planned(result)) => {
|
||||
plan::assignment::finish(p, live, result)
|
||||
}
|
||||
(Self::Arrange(p), Placed::Planned(result)) => plan::arrange::finish(p, live, result),
|
||||
(
|
||||
Self::Performers(p),
|
||||
Placed::Performers {
|
||||
preview,
|
||||
command,
|
||||
requested,
|
||||
},
|
||||
) => {
|
||||
if live != &p.accepted {
|
||||
return Err(plan::Refusal::StaleScope);
|
||||
}
|
||||
let checked = p.place(*requested)?;
|
||||
let Placed::Performers {
|
||||
preview: checked,
|
||||
command: checked_command,
|
||||
..
|
||||
} = checked
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
if checked.diagram != preview.diagram
|
||||
|| checked.view != preview.view
|
||||
|| checked.delta != preview.delta
|
||||
|| &checked_command != command
|
||||
{
|
||||
return Err(plan::Refusal::StaleScope);
|
||||
}
|
||||
let mut expected = p.accepted.clone();
|
||||
expected.diagrams.insert(p.diagram, checked.diagram);
|
||||
let actual = prepare_command(live, &checked_command)
|
||||
.map_err(|e| plan::Refusal::InvalidCandidate(e.to_string()))?
|
||||
.project;
|
||||
if actual != expected {
|
||||
return Err(plan::Refusal::InvalidCandidate(
|
||||
"Performer movement would change unrelated records.".into(),
|
||||
));
|
||||
}
|
||||
Ok((actual != *live).then_some(*checked_command))
|
||||
}
|
||||
_ => Err(plan::Refusal::StaleScope),
|
||||
}
|
||||
}
|
||||
fn label(&self) -> String {
|
||||
let (label, warnings) = match self {
|
||||
Self::Singleton(p) => (p.label.clone(), p.warnings.len()),
|
||||
Self::Assignment(p) => (p.label().to_owned(), p.warnings().len()),
|
||||
Self::Arrange(p) => (
|
||||
format!(
|
||||
"Arrange {} selected elements · {} affected placements",
|
||||
p.selected().len(),
|
||||
p.affected().len()
|
||||
),
|
||||
0,
|
||||
),
|
||||
Self::Performers(p) => (
|
||||
format!("Move {} selected performer(s)", p.selected.len()),
|
||||
0,
|
||||
),
|
||||
};
|
||||
if warnings == 0 {
|
||||
label
|
||||
} else {
|
||||
format!("{label} · {warnings} model warning(s) retained")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Active {
|
||||
press: Arc<Press>,
|
||||
selection: Snapshot,
|
||||
crossed: bool,
|
||||
prepared: Prepared,
|
||||
frozen: Option<Arc<Frozen>>,
|
||||
inspection_targets: Vec<usize>,
|
||||
ids: BTreeMap<Id, Id>,
|
||||
cache: Vec<(
|
||||
plan::Destination,
|
||||
Result<Arc<plan::SemanticPlan>, plan::Refusal>,
|
||||
)>,
|
||||
cache: Vec<(plan::Destination, Result<Candidate, plan::Refusal>)>,
|
||||
}
|
||||
pub struct Feedback {
|
||||
pub(crate) struct Feedback {
|
||||
pub press: Arc<Press>,
|
||||
pub frozen: Option<Arc<Frozen>>,
|
||||
ordinary_performers: bool,
|
||||
pub targets: Vec<usize>,
|
||||
/// Last captured target names for inspection only; never resolver input.
|
||||
pub inspection_targets: Vec<usize>,
|
||||
@@ -273,7 +532,13 @@ impl Feedback {
|
||||
let names = self
|
||||
.inspection_targets
|
||||
.iter()
|
||||
.map(|index| self.press.frozen.targets[*index].name.as_str())
|
||||
.filter_map(|index| {
|
||||
self.frozen
|
||||
.as_ref()?
|
||||
.targets
|
||||
.get(*index)
|
||||
.map(|t| t.name.as_str())
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" / ");
|
||||
format!(
|
||||
@@ -293,7 +558,9 @@ impl Feedback {
|
||||
/// Captured accepted names, independent of clipped status/refusal prose.
|
||||
pub fn destination_identity(&self) -> String {
|
||||
match self.targets.as_slice() {
|
||||
[] => if self.press.arrange {
|
||||
[] => if self.ordinary_performers {
|
||||
"Move performers"
|
||||
} else if self.press.mode == Mode::Arrange {
|
||||
"Arrange only"
|
||||
} else if self.refused {
|
||||
"No eligible destination"
|
||||
@@ -301,12 +568,22 @@ impl Feedback {
|
||||
"Outside performers"
|
||||
}
|
||||
.into(),
|
||||
[index] => self.press.frozen.targets[*index].name.clone(),
|
||||
[index] => self
|
||||
.frozen
|
||||
.as_ref()
|
||||
.and_then(|f| f.targets.get(*index))
|
||||
.map(|t| t.name.clone())
|
||||
.unwrap_or_else(|| "No eligible destination".into()),
|
||||
indices => format!(
|
||||
"Ambiguous: {}",
|
||||
indices
|
||||
.iter()
|
||||
.map(|index| self.press.frozen.targets[*index].name.as_str())
|
||||
.filter_map(|index| self
|
||||
.frozen
|
||||
.as_ref()?
|
||||
.targets
|
||||
.get(*index)
|
||||
.map(|t| t.name.as_str()))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" / ")
|
||||
),
|
||||
@@ -391,6 +668,18 @@ impl App {
|
||||
&& self.canvas_size.is_none_or(|s| s == stamp.bounds.size())
|
||||
&& stamp.bounds.width > 0.
|
||||
&& stamp.bounds.height > 0.
|
||||
&& stamp.zoom > 0.
|
||||
&& [
|
||||
stamp.bounds.x,
|
||||
stamp.bounds.y,
|
||||
stamp.bounds.width,
|
||||
stamp.bounds.height,
|
||||
stamp.pan.x,
|
||||
stamp.pan.y,
|
||||
stamp.zoom,
|
||||
]
|
||||
.iter()
|
||||
.all(|value| value.is_finite())
|
||||
}
|
||||
/// Called before general input dispatch. Even equal-valued actions (Fit,
|
||||
/// Undo/Redo, tool/modal round trips) retire old render-time capabilities.
|
||||
@@ -427,20 +716,54 @@ impl App {
|
||||
pub(super) fn allocation_retire(&mut self) {
|
||||
self.allocation_drag.retire();
|
||||
}
|
||||
fn select_work_clean(&mut self, press: &Press) -> bool {
|
||||
if !self.allocation_stamp_valid(press.stamp) {
|
||||
return false;
|
||||
}
|
||||
self.selected = press.scope.work;
|
||||
self.selected_node = Some(press.scope.placement);
|
||||
self.selected_bend = None;
|
||||
self.refresh_draft();
|
||||
true
|
||||
fn allocation_selection_valid(&self, snapshot: &Snapshot) -> bool {
|
||||
self.multiselect_context().is_some_and(|current| {
|
||||
self.diagram_selection
|
||||
.as_ref()
|
||||
.is_some_and(|selection| selection.matches(snapshot, current))
|
||||
})
|
||||
}
|
||||
pub(super) fn allocation_press_owns_current_view(&self, press: &Press) -> bool {
|
||||
self.allocation_drag.active.is_none()
|
||||
&& self.allocation_stamp_valid(press.stamp)
|
||||
&& press.arrange == self.allocation_drag.arrange_next
|
||||
if self.allocation_drag.active.is_some()
|
||||
|| !self.allocation_stamp_valid(press.stamp)
|
||||
|| (press.mode == Mode::Arrange) != self.allocation_drag.arrange_next
|
||||
|| !self.allocation_selection_valid(&press.selection)
|
||||
|| press.selection.context().scope.surface
|
||||
!= Surface::Saved(press.stamp.context.diagram)
|
||||
|| !press.start.x.is_finite()
|
||||
|| !press.start.y.is_finite()
|
||||
|| !contains(press.stamp.bounds, press.start)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let view = projection::project_view_with_index(
|
||||
self.project(),
|
||||
press.stamp.context.diagram,
|
||||
self.document.view_index(),
|
||||
);
|
||||
super::multiselect::visible(
|
||||
self.project(),
|
||||
press.stamp.context.diagram,
|
||||
&view,
|
||||
&press.target,
|
||||
)
|
||||
}
|
||||
/// Bare Allocation movement has no lifetime provenance. All actual Allocation
|
||||
/// placement gestures now enter Press/Active, including ordinary performers.
|
||||
pub(super) fn allocation_legacy_move(&self, message: &Message) -> bool {
|
||||
let allocation = |diagram| {
|
||||
self.project()
|
||||
.diagrams
|
||||
.get(&diagram)
|
||||
.is_some_and(|d| d.projection == ProjectionKind::Allocation)
|
||||
};
|
||||
match message {
|
||||
Message::MoveNode(..) => self.active_diagram.is_some_and(allocation),
|
||||
Message::DiagramSelection(super::multiselect::Event::Move(snapshot, _)) => {
|
||||
matches!(snapshot.context().scope.surface, Surface::Saved(diagram) if allocation(diagram))
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
pub(super) fn allocation_event_envelope(&self, event: &Event) -> bool {
|
||||
match event {
|
||||
@@ -451,11 +774,64 @@ impl App {
|
||||
.is_some_and(|active| active.press.token == *token)
|
||||
}
|
||||
Event::Refused(epoch, _) => *epoch == self.allocation_drag.epoch,
|
||||
// Invalid Press packets deliberately report why the view or draft
|
||||
// changed. Leave their existing validation and feedback intact.
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
fn allocation_prepare(&self, press: &Press, selection: &Snapshot) -> Prepared {
|
||||
let SelectionTarget::Placement(anchor) = &press.target else {
|
||||
return Prepared::Refused("Move requires only placed work or performers; ports and connections cannot be moved with this selection.".into());
|
||||
};
|
||||
let anchor = *anchor;
|
||||
let Some(ids) = super::multiselect::placements(selection) else {
|
||||
return Prepared::Refused("Move requires only placed work or performers; remove ports, rows and connections from the selection.".into());
|
||||
};
|
||||
let project = self.project();
|
||||
let diagram = press.stamp.context.diagram;
|
||||
let d = &project.diagrams[&diagram];
|
||||
let selected: BTreeMap<_, _> = ids.iter().map(|id| (*id, d.nodes[id].element)).collect();
|
||||
let result = if press.mode == Mode::Arrange {
|
||||
plan::arrange::capture(project, diagram, &selected, anchor)
|
||||
.map(|p| Prepared::Arrange(Arc::new(p)))
|
||||
} else if selected
|
||||
.values()
|
||||
.all(|id| project.elements[id].kind().is_work())
|
||||
{
|
||||
if selected.len() == 1 {
|
||||
plan::capture(project, diagram, selected[&anchor], anchor)
|
||||
.map(|scope| Prepared::Singleton(Box::new(scope)))
|
||||
} else {
|
||||
plan::assignment::capture(project, diagram, &selected, anchor)
|
||||
.map(Prepared::Assignment)
|
||||
}
|
||||
} else if selected
|
||||
.values()
|
||||
.all(|id| project.elements[id].kind().is_performer())
|
||||
{
|
||||
validate_candidate(project)
|
||||
.map_err(|e| plan::Refusal::InvalidCandidate(e.to_string()))
|
||||
.map(|_| {
|
||||
let mut affected = BTreeMap::new();
|
||||
for id in &ids {
|
||||
for member in std::iter::once(*id)
|
||||
.chain(projection::descendant_nodes(project, diagram, *id))
|
||||
{
|
||||
affected.insert(member, d.nodes[&member].clone());
|
||||
}
|
||||
}
|
||||
Prepared::Performers(Arc::new(PerformerPlan {
|
||||
accepted: project.clone(),
|
||||
index: view_options::ViewIndex::new(project),
|
||||
diagram,
|
||||
selected: ids,
|
||||
anchor,
|
||||
affected,
|
||||
}))
|
||||
})
|
||||
} else {
|
||||
Err(plan::Refusal::Ineligible("Assignment requires only work. Arm Arrange next drag to arrange a supported work/performer selection.".into()))
|
||||
};
|
||||
result.unwrap_or_else(|reason| Prepared::Refused(refusal(&reason)))
|
||||
}
|
||||
pub(super) fn allocation_event(&mut self, event: Event) -> Task<Message> {
|
||||
if !self.allocation_event_envelope(&event) {
|
||||
return Task::none();
|
||||
@@ -465,7 +841,8 @@ impl App {
|
||||
self.allocation_drag.retire();
|
||||
if self.allocation_drag_clean() {
|
||||
self.allocation_drag.arrange_next = true;
|
||||
self.status = "Arrange next drag armed · moves work only · Esc cancels".into();
|
||||
self.status =
|
||||
"Arrange next drag armed · presentation only · Esc cancels".into();
|
||||
} else {
|
||||
self.status = FINISH_EDIT.into();
|
||||
}
|
||||
@@ -476,22 +853,13 @@ impl App {
|
||||
self.allocation_drag.retire();
|
||||
}
|
||||
}
|
||||
Event::Cancel(token) => {
|
||||
if self
|
||||
.allocation_drag
|
||||
.active
|
||||
.as_ref()
|
||||
.is_some_and(|a| a.press.token == token)
|
||||
{
|
||||
self.allocation_drag.retire();
|
||||
self.status = "Work drag canceled".into();
|
||||
}
|
||||
Event::Cancel(_) => {
|
||||
self.allocation_drag.retire();
|
||||
self.status = "Drag canceled".into();
|
||||
}
|
||||
Event::Refused(epoch, text) => {
|
||||
if epoch == self.allocation_drag.epoch {
|
||||
self.allocation_drag.retire();
|
||||
self.status = text;
|
||||
}
|
||||
Event::Refused(_, text) => {
|
||||
self.allocation_drag.retire();
|
||||
self.status = text;
|
||||
}
|
||||
Event::Press(press) => {
|
||||
if self.allocation_drag.active.is_some() {
|
||||
@@ -505,32 +873,73 @@ impl App {
|
||||
};
|
||||
return Task::none();
|
||||
}
|
||||
let checked = plan::capture(
|
||||
self.project(),
|
||||
press.scope.diagram.id,
|
||||
press.scope.work,
|
||||
press.scope.placement,
|
||||
);
|
||||
if checked.as_ref() != Ok(&press.scope) || !self.select_work_clean(&press) {
|
||||
self.status = checked
|
||||
.err()
|
||||
.as_ref()
|
||||
.map(refusal)
|
||||
.unwrap_or_else(|| "The view changed. Start this drag again.".into());
|
||||
self.allocation_drag.retire();
|
||||
// A selected-member press has no selection or inspector effect.
|
||||
// An unselected press replaces selection atomically in this handler.
|
||||
if !press.selection.targets().contains(&press.target) {
|
||||
self.multiselect_event(super::multiselect::Event::Choose(
|
||||
press.selection.context(),
|
||||
press.target.clone(),
|
||||
super::multiselect::Choice::Replace,
|
||||
));
|
||||
}
|
||||
let Some(selection) = self.multiselect_snapshot() else {
|
||||
return Task::none();
|
||||
};
|
||||
if !selection.targets().contains(&press.target)
|
||||
|| (!press.selection.targets().contains(&press.target)
|
||||
&& selection.targets() != [press.target.clone()])
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
let ids = press
|
||||
.scope
|
||||
.leaves
|
||||
.keys()
|
||||
.map(|id| (*id, Id::new_v4()))
|
||||
.collect();
|
||||
let diagram = press.stamp.context.diagram;
|
||||
let view = projection::project_view_with_index(
|
||||
self.project(),
|
||||
diagram,
|
||||
self.document.view_index(),
|
||||
);
|
||||
if !selection.targets().iter().all(|target| {
|
||||
super::multiselect::visible(self.project(), diagram, &view, target)
|
||||
}) {
|
||||
self.allocation_drag.retire();
|
||||
self.status = "The view changed. Start this drag again.".into();
|
||||
return Task::none();
|
||||
}
|
||||
let prepared = self.allocation_prepare(&press, &selection);
|
||||
let (affected, leaf_ids) = match &prepared {
|
||||
Prepared::Singleton(scope) => (
|
||||
Some(&scope.placements),
|
||||
scope.leaves.keys().copied().collect::<Vec<_>>(),
|
||||
),
|
||||
Prepared::Assignment(scope) => (
|
||||
Some(scope.affected()),
|
||||
scope.leaves().keys().copied().collect(),
|
||||
),
|
||||
Prepared::Arrange(plan) => (Some(plan.affected()), Vec::new()),
|
||||
Prepared::Performers(plan) => (Some(&plan.affected), Vec::new()),
|
||||
Prepared::Refused(_) => (None, Vec::new()),
|
||||
};
|
||||
let frozen = affected.map(|affected| {
|
||||
let d = &self.project().diagrams[&diagram];
|
||||
let routed = routing::route_diagram(self.project(), d, &view);
|
||||
Arc::new(Frozen::capture(
|
||||
self.project(),
|
||||
d,
|
||||
affected,
|
||||
&view,
|
||||
&routed,
|
||||
press.stamp.zoom,
|
||||
))
|
||||
});
|
||||
let ids = leaf_ids.into_iter().map(|id| (id, Id::new_v4())).collect();
|
||||
self.allocation_drag.arrange_next = false;
|
||||
self.allocation_drag.preview = None;
|
||||
self.allocation_drag.feedback = None;
|
||||
self.allocation_drag.active = Some(Active {
|
||||
press,
|
||||
selection,
|
||||
crossed: false,
|
||||
prepared,
|
||||
frozen,
|
||||
inspection_targets: Vec::new(),
|
||||
ids,
|
||||
cache: Vec::new(),
|
||||
@@ -552,15 +961,16 @@ impl App {
|
||||
pointer: Option<Point>,
|
||||
release: bool,
|
||||
) {
|
||||
if self
|
||||
.allocation_drag
|
||||
.active
|
||||
.as_ref()
|
||||
.is_none_or(|a| a.press.token != token)
|
||||
{
|
||||
return; // An old packet cannot cancel a newer gesture.
|
||||
let Some(active) = self.allocation_drag.active.as_ref() else {
|
||||
return;
|
||||
};
|
||||
if active.press.token != token {
|
||||
return;
|
||||
}
|
||||
if !self.allocation_stamp_valid(stamp) {
|
||||
if !self.allocation_stamp_valid(stamp)
|
||||
|| !self.allocation_selection_valid(&active.selection)
|
||||
|| active.press.stamp != stamp
|
||||
{
|
||||
self.allocation_drag.retire();
|
||||
self.status = if self.allocation_drag_clean() {
|
||||
"The view changed. Start this drag again.".into()
|
||||
@@ -569,17 +979,35 @@ impl App {
|
||||
};
|
||||
return;
|
||||
}
|
||||
let Some(mut active) = self.allocation_drag.active.take() else {
|
||||
return;
|
||||
};
|
||||
if active.press.token != token || active.press.stamp != stamp {
|
||||
self.allocation_drag.active = Some(active);
|
||||
let mut active = self.allocation_drag.active.take().unwrap();
|
||||
let press = active.press.clone();
|
||||
active.crossed |= pointer.is_some_and(|p| p.distance(press.start) > 3.);
|
||||
let in_bounds = pointer
|
||||
.is_some_and(|p| p.x.is_finite() && p.y.is_finite() && contains(stamp.bounds, p));
|
||||
if !active.crossed && in_bounds {
|
||||
if release {
|
||||
self.multiselect_event(super::multiselect::Event::Collapse(
|
||||
active.selection,
|
||||
press.target.clone(),
|
||||
));
|
||||
self.allocation_drag.retire();
|
||||
self.status = "Element selected".into();
|
||||
} else {
|
||||
self.allocation_drag.active = Some(active);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let press = active.press.clone();
|
||||
let moved = pointer.is_some_and(|p| p.distance(press.start) > 3.);
|
||||
let hit = press.frozen.resolve(stamp, pointer, press.arrange);
|
||||
if moved {
|
||||
let ordinary_performers = matches!(&active.prepared, Prepared::Performers(_));
|
||||
let hit = if !in_bounds {
|
||||
Hit::OutsideViewport
|
||||
} else if ordinary_performers {
|
||||
Hit::Destination(plan::Destination::LayoutOnly, None)
|
||||
} else {
|
||||
active.frozen.as_ref().map_or(Hit::Occluded, |f| {
|
||||
f.resolve(stamp, pointer, press.mode == Mode::Arrange)
|
||||
})
|
||||
};
|
||||
if active.crossed {
|
||||
match &hit {
|
||||
Hit::Destination(_, Some(target)) => active.inspection_targets = vec![*target],
|
||||
Hit::Ambiguous(targets) => active.inspection_targets = targets.clone(),
|
||||
@@ -588,64 +1016,63 @@ impl App {
|
||||
}
|
||||
let mut targets = Vec::new();
|
||||
let mut preview = None;
|
||||
let result: Result<String, String> = match hit {
|
||||
Hit::OutsideViewport => Err(if release {
|
||||
"Work drag canceled outside the canvas."
|
||||
} else {
|
||||
"Pointer outside canvas · return to choose a destination; releasing here cancels the drag."
|
||||
}.into()),
|
||||
_ if !moved => Ok("Work selected".into()),
|
||||
let result: Result<String, String> = if matches!(hit, Hit::OutsideViewport) {
|
||||
Err(if release { "Drag canceled outside the canvas." }
|
||||
else { "Pointer outside canvas · return to choose a destination; releasing here cancels the drag." }.into())
|
||||
} else if let Prepared::Refused(reason) = &active.prepared {
|
||||
Err(reason.clone())
|
||||
} else {
|
||||
match hit {
|
||||
Hit::OutsideViewport => unreachable!(),
|
||||
Hit::Occluded => Err("Aim at exposed performer space or empty canvas; this card, port, connection or control blocks the drop.".into()),
|
||||
Hit::Ambiguous(indices) => {
|
||||
targets = indices;
|
||||
Err("Two unrelated performers overlap here. Use an exposed region.".into())
|
||||
targets = indices; Err("Two unrelated performers overlap here. Use an exposed region.".into())
|
||||
}
|
||||
Hit::Destination(destination, target) => {
|
||||
targets.extend(target);
|
||||
let semantic = if let Some((_, result)) = active.cache.iter().find(|(d, _)| *d == destination) {
|
||||
result.clone()
|
||||
} else {
|
||||
let result = plan::plan_semantics(self.project(), &press.scope, destination, &active.ids).map(Arc::new);
|
||||
active.cache.push((destination, result.clone()));
|
||||
result
|
||||
let candidate = if let Some((_, cached)) = active.cache.iter().find(|(d, _)| *d == destination) { cached.clone() }
|
||||
else {
|
||||
let candidate = match &active.prepared {
|
||||
Prepared::Singleton(scope) => plan::plan_semantics(self.project(), scope, destination, &active.ids)
|
||||
.map(|p| Candidate::Singleton(Arc::new(p))),
|
||||
Prepared::Assignment(scope) => plan::assignment::plan_semantics(scope, destination, &active.ids)
|
||||
.map(|p| Candidate::Assignment(Arc::new(p))),
|
||||
Prepared::Arrange(plan) => Ok(Candidate::Arrange(plan.clone())),
|
||||
Prepared::Performers(plan) => Ok(Candidate::Performers(plan.clone())),
|
||||
Prepared::Refused(_) => unreachable!(),
|
||||
};
|
||||
active.cache.push((destination, candidate.clone())); candidate
|
||||
};
|
||||
semantic.map_err(|r| refusal(&r)).and_then(|semantic| {
|
||||
if stamp.zoom * 16. < 9. {
|
||||
candidate.map_err(|r| refusal(&r)).and_then(|candidate| {
|
||||
if !ordinary_performers && stamp.zoom * 16. < 9. {
|
||||
return Err("Make room or adjust the view, then drag again. Zoom in to read the work and destination.".into());
|
||||
}
|
||||
let pointer = pointer.unwrap();
|
||||
let delta = pointer - press.start;
|
||||
let original = press.scope.placements[&press.scope.placement].position;
|
||||
let SelectionTarget::Placement(anchor) = &press.target else { unreachable!("supported placement") };
|
||||
let original = self.project().diagrams[&stamp.context.diagram].nodes[anchor].position;
|
||||
let delta = pointer.unwrap() - press.start;
|
||||
let requested = Position { x: original.x + delta.x / stamp.zoom, y: original.y + delta.y / stamp.zoom };
|
||||
let presentation = plan::PresentationContext {
|
||||
viewport: stamp.viewport(), zoom: stamp.zoom,
|
||||
target: target.map(|i| {
|
||||
let t = &press.frozen.targets[i];
|
||||
plan::TargetPresentation { placement: t.placement }
|
||||
}),
|
||||
};
|
||||
let placement = plan::place(&semantic, requested, presentation).map_err(|r| refusal(&r))?;
|
||||
let label = if semantic.warnings.is_empty() { semantic.label.clone() } else {
|
||||
format!("{} · {} model warning(s) retained", semantic.label, semantic.warnings.len())
|
||||
target: target.map(|i| plan::TargetPresentation { placement: active.frozen.as_ref().unwrap().targets[i].placement }),
|
||||
};
|
||||
let placed = candidate.place(requested, presentation).map_err(|r| refusal(&r))?;
|
||||
let label = candidate.label();
|
||||
if release {
|
||||
let command = plan::finish(&semantic, self.project(), &placement).map_err(|r| refusal(&r))?;
|
||||
if let Some(command) = command {
|
||||
// Bypass applying selectors and unrelated repair helpers. The
|
||||
// planner's exact Batch enters the authoritative source pipeline.
|
||||
self.document.execute(command).map_err(|e| format!("{} · retry this drag after correcting the source issue", e))?;
|
||||
if let Some(command) = candidate.finish(self.project(), &placed).map_err(|r| refusal(&r))? {
|
||||
self.document.execute(command)
|
||||
.map_err(|e| format!("{e} · retry this drag after correcting the source issue"))?;
|
||||
// sync refreshes the retained primary once; no anchor promotion.
|
||||
self.sync();
|
||||
self.selected = press.scope.work;
|
||||
self.selected_node = Some(press.scope.placement);
|
||||
self.refresh_draft();
|
||||
self.reconcile_multiselect();
|
||||
}
|
||||
} else {
|
||||
let routed = Arc::new(routing::route_diagram(semantic.project(), &placement.diagram, &placement.view));
|
||||
preview = Some(Arc::new(Preview { token, semantic, placement, routed }));
|
||||
let routed = Arc::new(routing::route_diagram(candidate.project(), placed.diagram(), placed.view()));
|
||||
preview = Some(Arc::new(Preview { token, candidate, placement: placed.into_preview(), routed }));
|
||||
}
|
||||
Ok(label)
|
||||
})
|
||||
}
|
||||
}
|
||||
};
|
||||
let refused = result.is_err();
|
||||
let text = result.unwrap_or_else(|text| text);
|
||||
@@ -654,10 +1081,13 @@ impl App {
|
||||
self.allocation_drag.retire();
|
||||
} else {
|
||||
let inspection_targets = active.inspection_targets.clone();
|
||||
let frozen = active.frozen.clone();
|
||||
self.allocation_drag.active = Some(active);
|
||||
self.allocation_drag.preview = preview;
|
||||
self.allocation_drag.feedback = Some(Feedback {
|
||||
press,
|
||||
frozen,
|
||||
ordinary_performers,
|
||||
targets,
|
||||
inspection_targets,
|
||||
text,
|
||||
|
||||
@@ -4,6 +4,9 @@ use super::*;
|
||||
use crate::behavior::canvas_workspace::tests::{Mounted, key};
|
||||
use iced::{Event as Input, keyboard};
|
||||
|
||||
#[path = "allocation_drag_tests/control_focus.rs"]
|
||||
mod control_focus;
|
||||
|
||||
fn character(text: &str, modifiers: keyboard::Modifiers) -> Input {
|
||||
Input::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: keyboard::Key::Character(text.into()),
|
||||
@@ -211,7 +214,7 @@ async fn actual_keyboard_arrange_shortcut_consumes_once_escape_cancels_and_dirty
|
||||
assert!(
|
||||
messages
|
||||
.iter()
|
||||
.any(|m| matches!(m, Message::WorkDrag(Event::Press(p)) if p.arrange))
|
||||
.any(|m| matches!(m, Message::WorkDrag(Event::Press(p)) if p.mode == Mode::Arrange))
|
||||
);
|
||||
assert!(!f.app.allocation_drag.arrange_next);
|
||||
assert_eq!(f.app.document.revision(), 1, "{}", f.app.status);
|
||||
@@ -282,7 +285,7 @@ async fn actual_rendered_canvas_pixels_do_not_change_when_toolbar_feedback_text_
|
||||
.as_ref()
|
||||
.expect("active frozen destination");
|
||||
assert!(!feedback.refused, "{}", feedback.text);
|
||||
let target = &feedback.press.frozen.targets[feedback.targets[0]];
|
||||
let target = &feedback.frozen.as_ref().unwrap().targets[feedback.targets[0]];
|
||||
assert!(target.region.y * f.app.zoom + f.app.pan.y < 0.);
|
||||
assert_eq!(feedback.destination_identity(), "A");
|
||||
let canvas = canvas_bounds(&mut mounted, &f.app);
|
||||
@@ -434,7 +437,7 @@ async fn actual_app_tree_exact_incoming_drag_keeps_destination_visible_and_canva
|
||||
selected_header.x + selected_header.width <= canvas.x + canvas.width
|
||||
&& selected_header.y + selected_header.height <= canvas.y + canvas.height
|
||||
);
|
||||
let mut expected = preview.semantic.project().clone();
|
||||
let mut expected = preview.project().clone();
|
||||
expected
|
||||
.diagrams
|
||||
.insert(f.diagram, preview.placement.diagram.clone());
|
||||
@@ -689,7 +692,7 @@ async fn actual_toolbar_hover_inspects_last_full_name_without_authorizing_an_out
|
||||
.await;
|
||||
assert!(f.app.allocation_drag.active.is_none());
|
||||
assert!(f.app.allocation_drag.feedback.is_none());
|
||||
assert_eq!(f.app.status, "Work drag canceled outside the canvas.");
|
||||
assert_eq!(f.app.status, "Drag canceled outside the canvas.");
|
||||
assert_eq!(f.app.document.bundle(), &before);
|
||||
assert!(!f.app.document.can_undo());
|
||||
// A fresh gesture cannot inherit the previous inspection name.
|
||||
|
||||
@@ -8,6 +8,9 @@ use iced::{
|
||||
#[path = "allocation_drag_full_tree_tests.rs"]
|
||||
mod full_tree;
|
||||
|
||||
#[path = "allocation_drag_tests/group.rs"]
|
||||
mod group;
|
||||
|
||||
#[path = "allocation_drag_tests/navigation_union.rs"]
|
||||
mod navigation_union;
|
||||
|
||||
@@ -48,6 +51,7 @@ fn fixture(layer: Layer) -> Fixture {
|
||||
app.zoom = 1.;
|
||||
app.pan = Vector::ZERO;
|
||||
app.fit_to_view = false;
|
||||
app.reconcile_multiselect();
|
||||
Fixture {
|
||||
app,
|
||||
a,
|
||||
@@ -59,7 +63,7 @@ fn fixture(layer: Layer) -> Fixture {
|
||||
}
|
||||
fn canvas(app: &App) -> DiagramCanvas<'_> {
|
||||
DiagramCanvas {
|
||||
selection: None,
|
||||
selection: app.diagram_selection.as_ref(),
|
||||
allocation: Some(app.allocation_capability()),
|
||||
project: app.project(),
|
||||
view_index: Some(app.document.view_index()),
|
||||
@@ -122,6 +126,18 @@ fn press(f: &Fixture) -> Arc<Press> {
|
||||
})
|
||||
.expect("actual clean work-body press")
|
||||
}
|
||||
fn admit_press(f: &mut Fixture) -> Arc<Press> {
|
||||
let p = press(f);
|
||||
let _ = f.app.update(Message::WorkDrag(Event::Press(p.clone())));
|
||||
assert!(f.app.allocation_drag.active.is_some(), "{}", f.app.status);
|
||||
p
|
||||
}
|
||||
fn singleton(candidate: &Candidate) -> &Arc<plan::SemanticPlan> {
|
||||
let Candidate::Singleton(plan) = candidate else {
|
||||
panic!("legacy singleton candidate")
|
||||
};
|
||||
plan
|
||||
}
|
||||
fn drag(f: &mut Fixture, to: Point) {
|
||||
let events = batch(&f.app, start(f), to);
|
||||
assert!(matches!(
|
||||
@@ -359,7 +375,7 @@ fn arrange_is_one_gesture_and_snapped_noop_preserves_redo_and_source() {
|
||||
assert!(f.app.allocation_drag.arrange_next);
|
||||
let p = press(&f);
|
||||
let _ = f.app.update(Message::WorkDrag(Event::Press(p.clone())));
|
||||
assert!(p.arrange);
|
||||
assert_eq!(p.mode, Mode::Arrange);
|
||||
assert!(!f.app.allocation_drag.arrange_next);
|
||||
let _ = f.app.update(Message::WorkDrag(Event::Release(
|
||||
p.stamp,
|
||||
@@ -370,7 +386,7 @@ fn arrange_is_one_gesture_and_snapped_noop_preserves_redo_and_source() {
|
||||
assert_eq!(f.app.document.revision(), revision);
|
||||
assert!(f.app.document.can_redo());
|
||||
assert!(f.app.project().relations.is_empty());
|
||||
assert!(!press(&f).arrange);
|
||||
assert_eq!(press(&f).mode, Mode::Assign);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -397,14 +413,15 @@ fn candidate_semantics_are_cached_per_destination_with_stable_ids_and_own_index(
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::WorkDrag(Event::Preview(p.stamp, p.token, Some(a))));
|
||||
let first = f
|
||||
.app
|
||||
.allocation_drag
|
||||
.preview
|
||||
.as_ref()
|
||||
.expect(&f.app.status)
|
||||
.semantic
|
||||
.clone();
|
||||
let first = singleton(
|
||||
&f.app
|
||||
.allocation_drag
|
||||
.preview
|
||||
.as_ref()
|
||||
.expect(&f.app.status)
|
||||
.candidate,
|
||||
)
|
||||
.clone();
|
||||
let stable = first
|
||||
.project()
|
||||
.relations
|
||||
@@ -419,20 +436,20 @@ fn candidate_semantics_are_cached_per_destination_with_stable_ids_and_own_index(
|
||||
)));
|
||||
assert!(Arc::ptr_eq(
|
||||
&first,
|
||||
&f.app.allocation_drag.preview.as_ref().unwrap().semantic
|
||||
singleton(&f.app.allocation_drag.preview.as_ref().unwrap().candidate)
|
||||
));
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::WorkDrag(Event::Preview(p.stamp, p.token, Some(b))));
|
||||
let preview = f.app.allocation_drag.preview.as_ref().expect(&f.app.status);
|
||||
assert!(!Arc::ptr_eq(&first, &preview.semantic));
|
||||
assert_eq!(preview.semantic.project().relations[&stable].source(), f.b);
|
||||
assert!(!Arc::ptr_eq(&first, singleton(&preview.candidate)));
|
||||
assert_eq!(preview.project().relations[&stable].source(), f.b);
|
||||
assert_eq!(
|
||||
preview.placement.view,
|
||||
projection::project_diagram_with_index(
|
||||
preview.semantic.project(),
|
||||
preview.project(),
|
||||
&preview.placement.diagram,
|
||||
preview.semantic.index()
|
||||
preview.index()
|
||||
)
|
||||
);
|
||||
assert_ne!(
|
||||
@@ -448,7 +465,7 @@ fn candidate_semantics_are_cached_per_destination_with_stable_ids_and_own_index(
|
||||
.update(Message::WorkDrag(Event::Preview(p.stamp, p.token, Some(a))));
|
||||
assert!(Arc::ptr_eq(
|
||||
&first,
|
||||
&f.app.allocation_drag.preview.as_ref().unwrap().semantic
|
||||
singleton(&f.app.allocation_drag.preview.as_ref().unwrap().candidate)
|
||||
));
|
||||
assert_eq!(
|
||||
f.app.allocation_drag.active.as_ref().unwrap().cache.len(),
|
||||
@@ -458,9 +475,19 @@ fn candidate_semantics_are_cached_per_destination_with_stable_ids_and_own_index(
|
||||
|
||||
#[test]
|
||||
fn frozen_targets_use_half_open_semantic_priority_and_refuse_incomparable_overlap() {
|
||||
let f = fixture(Layer::Oa);
|
||||
let p = press(&f);
|
||||
let mut frozen = p.frozen.clone();
|
||||
let mut f = fixture(Layer::Oa);
|
||||
let p = admit_press(&mut f);
|
||||
let mut frozen = f
|
||||
.app
|
||||
.allocation_drag
|
||||
.active
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.frozen
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.as_ref()
|
||||
.clone();
|
||||
let ai = frozen
|
||||
.targets
|
||||
.iter()
|
||||
@@ -540,6 +567,7 @@ fn release_uses_ordered_native_position_before_later_motion_in_same_batch() {
|
||||
fn replace(f: &mut Fixture, project: Project) {
|
||||
f.app.document = crate::document::Document::from_project(project, Default::default()).unwrap();
|
||||
f.app.sync();
|
||||
f.app.reconcile_multiselect();
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -588,7 +616,7 @@ fn same_snapped_position_on_opposite_shared_border_rebuilds_semantics_even_if_la
|
||||
.1
|
||||
.as_ref()
|
||||
.unwrap();
|
||||
assert!(!Arc::ptr_eq(a, b));
|
||||
assert!(!Arc::ptr_eq(singleton(a), singleton(b)));
|
||||
let stable = a
|
||||
.project()
|
||||
.relations
|
||||
@@ -655,13 +683,24 @@ fn mounted_role_only_outgoing_refusal_retains_placement_and_same_performer_creat
|
||||
"{}",
|
||||
f.app.status
|
||||
);
|
||||
let target = press(&f)
|
||||
let admitted = admit_press(&mut f);
|
||||
let target = f
|
||||
.app
|
||||
.allocation_drag
|
||||
.active
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.frozen
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.targets
|
||||
.iter()
|
||||
.find(|t| t.element == f.a)
|
||||
.unwrap()
|
||||
.region;
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::WorkDrag(Event::Cancel(admitted.token)));
|
||||
drag(&mut f, screen(xy(target.x + 45., target.y + 100.)));
|
||||
assert_eq!(f.app.document.revision(), 1, "{}", f.app.status);
|
||||
assert_eq!(f.app.project().relations, relations);
|
||||
@@ -774,8 +813,18 @@ fn frozen_collapsed_target_uses_only_visible_header_and_port_edge_controls_occlu
|
||||
.unwrap()
|
||||
.collapsed = true;
|
||||
replace(&mut f, project);
|
||||
let p = press(&f);
|
||||
let mut frozen = p.frozen.clone();
|
||||
let p = admit_press(&mut f);
|
||||
let mut frozen = f
|
||||
.app
|
||||
.allocation_drag
|
||||
.active
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.frozen
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.as_ref()
|
||||
.clone();
|
||||
let t = frozen.targets.iter().find(|t| t.element == f.a).unwrap();
|
||||
let bottom = xy(t.region.x + 30., t.region.y + t.region.height);
|
||||
assert!(t.region.height <= 62.);
|
||||
@@ -945,7 +994,7 @@ fn toolbar_destination_names_transformed_target_whose_header_is_clipped() {
|
||||
send(&mut f.app, events);
|
||||
let feedback = f.app.allocation_drag.feedback.as_ref().unwrap();
|
||||
assert!(!feedback.refused, "{}", feedback.text);
|
||||
let target = &feedback.press.frozen.targets[feedback.targets[0]];
|
||||
let target = &feedback.frozen.as_ref().unwrap().targets[feedback.targets[0]];
|
||||
assert!(screen(xy(target.region.x, target.region.y)).y < bounds().y);
|
||||
assert_eq!(feedback.destination_identity(), "A");
|
||||
assert!(f.app.allocation_drag.preview.is_some());
|
||||
@@ -1004,7 +1053,7 @@ fn oa_sa_clipped_selected_header_refuses_preview_release_and_retries_without_par
|
||||
assert!(feedback.refused, "{}", feedback.text);
|
||||
assert!(feedback.text.contains("Make room or adjust the view"));
|
||||
assert_eq!(
|
||||
feedback.press.frozen.targets[feedback.targets[0]].element,
|
||||
feedback.frozen.as_ref().unwrap().targets[feedback.targets[0]].element,
|
||||
f.a
|
||||
);
|
||||
assert_eq!(f.app.project(), &accepted);
|
||||
@@ -1029,14 +1078,14 @@ fn oa_sa_clipped_selected_header_refuses_preview_release_and_retries_without_par
|
||||
send(&mut f.app, events);
|
||||
let active = f.app.allocation_drag.active.as_ref().unwrap();
|
||||
let p = active.press.clone();
|
||||
let semantic = active.cache[0].1.as_ref().unwrap().clone();
|
||||
let semantic = singleton(active.cache[0].1.as_ref().unwrap()).clone();
|
||||
let _ = f.app.update(Message::WorkDrag(Event::Preview(
|
||||
p.stamp,
|
||||
p.token,
|
||||
Some(clear),
|
||||
)));
|
||||
let preview = f.app.allocation_drag.preview.as_ref().expect(&f.app.status);
|
||||
assert!(Arc::ptr_eq(&semantic, &preview.semantic));
|
||||
assert!(Arc::ptr_eq(&semantic, singleton(&preview.candidate)));
|
||||
assert_eq!(
|
||||
preview.placement.diagram.nodes[&f.node].position,
|
||||
xy(120., 160.)
|
||||
@@ -1132,7 +1181,7 @@ fn oa_sa_toolbar_identity_accepts_exact_receiver_and_collapsed_header_drag() {
|
||||
assert!(!feedback.refused, "{}", feedback.text);
|
||||
assert_eq!(feedback.destination_identity(), name);
|
||||
assert_eq!(
|
||||
feedback.press.frozen.targets[feedback.targets[0]].placement,
|
||||
feedback.frozen.as_ref().unwrap().targets[feedback.targets[0]].placement,
|
||||
target_id
|
||||
);
|
||||
let preview = f.app.allocation_drag.preview.as_ref().expect(&f.app.status);
|
||||
@@ -1144,12 +1193,12 @@ fn oa_sa_toolbar_identity_accepts_exact_receiver_and_collapsed_header_drag() {
|
||||
assert_eq!(
|
||||
preview.placement.view,
|
||||
projection::project_diagram_with_index(
|
||||
preview.semantic.project(),
|
||||
preview.project(),
|
||||
&preview.placement.diagram,
|
||||
preview.semantic.index()
|
||||
preview.index()
|
||||
)
|
||||
);
|
||||
let mut expected = preview.semantic.project().clone();
|
||||
let mut expected = preview.project().clone();
|
||||
expected
|
||||
.diagrams
|
||||
.insert(f.diagram, preview.placement.diagram.clone());
|
||||
@@ -1184,6 +1233,8 @@ fn destination_identity_does_not_describe_blocked_or_outside_viewport_as_unalloc
|
||||
let p = press(&f);
|
||||
let mut feedback = Feedback {
|
||||
press: p,
|
||||
frozen: None,
|
||||
ordinary_performers: false,
|
||||
targets: vec![],
|
||||
inspection_targets: vec![],
|
||||
text: "A card blocks this drop".into(),
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn dirty_selected_pin_outer_ring_keeps_real_name_focus_and_caret() {
|
||||
let mut f = fixture(Layer::Oa);
|
||||
let mut p = f.app.project().clone();
|
||||
let peer = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "Peer", None)
|
||||
.unwrap();
|
||||
p.place(f.diagram, peer, xy(700., 500.));
|
||||
let edge = p.relate(RelationKind::Interaction, "Flow", f.work, peer);
|
||||
let pin = xy(450., 547.);
|
||||
p.diagrams
|
||||
.get_mut(&f.diagram)
|
||||
.unwrap()
|
||||
.routes
|
||||
.push(EdgeRoute {
|
||||
edge,
|
||||
source_attachment: Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Right,
|
||||
offset: 0.5,
|
||||
}),
|
||||
target_attachment: Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Left,
|
||||
offset: 0.5,
|
||||
}),
|
||||
waypoints: vec![pin],
|
||||
label: Some(EdgeLabel {
|
||||
fraction: 0.8,
|
||||
offset: xy(0., -60.),
|
||||
}),
|
||||
});
|
||||
validate_candidate(&p).unwrap();
|
||||
let view = projection::project_view(&p, f.diagram);
|
||||
let routed = routing::route_diagram(&p, &p.diagrams[&f.diagram], &view);
|
||||
assert_eq!(routed.edges[&edge].pins, vec![pin]);
|
||||
assert!(!routed.edges[&edge].custom_suspended);
|
||||
assert!(
|
||||
routed.edges[&edge]
|
||||
.points
|
||||
.iter()
|
||||
.all(|point| point.y == 547.)
|
||||
);
|
||||
f.app = App::with_project(p);
|
||||
f.app.active_diagram = Some(f.diagram);
|
||||
f.app.window_size = iced::Size::new(2000., 1400.);
|
||||
let _ = f.app.update(Message::Select(edge));
|
||||
f.app.reconcile_multiselect();
|
||||
let mut mounted = Mounted::new(&mut f.app).await;
|
||||
f.app.zoom = 1.;
|
||||
f.app.pan = Vector::ZERO;
|
||||
f.app.fit_to_view = false;
|
||||
mounted.settle(&mut f.app).await;
|
||||
let field = name_field(&mut mounted, &f.app);
|
||||
mounted.click(&mut f.app, field.center()).await;
|
||||
mounted
|
||||
.batch(
|
||||
&mut f.app,
|
||||
&[key(keyboard::key::Named::Home)],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
type_key(&mut mounted, &mut f.app, "X").await;
|
||||
mounted
|
||||
.batch(
|
||||
&mut f.app,
|
||||
&[key(keyboard::key::Named::ArrowLeft)],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(f.app.draft.as_ref().unwrap().inputs["/name"].raw, "XFlow");
|
||||
assert!(f.app.draft_dirty());
|
||||
let accepted = f.app.document.snapshot();
|
||||
let source = f.app.document.bundle().clone();
|
||||
let selection = f.app.multiselect_snapshot().unwrap();
|
||||
let pan = f.app.pan;
|
||||
// Exposed straight-run bend: outside the 7px line hit, within the 8px handle.
|
||||
let point = at(&mut mounted, &f, xy(pin.x, pin.y + 7.5));
|
||||
let messages = mounted.click(&mut f.app, point).await;
|
||||
assert!(
|
||||
messages
|
||||
.iter()
|
||||
.any(|m| matches!(m, Message::WorkDrag(Event::Refused(..))))
|
||||
);
|
||||
assert!(!messages.iter().any(|m| matches!(
|
||||
m,
|
||||
Message::Pan(_)
|
||||
| Message::Select(_)
|
||||
| Message::SelectNode(_)
|
||||
| Message::MoveNode(..)
|
||||
| Message::Route(_)
|
||||
| Message::DiagramSelection(_)
|
||||
)));
|
||||
assert!(Arc::ptr_eq(&accepted, &f.app.document.snapshot()));
|
||||
assert_eq!(f.app.document.bundle(), &source);
|
||||
assert_eq!(f.app.multiselect_snapshot().unwrap(), selection);
|
||||
assert_eq!(f.app.pan, pan);
|
||||
assert!(
|
||||
is_focused(&mut mounted, &f.app, field),
|
||||
"dirty handle press must stop before outside-press unfocus"
|
||||
);
|
||||
let messages = type_key(&mut mounted, &mut f.app, "Y").await;
|
||||
assert!(
|
||||
messages
|
||||
.iter()
|
||||
.any(|m| matches!(m, Message::Field(path, _) if path == "/name"))
|
||||
);
|
||||
assert_eq!(f.app.draft.as_ref().unwrap().inputs["/name"].raw, "YXFlow");
|
||||
assert_eq!(f.app.document.bundle(), &source);
|
||||
assert!(!f.app.document.can_undo());
|
||||
}
|
||||
@@ -0,0 +1,739 @@
|
||||
use super::*;
|
||||
use crate::app::multiselect::{self, Choice};
|
||||
use crate::diagram_usage::Target as SelectionTarget;
|
||||
|
||||
struct Group {
|
||||
f: Fixture,
|
||||
y: Id,
|
||||
yn: Id,
|
||||
c: Id,
|
||||
cn: Id,
|
||||
an: Id,
|
||||
ax: Id,
|
||||
by: Id,
|
||||
}
|
||||
|
||||
fn group(layer: Layer) -> Group {
|
||||
let mut f = fixture(layer);
|
||||
let mut p = f.app.project().clone();
|
||||
let y = p
|
||||
.add_element(p.elements[&f.work].kind(), layer, "Y", None)
|
||||
.unwrap();
|
||||
let c = p
|
||||
.add_element(
|
||||
if layer == Layer::Oa {
|
||||
Kind::Actor
|
||||
} else {
|
||||
Kind::SystemActor
|
||||
},
|
||||
layer,
|
||||
"C",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let yn = p.place(f.diagram, y, xy(140., 600.));
|
||||
let cn = p.place(f.diagram, c, xy(1500., 100.));
|
||||
let d = p.diagrams.get_mut(&f.diagram).unwrap();
|
||||
d.nodes.get_mut(&f.node).unwrap().position = xy(140., 200.);
|
||||
d.nodes
|
||||
.values_mut()
|
||||
.find(|n| n.element == f.b)
|
||||
.unwrap()
|
||||
.position = xy(100., 500.);
|
||||
d.nodes.get_mut(&cn).unwrap().size = Size {
|
||||
width: 600.,
|
||||
height: 900.,
|
||||
};
|
||||
let an = d.nodes.values().find(|n| n.element == f.a).unwrap().id;
|
||||
let ax = p.relate(
|
||||
RelationKind::Allocation,
|
||||
"Reviewed X responsibility",
|
||||
f.a,
|
||||
f.work,
|
||||
);
|
||||
let by = p.relate(
|
||||
RelationKind::Allocation,
|
||||
"Reviewed Y responsibility",
|
||||
f.b,
|
||||
y,
|
||||
);
|
||||
p.relations.get_mut(&ax).unwrap().description = "Keep X provenance".into();
|
||||
p.relations.get_mut(&by).unwrap().description = "Keep Y provenance".into();
|
||||
validate_candidate(&p).unwrap();
|
||||
f.app = App::with_project(p);
|
||||
f.app.active_diagram = Some(f.diagram);
|
||||
f.app.zoom = 1.;
|
||||
f.app.pan = Vector::ZERO;
|
||||
f.app.fit_to_view = false;
|
||||
f.app.reconcile_multiselect();
|
||||
Group {
|
||||
f,
|
||||
y,
|
||||
yn,
|
||||
c,
|
||||
cn,
|
||||
an,
|
||||
ax,
|
||||
by,
|
||||
}
|
||||
}
|
||||
|
||||
fn choose(app: &mut App, target: SelectionTarget, choice: Choice) {
|
||||
let context = multiselect::context(app.route_context().unwrap());
|
||||
let _ = app.update(Message::DiagramSelection(multiselect::Event::Choose(
|
||||
context, target, choice,
|
||||
)));
|
||||
}
|
||||
|
||||
fn select(app: &mut App, ids: &[Id]) {
|
||||
for (i, id) in ids.iter().enumerate() {
|
||||
choose(
|
||||
app,
|
||||
SelectionTarget::Placement(*id),
|
||||
if i == 0 { Choice::Replace } else { Choice::Add },
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
app.diagram_selection.as_ref().unwrap().targets(),
|
||||
&ids.iter()
|
||||
.copied()
|
||||
.map(SelectionTarget::Placement)
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
fn selection(
|
||||
app: &App,
|
||||
) -> (
|
||||
Vec<SelectionTarget>,
|
||||
Option<SelectionTarget>,
|
||||
Id,
|
||||
Option<Id>,
|
||||
) {
|
||||
let s = app.diagram_selection.as_ref().unwrap();
|
||||
(
|
||||
s.targets().to_vec(),
|
||||
s.primary().cloned(),
|
||||
app.selected,
|
||||
app.selected_node,
|
||||
)
|
||||
}
|
||||
|
||||
fn node_point(app: &App, node: Id) -> Point {
|
||||
let view = projection::project_view(app.project(), app.active_diagram.unwrap());
|
||||
let n = view.nodes.iter().find(|n| n.id == node).unwrap();
|
||||
screen(xy(n.position.x + 40., n.position.y + 35.))
|
||||
}
|
||||
|
||||
fn input(app: &mut App, state: &mut CanvasState, event: mouse::Event, at: Point) {
|
||||
if let Some(message) = pointer(&canvas(app), state, event, at) {
|
||||
let _ = app.update(message);
|
||||
}
|
||||
}
|
||||
|
||||
fn begin(app: &mut App, state: &mut CanvasState, from: Point) -> Arc<Press> {
|
||||
input(
|
||||
app,
|
||||
state,
|
||||
mouse::Event::CursorMoved { position: from },
|
||||
from,
|
||||
);
|
||||
let message = pointer(
|
||||
&canvas(app),
|
||||
state,
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
from,
|
||||
)
|
||||
.unwrap();
|
||||
let Message::WorkDrag(Event::Press(packet)) = &message else {
|
||||
panic!("Allocation placement must enter its captured lifetime: {message:?}");
|
||||
};
|
||||
let packet = packet.clone();
|
||||
let _ = app.update(message);
|
||||
packet
|
||||
}
|
||||
|
||||
fn move_pointer(app: &mut App, state: &mut CanvasState, to: Point) {
|
||||
input(app, state, mouse::Event::CursorMoved { position: to }, to);
|
||||
}
|
||||
|
||||
fn release(app: &mut App, state: &mut CanvasState, to: Point) {
|
||||
input(
|
||||
app,
|
||||
state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
to,
|
||||
);
|
||||
}
|
||||
|
||||
fn exact_history(
|
||||
app: &mut App,
|
||||
before: &Project,
|
||||
source: &oa_core::source_project::SourceBundle,
|
||||
expected: &Project,
|
||||
) {
|
||||
assert_eq!(app.project(), expected);
|
||||
let after = app.document.bundle().clone();
|
||||
let reopened =
|
||||
crate::document::Document::from_bundle(after.clone(), Default::default()).unwrap();
|
||||
assert_eq!(reopened.project(), expected);
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.project(), before);
|
||||
assert_eq!(app.document.bundle(), source);
|
||||
assert!(
|
||||
!app.document.can_undo(),
|
||||
"one group drag is one history step"
|
||||
);
|
||||
let _ = app.update(Message::Redo);
|
||||
assert_eq!(app.project(), expected);
|
||||
assert_eq!(app.document.bundle(), &after);
|
||||
assert!(!app.document.can_redo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_assignment_split_rebuild_preserves_primary_and_commits_one_complete_candidate() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
for anchor_is_primary in [false, true] {
|
||||
let mut g = group(layer);
|
||||
select(&mut g.f.app, &[g.f.node, g.yn]);
|
||||
let held = selection(&g.f.app);
|
||||
let before = g.f.app.project().clone();
|
||||
let source = g.f.app.document.bundle().clone();
|
||||
let anchor = if anchor_is_primary { g.yn } else { g.f.node };
|
||||
let from = node_point(&g.f.app, anchor);
|
||||
let to = from + Vector::new(1400., 0.);
|
||||
let mut state = CanvasState::default();
|
||||
let packet = begin(&mut g.f.app, &mut state, from);
|
||||
assert_eq!(packet.target, SelectionTarget::Placement(anchor));
|
||||
assert_eq!(
|
||||
selection(&g.f.app),
|
||||
held,
|
||||
"member press must not inspect/collapse"
|
||||
);
|
||||
assert_eq!(g.f.app.document.bundle(), &source);
|
||||
move_pointer(&mut g.f.app, &mut state, to);
|
||||
let preview =
|
||||
g.f.app
|
||||
.allocation_drag
|
||||
.preview
|
||||
.as_ref()
|
||||
.expect(&g.f.app.status);
|
||||
let Candidate::Assignment(plan) = &preview.candidate else {
|
||||
panic!("group must use combined assignment");
|
||||
};
|
||||
assert_eq!(plan.changed_count(), 2);
|
||||
assert_eq!(plan.already_correct_count(), 0);
|
||||
assert_eq!(selection(&g.f.app), held);
|
||||
assert_eq!(g.f.app.project(), &before, "preview is immutable");
|
||||
release(&mut g.f.app, &mut state, to);
|
||||
let mut expected = before.clone();
|
||||
for (relation, work) in [(g.ax, g.f.work), (g.by, g.y)] {
|
||||
expected.relations.get_mut(&relation).unwrap().body = RelationBody::Allocation {
|
||||
source: g.c,
|
||||
target: work,
|
||||
};
|
||||
}
|
||||
let d = expected.diagrams.get_mut(&g.f.diagram).unwrap();
|
||||
d.nodes.get_mut(&g.f.node).unwrap().position = xy(1540., 200.);
|
||||
d.nodes.get_mut(&g.yn).unwrap().position = xy(1540., 600.);
|
||||
assert_eq!(selection(&g.f.app), held);
|
||||
exact_history(&mut g.f.app, &before, &source, &expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_arrange_work_and_performer_anchors_preserve_every_responsibility() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
for performer_anchor in [false, true] {
|
||||
let mut g = group(layer);
|
||||
select(&mut g.f.app, &[g.an, g.yn]);
|
||||
let held = selection(&g.f.app);
|
||||
let before = g.f.app.project().clone();
|
||||
let source = g.f.app.document.bundle().clone();
|
||||
let _ = g.f.app.update(Message::WorkDrag(Event::ArrangeNext));
|
||||
assert!(g.f.app.allocation_drag.arrange_next);
|
||||
let from = node_point(&g.f.app, if performer_anchor { g.an } else { g.yn });
|
||||
let to = from + Vector::new(400., 0.);
|
||||
let mut state = CanvasState::default();
|
||||
let packet = begin(&mut g.f.app, &mut state, from);
|
||||
assert_eq!(packet.mode, Mode::Arrange);
|
||||
assert!(!g.f.app.allocation_drag.arrange_next);
|
||||
assert_eq!(selection(&g.f.app), held);
|
||||
move_pointer(&mut g.f.app, &mut state, to);
|
||||
assert!(
|
||||
matches!(
|
||||
g.f.app
|
||||
.allocation_drag
|
||||
.preview
|
||||
.as_ref()
|
||||
.map(|p| &p.candidate),
|
||||
Some(Candidate::Arrange(_))
|
||||
),
|
||||
"{}",
|
||||
g.f.app.status
|
||||
);
|
||||
release(&mut g.f.app, &mut state, to);
|
||||
let mut expected = before.clone();
|
||||
let d = expected.diagrams.get_mut(&g.f.diagram).unwrap();
|
||||
d.nodes.get_mut(&g.an).unwrap().position = xy(500., 100.);
|
||||
d.nodes.get_mut(&g.f.node).unwrap().position = xy(540., 200.);
|
||||
d.nodes.get_mut(&g.yn).unwrap().position = xy(540., 600.);
|
||||
assert_eq!(selection(&g.f.app), held);
|
||||
exact_history(&mut g.f.app, &before, &source, &expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_group_click_collapses_but_threshold_crossing_never_becomes_click() {
|
||||
for performer_anchor in [false, true] {
|
||||
for distance in [0., 3., 3.25] {
|
||||
let mut g = group(Layer::Oa);
|
||||
select(&mut g.f.app, &[g.an, g.f.node]);
|
||||
let held = selection(&g.f.app);
|
||||
let before = g.f.app.document.snapshot();
|
||||
let anchor = if performer_anchor { g.an } else { g.f.node };
|
||||
let from = node_point(&g.f.app, anchor);
|
||||
let mut state = CanvasState::default();
|
||||
begin(&mut g.f.app, &mut state, from);
|
||||
assert_eq!(selection(&g.f.app), held);
|
||||
move_pointer(&mut g.f.app, &mut state, from + Vector::new(distance, 0.));
|
||||
if distance > 3. {
|
||||
assert!(
|
||||
g.f.app
|
||||
.allocation_drag
|
||||
.feedback
|
||||
.as_ref()
|
||||
.is_some_and(|f| f.refused)
|
||||
);
|
||||
assert!(g.f.app.allocation_drag.preview.is_none());
|
||||
}
|
||||
move_pointer(&mut g.f.app, &mut state, from);
|
||||
release(&mut g.f.app, &mut state, from);
|
||||
assert!(g.f.app.allocation_drag.active.is_none());
|
||||
assert!(Arc::ptr_eq(&before, &g.f.app.document.snapshot()));
|
||||
if distance > 3. {
|
||||
assert_eq!(
|
||||
selection(&g.f.app),
|
||||
held,
|
||||
"crossed-and-returned refusal must retain group"
|
||||
);
|
||||
} else {
|
||||
let s = g.f.app.diagram_selection.as_ref().unwrap();
|
||||
assert_eq!(s.targets(), &[SelectionTarget::Placement(anchor)]);
|
||||
assert_eq!(s.primary(), Some(&SelectionTarget::Placement(anchor)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allocation_bare_moves_cannot_bypass_armed_active_or_retired_lifetime() {
|
||||
for (stage, singleton) in (0..3).flat_map(|stage| [(stage, false), (stage, true)]) {
|
||||
let mut g = group(Layer::Oa);
|
||||
let ids = if singleton {
|
||||
vec![g.an]
|
||||
} else {
|
||||
vec![g.an, g.cn]
|
||||
};
|
||||
select(&mut g.f.app, &ids);
|
||||
let snapshot = g.f.app.multiselect_snapshot().unwrap();
|
||||
let held = selection(&g.f.app);
|
||||
let before = g.f.app.document.snapshot();
|
||||
let _ = g.f.app.update(Message::WorkDrag(Event::ArrangeNext));
|
||||
let mut state = CanvasState::default();
|
||||
let from = node_point(&g.f.app, g.an);
|
||||
if stage > 0 {
|
||||
let press = begin(&mut g.f.app, &mut state, from);
|
||||
if stage == 2 {
|
||||
let _ =
|
||||
g.f.app
|
||||
.update(Message::WorkDrag(Event::Cancel(press.token)));
|
||||
}
|
||||
}
|
||||
let epoch = g.f.app.allocation_drag.epoch;
|
||||
let armed = g.f.app.allocation_drag.arrange_next;
|
||||
let token =
|
||||
g.f.app
|
||||
.allocation_drag
|
||||
.active
|
||||
.as_ref()
|
||||
.map(|a| a.press.token);
|
||||
let _ = g.f.app.update(Message::MoveNode(g.an, xy(500., 100.)));
|
||||
let _ =
|
||||
g.f.app
|
||||
.update(Message::DiagramSelection(multiselect::Event::Move(
|
||||
snapshot,
|
||||
xy(400., 0.),
|
||||
)));
|
||||
assert!(Arc::ptr_eq(&before, &g.f.app.document.snapshot()));
|
||||
assert_eq!(selection(&g.f.app), held);
|
||||
assert_eq!(g.f.app.allocation_drag.epoch, epoch);
|
||||
assert_eq!(g.f.app.allocation_drag.arrange_next, armed);
|
||||
assert_eq!(
|
||||
g.f.app
|
||||
.allocation_drag
|
||||
.active
|
||||
.as_ref()
|
||||
.map(|a| a.press.token),
|
||||
token
|
||||
);
|
||||
}
|
||||
// A fresh ordinary performer gesture still uses the existing layout policy.
|
||||
// C's right edge leaves the viewport: explicit Arrange's header gate must not
|
||||
// accidentally become a new restriction on ordinary performer movement.
|
||||
let mut g = group(Layer::Oa);
|
||||
select(&mut g.f.app, &[g.an, g.cn]);
|
||||
let held = selection(&g.f.app);
|
||||
let before = g.f.app.project().clone();
|
||||
let source = g.f.app.document.bundle().clone();
|
||||
let _ = g.f.app.update(Message::WorkDrag(Event::ArrangeNext));
|
||||
let from = node_point(&g.f.app, g.an);
|
||||
let mut state = CanvasState::default();
|
||||
let old = begin(&mut g.f.app, &mut state, from);
|
||||
let _ = g.f.app.update(Message::WorkDrag(Event::Cancel(old.token)));
|
||||
let mut state = CanvasState::default();
|
||||
let fresh = begin(&mut g.f.app, &mut state, from);
|
||||
assert_eq!(fresh.mode, Mode::Assign);
|
||||
let to = from + Vector::new(300., 0.);
|
||||
move_pointer(&mut g.f.app, &mut state, to);
|
||||
assert!(
|
||||
matches!(
|
||||
g.f.app
|
||||
.allocation_drag
|
||||
.preview
|
||||
.as_ref()
|
||||
.map(|p| &p.candidate),
|
||||
Some(Candidate::Performers(_))
|
||||
),
|
||||
"{}",
|
||||
g.f.app.status
|
||||
);
|
||||
release(&mut g.f.app, &mut state, to);
|
||||
let mut expected = before.clone();
|
||||
let d = expected.diagrams.get_mut(&g.f.diagram).unwrap();
|
||||
d.nodes.get_mut(&g.an).unwrap().position = xy(400., 100.);
|
||||
d.nodes.get_mut(&g.f.node).unwrap().position = xy(440., 200.);
|
||||
d.nodes.get_mut(&g.cn).unwrap().position = xy(1800., 100.);
|
||||
assert_eq!(selection(&g.f.app), held);
|
||||
exact_history(&mut g.f.app, &before, &source, &expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_group_press_rejects_selection_generation_aba_with_unchanged_other_stamps() {
|
||||
for membership_aba in [false, true] {
|
||||
let mut g = group(Layer::Oa);
|
||||
select(&mut g.f.app, &[g.f.node, g.yn]);
|
||||
let old = press(&g.f);
|
||||
let before = g.f.app.document.snapshot();
|
||||
let held = selection(&g.f.app);
|
||||
let epoch = g.f.app.allocation_drag.epoch;
|
||||
// Deliberately isolate selection generation from App message retirement.
|
||||
// Final selection/primary/context/Document values are unchanged; this
|
||||
// exercises the snapshot boundary, not an ordinary user-input sequence.
|
||||
let s = g.f.app.diagram_selection.as_mut().unwrap();
|
||||
if membership_aba {
|
||||
assert!(s.remove(&SelectionTarget::Placement(g.yn)));
|
||||
assert!(s.add(SelectionTarget::Placement(g.yn), |_| true));
|
||||
} else {
|
||||
assert!(s.set_primary(&SelectionTarget::Placement(g.f.node)));
|
||||
assert!(s.set_primary(&SelectionTarget::Placement(g.yn)));
|
||||
}
|
||||
assert_eq!(selection(&g.f.app), held);
|
||||
assert_eq!(g.f.app.allocation_drag.epoch, epoch);
|
||||
assert_eq!(g.f.app.route_context(), Some(old.stamp.context));
|
||||
let _ = g.f.app.update(Message::WorkDrag(Event::Press(old.clone())));
|
||||
assert!(
|
||||
g.f.app.allocation_drag.active.is_none(),
|
||||
"generation-only stale selection must not acquire input"
|
||||
);
|
||||
let _ = g.f.app.update(Message::WorkDrag(Event::Release(
|
||||
old.stamp,
|
||||
old.token,
|
||||
Some(start(&g.f)),
|
||||
)));
|
||||
assert!(Arc::ptr_eq(&before, &g.f.app.document.snapshot()));
|
||||
assert_eq!(selection(&g.f.app), held);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_selection_aba_and_old_press_cannot_retire_a_new_token() {
|
||||
let mut g = group(Layer::Oa);
|
||||
select(&mut g.f.app, &[g.f.node, g.yn]);
|
||||
let old = press(&g.f);
|
||||
choose(
|
||||
&mut g.f.app,
|
||||
SelectionTarget::Placement(g.f.node),
|
||||
Choice::Inspect,
|
||||
);
|
||||
choose(
|
||||
&mut g.f.app,
|
||||
SelectionTarget::Placement(g.yn),
|
||||
Choice::Inspect,
|
||||
);
|
||||
let before = g.f.app.document.snapshot();
|
||||
let from = node_point(&g.f.app, g.f.node);
|
||||
let mut state = CanvasState::default();
|
||||
let fresh = begin(&mut g.f.app, &mut state, from);
|
||||
let held = selection(&g.f.app);
|
||||
let epoch = g.f.app.allocation_drag.epoch;
|
||||
let _ = g.f.app.update(Message::WorkDrag(Event::Press(old.clone())));
|
||||
let _ = g.f.app.update(Message::WorkDrag(Event::Release(
|
||||
old.stamp,
|
||||
old.token,
|
||||
Some(from + Vector::new(1400., 0.)),
|
||||
)));
|
||||
assert_eq!(
|
||||
g.f.app
|
||||
.allocation_drag
|
||||
.active
|
||||
.as_ref()
|
||||
.map(|a| a.press.token),
|
||||
Some(fresh.token)
|
||||
);
|
||||
assert_eq!(g.f.app.allocation_drag.epoch, epoch);
|
||||
assert_eq!(selection(&g.f.app), held);
|
||||
assert!(Arc::ptr_eq(&before, &g.f.app.document.snapshot()));
|
||||
release(&mut g.f.app, &mut state, from);
|
||||
}
|
||||
|
||||
fn unassign(g: &mut Group, inside_c: bool) {
|
||||
let mut project = g.f.app.project().clone();
|
||||
project.relations.remove(&g.ax);
|
||||
project.relations.remove(&g.by);
|
||||
if inside_c {
|
||||
let d = project.diagrams.get_mut(&g.f.diagram).unwrap();
|
||||
// These unassigned cards overlap an unrelated empty performer at the
|
||||
// same depth. Paint C first so the intended work press is visible and
|
||||
// independent of random UUID ordering; no semantic containment exists.
|
||||
let mut target = d.nodes.remove(&g.cn).unwrap();
|
||||
g.cn = Id::from_u128(1);
|
||||
target.id = g.cn;
|
||||
assert!(d.nodes.insert(g.cn, target).is_none());
|
||||
d.nodes.get_mut(&g.f.node).unwrap().position = xy(1540., 200.);
|
||||
d.nodes.get_mut(&g.yn).unwrap().position = xy(1540., 600.);
|
||||
}
|
||||
g.f.app = App::with_project(project);
|
||||
g.f.app.active_diagram = Some(g.f.diagram);
|
||||
g.f.app.zoom = 1.;
|
||||
g.f.app.pan = Vector::ZERO;
|
||||
g.f.app.fit_to_view = false;
|
||||
g.f.app.reconcile_multiselect();
|
||||
}
|
||||
|
||||
fn c_allocations(expected: &mut Project, g: &Group, ids: &BTreeMap<Id, Id>) {
|
||||
for (work, name, alias) in [
|
||||
(g.f.work, "C performs Work", "c_performs_work"),
|
||||
(g.y, "C performs Y", "c_performs_y"),
|
||||
] {
|
||||
expected.relations.insert(
|
||||
ids[&work],
|
||||
Relation {
|
||||
id: ids[&work],
|
||||
alias: alias.into(),
|
||||
name: name.into(),
|
||||
description: String::new(),
|
||||
body: RelationBody::Allocation {
|
||||
source: g.c,
|
||||
target: work,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_out_and_back_assigns_at_zero_delta_then_true_noop_preserves_redo() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let mut g = group(layer);
|
||||
unassign(&mut g, true);
|
||||
select(&mut g.f.app, &[g.f.node, g.yn]);
|
||||
let held = selection(&g.f.app);
|
||||
let before = g.f.app.project().clone();
|
||||
let source = g.f.app.document.bundle().clone();
|
||||
let from = node_point(&g.f.app, g.f.node);
|
||||
let mut state = CanvasState::default();
|
||||
let press = begin(&mut g.f.app, &mut state, from);
|
||||
assert_eq!(press.target, SelectionTarget::Placement(g.f.node));
|
||||
assert_eq!(selection(&g.f.app), held);
|
||||
move_pointer(&mut g.f.app, &mut state, from + Vector::new(4., 0.));
|
||||
let preview =
|
||||
g.f.app
|
||||
.allocation_drag
|
||||
.preview
|
||||
.as_ref()
|
||||
.expect(&g.f.app.status);
|
||||
assert_eq!(preview.placement.delta, xy(0., 0.));
|
||||
let Candidate::Assignment(candidate) = &preview.candidate else {
|
||||
panic!("exact work group must preview assignment");
|
||||
};
|
||||
assert_eq!(candidate.destination(), plan::Destination::Performer(g.c));
|
||||
assert_eq!(candidate.changed_count(), 2);
|
||||
let ids: BTreeMap<_, _> = preview
|
||||
.project()
|
||||
.relations
|
||||
.values()
|
||||
.filter(|r| r.kind() == RelationKind::Allocation)
|
||||
.map(|r| (r.target(), r.id))
|
||||
.collect();
|
||||
assert_eq!(ids.len(), 2);
|
||||
move_pointer(&mut g.f.app, &mut state, from);
|
||||
release(&mut g.f.app, &mut state, from);
|
||||
let mut expected = before.clone();
|
||||
c_allocations(&mut expected, &g, &ids);
|
||||
assert_eq!(selection(&g.f.app), held);
|
||||
exact_history(&mut g.f.app, &before, &source, &expected);
|
||||
g.f.app
|
||||
.document
|
||||
.execute(Command::Describe(g.y, "Future rationale".into()))
|
||||
.unwrap();
|
||||
g.f.app.sync();
|
||||
let future = g.f.app.document.bundle().clone();
|
||||
let _ = g.f.app.update(Message::Undo);
|
||||
assert!(g.f.app.document.can_undo() && g.f.app.document.can_redo());
|
||||
let snapshot = g.f.app.document.snapshot();
|
||||
let revision = g.f.app.document.revision();
|
||||
let held = selection(&g.f.app);
|
||||
let from = node_point(&g.f.app, g.f.node);
|
||||
let mut state = CanvasState::default();
|
||||
begin(&mut g.f.app, &mut state, from);
|
||||
move_pointer(&mut g.f.app, &mut state, from + Vector::new(4., 0.));
|
||||
move_pointer(&mut g.f.app, &mut state, from);
|
||||
release(&mut g.f.app, &mut state, from);
|
||||
assert!(
|
||||
Arc::ptr_eq(&snapshot, &g.f.app.document.snapshot()),
|
||||
"{}",
|
||||
g.f.app.status
|
||||
);
|
||||
assert_eq!(g.f.app.document.revision(), revision);
|
||||
assert_eq!(selection(&g.f.app), held);
|
||||
assert!(g.f.app.document.can_undo() && g.f.app.document.can_redo());
|
||||
let _ = g.f.app.update(Message::Redo);
|
||||
assert_eq!(g.f.app.document.bundle(), &future);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_source_refusal_retains_primary_source_and_both_history_paths() {
|
||||
let mut g = group(Layer::Oa);
|
||||
unassign(&mut g, false);
|
||||
let source = g.f.app.document.bundle().clone();
|
||||
let mut limits = oa_core::source_project::ProjectLimits::default();
|
||||
limits.model.max_source_bytes = source.files.values().map(String::len).sum::<usize>() + 128;
|
||||
g.f.app.document = crate::document::Document::from_bundle(source, limits).unwrap();
|
||||
g.f.app
|
||||
.document
|
||||
.execute(Command::Describe(g.f.work, "Reviewed".into()))
|
||||
.unwrap();
|
||||
g.f.app
|
||||
.document
|
||||
.execute(Command::Rename(g.y, "Future Y".into()))
|
||||
.unwrap();
|
||||
let future = g.f.app.document.bundle().clone();
|
||||
assert!(g.f.app.document.undo());
|
||||
g.f.app.sync();
|
||||
g.f.app.reconcile_multiselect();
|
||||
select(&mut g.f.app, &[g.f.node, g.yn]);
|
||||
let held = selection(&g.f.app);
|
||||
let before = g.f.app.document.snapshot();
|
||||
let source = g.f.app.document.bundle().clone();
|
||||
let revision = g.f.app.document.revision();
|
||||
assert!(g.f.app.document.can_undo() && g.f.app.document.can_redo());
|
||||
let from = node_point(&g.f.app, g.f.node);
|
||||
let to = from + Vector::new(1400., 0.);
|
||||
let mut state = CanvasState::default();
|
||||
begin(&mut g.f.app, &mut state, from);
|
||||
move_pointer(&mut g.f.app, &mut state, to);
|
||||
assert!(
|
||||
g.f.app.allocation_drag.preview.is_some(),
|
||||
"geometry and semantics pass before source refusal: {}",
|
||||
g.f.app.status
|
||||
);
|
||||
release(&mut g.f.app, &mut state, to);
|
||||
assert!(g.f.app.status.contains("retry"), "{}", g.f.app.status);
|
||||
assert!(g.f.app.allocation_drag.active.is_none());
|
||||
assert!(Arc::ptr_eq(&before, &g.f.app.document.snapshot()));
|
||||
assert_eq!(g.f.app.document.bundle(), &source);
|
||||
assert_eq!(g.f.app.document.revision(), revision);
|
||||
assert_eq!(selection(&g.f.app), held);
|
||||
assert!(g.f.app.document.can_undo() && g.f.app.document.can_redo());
|
||||
let _ = g.f.app.update(Message::Redo);
|
||||
assert_eq!(g.f.app.document.bundle(), &future);
|
||||
let _ = g.f.app.update(Message::Undo);
|
||||
assert_eq!(g.f.app.document.bundle(), &source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_off_grid_performer_out_and_back_is_noop_with_redo() {
|
||||
for singleton in [false, true] {
|
||||
let mut g = group(Layer::Oa);
|
||||
let mut project = g.f.app.project().clone();
|
||||
project
|
||||
.diagrams
|
||||
.get_mut(&g.f.diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&g.an)
|
||||
.unwrap()
|
||||
.position = xy(103., 107.);
|
||||
g.f.app = App::with_project(project);
|
||||
g.f.app.active_diagram = Some(g.f.diagram);
|
||||
g.f.app.zoom = 1.;
|
||||
g.f.app.pan = Vector::ZERO;
|
||||
g.f.app.fit_to_view = false;
|
||||
g.f.app
|
||||
.document
|
||||
.execute(Command::Describe(g.f.work, "Reviewed".into()))
|
||||
.unwrap();
|
||||
g.f.app
|
||||
.document
|
||||
.execute(Command::Describe(g.y, "Future".into()))
|
||||
.unwrap();
|
||||
let future = g.f.app.document.bundle().clone();
|
||||
assert!(g.f.app.document.undo());
|
||||
g.f.app.sync();
|
||||
g.f.app.reconcile_multiselect();
|
||||
select(
|
||||
&mut g.f.app,
|
||||
&if singleton {
|
||||
vec![g.an]
|
||||
} else {
|
||||
vec![g.an, g.cn]
|
||||
},
|
||||
);
|
||||
let held = selection(&g.f.app);
|
||||
let before = g.f.app.document.snapshot();
|
||||
let source = g.f.app.document.bundle().clone();
|
||||
let revision = g.f.app.document.revision();
|
||||
let from = node_point(&g.f.app, g.an);
|
||||
let mut state = CanvasState::default();
|
||||
begin(&mut g.f.app, &mut state, from);
|
||||
move_pointer(&mut g.f.app, &mut state, from + Vector::new(20., 0.));
|
||||
assert!(
|
||||
g.f.app.allocation_drag.preview.is_some(),
|
||||
"{}",
|
||||
g.f.app.status
|
||||
);
|
||||
move_pointer(&mut g.f.app, &mut state, from);
|
||||
assert_eq!(
|
||||
g.f.app
|
||||
.allocation_drag
|
||||
.preview
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.placement
|
||||
.delta,
|
||||
xy(0., 0.)
|
||||
);
|
||||
release(&mut g.f.app, &mut state, from);
|
||||
assert!(Arc::ptr_eq(&before, &g.f.app.document.snapshot()));
|
||||
assert_eq!(g.f.app.document.bundle(), &source);
|
||||
assert_eq!(g.f.app.document.revision(), revision);
|
||||
assert_eq!(selection(&g.f.app), held);
|
||||
assert!(g.f.app.document.can_undo() && g.f.app.document.can_redo());
|
||||
let _ = g.f.app.update(Message::Redo);
|
||||
assert_eq!(g.f.app.document.bundle(), &future);
|
||||
}
|
||||
}
|
||||
@@ -161,7 +161,7 @@ fn stale_gesture_packets_preserve_navigation_and_a_new_live_cancel_still_works()
|
||||
.update(Message::WorkDrag(Event::Cancel(current.token)));
|
||||
assert!(f.app.allocation_drag.active.is_none());
|
||||
assert!(f.app.allocation_drag.epoch > epoch);
|
||||
assert_eq!(f.app.status, "Work drag canceled");
|
||||
assert_eq!(f.app.status, "Drag canceled");
|
||||
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
|
||||
assert_eq!(f.app.source_workspace.bundle(), before.bundle);
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ pub(crate) fn placements(snapshot: &Snapshot) -> Option<std::collections::BTreeS
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn visible(
|
||||
pub(super) fn visible(
|
||||
project: &Project,
|
||||
diagram: Id,
|
||||
view: &projection::ProjectedView,
|
||||
@@ -283,7 +283,7 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
fn inspect_multiselect_primary(&mut self) {
|
||||
pub(super) fn inspect_multiselect_primary(&mut self) {
|
||||
if self.topology_context().is_some() {
|
||||
self.inspect_process_multiselect_primary();
|
||||
return;
|
||||
@@ -422,7 +422,7 @@ impl App {
|
||||
})
|
||||
{
|
||||
self.status =
|
||||
"Group allocation drag is not available yet. The selection was retained."
|
||||
"Use an Allocation drag to assign this selection, or arm Arrange next drag."
|
||||
.into();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,9 @@ use std::{
|
||||
sync::Arc,
|
||||
};
|
||||
#[cfg(test)]
|
||||
#[path = "diagram/allocation_group_tests.rs"]
|
||||
mod allocation_group_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "diagram/attachment_tests.rs"]
|
||||
mod attachment_tests;
|
||||
#[cfg(test)]
|
||||
@@ -912,6 +915,15 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
state.pan_start = None;
|
||||
return Some(Action::publish(Message::Connection(event)).and_capture());
|
||||
}
|
||||
// group_input handles production selection first (including
|
||||
// Shift). This also closes legacy movement when selection or
|
||||
// Allocation capability is absent: never emit a bare MoveNode.
|
||||
if self.allocation_surface()
|
||||
&& let Some(target) = self.allocation_press_target(&scene, p)
|
||||
&& self.allocation_handles_press(&target)
|
||||
{
|
||||
return Some(self.begin_allocation(state, target, cursor.position()?, bounds));
|
||||
}
|
||||
if self.editable
|
||||
&& let Some(edge) = scene.routed.edges.get(&self.selected)
|
||||
&& !edge.custom_suspended
|
||||
@@ -984,16 +996,6 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
return Some(Action::publish(Message::Select(port.element)).and_capture());
|
||||
}
|
||||
if let Some(id) = self.hit(view, p) {
|
||||
if self.work_eligible(id) {
|
||||
return Some(self.begin_work(
|
||||
state,
|
||||
id,
|
||||
cursor.position()?,
|
||||
bounds,
|
||||
view,
|
||||
&scene.routed,
|
||||
));
|
||||
}
|
||||
let position = self
|
||||
.project
|
||||
.diagrams
|
||||
@@ -1269,10 +1271,7 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
f.translate(self.pan);
|
||||
f.scale(self.zoom);
|
||||
let scene = self.scene(state);
|
||||
let project = scene
|
||||
.work
|
||||
.as_ref()
|
||||
.map_or(self.project, |p| p.semantic.project());
|
||||
let project = scene.work.as_ref().map_or(self.project, |p| p.project());
|
||||
let view = &scene.view;
|
||||
let containers = view
|
||||
.nodes
|
||||
|
||||
@@ -0,0 +1,520 @@
|
||||
//! Exact Allocation dispatch through real canvas events and App rebuilds.
|
||||
use super::*;
|
||||
use crate::app::{
|
||||
App,
|
||||
allocation_drag::{Event as WorkEvent, Mode},
|
||||
multiselect::{Choice, Event as SelectionEvent, context},
|
||||
};
|
||||
use crate::diagram_usage::Target;
|
||||
use iced::{keyboard, widget::canvas::Program};
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum Control {
|
||||
Attachment,
|
||||
Bend,
|
||||
}
|
||||
|
||||
struct Fixture {
|
||||
app: App,
|
||||
diagram: Id,
|
||||
work: Id,
|
||||
edge: Id,
|
||||
cover: Option<Id>,
|
||||
at: Position,
|
||||
}
|
||||
fn xy(x: f32, y: f32) -> Position {
|
||||
Position { x, y }
|
||||
}
|
||||
fn bounds() -> Rectangle {
|
||||
Rectangle::new(Point::new(120., 80.), Size::new(2400., 1800.))
|
||||
}
|
||||
fn screen(p: Position) -> Point {
|
||||
Point::new(bounds().x + p.x, bounds().y + p.y)
|
||||
}
|
||||
fn fixture(control: Control, cover: Option<Kind>) -> Fixture {
|
||||
let mut project = Project::empty();
|
||||
let a = project
|
||||
.add_element(Kind::Activity, Layer::Oa, "Send", None)
|
||||
.unwrap();
|
||||
let b = project
|
||||
.add_element(Kind::Activity, Layer::Oa, "Receive", None)
|
||||
.unwrap();
|
||||
let edge = project.relate(RelationKind::Interaction, "Report", a, b);
|
||||
let diagram = project.ensure_diagram(Layer::Oa);
|
||||
project.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Allocation;
|
||||
let work = project.place(diagram, a, xy(100., 150.));
|
||||
project.place(diagram, b, xy(700., 150.));
|
||||
project
|
||||
.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.routes
|
||||
.push(EdgeRoute {
|
||||
edge,
|
||||
waypoints: vec![xy(450., 450.)],
|
||||
label: None,
|
||||
source_attachment: Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Right,
|
||||
offset: 0.6,
|
||||
}),
|
||||
target_attachment: Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Left,
|
||||
offset: 0.5,
|
||||
}),
|
||||
});
|
||||
let view = projection::project_view(&project, diagram);
|
||||
let routed = routing::route_diagram(&project, &project.diagrams[&diagram], &view);
|
||||
let at = match control {
|
||||
Control::Attachment => {
|
||||
let at = *routed.edges[&edge].points.first().unwrap();
|
||||
// Inside the handle radius but outside its owner's right boundary,
|
||||
// so the covered-card discriminator does not depend on node order.
|
||||
xy(at.x + 4., at.y)
|
||||
}
|
||||
Control::Bend => routed.edges[&edge].pins[0],
|
||||
};
|
||||
let cover = cover.map(|kind| {
|
||||
let element = project
|
||||
.add_element(kind, Layer::Oa, "Underlying card", None)
|
||||
.unwrap();
|
||||
project.place(diagram, element, xy(at.x - 40., at.y - 35.))
|
||||
});
|
||||
validate_candidate(&project).unwrap();
|
||||
let mut app = App::with_project(project);
|
||||
app.active_diagram = Some(diagram);
|
||||
app.zoom = 1.;
|
||||
app.pan = Vector::ZERO;
|
||||
let _ = app.update(Message::Zoom(1.));
|
||||
let _ = app.update(Message::Select(edge));
|
||||
app.reconcile_multiselect();
|
||||
Fixture {
|
||||
app,
|
||||
diagram,
|
||||
work,
|
||||
edge,
|
||||
cover,
|
||||
at,
|
||||
}
|
||||
}
|
||||
fn canvas(app: &App) -> DiagramCanvas<'_> {
|
||||
DiagramCanvas {
|
||||
selection: app.diagram_selection.as_ref(),
|
||||
allocation: Some(app.allocation_capability()),
|
||||
project: app.project(),
|
||||
view_index: Some(app.document.view_index()),
|
||||
diagram: app.active_diagram.unwrap(),
|
||||
selected: app.selected,
|
||||
navigation_focus: None,
|
||||
zoom: app.zoom,
|
||||
pan: app.pan,
|
||||
context: app.route_context().unwrap(),
|
||||
selected_bend: app
|
||||
.selected_bend
|
||||
.filter(|(edge, _)| *edge == app.selected)
|
||||
.map(|(_, bend)| bend),
|
||||
selected_attachment: None,
|
||||
editable: !app.draft_dirty() && !app.source_dirty(),
|
||||
placing: false,
|
||||
connection: None,
|
||||
}
|
||||
}
|
||||
fn input(app: &App, state: &mut State, event: canvas::Event, at: Point) -> Option<Message> {
|
||||
canvas(app)
|
||||
.update(state, &event, bounds(), mouse::Cursor::Available(at))
|
||||
.and_then(|action| action.into_inner().0)
|
||||
}
|
||||
fn pointer(app: &App, state: &mut State, event: mouse::Event, at: Point) -> Option<Message> {
|
||||
input(app, state, canvas::Event::Mouse(event), at)
|
||||
}
|
||||
fn mixed(f: &mut Fixture) {
|
||||
for (target, choice) in [
|
||||
(Target::Placement(f.work), Choice::Replace),
|
||||
(Target::Edge(f.edge), Choice::Add),
|
||||
] {
|
||||
let current = context(f.app.route_context().unwrap());
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::DiagramSelection(SelectionEvent::Choose(
|
||||
current, target, choice,
|
||||
)));
|
||||
}
|
||||
assert_eq!(f.app.multiselect_snapshot().unwrap().targets().len(), 2);
|
||||
assert_eq!(
|
||||
f.app.selected, f.edge,
|
||||
"selected edge exposes its real handles"
|
||||
);
|
||||
}
|
||||
fn assert_control(f: &Fixture, control: Control) {
|
||||
let c = canvas(&f.app);
|
||||
let scene = c.scene(&State::default());
|
||||
let edge = &scene.routed.edges[&f.edge];
|
||||
assert!(!edge.custom_suspended);
|
||||
match control {
|
||||
Control::Attachment => assert!(c.attachment_at(&scene, point(f.at)).is_some()),
|
||||
Control::Bend => assert!(edge.pins.contains(&f.at)),
|
||||
}
|
||||
let local = point(f.at);
|
||||
if let Some(cover) = f.cover {
|
||||
assert_eq!(
|
||||
c.hit(&scene.view, local),
|
||||
Some(cover),
|
||||
"fixture actually places a card under the visible control"
|
||||
);
|
||||
} else if matches!(control, Control::Bend) {
|
||||
assert!(
|
||||
c.hit(&scene.view, local).is_none(),
|
||||
"isolated bend has no underlying card"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
c.allocation_press_target(&scene, local),
|
||||
Some(Target::Edge(f.edge))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allocation_handles_keep_exact_edge_through_shift_and_pending_group_dispatch() {
|
||||
for control in [Control::Attachment, Control::Bend] {
|
||||
for cover in [None, Some(Kind::Activity), Some(Kind::Entity)] {
|
||||
let mut f = fixture(control, cover);
|
||||
mixed(&mut f);
|
||||
assert_control(&f, control);
|
||||
let from = screen(f.at);
|
||||
let mut state = State::default();
|
||||
let _ = input(
|
||||
&f.app,
|
||||
&mut state,
|
||||
canvas::Event::Keyboard(keyboard::Event::ModifiersChanged(
|
||||
keyboard::Modifiers::SHIFT,
|
||||
)),
|
||||
from,
|
||||
);
|
||||
let _ = pointer(
|
||||
&f.app,
|
||||
&mut state,
|
||||
mouse::Event::CursorMoved { position: from },
|
||||
from,
|
||||
);
|
||||
let message = pointer(
|
||||
&f.app,
|
||||
&mut state,
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
from,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(&message, Message::DiagramSelection(SelectionEvent::Choose(_, Target::Edge(id), Choice::Toggle)) if *id == f.edge)
|
||||
);
|
||||
let _ = f.app.update(message);
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().targets(),
|
||||
&[Target::Placement(f.work)]
|
||||
);
|
||||
assert!(state.work.is_none() && state.group.is_none() && state.route_drag.is_none());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allocation_handle_click_collapses_but_out_and_back_refuses_after_app_rebuilds() {
|
||||
for control in [Control::Attachment, Control::Bend] {
|
||||
for cover in [None, Some(Kind::Activity), Some(Kind::Entity)] {
|
||||
for crossed in [false, true] {
|
||||
let mut f = fixture(control, cover);
|
||||
mixed(&mut f);
|
||||
assert_control(&f, control);
|
||||
let accepted = f.app.project().clone();
|
||||
let source = f.app.document.bundle().clone();
|
||||
let revision = f.app.document.revision();
|
||||
let selection = f.app.multiselect_snapshot().unwrap();
|
||||
let from = screen(f.at);
|
||||
let mut state = State::default();
|
||||
let _ = pointer(
|
||||
&f.app,
|
||||
&mut state,
|
||||
mouse::Event::CursorMoved { position: from },
|
||||
from,
|
||||
);
|
||||
let message = pointer(
|
||||
&f.app,
|
||||
&mut state,
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
from,
|
||||
)
|
||||
.unwrap();
|
||||
let Message::WorkDrag(WorkEvent::Press(press)) = &message else {
|
||||
panic!("exact mixed-set Allocation press")
|
||||
};
|
||||
assert_eq!(press.target, Target::Edge(f.edge));
|
||||
assert_eq!(press.selection, selection);
|
||||
assert_eq!(press.mode, Mode::Assign);
|
||||
let _ = f.app.update(message);
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap(),
|
||||
selection,
|
||||
"press retains primary and membership"
|
||||
);
|
||||
if crossed {
|
||||
for at in [from + Vector::new(4., 0.), from] {
|
||||
let message = pointer(
|
||||
&f.app,
|
||||
&mut state,
|
||||
mouse::Event::CursorMoved { position: at },
|
||||
at,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
&message,
|
||||
Message::WorkDrag(WorkEvent::Preview(..))
|
||||
));
|
||||
let _ = f.app.update(message);
|
||||
}
|
||||
}
|
||||
let message = pointer(
|
||||
&f.app,
|
||||
&mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
from,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
&message,
|
||||
Message::WorkDrag(WorkEvent::Release(..))
|
||||
));
|
||||
let _ = f.app.update(message);
|
||||
if crossed {
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap(),
|
||||
selection,
|
||||
"crossed return cannot collapse or move an underlying card"
|
||||
);
|
||||
} else {
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().targets(),
|
||||
&[Target::Edge(f.edge)]
|
||||
);
|
||||
}
|
||||
assert_eq!(f.app.project(), &accepted);
|
||||
assert_eq!(f.app.document.bundle(), &source);
|
||||
assert_eq!(f.app.document.revision(), revision);
|
||||
assert!(!f.app.document.can_undo());
|
||||
assert!(!f.app.document.can_redo());
|
||||
assert!(
|
||||
state.work.is_none() && state.group.is_none() && state.route_drag.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn armed_isolated_bend_is_owned_but_unarmed_controls_still_edit_routes() {
|
||||
for control in [Control::Attachment, Control::Bend] {
|
||||
for armed in [false, true] {
|
||||
let mut f = fixture(control, None);
|
||||
assert_control(&f, control);
|
||||
if armed {
|
||||
let _ = f.app.update(Message::WorkDrag(WorkEvent::ArrangeNext));
|
||||
}
|
||||
let from = screen(f.at);
|
||||
let mut state = State::default();
|
||||
let _ = pointer(
|
||||
&f.app,
|
||||
&mut state,
|
||||
mouse::Event::CursorMoved { position: from },
|
||||
from,
|
||||
);
|
||||
let message = pointer(
|
||||
&f.app,
|
||||
&mut state,
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
from,
|
||||
);
|
||||
if armed {
|
||||
let Some(Message::WorkDrag(WorkEvent::Press(press))) = message else {
|
||||
panic!("armed handle requires Allocation ownership")
|
||||
};
|
||||
assert_eq!(press.target, Target::Edge(f.edge));
|
||||
assert_eq!(press.mode, Mode::Arrange);
|
||||
assert!(state.work.is_some());
|
||||
assert!(state.route_drag.is_none());
|
||||
} else {
|
||||
assert!(state.work.is_none());
|
||||
let gesture = state
|
||||
.route_drag
|
||||
.as_ref()
|
||||
.expect("ordinary selected handle remains a route editor");
|
||||
match control {
|
||||
Control::Attachment => {
|
||||
assert!(matches!(gesture.handle, Handle::Attachment { .. }))
|
||||
}
|
||||
Control::Bend => assert!(matches!(gesture.handle, Handle::Bend(0))),
|
||||
}
|
||||
assert!(!matches!(message, Some(Message::WorkDrag(_))));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_performer_body_uses_one_token_and_retains_existing_movement_policy() {
|
||||
let mut f = fixture(Control::Bend, None);
|
||||
let mut project = f.app.project().clone();
|
||||
let performer = project
|
||||
.add_element(Kind::Entity, Layer::Oa, "Mover", None)
|
||||
.unwrap();
|
||||
let node = project.place(f.diagram, performer, xy(1200., 800.));
|
||||
f.app = App::with_project(project);
|
||||
f.app.active_diagram = Some(f.diagram);
|
||||
f.app.zoom = 1.;
|
||||
f.app.pan = Vector::ZERO;
|
||||
let _ = f.app.update(Message::Zoom(1.));
|
||||
f.app.reconcile_multiselect();
|
||||
let accepted = f.app.project().clone();
|
||||
let source = f.app.document.bundle().clone();
|
||||
let from = screen(xy(1240., 835.));
|
||||
let to = from + Vector::new(30., 20.);
|
||||
let mut state = State::default();
|
||||
let _ = pointer(
|
||||
&f.app,
|
||||
&mut state,
|
||||
mouse::Event::CursorMoved { position: from },
|
||||
from,
|
||||
);
|
||||
let message = pointer(
|
||||
&f.app,
|
||||
&mut state,
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
from,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(&message, Message::WorkDrag(WorkEvent::Press(p)) if p.target == Target::Placement(node) && p.mode == Mode::Assign)
|
||||
);
|
||||
let _ = f.app.update(message);
|
||||
assert_eq!(
|
||||
f.app.selected, performer,
|
||||
"clean unselected press selects immediately"
|
||||
);
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().targets(),
|
||||
&[Target::Placement(node)]
|
||||
);
|
||||
for event in [
|
||||
mouse::Event::CursorMoved { position: to },
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
] {
|
||||
let message = pointer(&f.app, &mut state, event, to).unwrap();
|
||||
assert!(matches!(
|
||||
&message,
|
||||
Message::WorkDrag(WorkEvent::Preview(..) | WorkEvent::Release(..))
|
||||
));
|
||||
let _ = f.app.update(message);
|
||||
}
|
||||
let mut expected = accepted;
|
||||
expected
|
||||
.diagrams
|
||||
.get_mut(&f.diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&node)
|
||||
.unwrap()
|
||||
.position = xy(1230., 820.);
|
||||
assert_eq!(f.app.project(), &expected);
|
||||
assert_eq!(f.app.document.revision(), 1);
|
||||
let after = f.app.document.bundle().clone();
|
||||
let _ = f.app.update(Message::Undo);
|
||||
assert_eq!(f.app.document.bundle(), &source);
|
||||
let _ = f.app.update(Message::Redo);
|
||||
assert_eq!(f.app.document.bundle(), &after);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dirty_selected_pin_outer_ring_is_shielded_without_starting_pan() {
|
||||
for zoom in [0.5, 1., 2.] {
|
||||
let mut f = fixture(Control::Bend, None);
|
||||
let mut project = f.app.project().clone();
|
||||
let route = &mut project.diagrams.get_mut(&f.diagram).unwrap().routes[0];
|
||||
route.source_attachment.as_mut().unwrap().offset = 0.5;
|
||||
route.target_attachment.as_mut().unwrap().offset = 0.5;
|
||||
let view = projection::project_view(&project, f.diagram);
|
||||
let routed = routing::route_diagram(&project, &project.diagrams[&f.diagram], &view);
|
||||
let y = routed.edges[&f.edge].points.first().unwrap().y;
|
||||
assert_eq!(routed.edges[&f.edge].points.last().unwrap().y, y);
|
||||
let pin = xy(450., y);
|
||||
project.diagrams.get_mut(&f.diagram).unwrap().routes[0].waypoints = vec![pin];
|
||||
f.app = App::with_project(project);
|
||||
f.app.active_diagram = Some(f.diagram);
|
||||
f.app.zoom = zoom;
|
||||
f.app.pan = Vector::ZERO;
|
||||
let _ = f.app.update(Message::Zoom(1.));
|
||||
let _ = f.app.update(Message::Select(f.edge));
|
||||
let _ = f.app.update(Message::Field(
|
||||
"/name".into(),
|
||||
"Keep this unfinished relation name".into(),
|
||||
));
|
||||
assert!(f.app.draft_dirty());
|
||||
let c = canvas(&f.app);
|
||||
assert!(!c.editable);
|
||||
let scene = c.scene(&State::default());
|
||||
assert!(!scene.routed.edges[&f.edge].custom_suspended);
|
||||
assert_eq!(scene.routed.edges[&f.edge].pins, vec![pin]);
|
||||
let local = Point::new(pin.x * zoom, pin.y * zoom + 7.5);
|
||||
let at = Point::new(bounds().x + local.x, bounds().y + local.y);
|
||||
assert!(c.hit(&scene.view, local).is_none());
|
||||
assert!(c.hit_port(&scene.view, local).is_none());
|
||||
assert!(
|
||||
c.hit_edge(&scene.routed, local).is_none(),
|
||||
"outer ring lies beyond the ordinary 7px edge hit"
|
||||
);
|
||||
assert!(c.hit_label(&scene.routed, local).is_none());
|
||||
assert!(c.hit_association_label(&scene.routed, local).is_none());
|
||||
assert!(
|
||||
c.work_dirty_point(bounds(), at),
|
||||
"the root shield must cover the visible 8px pin handle while editable=false"
|
||||
);
|
||||
let project = f.app.project().clone();
|
||||
let source = f.app.document.bundle().clone();
|
||||
let selection = f.app.multiselect_snapshot().unwrap();
|
||||
let draft = f.app.draft.as_ref().unwrap().value.clone();
|
||||
let revision = f.app.document.revision();
|
||||
let pan = f.app.pan;
|
||||
let mut state = State::default();
|
||||
let _ = pointer(
|
||||
&f.app,
|
||||
&mut state,
|
||||
mouse::Event::CursorMoved { position: at },
|
||||
at,
|
||||
);
|
||||
let message = pointer(
|
||||
&f.app,
|
||||
&mut state,
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
at,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
&message,
|
||||
Message::WorkDrag(WorkEvent::Refused(..))
|
||||
));
|
||||
let _ = f.app.update(message);
|
||||
assert!(state.pan_start.is_none() && state.route_drag.is_none() && state.work.is_none());
|
||||
let moved = at + Vector::new(40., 20.);
|
||||
for event in [
|
||||
mouse::Event::CursorMoved { position: moved },
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
] {
|
||||
let message = pointer(&f.app, &mut state, event, moved);
|
||||
assert!(
|
||||
message.is_none(),
|
||||
"refused handle press must not become background pan or a selector"
|
||||
);
|
||||
}
|
||||
assert_eq!(f.app.project(), &project);
|
||||
assert_eq!(f.app.document.bundle(), &source);
|
||||
assert_eq!(f.app.document.revision(), revision);
|
||||
assert_eq!(f.app.multiselect_snapshot().unwrap(), selection);
|
||||
assert_eq!(f.app.draft.as_ref().unwrap().value, draft);
|
||||
assert_eq!(f.app.pan, pan);
|
||||
}
|
||||
}
|
||||
@@ -89,6 +89,10 @@ impl DiagramCanvas<'_> {
|
||||
bounds: Rectangle,
|
||||
cursor: mouse::Cursor,
|
||||
) -> Option<Action<Message>> {
|
||||
// The Allocation token owns all ordered motion/release while active.
|
||||
if state.work.is_some() {
|
||||
return None;
|
||||
}
|
||||
let viewport = Viewport {
|
||||
zoom: self.zoom,
|
||||
pan: self.pan,
|
||||
@@ -192,7 +196,9 @@ impl DiagramCanvas<'_> {
|
||||
let pointer = cursor.position()?;
|
||||
let local = Point::new(pointer.x - bounds.x, pointer.y - bounds.y);
|
||||
let scene = self.scene(state);
|
||||
let target = if let Some(port) = self.hit_port(&scene.view, local) {
|
||||
let target = if self.allocation_surface() {
|
||||
self.allocation_press_target(&scene, local)
|
||||
} else if let Some(port) = self.hit_port(&scene.view, local) {
|
||||
Some(Target::Port {
|
||||
port: port.element,
|
||||
placement: port.owner,
|
||||
@@ -219,6 +225,9 @@ impl DiagramCanvas<'_> {
|
||||
.and_capture(),
|
||||
);
|
||||
}
|
||||
if self.allocation_handles_press(&target) {
|
||||
return Some(self.begin_allocation(state, target, pointer, bounds));
|
||||
}
|
||||
if selection.targets().len() <= 1 || !selection.contains(&target) {
|
||||
return None;
|
||||
}
|
||||
@@ -230,12 +239,6 @@ impl DiagramCanvas<'_> {
|
||||
Some(
|
||||
"Move requires only placed elements. Remove ports and connections from the selection.",
|
||||
)
|
||||
} else if snapshot
|
||||
.targets()
|
||||
.iter()
|
||||
.any(|t| matches!(t, Target::Placement(id) if self.work_eligible(*id)))
|
||||
{
|
||||
Some("Group allocation drag is not available yet. The selection was retained.")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use super::*;
|
||||
use crate::app::allocation_drag::{self as input, Event, Press, Stamp};
|
||||
use crate::app::allocation_drag::{self as input, Event, Mode, Press, Stamp};
|
||||
use crate::diagram_usage::Target as SelectionTarget;
|
||||
|
||||
impl DiagramCanvas<'_> {
|
||||
pub(crate) fn work_dirty_point(&self, bounds: Rectangle, screen: Point) -> bool {
|
||||
@@ -23,14 +24,83 @@ impl DiagramCanvas<'_> {
|
||||
.then_some(capability.state.preview.as_ref())
|
||||
.flatten()
|
||||
}
|
||||
pub(super) fn work_eligible(&self, placement: Id) -> bool {
|
||||
self.allocation.is_some()
|
||||
&& self.project.diagrams[&self.diagram].projection == ProjectionKind::Allocation
|
||||
&& self.project.diagrams[&self.diagram]
|
||||
.nodes
|
||||
.get(&placement)
|
||||
.and_then(|n| self.project.elements.get(&n.element))
|
||||
.is_some_and(|e| e.kind().is_work())
|
||||
pub(super) fn allocation_surface(&self) -> bool {
|
||||
self.project.diagrams[&self.diagram].projection == ProjectionKind::Allocation
|
||||
}
|
||||
|
||||
// Dirty input must shield the complete visible handle, including its outer
|
||||
// ring beyond the edge hit tolerance. This test deliberately ignores edit
|
||||
// permission: the root shield constructs a non-editable canvas.
|
||||
fn allocation_route_control(
|
||||
&self,
|
||||
view: &ProjectedView,
|
||||
routed: &RoutedView,
|
||||
world: Point,
|
||||
) -> bool {
|
||||
let Some(edge) = routed
|
||||
.edges
|
||||
.get(&self.selected)
|
||||
.filter(|e| !e.custom_suspended)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let radius = 8. / self.zoom;
|
||||
edge.pins
|
||||
.iter()
|
||||
.any(|pin| world.distance(point(*pin)) <= radius)
|
||||
|| [
|
||||
(EdgeEnd::Source, edge.points.first()),
|
||||
(EdgeEnd::Target, edge.points.last()),
|
||||
]
|
||||
.into_iter()
|
||||
.any(|(end, at)| {
|
||||
at.is_some_and(|at| world.distance(point(*at)) <= radius)
|
||||
&& route_edit::attachment_node(
|
||||
self.project,
|
||||
&self.project.diagrams[&self.diagram],
|
||||
view,
|
||||
self.selected,
|
||||
end,
|
||||
)
|
||||
.is_some()
|
||||
})
|
||||
}
|
||||
|
||||
/// Visible selected-route controls take precedence over underlying cards.
|
||||
/// Returning the exact edge keeps Shift and owned movement admission aligned.
|
||||
pub(super) fn allocation_press_target(
|
||||
&self,
|
||||
scene: &Scene,
|
||||
local: Point,
|
||||
) -> Option<SelectionTarget> {
|
||||
let world = self.world(local);
|
||||
if self.editable && self.allocation_route_control(&scene.view, &scene.routed, world) {
|
||||
return Some(SelectionTarget::Edge(self.selected));
|
||||
}
|
||||
if let Some(port) = self.hit_port(&scene.view, local) {
|
||||
Some(SelectionTarget::Port {
|
||||
port: port.element,
|
||||
placement: port.owner,
|
||||
})
|
||||
} else if let Some(id) = self.hit(&scene.view, local) {
|
||||
Some(SelectionTarget::Placement(id))
|
||||
} else {
|
||||
self.hit_label(&scene.routed, local)
|
||||
.or_else(|| self.hit_association_label(&scene.routed, local))
|
||||
.or_else(|| self.hit_edge(&scene.routed, local).map(|hit| hit.0))
|
||||
.map(SelectionTarget::Edge)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn allocation_handles_press(&self, target: &SelectionTarget) -> bool {
|
||||
self.allocation_surface()
|
||||
&& (self.allocation.is_some_and(|c| c.state.arrange_next)
|
||||
|| matches!(target, SelectionTarget::Placement(_))
|
||||
|| self.selection.is_some_and(|selection| {
|
||||
selection.scope() == crate::app::multiselect::context(self.context).scope
|
||||
&& selection.targets().len() > 1
|
||||
&& selection.contains(target)
|
||||
}))
|
||||
}
|
||||
pub(super) fn work_dirty_press(
|
||||
&self,
|
||||
@@ -42,26 +112,30 @@ impl DiagramCanvas<'_> {
|
||||
&& !self.placing
|
||||
&& self.connection.is_none()
|
||||
&& self.project.diagrams[&self.diagram].projection == ProjectionKind::Allocation
|
||||
&& (self.hit(view, local).is_some()
|
||||
&& (self.allocation_route_control(view, routed, self.world(local))
|
||||
|| self.hit(view, local).is_some()
|
||||
|| self.hit_port(view, local).is_some()
|
||||
|| self.hit_edge(routed, local).is_some()
|
||||
|| self.hit_label(routed, local).is_some()
|
||||
|| self.hit_association_label(routed, local).is_some())
|
||||
}
|
||||
pub(super) fn begin_work(
|
||||
pub(super) fn begin_allocation(
|
||||
&self,
|
||||
state: &mut State,
|
||||
node: Id,
|
||||
target: SelectionTarget,
|
||||
screen: Point,
|
||||
bounds: Rectangle,
|
||||
view: &ProjectedView,
|
||||
routed: &RoutedView,
|
||||
) -> Action<Message> {
|
||||
let capability = self.allocation.unwrap();
|
||||
state.drag = None;
|
||||
state.preview = None;
|
||||
state.port_drag = None;
|
||||
state.route_drag = None;
|
||||
state.attachment_viewport = None;
|
||||
state.group = None;
|
||||
*state.cache.borrow_mut() = None;
|
||||
let Some(capability) = self.allocation else {
|
||||
return Action::capture();
|
||||
};
|
||||
if !capability.clean {
|
||||
return Action::publish(Message::WorkDrag(Event::Refused(
|
||||
capability.state.epoch,
|
||||
@@ -69,18 +143,15 @@ impl DiagramCanvas<'_> {
|
||||
)))
|
||||
.and_capture();
|
||||
}
|
||||
let work = self.project.diagrams[&self.diagram].nodes[&node].element;
|
||||
let scope = match crate::allocation_drag::capture(self.project, self.diagram, work, node) {
|
||||
Ok(scope) => scope,
|
||||
Err(reason) => {
|
||||
return Action::publish(Message::WorkDrag(Event::Refused(
|
||||
capability.state.epoch,
|
||||
input::refusal(&reason),
|
||||
)))
|
||||
.and_capture();
|
||||
}
|
||||
let Some(selection) = self.selection.and_then(|selection| {
|
||||
selection.snapshot(crate::app::multiselect::context(self.context))
|
||||
}) else {
|
||||
return Action::publish(Message::WorkDrag(Event::Refused(
|
||||
capability.state.epoch,
|
||||
"The selection changed. Start this drag again.".into(),
|
||||
)))
|
||||
.and_capture();
|
||||
};
|
||||
let frozen = input::Frozen::capture(self.project, &scope, view, routed, self.zoom);
|
||||
let press = Arc::new(Press {
|
||||
stamp: Stamp {
|
||||
context: self.context,
|
||||
@@ -92,9 +163,13 @@ impl DiagramCanvas<'_> {
|
||||
},
|
||||
token: Id::new_v4(),
|
||||
start: screen,
|
||||
scope,
|
||||
frozen,
|
||||
arrange: capability.state.arrange_next,
|
||||
selection,
|
||||
target,
|
||||
mode: if capability.state.arrange_next {
|
||||
Mode::Arrange
|
||||
} else {
|
||||
Mode::Assign
|
||||
},
|
||||
});
|
||||
state.work = Some(press.clone());
|
||||
Action::publish(Message::WorkDrag(Event::Press(press))).and_capture()
|
||||
@@ -165,8 +240,11 @@ impl canvas::Program<Message> for WorkFeedback<'_> {
|
||||
} else {
|
||||
style::TEAL
|
||||
};
|
||||
let Some(frozen) = &feedback.frozen else {
|
||||
return vec![];
|
||||
};
|
||||
for index in &feedback.targets {
|
||||
let target = &feedback.press.frozen.targets[*index];
|
||||
let target = &frozen.targets[*index];
|
||||
let region = target.region;
|
||||
let top = Point::new(
|
||||
region.x * self.0.zoom + self.0.pan.x,
|
||||
|
||||
@@ -321,7 +321,9 @@ fn article(topic: Topic) -> Article {
|
||||
"Move work between performers",
|
||||
&[
|
||||
"Finish or cancel property and source edits, then drag an Activity or Function body in Architecture · allocated work. Aim at exposed performer space, including a collapsed performer's header. Read Destination above the canvas and the outlined target before releasing; release outside the canvas or press Escape to cancel.",
|
||||
"The move preserves work ownership and identities. A uniformly assigned work group moves its leaves together; a group distributed across performers asks you to choose a leaf. Role responsibilities remain explicit: an outward drag that would leave a role-derived performer explains why it cannot remove that responsibility.",
|
||||
"Use Shift-click to add or remove cards from the selection, or right-click a card and choose Add to selection. Drag any selected work card to assign the whole work selection to one performer. Independent work can start on different performers. The inspector keeps its primary element during the drag; right-click a selected card and choose Inspect this element to change that primary without losing the group.",
|
||||
"The move preserves work ownership and identities. A composite work card moves its leaves together when they have one coherent responsibility; if its own leaves are distributed across performers, choose specific leaves instead. Role responsibilities remain explicit: an outward drag that would leave a role-derived performer explains why it cannot remove that responsibility.",
|
||||
"To move work and performers together without changing responsibility, select their cards and choose Arrange next drag or press Alt+A. This moves the selected work subtrees and each selected performer's contained cards together, if the complete layout stays readable and clear. Ports and connections cannot join a placement move. Dragging only performer cards normally moves them and their contained cards without changing assignments.",
|
||||
"Make room when work cards would overlap, and move the view or zoom in if the moving work is not readable. The destination name stays above the canvas while the target outline stays fixed during the drag. Hover Destination to inspect a long name: outside the canvas, the tooltip explicitly identifies the last target, and releasing cancels. Return to the canvas to choose the drop. A refused drop keeps the model and history unchanged. One Undo reverses an accepted assignment and placement together; Save all writes the corresponding source changes.",
|
||||
],
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user