use darkly::brush::{
default_graph,
pipeline::BrushPipelines,
preview_renderer::{synthesize_stroke_path, BrushStrokePreviewRenderer},
};
use darkly::gpu::test_utils::{readback_texture, test_device};
#[test]
fn renders_s_curve_over_black_background() {
let (device, queue) = test_device();
let pipelines = BrushPipelines::new(
&device,
&queue,
&darkly::gpu::selection::selection_mask_bgl(&device),
);
let mut renderer = BrushStrokePreviewRenderer::new();
let graph = default_graph();
let width: u32 = 320;
let height: u32 = 120;
let path = synthesize_stroke_path(width as f32, height as f32, 30, 0.0);
let fg = [1.0, 1.0, 1.0, 1.0]; let bg = [0.0, 0.0, 0.0, 1.0];
let texture = renderer
.render_stroke(
&device, &queue, &pipelines, &graph, &path, fg, bg, width, height,
)
.expect("render_stroke should return a texture for the default graph");
let pixels = readback_texture(
&device,
&queue,
texture,
wgpu::TextureFormat::Rgba8Unorm,
width,
height,
);
assert_eq!(pixels.len(), (width * height * 4) as usize);
let get = |x: u32, y: u32| -> [u8; 4] {
let i = ((y * width + x) * 4) as usize;
[pixels[i], pixels[i + 1], pixels[i + 2], pixels[i + 3]]
};
let corner = get(width - 2, 1);
assert_eq!(
corner[3], 255,
"bg alpha should remain opaque away from the stroke"
);
assert!(
corner[0] < 40 && corner[1] < 40 && corner[2] < 40,
"bg corner should stay near-black, got {:?}",
corner
);
let mut any_bright = false;
for dy in -10i32..=10 {
for dx in -10i32..=10 {
let x = (width as i32 / 2 + dx) as u32;
let y = (height as i32 / 2 + dy) as u32;
let px = get(x, y);
if px[0] > 64 || px[1] > 64 || px[2] > 64 {
any_bright = true;
}
}
}
assert!(
any_bright,
"expected bright pixels near the center along the S-curve"
);
}
#[test]
fn renderer_reuses_target_across_renders_of_same_size() {
let (device, queue) = test_device();
let pipelines = BrushPipelines::new(
&device,
&queue,
&darkly::gpu::selection::selection_mask_bgl(&device),
);
let mut renderer = BrushStrokePreviewRenderer::new();
let graph = default_graph();
let path = synthesize_stroke_path(320.0, 120.0, 20, 0.0);
assert!(renderer.current_size().is_none());
let _ = renderer.render_stroke(
&device,
&queue,
&pipelines,
&graph,
&path,
[1.0, 1.0, 1.0, 1.0],
[0.0, 0.0, 0.0, 1.0],
320,
120,
);
assert_eq!(renderer.current_size(), Some((320, 120)));
let first_ptr = renderer.current_texture().map(|t| t as *const _);
let _ = renderer.render_stroke(
&device,
&queue,
&pipelines,
&graph,
&path,
[1.0, 0.0, 0.0, 1.0],
[1.0, 1.0, 1.0, 1.0],
320,
120,
);
let second_ptr = renderer.current_texture().map(|t| t as *const _);
assert_eq!(first_ptr, second_ptr);
}
fn decode_preview_png(png_bytes: &[u8]) -> (u32, u32, Vec<u8>) {
let img = image::load_from_memory(png_bytes).expect("valid PNG bytes");
let rgba = img.to_rgba8();
let (w, h) = rgba.dimensions();
(w, h, rgba.into_raw())
}
#[test]
fn engine_brush_stroke_preview_caches_after_readback() {
use darkly::engine::DarklyEngine;
use darkly::gpu::context::GpuContext;
let (device, queue) = test_device();
let gpu = GpuContext::new_headless(device, queue);
let mut engine = DarklyEngine::new(gpu, 1024, 768);
let first = engine.brush_stroke_preview();
assert!(
first.is_empty(),
"cache miss should return empty Vec, got {} bytes",
first.len()
);
engine.test_flush_readbacks();
let second = engine.brush_stroke_preview();
assert!(
!second.is_empty(),
"post-readback call should return cached PNG bytes"
);
assert_eq!(
&second[..8],
&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A],
"cache should hold PNG-encoded bytes"
);
let (w, h, pixels) = decode_preview_png(&second);
assert!(w > 0 && h > 0, "decoded preview has positive dimensions");
let non_zero_pixels = pixels
.chunks_exact(4)
.filter(|px| px[0] > 0 || px[1] > 0 || px[2] > 0)
.count();
assert!(
non_zero_pixels > 100,
"expected non-trivial stroke coverage in cached preview, got {non_zero_pixels} non-zero pixels"
);
}
#[test]
fn engine_brush_stroke_preview_skips_unchanged_graph() {
use darkly::engine::DarklyEngine;
use darkly::gpu::context::GpuContext;
let (device, queue) = test_device();
let gpu = GpuContext::new_headless(device, queue);
let mut engine = DarklyEngine::new(gpu, 1024, 768);
let _ = engine.brush_stroke_preview();
engine.test_flush_readbacks();
let first = engine.brush_stroke_preview();
let second = engine.brush_stroke_preview();
assert_eq!(first, second);
}
#[test]
fn set_preview_theme_invalidates_cache() {
use darkly::engine::DarklyEngine;
use darkly::gpu::context::GpuContext;
let (device, queue) = test_device();
let gpu = GpuContext::new_headless(device, queue);
let mut engine = DarklyEngine::new(gpu, 1024, 768);
engine.set_preview_theme([1.0, 1.0, 1.0, 1.0], [0.02, 0.02, 0.02, 1.0]);
let _ = engine.brush_stroke_preview();
engine.test_flush_readbacks();
let dark_png = engine.brush_stroke_preview();
engine.set_preview_theme([0.0, 0.0, 0.0, 1.0], [0.9, 0.9, 0.9, 1.0]);
let after_change = engine.brush_stroke_preview();
assert!(after_change.is_empty());
engine.test_flush_readbacks();
let light_png = engine.brush_stroke_preview();
assert_ne!(
dark_png, light_png,
"theme change must produce new preview bytes"
);
let (_, _, light_pixels) = decode_preview_png(&light_png);
let mut bright_bg = 0;
for chunk in light_pixels.chunks_exact(4) {
if chunk[0] > 200 && chunk[1] > 200 && chunk[2] > 200 {
bright_bg += 1;
}
}
assert!(
bright_bg > 1000,
"light theme preview should have many bright bg pixels, got {bright_bg}"
);
}
#[test]
fn brush_save_bakes_thumbnail_asynchronously() {
use darkly::brush::bundle::Brush;
use darkly::engine::DarklyEngine;
use darkly::gpu::context::GpuContext;
let (device, queue) = test_device();
let gpu = GpuContext::new_headless(device, queue);
let mut engine = DarklyEngine::new(gpu, 1024, 768);
engine.brush_save("TestBrush", "basic").unwrap();
let exported_before = engine.brush_export("TestBrush").expect("brush exported");
let bundle_before = Brush::from_bytes(&exported_before).unwrap();
assert!(
bundle_before.thumbnail_png.is_none(),
"thumbnail should be absent before readback completes"
);
engine.test_flush_readbacks();
let exported_after = engine.brush_export("TestBrush").unwrap();
let bundle_after = Brush::from_bytes(&exported_after).unwrap();
let png = bundle_after
.thumbnail_png
.expect("thumbnail present after readback");
assert_eq!(
&png[..8],
&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A],
"library entry now carries a PNG-encoded thumbnail"
);
}
#[test]
fn airbrush_endpoint_dabs_not_clipped_against_cache_border() {
use darkly::engine::DarklyEngine;
use darkly::gpu::context::GpuContext;
let (device, queue) = test_device();
let gpu = GpuContext::new_headless(device, queue);
let mut engine = DarklyEngine::new(gpu, 1024, 768);
engine.set_preview_theme([1.0, 1.0, 1.0, 1.0], [0.0, 0.0, 0.0, 1.0]);
engine.brush_load("Airbrush").expect("Airbrush built-in");
let _ = engine.brush_stroke_preview();
engine.test_flush_readbacks();
let png = engine.brush_stroke_preview();
let (width, height, pixels) = decode_preview_png(&png);
assert_eq!(pixels.len(), (width * height * 4) as usize);
const TOLERANCE: u8 = 16;
let is_stroke = |i: usize| -> bool {
pixels[i] > TOLERANCE || pixels[i + 1] > TOLERANCE || pixels[i + 2] > TOLERANCE
};
let edge_band = 1u32;
for x_band in [0..edge_band, (width - edge_band)..width] {
for x in x_band {
for y in 0..height {
let i = ((y * width + x) * 4) as usize;
assert!(
!is_stroke(i),
"Airbrush preview cuts off at the edge — column {x} y={y} \
has stroke pixel rgba={:?}",
[pixels[i], pixels[i + 1], pixels[i + 2]],
);
}
}
}
}
#[test]
fn stabilize_scrub_does_not_bump_editor_preview_version() {
use darkly::engine::DarklyEngine;
use darkly::gpu::context::GpuContext;
let (device, queue) = test_device();
let gpu = GpuContext::new_headless(device, queue);
let mut engine = DarklyEngine::new(gpu, 1024, 768);
engine.brush_load("Ink Pen").expect("Ink Pen built-in");
let _ = engine.brush_stroke_preview();
engine.test_flush_readbacks();
let v_before_stabilize = engine.brush_graph_version();
let stabilize = engine
.brush_exposed_ports()
.into_iter()
.find(|p| p.port_name == "stabilize")
.expect("Ink Pen exposes a `stabilize` port");
engine
.brush_set_exposed_port(stabilize.node_id, "stabilize", 90.0)
.expect("scrub set");
assert_eq!(
engine.brush_graph_version(),
v_before_stabilize,
"stabilize is preview-irrelevant (PassThrough is hardcoded for \
the synthetic stroke); scrubbing it must not bump \
brush_graph_version — bumping invalidates the editor preview \
cache and triggers a wasted full-stroke re-render."
);
let softness = engine
.brush_exposed_ports()
.into_iter()
.find(|p| p.port_name == "softness")
.expect("Ink Pen exposes a `softness` port (on shape after migration)");
let v_before_softness = engine.brush_graph_version();
engine
.brush_set_exposed_port(softness.node_id, "softness", 0.5)
.expect("scrub set");
assert_ne!(
engine.brush_graph_version(),
v_before_softness,
"softness is not flagged preview_irrelevant_scrub → it affects \
the preview output → its scrub must bump brush_graph_version. \
If this assertion fails, the preview-irrelevant rule is \
over-broad and real preview updates would also stall."
);
}
#[test]
fn size_scrub_does_not_bump_editor_preview_version() {
use darkly::engine::DarklyEngine;
use darkly::gpu::context::GpuContext;
let (device, queue) = test_device();
let gpu = GpuContext::new_headless(device, queue);
let mut engine = DarklyEngine::new(gpu, 1024, 768);
engine.brush_load("Ink Pen").expect("Ink Pen built-in");
let _ = engine.brush_stroke_preview();
engine.test_flush_readbacks();
let v_before = engine.brush_graph_version();
let size = engine
.brush_exposed_ports()
.into_iter()
.find(|p| p.port_name == "size")
.expect("Ink Pen exposes a `size` port");
engine
.brush_set_exposed_port(size.node_id, "size", 90.0)
.expect("scrub set");
assert_eq!(
engine.brush_graph_version(),
v_before,
"scrubbing `size` must not bump brush_graph_version. \
`paint.size` is flagged `preview_value` (= 0.1), and the \
stroke-preview render path applies `apply_preview_overrides` \
to neutralize it before rendering — so the editor preview's \
output cannot change in response to the scrub, and \
invalidating its cache would just trigger a wasted \
full-stroke re-render and a visible blink as the new size \
briefly shows."
);
}
#[test]
fn empty_path_returns_none() {
let (device, queue) = test_device();
let pipelines = BrushPipelines::new(
&device,
&queue,
&darkly::gpu::selection::selection_mask_bgl(&device),
);
let mut renderer = BrushStrokePreviewRenderer::new();
let graph = default_graph();
let result = renderer.render_stroke(
&device,
&queue,
&pipelines,
&graph,
&[],
[1.0, 1.0, 1.0, 1.0],
[0.0, 0.0, 0.0, 1.0],
320,
120,
);
assert!(result.is_none());
}