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 );
|
||||
}
|
||||
63
tests/common/mod.rs
Normal file
63
tests/common/mod.rs
Normal file
@@ -0,0 +1,63 @@
|
||||
// Shared fixtures for the integration tests in this directory. Each test
|
||||
// binary compiles its own copy of this module and only sees the helpers it
|
||||
// actually uses as "live", so the rest trip the `dead_code` lint —
|
||||
// `#![allow(dead_code)]` is the standard fix for shared `tests/common/`.
|
||||
#![ allow( dead_code ) ]
|
||||
|
||||
use ltk::test_support::{ LaidOutWidget, WidgetHandlers };
|
||||
use ltk::{ CursorShape, Rect };
|
||||
|
||||
/// Build a synthetic [`LaidOutWidget`] with no handlers. The `flat_idx` is
|
||||
/// what most tree lookups key on; `rect` is what hit testing keys on.
|
||||
/// Defaults to `keyboard_focusable = true` so existing Tab-navigation tests
|
||||
/// keep their meaning — the entry behaves like a typical Button.
|
||||
pub fn lw_none( flat_idx: usize, rect: Rect ) -> LaidOutWidget<()>
|
||||
{
|
||||
LaidOutWidget
|
||||
{
|
||||
rect,
|
||||
flat_idx,
|
||||
id: None,
|
||||
paint_rect: rect,
|
||||
handlers: WidgetHandlers::None,
|
||||
keyboard_focusable: true,
|
||||
cursor: CursorShape::Default,
|
||||
}
|
||||
}
|
||||
|
||||
/// Variant: synthetic Button entry that emits `()` on press.
|
||||
pub fn lw_button( flat_idx: usize, rect: Rect ) -> LaidOutWidget<()>
|
||||
{
|
||||
LaidOutWidget
|
||||
{
|
||||
rect,
|
||||
flat_idx,
|
||||
id: None,
|
||||
paint_rect: rect,
|
||||
handlers: WidgetHandlers::Button { on_press: Some( () ), on_long_press: None, on_drag_start: None, on_escape: None, repeating: false },
|
||||
keyboard_focusable: true,
|
||||
cursor: CursorShape::Pointer,
|
||||
}
|
||||
}
|
||||
|
||||
/// Variant: synthetic interactive-but-not-focusable entry, modelling chrome
|
||||
/// such as `WindowButton`. Used by the Tab-navigation tests that verify the
|
||||
/// projection step skips these.
|
||||
pub fn lw_chrome( flat_idx: usize, rect: Rect ) -> LaidOutWidget<()>
|
||||
{
|
||||
LaidOutWidget
|
||||
{
|
||||
rect,
|
||||
flat_idx,
|
||||
id: None,
|
||||
paint_rect: rect,
|
||||
handlers: WidgetHandlers::WindowButton { on_press: Some( () ) },
|
||||
keyboard_focusable: false,
|
||||
cursor: CursorShape::Pointer,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn rect( x: f32, y: f32, w: f32, h: f32 ) -> Rect
|
||||
{
|
||||
Rect { x, y, width: w, height: h }
|
||||
}
|
||||
227
tests/core_surface.rs
Normal file
227
tests/core_surface.rs
Normal file
@@ -0,0 +1,227 @@
|
||||
use ltk::core::{ Canvas, RenderOptions, UiSurface };
|
||||
use ltk::{ button, column, window_controls, Color, Element, Point, WindowButtonKind };
|
||||
|
||||
#[ derive( Clone, Debug, PartialEq, Eq ) ]
|
||||
enum Msg
|
||||
{
|
||||
Pressed,
|
||||
Close,
|
||||
Maximize,
|
||||
Minimize,
|
||||
}
|
||||
|
||||
fn view() -> Element<Msg>
|
||||
{
|
||||
column::<Msg>()
|
||||
.padding( 16.0 )
|
||||
.push( button( "Press" ).on_press( Msg::Pressed ) )
|
||||
.into()
|
||||
}
|
||||
|
||||
fn decoration_view() -> Element<Msg>
|
||||
{
|
||||
window_controls(
|
||||
Some( Msg::Minimize ),
|
||||
WindowButtonKind::Maximize,
|
||||
Some( Msg::Maximize ),
|
||||
Some( Msg::Close ),
|
||||
)
|
||||
.into()
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn core_surface_new_is_software_by_default()
|
||||
{
|
||||
let surface = UiSurface::<Msg>::new( 240, 120 );
|
||||
assert!( matches!( surface.canvas(), Canvas::Software( _ ) ) );
|
||||
assert!( surface.canvas().borrowed_gles_texture().is_none() );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn core_surface_renders_and_exposes_handlers_without_run()
|
||||
{
|
||||
let mut surface = UiSurface::<Msg>::new( 240, 120 );
|
||||
let out = surface.render(
|
||||
&view(),
|
||||
RenderOptions::full_canvas( 240, 120 ).background( Color::rgb( 0.1, 0.1, 0.1 ) ),
|
||||
);
|
||||
|
||||
assert!( out.full_redraw );
|
||||
assert_eq!( surface.widget_rects().len(), 1 );
|
||||
|
||||
let first = &surface.widget_rects()[ 0 ];
|
||||
let hit = surface.hit_test( Point { x: first.rect.x + 1.0, y: first.rect.y + 1.0 } );
|
||||
assert_eq!( hit, Some( first.flat_idx ) );
|
||||
|
||||
let msg = surface.handlers( first.flat_idx ).and_then( |h| h.press_msg() );
|
||||
assert_eq!( msg, Some( Msg::Pressed ) );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn core_surface_can_drive_window_controls()
|
||||
{
|
||||
let mut surface = UiSurface::<Msg>::new( 180, 48 );
|
||||
let _ = surface.render(
|
||||
&decoration_view(),
|
||||
RenderOptions::full_canvas( 180, 48 ).background( Color::rgb( 0.08, 0.08, 0.09 ) ),
|
||||
);
|
||||
|
||||
assert_eq!( surface.widget_rects().len(), 3 );
|
||||
|
||||
let close = surface.widget_rects()[ 2 ].flat_idx;
|
||||
let msg = surface.handlers( close ).and_then( |h| h.press_msg() );
|
||||
assert_eq!( msg, Some( Msg::Close ) );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn core_surface_reports_partial_damage_for_focus_change()
|
||||
{
|
||||
let mut surface = UiSurface::<Msg>::new( 240, 120 );
|
||||
let opts = RenderOptions::full_canvas( 240, 120 ).background( Color::rgb( 0.1, 0.1, 0.1 ) );
|
||||
let _ = surface.render( &view(), opts );
|
||||
|
||||
let idx = surface.widget_rects()[ 0 ].flat_idx;
|
||||
surface.set_focused( Some( idx ) );
|
||||
let out = surface.render( &view(), opts );
|
||||
|
||||
assert!( !out.full_redraw, "focus-only transition should be partial damage" );
|
||||
assert!( !out.damage_rects.is_empty() );
|
||||
}
|
||||
|
||||
// ── interaction-only damage paths ─────────────────────────────────────────────
|
||||
|
||||
#[ test ]
|
||||
fn hover_only_change_produces_partial_damage()
|
||||
{
|
||||
let mut surface = UiSurface::<Msg>::new( 240, 120 );
|
||||
let opts = RenderOptions::full_canvas( 240, 120 ).background( Color::rgb( 0.1, 0.1, 0.1 ) );
|
||||
let _ = surface.render( &view(), opts );
|
||||
|
||||
let idx = surface.widget_rects()[ 0 ].flat_idx;
|
||||
surface.set_hovered( Some( idx ) );
|
||||
let out = surface.render( &view(), opts );
|
||||
|
||||
assert!( !out.full_redraw, "hover-only transition should be partial damage" );
|
||||
assert!( !out.damage_rects.is_empty() );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn pressed_only_change_produces_partial_damage()
|
||||
{
|
||||
let mut surface = UiSurface::<Msg>::new( 240, 120 );
|
||||
let opts = RenderOptions::full_canvas( 240, 120 ).background( Color::rgb( 0.1, 0.1, 0.1 ) );
|
||||
let _ = surface.render( &view(), opts );
|
||||
|
||||
let idx = surface.widget_rects()[ 0 ].flat_idx;
|
||||
surface.set_pressed( Some( idx ) );
|
||||
let out = surface.render( &view(), opts );
|
||||
|
||||
assert!( !out.full_redraw );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn focused_hovered_pressed_setters_round_trip()
|
||||
{
|
||||
let mut surface = UiSurface::<Msg>::new( 240, 120 );
|
||||
let opts = RenderOptions::full_canvas( 240, 120 );
|
||||
let _ = surface.render( &view(), opts );
|
||||
|
||||
let idx = surface.widget_rects()[ 0 ].flat_idx;
|
||||
surface.set_focused( Some( idx ) );
|
||||
surface.set_hovered( Some( idx ) );
|
||||
surface.set_pressed( Some( idx ) );
|
||||
|
||||
assert_eq!( surface.focused(), Some( idx ) );
|
||||
assert_eq!( surface.hovered(), Some( idx ) );
|
||||
assert_eq!( surface.pressed(), Some( idx ) );
|
||||
|
||||
surface.set_focused( None );
|
||||
surface.set_hovered( None );
|
||||
surface.set_pressed( None );
|
||||
|
||||
assert_eq!( surface.focused(), None );
|
||||
assert_eq!( surface.hovered(), None );
|
||||
assert_eq!( surface.pressed(), None );
|
||||
}
|
||||
|
||||
// ── full-redraw paths ─────────────────────────────────────────────────────────
|
||||
|
||||
#[ test ]
|
||||
fn first_render_is_a_full_redraw()
|
||||
{
|
||||
let mut surface = UiSurface::<Msg>::new( 240, 120 );
|
||||
let out = surface.render(
|
||||
&view(),
|
||||
RenderOptions::full_canvas( 240, 120 ).background( Color::rgb( 0.1, 0.1, 0.1 ) ),
|
||||
);
|
||||
assert!( out.full_redraw, "the first render of a fresh surface is always a full redraw" );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn mark_content_dirty_forces_next_render_to_full_redraw()
|
||||
{
|
||||
let mut surface = UiSurface::<Msg>::new( 240, 120 );
|
||||
let opts = RenderOptions::full_canvas( 240, 120 ).background( Color::rgb( 0.1, 0.1, 0.1 ) );
|
||||
let _ = surface.render( &view(), opts );
|
||||
|
||||
// No interaction change — the next render would normally be partial.
|
||||
surface.mark_content_dirty();
|
||||
let out = surface.render( &view(), opts );
|
||||
assert!( out.full_redraw );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn resize_forces_next_render_to_full_redraw()
|
||||
{
|
||||
let mut surface = UiSurface::<Msg>::new( 240, 120 );
|
||||
let _ = surface.render( &view(), RenderOptions::full_canvas( 240, 120 ) );
|
||||
|
||||
surface.resize( 320, 200 );
|
||||
assert_eq!( surface.size(), ( 320, 200 ) );
|
||||
|
||||
let out = surface.render( &view(), RenderOptions::full_canvas( 320, 200 ) );
|
||||
assert!( out.full_redraw );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn tree_size_change_forces_full_redraw()
|
||||
{
|
||||
let mut surface = UiSurface::<Msg>::new( 320, 200 );
|
||||
let opts = RenderOptions::full_canvas( 320, 200 );
|
||||
let _ = surface.render( &view(), opts );
|
||||
let initial_count = surface.widget_rects().len();
|
||||
|
||||
// Render a different tree (decoration_view has 3 buttons; view has 1).
|
||||
let out = surface.render( &decoration_view(), opts );
|
||||
assert!( out.full_redraw );
|
||||
assert_ne!( surface.widget_rects().len(), initial_count );
|
||||
}
|
||||
|
||||
// ── lookups ───────────────────────────────────────────────────────────────────
|
||||
|
||||
#[ test ]
|
||||
fn hit_test_outside_any_widget_returns_none()
|
||||
{
|
||||
let mut surface = UiSurface::<Msg>::new( 320, 200 );
|
||||
let _ = surface.render( &view(), RenderOptions::full_canvas( 320, 200 ) );
|
||||
|
||||
// (0, 0) is the top-left corner — far from the centred button.
|
||||
assert_eq!( surface.hit_test( Point { x: 0.0, y: 0.0 } ), None );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn handlers_lookup_for_unknown_idx_returns_none()
|
||||
{
|
||||
let mut surface = UiSurface::<Msg>::new( 240, 120 );
|
||||
let _ = surface.render( &view(), RenderOptions::full_canvas( 240, 120 ) );
|
||||
assert!( surface.handlers( 999_999 ).is_none() );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn widget_lookup_for_known_idx_returns_some()
|
||||
{
|
||||
let mut surface = UiSurface::<Msg>::new( 240, 120 );
|
||||
let _ = surface.render( &view(), RenderOptions::full_canvas( 240, 120 ) );
|
||||
let idx = surface.widget_rects()[ 0 ].flat_idx;
|
||||
assert!( surface.widget( idx ).is_some() );
|
||||
}
|
||||
248
tests/element_map.rs
Normal file
248
tests/element_map.rs
Normal file
@@ -0,0 +1,248 @@
|
||||
//! Integration tests for [`ltk::Element::map`] — the Elm-style adapter
|
||||
//! that re-tags every per-leaf message in a sub-tree.
|
||||
//!
|
||||
//! Each test renders the mapped tree through a software [`UiSurface`],
|
||||
//! looks up the per-widget [`WidgetHandlers`] snapshot the runtime
|
||||
//! produced, and asserts that pressing a leaf fires the *outer*
|
||||
//! `AppMsg::Sub( ... )` instead of the inner `SubMsg::...`.
|
||||
|
||||
use ltk::core::{ RenderOptions, UiSurface };
|
||||
use ltk::test_support::WidgetHandlers;
|
||||
use ltk::{
|
||||
button, checkbox, column, list_item, radio, slider, text_edit, toggle,
|
||||
vslider,
|
||||
Color, Element,
|
||||
};
|
||||
|
||||
#[ derive( Clone, Debug, PartialEq ) ]
|
||||
enum SubMsg
|
||||
{
|
||||
Pressed,
|
||||
Toggled,
|
||||
Selected,
|
||||
Open,
|
||||
Slid( i32 ),
|
||||
Vol( i32 ),
|
||||
Typed( String ),
|
||||
Submit,
|
||||
}
|
||||
|
||||
#[ derive( Clone, Debug, PartialEq ) ]
|
||||
enum AppMsg
|
||||
{
|
||||
Sub( SubMsg ),
|
||||
}
|
||||
|
||||
fn sub_view() -> Element<SubMsg>
|
||||
{
|
||||
column::<SubMsg>()
|
||||
.padding( 0.0 )
|
||||
.spacing( 4.0 )
|
||||
.push( button( "press" ).on_press( SubMsg::Pressed ) )
|
||||
.push( toggle( false ).on_toggle( SubMsg::Toggled ) )
|
||||
.push( checkbox( true ).on_toggle( SubMsg::Toggled ) )
|
||||
.push( radio( false ).on_select( SubMsg::Selected ) )
|
||||
.push( list_item( "row" ).on_press( SubMsg::Open ) )
|
||||
.push( slider( 0.5 ).on_change( |v| SubMsg::Slid( ( v * 100.0 ) as i32 ) ) )
|
||||
.push( vslider( 0.25 ).on_change( |v| SubMsg::Vol( ( v * 100.0 ) as i32 ) ) )
|
||||
.push( text_edit::<SubMsg>( "ph", "" )
|
||||
.on_change( SubMsg::Typed )
|
||||
.on_submit( SubMsg::Submit ) )
|
||||
.into()
|
||||
}
|
||||
|
||||
fn render_mapped() -> UiSurface<AppMsg>
|
||||
{
|
||||
let mapped: Element<AppMsg> = sub_view().map( AppMsg::Sub );
|
||||
let mut surface = UiSurface::<AppMsg>::new( 320, 320 );
|
||||
let _ = surface.render(
|
||||
&mapped,
|
||||
RenderOptions::full_canvas( 320, 320 ).background( Color::rgb( 0.1, 0.1, 0.1 ) ),
|
||||
);
|
||||
surface
|
||||
}
|
||||
|
||||
/// Walk every laid-out widget rect in `surface` and call `probe` with
|
||||
/// each handler snapshot. The probe builds the test assertion for the
|
||||
/// matching widget kind.
|
||||
fn for_each_handler( surface: &UiSurface<AppMsg>, mut probe: impl FnMut( &WidgetHandlers<AppMsg> ) )
|
||||
{
|
||||
for widget in surface.widget_rects()
|
||||
{
|
||||
probe( &widget.handlers );
|
||||
}
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn button_press_msg_is_remapped()
|
||||
{
|
||||
let surface = render_mapped();
|
||||
let mut found = false;
|
||||
for_each_handler( &surface, |h|
|
||||
{
|
||||
if let WidgetHandlers::Button { on_press, .. } = h
|
||||
{
|
||||
if let Some( msg ) = on_press
|
||||
{
|
||||
assert_eq!( *msg, AppMsg::Sub( SubMsg::Pressed ), "button.on_press" );
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
} );
|
||||
assert!( found, "no Button handler with on_press in the rendered tree" );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn toggle_and_checkbox_messages_are_remapped()
|
||||
{
|
||||
let surface = render_mapped();
|
||||
let mut toggle_seen = false;
|
||||
let mut checkbox_seen = false;
|
||||
for_each_handler( &surface, |h|
|
||||
{
|
||||
match h
|
||||
{
|
||||
WidgetHandlers::Toggle { on_toggle: Some( m ) } =>
|
||||
{
|
||||
assert_eq!( *m, AppMsg::Sub( SubMsg::Toggled ) );
|
||||
toggle_seen = true;
|
||||
}
|
||||
WidgetHandlers::Checkbox { on_toggle: Some( m ) } =>
|
||||
{
|
||||
assert_eq!( *m, AppMsg::Sub( SubMsg::Toggled ) );
|
||||
checkbox_seen = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
} );
|
||||
assert!( toggle_seen );
|
||||
assert!( checkbox_seen );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn radio_and_list_item_messages_are_remapped()
|
||||
{
|
||||
let surface = render_mapped();
|
||||
let mut radio_seen = false;
|
||||
let mut item_seen = false;
|
||||
for_each_handler( &surface, |h|
|
||||
{
|
||||
match h
|
||||
{
|
||||
WidgetHandlers::Radio { on_select: Some( m ) } =>
|
||||
{
|
||||
assert_eq!( *m, AppMsg::Sub( SubMsg::Selected ) );
|
||||
radio_seen = true;
|
||||
}
|
||||
WidgetHandlers::ListItem { on_press: Some( m ) } =>
|
||||
{
|
||||
assert_eq!( *m, AppMsg::Sub( SubMsg::Open ) );
|
||||
item_seen = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
} );
|
||||
assert!( radio_seen );
|
||||
assert!( item_seen );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn slider_callbacks_run_through_the_mapper()
|
||||
{
|
||||
let surface = render_mapped();
|
||||
let mut h_axis = false;
|
||||
let mut v_axis = false;
|
||||
for_each_handler( &surface, |h|
|
||||
{
|
||||
if let WidgetHandlers::Slider { on_change: Some( cb ), axis } = h
|
||||
{
|
||||
// `cb` binds by-ref through default match ergonomics, so
|
||||
// reach the trait object via `**cb` to invoke it.
|
||||
let msg = ( **cb )( 0.5 );
|
||||
match axis
|
||||
{
|
||||
ltk::SliderAxis::Horizontal =>
|
||||
{
|
||||
assert_eq!( msg, AppMsg::Sub( SubMsg::Slid( 50 ) ) );
|
||||
h_axis = true;
|
||||
}
|
||||
ltk::SliderAxis::Vertical =>
|
||||
{
|
||||
assert_eq!( msg, AppMsg::Sub( SubMsg::Vol( 50 ) ) );
|
||||
v_axis = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
} );
|
||||
assert!( h_axis, "horizontal slider handler not exercised" );
|
||||
assert!( v_axis, "vertical slider handler not exercised" );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn text_edit_on_change_and_submit_are_remapped()
|
||||
{
|
||||
let surface = render_mapped();
|
||||
let mut found = false;
|
||||
for_each_handler( &surface, |h|
|
||||
{
|
||||
if let WidgetHandlers::TextEdit { on_change: Some( cb ), on_submit: Some( s ), .. } = h
|
||||
{
|
||||
let typed = ( **cb )( "hello".to_string() );
|
||||
assert_eq!( typed, AppMsg::Sub( SubMsg::Typed( "hello".to_string() ) ) );
|
||||
assert_eq!( *s, AppMsg::Sub( SubMsg::Submit ) );
|
||||
found = true;
|
||||
}
|
||||
} );
|
||||
assert!( found, "TextEdit handler not seen" );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn map_walks_into_nested_layouts()
|
||||
{
|
||||
// One level of nesting: Column -> Container -> Button.
|
||||
use ltk::container;
|
||||
let inner: Element<SubMsg> = container( button( "inner" ).on_press( SubMsg::Pressed ) ).into();
|
||||
let outer: Element<SubMsg> = column::<SubMsg>().padding( 0.0 ).push( inner ).into();
|
||||
let mapped: Element<AppMsg> = outer.map( AppMsg::Sub );
|
||||
|
||||
let mut surface = UiSurface::<AppMsg>::new( 200, 200 );
|
||||
let _ = surface.render(
|
||||
&mapped,
|
||||
RenderOptions::full_canvas( 200, 200 ).background( Color::rgb( 0.1, 0.1, 0.1 ) ),
|
||||
);
|
||||
|
||||
let widget = surface.widget_rects().iter().find( |w|
|
||||
matches!( w.handlers, WidgetHandlers::Button { on_press: Some( _ ), .. } ) );
|
||||
let widget = widget.expect( "button under nested layout not found" );
|
||||
if let WidgetHandlers::Button { on_press: Some( msg ), .. } = &widget.handlers
|
||||
{
|
||||
assert_eq!( *msg, AppMsg::Sub( SubMsg::Pressed ) );
|
||||
} else { unreachable!() }
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn map_can_be_chained()
|
||||
{
|
||||
// `view -> map(L1) -> map(L2)` rewrites twice; the inner Sub gets
|
||||
// double-wrapped. Verifies that a child Element<U> mapped again is
|
||||
// still walkable and the closure chain composes left-to-right.
|
||||
#[ derive( Clone, Debug, PartialEq ) ]
|
||||
enum L1 { Wrap( SubMsg ) }
|
||||
#[ derive( Clone, Debug, PartialEq ) ]
|
||||
enum L2 { Wrap( L1 ) }
|
||||
|
||||
let leaf: Element<SubMsg> = button( "x" ).on_press( SubMsg::Pressed ).into();
|
||||
let l1: Element<L1> = leaf.map( L1::Wrap );
|
||||
let l2: Element<L2> = l1.map( L2::Wrap );
|
||||
|
||||
let mut surface = UiSurface::<L2>::new( 100, 80 );
|
||||
let _ = surface.render(
|
||||
&l2,
|
||||
RenderOptions::full_canvas( 100, 80 ).background( Color::rgb( 0.1, 0.1, 0.1 ) ),
|
||||
);
|
||||
let widget = &surface.widget_rects()[ 0 ];
|
||||
if let WidgetHandlers::Button { on_press: Some( msg ), .. } = &widget.handlers
|
||||
{
|
||||
assert_eq!( *msg, L2::Wrap( L1::Wrap( SubMsg::Pressed ) ) );
|
||||
} else { panic!( "expected Button handler" ) }
|
||||
}
|
||||
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 );
|
||||
}
|
||||
277
tests/layout_stack_spacer.rs
Normal file
277
tests/layout_stack_spacer.rs
Normal file
@@ -0,0 +1,277 @@
|
||||
// Integration coverage for `Stack` alignment / margin / translation and for
|
||||
// the way `Spacer` distributes leftover space inside a `Column`.
|
||||
|
||||
use ltk::core::Canvas;
|
||||
use ltk::{ stack, spacer, HAlign, VAlign, Rect };
|
||||
|
||||
fn make_canvas() -> Canvas
|
||||
{
|
||||
Canvas::new( 800, 600 )
|
||||
}
|
||||
|
||||
fn rect( x: f32, y: f32, w: f32, h: f32 ) -> Rect
|
||||
{
|
||||
Rect { x, y, width: w, height: h }
|
||||
}
|
||||
|
||||
// ── Stack: alignment ──────────────────────────────────────────────────────────
|
||||
|
||||
#[ test ]
|
||||
fn stack_fill_child_takes_full_rect()
|
||||
{
|
||||
let canvas = make_canvas();
|
||||
let s = stack::<()>().push( spacer().width( 100.0 ).height( 100.0 ) );
|
||||
let layout = s.layout( rect( 0.0, 0.0, 400.0, 300.0 ), &canvas );
|
||||
|
||||
assert_eq!( layout.len(), 1 );
|
||||
let ( child_rect, _ ) = layout[ 0 ];
|
||||
// Fill ignores the spacer's preferred size and stretches across the
|
||||
// entire stack rect.
|
||||
assert_eq!( child_rect, rect( 0.0, 0.0, 400.0, 300.0 ) );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn stack_center_centers_child_inside_rect()
|
||||
{
|
||||
let canvas = make_canvas();
|
||||
let child = spacer().width( 100.0 ).height( 50.0 );
|
||||
let s = stack::<()>().push_aligned( child, HAlign::Center, VAlign::Center );
|
||||
let layout = s.layout( rect( 0.0, 0.0, 400.0, 300.0 ), &canvas );
|
||||
|
||||
let ( child_rect, _ ) = layout[ 0 ];
|
||||
assert_eq!( child_rect.x, 150.0 );
|
||||
assert_eq!( child_rect.y, 125.0 );
|
||||
assert_eq!( child_rect.width, 100.0 );
|
||||
assert_eq!( child_rect.height, 50.0 );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn stack_end_bottom_anchors_to_bottom_right()
|
||||
{
|
||||
let canvas = make_canvas();
|
||||
let child = spacer().width( 80.0 ).height( 40.0 );
|
||||
let s = stack::<()>().push_aligned( child, HAlign::End, VAlign::Bottom );
|
||||
let layout = s.layout( rect( 0.0, 0.0, 400.0, 300.0 ), &canvas );
|
||||
|
||||
let ( child_rect, _ ) = layout[ 0 ];
|
||||
assert_eq!( child_rect.x, 320.0 );
|
||||
assert_eq!( child_rect.y, 260.0 );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn stack_start_top_anchors_to_top_left()
|
||||
{
|
||||
let canvas = make_canvas();
|
||||
let child = spacer().width( 40.0 ).height( 30.0 );
|
||||
let s = stack::<()>().push_aligned( child, HAlign::Start, VAlign::Top );
|
||||
let layout = s.layout( rect( 100.0, 50.0, 400.0, 300.0 ), &canvas );
|
||||
|
||||
let ( child_rect, _ ) = layout[ 0 ];
|
||||
assert_eq!( child_rect.x, 100.0 );
|
||||
assert_eq!( child_rect.y, 50.0 );
|
||||
}
|
||||
|
||||
// ── Stack: margin ─────────────────────────────────────────────────────────────
|
||||
|
||||
#[ test ]
|
||||
fn stack_margin_insets_fill_child_on_all_sides()
|
||||
{
|
||||
let canvas = make_canvas();
|
||||
let child = spacer().width( 50.0 ).height( 50.0 );
|
||||
let s = stack::<()>()
|
||||
.push_aligned_margin( child, HAlign::Fill, VAlign::Fill, 12.0 );
|
||||
let layout = s.layout( rect( 0.0, 0.0, 400.0, 300.0 ), &canvas );
|
||||
|
||||
let ( child_rect, _ ) = layout[ 0 ];
|
||||
// Fill + margin = original rect inset on every side.
|
||||
assert_eq!( child_rect, rect( 12.0, 12.0, 376.0, 276.0 ) );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn stack_margin_pushes_end_anchored_child_inward()
|
||||
{
|
||||
let canvas = make_canvas();
|
||||
let child = spacer().width( 50.0 ).height( 50.0 );
|
||||
let s = stack::<()>()
|
||||
.push_aligned_margin( child, HAlign::End, VAlign::Top, 8.0 );
|
||||
let layout = s.layout( rect( 0.0, 0.0, 400.0, 300.0 ), &canvas );
|
||||
|
||||
let ( child_rect, _ ) = layout[ 0 ];
|
||||
// 400 - 50 - 8 = 342 from left edge; y starts at 8.
|
||||
assert_eq!( child_rect.x, 342.0 );
|
||||
assert_eq!( child_rect.y, 8.0 );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn stack_margin_larger_than_half_rect_clamps_inner_to_zero()
|
||||
{
|
||||
let canvas = make_canvas();
|
||||
let child = spacer().width( 10.0 ).height( 10.0 );
|
||||
let s = stack::<()>()
|
||||
.push_aligned_margin( child, HAlign::Fill, VAlign::Fill, 200.0 );
|
||||
// Stack rect is 100×100; margin 200 → inner is clamped to (0, 0).
|
||||
let layout = s.layout( rect( 0.0, 0.0, 100.0, 100.0 ), &canvas );
|
||||
|
||||
let ( child_rect, _ ) = layout[ 0 ];
|
||||
assert_eq!( child_rect.width, 0.0 );
|
||||
assert_eq!( child_rect.height, 0.0 );
|
||||
}
|
||||
|
||||
// ── Stack: translation ────────────────────────────────────────────────────────
|
||||
|
||||
#[ test ]
|
||||
fn stack_translation_offsets_child_after_alignment()
|
||||
{
|
||||
let canvas = make_canvas();
|
||||
let child = spacer().width( 40.0 ).height( 30.0 );
|
||||
let s = stack::<()>()
|
||||
.push_translated( child, HAlign::Start, VAlign::Top, 5.0, 7.0 );
|
||||
let layout = s.layout( rect( 0.0, 0.0, 400.0, 300.0 ), &canvas );
|
||||
|
||||
let ( child_rect, _ ) = layout[ 0 ];
|
||||
assert_eq!( child_rect.x, 5.0 );
|
||||
assert_eq!( child_rect.y, 7.0 );
|
||||
}
|
||||
|
||||
// ── Stack: order ─────────────────────────────────────────────────────────────
|
||||
|
||||
#[ test ]
|
||||
fn stack_layout_preserves_child_order()
|
||||
{
|
||||
let canvas = make_canvas();
|
||||
let s = stack::<()>()
|
||||
.push( spacer().width( 10.0 ).height( 10.0 ) )
|
||||
.push( spacer().width( 20.0 ).height( 20.0 ) )
|
||||
.push( spacer().width( 30.0 ).height( 30.0 ) );
|
||||
let layout = s.layout( rect( 0.0, 0.0, 400.0, 300.0 ), &canvas );
|
||||
|
||||
assert_eq!( layout.len(), 3 );
|
||||
assert_eq!( layout[ 0 ].1, 0 );
|
||||
assert_eq!( layout[ 1 ].1, 1 );
|
||||
assert_eq!( layout[ 2 ].1, 2 );
|
||||
}
|
||||
|
||||
// ── Stack: preferred_size ─────────────────────────────────────────────────────
|
||||
|
||||
#[ test ]
|
||||
fn stack_preferred_size_returns_max_child_height()
|
||||
{
|
||||
let canvas = make_canvas();
|
||||
let s = stack::<()>()
|
||||
.push( spacer().width( 40.0 ).height( 30.0 ) )
|
||||
.push( spacer().width( 60.0 ).height( 80.0 ) );
|
||||
let ( w, h ) = s.preferred_size( 400.0, &canvas );
|
||||
// Stack always reports `max_width` for width, and the tallest child for
|
||||
// height — Stack is meant for overlaying, not flow.
|
||||
assert_eq!( w, 400.0 );
|
||||
assert_eq!( h, 80.0 );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn stack_empty_preferred_size_height_is_zero()
|
||||
{
|
||||
let canvas = make_canvas();
|
||||
let s: ltk::Stack<()> = stack();
|
||||
let ( _, h ) = s.preferred_size( 400.0, &canvas );
|
||||
assert_eq!( h, 0.0 );
|
||||
}
|
||||
|
||||
// ── Spacer: builder semantics ─────────────────────────────────────────────────
|
||||
|
||||
#[ test ]
|
||||
fn spacer_default_has_weight_one_and_no_fixed_size()
|
||||
{
|
||||
let s = spacer();
|
||||
assert_eq!( s.weight, 1 );
|
||||
assert!( s.fixed_height.is_none() );
|
||||
assert!( s.fixed_width.is_none() );
|
||||
assert_eq!( s.preferred_size(), ( 0.0, 0.0 ) );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn spacer_height_pins_vertical_axis_only()
|
||||
{
|
||||
let s = spacer().height( 24.0 );
|
||||
assert_eq!( s.fixed_height, Some( 24.0 ) );
|
||||
assert!( s.fixed_width.is_none() );
|
||||
assert_eq!( s.preferred_size(), ( 0.0, 24.0 ) );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn spacer_width_pins_horizontal_axis_only()
|
||||
{
|
||||
let s = spacer().width( 12.0 );
|
||||
assert_eq!( s.fixed_width, Some( 12.0 ) );
|
||||
assert!( s.fixed_height.is_none() );
|
||||
assert_eq!( s.preferred_size(), ( 12.0, 0.0 ) );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn spacer_weight_replaces_default()
|
||||
{
|
||||
let s = spacer().weight( 5 );
|
||||
assert_eq!( s.weight, 5 );
|
||||
}
|
||||
|
||||
// ── Spacer in Column: leftover distribution ───────────────────────────────────
|
||||
|
||||
// These use `column().layout(...)` directly (Column re-exports its `layout()`
|
||||
// method as `pub`). Fixed-height children consume known space; flexible spacers
|
||||
// must absorb the rest.
|
||||
|
||||
#[ test ]
|
||||
fn flexible_spacer_in_column_consumes_leftover_height()
|
||||
{
|
||||
use ltk::column;
|
||||
|
||||
let canvas = make_canvas();
|
||||
let col = column::<()>()
|
||||
.padding( 0.0 )
|
||||
.spacing( 0.0 )
|
||||
.push( spacer().width( 50.0 ).height( 100.0 ) )
|
||||
.push( spacer() ) // flexible
|
||||
.push( spacer().width( 50.0 ).height( 100.0 ) );
|
||||
|
||||
let layout = col.layout( rect( 0.0, 0.0, 200.0, 500.0 ), &canvas );
|
||||
assert_eq!( layout.len(), 3 );
|
||||
|
||||
// Two fixed children of 100 each + one spacer absorbing the leftover 300.
|
||||
assert!( ( layout[ 0 ].0.height - 100.0 ).abs() < 1e-3 );
|
||||
assert!( ( layout[ 1 ].0.height - 300.0 ).abs() < 1e-3 );
|
||||
assert!( ( layout[ 2 ].0.height - 100.0 ).abs() < 1e-3 );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn weighted_spacers_split_leftover_proportionally()
|
||||
{
|
||||
use ltk::column;
|
||||
|
||||
let canvas = make_canvas();
|
||||
let col = column::<()>()
|
||||
.padding( 0.0 )
|
||||
.spacing( 0.0 )
|
||||
.push( spacer().weight( 1 ) )
|
||||
.push( spacer().weight( 3 ) );
|
||||
|
||||
let layout = col.layout( rect( 0.0, 0.0, 200.0, 400.0 ), &canvas );
|
||||
// Total weight 4; leftover 400; weight 1 → 100, weight 3 → 300.
|
||||
assert!( ( layout[ 0 ].0.height - 100.0 ).abs() < 1e-3 );
|
||||
assert!( ( layout[ 1 ].0.height - 300.0 ).abs() < 1e-3 );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn fixed_height_spacer_does_not_consume_leftover()
|
||||
{
|
||||
use ltk::column;
|
||||
|
||||
let canvas = make_canvas();
|
||||
let col = column::<()>()
|
||||
.padding( 0.0 )
|
||||
.spacing( 0.0 )
|
||||
.push( spacer().height( 50.0 ) ) // fixed — keeps its 50 px
|
||||
.push( spacer() ); // flexible — absorbs the rest
|
||||
|
||||
let layout = col.layout( rect( 0.0, 0.0, 200.0, 500.0 ), &canvas );
|
||||
assert!( ( layout[ 0 ].0.height - 50.0 ).abs() < 1e-3 );
|
||||
assert!( ( layout[ 1 ].0.height - 450.0 ).abs() < 1e-3 );
|
||||
}
|
||||
85
tests/overlay_reconciliation.rs
Normal file
85
tests/overlay_reconciliation.rs
Normal file
@@ -0,0 +1,85 @@
|
||||
use ltk::test_support::diff_overlay_ids;
|
||||
use ltk::OverlayId;
|
||||
|
||||
fn ids( xs: &[ u32 ] ) -> Vec<OverlayId>
|
||||
{
|
||||
xs.iter().copied().map( OverlayId ).collect()
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn empty_to_empty_is_no_change()
|
||||
{
|
||||
let ( added, removed ) = diff_overlay_ids( ids( &[] ), &ids( &[] ) );
|
||||
assert!( added.is_empty() );
|
||||
assert!( removed.is_empty() );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn first_frame_marks_everything_added()
|
||||
{
|
||||
let ( added, removed ) = diff_overlay_ids( ids( &[] ), &ids( &[ 1, 2, 3 ] ) );
|
||||
assert_eq!( added, ids( &[ 1, 2, 3 ] ) );
|
||||
assert!( removed.is_empty() );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn empty_next_marks_everything_removed()
|
||||
{
|
||||
let ( added, removed ) = diff_overlay_ids( ids( &[ 1, 2, 3 ] ), &ids( &[] ) );
|
||||
assert!( added.is_empty() );
|
||||
let mut removed_sorted = removed;
|
||||
removed_sorted.sort();
|
||||
assert_eq!( removed_sorted, ids( &[ 1, 2, 3 ] ) );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn unchanged_set_produces_no_diff()
|
||||
{
|
||||
let ( added, removed ) = diff_overlay_ids( ids( &[ 1, 2, 3 ] ), &ids( &[ 1, 2, 3 ] ) );
|
||||
assert!( added.is_empty(), "no overlays should be added" );
|
||||
assert!( removed.is_empty(), "no overlays should be removed" );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn order_in_next_does_not_matter_for_diff()
|
||||
{
|
||||
// Same set, different order — the overlay surfaces should be reused, not
|
||||
// recreated.
|
||||
let ( added, removed ) = diff_overlay_ids( ids( &[ 1, 2, 3 ] ), &ids( &[ 3, 2, 1 ] ) );
|
||||
assert!( added.is_empty() );
|
||||
assert!( removed.is_empty() );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn open_one_panel_marks_only_it_added()
|
||||
{
|
||||
let ( added, removed ) = diff_overlay_ids( ids( &[ 1 ] ), &ids( &[ 1, 2 ] ) );
|
||||
assert_eq!( added, ids( &[ 2 ] ) );
|
||||
assert!( removed.is_empty() );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn close_one_panel_marks_only_it_removed()
|
||||
{
|
||||
let ( added, removed ) = diff_overlay_ids( ids( &[ 1, 2 ] ), &ids( &[ 1 ] ) );
|
||||
assert!( added.is_empty() );
|
||||
assert_eq!( removed, ids( &[ 2 ] ) );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn swap_one_panel_marks_one_added_one_removed()
|
||||
{
|
||||
let ( added, removed ) = diff_overlay_ids( ids( &[ 1, 2 ] ), &ids( &[ 1, 3 ] ) );
|
||||
assert_eq!( added, ids( &[ 3 ] ) );
|
||||
assert_eq!( removed, ids( &[ 2 ] ) );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn added_preserves_next_order()
|
||||
{
|
||||
// Creation order is deterministic — the runtime instantiates new
|
||||
// surfaces in the order the app returned them, and diffing must
|
||||
// preserve it.
|
||||
let ( added, _removed ) = diff_overlay_ids( ids( &[] ), &ids( &[ 7, 3, 5 ] ) );
|
||||
assert_eq!( added, ids( &[ 7, 3, 5 ] ) );
|
||||
}
|
||||
312
tests/render_pixels.rs
Normal file
312
tests/render_pixels.rs
Normal file
@@ -0,0 +1,312 @@
|
||||
// Pixel-level "golden-like" coverage. We deliberately avoid storing reference
|
||||
// PNG bytes: their value depends on fontdue / tiny-skia / theme versions, so a
|
||||
// byte-exact reference would break on every dependency bump. Instead these
|
||||
// tests assert *structural* properties of the rendered buffer (background
|
||||
// colour, transparency, determinism, focus-state divergence) plus a few
|
||||
// canonical scenes that exercise the partial-redraw and full-redraw paths.
|
||||
|
||||
use ltk::core::{ Canvas, RenderOptions, UiSurface };
|
||||
use ltk::{ button, column, container, spacer, text, Color, Element };
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
fn extract_pixels( surface: &UiSurface<()>, w: u32, h: u32 ) -> Vec<u8>
|
||||
{
|
||||
let mut buf = vec![ 0u8; ( w * h * 4 ) as usize ];
|
||||
// Software canvas only — `Canvas::Gles::write_to_wayland_buf` is a no-op
|
||||
// (presentation goes through `eglSwapBuffers` elsewhere). UiSurface::new
|
||||
// returns a software surface.
|
||||
assert!( matches!( surface.canvas(), Canvas::Software( _ ) ) );
|
||||
surface.canvas().write_to_wayland_buf( &mut buf, false );
|
||||
buf
|
||||
}
|
||||
|
||||
fn fnv1a_64( bytes: &[u8] ) -> u64
|
||||
{
|
||||
let mut h: u64 = 0xcbf29ce484222325;
|
||||
for &b in bytes
|
||||
{
|
||||
h ^= b as u64;
|
||||
h = h.wrapping_mul( 0x100000001b3 );
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
// Approximate equality on a single u8 channel — accounts for AA / rounding
|
||||
// drift on the boundary of background-vs-content fills.
|
||||
fn near( a: u8, b: u8, tol: u8 ) -> bool
|
||||
{
|
||||
a.abs_diff( b ) <= tol
|
||||
}
|
||||
|
||||
// ── background colour ─────────────────────────────────────────────────────────
|
||||
|
||||
#[ test ]
|
||||
fn fully_transparent_background_yields_all_zero_alpha()
|
||||
{
|
||||
let mut surface = UiSurface::<()>::new( 64, 64 );
|
||||
let view: Element<()> = column().into();
|
||||
let _ = surface.render(
|
||||
&view,
|
||||
RenderOptions::full_canvas( 64, 64 ).background( Color::TRANSPARENT ),
|
||||
);
|
||||
let buf = extract_pixels( &surface, 64, 64 );
|
||||
for chunk in buf.chunks_exact( 4 )
|
||||
{
|
||||
// Every pixel ends up with alpha = 0; RGB after premultiplication is
|
||||
// also 0 because the background fill is the only paint.
|
||||
assert_eq!( chunk[ 3 ], 0, "transparent bg must produce zero alpha" );
|
||||
}
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn opaque_background_paints_canonical_rgb_in_corner_pixel()
|
||||
{
|
||||
let mut surface = UiSurface::<()>::new( 64, 64 );
|
||||
let view: Element<()> = column().into();
|
||||
let _ = surface.render(
|
||||
&view,
|
||||
RenderOptions::full_canvas( 64, 64 ).background( Color::rgb( 1.0, 0.25, 0.0 ) ),
|
||||
);
|
||||
let buf = extract_pixels( &surface, 64, 64 );
|
||||
|
||||
// Top-left pixel: nothing draws there beyond the background fill, so the
|
||||
// channels should match (255, ~64, 0, 255) with mild rounding tolerance.
|
||||
assert!( near( buf[ 0 ], 255, 1 ) );
|
||||
assert!( near( buf[ 1 ], 64, 2 ) );
|
||||
assert!( near( buf[ 2 ], 0, 1 ) );
|
||||
assert_eq!( buf[ 3 ], 255 );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn background_fills_every_pixel_when_view_is_empty()
|
||||
{
|
||||
let mut surface = UiSurface::<()>::new( 32, 32 );
|
||||
let view: Element<()> = column().into();
|
||||
let _ = surface.render(
|
||||
&view,
|
||||
RenderOptions::full_canvas( 32, 32 ).background( Color::rgb( 0.0, 0.5, 0.0 ) ),
|
||||
);
|
||||
let buf = extract_pixels( &surface, 32, 32 );
|
||||
for chunk in buf.chunks_exact( 4 )
|
||||
{
|
||||
assert!( near( chunk[ 0 ], 0, 1 ) );
|
||||
assert!( near( chunk[ 1 ], 128, 2 ) );
|
||||
assert!( near( chunk[ 2 ], 0, 1 ) );
|
||||
assert_eq!( chunk[ 3 ], 255 );
|
||||
}
|
||||
}
|
||||
|
||||
// ── determinism ───────────────────────────────────────────────────────────────
|
||||
|
||||
fn counter_view( count: u32 ) -> Element<()>
|
||||
{
|
||||
column::<()>()
|
||||
.padding( 16.0 )
|
||||
.spacing( 8.0 )
|
||||
.push( text( format!( "Count: {count}" ) ) )
|
||||
.push( button( "Increment" ) )
|
||||
.into()
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn rendering_the_same_view_twice_produces_identical_pixel_buffers()
|
||||
{
|
||||
let mut surface = UiSurface::<()>::new( 240, 120 );
|
||||
let opts = RenderOptions::full_canvas( 240, 120 ).background( Color::rgb( 0.05, 0.05, 0.05 ) );
|
||||
|
||||
let _ = surface.render( &counter_view( 0 ), opts );
|
||||
let buf_a = extract_pixels( &surface, 240, 120 );
|
||||
|
||||
// Force a clean re-paint via mark_content_dirty so both passes hit the
|
||||
// same code path (full redraw rather than partial-damage clip).
|
||||
surface.mark_content_dirty();
|
||||
let _ = surface.render( &counter_view( 0 ), opts );
|
||||
let buf_b = extract_pixels( &surface, 240, 120 );
|
||||
|
||||
assert_eq!( buf_a, buf_b, "two identical renders must produce identical pixels" );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn render_hash_is_stable_across_two_back_to_back_passes()
|
||||
{
|
||||
let mut surface = UiSurface::<()>::new( 320, 200 );
|
||||
let opts = RenderOptions::full_canvas( 320, 200 ).background( Color::rgb( 0.1, 0.1, 0.1 ) );
|
||||
|
||||
let _ = surface.render( &counter_view( 7 ), opts );
|
||||
let h1 = fnv1a_64( &extract_pixels( &surface, 320, 200 ) );
|
||||
|
||||
surface.mark_content_dirty();
|
||||
let _ = surface.render( &counter_view( 7 ), opts );
|
||||
let h2 = fnv1a_64( &extract_pixels( &surface, 320, 200 ) );
|
||||
|
||||
assert_eq!( h1, h2, "a deterministic render pass must produce a stable hash" );
|
||||
}
|
||||
|
||||
// ── content presence ──────────────────────────────────────────────────────────
|
||||
|
||||
#[ test ]
|
||||
fn rendering_a_button_produces_pixels_that_differ_from_the_background()
|
||||
{
|
||||
let mut surface = UiSurface::<()>::new( 240, 120 );
|
||||
let bg = Color::rgb( 0.0, 0.0, 0.0 );
|
||||
let view: Element<()> = column::<()>().push( button( "tap" ) ).into();
|
||||
let _ = surface.render(
|
||||
&view,
|
||||
RenderOptions::full_canvas( 240, 120 ).background( bg ),
|
||||
);
|
||||
let buf = extract_pixels( &surface, 240, 120 );
|
||||
|
||||
let differs = buf.chunks_exact( 4 ).any( |chunk|
|
||||
!( chunk[ 0 ] == 0 && chunk[ 1 ] == 0 && chunk[ 2 ] == 0 && chunk[ 3 ] == 255 )
|
||||
);
|
||||
assert!( differs, "a button must paint at least one non-background pixel" );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn rendering_text_produces_pixels_that_differ_from_the_background()
|
||||
{
|
||||
let mut surface = UiSurface::<()>::new( 240, 120 );
|
||||
let bg = Color::rgb( 0.0, 0.0, 0.0 );
|
||||
let view: Element<()> = column::<()>().push( text( "ltk" ).size( 32.0 ) ).into();
|
||||
let _ = surface.render(
|
||||
&view,
|
||||
RenderOptions::full_canvas( 240, 120 ).background( bg ),
|
||||
);
|
||||
let buf = extract_pixels( &surface, 240, 120 );
|
||||
let glyph_pixels = buf.chunks_exact( 4 )
|
||||
.filter( |chunk| !( chunk[ 0 ] == 0 && chunk[ 1 ] == 0 && chunk[ 2 ] == 0 ) )
|
||||
.count();
|
||||
assert!( glyph_pixels > 0, "text rasterisation must produce non-bg pixels" );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn container_with_explicit_color_fill_paints_inside_padding()
|
||||
{
|
||||
// Fill a sized container with a known colour, render on a black
|
||||
// background, and confirm a measurable region of red pixels appears.
|
||||
let mut surface = UiSurface::<()>::new( 100, 100 );
|
||||
let view: Element<()> = column::<()>()
|
||||
.padding( 0.0 )
|
||||
.push(
|
||||
container::<()>( spacer().width( 40.0 ).height( 40.0 ) )
|
||||
.background( Color::rgb( 1.0, 0.0, 0.0 ) ),
|
||||
)
|
||||
.into();
|
||||
let _ = surface.render(
|
||||
&view,
|
||||
RenderOptions::full_canvas( 100, 100 ).background( Color::BLACK ),
|
||||
);
|
||||
let bytes = extract_pixels( &surface, 100, 100 );
|
||||
|
||||
let red_pixels = bytes.chunks_exact( 4 )
|
||||
.filter( |c| c[ 0 ] > 200 && c[ 1 ] < 30 && c[ 2 ] < 30 && c[ 3 ] == 255 )
|
||||
.count();
|
||||
// A 40×40 fill produces 1600 fully-saturated red pixels; allow a wide
|
||||
// floor to absorb anti-aliased edge pixels that mix with the background.
|
||||
assert!(
|
||||
red_pixels > 500,
|
||||
"container with red fill must produce a substantial red region (got {red_pixels})",
|
||||
);
|
||||
}
|
||||
|
||||
// ── focus-state divergence ────────────────────────────────────────────────────
|
||||
|
||||
#[ test ]
|
||||
fn focusing_a_button_changes_pixels_inside_its_paint_rect()
|
||||
{
|
||||
// The focus ring is one of the cheapest ways to verify the partial-
|
||||
// redraw path actually paints something different. Render with no focus,
|
||||
// snapshot the pixels, set focus, render again, and confirm the buffer
|
||||
// has changed somewhere.
|
||||
let mut surface = UiSurface::<()>::new( 200, 80 );
|
||||
let opts = RenderOptions::full_canvas( 200, 80 ).background( Color::rgb( 0.05, 0.05, 0.05 ) );
|
||||
let view: Element<()> = column::<()>().push( button( "tap" ) ).into();
|
||||
|
||||
let _ = surface.render( &view, opts );
|
||||
let unfocused = extract_pixels( &surface, 200, 80 );
|
||||
|
||||
let idx = surface.widget_rects()[ 0 ].flat_idx;
|
||||
surface.set_focused( Some( idx ) );
|
||||
let _ = surface.render( &view, opts );
|
||||
let focused = extract_pixels( &surface, 200, 80 );
|
||||
|
||||
assert_ne!(
|
||||
unfocused, focused,
|
||||
"focus state change must produce visibly different pixels",
|
||||
);
|
||||
}
|
||||
|
||||
// ── invariants ────────────────────────────────────────────────────────────────
|
||||
|
||||
#[ test ]
|
||||
fn pixel_buffer_length_matches_canvas_size()
|
||||
{
|
||||
let surface = UiSurface::<()>::new( 100, 50 );
|
||||
let mut buf = vec![ 0u8; 100 * 50 * 4 ];
|
||||
surface.canvas().write_to_wayland_buf( &mut buf, false );
|
||||
// All pixels are zero before render — confirms the buffer is correctly
|
||||
// sized and write_to_wayland_buf doesn't overwrite past the end.
|
||||
assert!( buf.iter().all( |&b| b == 0 ) );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn resize_changes_the_required_buffer_length()
|
||||
{
|
||||
let mut surface = UiSurface::<()>::new( 64, 64 );
|
||||
let view: Element<()> = column().into();
|
||||
let opts = RenderOptions::full_canvas( 64, 64 );
|
||||
let _ = surface.render( &view, opts );
|
||||
|
||||
surface.resize( 128, 96 );
|
||||
assert_eq!( surface.size(), ( 128, 96 ) );
|
||||
|
||||
// Old buffer would be too short; the new size dictates a 49152-byte
|
||||
// buffer. Confirm `write_to_wayland_buf` accepts and fills it without
|
||||
// panicking.
|
||||
let mut new_buf = vec![ 0u8; 128 * 96 * 4 ];
|
||||
let _ = surface.render(
|
||||
&view,
|
||||
RenderOptions::full_canvas( 128, 96 ).background( Color::rgb( 0.0, 0.0, 0.5 ) ),
|
||||
);
|
||||
surface.canvas().write_to_wayland_buf( &mut new_buf, false );
|
||||
|
||||
// Last pixel should carry the navy bg.
|
||||
let last = &new_buf[ new_buf.len() - 4 .. ];
|
||||
assert!( near( last[ 2 ], 128, 2 ), "last pixel blue channel ≈ 128" );
|
||||
assert_eq!( last[ 3 ], 255 );
|
||||
}
|
||||
|
||||
// ── canonical scene byte equality ─────────────────────────────────────────────
|
||||
|
||||
#[ test ]
|
||||
fn canonical_solid_red_scene_has_exact_byte_layout()
|
||||
{
|
||||
// A scene with NO text (so no font-version drift) and a colour with NO
|
||||
// rounding ambiguity (1.0 → 255, 0.0 → 0). The buffer is portable
|
||||
// across machines because the path is plain rectangle clear → tiny-skia
|
||||
// premul → byte copy, and the values land exactly. If this assertion
|
||||
// drifts, either the renderer or the buffer format changed; investigate
|
||||
// before regenerating.
|
||||
let mut surface = UiSurface::<()>::new( 16, 16 );
|
||||
let view: Element<()> = column().into();
|
||||
let _ = surface.render(
|
||||
&view,
|
||||
RenderOptions::full_canvas( 16, 16 ).background( Color::rgb( 1.0, 0.0, 0.0 ) ),
|
||||
);
|
||||
let actual = extract_pixels( &surface, 16, 16 );
|
||||
let expected: Vec<u8> = ( 0..16 * 16 ).flat_map( |_| [ 255u8, 0, 0, 255 ] ).collect();
|
||||
assert_eq!( actual, expected, "16×16 solid red must serialise to exact RGBA bytes" );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn fnv1a_hash_changes_when_a_single_byte_flips()
|
||||
{
|
||||
// Sanity check on the in-test hash function itself — guards the rest of
|
||||
// the determinism tests against a regression that breaks the hash but
|
||||
// leaves the assertions trivially passing.
|
||||
let a = [ 0u8, 0, 0, 0 ];
|
||||
let mut b = a;
|
||||
b[ 2 ] = 1;
|
||||
assert_ne!( fnv1a_64( &a ), fnv1a_64( &b ) );
|
||||
}
|
||||
108
tests/scroll.rs
Normal file
108
tests/scroll.rs
Normal file
@@ -0,0 +1,108 @@
|
||||
// Integration coverage for `scroll`. The pure clamping math already lives in
|
||||
// `widget::scroll::tests`; these tests exercise the wiring: `preferred_size`
|
||||
// claims remaining space, and interactive children inside the viewport remain
|
||||
// reachable through `UiSurface`'s widget rects.
|
||||
|
||||
use ltk::core::{ Canvas, RenderOptions, UiSurface };
|
||||
use ltk::{ button, column, scroll, text, Color, Element, Point };
|
||||
|
||||
#[ derive( Clone, Debug, PartialEq, Eq ) ]
|
||||
enum Msg
|
||||
{
|
||||
Pressed( u32 ),
|
||||
}
|
||||
|
||||
fn list_view() -> Element<Msg>
|
||||
{
|
||||
scroll(
|
||||
column::<Msg>()
|
||||
.padding( 0.0 )
|
||||
.spacing( 4.0 )
|
||||
.push( button( "A" ).on_press( Msg::Pressed( 0 ) ) )
|
||||
.push( button( "B" ).on_press( Msg::Pressed( 1 ) ) )
|
||||
.push( button( "C" ).on_press( Msg::Pressed( 2 ) ) ),
|
||||
).into()
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn scroll_preferred_size_returns_zero_height_to_act_as_filler()
|
||||
{
|
||||
let canvas = Canvas::new( 800, 600 );
|
||||
let s = scroll::<Msg>( column::<Msg>() );
|
||||
let ( w, h ) = s.preferred_size( 320.0, &canvas );
|
||||
assert_eq!( w, 320.0 );
|
||||
assert_eq!( h, 0.0 );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn scroll_with_buttons_exposes_them_through_widget_rects()
|
||||
{
|
||||
let mut surface = UiSurface::<Msg>::new( 320, 480 );
|
||||
let _ = surface.render(
|
||||
&list_view(),
|
||||
RenderOptions::full_canvas( 320, 480 ).background( Color::rgb( 0.1, 0.1, 0.1 ) ),
|
||||
);
|
||||
// Three interactive buttons are visible inside the scroll viewport.
|
||||
assert_eq!( surface.widget_rects().len(), 3 );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn buttons_inside_scroll_dispatch_their_press_messages()
|
||||
{
|
||||
let mut surface = UiSurface::<Msg>::new( 320, 480 );
|
||||
let _ = surface.render(
|
||||
&list_view(),
|
||||
RenderOptions::full_canvas( 320, 480 ),
|
||||
);
|
||||
let first = &surface.widget_rects()[ 0 ];
|
||||
let msg = surface.handlers( first.flat_idx ).and_then( |h| h.press_msg() );
|
||||
assert_eq!( msg, Some( Msg::Pressed( 0 ) ) );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn hit_test_inside_scroll_returns_button_under_pointer()
|
||||
{
|
||||
let mut surface = UiSurface::<Msg>::new( 320, 480 );
|
||||
let _ = surface.render(
|
||||
&list_view(),
|
||||
RenderOptions::full_canvas( 320, 480 ),
|
||||
);
|
||||
let first = &surface.widget_rects()[ 0 ];
|
||||
let centre = Point
|
||||
{
|
||||
x: first.rect.x + first.rect.width * 0.5,
|
||||
y: first.rect.y + first.rect.height * 0.5,
|
||||
};
|
||||
assert_eq!( surface.hit_test( centre ), Some( first.flat_idx ) );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn empty_scroll_renders_without_panic()
|
||||
{
|
||||
let mut surface = UiSurface::<Msg>::new( 320, 480 );
|
||||
let view: Element<Msg> = scroll( column::<Msg>() ).into();
|
||||
let out = surface.render(
|
||||
&view,
|
||||
RenderOptions::full_canvas( 320, 480 ),
|
||||
);
|
||||
assert!( out.full_redraw );
|
||||
assert!( surface.widget_rects().is_empty(), "no buttons inside → no interactive widgets" );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn scroll_around_text_only_child_produces_no_interactive_widgets()
|
||||
{
|
||||
let mut surface = UiSurface::<Msg>::new( 320, 480 );
|
||||
let view: Element<Msg> = scroll(
|
||||
column::<Msg>()
|
||||
.push( text( "header" ) )
|
||||
.push( text( "body" ) ),
|
||||
).into();
|
||||
let _ = surface.render(
|
||||
&view,
|
||||
RenderOptions::full_canvas( 320, 480 ),
|
||||
);
|
||||
// Text is non-interactive — widget_rects only tracks focusable / hittable
|
||||
// widgets.
|
||||
assert!( surface.widget_rects().is_empty() );
|
||||
}
|
||||
112
tests/security_upload.rs
Normal file
112
tests/security_upload.rs
Normal file
@@ -0,0 +1,112 @@
|
||||
// Defensive coverage for the image-upload boundary. The GLES path requires a
|
||||
// live GL context (out of scope for these CPU-only tests) so the assertions
|
||||
// below exercise the software backend, which shares the same length-vs-
|
||||
// dimensions check before delegating to tiny-skia. Both paths:
|
||||
//
|
||||
// * refuse the upload when `data.len() != width * height * 4`,
|
||||
// * refuse zero-area uploads (`width == 0` or `height == 0`),
|
||||
// * never panic — they log to stderr and return without drawing.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use ltk::core::{ RenderOptions, UiSurface };
|
||||
use ltk::{ column, img_widget, Color, Element };
|
||||
|
||||
fn render_image( rgba: Arc<Vec<u8>>, w: u32, h: u32 ) -> UiSurface<()>
|
||||
{
|
||||
let mut surface = UiSurface::<()>::new( 64, 64 );
|
||||
let view: Element<()> = column()
|
||||
.padding( 0.0 )
|
||||
.push( img_widget( rgba, w, h ) )
|
||||
.into();
|
||||
let _ = surface.render(
|
||||
&view,
|
||||
RenderOptions::full_canvas( 64, 64 ).background( Color::BLACK ),
|
||||
);
|
||||
surface
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn declared_size_matching_buffer_renders_without_panic()
|
||||
{
|
||||
// Sanity baseline: a 4×4 image with the expected 64-byte buffer must
|
||||
// render through the path that the validation tests below exercise.
|
||||
let bytes = Arc::new( vec![ 0xFFu8; 4 * 4 * 4 ] );
|
||||
let _ = render_image( bytes, 4, 4 );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn shorter_buffer_is_rejected_without_panic()
|
||||
{
|
||||
// 10×10 declared but only 16 bytes provided. Without the boundary
|
||||
// check tiny-skia would walk past the slice end inside its premul
|
||||
// loop. With the check the renderer logs once and skips the draw.
|
||||
let bytes = Arc::new( vec![ 0u8; 16 ] );
|
||||
let _ = render_image( bytes, 10, 10 );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn longer_buffer_is_rejected_without_panic()
|
||||
{
|
||||
// 2×2 declared, 256 bytes provided. The buffer is larger than the
|
||||
// driver would read, but the size mismatch still fails the strict
|
||||
// equality and the draw is refused.
|
||||
let bytes = Arc::new( vec![ 0u8; 256 ] );
|
||||
let _ = render_image( bytes, 2, 2 );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn zero_width_image_does_not_blit()
|
||||
{
|
||||
// IntSize::from_wh rejects zero dimensions on tiny-skia, but the
|
||||
// boundary check fires earlier for a uniform error path on both
|
||||
// backends.
|
||||
let bytes = Arc::new( vec![] );
|
||||
let _ = render_image( bytes, 0, 10 );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn zero_height_image_does_not_blit()
|
||||
{
|
||||
let bytes = Arc::new( vec![] );
|
||||
let _ = render_image( bytes, 10, 0 );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn non_multiple_of_four_buffer_is_rejected()
|
||||
{
|
||||
// 1×1 should be 4 bytes; 3 bytes is shorter and not a multiple of 4 —
|
||||
// every byte counts.
|
||||
let bytes = Arc::new( vec![ 0xFF, 0xFF, 0xFF ] );
|
||||
let _ = render_image( bytes, 1, 1 );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn dimensions_overflow_usize_are_rejected_without_panic()
|
||||
{
|
||||
// `u32::MAX × u32::MAX × 4` overflows. The boundary check uses
|
||||
// `saturating_mul` (or i64-promotion in the GLES path) so the
|
||||
// expected length never wraps to a small number that happens to
|
||||
// match a real buffer length. The provided buffer is small, so the
|
||||
// equality fails and the draw is refused.
|
||||
let bytes = Arc::new( vec![ 0u8; 16 ] );
|
||||
let _ = render_image( bytes, u32::MAX, u32::MAX );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn renderer_recovers_after_a_rejected_upload()
|
||||
{
|
||||
// A frame with a malformed image must not poison the surface. The
|
||||
// next render with a valid view succeeds.
|
||||
let bad = Arc::new( vec![ 0u8; 5 ] );
|
||||
let mut s = render_image( bad, 4, 4 );
|
||||
|
||||
let view: Element<()> = column().padding( 0.0 ).into();
|
||||
let out = s.render(
|
||||
&view,
|
||||
RenderOptions::full_canvas( 64, 64 ).background( Color::rgb( 0.0, 1.0, 0.0 ) ),
|
||||
);
|
||||
// Tree shape change → full redraw, but the important check is that
|
||||
// render() returned at all.
|
||||
let _ = out;
|
||||
}
|
||||
65
tests/slider_math.rs
Normal file
65
tests/slider_math.rs
Normal file
@@ -0,0 +1,65 @@
|
||||
use ltk::test_support::value_from_x_in_rect;
|
||||
use ltk::Rect;
|
||||
|
||||
fn rect( x: f32, y: f32, w: f32, h: f32 ) -> Rect
|
||||
{
|
||||
Rect { x, y, width: w, height: h }
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn left_edge_clamps_to_zero()
|
||||
{
|
||||
let r = rect( 0.0, 0.0, 200.0, 36.0 );
|
||||
assert_eq!( value_from_x_in_rect( r, 0.0 ), 0.0 );
|
||||
assert_eq!( value_from_x_in_rect( r, -50.0 ), 0.0 );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn right_edge_clamps_to_one()
|
||||
{
|
||||
let r = rect( 0.0, 0.0, 200.0, 36.0 );
|
||||
assert_eq!( value_from_x_in_rect( r, 200.0 ), 1.0 );
|
||||
assert_eq!( value_from_x_in_rect( r, 999.0 ), 1.0 );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn center_returns_half()
|
||||
{
|
||||
// The thumb adds padding on both sides, so the geometric center of the
|
||||
// rect produces ~0.5 (within the THUMB_SIZE/2 tolerance).
|
||||
let r = rect( 0.0, 0.0, 200.0, 36.0 );
|
||||
let v = value_from_x_in_rect( r, 100.0 );
|
||||
assert!( ( v - 0.5 ).abs() < 0.1, "expected ~0.5 got {}", v );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn rect_offset_translates_correctly()
|
||||
{
|
||||
let r = rect( 500.0, 0.0, 200.0, 36.0 );
|
||||
assert_eq!( value_from_x_in_rect( r, 500.0 ), 0.0 );
|
||||
assert_eq!( value_from_x_in_rect( r, 700.0 ), 1.0 );
|
||||
let v = value_from_x_in_rect( r, 600.0 );
|
||||
assert!( ( v - 0.5 ).abs() < 0.1, "expected ~0.5 got {}", v );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn output_is_always_in_unit_range()
|
||||
{
|
||||
let r = rect( 10.0, 10.0, 300.0, 36.0 );
|
||||
for x in -100i32 ..= 500
|
||||
{
|
||||
let v = value_from_x_in_rect( r, x as f32 );
|
||||
assert!( ( 0.0..= 1.0 ).contains( &v ), "v={} out of range at x={}", v, x );
|
||||
}
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn very_narrow_rect_does_not_divide_by_zero()
|
||||
{
|
||||
// Width < THUMB_SIZE — track_w is clamped to 1.0 internally so the
|
||||
// formula stays defined.
|
||||
let r = rect( 0.0, 0.0, 4.0, 36.0 );
|
||||
let v = value_from_x_in_rect( r, 2.0 );
|
||||
assert!( v.is_finite() );
|
||||
assert!( ( 0.0..= 1.0 ).contains( &v ) );
|
||||
}
|
||||
107
tests/tab_navigation.rs
Normal file
107
tests/tab_navigation.rs
Normal file
@@ -0,0 +1,107 @@
|
||||
mod common;
|
||||
use common::{ lw_none, rect };
|
||||
|
||||
use ltk::test_support::next_focusable_index;
|
||||
|
||||
// ── Empty / single-element edge cases ─────────────────────────────────────────
|
||||
|
||||
#[ test ]
|
||||
fn empty_widget_list_returns_none()
|
||||
{
|
||||
let widgets: Vec<_> = vec![];
|
||||
assert_eq!( next_focusable_index::<()>( &widgets, None, false ), None );
|
||||
assert_eq!( next_focusable_index::<()>( &widgets, Some( 42 ), false ), None );
|
||||
assert_eq!( next_focusable_index::<()>( &widgets, None, true ), None );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn single_widget_wraps_to_itself()
|
||||
{
|
||||
let widgets = vec![ lw_none( 7, rect( 0.0, 0.0, 10.0, 10.0 ) ) ];
|
||||
assert_eq!( next_focusable_index( &widgets, Some( 7 ), false ), Some( 7 ) );
|
||||
assert_eq!( next_focusable_index( &widgets, Some( 7 ), true ), Some( 7 ) );
|
||||
}
|
||||
|
||||
// ── Forward (Tab) ─────────────────────────────────────────────────────────────
|
||||
|
||||
#[ test ]
|
||||
fn forward_advances_to_next_in_slice_order()
|
||||
{
|
||||
let widgets = vec![
|
||||
lw_none( 10, rect( 0.0, 0.0, 10.0, 10.0 ) ),
|
||||
lw_none( 20, rect( 0.0, 0.0, 10.0, 10.0 ) ),
|
||||
lw_none( 30, rect( 0.0, 0.0, 10.0, 10.0 ) ),
|
||||
];
|
||||
assert_eq!( next_focusable_index( &widgets, Some( 10 ), false ), Some( 20 ) );
|
||||
assert_eq!( next_focusable_index( &widgets, Some( 20 ), false ), Some( 30 ) );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn forward_wraps_around_at_end()
|
||||
{
|
||||
let widgets = vec![
|
||||
lw_none( 1, rect( 0.0, 0.0, 10.0, 10.0 ) ),
|
||||
lw_none( 2, rect( 0.0, 0.0, 10.0, 10.0 ) ),
|
||||
];
|
||||
assert_eq!( next_focusable_index( &widgets, Some( 2 ), false ), Some( 1 ) );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn forward_with_no_focus_targets_first_widget()
|
||||
{
|
||||
let widgets = vec![
|
||||
lw_none( 100, rect( 0.0, 0.0, 10.0, 10.0 ) ),
|
||||
lw_none( 200, rect( 0.0, 0.0, 10.0, 10.0 ) ),
|
||||
];
|
||||
assert_eq!( next_focusable_index( &widgets, None, false ), Some( 100 ) );
|
||||
}
|
||||
|
||||
// ── Reverse (Shift+Tab) ───────────────────────────────────────────────────────
|
||||
|
||||
#[ test ]
|
||||
fn reverse_steps_to_previous()
|
||||
{
|
||||
let widgets = vec![
|
||||
lw_none( 10, rect( 0.0, 0.0, 10.0, 10.0 ) ),
|
||||
lw_none( 20, rect( 0.0, 0.0, 10.0, 10.0 ) ),
|
||||
lw_none( 30, rect( 0.0, 0.0, 10.0, 10.0 ) ),
|
||||
];
|
||||
assert_eq!( next_focusable_index( &widgets, Some( 30 ), true ), Some( 20 ) );
|
||||
assert_eq!( next_focusable_index( &widgets, Some( 20 ), true ), Some( 10 ) );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn reverse_wraps_around_at_start()
|
||||
{
|
||||
let widgets = vec![
|
||||
lw_none( 10, rect( 0.0, 0.0, 10.0, 10.0 ) ),
|
||||
lw_none( 20, rect( 0.0, 0.0, 10.0, 10.0 ) ),
|
||||
];
|
||||
assert_eq!( next_focusable_index( &widgets, Some( 10 ), true ), Some( 20 ) );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn reverse_with_no_focus_targets_last_widget()
|
||||
{
|
||||
let widgets = vec![
|
||||
lw_none( 100, rect( 0.0, 0.0, 10.0, 10.0 ) ),
|
||||
lw_none( 200, rect( 0.0, 0.0, 10.0, 10.0 ) ),
|
||||
];
|
||||
assert_eq!( next_focusable_index( &widgets, None, true ), Some( 200 ) );
|
||||
}
|
||||
|
||||
// ── Stale focus index ─────────────────────────────────────────────────────────
|
||||
|
||||
#[ test ]
|
||||
fn stale_focus_idx_falls_back_to_first()
|
||||
{
|
||||
// Common after a re-layout removes the previously-focused widget: the
|
||||
// stored focus idx no longer maps to any entry, and the walker falls
|
||||
// back to the same behaviour as `None`.
|
||||
let widgets = vec![
|
||||
lw_none( 10, rect( 0.0, 0.0, 10.0, 10.0 ) ),
|
||||
lw_none( 20, rect( 0.0, 0.0, 10.0, 10.0 ) ),
|
||||
];
|
||||
assert_eq!( next_focusable_index( &widgets, Some( 999 ), false ), Some( 10 ) );
|
||||
assert_eq!( next_focusable_index( &widgets, Some( 999 ), true ), Some( 20 ) );
|
||||
}
|
||||
207
tests/text_edit_dispatch.rs
Normal file
207
tests/text_edit_dispatch.rs
Normal file
@@ -0,0 +1,207 @@
|
||||
// End-to-end coverage for the `text_edit` ↔ event-loop contract: the layout
|
||||
// pass snapshots the widget's `on_change` / `on_submit` / `value` into a
|
||||
// `WidgetHandlers::TextEdit` entry, and the runtime drives keystrokes through
|
||||
// `text_change_msg` / `submit_msg` / `current_value`. These tests skip the
|
||||
// Wayland loop and exercise that contract directly through `UiSurface`.
|
||||
|
||||
use ltk::core::{ RenderOptions, UiSurface };
|
||||
use ltk::test_support::WidgetHandlers;
|
||||
use ltk::{ column, text_edit, Color, Element };
|
||||
|
||||
#[ derive( Clone, Debug, PartialEq, Eq ) ]
|
||||
enum Msg
|
||||
{
|
||||
Username( String ),
|
||||
Password( String ),
|
||||
UsernameSubmit,
|
||||
PasswordSubmit,
|
||||
}
|
||||
|
||||
fn login_view() -> Element<Msg>
|
||||
{
|
||||
column::<Msg>()
|
||||
.padding( 16.0 )
|
||||
.spacing( 8.0 )
|
||||
.push(
|
||||
text_edit( "Username", "alice" )
|
||||
.on_change( |s| Msg::Username( s ) )
|
||||
.on_submit( Msg::UsernameSubmit ),
|
||||
)
|
||||
.push(
|
||||
text_edit::<Msg>( "Password", "" )
|
||||
.secure( true )
|
||||
.on_change( |s| Msg::Password( s ) )
|
||||
.on_submit( Msg::PasswordSubmit ),
|
||||
)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn render_login() -> UiSurface<Msg>
|
||||
{
|
||||
let mut surface = UiSurface::<Msg>::new( 320, 240 );
|
||||
let _ = surface.render(
|
||||
&login_view(),
|
||||
RenderOptions::full_canvas( 320, 240 ).background( Color::rgb( 0.1, 0.1, 0.1 ) ),
|
||||
);
|
||||
surface
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn layout_records_two_text_edit_widgets()
|
||||
{
|
||||
let surface = render_login();
|
||||
assert_eq!( surface.widget_rects().len(), 2 );
|
||||
for w in surface.widget_rects()
|
||||
{
|
||||
assert!( w.handlers.is_text_input() );
|
||||
}
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn text_change_msg_carries_new_value_through_user_callback()
|
||||
{
|
||||
let surface = render_login();
|
||||
let username_idx = surface.widget_rects()[ 0 ].flat_idx;
|
||||
|
||||
let h = surface.handlers( username_idx ).expect( "text edit handler should exist" );
|
||||
let msg = h.text_change_msg( "alic" ); // user pressed backspace once
|
||||
assert_eq!( msg, Some( Msg::Username( "alic".into() ) ) );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn text_change_msg_passes_unicode_unchanged()
|
||||
{
|
||||
let surface = render_login();
|
||||
let username_idx = surface.widget_rects()[ 0 ].flat_idx;
|
||||
let h = surface.handlers( username_idx ).unwrap();
|
||||
|
||||
let msg = h.text_change_msg( "café 🦀" );
|
||||
assert_eq!( msg, Some( Msg::Username( "café 🦀".into() ) ) );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn submit_msg_returns_configured_message()
|
||||
{
|
||||
let surface = render_login();
|
||||
let password_idx = surface.widget_rects()[ 1 ].flat_idx;
|
||||
let h = surface.handlers( password_idx ).unwrap();
|
||||
assert_eq!( h.submit_msg(), Some( Msg::PasswordSubmit ) );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn each_text_edit_dispatches_to_its_own_callback()
|
||||
{
|
||||
// The two fields share the same `Msg` enum but differ in which variant
|
||||
// they wrap the new value in — confirms the handler snapshot captured
|
||||
// the right closure for the right widget.
|
||||
let surface = render_login();
|
||||
let username_h = surface.handlers( surface.widget_rects()[ 0 ].flat_idx ).unwrap();
|
||||
let password_h = surface.handlers( surface.widget_rects()[ 1 ].flat_idx ).unwrap();
|
||||
|
||||
assert_eq!( username_h.text_change_msg( "x" ), Some( Msg::Username( "x".into() ) ) );
|
||||
assert_eq!( password_h.text_change_msg( "x" ), Some( Msg::Password( "x".into() ) ) );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn current_value_reflects_initial_state()
|
||||
{
|
||||
let surface = render_login();
|
||||
let username_h = surface.handlers( surface.widget_rects()[ 0 ].flat_idx ).unwrap();
|
||||
let password_h = surface.handlers( surface.widget_rects()[ 1 ].flat_idx ).unwrap();
|
||||
|
||||
assert_eq!( username_h.current_value(), Some( "alice" ) );
|
||||
assert_eq!( password_h.current_value(), Some( "" ) );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn current_value_reflects_state_after_app_update()
|
||||
{
|
||||
// User types into the username field. The app updates state and the next
|
||||
// render must snapshot the new value into the handler.
|
||||
let mut surface = UiSurface::<Msg>::new( 320, 240 );
|
||||
let mut username = String::from( "alice" );
|
||||
|
||||
let view = |val: &str| -> Element<Msg>
|
||||
{
|
||||
column::<Msg>()
|
||||
.padding( 16.0 )
|
||||
.push(
|
||||
text_edit( "Username", val.to_string() )
|
||||
.on_change( |s| Msg::Username( s ) ),
|
||||
)
|
||||
.into()
|
||||
};
|
||||
|
||||
let opts = RenderOptions::full_canvas( 320, 240 );
|
||||
let _ = surface.render( &view( &username ), opts );
|
||||
assert_eq!(
|
||||
surface.handlers( surface.widget_rects()[ 0 ].flat_idx ).unwrap().current_value(),
|
||||
Some( "alice" ),
|
||||
);
|
||||
|
||||
username.push_str( "_2" );
|
||||
let _ = surface.render( &view( &username ), opts );
|
||||
assert_eq!(
|
||||
surface.handlers( surface.widget_rects()[ 0 ].flat_idx ).unwrap().current_value(),
|
||||
Some( "alice_2" ),
|
||||
);
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn is_text_input_distinguishes_text_edit_from_other_handlers()
|
||||
{
|
||||
use ltk::button;
|
||||
let mut surface = UiSurface::<Msg>::new( 240, 200 );
|
||||
let view: Element<Msg> = column()
|
||||
.push( text_edit::<Msg>( "p", "" ) )
|
||||
.push( button( "go" ).on_press( Msg::UsernameSubmit ) )
|
||||
.into();
|
||||
let _ = surface.render( &view, RenderOptions::full_canvas( 240, 200 ) );
|
||||
|
||||
let h0 = surface.handlers( surface.widget_rects()[ 0 ].flat_idx ).unwrap();
|
||||
let h1 = surface.handlers( surface.widget_rects()[ 1 ].flat_idx ).unwrap();
|
||||
assert!( h0.is_text_input() );
|
||||
assert!( !h1.is_text_input() );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn submit_msg_returns_none_for_non_text_edit_handlers()
|
||||
{
|
||||
use ltk::button;
|
||||
let mut surface = UiSurface::<Msg>::new( 240, 100 );
|
||||
let view: Element<Msg> = column()
|
||||
.push( button( "go" ).on_press( Msg::UsernameSubmit ) )
|
||||
.into();
|
||||
let _ = surface.render( &view, RenderOptions::full_canvas( 240, 100 ) );
|
||||
|
||||
let h = surface.handlers( surface.widget_rects()[ 0 ].flat_idx ).unwrap();
|
||||
assert!( h.submit_msg().is_none() );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn text_change_msg_returns_none_when_no_callback_configured()
|
||||
{
|
||||
let mut surface = UiSurface::<Msg>::new( 240, 100 );
|
||||
let view: Element<Msg> = column()
|
||||
.push( text_edit::<Msg>( "no callback", "x" ) )
|
||||
.into();
|
||||
let _ = surface.render( &view, RenderOptions::full_canvas( 240, 100 ) );
|
||||
|
||||
let h = surface.handlers( surface.widget_rects()[ 0 ].flat_idx ).unwrap();
|
||||
assert!( h.text_change_msg( "y" ).is_none() );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn handler_variant_for_text_edit_carries_value_field()
|
||||
{
|
||||
// Confirm the layout pass populates the `value` field of the
|
||||
// `WidgetHandlers::TextEdit` snapshot — input dispatch reads it for cursor
|
||||
// placement / backspace rebuild.
|
||||
let surface = render_login();
|
||||
let h = surface.handlers( surface.widget_rects()[ 0 ].flat_idx ).unwrap();
|
||||
match h
|
||||
{
|
||||
WidgetHandlers::TextEdit { value, .. } => assert_eq!( value, "alice" ),
|
||||
_ => panic!( "expected TextEdit handler variant" ),
|
||||
}
|
||||
}
|
||||
399
tests/theme_parsing.rs
Normal file
399
tests/theme_parsing.rs
Normal file
@@ -0,0 +1,399 @@
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::{ AtomicU32, Ordering };
|
||||
|
||||
use ltk::{ ThemeDocument, ThemeMode, WallpaperFit };
|
||||
|
||||
/// Serialises tests that mutate the process-global active theme state
|
||||
/// (`set_active_mode`, `theme_wallpaper`, …). `cargo test` runs tests
|
||||
/// in parallel by default; without this guard a Light test can read
|
||||
/// Dark state set by a concurrent test mid-flight.
|
||||
static THEME_STATE_LOCK: Mutex<()> = Mutex::new( () );
|
||||
|
||||
// ── Per-test temp dir ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Build a unique temp dir per test invocation. `process::id()` alone is not
|
||||
/// enough — `cargo test` runs tests in the same process so a stale dir from a
|
||||
/// previous test in the same run would collide. Add a process-local counter.
|
||||
fn fresh_temp_dir( tag: &str ) -> PathBuf
|
||||
{
|
||||
static COUNTER: AtomicU32 = AtomicU32::new( 0 );
|
||||
let n = COUNTER.fetch_add( 1, Ordering::SeqCst );
|
||||
let dir = std::env::temp_dir()
|
||||
.join( format!( "ltk-theme-test-{}-{}-{}", std::process::id(), tag, n ) );
|
||||
let _ = std::fs::remove_dir_all( &dir );
|
||||
std::fs::create_dir_all( &dir ).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
const MINIMAL_THEME_JSON: &str = r##"
|
||||
{
|
||||
"theme": { "id": "demo", "name": "Demo" },
|
||||
"fonts": {
|
||||
"sora": { "name": "Sora", "fallbacks": [], "sources": [] }
|
||||
},
|
||||
"modes": {
|
||||
"light": {
|
||||
"wallpaper": { "path": "wp/light.jpg", "fit": "cover" },
|
||||
"lockscreen": { "path": "wp/lock-light.jpg", "fit": "cover" },
|
||||
"launcher": { "background": "#FFFFFFE6", "border_radius": 24.0 },
|
||||
"window_controls": {
|
||||
"icon": "#112233",
|
||||
"hover_bg": "#11223322",
|
||||
"pressed_bg": "#11223333",
|
||||
"close_hover_bg": "#AA0000",
|
||||
"close_icon": "#FFFFFF",
|
||||
"focus_ring": "#00CEB1"
|
||||
},
|
||||
"slots": {
|
||||
"bg-page": { "type": "color", "value": "#FFFFFF" },
|
||||
"surface": { "type": "color", "value": "#FFFFFF99" },
|
||||
"surface-alt": { "type": "color", "value": "#FFFFFFD9" },
|
||||
"text-primary": { "type": "color", "value": "#102030" },
|
||||
"text-secondary": { "type": "color", "value": "#10203080" },
|
||||
"accent": { "type": "color", "value": "#00CEB1" },
|
||||
"divider": { "type": "color", "value": "#00000014" },
|
||||
"icon": { "type": "color", "value": "#000000" }
|
||||
}
|
||||
},
|
||||
"dark": {
|
||||
"wallpaper": { "path": "wp/dark.jpg", "fit": "contain" },
|
||||
"lockscreen": { "path": "wp/lock-dark.jpg", "fit": "cover" },
|
||||
"launcher": { "background": "#221A5AE6", "border_radius": 24.0 },
|
||||
"window_controls": {
|
||||
"icon": "#EEEEEE",
|
||||
"hover_bg": "#FFFFFF18",
|
||||
"pressed_bg": "#FFFFFF26",
|
||||
"close_hover_bg": "#E5484D",
|
||||
"close_icon": "#FFFFFF",
|
||||
"focus_ring": "#00CEB1"
|
||||
},
|
||||
"slots": {
|
||||
"bg-page": { "type": "color", "value": "#000000" },
|
||||
"surface": { "type": "color", "value": "#18104599" },
|
||||
"surface-alt": { "type": "color", "value": "#221A5AD9" },
|
||||
"text-primary": { "type": "color", "value": "#FFFFFF" },
|
||||
"text-secondary": { "type": "color", "value": "#FFFFFFB3" },
|
||||
"accent": { "type": "color", "value": "#00CEB1" },
|
||||
"divider": { "type": "color", "value": "#FFFFFF14" },
|
||||
"icon": { "type": "color", "value": "#FFFFFF" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"##;
|
||||
|
||||
// ── Round-trip ────────────────────────────────────────────────────────────────
|
||||
|
||||
#[ test ]
|
||||
fn parses_id_name_and_both_modes()
|
||||
{
|
||||
let dir = fresh_temp_dir( "round-trip" );
|
||||
std::fs::write( dir.join( "theme.json" ), MINIMAL_THEME_JSON ).unwrap();
|
||||
|
||||
let doc = ThemeDocument::load_from_dir( &dir ).expect( "minimal theme should parse" );
|
||||
assert_eq!( doc.id, "demo" );
|
||||
assert_eq!( doc.name, "Demo" );
|
||||
|
||||
// Wallpaper fit fields round-trip through the enum.
|
||||
assert_eq!( doc.light.wallpaper.as_ref().unwrap().fit, WallpaperFit::Cover );
|
||||
assert_eq!( doc.dark.wallpaper.as_ref().unwrap().fit, WallpaperFit::Contain );
|
||||
|
||||
let _ = std::fs::remove_dir_all( &dir );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn palette_hex_strings_round_trip_through_slots()
|
||||
{
|
||||
let dir = fresh_temp_dir( "palette" );
|
||||
std::fs::write( dir.join( "theme.json" ), MINIMAL_THEME_JSON ).unwrap();
|
||||
let doc = ThemeDocument::load_from_dir( &dir ).unwrap();
|
||||
|
||||
// "#102030" → R=0x10/255, G=0x20/255, B=0x30/255, A=1.0
|
||||
let p = doc.light.slots.color( "text-primary" ).expect( "text-primary slot" );
|
||||
let expected = ( 0x10 as f32 / 255.0, 0x20 as f32 / 255.0, 0x30 as f32 / 255.0 );
|
||||
assert!( ( p.r - expected.0 ).abs() < 1e-3, "r={}, expected {}", p.r, expected.0 );
|
||||
assert!( ( p.g - expected.1 ).abs() < 1e-3, "g={}, expected {}", p.g, expected.1 );
|
||||
assert!( ( p.b - expected.2 ).abs() < 1e-3, "b={}, expected {}", p.b, expected.2 );
|
||||
assert!( ( p.a - 1.0 ).abs() < 1e-3, "a={}, expected 1.0", p.a );
|
||||
|
||||
// 8-digit hex with alpha: "#10203080" → A ≈ 0x80/255
|
||||
let s = doc.light.slots.color( "text-secondary" ).unwrap();
|
||||
assert!( ( s.a - ( 0x80 as f32 / 255.0 ) ).abs() < 1e-3 );
|
||||
|
||||
let _ = std::fs::remove_dir_all( &dir );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn window_control_tokens_round_trip()
|
||||
{
|
||||
let dir = fresh_temp_dir( "window-controls" );
|
||||
std::fs::write( dir.join( "theme.json" ), MINIMAL_THEME_JSON ).unwrap();
|
||||
let doc = ThemeDocument::load_from_dir( &dir ).unwrap();
|
||||
|
||||
let wc = doc.light.window_controls.as_ref().expect( "window_controls" );
|
||||
assert!( ( wc.icon.r - ( 0x11 as f32 / 255.0 ) ).abs() < 1e-3 );
|
||||
assert!( ( wc.icon.g - ( 0x22 as f32 / 255.0 ) ).abs() < 1e-3 );
|
||||
assert!( ( wc.icon.b - ( 0x33 as f32 / 255.0 ) ).abs() < 1e-3 );
|
||||
assert!( ( wc.icon.a - 1.0 ).abs() < 1e-3 );
|
||||
|
||||
assert!( ( wc.hover_bg.a - ( 0x22 as f32 / 255.0 ) ).abs() < 1e-3 );
|
||||
|
||||
let _ = std::fs::remove_dir_all( &dir );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn relative_asset_paths_resolve_against_theme_dir()
|
||||
{
|
||||
let dir = fresh_temp_dir( "paths" );
|
||||
std::fs::write( dir.join( "theme.json" ), MINIMAL_THEME_JSON ).unwrap();
|
||||
let doc = ThemeDocument::load_from_dir( &dir ).unwrap();
|
||||
|
||||
let wp = doc.light.wallpaper.as_ref().unwrap().path.as_ref().expect( "wallpaper path" );
|
||||
assert_eq!( wp, &dir.join( "wp/light.jpg" ) );
|
||||
|
||||
let lock = doc.dark.lockscreen.as_ref().unwrap().path.as_ref().expect( "lockscreen path" );
|
||||
assert_eq!( lock, &dir.join( "wp/lock-dark.jpg" ) );
|
||||
|
||||
let _ = std::fs::remove_dir_all( &dir );
|
||||
}
|
||||
|
||||
// ── Errors ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[ test ]
|
||||
fn missing_theme_json_returns_error()
|
||||
{
|
||||
// Empty dir — load_from_dir should fail rather than panic.
|
||||
let dir = fresh_temp_dir( "missing" );
|
||||
let result = ThemeDocument::load_from_dir( &dir );
|
||||
assert!( result.is_err(), "expected Err for empty dir" );
|
||||
let _ = std::fs::remove_dir_all( &dir );
|
||||
}
|
||||
|
||||
// ── Mode resolution ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Point `LTK_THEMES_DIR` at the in-tree `themes/` so any test that
|
||||
/// triggers `ensure_active` finds the bundled `default` theme without
|
||||
/// requiring a system install. Sets the env var unconditionally — the
|
||||
/// global `ACTIVE` is process-wide so the first test that calls
|
||||
/// `ensure_active` wins, and a stale env var from a prior run would
|
||||
/// just point at the same directory.
|
||||
fn point_at_in_tree_themes()
|
||||
{
|
||||
std::env::set_var
|
||||
(
|
||||
"LTK_THEMES_DIR",
|
||||
env!( "CARGO_MANIFEST_DIR" ).to_string() + "/themes",
|
||||
);
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn theme_mode_round_trip()
|
||||
{
|
||||
// Switching the active mode at runtime is observable via
|
||||
// `ltk::active_mode()`. This triggers `ensure_active()` which loads
|
||||
// the in-tree default theme.
|
||||
let _guard = THEME_STATE_LOCK.lock().unwrap_or_else( |p| p.into_inner() );
|
||||
point_at_in_tree_themes();
|
||||
|
||||
ltk::set_active_mode( ThemeMode::Light );
|
||||
assert_eq!( ltk::active_mode(), ThemeMode::Light );
|
||||
ltk::set_active_mode( ThemeMode::Dark );
|
||||
assert_eq!( ltk::active_mode(), ThemeMode::Dark );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn theme_wallpaper_resolves_for_active_mode()
|
||||
{
|
||||
// `theme_wallpaper()` resolves via the convention
|
||||
// `branding/{mode}/wallpaper.svg` (the default theme no longer
|
||||
// declares an explicit `wallpaper.path` in theme.json), with the
|
||||
// standard mode → opposite-mode → no-mode fallback chain.
|
||||
let _guard = THEME_STATE_LOCK.lock().unwrap_or_else( |p| p.into_inner() );
|
||||
point_at_in_tree_themes();
|
||||
|
||||
ltk::set_active_mode( ThemeMode::Light );
|
||||
let wp = ltk::theme_wallpaper().expect( "Light resolves via branding/light/" );
|
||||
let path = wp.path.expect( "convention resolution returns a concrete path" );
|
||||
assert!
|
||||
(
|
||||
path.to_string_lossy().ends_with( "branding/light/wallpaper.svg" ),
|
||||
"unexpected Light wallpaper path: {}", path.display(),
|
||||
);
|
||||
|
||||
ltk::set_active_mode( ThemeMode::Dark );
|
||||
let wp = ltk::theme_wallpaper().expect( "Dark resolves via branding/dark/" );
|
||||
let path = wp.path.expect( "convention resolution returns a concrete path" );
|
||||
assert!
|
||||
(
|
||||
path.to_string_lossy().ends_with( "branding/dark/wallpaper.svg" ),
|
||||
"unexpected Dark wallpaper path: {}", path.display(),
|
||||
);
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn theme_lockscreen_resolves_for_active_mode()
|
||||
{
|
||||
// `theme_lockscreen()` mirrors `theme_wallpaper()` against the
|
||||
// convention `branding/{mode}/lockscreen.svg`. The default theme
|
||||
// ships both Light and Dark variants, so each mode resolves to its
|
||||
// own asset without needing the fallback chain.
|
||||
let _guard = THEME_STATE_LOCK.lock().unwrap_or_else( |p| p.into_inner() );
|
||||
point_at_in_tree_themes();
|
||||
|
||||
ltk::set_active_mode( ThemeMode::Light );
|
||||
let ls = ltk::theme_lockscreen().expect( "Light resolves via branding/light/" );
|
||||
let path = ls.path.expect( "convention resolution returns a concrete path" );
|
||||
assert!
|
||||
(
|
||||
path.to_string_lossy().ends_with( "branding/light/lockscreen.svg" ),
|
||||
"unexpected Light lockscreen path: {}", path.display(),
|
||||
);
|
||||
|
||||
ltk::set_active_mode( ThemeMode::Dark );
|
||||
let ls = ltk::theme_lockscreen().expect( "Dark resolves via branding/dark/" );
|
||||
let path = ls.path.expect( "convention resolution returns a concrete path" );
|
||||
assert!
|
||||
(
|
||||
path.to_string_lossy().ends_with( "branding/dark/lockscreen.svg" ),
|
||||
"unexpected Dark lockscreen path: {}", path.display(),
|
||||
);
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn theme_logo_variants_resolve_for_active_mode()
|
||||
{
|
||||
// `theme_logo`, `theme_logo_square`, `theme_logo_horizontal` are
|
||||
// thin wrappers over `branding_asset( "logo/<variant>", "svg" )`.
|
||||
// The default theme ships all three SVG variants for both modes
|
||||
// under `branding/{mode}/logo/`, so each call resolves directly
|
||||
// to its own-mode file without the cross-mode fallback.
|
||||
let _guard = THEME_STATE_LOCK.lock().unwrap_or_else( |p| p.into_inner() );
|
||||
point_at_in_tree_themes();
|
||||
|
||||
for mode in [ ThemeMode::Light, ThemeMode::Dark ]
|
||||
{
|
||||
ltk::set_active_mode( mode );
|
||||
let mode_dir = match mode
|
||||
{
|
||||
ThemeMode::Light => "branding/light/logo/",
|
||||
ThemeMode::Dark => "branding/dark/logo/",
|
||||
};
|
||||
|
||||
let primary = ltk::theme_logo()
|
||||
.unwrap_or_else( || panic!( "theme_logo missing for {mode:?}" ) );
|
||||
assert!
|
||||
(
|
||||
primary.to_string_lossy().ends_with( &format!( "{mode_dir}logo.svg" ) ),
|
||||
"unexpected {mode:?} logo path: {}", primary.display(),
|
||||
);
|
||||
|
||||
let square = ltk::theme_logo_square()
|
||||
.unwrap_or_else( || panic!( "theme_logo_square missing for {mode:?}" ) );
|
||||
assert!
|
||||
(
|
||||
square.to_string_lossy().ends_with( &format!( "{mode_dir}square.svg" ) ),
|
||||
"unexpected {mode:?} square logo path: {}", square.display(),
|
||||
);
|
||||
|
||||
let horizontal = ltk::theme_logo_horizontal()
|
||||
.unwrap_or_else( || panic!( "theme_logo_horizontal missing for {mode:?}" ) );
|
||||
assert!
|
||||
(
|
||||
horizontal.to_string_lossy().ends_with( &format!( "{mode_dir}horizontal.svg" ) ),
|
||||
"unexpected {mode:?} horizontal logo path: {}", horizontal.display(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn branding_raster_picks_smallest_covering_at_each_target_size()
|
||||
{
|
||||
// The default theme ships `1280x720`, `1920x1080`, `3840x2160` WebPs
|
||||
// for both wallpaper and lockscreen in both modes. The lookup must
|
||||
// pick the smallest entry whose two dimensions cover the surface.
|
||||
let _guard = THEME_STATE_LOCK.lock().unwrap_or_else( |p| p.into_inner() );
|
||||
point_at_in_tree_themes();
|
||||
|
||||
ltk::set_active_mode( ThemeMode::Light );
|
||||
|
||||
// Surface ≤ 720p → 1280x720 covers it.
|
||||
let path = ltk::theme_branding_raster( "wallpaper", 800, 600 )
|
||||
.expect( "Light raster covers 800x600" );
|
||||
assert!
|
||||
(
|
||||
path.to_string_lossy().ends_with( "branding/light/wallpaper/1280x720.webp" ),
|
||||
"unexpected raster for 800x600: {}", path.display(),
|
||||
);
|
||||
|
||||
// Surface 1080p exact → 1920x1080 covers it.
|
||||
let path = ltk::theme_branding_raster( "wallpaper", 1920, 1080 )
|
||||
.expect( "Light raster covers 1920x1080" );
|
||||
assert!
|
||||
(
|
||||
path.to_string_lossy().ends_with( "branding/light/wallpaper/1920x1080.webp" ),
|
||||
"unexpected raster for 1920x1080: {}", path.display(),
|
||||
);
|
||||
|
||||
// 1920x1200 (16:10 laptop) → 1080 < 1200, so 1080p is too short. Next
|
||||
// up that covers both axes is 4K.
|
||||
let path = ltk::theme_branding_raster( "wallpaper", 1920, 1200 )
|
||||
.expect( "Light raster covers 1920x1200" );
|
||||
assert!
|
||||
(
|
||||
path.to_string_lossy().ends_with( "branding/light/wallpaper/3840x2160.webp" ),
|
||||
"unexpected raster for 1920x1200: {}", path.display(),
|
||||
);
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn branding_raster_falls_back_to_largest_when_none_covers()
|
||||
{
|
||||
// 5000-wide surface exceeds the largest shipped raster (3840). No
|
||||
// covering candidate, so the lookup falls back to the largest
|
||||
// available raster — a fast-decoding upscaled WebP beats paying the
|
||||
// SVG rasterisation cost.
|
||||
let _guard = THEME_STATE_LOCK.lock().unwrap_or_else( |p| p.into_inner() );
|
||||
point_at_in_tree_themes();
|
||||
|
||||
ltk::set_active_mode( ThemeMode::Dark );
|
||||
|
||||
let path = ltk::theme_branding_raster( "wallpaper", 5000, 3000 )
|
||||
.expect( "no covering raster but the largest is returned" );
|
||||
assert!
|
||||
(
|
||||
path.to_string_lossy().ends_with( "branding/dark/wallpaper/3840x2160.webp" ),
|
||||
"unexpected fallback raster for 5000x3000: {}", path.display(),
|
||||
);
|
||||
|
||||
// `branding_image` returns the same — it only falls to the SVG when
|
||||
// no raster files exist at all.
|
||||
let img = ltk::theme_branding_image( "wallpaper", 5000, 3000 )
|
||||
.expect( "branding_image returns the same largest raster" );
|
||||
assert_eq!( img, path );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn branding_raster_with_zero_size_returns_smallest_available()
|
||||
{
|
||||
// (0, 0) means "surface size unknown — give me the smallest variant".
|
||||
// Every entry trivially covers a 0×0 surface so the smallest by area
|
||||
// wins. Used at startup so the first frame paints a fast-decoded
|
||||
// lightweight raster instead of paying the SVG rasterisation cost.
|
||||
let _guard = THEME_STATE_LOCK.lock().unwrap_or_else( |p| p.into_inner() );
|
||||
point_at_in_tree_themes();
|
||||
|
||||
ltk::set_active_mode( ThemeMode::Light );
|
||||
|
||||
let path = ltk::theme_branding_raster( "wallpaper", 0, 0 )
|
||||
.expect( "(0, 0) returns the smallest raster" );
|
||||
assert!
|
||||
(
|
||||
path.to_string_lossy().ends_with( "branding/light/wallpaper/1280x720.webp" ),
|
||||
"unexpected raster for (0, 0): {}", path.display(),
|
||||
);
|
||||
|
||||
let img = ltk::theme_branding_image( "wallpaper", 0, 0 )
|
||||
.expect( "branding_image returns the same smallest raster" );
|
||||
assert_eq!( img, path );
|
||||
}
|
||||
95
tests/tree_lookup.rs
Normal file
95
tests/tree_lookup.rs
Normal file
@@ -0,0 +1,95 @@
|
||||
mod common;
|
||||
use common::{ lw_none, lw_button, rect };
|
||||
|
||||
use ltk::test_support::{ find_widget_at, find_widget, find_handlers, WidgetHandlers };
|
||||
use ltk::Point;
|
||||
|
||||
fn pt( x: f32, y: f32 ) -> Point
|
||||
{
|
||||
Point { x, y }
|
||||
}
|
||||
|
||||
// ── find_widget_at ────────────────────────────────────────────────────────────
|
||||
|
||||
#[ test ]
|
||||
fn hit_test_misses_when_empty()
|
||||
{
|
||||
let widgets: Vec<_> = vec![];
|
||||
assert_eq!( find_widget_at::<()>( &widgets, pt( 10.0, 10.0 ) ), None );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn hit_test_misses_outside_all_rects()
|
||||
{
|
||||
let widgets = vec![
|
||||
lw_none( 1, rect( 0.0, 0.0, 50.0, 50.0 ) ),
|
||||
lw_none( 2, rect( 100.0, 100.0, 50.0, 50.0 ) ),
|
||||
];
|
||||
assert_eq!( find_widget_at( &widgets, pt( 75.0, 75.0 ) ), None );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn hit_test_returns_widget_under_point()
|
||||
{
|
||||
let widgets = vec![
|
||||
lw_none( 7, rect( 0.0, 0.0, 100.0, 100.0 ) ),
|
||||
lw_none( 8, rect( 200.0, 0.0, 100.0, 100.0 ) ),
|
||||
];
|
||||
assert_eq!( find_widget_at( &widgets, pt( 50.0, 50.0 ) ), Some( 7 ) );
|
||||
assert_eq!( find_widget_at( &widgets, pt( 250.0, 50.0 ) ), Some( 8 ) );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn hit_test_returns_topmost_when_overlapping()
|
||||
{
|
||||
// Topmost wins. Layout pushes parents before children, so iteration in
|
||||
// reverse must return the child (drawn last, last in the slice).
|
||||
let widgets = vec![
|
||||
lw_none( 1, rect( 0.0, 0.0, 200.0, 200.0 ) ),
|
||||
lw_none( 2, rect( 50.0, 50.0, 100.0, 100.0 ) ),
|
||||
];
|
||||
assert_eq!( find_widget_at( &widgets, pt( 100.0, 100.0 ) ), Some( 2 ) );
|
||||
assert_eq!( find_widget_at( &widgets, pt( 10.0, 10.0 ) ), Some( 1 ) );
|
||||
}
|
||||
|
||||
// ── find_widget ───────────────────────────────────────────────────────────────
|
||||
|
||||
#[ test ]
|
||||
fn find_widget_returns_match_by_flat_idx()
|
||||
{
|
||||
let widgets = vec![
|
||||
lw_none( 10, rect( 0.0, 0.0, 10.0, 10.0 ) ),
|
||||
lw_none( 20, rect( 0.0, 0.0, 10.0, 10.0 ) ),
|
||||
lw_none( 30, rect( 0.0, 0.0, 10.0, 10.0 ) ),
|
||||
];
|
||||
let w = find_widget( &widgets, 20 ).expect( "widget 20 should be found" );
|
||||
assert_eq!( w.flat_idx, 20 );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn find_widget_returns_none_for_missing_idx()
|
||||
{
|
||||
let widgets = vec![ lw_none( 1, rect( 0.0, 0.0, 10.0, 10.0 ) ) ];
|
||||
assert!( find_widget( &widgets, 999 ).is_none() );
|
||||
}
|
||||
|
||||
// ── find_handlers ─────────────────────────────────────────────────────────────
|
||||
|
||||
#[ test ]
|
||||
fn find_handlers_returns_button_handler()
|
||||
{
|
||||
let widgets = vec![
|
||||
lw_button( 5, rect( 0.0, 0.0, 10.0, 10.0 ) ),
|
||||
];
|
||||
let h = find_handlers( &widgets, 5 ).expect( "handler should be present" );
|
||||
assert!( matches!( h, WidgetHandlers::Button { on_press: Some( () ), .. } ) );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn find_handlers_returns_none_for_missing_idx()
|
||||
{
|
||||
let widgets = vec![
|
||||
lw_button( 5, rect( 0.0, 0.0, 10.0, 10.0 ) ),
|
||||
];
|
||||
assert!( find_handlers( &widgets, 6 ).is_none() );
|
||||
}
|
||||
119
tests/vslider_math.rs
Normal file
119
tests/vslider_math.rs
Normal file
@@ -0,0 +1,119 @@
|
||||
use ltk::test_support::
|
||||
{
|
||||
value_from_y_in_rect,
|
||||
value_from_pos_in_rect,
|
||||
SliderAxis,
|
||||
};
|
||||
use ltk::{ Point, Rect };
|
||||
|
||||
fn rect( x: f32, y: f32, w: f32, h: f32 ) -> Rect
|
||||
{
|
||||
Rect { x, y, width: w, height: h }
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn top_is_one()
|
||||
{
|
||||
let r = rect( 0.0, 0.0, 56.0, 160.0 );
|
||||
assert_eq!( value_from_y_in_rect( r, 0.0 ), 1.0 );
|
||||
assert_eq!( value_from_y_in_rect( r, -50.0 ), 1.0 );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn bottom_is_zero()
|
||||
{
|
||||
let r = rect( 0.0, 0.0, 56.0, 160.0 );
|
||||
assert_eq!( value_from_y_in_rect( r, 160.0 ), 0.0 );
|
||||
assert_eq!( value_from_y_in_rect( r, 999.0 ), 0.0 );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn center_returns_half()
|
||||
{
|
||||
let r = rect( 0.0, 0.0, 56.0, 160.0 );
|
||||
let v = value_from_y_in_rect( r, 80.0 );
|
||||
assert!( ( v - 0.5 ).abs() < 1e-6, "expected 0.5 got {}", v );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn rect_offset_translates_correctly()
|
||||
{
|
||||
let r = rect( 0.0, 500.0, 56.0, 160.0 );
|
||||
assert_eq!( value_from_y_in_rect( r, 500.0 ), 1.0 );
|
||||
assert_eq!( value_from_y_in_rect( r, 660.0 ), 0.0 );
|
||||
let v = value_from_y_in_rect( r, 580.0 );
|
||||
assert!( ( v - 0.5 ).abs() < 1e-6, "expected 0.5 got {}", v );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn output_is_always_in_unit_range()
|
||||
{
|
||||
let r = rect( 10.0, 10.0, 56.0, 200.0 );
|
||||
for y in -100i32 ..= 400
|
||||
{
|
||||
let v = value_from_y_in_rect( r, y as f32 );
|
||||
assert!( ( 0.0..= 1.0 ).contains( &v ), "v={} out of range at y={}", v, y );
|
||||
}
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn very_short_rect_does_not_divide_by_zero()
|
||||
{
|
||||
// Height < 1.0 — track_h is clamped to 1.0 internally so the formula
|
||||
// stays defined.
|
||||
let r = rect( 0.0, 0.0, 56.0, 0.1 );
|
||||
let v = value_from_y_in_rect( r, 0.05 );
|
||||
assert!( v.is_finite() );
|
||||
assert!( ( 0.0..= 1.0 ).contains( &v ) );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn value_is_monotonically_decreasing_along_y()
|
||||
{
|
||||
// Moving downward inside the pill must never *increase* the value —
|
||||
// the invariant behind the "drag up = larger" UX contract.
|
||||
let r = rect( 0.0, 0.0, 56.0, 160.0 );
|
||||
let mut prev = value_from_y_in_rect( r, 0.0 );
|
||||
for y in 0 ..= 160
|
||||
{
|
||||
let v = value_from_y_in_rect( r, y as f32 );
|
||||
assert!( v <= prev + 1e-6, "not monotonic: y={} v={} prev={}", y, v, prev );
|
||||
prev = v;
|
||||
}
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn value_from_pos_dispatches_horizontal()
|
||||
{
|
||||
let r = rect( 0.0, 0.0, 200.0, 36.0 );
|
||||
let v = value_from_pos_in_rect(
|
||||
r,
|
||||
Point { x: 200.0, y: 0.0 },
|
||||
SliderAxis::Horizontal,
|
||||
);
|
||||
assert_eq!( v, 1.0 );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn value_from_pos_dispatches_vertical()
|
||||
{
|
||||
let r = rect( 0.0, 0.0, 56.0, 160.0 );
|
||||
let v = value_from_pos_in_rect(
|
||||
r,
|
||||
Point { x: 9_999.0, y: 0.0 },
|
||||
SliderAxis::Vertical,
|
||||
);
|
||||
assert_eq!( v, 1.0 );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn value_from_pos_axes_are_independent()
|
||||
{
|
||||
let r = rect( 0.0, 0.0, 200.0, 200.0 );
|
||||
let h = value_from_pos_in_rect(
|
||||
r, Point { x: 100.0, y: 0.0 }, SliderAxis::Horizontal );
|
||||
let v = value_from_pos_in_rect(
|
||||
r, Point { x: 0.0, y: 100.0 }, SliderAxis::Vertical );
|
||||
assert!( ( h - 0.5 ).abs() < 0.1 );
|
||||
assert!( ( v - 0.5 ).abs() < 1e-6 );
|
||||
}
|
||||
281
tests/widget_dispatch.rs
Normal file
281
tests/widget_dispatch.rs
Normal file
@@ -0,0 +1,281 @@
|
||||
// Handler-level dispatch coverage for the interactive widgets that aren't
|
||||
// buttons or text edits: toggle, checkbox, radio and slider. Each test renders
|
||||
// the widget through `UiSurface`, locates its `WidgetHandlers` snapshot and
|
||||
// confirms the snapshot routes activations to the configured Msg variant.
|
||||
|
||||
use ltk::core::{ RenderOptions, UiSurface };
|
||||
use ltk::test_support::WidgetHandlers;
|
||||
use ltk::{
|
||||
checkbox, column, radio, slider, toggle, Color, Element, Point, Rect, SliderAxis,
|
||||
};
|
||||
|
||||
#[ derive( Clone, Debug, PartialEq ) ]
|
||||
enum Msg
|
||||
{
|
||||
WifiToggled,
|
||||
TermsAccepted,
|
||||
PriorityHigh,
|
||||
Brightness( f32 ),
|
||||
}
|
||||
|
||||
fn render_view( view: Element<Msg> ) -> UiSurface<Msg>
|
||||
{
|
||||
let mut surface = UiSurface::<Msg>::new( 320, 240 );
|
||||
let _ = surface.render(
|
||||
&view,
|
||||
RenderOptions::full_canvas( 320, 240 ).background( Color::rgb( 0.1, 0.1, 0.1 ) ),
|
||||
);
|
||||
surface
|
||||
}
|
||||
|
||||
// ── Toggle ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[ test ]
|
||||
fn toggle_handler_carries_on_toggle_message()
|
||||
{
|
||||
let view: Element<Msg> = column()
|
||||
.push( toggle( false ).on_toggle( Msg::WifiToggled ) )
|
||||
.into();
|
||||
let surface = render_view( view );
|
||||
|
||||
let idx = surface.widget_rects()[ 0 ].flat_idx;
|
||||
let h = surface.handlers( idx ).expect( "toggle should expose a handler" );
|
||||
assert!( !h.is_text_input() );
|
||||
assert!( !h.is_slider() );
|
||||
assert_eq!( h.press_msg(), Some( Msg::WifiToggled ) );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn toggle_handler_variant_is_toggle()
|
||||
{
|
||||
let view: Element<Msg> = column()
|
||||
.push( toggle( true ).on_toggle( Msg::WifiToggled ) )
|
||||
.into();
|
||||
let surface = render_view( view );
|
||||
let h = surface.handlers( surface.widget_rects()[ 0 ].flat_idx ).unwrap();
|
||||
assert!( matches!( h, WidgetHandlers::Toggle { on_toggle: Some( _ ) } ) );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn toggle_without_on_toggle_yields_none_press_msg()
|
||||
{
|
||||
let view: Element<Msg> = column()
|
||||
.push( toggle::<Msg>( false ) )
|
||||
.into();
|
||||
let surface = render_view( view );
|
||||
let h = surface.handlers( surface.widget_rects()[ 0 ].flat_idx ).unwrap();
|
||||
assert!( h.press_msg().is_none() );
|
||||
}
|
||||
|
||||
// ── Checkbox ──────────────────────────────────────────────────────────────────
|
||||
|
||||
#[ test ]
|
||||
fn checkbox_handler_carries_on_toggle_message()
|
||||
{
|
||||
let view: Element<Msg> = column()
|
||||
.push( checkbox( false ).on_toggle( Msg::TermsAccepted ).label( "Accept" ) )
|
||||
.into();
|
||||
let surface = render_view( view );
|
||||
|
||||
let idx = surface.widget_rects()[ 0 ].flat_idx;
|
||||
let h = surface.handlers( idx ).unwrap();
|
||||
assert!( matches!( h, WidgetHandlers::Checkbox { on_toggle: Some( _ ) } ) );
|
||||
assert_eq!( h.press_msg(), Some( Msg::TermsAccepted ) );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn checkbox_state_is_independent_of_handler()
|
||||
{
|
||||
// The widget renders the current `checked` value; toggling that value is
|
||||
// the parent app's job — handler dispatch must work regardless of the
|
||||
// initial state.
|
||||
let unchecked: Element<Msg> = column().push( checkbox( false ).on_toggle( Msg::TermsAccepted ) ).into();
|
||||
let checked: Element<Msg> = column().push( checkbox( true ).on_toggle( Msg::TermsAccepted ) ).into();
|
||||
|
||||
for view in [ unchecked, checked ]
|
||||
{
|
||||
let surface = render_view( view );
|
||||
let h = surface.handlers( surface.widget_rects()[ 0 ].flat_idx ).unwrap();
|
||||
assert_eq!( h.press_msg(), Some( Msg::TermsAccepted ) );
|
||||
}
|
||||
}
|
||||
|
||||
// ── Radio ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[ test ]
|
||||
fn radio_handler_carries_on_select_message()
|
||||
{
|
||||
let view: Element<Msg> = column()
|
||||
.push( radio( false ).on_select( Msg::PriorityHigh ).label( "High" ) )
|
||||
.into();
|
||||
let surface = render_view( view );
|
||||
|
||||
let idx = surface.widget_rects()[ 0 ].flat_idx;
|
||||
let h = surface.handlers( idx ).unwrap();
|
||||
assert!( matches!( h, WidgetHandlers::Radio { on_select: Some( _ ) } ) );
|
||||
assert_eq!( h.press_msg(), Some( Msg::PriorityHigh ) );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn three_radios_each_route_to_their_own_message()
|
||||
{
|
||||
#[ derive( Clone, Debug, PartialEq ) ]
|
||||
enum Choice { Low, Medium, High }
|
||||
|
||||
let view: Element<Choice> = column()
|
||||
.push( radio( false ).on_select( Choice::Low ) )
|
||||
.push( radio( false ).on_select( Choice::Medium ) )
|
||||
.push( radio( true ).on_select( Choice::High ) )
|
||||
.into();
|
||||
|
||||
let mut surface = UiSurface::<Choice>::new( 240, 240 );
|
||||
let _ = surface.render( &view, RenderOptions::full_canvas( 240, 240 ) );
|
||||
|
||||
let messages: Vec<_> = surface.widget_rects().iter()
|
||||
.map( |w| surface.handlers( w.flat_idx ).and_then( |h| h.press_msg() ) )
|
||||
.collect();
|
||||
assert_eq!(
|
||||
messages,
|
||||
vec![ Some( Choice::Low ), Some( Choice::Medium ), Some( Choice::High ) ],
|
||||
);
|
||||
}
|
||||
|
||||
// ── Slider ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[ test ]
|
||||
fn slider_handler_variant_is_slider()
|
||||
{
|
||||
let view: Element<Msg> = column()
|
||||
.push( slider( 0.5 ).on_change( |v| Msg::Brightness( v ) ) )
|
||||
.into();
|
||||
let surface = render_view( view );
|
||||
|
||||
let h = surface.handlers( surface.widget_rects()[ 0 ].flat_idx ).unwrap();
|
||||
assert!( h.is_slider() );
|
||||
assert!( !h.is_text_input() );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn slider_change_msg_wraps_value_through_callback()
|
||||
{
|
||||
let view: Element<Msg> = column()
|
||||
.push( slider( 0.0 ).on_change( |v| Msg::Brightness( v ) ) )
|
||||
.into();
|
||||
let surface = render_view( view );
|
||||
|
||||
let h = surface.handlers( surface.widget_rects()[ 0 ].flat_idx ).unwrap();
|
||||
assert_eq!( h.slider_change_msg( 0.0 ), Some( Msg::Brightness( 0.0 ) ) );
|
||||
assert_eq!( h.slider_change_msg( 0.5 ), Some( Msg::Brightness( 0.5 ) ) );
|
||||
assert_eq!( h.slider_change_msg( 1.0 ), Some( Msg::Brightness( 1.0 ) ) );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn slider_value_from_pos_at_left_edge_is_zero()
|
||||
{
|
||||
let view: Element<Msg> = column()
|
||||
.push( slider( 0.5 ).on_change( |v| Msg::Brightness( v ) ) )
|
||||
.into();
|
||||
let surface = render_view( view );
|
||||
|
||||
let w = &surface.widget_rects()[ 0 ];
|
||||
let left = Point { x: w.rect.x, y: w.rect.y + w.rect.height * 0.5 };
|
||||
let v = w.handlers.slider_value_from_pos( w.rect, left );
|
||||
assert!( ( v - 0.0 ).abs() < 1e-3 );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn slider_value_from_pos_at_right_edge_is_one()
|
||||
{
|
||||
let view: Element<Msg> = column()
|
||||
.push( slider( 0.5 ).on_change( |v| Msg::Brightness( v ) ) )
|
||||
.into();
|
||||
let surface = render_view( view );
|
||||
|
||||
let w = &surface.widget_rects()[ 0 ];
|
||||
let right = Point { x: w.rect.x + w.rect.width, y: w.rect.y + w.rect.height * 0.5 };
|
||||
let v = w.handlers.slider_value_from_pos( w.rect, right );
|
||||
assert!( ( v - 1.0 ).abs() < 1e-3 );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn slider_value_from_pos_at_midpoint_is_one_half()
|
||||
{
|
||||
let view: Element<Msg> = column()
|
||||
.push( slider( 0.0 ).on_change( |v| Msg::Brightness( v ) ) )
|
||||
.into();
|
||||
let surface = render_view( view );
|
||||
|
||||
let w = &surface.widget_rects()[ 0 ];
|
||||
let mid = Point
|
||||
{
|
||||
x: w.rect.x + w.rect.width * 0.5,
|
||||
y: w.rect.y + w.rect.height * 0.5,
|
||||
};
|
||||
let v = w.handlers.slider_value_from_pos( w.rect, mid );
|
||||
assert!( ( v - 0.5 ).abs() < 1e-3 );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn slider_value_from_pos_clamps_outside_rect()
|
||||
{
|
||||
// Pointer way outside the rect on either side must clamp to 0 or 1.
|
||||
let view: Element<Msg> = column()
|
||||
.push( slider( 0.5 ).on_change( |v| Msg::Brightness( v ) ) )
|
||||
.into();
|
||||
let surface = render_view( view );
|
||||
|
||||
let w = &surface.widget_rects()[ 0 ];
|
||||
let far_left = Point { x: w.rect.x - 200.0, y: w.rect.y + 10.0 };
|
||||
let far_right = Point { x: w.rect.x + w.rect.width + 200.0, y: w.rect.y + 10.0 };
|
||||
|
||||
assert_eq!( w.handlers.slider_value_from_pos( w.rect, far_left ), 0.0 );
|
||||
assert_eq!( w.handlers.slider_value_from_pos( w.rect, far_right ), 1.0 );
|
||||
}
|
||||
|
||||
// Bonus: confirm slider_value_from_pos returns 0.0 for non-slider variants —
|
||||
// gives the gesture state machine a safe fallback when a slider handler gets
|
||||
// swapped out mid-gesture.
|
||||
#[ test ]
|
||||
fn slider_value_from_pos_returns_zero_for_non_slider_handler()
|
||||
{
|
||||
use ltk::button;
|
||||
let view: Element<Msg> = column()
|
||||
.push( button( "go" ).on_press( Msg::WifiToggled ) )
|
||||
.into();
|
||||
let surface = render_view( view );
|
||||
|
||||
let w = &surface.widget_rects()[ 0 ];
|
||||
let mid = Point
|
||||
{
|
||||
x: w.rect.x + w.rect.width * 0.5,
|
||||
y: w.rect.y + w.rect.height * 0.5,
|
||||
};
|
||||
assert_eq!( w.handlers.slider_value_from_pos( w.rect, mid ), 0.0 );
|
||||
}
|
||||
|
||||
// ── SliderAxis sanity ─────────────────────────────────────────────────────────
|
||||
|
||||
#[ test ]
|
||||
fn slider_axis_enum_is_publicly_exposed()
|
||||
{
|
||||
// Compile-time guard: SliderAxis must remain a publicly-named enum so
|
||||
// downstream apps can match on it (e.g. when wrapping value_from_pos
|
||||
// for a custom hit area).
|
||||
let _ = SliderAxis::Horizontal;
|
||||
let _ = SliderAxis::Vertical;
|
||||
}
|
||||
|
||||
// Bonus: ensure the rect from layout has non-zero size so the value tests
|
||||
// above are meaningful (if rect.width were 0 every position lookup would
|
||||
// degenerate to 0 / 0).
|
||||
#[ test ]
|
||||
fn slider_layout_rect_has_non_zero_width()
|
||||
{
|
||||
let view: Element<Msg> = column()
|
||||
.push( slider( 0.5 ).on_change( |v| Msg::Brightness( v ) ) )
|
||||
.into();
|
||||
let surface = render_view( view );
|
||||
let w: Rect = surface.widget_rects()[ 0 ].rect;
|
||||
assert!( w.width > 0.0 );
|
||||
assert!( w.height > 0.0 );
|
||||
}
|
||||
Reference in New Issue
Block a user