Files
ltk/tests/event_loop_flow.rs

284 lines
8.3 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// End-to-end coverage for the runtime contract: `Msg → App::update → next
// view → render`. The Wayland event loop in `ltk::run` is the integration
// point that ties widget-level handler snapshots, focus traversal and keysym
// dispatch together; these tests exercise the same wiring against `UiSurface`
// (the runtime-free embedding of that loop).
use ltk::core::{ RenderOptions, UiSurface };
use ltk::test_support::next_focusable_index;
use ltk::{
button, column, text, App, Color, Element, Keysym,
};
// ── A small counter app ───────────────────────────────────────────────────────
#[ derive( Clone, Debug, PartialEq, Eq ) ]
enum Msg
{
Inc,
Dec,
Reset,
Quit,
}
struct Counter
{
value: i32,
pending: Vec<Msg>,
quit: bool,
}
impl Counter
{
fn new() -> Self
{
Self { value: 0, pending: vec![], quit: false }
}
}
impl App for Counter
{
type Message = Msg;
fn view( &self ) -> Element<Msg>
{
column::<Msg>()
.padding( 16.0 )
.spacing( 8.0 )
.push( text( format!( "{}", self.value ) ) )
.push( button( "+" ).on_press( Msg::Inc ) )
.push( button( "" ).on_press( Msg::Dec ) )
.push( button( "reset" ).on_press( Msg::Reset ) )
.into()
}
fn update( &mut self, msg: Msg )
{
match msg
{
Msg::Inc => self.value += 1,
Msg::Dec => self.value -= 1,
Msg::Reset => self.value = 0,
Msg::Quit => self.quit = true,
}
}
fn poll_external( &mut self ) -> Vec<Msg>
{
std::mem::take( &mut self.pending )
}
fn on_key( &mut self, keysym: Keysym ) -> Option<Msg>
{
match keysym
{
Keysym::Escape => Some( Msg::Quit ),
_ => None,
}
}
}
fn render( surface: &mut UiSurface<Msg>, app: &Counter ) -> ltk::core::RenderOutput
{
surface.render(
&app.view(),
RenderOptions::full_canvas( 320, 240 ).background( Color::rgb( 0.1, 0.1, 0.1 ) ),
)
}
// ── Msg → update → re-render ──────────────────────────────────────────────────
#[ test ]
fn pressing_increment_button_advances_counter_state()
{
let mut surface = UiSurface::<Msg>::new( 320, 240 );
let mut app = Counter::new();
let _ = render( &mut surface, &app );
// Locate the "+" button. Layout pushes the text widget first (non-
// interactive), then the three buttons in declaration order.
let plus_idx = surface.widget_rects()[ 0 ].flat_idx;
let msg = surface.handlers( plus_idx )
.and_then( |h| h.press_msg() )
.expect( "button must carry on_press" );
assert_eq!( msg, Msg::Inc );
app.update( msg );
assert_eq!( app.value, 1 );
let _ = render( &mut surface, &app );
// Three buttons remain laid out — view shape did not change.
assert_eq!( surface.widget_rects().len(), 3 );
}
#[ test ]
fn multiple_dispatch_cycles_accumulate_state()
{
let mut surface = UiSurface::<Msg>::new( 320, 240 );
let mut app = Counter::new();
for _ in 0..5
{
let _ = render( &mut surface, &app );
let inc_idx = surface.widget_rects()[ 0 ].flat_idx;
let msg = surface.handlers( inc_idx ).and_then( |h| h.press_msg() ).unwrap();
app.update( msg );
}
assert_eq!( app.value, 5 );
}
#[ test ]
fn dec_then_reset_returns_state_to_zero()
{
let mut surface = UiSurface::<Msg>::new( 320, 240 );
let mut app = Counter::new();
let _ = render( &mut surface, &app );
// Buttons appear in declaration order: 0 = "+", 1 = "", 2 = "reset".
let dec_idx = surface.widget_rects()[ 1 ].flat_idx;
let reset_idx = surface.widget_rects()[ 2 ].flat_idx;
let dec_msg = surface.handlers( dec_idx ).and_then( |h| h.press_msg() ).unwrap();
app.update( dec_msg );
assert_eq!( app.value, -1 );
let _ = render( &mut surface, &app );
let reset_msg = surface.handlers( reset_idx ).and_then( |h| h.press_msg() ).unwrap();
app.update( reset_msg );
assert_eq!( app.value, 0 );
}
#[ test ]
fn re_render_after_state_change_preserves_widget_count()
{
let mut surface = UiSurface::<Msg>::new( 320, 240 );
let mut app = Counter::new();
let first = render( &mut surface, &app );
app.update( Msg::Inc );
// A real runtime would call `mark_content_dirty` here so the next render
// repaints the value text; UiSurface leaves that to the embedder. We
// just assert that the layout shape is stable across the message
// dispatch.
let _second = render( &mut surface, &app );
assert!( first.full_redraw, "first render is always a full redraw" );
assert_eq!( surface.widget_rects().len(), 3 );
}
// ── on_key / poll_external ────────────────────────────────────────────────────
#[ test ]
fn on_key_escape_emits_quit_message()
{
let mut app = Counter::new();
let msg = app.on_key( Keysym::Escape );
assert_eq!( msg, Some( Msg::Quit ) );
}
#[ test ]
fn on_key_unknown_keysym_returns_none()
{
let mut app = Counter::new();
assert!( app.on_key( Keysym::Tab ).is_none() );
}
#[ test ]
fn poll_external_drains_pending_messages_in_order()
{
let mut app = Counter::new();
app.pending.extend( [ Msg::Inc, Msg::Inc, Msg::Reset ] );
let drained = app.poll_external();
assert_eq!( drained, vec![ Msg::Inc, Msg::Inc, Msg::Reset ] );
// Consuming the queue empties it.
let again = app.poll_external();
assert!( again.is_empty() );
}
#[ test ]
fn updating_with_drained_messages_reflects_on_render()
{
let mut surface = UiSurface::<Msg>::new( 320, 240 );
let mut app = Counter::new();
app.pending.extend( [ Msg::Inc, Msg::Inc, Msg::Inc ] );
for msg in app.poll_external()
{
app.update( msg );
}
assert_eq!( app.value, 3 );
let _ = render( &mut surface, &app );
}
#[ test ]
fn defaults_for_unset_app_hooks_are_inert()
{
let app = Counter::new();
// is_animating defaults to false — confirms the runtime sleeps on idle.
assert!( !app.is_animating() );
// poll_interval defaults to None — pure event-driven scheduling.
assert!( app.poll_interval().is_none() );
}
// ── Tab navigation through UiSurface widget rects ─────────────────────────────
#[ test ]
fn tab_navigation_advances_through_focusable_widgets()
{
let mut surface = UiSurface::<Msg>::new( 320, 240 );
let app = Counter::new();
let _ = render( &mut surface, &app );
let widgets = surface.widget_rects();
assert_eq!( widgets.len(), 3 );
// Forward from None lands on the first focusable (button "+").
let first = next_focusable_index( widgets, None, false ).unwrap();
let second = next_focusable_index( widgets, Some( first ), false ).unwrap();
let third = next_focusable_index( widgets, Some( second ), false ).unwrap();
let wrap = next_focusable_index( widgets, Some( third ), false ).unwrap();
assert_ne!( first, second );
assert_ne!( second, third );
assert_eq!( wrap, first, "focus wraps to the head after the tail" );
}
#[ test ]
fn tab_navigation_reverse_walks_backward()
{
let mut surface = UiSurface::<Msg>::new( 320, 240 );
let app = Counter::new();
let _ = render( &mut surface, &app );
let widgets = surface.widget_rects();
let last = next_focusable_index( widgets, None, true ).unwrap();
let prev = next_focusable_index( widgets, Some( last ), true ).unwrap();
let wrap = next_focusable_index( widgets, Some( prev ), true ).unwrap();
let wrap2 = next_focusable_index( widgets, Some( wrap ), true ).unwrap();
assert_ne!( last, prev );
assert_ne!( prev, wrap );
assert_eq!( wrap2, last, "reverse traversal also wraps" );
}
// ── External invalidation ─────────────────────────────────────────────────────
#[ test ]
fn mark_content_dirty_triggers_full_redraw_after_external_state_mutation()
{
let mut surface = UiSurface::<Msg>::new( 320, 240 );
let mut app = Counter::new();
let _ = render( &mut surface, &app );
// Application state mutates via a path not derived from a Msg dispatch
// (e.g. a clock tick stored in a RefCell during view()). The runtime
// signals "content changed without interaction transition" via
// `mark_content_dirty`; the next render must come back as full redraw.
app.value = 42;
surface.mark_content_dirty();
let out = render( &mut surface, &app );
assert!( out.full_redraw );
}