313 lines
11 KiB
Rust
313 lines
11 KiB
Rust
// 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 ) );
|
||
}
|