First commit. Version 0.1.0
This commit is contained in:
336
tests/animation.rs
Normal file
336
tests/animation.rs
Normal file
@@ -0,0 +1,336 @@
|
||||
// Animation lifecycle coverage. The runtime polls `App::is_animating()` once
|
||||
// per frame and, while it returns `true`, redraws at ~60 Hz reading
|
||||
// `Instant::now()` against a stored start time. These tests exercise that
|
||||
// lifecycle, the swipe-progress callbacks and the `take_focus_request` /
|
||||
// `on_text_input_focused` hooks against an `App` impl — without the Wayland
|
||||
// loop, but with the same trait the loop drives.
|
||||
|
||||
use std::time::{ Duration, Instant };
|
||||
|
||||
use ltk::test_support::WidgetHandlers;
|
||||
use ltk::core::{ RenderOptions, UiSurface };
|
||||
use ltk::{
|
||||
column, text, App, Color, Element, WidgetId,
|
||||
};
|
||||
|
||||
const TWEEN: Duration = Duration::from_millis( 200 );
|
||||
|
||||
#[ derive( Clone, Debug, PartialEq ) ]
|
||||
enum Msg
|
||||
{
|
||||
StartFade,
|
||||
FadeDone,
|
||||
}
|
||||
|
||||
struct Animator
|
||||
{
|
||||
tween_start: Option<Instant>,
|
||||
swipe_progress: f32,
|
||||
swipe_h: f32,
|
||||
tap_count: u32,
|
||||
drag_xy: Option<( f32, f32 )>,
|
||||
drop_xy: Option<( f32, f32 )>,
|
||||
focus_request: Option<WidgetId>,
|
||||
ime_active: bool,
|
||||
}
|
||||
|
||||
impl Animator
|
||||
{
|
||||
fn new() -> Self
|
||||
{
|
||||
Self
|
||||
{
|
||||
tween_start: None,
|
||||
swipe_progress: 0.0,
|
||||
swipe_h: 0.0,
|
||||
tap_count: 0,
|
||||
drag_xy: None,
|
||||
drop_xy: None,
|
||||
focus_request: None,
|
||||
ime_active: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn tween_progress( &self ) -> f32
|
||||
{
|
||||
match self.tween_start
|
||||
{
|
||||
Some( t ) => ( t.elapsed().as_secs_f32() / TWEEN.as_secs_f32() ).clamp( 0.0, 1.0 ),
|
||||
None => 1.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl App for Animator
|
||||
{
|
||||
type Message = Msg;
|
||||
|
||||
fn view( &self ) -> Element<Msg>
|
||||
{
|
||||
column::<Msg>()
|
||||
.padding( 16.0 )
|
||||
.push( text( format!( "progress = {:.2}", self.tween_progress() ) ) )
|
||||
.into()
|
||||
}
|
||||
|
||||
fn update( &mut self, msg: Msg )
|
||||
{
|
||||
match msg
|
||||
{
|
||||
Msg::StartFade => self.tween_start = Some( Instant::now() ),
|
||||
Msg::FadeDone => self.tween_start = None,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_animating( &self ) -> bool
|
||||
{
|
||||
self.tween_start.is_some()
|
||||
}
|
||||
|
||||
fn poll_external( &mut self ) -> Vec<Msg>
|
||||
{
|
||||
// End-of-animation cleanup belongs here per the architecture doc:
|
||||
// when the tween completes, clear the start instant so is_animating
|
||||
// goes back to false and the runtime sleeps again.
|
||||
if let Some( t ) = self.tween_start
|
||||
{
|
||||
if t.elapsed() >= TWEEN
|
||||
{
|
||||
self.tween_start = None;
|
||||
}
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
|
||||
fn on_swipe_progress( &mut self, progress: f32 )
|
||||
{
|
||||
self.swipe_progress = progress;
|
||||
}
|
||||
|
||||
fn on_swipe_horizontal_progress( &mut self, progress: f32 )
|
||||
{
|
||||
self.swipe_h = progress;
|
||||
}
|
||||
|
||||
fn on_tap( &mut self ) -> Option<Msg>
|
||||
{
|
||||
self.tap_count += 1;
|
||||
None
|
||||
}
|
||||
|
||||
fn on_drag_move( &mut self, x: f32, y: f32 )
|
||||
{
|
||||
self.drag_xy = Some( ( x, y ) );
|
||||
}
|
||||
|
||||
fn on_drop( &mut self, x: f32, y: f32 ) -> Option<Msg>
|
||||
{
|
||||
self.drop_xy = Some( ( x, y ) );
|
||||
None
|
||||
}
|
||||
|
||||
fn take_focus_request( &mut self ) -> Option<WidgetId>
|
||||
{
|
||||
self.focus_request.take()
|
||||
}
|
||||
|
||||
fn on_text_input_focused( &mut self, active: bool )
|
||||
{
|
||||
self.ime_active = active;
|
||||
}
|
||||
}
|
||||
|
||||
// ── is_animating lifecycle ────────────────────────────────────────────────────
|
||||
|
||||
#[ test ]
|
||||
fn fresh_animator_is_not_animating()
|
||||
{
|
||||
let app = Animator::new();
|
||||
assert!( !app.is_animating() );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn starting_a_tween_flips_is_animating_to_true()
|
||||
{
|
||||
let mut app = Animator::new();
|
||||
app.update( Msg::StartFade );
|
||||
assert!( app.is_animating() );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn explicit_fade_done_message_settles_animation()
|
||||
{
|
||||
let mut app = Animator::new();
|
||||
app.update( Msg::StartFade );
|
||||
app.update( Msg::FadeDone );
|
||||
assert!( !app.is_animating() );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn poll_external_clears_tween_after_duration_elapsed()
|
||||
{
|
||||
let mut app = Animator::new();
|
||||
// Set tween_start to a moment far in the past so the elapsed > TWEEN
|
||||
// branch in poll_external runs immediately. Avoids `thread::sleep`.
|
||||
app.tween_start = Some( Instant::now() - Duration::from_secs( 10 ) );
|
||||
assert!( app.is_animating() );
|
||||
|
||||
let drained = app.poll_external();
|
||||
assert!( drained.is_empty() );
|
||||
assert!( !app.is_animating(), "elapsed > TWEEN must clear the tween via poll_external" );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn poll_external_keeps_tween_alive_within_duration()
|
||||
{
|
||||
let mut app = Animator::new();
|
||||
app.tween_start = Some( Instant::now() );
|
||||
let _ = app.poll_external();
|
||||
assert!( app.is_animating(), "fresh tween must not be cleared by poll_external" );
|
||||
}
|
||||
|
||||
// ── tween progress reading ────────────────────────────────────────────────────
|
||||
|
||||
#[ test ]
|
||||
fn tween_progress_reads_one_when_no_animation_in_flight()
|
||||
{
|
||||
let app = Animator::new();
|
||||
assert_eq!( app.tween_progress(), 1.0 );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn tween_progress_clamps_to_unit_interval()
|
||||
{
|
||||
let mut app = Animator::new();
|
||||
app.tween_start = Some( Instant::now() - Duration::from_secs( 60 ) );
|
||||
let p = app.tween_progress();
|
||||
assert!( ( 0.0..=1.0 ).contains( &p ), "progress {p} should clamp to [0,1]" );
|
||||
assert!( ( p - 1.0 ).abs() < f32::EPSILON );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn tween_progress_starts_close_to_zero_just_after_start()
|
||||
{
|
||||
let mut app = Animator::new();
|
||||
app.tween_start = Some( Instant::now() );
|
||||
let p = app.tween_progress();
|
||||
assert!( p < 0.1, "progress {p} should be near 0.0 at the start of the tween" );
|
||||
}
|
||||
|
||||
// ── swipe / tap / drag / drop callbacks ───────────────────────────────────────
|
||||
|
||||
#[ test ]
|
||||
fn on_swipe_progress_records_value_per_axis()
|
||||
{
|
||||
let mut app = Animator::new();
|
||||
app.on_swipe_progress( 0.7 );
|
||||
app.on_swipe_horizontal_progress( -0.4 );
|
||||
assert_eq!( app.swipe_progress, 0.7 );
|
||||
assert_eq!( app.swipe_h, -0.4 );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn on_tap_increments_counter()
|
||||
{
|
||||
let mut app = Animator::new();
|
||||
app.on_tap();
|
||||
app.on_tap();
|
||||
app.on_tap();
|
||||
assert_eq!( app.tap_count, 3 );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn on_drag_move_and_on_drop_record_coordinates()
|
||||
{
|
||||
let mut app = Animator::new();
|
||||
app.on_drag_move( 12.0, 34.0 );
|
||||
app.on_drop( 56.0, 78.0 );
|
||||
assert_eq!( app.drag_xy, Some( ( 12.0, 34.0 ) ) );
|
||||
assert_eq!( app.drop_xy, Some( ( 56.0, 78.0 ) ) );
|
||||
}
|
||||
|
||||
// ── focus / IME hooks ─────────────────────────────────────────────────────────
|
||||
|
||||
#[ test ]
|
||||
fn take_focus_request_returns_some_once_then_none()
|
||||
{
|
||||
let mut app = Animator::new();
|
||||
app.focus_request = Some( WidgetId( "username" ) );
|
||||
|
||||
let first = app.take_focus_request();
|
||||
let second = app.take_focus_request();
|
||||
|
||||
assert_eq!( first, Some( WidgetId( "username" ) ) );
|
||||
assert_eq!( second, None, "take_focus_request must consume the slot" );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn on_text_input_focused_records_active_state()
|
||||
{
|
||||
let mut app = Animator::new();
|
||||
assert!( !app.ime_active );
|
||||
app.on_text_input_focused( true );
|
||||
assert!( app.ime_active );
|
||||
app.on_text_input_focused( false );
|
||||
assert!( !app.ime_active );
|
||||
}
|
||||
|
||||
// ── swipe_threshold / long_press_duration defaults ────────────────────────────
|
||||
|
||||
#[ test ]
|
||||
fn swipe_and_long_press_defaults_match_documented_constants()
|
||||
{
|
||||
let app = Animator::new();
|
||||
// `0.6` (60 % of surface height for a vertical swipe) and 500 ms are
|
||||
// the documented defaults — apps override only when they need a hair
|
||||
// trigger or a longer press.
|
||||
assert!( ( app.swipe_threshold() - 0.6 ).abs() < 1e-6 );
|
||||
assert_eq!( app.long_press_duration(), Duration::from_millis( 500 ) );
|
||||
}
|
||||
|
||||
// ── animation + UiSurface interplay ───────────────────────────────────────────
|
||||
|
||||
#[ test ]
|
||||
fn animator_renders_inside_uisurface_without_wayland_loop()
|
||||
{
|
||||
// Prove that an App with an active animation can drive UiSurface.render
|
||||
// directly — important for compositor embedders that don't run
|
||||
// `ltk::run` but still want the same animation contract.
|
||||
let mut surface = UiSurface::<Msg>::new( 320, 80 );
|
||||
let mut app = Animator::new();
|
||||
app.update( Msg::StartFade );
|
||||
assert!( app.is_animating() );
|
||||
|
||||
let opts = RenderOptions::full_canvas( 320, 80 ).background( Color::rgb( 0.0, 0.0, 0.0 ) );
|
||||
let _ = surface.render( &app.view(), opts );
|
||||
// Snapshot the text widget — it isn't focusable / interactive, so the
|
||||
// surface's widget_rects should be empty.
|
||||
assert!( surface.widget_rects().is_empty() );
|
||||
|
||||
// Pretend a frame went by; force re-layout so the new tween_progress
|
||||
// percolates into the view.
|
||||
surface.mark_content_dirty();
|
||||
let _ = surface.render( &app.view(), opts );
|
||||
|
||||
// Animation eventually settles via poll_external.
|
||||
app.tween_start = Some( Instant::now() - Duration::from_secs( 5 ) );
|
||||
let _ = app.poll_external();
|
||||
assert!( !app.is_animating() );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn handlers_match_default_for_text_only_animator_view()
|
||||
{
|
||||
// Defensive: the animator's view is text-only, so widget_rects is empty
|
||||
// and any handlers lookup returns None. Regression guard against the
|
||||
// runtime accidentally treating animation-driven views as interactive.
|
||||
let mut surface = UiSurface::<Msg>::new( 320, 80 );
|
||||
let app = Animator::new();
|
||||
let _ = surface.render(
|
||||
&app.view(),
|
||||
RenderOptions::full_canvas( 320, 80 ),
|
||||
);
|
||||
assert!( surface.widget_rects().is_empty() );
|
||||
assert!( surface.handlers( 0 ).is_none() );
|
||||
let _: Option<&WidgetHandlers<Msg>> = surface.handlers( 999 );
|
||||
}
|
||||
Reference in New Issue
Block a user