Files
ltk/tests/render_pixels.rs

313 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 ) );
}