use std::time::Duration;
use frust_core::{FrameTime, PaintCtx, PaintOutcome, TickClass};
use frust_shell_common::frame_gate::{FrameDecision, FrameGate, FrameInputs, FramePacing};
use kurbo::{Point, Size};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Trigger {
None,
InputEvent,
SignalWrite,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct Frame {
loop_visible: bool,
slow_loop_visible: bool,
transition_active: bool,
trigger: Trigger,
}
impl Frame {
const IDLE: Frame = Frame {
loop_visible: false,
slow_loop_visible: false,
transition_active: false,
trigger: Trigger::None,
};
const PACED_LOOP_ONSCREEN: Frame = Frame {
loop_visible: true,
..Frame::IDLE
};
const SLOW_PACED_LOOP_ONSCREEN: Frame = Frame {
slow_loop_visible: true,
..Frame::IDLE
};
const BOTH_PACED_LOOPS_ONSCREEN: Frame = Frame {
loop_visible: true,
slow_loop_visible: true,
..Frame::IDLE
};
const PACED_LOOP_OFFSCREEN_CULLED: Frame = Frame::IDLE;
const TRANSITION: Frame = Frame {
transition_active: true,
..Frame::IDLE
};
const TRANSITION_AND_PACED_LOOP: Frame = Frame {
loop_visible: true,
transition_active: true,
..Frame::IDLE
};
const INPUT_EVENT: Frame = Frame {
trigger: Trigger::InputEvent,
..Frame::IDLE
};
const SIGNAL_WRITE: Frame = Frame {
trigger: Trigger::SignalWrite,
..Frame::IDLE
};
}
fn paint_for(frame: Frame) -> PaintOutcome {
let mut ctx = PaintCtx::new(Point::ZERO, Size::new(100.0, 100.0));
if frame.loop_visible {
ctx.request_frame_class(TickClass::CosmeticLoop);
}
if frame.slow_loop_visible {
ctx.request_frame_paced_at(SLOW_LOOP_INTERVAL);
}
if frame.transition_active {
ctx.request_frame_class(TickClass::Transition);
}
PaintOutcome {
needs_frame: ctx.needs_frame(),
needs_layout: ctx.needs_layout(),
needs_frame_paced_only: ctx.needs_frame_paced_only(),
paced_interval: ctx.paced_interval(),
}
}
fn tick_inputs(frame: Frame, prev_outcome: PaintOutcome) -> FrameInputs {
let mut inputs = FrameInputs {
last_needs_frame: prev_outcome.needs_frame,
last_needs_frame_paced_only: prev_outcome.needs_frame_paced_only,
..FrameInputs::default()
};
match frame.trigger {
Trigger::InputEvent => inputs.events_since_last_frame = true,
Trigger::SignalWrite => inputs.signals_dirty = true,
Trigger::None => {}
}
inputs
}
fn tick(
gate: &mut FrameGate,
frame: Frame,
now: FrameTime,
pace_interval: Duration,
prev_outcome: PaintOutcome,
) -> (FrameDecision, PaintOutcome) {
let inputs = tick_inputs(frame, prev_outcome);
let decision = gate.decide_paced(
inputs,
FramePacing {
now,
interval: pace_interval,
requested_interval: prev_outcome.paced_interval,
},
);
let next_outcome = if decision.is_run() {
paint_for(frame)
} else {
prev_outcome
};
(decision, next_outcome)
}
fn run_stream(
gate: &mut FrameGate,
frame: Frame,
start_tick: u64,
ticks: u64,
tick_dur: Duration,
pace_interval: Duration,
mut prev_outcome: PaintOutcome,
) -> (usize, PaintOutcome) {
let mut runs = 0usize;
for i in 0..ticks {
let now = FrameTime::from_nanos((start_tick + i) * tick_dur.as_nanos() as u64);
let (decision, next) = tick(gate, frame, now, pace_interval, prev_outcome);
prev_outcome = next;
if decision.is_run() {
runs += 1;
}
}
(runs, prev_outcome)
}
fn seeded(frame: Frame) -> PaintOutcome {
paint_for(frame)
}
fn step(hz: f64) -> Duration {
Duration::from_secs_f64(1.0 / hz)
}
fn cap_interval() -> Duration {
step(30.0)
}
const SLOW_LOOP_INTERVAL: Duration = Duration::from_millis(500);
fn tick_hz() -> Duration {
step(120.0)
}
#[test]
fn transition_input_and_signal_sources_are_never_paced() {
let cases: &[(&str, Frame)] = &[
("transition", Frame::TRANSITION),
("input_event", Frame::INPUT_EVENT),
("signal_write", Frame::SIGNAL_WRITE),
];
for (name, frame) in cases {
let mut gate = FrameGate::with_flags(true, true);
let (runs, _) = run_stream(
&mut gate,
*frame,
0,
120,
tick_hz(),
cap_interval(),
seeded(*frame),
);
assert!(
runs >= 118,
"{name} must run essentially every tick even with pacing on, got {runs}/120"
);
}
}
#[test]
fn paced_loop_onscreen_runs_at_the_cosmetic_loop_cap_when_pacing_is_on() {
let mut gate = FrameGate::with_flags(true, true);
let (runs, _) = run_stream(
&mut gate,
Frame::PACED_LOOP_ONSCREEN,
0,
120,
tick_hz(),
cap_interval(),
seeded(Frame::PACED_LOOP_ONSCREEN),
);
assert!(
(28..=32).contains(&runs),
"a 120Hz paced-only stream should run ~30x/s at the default cap, got {runs}"
);
}
#[test]
fn anim_pacing_kill_switch_runs_the_paced_loop_every_tick() {
let mut gate = FrameGate::with_flags(true, false);
let (runs, _) = run_stream(
&mut gate,
Frame::PACED_LOOP_ONSCREEN,
0,
120,
tick_hz(),
cap_interval(),
seeded(Frame::PACED_LOOP_ONSCREEN),
);
assert!(
runs >= 118,
"with anim pacing disabled the paced loop must run every tick, got {runs}/120"
);
}
#[test]
fn pacing_kill_switch_does_not_force_runs_on_an_idle_or_culled_stream() {
for frame in [Frame::IDLE, Frame::PACED_LOOP_OFFSCREEN_CULLED] {
let mut gate = FrameGate::with_flags(true, false);
let (runs, _) = run_stream(
&mut gate,
frame,
0,
60,
tick_hz(),
cap_interval(),
PaintOutcome::default(),
);
assert_eq!(
runs, 0,
"{frame:?}: the anim-pacing kill switch must not force runs on a quiet stream"
);
}
}
#[test]
fn frame_gate_kill_switch_runs_every_source_every_tick() {
let cases: &[(&str, Frame)] = &[
("paced_loop_onscreen", Frame::PACED_LOOP_ONSCREEN),
(
"paced_loop_offscreen_culled",
Frame::PACED_LOOP_OFFSCREEN_CULLED,
),
("transition", Frame::TRANSITION),
(
"transition_and_paced_loop",
Frame::TRANSITION_AND_PACED_LOOP,
),
("input_event", Frame::INPUT_EVENT),
("signal_write", Frame::SIGNAL_WRITE),
("idle", Frame::IDLE),
];
for (name, frame) in cases {
let mut gate = FrameGate::disabled();
let (runs, _) = run_stream(
&mut gate,
*frame,
0,
30,
tick_hz(),
cap_interval(),
PaintOutcome::default(),
);
assert_eq!(
runs, 30,
"{name}: a disabled gate (FRUST_NO_FRAME_GATE) must run every tick"
);
}
}
#[test]
fn a_slow_paced_loop_runs_at_its_own_requested_cadence() {
let mut gate = FrameGate::with_flags(true, true);
let seed = seeded(Frame::SLOW_PACED_LOOP_ONSCREEN);
assert_eq!(
seed.paced_interval,
Some(SLOW_LOOP_INTERVAL),
"the paint pass must carry the requested interval out on PaintOutcome"
);
let (runs, _) = run_stream(
&mut gate,
Frame::SLOW_PACED_LOOP_ONSCREEN,
0,
120,
tick_hz(),
cap_interval(),
seed,
);
assert!(
(1..=3).contains(&runs),
"a 500ms paced request should run ~2x/s against a 30Hz cap, got {runs}"
);
}
#[test]
fn a_shimmer_beside_a_caret_paces_at_the_shimmer_rate() {
let mut gate = FrameGate::with_flags(true, true);
let seed = seeded(Frame::BOTH_PACED_LOOPS_ONSCREEN);
assert_eq!(
seed.paced_interval,
Some(Duration::ZERO),
"the bare (theme-cap) request must win the MIN fold"
);
let (runs, _) = run_stream(
&mut gate,
Frame::BOTH_PACED_LOOPS_ONSCREEN,
0,
120,
tick_hz(),
cap_interval(),
seed,
);
assert!(
(28..=32).contains(&runs),
"a shimmer+caret frame should run at the 30Hz cap, got {runs}"
);
}
#[test]
fn a_shimmer_leaving_hands_the_stream_back_to_the_carets_cadence() {
let tick_dur = tick_hz();
let mut gate = FrameGate::with_flags(true, true);
let (fast_runs, outcome) = run_stream(
&mut gate,
Frame::BOTH_PACED_LOOPS_ONSCREEN,
0,
120,
tick_dur,
cap_interval(),
seeded(Frame::BOTH_PACED_LOOPS_ONSCREEN),
);
assert!((28..=32).contains(&fast_runs));
let mut runs = 0usize;
let mut last_run_tick: Option<u64> = None;
let mut min_gap = u64::MAX;
let mut max_gap = 0u64;
let mut prev = outcome;
for i in 120..360u64 {
let now = FrameTime::from_nanos(i * tick_dur.as_nanos() as u64);
let (decision, next) = tick(
&mut gate,
Frame::SLOW_PACED_LOOP_ONSCREEN,
now,
cap_interval(),
prev,
);
prev = next;
if decision.is_run() {
runs += 1;
if let Some(last) = last_run_tick {
min_gap = min_gap.min(i - last);
max_gap = max_gap.max(i - last);
}
last_run_tick = Some(i);
}
}
assert!(
(3..=6).contains(&runs),
"~2 runs/s once only the caret remains, got {runs} over 2s"
);
assert!(
min_gap >= 59,
"no burst at the interval change: tightest gap was {min_gap} ticks"
);
assert!(
max_gap <= 61,
"no stall at the interval change: widest gap was {max_gap} ticks"
);
}
#[test]
fn culled_paced_loop_never_wakes_the_gate_while_offscreen() {
let mut gate = FrameGate::with_flags(true, true);
let (runs, outcome) = run_stream(
&mut gate,
Frame::PACED_LOOP_OFFSCREEN_CULLED,
0,
120,
tick_hz(),
cap_interval(),
PaintOutcome::default(),
);
assert_eq!(
runs, 0,
"an offscreen (culled) loop must never wake the gate"
);
assert!(!outcome.needs_frame);
}
#[test]
fn resumption_after_scroll_back_in_restores_the_paced_cadence() {
let tick_dur = tick_hz();
let pace = cap_interval();
let mut gate = FrameGate::with_flags(true, true);
let (offscreen_runs, outcome_after_offscreen) = run_stream(
&mut gate,
Frame::PACED_LOOP_OFFSCREEN_CULLED,
0,
60,
tick_dur,
pace,
PaintOutcome::default(),
);
assert_eq!(offscreen_runs, 0);
let scroll_frame = Frame {
loop_visible: true,
trigger: Trigger::InputEvent,
..Frame::IDLE
};
let scroll_now = FrameTime::from_nanos(60 * tick_dur.as_nanos() as u64);
let (scroll_decision, outcome_after_scroll) = tick(
&mut gate,
scroll_frame,
scroll_now,
pace,
outcome_after_offscreen,
);
assert!(
scroll_decision.is_run(),
"the scroll-back-in event itself must run immediately"
);
let (runs, _) = run_stream(
&mut gate,
Frame::PACED_LOOP_ONSCREEN,
61,
120,
tick_dur,
pace,
outcome_after_scroll,
);
assert!(
(28..=33).contains(&runs),
"cadence should resume at ~30/s once scrolled back into view, got {runs}"
);
}
#[test]
fn transition_dominates_a_concurrent_paced_loop_until_it_settles_then_cadence_drops_to_cap() {
let tick_dur = tick_hz();
let pace = cap_interval();
let mut gate = FrameGate::with_flags(true, true);
let (transition_runs, outcome_after_transition) = run_stream(
&mut gate,
Frame::TRANSITION_AND_PACED_LOOP,
0,
30,
tick_dur,
pace,
seeded(Frame::TRANSITION_AND_PACED_LOOP),
);
assert_eq!(
transition_runs, 30,
"a concurrent transition must run every tick, never paced"
);
let (settled_runs, _) = run_stream(
&mut gate,
Frame::PACED_LOOP_ONSCREEN,
30,
120,
tick_dur,
pace,
outcome_after_transition,
);
assert!(
(28..=33).contains(&settled_runs),
"cadence should drop to the cap once the transition settles, got {settled_runs}"
);
}
#[test]
fn paced_only_stream_never_starves_across_a_long_run() {
let tick_dur = tick_hz();
let pace = cap_interval();
let mut gate = FrameGate::with_flags(true, true);
let mut prev = seeded(Frame::PACED_LOOP_ONSCREEN);
let mut last_run_tick: Option<u64> = None;
let mut max_gap = 0u64;
let mut total_runs = 0usize;
for i in 0..600u64 {
let now = FrameTime::from_nanos(i * tick_dur.as_nanos() as u64);
let (decision, next) = tick(&mut gate, Frame::PACED_LOOP_ONSCREEN, now, pace, prev);
prev = next;
if decision.is_run() {
total_runs += 1;
if let Some(last) = last_run_tick {
max_gap = max_gap.max(i - last);
}
last_run_tick = Some(i);
}
}
assert!(
total_runs > 0,
"a paced-only stream must keep producing frames"
);
assert!(
max_gap <= 5,
"paced runs must stay periodic; observed max gap of {max_gap} ticks"
);
}
#[test]
fn an_input_event_mid_paced_interval_runs_immediately() {
let tick_dur = tick_hz();
let pace = cap_interval();
let mut gate = FrameGate::with_flags(true, true);
let (_, outcome) = tick(
&mut gate,
Frame::PACED_LOOP_ONSCREEN,
FrameTime::from_nanos(0),
pace,
seeded(Frame::PACED_LOOP_ONSCREEN),
);
let (decision, _) = tick(
&mut gate,
Frame::INPUT_EVENT,
FrameTime::from_nanos(tick_dur.as_nanos() as u64),
pace,
outcome,
);
assert_eq!(
decision,
FrameDecision::Run,
"an input event mid-interval must run immediately, never paced"
);
}
#[test]
fn a_signal_write_mid_paced_interval_runs_immediately() {
let tick_dur = tick_hz();
let pace = cap_interval();
let mut gate = FrameGate::with_flags(true, true);
let (_, outcome) = tick(
&mut gate,
Frame::PACED_LOOP_ONSCREEN,
FrameTime::from_nanos(0),
pace,
seeded(Frame::PACED_LOOP_ONSCREEN),
);
let (decision, _) = tick(
&mut gate,
Frame::SIGNAL_WRITE,
FrameTime::from_nanos(tick_dur.as_nanos() as u64),
pace,
outcome,
);
assert_eq!(
decision,
FrameDecision::Run,
"a signal write mid-interval must run immediately, never paced"
);
}