Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e348d967d | ||
|
|
f6a6010bae | ||
|
|
12a7352dde |
@@ -0,0 +1,716 @@
|
||||
//! Native evidence fixture/checker; install as a temporary oa-core example.
|
||||
//! This invariant evaluator derives delta from saved A, NOT from pointer input.
|
||||
use oa_core::{source_project::*, *};
|
||||
use std::{fs, io::Write, path::Path};
|
||||
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
|
||||
|
||||
fn id(alias: &str) -> Id {
|
||||
// Make the intended OA view first for default UUID-ordered view selection.
|
||||
if alias == "oa_view" {
|
||||
return Id::from_u128(0x29_0001);
|
||||
}
|
||||
Id::new_v5(
|
||||
&Id::from_u128(0xc1192026_0923_5000_8000_000000000029),
|
||||
alias.as_bytes(),
|
||||
)
|
||||
}
|
||||
fn point(x: f32, y: f32) -> Position {
|
||||
Position { x, y }
|
||||
}
|
||||
fn element(p: &mut Project, alias: &str, name: &str, kind: Kind, layer: Layer, parent: Option<Id>) {
|
||||
p.elements.insert(
|
||||
id(alias),
|
||||
Element {
|
||||
id: id(alias),
|
||||
alias: alias.into(),
|
||||
name: name.into(),
|
||||
description: format!("Preserve engineering record {alias}."),
|
||||
parent,
|
||||
body: ElementBody::new(kind, layer).unwrap(),
|
||||
},
|
||||
);
|
||||
}
|
||||
fn diagram(p: &mut Project, alias: &str, name: &str, layer: Layer) {
|
||||
p.diagrams.insert(
|
||||
id(alias),
|
||||
Diagram {
|
||||
id: id(alias),
|
||||
alias: alias.into(),
|
||||
name: name.into(),
|
||||
description: "Selection changes placement geometry only.".into(),
|
||||
layer,
|
||||
projection: ProjectionKind::Overview,
|
||||
view_options: Default::default(),
|
||||
nodes: Default::default(),
|
||||
routes: vec![],
|
||||
},
|
||||
);
|
||||
}
|
||||
fn place(p: &mut Project, view: &str, alias: &str, object: &str, position: Position, size: Size) {
|
||||
p.diagrams.get_mut(&id(view)).unwrap().nodes.insert(
|
||||
id(alias),
|
||||
NodePlacement {
|
||||
id: id(alias),
|
||||
alias: alias.into(),
|
||||
element: id(object),
|
||||
position,
|
||||
size,
|
||||
collapsed: false,
|
||||
port_anchors: vec![],
|
||||
},
|
||||
);
|
||||
}
|
||||
fn route(edge: &str, pins: Vec<Position>) -> EdgeRoute {
|
||||
EdgeRoute {
|
||||
edge: id(edge),
|
||||
waypoints: pins,
|
||||
source_attachment: Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Right,
|
||||
offset: 0.25,
|
||||
}),
|
||||
target_attachment: Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Left,
|
||||
offset: 0.7,
|
||||
}),
|
||||
label: Some(EdgeLabel {
|
||||
fraction: 0.45,
|
||||
offset: point(7., -19.),
|
||||
}),
|
||||
}
|
||||
}
|
||||
fn fixture() -> Result<Project> {
|
||||
let mut p = Project::empty();
|
||||
p.id = id("project");
|
||||
p.alias = "multiselect_native".into();
|
||||
p.name = "Multiselect native".into();
|
||||
p.description =
|
||||
"Shift-select A and B; move together, retain C and engineering semantics.".into();
|
||||
for (alias, name, parent) in [
|
||||
("activity_a", "A - receive request", None),
|
||||
("activity_b", "B - assess request", None),
|
||||
("activity_c", "C - dispatch response", None),
|
||||
(
|
||||
"activity_child",
|
||||
"A.1 - classify request",
|
||||
Some(id("activity_a")),
|
||||
),
|
||||
] {
|
||||
element(&mut p, alias, name, Kind::Activity, Layer::Oa, parent);
|
||||
}
|
||||
element(
|
||||
&mut p,
|
||||
"sa_function",
|
||||
"Technical request processing",
|
||||
Kind::Function,
|
||||
Layer::Sa,
|
||||
None,
|
||||
);
|
||||
for (alias, name, source, target) in [
|
||||
("internal", "Request assessment", "activity_a", "activity_b"),
|
||||
("external", "Response decision", "activity_b", "activity_c"),
|
||||
] {
|
||||
let old = p.relate(RelationKind::Interaction, name, id(source), id(target));
|
||||
reidentify(&mut p, old, id(alias))?;
|
||||
p.relations.get_mut(&id(alias)).unwrap().alias = alias.into();
|
||||
}
|
||||
diagram(
|
||||
&mut p,
|
||||
"oa_view",
|
||||
"Multiselect native - OA overview",
|
||||
Layer::Oa,
|
||||
);
|
||||
diagram(&mut p, "oa_other", "Other OA view - preserve", Layer::Oa);
|
||||
diagram(&mut p, "sa_view", "SA engineering - preserve", Layer::Sa);
|
||||
let size = Size {
|
||||
width: 224.,
|
||||
height: 94.,
|
||||
};
|
||||
place(
|
||||
&mut p,
|
||||
"oa_view",
|
||||
"node_a",
|
||||
"activity_a",
|
||||
point(80., 80.),
|
||||
Size {
|
||||
width: 310.,
|
||||
height: 260.,
|
||||
},
|
||||
);
|
||||
place(
|
||||
&mut p,
|
||||
"oa_view",
|
||||
"node_b",
|
||||
"activity_b",
|
||||
point(470., 120.),
|
||||
size,
|
||||
);
|
||||
place(
|
||||
&mut p,
|
||||
"oa_view",
|
||||
"node_c",
|
||||
"activity_c",
|
||||
point(780., 270.),
|
||||
size,
|
||||
);
|
||||
place(
|
||||
&mut p,
|
||||
"oa_view",
|
||||
"node_child",
|
||||
"activity_child",
|
||||
point(120., 210.),
|
||||
size,
|
||||
);
|
||||
place(
|
||||
&mut p,
|
||||
"oa_other",
|
||||
"other_a",
|
||||
"activity_a",
|
||||
point(40., 60.),
|
||||
size,
|
||||
);
|
||||
place(
|
||||
&mut p,
|
||||
"sa_view",
|
||||
"sa_node",
|
||||
"sa_function",
|
||||
point(100., 110.),
|
||||
size,
|
||||
);
|
||||
p.diagrams.get_mut(&id("oa_view")).unwrap().routes = vec![
|
||||
route("internal", vec![point(420., 145.), point(420., 186.)]),
|
||||
route("external", vec![point(725., 150.), point(725., 336.)]),
|
||||
];
|
||||
p.traces.insert(
|
||||
id("trace"),
|
||||
Trace {
|
||||
id: id("trace"),
|
||||
alias: "preserved_refinement".into(),
|
||||
name: "Reviewed request refinement".into(),
|
||||
description: "Group geometry and Hide preserve this trace and both endpoints.".into(),
|
||||
oa: id("activity_a"),
|
||||
sa: id("sa_function"),
|
||||
kind: TraceKind::Refinement,
|
||||
baseline: Some(TraceBaseline {
|
||||
oa: p.properties(id("activity_a")).unwrap(),
|
||||
sa: p.properties(id("sa_function")).unwrap(),
|
||||
semantic: None,
|
||||
}),
|
||||
},
|
||||
);
|
||||
validate_candidate(&p)?;
|
||||
Ok(p)
|
||||
}
|
||||
fn load(path: &Path) -> Result<Project> {
|
||||
if path
|
||||
.extension()
|
||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("arcadia"))
|
||||
{
|
||||
let disk = source_files::load_project(path, ProjectLimits::default())?;
|
||||
// This deliberately partial engineering fixture has completeness
|
||||
// warnings (allocation/behavior context). They are not source errors.
|
||||
// Compilation, candidate validation and whole-Project equality still
|
||||
// reject malformed source or any unexpected model change.
|
||||
if disk
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|d| d.diagnostic.severity == validation::Severity::Error)
|
||||
{
|
||||
return Err(format!("Source discovery diagnostics: {:?}", disk.diagnostics).into());
|
||||
}
|
||||
let compiled = compile_bundle(&disk.reachable_bundle(), ProjectLimits::default())
|
||||
.map_err(|e| format!("Source compile diagnostics: {e:?}"))?;
|
||||
Ok(compiled.decoded.project)
|
||||
} else {
|
||||
Ok(persistence::load(path)?)
|
||||
}
|
||||
}
|
||||
fn equal(expected: &Project, actual: &Project) -> Result<()> {
|
||||
if expected == actual {
|
||||
return Ok(());
|
||||
}
|
||||
let e = serde_json::to_value(expected)?;
|
||||
let a = serde_json::to_value(actual)?;
|
||||
let fields: Vec<_> = e
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.keys()
|
||||
.filter(|key| e[*key] != a[*key])
|
||||
.cloned()
|
||||
.collect();
|
||||
Err(format!("Whole Project mismatch in {fields:?}; no partial PASS").into())
|
||||
}
|
||||
fn roundtrip(p: &Project) -> Result<SourceBundle> {
|
||||
validate_candidate(p)?;
|
||||
equal(p, &persistence::decode(&serde_json::to_vec(p)?)?)?;
|
||||
let bundle = format_bundle(p);
|
||||
let compiled = compile_bundle(&bundle, ProjectLimits::default())
|
||||
.map_err(|e| format!("Roundtrip: {e:?}"))?;
|
||||
equal(p, &compiled.decoded.project)?;
|
||||
Ok(bundle)
|
||||
}
|
||||
fn write_new(path: &Path, bytes: &[u8]) -> Result<()> {
|
||||
fs::create_dir_all(path.parent().ok_or("Missing parent directory")?)?;
|
||||
fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(path)?
|
||||
.write_all(bytes)?;
|
||||
Ok(())
|
||||
}
|
||||
fn generate(out: &Path) -> Result<()> {
|
||||
if out.exists() {
|
||||
return Err("Output directory already exists; choose a fresh path".into());
|
||||
}
|
||||
let p = fixture()?;
|
||||
let bundle = roundtrip(&p)?;
|
||||
write_new(&out.join("baseline.json"), &serde_json::to_vec_pretty(&p)?)?;
|
||||
for (path, source) in &bundle.files {
|
||||
write_new(&out.join("source").join(path), source.as_bytes())?;
|
||||
}
|
||||
let names = [
|
||||
"project",
|
||||
"oa_view",
|
||||
"oa_other",
|
||||
"sa_view",
|
||||
"activity_a",
|
||||
"activity_b",
|
||||
"activity_c",
|
||||
"activity_child",
|
||||
"node_a",
|
||||
"node_b",
|
||||
"node_c",
|
||||
"node_child",
|
||||
"internal",
|
||||
"external",
|
||||
"trace",
|
||||
"sa_function",
|
||||
"sa_node",
|
||||
"other_a",
|
||||
];
|
||||
let ids: std::collections::BTreeMap<_, _> =
|
||||
names.into_iter().map(|name| (name, id(name))).collect();
|
||||
write_new(
|
||||
&out.join("manifest.json"),
|
||||
&serde_json::to_vec_pretty(&serde_json::json!({
|
||||
"fixture": "multiselect-native-20260923", "ids": ids, "source_entry": bundle.entry,
|
||||
"move_members": ["node_a", "node_b", "node_child"], "fixed_member": "node_c",
|
||||
"evaluation": "Whole Project invariant comparison; saved A supplies bounded shared delta, not an exact pointer oracle",
|
||||
"hide": "Remove node_a/node_b only; retain child, stored routes, all model records and other views",
|
||||
}))?,
|
||||
)?;
|
||||
equal(&p, &load(&out.join("baseline.json"))?)?;
|
||||
equal(&p, &load(&out.join("source/project.arcadia"))?)?;
|
||||
println!("PASS generated JSON and source bundle: {}", out.display());
|
||||
Ok(())
|
||||
}
|
||||
fn check(mode: &str, baseline: &Path, saved: &Path) -> Result<()> {
|
||||
let before = load(baseline)?;
|
||||
let actual = load(saved)?;
|
||||
validate_candidate(&before)?;
|
||||
validate_candidate(&actual)?;
|
||||
if before.id != id("project") {
|
||||
return Err("Not this fixture's baseline project".into());
|
||||
}
|
||||
let mut expected = before.clone();
|
||||
let mut delta = None;
|
||||
match mode {
|
||||
"unchanged" => {}
|
||||
"hide" => {
|
||||
let view = expected
|
||||
.diagrams
|
||||
.get_mut(&id("oa_view"))
|
||||
.ok_or("Missing OA view")?;
|
||||
for alias in ["node_a", "node_b"] {
|
||||
view.nodes
|
||||
.remove(&id(alias))
|
||||
.ok_or("Baseline selected placement missing")?;
|
||||
}
|
||||
// references::remove_objects removes routes only when their semantic
|
||||
// edge is deleted. Hide deletes placements; both routes MUST survive.
|
||||
}
|
||||
"moved" => {
|
||||
let old = before
|
||||
.diagrams
|
||||
.get(&id("oa_view"))
|
||||
.ok_or("Missing baseline OA view")?;
|
||||
let new = actual
|
||||
.diagrams
|
||||
.get(&id("oa_view"))
|
||||
.ok_or("Missing saved OA view")?;
|
||||
let a = old
|
||||
.nodes
|
||||
.get(&id("node_a"))
|
||||
.ok_or("Missing baseline A")?
|
||||
.position;
|
||||
let moved_a = new
|
||||
.nodes
|
||||
.get(&id("node_a"))
|
||||
.ok_or("Missing saved A")?
|
||||
.position;
|
||||
let d = point(moved_a.x - a.x, moved_a.y - a.y);
|
||||
if !d.x.is_finite()
|
||||
|| !d.y.is_finite()
|
||||
|| d.x.abs().max(d.y.abs()) > 500.
|
||||
|| d.x.hypot(d.y) < 10.
|
||||
{
|
||||
return Err(format!(
|
||||
"Move delta must have length >=10 and each axis <=500 world units: {d:?}"
|
||||
)
|
||||
.into());
|
||||
}
|
||||
if moved_a.x % 10. != 0. || moved_a.y % 10. != 0. {
|
||||
return Err("Saved anchor is not on 10-unit grid".into());
|
||||
}
|
||||
let view = expected.diagrams.get_mut(&id("oa_view")).unwrap();
|
||||
// Independent explicit fixture enumeration: no group/descendant/
|
||||
// routing planner is called to predict the result.
|
||||
for alias in ["node_a", "node_b", "node_child"] {
|
||||
let node = view
|
||||
.nodes
|
||||
.get_mut(&id(alias))
|
||||
.ok_or("Missing expected moved placement")?;
|
||||
node.position.x += d.x;
|
||||
node.position.y += d.y;
|
||||
}
|
||||
let internal = view
|
||||
.routes
|
||||
.iter_mut()
|
||||
.find(|route| route.edge == id("internal"))
|
||||
.ok_or("Missing internal pins")?;
|
||||
for pin in &mut internal.waypoints {
|
||||
pin.x += d.x;
|
||||
pin.y += d.y;
|
||||
}
|
||||
delta = Some(d);
|
||||
}
|
||||
_ => return Err("Mode must be moved, hide, or unchanged".into()),
|
||||
}
|
||||
equal(&expected, &actual)?;
|
||||
roundtrip(&actual)?;
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&serde_json::json!({
|
||||
"result": "PASS", "mode": mode, "baseline": baseline, "saved": saved,
|
||||
"comparison": "complete Project equality", "inferred_common_delta": delta,
|
||||
"limits": "Invariant evaluator only; no pointer/action/focus/Undo/source-byte acceptance claim",
|
||||
}))?
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
fn main() -> Result<()> {
|
||||
let args: Vec<_> = std::env::args().skip(1).collect();
|
||||
match args.as_slice() {
|
||||
[command, out] if command == "generate" => generate(Path::new(out)),
|
||||
[command, mode, baseline, saved] if command == "check" => check(mode, Path::new(baseline), Path::new(saved)),
|
||||
_ => Err("Usage: multiselect-native-producer generate DIRECTORY | check moved|hide|unchanged BASELINE SAVED".into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
// Produce evidence through real validated commands; the checker itself uses
|
||||
// explicit fixture enumeration and never calls this movement implementation.
|
||||
fn moved(before: &Project, delta: Position) -> Project {
|
||||
let command = diagram_edit::translate_placements(
|
||||
before,
|
||||
id("oa_view"),
|
||||
&BTreeSet::from([id("node_a"), id("node_b")]),
|
||||
delta,
|
||||
)
|
||||
.unwrap();
|
||||
let mut session = Session::new(before.clone());
|
||||
session.execute(command).unwrap();
|
||||
session.project().clone()
|
||||
}
|
||||
fn hidden(before: &Project) -> Project {
|
||||
let mut session = Session::new(before.clone());
|
||||
session
|
||||
.execute(Command::Batch(vec![
|
||||
Command::Delete(id("node_a")),
|
||||
Command::Delete(id("node_b")),
|
||||
]))
|
||||
.unwrap();
|
||||
session.project().clone()
|
||||
}
|
||||
fn through_saved_json(mode: &str, baseline: &Project, actual: &Project) -> Result<()> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
let before = dir.path().join("before.json");
|
||||
let saved = dir.path().join("saved.json");
|
||||
// These assertions distinguish meaningful invariant corruptions from
|
||||
// malformed projects rejected before the evaluator even sees them.
|
||||
validate_candidate(baseline).expect("test baseline must be valid");
|
||||
validate_candidate(actual).expect("corruption must remain a valid model");
|
||||
persistence::save(&before, baseline)?;
|
||||
persistence::save(&saved, actual)?;
|
||||
check(mode, &before, &saved)
|
||||
}
|
||||
fn mismatch(mode: &str, before: &Project, actual: &Project, case: &str) {
|
||||
let error = through_saved_json(mode, before, actual)
|
||||
.expect_err(case)
|
||||
.to_string();
|
||||
assert!(
|
||||
error.contains("Whole Project mismatch"),
|
||||
"{case}: unexpected rejection: {error}"
|
||||
);
|
||||
}
|
||||
fn edge<'a>(p: &'a mut Project, alias: &str) -> &'a mut EdgeRoute {
|
||||
p.diagrams
|
||||
.get_mut(&id("oa_view"))
|
||||
.unwrap()
|
||||
.routes
|
||||
.iter_mut()
|
||||
.find(|route| route.edge == id(alias))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_json_and_real_source_bundle_are_accepted_unchanged() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let out = dir.path().join("fixture");
|
||||
generate(&out).unwrap();
|
||||
let baseline = out.join("baseline.json");
|
||||
let saved = out.join("saved.json");
|
||||
persistence::save(&saved, &fixture().unwrap()).unwrap();
|
||||
check("unchanged", &baseline, &saved).unwrap();
|
||||
check("unchanged", &baseline, &out.join("source/project.arcadia")).unwrap();
|
||||
let bytes = fs::read(&baseline).unwrap();
|
||||
assert!(
|
||||
generate(&out).is_err(),
|
||||
"generation cannot overwrite evidence"
|
||||
);
|
||||
assert_eq!(fs::read(&baseline).unwrap(), bytes);
|
||||
let invalid = dir.path().join("invalid.arcadia");
|
||||
fs::write(&invalid, "project {").unwrap();
|
||||
assert!(load(&invalid).is_err(), "source errors must still reject");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_group_command_outputs_pass_and_are_not_unchanged() {
|
||||
let before = fixture().unwrap();
|
||||
for delta in [point(40., 60.), point(-20., 30.)] {
|
||||
let actual = moved(&before, delta);
|
||||
through_saved_json("moved", &before, &actual).unwrap();
|
||||
mismatch(
|
||||
"unchanged",
|
||||
&before,
|
||||
&actual,
|
||||
"real movement cannot count as unchanged",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_hide_command_outputs_pass_before_and_after_movement() {
|
||||
let initial = fixture().unwrap();
|
||||
for before in [initial.clone(), moved(&initial, point(40., 60.))] {
|
||||
let actual = hidden(&before);
|
||||
through_saved_json("hide", &before, &actual).unwrap();
|
||||
mismatch(
|
||||
"unchanged",
|
||||
&before,
|
||||
&actual,
|
||||
"real Hide cannot count as unchanged",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omitted_and_double_moved_child_are_rejected() {
|
||||
let before = fixture().unwrap();
|
||||
let delta = point(40., 60.);
|
||||
let correct = moved(&before, delta);
|
||||
let initial = before.diagrams[&id("oa_view")].nodes[&id("node_child")].position;
|
||||
for position in [
|
||||
initial,
|
||||
point(initial.x + 2. * delta.x, initial.y + 2. * delta.y),
|
||||
] {
|
||||
let mut bad = correct.clone();
|
||||
bad.diagrams
|
||||
.get_mut(&id("oa_view"))
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&id("node_child"))
|
||||
.unwrap()
|
||||
.position = position;
|
||||
mismatch("moved", &before, &bad, "child did not move exactly once");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn internal_pins_left_behind_and_external_pins_moved_are_rejected() {
|
||||
let mut before = fixture().unwrap();
|
||||
let original_pins = edge(&mut before, "internal").waypoints.clone();
|
||||
let correct = moved(&before, point(40., 60.));
|
||||
let mut bad = correct.clone();
|
||||
edge(&mut bad, "internal").waypoints = original_pins;
|
||||
mismatch("moved", &before, &bad, "internal pins left behind");
|
||||
let mut bad = correct;
|
||||
for pin in &mut edge(&mut bad, "external").waypoints {
|
||||
pin.x += 40.;
|
||||
pin.y += 60.;
|
||||
}
|
||||
mismatch("moved", &before, &bad, "one-ended pins moved");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_attachments_and_label_data_must_remain_exact() {
|
||||
let before = fixture().unwrap();
|
||||
let correct = moved(&before, point(40., 60.));
|
||||
let mutations: [fn(&mut EdgeRoute); 4] = [
|
||||
|r| r.source_attachment.as_mut().unwrap().offset = 0.65,
|
||||
|r| r.target_attachment.as_mut().unwrap().side = PortAnchorSide::Top,
|
||||
|r| r.label.as_mut().unwrap().offset.x += 40.,
|
||||
|r| r.label.as_mut().unwrap().fraction = 0.75,
|
||||
];
|
||||
for mutate in mutations {
|
||||
let mut bad = correct.clone();
|
||||
mutate(edge(&mut bad, "internal"));
|
||||
mismatch("moved", &before, &bad, "relative route metadata changed");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn semantic_other_view_fixed_node_and_trace_drift_are_rejected() {
|
||||
let before = fixture().unwrap();
|
||||
let correct = moved(&before, point(40., 60.));
|
||||
let mutations: [fn(&mut Project); 5] = [
|
||||
|p| {
|
||||
p.elements
|
||||
.get_mut(&id("activity_a"))
|
||||
.unwrap()
|
||||
.description
|
||||
.push_str(" drift")
|
||||
},
|
||||
|p| {
|
||||
p.diagrams
|
||||
.get_mut(&id("oa_other"))
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&id("other_a"))
|
||||
.unwrap()
|
||||
.position
|
||||
.x += 40.
|
||||
},
|
||||
|p| {
|
||||
p.diagrams
|
||||
.get_mut(&id("sa_view"))
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&id("sa_node"))
|
||||
.unwrap()
|
||||
.position
|
||||
.y += 60.
|
||||
},
|
||||
|p| {
|
||||
p.diagrams
|
||||
.get_mut(&id("oa_view"))
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&id("node_c"))
|
||||
.unwrap()
|
||||
.position
|
||||
.x += 40.
|
||||
},
|
||||
|p| {
|
||||
p.traces
|
||||
.get_mut(&id("trace"))
|
||||
.unwrap()
|
||||
.description
|
||||
.push_str(" drift")
|
||||
},
|
||||
];
|
||||
for mutate in mutations {
|
||||
let mut bad = correct.clone();
|
||||
mutate(&mut bad);
|
||||
mismatch(
|
||||
"moved",
|
||||
&before,
|
||||
&bad,
|
||||
"record outside permitted movement changed",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_small_excessive_and_offgrid_moves_cannot_satisfy_move_evidence() {
|
||||
let before = fixture().unwrap();
|
||||
for (delta, reason) in [
|
||||
(point(0., 0.), "length >=10"),
|
||||
(point(3., 0.), "length >=10"),
|
||||
(point(510., 0.), "each axis <=500"),
|
||||
(point(20.5, 0.), "10-unit grid"),
|
||||
] {
|
||||
let actual = moved(&before, delta);
|
||||
let error = through_saved_json("moved", &before, &actual)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains(reason), "{delta:?}: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hide_must_preserve_child_routes_and_semantic_objects() {
|
||||
let before = fixture().unwrap();
|
||||
let correct = hidden(&before);
|
||||
let mut missing_child = correct.clone();
|
||||
missing_child
|
||||
.diagrams
|
||||
.get_mut(&id("oa_view"))
|
||||
.unwrap()
|
||||
.nodes
|
||||
.remove(&id("node_child"));
|
||||
mismatch(
|
||||
"hide",
|
||||
&before,
|
||||
&missing_child,
|
||||
"Hide removed unselected child placement",
|
||||
);
|
||||
let mut missing_routes = correct;
|
||||
missing_routes
|
||||
.diagrams
|
||||
.get_mut(&id("oa_view"))
|
||||
.unwrap()
|
||||
.routes
|
||||
.clear();
|
||||
mismatch(
|
||||
"hide",
|
||||
&before,
|
||||
&missing_routes,
|
||||
"Hide removed persistent route records",
|
||||
);
|
||||
let mut semantic_delete = Session::new(before.clone());
|
||||
semantic_delete
|
||||
.execute(Command::Batch(vec![
|
||||
Command::Delete(id("activity_a")),
|
||||
Command::Delete(id("activity_b")),
|
||||
]))
|
||||
.unwrap();
|
||||
mismatch(
|
||||
"hide",
|
||||
&before,
|
||||
semantic_delete.project(),
|
||||
"Hide became semantic deletion",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_rejects_valid_semantic_drift_without_geometry_changes() {
|
||||
let before = fixture().unwrap();
|
||||
let mut actual = before.clone();
|
||||
actual
|
||||
.elements
|
||||
.get_mut(&id("activity_b"))
|
||||
.unwrap()
|
||||
.name
|
||||
.push_str(" edited");
|
||||
mismatch(
|
||||
"unchanged",
|
||||
&before,
|
||||
&actual,
|
||||
"geometry equality is not complete Project equality",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
//! Pure diagram edits shared by movement previews and committed transactions.
|
||||
use crate::{Command, Id, MAX_COORDINATE, ModelError, Position, Project, projection};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
/// Translate selected placements and their projected descendants simultaneously.
|
||||
///
|
||||
/// Membership and geometry are read from one snapshot, including folded children.
|
||||
/// Overlapping roots move each placement once. Route pins follow the combined
|
||||
/// movement of their actual endpoint owners (and State boundaries). Semantic
|
||||
/// ownership and other views are untouched. Apply the returned batch through the
|
||||
/// normal validated command path; its selected identity is not a primary selection.
|
||||
pub fn translate_placements(
|
||||
project: &Project,
|
||||
diagram: Id,
|
||||
selected: &BTreeSet<Id>,
|
||||
delta: Position,
|
||||
) -> Result<Command, ModelError> {
|
||||
let view = project
|
||||
.diagrams
|
||||
.get(&diagram)
|
||||
.ok_or_else(|| ModelError("Movement requires an existing diagram.".into()))?;
|
||||
if selected.is_empty() {
|
||||
return Err(ModelError(
|
||||
"Movement requires at least one placement.".into(),
|
||||
));
|
||||
}
|
||||
if selected.iter().any(|id| !view.nodes.contains_key(id)) {
|
||||
return Err(ModelError(
|
||||
"Every selected placement must belong to the diagram.".into(),
|
||||
));
|
||||
}
|
||||
if !delta.x.is_finite() || !delta.y.is_finite() {
|
||||
return Err(ModelError("Movement displacement must be finite.".into()));
|
||||
}
|
||||
if delta.x == 0. && delta.y == 0. {
|
||||
return Ok(Command::Batch(vec![]));
|
||||
}
|
||||
let mut children: BTreeMap<Id, Vec<Id>> = BTreeMap::new();
|
||||
for (child, parent) in projection::parents(project, view) {
|
||||
if let Some(parent) = parent {
|
||||
children.entry(parent).or_default().push(child);
|
||||
}
|
||||
}
|
||||
let mut moved = BTreeSet::new();
|
||||
let mut pending: Vec<_> = selected.iter().copied().collect();
|
||||
while let Some(id) = pending.pop() {
|
||||
if moved.insert(id) {
|
||||
pending.extend(children.get(&id).into_iter().flatten().copied());
|
||||
}
|
||||
}
|
||||
let deltas: BTreeMap<_, _> = moved.iter().map(|id| (*id, delta)).collect();
|
||||
let routes = projection::translated_routes(project, view, &deltas);
|
||||
let bounded = |value: f32| value.is_finite() && value.abs() <= MAX_COORDINATE;
|
||||
let mut commands = Vec::with_capacity(moved.len() + routes.len());
|
||||
for id in moved {
|
||||
let mut node = view.nodes[&id].clone();
|
||||
node.position.x += delta.x;
|
||||
node.position.y += delta.y;
|
||||
if ![
|
||||
node.position.x,
|
||||
node.position.y,
|
||||
node.size.width,
|
||||
node.size.height,
|
||||
]
|
||||
.into_iter()
|
||||
.all(bounded)
|
||||
|| node.size.width <= 0.
|
||||
|| node.size.height <= 0.
|
||||
{
|
||||
return Err(ModelError(
|
||||
"Movement produces invalid placement bounds.".into(),
|
||||
));
|
||||
}
|
||||
commands.push(Command::EditNode {
|
||||
diagram,
|
||||
original: id,
|
||||
node,
|
||||
});
|
||||
}
|
||||
for route in routes {
|
||||
if route
|
||||
.waypoints
|
||||
.iter()
|
||||
.any(|point| !bounded(point.x) || !bounded(point.y))
|
||||
{
|
||||
return Err(ModelError("Movement produces invalid route bounds.".into()));
|
||||
}
|
||||
commands.push(Command::SetEdgeRoute { diagram, route });
|
||||
}
|
||||
Ok(Command::Batch(commands))
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Framework-independent OA/SA model, transactional commands and editing services.
|
||||
pub mod analysis_numeric;
|
||||
pub mod data;
|
||||
pub mod diagram_edit;
|
||||
pub mod language;
|
||||
pub mod model;
|
||||
pub mod operations;
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
use oa_core::{diagram_edit::translate_placements, source_project::*, *};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
fn point(x: f32, y: f32) -> Position {
|
||||
Position { x, y }
|
||||
}
|
||||
fn route(edge: Id, x: f32, y: f32) -> EdgeRoute {
|
||||
EdgeRoute {
|
||||
edge,
|
||||
source_attachment: Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Bottom,
|
||||
offset: 0.2,
|
||||
}),
|
||||
target_attachment: Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Top,
|
||||
offset: 0.8,
|
||||
}),
|
||||
waypoints: vec![point(x, y)],
|
||||
label: Some(EdgeLabel {
|
||||
fraction: 0.4,
|
||||
offset: point(3., -9.),
|
||||
}),
|
||||
}
|
||||
}
|
||||
struct Fixture {
|
||||
project: Project,
|
||||
diagram: Id,
|
||||
a: Id,
|
||||
b: Id,
|
||||
child: Id,
|
||||
foreign: Id,
|
||||
}
|
||||
fn fixture() -> Fixture {
|
||||
let mut p = Project::empty();
|
||||
let a = p.add_element(Kind::Activity, Layer::Oa, "A", None).unwrap();
|
||||
let b = p.add_element(Kind::Activity, Layer::Oa, "B", None).unwrap();
|
||||
let c = p.add_element(Kind::Activity, Layer::Oa, "C", None).unwrap();
|
||||
let child = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "Child", Some(a))
|
||||
.unwrap();
|
||||
let internal = p.relate(RelationKind::Interaction, "Internal", a, b);
|
||||
let external = p.relate(RelationKind::Interaction, "External", b, c);
|
||||
let d = p.ensure_diagram(Layer::Oa);
|
||||
let na = p.place(d, a, point(10., 20.));
|
||||
let nb = p.place(d, b, point(400., 20.));
|
||||
let nc = p.place(d, child, point(30., 80.));
|
||||
p.place(d, c, point(800., 20.));
|
||||
p.diagrams.get_mut(&d).unwrap().routes =
|
||||
vec![route(internal, 320., 150.), route(external, 720., 170.)];
|
||||
p.diagrams
|
||||
.get_mut(&d)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&na)
|
||||
.unwrap()
|
||||
.collapsed = true;
|
||||
let other = Id::new_v4();
|
||||
let mut other_view = p.diagrams[&d].clone();
|
||||
other_view.id = other;
|
||||
other_view.alias = "other_view".into();
|
||||
other_view.nodes.clear();
|
||||
other_view.routes.clear();
|
||||
p.diagrams.insert(other, other_view);
|
||||
let foreign = p.place(other, a, point(-40., -50.));
|
||||
validate_candidate(&p).unwrap();
|
||||
Fixture {
|
||||
project: p,
|
||||
diagram: d,
|
||||
a: na,
|
||||
b: nb,
|
||||
child: nc,
|
||||
foreign,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simultaneous_siblings_translate_internal_pins_once_and_preserve_complete_project() {
|
||||
let f = fixture();
|
||||
let before = f.project.clone();
|
||||
let command = translate_placements(
|
||||
&before,
|
||||
f.diagram,
|
||||
&BTreeSet::from([f.a, f.b]),
|
||||
point(40., 60.),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
before, f.project,
|
||||
"command construction must not mutate input"
|
||||
);
|
||||
// Independently enumerated expected edits, without the projection helpers.
|
||||
let mut expected = before.clone();
|
||||
let view = expected.diagrams.get_mut(&f.diagram).unwrap();
|
||||
view.nodes.get_mut(&f.a).unwrap().position = point(50., 80.);
|
||||
view.nodes.get_mut(&f.b).unwrap().position = point(440., 80.);
|
||||
view.nodes.get_mut(&f.child).unwrap().position = point(70., 140.);
|
||||
view.routes[0].waypoints = vec![point(360., 210.)];
|
||||
let mut session = Session::new(before.clone());
|
||||
session.execute(command.clone()).unwrap();
|
||||
assert_eq!(session.project(), &expected);
|
||||
session.undo();
|
||||
assert_eq!(session.project(), &before);
|
||||
assert!(
|
||||
!session.can_undo(),
|
||||
"one movement must be one history entry"
|
||||
);
|
||||
session.redo();
|
||||
assert_eq!(session.project(), &expected);
|
||||
assert!(!session.can_redo());
|
||||
|
||||
// Independent one-node moves cannot see the shared endpoint displacement.
|
||||
let mut sequential = Session::new(before.clone());
|
||||
for (node, position) in [(f.a, point(50., 80.)), (f.b, point(440., 80.))] {
|
||||
sequential
|
||||
.execute(Command::MoveNode {
|
||||
diagram: f.diagram,
|
||||
node,
|
||||
position,
|
||||
with_descendants: true,
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(
|
||||
sequential.project().diagrams[&f.diagram].routes[0],
|
||||
before.diagrams[&f.diagram].routes[0]
|
||||
);
|
||||
assert_ne!(sequential.project(), &expected);
|
||||
|
||||
let json = serde_json::to_string(&expected).unwrap();
|
||||
assert_eq!(serde_json::from_str::<Project>(&json).unwrap(), expected);
|
||||
let mut bundle = format_bundle(&before);
|
||||
bundle
|
||||
.files
|
||||
.get_mut(&bundle.entry.clone())
|
||||
.unwrap()
|
||||
.insert_str(0, "// preserve movement rationale\n");
|
||||
let original = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
|
||||
let prepared = prepare_command(&before, &command).unwrap();
|
||||
let updated = update_bundle_with_identity_map(
|
||||
&original,
|
||||
&prepared.project,
|
||||
&prepared.identity_map,
|
||||
ProjectLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(updated.decoded.project, expected);
|
||||
assert!(updated.bundle.files[&bundle.entry].starts_with("// preserve movement rationale\n"));
|
||||
let reread = compile_bundle(&updated.bundle, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(reread.decoded.project, expected);
|
||||
assert_eq!(reread.bundle, updated.bundle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selecting_folded_ancestor_and_hidden_descendant_moves_each_once() {
|
||||
let f = fixture();
|
||||
assert!(
|
||||
!projection::project_view(&f.project, f.diagram)
|
||||
.nodes
|
||||
.iter()
|
||||
.any(|n| n.id == f.child)
|
||||
);
|
||||
let ancestor = translate_placements(
|
||||
&f.project,
|
||||
f.diagram,
|
||||
&BTreeSet::from([f.a]),
|
||||
point(-15., 25.),
|
||||
)
|
||||
.unwrap();
|
||||
let overlap = translate_placements(
|
||||
&f.project,
|
||||
f.diagram,
|
||||
&BTreeSet::from([f.a, f.child]),
|
||||
point(-15., 25.),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(overlap, ancestor);
|
||||
let after = prepare_command(&f.project, &overlap).unwrap().project;
|
||||
assert_eq!(
|
||||
after.diagrams[&f.diagram].nodes[&f.child].position,
|
||||
point(15., 105.)
|
||||
);
|
||||
assert_eq!(
|
||||
after.diagrams[&f.diagram].routes,
|
||||
f.project.diagrams[&f.diagram].routes
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_movements_refuse_atomically_and_zero_has_no_history() {
|
||||
let f = fixture();
|
||||
let selected = BTreeSet::from([f.a, f.b]);
|
||||
for (diagram, ids, delta) in [
|
||||
(Id::new_v4(), selected.clone(), point(1., 1.)),
|
||||
(f.diagram, BTreeSet::new(), point(1., 1.)),
|
||||
(
|
||||
f.diagram,
|
||||
BTreeSet::from([f.a, Id::new_v4()]),
|
||||
point(1., 1.),
|
||||
),
|
||||
(f.diagram, BTreeSet::from([f.a, f.foreign]), point(0., 0.)),
|
||||
(f.diagram, selected.clone(), point(f32::NAN, 1.)),
|
||||
(f.diagram, selected.clone(), point(1., f32::INFINITY)),
|
||||
(f.diagram, selected.clone(), point(MAX_COORDINATE, 0.)),
|
||||
(f.diagram, selected.clone(), point(f32::MAX, f32::MAX)),
|
||||
] {
|
||||
let before = f.project.clone();
|
||||
assert!(translate_placements(&f.project, diagram, &ids, delta).is_err());
|
||||
assert_eq!(f.project, before);
|
||||
}
|
||||
let mut bad_route = f.project.clone();
|
||||
bad_route.diagrams.get_mut(&f.diagram).unwrap().routes[0].waypoints =
|
||||
vec![point(MAX_COORDINATE, 0.)];
|
||||
validate_candidate(&bad_route).unwrap();
|
||||
let snapshot = bad_route.clone();
|
||||
assert!(translate_placements(&bad_route, f.diagram, &selected, point(1., 0.)).is_err());
|
||||
assert_eq!(bad_route, snapshot);
|
||||
let zero = translate_placements(&f.project, f.diagram, &selected, point(0., -0.)).unwrap();
|
||||
assert_eq!(zero, Command::Batch(vec![]));
|
||||
let mut session = Session::new(f.project.clone());
|
||||
session.execute(zero).unwrap();
|
||||
assert_eq!(session.project(), &f.project);
|
||||
assert!(!session.can_undo());
|
||||
assert_eq!(session.revision(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_component_port_endpoints_follow_their_owner_placements() {
|
||||
let mut p = Project::empty();
|
||||
let a = p
|
||||
.add_element(Kind::System, Layer::Sa, "System", None)
|
||||
.unwrap();
|
||||
let b = p
|
||||
.add_element(Kind::SystemActor, Layer::Sa, "Actor", None)
|
||||
.unwrap();
|
||||
let output = Id::new_v4();
|
||||
let input = Id::new_v4();
|
||||
for (id, owner, name, orientation) in [
|
||||
(output, a, "Output", ComponentPortOrientation::Out),
|
||||
(input, b, "Input", ComponentPortOrientation::In),
|
||||
] {
|
||||
let port = make_port(
|
||||
&p,
|
||||
id,
|
||||
owner,
|
||||
name,
|
||||
PortContract::Component {
|
||||
kind: ComponentPortKind::Flow,
|
||||
orientation,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
p.elements.insert(id, port);
|
||||
}
|
||||
let edge = p.relate(RelationKind::BoundaryExchange, "Boundary", output, input);
|
||||
let d = p.ensure_diagram(Layer::Sa);
|
||||
let na = p.place(d, a, point(0., 0.));
|
||||
let nb = p.place(d, b, point(400., 0.));
|
||||
for (node, port, side, offset) in [
|
||||
(na, output, PortAnchorSide::Bottom, 0.3),
|
||||
(nb, input, PortAnchorSide::Top, 0.7),
|
||||
] {
|
||||
p.diagrams
|
||||
.get_mut(&d)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&node)
|
||||
.unwrap()
|
||||
.port_anchors
|
||||
.push(PortAnchor { port, side, offset });
|
||||
}
|
||||
p.diagrams
|
||||
.get_mut(&d)
|
||||
.unwrap()
|
||||
.routes
|
||||
.push(route(edge, 300., 100.));
|
||||
validate_candidate(&p).unwrap();
|
||||
let command = translate_placements(&p, d, &BTreeSet::from([na, nb]), point(10., -20.)).unwrap();
|
||||
let after = prepare_command(&p, &command).unwrap().project;
|
||||
let mut expected = p.clone();
|
||||
let view = expected.diagrams.get_mut(&d).unwrap();
|
||||
view.nodes.get_mut(&na).unwrap().position = point(10., -20.);
|
||||
view.nodes.get_mut(&nb).unwrap().position = point(410., -20.);
|
||||
view.routes[0].waypoints = vec![point(310., 80.)];
|
||||
assert_eq!(after, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allocation_movement_follows_visual_performers_and_same_performer_work_groups() {
|
||||
let mut p = Project::empty();
|
||||
let performer_a = p
|
||||
.add_element(Kind::Entity, Layer::Oa, "Performer A", None)
|
||||
.unwrap();
|
||||
let performer_b = p
|
||||
.add_element(Kind::Entity, Layer::Oa, "Performer B", None)
|
||||
.unwrap();
|
||||
let mixed = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "Mixed work", None)
|
||||
.unwrap();
|
||||
let branch = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "A work", Some(mixed))
|
||||
.unwrap();
|
||||
let leaf_a = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "A leaf", Some(branch))
|
||||
.unwrap();
|
||||
let leaf_b = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "B leaf", Some(mixed))
|
||||
.unwrap();
|
||||
p.relate(RelationKind::Allocation, "A performs", performer_a, leaf_a);
|
||||
p.relate(RelationKind::Allocation, "B performs", performer_b, leaf_b);
|
||||
let d = p.ensure_diagram(Layer::Oa);
|
||||
p.diagrams.get_mut(&d).unwrap().projection = ProjectionKind::Allocation;
|
||||
let na = p.place(d, performer_a, point(0., 0.));
|
||||
p.place(d, performer_b, point(600., 0.));
|
||||
let nm = p.place(d, mixed, point(1000., 0.));
|
||||
let branch_node = p.place(d, branch, point(20., 80.));
|
||||
let leaf_node = p.place(d, leaf_a, point(40., 160.));
|
||||
p.place(d, leaf_b, point(620., 80.));
|
||||
validate_candidate(&p).unwrap();
|
||||
|
||||
// Mixed work retains semantic ownership of both branches, but its leaves
|
||||
// have different performers. A work is visually nested under Performer A,
|
||||
// with its own same-performer leaf nested inside it.
|
||||
let command =
|
||||
translate_placements(&p, d, &BTreeSet::from([na, branch_node]), point(15., -25.)).unwrap();
|
||||
let after = prepare_command(&p, &command).unwrap().project;
|
||||
let mut expected = p.clone();
|
||||
let view = expected.diagrams.get_mut(&d).unwrap();
|
||||
view.nodes.get_mut(&na).unwrap().position = point(15., -25.);
|
||||
view.nodes.get_mut(&branch_node).unwrap().position = point(35., 55.);
|
||||
view.nodes.get_mut(&leaf_node).unwrap().position = point(55., 135.);
|
||||
assert_eq!(after, expected);
|
||||
|
||||
// Moving the semantic parent does not move children displayed elsewhere.
|
||||
let command = translate_placements(&p, d, &BTreeSet::from([nm]), point(15., -25.)).unwrap();
|
||||
let after = prepare_command(&p, &command).unwrap().project;
|
||||
let mut expected = p.clone();
|
||||
expected
|
||||
.diagrams
|
||||
.get_mut(&d)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&nm)
|
||||
.unwrap()
|
||||
.position = point(1015., -25.);
|
||||
assert_eq!(after, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn traceability_movement_is_flat_even_for_a_semantic_parent() {
|
||||
let mut f = fixture();
|
||||
f.project.diagrams.get_mut(&f.diagram).unwrap().projection = ProjectionKind::Traceability;
|
||||
validate_candidate(&f.project).unwrap();
|
||||
let command = translate_placements(
|
||||
&f.project,
|
||||
f.diagram,
|
||||
&BTreeSet::from([f.a]),
|
||||
point(40., 60.),
|
||||
)
|
||||
.unwrap();
|
||||
let after = prepare_command(&f.project, &command).unwrap().project;
|
||||
let mut expected = f.project.clone();
|
||||
expected
|
||||
.diagrams
|
||||
.get_mut(&f.diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&f.a)
|
||||
.unwrap()
|
||||
.position = point(50., 80.);
|
||||
assert_eq!(
|
||||
after, expected,
|
||||
"flat view movement must preserve semantic descendants, routes, other views and all semantics"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_entry_pins_follow_the_boundary_only_when_it_also_moves() {
|
||||
let mut p = Project::empty();
|
||||
let machine = p
|
||||
.add_element(Kind::StateMachine, Layer::Oa, "Machine", None)
|
||||
.unwrap();
|
||||
let region = p
|
||||
.add_element(Kind::Region, Layer::Oa, "Region", Some(machine))
|
||||
.unwrap();
|
||||
let composite = p
|
||||
.add_element(Kind::State, Layer::Oa, "Composite", Some(region))
|
||||
.unwrap();
|
||||
let inner = p
|
||||
.add_element(Kind::Region, Layer::Oa, "Inner", Some(composite))
|
||||
.unwrap();
|
||||
let entry = p
|
||||
.add_element(Kind::EntryPoint, Layer::Oa, "Entry", Some(inner))
|
||||
.unwrap();
|
||||
let leaf = p
|
||||
.add_element(Kind::State, Layer::Oa, "Leaf", Some(inner))
|
||||
.unwrap();
|
||||
let edge = p.relate(RelationKind::StateTransition, "Enter", entry, leaf);
|
||||
if let RelationBody::StateTransition {
|
||||
transition_kind, ..
|
||||
} = &mut p.relations.get_mut(&edge).unwrap().body
|
||||
{
|
||||
*transition_kind = TransitionKind::Local;
|
||||
}
|
||||
let d = p.ensure_diagram(Layer::Oa);
|
||||
p.diagrams.get_mut(&d).unwrap().projection = ProjectionKind::StateMachine { machine };
|
||||
let boundary = p.place(d, composite, point(0., 0.));
|
||||
let ne = p.place(d, entry, point(20., 80.));
|
||||
let nl = p.place(d, leaf, point(100., 80.));
|
||||
p.diagrams
|
||||
.get_mut(&d)
|
||||
.unwrap()
|
||||
.routes
|
||||
.push(route(edge, 90., 100.));
|
||||
validate_candidate(&p).unwrap();
|
||||
let endpoints =
|
||||
translate_placements(&p, d, &BTreeSet::from([ne, nl]), point(10., 20.)).unwrap();
|
||||
let after = prepare_command(&p, &endpoints).unwrap().project;
|
||||
assert_eq!(after.diagrams[&d].routes, p.diagrams[&d].routes);
|
||||
let with_boundary =
|
||||
translate_placements(&p, d, &BTreeSet::from([boundary, ne, nl]), point(10., 20.)).unwrap();
|
||||
let after = prepare_command(&p, &with_boundary).unwrap().project;
|
||||
assert_eq!(
|
||||
after.diagrams[&d].routes[0].waypoints,
|
||||
vec![point(100., 120.)]
|
||||
);
|
||||
assert_eq!(after.diagrams[&d].nodes[&ne].position, point(30., 100.));
|
||||
}
|
||||
@@ -60,6 +60,7 @@ impl Root<'_> {
|
||||
return false;
|
||||
}
|
||||
let canvas = DiagramCanvas {
|
||||
selection: self.app.diagram_selection.as_ref(),
|
||||
allocation: Some(self.app.allocation_capability()),
|
||||
project: self.app.project(),
|
||||
view_index: Some(self.app.document.view_index()),
|
||||
|
||||
@@ -15,6 +15,9 @@ mod diagram_reveal;
|
||||
mod diagram_selection_tests;
|
||||
mod diagram_usages;
|
||||
mod hosted;
|
||||
pub(crate) mod multiselect;
|
||||
#[cfg(test)]
|
||||
mod multiselect_tests;
|
||||
mod reference_authoring;
|
||||
#[cfg(test)]
|
||||
mod review_navigation_tests;
|
||||
@@ -364,6 +367,7 @@ pub enum Message {
|
||||
Select(Id),
|
||||
EditProperties(Id),
|
||||
SelectNode(Id),
|
||||
DiagramSelection(multiselect::Event),
|
||||
SelectProject,
|
||||
Search(String),
|
||||
StartTransition,
|
||||
@@ -507,6 +511,7 @@ pub struct App {
|
||||
pub layer: Layer,
|
||||
pub selected: Id,
|
||||
pub selected_node: Option<Id>,
|
||||
pub diagram_selection: Option<crate::diagram_selection::Selection>,
|
||||
pub draft: Option<Draft>,
|
||||
pub active_diagram: Option<Id>,
|
||||
pub search: String,
|
||||
@@ -627,6 +632,7 @@ impl App {
|
||||
layer,
|
||||
selected,
|
||||
selected_node: None,
|
||||
diagram_selection: None,
|
||||
draft,
|
||||
active_diagram,
|
||||
search: String::new(),
|
||||
@@ -1284,6 +1290,13 @@ impl App {
|
||||
])
|
||||
}
|
||||
pub fn update(&mut self, message: Message) -> Task<Message> {
|
||||
let selection_boundary = self.multiselect_boundary();
|
||||
// Reject delayed gestures before they can retire newer input ownership.
|
||||
if let Message::DiagramSelection(event) = &message
|
||||
&& !self.multiselect_envelope(event)
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
// Captured Text commands validate their origin before they can
|
||||
// invalidate that same origin or pass into existing command handlers.
|
||||
if let Message::TextWorkspace(event) = message {
|
||||
@@ -1336,7 +1349,11 @@ impl App {
|
||||
self.reconcile_pending_diagram_reveal(&message);
|
||||
self.allocation_before(&message);
|
||||
if let Message::WorkDrag(event) = message {
|
||||
return self.allocation_event(event);
|
||||
let task = self.allocation_event(event);
|
||||
if self.multiselect_boundary() != selection_boundary {
|
||||
self.reconcile_multiselect();
|
||||
}
|
||||
return task;
|
||||
}
|
||||
let allocation_before = self.allocation_boundary();
|
||||
if let Message::BehaviorControl(intent) = message {
|
||||
@@ -1369,6 +1386,11 @@ impl App {
|
||||
self.camera_epoch = self.camera_epoch.wrapping_add(1);
|
||||
self.camera_boundary_epoch = self.canvas_epoch;
|
||||
}
|
||||
if self.multiselect_boundary() != selection_boundary
|
||||
|| (self.diagram_selection.is_none() && self.page == Page::Diagram)
|
||||
{
|
||||
self.reconcile_multiselect();
|
||||
}
|
||||
task
|
||||
}
|
||||
fn behavior_control(&mut self, intent: behavior::canvas_workspace::Intent) -> Task<Message> {
|
||||
@@ -1732,6 +1754,7 @@ impl App {
|
||||
message,
|
||||
Message::Page(_) | Message::Layer(_) | Message::Diagram(_)
|
||||
) {
|
||||
self.diagram_selection = None;
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
self.selected_bend = None;
|
||||
}
|
||||
@@ -2310,8 +2333,12 @@ impl App {
|
||||
"Apply or revert pending edits before using modeling actions.".into();
|
||||
return Task::none();
|
||||
}
|
||||
if matches!(*action, Message::DiagramSelection(_)) {
|
||||
return self.update(*action);
|
||||
}
|
||||
self.select(menu.request.object);
|
||||
self.selected_node = menu.request.placement;
|
||||
self.reconcile_multiselect();
|
||||
self.selected_bend = menu.request.bend.map(|i| (menu.request.object, i));
|
||||
return self.update(*action);
|
||||
}
|
||||
@@ -2469,6 +2496,7 @@ impl App {
|
||||
self.status = "Property draft reverted".into();
|
||||
}
|
||||
Message::Select(id) => self.select(id),
|
||||
Message::DiagramSelection(event) => self.multiselect_event(event),
|
||||
Message::EditProperties(id) => {
|
||||
self.select(id);
|
||||
self.inspector_tab = crate::navigation::InspectorTab::Properties;
|
||||
@@ -3126,7 +3154,21 @@ impl App {
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::DiagramRemove(intent) => self.remove_from_diagram(intent),
|
||||
Message::DiagramRemove(intent) => {
|
||||
if self
|
||||
.multiselect_snapshot()
|
||||
.is_some_and(|s| s.targets().len() > 1)
|
||||
{
|
||||
// A pre-redraw singleton Delete cannot remove just one
|
||||
// member of a selection changed earlier in the same batch.
|
||||
if Some(intent.context) == self.route_context() {
|
||||
self.status =
|
||||
"Selection changed. Use Hide selected or press Delete again.".into();
|
||||
}
|
||||
} else {
|
||||
self.remove_from_diagram(intent);
|
||||
}
|
||||
}
|
||||
Message::DiagramTraverse(back) => {
|
||||
return if back {
|
||||
iced::widget::operation::focus_previous()
|
||||
@@ -3135,6 +3177,13 @@ impl App {
|
||||
};
|
||||
}
|
||||
Message::Delete => {
|
||||
if self
|
||||
.multiselect_snapshot()
|
||||
.is_some_and(|s| s.targets().len() > 1)
|
||||
{
|
||||
self.status = "Group model-deletion review is not available yet. Select one element or use Hide selected.".into();
|
||||
return Task::none();
|
||||
}
|
||||
if self.selected != self.project().id && self.apply() {
|
||||
self.begin_model_delete(None);
|
||||
}
|
||||
@@ -3266,6 +3315,13 @@ impl App {
|
||||
}
|
||||
}
|
||||
Message::HideSelected => {
|
||||
if let Some(snapshot) = self
|
||||
.multiselect_snapshot()
|
||||
.filter(|s| s.targets().len() > 1)
|
||||
{
|
||||
self.multiselect_event(multiselect::Event::Hide(snapshot));
|
||||
return Task::none();
|
||||
}
|
||||
if self.apply()
|
||||
&& let Some(node) = self.selected_node
|
||||
{
|
||||
@@ -3295,6 +3351,13 @@ impl App {
|
||||
}
|
||||
Message::Route(event) => self.edit_route(event),
|
||||
Message::MoveNode(node, position) => {
|
||||
if self
|
||||
.multiselect_snapshot()
|
||||
.is_some_and(|s| s.targets().len() > 1)
|
||||
{
|
||||
self.status = "Selection changed. Start the group drag again.".into();
|
||||
return Task::none();
|
||||
}
|
||||
let original = self.selected;
|
||||
if self.apply()
|
||||
&& let Some(diagram) = self.active_diagram
|
||||
|
||||
@@ -59,6 +59,7 @@ fn fixture(layer: Layer) -> Fixture {
|
||||
}
|
||||
fn canvas(app: &App) -> DiagramCanvas<'_> {
|
||||
DiagramCanvas {
|
||||
selection: None,
|
||||
allocation: Some(app.allocation_capability()),
|
||||
project: app.project(),
|
||||
view_index: Some(app.document.view_index()),
|
||||
|
||||
@@ -66,6 +66,7 @@ fn architecture_f2_routes_selected_node_to_inline_rename_and_exact_undo() {
|
||||
let camera = (app.zoom, app.pan);
|
||||
let bounds = iced::Rectangle::new(iced::Point::ORIGIN, iced::Size::new(600., 380.));
|
||||
let canvas = crate::diagram::DiagramCanvas {
|
||||
selection: None,
|
||||
project: app.project(),
|
||||
view_index: None,
|
||||
diagram: app.active_diagram.unwrap(),
|
||||
|
||||
@@ -221,6 +221,7 @@ fn architecture_canvas_left_click_uses_world_coordinate_once_at_zoom_and_pan() {
|
||||
y: (local.y - app.pan.y) / app.zoom,
|
||||
};
|
||||
let canvas = crate::diagram::DiagramCanvas {
|
||||
selection: None,
|
||||
project: app.project(),
|
||||
view_index: None,
|
||||
diagram: app.active_diagram.unwrap(),
|
||||
@@ -298,6 +299,7 @@ fn architecture_f2_rename_preserves_identity_placement_and_atomic_history() {
|
||||
let diagrams = app.project().diagrams.clone();
|
||||
let camera = (app.zoom, app.pan);
|
||||
let canvas = crate::diagram::DiagramCanvas {
|
||||
selection: None,
|
||||
project: app.project(),
|
||||
view_index: None,
|
||||
diagram: app.active_diagram.unwrap(),
|
||||
|
||||
@@ -154,3 +154,440 @@ async fn mounted_f2_requires_canvas_focus_and_yields_to_clean_inspector() {
|
||||
}
|
||||
}
|
||||
}
|
||||
async fn modifier_focus_fixture() -> (App, Mounted, Id, Id, Id) {
|
||||
let mut project = Project::empty();
|
||||
let a = project
|
||||
.add_element(Kind::Entity, Layer::Oa, "First team", None)
|
||||
.unwrap();
|
||||
let b = project
|
||||
.add_element(Kind::Entity, Layer::Oa, "Second team", None)
|
||||
.unwrap();
|
||||
let diagram = project.ensure_diagram(Layer::Oa);
|
||||
let first = project.place(diagram, a, Position { x: 80., y: 80. });
|
||||
let second = project.place(diagram, b, Position { x: 500., y: 80. });
|
||||
let mut app = App::with_project(project);
|
||||
app.page = Page::Diagram;
|
||||
app.layer = Layer::Oa;
|
||||
app.active_diagram = Some(diagram);
|
||||
app.window_size = Size::new(2000., 1400.);
|
||||
let _ = app.update(Message::Select(a));
|
||||
let mut mounted = Mounted::new(&mut app).await;
|
||||
app.zoom = 1.;
|
||||
app.pan = Vector::ZERO;
|
||||
app.fit_to_view = false;
|
||||
mounted.settle(&mut app).await;
|
||||
(app, mounted, diagram, first, second)
|
||||
}
|
||||
|
||||
fn modifier_node_point(mounted: &mut Mounted, app: &App, diagram: Id, placement: Id) -> Point {
|
||||
let bounds = mounted
|
||||
.geometry(app)
|
||||
.bounds(crate::allocation_input::CANVAS_ID);
|
||||
let node = projection::project_view(app.project(), diagram)
|
||||
.nodes
|
||||
.into_iter()
|
||||
.find(|n| n.id == placement)
|
||||
.unwrap();
|
||||
Point::new(
|
||||
bounds.x + node.position.x + 40.,
|
||||
bounds.y + node.position.y + 35.,
|
||||
)
|
||||
}
|
||||
|
||||
fn modifier_canvas_focused(mounted: &mut Mounted, app: &App) -> bool {
|
||||
mounted
|
||||
.geometry(app)
|
||||
.focused
|
||||
.iter()
|
||||
.find(|(id, _, _)| {
|
||||
id.as_ref() == Some(&iced::advanced::widget::Id::from("diagram-keyboard-owner"))
|
||||
})
|
||||
.expect("production focus::Owner is mounted")
|
||||
.2
|
||||
}
|
||||
|
||||
fn modifier_event(shift: bool) -> iced::Event {
|
||||
iced::Event::Keyboard(iced::keyboard::Event::ModifiersChanged(if shift {
|
||||
iced::keyboard::Modifiers::SHIFT
|
||||
} else {
|
||||
iced::keyboard::Modifiers::default()
|
||||
}))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_shift_before_canvas_focus_toggles_exact_node_without_granting_keyboard_focus() {
|
||||
use crate::diagram_usage::Target;
|
||||
let (mut app, mut mounted, diagram, first, second) = modifier_focus_fixture().await;
|
||||
let before = app.document.snapshot();
|
||||
let field = mounted
|
||||
.geometry(&app)
|
||||
.text_inputs
|
||||
.into_iter()
|
||||
.filter(|bounds| bounds.x > app.window_size.width - 320.)
|
||||
.min_by(|a, b| a.y.total_cmp(&b.y))
|
||||
.expect("clean Inspector name input");
|
||||
mounted.operate(&app, &mut FocusField(field));
|
||||
assert!(!modifier_canvas_focused(&mut mounted, &app));
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
&[Target::Placement(first)]
|
||||
);
|
||||
mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[modifier_event(true)],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
!modifier_canvas_focused(&mut mounted, &app),
|
||||
"modifier tracking must not acquire focus"
|
||||
);
|
||||
let at = modifier_node_point(&mut mounted, &app, diagram, second);
|
||||
let messages = mounted.click(&mut app, at).await;
|
||||
assert!(messages.iter().any(|m| matches!(m,
|
||||
Message::DiagramSelection(multiselect::Event::Choose(_, Target::Placement(id), multiselect::Choice::Toggle)) if *id == second
|
||||
)), "Shift already held before the first canvas press must reach its exact-target adapter");
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
&[Target::Placement(first), Target::Placement(second)]
|
||||
);
|
||||
assert_eq!(app.selected_node, Some(second));
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
assert!(!app.draft_dirty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_unfocused_typing_and_delete_remain_denied_after_passive_modifiers() {
|
||||
use crate::diagram_usage::Target;
|
||||
let (mut app, mut mounted, diagram, first, second) = modifier_focus_fixture().await;
|
||||
mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[modifier_event(true)],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
let at = modifier_node_point(&mut mounted, &app, diagram, second);
|
||||
mounted.click(&mut app, at).await;
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
&[Target::Placement(first), Target::Placement(second)]
|
||||
);
|
||||
// Revoke every field's focus through the real widget operation. No widget
|
||||
// has this off-window rectangle, so neither Inspector nor canvas owns keys.
|
||||
mounted.operate(
|
||||
&app,
|
||||
&mut FocusField(Rectangle {
|
||||
x: -1000.,
|
||||
y: -1000.,
|
||||
width: 1.,
|
||||
height: 1.,
|
||||
}),
|
||||
);
|
||||
assert!(!modifier_canvas_focused(&mut mounted, &app));
|
||||
let before = app.document.snapshot();
|
||||
let selection = app.multiselect_snapshot().unwrap();
|
||||
let messages = mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[
|
||||
modifier_event(false),
|
||||
crate::behavior::canvas_workspace::tests::character("x"),
|
||||
key(Named::Delete),
|
||||
key(Named::F2),
|
||||
],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
assert!(!messages.iter().any(|m| matches!(
|
||||
m,
|
||||
Message::DiagramSelection(_)
|
||||
| Message::DiagramRemove(_)
|
||||
| Message::HideSelected
|
||||
| Message::Delete
|
||||
)));
|
||||
assert!(!rename_emitted(&messages));
|
||||
assert!(!modifier_canvas_focused(&mut mounted, &app));
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
selection.targets()
|
||||
);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
assert!(!app.draft_dirty());
|
||||
assert!(!app.delete_pending);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_suspended_modifiers_are_passive_and_survive_until_resumed_click() {
|
||||
use crate::diagram_usage::Target;
|
||||
let (mut app, mut mounted, diagram, first, second) = modifier_focus_fixture().await;
|
||||
let before = app.document.snapshot();
|
||||
let at = modifier_node_point(&mut mounted, &app, diagram, second);
|
||||
app.busy = true;
|
||||
mounted.settle(&mut app).await;
|
||||
assert!(!modifier_canvas_focused(&mut mounted, &app));
|
||||
mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[modifier_event(true), key(Named::Delete)],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
mounted.click(&mut app, at).await;
|
||||
assert!(
|
||||
!modifier_canvas_focused(&mut mounted, &app),
|
||||
"passive I/O cannot grant canvas focus"
|
||||
);
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
&[Target::Placement(first)]
|
||||
);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
app.busy = false;
|
||||
mounted.settle(&mut app).await;
|
||||
assert!(!modifier_canvas_focused(&mut mounted, &app));
|
||||
mounted.click(&mut app, at).await;
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
&[Target::Placement(first), Target::Placement(second)]
|
||||
);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_shift_held_before_suspension_survives_without_another_modifier_event() {
|
||||
use crate::diagram_usage::Target;
|
||||
let (mut app, mut mounted, diagram, first, second) = modifier_focus_fixture().await;
|
||||
let before = app.document.snapshot();
|
||||
let field = mounted
|
||||
.geometry(&app)
|
||||
.text_inputs
|
||||
.into_iter()
|
||||
.filter(|bounds| bounds.x > app.window_size.width - 320.)
|
||||
.min_by(|a, b| a.y.total_cmp(&b.y))
|
||||
.expect("clean Inspector name input");
|
||||
mounted.operate(&app, &mut FocusField(field));
|
||||
mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[modifier_event(true)],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
assert!(!modifier_canvas_focused(&mut mounted, &app));
|
||||
app.busy = true;
|
||||
mounted.settle(&mut app).await;
|
||||
assert!(!modifier_canvas_focused(&mut mounted, &app));
|
||||
app.busy = false;
|
||||
mounted.settle(&mut app).await;
|
||||
assert!(
|
||||
!modifier_canvas_focused(&mut mounted, &app),
|
||||
"restoring passive modifiers must not grant keyboard focus"
|
||||
);
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
&[Target::Placement(first)]
|
||||
);
|
||||
// No modifier transition follows the subtree replacement during I/O.
|
||||
let at = modifier_node_point(&mut mounted, &app, diagram, second);
|
||||
let messages = mounted.click(&mut app, at).await;
|
||||
assert!(messages.iter().any(|m| matches!(m,
|
||||
Message::DiagramSelection(multiselect::Event::Choose(_, Target::Placement(id), multiselect::Choice::Toggle)) if *id == second
|
||||
)));
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
&[Target::Placement(first), Target::Placement(second)]
|
||||
);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
assert!(!app.draft_dirty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_shift_release_or_window_unfocus_during_suspension_does_not_restore_stale_shift() {
|
||||
use crate::diagram_usage::Target;
|
||||
for release in [
|
||||
modifier_event(false),
|
||||
iced::Event::Window(iced::window::Event::Unfocused),
|
||||
] {
|
||||
let (mut app, mut mounted, diagram, first, second) = modifier_focus_fixture().await;
|
||||
let before = app.document.snapshot();
|
||||
mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[modifier_event(true)],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
app.busy = true;
|
||||
mounted.settle(&mut app).await;
|
||||
mounted
|
||||
.batch(&mut app, &[release], mouse::Cursor::Unavailable)
|
||||
.await;
|
||||
assert!(!modifier_canvas_focused(&mut mounted, &app));
|
||||
app.busy = false;
|
||||
mounted.settle(&mut app).await;
|
||||
// A further I/O cycle must restore the updated owner state too.
|
||||
app.busy = true;
|
||||
mounted.settle(&mut app).await;
|
||||
app.busy = false;
|
||||
mounted.settle(&mut app).await;
|
||||
assert!(!modifier_canvas_focused(&mut mounted, &app));
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
&[Target::Placement(first)]
|
||||
);
|
||||
let at = modifier_node_point(&mut mounted, &app, diagram, second);
|
||||
let messages = mounted.click(&mut app, at).await;
|
||||
assert!(!messages.iter().any(|m| matches!(
|
||||
m,
|
||||
Message::DiagramSelection(multiselect::Event::Choose(
|
||||
_,
|
||||
_,
|
||||
multiselect::Choice::Toggle
|
||||
))
|
||||
)));
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
&[Target::Placement(second)]
|
||||
);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
assert!(!app.draft_dirty());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_window_unfocus_resets_held_shift_before_next_canvas_press() {
|
||||
use crate::diagram_usage::Target;
|
||||
let (mut app, mut mounted, diagram, first, second) = modifier_focus_fixture().await;
|
||||
let at_first = modifier_node_point(&mut mounted, &app, diagram, first);
|
||||
mounted.click(&mut app, at_first).await;
|
||||
let before = app.document.snapshot();
|
||||
mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[
|
||||
modifier_event(true),
|
||||
iced::Event::Window(iced::window::Event::Unfocused),
|
||||
],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
assert!(!modifier_canvas_focused(&mut mounted, &app));
|
||||
// No ModifiersChanged(empty) follows: a lost window must itself reset it.
|
||||
let at_second = modifier_node_point(&mut mounted, &app, diagram, second);
|
||||
mounted.click(&mut app, at_second).await;
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
&[Target::Placement(second)]
|
||||
);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_selection_strip_keeps_canvas_and_fitted_camera_fixed_for_empty_single_group_and_mixed()
|
||||
{
|
||||
use crate::diagram_usage::Target;
|
||||
let mut project = Project::empty();
|
||||
let a = project
|
||||
.add_element(Kind::Activity, Layer::Oa, "First activity", None)
|
||||
.unwrap();
|
||||
let b = project
|
||||
.add_element(
|
||||
Kind::Activity,
|
||||
Layer::Oa,
|
||||
&format!(
|
||||
"Second activity {}",
|
||||
"with a very long primary name ".repeat(20)
|
||||
),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let edge = project.relate(RelationKind::Interaction, "Activity exchange", a, b);
|
||||
let diagram = project.ensure_diagram(Layer::Oa);
|
||||
project.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Overview;
|
||||
let first = project.place(diagram, a, Position { x: 80., y: 80. });
|
||||
let second = project.place(diagram, b, Position { x: 500., y: 80. });
|
||||
let mut app = App::with_project(project);
|
||||
app.page = Page::Diagram;
|
||||
app.layer = Layer::Oa;
|
||||
app.active_diagram = Some(diagram);
|
||||
app.window_size = Size::new(1442., 932.);
|
||||
let _ = app.update(Message::Select(diagram));
|
||||
app.fit_to_view = true;
|
||||
let mut mounted = Mounted::new(&mut app).await;
|
||||
let baseline = app.document.snapshot();
|
||||
let bounds = mounted
|
||||
.geometry(&app)
|
||||
.bounds(crate::allocation_input::CANVAS_ID);
|
||||
let camera = (app.zoom, app.pan, app.fit_to_view, app.canvas_size);
|
||||
assert!(camera.2, "exercise the native resize-triggered refit path");
|
||||
assert!(app.multiselect_snapshot().unwrap().targets().is_empty());
|
||||
assert!(bounds.width > 500. && bounds.height > 300.);
|
||||
for (target, expected) in [
|
||||
(
|
||||
Some(Target::Placement(first)),
|
||||
vec![Target::Placement(first)],
|
||||
),
|
||||
(
|
||||
Some(Target::Placement(second)),
|
||||
vec![Target::Placement(first), Target::Placement(second)],
|
||||
),
|
||||
(
|
||||
Some(Target::Edge(edge)),
|
||||
vec![
|
||||
Target::Placement(first),
|
||||
Target::Placement(second),
|
||||
Target::Edge(edge),
|
||||
],
|
||||
),
|
||||
(
|
||||
Some(Target::Edge(edge)),
|
||||
vec![Target::Placement(first), Target::Placement(second)],
|
||||
),
|
||||
(
|
||||
Some(Target::Placement(second)),
|
||||
vec![Target::Placement(first)],
|
||||
),
|
||||
(None, vec![]),
|
||||
] {
|
||||
let context = app.multiselect_context().unwrap();
|
||||
let event = match target {
|
||||
Some(target) => {
|
||||
multiselect::Event::Choose(context, target, multiselect::Choice::Toggle)
|
||||
}
|
||||
None => multiselect::Event::Clear(context),
|
||||
};
|
||||
let task = app.update(Message::DiagramSelection(event));
|
||||
mounted.perform(&mut app, task).await;
|
||||
mounted.settle(&mut app).await;
|
||||
assert_eq!(app.multiselect_snapshot().unwrap().targets(), expected);
|
||||
assert_eq!(
|
||||
mounted
|
||||
.geometry(&app)
|
||||
.bounds(crate::allocation_input::CANVAS_ID),
|
||||
bounds,
|
||||
"selection feedback must not change the measured canvas rectangle"
|
||||
);
|
||||
assert_eq!(
|
||||
(app.zoom, app.pan, app.fit_to_view, app.canvas_size),
|
||||
camera
|
||||
);
|
||||
assert_eq!(app.project(), &baseline.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &baseline.bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
assert!(!app.draft_dirty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
//! Exact saved-view selection and atomic group commands.
|
||||
use super::*;
|
||||
use crate::diagram_selection::{Context, Scope, Selection, Snapshot};
|
||||
use crate::diagram_usage::{Surface, Target};
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) enum Choice {
|
||||
Replace,
|
||||
Add,
|
||||
Remove,
|
||||
Toggle,
|
||||
Inspect,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) enum Event {
|
||||
Choose(Context, Target, Choice),
|
||||
Collapse(Snapshot, Target),
|
||||
Clear(Context),
|
||||
Move(Snapshot, Position),
|
||||
Hide(Snapshot),
|
||||
Refuse(Snapshot, String),
|
||||
}
|
||||
|
||||
pub(crate) fn context(c: crate::route_edit::Context) -> Context {
|
||||
Context {
|
||||
scope: Scope {
|
||||
document: c.document,
|
||||
surface: Surface::Saved(c.diagram),
|
||||
},
|
||||
revision: c.revision,
|
||||
epoch: c.epoch,
|
||||
}
|
||||
}
|
||||
|
||||
/// The entire typed set must support a placement action. Never filter a mixture.
|
||||
pub(crate) fn placements(snapshot: &Snapshot) -> Option<std::collections::BTreeSet<Id>> {
|
||||
snapshot.primary()?;
|
||||
if snapshot.targets().is_empty() {
|
||||
return None;
|
||||
}
|
||||
snapshot
|
||||
.targets()
|
||||
.iter()
|
||||
.map(|target| match target {
|
||||
Target::Placement(id) => Some(*id),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn visible(view: &projection::ProjectedView, target: &Target) -> bool {
|
||||
match target {
|
||||
Target::Placement(id) => view.nodes.iter().any(|n| n.id == *id),
|
||||
Target::Port { port, placement } => view
|
||||
.ports
|
||||
.iter()
|
||||
.any(|p| p.element == *port && p.owner == *placement),
|
||||
Target::Edge(id) => view.edges.iter().any(|e| e.id == *id),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub(crate) fn multiselect_boundary(&self) -> impl PartialEq + use<> {
|
||||
(
|
||||
self.canvas_document,
|
||||
self.document.revision(),
|
||||
self.page,
|
||||
self.active_diagram,
|
||||
self.selected,
|
||||
self.selected_node,
|
||||
)
|
||||
}
|
||||
pub(crate) fn multiselect_context(&self) -> Option<Context> {
|
||||
// Other surfaces retain their existing adapters until their typed group
|
||||
// operations are implemented; never reuse an Architecture selection there.
|
||||
(self.page == Page::Diagram)
|
||||
.then(|| self.route_context().map(context))
|
||||
.flatten()
|
||||
}
|
||||
|
||||
pub(crate) fn multiselect_snapshot(&self) -> Option<Snapshot> {
|
||||
self.diagram_selection
|
||||
.as_ref()?
|
||||
.snapshot(self.multiselect_context()?)
|
||||
}
|
||||
|
||||
pub(crate) fn multiselect_envelope(&self, event: &Event) -> bool {
|
||||
let Some(current) = self.multiselect_context() else {
|
||||
return false;
|
||||
};
|
||||
match event {
|
||||
Event::Choose(c, ..) | Event::Clear(c) => *c == current,
|
||||
Event::Move(s, _) | Event::Hide(s) | Event::Refuse(s, _) | Event::Collapse(s, _) => {
|
||||
self.diagram_selection
|
||||
.as_ref()
|
||||
.is_some_and(|selection| selection.matches(s, current))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn legacy_target(&self, view: &projection::ProjectedView) -> Option<Target> {
|
||||
if let Some(id) = self.selected_node
|
||||
&& view.nodes.iter().any(|node| {
|
||||
node.id == id && (node.element == self.selected || node.id == self.selected)
|
||||
})
|
||||
{
|
||||
return Some(Target::Placement(id));
|
||||
}
|
||||
if let Some(port) = view.ports.iter().find(|port| port.element == self.selected) {
|
||||
return Some(Target::Port {
|
||||
port: port.element,
|
||||
placement: port.owner,
|
||||
});
|
||||
}
|
||||
view.edges
|
||||
.iter()
|
||||
.any(|edge| edge.id == self.selected)
|
||||
.then_some(Target::Edge(self.selected))
|
||||
}
|
||||
|
||||
pub(crate) fn reconcile_multiselect(&mut self) {
|
||||
let Some(current) = self.multiselect_context() else {
|
||||
self.diagram_selection = None;
|
||||
return;
|
||||
};
|
||||
let Surface::Saved(diagram) = current.scope.surface else {
|
||||
return;
|
||||
};
|
||||
let view = projection::project_view_with_index(
|
||||
self.project(),
|
||||
diagram,
|
||||
self.document.view_index(),
|
||||
);
|
||||
let legacy = self.legacy_target(&view);
|
||||
let lost_primary = self
|
||||
.diagram_selection
|
||||
.as_ref()
|
||||
.filter(|s| s.scope() == current.scope)
|
||||
.and_then(|s| s.primary())
|
||||
.is_some_and(|target| !visible(&view, target));
|
||||
let held_primary = self
|
||||
.diagram_selection
|
||||
.as_ref()
|
||||
.filter(|s| s.scope() == current.scope)
|
||||
.and_then(|s| s.primary())
|
||||
.is_some_and(|target| match target {
|
||||
Target::Placement(id) => self.selected_node == Some(*id),
|
||||
Target::Port { port, .. } => self.selected == *port,
|
||||
Target::Edge(id) => self.selected == *id,
|
||||
_ => false,
|
||||
});
|
||||
let selection = self
|
||||
.diagram_selection
|
||||
.get_or_insert_with(|| Selection::new(current.scope));
|
||||
selection.reconcile(current.scope, |target| visible(&view, target));
|
||||
// Navigation/property selection remains explicit. A removed exact target
|
||||
// is never rebound through its semantic identity to a different placement.
|
||||
if held_primary || lost_primary {
|
||||
if selection.primary() != legacy.as_ref() {
|
||||
self.inspect_multiselect_primary();
|
||||
}
|
||||
} else if selection.primary() != legacy.as_ref() {
|
||||
if let Some(target) = legacy {
|
||||
selection.replace(target, |target| visible(&view, target));
|
||||
} else {
|
||||
selection.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn inspect_multiselect_primary(&mut self) {
|
||||
let primary = self
|
||||
.diagram_selection
|
||||
.as_ref()
|
||||
.and_then(|s| s.primary())
|
||||
.cloned();
|
||||
self.selected_node = None;
|
||||
match primary {
|
||||
Some(Target::Placement(id)) => {
|
||||
if let Some(node) = self
|
||||
.active_diagram
|
||||
.and_then(|d| self.project().diagrams.get(&d))
|
||||
.and_then(|d| d.nodes.get(&id))
|
||||
{
|
||||
self.selected = node.element;
|
||||
self.selected_node = Some(id);
|
||||
}
|
||||
}
|
||||
Some(Target::Port { port, .. }) => self.selected = port,
|
||||
Some(Target::Edge(id)) => self.selected = id,
|
||||
_ => self.selected = self.active_diagram.unwrap_or(self.project().id),
|
||||
}
|
||||
self.selected_bend = None;
|
||||
self.selected_attachment = None;
|
||||
self.refresh_draft();
|
||||
}
|
||||
|
||||
pub(crate) fn multiselect_event(&mut self, event: Event) {
|
||||
if !self.multiselect_envelope(&event) {
|
||||
return;
|
||||
}
|
||||
if !diagram_removal::enabled(self)
|
||||
|| self.architecture.is_some()
|
||||
|| self.connection.is_some()
|
||||
{
|
||||
self.status =
|
||||
"Finish or cancel the active edit before changing the diagram selection.".into();
|
||||
return;
|
||||
}
|
||||
let current = self.multiselect_context().unwrap();
|
||||
let Surface::Saved(diagram) = current.scope.surface else {
|
||||
return;
|
||||
};
|
||||
let view = projection::project_view_with_index(
|
||||
self.project(),
|
||||
diagram,
|
||||
self.document.view_index(),
|
||||
);
|
||||
match event {
|
||||
Event::Collapse(_, target) => {
|
||||
if !visible(&view, &target) {
|
||||
return;
|
||||
}
|
||||
self.diagram_selection
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.replace(target, |target| visible(&view, target));
|
||||
self.inspect_multiselect_primary();
|
||||
}
|
||||
Event::Choose(_, target, choice) => {
|
||||
if !visible(&view, &target) {
|
||||
return;
|
||||
}
|
||||
self.reconcile_multiselect();
|
||||
let selection = self.diagram_selection.as_mut().unwrap();
|
||||
let valid = |target: &Target| visible(&view, target);
|
||||
match choice {
|
||||
Choice::Replace => {
|
||||
selection.replace(target, valid);
|
||||
}
|
||||
Choice::Add => {
|
||||
selection.add(target, valid);
|
||||
}
|
||||
Choice::Remove => {
|
||||
selection.remove(&target);
|
||||
}
|
||||
Choice::Toggle => {
|
||||
selection.toggle(target, valid);
|
||||
}
|
||||
Choice::Inspect => {
|
||||
selection.set_primary(&target);
|
||||
}
|
||||
}
|
||||
self.inspect_multiselect_primary();
|
||||
}
|
||||
Event::Clear(_) => {
|
||||
if let Some(selection) = &mut self.diagram_selection {
|
||||
selection.clear();
|
||||
}
|
||||
self.inspect_multiselect_primary();
|
||||
}
|
||||
Event::Refuse(_, reason) => self.status = reason,
|
||||
Event::Move(snapshot, delta) => {
|
||||
let Some(ids) = placements(&snapshot) else {
|
||||
self.status = "Move requires only placed elements. Remove ports and connections from the selection.".into();
|
||||
return;
|
||||
};
|
||||
if !snapshot
|
||||
.targets()
|
||||
.iter()
|
||||
.all(|target| visible(&view, target))
|
||||
{
|
||||
return;
|
||||
}
|
||||
if self.project().diagrams[&diagram].projection == ProjectionKind::Allocation
|
||||
&& ids.iter().any(|id| {
|
||||
self.project().elements
|
||||
[&self.project().diagrams[&diagram].nodes[id].element]
|
||||
.kind()
|
||||
.is_work()
|
||||
})
|
||||
{
|
||||
self.status =
|
||||
"Group allocation drag is not available yet. The selection was retained."
|
||||
.into();
|
||||
return;
|
||||
}
|
||||
if delta.x == 0. && delta.y == 0. {
|
||||
return;
|
||||
}
|
||||
match oa_core::diagram_edit::translate_placements(
|
||||
self.project(),
|
||||
diagram,
|
||||
&ids,
|
||||
delta,
|
||||
) {
|
||||
Ok(command) => {
|
||||
if self.execute(command) {
|
||||
self.inspect_multiselect_primary();
|
||||
self.status = format!(
|
||||
"Moved {} selected elements · Undo restores the group",
|
||||
ids.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(error) => self.status = error.to_string(),
|
||||
}
|
||||
}
|
||||
Event::Hide(snapshot) => {
|
||||
let Some(ids) = placements(&snapshot) else {
|
||||
self.status =
|
||||
"Hide requires only placed elements. Model objects remain unchanged."
|
||||
.into();
|
||||
return;
|
||||
};
|
||||
if !snapshot
|
||||
.targets()
|
||||
.iter()
|
||||
.all(|target| visible(&view, target))
|
||||
{
|
||||
return;
|
||||
}
|
||||
if self.execute(Command::Batch(
|
||||
ids.iter().copied().map(Command::Delete).collect(),
|
||||
)) {
|
||||
if let Some(selection) = &mut self.diagram_selection {
|
||||
selection.clear();
|
||||
}
|
||||
self.inspect_multiselect_primary();
|
||||
self.status = format!("Hid {} placements · Model objects retained", ids.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,602 @@
|
||||
//! Saved-view group operations through the real App message boundary.
|
||||
use super::multiselect::{Choice as GroupChoice, Event as GroupEvent};
|
||||
use super::*;
|
||||
use crate::diagram_usage::Target;
|
||||
|
||||
fn point(x: f32, y: f32) -> Position {
|
||||
Position { x, y }
|
||||
}
|
||||
|
||||
struct Fixture {
|
||||
app: App,
|
||||
diagram: Id,
|
||||
a: Id,
|
||||
b: Id,
|
||||
child: Id,
|
||||
edge: Id,
|
||||
}
|
||||
|
||||
fn fixture() -> Fixture {
|
||||
let mut p = Project::empty();
|
||||
let a = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Send", None)
|
||||
.unwrap();
|
||||
let b = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Receive", None)
|
||||
.unwrap();
|
||||
let child = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Prepare", Some(a))
|
||||
.unwrap();
|
||||
let edge = p.relate(RelationKind::FunctionalExchange, "Signal", a, b);
|
||||
let diagram = p.ensure_diagram(Layer::Sa);
|
||||
p.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::FunctionalDataflow;
|
||||
let a = p.place(diagram, a, point(10., 20.));
|
||||
let b = p.place(diagram, b, point(500., 20.));
|
||||
let child = p.place(diagram, child, point(40., 100.));
|
||||
p.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.routes
|
||||
.push(EdgeRoute {
|
||||
edge,
|
||||
source_attachment: Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Bottom,
|
||||
offset: 0.3,
|
||||
}),
|
||||
target_attachment: Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Top,
|
||||
offset: 0.7,
|
||||
}),
|
||||
waypoints: vec![point(370., 150.)],
|
||||
label: Some(EdgeLabel {
|
||||
fraction: 0.4,
|
||||
offset: point(5., -9.),
|
||||
}),
|
||||
});
|
||||
let other = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "Operational work", None)
|
||||
.unwrap();
|
||||
let other_diagram = p.ensure_diagram(Layer::Oa);
|
||||
p.place(other_diagram, other, point(-50., -100.));
|
||||
let mut app = App::with_project(p);
|
||||
app.page = Page::Diagram;
|
||||
app.layer = Layer::Sa;
|
||||
app.active_diagram = Some(diagram);
|
||||
app.selected = diagram;
|
||||
app.selected_node = None;
|
||||
app.refresh_draft();
|
||||
app.reconcile_multiselect();
|
||||
Fixture {
|
||||
app,
|
||||
diagram,
|
||||
a,
|
||||
b,
|
||||
child,
|
||||
edge,
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch(app: &mut App, event: GroupEvent) {
|
||||
let _ = app.update(Message::DiagramSelection(event));
|
||||
}
|
||||
fn choose(app: &mut App, target: Target, choice: GroupChoice) {
|
||||
let context = app.multiselect_context().unwrap();
|
||||
dispatch(app, GroupEvent::Choose(context, target, choice));
|
||||
}
|
||||
fn select_pair(f: &mut Fixture) {
|
||||
choose(&mut f.app, Target::Placement(f.a), GroupChoice::Replace);
|
||||
choose(&mut f.app, Target::Placement(f.b), GroupChoice::Toggle);
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().targets(),
|
||||
&[Target::Placement(f.a), Target::Placement(f.b)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queued_singleton_move_or_delete_cannot_partially_edit_a_new_group() {
|
||||
for model in [false, true] {
|
||||
let mut f = fixture();
|
||||
select_pair(&mut f);
|
||||
let before = f.app.document.snapshot();
|
||||
let selected = f.app.multiselect_snapshot().unwrap();
|
||||
let object = f.app.project().diagrams[&f.diagram].nodes[&f.b].element;
|
||||
for message in [
|
||||
Message::MoveNode(f.b, point(700., 600.)),
|
||||
Message::DiagramRemove(super::diagram_removal::Intent {
|
||||
context: f.app.route_context().unwrap(),
|
||||
object,
|
||||
placement: Some(f.b),
|
||||
model,
|
||||
}),
|
||||
Message::Delete,
|
||||
] {
|
||||
let _ = f.app.update(message);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().targets(),
|
||||
selected.targets()
|
||||
);
|
||||
assert!(!f.app.delete_pending);
|
||||
assert!(!f.app.document.can_undo());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_hide_action_applies_to_the_complete_visible_group() {
|
||||
let mut f = fixture();
|
||||
select_pair(&mut f);
|
||||
let before = f.app.document.snapshot();
|
||||
let mut expected = f.app.project().clone();
|
||||
let view = expected.diagrams.get_mut(&f.diagram).unwrap();
|
||||
view.nodes.remove(&f.a);
|
||||
view.nodes.remove(&f.b);
|
||||
let _ = f.app.update(Message::HideSelected);
|
||||
assert_eq!(f.app.project(), &expected);
|
||||
assert_source_matches_model(&f.app);
|
||||
let _ = f.app.update(Message::Undo);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert!(!f.app.document.can_undo());
|
||||
}
|
||||
fn assert_source_matches_model(app: &App) {
|
||||
let compiled = oa_core::source_project::compile_bundle(app.document.bundle(), LIMITS).unwrap();
|
||||
assert_eq!(&compiled.decoded.project, app.project());
|
||||
assert_eq!(&compiled.bundle, app.document.bundle());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggled_group_moves_model_and_source_once_and_keeps_primary_through_history() {
|
||||
let mut f = fixture();
|
||||
select_pair(&mut f);
|
||||
let before = f.app.document.snapshot();
|
||||
let snapshot = f.app.multiselect_snapshot().unwrap();
|
||||
assert_eq!(snapshot.primary(), Some(&Target::Placement(f.b)));
|
||||
let mut expected = f.app.project().clone();
|
||||
let view = expected.diagrams.get_mut(&f.diagram).unwrap();
|
||||
view.nodes.get_mut(&f.a).unwrap().position = point(50., 80.);
|
||||
view.nodes.get_mut(&f.b).unwrap().position = point(540., 80.);
|
||||
view.nodes.get_mut(&f.child).unwrap().position = point(80., 160.);
|
||||
view.routes[0].waypoints = vec![point(410., 210.)];
|
||||
dispatch(
|
||||
&mut f.app,
|
||||
GroupEvent::Move(snapshot.clone(), point(40., 60.)),
|
||||
);
|
||||
assert_eq!(f.app.project(), &expected);
|
||||
assert_source_matches_model(&f.app);
|
||||
assert_eq!(f.app.selected_node, Some(f.b));
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().targets(),
|
||||
snapshot.targets()
|
||||
);
|
||||
let after = f.app.document.snapshot();
|
||||
let _ = f.app.update(Message::Undo);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert!(!f.app.document.can_undo());
|
||||
let _ = f.app.update(Message::Redo);
|
||||
assert_eq!(f.app.project(), &expected);
|
||||
assert_eq!(f.app.document.bundle(), &after.bundle);
|
||||
assert!(!f.app.document.can_redo());
|
||||
assert_eq!(f.app.selected_node, Some(f.b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ancestor_and_child_selection_moves_child_exactly_once() {
|
||||
let mut f = fixture();
|
||||
choose(&mut f.app, Target::Placement(f.a), GroupChoice::Replace);
|
||||
choose(&mut f.app, Target::Placement(f.child), GroupChoice::Toggle);
|
||||
let snapshot = f.app.multiselect_snapshot().unwrap();
|
||||
assert_eq!(snapshot.targets().len(), 2);
|
||||
let mut expected = f.app.project().clone();
|
||||
let view = expected.diagrams.get_mut(&f.diagram).unwrap();
|
||||
view.nodes.get_mut(&f.a).unwrap().position = point(0., 45.);
|
||||
view.nodes.get_mut(&f.child).unwrap().position = point(30., 125.);
|
||||
dispatch(&mut f.app, GroupEvent::Move(snapshot, point(-10., 25.)));
|
||||
assert_eq!(f.app.project(), &expected);
|
||||
assert_eq!(f.app.selected_node, Some(f.child));
|
||||
assert_source_matches_model(&f.app);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_edge_or_port_selection_refuses_the_entire_move() {
|
||||
for include_port in [false, true] {
|
||||
let mut f = fixture();
|
||||
choose(&mut f.app, Target::Placement(f.a), GroupChoice::Replace);
|
||||
let extra = if include_port {
|
||||
let view = projection::project_view(f.app.project(), f.diagram);
|
||||
let port = view
|
||||
.ports
|
||||
.iter()
|
||||
.find(|p| p.owner == f.a)
|
||||
.expect("functional exchange exposes its typed owner port");
|
||||
Target::Port {
|
||||
port: port.element,
|
||||
placement: port.owner,
|
||||
}
|
||||
} else {
|
||||
Target::Edge(f.edge)
|
||||
};
|
||||
choose(&mut f.app, extra.clone(), GroupChoice::Toggle);
|
||||
let selection = f.app.multiselect_snapshot().unwrap();
|
||||
assert_eq!(selection.targets(), &[Target::Placement(f.a), extra]);
|
||||
let before = f.app.document.snapshot();
|
||||
let revision = f.app.document.revision();
|
||||
dispatch(
|
||||
&mut f.app,
|
||||
GroupEvent::Move(selection.clone(), point(40., 60.)),
|
||||
);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert_eq!(f.app.document.revision(), revision);
|
||||
assert!(!f.app.document.can_undo());
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().targets(),
|
||||
selection.targets()
|
||||
);
|
||||
assert!(f.app.status.contains("Move requires only placed elements"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_operation_envelopes_have_no_selection_history_status_or_allocation_side_effects() {
|
||||
// Isolate each lifetime component, including the same visible set recreated
|
||||
// by a remove/re-add cycle. Accepted setup changes are outside the assertion.
|
||||
for stale_by in ["epoch", "revision", "document", "selection generation"] {
|
||||
for action in ["move", "hide", "refuse", "collapse"] {
|
||||
let mut f = fixture();
|
||||
select_pair(&mut f);
|
||||
let stale = f.app.multiselect_snapshot().unwrap();
|
||||
match stale_by {
|
||||
"epoch" => f.app.canvas_epoch += 1,
|
||||
"revision" => {
|
||||
let semantic = f.app.project().diagrams[&f.diagram].nodes[&f.a].element;
|
||||
// Advance only the document revision, retaining this canvas
|
||||
// and selection epoch to independently test revision checking.
|
||||
f.app
|
||||
.document
|
||||
.execute(Command::Describe(semantic, "New revision".into()))
|
||||
.unwrap();
|
||||
}
|
||||
"document" => f.app.canvas_document = Id::new_v4(),
|
||||
_ => {
|
||||
choose(&mut f.app, Target::Placement(f.b), GroupChoice::Toggle);
|
||||
choose(&mut f.app, Target::Placement(f.b), GroupChoice::Toggle);
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().targets(),
|
||||
stale.targets()
|
||||
);
|
||||
}
|
||||
}
|
||||
f.app.status = "Current feedback must survive".into();
|
||||
f.app.allocation_drag.arrange_next = true;
|
||||
let before = f.app.document.snapshot();
|
||||
let selection = f.app.multiselect_snapshot();
|
||||
let legacy = (f.app.selected, f.app.selected_node);
|
||||
let revision = f.app.document.revision();
|
||||
let epochs = (
|
||||
f.app.canvas_epoch,
|
||||
f.app.camera_epoch,
|
||||
f.app.allocation_drag.epoch,
|
||||
);
|
||||
let history = (f.app.document.can_undo(), f.app.document.can_redo());
|
||||
let draft = f
|
||||
.app
|
||||
.draft
|
||||
.as_ref()
|
||||
.map(|d| serde_json::to_value(d.recovery_snapshot()).unwrap());
|
||||
let event = match action {
|
||||
"move" => GroupEvent::Move(stale, point(99., 100.)),
|
||||
"hide" => GroupEvent::Hide(stale),
|
||||
"collapse" => GroupEvent::Collapse(stale, Target::Placement(f.a)),
|
||||
_ => GroupEvent::Refuse(stale, "Obsolete refusal".into()),
|
||||
};
|
||||
dispatch(&mut f.app, event);
|
||||
assert_eq!(
|
||||
f.app.project(),
|
||||
&before.decoded.project,
|
||||
"{stale_by}/{action}"
|
||||
);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert_eq!(f.app.document.revision(), revision);
|
||||
assert_eq!(f.app.multiselect_snapshot(), selection);
|
||||
assert_eq!((f.app.selected, f.app.selected_node), legacy);
|
||||
assert_eq!(
|
||||
(
|
||||
f.app.canvas_epoch,
|
||||
f.app.camera_epoch,
|
||||
f.app.allocation_drag.epoch
|
||||
),
|
||||
epochs
|
||||
);
|
||||
assert_eq!(
|
||||
(f.app.document.can_undo(), f.app.document.can_redo()),
|
||||
history
|
||||
);
|
||||
assert_eq!(f.app.status, "Current feedback must survive");
|
||||
assert!(f.app.allocation_drag.arrange_next);
|
||||
assert_eq!(
|
||||
f.app
|
||||
.draft
|
||||
.as_ref()
|
||||
.map(|d| serde_json::to_value(d.recovery_snapshot()).unwrap()),
|
||||
draft
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_valid_and_invalid_property_drafts_refuse_group_edits_without_applying_them() {
|
||||
for (path, value) in [("/name", "Unapplied name"), ("/id", "-")] {
|
||||
for hide in [false, true] {
|
||||
let mut f = fixture();
|
||||
select_pair(&mut f);
|
||||
let _ = f.app.update(Message::Field(path.into(), value.into()));
|
||||
assert!(f.app.draft_dirty());
|
||||
let snapshot = f.app.multiselect_snapshot().unwrap();
|
||||
let before = f.app.document.snapshot();
|
||||
let draft =
|
||||
serde_json::to_value(f.app.draft.as_ref().unwrap().recovery_snapshot()).unwrap();
|
||||
let event = if hide {
|
||||
GroupEvent::Hide(snapshot.clone())
|
||||
} else {
|
||||
GroupEvent::Move(snapshot.clone(), point(20., 30.))
|
||||
};
|
||||
dispatch(&mut f.app, event);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert!(!f.app.document.can_undo());
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().targets(),
|
||||
snapshot.targets()
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(f.app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(),
|
||||
draft
|
||||
);
|
||||
assert!(f.app.status.contains("Finish or cancel"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hiding_a_group_removes_only_exact_placements_and_roundtrips_in_one_history_step() {
|
||||
let mut f = fixture();
|
||||
select_pair(&mut f);
|
||||
let snapshot = f.app.multiselect_snapshot().unwrap();
|
||||
let before = f.app.document.snapshot();
|
||||
let mut expected = f.app.project().clone();
|
||||
let view = expected.diagrams.get_mut(&f.diagram).unwrap();
|
||||
view.nodes.remove(&f.a);
|
||||
view.nodes.remove(&f.b);
|
||||
dispatch(&mut f.app, GroupEvent::Hide(snapshot));
|
||||
assert_eq!(
|
||||
f.app.project(),
|
||||
&expected,
|
||||
"engineering objects, descendant placement, authored route and opposite-layer view survive"
|
||||
);
|
||||
assert!(f.app.multiselect_snapshot().unwrap().targets().is_empty());
|
||||
assert_source_matches_model(&f.app);
|
||||
let after = f.app.document.snapshot();
|
||||
let _ = f.app.update(Message::Undo);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert!(!f.app.document.can_undo());
|
||||
let _ = f.app.update(Message::Redo);
|
||||
assert_eq!(f.app.project(), &expected);
|
||||
assert_eq!(f.app.document.bundle(), &after.bundle);
|
||||
assert!(!f.app.document.can_redo());
|
||||
}
|
||||
fn assert_surviving_primary(f: &Fixture) {
|
||||
let selection = f.app.multiselect_snapshot().unwrap();
|
||||
assert_eq!(selection.targets(), &[Target::Placement(f.a)]);
|
||||
assert_eq!(selection.primary(), Some(&Target::Placement(f.a)));
|
||||
assert_eq!(f.app.selected_node, Some(f.a));
|
||||
assert_eq!(
|
||||
f.app.selected,
|
||||
f.app.project().diagrams[&f.diagram].nodes[&f.a].element
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_primary_placement_from_model_preserves_exact_survivor_after_undo() {
|
||||
let mut f = fixture();
|
||||
select_pair(&mut f);
|
||||
let before = f.app.document.snapshot();
|
||||
let _ = f.app.update(Message::Command(Command::Delete(f.b)));
|
||||
assert!(
|
||||
!f.app.project().diagrams[&f.diagram]
|
||||
.nodes
|
||||
.contains_key(&f.b)
|
||||
);
|
||||
assert_surviving_primary(&f);
|
||||
let _ = f.app.update(Message::Undo);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert_surviving_primary(&f);
|
||||
let _ = f.app.update(Message::Redo);
|
||||
assert_surviving_primary(&f);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_primary_model_object_then_undo_does_not_restore_removed_selection_membership() {
|
||||
let mut f = fixture();
|
||||
select_pair(&mut f);
|
||||
let before = f.app.document.snapshot();
|
||||
let semantic = f.app.project().diagrams[&f.diagram].nodes[&f.b].element;
|
||||
let _ = f.app.update(Message::Command(Command::Delete(semantic)));
|
||||
assert!(!f.app.project().elements.contains_key(&semantic));
|
||||
assert_surviving_primary(&f);
|
||||
let _ = f.app.update(Message::Undo);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert_surviving_primary(&f);
|
||||
let _ = f.app.update(Message::Redo);
|
||||
assert_surviving_primary(&f);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacing_primary_occurrence_with_same_semantic_placement_does_not_retarget_selection() {
|
||||
let mut f = fixture();
|
||||
select_pair(&mut f);
|
||||
let mut candidate = f.app.project().clone();
|
||||
let view = candidate.diagrams.get_mut(&f.diagram).unwrap();
|
||||
let mut replacement = view.nodes.remove(&f.b).unwrap();
|
||||
let replacement_id = Id::new_v4();
|
||||
replacement.id = replacement_id;
|
||||
replacement.alias = "replacement_receive".into();
|
||||
replacement.position = point(800., 300.);
|
||||
view.nodes.insert(replacement_id, replacement);
|
||||
// Replace preserves the semantic inspector identity. Its normal sync used
|
||||
// to rediscover the new occurrence through that identity and steal the set.
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::Command(Command::Replace(candidate.clone())));
|
||||
assert_eq!(f.app.project(), &candidate);
|
||||
assert_surviving_primary(&f);
|
||||
assert!(
|
||||
!f.app
|
||||
.multiselect_snapshot()
|
||||
.unwrap()
|
||||
.targets()
|
||||
.contains(&Target::Placement(replacement_id))
|
||||
);
|
||||
assert_source_matches_model(&f.app);
|
||||
let _ = f.app.update(Message::Undo);
|
||||
assert!(
|
||||
f.app.project().diagrams[&f.diagram]
|
||||
.nodes
|
||||
.contains_key(&f.b)
|
||||
);
|
||||
assert_surviving_primary(&f);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_surface_navigation_and_return_never_resurrect_the_group() {
|
||||
for destination in [Page::Catalog, Page::Behavior] {
|
||||
let mut f = fixture();
|
||||
select_pair(&mut f);
|
||||
let before = f.app.document.snapshot();
|
||||
let stale = f.app.multiselect_snapshot().unwrap();
|
||||
let _ = f.app.update(Message::Page(destination));
|
||||
assert!(f.app.multiselect_snapshot().is_none());
|
||||
let _ = f.app.update(Message::Page(Page::Diagram));
|
||||
let live = f.app.multiselect_snapshot().unwrap();
|
||||
assert!(
|
||||
live.targets().len() <= 1,
|
||||
"navigation may inspect one exact member but must not restore the group"
|
||||
);
|
||||
dispatch(&mut f.app, GroupEvent::Move(stale, point(400., 500.)));
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert!(!f.app.document.can_undo());
|
||||
}
|
||||
}
|
||||
|
||||
fn open_placement_menu(f: &mut Fixture, placement: Id) {
|
||||
let semantic = f.app.project().diagrams[&f.diagram].nodes[&placement].element;
|
||||
let mut request = crate::model_menu::Request::object(semantic, iced::Point::new(100., 100.));
|
||||
request.placement = Some(placement);
|
||||
request.canvas = f.app.route_context();
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::Menu(crate::model_menu::Event::Open(request)));
|
||||
assert!(f.app.model_menu.is_some());
|
||||
}
|
||||
|
||||
fn menu_action(app: &App, caption: &str) -> Message {
|
||||
crate::model_menu::popup(app)
|
||||
.unwrap()
|
||||
.entries
|
||||
.into_iter()
|
||||
.find_map(|entry| match entry {
|
||||
crate::context_menu::Entry::Action { label, message, .. } if label == caption => {
|
||||
message
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_else(|| panic!("enabled menu action {caption:?} must exist"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_menu_dismissal_preserves_group_and_add_keeps_pre_menu_members() {
|
||||
let mut f = fixture();
|
||||
select_pair(&mut f);
|
||||
let before = f.app.document.snapshot();
|
||||
let selected = f.app.multiselect_snapshot().unwrap();
|
||||
let child = f.child;
|
||||
open_placement_menu(&mut f, child);
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().targets(),
|
||||
selected.targets()
|
||||
);
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().primary(),
|
||||
selected.primary()
|
||||
);
|
||||
let _ = f.app.update(Message::Menu(crate::model_menu::Event::Close));
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().targets(),
|
||||
selected.targets()
|
||||
);
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().primary(),
|
||||
selected.primary()
|
||||
);
|
||||
open_placement_menu(&mut f, child);
|
||||
let add = menu_action(&f.app, "Add to selection");
|
||||
let _ = f.app.update(add);
|
||||
let selected = f.app.multiselect_snapshot().unwrap();
|
||||
assert_eq!(
|
||||
selected.targets(),
|
||||
&[
|
||||
Target::Placement(f.a),
|
||||
Target::Placement(f.b),
|
||||
Target::Placement(child)
|
||||
]
|
||||
);
|
||||
assert_eq!(selected.primary(), Some(&Target::Placement(child)));
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert!(!f.app.document.can_undo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_member_context_menu_exposes_and_executes_group_hide_not_singleton_deletion() {
|
||||
let mut f = fixture();
|
||||
select_pair(&mut f);
|
||||
let before = f.app.document.snapshot();
|
||||
let member = f.a;
|
||||
open_placement_menu(&mut f, member);
|
||||
let popup = crate::model_menu::popup(&f.app).unwrap();
|
||||
for entry in &popup.entries {
|
||||
if let crate::context_menu::Entry::Action {
|
||||
message: Some(Message::Menu(crate::model_menu::Event::Invoke(_, action))),
|
||||
..
|
||||
} = entry
|
||||
{
|
||||
assert!(
|
||||
!matches!(
|
||||
action.as_ref(),
|
||||
Message::HideSelected | Message::Delete | Message::DiagramRemove(_)
|
||||
),
|
||||
"a selected-member group menu must not expose singleton removal"
|
||||
);
|
||||
}
|
||||
}
|
||||
let hide = menu_action(&f.app, "Hide selected placements");
|
||||
let _ = f.app.update(hide);
|
||||
let mut expected = before.decoded.project.clone();
|
||||
let view = expected.diagrams.get_mut(&f.diagram).unwrap();
|
||||
view.nodes.remove(&f.a);
|
||||
view.nodes.remove(&f.b);
|
||||
assert_eq!(f.app.project(), &expected);
|
||||
assert!(!f.app.delete_pending);
|
||||
assert!(f.app.model_menu.is_none());
|
||||
assert_source_matches_model(&f.app);
|
||||
let _ = f.app.update(Message::Undo);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert!(!f.app.document.can_undo());
|
||||
}
|
||||
@@ -39,12 +39,17 @@ mod port_labels;
|
||||
pub use port_labels::PortLabels;
|
||||
#[path = "diagram/focus.rs"]
|
||||
pub(crate) mod focus;
|
||||
mod group_gesture;
|
||||
mod multiselect;
|
||||
#[cfg(test)]
|
||||
mod multiselect_tests;
|
||||
#[path = "diagram/work_drag.rs"]
|
||||
mod work_drag;
|
||||
pub use work_drag::WorkFeedback;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct DiagramCanvas<'a> {
|
||||
pub selection: Option<&'a crate::diagram_selection::Selection>,
|
||||
pub allocation: Option<crate::app::allocation_drag::Capability<'a>>,
|
||||
pub project: &'a Project,
|
||||
pub view_index: Option<&'a oa_core::view_options::ViewIndex>,
|
||||
@@ -63,6 +68,8 @@ pub struct DiagramCanvas<'a> {
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub struct State {
|
||||
modifiers: iced::keyboard::Modifiers,
|
||||
group: Option<multiselect::Press>,
|
||||
work: Option<Arc<crate::app::allocation_drag::Press>>,
|
||||
work_cache: RefCell<Option<Arc<Scene>>>,
|
||||
pointer: Option<Point>,
|
||||
@@ -462,6 +469,7 @@ impl DiagramCanvas<'_> {
|
||||
}
|
||||
fn preview_diagram(&self, state: &State) -> Diagram {
|
||||
let mut diagram = self.project.diagrams[&self.diagram].clone();
|
||||
self.group_preview(state, &mut diagram);
|
||||
if let (Some((id, _, original)), Some(preview)) = (state.drag, state.preview) {
|
||||
let delta = Position {
|
||||
x: preview.x - original.x,
|
||||
@@ -604,6 +612,13 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
bounds: Rectangle,
|
||||
cursor: mouse::Cursor,
|
||||
) -> Option<Action<Message>> {
|
||||
if let canvas::Event::Keyboard(iced::keyboard::Event::ModifiersChanged(modifiers)) = event {
|
||||
state.modifiers = *modifiers;
|
||||
return None;
|
||||
}
|
||||
if matches!(event, canvas::Event::Window(iced::window::Event::Unfocused)) {
|
||||
state.modifiers = iced::keyboard::Modifiers::default();
|
||||
}
|
||||
// An attachment belongs to the boundary geometry captured at press.
|
||||
// Camera/layout changes must not reinterpret its pointer in another space.
|
||||
if matches!(
|
||||
@@ -641,6 +656,7 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
return Some(action);
|
||||
}
|
||||
if state.context != Some(self.context) {
|
||||
state.group = None;
|
||||
state.work = None;
|
||||
state.pointer = None;
|
||||
state.port_drag = None;
|
||||
@@ -707,6 +723,9 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
let local = cursor
|
||||
.position()
|
||||
.map(|p| Point::new(p.x - bounds.x, p.y - bounds.y));
|
||||
if let Some(action) = self.group_input(state, event, bounds, cursor) {
|
||||
return Some(action);
|
||||
}
|
||||
match event {
|
||||
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right))
|
||||
if cursor.is_over(bounds) =>
|
||||
@@ -1262,7 +1281,17 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
.collect::<BTreeSet<_>>();
|
||||
for n in &view.nodes {
|
||||
if containers.contains(&n.id) {
|
||||
paint_node(&mut f, project, n, self.selected, true);
|
||||
paint_node(
|
||||
&mut f,
|
||||
project,
|
||||
n,
|
||||
if self.selection_contains(crate::diagram_usage::Target::Placement(n.id)) {
|
||||
n.element
|
||||
} else {
|
||||
self.selected
|
||||
},
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
let highlighted = highlighted_exchanges(project, self.selected);
|
||||
@@ -1274,7 +1303,9 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
continue;
|
||||
};
|
||||
let end = point(end);
|
||||
let active = edge.id == self.selected || highlighted.contains(&edge.id);
|
||||
let active = edge.id == self.selected
|
||||
|| highlighted.contains(&edge.id)
|
||||
|| self.selection_contains(crate::diagram_usage::Target::Edge(edge.id));
|
||||
let color = if active {
|
||||
style::TEAL
|
||||
} else if routed
|
||||
@@ -1370,17 +1401,42 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
}
|
||||
for n in &view.nodes {
|
||||
if !containers.contains(&n.id) {
|
||||
paint_node(&mut f, project, n, self.selected, false);
|
||||
paint_node(
|
||||
&mut f,
|
||||
project,
|
||||
n,
|
||||
if self.selection_contains(crate::diagram_usage::Target::Placement(n.id)) {
|
||||
n.element
|
||||
} else {
|
||||
self.selected
|
||||
},
|
||||
false,
|
||||
);
|
||||
} else {
|
||||
// Container headers stay readable when relationships traverse
|
||||
// the allocation enclosure. Children remain on top.
|
||||
let mut header = n.clone();
|
||||
header.size.height = 62.;
|
||||
paint_node(&mut f, project, &header, self.selected, true);
|
||||
paint_node(
|
||||
&mut f,
|
||||
project,
|
||||
&header,
|
||||
if self.selection_contains(crate::diagram_usage::Target::Placement(n.id)) {
|
||||
n.element
|
||||
} else {
|
||||
self.selected
|
||||
},
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
for port in &view.ports {
|
||||
let color = if self.selected == port.element {
|
||||
let selected = self.selected == port.element
|
||||
|| self.selection_contains(crate::diagram_usage::Target::Port {
|
||||
port: port.element,
|
||||
placement: port.owner,
|
||||
});
|
||||
let color = if selected {
|
||||
style::TEAL
|
||||
} else {
|
||||
style::kind_color(
|
||||
@@ -1408,11 +1464,7 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
let mut stroke =
|
||||
Stroke::default()
|
||||
.with_color(color)
|
||||
.with_width(if self.selected == port.element {
|
||||
2.5
|
||||
} else {
|
||||
1.5
|
||||
});
|
||||
.with_width(if selected { 2.5 } else { 1.5 });
|
||||
if glyph == PortGlyph::Legacy {
|
||||
stroke.line_dash = canvas::LineDash {
|
||||
segments: &[3., 2.],
|
||||
@@ -2179,6 +2231,7 @@ mod tests {
|
||||
y: original.y - 200.,
|
||||
};
|
||||
let canvas = DiagramCanvas {
|
||||
selection: None,
|
||||
project: &p,
|
||||
view_index: None,
|
||||
context: Context {
|
||||
@@ -2256,6 +2309,7 @@ mod tests {
|
||||
.unwrap()
|
||||
.id;
|
||||
let canvas = DiagramCanvas {
|
||||
selection: None,
|
||||
project: &p,
|
||||
view_index: None,
|
||||
context: Context {
|
||||
@@ -2384,6 +2438,7 @@ mod tests {
|
||||
};
|
||||
let session = Session::new(&p, &view, context, Tool::FunctionalExchange);
|
||||
let canvas = DiagramCanvas {
|
||||
selection: None,
|
||||
project: &p,
|
||||
view_index: None,
|
||||
diagram,
|
||||
@@ -2559,6 +2614,7 @@ mod tests {
|
||||
for zoom in [0.5, 1., 1.5] {
|
||||
let (p, diagram, edge) = route_fixture();
|
||||
let canvas = DiagramCanvas {
|
||||
selection: None,
|
||||
project: &p,
|
||||
view_index: None,
|
||||
diagram,
|
||||
@@ -2637,6 +2693,7 @@ mod tests {
|
||||
fn fast_drags_commit_the_release_cursor_without_a_final_motion_event() {
|
||||
let (p, diagram, edge) = route_fixture();
|
||||
let canvas = DiagramCanvas {
|
||||
selection: None,
|
||||
project: &p,
|
||||
view_index: None,
|
||||
diagram,
|
||||
@@ -2736,6 +2793,7 @@ mod tests {
|
||||
fn route_cache_ignores_pan_zoom_and_cancelled_drag_cannot_commit() {
|
||||
let (p, diagram, edge) = route_fixture();
|
||||
let mut canvas = DiagramCanvas {
|
||||
selection: None,
|
||||
project: &p,
|
||||
view_index: None,
|
||||
diagram,
|
||||
@@ -2866,6 +2924,7 @@ mod tests {
|
||||
assert!(!svg.contains("data-navigation=\"source\""));
|
||||
assert!(svg.contains("data-navigation=\"target\""));
|
||||
let mut canvas = DiagramCanvas {
|
||||
selection: None,
|
||||
project: &p,
|
||||
view_index: None,
|
||||
diagram,
|
||||
@@ -2922,6 +2981,7 @@ mod tests {
|
||||
offset: Position { x: 0., y: -160. },
|
||||
});
|
||||
let mut canvas = DiagramCanvas {
|
||||
selection: None,
|
||||
project: &p,
|
||||
view_index: None,
|
||||
diagram,
|
||||
@@ -2998,6 +3058,7 @@ mod tests {
|
||||
},
|
||||
});
|
||||
let canvas = DiagramCanvas {
|
||||
selection: None,
|
||||
project: &p,
|
||||
view_index: None,
|
||||
diagram,
|
||||
@@ -3033,6 +3094,7 @@ mod tests {
|
||||
routed.edges.insert(duplicate, duplicate_route);
|
||||
let selected = edge.max(duplicate); // UUID ordering must not override selection.
|
||||
let canvas = DiagramCanvas {
|
||||
selection: None,
|
||||
project: &p,
|
||||
view_index: None,
|
||||
diagram,
|
||||
|
||||
@@ -34,6 +34,7 @@ fn fixture() -> App {
|
||||
|
||||
fn canvas(app: &App, zoom: f32) -> DiagramCanvas<'_> {
|
||||
DiagramCanvas {
|
||||
selection: None,
|
||||
allocation: None,
|
||||
project: app.project(),
|
||||
view_index: None,
|
||||
|
||||
@@ -12,6 +12,8 @@ struct Input {
|
||||
suspended: bool,
|
||||
traversing: bool,
|
||||
pointer: Option<Point>,
|
||||
modifiers: iced::keyboard::Modifiers,
|
||||
restore_modifiers: bool,
|
||||
}
|
||||
impl operation::Focusable for Input {
|
||||
fn is_focused(&self) -> bool {
|
||||
@@ -95,6 +97,7 @@ impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Owner<'_, Mes
|
||||
// canvas's transient gestures without retiring this focus owner.
|
||||
state.pointer = None;
|
||||
tree.children[0] = Tree::new(self.content.as_widget());
|
||||
state.restore_modifiers = true;
|
||||
} else {
|
||||
tree.diff_children(std::slice::from_ref(&self.content));
|
||||
}
|
||||
@@ -143,6 +146,14 @@ impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Owner<'_, Mes
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
let state = tree.state.downcast_mut::<Input>();
|
||||
if let InputEvent::Keyboard(iced::keyboard::Event::ModifiersChanged(modifiers)) = event {
|
||||
state.modifiers = *modifiers;
|
||||
state.restore_modifiers = false;
|
||||
}
|
||||
if matches!(event, InputEvent::Window(iced::window::Event::Unfocused)) {
|
||||
state.modifiers = iced::keyboard::Modifiers::default();
|
||||
state.restore_modifiers = true;
|
||||
}
|
||||
if let InputEvent::Mouse(mouse::Event::CursorMoved { position }) = event {
|
||||
state.pointer = Some(*position);
|
||||
}
|
||||
@@ -171,7 +182,14 @@ impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Owner<'_, Mes
|
||||
}
|
||||
// Save/export I/O denies commands and acquisition, while real focus
|
||||
// operations and outside clicks can still revoke the retained owner.
|
||||
if self.suspended && matches!(event, InputEvent::Keyboard(_) | InputEvent::Mouse(_)) {
|
||||
let passive_modifiers = matches!(
|
||||
event,
|
||||
InputEvent::Keyboard(iced::keyboard::Event::ModifiersChanged(_))
|
||||
);
|
||||
if self.suspended
|
||||
&& !passive_modifiers
|
||||
&& matches!(event, InputEvent::Keyboard(_) | InputEvent::Mouse(_))
|
||||
{
|
||||
return;
|
||||
}
|
||||
if let InputEvent::Keyboard(iced::keyboard::Event::KeyPressed {
|
||||
@@ -193,13 +211,30 @@ impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Owner<'_, Mes
|
||||
return;
|
||||
}
|
||||
if matches!(event, InputEvent::Keyboard(_))
|
||||
&& !passive_modifiers
|
||||
&& (!state.focused || state.traversing || !self.enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if shell.is_event_captured() {
|
||||
if shell.is_event_captured() && !passive_modifiers {
|
||||
return;
|
||||
}
|
||||
if state.restore_modifiers {
|
||||
// Suspending I/O replaces the child to retire pointer gestures.
|
||||
// Held modifiers belong to this longer-lived passive owner, so
|
||||
// restore them before the first admitted event reaches the child.
|
||||
state.restore_modifiers = false;
|
||||
self.content.as_widget_mut().update(
|
||||
&mut tree.children[0],
|
||||
&InputEvent::Keyboard(iced::keyboard::Event::ModifiersChanged(state.modifiers)),
|
||||
layout,
|
||||
cursor,
|
||||
renderer,
|
||||
clipboard,
|
||||
shell,
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
self.content.as_widget_mut().update(
|
||||
&mut tree.children[0],
|
||||
event,
|
||||
|
||||
@@ -0,0 +1,520 @@
|
||||
//! Saved-view pointer geometry. Selection, capability and model admission remain
|
||||
//! with the caller; this module never applies a selection or a model command.
|
||||
use crate::diagram_selection::Snapshot;
|
||||
use crate::diagram_usage::{Surface, Target};
|
||||
use iced::{Point, Rectangle, Vector};
|
||||
use oa_core::{Id, Position};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub(crate) struct Viewport {
|
||||
pub zoom: f32,
|
||||
pub pan: Vector,
|
||||
pub bounds: Rectangle,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct Gesture {
|
||||
snapshot: Snapshot,
|
||||
anchor: Id,
|
||||
original: Position,
|
||||
press: Point,
|
||||
viewport: Viewport,
|
||||
crossed_threshold: bool,
|
||||
delta: Position,
|
||||
retired: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub(crate) enum Outcome {
|
||||
Click(Id),
|
||||
Move(Snapshot, Position),
|
||||
}
|
||||
|
||||
impl Gesture {
|
||||
pub fn new(
|
||||
snapshot: Snapshot,
|
||||
anchor: Id,
|
||||
original: Position,
|
||||
press: Point,
|
||||
viewport: Viewport,
|
||||
) -> Option<Self> {
|
||||
if !matches!(snapshot.context().scope.surface, Surface::Saved(_))
|
||||
|| !snapshot.targets().contains(&Target::Placement(anchor))
|
||||
|| !snapshot
|
||||
.targets()
|
||||
.iter()
|
||||
.all(|target| matches!(target, Target::Placement(_)))
|
||||
|| !viewport.valid()
|
||||
|| !finite_position(original)
|
||||
|| !finite_point(press)
|
||||
|| !viewport.bounds.contains(press)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(Self {
|
||||
snapshot,
|
||||
anchor,
|
||||
original,
|
||||
press,
|
||||
viewport,
|
||||
crossed_threshold: false,
|
||||
delta: Position { x: 0., y: 0. },
|
||||
retired: false,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn snapshot(&self) -> &Snapshot {
|
||||
&self.snapshot
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn anchor(&self) -> Id {
|
||||
self.anchor
|
||||
}
|
||||
|
||||
/// Compare before processing each event. On mismatch, the caller must drop
|
||||
/// this gesture, just as on Escape, focus loss or changed model authority.
|
||||
pub fn viewport_matches(&self, viewport: Viewport) -> bool {
|
||||
!self.retired && self.viewport == viewport
|
||||
}
|
||||
|
||||
pub fn crossed_threshold(&self) -> bool {
|
||||
self.crossed_threshold
|
||||
}
|
||||
|
||||
/// Common snapped world delta for preview and release; zero before a drag.
|
||||
pub fn delta(&self) -> Position {
|
||||
self.delta
|
||||
}
|
||||
|
||||
/// Process an absolute screen position. False permanently retires the
|
||||
/// gesture; the caller must remove its preview and produce no operation.
|
||||
pub fn move_to(&mut self, pointer: Point) -> bool {
|
||||
if self.retired {
|
||||
return false;
|
||||
}
|
||||
if !finite_point(pointer) || !self.viewport.bounds.contains(pointer) {
|
||||
self.retire();
|
||||
return false;
|
||||
}
|
||||
let screen = pointer - self.press;
|
||||
self.crossed_threshold |= screen.x.hypot(screen.y) > 3.;
|
||||
if !self.crossed_threshold {
|
||||
return true;
|
||||
}
|
||||
let desired = Position {
|
||||
x: self.original.x + screen.x / self.viewport.zoom,
|
||||
y: self.original.y + screen.y / self.viewport.zoom,
|
||||
};
|
||||
if !finite_position(desired) {
|
||||
self.retire();
|
||||
return false;
|
||||
}
|
||||
// Returning to the original position must not align an off-grid anchor
|
||||
// as an accidental edit. It is still a drag, so selection stays grouped.
|
||||
let delta = if desired == self.original {
|
||||
Position { x: 0., y: 0. }
|
||||
} else {
|
||||
Position {
|
||||
x: (desired.x / 10.).round() * 10. - self.original.x,
|
||||
y: (desired.y / 10.).round() * 10. - self.original.y,
|
||||
}
|
||||
};
|
||||
if !finite_position(delta) {
|
||||
self.retire();
|
||||
return false;
|
||||
}
|
||||
self.delta = delta;
|
||||
true
|
||||
}
|
||||
|
||||
/// Consume the final pointer too: a release can be the first displaced
|
||||
/// event. The caller must validate snapshot and viewport before acting on
|
||||
/// either outcome. Cancelling is dropping the gesture without calling this.
|
||||
pub fn release(mut self, pointer: Point) -> Option<Outcome> {
|
||||
if !self.move_to(pointer) {
|
||||
return None;
|
||||
}
|
||||
Some(if self.crossed_threshold {
|
||||
Outcome::Move(self.snapshot, self.delta)
|
||||
} else {
|
||||
Outcome::Click(self.anchor)
|
||||
})
|
||||
}
|
||||
|
||||
fn retire(&mut self) {
|
||||
self.retired = true;
|
||||
self.delta = Position { x: 0., y: 0. };
|
||||
}
|
||||
}
|
||||
|
||||
impl Viewport {
|
||||
fn valid(self) -> bool {
|
||||
self.zoom.is_finite()
|
||||
&& self.zoom > 0.
|
||||
&& self.pan.x.is_finite()
|
||||
&& self.pan.y.is_finite()
|
||||
&& self.bounds.x.is_finite()
|
||||
&& self.bounds.y.is_finite()
|
||||
&& self.bounds.width.is_finite()
|
||||
&& self.bounds.height.is_finite()
|
||||
&& self.bounds.width > 0.
|
||||
&& self.bounds.height > 0.
|
||||
&& (self.bounds.x + self.bounds.width).is_finite()
|
||||
&& (self.bounds.y + self.bounds.height).is_finite()
|
||||
}
|
||||
}
|
||||
|
||||
fn finite_point(point: Point) -> bool {
|
||||
point.x.is_finite() && point.y.is_finite()
|
||||
}
|
||||
|
||||
fn finite_position(position: Position) -> bool {
|
||||
position.x.is_finite() && position.y.is_finite()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::diagram_selection::{Context, Scope, Selection};
|
||||
|
||||
fn id(value: u128) -> Id {
|
||||
Id::from_u128(value)
|
||||
}
|
||||
|
||||
fn context() -> Context {
|
||||
Context {
|
||||
scope: Scope {
|
||||
document: id(1),
|
||||
surface: Surface::Saved(id(2)),
|
||||
},
|
||||
revision: 3,
|
||||
epoch: 4,
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot(targets: impl IntoIterator<Item = Target>) -> Snapshot {
|
||||
let mut selection = Selection::new(context().scope);
|
||||
for target in targets {
|
||||
selection.add(target, |_| true);
|
||||
}
|
||||
selection.snapshot(context()).unwrap()
|
||||
}
|
||||
|
||||
fn viewport() -> Viewport {
|
||||
Viewport {
|
||||
zoom: 1.,
|
||||
pan: Vector::new(17., -8.),
|
||||
bounds: Rectangle {
|
||||
x: 100.,
|
||||
y: 50.,
|
||||
width: 400.,
|
||||
height: 300.,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn press() -> Point {
|
||||
Point::new(150., 120.)
|
||||
}
|
||||
|
||||
fn gesture(original: Position) -> Gesture {
|
||||
Gesture::new(
|
||||
snapshot([Target::Placement(id(10)), Target::Placement(id(11))]),
|
||||
id(10),
|
||||
original,
|
||||
press(),
|
||||
viewport(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stationary_or_exact_threshold_release_is_click_without_snapping() {
|
||||
for pointer in [press(), Point::new(153., 120.), Point::new(152., 122.)] {
|
||||
let mut gesture = gesture(Position { x: 13., y: -7. });
|
||||
assert!(gesture.move_to(pointer));
|
||||
assert!(!gesture.crossed_threshold());
|
||||
assert_eq!(gesture.delta(), Position { x: 0., y: 0. });
|
||||
assert_eq!(gesture.release(pointer), Some(Outcome::Click(id(10))));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn threshold_latches_and_return_to_offgrid_origin_is_zero_move() {
|
||||
let mut gesture = gesture(Position { x: 13., y: -7. });
|
||||
let captured = gesture.snapshot().clone();
|
||||
assert!(gesture.move_to(Point::new(170., 140.)));
|
||||
assert!(gesture.crossed_threshold());
|
||||
assert!(gesture.move_to(press()));
|
||||
assert!(gesture.crossed_threshold());
|
||||
assert_eq!(gesture.delta(), Position { x: 0., y: 0. });
|
||||
assert_eq!(
|
||||
gesture.release(press()),
|
||||
Some(Outcome::Move(captured, Position { x: 0., y: 0. }))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_only_displacement_that_snaps_to_zero_still_preserves_group() {
|
||||
let gesture = gesture(Position { x: 20., y: 40. });
|
||||
let captured = gesture.snapshot().clone();
|
||||
assert_eq!(
|
||||
gesture.release(Point::new(154., 120.)),
|
||||
Some(Outcome::Move(captured, Position { x: 0., y: 0. }))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offgrid_anchor_yields_one_shared_delta_and_release_matches_preview() {
|
||||
let mut gesture = gesture(Position { x: 13., y: -7. });
|
||||
let captured = gesture.snapshot().clone();
|
||||
assert_eq!(gesture.anchor(), id(10));
|
||||
let pointer = Point::new(167., 133.);
|
||||
assert!(gesture.move_to(pointer));
|
||||
// Desired anchor (30,6) snaps to (30,10), hence shared delta (17,17).
|
||||
assert_eq!(gesture.delta(), Position { x: 17., y: 17. });
|
||||
let sibling = Position { x: -6., y: 24. };
|
||||
let translated = Position {
|
||||
x: sibling.x + gesture.delta().x,
|
||||
y: sibling.y + gesture.delta().y,
|
||||
};
|
||||
assert_eq!(translated, Position { x: 11., y: 41. });
|
||||
assert_eq!(
|
||||
gesture.release(pointer),
|
||||
Some(Outcome::Move(captured, Position { x: 17., y: 17. }))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_scales_world_delta_but_threshold_remains_in_screen_pixels() {
|
||||
let mut gesture = Gesture::new(
|
||||
snapshot([Target::Placement(id(10))]),
|
||||
id(10),
|
||||
Position { x: 20., y: 40. },
|
||||
press(),
|
||||
Viewport {
|
||||
zoom: 2.,
|
||||
..viewport()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(gesture.move_to(Point::new(153., 120.)));
|
||||
assert!(!gesture.crossed_threshold());
|
||||
assert!(gesture.move_to(Point::new(168., 112.)));
|
||||
// Screen (18,-8) means world (9,-4), snapped anchor (30,40).
|
||||
assert_eq!(gesture.delta(), Position { x: 10., y: 0. });
|
||||
assert!(gesture.crossed_threshold());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_typed_selection_and_saved_surface_are_required() {
|
||||
for targets in [
|
||||
vec![],
|
||||
vec![Target::Placement(id(11))],
|
||||
vec![Target::Placement(id(10)), Target::Edge(id(20))],
|
||||
vec![
|
||||
Target::Placement(id(10)),
|
||||
Target::Port {
|
||||
port: id(20),
|
||||
placement: id(10),
|
||||
},
|
||||
],
|
||||
] {
|
||||
assert!(
|
||||
Gesture::new(
|
||||
snapshot(targets),
|
||||
id(10),
|
||||
Position { x: 0., y: 0. },
|
||||
press(),
|
||||
viewport()
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
let other = Context {
|
||||
scope: Scope {
|
||||
surface: Surface::Process(id(2)),
|
||||
..context().scope
|
||||
},
|
||||
..context()
|
||||
};
|
||||
let mut selection = Selection::new(other.scope);
|
||||
selection.add(Target::Placement(id(10)), |_| true);
|
||||
assert!(
|
||||
Gesture::new(
|
||||
selection.snapshot(other).unwrap(),
|
||||
id(10),
|
||||
Position { x: 0., y: 0. },
|
||||
press(),
|
||||
viewport()
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn any_frozen_viewport_change_is_detected() {
|
||||
let gesture = gesture(Position { x: 0., y: 0. });
|
||||
assert!(gesture.viewport_matches(viewport()));
|
||||
for changed in [
|
||||
Viewport {
|
||||
zoom: 2.,
|
||||
..viewport()
|
||||
},
|
||||
Viewport {
|
||||
pan: Vector::new(18., -8.),
|
||||
..viewport()
|
||||
},
|
||||
Viewport {
|
||||
pan: Vector::new(17., -7.),
|
||||
..viewport()
|
||||
},
|
||||
Viewport {
|
||||
bounds: Rectangle {
|
||||
x: 101.,
|
||||
..viewport().bounds
|
||||
},
|
||||
..viewport()
|
||||
},
|
||||
Viewport {
|
||||
bounds: Rectangle {
|
||||
y: 51.,
|
||||
..viewport().bounds
|
||||
},
|
||||
..viewport()
|
||||
},
|
||||
Viewport {
|
||||
bounds: Rectangle {
|
||||
width: 401.,
|
||||
..viewport().bounds
|
||||
},
|
||||
..viewport()
|
||||
},
|
||||
Viewport {
|
||||
bounds: Rectangle {
|
||||
height: 301.,
|
||||
..viewport().bounds
|
||||
},
|
||||
..viewport()
|
||||
},
|
||||
] {
|
||||
assert!(!gesture.viewport_matches(changed));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_initial_geometry_cannot_start() {
|
||||
let saved = snapshot([Target::Placement(id(10))]);
|
||||
for invalid in [
|
||||
Viewport {
|
||||
zoom: 0.,
|
||||
..viewport()
|
||||
},
|
||||
Viewport {
|
||||
zoom: -1.,
|
||||
..viewport()
|
||||
},
|
||||
Viewport {
|
||||
zoom: f32::NAN,
|
||||
..viewport()
|
||||
},
|
||||
Viewport {
|
||||
zoom: f32::INFINITY,
|
||||
..viewport()
|
||||
},
|
||||
Viewport {
|
||||
pan: Vector::new(f32::NAN, 0.),
|
||||
..viewport()
|
||||
},
|
||||
Viewport {
|
||||
bounds: Rectangle {
|
||||
width: 0.,
|
||||
..viewport().bounds
|
||||
},
|
||||
..viewport()
|
||||
},
|
||||
Viewport {
|
||||
bounds: Rectangle {
|
||||
height: -1.,
|
||||
..viewport().bounds
|
||||
},
|
||||
..viewport()
|
||||
},
|
||||
Viewport {
|
||||
bounds: Rectangle {
|
||||
x: f32::INFINITY,
|
||||
..viewport().bounds
|
||||
},
|
||||
..viewport()
|
||||
},
|
||||
] {
|
||||
assert!(
|
||||
Gesture::new(
|
||||
saved.clone(),
|
||||
id(10),
|
||||
Position { x: 0., y: 0. },
|
||||
press(),
|
||||
invalid
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
Gesture::new(
|
||||
saved.clone(),
|
||||
id(10),
|
||||
Position { x: f32::NAN, y: 0. },
|
||||
press(),
|
||||
viewport()
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
for invalid in [Point::new(f32::NAN, 120.), Point::new(99., 120.)] {
|
||||
assert!(
|
||||
Gesture::new(
|
||||
saved.clone(),
|
||||
id(10),
|
||||
Position { x: 0., y: 0. },
|
||||
invalid,
|
||||
viewport()
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_pointer_permanently_retires_even_if_pointer_returns() {
|
||||
for invalid in [Point::new(f32::NAN, 120.), Point::new(501., 120.)] {
|
||||
let mut gesture = gesture(Position { x: 20., y: 40. });
|
||||
assert!(gesture.move_to(Point::new(170., 140.)));
|
||||
assert!(!gesture.move_to(invalid));
|
||||
assert_eq!(gesture.delta(), Position { x: 0., y: 0. });
|
||||
assert!(!gesture.move_to(press()));
|
||||
assert!(!gesture.viewport_matches(viewport()));
|
||||
assert_eq!(gesture.release(press()), None);
|
||||
}
|
||||
assert_eq!(
|
||||
gesture(Position { x: 20., y: 40. }).release(Point::new(501., 120.)),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finite_inputs_that_overflow_world_geometry_retire_without_an_operation() {
|
||||
let mut gesture = Gesture::new(
|
||||
snapshot([Target::Placement(id(10))]),
|
||||
id(10),
|
||||
Position { x: 0., y: 0. },
|
||||
press(),
|
||||
Viewport {
|
||||
zoom: f32::MIN_POSITIVE,
|
||||
..viewport()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!gesture.move_to(Point::new(170., 140.)));
|
||||
assert_eq!(gesture.release(press()), None);
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,7 @@ fn bounds() -> Rectangle {
|
||||
}
|
||||
fn canvas(app: &App) -> DiagramCanvas<'_> {
|
||||
DiagramCanvas {
|
||||
selection: None,
|
||||
project: app.project(),
|
||||
view_index: None,
|
||||
diagram: app.active_diagram.unwrap(),
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
use super::group_gesture::{Gesture as GroupGesture, Outcome, Viewport};
|
||||
use super::*;
|
||||
use crate::app::multiselect::{Choice, Event, context};
|
||||
use crate::diagram_selection::Snapshot;
|
||||
use crate::diagram_usage::Target;
|
||||
|
||||
/// Even an unsupported mixed group must retain an ordinary click lifecycle.
|
||||
pub(super) struct Press {
|
||||
snapshot: Snapshot,
|
||||
target: Target,
|
||||
start: Point,
|
||||
viewport: Viewport,
|
||||
movement: Option<GroupGesture>,
|
||||
reason: Option<String>,
|
||||
crossed: bool,
|
||||
retired: bool,
|
||||
}
|
||||
impl Press {
|
||||
fn snapshot(&self) -> &Snapshot {
|
||||
self.movement
|
||||
.as_ref()
|
||||
.map_or(&self.snapshot, GroupGesture::snapshot)
|
||||
}
|
||||
fn viewport_matches(&self, viewport: Viewport) -> bool {
|
||||
!self.retired
|
||||
&& self.viewport == viewport
|
||||
&& self
|
||||
.movement
|
||||
.as_ref()
|
||||
.is_none_or(|movement| movement.viewport_matches(viewport))
|
||||
}
|
||||
fn crossed_threshold(&self) -> bool {
|
||||
self.movement
|
||||
.as_ref()
|
||||
.map_or(self.crossed, GroupGesture::crossed_threshold)
|
||||
}
|
||||
fn delta(&self) -> Position {
|
||||
self.movement
|
||||
.as_ref()
|
||||
.map_or(Position { x: 0., y: 0. }, GroupGesture::delta)
|
||||
}
|
||||
fn move_to(&mut self, pointer: Point) {
|
||||
if self.retired {
|
||||
return;
|
||||
}
|
||||
if !pointer.x.is_finite()
|
||||
|| !pointer.y.is_finite()
|
||||
|| !self.viewport.bounds.contains(pointer)
|
||||
{
|
||||
self.retired = true;
|
||||
self.movement = None;
|
||||
return;
|
||||
}
|
||||
self.crossed |= pointer.distance(self.start) > 3.;
|
||||
if let Some(movement) = &mut self.movement
|
||||
&& !movement.move_to(pointer)
|
||||
{
|
||||
self.retired = true;
|
||||
}
|
||||
}
|
||||
fn release(mut self, pointer: Point) -> Option<Event> {
|
||||
self.move_to(pointer);
|
||||
if self.retired {
|
||||
return None;
|
||||
}
|
||||
if !self.crossed {
|
||||
return Some(Event::Collapse(self.snapshot, self.target));
|
||||
}
|
||||
if let Some(reason) = self.reason {
|
||||
return Some(Event::Refuse(self.snapshot, reason));
|
||||
}
|
||||
match self.movement?.release(pointer)? {
|
||||
Outcome::Click(id) => Some(Event::Collapse(self.snapshot, Target::Placement(id))),
|
||||
Outcome::Move(snapshot, delta) => Some(Event::Move(snapshot, delta)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DiagramCanvas<'_> {
|
||||
pub(super) fn selection_contains(&self, target: Target) -> bool {
|
||||
self.selection
|
||||
.is_some_and(|s| s.scope() == context(self.context).scope && s.contains(&target))
|
||||
}
|
||||
|
||||
pub(super) fn group_input(
|
||||
&self,
|
||||
state: &mut State,
|
||||
event: &canvas::Event,
|
||||
bounds: Rectangle,
|
||||
cursor: mouse::Cursor,
|
||||
) -> Option<Action<Message>> {
|
||||
let viewport = Viewport {
|
||||
zoom: self.zoom,
|
||||
pan: self.pan,
|
||||
bounds,
|
||||
};
|
||||
if let Some(gesture) = &state.group {
|
||||
let valid = gesture.viewport_matches(viewport)
|
||||
&& self.editable
|
||||
&& self.connection.is_none()
|
||||
&& !self.placing
|
||||
&& self
|
||||
.selection
|
||||
.is_some_and(|s| s.matches(gesture.snapshot(), context(self.context)));
|
||||
let cancelled = matches!(
|
||||
event,
|
||||
canvas::Event::Window(
|
||||
iced::window::Event::Unfocused | iced::window::Event::Resized(_)
|
||||
) | canvas::Event::Mouse(
|
||||
mouse::Event::CursorLeft
|
||||
| mouse::Event::WheelScrolled { .. }
|
||||
| mouse::Event::ButtonPressed(mouse::Button::Right | mouse::Button::Middle)
|
||||
) | canvas::Event::Keyboard(iced::keyboard::Event::KeyPressed {
|
||||
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
|
||||
..
|
||||
})
|
||||
);
|
||||
if !valid || cancelled {
|
||||
state.group = None;
|
||||
*state.cache.borrow_mut() = None;
|
||||
return Some(Action::request_redraw().and_capture());
|
||||
}
|
||||
}
|
||||
if let Some(gesture) = &mut state.group {
|
||||
match event {
|
||||
canvas::Event::Mouse(mouse::Event::CursorMoved { position }) => {
|
||||
gesture.move_to(*position);
|
||||
*state.cache.borrow_mut() = None;
|
||||
return Some(Action::request_redraw().and_capture());
|
||||
}
|
||||
canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
|
||||
let gesture = state.group.take().unwrap();
|
||||
*state.cache.borrow_mut() = None;
|
||||
let outcome = cursor
|
||||
.position()
|
||||
.and_then(|pointer| gesture.release(pointer));
|
||||
return Some(match outcome {
|
||||
Some(event) => {
|
||||
Action::publish(Message::DiagramSelection(event)).and_capture()
|
||||
}
|
||||
None => Action::request_redraw().and_capture(),
|
||||
});
|
||||
}
|
||||
canvas::Event::Keyboard(_) => return Some(Action::capture()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let selection = self
|
||||
.selection
|
||||
.filter(|s| s.scope() == context(self.context).scope)?;
|
||||
if state.pan_start.is_some() {
|
||||
return None;
|
||||
}
|
||||
if self.connection.is_some() || self.placing {
|
||||
return None;
|
||||
}
|
||||
if let canvas::Event::Keyboard(iced::keyboard::Event::KeyPressed {
|
||||
key, modifiers, ..
|
||||
}) = event
|
||||
{
|
||||
use iced::keyboard::{Key, key::Named};
|
||||
if *key == Key::Named(Named::Escape)
|
||||
&& state.drag.is_none()
|
||||
&& state.route_drag.is_none()
|
||||
&& state.port_drag.is_none()
|
||||
&& state.work.is_none()
|
||||
{
|
||||
return Some(
|
||||
Action::publish(Message::DiagramSelection(Event::Clear(context(
|
||||
self.context,
|
||||
))))
|
||||
.and_capture(),
|
||||
);
|
||||
}
|
||||
if selection.targets().len() > 1 && *key == Key::Named(Named::Delete) {
|
||||
let snapshot = selection.snapshot(context(self.context))?;
|
||||
let action = if modifiers.is_empty() {
|
||||
Event::Hide(snapshot)
|
||||
} else {
|
||||
Event::Refuse(snapshot, "Use Hide selected to remove placements. Group model-deletion review is not available yet.".into())
|
||||
};
|
||||
return Some(Action::publish(Message::DiagramSelection(action)).and_capture());
|
||||
}
|
||||
}
|
||||
if !matches!(
|
||||
event,
|
||||
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
|
||||
) || !cursor.is_over(bounds)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
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) {
|
||||
Some(Target::Port {
|
||||
port: port.element,
|
||||
placement: port.owner,
|
||||
})
|
||||
} else if let Some(id) = self.hit(&scene.view, local) {
|
||||
Some(Target::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(Target::Edge)
|
||||
}?;
|
||||
if state.modifiers.shift() {
|
||||
state.drag = None;
|
||||
state.preview = None;
|
||||
state.port_drag = None;
|
||||
state.route_drag = None;
|
||||
return Some(
|
||||
Action::publish(Message::DiagramSelection(Event::Choose(
|
||||
context(self.context),
|
||||
target,
|
||||
Choice::Toggle,
|
||||
)))
|
||||
.and_capture(),
|
||||
);
|
||||
}
|
||||
if selection.targets().len() <= 1 || !selection.contains(&target) {
|
||||
return None;
|
||||
}
|
||||
let snapshot = selection.snapshot(context(self.context))?;
|
||||
if !self.editable {
|
||||
return Some(Action::capture());
|
||||
}
|
||||
let reason = if crate::app::multiselect::placements(&snapshot).is_none() {
|
||||
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
|
||||
};
|
||||
let movement = if reason.is_none()
|
||||
&& let Target::Placement(anchor) = target
|
||||
{
|
||||
let original = self.project.diagrams[&self.diagram].nodes[&anchor].position;
|
||||
GroupGesture::new(snapshot.clone(), anchor, original, pointer, viewport)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if movement.is_none() && reason.is_none() {
|
||||
return Some(Action::capture());
|
||||
}
|
||||
state.group = Some(Press {
|
||||
snapshot,
|
||||
target,
|
||||
start: pointer,
|
||||
viewport,
|
||||
movement,
|
||||
reason: reason.map(str::to_owned),
|
||||
crossed: false,
|
||||
retired: false,
|
||||
});
|
||||
*state.cache.borrow_mut() = None;
|
||||
Some(Action::request_redraw().and_capture())
|
||||
}
|
||||
|
||||
pub(super) fn group_preview(&self, state: &State, diagram: &mut Diagram) {
|
||||
let Some(gesture) = &state.group else {
|
||||
return;
|
||||
};
|
||||
if !gesture.crossed_threshold() {
|
||||
return;
|
||||
}
|
||||
let Some(ids) = crate::app::multiselect::placements(gesture.snapshot()) else {
|
||||
return;
|
||||
};
|
||||
let Ok(Command::Batch(commands)) = oa_core::diagram_edit::translate_placements(
|
||||
self.project,
|
||||
self.diagram,
|
||||
&ids,
|
||||
gesture.delta(),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
// Apply only the planner's presentation records. Full model validation
|
||||
// and source/history updates occur once, when App accepts the release.
|
||||
for command in commands {
|
||||
match command {
|
||||
Command::EditNode { node, .. } => {
|
||||
diagram.nodes.insert(node.id, node);
|
||||
}
|
||||
Command::SetEdgeRoute { route, .. } => route_edit::with_route(diagram, route),
|
||||
_ => unreachable!("placement planner returned a non-geometry command"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,758 @@
|
||||
//! Production canvas event regressions for exact multi-selection and group gestures.
|
||||
use super::{DiagramCanvas, State};
|
||||
use crate::app::{
|
||||
Message,
|
||||
multiselect::{Choice, Event as SelectionEvent, context},
|
||||
};
|
||||
use crate::diagram_selection::Selection;
|
||||
use crate::diagram_usage::Target;
|
||||
use iced::widget::canvas::{self, Program};
|
||||
use iced::{Point, Rectangle, Size, Vector, event::Status, keyboard, mouse};
|
||||
use oa_core::*;
|
||||
|
||||
fn position(x: f32, y: f32) -> Position {
|
||||
Position { x, y }
|
||||
}
|
||||
fn bounds() -> Rectangle {
|
||||
Rectangle::new(Point::new(260., 100.), Size::new(2400., 1800.))
|
||||
}
|
||||
|
||||
struct Fixture {
|
||||
project: Project,
|
||||
diagram: Id,
|
||||
a: Id,
|
||||
b: Id,
|
||||
child: Id,
|
||||
internal: Id,
|
||||
context: crate::route_edit::Context,
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
let mut project = Project::empty();
|
||||
let a = project
|
||||
.add_element(Kind::Activity, Layer::Oa, "A", None)
|
||||
.unwrap();
|
||||
let b = project
|
||||
.add_element(Kind::Activity, Layer::Oa, "B", None)
|
||||
.unwrap();
|
||||
let c = project
|
||||
.add_element(Kind::Activity, Layer::Oa, "C", None)
|
||||
.unwrap();
|
||||
let child = project
|
||||
.add_element(Kind::Activity, Layer::Oa, "Child", Some(a))
|
||||
.unwrap();
|
||||
let internal = project.relate(RelationKind::Interaction, "Internal", a, b);
|
||||
let external = project.relate(RelationKind::Interaction, "External", b, c);
|
||||
let diagram = project.ensure_diagram(Layer::Oa);
|
||||
project.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Overview;
|
||||
let a = project.place(diagram, a, position(100., 200.));
|
||||
let b = project.place(diagram, b, position(500., 200.));
|
||||
let child = project.place(diagram, child, position(120., 280.));
|
||||
project.place(diagram, c, position(900., 200.));
|
||||
let view = project.diagrams.get_mut(&diagram).unwrap();
|
||||
view.nodes.get_mut(&a).unwrap().collapsed = true;
|
||||
view.routes = [
|
||||
(internal, position(400., 320.)),
|
||||
(external, position(800., 340.)),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(edge, point)| EdgeRoute {
|
||||
edge,
|
||||
source_attachment: None,
|
||||
target_attachment: None,
|
||||
waypoints: vec![point],
|
||||
label: Some(EdgeLabel {
|
||||
fraction: 0.4,
|
||||
offset: position(3., -9.),
|
||||
}),
|
||||
})
|
||||
.collect();
|
||||
validate_candidate(&project).unwrap();
|
||||
Self {
|
||||
project,
|
||||
diagram,
|
||||
a,
|
||||
b,
|
||||
child,
|
||||
internal,
|
||||
context: crate::route_edit::Context {
|
||||
document: Id::new_v4(),
|
||||
diagram,
|
||||
revision: 7,
|
||||
epoch: 11,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn selection(&self, targets: impl IntoIterator<Item = Target>) -> Selection {
|
||||
let mut selection = Selection::new(context(self.context).scope);
|
||||
for target in targets {
|
||||
assert!(selection.add(target, |_| true));
|
||||
}
|
||||
selection
|
||||
}
|
||||
|
||||
fn pair(&self) -> Selection {
|
||||
self.selection([Target::Placement(self.a), Target::Placement(self.b)])
|
||||
}
|
||||
|
||||
fn canvas<'a>(&'a self, selection: &'a Selection, zoom: f32) -> DiagramCanvas<'a> {
|
||||
let selected = match selection.primary() {
|
||||
Some(Target::Placement(id)) => self.project.diagrams[&self.diagram].nodes[id].element,
|
||||
Some(Target::Edge(id)) => *id,
|
||||
Some(Target::Port { port, .. }) => *port,
|
||||
_ => self.diagram,
|
||||
};
|
||||
DiagramCanvas {
|
||||
selection: Some(selection),
|
||||
project: &self.project,
|
||||
view_index: None,
|
||||
diagram: self.diagram,
|
||||
selected,
|
||||
navigation_focus: None,
|
||||
zoom,
|
||||
pan: Vector::new(20., 30.),
|
||||
context: self.context,
|
||||
selected_bend: None,
|
||||
selected_attachment: None,
|
||||
editable: true,
|
||||
placing: false,
|
||||
connection: None,
|
||||
allocation: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn screen(&self, placement: Id, zoom: f32) -> Point {
|
||||
let p = self.project.diagrams[&self.diagram].nodes[&placement].position;
|
||||
Point::new(
|
||||
bounds().x + (p.x + 30.) * zoom + 20.,
|
||||
bounds().y + (p.y + 25.) * zoom + 30.,
|
||||
)
|
||||
}
|
||||
|
||||
fn expected_move(&self) -> Diagram {
|
||||
// Independent literal expectations: the folded child moves with A,
|
||||
// only the internal edge's authored world pin translates.
|
||||
let mut expected = self.project.diagrams[&self.diagram].clone();
|
||||
expected.nodes.get_mut(&self.a).unwrap().position = position(140., 260.);
|
||||
expected.nodes.get_mut(&self.b).unwrap().position = position(540., 260.);
|
||||
expected.nodes.get_mut(&self.child).unwrap().position = position(160., 340.);
|
||||
expected.routes[0].waypoints = vec![position(440., 380.)];
|
||||
expected
|
||||
}
|
||||
}
|
||||
|
||||
fn input(
|
||||
c: &DiagramCanvas<'_>,
|
||||
state: &mut State,
|
||||
event: canvas::Event,
|
||||
viewport: Rectangle,
|
||||
final_cursor: Point,
|
||||
) -> (Option<Message>, Status) {
|
||||
c.update(
|
||||
state,
|
||||
&event,
|
||||
viewport,
|
||||
mouse::Cursor::Available(final_cursor),
|
||||
)
|
||||
.map_or((None, Status::Ignored), |action| {
|
||||
let (message, _, status) = action.into_inner();
|
||||
(message, status)
|
||||
})
|
||||
}
|
||||
|
||||
fn pointer(
|
||||
c: &DiagramCanvas<'_>,
|
||||
state: &mut State,
|
||||
event: mouse::Event,
|
||||
final_cursor: Point,
|
||||
) -> (Option<Message>, Status) {
|
||||
input(
|
||||
c,
|
||||
state,
|
||||
canvas::Event::Mouse(event),
|
||||
bounds(),
|
||||
final_cursor,
|
||||
)
|
||||
}
|
||||
|
||||
fn start(c: &DiagramCanvas<'_>, state: &mut State, press: Point, final_cursor: Point) {
|
||||
assert!(
|
||||
pointer(
|
||||
c,
|
||||
state,
|
||||
mouse::Event::CursorMoved { position: press },
|
||||
final_cursor
|
||||
)
|
||||
.0
|
||||
.is_none()
|
||||
);
|
||||
let (message, status) = pointer(
|
||||
c,
|
||||
state,
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
final_cursor,
|
||||
);
|
||||
assert!(message.is_none());
|
||||
assert_eq!(status, Status::Captured);
|
||||
assert!(state.group.is_some());
|
||||
assert!(state.drag.is_none());
|
||||
}
|
||||
|
||||
fn key(named: keyboard::key::Named) -> canvas::Event {
|
||||
canvas::Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: keyboard::Key::Named(named),
|
||||
modified_key: keyboard::Key::Named(named),
|
||||
physical_key: keyboard::key::Physical::Code(if named == keyboard::key::Named::Escape {
|
||||
keyboard::key::Code::Escape
|
||||
} else {
|
||||
keyboard::key::Code::Delete
|
||||
}),
|
||||
location: keyboard::Location::Standard,
|
||||
modifiers: keyboard::Modifiers::empty(),
|
||||
text: None,
|
||||
repeat: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn expect_move(
|
||||
message: Option<Message>,
|
||||
selection: &Selection,
|
||||
current: crate::route_edit::Context,
|
||||
expected: Position,
|
||||
) -> crate::diagram_selection::Snapshot {
|
||||
let Some(Message::DiagramSelection(SelectionEvent::Move(snapshot, delta))) = message else {
|
||||
panic!("expected one captured group Move");
|
||||
};
|
||||
assert_eq!(delta, expected);
|
||||
assert!(selection.matches(&snapshot, context(current)));
|
||||
snapshot
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_press_uses_ordered_hit_not_native_batch_final_cursor() {
|
||||
let f = Fixture::new();
|
||||
let selection = f.selection([Target::Placement(f.b)]);
|
||||
let before = selection.snapshot(context(f.context)).unwrap();
|
||||
let c = f.canvas(&selection, 1.);
|
||||
let mut state = State::default();
|
||||
let at_a = f.screen(f.a, 1.);
|
||||
let final_cursor = f.screen(f.b, 1.);
|
||||
assert!(
|
||||
input(
|
||||
&c,
|
||||
&mut state,
|
||||
canvas::Event::Keyboard(keyboard::Event::ModifiersChanged(
|
||||
keyboard::Modifiers::SHIFT
|
||||
)),
|
||||
bounds(),
|
||||
final_cursor
|
||||
)
|
||||
.0
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
pointer(
|
||||
&c,
|
||||
&mut state,
|
||||
mouse::Event::CursorMoved { position: at_a },
|
||||
final_cursor
|
||||
)
|
||||
.0
|
||||
.is_none()
|
||||
);
|
||||
let (message, status) = pointer(
|
||||
&c,
|
||||
&mut state,
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
final_cursor,
|
||||
);
|
||||
assert_eq!(status, Status::Captured);
|
||||
assert!(
|
||||
matches!(message, Some(Message::DiagramSelection(SelectionEvent::Choose(captured, Target::Placement(id), Choice::Toggle))) if captured == context(f.context) && id == f.a)
|
||||
);
|
||||
assert!(
|
||||
state.group.is_none()
|
||||
&& state.drag.is_none()
|
||||
&& state.route_drag.is_none()
|
||||
&& state.port_drag.is_none()
|
||||
);
|
||||
assert!(
|
||||
pointer(
|
||||
&c,
|
||||
&mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
final_cursor
|
||||
)
|
||||
.0
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
selection.matches(&before, context(f.context)),
|
||||
"canvas emits intent without mutating selection"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_canvas_preview_and_release_match_independent_expected_geometry() {
|
||||
for zoom in [0.5, 1., 2.] {
|
||||
let f = Fixture::new();
|
||||
let selection = f.pair();
|
||||
let captured = selection.snapshot(context(f.context)).unwrap();
|
||||
let c = f.canvas(&selection, zoom);
|
||||
let mut state = State::default();
|
||||
let press = f.screen(f.a, zoom);
|
||||
let end = press + Vector::new(40. * zoom, 60. * zoom);
|
||||
start(&c, &mut state, press, end);
|
||||
assert_eq!(c.preview_diagram(&state), f.project.diagrams[&f.diagram]);
|
||||
let (message, status) = pointer(
|
||||
&c,
|
||||
&mut state,
|
||||
mouse::Event::CursorMoved { position: end },
|
||||
end,
|
||||
);
|
||||
assert!(message.is_none());
|
||||
assert_eq!(status, Status::Captured);
|
||||
assert_eq!(c.preview_diagram(&state), f.expected_move());
|
||||
let (message, status) = pointer(
|
||||
&c,
|
||||
&mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
end,
|
||||
);
|
||||
assert_eq!(status, Status::Captured);
|
||||
let snapshot = expect_move(message, &selection, f.context, position(40., 60.));
|
||||
assert_eq!(snapshot, captured);
|
||||
assert!(state.group.is_none());
|
||||
let ids = crate::app::multiselect::placements(&snapshot).unwrap();
|
||||
let command =
|
||||
diagram_edit::translate_placements(&f.project, f.diagram, &ids, position(40., 60.))
|
||||
.unwrap();
|
||||
let actual = prepare_command(&f.project, &command).unwrap().project;
|
||||
let mut expected = f.project.clone();
|
||||
expected.diagrams.insert(f.diagram, f.expected_move());
|
||||
assert_eq!(actual, expected);
|
||||
assert!(selection.matches(&captured, context(f.context)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stationary_group_click_does_not_use_motion_after_release_in_same_batch() {
|
||||
let f = Fixture::new();
|
||||
let selection = f.pair();
|
||||
let c = f.canvas(&selection, 1.);
|
||||
let mut state = State::default();
|
||||
let press = f.screen(f.a, 1.);
|
||||
let end = press + Vector::new(80., 70.);
|
||||
start(&c, &mut state, press, end);
|
||||
let (message, _) = pointer(
|
||||
&c,
|
||||
&mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
end,
|
||||
);
|
||||
assert!(
|
||||
matches!(message, Some(Message::DiagramSelection(SelectionEvent::Collapse(captured, Target::Placement(id)))) if selection.matches(&captured, context(f.context)) && id == f.a)
|
||||
);
|
||||
assert!(
|
||||
pointer(
|
||||
&c,
|
||||
&mut state,
|
||||
mouse::Event::CursorMoved { position: end },
|
||||
end
|
||||
)
|
||||
.0
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(c.preview_diagram(&state), f.project.diagrams[&f.diagram]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_set_keeps_click_lifecycle_and_refuses_only_actual_drag() {
|
||||
for moved in [false, true] {
|
||||
let f = Fixture::new();
|
||||
let selection = f.selection([Target::Placement(f.a), Target::Edge(f.internal)]);
|
||||
let captured = selection.snapshot(context(f.context)).unwrap();
|
||||
let c = f.canvas(&selection, 1.);
|
||||
let mut state = State::default();
|
||||
let press = f.screen(f.a, 1.);
|
||||
let end = if moved {
|
||||
press + Vector::new(40., 60.)
|
||||
} else {
|
||||
press + Vector::new(2., 1.)
|
||||
};
|
||||
start(&c, &mut state, press, end);
|
||||
assert!(
|
||||
pointer(
|
||||
&c,
|
||||
&mut state,
|
||||
mouse::Event::CursorMoved { position: end },
|
||||
end
|
||||
)
|
||||
.0
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(c.preview_diagram(&state), f.project.diagrams[&f.diagram]);
|
||||
let (message, status) = pointer(
|
||||
&c,
|
||||
&mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
end,
|
||||
);
|
||||
assert_eq!(status, Status::Captured);
|
||||
if moved {
|
||||
assert!(
|
||||
matches!(message, Some(Message::DiagramSelection(SelectionEvent::Refuse(snapshot, reason))) if snapshot == captured && reason.contains("only placed elements"))
|
||||
);
|
||||
} else {
|
||||
assert!(
|
||||
matches!(message, Some(Message::DiagramSelection(SelectionEvent::Collapse(current, Target::Placement(id)))) if current == captured && id == f.a)
|
||||
);
|
||||
}
|
||||
assert!(state.group.is_none());
|
||||
assert!(selection.matches(&captured, context(f.context)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_cancels_live_group_without_clearing_or_publishing_a_model_intent() {
|
||||
let f = Fixture::new();
|
||||
let selection = f.pair();
|
||||
let captured = selection.snapshot(context(f.context)).unwrap();
|
||||
let c = f.canvas(&selection, 1.);
|
||||
let mut state = State::default();
|
||||
let press = f.screen(f.a, 1.);
|
||||
let end = press + Vector::new(40., 60.);
|
||||
start(&c, &mut state, press, end);
|
||||
assert!(
|
||||
pointer(
|
||||
&c,
|
||||
&mut state,
|
||||
mouse::Event::CursorMoved { position: end },
|
||||
end
|
||||
)
|
||||
.0
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(c.preview_diagram(&state), f.expected_move());
|
||||
let (message, status) = input(
|
||||
&c,
|
||||
&mut state,
|
||||
key(keyboard::key::Named::Escape),
|
||||
bounds(),
|
||||
end,
|
||||
);
|
||||
assert!(message.is_none());
|
||||
assert_eq!(status, Status::Captured);
|
||||
assert!(state.group.is_none());
|
||||
assert_eq!(c.preview_diagram(&state), f.project.diagrams[&f.diagram]);
|
||||
assert!(
|
||||
pointer(
|
||||
&c,
|
||||
&mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
end
|
||||
)
|
||||
.0
|
||||
.is_none()
|
||||
);
|
||||
assert!(selection.matches(&captured, context(f.context)));
|
||||
// A subsequent Escape, after the gesture is idle, may clear selection.
|
||||
assert!(matches!(
|
||||
input(
|
||||
&c,
|
||||
&mut state,
|
||||
key(keyboard::key::Named::Escape),
|
||||
bounds(),
|
||||
end
|
||||
)
|
||||
.0,
|
||||
Some(Message::DiagramSelection(SelectionEvent::Clear(_)))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_is_captured_without_hiding_during_live_group_then_release_moves_once() {
|
||||
let f = Fixture::new();
|
||||
let selection = f.pair();
|
||||
let c = f.canvas(&selection, 1.);
|
||||
let mut state = State::default();
|
||||
let press = f.screen(f.a, 1.);
|
||||
let end = press + Vector::new(40., 60.);
|
||||
start(&c, &mut state, press, end);
|
||||
assert!(
|
||||
pointer(
|
||||
&c,
|
||||
&mut state,
|
||||
mouse::Event::CursorMoved { position: end },
|
||||
end
|
||||
)
|
||||
.0
|
||||
.is_none()
|
||||
);
|
||||
let (message, status) = input(
|
||||
&c,
|
||||
&mut state,
|
||||
key(keyboard::key::Named::Delete),
|
||||
bounds(),
|
||||
end,
|
||||
);
|
||||
assert!(message.is_none());
|
||||
assert_eq!(status, Status::Captured);
|
||||
assert!(state.group.is_some());
|
||||
assert_eq!(c.preview_diagram(&state), f.expected_move());
|
||||
expect_move(
|
||||
pointer(
|
||||
&c,
|
||||
&mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
end,
|
||||
)
|
||||
.0,
|
||||
&selection,
|
||||
f.context,
|
||||
position(40., 60.),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn middle_pan_excludes_group_start_and_delete_even_when_left_is_pressed() {
|
||||
let f = Fixture::new();
|
||||
let selection = f.pair();
|
||||
let captured = selection.snapshot(context(f.context)).unwrap();
|
||||
let c = f.canvas(&selection, 1.);
|
||||
let mut state = State::default();
|
||||
let press = f.screen(f.a, 1.);
|
||||
let end = press + Vector::new(40., 60.);
|
||||
assert!(
|
||||
pointer(
|
||||
&c,
|
||||
&mut state,
|
||||
mouse::Event::CursorMoved { position: press },
|
||||
end
|
||||
)
|
||||
.0
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
pointer(
|
||||
&c,
|
||||
&mut state,
|
||||
mouse::Event::ButtonPressed(mouse::Button::Middle),
|
||||
end
|
||||
)
|
||||
.0
|
||||
.is_none()
|
||||
);
|
||||
let (message, status) = pointer(
|
||||
&c,
|
||||
&mut state,
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
end,
|
||||
);
|
||||
assert!(message.is_none());
|
||||
assert_eq!(status, Status::Captured);
|
||||
assert!(state.group.is_none() && state.drag.is_none());
|
||||
assert!(
|
||||
input(
|
||||
&c,
|
||||
&mut state,
|
||||
key(keyboard::key::Named::Delete),
|
||||
bounds(),
|
||||
end
|
||||
)
|
||||
.0
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
matches!(pointer(&c, &mut state, mouse::Event::CursorMoved { position: end }, end).0, Some(Message::Pan(delta)) if delta == Vector::new(40., 60.))
|
||||
);
|
||||
assert!(
|
||||
pointer(
|
||||
&c,
|
||||
&mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
end
|
||||
)
|
||||
.0
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
pointer(
|
||||
&c,
|
||||
&mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Middle),
|
||||
end
|
||||
)
|
||||
.0
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(c.preview_diagram(&state), f.project.diagrams[&f.diagram]);
|
||||
assert!(selection.matches(&captured, context(f.context)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changed_viewport_or_context_retires_group_without_a_release_intent() {
|
||||
for change in 0..7 {
|
||||
let f = Fixture::new();
|
||||
let selection = f.pair();
|
||||
let c = f.canvas(&selection, 1.);
|
||||
let mut state = State::default();
|
||||
let press = f.screen(f.a, 1.);
|
||||
let end = press + Vector::new(40., 60.);
|
||||
start(&c, &mut state, press, end);
|
||||
assert!(
|
||||
pointer(
|
||||
&c,
|
||||
&mut state,
|
||||
mouse::Event::CursorMoved { position: end },
|
||||
end
|
||||
)
|
||||
.0
|
||||
.is_none()
|
||||
);
|
||||
let mut changed = c;
|
||||
let mut viewport = bounds();
|
||||
match change {
|
||||
0 => viewport.x += 1.,
|
||||
1 => viewport.width -= 1.,
|
||||
2 => changed.pan.x += 1.,
|
||||
3 => changed.zoom = 2.,
|
||||
4 => changed.context.epoch += 1,
|
||||
5 => changed.context.revision += 1,
|
||||
_ => changed.context.document = Id::new_v4(),
|
||||
}
|
||||
let (message, _) = input(
|
||||
&changed,
|
||||
&mut state,
|
||||
canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
|
||||
viewport,
|
||||
end,
|
||||
);
|
||||
assert!(
|
||||
message.is_none(),
|
||||
"changed viewport/context {change} must not publish a commit or click"
|
||||
);
|
||||
assert!(state.group.is_none());
|
||||
assert_eq!(
|
||||
changed.preview_diagram(&state),
|
||||
f.project.diagrams[&f.diagram]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changed_selection_generation_rejects_press_even_after_membership_is_restored() {
|
||||
let f = Fixture::new();
|
||||
let mut selection = f.pair();
|
||||
let press = f.screen(f.a, 1.);
|
||||
let end = press + Vector::new(40., 60.);
|
||||
let mut state = State::default();
|
||||
{
|
||||
let c = f.canvas(&selection, 1.);
|
||||
start(&c, &mut state, press, end);
|
||||
assert!(
|
||||
pointer(
|
||||
&c,
|
||||
&mut state,
|
||||
mouse::Event::CursorMoved { position: end },
|
||||
end
|
||||
)
|
||||
.0
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
assert!(selection.remove(&Target::Placement(f.b)));
|
||||
assert!(selection.add(Target::Placement(f.b), |_| true));
|
||||
let c = f.canvas(&selection, 1.);
|
||||
let (message, status) = pointer(
|
||||
&c,
|
||||
&mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
end,
|
||||
);
|
||||
assert!(message.is_none());
|
||||
assert_eq!(status, Status::Captured);
|
||||
assert!(state.group.is_none());
|
||||
assert_eq!(c.preview_diagram(&state), f.project.diagrams[&f.diagram]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initial_press_without_pointer_history_consumes_release_only_displacement() {
|
||||
let f = Fixture::new();
|
||||
let selection = f.pair();
|
||||
let c = f.canvas(&selection, 1.);
|
||||
let mut state = State::default();
|
||||
let press = f.screen(f.a, 1.);
|
||||
let end = press + Vector::new(40., 60.);
|
||||
// With no earlier ordered move, the supplied release cursor is authoritative.
|
||||
assert!(
|
||||
pointer(
|
||||
&c,
|
||||
&mut state,
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
press
|
||||
)
|
||||
.0
|
||||
.is_none()
|
||||
);
|
||||
assert!(state.group.is_some());
|
||||
expect_move(
|
||||
pointer(
|
||||
&c,
|
||||
&mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
end,
|
||||
)
|
||||
.0,
|
||||
&selection,
|
||||
f.context,
|
||||
position(40., 60.),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn threshold_crossing_then_return_to_origin_emits_zero_move_not_singleton_click() {
|
||||
let f = Fixture::new();
|
||||
let selection = f.pair();
|
||||
let c = f.canvas(&selection, 1.);
|
||||
let mut state = State::default();
|
||||
let press = f.screen(f.a, 1.);
|
||||
let end = press + Vector::new(40., 60.);
|
||||
start(&c, &mut state, press, end);
|
||||
assert!(
|
||||
pointer(
|
||||
&c,
|
||||
&mut state,
|
||||
mouse::Event::CursorMoved { position: end },
|
||||
end
|
||||
)
|
||||
.0
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
pointer(
|
||||
&c,
|
||||
&mut state,
|
||||
mouse::Event::CursorMoved { position: press },
|
||||
end
|
||||
)
|
||||
.0
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(c.preview_diagram(&state), f.project.diagrams[&f.diagram]);
|
||||
expect_move(
|
||||
pointer(
|
||||
&c,
|
||||
&mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
end,
|
||||
)
|
||||
.0,
|
||||
&selection,
|
||||
f.context,
|
||||
position(0., 0.),
|
||||
);
|
||||
assert_eq!(selection.targets().len(), 2);
|
||||
}
|
||||
@@ -61,6 +61,7 @@ fn screen(p: Position, zoom: f32) -> Point {
|
||||
}
|
||||
fn canvas(app: &App, zoom: f32) -> DiagramCanvas<'_> {
|
||||
DiagramCanvas {
|
||||
selection: None,
|
||||
project: app.project(),
|
||||
view_index: None,
|
||||
diagram: app.active_diagram.unwrap(),
|
||||
|
||||
@@ -2,6 +2,7 @@ use super::*;
|
||||
|
||||
fn canvas(project: &Project, diagram: Id, zoom: f32) -> DiagramCanvas<'_> {
|
||||
DiagramCanvas {
|
||||
selection: None,
|
||||
project,
|
||||
view_index: None,
|
||||
diagram,
|
||||
|
||||
@@ -177,6 +177,7 @@ mod tests {
|
||||
|
||||
fn program(app: &App) -> PortLabels<'_> {
|
||||
PortLabels(DiagramCanvas {
|
||||
selection: None,
|
||||
project: app.project(),
|
||||
view_index: None,
|
||||
diagram: app.active_diagram.unwrap(),
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
//! Transient exact-target selection; admission and model operations belong to adapters.
|
||||
use crate::diagram_usage::{Surface, Target};
|
||||
use oa_core::Id;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) struct Scope {
|
||||
/// Document-instance identity, not the persisted project UUID.
|
||||
pub document: Id,
|
||||
pub surface: Surface,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) struct Context {
|
||||
pub scope: Scope,
|
||||
pub revision: u64,
|
||||
pub epoch: u64,
|
||||
}
|
||||
|
||||
/// Insertion-ordered unique targets. Membership does not grant edit capability.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct Selection {
|
||||
scope: Scope,
|
||||
targets: Vec<Target>,
|
||||
primary: Option<Target>,
|
||||
generation: u64,
|
||||
}
|
||||
|
||||
/// Immutable operation input, including the selection lifetime that captured it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct Snapshot {
|
||||
context: Context,
|
||||
generation: u64,
|
||||
targets: Vec<Target>,
|
||||
primary: Option<Target>,
|
||||
}
|
||||
|
||||
impl Selection {
|
||||
pub fn new(scope: Scope) -> Self {
|
||||
Self {
|
||||
scope,
|
||||
targets: Vec::new(),
|
||||
primary: None,
|
||||
generation: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn scope(&self) -> Scope {
|
||||
self.scope
|
||||
}
|
||||
|
||||
pub fn targets(&self) -> &[Target] {
|
||||
&self.targets
|
||||
}
|
||||
|
||||
pub fn primary(&self) -> Option<&Target> {
|
||||
self.primary.as_ref()
|
||||
}
|
||||
|
||||
pub fn contains(&self, target: &Target) -> bool {
|
||||
self.targets.contains(target)
|
||||
}
|
||||
|
||||
/// Replace with one valid target. Rejected admission preserves the old set.
|
||||
pub fn replace(&mut self, target: Target, valid: impl FnOnce(&Target) -> bool) -> bool {
|
||||
if !admitted(&target, valid) || (self.targets.len() == 1 && self.contains(&target)) {
|
||||
return false;
|
||||
}
|
||||
self.targets = vec![target.clone()];
|
||||
self.primary = Some(target);
|
||||
self.changed();
|
||||
true
|
||||
}
|
||||
|
||||
/// Add a new member as primary. Adding an existing exact target is a no-op.
|
||||
pub fn add(&mut self, target: Target, valid: impl FnOnce(&Target) -> bool) -> bool {
|
||||
if !admitted(&target, valid) || self.contains(&target) {
|
||||
return false;
|
||||
}
|
||||
self.targets.push(target.clone());
|
||||
self.primary = Some(target);
|
||||
self.changed();
|
||||
true
|
||||
}
|
||||
|
||||
/// Removal is allowed even if the target is no longer valid in the model.
|
||||
pub fn remove(&mut self, target: &Target) -> bool {
|
||||
let Some(index) = self.targets.iter().position(|member| member == target) else {
|
||||
return false;
|
||||
};
|
||||
self.targets.remove(index);
|
||||
if self.primary.as_ref() == Some(target) {
|
||||
self.primary = self.targets.last().cloned();
|
||||
}
|
||||
self.changed();
|
||||
true
|
||||
}
|
||||
|
||||
pub fn toggle(&mut self, target: Target, valid: impl FnOnce(&Target) -> bool) -> bool {
|
||||
if self.contains(&target) {
|
||||
self.remove(&target)
|
||||
} else {
|
||||
self.add(target, valid)
|
||||
}
|
||||
}
|
||||
|
||||
/// Inspect another selected member without changing membership or its order.
|
||||
pub fn set_primary(&mut self, target: &Target) -> bool {
|
||||
if !self.contains(target) || self.primary.as_ref() == Some(target) {
|
||||
return false;
|
||||
}
|
||||
self.primary = Some(target.clone());
|
||||
self.changed();
|
||||
true
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) -> bool {
|
||||
if self.targets.is_empty() {
|
||||
return false;
|
||||
}
|
||||
self.targets.clear();
|
||||
self.primary = None;
|
||||
self.changed();
|
||||
true
|
||||
}
|
||||
|
||||
/// Change owner or prune exact targets against the current rendered surface.
|
||||
/// The predicate checks identity/visibility, not eligibility for one action.
|
||||
/// Revision-only changes may retain survivors; no semantic retargeting occurs.
|
||||
pub fn reconcile(&mut self, scope: Scope, valid: impl FnMut(&Target) -> bool) -> bool {
|
||||
if self.scope != scope {
|
||||
self.scope = scope;
|
||||
self.targets.clear();
|
||||
self.primary = None;
|
||||
self.changed();
|
||||
return true;
|
||||
}
|
||||
let previous_len = self.targets.len();
|
||||
self.targets.retain(valid);
|
||||
if self.targets.len() == previous_len {
|
||||
return false;
|
||||
}
|
||||
if !self
|
||||
.primary
|
||||
.as_ref()
|
||||
.is_some_and(|target| self.contains(target))
|
||||
{
|
||||
self.primary = self.targets.last().cloned();
|
||||
}
|
||||
self.changed();
|
||||
true
|
||||
}
|
||||
|
||||
/// Capture input, including an empty selection before a first pointer press.
|
||||
/// Does not grant permission to mutate; adapters still guard drafts, modals,
|
||||
/// capability and geometry. A mismatched owner cannot capture a snapshot.
|
||||
pub fn snapshot(&self, context: Context) -> Option<Snapshot> {
|
||||
if context.scope != self.scope {
|
||||
return None;
|
||||
}
|
||||
Some(Snapshot {
|
||||
context,
|
||||
generation: self.generation,
|
||||
targets: self.targets.clone(),
|
||||
primary: self.primary.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Check before any operation side effect, using the caller's live context.
|
||||
/// Generation rejects remove/re-add and primary-change/restore ABA cycles.
|
||||
pub fn matches(&self, snapshot: &Snapshot, current: Context) -> bool {
|
||||
snapshot.context == current
|
||||
&& current.scope == self.scope
|
||||
&& snapshot.generation == self.generation
|
||||
&& snapshot.targets == self.targets
|
||||
&& self.primary == snapshot.primary
|
||||
}
|
||||
|
||||
fn changed(&mut self) {
|
||||
self.generation = self
|
||||
.generation
|
||||
.checked_add(1)
|
||||
.expect("selection generation exhausted");
|
||||
}
|
||||
}
|
||||
|
||||
impl Snapshot {
|
||||
pub fn context(&self) -> Context {
|
||||
self.context
|
||||
}
|
||||
|
||||
pub fn targets(&self) -> &[Target] {
|
||||
&self.targets
|
||||
}
|
||||
|
||||
pub fn primary(&self) -> Option<&Target> {
|
||||
self.primary.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
fn admitted(target: &Target, valid: impl FnOnce(&Target) -> bool) -> bool {
|
||||
!matches!(target, Target::Whole) && valid(target)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use oa_core::OccurrenceEndpoint;
|
||||
|
||||
fn id(value: u128) -> Id {
|
||||
Id::from_u128(value)
|
||||
}
|
||||
|
||||
fn scope() -> Scope {
|
||||
Scope {
|
||||
document: id(1),
|
||||
surface: Surface::Saved(id(2)),
|
||||
}
|
||||
}
|
||||
|
||||
fn context() -> Context {
|
||||
Context {
|
||||
scope: scope(),
|
||||
revision: 7,
|
||||
epoch: 11,
|
||||
}
|
||||
}
|
||||
|
||||
fn node(value: u128) -> Target {
|
||||
Target::Placement(id(value))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_duplicates_are_noops_but_distinct_visual_identities_survive() {
|
||||
let mut selection = Selection::new(scope());
|
||||
let port_a = Target::Port {
|
||||
port: id(30),
|
||||
placement: id(3),
|
||||
};
|
||||
let port_b = Target::Port {
|
||||
port: id(30),
|
||||
placement: id(4),
|
||||
};
|
||||
assert!(selection.add(node(3), |_| true));
|
||||
assert!(selection.add(port_a.clone(), |_| true));
|
||||
let captured = selection.snapshot(context()).unwrap();
|
||||
assert!(!selection.add(node(3), |_| true));
|
||||
assert!(!selection.add(port_a.clone(), |_| true));
|
||||
assert!(selection.matches(&captured, context()));
|
||||
assert!(selection.add(port_b.clone(), |_| true));
|
||||
assert_eq!(selection.targets(), &[node(3), port_a, port_b.clone()]);
|
||||
assert_eq!(selection.primary(), Some(&port_b));
|
||||
assert_eq!(
|
||||
captured.targets(),
|
||||
&[
|
||||
node(3),
|
||||
Target::Port {
|
||||
port: id(30),
|
||||
placement: id(3)
|
||||
}
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn primary_removal_uses_latest_survivor_without_reordering() {
|
||||
let mut selection = Selection::new(scope());
|
||||
for value in [3, 4, 5] {
|
||||
selection.add(node(value), |_| true);
|
||||
}
|
||||
assert!(selection.set_primary(&node(3)));
|
||||
assert_eq!(selection.targets(), &[node(3), node(4), node(5)]);
|
||||
assert!(!selection.set_primary(&node(99)));
|
||||
assert!(selection.remove(&node(4)));
|
||||
assert_eq!(selection.primary(), Some(&node(3)));
|
||||
assert!(selection.remove(&node(3)));
|
||||
assert_eq!(selection.primary(), Some(&node(5)));
|
||||
// A now-invalid member remains removable; validity applies to additions.
|
||||
assert!(selection.toggle(node(5), |_| false));
|
||||
assert!(selection.targets().is_empty());
|
||||
assert_eq!(selection.primary(), None);
|
||||
let empty = selection.snapshot(context()).unwrap();
|
||||
assert!(empty.targets().is_empty());
|
||||
assert_eq!(empty.primary(), None);
|
||||
assert!(selection.matches(&empty, context()));
|
||||
selection.add(node(5), |_| true);
|
||||
assert!(!selection.matches(&empty, context()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whole_and_rejected_targets_cannot_replace_an_existing_selection() {
|
||||
let mut selection = Selection::new(scope());
|
||||
selection.add(node(3), |_| true);
|
||||
let captured = selection.snapshot(context()).unwrap();
|
||||
assert!(!selection.replace(Target::Whole, |_| true));
|
||||
assert!(!selection.add(Target::Whole, |_| true));
|
||||
assert!(!selection.toggle(Target::Whole, |_| true));
|
||||
assert!(!selection.replace(node(4), |_| false));
|
||||
assert!(!selection.toggle(node(4), |_| false));
|
||||
assert!(selection.matches(&captured, context()));
|
||||
assert!(selection.replace(node(4), |_| true));
|
||||
assert_eq!(selection.targets(), &[node(4)]);
|
||||
assert_eq!(selection.primary(), Some(&node(4)));
|
||||
assert!(!selection.replace(node(4), |_| true));
|
||||
assert!(selection.clear());
|
||||
assert!(!selection.clear());
|
||||
assert_eq!(selection.primary(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reused_process_occurrences_reconcile_by_full_path_never_definition() {
|
||||
let owner = Scope {
|
||||
surface: Surface::Process(id(2)),
|
||||
..scope()
|
||||
};
|
||||
let mut selection = Selection::new(owner);
|
||||
let occurrence = |path| {
|
||||
Target::ProcessOccurrence(OccurrenceEndpoint {
|
||||
references: vec![id(path), id(50)],
|
||||
occurrence: id(60),
|
||||
})
|
||||
};
|
||||
let first = occurrence(10);
|
||||
let second = occurrence(20);
|
||||
selection.add(first.clone(), |_| true);
|
||||
selection.add(second.clone(), |_| true);
|
||||
assert_eq!(selection.targets(), &[first.clone(), second.clone()]);
|
||||
assert!(selection.reconcile(owner, |target| target == &first));
|
||||
assert_eq!(selection.targets(), std::slice::from_ref(&first));
|
||||
assert_eq!(selection.primary(), Some(&first));
|
||||
// A new call path with the same definition is not a replacement identity.
|
||||
let replacement = occurrence(70);
|
||||
assert!(selection.reconcile(owner, |target| target == &replacement));
|
||||
assert!(selection.targets().is_empty());
|
||||
assert_eq!(selection.primary(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revision_reconciliation_retains_exact_survivors_but_invalidates_operations() {
|
||||
let mut selection = Selection::new(scope());
|
||||
selection.add(node(3), |_| true);
|
||||
selection.add(node(4), |_| true);
|
||||
selection.set_primary(&node(3));
|
||||
let captured = selection.snapshot(context()).unwrap();
|
||||
let next = Context {
|
||||
revision: 8,
|
||||
..context()
|
||||
};
|
||||
assert!(!selection.reconcile(scope(), |_| true));
|
||||
assert_eq!(selection.targets(), &[node(3), node(4)]);
|
||||
assert_eq!(selection.primary(), Some(&node(3)));
|
||||
assert!(!selection.matches(&captured, next));
|
||||
let accepted = selection.snapshot(next).unwrap();
|
||||
assert!(selection.matches(&accepted, next));
|
||||
assert!(selection.reconcile(scope(), |target| target != &node(4)));
|
||||
assert_eq!(selection.primary(), Some(&node(3)));
|
||||
assert!(!selection.matches(&accepted, next));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changing_document_or_surface_clears_even_identical_target_ids() {
|
||||
for changed in [
|
||||
Scope {
|
||||
document: id(99),
|
||||
..scope()
|
||||
},
|
||||
Scope {
|
||||
surface: Surface::Saved(id(99)),
|
||||
..scope()
|
||||
},
|
||||
Scope {
|
||||
surface: Surface::Scenario(id(2)),
|
||||
..scope()
|
||||
},
|
||||
] {
|
||||
let mut selection = Selection::new(scope());
|
||||
selection.add(node(3), |_| true);
|
||||
let captured = selection.snapshot(context()).unwrap();
|
||||
assert!(selection.reconcile(changed, |_| true));
|
||||
assert_eq!(selection.scope(), changed);
|
||||
assert!(selection.targets().is_empty());
|
||||
assert_eq!(selection.primary(), None);
|
||||
assert!(!selection.matches(&captured, context()));
|
||||
assert!(selection.snapshot(context()).is_none());
|
||||
selection.reconcile(scope(), |_| true);
|
||||
selection.add(node(3), |_| true);
|
||||
assert!(!selection.matches(&captured, context()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_epoch_selection_and_primary_cycles_do_not_regain_authority() {
|
||||
let mut selection = Selection::new(scope());
|
||||
selection.add(node(3), |_| true);
|
||||
selection.add(node(4), |_| true);
|
||||
let captured = selection.snapshot(context()).unwrap();
|
||||
assert_eq!(captured.context(), context());
|
||||
assert_eq!(captured.primary(), Some(&node(4)));
|
||||
assert!(!selection.matches(
|
||||
&captured,
|
||||
Context {
|
||||
epoch: 12,
|
||||
..context()
|
||||
}
|
||||
));
|
||||
selection.set_primary(&node(3));
|
||||
selection.set_primary(&node(4));
|
||||
assert!(!selection.matches(&captured, context()));
|
||||
let restored_primary = selection.snapshot(context()).unwrap();
|
||||
selection.remove(&node(4));
|
||||
selection.add(node(4), |_| true);
|
||||
assert_eq!(selection.targets(), captured.targets());
|
||||
assert_eq!(selection.primary(), captured.primary());
|
||||
assert!(!selection.matches(&restored_primary, context()));
|
||||
assert!(selection.matches(&selection.snapshot(context()).unwrap(), context()));
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ mod data_values;
|
||||
mod data_view;
|
||||
mod diagnostic_input;
|
||||
mod diagram;
|
||||
mod diagram_selection;
|
||||
mod diagram_usage;
|
||||
mod document;
|
||||
mod explorer;
|
||||
|
||||
@@ -193,6 +193,100 @@ pub fn popup(app: &App) -> Option<Menu<Message>> {
|
||||
return Some(menu);
|
||||
}
|
||||
let mut entries = vec![Entry::title(crate::views::short(p.named(id), 38))];
|
||||
if app.page == Page::Diagram
|
||||
&& request.canvas.is_some()
|
||||
&& let Some(context) = app.multiselect_context()
|
||||
{
|
||||
use crate::app::multiselect::{Choice, Event as SelectionEvent};
|
||||
use crate::diagram_usage::Target;
|
||||
let view = app
|
||||
.active_diagram
|
||||
.map(|d| projection::project_view_with_index(p, d, app.document.view_index()));
|
||||
let target = request
|
||||
.placement
|
||||
.map(Target::Placement)
|
||||
.or_else(|| {
|
||||
view.as_ref()?
|
||||
.ports
|
||||
.iter()
|
||||
.find(|port| port.element == id)
|
||||
.map(|port| Target::Port {
|
||||
port: port.element,
|
||||
placement: port.owner,
|
||||
})
|
||||
})
|
||||
.or_else(|| {
|
||||
view.as_ref()?
|
||||
.edges
|
||||
.iter()
|
||||
.any(|edge| edge.id == id)
|
||||
.then_some(Target::Edge(id))
|
||||
});
|
||||
if let Some(target) = target {
|
||||
let selected = app
|
||||
.diagram_selection
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.contains(&target));
|
||||
entries.push(item(
|
||||
if selected {
|
||||
"Remove from selection"
|
||||
} else {
|
||||
"Add to selection"
|
||||
},
|
||||
Message::DiagramSelection(SelectionEvent::Choose(
|
||||
context,
|
||||
target.clone(),
|
||||
if selected {
|
||||
Choice::Remove
|
||||
} else {
|
||||
Choice::Add
|
||||
},
|
||||
)),
|
||||
true,
|
||||
));
|
||||
if let Some(snapshot) = app.multiselect_snapshot().filter(|s| s.targets().len() > 1)
|
||||
&& selected
|
||||
{
|
||||
entries.push(Entry::title(format!(
|
||||
"{} selected",
|
||||
snapshot.targets().len()
|
||||
)));
|
||||
entries.push(item(
|
||||
"Inspect this element",
|
||||
Message::DiagramSelection(SelectionEvent::Choose(
|
||||
context,
|
||||
target.clone(),
|
||||
Choice::Inspect,
|
||||
)),
|
||||
true,
|
||||
));
|
||||
entries.push(item(
|
||||
"Select only this element",
|
||||
Message::DiagramSelection(SelectionEvent::Choose(
|
||||
context,
|
||||
target,
|
||||
Choice::Replace,
|
||||
)),
|
||||
true,
|
||||
));
|
||||
entries.push(item(
|
||||
"Hide selected placements",
|
||||
Message::DiagramSelection(SelectionEvent::Hide(snapshot.clone())),
|
||||
crate::app::multiselect::placements(&snapshot).is_some(),
|
||||
));
|
||||
entries.push(Entry::disabled(
|
||||
"Delete selected from model (not yet available)",
|
||||
));
|
||||
return Some(Menu {
|
||||
token: opened.token,
|
||||
anchor: request.anchor,
|
||||
entries,
|
||||
on_close: Message::Menu(Event::Close),
|
||||
});
|
||||
}
|
||||
entries.push(Entry::separator());
|
||||
}
|
||||
}
|
||||
if !clean {
|
||||
entries.push(Entry::title("Apply or revert pending edits first"));
|
||||
}
|
||||
|
||||
@@ -455,6 +455,7 @@ fn diagram(app: &App) -> View<'_, Message> {
|
||||
.align_y(iced::Center);
|
||||
let canvas: View<'_, Message> = if let Some(id) = app.active_diagram {
|
||||
let program = DiagramCanvas {
|
||||
selection: app.diagram_selection.as_ref(),
|
||||
allocation: Some(app.allocation_capability()),
|
||||
project: app.project(),
|
||||
view_index: Some(app.document.view_index()),
|
||||
@@ -608,6 +609,37 @@ fn diagram(app: &App) -> View<'_, Message> {
|
||||
)
|
||||
.is_some_and(|(source, target)| source != edge.source || target != edge.target)
|
||||
});
|
||||
let selection = app.multiselect_snapshot();
|
||||
let count = selection.as_ref().map_or(0, |s| s.targets().len());
|
||||
let mut selection_controls =
|
||||
row![label(format!("{count} selected")).wrapping(widget::text::Wrapping::None)]
|
||||
.spacing(7)
|
||||
.align_y(iced::Center)
|
||||
.height(32);
|
||||
if let Some(snapshot) = selection.as_ref().filter(|s| s.targets().len() > 1) {
|
||||
if crate::app::multiselect::placements(snapshot).is_some() {
|
||||
selection_controls = selection_controls.push(action(
|
||||
"Hide selected",
|
||||
Message::DiagramSelection(crate::app::multiselect::Event::Hide(
|
||||
snapshot.clone(),
|
||||
)),
|
||||
));
|
||||
}
|
||||
selection_controls = selection_controls
|
||||
.push(action(
|
||||
"Clear selection",
|
||||
Message::DiagramSelection(crate::app::multiselect::Event::Clear(
|
||||
snapshot.context(),
|
||||
)),
|
||||
))
|
||||
.push(
|
||||
label(format!(
|
||||
"Properties edit only {}",
|
||||
short(app.project().named(app.selected), 36)
|
||||
))
|
||||
.wrapping(widget::text::Wrapping::None),
|
||||
);
|
||||
}
|
||||
let hint = if let Some(feedback) = &app.allocation_drag.feedback {
|
||||
feedback.text.as_str()
|
||||
} else if let Some(session) = &app.connection {
|
||||
@@ -620,16 +652,37 @@ fn diagram(app: &App) -> View<'_, Message> {
|
||||
} else {
|
||||
session.tool.hint()
|
||||
}
|
||||
} else if count > 1 {
|
||||
if selection
|
||||
.as_ref()
|
||||
.is_some_and(|s| crate::app::multiselect::placements(s).is_some())
|
||||
&& projection != Some(ProjectionKind::Allocation)
|
||||
{
|
||||
"Shift-click to add/remove · drag selected to move · right-click for actions · scroll to zoom"
|
||||
} else {
|
||||
"Shift-click to add/remove · right-click for selection actions · Esc clears selection"
|
||||
}
|
||||
} else if promoted {
|
||||
"Expand endpoint containers to edit this connection route"
|
||||
} else if edge.is_some() {
|
||||
"Right-click for routing · drag line / bend / label · Insert adds a bend · Esc cancels"
|
||||
} else {
|
||||
"Right-click for actions · drag to arrange · scroll to zoom"
|
||||
"Shift-click to add/remove · right-click for actions · drag to arrange · scroll to zoom"
|
||||
};
|
||||
// Status is outside the measured canvas. Clipping prevents long text
|
||||
// from painting over work headers or the dedicated destination slot.
|
||||
tools = tools.push(container(label(hint)).height(30).width(Fill).clip(true));
|
||||
// Selection changes must not resize the measured canvas or refit its
|
||||
// camera. Reserve both rows even when no group actions are available.
|
||||
tools = tools.push(
|
||||
container(
|
||||
column![
|
||||
selection_controls,
|
||||
label(hint).wrapping(widget::text::Wrapping::None)
|
||||
]
|
||||
.spacing(3),
|
||||
)
|
||||
.height(56)
|
||||
.width(Fill)
|
||||
.clip(true),
|
||||
);
|
||||
}
|
||||
column![container(tools).padding(14), canvas].into()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# Preserve the exact pinned evidence and verifier bytes through Git round trips.
|
||||
* -text whitespace=cr-at-eol
|
||||
incidents/**/*.arcadia whitespace=-blank-at-eof,cr-at-eol
|
||||
@@ -0,0 +1,96 @@
|
||||
# Closed multiselect gate evidence
|
||||
|
||||
This package preserves all 19 closed captures selected from
|
||||
`.local/group-movement-gates`: 14 recorded exit 0, and five recorded exit 101.
|
||||
Every run reports unchanged captured inputs. The five failures remain failures;
|
||||
their complete original receipts and streams are included. See the separate
|
||||
[saved-view report](../../../multiselect-saved-view-20260923.md) for scope and
|
||||
native observations. These standard-library Python 3.10+ tools do not run Cargo,
|
||||
Git, archived source or executables.
|
||||
|
||||
| Run | Recorded exit | Input files |
|
||||
| --- | ---: | ---: |
|
||||
| core-1 | 0 | 722 |
|
||||
| core-2 | 101 | 729 |
|
||||
| core-3 | 0 | 729 |
|
||||
| desktop-1 | 101 | 727 |
|
||||
| desktop-2 | 101 | 728 |
|
||||
| desktop-3 | 0 | 729 |
|
||||
| desktop-4 | 0 | 729 |
|
||||
| desktop-5 | 0 | 729 |
|
||||
| format-1 | 0 | 729 |
|
||||
| format-2 | 0 | 729 |
|
||||
| lint-1 | 101 | 729 |
|
||||
| lint-2 | 0 | 729 |
|
||||
| lint-3 | 0 | 729 |
|
||||
| native-build-1 | 0 | 729 |
|
||||
| native-build-2 | 0 | 729 |
|
||||
| oracle-2 | 101 | 729 |
|
||||
| oracle-3 | 0 | 729 |
|
||||
| windows-build-1 | 0 | 729 |
|
||||
| windows-build-2 | 0 | 729 |
|
||||
|
||||
Core-3, desktop-4, format-1, lint-2, native-build-1 and windows-build-1 share the
|
||||
729-file map `55a5f354bb5049a8c77ba9bafa833438ebf754649b0cf5d175d0c8dcfcedd7bb`.
|
||||
Desktop-5, format-2, lint-3, native-build-2 and windows-build-2 share the distinct
|
||||
729-file map `99dc46645236e674244e7c4867595a6000d3d85b54bcc9ab2b0c601923ecf66c`.
|
||||
The [source identity verifier](../source/README.md) compares those maps with Git
|
||||
commits `12a7352ddef5f69a144ead8219a50916233d63a9` and
|
||||
`f6a6010baed9e6164a1fcc7ecd58256a562827d7`, respectively. Earlier maps retain
|
||||
their own evidence scope; they are not reruns against the later source.
|
||||
|
||||
To validate this exact raw run list without writing a package, from the checkout
|
||||
root run:
|
||||
|
||||
```text
|
||||
python -B harness/experiments/multiselect-20260923/checks/prepare.py --root . --runs core-1 core-2 core-3 desktop-1 desktop-2 desktop-3 desktop-4 desktop-5 format-1 format-2 lint-1 lint-2 lint-3 native-build-1 native-build-2 oracle-2 oracle-3 windows-build-1 windows-build-2 --check-only
|
||||
```
|
||||
|
||||
Missing closure, changed inputs, inconsistent receipts, unavailable exact helpers,
|
||||
and extra/unexpected run files are refused. Run names must be supplied, must be
|
||||
unique, and cannot contain path separators.
|
||||
|
||||
To create a package, use the same explicit `--runs` list, omit `--check-only`, and
|
||||
optionally supply `--output PATH`. The default output is this checks directory.
|
||||
An output package is not overwritten. `--helper PATH` can be repeated to supply
|
||||
an older gate helper whose exact pinned bytes are no longer the current helper.
|
||||
Default helper candidates are `.local/group-movement-gates/*.py`.
|
||||
|
||||
Verify a completed package anywhere, without the original checkout:
|
||||
|
||||
```text
|
||||
python -B PATH_TO_PACKAGE/verify.py
|
||||
```
|
||||
|
||||
The packager preserves the seven original files per run: started/process/result
|
||||
receipts, stdout/stderr logs, the input map, and its source ZIP. It preserves the
|
||||
exact helper bytes named by each receipt. Only byte-identical input maps and ZIPs
|
||||
are deduplicated into hash-named capsules; their per-run logical mapping remains.
|
||||
ZIP member ordering and timestamps are fixed. Raw input/log bytes are unchanged.
|
||||
|
||||
`manifest.json` records the explicit run list, outcomes, commands, recorded HEADs,
|
||||
input counts, helper paths, logical mapping and every stored member pin.
|
||||
`files.json` pins `prepare.py`, `verify.py`, this README, the manifest and archive.
|
||||
The verifier checks safe exact ZIP membership (including nested source ZIPs),
|
||||
every recorded byte count/hash, input counts, the 71 vendored iced_widget files,
|
||||
start/process/finish order, receipt relationships, unchanged-input flags, helper
|
||||
identity, and exact ordered executable-artifact records parsed from Cargo JSON
|
||||
stdout. Missing JSON artifact records remain missing; they are not synthesized
|
||||
from paths or inferred from a successful exit. Non-null artifact readback pins
|
||||
are checked structurally; executable bytes are not included or independently
|
||||
verified by this package.
|
||||
|
||||
The source capsules are **local historical input snapshots**, including captured
|
||||
uncommitted work. A recorded HEAD alone does not identify their contents. They
|
||||
are not an integrated release, a hermetic toolchain distribution, or a claim that
|
||||
one run's gate applies to a later input map. Commands, paths, PIDs, times and
|
||||
artifact hashes are historical receipts, not authenticated execution records.
|
||||
The verifier checks their internal consistency and does not access historical
|
||||
absolute paths, contact services, extract archives or rerun tests. Pin integrity
|
||||
is not an external signature or proof against coordinated receipt alteration.
|
||||
|
||||
This package has no native interaction, focus, selection, movement, saved-model,
|
||||
Undo/Redo or full #29 acceptance implication. The native evidence and its
|
||||
independent evaluator belong in their separate reviewed record. The verification
|
||||
summary reports captured outcomes, including failures; it does not declare the
|
||||
feature or all captured gates successful.
|
||||
Binary file not shown.
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"prepare.py": {
|
||||
"bytes": 4818,
|
||||
"sha256": "29eedcec091874b26572530517f015ab690ed1a9040abea52248e99603ba5f25"
|
||||
},
|
||||
"verify.py": {
|
||||
"bytes": 8873,
|
||||
"sha256": "c03513407073bef092d57b968f982b8ffc0c58e095f834fe7464c6a0e8454a9a"
|
||||
},
|
||||
"README.md": {
|
||||
"bytes": 5001,
|
||||
"sha256": "3f5232af2ebb12003c833e6bc6643b4858950ebf015435a9b850c6a826fe27d0"
|
||||
},
|
||||
"manifest.json": {
|
||||
"bytes": 37083,
|
||||
"sha256": "c23269443cd059895c672330720a10508ab8ecade0d3686ec3d82ff792c9b457"
|
||||
},
|
||||
"evidence.zip": {
|
||||
"bytes": 25914867,
|
||||
"sha256": "261660dd8a5e93d917c1d36cad19d1eec0b93e312d1e0dd15f9ea16f5e11c864"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,935 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"kind": "multiselect-cargo-gates",
|
||||
"runs": {
|
||||
"core-1": {
|
||||
"command": [
|
||||
"cargo",
|
||||
"test",
|
||||
"--locked",
|
||||
"--offline",
|
||||
"-p",
|
||||
"oa-core",
|
||||
"--test",
|
||||
"group_movement",
|
||||
"--test",
|
||||
"state_diagrams",
|
||||
"--",
|
||||
"--nocapture"
|
||||
],
|
||||
"head": "b8685416f56e53e30bd11ecf93a54cea10ac8458",
|
||||
"exitCode": 0,
|
||||
"inputCount": 722,
|
||||
"helper": "helpers/6b8fcf64a1eb0869ae29abd66b4e89564a1813fef5898b54f2a17a3230dbaa06.py"
|
||||
},
|
||||
"core-2": {
|
||||
"command": [
|
||||
"cargo",
|
||||
"test",
|
||||
"--locked",
|
||||
"--offline",
|
||||
"-p",
|
||||
"oa-core",
|
||||
"--test",
|
||||
"group_movement",
|
||||
"--test",
|
||||
"state_diagrams",
|
||||
"--example",
|
||||
"multiselect_native",
|
||||
"--",
|
||||
"--nocapture"
|
||||
],
|
||||
"head": "b8685416f56e53e30bd11ecf93a54cea10ac8458",
|
||||
"exitCode": 101,
|
||||
"inputCount": 729,
|
||||
"helper": "helpers/6b8fcf64a1eb0869ae29abd66b4e89564a1813fef5898b54f2a17a3230dbaa06.py"
|
||||
},
|
||||
"core-3": {
|
||||
"command": [
|
||||
"cargo",
|
||||
"test",
|
||||
"--locked",
|
||||
"--offline",
|
||||
"-p",
|
||||
"oa-core",
|
||||
"--test",
|
||||
"group_movement",
|
||||
"--test",
|
||||
"state_diagrams",
|
||||
"--example",
|
||||
"multiselect_native",
|
||||
"--",
|
||||
"--nocapture"
|
||||
],
|
||||
"head": "b8685416f56e53e30bd11ecf93a54cea10ac8458",
|
||||
"exitCode": 0,
|
||||
"inputCount": 729,
|
||||
"helper": "helpers/6b8fcf64a1eb0869ae29abd66b4e89564a1813fef5898b54f2a17a3230dbaa06.py"
|
||||
},
|
||||
"desktop-1": {
|
||||
"command": [
|
||||
"cargo",
|
||||
"test",
|
||||
"--locked",
|
||||
"--offline",
|
||||
"-p",
|
||||
"oa-desktop",
|
||||
"multiselect",
|
||||
"--",
|
||||
"--nocapture"
|
||||
],
|
||||
"head": "b8685416f56e53e30bd11ecf93a54cea10ac8458",
|
||||
"exitCode": 101,
|
||||
"inputCount": 727,
|
||||
"helper": "helpers/6b8fcf64a1eb0869ae29abd66b4e89564a1813fef5898b54f2a17a3230dbaa06.py"
|
||||
},
|
||||
"desktop-2": {
|
||||
"command": [
|
||||
"cargo",
|
||||
"test",
|
||||
"--locked",
|
||||
"--offline",
|
||||
"-p",
|
||||
"oa-desktop",
|
||||
"--",
|
||||
"--nocapture"
|
||||
],
|
||||
"head": "b8685416f56e53e30bd11ecf93a54cea10ac8458",
|
||||
"exitCode": 101,
|
||||
"inputCount": 728,
|
||||
"helper": "helpers/6b8fcf64a1eb0869ae29abd66b4e89564a1813fef5898b54f2a17a3230dbaa06.py"
|
||||
},
|
||||
"desktop-3": {
|
||||
"command": [
|
||||
"cargo",
|
||||
"test",
|
||||
"--locked",
|
||||
"--offline",
|
||||
"-p",
|
||||
"oa-desktop",
|
||||
"--",
|
||||
"--nocapture"
|
||||
],
|
||||
"head": "b8685416f56e53e30bd11ecf93a54cea10ac8458",
|
||||
"exitCode": 0,
|
||||
"inputCount": 729,
|
||||
"helper": "helpers/6b8fcf64a1eb0869ae29abd66b4e89564a1813fef5898b54f2a17a3230dbaa06.py"
|
||||
},
|
||||
"desktop-4": {
|
||||
"command": [
|
||||
"cargo",
|
||||
"test",
|
||||
"--locked",
|
||||
"--offline",
|
||||
"-p",
|
||||
"oa-desktop",
|
||||
"--",
|
||||
"--nocapture"
|
||||
],
|
||||
"head": "b8685416f56e53e30bd11ecf93a54cea10ac8458",
|
||||
"exitCode": 0,
|
||||
"inputCount": 729,
|
||||
"helper": "helpers/6b8fcf64a1eb0869ae29abd66b4e89564a1813fef5898b54f2a17a3230dbaa06.py"
|
||||
},
|
||||
"desktop-5": {
|
||||
"command": [
|
||||
"cargo",
|
||||
"test",
|
||||
"--locked",
|
||||
"--offline",
|
||||
"-p",
|
||||
"oa-desktop",
|
||||
"--",
|
||||
"--nocapture"
|
||||
],
|
||||
"head": "12a7352ddef5f69a144ead8219a50916233d63a9",
|
||||
"exitCode": 0,
|
||||
"inputCount": 729,
|
||||
"helper": "helpers/6b8fcf64a1eb0869ae29abd66b4e89564a1813fef5898b54f2a17a3230dbaa06.py"
|
||||
},
|
||||
"format-1": {
|
||||
"command": [
|
||||
"cargo",
|
||||
"fmt",
|
||||
"--all",
|
||||
"--",
|
||||
"--check"
|
||||
],
|
||||
"head": "12a7352ddef5f69a144ead8219a50916233d63a9",
|
||||
"exitCode": 0,
|
||||
"inputCount": 729,
|
||||
"helper": "helpers/6b8fcf64a1eb0869ae29abd66b4e89564a1813fef5898b54f2a17a3230dbaa06.py"
|
||||
},
|
||||
"format-2": {
|
||||
"command": [
|
||||
"cargo",
|
||||
"fmt",
|
||||
"--all",
|
||||
"--",
|
||||
"--check"
|
||||
],
|
||||
"head": "f6a6010baed9e6164a1fcc7ecd58256a562827d7",
|
||||
"exitCode": 0,
|
||||
"inputCount": 729,
|
||||
"helper": "helpers/6b8fcf64a1eb0869ae29abd66b4e89564a1813fef5898b54f2a17a3230dbaa06.py"
|
||||
},
|
||||
"lint-1": {
|
||||
"command": [
|
||||
"cargo",
|
||||
"clippy",
|
||||
"--locked",
|
||||
"--offline",
|
||||
"-p",
|
||||
"oa-core",
|
||||
"-p",
|
||||
"oa-desktop",
|
||||
"--all-targets",
|
||||
"--",
|
||||
"-D",
|
||||
"warnings"
|
||||
],
|
||||
"head": "b8685416f56e53e30bd11ecf93a54cea10ac8458",
|
||||
"exitCode": 101,
|
||||
"inputCount": 729,
|
||||
"helper": "helpers/6b8fcf64a1eb0869ae29abd66b4e89564a1813fef5898b54f2a17a3230dbaa06.py"
|
||||
},
|
||||
"lint-2": {
|
||||
"command": [
|
||||
"cargo",
|
||||
"clippy",
|
||||
"--locked",
|
||||
"--offline",
|
||||
"-p",
|
||||
"oa-core",
|
||||
"-p",
|
||||
"oa-desktop",
|
||||
"--all-targets",
|
||||
"--",
|
||||
"-D",
|
||||
"warnings"
|
||||
],
|
||||
"head": "b8685416f56e53e30bd11ecf93a54cea10ac8458",
|
||||
"exitCode": 0,
|
||||
"inputCount": 729,
|
||||
"helper": "helpers/6b8fcf64a1eb0869ae29abd66b4e89564a1813fef5898b54f2a17a3230dbaa06.py"
|
||||
},
|
||||
"lint-3": {
|
||||
"command": [
|
||||
"cargo",
|
||||
"clippy",
|
||||
"--locked",
|
||||
"--offline",
|
||||
"-p",
|
||||
"oa-core",
|
||||
"-p",
|
||||
"oa-desktop",
|
||||
"--all-targets",
|
||||
"--",
|
||||
"-D",
|
||||
"warnings"
|
||||
],
|
||||
"head": "12a7352ddef5f69a144ead8219a50916233d63a9",
|
||||
"exitCode": 0,
|
||||
"inputCount": 729,
|
||||
"helper": "helpers/6b8fcf64a1eb0869ae29abd66b4e89564a1813fef5898b54f2a17a3230dbaa06.py"
|
||||
},
|
||||
"native-build-1": {
|
||||
"command": [
|
||||
"cargo",
|
||||
"build",
|
||||
"--locked",
|
||||
"--offline",
|
||||
"-p",
|
||||
"oa-core",
|
||||
"--example",
|
||||
"multiselect_native",
|
||||
"--message-format=json"
|
||||
],
|
||||
"head": "12a7352ddef5f69a144ead8219a50916233d63a9",
|
||||
"exitCode": 0,
|
||||
"inputCount": 729,
|
||||
"helper": "helpers/6b8fcf64a1eb0869ae29abd66b4e89564a1813fef5898b54f2a17a3230dbaa06.py"
|
||||
},
|
||||
"native-build-2": {
|
||||
"command": [
|
||||
"cargo",
|
||||
"build",
|
||||
"--locked",
|
||||
"--offline",
|
||||
"-p",
|
||||
"oa-core",
|
||||
"--example",
|
||||
"multiselect_native",
|
||||
"--message-format=json"
|
||||
],
|
||||
"head": "f6a6010baed9e6164a1fcc7ecd58256a562827d7",
|
||||
"exitCode": 0,
|
||||
"inputCount": 729,
|
||||
"helper": "helpers/6b8fcf64a1eb0869ae29abd66b4e89564a1813fef5898b54f2a17a3230dbaa06.py"
|
||||
},
|
||||
"oracle-2": {
|
||||
"command": [
|
||||
"cargo",
|
||||
"test",
|
||||
"--locked",
|
||||
"--offline",
|
||||
"-p",
|
||||
"oa-core",
|
||||
"--example",
|
||||
"multiselect_native",
|
||||
"--",
|
||||
"--nocapture"
|
||||
],
|
||||
"head": "b8685416f56e53e30bd11ecf93a54cea10ac8458",
|
||||
"exitCode": 101,
|
||||
"inputCount": 729,
|
||||
"helper": "helpers/6b8fcf64a1eb0869ae29abd66b4e89564a1813fef5898b54f2a17a3230dbaa06.py"
|
||||
},
|
||||
"oracle-3": {
|
||||
"command": [
|
||||
"cargo",
|
||||
"test",
|
||||
"--locked",
|
||||
"--offline",
|
||||
"-p",
|
||||
"oa-core",
|
||||
"--example",
|
||||
"multiselect_native",
|
||||
"--",
|
||||
"--nocapture"
|
||||
],
|
||||
"head": "b8685416f56e53e30bd11ecf93a54cea10ac8458",
|
||||
"exitCode": 0,
|
||||
"inputCount": 729,
|
||||
"helper": "helpers/6b8fcf64a1eb0869ae29abd66b4e89564a1813fef5898b54f2a17a3230dbaa06.py"
|
||||
},
|
||||
"windows-build-1": {
|
||||
"command": [
|
||||
"cargo",
|
||||
"build",
|
||||
"--locked",
|
||||
"--offline",
|
||||
"-p",
|
||||
"oa-desktop",
|
||||
"--bin",
|
||||
"arcadia-studio",
|
||||
"--message-format=json"
|
||||
],
|
||||
"head": "12a7352ddef5f69a144ead8219a50916233d63a9",
|
||||
"exitCode": 0,
|
||||
"inputCount": 729,
|
||||
"helper": "helpers/6b8fcf64a1eb0869ae29abd66b4e89564a1813fef5898b54f2a17a3230dbaa06.py"
|
||||
},
|
||||
"windows-build-2": {
|
||||
"command": [
|
||||
"cargo",
|
||||
"build",
|
||||
"--locked",
|
||||
"--offline",
|
||||
"-p",
|
||||
"oa-desktop",
|
||||
"--bin",
|
||||
"arcadia-studio",
|
||||
"--message-format=json"
|
||||
],
|
||||
"head": "f6a6010baed9e6164a1fcc7ecd58256a562827d7",
|
||||
"exitCode": 0,
|
||||
"inputCount": 729,
|
||||
"helper": "helpers/6b8fcf64a1eb0869ae29abd66b4e89564a1813fef5898b54f2a17a3230dbaa06.py"
|
||||
}
|
||||
},
|
||||
"logicalFiles": {
|
||||
"cargo/core-1/inputs.json": "capsules/a73d565f90b14c986e0f87a7ac3d368c93e053b7e591cffdfa20d8e81e2eae08.json",
|
||||
"cargo/core-1/inputs.zip": "capsules/2dcb54b1dd51a05cdd983432b114da8a08dce326c42b6e77a6b8772e75d2282f.zip",
|
||||
"cargo/core-1/process.json": "cargo/core-1/process.json",
|
||||
"cargo/core-1/result.json": "cargo/core-1/result.json",
|
||||
"cargo/core-1/started.json": "cargo/core-1/started.json",
|
||||
"cargo/core-1/stderr.log": "cargo/core-1/stderr.log",
|
||||
"cargo/core-1/stdout.log": "cargo/core-1/stdout.log",
|
||||
"cargo/core-2/inputs.json": "capsules/c5b23cd340869d57bd1bc4400041d499ea06a1a7619a6fd6b51e75278c1755e1.json",
|
||||
"cargo/core-2/inputs.zip": "capsules/df441a733bf28d1c59eab52063ca4b2645067b490b0a2f0072f98fc9225ea2e8.zip",
|
||||
"cargo/core-2/process.json": "cargo/core-2/process.json",
|
||||
"cargo/core-2/result.json": "cargo/core-2/result.json",
|
||||
"cargo/core-2/started.json": "cargo/core-2/started.json",
|
||||
"cargo/core-2/stderr.log": "cargo/core-2/stderr.log",
|
||||
"cargo/core-2/stdout.log": "cargo/core-2/stdout.log",
|
||||
"cargo/core-3/inputs.json": "capsules/55a5f354bb5049a8c77ba9bafa833438ebf754649b0cf5d175d0c8dcfcedd7bb.json",
|
||||
"cargo/core-3/inputs.zip": "capsules/e1c7f3abe7b32616d1081b4a2bb8af2d6d4b071bc2a7cbdd61e96a92ffdc0533.zip",
|
||||
"cargo/core-3/process.json": "cargo/core-3/process.json",
|
||||
"cargo/core-3/result.json": "cargo/core-3/result.json",
|
||||
"cargo/core-3/started.json": "cargo/core-3/started.json",
|
||||
"cargo/core-3/stderr.log": "cargo/core-3/stderr.log",
|
||||
"cargo/core-3/stdout.log": "cargo/core-3/stdout.log",
|
||||
"cargo/desktop-1/inputs.json": "capsules/c1083b630ad79ed1fea6933de3221f05b8eb9200694fb0e5dbd637527803579c.json",
|
||||
"cargo/desktop-1/inputs.zip": "capsules/8d6b08c2b1220cdb48d32cc74004cc9b82e4385f6f50a764f76c4a8630d070bf.zip",
|
||||
"cargo/desktop-1/process.json": "cargo/desktop-1/process.json",
|
||||
"cargo/desktop-1/result.json": "cargo/desktop-1/result.json",
|
||||
"cargo/desktop-1/started.json": "cargo/desktop-1/started.json",
|
||||
"cargo/desktop-1/stderr.log": "cargo/desktop-1/stderr.log",
|
||||
"cargo/desktop-1/stdout.log": "cargo/desktop-1/stdout.log",
|
||||
"cargo/desktop-2/inputs.json": "capsules/5f049dbbee443af86cde70efc74027f290a9bfbd434e5d644aa74ddb4e97566b.json",
|
||||
"cargo/desktop-2/inputs.zip": "capsules/bf7dc41142e6d087f213b2914e0ffb0e9cd52294f5830301e5e5b78c3470f725.zip",
|
||||
"cargo/desktop-2/process.json": "cargo/desktop-2/process.json",
|
||||
"cargo/desktop-2/result.json": "cargo/desktop-2/result.json",
|
||||
"cargo/desktop-2/started.json": "cargo/desktop-2/started.json",
|
||||
"cargo/desktop-2/stderr.log": "cargo/desktop-2/stderr.log",
|
||||
"cargo/desktop-2/stdout.log": "cargo/desktop-2/stdout.log",
|
||||
"cargo/desktop-3/inputs.json": "capsules/6f8eae7613666bca412e62f67151d6fee9f5bd32fb50363d0ff9b9ac8b190acf.json",
|
||||
"cargo/desktop-3/inputs.zip": "capsules/cc6233ddec2db0e322659a73a55e1681f5cb3554750ba183a756a29dadf8a8c9.zip",
|
||||
"cargo/desktop-3/process.json": "cargo/desktop-3/process.json",
|
||||
"cargo/desktop-3/result.json": "cargo/desktop-3/result.json",
|
||||
"cargo/desktop-3/started.json": "cargo/desktop-3/started.json",
|
||||
"cargo/desktop-3/stderr.log": "cargo/desktop-3/stderr.log",
|
||||
"cargo/desktop-3/stdout.log": "cargo/desktop-3/stdout.log",
|
||||
"cargo/desktop-4/inputs.json": "capsules/55a5f354bb5049a8c77ba9bafa833438ebf754649b0cf5d175d0c8dcfcedd7bb.json",
|
||||
"cargo/desktop-4/inputs.zip": "capsules/e1c7f3abe7b32616d1081b4a2bb8af2d6d4b071bc2a7cbdd61e96a92ffdc0533.zip",
|
||||
"cargo/desktop-4/process.json": "cargo/desktop-4/process.json",
|
||||
"cargo/desktop-4/result.json": "cargo/desktop-4/result.json",
|
||||
"cargo/desktop-4/started.json": "cargo/desktop-4/started.json",
|
||||
"cargo/desktop-4/stderr.log": "cargo/desktop-4/stderr.log",
|
||||
"cargo/desktop-4/stdout.log": "cargo/desktop-4/stdout.log",
|
||||
"cargo/desktop-5/inputs.json": "capsules/99dc46645236e674244e7c4867595a6000d3d85b54bcc9ab2b0c601923ecf66c.json",
|
||||
"cargo/desktop-5/inputs.zip": "capsules/aa8bb3d7fbd1f73c36913235b0b1d3aa79f6ac418b6c07ade94a7dee4a617bd9.zip",
|
||||
"cargo/desktop-5/process.json": "cargo/desktop-5/process.json",
|
||||
"cargo/desktop-5/result.json": "cargo/desktop-5/result.json",
|
||||
"cargo/desktop-5/started.json": "cargo/desktop-5/started.json",
|
||||
"cargo/desktop-5/stderr.log": "cargo/desktop-5/stderr.log",
|
||||
"cargo/desktop-5/stdout.log": "cargo/desktop-5/stdout.log",
|
||||
"cargo/format-1/inputs.json": "capsules/55a5f354bb5049a8c77ba9bafa833438ebf754649b0cf5d175d0c8dcfcedd7bb.json",
|
||||
"cargo/format-1/inputs.zip": "capsules/e1c7f3abe7b32616d1081b4a2bb8af2d6d4b071bc2a7cbdd61e96a92ffdc0533.zip",
|
||||
"cargo/format-1/process.json": "cargo/format-1/process.json",
|
||||
"cargo/format-1/result.json": "cargo/format-1/result.json",
|
||||
"cargo/format-1/started.json": "cargo/format-1/started.json",
|
||||
"cargo/format-1/stderr.log": "cargo/format-1/stderr.log",
|
||||
"cargo/format-1/stdout.log": "cargo/format-1/stdout.log",
|
||||
"cargo/format-2/inputs.json": "capsules/99dc46645236e674244e7c4867595a6000d3d85b54bcc9ab2b0c601923ecf66c.json",
|
||||
"cargo/format-2/inputs.zip": "capsules/aa8bb3d7fbd1f73c36913235b0b1d3aa79f6ac418b6c07ade94a7dee4a617bd9.zip",
|
||||
"cargo/format-2/process.json": "cargo/format-2/process.json",
|
||||
"cargo/format-2/result.json": "cargo/format-2/result.json",
|
||||
"cargo/format-2/started.json": "cargo/format-2/started.json",
|
||||
"cargo/format-2/stderr.log": "cargo/format-2/stderr.log",
|
||||
"cargo/format-2/stdout.log": "cargo/format-2/stdout.log",
|
||||
"cargo/lint-1/inputs.json": "capsules/292f6139450109875549801a1da337dac08a8bca4cfa0124139a5d8d042386b9.json",
|
||||
"cargo/lint-1/inputs.zip": "capsules/d2ec5025067db2c05736bb1c9c9b6250d9199f1b7190e8c09f5a777424790c10.zip",
|
||||
"cargo/lint-1/process.json": "cargo/lint-1/process.json",
|
||||
"cargo/lint-1/result.json": "cargo/lint-1/result.json",
|
||||
"cargo/lint-1/started.json": "cargo/lint-1/started.json",
|
||||
"cargo/lint-1/stderr.log": "cargo/lint-1/stderr.log",
|
||||
"cargo/lint-1/stdout.log": "cargo/lint-1/stdout.log",
|
||||
"cargo/lint-2/inputs.json": "capsules/55a5f354bb5049a8c77ba9bafa833438ebf754649b0cf5d175d0c8dcfcedd7bb.json",
|
||||
"cargo/lint-2/inputs.zip": "capsules/e1c7f3abe7b32616d1081b4a2bb8af2d6d4b071bc2a7cbdd61e96a92ffdc0533.zip",
|
||||
"cargo/lint-2/process.json": "cargo/lint-2/process.json",
|
||||
"cargo/lint-2/result.json": "cargo/lint-2/result.json",
|
||||
"cargo/lint-2/started.json": "cargo/lint-2/started.json",
|
||||
"cargo/lint-2/stderr.log": "cargo/lint-2/stderr.log",
|
||||
"cargo/lint-2/stdout.log": "cargo/lint-2/stdout.log",
|
||||
"cargo/lint-3/inputs.json": "capsules/99dc46645236e674244e7c4867595a6000d3d85b54bcc9ab2b0c601923ecf66c.json",
|
||||
"cargo/lint-3/inputs.zip": "capsules/aa8bb3d7fbd1f73c36913235b0b1d3aa79f6ac418b6c07ade94a7dee4a617bd9.zip",
|
||||
"cargo/lint-3/process.json": "cargo/lint-3/process.json",
|
||||
"cargo/lint-3/result.json": "cargo/lint-3/result.json",
|
||||
"cargo/lint-3/started.json": "cargo/lint-3/started.json",
|
||||
"cargo/lint-3/stderr.log": "cargo/lint-3/stderr.log",
|
||||
"cargo/lint-3/stdout.log": "cargo/lint-3/stdout.log",
|
||||
"cargo/native-build-1/inputs.json": "capsules/55a5f354bb5049a8c77ba9bafa833438ebf754649b0cf5d175d0c8dcfcedd7bb.json",
|
||||
"cargo/native-build-1/inputs.zip": "capsules/e1c7f3abe7b32616d1081b4a2bb8af2d6d4b071bc2a7cbdd61e96a92ffdc0533.zip",
|
||||
"cargo/native-build-1/process.json": "cargo/native-build-1/process.json",
|
||||
"cargo/native-build-1/result.json": "cargo/native-build-1/result.json",
|
||||
"cargo/native-build-1/started.json": "cargo/native-build-1/started.json",
|
||||
"cargo/native-build-1/stderr.log": "cargo/native-build-1/stderr.log",
|
||||
"cargo/native-build-1/stdout.log": "cargo/native-build-1/stdout.log",
|
||||
"cargo/native-build-2/inputs.json": "capsules/99dc46645236e674244e7c4867595a6000d3d85b54bcc9ab2b0c601923ecf66c.json",
|
||||
"cargo/native-build-2/inputs.zip": "capsules/aa8bb3d7fbd1f73c36913235b0b1d3aa79f6ac418b6c07ade94a7dee4a617bd9.zip",
|
||||
"cargo/native-build-2/process.json": "cargo/native-build-2/process.json",
|
||||
"cargo/native-build-2/result.json": "cargo/native-build-2/result.json",
|
||||
"cargo/native-build-2/started.json": "cargo/native-build-2/started.json",
|
||||
"cargo/native-build-2/stderr.log": "cargo/native-build-2/stderr.log",
|
||||
"cargo/native-build-2/stdout.log": "cargo/native-build-2/stdout.log",
|
||||
"cargo/oracle-2/inputs.json": "capsules/ae16337dbc617e26b23468adb86d963e3bcf7eddf177e8fb4654c69019904548.json",
|
||||
"cargo/oracle-2/inputs.zip": "capsules/e99bcaf1ab2becbcad0a005bdd0a4eb14fad2075bd7a38433857df4d3cad5e4d.zip",
|
||||
"cargo/oracle-2/process.json": "cargo/oracle-2/process.json",
|
||||
"cargo/oracle-2/result.json": "cargo/oracle-2/result.json",
|
||||
"cargo/oracle-2/started.json": "cargo/oracle-2/started.json",
|
||||
"cargo/oracle-2/stderr.log": "cargo/oracle-2/stderr.log",
|
||||
"cargo/oracle-2/stdout.log": "cargo/oracle-2/stdout.log",
|
||||
"cargo/oracle-3/inputs.json": "capsules/292f6139450109875549801a1da337dac08a8bca4cfa0124139a5d8d042386b9.json",
|
||||
"cargo/oracle-3/inputs.zip": "capsules/d2ec5025067db2c05736bb1c9c9b6250d9199f1b7190e8c09f5a777424790c10.zip",
|
||||
"cargo/oracle-3/process.json": "cargo/oracle-3/process.json",
|
||||
"cargo/oracle-3/result.json": "cargo/oracle-3/result.json",
|
||||
"cargo/oracle-3/started.json": "cargo/oracle-3/started.json",
|
||||
"cargo/oracle-3/stderr.log": "cargo/oracle-3/stderr.log",
|
||||
"cargo/oracle-3/stdout.log": "cargo/oracle-3/stdout.log",
|
||||
"cargo/windows-build-1/inputs.json": "capsules/55a5f354bb5049a8c77ba9bafa833438ebf754649b0cf5d175d0c8dcfcedd7bb.json",
|
||||
"cargo/windows-build-1/inputs.zip": "capsules/e1c7f3abe7b32616d1081b4a2bb8af2d6d4b071bc2a7cbdd61e96a92ffdc0533.zip",
|
||||
"cargo/windows-build-1/process.json": "cargo/windows-build-1/process.json",
|
||||
"cargo/windows-build-1/result.json": "cargo/windows-build-1/result.json",
|
||||
"cargo/windows-build-1/started.json": "cargo/windows-build-1/started.json",
|
||||
"cargo/windows-build-1/stderr.log": "cargo/windows-build-1/stderr.log",
|
||||
"cargo/windows-build-1/stdout.log": "cargo/windows-build-1/stdout.log",
|
||||
"cargo/windows-build-2/inputs.json": "capsules/99dc46645236e674244e7c4867595a6000d3d85b54bcc9ab2b0c601923ecf66c.json",
|
||||
"cargo/windows-build-2/inputs.zip": "capsules/aa8bb3d7fbd1f73c36913235b0b1d3aa79f6ac418b6c07ade94a7dee4a617bd9.zip",
|
||||
"cargo/windows-build-2/process.json": "cargo/windows-build-2/process.json",
|
||||
"cargo/windows-build-2/result.json": "cargo/windows-build-2/result.json",
|
||||
"cargo/windows-build-2/started.json": "cargo/windows-build-2/started.json",
|
||||
"cargo/windows-build-2/stderr.log": "cargo/windows-build-2/stderr.log",
|
||||
"cargo/windows-build-2/stdout.log": "cargo/windows-build-2/stdout.log",
|
||||
"helpers/6b8fcf64a1eb0869ae29abd66b4e89564a1813fef5898b54f2a17a3230dbaa06.py": "helpers/6b8fcf64a1eb0869ae29abd66b4e89564a1813fef5898b54f2a17a3230dbaa06.py"
|
||||
},
|
||||
"members": {
|
||||
"capsules/292f6139450109875549801a1da337dac08a8bca4cfa0124139a5d8d042386b9.json": {
|
||||
"bytes": 121985,
|
||||
"sha256": "292f6139450109875549801a1da337dac08a8bca4cfa0124139a5d8d042386b9"
|
||||
},
|
||||
"capsules/2dcb54b1dd51a05cdd983432b114da8a08dce326c42b6e77a6b8772e75d2282f.zip": {
|
||||
"bytes": 2885634,
|
||||
"sha256": "2dcb54b1dd51a05cdd983432b114da8a08dce326c42b6e77a6b8772e75d2282f"
|
||||
},
|
||||
"capsules/55a5f354bb5049a8c77ba9bafa833438ebf754649b0cf5d175d0c8dcfcedd7bb.json": {
|
||||
"bytes": 121985,
|
||||
"sha256": "55a5f354bb5049a8c77ba9bafa833438ebf754649b0cf5d175d0c8dcfcedd7bb"
|
||||
},
|
||||
"capsules/5f049dbbee443af86cde70efc74027f290a9bfbd434e5d644aa74ddb4e97566b.json": {
|
||||
"bytes": 121820,
|
||||
"sha256": "5f049dbbee443af86cde70efc74027f290a9bfbd434e5d644aa74ddb4e97566b"
|
||||
},
|
||||
"capsules/6f8eae7613666bca412e62f67151d6fee9f5bd32fb50363d0ff9b9ac8b190acf.json": {
|
||||
"bytes": 121984,
|
||||
"sha256": "6f8eae7613666bca412e62f67151d6fee9f5bd32fb50363d0ff9b9ac8b190acf"
|
||||
},
|
||||
"capsules/8d6b08c2b1220cdb48d32cc74004cc9b82e4385f6f50a764f76c4a8630d070bf.zip": {
|
||||
"bytes": 2903240,
|
||||
"sha256": "8d6b08c2b1220cdb48d32cc74004cc9b82e4385f6f50a764f76c4a8630d070bf"
|
||||
},
|
||||
"capsules/99dc46645236e674244e7c4867595a6000d3d85b54bcc9ab2b0c601923ecf66c.json": {
|
||||
"bytes": 121985,
|
||||
"sha256": "99dc46645236e674244e7c4867595a6000d3d85b54bcc9ab2b0c601923ecf66c"
|
||||
},
|
||||
"capsules/a73d565f90b14c986e0f87a7ac3d368c93e053b7e591cffdfa20d8e81e2eae08.json": {
|
||||
"bytes": 120844,
|
||||
"sha256": "a73d565f90b14c986e0f87a7ac3d368c93e053b7e591cffdfa20d8e81e2eae08"
|
||||
},
|
||||
"capsules/aa8bb3d7fbd1f73c36913235b0b1d3aa79f6ac418b6c07ade94a7dee4a617bd9.zip": {
|
||||
"bytes": 2918930,
|
||||
"sha256": "aa8bb3d7fbd1f73c36913235b0b1d3aa79f6ac418b6c07ade94a7dee4a617bd9"
|
||||
},
|
||||
"capsules/ae16337dbc617e26b23468adb86d963e3bcf7eddf177e8fb4654c69019904548.json": {
|
||||
"bytes": 121984,
|
||||
"sha256": "ae16337dbc617e26b23468adb86d963e3bcf7eddf177e8fb4654c69019904548"
|
||||
},
|
||||
"capsules/bf7dc41142e6d087f213b2914e0ffb0e9cd52294f5830301e5e5b78c3470f725.zip": {
|
||||
"bytes": 2909281,
|
||||
"sha256": "bf7dc41142e6d087f213b2914e0ffb0e9cd52294f5830301e5e5b78c3470f725"
|
||||
},
|
||||
"capsules/c1083b630ad79ed1fea6933de3221f05b8eb9200694fb0e5dbd637527803579c.json": {
|
||||
"bytes": 121652,
|
||||
"sha256": "c1083b630ad79ed1fea6933de3221f05b8eb9200694fb0e5dbd637527803579c"
|
||||
},
|
||||
"capsules/c5b23cd340869d57bd1bc4400041d499ea06a1a7619a6fd6b51e75278c1755e1.json": {
|
||||
"bytes": 121984,
|
||||
"sha256": "c5b23cd340869d57bd1bc4400041d499ea06a1a7619a6fd6b51e75278c1755e1"
|
||||
},
|
||||
"capsules/cc6233ddec2db0e322659a73a55e1681f5cb3554750ba183a756a29dadf8a8c9.zip": {
|
||||
"bytes": 2917494,
|
||||
"sha256": "cc6233ddec2db0e322659a73a55e1681f5cb3554750ba183a756a29dadf8a8c9"
|
||||
},
|
||||
"capsules/d2ec5025067db2c05736bb1c9c9b6250d9199f1b7190e8c09f5a777424790c10.zip": {
|
||||
"bytes": 2918296,
|
||||
"sha256": "d2ec5025067db2c05736bb1c9c9b6250d9199f1b7190e8c09f5a777424790c10"
|
||||
},
|
||||
"capsules/df441a733bf28d1c59eab52063ca4b2645067b490b0a2f0072f98fc9225ea2e8.zip": {
|
||||
"bytes": 2917518,
|
||||
"sha256": "df441a733bf28d1c59eab52063ca4b2645067b490b0a2f0072f98fc9225ea2e8"
|
||||
},
|
||||
"capsules/e1c7f3abe7b32616d1081b4a2bb8af2d6d4b071bc2a7cbdd61e96a92ffdc0533.zip": {
|
||||
"bytes": 2918309,
|
||||
"sha256": "e1c7f3abe7b32616d1081b4a2bb8af2d6d4b071bc2a7cbdd61e96a92ffdc0533"
|
||||
},
|
||||
"capsules/e99bcaf1ab2becbcad0a005bdd0a4eb14fad2075bd7a38433857df4d3cad5e4d.zip": {
|
||||
"bytes": 2917719,
|
||||
"sha256": "e99bcaf1ab2becbcad0a005bdd0a4eb14fad2075bd7a38433857df4d3cad5e4d"
|
||||
},
|
||||
"cargo/core-1/process.json": {
|
||||
"bytes": 210,
|
||||
"sha256": "92e5a88b5b86400c13bad4d0a9d490466bb2ef0b6077d14930ac5084187b5506"
|
||||
},
|
||||
"cargo/core-1/result.json": {
|
||||
"bytes": 1821,
|
||||
"sha256": "c90e020e6d912516fbc5ef5b4bf6a5df5fc668e5f5e19a9108c29bf3723cea9c"
|
||||
},
|
||||
"cargo/core-1/started.json": {
|
||||
"bytes": 1428,
|
||||
"sha256": "230ea369285569a0fa179fe420b0b2133e7fcb2349601a53d69881e9a66bd39b"
|
||||
},
|
||||
"cargo/core-1/stderr.log": {
|
||||
"bytes": 434,
|
||||
"sha256": "84a1b0b7352dcde2fe51293feabbddf8e94206d9278e4a38e6d354af8a1bcd48"
|
||||
},
|
||||
"cargo/core-1/stdout.log": {
|
||||
"bytes": 1671,
|
||||
"sha256": "eb04561c11e394cc368ea1c5e38aa4b1dc8b5c3c44acb168469dd941f80f14e8"
|
||||
},
|
||||
"cargo/core-2/process.json": {
|
||||
"bytes": 245,
|
||||
"sha256": "020d6cb0ce6e4b9122ac2dcbd86c9c1719479f29d3690247182ade858caba12d"
|
||||
},
|
||||
"cargo/core-2/result.json": {
|
||||
"bytes": 1870,
|
||||
"sha256": "c97cbcd85b60a3476135bf9026d41868ea091061c12ce4bbc18a40c6023c5b3e"
|
||||
},
|
||||
"cargo/core-2/started.json": {
|
||||
"bytes": 1473,
|
||||
"sha256": "f367b289c9d378975514fa450e4fa25049f4461156417ba5bd6e79836faf401e"
|
||||
},
|
||||
"cargo/core-2/stderr.log": {
|
||||
"bytes": 5287,
|
||||
"sha256": "4ea6f38eef014ab8176c3ab1ed9d3be3addb4463a8f073425f1b5493b33a7c86"
|
||||
},
|
||||
"cargo/core-2/stdout.log": {
|
||||
"bytes": 4138,
|
||||
"sha256": "a2dfdb0d7c6c7ba7e9ef86063a815f43111188a44ae4b0e20717cc3e518b7c3d"
|
||||
},
|
||||
"cargo/core-3/process.json": {
|
||||
"bytes": 245,
|
||||
"sha256": "b26f22b0aed07e55906b024d1fd94198da1ab1d56363fc5ad66868940ae1c9a4"
|
||||
},
|
||||
"cargo/core-3/result.json": {
|
||||
"bytes": 1867,
|
||||
"sha256": "6d04a2818750491cb73ebb1b257d214316c048e1ac6165a119a27534d636406c"
|
||||
},
|
||||
"cargo/core-3/started.json": {
|
||||
"bytes": 1473,
|
||||
"sha256": "49e9d637eb063277925c577266a63e5fd06723ca24726d609dc0e458596e8316"
|
||||
},
|
||||
"cargo/core-3/stderr.log": {
|
||||
"bytes": 539,
|
||||
"sha256": "f05f7ab2f0244968100eb0e0a82808af8e42dceb5b5de70d034b7ae56bad9489"
|
||||
},
|
||||
"cargo/core-3/stdout.log": {
|
||||
"bytes": 4797,
|
||||
"sha256": "d0f2180aaecc3b253b473200b77348a19a21594e193ce45c21f040b7242be253"
|
||||
},
|
||||
"cargo/desktop-1/process.json": {
|
||||
"bytes": 172,
|
||||
"sha256": "d9ba2b6a0968b702af0aa857e7bb8667680eeddc32a0403781cd3d5e2467c067"
|
||||
},
|
||||
"cargo/desktop-1/result.json": {
|
||||
"bytes": 1769,
|
||||
"sha256": "2384c076e4bbc0e15951671e3a830725213c31cfcded0a27e1bb72802c576dbc"
|
||||
},
|
||||
"cargo/desktop-1/started.json": {
|
||||
"bytes": 1375,
|
||||
"sha256": "6b1ba478ebe5e14d5ee76199a56874a6a10156c2dd7e9d3830fd5ba5311094d6"
|
||||
},
|
||||
"cargo/desktop-1/stderr.log": {
|
||||
"bytes": 4604,
|
||||
"sha256": "6a47286c4daaf9f4ac8ac8cca3e98931f7812a44b192128aa67880c1ac930b87"
|
||||
},
|
||||
"cargo/desktop-1/stdout.log": {
|
||||
"bytes": 0,
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
"cargo/desktop-2/process.json": {
|
||||
"bytes": 157,
|
||||
"sha256": "cc037bab1ec44bd2e4095e165597f5e1d077bae5fce7a96c6a5fd814470d4244"
|
||||
},
|
||||
"cargo/desktop-2/result.json": {
|
||||
"bytes": 1753,
|
||||
"sha256": "9c037405572273086737e22b60796eff0745f4746ee3f25529f928da53c98fbc"
|
||||
},
|
||||
"cargo/desktop-2/started.json": {
|
||||
"bytes": 1355,
|
||||
"sha256": "18f71e571722bdb3696195623b3af3a2b9a45d38f98307a68049aacff3fbad9c"
|
||||
},
|
||||
"cargo/desktop-2/stderr.log": {
|
||||
"bytes": 1085,
|
||||
"sha256": "316e0179675fb071d7d9773d7073d771438ecd70e66c99559f4239e0638a3238"
|
||||
},
|
||||
"cargo/desktop-2/stdout.log": {
|
||||
"bytes": 145051,
|
||||
"sha256": "7c907317d106ff63347456b5ad2015fa8a9974ffbb7003d0a123afc99879ce42"
|
||||
},
|
||||
"cargo/desktop-3/process.json": {
|
||||
"bytes": 157,
|
||||
"sha256": "368d265e1ee5cceeb5995172c662dc3bc551591b9600a781c63ed64579760c24"
|
||||
},
|
||||
"cargo/desktop-3/result.json": {
|
||||
"bytes": 1751,
|
||||
"sha256": "32076fb49a6ddfcef63e77ca9f1edec34700ca80d36f32890581d56035f80054"
|
||||
},
|
||||
"cargo/desktop-3/started.json": {
|
||||
"bytes": 1355,
|
||||
"sha256": "da1ccd8352380b357d5954a70d86f2c51c8a2da8d1c4b553ca7b281d3000eab9"
|
||||
},
|
||||
"cargo/desktop-3/stderr.log": {
|
||||
"bytes": 626,
|
||||
"sha256": "5333e25e78f515449877867d0a2bf714b7cb3302c9497d4f4343a1e05e0f8b03"
|
||||
},
|
||||
"cargo/desktop-3/stdout.log": {
|
||||
"bytes": 145562,
|
||||
"sha256": "dc7e40a7d2ab1e78c74f0ee0344c4b2e5b1402bc6367e93a40780774000e4f81"
|
||||
},
|
||||
"cargo/desktop-4/process.json": {
|
||||
"bytes": 157,
|
||||
"sha256": "0823d0fbda95fe5efb08a7c11c98a7ac2409b5b3d170cf2e41b702895211abf4"
|
||||
},
|
||||
"cargo/desktop-4/result.json": {
|
||||
"bytes": 1750,
|
||||
"sha256": "d88b177e9d181f7c9c3685100c8676c0c8954cd0b071344ff5b4bb7a8e498865"
|
||||
},
|
||||
"cargo/desktop-4/started.json": {
|
||||
"bytes": 1355,
|
||||
"sha256": "0f9927d5c0b09c7fcc8922c68a2c88a4d840e0420a7be90193f566b02312a3d3"
|
||||
},
|
||||
"cargo/desktop-4/stderr.log": {
|
||||
"bytes": 628,
|
||||
"sha256": "992db238282b7acf1bbfeee7f2c459f9afe4bce814761d70df789e4f6239afcf"
|
||||
},
|
||||
"cargo/desktop-4/stdout.log": {
|
||||
"bytes": 145802,
|
||||
"sha256": "70c38e8c98cac4f220999481908beb549e5377f6f48c31931f9412ed25a67a41"
|
||||
},
|
||||
"cargo/desktop-5/process.json": {
|
||||
"bytes": 157,
|
||||
"sha256": "83bf9efc58fa5692f3ed038c107c081e5483d91117c73eddba62d3eb45cc7267"
|
||||
},
|
||||
"cargo/desktop-5/result.json": {
|
||||
"bytes": 1751,
|
||||
"sha256": "7cbbae8bbceec07cebb4696b999701cd29d46bfd047351aa3e20ea49d7c67ae6"
|
||||
},
|
||||
"cargo/desktop-5/started.json": {
|
||||
"bytes": 1355,
|
||||
"sha256": "0bfd2a6c185afb1b301e3b6b5f746d0e37251f147590aa2e9f25e370e99f53e8"
|
||||
},
|
||||
"cargo/desktop-5/stderr.log": {
|
||||
"bytes": 631,
|
||||
"sha256": "466f26c41956ed05629b633827d7ffca497c57c87ff67acff6351043c7bdefd4"
|
||||
},
|
||||
"cargo/desktop-5/stdout.log": {
|
||||
"bytes": 146577,
|
||||
"sha256": "e89cf5f2375fd40ae70261471d797cbaca5127cb27730e6094a616afa12514ae"
|
||||
},
|
||||
"cargo/format-1/process.json": {
|
||||
"bytes": 116,
|
||||
"sha256": "bb8e0a7620b40bb5f0932643848a57b0d207dde39006a9a20141c00f13d94284"
|
||||
},
|
||||
"cargo/format-1/result.json": {
|
||||
"bytes": 1687,
|
||||
"sha256": "967eeddb3fc6a0dc0fdf9a75f575d75fa8f8b0b5ea9c8a422590bcbece6de77b"
|
||||
},
|
||||
"cargo/format-1/started.json": {
|
||||
"bytes": 1299,
|
||||
"sha256": "ceb354dfbc4a41d72e496342e28475ddcce5a3c17a4e1553f0d699404c374657"
|
||||
},
|
||||
"cargo/format-1/stderr.log": {
|
||||
"bytes": 0,
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
"cargo/format-1/stdout.log": {
|
||||
"bytes": 0,
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
"cargo/format-2/process.json": {
|
||||
"bytes": 116,
|
||||
"sha256": "aa31b91d224845b7dd1df777e865c88c5b8f55b66f173053be318132d1bc3870"
|
||||
},
|
||||
"cargo/format-2/result.json": {
|
||||
"bytes": 1687,
|
||||
"sha256": "c4d5529c8d2feae1b805bd8d404a3817053a5a0cd4ec341aa8c1e0a5c05eed5b"
|
||||
},
|
||||
"cargo/format-2/started.json": {
|
||||
"bytes": 1299,
|
||||
"sha256": "2ea70bb29d7fef73a194acd7aa36836fd4a54ae8a4be173f919cd2fc32e97919"
|
||||
},
|
||||
"cargo/format-2/stderr.log": {
|
||||
"bytes": 0,
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
"cargo/format-2/stdout.log": {
|
||||
"bytes": 0,
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
"cargo/lint-1/process.json": {
|
||||
"bytes": 196,
|
||||
"sha256": "d30e400277dc0bb4a1618689b16ab86be7838cc1b42688185d655df235080286"
|
||||
},
|
||||
"cargo/lint-1/result.json": {
|
||||
"bytes": 1808,
|
||||
"sha256": "7ffb361d398def4520afc48683a69effbb8b066efd9442ba3b356c8124aae6d9"
|
||||
},
|
||||
"cargo/lint-1/started.json": {
|
||||
"bytes": 1414,
|
||||
"sha256": "771b5bdc31662fb3b4f76735e92b0e89d31a02188fbce35c5cfb0aea4c37c0ca"
|
||||
},
|
||||
"cargo/lint-1/stderr.log": {
|
||||
"bytes": 1023,
|
||||
"sha256": "9cbab357d810ce8164a0e614491f53529e61c1cad79793ca429e13897a8dc5ed"
|
||||
},
|
||||
"cargo/lint-1/stdout.log": {
|
||||
"bytes": 0,
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
"cargo/lint-2/process.json": {
|
||||
"bytes": 196,
|
||||
"sha256": "f4aed65014ed120dc72bd08160559ae0285bc5c4968305a5fb86cf29e8c0d347"
|
||||
},
|
||||
"cargo/lint-2/result.json": {
|
||||
"bytes": 1805,
|
||||
"sha256": "b873add4777fd8f7516513e2423f9d3eb4a40505a3b1fa7edfbee89fb5c7e0d3"
|
||||
},
|
||||
"cargo/lint-2/started.json": {
|
||||
"bytes": 1414,
|
||||
"sha256": "0d01a20ec0f7cd796c066c95936c74df0abf8947fca655d900a5bdd4f8dbd610"
|
||||
},
|
||||
"cargo/lint-2/stderr.log": {
|
||||
"bytes": 207,
|
||||
"sha256": "e6f76746d5864a4de1e9d238c74799e2080e2c6214f51b68a4692d5e15f5b107"
|
||||
},
|
||||
"cargo/lint-2/stdout.log": {
|
||||
"bytes": 0,
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
"cargo/lint-3/process.json": {
|
||||
"bytes": 196,
|
||||
"sha256": "620094b52dab8f23f389687cf72b8b3aa4effa55697842206a618293537eb869"
|
||||
},
|
||||
"cargo/lint-3/result.json": {
|
||||
"bytes": 1805,
|
||||
"sha256": "1e7dc472c03d653094850ed653599cee41e50d1639389bbc3fee45e40a754b5c"
|
||||
},
|
||||
"cargo/lint-3/started.json": {
|
||||
"bytes": 1414,
|
||||
"sha256": "b921ee7102386187c288c1b25aea96a3019ff00a4a508065aefbc4499b7c7818"
|
||||
},
|
||||
"cargo/lint-3/stderr.log": {
|
||||
"bytes": 137,
|
||||
"sha256": "d19e47547a69ce7956c74f2a65cd1064571985d073bcf497a11a0b0eebcfbd04"
|
||||
},
|
||||
"cargo/lint-3/stdout.log": {
|
||||
"bytes": 0,
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
"cargo/native-build-1/process.json": {
|
||||
"bytes": 194,
|
||||
"sha256": "390cb979d680b9fdb7e0afa05d6b9d10a25dc27abaff16596d113bb0b2142b43"
|
||||
},
|
||||
"cargo/native-build-1/result.json": {
|
||||
"bytes": 3132,
|
||||
"sha256": "35f76499ac2a49446b896932c963fd8734a5755e00e3511cedf1e584e7b17704"
|
||||
},
|
||||
"cargo/native-build-1/started.json": {
|
||||
"bytes": 1397,
|
||||
"sha256": "5f37fc095aa00eae8fae043651a29f387f484bf4a0cc454e3919ea4f49567126"
|
||||
},
|
||||
"cargo/native-build-1/stderr.log": {
|
||||
"bytes": 130,
|
||||
"sha256": "731d61ee089d75c107edcd02d2cdedcddaad57ec06607ccd0822d9dc71a72720"
|
||||
},
|
||||
"cargo/native-build-1/stdout.log": {
|
||||
"bytes": 41700,
|
||||
"sha256": "832dc599cad2c9ba28ff9153537fb428390d8f25959f5b4a080cd432f6bea0e8"
|
||||
},
|
||||
"cargo/native-build-2/process.json": {
|
||||
"bytes": 194,
|
||||
"sha256": "46ff130840d8b12272be43f479adeb0b2ace94ee1d5389c5638a61b22d732496"
|
||||
},
|
||||
"cargo/native-build-2/result.json": {
|
||||
"bytes": 3131,
|
||||
"sha256": "fbecac3b0f7913db5e2ad19043c7df167a92287833ddf3ea4f24766e86ae523b"
|
||||
},
|
||||
"cargo/native-build-2/started.json": {
|
||||
"bytes": 1397,
|
||||
"sha256": "cbd2c7ad98baddc3719a5c03bef103747d4cb5c560d631376149f116ede49584"
|
||||
},
|
||||
"cargo/native-build-2/stderr.log": {
|
||||
"bytes": 60,
|
||||
"sha256": "b2558545bbfa50a0f0c26623c0982eb5f96d71ea228d7c56180edbac0d0b295d"
|
||||
},
|
||||
"cargo/native-build-2/stdout.log": {
|
||||
"bytes": 41699,
|
||||
"sha256": "7884b8ea1a92d58c7174657560ec38ff9a073e52f93cc19a78290840f2ab5486"
|
||||
},
|
||||
"cargo/oracle-2/process.json": {
|
||||
"bytes": 189,
|
||||
"sha256": "1e8ffd7a03b22bf2ec43b0d66ae3cc98016a431c4a07499faf3ca0c635b92337"
|
||||
},
|
||||
"cargo/oracle-2/result.json": {
|
||||
"bytes": 1789,
|
||||
"sha256": "e0b7331751bae8564ee5c28d05029071f1c8460195e6f9e2f0d00a5ae4e05a16"
|
||||
},
|
||||
"cargo/oracle-2/started.json": {
|
||||
"bytes": 1397,
|
||||
"sha256": "b7a8278dfa2324f0c3ff6e9616a65ef7a51c99699ad700adfe90e2accc435765"
|
||||
},
|
||||
"cargo/oracle-2/stderr.log": {
|
||||
"bytes": 520,
|
||||
"sha256": "9dd8b849afdbbb2207a9ad82f1b6c6f07fd9cd0846fe4591f33c40fdadcbb3ac"
|
||||
},
|
||||
"cargo/oracle-2/stdout.log": {
|
||||
"bytes": 0,
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
"cargo/oracle-3/process.json": {
|
||||
"bytes": 189,
|
||||
"sha256": "a2cb30f512ee390bb03c9fcf5ea8069ca3a05da91f798985efc3f9f54670ace4"
|
||||
},
|
||||
"cargo/oracle-3/result.json": {
|
||||
"bytes": 1791,
|
||||
"sha256": "f0768fecc9f6711dd3cfc627c60aaf6118216bce47e76faa4cbdda571ac675e4"
|
||||
},
|
||||
"cargo/oracle-3/started.json": {
|
||||
"bytes": 1397,
|
||||
"sha256": "30cf614b6dede363ae4bdeca56fc4a1ddb7ee19a7e592b1b1616d4a98a53b535"
|
||||
},
|
||||
"cargo/oracle-3/stderr.log": {
|
||||
"bytes": 307,
|
||||
"sha256": "b4ccc583c5001d81b7208fe6f2e52788d0d40b63c7da034ca1fb90ec2adb18a4"
|
||||
},
|
||||
"cargo/oracle-3/stdout.log": {
|
||||
"bytes": 2968,
|
||||
"sha256": "e0a62ca8b008370c5a41378eb4c9e2a229a2fcde7bdba55426313c7d23da8aca"
|
||||
},
|
||||
"cargo/windows-build-1/process.json": {
|
||||
"bytes": 189,
|
||||
"sha256": "4e6b57dc0d5f10ac1b4af991f8b0f7558f4fc5b626e00f1a49c5782ea13abe18"
|
||||
},
|
||||
"cargo/windows-build-1/result.json": {
|
||||
"bytes": 3082,
|
||||
"sha256": "3cdd09a859288afe71dde6fb51bcc4bca84e47ceb9cb6389d57f6722d9e23fdf"
|
||||
},
|
||||
"cargo/windows-build-1/started.json": {
|
||||
"bytes": 1392,
|
||||
"sha256": "a3ebcdffc01e93fd5609005a0539eb11bf473b45bc28eee71dd175b943b387eb"
|
||||
},
|
||||
"cargo/windows-build-1/stderr.log": {
|
||||
"bytes": 319,
|
||||
"sha256": "7201ff3f71fe793907482992a00682023fc6ee1ac0a016f53a048b299ec2fffd"
|
||||
},
|
||||
"cargo/windows-build-1/stdout.log": {
|
||||
"bytes": 284182,
|
||||
"sha256": "3fc90b8c923415496c7990d33a5fe7211423b83df833a80111079fad71f04a2d"
|
||||
},
|
||||
"cargo/windows-build-2/process.json": {
|
||||
"bytes": 189,
|
||||
"sha256": "f18acb4c9feede438e1aa67bb86ce2d74f5b3aa0142499ae99b56b23daaf834b"
|
||||
},
|
||||
"cargo/windows-build-2/result.json": {
|
||||
"bytes": 3081,
|
||||
"sha256": "d7b191ee07c302ffed1017374b0c5d8b86245c0f3348fbac4778ae573397b9de"
|
||||
},
|
||||
"cargo/windows-build-2/started.json": {
|
||||
"bytes": 1392,
|
||||
"sha256": "b6a6656d966559060f01e87ccbd75837b0cf67e70c77246b20f587167bdbf651"
|
||||
},
|
||||
"cargo/windows-build-2/stderr.log": {
|
||||
"bytes": 137,
|
||||
"sha256": "aafe81cdd7c1e5558e957d4296d46e3238c72af79ba79d6147c5cc16f938d6ef"
|
||||
},
|
||||
"cargo/windows-build-2/stdout.log": {
|
||||
"bytes": 284179,
|
||||
"sha256": "cfba733cb79a9496004109bb0bc8b8996df5ec1645691fd79b952063faa519c5"
|
||||
},
|
||||
"helpers/6b8fcf64a1eb0869ae29abd66b4e89564a1813fef5898b54f2a17a3230dbaa06.py": {
|
||||
"bytes": 3502,
|
||||
"sha256": "6b8fcf64a1eb0869ae29abd66b4e89564a1813fef5898b54f2a17a3230dbaa06"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Package explicitly selected closed gate captures without running Cargo."""
|
||||
from pathlib import Path
|
||||
import argparse
|
||||
import json
|
||||
import zipfile
|
||||
from verify import RAW_FILES, pin, parsed, need, run_name, verify_data, verify_package
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def collect(root, runs, helpers):
|
||||
gates = root / ".local/group-movement-gates"
|
||||
candidates = sorted(gates.glob("*.py")) + helpers
|
||||
helper_bytes = [path.read_bytes() for path in candidates]
|
||||
payload, logical, specs = {}, {}, {}
|
||||
def add(name, data):
|
||||
stored = ("capsules/" + pin(data)["sha256"] + Path(name).suffix
|
||||
if name.rsplit("/", 1)[1] in {"inputs.json", "inputs.zip"} else name)
|
||||
need(stored not in payload or payload[stored] == data, "deduplication collision")
|
||||
payload[stored] = data
|
||||
logical[name] = stored
|
||||
for run in runs:
|
||||
folder = gates / run
|
||||
need(folder.is_dir(), "run directory missing: " + run)
|
||||
paths = list(folder.iterdir())
|
||||
need(all(path.is_file() and not path.is_symlink() for path in paths)
|
||||
and {path.name for path in paths} == RAW_FILES, "exact closed raw files required: " + run)
|
||||
raw = {path.name: path.read_bytes() for path in paths}
|
||||
result = parsed(raw["result.json"])
|
||||
need("finish" in result and result["inputsUnchanged"] is True, "unfinished/changed run refused: " + run)
|
||||
matches = [data for data in helper_bytes if pin(data) == result["helper"]]
|
||||
need(bool(matches), "exact historical helper unavailable for " + run + "; supply --helper PATH")
|
||||
helper = "helpers/" + result["helper"]["sha256"] + ".py"
|
||||
add(helper, matches[0])
|
||||
for name, data in sorted(raw.items()):
|
||||
add("cargo/" + run + "/" + name, data)
|
||||
specs[run] = {key: result[key] for key in ("command", "head", "exitCode", "inputCount")}
|
||||
specs[run]["helper"] = helper
|
||||
manifest = {"schema": 1, "kind": "multiselect-cargo-gates", "runs": specs,
|
||||
"logicalFiles": dict(sorted(logical.items())),
|
||||
"members": {name: pin(data) for name, data in sorted(payload.items())}}
|
||||
summary = verify_data(manifest, payload.__getitem__)
|
||||
return payload, manifest, summary
|
||||
|
||||
|
||||
def write_json(path, value):
|
||||
with path.open("xb") as output:
|
||||
output.write((json.dumps(value, indent=2) + "\n").encode("utf-8"))
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--root", type=Path, default=HERE.parents[3], help="checkout holding .local/group-movement-gates")
|
||||
parser.add_argument("--runs", nargs="+", required=True, help="explicit run names, including failures to retain")
|
||||
parser.add_argument("--helper", type=Path, action="append", default=[], help="additional exact historical helper (repeatable)")
|
||||
parser.add_argument("--output", type=Path, default=HERE, help="package directory; existing evidence is never overwritten")
|
||||
parser.add_argument("--check-only", action="store_true", help="validate raw captures in memory without creating an archive")
|
||||
args = parser.parse_args()
|
||||
need(all(run_name(run) for run in args.runs) and len(args.runs) == len(set(args.runs)), "unique safe run names required")
|
||||
payload, manifest, summary = collect(args.root.resolve(), args.runs, args.helper)
|
||||
if args.check_only:
|
||||
print("PASS raw consistency; no package written:", summary)
|
||||
return
|
||||
out = args.output.resolve()
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
for name in ("evidence.zip", "manifest.json", "files.json"):
|
||||
need(not (out / name).exists(), "existing package output refused: " + name)
|
||||
for name in ("prepare.py", "verify.py", "README.md"):
|
||||
data = (HERE / name).read_bytes()
|
||||
destination = out / name
|
||||
if destination.exists():
|
||||
need(destination.read_bytes() == data, "different existing package tool: " + name)
|
||||
else:
|
||||
with destination.open("xb") as output:
|
||||
output.write(data)
|
||||
with zipfile.ZipFile(out / "evidence.zip", "x", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as archive:
|
||||
for name, data in sorted(payload.items()):
|
||||
info = zipfile.ZipInfo(name, date_time=(2026, 9, 23, 0, 0, 0))
|
||||
info.compress_type = zipfile.ZIP_DEFLATED
|
||||
info.external_attr = 0o100644 << 16
|
||||
archive.writestr(info, data)
|
||||
write_json(out / "manifest.json", manifest)
|
||||
names = ("prepare.py", "verify.py", "README.md", "manifest.json", "evidence.zip")
|
||||
write_json(out / "files.json", {name: pin((out / name).read_bytes()) for name in names})
|
||||
need(verify_package(out) == summary, "written package differs from validated captures")
|
||||
print("PASS package readback:", out, summary)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,164 @@
|
||||
"""Offline consistency checks only; never execute or extract archived inputs."""
|
||||
from pathlib import Path, PurePosixPath
|
||||
from datetime import datetime
|
||||
import argparse
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import stat
|
||||
import zipfile
|
||||
|
||||
RAW_FILES = {"started.json", "process.json", "result.json", "stdout.log", "stderr.log", "inputs.json", "inputs.zip"}
|
||||
OUTER_FILES = {"prepare.py", "verify.py", "README.md", "manifest.json", "evidence.zip"}
|
||||
|
||||
|
||||
def need(condition, message):
|
||||
if not condition:
|
||||
raise ValueError(message)
|
||||
|
||||
|
||||
def pin(data):
|
||||
return {"bytes": len(data), "sha256": hashlib.sha256(data).hexdigest()}
|
||||
|
||||
|
||||
def valid_pin(value):
|
||||
return (isinstance(value, dict) and set(value) == {"bytes", "sha256"}
|
||||
and type(value["bytes"]) is int and value["bytes"] >= 0
|
||||
and isinstance(value["sha256"], str)
|
||||
and re.fullmatch(r"[0-9a-f]{64}", value["sha256"]) is not None)
|
||||
|
||||
|
||||
def safe(name):
|
||||
return (isinstance(name, str) and bool(name) and "\\" not in name and ":" not in name
|
||||
and all(ord(char) >= 32 for char in name)
|
||||
and not PurePosixPath(name).is_absolute()
|
||||
and all(part not in ("", ".", "..") for part in name.split("/")))
|
||||
|
||||
|
||||
def run_name(name):
|
||||
return isinstance(name, str) and re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", name) is not None
|
||||
|
||||
|
||||
def parsed(data):
|
||||
def pairs(items):
|
||||
result = {}
|
||||
for key, value in items:
|
||||
need(key not in result, "duplicate JSON key: " + key)
|
||||
result[key] = value
|
||||
return result
|
||||
def constant(value):
|
||||
raise ValueError("nonfinite JSON constant: " + value)
|
||||
return json.loads(data, object_pairs_hook=pairs, parse_constant=constant)
|
||||
|
||||
|
||||
def inventory(archive, expected):
|
||||
names = archive.namelist()
|
||||
need(len(names) == len(set(names)) and set(names) == set(expected), "exact ZIP membership")
|
||||
for entry in archive.infolist():
|
||||
need(safe(entry.filename) and not entry.is_dir(), "unsafe ZIP member: " + entry.filename)
|
||||
need(not stat.S_ISLNK(entry.external_attr >> 16), "ZIP symlink: " + entry.filename)
|
||||
need(valid_pin(expected[entry.filename]), "invalid member pin: " + entry.filename)
|
||||
need(entry.file_size == expected[entry.filename]["bytes"], "ZIP recorded size: " + entry.filename)
|
||||
|
||||
|
||||
def ordered_times(*values):
|
||||
times = [datetime.fromisoformat(value) for value in values]
|
||||
return all(value.utcoffset() is not None for value in times) and times == sorted(times)
|
||||
|
||||
|
||||
def verify_data(manifest, read):
|
||||
"""Shared read-only verifier for packaged bytes or prepare's in-memory payload."""
|
||||
need(manifest["schema"] == 1 and manifest["kind"] == "multiselect-cargo-gates", "manifest schema")
|
||||
runs, logical, stored = manifest["runs"], manifest["logicalFiles"], manifest["members"]
|
||||
need(isinstance(runs, dict) and bool(runs) and all(run_name(run) for run in runs), "explicit run inventory")
|
||||
need(all(safe(name) for name in logical) and all(safe(name) for name in stored), "unsafe manifest path")
|
||||
need(set(logical.values()) == set(stored), "unreferenced or absent stored members")
|
||||
for name, expected in stored.items():
|
||||
need(valid_pin(expected) and pin(read(name)) == expected, "stored member hash: " + name)
|
||||
expected_logical = {"cargo/" + run + "/" + name for run in runs for name in RAW_FILES}
|
||||
expected_logical.update(spec["helper"] for spec in runs.values())
|
||||
need(set(logical) == expected_logical, "exact logical raw/helper inventory")
|
||||
for name, destination in logical.items():
|
||||
if name.startswith("cargo/") and name.rsplit("/", 1)[1] in {"inputs.json", "inputs.zip"}:
|
||||
expected = "capsules/" + stored[destination]["sha256"] + PurePosixPath(name).suffix
|
||||
need(destination == expected, "input capsule mapping: " + name)
|
||||
else:
|
||||
need(destination == name, "only input maps/archives may be deduplicated: " + name)
|
||||
def raw(name):
|
||||
return read(logical[name])
|
||||
seen_capsules = set()
|
||||
for run, spec in runs.items():
|
||||
prefix = "cargo/" + run + "/"
|
||||
start = parsed(raw(prefix + "started.json"))
|
||||
process = parsed(raw(prefix + "process.json"))
|
||||
result = parsed(raw(prefix + "result.json"))
|
||||
required_start = {"start", "command", "cwd", "head", "scope", "inputs", "archive",
|
||||
"inputCount", "environment", "helper", "cargo", "rustc"}
|
||||
need(required_start <= start.keys(), run + ": incomplete start receipt")
|
||||
need(all(result.get(key) == value for key, value in start.items()), run + ": start/result relationship")
|
||||
command = result["command"]
|
||||
need(isinstance(command, list) and command and command[0] == "cargo"
|
||||
and all(isinstance(arg, str) for arg in command), run + ": Cargo command")
|
||||
need(command == process["command"] == spec["command"], run + ": command relationship")
|
||||
need(result["head"] == spec["head"] and re.fullmatch(r"(?:[0-9a-f]{40}|[0-9a-f]{64})", result["head"]), run + ": HEAD")
|
||||
need(type(result["exitCode"]) is int and result["exitCode"] == spec["exitCode"], run + ": recorded exit")
|
||||
need(result["inputsUnchanged"] is True, run + ": changed inputs refused")
|
||||
need(type(result["seconds"]) in (int, float) and math.isfinite(result["seconds"])
|
||||
and result["seconds"] >= 0, run + ": duration")
|
||||
need(type(process["pid"]) is int and process["pid"] > 0, run + ": process identity")
|
||||
need(ordered_times(start["start"], process["observed"], result["finish"]), run + ": completed timestamp order")
|
||||
for field, filename in [("inputs", "inputs.json"), ("archive", "inputs.zip"), ("stdout", "stdout.log"), ("stderr", "stderr.log")]:
|
||||
need(result[field] == pin(raw(prefix + filename)), run + ": " + field + " pin")
|
||||
need(valid_pin(result["helper"]) and result["helper"] == pin(raw(spec["helper"])), run + ": helper pin")
|
||||
need(spec["helper"] == "helpers/" + result["helper"]["sha256"] + ".py", run + ": helper mapping")
|
||||
inputs = parsed(raw(prefix + "inputs.json"))
|
||||
need(isinstance(inputs, dict) and all(safe(name) for name in inputs), run + ": safe input inventory")
|
||||
need(type(result["inputCount"]) is int and len(inputs) == result["inputCount"] == spec["inputCount"], run + ": input counts")
|
||||
need(sum(name.startswith("crates/vendor/iced_widget/") for name in inputs) == 71, run + ": vendor count")
|
||||
capsule = (logical[prefix + "inputs.json"], logical[prefix + "inputs.zip"])
|
||||
if capsule not in seen_capsules:
|
||||
with zipfile.ZipFile(io.BytesIO(raw(prefix + "inputs.zip"))) as source:
|
||||
inventory(source, inputs)
|
||||
for name, expected in inputs.items():
|
||||
need(pin(source.read(name)) == expected, run + ": source hash " + name)
|
||||
seen_capsules.add(capsule)
|
||||
artifacts = []
|
||||
for line in raw(prefix + "stdout.log").decode("utf-8", errors="replace").splitlines():
|
||||
try:
|
||||
value = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if isinstance(value, dict) and value.get("reason") == "compiler-artifact" and value.get("executable"):
|
||||
artifacts.append(value)
|
||||
need(isinstance(result["artifacts"], list), run + ": artifact records")
|
||||
need(artifacts == [record["cargo"] for record in result["artifacts"]], run + ": Cargo JSON artifact relationship")
|
||||
for record in result["artifacts"]:
|
||||
need(set(record) == {"cargo", "readback"}
|
||||
and (record["readback"] is None or valid_pin(record["readback"])), run + ": artifact readback pin")
|
||||
print(run, "recorded exit", result["exitCode"], "inputs", len(inputs), "executable records", len(artifacts))
|
||||
return {"runs": len(runs), "logicalFiles": len(logical), "storedMembers": len(stored), "sourceCapsulePairs": len(seen_capsules)}
|
||||
|
||||
|
||||
def verify_package(root):
|
||||
files = parsed((root / "files.json").read_bytes())
|
||||
need(set(files) == OUTER_FILES, "outer file inventory")
|
||||
for name, expected in files.items():
|
||||
need(valid_pin(expected) and pin((root / name).read_bytes()) == expected, "outer hash: " + name)
|
||||
manifest = parsed((root / "manifest.json").read_bytes())
|
||||
with zipfile.ZipFile(root / "evidence.zip") as archive:
|
||||
inventory(archive, manifest["members"])
|
||||
return verify_data(manifest, archive.read)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("package", nargs="?", type=Path, default=Path(__file__).resolve().parent)
|
||||
args = parser.parse_args()
|
||||
print("PASS offline consistency:", verify_package(args.package))
|
||||
print("Historical receipts only; no source/binary execution or native acceptance claim.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,35 @@
|
||||
# Run-2 baseline checker environment incident
|
||||
|
||||
The first baseline checker invocation exited `3221225781` (`0xC0000135`) at
|
||||
2026-09-23 09:07:10 UTC with empty stdout/stderr. The parent reported that this
|
||||
shell omitted dot-sourcing the toolchain `env.ps1`. After that environment setup,
|
||||
the same checker command succeeded at 09:08:08 UTC with a complete-Project PASS.
|
||||
This records a checker-process launch incident, not a failure of the running GUI.
|
||||
No particular missing DLL was measured or identified.
|
||||
|
||||
`run-2-baseline-runtime-environment/` is an exact copy of the failed snapshot:
|
||||
started/result receipts, empty streams and all nine source files. Its unchanged
|
||||
command text still names the original `run-2/snapshots/baseline` location; the
|
||||
parent moved the failed directory aside before retrying. Paths in these records
|
||||
are historical labels and have not been rewritten.
|
||||
|
||||
`retry-baseline/` preserves the successful retry's original started/result
|
||||
receipts and streams. Direct comparison confirmed identical command, build
|
||||
receipt, binary, helper, baseline and source pins between attempts. All three
|
||||
unchanged flags are true in both results. The nine retry source files were
|
||||
also compared byte for byte with the failed snapshot and are identical; they
|
||||
are not duplicated here. The successful run's complete snapshots belong in the
|
||||
separate native evidence package.
|
||||
|
||||
`helpers/check-snapshot-2.py` matches the helper pin in both receipts.
|
||||
`helpers/env.ps1` preserves the environment setup script available when this
|
||||
incident was packaged. It sets toolchain paths and runtime search paths and
|
||||
contains no credentials. The checker receipts do not record the environment or
|
||||
pin `env.ps1`, so the omitted/setup distinction is the parent's invocation
|
||||
observation, not an independently measured environment comparison. Both scripts
|
||||
are historical data; this package does not execute them.
|
||||
|
||||
The checker binary pin in both attempts is
|
||||
`4de47a2b62192be6a1394e654f368be19e36fb966d60d6c833a5d51d18247bb1`.
|
||||
`files.json` pins every included file except itself. Raw bytes are preserved.
|
||||
No frozen checks, source-identity or native packaging tools were changed.
|
||||
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"helpers/check-snapshot-2.py": {
|
||||
"bytes": 2494,
|
||||
"sha256": "1dbfff41f07bd750d4e550660d91ae9790d2442fbfe685dc88b7f63a47f5acb8"
|
||||
},
|
||||
"helpers/env.ps1": {
|
||||
"bytes": 621,
|
||||
"sha256": "d7eb6249bef73adacdb05cebefe7ca478758c3c38de17a1731bf01f8b25dc379"
|
||||
},
|
||||
"README.md": {
|
||||
"bytes": 2146,
|
||||
"sha256": "d5a853c62713a6c87d28acfbe28c03bd95459275d0a2eb752d978e5a9b427bc8"
|
||||
},
|
||||
"retry-baseline/result.json": {
|
||||
"bytes": 2758,
|
||||
"sha256": "ae16cbc54bf5b6054750ef082f47bf0a26ee2dfb7bce46f19c8fd2c1ac622d7f"
|
||||
},
|
||||
"retry-baseline/started.json": {
|
||||
"bytes": 2356,
|
||||
"sha256": "e172a36f0872d49a8fe0f7200fb56d597b71fd1849dc9902d53ac1813a3c7d4a"
|
||||
},
|
||||
"retry-baseline/stderr.log": {
|
||||
"bytes": 0,
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
"retry-baseline/stdout.log": {
|
||||
"bytes": 441,
|
||||
"sha256": "12842cc4e483e2546f5c8615b17fe72c5fe07f3fb4440469071433c82d9a9b30"
|
||||
},
|
||||
"run-2-baseline-runtime-environment/result.json": {
|
||||
"bytes": 2765,
|
||||
"sha256": "539b950952461cc300876dbc45fa9ee02a9ecefb6d712fce6b74bad3fa701b30"
|
||||
},
|
||||
"run-2-baseline-runtime-environment/source/oa/activities/activity_activity_a.arcadia": {
|
||||
"bytes": 474,
|
||||
"sha256": "9bf7c5637d87c97213cf869118e942e8a29a67b429fa58e4de42235bba0980ee"
|
||||
},
|
||||
"run-2-baseline-runtime-environment/source/oa/activities/activity_activity_b.arcadia": {
|
||||
"bytes": 286,
|
||||
"sha256": "152007669b7c570674aff99615a48a6f557be50f88bbbb3a5edc20b423c79a1f"
|
||||
},
|
||||
"run-2-baseline-runtime-environment/source/oa/activities/activity_activity_c.arcadia": {
|
||||
"bytes": 153,
|
||||
"sha256": "d726b6b4e1200edd9ef752bb767e38d3d69dcac8eb6135d5f83c93832ff849b1"
|
||||
},
|
||||
"run-2-baseline-runtime-environment/source/project.arcadia": {
|
||||
"bytes": 597,
|
||||
"sha256": "e7bd67c5785a78766298255ed3d53ff6d65dcd24bde1570ac8a0ff45cc9dc385"
|
||||
},
|
||||
"run-2-baseline-runtime-environment/source/sa/functions/function_sa_function.arcadia": {
|
||||
"bytes": 162,
|
||||
"sha256": "1c724a9204784435e729badd4ad164b1a6b891840bb1ddc354ac1e175e3d712c"
|
||||
},
|
||||
"run-2-baseline-runtime-environment/source/traceability.arcadia": {
|
||||
"bytes": 522,
|
||||
"sha256": "26b0b10715ced7aefcde941e6d1c1a6973f174997eae989e32a25c695dfb7cd1"
|
||||
},
|
||||
"run-2-baseline-runtime-environment/source/views/diagram_oa_other.arcadia": {
|
||||
"bytes": 315,
|
||||
"sha256": "06ceef9cfdbb3ef229a22db6c48d8bb808388e027c37294648555ecbfb6bfab9"
|
||||
},
|
||||
"run-2-baseline-runtime-environment/source/views/diagram_oa_view.arcadia": {
|
||||
"bytes": 1896,
|
||||
"sha256": "4e7d47d6a03153070687136c89faf01fc6df45ae89e2695beeab2fe4872dbc88"
|
||||
},
|
||||
"run-2-baseline-runtime-environment/source/views/diagram_sa_view.arcadia": {
|
||||
"bytes": 331,
|
||||
"sha256": "c75b0fe14496631c9b017a32e277d1e5ad5b25e00e06b804fc54d950d7fb57f6"
|
||||
},
|
||||
"run-2-baseline-runtime-environment/started.json": {
|
||||
"bytes": 2356,
|
||||
"sha256": "338c14c7dc827bd4ab09009bb974ad3c481b07cdbad2437f5291b1ff67b5ba42"
|
||||
},
|
||||
"run-2-baseline-runtime-environment/stderr.log": {
|
||||
"bytes": 0,
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
"run-2-baseline-runtime-environment/stdout.log": {
|
||||
"bytes": 0,
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Copy stable native-saved source and check its complete Project independently."""
|
||||
from pathlib import Path
|
||||
import datetime, hashlib, json, re, shutil, subprocess, sys
|
||||
root = Path('C:/ar-multiselect-9e8r6t')
|
||||
native = root / '.local/multiselect-native'
|
||||
label, mode, *rest = sys.argv[1:]
|
||||
assert not rest and re.fullmatch('[a-z0-9-]+', label)
|
||||
working = native / 'working-2'
|
||||
out = native / 'run-2/snapshots' / label
|
||||
out.mkdir(parents=True, exist_ok=False)
|
||||
gate_path = root / '.local/group-movement-gates/native-build-2/result.json'
|
||||
gate = json.loads(gate_path.read_bytes())
|
||||
assert gate['exitCode'] == 0 and gate['inputsUnchanged']
|
||||
artifact, = [a for a in gate['artifacts'] if a['cargo']['target']['name'] == 'multiselect_native']
|
||||
binary = Path(artifact['cargo']['executable'])
|
||||
baseline = native / 'fixture-2/baseline.json'
|
||||
def pin(path):
|
||||
b = path.read_bytes()
|
||||
return {'bytes': len(b), 'sha256': hashlib.sha256(b).hexdigest()}
|
||||
def inventory():
|
||||
return {p.relative_to(working).as_posix():pin(p) for p in sorted(working.rglob('*.arcadia')) if p.is_file()}
|
||||
def now(): return datetime.datetime.now(datetime.timezone.utc).isoformat()
|
||||
before = inventory()
|
||||
assert 'project.arcadia' in before and pin(binary) == artifact['readback']
|
||||
for name in before:
|
||||
target = out/'source'/name
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(working/name, target)
|
||||
assert before == inventory() == {name:pin(out/'source'/name) for name in before}
|
||||
command = [str(binary), 'check', mode, str(baseline), str(out/'source/project.arcadia')]
|
||||
start = {'start':now(), 'command':command, 'buildReceipt':pin(gate_path), 'binary':pin(binary), 'helper':pin(Path(__file__)), 'source':before, 'baseline':pin(baseline)}
|
||||
(out/'started.json').write_text(json.dumps(start,indent=2),encoding='utf-8')
|
||||
r = subprocess.run(command,cwd=root,capture_output=True)
|
||||
(out/'stdout.log').write_bytes(r.stdout)
|
||||
(out/'stderr.log').write_bytes(r.stderr)
|
||||
result = {**start, 'finish':now(), 'exitCode':r.returncode, 'binaryUnchanged':pin(binary)==artifact['readback'], 'workingSourceUnchanged':before==inventory(), 'baselineUnchanged':pin(baseline)==start['baseline'], 'stdout':pin(out/'stdout.log'), 'stderr':pin(out/'stderr.log')}
|
||||
(out/'result.json').write_text(json.dumps(result,indent=2),encoding='utf-8')
|
||||
print(r.stdout.decode(errors='replace'))
|
||||
print(r.stderr.decode(errors='replace'))
|
||||
assert result['binaryUnchanged'] and result['workingSourceUnchanged'] and result['baselineUnchanged']
|
||||
sys.exit(r.returncode)
|
||||
@@ -0,0 +1,9 @@
|
||||
$taskRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
|
||||
$env:RUSTUP_HOME = Join-Path $taskRoot '.local\toolchain\rustup'
|
||||
$env:CARGO_HOME = Join-Path $taskRoot '.local\toolchain\cargo'
|
||||
$env:RUSTUP_TOOLCHAIN = 'stable-x86_64-pc-windows-gnullvm'
|
||||
$env:PATH = "$env:CARGO_HOME\bin;$(Join-Path $taskRoot '.local\toolchain\llvm-mingw-20260826-ucrt-x86_64\bin');$env:PATH"
|
||||
$env:CARGO_TARGET_DIR = Join-Path $taskRoot '.local\target-gnullvm'
|
||||
# Keep this small development disk from accumulating multi-GiB incremental caches.
|
||||
$env:CARGO_INCREMENTAL = '0'
|
||||
$env:CARGO_TARGET_X86_64_PC_WINDOWS_GNULLVM_LINKER = 'clang.exe'
|
||||
@@ -0,0 +1,77 @@
|
||||
{
|
||||
"start": "2026-09-23T09:08:07.906733+00:00",
|
||||
"command": [
|
||||
"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\examples\\multiselect_native.exe",
|
||||
"check",
|
||||
"unchanged",
|
||||
"C:\\ar-multiselect-9e8r6t\\.local\\multiselect-native\\fixture-2\\baseline.json",
|
||||
"C:\\ar-multiselect-9e8r6t\\.local\\multiselect-native\\run-2\\snapshots\\baseline\\source\\project.arcadia"
|
||||
],
|
||||
"buildReceipt": {
|
||||
"bytes": 3131,
|
||||
"sha256": "fbecac3b0f7913db5e2ad19043c7df167a92287833ddf3ea4f24766e86ae523b"
|
||||
},
|
||||
"binary": {
|
||||
"bytes": 22162432,
|
||||
"sha256": "4de47a2b62192be6a1394e654f368be19e36fb966d60d6c833a5d51d18247bb1"
|
||||
},
|
||||
"helper": {
|
||||
"bytes": 2494,
|
||||
"sha256": "1dbfff41f07bd750d4e550660d91ae9790d2442fbfe685dc88b7f63a47f5acb8"
|
||||
},
|
||||
"source": {
|
||||
"oa/activities/activity_activity_a.arcadia": {
|
||||
"bytes": 474,
|
||||
"sha256": "9bf7c5637d87c97213cf869118e942e8a29a67b429fa58e4de42235bba0980ee"
|
||||
},
|
||||
"oa/activities/activity_activity_b.arcadia": {
|
||||
"bytes": 286,
|
||||
"sha256": "152007669b7c570674aff99615a48a6f557be50f88bbbb3a5edc20b423c79a1f"
|
||||
},
|
||||
"oa/activities/activity_activity_c.arcadia": {
|
||||
"bytes": 153,
|
||||
"sha256": "d726b6b4e1200edd9ef752bb767e38d3d69dcac8eb6135d5f83c93832ff849b1"
|
||||
},
|
||||
"project.arcadia": {
|
||||
"bytes": 597,
|
||||
"sha256": "e7bd67c5785a78766298255ed3d53ff6d65dcd24bde1570ac8a0ff45cc9dc385"
|
||||
},
|
||||
"sa/functions/function_sa_function.arcadia": {
|
||||
"bytes": 162,
|
||||
"sha256": "1c724a9204784435e729badd4ad164b1a6b891840bb1ddc354ac1e175e3d712c"
|
||||
},
|
||||
"traceability.arcadia": {
|
||||
"bytes": 522,
|
||||
"sha256": "26b0b10715ced7aefcde941e6d1c1a6973f174997eae989e32a25c695dfb7cd1"
|
||||
},
|
||||
"views/diagram_oa_other.arcadia": {
|
||||
"bytes": 315,
|
||||
"sha256": "06ceef9cfdbb3ef229a22db6c48d8bb808388e027c37294648555ecbfb6bfab9"
|
||||
},
|
||||
"views/diagram_oa_view.arcadia": {
|
||||
"bytes": 1896,
|
||||
"sha256": "4e7d47d6a03153070687136c89faf01fc6df45ae89e2695beeab2fe4872dbc88"
|
||||
},
|
||||
"views/diagram_sa_view.arcadia": {
|
||||
"bytes": 331,
|
||||
"sha256": "c75b0fe14496631c9b017a32e277d1e5ad5b25e00e06b804fc54d950d7fb57f6"
|
||||
}
|
||||
},
|
||||
"baseline": {
|
||||
"bytes": 8944,
|
||||
"sha256": "d1278e58cd31e1d61093292eddc1d7b2cc9c8cdc7fe5dd3801de8411af8db89d"
|
||||
},
|
||||
"finish": "2026-09-23T09:08:08.158861+00:00",
|
||||
"exitCode": 0,
|
||||
"binaryUnchanged": true,
|
||||
"workingSourceUnchanged": true,
|
||||
"baselineUnchanged": true,
|
||||
"stdout": {
|
||||
"bytes": 441,
|
||||
"sha256": "12842cc4e483e2546f5c8615b17fe72c5fe07f3fb4440469071433c82d9a9b30"
|
||||
},
|
||||
"stderr": {
|
||||
"bytes": 0,
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"start": "2026-09-23T09:08:07.906733+00:00",
|
||||
"command": [
|
||||
"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\examples\\multiselect_native.exe",
|
||||
"check",
|
||||
"unchanged",
|
||||
"C:\\ar-multiselect-9e8r6t\\.local\\multiselect-native\\fixture-2\\baseline.json",
|
||||
"C:\\ar-multiselect-9e8r6t\\.local\\multiselect-native\\run-2\\snapshots\\baseline\\source\\project.arcadia"
|
||||
],
|
||||
"buildReceipt": {
|
||||
"bytes": 3131,
|
||||
"sha256": "fbecac3b0f7913db5e2ad19043c7df167a92287833ddf3ea4f24766e86ae523b"
|
||||
},
|
||||
"binary": {
|
||||
"bytes": 22162432,
|
||||
"sha256": "4de47a2b62192be6a1394e654f368be19e36fb966d60d6c833a5d51d18247bb1"
|
||||
},
|
||||
"helper": {
|
||||
"bytes": 2494,
|
||||
"sha256": "1dbfff41f07bd750d4e550660d91ae9790d2442fbfe685dc88b7f63a47f5acb8"
|
||||
},
|
||||
"source": {
|
||||
"oa/activities/activity_activity_a.arcadia": {
|
||||
"bytes": 474,
|
||||
"sha256": "9bf7c5637d87c97213cf869118e942e8a29a67b429fa58e4de42235bba0980ee"
|
||||
},
|
||||
"oa/activities/activity_activity_b.arcadia": {
|
||||
"bytes": 286,
|
||||
"sha256": "152007669b7c570674aff99615a48a6f557be50f88bbbb3a5edc20b423c79a1f"
|
||||
},
|
||||
"oa/activities/activity_activity_c.arcadia": {
|
||||
"bytes": 153,
|
||||
"sha256": "d726b6b4e1200edd9ef752bb767e38d3d69dcac8eb6135d5f83c93832ff849b1"
|
||||
},
|
||||
"project.arcadia": {
|
||||
"bytes": 597,
|
||||
"sha256": "e7bd67c5785a78766298255ed3d53ff6d65dcd24bde1570ac8a0ff45cc9dc385"
|
||||
},
|
||||
"sa/functions/function_sa_function.arcadia": {
|
||||
"bytes": 162,
|
||||
"sha256": "1c724a9204784435e729badd4ad164b1a6b891840bb1ddc354ac1e175e3d712c"
|
||||
},
|
||||
"traceability.arcadia": {
|
||||
"bytes": 522,
|
||||
"sha256": "26b0b10715ced7aefcde941e6d1c1a6973f174997eae989e32a25c695dfb7cd1"
|
||||
},
|
||||
"views/diagram_oa_other.arcadia": {
|
||||
"bytes": 315,
|
||||
"sha256": "06ceef9cfdbb3ef229a22db6c48d8bb808388e027c37294648555ecbfb6bfab9"
|
||||
},
|
||||
"views/diagram_oa_view.arcadia": {
|
||||
"bytes": 1896,
|
||||
"sha256": "4e7d47d6a03153070687136c89faf01fc6df45ae89e2695beeab2fe4872dbc88"
|
||||
},
|
||||
"views/diagram_sa_view.arcadia": {
|
||||
"bytes": 331,
|
||||
"sha256": "c75b0fe14496631c9b017a32e277d1e5ad5b25e00e06b804fc54d950d7fb57f6"
|
||||
}
|
||||
},
|
||||
"baseline": {
|
||||
"bytes": 8944,
|
||||
"sha256": "d1278e58cd31e1d61093292eddc1d7b2cc9c8cdc7fe5dd3801de8411af8db89d"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"baseline": "C:\\ar-multiselect-9e8r6t\\.local\\multiselect-native\\fixture-2\\baseline.json",
|
||||
"comparison": "complete Project equality",
|
||||
"inferred_common_delta": null,
|
||||
"limits": "Invariant evaluator only; no pointer/action/focus/Undo/source-byte acceptance claim",
|
||||
"mode": "unchanged",
|
||||
"result": "PASS",
|
||||
"saved": "C:\\ar-multiselect-9e8r6t\\.local\\multiselect-native\\run-2\\snapshots\\baseline\\source\\project.arcadia"
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
{
|
||||
"start": "2026-09-23T09:07:10.546708+00:00",
|
||||
"command": [
|
||||
"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\examples\\multiselect_native.exe",
|
||||
"check",
|
||||
"unchanged",
|
||||
"C:\\ar-multiselect-9e8r6t\\.local\\multiselect-native\\fixture-2\\baseline.json",
|
||||
"C:\\ar-multiselect-9e8r6t\\.local\\multiselect-native\\run-2\\snapshots\\baseline\\source\\project.arcadia"
|
||||
],
|
||||
"buildReceipt": {
|
||||
"bytes": 3131,
|
||||
"sha256": "fbecac3b0f7913db5e2ad19043c7df167a92287833ddf3ea4f24766e86ae523b"
|
||||
},
|
||||
"binary": {
|
||||
"bytes": 22162432,
|
||||
"sha256": "4de47a2b62192be6a1394e654f368be19e36fb966d60d6c833a5d51d18247bb1"
|
||||
},
|
||||
"helper": {
|
||||
"bytes": 2494,
|
||||
"sha256": "1dbfff41f07bd750d4e550660d91ae9790d2442fbfe685dc88b7f63a47f5acb8"
|
||||
},
|
||||
"source": {
|
||||
"oa/activities/activity_activity_a.arcadia": {
|
||||
"bytes": 474,
|
||||
"sha256": "9bf7c5637d87c97213cf869118e942e8a29a67b429fa58e4de42235bba0980ee"
|
||||
},
|
||||
"oa/activities/activity_activity_b.arcadia": {
|
||||
"bytes": 286,
|
||||
"sha256": "152007669b7c570674aff99615a48a6f557be50f88bbbb3a5edc20b423c79a1f"
|
||||
},
|
||||
"oa/activities/activity_activity_c.arcadia": {
|
||||
"bytes": 153,
|
||||
"sha256": "d726b6b4e1200edd9ef752bb767e38d3d69dcac8eb6135d5f83c93832ff849b1"
|
||||
},
|
||||
"project.arcadia": {
|
||||
"bytes": 597,
|
||||
"sha256": "e7bd67c5785a78766298255ed3d53ff6d65dcd24bde1570ac8a0ff45cc9dc385"
|
||||
},
|
||||
"sa/functions/function_sa_function.arcadia": {
|
||||
"bytes": 162,
|
||||
"sha256": "1c724a9204784435e729badd4ad164b1a6b891840bb1ddc354ac1e175e3d712c"
|
||||
},
|
||||
"traceability.arcadia": {
|
||||
"bytes": 522,
|
||||
"sha256": "26b0b10715ced7aefcde941e6d1c1a6973f174997eae989e32a25c695dfb7cd1"
|
||||
},
|
||||
"views/diagram_oa_other.arcadia": {
|
||||
"bytes": 315,
|
||||
"sha256": "06ceef9cfdbb3ef229a22db6c48d8bb808388e027c37294648555ecbfb6bfab9"
|
||||
},
|
||||
"views/diagram_oa_view.arcadia": {
|
||||
"bytes": 1896,
|
||||
"sha256": "4e7d47d6a03153070687136c89faf01fc6df45ae89e2695beeab2fe4872dbc88"
|
||||
},
|
||||
"views/diagram_sa_view.arcadia": {
|
||||
"bytes": 331,
|
||||
"sha256": "c75b0fe14496631c9b017a32e277d1e5ad5b25e00e06b804fc54d950d7fb57f6"
|
||||
}
|
||||
},
|
||||
"baseline": {
|
||||
"bytes": 8944,
|
||||
"sha256": "d1278e58cd31e1d61093292eddc1d7b2cc9c8cdc7fe5dd3801de8411af8db89d"
|
||||
},
|
||||
"finish": "2026-09-23T09:07:10.828848+00:00",
|
||||
"exitCode": 3221225781,
|
||||
"binaryUnchanged": true,
|
||||
"workingSourceUnchanged": true,
|
||||
"baselineUnchanged": true,
|
||||
"stdout": {
|
||||
"bytes": 0,
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
"stderr": {
|
||||
"bytes": 0,
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
oa_activity activity_a "A - receive request" id "f0149281-4798-58d3-acff-131eed250959" {
|
||||
description "Preserve engineering record activity_a.";
|
||||
}
|
||||
|
||||
oa_activity activity_child "A.1 - classify request" id "5a80d3af-c7ee-59eb-9680-eb80d60cb94f" {
|
||||
description "Preserve engineering record activity_child.";
|
||||
parent @activity_a;
|
||||
}
|
||||
|
||||
interaction internal "Request assessment" id "be7a39d9-3463-534e-b37b-76a74077d434" {
|
||||
source @activity_a;
|
||||
target @activity_b;
|
||||
}
|
||||
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
oa_activity activity_b "B - assess request" id "ecf076b7-e8be-5f64-8808-6a2edae5b9e2" {
|
||||
description "Preserve engineering record activity_b.";
|
||||
}
|
||||
|
||||
interaction external "Response decision" id "927dfa82-40d2-5c5f-a525-6a884725ea1d" {
|
||||
source @activity_b;
|
||||
target @activity_c;
|
||||
}
|
||||
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
oa_activity activity_c "C - dispatch response" id "3b258d00-854d-5ddd-a057-16eedbe31714" {
|
||||
description "Preserve engineering record activity_c.";
|
||||
}
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
arcadia 7;
|
||||
project multiselect_native "Multiselect native" id "c1deb118-0fb0-5692-8b80-d24339e5b6c3" {
|
||||
description "Shift-select A and B; move together, retain C and engineering semantics.";
|
||||
include "oa/activities/activity_activity_a.arcadia";
|
||||
include "oa/activities/activity_activity_b.arcadia";
|
||||
include "oa/activities/activity_activity_c.arcadia";
|
||||
include "sa/functions/function_sa_function.arcadia";
|
||||
include "traceability.arcadia";
|
||||
include "views/diagram_oa_other.arcadia";
|
||||
include "views/diagram_oa_view.arcadia";
|
||||
include "views/diagram_sa_view.arcadia";
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
sa_function sa_function "Technical request processing" id "878a1b33-a7c2-5a68-a8b6-d8273c6354e0" {
|
||||
description "Preserve engineering record sa_function.";
|
||||
}
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
trace preserved_refinement "Reviewed request refinement" id "b42fb6e3-db90-5349-b957-faeba373ba91" {
|
||||
baseline {
|
||||
oa {
|
||||
description "Preserve engineering record activity_a.";
|
||||
name "A - receive request";
|
||||
};
|
||||
sa {
|
||||
description "Preserve engineering record sa_function.";
|
||||
name "Technical request processing";
|
||||
};
|
||||
};
|
||||
description "Group geometry and Hide preserve this trace and both endpoints.";
|
||||
oa @activity_a;
|
||||
sa @sa_function;
|
||||
}
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
diagram oa_other "Other OA view - preserve" id "c4a58011-d2d7-59b1-ad58-feacef0e2d78" {
|
||||
description "Selection changes placement geometry only.";
|
||||
}
|
||||
|
||||
node other_a id "9aa9a68b-4c26-5d21-bc35-63d3f0f311d6" {
|
||||
diagram @oa_other;
|
||||
element @activity_a;
|
||||
position {
|
||||
x 40.0;
|
||||
y 60.0;
|
||||
};
|
||||
}
|
||||
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
diagram oa_view "Multiselect native - OA overview" id "00000000-0000-0000-0000-000000290001" {
|
||||
description "Selection changes placement geometry only.";
|
||||
routes [{
|
||||
edge @internal;
|
||||
label {
|
||||
fraction 0.44999998807907104;
|
||||
offset {
|
||||
x 7.0;
|
||||
y -19.0;
|
||||
};
|
||||
};
|
||||
source_attachment {
|
||||
offset 0.25;
|
||||
side Right;
|
||||
};
|
||||
target_attachment {
|
||||
offset 0.699999988079071;
|
||||
side Left;
|
||||
};
|
||||
waypoints [{
|
||||
x 420.0;
|
||||
y 145.0;
|
||||
}, {
|
||||
x 420.0;
|
||||
y 186.0;
|
||||
}];
|
||||
}, {
|
||||
edge @external;
|
||||
label {
|
||||
fraction 0.44999998807907104;
|
||||
offset {
|
||||
x 7.0;
|
||||
y -19.0;
|
||||
};
|
||||
};
|
||||
source_attachment {
|
||||
offset 0.25;
|
||||
side Right;
|
||||
};
|
||||
target_attachment {
|
||||
offset 0.699999988079071;
|
||||
side Left;
|
||||
};
|
||||
waypoints [{
|
||||
x 725.0;
|
||||
y 150.0;
|
||||
}, {
|
||||
x 725.0;
|
||||
y 336.0;
|
||||
}];
|
||||
}];
|
||||
}
|
||||
|
||||
node node_a id "2670dd46-9fe7-5047-b13d-59817cff96dd" {
|
||||
diagram @oa_view;
|
||||
element @activity_a;
|
||||
position {
|
||||
x 80.0;
|
||||
y 80.0;
|
||||
};
|
||||
size {
|
||||
height 260.0;
|
||||
width 310.0;
|
||||
};
|
||||
}
|
||||
|
||||
node node_b id "70e322db-f401-58c8-a50e-2f71c61ad721" {
|
||||
diagram @oa_view;
|
||||
element @activity_b;
|
||||
position {
|
||||
x 470.0;
|
||||
y 120.0;
|
||||
};
|
||||
}
|
||||
|
||||
node node_c id "d6b89a5a-6e2f-50f9-9d1e-993304a54583" {
|
||||
diagram @oa_view;
|
||||
element @activity_c;
|
||||
position {
|
||||
x 780.0;
|
||||
y 270.0;
|
||||
};
|
||||
}
|
||||
|
||||
node node_child id "87f4652b-15ef-55ed-ad7e-cc5d22b7facb" {
|
||||
diagram @oa_view;
|
||||
element @activity_child;
|
||||
position {
|
||||
x 120.0;
|
||||
y 210.0;
|
||||
};
|
||||
}
|
||||
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
diagram sa_view "SA engineering - preserve" id "637b9a3e-bd74-534c-9be5-4470a408729f" {
|
||||
description "Selection changes placement geometry only.";
|
||||
layer Sa;
|
||||
}
|
||||
|
||||
node sa_node id "5f1b1c24-0583-5923-a69c-d0ea99ccb18c" {
|
||||
diagram @sa_view;
|
||||
element @sa_function;
|
||||
position {
|
||||
x 100.0;
|
||||
y 110.0;
|
||||
};
|
||||
}
|
||||
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"start": "2026-09-23T09:07:10.546708+00:00",
|
||||
"command": [
|
||||
"C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\examples\\multiselect_native.exe",
|
||||
"check",
|
||||
"unchanged",
|
||||
"C:\\ar-multiselect-9e8r6t\\.local\\multiselect-native\\fixture-2\\baseline.json",
|
||||
"C:\\ar-multiselect-9e8r6t\\.local\\multiselect-native\\run-2\\snapshots\\baseline\\source\\project.arcadia"
|
||||
],
|
||||
"buildReceipt": {
|
||||
"bytes": 3131,
|
||||
"sha256": "fbecac3b0f7913db5e2ad19043c7df167a92287833ddf3ea4f24766e86ae523b"
|
||||
},
|
||||
"binary": {
|
||||
"bytes": 22162432,
|
||||
"sha256": "4de47a2b62192be6a1394e654f368be19e36fb966d60d6c833a5d51d18247bb1"
|
||||
},
|
||||
"helper": {
|
||||
"bytes": 2494,
|
||||
"sha256": "1dbfff41f07bd750d4e550660d91ae9790d2442fbfe685dc88b7f63a47f5acb8"
|
||||
},
|
||||
"source": {
|
||||
"oa/activities/activity_activity_a.arcadia": {
|
||||
"bytes": 474,
|
||||
"sha256": "9bf7c5637d87c97213cf869118e942e8a29a67b429fa58e4de42235bba0980ee"
|
||||
},
|
||||
"oa/activities/activity_activity_b.arcadia": {
|
||||
"bytes": 286,
|
||||
"sha256": "152007669b7c570674aff99615a48a6f557be50f88bbbb3a5edc20b423c79a1f"
|
||||
},
|
||||
"oa/activities/activity_activity_c.arcadia": {
|
||||
"bytes": 153,
|
||||
"sha256": "d726b6b4e1200edd9ef752bb767e38d3d69dcac8eb6135d5f83c93832ff849b1"
|
||||
},
|
||||
"project.arcadia": {
|
||||
"bytes": 597,
|
||||
"sha256": "e7bd67c5785a78766298255ed3d53ff6d65dcd24bde1570ac8a0ff45cc9dc385"
|
||||
},
|
||||
"sa/functions/function_sa_function.arcadia": {
|
||||
"bytes": 162,
|
||||
"sha256": "1c724a9204784435e729badd4ad164b1a6b891840bb1ddc354ac1e175e3d712c"
|
||||
},
|
||||
"traceability.arcadia": {
|
||||
"bytes": 522,
|
||||
"sha256": "26b0b10715ced7aefcde941e6d1c1a6973f174997eae989e32a25c695dfb7cd1"
|
||||
},
|
||||
"views/diagram_oa_other.arcadia": {
|
||||
"bytes": 315,
|
||||
"sha256": "06ceef9cfdbb3ef229a22db6c48d8bb808388e027c37294648555ecbfb6bfab9"
|
||||
},
|
||||
"views/diagram_oa_view.arcadia": {
|
||||
"bytes": 1896,
|
||||
"sha256": "4e7d47d6a03153070687136c89faf01fc6df45ae89e2695beeab2fe4872dbc88"
|
||||
},
|
||||
"views/diagram_sa_view.arcadia": {
|
||||
"bytes": 331,
|
||||
"sha256": "c75b0fe14496631c9b017a32e277d1e5ad5b25e00e06b804fc54d950d7fb57f6"
|
||||
}
|
||||
},
|
||||
"baseline": {
|
||||
"bytes": 8944,
|
||||
"sha256": "d1278e58cd31e1d61093292eddc1d7b2cc9c8cdc7fe5dd3801de8411af8db89d"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
# Native multiselect evidence package
|
||||
|
||||
This package preserves native run-1 and run-2, both closed with exit 0, empty app
|
||||
stdout/stderr and all three GUI/checker/fixture unchanged flags true. Each run
|
||||
contains nine positive complete-Project checker snapshots, nine source files per
|
||||
snapshot and three exact source-history equality groups. Only
|
||||
`views/diagram_oa_view.arcadia` differs from baseline in its move and hidden
|
||||
states. These artifacts do not establish native acceptance on their own; consult
|
||||
the separate [saved-view report](../../../multiselect-saved-view-20260923.md) for
|
||||
action observations, usability findings and the implemented subset of issue 29.
|
||||
|
||||
| Run | Source commit | Captures | Layout scope |
|
||||
| --- | --- | ---: | --- |
|
||||
| run-1 | `12a7352ddef5f69a144ead8219a50916233d63a9` | 27 | Before selection/status toolbar correction |
|
||||
| run-2 | `f6a6010baed9e6164a1fcc7ecd58256a562827d7` | 24 | Corrected toolbar layout build |
|
||||
|
||||
Run-1 GUI/checker build receipts refer to the 729-file input map
|
||||
`55a5f354bb5049a8c77ba9bafa833438ebf754649b0cf5d175d0c8dcfcedd7bb`;
|
||||
run-2 refers to the distinct 729-file map
|
||||
`99dc46645236e674244e7c4867595a6000d3d85b54bcc9ab2b0c601923ecf66c`.
|
||||
The [source identity evidence](../source/README.md) ties those maps to the commits
|
||||
above. The GUI binary pins differ; the recorded checker binary pins match.
|
||||
Run-1 is retained as evidence of the earlier layout, not as verification of the
|
||||
corrected build.
|
||||
|
||||
The first run-2 baseline checker attempt failed before the successful retry after
|
||||
the toolchain environment was initialized. Its unchanged raw snapshot and retry
|
||||
comparison are preserved separately in the [environment incident](../incidents/README.md).
|
||||
The successful retry is the baseline snapshot included in this native package;
|
||||
the failed invocation is not silently reclassified or presented as a GUI failure.
|
||||
|
||||
These Python 3.10+ standard-library tools never operate the GUI or rerun the model
|
||||
checker. From the checkout root, validate both raw runs without writing a package:
|
||||
|
||||
```text
|
||||
python -B harness/experiments/multiselect-20260923/native/prepare.py --root . --runs run-1 run-2 --check-only
|
||||
```
|
||||
|
||||
Omit `--check-only` to package; optionally choose `--output DIRECTORY`. The default
|
||||
is this native directory. Existing package outputs are not overwritten. A missing
|
||||
or unfinished run, changed recorded inputs, missing/extra fixture or snapshot
|
||||
files, unmatched helper bytes, or inconsistent receipts fails preparation.
|
||||
|
||||
Verify a completed package independently of the original machine:
|
||||
|
||||
```text
|
||||
python -B PATH_TO_PACKAGE/verify.py
|
||||
```
|
||||
|
||||
Each run includes all original capture JSON/images, process/start/finish records,
|
||||
generation and app streams, all nine source snapshots and checker streams/receipts,
|
||||
and the history-bytes record. Its fixture includes baseline JSON, the manifest,
|
||||
and canonical source files. Exact helpers are found by their recorded hashes among
|
||||
the native directory's `.py`/`.js` files, so original and suffixed run-2 helpers can
|
||||
coexist. Helpers are archived as data and never executed by these tools.
|
||||
|
||||
Referenced GUI/checker build results, exact gate helpers and input maps are
|
||||
included. The checker example is read from its pinned historical input ZIP, not
|
||||
from current source.
|
||||
Full build logs and large source ZIPs belong in the sibling `checks` package;
|
||||
verify that package separately. This package validates build receipt, input map,
|
||||
example-source and executable-pin relationships without duplicating those ZIPs.
|
||||
It does not contain or execute the GUI/checker binaries.
|
||||
|
||||
The verifier checks every archived byte pin and exact safe membership, clean
|
||||
recorded exit and unchanged flags, process and capture lifetimes, each capture's
|
||||
normalized executable path against the launched GUI, one consistent positive
|
||||
window ID per run, fixture/source inventories, nine positive complete-Project
|
||||
checker reports, baseline/binary/helper
|
||||
relationships, and the actual archived bytes in three history equality groups.
|
||||
It verifies that only `views/diagram_oa_view.arcadia` differs from baseline in the
|
||||
move and hidden source states, and that baseline source bytes match the fixture.
|
||||
Window identity checks join recorded metadata; the receipts do not independently
|
||||
map that window handle to the recorded PID. Source models are not recompiled by
|
||||
this offline Python verifier. Screenshot dimensions are recorded metadata, not a
|
||||
minimum-window usability evaluation.
|
||||
|
||||
`manifest.json` maps each run's original historical paths to archived run,
|
||||
fixture, helper, build and checker-source members and pins their bytes.
|
||||
`files.json` pins the packager, verifier, README, manifest and archive. Raw bytes
|
||||
are preserved; ZIP ordering and timestamps are fixed. Historic absolute paths are
|
||||
labels and are not accessed by offline verification.
|
||||
|
||||
GUI actions remain separately documented parent observations. Screenshots do
|
||||
not prove which inputs occurred, and the checker infers a bounded common delta
|
||||
from saved geometry; it cannot validate the exact pointer destination. Source
|
||||
history byte equality supports the stated saved-state relationships but does not
|
||||
independently authenticate button presses, focus behavior or Undo stack structure.
|
||||
Run-1 evidence does not establish the corrected toolbar's usability or any later
|
||||
binary's behavior. Consult the parent report for the evaluated version, observed
|
||||
limitations, run differences and the remaining full-issue acceptance work.
|
||||
Binary file not shown.
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"prepare.py": {
|
||||
"bytes": 7237,
|
||||
"sha256": "d9d49d3082e485e670b43010822ebd3ab7a79762b36a247dcd3bdfc6b5d7f41d"
|
||||
},
|
||||
"verify.py": {
|
||||
"bytes": 14703,
|
||||
"sha256": "7ef5f28b6c9a43bf0361eec2d8be33a1e9f284d0ffc21b50fd09a2d082642ce3"
|
||||
},
|
||||
"README.md": {
|
||||
"bytes": 5418,
|
||||
"sha256": "8d6fa25d8bd97c654e78b89194bd499637427ce027157d568215e13421b22c03"
|
||||
},
|
||||
"manifest.json": {
|
||||
"bytes": 72794,
|
||||
"sha256": "de7642ad43aa881f7d984629db52ea41b62d0ebacc09260edd80a5c1fee7dd7b"
|
||||
},
|
||||
"evidence.zip": {
|
||||
"bytes": 4399281,
|
||||
"sha256": "7dc3e0571179e0f67fefe3c73462308b79e4c7f7a09bbd805e0acf0e4faeb1cf"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,129 @@
|
||||
"""Package explicit completed native runs; helpers are pinned data, never executed."""
|
||||
from pathlib import Path
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import zipfile
|
||||
from verify import pin, parsed, need, norm, relative, safe, verify_data, verify_package
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def collect(root, runs):
|
||||
native = root / ".local/multiselect-native"
|
||||
candidates = [path for path in native.iterdir() if path.is_file() and path.suffix in (".py", ".js")]
|
||||
candidates.extend(sorted((root / ".local/group-movement-gates").glob("*.py")))
|
||||
helpers = [(path.suffix, path.read_bytes()) for path in candidates]
|
||||
payload, specs = {}, {}
|
||||
def add(name, data):
|
||||
need(safe(name) and (name not in payload or payload[name] == data), "member collision: " + name)
|
||||
payload[name] = data
|
||||
def tree(folder, prefix):
|
||||
need(folder.is_dir(), "missing directory: " + str(folder))
|
||||
for path in sorted(folder.rglob("*")):
|
||||
need(not path.is_symlink(), "symlink evidence input")
|
||||
if path.is_file():
|
||||
add(prefix + path.relative_to(folder).as_posix(), path.read_bytes())
|
||||
for run in runs:
|
||||
folder = native / run
|
||||
start = parsed((folder / "started.json").read_bytes())
|
||||
end = parsed((folder / "finished.json").read_bytes())
|
||||
need(end["exitCode"] == 0 and all(end[k] is True for k in
|
||||
("guiUnchanged", "checkerUnchanged", "fixtureUnchanged")), "unfinished/changed native run: " + run)
|
||||
generation = parsed((folder / "generation.json").read_bytes())
|
||||
history = parsed((folder / "history-bytes.json").read_bytes())
|
||||
tree(folder, "runs/" + run + "/raw/")
|
||||
fixture_relative = relative(generation["command"][2], start["cwd"])
|
||||
need(fixture_relative.startswith(".local/multiselect-native/"), "fixture outside native root")
|
||||
tree(root / fixture_relative, "runs/" + run + "/fixture/")
|
||||
spec = {"originalRun": norm(start["cwd"]) + "/.local/multiselect-native/" + run,
|
||||
"originalFixture": norm(generation["command"][2]), "helpers": {}, "builds": {}}
|
||||
for key, expected in [("helper", start["helper"]), ("capture", start["capture"]),
|
||||
("snapshotHelper", start["snapshotHelper"]), ("history", history["helper"])]:
|
||||
matches = [(suffix, data) for suffix, data in helpers if pin(data) == expected]
|
||||
need(bool(matches), "exact historical helper unavailable: " + key)
|
||||
suffix, data = matches[0]
|
||||
member = "helpers/" + expected["sha256"] + suffix
|
||||
add(member, data)
|
||||
spec["helpers"][key] = member
|
||||
for kind in ("gui", "checker"):
|
||||
recorded = start[kind]
|
||||
rel = relative(recorded["receipt"], start["cwd"])
|
||||
need(rel.startswith(".local/group-movement-gates/") and rel.endswith("/result.json"), "unexpected build receipt location")
|
||||
receipt = root / rel
|
||||
data = receipt.read_bytes()
|
||||
need(pin(data) == recorded["receiptPin"], "historical build receipt changed")
|
||||
prefix = "builds/" + pin(data)["sha256"] + "/"
|
||||
add(prefix + "result.json", data)
|
||||
inputs_data = (receipt.parent / "inputs.json").read_bytes()
|
||||
need(pin(inputs_data) == recorded["inputs"], "historical build input map changed")
|
||||
add(prefix + "inputs.json", inputs_data)
|
||||
spec["builds"][kind] = {"receipt": prefix + "result.json", "inputs": prefix + "inputs.json", "originalRelative": rel}
|
||||
build_helper = parsed(data)["helper"]
|
||||
matching_helpers = [(suffix, value) for suffix, value in helpers if pin(value) == build_helper]
|
||||
need(bool(matching_helpers), "exact historical build helper unavailable")
|
||||
suffix, helper_data = matching_helpers[0]
|
||||
helper_member = "helpers/" + build_helper["sha256"] + suffix
|
||||
add(helper_member, helper_data)
|
||||
spec["builds"][kind]["helper"] = helper_member
|
||||
if kind == "checker":
|
||||
# Read only the pinned historical example, not today's checkout.
|
||||
archive_path = receipt.parent / "inputs.zip"
|
||||
need(pin(archive_path.read_bytes()) == parsed(data)["archive"], "checker input archive pin")
|
||||
source_path = "crates/oa-core/examples/multiselect_native.rs"
|
||||
with zipfile.ZipFile(archive_path) as archive:
|
||||
source = archive.read(source_path)
|
||||
need(pin(source) == parsed(inputs_data)[source_path], "historical checker source pin")
|
||||
member = "checker-source/" + pin(source)["sha256"] + ".rs"
|
||||
add(member, source)
|
||||
spec["checkerSource"] = {"inputPath": source_path, "member": member}
|
||||
specs[run] = spec
|
||||
manifest = {"schema": 1, "kind": "multiselect-native", "runs": specs,
|
||||
"members": {name: pin(data) for name, data in sorted(payload.items())}}
|
||||
summary = verify_data(manifest, payload.__getitem__)
|
||||
return payload, manifest, summary
|
||||
|
||||
|
||||
def write_json(path, value):
|
||||
with path.open("xb") as stream:
|
||||
stream.write((json.dumps(value, indent=2) + "\n").encode())
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--root", type=Path, default=HERE.parents[3])
|
||||
parser.add_argument("--runs", nargs="+", required=True)
|
||||
parser.add_argument("--output", type=Path, default=HERE)
|
||||
parser.add_argument("--check-only", action="store_true")
|
||||
args = parser.parse_args()
|
||||
need(len(args.runs) == len(set(args.runs)) and all(re.fullmatch(r"run-[0-9]+", name) for name in args.runs), "explicit unique run-N names required")
|
||||
payload, manifest, summary = collect(args.root.resolve(), args.runs)
|
||||
if args.check_only:
|
||||
print("PASS raw consistency; no archive written:", summary)
|
||||
return
|
||||
out = args.output.resolve()
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
for name in ("evidence.zip", "manifest.json", "files.json"):
|
||||
need(not (out / name).exists(), "existing output refused: " + name)
|
||||
for name in ("prepare.py", "verify.py", "README.md"):
|
||||
data = (HERE / name).read_bytes()
|
||||
if (out / name).exists():
|
||||
need((out / name).read_bytes() == data, "different existing package tool")
|
||||
else:
|
||||
with (out / name).open("xb") as stream:
|
||||
stream.write(data)
|
||||
with zipfile.ZipFile(out / "evidence.zip", "x", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as archive:
|
||||
for name, data in sorted(payload.items()):
|
||||
info = zipfile.ZipInfo(name, date_time=(2026, 9, 23, 0, 0, 0))
|
||||
info.compress_type = zipfile.ZIP_DEFLATED
|
||||
info.external_attr = 0o100644 << 16
|
||||
archive.writestr(info, data)
|
||||
write_json(out / "manifest.json", manifest)
|
||||
names = ("prepare.py", "verify.py", "README.md", "manifest.json", "evidence.zip")
|
||||
write_json(out / "files.json", {name: pin((out / name).read_bytes()) for name in names})
|
||||
need(verify_package(out) == summary, "written package differs from validated evidence")
|
||||
print("PASS package readback:", out, summary)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,235 @@
|
||||
"""Verify archived native receipts and bytes; do not rerun the GUI or checker."""
|
||||
from pathlib import Path, PurePosixPath
|
||||
from datetime import datetime
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import stat
|
||||
import zipfile
|
||||
|
||||
BASE = {"started.json", "finished.json", "generation.json", "generation.stdout.log",
|
||||
"generation.stderr.log", "app.stdout.log", "app.stderr.log", "processes.json", "history-bytes.json"}
|
||||
GROUPS = [["baseline", "move-undo", "before-hide", "hide-undo"], ["move", "move-redo"],
|
||||
["hidden", "hide-redo", "reopened-hidden"]]
|
||||
MODES = {name: mode for group, mode in zip(GROUPS, ["unchanged", "moved", "hide"]) for name in group}
|
||||
OUTER = {"prepare.py", "verify.py", "README.md", "manifest.json", "evidence.zip"}
|
||||
|
||||
|
||||
def need(ok, message):
|
||||
if not ok:
|
||||
raise ValueError(message)
|
||||
|
||||
|
||||
def pin(data):
|
||||
return {"bytes": len(data), "sha256": hashlib.sha256(data).hexdigest()}
|
||||
|
||||
|
||||
def safe(name):
|
||||
return (isinstance(name, str) and bool(name) and "\\" not in name and ":" not in name
|
||||
and not PurePosixPath(name).is_absolute() and all(ord(c) >= 32 for c in name)
|
||||
and all(part not in ("", ".", "..") for part in name.split("/")))
|
||||
|
||||
|
||||
def parsed(data):
|
||||
def unique(items):
|
||||
result = {}
|
||||
for key, value in items:
|
||||
need(key not in result, "duplicate JSON key: " + key)
|
||||
result[key] = value
|
||||
return result
|
||||
return json.loads(data, object_pairs_hook=unique)
|
||||
|
||||
|
||||
def norm(path):
|
||||
return path.replace("\\", "/").rstrip("/")
|
||||
|
||||
|
||||
def relative(path, root):
|
||||
prefix = norm(root) + "/"
|
||||
need(norm(path).startswith(prefix), "recorded path outside owner: " + path)
|
||||
value = norm(path)[len(prefix):]
|
||||
need(safe(value), "unsafe recorded relative path")
|
||||
return value
|
||||
|
||||
|
||||
def ordered(*values):
|
||||
times = [datetime.fromisoformat(value) for value in values]
|
||||
return all(t.utcoffset() is not None for t in times) and times == sorted(times)
|
||||
|
||||
|
||||
def zip_inventory(archive, members):
|
||||
names = archive.namelist()
|
||||
need(len(names) == len(set(names)) and set(names) == set(members), "exact archive members")
|
||||
for info in archive.infolist():
|
||||
need(safe(info.filename) and not info.is_dir() and not stat.S_ISLNK(info.external_attr >> 16), "unsafe archive member")
|
||||
need(info.file_size == members[info.filename]["bytes"], "archive member size")
|
||||
|
||||
|
||||
def verify_data(manifest, read):
|
||||
need(manifest["schema"] == 1 and manifest["kind"] == "multiselect-native", "manifest kind")
|
||||
members, runs = manifest["members"], manifest["runs"]
|
||||
need(bool(runs) and all(safe(run) and "/" not in run for run in runs), "run names")
|
||||
need(all(safe(name) for name in members), "safe member names")
|
||||
for name, expected in members.items():
|
||||
need(pin(read(name)) == expected, "member pin: " + name)
|
||||
used = set()
|
||||
def raw(name):
|
||||
need(name in members, "missing member: " + name)
|
||||
used.add(name)
|
||||
return read(name)
|
||||
def obj(name):
|
||||
return parsed(raw(name))
|
||||
def tree(prefix):
|
||||
return {name[len(prefix):]: raw(name) for name in members if name.startswith(prefix)}
|
||||
def pinned_tree(prefix, expected):
|
||||
actual = tree(prefix)
|
||||
need({name: pin(data) for name, data in actual.items()} == expected, "exact pinned tree: " + prefix)
|
||||
return actual
|
||||
for run, spec in runs.items():
|
||||
prefix = "runs/" + run + "/raw/"
|
||||
start, end = obj(prefix + "started.json"), obj(prefix + "finished.json")
|
||||
generation, process = obj(prefix + "generation.json"), obj(prefix + "processes.json")
|
||||
history = obj(prefix + "history-bytes.json")
|
||||
need(end["exitCode"] == 0 and all(end[key] is True for key in
|
||||
("guiUnchanged", "checkerUnchanged", "fixtureUnchanged")), run + ": clean unchanged closure")
|
||||
need(ordered(generation["start"], generation["finish"], process["CreationDate"], start["start"], end["finish"], history["verified"]), run + ": process/generation times")
|
||||
need(process["ProcessId"] == start["pid"] and type(start["pid"]) is int and start["pid"] > 0, run + ": process identity")
|
||||
need(norm(process["ExecutablePath"]) == norm(start["gui"]["binary"])
|
||||
and start["command"] == [start["gui"]["binary"]], run + ": GUI command")
|
||||
need(spec["originalRun"] == norm(start["cwd"]) + "/.local/multiselect-native/" + run, run + ": original run mapping")
|
||||
need(generation["exitCode"] == 0 and generation["checker"] == start["checker"], run + ": generation checker")
|
||||
need(generation["command"][:2] == [start["checker"]["binary"], "generate"]
|
||||
and len(generation["command"]) == 3 and norm(generation["command"][2]) == spec["originalFixture"], run + ": generation command")
|
||||
for stream in ("stdout", "stderr"):
|
||||
need(end[stream] == pin(raw(prefix + "app." + stream + ".log")), run + ": app stream")
|
||||
need(generation[stream] == pin(raw(prefix + "generation." + stream + ".log")), run + ": generation stream")
|
||||
fixture = pinned_tree("runs/" + run + "/fixture/", start["fixture"])
|
||||
need(set(fixture) == {"baseline.json", "manifest.json"} | {"source/" + name for name in
|
||||
obj(prefix + "snapshots/baseline/result.json")["source"]}, run + ": fixture membership")
|
||||
fixture_manifest = parsed(fixture["manifest.json"])
|
||||
need(fixture_manifest["fixture"] == "multiselect-native-20260923"
|
||||
and fixture_manifest["source_entry"] == "project.arcadia", run + ": fixture identity")
|
||||
need(parsed(fixture["baseline.json"])["id"] == fixture_manifest["ids"]["project"], run + ": baseline identity")
|
||||
for key, expected in [("helper", start["helper"]), ("capture", start["capture"]),
|
||||
("snapshotHelper", start["snapshotHelper"]), ("history", history["helper"])]:
|
||||
need(pin(raw(spec["helpers"][key])) == expected, run + ": helper " + key)
|
||||
builds = {}
|
||||
for kind, target in [("gui", "arcadia-studio"), ("checker", "multiselect_native")]:
|
||||
recorded = start[kind]
|
||||
build = obj(spec["builds"][kind]["receipt"])
|
||||
need(pin(raw(spec["builds"][kind]["receipt"])) == recorded["receiptPin"], run + ": build receipt pin")
|
||||
need(relative(recorded["receipt"], start["cwd"]) == spec["builds"][kind]["originalRelative"], run + ": build path mapping")
|
||||
need(build["exitCode"] == 0 and build["inputsUnchanged"] is True
|
||||
and ordered(build["start"], build["finish"], generation["start"]), run + ": accepted preceding build")
|
||||
need(build["inputs"] == recorded["inputs"] == pin(raw(spec["builds"][kind]["inputs"])), run + ": build input map")
|
||||
need(pin(raw(spec["builds"][kind]["helper"])) == build["helper"], run + ": build helper pin")
|
||||
inputs = obj(spec["builds"][kind]["inputs"])
|
||||
need(len(inputs) == build["inputCount"] and sum(name.startswith("crates/vendor/iced_widget/") for name in inputs) == 71, run + ": build input count")
|
||||
artifacts = [item for item in build["artifacts"] if item["cargo"]["target"]["name"] == target]
|
||||
need(len(artifacts) == 1 and artifacts[0]["readback"] == recorded["pin"]
|
||||
and artifacts[0]["cargo"]["executable"] == recorded["binary"], run + ": executable relationship")
|
||||
builds[kind] = inputs
|
||||
need(start["gui"]["inputs"] == start["checker"]["inputs"], run + ": matching GUI/checker inputs")
|
||||
source = spec["checkerSource"]
|
||||
need(source["inputPath"] == "crates/oa-core/examples/multiselect_native.rs"
|
||||
and pin(raw(source["member"])) == builds["checker"][source["inputPath"]], run + ": archived exact checker source")
|
||||
|
||||
top = {name[len(prefix):] for name in members if name.startswith(prefix) and "/" not in name[len(prefix):]}
|
||||
captures = sorted(name for name in top - BASE if name.endswith(".json"))
|
||||
need(bool(captures), run + ": missing captures")
|
||||
expected_top = set(BASE) | set(captures)
|
||||
captured_window = None
|
||||
for name in captures:
|
||||
capture = obj(prefix + name)
|
||||
need(ordered(start["start"], capture["time"], end["finish"]), run + ": capture lifetime")
|
||||
window = capture["window"]
|
||||
app = window["app"]
|
||||
need(isinstance(app, str) and app.startswith("process:")
|
||||
and norm(app[len("process:"):]) == norm(start["gui"]["binary"]), run + ": captured executable relationship")
|
||||
need(type(window["id"]) is int and window["id"] > 0, run + ": captured window identity")
|
||||
if captured_window is None:
|
||||
captured_window = window["id"]
|
||||
need(window["id"] == captured_window, run + ": inconsistent captured window identity")
|
||||
need(isinstance(capture["images"], list), run + ": capture images")
|
||||
for image in capture["images"]:
|
||||
filename = image["file"]
|
||||
need(safe(filename) and "/" not in filename and filename.startswith(name[:-5] + "-")
|
||||
and filename.endswith((".jpg", ".png")), run + ": capture image mapping")
|
||||
need(type(image["width"]) is int and image["width"] > 0
|
||||
and type(image["height"]) is int and image["height"] > 0, run + ": image dimensions")
|
||||
need(filename not in expected_top, run + ": duplicate image reference")
|
||||
expected_top.add(filename)
|
||||
raw(prefix + filename)
|
||||
need(top == expected_top, run + ": extra/missing top-level run files")
|
||||
|
||||
snapshots = {name[len(prefix + "snapshots/"):].split("/")[0] for name in members if name.startswith(prefix + "snapshots/")}
|
||||
need(snapshots == set(MODES), run + ": exact nine snapshot labels")
|
||||
states = {}
|
||||
for label, mode in MODES.items():
|
||||
folder = prefix + "snapshots/" + label + "/"
|
||||
began, result = obj(folder + "started.json"), obj(folder + "result.json")
|
||||
need(all(result.get(key) == value for key, value in began.items()), label + ": start/result")
|
||||
need(result["exitCode"] == 0 and all(result[key] is True for key in
|
||||
("binaryUnchanged", "workingSourceUnchanged", "baselineUnchanged")), label + ": unchanged successful checker")
|
||||
need(ordered(start["start"], result["start"], result["finish"], end["finish"]), label + ": checker lifetime")
|
||||
need(result["buildReceipt"] == start["checker"]["receiptPin"] and result["binary"] == start["checker"]["pin"]
|
||||
and result["helper"] == start["snapshotHelper"], label + ": checker provenance")
|
||||
need(result["baseline"] == pin(fixture["baseline.json"]), label + ": baseline pin")
|
||||
command = result["command"]
|
||||
need(len(command) == 5 and command[:3] == [start["checker"]["binary"], "check", mode]
|
||||
and norm(command[3]) == spec["originalFixture"] + "/baseline.json"
|
||||
and norm(command[4]) == spec["originalRun"] + "/snapshots/" + label + "/source/project.arcadia", label + ": exact checker command")
|
||||
for stream in ("stdout", "stderr"):
|
||||
need(result[stream] == pin(raw(folder + stream + ".log")), label + ": stream pin")
|
||||
need(not raw(folder + "stderr.log"), label + ": checker stderr")
|
||||
report = obj(folder + "stdout.log")
|
||||
need(report["result"] == "PASS" and report["comparison"] == "complete Project equality"
|
||||
and report["mode"] == mode and report["baseline"] == command[3] and report["saved"] == command[4], label + ": positive checker report")
|
||||
delta = report["inferred_common_delta"]
|
||||
if mode == "moved":
|
||||
need(isinstance(delta, dict) and set(delta) == {"x", "y"}
|
||||
and all(type(v) in (int, float) and math.isfinite(v) and abs(v) <= 500 for v in delta.values())
|
||||
and math.hypot(delta["x"], delta["y"]) >= 10, label + ": inferred movement bounds")
|
||||
else:
|
||||
need(delta is None, label + ": unexpected inferred delta")
|
||||
states[label] = pinned_tree(folder + "source/", result["source"])
|
||||
need(len(states[label]) == history["sourceFilesPerSnapshot"] == 9, label + ": source count")
|
||||
expected = {folder + name for name in ("started.json", "result.json", "stdout.log", "stderr.log")}
|
||||
expected.update(folder + "source/" + name for name in states[label])
|
||||
need({name for name in members if name.startswith(folder)} == expected, label + ": extra snapshot files")
|
||||
need(history["status"] == "PASS" and history["equalByteGroups"] == GROUPS, run + ": history groups")
|
||||
for group in GROUPS:
|
||||
need(all(states[label] == states[group[0]] for label in group), run + ": actual history bytes " + str(group))
|
||||
need(all(states[a[0]] != states[b[0]] for i, a in enumerate(GROUPS) for b in GROUPS[i + 1:]), run + ": distinct history states")
|
||||
need(states["baseline"] == {name[7:]: data for name, data in fixture.items() if name.startswith("source/")}, run + ": baseline source exact")
|
||||
changed = {label: sorted(name for name in set(states["baseline"]) | set(states[label])
|
||||
if states["baseline"].get(name) != states[label].get(name)) for label in ("move", "hidden")}
|
||||
need(changed == history["changedFilesFromBaseline"]
|
||||
== {label: ["views/diagram_oa_view.arcadia"] for label in ("move", "hidden")}, run + ": changed source files")
|
||||
print(run, "closed exit 0;", len(captures), "captures; nine positive snapshots; exact three-group source history")
|
||||
need(used == set(members), "unreferenced/extra package members")
|
||||
return {"runs": len(runs), "members": len(members)}
|
||||
|
||||
|
||||
def verify_package(root):
|
||||
files = parsed((root / "files.json").read_bytes())
|
||||
need(set(files) == OUTER, "outer files")
|
||||
for name, expected in files.items():
|
||||
need(pin((root / name).read_bytes()) == expected, "outer pin: " + name)
|
||||
manifest = parsed((root / "manifest.json").read_bytes())
|
||||
with zipfile.ZipFile(root / "evidence.zip") as archive:
|
||||
zip_inventory(archive, manifest["members"])
|
||||
return verify_data(manifest, archive.read)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("package", nargs="?", type=Path, default=Path(__file__).resolve().parent)
|
||||
args = parser.parse_args()
|
||||
print("PASS native evidence byte/receipt consistency:", verify_package(args.package))
|
||||
print("No GUI/checker execution; actions and native acceptance require the separate parent review.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,2 @@
|
||||
# Preserve pinned historical receipts and helper bytes through Git round trips.
|
||||
* -text whitespace=cr-at-eol
|
||||
@@ -0,0 +1,52 @@
|
||||
# Captured inputs and committed source identity
|
||||
|
||||
This directory preserves exact historical `committed-desktop-4.json` and
|
||||
`committed-desktop-5.json` receipts plus `verify-committed-inputs.py` as data.
|
||||
The original helper contains its historical absolute checkout path; do not run
|
||||
it for portable verification. The receipts do not themselves pin that helper;
|
||||
`files.json` pins the copies distributed here and the new verifier/README.
|
||||
The local `.gitattributes` preserves those pinned bytes through Git round trips;
|
||||
retain it with this directory rather than normalizing the historical text.
|
||||
|
||||
With Python 3.10+, Git, a repository containing both commits, and the completed
|
||||
sibling checks package, run:
|
||||
|
||||
```text
|
||||
python -B SOURCE_DIRECTORY/verify.py --repo REPOSITORY --checks CHECKS_PACKAGE_DIRECTORY
|
||||
```
|
||||
|
||||
Before that package exists, an explicitly optional raw-input mode is available:
|
||||
|
||||
```text
|
||||
python -B harness/experiments/multiselect-20260923/source/verify.py --repo . --raw-gates .local/group-movement-gates
|
||||
```
|
||||
|
||||
The verifier checks source-evidence pins, captured map byte hashes, recorded gate
|
||||
success/unchanged flags, counts and 71 vendored iced_widget inputs. It compares the
|
||||
complete relevant Git tree inventory and every blob's size/SHA-256 at these
|
||||
commits with their corresponding captured input maps:
|
||||
|
||||
- `desktop-4`: `12a7352ddef5f69a144ead8219a50916233d63a9`
|
||||
- `desktop-5`: `f6a6010baed9e6164a1fcc7ecd58256a562827d7`
|
||||
|
||||
The scope matches the historical helper: Cargo manifests/lock/toolchain file,
|
||||
`crates/`, `build/`, `.cargo/`, `scripts/`, `docs/`, `examples/`,
|
||||
`harness/fixtures/`, and `harness/prototypes/`. This is the complete inventory
|
||||
within that scope, not a whole-repository identity claim. Worktree contents,
|
||||
current HEAD, line-ending checkout conversion and original machine paths do not
|
||||
participate. Git objects are read by object ID; no checkout, build or archived
|
||||
helper runs. Both commits must already be available; lazy object fetching is
|
||||
disabled for these reads.
|
||||
|
||||
In package mode, outer checks-package pins and selected member/run relationships
|
||||
are also checked. Run the checks package's own verifier for its full source ZIP,
|
||||
logs, helpers and all other gate relationships. Raw mode reads the named local
|
||||
maps/receipts directly and is not a substitute for packaged-evidence verification.
|
||||
No source maps or ZIPs are duplicated in this directory.
|
||||
|
||||
Historical receipts report an earlier comparison. A successful run of this new
|
||||
verifier performs a fresh Git-object comparison against the pinned maps. A gate's
|
||||
recorded HEAD can predate its later source-identical commit; that difference is
|
||||
why blob comparison is necessary. This evidence identifies tested source bytes;
|
||||
it does not rerun tests or establish native usability, release integration or
|
||||
full #29 acceptance.
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"checked": "2026-09-23T08:11:54.115058+00:00",
|
||||
"commit": "12a7352ddef5f69a144ead8219a50916233d63a9",
|
||||
"gate": "desktop-4",
|
||||
"inputCount": 729,
|
||||
"inputMap": {
|
||||
"bytes": 121985,
|
||||
"sha256": "55a5f354bb5049a8c77ba9bafa833438ebf754649b0cf5d175d0c8dcfcedd7bb"
|
||||
},
|
||||
"comparison": "Exact committed Git blobs equal every captured input; same complete filtered inventory"
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"checked": "2026-09-23T08:53:15.452246+00:00",
|
||||
"commit": "f6a6010baed9e6164a1fcc7ecd58256a562827d7",
|
||||
"gate": "desktop-5",
|
||||
"inputCount": 729,
|
||||
"inputMap": {
|
||||
"bytes": 121985,
|
||||
"sha256": "99dc46645236e674244e7c4867595a6000d3d85b54bcc9ab2b0c601923ecf66c"
|
||||
},
|
||||
"comparison": "Exact committed Git blobs equal every captured input; same complete filtered inventory"
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
".gitattributes": {
|
||||
"bytes": 109,
|
||||
"sha256": "aca8eef17dbab8cfedcbe18e1175cfee115802376fac4ec69e8c3320a6399d6c"
|
||||
},
|
||||
"README.md": {
|
||||
"bytes": 2792,
|
||||
"sha256": "b8db0c2c71e491b6179b3e57d27e5dfb23c9283894219feea328bc632603f2aa"
|
||||
},
|
||||
"verify.py": {
|
||||
"bytes": 7255,
|
||||
"sha256": "2ed667c127470efb669fb43d5dcdb288f891e42e10e67ef98ca4cd1125df09d1"
|
||||
},
|
||||
"verify-committed-inputs.py": {
|
||||
"bytes": 2138,
|
||||
"sha256": "ac2b50a73f678456b9628684347484c013f5f9c78f054de72b74f56a34c990d5"
|
||||
},
|
||||
"committed-desktop-4.json": {
|
||||
"bytes": 390,
|
||||
"sha256": "47c6774bcb7d0c37c50e0ed50f8fb58ee9b55c6fcee30af488481c314a4e66e8"
|
||||
},
|
||||
"committed-desktop-5.json": {
|
||||
"bytes": 390,
|
||||
"sha256": "da1f08e06bd0af6512da4196618b71d0c4ecc090012f061bb82b6a36f5b7686c"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Check the tested source inventory against exact Git blobs, not HEAD alone."""
|
||||
from pathlib import Path
|
||||
import datetime, hashlib, json, subprocess, sys
|
||||
root=Path('C:/ar-multiselect-9e8r6t')
|
||||
run,=sys.argv[1:]
|
||||
gate=root/'.local/group-movement-gates'/run
|
||||
inputs=json.loads((gate/'inputs.json').read_bytes())
|
||||
result=json.loads((gate/'result.json').read_bytes())
|
||||
assert result['exitCode']==0 and result['inputsUnchanged']
|
||||
head=subprocess.check_output(['git','rev-parse','HEAD'],cwd=root,text=True).strip()
|
||||
def relevant(p):
|
||||
return p in ('Cargo.toml','Cargo.lock','rust-toolchain.toml') or p.startswith(('crates/','build/','.cargo/','scripts/','docs/','examples/','harness/fixtures/','harness/prototypes/'))
|
||||
paths=subprocess.check_output(['git','ls-tree','-r','--name-only','-z',head],cwd=root).decode().split('\0')
|
||||
assert set(inputs)=={p for p in paths if p and relevant(p)}, 'Committed input inventory differs'
|
||||
assert all('\n' not in p for p in inputs)
|
||||
request=''.join(head+':'+p+'\n' for p in inputs).encode()
|
||||
raw=subprocess.run(['git','cat-file','--batch'],cwd=root,input=request,capture_output=True,check=True).stdout
|
||||
offset=0
|
||||
actual={}
|
||||
for path in inputs:
|
||||
end=raw.index(b'\n',offset)
|
||||
oid,kind,size=raw[offset:end].split()
|
||||
assert kind==b'blob'
|
||||
size=int(size)
|
||||
data=raw[end+1:end+1+size]
|
||||
assert len(data)==size and raw[end+1+size:end+2+size]==b'\n'
|
||||
actual[path]={'bytes':size,'sha256':hashlib.sha256(data).hexdigest()}
|
||||
offset=end+2+size
|
||||
assert offset==len(raw)
|
||||
changed=[p for p in inputs if inputs[p]!=actual[p]]
|
||||
assert not changed, 'Tested bytes differ from committed blobs: '+repr(changed)
|
||||
data=(gate/'inputs.json').read_bytes()
|
||||
report={'checked':datetime.datetime.now(datetime.timezone.utc).isoformat(),'commit':head,'gate':run,'inputCount':len(inputs),'inputMap':{'bytes':len(data),'sha256':hashlib.sha256(data).hexdigest()},'comparison':'Exact committed Git blobs equal every captured input; same complete filtered inventory'}
|
||||
out=root/'.local/group-movement-gates'/('committed-'+run+'.json')
|
||||
with out.open('x',encoding='utf-8') as f: json.dump(report,f,indent=2)
|
||||
print(json.dumps(report,indent=2))
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Compare captured input maps to exact named Git trees; no builds or checkouts."""
|
||||
from pathlib import Path, PurePosixPath
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import zipfile
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
COMMITS = {"desktop-4": "12a7352ddef5f69a144ead8219a50916233d63a9",
|
||||
"desktop-5": "f6a6010baed9e6164a1fcc7ecd58256a562827d7"}
|
||||
COMPARISON = "Exact committed Git blobs equal every captured input; same complete filtered inventory"
|
||||
FILES = {".gitattributes", "verify.py", "README.md", "verify-committed-inputs.py",
|
||||
"committed-desktop-4.json", "committed-desktop-5.json"}
|
||||
|
||||
|
||||
def need(ok, message):
|
||||
if not ok:
|
||||
raise ValueError(message)
|
||||
|
||||
|
||||
def pin(data):
|
||||
return {"bytes": len(data), "sha256": hashlib.sha256(data).hexdigest()}
|
||||
|
||||
|
||||
def parsed(data):
|
||||
def unique(items):
|
||||
result = {}
|
||||
for key, value in items:
|
||||
need(key not in result, "duplicate JSON key: " + key)
|
||||
result[key] = value
|
||||
return result
|
||||
return json.loads(data, object_pairs_hook=unique)
|
||||
|
||||
|
||||
def safe(name):
|
||||
return (isinstance(name, str) and bool(name) and "\\" not in name and ":" not in name
|
||||
and not PurePosixPath(name).is_absolute()
|
||||
and all(part not in ("", ".", "..") for part in name.split("/")))
|
||||
|
||||
|
||||
def relevant(path):
|
||||
return path in ("Cargo.toml", "Cargo.lock", "rust-toolchain.toml") or path.startswith(
|
||||
("crates/", "build/", ".cargo/", "scripts/", "docs/", "examples/", "harness/fixtures/", "harness/prototypes/"))
|
||||
|
||||
|
||||
def git(repo, *arguments, data=None):
|
||||
environment = dict(os.environ, GIT_NO_LAZY_FETCH="1")
|
||||
return subprocess.run(["git", *arguments], cwd=repo, input=data, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE, check=True, env=environment).stdout
|
||||
|
||||
|
||||
def compare_commit(repo, commit, inputs):
|
||||
need(git(repo, "rev-parse", "--verify", commit + "^{commit}").decode().strip() == commit, "named commit unavailable")
|
||||
entries = git(repo, "ls-tree", "-r", "--full-tree", "-z", commit).split(b"\0")
|
||||
blobs = {}
|
||||
for entry in filter(None, entries):
|
||||
header, path_bytes = entry.split(b"\t", 1)
|
||||
path = path_bytes.decode("utf-8")
|
||||
if relevant(path):
|
||||
_mode, kind, oid = header.split()
|
||||
need(kind == b"blob", "relevant non-blob tree entry: " + path)
|
||||
need(path not in blobs, "duplicate tree path")
|
||||
blobs[path] = oid
|
||||
need(set(blobs) == set(inputs), "complete filtered Git inventory differs; missing="
|
||||
+ repr(sorted(set(inputs) - set(blobs))) + "; additional=" + repr(sorted(set(blobs) - set(inputs))))
|
||||
# Object IDs avoid path quoting and commit:path interpretation in batch input.
|
||||
paths = sorted(blobs)
|
||||
data = git(repo, "cat-file", "--batch", data=b"".join(blobs[path] + b"\n" for path in paths))
|
||||
offset, actual = 0, {}
|
||||
for path in paths:
|
||||
end = data.index(b"\n", offset)
|
||||
oid, kind, size_bytes = data[offset:end].split()
|
||||
size = int(size_bytes)
|
||||
need(oid == blobs[path] and kind == b"blob" and size >= 0, "Git batch object relationship")
|
||||
content = data[end + 1:end + 1 + size]
|
||||
need(len(content) == size and data[end + 1 + size:end + 2 + size] == b"\n", "Git batch framing")
|
||||
actual[path] = pin(content)
|
||||
offset = end + 2 + size
|
||||
need(offset == len(data), "trailing Git batch bytes")
|
||||
different = [path for path in paths if actual[path] != inputs[path]]
|
||||
need(not different, "captured bytes differ from named commit: " + repr(different))
|
||||
|
||||
|
||||
def packaged(checks, runs):
|
||||
files = parsed((checks / "files.json").read_bytes())
|
||||
need(set(files) == {"prepare.py", "verify.py", "README.md", "manifest.json", "evidence.zip"}, "checks outer inventory")
|
||||
for name, expected in files.items():
|
||||
need(pin((checks / name).read_bytes()) == expected, "checks outer pin: " + name)
|
||||
manifest = parsed((checks / "manifest.json").read_bytes())
|
||||
need(manifest["schema"] == 1 and manifest["kind"] == "multiselect-cargo-gates", "checks manifest kind")
|
||||
captured = {}
|
||||
with zipfile.ZipFile(checks / "evidence.zip") as archive:
|
||||
names = archive.namelist()
|
||||
need(len(names) == len(set(names)) and set(names) == set(manifest["members"])
|
||||
and all(safe(name) for name in names), "checks exact safe members")
|
||||
def raw(logical):
|
||||
member = manifest["logicalFiles"][logical]
|
||||
data = archive.read(member)
|
||||
need(pin(data) == manifest["members"][member], "checks member pin: " + logical)
|
||||
return data
|
||||
for run in runs:
|
||||
prefix = "cargo/" + run + "/"
|
||||
result = parsed(raw(prefix + "result.json"))
|
||||
spec = manifest["runs"][run]
|
||||
need(all(result[key] == spec[key] for key in ("command", "head", "exitCode", "inputCount")), run + ": checks run relationships")
|
||||
captured[run] = (raw(prefix + "inputs.json"), result)
|
||||
return captured
|
||||
|
||||
|
||||
def verify(repo, captured):
|
||||
outer = parsed((HERE / "files.json").read_bytes())
|
||||
need(set(outer) == FILES, "source evidence file inventory")
|
||||
for name, expected in outer.items():
|
||||
need(pin((HERE / name).read_bytes()) == expected, "source evidence pin: " + name)
|
||||
for run, commit in COMMITS.items():
|
||||
receipt = parsed((HERE / ("committed-" + run + ".json")).read_bytes())
|
||||
need(receipt["gate"] == run and receipt["commit"] == commit
|
||||
and receipt["comparison"] == COMPARISON, run + ": historical identity relationship")
|
||||
map_bytes, result = captured[run]
|
||||
inputs = parsed(map_bytes)
|
||||
need(pin(map_bytes) == receipt["inputMap"] == result["inputs"], run + ": exact captured input map")
|
||||
need(result["exitCode"] == 0 and result["inputsUnchanged"] is True, run + ": accepted unchanged gate")
|
||||
need(len(inputs) == receipt["inputCount"] == result["inputCount"], run + ": input counts")
|
||||
need(all(safe(path) and relevant(path) for path in inputs), run + ": input scope")
|
||||
need(sum(path.startswith("crates/vendor/iced_widget/") for path in inputs) == 71, run + ": vendor count")
|
||||
compare_commit(repo, commit, inputs)
|
||||
print("PASS exact Git tree/blobs:", run, commit, len(inputs), "captured inputs")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--repo", type=Path, required=True, help="repository with both named commits already present")
|
||||
source = parser.add_mutually_exclusive_group(required=True)
|
||||
source.add_argument("--checks", type=Path, help="portable checks package directory")
|
||||
source.add_argument("--raw-gates", type=Path, help="optional preparation mode: raw gate directory, not a packaged-evidence verification")
|
||||
args = parser.parse_args()
|
||||
if args.checks:
|
||||
captured = packaged(args.checks, COMMITS)
|
||||
else:
|
||||
captured = {run: ((args.raw_gates / run / "inputs.json").read_bytes(),
|
||||
parsed((args.raw_gates / run / "result.json").read_bytes())) for run in COMMITS}
|
||||
verify(args.repo, captured)
|
||||
print("Source identity only; no build, checkout, helper execution or native acceptance claim.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,229 @@
|
||||
# Architecture multi-selection critique — 2026-09-23
|
||||
|
||||
Independent read-only review of `b8685416f56e53e30bd11ecf93a54cea10ac8458`
|
||||
for issue #29 by `transition_picker_review`. This is a proposal, not passing
|
||||
implementation or native evidence. Only this note was written; no Cargo, GUI,
|
||||
production changes, or coordination posts were made. The parent owns synthesis.
|
||||
Architecture geometry can be a foundation, but does not complete the requested
|
||||
coherent outcome across Allocation and Behavior. See the parallel
|
||||
[Behavior review](multiselect-behavior-design-20260923.md).
|
||||
|
||||
## Verified constraints
|
||||
|
||||
| Boundary | Evidence at reviewed commit | Consequence |
|
||||
| --- | --- | --- |
|
||||
| Selection | `app.rs:790–835,1107,2477` uses semantic `selected` and one `selected_node`; `refresh_draft` derives placement again from semantic identity. | A new group cannot be inferred from the inspector selection or rebuilt as a singleton after each refresh. |
|
||||
| Identity | `validation.rs:794` permits one placement per element per diagram; `projection.rs:169` resolves port endpoints to their owner placements. | Placement identity and active diagram still matter. A port does not become a separately movable card. |
|
||||
| Ordinary movement | `diagram.rs:463,985,1166` previews one root plus descendants; `app.rs:3296` submits `MoveNode` against the then-active diagram after `apply()`. | New group intents need captured context and exact preview/commit parity, not repeated old messages. |
|
||||
| Routes | `projection.rs:191` translates pins only when both actual endpoint owners share the same nonzero delta; State adds boundary checks. | A single simultaneous displacement map is necessary for unrelated selected endpoints. Labels/attachments retain relative data. |
|
||||
| Allocation | `diagram/work_drag.rs:65` captures a work scope; `app/allocation_drag.rs:429,568` selects one work root on press and after commit. | Existing handlers would collapse a group. Work-body drag changes leaf allocations, not merely coordinates. |
|
||||
| Admission | `app/allocation_drag.rs:353–411` checks owner, drafts, snapshot, context, camera and lifetime; `diagram/work_drag.rs:118` cancels on view/input changes. | Reuse this admission discipline, including rejection before shared state mutation. |
|
||||
| Focus | `diagram/focus.rs:195` drops all keyboard events when canvas lacks focus. | Canvas-local modifier tracking alone misses Shift pressed while the inspector owns focus. |
|
||||
| Removal | `app/diagram_removal.rs:114–260` distinguishes placement Hide from confirmed semantic Delete and captures one target/token. | Group removal needs an explicit set and a single combined confirmation. |
|
||||
|
||||
## Selection contract
|
||||
|
||||
Use transient selection scoped to document-instance and surface identity, with
|
||||
ordered unique typed targets and one primary member for the existing inspector.
|
||||
For ordinary diagrams, movable members are exact node placements. The owner
|
||||
must distinguish a reopened document even if its project UUID is unchanged.
|
||||
Selection ownership should not itself be tied to model revision: surviving
|
||||
members should remain selected after a successful group move or primary-property
|
||||
edit. Gesture authority must additionally capture revision/snapshot, epoch,
|
||||
selection generation, camera and actual canvas bounds.
|
||||
|
||||
Recommended interactions, consistent across applicable families:
|
||||
|
||||
- Plain click an unselected card replaces selection. Shift+click toggles it;
|
||||
adding makes it primary, and removing the primary promotes the most recent
|
||||
remaining member. Shift selection must precede port/route/reorder handle logic.
|
||||
- Pressing an already selected card preserves the set so dragging works. The
|
||||
synthesis chooses collapse to that member on a completed stationary release;
|
||||
this is acceptable consistently across families. A cancelled, refused, or
|
||||
threshold-crossing drag must retain the group even if it ends at its origin.
|
||||
- Right-click a member retains the group. Right-click elsewhere selects that
|
||||
target before opening its menu. A stationary blank click clears; a pan gesture
|
||||
retains selection. Do not replace existing blank-drag pan with marquee silently.
|
||||
- Paint every member, distinguish primary without relying only on color, and show
|
||||
the count. Inspector edits remain explicitly primary-only. Do not expose
|
||||
singleton commands under a group label or imply bulk property editing.
|
||||
- Track modifiers passively through the relevant input owner even before it has
|
||||
keyboard focus; this does not grant permission for canvas shortcuts in text
|
||||
fields. Freeze press-time modifiers and clear them on window focus loss.
|
||||
|
||||
Typed edge/port selection can be shared with other families, but command
|
||||
capabilities must remain explicit. Edges do not have node coordinates; port
|
||||
anchors are owner-local. A mixed set must never silently move only its primary
|
||||
or eligible subset. If placement-only grouping is delivered first, report that
|
||||
scope and give an explicit unavailable-action reason for other target types.
|
||||
|
||||
## Simultaneous movement and alternatives
|
||||
|
||||
Prefer a pure planner which accepts the original diagram, selected placement
|
||||
IDs and one delta, unions roots with visual descendants, translates each placement
|
||||
once, calls `translated_routes` once on the original geometry, and emits one
|
||||
`Batch(EditNode..., SetEdgeRoute...)`. Use the same plan for preview and commit.
|
||||
Snap one shared delta, based on the grabbed anchor, rather than snapping each
|
||||
node independently and changing their spacing. Nonselected records and other
|
||||
diagrams must remain exact.
|
||||
|
||||
Sequential `Batch(MoveNode(A), MoveNode(B))` is unsuitable: parent+child can move
|
||||
twice, and a route between unrelated selected endpoints never sees both owners
|
||||
in the same displacement map. A dedicated serialized `MoveNodes` command could
|
||||
eventually express public API intent, but adds schema/documentation/compatibility
|
||||
cost without being needed for the GUI's atomic transaction. Selection itself
|
||||
does not belong in the model/source schema. An intent command also would not
|
||||
remove the need for preview planning or stale-context checks.
|
||||
|
||||
Use visual ancestry for placement closure, semantic work ancestry for allocation
|
||||
leaf normalization, and semantic deletion dependencies for model removal. These
|
||||
are different graphs. Folded descendants travel with a moved visual parent,
|
||||
but selecting parent+child must not add a second delta. Selected port owners
|
||||
already carry their ports and relative anchor/label data; do not translate that
|
||||
data again. Internal routes translate once; one-ended routes keep world pins;
|
||||
automatic routes reroute. Reuse State boundary rules without broadening their
|
||||
meaning. Cache keys must include the captured selection/plan as well as delta:
|
||||
the existing singleton preview-position cache key is insufficient.
|
||||
|
||||
## Allocation decision
|
||||
|
||||
Prefer the parent's proposed shared-destination, all-or-nothing group allocation
|
||||
over always-arrange multi-selection. Shift changes cardinality, not drag meaning;
|
||||
silently disabling reassignment only for groups would be unpredictable.
|
||||
|
||||
Normal Allocation drag of a work-only selection should normalize semantic roots
|
||||
and all leaves once, evaluate one frozen performer/outside destination for the
|
||||
entire set, and preserve semantic ownership. Every constituent subtree must
|
||||
satisfy existing role, mixed-allocation and geometric-coherence rules; one
|
||||
refusal rejects the whole operation. Separate valid roots starting under different
|
||||
performers are not inherently ineligible for an explicit common target. Never
|
||||
weaken role-exit or collateral-change protections to make a group succeed.
|
||||
|
||||
Exclude all moving placements from frozen drop occlusion. Allocate new relation
|
||||
UUIDs once per leaf, reuse them on target revisits, and commit precisely the
|
||||
previewed candidate through one Document transaction and one Undo step. Layout
|
||||
must preserve group spacing while respecting the destination's constraints; an
|
||||
impossible placement refuses the group rather than packing a subset.
|
||||
|
||||
Arrange only uses the pure simultaneous geometry planner and preserves every
|
||||
allocation record. A mixed performer/work/other set cannot promise semantic
|
||||
reassignment: refuse a normal work-primary drag with actionable Arrange only
|
||||
guidance, or offer an explicit group Move action. This remains a dependency of
|
||||
full #29 acceptance even if geometry support lands first.
|
||||
|
||||
## Other coherent actions and lifecycle
|
||||
|
||||
Group Hide removes exactly the selected placements from the active diagram,
|
||||
preserving semantic elements and other diagrams. Do not reuse movement's
|
||||
descendant closure: hiding a parent must not silently hide unselected children.
|
||||
Ports/edges without placements must not fall through from Hide to model Delete.
|
||||
Group Delete from model needs one combined impact preview over unique semantic
|
||||
targets, with overlapping descendants/incidents counted once. Avoid sequential
|
||||
deletes of a parent followed by an already-removed child. One invalid/read-only
|
||||
member rejects the whole command before any mutation.
|
||||
|
||||
Fold/Unfold should apply one explicit state to all eligible selected containers,
|
||||
not toggle each member into the opposite mixed state. Select All visible cards,
|
||||
clear selection, nudge, and align/distribute are useful discoverable related
|
||||
actions; alignment is geometry-only and not automatically suitable for derived
|
||||
Behavior layouts. Duplication and bulk semantic-property edits need separate
|
||||
contracts. Existing menus/help should advertise Shift+click, group count,
|
||||
Arrange only, Hide versus Delete, and unavailable-action reasons.
|
||||
|
||||
Selection-only changes create no model/source/history entries. Changing the
|
||||
primary must not discard an unfinished inspector draft. A group gesture requires
|
||||
clean admitted input; do not auto-apply an unrelated draft merely to start or
|
||||
finish it. Dirty/read-only/pending/modal contexts keep their current input owner.
|
||||
Opening a modal cancels drag authority but can retain visible selection; closing
|
||||
it must not revive that drag. Source apply, Undo/Redo and property changes prune
|
||||
vanished/invisible members while retaining survivors. Explicit UUID edits need
|
||||
known identity remapping or safe pruning, never alias/name matching. Switching
|
||||
document/diagram/layer/owner clears the active set. Native pointer ordering and
|
||||
press/release batching must remain respected. Successful moves retain the group;
|
||||
Hide/Delete prune it. Automatic reselection after Undo of deletion is a separate
|
||||
UX decision, not an implied model-history guarantee.
|
||||
|
||||
## Falsifiers before acceptance
|
||||
|
||||
1. Shift held in a clean inspector before clicking the second function adds it;
|
||||
no property text, model, source or history changes. Shift-click a selected
|
||||
route/port handle does not start another editing gesture.
|
||||
2. A parent and its child, plus an unrelated sibling, move once by one delta;
|
||||
folded descendants and both-ended custom pins follow once. One-ended pins,
|
||||
attachments, label offsets, allocations and other diagrams remain exact.
|
||||
3. Two unrelated selected function endpoints move with their internal route;
|
||||
preview projection/routing equals committed projection/routing. Undo/Redo is
|
||||
one exact operation; zero displacement adds no history.
|
||||
4. Allocation of two valid roots to one performer succeeds atomically. Add one
|
||||
role-exit/collateral/ineligible member: candidate and release both refuse
|
||||
without partial allocation. Arrange only moves the same set with allocation
|
||||
records byte-for-byte unchanged.
|
||||
5. During a drag, change selection, dirty source, open a modal, lose focus,
|
||||
resize/pan/zoom, replace the document with the same project UUID, or deliver
|
||||
an old release after a new press. No stale operation commits or cancels a
|
||||
newer owner's gesture. Esc preserves model/source/history and the selection.
|
||||
6. Group Hide preserves semantic records and unselected child placements;
|
||||
group Delete previews the union once and preserves unrelated counterparts.
|
||||
Mixed nonplacement/read-only selection never becomes partial success.
|
||||
7. Group movement and ordinary primary-property Apply retain surviving members;
|
||||
inspector clearly edits only the primary. Right-click a selected card keeps
|
||||
group actions; text-field Delete/Ctrl+A remain text actions.
|
||||
|
||||
These are proposed evaluators, not reported test passes. No edits to peer-owned
|
||||
#16 resize or #31 traceability/navigation files are recommended by this note;
|
||||
coordinate any necessary shared integration through their owners.
|
||||
|
||||
## Review of the parent synthesis
|
||||
|
||||
Reviewed `multiselect-contract-20260923.md` after its initial synthesis. Its
|
||||
common selection plus family-specific planners, simultaneous geometry,
|
||||
all-or-nothing Allocation, derived-order adapters and aggregate removal form a
|
||||
coherent scope. The architecture planner is a useful foundation and does not
|
||||
by itself establish full issue acceptance. No production implementation was
|
||||
reviewed or executed in this pass.
|
||||
|
||||
The selected-member stationary-release policy is a reasonable choice. My
|
||||
initial preference for retaining the group favored easy primary inspection;
|
||||
collapse favors the ordinary single-target click convention. Neither needs a
|
||||
second preference setting. The chosen policy needs these precise boundaries:
|
||||
|
||||
- Collapse only after a valid stationary press/release which never exceeded the
|
||||
drag threshold. A drag which returns to its origin or snaps to zero has still
|
||||
been a drag. A rejected destination, Escape, stale release, focus loss, or
|
||||
modal entry must not reinterpret a group gesture as a singleton click.
|
||||
- Freeze selection and primary for gesture authority. Refreshing the inspector
|
||||
on selected-member press may change canvas bounds and cancel the gesture
|
||||
through the existing geometry guards. Defer that refresh until the outcome,
|
||||
or prove that a deliberately frozen pane arrangement preserves the captured
|
||||
geometry. Do not weaken cancellation guards merely to mask this interaction.
|
||||
- Specify whether the grabbed member becomes primary after a completed group
|
||||
drag; either choice is viable, but the inspector and primary highlight must
|
||||
agree while the full group remains selected.
|
||||
|
||||
Two capability details remain material implementation decisions:
|
||||
|
||||
1. Exact visual targets include ports, edges and read-only occurrences, while
|
||||
the geometry planner consumes placements. The adapter must evaluate the
|
||||
complete selection before calling it. For a mixed unsupported group, refuse
|
||||
with an explicit reason; do not silently filter to placements, promote a
|
||||
selected port to its owner, or infer an edge's endpoints as selected nodes.
|
||||
A separately named nodes-only action could be specified later, but cannot
|
||||
be implicit in the ordinary group drag. Arrange only has the same admission
|
||||
obligation even though it preserves allocations.
|
||||
2. Context Add/Remove must capture an exact target and retain the previous set.
|
||||
The current singleton-select-before-menu pattern cannot run before Add or
|
||||
the existing set has already been lost. Right-click a selected member should
|
||||
retain its group for group actions; offering Add for an unselected target
|
||||
requires retaining the prior set until that explicit command is resolved.
|
||||
Therefore the blanket rule that right-click elsewhere first replaces the
|
||||
selection needs an exception or an explicit two-stage menu contract. Menu
|
||||
dismissal must not accidentally apply Add, Remove, or a singleton mutation.
|
||||
|
||||
Additional falsifiers for the final policy: press a selected member, cross the
|
||||
threshold, return to origin and release (group retained, no history); drag to
|
||||
an invalid allocation target (group retained, explanation, no edits); open a
|
||||
context menu on an unselected function and choose Add (old members preserved);
|
||||
then remove the primary (latest surviving member becomes primary). Repeat the
|
||||
first press from an inspector with different field height and confirm that
|
||||
inspector refresh cannot turn the gesture into a stale drop. A mixed node/port
|
||||
selection must receive the declared refusal rather than a partial move.
|
||||
@@ -0,0 +1,61 @@
|
||||
# Behavior multi-selection design review — 2026-09-23
|
||||
|
||||
Read-only review of published `b8685416f56e53e30bd11ecf93a54cea10ac8458` for issue #29. This note is a local-only proposal until the owning session publishes it. No runtime, build, GUI, or acceptance claim is made. Process, Scenario, and State are all within the requested usability outcome; architecture-only support is not equivalent completion.
|
||||
|
||||
## Verified implementation boundaries
|
||||
|
||||
| Surface | Identity and current gesture | Source at reviewed commit |
|
||||
| --- | --- | --- |
|
||||
| Process | Local occurrence/relation UUID; expanded reusable contents additionally require `OccurrenceEndpoint { references, occurrence }` and owner. Dragging the selected handle changes `steps` order only within one derived graph column. Links determine columns. | `crates/oa-desktop/src/behavior/topology/direct.rs:5`, `:29`, `:112`, `:448`, `:721`; `crates/oa-desktop/src/app/process_canvas.rs:145` |
|
||||
| Scenario | Lifeline/occurrence UUID under a scenario; occurrence rows also identify their immediate sequence/operand parent. Drag changes semantic ordering, constrained to the same parent; the candidate is validated. A stationary row click selects on release. | `crates/oa-desktop/src/behavior.rs:1171`, `:1217`, `:1909`, `:1985`; `crates/oa-desktop/src/behavior/scenario.rs:1091` |
|
||||
| State | Semantic UUID and exact placement UUID are distinct. Vertex movement mutates persisted geometry and includes projected descendants. State-action rows and transitions are relation UUIDs. | `crates/oa-desktop/src/behavior/state_canvas.rs:136`, `:983`, `:1359`; `crates/oa-desktop/src/behavior/state_edit.rs:131`, `:533` |
|
||||
| Navigation | Existing `Surface`/`Target` describes saved placements, state rows, qualified process occurrences, preview links and scenario objects. Its navigation highlight is explicitly not an editing target. Coordinate with #31 before touching its files. | `crates/oa-desktop/src/diagram_usage.rs:105–156` |
|
||||
|
||||
The shared viewport admits keyboard events only while focused, keyboard-enabled, and outside pan/space gestures (`behavior/canvas_workspace/viewport.rs:327`). `keyboard_owner_enabled` excludes modal menus, confirmation, reference inputs, workflow, and usage chooser (`behavior/canvas_workspace.rs:17`). Dirty inspector/source drafts and pending behavior editors add mutation guards (`app/diagram_removal.rs:24`; `behavior/state_view.rs:178`). Pointer state already captures document, owner/diagram, revision and epoch, and cancels on relevant context/view changes. Preserve these lifetimes.
|
||||
|
||||
Delete currently has materially different meanings: persisted placement Delete hides that placement; Shift+Delete targets its semantic object. Derived Process/Scenario Delete removes the local semantic occurrence or relation, preserving reusable definitions. Qualified Process preview contents cannot be deleted from the caller (`app/diagram_removal.rs:55–110`, `:170`; `app/process_canvas.rs:96–113`). This distinction must remain visible in any group action.
|
||||
|
||||
## Recommended minimal coherent contract (proposal)
|
||||
|
||||
Use a common selection interaction and family-specific command planners. Do not add free geometry to derived Process/Scenario diagrams as an accidental consequence of this feature: doing so would require a separate persisted-layout specification and source/API parity decision.
|
||||
|
||||
1. Selection belongs to `(document identity, surface identity)`, contains typed visual targets in stable order, and has one primary target for the existing inspector. Semantic UUID alone is insufficient for saved placements or repeated Process previews. A selected target may be inspectable without being movable/deletable. Invalid or newly hidden targets are pruned when the document/view changes; navigation to another owner clears selection (Back may deliberately restore a separately captured selection).
|
||||
2. Plain click an unselected target replaces the set. Shift+click toggles membership; adding makes it primary, removing the primary selects the most recent remaining member. Track modifiers in the focused input path, not through a global shortcut that also fires in text fields. Shift selection must precede endpoint/bend/reorder handles and must never reconnect or start a move.
|
||||
3. Press on a selected movable target preserves the set so drag moves the group. A stationary plain release may collapse to that target; decide once across families and test it. Right-click a selected member preserves the group; right-click an unselected item makes it the sole member before presenting commands. A stationary background click clears selection. Existing background pan must not clear it merely because the pan started.
|
||||
4. Paint every member, distinguish the primary, and show a concise count and available group action. Single-object property editing remains explicitly the primary object's editor. Do not imply that changing its name, type, guard, or operand edits the set.
|
||||
5. Group operations capture the complete immutable target set, primary, document, surface, revision and gesture epoch. Preview and commit use that same snapshot. One successful gesture is one document transaction/undo entry; Escape, stale context, modal entry, tool switch, focus loss, invalid release, or validation failure produces no partial edit. Preserve selection after successful mutation and reconcile after undo/redo.
|
||||
6. Capability admission evaluates the entire selection, including qualified/read-only members. Unsupported mixtures must give a specific reason; silently acting on only the primary or silently omitting unsupported members is not a group action. If a supported action intentionally affects only nodes in a nodes-and-edges set, label it "Move selected nodes" and derive attached routes; never move a selected edge's endpoints implicitly.
|
||||
|
||||
## Family-specific group operations (proposal)
|
||||
|
||||
**State/saved geometry:** Translate all selected root placements by one world-space delta. Expand the union of each root's descendant placements from the original model; parent plus selected child moves the child exactly once, including folded descendants. Preserve semantic ownership and all relative offsets. Compute route translation against the complete placement-delta union once. `Command::Batch` containing independent `MoveNode` calls is not sufficient: each `MoveNode` currently computes its own route delta map (`oa-core/src/session.rs:771–806`), so a route between two independently moved roots does not receive the same treatment as one whose endpoints move in one operation. Prefer a small shared core group-move command/planner used by both saved architecture and State. Coordinate geometry interaction with #16; group resizing is not implied by group movement.
|
||||
|
||||
**Process:** Reorder a selected group of local contributions as a stable block within their existing derived column. Retain their prior `steps` order, remove all selected IDs, and insert before the chosen unselected contribution. Drop inside the selected block is a no-op. Require one owner and one column for all selected contributions; reject qualified previews, links, and cross-column sets for this operation. Preserve all sequence/contextual link UUIDs, endpoints and guards. Presentation order must not become new topology. Explicitly distinguish the legacy ordered-path representation, whose `steps` order can carry behavioral meaning; the planner must validate the complete candidate and guidance should say "Reorder contributions", not "Move freely". Add block movement through both pointer and bounded earlier/later controls so small Fit views remain usable.
|
||||
|
||||
**Scenario:** Reorder selected occurrences as a stable block inside one immediate sequence/operand parent, with the same remove-then-insert algorithm and no-op rule. Lifelines are not occurrence rows and cannot participate in this operation. Nested fragments/branches move with their owned contents; do not separately reorder a selected descendant when its ancestor is moved. Cross-parent selection may remain useful for inspection/deletion but cannot silently migrate between operands. Run existing full-candidate validation once after the proposed order, preserving call/reply, activation, lifecycle, and fragment semantics. Invalid causal order returns an actionable reason and retains the original sequence. Do not claim pixel-distance preservation or arbitrary x/y movement in this temporal view.
|
||||
|
||||
**Bulk removal across families:** Reuse the current semantics with an aggregate target/impact preview and one atomic transaction. Deduplicate objects and descendants so overlapping selections are not applied twice. For persisted diagrams, expose "Hide selected placements" separately from "Delete selected model objects"; never silently delete an edge semantically in an otherwise placement-only hide action. For Process/Scenario expose "Delete selected occurrences/relations" and preserve definitions. Read-only qualified preview contents require opening the definition; selecting the outer reuse occurrence allows deleting that use. The preview must enumerate actual dependency effects (e.g. deleting a participant clears occurrence participant references; deleting a fragment removes its owned branches/steps) rather than merely displaying a count.
|
||||
|
||||
Useful later group actions discovered from these workflows are hide/show placement sets, align/distribute geometry-only nodes, reorder block earlier/later, duplicate an occurrence block with identity/reference remapping, and applying genuinely shared editable properties. Alignment is not meaningful for derived temporal/topology layouts. Duplication and batch property editing each need their own typed semantic contract; do not expose existing singleton actions under a misleading multi-selection label.
|
||||
|
||||
## Alternatives and staged dependencies
|
||||
|
||||
- **Uniform free drag everywhere:** visually simple but breaks the present Process topology/Scenario temporal representation or requires new persisted layout models. Reject for this change unless explicitly undertaking that broader semantics work.
|
||||
- **Geometry surfaces only:** quickest, but leaves the user's family-wide inability to select/act on groups unresolved. A staged delivery can name this limitation; it cannot close the full outcome.
|
||||
- **Common selection plus capability planners:** recommended. Reuses transaction, identity and input guards while respecting derived layouts. Costs a small shared selection layer plus independently testable State, Process and Scenario planners.
|
||||
|
||||
Implementation dependency order: (1) selection identity/primary/context reducer and gestures; (2) union-based core geometry movement and State/saved adapters; independently, (3) stable-block reorder planners and Process/Scenario adapters; (4) aggregate removal with impact/confirmation lifetime; (5) realistic OA/SA native validation, undo/save/reopen/source parity, fresh review. Sharing selection does not require merging navigation-only `Focus` with mutable selection or generalizing every diagram renderer.
|
||||
|
||||
Choices to settle explicitly: stationary click on an already selected member retains group versus collapses on release; whether Shift+click toggles or only adds (toggle recommended); exact shortcut for Select All when canvas owns focus; whether marquee replaces existing blank-left-drag panning (retain current pan and defer marquee unless a deliberate cross-family gesture decision is made); whether bulk deletion is delivered in the same milestone or left as a specifically tracked gap. None requires changing semantic ownership through a drag.
|
||||
|
||||
## High-risk falsifiers / acceptance examples
|
||||
|
||||
- Select State parent plus child plus an independent node with an edge between roots. Move once: each placement and eligible authored bend translates once; other diagram placements and semantic parents are byte-equivalent. Undo/redo and source round-trip restore exact geometry.
|
||||
- Reuse one subprocess twice and Shift-select the same inner occurrence in both previews. Highlights remain distinct; group deletion/reorder cannot mutate the reusable definition or confuse the two callers.
|
||||
- Select noncontiguous Process contributions in one column, reorder together, and compare every link and guard. Cross-column drop rejects with no partial change. Exercise a legacy exchange-only ordered process as well.
|
||||
- Select Scenario call/reply together and reorder; also move reply alone before call, across an operand, or across create/delete lifecycle boundaries. Valid block succeeds once; invalid candidate leaves sequence/model/source/history unchanged. Fragment plus descendant never duplicates or reparents a step.
|
||||
- Shift-click selected transition endpoint, bend handle, State action row, Scenario message line, and Process reorder handle. Membership changes without starting reconnect/bend/reorder gestures. An ordinary drag still performs the established operation when selection is singular.
|
||||
- Focus inspector text, open modal/usage chooser, switch document with same project UUID, change owner, scroll/zoom, lose window focus, or start source edits during group gesture. No queued release/Delete can commit against stale context; typed text shortcuts remain local.
|
||||
- Select mixed nodes/edges or local/qualified-preview items. UI tells the truth about action capability and never silently operates on the primary alone. All visible selection states remain distinguishable without hue alone.
|
||||
|
||||
No code edits were made as part of this review. These cases are proposed evaluators, not passing test results.
|
||||
@@ -0,0 +1,118 @@
|
||||
# Multiple selection and group actions: implementation contract
|
||||
|
||||
User requirement: [issue 29](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/29)
|
||||
asks for Shift+click selection of several functions, moving them together, and
|
||||
related useful group actions. This contract derives from that request and the
|
||||
existing graphical/textual equivalence requirement. It is a development decision,
|
||||
not a claim that the feature is implemented or accepted. Inspected source is
|
||||
`b8685416f56e53e30bd11ecf93a54cea10ac8458`; integration with later peer branches
|
||||
requires inspection of their actual changes.
|
||||
|
||||
## Selection and operation identity
|
||||
|
||||
Selection is transient and scoped to one document and rendered surface. It has
|
||||
an ordered set of exact visual targets and one primary target for the inspector.
|
||||
A semantic UUID alone cannot identify a placed node, a port on a particular
|
||||
placement, or a repeated occurrence in an expanded reusable Process. Navigation
|
||||
highlights are not edit targets. Changing surface clears the set; accepted model
|
||||
changes reconcile surviving exact targets without mapping a missing occurrence
|
||||
to another occurrence of its semantic definition.
|
||||
|
||||
Shift+click toggles the hit target. Plain click on an unselected target replaces
|
||||
the set. Pressing a selected member retains the set for a possible group drag;
|
||||
a stationary release that never crossed the drag threshold selects that member
|
||||
alone. A threshold-crossing drag keeps the group, including cancellation, refusal,
|
||||
snap-to-zero or returning to the original pointer position. Defer any primary
|
||||
inspector/layout change until the gesture outcome. The interaction must use
|
||||
captured pointer order and original gesture
|
||||
geometry even if the inspector changes layout. Modifiers must be available on
|
||||
the first canvas press after focus comes from another editor; passive modifier
|
||||
tracking must not grant keyboard command focus. Escape cancels a live gesture
|
||||
before clearing selection. Contextual Add/Remove from selection provides an
|
||||
explicit alternative to the modifier gesture. Opening a context menu captures
|
||||
its exact target without replacing the selection; Add/Remove operates on that
|
||||
captured target. Dismissing the menu preserves the set. A visible count and appropriate
|
||||
group actions distinguish group selection from the primary inspector target.
|
||||
|
||||
Property edits continue to address the primary object. No implicit bulk property
|
||||
editing is introduced. Group actions explicitly address the captured set and
|
||||
must reject stale document, revision, surface or target identity before any
|
||||
selection, source, history or allocation state changes.
|
||||
|
||||
## Movement depends on the diagram's meaning
|
||||
|
||||
Saved Architecture and State views store placement geometry. Translate the
|
||||
union of selected placements and their visual descendants by one displacement;
|
||||
each placement moves once, including when both parent and child are selected.
|
||||
Derive all affected routes from the original diagram and the complete movement
|
||||
map. Both moved endpoint owners translate internal pins; an unmoved endpoint
|
||||
keeps world pins fixed. Existing State boundary rules still apply. Labels and
|
||||
manual attachment fractions remain relative. Other views, semantic ownership,
|
||||
contracts, IDs and unrelated records remain exact.
|
||||
|
||||
Movement admission evaluates the complete typed selection. Unsupported mixtures
|
||||
of placements, ports, edges, rows or read-only occurrences are refused with a
|
||||
reason; adapters must not silently omit members or promote ports to owner nodes.
|
||||
|
||||
Rejected approach: sequential `MoveNode` commands. Each command sees only its
|
||||
own root/descendants, so routes between separate selected roots do not receive
|
||||
the simultaneous translation required by `docs/language.rst`. A pure planner
|
||||
can emit the existing atomic `Batch(EditNode..., SetEdgeRoute...)`; a new wire
|
||||
command or source schema is unnecessary. Preview and commit must consume the
|
||||
same plan; tests independently construct expected geometry rather than merely
|
||||
comparing those two uses of the implementation.
|
||||
|
||||
Process and Scenario layouts are derived from semantic order, not free XY node
|
||||
placements. Their group drag must reorder a stable block while retaining its
|
||||
internal order and applicable owner/column/operand boundaries. Existing topology
|
||||
and temporal/lifecycle validation remains authoritative. Qualified reused
|
||||
Process content must not silently edit the reusable definition. These adapters
|
||||
are required work for the issue, not waived because ordinary diagrams are easier.
|
||||
|
||||
In Allocation views, multiple selection must not silently change work-body drag
|
||||
from assignment to arrangement. Work-only groups use one frozen common performer
|
||||
destination and one all-or-nothing allocation plan. Normalize semantic work
|
||||
ancestry, deduplicate leaves and placements, and apply existing role/coherence
|
||||
rules to every constituent. Different valid initial performers on independent
|
||||
roots are not automatically an ambiguous destination. Any invalid constituent
|
||||
refuses the whole action. Mixed selections cannot partially assign only eligible
|
||||
members; explain the refusal and offer the existing Arrange-only operation.
|
||||
Explicit Arrange-only always preserves every allocation record. Destination
|
||||
feedback and hit geometry remain stable throughout the gesture.
|
||||
|
||||
## Related group actions
|
||||
|
||||
Move/reorder and removal are the initial useful operation families. Saved-view
|
||||
Hide removes selected placements rather than the underlying engineering objects.
|
||||
Delete-from-model uses one aggregate impact review and transaction; preserve
|
||||
opposite-layer counterparts under the established deletion contract. Process
|
||||
and Scenario removal identifies local occurrences/messages/operands, not the
|
||||
reused definitions. A mixed set that cannot express one clear removal operation
|
||||
is refused with an explanation. Never act on only the primary object while the
|
||||
interface depicts a group. Alignment, distribution and duplication are separate
|
||||
capabilities to assess after this foundation; no speculative semantics are adopted.
|
||||
|
||||
## Acceptance and dependencies
|
||||
|
||||
1. Prove simultaneous geometry planning with unrelated roots/internal routes,
|
||||
nested and folded selections, one-ended routes, typed ports and State
|
||||
boundaries. Whole-model comparison, JSON/source round trips, atomic refusal,
|
||||
one Undo/Redo and zero-motion history behavior are required.
|
||||
2. Implement exact selection/primary state and focus-aware input. Exercise
|
||||
Shift held before canvas focus, toggle/removal, stationary click versus drag,
|
||||
captured event batches, hidden/stale targets and navigation invalidation.
|
||||
3. Connect ordinary/State previews and commits to the geometry planner, then
|
||||
implement group Allocation and Process/Scenario order adapters with their
|
||||
independent semantic evaluators. Preserve current route/attachment cancel,
|
||||
pan, read-only and pending-source/property behavior.
|
||||
4. Implement aggregate removal admission, feedback and history. Destructive
|
||||
semantics remain explicit; raw drafts must not be silently discarded.
|
||||
5. Build and exercise realistic native workflows, including multi-function
|
||||
selection/movement, nested selection, cancellation, source inspection,
|
||||
save/reopen and one-step history. Review interaction and saved-model evidence
|
||||
independently. Integrate with the release owner's actual combined source.
|
||||
|
||||
The [Architecture critique](multiselect-architecture-review-20260923.md) and
|
||||
[Behavior investigation](multiselect-behavior-design-20260923.md) contain source
|
||||
references and additional falsifiers. Live ownership, implementation sequencing
|
||||
and blockers belong to issue 29. Linux verification remains user-deferred.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Independent simultaneous-movement core review — 2026-09-23
|
||||
|
||||
Scope: frozen local `crates/oa-core/src/diagram_edit.rs`, `tests/group_movement.rs`, and `lib.rs` export, against `harness/multiselect-contract-20260923.md` and base `b8685416f56e53e30bd11ecf93a54cea10ac8458`. This is a static correctness/design review, not a passing build, test execution, GUI or final-feature acceptance claim. No production files were edited and no Cargo command ran.
|
||||
|
||||
Reviewed file SHA-256 values:
|
||||
|
||||
- `diagram_edit.rs`: `7F307D64FE09947973871A05BC8D5DCA82B64B65A8E6B3AAED22D6EFC6497962`
|
||||
- `group_movement.rs`: `BADEC10713B9E9961330A1A77710B6D0D75C27B482281B02BEA6868C52B8F994`
|
||||
|
||||
## Findings, in priority order
|
||||
|
||||
**No verified blocking correctness defect in the pure geometry planner.** There is one concrete scaling concern and two nonblocking coverage extensions. The latter are not claims that the implementation currently behaves incorrectly.
|
||||
|
||||
1. **P2 — applying the emitted batch performs a full project-identity enumeration for every moved placement.** `diagram_edit.rs:74` emits one `EditNode` per placement. Its existing handler (`session.rs:530–544`) calls `reidentify_tracked`, and `reidentify` invokes `Project::contains` before the same-ID early return (`session.rs:873–879`). `Project::contains` builds the entire `identities()` vector (`model.rs:1761–1781`). Thus applying a group of M placements adds O(M × P) identity enumeration/allocation for P project records, although every placement retains its ID. This is a verified code-path cost, not a measured latency defect. It matters especially if a future preview applies the whole command on every pointer event. The planner itself computes parents once and has no repeated full-project clone. Recommended follow-up: measure representative large nested selections before choosing the preview path; if needed, skip identity remapping for an unchanged placement UUID after the handler's existing-node check, or apply validated geometry records through a preview path without full session execution. Do not widen this into unrelated transaction refactoring.
|
||||
|
||||
2. **P3 — new-planner tests do not directly exercise projection-derived ancestry differing from semantic ancestry.** Current nested coverage uses an Activity child (`group_movement.rs:27`, `:148`). `projection::parents` specifically replaces work ancestry with allocation grouping and makes Traceability placements flat (`projection.rs:94–138`). The planner correctly calls that authoritative helper, so no divergent implementation was found. A compact follow-up fixture should select an allocated performer plus a selected work descendant and assert all visually grouped work moves exactly once without changing allocations; an equivalent Traceability fixture should assert an unselected semantic child remains stationary. Existing `release_routing.rs:403–494` covers portions of Allocation grouping through old `MoveNode` and route helpers, but not the new command planner's final whole-model result.
|
||||
|
||||
3. **P3 — the new end-to-end preservation fixture uses default attachment/port-anchor data.** The route fixture always has `source_attachment: None` and `target_attachment: None` (`group_movement.rs:7–11`); the typed-port case has no authored `port_anchors` (`:221`). Clone-and-change implementation preserves them by inspection, and label metadata is already exercised. Populate at least one manual edge attachment and one owner-port anchor, then retain the whole-project equality and source round-trip assertion. This would make the contract's authored attachment preservation observable rather than relying exclusively on inspection.
|
||||
|
||||
## Correctness traced to authoritative behavior
|
||||
|
||||
- Admission checks the diagram, nonempty set, every exact placement membership, and finite delta before the zero-motion return (`diagram_edit.rs:18–36`). A foreign or missing selected placement cannot disappear into a partial move, including zero delta. Typed visual-target admission, visibility and stale document/revision checks remain adapter responsibilities; the helper intentionally receives placement IDs only.
|
||||
- The closure in `:38–50` traverses `projection::parents` with a deduplicating visited set. It is equivalent to the existing `descendant_nodes` ancestry relation for accepted project snapshots, includes folded placements because folding is not used to filter `parents`, and cannot double-translate overlapping selected roots. It correctly preserves Traceability's flat projection and Allocation's visual parenting by delegation.
|
||||
- Route derivation in `:51–52` uses the original view and the complete union of displacement entries. `translated_routes` resolves actual endpoint owner placements, including ports, requires both owners to move by equal delta, handles State Entry/Exit containing boundaries, and clones changed routes (`projection.rs:169–224`; `projection/state.rs:119–144`). One-ended routes remain exact; label offsets and attachment fractions are retained. This addresses the independent-root failure of sequential `MoveNode` calls.
|
||||
- Each placement clone changes only x/y (`diagram_edit.rs:55–78`). Bounds match existing validation: finite values within `MAX_COORDINATE`, positive bounded dimensions (`validation.rs:820–840`). Translated route pins are checked before returning a batch. Partial command construction cannot mutate the immutable input or escape on error.
|
||||
- `EditNode` with the unchanged ID retains provenance; `SetEdgeRoute` updates its existing entry in place, retaining unrelated route ordering (`session.rs:749–755`). Batch execution prepares one candidate, validates the final state, and publishes one history entry only if the candidate changed (`session.rs:172–198`, `:250–269`, `:386–395`). The planner's returned command-selection ID is the last changed record rather than the user's primary; its doc comment correctly warns adapters not to adopt it as primary selection.
|
||||
- Desktop `Document::execute` performs source update before committing (`document.rs:322–335`), so using this existing command path preserves model/source atomicity. The planner does not itself enforce source-draft or focus guards and should not be treated as doing so. Existing `Document::commit` suppresses unchanged source bundles (`:399–406`); the new zero-motion test directly proves only the core Session contract, not a desktop gesture/source-draft integration.
|
||||
|
||||
## Test review and evidence limits
|
||||
|
||||
The main sibling test (`group_movement.rs:71`) independently assigns three exact expected positions and the internal route pin, then compares the entire Project. It proves intent independently of `translate_placements`, rather than comparing a preview and commit that share the same bug. It also includes a useful negative control showing sequential old moves retain the original internal pin, one Undo/Redo, JSON round-trip, and source-update/recompile preservation with a comment. The typed-port test independently constructs its complete expected Project (`:221`).
|
||||
|
||||
The folded-overlap test partly compares two planner outputs, but also independently asserts the hidden child's exact position and unchanged routes (`:148`). The invalid-input test covers unknown diagram, empty/mixed/missing membership, nonfinite/overflow deltas, translated route overflow, no mutation, and zero history (`:183`). The State boundary test checks independent expected route coordinates when the boundary moves and unchanged pins when it does not (`:269`); it is intentionally narrower than a whole-project comparison.
|
||||
|
||||
The input immutability assertions are inherently weak by themselves because the helper takes `&Project`; their value is secondary to the complete expected-result assertions. No test-run result was inferred from reading test source. No GUI adapter, mixed target-set admission, allocation reassignment, Process/Scenario reorder, stale input, preview latency, or full feature completion was reviewed or accepted here.
|
||||
|
||||
Overall: the frozen foundation implements the selected pure-geometry contract coherently. Retain the pending performance check and targeted coverage extensions while proceeding to adapter work; neither a successful compile nor this review alone establishes interaction acceptance.
|
||||
@@ -0,0 +1,62 @@
|
||||
# Independent ordinary saved-view multi-selection review — 2026-09-23
|
||||
|
||||
Read-only review of the in-progress ordinary saved-view adapter against `multiselect-contract-20260923.md`. The owning session was actively wiring UI during inspection; references describe the inspected code and must be rechecked after fixes. No production edits, Cargo, GUI, or runtime/test acceptance claims. Missing State, Allocation, derived Process/Scenario and aggregate model-delete adapters are explicitly outside this review, not findings below.
|
||||
|
||||
Reviewed primary files: `crates/oa-desktop/src/app/multiselect.rs`, `diagram/multiselect.rs`, `diagram/group_gesture.rs`, `diagram_selection.rs`, plus relevant `app.rs`, `diagram.rs`, `diagram/focus.rs`, `views.rs` and menu paths.
|
||||
|
||||
## Verified findings
|
||||
|
||||
### P2 — surviving selection is discarded when legacy sync removes the primary
|
||||
|
||||
`app/multiselect.rs:78–97` decides whether the group held the primary by comparing the old selection against **post-sync** `selected_node`/`selected`. But `sync_with_alias_editor` clears nonexistent semantic selection and calls `refresh_draft`, which recomputes/clears the placement (`app.rs:800–810`, `:829–853`). Consequently the reducer's correct survivor fallback is overwritten.
|
||||
|
||||
Concrete path: select placement A and B, with B primary; undo the model operation that created B, or remove B's placement through a singleton-target action. `sync` first makes `selected_node = None` (and may select the project). At final reconciliation, `held_primary` is false. `Selection::reconcile` prunes B and chooses A, but `legacy_target` is None, so line 95 clears A too. The same problem occurs when an external accepted model/source change removes the primary while other exact selected placements remain.
|
||||
|
||||
Preserve provenance of the pre-change primary across sync, and distinguish genuine explicit navigation from model/history reconciliation. The latter should prune only invalid members and inspect its surviving fallback. Do not recover by mapping an old semantic UUID to an arbitrary replacement placement. Regression: select A/B, undo B creation; expect A selected and inspected, no additional history. Repeat for placement deletion and semantic deletion, plus deletion of a non-primary member and ordinary group-move Undo/Redo.
|
||||
|
||||
### P2 — mixed selection cannot collapse through the specified stationary click
|
||||
|
||||
`diagram/multiselect.rs:83–97` tests whole-set movement capability on pointer press. For a legal selected set containing placement A and edge E (or a port), a plain press on either member immediately publishes `Refuse`, stores no click gesture, and captures the press. A stationary release therefore cannot execute the contract's "select that member alone" behavior. This is a selection defect inside ordinary saved views, not a request to implement mixed-target movement.
|
||||
|
||||
Track a deferred click for every selected exact target, independently of movement capability. On a stationary release replace with that target; only crossing the drag threshold should refuse unsupported movement while retaining the set. Regression: A+E, click A, expect exactly A and no model/history mutation; recreate A+E and drag A, expect refusal and both retained. Repeat with a port and with pointer displacement that never exceeds the threshold.
|
||||
|
||||
### P2 — a left press can start group movement while middle-button pan owns the pointer
|
||||
|
||||
The new `group_input` runs before the existing singleton left-press branch (`diagram.rs:724` versus the middle-pan guard around `:858`). It has no `state.pan_start` admission check. Reproducible event path from inspection: select A/B, middle-button press sets `pan_start`; while holding middle, left-button press on selected A enters `GroupGesture::new`; subsequent cursor motion is consumed by the group and left release emits a model move even though the middle pan remains active. The legacy singleton handler explicitly captures this left press without moving nodes.
|
||||
|
||||
Keep gesture ownership exclusive before starting or toggling group selection. Reject/defer left presses while middle pan is active, and ensure starting a group cannot coexist with `drag`, `route_drag`, `port_drag`, or work gesture state. Regression should replay middle-down → left-down on selected node → move → left-up → middle-up and assert no geometry/history/selection mutation attributable to a group drag.
|
||||
|
||||
### P2 — Delete hides the group during an active drag
|
||||
|
||||
The active-group dispatcher handles only cursor movement and left release (`diagram/multiselect.rs:27–45`). Delete falls through into the unconditional multi-selection Delete branch at `:54–63`, emitting `Hide(snapshot)` while the live preview exists. App validates that snapshot and hides the placements. By contrast the established singleton Delete branch requires every move/route/port/pan/work gesture to be idle (`diagram.rs:803–813`). Group selection should preserve this input-safety convention.
|
||||
|
||||
Require no live pointer gesture before publishing Hide, or define and test an explicit cancel-then-delete policy. Do not silently delete while the drag's release is still pending. Regression: A/B selected, start group drag and move, press Delete before release; expect no deletion/model history, retain the selection, and verify the subsequent release follows the chosen policy. Cover Delete during middle pan too: the same new branch also lacks `pan_start` gating.
|
||||
|
||||
## Performance observation (P3, no latency measurement)
|
||||
|
||||
Every accepted `App::update` calls `reconcile_multiselect` (`app.rs:1383`), which unconditionally calls `projection::project_view` (`app/multiselect.rs:76`) while on Page::Diagram. That rebuilds `ViewIndex::new`, including the global BehaviorIndex/evidence and all edge facts (`oa-core/src/view_options.rs:484–543`). Thus each pan delta, inspector keystroke and unrelated asynchronous completion now incurs global semantic indexing plus projection, even when surface/model visibility and selection are unchanged. Explicit Choose additionally projects before calling reconciliation and again at end of update.
|
||||
|
||||
Reuse `document.view_index()` via `project_view_with_index`, and separate revision/surface/visibility reconciliation from explicit legacy selection changes. Cache or skip the former when authority and visibility inputs have not changed. This is a verified unnecessary work path; responsiveness has not been measured and no timing claim is made.
|
||||
|
||||
## Sound portions and boundaries
|
||||
|
||||
- `Selection` stores insertion-ordered exact typed targets, maintains primary membership, prevents Whole admission, and uses a generation plus exact snapshot/context comparison to reject remove/re-add and primary ABA cycles. The reducer's survivor fallback itself is correct; the defect is App's later legacy reconciliation.
|
||||
- `placements` collects an `Option<BTreeSet<_>>` over the full target set, so movement/hide cannot silently filter ports or edges. Selection events are checked before App's general allocation/camera boundaries, and operation events require the exact captured selection generation.
|
||||
- `GroupGesture` freezes viewport and press geometry, consumes release position, latches threshold crossing, preserves a group after return-to-origin or snap-to-zero, and permanently retires invalid/outside pointers. Ordinary group input checks current selection snapshot and viewport before processing it. Preview applies only the geometry planner's emitted records, avoiding the full session-per-frame identity work identified in the core review.
|
||||
- The existing ordered `state.pointer` normalization occurs before `group_input`, preserving native event-order handling rather than using the final batch cursor for earlier presses/releases.
|
||||
- Escape, viewport/context invalidation, window focus loss, cursor leave, wheel, and right/middle press cancel an existing group before committing. App independently denies dirty drafts, busy/modal ownership, active architecture editors and connections. The newly passive modifier path is appropriate for Shift held before canvas focus; it does not grant keyboard command focus.
|
||||
- Model/source transactions still flow through `App::execute` and `Document::execute`. Successful group movement restores the exact primary after the command's implementation-selected last record; zero movement returns before execution. Source failure is not accepted as a successful group operation.
|
||||
|
||||
Future verification should use mounted App and native-ordered canvas-event tests for the four findings, then actual GUI exercise. Reducer and gesture unit tests alone cannot expose the cross-layer sync and competing-gesture paths above. Existing known incomplete adapters are neither accepted nor reopened by this review.
|
||||
|
||||
## Follow-up inspection while desktop-1 gate runs
|
||||
|
||||
The owner has addressed the reported paths in source. This status is static inspection only; the review findings above remain useful regression definitions and are not asserted to be outstanding in the updated source:
|
||||
|
||||
- App reconciliation now detects an invalid exact primary before pruning and retains the reducer's survivor fallback when legacy sync has no target.
|
||||
- A typed deferred `multiselect::Press` preserves mixed-selection stationary clicks; movement refusal occurs only after threshold crossing.
|
||||
- An active group captures keyboard input, so Delete does not fall through to Hide; existing Escape cancellation still precedes that capture.
|
||||
- Group admission yields to an existing `pan_start`, preserving the legacy middle-pan exclusion and Delete guard.
|
||||
- App reconciliation is conditional on its selection/model/surface boundary and uses the document's cached view index.
|
||||
|
||||
Eleven production-canvas event regression tests have been drafted in the local-only `.local/multiselect-canvas-tests.rs` for later authorized incorporation into `diagram/multiselect_tests.rs`. They exercise actual `DiagramCanvas::update`, including ordered native batches, captured status, preview records, snapshots and independent literal geometry expectations. Coverage includes Shift hit identity, 0.5/1/2 zoom grouped movement, mixed click versus drag, Escape, Delete during drag, middle-pan exclusivity, seven viewport/context changes, membership ABA, release-only displacement and return-to-origin. The parent owns App-level survivor tests, module registration, compilation and native verification. No draft test has been compiled or run by this reviewer; no production test file was created during the frozen-input gate.
|
||||
@@ -0,0 +1,133 @@
|
||||
# Fresh ordinary saved-view selection review — 2026-09-23
|
||||
|
||||
Independent static correctness review of the local, uncommitted feature on
|
||||
`codex/multiselect-9e8r6t-20260923`, based on
|
||||
`b8685416f56e53e30bd11ecf93a54cea10ac8458`. Scope is ordinary saved views only,
|
||||
against [the implementation contract](multiselect-contract-20260923.md).
|
||||
State, Allocation, Process/Scenario adapters and aggregate model deletion are
|
||||
known incomplete work and are not findings in this review. The pure core
|
||||
movement planner has its [separate review](multiselect-core-review-20260923.md).
|
||||
|
||||
Reviewed the selection reducer, App adapter, canvas adapter and gesture, focus
|
||||
owner, App/menu/view integration, selection and focus tests, and adjacent
|
||||
selection/sync/route/navigation paths. The initial review did not edit production
|
||||
or test source. The parent subsequently delegated the bounded correction below.
|
||||
No test execution, builds, GUI operation, Gitea update or publication were
|
||||
performed by this reviewer.
|
||||
|
||||
## Findings
|
||||
|
||||
### P2 — starting passive I/O loses Shift that was already held
|
||||
|
||||
`crates/oa-desktop/src/diagram/focus.rs:93–97` replaces the wrapped canvas tree
|
||||
with `Tree::new` when suspension starts. This intentionally retires pointer
|
||||
gestures, but also resets the newly added `State.modifiers`
|
||||
(`diagram.rs:69–72`) to its default. The only normal assignment of a nonempty
|
||||
modifier state is receipt of `ModifiersChanged` (`diagram.rs:615–617`), and
|
||||
the toggle decision reads that retained state (`diagram/multiselect.rs:208`).
|
||||
The wrapping view supplies `app.busy` as suspension (`views.rs:511–516`).
|
||||
|
||||
Concrete event path: select A; deliver `ModifiersChanged(SHIFT)` while a clean
|
||||
Inspector field owns focus; start Save/export through a mouse action while
|
||||
continuing to hold Shift; let busy become true and then false; click B without
|
||||
an intervening modifier transition. The tree replacement discards Shift, so
|
||||
the click follows the plain selection path and replaces A with B instead of
|
||||
adding B. The same loss occurs if the canvas owned focus before I/O. No stale
|
||||
model mutation is needed to trigger it.
|
||||
|
||||
The existing test
|
||||
`mounted_suspended_modifiers_are_passive_and_survive_until_resumed_click`
|
||||
(`app/diagram_focus_tests.rs:329`) sends Shift **after** setting busy and
|
||||
settling, so it verifies a different ordering and cannot catch this path.
|
||||
|
||||
Retain passive modifier state across gesture retirement, or restore it from
|
||||
an owner whose lifetime survives the canvas replacement before the first
|
||||
resumed press. Keep the existing I/O pointer-retirement behavior. Add the
|
||||
mounted replay with Shift delivered before suspension, no modifier events
|
||||
during suspension/resumption, and assert A/B selection, unchanged model and
|
||||
history, and no keyboard focus granted by modifier tracking. This finding is
|
||||
established by the explicit state-reset path; runtime reproduction remains
|
||||
for the owning session's affected gate.
|
||||
|
||||
No other new in-scope correctness finding was established.
|
||||
|
||||
## Rechecked protections
|
||||
|
||||
- `Selection::matches` combines document/surface/revision/epoch, membership,
|
||||
primary and monotonic generation. The App rejects stale operation envelopes
|
||||
before allocation and camera boundary side effects. Choose/Clear use context
|
||||
admission; movement, Hide, refusal and collapse require a captured snapshot.
|
||||
- Full typed-set admission prevents silently dropping edges or ports from a
|
||||
group movement/Hide request. Exact visibility is rechecked at execution.
|
||||
Reconciliation now preserves surviving targets when the exact primary
|
||||
disappears, and does not substitute a new placement of the same semantic
|
||||
element. The Placement-properties identity correction is present in
|
||||
`legacy_target`.
|
||||
- A selected-member press defers primary/Inspector changes. Its stationary
|
||||
release collapses even a mixed group; a threshold-crossing mixed drag
|
||||
refuses while retaining membership. Threshold crossing remains latched for
|
||||
cancellation, zero movement and return to origin. The latest
|
||||
`Press::viewport_matches` delegates to the underlying gesture, so its retired
|
||||
state cannot regain authority.
|
||||
- Ordered pointer normalization precedes group handling. Group preview and
|
||||
release consume the same captured geometry and planner. Active groups
|
||||
consume keyboard input; Escape cancels before a subsequent Escape clears.
|
||||
Middle pan excludes group admission. Context, selection generation,
|
||||
viewport and window/pointer lifecycle changes retire the gesture.
|
||||
- Context-menu opening/dismissal preserves membership; Add/Remove uses the
|
||||
captured target, and selected-member group menus expose aggregate Hide and
|
||||
explicit inspection/collapse. App guards prevent queued singleton Move,
|
||||
diagram removal or model Delete from acting on one member of a new group.
|
||||
The legacy Hide action redirects to the whole visible set.
|
||||
- Ordinary passive modifiers are forwarded without acquiring keyboard focus,
|
||||
including while another mounted field owns focus. Mounted tests cover this
|
||||
primary requirement and unfocused command denial. The finding above concerns
|
||||
the separate tree-replacement lifetime.
|
||||
- The earlier unconditional global reconciliation cost is removed. Boundary
|
||||
changes trigger reconciliation and projection reuses `document.view_index()`.
|
||||
No latency or scale-performance measurement is claimed here.
|
||||
|
||||
## Acceptance limits
|
||||
|
||||
The parent reports desktop-3 passed 1,254 tests with zero failures and six
|
||||
ignored on its recorded 729-file input set, followed by the small
|
||||
`Press::viewport_matches` change. That report was context for this review;
|
||||
this reviewer did not rerun or independently certify it. The parent owns the
|
||||
affected gate, core/native checker result, native interaction verification and
|
||||
integration acceptance. This static review cannot establish actual Windows
|
||||
focus behavior, visual clarity, save/reopen usability, or performance.
|
||||
|
||||
The review inspected `diagram/multiselect.rs` with SHA-256
|
||||
`C8ADF20046176F620CAEF52DD8ED93EEDB349F72D8C9F50401AAA440848803B4`,
|
||||
`diagram/focus.rs` with
|
||||
`16AA9C6254BE7E30C67A71C0D5FE5FC763F40923912154209907D0FA17E75889`, and
|
||||
`app/multiselect.rs` with
|
||||
`D3CA4A2D593CB15CEDD40897BBCCF410111D9E54AD8ADFE9AD985B47611B0B0B`.
|
||||
These identify local reviewed contents, not a published commit.
|
||||
|
||||
## Delegated correction after review
|
||||
|
||||
The parent authorized edits only to `diagram/focus.rs`,
|
||||
`app/diagram_focus_tests.rs` and this note. The focus owner now stores passive
|
||||
modifier state independently of the child canvas and marks it for restoration
|
||||
when suspension replaces the child. Before the next admitted child event it
|
||||
replays `ModifiersChanged` without acquiring keyboard focus. A genuine modifier
|
||||
change updates that retained state and window unfocus clears it. The existing
|
||||
subtree replacement, pointer retirement, suspended-input rejection and keyboard
|
||||
ownership checks are preserved.
|
||||
|
||||
Two mounted regression tests were added in source:
|
||||
|
||||
- `mounted_shift_held_before_suspension_survives_without_another_modifier_event`
|
||||
holds Shift with Inspector focus before I/O, crosses busy/resume without a
|
||||
modifier transition, and verifies that the next click selects A/B without
|
||||
model/history/draft changes or passive focus acquisition.
|
||||
- `mounted_shift_release_or_window_unfocus_during_suspension_does_not_restore_stale_shift`
|
||||
covers release and window-unfocus while busy, then another suspension cycle
|
||||
to prove the retained owner state was updated rather than restoring stale
|
||||
Shift. The resumed click must select B alone with unchanged model/history.
|
||||
|
||||
The correction and test source were statically inspected; the parent owns
|
||||
compilation, execution and acceptance. The P2 above describes the pre-correction
|
||||
path and remains the regression rationale, not a claim that the new source
|
||||
still has that path.
|
||||
@@ -0,0 +1,277 @@
|
||||
# Independent native evaluator review — 2026-09-23
|
||||
|
||||
Read-only review of `crates/oa-core/examples/multiselect_native.rs` at
|
||||
`12a7352ddef5f69a144ead8219a50916233d63a9`, the local native preparation,
|
||||
snapshot and capture helpers, and the owning session's proposed UI sequence.
|
||||
Only this note was edited by the reviewer. No builds, tests, UI actions or
|
||||
Gitea operations were run. The parent reports core, full desktop and lint gates
|
||||
passed; no native run was available for acceptance at this review checkpoint.
|
||||
|
||||
## Finding and correction
|
||||
|
||||
**P2, corrected in the helper during review:** the original
|
||||
`.local/multiselect-native/prepare-native.py:38–41` recorded the GUI exit code
|
||||
and binary/fixture immutability booleans, then returned normal helper success
|
||||
even if the GUI crashed or a recorded input changed. This could mislead a
|
||||
consumer using process exit status as the gate. The parent added assertions at
|
||||
lines 42–43 requiring GUI exit zero and all three immutability conditions after
|
||||
writing the durable receipt. Re-reading the helper confirmed the correction.
|
||||
No production source or build input changed for this correction, and it was
|
||||
not runtime-tested by this reviewer.
|
||||
|
||||
No additional actionable checker correctness defect was established. A native
|
||||
acceptance result still requires the actual UI sequence and evidence below;
|
||||
successful launcher completion alone is insufficient.
|
||||
|
||||
## Independence and falsification coverage
|
||||
|
||||
- The expected Project starts from the loaded baseline and changes explicitly
|
||||
enumerated fixture records. Movement expects A, B and the child to move once,
|
||||
translates only the internal route's pins, and preserves C, one-ended route
|
||||
pins, attachments, labels, all semantic records, traces and other views.
|
||||
Hide removes only A/B placements. It preserves the unselected child, stored
|
||||
routes and model objects. Neither expectation invokes the group planner,
|
||||
descendant traversal or routing implementation.
|
||||
- `equal` compares the complete Project, not a selected geometry subset.
|
||||
Validation, JSON roundtrip and source compilation/roundtrip supplement that
|
||||
comparison. Completeness warnings in this deliberately partial fixture are
|
||||
allowed; source discovery errors and compilation failures are rejected.
|
||||
- The ten checker tests include positive generation/source/command examples
|
||||
and multiple valid-model corruption falsifiers. The negative cases cover
|
||||
an omitted or double-moved child, stranded internal pins, moved one-ended
|
||||
pins, attachment/label drift, semantic and trace drift, other-view and fixed
|
||||
node changes, invalid movement evidence, destructive Hide and unchanged-mode
|
||||
semantic drift. Corruptions are validated before comparison and most tests
|
||||
require the specific whole-Project mismatch, avoiding a fake pass caused
|
||||
only by malformed input rejection. Planner use is confined to generating
|
||||
positive/corrupted test candidates, not expected evaluator output.
|
||||
- Fixture UUIDs are deterministic, generation refuses an existing output
|
||||
directory, and evidence writes are exclusive. Preparation validates binary
|
||||
hashes against successful unchanged-input build receipts and requires the
|
||||
GUI and checker input-manifest hashes to match. The inspected checker
|
||||
receipt names the reviewed commit; the GUI's then-current started receipt
|
||||
records the same input hash. Final build/run results remain the parent's gate.
|
||||
- The snapshot helper copies source into a unique directory, compares the
|
||||
source file set and hashes before/after copying and after checking, pins the
|
||||
checker and baseline, and propagates evaluator failure. Source snapshot
|
||||
evaluation is separate from UI capture, as it should be.
|
||||
|
||||
## Acceptance limits for the planned sequence
|
||||
|
||||
The parent plans native context Add for A/B, actual drag, saved-model checks,
|
||||
one-step Undo/Redo with exact source-byte comparison, undo back to baseline,
|
||||
group Hide with Undo/Redo, and reopen. That is appropriate evidence for the
|
||||
ordinary saved-view path with these explicit limits:
|
||||
|
||||
1. **The movement checker is an invariant evaluator.** It infers the common
|
||||
delta from saved A and checks bounds, nontrivial length and anchor grid
|
||||
alignment. A wrong drag direction or magnitude can pass if all required
|
||||
records share the same admissible delta. The code and manifest already
|
||||
state this limit. Record native press/release and viewport observations if
|
||||
claiming intended displacement accuracy; otherwise claim only the verified
|
||||
common translation and preservation invariants. Existing pointer/gesture
|
||||
tests provide separate evidence and are not native coordinate evidence.
|
||||
2. **Model equality does not prove an action happened.** Capture one actual
|
||||
Undo or Redo and completed Save between the appropriate snapshots. An
|
||||
unchanged check alone cannot distinguish successful Undo from never making
|
||||
an edit. Reopen must actually load the saved working source in the GUI and
|
||||
demonstrate its state; checker roundtrip alone does not verify GUI reopening.
|
||||
3. **Exact source-byte restoration is a separate comparison.** The snapshot
|
||||
helper records each source inventory but does not compare different stages.
|
||||
Explicitly compare all relative paths and hashes: Undo movement to the
|
||||
original fixture source, Redo movement to the first moved snapshot, and
|
||||
corresponding baseline/hidden inventories for Hide history. A Project PASS
|
||||
does not establish identical source formatting or file layout.
|
||||
4. **Join provenance rather than assuming it.** Tie the captured native window
|
||||
to the launched PID/executable in `started.json`, identify the opened working
|
||||
entry, retain the build/input receipts, and require the final fixture and
|
||||
binary immutability checks. The checker verifies the expected fixture project
|
||||
ID, not a baked-in baseline hash; baseline authenticity comes from the
|
||||
generated fixture inventory and unchanged final receipt. Capture JSON and
|
||||
screenshots alone do not establish which binary produced saved source.
|
||||
5. **This fixture is OA Activities on an ordinary Overview.** Native context
|
||||
Add verifies that explicit alternative, not held-Shift injection (unavailable
|
||||
through the chosen native tool). Mounted focus tests separately exercise
|
||||
Shift before canvas focus. The native run does not establish an SA Function
|
||||
workflow, selected ancestor-plus-child deduplication, mixed-port/edge refusal,
|
||||
busy suspension behavior or every cancellation path. Existing targeted tests
|
||||
remain separate evidence for those cases; no additional broad native claims
|
||||
should be inferred.
|
||||
6. **Scope remains incomplete beyond ordinary saved views.** State, Allocation,
|
||||
Process/Scenario and aggregate model deletion are not accepted by this
|
||||
checker or planned native sequence. Their absence is not a new finding in
|
||||
this bounded review.
|
||||
|
||||
These limits qualify what the evidence proves. They do not require replacing
|
||||
the independently enumerated Project oracle or broadening this native fixture
|
||||
into the unfinished adapter families.
|
||||
|
||||
## Native layout follow-up: selection changes move the canvas
|
||||
|
||||
**P2 — group feedback changes the diagram viewport while merely selecting.**
|
||||
The parent subsequently supplied native run-1 captures. I independently viewed
|
||||
`.local/multiselect-native/run-1/06-select-a-0.jpg` and
|
||||
`08-group-selected-0.jpg`; both capture the same native window at 1442 by 932.
|
||||
The canvas begins near y=314 in the singleton capture and y=370 after forming
|
||||
the group. A's header moves from approximately y=484 to y=511. The group count,
|
||||
Hide/Clear actions and primary-properties explanation are readable, but adding
|
||||
them has moved the diagram under the user's pointer. This is a presentation
|
||||
finding; it does not assert that selecting changed saved geometry.
|
||||
|
||||
The source cause is explicit: `views.rs:434–461` conditionally appends a two-row
|
||||
group block above the View content control and canvas when the target count
|
||||
exceeds one. The additional block is about 55 pixels high. The observed node
|
||||
shift is smaller than the viewport-top shift, consistent with the canvas resize
|
||||
and current fitted camera reacting to the reduced height. Removing the final
|
||||
group member or clearing selection reverses the same conditional layout.
|
||||
|
||||
The ordinary idle hint (`views.rs:654`) also mentions right-click, drag and zoom
|
||||
but gives no visible Shift-click discovery cue; neither inspected screenshot
|
||||
shows such a cue. Context Add is usable, but the user's requested modifier
|
||||
gesture should be discoverable directly in the ordinary selection UI.
|
||||
|
||||
### Smallest space-conscious correction
|
||||
|
||||
Move the group feedback into an **always-mounted, fixed-height selection/status
|
||||
strip at the existing fixed 30-pixel hint location** near `views.rs:658`. Remove
|
||||
the conditional group block from the toolbar. A roughly 56-pixel strip can
|
||||
contain:
|
||||
|
||||
- A fixed approximately 32-pixel first row with selection count, the existing
|
||||
group Hide/Clear controls when applicable, and the short explicit disclosure
|
||||
`Properties edit <primary> only`. Use a bounded/nonwrapping primary label so
|
||||
long names or a mixed selection cannot increase strip height. Empty and
|
||||
singleton states keep the same row allocation even with fewer controls.
|
||||
- A short second line: `Shift-click to add/remove · drag selected to move ·
|
||||
right-click for actions`. Existing active connection, allocation and route
|
||||
instructions retain precedence when those modes own input; ordinary idle
|
||||
selection should expose the modifier hint.
|
||||
|
||||
This reuses the existing status location and consumes about 26 more vertical
|
||||
pixels than today's singleton layout, while recovering about 29 pixels from
|
||||
today's group layout. The shown central workspace is approximately 860 pixels
|
||||
wide, sufficient for these compact controls and a bounded primary label without
|
||||
adding a third row. A fixed outer height, fixed first-row height and nonwrapping
|
||||
text keep the canvas rectangle identical for zero, one and multiple targets.
|
||||
Keep this strip outside the canvas; an overlay would obscure diagram content
|
||||
and complicate hit testing. Simply reserving the current extra 55-pixel toolbar
|
||||
block would also stop the jump, but spends more permanent vertical space than
|
||||
reusing the existing status strip.
|
||||
|
||||
The narrow verification target is mounted canvas-bounds equality across
|
||||
0→1→2→1→0 selection transitions, including mixed selection and a long primary
|
||||
name, with unchanged model/history for those selection changes. A native
|
||||
before/after capture should confirm stationary node screen positions and readable
|
||||
controls at the same 1442-pixel window width. No source changes, build or native
|
||||
interaction were performed by this reviewer for this follow-up.
|
||||
|
||||
### Delegated layout correction
|
||||
|
||||
The parent subsequently authorized edits to `views.rs`,
|
||||
`app/diagram_focus_tests.rs` and this note. The conditional toolbar group block
|
||||
is removed. Every active ordinary diagram now reserves a 56-pixel strip in the
|
||||
former hint location with a fixed 32-pixel controls row. Count is always shown;
|
||||
the existing group Hide/Clear intents and primary-only property explanation are
|
||||
shown for groups. The primary name remains shortened and all strip text is
|
||||
nonwrapping. Hide still requires whole-set placement admission and all App
|
||||
event guards are unchanged.
|
||||
|
||||
Idle selection hints expose Shift-click add/remove. Supported ordinary placement
|
||||
groups advertise dragging selected elements together; mixed groups and Allocation
|
||||
groups receive selection guidance without claiming a movement adapter they do
|
||||
not have. Active allocation feedback and connection instructions retain priority;
|
||||
singleton route/promoted-edge hints are preserved. The strip remains outside the
|
||||
canvas with clipping, and no overlay or global layout policy was introduced.
|
||||
|
||||
One mounted regression was added in source:
|
||||
`mounted_selection_strip_keeps_canvas_and_fitted_camera_fixed_for_empty_single_group_and_mixed`.
|
||||
It mounts the real App at 1442 by 932 with fitting enabled, a very long primary
|
||||
name and a visible edge, then checks 0→1→2→3 mixed→2→1→0 selection transitions.
|
||||
Each step must retain the exact measured canvas rectangle, zoom, pan, fit flag,
|
||||
observed canvas size, complete Project, source bundle and empty history.
|
||||
The previous implementation changes the canvas rectangle on the 1→2 transition,
|
||||
so this verifies the observed regression through production layout and sensors.
|
||||
|
||||
Only a static inspection and `git diff --check` were performed for these edits.
|
||||
Formatting, compilation, regression execution and a fresh native before/after
|
||||
capture remain owned by the parent. Native run-1 continues against its unchanged
|
||||
pinned binary and cannot serve as verification of this subsequent source edit.
|
||||
|
||||
### Native run-2 visual follow-up: stable selection strip
|
||||
|
||||
I independently inspected the saved images `03-open-submitted-0.jpg`,
|
||||
`05-singleton-settled-0.jpg` and `07-group-selected-0.jpg` under native run-2.
|
||||
This is image inspection separate from the parent's live GUI operation; the
|
||||
inspector also authored the layout correction, so it is not a fresh-author
|
||||
review. No UI actions or source edits were performed for this check.
|
||||
The run records source `f6a6010baed9e6164a1fcc7ecd58256a562827d7` and GUI SHA-256
|
||||
`49396ee5c24f749cbee54e1fd77e15f5d73b0e19cf246724216ce7464d3deba5`.
|
||||
|
||||
At the recorded 1442 by 932 window size, all three images show the canvas top
|
||||
near y=341 and the zoom at 84%. A's header remains near y=497, B near y=531,
|
||||
the child near y=606 and C near y=656 across zero, one and two selected targets.
|
||||
Selection outlines and the Inspector change; the diagram and canvas do not
|
||||
visibly jump. This corrects the run-1 singleton/group viewport and node shifts
|
||||
documented above. Pixel positions are approximate visual readings, not a new
|
||||
instrumented layout measurement.
|
||||
|
||||
The strip clearly shows `0 selected`, `1 selected` and `2 selected` respectively.
|
||||
The group image displays readable `Hide selected`, `Clear selection` and
|
||||
`Properties edit only B - assess request` on one line with space remaining.
|
||||
The second line exposes Shift-click add/remove in each state, and the group
|
||||
state additionally says to drag selected elements to move. No wrapping,
|
||||
overlap, clipped action caption or obscured diagram content is visible in these
|
||||
captures. No new visual correction is indicated for this shown window size.
|
||||
|
||||
This follow-up establishes only the shown 0/1/2 selection layout and displayed
|
||||
zoom stability. It does not yet claim run-2 movement, Hide, history, saved-model
|
||||
checks or completed process lifetime; those actions were still pending at the
|
||||
time of this image inspection. Long-name/mixed-selection and exact camera/model
|
||||
invariants remain separate mounted-test evidence, not claims derived from these
|
||||
three images.
|
||||
|
||||
### Closed run-2 follow-up: Hide, history and reopening
|
||||
|
||||
After run-2 closed, I independently viewed `15-before-hide-saved-0.jpg`,
|
||||
`16-hide-group-0.jpg`, `18-hide-undo-0.jpg`, `20-hide-redo-0.jpg` and
|
||||
`23-reopened-hidden-0.jpg`, and read the nine snapshot checker receipts,
|
||||
`history-bytes.json` and `finished.json`. I also rehashed the actual nine source
|
||||
files in each snapshot to compare the three history equality groups, and read
|
||||
the saved moved/hidden OA view source. This remains an evidence inspection;
|
||||
the parent observed the actual UI actions. It does not independently
|
||||
authenticate individual key or pointer events or rerun the model compiler.
|
||||
|
||||
Before Hide, two targets are selected. The hidden captures remove A/B while
|
||||
retaining the child and C; the Explorer retains the engineering activities.
|
||||
Undo restores A/B and their visible relationships, while the transient selection
|
||||
appropriately remains empty. Redo returns to the same hidden visible state.
|
||||
Across these four images, the canvas top stays near y=341, zoom remains 84%,
|
||||
and child/C positions stay near y=606/y=656. Returning from two selected to zero
|
||||
therefore no longer causes the run-1 viewport jump. The count, status text and
|
||||
Inspector change without crowding or covering diagram content. No further
|
||||
material visual defect was established in these captures.
|
||||
|
||||
The reopened hidden image still has the same canvas allocation and only child/C
|
||||
visible. Its zoom is 88% and the nodes have been refitted into the available
|
||||
space. This is a reopened transient-camera change, not evidence of changed saved
|
||||
placement geometry: the reopened source is byte-identical to the other hidden
|
||||
snapshots and its complete-Project Hide check passes.
|
||||
|
||||
All nine checker receipts report exit zero and PASS, with unchanged binary,
|
||||
working source and baseline pins. The movement and movement-Redo reports infer
|
||||
the same `(100, 60)` delta. The saved moved view explicitly contains A at
|
||||
`(180, 140)`, B at `(570, 180)` and the child at `(220, 270)`; C remains at
|
||||
`(780, 270)`. Internal pins translate, while the external pins and relative
|
||||
route metadata stay unchanged. The hidden view retains both stored routes and
|
||||
the baseline child/C placements while omitting A/B placements.
|
||||
|
||||
My independent source-inventory hash comparison confirms the recorded byte
|
||||
groups: baseline = movement Undo = before Hide = Hide Undo; movement = movement
|
||||
Redo; hidden = Hide Redo = reopened hidden. Each snapshot contains nine files.
|
||||
The history receipt reports that only `views/diagram_oa_view.arcadia` differs
|
||||
from baseline for movement and Hide. The final lifecycle receipt records clean
|
||||
exit zero, unchanged GUI/checker/fixture and empty application stdout/stderr.
|
||||
There are 24 numbered run-2 capture pairs. These results support the stated
|
||||
saved-state and visual relationships together with the parent's action
|
||||
observations; the invariant-oracle, native Shift, SA and unfinished-adapter
|
||||
limits documented above continue to apply.
|
||||
@@ -0,0 +1,218 @@
|
||||
# Ordinary saved-view multiple selection — verification report
|
||||
|
||||
**Verified branch contribution, not full issue acceptance.** This report covers the ordinary saved-view
|
||||
subset of [issue 29](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/29).
|
||||
Final source is `f6a6010baed9e6164a1fcc7ecd58256a562827d7` on
|
||||
`codex/multiselect-9e8r6t-20260923`, based on the published State/attachment
|
||||
checkpoint `b8685416f56e53e30bd11ecf93a54cea10ac8458`. The final Windows desktop
|
||||
suite, strict lint, formatting, build and corrected-layout native run passed.
|
||||
Integration with the release owner's later union remains separate work.
|
||||
|
||||
## Evaluated source and scope
|
||||
|
||||
Native run-1 used commit `12a7352ddef5f69a144ead8219a50916233d63a9` and the
|
||||
729-file input map
|
||||
`55a5f354bb5049a8c77ba9bafa833438ebf754649b0cf5d175d0c8dcfcedd7bb`.
|
||||
Core-3, desktop-4, lint-2, format-1 and both native/checker builds share that
|
||||
exact input map. Earlier test receipts record HEAD
|
||||
`b8685416f56e53e30bd11ecf93a54cea10ac8458` because the feature was still
|
||||
uncommitted; the captured source map, rather than HEAD alone, identifies their
|
||||
tested contents.
|
||||
|
||||
The corrected layout verification uses a distinct 729-file input map,
|
||||
`99dc46645236e674244e7c4867595a6000d3d85b54bcc9ab2b0c601923ecf66c`,
|
||||
captured by desktop-5 at HEAD `12a7352ddef5f69a144ead8219a50916233d63a9`.
|
||||
An exact Git-blob comparison subsequently confirmed the complete captured input
|
||||
inventory equals commit `f6a6010baed9e6164a1fcc7ecd58256a562827d7`.
|
||||
Results for the earlier map do not establish the changed layout's behavior.
|
||||
Desktop-5, lint-3, format-2, native-build-2 and windows-build-2 all use the
|
||||
corrected map. Native run-2 operated that Windows build; its 117,519,360-byte
|
||||
executable has SHA-256
|
||||
`49396ee5c24f749cbee54e1fd77e15f5d73b0e19cf246724216ce7464d3deba5`.
|
||||
|
||||
The implemented subset provides exact transient saved-view selection with a
|
||||
primary Inspector target, Shift toggling and contextual Add/Remove, deferred
|
||||
stationary-click versus group-drag behavior, simultaneous placement movement,
|
||||
and group Hide that preserves model objects. The complete typed selection is
|
||||
admitted or refused; ports/edges are not silently filtered from a group action.
|
||||
Captured operations reject stale document, revision, surface and selection
|
||||
generation. Ordinary property edits remain primary-only. Movement uses one
|
||||
atomic transaction and the same pure plan for preview and commit.
|
||||
|
||||
Required behavior and remaining adapter families are defined by the
|
||||
[contract](multiselect-contract-20260923.md). Design and detailed review evidence
|
||||
remain in the [core review](multiselect-core-review-20260923.md),
|
||||
[desktop review](multiselect-desktop-review-20260923.md),
|
||||
[fresh review](multiselect-fresh-review-20260923.md), and
|
||||
[native evaluator/layout review](multiselect-native-review-20260923.md).
|
||||
|
||||
## Closed gates
|
||||
|
||||
The closed receipts below report unchanged captured inputs and successful exit.
|
||||
Windows used `stable-x86_64-pc-windows-gnullvm`, Cargo 1.98.1 and rustc 1.98.1,
|
||||
locked/offline dependencies, and the supported headless desktop renderer.
|
||||
|
||||
| Gate | Command/result |
|
||||
| --- | --- |
|
||||
| core-3 | `cargo test --locked --offline -p oa-core --test group_movement --test state_diagrams --example multiselect_native -- --nocapture`: 7 movement + 13 existing State + 10 evaluator tests passed; zero failures. State test passage does not accept a State group UI adapter. |
|
||||
| desktop-4 | `cargo test --locked --offline -p oa-desktop -- --nocapture`: 1,256 passed, zero failed, six ignored. Includes mounted focus, passive modifiers across I/O, selection/history and pointer-lifecycle regressions. |
|
||||
| desktop-5 | Same desktop command, corrected layout: 1,257 passed, zero failed, six existing ignored. The added mounted regression preserves canvas bounds, fitted camera, complete Project, source and empty history across empty/single/group/mixed selection. |
|
||||
| lint-2 | `cargo clippy --locked --offline -p oa-core -p oa-desktop --all-targets -- -D warnings`: passed. |
|
||||
| lint-3 / format-2 | Strict all-target Clippy for core/desktop and `cargo fmt --all -- --check` passed on the corrected layout input map. |
|
||||
| format-1 | `cargo fmt --all -- --check`: passed. |
|
||||
| native-build-1 / windows-build-1 | Locked/offline checker example and Windows desktop executable builds passed, with matching input maps and recorded executable hashes. |
|
||||
| native-build-2 / windows-build-2 | Both builds passed from final source `f6a6010`, with matching corrected-layout inputs and executable pins. |
|
||||
|
||||
Failures were retained and corrected, not counted as passing evidence:
|
||||
desktop-1 exposed module visibility/menu-token compile errors; desktop-2
|
||||
reported one Placement-properties selection regression (1,247 passed, one
|
||||
failed, six ignored), corrected by recognizing the selected placement identity;
|
||||
core-2 exposed the evaluator incorrectly rejecting ordinary fixture warnings;
|
||||
oracle-2 then exposed an unqualified Severity type; lint-1 rejected an
|
||||
unnecessary clone. Later closed gates above cover the corrected source.
|
||||
Desktop-3's earlier 1,254/0/6 pass preceded the two passive-modifier suspension
|
||||
regressions included in desktop-4. Full historical commands, inputs and logs
|
||||
belong in the checks package, not this summary.
|
||||
|
||||
## Native behavior and saved-state evidence
|
||||
|
||||
The owning session operated each pinned Windows app through contextual Add for
|
||||
A/B, one group drag, Save, movement Undo/Redo with Save, return to baseline,
|
||||
group Hide, Hide Undo/Redo with Save, and reopening the hidden saved source.
|
||||
These are actual Computer Use observations, not inputs independently replayed
|
||||
by the evidence verifier. Run-1 retains 27 numbered native captures and run-2
|
||||
retains 24. Each run has nine saved-source snapshots with evaluator receipts,
|
||||
process/start/finish records and `history-bytes.json`.
|
||||
|
||||
Run-1 records PID 7540 / window 131836; run-2 records PID 5312 / window 20055002.
|
||||
Each capture names the corresponding pinned executable path and consistent
|
||||
window identity. Both closing receipts report exit zero, unchanged GUI/checker
|
||||
binaries and fixture, and empty stdout/stderr. These are joined historical
|
||||
records, not independent OS authentication of every captured input.
|
||||
|
||||
The independent checker compares the complete Project against explicit fixture
|
||||
expectations and validates JSON/source roundtrips. All nine final snapshots
|
||||
in each run (18 total) report PASS with stable source, baseline and checker pins:
|
||||
|
||||
| Saved state | Verified relationship |
|
||||
| --- | --- |
|
||||
| `baseline`, `move-undo`, `before-hide`, `hide-undo` | Equal to the complete baseline Project; all nine source files byte-identical to the fixture baseline. |
|
||||
| `move`, `move-redo` | A, B and A's unselected child share delta `(100, 60)`; internal route pins translate once. C, one-ended pins, attachments, labels, semantic records, traces and other views remain exact. The two source inventories are byte-identical. |
|
||||
| `hidden`, `hide-redo`, `reopened-hidden` | Only A/B placements are removed. The child placement, stored routes, model objects and other views remain exact. These three source inventories are byte-identical. |
|
||||
|
||||
Across both changed states, the only changed source file is
|
||||
`views/diagram_oa_view.arcadia`; the other eight source files remain unchanged.
|
||||
This supports precise saved-state restoration across the observed
|
||||
one-step history sequence. Equality alone does not authenticate Undo button
|
||||
presses or establish history behavior without those observations.
|
||||
|
||||
The fixture is deliberately incomplete engineering content. The parent observed
|
||||
11 warnings: `missing_system` ×1, `unallocated_leaf` ×4,
|
||||
`work_without_behavior_context` ×4, `exchange_without_behavior_context` ×1,
|
||||
and `non_leaf_work_exchange` ×1. The native status shows 11 model checks; these
|
||||
are not presented as new application defects or zero-warning acceptance.
|
||||
The evaluator permits completeness warnings but rejects source errors and
|
||||
compilation failures.
|
||||
|
||||
Run-1 also exposed a real layout issue: forming a group inserted a conditional
|
||||
toolbar block, resized the canvas and shifted nodes without a geometry edit.
|
||||
Independent inspection of captures 06 and 08 confirms the shift. The subsequent
|
||||
correction reserves a fixed selection/status strip, adds a visible
|
||||
Shift-click hint and includes a mounted bounds/camera regression. Run-1's pinned
|
||||
binary is unchanged and cannot verify that correction. Desktop-5 passed, and
|
||||
run-2 then confirmed the corrected behavior: captures 03/05/07 retain the same
|
||||
canvas top (about y=341), 84% zoom, and node positions across empty/single/group
|
||||
selection. The count, Hide/Clear controls, primary-only disclosure and Shift
|
||||
hint are readable at the observed 1442×932 window size.
|
||||
|
||||
Run-2's drag was from `(405,533)` to `(489,583)` in window coordinates, an
|
||||
84-pixel right / 50-pixel down movement at the displayed 84% zoom. The observed
|
||||
geometry moves accordingly; the independent saved-model oracle finds the
|
||||
grid-aligned common world delta `(100,60)`. One Ctrl+Z restores baseline, and one
|
||||
Ctrl+Y restores the moved state. Save and complete-Project checks follow each.
|
||||
Hide at `(377,287)` removes A/B placements while leaving the child and C at their
|
||||
previous screen positions; Undo restores A/B without the former clipping or
|
||||
viewport shift. Zoom remains 84% throughout those edits. Reopening the hidden
|
||||
source performs the normal fresh fit (88%); camera is transient, and the saved
|
||||
Project/source remain exact. Capture labels identify each stage.
|
||||
|
||||
The initial run-2 baseline checker invocation omitted the shell's GNU toolchain
|
||||
runtime setup and exited `3221225781` with empty streams. The same checker and
|
||||
byte-identical source passed after environment setup. The failed snapshot,
|
||||
retry receipts and exact helpers are retained in the separate
|
||||
[incident record](experiments/multiselect-20260923/incidents/README.md); it is not
|
||||
counted as a successful checker attempt or an application crash.
|
||||
|
||||
## Reproduce and inspect
|
||||
|
||||
Use the repository's [Windows build/packaging guidance](../docs/windows-packaging.md)
|
||||
and a configured Rust/GNU runtime environment. Captured runs used Rust/Cargo
|
||||
1.98.1, LLVM-MinGW 20260826, one Cargo job and `CARGO_INCREMENTAL=0`.
|
||||
`--offline` requires the dependency cache to be present. From the source checkout:
|
||||
|
||||
```text
|
||||
cargo test --locked --offline -p oa-core --test group_movement --test state_diagrams --example multiselect_native -- --nocapture
|
||||
cargo test --locked --offline -p oa-desktop -- --nocapture
|
||||
cargo clippy --locked --offline -p oa-core -p oa-desktop --all-targets -- -D warnings
|
||||
cargo fmt --all -- --check
|
||||
cargo build --locked --offline -p oa-desktop --bin arcadia-studio
|
||||
cargo build --locked --offline -p oa-core --example multiselect_native
|
||||
```
|
||||
|
||||
Run the resulting desktop executable with the GNU runtime directory on PATH.
|
||||
The built `multiselect_native` example accepts `generate NEW_DIRECTORY`; keep
|
||||
the generated fixture unchanged, copy its `source` directory to a fresh working
|
||||
directory, and open that copy's `project.arcadia` in the app. After saving each
|
||||
observed operation, evaluate with:
|
||||
|
||||
```text
|
||||
multiselect_native check unchanged FIXTURE/baseline.json WORKING/project.arcadia
|
||||
multiselect_native check moved FIXTURE/baseline.json WORKING/project.arcadia
|
||||
multiselect_native check hide FIXTURE/baseline.json WORKING/project.arcadia
|
||||
```
|
||||
|
||||
Use the mode matching the table above. Preserve separate saved source copies
|
||||
for the nine named stages and compare their exact inventories/bytes in the
|
||||
three stated equality groups. Commands cannot substitute for the actual native
|
||||
selection, movement, history and reopen actions.
|
||||
|
||||
Portable verification from a repository containing the named source commits:
|
||||
|
||||
```text
|
||||
python -B harness/experiments/multiselect-20260923/checks/verify.py
|
||||
python -B harness/experiments/multiselect-20260923/native/verify.py
|
||||
python -B harness/experiments/multiselect-20260923/source/verify.py --repo . --checks harness/experiments/multiselect-20260923/checks
|
||||
```
|
||||
|
||||
The packages retain original logs, inputs, captures, fixtures and snapshot
|
||||
receipts. The source verifier freshly compares all captured Git blobs and the
|
||||
complete filtered inventory at both named commits. These offline checks do not
|
||||
execute archived code or rerun the GUI; consult their READMEs for precise limits.
|
||||
|
||||
## Acceptance limits and delivery
|
||||
|
||||
- Both native runs exercise OA Activities in an ordinary Overview, not a native SA
|
||||
Function workflow. Native selection used contextual Add because the chosen
|
||||
tool cannot inject held-Shift plus click; mounted tests provide separate
|
||||
Shift-before-focus evidence. The App-level multiselect tests separately use
|
||||
SA Functions, a nested Function and a FunctionalExchange in a Functional
|
||||
Dataflow view; that automated coverage is not a native SA interaction claim.
|
||||
- The checker infers a bounded common delta from saved A. It verifies group
|
||||
invariants, not the exact intended pointer destination or drag magnitude.
|
||||
Native action observations, model equality, byte equality and mounted tests
|
||||
are distinct evidence and are not interchangeable.
|
||||
- State, Allocation, derived Process/Scenario group adapters and aggregate
|
||||
model deletion remain incomplete. This report does not close the full issue,
|
||||
accept those families, claim Linux verification, or represent an integrated
|
||||
release. The native runs do not cover every mixed-target, cancellation or
|
||||
suspension scenario exercised by automated tests.
|
||||
- Native layout review covers 1442×932, not every minimum-window, DPI or keyboard
|
||||
accessibility condition. No broad usability acceptance is inferred from this
|
||||
bounded fixture.
|
||||
- Portable evidence is supplied under
|
||||
[checks](experiments/multiselect-20260923/checks/README.md) and
|
||||
[native](experiments/multiselect-20260923/native/README.md), with separate
|
||||
[source identity](experiments/multiselect-20260923/source/README.md) and incident
|
||||
records. The checks package retains 19 closed runs, including five nonzero
|
||||
historical gates. Raw absolute paths in the receipts are historical labels;
|
||||
the versioned packages make the evidence consumable on other machines.
|
||||
@@ -0,0 +1,108 @@
|
||||
# Persisted State multiple-selection adapter
|
||||
|
||||
Scope: next required adapter for [issue 29](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/29), following the [selection contract](multiselect-contract-20260923.md). This is a code-grounded proposal, not implementation or acceptance evidence. Inspected local branch `codex/multiselect-9e8r6t-20260923`, base `b8685416f56e53e30bd11ecf93a54cea10ac8458`, including uncommitted ordinary-selection work on 2026-09-23. Parent owns integration; no build or GUI checks were run for this investigation.
|
||||
|
||||
## Verified interfaces and present workflow
|
||||
|
||||
- `diagram_usage::{Surface, Target}` already represents this surface as `Surface::Saved(diagram)` and a state node as `Target::Placement(placement)`. There is **no** `StatePlacement` variant and none is needed. Action and internal-transition rows have `Target::StateRow { placement, relation }`; rendered transition edges have `Target::Edge(relation)`.
|
||||
- `behavior/state_view.rs::view` constructs `StateCanvas` using `state_context()`, semantic `app.selected`, its own camera/tool, and existing draft guards. It wraps the mapped canvas in the same production `diagram::focus::wrap` as Architecture, including `app.busy` suspension. Its optional unplaced-elements picker deliberately occupies a stable slot to preserve the widget focus tree.
|
||||
- State primary placement lives in `app.behavior.diagram.placement`, not `app.selected_node`. `app/state_diagram.rs::edit_state_diagram` selects semantic identities and clears `selected_node`. `App::refresh_draft` rediscovers the State placement from the semantic identity; therefore exact-target reconciliation must override that discovery when an occurrence disappears or is replaced.
|
||||
- `state_canvas::{hit, node_at}` respects symbol hit shapes, labels, nesting and row priority. `Hit::Node` preserves placement and element; `Hit::StateAction(Id)` and internal rows represented by `Hit::Transition(Id)` currently discard the row's placement identity. Do not infer a group target from semantic kind alone.
|
||||
- `StateCanvas::update` uses ordered window coordinates, a 3-pixel movement threshold, and stored press geometry. It currently selects on a node press and emits one `state_edit::Event::Move` on release. It has distinct attachment, bend, transition, reconnect, creation and pan gestures. Escape resets these gestures and selects the Select tool. Right-click resets provisional geometry before capturing the menu target.
|
||||
- `state_canvas::scene_with_bounds` currently clones the diagram, applies a singleton movement plus descendants and `translated_routes`, then projects and routes the result. Attachment and bend previews are independently cached; bend preview reroutes only its edge against a baseline. `state_edit::plan(Event::Move)` commits through the existing singleton `Command::MoveNode`.
|
||||
- `state_menu::popup` runs before the ordinary group-menu branch in `model_menu::popup`; merely broadening the ordinary App selection context will not provide State group menus. Current State menu offers singleton Hide, semantic deletion and route tools. `app/diagram_removal.rs` also distinguishes a placement Hide from a row/edge's semantic deletion confirmation.
|
||||
|
||||
User workflows to preserve: choose a machine and saved view; inspect state headers, entry/do/exit rows and transitions; select tools to add symbols or reconnect transitions; move composite states with folded children; adjust hard route pins and attachments; Hide placements and recover them through the unplaced picker or Undo. Group selection must coexist with these workflows, not silently reinterpret rows as nodes or alter route tooling.
|
||||
|
||||
## Recommended minimal coherent adapter
|
||||
|
||||
Use the existing transient `Selection` and its snapshot generation without adding model/source schema. Share the App's Saved-view group operations, but make diagram resolution, primary inspection and visibility surface-aware. A State view remains a Saved surface; switching between pages or behavior owners explicitly clears the set even if both resolve to the same diagram UUID.
|
||||
|
||||
Select-tool hit mapping:
|
||||
|
||||
| Rendered hit | Exact target | Initial group movement/Hide capability |
|
||||
| --- | --- | --- |
|
||||
| State, machine, region or pseudostate placement | `Placement(node.id)` | Placement-only set admitted |
|
||||
| Routed transition | `Edge(relation.id)` | Select/inspect; refuse group placement actions |
|
||||
| Entry/do/exit action or internal-transition row | `StateRow { placement, relation }` | Select/inspect; refuse group placement actions |
|
||||
| Navigation highlight / empty content | No edit target | Never promote to selection |
|
||||
|
||||
Keep individual route editing available after choosing a singleton edge. When several targets are selected, selected-edge handles must not bypass the group operation contract: a mixed drag is refused as a whole, and a stationary selected-member click can collapse to that edge before its existing tools are used. Shift-click takes precedence over singleton bend insertion/reconnect hit paths in Select mode. Other active tools keep their existing ownership; switching tools retires gesture snapshots through the canvas epoch.
|
||||
|
||||
Use `diagram_edit::translate_placements` once for the full captured placement set. Apply its returned `Batch(EditNode, SetEdgeRoute)` through `Document::execute` once; after success restore the typed primary instead of adopting the Batch's final selected edge/node. Preview applies those same node/route commands to a cloned diagram and projects with the unchanged project semantics. Do not call `prepare_command` on a cloned whole project for every pointer frame. The ordinary preview already follows this bounded approach and is the reference to factor if sharing becomes simpler than two tiny adapters.
|
||||
|
||||
### Entry/exit geometry is a required distinction
|
||||
|
||||
State projection constrains EntryPoint/ExitPoint display geometry to their owner's boundary, while authored placement positions remain stored. The movement planner must use authored positions and let `projection::project_diagram` derive displayed geometry. `projection::translated_routes` already calls `state::boundary_moves_with`: moving two endpoints does not translate pins if their entry/exit owner's boundary placement remains fixed. Moving the boundary and those descendants together translates pins once. A partial view without the boundary must retain the helper's existing rule; do not invent an implicit boundary placement.
|
||||
|
||||
Do not promise that moving an entry symbol's authored X coordinate causes the same displayed X displacement when its boundary is fixed. Preserve current State projection behavior and verify stored positions, projected symbols and route pins separately. This adapter does not change symbol sizing, container resizing, or route/traceability policies.
|
||||
|
||||
### Pointer helper reuse has one consequential limit
|
||||
|
||||
`diagram/group_gesture.rs::Gesture` provides snapshot capture, viewport validity, irreversible threshold crossing, stationary collapse and return-to-origin no-op handling. Its current displacement is explicitly snapped to a 10-unit grid. Existing State node movement is unsnapped; State bend movement separately snaps to 5 units. Blindly reusing its constructor changes State movement behavior.
|
||||
|
||||
Preferred reversible design: reuse the lifecycle helper with an explicit small displacement policy (`Unsnapped` for State, existing grid behavior for Architecture), or separate displacement calculation from that helper. Preserve bend snapping unchanged. Avoid implementing another subtly different threshold/cancellation state machine merely to preserve the State delta calculation. Shared helper tests must cover both policies, off-grid starting points and out-and-back drags. This is an implementation choice, not a request for user approval.
|
||||
|
||||
## Concrete touch map
|
||||
|
||||
| File / function | Required change | Reusable boundary |
|
||||
| --- | --- | --- |
|
||||
| `app/multiselect.rs::multiselect_context` | Accept `Page::Behavior` only when `state_context()` resolves a real State view; retain Architecture path | Same `Context`, `Scope`, Saved ID, snapshot generation |
|
||||
| `app/multiselect.rs::multiselect_boundary` | Include resolved State view identity and State primary placement so owner/view changes trigger reconciliation | Existing revision/document/page invalidation |
|
||||
| `app/multiselect.rs::{visible, legacy_target, inspect_multiselect_primary, reconcile_multiselect}` | Resolve diagram from live context, use State placement field, admit exact visible State rows; retain lost-primary survivor behavior against semantic remapping | Existing exact placement/edge handling and stale-generation checks |
|
||||
| `app/multiselect.rs::multiselect_event` | Require State Select tool for group selection/move/hide, preserve pending behavior/property/source guards, clear State bend/attachment primary details appropriately | Existing `placements`, atomic movement and Hide transaction |
|
||||
| `behavior/state_edit.rs::Event` and `state_view.rs::event` | Transport typed selection events from `Canvas<Event>` to App; use a small event wrapper rather than duplicating selection commands | Existing `Message::DiagramSelection` handler |
|
||||
| `app.rs::update` early admission | If selection events remain wrapped in `Message::StateDiagram`, validate their envelope **before** allocation/camera/reveal boundaries; alternatively map them to `Message::DiagramSelection` at the widget boundary and still guard any directly constructible nested variant | Ordinary stale-operation guard |
|
||||
| `app/state_diagram.rs::edit_state_diagram` | Route wrapper to group handler without calling generic `apply`; preserve singleton state mutation plans; reconcile after accepted create/delete/Undo | Existing `validate_context`, `execute`, state form guards |
|
||||
| `app.rs` navigation / `reset_state_view_after_navigation` | Clear set on BehaviorDiagram, owner navigation and explicit page transitions; detect implicit fallback to another saved State view | Existing explicit Architecture selection reset |
|
||||
| `behavior/state_canvas.rs::{State, Press, update}` | Passive modifier tracking/reset, exact hit targets, group press lifecycle, no primary changes during selected-member press, Escape cancel-before-clear | Focus wrapper and shared group lifecycle |
|
||||
| `behavior/state_canvas.rs::{Hit, hit, visible_row_relation}` | Preserve row placement in hit result; add exact row visibility predicate that checks this placement and relation, not any row in the diagram | Existing `state_action_rows` / `internal_rows` geometry |
|
||||
| `behavior/state_canvas.rs::{scene_with_bounds, CachedScene, PaintKey, draw}` | Group planned-diagram preview; membership/primary-aware paint keys and rings for nodes, edges and rows; retain separate bend cache | Existing State projection, boundary rules and routing |
|
||||
| `behavior/state_view.rs::view` | Pass selection into StateCanvas; show count and group actions without unstable sibling insertion before canvas | Existing stable missing-controls slot, focus wrapper |
|
||||
| `behavior/state_menu.rs::popup` | Add/Remove exact target before singleton actions; selected-member multi-menu returns explicit group actions and suppresses singleton destructive/route actions | Tokenized menu dispatch already preserves set for DiagramSelection events |
|
||||
| `app/diagram_removal.rs` / State Delete dispatch | For multi-selection emit full-set Hide or explicit unsupported mixed/destructive reason; never fall through to primary-only deletion | Existing single-placement/row semantics remain for singleton |
|
||||
|
||||
Context-menu rows need exact placement metadata as well as semantic relation ID. `model_menu::Request::placement` currently identifies the node to Hide, so do not overload it to mean a row owner without auditing singleton invocation. Prefer adding an optional typed selection target to the request (or a dedicated State capture field) while preserving existing semantic object/placement fields used by old actions. This is a boundary change justified by row occurrence identity, not a new serialization format.
|
||||
|
||||
Placement-only Hide removes only explicitly selected placements, not the movement descendant union; hidden descendant placements and engineering objects survive, as in current ordinary tests. Group semantic deletion remains a separate aggregate-impact transaction/review requirement from the contract. Do not ship an enabled primary-only Delete action under a group label. A first coherent State adapter may explicitly refuse unsupported mixtures and group semantic deletion while the shared aggregate-removal implementation is developed; record that limitation, rather than treating it as full issue completion.
|
||||
|
||||
## Independent falsifiers and acceptance tasks
|
||||
|
||||
1. Two unrelated states with internal hard pins: independently enumerate expected node and pin positions. One simultaneous move translates pins; one-end route remains fixed. Assert the entire expected Project, exact source recompile, one Undo/Redo, other State views and opposite-layer records unchanged.
|
||||
2. Select composite plus visible descendant, then fold the composite. Reconciliation prunes invisible selected targets while moving the still-selected composite carries hidden descendants exactly once. Test a directly captured nested set before folding as well.
|
||||
3. Select EntryPoint and inner leaf with fixed boundary: pins stay in world coordinates. Select the boundary as well: pins translate once. Include a partial view omitting the boundary and an ExitPoint case. Verify authored versus projected position separately.
|
||||
4. Shift held while Inspector owns focus, then first State click: exact target toggles. Passive modifiers during busy I/O do not acquire focus; window unfocus resets them. Typing/Delete/F2 while unfocused remain denied. Use the mounted App's production focus wrapper, not only Canvas Program tests.
|
||||
5. Native event batches: press on a selected member, inspector would otherwise change canvas layout, then motion/release. No primary mutation before gesture outcome; no singleton command. Stationary release collapses; threshold-crossing out-and-back/no-op retains set and creates no history. Fit/pan/viewport change retires stale geometry without redefining resize policy.
|
||||
6. Shift-click entry-action and internal-transition rows: target includes exact owner placement. Mixed row/node and edge/node movement or Hide refuses the whole set. A row cannot silently select/move its reusable work target or owning state.
|
||||
7. Select transition alone and exercise existing bend, endpoint and attachment interactions. Repeat with the transition in a group: group admission takes precedence. Cancel a provisional bend, right-click and verify committed pin indices remain unchanged. Reuse all existing State bend/attachment gates.
|
||||
8. Old Move/Hide/Collapse/Refuse snapshots after view switch, owner change, revision, remove/re-add ABA, tool change, same-semantic placement replacement: no model/source/history/selection/status/allocation side effects. Validate top-level App admission, not only state-edit planner rejection.
|
||||
9. Open menu on an unselected row/node, dismiss: preserve set/primary. Add retains prior members. Selected-member menu hides the complete captured placement set and offers no singleton Delete. Menus from former views never apply after navigation.
|
||||
10. Pending valid and invalid property forms, source drafts and State forms: preserve raw input, model and source; do not auto-apply before group edit. Hide/Undo restores exact authored source and geometry; primary-survivor rules do not resurrect removed selection membership.
|
||||
|
||||
## Resolved questions and remaining bounded inspection
|
||||
|
||||
Resolved from code: no new target type is needed for State placements; row hits need stronger occurrence identity; State has unsnapped node moves; boundary route behavior is already in core; menu dispatch and App selection boundary must be extended beyond a page test.
|
||||
|
||||
Before implementation, inspect the actual then-current ordinary adapter and `group_gesture` API (they are still local work), and enumerate all `StateCanvas` struct literals in existing tests for the added selection field. Inspect `behavior::selected_owner`, BehaviorDiagram/owner navigation and any early-return App paths together when extending reconciliation; owner changes must not survive because `active_diagram` stayed constant. These are discoverable code questions, not user decisions. No additional external research or schema change is indicated.
|
||||
|
||||
## Follow-up falsification review of the proposal
|
||||
|
||||
Read-only follow-up against the current local implementation confirms the approach, with these tighter constraints. This is a second pass by the same investigator, not an independent-agent approval or execution evidence.
|
||||
|
||||
1. **Row visibility must be occurrence-exact, not merely relation-valid.** `visible_row_relation` finds the relation's current source and then accepts any rendered row for that source. It is adequate for existing singleton removal, but not sufficient to validate `StateRow { placement, relation }`. A relation can survive while being reassigned to another State or while its owner's placement is replaced. The group predicate must require the specified projected placement, the relation's current source equal to that placement's element, and membership in that placement's currently rendered `state_action_rows` / `internal_rows`. Falsifier: preserve the relation UUID, reassign its source to another placed State, then deliver the old row snapshot; never rebound selection to the new row. Both row kinds currently lose placement at `hit` and right-click capture, so fixing only one path is incomplete.
|
||||
|
||||
2. **Existing primary fallback is now stronger than the initial ordinary adapter.** Current `app/multiselect.rs` preserves the reconciled survivor when `held_primary || lost_primary`, regardless of a legacy semantic-to-placement remap. Keep this rule for State; do not restore the older `lost_primary && legacy.is_none()` condition. State additionally needs an exact row primary marker because both an internal row and an edge inspect a relation UUID. `behavior.diagram.placement` remains a node-inspection field; putting a row owner there would allow legacy Hide to delete its state placement. The typed selection is authoritative for rows, while the inspector still addresses the relation. Falsifier: remove a primary row's occurrence, keep its relation/model work alive, and retain an unrelated selected node; that node becomes primary with one coherent inspector, including after Undo.
|
||||
|
||||
3. **Unchanged authored route does not imply unchanged rendered route or labels.** `translated_routes` changes only pins when all actual endpoint owners and any required boundary move together; it preserves manual attachments and `EdgeLabel` fraction/offset. An external route's stored record stays byte-for-byte equal, while its automatic path and computed label position may change as one endpoint moves. State action/internal rows follow their projected state, and `symbol_labels` recomputes collision-aware derived positions after routing. Do not add a second label translation or freeze their old screen positions. Independent assertions must separate authored `EdgeRoute` equality from rendered geometry and symbol-label ownership. Include guards and multi-line transition labels, a State action referencing work shared by another State, and an external route with a manually offset label.
|
||||
|
||||
4. **Projected boundary geometry cannot be used as the movement anchor's original authored position.** EntryPoint/ExitPoint X is snapped to the owner boundary by projection; Y is clamped below the header and above the bottom. `StateCanvas` currently captures the original from `diagram.nodes[placement].position`, correctly. A reusable group helper initialized from `ProjectedNode.position` can introduce a large unintended first delta even with an unsnapped policy. Falsifier: stored entry position differs substantially from its displayed boundary position; drag by 7.25 world units and require precisely that stored displacement, with independent projected-boundary expectations. Preserve unsnapped State node movement; bend snapping remains 5 units and ordinary movement 10.
|
||||
|
||||
5. **One shared plan is a contract, not sequential reuse of singleton movement.** `diagram::multiselect::group_preview` already consumes only `EditNode`/`SetEdgeRoute` members of the pure planner's Batch into a cloned diagram. State can reuse that small application pattern. Neither preview nor commit may loop through `state_edit::plan(Event::Move)` or `Command::MoveNode`: each sees an incomplete movement map and misses internal pins between unrelated roots. Recheck all selection members against one original snapshot before constructing the plan; commit once through Document. A refused/out-of-bounds member must leave every other member, source and history unchanged.
|
||||
|
||||
### Smallest safe implementation split
|
||||
|
||||
- **First: exact selection boundary and inspection**, including State context, row hit identity, primary reconciliation, passive modifiers, menu Add/Remove/dismissal and visible membership. Until movement exists, intercept group drags/Delete with an explicit full-set refusal so no singleton fallback is reachable. Test actual App admission before shared side effects, plus same-semantic replacement and row reassignment. This is useful foundation, not completed State group support.
|
||||
- **Second: placement movement**, integrating unsnapped captured displacement, common atomic planner, diagram-only preview and paint-cache keys. Run independent whole-Project/source/history oracles plus mounted ordered-pointer/Inspector-relayout workflows. Re-run existing State bend/reconnect/attachment checks because hit-priority changes can regress them even without editing their planners.
|
||||
- **Third: placement Hide and removal boundary**, captured-set Hide with one Undo/Redo, survivor primary and unplaced-picker recovery. Mixed rows/edges and semantic group deletion must remain explicit refusal until the separate aggregate impact-review path exists. A group menu cannot expose enabled singleton destructive actions during any split.
|
||||
|
||||
Every split must reject pending property/source/State drafts without calling `apply`, and reject stale snapshots before status, camera, allocation or selection mutation. Advancing source history must retire old snapshots even if semantic/placement UUIDs are unchanged. Reuse existing ordinary stale-event loop for State Move/Hide/Collapse/Refuse with view, owner, tool, revision and selection-generation changes; add the row-source reassignment and entry authored-versus-projected tests above.
|
||||
Reference in New Issue
Block a user