Compare commits

...
76 changed files with 10528 additions and 18 deletions
@@ -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",
);
}
}
+91
View File
@@ -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
View File
@@ -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;
+427
View File
@@ -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()),
+65 -2
View File
@@ -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());
}
}
+337
View File
@@ -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());
}
+72 -10
View File
@@ -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,
+37 -2
View File
@@ -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(),
+416
View File
@@ -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()));
}
}
+1
View File
@@ -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;
+94
View File
@@ -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"));
}
+57 -4
View File
@@ -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.
@@ -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"
}
@@ -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"
}
}
@@ -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;
}
@@ -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;
}
@@ -0,0 +1,4 @@
oa_activity activity_c "C - dispatch response" id "3b258d00-854d-5ddd-a057-16eedbe31714" {
description "Preserve engineering record activity_c.";
}
@@ -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";
}
@@ -0,0 +1,4 @@
sa_function sa_function "Technical request processing" id "878a1b33-a7c2-5a68-a8b6-d8273c6354e0" {
description "Preserve engineering record sa_function.";
}
@@ -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;
}
@@ -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;
};
}
@@ -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;
};
}
@@ -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;
};
}
@@ -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.
@@ -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.
+118
View File
@@ -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.
+218
View File
@@ -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.