From a5845fb293c09eac036d42e49c6dacddc1329c0b Mon Sep 17 00:00:00 2001 From: atagen Date: Thu, 21 May 2026 21:34:53 +1000 Subject: [PATCH] Assert mixed multiphase action boundaries --- docs/window-animations-plan.md | 5 +- src/animation/multiphase.rs | 87 ++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 1 deletion(-) diff --git a/docs/window-animations-plan.md b/docs/window-animations-plan.md index 98d4df98..16c5a7b6 100644 --- a/docs/window-animations-plan.md +++ b/docs/window-animations-plan.md @@ -170,7 +170,9 @@ Core rules: - Each phase is a discrete animation using the full curve. - A phase performs only one action kind per window: move or scale. - Movement and scaling are split by axis. -- No diagonal motion. +- No diagonal motion. Mixed-action phases may combine different per-window + actions, but no single window may move or resize on more than one axis in one + step. - A window or synchronized group owns its own timeline. - New layout changes interrupt only windows/groups with changed destinations. - Current hierarchy and target hierarchy both matter. The planner must know @@ -270,6 +272,7 @@ Tests: - bounded generated split-tree corpus produces identical plans on repeated runs - unsupported and invalid candidate plans produce exact expected diagnostics - mixed-action phases are accepted only under exact continuous validation +- diagonal per-window motion remains a hard rejection even inside mixed phases - child waits for parent/container-space phases when moving upward into a toplevel peer position - mono-mode tab switches do not animate, while entering/exiting mono can animate diff --git a/src/animation/multiphase.rs b/src/animation/multiphase.rs index 4db0bed1..8fa58e81 100644 --- a/src/animation/multiphase.rs +++ b/src/animation/multiphase.rs @@ -1910,6 +1910,56 @@ mod tests { assert!(validate_plan_continuous(&req, &planned.plan)); } + #[test] + fn mixed_single_phase_accepts_move_and_scale_when_proven() { + let req = request(vec![ + window(1, rect(0, 0, 80, 80), rect(40, 0, 120, 80)), + window(2, rect(200, 0, 280, 80), rect(200, 0, 280, 120)), + ]); + let planned = plan_no_overlap_explained(&req).unwrap(); + + assert_eq!(planned.explanation.strategy, PlanStrategy::MixedSinglePhase); + assert_eq!( + planned.plan.phases[0].action, + MultiphasePhaseAction::Mixed(vec![ + PhaseAction { + kind: PhaseKind::Move, + axis: PhaseAxis::Horizontal, + }, + PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Vertical, + }, + ]) + ); + assert_eq!( + planned.explanation.phases[0].reason, + PhaseReason::MixedAxisActions + ); + assert_eq!(planned.explanation.phases[0].nodes, vec![id(1), id(2)]); + assert!(validate_plan_continuous(&req, &planned.plan)); + } + + #[test] + fn mixed_single_phase_still_rejects_diagonal_per_window_motion() { + let req = request(vec![ + window(1, rect(0, 0, 80, 80), rect(40, 40, 120, 120)), + window(2, rect(200, 0, 280, 80), rect(200, 0, 280, 120)), + ]); + + assert_eq!(plan_no_overlap(&req), Err(MultiphaseError::NoPlan)); + let diagnostic = plan_no_overlap_with_diagnostics(&req).unwrap_err(); + let rejection = MultiphasePlanFailure::InvalidPhaseStep { + action: PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Horizontal, + }, + node_id: id(1), + }; + assert_eq!(diagnostic.forward, rejection); + assert_eq!(diagnostic.reverse, Some(rejection)); + } + #[test] fn generated_nested_size_redistribution_scales_parent_axis_first() { let old = split( @@ -2518,6 +2568,43 @@ mod tests { assert!(validate_plan_continuous(&req, &plan)); } + #[test] + fn continuous_validation_rejects_mixed_phase_action_count_mismatch() { + let req = request(vec![ + window(1, rect(0, 0, 40, 40), rect(40, 0, 80, 40)), + window(2, rect(100, 0, 140, 40), rect(100, 0, 140, 80)), + ]); + let plan = MultiphasePlan { + phases: vec![MultiphasePhase { + action: MultiphasePhaseAction::Mixed(vec![PhaseAction { + kind: PhaseKind::Move, + axis: PhaseAxis::Horizontal, + }]), + steps: vec![ + MultiphaseStep { + node_id: id(1), + from: rect(0, 0, 40, 40), + to: rect(40, 0, 80, 40), + }, + MultiphaseStep { + node_id: id(2), + from: rect(100, 0, 140, 40), + to: rect(100, 0, 140, 80), + }, + ], + }], + }; + + assert_eq!( + validate_plan_continuous_diagnostic(&req, &plan), + Err(MultiphaseValidationError::PhaseActionCount { + phase: 0, + actions: 1, + steps: 2, + }) + ); + } + #[test] fn continuous_validation_rejects_stale_step_start_rect() { let req = request(vec![MultiphaseWindow {