mirror of
https://github.com/openai/codex.git
synced 2026-10-28 18:58:46 +00:00
## 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
466 lines
15 KiB
Rust
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);
|
|
},
|
|
);
|
|
}
|