use cranpose_core::NodeId;
use cranpose_render_common::{
Renderer,
graph::{
CachePolicy, DrawPrimitiveNode, LayerNode, PrimitiveEntry, PrimitiveNode, PrimitivePhase,
ProjectiveTransform, RenderGraph, RenderNode, TextPrimitiveNode,
},
};
use cranpose_ui::{
TextLayoutOptions, TextStyle,
text::{AnnotatedString, SpanStyle},
};
use cranpose_ui_graphics::{Brush, Color, Rect};
use crate::support;
fn card_layer(node_id: NodeId, y: f32) -> LayerNode {
let local_bounds = Rect {
x: 0.0,
y: 0.0,
width: 96.0,
height: 28.0,
};
let primitive = PrimitiveEntry {
phase: PrimitivePhase::BeforeChildren,
node: PrimitiveNode::Draw(DrawPrimitiveNode {
primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
rect: local_bounds,
brush: Brush::solid(Color(0.15, 0.35, 0.85, 1.0)),
stroke: None,
},
clip: None,
}),
};
let mut layer = support::contract_layer(
Some(node_id),
CachePolicy::Auto,
local_bounds,
ProjectiveTransform::translation(12.0, y),
vec![RenderNode::Primitive(primitive)],
);
layer.graphics_layer.alpha = 0.85;
layer
}
fn scroll_like_graph(offsets: &[f32]) -> RenderGraph {
let children = offsets
.iter()
.enumerate()
.map(|(index, y)| RenderNode::Layer(Box::new(card_layer(index + 1, *y))))
.collect();
RenderGraph::new(support::contract_layer(
Some(10_000),
CachePolicy::None,
Rect {
x: 0.0,
y: 0.0,
width: 128.0,
height: 160.0,
},
ProjectiveTransform::identity(),
children,
))
}
fn text_scroll_like_graph(y: f32) -> RenderGraph {
RenderGraph::new(support::contract_layer(
Some(20_000),
CachePolicy::None,
Rect {
x: 0.0,
y: 0.0,
width: 320.0,
height: 140.0,
},
ProjectiveTransform::identity(),
vec![RenderNode::Layer(Box::new(text_layer(
77,
16.0,
y,
"Markdown text row cache reuse",
)))],
))
}
fn repeated_text_graph() -> RenderGraph {
RenderGraph::new(support::contract_layer(
Some(20_001),
CachePolicy::None,
Rect {
x: 0.0,
y: 0.0,
width: 360.0,
height: 160.0,
},
ProjectiveTransform::identity(),
vec![
RenderNode::Layer(Box::new(text_layer(
77,
16.0,
20.0,
"Repeated markdown heading",
))),
RenderNode::Layer(Box::new(text_layer(
78,
16.0,
64.0,
"Repeated markdown heading",
))),
],
))
}
fn text_layer(node_id: NodeId, x: f32, y: f32, text_value: &str) -> LayerNode {
let local_bounds = Rect {
x: 0.0,
y: 0.0,
width: 260.0,
height: 36.0,
};
let text = PrimitiveEntry {
phase: PrimitivePhase::BeforeChildren,
node: PrimitiveNode::Text(Box::new(TextPrimitiveNode {
node_id,
rect: local_bounds,
text: std::rc::Rc::new(AnnotatedString::from(text_value)),
text_style: TextStyle::from_span_style(SpanStyle {
color: Some(Color(0.88, 0.90, 0.96, 1.0)),
..Default::default()
}),
font_size: 14.0,
layout_options: TextLayoutOptions::default(),
clip: None,
})),
};
support::contract_layer(
Some(node_id),
CachePolicy::None,
local_bounds,
ProjectiveTransform::translation(x, y),
vec![RenderNode::Primitive(text)],
)
}
#[test]
fn capture_frame_reuses_cached_child_layers_during_rigid_scroll() {
let mut renderer = match support::headless_renderer() {
Ok(renderer) => renderer,
Err(err) => {
eprintln!(
"skipping rigid scroll cache reuse assertion because headless WGPU init failed: {err}"
);
return;
}
};
renderer.scene_mut().graph = Some(scroll_like_graph(&[8.0, 44.0, 80.0, 116.0]));
renderer
.capture_frame(160, 180)
.expect("first capture should succeed");
let first_stats = renderer.last_frame_stats().expect("first frame stats");
renderer.scene_mut().graph = Some(scroll_like_graph(&[15.25, 51.25, 87.25, 123.25]));
renderer
.capture_frame(160, 180)
.expect("second capture should succeed");
let second_stats = renderer.last_frame_stats().expect("second frame stats");
assert_eq!(first_stats.layer_cache_hits, 0);
assert_eq!(first_stats.layer_cache_misses, 4);
assert_eq!(second_stats.layer_cache_hits, 4);
assert_eq!(second_stats.layer_cache_misses, 0);
assert!(
second_stats.isolated_layer_renders < first_stats.isolated_layer_renders,
"cached child layers should avoid repainting isolated child surfaces"
);
assert!(
second_stats.isolated_layer_pixels < first_stats.isolated_layer_pixels,
"rigid scroll should reduce isolated layer repaint area after cache warmup"
);
assert!(
second_stats.offscreen_acquires < first_stats.offscreen_acquires,
"cache reuse should reduce offscreen acquisitions on the second frame"
);
}
#[test]
fn static_text_glyph_atlas_reuses_raster_under_scroll_translation() {
let mut renderer = match support::headless_renderer() {
Ok(renderer) => renderer,
Err(err) => {
eprintln!(
"skipping static text glyph atlas assertion because headless WGPU init failed: {err}"
);
return;
}
};
renderer.scene_mut().graph = Some(text_scroll_like_graph(20.0));
renderer
.capture_frame(320, 140)
.expect("first text capture should succeed");
let first_stats = renderer.last_frame_stats().expect("first frame stats");
renderer.scene_mut().graph = Some(text_scroll_like_graph(54.0));
renderer
.capture_frame(320, 140)
.expect("translated text capture should succeed");
let second_stats = renderer.last_frame_stats().expect("second frame stats");
assert_eq!(first_stats.text_image_cache_misses, 0);
assert!(
first_stats.text_glyph_atlas_misses > 0,
"first frame must populate the text glyph atlas: {first_stats:?}"
);
assert!(
second_stats.text_glyph_atlas_hits >= first_stats.text_glyph_atlas_misses,
"translated static text should reuse first-frame atlas glyphs: {second_stats:?}"
);
assert_eq!(
second_stats.text_glyph_atlas_misses, 0,
"scroll translation alone must not upload new atlas glyphs: {second_stats:?}"
);
assert_eq!(
second_stats.text_image_raster_bytes, 0,
"atlas hit frame should not allocate CPU text image raster bytes: {second_stats:?}"
);
}
#[test]
fn text_glyph_atlas_reuses_identical_content_across_node_ids() {
let mut renderer = match support::headless_renderer() {
Ok(renderer) => renderer,
Err(err) => {
eprintln!(
"skipping repeated text glyph atlas assertion because headless WGPU init failed: {err}"
);
return;
}
};
renderer.scene_mut().graph = Some(repeated_text_graph());
renderer
.capture_frame(360, 160)
.expect("repeated text capture should succeed");
let stats = renderer.last_frame_stats().expect("frame stats");
assert_eq!(
stats.text_image_cache_misses, 0,
"atlas-safe text should not populate whole text image cache: {stats:?}"
);
assert!(
stats.text_glyph_atlas_hits > 0,
"second identical text node should hit shared atlas glyphs: {stats:?}"
);
assert!(
stats.text_glyph_atlas_misses > 0,
"first repeated text node should populate atlas glyphs: {stats:?}"
);
}
fn animated_shader_wgsl() -> String {
format!(
"{}\n{}",
cranpose_ui_graphics::RUNTIME_SHADER_PRELUDE_WGSL,
r"@fragment
fn effect_fs(input: VertexOutput) -> @location(0) vec4<f32> {
return vec4<f32>(u[0][0], 0.0, 0.0, 1.0);
}
"
)
}
fn shaded_runtime_shader_layer(node_id: NodeId, time: f32) -> LayerNode {
let shaded_bounds = Rect {
x: 0.0,
y: 0.0,
width: 64.0,
height: 48.0,
};
let mut shader = cranpose_ui_graphics::RuntimeShader::new(&animated_shader_wgsl());
shader.set_float(0, time);
let mut shaded = support::contract_layer(
Some(node_id),
CachePolicy::Auto,
shaded_bounds,
ProjectiveTransform::translation(8.0, 8.0),
vec![RenderNode::Primitive(PrimitiveEntry {
phase: PrimitivePhase::BeforeChildren,
node: PrimitiveNode::Draw(DrawPrimitiveNode {
primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
rect: shaded_bounds,
brush: Brush::solid(Color(0.0, 0.0, 0.0, 1.0)),
stroke: None,
},
clip: None,
}),
})],
);
shaded.graphics_layer.render_effect =
Some(cranpose_ui_graphics::RenderEffect::runtime_shader(shader));
shaded
}
fn shader_inside_cached_container_graph(time: f32) -> RenderGraph {
let shaded = shaded_runtime_shader_layer(30_001, time);
let mut container = support::contract_layer(
Some(30_000),
CachePolicy::Auto,
Rect {
x: 0.0,
y: 0.0,
width: 96.0,
height: 80.0,
},
ProjectiveTransform::translation(4.0, 4.0),
vec![RenderNode::Layer(Box::new(shaded))],
);
container.clip_to_bounds = true;
container.isolation.shape_clip = true;
RenderGraph::new(support::contract_layer(
Some(30_002),
CachePolicy::None,
Rect {
x: 0.0,
y: 0.0,
width: 128.0,
height: 96.0,
},
ProjectiveTransform::identity(),
vec![RenderNode::Layer(Box::new(container))],
))
}
fn shader_layer_graph(time: f32) -> RenderGraph {
let shaded = shaded_runtime_shader_layer(30_101, time);
RenderGraph::new(support::contract_layer(
Some(30_102),
CachePolicy::None,
Rect {
x: 0.0,
y: 0.0,
width: 128.0,
height: 96.0,
},
ProjectiveTransform::identity(),
vec![RenderNode::Layer(Box::new(shaded))],
))
}
#[test]
fn a_runtime_shader_layer_with_stable_uniforms_is_served_from_the_layer_cache() {
let mut renderer = match support::headless_renderer() {
Ok(renderer) => renderer,
Err(err) => {
eprintln!(
"skipping runtime-shader cache assertion because headless WGPU init failed: {err}"
);
return;
}
};
renderer.scene_mut().graph = Some(shader_layer_graph(0.25));
let first = renderer
.capture_frame(128, 96)
.expect("first shader capture should succeed");
renderer.scene_mut().graph = Some(shader_layer_graph(0.25));
renderer
.capture_frame(128, 96)
.expect("the second sighting admits the layer into the cache");
renderer.scene_mut().graph = Some(shader_layer_graph(0.25));
let second = renderer
.capture_frame(128, 96)
.expect("second shader capture should succeed");
let stats = renderer.last_frame_stats().expect("frame stats");
let cached_pass_count = stats.pass_count;
assert_eq!(second.pixels, first.pixels);
assert!(
stats.layer_cache_hits >= 1 && stats.isolated_layer_renders == 0,
"a runtime shader's output depends only on its uniforms, source and layer rect, all in \
the cache key, so a repeated frame must not re-run it: {stats:?}"
);
renderer.scene_mut().graph = Some(shader_layer_graph(0.75));
let third = renderer
.capture_frame(128, 96)
.expect("third shader capture should succeed");
let stats = renderer.last_frame_stats().expect("frame stats");
assert!(
third.pixels != second.pixels,
"a changed uniform must re-run the shader: {stats:?}"
);
assert!(
stats.layer_cache_hits >= 1 && stats.isolated_layer_renders == 0,
"the cache holds the layer's content, not its effect: a changed uniform re-runs the \
shader over the cached content and re-renders nothing: {stats:?}"
);
assert_eq!(
stats.pass_count, cached_pass_count,
"a runtime shader over cached content draws in the final pass, not in a pass of its \
own: {stats:?}"
);
}
#[test]
fn an_animated_shader_keeps_animating_inside_a_cacheable_container() {
let mut renderer = match support::headless_renderer() {
Ok(renderer) => renderer,
Err(err) => {
eprintln!(
"skipping animated-shader cache assertion because headless WGPU init failed: {err}"
);
return;
}
};
renderer.scene_mut().graph = Some(shader_inside_cached_container_graph(0.0));
let first = renderer
.capture_frame(128, 96)
.expect("first shader capture should succeed");
renderer.scene_mut().graph = Some(shader_inside_cached_container_graph(1.0));
let second = renderer
.capture_frame(128, 96)
.expect("second shader capture should succeed");
let changed = first
.pixels
.as_chunks::<4>()
.0
.iter()
.zip(second.pixels.as_chunks::<4>().0)
.filter(|(a, b)| a != b)
.count();
assert!(
changed > 0,
"an animated shader inside a cacheable container must still animate: \
the container cannot cache a subtree whose output changes every frame"
);
}
fn clipped_text_graph(y: f32) -> RenderGraph {
let mut layer = text_layer(77, 16.25, y, "Cached short label");
let RenderNode::Primitive(PrimitiveEntry {
node: PrimitiveNode::Text(text),
..
}) = &mut layer.children[0]
else {
panic!("text primitive")
};
text.clip = Some(Rect {
x: 7.25,
y: 2.0,
width: 91.5,
height: 19.0,
});
support::page_graph(320, 140, vec![RenderNode::Layer(Box::new(layer))])
}
#[test]
fn retained_short_text_matches_fresh_clipped_pixels_after_translation() {
let mut renderer = support::headless_renderer().expect("headless renderer");
for scale in [1.0, 1.5, 3.0] {
let width = (320.0 * scale) as u32;
let height = (140.0 * scale) as u32;
for y in [20.25, -4.5, 72.75, 20.25] {
let mut fresh = support::headless_renderer_beside_locked().expect("reference renderer");
let context = cranpose_ui::AppContext::new();
fresh.attach_app_context_services(&context);
fresh.scene_mut().graph = Some(clipped_text_graph(y));
let expected = context
.enter(|| fresh.capture_frame_with_scale(width, height, scale))
.expect("reference pixels");
assert!(
expected
.pixels
.as_chunks::<4>()
.0
.iter()
.any(|pixel| pixel[0] > 128)
);
renderer.scene_mut().graph = Some(clipped_text_graph(y));
for _ in 0..2 {
let actual = renderer
.capture_frame_with_scale(width, height, scale)
.expect("retained pixels");
let diff = cranpose_render_common::image_compare::image_difference_stats(
&expected.pixels,
&actual.pixels,
width,
height,
0,
);
assert_eq!(diff.differing_pixels, 0, "scale={scale} y={y}: {diff:?}");
}
}
}
}