Files
codex/codex-rs/tui/src/empty_state_animation_tests.rs
T
Felipe Coury d6f25a54ba Fade blossom replays back to the idle state (#48547)
## Why

Clicking the welcome blossom replays its animation, but completion abruptly switches from full color to the dim idle frame.

## What changed

Fade the blossom back to idle opacity over 400 ms after the replay finishes spinning. Keep scheduling redraws until the fade completes, then clear the replay state.

## Testing

Extend the replay test with color snapshots at the start, midpoint, and end of the fade, and assertions that redraws stop and the original idle frame is restored.

GitOrigin-RevId: ec3fafb6766533d9c221913e8a3be03b8a8036f7
2026-09-26 21:57:55 +00:00

466 lines
15 KiB
Rust

//! Logo visibility, theme contrast, placement, and redraw lifecycle tests.
use super::*;
use crossterm::event::KeyModifiers;
use pretty_assertions::assert_eq;
#[test]
fn first_screen_stage_ignores_notices_and_hides_instead_of_shrinking() {
let mut animation = EmptyStateAnimation::default();
animation.start_fresh();
let screen = Rect::new(
/*x*/ 7, /*y*/ 3, /*width*/ 80, /*height*/ 44,
);
let mut buffer = Buffer::empty(screen);
for header_rows in [5, 7, 11] {
buffer.reset();
let free = Rect::new(
screen.x,
screen.y + header_rows,
screen.width,
screen.height - header_rows - 3,
);
assert_eq!(
animation.render_first_screen(
free,
&mut buffer,
Some(ComposerState::Empty),
MotionMode::Animated
),
None
);
assert_eq!(
animation.stage,
Some(Rect::new(
/*x*/ 17, /*y*/ 14, /*width*/ 60, /*height*/ 21
))
);
}
// Content which reaches the center must win; it must not move the stage down.
let free = Rect::new(screen.x, /*y*/ 20, screen.width, /*height*/ 24);
animation.render_first_screen(
free,
&mut buffer,
Some(ComposerState::Empty),
MotionMode::Animated,
);
assert_eq!(animation.stage, None);
// Both sides of the minimum size, and a terminal that is too short for the full logo.
for (width, height, expected) in [(44, 36, true), (43, 36, false), (80, 20, false)] {
let screen = Rect::new(/*x*/ 7, /*y*/ 3, width, height);
let mut buffer = Buffer::empty(screen);
animation.render_first_screen(
screen,
&mut buffer,
Some(ComposerState::Empty),
MotionMode::Animated,
);
assert_eq!(animation.stage.is_some(), expected);
}
}
#[test]
fn first_screen_single_click_replays_the_visible_blossom() {
let mut animation = EmptyStateAnimation::default();
animation.start_fresh();
let screen = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 44,
);
let mut buffer = Buffer::empty(screen);
let click = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 40,
row: 22,
modifiers: KeyModifiers::NONE,
};
let draw = |animation: &mut EmptyStateAnimation, buffer: &mut Buffer, composer| {
buffer.reset();
animation.render_first_screen(screen, buffer, composer, MotionMode::Animated)
};
assert_eq!(
draw(&mut animation, &mut buffer, Some(ComposerState::Empty)),
None
);
let static_frame = buffer.clone();
for other in [
MouseEvent {
column: 0,
row: 0,
..click
},
MouseEvent {
modifiers: KeyModifiers::CONTROL,
..click
},
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Right),
..click
},
] {
assert!(!animation.handle_mouse(other));
}
assert_eq!(
draw(&mut animation, &mut buffer, Some(ComposerState::Empty)),
None
);
assert_eq!(buffer, static_frame);
assert!(animation.handle_mouse(click));
// Release belongs to the transcript if it started a selection outside the logo.
assert!(!animation.handle_mouse(MouseEvent {
kind: MouseEventKind::Up(MouseButton::Left),
..click
}));
assert_eq!(
draw(&mut animation, &mut buffer, Some(ComposerState::Empty)),
Some(FRAME_INTERVAL)
);
assert_eq!(buffer, static_frame);
let started = animation.last_frame.unwrap();
let stage = animation.stage.unwrap();
buffer.reset();
assert_eq!(
animation.render_in_at(
stage,
&mut buffer,
Presentation::Animated,
started + sequence::STATIC_FADE,
),
Some(FRAME_INTERVAL)
);
assert_ne!(buffer, static_frame);
assert!(animation.handle_mouse(click));
assert_eq!(
draw(&mut animation, &mut buffer, Some(ComposerState::Empty)),
Some(FRAME_INTERVAL)
);
assert_eq!(buffer, static_frame);
let finished_at =
animation.last_frame.unwrap() + sequence::SPIN_DURATION - animation.spin_elapsed;
let mut fade_frames = Vec::new();
for elapsed in [
Duration::ZERO,
sequence::STATIC_FADE / 2,
sequence::STATIC_FADE,
] {
buffer.reset();
crate::terminal_palette::with_test_default_colors(
crate::terminal_probe::DefaultColors {
fg: (210, 221, 235),
bg: (15, 20, 37),
},
|| {
assert_eq!(
animation.render_in_at(
stage,
&mut buffer,
Presentation::Animated,
finished_at + elapsed,
),
(elapsed < sequence::STATIC_FADE).then_some(FRAME_INTERVAL)
);
},
);
let painted = buffer
.content
.iter()
.find(|cell| cell.symbol() != " ")
.unwrap();
fade_frames.push(format!(
"{}ms: {} {:?}",
elapsed.as_millis(),
painted.symbol(),
painted.fg,
));
}
insta::assert_snapshot!("first_screen_replay_fade", fade_frames.join("\n"));
assert_eq!(
draw(&mut animation, &mut buffer, Some(ComposerState::Empty)),
None
);
assert_eq!(buffer, static_frame);
assert!(!animation.replaying);
assert!(animation.handle_mouse(click));
assert_eq!(
draw(&mut animation, &mut buffer, Some(ComposerState::Draft)),
None
);
assert!(!animation.handle_mouse(click));
assert_eq!(
draw(&mut animation, &mut buffer, Some(ComposerState::Empty)),
None
);
assert_eq!(buffer, static_frame);
// Disabling motion removes both the logo and its click target.
buffer.reset();
animation.render_first_screen(
screen,
&mut buffer,
Some(ComposerState::Empty),
MotionMode::Reduced,
);
assert_eq!(buffer, Buffer::empty(screen));
assert!(!animation.handle_mouse(click));
}
#[test]
fn loop_closes_and_light_themes_preserve_coverage() {
let mut renderer = Renderer::default();
let dark = Lighting::terminal(/*fg*/ (210, 221, 235), /*bg*/ (15, 20, 37));
let first = renderer
.frame(
/*columns*/ 60, /*rows*/ 21, /*phase*/ 0.0, &dark,
)
.to_vec();
assert_eq!(
renderer.frame(
/*columns*/ 60, /*rows*/ 21, /*phase*/ 1.0, &dark
),
first
);
let other_mark = renderer.frame(
/*columns*/ 60, /*rows*/ 21, /*phase*/ 0.5, &dark,
);
assert!(first.iter().any(|cell| cell.dots != 0));
assert!(other_mark.iter().any(|cell| cell.dots != 0));
assert_ne!(other_mark, first);
let light = Lighting::terminal(/*fg*/ (32, 32, 32), /*bg*/ (250, 250, 250));
let light_frame = renderer.frame(
/*columns*/ 60, /*rows*/ 21, /*phase*/ 0.0, &light,
);
assert_eq!(
light_frame.iter().map(|cell| cell.dots).collect::<Vec<_>>(),
first.iter().map(|cell| cell.dots).collect::<Vec<_>>()
);
for cell in light_frame.iter().filter(|cell| cell.dots != 0) {
let [_, r, g, b] = cell.rgb.to_be_bytes();
assert!(
r < 200 && g < 200 && b < 200,
"light-theme ink must contrast with the background"
);
}
}
#[test]
fn faded_presentation_settles_without_redraws_then_resumes() {
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 48, /*height*/ 17,
);
let start = Instant::now();
for elapsed in [
Duration::from_secs(/*secs*/ 2),
sequence::SPIN_DURATION - FRAME_INTERVAL * 2,
] {
let mut animation = EmptyStateAnimation::default();
animation.start_fresh();
animation.spin_elapsed = elapsed;
let mut buffer = Buffer::empty(area);
animation.render_in_at(area, &mut buffer, Presentation::Animated, start);
let moving = buffer.clone();
buffer.reset();
assert_eq!(
animation.render_in_at(
area,
&mut buffer,
Presentation::Faded,
start + FRAME_INTERVAL,
),
Some(FRAME_INTERVAL)
);
assert_eq!(buffer, moving);
let settled_at = start + FRAME_INTERVAL + sequence::STATIC_FADE;
buffer.reset();
assert_eq!(
animation.render_in_at(area, &mut buffer, Presentation::Faded, settled_at,),
None
);
assert!(buffer.content.iter().any(|cell| cell.symbol() != " "));
// Missing OSC colors must preserve the terminal's readable default foreground.
if terminal_palette::default_bg().is_none() {
assert!(
buffer
.content
.iter()
.filter(|cell| cell.symbol() != " ")
.all(|cell| cell.fg == ratatui::style::Color::Reset)
);
}
let settled = buffer.clone();
buffer.reset();
assert_eq!(
animation.render_in_at(
area,
&mut buffer,
Presentation::Faded,
settled_at + Duration::from_secs(/*secs*/ 60),
),
None
);
assert_eq!(buffer, settled);
buffer.reset();
assert_eq!(
animation.render_in_at(
area,
&mut buffer,
Presentation::Animated,
settled_at + Duration::from_secs(/*secs*/ 61),
),
Some(FRAME_INTERVAL)
);
assert_eq!(buffer, settled);
assert_eq!(animation.spin_elapsed, elapsed);
buffer.reset();
assert_eq!(
animation.render_in_at(
area,
&mut buffer,
Presentation::Animated,
settled_at + Duration::from_secs(/*secs*/ 61) + FRAME_INTERVAL,
),
Some(FRAME_INTERVAL)
);
assert_ne!(animation.opacity, sequence::STATIC_OPACITY);
}
}
#[test]
fn skipped_frames_preserve_spin_until_visible_time_resumes() {
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 48, /*height*/ 17,
);
let start = Instant::now();
// The same pause is used before editor handoff and after a job-control resume.
// Calling it after an arbitrarily long interruption must still stop at the last frame.
let mut animation = EmptyStateAnimation::default();
animation.start_fresh();
let mut buffer = Buffer::empty(area);
animation.render_in_at(area, &mut buffer, Presentation::Animated, start);
let visible = Duration::from_millis(/*millis*/ 150);
buffer.reset();
animation.render_in_at(area, &mut buffer, Presentation::Animated, start + visible);
let before = buffer.clone();
animation.pause_clock();
let resumed = start + Duration::from_secs(/*secs*/ 3600);
buffer.reset();
assert_eq!(
animation.render_in_at(area, &mut buffer, Presentation::Animated, resumed),
Some(FRAME_INTERVAL)
);
assert_eq!(buffer, before);
assert_eq!(animation.spin_elapsed, visible);
buffer.reset();
animation.render_in_at(
area,
&mut buffer,
Presentation::Animated,
resumed + FRAME_INTERVAL,
);
assert_eq!(animation.spin_elapsed, visible + FRAME_INTERVAL);
}
#[test]
fn hidden_and_clipped_frames_preserve_spin_budget_without_restarting() {
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 48, /*height*/ 17,
);
let start = Instant::now();
for hidden_area in [area, Rect::default()] {
let mut animation = EmptyStateAnimation::default();
animation.start_fresh();
animation.spin_elapsed = sequence::SPIN_DURATION - FRAME_INTERVAL;
let mut buffer = Buffer::empty(area);
animation.render_in_at(area, &mut buffer, Presentation::Animated, start);
let before = buffer.clone();
buffer.reset();
let hidden = if hidden_area.is_empty() {
Presentation::Animated
} else {
Presentation::Hidden
};
assert_eq!(
animation.render_in_at(
hidden_area,
&mut buffer,
hidden,
start + Duration::from_secs(/*secs*/ 20)
),
None
);
assert_eq!(buffer, Buffer::empty(area));
let resumed = start + Duration::from_secs(/*secs*/ 40);
animation.render_in_at(area, &mut buffer, Presentation::Animated, resumed);
assert_eq!(buffer, before);
buffer.reset();
assert_eq!(
animation.render_in_at(
area,
&mut buffer,
Presentation::Animated,
resumed + FRAME_INTERVAL
),
None
);
assert!(buffer.content.iter().any(|cell| cell.symbol() != " "));
assert_eq!(animation.opacity, 1.0);
animation.pause_clock();
}
}
#[test]
fn onboarding_keeps_full_color_after_budget_and_pause() {
crate::terminal_palette::with_test_default_colors(
crate::terminal_probe::DefaultColors {
fg: (210, 221, 235),
bg: (15, 20, 37),
},
|| {
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 48, /*height*/ 17,
);
let start = Instant::now();
let mut animation = EmptyStateAnimation::default();
animation.start_fresh();
let mut buffer = Buffer::empty(area);
animation.render_in_at(area, &mut buffer, Presentation::Animated, start);
buffer.reset();
assert_eq!(
animation.render_in_at(
area,
&mut buffer,
Presentation::Animated,
start + sequence::SPIN_DURATION
),
None
);
assert_eq!(animation.opacity, 1.0);
assert!(buffer.content.iter().any(|cell| cell.symbol() != " "));
insta::assert_snapshot!(
"onboarding_settled_logo",
buffer
.content
.chunks(usize::from(area.width))
.map(|row| row
.iter()
.map(ratatui::buffer::Cell::symbol)
.collect::<String>()
.trim_end()
.to_owned())
.collect::<Vec<_>>()
.join("\n")
);
let settled = buffer.clone();
animation.pause_clock();
buffer.reset();
assert_eq!(
animation.render_in_at(
area,
&mut buffer,
Presentation::Faded,
start + Duration::from_secs(/*secs*/ 3600)
),
None
);
assert_eq!(buffer, settled);
},
);
}