diff --git a/crates/hypercolor-daemon/src/render_thread/frame_executor.rs b/crates/hypercolor-daemon/src/render_thread/frame_executor.rs index f0be6b7c2..387d17965 100644 --- a/crates/hypercolor-daemon/src/render_thread/frame_executor.rs +++ b/crates/hypercolor-daemon/src/render_thread/frame_executor.rs @@ -951,11 +951,21 @@ async fn force_static_sleep_snapshot( .note_canvas_frame(frame_number, elapsed_ms); } +/// Whether a frame admitted while awake should end as a static sleep frame. +/// +/// Only the static off behavior can be satisfied by writing one off-color +/// frame here. A release sleep has to clear the published zones and stop +/// driving devices, which the sleep throttle path owns; taking the static +/// shortcut for it would latch the sleep frame as pushed with populated +/// zones still on the bus, so release lets this frame finish and the next +/// frame runs the release path. fn should_switch_to_late_sleep_frame( frame_output_power: crate::output_power::OutputPowerState, latest_output_power: crate::output_power::OutputPowerState, ) -> bool { - !frame_output_power.sleeping() && latest_output_power.sleeping() + !frame_output_power.sleeping() + && latest_output_power.sleeping() + && latest_output_power.effective_off_output_behavior() == OffOutputBehavior::Static } const fn output_frame_source_kind(source: OutputFrameSource) -> OutputFrameSourceKind { @@ -1082,6 +1092,24 @@ mod tests { assert!(!super::should_switch_to_late_sleep_frame(running, running)); } + #[test] + fn late_sleep_frame_leaves_release_sleep_to_the_throttle_path() { + let running = OutputPowerState::default(); + let releasing = OutputPowerState { + session_sleeping: true, + session_brightness: 0.0, + off_output_behavior: OffOutputBehavior::Release, + ..OutputPowerState::default() + }; + + assert!(!super::should_switch_to_late_sleep_frame( + running, releasing + )); + assert!(!super::should_switch_to_late_sleep_frame( + releasing, releasing + )); + } + fn sample_layout(zone_ids: &[&str]) -> SpatialLayout { SpatialLayout { id: "layout".to_owned(), diff --git a/crates/hypercolor-daemon/tests/render_thread_tests.rs b/crates/hypercolor-daemon/tests/render_thread_tests.rs index f3cd4053f..e6223bf0d 100644 --- a/crates/hypercolor-daemon/tests/render_thread_tests.rs +++ b/crates/hypercolor-daemon/tests/render_thread_tests.rs @@ -1359,6 +1359,32 @@ impl SourceRoleBinding for EventOnlySource { impl InteractionSource for EventOnlySource {} +/// Assert a demand-gated source was switched on exactly once and left off. +/// +/// The input publication worker reconciles capture demand as soon as it +/// starts, before the first frame has published the scene's authoritative +/// demand. When that first reconcile wins the race it applies "inactive" to +/// a source whose cached demand was cleared by `start_all`, logging a leading +/// `false`; when the first frame wins, the first application is already +/// `true`. Both orderings are correct, so the contract is the shape of the +/// real transitions, not the presence of the startup no-op. +fn assert_capture_toggled_once(transitions: &[bool]) { + assert_capture_transitions( + transitions, + &[true, false], + "capture should activate once for the reactive scene and deactivate after it", + ); +} + +/// Assert the demand transitions after the optional startup no-op. +fn assert_capture_transitions(transitions: &[bool], expected: &[bool], context: &str) { + let real = match transitions { + [false, rest @ ..] => rest, + rest => rest, + }; + assert_eq!(real, expected, "{context}; saw {transitions:?}"); +} + async fn wait_for_audio_capture_transition(transitions: &Arc>>, expected: bool) { tokio::time::timeout(WAIT_DEADLINE, async { loop { @@ -2067,7 +2093,7 @@ async fn render_thread_gates_audio_capture_to_audio_reactive_effects() { .lock() .expect("transition log should lock") .clone(); - assert_eq!(transitions, vec![false, true, false]); + assert_capture_toggled_once(&transitions); } #[tokio::test] @@ -2115,10 +2141,10 @@ async fn output_sleep_keeps_reactive_input_capture_live() { frame_rx.borrow().zones.is_empty() }) .await; - assert_eq!( - *transitions.lock().expect("transition log should lock"), - [false, true], - "output policy must not disable a live input consumer" + assert_capture_transitions( + &transitions.lock().expect("transition log should lock"), + &[true], + "output policy must not disable a live input consumer", ); { @@ -2127,10 +2153,7 @@ async fn output_sleep_keeps_reactive_input_capture_live() { } render_thread.shutdown().await.expect("shutdown"); - assert_eq!( - *transitions.lock().expect("transition log should lock"), - [false, true, false] - ); + assert_capture_toggled_once(&transitions.lock().expect("transition log should lock")); } // ── Frame Pipeline Tests ──────────────────────────────────────────────────── @@ -2729,7 +2752,7 @@ async fn audio_capture_enabled_when_any_active_zone_is_reactive() { .lock() .expect("transition log should lock") .clone(); - assert_eq!(transitions, vec![false, true, false]); + assert_capture_toggled_once(&transitions); } #[tokio::test] @@ -2809,7 +2832,7 @@ async fn render_thread_gates_screen_capture_to_screen_reactive_scene_groups() { .lock() .expect("transition log should lock") .clone(); - assert_eq!(transitions, vec![false, true, false]); + assert_capture_toggled_once(&transitions); } #[tokio::test]