use darkly::engine::DarklyEngine;
use darkly::gpu::context::GpuContext;
use darkly::gpu::test_utils::test_device;
fn fresh_engine() -> DarklyEngine {
let (device, queue) = test_device();
let gpu = GpuContext::new_headless(device, queue);
DarklyEngine::new(gpu, 1024, 768)
}
#[test]
fn brush_thumbnail_first_call_kicks_bake_then_returns_png() {
let mut engine = fresh_engine();
let first = engine.brush_thumbnail("Airbrush");
assert!(
first.is_empty(),
"first call should return empty bytes while the bake is in flight"
);
let second = engine.brush_thumbnail("Airbrush");
assert!(
second.is_empty(),
"second call before flush should still be empty"
);
engine.test_flush_readbacks();
let third = engine.brush_thumbnail("Airbrush");
assert!(
!third.is_empty(),
"after flush the library entry should hold the baked PNG"
);
assert_eq!(
&third[..8],
&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A],
"bytes start with the PNG signature"
);
}
#[test]
fn theme_change_invalidates_brush_thumbnail() {
let mut engine = fresh_engine();
let _ = engine.brush_thumbnail("Airbrush");
engine.test_flush_readbacks();
let dark_png = engine.brush_thumbnail("Airbrush");
assert!(!dark_png.is_empty(), "dark-theme bake produced bytes");
assert_eq!(
&dark_png[..8],
&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]
);
engine.set_preview_theme([0.0, 0.0, 0.0, 1.0], [1.0, 1.0, 1.0, 1.0]);
let pending = engine.brush_thumbnail("Airbrush");
assert!(
pending.is_empty(),
"first call after theme swap returns empty while the re-bake is in flight"
);
engine.test_flush_readbacks();
let light_png = engine.brush_thumbnail("Airbrush");
assert!(
!light_png.is_empty(),
"post-flush bytes are present under the new theme"
);
assert_ne!(
light_png, dark_png,
"inverted theme should produce different PNG bytes"
);
}
#[test]
fn brush_dab_thumbnail_first_call_kicks_bake_then_returns_png() {
let mut engine = fresh_engine();
let first = engine.brush_dab_thumbnail("Airbrush");
assert!(
first.is_empty(),
"first call returns empty while the dab bake is in flight"
);
let second = engine.brush_dab_thumbnail("Airbrush");
assert!(
second.is_empty(),
"back-to-back call before flush stays empty (no double-queue)"
);
engine.test_flush_readbacks();
let third = engine.brush_dab_thumbnail("Airbrush");
assert!(
!third.is_empty(),
"after flush the dab cache holds a baked PNG"
);
assert_eq!(
&third[..8],
&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A],
"bytes start with the PNG signature"
);
}
#[test]
fn theme_change_invalidates_dab_thumbnail() {
let mut engine = fresh_engine();
let _ = engine.brush_dab_thumbnail("Airbrush");
engine.test_flush_readbacks();
let dark = engine.brush_dab_thumbnail("Airbrush");
assert!(!dark.is_empty(), "dark-theme dab bake produced bytes");
engine.set_preview_theme([0.0, 0.0, 0.0, 1.0], [1.0, 1.0, 1.0, 1.0]);
let pending = engine.brush_dab_thumbnail("Airbrush");
assert!(
pending.is_empty(),
"first call after theme swap returns empty while the re-bake is in flight"
);
engine.test_flush_readbacks();
let light = engine.brush_dab_thumbnail("Airbrush");
assert!(!light.is_empty(), "rebake produces fresh bytes");
assert_ne!(
light, dark,
"inverted theme should produce different dab PNG bytes"
);
}
fn decoded_dab_content_ratio(png: &[u8]) -> f64 {
let img = image::load_from_memory(png).expect("valid PNG");
let rgba = img.to_rgba8();
let bright = rgba.pixels().filter(|p| p.0[0] > 128).count();
let total = (rgba.width() * rgba.height()) as usize;
bright as f64 / total as f64
}
#[test]
fn small_size_brush_dab_thumbnail_is_framed() {
let mut engine = fresh_engine();
let _ = engine.brush_dab_thumbnail("Airbrush");
engine.test_flush_readbacks();
let png = engine.brush_dab_thumbnail("Airbrush");
let ratio = decoded_dab_content_ratio(&png);
assert!(
ratio > 0.10,
"Airbrush dab should fill at least 10% of the framed thumbnail; got {:.1}%",
ratio * 100.0
);
}
#[test]
fn brush_thumbnail_unknown_name_returns_empty() {
let mut engine = fresh_engine();
let bytes = engine.brush_thumbnail("Definitely Not A Real Brush");
assert!(
bytes.is_empty(),
"unknown brush names return empty without queueing anything"
);
engine.test_flush_readbacks();
let bytes = engine.brush_thumbnail("Definitely Not A Real Brush");
assert!(bytes.is_empty(), "still empty after flush");
}
#[test]
fn active_dab_preview_first_call_empty_then_present_after_flush() {
let mut engine = fresh_engine();
engine
.brush_load("Airbrush")
.expect("Airbrush is a built-in brush");
let first = engine.brush_active_dab_preview();
assert!(
first.is_empty(),
"cache miss returns an empty Vec — frontends use that as 'no fresh \
bytes' so the previous render stays on screen instead of flashing \
transparent. Got {} bytes.",
first.len(),
);
engine.test_flush_readbacks();
let live = engine.brush_active_dab_preview();
assert!(!live.is_empty(), "post-flush bytes carry the framed PNG");
assert_eq!(
&live[..8],
&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A],
"bytes start with the PNG signature — same wire format as `brush_dab_thumbnail`"
);
let _ = engine.brush_dab_thumbnail("Airbrush");
engine.test_flush_readbacks();
let baked = engine.brush_dab_thumbnail("Airbrush");
assert!(!baked.is_empty(), "baked thumbnail produced bytes");
assert_eq!(
live, baked,
"active-brush preview must be byte-identical to the baked thumbnail \
for the same brush — divergence means the BrushBar trigger / \
picker active strip will visually disagree with the picker tile."
);
}
#[test]
fn active_dab_preview_cached_across_calls() {
let mut engine = fresh_engine();
let _ = engine.brush_active_dab_preview();
engine.test_flush_readbacks();
let cached_a = engine.brush_active_dab_preview();
let cached_b = engine.brush_active_dab_preview();
assert_eq!(
cached_a, cached_b,
"back-to-back calls without invalidation return the same cached PNG"
);
}
#[test]
fn theme_change_invalidates_active_dab_preview() {
let mut engine = fresh_engine();
let _ = engine.brush_active_dab_preview();
engine.test_flush_readbacks();
let before = engine.brush_active_dab_preview();
assert!(!before.is_empty(), "baseline has framed PNG");
engine.set_preview_theme([0.0, 0.0, 0.0, 1.0], [1.0, 1.0, 1.0, 1.0]);
let after_invalidate_first = engine.brush_active_dab_preview();
assert!(
after_invalidate_first.is_empty(),
"theme change drops the cache; next call returns empty until the \
rebake lands. Got {} bytes.",
after_invalidate_first.len(),
);
engine.test_flush_readbacks();
let after = engine.brush_active_dab_preview();
assert!(!after.is_empty(), "rebake produces fresh PNG");
assert_ne!(
after, before,
"different theme should yield different bytes"
);
}
#[test]
fn size_scrub_does_not_change_active_dab_pixels() {
let mut engine = fresh_engine();
let _ = engine.brush_active_dab_preview();
engine.test_flush_readbacks();
let before = engine.brush_active_dab_preview();
assert!(!before.is_empty());
let size = engine
.brush_exposed_ports()
.into_iter()
.find(|p| p.port_name == "size")
.expect("default brush exposes a `size` port");
let topo_before_scrub = engine.brush_topology_version();
engine
.brush_set_exposed_port(size.node_id, "size", 250.0)
.expect("scrub set");
assert_eq!(
engine.brush_topology_version(),
topo_before_scrub,
"exposed-port scrub must not advance the topology version — \
the frontend uses this to keep the active preset name across scrubs"
);
engine.test_flush_readbacks();
let after = engine.brush_active_dab_preview();
assert_eq!(
after, before,
"scrubbing the user-facing size port must not change the dab thumbnail bytes"
);
let topo_before_toggle = engine.brush_topology_version();
engine
.brush_graph_unexpose_port(size.node_id, "size")
.expect("unexpose size port");
assert_ne!(
engine.brush_topology_version(),
topo_before_toggle,
"exposing/unexposing a port is a structural change and must advance the topology version"
);
}
#[test]
fn graph_change_triggers_active_dab_rebake() {
let mut engine = fresh_engine();
let _ = engine.brush_active_dab_preview();
engine.test_flush_readbacks();
let before = engine.brush_active_dab_preview();
assert!(!before.is_empty());
engine
.brush_load("Ink Pen")
.expect("Ink Pen is a built-in brush");
let _stale_fallback = engine.brush_active_dab_preview();
engine.test_flush_readbacks();
let after = engine.brush_active_dab_preview();
assert!(
!after.is_empty(),
"rebake under the new brush produces fresh pixels"
);
assert_ne!(
after, before,
"swapping Airbrush for Ink Pen should produce different dab pixels"
);
}