diff --git a/crates/oa-core/examples/allocation_multiselect_native.rs b/crates/oa-core/examples/allocation_multiselect_native.rs new file mode 100644 index 00000000..2b1b3361 --- /dev/null +++ b/crates/oa-core/examples/allocation_multiselect_native.rs @@ -0,0 +1,768 @@ +//! Deterministic native fixture and literal complete-Project Allocation oracle. +//! No desktop planner, inferred output delta, or GUI input is used by this oracle. +use oa_core::{source_project::*, *}; +use std::{collections::BTreeMap, fs, io::Write, path::Path}; +type Result = std::result::Result>; +const MODES: [&str; 6] = [ + "unchanged", + "oa-assigned", + "sa-assigned", + "oa-arranged", + "sa-arranged", + "oa-performers", +]; + +fn id(alias: &str) -> Id { + if alias == "oa_assign" { + return Id::from_u128(0x292609230001); + } + Id::new_v5( + &Id::from_u128(0xc1192026_0923_5000_8000_000000002931), + alias.as_bytes(), + ) +} +fn xy(x: f32, y: f32) -> Position { + Position { x, y } +} +fn named(scope: &str, suffix: &str) -> String { + format!("{scope}_{suffix}") +} +fn need(ok: bool, message: impl Into) -> Result<()> { + if ok { + Ok(()) + } else { + Err(message.into().into()) + } +} +fn element(p: &mut Project, alias: &str, name: &str, kind: Kind, layer: Layer) { + p.elements.insert( + id(alias), + Element { + id: id(alias), + alias: alias.into(), + name: name.into(), + description: format!("Preserve engineering record {alias}."), + parent: None, + body: ElementBody::new(kind, layer).unwrap(), + }, + ); +} +fn diagram(p: &mut Project, alias: &str, name: &str, layer: Layer, projection: ProjectionKind) { + p.diagrams.insert( + id(alias), + Diagram { + id: id(alias), + alias: alias.into(), + name: name.into(), + description: "Native group fixture; preserve unrelated records exactly.".into(), + layer, + projection, + layout: None, + view_options: Default::default(), + nodes: Default::default(), + routes: vec![], + }, + ); +} +fn place(p: &mut Project, scope: &str, suffix: &str, position: Position) { + let alias = named(scope, &format!("node_{suffix}")); + p.diagrams.get_mut(&id(scope)).unwrap().nodes.insert( + id(&alias), + NodePlacement { + id: id(&alias), + alias, + element: id(&named(scope, suffix)), + position, + size: Size { + width: 224., + height: 94., + }, + collapsed: false, + port_anchors: vec![], + }, + ); +} +fn relation( + p: &mut Project, + scope: &str, + suffix: &str, + kind: RelationKind, + from: &str, + to: &str, +) -> Result<()> { + let alias = named(scope, suffix); + let old = p.relate( + kind, + &format!("{scope} {suffix}"), + id(&named(scope, from)), + id(&named(scope, to)), + ); + reidentify(p, old, id(&alias))?; + let r = p.relations.get_mut(&id(&alias)).unwrap(); + r.alias = alias.clone(); + r.description = format!("Reviewed identity and provenance {alias} must survive."); + // The actual constructor creates proper SA function contracts. Normalize + // every generated identity/alias; no random UUID can leak into the fixture. + let ports = match r.body { + RelationBody::FunctionalExchange { + source_port, + target_port, + .. + } => Some([source_port, target_port]), + _ => None, + }; + if let Some(ports) = ports { + for (old_port, suffix) in ports.into_iter().zip(["output", "input"]) { + let alias = format!("{alias}_{suffix}"); + reidentify(p, old_port, id(&alias))?; + let port = p.elements.get_mut(&id(&alias)).unwrap(); + port.alias = alias.clone(); + port.name = format!("{scope} {suffix}"); + port.description = format!("Preserved typed endpoint {alias}."); + } + } + Ok(()) +} +fn route(scope: &str, pins: [Position; 2]) -> EdgeRoute { + EdgeRoute { + edge: id(&named(scope, "exchange")), + waypoints: pins.to_vec(), + source_attachment: None, + target_attachment: None, + label: Some(EdgeLabel { + fraction: 0.45, + offset: xy(7., -19.), + }), + } +} +fn fixture() -> Result { + let mut p = Project::empty(); + p.id = id("project"); + p.alias = "allocation_multiselect_native".into(); + p.name = "Allocation group native".into(); + p.description = "Four independent compact OA/SA assignment and Arrange trials; ordinary performers use fresh OA assign baseline.".into(); + for (layer, prefix, work, performer) in [ + (Layer::Oa, "oa", Kind::Activity, Kind::Entity), + (Layer::Sa, "sa", Kind::Function, Kind::SystemActor), + ] { + for arrange in [false, true] { + let scope = format!("{prefix}_{}", if arrange { "arrange" } else { "assign" }); + let number = match (prefix, arrange) { + ("oa", false) => "01", + ("sa", false) => "02", + ("oa", true) => "03", + _ => "04", + }; + diagram( + &mut p, + &scope, + &format!( + "{number} {} {}", + prefix.to_uppercase(), + if arrange { "arrange" } else { "assign" } + ), + layer, + ProjectionKind::Allocation, + ); + let records: Vec<_> = if arrange { + vec![ + ("p", "P - team", performer, xy(40., 40.)), + ("u", "U - contained work", work, xy(80., 140.)), + ("v", "V - independent work", work, xy(440., 140.)), + ] + } else { + vec![ + ("a", "A - origin team", performer, xy(43., 47.)), + // Leave room for SA Y's automatic top port stub below + // B's 64-unit header plus 12-unit routing clearance. + ("b", "B - other team", performer, xy(43., 337.)), + ("c", "C - destination", performer, xy(500., 40.)), + ("x", "X - receive", work, xy(80., 140.)), + ("y", "Y - assess", work, xy(80., 440.)), + ] + }; + for (suffix, label, kind, pos) in records { + // SA admits one System for the entire project, not one per view. + let kind = if layer == Layer::Sa && !arrange && suffix == "a" { + Kind::System + } else { + kind + }; + element(&mut p, &named(&scope, suffix), label, kind, layer); + place(&mut p, &scope, suffix, pos); + } + let (from, to, pins) = if arrange { + relation(&mut p, &scope, "pu", RelationKind::Allocation, "p", "u")?; + ("u", "v", [xy(360., 180.), xy(360., 230.)]) + } else { + p.diagrams + .get_mut(&id(&scope)) + .unwrap() + .nodes + .get_mut(&id(&named(&scope, "node_c"))) + .unwrap() + .size = Size { + width: 360., + height: 600., + }; + relation(&mut p, &scope, "ax", RelationKind::Allocation, "a", "x")?; + relation(&mut p, &scope, "by", RelationKind::Allocation, "b", "y")?; + ("x", "y", [xy(340., 260.), xy(340., 400.)]) + }; + relation( + &mut p, + &scope, + "exchange", + if layer == Layer::Oa { + RelationKind::Interaction + } else { + RelationKind::FunctionalExchange + }, + from, + to, + )?; + p.diagrams + .get_mut(&id(&scope)) + .unwrap() + .routes + .push(route(&scope, pins)); + } + } + diagram( + &mut p, + "other_view", + "05 Other saved view - preserve", + Layer::Oa, + ProjectionKind::Overview, + ); + for (suffix, pos) in [("x", xy(40., 60.)), ("y", xy(410., 180.))] { + let alias = named("other_view", suffix); + p.diagrams.get_mut(&id("other_view")).unwrap().nodes.insert( + id(&alias), + NodePlacement { + id: id(&alias), + alias, + element: id(&named("oa_assign", suffix)), + position: pos, + size: Size { + width: 224., + height: 94., + }, + collapsed: false, + port_anchors: vec![], + }, + ); + } + validate_candidate(&p)?; + Ok(p) +} +fn node_mut<'a>(p: &'a mut Project, scope: &str, suffix: &str) -> &'a mut NodePlacement { + p.diagrams + .get_mut(&id(scope)) + .unwrap() + .nodes + .get_mut(&id(&named(scope, &format!("node_{suffix}")))) + .unwrap() +} +fn expected(before: &Project, mode: &str) -> Result { + let mut p = before.clone(); + match mode { + "unchanged" => {} + "oa-assigned" | "sa-assigned" => { + let scope = if mode == "oa-assigned" { + "oa_assign" + } else { + "sa_assign" + }; + for (rel, work) in [("ax", "x"), ("by", "y")] { + p.relations.get_mut(&id(&named(scope, rel))).unwrap().body = + RelationBody::Allocation { + source: id(&named(scope, "c")), + target: id(&named(scope, work)), + }; + } + node_mut(&mut p, scope, "x").position = xy(560., 140.); + node_mut(&mut p, scope, "y").position = xy(560., 440.); + p.diagrams.get_mut(&id(scope)).unwrap().routes[0].waypoints = + vec![xy(820., 260.), xy(820., 400.)]; + } + "oa-arranged" | "sa-arranged" => { + let scope = if mode == "oa-arranged" { + "oa_arrange" + } else { + "sa_arrange" + }; + for (suffix, pos) in [ + ("p", xy(40., 280.)), + ("u", xy(80., 380.)), + ("v", xy(440., 380.)), + ] { + node_mut(&mut p, scope, suffix).position = pos; + } + p.diagrams.get_mut(&id(scope)).unwrap().routes[0].waypoints = + vec![xy(360., 420.), xy(360., 470.)]; + } + "oa-performers" => { + for (suffix, pos) in [ + ("a", xy(120., 90.)), + ("b", xy(120., 380.)), + ("x", xy(157., 183.)), + ("y", xy(157., 483.)), + ] { + node_mut(&mut p, "oa_assign", suffix).position = pos; + } + p.diagrams.get_mut(&id("oa_assign")).unwrap().routes[0].waypoints = + vec![xy(417., 303.), xy(417., 443.)]; + } + _ => return Err(format!("Unknown mode {mode}; expected {MODES:?}").into()), + } + Ok(p) +} +fn ancestor(view: &projection::ProjectedView, parent: Id, child: Id) -> bool { + let mut next = view + .nodes + .iter() + .find(|n| n.id == child) + .and_then(|n| n.visual_parent); + for _ in 0..view.nodes.len() { + let Some(current) = next else { + return false; + }; + if current == parent { + return true; + } + next = view + .nodes + .iter() + .find(|n| n.id == current) + .and_then(|n| n.visual_parent); + } + false +} +/// Fixture-usability preflight only: never calculates expected mutations. +fn preflight(p: &Project) -> Result { + validate_candidate(p)?; + let mut scenes = Vec::new(); + for scope in ["oa_assign", "sa_assign", "oa_arrange", "sa_arrange"] { + let d = &p.diagrams[&id(scope)]; + let view = projection::project_view(p, d.id); + need( + view.nodes.len() == d.nodes.len(), + format!("{scope}: hidden/missing fixture placement"), + )?; + for n in &view.nodes { + need( + n.position == d.nodes[&n.id].position, + format!("{scope}: unexpectedly shifted projected origin {}", n.id), + )?; + need( + n.position.x >= 0. + && n.position.y >= 0. + && n.position.x + n.size.width <= 900. + && n.position.y + n.size.height <= 750., + format!("{scope}: fixture exceeds declared compact world envelope: {n:?}"), + )?; + if p.elements[&n.element].kind().is_work() { + let height = if d.layer == Layer::Sa { 132. } else { 94. }; + need( + n.size + == Size { + width: 224., + height, + }, + format!("{scope}: unexpected work/port extent {n:?}"), + )?; + } + } + for (i, a) in view.nodes.iter().enumerate() { + for b in &view.nodes[i + 1..] { + if ancestor(&view, a.id, b.id) || ancestor(&view, b.id, a.id) { + continue; + } + let width = (a.position.x + a.size.width).min(b.position.x + b.size.width) + - a.position.x.max(b.position.x); + let height = (a.position.y + a.size.height).min(b.position.y + b.size.height) + - a.position.y.max(b.position.y); + need( + width <= 0. || height <= 0., + format!("{scope}: unrelated projected overlap {} {}", a.id, b.id), + )?; + } + } + let routed = routing::route_diagram(p, d, &view); + let edge = id(&named(scope, "exchange")); + let r = routed + .edges + .get(&edge) + .ok_or("Authored exchange route is missing")?; + need( + !r.custom_suspended && r.pins == d.routes[0].waypoints, + format!("{scope}: authored pins are not active"), + )?; + need( + r.warnings.is_empty(), + format!( + "{scope}: route warnings require fixture review: {:?}; ports={:?}; points={:?}; nodes={:?}", + r.warnings, view.ports, r.points, view.nodes + ), + )?; + need( + view.ports.len() == if d.layer == Layer::Sa { 2 } else { 0 }, + format!("{scope}: unexpected visible typed ports"), + )?; + scenes.push(serde_json::json!({"diagram":scope,"nodes":view.nodes,"ports":view.ports,"active_pins":r.pins,"route_warnings":r.warnings.iter().map(ToString::to_string).collect::>()})); + } + Ok(serde_json::json!(scenes)) +} +fn load(path: &Path) -> Result { + if path + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("arcadia")) + { + let disk = source_files::load_project(path, ProjectLimits::default())?; + need( + !disk + .diagnostics + .iter() + .any(|d| d.diagnostic.severity == validation::Severity::Error), + format!("Source discovery errors: {:?}", disk.diagnostics), + )?; + let compiled = compile_bundle(&disk.reachable_bundle(), ProjectLimits::default()) + .map_err(|e| format!("Source compile: {e:?}"))?; + Ok(compiled.decoded.project) + } else { + Ok(persistence::load(path)?) + } +} +fn equal(expected: &Project, actual: &Project) -> Result<()> { + if expected == actual { + return Ok(()); + } + let e = serde_json::to_value(expected)?; + let a = serde_json::to_value(actual)?; + let fields: Vec<_> = e + .as_object() + .unwrap() + .keys() + .filter(|key| e[*key] != a[*key]) + .cloned() + .collect(); + Err(format!("Whole Project mismatch in {fields:?}; no partial PASS").into()) +} +fn roundtrip(p: &Project) -> Result { + validate_candidate(p)?; + equal(p, &persistence::decode(&serde_json::to_vec(p)?)?)?; + let mut bundle = format_bundle(p); + for (path, text) in &mut bundle.files { + *text = format!("// Native preservation sentinel: {path}\n{text}"); + } + let parsed = compile_bundle(&bundle, ProjectLimits::default()) + .map_err(|e| format!("Source roundtrip: {e:?}"))?; + equal(p, &parsed.decoded.project)?; + Ok(bundle) +} +fn write_new(path: &Path, bytes: &[u8]) -> Result<()> { + fs::create_dir_all(path.parent().ok_or("Missing parent directory")?)?; + fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(path)? + .write_all(bytes)?; + Ok(()) +} +fn generate(out: &Path) -> Result<()> { + need(!out.exists(), "Output exists; choose a fresh directory")?; + let p = fixture()?; + let bundle = roundtrip(&p)?; + let mut geometry = BTreeMap::new(); + // All literal candidates must satisfy the same compact-fixture prerequisites + // before any bytes are published for a native run. + for mode in MODES { + let candidate = expected(&p, mode)?; + roundtrip(&candidate)?; + geometry.insert(mode, preflight(&candidate)?); + } + let mut ids = BTreeMap::from([(p.alias.clone(), p.id)]); + ids.extend(p.elements.values().map(|v| (v.alias.clone(), v.id))); + ids.extend(p.relations.values().map(|v| (v.alias.clone(), v.id))); + ids.extend(p.diagrams.values().map(|v| (v.alias.clone(), v.id))); + ids.extend( + p.diagrams + .values() + .flat_map(|d| d.nodes.values().map(|v| (v.alias.clone(), v.id))), + ); + write_new(&out.join("baseline.json"), &serde_json::to_vec_pretty(&p)?)?; + for (path, text) in &bundle.files { + write_new(&out.join("source").join(path), text.as_bytes())?; + } + let paths: BTreeMap<_, _> = ids + .iter() + .map(|(alias, id)| { + let needle = id.to_string(); + let files: Vec<_> = bundle + .files + .iter() + .filter(|(_, text)| text.contains(&needle)) + .map(|(path, _)| path.clone()) + .collect(); + (alias.clone(), files) + }) + .collect(); + write_new( + &out.join("manifest.json"), + &serde_json::to_vec_pretty(&serde_json::json!({ + "fixture":"allocation-multiselect-native-20260923","source_entry":bundle.entry,"ids":ids, + "source_files":bundle.files.keys().collect::>(),"id_text_occurrence_files":paths, + "modes":MODES,"geometry_preflight":geometry, + "literal_deltas":{"oa-assigned":[480,0],"sa-assigned":[480,0],"oa-arranged":[0,240],"sa-arranged":[0,240],"oa-performers":[77,43]}, + "selection":{"assigned":["X","Y"],"arranged":["P","V"],"ordinary_performers":["A","B"]}, + "ordinary_anchor_target":[120,90], + "evaluation":"Literal complete Project oracle, never inferred from saved geometry; baseline pinned to deterministic original", + "limits":"Geometry preflight is not GUI/planner admission. Source comments/history bytes, focus, exact selection, pointer delivery and one Undo/Redo require separate observation/checks. Each mode is one operation from baseline." + }))?, + )?; + equal(&p, &load(&out.join("baseline.json"))?)?; + equal(&p, &load(&out.join("source").join(&bundle.entry))?)?; + println!("PASS generated and reloaded fixture: {}", out.display()); + Ok(()) +} +fn check(mode: &str, baseline: &Path, saved: &Path) -> Result<()> { + let before = load(baseline)?; + equal(&fixture()?, &before).map_err(|e| format!("Baseline is not immutable original: {e}"))?; + let actual = load(saved)?; + validate_candidate(&actual)?; + equal(&expected(&before, mode)?, &actual)?; + roundtrip(&actual)?; + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "result":"PASS","mode":mode,"baseline":baseline,"saved":saved, + "comparison":"complete Project equality; literal fixed record edits", + "limits":"No pointer, selection, source-byte preservation, focus, Undo/Redo, or native acceptance claim" + }))? + ); + Ok(()) +} +fn main() -> Result<()> { + let args: Vec<_> = std::env::args().skip(1).collect(); + match args.as_slice() { + [command,out] if command=="generate" => generate(Path::new(out)), + [command,mode,baseline,saved] if command=="check" => check(mode,Path::new(baseline),Path::new(saved)), + _=>Err(format!("Usage: allocation_multiselect_native generate DIRECTORY | check MODE BASELINE SAVED; modes={MODES:?}").into()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + fn through_json(mode: &str, before: &Project, actual: &Project) -> Result<()> { + validate_candidate(before).expect("baseline valid"); + validate_candidate(actual).expect("falsifier must remain core-valid"); + let dir = tempfile::tempdir()?; + let baseline = dir.path().join("baseline.json"); + let saved = dir.path().join("saved.json"); + persistence::save(&baseline, before)?; + persistence::save(&saved, actual)?; + check(mode, &baseline, &saved) + } + fn mismatch(mode: &str, before: &Project, actual: &Project) { + let error = through_json(mode, before, actual).unwrap_err().to_string(); + assert!( + error.contains("Whole Project mismatch"), + "wrong failure cause: {error}" + ); + } + #[test] + fn deterministic_create_only_generation_and_all_literal_modes() { + let p = fixture().unwrap(); + assert_eq!(p, fixture().unwrap()); + let dir = tempfile::tempdir().unwrap(); + let out = dir.path().join("fixture"); + generate(&out).unwrap(); + let before = fs::read(out.join("baseline.json")).unwrap(); + assert!(generate(&out).is_err()); + assert_eq!(before, fs::read(out.join("baseline.json")).unwrap()); + for mode in MODES { + let candidate = expected(&p, mode).unwrap(); + preflight(&candidate).unwrap(); + through_json(mode, &p, &candidate).unwrap(); + if mode != "unchanged" { + mismatch(mode, &p, &p); + } + } + assert!(expected(&p, "invented").is_err()); + let changed = expected(&p, "oa-assigned").unwrap(); + assert!( + through_json("unchanged", &changed, &changed) + .unwrap_err() + .to_string() + .contains("immutable original") + ); + } + #[test] + fn actual_core_assignment_batch_matches_literal_oracle_and_one_step_history() { + let before = fixture().unwrap(); + for (mode, scope) in [("oa-assigned", "oa_assign"), ("sa-assigned", "sa_assign")] { + let mut commands = Vec::new(); + for (alias, work) in [("ax", "x"), ("by", "y")] { + let relation = id(&named(scope, alias)); + let mut value = before.relations[&relation].clone(); + value.body = RelationBody::Allocation { + source: id(&named(scope, "c")), + target: id(&named(scope, work)), + }; + commands.push(Command::EditRelation(relation, value)); + } + for (suffix, position) in [("x", xy(560., 140.)), ("y", xy(560., 440.))] { + let original = id(&named(scope, &format!("node_{suffix}"))); + let mut node = before.diagrams[&id(scope)].nodes[&original].clone(); + node.position = position; + commands.push(Command::EditNode { + diagram: id(scope), + original, + node, + }); + } + let mut route = before.diagrams[&id(scope)].routes[0].clone(); + route.waypoints = vec![xy(820., 260.), xy(820., 400.)]; + commands.push(Command::SetEdgeRoute { + diagram: id(scope), + route, + }); + let mut session = Session::new(before.clone()); + session.execute(Command::Batch(commands)).unwrap(); + through_json(mode, &before, session.project()).unwrap(); + let after = session.project().clone(); + session.undo(); + assert_eq!(session.project(), &before); + assert!(!session.can_undo()); + session.redo(); + assert_eq!(session.project(), &after); + assert!(!session.can_redo()); + } + } + #[test] + fn assignment_rejects_singleton_semantic_identity_and_route_drift() { + let before = fixture().unwrap(); + for mode in ["oa-assigned", "sa-assigned"] { + let scope = if mode == "oa-assigned" { + "oa_assign" + } else { + "sa_assign" + }; + let good = expected(&before, mode).unwrap(); + let mut bad = good.clone(); + node_mut(&mut bad, scope, "y").position = xy(80., 440.); + mismatch(mode, &before, &bad); + let mut bad = good.clone(); + bad.relations.insert( + id(&named(scope, "by")), + before.relations[&id(&named(scope, "by"))].clone(), + ); + mismatch(mode, &before, &bad); + let mut bad = good.clone(); + reidentify( + &mut bad, + id(&named(scope, "ax")), + id("replacement_allocation"), + ) + .unwrap(); + mismatch(mode, &before, &bad); + let mut bad = good.clone(); + bad.relations + .get_mut(&id(&named(scope, "ax"))) + .unwrap() + .description + .push_str(" drift"); + mismatch(mode, &before, &bad); + for pins in [ + vec![xy(340., 260.), xy(340., 400.)], + vec![xy(1300., 260.), xy(1300., 400.)], + ] { + let mut bad = good.clone(); + bad.diagrams.get_mut(&id(scope)).unwrap().routes[0].waypoints = pins; + mismatch(mode, &before, &bad); + } + } + } + #[test] + fn arrange_and_ordinary_closure_falsifiers_remain_core_valid() { + let before = fixture().unwrap(); + for mode in ["oa-arranged", "sa-arranged"] { + let scope = if mode == "oa-arranged" { + "oa_arrange" + } else { + "sa_arrange" + }; + let good = expected(&before, mode).unwrap(); + for (suffix, pos) in [("u", xy(80., 140.)), ("v", xy(440., 140.))] { + let mut bad = good.clone(); + node_mut(&mut bad, scope, suffix).position = pos; + mismatch(mode, &before, &bad); + } + let mut bad = good.clone(); + bad.relations.remove(&id(&named(scope, "pu"))); + mismatch(mode, &before, &bad); + } + let good = expected(&before, "oa-performers").unwrap(); + let mut bad = good.clone(); + node_mut(&mut bad, "oa_assign", "x").position = xy(80., 140.); + mismatch("oa-performers", &before, &bad); + let mut bad = good; + bad.relations.get_mut(&id("oa_assign_ax")).unwrap().body = RelationBody::Allocation { + source: id("oa_assign_c"), + target: id("oa_assign_x"), + }; + mismatch("oa-performers", &before, &bad); + } + #[test] + fn whole_project_sentinels_and_real_saved_source_are_checked() { + let before = fixture().unwrap(); + let dir = tempfile::tempdir().unwrap(); + let baseline = dir.path().join("baseline.json"); + persistence::save(&baseline, &before).unwrap(); + for mode in MODES { + let good = expected(&before, mode).unwrap(); + for corrupt in [false, true] { + let mut actual = good.clone(); + if corrupt { + actual + .elements + .get_mut(&id("sa_arrange_v")) + .unwrap() + .description + .push_str(" drift"); + } + let bundle = roundtrip(&actual).unwrap(); + let folder = dir.path().join(format!("{mode}-{corrupt}")); + for (path, text) in &bundle.files { + write_new(&folder.join(path), text.as_bytes()).unwrap(); + } + let result = check(mode, &baseline, &folder.join(&bundle.entry)); + if corrupt { + assert!( + result + .unwrap_err() + .to_string() + .contains("Whole Project mismatch") + ); + } else { + result.unwrap(); + } + } + let mut bad = good; + bad.diagrams + .get_mut(&id("other_view")) + .unwrap() + .nodes + .get_mut(&id("other_view_x")) + .unwrap() + .position + .x += 10.; + mismatch(mode, &before, &bad); + } + let invalid = dir.path().join("invalid.arcadia"); + write_new(&invalid, b"project {").unwrap(); + assert!(load(&invalid).is_err()); + } +} diff --git a/crates/oa-core/examples/process_multiselect_native.rs b/crates/oa-core/examples/process_multiselect_native.rs new file mode 100644 index 00000000..29401be4 --- /dev/null +++ b/crates/oa-core/examples/process_multiselect_native.rs @@ -0,0 +1,548 @@ +//! Deterministic Process native fixture and independent complete-Project oracle. +//! Expected order is literal; no desktop reorder/layout planner is used here. +use oa_core::{source_project::*, *}; +use std::{fs, io::Write, path::Path}; +type Result = std::result::Result>; + +fn id(alias: &str) -> Id { + Id::new_v5( + &Id::from_u128(0xc1192026_0923_5000_8000_000000002930), + alias.as_bytes(), + ) +} +fn element(p: &mut Project, alias: &str, name: &str, kind: Kind, layer: Layer, parent: Option) { + 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 occurrence( + p: &mut Project, + alias: &str, + name: &str, + layer: Layer, + owner: &str, + value: OccurrenceBody, +) { + element(p, alias, name, Kind::Occurrence, layer, Some(id(owner))); + let Some(SharedBody::Occurrence { body, .. }) = + p.elements.get_mut(&id(alias)).unwrap().shared_mut() + else { + panic!("Occurrence body"); + }; + *body = value; +} +fn set_steps(p: &mut Project, owner: &str, aliases: &[&str]) { + *p.elements.get_mut(&id(owner)).unwrap().steps_mut().unwrap() = + aliases.iter().map(|alias| id(alias)).collect(); +} +fn sequence_link( + p: &mut Project, + alias: &str, + name: &str, + source: &str, + target: &str, +) -> Result<()> { + let old = p.relate(RelationKind::SequenceLink, name, id(source), id(target)); + reidentify(p, old, id(alias))?; + let relation = p.relations.get_mut(&id(alias)).unwrap(); + relation.alias = alias.into(); + relation.description = format!("Preserve link {alias} exactly."); + if let RelationBody::SequenceLink { guard, .. } = &mut relation.body { + *guard = "ready".into(); + } + Ok(()) +} +fn fixture() -> Result { + let mut p = Project::empty(); + p.id = id("project"); + p.alias = "process_multiselect_native".into(); + p.name = "Process group native".into(); + p.description = "Select Card C then Shift-select Card A; drop after Card B. Other columns and reusable engineering records remain exact.".into(); + for (layer, prefix, owner, name, owner_kind, work_kind) in [ + ( + Layer::Oa, + "oa", + "oa_process", + "01 OA group order", + Kind::Process, + Kind::Activity, + ), + ( + Layer::Sa, + "sa", + "sa_chain", + "SA chain - preserve", + Kind::FunctionalChain, + Kind::Function, + ), + ] { + element(&mut p, owner, name, owner_kind, layer, None); + let shared = format!("{prefix}_shared_work"); + let downstream = format!("{prefix}_downstream_work"); + element( + &mut p, + &shared, + "Shared request work", + work_kind, + layer, + None, + ); + element( + &mut p, + &downstream, + "Downstream verification", + work_kind, + layer, + None, + ); + for (suffix, label) in [ + ("a", "Card A"), + ("u", "Downstream U"), + ("b", "Card B"), + ("v", "Downstream V"), + ("c", "Card C"), + ] { + let body = if suffix == "b" { + OccurrenceBody::Control { + kind: ControlKind::Choice, + } + } else { + OccurrenceBody::Action { + action: Some(id(if suffix == "a" || suffix == "c" { + &shared + } else { + &downstream + })), + lifeline: None, + } + }; + occurrence( + &mut p, + &format!("{prefix}_{suffix}"), + label, + layer, + owner, + body, + ); + } + let order: Vec<_> = ["a", "u", "b", "v", "c"] + .map(|suffix| format!("{prefix}_{suffix}")) + .into_iter() + .collect(); + set_steps( + &mut p, + owner, + &order.iter().map(String::as_str).collect::>(), + ); + sequence_link( + &mut p, + &format!("{prefix}_a_u"), + "A to U", + &format!("{prefix}_a"), + &format!("{prefix}_u"), + )?; + sequence_link( + &mut p, + &format!("{prefix}_b_v"), + "B to V", + &format!("{prefix}_b"), + &format!("{prefix}_v"), + )?; + } + element( + &mut p, + "reusable", + "Reusable request detail", + Kind::Process, + Layer::Oa, + None, + ); + occurrence( + &mut p, + "reused_leaf", + "Shared detail", + Layer::Oa, + "reusable", + OccurrenceBody::Action { + action: Some(id("oa_shared_work")), + lifeline: None, + }, + ); + set_steps(&mut p, "reusable", &["reused_leaf"]); + element( + &mut p, + "reuse_trial", + "02 Read-only preview trial", + Kind::Process, + Layer::Oa, + None, + ); + for (alias, name) in [ + ("reference_one", "First use"), + ("reference_two", "Second use"), + ] { + occurrence( + &mut p, + alias, + name, + Layer::Oa, + "reuse_trial", + OccurrenceBody::SequenceReference { + sequence: Some(id("reusable")), + }, + ); + } + set_steps(&mut p, "reuse_trial", &["reference_one", "reference_two"]); + p.traces.insert( + id("trace"), + Trace { + id: id("trace"), + alias: "preserved_work_refinement".into(), + name: "Shared request refinement".into(), + description: "Process arrangement must preserve cross-layer correspondence.".into(), + oa: id("oa_shared_work"), + sa: id("sa_shared_work"), + kind: TraceKind::Refinement, + baseline: Some(TraceBaseline { + oa: p.properties(id("oa_shared_work")).unwrap(), + sa: p.properties(id("sa_shared_work")).unwrap(), + semantic: None, + }), + }, + ); + validate_candidate(&p)?; + Ok(p) +} +fn load(path: &Path) -> Result { + if path + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("arcadia")) + { + let disk = source_files::load_project(path, ProjectLimits::default())?; + 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 expected = serde_json::to_value(expected)?; + let actual = serde_json::to_value(actual)?; + let fields: Vec<_> = expected + .as_object() + .unwrap() + .keys() + .filter(|key| expected[*key] != actual[*key]) + .cloned() + .collect(); + Err(format!("Whole Project mismatch in {fields:?}; no partial PASS").into()) +} +fn roundtrip(p: &Project) -> Result { + 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 ids: std::collections::BTreeMap<_, _> = [ + "project", + "oa_process", + "oa_a", + "oa_u", + "oa_b", + "oa_v", + "oa_c", + "oa_a_u", + "oa_b_v", + "oa_shared_work", + "oa_downstream_work", + "sa_chain", + "sa_shared_work", + "sa_downstream_work", + "sa_a", + "sa_u", + "sa_b", + "sa_v", + "sa_c", + "sa_a_u", + "sa_b_v", + "trace", + "reusable", + "reused_leaf", + "reuse_trial", + "reference_one", + "reference_two", + ] + .into_iter() + .map(|name| (name, id(name))) + .collect(); + write_new( + &out.join("manifest.json"), + &serde_json::to_vec_pretty(&serde_json::json!({ + "fixture": "process-multiselect-native-20260923", "ids": ids, "source_entry": bundle.entry, + "original_oa_steps": ["oa_a", "oa_u", "oa_b", "oa_v", "oa_c"], + "reordered_oa_steps": ["oa_b", "oa_u", "oa_a", "oa_v", "oa_c"], + "gesture": "Select Card C then add Card A; Inspect Card C without changing membership, then drag Card C to After Card B", + "fixed_slots": {"1": "oa_u", "3": "oa_v"}, + "read_only_trial": "02 Read-only preview trial has two qualified tiles of reused_leaf through distinct references", + "receipt": "Pin baseline.json, manifest.json and generation command externally before interaction. Never replace baseline with a later saved snapshot.", + "evaluation": "Literal expected OA steps and complete Project equality; all SA/reuse/trace records exact", + "limits": "No pointer, focus, selection-paint, history or source-byte acceptance claim", + }))?, + )?; + equal(&p, &load(&out.join("baseline.json"))?)?; + equal(&p, &load(&out.join("source").join(&bundle.entry))?)?; + 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)?; + // Pin the exact deterministic original, not merely a reusable project UUID. + equal(&fixture()?, &before) + .map_err(|e| format!("Baseline is not the immutable original: {e}"))?; + let mut expected = before; + match mode { + "unchanged" => {} + "reordered" => set_steps( + &mut expected, + "oa_process", + &["oa_b", "oa_u", "oa_a", "oa_v", "oa_c"], + ), + _ => return Err("Mode must be reordered 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; literal expected order", + "limits": "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: process_multiselect_native generate DIRECTORY | check reordered|unchanged BASELINE SAVED".into()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + fn reordered(before: &Project) -> Project { + let mut session = Session::new(before.clone()); + session + .execute(Command::SetSteps( + id("oa_process"), + ["oa_b", "oa_u", "oa_a", "oa_v", "oa_c"].map(id).to_vec(), + )) + .unwrap(); + session.project().clone() + } + fn through_json(mode: &str, baseline: &Project, actual: &Project) -> Result<()> { + validate_candidate(baseline).expect("Baseline must be valid"); + validate_candidate(actual).expect("Corruption must remain semantically valid"); + let dir = tempfile::tempdir()?; + let before = dir.path().join("before.json"); + let saved = dir.path().join("saved.json"); + persistence::save(&before, baseline)?; + persistence::save(&saved, actual)?; + check(mode, &before, &saved) + } + fn mismatch(before: &Project, actual: &Project) { + let error = through_json("reordered", before, actual) + .unwrap_err() + .to_string(); + assert!( + error.contains("Whole Project mismatch"), + "Unexpected rejection: {error}" + ); + } + #[test] + fn generate_is_deterministic_create_only_and_checks_real_source() { + equal(&fixture().unwrap(), &fixture().unwrap()).unwrap(); + let dir = tempfile::tempdir().unwrap(); + let out = dir.path().join("fixture"); + generate(&out).unwrap(); + let baseline = out.join("baseline.json"); + let bytes = fs::read(&baseline).unwrap(); + check("unchanged", &baseline, &out.join("source/project.arcadia")).unwrap(); + assert!(generate(&out).is_err()); + assert_eq!(fs::read(&baseline).unwrap(), bytes); + let invalid = dir.path().join("invalid.arcadia"); + fs::write(&invalid, "project {").unwrap(); + assert!(load(&invalid).is_err()); + } + #[test] + fn real_set_steps_passes_but_unchanged_and_mutated_baseline_do_not() { + let before = fixture().unwrap(); + let actual = reordered(&before); + through_json("reordered", &before, &actual).unwrap(); + through_json("unchanged", &before, &before).unwrap(); + mismatch(&before, &before); + assert!(through_json("unchanged", &before, &actual).is_err()); + let error = through_json("unchanged", &actual, &actual) + .unwrap_err() + .to_string(); + assert!(error.contains("immutable original"), "{error}"); + } + #[test] + fn global_splice_reversed_block_and_singleton_c_results_are_rejected() { + let before = fixture().unwrap(); + // Native primary/pressed C is already after B, so a singleton C move + // is unchanged. Group {C,A} instead puts B before both selected cards. + // If A were primary, its singleton result could equal the group result. + mismatch(&before, &before); + for order in [ + ["oa_u", "oa_b", "oa_a", "oa_c", "oa_v"], + ["oa_b", "oa_u", "oa_c", "oa_v", "oa_a"], + ["oa_a", "oa_u", "oa_b", "oa_c", "oa_v"], + ] { + let mut actual = before.clone(); + set_steps(&mut actual, "oa_process", &order); + mismatch(&before, &actual); + } + } + #[test] + fn semantic_link_work_sa_reuse_and_trace_changes_are_rejected() { + let before = fixture().unwrap(); + let correct = reordered(&before); + let mutations: [fn(&mut Project); 7] = [ + |p| { + if let RelationBody::SequenceLink { guard, .. } = + &mut p.relations.get_mut(&id("oa_a_u")).unwrap().body + { + *guard = "changed".into(); + } + }, + |p| { + if let RelationBody::SequenceLink { target, .. } = + &mut p.relations.get_mut(&id("oa_a_u")).unwrap().body + { + *target = id("oa_v"); + } + }, + |p| { + p.elements + .get_mut(&id("oa_shared_work")) + .unwrap() + .name + .push_str(" drift"); + }, + |p| { + p.elements + .get_mut(&id("sa_shared_work")) + .unwrap() + .description + .push_str(" drift"); + }, + |p| { + set_steps(p, "sa_chain", &["sa_b", "sa_u", "sa_a", "sa_v", "sa_c"]); + }, + |p| { + p.elements + .get_mut(&id("reused_leaf")) + .unwrap() + .description + .push_str(" drift"); + }, + |p| { + p.traces.get_mut(&id("trace")).unwrap().baseline = None; + }, + ]; + for mutate in mutations { + let mut bad = correct.clone(); + mutate(&mut bad); + mismatch(&before, &bad); + } + } + #[test] + fn changed_source_roundtrip_preserves_both_layers_and_checker_rejects_source_drift() { + let before = fixture().unwrap(); + let actual = reordered(&before); + let dir = tempfile::tempdir().unwrap(); + let baseline = dir.path().join("baseline.json"); + persistence::save(&baseline, &before).unwrap(); + for corrupt in [false, true] { + let mut saved = actual.clone(); + if corrupt { + saved + .elements + .get_mut(&id("sa_c")) + .unwrap() + .name + .push_str(" drift"); + } + let bundle = roundtrip(&saved).unwrap(); + let folder = dir.path().join(if corrupt { "corrupt" } else { "saved" }); + for (path, text) in &bundle.files { + write_new(&folder.join(path), text.as_bytes()).unwrap(); + } + let parsed = load(&folder.join(&bundle.entry)).unwrap(); + assert_eq!(parsed.elements[&id("oa_process")].kind(), Kind::Process); + assert_eq!( + parsed.elements[&id("sa_chain")].kind(), + Kind::FunctionalChain + ); + let result = check("reordered", &baseline, &folder.join(&bundle.entry)); + if corrupt { + assert!( + result + .unwrap_err() + .to_string() + .contains("Whole Project mismatch") + ); + } else { + result.unwrap(); + } + } + } +} diff --git a/crates/oa-desktop/examples/allocation_policy_probe.rs b/crates/oa-desktop/examples/allocation_policy_probe.rs new file mode 100644 index 00000000..73c627b6 --- /dev/null +++ b/crates/oa-desktop/examples/allocation_policy_probe.rs @@ -0,0 +1,14 @@ +//! Focused test harness for the production Allocation planner and Document. +//! This does not exercise App input, painting, or native gestures. + +#[cfg(test)] +#[allow(dead_code)] // App-facing entry points are intentionally absent here. +#[path = "../src/allocation_drag.rs"] +mod allocation_drag; + +#[cfg(test)] +#[allow(dead_code, unused_imports)] // App-facing API and reexports are absent here. +#[path = "../src/document.rs"] +mod document; + +fn main() {} diff --git a/crates/oa-desktop/src/allocation_drag.rs b/crates/oa-desktop/src/allocation_drag.rs index 1bcd8202..5f6b266d 100644 --- a/crates/oa-desktop/src/allocation_drag.rs +++ b/crates/oa-desktop/src/allocation_drag.rs @@ -13,6 +13,19 @@ use oa_core::{ }; use std::collections::{BTreeMap, BTreeSet}; +// The pure planner is verified before its App gesture adapter is connected. +#[allow(dead_code)] +#[path = "allocation_drag/arrange.rs"] +pub mod arrange; + +#[path = "allocation_drag/geometry.rs"] +mod geometry; + +// The combined semantic planner is verified before connecting its App adapter. +#[allow(dead_code)] +#[path = "allocation_drag/assignment.rs"] +pub mod assignment; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Destination { LayoutOnly, @@ -76,6 +89,11 @@ pub enum Refusal { pub fn capture(p: &Project, diagram: Id, work: Id, placement: Id) -> Result { validate_candidate(p).map_err(|e| Refusal::InvalidCandidate(e.to_string()))?; + capture_validated(p, diagram, work, placement) +} + +/// Collect work facts after the caller's single complete Project validation. +fn capture_validated(p: &Project, diagram: Id, work: Id, placement: Id) -> Result { let d = p.diagrams.get(&diagram).ok_or(Refusal::StaleScope)?; let root = p.elements.get(&work).ok_or(Refusal::StaleScope)?; if !root.kind().is_work() @@ -232,8 +250,93 @@ pub fn plan_semantics( let same_performer = matches!(destination, Destination::Performer(id) if scope.uniform == Uniform::Assigned(id)); let mut expected = p.clone(); - let mut commands = Vec::new(); - let mut deleted = BTreeSet::new(); + let mut edits = IntendedEdits::default(); + append_semantic_edits( + p, + scope, + destination, + allocation_ids, + &mut expected, + &mut edits, + )?; + // A route cannot survive the intentional deletion of its own edge, even in + // another diagram. No other saved-view cleanup is authorized by this drag. + for diagram in expected.diagrams.values_mut() { + diagram.routes.retain(|r| !edits.deleted.contains(&r.edge)); + } + let actual = prepare_command(p, &Command::Batch(edits.commands.clone())) + .map_err(|e| Refusal::InvalidCandidate(e.to_string()))? + .project; + exact_project(&expected, &actual)?; + let retained_roles = scope + .leaves + .values() + .any(|l| !l.role_assignments.is_empty()); + let label = match destination { + Destination::LayoutOnly => "Arrange only".into(), + Destination::Performer(id) if same_performer => format!("Arrange within {}", p.named(id)), + Destination::Performer(id) => format!( + "Assign {} leaf {} to {}{}", + scope.leaves.len(), + match (scope.diagram.layer, scope.leaves.len()) { + (Layer::Oa, 1) => "activity", + (Layer::Oa, _) => "activities", + (Layer::Sa, 1) => "function", + (Layer::Sa, _) => "functions", + }, + p.named(id), + if retained_roles { + " — explicit assignment; role responsibility retained" + } else { + "" + } + ), + Destination::OutsidePerformers if edits.commands.is_empty() => { + "Arrange unassigned work — decomposition retained".into() + } + Destination::OutsidePerformers => "Remove assignment — decomposition retained".into(), + }; + let warnings = validation::validate(&actual) + .into_iter() + .filter(|i| i.severity == Severity::Warning) + .collect(); + let index = ViewIndex::new(&actual); + Ok(SemanticPlan { + scope: scope.clone(), + destination, + label, + warnings, + accepted: p.clone(), + candidate: actual, + index, + commands: edits.commands, + before_visible, + before_expanded, + }) +} + +#[derive(Default)] +struct IntendedEdits { + commands: Vec, + deleted: BTreeSet, + changed: BTreeSet, +} + +/// Read each root's accepted responsibility while accumulating one intended +/// Project, reserving relation identities and aliases across every root. +fn append_semantic_edits( + p: &Project, + scope: &Scope, + destination: Destination, + allocation_ids: &BTreeMap, + expected: &mut Project, + edits: &mut IntendedEdits, +) -> Result<(), Refusal> { + let same_performer = + matches!(destination, Destination::Performer(id) if scope.uniform == Uniform::Assigned(id)); + let commands = &mut edits.commands; + let deleted = &mut edits.deleted; + let changed = &mut edits.changed; if destination != Destination::LayoutOnly && !same_performer { for (work, leaf) in &scope.leaves { match destination { @@ -246,6 +349,7 @@ pub fn plan_semantics( target: *work, }; expected.relations.insert(edited.id, edited.clone()); + changed.insert(*work); commands.push(Command::EditRelation(edited.id, edited)); } } else { @@ -269,6 +373,7 @@ pub fn plan_semantics( }, }; expected.relations.insert(id, relation.clone()); + changed.insert(*work); commands.push(Command::CreateRelation(relation)); } } @@ -327,6 +432,7 @@ pub fn plan_semantics( if let Some(existing) = &leaf.direct { expected.relations.remove(&existing.id); deleted.insert(existing.id); + changed.insert(*work); commands.push(Command::Delete(existing.id)); } } @@ -334,60 +440,7 @@ pub fn plan_semantics( } } } - // A route cannot survive the intentional deletion of its own edge, even in - // another diagram. No other saved-view cleanup is authorized by this drag. - for diagram in expected.diagrams.values_mut() { - diagram.routes.retain(|r| !deleted.contains(&r.edge)); - } - let actual = prepare_command(p, &Command::Batch(commands.clone())) - .map_err(|e| Refusal::InvalidCandidate(e.to_string()))? - .project; - exact_project(&expected, &actual)?; - let retained_roles = scope - .leaves - .values() - .any(|l| !l.role_assignments.is_empty()); - let label = match destination { - Destination::LayoutOnly => "Arrange only".into(), - Destination::Performer(id) if same_performer => format!("Arrange within {}", p.named(id)), - Destination::Performer(id) => format!( - "Assign {} leaf {} to {}{}", - scope.leaves.len(), - match (scope.diagram.layer, scope.leaves.len()) { - (Layer::Oa, 1) => "activity", - (Layer::Oa, _) => "activities", - (Layer::Sa, 1) => "function", - (Layer::Sa, _) => "functions", - }, - p.named(id), - if retained_roles { - " — explicit assignment; role responsibility retained" - } else { - "" - } - ), - Destination::OutsidePerformers if commands.is_empty() => { - "Arrange unassigned work — decomposition retained".into() - } - Destination::OutsidePerformers => "Remove assignment — decomposition retained".into(), - }; - let warnings = validation::validate(&actual) - .into_iter() - .filter(|i| i.severity == Severity::Warning) - .collect(); - let index = ViewIndex::new(&actual); - Ok(SemanticPlan { - scope: scope.clone(), - destination, - label, - warnings, - accepted: p.clone(), - candidate: actual, - index, - commands, - before_visible, - before_expanded, - }) + Ok(()) } /// World-space rectangle in the gesture's frozen transform. The UI resolver @@ -462,60 +515,18 @@ pub fn place( "Make room or adjust the view, then drag again.".into(), )); } - let root = &plan.scope.placements[&plan.scope.placement]; - let delta = Position { - x: (requested.x / 10.).round() * 10. - root.position.x, - y: (requested.y / 10.).round() * 10. - root.position.y, - }; - let mut diagram = plan.candidate.diagrams[&plan.scope.diagram.id].clone(); - let mut commands = Vec::new(); - let mut deltas = BTreeMap::new(); - for (id, original) in &plan.scope.placements { - let node = diagram.nodes.get_mut(id).ok_or(Refusal::StaleScope)?; - node.position = Position { - x: original.position.x + delta.x, - y: original.position.y + delta.y, - }; - if !(Rect { - position: node.position, - size: node.size, - }) - .valid() - { - return Err(Refusal::InvalidCandidate( - "Moved coordinates exceed the model bounds.".into(), - )); - } - deltas.insert(*id, delta); - if node != original { - commands.push(Command::EditNode { - diagram: diagram.id, - original: *id, - node: node.clone(), - }); - } - } - // Resolve endpoints using the captured accepted semantics, not a newly - // regrouped descendant walk. Own-edge deletions were removed above already. - for route in projection::translated_routes(&plan.accepted, &plan.scope.diagram, &deltas) { - if let Some(existing) = diagram.routes.iter_mut().find(|r| r.edge == route.edge) { - if route.waypoints.iter().any(|p| { - !p.x.is_finite() - || !p.y.is_finite() - || p.x.abs() > MAX_COORDINATE - || p.y.abs() > MAX_COORDINATE - }) { - return Err(Refusal::InvalidCandidate( - "Moved route exceeds the model bounds.".into(), - )); - } - *existing = route.clone(); - commands.push(Command::SetEdgeRoute { - diagram: diagram.id, - route, - }); - } - } + let geometry::Translation { + mut diagram, + mut commands, + delta, + } = geometry::translate( + &plan.accepted, + &plan.scope.diagram, + &plan.candidate.diagrams[&plan.scope.diagram.id], + &plan.scope.placements, + plan.scope.placement, + requested, + )?; let mut unfolded = None; if let Destination::Performer(performer) = plan.destination { let target = presentation.target.ok_or_else(|| { @@ -833,4 +844,5 @@ fn exact_project(expected: &Project, actual: &Project) -> Result<(), Refusal> { } #[cfg(test)] +#[path = "allocation_drag/tests.rs"] mod tests; diff --git a/crates/oa-desktop/src/allocation_drag/arrange.rs b/crates/oa-desktop/src/allocation_drag/arrange.rs new file mode 100644 index 00000000..8a8a7311 --- /dev/null +++ b/crates/oa-desktop/src/allocation_drag/arrange.rs @@ -0,0 +1,232 @@ +//! Explicit, semantics-preserving arrangement of one or more Allocation cards. +//! +//! The App must admit the entire original typed selection before converting it +//! to placement/element identities. These values do not grant input authority. +use super::*; + +#[derive(Debug)] +pub struct Plan { + accepted: Project, + diagram: Id, + selected: BTreeMap, + anchor: Id, + placements: BTreeMap, + expanded_ids: BTreeSet, + index: ViewIndex, + before_visible: ProjectedView, + before_expanded: ProjectedView, +} + +impl Plan { + pub fn project(&self) -> &Project { + &self.accepted + } + + pub fn index(&self) -> &ViewIndex { + &self.index + } + + pub fn affected(&self) -> &BTreeMap { + &self.placements + } + + pub fn selected(&self) -> &BTreeMap { + &self.selected + } + + pub fn anchor(&self) -> Id { + self.anchor + } + + pub fn diagram(&self) -> &Diagram { + &self.accepted.diagrams[&self.diagram] + } +} + +/// Capture every original placement identity before normalizing semantic roots. +/// Document/revision/selection-generation and clean-owner guards belong to App. +pub fn capture( + p: &Project, + diagram: Id, + selected: &BTreeMap, + anchor: Id, +) -> Result { + validate_candidate(p).map_err(|e| Refusal::InvalidCandidate(e.to_string()))?; + let d = p.diagrams.get(&diagram).ok_or(Refusal::StaleScope)?; + if d.projection != ProjectionKind::Allocation + || selected.is_empty() + || !selected.contains_key(&anchor) + { + return Err(Refusal::Ineligible( + "Arrange requires selected placements and a selected anchor in an Allocation view." + .into(), + )); + } + let index = ViewIndex::new(p); + let before_visible = projection::project_diagram_with_index(p, d, &index); + let visible: BTreeMap<_, _> = before_visible + .nodes + .iter() + .map(|n| (n.id, n.element)) + .collect(); + let mut work = BTreeMap::new(); + let mut performers = Vec::new(); + for (placement, element) in selected { + let node = d.nodes.get(placement).ok_or(Refusal::StaleScope)?; + let record = p.elements.get(element).ok_or(Refusal::StaleScope)?; + if node.element != *element || visible.get(placement) != Some(element) { + return Err(Refusal::StaleScope); + } + if record.layer() != d.layer || !(record.kind().is_work() || record.kind().is_performer()) { + return Err(Refusal::Ineligible( + "Arrange requires only activities, functions or performers in this layer.".into(), + )); + } + if record.kind().is_work() { + work.insert(*element, *placement); + } else { + performers.push(*placement); + } + } + + let mut placements = BTreeMap::new(); + for (element, placement) in &work { + let root = &p.elements[element]; + let mut ancestor = root.parent; + let mut covered = false; + let mut seen = BTreeSet::new(); + while let Some(id) = ancestor { + if !seen.insert(id) { + return Err(Refusal::StaleScope); + } + let parent = p.elements.get(&id).ok_or(Refusal::StaleScope)?; + if parent.kind() != root.kind() { + break; + } + if work.contains_key(&id) { + covered = true; + break; + } + ancestor = parent.parent; + } + if !covered { + placements.extend(capture_validated(p, diagram, *element, *placement)?.placements); + } + } + for performer in performers { + for id in + std::iter::once(performer).chain(projection::descendant_nodes(p, diagram, performer)) + { + placements.insert(id, d.nodes.get(&id).ok_or(Refusal::StaleScope)?.clone()); + } + } + + // Arrange preserves ancestry, so accepted diagnostic identities also apply + // to every translated candidate. + let expanded_ids = geometry::expansion_ids(p, d, &index, &placements)?; + let before_expanded = geometry::expanded_view(p, d, &index, &expanded_ids); + Ok(Plan { + accepted: p.clone(), + diagram, + selected: selected.clone(), + anchor, + placements, + expanded_ids, + index, + before_visible, + before_expanded, + }) +} + +/// Snap the captured anchor once; preview the complete union without cloning or +/// validating a Project, rebuilding its semantic index, or expanding scope. +pub fn place( + plan: &Plan, + requested: Position, + presentation: PresentationContext, +) -> Result { + if presentation.target.is_some() { + return Err(Refusal::Ineligible( + "Arrange only has no performer destination.".into(), + )); + } + if !requested.x.is_finite() + || !requested.y.is_finite() + || !presentation.viewport.valid() + || !presentation.zoom.is_finite() + || presentation.zoom <= 0. + { + return Err(Refusal::LayoutRequired( + "Make room or adjust the view, then drag again.".into(), + )); + } + let geometry::Translation { + diagram, + commands, + delta, + } = geometry::translate( + &plan.accepted, + plan.diagram(), + plan.diagram(), + &plan.placements, + plan.anchor, + requested, + )?; + let view = projection::project_diagram_with_index(&plan.accepted, &diagram, &plan.index); + let expanded = + geometry::expanded_view(&plan.accepted, &diagram, &plan.index, &plan.expanded_ids); + // Preserve Arrange's refusal precedence: projected bounds, then rigidity, + // then overlap growth. The shared scene check combines bounds and overlaps. + let scenes = match geometry::check_scenes( + &plan.before_visible, + &plan.before_expanded, + &view, + &expanded, + ) { + Err(error @ Refusal::InvalidCandidate(_)) => return Err(error), + result => result, + }; + geometry::check_rigid(&plan.before_expanded, &expanded, &plan.placements, delta)?; + scenes?; + geometry::check_headers(&view, &plan.selected, presentation.viewport)?; + Ok(PlacementPlan { + diagram, + view, + delta, + unfolded: None, + requested, + presentation, + commands, + }) +} + +/// Recompute the candidate and compare the complete expected Project before the +/// caller performs one source-admitted transaction. None means no history entry. +pub fn finish( + plan: &Plan, + live: &Project, + placement: &PlacementPlan, +) -> Result, Refusal> { + if live != &plan.accepted { + return Err(Refusal::StaleScope); + } + let checked = place(plan, placement.requested, placement.presentation)?; + if checked.diagram != placement.diagram + || checked.view != placement.view + || checked.delta != placement.delta + || checked.unfolded != placement.unfolded + || checked.commands != placement.commands + { + return Err(Refusal::StaleScope); + } + let mut expected = plan.accepted.clone(); + expected + .diagrams + .insert(checked.diagram.id, checked.diagram); + let command = Command::Batch(checked.commands); + let actual = prepare_command(live, &command) + .map_err(|e| Refusal::InvalidCandidate(e.to_string()))? + .project; + exact_project(&expected, &actual)?; + Ok((actual != *live).then_some(command)) +} diff --git a/crates/oa-desktop/src/allocation_drag/arrange_tests.rs b/crates/oa-desktop/src/allocation_drag/arrange_tests.rs new file mode 100644 index 00000000..f3c86973 --- /dev/null +++ b/crates/oa-desktop/src/allocation_drag/arrange_tests.rs @@ -0,0 +1,443 @@ +use super::*; + +struct Distributed { + f: Fixture, + a: Id, + b: Id, + x: Id, + y: Id, + internal: Id, +} + +fn distributed(layer: Layer, folded: bool) -> Distributed { + let mut f = fixture(layer); + let kind = f.p.elements[&f.w].kind(); + let x = f.p.add_element(kind, layer, "X", Some(f.w)).unwrap(); + let y = f.p.add_element(kind, layer, "Y", Some(f.w)).unwrap(); + let outside = f.p.add_element(kind, layer, "Outside", None).unwrap(); + let xn = f.p.place(f.d, x, xy(140., 200.)); + let yn = f.p.place(f.d, y, xy(940., 200.)); + f.p.place(f.d, outside, xy(1800., 800.)); + f.p.relate(RelationKind::Allocation, "A owns X", f.a, x); + f.p.relate(RelationKind::Allocation, "B owns Y", f.b, y); + let exchange = match layer { + Layer::Oa => RelationKind::Interaction, + Layer::Sa => RelationKind::FunctionalExchange, + }; + let internal = f.p.relate(exchange, "X to Y", x, y); + let external = f.p.relate(exchange, "Y to Outside", y, outside); + let d = f.p.diagrams.get_mut(&f.d).unwrap(); + let a = d.nodes.values().find(|n| n.element == f.a).unwrap().id; + let b = d.nodes.values().find(|n| n.element == f.b).unwrap().id; + d.nodes.get_mut(&a).unwrap().collapsed = folded; + d.nodes.get_mut(&f.n).unwrap().position = xy(100., 800.); + d.routes = vec![route(internal), route(external)]; + validate_candidate(&f.p).unwrap(); + Distributed { + f, + a, + b, + x: xn, + y: yn, + internal, + } +} + +fn identities(m: &Distributed, ids: &[Id]) -> BTreeMap { + ids.iter() + .map(|id| (*id, m.f.p.diagrams[&m.f.d].nodes[id].element)) + .collect() +} + +fn expected(m: &Distributed, include_a: bool) -> Project { + let mut p = m.f.p.clone(); + let d = p.diagrams.get_mut(&m.f.d).unwrap(); + for (id, position) in [ + (m.f.n, xy(500., 800.)), + (m.x, xy(540., 200.)), + (m.y, xy(1340., 200.)), + ] { + d.nodes.get_mut(&id).unwrap().position = position; + } + if include_a { + d.nodes.get_mut(&m.a).unwrap().position = xy(500., 100.); + } + d.routes + .iter_mut() + .find(|r| r.edge == m.internal) + .unwrap() + .waypoints = vec![xy(820., 400.), xy(820., 550.)]; + p +} + +#[test] +fn explicit_arrange_distributed_oa_sa_exact_scope_routes_source_history_and_anchor() { + for layer in [Layer::Oa, Layer::Sa] { + for folded in [false, true] { + for include_a in [false, true] { + let m = distributed(layer, folded); + let members = if include_a { + vec![m.f.n, m.a] + } else { + vec![m.f.n] + }; + let selected = identities(&m, &members); + for anchor in selected.keys().copied() { + let plan = arrange::capture(&m.f.p, m.f.d, &selected, anchor).unwrap(); + assert_eq!(plan.selected(), &selected); + let mut affected = BTreeSet::from([m.f.n, m.x, m.y]); + if include_a { + affected.insert(m.a); + } + assert_eq!( + plan.affected().keys().copied().collect::>(), + affected + ); + let requested = if anchor == m.a { + xy(503., 102.) + } else { + xy(503., 802.) + }; + let placed = + arrange::place(&plan, requested, presentation(&m.f.p, m.f.d, None)) + .unwrap(); + assert_eq!(placed.delta, xy(400., 0.)); + assert_eq!(placed.unfolded, None); + let command = arrange::finish(&plan, &m.f.p, &placed).unwrap().unwrap(); + assert_eq!(roundtrip(&m.f.p, command), expected(&m, include_a)); + // Explicit Arrange does not widen ordinary distributed assignment. + assert!(matches!( + plan_semantics( + &m.f.p, + &scope(&m.f), + Destination::Performer(m.f.a), + &BTreeMap::new() + ), + Err(Refusal::MixedLayoutRequired) + )); + } + } + } + } +} + +#[test] +fn explicit_arrange_performer_closure_does_not_acquire_incidental_semantic_root() { + for layer in [Layer::Oa, Layer::Sa] { + let m = distributed(layer, true); + let selected = identities(&m, &[m.a]); + let plan = arrange::capture(&m.f.p, m.f.d, &selected, m.a).unwrap(); + assert_eq!( + plan.affected().keys().copied().collect::>(), + BTreeSet::from([m.a, m.x]) + ); + let placed = + arrange::place(&plan, xy(500., 100.), presentation(&m.f.p, m.f.d, None)).unwrap(); + let mut expected = m.f.p.clone(); + let d = expected.diagrams.get_mut(&m.f.d).unwrap(); + d.nodes.get_mut(&m.a).unwrap().position = xy(500., 100.); + d.nodes.get_mut(&m.x).unwrap().position = xy(540., 200.); + // X-to-Y is now one-ended; all saved pins remain byte-for-byte unchanged. + assert_eq!( + roundtrip( + &m.f.p, + arrange::finish(&plan, &m.f.p, &placed).unwrap().unwrap() + ), + expected + ); + } +} + +#[test] +fn explicit_arrange_validates_all_original_members_before_normalizing() { + let mut m = distributed(Layer::Oa, false); + let selected = identities(&m, &[m.f.n, m.x]); + let plan = arrange::capture(&m.f.p, m.f.d, &selected, m.x).unwrap(); + assert_eq!(plan.selected(), &selected); + assert_eq!(plan.affected().len(), 3); + let placed = arrange::place(&plan, xy(540., 200.), presentation(&m.f.p, m.f.d, None)).unwrap(); + assert_eq!( + roundtrip( + &m.f.p, + arrange::finish(&plan, &m.f.p, &placed).unwrap().unwrap() + ), + expected(&m, false) + ); + let mut stale = selected.clone(); + stale.insert(m.x, m.f.w); + assert!(arrange::capture(&m.f.p, m.f.d, &stale, m.f.n).is_err()); + m.f.p + .diagrams + .get_mut(&m.f.d) + .unwrap() + .nodes + .get_mut(&m.a) + .unwrap() + .collapsed = true; + assert!( + arrange::capture(&m.f.p, m.f.d, &selected, m.f.n).is_err(), + "covered selected X is hidden and cannot disappear in normalization" + ); + assert!(arrange::capture(&m.f.p, m.f.d, &BTreeMap::new(), m.f.n).is_err()); + assert!(arrange::capture(&m.f.p, m.f.d, &identities(&m, &[m.f.n]), m.a).is_err()); + assert!(arrange::capture(&m.f.p, m.f.second, &identities(&m, &[m.f.n]), m.f.n).is_err()); + let foreign = Id::new_v4(); + assert!( + arrange::capture( + &m.f.p, + m.f.d, + &BTreeMap::from([(m.f.n, m.f.w), (foreign, m.f.w)]), + m.f.n + ) + .is_err() + ); + m.f.p.diagrams.get_mut(&m.f.d).unwrap().projection = ProjectionKind::Overview; + assert!(arrange::capture(&m.f.p, m.f.d, &identities(&m, &[m.f.n]), m.f.n).is_err()); +} + +#[test] +fn explicit_arrange_simultaneous_roots_may_enter_each_others_vacated_bounds() { + for layer in [Layer::Oa, Layer::Sa] { + let mut f = fixture(layer); + let kind = f.p.elements[&f.w].kind(); + let y = f.p.add_element(kind, layer, "Second root", None).unwrap(); + let yn = f.p.place(f.d, y, xy(400., 500.)); + let relation = f.p.relate( + if layer == Layer::Oa { + RelationKind::Interaction + } else { + RelationKind::FunctionalExchange + }, + "Between roots", + f.w, + y, + ); + f.p.diagrams.get_mut(&f.d).unwrap().routes = vec![route(relation)]; + let context = presentation(&f.p, f.d, None); + let single = arrange::capture(&f.p, f.d, &BTreeMap::from([(f.n, f.w)]), f.n).unwrap(); + assert!( + arrange::place(&single, xy(400., 500.), context).is_err(), + "singleton would collide with stationary Y" + ); + let plan = + arrange::capture(&f.p, f.d, &BTreeMap::from([(f.n, f.w), (yn, y)]), f.n).unwrap(); + let placed = arrange::place(&plan, xy(400., 500.), context).unwrap(); + let mut expected = f.p.clone(); + let d = expected.diagrams.get_mut(&f.d).unwrap(); + d.nodes.get_mut(&f.n).unwrap().position = xy(400., 500.); + d.nodes.get_mut(&yn).unwrap().position = xy(700., 500.); + d.routes[0].waypoints = vec![xy(720., 400.), xy(720., 550.)]; + assert_eq!( + roundtrip( + &f.p, + arrange::finish(&plan, &f.p, &placed).unwrap().unwrap() + ), + expected + ); + } +} + +#[test] +fn explicit_arrange_preserves_roles_and_does_not_invent_unplaced_descendants() { + let mut f = fixture(Layer::Oa); + let a = f.a; + role(&mut f, Some(a)); + f.p.diagrams + .get_mut(&f.d) + .unwrap() + .nodes + .get_mut(&f.n) + .unwrap() + .position = xy(150., 200.); + let plan = arrange::capture(&f.p, f.d, &BTreeMap::from([(f.n, f.w)]), f.n).unwrap(); + let placed = arrange::place(&plan, xy(160., 210.), presentation(&f.p, f.d, None)).unwrap(); + let mut role_expected = f.p.clone(); + role_expected + .diagrams + .get_mut(&f.d) + .unwrap() + .nodes + .get_mut(&f.n) + .unwrap() + .position = xy(160., 210.); + assert_eq!( + roundtrip( + &f.p, + arrange::finish(&plan, &f.p, &placed).unwrap().unwrap() + ), + role_expected + ); + for layer in [Layer::Oa, Layer::Sa] { + let mut m = distributed(layer, true); + let kind = m.f.p.elements[&m.f.w].kind(); + m.f.p + .add_element(kind, layer, "Unplaced leaf", Some(m.f.w)) + .unwrap(); + let plan = arrange::capture(&m.f.p, m.f.d, &identities(&m, &[m.f.n]), m.f.n).unwrap(); + assert_eq!(plan.affected().len(), 3); + let placed = + arrange::place(&plan, xy(500., 800.), presentation(&m.f.p, m.f.d, None)).unwrap(); + assert_eq!( + roundtrip( + &m.f.p, + arrange::finish(&plan, &m.f.p, &placed).unwrap().unwrap() + ), + expected(&m, false) + ); + } +} + +#[test] +fn explicit_arrange_detects_hidden_moved_child_and_stationary_enclosure_growth() { + for layer in [Layer::Oa, Layer::Sa] { + for hidden in [false, true] { + let mut m = distributed(layer, hidden); + let kind = m.f.p.elements[&m.f.w].kind(); + let z = m.f.p.add_element(kind, layer, "Obstacle", None).unwrap(); + let zn = m.f.p.place( + m.f.d, + z, + if hidden { + xy(550., 230.) + } else { + xy(1200., 100.) + }, + ); + // The independent candidate differs only by literal admitted edits. + // Establish which geometric case the real planner must reject. + let candidate = expected(&m, true); + let before = projection::project_view(&m.f.p, m.f.d); + let after = projection::project_view(&candidate, m.f.d); + let find = + |view: &ProjectedView, id| view.nodes.iter().find(|n| n.id == id).unwrap().clone(); + if hidden { + assert!(!overlaps(&before, &after)); + assert!(!after.nodes.iter().any(|n| n.id == m.x)); + let mut diagnostic = candidate.diagrams[&m.f.d].clone(); + diagnostic.nodes.get_mut(&m.a).unwrap().collapsed = false; + let expanded = projection::project_diagram(&candidate, &diagnostic); + assert_eq!( + overlap(&find(&expanded, m.x), &find(&expanded, zn)), + if layer == Layer::Oa { 13696. } else { 20116. } + ); + } else { + assert_eq!(overlap(&find(&before, m.b), &find(&before, zn)), 0.); + assert_eq!(overlap(&find(&after, m.b), &find(&after, zn)), 21056.); + for id in [m.a, m.f.n, m.x, m.y] { + assert_eq!(overlap(&find(&after, id), &find(&after, zn)), 0.); + } + assert_eq!( + candidate.diagrams[&m.f.d].nodes[&m.b], + m.f.p.diagrams[&m.f.d].nodes[&m.b] + ); + } + let plan = + arrange::capture(&m.f.p, m.f.d, &identities(&m, &[m.a, m.f.n]), m.f.n).unwrap(); + assert!(matches!( + arrange::place(&plan, xy(500., 800.), presentation(&m.f.p, m.f.d, None)), + Err(Refusal::LayoutRequired(_)) + )); + } + } +} + +#[test] +fn explicit_arrange_preserves_existing_overlap_but_refuses_its_growth() { + let mut f = fixture(Layer::Oa); + let y = + f.p.add_element(Kind::Activity, Layer::Oa, "Existing overlap", None) + .unwrap(); + let yn = f.p.place(f.d, y, xy(250., 500.)); + let context = presentation(&f.p, f.d, None); + let single = arrange::capture(&f.p, f.d, &BTreeMap::from([(f.n, f.w)]), f.n).unwrap(); + assert!(arrange::place(&single, xy(110., 500.), context).is_err()); + let plan = arrange::capture(&f.p, f.d, &BTreeMap::from([(f.n, f.w), (yn, y)]), f.n).unwrap(); + let placed = arrange::place(&plan, xy(400., 500.), context).unwrap(); + let mut expected = f.p.clone(); + let d = expected.diagrams.get_mut(&f.d).unwrap(); + d.nodes.get_mut(&f.n).unwrap().position = xy(400., 500.); + d.nodes.get_mut(&yn).unwrap().position = xy(550., 500.); + assert_eq!( + roundtrip( + &f.p, + arrange::finish(&plan, &f.p, &placed).unwrap().unwrap() + ), + expected + ); +} + +#[test] +fn explicit_arrange_preserves_unrelated_folded_interior_diagnostic_boundary() { + for layer in [Layer::Oa, Layer::Sa] { + let mut m = distributed(layer, true); + let performer = if layer == Layer::Oa { + Kind::Actor + } else { + Kind::SystemActor + }; + let d = m.f.p.add_element(performer, layer, "D", None).unwrap(); + let z = + m.f.p + .add_element(m.f.p.elements[&m.f.w].kind(), layer, "Hidden Z", None) + .unwrap(); + m.f.p.relate(RelationKind::Allocation, "D owns Z", d, z); + let dn = m.f.p.place(m.f.d, d, xy(2500., 100.)); + m.f.p.place(m.f.d, z, xy(550., 230.)); + m.f.p + .diagrams + .get_mut(&m.f.d) + .unwrap() + .nodes + .get_mut(&dn) + .unwrap() + .collapsed = true; + let plan = arrange::capture(&m.f.p, m.f.d, &identities(&m, &[m.a, m.f.n]), m.f.n).unwrap(); + let placed = + arrange::place(&plan, xy(500., 800.), presentation(&m.f.p, m.f.d, None)).unwrap(); + assert_eq!( + roundtrip( + &m.f.p, + arrange::finish(&plan, &m.f.p, &placed).unwrap().unwrap() + ), + expected(&m, true) + ); + } +} + +#[test] +fn explicit_arrange_noop_stale_tamper_target_and_all_selected_headers() { + let m = distributed(Layer::Sa, false); + let selected = identities(&m, &[m.a, m.f.n]); + let plan = arrange::capture(&m.f.p, m.f.d, &selected, m.f.n).unwrap(); + let context = presentation(&m.f.p, m.f.d, None); + let noop = arrange::place(&plan, xy(103., 802.), context).unwrap(); + assert!(arrange::finish(&plan, &m.f.p, &noop).unwrap().is_none()); + let mut placed = arrange::place(&plan, xy(500., 800.), context).unwrap(); + let mut stale = m.f.p.clone(); + stale.name.push_str(" changed"); + assert!(matches!( + arrange::finish(&plan, &stale, &placed), + Err(Refusal::StaleScope) + )); + placed.diagram.nodes.get_mut(&m.b).unwrap().position.x += 1.; + assert!(matches!( + arrange::finish(&plan, &m.f.p, &placed), + Err(Refusal::StaleScope) + )); + assert!( + arrange::place( + &plan, + xy(500., 800.), + presentation(&m.f.p, m.f.d, Some(m.f.b)) + ) + .is_err() + ); + let mut clipped = context; + clipped.viewport.position = xy(400., 700.); + assert!( + arrange::place(&plan, xy(500., 800.), clipped).is_err(), + "anchor C visible, other selected A header clipped" + ); + assert!(arrange::place(&plan, xy(f32::NAN, 800.), context).is_err()); + assert!(arrange::place(&plan, xy(MAX_COORDINATE, 800.), context).is_err()); +} diff --git a/crates/oa-desktop/src/allocation_drag/assignment.rs b/crates/oa-desktop/src/allocation_drag/assignment.rs new file mode 100644 index 00000000..64bd47e8 --- /dev/null +++ b/crates/oa-desktop/src/allocation_drag/assignment.rs @@ -0,0 +1,444 @@ +//! Ordinary work-only group assignment, with one combined semantic/geometry plan. +//! App retains authority over the complete typed selection and gesture lifetime. +use super::*; +use std::sync::Arc; + +#[derive(Debug)] +struct Captured { + accepted: Project, + diagram: Id, + selected: BTreeMap, + anchor: Id, + roots: Vec, + placements: BTreeMap, + leaves: BTreeMap, + index: ViewIndex, + before_visible: ProjectedView, + before_expanded: ProjectedView, +} + +/// Destination plans share one immutable accepted capture. +#[derive(Debug, Clone)] +pub struct Scope(Arc); + +impl Scope { + pub fn project(&self) -> &Project { + &self.0.accepted + } + + pub fn index(&self) -> &ViewIndex { + &self.0.index + } + + pub fn diagram(&self) -> &Diagram { + &self.0.accepted.diagrams[&self.0.diagram] + } + + pub fn selected(&self) -> &BTreeMap { + &self.0.selected + } + + pub fn affected(&self) -> &BTreeMap { + &self.0.placements + } + + pub fn anchor(&self) -> Id { + self.0.anchor + } + + pub fn leaves(&self) -> &BTreeMap { + &self.0.leaves + } +} + +/// App must reject non-placement members before constructing this complete map. +/// Every original identity is checked before redundant work roots are removed. +pub fn capture( + p: &Project, + diagram: Id, + selected: &BTreeMap, + anchor: Id, +) -> Result { + validate_candidate(p).map_err(|e| Refusal::InvalidCandidate(e.to_string()))?; + let d = p.diagrams.get(&diagram).ok_or(Refusal::StaleScope)?; + if d.projection != ProjectionKind::Allocation + || selected.is_empty() + || !selected.contains_key(&anchor) + { + return Err(Refusal::Ineligible( + "Assignment requires selected work placements and a selected anchor in an Allocation view." + .into(), + )); + } + let index = ViewIndex::new(p); + let before_visible = projection::project_diagram_with_index(p, d, &index); + let visible: BTreeMap<_, _> = before_visible + .nodes + .iter() + .map(|node| (node.id, node.element)) + .collect(); + let mut work = BTreeMap::new(); + for (placement, element) in selected { + let node = d.nodes.get(placement).ok_or(Refusal::StaleScope)?; + let record = p.elements.get(element).ok_or(Refusal::StaleScope)?; + if node.element != *element || visible.get(placement) != Some(element) { + return Err(Refusal::StaleScope); + } + if !record.kind().is_work() || record.layer() != d.layer { + return Err(Refusal::Ineligible( + "Assignment requires only activities or functions in this layer. Use Arrange only for a supported mixed selection." + .into(), + )); + } + work.insert(*element, *placement); + } + + let mut roots = Vec::new(); + let mut placements = BTreeMap::new(); + let mut leaves = BTreeMap::new(); + for (element, placement) in &work { + let record = &p.elements[element]; + let mut ancestor = record.parent; + let mut seen = BTreeSet::new(); + let mut covered = false; + while let Some(id) = ancestor { + if !seen.insert(id) { + return Err(Refusal::StaleScope); + } + let parent = p.elements.get(&id).ok_or(Refusal::StaleScope)?; + if parent.kind() != record.kind() { + break; + } + if work.contains_key(&id) { + covered = true; + break; + } + ancestor = parent.parent; + } + if covered { + continue; + } + let root = capture_validated(p, diagram, *element, *placement)?; + if root.work_records.len() > 1 && root.uniform == Uniform::MixedOrUnresolved { + return Err(Refusal::MixedLayoutRequired); + } + for (id, leaf) in &root.leaves { + if leaves.insert(*id, leaf.clone()).is_some() { + return Err(Refusal::StaleScope); + } + } + placements.extend(root.placements.clone()); + roots.push(root); + } + let expansion = geometry::expansion_ids(p, d, &index, &placements)?; + let before_expanded = geometry::expanded_view(p, d, &index, &expansion); + if roots + .iter() + .any(|root| root.work_records.len() > 1 && !coherent(root, &before_expanded)) + { + return Err(Refusal::MixedLayoutRequired); + } + Ok(Scope(Arc::new(Captured { + accepted: p.clone(), + diagram, + selected: selected.clone(), + anchor, + roots, + placements, + leaves, + index, + before_visible, + before_expanded, + }))) +} + +#[derive(Debug)] +pub struct SemanticPlan { + scope: Scope, + destination: Destination, + candidate: Project, + index: ViewIndex, + commands: Vec, + changed: BTreeSet, + expanded_ids: BTreeSet, + label: String, + warnings: Vec, +} + +impl SemanticPlan { + pub fn scope(&self) -> &Scope { + &self.scope + } + + pub fn project(&self) -> &Project { + &self.candidate + } + + pub fn index(&self) -> &ViewIndex { + &self.index + } + + pub fn destination(&self) -> Destination { + self.destination + } + + pub fn label(&self) -> &str { + &self.label + } + + pub fn warnings(&self) -> &[Issue] { + &self.warnings + } + + pub fn changed_count(&self) -> usize { + self.changed.len() + } + + /// Already effective at the performer, or already unassigned for Outside. + pub fn already_correct_count(&self) -> usize { + self.scope.leaves().len() - self.changed.len() + } +} + +/// Build one destination candidate. Per-root decisions read accepted facts; +/// aliases and new identities are reserved in the combined expected Project. +pub fn plan_semantics( + scope: &Scope, + destination: Destination, + allocation_ids: &BTreeMap, +) -> Result { + let accepted = scope.project(); + match destination { + Destination::LayoutOnly => { + return Err(Refusal::Ineligible( + "Use the explicit Arrange only planner for presentation-only movement.".into(), + )); + } + Destination::Performer(id) => { + if !accepted.elements.get(&id).is_some_and(|element| { + element.kind().is_performer() && element.layer() == scope.diagram().layer + }) { + return Err(Refusal::Ineligible( + "Choose a performer in this layer.".into(), + )); + } + } + Destination::OutsidePerformers => {} + } + let mut expected = accepted.clone(); + let mut edits = IntendedEdits::default(); + for root in &scope.0.roots { + append_semantic_edits( + accepted, + root, + destination, + allocation_ids, + &mut expected, + &mut edits, + )?; + } + for diagram in expected.diagrams.values_mut() { + diagram + .routes + .retain(|route| !edits.deleted.contains(&route.edge)); + } + let candidate = prepare_command(accepted, &Command::Batch(edits.commands.clone())) + .map_err(|e| Refusal::InvalidCandidate(e.to_string()))? + .project; + exact_project(&expected, &candidate)?; + let warnings = validation::validate(&candidate) + .into_iter() + .filter(|issue| issue.severity == Severity::Warning) + .collect(); + let index = ViewIndex::new(&candidate); + // Assignment may change visual ancestry: never reuse accepted expansion IDs. + let expanded_ids = geometry::expansion_ids( + &candidate, + &candidate.diagrams[&scope.0.diagram], + &index, + scope.affected(), + )?; + let total = scope.leaves().len(); + let changed = edits.changed.len(); + let already_correct = total - changed; + let noun = match (scope.diagram().layer, total) { + (Layer::Oa, 1) => "activity", + (Layer::Oa, _) => "activities", + (Layer::Sa, 1) => "function", + (Layer::Sa, _) => "functions", + }; + let retained_roles = scope + .leaves() + .values() + .any(|leaf| !leaf.role_assignments.is_empty()); + let label = match destination { + Destination::Performer(id) if changed == 0 => { + format!( + "Arrange within {} — all {total} leaf {noun} already assigned", + accepted.named(id) + ) + } + Destination::Performer(id) => format!( + "Assign {changed} of {total} leaf {noun} to {}{}{}", + accepted.named(id), + if already_correct > 0 { + format!(" · {already_correct} already assigned") + } else { + String::new() + }, + if retained_roles { + " — role responsibility retained" + } else { + "" + }, + ), + Destination::OutsidePerformers if changed == 0 => { + "Arrange unassigned work — decomposition retained".into() + } + Destination::OutsidePerformers => format!( + "Remove assignments from {changed} of {total} leaf {noun} — decomposition retained" + ), + Destination::LayoutOnly => unreachable!(), + }; + Ok(SemanticPlan { + scope: scope.clone(), + destination, + candidate, + index, + commands: edits.commands, + changed: edits.changed, + expanded_ids, + label, + warnings, + }) +} + +/// Translate the entire captured Q once, then evaluate the combined candidate. +/// This path does not clone/validate a Project or rebuild semantic indexes. +pub fn place( + plan: &SemanticPlan, + requested: Position, + presentation: PresentationContext, +) -> Result { + if !requested.x.is_finite() + || !requested.y.is_finite() + || !presentation.viewport.valid() + || !presentation.zoom.is_finite() + || presentation.zoom <= 0. + { + return Err(Refusal::LayoutRequired( + "Make room or adjust the view, then drag again.".into(), + )); + } + let scope = &plan.scope; + let geometry::Translation { + mut diagram, + mut commands, + delta, + } = geometry::translate( + scope.project(), + scope.diagram(), + &plan.candidate.diagrams[&scope.0.diagram], + scope.affected(), + scope.anchor(), + requested, + )?; + let mut unfolded = None; + if let Destination::Performer(performer) = plan.destination { + let target = presentation.target.ok_or_else(|| { + Refusal::LayoutRequired("A visible accepted performer target is required.".into()) + })?; + if !scope + .0 + .before_visible + .nodes + .iter() + .any(|node| node.id == target.placement && node.element == performer) + { + return Err(Refusal::LayoutRequired( + "Make room or adjust the view, then drag again.".into(), + )); + } + let target_node = diagram + .nodes + .get_mut(&target.placement) + .ok_or(Refusal::StaleScope)?; + if target_node.element != performer { + return Err(Refusal::StaleScope); + } + if !plan.changed.is_empty() && target_node.collapsed { + target_node.collapsed = false; + unfolded = Some(target_node.id); + commands.push(Command::EditNode { + diagram: diagram.id, + original: target_node.id, + node: target_node.clone(), + }); + } + } else if presentation.target.is_some() { + return Err(Refusal::Ineligible( + "An outside destination has no performer target.".into(), + )); + } + let view = projection::project_diagram_with_index(&plan.candidate, &diagram, &plan.index); + let expanded = + geometry::expanded_view(&plan.candidate, &diagram, &plan.index, &plan.expanded_ids); + if scope + .0 + .roots + .iter() + .any(|root| root.work_records.len() > 1 && !coherent(root, &expanded)) + { + return Err(Refusal::MixedLayoutRequired); + } + geometry::check_scenes( + &scope.0.before_visible, + &scope.0.before_expanded, + &view, + &expanded, + )?; + geometry::check_rigid(&scope.0.before_expanded, &expanded, scope.affected(), delta)?; + geometry::check_headers(&view, scope.selected(), presentation.viewport)?; + Ok(PlacementPlan { + diagram, + view, + delta, + unfolded, + requested, + presentation, + commands, + }) +} + +/// Recheck immutable facts and recompute geometry before one final source batch. +/// Zero delta remains a transaction when assignment semantics changed. +pub fn finish( + plan: &SemanticPlan, + live: &Project, + placement: &PlacementPlan, +) -> Result, Refusal> { + if live != plan.scope.project() { + return Err(Refusal::StaleScope); + } + let checked = place(plan, placement.requested, placement.presentation)?; + if checked.diagram != placement.diagram + || checked.view != placement.view + || checked.delta != placement.delta + || checked.unfolded != placement.unfolded + || checked.commands != placement.commands + { + return Err(Refusal::StaleScope); + } + let mut expected = plan.candidate.clone(); + expected + .diagrams + .insert(checked.diagram.id, checked.diagram); + let mut commands = plan.commands.clone(); + commands.extend(checked.commands); + let command = Command::Batch(commands); + let actual = prepare_command(live, &command) + .map_err(|e| Refusal::InvalidCandidate(e.to_string()))? + .project; + exact_project(&expected, &actual)?; + Ok((actual != *live).then_some(command)) +} diff --git a/crates/oa-desktop/src/allocation_drag/assignment_boundary_tests.rs b/crates/oa-desktop/src/allocation_drag/assignment_boundary_tests.rs new file mode 100644 index 00000000..c08bd859 --- /dev/null +++ b/crates/oa-desktop/src/allocation_drag/assignment_boundary_tests.rs @@ -0,0 +1,497 @@ +use super::*; + +struct Composite { + f: Fixture, + child: Id, + child_node: Id, + unplaced: Id, +} + +fn composite(layer: Layer) -> Composite { + let mut f = fixture(layer); + let kind = f.p.elements[&f.w].kind(); + let child = + f.p.add_element(kind, layer, "Placed child", Some(f.w)) + .unwrap(); + let unplaced = + f.p.add_element(kind, layer, "Unplaced leaf", Some(f.w)) + .unwrap(); + let child_node = f.p.place(f.d, child, xy(140., 600.)); + validate_candidate(&f.p).unwrap(); + Composite { + f, + child, + child_node, + unplaced, + } +} + +fn expected_allocation(id: Id, alias: &str, name: &str, performer: Id, work: Id) -> Relation { + Relation { + id, + alias: alias.into(), + name: name.into(), + description: String::new(), + body: RelationBody::Allocation { + source: performer, + target: work, + }, + } +} + +#[test] +fn group_assignment_checks_original_identity_and_visibility_before_normalization() { + for layer in [Layer::Oa, Layer::Sa] { + let c = composite(layer); + let valid = BTreeMap::from([(c.f.n, c.f.w), (c.child_node, c.child)]); + let admitted = assignment::capture(&c.f.p, c.f.d, &valid, c.f.n).unwrap(); + assert_eq!(admitted.selected(), &valid); + let foreign = c.f.p.diagrams[&c.f.second] + .nodes + .values() + .find(|node| node.element == c.f.w) + .unwrap() + .id; + // Each extra member is semantically covered by the valid selected parent. + // Normalizing first would incorrectly hide its invalid original identity. + for invalid in [ + BTreeMap::from([(c.f.n, c.f.w), (c.child_node, c.unplaced)]), + BTreeMap::from([(c.f.n, c.f.w), (Id::new_v4(), c.child)]), + BTreeMap::from([(c.f.n, c.f.w), (foreign, c.f.w)]), + ] { + assert!(matches!( + assignment::capture(&c.f.p, c.f.d, &invalid, c.f.n), + Err(Refusal::StaleScope) + )); + } + let mut folded = c.f.p.clone(); + folded + .diagrams + .get_mut(&c.f.d) + .unwrap() + .nodes + .get_mut(&c.f.n) + .unwrap() + .collapsed = true; + validate_candidate(&folded).unwrap(); + assert!( + !projection::project_view(&folded, c.f.d) + .nodes + .iter() + .any(|node| node.id == c.child_node) + ); + // Hidden descendants remain in Q when only the visible parent is selected. + let parent_only = + assignment::capture(&folded, c.f.d, &BTreeMap::from([(c.f.n, c.f.w)]), c.f.n).unwrap(); + assert!(parent_only.affected().contains_key(&c.child_node)); + assert!(matches!( + assignment::capture(&folded, c.f.d, &valid, c.f.n), + Err(Refusal::StaleScope) + )); + } +} + +#[test] +fn group_assignment_normalizes_parent_child_and_assigns_unplaced_leaf_once() { + for layer in [Layer::Oa, Layer::Sa] { + let c = composite(layer); + let selected = BTreeMap::from([(c.f.n, c.f.w), (c.child_node, c.child)]); + let scope = assignment::capture(&c.f.p, c.f.d, &selected, c.f.n).unwrap(); + assert_eq!(scope.selected(), &selected); + assert_eq!( + scope.affected().keys().copied().collect::>(), + BTreeSet::from([c.f.n, c.child_node]) + ); + assert_eq!( + scope.leaves().keys().copied().collect::>(), + BTreeSet::from([c.child, c.unplaced]) + ); + let child_allocation = Id::new_v4(); + let unplaced_allocation = Id::new_v4(); + let ids = BTreeMap::from([ + (c.child, child_allocation), + (c.unplaced, unplaced_allocation), + ]); + let plan = assignment::plan_semantics(&scope, Destination::Performer(c.f.a), &ids).unwrap(); + assert_eq!(plan.changed_count(), 2); + let mut expected = c.f.p.clone(); + expected.relations.insert( + child_allocation, + expected_allocation( + child_allocation, + "a_performs_placed_child", + "A performs Placed child", + c.f.a, + c.child, + ), + ); + expected.relations.insert( + unplaced_allocation, + expected_allocation( + unplaced_allocation, + "a_performs_unplaced_leaf", + "A performs Unplaced leaf", + c.f.a, + c.unplaced, + ), + ); + assert_eq!(plan.project(), &expected); + let placed = assignment::place( + &plan, + xy(140., 200.), + presentation(&c.f.p, c.f.d, Some(c.f.a)), + ) + .unwrap(); + let mut clipped = presentation(&c.f.p, c.f.d, Some(c.f.a)); + clipped.viewport.size.height = 450.; // bottom350: parent header fits; selected child ends362. + for (id, should_fit) in [(c.f.n, true), (c.child_node, false)] { + let node = placed.view.nodes.iter().find(|node| node.id == id).unwrap(); + let header = Rect { + position: node.position, + size: Size { + width: node.size.width, + height: 62., + }, + }; + assert_eq!(clipped.viewport.contains(header), should_fit); + } + assert!( + matches!( + assignment::place(&plan, xy(140., 200.), clipped), + Err(Refusal::LayoutRequired(_)) + ), + "normalized-away selected child still requires its header" + ); + let d = expected.diagrams.get_mut(&c.f.d).unwrap(); + d.nodes.get_mut(&c.f.n).unwrap().position = xy(140., 200.); + d.nodes.get_mut(&c.child_node).unwrap().position = xy(180., 300.); + assert_eq!(placed.delta, xy(40., -300.)); + assert_eq!( + roundtrip( + &c.f.p, + assignment::finish(&plan, &c.f.p, &placed).unwrap().unwrap() + ), + expected + ); + assert_eq!( + expected.diagrams[&c.f.d].nodes.len(), + c.f.p.diagrams[&c.f.d].nodes.len() + ); + assert!( + !expected.diagrams[&c.f.d] + .nodes + .values() + .any(|node| node.element == c.unplaced) + ); + assert!( + !expected + .relations + .values() + .any(|relation| relation.kind() == RelationKind::Allocation + && relation.target() == c.f.w) + ); + } +} + +#[test] +fn group_assignment_distributed_parent_refuses_but_its_independent_leaves_can_reassign() { + for layer in [Layer::Oa, Layer::Sa] { + let mut c = composite(layer); + let second_node = c.f.p.place(c.f.d, c.unplaced, xy(940., 200.)); + let performer = + c.f.p + .add_element( + if layer == Layer::Oa { + Kind::Actor + } else { + Kind::SystemActor + }, + layer, + "C", + None, + ) + .unwrap(); + c.f.p.place(c.f.d, performer, xy(1700., 100.)); + let d = c.f.p.diagrams.get_mut(&c.f.d).unwrap(); + d.nodes.get_mut(&c.f.n).unwrap().position = xy(1700., 800.); + d.nodes.get_mut(&c.child_node).unwrap().position = xy(140., 200.); + let ax = + c.f.p + .relate(RelationKind::Allocation, "A child", c.f.a, c.child); + let by = + c.f.p + .relate(RelationKind::Allocation, "B child", c.f.b, c.unplaced); + validate_candidate(&c.f.p).unwrap(); + let visible = projection::project_view(&c.f.p, c.f.d); + for id in [c.f.n, c.child_node, second_node] { + assert!(visible.nodes.iter().any(|node| node.id == id)); + } + assert_eq!(scope(&c.f).uniform, Uniform::MixedOrUnresolved); + assert!(matches!( + assignment::capture(&c.f.p, c.f.d, &BTreeMap::from([(c.f.n, c.f.w)]), c.f.n,), + Err(Refusal::MixedLayoutRequired) + )); + let selected = BTreeMap::from([(c.child_node, c.child), (second_node, c.unplaced)]); + let captured = assignment::capture(&c.f.p, c.f.d, &selected, c.child_node).unwrap(); + let plan = assignment::plan_semantics( + &captured, + Destination::Performer(performer), + &BTreeMap::new(), + ) + .unwrap(); + let mut expected = c.f.p.clone(); + expected.relations.get_mut(&ax).unwrap().body = RelationBody::Allocation { + source: performer, + target: c.child, + }; + expected.relations.get_mut(&by).unwrap().body = RelationBody::Allocation { + source: performer, + target: c.unplaced, + }; + assert_eq!(plan.project(), &expected); + // One delta moves both leaves. The unselected semantic parent remains + // authored at (1700,800), while its derived enclosure may change. + let placed = assignment::place( + &plan, + xy(1740., 120.), + presentation(&c.f.p, c.f.d, Some(performer)), + ) + .unwrap(); + let d = expected.diagrams.get_mut(&c.f.d).unwrap(); + d.nodes.get_mut(&c.child_node).unwrap().position = xy(1740., 120.); + d.nodes.get_mut(&second_node).unwrap().position = xy(2540., 120.); + assert_eq!( + roundtrip( + &c.f.p, + assignment::finish(&plan, &c.f.p, &placed).unwrap().unwrap() + ), + expected + ); + } +} + +fn separate_unassigned(layer: Layer) -> (Fixture, Id, Id) { + let mut f = fixture(layer); + let companion = + f.p.add_element(f.p.elements[&f.w].kind(), layer, "Companion", None) + .unwrap(); + let companion_node = f.p.place(f.d, companion, xy(100., 1000.)); + validate_candidate(&f.p).unwrap(); + (f, companion, companion_node) +} + +#[test] +fn group_assignment_finish_rejects_stale_project_and_each_tampered_result() { + let (f, companion, companion_node) = separate_unassigned(Layer::Oa); + let selected = BTreeMap::from([(f.n, f.w), (companion_node, companion)]); + let captured = assignment::capture(&f.p, f.d, &selected, f.n).unwrap(); + let plan = + assignment::plan_semantics(&captured, Destination::OutsidePerformers, &BTreeMap::new()) + .unwrap(); + let context = presentation(&f.p, f.d, None); + let valid = assignment::place(&plan, xy(500., 500.), context).unwrap(); + let mut expected = f.p.clone(); + expected + .diagrams + .get_mut(&f.d) + .unwrap() + .nodes + .get_mut(&f.n) + .unwrap() + .position = xy(500., 500.); + expected + .diagrams + .get_mut(&f.d) + .unwrap() + .nodes + .get_mut(&companion_node) + .unwrap() + .position = xy(500., 1000.); + assert_eq!( + roundtrip( + &f.p, + assignment::finish(&plan, &f.p, &valid).unwrap().unwrap() + ), + expected + ); + let mut stale = f.p.clone(); + stale.elements.get_mut(&f.b).unwrap().description = "Changed unrelated accepted record".into(); + validate_candidate(&stale).unwrap(); + assert!(matches!( + assignment::finish(&plan, &stale, &valid), + Err(Refusal::StaleScope) + )); + for field in 0..6 { + let mut altered = assignment::place(&plan, xy(500., 500.), context).unwrap(); + match field { + 0 => altered.diagram.nodes.get_mut(&f.n).unwrap().position.x += 20., + 1 => { + altered + .view + .nodes + .iter_mut() + .find(|node| node.id == f.n) + .unwrap() + .position + .x += 20. + } + 2 => altered.delta.x += 20., + 3 => altered.unfolded = Some(f.n), + 4 => altered.commands.clear(), + 5 => altered.requested.x += 10., + _ => unreachable!(), + } + assert!( + matches!( + assignment::finish(&plan, &f.p, &altered), + Err(Refusal::StaleScope) + ), + "tampered result field {field}" + ); + } +} + +#[test] +fn group_assignment_requires_non_anchor_selected_header_inside_viewport() { + let (f, companion, companion_node) = separate_unassigned(Layer::Oa); + let captured = assignment::capture( + &f.p, + f.d, + &BTreeMap::from([(f.n, f.w), (companion_node, companion)]), + f.n, + ) + .unwrap(); + let plan = + assignment::plan_semantics(&captured, Destination::OutsidePerformers, &BTreeMap::new()) + .unwrap(); + let wide = presentation(&f.p, f.d, None); + let placed = assignment::place(&plan, xy(500., 500.), wide).unwrap(); + let mut narrow = wide; + narrow.viewport.size.height = 800.; // world bottom700: anchor header fits, companion does not. + let header = |id| { + let node = placed.view.nodes.iter().find(|node| node.id == id).unwrap(); + Rect { + position: node.position, + size: Size { + width: node.size.width, + height: 62., + }, + } + }; + assert!(narrow.viewport.contains(header(f.n))); + assert!(!narrow.viewport.contains(header(companion_node))); + // Only the viewport differs from the already admitted identical geometry. + assert!(matches!( + assignment::place(&plan, xy(500., 500.), narrow), + Err(Refusal::LayoutRequired(_)) + )); +} + +#[test] +fn group_assignment_unresolved_oa_role_leaf_accepts_explicit_target_but_not_outside() { + for ambiguous in [false, true] { + let (mut f, companion, companion_node) = separate_unassigned(Layer::Oa); + let (role_id, role_assignment) = role(&mut f, None); + if ambiguous { + f.p.relate( + RelationKind::RoleRealization, + "A realizes responsibility", + f.a, + role_id, + ); + f.p.relate( + RelationKind::RoleRealization, + "B realizes responsibility", + f.b, + role_id, + ); + } + validate_candidate(&f.p).unwrap(); + assert_eq!(validation::effective_performer(&f.p, f.w), None); + let captured = assignment::capture( + &f.p, + f.d, + &BTreeMap::from([(f.n, f.w), (companion_node, companion)]), + f.n, + ) + .unwrap(); + let Refusal::RoleExitRequired { + work, + roles, + performers, + .. + } = assignment::plan_semantics(&captured, Destination::OutsidePerformers, &BTreeMap::new()) + .unwrap_err() + else { + panic!("unresolved role must produce its specific outward refusal"); + }; + assert_eq!(work, f.w); + assert_eq!(roles, vec![role_id]); + assert_eq!( + performers.into_iter().collect::>(), + if ambiguous { + BTreeSet::from([f.a, f.b]) + } else { + BTreeSet::new() + } + ); + let work_allocation = Id::new_v4(); + let companion_allocation = Id::new_v4(); + let ids = BTreeMap::from([(f.w, work_allocation), (companion, companion_allocation)]); + let plan = + assignment::plan_semantics(&captured, Destination::Performer(f.b), &ids).unwrap(); + assert_eq!(plan.changed_count(), 2); + assert!(plan.label().contains("role responsibility retained")); + let mut expected = f.p.clone(); + expected.relations.insert( + work_allocation, + expected_allocation( + work_allocation, + "b_performs_work", + "B performs Work", + f.b, + f.w, + ), + ); + expected.relations.insert( + companion_allocation, + expected_allocation( + companion_allocation, + "b_performs_companion", + "B performs Companion", + f.b, + companion, + ), + ); + assert_eq!(plan.project(), &expected); + assert_eq!( + plan.project().relations[&role_assignment], + f.p.relations[&role_assignment] + ); + let placed = + assignment::place(&plan, xy(940., 200.), presentation(&f.p, f.d, Some(f.b))).unwrap(); + expected + .diagrams + .get_mut(&f.d) + .unwrap() + .nodes + .get_mut(&f.n) + .unwrap() + .position = xy(940., 200.); + expected + .diagrams + .get_mut(&f.d) + .unwrap() + .nodes + .get_mut(&companion_node) + .unwrap() + .position = xy(940., 700.); + assert_eq!( + roundtrip( + &f.p, + assignment::finish(&plan, &f.p, &placed).unwrap().unwrap() + ), + expected + ); + } +} diff --git a/crates/oa-desktop/src/allocation_drag/assignment_tests.rs b/crates/oa-desktop/src/allocation_drag/assignment_tests.rs new file mode 100644 index 00000000..a3b71b3f --- /dev/null +++ b/crates/oa-desktop/src/allocation_drag/assignment_tests.rs @@ -0,0 +1,606 @@ +use super::*; + +struct Roots { + f: Fixture, + c: Id, + cn: Id, + y: Id, + yn: Id, + z: Id, + zn: Id, + ax: Id, + by: Id, +} + +fn roots(layer: Layer) -> Roots { + let mut f = fixture(layer); + let kind = f.p.elements[&f.w].kind(); + let c = + f.p.add_element( + if layer == Layer::Oa { + Kind::Actor + } else { + Kind::SystemActor + }, + layer, + "C", + None, + ) + .unwrap(); + let y = f.p.add_element(kind, layer, "Y", None).unwrap(); + let z = f.p.add_element(kind, layer, "Z", None).unwrap(); + let cn = f.p.place(f.d, c, xy(1500., 100.)); + let yn = f.p.place(f.d, y, xy(140., 800.)); + let zn = f.p.place(f.d, z, xy(1540., 1400.)); + let d = f.p.diagrams.get_mut(&f.d).unwrap(); + d.nodes.get_mut(&f.n).unwrap().position = xy(140., 200.); + d.nodes + .values_mut() + .find(|n| n.element == f.b) + .unwrap() + .position = xy(100., 700.); + let ax = f.p.relate( + RelationKind::Allocation, + "Original X responsibility", + f.a, + f.w, + ); + let by = f.p.relate( + RelationKind::Allocation, + "Original Y responsibility", + f.b, + y, + ); + f.p.relations.get_mut(&ax).unwrap().description = "Reviewed X identity must survive".into(); + f.p.relations.get_mut(&by).unwrap().description = "Reviewed Y identity must survive".into(); + if layer == Layer::Oa { + let r = + f.p.add_element(Kind::Role, layer, "Responsibility", None) + .unwrap(); + f.p.relate(RelationKind::RoleAssignment, "Z role", r, z); + f.p.relate(RelationKind::RoleRealization, "C role", c, r); + } else { + f.p.relate(RelationKind::Allocation, "Z already assigned", c, z); + } + validate_candidate(&f.p).unwrap(); + Roots { + f, + c, + cn, + y, + yn, + z, + zn, + ax, + by, + } +} + +fn wide(p: &Project, d: Id, target: Option) -> PresentationContext { + let mut context = presentation(p, d, target); + context.viewport.size = Size { + width: 5000., + height: 3000., + }; + context +} + +fn selected(r: &Roots, with_z: bool) -> BTreeMap { + let mut ids = BTreeMap::from([(r.f.n, r.f.w), (r.yn, r.y)]); + if with_z { + ids.insert(r.zn, r.z); + } + ids +} + +fn retarget(p: &mut Project, relation: Id, target: Id) { + let r = p.relations.get_mut(&relation).unwrap(); + r.body = RelationBody::Allocation { + source: target, + target: r.target(), + }; +} + +#[test] +fn group_assignment_independent_performers_preserves_already_correct_provenance() { + for layer in [Layer::Oa, Layer::Sa] { + let r = roots(layer); + let selected = selected(&r, true); + let scope = assignment::capture(&r.f.p, r.f.d, &selected, r.f.n).unwrap(); + let plan = + assignment::plan_semantics(&scope, Destination::Performer(r.c), &BTreeMap::new()) + .unwrap(); + assert_eq!(plan.changed_count(), 2); + assert_eq!(plan.already_correct_count(), 1); + let placed = + assignment::place(&plan, xy(1540., 200.), wide(&r.f.p, r.f.d, Some(r.c))).unwrap(); + let mut expected = r.f.p.clone(); + retarget(&mut expected, r.ax, r.c); + retarget(&mut expected, r.by, r.c); + let d = expected.diagrams.get_mut(&r.f.d).unwrap(); + d.nodes.get_mut(&r.f.n).unwrap().position = xy(1540., 200.); + d.nodes.get_mut(&r.yn).unwrap().position = xy(1540., 800.); + d.nodes.get_mut(&r.zn).unwrap().position = xy(2940., 1400.); + assert_eq!( + roundtrip( + &r.f.p, + assignment::finish(&plan, &r.f.p, &placed).unwrap().unwrap() + ), + expected + ); + if layer == Layer::Oa { + assert!( + !plan + .project() + .relations + .values() + .any(|relation| relation.kind() == RelationKind::Allocation + && relation.target() == r.z) + ); + assert!(matches!( + assignment::plan_semantics( + &scope, + Destination::OutsidePerformers, + &BTreeMap::new() + ), + Err(Refusal::RoleExitRequired { .. }) + )); + } + assert!( + assignment::plan_semantics(&scope, Destination::LayoutOnly, &BTreeMap::new()).is_err() + ); + } +} + +#[test] +fn group_assignment_zero_delta_changes_semantics_once_and_then_is_noop() { + for layer in [Layer::Oa, Layer::Sa] { + let mut f = fixture(layer); + let d = f.p.diagrams.get_mut(&f.d).unwrap(); + d.nodes + .values_mut() + .find(|n| n.element == f.a) + .unwrap() + .size = Size { + width: 600., + height: 400., + }; + d.nodes.get_mut(&f.n).unwrap().position = xy(140., 200.); + let scope = assignment::capture(&f.p, f.d, &BTreeMap::from([(f.n, f.w)]), f.n).unwrap(); + let id = Id::new_v4(); + let plan = assignment::plan_semantics( + &scope, + Destination::Performer(f.a), + &BTreeMap::from([(f.w, id)]), + ) + .unwrap(); + let placed = assignment::place(&plan, xy(140., 200.), wide(&f.p, f.d, Some(f.a))).unwrap(); + assert_eq!(placed.delta, xy(0., 0.)); + let mut expected = f.p.clone(); + expected.relations.insert( + id, + Relation { + id, + alias: "a_performs_work".into(), + name: "A performs Work".into(), + description: String::new(), + body: RelationBody::Allocation { + source: f.a, + target: f.w, + }, + }, + ); + assert_eq!( + roundtrip( + &f.p, + assignment::finish(&plan, &f.p, &placed).unwrap().unwrap() + ), + expected + ); + let scope = + assignment::capture(&expected, f.d, &BTreeMap::from([(f.n, f.w)]), f.n).unwrap(); + let noop = + assignment::plan_semantics(&scope, Destination::Performer(f.a), &BTreeMap::new()) + .unwrap(); + let placed = + assignment::place(&noop, xy(140., 200.), wide(&expected, f.d, Some(f.a))).unwrap(); + assert!( + assignment::finish(&noop, &expected, &placed) + .unwrap() + .is_none() + ); + } +} + +#[test] +fn group_assignment_unfolds_only_changed_destination_and_refuses_newly_exposed_overlap() { + for layer in [Layer::Oa, Layer::Sa] { + for obstacle in [false, true] { + let mut r = roots(layer); + let d = r.f.p.diagrams.get_mut(&r.f.d).unwrap(); + d.nodes + .values_mut() + .find(|n| n.element == r.f.b) + .unwrap() + .position = xy(100., 400.); + d.nodes.get_mut(&r.yn).unwrap().position = xy(140., 500.); + d.nodes.get_mut(&r.zn).unwrap().position = xy(1540., 800.); + d.nodes.get_mut(&r.cn).unwrap().collapsed = true; + if obstacle { + let k = + r.f.p + .add_element(r.f.p.elements[&r.f.w].kind(), layer, "Hidden overlap", None) + .unwrap(); + r.f.p + .relate(RelationKind::Allocation, "C also owns K", r.c, k); + r.f.p.place(r.f.d, k, xy(1540., 800.)); + } + assert!( + !projection::project_view(&r.f.p, r.f.d) + .nodes + .iter() + .any(|n| n.id == r.zn) + ); + assert!( + assignment::capture(&r.f.p, r.f.d, &selected(&r, true), r.f.n).is_err(), + "hidden selected member is not normalized away" + ); + let scope = assignment::capture(&r.f.p, r.f.d, &selected(&r, false), r.f.n).unwrap(); + let plan = + assignment::plan_semantics(&scope, Destination::Performer(r.c), &BTreeMap::new()) + .unwrap(); + let placed = assignment::place(&plan, xy(1540., 120.), wide(&r.f.p, r.f.d, Some(r.c))); + if obstacle { + assert!(matches!(placed, Err(Refusal::LayoutRequired(_)))); + continue; + } + let placed = placed.unwrap(); + assert_eq!(placed.unfolded, Some(r.cn)); + let mut expected = r.f.p.clone(); + retarget(&mut expected, r.ax, r.c); + retarget(&mut expected, r.by, r.c); + let d = expected.diagrams.get_mut(&r.f.d).unwrap(); + d.nodes.get_mut(&r.cn).unwrap().collapsed = false; + d.nodes.get_mut(&r.f.n).unwrap().position = xy(1540., 120.); + d.nodes.get_mut(&r.yn).unwrap().position = xy(1540., 420.); + assert_eq!( + roundtrip( + &r.f.p, + assignment::finish(&plan, &r.f.p, &placed).unwrap().unwrap() + ), + expected + ); + } + } +} + +#[test] +fn group_assignment_accumulates_stable_ids_and_aliases_without_partial_reuse() { + let mut f = fixture(Layer::Oa); + let y = + f.p.add_element(Kind::Activity, Layer::Oa, "Work", None) + .unwrap(); + let yn = f.p.place(f.d, y, xy(400., 500.)); + let selected = BTreeMap::from([(f.n, f.w), (yn, y)]); + let scope = assignment::capture(&f.p, f.d, &selected, f.n).unwrap(); + let ids = BTreeMap::from([(f.w, Id::new_v4()), (y, Id::new_v4())]); + let first = assignment::plan_semantics(&scope, Destination::Performer(f.a), &ids).unwrap(); + let other = assignment::plan_semantics(&scope, Destination::Performer(f.b), &ids).unwrap(); + let repeated = assignment::plan_semantics(&scope, Destination::Performer(f.a), &ids).unwrap(); + assert_eq!(first.project(), repeated.project()); + assert_eq!(first.changed_count(), 2); + let mut expected = f.p.clone(); + let aliases: BTreeSet<_> = ids + .values() + .map(|id| first.project().relations[id].alias.clone()) + .collect(); + assert_eq!(aliases.len(), 2); + assert!(aliases.contains("a_performs_work")); + for (ordinal, (work, id)) in ids.iter().enumerate() { + let relation = &first.project().relations[id]; + let expected_relation = Relation { + id: *id, + alias: if ordinal == 0 { + "a_performs_work".into() + } else { + format!("a_performs_work_{}", &id.simple().to_string()[..8]) + }, + name: "A performs Work".into(), + description: String::new(), + body: RelationBody::Allocation { + source: f.a, + target: *work, + }, + }; + assert_eq!(relation, &expected_relation); + assert_eq!( + other.project().relations[id].body, + RelationBody::Allocation { + source: f.b, + target: *work + } + ); + expected.relations.insert(*id, expected_relation); + } + assert_eq!(first.project(), &expected); + let duplicate = Id::new_v4(); + for bad in [ + BTreeMap::from([(f.w, duplicate), (y, duplicate)]), + BTreeMap::from([(f.w, Id::nil()), (y, ids[&y])]), + BTreeMap::from([(f.w, f.a), (y, ids[&y])]), + ] { + assert!(assignment::plan_semantics(&scope, Destination::Performer(f.a), &bad).is_err()); + } +} + +#[test] +fn group_assignment_source_failure_preserves_existing_history_and_retry_ids() { + for layer in [Layer::Oa, Layer::Sa] { + let mut f = fixture(layer); + let y = + f.p.add_element(f.p.elements[&f.w].kind(), layer, "Y", None) + .unwrap(); + let yn = f.p.place(f.d, y, xy(400., 800.)); + let mut bundle = format_bundle(&f.p); + bundle + .files + .get_mut(&bundle.entry.clone()) + .unwrap() + .insert_str( + 0, + "// Keep reviewed source through a refused group assignment.\n", + ); + let mut limits = ProjectLimits::default(); + limits.model.max_source_bytes = bundle.files.values().map(String::len).sum::() + 128; + let mut document = Document::from_bundle(bundle, limits).unwrap(); + document + .execute(Command::Describe(f.w, "Reviewed".into())) + .unwrap(); + document + .execute(Command::Rename(y, "Future Y".into())) + .unwrap(); + let redo_project = document.project().clone(); + let redo_source = document.bundle().clone(); + assert!(document.undo()); + assert!(document.can_undo() && document.can_redo()); + let accepted = document.project().clone(); + let source = document.bundle().clone(); + let revision = document.revision(); + let ids = BTreeMap::from([(f.w, Id::new_v4()), (y, Id::new_v4())]); + let scope = + assignment::capture(&accepted, f.d, &BTreeMap::from([(f.n, f.w), (yn, y)]), f.n) + .unwrap(); + let plan = assignment::plan_semantics(&scope, Destination::Performer(f.a), &ids).unwrap(); + let placed = + assignment::place(&plan, xy(150., 200.), wide(&accepted, f.d, Some(f.a))).unwrap(); + let command = assignment::finish(&plan, &accepted, &placed) + .unwrap() + .unwrap(); + assert!(matches!( + document.execute(command.clone()), + Err(crate::document::DocumentError::Source(_)) + )); + assert_eq!(document.project(), &accepted); + assert_eq!(document.bundle(), &source); + assert_eq!(document.revision(), revision); + assert!(document.can_undo() && document.can_redo()); + assert!(document.redo()); + assert_eq!(document.project(), &redo_project); + assert_eq!(document.bundle(), &redo_source); + assert!(document.undo()); + assert_eq!(document.project(), &accepted); + assert_eq!(document.bundle(), &source); + let mut expected = accepted.clone(); + for (work, name, alias) in [ + (f.w, "A performs Work", "a_performs_work"), + (y, "A performs Y", "a_performs_y"), + ] { + expected.relations.insert( + ids[&work], + Relation { + id: ids[&work], + alias: alias.into(), + name: name.into(), + description: String::new(), + body: RelationBody::Allocation { + source: f.a, + target: work, + }, + }, + ); + } + let d = expected.diagrams.get_mut(&f.d).unwrap(); + d.nodes.get_mut(&f.n).unwrap().position = xy(150., 200.); + d.nodes.get_mut(&yn).unwrap().position = xy(450., 500.); + assert_eq!(roundtrip(&accepted, command), expected); + } +} + +#[test] +fn group_assignment_collateral_surviving_constraint_refuses_whole_semantic_candidate() { + for layer in [Layer::Oa, Layer::Sa] { + let mut r = roots(layer); + let k = + r.f.p + .add_element(Kind::Constraint, layer, "Keep reviewed assignment", None) + .unwrap(); + *r.f.p.elements.get_mut(&k).unwrap().shared_mut().unwrap() = SharedBody::Constraint { + expression: "reviewed assignment".into(), + constrained: vec![r.ax, r.f.w], + }; + let core = prepare_command(&r.f.p, &Command::Delete(r.ax)) + .unwrap() + .project; + validate_candidate(&core).unwrap(); + assert!(core.elements.contains_key(&k)); + assert_ne!(core.elements[&k], r.f.p.elements[&k]); + let scope = assignment::capture(&r.f.p, r.f.d, &selected(&r, false), r.f.n).unwrap(); + let Err(Refusal::CollateralChangeRequired(changed)) = + assignment::plan_semantics(&scope, Destination::OutsidePerformers, &BTreeMap::new()) + else { + panic!("surviving constraint edit must refuse the entire plan"); + }; + assert!(changed.iter().any(|record| record.id == k)); + } +} + +#[test] +fn group_assignment_uses_vacated_space_and_one_complete_route_owner_map() { + for layer in [Layer::Oa, Layer::Sa] { + let mut f = fixture(layer); + let kind = f.p.elements[&f.w].kind(); + let y = f.p.add_element(kind, layer, "Y", None).unwrap(); + let outside = f.p.add_element(kind, layer, "Outside", None).unwrap(); + let yn = f.p.place(f.d, y, xy(400., 500.)); + f.p.place(f.d, outside, xy(1600., 500.)); + let d = f.p.diagrams.get_mut(&f.d).unwrap(); + let a = d.nodes.values_mut().find(|n| n.element == f.a).unwrap(); + a.position = xy(350., 350.); + a.size = Size { + width: 1000., + height: 600., + }; + let edge_kind = if layer == Layer::Oa { + RelationKind::Interaction + } else { + RelationKind::FunctionalExchange + }; + let internal = f.p.relate(edge_kind, "Internal", f.w, y); + let external = f.p.relate(edge_kind, "External", y, outside); + let mut internal_route = route(internal); + internal_route.waypoints = vec![xy(370., 550.), xy(370., 650.)]; + internal_route.source_attachment = Some(EdgeAttachment { + side: PortAnchorSide::Bottom, + offset: 0.3, + }); + internal_route.target_attachment = Some(EdgeAttachment { + side: PortAnchorSide::Top, + offset: 0.7, + }); + let mut external_route = internal_route.clone(); + external_route.edge = external; + external_route.waypoints = vec![xy(1200., 550.), xy(1300., 650.)]; + for diagram in [f.d, f.second] { + f.p.diagrams.get_mut(&diagram).unwrap().routes = + vec![internal_route.clone(), external_route.clone()]; + } + if let RelationBody::FunctionalExchange { source_port, .. } = f.p.relations[&internal].body + { + f.p.diagrams + .get_mut(&f.d) + .unwrap() + .nodes + .get_mut(&f.n) + .unwrap() + .port_anchors + .push(PortAnchor { + port: source_port, + side: PortAnchorSide::Bottom, + offset: 0.4, + }); + } + validate_candidate(&f.p).unwrap(); + let ids = BTreeMap::from([(f.w, Id::new_v4()), (y, Id::new_v4())]); + // The singleton candidate really occupies stationary Y's card. The + // combined candidate must not inherit that sequential-planning veto. + let mut singleton = f.p.clone(); + singleton + .diagrams + .get_mut(&f.d) + .unwrap() + .nodes + .get_mut(&f.n) + .unwrap() + .position = xy(400., 500.); + let projected = projection::project_view(&singleton, f.d); + let x_card = projected.nodes.iter().find(|n| n.id == f.n).unwrap(); + let y_card = projected.nodes.iter().find(|n| n.id == yn).unwrap(); + assert_eq!( + overlap(x_card, y_card), + if layer == Layer::Oa { 21056. } else { 29568. } + ); + let single = assignment::capture(&f.p, f.d, &BTreeMap::from([(f.n, f.w)]), f.n).unwrap(); + let single = + assignment::plan_semantics(&single, Destination::Performer(f.a), &ids).unwrap(); + assert!(matches!( + assignment::place(&single, xy(400., 500.), wide(&f.p, f.d, Some(f.a))), + Err(Refusal::LayoutRequired(_)) + )); + let scope = + assignment::capture(&f.p, f.d, &BTreeMap::from([(f.n, f.w), (yn, y)]), f.n).unwrap(); + let plan = assignment::plan_semantics(&scope, Destination::Performer(f.a), &ids).unwrap(); + let placed = assignment::place(&plan, xy(400., 500.), wide(&f.p, f.d, Some(f.a))).unwrap(); + let mut expected = f.p.clone(); + for (work, name, alias) in [ + (f.w, "A performs Work", "a_performs_work"), + (y, "A performs Y", "a_performs_y"), + ] { + expected.relations.insert( + ids[&work], + Relation { + id: ids[&work], + alias: alias.into(), + name: name.into(), + description: String::new(), + body: RelationBody::Allocation { + source: f.a, + target: work, + }, + }, + ); + } + let d = expected.diagrams.get_mut(&f.d).unwrap(); + d.nodes.get_mut(&f.n).unwrap().position = xy(400., 500.); + d.nodes.get_mut(&yn).unwrap().position = xy(700., 500.); + d.routes[0].waypoints = vec![xy(670., 550.), xy(670., 650.)]; + assert_eq!(placed.diagram.routes[1], external_route); + assert_eq!( + roundtrip( + &f.p, + assignment::finish(&plan, &f.p, &placed).unwrap().unwrap() + ), + expected + ); + } +} + +#[test] +fn group_assignment_deletes_only_own_routes_across_views_and_keeps_internal_flow() { + for layer in [Layer::Oa, Layer::Sa] { + let mut r = roots(layer); + let kind = if layer == Layer::Oa { + RelationKind::Interaction + } else { + RelationKind::FunctionalExchange + }; + let internal = r.f.p.relate(kind, "Keep internal flow", r.f.w, r.y); + for diagram in [r.f.d, r.f.second] { + r.f.p.diagrams.get_mut(&diagram).unwrap().routes = + vec![route(r.ax), route(internal), route(r.by)]; + } + validate_candidate(&r.f.p).unwrap(); + let scope = assignment::capture(&r.f.p, r.f.d, &selected(&r, false), r.f.n).unwrap(); + let plan = + assignment::plan_semantics(&scope, Destination::OutsidePerformers, &BTreeMap::new()) + .unwrap(); + assert_eq!(plan.changed_count(), 2); + let placed = assignment::place(&plan, xy(640., 200.), wide(&r.f.p, r.f.d, None)).unwrap(); + let mut expected = r.f.p.clone(); + expected.relations.remove(&r.ax); + expected.relations.remove(&r.by); + for diagram in [r.f.d, r.f.second] { + expected.diagrams.get_mut(&diagram).unwrap().routes = vec![route(internal)]; + } + let d = expected.diagrams.get_mut(&r.f.d).unwrap(); + d.nodes.get_mut(&r.f.n).unwrap().position = xy(640., 200.); + d.nodes.get_mut(&r.yn).unwrap().position = xy(640., 800.); + d.routes[0].waypoints = vec![xy(920., 400.), xy(920., 550.)]; + assert_eq!( + roundtrip( + &r.f.p, + assignment::finish(&plan, &r.f.p, &placed).unwrap().unwrap() + ), + expected + ); + } +} diff --git a/crates/oa-desktop/src/allocation_drag/geometry.rs b/crates/oa-desktop/src/allocation_drag/geometry.rs new file mode 100644 index 00000000..bd53fecc --- /dev/null +++ b/crates/oa-desktop/src/allocation_drag/geometry.rs @@ -0,0 +1,195 @@ +//! Geometry operations over an explicitly captured placement set. +use super::*; + +pub(super) struct Translation { + pub diagram: Diagram, + pub commands: Vec, + pub delta: Position, +} + +/// Translate exactly `placements` using one snapped anchor delta. `base` must +/// come from an exact, validated semantic candidate; absent routes are permitted +/// only because that candidate has already admitted their semantic deletion. +pub(super) fn translate( + accepted: &Project, + original: &Diagram, + base: &Diagram, + placements: &BTreeMap, + anchor: Id, + requested: Position, +) -> Result { + let anchor = placements.get(&anchor).ok_or(Refusal::StaleScope)?; + let delta = Position { + x: (requested.x / 10.).round() * 10. - anchor.position.x, + y: (requested.y / 10.).round() * 10. - anchor.position.y, + }; + let mut diagram = base.clone(); + let mut commands = Vec::new(); + let mut deltas = BTreeMap::new(); + for (id, captured) in placements { + let node = diagram.nodes.get_mut(id).ok_or(Refusal::StaleScope)?; + node.position = Position { + x: captured.position.x + delta.x, + y: captured.position.y + delta.y, + }; + if !(Rect { + position: node.position, + size: node.size, + }) + .valid() + { + return Err(Refusal::InvalidCandidate( + "Moved coordinates exceed the model bounds.".into(), + )); + } + deltas.insert(*id, delta); + if node != captured { + commands.push(Command::EditNode { + diagram: diagram.id, + original: *id, + node: node.clone(), + }); + } + } + // Resolve actual endpoint owners once against accepted semantics and the + // complete delta map. Never reinsert a semantically deleted saved route. + for route in projection::translated_routes(accepted, original, &deltas) { + if let Some(stored) = diagram.routes.iter_mut().find(|r| r.edge == route.edge) { + if route.waypoints.iter().any(|point| { + !point.x.is_finite() + || !point.y.is_finite() + || point.x.abs() > MAX_COORDINATE + || point.y.abs() > MAX_COORDINATE + }) { + return Err(Refusal::InvalidCandidate( + "Moved route exceeds the model bounds.".into(), + )); + } + *stored = route.clone(); + commands.push(Command::SetEdgeRoute { + diagram: diagram.id, + route, + }); + } + } + Ok(Translation { + diagram, + commands, + delta, + }) +} + +/// Recover hidden ancestry for identity lookup only. The all-unfolded scene is +/// never used for collision admission; only affected nodes and their ancestors +/// enter the required diagnostic expansion. +pub(super) fn expansion_ids( + p: &Project, + d: &Diagram, + index: &ViewIndex, + placements: &BTreeMap, +) -> Result, Refusal> { + let mut all_unfolded = d.clone(); + for node in all_unfolded.nodes.values_mut() { + node.collapsed = false; + } + let all_view = projection::project_diagram_with_index(p, &all_unfolded, index); + let ancestry = ancestors(&all_view); + let mut ids: BTreeSet<_> = placements.keys().copied().collect(); + for id in placements.keys() { + ids.extend(ancestry.get(id).ok_or(Refusal::StaleScope)?.iter().copied()); + } + Ok(ids) +} + +pub(super) fn expanded_view( + p: &Project, + d: &Diagram, + index: &ViewIndex, + ids: &BTreeSet, +) -> ProjectedView { + let mut expanded = d.clone(); + for id in ids { + if let Some(node) = expanded.nodes.get_mut(id) { + node.collapsed = false; + } + } + projection::project_diagram_with_index(p, &expanded, index) +} + +pub(super) fn check_scenes( + before_visible: &ProjectedView, + before_expanded: &ProjectedView, + visible: &ProjectedView, + expanded: &ProjectedView, +) -> Result<(), Refusal> { + if visible.nodes.iter().chain(&expanded.nodes).any(|node| { + !(Rect { + position: node.position, + size: node.size, + }) + .valid() + }) { + return Err(Refusal::InvalidCandidate( + "The projected enclosure exceeds the model bounds.".into(), + )); + } + if overlaps(before_visible, visible) || overlaps(before_expanded, expanded) { + return Err(Refusal::LayoutRequired( + "Make room before arranging: the candidate would introduce or enlarge overlapping cards." + .into(), + )); + } + Ok(()) +} + +pub(super) fn check_rigid( + before: &ProjectedView, + after: &ProjectedView, + placements: &BTreeMap, + delta: Position, +) -> Result<(), Refusal> { + let before: BTreeMap<_, _> = before.nodes.iter().map(|n| (n.id, n)).collect(); + let after: BTreeMap<_, _> = after.nodes.iter().map(|n| (n.id, n)).collect(); + for id in placements.keys() { + let old = before.get(id).ok_or(Refusal::StaleScope)?; + let new = after.get(id).ok_or(Refusal::StaleScope)?; + if (new.position.x - old.position.x - delta.x).abs() > 0.01 + || (new.position.y - old.position.y - delta.y).abs() > 0.01 + || (new.size.width - old.size.width).abs() > 0.01 + || (new.size.height - old.size.height).abs() > 0.01 + { + return Err(Refusal::LayoutRequired( + "This selection requires an explicit layout; its footprint cannot be translated unchanged." + .into(), + )); + } + } + Ok(()) +} + +pub(super) fn check_headers( + view: &ProjectedView, + selected: &BTreeMap, + viewport: Rect, +) -> Result<(), Refusal> { + for (id, element) in selected { + let node = view + .nodes + .iter() + .find(|node| node.id == *id && node.element == *element) + .ok_or_else(|| Refusal::LayoutRequired("A selected header would be hidden.".into()))?; + let header = Rect { + position: node.position, + size: Size { + width: node.size.width, + height: node.size.height.min(62.), + }, + }; + if !viewport.contains(header) { + return Err(Refusal::LayoutRequired( + "Make room or adjust the view, then drag again.".into(), + )); + } + } + Ok(()) +} diff --git a/crates/oa-desktop/src/allocation_drag/group_arrange_experiment.rs b/crates/oa-desktop/src/allocation_drag/group_arrange_experiment.rs new file mode 100644 index 00000000..efa16788 --- /dev/null +++ b/crates/oa-desktop/src/allocation_drag/group_arrange_experiment.rs @@ -0,0 +1,450 @@ +//! Reviewed policy experiment using the production projection and translator. +//! No production admission or atomic-refusal behavior is tested here. +use super::*; + +struct Mixed { + f: Fixture, + an: Id, + xn: Id, + yn: Id, + internal: Id, + include_performer: bool, +} + +fn mixed(layer: Layer, collapsed: bool, obstacle: bool, include_performer: bool) -> Mixed { + let mut f = fixture(layer); + let work = f.p.elements[&f.w].kind(); + let exchange = match layer { + Layer::Oa => RelationKind::Interaction, + Layer::Sa => RelationKind::FunctionalExchange, + }; + let x = f.p.add_element(work, layer, "X", Some(f.w)).unwrap(); + let y = f.p.add_element(work, layer, "Y", Some(f.w)).unwrap(); + let outside = f.p.add_element(work, layer, "Outside", None).unwrap(); + let xn = f.p.place(f.d, x, xy(140., 200.)); + let yn = f.p.place(f.d, y, xy(940., 200.)); + f.p.place(f.d, outside, xy(1800., 800.)); + f.p.relate(RelationKind::Allocation, "A owns X", f.a, x); + f.p.relate(RelationKind::Allocation, "B owns Y", f.b, y); + // FunctionalExchange's actual builder creates valid function input/output + // ports; do not manufacture Interaction records between SA Functions. + let internal = f.p.relate(exchange, "X to Y", x, y); + let external = f.p.relate(exchange, "Y to Outside", y, outside); + let an = f.p.diagrams[&f.d] + .nodes + .values() + .find(|n| n.element == f.a) + .unwrap() + .id; + let d = f.p.diagrams.get_mut(&f.d).unwrap(); + d.nodes.get_mut(&f.n).unwrap().position = xy(100., 800.); + d.nodes.get_mut(&an).unwrap().collapsed = collapsed; + d.routes = vec![route(internal), route(external)]; + if obstacle { + let id = + f.p.add_element(work, layer, "Stationary obstacle", None) + .unwrap(); + f.p.place(f.d, id, xy(550., 230.)); + } + validate_candidate(&f.p).unwrap(); + let captured = scope(&f); + assert_eq!(captured.uniform, Uniform::MixedOrUnresolved); + assert_eq!( + captured.placements.keys().copied().collect::>(), + BTreeSet::from([f.n, xn, yn]) + ); + // Explicit control: the real current API still refuses Arrange for this root. + assert!(matches!( + plan_semantics(&f.p, &captured, Destination::LayoutOnly, &BTreeMap::new()), + Err(Refusal::MixedLayoutRequired) + )); + assert!(matches!( + plan_semantics( + &f.p, + &captured, + Destination::Performer(f.a), + &BTreeMap::new() + ), + Err(Refusal::MixedLayoutRequired) + )); + Mixed { + f, + an, + xn, + yn, + internal, + include_performer, + } +} + +fn affected(m: &Mixed) -> BTreeSet { + let mut ids: BTreeSet<_> = scope(&m.f).placements.keys().copied().collect(); + let mut expected = BTreeSet::from([m.f.n, m.xn, m.yn]); + if m.include_performer { + ids.insert(m.an); + ids.extend(projection::descendant_nodes(&m.f.p, m.f.d, m.an)); + expected.insert(m.an); + } + assert_eq!(ids, expected); + ids +} + +// Diagnostic expansion changes only a temporary Diagram. Include affected +// descendants and ancestors even when the normal projection hides them. +// Unrelated folded interiors are intentionally OUTSIDE this diagnostic domain. +fn diagnostic(p: &Project, diagram: Id, ids: &BTreeSet) -> ProjectedView { + let mut d = p.diagrams[&diagram].clone(); + for (id, node) in &mut d.nodes { + if ids.contains(id) + || projection::descendant_nodes(p, diagram, *id) + .iter() + .any(|n| ids.contains(n)) + { + node.collapsed = false; + } + } + projection::project_diagram(p, &d) +} + +fn rigid(before: &ProjectedView, after: &ProjectedView, ids: &BTreeSet) { + for id in ids { + let old = before.nodes.iter().find(|n| n.id == *id).unwrap(); + let new = after.nodes.iter().find(|n| n.id == *id).unwrap(); + assert_eq!(new.position, xy(old.position.x + 400., old.position.y)); + assert_eq!(new.size, old.size, "no reflow of an affected footprint"); + } +} + +// The only experimental bypass: use the pure general geometry command instead +// of plan_semantics(LayoutOnly), whose mixed-root pre-veto is asserted above. +// This is not a fabricated Scope or a call to singleton check_geometry with a +// falsely enlarged Scope. The combined scene uses the real overlap predicate. +fn candidate(m: &Mixed) -> (Command, Project) { + let ids = affected(m); + let command = + oa_core::diagram_edit::translate_placements(&m.f.p, m.f.d, &ids, xy(400., 0.)).unwrap(); + let actual = prepare_command(&m.f.p, &command).unwrap().project; + validate_candidate(&actual).unwrap(); + // Independent literal expected Project: no planner or route helper oracle. + let mut expected = m.f.p.clone(); + let d = expected.diagrams.get_mut(&m.f.d).unwrap(); + for (id, position) in [ + (m.f.n, xy(500., 800.)), + (m.xn, xy(540., 200.)), + (m.yn, xy(1340., 200.)), + ] { + d.nodes.get_mut(&id).unwrap().position = position; + } + if m.include_performer { + d.nodes.get_mut(&m.an).unwrap().position = xy(500., 100.); + } + let internal = d.routes.iter_mut().find(|r| r.edge == m.internal).unwrap(); + internal.waypoints = vec![xy(820., 400.), xy(820., 550.)]; + assert_eq!( + actual, expected, + "complete records, including folds, routes, other view and relations" + ); + (command, actual) +} + +#[test] +fn mixed_arrange_experiment_distributed_root_and_performer_can_translate_rigidly() { + for layer in [Layer::Oa, Layer::Sa] { + let enclosure_height = match layer { + Layer::Oa => 218., + Layer::Sa => 256., + }; + for include_performer in [false, true] { + for collapsed in [false, true] { + let m = mixed(layer, collapsed, false, include_performer); + let accepted = m.f.p.clone(); + let (command, actual) = candidate(&m); + let ids = affected(&m); + let before = projection::project_view(&accepted, m.f.d); + let after = projection::project_view(&actual, m.f.d); + let before_expanded = diagnostic(&accepted, m.f.d, &ids); + let after_expanded = diagnostic(&actual, m.f.d, &ids); + assert!( + !overlaps(&before, &after), + "visible scene must admit the combined move" + ); + assert!( + !overlaps(&before_expanded, &after_expanded), + "expanded scene must admit the combined move" + ); + rigid(&before_expanded, &after_expanded, &ids); + for node in after.nodes.iter().chain(&after_expanded.nodes) { + assert!( + (Rect { + position: node.position, + size: node.size + }) + .valid() + ); + } + // Explicit selected headers remain visible. Native clipping/readability + // and complete group gesture admission are intentionally not claimed. + assert!(after.nodes.iter().any(|n| n.id == m.f.n)); + if include_performer { + assert!(after.nodes.iter().any(|n| n.id == m.an)); + } else { + assert_eq!( + actual.diagrams[&m.f.d].nodes[&m.an], + accepted.diagrams[&m.f.d].nodes[&m.an] + ); + assert_eq!(node(&before_expanded, m.an).position, xy(100., 100.)); + assert_eq!( + node(&before_expanded, m.an).size, + Size { + width: 288., + height: enclosure_height + } + ); + assert_eq!(node(&after_expanded, m.an).position, xy(100., 100.)); + assert_eq!( + node(&after_expanded, m.an).size, + Size { + width: 688., + height: enclosure_height + } + ); + } + assert_eq!(roundtrip(&accepted, command), actual); + assert_eq!(m.f.p, accepted); + } + } + } +} + +#[test] +fn mixed_arrange_experiment_detects_moved_folded_child_collision() { + for layer in [Layer::Oa, Layer::Sa] { + let expected_overlap = match layer { + Layer::Oa => 13696., + Layer::Sa => 20116., + }; + let m = mixed(layer, true, true, true); + let accepted = m.f.p.clone(); + let (_, actual) = candidate(&m); + let ids = affected(&m); + let before = projection::project_view(&accepted, m.f.d); + let after = projection::project_view(&actual, m.f.d); + assert!(!before.nodes.iter().any(|n| n.id == m.xn)); + assert!(!after.nodes.iter().any(|n| n.id == m.xn)); + assert!( + !overlaps(&before, &after), + "must distinguish hidden collision from ordinary visible refusal" + ); + let before_expanded = diagnostic(&accepted, m.f.d, &ids); + let after_expanded = diagnostic(&actual, m.f.d, &ids); + rigid(&before_expanded, &after_expanded, &ids); + let obstacle = before_expanded + .nodes + .iter() + .find(|n| n.position == xy(550., 230.)) + .unwrap() + .id; + assert_eq!( + overlap( + node(&before_expanded, m.xn), + node(&before_expanded, obstacle) + ), + 0. + ); + assert_eq!( + overlap(node(&after_expanded, m.xn), node(&after_expanded, obstacle)), + expected_overlap + ); + assert!( + overlaps(&before_expanded, &after_expanded), + "combined diagnostic scene must refuse" + ); + // Detection only: there is no production group admission/commit API here. + assert_eq!(m.f.p, accepted); + } +} + +fn node(view: &ProjectedView, id: Id) -> &ProjectedNode { + view.nodes.iter().find(|n| n.id == id).unwrap() +} + +#[test] +fn mixed_arrange_experiment_detects_stationary_enclosure_growth_collision() { + for layer in [Layer::Oa, Layer::Sa] { + let enclosure_height = match layer { + Layer::Oa => 218., + Layer::Sa => 256., + }; + let mut m = mixed(layer, false, false, true); + let kind = m.f.p.elements[&m.f.w].kind(); + let z = + m.f.p + .add_element(kind, layer, "Stationary Z", None) + .unwrap(); + let zn = m.f.p.place(m.f.d, z, xy(1200., 100.)); + validate_candidate(&m.f.p).unwrap(); + let accepted = m.f.p.clone(); + let (_, actual) = candidate(&m); + let bn = accepted.diagrams[&m.f.d] + .nodes + .values() + .find(|n| n.element == m.f.b) + .unwrap() + .id; + let before = projection::project_view(&accepted, m.f.d); + let after = projection::project_view(&actual, m.f.d); + assert_eq!(node(&before, bn).position, xy(900., 100.)); + assert_eq!( + node(&before, bn).size, + Size { + width: 288., + height: enclosure_height + } + ); + assert_eq!(node(&after, bn).position, xy(900., 100.)); + assert_eq!( + node(&after, bn).size, + Size { + width: 688., + height: enclosure_height + } + ); + assert_eq!(node(&after, zn).position, xy(1200., 100.)); + assert_eq!( + node(&after, zn).size, + Size { + width: 224., + height: 94. + } + ); + assert_eq!(overlap(node(&before, bn), node(&before, zn)), 0.); + assert_eq!(overlap(node(&after, bn), node(&after, zn)), 21056.); + assert_eq!(node(&after, m.yn).position, xy(1340., 200.)); + assert_eq!( + node(&after, m.yn).position.y + - (node(&after, zn).position.y + node(&after, zn).size.height), + 6. + ); + for id in affected(&m) { + assert_eq!(overlap(node(&before, id), node(&before, zn)), 0.); + assert_eq!(overlap(node(&after, id), node(&after, zn)), 0.); + } + assert_eq!( + actual.diagrams[&m.f.d].nodes[&bn], + accepted.diagrams[&m.f.d].nodes[&bn] + ); + assert_eq!( + actual.diagrams[&m.f.d].nodes[&zn], + accepted.diagrams[&m.f.d].nodes[&zn] + ); + assert!( + overlaps(&before, &after), + "complete scene must detect stationary enclosure growth" + ); + let ids = affected(&m); + assert!(overlaps( + &diagnostic(&accepted, m.f.d, &ids), + &diagnostic(&actual, m.f.d, &ids) + )); + assert_eq!(m.f.p, accepted); + } +} + +#[test] +fn mixed_arrange_experiment_unrelated_folded_interiors_are_outside_diagnostic_domain() { + for layer in [Layer::Oa, Layer::Sa] { + // Independent source-derived literals: SA's auto-created function ports + // raise projected X/Y height to 76 + 28 * (1 + 1), while Z is port-free. + let (work_height, expected_overlap) = match layer { + Layer::Oa => (94., 13696.), + Layer::Sa => (132., 20116.), + }; + let mut m = mixed(layer, true, false, true); + let performer = match layer { + Layer::Oa => Kind::Actor, + Layer::Sa => Kind::SystemActor, + }; + let kind = m.f.p.elements[&m.f.w].kind(); + let d = + m.f.p + .add_element(performer, layer, "Unrelated D", None) + .unwrap(); + let z = + m.f.p + .add_element(kind, layer, "Unrelated hidden Z", None) + .unwrap(); + m.f.p.relate(RelationKind::Allocation, "D owns Z", d, z); + let dn = m.f.p.place(m.f.d, d, xy(2500., 100.)); + let zn = m.f.p.place(m.f.d, z, xy(550., 230.)); + m.f.p + .diagrams + .get_mut(&m.f.d) + .unwrap() + .nodes + .get_mut(&dn) + .unwrap() + .collapsed = true; + validate_candidate(&m.f.p).unwrap(); + let accepted = m.f.p.clone(); + let (_, actual) = candidate(&m); + let ids = affected(&m); + let before = diagnostic(&accepted, m.f.d, &ids); + let after = diagnostic(&actual, m.f.d, &ids); + assert!(!before.nodes.iter().any(|n| n.id == zn)); + assert!(!after.nodes.iter().any(|n| n.id == zn)); + assert!( + !overlaps(&before, &after), + "chosen diagnostic domain excludes unrelated folded contents" + ); + assert!(!overlaps( + &projection::project_view(&accepted, m.f.d), + &projection::project_view(&actual, m.f.d) + )); + let all_unfolded = |p: &Project| { + let mut diagram = p.diagrams[&m.f.d].clone(); + for n in diagram.nodes.values_mut() { + n.collapsed = false; + } + projection::project_diagram(p, &diagram) + }; + let broad_before = all_unfolded(&accepted); + let broad_after = all_unfolded(&actual); + assert_eq!(node(&broad_before, m.xn).position, xy(140., 200.)); + assert_eq!(node(&broad_after, m.xn).position, xy(540., 200.)); + assert_eq!( + node(&broad_after, m.xn).size, + Size { + width: 224., + height: work_height + } + ); + assert_eq!(node(&broad_before, zn).position, xy(550., 230.)); + assert_eq!(node(&broad_after, zn).position, xy(550., 230.)); + assert_eq!( + node(&broad_after, zn).size, + Size { + width: 224., + height: 94. + } + ); + assert_eq!( + overlap(node(&broad_before, m.xn), node(&broad_before, zn)), + 0. + ); + assert_eq!( + overlap(node(&broad_after, m.xn), node(&broad_after, zn)), + expected_overlap + ); + // Other newly exposed enclosure pairs may also overlap. X/Z is a + // concrete discriminator, not a claim that it is the only cause. + assert!(overlaps(&broad_before, &broad_after)); + for id in [dn, zn] { + assert_eq!( + actual.diagrams[&m.f.d].nodes[&id], + accepted.diagrams[&m.f.d].nodes[&id] + ); + } + assert!(actual.diagrams[&m.f.d].nodes[&dn].collapsed); + assert_eq!(m.f.p, accepted); + } +} diff --git a/crates/oa-desktop/src/allocation_drag/tests.rs b/crates/oa-desktop/src/allocation_drag/tests.rs index c212921b..7660ab9c 100644 --- a/crates/oa-desktop/src/allocation_drag/tests.rs +++ b/crates/oa-desktop/src/allocation_drag/tests.rs @@ -2,6 +2,18 @@ use super::*; use crate::document::Document; use oa_core::source_project::{ProjectLimits, compile_bundle, format_bundle}; +#[path = "group_arrange_experiment.rs"] +mod group_arrange_experiment; + +#[path = "arrange_tests.rs"] +mod arrange_tests; + +#[path = "assignment_tests.rs"] +mod assignment_tests; + +#[path = "assignment_boundary_tests.rs"] +mod assignment_boundary_tests; + struct Fixture { p: Project, d: Id, diff --git a/crates/oa-desktop/src/app.rs b/crates/oa-desktop/src/app.rs index 5122506a..bf7693da 100644 --- a/crates/oa-desktop/src/app.rs +++ b/crates/oa-desktop/src/app.rs @@ -275,6 +275,7 @@ mod direct_scenario_tests { #[cfg(test)] mod behavior_lifecycle_tests; mod process_canvas; +mod process_multiselect; mod state_diagram; #[cfg(test)] mod state_diagram_tests; @@ -829,6 +830,9 @@ impl App { .map(|n| n.id); } self.draft = Draft::from_project(self.project(), self.selected); + if self.process_preview_primary().is_some() { + self.draft = None; + } if self.exact_strings && let Some(d) = &mut self.draft { @@ -1157,10 +1161,13 @@ impl App { previous != (self.layer, self.active_diagram) } fn select(&mut self, id: Id) { + // An exact Process preview and its defining object share a semantic ID, + // but ordinary Explorer/property navigation must still leave the use. + let process_preview = self.process_preview_primary().is_some(); if id != self.selected { self.selected_attachment = None; } - if self.selected == id { + if self.selected == id && !process_preview { return; } if !self.apply() { @@ -1169,6 +1176,10 @@ impl App { if !self.project().contains(id) { return; } + self.behavior.topology.clear_context(); + if process_preview { + self.diagram_navigation_focus = None; + } let previous_state_view = self.state_view_identity(); self.selected = id; self.selected_node = None; @@ -1347,6 +1358,7 @@ impl App { ]) } pub fn update(&mut self, message: Message) -> Task { + let preview_read_only = self.process_preview_primary().is_some(); let selection_boundary = self.multiselect_boundary(); // Reject delayed gestures before they can retire newer input ownership. if let Message::DiagramSelection(event) @@ -1379,11 +1391,62 @@ impl App { // or allocation ownership before its edit is refused. return Task::none(); } + if let Message::TopologyCanvas(event) = &message { + use crate::behavior::topology::CanvasEvent; + if !matches!( + event, + CanvasEvent::Camera(..) | CanvasEvent::Scrolled(..) | CanvasEvent::Viewport(..) + ) && self.topology_context() != Some(event.context()) + { + return Task::none(); + } + if matches!( + event, + CanvasEvent::Select(..) + | CanvasEvent::SelectQualified(..) + | CanvasEvent::Remove(..) + | CanvasEvent::Reorder { .. } + | CanvasEvent::Reconnect { .. } + ) && self + .multiselect_snapshot() + .is_some_and(|s| s.targets().len() > 1) + { + return Task::none(); + } + } + if self.process_order_noop(&message) { + return Task::none(); + } + if self.process_preview_primary().is_some() + && matches!( + &message, + Message::Field(..) + | Message::FieldText(..) + | Message::FieldShape(..) + | Message::FieldAppend(..) + | Message::FieldValuePath(..) + | Message::FieldVariant(..) + | Message::FieldArray(..) + | Message::ElementKind(_) + | Message::RelationKind(_) + | Message::Apply + | Message::Delete + | Message::DiagramRemove(_) + ) + { + self.status = "Open the referenced definition to edit its contents.".into(); + 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 { return self.text_workspace_event(event); } + // Allocation placement movement requires its captured Press/Active + // lifetime, even after cancellation. A bare queued move has no epoch. + if self.allocation_legacy_move(&message) { + return Task::none(); + } // These handlers reject old owners/tokens without changing state. Apply // the same admission before shared camera and allocation boundaries. if !self.source_input_envelope(&message) @@ -1531,6 +1594,9 @@ impl App { { self.reconcile_multiselect(); } + if preview_read_only && self.process_preview_primary().is_none() && self.draft.is_none() { + self.refresh_draft(); + } task } fn behavior_control(&mut self, intent: behavior::canvas_workspace::Intent) -> Task { @@ -2586,6 +2652,9 @@ impl App { self.route_context() }; } + if request.process.is_some() { + request.process = self.topology_context().map(Box::new); + } self.model_menu = crate::model_menu::Opened::new(self, request, self.menu_sequence); } Message::Menu(crate::model_menu::Event::Close) => { @@ -2615,7 +2684,10 @@ impl App { "Apply or revert pending edits before using modeling actions.".into(); return Task::none(); } - if matches!(*action, Message::DiagramSelection(_)) { + if matches!(*action, Message::DiagramSelection(_)) + || (menu.request.process.is_some() + && matches!(*action, Message::TopologyCanvas(_))) + { return self.update(*action); } self.select(menu.request.object); @@ -3484,7 +3556,11 @@ impl App { .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(); + self.status = if self.topology_context().is_some() { + "Group removal needs an aggregate review. Select one contribution to remove it individually." + } else { + "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() { diff --git a/crates/oa-desktop/src/app/allocation_drag.rs b/crates/oa-desktop/src/app/allocation_drag.rs index e95991b6..b6c32949 100644 --- a/crates/oa-desktop/src/app/allocation_drag.rs +++ b/crates/oa-desktop/src/app/allocation_drag.rs @@ -1,12 +1,21 @@ //! Allocation work-body input admission. Gesture authority lives here, while //! the canvas retains ordered native pointer positions and frozen hit geometry. use super::{App, Message, Page}; -use crate::{allocation_drag as plan, route_edit::Context}; +use crate::{ + allocation_drag as plan, + diagram_selection::Snapshot, + diagram_usage::{Surface, Target as SelectionTarget}, + route_edit::Context, +}; use iced::{Point, Rectangle, Task, Vector}; use oa_core::{projection::ProjectedView, routing::RoutedView, *}; -use std::{collections::BTreeMap, sync::Arc}; +use std::{ + collections::{BTreeMap, BTreeSet}, + sync::Arc, +}; -pub const FINISH_EDIT: &str = "Finish or cancel the current edit before selecting or moving work."; +pub const FINISH_EDIT: &str = + "Finish or cancel the current edit before selecting or moving elements."; #[derive(Debug, Clone, Copy, PartialEq)] pub struct Stamp { @@ -65,7 +74,8 @@ pub fn contains(r: Rectangle, p: Point) -> bool { impl Frozen { pub fn capture( project: &Project, - scope: &plan::Scope, + diagram: &Diagram, + affected: &BTreeMap, view: &ProjectedView, routed: &RoutedView, zoom: f32, @@ -73,7 +83,7 @@ impl Frozen { let mut targets = Vec::new(); let mut cards = Vec::new(); for n in &view.nodes { - if scope.placements.contains_key(&n.id) { + if affected.contains_key(&n.id) { continue; } let region = Rectangle::new( @@ -84,7 +94,7 @@ impl Frozen { cards.push(region); continue; }; - if e.kind().is_performer() && e.layer() == scope.diagram.layer { + if e.kind().is_performer() && e.layer() == diagram.layer { let mut ancestors = Vec::new(); let mut parent = e.parent; while let Some(id) = parent { @@ -95,7 +105,7 @@ impl Frozen { parent = project.elements.get(&id).and_then(|e| e.parent); } let name = project.named(e.id).to_owned(); - let collapsed = scope.diagram.nodes[&n.id].collapsed; + let collapsed = diagram.nodes[&n.id].collapsed; targets.push(Target { element: e.id, placement: n.id, @@ -118,7 +128,7 @@ impl Frozen { let mut controls: Vec<_> = view .ports .iter() - .filter(|port| !scope.placements.contains_key(&port.owner)) + .filter(|port| !affected.contains_key(&port.owner)) .map(|port| { Rectangle::new( Point::new(port.position.x - radius, port.position.y - radius), @@ -224,17 +234,23 @@ impl Frozen { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Mode { + Assign, + Arrange, +} + #[derive(Debug, Clone)] -pub struct Press { +pub(crate) struct Press { pub stamp: Stamp, pub token: Id, pub start: Point, - pub scope: plan::Scope, - pub frozen: Frozen, - pub arrange: bool, + pub selection: Snapshot, + pub target: SelectionTarget, + pub mode: Mode, } #[derive(Debug, Clone)] -pub enum Event { +pub(crate) enum Event { Press(Arc), Preview(Stamp, Id, Option), Release(Stamp, Id, Option), @@ -244,23 +260,266 @@ pub enum Event { Unfocused, KeyboardBoundary, } +pub struct PreviewPlacement { + pub diagram: Diagram, + pub view: ProjectedView, + pub delta: Position, +} + pub struct Preview { pub token: Id, - pub semantic: Arc, - pub placement: plan::PlacementPlan, + pub candidate: Candidate, + pub placement: PreviewPlacement, pub routed: Arc, } +impl Preview { + pub fn project(&self) -> &Project { + self.candidate.project() + } + #[cfg(test)] + pub fn index(&self) -> &view_options::ViewIndex { + self.candidate.index() + } +} + +enum Prepared { + Singleton(Box), + Assignment(plan::assignment::Scope), + Arrange(Arc), + Performers(Arc), + Refused(String), +} + +#[derive(Clone)] +pub enum Candidate { + Singleton(Arc), + Assignment(Arc), + Arrange(Arc), + Performers(Arc), +} + +enum Placed { + Planned(plan::PlacementPlan), + Performers { + preview: PreviewPlacement, + command: Box, + requested: Position, + }, +} + +impl Placed { + fn diagram(&self) -> &Diagram { + match self { + Self::Planned(p) => &p.diagram, + Self::Performers { preview, .. } => &preview.diagram, + } + } + fn view(&self) -> &ProjectedView { + match self { + Self::Planned(p) => &p.view, + Self::Performers { preview, .. } => &preview.view, + } + } + fn into_preview(self) -> PreviewPlacement { + match self { + Self::Planned(p) => PreviewPlacement { + diagram: p.diagram, + view: p.view, + delta: p.delta, + }, + Self::Performers { preview, .. } => preview, + } + } +} + +/// Ordinary performer movement preserves the established core translation policy. +/// It shares input provenance, not the explicit Arrange geometry restrictions. +pub struct PerformerPlan { + accepted: Project, + index: view_options::ViewIndex, + diagram: Id, + selected: BTreeSet, + anchor: Id, + affected: BTreeMap, +} +impl PerformerPlan { + fn place(&self, requested: Position) -> Result { + let original = &self.accepted.diagrams[&self.diagram]; + let anchor = original.nodes[&self.anchor].position; + let delta = if requested == anchor { + Position { x: 0., y: 0. } + } else { + Position { + x: (requested.x / 10.).round() * 10. - anchor.x, + y: (requested.y / 10.).round() * 10. - anchor.y, + } + }; + let command = + diagram_edit::translate_placements(&self.accepted, self.diagram, &self.selected, delta) + .map_err(|e| plan::Refusal::InvalidCandidate(e.to_string()))?; + let mut diagram = original.clone(); + let Command::Batch(edits) = &command else { + unreachable!("translation is an atomic batch") + }; + for edit in edits { + match edit { + Command::EditNode { + diagram: id, + original, + node, + } if *id == self.diagram => { + diagram.nodes.insert(*original, node.clone()); + } + Command::SetEdgeRoute { diagram: id, route } if *id == self.diagram => { + let stored = diagram + .routes + .iter_mut() + .find(|r| r.edge == route.edge) + .ok_or(plan::Refusal::StaleScope)?; + *stored = route.clone(); + } + _ => { + return Err(plan::Refusal::InvalidCandidate( + "Unexpected performer movement edit.".into(), + )); + } + } + } + let view = projection::project_diagram_with_index(&self.accepted, &diagram, &self.index); + Ok(Placed::Performers { + preview: PreviewPlacement { + diagram, + view, + delta, + }, + command: Box::new(command), + requested, + }) + } +} + +impl Candidate { + pub fn project(&self) -> &Project { + match self { + Self::Singleton(p) => p.project(), + Self::Assignment(p) => p.project(), + Self::Arrange(p) => p.project(), + Self::Performers(p) => &p.accepted, + } + } + #[cfg(test)] + pub fn index(&self) -> &view_options::ViewIndex { + match self { + Self::Singleton(p) => p.index(), + Self::Assignment(p) => p.index(), + Self::Arrange(p) => p.index(), + Self::Performers(p) => &p.index, + } + } + fn place( + &self, + requested: Position, + presentation: plan::PresentationContext, + ) -> Result { + match self { + Self::Singleton(p) => plan::place(p, requested, presentation).map(Placed::Planned), + Self::Assignment(p) => { + plan::assignment::place(p, requested, presentation).map(Placed::Planned) + } + Self::Arrange(p) => { + plan::arrange::place(p, requested, presentation).map(Placed::Planned) + } + Self::Performers(p) => p.place(requested), + } + } + fn finish(&self, live: &Project, placed: &Placed) -> Result, plan::Refusal> { + match (self, placed) { + (Self::Singleton(p), Placed::Planned(result)) => plan::finish(p, live, result), + (Self::Assignment(p), Placed::Planned(result)) => { + plan::assignment::finish(p, live, result) + } + (Self::Arrange(p), Placed::Planned(result)) => plan::arrange::finish(p, live, result), + ( + Self::Performers(p), + Placed::Performers { + preview, + command, + requested, + }, + ) => { + if live != &p.accepted { + return Err(plan::Refusal::StaleScope); + } + let checked = p.place(*requested)?; + let Placed::Performers { + preview: checked, + command: checked_command, + .. + } = checked + else { + unreachable!() + }; + if checked.diagram != preview.diagram + || checked.view != preview.view + || checked.delta != preview.delta + || &checked_command != command + { + return Err(plan::Refusal::StaleScope); + } + let mut expected = p.accepted.clone(); + expected.diagrams.insert(p.diagram, checked.diagram); + let actual = prepare_command(live, &checked_command) + .map_err(|e| plan::Refusal::InvalidCandidate(e.to_string()))? + .project; + if actual != expected { + return Err(plan::Refusal::InvalidCandidate( + "Performer movement would change unrelated records.".into(), + )); + } + Ok((actual != *live).then_some(*checked_command)) + } + _ => Err(plan::Refusal::StaleScope), + } + } + fn label(&self) -> String { + let (label, warnings) = match self { + Self::Singleton(p) => (p.label.clone(), p.warnings.len()), + Self::Assignment(p) => (p.label().to_owned(), p.warnings().len()), + Self::Arrange(p) => ( + format!( + "Arrange {} selected elements · {} affected placements", + p.selected().len(), + p.affected().len() + ), + 0, + ), + Self::Performers(p) => ( + format!("Move {} selected performer(s)", p.selected.len()), + 0, + ), + }; + if warnings == 0 { + label + } else { + format!("{label} · {warnings} model warning(s) retained") + } + } +} + struct Active { press: Arc, + selection: Snapshot, + crossed: bool, + prepared: Prepared, + frozen: Option>, inspection_targets: Vec, ids: BTreeMap, - cache: Vec<( - plan::Destination, - Result, plan::Refusal>, - )>, + cache: Vec<(plan::Destination, Result)>, } -pub struct Feedback { +pub(crate) struct Feedback { pub press: Arc, + pub frozen: Option>, + ordinary_performers: bool, pub targets: Vec, /// Last captured target names for inspection only; never resolver input. pub inspection_targets: Vec, @@ -273,7 +532,13 @@ impl Feedback { let names = self .inspection_targets .iter() - .map(|index| self.press.frozen.targets[*index].name.as_str()) + .filter_map(|index| { + self.frozen + .as_ref()? + .targets + .get(*index) + .map(|t| t.name.as_str()) + }) .collect::>() .join(" / "); format!( @@ -293,7 +558,9 @@ impl Feedback { /// Captured accepted names, independent of clipped status/refusal prose. pub fn destination_identity(&self) -> String { match self.targets.as_slice() { - [] => if self.press.arrange { + [] => if self.ordinary_performers { + "Move performers" + } else if self.press.mode == Mode::Arrange { "Arrange only" } else if self.refused { "No eligible destination" @@ -301,12 +568,22 @@ impl Feedback { "Outside performers" } .into(), - [index] => self.press.frozen.targets[*index].name.clone(), + [index] => self + .frozen + .as_ref() + .and_then(|f| f.targets.get(*index)) + .map(|t| t.name.clone()) + .unwrap_or_else(|| "No eligible destination".into()), indices => format!( "Ambiguous: {}", indices .iter() - .map(|index| self.press.frozen.targets[*index].name.as_str()) + .filter_map(|index| self + .frozen + .as_ref()? + .targets + .get(*index) + .map(|t| t.name.as_str())) .collect::>() .join(" / ") ), @@ -391,6 +668,18 @@ impl App { && self.canvas_size.is_none_or(|s| s == stamp.bounds.size()) && stamp.bounds.width > 0. && stamp.bounds.height > 0. + && stamp.zoom > 0. + && [ + stamp.bounds.x, + stamp.bounds.y, + stamp.bounds.width, + stamp.bounds.height, + stamp.pan.x, + stamp.pan.y, + stamp.zoom, + ] + .iter() + .all(|value| value.is_finite()) } /// Called before general input dispatch. Even equal-valued actions (Fit, /// Undo/Redo, tool/modal round trips) retire old render-time capabilities. @@ -427,20 +716,54 @@ impl App { pub(super) fn allocation_retire(&mut self) { self.allocation_drag.retire(); } - fn select_work_clean(&mut self, press: &Press) -> bool { - if !self.allocation_stamp_valid(press.stamp) { - return false; - } - self.selected = press.scope.work; - self.selected_node = Some(press.scope.placement); - self.selected_bend = None; - self.refresh_draft(); - true + fn allocation_selection_valid(&self, snapshot: &Snapshot) -> bool { + self.multiselect_context().is_some_and(|current| { + self.diagram_selection + .as_ref() + .is_some_and(|selection| selection.matches(snapshot, current)) + }) } pub(super) fn allocation_press_owns_current_view(&self, press: &Press) -> bool { - self.allocation_drag.active.is_none() - && self.allocation_stamp_valid(press.stamp) - && press.arrange == self.allocation_drag.arrange_next + if self.allocation_drag.active.is_some() + || !self.allocation_stamp_valid(press.stamp) + || (press.mode == Mode::Arrange) != self.allocation_drag.arrange_next + || !self.allocation_selection_valid(&press.selection) + || press.selection.context().scope.surface + != Surface::Saved(press.stamp.context.diagram) + || !press.start.x.is_finite() + || !press.start.y.is_finite() + || !contains(press.stamp.bounds, press.start) + { + return false; + } + let view = projection::project_view_with_index( + self.project(), + press.stamp.context.diagram, + self.document.view_index(), + ); + super::multiselect::visible( + self.project(), + press.stamp.context.diagram, + &view, + &press.target, + ) + } + /// Bare Allocation movement has no lifetime provenance. All actual Allocation + /// placement gestures now enter Press/Active, including ordinary performers. + pub(super) fn allocation_legacy_move(&self, message: &Message) -> bool { + let allocation = |diagram| { + self.project() + .diagrams + .get(&diagram) + .is_some_and(|d| d.projection == ProjectionKind::Allocation) + }; + match message { + Message::MoveNode(..) => self.active_diagram.is_some_and(allocation), + Message::DiagramSelection(super::multiselect::Event::Move(snapshot, _)) => { + matches!(snapshot.context().scope.surface, Surface::Saved(diagram) if allocation(diagram)) + } + _ => false, + } } pub(super) fn allocation_event_envelope(&self, event: &Event) -> bool { match event { @@ -451,11 +774,64 @@ impl App { .is_some_and(|active| active.press.token == *token) } Event::Refused(epoch, _) => *epoch == self.allocation_drag.epoch, - // Invalid Press packets deliberately report why the view or draft - // changed. Leave their existing validation and feedback intact. _ => true, } } + fn allocation_prepare(&self, press: &Press, selection: &Snapshot) -> Prepared { + let SelectionTarget::Placement(anchor) = &press.target else { + return Prepared::Refused("Move requires only placed work or performers; ports and connections cannot be moved with this selection.".into()); + }; + let anchor = *anchor; + let Some(ids) = super::multiselect::placements(selection) else { + return Prepared::Refused("Move requires only placed work or performers; remove ports, rows and connections from the selection.".into()); + }; + let project = self.project(); + let diagram = press.stamp.context.diagram; + let d = &project.diagrams[&diagram]; + let selected: BTreeMap<_, _> = ids.iter().map(|id| (*id, d.nodes[id].element)).collect(); + let result = if press.mode == Mode::Arrange { + plan::arrange::capture(project, diagram, &selected, anchor) + .map(|p| Prepared::Arrange(Arc::new(p))) + } else if selected + .values() + .all(|id| project.elements[id].kind().is_work()) + { + if selected.len() == 1 { + plan::capture(project, diagram, selected[&anchor], anchor) + .map(|scope| Prepared::Singleton(Box::new(scope))) + } else { + plan::assignment::capture(project, diagram, &selected, anchor) + .map(Prepared::Assignment) + } + } else if selected + .values() + .all(|id| project.elements[id].kind().is_performer()) + { + validate_candidate(project) + .map_err(|e| plan::Refusal::InvalidCandidate(e.to_string())) + .map(|_| { + let mut affected = BTreeMap::new(); + for id in &ids { + for member in std::iter::once(*id) + .chain(projection::descendant_nodes(project, diagram, *id)) + { + affected.insert(member, d.nodes[&member].clone()); + } + } + Prepared::Performers(Arc::new(PerformerPlan { + accepted: project.clone(), + index: view_options::ViewIndex::new(project), + diagram, + selected: ids, + anchor, + affected, + })) + }) + } else { + Err(plan::Refusal::Ineligible("Assignment requires only work. Arm Arrange next drag to arrange a supported work/performer selection.".into())) + }; + result.unwrap_or_else(|reason| Prepared::Refused(refusal(&reason))) + } pub(super) fn allocation_event(&mut self, event: Event) -> Task { if !self.allocation_event_envelope(&event) { return Task::none(); @@ -465,7 +841,8 @@ impl App { self.allocation_drag.retire(); if self.allocation_drag_clean() { self.allocation_drag.arrange_next = true; - self.status = "Arrange next drag armed · moves work only · Esc cancels".into(); + self.status = + "Arrange next drag armed · presentation only · Esc cancels".into(); } else { self.status = FINISH_EDIT.into(); } @@ -476,22 +853,13 @@ impl App { self.allocation_drag.retire(); } } - Event::Cancel(token) => { - if self - .allocation_drag - .active - .as_ref() - .is_some_and(|a| a.press.token == token) - { - self.allocation_drag.retire(); - self.status = "Work drag canceled".into(); - } + Event::Cancel(_) => { + self.allocation_drag.retire(); + self.status = "Drag canceled".into(); } - Event::Refused(epoch, text) => { - if epoch == self.allocation_drag.epoch { - self.allocation_drag.retire(); - self.status = text; - } + Event::Refused(_, text) => { + self.allocation_drag.retire(); + self.status = text; } Event::Press(press) => { if self.allocation_drag.active.is_some() { @@ -505,32 +873,73 @@ impl App { }; return Task::none(); } - let checked = plan::capture( - self.project(), - press.scope.diagram.id, - press.scope.work, - press.scope.placement, - ); - if checked.as_ref() != Ok(&press.scope) || !self.select_work_clean(&press) { - self.status = checked - .err() - .as_ref() - .map(refusal) - .unwrap_or_else(|| "The view changed. Start this drag again.".into()); - self.allocation_drag.retire(); + // A selected-member press has no selection or inspector effect. + // An unselected press replaces selection atomically in this handler. + if !press.selection.targets().contains(&press.target) { + self.multiselect_event(super::multiselect::Event::Choose( + press.selection.context(), + press.target.clone(), + super::multiselect::Choice::Replace, + )); + } + let Some(selection) = self.multiselect_snapshot() else { + return Task::none(); + }; + if !selection.targets().contains(&press.target) + || (!press.selection.targets().contains(&press.target) + && selection.targets() != [press.target.clone()]) + { return Task::none(); } - let ids = press - .scope - .leaves - .keys() - .map(|id| (*id, Id::new_v4())) - .collect(); + let diagram = press.stamp.context.diagram; + let view = projection::project_view_with_index( + self.project(), + diagram, + self.document.view_index(), + ); + if !selection.targets().iter().all(|target| { + super::multiselect::visible(self.project(), diagram, &view, target) + }) { + self.allocation_drag.retire(); + self.status = "The view changed. Start this drag again.".into(); + return Task::none(); + } + let prepared = self.allocation_prepare(&press, &selection); + let (affected, leaf_ids) = match &prepared { + Prepared::Singleton(scope) => ( + Some(&scope.placements), + scope.leaves.keys().copied().collect::>(), + ), + Prepared::Assignment(scope) => ( + Some(scope.affected()), + scope.leaves().keys().copied().collect(), + ), + Prepared::Arrange(plan) => (Some(plan.affected()), Vec::new()), + Prepared::Performers(plan) => (Some(&plan.affected), Vec::new()), + Prepared::Refused(_) => (None, Vec::new()), + }; + let frozen = affected.map(|affected| { + let d = &self.project().diagrams[&diagram]; + let routed = routing::route_diagram(self.project(), d, &view); + Arc::new(Frozen::capture( + self.project(), + d, + affected, + &view, + &routed, + press.stamp.zoom, + )) + }); + let ids = leaf_ids.into_iter().map(|id| (id, Id::new_v4())).collect(); self.allocation_drag.arrange_next = false; self.allocation_drag.preview = None; self.allocation_drag.feedback = None; self.allocation_drag.active = Some(Active { press, + selection, + crossed: false, + prepared, + frozen, inspection_targets: Vec::new(), ids, cache: Vec::new(), @@ -552,15 +961,16 @@ impl App { pointer: Option, release: bool, ) { - if self - .allocation_drag - .active - .as_ref() - .is_none_or(|a| a.press.token != token) - { - return; // An old packet cannot cancel a newer gesture. + let Some(active) = self.allocation_drag.active.as_ref() else { + return; + }; + if active.press.token != token { + return; } - if !self.allocation_stamp_valid(stamp) { + if !self.allocation_stamp_valid(stamp) + || !self.allocation_selection_valid(&active.selection) + || active.press.stamp != stamp + { self.allocation_drag.retire(); self.status = if self.allocation_drag_clean() { "The view changed. Start this drag again.".into() @@ -569,17 +979,35 @@ impl App { }; return; } - let Some(mut active) = self.allocation_drag.active.take() else { - return; - }; - if active.press.token != token || active.press.stamp != stamp { - self.allocation_drag.active = Some(active); + let mut active = self.allocation_drag.active.take().unwrap(); + let press = active.press.clone(); + active.crossed |= pointer.is_some_and(|p| p.distance(press.start) > 3.); + let in_bounds = pointer + .is_some_and(|p| p.x.is_finite() && p.y.is_finite() && contains(stamp.bounds, p)); + if !active.crossed && in_bounds { + if release { + self.multiselect_event(super::multiselect::Event::Collapse( + active.selection, + press.target.clone(), + )); + self.allocation_drag.retire(); + self.status = "Element selected".into(); + } else { + self.allocation_drag.active = Some(active); + } return; } - let press = active.press.clone(); - let moved = pointer.is_some_and(|p| p.distance(press.start) > 3.); - let hit = press.frozen.resolve(stamp, pointer, press.arrange); - if moved { + let ordinary_performers = matches!(&active.prepared, Prepared::Performers(_)); + let hit = if !in_bounds { + Hit::OutsideViewport + } else if ordinary_performers { + Hit::Destination(plan::Destination::LayoutOnly, None) + } else { + active.frozen.as_ref().map_or(Hit::Occluded, |f| { + f.resolve(stamp, pointer, press.mode == Mode::Arrange) + }) + }; + if active.crossed { match &hit { Hit::Destination(_, Some(target)) => active.inspection_targets = vec![*target], Hit::Ambiguous(targets) => active.inspection_targets = targets.clone(), @@ -588,64 +1016,63 @@ impl App { } let mut targets = Vec::new(); let mut preview = None; - let result: Result = match hit { - Hit::OutsideViewport => Err(if release { - "Work drag canceled outside the canvas." - } else { - "Pointer outside canvas · return to choose a destination; releasing here cancels the drag." - }.into()), - _ if !moved => Ok("Work selected".into()), + let result: Result = if matches!(hit, Hit::OutsideViewport) { + Err(if release { "Drag canceled outside the canvas." } + else { "Pointer outside canvas · return to choose a destination; releasing here cancels the drag." }.into()) + } else if let Prepared::Refused(reason) = &active.prepared { + Err(reason.clone()) + } else { + match hit { + Hit::OutsideViewport => unreachable!(), Hit::Occluded => Err("Aim at exposed performer space or empty canvas; this card, port, connection or control blocks the drop.".into()), Hit::Ambiguous(indices) => { - targets = indices; - Err("Two unrelated performers overlap here. Use an exposed region.".into()) + targets = indices; Err("Two unrelated performers overlap here. Use an exposed region.".into()) } Hit::Destination(destination, target) => { targets.extend(target); - let semantic = if let Some((_, result)) = active.cache.iter().find(|(d, _)| *d == destination) { - result.clone() - } else { - let result = plan::plan_semantics(self.project(), &press.scope, destination, &active.ids).map(Arc::new); - active.cache.push((destination, result.clone())); - result + let candidate = if let Some((_, cached)) = active.cache.iter().find(|(d, _)| *d == destination) { cached.clone() } + else { + let candidate = match &active.prepared { + Prepared::Singleton(scope) => plan::plan_semantics(self.project(), scope, destination, &active.ids) + .map(|p| Candidate::Singleton(Arc::new(p))), + Prepared::Assignment(scope) => plan::assignment::plan_semantics(scope, destination, &active.ids) + .map(|p| Candidate::Assignment(Arc::new(p))), + Prepared::Arrange(plan) => Ok(Candidate::Arrange(plan.clone())), + Prepared::Performers(plan) => Ok(Candidate::Performers(plan.clone())), + Prepared::Refused(_) => unreachable!(), + }; + active.cache.push((destination, candidate.clone())); candidate }; - semantic.map_err(|r| refusal(&r)).and_then(|semantic| { - if stamp.zoom * 16. < 9. { + candidate.map_err(|r| refusal(&r)).and_then(|candidate| { + if !ordinary_performers && stamp.zoom * 16. < 9. { return Err("Make room or adjust the view, then drag again. Zoom in to read the work and destination.".into()); } - let pointer = pointer.unwrap(); - let delta = pointer - press.start; - let original = press.scope.placements[&press.scope.placement].position; + let SelectionTarget::Placement(anchor) = &press.target else { unreachable!("supported placement") }; + let original = self.project().diagrams[&stamp.context.diagram].nodes[anchor].position; + let delta = pointer.unwrap() - press.start; let requested = Position { x: original.x + delta.x / stamp.zoom, y: original.y + delta.y / stamp.zoom }; let presentation = plan::PresentationContext { viewport: stamp.viewport(), zoom: stamp.zoom, - target: target.map(|i| { - let t = &press.frozen.targets[i]; - plan::TargetPresentation { placement: t.placement } - }), - }; - let placement = plan::place(&semantic, requested, presentation).map_err(|r| refusal(&r))?; - let label = if semantic.warnings.is_empty() { semantic.label.clone() } else { - format!("{} · {} model warning(s) retained", semantic.label, semantic.warnings.len()) + target: target.map(|i| plan::TargetPresentation { placement: active.frozen.as_ref().unwrap().targets[i].placement }), }; + let placed = candidate.place(requested, presentation).map_err(|r| refusal(&r))?; + let label = candidate.label(); if release { - let command = plan::finish(&semantic, self.project(), &placement).map_err(|r| refusal(&r))?; - if let Some(command) = command { - // Bypass applying selectors and unrelated repair helpers. The - // planner's exact Batch enters the authoritative source pipeline. - self.document.execute(command).map_err(|e| format!("{} · retry this drag after correcting the source issue", e))?; + if let Some(command) = candidate.finish(self.project(), &placed).map_err(|r| refusal(&r))? { + self.document.execute(command) + .map_err(|e| format!("{e} · retry this drag after correcting the source issue"))?; + // sync refreshes the retained primary once; no anchor promotion. self.sync(); - self.selected = press.scope.work; - self.selected_node = Some(press.scope.placement); - self.refresh_draft(); + self.reconcile_multiselect(); } } else { - let routed = Arc::new(routing::route_diagram(semantic.project(), &placement.diagram, &placement.view)); - preview = Some(Arc::new(Preview { token, semantic, placement, routed })); + let routed = Arc::new(routing::route_diagram(candidate.project(), placed.diagram(), placed.view())); + preview = Some(Arc::new(Preview { token, candidate, placement: placed.into_preview(), routed })); } Ok(label) }) } + } }; let refused = result.is_err(); let text = result.unwrap_or_else(|text| text); @@ -654,10 +1081,13 @@ impl App { self.allocation_drag.retire(); } else { let inspection_targets = active.inspection_targets.clone(); + let frozen = active.frozen.clone(); self.allocation_drag.active = Some(active); self.allocation_drag.preview = preview; self.allocation_drag.feedback = Some(Feedback { press, + frozen, + ordinary_performers, targets, inspection_targets, text, diff --git a/crates/oa-desktop/src/app/allocation_drag_full_tree_tests.rs b/crates/oa-desktop/src/app/allocation_drag_full_tree_tests.rs index c53d29f4..e445ac69 100644 --- a/crates/oa-desktop/src/app/allocation_drag_full_tree_tests.rs +++ b/crates/oa-desktop/src/app/allocation_drag_full_tree_tests.rs @@ -4,6 +4,9 @@ use super::*; use crate::behavior::canvas_workspace::tests::{Mounted, key}; use iced::{Event as Input, keyboard}; +#[path = "allocation_drag_tests/control_focus.rs"] +mod control_focus; + fn character(text: &str, modifiers: keyboard::Modifiers) -> Input { Input::Keyboard(keyboard::Event::KeyPressed { key: keyboard::Key::Character(text.into()), @@ -211,7 +214,7 @@ async fn actual_keyboard_arrange_shortcut_consumes_once_escape_cancels_and_dirty assert!( messages .iter() - .any(|m| matches!(m, Message::WorkDrag(Event::Press(p)) if p.arrange)) + .any(|m| matches!(m, Message::WorkDrag(Event::Press(p)) if p.mode == Mode::Arrange)) ); assert!(!f.app.allocation_drag.arrange_next); assert_eq!(f.app.document.revision(), 1, "{}", f.app.status); @@ -282,7 +285,7 @@ async fn actual_rendered_canvas_pixels_do_not_change_when_toolbar_feedback_text_ .as_ref() .expect("active frozen destination"); assert!(!feedback.refused, "{}", feedback.text); - let target = &feedback.press.frozen.targets[feedback.targets[0]]; + let target = &feedback.frozen.as_ref().unwrap().targets[feedback.targets[0]]; assert!(target.region.y * f.app.zoom + f.app.pan.y < 0.); assert_eq!(feedback.destination_identity(), "A"); let canvas = canvas_bounds(&mut mounted, &f.app); @@ -434,7 +437,7 @@ async fn actual_app_tree_exact_incoming_drag_keeps_destination_visible_and_canva selected_header.x + selected_header.width <= canvas.x + canvas.width && selected_header.y + selected_header.height <= canvas.y + canvas.height ); - let mut expected = preview.semantic.project().clone(); + let mut expected = preview.project().clone(); expected .diagrams .insert(f.diagram, preview.placement.diagram.clone()); @@ -689,7 +692,7 @@ async fn actual_toolbar_hover_inspects_last_full_name_without_authorizing_an_out .await; assert!(f.app.allocation_drag.active.is_none()); assert!(f.app.allocation_drag.feedback.is_none()); - assert_eq!(f.app.status, "Work drag canceled outside the canvas."); + assert_eq!(f.app.status, "Drag canceled outside the canvas."); assert_eq!(f.app.document.bundle(), &before); assert!(!f.app.document.can_undo()); // A fresh gesture cannot inherit the previous inspection name. diff --git a/crates/oa-desktop/src/app/allocation_drag_tests.rs b/crates/oa-desktop/src/app/allocation_drag_tests.rs index af02e7f5..46be13a0 100644 --- a/crates/oa-desktop/src/app/allocation_drag_tests.rs +++ b/crates/oa-desktop/src/app/allocation_drag_tests.rs @@ -8,6 +8,9 @@ use iced::{ #[path = "allocation_drag_full_tree_tests.rs"] mod full_tree; +#[path = "allocation_drag_tests/group.rs"] +mod group; + #[path = "allocation_drag_tests/navigation_union.rs"] mod navigation_union; @@ -48,6 +51,7 @@ fn fixture(layer: Layer) -> Fixture { app.zoom = 1.; app.pan = Vector::ZERO; app.fit_to_view = false; + app.reconcile_multiselect(); Fixture { app, a, @@ -59,7 +63,7 @@ fn fixture(layer: Layer) -> Fixture { } fn canvas(app: &App) -> DiagramCanvas<'_> { DiagramCanvas { - selection: None, + selection: app.diagram_selection.as_ref(), allocation: Some(app.allocation_capability()), project: app.project(), view_index: Some(app.document.view_index()), @@ -122,6 +126,18 @@ fn press(f: &Fixture) -> Arc { }) .expect("actual clean work-body press") } +fn admit_press(f: &mut Fixture) -> Arc { + let p = press(f); + let _ = f.app.update(Message::WorkDrag(Event::Press(p.clone()))); + assert!(f.app.allocation_drag.active.is_some(), "{}", f.app.status); + p +} +fn singleton(candidate: &Candidate) -> &Arc { + let Candidate::Singleton(plan) = candidate else { + panic!("legacy singleton candidate") + }; + plan +} fn drag(f: &mut Fixture, to: Point) { let events = batch(&f.app, start(f), to); assert!(matches!( @@ -359,7 +375,7 @@ fn arrange_is_one_gesture_and_snapped_noop_preserves_redo_and_source() { assert!(f.app.allocation_drag.arrange_next); let p = press(&f); let _ = f.app.update(Message::WorkDrag(Event::Press(p.clone()))); - assert!(p.arrange); + assert_eq!(p.mode, Mode::Arrange); assert!(!f.app.allocation_drag.arrange_next); let _ = f.app.update(Message::WorkDrag(Event::Release( p.stamp, @@ -370,7 +386,7 @@ fn arrange_is_one_gesture_and_snapped_noop_preserves_redo_and_source() { assert_eq!(f.app.document.revision(), revision); assert!(f.app.document.can_redo()); assert!(f.app.project().relations.is_empty()); - assert!(!press(&f).arrange); + assert_eq!(press(&f).mode, Mode::Assign); } #[test] @@ -397,14 +413,15 @@ fn candidate_semantics_are_cached_per_destination_with_stable_ids_and_own_index( let _ = f .app .update(Message::WorkDrag(Event::Preview(p.stamp, p.token, Some(a)))); - let first = f - .app - .allocation_drag - .preview - .as_ref() - .expect(&f.app.status) - .semantic - .clone(); + let first = singleton( + &f.app + .allocation_drag + .preview + .as_ref() + .expect(&f.app.status) + .candidate, + ) + .clone(); let stable = first .project() .relations @@ -419,20 +436,20 @@ fn candidate_semantics_are_cached_per_destination_with_stable_ids_and_own_index( ))); assert!(Arc::ptr_eq( &first, - &f.app.allocation_drag.preview.as_ref().unwrap().semantic + singleton(&f.app.allocation_drag.preview.as_ref().unwrap().candidate) )); let _ = f .app .update(Message::WorkDrag(Event::Preview(p.stamp, p.token, Some(b)))); let preview = f.app.allocation_drag.preview.as_ref().expect(&f.app.status); - assert!(!Arc::ptr_eq(&first, &preview.semantic)); - assert_eq!(preview.semantic.project().relations[&stable].source(), f.b); + assert!(!Arc::ptr_eq(&first, singleton(&preview.candidate))); + assert_eq!(preview.project().relations[&stable].source(), f.b); assert_eq!( preview.placement.view, projection::project_diagram_with_index( - preview.semantic.project(), + preview.project(), &preview.placement.diagram, - preview.semantic.index() + preview.index() ) ); assert_ne!( @@ -448,7 +465,7 @@ fn candidate_semantics_are_cached_per_destination_with_stable_ids_and_own_index( .update(Message::WorkDrag(Event::Preview(p.stamp, p.token, Some(a)))); assert!(Arc::ptr_eq( &first, - &f.app.allocation_drag.preview.as_ref().unwrap().semantic + singleton(&f.app.allocation_drag.preview.as_ref().unwrap().candidate) )); assert_eq!( f.app.allocation_drag.active.as_ref().unwrap().cache.len(), @@ -458,9 +475,19 @@ fn candidate_semantics_are_cached_per_destination_with_stable_ids_and_own_index( #[test] fn frozen_targets_use_half_open_semantic_priority_and_refuse_incomparable_overlap() { - let f = fixture(Layer::Oa); - let p = press(&f); - let mut frozen = p.frozen.clone(); + let mut f = fixture(Layer::Oa); + let p = admit_press(&mut f); + let mut frozen = f + .app + .allocation_drag + .active + .as_ref() + .unwrap() + .frozen + .as_ref() + .unwrap() + .as_ref() + .clone(); let ai = frozen .targets .iter() @@ -540,6 +567,7 @@ fn release_uses_ordered_native_position_before_later_motion_in_same_batch() { fn replace(f: &mut Fixture, project: Project) { f.app.document = crate::document::Document::from_project(project, Default::default()).unwrap(); f.app.sync(); + f.app.reconcile_multiselect(); } #[test] @@ -588,7 +616,7 @@ fn same_snapped_position_on_opposite_shared_border_rebuilds_semantics_even_if_la .1 .as_ref() .unwrap(); - assert!(!Arc::ptr_eq(a, b)); + assert!(!Arc::ptr_eq(singleton(a), singleton(b))); let stable = a .project() .relations @@ -655,13 +683,24 @@ fn mounted_role_only_outgoing_refusal_retains_placement_and_same_performer_creat "{}", f.app.status ); - let target = press(&f) + let admitted = admit_press(&mut f); + let target = f + .app + .allocation_drag + .active + .as_ref() + .unwrap() .frozen + .as_ref() + .unwrap() .targets .iter() .find(|t| t.element == f.a) .unwrap() .region; + let _ = f + .app + .update(Message::WorkDrag(Event::Cancel(admitted.token))); drag(&mut f, screen(xy(target.x + 45., target.y + 100.))); assert_eq!(f.app.document.revision(), 1, "{}", f.app.status); assert_eq!(f.app.project().relations, relations); @@ -774,8 +813,18 @@ fn frozen_collapsed_target_uses_only_visible_header_and_port_edge_controls_occlu .unwrap() .collapsed = true; replace(&mut f, project); - let p = press(&f); - let mut frozen = p.frozen.clone(); + let p = admit_press(&mut f); + let mut frozen = f + .app + .allocation_drag + .active + .as_ref() + .unwrap() + .frozen + .as_ref() + .unwrap() + .as_ref() + .clone(); let t = frozen.targets.iter().find(|t| t.element == f.a).unwrap(); let bottom = xy(t.region.x + 30., t.region.y + t.region.height); assert!(t.region.height <= 62.); @@ -945,7 +994,7 @@ fn toolbar_destination_names_transformed_target_whose_header_is_clipped() { send(&mut f.app, events); let feedback = f.app.allocation_drag.feedback.as_ref().unwrap(); assert!(!feedback.refused, "{}", feedback.text); - let target = &feedback.press.frozen.targets[feedback.targets[0]]; + let target = &feedback.frozen.as_ref().unwrap().targets[feedback.targets[0]]; assert!(screen(xy(target.region.x, target.region.y)).y < bounds().y); assert_eq!(feedback.destination_identity(), "A"); assert!(f.app.allocation_drag.preview.is_some()); @@ -1004,7 +1053,7 @@ fn oa_sa_clipped_selected_header_refuses_preview_release_and_retries_without_par assert!(feedback.refused, "{}", feedback.text); assert!(feedback.text.contains("Make room or adjust the view")); assert_eq!( - feedback.press.frozen.targets[feedback.targets[0]].element, + feedback.frozen.as_ref().unwrap().targets[feedback.targets[0]].element, f.a ); assert_eq!(f.app.project(), &accepted); @@ -1029,14 +1078,14 @@ fn oa_sa_clipped_selected_header_refuses_preview_release_and_retries_without_par send(&mut f.app, events); let active = f.app.allocation_drag.active.as_ref().unwrap(); let p = active.press.clone(); - let semantic = active.cache[0].1.as_ref().unwrap().clone(); + let semantic = singleton(active.cache[0].1.as_ref().unwrap()).clone(); let _ = f.app.update(Message::WorkDrag(Event::Preview( p.stamp, p.token, Some(clear), ))); let preview = f.app.allocation_drag.preview.as_ref().expect(&f.app.status); - assert!(Arc::ptr_eq(&semantic, &preview.semantic)); + assert!(Arc::ptr_eq(&semantic, singleton(&preview.candidate))); assert_eq!( preview.placement.diagram.nodes[&f.node].position, xy(120., 160.) @@ -1132,7 +1181,7 @@ fn oa_sa_toolbar_identity_accepts_exact_receiver_and_collapsed_header_drag() { assert!(!feedback.refused, "{}", feedback.text); assert_eq!(feedback.destination_identity(), name); assert_eq!( - feedback.press.frozen.targets[feedback.targets[0]].placement, + feedback.frozen.as_ref().unwrap().targets[feedback.targets[0]].placement, target_id ); let preview = f.app.allocation_drag.preview.as_ref().expect(&f.app.status); @@ -1144,12 +1193,12 @@ fn oa_sa_toolbar_identity_accepts_exact_receiver_and_collapsed_header_drag() { assert_eq!( preview.placement.view, projection::project_diagram_with_index( - preview.semantic.project(), + preview.project(), &preview.placement.diagram, - preview.semantic.index() + preview.index() ) ); - let mut expected = preview.semantic.project().clone(); + let mut expected = preview.project().clone(); expected .diagrams .insert(f.diagram, preview.placement.diagram.clone()); @@ -1184,6 +1233,8 @@ fn destination_identity_does_not_describe_blocked_or_outside_viewport_as_unalloc let p = press(&f); let mut feedback = Feedback { press: p, + frozen: None, + ordinary_performers: false, targets: vec![], inspection_targets: vec![], text: "A card blocks this drop".into(), diff --git a/crates/oa-desktop/src/app/allocation_drag_tests/control_focus.rs b/crates/oa-desktop/src/app/allocation_drag_tests/control_focus.rs new file mode 100644 index 00000000..3a5c1cac --- /dev/null +++ b/crates/oa-desktop/src/app/allocation_drag_tests/control_focus.rs @@ -0,0 +1,111 @@ +use super::*; + +#[tokio::test] +async fn dirty_selected_pin_outer_ring_keeps_real_name_focus_and_caret() { + let mut f = fixture(Layer::Oa); + let mut p = f.app.project().clone(); + let peer = p + .add_element(Kind::Activity, Layer::Oa, "Peer", None) + .unwrap(); + p.place(f.diagram, peer, xy(700., 500.)); + let edge = p.relate(RelationKind::Interaction, "Flow", f.work, peer); + let pin = xy(450., 547.); + p.diagrams + .get_mut(&f.diagram) + .unwrap() + .routes + .push(EdgeRoute { + edge, + source_attachment: Some(EdgeAttachment { + side: PortAnchorSide::Right, + offset: 0.5, + }), + target_attachment: Some(EdgeAttachment { + side: PortAnchorSide::Left, + offset: 0.5, + }), + waypoints: vec![pin], + label: Some(EdgeLabel { + fraction: 0.8, + offset: xy(0., -60.), + }), + }); + validate_candidate(&p).unwrap(); + let view = projection::project_view(&p, f.diagram); + let routed = routing::route_diagram(&p, &p.diagrams[&f.diagram], &view); + assert_eq!(routed.edges[&edge].pins, vec![pin]); + assert!(!routed.edges[&edge].custom_suspended); + assert!( + routed.edges[&edge] + .points + .iter() + .all(|point| point.y == 547.) + ); + f.app = App::with_project(p); + f.app.active_diagram = Some(f.diagram); + f.app.window_size = iced::Size::new(2000., 1400.); + let _ = f.app.update(Message::Select(edge)); + f.app.reconcile_multiselect(); + let mut mounted = Mounted::new(&mut f.app).await; + f.app.zoom = 1.; + f.app.pan = Vector::ZERO; + f.app.fit_to_view = false; + mounted.settle(&mut f.app).await; + let field = name_field(&mut mounted, &f.app); + mounted.click(&mut f.app, field.center()).await; + mounted + .batch( + &mut f.app, + &[key(keyboard::key::Named::Home)], + mouse::Cursor::Unavailable, + ) + .await; + type_key(&mut mounted, &mut f.app, "X").await; + mounted + .batch( + &mut f.app, + &[key(keyboard::key::Named::ArrowLeft)], + mouse::Cursor::Unavailable, + ) + .await; + assert_eq!(f.app.draft.as_ref().unwrap().inputs["/name"].raw, "XFlow"); + assert!(f.app.draft_dirty()); + let accepted = f.app.document.snapshot(); + let source = f.app.document.bundle().clone(); + let selection = f.app.multiselect_snapshot().unwrap(); + let pan = f.app.pan; + // Exposed straight-run bend: outside the 7px line hit, within the 8px handle. + let point = at(&mut mounted, &f, xy(pin.x, pin.y + 7.5)); + let messages = mounted.click(&mut f.app, point).await; + assert!( + messages + .iter() + .any(|m| matches!(m, Message::WorkDrag(Event::Refused(..)))) + ); + assert!(!messages.iter().any(|m| matches!( + m, + Message::Pan(_) + | Message::Select(_) + | Message::SelectNode(_) + | Message::MoveNode(..) + | Message::Route(_) + | Message::DiagramSelection(_) + ))); + assert!(Arc::ptr_eq(&accepted, &f.app.document.snapshot())); + assert_eq!(f.app.document.bundle(), &source); + assert_eq!(f.app.multiselect_snapshot().unwrap(), selection); + assert_eq!(f.app.pan, pan); + assert!( + is_focused(&mut mounted, &f.app, field), + "dirty handle press must stop before outside-press unfocus" + ); + let messages = type_key(&mut mounted, &mut f.app, "Y").await; + assert!( + messages + .iter() + .any(|m| matches!(m, Message::Field(path, _) if path == "/name")) + ); + assert_eq!(f.app.draft.as_ref().unwrap().inputs["/name"].raw, "YXFlow"); + assert_eq!(f.app.document.bundle(), &source); + assert!(!f.app.document.can_undo()); +} diff --git a/crates/oa-desktop/src/app/allocation_drag_tests/group.rs b/crates/oa-desktop/src/app/allocation_drag_tests/group.rs new file mode 100644 index 00000000..6a60d986 --- /dev/null +++ b/crates/oa-desktop/src/app/allocation_drag_tests/group.rs @@ -0,0 +1,739 @@ +use super::*; +use crate::app::multiselect::{self, Choice}; +use crate::diagram_usage::Target as SelectionTarget; + +struct Group { + f: Fixture, + y: Id, + yn: Id, + c: Id, + cn: Id, + an: Id, + ax: Id, + by: Id, +} + +fn group(layer: Layer) -> Group { + let mut f = fixture(layer); + let mut p = f.app.project().clone(); + let y = p + .add_element(p.elements[&f.work].kind(), layer, "Y", None) + .unwrap(); + let c = p + .add_element( + if layer == Layer::Oa { + Kind::Actor + } else { + Kind::SystemActor + }, + layer, + "C", + None, + ) + .unwrap(); + let yn = p.place(f.diagram, y, xy(140., 600.)); + let cn = p.place(f.diagram, c, xy(1500., 100.)); + let d = p.diagrams.get_mut(&f.diagram).unwrap(); + d.nodes.get_mut(&f.node).unwrap().position = xy(140., 200.); + d.nodes + .values_mut() + .find(|n| n.element == f.b) + .unwrap() + .position = xy(100., 500.); + d.nodes.get_mut(&cn).unwrap().size = Size { + width: 600., + height: 900., + }; + let an = d.nodes.values().find(|n| n.element == f.a).unwrap().id; + let ax = p.relate( + RelationKind::Allocation, + "Reviewed X responsibility", + f.a, + f.work, + ); + let by = p.relate( + RelationKind::Allocation, + "Reviewed Y responsibility", + f.b, + y, + ); + p.relations.get_mut(&ax).unwrap().description = "Keep X provenance".into(); + p.relations.get_mut(&by).unwrap().description = "Keep Y provenance".into(); + validate_candidate(&p).unwrap(); + f.app = App::with_project(p); + f.app.active_diagram = Some(f.diagram); + f.app.zoom = 1.; + f.app.pan = Vector::ZERO; + f.app.fit_to_view = false; + f.app.reconcile_multiselect(); + Group { + f, + y, + yn, + c, + cn, + an, + ax, + by, + } +} + +fn choose(app: &mut App, target: SelectionTarget, choice: Choice) { + let context = multiselect::context(app.route_context().unwrap()); + let _ = app.update(Message::DiagramSelection(multiselect::Event::Choose( + context, target, choice, + ))); +} + +fn select(app: &mut App, ids: &[Id]) { + for (i, id) in ids.iter().enumerate() { + choose( + app, + SelectionTarget::Placement(*id), + if i == 0 { Choice::Replace } else { Choice::Add }, + ); + } + assert_eq!( + app.diagram_selection.as_ref().unwrap().targets(), + &ids.iter() + .copied() + .map(SelectionTarget::Placement) + .collect::>() + ); +} + +fn selection( + app: &App, +) -> ( + Vec, + Option, + Id, + Option, +) { + let s = app.diagram_selection.as_ref().unwrap(); + ( + s.targets().to_vec(), + s.primary().cloned(), + app.selected, + app.selected_node, + ) +} + +fn node_point(app: &App, node: Id) -> Point { + let view = projection::project_view(app.project(), app.active_diagram.unwrap()); + let n = view.nodes.iter().find(|n| n.id == node).unwrap(); + screen(xy(n.position.x + 40., n.position.y + 35.)) +} + +fn input(app: &mut App, state: &mut CanvasState, event: mouse::Event, at: Point) { + if let Some(message) = pointer(&canvas(app), state, event, at) { + let _ = app.update(message); + } +} + +fn begin(app: &mut App, state: &mut CanvasState, from: Point) -> Arc { + input( + app, + state, + mouse::Event::CursorMoved { position: from }, + from, + ); + let message = pointer( + &canvas(app), + state, + mouse::Event::ButtonPressed(mouse::Button::Left), + from, + ) + .unwrap(); + let Message::WorkDrag(Event::Press(packet)) = &message else { + panic!("Allocation placement must enter its captured lifetime: {message:?}"); + }; + let packet = packet.clone(); + let _ = app.update(message); + packet +} + +fn move_pointer(app: &mut App, state: &mut CanvasState, to: Point) { + input(app, state, mouse::Event::CursorMoved { position: to }, to); +} + +fn release(app: &mut App, state: &mut CanvasState, to: Point) { + input( + app, + state, + mouse::Event::ButtonReleased(mouse::Button::Left), + to, + ); +} + +fn exact_history( + app: &mut App, + before: &Project, + source: &oa_core::source_project::SourceBundle, + expected: &Project, +) { + assert_eq!(app.project(), expected); + let after = app.document.bundle().clone(); + let reopened = + crate::document::Document::from_bundle(after.clone(), Default::default()).unwrap(); + assert_eq!(reopened.project(), expected); + let _ = app.update(Message::Undo); + assert_eq!(app.project(), before); + assert_eq!(app.document.bundle(), source); + assert!( + !app.document.can_undo(), + "one group drag is one history step" + ); + let _ = app.update(Message::Redo); + assert_eq!(app.project(), expected); + assert_eq!(app.document.bundle(), &after); + assert!(!app.document.can_redo()); +} + +#[test] +fn group_assignment_split_rebuild_preserves_primary_and_commits_one_complete_candidate() { + for layer in [Layer::Oa, Layer::Sa] { + for anchor_is_primary in [false, true] { + let mut g = group(layer); + select(&mut g.f.app, &[g.f.node, g.yn]); + let held = selection(&g.f.app); + let before = g.f.app.project().clone(); + let source = g.f.app.document.bundle().clone(); + let anchor = if anchor_is_primary { g.yn } else { g.f.node }; + let from = node_point(&g.f.app, anchor); + let to = from + Vector::new(1400., 0.); + let mut state = CanvasState::default(); + let packet = begin(&mut g.f.app, &mut state, from); + assert_eq!(packet.target, SelectionTarget::Placement(anchor)); + assert_eq!( + selection(&g.f.app), + held, + "member press must not inspect/collapse" + ); + assert_eq!(g.f.app.document.bundle(), &source); + move_pointer(&mut g.f.app, &mut state, to); + let preview = + g.f.app + .allocation_drag + .preview + .as_ref() + .expect(&g.f.app.status); + let Candidate::Assignment(plan) = &preview.candidate else { + panic!("group must use combined assignment"); + }; + assert_eq!(plan.changed_count(), 2); + assert_eq!(plan.already_correct_count(), 0); + assert_eq!(selection(&g.f.app), held); + assert_eq!(g.f.app.project(), &before, "preview is immutable"); + release(&mut g.f.app, &mut state, to); + let mut expected = before.clone(); + for (relation, work) in [(g.ax, g.f.work), (g.by, g.y)] { + expected.relations.get_mut(&relation).unwrap().body = RelationBody::Allocation { + source: g.c, + target: work, + }; + } + let d = expected.diagrams.get_mut(&g.f.diagram).unwrap(); + d.nodes.get_mut(&g.f.node).unwrap().position = xy(1540., 200.); + d.nodes.get_mut(&g.yn).unwrap().position = xy(1540., 600.); + assert_eq!(selection(&g.f.app), held); + exact_history(&mut g.f.app, &before, &source, &expected); + } + } +} + +#[test] +fn group_arrange_work_and_performer_anchors_preserve_every_responsibility() { + for layer in [Layer::Oa, Layer::Sa] { + for performer_anchor in [false, true] { + let mut g = group(layer); + select(&mut g.f.app, &[g.an, g.yn]); + let held = selection(&g.f.app); + let before = g.f.app.project().clone(); + let source = g.f.app.document.bundle().clone(); + let _ = g.f.app.update(Message::WorkDrag(Event::ArrangeNext)); + assert!(g.f.app.allocation_drag.arrange_next); + let from = node_point(&g.f.app, if performer_anchor { g.an } else { g.yn }); + let to = from + Vector::new(400., 0.); + let mut state = CanvasState::default(); + let packet = begin(&mut g.f.app, &mut state, from); + assert_eq!(packet.mode, Mode::Arrange); + assert!(!g.f.app.allocation_drag.arrange_next); + assert_eq!(selection(&g.f.app), held); + move_pointer(&mut g.f.app, &mut state, to); + assert!( + matches!( + g.f.app + .allocation_drag + .preview + .as_ref() + .map(|p| &p.candidate), + Some(Candidate::Arrange(_)) + ), + "{}", + g.f.app.status + ); + release(&mut g.f.app, &mut state, to); + let mut expected = before.clone(); + let d = expected.diagrams.get_mut(&g.f.diagram).unwrap(); + d.nodes.get_mut(&g.an).unwrap().position = xy(500., 100.); + d.nodes.get_mut(&g.f.node).unwrap().position = xy(540., 200.); + d.nodes.get_mut(&g.yn).unwrap().position = xy(540., 600.); + assert_eq!(selection(&g.f.app), held); + exact_history(&mut g.f.app, &before, &source, &expected); + } + } +} + +#[test] +fn unsupported_group_click_collapses_but_threshold_crossing_never_becomes_click() { + for performer_anchor in [false, true] { + for distance in [0., 3., 3.25] { + let mut g = group(Layer::Oa); + select(&mut g.f.app, &[g.an, g.f.node]); + let held = selection(&g.f.app); + let before = g.f.app.document.snapshot(); + let anchor = if performer_anchor { g.an } else { g.f.node }; + let from = node_point(&g.f.app, anchor); + let mut state = CanvasState::default(); + begin(&mut g.f.app, &mut state, from); + assert_eq!(selection(&g.f.app), held); + move_pointer(&mut g.f.app, &mut state, from + Vector::new(distance, 0.)); + if distance > 3. { + assert!( + g.f.app + .allocation_drag + .feedback + .as_ref() + .is_some_and(|f| f.refused) + ); + assert!(g.f.app.allocation_drag.preview.is_none()); + } + move_pointer(&mut g.f.app, &mut state, from); + release(&mut g.f.app, &mut state, from); + assert!(g.f.app.allocation_drag.active.is_none()); + assert!(Arc::ptr_eq(&before, &g.f.app.document.snapshot())); + if distance > 3. { + assert_eq!( + selection(&g.f.app), + held, + "crossed-and-returned refusal must retain group" + ); + } else { + let s = g.f.app.diagram_selection.as_ref().unwrap(); + assert_eq!(s.targets(), &[SelectionTarget::Placement(anchor)]); + assert_eq!(s.primary(), Some(&SelectionTarget::Placement(anchor))); + } + } + } +} + +#[test] +fn allocation_bare_moves_cannot_bypass_armed_active_or_retired_lifetime() { + for (stage, singleton) in (0..3).flat_map(|stage| [(stage, false), (stage, true)]) { + let mut g = group(Layer::Oa); + let ids = if singleton { + vec![g.an] + } else { + vec![g.an, g.cn] + }; + select(&mut g.f.app, &ids); + let snapshot = g.f.app.multiselect_snapshot().unwrap(); + let held = selection(&g.f.app); + let before = g.f.app.document.snapshot(); + let _ = g.f.app.update(Message::WorkDrag(Event::ArrangeNext)); + let mut state = CanvasState::default(); + let from = node_point(&g.f.app, g.an); + if stage > 0 { + let press = begin(&mut g.f.app, &mut state, from); + if stage == 2 { + let _ = + g.f.app + .update(Message::WorkDrag(Event::Cancel(press.token))); + } + } + let epoch = g.f.app.allocation_drag.epoch; + let armed = g.f.app.allocation_drag.arrange_next; + let token = + g.f.app + .allocation_drag + .active + .as_ref() + .map(|a| a.press.token); + let _ = g.f.app.update(Message::MoveNode(g.an, xy(500., 100.))); + let _ = + g.f.app + .update(Message::DiagramSelection(multiselect::Event::Move( + snapshot, + xy(400., 0.), + ))); + assert!(Arc::ptr_eq(&before, &g.f.app.document.snapshot())); + assert_eq!(selection(&g.f.app), held); + assert_eq!(g.f.app.allocation_drag.epoch, epoch); + assert_eq!(g.f.app.allocation_drag.arrange_next, armed); + assert_eq!( + g.f.app + .allocation_drag + .active + .as_ref() + .map(|a| a.press.token), + token + ); + } + // A fresh ordinary performer gesture still uses the existing layout policy. + // C's right edge leaves the viewport: explicit Arrange's header gate must not + // accidentally become a new restriction on ordinary performer movement. + let mut g = group(Layer::Oa); + select(&mut g.f.app, &[g.an, g.cn]); + let held = selection(&g.f.app); + let before = g.f.app.project().clone(); + let source = g.f.app.document.bundle().clone(); + let _ = g.f.app.update(Message::WorkDrag(Event::ArrangeNext)); + let from = node_point(&g.f.app, g.an); + let mut state = CanvasState::default(); + let old = begin(&mut g.f.app, &mut state, from); + let _ = g.f.app.update(Message::WorkDrag(Event::Cancel(old.token))); + let mut state = CanvasState::default(); + let fresh = begin(&mut g.f.app, &mut state, from); + assert_eq!(fresh.mode, Mode::Assign); + let to = from + Vector::new(300., 0.); + move_pointer(&mut g.f.app, &mut state, to); + assert!( + matches!( + g.f.app + .allocation_drag + .preview + .as_ref() + .map(|p| &p.candidate), + Some(Candidate::Performers(_)) + ), + "{}", + g.f.app.status + ); + release(&mut g.f.app, &mut state, to); + let mut expected = before.clone(); + let d = expected.diagrams.get_mut(&g.f.diagram).unwrap(); + d.nodes.get_mut(&g.an).unwrap().position = xy(400., 100.); + d.nodes.get_mut(&g.f.node).unwrap().position = xy(440., 200.); + d.nodes.get_mut(&g.cn).unwrap().position = xy(1800., 100.); + assert_eq!(selection(&g.f.app), held); + exact_history(&mut g.f.app, &before, &source, &expected); +} + +#[test] +fn idle_group_press_rejects_selection_generation_aba_with_unchanged_other_stamps() { + for membership_aba in [false, true] { + let mut g = group(Layer::Oa); + select(&mut g.f.app, &[g.f.node, g.yn]); + let old = press(&g.f); + let before = g.f.app.document.snapshot(); + let held = selection(&g.f.app); + let epoch = g.f.app.allocation_drag.epoch; + // Deliberately isolate selection generation from App message retirement. + // Final selection/primary/context/Document values are unchanged; this + // exercises the snapshot boundary, not an ordinary user-input sequence. + let s = g.f.app.diagram_selection.as_mut().unwrap(); + if membership_aba { + assert!(s.remove(&SelectionTarget::Placement(g.yn))); + assert!(s.add(SelectionTarget::Placement(g.yn), |_| true)); + } else { + assert!(s.set_primary(&SelectionTarget::Placement(g.f.node))); + assert!(s.set_primary(&SelectionTarget::Placement(g.yn))); + } + assert_eq!(selection(&g.f.app), held); + assert_eq!(g.f.app.allocation_drag.epoch, epoch); + assert_eq!(g.f.app.route_context(), Some(old.stamp.context)); + let _ = g.f.app.update(Message::WorkDrag(Event::Press(old.clone()))); + assert!( + g.f.app.allocation_drag.active.is_none(), + "generation-only stale selection must not acquire input" + ); + let _ = g.f.app.update(Message::WorkDrag(Event::Release( + old.stamp, + old.token, + Some(start(&g.f)), + ))); + assert!(Arc::ptr_eq(&before, &g.f.app.document.snapshot())); + assert_eq!(selection(&g.f.app), held); + } +} + +#[test] +fn group_selection_aba_and_old_press_cannot_retire_a_new_token() { + let mut g = group(Layer::Oa); + select(&mut g.f.app, &[g.f.node, g.yn]); + let old = press(&g.f); + choose( + &mut g.f.app, + SelectionTarget::Placement(g.f.node), + Choice::Inspect, + ); + choose( + &mut g.f.app, + SelectionTarget::Placement(g.yn), + Choice::Inspect, + ); + let before = g.f.app.document.snapshot(); + let from = node_point(&g.f.app, g.f.node); + let mut state = CanvasState::default(); + let fresh = begin(&mut g.f.app, &mut state, from); + let held = selection(&g.f.app); + let epoch = g.f.app.allocation_drag.epoch; + let _ = g.f.app.update(Message::WorkDrag(Event::Press(old.clone()))); + let _ = g.f.app.update(Message::WorkDrag(Event::Release( + old.stamp, + old.token, + Some(from + Vector::new(1400., 0.)), + ))); + assert_eq!( + g.f.app + .allocation_drag + .active + .as_ref() + .map(|a| a.press.token), + Some(fresh.token) + ); + assert_eq!(g.f.app.allocation_drag.epoch, epoch); + assert_eq!(selection(&g.f.app), held); + assert!(Arc::ptr_eq(&before, &g.f.app.document.snapshot())); + release(&mut g.f.app, &mut state, from); +} + +fn unassign(g: &mut Group, inside_c: bool) { + let mut project = g.f.app.project().clone(); + project.relations.remove(&g.ax); + project.relations.remove(&g.by); + if inside_c { + let d = project.diagrams.get_mut(&g.f.diagram).unwrap(); + // These unassigned cards overlap an unrelated empty performer at the + // same depth. Paint C first so the intended work press is visible and + // independent of random UUID ordering; no semantic containment exists. + let mut target = d.nodes.remove(&g.cn).unwrap(); + g.cn = Id::from_u128(1); + target.id = g.cn; + assert!(d.nodes.insert(g.cn, target).is_none()); + d.nodes.get_mut(&g.f.node).unwrap().position = xy(1540., 200.); + d.nodes.get_mut(&g.yn).unwrap().position = xy(1540., 600.); + } + g.f.app = App::with_project(project); + g.f.app.active_diagram = Some(g.f.diagram); + g.f.app.zoom = 1.; + g.f.app.pan = Vector::ZERO; + g.f.app.fit_to_view = false; + g.f.app.reconcile_multiselect(); +} + +fn c_allocations(expected: &mut Project, g: &Group, ids: &BTreeMap) { + for (work, name, alias) in [ + (g.f.work, "C performs Work", "c_performs_work"), + (g.y, "C performs Y", "c_performs_y"), + ] { + expected.relations.insert( + ids[&work], + Relation { + id: ids[&work], + alias: alias.into(), + name: name.into(), + description: String::new(), + body: RelationBody::Allocation { + source: g.c, + target: work, + }, + }, + ); + } +} + +#[test] +fn group_out_and_back_assigns_at_zero_delta_then_true_noop_preserves_redo() { + for layer in [Layer::Oa, Layer::Sa] { + let mut g = group(layer); + unassign(&mut g, true); + select(&mut g.f.app, &[g.f.node, g.yn]); + let held = selection(&g.f.app); + let before = g.f.app.project().clone(); + let source = g.f.app.document.bundle().clone(); + let from = node_point(&g.f.app, g.f.node); + let mut state = CanvasState::default(); + let press = begin(&mut g.f.app, &mut state, from); + assert_eq!(press.target, SelectionTarget::Placement(g.f.node)); + assert_eq!(selection(&g.f.app), held); + move_pointer(&mut g.f.app, &mut state, from + Vector::new(4., 0.)); + let preview = + g.f.app + .allocation_drag + .preview + .as_ref() + .expect(&g.f.app.status); + assert_eq!(preview.placement.delta, xy(0., 0.)); + let Candidate::Assignment(candidate) = &preview.candidate else { + panic!("exact work group must preview assignment"); + }; + assert_eq!(candidate.destination(), plan::Destination::Performer(g.c)); + assert_eq!(candidate.changed_count(), 2); + let ids: BTreeMap<_, _> = preview + .project() + .relations + .values() + .filter(|r| r.kind() == RelationKind::Allocation) + .map(|r| (r.target(), r.id)) + .collect(); + assert_eq!(ids.len(), 2); + move_pointer(&mut g.f.app, &mut state, from); + release(&mut g.f.app, &mut state, from); + let mut expected = before.clone(); + c_allocations(&mut expected, &g, &ids); + assert_eq!(selection(&g.f.app), held); + exact_history(&mut g.f.app, &before, &source, &expected); + g.f.app + .document + .execute(Command::Describe(g.y, "Future rationale".into())) + .unwrap(); + g.f.app.sync(); + let future = g.f.app.document.bundle().clone(); + let _ = g.f.app.update(Message::Undo); + assert!(g.f.app.document.can_undo() && g.f.app.document.can_redo()); + let snapshot = g.f.app.document.snapshot(); + let revision = g.f.app.document.revision(); + let held = selection(&g.f.app); + let from = node_point(&g.f.app, g.f.node); + let mut state = CanvasState::default(); + begin(&mut g.f.app, &mut state, from); + move_pointer(&mut g.f.app, &mut state, from + Vector::new(4., 0.)); + move_pointer(&mut g.f.app, &mut state, from); + release(&mut g.f.app, &mut state, from); + assert!( + Arc::ptr_eq(&snapshot, &g.f.app.document.snapshot()), + "{}", + g.f.app.status + ); + assert_eq!(g.f.app.document.revision(), revision); + assert_eq!(selection(&g.f.app), held); + assert!(g.f.app.document.can_undo() && g.f.app.document.can_redo()); + let _ = g.f.app.update(Message::Redo); + assert_eq!(g.f.app.document.bundle(), &future); + } +} + +#[test] +fn group_source_refusal_retains_primary_source_and_both_history_paths() { + let mut g = group(Layer::Oa); + unassign(&mut g, false); + let source = g.f.app.document.bundle().clone(); + let mut limits = oa_core::source_project::ProjectLimits::default(); + limits.model.max_source_bytes = source.files.values().map(String::len).sum::() + 128; + g.f.app.document = crate::document::Document::from_bundle(source, limits).unwrap(); + g.f.app + .document + .execute(Command::Describe(g.f.work, "Reviewed".into())) + .unwrap(); + g.f.app + .document + .execute(Command::Rename(g.y, "Future Y".into())) + .unwrap(); + let future = g.f.app.document.bundle().clone(); + assert!(g.f.app.document.undo()); + g.f.app.sync(); + g.f.app.reconcile_multiselect(); + select(&mut g.f.app, &[g.f.node, g.yn]); + let held = selection(&g.f.app); + let before = g.f.app.document.snapshot(); + let source = g.f.app.document.bundle().clone(); + let revision = g.f.app.document.revision(); + assert!(g.f.app.document.can_undo() && g.f.app.document.can_redo()); + let from = node_point(&g.f.app, g.f.node); + let to = from + Vector::new(1400., 0.); + let mut state = CanvasState::default(); + begin(&mut g.f.app, &mut state, from); + move_pointer(&mut g.f.app, &mut state, to); + assert!( + g.f.app.allocation_drag.preview.is_some(), + "geometry and semantics pass before source refusal: {}", + g.f.app.status + ); + release(&mut g.f.app, &mut state, to); + assert!(g.f.app.status.contains("retry"), "{}", g.f.app.status); + assert!(g.f.app.allocation_drag.active.is_none()); + assert!(Arc::ptr_eq(&before, &g.f.app.document.snapshot())); + assert_eq!(g.f.app.document.bundle(), &source); + assert_eq!(g.f.app.document.revision(), revision); + assert_eq!(selection(&g.f.app), held); + assert!(g.f.app.document.can_undo() && g.f.app.document.can_redo()); + let _ = g.f.app.update(Message::Redo); + assert_eq!(g.f.app.document.bundle(), &future); + let _ = g.f.app.update(Message::Undo); + assert_eq!(g.f.app.document.bundle(), &source); +} + +#[test] +fn ordinary_off_grid_performer_out_and_back_is_noop_with_redo() { + for singleton in [false, true] { + let mut g = group(Layer::Oa); + let mut project = g.f.app.project().clone(); + project + .diagrams + .get_mut(&g.f.diagram) + .unwrap() + .nodes + .get_mut(&g.an) + .unwrap() + .position = xy(103., 107.); + g.f.app = App::with_project(project); + g.f.app.active_diagram = Some(g.f.diagram); + g.f.app.zoom = 1.; + g.f.app.pan = Vector::ZERO; + g.f.app.fit_to_view = false; + g.f.app + .document + .execute(Command::Describe(g.f.work, "Reviewed".into())) + .unwrap(); + g.f.app + .document + .execute(Command::Describe(g.y, "Future".into())) + .unwrap(); + let future = g.f.app.document.bundle().clone(); + assert!(g.f.app.document.undo()); + g.f.app.sync(); + g.f.app.reconcile_multiselect(); + select( + &mut g.f.app, + &if singleton { + vec![g.an] + } else { + vec![g.an, g.cn] + }, + ); + let held = selection(&g.f.app); + let before = g.f.app.document.snapshot(); + let source = g.f.app.document.bundle().clone(); + let revision = g.f.app.document.revision(); + let from = node_point(&g.f.app, g.an); + let mut state = CanvasState::default(); + begin(&mut g.f.app, &mut state, from); + move_pointer(&mut g.f.app, &mut state, from + Vector::new(20., 0.)); + assert!( + g.f.app.allocation_drag.preview.is_some(), + "{}", + g.f.app.status + ); + move_pointer(&mut g.f.app, &mut state, from); + assert_eq!( + g.f.app + .allocation_drag + .preview + .as_ref() + .unwrap() + .placement + .delta, + xy(0., 0.) + ); + release(&mut g.f.app, &mut state, from); + assert!(Arc::ptr_eq(&before, &g.f.app.document.snapshot())); + assert_eq!(g.f.app.document.bundle(), &source); + assert_eq!(g.f.app.document.revision(), revision); + assert_eq!(selection(&g.f.app), held); + assert!(g.f.app.document.can_undo() && g.f.app.document.can_redo()); + let _ = g.f.app.update(Message::Redo); + assert_eq!(g.f.app.document.bundle(), &future); + } +} diff --git a/crates/oa-desktop/src/app/allocation_drag_tests/navigation_union.rs b/crates/oa-desktop/src/app/allocation_drag_tests/navigation_union.rs index 3b406656..7bce1ba5 100644 --- a/crates/oa-desktop/src/app/allocation_drag_tests/navigation_union.rs +++ b/crates/oa-desktop/src/app/allocation_drag_tests/navigation_union.rs @@ -161,7 +161,7 @@ fn stale_gesture_packets_preserve_navigation_and_a_new_live_cancel_still_works() .update(Message::WorkDrag(Event::Cancel(current.token))); assert!(f.app.allocation_drag.active.is_none()); assert!(f.app.allocation_drag.epoch > epoch); - assert_eq!(f.app.status, "Work drag canceled"); + assert_eq!(f.app.status, "Drag canceled"); assert!(Arc::ptr_eq(&before, &f.app.document.snapshot())); assert_eq!(f.app.source_workspace.bundle(), before.bundle); } diff --git a/crates/oa-desktop/src/app/behavior_removal_tests.rs b/crates/oa-desktop/src/app/behavior_removal_tests.rs index 1159423f..f6d6d609 100644 --- a/crates/oa-desktop/src/app/behavior_removal_tests.rs +++ b/crates/oa-desktop/src/app/behavior_removal_tests.rs @@ -544,9 +544,16 @@ async fn mounted_qualified_process_child_delete_refuses_outer_reference_and_shar .await; assert!(messages.iter().any(|message| matches!( message, - Message::TopologyCanvas(topology::CanvasEvent::SelectQualified(..)) + Message::DiagramSelection(multiselect::Event::Choose( + _, crate::diagram_usage::Target::ProcessOccurrence(actual), multiselect::Choice::Replace + )) if actual == &endpoint ))); - assert_eq!(app.selected, references[0]); + assert_eq!(app.selected, f.nodes[0]); + assert!(app.draft.is_none(), "Qualified inspection stays read-only"); + assert_eq!( + app.behavior.topology.selection_address().unwrap().endpoint, + endpoint + ); assert!( app.behavior .topology diff --git a/crates/oa-desktop/src/app/diagram_usages.rs b/crates/oa-desktop/src/app/diagram_usages.rs index f9b45d12..d76725d0 100644 --- a/crates/oa-desktop/src/app/diagram_usages.rs +++ b/crates/oa-desktop/src/app/diagram_usages.rs @@ -388,14 +388,7 @@ impl App { | Target::ScenarioObject(id) | Target::AllocationGrouping { relation: id, .. } | Target::ProcessPreviewLink { relation: id, .. } => (*id, None), - Target::ProcessOccurrence(endpoint) => ( - endpoint - .references - .first() - .copied() - .unwrap_or(endpoint.occurrence), - None, - ), + Target::ProcessOccurrence(endpoint) => (endpoint.occurrence, None), }; match usage.surface { Surface::Saved(id) => { diff --git a/crates/oa-desktop/src/app/diagram_usages/tests.rs b/crates/oa-desktop/src/app/diagram_usages/tests.rs index adced1b1..fe27aca2 100644 --- a/crates/oa-desktop/src/app/diagram_usages/tests.rs +++ b/crates/oa-desktop/src/app/diagram_usages/tests.rs @@ -881,7 +881,32 @@ fn repeated_process_calls_reveal_the_chosen_caller_and_distinct_tile_camera() { .unwrap() .clone(); choose(&mut app, token, row); - assert_eq!(app.selected, first); + assert_eq!(app.selected, leaf); + let context = app.topology_context().unwrap(); + send( + &mut app, + Message::TopologyCanvas(behavior::topology::CanvasEvent::Viewport( + context, + iced::Size::new(850., 460.), + )), + ); + assert!(app.pending_diagram_reveal.is_none()); + assert_eq!( + app.behavior.topology.selection_address().unwrap().endpoint, + OccurrenceEndpoint { + references: vec![first], + occurrence: leaf + } + ); + assert_eq!( + app.multiselect_snapshot().unwrap().primary(), + Some(&Target::ProcessOccurrence(OccurrenceEndpoint { + references: vec![first], + occurrence: leaf + })) + ); + assert!(app.process_preview_primary().is_some()); + assert!(app.draft.is_none()); assert_eq!(app.page, Page::Behavior); assert_eq!(app.behavior.active_owner, Some(fixture.owner)); let first_camera = app.behavior.topology.camera_boundary(); @@ -898,7 +923,33 @@ fn repeated_process_calls_reveal_the_chosen_caller_and_distinct_tile_camera() { }) .unwrap(); choose(&mut app, token, row); - assert_eq!(app.selected, second); + assert_eq!(app.selected, leaf); + let context = app.topology_context().unwrap(); + send( + &mut app, + Message::TopologyCanvas(behavior::topology::CanvasEvent::Viewport( + context, + iced::Size::new(850., 460.), + )), + ); + assert!(app.pending_diagram_reveal.is_none()); + assert_eq!(app.behavior.active_owner, Some(fixture.owner)); + assert_eq!( + app.behavior.topology.selection_address().unwrap().endpoint, + OccurrenceEndpoint { + references: vec![second], + occurrence: leaf + } + ); + assert_eq!( + app.multiselect_snapshot().unwrap().primary(), + Some(&Target::ProcessOccurrence(OccurrenceEndpoint { + references: vec![second], + occurrence: leaf + })) + ); + assert!(app.process_preview_primary().is_some()); + assert!(app.draft.is_none()); assert_eq!(app.selected_node, None); assert!(app.behavior.topology.camera_boundary() != first_camera); assert_eq!(authored(&app), before); diff --git a/crates/oa-desktop/src/app/multiselect.rs b/crates/oa-desktop/src/app/multiselect.rs index a518b4ae..a4bbacf1 100644 --- a/crates/oa-desktop/src/app/multiselect.rs +++ b/crates/oa-desktop/src/app/multiselect.rs @@ -19,6 +19,10 @@ pub(crate) enum Event { Clear(Context), Move(Snapshot, Position), Hide(Snapshot), + ProcessReorder( + Snapshot, + crate::behavior::topology::group_reorder::Destination, + ), Refuse(Snapshot, String), } @@ -33,6 +37,17 @@ pub(crate) fn context(c: crate::route_edit::Context) -> Context { } } +pub(crate) fn process_context(c: crate::behavior::topology::Context) -> Context { + Context { + scope: Scope { + document: c.document, + surface: Surface::Process(c.owner), + }, + 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> { snapshot.primary()?; @@ -49,7 +64,7 @@ pub(crate) fn placements(snapshot: &Snapshot) -> Option Option { match self.page { Page::Diagram => self.route_context().map(context), Page::Behavior => { + if let Some(current) = self.topology_context() { + return Some(process_context(current)); + } let current = self.state_context()?; crate::behavior::state_edit::validate_context(self.project(), current, current) .ok()?; @@ -99,6 +119,13 @@ impl App { } pub(crate) fn multiselect_target_visible(&self, target: &Target) -> bool { + if let Some(context) = self.topology_context() { + return self + .behavior + .topology + .selection_targets(self.project(), context.owner) + .contains(target); + } let Some(Context { scope: Scope { @@ -130,11 +157,14 @@ impl App { }; 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)) - } + Event::Move(s, _) + | Event::Hide(s) + | Event::ProcessReorder(s, _) + | Event::Refuse(s, _) + | Event::Collapse(s, _) => self + .diagram_selection + .as_ref() + .is_some_and(|selection| selection.matches(s, current)), } } @@ -189,6 +219,10 @@ impl App { } pub(crate) fn reconcile_multiselect(&mut self) { + if self.topology_context().is_some() { + self.reconcile_process_multiselect(); + return; + } let Some(current) = self.multiselect_context() else { self.diagram_selection = None; return; @@ -249,7 +283,11 @@ impl App { } } - fn inspect_multiselect_primary(&mut self) { + pub(super) fn inspect_multiselect_primary(&mut self) { + if self.topology_context().is_some() { + self.inspect_process_multiselect_primary(); + return; + } let diagram = self .multiselect_context() .and_then(|c| match c.scope.surface { @@ -293,6 +331,10 @@ impl App { if !self.multiselect_envelope(&event) { return; } + if self.topology_context().is_some() { + self.process_multiselect_event(event); + return; + } if !diagram_removal::enabled(self) || self.architecture.is_some() || self.connection.is_some() @@ -313,6 +355,7 @@ impl App { self.document.view_index(), ); match event { + Event::ProcessReorder(..) => {} Event::Collapse(_, target) => { if !visible(self.project(), diagram, &view, &target) { return; @@ -379,7 +422,7 @@ impl App { }) { self.status = - "Group allocation drag is not available yet. The selection was retained." + "Use an Allocation drag to assign this selection, or arm Arrange next drag." .into(); return; } diff --git a/crates/oa-desktop/src/app/process_canvas.rs b/crates/oa-desktop/src/app/process_canvas.rs index d17e7a0d..214f8455 100644 --- a/crates/oa-desktop/src/app/process_canvas.rs +++ b/crates/oa-desktop/src/app/process_canvas.rs @@ -129,6 +129,12 @@ impl App { let resolved = oa_core::topology_context::TopologyIndex::new(self.project()) .resolve(context.owner, &endpoint); if let Ok(resolved) = resolved { + // The definition may be opened from a nonprimary context + // target. Remember that exact invocation, not whichever + // qualified occurrence happened to be primary beforehand. + self.behavior + .topology + .select_context(resolved.address.clone()); self.behavior.topology.remember( context.owner, endpoint @@ -138,27 +144,18 @@ impl App { .unwrap_or(endpoint.occurrence), resolved.defining_sequence, ); + self.behavior.topology.clear_context(); self.behavior.active_owner = Some(resolved.defining_sequence); self.select(endpoint.occurrence); self.canvas_epoch = self.canvas_epoch.wrapping_add(1); } } CanvasEvent::SelectQualified(_, endpoint) => { - if !self.behavior.has_pending() - && !self.draft_dirty() - && !self.source_dirty() - && let Ok(resolved) = - oa_core::topology_context::TopologyIndex::new(self.project()) - .resolve(context.owner, &endpoint) - { - let reference = endpoint - .references - .first() - .copied() - .unwrap_or(endpoint.occurrence); - self.select(reference); - self.behavior.topology.select_context(resolved.address); - } + self.multiselect_event(multiselect::Event::Choose( + multiselect::process_context(context), + crate::diagram_usage::Target::ProcessOccurrence(endpoint), + multiselect::Choice::Replace, + )); } CanvasEvent::Select(_, id) => { if !self.behavior.has_pending() @@ -199,6 +196,9 @@ impl App { if let Some(target) = topology::referenced(self.project(), context.owner, occurrence) { + // Opening a local card returns to that local use, even if + // a qualified tile was the previous inspection target. + self.behavior.topology.clear_context(); if self .project() .elements @@ -223,8 +223,20 @@ impl App { self.behavior.topology = state; if let Some((owner, occurrence)) = back { self.behavior.active_owner = Some(owner); - self.select(occurrence); + // Back restores a use, whereas ordinary select navigates + // to a defining object. The validated return address owns + // the exact subject; never infer it from the outer card ID. + self.selected = self + .behavior + .topology + .selection_address() + .filter(|address| address.owner == owner) + .map_or(occurrence, |address| address.endpoint.occurrence); self.canvas_epoch = self.canvas_epoch.wrapping_add(1); + // Synchronize the exact primary and inspector before the + // returned caller is drawn or accepts property edits. + self.reconcile_process_multiselect(); + self.inspect_process_multiselect_primary(); } } mutation => { @@ -294,8 +306,9 @@ impl App { occurrence, before, .. } => { match topology::reorder(self.project(), context.owner, occurrence, before) { - Ok(command) => { - if self.execute(command) { + Ok(topology::group_reorder::Plan::Noop) => {} + Ok(topology::group_reorder::Plan::Change(command)) => { + if self.execute(*command) { self.selected = occurrence; self.refresh_draft(); return self.reveal_process_selection(); diff --git a/crates/oa-desktop/src/app/process_multiselect.rs b/crates/oa-desktop/src/app/process_multiselect.rs new file mode 100644 index 00000000..3258f76e --- /dev/null +++ b/crates/oa-desktop/src/app/process_multiselect.rs @@ -0,0 +1,260 @@ +//! Exact Process presentation selection and one atomic order transaction. +use super::*; +use crate::behavior::topology::group_reorder::{Plan, Prepared}; +use crate::diagram_selection::Selection; +use crate::diagram_usage::{Surface, Target}; +use multiselect::{Choice, Event}; + +fn subject(target: &Target) -> Option { + match target { + Target::ProcessOccurrence(endpoint) => Some(endpoint.occurrence), + Target::ProcessLink(id) | Target::ProcessPreviewLink { relation: id, .. } => Some(*id), + _ => None, + } +} + +impl App { + pub(crate) fn process_preview_primary(&self) -> Option<&Target> { + let context = self.topology_context()?; + let selection = self.diagram_selection.as_ref()?; + if selection.scope() != multiselect::process_context(context).scope { + return None; + } + selection + .primary() + .filter(|target| subject(target) == Some(self.selected)) + .filter(|target| match target { + Target::ProcessOccurrence(endpoint) => !endpoint.references.is_empty(), + Target::ProcessPreviewLink { .. } => true, + _ => false, + }) + } + + pub(super) fn reconcile_process_multiselect(&mut self) { + let Some(context) = self.topology_context() else { + return; + }; + let current = multiselect::process_context(context); + let visible = self + .behavior + .topology + .selection_targets(self.project(), context.owner); + let old = self + .diagram_selection + .as_ref() + .filter(|s| s.scope() == current.scope) + .and_then(Selection::primary) + .cloned(); + let address = self.behavior.topology.selection_address(); + let focused = self + .diagram_focus(Surface::Process(context.owner)) + .map(|usage| usage.target.clone()); + let legacy = focused + .clone() + .or_else(|| { + address + .as_ref() + .filter(|a| a.owner == context.owner && a.endpoint.occurrence == self.selected) + .map(|a| Target::ProcessOccurrence(a.endpoint.clone())) + }) + .or_else(|| { + visible + .iter() + .find(|target| match target { + Target::ProcessOccurrence(endpoint) => { + endpoint.references.is_empty() && endpoint.occurrence == self.selected + } + Target::ProcessLink(id) => *id == self.selected, + _ => false, + }) + .cloned() + }) + .filter(|target| visible.contains(target)); + let held = old.as_ref().is_some_and(|target| { + subject(target) == Some(self.selected) + && focused.as_ref().is_none_or(|focused| focused == target) + && match target { + Target::ProcessOccurrence(endpoint) if !endpoint.references.is_empty() => { + address + .as_ref() + .is_some_and(|a| a.owner == context.owner && a.endpoint == *endpoint) + } + _ => true, + } + }); + let lost = old.as_ref().is_some_and(|target| !visible.contains(target)); + let selection = self + .diagram_selection + .get_or_insert_with(|| Selection::new(current.scope)); + selection.reconcile(current.scope, |target| visible.contains(target)); + if held || lost { + if selection.primary() != old.as_ref() { + self.inspect_process_multiselect_primary(); + } + } else if selection.primary() != legacy.as_ref() { + if let Some(target) = legacy { + selection.replace(target, |t| visible.contains(t)); + } else { + selection.clear(); + } + } + if self.process_preview_primary().is_some() { + self.draft = None; + } + } + + pub(super) fn inspect_process_multiselect_primary(&mut self) { + let Some(context) = self.topology_context() else { + return; + }; + let primary = self + .diagram_selection + .as_ref() + .and_then(Selection::primary) + .cloned(); + self.behavior.topology.clear_context(); + self.selected = primary.as_ref().and_then(subject).unwrap_or(context.owner); + self.selected_node = None; + self.selected_bend = None; + self.selected_attachment = None; + self.diagram_navigation_focus = None; + if let Some(Target::ProcessOccurrence(endpoint)) = primary + && !endpoint.references.is_empty() + && let Ok(resolved) = oa_core::topology_context::TopologyIndex::new(self.project()) + .resolve(context.owner, &endpoint) + { + self.behavior.topology.select_context(resolved.address); + } + self.refresh_draft(); + if self.process_preview_primary().is_some() { + self.draft = None; + } + } + + pub(super) fn process_multiselect_event(&mut self, event: Event) { + let Some(context) = self.topology_context() else { + return; + }; + if let Event::Refuse(_, reason) = &event + && diagram_removal::enabled(self) + { + self.status = reason.clone(); + return; + } + if !diagram_removal::enabled(self) || !self.behavior.topology.selecting() { + self.status = + "Finish or cancel the active edit before changing the process selection.".into(); + return; + } + let visible = self + .behavior + .topology + .selection_targets(self.project(), context.owner); + match event { + Event::Choose(_, target, choice) => { + if !visible.contains(&target) { + return; + } + self.reconcile_process_multiselect(); + let selection = self.diagram_selection.as_mut().unwrap(); + let valid = |t: &Target| visible.contains(t); + 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_process_multiselect_primary(); + } + Event::Collapse(_, target) => { + if !visible.contains(&target) { + return; + } + self.diagram_selection + .as_mut() + .unwrap() + .replace(target, |t| visible.contains(t)); + self.inspect_process_multiselect_primary(); + } + Event::Clear(_) => { + self.diagram_selection.as_mut().unwrap().clear(); + self.inspect_process_multiselect_primary(); + } + Event::Refuse(_, reason) => self.status = reason, + Event::Hide(_) => { + self.status = + "Process cards are model contributions, not placements. Hide is unavailable." + .into() + } + Event::Move(..) => { + self.status = + "Process positions come from their paths. Reorder within one column instead." + .into() + } + Event::ProcessReorder(snapshot, destination) => { + if !snapshot + .targets() + .iter() + .all(|target| visible.contains(target)) + { + return; + } + match Prepared::new(self.project(), context.owner, snapshot.targets()) + .and_then(|prepared| prepared.plan(destination)) + { + Ok(Plan::Noop) => {} + Ok(Plan::Change(command)) => { + if self.execute(*command) { + self.inspect_process_multiselect_primary(); + self.status = format!( + "Reordered {} contributions · Undo restores their order", + snapshot.targets().len() + ); + } + } + Err(error) => self.status = error, + } + } + } + } + + /// A no-op must not retire a pending reveal or allocate a history entry. + pub(super) fn process_order_noop(&self, message: &Message) -> bool { + let Some(context) = self.topology_context() else { + return false; + }; + match message { + Message::DiagramSelection(Event::ProcessReorder(snapshot, destination)) => { + Prepared::new(self.project(), context.owner, snapshot.targets()) + .and_then(|prepared| prepared.plan(*destination)) + .is_ok_and(|p| matches!(p, Plan::Noop)) + } + Message::TopologyCanvas(crate::behavior::topology::CanvasEvent::Reorder { + context: captured, + occurrence, + before, + }) if *captured == context => crate::behavior::topology::reorder( + self.project(), + context.owner, + *occurrence, + *before, + ) + .is_ok_and(|p| matches!(p, Plan::Noop)), + _ => false, + } + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/oa-desktop/src/app/process_multiselect/tests.rs b/crates/oa-desktop/src/app/process_multiselect/tests.rs new file mode 100644 index 00000000..f29afa20 --- /dev/null +++ b/crates/oa-desktop/src/app/process_multiselect/tests.rs @@ -0,0 +1,979 @@ +//! Process selection through the real App admission, document and source boundary. +use super::*; +use crate::behavior::topology::group_reorder::Destination; +use crate::behavior::topology::{self, CanvasEvent, CanvasTool}; +use oa_core::OccurrenceEndpoint; + +struct Fixture { + app: App, + owner: Id, + nodes: [Id; 5], + link: Id, + work: Id, + other_owner: Id, +} + +fn local(id: Id) -> Target { + Target::ProcessOccurrence(OccurrenceEndpoint { + references: vec![], + occurrence: id, + }) +} + +fn add(project: &mut Project, owner: Id, name: &str, body: OccurrenceBody) -> Id { + let mut element = project + .make_element( + Kind::Occurrence, + project.elements[&owner].layer(), + name, + Some(owner), + ) + .unwrap(); + let Some(SharedBody::Occurrence { body: value, .. }) = element.shared_mut() else { + unreachable!() + }; + *value = body; + let id = element.id; + *project = execute_batch(project, &[Command::CreateElement(element)]).unwrap(); + id +} + +fn open(project: Project, owner: Id) -> App { + let mut app = App::with_project(project); + let _ = app.update(Message::Select(owner)); + let _ = app.update(Message::Page(Page::Behavior)); + app.reconcile_multiselect(); + assert_eq!(app.topology_context().unwrap().owner, owner); + assert_eq!( + app.multiselect_context().unwrap().scope.surface, + Surface::Process(owner) + ); + app +} + +fn fixture(layer: Layer) -> Fixture { + let (f, _) = topology::direct_tests::empty_exchange_fixture(layer); + let mut project = f.p; + let mut nodes = vec![]; + for name in ["a", "u", "b", "v", "c"] { + nodes.push(add( + &mut project, + f.owner, + name, + OccurrenceBody::Action { + action: Some(f.work[0]), + lifeline: None, + }, + )); + } + let nodes: [Id; 5] = nodes.try_into().unwrap(); + let [a, u, b, v, _] = nodes; + let link = project.relate(RelationKind::SequenceLink, "a to u", a, u); + project.relate(RelationKind::SequenceLink, "b to v", b, v); + let other = project + .make_element( + if layer == Layer::Oa { + Kind::Process + } else { + Kind::FunctionalChain + }, + layer, + "Other owner", + None, + ) + .unwrap(); + let other_owner = other.id; + project = execute_batch(&project, &[Command::CreateElement(other)]).unwrap(); + validate_candidate(&project).unwrap(); + Fixture { + app: open(project, f.owner), + owner: f.owner, + nodes, + link, + work: f.work[0], + other_owner, + } +} + +fn send(app: &mut App, event: Event) { + let _ = app.update(Message::DiagramSelection(event)); +} +fn choose(app: &mut App, target: Target, choice: Choice) { + let context = app.multiselect_context().unwrap(); + send(app, Event::Choose(context, target, choice)); +} +fn pair(f: &mut Fixture) { + choose(&mut f.app, local(f.nodes[4]), Choice::Replace); + choose(&mut f.app, local(f.nodes[0]), Choice::Add); + assert_eq!( + f.app.multiselect_snapshot().unwrap().targets(), + &[local(f.nodes[4]), local(f.nodes[0])] + ); +} +fn source_matches(app: &App) { + let compiled = source_project::compile_bundle(app.document.bundle(), LIMITS).unwrap(); + assert_eq!(&compiled.decoded.project, app.project()); + assert_eq!(&compiled.bundle, app.document.bundle()); + assert_eq!( + source_workspace::reachable(&app.source_workspace.bundle()).unwrap(), + *app.document.bundle() + ); +} +fn marker(app: &mut App, owner: Id, occurrence: Id) { + use crate::diagram_usage as usage; + let target = usage::collect( + app.project(), + occurrence, + &app.behavior.topology, + &app.behavior.scenario, + ) + .into_iter() + .find(|u| u.surface == Surface::Process(owner) && u.target == local(occurrence)) + .unwrap(); + let context = usage::Context { + document: app.canvas_document, + revision: app.document.revision(), + subject: occurrence, + }; + app.pending_diagram_reveal = Some((context, target.clone(), app.canvas_epoch)); + app.diagram_navigation_focus = Some(usage::Focus { + context, + usage: target, + selected: app.selected, + }); + assert!(app.diagram_focus(Surface::Process(owner)).is_some()); +} + +#[test] +fn process_group_reverse_click_order_changes_only_owner_steps_and_undoes_once() { + for layer in [Layer::Oa, Layer::Sa] { + let mut f = fixture(layer); + let [a, u, b, v, c] = f.nodes; + pair(&mut f); + let before = f.app.document.snapshot(); + let selection = f.app.multiselect_snapshot().unwrap(); + assert_eq!(selection.primary(), Some(&local(a))); + send( + &mut f.app, + Event::ProcessReorder(selection.clone(), Destination::After(b)), + ); + let mut expected = before.decoded.project.clone(); + *expected + .elements + .get_mut(&f.owner) + .unwrap() + .steps_mut() + .unwrap() = vec![b, u, a, v, c]; + assert_eq!(f.app.project(), &expected); + assert_eq!(f.app.selected, a); + let after_selection = f.app.multiselect_snapshot().unwrap(); + assert_eq!(after_selection.targets(), selection.targets()); + assert_eq!(after_selection.primary(), selection.primary()); + source_matches(&f.app); + let after = f.app.document.snapshot(); + let reopened = Document::from_bundle(after.bundle.clone(), LIMITS).unwrap(); + assert_eq!(reopened.project(), &expected); + 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()); + source_matches(&f.app); + 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()); + source_matches(&f.app); + } +} + +#[test] +fn process_noop_group_and_legacy_reorder_preserve_history_camera_and_pending_reveal() { + for group in [true, false] { + let mut f = fixture(Layer::Oa); + if group { + pair(&mut f); + } else { + choose(&mut f.app, local(f.nodes[0]), Choice::Replace); + } + marker(&mut f.app, f.owner, f.nodes[0]); + let before = f.app.document.snapshot(); + let selection = f.app.multiselect_snapshot().unwrap(); + let pending = f.app.pending_diagram_reveal.clone(); + let camera = f.app.behavior.topology.camera_boundary(); + let epochs = (f.app.canvas_epoch, f.app.camera_epoch); + f.app.status = "Unchanged order feedback".into(); + let message = if group { + Message::DiagramSelection(Event::ProcessReorder( + selection.clone(), + Destination::Before(f.nodes[4]), + )) + } else { + Message::TopologyCanvas(CanvasEvent::Reorder { + context: f.app.topology_context().unwrap(), + occurrence: f.nodes[0], + before: f.nodes[2], + }) + }; + let _ = f.app.update(message); + assert!(Arc::ptr_eq(&before, &f.app.document.snapshot())); + assert_eq!(f.app.multiselect_snapshot().unwrap(), selection); + assert_eq!(f.app.pending_diagram_reveal, pending); + assert!(f.app.behavior.topology.camera_boundary() == camera); + assert_eq!((f.app.canvas_epoch, f.app.camera_epoch), epochs); + assert_eq!(f.app.status, "Unchanged order feedback"); + assert!(!f.app.document.can_undo()); + assert!(!f.app.document.can_redo()); + } +} + +#[test] +fn process_mixed_link_and_cross_column_groups_refuse_whole_operation() { + for layer in [Layer::Oa, Layer::Sa] { + for mixed in [true, false] { + let mut f = fixture(layer); + choose(&mut f.app, local(f.nodes[0]), Choice::Replace); + let other = if mixed { + Target::ProcessLink(f.link) + } else { + local(f.nodes[1]) + }; + choose(&mut f.app, other.clone(), Choice::Add); + let selection = f.app.multiselect_snapshot().unwrap(); + assert_eq!(selection.targets(), &[local(f.nodes[0]), other]); + let before = f.app.document.snapshot(); + send( + &mut f.app, + Event::ProcessReorder(selection.clone(), Destination::After(f.nodes[2])), + ); + assert!(Arc::ptr_eq(&before, &f.app.document.snapshot())); + assert_eq!(f.app.multiselect_snapshot().unwrap(), selection); + assert!(!f.app.document.can_undo()); + source_matches(&f.app); + } + } +} + +#[test] +fn process_stale_document_revision_owner_epoch_and_selection_aba_reject_before_shared_hooks() { + for stale_by in ["document", "revision", "owner", "epoch", "ABA"] { + let mut f = fixture(Layer::Oa); + pair(&mut f); + let stale = f.app.multiselect_snapshot().unwrap(); + match stale_by { + "document" => f.app.canvas_document = Id::new_v4(), + "revision" => { + f.app + .document + .execute(Command::Describe(f.work, "New revision".into())) + .unwrap(); + } + "owner" => { + f.app.behavior.active_owner = Some(f.other_owner); + f.app.selected = f.other_owner; + f.app.reconcile_multiselect(); + } + "epoch" => f.app.canvas_epoch += 1, + _ => { + choose(&mut f.app, local(f.nodes[0]), Choice::Toggle); + choose(&mut f.app, local(f.nodes[0]), Choice::Toggle); + assert_eq!( + f.app.multiselect_snapshot().unwrap().targets(), + stale.targets() + ); + assert_eq!( + f.app.multiselect_snapshot().unwrap().primary(), + stale.primary() + ); + } + } + let before = f.app.document.snapshot(); + let selection = f.app.multiselect_snapshot(); + let camera = f.app.behavior.topology.camera_boundary(); + let selected = f.app.selected; + 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()); + f.app.status = "Current owner feedback".into(); + f.app.allocation_drag.arrange_next = true; + send( + &mut f.app, + Event::ProcessReorder(stale, Destination::After(f.nodes[2])), + ); + assert!( + Arc::ptr_eq(&before, &f.app.document.snapshot()), + "{stale_by}" + ); + assert_eq!(f.app.multiselect_snapshot(), selection); + assert_eq!(f.app.selected, selected); + assert!(f.app.behavior.topology.camera_boundary() == camera); + 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 owner feedback"); + assert!(f.app.allocation_drag.arrange_next); + } +} + +#[test] +fn process_pending_properties_source_form_tool_and_busy_never_implicitly_apply_or_change_group() { + for pending in [ + "properties", + "invalid properties", + "source", + "form", + "tool", + "busy", + ] { + let mut f = fixture(Layer::Oa); + pair(&mut f); + match pending { + "properties" => { + let _ = f.app.update(Message::Field( + "/name".into(), + "Unapplied contribution".into(), + )); + } + "invalid properties" => { + let _ = f + .app + .update(Message::Field("/alias".into(), "invalid alias!".into())); + } + "source" => { + let text = format!("{}\n// Unapplied source\n", f.app.source_workspace.text()); + let _ = f + .app + .update(Message::SourceEdit(text_editor::Action::SelectAll)); + let _ = f.app.update(Message::SourceEdit(text_editor::Action::Edit( + text_editor::Edit::Paste(Arc::new(text)), + ))); + } + "form" => { + let context = f.app.topology_context().unwrap(); + let _ = f.app.update(Message::TopologyCanvas(CanvasEvent::Begin { + context, + tool: topology::Tool::Work, + control: ControlKind::Choice, + insertion: topology::Insertion::default(), + })); + let _ = f.app.update(Message::Behavior(behavior::Event::Topology( + topology::Event::Name("Unsubmitted form".into()), + ))); + assert!(f.app.behavior.has_pending()); + } + "tool" => f.app.behavior.topology.choose(CanvasTool::Connect), + _ => f.app.busy = true, + } + if pending == "source" { + assert!(f.app.source_dirty()); + } + if pending.contains("properties") { + assert!(f.app.draft_dirty()); + } + let selection = f.app.multiselect_snapshot().unwrap(); + let before = f.app.document.snapshot(); + let source = f.app.source_workspace.bundle(); + let draft = f + .app + .draft + .as_ref() + .map(|d| serde_json::to_value(d.recovery_snapshot()).unwrap()); + let form = f.app.behavior.recovery_snapshot(); + for event in [ + Event::ProcessReorder(selection.clone(), Destination::After(f.nodes[2])), + Event::Clear(selection.context()), + Event::Choose(selection.context(), local(f.nodes[2]), Choice::Replace), + ] { + send(&mut f.app, event); + assert!( + Arc::ptr_eq(&before, &f.app.document.snapshot()), + "{pending}" + ); + assert_eq!(f.app.source_workspace.bundle(), source); + assert_eq!( + f.app + .draft + .as_ref() + .map(|d| serde_json::to_value(d.recovery_snapshot()).unwrap()), + draft + ); + assert_eq!(f.app.behavior.recovery_snapshot(), form); + assert_eq!(f.app.multiselect_snapshot().unwrap(), selection); + assert!(!f.app.document.can_undo()); + } + } +} + +#[test] +fn process_queued_singleton_selection_remove_reorder_and_reconnect_cannot_retire_current_group() { + let mut f = fixture(Layer::Oa); + pair(&mut f); + marker(&mut f.app, f.owner, f.nodes[0]); + let context = f.app.topology_context().unwrap(); + let before = f.app.document.snapshot(); + let selection = f.app.multiselect_snapshot().unwrap(); + let pending = f.app.pending_diagram_reveal.clone(); + let camera = f.app.behavior.topology.camera_boundary(); + let epochs = ( + f.app.canvas_epoch, + f.app.camera_epoch, + f.app.allocation_drag.epoch, + ); + f.app.status = "Group owns input".into(); + for event in [ + CanvasEvent::Select(context, f.nodes[2]), + CanvasEvent::SelectQualified( + context, + OccurrenceEndpoint { + references: vec![], + occurrence: f.nodes[2], + }, + ), + CanvasEvent::Remove(context, f.nodes[0]), + CanvasEvent::Reorder { + context, + occurrence: f.nodes[0], + before: f.nodes[4], + }, + CanvasEvent::Reconnect { + context, + link: f.link, + end: topology::End::Target, + target: f.nodes[4], + }, + ] { + let _ = f.app.update(Message::TopologyCanvas(event)); + assert!(Arc::ptr_eq(&before, &f.app.document.snapshot())); + assert_eq!(f.app.multiselect_snapshot().unwrap(), selection); + assert_eq!(f.app.pending_diagram_reveal, pending); + assert!(f.app.behavior.topology.camera_boundary() == camera); + assert_eq!( + ( + f.app.canvas_epoch, + f.app.camera_epoch, + f.app.allocation_drag.epoch + ), + epochs + ); + assert_eq!(f.app.status, "Group owns input"); + assert!(!f.app.delete_pending); + assert!(!f.app.document.can_undo()); + } +} + +fn qualified_fixture() -> (Fixture, Id, Id, Id, Id) { + let mut f = fixture(Layer::Oa); + let mut project = f.app.project().clone(); + let sequence = project + .make_element(Kind::Process, Layer::Oa, "Reusable checks", None) + .unwrap(); + let sequence_id = sequence.id; + project = execute_batch(&project, &[Command::CreateElement(sequence)]).unwrap(); + // Collapsed reuse cards still show four preview entries. Keep the tested + // leaf beyond that bound so collapsing really removes its exact target. + for index in 0..4 { + add( + &mut project, + sequence_id, + &format!("Preview prefix {index}"), + OccurrenceBody::Action { + action: Some(f.work), + lifeline: None, + }, + ); + } + let leaf = add( + &mut project, + sequence_id, + "Shared check", + OccurrenceBody::Action { + action: Some(f.work), + lifeline: None, + }, + ); + let first = add( + &mut project, + f.owner, + "First call", + OccurrenceBody::SequenceReference { + sequence: Some(sequence_id), + }, + ); + let second = add( + &mut project, + f.owner, + "Second call", + OccurrenceBody::SequenceReference { + sequence: Some(sequence_id), + }, + ); + f.app = open(project, f.owner); + let p = f.app.project().clone(); + assert!(f.app.behavior.topology.expand(&p, f.owner, first)); + assert!(f.app.behavior.topology.expand(&p, f.owner, second)); + (f, sequence_id, first, second, leaf) +} + +#[test] +fn process_qualified_primary_is_exact_readonly_and_clears_without_semantic_rebinding() { + let (mut f, _, first, second, leaf) = qualified_fixture(); + let p = f.app.project().clone(); + let endpoint = OccurrenceEndpoint { + references: vec![first], + occurrence: leaf, + }; + let target = Target::ProcessOccurrence(endpoint.clone()); + assert!(f.app.multiselect_target_visible(&target)); + choose(&mut f.app, target.clone(), Choice::Replace); + assert_eq!( + f.app.selected, leaf, + "Inspect exact leaf, not the first caller or shared work" + ); + assert_eq!(f.app.process_preview_primary(), Some(&target)); + assert_eq!( + f.app + .behavior + .topology + .selection_address() + .unwrap() + .endpoint, + endpoint + ); + assert!(f.app.draft.is_none()); + let before = f.app.document.snapshot(); + for message in [ + Message::Field("/name".into(), "Forbidden definition rename".into()), + Message::Apply, + Message::Delete, + ] { + let _ = f.app.update(message); + assert!(Arc::ptr_eq(&before, &f.app.document.snapshot())); + assert!(f.app.draft.is_none()); + assert!(!f.app.delete_pending); + assert_eq!(f.app.process_preview_primary(), Some(&target)); + } + choose(&mut f.app, local(f.nodes[0]), Choice::Add); + let mixed = f.app.multiselect_snapshot().unwrap(); + send( + &mut f.app, + Event::ProcessReorder(mixed.clone(), Destination::After(f.nodes[2])), + ); + assert!(Arc::ptr_eq(&before, &f.app.document.snapshot())); + assert_eq!(f.app.multiselect_snapshot().unwrap(), mixed); + choose(&mut f.app, target.clone(), Choice::Replace); + let current = f.app.multiselect_context().unwrap(); + send(&mut f.app, Event::Clear(current)); + assert!(f.app.multiselect_snapshot().unwrap().targets().is_empty()); + assert_eq!(f.app.selected, f.owner); + assert!(f.app.behavior.topology.selection_address().is_none()); + choose(&mut f.app, target, Choice::Replace); + assert!(f.app.behavior.topology.expand(&p, f.owner, first)); + f.app.reconcile_multiselect(); + assert!(f.app.multiselect_snapshot().unwrap().targets().is_empty()); + assert_eq!(f.app.selected, f.owner); + assert!(f.app.behavior.topology.selection_address().is_none()); + assert!(f.app.process_preview_primary().is_none()); + assert!( + f.app + .multiselect_target_visible(&Target::ProcessOccurrence(OccurrenceEndpoint { + references: vec![second], + occurrence: leaf + })) + ); + assert!(Arc::ptr_eq(&before, &f.app.document.snapshot())); +} + +fn menu(app: &mut App, target: Target, label: &str) -> Message { + use crate::context_menu::Entry; + use crate::model_menu; + let object = match &target { + Target::ProcessOccurrence(endpoint) => endpoint.occurrence, + _ => panic!("This fixture opens contribution menus"), + }; + let mut request = model_menu::Request::object(object, iced::Point::new(180., 150.)); + request.process = app.topology_context().map(Box::new); + request.selection_target = Some(Box::new(target)); + let _ = app.update(Message::Menu(model_menu::Event::Open(request))); + let opened = app + .model_menu + .as_ref() + .expect("Captured Process menu must survive the open epoch change"); + assert!(opened.valid(app)); + assert_eq!( + opened.request.process.as_deref().copied(), + app.topology_context() + ); + model_menu::popup(app) + .unwrap() + .entries + .into_iter() + .find_map(|entry| match entry { + Entry::Action { + label: name, + message: Some(message), + .. + } if name == label => Some(message), + _ => None, + }) + .unwrap_or_else(|| panic!("Missing enabled menu action: {label}")) +} + +#[test] +fn process_menu_add_uses_rebound_epoch_and_preserves_existing_primary_membership() { + let mut f = fixture(Layer::Oa); + choose(&mut f.app, local(f.nodes[0]), Choice::Replace); + let before = f.app.document.snapshot(); + let old_context = f.app.topology_context().unwrap(); + let action = menu(&mut f.app, local(f.nodes[4]), "Add to selection"); + assert_ne!(f.app.topology_context().unwrap().epoch, old_context.epoch); + assert_eq!(f.app.selected, f.nodes[0]); + let _ = f.app.update(action); + let selection = f.app.multiselect_snapshot().unwrap(); + assert_eq!(selection.targets(), &[local(f.nodes[0]), local(f.nodes[4])]); + assert_eq!(selection.primary(), Some(&local(f.nodes[4]))); + assert_eq!(f.app.selected, f.nodes[4]); + assert!(Arc::ptr_eq(&before, &f.app.document.snapshot())); + assert!(!f.app.document.can_undo()); +} + +#[test] +fn process_qualified_nonprimary_menu_opens_definition_from_caller_and_back_returns() { + let (mut f, definition, first, _, leaf) = qualified_fixture(); + choose(&mut f.app, local(f.nodes[0]), Choice::Replace); + let before = f.app.document.snapshot(); + let target = Target::ProcessOccurrence(OccurrenceEndpoint { + references: vec![first], + occurrence: leaf, + }); + let action = menu(&mut f.app, target, "Open referenced definition"); + assert_eq!(f.app.selected, f.nodes[0]); + assert_eq!(f.app.topology_context().unwrap().owner, f.owner); + let _ = f.app.update(action); + assert_eq!(f.app.behavior.active_owner, Some(definition)); + assert_eq!(f.app.topology_context().unwrap().owner, definition); + let context = f.app.topology_context().unwrap(); + let _ = f + .app + .update(Message::TopologyCanvas(CanvasEvent::Back(context))); + assert_eq!(f.app.behavior.active_owner, Some(f.owner)); + assert_eq!(f.app.topology_context().unwrap().owner, f.owner); + assert!(Arc::ptr_eq(&before, &f.app.document.snapshot())); + assert!(!f.app.document.can_undo()); +} + +#[test] +fn process_qualified_definition_back_restores_exact_invocation_readonly_subject_and_camera() { + for open_nonprimary in [false, true] { + let (mut f, definition, first, second, leaf) = qualified_fixture(); + // The shared fixture has already expanded both fifth-tile invocations. + let context = f.app.topology_context().unwrap(); + let _ = f.app.update(Message::TopologyCanvas(CanvasEvent::Zoom( + context, + Some(0.75), + ))); + let first_target = Target::ProcessOccurrence(OccurrenceEndpoint { + references: vec![first], + occurrence: leaf, + }); + let second_target = Target::ProcessOccurrence(OccurrenceEndpoint { + references: vec![second], + occurrence: leaf, + }); + if open_nonprimary { + // Keep the requested second path outside the current selection so + // its ordinary definition action is available in its context menu. + choose(&mut f.app, local(f.nodes[0]), Choice::Replace); + choose(&mut f.app, first_target.clone(), Choice::Add); + } else { + choose(&mut f.app, first_target.clone(), Choice::Replace); + choose(&mut f.app, second_target.clone(), Choice::Add); + } + assert_eq!(f.app.multiselect_snapshot().unwrap().targets().len(), 2); + assert_eq!( + f.app.multiselect_snapshot().unwrap().primary(), + Some(if open_nonprimary { + &first_target + } else { + &second_target + }) + ); + assert_eq!(f.app.selected, leaf); + assert!(f.app.draft.is_none()); + let original = f.app.document.snapshot(); + let source = f.app.document.bundle().clone(); + let camera = f.app.behavior.topology.camera_boundary(); + let target = second_target; + let action = if open_nonprimary { + menu(&mut f.app, target.clone(), "Open referenced definition") + } else { + let Target::ProcessOccurrence(endpoint) = &target else { + unreachable!() + }; + Message::TopologyCanvas(CanvasEvent::OpenQualified( + f.app.topology_context().unwrap(), + endpoint.clone(), + )) + }; + let _ = f.app.update(action); + assert_eq!(f.app.topology_context().unwrap().owner, definition); + assert_eq!(f.app.selected, leaf); + assert!(f.app.process_preview_primary().is_none()); + assert!(f.app.draft.is_some(), "Definition view is editable"); + let context = f.app.topology_context().unwrap(); + let _ = f + .app + .update(Message::TopologyCanvas(CanvasEvent::Back(context))); + assert_eq!(f.app.topology_context().unwrap().owner, f.owner); + assert_eq!(f.app.behavior.active_owner, Some(f.owner)); + assert_eq!( + f.app.multiselect_snapshot().unwrap().targets(), + std::slice::from_ref(&target) + ); + assert_eq!( + f.app.multiselect_snapshot().unwrap().primary(), + Some(&target) + ); + assert_eq!( + f.app.selected, leaf, + "Back must not inspect the outer reference" + ); + assert_eq!(f.app.process_preview_primary(), Some(&target)); + let Target::ProcessOccurrence(endpoint) = &target else { + unreachable!() + }; + let address = f.app.behavior.topology.selection_address().unwrap(); + assert_eq!(address.owner, f.owner); + assert_eq!(&address.endpoint, endpoint); + assert!( + f.app.multiselect_target_visible(&target), + "Caller expansions survive Back" + ); + assert!( + f.app.draft.is_none(), + "Returned qualified inspector stays read-only" + ); + assert!(f.app.behavior.topology.camera_boundary() == camera); + let _ = f.app.update(Message::Field( + "/name".into(), + "Must not edit the outer reference or definition".into(), + )); + assert!(f.app.draft.is_none()); + assert_eq!(f.app.process_preview_primary(), Some(&target)); + assert!(Arc::ptr_eq(&original, &f.app.document.snapshot())); + assert_eq!(f.app.document.bundle(), &source); + assert!(!f.app.document.can_undo() && !f.app.document.can_redo()); + source_matches(&f.app); + } +} + +#[test] +fn process_open_current_qualified_primary_and_catalog_navigation_restore_editable_draft() { + for catalog in [false, true] { + let (mut f, definition, first, _, leaf) = qualified_fixture(); + let target = Target::ProcessOccurrence(OccurrenceEndpoint { + references: vec![first], + occurrence: leaf, + }); + choose(&mut f.app, target.clone(), Choice::Replace); + assert_eq!(f.app.selected, leaf); + assert!(f.app.draft.is_none()); + let before = f.app.document.snapshot(); + if catalog { + let _ = f.app.update(Message::Page(Page::Catalog)); + assert_eq!(f.app.page, Page::Catalog); + } else { + let action = menu(&mut f.app, target, "Open referenced definition"); + let _ = f.app.update(action); + assert_eq!(f.app.topology_context().unwrap().owner, definition); + } + assert_eq!(f.app.selected, leaf); + assert!(f.app.process_preview_primary().is_none()); + assert!( + f.app.draft.is_some(), + "Leaving a read-only presentation restores normal property editing" + ); + let _ = f.app.update(Message::Field( + "/name".into(), + "Definition edit stays unapplied".into(), + )); + assert!(f.app.draft_dirty()); + assert!(Arc::ptr_eq(&before, &f.app.document.snapshot())); + assert!(!f.app.document.can_undo()); + } +} + +#[test] +fn process_explicit_object_navigation_leaves_qualified_use_even_for_same_leaf_id() { + for edit_properties in [false, true] { + for defining_leaf in [false, true] { + let (mut f, definition, first, _, leaf) = qualified_fixture(); + let qualified = Target::ProcessOccurrence(OccurrenceEndpoint { + references: vec![first], + occurrence: leaf, + }); + choose(&mut f.app, qualified.clone(), Choice::Replace); + assert_eq!(f.app.selected, leaf); + assert_eq!(f.app.process_preview_primary(), Some(&qualified)); + assert!(f.app.draft.is_none()); + let before = f.app.document.snapshot(); + let source = f.app.document.bundle().clone(); + let camera = f.app.behavior.topology.camera_boundary(); + let target = if defining_leaf { leaf } else { first }; + let expected_owner = if defining_leaf { definition } else { f.owner }; + let message = if edit_properties { + Message::EditProperties(target) + } else { + Message::Select(target) + }; + let _ = f.app.update(message); + assert_eq!(f.app.selected, target); + assert_eq!(f.app.topology_context().unwrap().owner, expected_owner); + assert_eq!(f.app.behavior.active_owner, Some(expected_owner)); + assert_eq!( + f.app.multiselect_snapshot().unwrap().targets(), + &[local(target)] + ); + assert_eq!( + f.app.multiselect_snapshot().unwrap().primary(), + Some(&local(target)) + ); + assert!(f.app.behavior.topology.selection_address().is_none()); + assert!(f.app.process_preview_primary().is_none()); + assert!( + f.app.draft.is_some(), + "Explicit model-object navigation restores its editor" + ); + assert!(!f.app.draft_dirty()); + if !defining_leaf { + assert!(f.app.behavior.topology.camera_boundary() == camera); + } + assert!(Arc::ptr_eq(&before, &f.app.document.snapshot())); + assert_eq!(f.app.document.bundle(), &source); + assert!(!f.app.document.can_undo() && !f.app.document.can_redo()); + source_matches(&f.app); + } + } +} + +#[test] +fn process_open_local_reference_after_qualified_inspection_returns_local_reference() { + let (mut f, definition, first, _, leaf) = qualified_fixture(); + choose( + &mut f.app, + Target::ProcessOccurrence(OccurrenceEndpoint { + references: vec![first], + occurrence: leaf, + }), + Choice::Replace, + ); + let before = f.app.document.snapshot(); + let source = f.app.document.bundle().clone(); + let camera = f.app.behavior.topology.camera_boundary(); + let context = f.app.topology_context().unwrap(); + let _ = f + .app + .update(Message::TopologyCanvas(CanvasEvent::Open(context, first))); + assert_eq!(f.app.topology_context().unwrap().owner, definition); + assert_eq!(f.app.selected, definition); + assert!(f.app.behavior.topology.selection_address().is_none()); + let context = f.app.topology_context().unwrap(); + let _ = f + .app + .update(Message::TopologyCanvas(CanvasEvent::Back(context))); + assert_eq!(f.app.topology_context().unwrap().owner, f.owner); + assert_eq!(f.app.selected, first); + assert_eq!( + f.app.multiselect_snapshot().unwrap().targets(), + &[local(first)] + ); + assert!(f.app.behavior.topology.selection_address().is_none()); + assert!(f.app.process_preview_primary().is_none()); + assert!(f.app.draft.is_some()); + assert!(f.app.behavior.topology.camera_boundary() == camera); + assert!(Arc::ptr_eq(&before, &f.app.document.snapshot())); + assert_eq!(f.app.document.bundle(), &source); + assert!(!f.app.document.can_undo() && !f.app.document.can_redo()); + source_matches(&f.app); +} + +#[test] +fn process_usage_navigation_between_two_preview_paths_keeps_exact_link_identity() { + use crate::diagram_usage as usage; + let (mut f, definition, first, second, leaf) = qualified_fixture(); + let mut project = f.app.project().clone(); + let next = add( + &mut project, + definition, + "Following check", + OccurrenceBody::Action { + action: Some(f.work), + lifeline: None, + }, + ); + let relation = project.relate(RelationKind::SequenceLink, "Reusable path", leaf, next); + f.app = open(project, f.owner); + let project = f.app.project().clone(); + assert!(f.app.behavior.topology.expand(&project, f.owner, first)); + assert!(f.app.behavior.topology.expand(&project, f.owner, second)); + let before = f.app.document.snapshot(); + for call in [first, second] { + let target = Target::ProcessPreviewLink { + relation, + source: OccurrenceEndpoint { + references: vec![call], + occurrence: leaf, + }, + target: OccurrenceEndpoint { + references: vec![call], + occurrence: next, + }, + }; + let context = usage::Context { + document: f.app.canvas_document, + revision: f.app.document.revision(), + subject: relation, + }; + let _ = f + .app + .update(Message::DiagramUsage(usage::Event::Open(context))); + let token = f.app.diagram_usages.as_ref().unwrap().token; + let rows = usage::collect( + f.app.project(), + relation, + &f.app.behavior.topology, + &f.app.behavior.scenario, + ); + let row = rows + .iter() + .find(|row| row.surface == Surface::Process(f.owner) && row.target == target) + .unwrap() + .clone(); + let _ = f.app.update(Message::DiagramUsagesReady(usage::Completion { + token, + context, + result: Ok(rows), + })); + let _ = f + .app + .update(Message::DiagramUsage(usage::Event::Choose(token, row))); + assert_eq!(f.app.selected, relation); + assert_eq!( + f.app.multiselect_snapshot().unwrap().primary(), + Some(&target) + ); + assert_eq!(f.app.process_preview_primary(), Some(&target)); + assert!(f.app.draft.is_none()); + assert!(Arc::ptr_eq(&before, &f.app.document.snapshot())); + } +} diff --git a/crates/oa-desktop/src/behavior/canvas_workspace/viewport.rs b/crates/oa-desktop/src/behavior/canvas_workspace/viewport.rs index e187e887..fcb82ef4 100644 --- a/crates/oa-desktop/src/behavior/canvas_workspace/viewport.rs +++ b/crates/oa-desktop/src/behavior/canvas_workspace/viewport.rs @@ -15,6 +15,7 @@ struct Input { pan: Option<(mouse::Button, Point)>, space: bool, bounds: Option, + modifiers: keyboard::Modifiers, } impl operation::Focusable for Input { fn is_focused(&self) -> bool { @@ -83,7 +84,11 @@ impl Widget for Viewport<'_, (s.document, s.owner, s.revision) != (self.scope.document, self.scope.owner, self.scope.revision) }) { - *state = Input::default(); + let modifiers = state.modifiers; + *state = Input { + modifiers, + ..Input::default() + }; } if state .scope @@ -143,10 +148,29 @@ impl Widget for Viewport<'_, shell: &mut Shell<'_, Message>, viewport: &Rectangle, ) { + let state = tree.state.downcast_mut::(); + if let Event::Keyboard(keyboard::Event::ModifiersChanged(modifiers)) = event { + // Physical modifiers outlive focus, busy-state child replacement, + // and semantic revisions. Observing these emits no camera command. + state.modifiers = *modifiers; + self.content.as_widget_mut().update( + &mut tree.children[0], + event, + layout, + cursor, + renderer, + clipboard, + shell, + viewport, + ); + return; + } + if matches!(event, Event::Window(iced::window::Event::Unfocused)) { + state.modifiers = keyboard::Modifiers::default(); + } if shell.is_event_captured() { return; } - let state = tree.state.downcast_mut::(); // A traversal operation is applied after this Iced event batch. Keep // its focus witness, but retire keyboard admission immediately. if state.focus_pending && matches!(event, Event::Keyboard(_)) { @@ -334,6 +358,20 @@ impl Widget for Viewport<'_, } else { mouse::Cursor::Unavailable }; + if matches!(event, Event::Mouse(mouse::Event::ButtonPressed(_))) { + // A canvas may have been rebuilt while focus/commands were denied. + // Restore held (or released) Shift before this new pointer gesture. + self.content.as_widget_mut().update( + &mut tree.children[0], + &Event::Keyboard(keyboard::Event::ModifiersChanged(state.modifiers)), + layout, + child_cursor, + renderer, + clipboard, + shell, + viewport, + ); + } self.content.as_widget_mut().update( &mut tree.children[0], event, diff --git a/crates/oa-desktop/src/behavior/scenario/workspace/tests.rs b/crates/oa-desktop/src/behavior/scenario/workspace/tests.rs index 14f69205..f89b3ce8 100644 --- a/crates/oa-desktop/src/behavior/scenario/workspace/tests.rs +++ b/crates/oa-desktop/src/behavior/scenario/workspace/tests.rs @@ -3,6 +3,58 @@ use crate::behavior::canvas_workspace::tests::{Geometry, Mounted}; use iced::Vector; use iced::advanced::{Layout, layout, widget}; +#[tokio::test] +async fn production_scenario_passive_modifiers_do_not_acquire_keyboard_focus_or_mutate_model() { + use crate::behavior::canvas_workspace::tests::{focused, key}; + let f = super::super::direct_tests::fixture(); + let mut app = App::with_project(f.project.clone()); + let _ = app.update(Message::Select(f.owner)); + let _ = app.update(Message::Page(crate::app::Page::Behavior)); + app.window_size = Size::new(1100., 700.); + let mut mounted = Mounted::new(&mut app).await; + let bounds = mounted.geometry(&app).bounds("scenario-viewport"); + let point = bounds.position() + Vector::new(50., 50.); + let camera = app.behavior.scenario.camera_boundary(); + let source = app.document.bundle().clone(); + let shift = |held| { + iced::Event::Keyboard(iced::keyboard::Event::ModifiersChanged(if held { + iced::keyboard::Modifiers::SHIFT + } else { + iced::keyboard::Modifiers::empty() + })) + }; + for busy in [false, true, false] { + app.busy = busy; + mounted.settle(&mut app).await; + let messages = mounted + .batch( + &mut app, + &[ + shift(true), + shift(false), + key(iced::keyboard::key::Named::ArrowRight), + ], + mouse::Cursor::Available(point), + ) + .await; + assert!( + !messages + .iter() + .any(|message| matches!(message, Message::ScenarioCanvas(_))) + ); + assert!(!focused(&mut mounted, &app, "viewport")); + assert!(app.behavior.scenario.camera_boundary() == camera); + assert_eq!(app.document.bundle(), &source); + } + mounted + .pan(&mut app, point, point + Vector::new(-15., 25.)) + .await; + assert!(app.behavior.scenario.camera_boundary() != camera); + assert_eq!(app.document.bundle(), &source); + assert!(!app.document.can_undo()); + assert_eq!(mounted.geometry(&app).bounds("scenario-viewport"), bounds); +} + #[tokio::test] async fn production_scenario_canvas_keeps_bounds_draft_identity_and_camera_through_editing() { use crate::behavior::canvas_workspace::{self, tests::Mounted}; diff --git a/crates/oa-desktop/src/behavior/topology.rs b/crates/oa-desktop/src/behavior/topology.rs index be0bcc0c..5381f47f 100644 --- a/crates/oa-desktop/src/behavior/topology.rs +++ b/crates/oa-desktop/src/behavior/topology.rs @@ -5,8 +5,11 @@ use iced::widget::{Column, text_input}; use std::collections::BTreeSet; pub(crate) mod contextual; mod direct; +pub(crate) mod group_reorder; mod usages; mod view; +#[cfg(test)] +pub(crate) use direct::End; use direct::Input; pub(crate) use direct::{ CanvasEvent, CanvasTool, Context, Insertion, reconnect, referenced, reorder, @@ -909,6 +912,53 @@ fn sequence_links(p: &Project, owner: Id) -> Vec<&Relation> { }) .collect() } + +/// Integer columns shared by rendering and order planning. Edges whose endpoints +/// are absent from the displayed order cannot participate in its layout. +fn column_ranks(order: &[Id], adjacency: &[(Id, Id)]) -> BTreeMap { + let ids: BTreeSet<_> = order.iter().copied().collect(); + let adjacency: Vec<_> = adjacency + .iter() + .copied() + .filter(|(source, target)| ids.contains(source) && ids.contains(target)) + .collect(); + let mut incoming: BTreeMap<_, usize> = ids.iter().map(|id| (*id, 0)).collect(); + let mut ranks: BTreeMap<_, usize> = ids.iter().map(|id| (*id, 0)).collect(); + for &(_, target) in &adjacency { + *incoming.get_mut(&target).expect("Displayed endpoint") += 1; + } + let mut queue: Vec<_> = order + .iter() + .copied() + .filter(|id| incoming[id] == 0) + .collect(); + let mut done = BTreeSet::new(); + let mut offset = 0; + while offset < queue.len() { + let id = queue[offset]; + offset += 1; + if !done.insert(id) { + continue; + } + for &(_, next) in adjacency.iter().filter(|(source, _)| *source == id) { + ranks.insert(next, ranks[&next].max(ranks[&id] + 1)); + let count = incoming.get_mut(&next).expect("Displayed endpoint"); + *count = count.saturating_sub(1); + if *count == 0 { + queue.push(next); + } + } + } + // Preserve deterministic fallback order for loops and cycle-blocked nodes. + let mut fallback = ranks.values().copied().max().unwrap_or(0) + 1; + for id in order { + if !done.contains(id) { + ranks.insert(*id, fallback); + fallback += 1; + } + } + ranks +} #[derive(Debug, Clone, PartialEq, Eq)] struct PreviewLink { relation: Id, @@ -1066,6 +1116,7 @@ struct Graph<'a> { selected: Id, selected_preview: Option, selected_preview_link: Option, + selection: Option, scale: f32, } impl<'a> Graph<'a> { @@ -1084,50 +1135,19 @@ impl<'a> Graph<'a> { ) -> Self { let context_index = oa_core::topology_context::TopologyIndex::new(p); let choices = contributions(p, owner); - let links = sequence_links(p, owner); + let displayed: BTreeSet<_> = choices.iter().map(|choice| choice.id).collect(); + let links: Vec<_> = sequence_links(p, owner) + .into_iter() + .filter(|link| displayed.contains(&link.source()) && displayed.contains(&link.target())) + .collect(); let order = legacy_order(p, owner); let adjacency: Vec<_> = links .iter() .map(|r| (r.source(), r.target())) .chain(order.windows(2).map(|pair| (pair[0], pair[1]))) .collect(); - let ids: BTreeSet<_> = choices.iter().map(|c| c.id).collect(); - let mut incoming: BTreeMap<_, usize> = ids.iter().map(|id| (*id, 0)).collect(); - let mut ranks: BTreeMap<_, usize> = ids.iter().map(|id| (*id, 0)).collect(); - for &(_, target) in &adjacency { - *incoming.entry(target).or_default() += 1; - } - let mut queue: Vec<_> = choices - .iter() - .filter(|c| incoming[&c.id] == 0) - .map(|c| c.id) - .collect(); - let mut done = BTreeSet::new(); - let mut offset = 0; - while offset < queue.len() { - let id = queue[offset]; - offset += 1; - if !done.insert(id) { - continue; - } - for &(_, next) in adjacency.iter().filter(|(source, _)| *source == id) { - ranks.insert(next, ranks[&next].max(ranks[&id] + 1)); - let count = incoming.get_mut(&next).expect("Graph endpoint"); - *count = count.saturating_sub(1); - if *count == 0 { - queue.push(next); - } - } - } - // Cycles remain explicit edges; deterministic fallback placement must - // terminate even while users are building a loop or incomplete graph. - let mut fallback = ranks.values().copied().max().unwrap_or(0) + 1; - for c in &choices { - if !done.contains(&c.id) { - ranks.insert(c.id, fallback); - fallback += 1; - } - } + let displayed_order: Vec<_> = choices.iter().map(|choice| choice.id).collect(); + let ranks = column_ranks(&displayed_order, &adjacency); let mut rows: BTreeMap = BTreeMap::new(); let mut nodes = vec![]; let mut size: Size = Size::new(640., 220.); @@ -1238,25 +1258,17 @@ impl<'a> Graph<'a> { } let ordered_paths = order .windows(2) - .map(|pair| { - let a = nodes - .iter() - .find(|n| n.id == pair[0]) - .expect("ordered source") - .rect; - let b = nodes - .iter() - .find(|n| n.id == pair[1]) - .expect("ordered target") - .rect; - OrderedPath { + .filter_map(|pair| { + let a = nodes.iter().find(|n| n.id == pair[0])?.rect; + let b = nodes.iter().find(|n| n.id == pair[1])?.rect; + Some(OrderedPath { source: pair[0], target: pair[1], points: vec![ Point::new(a.x + a.width, a.center_y()), Point::new(b.x, b.center_y()), ], - } + }) }) .collect(); let mut edges = vec![]; @@ -1400,6 +1412,7 @@ impl<'a> Graph<'a> { selected, selected_preview: None, selected_preview_link: None, + selection: None, scale: 1., } } @@ -1518,9 +1531,16 @@ impl canvas::Program for Graph<'_> { EdgeLayer::Contextual } }) { - let selected = e.id == self.selected - || e.source == self.selected - || e.target == self.selected; + let selected = self.selection.as_ref().map_or( + e.id == self.selected + || e.source == self.selected + || e.target == self.selected, + |selection| { + selection + .targets() + .contains(&crate::diagram_usage::Target::ProcessLink(e.id)) + }, + ); if e.incidence { f.stroke( &Path::new(|path| { diff --git a/crates/oa-desktop/src/behavior/topology/direct.rs b/crates/oa-desktop/src/behavior/topology/direct.rs index 1b37e0bd..ae388b3d 100644 --- a/crates/oa-desktop/src/behavior/topology/direct.rs +++ b/crates/oa-desktop/src/behavior/topology/direct.rs @@ -1,5 +1,7 @@ //! Local pointer state and ordinary command proposals for the derived process graph. use super::*; +#[path = "direct_selection.rs"] +mod selection; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Context { @@ -445,27 +447,23 @@ pub(crate) fn reconnect( execute_batch(p, std::slice::from_ref(&command)).map_err(|e| e.to_string())?; Ok(command) } -pub(crate) fn reorder(p: &Project, owner: Id, id: Id, before: Id) -> Result { - if !is_contribution(p, owner, id) || !is_contribution(p, owner, before) { - return Err("Reorder contributions in this process only.".into()); - } - let graph = Graph::new(p, owner, id); - let position = |id| graph.nodes.iter().find(|n| n.id == id).map(|n| n.rect.x); - if position(id) != position(before) { - return Err("Reorder within this column; links determine the path columns.".into()); - } - let mut steps = p.elements[&owner].steps().unwrap().clone(); - if id != before { - steps.retain(|v| *v != id); - let index = steps - .iter() - .position(|v| *v == before) - .ok_or("This slot no longer exists.")?; - steps.insert(index, id); - } - let command = Command::SetSteps(owner, steps); - execute_batch(p, std::slice::from_ref(&command)).map_err(|e| e.to_string())?; - Ok(command) +pub(crate) fn reorder( + p: &Project, + owner: Id, + id: Id, + before: Id, +) -> Result { + super::group_reorder::Prepared::new( + p, + owner, + &[crate::diagram_usage::Target::ProcessOccurrence( + OccurrenceEndpoint { + references: vec![], + occurrence: id, + }, + )], + )? + .plan(super::group_reorder::Destination::Before(before)) } #[derive(Default)] @@ -476,6 +474,8 @@ pub(super) struct Input { bounds: Option, source: Option, press: Option, + modifiers: iced::keyboard::Modifiers, + group: Option, } #[derive(Clone, Copy)] enum Press { @@ -504,6 +504,7 @@ impl Input { fn cancel(&mut self) { self.source = None; self.press = None; + self.group = None; } } impl Graph<'_> { @@ -592,6 +593,9 @@ impl Graph<'_> { bounds: Rectangle, cursor: mouse::Cursor, ) -> Option> { + if let Some(action) = self.selection_input(state, event, bounds, cursor) { + return Some(action); + } let (context, tool, editable) = self .interaction .map_or((None, CanvasTool::Select, false), |(c, t, e)| { @@ -835,6 +839,7 @@ impl Graph<'_> { cursor: mouse::Cursor, bounds: Rectangle, ) { + self.selection_overlay(state, f, bounds); let Some((context, tool, editable)) = self.interaction else { return; }; @@ -935,7 +940,12 @@ impl Graph<'_> { ); } } - if tool == CanvasTool::Select { + if tool == CanvasTool::Select + && self + .selection + .as_ref() + .is_none_or(|s| s.targets().len() == 1) + { if let Some(r) = self.handle(self.selected) { f.fill_rectangle(r.position(), r.size(), Color::WHITE); draw_text(f, "↕".into(), r.position(), 17., style::TEAL); diff --git a/crates/oa-desktop/src/behavior/topology/direct_selection.rs b/crates/oa-desktop/src/behavior/topology/direct_selection.rs new file mode 100644 index 00000000..7d233e29 --- /dev/null +++ b/crates/oa-desktop/src/behavior/topology/direct_selection.rs @@ -0,0 +1,474 @@ +//! Exact-target selection and an order gesture over one immutable selection. +use super::super::group_reorder::{Destination, Plan, Prepared}; +use super::*; +use crate::app::multiselect::{Choice, Event as SelectionEvent, process_context}; +use crate::diagram_selection::Snapshot; +use crate::diagram_usage::Target; + +pub(super) struct GroupPress { + snapshot: Snapshot, + target: Target, + start: Point, + bounds: Rectangle, + pan: iced::Vector, + scale: f32, + crossed: bool, + retired: bool, + prepared: Result, + destination: Option, + plan: Option>, +} + +impl Graph<'_> { + fn exact_target_at(&self, point: Point) -> Option { + // Visible singleton reconnect rings are painted over the card/edge. + // Shift and context selection must resolve that same topmost target. + if self.interaction.is_some_and(|(_, tool, editable)| editable && tool == CanvasTool::Select) + && self.selection.as_ref().is_some_and(|snapshot| { + snapshot.targets().len() == 1 + && matches!(snapshot.primary(), Some(Target::ProcessLink(id)) if *id == self.selected) + }) + && let Some((link, _)) = self.endpoint_at(point) + { + return Some(Target::ProcessLink(link)); + } + if let Some(endpoint) = self.qualified_at(point) { + return Some(Target::ProcessOccurrence(endpoint)); + } + for node in &self.nodes { + for link in &node.preview_links { + if node + .preview_link_visible_segment(link) + .is_some_and(|[a, b]| { + crate::diagram::distance_to_segment(point, a, b) < 5. / self.scale + }) + { + return Some(Target::ProcessPreviewLink { + relation: link.relation, + source: link.source.clone(), + target: link.target.clone(), + }); + } + } + } + self.contextual_edge_at(point) + .map(Target::ProcessLink) + .or_else(|| { + self.node_at(point).map(|id| { + Target::ProcessOccurrence(OccurrenceEndpoint { + references: vec![], + occurrence: id, + }) + }) + }) + .or_else(|| self.edge_at(point).map(Target::ProcessLink)) + } + + fn destination_at(&self, point: Point) -> Option { + let id = self.node_at(point)?; + let node = self.nodes.iter().find(|n| n.id == id)?; + Some(if point.y < node.rect.center_y() { + Destination::Before(id) + } else { + Destination::After(id) + }) + } + + fn update_group(&self, group: &mut GroupPress, pointer: Point) { + if group.retired { + return; + } + if !pointer.x.is_finite() || !pointer.y.is_finite() || !group.bounds.contains(pointer) { + group.retired = true; + group.plan = None; + return; + } + group.crossed |= pointer.distance(group.start) >= 3.; + let world = Point::new( + (pointer.x - group.bounds.x - self.camera_pan.x) / self.scale, + (pointer.y - group.bounds.y - self.camera_pan.y) / self.scale, + ); + let destination = self.destination_at(world); + // The captured semantic facts are prepared once. Only a changed object + // anchor/side needs another order plan; motion within a slot is free. + if destination != group.destination { + group.destination = destination; + group.plan = destination.map(|destination| match &group.prepared { + Ok(prepared) => prepared.plan(destination), + Err(reason) => Err(reason.clone()), + }); + } + } + + pub(super) fn selection_input( + &self, + state: &mut Input, + event: &canvas::Event, + bounds: Rectangle, + cursor: mouse::Cursor, + ) -> Option> { + use iced::keyboard::{Event as KeyEvent, Key, key::Named}; + match event { + canvas::Event::Keyboard(KeyEvent::ModifiersChanged(modifiers)) + | canvas::Event::Keyboard(KeyEvent::KeyPressed { modifiers, .. }) + | canvas::Event::Keyboard(KeyEvent::KeyReleased { modifiers, .. }) => { + state.modifiers = *modifiers + } + canvas::Event::Window(iced::window::Event::Unfocused) => { + state.modifiers = iced::keyboard::Modifiers::empty() + } + _ => {} + } + let Some((context, tool, editable)) = self.interaction else { + state.group = None; + return None; + }; + let snapshot = self + .selection + .as_ref() + .filter(|s| s.context() == process_context(context)); + if let Some(group) = &state.group { + let cancelled = !editable + || tool != CanvasTool::Select + || snapshot != Some(&group.snapshot) + || group.bounds != bounds + || group.pan != self.camera_pan + || group.scale != self.scale + || 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(KeyEvent::KeyPressed { + key: Key::Named(Named::Escape), + .. + }) + ); + if cancelled { + state.cancel(); + state.pointer = None; + return Some(Action::request_redraw().and_capture()); + } + } + if let Some(group) = &mut state.group { + match event { + canvas::Event::Mouse(mouse::Event::CursorMoved { position }) => { + state.pointer = Some(*position); + self.update_group(group, *position); + return Some(Action::request_redraw().and_capture()); + } + canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => { + let mut group = state.group.take().unwrap(); + // An unavailable cursor must never reuse the last inside point. + if let Some(pointer) = cursor.position() { + self.update_group(&mut group, pointer); + } else { + group.retired = true; + } + let result = if group.retired { + None + } else if !group.crossed { + Some(SelectionEvent::Collapse(group.snapshot, group.target)) + } else if let Err(reason) = group.prepared { + Some(SelectionEvent::Refuse(group.snapshot, reason)) + } else { + match (group.destination, group.plan) { + (Some(destination), Some(Ok(Plan::Change(_)))) => { + Some(SelectionEvent::ProcessReorder(group.snapshot, destination)) + } + (_, Some(Err(reason))) => { + Some(SelectionEvent::Refuse(group.snapshot, reason)) + } + _ => None, + } + }; + return Some( + result + .map_or_else(Action::request_redraw, |event| { + Action::publish(Message::DiagramSelection(event)) + }) + .and_capture(), + ); + } + canvas::Event::Keyboard(iced::keyboard::Event::ModifiersChanged(_)) => return None, + canvas::Event::Keyboard(_) => return Some(Action::capture()), + _ => {} + } + } + let snapshot = snapshot?; + if !editable { + return None; + } + let publish = |event| Some(Action::publish(Message::DiagramSelection(event)).and_capture()); + if matches!( + event, + canvas::Event::Keyboard(KeyEvent::KeyPressed { + key: Key::Named(Named::Delete), + repeat: false, + .. + }) + ) && (snapshot.targets().len() > 1 + || snapshot.targets().iter().any(|t| match t { + Target::ProcessPreviewLink { .. } => true, + Target::ProcessOccurrence(endpoint) => !endpoint.references.is_empty(), + _ => false, + })) + { + return publish(SelectionEvent::Refuse(snapshot.clone(), "Remove is unavailable for this selection. Open the referenced definition to edit qualified contributions; group removal needs an aggregate review.".into())); + } + let pointer = match event { + canvas::Event::Mouse(mouse::Event::CursorMoved { position }) => Some(*position), + _ => cursor.position().and(state.pointer.or(cursor.position())), + }?; + if !bounds.contains(pointer) { + return None; + } + let world = Point::new( + (pointer.x - bounds.x - self.camera_pan.x) / self.scale, + (pointer.y - bounds.y - self.camera_pan.y) / self.scale, + ); + if matches!( + event, + canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right)) + ) { + state.cancel(); + let target = self.exact_target_at(world)?; + let object = match &target { + Target::ProcessOccurrence(endpoint) => endpoint.occurrence, + Target::ProcessLink(id) | Target::ProcessPreviewLink { relation: id, .. } => *id, + _ => return None, + }; + let mut request = crate::model_menu::Request::object(object, pointer); + request.selection_target = Some(Box::new(target)); + request.process = Some(Box::new(context)); + return Some( + Action::publish(Message::Menu(crate::model_menu::Event::Open(request))) + .and_capture(), + ); + } + if !matches!( + event, + canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) + ) { + return None; + } + if tool == CanvasTool::Connect && snapshot.targets().len() > 1 { + state.cancel(); + return publish(SelectionEvent::Refuse( + snapshot.clone(), + "Connecting a selection as a group is unavailable. Select one contribution first." + .into(), + )); + } + if tool != CanvasTool::Select { + return None; + } + let target = self.exact_target_at(world); + if state.modifiers.shift() { + state.cancel(); + return target + .and_then(|target| { + publish(SelectionEvent::Choose( + process_context(context), + target, + Choice::Toggle, + )) + }) + .or_else(|| Some(Action::capture())); + } + let Some(target) = target else { + state.cancel(); + // Clearing and starting the existing background camera pan can + // share this press; only semantic object hits capture it. + return Some(Action::publish(Message::DiagramSelection( + SelectionEvent::Clear(process_context(context)), + ))); + }; + // The ordinary endpoint gesture is valid only for an exact singleton + // local link. A mixed selection must never reconnect just its primary. + if snapshot.targets().len() == 1 + && matches!(snapshot.primary(), Some(Target::ProcessLink(id)) if *id == self.selected) + && self.endpoint_at(world).is_some() + { + return None; + } + if !snapshot.targets().contains(&target) { + state.cancel(); + return publish(SelectionEvent::Choose( + process_context(context), + target, + Choice::Replace, + )); + } + state.cancel(); + state.context = Some(context); + state.tool = tool; + state.bounds = Some(bounds); + state.pointer = Some(pointer); + state.group = Some(GroupPress { + snapshot: snapshot.clone(), + target, + start: pointer, + bounds, + pan: self.camera_pan, + scale: self.scale, + crossed: false, + retired: false, + prepared: Prepared::new(self.project, context.owner, snapshot.targets()), + destination: None, + plan: None, + }); + Some(Action::request_redraw().and_capture()) + } + + pub(super) fn selection_overlay(&self, state: &Input, frame: &mut Frame, bounds: Rectangle) { + if let Some(selection) = &self.selection { + for target in selection.targets() { + let primary = selection.primary() == Some(target); + let width = if primary { 3.5 } else { 2. }; + let color = if primary { + style::TEAL + } else { + Color::from_rgb8(64, 123, 188) + }; + match target { + Target::ProcessOccurrence(endpoint) => { + let rect = if endpoint.references.is_empty() { + self.nodes + .iter() + .find(|n| n.id == endpoint.occurrence) + .map(|n| n.rect) + } else { + self.nodes + .iter() + .find(|n| n.preview.iter().any(|(shown, _)| shown == endpoint)) + .map(|n| n.endpoint_rect(endpoint)) + }; + if let Some(rect) = rect { + frame.stroke( + &Path::rounded_rectangle(rect.position(), rect.size(), 5.into()), + Stroke::default().with_color(color).with_width(width), + ); + } + } + Target::ProcessPreviewLink { + relation, + source, + target, + } => { + let link = PreviewLink { + relation: *relation, + source: source.clone(), + target: target.clone(), + }; + for node in &self.nodes { + if node.preview_links.contains(&link) + && let Some([a, b]) = node.preview_link_visible_segment(&link) + { + frame.stroke( + &Path::line(a, b), + Stroke::default().with_color(color).with_width(width), + ); + } + } + } + _ => {} + } + } + } + let Some(group) = &state.group else { + return; + }; + if group.retired + || !group.crossed + || group.bounds != bounds + || self.selection.as_ref() != Some(&group.snapshot) + || group.pan != self.camera_pan + || group.scale != self.scale + { + return; + } + let reason = group + .prepared + .as_ref() + .err() + .or_else(|| group.plan.as_ref().and_then(|plan| plan.as_ref().err())); + let color = if reason.is_some() { + Color::from_rgb8(178, 49, 49) + } else { + style::TEAL + }; + let mut position = state + .pointer + .map(|p| { + Point::new( + (p.x - bounds.x - self.camera_pan.x) / self.scale + 12., + (p.y - bounds.y - self.camera_pan.y) / self.scale + 18., + ) + }) + .unwrap_or(Point::ORIGIN); + if let Some(destination) = group.destination { + let (id, after) = match destination { + Destination::Before(id) => (id, false), + Destination::After(id) => (id, true), + }; + if let Some(node) = self.nodes.iter().find(|n| n.id == id) { + let y = if after { + node.rect.y + node.rect.height + 7. + } else { + node.rect.y - 7. + }; + frame.stroke( + &Path::line( + Point::new(node.rect.x, y), + Point::new(node.rect.x + node.rect.width, y), + ), + Stroke::default().with_color(color).with_width(3.), + ); + position = Point::new(node.rect.x, y + 3.); + } + } + let caption = reason.cloned().unwrap_or_else(|| match &group.plan { + Some(Ok(Plan::Noop)) => "Order unchanged".into(), + Some(Ok(Plan::Change(_))) => format!( + "Insert {} in source order · paths unchanged", + group.snapshot.targets().len() + ), + _ => "Point at a card's upper / lower half to insert before / after".into(), + }); + draw_text(frame, caption, position, 12. / self.scale, color); + if reason.is_none() { + let mut index = 0; + // Graph nodes are in original steps order; these numbers describe + // the block the exact same cached plan will commit on release. + for node in &self.nodes { + if group + .snapshot + .targets() + .contains(&Target::ProcessOccurrence(OccurrenceEndpoint { + references: vec![], + occurrence: node.id, + })) + { + index += 1; + draw_text( + frame, + format!("{index}"), + Point::new(node.rect.x + node.rect.width - 18., node.rect.y + 7.), + 13., + style::TEAL, + ); + } + } + } + } +} + +#[cfg(test)] +#[path = "direct_selection_tests.rs"] +mod tests; diff --git a/crates/oa-desktop/src/behavior/topology/direct_selection_tests.rs b/crates/oa-desktop/src/behavior/topology/direct_selection_tests.rs new file mode 100644 index 00000000..48e41de2 --- /dev/null +++ b/crates/oa-desktop/src/behavior/topology/direct_selection_tests.rs @@ -0,0 +1,403 @@ +use super::*; +use crate::behavior::topology::direct_tests::{Fixture, context, fixture}; +use crate::diagram_selection::Selection; + +fn local(id: Id) -> Target { + Target::ProcessOccurrence(OccurrenceEndpoint { + references: vec![], + occurrence: id, + }) +} +fn peers(layer: Layer) -> Fixture { + let mut fixture = fixture(layer); + fixture.p = execute_batch(&fixture.p, &[Command::Delete(fixture.link)]).unwrap(); + fixture +} +fn graph<'a>(fixture: &'a Fixture, targets: &[Target]) -> Graph<'a> { + let context = context(fixture.owner); + let mut selection = Selection::new(process_context(context).scope); + for target in targets { + selection.add(target.clone(), |_| true); + } + let mut graph = Graph::new(&fixture.p, fixture.owner, fixture.owner); + graph.interaction = Some((context, CanvasTool::Select, true)); + graph.selection = selection.snapshot(process_context(context)); + graph +} +fn bounds() -> Rectangle { + Rectangle::new(Point::ORIGIN, Size::new(1800., 1400.)) +} +fn point(graph: &Graph<'_>, id: Id, after: bool) -> Point { + let rect = graph.nodes.iter().find(|node| node.id == id).unwrap().rect; + Point::new( + rect.x + 70., + rect.y + if after { rect.height - 15. } else { 20. }, + ) +} +fn send( + graph: &Graph<'_>, + input: &mut Input, + event: canvas::Event, + point: Point, +) -> Option { + graph + .input(input, &event, bounds(), mouse::Cursor::Available(point)) + .and_then(|action| action.into_inner().0) +} +fn press(graph: &Graph<'_>, input: &mut Input, point: Point) -> Option { + send( + graph, + input, + canvas::Event::Mouse(mouse::Event::CursorMoved { position: point }), + point, + ); + send( + graph, + input, + canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)), + point, + ) +} +fn release(graph: &Graph<'_>, input: &mut Input, point: Point) -> Option { + send( + graph, + input, + canvas::Event::Mouse(mouse::Event::CursorMoved { position: point }), + point, + ); + send( + graph, + input, + canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)), + point, + ) +} + +#[test] +fn exact_shift_toggle_and_stationary_group_click_defer_primary() { + let f = peers(Layer::Oa); + let graph = graph(&f, &[local(f.nodes[2]), local(f.nodes[0])]); + let mut input = Input::default(); + let at = point(&graph, f.nodes[2], false); + assert!(press(&graph, &mut input, at).is_none()); + assert!( + matches!(release(&graph, &mut input, at), Some(Message::DiagramSelection(SelectionEvent::Collapse(snapshot, target))) + if target == local(f.nodes[2]) && snapshot == graph.selection.clone().unwrap()) + ); + send( + &graph, + &mut input, + canvas::Event::Keyboard(iced::keyboard::Event::ModifiersChanged( + iced::keyboard::Modifiers::SHIFT, + )), + at, + ); + assert!( + matches!(press(&graph, &mut input, at), Some(Message::DiagramSelection(SelectionEvent::Choose(_, target, Choice::Toggle))) if target == local(f.nodes[2])) + ); + assert!(input.group.is_none()); + send( + &graph, + &mut input, + canvas::Event::Window(iced::window::Event::Unfocused), + at, + ); + assert!(!input.modifiers.shift()); +} + +#[test] +fn group_drag_uses_midpoint_anchors_and_original_order_and_singleton_after_gap() { + for layer in [Layer::Oa, Layer::Sa] { + let f = peers(layer); + for targets in [ + vec![local(f.nodes[2]), local(f.nodes[0])], + vec![local(f.nodes[0])], + ] { + let graph = graph(&f, &targets); + let mut input = Input::default(); + let start = point(&graph, f.nodes[0], false); + let end = point(&graph, f.nodes[1], true); + assert!(press(&graph, &mut input, start).is_none()); + let Some(Message::DiagramSelection(SelectionEvent::ProcessReorder( + snapshot, + destination, + ))) = release(&graph, &mut input, end) + else { + panic!("admitted after slot"); + }; + assert_eq!(snapshot, graph.selection.clone().unwrap()); + assert_eq!(destination, Destination::After(f.nodes[1])); + let Plan::Change(command) = Prepared::new(&f.p, f.owner, snapshot.targets()) + .unwrap() + .plan(destination) + .unwrap() + else { + panic!("changed steps"); + }; + let Command::SetSteps(owner, steps) = *command else { + panic!("expected SetSteps"); + }; + assert_eq!(owner, f.owner); + assert_eq!(steps, vec![f.nodes[1], f.nodes[0], f.nodes[2]]); + } + } +} + +#[test] +fn selected_anchor_noop_and_irreversible_threshold_never_collapse_after_motion() { + let f = peers(Layer::Oa); + let graph = graph(&f, &[local(f.nodes[0]), local(f.nodes[2])]); + let mut input = Input::default(); + let start = point(&graph, f.nodes[0], false); + press(&graph, &mut input, start); + let away = start + iced::Vector::new(10., 0.); + send( + &graph, + &mut input, + canvas::Event::Mouse(mouse::Event::CursorMoved { position: away }), + away, + ); + assert!(release(&graph, &mut input, start).is_none()); + assert!(input.group.is_none()); + press(&graph, &mut input, start); + let outside = Point::new(-1., 100.); + send( + &graph, + &mut input, + canvas::Event::Mouse(mouse::Event::CursorMoved { position: outside }), + outside, + ); + assert!(release(&graph, &mut input, point(&graph, f.nodes[1], true)).is_none()); +} + +#[test] +fn whole_mixed_set_refuses_drag_delete_and_connect_without_singleton_bypass() { + let f = fixture(Layer::Sa); + let mut graph = graph(&f, &[local(f.nodes[0]), Target::ProcessLink(f.link)]); + graph.selected = f.link; + let mut input = Input::default(); + let start = point(&graph, f.nodes[0], false); + press(&graph, &mut input, start); + assert!( + matches!(release(&graph, &mut input, point(&graph, f.nodes[2], true)), Some(Message::DiagramSelection(SelectionEvent::Refuse(snapshot, _))) if snapshot.targets().len() == 2) + ); + let key = crate::behavior::canvas_workspace::tests::key(iced::keyboard::key::Named::Delete); + let iced::Event::Keyboard(key) = key else { + panic!("keyboard"); + }; + assert!(matches!( + send(&graph, &mut input, canvas::Event::Keyboard(key), start), + Some(Message::DiagramSelection(SelectionEvent::Refuse(_, _))) + )); + graph.interaction.as_mut().unwrap().1 = CanvasTool::Connect; + assert!(matches!( + press(&graph, &mut input, start), + Some(Message::DiagramSelection(SelectionEvent::Refuse(_, _))) + )); +} + +#[test] +fn camera_busy_context_bounds_and_escape_retire_captured_gestures() { + let f = peers(Layer::Oa); + for change in 0..6 { + let mut graph = graph(&f, &[local(f.nodes[0]), local(f.nodes[2])]); + let mut input = Input::default(); + let start = point(&graph, f.nodes[0], false); + press(&graph, &mut input, start); + match change { + 0 => graph.camera_pan.x += 1., + 1 => graph.scale = 2., + 2 => graph.interaction.as_mut().unwrap().2 = false, + 3 => graph.interaction.as_mut().unwrap().0.epoch += 1, + 4 => { + let iced::Event::Keyboard(key) = crate::behavior::canvas_workspace::tests::key( + iced::keyboard::key::Named::Escape, + ) else { + panic!("keyboard"); + }; + send(&graph, &mut input, canvas::Event::Keyboard(key), start); + } + _ => { + graph.input( + &mut input, + &canvas::Event::Mouse(mouse::Event::CursorMoved { position: start }), + Rectangle::new(Point::new(1., 0.), bounds().size()), + mouse::Cursor::Available(start), + ); + } + } + assert!(release(&graph, &mut input, point(&graph, f.nodes[1], true)).is_none()); + assert!(input.group.is_none()); + } +} + +#[test] +fn qualified_occurrences_and_visible_preview_links_keep_exact_invocation_paths() { + let mut f = peers(Layer::Oa); + let source = + f.p.make_element(Kind::Process, Layer::Oa, "Reusable", None) + .unwrap(); + let source_id = source.id; + f.p = execute_batch(&f.p, &[Command::CreateElement(source)]).unwrap(); + let mut leaves = vec![]; + for name in ["One", "Two"] { + let leaf = occurrence( + &f.p, + source_id, + name, + OccurrenceBody::Action { + action: Some(f.work[0]), + lifeline: None, + }, + ); + leaves.push(leaf.id); + f.p = execute_batch(&f.p, &[Command::CreateElement(leaf)]).unwrap(); + } + let link = link(&f.p, leaves[0], leaves[1], ""); + f.p = execute_batch(&f.p, &[link]).unwrap(); + let mut references = vec![]; + for name in ["First", "Second"] { + let reference = occurrence( + &f.p, + f.owner, + name, + OccurrenceBody::SequenceReference { + sequence: Some(source_id), + }, + ); + references.push(reference.id); + f.p = execute_batch(&f.p, &[Command::CreateElement(reference)]).unwrap(); + } + let graph = graph(&f, &[]); + let mut targets = vec![]; + for reference in references { + let node = graph.nodes.iter().find(|n| n.id == reference).unwrap(); + let endpoint = OccurrenceEndpoint { + references: vec![reference], + occurrence: leaves[0], + }; + let tile = node.endpoint_rect(&endpoint).center(); + assert_eq!( + graph.exact_target_at(tile), + Some(Target::ProcessOccurrence(endpoint.clone())) + ); + targets.push(Target::ProcessOccurrence(endpoint)); + let link = &node.preview_links[0]; + let [a, b] = node.preview_link_visible_segment(link).unwrap(); + let at = Point::new((a.x + b.x) / 2., (a.y + b.y) / 2.); + let expected = Target::ProcessPreviewLink { + relation: link.relation, + source: link.source.clone(), + target: link.target.clone(), + }; + assert_eq!(graph.exact_target_at(at), Some(expected.clone())); + let mut input = Input::default(); + send( + &graph, + &mut input, + canvas::Event::Keyboard(iced::keyboard::Event::ModifiersChanged( + iced::keyboard::Modifiers::SHIFT, + )), + at, + ); + assert!( + matches!(press(&graph, &mut input, at), Some(Message::DiagramSelection(SelectionEvent::Choose(_, target, Choice::Toggle))) if target == expected) + ); + assert!( + matches!(send(&graph, &mut input, canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right)), at), Some(Message::Menu(crate::model_menu::Event::Open(request))) if request.selection_target.as_deref() == Some(&expected) && request.process.as_deref().copied() == graph.interaction.map(|(context,_,_)| context)) + ); + } + assert_ne!(targets[0], targets[1]); + assert!(Prepared::new(&f.p, f.owner, &targets).is_err()); +} + +// The endpoint ring is drawn last, over the underlying contribution card. +// Probe two pixels inside that card: both semantic shapes occupy the point, +// so these tests establish the visible handle's selection precedence. +fn handle_point(graph: &Graph<'_>, link: Id, end: End) -> Point { + let edge = graph.edges.iter().find(|edge| edge.id == link).unwrap(); + match end { + End::Source => edge.points[0] + iced::Vector::new(-2., 0.), + End::Target => *edge.points.last().unwrap() + iced::Vector::new(2., 0.), + } +} + +#[test] +fn singleton_process_link_snapshot_reconnects_both_ends_but_stationary_release_is_inert() { + for layer in [Layer::Oa, Layer::Sa] { + let f = fixture(layer); + for end in [End::Source, End::Target] { + let mut graph = graph(&f, &[Target::ProcessLink(f.link)]); + graph.selected = f.link; + let original = graph.selection.clone().unwrap(); + let start = handle_point(&graph, f.link, end); + let mut input = Input::default(); + assert!(press(&graph, &mut input, start).is_none()); + assert!(input.group.is_none()); + assert!( + matches!(input.press, Some(Press::Reconnect { link, end: armed, moved: false, .. }) if link == f.link && armed == end) + ); + assert!(release(&graph, &mut input, start).is_none()); + assert!(input.press.is_none()); + assert_eq!(graph.selection.as_ref(), Some(&original)); + + assert!(press(&graph, &mut input, start).is_none()); + let target = point(&graph, f.nodes[2], false); + assert!(matches!(release(&graph, &mut input, target), + Some(Message::TopologyCanvas(CanvasEvent::Reconnect { context, link, end: actual, target })) + if context == graph.interaction.unwrap().0 && link == f.link && actual == end && target == f.nodes[2])); + assert!(input.group.is_none()); + assert_eq!(graph.selection.as_ref(), Some(&original)); + } + } +} + +#[test] +fn shift_on_visible_singleton_process_link_handle_toggles_that_link_not_underlying_card() { + for layer in [Layer::Oa, Layer::Sa] { + let f = fixture(layer); + for (end, underneath) in [(End::Source, f.nodes[0]), (End::Target, f.nodes[1])] { + let mut graph = graph(&f, &[Target::ProcessLink(f.link)]); + graph.selected = f.link; + let start = handle_point(&graph, f.link, end); + assert_eq!(graph.node_at(start), Some(underneath)); + let mut input = Input::default(); + send( + &graph, + &mut input, + canvas::Event::Keyboard(iced::keyboard::Event::ModifiersChanged( + iced::keyboard::Modifiers::SHIFT, + )), + start, + ); + assert!(matches!(press(&graph, &mut input, start), + Some(Message::DiagramSelection(SelectionEvent::Choose(_, Target::ProcessLink(id), Choice::Toggle))) + if id == f.link)); + assert!(input.group.is_none() && input.press.is_none()); + } + } +} + +#[test] +fn mixed_group_at_endpoint_card_refuses_whole_drag_without_hidden_singleton_handle() { + for layer in [Layer::Oa, Layer::Sa] { + let f = fixture(layer); + for (end, underneath) in [(End::Source, f.nodes[0]), (End::Target, f.nodes[1])] { + let mut graph = graph(&f, &[local(underneath), Target::ProcessLink(f.link)]); + graph.selected = f.link; + let snapshot = graph.selection.clone().unwrap(); + let start = handle_point(&graph, f.link, end); + // Multiple selections render no reconnect rings. The visible + // selected card owns this point, while the mixed set stays whole. + assert_eq!(graph.exact_target_at(start), Some(local(underneath))); + let mut input = Input::default(); + assert!(press(&graph, &mut input, start).is_none()); + assert!(input.group.is_some() && input.press.is_none()); + assert!( + matches!(release(&graph, &mut input, point(&graph, f.nodes[2], false)), + Some(Message::DiagramSelection(SelectionEvent::Refuse(captured, _))) if captured == snapshot) + ); + assert_eq!(graph.selection.as_ref(), Some(&snapshot)); + } + } +} diff --git a/crates/oa-desktop/src/behavior/topology/direct_tests.rs b/crates/oa-desktop/src/behavior/topology/direct_tests.rs index 7a2c2d65..8148f7b3 100644 --- a/crates/oa-desktop/src/behavior/topology/direct_tests.rs +++ b/crates/oa-desktop/src/behavior/topology/direct_tests.rs @@ -360,11 +360,12 @@ fn direct_process_reconnect_delete_and_reorder_preserve_other_paths_and_sources( assert_eq!(doc.bundle(), &reconnected); doc.undo(); assert_eq!(doc.bundle(), &original); - let reordered = execute_batch( - &f.p, - &[direct::reorder(&f.p, f.owner, f.nodes[2], f.nodes[0]).unwrap()], - ) - .unwrap(); + let group_reorder::Plan::Change(command) = + direct::reorder(&f.p, f.owner, f.nodes[2], f.nodes[0]).unwrap() + else { + panic!("changed order"); + }; + let reordered = execute_batch(&f.p, &[*command]).unwrap(); assert_eq!(reordered.elements[&f.owner].steps().unwrap()[0], f.nodes[2]); assert_eq!(reordered.relations, f.p.relations); assert!(direct::reorder(&f.p, f.owner, f.nodes[0], f.nodes[1]).is_err()); diff --git a/crates/oa-desktop/src/behavior/topology/group_reorder.rs b/crates/oa-desktop/src/behavior/topology/group_reorder.rs new file mode 100644 index 00000000..74e2a585 --- /dev/null +++ b/crates/oa-desktop/src/behavior/topology/group_reorder.rs @@ -0,0 +1,176 @@ +//! Immutable, bounded order proposals for local Process/FunctionalChain cards. +use super::*; +use crate::diagram_usage::Target; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Destination { + Before(Id), + After(Id), +} + +#[derive(Debug)] +pub(crate) enum Plan { + Noop, + Change(Box), +} + +/// Captured semantic facts only: no project clone, projection or routed scene. +/// The adapter must check its document/revision/selection lifetime before commit +/// and apply the returned command through the ordinary validated transaction. +#[derive(Debug)] +pub(crate) struct Prepared { + owner: Id, + steps: Vec, + adjacency: Vec<(Id, Id)>, + legacy: bool, + ranks: BTreeMap, + selected: BTreeSet, + column: usize, +} + +impl Prepared { + pub(crate) fn new(project: &Project, owner: Id, targets: &[Target]) -> Result { + let element = project + .elements + .get(&owner) + .filter(|e| matches!(e.kind(), Kind::Process | Kind::FunctionalChain)) + .ok_or("Choose a Process or Functional Chain.")?; + let steps = element + .steps() + .ok_or("This owner has no contribution order.")?; + let mut ids = BTreeSet::new(); + for id in steps { + if !is_contribution(project, owner, *id) || !ids.insert(*id) { + return Err( + "Every process step must name a unique local contribution before reordering." + .into(), + ); + } + } + let mut adjacency: Vec<_> = sequence_links(project, owner) + .into_iter() + .map(|link| (link.source(), link.target())) + .collect(); + // Unlisted local occurrences are legal incomplete source. Rendering can + // omit their links, but order editing must not silently ignore them. + if adjacency + .iter() + .any(|(a, b)| !ids.contains(a) || !ids.contains(b)) + { + return Err("A sequence link refers to an unlisted contribution; repair the process order before reordering.".into()); + } + let legacy = !legacy_order(project, owner).is_empty(); + if legacy { + adjacency.extend(steps.windows(2).map(|pair| (pair[0], pair[1]))); + } + if targets.is_empty() { + return Err("Select at least one local contribution.".into()); + } + let mut selected = BTreeSet::new(); + for target in targets { + let Target::ProcessOccurrence(endpoint) = target else { + return Err( + "Reorder requires local contribution cards only; links cannot move with them." + .into(), + ); + }; + if !endpoint.references.is_empty() { + return Err("Open the referenced definition to reorder its contents.".into()); + } + if !ids.contains(&endpoint.occurrence) || !selected.insert(endpoint.occurrence) { + return Err( + "Every selected contribution must occur exactly once in this process.".into(), + ); + } + } + let ranks = column_ranks(steps, &adjacency); + let first = *selected.first().expect("Nonempty selection admitted"); + let column = ranks[&first]; + if selected.iter().any(|id| ranks[id] != column) { + return Err("Reorder within one column; links determine the path columns.".into()); + } + Ok(Self { + owner, + steps: steps.clone(), + adjacency, + legacy, + ranks, + selected, + column, + }) + } + + pub(crate) fn plan(&self, destination: Destination) -> Result { + let (anchor, after) = match destination { + Destination::Before(id) => (id, false), + Destination::After(id) => (id, true), + }; + if self.ranks.get(&anchor) != Some(&self.column) { + return Err( + "Choose a local contribution in the selected column as the destination.".into(), + ); + } + // Complete step, adjacency and typed selection admission already ran. + if self.selected.contains(&anchor) { + return Ok(Plan::Noop); + } + let indices: Vec<_> = self + .steps + .iter() + .enumerate() + .filter_map(|(i, id)| (self.ranks[id] == self.column).then_some(i)) + .collect(); + let block: Vec<_> = indices + .iter() + .map(|&i| self.steps[i]) + .filter(|id| self.selected.contains(id)) + .collect(); + let mut remaining: Vec<_> = indices + .iter() + .map(|&i| self.steps[i]) + .filter(|id| !self.selected.contains(id)) + .collect(); + let gap = remaining + .iter() + .position(|id| *id == anchor) + .ok_or("The destination slot no longer exists.")? + + usize::from(after); + remaining.splice(gap..gap, block); + let mut ordered = self.steps.clone(); + for (index, id) in indices.into_iter().zip(remaining) { + ordered[index] = id; + } + if ordered == self.steps { + return Ok(Plan::Noop); + } + // Legacy adjacency comes from order itself. In valid legacy layouts each + // card has its own column; retain the check instead of assuming that. + let ranks = if self.legacy { + let adjacency: Vec<_> = ordered.windows(2).map(|pair| (pair[0], pair[1])).collect(); + column_ranks(&ordered, &adjacency) + } else { + column_ranks(&ordered, &self.adjacency) + }; + if ranks != self.ranks { + return Err( + "This reorder would change process columns; the whole move was refused.".into(), + ); + } + Ok(Plan::Change(Box::new(Command::SetSteps( + self.owner, ordered, + )))) + } +} + +#[cfg(test)] +fn plan( + project: &Project, + owner: Id, + targets: &[Target], + destination: Destination, +) -> Result { + Prepared::new(project, owner, targets)?.plan(destination) +} + +#[cfg(test)] +mod tests; diff --git a/crates/oa-desktop/src/behavior/topology/group_reorder/tests.rs b/crates/oa-desktop/src/behavior/topology/group_reorder/tests.rs new file mode 100644 index 00000000..b46417ed --- /dev/null +++ b/crates/oa-desktop/src/behavior/topology/group_reorder/tests.rs @@ -0,0 +1,407 @@ +use super::*; +use crate::document::Document; +use oa_core::source_project::ProjectLimits; + +fn local(id: Id) -> Target { + Target::ProcessOccurrence(OccurrenceEndpoint { + references: vec![], + occurrence: id, + }) +} + +fn columns(project: &Project, owner: Id) -> BTreeMap { + let first = project.elements[&owner].steps().unwrap()[0]; + Prepared::new(project, owner, &[local(first)]) + .unwrap() + .ranks +} + +// Literal order [a,u,b,v,c]; a,b,c are peers. u,v are either downstream +// contributions or a separate cycle, so their slots must never change. +fn fixture(layer: Layer, cycle: bool) -> (Project, Id, [Id; 5]) { + let (f, _) = super::super::direct_tests::empty_exchange_fixture(layer); + let mut project = f.p; + let mut ids = vec![]; + for name in ["a", "u", "b", "v", "c"] { + let element = occurrence( + &project, + f.owner, + name, + OccurrenceBody::Action { + action: Some(f.work[0]), + lifeline: None, + }, + ); + ids.push(element.id); + project = execute_batch(&project, &[Command::CreateElement(element)]).unwrap(); + } + let ids: [Id; 5] = ids.try_into().unwrap(); + let [a, u, b, v, _] = ids; + let pairs = if cycle { + [(u, v), (v, u)] + } else { + [(a, u), (b, v)] + }; + // fresh_alias must see the first link before generating the second; two + // commands built from the same snapshot both choose "sequence_link". + for (source, target) in pairs { + let command = link(&project, source, target, ""); + project = execute_batch(&project, &[command]).unwrap(); + } + assert_eq!(project.elements[&f.owner].steps().unwrap(), &ids); + (project, f.owner, ids) +} + +fn changed(project: &Project, owner: Id, targets: &[Target], drop: Destination) -> Command { + let Plan::Change(command) = plan(project, owner, targets, drop).unwrap() else { + panic!("Expected an actual order change"); + }; + assert!(matches!(command.as_ref(), Command::SetSteps(id, _) if *id == owner)); + *command +} + +#[test] +fn process_group_columns_reverse_noncontiguous_selection_preserves_project_and_history() { + for layer in [Layer::Oa, Layer::Sa] { + for cycle in [false, true] { + let (project, owner, [a, u, b, v, c]) = fixture(layer, cycle); + assert_eq!( + columns(&project, owner), + BTreeMap::from([ + (a, 0), + (u, 1), + (b, 0), + (v, if cycle { 2 } else { 1 }), + (c, 0) + ]) + ); + // Reverse click order must not reverse the source-ordered a,c block. + let command = changed( + &project, + owner, + &[local(c), local(a)], + Destination::After(b), + ); + let mut expected = project.clone(); + *expected + .elements + .get_mut(&owner) + .unwrap() + .steps_mut() + .unwrap() = vec![b, u, a, v, c]; + let result = execute_batch(&project, std::slice::from_ref(&command)).unwrap(); + assert_eq!(result, expected, "Nothing outside owner steps may change"); + assert_eq!(columns(&result, owner), columns(&project, owner)); + let mut doc = + Document::from_project(project.clone(), ProjectLimits::default()).unwrap(); + let original = doc.bundle().clone(); + doc.execute(command).unwrap(); + assert_eq!(doc.project(), &expected); + let after = doc.bundle().clone(); + let reopened = Document::from_bundle(after.clone(), ProjectLimits::default()).unwrap(); + assert_eq!(reopened.project(), &expected); + assert_eq!(reopened.bundle(), &after); + assert!(doc.undo()); + assert_eq!(doc.project(), &project); + assert_eq!(doc.bundle(), &original); + assert!(!doc.can_undo()); + assert!(doc.redo()); + assert_eq!(doc.project(), &expected); + assert_eq!(doc.bundle(), &after); + assert!(!doc.can_redo()); + } + } +} + +#[test] +fn process_group_columns_end_gaps_and_selected_anchor_noops() { + for layer in [Layer::Oa, Layer::Sa] { + let (project, owner, [a, u, b, v, c]) = fixture(layer, false); + for (members, drop, expected) in [ + ( + vec![local(c), local(b)], + Destination::Before(a), + vec![b, u, c, v, a], + ), + ( + vec![local(b), local(a)], + Destination::After(c), + vec![c, u, a, v, b], + ), + ] { + let command = changed(&project, owner, &members, drop); + assert_eq!( + execute_batch(&project, &[command]).unwrap().elements[&owner] + .steps() + .unwrap(), + &expected + ); + } + let doc = Document::from_project(project.clone(), ProjectLimits::default()).unwrap(); + let original = doc.bundle().clone(); + for (members, drop) in [ + (vec![local(b), local(c)], Destination::After(a)), + (vec![local(c), local(a)], Destination::Before(c)), + (vec![local(a), local(c)], Destination::After(a)), + (vec![local(c), local(b), local(a)], Destination::After(b)), + ] { + assert!(matches!( + plan(doc.project(), owner, &members, drop), + Ok(Plan::Noop) + )); + assert_eq!(doc.bundle(), &original); + assert!(!doc.can_undo()); + } + } +} + +#[test] +fn process_group_columns_refuse_entire_invalid_typed_sets_before_noop() { + for layer in [Layer::Oa, Layer::Sa] { + let (project, owner, [a, u, _, _, c]) = fixture(layer, true); + let foreign = super::super::direct_tests::fixture(layer).nodes[0]; + let qualified = Target::ProcessOccurrence(OccurrenceEndpoint { + references: vec![a], + occurrence: c, + }); + let relation = *project.relations.keys().next().unwrap(); + for members in [ + vec![], + vec![local(a), local(a)], + vec![local(a), local(u)], + vec![local(a), local(foreign)], + vec![local(a), qualified], + vec![local(a), Target::ProcessLink(relation)], + vec![local(a), Target::Placement(c)], + ] { + // A selected anchor must not conceal invalid second members. + assert!(plan(&project, owner, &members, Destination::Before(a)).is_err()); + } + assert!(plan(&project, owner, &[local(a)], Destination::After(foreign)).is_err()); + assert!(plan(&project, a, &[local(a)], Destination::Before(a)).is_err()); + assert!( + plan( + &project, + owner, + &[ + local(u), + local(project.elements[&owner].steps().unwrap()[3]) + ], + Destination::Before(u) + ) + .is_err() + ); + } +} + +#[test] +fn process_group_columns_legacy_exchange_order_refuses_cross_rank_group() { + for layer in [Layer::Oa, Layer::Sa] { + let (f, next) = super::super::direct_tests::empty_exchange_fixture(layer); + let mut project = f.p; + let mut ids = vec![]; + for (name, exchange) in [("first", f.exchange), ("second", next)] { + let element = occurrence( + &project, + f.owner, + name, + OccurrenceBody::Exchange { + exchange: Some(exchange), + sender: None, + receiver: None, + message_kind: MessageKind::default(), + reply_to: None, + values: vec![], + operation: None, + }, + ); + ids.push(element.id); + project = execute_batch(&project, &[Command::CreateElement(element)]).unwrap(); + } + assert_eq!(legacy_order(&project, f.owner), ids); + assert!( + plan( + &project, + f.owner, + &[local(ids[0]), local(ids[1])], + Destination::Before(ids[0]) + ) + .is_err() + ); + assert!(matches!( + plan( + &project, + f.owner, + &[local(ids[1])], + Destination::After(ids[1]) + ), + Ok(Plan::Noop) + )); + } +} + +#[test] +fn process_group_columns_accept_all_local_card_kinds_and_different_heights() { + for layer in [Layer::Oa, Layer::Sa] { + let (f, _) = super::super::direct_tests::empty_exchange_fixture(layer); + let mut project = f.p; + let source = project + .make_element( + project.elements[&f.owner].kind(), + layer, + "Reusable definition", + None, + ) + .unwrap(); + let source_id = source.id; + project = execute_batch(&project, &[Command::CreateElement(source)]).unwrap(); + let bodies = [ + OccurrenceBody::Action { + action: Some(f.work[0]), + lifeline: None, + }, + OccurrenceBody::Exchange { + exchange: Some(f.exchange), + sender: None, + receiver: None, + message_kind: MessageKind::default(), + reply_to: None, + values: vec![], + operation: None, + }, + OccurrenceBody::Control { + kind: ControlKind::Choice, + }, + OccurrenceBody::SequenceReference { + sequence: Some(source_id), + }, + OccurrenceBody::Action { + action: Some(f.work[0]), + lifeline: None, + }, + ]; + let mut ids = vec![]; + for (index, body) in bodies.into_iter().enumerate() { + let element = occurrence(&project, f.owner, &format!("Card {index}"), body); + ids.push(element.id); + project = execute_batch(&project, &[Command::CreateElement(element)]).unwrap(); + } + let graph = Graph::new(&project, f.owner, ids[0]); + assert_ne!(graph.nodes[0].rect.height, graph.nodes[3].rect.height); + let selected = vec![local(ids[3]), local(ids[2]), local(ids[1]), local(ids[0])]; + let command = changed(&project, f.owner, &selected, Destination::After(ids[4])); + let mut expected = project.clone(); + *expected + .elements + .get_mut(&f.owner) + .unwrap() + .steps_mut() + .unwrap() = vec![ids[4], ids[0], ids[1], ids[2], ids[3]]; + assert_eq!(execute_batch(&project, &[command]).unwrap(), expected); + } +} + +#[test] +fn process_group_columns_full_order_admission_precedes_selected_anchor_noop() { + for layer in [Layer::Oa, Layer::Sa] { + let (mut project, owner, [a, u, b, v, c]) = fixture(layer, false); + let other = project + .make_element(project.elements[&owner].kind(), layer, "Other", None) + .unwrap(); + let other_owner = other.id; + project = execute_batch(&project, &[Command::CreateElement(other)]).unwrap(); + let foreign = occurrence( + &project, + other_owner, + "Foreign local occurrence", + OccurrenceBody::Control { + kind: ControlKind::Choice, + }, + ); + let foreign_id = foreign.id; + project = execute_batch(&project, &[Command::CreateElement(foreign)]).unwrap(); + assert!(Prepared::new(&project, owner, &[local(a), local(foreign_id)]).is_err()); + let prepared = Prepared::new(&project, owner, &[local(a)]).unwrap(); + assert!(prepared.plan(Destination::Before(foreign_id)).is_err()); + + // Corrupt/incomplete imported orders must fail even when only the valid + // card is selected and the requested destination would be a no-op. + for order in [ + vec![a, u, b, v, c, c], + vec![a, u, b, v, Id::new_v4()], + vec![a, u, b, v, foreign_id], + vec![a, b, v, c], // local a -> u remains, but u is unlisted + ] { + let mut incomplete = project.clone(); + *incomplete + .elements + .get_mut(&owner) + .unwrap() + .steps_mut() + .unwrap() = order; + let original = incomplete.clone(); + assert!(plan(&incomplete, owner, &[local(a)], Destination::Before(a)).is_err()); + assert_eq!(incomplete, original); + } + } +} + +#[test] +fn process_group_columns_renderer_omits_links_to_unlisted_nodes_without_panicking() { + for layer in [Layer::Oa, Layer::Sa] { + let (project, owner, [a, u, b, v, c]) = fixture(layer, false); + for (omitted, expected_ids, expected_ranks) in [ + ( + u, + vec![a, b, v, c], + BTreeMap::from([(a, 0), (b, 0), (v, 1), (c, 0)]), + ), + ( + a, + vec![u, b, v, c], + BTreeMap::from([(u, 0), (b, 0), (v, 1), (c, 0)]), + ), + ] { + // This omission is core-valid incomplete source, not a renderer-only + // manufactured dangling element. The semantic occurrence remains. + let incomplete = + execute_batch(&project, &[Command::SetSteps(owner, expected_ids.clone())]).unwrap(); + assert!(incomplete.elements.contains_key(&omitted)); + let graph = Graph::new(&incomplete, owner, b); + assert_eq!( + graph.nodes.iter().map(|node| node.id).collect::>(), + expected_ids + ); + assert_eq!(graph.edges.len(), 1); + assert_eq!((graph.edges[0].source, graph.edges[0].target), (b, v)); + for node in &graph.nodes { + assert_eq!(node.rect.x, 24. + expected_ranks[&node.id] as f32 * 420.); + } + assert!(Prepared::new(&incomplete, owner, &[local(b)]).is_err()); + } + } +} + +#[test] +fn process_group_columns_parallel_links_keep_exact_ranks_and_relation_records() { + for layer in [Layer::Oa, Layer::Sa] { + let (mut project, owner, [a, u, b, v, c]) = fixture(layer, false); + project = execute_batch(&project, &[link(&project, a, u, "alternative")]).unwrap(); + assert_eq!( + columns(&project, owner), + BTreeMap::from([(a, 0), (u, 1), (b, 0), (v, 1), (c, 0)]) + ); + let prepared = Prepared::new(&project, owner, &[local(c), local(b)]).unwrap(); + let Plan::Change(command) = prepared.plan(Destination::Before(a)).unwrap() else { + panic!("Expected move"); + }; + let result = execute_batch(&project, &[*command]).unwrap(); + assert_eq!( + result.elements[&owner].steps().unwrap(), + &vec![b, u, c, v, a] + ); + assert_eq!(result.relations, project.relations); + assert_eq!(columns(&result, owner), prepared.ranks); + assert_eq!(Graph::new(&result, owner, b).edges.len(), 3); + } +} diff --git a/crates/oa-desktop/src/behavior/topology/layout_tests.rs b/crates/oa-desktop/src/behavior/topology/layout_tests.rs index eba7f2a1..704511d5 100644 --- a/crates/oa-desktop/src/behavior/topology/layout_tests.rs +++ b/crates/oa-desktop/src/behavior/topology/layout_tests.rs @@ -1,5 +1,320 @@ use super::*; +#[tokio::test] +async fn production_process_multiselect_modifiers_survive_focus_busy_and_clear_on_window_loss() { + use crate::behavior::canvas_workspace::tests::Mounted; + use iced::keyboard::{Event as KeyEvent, Modifiers}; + let mut f = super::super::direct_tests::fixture(Layer::Oa); + f.p = execute_batch(&f.p, &[Command::Delete(f.link)]).unwrap(); + let mut app = App::with_project(f.p); + let _ = app.update(Message::Select(f.owner)); + let _ = app.update(Message::Page(crate::app::Page::Behavior)); + app.window_size = Size::new(1440., 900.); + let mut mounted = Mounted::new(&mut app).await; + let bounds = mounted.geometry(&app).bounds("process-viewport"); + let at = |app: &App, id| { + let graph = Graph::new(app.project(), f.owner, id); + let rect = graph.nodes.iter().find(|n| n.id == id).unwrap().rect; + bounds.position() + + app.behavior.topology.pan + + iced::Vector::new(rect.x + 60., rect.y + 20.) + * app + .behavior + .topology + .scale(graph.size, (app.window_size.width - 600.).max(200.)) + }; + let shift = |held| { + iced::Event::Keyboard(KeyEvent::ModifiersChanged(if held { + Modifiers::SHIFT + } else { + Modifiers::empty() + })) + }; + let first = at(&app, f.nodes[2]); + mounted + .batch(&mut app, &[shift(true)], mouse::Cursor::Available(first)) + .await; + mounted.click(&mut app, first).await; + let second = at(&app, f.nodes[0]); + mounted.click(&mut app, second).await; + assert_eq!( + app.multiselect_snapshot().unwrap().targets().len(), + 2, + "Shift before canvas focus reaches both clicks" + ); + assert_eq!(mounted.geometry(&app).bounds("process-viewport"), bounds); + app.busy = true; + mounted.settle(&mut app).await; + mounted + .batch(&mut app, &[shift(false)], mouse::Cursor::Available(second)) + .await; + app.busy = false; + mounted.settle(&mut app).await; + let third = at(&app, f.nodes[1]); + mounted.click(&mut app, third).await; + assert_eq!( + app.multiselect_snapshot().unwrap().targets().len(), + 1, + "Shift released during busy is not resurrected" + ); + app.busy = true; + mounted + .batch(&mut app, &[shift(true)], mouse::Cursor::Available(second)) + .await; + app.busy = false; + mounted.settle(&mut app).await; + mounted.click(&mut app, second).await; + assert_eq!( + app.multiselect_snapshot().unwrap().targets().len(), + 2, + "Shift held through busy survives child lifetime" + ); + mounted + .batch( + &mut app, + &[iced::Event::Window(iced::window::Event::Unfocused)], + mouse::Cursor::Unavailable, + ) + .await; + mounted.click(&mut app, first).await; + assert_eq!( + app.multiselect_snapshot().unwrap().targets().len(), + 1, + "real window loss clears held modifiers" + ); + assert_eq!(mounted.geometry(&app).bounds("process-viewport"), bounds); + assert!(!app.document.can_undo()); + let pan = app.behavior.topology.pan; + let source = app.document.bundle().clone(); + let blank = bounds.position() + iced::Vector::new(bounds.width - 40., bounds.height - 40.); + let end = blank + iced::Vector::new(-25., -20.); + mounted + .batch( + &mut app, + &[ + iced::Event::Mouse(mouse::Event::CursorMoved { position: blank }), + iced::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)), + ], + mouse::Cursor::Available(blank), + ) + .await; + assert!(app.multiselect_snapshot().unwrap().targets().is_empty()); + assert_eq!(app.behavior.topology.pan, pan); + assert_eq!(app.document.bundle(), &source); + // Each batch rebuilds the production tree and applies all App messages. + // Selection Clear must not retire the camera pan started by its press. + assert_eq!(mounted.geometry(&app).bounds("process-viewport"), bounds); + mounted + .batch( + &mut app, + &[iced::Event::Mouse(mouse::Event::CursorMoved { + position: end, + })], + mouse::Cursor::Available(end), + ) + .await; + assert_eq!( + app.behavior.topology.pan, + pan + iced::Vector::new(-25., -20.) + ); + assert_eq!(app.document.bundle(), &source); + assert_eq!(mounted.geometry(&app).bounds("process-viewport"), bounds); + mounted + .batch( + &mut app, + &[iced::Event::Mouse(mouse::Event::ButtonReleased( + mouse::Button::Left, + ))], + mouse::Cursor::Available(end), + ) + .await; + assert!(app.multiselect_snapshot().unwrap().targets().is_empty()); + assert_eq!( + app.behavior.topology.pan, + pan + iced::Vector::new(-25., -20.) + ); + assert_eq!(app.document.bundle(), &source); + assert!(!app.document.can_undo()); +} + +#[tokio::test] +async fn production_process_multiselect_reverse_click_group_drop_has_one_history_step_and_stable_camera() + { + use crate::behavior::canvas_workspace::tests::Mounted; + for layer in [Layer::Oa, Layer::Sa] { + let mut f = super::super::direct_tests::fixture(layer); + f.p = execute_batch(&f.p, &[Command::Delete(f.link)]).unwrap(); + let mut app = App::with_project(f.p); + let _ = app.update(Message::Select(f.owner)); + let _ = app.update(Message::Page(crate::app::Page::Behavior)); + app.window_size = Size::new(1440., 900.); + let mut mounted = Mounted::new(&mut app).await; + let bounds = mounted.geometry(&app).bounds("process-viewport"); + let graph = Graph::new(app.project(), f.owner, f.owner); + let scale = app + .behavior + .topology + .scale(graph.size, (app.window_size.width - 600.).max(200.)); + let at = |id, after| { + let rect = graph.nodes.iter().find(|n| n.id == id).unwrap().rect; + bounds.position() + + iced::Vector::new( + rect.x + 60., + rect.y + if after { rect.height - 15. } else { 20. }, + ) * scale + }; + let points = [ + at(f.nodes[2], false), + at(f.nodes[0], false), + at(f.nodes[1], true), + ]; + drop(graph); + mounted + .batch( + &mut app, + &[iced::Event::Keyboard( + iced::keyboard::Event::ModifiersChanged(iced::keyboard::Modifiers::SHIFT), + )], + mouse::Cursor::Available(points[0]), + ) + .await; + mounted.click(&mut app, points[0]).await; + mounted.click(&mut app, points[1]).await; + mounted + .batch( + &mut app, + &[iced::Event::Keyboard( + iced::keyboard::Event::ModifiersChanged(iced::keyboard::Modifiers::empty()), + )], + mouse::Cursor::Available(points[1]), + ) + .await; + let snapshot = app.multiselect_snapshot().unwrap(); + let source = app.document.bundle().clone(); + let camera = (app.behavior.topology.pan, app.behavior.topology.zoom); + // Reuse the existing opt-in raw WGPU evidence convention. These are + // actual production-tree pixels, not a reconstructed preview image. + let record_feedback = |stage: &str, mounted: &mut Mounted, app: &App, cursor: Point| { + let Some(path) = std::env::var_os("ARCADIA_RENDER_EVIDENCE_DIR") else { + return; + }; + let path = std::path::PathBuf::from(path); + std::fs::create_dir_all(&path).unwrap(); + let name = format!("process-{layer:?}-{stage}"); + let pixels = mounted.pixels_at(app, mouse::Cursor::Available(cursor)); + let width = app.window_size.width as u32; + let height = app.window_size.height as u32; + assert_eq!(pixels.len(), width as usize * height as usize * 4); + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(path.join(format!("{name}.rgba"))) + .unwrap(); + std::io::Write::write_all(&mut file, &pixels).unwrap(); + let metadata = serde_json::json!({ + "width": width, "height": height, "format": "RGBA8", + "stage": stage, "layer": format!("{layer:?}"), + "canvas": [bounds.x, bounds.y, bounds.width, bounds.height], + "cursor": [cursor.x, cursor.y], "scale": scale, + "camera": [app.behavior.topology.pan.x, app.behavior.topology.pan.y], + "selectionCount": snapshot.targets().len(), + "selection": format!("{snapshot:?}"), + "source": app.document.bundle(), + }); + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(path.join(format!("{name}.json"))) + .unwrap(); + std::io::Write::write_all(&mut file, &serde_json::to_vec_pretty(&metadata).unwrap()) + .unwrap(); + assert_eq!(app.document.bundle(), &source); + assert_eq!(app.multiselect_snapshot().unwrap(), snapshot); + assert!(!app.document.can_undo()); + }; + record_feedback("before-press", &mut mounted, &app, points[1]); + let pressed = mounted + .batch( + &mut app, + &[ + iced::Event::Mouse(mouse::Event::CursorMoved { + position: points[1], + }), + iced::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)), + ], + mouse::Cursor::Available(points[1]), + ) + .await; + assert!( + !pressed + .iter() + .any(|message| matches!(message, Message::DiagramSelection(_))) + ); + assert_eq!(app.multiselect_snapshot().unwrap(), snapshot); + assert_eq!(app.document.bundle(), &source); + assert!(!app.document.can_undo()); + assert_eq!(mounted.geometry(&app).bounds("process-viewport"), bounds); + let moved = mounted + .batch( + &mut app, + &[iced::Event::Mouse(mouse::Event::CursorMoved { + position: points[2], + })], + mouse::Cursor::Available(points[2]), + ) + .await; + assert!( + !moved + .iter() + .any(|message| matches!(message, Message::DiagramSelection(_))) + ); + assert_eq!(app.multiselect_snapshot().unwrap(), snapshot); + assert_eq!(app.document.bundle(), &source); + assert!(!app.document.can_undo()); + assert_eq!(mounted.geometry(&app).bounds("process-viewport"), bounds); + assert_eq!( + (app.behavior.topology.pan, app.behavior.topology.zoom), + camera + ); + record_feedback("after-motion", &mut mounted, &app, points[2]); + let messages = mounted + .batch( + &mut app, + &[iced::Event::Mouse(mouse::Event::ButtonReleased( + mouse::Button::Left, + ))], + mouse::Cursor::Available(points[2]), + ) + .await; + assert!( + messages.iter().any(|m| matches!( + m, + Message::DiagramSelection(crate::app::multiselect::Event::ProcessReorder(_, _)) + )), + "{messages:?}" + ); + assert_eq!( + app.project().elements[&f.owner].steps().unwrap(), + &vec![f.nodes[1], f.nodes[0], f.nodes[2]] + ); + assert_eq!( + app.multiselect_snapshot().unwrap().targets(), + snapshot.targets() + ); + assert_eq!(mounted.geometry(&app).bounds("process-viewport"), bounds); + assert_eq!( + (app.behavior.topology.pan, app.behavior.topology.zoom), + camera + ); + let changed = app.document.bundle().clone(); + let _ = app.update(Message::Undo); + assert_eq!(app.document.bundle(), &source); + assert!(!app.document.can_undo()); + let _ = app.update(Message::Redo); + assert_eq!(app.document.bundle(), &changed); + } +} + #[tokio::test] async fn production_process_canvas_scaled_path_creation_preserves_source_and_editor_bounds() { use crate::behavior::canvas_workspace::{self, tests::Mounted}; diff --git a/crates/oa-desktop/src/behavior/topology/usages.rs b/crates/oa-desktop/src/behavior/topology/usages.rs index d4bef565..d5e964bd 100644 --- a/crates/oa-desktop/src/behavior/topology/usages.rs +++ b/crates/oa-desktop/src/behavior/topology/usages.rs @@ -23,6 +23,38 @@ impl UsageSnapshot { } impl State { + pub(crate) fn selection_address(&self) -> Option { + self.selected_context.clone() + } + + pub(crate) fn selection_targets(&self, p: &Project, owner: Id) -> Vec { + let graph = self.usage_graph(p, owner); + let mut targets = vec![]; + for node in &graph.nodes { + targets.push(Target::ProcessOccurrence(OccurrenceEndpoint { + references: vec![], + occurrence: node.id, + })); + targets.extend( + node.preview + .iter() + .map(|(endpoint, _)| Target::ProcessOccurrence(endpoint.clone())), + ); + for link in &node.preview_links { + let target = Target::ProcessPreviewLink { + relation: link.relation, + source: link.source.clone(), + target: link.target.clone(), + }; + if graph.usage_bounds(&target).is_some() { + targets.push(target); + } + } + } + targets.extend(graph.edges.iter().map(|edge| Target::ProcessLink(edge.id))); + targets + } + pub(crate) fn usage_snapshot(&self) -> UsageSnapshot { UsageSnapshot { identity: self.identity, diff --git a/crates/oa-desktop/src/behavior/topology/view.rs b/crates/oa-desktop/src/behavior/topology/view.rs index 19f10264..5f05635a 100644 --- a/crates/oa-desktop/src/behavior/topology/view.rs +++ b/crates/oa-desktop/src/behavior/topology/view.rs @@ -66,7 +66,8 @@ pub(super) fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Ele let pending = state.draft.is_some(); let ordered = !legacy_order(p, owner).is_empty(); let selected = is_contribution(p, owner, app.selected).then_some(app.selected); - let clean = !app.behavior.has_pending() && !app.draft_dirty() && !app.source_dirty(); + let clean = + !app.busy && !app.behavior.has_pending() && !app.draft_dirty() && !app.source_dirty(); // A fixed sibling keeps Iced's canvas scroll state through selection/form changes. let mut navigation = column![].spacing(6); if let Some(Relation { @@ -183,6 +184,7 @@ pub(super) fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Ele &state.expanded, &state.contextual_expanded, ); + graph.selection = app.multiselect_snapshot(); graph.selected_preview = state .qualified_selection(p, owner) .filter(|address| { @@ -195,6 +197,11 @@ pub(super) fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Ele == app.selected }) .map(|address| address.endpoint.clone()); + if let Some(crate::diagram_usage::Target::ProcessOccurrence(endpoint)) = + graph.selection.as_ref().and_then(|s| s.primary()) + { + graph.selected_preview = (!endpoint.references.is_empty()).then(|| endpoint.clone()); + } graph.selected_preview_link = app .diagram_focus(crate::diagram_usage::Surface::Process(owner)) .and_then(|usage| match &usage.target { @@ -256,7 +263,8 @@ pub(super) fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Ele event(Event::Shelf(Shelf::Selection)), move |zoom| Message::TopologyCanvas(CanvasEvent::Zoom(context, zoom)), camera - ) + ), + selection_controls(app) ] .spacing(4); let entries = [ @@ -322,7 +330,7 @@ pub(super) fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Ele ) }), ); - let details = scrollable(navigation.push(text("Layout is derived. The reorder handle changes display order within its column; it does not author a position.").size(11))) + let details = scrollable(navigation.push(text("Drag selected local cards to the upper or lower half of a card to insert before or after it in the same column. The group keeps source order; paths stay unchanged. Qualified previews remain read-only.").size(11))) .id("process-details").width(Fill).height(Fill).into(); let mut form_panel: Element<'_, Message> = iced::widget::Space::new().height(0).into(); if let Some(d) = state.draft.as_ref().filter(|d| d.owner == owner) { @@ -552,6 +560,42 @@ pub(super) fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Ele super::super::canvas_workspace::host(header.into(), palette, canvas, editor) } +fn selection_controls(app: &App) -> Element<'_, Message> { + use crate::app::multiselect::Event as SelectionEvent; + let snapshot = app.multiselect_snapshot(); + let enabled = !app.busy + && !app.behavior.has_pending() + && !app.draft_dirty() + && !app.source_dirty() + && app.behavior.topology.tool == CanvasTool::Select; + let count = snapshot.as_ref().map_or(0, |s| s.targets().len()); + let mut controls = row![label(format!("{count} selected"))] + .spacing(7) + .align_y(iced::Center) + .height(32); + if let Some(snapshot) = snapshot.filter(|s| !s.targets().is_empty()) { + controls = controls.push(button("Clear selection").on_press_maybe( + enabled.then(|| Message::DiagramSelection(SelectionEvent::Clear(snapshot.context()))), + )); + controls = controls.push( + label(format!( + "Properties: {}", + short(app.project().named(app.selected), 24) + )) + .wrapping(iced::widget::text::Wrapping::None), + ); + } + // These two rows exist even with an empty set so selection never moves the + // canvas viewport or changes the camera's interpretation of the pointer. + column![ + controls, + label("Shift+click toggles · drag selected cards to reorder · previews are read-only") + .wrapping(iced::widget::text::Wrapping::None) + ] + .height(56) + .into() +} + fn editor_panel<'a, R: iced::advanced::text::Renderer + 'a>( session: Id, actions: iced::Element<'a, Message, iced::Theme, R>, diff --git a/crates/oa-desktop/src/diagram.rs b/crates/oa-desktop/src/diagram.rs index 8c8bacc2..464c085b 100644 --- a/crates/oa-desktop/src/diagram.rs +++ b/crates/oa-desktop/src/diagram.rs @@ -22,6 +22,9 @@ use std::{ sync::Arc, }; #[cfg(test)] +#[path = "diagram/allocation_group_tests.rs"] +mod allocation_group_tests; +#[cfg(test)] #[path = "diagram/attachment_tests.rs"] mod attachment_tests; #[cfg(test)] @@ -912,6 +915,15 @@ impl canvas::Program for DiagramCanvas<'_> { state.pan_start = None; return Some(Action::publish(Message::Connection(event)).and_capture()); } + // group_input handles production selection first (including + // Shift). This also closes legacy movement when selection or + // Allocation capability is absent: never emit a bare MoveNode. + if self.allocation_surface() + && let Some(target) = self.allocation_press_target(&scene, p) + && self.allocation_handles_press(&target) + { + return Some(self.begin_allocation(state, target, cursor.position()?, bounds)); + } if self.editable && let Some(edge) = scene.routed.edges.get(&self.selected) && !edge.custom_suspended @@ -984,16 +996,6 @@ impl canvas::Program for DiagramCanvas<'_> { return Some(Action::publish(Message::Select(port.element)).and_capture()); } if let Some(id) = self.hit(view, p) { - if self.work_eligible(id) { - return Some(self.begin_work( - state, - id, - cursor.position()?, - bounds, - view, - &scene.routed, - )); - } let position = self .project .diagrams @@ -1269,10 +1271,7 @@ impl canvas::Program for DiagramCanvas<'_> { f.translate(self.pan); f.scale(self.zoom); let scene = self.scene(state); - let project = scene - .work - .as_ref() - .map_or(self.project, |p| p.semantic.project()); + let project = scene.work.as_ref().map_or(self.project, |p| p.project()); let view = &scene.view; let containers = view .nodes diff --git a/crates/oa-desktop/src/diagram/allocation_group_tests.rs b/crates/oa-desktop/src/diagram/allocation_group_tests.rs new file mode 100644 index 00000000..ca4936a4 --- /dev/null +++ b/crates/oa-desktop/src/diagram/allocation_group_tests.rs @@ -0,0 +1,520 @@ +//! Exact Allocation dispatch through real canvas events and App rebuilds. +use super::*; +use crate::app::{ + App, + allocation_drag::{Event as WorkEvent, Mode}, + multiselect::{Choice, Event as SelectionEvent, context}, +}; +use crate::diagram_usage::Target; +use iced::{keyboard, widget::canvas::Program}; + +#[derive(Clone, Copy, Debug)] +enum Control { + Attachment, + Bend, +} + +struct Fixture { + app: App, + diagram: Id, + work: Id, + edge: Id, + cover: Option, + at: Position, +} +fn xy(x: f32, y: f32) -> Position { + Position { x, y } +} +fn bounds() -> Rectangle { + Rectangle::new(Point::new(120., 80.), Size::new(2400., 1800.)) +} +fn screen(p: Position) -> Point { + Point::new(bounds().x + p.x, bounds().y + p.y) +} +fn fixture(control: Control, cover: Option) -> Fixture { + let mut project = Project::empty(); + let a = project + .add_element(Kind::Activity, Layer::Oa, "Send", None) + .unwrap(); + let b = project + .add_element(Kind::Activity, Layer::Oa, "Receive", None) + .unwrap(); + let edge = project.relate(RelationKind::Interaction, "Report", a, b); + let diagram = project.ensure_diagram(Layer::Oa); + project.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Allocation; + let work = project.place(diagram, a, xy(100., 150.)); + project.place(diagram, b, xy(700., 150.)); + project + .diagrams + .get_mut(&diagram) + .unwrap() + .routes + .push(EdgeRoute { + edge, + waypoints: vec![xy(450., 450.)], + label: None, + source_attachment: Some(EdgeAttachment { + side: PortAnchorSide::Right, + offset: 0.6, + }), + target_attachment: Some(EdgeAttachment { + side: PortAnchorSide::Left, + offset: 0.5, + }), + }); + let view = projection::project_view(&project, diagram); + let routed = routing::route_diagram(&project, &project.diagrams[&diagram], &view); + let at = match control { + Control::Attachment => { + let at = *routed.edges[&edge].points.first().unwrap(); + // Inside the handle radius but outside its owner's right boundary, + // so the covered-card discriminator does not depend on node order. + xy(at.x + 4., at.y) + } + Control::Bend => routed.edges[&edge].pins[0], + }; + let cover = cover.map(|kind| { + let element = project + .add_element(kind, Layer::Oa, "Underlying card", None) + .unwrap(); + project.place(diagram, element, xy(at.x - 40., at.y - 35.)) + }); + validate_candidate(&project).unwrap(); + let mut app = App::with_project(project); + app.active_diagram = Some(diagram); + app.zoom = 1.; + app.pan = Vector::ZERO; + let _ = app.update(Message::Zoom(1.)); + let _ = app.update(Message::Select(edge)); + app.reconcile_multiselect(); + Fixture { + app, + diagram, + work, + edge, + cover, + at, + } +} +fn canvas(app: &App) -> DiagramCanvas<'_> { + DiagramCanvas { + selection: app.diagram_selection.as_ref(), + allocation: Some(app.allocation_capability()), + project: app.project(), + view_index: Some(app.document.view_index()), + diagram: app.active_diagram.unwrap(), + selected: app.selected, + navigation_focus: None, + zoom: app.zoom, + pan: app.pan, + context: app.route_context().unwrap(), + selected_bend: app + .selected_bend + .filter(|(edge, _)| *edge == app.selected) + .map(|(_, bend)| bend), + selected_attachment: None, + editable: !app.draft_dirty() && !app.source_dirty(), + placing: false, + connection: None, + } +} +fn input(app: &App, state: &mut State, event: canvas::Event, at: Point) -> Option { + canvas(app) + .update(state, &event, bounds(), mouse::Cursor::Available(at)) + .and_then(|action| action.into_inner().0) +} +fn pointer(app: &App, state: &mut State, event: mouse::Event, at: Point) -> Option { + input(app, state, canvas::Event::Mouse(event), at) +} +fn mixed(f: &mut Fixture) { + for (target, choice) in [ + (Target::Placement(f.work), Choice::Replace), + (Target::Edge(f.edge), Choice::Add), + ] { + let current = context(f.app.route_context().unwrap()); + let _ = f + .app + .update(Message::DiagramSelection(SelectionEvent::Choose( + current, target, choice, + ))); + } + assert_eq!(f.app.multiselect_snapshot().unwrap().targets().len(), 2); + assert_eq!( + f.app.selected, f.edge, + "selected edge exposes its real handles" + ); +} +fn assert_control(f: &Fixture, control: Control) { + let c = canvas(&f.app); + let scene = c.scene(&State::default()); + let edge = &scene.routed.edges[&f.edge]; + assert!(!edge.custom_suspended); + match control { + Control::Attachment => assert!(c.attachment_at(&scene, point(f.at)).is_some()), + Control::Bend => assert!(edge.pins.contains(&f.at)), + } + let local = point(f.at); + if let Some(cover) = f.cover { + assert_eq!( + c.hit(&scene.view, local), + Some(cover), + "fixture actually places a card under the visible control" + ); + } else if matches!(control, Control::Bend) { + assert!( + c.hit(&scene.view, local).is_none(), + "isolated bend has no underlying card" + ); + } + assert_eq!( + c.allocation_press_target(&scene, local), + Some(Target::Edge(f.edge)) + ); +} + +#[test] +fn allocation_handles_keep_exact_edge_through_shift_and_pending_group_dispatch() { + for control in [Control::Attachment, Control::Bend] { + for cover in [None, Some(Kind::Activity), Some(Kind::Entity)] { + let mut f = fixture(control, cover); + mixed(&mut f); + assert_control(&f, control); + let from = screen(f.at); + let mut state = State::default(); + let _ = input( + &f.app, + &mut state, + canvas::Event::Keyboard(keyboard::Event::ModifiersChanged( + keyboard::Modifiers::SHIFT, + )), + from, + ); + let _ = pointer( + &f.app, + &mut state, + mouse::Event::CursorMoved { position: from }, + from, + ); + let message = pointer( + &f.app, + &mut state, + mouse::Event::ButtonPressed(mouse::Button::Left), + from, + ) + .unwrap(); + assert!( + matches!(&message, Message::DiagramSelection(SelectionEvent::Choose(_, Target::Edge(id), Choice::Toggle)) if *id == f.edge) + ); + let _ = f.app.update(message); + assert_eq!( + f.app.multiselect_snapshot().unwrap().targets(), + &[Target::Placement(f.work)] + ); + assert!(state.work.is_none() && state.group.is_none() && state.route_drag.is_none()); + } + } +} + +#[test] +fn allocation_handle_click_collapses_but_out_and_back_refuses_after_app_rebuilds() { + for control in [Control::Attachment, Control::Bend] { + for cover in [None, Some(Kind::Activity), Some(Kind::Entity)] { + for crossed in [false, true] { + let mut f = fixture(control, cover); + mixed(&mut f); + assert_control(&f, control); + let accepted = f.app.project().clone(); + let source = f.app.document.bundle().clone(); + let revision = f.app.document.revision(); + let selection = f.app.multiselect_snapshot().unwrap(); + let from = screen(f.at); + let mut state = State::default(); + let _ = pointer( + &f.app, + &mut state, + mouse::Event::CursorMoved { position: from }, + from, + ); + let message = pointer( + &f.app, + &mut state, + mouse::Event::ButtonPressed(mouse::Button::Left), + from, + ) + .unwrap(); + let Message::WorkDrag(WorkEvent::Press(press)) = &message else { + panic!("exact mixed-set Allocation press") + }; + assert_eq!(press.target, Target::Edge(f.edge)); + assert_eq!(press.selection, selection); + assert_eq!(press.mode, Mode::Assign); + let _ = f.app.update(message); + assert_eq!( + f.app.multiselect_snapshot().unwrap(), + selection, + "press retains primary and membership" + ); + if crossed { + for at in [from + Vector::new(4., 0.), from] { + let message = pointer( + &f.app, + &mut state, + mouse::Event::CursorMoved { position: at }, + at, + ) + .unwrap(); + assert!(matches!( + &message, + Message::WorkDrag(WorkEvent::Preview(..)) + )); + let _ = f.app.update(message); + } + } + let message = pointer( + &f.app, + &mut state, + mouse::Event::ButtonReleased(mouse::Button::Left), + from, + ) + .unwrap(); + assert!(matches!( + &message, + Message::WorkDrag(WorkEvent::Release(..)) + )); + let _ = f.app.update(message); + if crossed { + assert_eq!( + f.app.multiselect_snapshot().unwrap(), + selection, + "crossed return cannot collapse or move an underlying card" + ); + } else { + assert_eq!( + f.app.multiselect_snapshot().unwrap().targets(), + &[Target::Edge(f.edge)] + ); + } + assert_eq!(f.app.project(), &accepted); + assert_eq!(f.app.document.bundle(), &source); + assert_eq!(f.app.document.revision(), revision); + assert!(!f.app.document.can_undo()); + assert!(!f.app.document.can_redo()); + assert!( + state.work.is_none() && state.group.is_none() && state.route_drag.is_none() + ); + } + } + } +} + +#[test] +fn armed_isolated_bend_is_owned_but_unarmed_controls_still_edit_routes() { + for control in [Control::Attachment, Control::Bend] { + for armed in [false, true] { + let mut f = fixture(control, None); + assert_control(&f, control); + if armed { + let _ = f.app.update(Message::WorkDrag(WorkEvent::ArrangeNext)); + } + let from = screen(f.at); + let mut state = State::default(); + let _ = pointer( + &f.app, + &mut state, + mouse::Event::CursorMoved { position: from }, + from, + ); + let message = pointer( + &f.app, + &mut state, + mouse::Event::ButtonPressed(mouse::Button::Left), + from, + ); + if armed { + let Some(Message::WorkDrag(WorkEvent::Press(press))) = message else { + panic!("armed handle requires Allocation ownership") + }; + assert_eq!(press.target, Target::Edge(f.edge)); + assert_eq!(press.mode, Mode::Arrange); + assert!(state.work.is_some()); + assert!(state.route_drag.is_none()); + } else { + assert!(state.work.is_none()); + let gesture = state + .route_drag + .as_ref() + .expect("ordinary selected handle remains a route editor"); + match control { + Control::Attachment => { + assert!(matches!(gesture.handle, Handle::Attachment { .. })) + } + Control::Bend => assert!(matches!(gesture.handle, Handle::Bend(0))), + } + assert!(!matches!(message, Some(Message::WorkDrag(_)))); + } + } + } +} + +#[test] +fn ordinary_performer_body_uses_one_token_and_retains_existing_movement_policy() { + let mut f = fixture(Control::Bend, None); + let mut project = f.app.project().clone(); + let performer = project + .add_element(Kind::Entity, Layer::Oa, "Mover", None) + .unwrap(); + let node = project.place(f.diagram, performer, xy(1200., 800.)); + f.app = App::with_project(project); + f.app.active_diagram = Some(f.diagram); + f.app.zoom = 1.; + f.app.pan = Vector::ZERO; + let _ = f.app.update(Message::Zoom(1.)); + f.app.reconcile_multiselect(); + let accepted = f.app.project().clone(); + let source = f.app.document.bundle().clone(); + let from = screen(xy(1240., 835.)); + let to = from + Vector::new(30., 20.); + let mut state = State::default(); + let _ = pointer( + &f.app, + &mut state, + mouse::Event::CursorMoved { position: from }, + from, + ); + let message = pointer( + &f.app, + &mut state, + mouse::Event::ButtonPressed(mouse::Button::Left), + from, + ) + .unwrap(); + assert!( + matches!(&message, Message::WorkDrag(WorkEvent::Press(p)) if p.target == Target::Placement(node) && p.mode == Mode::Assign) + ); + let _ = f.app.update(message); + assert_eq!( + f.app.selected, performer, + "clean unselected press selects immediately" + ); + assert_eq!( + f.app.multiselect_snapshot().unwrap().targets(), + &[Target::Placement(node)] + ); + for event in [ + mouse::Event::CursorMoved { position: to }, + mouse::Event::ButtonReleased(mouse::Button::Left), + ] { + let message = pointer(&f.app, &mut state, event, to).unwrap(); + assert!(matches!( + &message, + Message::WorkDrag(WorkEvent::Preview(..) | WorkEvent::Release(..)) + )); + let _ = f.app.update(message); + } + let mut expected = accepted; + expected + .diagrams + .get_mut(&f.diagram) + .unwrap() + .nodes + .get_mut(&node) + .unwrap() + .position = xy(1230., 820.); + assert_eq!(f.app.project(), &expected); + assert_eq!(f.app.document.revision(), 1); + let after = f.app.document.bundle().clone(); + let _ = f.app.update(Message::Undo); + assert_eq!(f.app.document.bundle(), &source); + let _ = f.app.update(Message::Redo); + assert_eq!(f.app.document.bundle(), &after); +} + +#[test] +fn dirty_selected_pin_outer_ring_is_shielded_without_starting_pan() { + for zoom in [0.5, 1., 2.] { + let mut f = fixture(Control::Bend, None); + let mut project = f.app.project().clone(); + let route = &mut project.diagrams.get_mut(&f.diagram).unwrap().routes[0]; + route.source_attachment.as_mut().unwrap().offset = 0.5; + route.target_attachment.as_mut().unwrap().offset = 0.5; + let view = projection::project_view(&project, f.diagram); + let routed = routing::route_diagram(&project, &project.diagrams[&f.diagram], &view); + let y = routed.edges[&f.edge].points.first().unwrap().y; + assert_eq!(routed.edges[&f.edge].points.last().unwrap().y, y); + let pin = xy(450., y); + project.diagrams.get_mut(&f.diagram).unwrap().routes[0].waypoints = vec![pin]; + f.app = App::with_project(project); + f.app.active_diagram = Some(f.diagram); + f.app.zoom = zoom; + f.app.pan = Vector::ZERO; + let _ = f.app.update(Message::Zoom(1.)); + let _ = f.app.update(Message::Select(f.edge)); + let _ = f.app.update(Message::Field( + "/name".into(), + "Keep this unfinished relation name".into(), + )); + assert!(f.app.draft_dirty()); + let c = canvas(&f.app); + assert!(!c.editable); + let scene = c.scene(&State::default()); + assert!(!scene.routed.edges[&f.edge].custom_suspended); + assert_eq!(scene.routed.edges[&f.edge].pins, vec![pin]); + let local = Point::new(pin.x * zoom, pin.y * zoom + 7.5); + let at = Point::new(bounds().x + local.x, bounds().y + local.y); + assert!(c.hit(&scene.view, local).is_none()); + assert!(c.hit_port(&scene.view, local).is_none()); + assert!( + c.hit_edge(&scene.routed, local).is_none(), + "outer ring lies beyond the ordinary 7px edge hit" + ); + assert!(c.hit_label(&scene.routed, local).is_none()); + assert!(c.hit_association_label(&scene.routed, local).is_none()); + assert!( + c.work_dirty_point(bounds(), at), + "the root shield must cover the visible 8px pin handle while editable=false" + ); + let project = f.app.project().clone(); + let source = f.app.document.bundle().clone(); + let selection = f.app.multiselect_snapshot().unwrap(); + let draft = f.app.draft.as_ref().unwrap().value.clone(); + let revision = f.app.document.revision(); + let pan = f.app.pan; + let mut state = State::default(); + let _ = pointer( + &f.app, + &mut state, + mouse::Event::CursorMoved { position: at }, + at, + ); + let message = pointer( + &f.app, + &mut state, + mouse::Event::ButtonPressed(mouse::Button::Left), + at, + ) + .unwrap(); + assert!(matches!( + &message, + Message::WorkDrag(WorkEvent::Refused(..)) + )); + let _ = f.app.update(message); + assert!(state.pan_start.is_none() && state.route_drag.is_none() && state.work.is_none()); + let moved = at + Vector::new(40., 20.); + for event in [ + mouse::Event::CursorMoved { position: moved }, + mouse::Event::ButtonReleased(mouse::Button::Left), + ] { + let message = pointer(&f.app, &mut state, event, moved); + assert!( + message.is_none(), + "refused handle press must not become background pan or a selector" + ); + } + assert_eq!(f.app.project(), &project); + assert_eq!(f.app.document.bundle(), &source); + assert_eq!(f.app.document.revision(), revision); + assert_eq!(f.app.multiselect_snapshot().unwrap(), selection); + assert_eq!(f.app.draft.as_ref().unwrap().value, draft); + assert_eq!(f.app.pan, pan); + } +} diff --git a/crates/oa-desktop/src/diagram/multiselect.rs b/crates/oa-desktop/src/diagram/multiselect.rs index 4b856608..5f95b92c 100644 --- a/crates/oa-desktop/src/diagram/multiselect.rs +++ b/crates/oa-desktop/src/diagram/multiselect.rs @@ -89,6 +89,10 @@ impl DiagramCanvas<'_> { bounds: Rectangle, cursor: mouse::Cursor, ) -> Option> { + // The Allocation token owns all ordered motion/release while active. + if state.work.is_some() { + return None; + } let viewport = Viewport { zoom: self.zoom, pan: self.pan, @@ -192,7 +196,9 @@ impl DiagramCanvas<'_> { let pointer = cursor.position()?; let local = Point::new(pointer.x - bounds.x, pointer.y - bounds.y); let scene = self.scene(state); - let target = if let Some(port) = self.hit_port(&scene.view, local) { + let target = if self.allocation_surface() { + self.allocation_press_target(&scene, local) + } else if let Some(port) = self.hit_port(&scene.view, local) { Some(Target::Port { port: port.element, placement: port.owner, @@ -219,6 +225,9 @@ impl DiagramCanvas<'_> { .and_capture(), ); } + if self.allocation_handles_press(&target) { + return Some(self.begin_allocation(state, target, pointer, bounds)); + } if selection.targets().len() <= 1 || !selection.contains(&target) { return None; } @@ -230,12 +239,6 @@ impl DiagramCanvas<'_> { Some( "Move requires only placed elements. Remove ports and connections from the selection.", ) - } else if snapshot - .targets() - .iter() - .any(|t| matches!(t, Target::Placement(id) if self.work_eligible(*id))) - { - Some("Group allocation drag is not available yet. The selection was retained.") } else { None }; diff --git a/crates/oa-desktop/src/diagram/work_drag.rs b/crates/oa-desktop/src/diagram/work_drag.rs index adb01d25..0cc4957d 100644 --- a/crates/oa-desktop/src/diagram/work_drag.rs +++ b/crates/oa-desktop/src/diagram/work_drag.rs @@ -1,5 +1,6 @@ use super::*; -use crate::app::allocation_drag::{self as input, Event, Press, Stamp}; +use crate::app::allocation_drag::{self as input, Event, Mode, Press, Stamp}; +use crate::diagram_usage::Target as SelectionTarget; impl DiagramCanvas<'_> { pub(crate) fn work_dirty_point(&self, bounds: Rectangle, screen: Point) -> bool { @@ -23,14 +24,83 @@ impl DiagramCanvas<'_> { .then_some(capability.state.preview.as_ref()) .flatten() } - pub(super) fn work_eligible(&self, placement: Id) -> bool { - self.allocation.is_some() - && self.project.diagrams[&self.diagram].projection == ProjectionKind::Allocation - && self.project.diagrams[&self.diagram] - .nodes - .get(&placement) - .and_then(|n| self.project.elements.get(&n.element)) - .is_some_and(|e| e.kind().is_work()) + pub(super) fn allocation_surface(&self) -> bool { + self.project.diagrams[&self.diagram].projection == ProjectionKind::Allocation + } + + // Dirty input must shield the complete visible handle, including its outer + // ring beyond the edge hit tolerance. This test deliberately ignores edit + // permission: the root shield constructs a non-editable canvas. + fn allocation_route_control( + &self, + view: &ProjectedView, + routed: &RoutedView, + world: Point, + ) -> bool { + let Some(edge) = routed + .edges + .get(&self.selected) + .filter(|e| !e.custom_suspended) + else { + return false; + }; + let radius = 8. / self.zoom; + edge.pins + .iter() + .any(|pin| world.distance(point(*pin)) <= radius) + || [ + (EdgeEnd::Source, edge.points.first()), + (EdgeEnd::Target, edge.points.last()), + ] + .into_iter() + .any(|(end, at)| { + at.is_some_and(|at| world.distance(point(*at)) <= radius) + && route_edit::attachment_node( + self.project, + &self.project.diagrams[&self.diagram], + view, + self.selected, + end, + ) + .is_some() + }) + } + + /// Visible selected-route controls take precedence over underlying cards. + /// Returning the exact edge keeps Shift and owned movement admission aligned. + pub(super) fn allocation_press_target( + &self, + scene: &Scene, + local: Point, + ) -> Option { + let world = self.world(local); + if self.editable && self.allocation_route_control(&scene.view, &scene.routed, world) { + return Some(SelectionTarget::Edge(self.selected)); + } + if let Some(port) = self.hit_port(&scene.view, local) { + Some(SelectionTarget::Port { + port: port.element, + placement: port.owner, + }) + } else if let Some(id) = self.hit(&scene.view, local) { + Some(SelectionTarget::Placement(id)) + } else { + self.hit_label(&scene.routed, local) + .or_else(|| self.hit_association_label(&scene.routed, local)) + .or_else(|| self.hit_edge(&scene.routed, local).map(|hit| hit.0)) + .map(SelectionTarget::Edge) + } + } + + pub(super) fn allocation_handles_press(&self, target: &SelectionTarget) -> bool { + self.allocation_surface() + && (self.allocation.is_some_and(|c| c.state.arrange_next) + || matches!(target, SelectionTarget::Placement(_)) + || self.selection.is_some_and(|selection| { + selection.scope() == crate::app::multiselect::context(self.context).scope + && selection.targets().len() > 1 + && selection.contains(target) + })) } pub(super) fn work_dirty_press( &self, @@ -42,26 +112,30 @@ impl DiagramCanvas<'_> { && !self.placing && self.connection.is_none() && self.project.diagrams[&self.diagram].projection == ProjectionKind::Allocation - && (self.hit(view, local).is_some() + && (self.allocation_route_control(view, routed, self.world(local)) + || self.hit(view, local).is_some() || self.hit_port(view, local).is_some() || self.hit_edge(routed, local).is_some() || self.hit_label(routed, local).is_some() || self.hit_association_label(routed, local).is_some()) } - pub(super) fn begin_work( + pub(super) fn begin_allocation( &self, state: &mut State, - node: Id, + target: SelectionTarget, screen: Point, bounds: Rectangle, - view: &ProjectedView, - routed: &RoutedView, ) -> Action { - let capability = self.allocation.unwrap(); state.drag = None; state.preview = None; state.port_drag = None; state.route_drag = None; + state.attachment_viewport = None; + state.group = None; + *state.cache.borrow_mut() = None; + let Some(capability) = self.allocation else { + return Action::capture(); + }; if !capability.clean { return Action::publish(Message::WorkDrag(Event::Refused( capability.state.epoch, @@ -69,18 +143,15 @@ impl DiagramCanvas<'_> { ))) .and_capture(); } - let work = self.project.diagrams[&self.diagram].nodes[&node].element; - let scope = match crate::allocation_drag::capture(self.project, self.diagram, work, node) { - Ok(scope) => scope, - Err(reason) => { - return Action::publish(Message::WorkDrag(Event::Refused( - capability.state.epoch, - input::refusal(&reason), - ))) - .and_capture(); - } + let Some(selection) = self.selection.and_then(|selection| { + selection.snapshot(crate::app::multiselect::context(self.context)) + }) else { + return Action::publish(Message::WorkDrag(Event::Refused( + capability.state.epoch, + "The selection changed. Start this drag again.".into(), + ))) + .and_capture(); }; - let frozen = input::Frozen::capture(self.project, &scope, view, routed, self.zoom); let press = Arc::new(Press { stamp: Stamp { context: self.context, @@ -92,9 +163,13 @@ impl DiagramCanvas<'_> { }, token: Id::new_v4(), start: screen, - scope, - frozen, - arrange: capability.state.arrange_next, + selection, + target, + mode: if capability.state.arrange_next { + Mode::Arrange + } else { + Mode::Assign + }, }); state.work = Some(press.clone()); Action::publish(Message::WorkDrag(Event::Press(press))).and_capture() @@ -165,8 +240,11 @@ impl canvas::Program for WorkFeedback<'_> { } else { style::TEAL }; + let Some(frozen) = &feedback.frozen else { + return vec![]; + }; for index in &feedback.targets { - let target = &feedback.press.frozen.targets[*index]; + let target = &frozen.targets[*index]; let region = target.region; let top = Point::new( region.x * self.0.zoom + self.0.pan.x, diff --git a/crates/oa-desktop/src/guide.rs b/crates/oa-desktop/src/guide.rs index e3afadf4..b39aadf4 100644 --- a/crates/oa-desktop/src/guide.rs +++ b/crates/oa-desktop/src/guide.rs @@ -321,7 +321,9 @@ fn article(topic: Topic) -> Article { "Move work between performers", &[ "Finish or cancel property and source edits, then drag an Activity or Function body in Architecture · allocated work. Aim at exposed performer space, including a collapsed performer's header. Read Destination above the canvas and the outlined target before releasing; release outside the canvas or press Escape to cancel.", - "The move preserves work ownership and identities. A uniformly assigned work group moves its leaves together; a group distributed across performers asks you to choose a leaf. Role responsibilities remain explicit: an outward drag that would leave a role-derived performer explains why it cannot remove that responsibility.", + "Use Shift-click to add or remove cards from the selection, or right-click a card and choose Add to selection. Drag any selected work card to assign the whole work selection to one performer. Independent work can start on different performers. The inspector keeps its primary element during the drag; right-click a selected card and choose Inspect this element to change that primary without losing the group.", + "The move preserves work ownership and identities. A composite work card moves its leaves together when they have one coherent responsibility; if its own leaves are distributed across performers, choose specific leaves instead. Role responsibilities remain explicit: an outward drag that would leave a role-derived performer explains why it cannot remove that responsibility.", + "To move work and performers together without changing responsibility, select their cards and choose Arrange next drag or press Alt+A. This moves the selected work subtrees and each selected performer's contained cards together, if the complete layout stays readable and clear. Ports and connections cannot join a placement move. Dragging only performer cards normally moves them and their contained cards without changing assignments.", "Make room when work cards would overlap, and move the view or zoom in if the moving work is not readable. The destination name stays above the canvas while the target outline stays fixed during the drag. Hover Destination to inspect a long name: outside the canvas, the tooltip explicitly identifies the last target, and releasing cancels. Return to the canvas to choose the drop. A refused drop keeps the model and history unchanged. One Undo reverses an accepted assignment and placement together; Save all writes the corresponding source changes.", ], ), diff --git a/crates/oa-desktop/src/model_menu.rs b/crates/oa-desktop/src/model_menu.rs index 4ca4b6a0..212d063e 100644 --- a/crates/oa-desktop/src/model_menu.rs +++ b/crates/oa-desktop/src/model_menu.rs @@ -18,6 +18,7 @@ pub struct Request { pub route_segment: Option<(usize, Position)>, pub creation_point: Option, pub canvas: Option, + pub process: Option>, pub source_file: Option, } impl Request { @@ -32,6 +33,7 @@ impl Request { route_segment: None, creation_point: None, canvas: None, + process: None, source_file: None, } } @@ -61,6 +63,10 @@ impl Opened { || request .canvas .is_some_and(|c| Some(c) != current_canvas(app)) + || request + .process + .as_deref() + .is_some_and(|c| Some(*c) != app.topology_context()) { return None; } @@ -88,6 +94,11 @@ impl Opened { .request .canvas .is_none_or(|c| Some(c) == current_canvas(app)) + && self + .request + .process + .as_deref() + .is_none_or(|c| Some(*c) == app.topology_context()) && self .request .source_file @@ -101,10 +112,24 @@ fn target_matches(app: &App, request: &Request) -> bool { let Some(target) = request.selection_target.as_deref() else { return true; }; - if request.canvas.is_none() || !app.multiselect_target_visible(target) { + if (request.canvas.is_none() && request.process.is_none()) + || !app.multiselect_target_visible(target) + { return false; } match target { + Target::ProcessOccurrence(endpoint) => { + request.process.is_some() + && request.canvas.is_none() + && request.placement.is_none() + && endpoint.occurrence == request.object + } + Target::ProcessLink(id) | Target::ProcessPreviewLink { relation: id, .. } => { + request.process.is_some() + && request.canvas.is_none() + && request.placement.is_none() + && *id == request.object + } Target::Placement(id) => { request.placement == Some(*id) && request @@ -132,7 +157,9 @@ fn current_canvas(app: &App) -> Option { fn current_diagram(app: &App) -> Option { if app.page == Page::Behavior { - app.state_context().map(|context| context.diagram) + app.topology_context() + .map(|context| context.owner) + .or_else(|| app.state_context().map(|context| context.diagram)) } else { app.active_diagram } @@ -156,6 +183,7 @@ pub fn trigger(app: &App) -> iced::Element<'_, Message> { request.canvas = app.route_context(); } else if app.page == Page::Behavior { request.canvas = app.state_context(); + request.process = app.topology_context().map(Box::new); if let Some(target) = app.diagram_selection.as_ref().and_then(|s| s.primary()) { request.selection_target = Some(Box::new(target.clone())); request.placement = match target { @@ -233,7 +261,7 @@ pub fn popup(app: &App) -> Option> { }); } let mut entries = vec![Entry::title(crate::views::short(p.named(id), 38))]; - if request.canvas.is_some() + if (request.canvas.is_some() || request.process.is_some()) && let Some(context) = app.multiselect_context() { use crate::app::multiselect::{Choice, Event as SelectionEvent}; @@ -311,11 +339,20 @@ pub fn popup(app: &App) -> Option> { )), true, )); - entries.push(item( - "Hide selected placements", - Message::DiagramSelection(SelectionEvent::Hide(snapshot.clone())), - crate::app::multiselect::placements(&snapshot).is_some(), - )); + if request.process.is_some() { + entries.push(Entry::title( + "Drag contributions within one column to reorder", + )); + entries.push(Entry::disabled( + "Hide unavailable · cards are model contributions", + )); + } else { + 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)", )); @@ -327,6 +364,35 @@ pub fn popup(app: &App) -> Option> { }); } entries.push(Entry::separator()); + if let Some(context) = request.process.as_deref().copied() { + let endpoint = match request.selection_target.as_deref() { + Some(Target::ProcessOccurrence(endpoint)) + if !endpoint.references.is_empty() => + { + Some(endpoint.clone()) + } + Some(Target::ProcessPreviewLink { source, .. }) => Some(source.clone()), + _ => None, + }; + if let Some(endpoint) = endpoint { + entries.push(Entry::title("Reused contents · read-only in this process")); + entries.push(item( + "Open referenced definition", + Message::TopologyCanvas( + crate::behavior::topology::CanvasEvent::OpenQualified( + context, endpoint, + ), + ), + true, + )); + return Some(Menu { + token: opened.token, + anchor: request.anchor, + entries, + on_close: Message::Menu(Event::Close), + }); + } + } } } if app.page == Page::Behavior diff --git a/crates/oa-desktop/src/views.rs b/crates/oa-desktop/src/views.rs index 1f570f6e..7cc2ac23 100644 --- a/crates/oa-desktop/src/views.rs +++ b/crates/oa-desktop/src/views.rs @@ -702,6 +702,34 @@ fn diagram(app: &App) -> View<'_, Message> { fn inspector(app: &App) -> View<'_, Message> { let p = app.project(); + if let Some(target) = app.process_preview_primary() { + let endpoint = match target { + crate::diagram_usage::Target::ProcessOccurrence(endpoint) => endpoint.clone(), + crate::diagram_usage::Target::ProcessPreviewLink { source, .. } => source.clone(), + _ => unreachable!(), + }; + let context = app.topology_context().unwrap(); + return container( + column![ + text(p.named(app.selected)).size(17), + label("Reused contents · read-only here"), + label("Open the referenced definition to edit this contribution or connection."), + action( + "Open referenced definition", + Message::TopologyCanvas(crate::behavior::topology::CanvasEvent::OpenQualified( + context, endpoint + )) + ), + crate::model_menu::trigger(app), + ] + .spacing(12), + ) + .padding(14) + .width(320) + .height(Fill) + .style(style::panel) + .into(); + } if app.source_dirty() { let mut notice = column![ text("Source drafts pending").size(17), diff --git a/harness/allocation-arrange-amendment-20260923.md b/harness/allocation-arrange-amendment-20260923.md new file mode 100644 index 00000000..7dcd1420 --- /dev/null +++ b/harness/allocation-arrange-amendment-20260923.md @@ -0,0 +1,69 @@ +# Explicit Allocation Arrange amendment — 2026-09-23 + +**Status: accepted design; pure planner verified, App integration and native acceptance pending.** Codex-9e8r6t /root adopts this bounded policy after reviewing the actual OA/SA experiment, its independent critique and corrected geometry oracles. No user-owned application specification is changed. + +User-derived basis: [PROJECT_GOAL](../PROJECT_GOAL.md), [multiselect contract](multiselect-contract-20260923.md). Existing authority: [Allocation drag v2](release-activity-allocation-drag-contract-v2-20260911.md) and its linked destination-feedback amendment. [Experiment and planner evidence](allocation-arrange-planner-20260923.md) records the decision history, exact source, results and limits. + +## Scope of the change + +Explicit **Arrange only** supports one or more selected work/performer placements through one declared footprint and one combined geometry check. A distributed work root does not fail Arrange solely because its allocation summary is MixedOrUnresolved or its semantic descendants occupy different visual performer groups. Its affected projected footprint must still translate rigidly and satisfy all geometry/readability checks below. + +This narrowly supersedes v2's mixed-composite/coherence pre-veto **only for explicit Arrange**. Ordinary work-body assignment is unchanged: work-only groups use one frozen common destination, normalize work ancestry, and admit each root under existing role/coherence rules. A mixed/distributed composite still refuses ordinary assignment; different valid initial performers on independent roots do not alone require refusal. Same-effective-performer role-only roots gain no direct Allocation, unsupported role exits refuse wholly, and no drag changes semantic work ownership. Ordinary mixed work/performer selection cannot partially assign its eligible members. Offer Arrange only when its own full-set admission is available. + +Arrange admission cannot depend on having two selected items. C alone and {A,C} use the same rule; adding A changes the actual movement footprint rather than unlocking an otherwise identical operation. Existing ordinary performer arrangement remains governed by its existing behavior; this amendment defines the explicit mode consistently across cardinalities. + +## Exact target and footprint definitions + +1. Validate every original typed target before normalization: exact placement/lifetime in the same active Allocation diagram and layer, currently visible, eligible work or performer kind, and core-valid accepted Project. Any stale, missing, foreign, port, edge or grouping-relation member refuses the whole action. Same-element duplicate placement in one diagram is malformed input. Preserve exact selected membership and primary separately from planning roots. +2. Normalize explicitly selected work roots by same-kind semantic ancestry. For each root, S contains its recursive same-kind work closure. Q is all existing active-diagram placements of the union S, including folded descendants. Unplaced work contributes no fabricated placement. +3. V contains each explicitly selected performer placement and its accepted visual descendants, including folded descendants. R = Q ∪ V. Capture this union once; do not acquire semantic ancestors/siblings or new semantic roots merely because a work placement is an incidental visual descendant. Each R placement moves once. Authored placements outside R and every other diagram remain exact. +4. The press anchor must be an exact selected placement. Snap its requested position once on the existing ten-world-unit grid and apply that one delta to all R. Another selected anchor may change the snapped delta but never the mode or R. Preserve authored sizes, fold states and port anchors. Arrange authorizes no destination unfold, reparenting, packing, Fit or automatic topology change. +5. Preserve every engineering record, including Allocations, RoleAssignments, RoleRealizations and semantic parents. Resolve routes using accepted actual endpoint owners and the complete R delta map: internal pins with both owners moving by the same nonzero delta translate once; one-ended pins remain fixed. Relative labels and attachments remain exact. + +## Combined geometry and its deliberate diagnostic boundary + +Project the complete accepted and candidate diagrams using the appropriate semantic index. Check the entire resulting visible scene, not only moved cards. An unselected performer enclosure may change its derived bounds even though its authored record is untouched. Such growth is allowed only if the complete candidate meets model bounds and the existing unrelated-overlap rule: no new or enlarged positive-area overlap beyond the current 0.01 area tolerance; visual ancestor/descendant containment is exempt. + +Also compare accepted/candidate diagnostic scenes. In each scene, temporarily unfold R and the visual ancestors necessary to reveal R; use the corresponding accepted/candidate visual ancestry. Include stationary geometry revealed by that required expansion. These temporary Diagram changes are never committed. A moved hidden child cannot evade collision checking through a selected performer's fold. + +**Unrelated stationary folded interiors are outside this diagnostic domain.** Do not unfold every container in the diagram. Their visible collapsed headers still participate in the ordinary scene check. This limit prevents invisible distant contents from becoming an unexplained blocker; it does not promise that future expansion will be free of overlap. A stricter all-unfolded policy is a different candidate, not an implicit interpretation of this amendment. + +Every R node's affected diagnostic footprint must preserve size and translate by the common delta. No automatic convergence/reflow is licensed by removing the semantic pre-veto. Check the required originally selected headers for visibility and containment in the captured viewport; a normalized-away selected descendant cannot silently disappear. Any unsupported footprint, unreadable selection, invalid bounds or new/enlarged unrelated overlap refuses the complete action. Show the actual combined candidate and affected counts; do not present independently valid singleton ghosts as evidence of group feasibility. + +## Valid and refused examples + +| Example | Required result under this amendment | +| --- | --- | +| Composite C contains x on A and y on B; select C; enough room for one rigid delta | Arrange may move Q={C,x,y}, preserving A/B authored positions and every allocation. Their derived enclosures may grow if the complete scenes remain admissible. Ordinary assignment still refuses the distributed root. | +| Same model, select {A,C} | R additionally includes A and its accepted visual descendants. Move each union member once. The operation is not equivalent to C alone. | +| Select parent C and its already covered child x | Validate both selected targets, deduplicate planning scope, move each placement once, retain the two exact selection identities/primary. | +| X's translated bounds enter Y's old bounds, while both selected roots move clear together | Check simultaneous candidate geometry. Do not refuse because an independent singleton-X check treated Y as stationary. | +| Unselected B's projected enclosure grows into stationary Z although no R card intersects Z | Refuse. Unchanged authored B geometry is not evidence that its derived enclosure is harmless. | +| Folded selected A hides moved x, which newly intersects a stationary visible obstacle | Required diagnostic expansion detects the collision and refuses. | +| Unrelated folded D hides Z that intersects moved x only in an all-unfolded alternative | Z alone does not block under this diagnostic policy. Required visible/R-plus-ancestor checks must still pass; retain D/Z authored records and D's fold. Future expansion may reveal overlap. | +| Selection includes a work card plus port, edge or grouping-relation target | Whole-set refusal; no eligible-subset fallback. | +| Snapped zero delta, unchanged semantics/folds/routes | No command and no history entry. Arrange never changes assignment merely because a pointer crosses a performer. | + +## Lifecycle, source and history + +Retain v2's clean-owner, dirty-pointer shielding, modal/tool priority and frozen camera/input lifetime. ArrangeNext is explicit and consumed once at press, whether the anchor is work or performer. Preserve the selected set and primary on selected-member press while distinguishing stationary release from group drag; a drag cannot become a singleton click after crossing the three-screen-pixel threshold and returning near its origin. Mode and footprint cannot change midway through a token. + +Capture exact selection generation with document/revision, active diagram/layer, epoch, bounds/pan/zoom and accepted scope. Dirty source/property/behavior input, owner/tool/lifetime change, Fit/resize, Escape, focus loss or outside/hidden release cancels without partial movement. Retiring a token is irreversible even when numeric values later return. Unsupported mixtures never fall through to legacy MoveNode, assignment, reconnection or deletion. + +Preview is immutable. On release, revalidate the same capability/snapshot, recompute combined geometry and compare the complete prepared Project with an independently constructed expected Project containing only the permitted R/route edits. Execute one Batch through normal Document source admission. Source failure/refusal leaves accepted source, selection/primary and history unchanged; successful movement is one Undo/Redo step with exact source roundtrip. Geometry detector assertions alone do not establish any of these transaction/lifecycle properties. + +## Choice rationale and rejected alternatives + +Retaining strict v2 is the smallest implementation but rejects a semantics-preserving explicit layout solely for distribution; until this amendment is implemented, its refusal remains truthful. Visual-selected-only translation is rejected because it omits required semantic/folded descendants. Sequential singleton planning is rejected because geometry, routes, deduplication and final expected Projects are not compositional. Universal unfolding is deliberately not chosen for this bounded mode; it introduces invisible unrelated geometry into admission. Packing/reflow and arbitrary convergence remain separate layout work. + +## Evidence and acceptance still required + +The [experiment/planner report](allocation-arrange-planner-20260923.md) is authoritative for exact runs, source identities, independent review and retained failures. It distinguishes actual projection/source/history evidence from policy predictions and App acceptance. Its independent expected geometry accounts for SA function ports; collision detection is not presented as atomic UI refusal. + +Production integration still requires: + +- Whole original typed-set admission, token/selection-generation, dirty/stale/source-failure, one-shot mode, irreversible threshold, cancellation and minimum-readable-text-scale checks. +- OA/SA native workflows anchored on performer and work members, truthful counts/preview/refusal, one Undo/Redo, Save/reopen and exact unrelated-record preservation. +- Separate ordinary work-only group assignment with per-root responsibility rules and one combined candidate/geometry gate. + +This amendment defines a bounded Arrange capability. It does not establish all Allocation group interactions, general convergence, grouping removal, routing usability or completion of the multiselect issue. diff --git a/harness/allocation-arrange-evidence-20260923/.gitattributes b/harness/allocation-arrange-evidence-20260923/.gitattributes new file mode 100644 index 00000000..48f590d2 --- /dev/null +++ b/harness/allocation-arrange-evidence-20260923/.gitattributes @@ -0,0 +1 @@ +** -text whitespace=cr-at-eol diff --git a/harness/allocation-arrange-evidence-20260923/checks/README.md b/harness/allocation-arrange-evidence-20260923/checks/README.md new file mode 100644 index 00000000..029f78f5 --- /dev/null +++ b/harness/allocation-arrange-evidence-20260923/checks/README.md @@ -0,0 +1,24 @@ +# Allocation captured-gate evidence tools + +Adapted from the reviewed Process prepare/verify/selfcheck tools. Read-only preparation consumes explicitly selected **closed** runs in CHECKOUT/.local/allocation-group-gates. No Cargo, source execution, checkout, GUI, network or archived helper execution occurs. + +```powershell +python -B prepare.py --root CHECKOUT --runs experiment-1 experiment-2 experiment-3 planner-document-1 probe-lint-1 format-1 --check-only +``` + +Keep failed experiment-2: verification preserves its recorded compile failure rather than converting archive consistency into test success. The example selects the first six closed gates. Later closed planner runs can be explicitly listed with the same interface; the final package manifest is authoritative for its exact inventory. No implicit discovery includes active directories. + +To create a portable package, replace --check-only with `--output NEW_DIRECTORY`. Existing output refuses. The exact seven raw files are required, with only `source-proof.json` accepted as an optional eighth file. No raw byte normalization or receipt rewriting occurs. Maps and nested input ZIPs deduplicate by SHA-256; other logical raw files keep their names. All original helper bytes, failed-run streams and optional proof bytes remain archived. The Allocation-specific manifest kind distinguishes this extension from the seven-file Process schema. + +The gate.py and source-proof.py helper catalog is preserved. Every gate's recorded helper must match available bytes; use repeatable `--helper PATH` for older captures. If a proof exists, its exact comparator bytes must be available at the default `.local/process-source-evidence-tools/verify.py` or a repeatable `--comparator PATH`. They are archived under their content hash and never executed. + +An optional source proof is independently pinned in the manifest with its full named commit. Verification binds it to the enclosing run, exact result receipt, captured map/count, completed gate time, status and comparator bytes/hash. Captured HEAD is preserved as historical metadata and is not substituted for the named source commit. This packager checks historical proof consistency; it does **not** repeat Git blob comparison. A source proof on a failed gate is source-identity evidence only, never a passing-test claim. + +```powershell +python -B PACKAGE/verify.py +python -B selfcheck.py --root CHECKOUT --runs experiment-1 experiment-2 experiment-3 planner-document-1 probe-lint-1 format-1 --output TOOLS/NEW_SELFCHECK_DIRECTORY +``` + +The verifier checks pins, exact ZIP inventory, process/start/result relationships, streams, command/exit identities, source capsules, vendor inventory and Cargo artifact relationships. Selfchecks compare packaged raw bytes to originals, reject existing output, and mutate only in-memory copies to test stream/helper/exit/capsule/proof corruption. The package contains tools, manifest, files.json and evidence.zip; selfcheck is a preparation utility, not packaged runtime input. + +Limits: internal consistency is not external authentication, hermetic build proof, native usability, a new source comparison or production acceptance. Review exact run results and source identity separately. Run with `python -B` to avoid incidental bytecode writes. diff --git a/harness/allocation-arrange-evidence-20260923/checks/evidence.zip b/harness/allocation-arrange-evidence-20260923/checks/evidence.zip new file mode 100644 index 00000000..f14e7b5a Binary files /dev/null and b/harness/allocation-arrange-evidence-20260923/checks/evidence.zip differ diff --git a/harness/allocation-arrange-evidence-20260923/checks/files.json b/harness/allocation-arrange-evidence-20260923/checks/files.json new file mode 100644 index 00000000..6a25c482 --- /dev/null +++ b/harness/allocation-arrange-evidence-20260923/checks/files.json @@ -0,0 +1,22 @@ +{ + "prepare.py": { + "bytes": 6316, + "sha256": "aa52592fb8e373c2a90007e187734d29129d38d9fce9dc45461482c7f6376bff" + }, + "verify.py": { + "bytes": 11440, + "sha256": "584187370a1076e13190914158fc72a9ab65f07e72cc6a0d3b5c319f3dc7f05f" + }, + "README.md": { + "bytes": 3284, + "sha256": "3ff0a61fb15ca26e54b8903962c99ba7802a87f0204e7b71f2d04c812b9588b7" + }, + "manifest.json": { + "bytes": 31065, + "sha256": "a993618118369f293569ffdf10c6c05ded4fe8f105df50838c56dca25e0b9eab" + }, + "evidence.zip": { + "bytes": 20799621, + "sha256": "a3fdde41e8e8cb4c2c2381a28628b5c9687461c18a17008bb1294a33ab962bfd" + } +} diff --git a/harness/allocation-arrange-evidence-20260923/checks/manifest.json b/harness/allocation-arrange-evidence-20260923/checks/manifest.json new file mode 100644 index 00000000..facfe2ed --- /dev/null +++ b/harness/allocation-arrange-evidence-20260923/checks/manifest.json @@ -0,0 +1,719 @@ +{ + "schema": 1, + "kind": "allocation-multiselect-cargo-gates", + "runs": { + "experiment-1": { + "command": [ + "cargo", + "test", + "-p", + "oa-desktop", + "--bin", + "arcadia-studio", + "--message-format=json", + "--", + "mixed_arrange_experiment", + "--nocapture", + "--test-threads=1" + ], + "head": "6d99aacab8ca1a91c857a2179baa14d80a2901e9", + "exitCode": 0, + "inputCount": 743, + "helper": "helpers/1368375b1fba0dfc50b77ea4f406dba71960a78317ce8f259d4c4a0254863a20.py", + "sourceProof": { + "commit": "3a7923b96d671041cbc747a614cbd0fad6b6b3d3", + "pin": { + "bytes": 591, + "sha256": "ca8e9265a34d465c365a5ba1472d80fef8720a4fb56d82dc7cfaa0a7fa9e391b" + }, + "comparator": "comparators/7805832d5f563ab2d7a50dd3de700792b5d6f459c68f7b1d8bcfcc9800fa0c3a.py" + } + }, + "experiment-2": { + "command": [ + "cargo", + "test", + "-p", + "oa-desktop", + "--example", + "allocation_policy_probe", + "--message-format=json", + "--", + "mixed_arrange_experiment", + "--nocapture", + "--test-threads=1" + ], + "head": "3a7923b96d671041cbc747a614cbd0fad6b6b3d3", + "exitCode": 101, + "inputCount": 744, + "helper": "helpers/1368375b1fba0dfc50b77ea4f406dba71960a78317ce8f259d4c4a0254863a20.py" + }, + "experiment-3": { + "command": [ + "cargo", + "test", + "-p", + "oa-desktop", + "--example", + "allocation_policy_probe", + "--message-format=json", + "--", + "mixed_arrange_experiment", + "--nocapture", + "--test-threads=1" + ], + "head": "3a7923b96d671041cbc747a614cbd0fad6b6b3d3", + "exitCode": 0, + "inputCount": 744, + "helper": "helpers/1368375b1fba0dfc50b77ea4f406dba71960a78317ce8f259d4c4a0254863a20.py", + "sourceProof": { + "commit": "8dbbaf262e3b8d26a32d6c30f0cc5f963fab47a0", + "pin": { + "bytes": 591, + "sha256": "e1e330d2dc4fee475991d0ba1a1bb75fae77c13d5874689af26987be12db0aae" + }, + "comparator": "comparators/7805832d5f563ab2d7a50dd3de700792b5d6f459c68f7b1d8bcfcc9800fa0c3a.py" + } + }, + "planner-document-1": { + "command": [ + "cargo", + "test", + "-p", + "oa-desktop", + "--example", + "allocation_policy_probe", + "--message-format=json", + "--", + "--test-threads=1" + ], + "head": "3a7923b96d671041cbc747a614cbd0fad6b6b3d3", + "exitCode": 0, + "inputCount": 744, + "helper": "helpers/1368375b1fba0dfc50b77ea4f406dba71960a78317ce8f259d4c4a0254863a20.py", + "sourceProof": { + "commit": "8dbbaf262e3b8d26a32d6c30f0cc5f963fab47a0", + "pin": { + "bytes": 597, + "sha256": "c64e4c556e27e6a6fbc8be463738783bcd5afa40959d4cec87801a53c8b08773" + }, + "comparator": "comparators/7805832d5f563ab2d7a50dd3de700792b5d6f459c68f7b1d8bcfcc9800fa0c3a.py" + } + }, + "probe-lint-1": { + "command": [ + "cargo", + "clippy", + "-p", + "oa-desktop", + "--example", + "allocation_policy_probe", + "--tests", + "--message-format=json", + "--", + "-D", + "warnings" + ], + "head": "3a7923b96d671041cbc747a614cbd0fad6b6b3d3", + "exitCode": 0, + "inputCount": 744, + "helper": "helpers/1368375b1fba0dfc50b77ea4f406dba71960a78317ce8f259d4c4a0254863a20.py", + "sourceProof": { + "commit": "8dbbaf262e3b8d26a32d6c30f0cc5f963fab47a0", + "pin": { + "bytes": 591, + "sha256": "d49e047e0bfe3591836e702141be0f3935b94ecff11d5eb4bcd26658e153ccb8" + }, + "comparator": "comparators/7805832d5f563ab2d7a50dd3de700792b5d6f459c68f7b1d8bcfcc9800fa0c3a.py" + } + }, + "format-1": { + "command": [ + "cargo", + "fmt", + "--all", + "--", + "--check" + ], + "head": "3a7923b96d671041cbc747a614cbd0fad6b6b3d3", + "exitCode": 0, + "inputCount": 744, + "helper": "helpers/1368375b1fba0dfc50b77ea4f406dba71960a78317ce8f259d4c4a0254863a20.py", + "sourceProof": { + "commit": "8dbbaf262e3b8d26a32d6c30f0cc5f963fab47a0", + "pin": { + "bytes": 587, + "sha256": "26071a3c7f146022eebe2f22c50debf0c5f3f554cd561516d1a473be31a88f0d" + }, + "comparator": "comparators/7805832d5f563ab2d7a50dd3de700792b5d6f459c68f7b1d8bcfcc9800fa0c3a.py" + } + }, + "arrange-planner-1": { + "command": [ + "cargo", + "test", + "-p", + "oa-desktop", + "--example", + "allocation_policy_probe", + "--message-format=json", + "--", + "--test-threads=1" + ], + "head": "8dbbaf262e3b8d26a32d6c30f0cc5f963fab47a0", + "exitCode": 101, + "inputCount": 746, + "helper": "helpers/1368375b1fba0dfc50b77ea4f406dba71960a78317ce8f259d4c4a0254863a20.py" + }, + "arrange-planner-2": { + "command": [ + "cargo", + "test", + "-p", + "oa-desktop", + "--example", + "allocation_policy_probe", + "--message-format=json", + "--", + "--test-threads=1" + ], + "head": "8dbbaf262e3b8d26a32d6c30f0cc5f963fab47a0", + "exitCode": 101, + "inputCount": 746, + "helper": "helpers/1368375b1fba0dfc50b77ea4f406dba71960a78317ce8f259d4c4a0254863a20.py" + }, + "arrange-planner-3": { + "command": [ + "cargo", + "test", + "-p", + "oa-desktop", + "--example", + "allocation_policy_probe", + "--message-format=json", + "--", + "--test-threads=1" + ], + "head": "8dbbaf262e3b8d26a32d6c30f0cc5f963fab47a0", + "exitCode": 0, + "inputCount": 746, + "helper": "helpers/1368375b1fba0dfc50b77ea4f406dba71960a78317ce8f259d4c4a0254863a20.py" + }, + "arrange-planner-4": { + "command": [ + "cargo", + "test", + "-p", + "oa-desktop", + "--example", + "allocation_policy_probe", + "--message-format=json", + "--", + "--test-threads=1" + ], + "head": "8dbbaf262e3b8d26a32d6c30f0cc5f963fab47a0", + "exitCode": 0, + "inputCount": 746, + "helper": "helpers/1368375b1fba0dfc50b77ea4f406dba71960a78317ce8f259d4c4a0254863a20.py", + "sourceProof": { + "commit": "9b83f7ca0dcd34056765b9399863a21da72c3a9f", + "pin": { + "bytes": 596, + "sha256": "cbf8c9171bb6debd47397e48a62231f8295f86c7b79bfd3c36ad885689c742e6" + }, + "comparator": "comparators/7805832d5f563ab2d7a50dd3de700792b5d6f459c68f7b1d8bcfcc9800fa0c3a.py" + } + }, + "arrange-lint-1": { + "command": [ + "cargo", + "clippy", + "-p", + "oa-desktop", + "--example", + "allocation_policy_probe", + "--tests", + "--message-format=json", + "--", + "-D", + "warnings" + ], + "head": "8dbbaf262e3b8d26a32d6c30f0cc5f963fab47a0", + "exitCode": 0, + "inputCount": 746, + "helper": "helpers/1368375b1fba0dfc50b77ea4f406dba71960a78317ce8f259d4c4a0254863a20.py", + "sourceProof": { + "commit": "9b83f7ca0dcd34056765b9399863a21da72c3a9f", + "pin": { + "bytes": 593, + "sha256": "d476324a632b56df88343b7bc90c89fc14ee70b30fe91c0be55de01c0de5c846" + }, + "comparator": "comparators/7805832d5f563ab2d7a50dd3de700792b5d6f459c68f7b1d8bcfcc9800fa0c3a.py" + } + }, + "arrange-format-1": { + "command": [ + "cargo", + "fmt", + "--all", + "--", + "--check" + ], + "head": "9b83f7ca0dcd34056765b9399863a21da72c3a9f", + "exitCode": 0, + "inputCount": 746, + "helper": "helpers/1368375b1fba0dfc50b77ea4f406dba71960a78317ce8f259d4c4a0254863a20.py", + "sourceProof": { + "commit": "9b83f7ca0dcd34056765b9399863a21da72c3a9f", + "pin": { + "bytes": 595, + "sha256": "03ae19dca92db8cdde62c5a0f63c6c5789d75a977079bbd1d84c76c91d775f08" + }, + "comparator": "comparators/7805832d5f563ab2d7a50dd3de700792b5d6f459c68f7b1d8bcfcc9800fa0c3a.py" + } + } + }, + "captureHelpers": { + "gate.py": "helpers/1368375b1fba0dfc50b77ea4f406dba71960a78317ce8f259d4c4a0254863a20.py", + "source-proof.py": "helpers/58597566ae1cb5c4021ddc7e86635b76d127bdb63b5d3026c427771aae7e2119.py" + }, + "logicalFiles": { + "cargo/arrange-format-1/inputs.json": "capsules/c8192fd84fba63fa576975e9fd44f0fa2d6c8ed2e6b5ada3fd4bee076d36f686.json", + "cargo/arrange-format-1/inputs.zip": "capsules/29ab4dce454ec8dd92208620e6f8eb0749b6fd72c1687bf1d19264c77159e4f1.zip", + "cargo/arrange-format-1/process.json": "cargo/arrange-format-1/process.json", + "cargo/arrange-format-1/result.json": "cargo/arrange-format-1/result.json", + "cargo/arrange-format-1/source-proof.json": "cargo/arrange-format-1/source-proof.json", + "cargo/arrange-format-1/started.json": "cargo/arrange-format-1/started.json", + "cargo/arrange-format-1/stderr.log": "cargo/arrange-format-1/stderr.log", + "cargo/arrange-format-1/stdout.log": "cargo/arrange-format-1/stdout.log", + "cargo/arrange-lint-1/inputs.json": "capsules/c8192fd84fba63fa576975e9fd44f0fa2d6c8ed2e6b5ada3fd4bee076d36f686.json", + "cargo/arrange-lint-1/inputs.zip": "capsules/29ab4dce454ec8dd92208620e6f8eb0749b6fd72c1687bf1d19264c77159e4f1.zip", + "cargo/arrange-lint-1/process.json": "cargo/arrange-lint-1/process.json", + "cargo/arrange-lint-1/result.json": "cargo/arrange-lint-1/result.json", + "cargo/arrange-lint-1/source-proof.json": "cargo/arrange-lint-1/source-proof.json", + "cargo/arrange-lint-1/started.json": "cargo/arrange-lint-1/started.json", + "cargo/arrange-lint-1/stderr.log": "cargo/arrange-lint-1/stderr.log", + "cargo/arrange-lint-1/stdout.log": "cargo/arrange-lint-1/stdout.log", + "cargo/arrange-planner-1/inputs.json": "capsules/084e4bd0288b35761193f3e28cdc580fa545de80f9147ef0ba997d090f0ba2a1.json", + "cargo/arrange-planner-1/inputs.zip": "capsules/3c0175ff95e0639f8e400e41f6014f8b0223672494682b4f39a8ca8d45ff898f.zip", + "cargo/arrange-planner-1/process.json": "cargo/arrange-planner-1/process.json", + "cargo/arrange-planner-1/result.json": "cargo/arrange-planner-1/result.json", + "cargo/arrange-planner-1/started.json": "cargo/arrange-planner-1/started.json", + "cargo/arrange-planner-1/stderr.log": "cargo/arrange-planner-1/stderr.log", + "cargo/arrange-planner-1/stdout.log": "cargo/arrange-planner-1/stdout.log", + "cargo/arrange-planner-2/inputs.json": "capsules/f2ffd9ca8ee08a31769e1a5f460f5edd1a78e539463e25c1dcec8c7065829619.json", + "cargo/arrange-planner-2/inputs.zip": "capsules/5c2134898d477eca46ed26eff9a9833de4005660eb7ee89a124eacba21a9b18e.zip", + "cargo/arrange-planner-2/process.json": "cargo/arrange-planner-2/process.json", + "cargo/arrange-planner-2/result.json": "cargo/arrange-planner-2/result.json", + "cargo/arrange-planner-2/started.json": "cargo/arrange-planner-2/started.json", + "cargo/arrange-planner-2/stderr.log": "cargo/arrange-planner-2/stderr.log", + "cargo/arrange-planner-2/stdout.log": "cargo/arrange-planner-2/stdout.log", + "cargo/arrange-planner-3/inputs.json": "capsules/d918a5797a645d1483139139b1a56a558f5c54fe5f797433cbaa7ed5c34c07a0.json", + "cargo/arrange-planner-3/inputs.zip": "capsules/0b08fc45ecc1d38421d9c5b42e65d2b577d4641d0addb0e04043815e76b4b50f.zip", + "cargo/arrange-planner-3/process.json": "cargo/arrange-planner-3/process.json", + "cargo/arrange-planner-3/result.json": "cargo/arrange-planner-3/result.json", + "cargo/arrange-planner-3/started.json": "cargo/arrange-planner-3/started.json", + "cargo/arrange-planner-3/stderr.log": "cargo/arrange-planner-3/stderr.log", + "cargo/arrange-planner-3/stdout.log": "cargo/arrange-planner-3/stdout.log", + "cargo/arrange-planner-4/inputs.json": "capsules/c8192fd84fba63fa576975e9fd44f0fa2d6c8ed2e6b5ada3fd4bee076d36f686.json", + "cargo/arrange-planner-4/inputs.zip": "capsules/29ab4dce454ec8dd92208620e6f8eb0749b6fd72c1687bf1d19264c77159e4f1.zip", + "cargo/arrange-planner-4/process.json": "cargo/arrange-planner-4/process.json", + "cargo/arrange-planner-4/result.json": "cargo/arrange-planner-4/result.json", + "cargo/arrange-planner-4/source-proof.json": "cargo/arrange-planner-4/source-proof.json", + "cargo/arrange-planner-4/started.json": "cargo/arrange-planner-4/started.json", + "cargo/arrange-planner-4/stderr.log": "cargo/arrange-planner-4/stderr.log", + "cargo/arrange-planner-4/stdout.log": "cargo/arrange-planner-4/stdout.log", + "cargo/experiment-1/inputs.json": "capsules/fc74b619bba31bed7a075cf519456d32f7ccffd1e06624bdb92ad309d55cac45.json", + "cargo/experiment-1/inputs.zip": "capsules/f2e0d3cfeff569cc7794d56f1c439ae1fe1eac15269ee0c8edd58bc80aacaa2d.zip", + "cargo/experiment-1/process.json": "cargo/experiment-1/process.json", + "cargo/experiment-1/result.json": "cargo/experiment-1/result.json", + "cargo/experiment-1/source-proof.json": "cargo/experiment-1/source-proof.json", + "cargo/experiment-1/started.json": "cargo/experiment-1/started.json", + "cargo/experiment-1/stderr.log": "cargo/experiment-1/stderr.log", + "cargo/experiment-1/stdout.log": "cargo/experiment-1/stdout.log", + "cargo/experiment-2/inputs.json": "capsules/4e26f58e200b8c7b825f3c96e2f1df0bdc56f9f02aa8e1d58866b00a81275cdc.json", + "cargo/experiment-2/inputs.zip": "capsules/c2e6e9747e345dba2f35c79b00dd1302c59072a848f18534016acf2e81454acd.zip", + "cargo/experiment-2/process.json": "cargo/experiment-2/process.json", + "cargo/experiment-2/result.json": "cargo/experiment-2/result.json", + "cargo/experiment-2/started.json": "cargo/experiment-2/started.json", + "cargo/experiment-2/stderr.log": "cargo/experiment-2/stderr.log", + "cargo/experiment-2/stdout.log": "cargo/experiment-2/stdout.log", + "cargo/experiment-3/inputs.json": "capsules/acd1c8c1f4f2f529455431bbe4193acb25ab8d69bf72ff543265007847b808eb.json", + "cargo/experiment-3/inputs.zip": "capsules/8fd3ea41c3f5278bbb3986de804cc93229084b00096c131acd09ba8705db502e.zip", + "cargo/experiment-3/process.json": "cargo/experiment-3/process.json", + "cargo/experiment-3/result.json": "cargo/experiment-3/result.json", + "cargo/experiment-3/source-proof.json": "cargo/experiment-3/source-proof.json", + "cargo/experiment-3/started.json": "cargo/experiment-3/started.json", + "cargo/experiment-3/stderr.log": "cargo/experiment-3/stderr.log", + "cargo/experiment-3/stdout.log": "cargo/experiment-3/stdout.log", + "cargo/format-1/inputs.json": "capsules/acd1c8c1f4f2f529455431bbe4193acb25ab8d69bf72ff543265007847b808eb.json", + "cargo/format-1/inputs.zip": "capsules/8fd3ea41c3f5278bbb3986de804cc93229084b00096c131acd09ba8705db502e.zip", + "cargo/format-1/process.json": "cargo/format-1/process.json", + "cargo/format-1/result.json": "cargo/format-1/result.json", + "cargo/format-1/source-proof.json": "cargo/format-1/source-proof.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/planner-document-1/inputs.json": "capsules/acd1c8c1f4f2f529455431bbe4193acb25ab8d69bf72ff543265007847b808eb.json", + "cargo/planner-document-1/inputs.zip": "capsules/8fd3ea41c3f5278bbb3986de804cc93229084b00096c131acd09ba8705db502e.zip", + "cargo/planner-document-1/process.json": "cargo/planner-document-1/process.json", + "cargo/planner-document-1/result.json": "cargo/planner-document-1/result.json", + "cargo/planner-document-1/source-proof.json": "cargo/planner-document-1/source-proof.json", + "cargo/planner-document-1/started.json": "cargo/planner-document-1/started.json", + "cargo/planner-document-1/stderr.log": "cargo/planner-document-1/stderr.log", + "cargo/planner-document-1/stdout.log": "cargo/planner-document-1/stdout.log", + "cargo/probe-lint-1/inputs.json": "capsules/acd1c8c1f4f2f529455431bbe4193acb25ab8d69bf72ff543265007847b808eb.json", + "cargo/probe-lint-1/inputs.zip": "capsules/8fd3ea41c3f5278bbb3986de804cc93229084b00096c131acd09ba8705db502e.zip", + "cargo/probe-lint-1/process.json": "cargo/probe-lint-1/process.json", + "cargo/probe-lint-1/result.json": "cargo/probe-lint-1/result.json", + "cargo/probe-lint-1/source-proof.json": "cargo/probe-lint-1/source-proof.json", + "cargo/probe-lint-1/started.json": "cargo/probe-lint-1/started.json", + "cargo/probe-lint-1/stderr.log": "cargo/probe-lint-1/stderr.log", + "cargo/probe-lint-1/stdout.log": "cargo/probe-lint-1/stdout.log", + "comparators/7805832d5f563ab2d7a50dd3de700792b5d6f459c68f7b1d8bcfcc9800fa0c3a.py": "comparators/7805832d5f563ab2d7a50dd3de700792b5d6f459c68f7b1d8bcfcc9800fa0c3a.py", + "helpers/1368375b1fba0dfc50b77ea4f406dba71960a78317ce8f259d4c4a0254863a20.py": "helpers/1368375b1fba0dfc50b77ea4f406dba71960a78317ce8f259d4c4a0254863a20.py", + "helpers/58597566ae1cb5c4021ddc7e86635b76d127bdb63b5d3026c427771aae7e2119.py": "helpers/58597566ae1cb5c4021ddc7e86635b76d127bdb63b5d3026c427771aae7e2119.py" + }, + "members": { + "capsules/084e4bd0288b35761193f3e28cdc580fa545de80f9147ef0ba997d090f0ba2a1.json": { + "bytes": 124966, + "sha256": "084e4bd0288b35761193f3e28cdc580fa545de80f9147ef0ba997d090f0ba2a1" + }, + "capsules/0b08fc45ecc1d38421d9c5b42e65d2b577d4641d0addb0e04043815e76b4b50f.zip": { + "bytes": 2998637, + "sha256": "0b08fc45ecc1d38421d9c5b42e65d2b577d4641d0addb0e04043815e76b4b50f" + }, + "capsules/29ab4dce454ec8dd92208620e6f8eb0749b6fd72c1687bf1d19264c77159e4f1.zip": { + "bytes": 2999625, + "sha256": "29ab4dce454ec8dd92208620e6f8eb0749b6fd72c1687bf1d19264c77159e4f1" + }, + "capsules/3c0175ff95e0639f8e400e41f6014f8b0223672494682b4f39a8ca8d45ff898f.zip": { + "bytes": 2998617, + "sha256": "3c0175ff95e0639f8e400e41f6014f8b0223672494682b4f39a8ca8d45ff898f" + }, + "capsules/4e26f58e200b8c7b825f3c96e2f1df0bdc56f9f02aa8e1d58866b00a81275cdc.json": { + "bytes": 124628, + "sha256": "4e26f58e200b8c7b825f3c96e2f1df0bdc56f9f02aa8e1d58866b00a81275cdc" + }, + "capsules/5c2134898d477eca46ed26eff9a9833de4005660eb7ee89a124eacba21a9b18e.zip": { + "bytes": 2998623, + "sha256": "5c2134898d477eca46ed26eff9a9833de4005660eb7ee89a124eacba21a9b18e" + }, + "capsules/8fd3ea41c3f5278bbb3986de804cc93229084b00096c131acd09ba8705db502e.zip": { + "bytes": 2992280, + "sha256": "8fd3ea41c3f5278bbb3986de804cc93229084b00096c131acd09ba8705db502e" + }, + "capsules/acd1c8c1f4f2f529455431bbe4193acb25ab8d69bf72ff543265007847b808eb.json": { + "bytes": 124628, + "sha256": "acd1c8c1f4f2f529455431bbe4193acb25ab8d69bf72ff543265007847b808eb" + }, + "capsules/c2e6e9747e345dba2f35c79b00dd1302c59072a848f18534016acf2e81454acd.zip": { + "bytes": 2992231, + "sha256": "c2e6e9747e345dba2f35c79b00dd1302c59072a848f18534016acf2e81454acd" + }, + "capsules/c8192fd84fba63fa576975e9fd44f0fa2d6c8ed2e6b5ada3fd4bee076d36f686.json": { + "bytes": 124966, + "sha256": "c8192fd84fba63fa576975e9fd44f0fa2d6c8ed2e6b5ada3fd4bee076d36f686" + }, + "capsules/d918a5797a645d1483139139b1a56a558f5c54fe5f797433cbaa7ed5c34c07a0.json": { + "bytes": 124966, + "sha256": "d918a5797a645d1483139139b1a56a558f5c54fe5f797433cbaa7ed5c34c07a0" + }, + "capsules/f2e0d3cfeff569cc7794d56f1c439ae1fe1eac15269ee0c8edd58bc80aacaa2d.zip": { + "bytes": 2990212, + "sha256": "f2e0d3cfeff569cc7794d56f1c439ae1fe1eac15269ee0c8edd58bc80aacaa2d" + }, + "capsules/f2ffd9ca8ee08a31769e1a5f460f5edd1a78e539463e25c1dcec8c7065829619.json": { + "bytes": 124966, + "sha256": "f2ffd9ca8ee08a31769e1a5f460f5edd1a78e539463e25c1dcec8c7065829619" + }, + "capsules/fc74b619bba31bed7a075cf519456d32f7ccffd1e06624bdb92ad309d55cac45.json": { + "bytes": 124458, + "sha256": "fc74b619bba31bed7a075cf519456d32f7ccffd1e06624bdb92ad309d55cac45" + }, + "cargo/arrange-format-1/process.json": { + "bytes": 116, + "sha256": "2eb27eb4ad4b1d6cdb493ece9df3247de50b4f494522a7fc0cd75cf286847507" + }, + "cargo/arrange-format-1/result.json": { + "bytes": 1871, + "sha256": "a5f6e31729519aa3fc064d675731a79c9d93297f713a39afad04de9c8fedeaa8" + }, + "cargo/arrange-format-1/source-proof.json": { + "bytes": 595, + "sha256": "03ae19dca92db8cdde62c5a0f63c6c5789d75a977079bbd1d84c76c91d775f08" + }, + "cargo/arrange-format-1/started.json": { + "bytes": 1483, + "sha256": "098fd74217e751dc4aa4767f6d59b24ca65bc834dfe7a9dee8330f557baef8ce" + }, + "cargo/arrange-format-1/stderr.log": { + "bytes": 0, + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "cargo/arrange-format-1/stdout.log": { + "bytes": 0, + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "cargo/arrange-lint-1/process.json": { + "bytes": 213, + "sha256": "930d2a3b839ac22ae66dbf283634878a90bff77cd3f21c3efbefed87d2561f1b" + }, + "cargo/arrange-lint-1/result.json": { + "bytes": 2005, + "sha256": "278a880fc03cd922c0055b84cf364c0f17ee6217f0cfa1d75ca3f52399e96aad" + }, + "cargo/arrange-lint-1/source-proof.json": { + "bytes": 593, + "sha256": "d476324a632b56df88343b7bc90c89fc14ee70b30fe91c0be55de01c0de5c846" + }, + "cargo/arrange-lint-1/started.json": { + "bytes": 1610, + "sha256": "8171889b3bebe97b49b4f2214bfe732f2651a1c827c3777ea91fa57235e73fc2" + }, + "cargo/arrange-lint-1/stderr.log": { + "bytes": 137, + "sha256": "412177405c27599386afb28d603f0895c31becfc6b6de4ebb5a4063734ba94a7" + }, + "cargo/arrange-lint-1/stdout.log": { + "bytes": 268845, + "sha256": "f6b71a7a22bd4af2205cde4becc7e1e017ad4d78013944cb2b5218fc32d2e4b8" + }, + "cargo/arrange-planner-1/process.json": { + "bytes": 202, + "sha256": "ffeb3bed75938dc4335bd6253783601f21c72999cad952dd191ca7243bf4c506" + }, + "cargo/arrange-planner-1/result.json": { + "bytes": 1987, + "sha256": "6e71d9a4f75ce5aeb7714aeffb23f622d4b3a1b267601e45368fcc3597742f47" + }, + "cargo/arrange-planner-1/started.json": { + "bytes": 1589, + "sha256": "be88ce011f96d06e07ba02b5e485fe365940a8fb8aa19158ba82e46f5abbb94c" + }, + "cargo/arrange-planner-1/stderr.log": { + "bytes": 179, + "sha256": "fff43766ea766c831cd1963c57fd67791b065badb93ec96b62e8546233f3109c" + }, + "cargo/arrange-planner-1/stdout.log": { + "bytes": 295296, + "sha256": "427033f5308ecb85e754d6a5fc21ea56d0276edaa0d0d0bf41bf234d9cb452e1" + }, + "cargo/arrange-planner-2/process.json": { + "bytes": 202, + "sha256": "d1e6c39dcdf512da2c2ad457200cd14d3f989048d0244c7eb784c7547f2db63f" + }, + "cargo/arrange-planner-2/result.json": { + "bytes": 1987, + "sha256": "ccea0caeaf49f4bb0db9ac790cac9fef2138cff072c4d7104fea5597412260b3" + }, + "cargo/arrange-planner-2/started.json": { + "bytes": 1589, + "sha256": "22c4c2579b3d16b61929323cb7f8c2be55f4402d8091dcba20b3dc539cb65b87" + }, + "cargo/arrange-planner-2/stderr.log": { + "bytes": 180, + "sha256": "e96d24ab1396169886e66c47bfc8ad423c47eb1ba04f23cb0b993a42fcd00672" + }, + "cargo/arrange-planner-2/stdout.log": { + "bytes": 304852, + "sha256": "b1de0cb57b6c9d8a1163635bb9428c9058989f3a3e04f771c6d52ec3e98bb38d" + }, + "cargo/arrange-planner-3/process.json": { + "bytes": 202, + "sha256": "3d40643702d3e4cd5f1c77665d6a6fe7489a8962ad4831a4bf36176716fc3806" + }, + "cargo/arrange-planner-3/result.json": { + "bytes": 3388, + "sha256": "281bd88cc0e8908b12e11ca8c32c87b904e9aa511022d5737aa208f6d09d5516" + }, + "cargo/arrange-planner-3/started.json": { + "bytes": 1589, + "sha256": "97f7f2fb041de0bcfb4c55fc998c33181d50a15aad3f1ef9d98e9f8ae81c91af" + }, + "cargo/arrange-planner-3/stderr.log": { + "bytes": 323, + "sha256": "c02d99f2243e3b34e2ca3512d4b18ecd60cd27eb1066e2336df3f88908ef26af" + }, + "cargo/arrange-planner-3/stdout.log": { + "bytes": 300602, + "sha256": "b681a64a77fb83078b2aef1035c3ce5a7a7fc4c0cc439795eb5ad476216a8bea" + }, + "cargo/arrange-planner-4/process.json": { + "bytes": 202, + "sha256": "ce53d413817d6ba48cd361c9d97989c65f53b9ced8fa772870c0162a0725c44b" + }, + "cargo/arrange-planner-4/result.json": { + "bytes": 3388, + "sha256": "71ff6ed0ff007f15dc0c9d574981c7fb765771baa0370b69f2868822c3b0cd76" + }, + "cargo/arrange-planner-4/source-proof.json": { + "bytes": 596, + "sha256": "cbf8c9171bb6debd47397e48a62231f8295f86c7b79bfd3c36ad885689c742e6" + }, + "cargo/arrange-planner-4/started.json": { + "bytes": 1589, + "sha256": "9686ab1af2b7131de0656d131eadb217b869eeeeeef860c94025e42c4a102ae7" + }, + "cargo/arrange-planner-4/stderr.log": { + "bytes": 324, + "sha256": "8ba7a613f49ce57efa9a35742c9bfe8acf9c1536ebb9a9f1873f5d0ca9a3301b" + }, + "cargo/arrange-planner-4/stdout.log": { + "bytes": 300969, + "sha256": "e0cd9d35beeb9f48ee7e97fee96d7bda700c96786a1c7199c4c38170d9b71bc2" + }, + "cargo/experiment-1/process.json": { + "bytes": 232, + "sha256": "021352da56436e72c00da14992f8a94d26efd0893af5d7230b97d1f0af97927a" + }, + "cargo/experiment-1/result.json": { + "bytes": 3364, + "sha256": "a82c43d59954773907d5268395e40c9829793fb2d4db5387cb306fd5b39edd01" + }, + "cargo/experiment-1/source-proof.json": { + "bytes": 591, + "sha256": "ca8e9265a34d465c365a5ba1472d80fef8720a4fb56d82dc7cfaa0a7fa9e391b" + }, + "cargo/experiment-1/started.json": { + "bytes": 1629, + "sha256": "b2bd9e31d8242956809a0d3b6a3cf506f910539d776d9cc0c8131102b1846081" + }, + "cargo/experiment-1/stderr.log": { + "bytes": 288, + "sha256": "c8e5c4c82f33c431eca2fdec4dee37250fcaadd4871cb896520ee779a3523924" + }, + "cargo/experiment-1/stdout.log": { + "bytes": 293505, + "sha256": "a5fb7e62505efc44ff3d17f11437ede717eedd05e639d9c19db8e80d52699c70" + }, + "cargo/experiment-2/process.json": { + "bytes": 245, + "sha256": "2b60185d235cf572b2a1ed79795dbf0868fbc6cfc532edd895d51dd09eb889cd" + }, + "cargo/experiment-2/result.json": { + "bytes": 2040, + "sha256": "746b1a53511248914d647ac51db2c222d7378cedc7e59e91ef8beea8eb043315" + }, + "cargo/experiment-2/started.json": { + "bytes": 1642, + "sha256": "4cd8ca5c534c16594c52e4fe46f641652e88146afbf025c63a140670cd3e6174" + }, + "cargo/experiment-2/stderr.log": { + "bytes": 200, + "sha256": "75cc2e0e245d1c80b04a4a2c2d822a925df836958c4b5c887c1a8f11f99f1f63" + }, + "cargo/experiment-2/stdout.log": { + "bytes": 355372, + "sha256": "c513f481adcaedc4461b2d37ea2a30a72b1b49d20cf66fce587bdbead9cdc3fa" + }, + "cargo/experiment-3/process.json": { + "bytes": 245, + "sha256": "159f9bea3bea574f63fd1f2a505e245c1839ded6943abf33f0228a08b55f7b9a" + }, + "cargo/experiment-3/result.json": { + "bytes": 3439, + "sha256": "dffdc1b4b417888f83c87118e981be7a9aa5b1a1dfddb9c3a166ac149013f937" + }, + "cargo/experiment-3/source-proof.json": { + "bytes": 591, + "sha256": "e1e330d2dc4fee475991d0ba1a1bb75fae77c13d5874689af26987be12db0aae" + }, + "cargo/experiment-3/started.json": { + "bytes": 1642, + "sha256": "26a7b07180d4a82b3e73ab2bd3435b5dc88f5bf4b3d9657a1e3e4a87e9502415" + }, + "cargo/experiment-3/stderr.log": { + "bytes": 323, + "sha256": "21871e9ad66301bf27eecf78b22ea4ff2171bf37ed32d5fdc3bd15f1148f6791" + }, + "cargo/experiment-3/stdout.log": { + "bytes": 293837, + "sha256": "f618b8bff61a3d7dd50d3a427c9a42b4650cedb308752343b3878a997c146e5c" + }, + "cargo/format-1/process.json": { + "bytes": 116, + "sha256": "e513f657fb6f90a0774e327591f0e5c4e500a36fc09dbbd632758412c5191e31" + }, + "cargo/format-1/result.json": { + "bytes": 1872, + "sha256": "26947da874f10973bb5f7f00692b354cf1e9680d1a8743d7185ddfbab8ba690a" + }, + "cargo/format-1/source-proof.json": { + "bytes": 587, + "sha256": "26071a3c7f146022eebe2f22c50debf0c5f3f554cd561516d1a473be31a88f0d" + }, + "cargo/format-1/started.json": { + "bytes": 1483, + "sha256": "a6cd6be75c5aa87e5dbdebd5e91f1dea44272d5a8bc359133f0bba88093c99d1" + }, + "cargo/format-1/stderr.log": { + "bytes": 0, + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "cargo/format-1/stdout.log": { + "bytes": 0, + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "cargo/planner-document-1/process.json": { + "bytes": 202, + "sha256": "62b9efe10f28e06ae1889aec50c64d08122e7c59498bea461a0d246fd8191f86" + }, + "cargo/planner-document-1/result.json": { + "bytes": 3387, + "sha256": "c1fcf4c5ba394dd060a7480c6528d3003fbbe28d0393f31f6b29af97ab6e0f96" + }, + "cargo/planner-document-1/source-proof.json": { + "bytes": 597, + "sha256": "c64e4c556e27e6a6fbc8be463738783bcd5afa40959d4cec87801a53c8b08773" + }, + "cargo/planner-document-1/started.json": { + "bytes": 1589, + "sha256": "46e446a7aef0bcf53433f6da250773ec1b1180eeebc5423659240d2d9f57993d" + }, + "cargo/planner-document-1/stderr.log": { + "bytes": 247, + "sha256": "a9712b2a81ccf9c8b139170c92ed4d0477596d634c30fd0b0d71c362fafdff93" + }, + "cargo/planner-document-1/stdout.log": { + "bytes": 299855, + "sha256": "ce239ba7364a035c08c0bf4c46174d0c0eb63f0bf2e9233bddac2b74e2f31de7" + }, + "cargo/probe-lint-1/process.json": { + "bytes": 212, + "sha256": "a2bfa1d2452c0ca13feebd54840af0425817d114cd864c04e26c87a5229538a1" + }, + "cargo/probe-lint-1/result.json": { + "bytes": 2004, + "sha256": "2bdac177fc3ce3c4f6536a3348ec516e0a08fe1a478dd5382b70dc64a41d1385" + }, + "cargo/probe-lint-1/source-proof.json": { + "bytes": 591, + "sha256": "d49e047e0bfe3591836e702141be0f3935b94ecff11d5eb4bcd26658e153ccb8" + }, + "cargo/probe-lint-1/started.json": { + "bytes": 1610, + "sha256": "682ae85b82b0551aec98da64192baa124e9dc9b7b2efa302b09cfeec24e395a4" + }, + "cargo/probe-lint-1/stderr.log": { + "bytes": 137, + "sha256": "6cef38e57b28fdf01ceda8d4115372f4b9957505bbe28f9b6062e6c93c8feebc" + }, + "cargo/probe-lint-1/stdout.log": { + "bytes": 268846, + "sha256": "38245fd5fc2ba8b6aac057f12438497e67a77a4fe96b2ca6f5aa6e1db36084f5" + }, + "comparators/7805832d5f563ab2d7a50dd3de700792b5d6f459c68f7b1d8bcfcc9800fa0c3a.py": { + "bytes": 10094, + "sha256": "7805832d5f563ab2d7a50dd3de700792b5d6f459c68f7b1d8bcfcc9800fa0c3a" + }, + "helpers/1368375b1fba0dfc50b77ea4f406dba71960a78317ce8f259d4c4a0254863a20.py": { + "bytes": 3827, + "sha256": "1368375b1fba0dfc50b77ea4f406dba71960a78317ce8f259d4c4a0254863a20" + }, + "helpers/58597566ae1cb5c4021ddc7e86635b76d127bdb63b5d3026c427771aae7e2119.py": { + "bytes": 1399, + "sha256": "58597566ae1cb5c4021ddc7e86635b76d127bdb63b5d3026c427771aae7e2119" + } + } +} diff --git a/harness/allocation-arrange-evidence-20260923/checks/prepare.py b/harness/allocation-arrange-evidence-20260923/checks/prepare.py new file mode 100644 index 00000000..23d1d9ce --- /dev/null +++ b/harness/allocation-arrange-evidence-20260923/checks/prepare.py @@ -0,0 +1,110 @@ +"""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, comparators=()): + gates = root / ".local/allocation-group-gates" + candidates = [gates / name for name in ("gate.py", "source-proof.py")] + helpers + helper_bytes = [path.read_bytes() for path in candidates] + comparator_paths = [root / ".local/process-source-evidence-tools/verify.py", *comparators] + comparator_bytes = [path.read_bytes() for path in comparator_paths if path.is_file()] + 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 + helper_catalog = {} + for name, data in zip(("gate.py", "source-proof.py"), helper_bytes): + helper = "helpers/" + pin(data)["sha256"] + ".py" + add(helper, data) + helper_catalog[name] = helper + for run in runs: + folder = gates / run + need(folder.is_dir(), "run directory missing: " + run) + paths = list(folder.iterdir()) + names = {path.name for path in paths} + need(all(path.is_file() and not path.is_symlink() for path in paths) + and names in (RAW_FILES, RAW_FILES | {"source-proof.json"}), "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 + if "source-proof.json" in raw: + proof = parsed(raw["source-proof.json"]) + matches = [data for data in comparator_bytes if pin(data) == proof["comparator"]] + need(bool(matches), "exact source comparator unavailable for " + run + "; supply --comparator PATH") + comparator = "comparators/" + proof["comparator"]["sha256"] + ".py" + add(comparator, matches[0]) + specs[run]["sourceProof"] = {"commit": proof["commit"], "pin": pin(raw["source-proof.json"]), "comparator": comparator} + manifest = {"schema": 1, "kind": "allocation-multiselect-cargo-gates", "runs": specs, + "captureHelpers": helper_catalog, + "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, required=True, help="checkout holding .local/allocation-group-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("--comparator", type=Path, action="append", default=[], help="additional exact historical source comparator (repeatable)") + parser.add_argument("--output", type=Path, help="fresh, nonexistent package directory (required unless --check-only)") + 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") + need(args.check_only or args.output is not None, "--output is required unless --check-only") + if not args.check_only: + need(not args.output.exists(), "output must be a fresh directory: " + str(args.output)) + payload, manifest, summary = collect(args.root.resolve(), args.runs, args.helper, args.comparator) + if args.check_only: + print("PASS raw consistency; no package written:", summary) + return + out = args.output.resolve() + out.mkdir(parents=True, exist_ok=False) + 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() diff --git a/harness/allocation-arrange-evidence-20260923/checks/verify.py b/harness/allocation-arrange-evidence-20260923/checks/verify.py new file mode 100644 index 00000000..f6e1cfa0 --- /dev/null +++ b/harness/allocation-arrange-evidence-20260923/checks/verify.py @@ -0,0 +1,194 @@ +"""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"] == "allocation-multiselect-cargo-gates", "manifest schema") + runs, logical, stored = manifest["runs"], manifest["logicalFiles"], manifest["members"] + helpers = manifest["captureHelpers"] + need(isinstance(helpers, dict) and set(helpers) == {"gate.py", "source-proof.py"}, "capture helper names") + need(all(isinstance(name, str) and re.fullmatch(r"helpers/[0-9a-f]{64}\.py", name) + for name in helpers.values()), "capture helper mapping") + 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()) + expected_logical.update(helpers.values()) + for run, spec in runs.items(): + if "sourceProof" in spec: + proof_spec = spec["sourceProof"] + need(set(proof_spec) == {"commit", "pin", "comparator"}, run + ": exact source proof spec") + need(isinstance(proof_spec["commit"], str) and re.fullmatch(r"[0-9a-f]{40}", proof_spec["commit"]), run + ": named full source commit") + need(valid_pin(proof_spec["pin"]), run + ": source proof pin") + need(isinstance(proof_spec["comparator"], str) and re.fullmatch(r"comparators/[0-9a-f]{64}\.py", proof_spec["comparator"]), run + ": comparator path") + expected_logical.update({"cargo/" + run + "/source-proof.json", proof_spec["comparator"]}) + 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]) + for name, helper in helpers.items(): + need(helper == "helpers/" + pin(raw(helper))["sha256"] + ".py", "named helper hash: " + 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") + if "sourceProof" in spec: + proof_spec = spec["sourceProof"] + proof_bytes = raw(prefix + "source-proof.json") + proof = parsed(proof_bytes) + need(pin(proof_bytes) == proof_spec["pin"], run + ": source proof pinned bytes") + need(proof["run"] == run and proof["commit"] == proof_spec["commit"], run + ": proof run/commit identity") + need(proof["status"] == "PASS exact captured input inventory and Git blob bytes", run + ": source proof status") + need(proof["inputCount"] == len(inputs) and proof["inputMap"] == result["inputs"], run + ": proof input relationship") + need(proof["result"] == pin(raw(prefix + "result.json")), run + ": proof result relationship") + need(ordered_times(result["finish"], proof["verified"]), run + ": proof time after closed gate") + comparator = proof_spec["comparator"] + need(valid_pin(proof["comparator"]) and pin(raw(comparator)) == proof["comparator"], run + ": comparator bytes") + need(comparator == "comparators/" + proof["comparator"]["sha256"] + ".py", run + ": comparator identity") + 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), "recordedFailures": sum(spec["exitCode"] != 0 for spec in runs.values()), + "logicalFiles": len(logical), "storedMembers": len(stored), "sourceCapsulePairs": len(seen_capsules), + "captureHelpers": helpers, "sourceProofs": sum("sourceProof" in spec for spec in runs.values())} + + +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() diff --git a/harness/allocation-arrange-evidence-20260923/reviews/README.md b/harness/allocation-arrange-evidence-20260923/reviews/README.md new file mode 100644 index 00000000..04294bf9 --- /dev/null +++ b/harness/allocation-arrange-evidence-20260923/reviews/README.md @@ -0,0 +1,26 @@ +# Preserved independent review records + +[reviews.zip](reviews.zip) contains six exact historical Markdown records, +pinned by [members.json](members.json). Their original context was +`.local/allocation-group-design/` in the reviewed checkout. Relative links and +line references inside this historical archive retain that context and reviewed +source version; they are not links to an installed peer workspace. + +| Record | Scope | +| --- | --- | +| critique.md | Initial combined assignment/Arrange proposal criticism | +| fresh-policy-critique.md | Independent policy alternatives and falsifiers | +| experiment/v2-review.md | Four-family experiment; SA port sizing oracle defect | +| selection-boundary.md | Exact typed App selection versus pure placement API | +| input-boundaries.md | Pending click, irreversible threshold and input integration obligations | +| arrange-planner-review.md | Fresh pure-planner review plus R1/R2 test-disposition addendum | + +The [accepted contract](../../allocation-arrange-amendment-20260923.md) and +[result report](../../allocation-arrange-planner-20260923.md) are authoritative for +current decisions and acceptance. Historical recommendations or proposed APIs +inside the archive do not supersede them. Root verified reported execution +separately; review authors did not independently run Cargo or native workflows. + +The report records remaining P3 coverage suggestions and App/native gaps. +The archive preserves earlier findings even when the dated addendum addresses +them. [files.json](files.json) pins this index, the archive and its member map. diff --git a/harness/allocation-arrange-evidence-20260923/reviews/files.json b/harness/allocation-arrange-evidence-20260923/reviews/files.json new file mode 100644 index 00000000..481384f7 --- /dev/null +++ b/harness/allocation-arrange-evidence-20260923/reviews/files.json @@ -0,0 +1,14 @@ +{ + "README.md": { + "bytes": 1576, + "sha256": "b25dc1db4a8a46f5f41381426a237f1a21f21f8b18c0bbfc8590b6f91804d03b" + }, + "reviews.zip": { + "bytes": 28248, + "sha256": "d9202f0c6b82ad383de3b4e9e84bb0457f62ae53f1511c458294b0593753e2f1" + }, + "members.json": { + "bytes": 831, + "sha256": "2edee904da60ca409192a6152952b556d61bcb36cddcfcfce29fd65476a4fba7" + } +} \ No newline at end of file diff --git a/harness/allocation-arrange-evidence-20260923/reviews/members.json b/harness/allocation-arrange-evidence-20260923/reviews/members.json new file mode 100644 index 00000000..637eeed8 --- /dev/null +++ b/harness/allocation-arrange-evidence-20260923/reviews/members.json @@ -0,0 +1,26 @@ +{ + "critique.md": { + "bytes": 8955, + "sha256": "a69c27b472f27e06567a216836b42c1593956f23616a6d12ef6ec61c1cf29bdb" + }, + "fresh-policy-critique.md": { + "bytes": 15227, + "sha256": "d9ce5c5e6b3f61c8f5933424e178a476f65e262a8990380021c461fd7bcbe38b" + }, + "experiment/v2-review.md": { + "bytes": 5901, + "sha256": "46439cdf44cef6d95b8b45df1ba93fd27ca9b78c9470e4c5b9e166ce3082a1f2" + }, + "selection-boundary.md": { + "bytes": 6420, + "sha256": "f7971131bac65cde48810abb629695a5a3d0a0aa5b8d115b5fa141b8c3fe5988" + }, + "input-boundaries.md": { + "bytes": 16200, + "sha256": "81a2b55d3dde1f658bf10811d06fa0bf1759afc90aa616a376a754cec5af6d22" + }, + "arrange-planner-review.md": { + "bytes": 13461, + "sha256": "6171cd269a261ebf255fd75b04a155b78cdb359940b164557a3bc2409b7df3b8" + } +} \ No newline at end of file diff --git a/harness/allocation-arrange-evidence-20260923/reviews/reviews.zip b/harness/allocation-arrange-evidence-20260923/reviews/reviews.zip new file mode 100644 index 00000000..dcd8adda Binary files /dev/null and b/harness/allocation-arrange-evidence-20260923/reviews/reviews.zip differ diff --git a/harness/allocation-arrange-planner-20260923.md b/harness/allocation-arrange-planner-20260923.md new file mode 100644 index 00000000..e2df9f1e --- /dev/null +++ b/harness/allocation-arrange-planner-20260923.md @@ -0,0 +1,136 @@ +# Allocation Arrange policy and pure planner — 2026-09-23 + +Codex-9e8r6t contribution to [issue 29](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/29), above Process delivery `6d99aacab8ca1a91c857a2179baa14d80a2901e9`. +**Pure planner verified; App integration and native acceptance remain open.** +This is not a release or full Allocation/multiselect completion claim. + +## Result and decision + +The [accepted amendment](allocation-arrange-amendment-20260923.md) defines explicit +Arrange for one or more work/performer placements. It preserves engineering +records and moves one union: semantic descendants of explicitly selected work +plus accepted visual descendants of explicitly selected performers. It does not +acquire incidental semantic parents or siblings. Ordinary assignment retains its +existing per-root responsibility and coherence rules. + +The experiment compared strict refusal, visible-selected-only movement and this +combined footprint. Strict refusal discarded valid semantics-preserving layouts; +visible-only movement omitted hidden/semantic descendants. Actual OA/SA examples +support the bounded union policy, subject to complete geometry checks. There is +no automatic packing, convergence, semantic reparenting or target unfolding. + +Both visible and required diagnostic scenes check **all** unrelated card pairs. +An unselected performer's derived enclosure can grow into an obstacle even when +none of the moved cards touches it. Diagnostics unfold affected placements and +their required visual ancestors; unrelated stationary folded interiors remain +outside that domain. The experiment explicitly distinguishes this policy from +universal unfolding. Future expansion can reveal overlap. + +At source **9b83f7ca0dcd34056765b9399863a21da72c3a9f**, +[`arrange.rs`](../crates/oa-desktop/src/allocation_drag/arrange.rs) implements +immutable capture/place/finish, exact original placement identities, one snapped +anchor delta, route translation using actual accepted endpoint owners, combined +projection, every selected header, stale/tamper rejection and an exact final +Project comparison. Motion reuses the semantic index and captured footprint; +it does not clone/validate a Project per pointer position. Pairwise collision +cost remains real; no large-scene latency benchmark is claimed. + +The planner's ID map is deliberately not an input capability. App must validate +the entire original typed selection before conversion, retaining its exact +snapshot/generation and clean-owner lifetime. The new module is not yet connected +to App; its temporary dead-code annotation states that boundary. The only change +to singleton capture extracts its already-validated fact collector; ordinary +singleton policy is unchanged. + +## Exact verification + +All twelve closed runs are preserved in the [portable evidence](allocation-arrange-evidence-20260923/checks/README.md). +Every run reports unchanged captured inputs, including all 71 patched Iced files. +Recorded compile failures remain failures. Historical HEAD alone does not identify +uncommitted captured inputs; named source proofs compare the complete relevant +Git inventory and every blob byte with the captured map. + +| Gates | Observed result | Exact source | +| --- | --- | --- | +| experiment-1 | 2 OA experiment tests pass | 743 blobs at `3a7923b96d671041cbc747a614cbd0fad6b6b3d3` | +| experiment-2 | Compile failure: imported module resolved desktop-wide tests | Captured capsule retained; no named-commit proof | +| experiment-3 | Four OA/SA experiment families pass, 57 filtered | 744 blobs at `8dbbaf262e3b8d26a32d6c30f0cc5f963fab47a0` | +| planner-document-1 | All 61 planner/Document module tests pass | Same `8dbbaf2` bytes | +| probe-lint-1 / format-1 | Strict scoped Clippy and workspace format pass | Same `8dbbaf2` bytes | +| arrange-planner-1 / -2 | Compile failures: missing explicit module path, then test temporary-array borrow | Both exact capsules retained | +| arrange-planner-3 | 67 tests pass, 2.44s | Earlier captured planner/tests; not labelled as `8dbbaf2` | +| arrange-planner-4 | **70 tests pass**, 2.77s | 746 blobs at `9b83f7ca0dcd34056765b9399863a21da72c3a9f` | +| arrange-lint-1 / arrange-format-1 | Strict scoped Clippy and workspace format pass | Same `9b83f7c` bytes | + +The final input-map SHA-256 is +`c8192fd84fba63fa576975e9fd44f0fa2d6c8ed2e6b5ada3fd4bee076d36f686`. +The 70 include nine new Arrange test families and the existing 61 module tests. +Literal full-Project oracles check engineering records, sizes/folds, other views, +internal and one-ended saved routes. Actual Document execution checks compiled +source, reopen and one exact Undo/Redo. Further cases exercise either selected +anchor, parent/child deduplication, role-only work, unplaced leaves, simultaneous +vacated-space movement, preexisting-overlap preservation versus growth, hidden +collision versus unselected enclosure growth, no-op, stale/tampered results and +all selected headers. These are pure/source checks, not native gesture evidence. + +The first experiment's untouched fresh-Document assertion was not atomic refusal +evidence; the successor removed that overstatement. Independent review also +corrected SA oracle literals before execution: FunctionalExchange auto-ports +raise projected work height from OA 94 to SA 132, enclosure height 218 to 256, +and the hidden overlap area 13696 to 20116. Production sizing was not changed to +fit an oracle. + +## Independent review and remaining checks + +[Preserved reviews](allocation-arrange-evidence-20260923/reviews/README.md) record +policy criticism, the SA oracle finding, the selection boundary and fresh planner +review. No concrete planner defect was found. Review R1/R2 led to independent +collision-cause discriminators, a singleton-refuses/combined-passes vacated-space +case, and unchanged-overlap versus enlargement controls. The final planner +production bytes are unchanged from the reviewed 67-test capture; only +`arrange_tests.rs` differs in the 70-test capture. + +Deeper nested folded ancestry, explicit endpoint attachment fixtures and broader +tamper/non-grid cases remain protective P3 coverage suggestions, not reproduced +defects or completed checks. App must retain minimum readable text scale; the +pure planner validates world-space header containment and finite positive zoom. + +App integration must still prove exact whole typed-set admission, deferred +selected-member click collapse, an irreversible drag threshold, one-shot Arrange +on both performer/work anchors, dirty/source-failure preservation, stale selection +generation, cancellation, frozen geometry and truthful preview/counts. Ordinary +work-only group assignment needs its own combined semantic candidate. OA/SA native +workflows, actual Save/reopen, Scenario group operations and aggregate deletion +remain open. Existing Process routing overlap is not solved here. Linux/WSL +verification remains user-paused. + +## Reproduction and portable records + +Verified on Windows x86_64 gnullvm, Rust/Cargo 1.98.1, LLVM-MinGW20260826, +one build job and incremental compilation disabled. Initialize the documented +project toolchain/DLL environment before invoking Cargo. From the named source: + +```powershell +$env:CARGO_BUILD_JOBS='1' +$env:CARGO_INCREMENTAL='0' +cargo test -p oa-desktop --example allocation_policy_probe --message-format=json -- --test-threads=1 +cargo clippy -p oa-desktop --example allocation_policy_probe --tests --message-format=json -- -D warnings +cargo fmt --all -- --check +python -B harness/allocation-arrange-evidence-20260923/checks/verify.py +``` + +The focused example imports the actual planner and Document modules. It does not +replace desktop/App/native acceptance. The initial full desktop experiment +reported 30m42s compile time; the focused successor reported 7.11s. This useful +iteration improvement is not a controlled benchmark: target graphs, warm artifacts +and machine state differ. + +The package retains twelve runs, three compile failures, eight named source +proofs, 95 logical files in 85 stored members and seven deduplicated source +capsule pairs. Offline verification checks exact inventories, pins, streams, +receipts, source capsule contents and proof relationships; it does not execute +archived code, independently authenticate logs or repeat Git comparisons. +The packager's corruption checks rejected eleven changed relationships and an +attempt to overwrite an existing output. Root verified all eleven exact staged +evidence files in a fresh export: the portable checks verifier and every review +archive/member pin passed without consulting working-copy evidence bytes. diff --git a/harness/allocation-assignment-evidence-20260923/.gitattributes b/harness/allocation-assignment-evidence-20260923/.gitattributes new file mode 100644 index 00000000..48f590d2 --- /dev/null +++ b/harness/allocation-assignment-evidence-20260923/.gitattributes @@ -0,0 +1 @@ +** -text whitespace=cr-at-eol diff --git a/harness/allocation-assignment-evidence-20260923/checks/README.md b/harness/allocation-assignment-evidence-20260923/checks/README.md new file mode 100644 index 00000000..029f78f5 --- /dev/null +++ b/harness/allocation-assignment-evidence-20260923/checks/README.md @@ -0,0 +1,24 @@ +# Allocation captured-gate evidence tools + +Adapted from the reviewed Process prepare/verify/selfcheck tools. Read-only preparation consumes explicitly selected **closed** runs in CHECKOUT/.local/allocation-group-gates. No Cargo, source execution, checkout, GUI, network or archived helper execution occurs. + +```powershell +python -B prepare.py --root CHECKOUT --runs experiment-1 experiment-2 experiment-3 planner-document-1 probe-lint-1 format-1 --check-only +``` + +Keep failed experiment-2: verification preserves its recorded compile failure rather than converting archive consistency into test success. The example selects the first six closed gates. Later closed planner runs can be explicitly listed with the same interface; the final package manifest is authoritative for its exact inventory. No implicit discovery includes active directories. + +To create a portable package, replace --check-only with `--output NEW_DIRECTORY`. Existing output refuses. The exact seven raw files are required, with only `source-proof.json` accepted as an optional eighth file. No raw byte normalization or receipt rewriting occurs. Maps and nested input ZIPs deduplicate by SHA-256; other logical raw files keep their names. All original helper bytes, failed-run streams and optional proof bytes remain archived. The Allocation-specific manifest kind distinguishes this extension from the seven-file Process schema. + +The gate.py and source-proof.py helper catalog is preserved. Every gate's recorded helper must match available bytes; use repeatable `--helper PATH` for older captures. If a proof exists, its exact comparator bytes must be available at the default `.local/process-source-evidence-tools/verify.py` or a repeatable `--comparator PATH`. They are archived under their content hash and never executed. + +An optional source proof is independently pinned in the manifest with its full named commit. Verification binds it to the enclosing run, exact result receipt, captured map/count, completed gate time, status and comparator bytes/hash. Captured HEAD is preserved as historical metadata and is not substituted for the named source commit. This packager checks historical proof consistency; it does **not** repeat Git blob comparison. A source proof on a failed gate is source-identity evidence only, never a passing-test claim. + +```powershell +python -B PACKAGE/verify.py +python -B selfcheck.py --root CHECKOUT --runs experiment-1 experiment-2 experiment-3 planner-document-1 probe-lint-1 format-1 --output TOOLS/NEW_SELFCHECK_DIRECTORY +``` + +The verifier checks pins, exact ZIP inventory, process/start/result relationships, streams, command/exit identities, source capsules, vendor inventory and Cargo artifact relationships. Selfchecks compare packaged raw bytes to originals, reject existing output, and mutate only in-memory copies to test stream/helper/exit/capsule/proof corruption. The package contains tools, manifest, files.json and evidence.zip; selfcheck is a preparation utility, not packaged runtime input. + +Limits: internal consistency is not external authentication, hermetic build proof, native usability, a new source comparison or production acceptance. Review exact run results and source identity separately. Run with `python -B` to avoid incidental bytecode writes. diff --git a/harness/allocation-assignment-evidence-20260923/checks/evidence.zip b/harness/allocation-assignment-evidence-20260923/checks/evidence.zip new file mode 100644 index 00000000..714e3abf Binary files /dev/null and b/harness/allocation-assignment-evidence-20260923/checks/evidence.zip differ diff --git a/harness/allocation-assignment-evidence-20260923/checks/files.json b/harness/allocation-assignment-evidence-20260923/checks/files.json new file mode 100644 index 00000000..e3da90ad --- /dev/null +++ b/harness/allocation-assignment-evidence-20260923/checks/files.json @@ -0,0 +1,22 @@ +{ + "prepare.py": { + "bytes": 6316, + "sha256": "aa52592fb8e373c2a90007e187734d29129d38d9fce9dc45461482c7f6376bff" + }, + "verify.py": { + "bytes": 11440, + "sha256": "584187370a1076e13190914158fc72a9ab65f07e72cc6a0d3b5c319f3dc7f05f" + }, + "README.md": { + "bytes": 3284, + "sha256": "3ff0a61fb15ca26e54b8903962c99ba7802a87f0204e7b71f2d04c812b9588b7" + }, + "manifest.json": { + "bytes": 17748, + "sha256": "f053e2f1492ee4fdfa2d4dfd7e48c5768d098cf96b629471699810d06cb61537" + }, + "evidence.zip": { + "bytes": 8984108, + "sha256": "51b898205ee7a2907ce746389882f0607f7ad7290a1759b392cd307ef9e9ef5a" + } +} diff --git a/harness/allocation-assignment-evidence-20260923/checks/manifest.json b/harness/allocation-assignment-evidence-20260923/checks/manifest.json new file mode 100644 index 00000000..ae74d46b --- /dev/null +++ b/harness/allocation-assignment-evidence-20260923/checks/manifest.json @@ -0,0 +1,396 @@ +{ + "schema": 1, + "kind": "allocation-multiselect-cargo-gates", + "runs": { + "assignment-planner-1": { + "command": [ + "cargo", + "test", + "-p", + "oa-desktop", + "--example", + "allocation_policy_probe", + "--message-format=json", + "--", + "--test-threads=1" + ], + "head": "eed80ffd6e94c7127491de1fd45c0415685f6328", + "exitCode": 0, + "inputCount": 749, + "helper": "helpers/1368375b1fba0dfc50b77ea4f406dba71960a78317ce8f259d4c4a0254863a20.py" + }, + "assignment-planner-2": { + "command": [ + "cargo", + "test", + "-p", + "oa-desktop", + "--example", + "allocation_policy_probe", + "--message-format=json", + "--", + "--test-threads=1" + ], + "head": "eed80ffd6e94c7127491de1fd45c0415685f6328", + "exitCode": 0, + "inputCount": 750, + "helper": "helpers/1368375b1fba0dfc50b77ea4f406dba71960a78317ce8f259d4c4a0254863a20.py" + }, + "assignment-lint-1": { + "command": [ + "cargo", + "clippy", + "-p", + "oa-desktop", + "--example", + "allocation_policy_probe", + "--tests", + "--message-format=json", + "--", + "-D", + "warnings" + ], + "head": "eed80ffd6e94c7127491de1fd45c0415685f6328", + "exitCode": 0, + "inputCount": 750, + "helper": "helpers/1368375b1fba0dfc50b77ea4f406dba71960a78317ce8f259d4c4a0254863a20.py" + }, + "assignment-format-1": { + "command": [ + "cargo", + "fmt", + "--all", + "--", + "--check" + ], + "head": "eed80ffd6e94c7127491de1fd45c0415685f6328", + "exitCode": 0, + "inputCount": 750, + "helper": "helpers/1368375b1fba0dfc50b77ea4f406dba71960a78317ce8f259d4c4a0254863a20.py" + }, + "assignment-planner-3": { + "command": [ + "cargo", + "test", + "-p", + "oa-desktop", + "--example", + "allocation_policy_probe", + "--message-format=json", + "--", + "--test-threads=1" + ], + "head": "33e880103c5fa935a4431ec3dc089e2c4c92f680", + "exitCode": 0, + "inputCount": 750, + "helper": "helpers/1368375b1fba0dfc50b77ea4f406dba71960a78317ce8f259d4c4a0254863a20.py", + "sourceProof": { + "commit": "33e880103c5fa935a4431ec3dc089e2c4c92f680", + "pin": { + "bytes": 599, + "sha256": "3b249398b0ade218ec9737677e1021e21eae7aa03deeb6bc071e0e7e7fe6182b" + }, + "comparator": "comparators/7805832d5f563ab2d7a50dd3de700792b5d6f459c68f7b1d8bcfcc9800fa0c3a.py" + } + }, + "assignment-lint-2": { + "command": [ + "cargo", + "clippy", + "-p", + "oa-desktop", + "--example", + "allocation_policy_probe", + "--tests", + "--message-format=json", + "--", + "-D", + "warnings" + ], + "head": "33e880103c5fa935a4431ec3dc089e2c4c92f680", + "exitCode": 0, + "inputCount": 750, + "helper": "helpers/1368375b1fba0dfc50b77ea4f406dba71960a78317ce8f259d4c4a0254863a20.py", + "sourceProof": { + "commit": "33e880103c5fa935a4431ec3dc089e2c4c92f680", + "pin": { + "bytes": 596, + "sha256": "3a8f4d17bc6e696f8c0a1373f739650da5aa5b84683478c697e71b280d07b430" + }, + "comparator": "comparators/7805832d5f563ab2d7a50dd3de700792b5d6f459c68f7b1d8bcfcc9800fa0c3a.py" + } + }, + "assignment-format-2": { + "command": [ + "cargo", + "fmt", + "--all", + "--", + "--check" + ], + "head": "33e880103c5fa935a4431ec3dc089e2c4c92f680", + "exitCode": 0, + "inputCount": 750, + "helper": "helpers/1368375b1fba0dfc50b77ea4f406dba71960a78317ce8f259d4c4a0254863a20.py", + "sourceProof": { + "commit": "33e880103c5fa935a4431ec3dc089e2c4c92f680", + "pin": { + "bytes": 598, + "sha256": "41553cf5d5e5f6414930ad088fa82515630d3478da53a5ac961c8bf4191d1bc6" + }, + "comparator": "comparators/7805832d5f563ab2d7a50dd3de700792b5d6f459c68f7b1d8bcfcc9800fa0c3a.py" + } + } + }, + "captureHelpers": { + "gate.py": "helpers/1368375b1fba0dfc50b77ea4f406dba71960a78317ce8f259d4c4a0254863a20.py", + "source-proof.py": "helpers/58597566ae1cb5c4021ddc7e86635b76d127bdb63b5d3026c427771aae7e2119.py" + }, + "logicalFiles": { + "cargo/assignment-format-1/inputs.json": "capsules/e41e7d1a2b9519b78730821f96c1bf7ca8431935fe67efa203daa80d3f15c437.json", + "cargo/assignment-format-1/inputs.zip": "capsules/b471d434d8eac790ef7cc9073be0d395c9c9ad1472ba06a05b5818269ef03dfe.zip", + "cargo/assignment-format-1/process.json": "cargo/assignment-format-1/process.json", + "cargo/assignment-format-1/result.json": "cargo/assignment-format-1/result.json", + "cargo/assignment-format-1/started.json": "cargo/assignment-format-1/started.json", + "cargo/assignment-format-1/stderr.log": "cargo/assignment-format-1/stderr.log", + "cargo/assignment-format-1/stdout.log": "cargo/assignment-format-1/stdout.log", + "cargo/assignment-format-2/inputs.json": "capsules/ce988ecaeaca395d0f120844db7e3da90d039633beef10d2d4a8b8f19e92ee27.json", + "cargo/assignment-format-2/inputs.zip": "capsules/ae03a0a847a5c2000ba46e356e3dcd13a219e5fb699be8ad71a619b593e13c23.zip", + "cargo/assignment-format-2/process.json": "cargo/assignment-format-2/process.json", + "cargo/assignment-format-2/result.json": "cargo/assignment-format-2/result.json", + "cargo/assignment-format-2/source-proof.json": "cargo/assignment-format-2/source-proof.json", + "cargo/assignment-format-2/started.json": "cargo/assignment-format-2/started.json", + "cargo/assignment-format-2/stderr.log": "cargo/assignment-format-2/stderr.log", + "cargo/assignment-format-2/stdout.log": "cargo/assignment-format-2/stdout.log", + "cargo/assignment-lint-1/inputs.json": "capsules/e41e7d1a2b9519b78730821f96c1bf7ca8431935fe67efa203daa80d3f15c437.json", + "cargo/assignment-lint-1/inputs.zip": "capsules/b471d434d8eac790ef7cc9073be0d395c9c9ad1472ba06a05b5818269ef03dfe.zip", + "cargo/assignment-lint-1/process.json": "cargo/assignment-lint-1/process.json", + "cargo/assignment-lint-1/result.json": "cargo/assignment-lint-1/result.json", + "cargo/assignment-lint-1/started.json": "cargo/assignment-lint-1/started.json", + "cargo/assignment-lint-1/stderr.log": "cargo/assignment-lint-1/stderr.log", + "cargo/assignment-lint-1/stdout.log": "cargo/assignment-lint-1/stdout.log", + "cargo/assignment-lint-2/inputs.json": "capsules/ce988ecaeaca395d0f120844db7e3da90d039633beef10d2d4a8b8f19e92ee27.json", + "cargo/assignment-lint-2/inputs.zip": "capsules/ae03a0a847a5c2000ba46e356e3dcd13a219e5fb699be8ad71a619b593e13c23.zip", + "cargo/assignment-lint-2/process.json": "cargo/assignment-lint-2/process.json", + "cargo/assignment-lint-2/result.json": "cargo/assignment-lint-2/result.json", + "cargo/assignment-lint-2/source-proof.json": "cargo/assignment-lint-2/source-proof.json", + "cargo/assignment-lint-2/started.json": "cargo/assignment-lint-2/started.json", + "cargo/assignment-lint-2/stderr.log": "cargo/assignment-lint-2/stderr.log", + "cargo/assignment-lint-2/stdout.log": "cargo/assignment-lint-2/stdout.log", + "cargo/assignment-planner-1/inputs.json": "capsules/4e237aa6ba6359490d65efd40e15a697991ce53350af2080503c0635d2d9de3e.json", + "cargo/assignment-planner-1/inputs.zip": "capsules/1bb6e8b18022977f43e29bd44d25238acbf8c424bd4bf736474fce74bd8de8bc.zip", + "cargo/assignment-planner-1/process.json": "cargo/assignment-planner-1/process.json", + "cargo/assignment-planner-1/result.json": "cargo/assignment-planner-1/result.json", + "cargo/assignment-planner-1/started.json": "cargo/assignment-planner-1/started.json", + "cargo/assignment-planner-1/stderr.log": "cargo/assignment-planner-1/stderr.log", + "cargo/assignment-planner-1/stdout.log": "cargo/assignment-planner-1/stdout.log", + "cargo/assignment-planner-2/inputs.json": "capsules/e41e7d1a2b9519b78730821f96c1bf7ca8431935fe67efa203daa80d3f15c437.json", + "cargo/assignment-planner-2/inputs.zip": "capsules/b471d434d8eac790ef7cc9073be0d395c9c9ad1472ba06a05b5818269ef03dfe.zip", + "cargo/assignment-planner-2/process.json": "cargo/assignment-planner-2/process.json", + "cargo/assignment-planner-2/result.json": "cargo/assignment-planner-2/result.json", + "cargo/assignment-planner-2/started.json": "cargo/assignment-planner-2/started.json", + "cargo/assignment-planner-2/stderr.log": "cargo/assignment-planner-2/stderr.log", + "cargo/assignment-planner-2/stdout.log": "cargo/assignment-planner-2/stdout.log", + "cargo/assignment-planner-3/inputs.json": "capsules/ce988ecaeaca395d0f120844db7e3da90d039633beef10d2d4a8b8f19e92ee27.json", + "cargo/assignment-planner-3/inputs.zip": "capsules/ae03a0a847a5c2000ba46e356e3dcd13a219e5fb699be8ad71a619b593e13c23.zip", + "cargo/assignment-planner-3/process.json": "cargo/assignment-planner-3/process.json", + "cargo/assignment-planner-3/result.json": "cargo/assignment-planner-3/result.json", + "cargo/assignment-planner-3/source-proof.json": "cargo/assignment-planner-3/source-proof.json", + "cargo/assignment-planner-3/started.json": "cargo/assignment-planner-3/started.json", + "cargo/assignment-planner-3/stderr.log": "cargo/assignment-planner-3/stderr.log", + "cargo/assignment-planner-3/stdout.log": "cargo/assignment-planner-3/stdout.log", + "comparators/7805832d5f563ab2d7a50dd3de700792b5d6f459c68f7b1d8bcfcc9800fa0c3a.py": "comparators/7805832d5f563ab2d7a50dd3de700792b5d6f459c68f7b1d8bcfcc9800fa0c3a.py", + "helpers/1368375b1fba0dfc50b77ea4f406dba71960a78317ce8f259d4c4a0254863a20.py": "helpers/1368375b1fba0dfc50b77ea4f406dba71960a78317ce8f259d4c4a0254863a20.py", + "helpers/58597566ae1cb5c4021ddc7e86635b76d127bdb63b5d3026c427771aae7e2119.py": "helpers/58597566ae1cb5c4021ddc7e86635b76d127bdb63b5d3026c427771aae7e2119.py" + }, + "members": { + "capsules/1bb6e8b18022977f43e29bd44d25238acbf8c424bd4bf736474fce74bd8de8bc.zip": { + "bytes": 3008043, + "sha256": "1bb6e8b18022977f43e29bd44d25238acbf8c424bd4bf736474fce74bd8de8bc" + }, + "capsules/4e237aa6ba6359490d65efd40e15a697991ce53350af2080503c0635d2d9de3e.json": { + "bytes": 125475, + "sha256": "4e237aa6ba6359490d65efd40e15a697991ce53350af2080503c0635d2d9de3e" + }, + "capsules/ae03a0a847a5c2000ba46e356e3dcd13a219e5fb699be8ad71a619b593e13c23.zip": { + "bytes": 3013577, + "sha256": "ae03a0a847a5c2000ba46e356e3dcd13a219e5fb699be8ad71a619b593e13c23" + }, + "capsules/b471d434d8eac790ef7cc9073be0d395c9c9ad1472ba06a05b5818269ef03dfe.zip": { + "bytes": 3013606, + "sha256": "b471d434d8eac790ef7cc9073be0d395c9c9ad1472ba06a05b5818269ef03dfe" + }, + "capsules/ce988ecaeaca395d0f120844db7e3da90d039633beef10d2d4a8b8f19e92ee27.json": { + "bytes": 125659, + "sha256": "ce988ecaeaca395d0f120844db7e3da90d039633beef10d2d4a8b8f19e92ee27" + }, + "capsules/e41e7d1a2b9519b78730821f96c1bf7ca8431935fe67efa203daa80d3f15c437.json": { + "bytes": 125659, + "sha256": "e41e7d1a2b9519b78730821f96c1bf7ca8431935fe67efa203daa80d3f15c437" + }, + "cargo/assignment-format-1/process.json": { + "bytes": 116, + "sha256": "ea332ddf4129442f2a8f19b150a6c2fc21a41299eadc32287ba6ba67769e2b58" + }, + "cargo/assignment-format-1/result.json": { + "bytes": 1872, + "sha256": "46047bb39986083c89d12751153b678eedd660963b9dafb02c1738986f0dd47a" + }, + "cargo/assignment-format-1/started.json": { + "bytes": 1484, + "sha256": "3657a2a7ac270bca405c128328557fb5e1725f913ae0c9c5baa1ba08269bb501" + }, + "cargo/assignment-format-1/stderr.log": { + "bytes": 0, + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "cargo/assignment-format-1/stdout.log": { + "bytes": 0, + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "cargo/assignment-format-2/process.json": { + "bytes": 116, + "sha256": "8371d4a455f0dd7678c5b8fb91f679355c05f9f0594985460aee9796bd6adc65" + }, + "cargo/assignment-format-2/result.json": { + "bytes": 1872, + "sha256": "319d67031a3f30f70b21f869d755b91196ce0be68d8132775288b3d86b223d06" + }, + "cargo/assignment-format-2/source-proof.json": { + "bytes": 598, + "sha256": "41553cf5d5e5f6414930ad088fa82515630d3478da53a5ac961c8bf4191d1bc6" + }, + "cargo/assignment-format-2/started.json": { + "bytes": 1483, + "sha256": "a59985096b89da3518c367131b3ec257596725d18f62307bfc174ccb7969c362" + }, + "cargo/assignment-format-2/stderr.log": { + "bytes": 0, + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "cargo/assignment-format-2/stdout.log": { + "bytes": 0, + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "cargo/assignment-lint-1/process.json": { + "bytes": 213, + "sha256": "b14d908c775b772f6993b40965a21ae894a406665fa5c2794ebfdb7ffe082769" + }, + "cargo/assignment-lint-1/result.json": { + "bytes": 2006, + "sha256": "a8c6cc8a726c9bee821b2c12a0768d860bb1c40261783bcc23da2ea19a677959" + }, + "cargo/assignment-lint-1/started.json": { + "bytes": 1610, + "sha256": "3f42270e22476b1698f19cfdc8cd6c78b0754480b613963d582d98d89bf541f6" + }, + "cargo/assignment-lint-1/stderr.log": { + "bytes": 137, + "sha256": "d64f9789f0e3e22b0c98af77d19231a9026083ad317f6b2f989ebebd556f1269" + }, + "cargo/assignment-lint-1/stdout.log": { + "bytes": 268845, + "sha256": "98a24be8306e586a1acb6a72c3e62005afc0db01fa4ec0b242c420aafadcf779" + }, + "cargo/assignment-lint-2/process.json": { + "bytes": 212, + "sha256": "17b1f10f739cdf456da58ef2dcd9c15a8569132904876b8e2289ea543f1217d7" + }, + "cargo/assignment-lint-2/result.json": { + "bytes": 2005, + "sha256": "ec3bc72c846cc08188ff95d38aa743c52ac9ec2c616fb8acb7566a6619490b68" + }, + "cargo/assignment-lint-2/source-proof.json": { + "bytes": 596, + "sha256": "3a8f4d17bc6e696f8c0a1373f739650da5aa5b84683478c697e71b280d07b430" + }, + "cargo/assignment-lint-2/started.json": { + "bytes": 1610, + "sha256": "afdad6e675e3351c749dccea6759c01f8b303a0d598f41592f62beecbc94fdf7" + }, + "cargo/assignment-lint-2/stderr.log": { + "bytes": 137, + "sha256": "6ceb01ea1a2f7759c0f7e4fcc98504fae7f6f9cb758b16a2701f804c88ec401d" + }, + "cargo/assignment-lint-2/stdout.log": { + "bytes": 268845, + "sha256": "8b993ec00e2a4a58b15af1e57051906cbee8d3d51f4314b71f71af28f582a675" + }, + "cargo/assignment-planner-1/process.json": { + "bytes": 202, + "sha256": "1aec12d9a133576033cb736bdec098873005e8b248e09157a3e7608c2d551fdc" + }, + "cargo/assignment-planner-1/result.json": { + "bytes": 3388, + "sha256": "b1bc1f3f3013c9df43f7af3cef801e0b977ec484457cfc314e4aa2f1c8d6db98" + }, + "cargo/assignment-planner-1/started.json": { + "bytes": 1589, + "sha256": "75d0eeca99ff5f2819a0302cbdc739d2377a2f524890f448f040585288680d2e" + }, + "cargo/assignment-planner-1/stderr.log": { + "bytes": 324, + "sha256": "8ee7484db45f8ddf1bf0057317feb689990758c35dc0946c12e0eef0de08e808" + }, + "cargo/assignment-planner-1/stdout.log": { + "bytes": 301624, + "sha256": "7be201e6c1a812d8a32d73e75999e5e50a07af2f11eebc77f442aee44f64e5af" + }, + "cargo/assignment-planner-2/process.json": { + "bytes": 202, + "sha256": "5837e6b91cbd5c6c95bbeaf08ac89a47149e84c4c3f20004457e80c316e388c9" + }, + "cargo/assignment-planner-2/result.json": { + "bytes": 3387, + "sha256": "2dd1c6036eb805cd8e95050661e3dce9d132f9071186165097d4212f2b8253c0" + }, + "cargo/assignment-planner-2/started.json": { + "bytes": 1589, + "sha256": "a4384901a0d3a29631a29d5e91b6d3dc944cefca1bb7653901d351a7bd906ec1" + }, + "cargo/assignment-planner-2/stderr.log": { + "bytes": 324, + "sha256": "a046d75d45956402707b7c1089ec5005b0ba640857473e1f284a8f377f896f2c" + }, + "cargo/assignment-planner-2/stdout.log": { + "bytes": 302839, + "sha256": "1905a2726b6e9c388566af75d7705b3916ade3a7d2c9dab772bffbb5b115762b" + }, + "cargo/assignment-planner-3/process.json": { + "bytes": 202, + "sha256": "ec191e2d953d52f6da8c54bc6e9dfa5638273eb6c351823fc4871628320b3bab" + }, + "cargo/assignment-planner-3/result.json": { + "bytes": 3388, + "sha256": "117bcfa31629682df708dfebb892464f01060888388ea680a231b12cd447db67" + }, + "cargo/assignment-planner-3/source-proof.json": { + "bytes": 599, + "sha256": "3b249398b0ade218ec9737677e1021e21eae7aa03deeb6bc071e0e7e7fe6182b" + }, + "cargo/assignment-planner-3/started.json": { + "bytes": 1589, + "sha256": "30cf725d9475a24b177c2dbddb3f59b7d298343556598bc0e760d51ad7e111e5" + }, + "cargo/assignment-planner-3/stderr.log": { + "bytes": 324, + "sha256": "d3a0110134db80cd8715e6081027595056203feada2278053fdc62ae75c6df31" + }, + "cargo/assignment-planner-3/stdout.log": { + "bytes": 302839, + "sha256": "aa832fe75c46be8990d0c679e6e321494c0ea6638657634010bae7022b7a2ee9" + }, + "comparators/7805832d5f563ab2d7a50dd3de700792b5d6f459c68f7b1d8bcfcc9800fa0c3a.py": { + "bytes": 10094, + "sha256": "7805832d5f563ab2d7a50dd3de700792b5d6f459c68f7b1d8bcfcc9800fa0c3a" + }, + "helpers/1368375b1fba0dfc50b77ea4f406dba71960a78317ce8f259d4c4a0254863a20.py": { + "bytes": 3827, + "sha256": "1368375b1fba0dfc50b77ea4f406dba71960a78317ce8f259d4c4a0254863a20" + }, + "helpers/58597566ae1cb5c4021ddc7e86635b76d127bdb63b5d3026c427771aae7e2119.py": { + "bytes": 1399, + "sha256": "58597566ae1cb5c4021ddc7e86635b76d127bdb63b5d3026c427771aae7e2119" + } + } +} diff --git a/harness/allocation-assignment-evidence-20260923/checks/prepare.py b/harness/allocation-assignment-evidence-20260923/checks/prepare.py new file mode 100644 index 00000000..23d1d9ce --- /dev/null +++ b/harness/allocation-assignment-evidence-20260923/checks/prepare.py @@ -0,0 +1,110 @@ +"""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, comparators=()): + gates = root / ".local/allocation-group-gates" + candidates = [gates / name for name in ("gate.py", "source-proof.py")] + helpers + helper_bytes = [path.read_bytes() for path in candidates] + comparator_paths = [root / ".local/process-source-evidence-tools/verify.py", *comparators] + comparator_bytes = [path.read_bytes() for path in comparator_paths if path.is_file()] + 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 + helper_catalog = {} + for name, data in zip(("gate.py", "source-proof.py"), helper_bytes): + helper = "helpers/" + pin(data)["sha256"] + ".py" + add(helper, data) + helper_catalog[name] = helper + for run in runs: + folder = gates / run + need(folder.is_dir(), "run directory missing: " + run) + paths = list(folder.iterdir()) + names = {path.name for path in paths} + need(all(path.is_file() and not path.is_symlink() for path in paths) + and names in (RAW_FILES, RAW_FILES | {"source-proof.json"}), "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 + if "source-proof.json" in raw: + proof = parsed(raw["source-proof.json"]) + matches = [data for data in comparator_bytes if pin(data) == proof["comparator"]] + need(bool(matches), "exact source comparator unavailable for " + run + "; supply --comparator PATH") + comparator = "comparators/" + proof["comparator"]["sha256"] + ".py" + add(comparator, matches[0]) + specs[run]["sourceProof"] = {"commit": proof["commit"], "pin": pin(raw["source-proof.json"]), "comparator": comparator} + manifest = {"schema": 1, "kind": "allocation-multiselect-cargo-gates", "runs": specs, + "captureHelpers": helper_catalog, + "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, required=True, help="checkout holding .local/allocation-group-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("--comparator", type=Path, action="append", default=[], help="additional exact historical source comparator (repeatable)") + parser.add_argument("--output", type=Path, help="fresh, nonexistent package directory (required unless --check-only)") + 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") + need(args.check_only or args.output is not None, "--output is required unless --check-only") + if not args.check_only: + need(not args.output.exists(), "output must be a fresh directory: " + str(args.output)) + payload, manifest, summary = collect(args.root.resolve(), args.runs, args.helper, args.comparator) + if args.check_only: + print("PASS raw consistency; no package written:", summary) + return + out = args.output.resolve() + out.mkdir(parents=True, exist_ok=False) + 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() diff --git a/harness/allocation-assignment-evidence-20260923/checks/verify.py b/harness/allocation-assignment-evidence-20260923/checks/verify.py new file mode 100644 index 00000000..f6e1cfa0 --- /dev/null +++ b/harness/allocation-assignment-evidence-20260923/checks/verify.py @@ -0,0 +1,194 @@ +"""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"] == "allocation-multiselect-cargo-gates", "manifest schema") + runs, logical, stored = manifest["runs"], manifest["logicalFiles"], manifest["members"] + helpers = manifest["captureHelpers"] + need(isinstance(helpers, dict) and set(helpers) == {"gate.py", "source-proof.py"}, "capture helper names") + need(all(isinstance(name, str) and re.fullmatch(r"helpers/[0-9a-f]{64}\.py", name) + for name in helpers.values()), "capture helper mapping") + 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()) + expected_logical.update(helpers.values()) + for run, spec in runs.items(): + if "sourceProof" in spec: + proof_spec = spec["sourceProof"] + need(set(proof_spec) == {"commit", "pin", "comparator"}, run + ": exact source proof spec") + need(isinstance(proof_spec["commit"], str) and re.fullmatch(r"[0-9a-f]{40}", proof_spec["commit"]), run + ": named full source commit") + need(valid_pin(proof_spec["pin"]), run + ": source proof pin") + need(isinstance(proof_spec["comparator"], str) and re.fullmatch(r"comparators/[0-9a-f]{64}\.py", proof_spec["comparator"]), run + ": comparator path") + expected_logical.update({"cargo/" + run + "/source-proof.json", proof_spec["comparator"]}) + 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]) + for name, helper in helpers.items(): + need(helper == "helpers/" + pin(raw(helper))["sha256"] + ".py", "named helper hash: " + 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") + if "sourceProof" in spec: + proof_spec = spec["sourceProof"] + proof_bytes = raw(prefix + "source-proof.json") + proof = parsed(proof_bytes) + need(pin(proof_bytes) == proof_spec["pin"], run + ": source proof pinned bytes") + need(proof["run"] == run and proof["commit"] == proof_spec["commit"], run + ": proof run/commit identity") + need(proof["status"] == "PASS exact captured input inventory and Git blob bytes", run + ": source proof status") + need(proof["inputCount"] == len(inputs) and proof["inputMap"] == result["inputs"], run + ": proof input relationship") + need(proof["result"] == pin(raw(prefix + "result.json")), run + ": proof result relationship") + need(ordered_times(result["finish"], proof["verified"]), run + ": proof time after closed gate") + comparator = proof_spec["comparator"] + need(valid_pin(proof["comparator"]) and pin(raw(comparator)) == proof["comparator"], run + ": comparator bytes") + need(comparator == "comparators/" + proof["comparator"]["sha256"] + ".py", run + ": comparator identity") + 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), "recordedFailures": sum(spec["exitCode"] != 0 for spec in runs.values()), + "logicalFiles": len(logical), "storedMembers": len(stored), "sourceCapsulePairs": len(seen_capsules), + "captureHelpers": helpers, "sourceProofs": sum("sourceProof" in spec for spec in runs.values())} + + +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() diff --git a/harness/allocation-assignment-evidence-20260923/reviews/README.md b/harness/allocation-assignment-evidence-20260923/reviews/README.md new file mode 100644 index 00000000..e36a7619 --- /dev/null +++ b/harness/allocation-assignment-evidence-20260923/reviews/README.md @@ -0,0 +1,16 @@ +# Assignment review archive + +Exact historical design, fixtures and independent review notes, with original +`.local` paths preserved inside the archive. Their relative links refer to that +original checkout context; extract only into a separate scratch directory if +needed. Do not interpret historical plans or source-derived predictions as +executed results. The review addendum inspects the recorded 84-test gate without +claiming an independent rerun. The App adapter plan is design only. + +The newline diagnosis records why three initial exact source comparisons +refused. Their captures were retained and canonical-byte gates rerun; historical +receipts were not rewritten. Current accepted facts and limitations are in the +[planner report](../../allocation-assignment-planner-20260923.md). + +`files.json` pins this archive/index/readme; `members.json` pins every exact ZIP +member. Neither internal hashes nor independent review replace runtime evidence. diff --git a/harness/allocation-assignment-evidence-20260923/reviews/files.json b/harness/allocation-assignment-evidence-20260923/reviews/files.json new file mode 100644 index 00000000..8817f434 --- /dev/null +++ b/harness/allocation-assignment-evidence-20260923/reviews/files.json @@ -0,0 +1,14 @@ +{ + "reviews.zip": { + "bytes": 26141, + "sha256": "35a5faaf9f60eaa88d8c97f8a34dceac7a2cebf356ac6f69ec0e794cec258101" + }, + "members.json": { + "bytes": 840, + "sha256": "3b1ce35c9b65b241b80367c90adc0ffbc65155d9cea8872f98e58901b2d52513" + }, + "README.md": { + "bytes": 952, + "sha256": "a518a056c78b982c45f8d9a2098729f27df8f1702132c0e4cc6c555c603dbd22" + } +} diff --git a/harness/allocation-assignment-evidence-20260923/reviews/members.json b/harness/allocation-assignment-evidence-20260923/reviews/members.json new file mode 100644 index 00000000..4fd1e740 --- /dev/null +++ b/harness/allocation-assignment-evidence-20260923/reviews/members.json @@ -0,0 +1,22 @@ +{ + ".local/allocation-group-design/assignment-plan.md": { + "bytes": 16268, + "sha256": "000b80e13845a1532e083281ee94486964306af0ce7fb27fee3a9eb2074f2405" + }, + ".local/allocation-group-design/assignment-fixtures.md": { + "bytes": 11563, + "sha256": "507e5d33745ffb65f096a8c5640f6e22d61ecb8701eb13769bfe538069e71ecc" + }, + ".local/allocation-group-design/assignment-review.md": { + "bytes": 11510, + "sha256": "f1c36cdba4a6f74770d541d9529d7f116473b2bc78bc2b2902c80f387eba82d1" + }, + ".local/allocation-group-design/app-adapter-plan.md": { + "bytes": 21648, + "sha256": "316633ae45d8f9836212d37e64639251cdb2fd96cda522d0a979d9bc81782cf7" + }, + ".local/allocation-evidence-tools/assignment-newline-diagnosis.json": { + "bytes": 622, + "sha256": "60631de3a45d0cb6c4108e7aaaf170b80e43e00e076674aabf379d05ee77a62a" + } +} diff --git a/harness/allocation-assignment-evidence-20260923/reviews/reviews.zip b/harness/allocation-assignment-evidence-20260923/reviews/reviews.zip new file mode 100644 index 00000000..86012f4f Binary files /dev/null and b/harness/allocation-assignment-evidence-20260923/reviews/reviews.zip differ diff --git a/harness/allocation-assignment-planner-20260923.md b/harness/allocation-assignment-planner-20260923.md new file mode 100644 index 00000000..3ada7326 --- /dev/null +++ b/harness/allocation-assignment-planner-20260923.md @@ -0,0 +1,115 @@ +# Ordinary Allocation group assignment: pure planner checkpoint + +Status: implemented and verified as a pure planner; App wiring and native +acceptance remain pending. Source commit +`33e880103c5fa935a4431ec3dc089e2c4c92f680`, on the Allocation contribution branch +above the [Arrange foundation](allocation-arrange-planner-20260923.md). +This is a branch-specific checkpoint, not an integrated application release. + +Required behavior comes from [multiselect](multiselect-contract-20260923.md), +[Allocation v2](release-activity-allocation-drag-contract-v2-20260911.md) and the +[explicit Arrange amendment](allocation-arrange-amendment-20260923.md). +No core model, source schema or user-owned specification changed. + +## Result and design + +Ordinary assignment captures every original visible work placement before +normalizing selected semantic ancestors. It collects the union of leaf work, +including unplaced leaves, and existing active placements, including folded +descendants. Each normalized root retains its own accepted responsibility: +independent roots may start under different performers; a distributed composite +still refuses ordinary assignment. Already-correct role-only work does not gain +a direct Allocation merely because another selected root changes performer. + +One destination plan accumulates stable IDs, unique aliases and intended edits, +prepares one semantic Batch, and compares the complete candidate Project with +the intended result. Existing Allocation IDs and header metadata survive +retargeting. Outside removes only intended direct Allocations and their own +saved routes across views. Role exits and collateral changes to surviving +records refuse the whole plan. Warning collection and changed/already-correct +leaf counts are retained for future App feedback. + +One shared geometry helper now serves singleton assignment, group assignment +and explicit Arrange. It uses accepted endpoint ownership and the complete +movement map: internal pins translate once, one-ended pins stay fixed, labels +and attachments remain exact. Geometry checks compare whole visible scenes and +the affected/ancestor diagnostic expansion, require rigid affected footprints +and every original selected header, and authorize target unfolding only when +assignment actually changes. Accepted and candidate expansion use their own +ancestry. Per-pointer placement does not clone or validate a Project or rebuild +semantic indexes. Finish rechecks the entire live Project and recomputes the +placement before one exact source Batch. Zero movement can still change +assignment; complete Project equality alone yields no command. + +## Verification + +Windows gnullvm, cargo/rustc 1.98.1, LLVM-MinGW 20260826, one Cargo build job, +incremental disabled. Linux remains user-deferred. The focused example imports +the actual production planner and Document modules; it is not a substitute +implementation. Commands: + +```powershell +cargo test -p oa-desktop --example allocation_policy_probe --message-format=json -- --test-threads=1 +cargo clippy -p oa-desktop --example allocation_policy_probe --tests --message-format=json -- -D warnings +cargo fmt --all -- --check +``` + +Final canonical-byte gates: `assignment-planner-3` **84 passed, 0 failed**, +`assignment-lint-2` strict lint passed, `assignment-format-2` passed. Each captured +750 unchanged inputs, including 71 vendored dependency files and both explicit +compiled fixtures; all captured blobs compare exactly with source commit +`33e880103c5fa935a4431ec3dc089e2c4c92f680`. +Final input-map SHA256: +`ce988ecaeaca395d0f120844db7e3da90d039633beef10d2d4a8b8f19e92ee27`. + +The 14 added test families cover OA/SA independent roots and original identity, +normalization/unplaced work, distributed composite versus independent leaves, +role provenance and unresolved OA roles, stable IDs/aliases, zero-delta semantic +change, target unfold and newly exposed overlap, simultaneous vacated-space +movement, internal/external routes with explicit attachments and SA port anchor, +own-route deletion across views, surviving Constraint collateral refusal, +stale/tampered results and all selected headers. Existing singleton/Arrange and +Document tests remain in the same passing suite. + +Positive tests use independently authored complete Projects and actual Document +execute, source compilation/reopen and exact one-step Undo/Redo. The source-budget +case executes the actual group command against an existing Document with Undo +and Redo available, preserves Project/bundle/revision, and exercises both history +paths after refusal. The same command/IDs then succeed in a fresh normal-budget +Document; this is not evidence of same-session gesture retry. + +Independent review found no concrete planner defect. A second pass reviewed +the added evaluators and found no blocking invalid fixture or circular oracle. +It inspected the captured 84-test result without claiming its own execution. +Exact historical notes and newline diagnosis are in the +[review archive](allocation-assignment-evidence-20260923/reviews/README.md). + +## Evidence identity and limits + +The [portable gate package](allocation-assignment-evidence-20260923/checks/README.md) +retains seven runs: the first 75-test capture, expanded 84-test capture, initial +lint/format and three final canonical-byte gates. Initial expanded checks +passed, but exact Git comparison refused because `arrange.rs` was CRLF in the +working tree and LF in the committed blob. The independently checked difference +was newline-only. Those historical captures are not attributed to the pristine +commit and have no source-proof receipt. The file was restored to the committed +bytes and all three gates rerun. No receipt or historical log was rewritten. + +Run the package verifier with `python -B checks/verify.py` from the evidence +directory. It checks archive/receipt consistency and pinned historical source +proofs, not external authenticity or a new Git comparison. The report's named +source comparisons were executed separately against actual Git blobs. + +Remaining focused protective coverage includes deeper folded-grandchild group +reassignment and a changed-assignment enclosure-growth blocker. Existing +singleton and shared geometry coverage reduces but does not remove those gaps. +Role warning collection is reviewed; the new role tests do not independently +assert every warning code/severity. + +There is no new App execution, full desktop test run, matching GUI build or native +acceptance for this checkpoint. Exact typed selection, frozen pointer target, +minimum readable zoom, pending-click behavior, sticky drag threshold, selection +generation, clean-owner admission and focus/camera/token retirement remain the +App adapter's obligations. The archived App plan is design only. Neither this +checkpoint nor the earlier Arrange foundation closes issue 29 or authorizes a +combined release; the coordinated integration/release owner is unchanged. diff --git a/harness/multiselect-contract-20260923.md b/harness/multiselect-contract-20260923.md index 8577aed6..d3877287 100644 --- a/harness/multiselect-contract-20260923.md +++ b/harness/multiselect-contract-20260923.md @@ -80,6 +80,13 @@ 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. +The [explicit Arrange amendment](allocation-arrange-amendment-20260923.md) defines +the bounded one-or-more work/performer footprint and diagnostic collision domain. +Its [pure planner evidence](allocation-arrange-planner-20260923.md) and the +[ordinary assignment foundation](allocation-assignment-planner-20260923.md) +establish bounded source/planner behavior. App/native group input acceptance +remains pending. + ## Related group actions Move/reorder and removal are the initial useful operation families. Saved-view diff --git a/harness/multiselect-process-20260923.md b/harness/multiselect-process-20260923.md new file mode 100644 index 00000000..b91ea393 --- /dev/null +++ b/harness/multiselect-process-20260923.md @@ -0,0 +1,72 @@ +# Process multi-selection evidence — 2026-09-23 + +This slice implements exact multi-selection and topology-preserving order for OA Process and SA Functional Chain. Desktop, independent-source and corrected Windows native checks pass within the scopes below. This is branch-specific evidence, not a release, combined integration or completion of multi-selection issue #29. Combined release publication remains with the integration owner. + +The [adapter design](multiselect-process-adapter-design-20260923.md) owns the selection, planning and lifecycle contract. The [native evaluator design](multiselect-process-native-design-20260923.md) owns the independent fixture/oracle and discriminating gesture. Detailed evidence is separated into [checks](multiselect-process-evidence-20260923/checks/), [source identity](multiselect-process-evidence-20260923/source/), [render feedback](multiselect-process-evidence-20260923/feedback/), [native trials](multiselect-process-evidence-20260923/native/), [reviews](multiselect-process-evidence-20260923/reviews/) and [preparation failure](multiselect-process-evidence-20260923/preparation-run-2/). + +## Implementation and integration seams + +Selection is transient and owner-scoped, preserving local occurrence/link and qualified path/preview-link identities. Primary controls Properties; qualified subjects stay read-only. Shift toggles, stationary member clicks collapse, and contextual membership/inspection is explicit. Feedback preserves canvas bounds. Visible reconnect handles win hit testing; groups cannot fall through to singleton mutations. Mixed/qualified movement refuses the whole set unchanged. + +Local occurrences in one column move in source order into Before/After slots, preserving other-column indices and all ranks. `[A,U,B,V,C]`, C then A After B, becomes `[B,U,A,V,C]`. Singleton order shares the planner. One `SetSteps` transaction records a change; invalid/no-op results create no history. Facts are captured once per press; plans cache destinations without pointer-time Project clones/Graph rebuilds. Feedback shows slot/count and source-order numbers. + +Integration uses `app::multiselect`, Process App handlers, topology `direct_selection.rs`/`group_reorder.rs`, existing menus/transactions and shared Viewport modifier retention. Mounted tests split press/motion/release across App rebuilds and check snapshots, reconnect precedence, intermediate source/history and final order/Undo/Redo. Scenario has a shared-modifier regression, not multi-selection implementation. + +## Exact-subject failure and correction + +Native run 1 on `b153451...` exposed a blocking identity defect: Back restored caller/camera but showed editable outer-reference Properties alongside qualified-leaf highlighting (frame 26). Baseline/mixed/qualified refusal saves passed three independent complete-Project `unchanged` checks, which could not detect UI identity. The run stopped before order/history/reopen acceptance. It exited 0 with empty logs and unchanged pinned binaries/fixture; it has no successful history-bytes receipt. + +The correction remembers the requested qualified address, including nonprimary menu invocation. Back selects its validated leaf and refreshes read-only inspection. Select/EditProperties clears obsolete context after admission, including same-ID defining-leaf navigation; ordinary Open saves a local frame. Tests assert path, subject, draft, camera, source/history and ordinary return. Back restores the invoked singleton. Review 2 found no concrete regression; protective-test suggestions remain notes rather than claimed coverage. + +Run 3 confirms current/nonprimary Open/Back, exact read-only caller subject, and Explorer navigation to outer reference and same-ID leaf with editable local Properties. [Parent observations](multiselect-process-evidence-20260923/reviews/run-3-observations.md) and the [independent native review](multiselect-process-evidence-20260923/reviews/process-native-review-3.md) establish these UI results separately from saved-Project checks. + +## Source and test evidence + +Gates retain commands/logs, artifact/helper hashes and unchanged 742-file maps/archives, including 71 vendored files and compile-time fixtures. Source receipts compare bytes with named Git blobs; captured HEAD alone cannot identify uncommitted source. + +| Source | Matching evidence and result | +| --- | --- | +| Historical `1711bff650dabb1df3a9bc37f19060d5db297feb`, map `d5acfcf6...` | `full-desktop-1`: **1,317 passed, 6 ignored**; `format-1` passed. Historical implementation, before final navigation corrections. | +| `b15345123a7feaf630ef24d45f591c76c6715778`, map `7f5b3de4...` | `desktop-3`: **147 passed**; strict `lint-2`, `format-2`, `feedback-1` passed; `core-2`: **5 passed**. Matching checker/GUI builds passed. Native run 1 failed as recorded above. | +| Production correction `ff712b24190342363d0275467e96f181872da6ad`, map `1ba4ed41...` | `desktop-6`: **150 passed**; `full-desktop-2`: **1,320 passed, 0 failed, 6 ignored**. Strict core/desktop all-target `lint-3` and `format-3` passed. | +| Evaluator clarification `b743a10d51032dc1655b94fed5e49fbbbcee83f4`, map `f3fd180a...` | `core-3`: **5 passed**; example-scoped `lint-4`, `format-4`, `windows-build-2`, `native-build-2` passed. Production desktop/core library unchanged from `ff712...`; this is not a second full-desktop run. Matching GUI/checker used for native run 3. | + +Failures remain preserved: `desktop-1` 94 passed/8 failed; `desktop-2` 104/1; `lint-1` failed before the payload-size correction. `desktop-4` rejected invalid Cargo filter placement before tests. `desktop-5` ran 147/1 because the new fixture toggled already-expanded references closed, yielding an empty initial group; corrected setup/navigation pass in `desktop-6`. Preparation run 2 failed while generating its fixture with missing toolchain DLL environment (exit 3221225781); **no GUI trial occurred**. Run 3 corrected the environment and used a fresh directory. + +## Independent semantics, rendering and native history + +The core checker compares complete Projects against immutable baseline/literal expected OA order without invoking the desktop planner. SA/reuse/trace are preservation sentinels. Primary A would be ambiguous: singleton-A After B equals the group result. The clarified evaluator adds unchanged-C rejection and a discriminating trial: membership `[C,A]`, Inspect C, drag C After B. Group output is `[B,U,A,V,C]`; singleton primary/pressed C leaves the original order. + +The native observer performed that sequence in run 3: click C, context Add A, Inspect C, verify two selected/primary C, then drag C After B. The visible column became B,A,C with U,V unchanged. One Ctrl+Z restored baseline; one Ctrl+Y restored reordered output; an in-process reopen retained it. The trial also observed mixed-set refusal and selected-anchor no-op. Run 3 closed normally at `2026-09-23T13:43:54Z`, exit 0, empty stdout/stderr and unchanged pinned checker/GUI/fixture. + +**Routing remains visibly imperfect.** In the [reordered frame](multiselect-process-evidence-20260923/reviews/images/run-3/39-group-reordered-0.jpg), A→U and B→V share portions of their orthogonal paths, obscuring endpoint connectivity. The independent review confirms this defect; unchanged link records do not establish readable routing. It is related to the intersection concerns in issue #32, whose original scope is Auto Arrange; this trial exercises Process order instead. No source-level attribution or routing fix is claimed here. + +All eight saved snapshots passed the independent complete-Project checker: baseline, qualified-navigation, mixed-refusal, anchor-noop, reordered, undo, redo and reopened. The history-byte gate passed for all ten source files: `baseline == undo == qualified-navigation == mixed-refusal == anchor-noop` and `reordered == redo == reopened`, also checking immutable fixture equality for the baseline group. Only `oa/behavior/process_oa_process.arcadia` differs between groups. This lexical proof complements observed one-step history; equality alone cannot prove the user actions. + +Actual headless WGPU App feedback covers OA/SA at 1440×900 and 100% zoom: stable source/selection/camera, legible insertion slot/count and source-order numbers. Raw RGBA, lossless PNG readback and parent inspection are preserved. These split-batch renderings establish actual production pixels, not a paused native drag or other DPI/window sizes. Native membership used context actions; held Shift-click is mounted-test evidence. Reopen is in-process, not cold restart. Aggregate removal, Allocation/Scenario adapters, native SA reorder, Linux/DPI and full accessibility remain outside this slice's acceptance. + +## Reproduction and package verification + +From the named source checkout, use the documented project toolchain. Captures used Windows GNU LLVM, Cargo/rustc 1.98.1, one build job and incremental compilation disabled. Multiple test filters belong **after** `--`: + +```text +cargo test -p oa-core --example process_multiselect_native -- --test-threads=1 +cargo test -p oa-desktop -- topology process_ canvas_workspace repeated_process menu --test-threads=1 +cargo test -p oa-desktop -- --test-threads=1 +cargo clippy -p oa-core -p oa-desktop --all-targets -- -D warnings +cargo fmt --all -- --check +cargo build -p oa-core --example process_multiselect_native +cargo build -p oa-desktop +``` + +Portable evidence checks use Python; feedback PNG verification also needs Pillow. They do not build or execute archived sources. From repository root: + +```text +python -B harness/multiselect-process-evidence-20260923/checks/verify.py +python -B harness/multiselect-process-evidence-20260923/source/verify.py --repo . +python -B harness/multiselect-process-evidence-20260923/feedback/verify.py --checks harness/multiselect-process-evidence-20260923/checks +python -B harness/multiselect-process-evidence-20260923/native/verify.py +python -B harness/multiselect-process-evidence-20260923/preparation-run-2/verify.py +``` + +The source check requires named commits locally; omit `--repo .` for offline receipt consistency. Before publication, all five verifiers and review pins passed against a fresh export of the exact 47 staged evidence files; exported bytes matched the working files. The first staging attempt omitted the two ignored empty preparation logs; inventory validation caught that omission before verification or publication, and they were explicitly staged. Package READMEs cover native reproduction with fresh fixtures and matching GUI/checker receipts. Preserve failures and immutable baselines. Package consistency, semantic equality, lexical history, rendered feedback and observed interaction remain distinct claims. diff --git a/harness/multiselect-process-adapter-design-20260923.md b/harness/multiselect-process-adapter-design-20260923.md index a46bec60..fd114c16 100644 --- a/harness/multiselect-process-adapter-design-20260923.md +++ b/harness/multiselect-process-adapter-design-20260923.md @@ -1,55 +1,119 @@ -# Process group adapter candidate +# Process selection and column-order adapter -Read-only Astra investigation against the State follow-up working tree above `7e348d967d1787c052a145b7448636613a6a7054`. This is a proposed next adapter, not implementation, accepted semantics, or verification. Live ownership and priorities remain in Gitea #29. No source, build or GUI change accompanied the investigation. +**Accepted implementation contract; runtime/native acceptance pending.** This document describes the current unpublished Process working source above `bed7f825fd00ec32299032a664a5f5d753aad86d`, implementing the Process portion of the [multiple-selection contract](multiselect-contract-20260923.md). It does not establish a verified release or completion of issue #29. Gitea holds live ownership, priorities and gate results. The initial read-only investigation inspected State follow-up source above `7e348d967d1787c052a145b7448636613a6a7054`; its alternatives, critiques and experiment evidence are retained as history below. -## Representation facts +## Accepted implemented contract + +The surface is `Surface::Process(owner)` for an OA Process or SA FunctionalChain. Selection is a transient insertion-ordered exact target set with one primary Inspector target: + +| Target | Identity and admitted use | +| --- | --- | +| `ProcessOccurrence(OccurrenceEndpoint { references: [], occurrence })` | One local semantic contribution card. Action, Exchange, Control and SequenceReference cards participate in order planning; these are not saved placements or reusable work definitions. | +| `ProcessOccurrence` with nonempty references | One visible qualified tile with its complete reuse path. Select/inspect; no caller-side reorder or deletion. | +| `ProcessLink(id)` | One local rendered SequenceLink or ContextualLink. Select/inspect; not a member of a contribution reorder. Existing singleton local SequenceLink reconnect remains separate. | +| `ProcessPreviewLink { relation, source, target }` | One actually painted qualified preview segment with exact endpoint paths. Select/inspect; read-only through the caller. | + +Different caller paths to the same defining occurrence remain different targets. A qualified primary identifies the **actual occurrence or relation subject**, not the outer reference's properties. Its caller owner/path remains captured; properties are read-only until explicit **Open** navigates to the defining process. Hidden/collapsed targets are pruned exactly without rebinding to another reuse path or outer card. Open/Back and owner changes establish a new surface lifetime. Legacy `OrderedPath` arrows have no relation identity and do not become editable links. Navigation emphasis alone is not mutation authority. + +Shift-click toggles the exact hit. Plain press on an unselected target replaces the set. A selected-member press freezes membership and primary until stationary release versus drag; stationary release collapses to that member. Right-click captures a target without replacing the selection; contextual Add/Remove changes membership explicitly. Counts and distinct primary/membership paint describe the set. Ordinary local property edits remain primary-only. + +### One admitted column-slot plan + +`behavior/topology/group_reorder.rs::Prepared::new(&Project, owner, &[Target])` captures immutable steps, adjacency, integer ranks and selected IDs. It rejects empty/duplicate selections, non-occurrence members, qualified paths, wrong-owner/missing contributions, cross-column roots, or any missing/foreign/duplicate **unselected** step. A local SequenceLink whose endpoint is omitted from owner steps also refuses before any selected-anchor no-op. Such incomplete source can be core-valid; that does not authorize silently ignoring its topology when editing order. + +The renderer and planner share `topology.rs::column_ranks`, extracted from the established rank algorithm. The renderer filters direct links to displayed endpoints and skips unavailable legacy path pairs, retaining displayed nodes without panicking. This tolerant display behavior does not weaken planner admission. Parallel SequenceLinks remain distinct; longest-path and deterministic cycle-blocked integer ranks retain their existing meaning. + +`Prepared::plan(Destination::Before(id) | Destination::After(id))` requires an anchor in the selected column. After full admission, a selected anchor returns `Plan::Noop`, even for a noncontiguous set. For an unselected anchor: extract the column's order and original step indices; remove selected IDs and form their block in original source order; insert before/after the remaining anchor; write the column permutation back to those same indices. Other-column IDs retain exact indices, and unselected IDs within this column retain relative order. Identical steps return Noop. Otherwise the complete ID-to-rank map is recomputed and must remain exact before one existing `Command::SetSteps` is returned. + +Example: `[a,u,b,v,c]`, column `[a,b,c]`, selection C then A, After B yields `[b,u,a,v,c]`. U/V retain indices 1/3, and A/C retain source order. Selected `{b,c}` After A is unchanged. There is no XY schema, control-flow rewiring, reusable-definition edit or unrelated-record change. Legacy exchange-only paths have successive ranks; cycle-blocked nodes receive distinct fallback ranks, so multi-member sets spanning those columns refuse. A cycle elsewhere does not prevent movement of an acyclic peer group. + +Semantic facts are prepared once per press and candidate plans are cached by anchor/side. Motion within a slot does not clone the Project, rebuild a Graph or run whole-model validation. App release handling re-admits the live exact selection and uses the same planner before one source-validated transaction. Source failure preserves model/history. Successful commit restores the exact primary without an incidental Fit/reveal. Noop emits no command/history entry. + +**Singleton migration is implemented:** `topology/direct.rs::reorder` delegates a singleton typed target to this planner and returns Noop/Change. Moving B before A in `[a,u,b,v,c]` now writes `[b,u,a,v,c]`, replacing the old global splice `[b,a,u,v,c]`. This deliberately changes invisible cross-column source order to preserve unrelated indices consistently for singleton and group operations. + +### Pointer feedback and lifetime + +`behavior/topology/direct_selection.rs` owns an order-specific press; it does not use the saved-placement XY constructor. The press captures exact selection snapshot, target, screen position, bounds, pan and scale. Its 3-screen-pixel threshold is irreversible: out-and-back movement, refused drops and no-op destinations retain the set rather than becoming stationary clicks. Native pointer order is preserved; an unavailable/outside release cannot reuse an earlier inside point. + +Each local card's upper half means Before and its lower half means After. An insertion indicator, refusal/unchanged caption and source-order member numbers describe the cached operation. Cards are not freely translated or reflowed during the drag. Before-first and after-last are reachable through the first/last unselected card. Actual reachability/readability at native zoom/window sizes remains an acceptance gate. + +Snapshot/document/revision/surface/selection-generation changes, tool/editability changes, bounds/pan/scale changes, resize/unfocus, wheel, cursor exit, right/middle press and Escape cancel the captured gesture. Escape cancels a live press before selection clearing. Passive modifiers support Shift across editor focus. Stable count/Inspector layout and retained camera geometry are required; selection and qualified inspection do not authorize incidental source application or camera movement. + +Shift/context hits on painted reconnect rings resolve the exact link. A mixed set cannot reconnect only its primary, and Connect with several selected targets is refused. Existing singleton tools retain their separate ownership. App envelope validation precedes incidental selection/camera/allocation state changes; dirty source/property/forms or stale packets cannot partially act. + +### Current removal boundary and remaining adapters + +Process has no saved placements: Hide explicitly refuses instead of deleting contributions or adding hidden-card persistence. Group Delete and qualified-target Delete explicitly refuse. Existing admissible local singleton deletion remains separate; no depicted group falls through to it. Aggregate local-occurrence removal still requires one captured impact review and atomic union deletion, including ContextualLink cleanup and explicit-to-legacy meaning changes described in the historical critique below. + +This slice implements exact Process selection and column reorder with whole-set refusals. Aggregate removal, Allocation assignment/Arrange-only groups and Scenario order adapters remain required by the [overall contract](multiselect-contract-20260923.md), outside this Process acceptance claim. + +### Implementation and verification boundaries + +| Source | Responsibility | +| --- | --- | +| `app/multiselect.rs`, `app/process_multiselect.rs` | Scope/envelope admission, exact reconciliation, actual-subject primary and transaction dispatch. | +| `app/process_canvas.rs`, `app.rs` | Legacy command guards, navigation/tool lifetime and existing Process command integration. | +| `behavior/topology/group_reorder.rs`, `topology.rs::column_ranks` | Immutable admitted facts, column-slot planning and shared production ranks. | +| `behavior/topology/direct_selection.rs`, `direct.rs` | Exact hits/modifiers, order gesture, feedback, singleton migration and separate tool ownership. | +| `behavior/topology.rs`, `topology/view.rs`, `topology/usages.rs` | Exact paint/visibility, stable controls/camera and incomplete-topology rendering. | +| `model_menu.rs`, Inspector/behavior property boundaries | Captured membership menus, qualified read-only subject and explicit Open. | + +Planner tests define independent literal OA/SA orders, whole-Project preservation, source roundtrip/Undo/Redo, all four card kinds/differing heights, reverse noncontiguous selection, end gaps/noops, typed refusal, five-node cycle-plus-peer ranks, parallel links and malformed/unlisted steps. App/input tests cover qualified primary, reconciliation, stale messages and gesture boundaries. Their definitions and source inspection alone do not establish current full-suite or native acceptance. + +The [native evaluator design](multiselect-process-native-design-20260923.md) and [core example](../crates/oa-core/examples/process_multiselect_native.rs) use an immutable deterministic OA/SA fixture, literal expected `[b,u,a,v,c]` and complete Project comparison. Global splice, reversed-block, primary-only and unrelated-record corruptions are falsifiers. Native Shift/focus, menus, cancellation, insertion reachability, stable layout/camera, real save/reopen and Undo/Redo require matching executable evidence. No acceptance receipt is asserted here. + +## Historical investigation and design alternatives + +The following sections retain the original reasoning, corrected by the accepted contract above. Statements of proposed work describe that earlier investigation, not the current implementation status. + +### Representation facts recorded initially `diagram_usage.rs` already defines `Surface::Process(owner)`, exact `ProcessOccurrence(OccurrenceEndpoint { references, occurrence })`, local `ProcessLink`, and qualified `ProcessPreviewLink` targets. Local cards are semantic occurrences in owner steps; they reference reusable definitions and are not saved diagram placements. Different reference paths to the same defining occurrence are different selected targets. Legacy derived `OrderedPath` arrows lack relation identities: do not fabricate editable IDs or substitute endpoints. `topology/usages.rs::Graph::usage_bounds` provides exact displayed-target visibility, including bounded qualified previews. Hidden preview tiles do not fall back to their outer reference. Its approximate reveal rectangles are not adequate edge hit tests. Qualified preview content stays read-only until its definition is explicitly opened. -## Movement candidates +### Movement candidates considered | Candidate | Assessment | | --- | --- | | Persist arbitrary XY | Conflicts with derived process topology and needs new schema; reject for this adapter. | | Sequential singleton reorder | Produces click-order-dependent layouts and does not define a simultaneous operation; reject. | -| Stable block within one original column | Candidate: preserves existing topology and uses one existing `SetSteps` command. | +| Stable block within one original column | Accepted with the column-slot correction: preserves topology and uses one existing `SetSteps` command. | | Rewire links during a cross-column drag | Alters process execution meaning; requires a separate capability and explicit interaction. | -Existing `topology/direct.rs::reorder` verifies local contributions and equal original graph X-columns, then validates `SetSteps`. `Graph::expanded_qualified` derives ranks from sequence links and legacy order, with steps supplying vertical order; cyclic fallback can depend on enumeration order. +The former `topology/direct.rs::reorder` verified local contributions and equal graph X-columns, globally spliced steps, then validated `SetSteps`. `Graph::expanded_qualified` derived ranks from sequence links and legacy order, with steps supplying vertical order; cyclic fallback could depend on enumeration order. The shared integer rank helper and singleton migration supersede that implementation. The proposed pure planner admits the whole nonempty set only if every target is a visible local occurrence with an empty reference path and belongs to this owner. Selected IDs and the destination must share one original column. Build the block in original steps order, then use the column-slot permutation below: preserve other-column indices and unselected order within this column. Object-anchored drops on selected items are no-op; a raw gap is a separate API. Check the complete rank map remains unchanged, especially cycle fallback. Distinguish no-op from a command so it creates no source/history entry. Preview and commit must consume this same planned order; do not freely translate cards. -Before-card only could reuse existing behavior but makes moving to the end awkward. Before/after slots are the preferred candidate, subject to independent review and actual interaction testing. Participation of each occurrence-body type also needs a precise decision. +Before-card only could reuse earlier behavior but made moving to the end awkward. Before/After and all four local contribution kinds were subsequently accepted and implemented; actual interaction acceptance remains pending. -## Hide and removal +### Original Hide and removal proposal Process has no saved placements and therefore no existing Hide operation. Explain that limitation; do not silently substitute semantic deletion or add persisted hidden cards without a separate design decision. Homogeneous local-occurrence removal should present one aggregate impact review and apply one `Batch(Delete(...))`. Core deferred deletion already supports union cleanup. Preserve referenced work, exchanges, reused process definitions and opposite-layer counterparts. Initially refuse qualified or mixed sets in full; local-link-only deletion can be a separate extension. Never invoke singleton deletion once per member. Capture document, revision, surface and selection generation through confirmation, revalidate the whole set, and preserve pending property/source/forms. -## Implementation boundaries and falsifiers +### Initial implementation plan and falsifiers -Extend `app/multiselect.rs` with Process dispatch and exact primary paths, avoiding the assumption that every context is a saved diagram. Use `app/process_canvas.rs` for captured adapter commands. The input and reorder planner belong in `behavior/topology/direct.rs`; graph/render changes in `topology.rs`; exact visibility in `topology/usages.rs`; stable controls in `topology/view.rs`. Process currently lacks the needed canvas right-click branch, so contextual Add/Remove needs explicit transport into `model_menu.rs`. Aggregate confirmation belongs at `app/diagram_removal.rs` and its view. Reject stale and legacy singleton commands before App's shared side effects. +The initial plan called for Process dispatch, exact primary paths, right-click membership and whole-set guards rather than assuming every context is a saved diagram. These are implemented across the source boundaries above; the pure planner and input adapter were factored into dedicated modules rather than expanding `direct.rs`. Aggregate confirmation at `app/diagram_removal.rs` remains deferred. -The current shared group gesture requires saved placements and an XY anchor; reuse its lifecycle concepts, not that constructor unchanged. Compare a small shared threshold/cancellation component with a Process order-specific gesture before introducing abstraction. +The saved-view group gesture requires placements and an XY anchor. A Process order-specific gesture was chosen, preserving its lifecycle principles without widening that constructor into an inappropriate generic geometry abstraction. First useful outcome: exact local/qualified/link selection with whole-set refusals, plus one atomic same-column local-occurrence reorder and one Undo/Redo. Aggregate removal remains separate acceptance if omitted from that outcome. Tests must independently assert source-order blocks from noncontiguous reverse-click selections; unchanged other-column indices and unselected order within the affected column, relations and reusable definitions; identical referenced work represented by distinct occurrences; distinct qualified paths and collapse visibility; full refusal of cross-column/mixed/stale sets; no-op history; ordered pointer/focus/Shift/cancellation/camera behavior; no primary-only reconnect bypass; OA and SA, legacy exchange order, explicit branches and cyclic fallback. Native slot feedback, stable canvas/camera, source save/reopen and one-step history are required beyond compilation. -## Independent critique: revise before implementation +### Independent critiques that changed the design A fresh read-only Astra review found a concrete defect in global block splicing. Given steps `[a,u,b,v,c]`, one displayed column `[a,b,c]`, and selected `{b,c}`, dropping after `a` is visually a no-op. The proposed global splice produces `[a,b,c,u,v]`, changing source/history without a visible change. Equivalent before/after positions in the same displayed gap must have one interpretation. -The revised candidate is a **column-slot permutation**: extract this column's order from original steps, remove the selected members, normalize the drop to a gap in the remainder, insert the original-order selected block, then write the resulting column order back into this column's original steps indices. Other-column indices remain exact; unselected relative order within the affected column is preserved. This deliberately replaces the earlier all-unselected-global-order condition. Return no command when column order is unchanged, and verify the complete ID-to-rank map remains unchanged before accepting `SetSteps`. Local Action, Exchange, Control and SequenceReference occurrences are proposed participants; qualified descendants and links are whole-set refusals. This revision still requires an independent pure experiment before UI implementation. +The correction was a **column-slot permutation**, now implemented as specified above. It deliberately replaces the earlier all-unselected-global-order condition with exact other-column indices and within-column unselected order. The independent pure experiment preceded UI implementation; selected-anchor no-op remained distinct from raw-gap normalization. Scope correction: legacy ordered-path nodes have successive ranks, and cycle-blocked nodes receive distinct fallback ranks (`topology.rs` rank computation). A multiple selection of those nodes cannot satisfy same-column admission. Test refusal and unchanged execution meaning rather than claiming useful reordering of those sets. A graph containing a cycle can still admit a separate acyclic peer group. -Removal has an additional semantic consequence: `behavior.rs` derives explicit-versus-legacy interpretation from remaining occurrence kinds and SequenceLinks. Deleting action/control from `[action,exchange1,control,exchange2]` without links leaves an exchange-only sequence with implicit ordered-linear meaning. Aggregate review must expose this transition or initially refuse it. Include caller-owned ContextualLinks targeting removed occurrences in the impact union (`references.rs` cleanup), not just local links. See the presentation-versus-legacy distinction in `docs/language.rst`. +Removal has an additional semantic consequence: `behavior.rs` derives explicit-versus-legacy interpretation from remaining occurrence kinds and SequenceLinks. Deleting action/control from `[action,exchange1,control,exchange2]` without links leaves an exchange-only sequence with implicit ordered-linear meaning. Aggregate review must expose this transition or initially refuse it. Include caller-owned ContextualLinks targeting removed occurrences in the impact union (`references.rs` cleanup), not just local links. See the presentation-versus-legacy distinction in [language documentation](../docs/language.rst). -Smallest production experiment: independent OA/SA fixtures with interleaved steps and different card heights; reverse-click selection; before-first/after-last and equivalent gaps; already-positioned no-ops; literal expected steps; complete Project equality outside steps; unchanged ranks; exact source roundtrip and one Undo/Redo. Add legacy and cycle refusal plus acyclic peers beside a cycle. Separately demonstrate deletion-induced mode switching before designing confirmation. These Rust/application checks have not run yet. +The original production-evaluator plan became the Rust/App tests and native fixture above. Deletion-induced mode switching still needs a separate removal evaluator. A later source critic found an incomplete-order panic (`a→u` with U omitted from owner steps), prompting complete planner admission and graceful renderer omission. Bounded test results must remain tied to the source and receipts actually exercised. ## Bounded permutation experiment @@ -57,4 +121,4 @@ Smallest production experiment: independent OA/SA fixtures with interleaved step An object-anchored Before/After interface can keep selected-target no-op without losing changed destinations: all remainder gaps were reachable using unselected anchors in 2,036 bounded subsets. Raw-gap normalization is different: in `[a,b,c,d]`, selecting `{b,d}` and normalizing Before `d` gives `[a,c,b,d]`; selected-target no-op keeps the original, while After unselected `c` reaches the changed order. This supports the simpler selected-target no-op interaction. Block contiguity applies to an admitted planned move, not to that no-op. Actual pointer feedback, production graph admission, serialization and history remain unverified. -An independent critic matched the experiment's rank algorithm to Rust and found no counterexample under the stated valid-input assumptions. The review prompted an explicit five-node changed group beside a cycle; four-node enumeration could only cover singleton movement or unchanged whole-peer blocks there. It also identified a compatibility choice: current singleton `direct::reorder` globally splices, so moving `b` before `a` in `[a,u,b,v,c]` writes `[b,a,u,v,c]`, while this candidate writes `[b,u,a,v,c]` with the same display. Prefer one column-slot planner for both singleton and group reorder, preserving unrelated column indices consistently. This would intentionally revise singleton source ordering and requires production regression/roundtrip/history checks; no such migration is implemented here. Retaining global singleton splice was rejected as two source transformation rules for the same visible operation. Native anchor reachability and selected-target guard execution remain separate checks. +An independent critic matched the experiment's rank algorithm to Rust and found no counterexample under the stated valid-input assumptions. The review prompted an explicit five-node changed group beside a cycle; four-node enumeration could only cover singleton movement or unchanged whole-peer blocks there. It also identified the singleton global-splice compatibility choice. Retaining that splice was rejected as two source transformation rules for the same visible operation; the implemented migration above intentionally preserves unrelated column indices instead. Native anchor reachability and selected-target guard execution remain separate acceptance checks. These exploratory results support the design but do not replace runtime/native verification. diff --git a/harness/multiselect-process-evidence-20260923/.gitattributes b/harness/multiselect-process-evidence-20260923/.gitattributes new file mode 100644 index 00000000..0e97fd3d --- /dev/null +++ b/harness/multiselect-process-evidence-20260923/.gitattributes @@ -0,0 +1,2 @@ +# Receipt-bound files must retain exact bytes through Git checkout. +** -text whitespace=cr-at-eol diff --git a/harness/multiselect-process-evidence-20260923/checks/README.md b/harness/multiselect-process-evidence-20260923/checks/README.md new file mode 100644 index 00000000..91ee474d --- /dev/null +++ b/harness/multiselect-process-evidence-20260923/checks/README.md @@ -0,0 +1,58 @@ +# Process multiselect closed gate evidence + +These tools adapt the State multiselect checks packager/verifier. They copy +explicitly named **closed** runs from `.local/process-group-gates`, preserve +their exact seven raw files, retain nonzero exit codes and both streams, and +verify all source-map/archive bytes before writing and again after ZIP readback. +They never run Cargo, execute archived code, extract inputs, or publish evidence. + +Check-only example: + +```text +python -B prepare.py --root CHECKOUT --runs core-1 desktop-1 desktop-2 lint-1 full-desktop-1 lint-2 format-2 --check-only +``` + +After all desired runs close, supply a fresh, nonexistent output directory: + +```text +python -B prepare.py --root CHECKOUT --runs core-1 desktop-1 desktop-2 lint-1 full-desktop-1 desktop-3 feedback-1 lint-2 format-2 native-build-1 windows-build-1 --output FRESH_DIRECTORY +python -B verify.py FRESH_DIRECTORY +``` + +Run names in the second example are a proposed final inventory, not an existence, +completion, pass, or source-equivalence claim. The parent chooses the final list. +Preserve failed desktop-1, desktop-2, and lint-1 alongside subsequent receipts; +their successful packaging means only byte/receipt consistency. No run is inferred +from directory scanning. Open/incomplete runs, changed-input receipts, missing +historical helpers, unsafe/duplicate archive members, and mismatched input +maps/bytes are refused. An existing output directory is refused even if empty. +Use repeatable `--helper PATH` to provide an exact historical capture helper when +the current two helpers no longer match a selected receipt. + +`manifest.json` maps each logical raw path to its stored ZIP member. Input maps +and input ZIPs deduplicate independently by their exact SHA-256 bytes, including +ZIP metadata; equal source contents need not imply byte-identical ZIPs. +`captureHelpers` names both gate.py and gate-feedback.py and points to their +hash-addressed archived bytes. Each run separately references the helper matching +its receipt. Current reviewed helper SHA-256 values are: + +```text +gate.py 1368375b1fba0dfc50b77ea4f406dba71960a78317ce8f259d4c4a0254863a20 +gate-feedback.py 230f1222f133edef76ffb9c09ed6eecf952fc326b04a0bbccb3f3f49df4c0c29 +``` + +Both current named helpers are included even when no selected run uses one yet. +The manifest is authoritative for helper pins at packaging time; this README's +values document this tooling review, not a substitute for receipt matching. +`files.json` pins the archive, manifest, tools and this README. Keep that pin file +under the parent's source/evidence record; self-consistency is not authentication. + +The verifier retains the existing 71-file vendored-input invariant and requires +`inputsUnchanged` to be exactly true. Source coverage is whatever each original +input map and receipt actually captured; recorded HEAD alone does not identify +uncommitted tested bytes. Executable readback pins are receipts only: no executable +or toolchain is packaged or independently reconstructed. Rendered feedback images, +native screenshots/saves and source-to-commit receipts are separate parent-owned +packages. Offline validation is not test execution, native acceptance or usability +evidence. `selfcheck.py` checks these tools against explicitly selected closed runs +and includes in-memory corruption rejection checks. diff --git a/harness/multiselect-process-evidence-20260923/checks/evidence.zip b/harness/multiselect-process-evidence-20260923/checks/evidence.zip new file mode 100644 index 00000000..0b25b9f8 Binary files /dev/null and b/harness/multiselect-process-evidence-20260923/checks/evidence.zip differ diff --git a/harness/multiselect-process-evidence-20260923/checks/files.json b/harness/multiselect-process-evidence-20260923/checks/files.json new file mode 100644 index 00000000..ac81837d --- /dev/null +++ b/harness/multiselect-process-evidence-20260923/checks/files.json @@ -0,0 +1,22 @@ +{ + "prepare.py": { + "bytes": 5326, + "sha256": "2c8d7cbd1ec2415e671c35734fb5b78beb9a535626b54492a4c1d3687a6e4dd9" + }, + "verify.py": { + "bytes": 9497, + "sha256": "a52e4057d975717d132c1a8b656d34e1ef8ef8fbd125687715a69696a866fd44" + }, + "README.md": { + "bytes": 3344, + "sha256": "9d1eaa1dabdad0d95829da6d6e3004703ba90bc9824a963c43ca61e24b210e7e" + }, + "manifest.json": { + "bytes": 45584, + "sha256": "ea8d070535c3d62f3e2c13b81340ad2d506863fd93fe981f03a4edb87552e943" + }, + "evidence.zip": { + "bytes": 20916711, + "sha256": "591a81f55aa409044ecbcaf6da83ca107602a6ab690b347b84fd40bea469fd35" + } +} diff --git a/harness/multiselect-process-evidence-20260923/checks/manifest.json b/harness/multiselect-process-evidence-20260923/checks/manifest.json new file mode 100644 index 00000000..325a1fc1 --- /dev/null +++ b/harness/multiselect-process-evidence-20260923/checks/manifest.json @@ -0,0 +1,1126 @@ +{ + "schema": 1, + "kind": "multiselect-cargo-gates", + "runs": { + "core-1": { + "command": [ + "cargo", + "test", + "-p", + "oa-core", + "--example", + "process_multiselect_native", + "--message-format=json", + "--", + "--test-threads=1" + ], + "head": "bed7f825fd00ec32299032a664a5f5d753aad86d", + "exitCode": 0, + "inputCount": 742, + "helper": "helpers/1368375b1fba0dfc50b77ea4f406dba71960a78317ce8f259d4c4a0254863a20.py" + }, + "core-2": { + "command": [ + "cargo", + "test", + "-p", + "oa-core", + "--example", + "process_multiselect_native", + "--message-format=json", + "--", + "--test-threads=1" + ], + "head": "b15345123a7feaf630ef24d45f591c76c6715778", + "exitCode": 0, + "inputCount": 742, + "helper": "helpers/1368375b1fba0dfc50b77ea4f406dba71960a78317ce8f259d4c4a0254863a20.py" + }, + "core-3": { + "command": [ + "cargo", + "test", + "-p", + "oa-core", + "--example", + "process_multiselect_native", + "--message-format=json", + "--", + "--test-threads=1" + ], + "head": "ff712b24190342363d0275467e96f181872da6ad", + "exitCode": 0, + "inputCount": 742, + "helper": "helpers/1368375b1fba0dfc50b77ea4f406dba71960a78317ce8f259d4c4a0254863a20.py" + }, + "desktop-1": { + "command": [ + "cargo", + "test", + "-p", + "oa-desktop", + "--message-format=json", + "--", + "topology", + "process_", + "canvas_workspace", + "repeated_process", + "--test-threads=1" + ], + "head": "bed7f825fd00ec32299032a664a5f5d753aad86d", + "exitCode": 101, + "inputCount": 742, + "helper": "helpers/1368375b1fba0dfc50b77ea4f406dba71960a78317ce8f259d4c4a0254863a20.py" + }, + "desktop-2": { + "command": [ + "cargo", + "test", + "-p", + "oa-desktop", + "--message-format=json", + "--", + "topology", + "process_", + "canvas_workspace", + "repeated_process", + "--test-threads=1" + ], + "head": "bed7f825fd00ec32299032a664a5f5d753aad86d", + "exitCode": 101, + "inputCount": 742, + "helper": "helpers/1368375b1fba0dfc50b77ea4f406dba71960a78317ce8f259d4c4a0254863a20.py" + }, + "desktop-3": { + "command": [ + "cargo", + "test", + "-p", + "oa-desktop", + "--message-format=json", + "--", + "topology", + "process_", + "canvas_workspace", + "repeated_process", + "menu", + "--test-threads=1" + ], + "head": "1711bff650dabb1df3a9bc37f19060d5db297feb", + "exitCode": 0, + "inputCount": 742, + "helper": "helpers/1368375b1fba0dfc50b77ea4f406dba71960a78317ce8f259d4c4a0254863a20.py" + }, + "desktop-4": { + "command": [ + "cargo", + "test", + "-p", + "oa-desktop", + "--message-format=json", + "topology", + "process_", + "canvas_workspace", + "repeated_process", + "menu", + "--", + "--test-threads=1" + ], + "head": "b15345123a7feaf630ef24d45f591c76c6715778", + "exitCode": 1, + "inputCount": 742, + "helper": "helpers/1368375b1fba0dfc50b77ea4f406dba71960a78317ce8f259d4c4a0254863a20.py" + }, + "desktop-5": { + "command": [ + "cargo", + "test", + "-p", + "oa-desktop", + "--message-format=json", + "--", + "topology", + "process_", + "canvas_workspace", + "repeated_process", + "menu", + "--test-threads=1" + ], + "head": "b15345123a7feaf630ef24d45f591c76c6715778", + "exitCode": 101, + "inputCount": 742, + "helper": "helpers/1368375b1fba0dfc50b77ea4f406dba71960a78317ce8f259d4c4a0254863a20.py" + }, + "desktop-6": { + "command": [ + "cargo", + "test", + "-p", + "oa-desktop", + "--message-format=json", + "--", + "topology", + "process_", + "canvas_workspace", + "repeated_process", + "menu", + "--test-threads=1" + ], + "head": "b15345123a7feaf630ef24d45f591c76c6715778", + "exitCode": 0, + "inputCount": 742, + "helper": "helpers/1368375b1fba0dfc50b77ea4f406dba71960a78317ce8f259d4c4a0254863a20.py" + }, + "feedback-1": { + "command": [ + "cargo", + "test", + "-p", + "oa-desktop", + "--message-format=json", + "--", + "production_process_multiselect_reverse_click_group_drop_has_one_history_step_and_stable_camera", + "--test-threads=1" + ], + "head": "1711bff650dabb1df3a9bc37f19060d5db297feb", + "exitCode": 0, + "inputCount": 742, + "helper": "helpers/230f1222f133edef76ffb9c09ed6eecf952fc326b04a0bbccb3f3f49df4c0c29.py" + }, + "format-1": { + "command": [ + "cargo", + "fmt", + "--all", + "--", + "--check" + ], + "head": "bed7f825fd00ec32299032a664a5f5d753aad86d", + "exitCode": 0, + "inputCount": 742, + "helper": "helpers/1368375b1fba0dfc50b77ea4f406dba71960a78317ce8f259d4c4a0254863a20.py" + }, + "format-2": { + "command": [ + "cargo", + "fmt", + "--all", + "--", + "--check" + ], + "head": "1711bff650dabb1df3a9bc37f19060d5db297feb", + "exitCode": 0, + "inputCount": 742, + "helper": "helpers/1368375b1fba0dfc50b77ea4f406dba71960a78317ce8f259d4c4a0254863a20.py" + }, + "format-3": { + "command": [ + "cargo", + "fmt", + "--all", + "--", + "--check" + ], + "head": "b15345123a7feaf630ef24d45f591c76c6715778", + "exitCode": 0, + "inputCount": 742, + "helper": "helpers/1368375b1fba0dfc50b77ea4f406dba71960a78317ce8f259d4c4a0254863a20.py" + }, + "format-4": { + "command": [ + "cargo", + "fmt", + "--all", + "--", + "--check" + ], + "head": "ff712b24190342363d0275467e96f181872da6ad", + "exitCode": 0, + "inputCount": 742, + "helper": "helpers/1368375b1fba0dfc50b77ea4f406dba71960a78317ce8f259d4c4a0254863a20.py" + }, + "full-desktop-1": { + "command": [ + "cargo", + "test", + "-p", + "oa-desktop", + "--message-format=json", + "--", + "--test-threads=1" + ], + "head": "bed7f825fd00ec32299032a664a5f5d753aad86d", + "exitCode": 0, + "inputCount": 742, + "helper": "helpers/1368375b1fba0dfc50b77ea4f406dba71960a78317ce8f259d4c4a0254863a20.py" + }, + "full-desktop-2": { + "command": [ + "cargo", + "test", + "-p", + "oa-desktop", + "--message-format=json", + "--", + "--test-threads=1" + ], + "head": "b15345123a7feaf630ef24d45f591c76c6715778", + "exitCode": 0, + "inputCount": 742, + "helper": "helpers/1368375b1fba0dfc50b77ea4f406dba71960a78317ce8f259d4c4a0254863a20.py" + }, + "lint-1": { + "command": [ + "cargo", + "clippy", + "-p", + "oa-core", + "-p", + "oa-desktop", + "--all-targets", + "--message-format=json", + "--", + "-D", + "warnings" + ], + "head": "bed7f825fd00ec32299032a664a5f5d753aad86d", + "exitCode": 101, + "inputCount": 742, + "helper": "helpers/1368375b1fba0dfc50b77ea4f406dba71960a78317ce8f259d4c4a0254863a20.py" + }, + "lint-2": { + "command": [ + "cargo", + "clippy", + "-p", + "oa-core", + "-p", + "oa-desktop", + "--all-targets", + "--message-format=json", + "--", + "-D", + "warnings" + ], + "head": "1711bff650dabb1df3a9bc37f19060d5db297feb", + "exitCode": 0, + "inputCount": 742, + "helper": "helpers/1368375b1fba0dfc50b77ea4f406dba71960a78317ce8f259d4c4a0254863a20.py" + }, + "lint-3": { + "command": [ + "cargo", + "clippy", + "-p", + "oa-core", + "-p", + "oa-desktop", + "--all-targets", + "--message-format=json", + "--", + "-D", + "warnings" + ], + "head": "b15345123a7feaf630ef24d45f591c76c6715778", + "exitCode": 0, + "inputCount": 742, + "helper": "helpers/1368375b1fba0dfc50b77ea4f406dba71960a78317ce8f259d4c4a0254863a20.py" + }, + "lint-4": { + "command": [ + "cargo", + "clippy", + "-p", + "oa-core", + "--example", + "process_multiselect_native", + "--message-format=json", + "--", + "-D", + "warnings" + ], + "head": "ff712b24190342363d0275467e96f181872da6ad", + "exitCode": 0, + "inputCount": 742, + "helper": "helpers/1368375b1fba0dfc50b77ea4f406dba71960a78317ce8f259d4c4a0254863a20.py" + }, + "native-build-1": { + "command": [ + "cargo", + "build", + "-p", + "oa-core", + "--example", + "process_multiselect_native", + "--message-format=json" + ], + "head": "b15345123a7feaf630ef24d45f591c76c6715778", + "exitCode": 0, + "inputCount": 742, + "helper": "helpers/1368375b1fba0dfc50b77ea4f406dba71960a78317ce8f259d4c4a0254863a20.py" + }, + "native-build-2": { + "command": [ + "cargo", + "build", + "-p", + "oa-core", + "--example", + "process_multiselect_native", + "--message-format=json" + ], + "head": "b743a10d51032dc1655b94fed5e49fbbbcee83f4", + "exitCode": 0, + "inputCount": 742, + "helper": "helpers/1368375b1fba0dfc50b77ea4f406dba71960a78317ce8f259d4c4a0254863a20.py" + }, + "windows-build-1": { + "command": [ + "cargo", + "build", + "-p", + "oa-desktop", + "--message-format=json" + ], + "head": "b15345123a7feaf630ef24d45f591c76c6715778", + "exitCode": 0, + "inputCount": 742, + "helper": "helpers/1368375b1fba0dfc50b77ea4f406dba71960a78317ce8f259d4c4a0254863a20.py" + }, + "windows-build-2": { + "command": [ + "cargo", + "build", + "-p", + "oa-desktop", + "--message-format=json" + ], + "head": "b743a10d51032dc1655b94fed5e49fbbbcee83f4", + "exitCode": 0, + "inputCount": 742, + "helper": "helpers/1368375b1fba0dfc50b77ea4f406dba71960a78317ce8f259d4c4a0254863a20.py" + } + }, + "captureHelpers": { + "gate.py": "helpers/1368375b1fba0dfc50b77ea4f406dba71960a78317ce8f259d4c4a0254863a20.py", + "gate-feedback.py": "helpers/230f1222f133edef76ffb9c09ed6eecf952fc326b04a0bbccb3f3f49df4c0c29.py" + }, + "logicalFiles": { + "cargo/core-1/inputs.json": "capsules/50a2a94fd8302e657ec60b736134cbb0317bb45dbadaa33386654450588dc4f1.json", + "cargo/core-1/inputs.zip": "capsules/5d461ddbf464abb553f430bedd5c043cb104dfe27b38369e9e123acb92475fac.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/7f5b3de4d5bb70282e424fbe90211af2ac259965aef10b05438b46076467a767.json", + "cargo/core-2/inputs.zip": "capsules/f681fb5ead73d5aba1d9a0f25ea29a96ccb430692630b64987b984623cd38148.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/f3fd180af294e0797f9dfdbff647274de30a907b685a4fe1a2b58027bfe7da95.json", + "cargo/core-3/inputs.zip": "capsules/2dcc12ec457a6df0997c11c2fed3d5085b7033c9e100ec7d29b01af254d693cc.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/50a2a94fd8302e657ec60b736134cbb0317bb45dbadaa33386654450588dc4f1.json", + "cargo/desktop-1/inputs.zip": "capsules/5d461ddbf464abb553f430bedd5c043cb104dfe27b38369e9e123acb92475fac.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/eabf7fb87cf9872d08482b1178f16553e5d194596639f51e42f5a248ffdb66f0.json", + "cargo/desktop-2/inputs.zip": "capsules/7991e2c80a908d40a85e0a68730b337e47fed993550d5d6dc6be52607f915a82.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/7f5b3de4d5bb70282e424fbe90211af2ac259965aef10b05438b46076467a767.json", + "cargo/desktop-3/inputs.zip": "capsules/f681fb5ead73d5aba1d9a0f25ea29a96ccb430692630b64987b984623cd38148.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/b00d7695489437f5c896abe5a12e99a42f1b0b759d9e07828bf4b3bb3de69634.json", + "cargo/desktop-4/inputs.zip": "capsules/d21871d7b41d87f34d483382886e4c3cb46023a23f911b541852ffc154529b55.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/b00d7695489437f5c896abe5a12e99a42f1b0b759d9e07828bf4b3bb3de69634.json", + "cargo/desktop-5/inputs.zip": "capsules/d21871d7b41d87f34d483382886e4c3cb46023a23f911b541852ffc154529b55.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/desktop-6/inputs.json": "capsules/1ba4ed414b3ccbc139ad4998af77370cba994f3edf4395b80acc69d3440ccc45.json", + "cargo/desktop-6/inputs.zip": "capsules/c5e367e9a2d1c155bf872fc42ae5a1bbb06947ee4b6ad5bc6c9ac767eee2166c.zip", + "cargo/desktop-6/process.json": "cargo/desktop-6/process.json", + "cargo/desktop-6/result.json": "cargo/desktop-6/result.json", + "cargo/desktop-6/started.json": "cargo/desktop-6/started.json", + "cargo/desktop-6/stderr.log": "cargo/desktop-6/stderr.log", + "cargo/desktop-6/stdout.log": "cargo/desktop-6/stdout.log", + "cargo/feedback-1/inputs.json": "capsules/7f5b3de4d5bb70282e424fbe90211af2ac259965aef10b05438b46076467a767.json", + "cargo/feedback-1/inputs.zip": "capsules/f681fb5ead73d5aba1d9a0f25ea29a96ccb430692630b64987b984623cd38148.zip", + "cargo/feedback-1/process.json": "cargo/feedback-1/process.json", + "cargo/feedback-1/result.json": "cargo/feedback-1/result.json", + "cargo/feedback-1/started.json": "cargo/feedback-1/started.json", + "cargo/feedback-1/stderr.log": "cargo/feedback-1/stderr.log", + "cargo/feedback-1/stdout.log": "cargo/feedback-1/stdout.log", + "cargo/format-1/inputs.json": "capsules/d5acfcf62fb4efd4efaf073abcc7e671644a6086e9e6cf105a06034c4a27daf8.json", + "cargo/format-1/inputs.zip": "capsules/54f2d4cf11dce768ba3a5376938f377ff1f0c169ab55e72f97206f291492014d.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/7f5b3de4d5bb70282e424fbe90211af2ac259965aef10b05438b46076467a767.json", + "cargo/format-2/inputs.zip": "capsules/f681fb5ead73d5aba1d9a0f25ea29a96ccb430692630b64987b984623cd38148.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/format-3/inputs.json": "capsules/1ba4ed414b3ccbc139ad4998af77370cba994f3edf4395b80acc69d3440ccc45.json", + "cargo/format-3/inputs.zip": "capsules/c5e367e9a2d1c155bf872fc42ae5a1bbb06947ee4b6ad5bc6c9ac767eee2166c.zip", + "cargo/format-3/process.json": "cargo/format-3/process.json", + "cargo/format-3/result.json": "cargo/format-3/result.json", + "cargo/format-3/started.json": "cargo/format-3/started.json", + "cargo/format-3/stderr.log": "cargo/format-3/stderr.log", + "cargo/format-3/stdout.log": "cargo/format-3/stdout.log", + "cargo/format-4/inputs.json": "capsules/f3fd180af294e0797f9dfdbff647274de30a907b685a4fe1a2b58027bfe7da95.json", + "cargo/format-4/inputs.zip": "capsules/2dcc12ec457a6df0997c11c2fed3d5085b7033c9e100ec7d29b01af254d693cc.zip", + "cargo/format-4/process.json": "cargo/format-4/process.json", + "cargo/format-4/result.json": "cargo/format-4/result.json", + "cargo/format-4/started.json": "cargo/format-4/started.json", + "cargo/format-4/stderr.log": "cargo/format-4/stderr.log", + "cargo/format-4/stdout.log": "cargo/format-4/stdout.log", + "cargo/full-desktop-1/inputs.json": "capsules/d5acfcf62fb4efd4efaf073abcc7e671644a6086e9e6cf105a06034c4a27daf8.json", + "cargo/full-desktop-1/inputs.zip": "capsules/54f2d4cf11dce768ba3a5376938f377ff1f0c169ab55e72f97206f291492014d.zip", + "cargo/full-desktop-1/process.json": "cargo/full-desktop-1/process.json", + "cargo/full-desktop-1/result.json": "cargo/full-desktop-1/result.json", + "cargo/full-desktop-1/started.json": "cargo/full-desktop-1/started.json", + "cargo/full-desktop-1/stderr.log": "cargo/full-desktop-1/stderr.log", + "cargo/full-desktop-1/stdout.log": "cargo/full-desktop-1/stdout.log", + "cargo/full-desktop-2/inputs.json": "capsules/1ba4ed414b3ccbc139ad4998af77370cba994f3edf4395b80acc69d3440ccc45.json", + "cargo/full-desktop-2/inputs.zip": "capsules/c5e367e9a2d1c155bf872fc42ae5a1bbb06947ee4b6ad5bc6c9ac767eee2166c.zip", + "cargo/full-desktop-2/process.json": "cargo/full-desktop-2/process.json", + "cargo/full-desktop-2/result.json": "cargo/full-desktop-2/result.json", + "cargo/full-desktop-2/started.json": "cargo/full-desktop-2/started.json", + "cargo/full-desktop-2/stderr.log": "cargo/full-desktop-2/stderr.log", + "cargo/full-desktop-2/stdout.log": "cargo/full-desktop-2/stdout.log", + "cargo/lint-1/inputs.json": "capsules/d5acfcf62fb4efd4efaf073abcc7e671644a6086e9e6cf105a06034c4a27daf8.json", + "cargo/lint-1/inputs.zip": "capsules/54f2d4cf11dce768ba3a5376938f377ff1f0c169ab55e72f97206f291492014d.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/7f5b3de4d5bb70282e424fbe90211af2ac259965aef10b05438b46076467a767.json", + "cargo/lint-2/inputs.zip": "capsules/f681fb5ead73d5aba1d9a0f25ea29a96ccb430692630b64987b984623cd38148.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/1ba4ed414b3ccbc139ad4998af77370cba994f3edf4395b80acc69d3440ccc45.json", + "cargo/lint-3/inputs.zip": "capsules/c5e367e9a2d1c155bf872fc42ae5a1bbb06947ee4b6ad5bc6c9ac767eee2166c.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/lint-4/inputs.json": "capsules/f3fd180af294e0797f9dfdbff647274de30a907b685a4fe1a2b58027bfe7da95.json", + "cargo/lint-4/inputs.zip": "capsules/2dcc12ec457a6df0997c11c2fed3d5085b7033c9e100ec7d29b01af254d693cc.zip", + "cargo/lint-4/process.json": "cargo/lint-4/process.json", + "cargo/lint-4/result.json": "cargo/lint-4/result.json", + "cargo/lint-4/started.json": "cargo/lint-4/started.json", + "cargo/lint-4/stderr.log": "cargo/lint-4/stderr.log", + "cargo/lint-4/stdout.log": "cargo/lint-4/stdout.log", + "cargo/native-build-1/inputs.json": "capsules/7f5b3de4d5bb70282e424fbe90211af2ac259965aef10b05438b46076467a767.json", + "cargo/native-build-1/inputs.zip": "capsules/f681fb5ead73d5aba1d9a0f25ea29a96ccb430692630b64987b984623cd38148.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/f3fd180af294e0797f9dfdbff647274de30a907b685a4fe1a2b58027bfe7da95.json", + "cargo/native-build-2/inputs.zip": "capsules/2dcc12ec457a6df0997c11c2fed3d5085b7033c9e100ec7d29b01af254d693cc.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/windows-build-1/inputs.json": "capsules/7f5b3de4d5bb70282e424fbe90211af2ac259965aef10b05438b46076467a767.json", + "cargo/windows-build-1/inputs.zip": "capsules/f681fb5ead73d5aba1d9a0f25ea29a96ccb430692630b64987b984623cd38148.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/f3fd180af294e0797f9dfdbff647274de30a907b685a4fe1a2b58027bfe7da95.json", + "cargo/windows-build-2/inputs.zip": "capsules/2dcc12ec457a6df0997c11c2fed3d5085b7033c9e100ec7d29b01af254d693cc.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/1368375b1fba0dfc50b77ea4f406dba71960a78317ce8f259d4c4a0254863a20.py": "helpers/1368375b1fba0dfc50b77ea4f406dba71960a78317ce8f259d4c4a0254863a20.py", + "helpers/230f1222f133edef76ffb9c09ed6eecf952fc326b04a0bbccb3f3f49df4c0c29.py": "helpers/230f1222f133edef76ffb9c09ed6eecf952fc326b04a0bbccb3f3f49df4c0c29.py" + }, + "members": { + "capsules/1ba4ed414b3ccbc139ad4998af77370cba994f3edf4395b80acc69d3440ccc45.json": { + "bytes": 124276, + "sha256": "1ba4ed414b3ccbc139ad4998af77370cba994f3edf4395b80acc69d3440ccc45" + }, + "capsules/2dcc12ec457a6df0997c11c2fed3d5085b7033c9e100ec7d29b01af254d693cc.zip": { + "bytes": 2987614, + "sha256": "2dcc12ec457a6df0997c11c2fed3d5085b7033c9e100ec7d29b01af254d693cc" + }, + "capsules/50a2a94fd8302e657ec60b736134cbb0317bb45dbadaa33386654450588dc4f1.json": { + "bytes": 124276, + "sha256": "50a2a94fd8302e657ec60b736134cbb0317bb45dbadaa33386654450588dc4f1" + }, + "capsules/54f2d4cf11dce768ba3a5376938f377ff1f0c169ab55e72f97206f291492014d.zip": { + "bytes": 2985236, + "sha256": "54f2d4cf11dce768ba3a5376938f377ff1f0c169ab55e72f97206f291492014d" + }, + "capsules/5d461ddbf464abb553f430bedd5c043cb104dfe27b38369e9e123acb92475fac.zip": { + "bytes": 2983752, + "sha256": "5d461ddbf464abb553f430bedd5c043cb104dfe27b38369e9e123acb92475fac" + }, + "capsules/7991e2c80a908d40a85e0a68730b337e47fed993550d5d6dc6be52607f915a82.zip": { + "bytes": 2985226, + "sha256": "7991e2c80a908d40a85e0a68730b337e47fed993550d5d6dc6be52607f915a82" + }, + "capsules/7f5b3de4d5bb70282e424fbe90211af2ac259965aef10b05438b46076467a767.json": { + "bytes": 124276, + "sha256": "7f5b3de4d5bb70282e424fbe90211af2ac259965aef10b05438b46076467a767" + }, + "capsules/b00d7695489437f5c896abe5a12e99a42f1b0b759d9e07828bf4b3bb3de69634.json": { + "bytes": 124276, + "sha256": "b00d7695489437f5c896abe5a12e99a42f1b0b759d9e07828bf4b3bb3de69634" + }, + "capsules/c5e367e9a2d1c155bf872fc42ae5a1bbb06947ee4b6ad5bc6c9ac767eee2166c.zip": { + "bytes": 2987472, + "sha256": "c5e367e9a2d1c155bf872fc42ae5a1bbb06947ee4b6ad5bc6c9ac767eee2166c" + }, + "capsules/d21871d7b41d87f34d483382886e4c3cb46023a23f911b541852ffc154529b55.zip": { + "bytes": 2986783, + "sha256": "d21871d7b41d87f34d483382886e4c3cb46023a23f911b541852ffc154529b55" + }, + "capsules/d5acfcf62fb4efd4efaf073abcc7e671644a6086e9e6cf105a06034c4a27daf8.json": { + "bytes": 124276, + "sha256": "d5acfcf62fb4efd4efaf073abcc7e671644a6086e9e6cf105a06034c4a27daf8" + }, + "capsules/eabf7fb87cf9872d08482b1178f16553e5d194596639f51e42f5a248ffdb66f0.json": { + "bytes": 124276, + "sha256": "eabf7fb87cf9872d08482b1178f16553e5d194596639f51e42f5a248ffdb66f0" + }, + "capsules/f3fd180af294e0797f9dfdbff647274de30a907b685a4fe1a2b58027bfe7da95.json": { + "bytes": 124276, + "sha256": "f3fd180af294e0797f9dfdbff647274de30a907b685a4fe1a2b58027bfe7da95" + }, + "capsules/f681fb5ead73d5aba1d9a0f25ea29a96ccb430692630b64987b984623cd38148.zip": { + "bytes": 2985964, + "sha256": "f681fb5ead73d5aba1d9a0f25ea29a96ccb430692630b64987b984623cd38148" + }, + "cargo/core-1/process.json": { + "bytes": 202, + "sha256": "6bec8cab8e25f85ce12eae63c1f1a7a7588f9f0a2590e15e6ce1ecbd64da5367" + }, + "cargo/core-1/result.json": { + "bytes": 3389, + "sha256": "b5ab41b84650466594131e579bcebf6b4959b01594e15dd0c97dcbac02c78730" + }, + "cargo/core-1/started.json": { + "bytes": 1589, + "sha256": "f750afed5cc7828160dfbaffdfdaf65f6e6ea6077155386dbad842416bd998c4" + }, + "cargo/core-1/stderr.log": { + "bytes": 324, + "sha256": "988ac75555adaa3a8162de10a6e086cfdf9d63694e4fd0df02d5a7a2fbe7bb5e" + }, + "cargo/core-1/stdout.log": { + "bytes": 42304, + "sha256": "d6110df004e6bd219a9bc7df6adbba827af7eee725807f38d4f4bb7044ecd302" + }, + "cargo/core-2/process.json": { + "bytes": 202, + "sha256": "4a3fb9bb4281e211028ce2c15e8070138c92aafe794bf152151ff49d65089304" + }, + "cargo/core-2/result.json": { + "bytes": 3387, + "sha256": "aeef77c61452cfeadc104a307d962a2fad4287a99d0fdecfb2d59069631d40b8" + }, + "cargo/core-2/started.json": { + "bytes": 1589, + "sha256": "96b3c701cba809868dccb603abcd12ccfb5ab200883b3bde75e65d33b13af2dc" + }, + "cargo/core-2/stderr.log": { + "bytes": 253, + "sha256": "3caa803d0c76a3e9d070934ec663f5f3e505ffb25f21c946a321e075a3f45541" + }, + "cargo/core-2/stdout.log": { + "bytes": 42303, + "sha256": "3f7d406f09c0d18fa464f7313e03e50d1a5587ebead8585bae8fcbb8fbc12bc3" + }, + "cargo/core-3/process.json": { + "bytes": 202, + "sha256": "fe18c8791e1290895cfcdc3d8e70955ba1a3d8d33bca825e2495bcfa3b0ba1d5" + }, + "cargo/core-3/result.json": { + "bytes": 3389, + "sha256": "935962d372e0570ea7e55416050c0ae37464148f7a327a63d40531be7e3f5489" + }, + "cargo/core-3/started.json": { + "bytes": 1589, + "sha256": "ee0d66717cc0121b191887b031c29a037268ded1df8b35624c0c61da476b8586" + }, + "cargo/core-3/stderr.log": { + "bytes": 323, + "sha256": "ede43135edfb82b643ab3af51a815546ddd9f069a29ce092571e11bbd633091b" + }, + "cargo/core-3/stdout.log": { + "bytes": 42303, + "sha256": "abd875a5d586d77e12a4e71b0d51d707f9cfafbd6cf980da58be2fe0576612ea" + }, + "cargo/desktop-1/process.json": { + "bytes": 225, + "sha256": "884be8242a9d9d5e363e0b25ce2aa937bf78ef758a716f1313f7e7f459f795d0" + }, + "cargo/desktop-1/result.json": { + "bytes": 3360, + "sha256": "959cd00d90c6311603e1c2910e6c23d98b102c004884a55a0560d34b2a8ff00a" + }, + "cargo/desktop-1/started.json": { + "bytes": 1623, + "sha256": "d02d433efbdbe63d5917b1d90b73296e205d67d2fc4954b092172bb17465eb21" + }, + "cargo/desktop-1/stderr.log": { + "bytes": 358, + "sha256": "d26edae49256149f93a16181ddf4e1fdf05019b97f6176f67feb14d69da72a5d" + }, + "cargo/desktop-1/stdout.log": { + "bytes": 310704, + "sha256": "0d5dd558a893448b0193422332f06f194a52d22c205507c12d6676123fc28572" + }, + "cargo/desktop-2/process.json": { + "bytes": 226, + "sha256": "cb2a01b905d18e5ed7a33346d025c84a02d456c166b5c51892552b952eb0c2b9" + }, + "cargo/desktop-2/result.json": { + "bytes": 3359, + "sha256": "6818ea36e2c99162772abddf1ad15831c87aa9ce99f2befe779b4214658b4432" + }, + "cargo/desktop-2/started.json": { + "bytes": 1623, + "sha256": "1fdc4e78cfa343b169d03c03678c2c6e82fdb1e77d2b917b09575b59e4b1e539" + }, + "cargo/desktop-2/stderr.log": { + "bytes": 358, + "sha256": "936c116c7fbb54d09dadea3ee99a7ee0aab6e22c5847663ec787180cad7b87a5" + }, + "cargo/desktop-2/stdout.log": { + "bytes": 306783, + "sha256": "92cc0cc535b2c7682b4f9d4509d3d6ec11e5ac6fcfcef7ef4f9b681a0f20e273" + }, + "cargo/desktop-3/process.json": { + "bytes": 234, + "sha256": "cadbdcb6942795ab506e08bcf0a2729187271826cd512862b305d3f95af6763a" + }, + "cargo/desktop-3/result.json": { + "bytes": 3371, + "sha256": "f3e857d930e70666dc6c5f88c508a98e035330db2d18f6ca10bdc3fd2efe0cbb" + }, + "cargo/desktop-3/started.json": { + "bytes": 1636, + "sha256": "cb2047f966669b93c4e4c480e3cb744fbc6cc4dd60b6ffebb455aaa2eb45379f" + }, + "cargo/desktop-3/stderr.log": { + "bytes": 287, + "sha256": "8db54c27653786373f3100bb0aad89ac82d08b684066178b6dcc57c0f30a5186" + }, + "cargo/desktop-3/stdout.log": { + "bytes": 310641, + "sha256": "a523e55b55dabc43ec507ffa50116f299158b6e91d7f906f59002675be551617" + }, + "cargo/desktop-4/process.json": { + "bytes": 234, + "sha256": "8f07e87bcefddf59b8a51ec35996b2dde71dc0dd67e314c46de1021cbc3b7d66" + }, + "cargo/desktop-4/result.json": { + "bytes": 2028, + "sha256": "964d5975595681cce40a659a4dc2283157f599e443f74b3fd0a70085d9ff9f55" + }, + "cargo/desktop-4/started.json": { + "bytes": 1636, + "sha256": "e04d4f066c6402799475f607bbe16d6f40a267e6287bed6497faac7eb31ca6a0" + }, + "cargo/desktop-4/stderr.log": { + "bytes": 140, + "sha256": "5e30c2f757d39ba09c42f239921c78c37974120df0da0063f2cf43fb10b7c903" + }, + "cargo/desktop-4/stdout.log": { + "bytes": 0, + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "cargo/desktop-5/process.json": { + "bytes": 234, + "sha256": "4c99e502f6d6e887c7489454b45291d74cd354e8ed7aac1311abe17d203b44ea" + }, + "cargo/desktop-5/result.json": { + "bytes": 3373, + "sha256": "9e799b7f410fe02586a1d3029eccfd9cec2452f1992e7b071dc2a752e88e69e4" + }, + "cargo/desktop-5/started.json": { + "bytes": 1636, + "sha256": "27c7b5f4960c6ec6337fa8327ef418bed447010bfd113211bcc9311ab821fddf" + }, + "cargo/desktop-5/stderr.log": { + "bytes": 358, + "sha256": "add3bda277cabc15598ab228c46683c2da7f2b0a55bd3f0a043bf3f06c17a04e" + }, + "cargo/desktop-5/stdout.log": { + "bytes": 311412, + "sha256": "83832ac3e66a691a72ccd2867cfba151c950937ed5a80a469032f0a28045c3ab" + }, + "cargo/desktop-6/process.json": { + "bytes": 234, + "sha256": "df402ee20070c46d1af54bd9301ae8cc394e1fd881d590368d1d9860f25ecbdc" + }, + "cargo/desktop-6/result.json": { + "bytes": 3371, + "sha256": "ba690911358cdbdfc16847466c688907ede520cd2642077685219caf7ac38470" + }, + "cargo/desktop-6/started.json": { + "bytes": 1636, + "sha256": "7d46478387b3a8c46b300fb621c2288d6f9158a6da19314026876db843337a68" + }, + "cargo/desktop-6/stderr.log": { + "bytes": 287, + "sha256": "1f617c1a2e680c3e0282499eebad6a530dd2941bcd0f09543ebeae786476b169" + }, + "cargo/desktop-6/stdout.log": { + "bytes": 311022, + "sha256": "780ab5b3eda2e0bd56b56a0e60b66c161cc2f811c084b64ac296a19383b37df5" + }, + "cargo/feedback-1/process.json": { + "bytes": 260, + "sha256": "b8559e934c97f9eba407b1ceceed637fc570a9a8ba9e3362b961824cb0344fcd" + }, + "cargo/feedback-1/result.json": { + "bytes": 3481, + "sha256": "2f007dfda469a75eaaf713577b394605af7d3d42fed2137d9e3ed632301c78d3" + }, + "cargo/feedback-1/started.json": { + "bytes": 1747, + "sha256": "01fbcfd28b752921ded95c40a711909851bcd005394b34b5f4f49a7eaa0a594c" + }, + "cargo/feedback-1/stderr.log": { + "bytes": 210, + "sha256": "504bdaa5c008c52a9462aeca71413f9933a4e9c6b0272715ba5ca916411781c1" + }, + "cargo/feedback-1/stdout.log": { + "bytes": 293378, + "sha256": "8c611aa1671316570c79b079882400e3b710d3eff8fe04e37ec59f17071fa92c" + }, + "cargo/format-1/process.json": { + "bytes": 116, + "sha256": "52687a01d58ed383c8f834c47541274e279f3c2f686c002a3394cc00b0bc6dcf" + }, + "cargo/format-1/result.json": { + "bytes": 1871, + "sha256": "7a14c5d541c6b857ae89f58561cc65146e975f52bedc12ba6a31462fcfdb97b2" + }, + "cargo/format-1/started.json": { + "bytes": 1483, + "sha256": "9b0b172efc3f682220b1a4052b2c810a3b5bf97643ffd47725911cd78a1a0bc7" + }, + "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": "0403723a9cca41759a0891b0068a7323232e7e2fd236d23cb02bd256ef29ab45" + }, + "cargo/format-2/result.json": { + "bytes": 1872, + "sha256": "8f16c9146cf721e8c79b053f83684be13ae2329913294506e5f4cc92bb2fb1dc" + }, + "cargo/format-2/started.json": { + "bytes": 1483, + "sha256": "2212772b937a3f9a27bc23e6496acd03b6ad038eaa5f06b8ddb4e4b799a7cb83" + }, + "cargo/format-2/stderr.log": { + "bytes": 0, + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "cargo/format-2/stdout.log": { + "bytes": 0, + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "cargo/format-3/process.json": { + "bytes": 116, + "sha256": "048378f339bad8da7863f97e5671b5cc18de101c6bfbf06d1d5a941cb44c1aba" + }, + "cargo/format-3/result.json": { + "bytes": 1871, + "sha256": "402ccfff4d08ddeed53b64a758d395446ba4c6926e8273435afc4adabf039ad3" + }, + "cargo/format-3/started.json": { + "bytes": 1483, + "sha256": "6411f41ae5f653e4986e16a38dd905d820bedae9e7935d29b9bc74ff5c01df2b" + }, + "cargo/format-3/stderr.log": { + "bytes": 0, + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "cargo/format-3/stdout.log": { + "bytes": 0, + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "cargo/format-4/process.json": { + "bytes": 116, + "sha256": "1235445b246e66285da7a43a2a4f9067ed4a3ab734b400a0b5abcca1d521be54" + }, + "cargo/format-4/result.json": { + "bytes": 1871, + "sha256": "e7ec8b836983ba5bd7087f1e47cc4de85820d5339ec392f7d629737234a5f5dd" + }, + "cargo/format-4/started.json": { + "bytes": 1483, + "sha256": "638780b2bd8a755a432d088cbb69aa7d86d5f08c22bcde709124a73428520e8a" + }, + "cargo/format-4/stderr.log": { + "bytes": 0, + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "cargo/format-4/stdout.log": { + "bytes": 0, + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "cargo/full-desktop-1/process.json": { + "bytes": 162, + "sha256": "afa6aa7b6d735148a1db2366894f59c2e0ecca45e222060bd5b7247aad8476f3" + }, + "cargo/full-desktop-1/result.json": { + "bytes": 3273, + "sha256": "d0804979b3f9deabc61ca97f4cee9f0c11837b69a41730fad77c1f992329af47" + }, + "cargo/full-desktop-1/started.json": { + "bytes": 1539, + "sha256": "f4d636477484f73d1c7ed1b6423309cf46510fa9a8aad08cf698f164ef928692" + }, + "cargo/full-desktop-1/stderr.log": { + "bytes": 287, + "sha256": "6b4d50ba398f541058f59f2ab87fabeb28c669e3395e1cb99551ad0829bbf68e" + }, + "cargo/full-desktop-1/stdout.log": { + "bytes": 445423, + "sha256": "a62c4e5fc711805564c25c196505988b00383c78e9e44ea0b05842e959b1c61c" + }, + "cargo/full-desktop-2/process.json": { + "bytes": 161, + "sha256": "b59db79901c47ff0bbc529690f98c804f7a6ded71160a86ecf36c38e78cfebd5" + }, + "cargo/full-desktop-2/result.json": { + "bytes": 3273, + "sha256": "1f97146a3d8efe9f3555af22446ee5adbddfe8bde521abf031dec59be4fb9d11" + }, + "cargo/full-desktop-2/started.json": { + "bytes": 1539, + "sha256": "7683c8e6ea6c4d2a9a0e52184701ef11016ff38ff8e3080f9c2c8827933ca48e" + }, + "cargo/full-desktop-2/stderr.log": { + "bytes": 210, + "sha256": "31df583ee1b398976b4373f4be995d79c31a094d38054777a7b281a36a17dd9e" + }, + "cargo/full-desktop-2/stdout.log": { + "bytes": 445803, + "sha256": "21e66e479c4ec5e7013e110ea0355f87317906e46e9300efb7854fed7470ab63" + }, + "cargo/lint-1/process.json": { + "bytes": 196, + "sha256": "fed7ff6c147b958dd9a114f7da8240133184cc2d4d2b6a1ff593641bdd42e859" + }, + "cargo/lint-1/result.json": { + "bytes": 1989, + "sha256": "161792fa35e0452ce75b6ebb82a9e85bd07d9f066ee97640358fbe38d7c0b6c2" + }, + "cargo/lint-1/started.json": { + "bytes": 1593, + "sha256": "afea705ba46b8939f7908aa62cce1f54d2aac74044b526bffc58eda1e08a639c" + }, + "cargo/lint-1/stderr.log": { + "bytes": 167, + "sha256": "de5fcf7a630c9db5e9ae0a74e70637edf372c4590dbf948eee88e642a7c4ccd3" + }, + "cargo/lint-1/stdout.log": { + "bytes": 274895, + "sha256": "94b8e1c8f6cd3f97cab07e02186eb5f2ef1ae0a1a9d8729347cf9b1a457108a9" + }, + "cargo/lint-2/process.json": { + "bytes": 196, + "sha256": "65cbaedf9fb3286c2800fba3cb24e4c1124f2f148a236c41025e7d1e33b1c553" + }, + "cargo/lint-2/result.json": { + "bytes": 1989, + "sha256": "6ed82f0fb01fb63dab35856a44910c8d274da7ded636f1f9be211e31ee75fe51" + }, + "cargo/lint-2/started.json": { + "bytes": 1593, + "sha256": "68741478c91159a3caa303a3eb547e4a5de4696253671839e11ab88bf350005a" + }, + "cargo/lint-2/stderr.log": { + "bytes": 207, + "sha256": "1f90fcac2272f9f45a281a87561cc132658b5f8e923aa461b9676da5435628ab" + }, + "cargo/lint-2/stdout.log": { + "bytes": 310285, + "sha256": "eb00f6e2f909bca8d3765cacbf2aca4142f6314ebf1916df3f25ae2f7bac735d" + }, + "cargo/lint-3/process.json": { + "bytes": 196, + "sha256": "cbf735cedd2d93a411bd208a3f2d620fc53deba36afd141d31662945bb530b54" + }, + "cargo/lint-3/result.json": { + "bytes": 1988, + "sha256": "62713ba7f0c0184af1f1bd76154c50684724eeb36bf7562d3a98eef183dc7089" + }, + "cargo/lint-3/started.json": { + "bytes": 1593, + "sha256": "096ae947d794895f97a4fdce733ed2fcba8108fe5560f3867eb6a712858a9212" + }, + "cargo/lint-3/stderr.log": { + "bytes": 137, + "sha256": "2233336da05e39aa60c45cae62339002a904cf342d551ca5e68869c206c3ed23" + }, + "cargo/lint-3/stdout.log": { + "bytes": 310284, + "sha256": "56b8e028611b240348b0373525741cb5e8e5e4fa044054845bbefd918c17bbeb" + }, + "cargo/lint-4/process.json": { + "bytes": 202, + "sha256": "2e4f98647c758e6545956aed20d8e464726b6639cc04274ad9bd47a289a5a65c" + }, + "cargo/lint-4/result.json": { + "bytes": 1989, + "sha256": "580171eca5ccd60da5615e50ba5d93ae3d06d072fd7bc1b4f1b8992596982050" + }, + "cargo/lint-4/started.json": { + "bytes": 1594, + "sha256": "fa32704c52e5335328300b4105eaba3b38d69f70bb14fa2ea8ecffb3bcb7ef79" + }, + "cargo/lint-4/stderr.log": { + "bytes": 227, + "sha256": "44b42afaf393a583a3c5f09844b241bdd61af02c2efd3d9bac2e785915073779" + }, + "cargo/lint-4/stdout.log": { + "bytes": 38950, + "sha256": "d4aabaa63e77b6959444dde23046e9722619bc67bf37037bb49ffc09a0ae29b7" + }, + "cargo/native-build-1/process.json": { + "bytes": 177, + "sha256": "96b067ef1a49f4e908452798214fa1691a6f58e47923ce338c182dd4135e7b12" + }, + "cargo/native-build-1/result.json": { + "bytes": 3321, + "sha256": "98f62963fc4a1d70ea49a2b3a8eb54dca7bc8c3e90edb26fef0b6af89f50e99b" + }, + "cargo/native-build-1/started.json": { + "bytes": 1554, + "sha256": "c92f92b1de9d0fef6e19a1365fd18782f9310cdbfa68d9b66ede3d179318c47e" + }, + "cargo/native-build-1/stderr.log": { + "bytes": 130, + "sha256": "2dc3f9bcc90f1046481f83482b478e3debd0d0784d137bc463cfd2813ebc0693" + }, + "cargo/native-build-1/stdout.log": { + "bytes": 41732, + "sha256": "582fef43a9b92e91df8dc3fc49f73e8d56e378a5451958ded376f90c7309b55e" + }, + "cargo/native-build-2/process.json": { + "bytes": 177, + "sha256": "654d7094bb258d53a981e23ee8a65dfe3c15bd5afd729ffcb5f9ab3d9bc7266b" + }, + "cargo/native-build-2/result.json": { + "bytes": 3322, + "sha256": "1df7538acda0f38b36f91d0293a7f74a5f4d308797f35b1d4dabaa7155aae23b" + }, + "cargo/native-build-2/started.json": { + "bytes": 1554, + "sha256": "c035f4568abe654969be40fbbc1af51d56adf5cf92e5242ba506c18ac84ce635" + }, + "cargo/native-build-2/stderr.log": { + "bytes": 130, + "sha256": "2dc3f9bcc90f1046481f83482b478e3debd0d0784d137bc463cfd2813ebc0693" + }, + "cargo/native-build-2/stdout.log": { + "bytes": 41732, + "sha256": "90d105724d4c77826bea5a3686fd23951d2a3f23ff221d326c753ba248f2ff90" + }, + "cargo/windows-build-1/process.json": { + "bytes": 137, + "sha256": "2d45f19802388b96fd734b034b5b6da3f9f7a04e8666b9c2eefa4c43e97e5031" + }, + "cargo/windows-build-1/result.json": { + "bytes": 3193, + "sha256": "0a4d5ac9e798153c54c415d4bbddb98c5f725ba28a537e340714d610a3a97547" + }, + "cargo/windows-build-1/started.json": { + "bytes": 1504, + "sha256": "acf7cd9f6ec1418a1cd9eaf99940851edc4889ee206b26a6a092dd7ec9d77b3f" + }, + "cargo/windows-build-1/stderr.log": { + "bytes": 137, + "sha256": "a5f6f3ceebc0811634cb745e93312a4103cc4c3caae0f217b6c97929a25e383e" + }, + "cargo/windows-build-1/stdout.log": { + "bytes": 284179, + "sha256": "c8a0b1cde5523aceddb7fe9f266b317af923a86c1e9ab73bc4ce03ad2e425f3c" + }, + "cargo/windows-build-2/process.json": { + "bytes": 137, + "sha256": "3435c171165966bc69284a1557a1b032afe6214485e1cf1931b8ea23113ab1e7" + }, + "cargo/windows-build-2/result.json": { + "bytes": 3193, + "sha256": "8082f00a7a2ecc6ce81cb2ac3d58ad0a115fdb598fbbed3ca521e00e6b57bbba" + }, + "cargo/windows-build-2/started.json": { + "bytes": 1504, + "sha256": "c44c7502122f5fc17e96476b9240cf8a590207eaab06cdcf38c0a6c98a2fca34" + }, + "cargo/windows-build-2/stderr.log": { + "bytes": 137, + "sha256": "acead8fc321ee49e00eadc746fbb2454442e48c93f4c001f8f7a00e747eb8bf6" + }, + "cargo/windows-build-2/stdout.log": { + "bytes": 284179, + "sha256": "0f4030afef0af42ad17b8c8cfc9b5b0051bb5fc79edbf8f589087a06734760cc" + }, + "helpers/1368375b1fba0dfc50b77ea4f406dba71960a78317ce8f259d4c4a0254863a20.py": { + "bytes": 3827, + "sha256": "1368375b1fba0dfc50b77ea4f406dba71960a78317ce8f259d4c4a0254863a20" + }, + "helpers/230f1222f133edef76ffb9c09ed6eecf952fc326b04a0bbccb3f3f49df4c0c29.py": { + "bytes": 3856, + "sha256": "230f1222f133edef76ffb9c09ed6eecf952fc326b04a0bbccb3f3f49df4c0c29" + } + } +} diff --git a/harness/multiselect-process-evidence-20260923/checks/prepare.py b/harness/multiselect-process-evidence-20260923/checks/prepare.py new file mode 100644 index 00000000..806c4d11 --- /dev/null +++ b/harness/multiselect-process-evidence-20260923/checks/prepare.py @@ -0,0 +1,99 @@ +"""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/process-group-gates" + candidates = [gates / name for name in ("gate.py", "gate-feedback.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 + helper_catalog = {} + for name, data in zip(("gate.py", "gate-feedback.py"), helper_bytes): + helper = "helpers/" + pin(data)["sha256"] + ".py" + add(helper, data) + helper_catalog[name] = helper + 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, + "captureHelpers": helper_catalog, + "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, required=True, help="checkout holding .local/process-group-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, help="fresh, nonexistent package directory (required unless --check-only)") + 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") + need(args.check_only or args.output is not None, "--output is required unless --check-only") + if not args.check_only: + need(not args.output.exists(), "output must be a fresh directory: " + str(args.output)) + 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=False) + 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() diff --git a/harness/multiselect-process-evidence-20260923/checks/verify.py b/harness/multiselect-process-evidence-20260923/checks/verify.py new file mode 100644 index 00000000..63662f6a --- /dev/null +++ b/harness/multiselect-process-evidence-20260923/checks/verify.py @@ -0,0 +1,173 @@ +"""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"] + helpers = manifest["captureHelpers"] + need(isinstance(helpers, dict) and set(helpers) == {"gate.py", "gate-feedback.py"}, "capture helper names") + need(all(isinstance(name, str) and re.fullmatch(r"helpers/[0-9a-f]{64}\.py", name) + for name in helpers.values()), "capture helper mapping") + 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()) + expected_logical.update(helpers.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]) + for name, helper in helpers.items(): + need(helper == "helpers/" + pin(raw(helper))["sha256"] + ".py", "named helper hash: " + 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), "recordedFailures": sum(spec["exitCode"] != 0 for spec in runs.values()), + "logicalFiles": len(logical), "storedMembers": len(stored), "sourceCapsulePairs": len(seen_capsules), + "captureHelpers": helpers} + + +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() diff --git a/harness/multiselect-process-evidence-20260923/feedback/README.md b/harness/multiselect-process-evidence-20260923/feedback/README.md new file mode 100644 index 00000000..912d871d --- /dev/null +++ b/harness/multiselect-process-evidence-20260923/feedback/README.md @@ -0,0 +1,42 @@ +# Actual Process render feedback byte proof + +This small package holds the four actual WGPU RGBA readbacks, adjacent JSON +metadata, lossless PNGs, original png-readback.json, encode-png.py and parent +observations.md from `.local/process-feedback-review/capture-1`. It requires +Python and Pillow; it never invokes Cargo, a GUI, or archived capture helpers. + +Use a portable Process checks package containing the closed `feedback-1` gate: + +```text +python -B prepare.py --root C:/ar-multiselect-9e8r6t --checks CHECKS_PACKAGE --check-only +python -B prepare.py --root C:/ar-multiselect-9e8r6t --checks CHECKS_PACKAGE --output FRESH_DIRECTORY +python -B FRESH_DIRECTORY/verify.py --checks CHECKS_PACKAGE +``` + +Run the checks package's own verifier too. Feedback references exact logical +feedback-1 receipt/helper/log/input pins, so the checks package may include other +runs and may be moved independently. No full source capsule is duplicated here. +Preparation refuses existing output and compares all 742 captured input bytes +to Git commit b15345123a7feaf630ef24d45f591c76c6715778. Verification checks the +fixed source map identity and every source-capsule member through the supplied +checks package. The original gate HEAD predates b153; its captured inputs, rather +than that HEAD, identify the rendered source. The relevant captured test and +Mounted renderer paths have explicit source pins. + +Every PNG is decoded and required to equal its raw RGBA byte for byte at the +recorded 1440x900 size. For OA and SA separately, before-press and after-motion +must preserve complete source bundle, selection string/count, canvas, camera, +scale and dimensions while pixels differ. The original PNG readback receipt, +unique safe ZIP inventory and every archived file hash are checked. A matching +checks receipt must record the exact passing test and capture output directory. + +This is byte/provenance consistency, not native acceptance or independent image +interpretation. Pixel files have no gate-time hashes: their linkage is the +recorded output directory, create-only captured test code, passing test receipt +and parent observations; package pins establish bytes at packaging time. +Selection metadata stores the test's captured snapshot; unchanged current +selection and empty history are separately asserted in the passing test. +There is no history metadata, and this tool does not invent any. Source text in +metadata is compared exactly between stages, not reparsed as a Project. +The parent observations describe visible quality and scope limits. Encoding +helpers and observations are preserved, not executed or treated as instructions. diff --git a/harness/multiselect-process-evidence-20260923/feedback/evidence.zip b/harness/multiselect-process-evidence-20260923/feedback/evidence.zip new file mode 100644 index 00000000..0bf5e62d Binary files /dev/null and b/harness/multiselect-process-evidence-20260923/feedback/evidence.zip differ diff --git a/harness/multiselect-process-evidence-20260923/feedback/files.json b/harness/multiselect-process-evidence-20260923/feedback/files.json new file mode 100644 index 00000000..e9e2a214 --- /dev/null +++ b/harness/multiselect-process-evidence-20260923/feedback/files.json @@ -0,0 +1,22 @@ +{ + "README.md": { + "bytes": 2623, + "sha256": "731c4062a0bc4a66a44246fc69c53b8aa32fd09613e62c9bbef4716f7c930641" + }, + "evidence.zip": { + "bytes": 797909, + "sha256": "6ebd3beafec69da81124511a509f56fe966d35fbd1b127567a1b7f57eb1a9051" + }, + "manifest.json": { + "bytes": 4079, + "sha256": "caf50753143216c0cf503473508581f1229528215cf6a72b038ddc8f84a75000" + }, + "prepare.py": { + "bytes": 3753, + "sha256": "4fb1db6b465ae6fd25cdabdf63b3a5fb76a6cd9399f01afd3dca4dd9ec4e7f86" + }, + "verify.py": { + "bytes": 8730, + "sha256": "d0bcb4fc8b07c7ba9b4573505b72e865a5eb18070b9b5e4411db757e13175d96" + } +} diff --git a/harness/multiselect-process-evidence-20260923/feedback/manifest.json b/harness/multiselect-process-evidence-20260923/feedback/manifest.json new file mode 100644 index 00000000..f448232b --- /dev/null +++ b/harness/multiselect-process-evidence-20260923/feedback/manifest.json @@ -0,0 +1,112 @@ +{ + "schema": 1, + "kind": "process-render-feedback", + "sourceCommit": "b15345123a7feaf630ef24d45f591c76c6715778", + "checksRun": "feedback-1", + "checksPins": { + "started.json": { + "bytes": 1747, + "sha256": "01fbcfd28b752921ded95c40a711909851bcd005394b34b5f4f49a7eaa0a594c" + }, + "process.json": { + "bytes": 260, + "sha256": "b8559e934c97f9eba407b1ceceed637fc570a9a8ba9e3362b961824cb0344fcd" + }, + "result.json": { + "bytes": 3481, + "sha256": "2f007dfda469a75eaaf713577b394605af7d3d42fed2137d9e3ed632301c78d3" + }, + "stdout.log": { + "bytes": 293378, + "sha256": "8c611aa1671316570c79b079882400e3b710d3eff8fe04e37ec59f17071fa92c" + }, + "stderr.log": { + "bytes": 210, + "sha256": "504bdaa5c008c52a9462aeca71413f9933a4e9c6b0272715ba5ca916411781c1" + }, + "inputs.json": { + "bytes": 124276, + "sha256": "7f5b3de4d5bb70282e424fbe90211af2ac259965aef10b05438b46076467a767" + }, + "inputs.zip": { + "bytes": 2985964, + "sha256": "f681fb5ead73d5aba1d9a0f25ea29a96ccb430692630b64987b984623cd38148" + }, + "helper": { + "bytes": 3856, + "sha256": "230f1222f133edef76ffb9c09ed6eecf952fc326b04a0bbccb3f3f49df4c0c29" + } + }, + "capturingSources": { + "crates/oa-desktop/src/behavior/topology/layout_tests.rs": { + "bytes": 50414, + "sha256": "993d700d6590757fb329cab3029dd0202e71c2c2f852e13fe1228417b01d8fd9" + }, + "crates/oa-desktop/src/behavior/canvas_workspace/tests.rs": { + "bytes": 27368, + "sha256": "de13f0112b1a39fa03f32a9308dcbd30b235eef9ead877b440dde023c93bc5a3" + } + }, + "members": { + "capture-1/png-readback.json": { + "bytes": 1006, + "sha256": "4f397109e46512010fdb777c85abb6dc658fc9e26cb255d22165b0a1c6502eac" + }, + "capture-1/process-Oa-after-motion.json": { + "bytes": 2674, + "sha256": "17c55c7275634ac9a22b78a6eeab7464ceb0f0e389f02947d9ec96caaf18ecfe" + }, + "capture-1/process-Oa-after-motion.png": { + "bytes": 111782, + "sha256": "051c44a392eb23787b6e7ff28b0384336602eb2d6e166cca2859c7e44a8f5386" + }, + "capture-1/process-Oa-after-motion.rgba": { + "bytes": 5184000, + "sha256": "7726fa5de90ad7ec5b14e87883c40cace0007ece54279048c6f49c9386ce5d6d" + }, + "capture-1/process-Oa-before-press.json": { + "bytes": 2674, + "sha256": "6c1d80c1690d13a8fa2c21505322651c7436673368035fa7a054d0ee55ddbb5f" + }, + "capture-1/process-Oa-before-press.png": { + "bytes": 108986, + "sha256": "d9b5c2e0b7b50ea894986aa38c63b8d1559169974d6a93954dd0a69107b5740d" + }, + "capture-1/process-Oa-before-press.rgba": { + "bytes": 5184000, + "sha256": "399e7442f56d320a92d6d9beccd2f66a241e572adadcd733ba797aef97453a1e" + }, + "capture-1/process-Sa-after-motion.json": { + "bytes": 3186, + "sha256": "441d18d75dab872012f68020012840aa67a09b52a5946749ceea669e2839439e" + }, + "capture-1/process-Sa-after-motion.png": { + "bytes": 111760, + "sha256": "bc31d57fae268b6ad19c5b14aaa0860e14b7a5f80608a8896970f7ad82b041f0" + }, + "capture-1/process-Sa-after-motion.rgba": { + "bytes": 5184000, + "sha256": "82493c91aa5ac0e1f44a50f52496fe933104acd7992b2f9adbb7710ed3e85e29" + }, + "capture-1/process-Sa-before-press.json": { + "bytes": 3186, + "sha256": "b13fbdab939a9d34a8950ee89800f41c2082b5928ef5c8781dc813940ecf6df1" + }, + "capture-1/process-Sa-before-press.png": { + "bytes": 108966, + "sha256": "9b2a4a7cc355639e26e4e87cddf17b9ce859a00e5a80400da62b67ba27198a59" + }, + "capture-1/process-Sa-before-press.rgba": { + "bytes": 5184000, + "sha256": "30ede8974ba5d9227c109fd9a5c08c93119bdb897ad7f273e87a6602bc42bdd5" + }, + "encode-png.py": { + "bytes": 2181, + "sha256": "c05b2d9f2a02d72dba50422da733e36758bd6e2773e48d84174b72ec0a804b5e" + }, + "observations.md": { + "bytes": 1912, + "sha256": "11828e5467785990a11d462813b821f677cbff1c64dcd1ca8244a94fe01e2423" + } + } +} diff --git a/harness/multiselect-process-evidence-20260923/feedback/prepare.py b/harness/multiselect-process-evidence-20260923/feedback/prepare.py new file mode 100644 index 00000000..a2cff69b --- /dev/null +++ b/harness/multiselect-process-evidence-20260923/feedback/prepare.py @@ -0,0 +1,70 @@ +"""Package the fixed, closed capture-1 pixels using an existing checks package.""" +from pathlib import Path +import argparse +import json +import subprocess +import zipfile +from verify import COMMIT, MEMBERS, SOURCES, OUTER, need, pin, checks_data, verify_data, verify_package + +HERE = Path(__file__).resolve().parent + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", type=Path, required=True) + parser.add_argument("--checks", type=Path, required=True) + parser.add_argument("--output", type=Path) + parser.add_argument("--check-only", action="store_true") + args = parser.parse_args() + need(args.check_only or args.output is not None, "--output or --check-only required") + if args.output: + need(not args.output.exists(), "fresh nonexistent output directory required") + checks = checks_data(args.checks) + gate, source = checks + # Compare every captured source byte to the named commit, not current worktree. + paths = sorted(source) + result = subprocess.run(["git", "cat-file", "--batch"], cwd=args.root, + input="".join(COMMIT + ":" + name + "\n" for name in paths).encode(), capture_output=True, check=True) + offset = 0 + for name in paths: + end = result.stdout.index(b"\n", offset) + header = result.stdout[offset:end].split() + need(len(header) == 3 and header[1] == b"blob", "missing commit blob: " + name) + size = int(header[2]) + data = result.stdout[end + 1:end + 1 + size] + need(data == source[name], "captured input differs from b153: " + name) + offset = end + 2 + size + need(offset == len(result.stdout), "exact Git batch readback") + folder = args.root / ".local/process-feedback-review" + need({p.relative_to(folder).as_posix() for p in (folder / "capture-1").rglob("*") if p.is_file()} == {name for name in MEMBERS if name.startswith("capture-1/")}, "exact captured file inventory") + payload = {} + for name in sorted(MEMBERS): + path = folder / name + need(not path.is_symlink() and not getattr(path, "is_junction", lambda: False)(), "linked input") + payload[name] = path.read_bytes() + manifest = {"schema": 1, "kind": "process-render-feedback", "sourceCommit": COMMIT, + "checksRun": "feedback-1", "checksPins": {name: pin(data) for name, data in gate.items()}, + "capturingSources": {name: pin(source[name]) for name in SOURCES}, + "members": {name: pin(data) for name, data in payload.items()}} + summary = verify_data(manifest, payload.__getitem__, checks) + if args.check_only: + print("PASS raw pixels, metadata and all 742 b153 source blobs:", summary) + return + out = args.output.resolve() + out.mkdir(parents=True, exist_ok=False) + for name in ("prepare.py", "verify.py", "README.md"): + (out / name).write_bytes((HERE / name).read_bytes()) + with zipfile.ZipFile(out / "evidence.zip", "x", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as archive: + for name, data in 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) + (out / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") + (out / "files.json").write_text(json.dumps({name: pin((out / name).read_bytes()) for name in sorted(OUTER)}, indent=2) + "\n", encoding="utf-8") + need(verify_package(out, checks) == summary, "written archive readback") + print("PASS package readback and all 742 b153 source blobs:", out, summary) + + +if __name__ == "__main__": + main() diff --git a/harness/multiselect-process-evidence-20260923/feedback/verify.py b/harness/multiselect-process-evidence-20260923/feedback/verify.py new file mode 100644 index 00000000..2a3e884c --- /dev/null +++ b/harness/multiselect-process-evidence-20260923/feedback/verify.py @@ -0,0 +1,142 @@ +"""Verify actual rendered feedback bytes against a separate Process checks package.""" +from pathlib import Path, PurePosixPath +from datetime import datetime +import argparse +import hashlib +import io +import json +import stat +import zipfile +from PIL import Image + +COMMIT = "b15345123a7feaf630ef24d45f591c76c6715778" +INPUT_SHA = "7f5b3de4d5bb70282e424fbe90211af2ac259965aef10b05438b46076467a767" +TEST = "production_process_multiselect_reverse_click_group_drop_has_one_history_step_and_stable_camera" +SOURCES = ("crates/oa-desktop/src/behavior/topology/layout_tests.rs", "crates/oa-desktop/src/behavior/canvas_workspace/tests.rs") +NAMES = [f"process-{layer}-{stage}" for layer in ("Oa", "Sa") for stage in ("before-press", "after-motion")] +MEMBERS = {"observations.md", "encode-png.py", "capture-1/png-readback.json"} | {f"capture-1/{name}.{ext}" for name in NAMES for ext in ("rgba", "png", "json")} +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 PurePosixPath(name).is_absolute() and all(c not in name for c in ("\\", ":")) and all(ord(c) >= 32 for c in name) and all(p not in ("", ".", "..") for p in name.split("/")) + + +def parsed(data): + def unique(items): + result = {} + for key, value in items: + need(key not in result, "duplicate JSON key") + result[key] = value + return result + def constant(value): + raise ValueError("nonfinite JSON: " + value) + return json.loads(data, object_pairs_hook=unique, parse_constant=constant) + + +def inventory(archive, expected): + names = archive.namelist() + need(len(names) == len(set(names)) and set(names) == set(expected), "exact ZIP inventory") + for row in archive.infolist(): + need(safe(row.filename) and not row.is_dir() and not stat.S_ISLNK(row.external_attr >> 16), "unsafe ZIP entry") + need(row.file_size == expected[row.filename]["bytes"], "ZIP size") + + +def checks_data(root): + """Read referenced gate bytes, without importing or executing package code.""" + files = parsed((root / "files.json").read_bytes()) + need(set(files) == OUTER, "checks outer inventory") + for name, expected in files.items(): + need(pin((root / name).read_bytes()) == expected, "checks outer pin: " + name) + manifest = parsed((root / "manifest.json").read_bytes()) + need(manifest["schema"] == 1 and manifest["kind"] == "multiselect-cargo-gates", "checks format") + with zipfile.ZipFile(root / "evidence.zip") as archive: + inventory(archive, manifest["members"]) + def raw(logical): + member = manifest["logicalFiles"][logical] + data = archive.read(member) + need(pin(data) == manifest["members"][member], "referenced checks pin") + return data + prefix = "cargo/feedback-1/" + gate = {name: raw(prefix + name) for name in ("started.json", "process.json", "result.json", "stdout.log", "stderr.log", "inputs.json", "inputs.zip")} + spec = manifest["runs"]["feedback-1"] + gate["helper"] = raw(spec["helper"]) + result, start, process = [parsed(gate[name + ".json"]) for name in ("result", "started", "process")] + need(all(result.get(k) == v for k, v in start.items()) and result["command"] == process["command"], "feedback start/process/result") + times = [datetime.fromisoformat(t) for t in (start["start"], process["observed"], result["finish"])] + need(all(t.utcoffset() is not None for t in times) and times == sorted(times), "closed feedback chronology") + need(result["exitCode"] == 0 and result["inputsUnchanged"] is True and TEST in result["command"], "passed exact feedback gate") + need(result["environment"]["ARCADIA_RENDER_EVIDENCE_DIR"].replace("\\", "/") == result["cwd"].replace("\\", "/") + "/.local/process-feedback-review/capture-1", "actual output directory provenance") + for key, name in (("inputs", "inputs.json"), ("archive", "inputs.zip"), ("stdout", "stdout.log"), ("stderr", "stderr.log"), ("helper", "helper")): + need(result[key] == pin(gate[name]), "feedback receipt pin: " + key) + need(result["inputs"]["sha256"] == INPUT_SHA, "fixed b153 input map identity") + inputs = parsed(gate["inputs.json"]) + need(len(inputs) == result["inputCount"] == 742 and all(safe(name) for name in inputs), "captured source inventory") + with zipfile.ZipFile(io.BytesIO(gate["inputs.zip"])) as capsule: + inventory(capsule, inputs) + source = {name: capsule.read(name) for name in inputs} + need(all(pin(source[name]) == expected for name, expected in inputs.items()), "all captured source bytes") + need(("test behavior::topology::view::layout_tests::" + TEST + " ... ok").encode() in gate["stdout.log"], "actual test result") + return gate, source + + +def verify_data(manifest, read, checks): + need(manifest["schema"] == 1 and manifest["kind"] == "process-render-feedback" and manifest["sourceCommit"] == COMMIT, "feedback manifest") + need(set(manifest["members"]) == MEMBERS, "exact feedback files") + for name, expected in manifest["members"].items(): + need(pin(read(name)) == expected, "feedback pin: " + name) + gate, source = checks + need(manifest["checksRun"] == "feedback-1" and manifest["checksPins"] == {name: pin(data) for name, data in gate.items()}, "exact checks references") + need(manifest["capturingSources"] == {name: pin(source[name]) for name in SOURCES}, "capturing test and renderer source identity") + report = parsed(read("capture-1/png-readback.json")) + need(set(report["captures"]) == set(NAMES), "four readback records") + for layer in ("Oa", "Sa"): + previous = None + for stage in ("before-press", "after-motion"): + name = f"process-{layer}-{stage}" + metadata = parsed(read("capture-1/" + name + ".json")) + raw = read("capture-1/" + name + ".rgba") + need(metadata["layer"] == layer and metadata["stage"] == stage and metadata["format"] == "RGBA8", "capture identity") + need(metadata["width"] == 1440 and metadata["height"] == 900 and len(raw) == 1440 * 900 * 4, "exact original dimensions") + need(metadata["selectionCount"] == 2 and isinstance(metadata["selection"], str) and "history" not in metadata, "selection metadata; no invented history field") + bundle = metadata["source"] + need(safe(bundle["entry"]) and bundle["entry"] in bundle["files"] and all(safe(n) and isinstance(s, str) for n, s in bundle["files"].items()), "complete captured source bundle") + with Image.open(io.BytesIO(read("capture-1/" + name + ".png"))) as png: + need(png.format == "PNG" and png.size == (1440, 900) and png.convert("RGBA").tobytes() == raw, "decoded PNG equals actual raw RGBA") + expected = {"bytes": len(raw), "rgbaSha256": pin(raw)["sha256"], "width": 1440, "height": 900} + need(report["captures"][name] == expected, "original PNG readback receipt") + if previous: + for field in ("source", "selection", "selectionCount", "canvas", "camera", "scale", "width", "height"): + need(metadata[field] == previous[0][field], layer + " changed " + field + " before release") + need(raw != previous[1], "actual before/after pixel difference") + previous = metadata, raw + return {"captures": 4, "pngPixelsExact": True, "stableMetadataPairs": 2, "checksRun": "feedback-1", "nativeAcceptance": False} + + +def verify_package(root, checks): + files = parsed((root / "files.json").read_bytes()) + need(set(files) == OUTER, "feedback outer inventory") + for name, expected in files.items(): + need(pin((root / name).read_bytes()) == expected, "feedback outer pin") + 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, checks) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("package", nargs="?", type=Path, default=Path(__file__).resolve().parent) + parser.add_argument("--checks", type=Path, required=True, help="Process checks package containing closed feedback-1") + args = parser.parse_args() + print("PASS feedback byte proof:", verify_package(args.package, checks_data(args.checks))) + print("History is asserted by the captured test, not recorded in image metadata. No native acceptance claim.") diff --git a/harness/multiselect-process-evidence-20260923/native/README.md b/harness/multiselect-process-evidence-20260923/native/README.md new file mode 100644 index 00000000..922d5b54 --- /dev/null +++ b/harness/multiselect-process-evidence-20260923/native/README.md @@ -0,0 +1,48 @@ +# Portable Process native receipts + +Adapted from the State native evidence package. Python standard library only; +neither preparation nor verification runs any GUI, checker, archived helper or binary. + +From a checkout, validate explicit closed runs: + +```text +python -B prepare.py --root C:/ar-multiselect-9e8r6t --runs run-1 --check-only +python -B prepare.py --root C:/ar-multiselect-9e8r6t --runs run-1 run-2 --output FRESH_DIRECTORY +python -B FRESH_DIRECTORY/verify.py +``` + +The output directory must not exist. Run-2 is an example, usable only after its +finished.json exists. Explicit names prevent accidental capture of a live run. +No run is automatically accepted. Run-1 always retains `reviewStatus: failed`: +Back exposed the outer reference's editable Properties while highlighting the +qualified tile; reorder and history were not performed. Later runs are +`unreviewed` by this tool even if independently accepted in the parent's report. + +The archive retains every run file byte, exact prepared helpers (including +common.py and README.md), the actual capture-executed.js separately, complete +fixture/working/snapshot source, screenshots and accessibility JSON, logs, +normal-exit/closure flags, and history-bytes.json when present. The executed +capture function is preserved and package-pinned; the preparation receipt does +not authenticate it as the same bytes as the prepared capture.js template. + +Both GUI and checker build raw receipts, process/start metadata, logs, complete +input maps and source ZIPs are archived. Source maps/ZIPs are deduplicated by +exact hash. The historical checker source is also directly available. Helpers +must match recorded pins; use repeated `--helper PATH` for unavailable historical +versions. Never rewrite original capture helpers to make pins pass. + +Verification checks safe unique ZIP paths and JSON keys, all member hashes, +reference closure, lifecycle/time relationships, exact prepared helpers, +source-map equality, every source-capsule byte, executable receipt identity, +artifact membership in raw Cargo output, all snapshot reports, and literal +history bytes if present. Nonzero native/checker exits are retained as data; +missing closure or inconsistent receipts refuse packaging. A successful receipt +consistency check is not a claim that native interactions succeeded. + +The verifier does not authenticate authorship, independently replay the checker, +interpret screenshot contents, verify pixel dimensions, infer click/drag/focus +actions, prove one-step history, or establish usability/native acceptance. Input +hashes establish recorded consistency, not a reproducible-build proof. Binaries +are identified by build readback pins and native receipts, not included or run. +Independent parent review remains necessary. Failed checker output is preserved +verbatim and not required to be a positive JSON report. diff --git a/harness/multiselect-process-evidence-20260923/native/evidence.zip b/harness/multiselect-process-evidence-20260923/native/evidence.zip new file mode 100644 index 00000000..188a1f3f Binary files /dev/null and b/harness/multiselect-process-evidence-20260923/native/evidence.zip differ diff --git a/harness/multiselect-process-evidence-20260923/native/files.json b/harness/multiselect-process-evidence-20260923/native/files.json new file mode 100644 index 00000000..d7b59a6f --- /dev/null +++ b/harness/multiselect-process-evidence-20260923/native/files.json @@ -0,0 +1,22 @@ +{ + "prepare.py": { + "bytes": 6712, + "sha256": "d6b265b0047e9e0f8d67b71a9208b0467cd12b0470690713bfce6e3ceba6e0d3" + }, + "verify.py": { + "bytes": 16518, + "sha256": "035e2e3eacdfb4205e6560fa85befbe65bbc2d20c5c8a42a11e4a965131ed0e4" + }, + "README.md": { + "bytes": 2857, + "sha256": "57d447f984fd8dccc1e93fdb7b493bbb9e76a081b2a0a2d28689da86c0d61ce4" + }, + "manifest.json": { + "bytes": 80265, + "sha256": "7bd4fa9247941d17939de989501b66c0d5d04f222424fb092c00f52f765b5bca" + }, + "evidence.zip": { + "bytes": 12355442, + "sha256": "e94c3e9f2fb644c8e415e09b75eaf973d9c2a93534ad9e41d69793a3c11e8f88" + } +} diff --git a/harness/multiselect-process-evidence-20260923/native/manifest.json b/harness/multiselect-process-evidence-20260923/native/manifest.json new file mode 100644 index 00000000..9cab9f6b --- /dev/null +++ b/harness/multiselect-process-evidence-20260923/native/manifest.json @@ -0,0 +1,1768 @@ +{ + "schema": 1, + "kind": "process-multiselect-native", + "runs": { + "run-1": { + "originalRun": "C:/ar-multiselect-9e8r6t/.local/process-multiselect-native/run-1", + "reviewStatus": "failed", + "reviewNote": "Observed Back restored qualified highlight with outer-reference editable Properties; reorder and Undo/Redo/reopen history were not performed. Failed native workflow, despite three positive unchanged Project reports.", + "helpers": { + "capture.js": "helpers/5e64ff50d13b14b1ac75c4bcfc961b527d661a5729eedce789a472d2f92e0473.js", + "common.py": "helpers/81b21630a8235c8658839662bc7b56dda842b25fd816a3f812b9b7d385eaca96.py", + "history-bytes.py": "helpers/0c3786fd0c28f43153934dbfcc64b0fcaebded0787f5dda9347b89bb602ebd2f.py", + "native.py": "helpers/a0554aca92cfcd51f644ec54c46f1f10925d1fc63679d0a04b9a0e778021b600.py", + "README.md": "helpers/0a9ae83c48a4d04930f7ec8b7837500859096975e174cf44c0043af48e211a5c.md", + "snapshot.py": "helpers/e64783b09a615cc2354a0fda97700993c334a0855fd163562848774fbdaee425.py" + }, + "executedCapture": { + "bytes": 1255, + "sha256": "d2c9a3d2bcfe27e6ecd5077ef132862bcc3876f072c6abd787419e8d82de9642" + }, + "builds": { + "gui": { + "originalRelative": ".local/process-group-gates/windows-build-1/result.json", + "helper": "helpers/1368375b1fba0dfc50b77ea4f406dba71960a78317ce8f259d4c4a0254863a20.py", + "started.json": "builds/0a4d5ac9e798153c54c415d4bbddb98c5f725ba28a537e340714d610a3a97547/started.json", + "process.json": "builds/0a4d5ac9e798153c54c415d4bbddb98c5f725ba28a537e340714d610a3a97547/process.json", + "result.json": "builds/0a4d5ac9e798153c54c415d4bbddb98c5f725ba28a537e340714d610a3a97547/result.json", + "stdout.log": "builds/0a4d5ac9e798153c54c415d4bbddb98c5f725ba28a537e340714d610a3a97547/stdout.log", + "stderr.log": "builds/0a4d5ac9e798153c54c415d4bbddb98c5f725ba28a537e340714d610a3a97547/stderr.log", + "inputs.json": "inputs/7f5b3de4d5bb70282e424fbe90211af2ac259965aef10b05438b46076467a767.json", + "inputs.zip": "inputs/f681fb5ead73d5aba1d9a0f25ea29a96ccb430692630b64987b984623cd38148.zip" + }, + "checker": { + "originalRelative": ".local/process-group-gates/native-build-1/result.json", + "helper": "helpers/1368375b1fba0dfc50b77ea4f406dba71960a78317ce8f259d4c4a0254863a20.py", + "started.json": "builds/98f62963fc4a1d70ea49a2b3a8eb54dca7bc8c3e90edb26fef0b6af89f50e99b/started.json", + "process.json": "builds/98f62963fc4a1d70ea49a2b3a8eb54dca7bc8c3e90edb26fef0b6af89f50e99b/process.json", + "result.json": "builds/98f62963fc4a1d70ea49a2b3a8eb54dca7bc8c3e90edb26fef0b6af89f50e99b/result.json", + "stdout.log": "builds/98f62963fc4a1d70ea49a2b3a8eb54dca7bc8c3e90edb26fef0b6af89f50e99b/stdout.log", + "stderr.log": "builds/98f62963fc4a1d70ea49a2b3a8eb54dca7bc8c3e90edb26fef0b6af89f50e99b/stderr.log", + "inputs.json": "inputs/7f5b3de4d5bb70282e424fbe90211af2ac259965aef10b05438b46076467a767.json", + "inputs.zip": "inputs/f681fb5ead73d5aba1d9a0f25ea29a96ccb430692630b64987b984623cd38148.zip" + } + }, + "checkerSource": "checker-source/40f0914c2363cad48881dc651bfb9777bda42b0485368d18b78d43db42c29b56.rs" + }, + "run-3": { + "originalRun": "C:/ar-multiselect-9e8r6t/.local/process-multiselect-native/run-3", + "reviewStatus": "unreviewed", + "reviewNote": "Packaging consistency does not decide native acceptance. Consult the independent parent review.", + "helpers": { + "capture.js": "helpers/5e64ff50d13b14b1ac75c4bcfc961b527d661a5729eedce789a472d2f92e0473.js", + "common.py": "helpers/81b21630a8235c8658839662bc7b56dda842b25fd816a3f812b9b7d385eaca96.py", + "history-bytes.py": "helpers/0c3786fd0c28f43153934dbfcc64b0fcaebded0787f5dda9347b89bb602ebd2f.py", + "native.py": "helpers/a0554aca92cfcd51f644ec54c46f1f10925d1fc63679d0a04b9a0e778021b600.py", + "README.md": "helpers/0a9ae83c48a4d04930f7ec8b7837500859096975e174cf44c0043af48e211a5c.md", + "snapshot.py": "helpers/e64783b09a615cc2354a0fda97700993c334a0855fd163562848774fbdaee425.py" + }, + "executedCapture": { + "bytes": 1255, + "sha256": "d2c9a3d2bcfe27e6ecd5077ef132862bcc3876f072c6abd787419e8d82de9642" + }, + "builds": { + "gui": { + "originalRelative": ".local/process-group-gates/windows-build-2/result.json", + "helper": "helpers/1368375b1fba0dfc50b77ea4f406dba71960a78317ce8f259d4c4a0254863a20.py", + "started.json": "builds/8082f00a7a2ecc6ce81cb2ac3d58ad0a115fdb598fbbed3ca521e00e6b57bbba/started.json", + "process.json": "builds/8082f00a7a2ecc6ce81cb2ac3d58ad0a115fdb598fbbed3ca521e00e6b57bbba/process.json", + "result.json": "builds/8082f00a7a2ecc6ce81cb2ac3d58ad0a115fdb598fbbed3ca521e00e6b57bbba/result.json", + "stdout.log": "builds/8082f00a7a2ecc6ce81cb2ac3d58ad0a115fdb598fbbed3ca521e00e6b57bbba/stdout.log", + "stderr.log": "builds/8082f00a7a2ecc6ce81cb2ac3d58ad0a115fdb598fbbed3ca521e00e6b57bbba/stderr.log", + "inputs.json": "inputs/f3fd180af294e0797f9dfdbff647274de30a907b685a4fe1a2b58027bfe7da95.json", + "inputs.zip": "inputs/2dcc12ec457a6df0997c11c2fed3d5085b7033c9e100ec7d29b01af254d693cc.zip" + }, + "checker": { + "originalRelative": ".local/process-group-gates/native-build-2/result.json", + "helper": "helpers/1368375b1fba0dfc50b77ea4f406dba71960a78317ce8f259d4c4a0254863a20.py", + "started.json": "builds/1df7538acda0f38b36f91d0293a7f74a5f4d308797f35b1d4dabaa7155aae23b/started.json", + "process.json": "builds/1df7538acda0f38b36f91d0293a7f74a5f4d308797f35b1d4dabaa7155aae23b/process.json", + "result.json": "builds/1df7538acda0f38b36f91d0293a7f74a5f4d308797f35b1d4dabaa7155aae23b/result.json", + "stdout.log": "builds/1df7538acda0f38b36f91d0293a7f74a5f4d308797f35b1d4dabaa7155aae23b/stdout.log", + "stderr.log": "builds/1df7538acda0f38b36f91d0293a7f74a5f4d308797f35b1d4dabaa7155aae23b/stderr.log", + "inputs.json": "inputs/f3fd180af294e0797f9dfdbff647274de30a907b685a4fe1a2b58027bfe7da95.json", + "inputs.zip": "inputs/2dcc12ec457a6df0997c11c2fed3d5085b7033c9e100ec7d29b01af254d693cc.zip" + } + }, + "checkerSource": "checker-source/7ce7dc9dedb22140a8c226252bfcb5861f581c605336ec442929c3ea42f26562.rs" + } + }, + "members": { + "builds/0a4d5ac9e798153c54c415d4bbddb98c5f725ba28a537e340714d610a3a97547/process.json": { + "bytes": 137, + "sha256": "2d45f19802388b96fd734b034b5b6da3f9f7a04e8666b9c2eefa4c43e97e5031" + }, + "builds/0a4d5ac9e798153c54c415d4bbddb98c5f725ba28a537e340714d610a3a97547/result.json": { + "bytes": 3193, + "sha256": "0a4d5ac9e798153c54c415d4bbddb98c5f725ba28a537e340714d610a3a97547" + }, + "builds/0a4d5ac9e798153c54c415d4bbddb98c5f725ba28a537e340714d610a3a97547/started.json": { + "bytes": 1504, + "sha256": "acf7cd9f6ec1418a1cd9eaf99940851edc4889ee206b26a6a092dd7ec9d77b3f" + }, + "builds/0a4d5ac9e798153c54c415d4bbddb98c5f725ba28a537e340714d610a3a97547/stderr.log": { + "bytes": 137, + "sha256": "a5f6f3ceebc0811634cb745e93312a4103cc4c3caae0f217b6c97929a25e383e" + }, + "builds/0a4d5ac9e798153c54c415d4bbddb98c5f725ba28a537e340714d610a3a97547/stdout.log": { + "bytes": 284179, + "sha256": "c8a0b1cde5523aceddb7fe9f266b317af923a86c1e9ab73bc4ce03ad2e425f3c" + }, + "builds/1df7538acda0f38b36f91d0293a7f74a5f4d308797f35b1d4dabaa7155aae23b/process.json": { + "bytes": 177, + "sha256": "654d7094bb258d53a981e23ee8a65dfe3c15bd5afd729ffcb5f9ab3d9bc7266b" + }, + "builds/1df7538acda0f38b36f91d0293a7f74a5f4d308797f35b1d4dabaa7155aae23b/result.json": { + "bytes": 3322, + "sha256": "1df7538acda0f38b36f91d0293a7f74a5f4d308797f35b1d4dabaa7155aae23b" + }, + "builds/1df7538acda0f38b36f91d0293a7f74a5f4d308797f35b1d4dabaa7155aae23b/started.json": { + "bytes": 1554, + "sha256": "c035f4568abe654969be40fbbc1af51d56adf5cf92e5242ba506c18ac84ce635" + }, + "builds/1df7538acda0f38b36f91d0293a7f74a5f4d308797f35b1d4dabaa7155aae23b/stderr.log": { + "bytes": 130, + "sha256": "2dc3f9bcc90f1046481f83482b478e3debd0d0784d137bc463cfd2813ebc0693" + }, + "builds/1df7538acda0f38b36f91d0293a7f74a5f4d308797f35b1d4dabaa7155aae23b/stdout.log": { + "bytes": 41732, + "sha256": "90d105724d4c77826bea5a3686fd23951d2a3f23ff221d326c753ba248f2ff90" + }, + "builds/8082f00a7a2ecc6ce81cb2ac3d58ad0a115fdb598fbbed3ca521e00e6b57bbba/process.json": { + "bytes": 137, + "sha256": "3435c171165966bc69284a1557a1b032afe6214485e1cf1931b8ea23113ab1e7" + }, + "builds/8082f00a7a2ecc6ce81cb2ac3d58ad0a115fdb598fbbed3ca521e00e6b57bbba/result.json": { + "bytes": 3193, + "sha256": "8082f00a7a2ecc6ce81cb2ac3d58ad0a115fdb598fbbed3ca521e00e6b57bbba" + }, + "builds/8082f00a7a2ecc6ce81cb2ac3d58ad0a115fdb598fbbed3ca521e00e6b57bbba/started.json": { + "bytes": 1504, + "sha256": "c44c7502122f5fc17e96476b9240cf8a590207eaab06cdcf38c0a6c98a2fca34" + }, + "builds/8082f00a7a2ecc6ce81cb2ac3d58ad0a115fdb598fbbed3ca521e00e6b57bbba/stderr.log": { + "bytes": 137, + "sha256": "acead8fc321ee49e00eadc746fbb2454442e48c93f4c001f8f7a00e747eb8bf6" + }, + "builds/8082f00a7a2ecc6ce81cb2ac3d58ad0a115fdb598fbbed3ca521e00e6b57bbba/stdout.log": { + "bytes": 284179, + "sha256": "0f4030afef0af42ad17b8c8cfc9b5b0051bb5fc79edbf8f589087a06734760cc" + }, + "builds/98f62963fc4a1d70ea49a2b3a8eb54dca7bc8c3e90edb26fef0b6af89f50e99b/process.json": { + "bytes": 177, + "sha256": "96b067ef1a49f4e908452798214fa1691a6f58e47923ce338c182dd4135e7b12" + }, + "builds/98f62963fc4a1d70ea49a2b3a8eb54dca7bc8c3e90edb26fef0b6af89f50e99b/result.json": { + "bytes": 3321, + "sha256": "98f62963fc4a1d70ea49a2b3a8eb54dca7bc8c3e90edb26fef0b6af89f50e99b" + }, + "builds/98f62963fc4a1d70ea49a2b3a8eb54dca7bc8c3e90edb26fef0b6af89f50e99b/started.json": { + "bytes": 1554, + "sha256": "c92f92b1de9d0fef6e19a1365fd18782f9310cdbfa68d9b66ede3d179318c47e" + }, + "builds/98f62963fc4a1d70ea49a2b3a8eb54dca7bc8c3e90edb26fef0b6af89f50e99b/stderr.log": { + "bytes": 130, + "sha256": "2dc3f9bcc90f1046481f83482b478e3debd0d0784d137bc463cfd2813ebc0693" + }, + "builds/98f62963fc4a1d70ea49a2b3a8eb54dca7bc8c3e90edb26fef0b6af89f50e99b/stdout.log": { + "bytes": 41732, + "sha256": "582fef43a9b92e91df8dc3fc49f73e8d56e378a5451958ded376f90c7309b55e" + }, + "checker-source/40f0914c2363cad48881dc651bfb9777bda42b0485368d18b78d43db42c29b56.rs": { + "bytes": 18679, + "sha256": "40f0914c2363cad48881dc651bfb9777bda42b0485368d18b78d43db42c29b56" + }, + "checker-source/7ce7dc9dedb22140a8c226252bfcb5861f581c605336ec442929c3ea42f26562.rs": { + "bytes": 18986, + "sha256": "7ce7dc9dedb22140a8c226252bfcb5861f581c605336ec442929c3ea42f26562" + }, + "helpers/0a9ae83c48a4d04930f7ec8b7837500859096975e174cf44c0043af48e211a5c.md": { + "bytes": 7599, + "sha256": "0a9ae83c48a4d04930f7ec8b7837500859096975e174cf44c0043af48e211a5c" + }, + "helpers/0c3786fd0c28f43153934dbfcc64b0fcaebded0787f5dda9347b89bb602ebd2f.py": { + "bytes": 3998, + "sha256": "0c3786fd0c28f43153934dbfcc64b0fcaebded0787f5dda9347b89bb602ebd2f" + }, + "helpers/1368375b1fba0dfc50b77ea4f406dba71960a78317ce8f259d4c4a0254863a20.py": { + "bytes": 3827, + "sha256": "1368375b1fba0dfc50b77ea4f406dba71960a78317ce8f259d4c4a0254863a20" + }, + "helpers/5e64ff50d13b14b1ac75c4bcfc961b527d661a5729eedce789a472d2f92e0473.js": { + "bytes": 1624, + "sha256": "5e64ff50d13b14b1ac75c4bcfc961b527d661a5729eedce789a472d2f92e0473" + }, + "helpers/81b21630a8235c8658839662bc7b56dda842b25fd816a3f812b9b7d385eaca96.py": { + "bytes": 2906, + "sha256": "81b21630a8235c8658839662bc7b56dda842b25fd816a3f812b9b7d385eaca96" + }, + "helpers/a0554aca92cfcd51f644ec54c46f1f10925d1fc63679d0a04b9a0e778021b600.py": { + "bytes": 4247, + "sha256": "a0554aca92cfcd51f644ec54c46f1f10925d1fc63679d0a04b9a0e778021b600" + }, + "helpers/e64783b09a615cc2354a0fda97700993c334a0855fd163562848774fbdaee425.py": { + "bytes": 2597, + "sha256": "e64783b09a615cc2354a0fda97700993c334a0855fd163562848774fbdaee425" + }, + "inputs/2dcc12ec457a6df0997c11c2fed3d5085b7033c9e100ec7d29b01af254d693cc.zip": { + "bytes": 2987614, + "sha256": "2dcc12ec457a6df0997c11c2fed3d5085b7033c9e100ec7d29b01af254d693cc" + }, + "inputs/7f5b3de4d5bb70282e424fbe90211af2ac259965aef10b05438b46076467a767.json": { + "bytes": 124276, + "sha256": "7f5b3de4d5bb70282e424fbe90211af2ac259965aef10b05438b46076467a767" + }, + "inputs/f3fd180af294e0797f9dfdbff647274de30a907b685a4fe1a2b58027bfe7da95.json": { + "bytes": 124276, + "sha256": "f3fd180af294e0797f9dfdbff647274de30a907b685a4fe1a2b58027bfe7da95" + }, + "inputs/f681fb5ead73d5aba1d9a0f25ea29a96ccb430692630b64987b984623cd38148.zip": { + "bytes": 2985964, + "sha256": "f681fb5ead73d5aba1d9a0f25ea29a96ccb430692630b64987b984623cd38148" + }, + "runs/run-1/raw/01-start-0.jpg": { + "bytes": 116617, + "sha256": "849e79e7035fbaa57d48afbdccd6c9634bc2ab1024eda7606ef5b320227e7a07" + }, + "runs/run-1/raw/01-start.json": { + "bytes": 919, + "sha256": "0cefa14b6b7dcc76bc069ccdf8b378cf076e96961f143907af9e848a9a806282" + }, + "runs/run-1/raw/02-open-dialog-0.jpg": { + "bytes": 115087, + "sha256": "4b466f7086aca8823f717bf6123633d71bbd86eee52edc0ada3919c9c6db685f" + }, + "runs/run-1/raw/02-open-dialog.json": { + "bytes": 925, + "sha256": "d5b254f098f662e2816f5787f825b1d1088b5baadf07745b58bda30ffa2ca314" + }, + "runs/run-1/raw/03-file-dialog-0.jpg": { + "bytes": 674, + "sha256": "5a2387a1a593a32ea169b56b9a3e59333936ee1da95164564e4d9be61bf5a24b" + }, + "runs/run-1/raw/03-file-dialog-1.jpg": { + "bytes": 58361, + "sha256": "086cc578e3d43c717b68894edbb77b51c678fb82d3eb3eb6d4319688144c5f54" + }, + "runs/run-1/raw/03-file-dialog.json": { + "bytes": 5635, + "sha256": "c73848096ab9159f1cc07110a82481276b7176aa90bc14e5913b16cfe525c962" + }, + "runs/run-1/raw/04-file-focus-0.jpg": { + "bytes": 674, + "sha256": "5a2387a1a593a32ea169b56b9a3e59333936ee1da95164564e4d9be61bf5a24b" + }, + "runs/run-1/raw/04-file-focus-1.jpg": { + "bytes": 58731, + "sha256": "dc937c1ae2093dcc60b0ddfb76c45f47c206311ddffaf377637bf91f75eb2b32" + }, + "runs/run-1/raw/04-file-focus.json": { + "bytes": 5633, + "sha256": "c6f8076a5659a56cf9664892aff69286cb51c0c304228debac8916a940968552" + }, + "runs/run-1/raw/06-dialog-reobserve-0.jpg": { + "bytes": 674, + "sha256": "5a2387a1a593a32ea169b56b9a3e59333936ee1da95164564e4d9be61bf5a24b" + }, + "runs/run-1/raw/06-dialog-reobserve-1.jpg": { + "bytes": 58538, + "sha256": "3ea648cdac8c0cfb7a254873402f30977a6b7390ac2bbf7c0f7f7ad1804582ac" + }, + "runs/run-1/raw/06-dialog-reobserve.json": { + "bytes": 5645, + "sha256": "ba6b391bbc2150cf8dee8e147fac907e5e8e98abf93d430f0fd8cea5a432bf69" + }, + "runs/run-1/raw/07-file-path-typed-0.jpg": { + "bytes": 674, + "sha256": "5a2387a1a593a32ea169b56b9a3e59333936ee1da95164564e4d9be61bf5a24b" + }, + "runs/run-1/raw/07-file-path-typed-1.jpg": { + "bytes": 62035, + "sha256": "500ebf0e521e09bdcbaf38a0f6dba5f476bec3bb089e7c6d23af83fa5a621ea7" + }, + "runs/run-1/raw/07-file-path-typed.json": { + "bytes": 5643, + "sha256": "2f7f78bb515b1fbcdb2e41f2265ac6d270624cebdcdca0b8f73a29041fcf1c96" + }, + "runs/run-1/raw/08-loaded-0.jpg": { + "bytes": 103776, + "sha256": "0bea954479204da6dba6d44d906b1448465c59ddd7b0d5a9b3e1f9e830a7f867" + }, + "runs/run-1/raw/08-loaded.json": { + "bytes": 936, + "sha256": "dd4e46eff1e112c1aebc6dfccce525de40a270814e0bdbf5e6c4ea1ad9a80ab4" + }, + "runs/run-1/raw/09-behavior-0.jpg": { + "bytes": 117500, + "sha256": "a31940c2c191795e3a461c2436ca83e613f8f4705de8e4987cdad7009f58f1bd" + }, + "runs/run-1/raw/09-behavior.json": { + "bytes": 942, + "sha256": "6265b18fd7427f4eff32451e17bc2b048e6f03cc202d1beb9216f64dee6bac59" + }, + "runs/run-1/raw/10-oa-baseline-0.jpg": { + "bytes": 130042, + "sha256": "1588e0dc3b9418d9bd190a9096e562b24dd110d328e5dd564b898ee826716e3b" + }, + "runs/run-1/raw/10-oa-baseline.json": { + "bytes": 945, + "sha256": "b99a3098c3dda171120da4eec5931fcc78751046ddb15a8daa13958ee1e3df97" + }, + "runs/run-1/raw/11-baseline-saved-0.jpg": { + "bytes": 128248, + "sha256": "a05a81cc0a75fb69d5d9d9488a58d7afca7b48d536e40cc86faeaa69e156a7ec" + }, + "runs/run-1/raw/11-baseline-saved.json": { + "bytes": 948, + "sha256": "be476955f0ee0d007d664943f9c1caf3a567857bb992c99a08e33b2a190ce9ec" + }, + "runs/run-1/raw/12-card-selected-0.jpg": { + "bytes": 136377, + "sha256": "b0884589f99d59c3bfc1a82c0d7d3eb1dcc0cd28ac19093fd18d5bbd1f61c150" + }, + "runs/run-1/raw/12-card-selected.json": { + "bytes": 947, + "sha256": "e2e22eb351a2bca56be890309d54085c40c1aa8d0c058a22d35e205102e63f34" + }, + "runs/run-1/raw/13-link-menu-0.jpg": { + "bytes": 146294, + "sha256": "94c1deb6e124c3ddfb9e159e8cc7d13680b1c96fdb4cc64086194ba8518ab269" + }, + "runs/run-1/raw/13-link-menu.json": { + "bytes": 943, + "sha256": "aa5164a0dce191b263dfb79b591568d18912f2278daf73758a3b29100cb1a48b" + }, + "runs/run-1/raw/14-mixed-selection-0.jpg": { + "bytes": 130466, + "sha256": "c7d8c9df6ae30107ef973ae9be79577087784413814170cb46117f020f541dab" + }, + "runs/run-1/raw/14-mixed-selection.json": { + "bytes": 949, + "sha256": "3f025b01f203adf43311470576c9feafe5c8cd54ca6d387782a07ad74e1246ec" + }, + "runs/run-1/raw/15-mixed-drag-refused-0.jpg": { + "bytes": 133486, + "sha256": "8bb640d8a2ce948334dc8d4c69d5fffa035ad7a7faaaa9a38f9aadfc3bece436" + }, + "runs/run-1/raw/15-mixed-drag-refused.json": { + "bytes": 952, + "sha256": "cc7ca734f8127f1c42a64466c79103489a0bcc00475bc2537cb1cedcb55d7dc5" + }, + "runs/run-1/raw/16-mixed-saved-0.jpg": { + "bytes": 130608, + "sha256": "14dc6e368107c867cb1e3d23621d3e205d0f14bbf9c8813c2c4c5e86a249488a" + }, + "runs/run-1/raw/16-mixed-saved.json": { + "bytes": 945, + "sha256": "f547320564bbb624b3b1ad883e56a0aa3a687172580f0fbea432cac751613e95" + }, + "runs/run-1/raw/17-stationary-collapse-0.jpg": { + "bytes": 136903, + "sha256": "8e6abf6582e4d8294062e84aafffcf20c5a0642108e127e590075b1031c7c414" + }, + "runs/run-1/raw/17-stationary-collapse.json": { + "bytes": 953, + "sha256": "1140c4fd7d6ec91e99d313b5ea6e8063c113faac447ddce2dfae0421c31beea3" + }, + "runs/run-1/raw/18-qualified-owner-0.jpg": { + "bytes": 127598, + "sha256": "253432217c38e6b8a8d8318b910bb6f3c0be50c683c004fbbe1d33b331e91f54" + }, + "runs/run-1/raw/18-qualified-owner.json": { + "bytes": 949, + "sha256": "0236413d5c812a3fda8114a61bf0a24c93fc5d57b1c3a7ae3998af5e8f24a8b5" + }, + "runs/run-1/raw/19-qualified-primary-0.jpg": { + "bytes": 111178, + "sha256": "081c3b7aa547a939f5a52f65236adef343b74bb016ec462ffb81d2f68bdd9b13" + }, + "runs/run-1/raw/19-qualified-primary.json": { + "bytes": 951, + "sha256": "d3bb8dcb8dab9014a67b830013e14f71c4aa5b7c3c0ee7db64836bb8889b6d5d" + }, + "runs/run-1/raw/20-second-path-menu-0.jpg": { + "bytes": 116250, + "sha256": "8d230ca47a52b1c299374f41fd6cfe7b4f0af57c093eff6e6d17e0026b9cdf48" + }, + "runs/run-1/raw/20-second-path-menu.json": { + "bytes": 950, + "sha256": "ba05e0d700f1191415789f745ca8838fcdf1eccf1ca253da265e71cd626cac7d" + }, + "runs/run-1/raw/21-two-qualified-paths-0.jpg": { + "bytes": 110956, + "sha256": "ff60db624f81b657f30d79e4e3ee6ed09e84dd2002380576681de6374ddaef68" + }, + "runs/run-1/raw/21-two-qualified-paths.json": { + "bytes": 953, + "sha256": "72d9ea65efecc018971c6bb9819c210c24275b5b89ae1992faeec2bd6b3c8627" + }, + "runs/run-1/raw/22-qualified-drag-refused-0.jpg": { + "bytes": 113089, + "sha256": "86f9575f88b758d77d51bab120eca0a2ca3bfbf7a40de4a64f0a835949eed559" + }, + "runs/run-1/raw/22-qualified-drag-refused.json": { + "bytes": 956, + "sha256": "c760a3ebde00efe6bac104afe081a3cb57f9efb4cf209dcc13b72cb15c83f39f" + }, + "runs/run-1/raw/23-qualified-delete-refused-0.jpg": { + "bytes": 116503, + "sha256": "a4398946a8e6814e40ec68603b1d1c1250c8da26ae89734bcd25c7dd5f4b8640" + }, + "runs/run-1/raw/23-qualified-delete-refused.json": { + "bytes": 958, + "sha256": "82f6af3884baaf16b08a083a45468924e467e0dab2c9ae4f1a81c22d9f2c7527" + }, + "runs/run-1/raw/24-open-definition-0.jpg": { + "bytes": 130027, + "sha256": "5ede19d0f58d9e1fb9f51ef3f2154157bca165118ca4f41cc0c3689e058f5b72" + }, + "runs/run-1/raw/24-open-definition.json": { + "bytes": 949, + "sha256": "94771daa6317004f9bb342a8432020528c165e135f3b215fb68531eb8ba723ff" + }, + "runs/run-1/raw/25-definition-details-0.jpg": { + "bytes": 150336, + "sha256": "e678e09a1ff20ded6d307320c973a8c54c059b978fa904d6d74543405a84bee4" + }, + "runs/run-1/raw/25-definition-details.json": { + "bytes": 952, + "sha256": "613630ef997219da31be81762f5d82b69dc311944db93a06fbab7179b7bf602b" + }, + "runs/run-1/raw/26-back-to-caller-0.jpg": { + "bytes": 166409, + "sha256": "a74368cc8ce4ad3ffb397b8ad3fc88349220635320bfe7458f29a980e033dcfe" + }, + "runs/run-1/raw/26-back-to-caller.json": { + "bytes": 948, + "sha256": "e4d7a282effdfdec5bf1f2ed47892ba1237090cbcb9b8b9d345229dc512f1b4b" + }, + "runs/run-1/raw/27-qualified-saved-before-fix-0.jpg": { + "bytes": 161076, + "sha256": "d554d634cec37b1d6bdbd0f1c3c6b51ae4c51b0516cb481477ace038d49e3ca7" + }, + "runs/run-1/raw/27-qualified-saved-before-fix.json": { + "bytes": 960, + "sha256": "38a4d0cc06b3874140bc7d51f49ca05a8b66ff0d171ef522dd0c43ffe8911a1f" + }, + "runs/run-1/raw/app.stderr.log": { + "bytes": 0, + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "runs/run-1/raw/app.stdout.log": { + "bytes": 0, + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "runs/run-1/raw/capture-executed.js": { + "bytes": 1255, + "sha256": "d2c9a3d2bcfe27e6ecd5077ef132862bcc3876f072c6abd787419e8d82de9642" + }, + "runs/run-1/raw/finished.json": { + "bytes": 380, + "sha256": "cbdf0f3a5831bd52e60cc3cd555c55976648a173ac83df20fa411badc1672e1e" + }, + "runs/run-1/raw/fixture/baseline.json": { + "bytes": 14627, + "sha256": "5cd1befea8666c5a686c22ab4bbbbf4d34523cd031b236d226371cbc19478683" + }, + "runs/run-1/raw/fixture/manifest.json": { + "bytes": 2416, + "sha256": "9ca15e323db26db695037c9d84afcffaecc8b1920026aeaa16336cb9df5d2a46" + }, + "runs/run-1/raw/fixture/source/oa/activities/activity_oa_downstream_work.arcadia": { + "bytes": 171, + "sha256": "7ebf9516f9f016139a4504649cd41e327b59b6798581bf9f65af2d30cfcd5a0e" + }, + "runs/run-1/raw/fixture/source/oa/activities/activity_oa_shared_work.arcadia": { + "bytes": 159, + "sha256": "56139bf120e41b752d50aa85955b96eddc0c7e573fe493248deed1b49933a95d" + }, + "runs/run-1/raw/fixture/source/oa/behavior/process_oa_process.arcadia": { + "bytes": 1737, + "sha256": "2f39cdf59c59bf56099ce5f07e339fa3f4fd0c8e964545f19c995a2341aba2e0" + }, + "runs/run-1/raw/fixture/source/oa/behavior/process_reusable.arcadia": { + "bytes": 429, + "sha256": "21d216c39d0990a3ebc2d40b02d00c1cc126185def1fb5342c3205ca890ae04a" + }, + "runs/run-1/raw/fixture/source/oa/behavior/process_reuse_trial.arcadia": { + "bytes": 730, + "sha256": "2384e5b81dc64ec229a0b1664333ee48a8a251b7ac8138584215a8611472fe2a" + }, + "runs/run-1/raw/fixture/source/project.arcadia": { + "bytes": 753, + "sha256": "a942705a988f5bfa35f2bdae273bce3fc6dd9afff8267a96483df598dd5a13a6" + }, + "runs/run-1/raw/fixture/source/sa/behavior/process_sa_chain.arcadia": { + "bytes": 1734, + "sha256": "b6aa1d90a113dacffdbc980f1fa6c6b2b85050921622c57492c80a1602e11a16" + }, + "runs/run-1/raw/fixture/source/sa/functions/function_sa_downstream_work.arcadia": { + "bytes": 171, + "sha256": "95ee9f35a41a4580c51bbf56b1b00717969adb77a0c05a1f30740c4c23788a02" + }, + "runs/run-1/raw/fixture/source/sa/functions/function_sa_shared_work.arcadia": { + "bytes": 159, + "sha256": "0117bcfe32321b63d6b05b745f67aa57a565026eed68eb5a98300b7265941e9a" + }, + "runs/run-1/raw/fixture/source/traceability.arcadia": { + "bytes": 528, + "sha256": "c5eaf8dc3c09dc56129a73da9630bb3fda99a0cfc9a8fa266e3a6ee32f53deb6" + }, + "runs/run-1/raw/generation.json": { + "bytes": 1244, + "sha256": "58983ff0fc371e91ab5ed90e4a7ebffe9289685591aae384d5676490619304ca" + }, + "runs/run-1/raw/generation.stderr.log": { + "bytes": 0, + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "runs/run-1/raw/generation.stdout.log": { + "bytes": 112, + "sha256": "55f118f32a85b568edcc94bb6d91deac8785c78ad35aa3c4dc89874cc68b9b2e" + }, + "runs/run-1/raw/observations.md": { + "bytes": 1964, + "sha256": "9e16dd6d146262cb7db7c2bccfa55d7d40209847830babc4ae616df3098d323d" + }, + "runs/run-1/raw/prepared.json": { + "bytes": 4601, + "sha256": "50d53171d19f1f3cdbb932ed8b71b7317c091f9778dca8c18b0974a6f80ebeb2" + }, + "runs/run-1/raw/snapshots/baseline/result.json": { + "bytes": 3489, + "sha256": "538686103b99abd01d2784a2d2738bd8e26803eb69f1c7ff47f416332f047f2c" + }, + "runs/run-1/raw/snapshots/baseline/source/oa/activities/activity_oa_downstream_work.arcadia": { + "bytes": 171, + "sha256": "7ebf9516f9f016139a4504649cd41e327b59b6798581bf9f65af2d30cfcd5a0e" + }, + "runs/run-1/raw/snapshots/baseline/source/oa/activities/activity_oa_shared_work.arcadia": { + "bytes": 159, + "sha256": "56139bf120e41b752d50aa85955b96eddc0c7e573fe493248deed1b49933a95d" + }, + "runs/run-1/raw/snapshots/baseline/source/oa/behavior/process_oa_process.arcadia": { + "bytes": 1737, + "sha256": "2f39cdf59c59bf56099ce5f07e339fa3f4fd0c8e964545f19c995a2341aba2e0" + }, + "runs/run-1/raw/snapshots/baseline/source/oa/behavior/process_reusable.arcadia": { + "bytes": 429, + "sha256": "21d216c39d0990a3ebc2d40b02d00c1cc126185def1fb5342c3205ca890ae04a" + }, + "runs/run-1/raw/snapshots/baseline/source/oa/behavior/process_reuse_trial.arcadia": { + "bytes": 730, + "sha256": "2384e5b81dc64ec229a0b1664333ee48a8a251b7ac8138584215a8611472fe2a" + }, + "runs/run-1/raw/snapshots/baseline/source/project.arcadia": { + "bytes": 753, + "sha256": "a942705a988f5bfa35f2bdae273bce3fc6dd9afff8267a96483df598dd5a13a6" + }, + "runs/run-1/raw/snapshots/baseline/source/sa/behavior/process_sa_chain.arcadia": { + "bytes": 1734, + "sha256": "b6aa1d90a113dacffdbc980f1fa6c6b2b85050921622c57492c80a1602e11a16" + }, + "runs/run-1/raw/snapshots/baseline/source/sa/functions/function_sa_downstream_work.arcadia": { + "bytes": 171, + "sha256": "95ee9f35a41a4580c51bbf56b1b00717969adb77a0c05a1f30740c4c23788a02" + }, + "runs/run-1/raw/snapshots/baseline/source/sa/functions/function_sa_shared_work.arcadia": { + "bytes": 159, + "sha256": "0117bcfe32321b63d6b05b745f67aa57a565026eed68eb5a98300b7265941e9a" + }, + "runs/run-1/raw/snapshots/baseline/source/traceability.arcadia": { + "bytes": 528, + "sha256": "c5eaf8dc3c09dc56129a73da9630bb3fda99a0cfc9a8fa266e3a6ee32f53deb6" + }, + "runs/run-1/raw/snapshots/baseline/started.json": { + "bytes": 3071, + "sha256": "e50c6a04290b17af67591430169a8144db4cc663fd7930015e0ea4412bb6746b" + }, + "runs/run-1/raw/snapshots/baseline/stderr.log": { + "bytes": 0, + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "runs/run-1/raw/snapshots/baseline/stdout.log": { + "bytes": 427, + "sha256": "0b184cbe35709f4e186769d6127927b387765573d2118b6e31018bbc59c38faa" + }, + "runs/run-1/raw/snapshots/mixed-refusal/result.json": { + "bytes": 3494, + "sha256": "4b400c81b9ddaeaf9ff6869fbbe25b92a2b6e6317e071e7232a6d91e501ef871" + }, + "runs/run-1/raw/snapshots/mixed-refusal/source/oa/activities/activity_oa_downstream_work.arcadia": { + "bytes": 171, + "sha256": "7ebf9516f9f016139a4504649cd41e327b59b6798581bf9f65af2d30cfcd5a0e" + }, + "runs/run-1/raw/snapshots/mixed-refusal/source/oa/activities/activity_oa_shared_work.arcadia": { + "bytes": 159, + "sha256": "56139bf120e41b752d50aa85955b96eddc0c7e573fe493248deed1b49933a95d" + }, + "runs/run-1/raw/snapshots/mixed-refusal/source/oa/behavior/process_oa_process.arcadia": { + "bytes": 1737, + "sha256": "2f39cdf59c59bf56099ce5f07e339fa3f4fd0c8e964545f19c995a2341aba2e0" + }, + "runs/run-1/raw/snapshots/mixed-refusal/source/oa/behavior/process_reusable.arcadia": { + "bytes": 429, + "sha256": "21d216c39d0990a3ebc2d40b02d00c1cc126185def1fb5342c3205ca890ae04a" + }, + "runs/run-1/raw/snapshots/mixed-refusal/source/oa/behavior/process_reuse_trial.arcadia": { + "bytes": 730, + "sha256": "2384e5b81dc64ec229a0b1664333ee48a8a251b7ac8138584215a8611472fe2a" + }, + "runs/run-1/raw/snapshots/mixed-refusal/source/project.arcadia": { + "bytes": 753, + "sha256": "a942705a988f5bfa35f2bdae273bce3fc6dd9afff8267a96483df598dd5a13a6" + }, + "runs/run-1/raw/snapshots/mixed-refusal/source/sa/behavior/process_sa_chain.arcadia": { + "bytes": 1734, + "sha256": "b6aa1d90a113dacffdbc980f1fa6c6b2b85050921622c57492c80a1602e11a16" + }, + "runs/run-1/raw/snapshots/mixed-refusal/source/sa/functions/function_sa_downstream_work.arcadia": { + "bytes": 171, + "sha256": "95ee9f35a41a4580c51bbf56b1b00717969adb77a0c05a1f30740c4c23788a02" + }, + "runs/run-1/raw/snapshots/mixed-refusal/source/sa/functions/function_sa_shared_work.arcadia": { + "bytes": 159, + "sha256": "0117bcfe32321b63d6b05b745f67aa57a565026eed68eb5a98300b7265941e9a" + }, + "runs/run-1/raw/snapshots/mixed-refusal/source/traceability.arcadia": { + "bytes": 528, + "sha256": "c5eaf8dc3c09dc56129a73da9630bb3fda99a0cfc9a8fa266e3a6ee32f53deb6" + }, + "runs/run-1/raw/snapshots/mixed-refusal/started.json": { + "bytes": 3076, + "sha256": "0d09c62f6e544fdeb23263a926438efb3dda9883d37f5bf659d8145dd645edcc" + }, + "runs/run-1/raw/snapshots/mixed-refusal/stderr.log": { + "bytes": 0, + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "runs/run-1/raw/snapshots/mixed-refusal/stdout.log": { + "bytes": 432, + "sha256": "a9cdab0aa2e5bdf726672dd857be7760b763a582a5651a6c92b0141dfd6f5e46" + }, + "runs/run-1/raw/snapshots/qualified-refusal/result.json": { + "bytes": 3498, + "sha256": "8435e35149bc488e26c225d520c8fb3f7b0fa6a6ab8619449c1d029664074f46" + }, + "runs/run-1/raw/snapshots/qualified-refusal/source/oa/activities/activity_oa_downstream_work.arcadia": { + "bytes": 171, + "sha256": "7ebf9516f9f016139a4504649cd41e327b59b6798581bf9f65af2d30cfcd5a0e" + }, + "runs/run-1/raw/snapshots/qualified-refusal/source/oa/activities/activity_oa_shared_work.arcadia": { + "bytes": 159, + "sha256": "56139bf120e41b752d50aa85955b96eddc0c7e573fe493248deed1b49933a95d" + }, + "runs/run-1/raw/snapshots/qualified-refusal/source/oa/behavior/process_oa_process.arcadia": { + "bytes": 1737, + "sha256": "2f39cdf59c59bf56099ce5f07e339fa3f4fd0c8e964545f19c995a2341aba2e0" + }, + "runs/run-1/raw/snapshots/qualified-refusal/source/oa/behavior/process_reusable.arcadia": { + "bytes": 429, + "sha256": "21d216c39d0990a3ebc2d40b02d00c1cc126185def1fb5342c3205ca890ae04a" + }, + "runs/run-1/raw/snapshots/qualified-refusal/source/oa/behavior/process_reuse_trial.arcadia": { + "bytes": 730, + "sha256": "2384e5b81dc64ec229a0b1664333ee48a8a251b7ac8138584215a8611472fe2a" + }, + "runs/run-1/raw/snapshots/qualified-refusal/source/project.arcadia": { + "bytes": 753, + "sha256": "a942705a988f5bfa35f2bdae273bce3fc6dd9afff8267a96483df598dd5a13a6" + }, + "runs/run-1/raw/snapshots/qualified-refusal/source/sa/behavior/process_sa_chain.arcadia": { + "bytes": 1734, + "sha256": "b6aa1d90a113dacffdbc980f1fa6c6b2b85050921622c57492c80a1602e11a16" + }, + "runs/run-1/raw/snapshots/qualified-refusal/source/sa/functions/function_sa_downstream_work.arcadia": { + "bytes": 171, + "sha256": "95ee9f35a41a4580c51bbf56b1b00717969adb77a0c05a1f30740c4c23788a02" + }, + "runs/run-1/raw/snapshots/qualified-refusal/source/sa/functions/function_sa_shared_work.arcadia": { + "bytes": 159, + "sha256": "0117bcfe32321b63d6b05b745f67aa57a565026eed68eb5a98300b7265941e9a" + }, + "runs/run-1/raw/snapshots/qualified-refusal/source/traceability.arcadia": { + "bytes": 528, + "sha256": "c5eaf8dc3c09dc56129a73da9630bb3fda99a0cfc9a8fa266e3a6ee32f53deb6" + }, + "runs/run-1/raw/snapshots/qualified-refusal/started.json": { + "bytes": 3080, + "sha256": "eb8784c1c211a5e8ae95159441bb9bc24d0acffd1f6b1dc44f807bbcb608571f" + }, + "runs/run-1/raw/snapshots/qualified-refusal/stderr.log": { + "bytes": 0, + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "runs/run-1/raw/snapshots/qualified-refusal/stdout.log": { + "bytes": 436, + "sha256": "7fcbf6f663dd0deaa7036fd27b7ba5a81479ac7e460c3fd0116fa89660d19cb3" + }, + "runs/run-1/raw/started.json": { + "bytes": 1738, + "sha256": "0cf41607c190da91578ac6cf729ed939eea22bdb1af11cccfc305b1cfbd927cb" + }, + "runs/run-1/raw/working/oa/activities/activity_oa_downstream_work.arcadia": { + "bytes": 171, + "sha256": "7ebf9516f9f016139a4504649cd41e327b59b6798581bf9f65af2d30cfcd5a0e" + }, + "runs/run-1/raw/working/oa/activities/activity_oa_shared_work.arcadia": { + "bytes": 159, + "sha256": "56139bf120e41b752d50aa85955b96eddc0c7e573fe493248deed1b49933a95d" + }, + "runs/run-1/raw/working/oa/behavior/process_oa_process.arcadia": { + "bytes": 1737, + "sha256": "2f39cdf59c59bf56099ce5f07e339fa3f4fd0c8e964545f19c995a2341aba2e0" + }, + "runs/run-1/raw/working/oa/behavior/process_reusable.arcadia": { + "bytes": 429, + "sha256": "21d216c39d0990a3ebc2d40b02d00c1cc126185def1fb5342c3205ca890ae04a" + }, + "runs/run-1/raw/working/oa/behavior/process_reuse_trial.arcadia": { + "bytes": 730, + "sha256": "2384e5b81dc64ec229a0b1664333ee48a8a251b7ac8138584215a8611472fe2a" + }, + "runs/run-1/raw/working/project.arcadia": { + "bytes": 753, + "sha256": "a942705a988f5bfa35f2bdae273bce3fc6dd9afff8267a96483df598dd5a13a6" + }, + "runs/run-1/raw/working/sa/behavior/process_sa_chain.arcadia": { + "bytes": 1734, + "sha256": "b6aa1d90a113dacffdbc980f1fa6c6b2b85050921622c57492c80a1602e11a16" + }, + "runs/run-1/raw/working/sa/functions/function_sa_downstream_work.arcadia": { + "bytes": 171, + "sha256": "95ee9f35a41a4580c51bbf56b1b00717969adb77a0c05a1f30740c4c23788a02" + }, + "runs/run-1/raw/working/sa/functions/function_sa_shared_work.arcadia": { + "bytes": 159, + "sha256": "0117bcfe32321b63d6b05b745f67aa57a565026eed68eb5a98300b7265941e9a" + }, + "runs/run-1/raw/working/traceability.arcadia": { + "bytes": 528, + "sha256": "c5eaf8dc3c09dc56129a73da9630bb3fda99a0cfc9a8fa266e3a6ee32f53deb6" + }, + "runs/run-3/raw/01-initial-0.jpg": { + "bytes": 117454, + "sha256": "b394fc7ce880b476523ec2824a610df0d378a929832f68b51745980e31b658e8" + }, + "runs/run-3/raw/01-initial.json": { + "bytes": 922, + "sha256": "37d6c45e76725f9d1e461d83f9680b2f01ac7e4ca6a448f38d4290c0bca43e56" + }, + "runs/run-3/raw/02-open-request-0.jpg": { + "bytes": 115671, + "sha256": "d9ea0557fa0ac86676f573c0454e3c400cdd8171f725120f0d93e6a6cad75001" + }, + "runs/run-3/raw/02-open-request.json": { + "bytes": 927, + "sha256": "e6d4d18f928a997fa50e4b57d089acbbcb9a48f685275c318a2811f9c4de3480" + }, + "runs/run-3/raw/03-file-dialog-0.jpg": { + "bytes": 674, + "sha256": "5a2387a1a593a32ea169b56b9a3e59333936ee1da95164564e4d9be61bf5a24b" + }, + "runs/run-3/raw/03-file-dialog-1.jpg": { + "bytes": 56820, + "sha256": "ae98662869a277df48796ddd792313273d7d9cb7a68dc81ed59cdfa003268c23" + }, + "runs/run-3/raw/03-file-dialog.json": { + "bytes": 5363, + "sha256": "e25b68daea2273998462bec8a467ed43f029dbef5cab7afd59400e3c325fb53e" + }, + "runs/run-3/raw/04-filename-focus-0.jpg": { + "bytes": 674, + "sha256": "5a2387a1a593a32ea169b56b9a3e59333936ee1da95164564e4d9be61bf5a24b" + }, + "runs/run-3/raw/04-filename-focus-1.jpg": { + "bytes": 57188, + "sha256": "31e20c7e7a50bb0b6da7a6a73638fe3208a5626e32e5d65b5b2d1232492eac29" + }, + "runs/run-3/raw/04-filename-focus.json": { + "bytes": 5369, + "sha256": "dcf68b111a0e9ec9e148d04ba4f4218796f4ea48ae1533918c4748cb259624ae" + }, + "runs/run-3/raw/05-fixture-path-0.jpg": { + "bytes": 674, + "sha256": "5a2387a1a593a32ea169b56b9a3e59333936ee1da95164564e4d9be61bf5a24b" + }, + "runs/run-3/raw/05-fixture-path-1.jpg": { + "bytes": 60453, + "sha256": "d854f5d5cbcf2d3c981e039b3cbed95d8fcd61fe1015cbc064c3d342fab613fa" + }, + "runs/run-3/raw/05-fixture-path.json": { + "bytes": 5365, + "sha256": "cb733587c924392974862a4eb04c8a2e5a3733fd80b1ffe55dad4d786f255be9" + }, + "runs/run-3/raw/06-loaded-0.jpg": { + "bytes": 101483, + "sha256": "796119babf149db4344b67dba00be0384ecf157f68cfb730fa5364a187ec7215" + }, + "runs/run-3/raw/06-loaded.json": { + "bytes": 725, + "sha256": "882df3f992fefcfd9471838fd57a97986d0686c3b8c3a8b0659bfcc04bdf058c" + }, + "runs/run-3/raw/07-load-settled-0.jpg": { + "bytes": 101483, + "sha256": "796119babf149db4344b67dba00be0384ecf157f68cfb730fa5364a187ec7215" + }, + "runs/run-3/raw/07-load-settled.json": { + "bytes": 731, + "sha256": "1334548661f18297a1d4294c8ec9f9e1def2731a5b0d671907309efb28ccf40f" + }, + "runs/run-3/raw/08-dialog-still-open-0.jpg": { + "bytes": 674, + "sha256": "5a2387a1a593a32ea169b56b9a3e59333936ee1da95164564e4d9be61bf5a24b" + }, + "runs/run-3/raw/08-dialog-still-open-1.jpg": { + "bytes": 60405, + "sha256": "80b53b108066add1a93775a93217e5e03dbe8c18d3d78839cb0f83ee542454ab" + }, + "runs/run-3/raw/08-dialog-still-open.json": { + "bytes": 5375, + "sha256": "08bcce5860dc67f72b9b300f39657fabb11ea6bd212c202d499eacf1f170e8ad" + }, + "runs/run-3/raw/09-model-opened-0.jpg": { + "bytes": 104176, + "sha256": "b5183c42de02b1067ee03696284757a2414813b1f314db59ad700d3368cb1d61" + }, + "runs/run-3/raw/09-model-opened.json": { + "bytes": 927, + "sha256": "2ef7223922936691ff9a0eaeaebe1c5465299713f6d80060774cb359de0b7f27" + }, + "runs/run-3/raw/10-behavior-0.jpg": { + "bytes": 117859, + "sha256": "69f17271f53d2c46bb45bfbad0384736a822c38e3b0d93da68f5f6ea23b667cc" + }, + "runs/run-3/raw/10-behavior.json": { + "bytes": 943, + "sha256": "04545cf4ea7593eb637b7403b87ad50387797ada3db326a0073cfbdd89c0a734" + }, + "runs/run-3/raw/11-baseline-saved-0.jpg": { + "bytes": 116102, + "sha256": "d8d540084414eebd6f71e025fd2973dd01cfe5631d8d8d23c6f79f7145bb06d0" + }, + "runs/run-3/raw/11-baseline-saved.json": { + "bytes": 949, + "sha256": "19b64b39254a54f47d910df5fc9cfa809482e73bc1297f66cb284e7acb2335a1" + }, + "runs/run-3/raw/12-first-qualified-0.jpg": { + "bytes": 108201, + "sha256": "719838e47dc03b235803e61fc9c2eb894716f374792bb56f1f6a1d156ad49bbb" + }, + "runs/run-3/raw/12-first-qualified.json": { + "bytes": 950, + "sha256": "20e6110c9283310f725701299d94a33e6594202a8496a22aa06f911ad97cf934" + }, + "runs/run-3/raw/13-nonprimary-menu-0.jpg": { + "bytes": 113445, + "sha256": "bce5afe8629d401db1aa483e2a5ffb1eff8e760d5f2506a9c25c3ad6c8bef35c" + }, + "runs/run-3/raw/13-nonprimary-menu.json": { + "bytes": 950, + "sha256": "9050dbc099422977a52f035c62e01b95eb5e17dd019193a4e50e34465db29292" + }, + "runs/run-3/raw/14-nonprimary-definition-0.jpg": { + "bytes": 123010, + "sha256": "06520c678c6babb27fd4aa75b45e5954aed6282ee27209a0dac08a2cdea57219" + }, + "runs/run-3/raw/14-nonprimary-definition.json": { + "bytes": 956, + "sha256": "2f062e788e47afb11be17d2e16b9c0ab032748108e3fb2ccb824f9dc4a454264" + }, + "runs/run-3/raw/15-return-control-0.jpg": { + "bytes": 142967, + "sha256": "ee0065f47dca396413e025fffc65c8a50aee15bd0ac3184e211134cee4eb4be1" + }, + "runs/run-3/raw/15-return-control.json": { + "bytes": 949, + "sha256": "98076ecfe9850f09362527430df606fd030b770bb2c4291051a4910c66a070b9" + }, + "runs/run-3/raw/16-exact-qualified-back-0.jpg": { + "bytes": 129288, + "sha256": "dd32a1faceb0dcda161fce7b614ed554ad97d85471b01f09faeea319255bb7fc" + }, + "runs/run-3/raw/16-exact-qualified-back.json": { + "bytes": 955, + "sha256": "e82e5627d3ccf6a53eba65f07d8d050c7ebf39d2bd06bf7b7942e85ed9e228a3" + }, + "runs/run-3/raw/17-caller-tree-expanded-0.jpg": { + "bytes": 130054, + "sha256": "b7340dcf9e34303c3119a185ebf5ec201ca18510fe5c7a88d4978e5f9df72122" + }, + "runs/run-3/raw/17-caller-tree-expanded.json": { + "bytes": 955, + "sha256": "494656be1196b4816e4746a63055ad54ed6f903faccdb56ea496a522fb53ea7a" + }, + "runs/run-3/raw/18-explicit-outer-properties-0.jpg": { + "bytes": 147914, + "sha256": "34611d45cd5128b4c79186fe1133bf4a557bdcacde54b125762fd707504224fc" + }, + "runs/run-3/raw/18-explicit-outer-properties.json": { + "bytes": 960, + "sha256": "2d632008044c61b346e0010b8de0c2547f0f5b2b29e1cc011bab5bdd568d54b1" + }, + "runs/run-3/raw/19-current-qualified-0.jpg": { + "bytes": 130920, + "sha256": "67a730f954586352ff6547608d92a2dd4602bd697f4f50835c2e7605f7ccf9ac" + }, + "runs/run-3/raw/19-current-qualified.json": { + "bytes": 952, + "sha256": "fdc8e45964f39a2ceb7e8efe4a21f4132ebb5a0a6a949289afa0562ae909c9fe" + }, + "runs/run-3/raw/20-current-definition-0.jpg": { + "bytes": 142408, + "sha256": "91870ea8556b2512fb44c1386478a1233665fc55906b3508d17d678bba0798b7" + }, + "runs/run-3/raw/20-current-definition.json": { + "bytes": 953, + "sha256": "c034163ff6be5bcad5da49bda6a1c69b97bb66d1ebf8d82e2dd0e57d2381abc4" + }, + "runs/run-3/raw/21-current-back-readonly-0.jpg": { + "bytes": 129954, + "sha256": "d5b6569134d8507e3ded44e32552ce7d6b104dc5f74de5de991867d328267116" + }, + "runs/run-3/raw/21-current-back-readonly.json": { + "bytes": 956, + "sha256": "2d12c0aa53fc8bdba96e22d4c2ff471295990d68bbf2b10102da1950220e9bfb" + }, + "runs/run-3/raw/22-explorer-same-leaf-definition-0.jpg": { + "bytes": 141748, + "sha256": "c94bfdc7962368ff62d12327cfcf50ae8a35d737000ca85aae4dc17f64ae1dcb" + }, + "runs/run-3/raw/22-explorer-same-leaf-definition.json": { + "bytes": 964, + "sha256": "0c7e29bd1e9fa0eb36a96345f352e2cc37e20ee93ed07d6ef65b27bdb9ea8d48" + }, + "runs/run-3/raw/23-navigation-saved-0.jpg": { + "bytes": 141652, + "sha256": "e1b137d6b3ec34be5770d22b312a8706a51aee96ace8127101559ba5dde49057" + }, + "runs/run-3/raw/23-navigation-saved.json": { + "bytes": 951, + "sha256": "aa118dc6d293929ee23c2817758501b61b8a0def0499b979adf25db48f2803c3" + }, + "runs/run-3/raw/24-details-closed-0.jpg": { + "bytes": 124930, + "sha256": "f63155e2192f9da2ea3dfaa34f7d935d818db4b6639fb31c6b30d289a06ae6aa" + }, + "runs/run-3/raw/24-details-closed.json": { + "bytes": 949, + "sha256": "8f3d008db3bd3a7eff41967604f7315b15c0bd0b77b106505275e8fed4425c66" + }, + "runs/run-3/raw/25-oa-order-0.jpg": { + "bytes": 130248, + "sha256": "1b3fecb6c71ecd44504840ef07be0ba9075bb2ecfc065a798ec6f4a8315dd8d4" + }, + "runs/run-3/raw/25-oa-order.json": { + "bytes": 943, + "sha256": "3e7a2397640f8df4419f129aebdfd75b4391c351700809e5378e74cc08456047" + }, + "runs/run-3/raw/26-local-card-0.jpg": { + "bytes": 138090, + "sha256": "b8a754e653d5735395386aaccca6f7363ba0c2659e40d3da642453d75b85cc90" + }, + "runs/run-3/raw/26-local-card.json": { + "bytes": 945, + "sha256": "589620b059e71f9c6071a69e941b1d9a38b3b98e6c1b93ebdfb5d8f9adb78a74" + }, + "runs/run-3/raw/27-resumed-card-0.jpg": { + "bytes": 138211, + "sha256": "e4f85e88c153136a5902e3f0821107d2a4f0668a15af9387e229a7d2003265d1" + }, + "runs/run-3/raw/27-resumed-card.json": { + "bytes": 947, + "sha256": "55ff218bb80d82ea92d2920f95b2aca832512154157e2506fb76aa6433284f6e" + }, + "runs/run-3/raw/28-link-menu-0.jpg": { + "bytes": 148009, + "sha256": "355859d4ef776835392b1bae6450259ab1dc930400fc7941524c3e5ea7ce3021" + }, + "runs/run-3/raw/28-link-menu.json": { + "bytes": 944, + "sha256": "46949da37c6a83648f6cfe359ff7c2ea6728d0d95c95e73b863c8a9385f44bb0" + }, + "runs/run-3/raw/29-mixed-selected-0.jpg": { + "bytes": 132323, + "sha256": "5a4e8db554d7e10fc338886a5abb932c34b5a4a39b2cdce81f5ee314943c3c37" + }, + "runs/run-3/raw/29-mixed-selected.json": { + "bytes": 949, + "sha256": "46d68e59984befda9fcc22818d4c65387402fa8ece52b2ce1acc9c9be61fbfe6" + }, + "runs/run-3/raw/30-mixed-refused-0.jpg": { + "bytes": 135371, + "sha256": "6f57e9981b0e778ec3b1fda43b1802c4c02bf42e583b6d9652c3bb5a063d60bf" + }, + "runs/run-3/raw/30-mixed-refused.json": { + "bytes": 948, + "sha256": "5f646d9d26b7d94a7eb7c4cbaefbd638838c5501ab9f7e71ba1816179339e1aa" + }, + "runs/run-3/raw/31-mixed-saved-0.jpg": { + "bytes": 132386, + "sha256": "396a43ee037a33540ede05f723be906fae39e066df3798704e811ac06991e5e0" + }, + "runs/run-3/raw/31-mixed-saved.json": { + "bytes": 946, + "sha256": "ae67f67a798396b64a29b4823bd730b60309909f75fcf7c820516c6cb54d7dcc" + }, + "runs/run-3/raw/32-card-c-0.jpg": { + "bytes": 138185, + "sha256": "3b317f73949e2566698503c262cbf4cf88d8d5f22055750b90e98946c11d4e4f" + }, + "runs/run-3/raw/32-card-c.json": { + "bytes": 941, + "sha256": "c38a4def6b82f787eb066c4e5fed33b24cc33d19113cbbab83853d458f67992e" + }, + "runs/run-3/raw/33-card-a-menu-0.jpg": { + "bytes": 146387, + "sha256": "244e01ffe98fd8da081d094f24e950bea856abdde7b47fb49f3a303bfc6ad398" + }, + "runs/run-3/raw/33-card-a-menu.json": { + "bytes": 946, + "sha256": "01a59f4982fa8fc3e1cd5c103dfcbf6c7cd59e55a36aa13aa7fb07608e29d3e3" + }, + "runs/run-3/raw/34-group-primary-a-0.jpg": { + "bytes": 137527, + "sha256": "10e27d0c93f050d3bc1c2f4db6b20b7659df4f5589c1168d4d64a4c986235bb9" + }, + "runs/run-3/raw/34-group-primary-a.json": { + "bytes": 950, + "sha256": "bd184effcb83d8029e07d79667cbd044442f66c84ab744e847d7f0b3ea522823" + }, + "runs/run-3/raw/35-group-menu-c-0.jpg": { + "bytes": 148604, + "sha256": "012f60ea7c78f2d18d744e38ff0145d437d3500190376219a1087ce5339c3284" + }, + "runs/run-3/raw/35-group-menu-c.json": { + "bytes": 947, + "sha256": "84f0f1d2ee26b7c27b9cdfadc5b7c729e3181e0fc8520bd87f007ce263009555" + }, + "runs/run-3/raw/36-group-primary-c-0.jpg": { + "bytes": 137655, + "sha256": "8ba0c813893e00371ac34af5231b48e82f5b25b60c5797f4e618df7a1dab462f" + }, + "runs/run-3/raw/36-group-primary-c.json": { + "bytes": 950, + "sha256": "5a02bdceb22ea683600b40b491961561c4816a97bb666f472fd69f69b736f12b" + }, + "runs/run-3/raw/37-selected-anchor-0.jpg": { + "bytes": 138388, + "sha256": "31a9bec04d13d494e4d7053571434105caae312c2a660fc2b9d889ad22a01bd5" + }, + "runs/run-3/raw/37-selected-anchor.json": { + "bytes": 950, + "sha256": "68faad5306c55910b3de2259f9009c8b7b268c7b96efc39c2c66005a71a0156b" + }, + "runs/run-3/raw/38-anchor-saved-0.jpg": { + "bytes": 137524, + "sha256": "d10d5e8c10fd6fb2dadd088ef53b228de15187a24d2f65aa767ca48fc7973688" + }, + "runs/run-3/raw/38-anchor-saved.json": { + "bytes": 947, + "sha256": "f70232688aadc09b680b0651cf6171d44a0c18d7c518afed0cd9ccc2216bed91" + }, + "runs/run-3/raw/39-group-reordered-0.jpg": { + "bytes": 139094, + "sha256": "3f967cd523085ba01607ec33ff30e908480ed1847e2688b2b28b896f43d5c0c9" + }, + "runs/run-3/raw/39-group-reordered.json": { + "bytes": 958, + "sha256": "7275030e69892f4c8cd7aadc638360d140fb1770d77f5b9b4421bda0be01e4ad" + }, + "runs/run-3/raw/40-reordered-saved-0.jpg": { + "bytes": 137845, + "sha256": "cbf1cdd68899c1811970d228b1065317bb333e7b119c59300ab5060a1efee096" + }, + "runs/run-3/raw/40-reordered-saved.json": { + "bytes": 950, + "sha256": "5459002ad776c245bcfd53b14fb4c93973a3b42197784796fcec2adaec2ad39b" + }, + "runs/run-3/raw/41-one-undo-0.jpg": { + "bytes": 137809, + "sha256": "035edf0630cccd93bdbb97160c817b555b9009471670679d70015ce6c2c30400" + }, + "runs/run-3/raw/41-one-undo.json": { + "bytes": 953, + "sha256": "e003fd2fb778b4855b0d2ff2231d7310af7e88b8860b55566c0c993691cc5288" + }, + "runs/run-3/raw/42-undo-saved-0.jpg": { + "bytes": 137708, + "sha256": "5a07f0ad701097bcf777a32c60bc2f3532835bb176b54d0887c5ce5fbd81793d" + }, + "runs/run-3/raw/42-undo-saved.json": { + "bytes": 947, + "sha256": "9b5c2a2b45e9ec867e909c15dafc0ee23e9c857313ad9498d5c58d147d8c1134" + }, + "runs/run-3/raw/43-one-redo-0.jpg": { + "bytes": 138035, + "sha256": "8b98fbda6e6a587289cd1d46cb301acf85b2c1a680ddb452fc74b92d4c0e5c77" + }, + "runs/run-3/raw/43-one-redo.json": { + "bytes": 953, + "sha256": "a96679da85055d4dc46a580d700b5fea6c56c47da81e10213de834d1afcaaf64" + }, + "runs/run-3/raw/44-redo-saved-0.jpg": { + "bytes": 137845, + "sha256": "cbf1cdd68899c1811970d228b1065317bb333e7b119c59300ab5060a1efee096" + }, + "runs/run-3/raw/44-redo-saved.json": { + "bytes": 945, + "sha256": "2de274a0c4a64de4bfeab1a7888463a83f046016b5bccfde03670f88772508f7" + }, + "runs/run-3/raw/45-reopen-request-0.jpg": { + "bytes": 110319, + "sha256": "c1f9a681d1a4f38104d75dfdffc3b94d70bbd0735214b78afc6274950a5c3b86" + }, + "runs/run-3/raw/45-reopen-request.json": { + "bytes": 949, + "sha256": "e1c855f16a488ea7b9d7e940045baceed48b4f1e681c3e48898da92838a41411" + }, + "runs/run-3/raw/46-reopen-dialog-0.jpg": { + "bytes": 674, + "sha256": "5a2387a1a593a32ea169b56b9a3e59333936ee1da95164564e4d9be61bf5a24b" + }, + "runs/run-3/raw/46-reopen-dialog-1.jpg": { + "bytes": 56513, + "sha256": "b66715c568db538617e7cbdcf4ec7dee9e7e78d9dcad3d3e4f4970a4887a141c" + }, + "runs/run-3/raw/46-reopen-dialog.json": { + "bytes": 5369, + "sha256": "59fc90e97e131ffddb8f08b8724910fb2047f33c1f77c660faacb92ec3d088a0" + }, + "runs/run-3/raw/47-reopen-filename-focus-0.jpg": { + "bytes": 674, + "sha256": "5a2387a1a593a32ea169b56b9a3e59333936ee1da95164564e4d9be61bf5a24b" + }, + "runs/run-3/raw/47-reopen-filename-focus-1.jpg": { + "bytes": 56915, + "sha256": "d295eb318dfe1dc3ca43d96f1b0a707f46ecc1871fa9d15937acf4347e4b83e5" + }, + "runs/run-3/raw/47-reopen-filename-focus.json": { + "bytes": 5385, + "sha256": "70fbaef2181d63002d325701ae61fed1868958fbb2eac912110daab2f35e2515" + }, + "runs/run-3/raw/48-reopen-path-0.jpg": { + "bytes": 674, + "sha256": "5a2387a1a593a32ea169b56b9a3e59333936ee1da95164564e4d9be61bf5a24b" + }, + "runs/run-3/raw/48-reopen-path-1.jpg": { + "bytes": 60144, + "sha256": "b7ff90cf853f39b9d28b6df02ba8c613b4c484ec329ab5e5150807b2e67952f1" + }, + "runs/run-3/raw/48-reopen-path.json": { + "bytes": 5365, + "sha256": "d2756c9a604e4762fb75c7369644adf751ee6caf118c8ed7a620d23ce9f5e2b8" + }, + "runs/run-3/raw/49-reopened-0.jpg": { + "bytes": 103619, + "sha256": "0e6f7d26d951607b23f1a780c78209a698b663ee41f8125055a75ae074af1bde" + }, + "runs/run-3/raw/49-reopened.json": { + "bytes": 943, + "sha256": "e2edb80c611df6f205954e6a099011d951f327a87218716c977f17bbf4bb547b" + }, + "runs/run-3/raw/50-reopened-behavior-0.jpg": { + "bytes": 117246, + "sha256": "1ff7dabb2022cc1b0488cb224ff3f2273809a55c47790b01e47e66edf133265e" + }, + "runs/run-3/raw/50-reopened-behavior.json": { + "bytes": 952, + "sha256": "1d7d48298782d22902a85c87c099734fec129408f00ddb1e0afa030a95ea0345" + }, + "runs/run-3/raw/51-reopened-order-0.jpg": { + "bytes": 130526, + "sha256": "26c51898a24624514f636a390f5a0cddef2a11bc3ebff9f3359bf61d54c2c9f5" + }, + "runs/run-3/raw/51-reopened-order.json": { + "bytes": 949, + "sha256": "4e4496ff615fb4c2a09679655eebae24c68ef4da13828741412fb4dcf2c7c233" + }, + "runs/run-3/raw/52-reopened-saved-0.jpg": { + "bytes": 128777, + "sha256": "2e71a4dd0cd4ab7e224ae1e1be37b576e0947f2190e3cc49b59ce6ac58895fc9" + }, + "runs/run-3/raw/52-reopened-saved.json": { + "bytes": 949, + "sha256": "620d5485447fdf9541a3d50743ff685017dddd81429a92618dc5bee63e280683" + }, + "runs/run-3/raw/app.stderr.log": { + "bytes": 0, + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "runs/run-3/raw/app.stdout.log": { + "bytes": 0, + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "runs/run-3/raw/capture-executed.js": { + "bytes": 1255, + "sha256": "d2c9a3d2bcfe27e6ecd5077ef132862bcc3876f072c6abd787419e8d82de9642" + }, + "runs/run-3/raw/finished.json": { + "bytes": 380, + "sha256": "6044948da93f4e7aa9d0914fbaf08013d196eef868f1f603ba5014f780b79e19" + }, + "runs/run-3/raw/fixture/baseline.json": { + "bytes": 14627, + "sha256": "5cd1befea8666c5a686c22ab4bbbbf4d34523cd031b236d226371cbc19478683" + }, + "runs/run-3/raw/fixture/manifest.json": { + "bytes": 2449, + "sha256": "ef76db9595f5888c0207e2e42d13acbf0a6cda8d97fc5ab70b35183e8255b041" + }, + "runs/run-3/raw/fixture/source/oa/activities/activity_oa_downstream_work.arcadia": { + "bytes": 171, + "sha256": "7ebf9516f9f016139a4504649cd41e327b59b6798581bf9f65af2d30cfcd5a0e" + }, + "runs/run-3/raw/fixture/source/oa/activities/activity_oa_shared_work.arcadia": { + "bytes": 159, + "sha256": "56139bf120e41b752d50aa85955b96eddc0c7e573fe493248deed1b49933a95d" + }, + "runs/run-3/raw/fixture/source/oa/behavior/process_oa_process.arcadia": { + "bytes": 1737, + "sha256": "2f39cdf59c59bf56099ce5f07e339fa3f4fd0c8e964545f19c995a2341aba2e0" + }, + "runs/run-3/raw/fixture/source/oa/behavior/process_reusable.arcadia": { + "bytes": 429, + "sha256": "21d216c39d0990a3ebc2d40b02d00c1cc126185def1fb5342c3205ca890ae04a" + }, + "runs/run-3/raw/fixture/source/oa/behavior/process_reuse_trial.arcadia": { + "bytes": 730, + "sha256": "2384e5b81dc64ec229a0b1664333ee48a8a251b7ac8138584215a8611472fe2a" + }, + "runs/run-3/raw/fixture/source/project.arcadia": { + "bytes": 753, + "sha256": "a942705a988f5bfa35f2bdae273bce3fc6dd9afff8267a96483df598dd5a13a6" + }, + "runs/run-3/raw/fixture/source/sa/behavior/process_sa_chain.arcadia": { + "bytes": 1734, + "sha256": "b6aa1d90a113dacffdbc980f1fa6c6b2b85050921622c57492c80a1602e11a16" + }, + "runs/run-3/raw/fixture/source/sa/functions/function_sa_downstream_work.arcadia": { + "bytes": 171, + "sha256": "95ee9f35a41a4580c51bbf56b1b00717969adb77a0c05a1f30740c4c23788a02" + }, + "runs/run-3/raw/fixture/source/sa/functions/function_sa_shared_work.arcadia": { + "bytes": 159, + "sha256": "0117bcfe32321b63d6b05b745f67aa57a565026eed68eb5a98300b7265941e9a" + }, + "runs/run-3/raw/fixture/source/traceability.arcadia": { + "bytes": 528, + "sha256": "c5eaf8dc3c09dc56129a73da9630bb3fda99a0cfc9a8fa266e3a6ee32f53deb6" + }, + "runs/run-3/raw/generation.json": { + "bytes": 1244, + "sha256": "1a02afe08db16914c72ddab1eb9bfde5bbf0d5609ad31975a0be461c2cd8b864" + }, + "runs/run-3/raw/generation.stderr.log": { + "bytes": 0, + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "runs/run-3/raw/generation.stdout.log": { + "bytes": 112, + "sha256": "74c4b10c056ca7b1ff6b0cca5e1b5db17fb97047e0422b2a04e6eb3f7a042bea" + }, + "runs/run-3/raw/history-bytes.json": { + "bytes": 1885, + "sha256": "5b70ea68e8bacf9e2557ca7db8af9d166d02ca56872be2af4265bb6cf2c727db" + }, + "runs/run-3/raw/observations.md": { + "bytes": 5124, + "sha256": "d5bccef8f0492e180aaa71d0d470c461d571c6ce3ad2c2fbd43c63762af43cac" + }, + "runs/run-3/raw/prepared.json": { + "bytes": 4600, + "sha256": "c75ef9e3019f54a8fdd3f98e2d520fca1657faf535ba08a5bdb88067f9461998" + }, + "runs/run-3/raw/snapshots/anchor-noop/result.json": { + "bytes": 3492, + "sha256": "cf056d517764f2a0f46371423d5b78204baf747eb8dcc27f03b6dffc86fc0d48" + }, + "runs/run-3/raw/snapshots/anchor-noop/source/oa/activities/activity_oa_downstream_work.arcadia": { + "bytes": 171, + "sha256": "7ebf9516f9f016139a4504649cd41e327b59b6798581bf9f65af2d30cfcd5a0e" + }, + "runs/run-3/raw/snapshots/anchor-noop/source/oa/activities/activity_oa_shared_work.arcadia": { + "bytes": 159, + "sha256": "56139bf120e41b752d50aa85955b96eddc0c7e573fe493248deed1b49933a95d" + }, + "runs/run-3/raw/snapshots/anchor-noop/source/oa/behavior/process_oa_process.arcadia": { + "bytes": 1737, + "sha256": "2f39cdf59c59bf56099ce5f07e339fa3f4fd0c8e964545f19c995a2341aba2e0" + }, + "runs/run-3/raw/snapshots/anchor-noop/source/oa/behavior/process_reusable.arcadia": { + "bytes": 429, + "sha256": "21d216c39d0990a3ebc2d40b02d00c1cc126185def1fb5342c3205ca890ae04a" + }, + "runs/run-3/raw/snapshots/anchor-noop/source/oa/behavior/process_reuse_trial.arcadia": { + "bytes": 730, + "sha256": "2384e5b81dc64ec229a0b1664333ee48a8a251b7ac8138584215a8611472fe2a" + }, + "runs/run-3/raw/snapshots/anchor-noop/source/project.arcadia": { + "bytes": 753, + "sha256": "a942705a988f5bfa35f2bdae273bce3fc6dd9afff8267a96483df598dd5a13a6" + }, + "runs/run-3/raw/snapshots/anchor-noop/source/sa/behavior/process_sa_chain.arcadia": { + "bytes": 1734, + "sha256": "b6aa1d90a113dacffdbc980f1fa6c6b2b85050921622c57492c80a1602e11a16" + }, + "runs/run-3/raw/snapshots/anchor-noop/source/sa/functions/function_sa_downstream_work.arcadia": { + "bytes": 171, + "sha256": "95ee9f35a41a4580c51bbf56b1b00717969adb77a0c05a1f30740c4c23788a02" + }, + "runs/run-3/raw/snapshots/anchor-noop/source/sa/functions/function_sa_shared_work.arcadia": { + "bytes": 159, + "sha256": "0117bcfe32321b63d6b05b745f67aa57a565026eed68eb5a98300b7265941e9a" + }, + "runs/run-3/raw/snapshots/anchor-noop/source/traceability.arcadia": { + "bytes": 528, + "sha256": "c5eaf8dc3c09dc56129a73da9630bb3fda99a0cfc9a8fa266e3a6ee32f53deb6" + }, + "runs/run-3/raw/snapshots/anchor-noop/started.json": { + "bytes": 3074, + "sha256": "e4e50d3c45d9644c8e832d56730416d5be00203832111c7b7c8bd3dbf0c2cd78" + }, + "runs/run-3/raw/snapshots/anchor-noop/stderr.log": { + "bytes": 0, + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "runs/run-3/raw/snapshots/anchor-noop/stdout.log": { + "bytes": 430, + "sha256": "f65ba21f51a6a43191dbd27b29ba764739ae954212675ed89fef6b6276a97a92" + }, + "runs/run-3/raw/snapshots/baseline/result.json": { + "bytes": 3489, + "sha256": "6a883d21bf0f549e042dc5b9b4644714c9e7fe8ee0c52e0db551d1d6ec4a10d6" + }, + "runs/run-3/raw/snapshots/baseline/source/oa/activities/activity_oa_downstream_work.arcadia": { + "bytes": 171, + "sha256": "7ebf9516f9f016139a4504649cd41e327b59b6798581bf9f65af2d30cfcd5a0e" + }, + "runs/run-3/raw/snapshots/baseline/source/oa/activities/activity_oa_shared_work.arcadia": { + "bytes": 159, + "sha256": "56139bf120e41b752d50aa85955b96eddc0c7e573fe493248deed1b49933a95d" + }, + "runs/run-3/raw/snapshots/baseline/source/oa/behavior/process_oa_process.arcadia": { + "bytes": 1737, + "sha256": "2f39cdf59c59bf56099ce5f07e339fa3f4fd0c8e964545f19c995a2341aba2e0" + }, + "runs/run-3/raw/snapshots/baseline/source/oa/behavior/process_reusable.arcadia": { + "bytes": 429, + "sha256": "21d216c39d0990a3ebc2d40b02d00c1cc126185def1fb5342c3205ca890ae04a" + }, + "runs/run-3/raw/snapshots/baseline/source/oa/behavior/process_reuse_trial.arcadia": { + "bytes": 730, + "sha256": "2384e5b81dc64ec229a0b1664333ee48a8a251b7ac8138584215a8611472fe2a" + }, + "runs/run-3/raw/snapshots/baseline/source/project.arcadia": { + "bytes": 753, + "sha256": "a942705a988f5bfa35f2bdae273bce3fc6dd9afff8267a96483df598dd5a13a6" + }, + "runs/run-3/raw/snapshots/baseline/source/sa/behavior/process_sa_chain.arcadia": { + "bytes": 1734, + "sha256": "b6aa1d90a113dacffdbc980f1fa6c6b2b85050921622c57492c80a1602e11a16" + }, + "runs/run-3/raw/snapshots/baseline/source/sa/functions/function_sa_downstream_work.arcadia": { + "bytes": 171, + "sha256": "95ee9f35a41a4580c51bbf56b1b00717969adb77a0c05a1f30740c4c23788a02" + }, + "runs/run-3/raw/snapshots/baseline/source/sa/functions/function_sa_shared_work.arcadia": { + "bytes": 159, + "sha256": "0117bcfe32321b63d6b05b745f67aa57a565026eed68eb5a98300b7265941e9a" + }, + "runs/run-3/raw/snapshots/baseline/source/traceability.arcadia": { + "bytes": 528, + "sha256": "c5eaf8dc3c09dc56129a73da9630bb3fda99a0cfc9a8fa266e3a6ee32f53deb6" + }, + "runs/run-3/raw/snapshots/baseline/started.json": { + "bytes": 3071, + "sha256": "a0102c7dc3e7a591ab8d7c2800400d67169de1144dde08647c9c24645432a918" + }, + "runs/run-3/raw/snapshots/baseline/stderr.log": { + "bytes": 0, + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "runs/run-3/raw/snapshots/baseline/stdout.log": { + "bytes": 427, + "sha256": "b25adffce038f9b24a5f6e1a396aca6176c3d5f47ad0faa5c0abc2609ee454ce" + }, + "runs/run-3/raw/snapshots/mixed-refusal/result.json": { + "bytes": 3494, + "sha256": "a796e6b135cc9a065855cdfc1a89c86621a2a400438ccf464b1db5e6f5159059" + }, + "runs/run-3/raw/snapshots/mixed-refusal/source/oa/activities/activity_oa_downstream_work.arcadia": { + "bytes": 171, + "sha256": "7ebf9516f9f016139a4504649cd41e327b59b6798581bf9f65af2d30cfcd5a0e" + }, + "runs/run-3/raw/snapshots/mixed-refusal/source/oa/activities/activity_oa_shared_work.arcadia": { + "bytes": 159, + "sha256": "56139bf120e41b752d50aa85955b96eddc0c7e573fe493248deed1b49933a95d" + }, + "runs/run-3/raw/snapshots/mixed-refusal/source/oa/behavior/process_oa_process.arcadia": { + "bytes": 1737, + "sha256": "2f39cdf59c59bf56099ce5f07e339fa3f4fd0c8e964545f19c995a2341aba2e0" + }, + "runs/run-3/raw/snapshots/mixed-refusal/source/oa/behavior/process_reusable.arcadia": { + "bytes": 429, + "sha256": "21d216c39d0990a3ebc2d40b02d00c1cc126185def1fb5342c3205ca890ae04a" + }, + "runs/run-3/raw/snapshots/mixed-refusal/source/oa/behavior/process_reuse_trial.arcadia": { + "bytes": 730, + "sha256": "2384e5b81dc64ec229a0b1664333ee48a8a251b7ac8138584215a8611472fe2a" + }, + "runs/run-3/raw/snapshots/mixed-refusal/source/project.arcadia": { + "bytes": 753, + "sha256": "a942705a988f5bfa35f2bdae273bce3fc6dd9afff8267a96483df598dd5a13a6" + }, + "runs/run-3/raw/snapshots/mixed-refusal/source/sa/behavior/process_sa_chain.arcadia": { + "bytes": 1734, + "sha256": "b6aa1d90a113dacffdbc980f1fa6c6b2b85050921622c57492c80a1602e11a16" + }, + "runs/run-3/raw/snapshots/mixed-refusal/source/sa/functions/function_sa_downstream_work.arcadia": { + "bytes": 171, + "sha256": "95ee9f35a41a4580c51bbf56b1b00717969adb77a0c05a1f30740c4c23788a02" + }, + "runs/run-3/raw/snapshots/mixed-refusal/source/sa/functions/function_sa_shared_work.arcadia": { + "bytes": 159, + "sha256": "0117bcfe32321b63d6b05b745f67aa57a565026eed68eb5a98300b7265941e9a" + }, + "runs/run-3/raw/snapshots/mixed-refusal/source/traceability.arcadia": { + "bytes": 528, + "sha256": "c5eaf8dc3c09dc56129a73da9630bb3fda99a0cfc9a8fa266e3a6ee32f53deb6" + }, + "runs/run-3/raw/snapshots/mixed-refusal/started.json": { + "bytes": 3076, + "sha256": "59f380abe57cb10caa3722759c18619325b78ea6c09b223d1f588fa0c59706a3" + }, + "runs/run-3/raw/snapshots/mixed-refusal/stderr.log": { + "bytes": 0, + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "runs/run-3/raw/snapshots/mixed-refusal/stdout.log": { + "bytes": 432, + "sha256": "dfa538b7e8315285ded86a8ad9ba2337134638b64c0dbfd03643c29c7ff79eae" + }, + "runs/run-3/raw/snapshots/qualified-navigation/result.json": { + "bytes": 3501, + "sha256": "69ce6543babe7a0364cf15717a837cebff07ae65e1d1ebf7d9d2a196c8db56e0" + }, + "runs/run-3/raw/snapshots/qualified-navigation/source/oa/activities/activity_oa_downstream_work.arcadia": { + "bytes": 171, + "sha256": "7ebf9516f9f016139a4504649cd41e327b59b6798581bf9f65af2d30cfcd5a0e" + }, + "runs/run-3/raw/snapshots/qualified-navigation/source/oa/activities/activity_oa_shared_work.arcadia": { + "bytes": 159, + "sha256": "56139bf120e41b752d50aa85955b96eddc0c7e573fe493248deed1b49933a95d" + }, + "runs/run-3/raw/snapshots/qualified-navigation/source/oa/behavior/process_oa_process.arcadia": { + "bytes": 1737, + "sha256": "2f39cdf59c59bf56099ce5f07e339fa3f4fd0c8e964545f19c995a2341aba2e0" + }, + "runs/run-3/raw/snapshots/qualified-navigation/source/oa/behavior/process_reusable.arcadia": { + "bytes": 429, + "sha256": "21d216c39d0990a3ebc2d40b02d00c1cc126185def1fb5342c3205ca890ae04a" + }, + "runs/run-3/raw/snapshots/qualified-navigation/source/oa/behavior/process_reuse_trial.arcadia": { + "bytes": 730, + "sha256": "2384e5b81dc64ec229a0b1664333ee48a8a251b7ac8138584215a8611472fe2a" + }, + "runs/run-3/raw/snapshots/qualified-navigation/source/project.arcadia": { + "bytes": 753, + "sha256": "a942705a988f5bfa35f2bdae273bce3fc6dd9afff8267a96483df598dd5a13a6" + }, + "runs/run-3/raw/snapshots/qualified-navigation/source/sa/behavior/process_sa_chain.arcadia": { + "bytes": 1734, + "sha256": "b6aa1d90a113dacffdbc980f1fa6c6b2b85050921622c57492c80a1602e11a16" + }, + "runs/run-3/raw/snapshots/qualified-navigation/source/sa/functions/function_sa_downstream_work.arcadia": { + "bytes": 171, + "sha256": "95ee9f35a41a4580c51bbf56b1b00717969adb77a0c05a1f30740c4c23788a02" + }, + "runs/run-3/raw/snapshots/qualified-navigation/source/sa/functions/function_sa_shared_work.arcadia": { + "bytes": 159, + "sha256": "0117bcfe32321b63d6b05b745f67aa57a565026eed68eb5a98300b7265941e9a" + }, + "runs/run-3/raw/snapshots/qualified-navigation/source/traceability.arcadia": { + "bytes": 528, + "sha256": "c5eaf8dc3c09dc56129a73da9630bb3fda99a0cfc9a8fa266e3a6ee32f53deb6" + }, + "runs/run-3/raw/snapshots/qualified-navigation/started.json": { + "bytes": 3083, + "sha256": "eef5f16c3c8bf5e8e0f5c553177bd798f6300bdf3b07f7bf700cfef0ae0c8832" + }, + "runs/run-3/raw/snapshots/qualified-navigation/stderr.log": { + "bytes": 0, + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "runs/run-3/raw/snapshots/qualified-navigation/stdout.log": { + "bytes": 439, + "sha256": "adc403e0006c6543ed20e6abd681055e1a825ca127d18dc432789fdddf40a429" + }, + "runs/run-3/raw/snapshots/redo/result.json": { + "bytes": 3485, + "sha256": "148458cb2c9877e67dc534b1bf14ea3652f4b2c856e972d6029dbce228a6a8d4" + }, + "runs/run-3/raw/snapshots/redo/source/oa/activities/activity_oa_downstream_work.arcadia": { + "bytes": 171, + "sha256": "7ebf9516f9f016139a4504649cd41e327b59b6798581bf9f65af2d30cfcd5a0e" + }, + "runs/run-3/raw/snapshots/redo/source/oa/activities/activity_oa_shared_work.arcadia": { + "bytes": 159, + "sha256": "56139bf120e41b752d50aa85955b96eddc0c7e573fe493248deed1b49933a95d" + }, + "runs/run-3/raw/snapshots/redo/source/oa/behavior/process_oa_process.arcadia": { + "bytes": 1737, + "sha256": "3aa5cbb23ecc74bec7f31a0d7f7270b1f49b3444d3f35cf4baacaebfa6597999" + }, + "runs/run-3/raw/snapshots/redo/source/oa/behavior/process_reusable.arcadia": { + "bytes": 429, + "sha256": "21d216c39d0990a3ebc2d40b02d00c1cc126185def1fb5342c3205ca890ae04a" + }, + "runs/run-3/raw/snapshots/redo/source/oa/behavior/process_reuse_trial.arcadia": { + "bytes": 730, + "sha256": "2384e5b81dc64ec229a0b1664333ee48a8a251b7ac8138584215a8611472fe2a" + }, + "runs/run-3/raw/snapshots/redo/source/project.arcadia": { + "bytes": 753, + "sha256": "a942705a988f5bfa35f2bdae273bce3fc6dd9afff8267a96483df598dd5a13a6" + }, + "runs/run-3/raw/snapshots/redo/source/sa/behavior/process_sa_chain.arcadia": { + "bytes": 1734, + "sha256": "b6aa1d90a113dacffdbc980f1fa6c6b2b85050921622c57492c80a1602e11a16" + }, + "runs/run-3/raw/snapshots/redo/source/sa/functions/function_sa_downstream_work.arcadia": { + "bytes": 171, + "sha256": "95ee9f35a41a4580c51bbf56b1b00717969adb77a0c05a1f30740c4c23788a02" + }, + "runs/run-3/raw/snapshots/redo/source/sa/functions/function_sa_shared_work.arcadia": { + "bytes": 159, + "sha256": "0117bcfe32321b63d6b05b745f67aa57a565026eed68eb5a98300b7265941e9a" + }, + "runs/run-3/raw/snapshots/redo/source/traceability.arcadia": { + "bytes": 528, + "sha256": "c5eaf8dc3c09dc56129a73da9630bb3fda99a0cfc9a8fa266e3a6ee32f53deb6" + }, + "runs/run-3/raw/snapshots/redo/started.json": { + "bytes": 3067, + "sha256": "f5165650693e2e749f7675b9957a21a365d8afbcfd37f5133853b44eaa5d362e" + }, + "runs/run-3/raw/snapshots/redo/stderr.log": { + "bytes": 0, + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "runs/run-3/raw/snapshots/redo/stdout.log": { + "bytes": 423, + "sha256": "18fe05afbf0193fd4ab892e9e64ac6793df5f3d863a2fafd1c92aa9f5cad2dd7" + }, + "runs/run-3/raw/snapshots/reopened/result.json": { + "bytes": 3489, + "sha256": "7f5c338b3c49219b2a59274ae0f38f2816a961e04d1552d69b10120142c65974" + }, + "runs/run-3/raw/snapshots/reopened/source/oa/activities/activity_oa_downstream_work.arcadia": { + "bytes": 171, + "sha256": "7ebf9516f9f016139a4504649cd41e327b59b6798581bf9f65af2d30cfcd5a0e" + }, + "runs/run-3/raw/snapshots/reopened/source/oa/activities/activity_oa_shared_work.arcadia": { + "bytes": 159, + "sha256": "56139bf120e41b752d50aa85955b96eddc0c7e573fe493248deed1b49933a95d" + }, + "runs/run-3/raw/snapshots/reopened/source/oa/behavior/process_oa_process.arcadia": { + "bytes": 1737, + "sha256": "3aa5cbb23ecc74bec7f31a0d7f7270b1f49b3444d3f35cf4baacaebfa6597999" + }, + "runs/run-3/raw/snapshots/reopened/source/oa/behavior/process_reusable.arcadia": { + "bytes": 429, + "sha256": "21d216c39d0990a3ebc2d40b02d00c1cc126185def1fb5342c3205ca890ae04a" + }, + "runs/run-3/raw/snapshots/reopened/source/oa/behavior/process_reuse_trial.arcadia": { + "bytes": 730, + "sha256": "2384e5b81dc64ec229a0b1664333ee48a8a251b7ac8138584215a8611472fe2a" + }, + "runs/run-3/raw/snapshots/reopened/source/project.arcadia": { + "bytes": 753, + "sha256": "a942705a988f5bfa35f2bdae273bce3fc6dd9afff8267a96483df598dd5a13a6" + }, + "runs/run-3/raw/snapshots/reopened/source/sa/behavior/process_sa_chain.arcadia": { + "bytes": 1734, + "sha256": "b6aa1d90a113dacffdbc980f1fa6c6b2b85050921622c57492c80a1602e11a16" + }, + "runs/run-3/raw/snapshots/reopened/source/sa/functions/function_sa_downstream_work.arcadia": { + "bytes": 171, + "sha256": "95ee9f35a41a4580c51bbf56b1b00717969adb77a0c05a1f30740c4c23788a02" + }, + "runs/run-3/raw/snapshots/reopened/source/sa/functions/function_sa_shared_work.arcadia": { + "bytes": 159, + "sha256": "0117bcfe32321b63d6b05b745f67aa57a565026eed68eb5a98300b7265941e9a" + }, + "runs/run-3/raw/snapshots/reopened/source/traceability.arcadia": { + "bytes": 528, + "sha256": "c5eaf8dc3c09dc56129a73da9630bb3fda99a0cfc9a8fa266e3a6ee32f53deb6" + }, + "runs/run-3/raw/snapshots/reopened/started.json": { + "bytes": 3071, + "sha256": "a6a3a9b64b7398a00be9fed9bf67013e8b4fe5a24a6d344c051e7a4ae164f1a1" + }, + "runs/run-3/raw/snapshots/reopened/stderr.log": { + "bytes": 0, + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "runs/run-3/raw/snapshots/reopened/stdout.log": { + "bytes": 427, + "sha256": "ab357e3de52126f520721782309c09e2d7dec59d515aa625834c90e7d542b8a6" + }, + "runs/run-3/raw/snapshots/reordered/result.json": { + "bytes": 3490, + "sha256": "6378ec2ce1d560459a0a4683b5dfb6f3519cfcf3d21e320c6d1bbe5a9dbb652b" + }, + "runs/run-3/raw/snapshots/reordered/source/oa/activities/activity_oa_downstream_work.arcadia": { + "bytes": 171, + "sha256": "7ebf9516f9f016139a4504649cd41e327b59b6798581bf9f65af2d30cfcd5a0e" + }, + "runs/run-3/raw/snapshots/reordered/source/oa/activities/activity_oa_shared_work.arcadia": { + "bytes": 159, + "sha256": "56139bf120e41b752d50aa85955b96eddc0c7e573fe493248deed1b49933a95d" + }, + "runs/run-3/raw/snapshots/reordered/source/oa/behavior/process_oa_process.arcadia": { + "bytes": 1737, + "sha256": "3aa5cbb23ecc74bec7f31a0d7f7270b1f49b3444d3f35cf4baacaebfa6597999" + }, + "runs/run-3/raw/snapshots/reordered/source/oa/behavior/process_reusable.arcadia": { + "bytes": 429, + "sha256": "21d216c39d0990a3ebc2d40b02d00c1cc126185def1fb5342c3205ca890ae04a" + }, + "runs/run-3/raw/snapshots/reordered/source/oa/behavior/process_reuse_trial.arcadia": { + "bytes": 730, + "sha256": "2384e5b81dc64ec229a0b1664333ee48a8a251b7ac8138584215a8611472fe2a" + }, + "runs/run-3/raw/snapshots/reordered/source/project.arcadia": { + "bytes": 753, + "sha256": "a942705a988f5bfa35f2bdae273bce3fc6dd9afff8267a96483df598dd5a13a6" + }, + "runs/run-3/raw/snapshots/reordered/source/sa/behavior/process_sa_chain.arcadia": { + "bytes": 1734, + "sha256": "b6aa1d90a113dacffdbc980f1fa6c6b2b85050921622c57492c80a1602e11a16" + }, + "runs/run-3/raw/snapshots/reordered/source/sa/functions/function_sa_downstream_work.arcadia": { + "bytes": 171, + "sha256": "95ee9f35a41a4580c51bbf56b1b00717969adb77a0c05a1f30740c4c23788a02" + }, + "runs/run-3/raw/snapshots/reordered/source/sa/functions/function_sa_shared_work.arcadia": { + "bytes": 159, + "sha256": "0117bcfe32321b63d6b05b745f67aa57a565026eed68eb5a98300b7265941e9a" + }, + "runs/run-3/raw/snapshots/reordered/source/traceability.arcadia": { + "bytes": 528, + "sha256": "c5eaf8dc3c09dc56129a73da9630bb3fda99a0cfc9a8fa266e3a6ee32f53deb6" + }, + "runs/run-3/raw/snapshots/reordered/started.json": { + "bytes": 3072, + "sha256": "2f61098aa6537580f66687ccda462e578baa7e8c49ac5146ee5f7f93db5a2b6c" + }, + "runs/run-3/raw/snapshots/reordered/stderr.log": { + "bytes": 0, + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "runs/run-3/raw/snapshots/reordered/stdout.log": { + "bytes": 428, + "sha256": "c0534dbd6525c5165a54b72387d89eb798da7cd023a027c2112bf14e2d70d0cf" + }, + "runs/run-3/raw/snapshots/undo/result.json": { + "bytes": 3485, + "sha256": "7f092ca7549d5e9601bc2b3fce3ee1419ca8aad4091d874379b48588bbd795c2" + }, + "runs/run-3/raw/snapshots/undo/source/oa/activities/activity_oa_downstream_work.arcadia": { + "bytes": 171, + "sha256": "7ebf9516f9f016139a4504649cd41e327b59b6798581bf9f65af2d30cfcd5a0e" + }, + "runs/run-3/raw/snapshots/undo/source/oa/activities/activity_oa_shared_work.arcadia": { + "bytes": 159, + "sha256": "56139bf120e41b752d50aa85955b96eddc0c7e573fe493248deed1b49933a95d" + }, + "runs/run-3/raw/snapshots/undo/source/oa/behavior/process_oa_process.arcadia": { + "bytes": 1737, + "sha256": "2f39cdf59c59bf56099ce5f07e339fa3f4fd0c8e964545f19c995a2341aba2e0" + }, + "runs/run-3/raw/snapshots/undo/source/oa/behavior/process_reusable.arcadia": { + "bytes": 429, + "sha256": "21d216c39d0990a3ebc2d40b02d00c1cc126185def1fb5342c3205ca890ae04a" + }, + "runs/run-3/raw/snapshots/undo/source/oa/behavior/process_reuse_trial.arcadia": { + "bytes": 730, + "sha256": "2384e5b81dc64ec229a0b1664333ee48a8a251b7ac8138584215a8611472fe2a" + }, + "runs/run-3/raw/snapshots/undo/source/project.arcadia": { + "bytes": 753, + "sha256": "a942705a988f5bfa35f2bdae273bce3fc6dd9afff8267a96483df598dd5a13a6" + }, + "runs/run-3/raw/snapshots/undo/source/sa/behavior/process_sa_chain.arcadia": { + "bytes": 1734, + "sha256": "b6aa1d90a113dacffdbc980f1fa6c6b2b85050921622c57492c80a1602e11a16" + }, + "runs/run-3/raw/snapshots/undo/source/sa/functions/function_sa_downstream_work.arcadia": { + "bytes": 171, + "sha256": "95ee9f35a41a4580c51bbf56b1b00717969adb77a0c05a1f30740c4c23788a02" + }, + "runs/run-3/raw/snapshots/undo/source/sa/functions/function_sa_shared_work.arcadia": { + "bytes": 159, + "sha256": "0117bcfe32321b63d6b05b745f67aa57a565026eed68eb5a98300b7265941e9a" + }, + "runs/run-3/raw/snapshots/undo/source/traceability.arcadia": { + "bytes": 528, + "sha256": "c5eaf8dc3c09dc56129a73da9630bb3fda99a0cfc9a8fa266e3a6ee32f53deb6" + }, + "runs/run-3/raw/snapshots/undo/started.json": { + "bytes": 3067, + "sha256": "b7e634bb8f699f0fd1dee1ce981da17e47e2baa5d4f411b9ce88dddec62c4212" + }, + "runs/run-3/raw/snapshots/undo/stderr.log": { + "bytes": 0, + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "runs/run-3/raw/snapshots/undo/stdout.log": { + "bytes": 423, + "sha256": "69b8cf621ffaf1787f5c0538e2953eb31f5297fa7d97fcfcbf21f0f41a4cf85d" + }, + "runs/run-3/raw/started.json": { + "bytes": 1738, + "sha256": "510d40f4873b62ff2e2e706ba04dc20e6eba945e1087252fd6d98b8e9bf0f3f7" + }, + "runs/run-3/raw/working/oa/activities/activity_oa_downstream_work.arcadia": { + "bytes": 171, + "sha256": "7ebf9516f9f016139a4504649cd41e327b59b6798581bf9f65af2d30cfcd5a0e" + }, + "runs/run-3/raw/working/oa/activities/activity_oa_shared_work.arcadia": { + "bytes": 159, + "sha256": "56139bf120e41b752d50aa85955b96eddc0c7e573fe493248deed1b49933a95d" + }, + "runs/run-3/raw/working/oa/behavior/process_oa_process.arcadia": { + "bytes": 1737, + "sha256": "3aa5cbb23ecc74bec7f31a0d7f7270b1f49b3444d3f35cf4baacaebfa6597999" + }, + "runs/run-3/raw/working/oa/behavior/process_reusable.arcadia": { + "bytes": 429, + "sha256": "21d216c39d0990a3ebc2d40b02d00c1cc126185def1fb5342c3205ca890ae04a" + }, + "runs/run-3/raw/working/oa/behavior/process_reuse_trial.arcadia": { + "bytes": 730, + "sha256": "2384e5b81dc64ec229a0b1664333ee48a8a251b7ac8138584215a8611472fe2a" + }, + "runs/run-3/raw/working/project.arcadia": { + "bytes": 753, + "sha256": "a942705a988f5bfa35f2bdae273bce3fc6dd9afff8267a96483df598dd5a13a6" + }, + "runs/run-3/raw/working/sa/behavior/process_sa_chain.arcadia": { + "bytes": 1734, + "sha256": "b6aa1d90a113dacffdbc980f1fa6c6b2b85050921622c57492c80a1602e11a16" + }, + "runs/run-3/raw/working/sa/functions/function_sa_downstream_work.arcadia": { + "bytes": 171, + "sha256": "95ee9f35a41a4580c51bbf56b1b00717969adb77a0c05a1f30740c4c23788a02" + }, + "runs/run-3/raw/working/sa/functions/function_sa_shared_work.arcadia": { + "bytes": 159, + "sha256": "0117bcfe32321b63d6b05b745f67aa57a565026eed68eb5a98300b7265941e9a" + }, + "runs/run-3/raw/working/traceability.arcadia": { + "bytes": 528, + "sha256": "c5eaf8dc3c09dc56129a73da9630bb3fda99a0cfc9a8fa266e3a6ee32f53deb6" + } + } +} diff --git a/harness/multiselect-process-evidence-20260923/native/prepare.py b/harness/multiselect-process-evidence-20260923/native/prepare.py new file mode 100644 index 00000000..a7eee20c --- /dev/null +++ b/harness/multiselect-process-evidence-20260923/native/prepare.py @@ -0,0 +1,115 @@ +"""Package explicitly named closed Process native runs; never execute archived helpers.""" +from pathlib import Path +import argparse +import io +import json +import re +import zipfile +from verify import pin, parsed, need, norm, relative, safe, zip_inventory, verify_data, verify_package + +HERE = Path(__file__).resolve().parent +FAILED_RUN_ONE = "Observed Back restored qualified highlight with outer-reference editable Properties; reorder and Undo/Redo/reopen history were not performed. Failed native workflow, despite three positive unchanged Project reports." + + +def collect(root, runs, extra_helpers=()): + native = root / ".local/process-multiselect-native" + candidates = [p for p in native.iterdir() if p.is_file()] + candidates += sorted((root / ".local/process-group-gates").glob("*.py")) + candidates += list(extra_helpers) + helpers = [(p.suffix, p.read_bytes()) for p 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 + return name + def helper(expected): + matches = [(suffix, data) for suffix, data in helpers if pin(data) == expected] + need(matches, "exact historical helper unavailable: " + str(expected)) + suffix, data = matches[0] + return add("helpers/" + expected["sha256"] + suffix, data) + def tree(folder, prefix): + need(folder.is_dir(), "missing directory: " + str(folder)) + for path in sorted(folder.rglob("*")): + need(not path.is_symlink() and not getattr(path, "is_junction", lambda: False)(), "linked evidence input") + if path.is_file(): + add(prefix + path.relative_to(folder).as_posix(), path.read_bytes()) + for run in runs: + folder = native / run + need((folder / "finished.json").is_file(), "open/unlaunched run refused: " + run) + prepared = parsed((folder / "prepared.json").read_bytes()) + tree(folder, "runs/" + run + "/raw/") + spec = {"originalRun": norm(prepared["cwd"]) + "/.local/process-multiselect-native/" + run, + "reviewStatus": "failed" if run == "run-1" else "unreviewed", + "reviewNote": FAILED_RUN_ONE if run == "run-1" else "Packaging consistency does not decide native acceptance. Consult the independent parent review.", + "helpers": {name: helper(expected) for name, expected in prepared["helpers"].items()}, + "executedCapture": pin((folder / "capture-executed.js").read_bytes()), "builds": {}} + for kind in ("gui", "checker"): + recorded = prepared[kind] + rel = relative(recorded["receipt"], prepared["cwd"]) + need(rel.startswith(".local/process-group-gates/") and rel.endswith("/result.json"), "unexpected receipt location") + receipt = root / rel + result_data = receipt.read_bytes() + need(pin(result_data) == recorded["receiptPin"], "historical build receipt changed") + result = parsed(result_data) + prefix = "builds/" + pin(result_data)["sha256"] + "/" + build = {"originalRelative": rel, "helper": helper(result["helper"])} + for name in ("started.json", "process.json", "result.json", "stdout.log", "stderr.log", "inputs.json", "inputs.zip"): + data = (receipt.parent / name).read_bytes() + # Deduplicate source capsules independently of differing build receipts. + member = ("inputs/" + pin(data)["sha256"] + Path(name).suffix) if name.startswith("inputs.") else prefix + name + build[name] = add(member, data) + spec["builds"][kind] = build + if kind == "checker": + capsule = payload[build["inputs.zip"]] + need(pin(capsule) == result["archive"], "historical checker capsule pin") + inputs = parsed(payload[build["inputs.json"]]) + with zipfile.ZipFile(io.BytesIO(capsule)) as archive: + zip_inventory(archive, inputs) + source = archive.read("crates/oa-core/examples/process_multiselect_native.rs") + spec["checkerSource"] = add("checker-source/" + pin(source)["sha256"] + ".rs", source) + specs[run] = spec + manifest = {"schema": 1, "kind": "process-multiselect-native", "runs": specs, + "members": {name: pin(data) for name, data in sorted(payload.items())}} + return payload, manifest, verify_data(manifest, payload.__getitem__) + + +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, required=True) + parser.add_argument("--runs", nargs="+", required=True) + parser.add_argument("--output", type=Path) + parser.add_argument("--check-only", action="store_true") + parser.add_argument("--helper", type=Path, action="append", default=[], help="Exact historical helper bytes if current helper changed") + 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") + need(args.check_only or args.output is not None, "--output or --check-only required") + if args.output is not None: + need(not args.output.exists(), "fresh nonexistent output directory required") + payload, manifest, summary = collect(args.root.resolve(), args.runs, args.helper) + if args.check_only: + print("PASS raw consistency; no archive written:", summary) + return + out = args.output.resolve() + out.mkdir(parents=True, exist_ok=False) + for name in ("prepare.py", "verify.py", "README.md"): + with (out / name).open("xb") as stream: + stream.write((HERE / name).read_bytes()) + 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) + write_json(out / "files.json", {name: pin((out / name).read_bytes()) for name in ("prepare.py", "verify.py", "README.md", "manifest.json", "evidence.zip")}) + need(verify_package(out) == summary, "written package differs from raw evidence") + print("PASS package readback:", out, summary) + + +if __name__ == "__main__": + main() diff --git a/harness/multiselect-process-evidence-20260923/native/verify.py b/harness/multiselect-process-evidence-20260923/native/verify.py new file mode 100644 index 00000000..752bdf49 --- /dev/null +++ b/harness/multiselect-process-evidence-20260923/native/verify.py @@ -0,0 +1,229 @@ +"""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 io +import re +import stat +import zipfile + +BASE = {"prepared.json", "started.json", "finished.json", "generation.json", "generation.stdout.log", + "generation.stderr.log", "app.stdout.log", "app.stderr.log", "capture-executed.js"} +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"] == "process-multiselect-native", "manifest kind") + members, runs = manifest["members"], manifest["runs"] + need(bool(runs) and all(re.fullmatch(r"run-[0-9]+", 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, summaries = 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, "pinned tree: " + prefix) + return actual + for run, spec in runs.items(): + prefix = "runs/" + run + "/raw/" + prepared, start, end, generation = [obj(prefix + n + ".json") for n in ("prepared", "started", "finished", "generation")] + need(spec["reviewStatus"] in ("failed", "unreviewed") and isinstance(spec["reviewNote"], str) and spec["reviewNote"], "explicit non-acceptance status") + if run == "run-1": + need(spec["reviewStatus"] == "failed", "run-1 must retain observed native failure") + origin = norm(prepared["cwd"]) + "/.local/process-multiselect-native/" + run + need(spec["originalRun"] == origin and prepared["run"] == run and start["cwd"] == prepared["cwd"], "original run mapping") + need(start["prepared"] == pin(raw(prefix + "prepared.json")), "launch preparation pin") + need(ordered(generation["start"], generation["finish"], prepared["prepared"], start["start"], end["finish"]), "native lifetime") + need(type(start["pid"]) is int and start["pid"] > 0 and type(end["exitCode"]) is int, "closed native process") + for key in ("guiUnchanged", "checkerUnchanged", "fixtureUnchanged"): + need(type(end[key]) is bool, "closure flag") + need(start["gui"] == prepared["gui"] and start["checker"] == prepared["checker"] == generation["checker"], "binary identity") + need(start["command"] == [prepared["gui"]["binary"]], "GUI command") + need(generation["exitCode"] == 0 and generation["command"][:2] == [prepared["checker"]["binary"], "generate"] and len(generation["command"]) == 3 and norm(generation["command"][2]) == origin + "/fixture", "generation command") + for stream in ("stdout", "stderr"): + need(end[stream] == pin(raw(prefix + "app." + stream + ".log")), "GUI log pin") + need(generation[stream] == pin(raw(prefix + "generation." + stream + ".log")), "generation log pin") + need(set(spec["helpers"]) == set(prepared["helpers"]), "exact prepared helper mapping") + for name, expected in prepared["helpers"].items(): + need(safe(name) and "/" not in name and pin(raw(spec["helpers"][name])) == expected, "historical helper: " + name) + need(start["helper"] == prepared["helpers"]["native.py"], "launch helper") + need(pin(raw(prefix + "capture-executed.js")) == spec["executedCapture"], "actual capture function pin") + fixture = pinned_tree(prefix + "fixture/", prepared["fixture"]) + fm = parsed(fixture["manifest.json"]) + need(fm["fixture"] == "process-multiselect-native-20260923" and fm["source_entry"] == prepared["entry"] and safe(prepared["entry"]), "fixture identity/entry") + need(parsed(fixture["baseline.json"])["id"] == fm["ids"]["project"], "complete Project baseline identity") + fixture_source = {name[7:]: data for name, data in fixture.items() if name.startswith("source/")} + need(prepared["entry"] in fixture_source and norm(prepared["workingEntry"]) == origin + "/working/" + prepared["entry"], "working entry mapping") + working = tree(prefix + "working/") + need(bool(working) and all(name.endswith(".arcadia") for name in working), "saved working tree") + build_maps = {} + for kind, target in (("gui", "arcadia-studio"), ("checker", "process_multiselect_native")): + recorded, b = prepared[kind], spec["builds"][kind] + result, began, process = [obj(b[n]) for n in ("result.json", "started.json", "process.json")] + need(pin(raw(b["result.json"])) == recorded["receiptPin"] and relative(recorded["receipt"], prepared["cwd"]) == b["originalRelative"], "build receipt mapping") + need(all(result.get(k) == v for k, v in began.items()) and process["command"] == result["command"] and type(process["pid"]) is int and process["pid"] > 0, "build start/result/process") + need(result["exitCode"] == 0 and result["inputsUnchanged"] is True and ordered(result["start"], process["observed"], result["finish"], generation["start"]), "successful preceding build") + for stream in ("stdout", "stderr"): + need(pin(raw(b[stream + ".log"])) == result[stream], "build log pin") + need(pin(raw(b["helper"])) == result["helper"], "build helper pin") + need(pin(raw(b["inputs.json"])) == recorded["inputs"] == result["inputs"], "build source map pin") + inputs = obj(b["inputs.json"]) + need(len(inputs) == result["inputCount"] and all(safe(name) for name in inputs), "source map count/paths") + capsule = raw(b["inputs.zip"]) + need(pin(capsule) == result["archive"], "build source capsule pin") + with zipfile.ZipFile(io.BytesIO(capsule)) as archive: + zip_inventory(archive, inputs) + need(all(pin(archive.read(name)) == expected for name, expected in inputs.items()), "all captured source bytes") + if kind == "checker": + path = "crates/oa-core/examples/process_multiselect_native.rs" + need(raw(spec["checkerSource"]) == archive.read(path), "historical checker source") + artifacts = [row for row in result["artifacts"] if row["cargo"]["target"]["name"] == target] + need(len(artifacts) == 1 and artifacts[0]["readback"] == recorded["pin"] and artifacts[0]["cargo"]["executable"] == recorded["binary"] and artifacts[0]["cargo"]["profile"]["test"] is False, "native executable provenance") + stdout_rows = [parsed(line) for line in raw(b["stdout.log"]).splitlines() if line.strip().startswith(b"{")] + need(artifacts[0]["cargo"] in stdout_rows, "artifact appears in raw Cargo output") + build_maps[kind] = inputs + need(prepared["gui"]["inputs"] == prepared["checker"]["inputs"] and build_maps["gui"] == build_maps["checker"], "same GUI/checker source map") + top = {name[len(prefix):] for name in members if name.startswith(prefix) and "/" not in name[len(prefix):]} + base = BASE | (top & {"history-bytes.json", "observations.md"}) + if "observations.md" in top: + raw(prefix + "observations.md") + captures = sorted(name for name in top - base if name.endswith(".json")) + need(captures, "native captures required") + expected_top = base | set(captures) + for name in captures: + capture = obj(prefix + name) + need(ordered(start["start"], capture["time"], end["finish"]), "capture lifetime") + window = capture["window"] + need(norm(window["app"]) == "process:" + norm(prepared["gui"]["binary"]) and type(window["id"]) is int and window["id"] > 0, "captured executable/window") + need(isinstance(capture["accessibility"], dict) and isinstance(capture["images"], list), "accessibility/capture payload") + 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")) and filename not in expected_top, "unique image mapping") + need(all(type(image[k]) is int and image[k] > 0 for k in ("width", "height")), "image dimensions") + expected_top.add(filename) + data = raw(prefix + filename) + need(data.startswith(b"\xff\xd8") if filename.endswith(".jpg") else data.startswith(b"\x89PNG\r\n\x1a\n"), "image signature") + need(top == expected_top, "exact top-level run files") + labels = {name[len(prefix + "snapshots/"):].split("/")[0] for name in members if name.startswith(prefix + "snapshots/")} + states, results, positive = {}, {}, [] + for label in sorted(labels): + need(re.fullmatch(r"[a-z0-9-]+", label), "snapshot label") + folder = prefix + "snapshots/" + label + "/" + began, result = obj(folder + "started.json"), obj(folder + "result.json") + need(all(result.get(k) == v for k, v in began.items()), "snapshot start/result") + need(result["prepared"] == start["prepared"] and result["checker"] == prepared["checker"] and result["helper"] == prepared["helpers"]["snapshot.py"], "snapshot provenance") + need(ordered(start["start"], result["start"], result["finish"], end["finish"]), "snapshot lifetime") + mode, command = result["mode"], result["command"] + need(mode in ("unchanged", "reordered") and len(command) == 5 and command[:3] == [prepared["checker"]["binary"], "check", mode] and norm(command[3]) == origin + "/fixture/baseline.json" and norm(command[4]) == origin + "/snapshots/" + label + "/source/" + prepared["entry"], "snapshot command") + need(result["baseline"] == pin(fixture["baseline.json"]), "snapshot baseline") + for stream in ("stdout", "stderr"): + need(result[stream] == pin(raw(folder + stream + ".log")), "snapshot log pin") + need(type(result["exitCode"]) is int and all(type(result[k]) is bool for k in ("binaryUnchanged", "workingSourceUnchanged", "snapshotUnchanged", "baselineUnchanged")), "snapshot closure") + states[label] = pinned_tree(folder + "source/", result["source"]) + need({name for name in members if name.startswith(folder)} == {folder + n for n in ("started.json", "result.json", "stdout.log", "stderr.log")} | {folder + "source/" + n for n in states[label]}, "exact snapshot files") + if result["exitCode"] == 0: + report = obj(folder + "stdout.log") + need(report["result"] == "PASS" and report["comparison"] == "complete Project equality; literal expected order" and report["mode"] == mode and report["baseline"] == command[3] and report["saved"] == command[4], "positive complete Project report") + positive.append(label) + results[label] = result + history_present = "history-bytes.json" in top + if history_present: + history = obj(prefix + "history-bytes.json") + groups = history["equalByteGroups"] + need(len(groups) == 2 and groups[0][:2] == ["baseline", "undo"] and groups[1] == ["reordered", "redo", "reopened"], "literal Process history groups") + history_labels = [label for group in groups for label in group] + need(len(history_labels) == len(set(history_labels)) and set(history_labels) == set(history["snapshots"]) and set(history_labels) <= set(positive), "history labels/positive reports") + need(history["status"] == "PASS" and history["prepared"] == start["prepared"] and history["helper"] == prepared["helpers"]["history-bytes.py"] and ordered(end["finish"], history["verified"]), "history provenance") + need(end["exitCode"] == 0 and all(end[k] is True for k in ("guiUnchanged", "checkerUnchanged", "fixtureUnchanged")), "history clean closure") + for group, mode in zip(groups, ("unchanged", "reordered")): + for label in group: + need(states[label] == states[group[0]] and results[label]["mode"] == mode and history["snapshots"][label] == pin(raw(prefix + "snapshots/" + label + "/result.json")), "literal history bytes/receipt") + need(all(results[label][k] is True for k in ("binaryUnchanged", "workingSourceUnchanged", "snapshotUnchanged", "baselineUnchanged")), "stable history snapshot") + need(len(states[label]) == history["sourceFilesPerSnapshot"], "history source count") + need(states["baseline"] == fixture_source and states["baseline"] != states["reordered"], "baseline exact and reorder changed") + changed = sorted(name for name in set(states["baseline"]) | set(states["reordered"]) if states["baseline"].get(name) != states["reordered"].get(name)) + need(changed == history["changedFilesFromBaseline"] == ["oa/behavior/process_oa_process.arcadia"], "only intended OA source changed") + sequence = [results[label] for label in ("baseline", "reordered", "undo", "redo", "reopened")] + need(all(ordered(a["finish"], b["start"]) for a, b in zip(sequence, sequence[1:])), "history chronology") + summaries[run] = {"reviewStatus": spec["reviewStatus"], "exitCode": end["exitCode"], "captures": len(captures), "snapshots": len(labels), "positiveReports": len(positive), "historyBytes": history_present} + need(used == set(members), "unreferenced package members: " + repr(set(members) - used)) + return summaries + + +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) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("package", nargs="?", type=Path, default=Path(__file__).resolve().parent) + args = parser.parse_args() + print("PASS byte/receipt consistency only:", verify_package(args.package)) + print("No GUI/checker execution or native acceptance claim; see independent parent review.") diff --git a/harness/multiselect-process-evidence-20260923/preparation-run-2/.gitattributes b/harness/multiselect-process-evidence-20260923/preparation-run-2/.gitattributes new file mode 100644 index 00000000..fa1385d9 --- /dev/null +++ b/harness/multiselect-process-evidence-20260923/preparation-run-2/.gitattributes @@ -0,0 +1 @@ +* -text diff --git a/harness/multiselect-process-evidence-20260923/preparation-run-2/README.md b/harness/multiselect-process-evidence-20260923/preparation-run-2/README.md new file mode 100644 index 00000000..ca8fd614 --- /dev/null +++ b/harness/multiselect-process-evidence-20260923/preparation-run-2/README.md @@ -0,0 +1,31 @@ +# Process run-2 preparation failure + +These are the exact three files retained from +`.local/process-multiselect-native/run-2`: generation.json and its empty stdout +and stderr logs. Fixture generation exited **3221225781 (0xC0000135)** before +preparation completed. There was no GUI launch, prepared.json, started.json or +finished.json. This is a failed preparation receipt, not a native run or a +failed complete-Project comparison. + +The checker came from `native-build-2`, source commit +`b743a10d51032dc1655b94fed5e49fbbbcee83f4`. The parent diagnosed the omitted +toolchain runtime environment in its preparation shell; Windows reported the +missing-DLL status. The parent subsequently retried with that environment in +run-3 successfully. That diagnosis and retry outcome are parent observations; +the raw run-2 receipt records the exit code, executable/build pins and empty +streams, and does not record its environment or name a missing DLL. Run-3's +separate native evidence owns its acceptance claims. + +Verify with Python's standard library, using the portable sibling checks: + +```text +python -B verify.py --checks ../checks +``` + +The verifier checks exact raw/outer bytes, failed generation command and +chronology, empty stream pins, and the checker's precise build receipt, input +map and Cargo executable/readback relationship. It also checks the shared +source map against the sibling source proof for b743a10. Run the checks and +source verifiers separately for their complete gate/source verification. +No binary, capture helper or GUI is executed. Pins establish consistency, +not independent authentication or a reproduction of the environment failure. diff --git a/harness/multiselect-process-evidence-20260923/preparation-run-2/files.json b/harness/multiselect-process-evidence-20260923/preparation-run-2/files.json new file mode 100644 index 00000000..433b73d7 --- /dev/null +++ b/harness/multiselect-process-evidence-20260923/preparation-run-2/files.json @@ -0,0 +1,26 @@ +{ + ".gitattributes": { + "bytes": 8, + "sha256": "705fd4d6451a31d36b3df7de96f83f30ac976c9b4a6d1e51671d8e2f33e2d0da" + }, + "generation.json": { + "bytes": 1251, + "sha256": "a1b145327abcc8fe11a1871e1895dba4a58366e59edd7e21eda7dd971bb6106a" + }, + "generation.stderr.log": { + "bytes": 0, + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "generation.stdout.log": { + "bytes": 0, + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "README.md": { + "bytes": 1654, + "sha256": "774ad0e899198ab177b52e1f231d05f0b0cf953c8d8f08388b0ce3ab113e9f9c" + }, + "verify.py": { + "bytes": 6029, + "sha256": "93824a57652fad13cf780837dc19f3db0913d67893ade6ae4bc26a3569667a63" + } +} diff --git a/harness/multiselect-process-evidence-20260923/preparation-run-2/generation.json b/harness/multiselect-process-evidence-20260923/preparation-run-2/generation.json new file mode 100644 index 00000000..27badfa9 --- /dev/null +++ b/harness/multiselect-process-evidence-20260923/preparation-run-2/generation.json @@ -0,0 +1,34 @@ +{ + "start": "2026-09-23T13:29:37.155542+00:00", + "finish": "2026-09-23T13:29:37.176859+00:00", + "command": [ + "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\examples\\process_multiselect_native.exe", + "generate", + "C:\\ar-multiselect-9e8r6t\\.local\\process-multiselect-native\\run-2\\fixture" + ], + "exitCode": 3221225781, + "checker": { + "receipt": "C:\\ar-multiselect-9e8r6t\\.local\\process-group-gates\\native-build-2\\result.json", + "receiptPin": { + "bytes": 3322, + "sha256": "1df7538acda0f38b36f91d0293a7f74a5f4d308797f35b1d4dabaa7155aae23b" + }, + "binary": "C:\\Users\\User\\Documents\\Git\\ARCADIA-Studio\\.local\\target-gnullvm\\debug\\examples\\process_multiselect_native.exe", + "pin": { + "bytes": 22190080, + "sha256": "1cbafb797c9451f908d7567048ace474511a76d98129dabc635e4ee6f976367c" + }, + "inputs": { + "bytes": 124276, + "sha256": "f3fd180af294e0797f9dfdbff647274de30a907b685a4fe1a2b58027bfe7da95" + } + }, + "stdout": { + "bytes": 0, + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "stderr": { + "bytes": 0, + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } +} diff --git a/harness/multiselect-process-evidence-20260923/preparation-run-2/generation.stderr.log b/harness/multiselect-process-evidence-20260923/preparation-run-2/generation.stderr.log new file mode 100644 index 00000000..e69de29b diff --git a/harness/multiselect-process-evidence-20260923/preparation-run-2/generation.stdout.log b/harness/multiselect-process-evidence-20260923/preparation-run-2/generation.stdout.log new file mode 100644 index 00000000..e69de29b diff --git a/harness/multiselect-process-evidence-20260923/preparation-run-2/verify.py b/harness/multiselect-process-evidence-20260923/preparation-run-2/verify.py new file mode 100644 index 00000000..19f77dc9 --- /dev/null +++ b/harness/multiselect-process-evidence-20260923/preparation-run-2/verify.py @@ -0,0 +1,97 @@ +"""Verify a preserved failed preparation; never run its executable or helpers.""" +from pathlib import Path, PurePosixPath +from datetime import datetime +import argparse +import hashlib +import json +import stat +import zipfile + +HERE = Path(__file__).resolve().parent +COMMIT = "b743a10d51032dc1655b94fed5e49fbbbcee83f4" +FILES = {".gitattributes", "README.md", "verify.py", "generation.json", "generation.stdout.log", "generation.stderr.log"} + + +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") + 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 PurePosixPath(name).is_absolute() and all(c not in name for c in ("\\", ":")) and all(ord(c) >= 32 for c in name) and all(p not in ("", ".", "..") for p in name.split("/")) + + +def verify(root, checks, source): + pins = parsed((root / "files.json").read_bytes()) + need(set(pins) == FILES and {p.name for p in root.iterdir()} == FILES | {"files.json"}, "exact failed-preparation files") + for name, expected in pins.items(): + need((root / name).is_file() and not (root / name).is_symlink() and pin((root / name).read_bytes()) == expected, "preserved pin: " + name) + generation = parsed((root / "generation.json").read_bytes()) + need(generation["exitCode"] == 3221225781, "original missing-DLL status") + for stream in ("stdout", "stderr"): + data = (root / ("generation." + stream + ".log")).read_bytes() + need(not data and pin(data) == generation[stream], "original empty generation stream") + recorded = generation["checker"] + outer = parsed((checks / "files.json").read_bytes()) + need(set(outer) == {"README.md", "prepare.py", "verify.py", "manifest.json", "evidence.zip"}, "checks package inventory") + for name, expected in outer.items(): + need(pin((checks / name).read_bytes()) == expected, "checks outer pin") + manifest = parsed((checks / "manifest.json").read_bytes()) + need(manifest["kind"] == "multiselect-cargo-gates", "checks kind") + with zipfile.ZipFile(checks / "evidence.zip") as archive: + need(len(archive.namelist()) == len(set(archive.namelist())) and set(archive.namelist()) == set(manifest["members"]), "checks archive inventory") + for row in archive.infolist(): + need(safe(row.filename) and not row.is_dir() and not stat.S_ISLNK(row.external_attr >> 16), "unsafe checks archive member") + def raw(name): + member = manifest["logicalFiles"]["cargo/native-build-2/" + name] + data = archive.read(member) + need(pin(data) == manifest["members"][member], "checks member pin") + return data + result_bytes, input_bytes = raw("result.json"), raw("inputs.json") + result = parsed(result_bytes) + need(recorded["receiptPin"] == pin(result_bytes), "exact checker build receipt") + need(recorded["inputs"] == result["inputs"] == pin(input_bytes), "same exact checker input map") + need(len(parsed(input_bytes)) == result["inputCount"] == 742, "complete Process source count") + need(result["head"] == COMMIT and result["exitCode"] == 0 and result["inputsUnchanged"] is True, "successful b743 checker build") + need(recorded["receipt"].replace("\\", "/") == result["cwd"].replace("\\", "/") + "/.local/process-group-gates/native-build-2/result.json", "original receipt path") + rows = [row for row in result["artifacts"] if row["cargo"]["target"]["name"] == "process_multiselect_native"] + need(len(rows) == 1 and rows[0]["readback"] == recorded["pin"] and rows[0]["cargo"]["executable"] == recorded["binary"] and rows[0]["cargo"]["profile"]["test"] is False, "exact native checker artifact") + stdout = raw("stdout.log") + need(pin(stdout) == result["stdout"], "checker build log pin") + cargo_rows = [parsed(line) for line in stdout.splitlines() if line.startswith(b"{")] + need(rows[0]["cargo"] in cargo_rows, "actual Cargo output artifact") + command = generation["command"] + need(len(command) == 3 and command[:2] == [recorded["binary"], "generate"] and command[2].replace("\\", "/") == result["cwd"].replace("\\", "/") + "/.local/process-multiselect-native/run-2/fixture", "exact failed generation command") + times = [datetime.fromisoformat(t) for t in (result["start"], result["finish"], generation["start"], generation["finish"])] + need(all(t.utcoffset() is not None for t in times) and times == sorted(times), "build then failed generation chronology") + source_pins = parsed((source / "files.json").read_bytes()) + for name in ("manifest.json", "source-core-3.json"): + need(pin((source / name).read_bytes()) == source_pins[name], "source proof pin") + spec = parsed((source / "manifest.json").read_bytes())["runs"]["core-3"] + proof_bytes = (source / "source-core-3.json").read_bytes() + proof = parsed(proof_bytes) + need(spec["receiptPin"] == pin(proof_bytes) and spec["commit"] == proof["commit"] == COMMIT and spec["inputMap"] == proof["inputMap"] == recorded["inputs"], "same b743 source proof") + return {"preparationExit": generation["exitCode"], "hexExit": hex(generation["exitCode"]), "checkerBuild": "native-build-2", "sourceCommit": COMMIT, "guiLaunch": False} + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("package", nargs="?", type=Path, default=HERE) + parser.add_argument("--checks", type=Path) + parser.add_argument("--source", type=Path) + args = parser.parse_args() + print("PASS preserved failed-preparation consistency:", verify(args.package, args.checks or args.package.parent / "checks", args.source or args.package.parent / "source")) diff --git a/harness/multiselect-process-evidence-20260923/reviews/.gitattributes b/harness/multiselect-process-evidence-20260923/reviews/.gitattributes new file mode 100644 index 00000000..fa1385d9 --- /dev/null +++ b/harness/multiselect-process-evidence-20260923/reviews/.gitattributes @@ -0,0 +1 @@ +* -text diff --git a/harness/multiselect-process-evidence-20260923/reviews/README.md b/harness/multiselect-process-evidence-20260923/reviews/README.md new file mode 100644 index 00000000..4e20e0fb --- /dev/null +++ b/harness/multiselect-process-evidence-20260923/reviews/README.md @@ -0,0 +1,15 @@ +# Historical Process return/navigation reviews + +The two Markdown reports are byte-for-byte copies of the independent delegated +reviews `.local/process-return-review.md` and `.local/process-return-review-2.md`, +written on 2026-09-23 against successive working diffs over b153451. They record +static source inspection, findings, correction assessment and remaining test +gaps at those checkpoints. They did not execute Cargo or native UI and are not +acceptance receipts for the final source. + +Review 1 identified the invalid nonprimary menu test setup and retained qualified +identity during explicit local navigation. Review 2 assessed the broader fix +and distinguished protective test gaps from demonstrated defects. Preserve +their historical wording; subsequent gate/source/native evidence establishes +what was tested later. files.json pins both exact copied reports and this +provenance note. The [native run-3 review](process-native-review-3.md) is a portable copy with only archive-link formatting adapted; the original review hash is recorded in it. [Parent observations](run-3-observations.md) describe the actual input sequence. The four browseable screenshots under `images/` are exact copies of their native archive members. The review identifies a remaining routing readability defect, not a model-corruption result. diff --git a/harness/multiselect-process-evidence-20260923/reviews/files.json b/harness/multiselect-process-evidence-20260923/reviews/files.json new file mode 100644 index 00000000..95e4521a --- /dev/null +++ b/harness/multiselect-process-evidence-20260923/reviews/files.json @@ -0,0 +1,42 @@ +{ + ".gitattributes": { + "bytes": 8, + "sha256": "705fd4d6451a31d36b3df7de96f83f30ac976c9b4a6d1e51671d8e2f33e2d0da" + }, + "images/run-1/26-back-to-caller-0.jpg": { + "bytes": 166409, + "sha256": "a74368cc8ce4ad3ffb397b8ad3fc88349220635320bfe7458f29a980e033dcfe" + }, + "images/run-3/16-exact-qualified-back-0.jpg": { + "bytes": 129288, + "sha256": "dd32a1faceb0dcda161fce7b614ed554ad97d85471b01f09faeea319255bb7fc" + }, + "images/run-3/36-group-primary-c-0.jpg": { + "bytes": 137655, + "sha256": "8ba0c813893e00371ac34af5231b48e82f5b25b60c5797f4e618df7a1dab462f" + }, + "images/run-3/39-group-reordered-0.jpg": { + "bytes": 139094, + "sha256": "3f967cd523085ba01607ec33ff30e908480ed1847e2688b2b28b896f43d5c0c9" + }, + "process-native-review-3.md": { + "bytes": 7040, + "sha256": "dacf078c1f3e9e669fd93bb13949851e7e2c54a9ff3e66a559c34308646af86c" + }, + "process-return-review-2.md": { + "bytes": 4489, + "sha256": "65dfd4dd26c492fc32c9c8abecea299648c560eb69ff4f73438f77302e63c4b8" + }, + "process-return-review.md": { + "bytes": 3903, + "sha256": "a2da6b641e594438b94194664a3d44ef1d07c28d080d25fc4252d45d199e5dfe" + }, + "README.md": { + "bytes": 1324, + "sha256": "ba2beda41d8e3af88e2830280364dc4791f48e159f445c99716cc2e2d32a5b76" + }, + "run-3-observations.md": { + "bytes": 5124, + "sha256": "d5bccef8f0492e180aaa71d0d470c461d571c6ce3ad2c2fbd43c63762af43cac" + } +} diff --git a/harness/multiselect-process-evidence-20260923/reviews/images/run-1/26-back-to-caller-0.jpg b/harness/multiselect-process-evidence-20260923/reviews/images/run-1/26-back-to-caller-0.jpg new file mode 100644 index 00000000..fca7a491 Binary files /dev/null and b/harness/multiselect-process-evidence-20260923/reviews/images/run-1/26-back-to-caller-0.jpg differ diff --git a/harness/multiselect-process-evidence-20260923/reviews/images/run-3/16-exact-qualified-back-0.jpg b/harness/multiselect-process-evidence-20260923/reviews/images/run-3/16-exact-qualified-back-0.jpg new file mode 100644 index 00000000..eddf2f83 Binary files /dev/null and b/harness/multiselect-process-evidence-20260923/reviews/images/run-3/16-exact-qualified-back-0.jpg differ diff --git a/harness/multiselect-process-evidence-20260923/reviews/images/run-3/36-group-primary-c-0.jpg b/harness/multiselect-process-evidence-20260923/reviews/images/run-3/36-group-primary-c-0.jpg new file mode 100644 index 00000000..32caa791 Binary files /dev/null and b/harness/multiselect-process-evidence-20260923/reviews/images/run-3/36-group-primary-c-0.jpg differ diff --git a/harness/multiselect-process-evidence-20260923/reviews/images/run-3/39-group-reordered-0.jpg b/harness/multiselect-process-evidence-20260923/reviews/images/run-3/39-group-reordered-0.jpg new file mode 100644 index 00000000..5bd02c3b Binary files /dev/null and b/harness/multiselect-process-evidence-20260923/reviews/images/run-3/39-group-reordered-0.jpg differ diff --git a/harness/multiselect-process-evidence-20260923/reviews/process-native-review-3.md b/harness/multiselect-process-evidence-20260923/reviews/process-native-review-3.md new file mode 100644 index 00000000..f28bced6 --- /dev/null +++ b/harness/multiselect-process-evidence-20260923/reviews/process-native-review-3.md @@ -0,0 +1,39 @@ +# Independent Process native review — run 3 + +Reviewed on 2026-09-23, read-only. Directly inspected run-3 images 12–23, 27–44 and 49–52, and run-1 frame 26. No source edits, builds, UI actions, commits or publication. The native window captures are 1442×932 including Windows chrome, with Process zoom 100%; this review does not generalize to other display sizes. + +Capture paths below are members of [native/evidence.zip](../native/evidence.zip). Only link formatting was adapted for this portable copy; original review SHA-256: `bbe98aa59fa9000cb612843dd2a6f5e33692a19512de32cd0baff5ff059b3a69`. + +## Result + +**No new selection, primary, qualified-return, group-order or saved-state blocker found in the reviewed evidence.** The run-1 Inspector mismatch is visibly corrected. There remains a concrete routing readability problem after reorder, described separately below; do not call routing fully accepted. + +## Visible evidence + +- 12 first qualified (`runs/run-3/raw/12-first-qualified-0.jpg`) selects Shared detail inside First use. 13 nonprimary menu (`runs/run-3/raw/13-nonprimary-menu-0.jpg`) addresses Shared detail inside Second use while First use remains selected. 14 definition (`runs/run-3/raw/14-nonprimary-definition-0.jpg`) shows the local Shared detail editor with alias `reused_leaf`. +- 16 exact Back (`runs/run-3/raw/16-exact-qualified-back-0.jpg`) and 17 (`runs/run-3/raw/17-caller-tree-expanded-0.jpg`) return to the Second use's inner Shared detail tile, with the exact Second use path in Details, Shared detail as Properties subject and a read-only Inspector. The outer card is not simultaneously highlighted. In contrast, run-1 frame 26 (`runs/run-1/raw/26-back-to-caller-0.jpg`) shows both inner/outer outlines and the editable Second use Inspector despite qualified Details. +- 18 explicit outer (`runs/run-3/raw/18-explicit-outer-properties-0.jpg`) deliberately selects Second use: outline, Properties label and editable Inspector name/alias `reference_two` agree. 19–21 (`runs/run-3/raw/21-current-back-readonly-0.jpg`) show current-qualified Open and Back again returning to read-only Shared detail. 22 same-leaf Explorer navigation (`runs/run-3/raw/22-explorer-same-leaf-definition-0.jpg`) changes to Reusable request detail and restores the editable local `reused_leaf` Inspector. Card bounds/100% scale are stable across equivalent caller states; no visual camera jump is apparent. +- 29 mixed selection (`runs/run-3/raw/29-mixed-selected-0.jpg`) displays two selected, Card A as member and A→U link as primary, with the link Inspector. 30 refusal (`runs/run-3/raw/30-mixed-refused-0.jpg`) retains those identities/positions and presents the readable status: “Reorder requires local contribution cards only; links cannot move with them.” Saved frame 31 preserves that display. +- 32 C (`runs/run-3/raw/32-card-c-0.jpg`), 33 Add A (`runs/run-3/raw/33-card-a-menu-0.jpg`), 34 primary A (`runs/run-3/raw/34-group-primary-a-0.jpg`), 35 Inspect C (`runs/run-3/raw/35-group-menu-c-0.jpg`) and 36 primary C (`runs/run-3/raw/36-group-primary-c-0.jpg`) provide consistent membership/count and primary feedback. Inspect changes the primary to C without dropping A. The menu fits and its unavailable Hide/group-delete explanations are visible. +- 37 selected anchor (`runs/run-3/raw/37-selected-anchor-0.jpg`) / 38 saved (`runs/run-3/raw/38-anchor-saved-0.jpg`) retain A/B/C order and primary C. 39 reordered (`runs/run-3/raw/39-group-reordered-0.jpg`) / 40 saved (`runs/run-3/raw/40-reordered-saved-0.jpg`) show B/A/C, U/V still in their original right-column slots, count two and primary C. This is consistent with source-order A,C despite selection C then A. 41 Undo (`runs/run-3/raw/41-one-undo-0.jpg`) returns A/B/C; 43 Redo (`runs/run-3/raw/43-one-redo-0.jpg`) restores B/A/C; primary C/count stay visible. Actions themselves were observed by the parent, not by this reviewer. +- 49 reopen (`runs/run-3/raw/49-reopened-0.jpg`), 50 Behavior (`runs/run-3/raw/50-reopened-behavior-0.jpg`), 51 order (`runs/run-3/raw/51-reopened-order-0.jpg`) and 52 saved (`runs/run-3/raw/52-reopened-saved-0.jpg`) show the reopened project and B/A/C order with zero selected. The process Inspector agrees with the newly selected process. The Explorer tooltip temporarily covers part of Card A's title in 51/52; this is transient pointer feedback, not missing content. + +## Concrete remaining visual issue + +In reordered frames 39/40/43/44/51/52, the two orthogonal A→U and B→V routes overlap along their shared middle x-coordinate and horizontal segments. Portions of the central vertical path appear interrupted, and the resulting picture can be read as B→U and A→V without carefully tracing the bends. Card titles and primary outlines remain readable, but endpoint connectivity is not reliably legible from this drawing. This is consistent with the parent's existing routing issue #32; I have not independently established its source-level attribution. The complete-Project checker establishes unchanged link records separately, so this should not be reported as group reassignment or source corruption, nor hidden by a blanket “visual pass.” + +## Receipt cross-check, independent of screenshots + +Read history-bytes.json (`runs/run-3/raw/history-bytes.json`), all eight `snapshots/*/result.json` and checker stdout/stderr, plus the actual ten saved source files for each snapshot. Independently recomputed SHA-256/size pins and confirmed: + +- All eight pinned checker receipts/logs/source maps match actual bytes; each checker reports PASS for complete Project equality against literal expected order and exits zero with unchanged baseline/checker/snapshot flags. +- Baseline = undo = qualified-navigation = mixed-refusal = anchor-noop across all ten source files. +- Reordered = redo = reopened across all ten source files. +- Only `oa/behavior/process_oa_process.arcadia` differs between baseline and reordered. +- finished.json (`runs/run-3/raw/finished.json`) records normal exit 0 at `2026-09-23T13:43:54.209222+00:00`; both actual application logs are empty and match their recorded pins. GUI/checker/fixture unchanged flags are true. + +The launch receipt records matching GUI/checker input-map pin `f3fd180af294e0797f9dfdbff647274de30a907b685a4fe1a2b58027bfe7da95`; the parent identifies this run with `b743a10`. This review did not independently repeat commit-to-build source identity proof or rerun the checker executable. + +## Limits + +Screenshots establish visible states, not exact model equality, action counts or Undo stack depth. Those conclusions rely respectively on the checker/source readbacks and the parent's observed single Undo/Redo actions. Frames 37/39 are post-action states; they do not establish native mid-drag insertion-line/count feedback (separate WGPU evidence exists). No new evidence here for SA native interaction, keyboard Shift behavior, dirty/stale guards, Escape/out-and-back, other DPI, accessibility contrast, performance or routing correctness beyond the concrete issue above. diff --git a/harness/multiselect-process-evidence-20260923/reviews/process-return-review-2.md b/harness/multiselect-process-evidence-20260923/reviews/process-return-review-2.md new file mode 100644 index 00000000..2b2fa76a --- /dev/null +++ b/harness/multiselect-process-evidence-20260923/reviews/process-return-review-2.md @@ -0,0 +1,27 @@ +# Process return/navigation review 2 + +Read-only review on 2026-09-23 of the working diff over `b153451` in `app.rs`, `app/process_canvas.rs`, `app/process_multiselect.rs` and its tests. No source edits, Cargo, GUI, or native input. This is a correctness review, not runtime acceptance. + +## Finding + +**No concrete regression found in the reviewed fix.** The previous editable-outer-Inspector failure is addressed at its source: Back restores the validated address's actual occurrence, then reconciles exact selection and explicitly refreshes the primary Inspector. It no longer relies on ordinary object navigation or on matching the outer reference ID to an address. + +The new tests cover primary/nonprimary qualified Open and Back, exact invocation path, singleton returned selection, leaf Inspector read-only enforcement, camera/source/history stability, ordinary Select/EditProperties to both the outer reference and same-ID defining leaf, and local-reference Open after qualified inspection. These are assertions in reviewed source; I did not run them. + +## Interaction checks + +- `App::select` captures preview status before changing owner/selection. Same semantic ID is no longer an early-return when it denotes a qualified preview. Context/focus clearing occurs after `apply()` and existence admission. Existing cross-layer selection still sets layer/diagram, fits and updates the selected behavior owner through the unchanged path. +- `OpenQualified` records the requested resolved address before saving the frame, rather than relying on previous primary. Clearing the live address after saving leaves the frame's address intact. For same-ID current-preview Open, the outer update's existing `preview_read_only` refresh restores a definition draft even if `select` returns early after the owner has already changed. +- Local Open clears the old qualified address before `remember`, so ordinary Back restores a local occurrence rather than inheriting a prior preview. Its new test explicitly covers this. +- `State::back` validates a surviving return route/address and restores camera/expansion state. The App now selects its validated actual subject; `inspect_process_multiselect_primary` clears stale node/bend/attachment/navigation-focus state and restores qualified context only from the exact selected endpoint. +- Usage focus has precedence during reconciliation, but the normal update retires focus as soon as its surface/selected identity becomes invalid on definition navigation. The final inspect clears it again. I found no reachable stale caller-focus override in the reviewed Open→Back path. Existing preview-link usage tests exercise distinct invocation identities, though not this return sequence. +- The Back/Open `apply()` guard remains before frame/context mutation. This preserves the existing policy of applying valid dirty property drafts and refusing pending behavior/source or invalid property edits. It is not a new blanket prohibition on dirty property navigation. +- Qualified resolution enforces identical sequence kind and layer (`topology_context::resolve_path`), so successful qualified Back cannot require a cross-layer restoration that the new direct assignment forgot. Ordinary generic cross-layer navigation still goes through `select`. + +## Focused missing regression coverage + +The principal gap is **refused navigation at the newly sensitive same-ID boundary**. Add a test starting from a qualified preview with a dirty source draft: send `Select(leaf)` and `EditProperties(leaf)`, and assert exact owner, address, selection/primary, read-only draft, camera, source draft/cursor, and history preservation. Also open a definition, make its property draft invalid, attempt Back, then revert and Back again: the first attempt must leave the return frame available and the second must restore the exact invocation. The static guard ordering appears correct, so this is a missing protective test, not a demonstrated bug. + +A smaller extension is generic same-ID navigation from `ProcessPreviewLink`, preferably after a diagram-usage focus. The shared `process_preview_primary` path includes links, but the new generic navigation matrix tests only occurrences. Verify local defining-link selection and editable draft after navigation. Existing tests establish preview-link identity/readonly display, not this transition. + +Native pointer/menu/rebuild behavior, focused compile/test results, and final screenshot acceptance remain the parent's separate evidence. diff --git a/harness/multiselect-process-evidence-20260923/reviews/process-return-review.md b/harness/multiselect-process-evidence-20260923/reviews/process-return-review.md new file mode 100644 index 00000000..90f892b4 --- /dev/null +++ b/harness/multiselect-process-evidence-20260923/reviews/process-return-review.md @@ -0,0 +1,21 @@ +# Process qualified return review — 2026-09-23 + +Scope: static review of the two-file working diff over `b153451`, plus the existing return-frame, selection reconciliation and menu code. No production edits, Cargo, or GUI execution. The parent owns the running gate and native acceptance. + +## Findings + +1. **Concrete new test error:** `app/process_multiselect/tests.rs:676` creates a two-member group containing `second_target`, then in the nonprimary case requests its menu action “Open referenced definition.” `model_menu.rs:312` returns the selected-group menu before the qualified-open action is added. Therefore the `menu` helper cannot find that action and panics. Keep the invoked target unselected while a different qualified target is primary (optionally add a local card), or dispatch captured `OpenQualified` directly and retain a separate actual-menu case. Do not expose a new group menu action merely to satisfy this fixture. + +2. **Concrete adjacent identity mismatch remains in generic local navigation:** choose qualified `[R]/leaf`, then send `Message::Select(R)` from Explorer, or `EditProperties(R)`. `App::select` changes `selected` to R and builds its editable draft without clearing the qualified address. `reconcile_process_multiselect` accepts the address when `references.first() == selected`, so it retains the qualified leaf target. The result is the same class of disagreement: leaf highlight with editable R properties. This path predates the proposed Back fix; it is not a new regression caused by the two-file patch. Expected explicit local navigation is local R primary with its own editable draft and no retained qualified address. Add this minimal case before claiming that return/selection identity is generally reconciled. The outer-reference compatibility condition should be confined to return-frame reconstruction, not ordinary selection admission. + +## Product patch assessment + +- Seeding `resolved.address` immediately before `remember` in `app/process_canvas.rs:131` correctly records the requested qualified invocation when opening a nonprimary context target. The previous primary cannot substitute its reference path. +- Back now reconciles the restored caller scope and inspects its exact primary (`app/process_canvas.rs:226`). This repairs the observed native case: return ID is the outer reference, restored address identifies the qualified leaf, and inspection changes the semantic subject to that leaf with `draft=None` before the next render. +- Ordinary Open/Back with no qualified address continues to reconcile to the local reference and leaves its properties editable. Existing ordinary App and mounted camera-return tests exercise that simpler path (`app/process_canvas.rs:666`, `behavior/topology/layout_tests.rs:805`); this review did not rerun them. A stale qualified address from the generic-navigation finding deserves the additional ordinary-open regression case. +- The new calls do not modify pan/zoom/fit/viewport. Those remain restored by `State::back`; the added inspection clears only selection context/focus and rebuilds properties. Dirty-draft/source/behavior refusal still runs before Back, so this patch does not introduce an unapplied-draft discard path. +- Returning clears the departed surface's group and selects the invoked caller target alone, consistent with surface-scoped selection. The patch does not promise to restore the entire earlier group. Preview-link OpenQualified still transports its source endpoint, so this review does not establish restoration of the exact link selection. + +## Gate implications + +Correct the test setup, retain the ordinary return/camera checks, and add qualified-to-explicit-local navigation coverage. Native rerun must verify caller path, primary, inspector read-only state, camera and saved Project/source together. Static reasoning here is not a passing gate or native acceptance receipt. diff --git a/harness/multiselect-process-evidence-20260923/reviews/run-3-observations.md b/harness/multiselect-process-evidence-20260923/reviews/run-3-observations.md new file mode 100644 index 00000000..b68218b0 --- /dev/null +++ b/harness/multiselect-process-evidence-20260923/reviews/run-3-observations.md @@ -0,0 +1,28 @@ +# Process native run 3 — parent observations + +Root operated the Windows app through Computer Use on 2026-09-23. Source b743a10d51032dc1655b94fed5e49fbbbcee83f4; matching captured GUI/checker builds windows-build-2/native-build-2. Production is unchanged from ff712b24190342363d0275467e96f181872da6ad. This run followed a preparation-only run-2 failure; no GUI launched in run 2. Review screenshots with the same-numbered accessibility/time JSON, prepared/start/finish records, and snapshots. Native acceptance below is the parent's observed workflow judgment, separate from automated receipt consistency. + +## Observed actions and outcomes + +- 01–11: launched, opened the generated run-3 working source, selected Behavior, saved baseline. The Open dialog's accessibility click did not dismiss it; fresh window selection and screenshot-grounded coordinate Open did. UIA reports Search Box focus although the visible filename caret/text is correct. No app failure is inferred from that automation mismatch. +- 12–16: selected first invocation's Shared detail; right-clicked second invocation's tile and explicitly opened its definition while it was nonprimary. Actual defining leaf became editable. Back restored the second qualified tile alone, Shared detail read-only Properties, exact Second use → Shared detail breadcrumb, and caller camera. Outer card was not primary/highlighted. This corrects run-1 frame 26. +- 17–18: Explorer selection of the outer Second use changed to its local editable inspector and outer-card highlight; the qualified leaf was no longer primary. +- 19–21: selected the second leaf and used inspector Open referenced definition, then Back. Current-primary return again restored exact second leaf and read-only Properties. +- 22–23: Explorer selection of the same-ID Shared detail while in preview navigated to its defining owner and editable local inspector. Saved complete Project remained unchanged. +- 24–27: returned to OA group order, selected Card A. Frame 26 tool output was truncated; frame 27 reobserved before further input. +- 28–31: context Add included A-to-U link with Card A, count 2, link primary. Drag A after B was refused with the explicit local-cards-only explanation. Membership, primary, camera and order remained unchanged. Saved Project unchanged. +- 32–36: clicked C, context Add A, then context Inspect this element on C. Count stayed 2; A secondary blue and C primary teal, Properties Card C. Selection order was C then A, contrary to source A then C. +- 37–38: dragged C onto selected A's lower half. Order, count and primary remained unchanged; saved Project unchanged. +- 39–40: dragged C from (420,620) after B at (420,529). Left column became B,A,C; right column U,V unchanged. Count 2, C primary and A secondary persisted; camera stayed 100%. Status reported two contributions reordered. This discriminates against singleton pressed/primary C, whose valid same-column move after B would leave the original order. Saved complete Project matches literal [B,U,A,V,C], with all other model records identical. +- 41–42: exactly one Ctrl+Z restored A,B,C and original complete Project; selection and primary stayed stable. Saved Undo snapshot. +- 43–44: exactly one Ctrl+Y restored B,A,C and the reordered complete Project. Saved Redo snapshot. +- 45–52: opened the exact run-3 working project again in the same process, returned to Behavior/OA group owner, observed B,A,C and U,V, and saved. Transient selection cleared on reopening. Reopened complete Project matches reordered. +- Closed via Alt+F4; launch wrapper finished 13:43:54.209222Z, exit 0, empty stdout/stderr, binaries and immutable fixture unchanged. + +## Independent saved-model and byte checks + +Eight complete-Project checks passed: baseline, qualified-navigation, mixed-refusal, anchor-noop and undo equal the immutable baseline; reordered, redo and reopened equal the literal expected Project. `history-bytes.json` passes ten files per snapshot: fixture/baseline/undo/three unchanged extras are byte-identical; reordered/redo/reopened are byte-identical. Only `oa/behavior/process_oa_process.arcadia` changes across the two groups. These checks do not infer gestures, focus, selection, read-only state or one-step history; the observed actions above supply that evidence. + +## Scope and limitations + +Accepted for the observed Windows OA workflow at 1442×932 and 100% zoom. Context Add/Inspect was used; native held Shift was not exercised. Drag API executes a whole gesture, so held-motion line readability comes from the separate actual-WGPU mounted OA/SA capture. Native SA reorder, alternate DPI/minimum window, full accessibility, aggregate deletion, Allocation/Scenario adapters and Linux are not established. Run 1's qualified drag/Delete refusal is historical b153 evidence, supported by final focused/full tests; it was not repeated here. Existing crossing/routing appearance after reordered rows is visible and is not claimed repaired by this change (routing tracked separately in issue 32). This is branch-specific verification, not combined integration or release acceptance. diff --git a/harness/multiselect-process-evidence-20260923/source/.gitattributes b/harness/multiselect-process-evidence-20260923/source/.gitattributes new file mode 100644 index 00000000..fcadb2cf --- /dev/null +++ b/harness/multiselect-process-evidence-20260923/source/.gitattributes @@ -0,0 +1 @@ +* text eol=lf diff --git a/harness/multiselect-process-evidence-20260923/source/README.md b/harness/multiselect-process-evidence-20260923/source/README.md new file mode 100644 index 00000000..28ff4dff --- /dev/null +++ b/harness/multiselect-process-evidence-20260923/source/README.md @@ -0,0 +1,21 @@ +# Process source identity tools + +Adapted from the State source package. The Git comparator retains its exact filtered `ls-tree` inventory and `cat-file --batch` blob-byte comparison, with `GIT_NO_LAZY_FETCH=1`. Process scope always contains 742 inputs, including all 71 vendored files and both explicit compile-time fixtures. Commit IDs must be full, explicit IDs already available locally; neither captured HEAD, timestamps nor the current working tree substitutes for comparison. + +Validate completed raw captures without writing: + +```powershell +python -B prepare.py --repo CHECKOUT --raw-gates CHECKOUT/.local/process-group-gates --gate full-desktop-1 1711bff650dabb1df3a9bc37f19060d5db297feb --gate lint-2 b15345123a7feaf630ef24d45f591c76c6715778 --check-only +``` + +For the final gate, append `--gate full-desktop-2 FULL_FINAL_COMMIT` only after its result exists and the exact final source commit is available. No final commit is guessed or hardcoded. Other completed gates may be named explicitly. Failed, missing, changed-input or mismatching source gates refuse. + +For portable preparation, supply the completed Process `--checks CHECKS_PACKAGE`, remove `--check-only`, and add `--output NEW_SOURCE_PACKAGE`. `--raw-gates` may be omitted when using the package; supplying both requires their selected captured maps/results/archives to be equal. The output is create-only. Preparation validates all captures and Git comparisons before writing pinned manifest, newly generated source receipts, tools and files.json, then performs portable readback. Existing historical receipt files are neither consumed nor rewritten. + +```powershell +python -B SOURCE_PACKAGE/verify.py --checks CHECKS_PACKAGE --repo CHECKOUT +``` + +Without `--repo`, verification validates pinned receipt/map relationships and sibling archived source capsules, but explicitly reports that Git comparison was not rerun. The sibling defaults to ../checks. The checks package must use the existing `multiselect-cargo-gates` schema. All outer/member byte pins, exact ZIP inventory and source capsule mappings are checked; selected nested source archives are checked byte-for-byte against their maps. Run the sibling checks verifier separately for complete helper/process/stream relationships. No archived executable/helper is run or extracted. + +`manifest.json` pins the explicitly selected run/commit/map/count/fixture/receipt relationships; files.json pins all package bytes. These are consistency evidence, not independent authentication, hermetic build proof, native usability or release acceptance. Package source checks should be run with `python -B` to avoid an extra __pycache__ directory violating exact package inventory. diff --git a/harness/multiselect-process-evidence-20260923/source/files.json b/harness/multiselect-process-evidence-20260923/source/files.json new file mode 100644 index 00000000..c6fcf7e9 --- /dev/null +++ b/harness/multiselect-process-evidence-20260923/source/files.json @@ -0,0 +1,38 @@ +{ + ".gitattributes": { + "bytes": 14, + "sha256": "a79691a93b46e49ce460c26ef22afcc03d6eca1e63bf2edbc20e96159510f6c9" + }, + "README.md": { + "bytes": 2695, + "sha256": "7f20dc17395af4d5f7e08c2873788de9e6dffd0d0401b11efd8a9987cb78bdac" + }, + "manifest.json": { + "bytes": 2462, + "sha256": "d4a958402395d806e037dcb236e2460329db42003e72d958939133a049da8bb4" + }, + "prepare.py": { + "bytes": 3493, + "sha256": "da6a1c8da417b3caf5ba44a0848cbfd64e6d299ccf2d16f3e7ae742eed69f1c6" + }, + "source-core-3.json": { + "bytes": 570, + "sha256": "c0ad71de62a092df89c851c2be6e466c1f24932e43db1b8a7a2b188401ec12cf" + }, + "source-feedback-1.json": { + "bytes": 574, + "sha256": "b8688707c7661d22858b2b799f6c671509f8a438069085790a2e43f0fee4aa22" + }, + "source-full-desktop-1.json": { + "bytes": 578, + "sha256": "7f2f7675dc11e649a3ac3e3ee834a7dc135436747feaa789d4d7c89c41300d73" + }, + "source-full-desktop-2.json": { + "bytes": 578, + "sha256": "7d34865d642f40c865ff69be1c7d744bf1660dd8f5e7a7d421d3f05c45bd446a" + }, + "verify.py": { + "bytes": 10094, + "sha256": "7805832d5f563ab2d7a50dd3de700792b5d6f459c68f7b1d8bcfcc9800fa0c3a" + } +} diff --git a/harness/multiselect-process-evidence-20260923/source/manifest.json b/harness/multiselect-process-evidence-20260923/source/manifest.json new file mode 100644 index 00000000..40ad5205 --- /dev/null +++ b/harness/multiselect-process-evidence-20260923/source/manifest.json @@ -0,0 +1,70 @@ +{ + "kind": "process-multiselect-source", + "runs": { + "core-3": { + "commit": "b743a10d51032dc1655b94fed5e49fbbbcee83f4", + "explicitCompileTimeFixtures": [ + "harness/experiments/edge-attachments-20260922/native-fixture.arcadia", + "harness/experiments/textual-authoring-20260911/alarm-response.arcadia" + ], + "inputCount": 742, + "inputMap": { + "bytes": 124276, + "sha256": "f3fd180af294e0797f9dfdbff647274de30a907b685a4fe1a2b58027bfe7da95" + }, + "receiptPin": { + "bytes": 570, + "sha256": "c0ad71de62a092df89c851c2be6e466c1f24932e43db1b8a7a2b188401ec12cf" + } + }, + "feedback-1": { + "commit": "b15345123a7feaf630ef24d45f591c76c6715778", + "explicitCompileTimeFixtures": [ + "harness/experiments/edge-attachments-20260922/native-fixture.arcadia", + "harness/experiments/textual-authoring-20260911/alarm-response.arcadia" + ], + "inputCount": 742, + "inputMap": { + "bytes": 124276, + "sha256": "7f5b3de4d5bb70282e424fbe90211af2ac259965aef10b05438b46076467a767" + }, + "receiptPin": { + "bytes": 574, + "sha256": "b8688707c7661d22858b2b799f6c671509f8a438069085790a2e43f0fee4aa22" + } + }, + "full-desktop-1": { + "commit": "1711bff650dabb1df3a9bc37f19060d5db297feb", + "explicitCompileTimeFixtures": [ + "harness/experiments/edge-attachments-20260922/native-fixture.arcadia", + "harness/experiments/textual-authoring-20260911/alarm-response.arcadia" + ], + "inputCount": 742, + "inputMap": { + "bytes": 124276, + "sha256": "d5acfcf62fb4efd4efaf073abcc7e671644a6086e9e6cf105a06034c4a27daf8" + }, + "receiptPin": { + "bytes": 578, + "sha256": "7f2f7675dc11e649a3ac3e3ee834a7dc135436747feaa789d4d7c89c41300d73" + } + }, + "full-desktop-2": { + "commit": "ff712b24190342363d0275467e96f181872da6ad", + "explicitCompileTimeFixtures": [ + "harness/experiments/edge-attachments-20260922/native-fixture.arcadia", + "harness/experiments/textual-authoring-20260911/alarm-response.arcadia" + ], + "inputCount": 742, + "inputMap": { + "bytes": 124276, + "sha256": "1ba4ed414b3ccbc139ad4998af77370cba994f3edf4395b80acc69d3440ccc45" + }, + "receiptPin": { + "bytes": 578, + "sha256": "7d34865d642f40c865ff69be1c7d744bf1660dd8f5e7a7d421d3f05c45bd446a" + } + } + }, + "schema": 1 +} diff --git a/harness/multiselect-process-evidence-20260923/source/prepare.py b/harness/multiselect-process-evidence-20260923/source/prepare.py new file mode 100644 index 00000000..75a1b7f5 --- /dev/null +++ b/harness/multiselect-process-evidence-20260923/source/prepare.py @@ -0,0 +1,61 @@ +"""Bind explicitly named Process gate captures to Git commits; no builds or checkouts.""" +from pathlib import Path +import argparse +import datetime +import json +import re +from verify import (HERE, TOOLS, COMPARISON, EXPLICIT_FIXTURES, need, pin, parsed, + compare_commit, verify_data, raw_captures, packaged, verify_package) + +def encode(value): + return (json.dumps(value, indent=2, sort_keys=True) + "\n").encode("utf-8") + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repo", required=True, type=Path, help="repository containing the explicitly named full commit IDs") + parser.add_argument("--raw-gates", type=Path, help="completed raw captures; required unless --checks provided") + parser.add_argument("--checks", type=Path, help="completed portable Process checks package; required for writing") + parser.add_argument("--gate", nargs=2, action="append", required=True, metavar=("RUN", "COMMIT")) + parser.add_argument("--check-only", action="store_true") + parser.add_argument("--output", type=Path, help="new source package directory") + args = parser.parse_args() + pairs = {} + for run, commit in args.gate: + need(re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", run) is not None, "safe gate name") + need(re.fullmatch(r"[0-9a-f]{40}", commit) is not None, "explicit full commit ID required") + need(run not in pairs, "duplicate gate") + pairs[run] = commit + need(args.raw_gates is not None or args.checks is not None, "capture input required") + captured = packaged(args.checks, pairs) if args.checks else raw_captures(args.raw_gates, pairs) + if args.checks and args.raw_gates: + need(captured == raw_captures(args.raw_gates, pairs), "raw and packaged captures differ") + specs, receipts = {}, {} + for run, commit in pairs.items(): + map_bytes, result, _archive = captured[run] + spec = {"commit": commit, "inputCount": 742, "inputMap": pin(map_bytes), + "explicitCompileTimeFixtures": sorted(EXPLICIT_FIXTURES)} + # No receipt is written until every capture and exact commit comparison passes. + receipts[run] = encode({**spec, "gate": run, "comparison": COMPARISON, + "checked": datetime.datetime.now(datetime.timezone.utc).isoformat()}) + specs[run] = {**spec, "receiptPin": pin(receipts[run])} + summary = verify_data(receipts, captured, specs, args.repo) + if args.check_only: + need(args.output is None, "--check-only does not write output") + print("PASS source check-only; no package written:", summary) + return + need(args.checks is not None and args.output is not None, "writing requires completed --checks and new --output") + out = args.output.resolve() + need(not out.exists(), "existing output refused") + payload = {name:(HERE/name).read_bytes() for name in TOOLS} + payload["manifest.json"] = encode({"schema":1, "kind":"process-multiselect-source", "runs":specs}) + payload.update({"source-"+run+".json": value for run,value in receipts.items()}) + out.mkdir(parents=True) + for name, value in payload.items(): + with (out/name).open("xb") as stream: + stream.write(value) + with (out/"files.json").open("xb") as stream: + stream.write(encode({name:pin(value) for name,value in sorted(payload.items())})) + print("PASS written package readback:", verify_package(out,args.checks), out) + +if __name__ == "__main__": + main() diff --git a/harness/multiselect-process-evidence-20260923/source/source-core-3.json b/harness/multiselect-process-evidence-20260923/source/source-core-3.json new file mode 100644 index 00000000..531f43c1 --- /dev/null +++ b/harness/multiselect-process-evidence-20260923/source/source-core-3.json @@ -0,0 +1,15 @@ +{ + "checked": "2026-09-23T13:41:55.911287+00:00", + "commit": "b743a10d51032dc1655b94fed5e49fbbbcee83f4", + "comparison": "Exact committed Git blobs equal every captured input; same complete filtered inventory", + "explicitCompileTimeFixtures": [ + "harness/experiments/edge-attachments-20260922/native-fixture.arcadia", + "harness/experiments/textual-authoring-20260911/alarm-response.arcadia" + ], + "gate": "core-3", + "inputCount": 742, + "inputMap": { + "bytes": 124276, + "sha256": "f3fd180af294e0797f9dfdbff647274de30a907b685a4fe1a2b58027bfe7da95" + } +} diff --git a/harness/multiselect-process-evidence-20260923/source/source-feedback-1.json b/harness/multiselect-process-evidence-20260923/source/source-feedback-1.json new file mode 100644 index 00000000..00f8d715 --- /dev/null +++ b/harness/multiselect-process-evidence-20260923/source/source-feedback-1.json @@ -0,0 +1,15 @@ +{ + "checked": "2026-09-23T13:41:55.910110+00:00", + "commit": "b15345123a7feaf630ef24d45f591c76c6715778", + "comparison": "Exact committed Git blobs equal every captured input; same complete filtered inventory", + "explicitCompileTimeFixtures": [ + "harness/experiments/edge-attachments-20260922/native-fixture.arcadia", + "harness/experiments/textual-authoring-20260911/alarm-response.arcadia" + ], + "gate": "feedback-1", + "inputCount": 742, + "inputMap": { + "bytes": 124276, + "sha256": "7f5b3de4d5bb70282e424fbe90211af2ac259965aef10b05438b46076467a767" + } +} diff --git a/harness/multiselect-process-evidence-20260923/source/source-full-desktop-1.json b/harness/multiselect-process-evidence-20260923/source/source-full-desktop-1.json new file mode 100644 index 00000000..c3c3fe5c --- /dev/null +++ b/harness/multiselect-process-evidence-20260923/source/source-full-desktop-1.json @@ -0,0 +1,15 @@ +{ + "checked": "2026-09-23T13:41:55.910110+00:00", + "commit": "1711bff650dabb1df3a9bc37f19060d5db297feb", + "comparison": "Exact committed Git blobs equal every captured input; same complete filtered inventory", + "explicitCompileTimeFixtures": [ + "harness/experiments/edge-attachments-20260922/native-fixture.arcadia", + "harness/experiments/textual-authoring-20260911/alarm-response.arcadia" + ], + "gate": "full-desktop-1", + "inputCount": 742, + "inputMap": { + "bytes": 124276, + "sha256": "d5acfcf62fb4efd4efaf073abcc7e671644a6086e9e6cf105a06034c4a27daf8" + } +} diff --git a/harness/multiselect-process-evidence-20260923/source/source-full-desktop-2.json b/harness/multiselect-process-evidence-20260923/source/source-full-desktop-2.json new file mode 100644 index 00000000..56a03ee7 --- /dev/null +++ b/harness/multiselect-process-evidence-20260923/source/source-full-desktop-2.json @@ -0,0 +1,15 @@ +{ + "checked": "2026-09-23T13:41:55.911287+00:00", + "commit": "ff712b24190342363d0275467e96f181872da6ad", + "comparison": "Exact committed Git blobs equal every captured input; same complete filtered inventory", + "explicitCompileTimeFixtures": [ + "harness/experiments/edge-attachments-20260922/native-fixture.arcadia", + "harness/experiments/textual-authoring-20260911/alarm-response.arcadia" + ], + "gate": "full-desktop-2", + "inputCount": 742, + "inputMap": { + "bytes": 124276, + "sha256": "1ba4ed414b3ccbc139ad4998af77370cba994f3edf4395b80acc69d3440ccc45" + } +} diff --git a/harness/multiselect-process-evidence-20260923/source/verify.py b/harness/multiselect-process-evidence-20260923/source/verify.py new file mode 100644 index 00000000..ee355970 --- /dev/null +++ b/harness/multiselect-process-evidence-20260923/source/verify.py @@ -0,0 +1,186 @@ +"""Verify pinned Process source receipts against sibling capture capsules; optionally compare Git blobs.""" +from pathlib import Path, PurePosixPath +import argparse +import hashlib +import json +import os +import subprocess +import zipfile +import io +import stat + +HERE = Path(__file__).resolve().parent +COMPARISON = "Exact committed Git blobs equal every captured input; same complete filtered inventory" +EXPLICIT_FIXTURES = { + "harness/experiments/edge-attachments-20260922/native-fixture.arcadia", + "harness/experiments/textual-authoring-20260911/alarm-response.arcadia", +} +INCLUDE_FIXTURES = True + + +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 (INCLUDE_FIXTURES and path in EXPLICIT_FIXTURES) or 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 inventory(archive, expected): + names = archive.namelist() + need(len(names) == len(set(names)) and set(names) == set(expected), "exact ZIP inventory") + for item in archive.infolist(): + need(safe(item.filename) and not item.is_dir() and not stat.S_ISLNK(item.external_attr >> 16), "safe ZIP member") + need(pin(archive.read(item.filename)) == expected[item.filename], "ZIP member pin: " + item.filename) + +def verify_data(receipts, captured, specs, repo=None): + need(set(receipts) == set(captured) == set(specs), "exact source run inventory") + for run, spec in specs.items(): + receipt_bytes = receipts[run] + need(pin(receipt_bytes) == spec["receiptPin"], run + ": exact historical receipt bytes") + receipt = parsed(receipt_bytes) + need(receipt["gate"] == run and receipt["comparison"] == COMPARISON, run + ": receipt identity") + need(all(receipt[key] == spec[key] for key in ("commit", "inputCount", "inputMap", "explicitCompileTimeFixtures")), run + ": pinned receipt relationships") + map_bytes, result, archive_bytes = captured[run] + need(pin(map_bytes) == spec["inputMap"] == result["inputs"], run + ": exact captured input map") + need(result["exitCode"] == 0 and result["inputsUnchanged"] is True, run + ": accepted unchanged gate") + inputs = parsed(map_bytes) + need(len(inputs) == 742, run + ": Process 742-input scope required") + need(len(inputs) == spec["inputCount"] == result["inputCount"], run + ": input counts") + need(spec["explicitCompileTimeFixtures"] == (sorted(EXPLICIT_FIXTURES) if INCLUDE_FIXTURES else []), run + ": exact fixture scope") + need(all(safe(name) and relevant(name) for name in inputs), run + ": input scope") + need((EXPLICIT_FIXTURES & set(inputs)) == (EXPLICIT_FIXTURES if INCLUDE_FIXTURES else set()), run + ": explicit fixture membership") + need(sum(name.startswith("crates/vendor/iced_widget/") for name in inputs) == 71, run + ": vendor count") + need(pin(archive_bytes) == result["archive"], run + ": source capsule pin") + with zipfile.ZipFile(io.BytesIO(archive_bytes)) as archive: + inventory(archive, inputs) + if repo is not None: + compare_commit(repo, spec["commit"], inputs) + print("PASS", run, spec["commit"], len(inputs), "source pins" + (" and exact Git blobs" if repo is not None else " (Git comparison not rerun)")) + return {"runs": len(specs), "gitCompared": repo is not None} + +def raw_captures(gates, specs): + return {run: ((gates/run/"inputs.json").read_bytes(), parsed((gates/run/"result.json").read_bytes()), + (gates/run/"inputs.zip").read_bytes()) for run in specs} + +def packaged(checks, specs): + 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 kind") + logical, members = manifest["logicalFiles"], manifest["members"] + need(all(safe(name) and safe(target) for name,target in logical.items()) and set(logical.values()) == set(members), "checks logical inventory") + captured = {} + with zipfile.ZipFile(checks/"evidence.zip") as archive: + inventory(archive, members) + for run in specs: + prefix = "cargo/" + run + "/" + result = parsed(archive.read(logical[prefix + "result.json"])) + spec = manifest["runs"][run] + need(all(result[key] == spec[key] for key in ("command", "head", "exitCode", "inputCount")), run + ": checks relationships") + for name in ("inputs.json", "inputs.zip"): + member = logical[prefix+name] + need(member == "capsules/" + members[member]["sha256"] + Path(name).suffix, run + ": source capsule mapping") + captured[run] = (archive.read(logical[prefix+"inputs.json"]), result, archive.read(logical[prefix+"inputs.zip"])) + return captured + + +TOOLS = {".gitattributes", "prepare.py", "verify.py", "README.md"} + +def specifications(manifest): + import re + need(manifest.get("schema") == 1 and manifest.get("kind") == "process-multiselect-source", "source manifest kind") + specs = manifest["runs"] + need(isinstance(specs, dict) and bool(specs), "nonempty explicit source run inventory") + for run, spec in specs.items(): + need(isinstance(run, str) and re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", run), "safe source gate") + need(isinstance(spec["commit"], str) and re.fullmatch(r"[0-9a-f]{40}", spec["commit"]), "full explicit commit required") + need(spec["inputCount"] == 742 and spec["explicitCompileTimeFixtures"] == sorted(EXPLICIT_FIXTURES), "fixed Process input scope") + return specs + +def verify_package(package, checks, repo=None): + files = parsed((package/"files.json").read_bytes()) + manifest = parsed((package/"manifest.json").read_bytes()) + specs = specifications(manifest) + expected = TOOLS | {"manifest.json"} | {"source-"+run+".json" for run in specs} + need(set(files) == expected, "source outer inventory") + need({p.name for p in package.iterdir()} == expected | {"files.json"}, "source exact package inventory") + for name, value in files.items(): + need((package/name).is_file() and not (package/name).is_symlink() and pin((package/name).read_bytes()) == value, "source outer pin: " + name) + receipts = {run:(package/("source-"+run+".json")).read_bytes() for run in specs} + return verify_data(receipts, packaged(checks, specs), specs, repo) + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("package", nargs="?", type=Path, default=HERE) + parser.add_argument("--checks", type=Path, help="completed sibling checks package; default ../checks") + parser.add_argument("--repo", type=Path, help="repeat exact named-commit Git inventory/blob comparison") + args = parser.parse_args() + print("PASS source evidence:", verify_package(args.package, args.checks or args.package.parent/"checks", args.repo)) + print("No builds or archived helper execution. Run checks verifier separately for full process/log relationships.") + +if __name__ == "__main__": + main() diff --git a/harness/multiselect-process-native-design-20260923.md b/harness/multiselect-process-native-design-20260923.md new file mode 100644 index 00000000..40e6a491 --- /dev/null +++ b/harness/multiselect-process-native-design-20260923.md @@ -0,0 +1,32 @@ +# Process multiple-selection native evaluator design + +The independent evaluator is `crates/oa-core/examples/process_multiselect_native.rs`. This document describes its intended gates, not successful build/test/native evidence. It was prepared while the parent integrated the desktop adapter; no Cargo or formatting command ran during preparation. + +## Fixture and expected operation + +Generate a fresh directory with deterministic UUIDs, `baseline.json`, source bundle and `manifest.json`. Generation refuses an existing output directory; every write is create-only. The checker also compares the loaded baseline with its deterministic original fixture, so a reordered snapshot cannot silently become the baseline. Pin the generated files and command in the native receipt before interaction; these checks do not authenticate external evidence by themselves. + +Open the generated source, choose Behavior and **01 OA group order**. Its source steps are `[Card A, Downstream U, Card B, Downstream V, Card C]`. Explicit links A→U and B→V produce one column containing A/B/C and another containing U/V. A and C are distinct Action occurrences sharing the same reusable Activity; B is a Choice control. Select C, add A, then use **Inspect this element** on C without changing membership. Drag C after B. C is both pressed member and primary: a singleton C reorder would leave the original order unchanged, whereas the group moves A before C. With A primary, a singleton A reorder could accidentally produce the expected group order; that weaker trial is insufficient. Literal expected source steps are `[B,U,A,V,C]`, retaining U/V in indices 1/3 and A/C in their original internal order despite reverse selection. + +The checker has `unchanged` and `reordered` modes. It modifies only the expected OA owner's steps with that literal list, then compares the complete Project. It never calls a desktop planner, renderer, selection helper or inferred pointer geometry. Links/guards, reused work, occurrence bodies, all SA records, trace baseline and unrelated processes must remain exact. JSON and source roundtrips are also checked; completeness warnings are permitted, source errors are not. + +The compact **SA chain - preserve** contains a matching five-card FunctionalChain fixture. Its complete records must remain unchanged during OA trials. **02 Read-only preview trial** has two local references to **Reusable request detail**. Their preview tiles show the same defining occurrence through different paths, permitting exact-identity/read-only refusal trials; saving after those refusal trials should satisfy `unchanged` against the original baseline. + +## Proposed commands + +Use the parent's established toolchain and gate receipts. From the repository root: + +```text +cargo test --locked --offline -p oa-core --example process_multiselect_native -- --nocapture +cargo run --locked --offline -p oa-core --example process_multiselect_native -- generate FRESH_DIRECTORY +cargo run --locked --offline -p oa-core --example process_multiselect_native -- check unchanged FRESH_DIRECTORY/baseline.json SAVED_SOURCE_ENTRY +cargo run --locked --offline -p oa-core --example process_multiselect_native -- check reordered FRESH_DIRECTORY/baseline.json SAVED_SOURCE_ENTRY +``` + +The manifest identifies the emitted source entry. Native windows/load paths and executable hashes must be captured by the actual evaluator run. The example supports JSON saved inputs too. + +## Falsifiers and limits + +Five evaluator tests exercise deterministic/create-only generation; real validated `SetSteps` output; unchanged/reordered distinction and replaced-baseline refusal; wrong global splicing, reversed block, and the unchanged singleton-C result for the prescribed gesture; link guard/endpoint drift, reusable Activity changes, SA semantic/order changes, reused-definition and cross-layer baseline corruption; source compile/roundtrip and valid source drift. Negative Project mutations are asserted core-valid before checker rejection, distinguishing oracle sensitivity from trivial malformed-input rejection. + +Native acceptance must separately establish Shift/focus delivery, selection painting/count, selected-anchor no-op, actual Before/After zones, cancellation, context menus, whole-set mixed/qualified refusal, one Undo/Redo, source inspection, stable camera/layout and responsiveness. Whole-Project equality does not establish those interactions or exact source-byte preservation. This fixture does not test aggregate deletion, Allocation or Scenario semantics. `reordered` is specifically the OA operation above; the SA fixture is a preservation sentinel, not an accepted SA native reorder run. diff --git a/harness/release-activity-allocation-drag-contract-v2-20260911.md b/harness/release-activity-allocation-drag-contract-v2-20260911.md index 6ee934ae..3eb74d0a 100644 --- a/harness/release-activity-allocation-drag-contract-v2-20260911.md +++ b/harness/release-activity-allocation-drag-contract-v2-20260911.md @@ -1,5 +1,12 @@ # Allocation drag contract v2 — 2026-09-11 +**2026-09-23 explicit Arrange amendment:** +[the accepted bounded policy](allocation-arrange-amendment-20260923.md) supersedes +the mixed/coherence pre-veto only for explicit Arrange, with a declared combined +footprint and geometry gate. [Pure planner verification](allocation-arrange-planner-20260923.md) +is complete within its recorded scope; App/native integration remains pending. +Ordinary assignment and all other v2 lifetime/source rules remain binding. + Codex-956atf `/root/capella_process_gap`. Implementation-ready proposal for root ratification of [issue 3](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/3). The [original design](release-activity-allocation-drag-design-20260911.md) and [independent review](release-activity-allocation-drag-review-20260911.md) remain immutable. Root accepted all five review corrections; this document resolves them without claiming implementation, issue closure or full-goal completion. **2026-09-12 presentation amendment:** the [destination feedback decision](release-allocation-feedback-decision-20260912.md) supersedes references below requiring a duplicate target-name label at its frozen canvas position. Target hit regions and outlines remain frozen; destination identity moves to a dedicated stable toolbar slot. This prevents label occlusion without making ordinary collapsed-target drops unreachable. All semantic, exact-placement and lifetime rules remain binding.