From 3a7923b96d671041cbc747a614cbd0fad6b6b3d3 Mon Sep 17 00:00:00 2001 From: "sorokin.oleg.edu" Date: Wed, 23 Sep 2026 07:27:42 -0700 Subject: [PATCH] experiment: test distributed Allocation arrangement with actual projection --- .../group_arrange_experiment.rs | 140 ++++++++++++++++++ .../oa-desktop/src/allocation_drag/tests.rs | 3 + 2 files changed, 143 insertions(+) create mode 100644 crates/oa-desktop/src/allocation_drag/group_arrange_experiment.rs 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..18504663 --- /dev/null +++ b/crates/oa-desktop/src/allocation_drag/group_arrange_experiment.rs @@ -0,0 +1,140 @@ +//! Unaccepted mixed-Arrange experiment. No production admission is changed. +use super::*; + +struct Mixed { + f: Fixture, + an: Id, + xn: Id, + yn: Id, + internal: Id, +} + +fn mixed(collapsed: bool, obstacle: bool) -> Mixed { + let mut f = fixture(Layer::Oa); + let x = f.p.add_element(Kind::Activity, Layer::Oa, "X", Some(f.w)).unwrap(); + let y = f.p.add_element(Kind::Activity, Layer::Oa, "Y", Some(f.w)).unwrap(); + let outside = f.p.add_element(Kind::Activity, Layer::Oa, "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 internal = f.p.relate(RelationKind::Interaction, "X to Y", x, y); + let external = f.p.relate(RelationKind::Interaction, "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(Kind::Activity, Layer::Oa, "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))); + Mixed { f, an, xn, yn, internal } +} + +fn affected(m: &Mixed) -> BTreeSet { + let mut ids: BTreeSet<_> = scope(&m.f).placements.keys().copied().collect(); + ids.insert(m.an); + ids.extend(projection::descendant_nodes(&m.f.p, m.f.d, m.an)); + assert_eq!(ids, BTreeSet::from([m.an, m.f.n, m.xn, m.yn])); + ids +} + +// Diagnostic expansion changes only a temporary Diagram. Include affected +// descendants and ancestors even when the normal projection hides them. +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.an, xy(500., 100.)), (m.f.n, xy(500., 800.)), (m.xn, xy(540., 200.)), (m.yn, xy(1340., 200.))] { + d.nodes.get_mut(&id).unwrap().position = position; + } + 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 collapsed in [false, true] { + let m = mixed(collapsed, false); + 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. + for id in [m.an, m.f.n] { assert!(after.nodes.iter().any(|n| n.id == id)); } + assert_eq!(roundtrip(&accepted, command), actual); + assert_eq!(m.f.p, accepted); + } +} + +#[test] +fn mixed_arrange_experiment_folded_child_collision_refuses_before_commit() { + let m = mixed(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); + assert!(overlaps(&before_expanded, &after_expanded), "combined diagnostic scene must refuse"); + // No commit on refusal; candidate preparation never mutates accepted source. + let document = Document::from_project(accepted.clone(), ProjectLimits::default()).unwrap(); + let source = document.bundle().clone(); + assert_eq!(document.project(), &accepted); + assert_eq!(document.bundle(), &source); + assert_eq!(document.revision(), 0); + assert!(!document.can_undo()); + 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..643a9517 100644 --- a/crates/oa-desktop/src/allocation_drag/tests.rs +++ b/crates/oa-desktop/src/allocation_drag/tests.rs @@ -2,6 +2,9 @@ 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; + struct Fixture { p: Project, d: Id,