use darkly::engine::DarklyEngine;
use darkly::gpu::context::GpuContext;
use darkly::gpu::params::ParamValue;
use darkly::gpu::test_utils::test_device;
fn test_engine(width: u32, height: u32) -> DarklyEngine {
let (device, queue) = test_device();
let gpu = GpuContext::new_headless(device, queue);
DarklyEngine::new(gpu, width, height)
}
fn noise_defaults(engine: &DarklyEngine) -> Vec<ParamValue> {
engine
.void_param_defs("noise")
.iter()
.map(darkly::gpu::params::ParamDef::default_value)
.collect()
}
#[test]
fn noise_void_is_registered() {
let engine = test_engine(64, 64);
let types: Vec<_> = engine.void_types().into_iter().map(|t| t.type_id).collect();
assert!(
types.contains(&"noise"),
"noise void must be auto-registered by build.rs; got {types:?}",
);
}
#[test]
fn noise_void_default_params_match_schema() {
let engine = test_engine(64, 64);
let defs = engine.void_param_defs("noise");
let defaults = noise_defaults(&engine);
assert_eq!(
defs.len(),
defaults.len(),
"default vector length must match the void's ParamDef slice",
);
}
#[test]
fn fresh_void_is_named_after_its_display_type() {
let mut engine = test_engine(64, 64);
let params = noise_defaults(&engine);
let id1 = engine
.add_void_layer("noise", params.clone(), None)
.expect("noise void should be addable");
let id2 = engine
.add_void_layer("noise", params, None)
.expect("second noise void should be addable");
let name1 = layer_name(&engine, id1);
let name2 = layer_name(&engine, id2);
assert_eq!(name1, "Noise 1");
assert_eq!(name2, "Noise 2");
}
fn layer_name(engine: &DarklyEngine, layer_id: darkly::layer::LayerId) -> String {
let tree = engine.layer_tree();
let json = serde_json::to_value(&tree).unwrap();
fn walk(node: &serde_json::Value, want: u64) -> Option<String> {
if let Some(arr) = node.as_array() {
for n in arr {
if let Some(s) = walk(n, want) {
return Some(s);
}
}
return None;
}
if let Some(obj) = node.as_object() {
if obj.get("id").and_then(|i| i.as_f64()) == Some(want as f64) {
return obj.get("name").and_then(|n| n.as_str()).map(str::to_string);
}
if let Some(children) = obj.get("children") {
return walk(children, want);
}
}
None
}
walk(&json, layer_id.to_ffi()).expect("layer should be in the tree")
}
#[test]
fn voids_are_not_paintable() {
let mut engine = test_engine(64, 64);
let raster_id = engine.add_raster_layer(None);
let params = noise_defaults(&engine);
let void_id = engine
.add_void_layer("noise", params, None)
.expect("noise void should be addable");
assert!(
engine.is_node_paintable(raster_id),
"raster layers must remain paintable",
);
assert!(
!engine.is_node_paintable(void_id),
"void layers must not accept paint",
);
}
#[test]
fn add_void_layer_appears_in_tree() {
let mut engine = test_engine(64, 64);
let params = noise_defaults(&engine);
let id = engine
.add_void_layer("noise", params, None)
.expect("noise void should be addable");
let tree = engine.layer_tree();
let json = serde_json::to_string(&tree).unwrap();
assert!(
json.contains("\"type\":\"void\""),
"layer_tree should expose the void with type=void; got {json}",
);
assert!(engine.has_layer(id));
}
#[test]
fn add_unknown_void_type_returns_none() {
let mut engine = test_engine(64, 64);
let result = engine.add_void_layer("totally-not-a-real-void", Vec::new(), None);
assert!(
result.is_none(),
"unknown void type ids should be rejected, not silently substituted",
);
}
#[test]
fn add_void_layer_is_undoable() {
let mut engine = test_engine(64, 64);
let params = noise_defaults(&engine);
let id = engine
.add_void_layer("noise", params, None)
.expect("noise void should be addable");
assert!(engine.has_layer(id), "fresh void should be in the tree");
engine.undo();
assert!(
!engine.has_layer(id),
"undo should detach the void from the tree (id stays valid for redo)",
);
engine.redo();
assert!(engine.has_layer(id), "redo should re-attach the void");
}
#[test]
fn update_void_params_is_undoable_and_coalesces() {
let mut engine = test_engine(64, 64);
let defaults = noise_defaults(&engine);
let id = engine
.add_void_layer("noise", defaults.clone(), None)
.expect("noise void should be addable");
let info = void_params_via_tree(&engine, id);
assert_eq!(info.len(), defaults.len());
let mut next = defaults.clone();
for new_seed in [123, 124, 125] {
for v in next.iter_mut() {
if let ParamValue::Int(_) = v {
*v = ParamValue::Int(new_seed);
break;
}
}
engine.update_void_params(id, next.clone());
}
engine.undo();
let restored = void_params_via_tree(&engine, id);
assert_eq!(
restored.first().cloned(),
defaults.first().cloned(),
"undo should roll back to pre-drag seed, not the intermediate one",
);
}
#[test]
fn noise_void_time_scrub_changes_output() {
let mut engine = test_engine(64, 64);
let mut params = noise_defaults(&engine);
set_float_param(&mut params, "time", 0.0);
let id = engine
.add_void_layer("noise", params.clone(), None)
.expect("noise void should be addable");
let frame_a = engine.test_readback_canvas();
set_float_param(&mut params, "time", 5.0);
engine.update_void_params(id, params);
let frame_b = engine.test_readback_canvas();
assert_ne!(
frame_a, frame_b,
"scrubbing `time` must produce a different cross-section of the field",
);
}
#[test]
fn noise_void_time_scrub_is_continuous() {
let mut engine = test_engine(64, 64);
let mut params = noise_defaults(&engine);
set_float_param(&mut params, "time", 0.0);
set_float_param(&mut params, "warp", 1.5);
let id = engine
.add_void_layer("noise", params.clone(), None)
.expect("noise void should be addable");
let frame_a = engine.test_readback_canvas();
set_float_param(&mut params, "time", 0.5);
engine.update_void_params(id, params);
let frame_b = engine.test_readback_canvas();
assert_eq!(
frame_a.len(),
frame_b.len(),
"readbacks must be the same size",
);
let mut total_delta: u64 = 0;
let mut changed_bytes: u32 = 0;
let mut max_delta: u8 = 0;
for (a, b) in frame_a.iter().zip(frame_b.iter()) {
let d = a.abs_diff(*b);
total_delta += d as u64;
if d > 0 {
changed_bytes += 1;
}
if d > max_delta {
max_delta = d;
}
}
let mean_delta = (total_delta as f32) / (frame_a.len() as f32);
assert!(
mean_delta < 25.0,
"mean per-byte delta {mean_delta:.1} exceeds continuity bound; \
a value near 70 indicates `time` is reseeding instead of scrubbing",
);
let total_bytes = frame_a.len() as u32;
assert!(
changed_bytes * 10 >= total_bytes,
"only {changed_bytes}/{total_bytes} bytes changed — `time` may not \
be reaching the GPU uniform",
);
assert!(
max_delta < 100,
"max per-byte delta {max_delta} exceeds plausible 3D-FBM bound",
);
}
#[test]
fn void_dirty_is_per_instance() {
let mut engine = test_engine(64, 64);
let mut params_a = noise_defaults(&engine);
let mut params_b = noise_defaults(&engine);
set_int_param(&mut params_a, "seed", 100);
set_int_param(&mut params_b, "seed", 200);
let id_a = engine
.add_void_layer("noise", params_a.clone(), None)
.expect("noise void should be addable");
let id_b = engine
.add_void_layer("noise", params_b, None)
.expect("second noise void should be addable");
let _ = engine.test_readback_canvas();
let before_a = engine.test_readback_layer(id_a);
let before_b = engine.test_readback_layer(id_b);
assert_ne!(
before_a, before_b,
"two different seeds must produce different textures",
);
set_float_param(&mut params_a, "time", 5.0);
engine.update_void_params(id_a, params_a);
let _ = engine.test_readback_canvas();
let after_a = engine.test_readback_layer(id_a);
let after_b = engine.test_readback_layer(id_b);
assert_ne!(
before_a, after_a,
"mutated void should re-encode and change pixels",
);
assert_eq!(
before_b, after_b,
"untouched void must not re-encode — its dirty bit is its own state",
);
}
fn set_int_param(params: &mut [ParamValue], name: &str, value: i32) {
let engine = test_engine(1, 1);
let defs = engine.void_param_defs("noise");
let idx = defs
.iter()
.position(|d| match d {
darkly::gpu::params::ParamDef::Int { name: n, .. } => *n == name,
_ => false,
})
.unwrap_or_else(|| panic!("noise void has no int param '{name}'"));
params[idx] = ParamValue::Int(value);
}
fn set_float_param(params: &mut [ParamValue], name: &str, value: f32) {
let engine = test_engine(1, 1);
let defs = engine.void_param_defs("noise");
let idx = defs
.iter()
.position(|d| match d {
darkly::gpu::params::ParamDef::Float { name: n, .. } => *n == name,
_ => false,
})
.unwrap_or_else(|| panic!("noise void has no float param '{name}'"));
params[idx] = ParamValue::Float(value);
}
fn void_params_via_tree(
engine: &DarklyEngine,
layer_id: darkly::layer::LayerId,
) -> Vec<ParamValue> {
let tree = engine.layer_tree();
let json = serde_json::to_value(&tree).unwrap();
fn walk(node: &serde_json::Value, want: u64) -> Option<serde_json::Value> {
if let Some(arr) = node.as_array() {
for n in arr {
if let Some(found) = walk(n, want) {
return Some(found);
}
}
return None;
}
if let Some(obj) = node.as_object() {
if obj.get("type").and_then(|t| t.as_str()) == Some("void")
&& obj.get("id").and_then(|i| i.as_f64()) == Some(want as f64)
{
return Some(node.clone());
}
if let Some(children) = obj.get("children") {
return walk(children, want);
}
}
None
}
let v = walk(&json, layer_id.to_ffi()).expect("void layer should be in the tree");
let params = v.get("params").unwrap().as_array().unwrap();
params
.iter()
.map(|p| {
let default = p.get("default").unwrap().clone();
let value = p.get("value").cloned().unwrap_or(default);
serde_json::from_value(value).expect("ParamValue round-trips through JSON")
})
.collect()
}
fn camera_defaults(engine: &DarklyEngine) -> Vec<ParamValue> {
engine
.void_param_defs("camera")
.iter()
.map(darkly::gpu::params::ParamDef::default_value)
.collect()
}
#[test]
fn transform_capability_reports_live_for_camera() {
let mut engine = test_engine(64, 64);
let cam = engine
.add_void_layer("camera", camera_defaults(&engine), None)
.expect("camera void should be addable");
let noise = engine
.add_void_layer("noise", noise_defaults(&engine), None)
.expect("noise void should be addable");
assert_eq!(engine.layer_transform_capability(cam), "live");
assert_eq!(engine.layer_transform_capability(noise), "live");
let group = engine.add_group(None);
assert_eq!(engine.layer_transform_capability(group), "none");
}
#[test]
fn void_transform_info_reports_canvas_origin_after_crop() {
use darkly::coord::CanvasRect;
let mut engine = test_engine(128, 128);
let cam = engine
.add_void_layer("camera", camera_defaults(&engine), None)
.expect("camera void should be addable");
engine.resize_canvas(CanvasRect::from_xywh(10, 20, 40, 50));
assert_eq!(engine.canvas_rect().origin.x, 10);
assert_eq!(engine.canvas_rect().origin.y, 20);
let (ox, oy, w, h, _t) = engine
.void_transform_info(cam)
.expect("camera reports transform info");
assert_eq!(
(ox, oy),
(10.0, 20.0),
"gizmo origin must be canvas_origin, not (0,0)"
);
assert_eq!((w, h), (40.0, 50.0), "gizmo extent must be the canvas size");
}
#[test]
fn update_void_transform_stores_and_renders() {
use darkly::transform::Transform;
let mut engine = test_engine(64, 64);
let cam = engine
.add_void_layer("camera", camera_defaults(&engine), None)
.expect("camera void should be addable");
let t = Transform::from_affine([2.0, 0.0, 5.0, 0.0, 2.0, 9.0]);
engine.update_void_transform(cam, t);
let _ = engine.test_readback_canvas();
let (.., stored) = engine.void_transform_info(cam).expect("info");
assert_eq!(stored, t, "the stored transform reflects the update");
}
#[test]
fn void_transform_undo_restores_pre_edit_value() {
use darkly::transform::Transform;
let mut engine = test_engine(64, 64);
let cam = engine
.add_void_layer("camera", camera_defaults(&engine), None)
.expect("camera void should be addable");
let a = Transform::from_affine([1.0, 0.0, 3.0, 0.0, 1.0, 0.0]);
engine.update_void_transform(cam, a);
engine.update_void_params(cam, camera_defaults(&engine));
let b = Transform::from_affine([1.0, 0.0, 7.0, 0.0, 1.0, 0.0]);
engine.update_void_transform(cam, b);
engine.undo();
let (.., after_undo) = engine.void_transform_info(cam).expect("info");
assert_eq!(
after_undo, a,
"undo must restore the pre-edit transform, not identity"
);
}
#[test]
fn void_transform_drag_coalesces_to_one_undo_step() {
use darkly::transform::Transform;
let mut engine = test_engine(64, 64);
let cam = engine
.add_void_layer("camera", camera_defaults(&engine), None)
.expect("camera void should be addable");
for i in 1..=8 {
engine.update_void_transform(
cam,
Transform::from_affine([1.0, 0.0, i as f32, 0.0, 1.0, 0.0]),
);
}
engine.undo();
let (.., after) = engine.void_transform_info(cam).expect("info");
assert_eq!(
after,
Transform::from_affine([-1.0, 0.0, 64.0, 0.0, 1.0, 0.0]),
"the drag must be a single undo step back to the seeded selfie flip"
);
}
#[test]
fn noise_void_transform_changes_output() {
use darkly::transform::Transform;
let mut engine = test_engine(64, 64);
let mut params = noise_defaults(&engine);
set_float_param(&mut params, "size", 40.0); let id = engine
.add_void_layer("noise", params, None)
.expect("noise void should be addable");
let frame_a = engine.test_readback_canvas();
engine.update_void_transform(id, Transform::from_affine([2.0, 0.0, 0.0, 0.0, 2.0, 0.0]));
let frame_b = engine.test_readback_canvas();
assert_eq!(frame_a.len(), frame_b.len(), "readbacks must match size");
assert_ne!(
frame_a, frame_b,
"transforming a noise void must scale/pan the field in the output",
);
}
#[test]
fn camera_void_frame_round_trips_through_save_readback() {
let mut engine = test_engine(64, 64);
let cam = engine
.add_void_layer("camera", camera_defaults(&engine), None)
.expect("camera void should be addable");
let (w, h) = (4u32, 4u32);
let known: Vec<u8> = (0..(w * h))
.flat_map(|i| {
let v = (i * 16) as u8;
[v, 255 - v, 128, 255]
})
.collect();
engine.test_plant_void_frame(cam, w, h, &known);
let read_back = engine
.test_readback_void_frame(cam)
.expect("camera void with a planted frame must surface pixel data for save");
assert_eq!(
read_back, known,
"the camera void's persistent frame must read back byte-for-byte through the save path",
);
}