use super::*;
use crate::base::Rect;
use crate::input::{KeyCode, KeyEvent, Mods};
use crate::theme::default_theme;
use std::cell::RefCell;
use std::rc::Rc;
struct VClock {
times: Vec<u64>,
i: usize,
}
impl VClock {
fn new(times: Vec<u64>) -> Self {
VClock { times, i: 0 }
}
fn tick(&mut self) -> u64 {
let v = *self.times.get(self.i).or(self.times.last()).unwrap_or(&0);
self.i += 1;
v
}
}
struct RecordingSource {
surface: Surface,
ts: Rc<RefCell<Vec<f32>>>,
}
impl RecordingSource {
fn new(size: Size, ts: Rc<RefCell<Vec<f32>>>) -> Self {
RecordingSource {
surface: Surface::new(size, Cell::EMPTY),
ts,
}
}
}
impl SplashFrameSource for RecordingSource {
fn render(&mut self, t: f32, size: Size, _theme: &Theme) -> &Surface {
if self.surface.size() != size {
self.surface = Surface::new(size, Cell::EMPTY);
}
self.ts.borrow_mut().push(t);
&self.surface
}
}
struct ScriptIo {
size: Size,
presents: usize,
waits: Vec<SplashWait>, }
impl ScriptIo {
fn new(size: Size, waits: Vec<SplashWait>) -> Self {
ScriptIo {
size,
presents: 0,
waits,
}
}
}
impl SplashIo for ScriptIo {
fn size(&mut self) -> Result<Size> {
Ok(self.size)
}
fn present(&mut self, _bytes: &[u8]) -> Result<()> {
self.presents += 1;
Ok(())
}
fn wait(&mut self, _budget_ms: u64) -> Result<SplashWait> {
if self.waits.is_empty() {
Ok(SplashWait::Idle)
} else {
Ok(self.waits.remove(0))
}
}
}
fn key(ch: char) -> Event {
Event::Key(KeyEvent::new(KeyCode::Char(ch), Mods::NONE))
}
fn opts() -> SplashOptions {
SplashOptions {
fps: 25,
total_ms: 2000,
hard_cutoff_ms: 2500,
fade_ms: 120,
}
}
#[test]
fn wall_clock_pacing_drops_frames_when_the_clock_jumps() {
let ts = Rc::new(RefCell::new(Vec::new()));
let mut source = RecordingSource::new(Size::new(20, 6), ts.clone());
let mut io = ScriptIo::new(Size::new(20, 6), vec![]);
let mut clock = VClock::new(vec![0, 0, 40, 500, 540, 1000, 1040, 1500, 1540, 2000]);
let mut now = move || clock.tick();
let out = play(
&mut io,
&mut source,
default_theme(),
&PresentCaps::FULL,
&opts(),
&mut now,
)
.expect("play");
assert_eq!(out, SplashOutcome::Completed);
let ts = ts.borrow();
assert_eq!(io.presents, ts.len(), "one present per rendered frame");
assert!(
ts.len() <= 5,
"dropped pacing must render ~one frame per clock leap, got {ts:?}"
);
assert!((ts[0] - 0.0).abs() < 1e-3);
assert!(
ts.windows(2).all(|w| w[1] > w[0]),
"t must be monotonic: {ts:?}"
);
assert!(
(ts.last().unwrap() - 1.5).abs() < 0.06,
"last frame near 1.5 s: {ts:?}"
);
}
#[test]
fn deliberate_event_skips_with_fade_then_ends() {
let ts = Rc::new(RefCell::new(Vec::new()));
let mut source = RecordingSource::new(Size::new(20, 6), ts.clone());
let mut io = ScriptIo::new(Size::new(20, 6), vec![SplashWait::Events(vec![key(' ')])]);
let mut clock = VClock::new(vec![0, 0, 40, 80, 100, 140, 160, 180, 210]);
let mut now = move || clock.tick();
let out = play(
&mut io,
&mut source,
default_theme(),
&PresentCaps::FULL,
&opts(),
&mut now,
)
.expect("play");
assert_eq!(out, SplashOutcome::Skipped);
assert!(
io.presents >= 2,
"the fade must present at least one post-skip frame (got {})",
io.presents
);
assert!(ts.borrow().iter().all(|t| *t < 0.5), "{:?}", ts.borrow());
}
#[test]
fn second_deliberate_event_cuts_the_fade_immediately() {
let ts = Rc::new(RefCell::new(Vec::new()));
let mut source = RecordingSource::new(Size::new(20, 6), ts.clone());
let mut io = ScriptIo::new(
Size::new(20, 6),
vec![
SplashWait::Events(vec![key(' ')]),
SplashWait::Events(vec![key(' ')]),
],
);
let mut clock = VClock::new(vec![0, 0, 40, 60, 70, 90]);
let mut now = move || clock.tick();
let out = play(
&mut io,
&mut source,
default_theme(),
&PresentCaps::FULL,
&opts(),
&mut now,
)
.expect("play");
assert_eq!(out, SplashOutcome::Skipped);
assert!(
io.presents <= 2,
"cut must not keep fading (presents={})",
io.presents
);
}
#[test]
fn hard_cutoff_fires_unconditionally() {
let ts = Rc::new(RefCell::new(Vec::new()));
let mut source = RecordingSource::new(Size::new(20, 6), ts.clone());
let mut io = ScriptIo::new(Size::new(20, 6), vec![]);
let mut clock = VClock::new(vec![0, 0, 900, 1800, 1900, 2700]);
let mut now = move || clock.tick();
let out = play(
&mut io,
&mut source,
default_theme(),
&PresentCaps::FULL,
&opts(),
&mut now,
)
.expect("play");
assert_eq!(out, SplashOutcome::CutOff);
}
#[test]
fn probe_replies_and_focus_never_skip() {
let ts = Rc::new(RefCell::new(Vec::new()));
let mut source = RecordingSource::new(Size::new(20, 6), ts.clone());
let mut io = ScriptIo::new(
Size::new(20, 6),
vec![
SplashWait::Events(vec![Event::FocusGained]),
SplashWait::Events(vec![Event::Key(KeyEvent {
kind: KeyEventKind::Release,
..KeyEvent::new(KeyCode::Char('x'), Mods::NONE)
})]),
],
);
let mut clock = VClock::new(vec![0, 0, 40, 500, 540, 1000, 1040, 1500, 1540, 2000]);
let mut now = move || clock.tick();
let out = play(
&mut io,
&mut source,
default_theme(),
&PresentCaps::FULL,
&opts(),
&mut now,
)
.expect("play");
assert_eq!(
out,
SplashOutcome::Completed,
"non-deliberate events must not skip"
);
}
#[test]
fn resize_recreates_the_diff_baseline() {
let ts = Rc::new(RefCell::new(Vec::new()));
let mut source = RecordingSource::new(Size::new(20, 6), ts.clone());
let mut io = ScriptIo::new(Size::new(20, 6), vec![SplashWait::Resize(Size::new(30, 8))]);
let mut clock = VClock::new(vec![0, 0, 40, 500, 540, 1000, 1040, 1500, 1540, 2000]);
let mut now = move || clock.tick();
let out = play(
&mut io,
&mut source,
default_theme(),
&PresentCaps::FULL,
&opts(),
&mut now,
)
.expect("play");
assert_eq!(out, SplashOutcome::Completed);
assert_eq!(source.surface.size(), Size::new(30, 8));
}
#[test]
fn gate_reasons() {
let none = |_: &str| None::<String>;
assert_eq!(
skip_reason(false, &none),
Some("render handle is not a tty")
);
assert!(skip_reason(true, &none).is_none());
let no_splash = |k: &str| (k == identity::SPLASH_DISABLE_ENV).then(|| "1".to_string());
assert!(skip_reason(true, &no_splash).is_some());
let no_splash_zero = |k: &str| (k == identity::SPLASH_DISABLE_ENV).then(|| "0".to_string());
assert!(
skip_reason(true, &no_splash_zero).is_none(),
"'0' opts back in"
);
let no_color = |k: &str| (k == "NO_COLOR").then(|| "1".to_string());
assert!(skip_reason(true, &no_color).is_some());
let dumb = |k: &str| (k == "TERM").then(|| "dumb".to_string());
assert!(skip_reason(true, &dumb).is_some());
let smart = |k: &str| (k == "TERM").then(|| "xterm-256color".to_string());
assert!(skip_reason(true, &smart).is_none());
}
#[test]
fn fade_dims_cells_toward_ground() {
let theme = default_theme();
let mut frame = Surface::new(Size::new(4, 1), Cell::EMPTY);
frame.fill_rect(
Rect::new(0, 0, 4, 1),
Cell::new(crate::render::Glyph::SPACE)
.with_fg(theme.tokens.text)
.with_bg(theme.tokens.accent),
);
let mut buf = Surface::new(Size::new(4, 1), Cell::EMPTY);
fade_toward_ground(&mut buf, &frame, theme, 1.0);
let cell = buf.get(0, 0).unwrap();
assert_eq!(
cell.fg, theme.tokens.bg,
"full fade lands exactly on the ground"
);
assert_eq!(cell.bg, theme.tokens.bg);
fade_toward_ground(&mut buf, &frame, theme, 0.5);
let cell = buf.get(0, 0).unwrap();
assert_ne!(cell.bg, theme.tokens.bg);
assert_ne!(cell.bg, theme.tokens.accent);
}