diff --git a/crates/oa-core/examples/auto_layout_probe.rs b/crates/oa-core/examples/auto_layout_probe.rs index 2b18cf12..04a7ad80 100644 --- a/crates/oa-core/examples/auto_layout_probe.rs +++ b/crates/oa-core/examples/auto_layout_probe.rs @@ -967,7 +967,50 @@ fn import_placement( } } validate_candidate(&after)?; - let final_view = projection::project_view(&after, diagram); + let mut final_view = projection::project_view(&after, diagram); + // Moving children inward can remove a formerly derived container minimum. + // Preserve authored size for true identity transfers, but materialize a + // requested unchanged effective size when reprojection would shrink it. + // This is one simultaneous restoration, never an iterative layout repair. + let mut restore_minima = Vec::new(); + let original_sizes: BTreeMap<_, _> = view.nodes.iter().map(|n| (n.id, n.size)).collect(); + let projected_ids: BTreeSet<_> = final_view.nodes.iter().map(|n| n.id).collect(); + if projected_ids != visible || final_view.nodes.len() != visible.len() { + return Err(fail( + "Final Rust projection differs from intended node identities", + )); + } + for node in &final_view.nodes { + let (position, size) = intended[&node.id]; + if node.position == position && node.size == size { + continue; + } + if node.position != position + || d.nodes[&node.id].collapsed + || original_sizes[&node.id] != size + || node.size.width > size.width + || node.size.height > size.height + { + return Err(fail( + "Final Rust projection differs from intended node geometry", + )); + } + restore_minima.push((node.id, size)); + } + if !restore_minima.is_empty() { + for (id, size) in restore_minima { + after + .diagrams + .get_mut(&diagram) + .unwrap() + .nodes + .get_mut(&id) + .unwrap() + .size = size; + } + validate_candidate(&after)?; + final_view = projection::project_view(&after, diagram); + } if final_view.nodes.len() != intended.len() || final_view .nodes @@ -980,6 +1023,120 @@ fn import_placement( } Ok(after) } + +#[cfg(test)] +mod placement_import_tests { + use super::*; + + fn nested() -> Builder { + fixtures() + .into_iter() + .find(|(name, _)| *name == "two_containers_reversed_children") + .unwrap() + .1 + } + fn identity(project: &Project, diagram: Id) -> PlacementPlan { + PlacementPlan { + schema: 1, + nodes: projection::project_view(project, diagram) + .nodes + .into_iter() + .map(|n| PlacementNode { + id: n.id, + position: n.position, + size: n.size, + }) + .collect(), + } + } + + #[test] + fn unchanged_effective_container_size_survives_inward_child_movement() { + let f = nested(); + let before = projection::project_view(&f.project, f.diagram); + let mut plan = identity(&f.project, f.diagram); + let mut expected = f.project.clone(); + for node in &mut plan.nodes { + let projected = before.nodes.iter().find(|n| n.id == node.id).unwrap(); + if projected.visual_parent.is_some() && node.position.y == 330.0 { + node.position.y -= 20.0; + expected + .diagrams + .get_mut(&f.diagram) + .unwrap() + .nodes + .get_mut(&node.id) + .unwrap() + .position = node.position; + } + if projected.visual_parent.is_none() { + expected + .diagrams + .get_mut(&f.diagram) + .unwrap() + .nodes + .get_mut(&node.id) + .unwrap() + .size = node.size; + } + } + let after = import_placement(&f.project, f.diagram, plan).unwrap(); + assert_eq!(after, expected); + let final_view = projection::project_view(&after, f.diagram); + for node in &final_view.nodes { + assert_eq!( + node.size, + before.nodes.iter().find(|n| n.id == node.id).unwrap().size + ); + } + } + + #[test] + fn identity_keeps_authored_expanded_and_folded_sizes() { + let mut f = nested(); + assert_eq!( + import_placement(&f.project, f.diagram, identity(&f.project, f.diagram)).unwrap(), + f.project + ); + let parent = projection::project_view(&f.project, f.diagram) + .nodes + .into_iter() + .find(|n| n.visual_parent.is_none()) + .unwrap() + .id; + f.project + .diagrams + .get_mut(&f.diagram) + .unwrap() + .nodes + .get_mut(&parent) + .unwrap() + .collapsed = true; + assert_eq!( + import_placement(&f.project, f.diagram, identity(&f.project, f.diagram)).unwrap(), + f.project + ); + } + + #[test] + fn inconsistent_smaller_container_plan_still_rejects() { + let f = nested(); + let child = projection::project_view(&f.project, f.diagram) + .nodes + .into_iter() + .find(|n| n.visual_parent.is_some()) + .unwrap() + .id; + let mut plan = identity(&f.project, f.diagram); + plan.nodes + .iter_mut() + .find(|n| n.id == child) + .unwrap() + .position + .x += 1000.0; + assert!(import_placement(&f.project, f.diagram, plan).is_err()); + } +} fn import_placement_cli( project_file: &std::path::Path, diagram_text: &std::ffi::OsStr, diff --git a/harness/experiments/scc-placement-20260925/plan.md b/harness/experiments/scc-placement-20260925/plan.md index 8320b2b3..55cb9142 100644 --- a/harness/experiments/scc-placement-20260925/plan.md +++ b/harness/experiments/scc-placement-20260925/plan.md @@ -66,3 +66,17 @@ ordering remain distinct alternatives; choose the next action from the observed failure, not from commitment to this prototype. Windows only; Linux builds and the frozen published prerelease remain unchanged. + +## Transfer correction after the first run + +Run01 at 4dd764fa exposed a transfer bug in the unchanged-effective-size shortcut, +not an SCC placement choice: moving children inward removed part of a parent's +derived enclosure while the importer preserved its smaller authored size. The +parent was therefore projected smaller than the explicitly requested geometry. +Retain run01. Correct the importer with one simultaneous restoration of intended +unchanged effective minima for noncollapsed nodes whose first projection is +strictly smaller and at the exact intended position. Reject every other mismatch +before restoration; require an exact second projection. Add identity, inward-child +and inconsistent-plan regression checks. Then repeat the same corpus as run02. +The SCC ordering, gaps, packing, routing and quality criteria remain unchanged; +this is a correctness repair to faithfully execute the predeclared candidate.