From 8dbbaf262e3b8d26a32d6c30f0cc5f963fab47a0 Mon Sep 17 00:00:00 2001 From: "sorokin.oleg.edu" Date: Wed, 23 Sep 2026 07:41:54 -0700 Subject: [PATCH] Verify OA and SA Arrange policy boundaries with focused production modules --- .../examples/allocation_policy_probe.rs | 14 + crates/oa-desktop/src/allocation_drag.rs | 1 + .../group_arrange_experiment.rs | 416 +++++++++++++++--- 3 files changed, 378 insertions(+), 53 deletions(-) create mode 100644 crates/oa-desktop/examples/allocation_policy_probe.rs 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..a903c682 100644 --- a/crates/oa-desktop/src/allocation_drag.rs +++ b/crates/oa-desktop/src/allocation_drag.rs @@ -833,4 +833,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/group_arrange_experiment.rs b/crates/oa-desktop/src/allocation_drag/group_arrange_experiment.rs index 18504663..efa16788 100644 --- a/crates/oa-desktop/src/allocation_drag/group_arrange_experiment.rs +++ b/crates/oa-desktop/src/allocation_drag/group_arrange_experiment.rs @@ -1,4 +1,5 @@ -//! Unaccepted mixed-Arrange experiment. No production admission is changed. +//! Reviewed policy experiment using the production projection and translator. +//! No production admission or atomic-refusal behavior is tested here. use super::*; struct Mixed { @@ -7,52 +8,98 @@ struct Mixed { xn: Id, yn: Id, internal: Id, + include_performer: bool, } -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(); +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); - 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; + // 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(Kind::Activity, Layer::Oa, "Stationary obstacle", None).unwrap(); + 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])); + 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 } + 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(); - 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])); + 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)) { + if ids.contains(id) + || projection::descendant_nodes(p, diagram, *id) + .iter() + .any(|n| ids.contains(n)) + { node.collapsed = false; } } @@ -74,67 +121,330 @@ fn rigid(before: &ProjectedView, after: &ProjectedView, ids: &BTreeSet) { // 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 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.))] { + 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"); + 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); + 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 (command, actual) = candidate(&m); + 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); - 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()); + 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.); } - // 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!( + 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_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); +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); + } }