// 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, 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 { column::() .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 { std::mem::take( &mut self.pending ) } fn on_key( &mut self, keysym: Keysym ) -> Option { match keysym { Keysym::Escape => Some( Msg::Quit ), _ => None, } } } fn render( surface: &mut UiSurface, 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::::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::::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::::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::::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::::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::::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::::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::::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 ); }