Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion crates/hypercolor-daemon/src/render_thread/frame_executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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(),
Expand Down
45 changes: 34 additions & 11 deletions crates/hypercolor-daemon/tests/render_thread_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<StdMutex<Vec<bool>>>, expected: bool) {
tokio::time::timeout(WAIT_DEADLINE, async {
loop {
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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",
);

{
Expand All @@ -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 ────────────────────────────────────────────────────
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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]
Expand Down