First commit. Version 0.1.0
This commit is contained in:
283
tests/event_loop_flow.rs
Normal file
283
tests/event_loop_flow.rs
Normal file
@@ -0,0 +1,283 @@
|
||||
// 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 );
|
||||
}
|
||||
Reference in New Issue
Block a user