use serde::{Deserialize, Serialize};
use super::registry_io::{deserialize_instance, serialize_instance, InstancePayload};
use crate::brush::registry;
use crate::brush::stabilizer::StabilizerRegistry;
use crate::brush::wire::BrushWireType;
use crate::document::filter;
use crate::document::layer_kind;
use crate::gpu::blend_mode;
use crate::gpu::context::GpuContext;
use crate::gpu::params::{ParamDef, ParamValue};
use crate::gpu::test_utils::test_device;
use crate::gpu::veil::VeilRegistry;
use crate::nodegraph::Graph;
fn defaults_of(params: &[ParamDef]) -> Vec<ParamValue> {
params.iter().map(ParamDef::default_value).collect()
}
fn kitchen_sink_engine(width: u32, height: u32) -> crate::engine::DarklyEngine {
let (device, queue) = test_device();
let gpu = GpuContext::new_headless(device, queue);
let mut engine = crate::engine::DarklyEngine::new(gpu, width, height);
engine
.compositor
.veil_chain_mut()
.resize(&engine.gpu.device, &engine.gpu.queue, width, height);
engine
}
#[test]
fn round_trip_every_veil() {
let (device, queue) = test_device();
let gpu = GpuContext::new_headless(device, queue);
let format = gpu.surface_format();
let mut registry = VeilRegistry::new();
let types: Vec<(&'static str, &'static [ParamDef])> = registry
.types()
.into_iter()
.map(|(id, _name, params)| (id, params))
.collect();
assert!(
!types.is_empty(),
"veil registry must contain at least one veil"
);
for (type_id, params_schema) in types {
let defaults = defaults_of(params_schema);
let veil = registry.create_veil(type_id, &defaults, &gpu.device, format);
let json = serialize_instance(veil.type_id(), veil.param_values())
.unwrap_or_else(|e| panic!("serialize veil '{type_id}' failed: {e}"));
let payload = deserialize_instance(&json)
.unwrap_or_else(|e| panic!("deserialize veil '{type_id}' failed: {e}"));
assert_eq!(payload.type_id, type_id);
let restored = registry.create_veil(&payload.type_id, &payload.params, &gpu.device, format);
assert_eq!(
restored.type_id(),
veil.type_id(),
"type_id drift for '{type_id}'"
);
assert_eq!(
restored.param_values().len(),
veil.param_values().len(),
"param count drift for veil '{type_id}'"
);
for (a, b) in restored
.param_values()
.iter()
.zip(veil.param_values().iter())
{
assert_param_eq(a, b, type_id);
}
}
}
#[test]
fn round_trip_every_stabilizer() {
let registry = StabilizerRegistry::new();
let types = registry.types();
assert!(
!types.is_empty(),
"stabilizer registry must contain at least one algorithm"
);
for (type_id, _name, params_schema) in types {
let defaults = defaults_of(params_schema);
let json = serialize_instance(type_id, defaults.clone())
.unwrap_or_else(|e| panic!("serialize stabilizer '{type_id}' failed: {e}"));
let payload = deserialize_instance(&json)
.unwrap_or_else(|e| panic!("deserialize stabilizer '{type_id}' failed: {e}"));
assert_eq!(payload.type_id, type_id);
assert_eq!(payload.params.len(), defaults.len());
let _stab = registry
.create(&payload.type_id, &payload.params)
.unwrap_or_else(|| panic!("registry rejected its own '{type_id}'"));
}
}
#[test]
fn round_trip_every_brush_node() {
let registry = registry();
let types: Vec<&str> = registry.types().map(|reg| reg.type_id).collect();
assert!(
!types.is_empty(),
"brush node registry must contain at least one node type"
);
for type_id in types {
let mut graph: Graph<BrushWireType> = Graph::new();
let reg = registry
.get(type_id)
.unwrap_or_else(|| panic!("missing registration for '{type_id}'"));
let id = graph.add_node(reg.type_id, reg.ports.clone(), defaults_of(reg.params));
let json = serde_json::to_string(&graph)
.unwrap_or_else(|e| panic!("serialize graph with '{type_id}' failed: {e}"));
let back: Graph<BrushWireType> = serde_json::from_str(&json)
.unwrap_or_else(|e| panic!("deserialize graph with '{type_id}' failed: {e}"));
let node = back
.nodes()
.get(&id)
.unwrap_or_else(|| panic!("node lost across round-trip for '{type_id}'"));
assert_eq!(node.type_id, type_id);
assert_eq!(node.params.len(), reg.params.len());
}
}
#[test]
fn round_trip_every_blend_mode() {
let registry = blend_mode::registry();
let all = registry.all();
assert!(
!all.is_empty(),
"blend mode registry must contain at least 'normal'"
);
for reg in all {
let id = reg.type_id;
let json = serde_json::to_string(id).unwrap();
let back: String = serde_json::from_str(&json).unwrap();
assert_eq!(back, id);
assert!(
registry.get(&back).is_some(),
"round-tripped blend mode '{id}' must resolve back through registry"
);
}
}
#[test]
fn round_trip_every_modifier_kind() {
let registry = filter::registry();
let all = registry.all();
assert!(
!all.is_empty(),
"filter registry must contain at least one kind"
);
for reg in all {
let id = reg.type_id;
let json = serde_json::to_string(id).unwrap();
let back: String = serde_json::from_str(&json).unwrap();
assert_eq!(back, id);
assert!(
registry.get(&back).is_some(),
"round-tripped filter kind '{id}' must resolve back through registry"
);
}
}
#[test]
fn round_trip_every_layer_kind() {
let registry = layer_kind::registry();
let all = registry.all();
assert!(
!all.is_empty(),
"layer kind registry must contain at least one kind"
);
for reg in all {
let id = reg.type_id;
let json = serde_json::to_string(id).unwrap();
let back: String = serde_json::from_str(&json).unwrap();
assert_eq!(back, id);
assert!(
registry.get(&back).is_some(),
"round-tripped layer kind '{id}' must resolve back through registry"
);
}
}
#[test]
fn round_trip_param_value_variants() {
let cases = [
ParamValue::Bool(true),
ParamValue::Bool(false),
ParamValue::Int(0),
ParamValue::Int(42),
ParamValue::Int(-7),
ParamValue::Float(0.0),
ParamValue::Float(1.5),
ParamValue::Float(-3.25),
ParamValue::String("hello".into()),
ParamValue::String(String::new()),
ParamValue::Curve(vec![[0.0, 0.0], [0.5, 0.5], [1.0, 1.0]]),
ParamValue::Curve(vec![]),
];
for v in &cases {
let payload = InstancePayload::new("noop", vec![v.clone()]);
let json = serde_json::to_value(&payload).unwrap();
let back: InstancePayload = serde_json::from_value(json).unwrap();
assert_eq!(back.params.len(), 1);
assert_param_eq(&back.params[0], v, "param_value_variants");
}
}
fn assert_param_eq(a: &ParamValue, b: &ParamValue, context: &str) {
let ok = match (a, b) {
(ParamValue::Bool(x), ParamValue::Bool(y)) => x == y,
(ParamValue::Int(x), ParamValue::Int(y)) => x == y,
(ParamValue::Float(x), ParamValue::Float(y)) => x == y,
(ParamValue::String(x), ParamValue::String(y)) => x == y,
(ParamValue::Curve(x), ParamValue::Curve(y)) => x == y,
_ => false,
};
assert!(ok, "[{context}] param mismatch: {a:?} vs {b:?}");
}
#[test]
fn instance_payload_shape_is_type_id_plus_params() {
#[derive(Serialize, Deserialize)]
struct Probe {
type_id: String,
params: Vec<ParamValue>,
}
let payload = InstancePayload::new("foo", vec![ParamValue::Int(1)]);
let json = serde_json::to_value(&payload).unwrap();
let probe: Probe = serde_json::from_value(json).unwrap();
assert_eq!(probe.type_id, "foo");
assert!(matches!(probe.params.as_slice(), [ParamValue::Int(1)]));
}
use crate::document::Document;
use crate::engine::DarklyEngine;
use crate::format::manifest::SaveBundle;
use crate::format::zip_io::{assemble_zip, extract_zip};
use crate::layer::LayerId;
fn populate_kitchen_sink(engine: &mut DarklyEngine) {
engine.set_document_name("Kitchen Sink".to_string());
let modes: Vec<&'static str> = blend_mode::registry()
.all()
.iter()
.map(|reg| reg.type_id)
.collect();
let mut raster_ids: Vec<LayerId> = Vec::with_capacity(modes.len());
for type_id in &modes {
let id = engine.add_raster_layer(None);
engine.set_blend_mode(id, type_id);
engine.set_layer_name(id, &format!("blend-{type_id}"));
raster_ids.push(id);
}
let group = engine.add_group(None);
if let Some(first_layer) = raster_ids.first() {
engine.move_layer(
*first_layer,
crate::document::MoveTarget::IntoGroupTop(group),
);
}
if let Some(target) = raster_ids.get(1).copied() {
engine.add_mask(target);
}
engine.select_all();
let veil_types: Vec<(&'static str, &'static [ParamDef])> = engine
.compositor
.veil_chain()
.registry()
.types()
.into_iter()
.map(|(id, _name, params)| (id, params))
.collect();
for (type_id, schema) in veil_types {
let defaults = defaults_of(schema);
engine.add_veil(type_id, &defaults);
}
let void_types: Vec<(&'static str, &'static [ParamDef])> = engine
.compositor
.void_registry()
.types()
.into_iter()
.map(|reg| (reg.type_id, reg.params))
.collect();
for (type_id, schema) in void_types {
let defaults = defaults_of(schema);
engine.add_void_layer(type_id, defaults, None);
}
let filter_types: Vec<String> = engine
.compositor
.filter_pipeline_registry()
.types()
.into_iter()
.map(|(id, _name)| id.to_string())
.collect();
for pipeline in filter_types {
engine.add_filter_layer(&pipeline, Vec::new(), None);
}
}
fn drive_save_to_completion(engine: &mut DarklyEngine) -> SaveBundle {
engine
.start_save_document(crate::engine::SavePurpose::File)
.expect("start save");
for _ in 0..32 {
engine.test_flush_readbacks();
engine.render(0.0);
if let Some(b) = engine.poll_save_result() {
return b;
}
}
panic!("save did not complete within 32 frames");
}
fn assert_documents_equivalent(a: &Document, b: &Document) {
assert_eq!(a.width, b.width);
assert_eq!(a.height, b.height);
assert_eq!(a.name, b.name);
assert_eq!(a.all_raster_layers().len(), b.all_raster_layers().len());
assert_eq!(a.all_groups().len(), b.all_groups().len());
assert_eq!(a.all_filters().len(), b.all_filters().len());
assert_eq!(a.selection_id().is_some(), b.selection_id().is_some());
let modes_a: std::collections::BTreeSet<&'static str> = a
.all_raster_layers()
.iter()
.map(|r| r.blend.blend_mode.type_id)
.collect();
let modes_b: std::collections::BTreeSet<&'static str> = b
.all_raster_layers()
.iter()
.map(|r| r.blend.blend_mode.type_id)
.collect();
assert_eq!(
modes_a, modes_b,
"blend mode coverage drifted on round-trip"
);
}
#[test]
fn round_trip_kitchen_sink_document() {
let (canvas_w, canvas_h) = (32u32, 32u32);
let mut original = kitchen_sink_engine(canvas_w, canvas_h);
populate_kitchen_sink(&mut original);
let bundle = drive_save_to_completion(&mut original);
let zip_bytes = assemble_zip(&bundle);
let entries = extract_zip(&zip_bytes);
assert!(
entries.get("manifest.json").is_some(),
"kitchen-sink zip must contain manifest.json"
);
assert!(
entries.get("composite.png").is_some(),
"kitchen-sink zip must contain composite.png"
);
let mut reloaded = kitchen_sink_engine(1, 1);
reloaded
.open_document(&zip_bytes)
.expect("kitchen-sink reload happy path");
assert_documents_equivalent(&original.doc, &reloaded.doc);
let composite_a = original.test_readback_canvas();
let composite_b = reloaded.test_readback_canvas();
assert_eq!(
composite_a.len(),
composite_b.len(),
"composite size drifted across save/reload"
);
assert_eq!(
composite_a, composite_b,
"kitchen-sink composite bytes must match across save/reload"
);
}
#[test]
fn sub_canvas_mask_survives_save_load_round_trip() {
use crate::coord::CanvasRect;
use crate::engine::types::StrokeOp;
let mut original = kitchen_sink_engine(32, 32);
let host = original.add_raster_layer(None);
original.begin_stroke(host);
original.stroke_to(StrokeOp::FloodFill {
x: 1.0,
y: 1.0,
r: 0,
g: 0,
b: 255,
a: 255,
tolerance: 255,
});
original.end_stroke();
original.render(0.0);
original.add_mask(host);
let mask_id = original.host_mask_id(host).expect("mask");
original.begin_stroke(mask_id);
original.stroke_to(StrokeOp::BrushStroke {
x: 10.0,
y: 10.0,
pressure: 1.0,
x_tilt: 0.0,
y_tilt: 0.0,
rotation: 0.0,
tangential_pressure: 0.0,
time_ms: 0.0,
cr: 0.0,
cg: 0.0,
cb: 0.0,
ca: 1.0,
});
original.end_stroke();
original.render(0.0);
original.resize_canvas(CanvasRect::from_xywh(12, 8, 64, 48));
original.render(0.0);
let bounds_before = original.node_pixel_bounds(mask_id).expect("mask bounds");
assert!(
bounds_before.width < 64 && bounds_before.height < 48,
"test setup: the mask must be sub-canvas; got {bounds_before:?}"
);
let bundle = drive_save_to_completion(&mut original);
let zip_bytes = assemble_zip(&bundle);
let mut reloaded = kitchen_sink_engine(1, 1);
reloaded
.open_document(&zip_bytes)
.expect("sub-canvas-mask reload happy path");
let r_host = reloaded
.doc
.all_raster_layers()
.iter()
.map(|r| r.id)
.find(|id| reloaded.doc.has_mask(*id))
.expect("reloaded doc must have a masked raster");
let r_mask = reloaded.host_mask_id(r_host).expect("reloaded mask");
let bounds_after = reloaded
.node_pixel_bounds(r_mask)
.expect("reloaded mask bounds");
assert_eq!(
bounds_after, bounds_before,
"sub-canvas mask bounds must round-trip independently of the host"
);
let mask_a = original.test_readback_mask(host);
let mask_b = reloaded.test_readback_mask(r_host);
assert_eq!(
mask_a, mask_b,
"sub-canvas mask pixels must round-trip byte-for-byte across save/reload"
);
let host_a = original.test_readback_layer(host);
let host_b = reloaded.test_readback_layer(r_host);
assert_eq!(
host_a, host_b,
"masked host pixels must round-trip across save/reload"
);
}
#[test]
fn loaded_thumbnails_populate_without_a_first_edit() {
use crate::engine::DEFAULT_THUMB_SIZE;
let (canvas_w, canvas_h) = (16u32, 16u32);
let mut source = kitchen_sink_engine(canvas_w, canvas_h);
let red: Vec<u8> = (0..canvas_w * canvas_h)
.flat_map(|_| [255u8, 0, 0, 255])
.collect();
source.paste_image(canvas_w, canvas_h, &red, 0, 0, None);
let bundle = drive_save_to_completion(&mut source);
let zip_bytes = assemble_zip(&bundle);
let mut reloaded = kitchen_sink_engine(canvas_w, canvas_h);
reloaded
.open_document(&zip_bytes)
.expect("reload happy path");
for _ in 0..16 {
reloaded.test_flush_readbacks();
reloaded.render(0.0);
}
let raster_ids: Vec<crate::layer::LayerId> = reloaded
.doc
.all_raster_layers()
.iter()
.map(|r| r.id)
.collect();
assert!(
!raster_ids.is_empty(),
"reloaded doc must have raster layers"
);
let any_non_zero = raster_ids.iter().any(|id| {
let thumb = reloaded.node_thumbnail(*id, DEFAULT_THUMB_SIZE, DEFAULT_THUMB_SIZE);
thumb.iter().any(|&b| b != 0)
});
assert!(
any_non_zero,
"no loaded raster has a non-zero thumbnail — \
load path forgot to mark_node_pixels_dirty after upload"
);
}
#[test]
fn dirty_flag_cleared_by_open() {
let (canvas_w, canvas_h) = (32u32, 32u32);
let mut original = kitchen_sink_engine(canvas_w, canvas_h);
populate_kitchen_sink(&mut original);
let bundle = drive_save_to_completion(&mut original);
let zip_bytes = assemble_zip(&bundle);
let mut reloaded = kitchen_sink_engine(canvas_w, canvas_h);
let _layer = reloaded.add_raster_layer(None);
assert!(reloaded.is_dirty(), "setup must produce a dirty engine");
reloaded
.open_document(&zip_bytes)
.expect("kitchen-sink reload happy path");
assert!(
!reloaded.is_dirty(),
"successful open_document must install a clean doc"
);
}
#[test]
fn kitchen_sink_covers_every_closed_set_variant() {
let mut engine = kitchen_sink_engine(8, 8);
populate_kitchen_sink(&mut engine);
let used_blend_modes: std::collections::HashSet<&'static str> = engine
.doc
.all_raster_layers()
.iter()
.map(|r| r.blend.blend_mode.type_id)
.collect();
for reg in blend_mode::registry().all() {
assert!(
used_blend_modes.contains(reg.type_id),
"kitchen-sink missing blend_mode/{} — extend populate_kitchen_sink",
reg.type_id
);
}
let mut used_layer_kinds = std::collections::HashSet::new();
for entity in engine.doc.entities.values() {
if let crate::document::Entity::Node(node) = entity {
used_layer_kinds.insert(node.type_id());
}
}
for reg in layer_kind::registry().all() {
assert!(
used_layer_kinds.contains(reg.type_id),
"kitchen-sink missing layer_kind/{} — extend populate_kitchen_sink",
reg.type_id
);
}
let mut used_modifier_kinds = std::collections::HashSet::new();
for entity in engine.doc.entities.values() {
if let crate::document::Entity::Filter(m) = entity {
used_modifier_kinds.insert(m.type_id());
}
}
for reg in filter::registry().all() {
assert!(
used_modifier_kinds.contains(reg.type_id),
"kitchen-sink missing filter/{} — extend populate_kitchen_sink",
reg.type_id
);
}
}
use crate::format::error::LoadError;
use crate::format::manifest::{
Manifest, ManifestCanvas, ManifestEntry, ManifestRequires, ManifestWriter, CONTAINER_VERSION,
FORMAT_TAG,
};
fn synth_zip_from_manifest(manifest: &Manifest) -> Vec<u8> {
let bundle = SaveBundle {
manifest_json: serde_json::to_vec_pretty(manifest).unwrap(),
composite_width: 1,
composite_height: 1,
composite_rgba: vec![0, 0, 0, 255],
blobs: Vec::new(),
};
assemble_zip(&bundle)
}
fn root_group_body() -> serde_json::Value {
serde_json::json!({
"name": "Root",
"visible": true,
"locked": false,
"opacity": 1.0,
"blend_mode": "normal",
"passthrough": true,
"collapsed": false,
"children": [],
"modifiers": [],
})
}
fn synth_minimal_manifest() -> Manifest {
Manifest {
format: FORMAT_TAG.to_string(),
container_version: CONTAINER_VERSION,
writer: ManifestWriter::current(),
name: "Test".to_string(),
canvas: ManifestCanvas {
width: 4,
height: 4,
origin_x: 0,
origin_y: 0,
},
requires: ManifestRequires {
layer_kind: vec!["group".to_string()],
blend_mode: vec!["normal".to_string()],
..ManifestRequires::default()
},
composite: "composite.png".to_string(),
root: 1,
nodes: vec![ManifestEntry {
id: 1,
type_id: "group".to_string(),
body: root_group_body(),
}],
modifiers: Vec::new(),
selection_id: None,
veils: Vec::new(),
}
}
fn assert_engine_untouched(engine: &DarklyEngine, prior_doc_ptr: *const Document) {
assert_eq!(
engine.document_ptr_for_test(),
prior_doc_ptr,
"atomic install violated: refused load swapped the engine's doc"
);
}
#[test]
fn refuse_container_version_too_new() {
let mut engine = kitchen_sink_engine(4, 4);
let prior = engine.document_ptr_for_test();
let mut manifest = synth_minimal_manifest();
manifest.container_version = 999;
let bytes = synth_zip_from_manifest(&manifest);
let err = engine.open_document(&bytes).expect_err("must refuse");
match err {
LoadError::ContainerTooNew { found, supported } => {
assert_eq!(found, 999);
assert_eq!(supported, CONTAINER_VERSION);
}
other => panic!("expected ContainerTooNew, got {other:?}"),
}
assert_engine_untouched(&engine, prior);
}
#[test]
fn refuse_missing_requires() {
let mut engine = kitchen_sink_engine(4, 4);
let prior = engine.document_ptr_for_test();
let manifest = synth_minimal_manifest();
let mut raw: serde_json::Value = serde_json::to_value(&manifest).unwrap();
raw.as_object_mut().unwrap().remove("requires");
let manifest_bytes = serde_json::to_vec(&raw).unwrap();
let bundle = SaveBundle {
manifest_json: manifest_bytes,
composite_width: 1,
composite_height: 1,
composite_rgba: vec![0, 0, 0, 255],
blobs: Vec::new(),
};
let bytes = assemble_zip(&bundle);
let err = engine.open_document(&bytes).expect_err("must refuse");
match err {
LoadError::CorruptManifest { reason } => {
assert!(
reason.contains("requires"),
"missing-requires diagnostic should name the requires block, got {reason:?}"
);
}
other => panic!("expected CorruptManifest, got {other:?}"),
}
assert_engine_untouched(&engine, prior);
}
#[test]
fn refuse_unknown_veil() {
let mut engine = kitchen_sink_engine(4, 4);
let prior = engine.document_ptr_for_test();
let mut manifest = synth_minimal_manifest();
manifest.requires.veil = vec!["future_lens_flare".to_string()];
let bytes = synth_zip_from_manifest(&manifest);
let err = engine.open_document(&bytes).expect_err("must refuse");
match err {
LoadError::UnsupportedFeatures { missing } => {
assert!(
missing.iter().any(|m| m == "veil/future_lens_flare"),
"diagnostic should name veil/future_lens_flare, got {missing:?}"
);
}
other => panic!("expected UnsupportedFeatures, got {other:?}"),
}
assert_engine_untouched(&engine, prior);
}
#[test]
fn refuse_unknown_blend_mode() {
let mut engine = kitchen_sink_engine(4, 4);
let prior = engine.document_ptr_for_test();
let mut manifest = synth_minimal_manifest();
manifest.requires.blend_mode.push("divide_v2".to_string());
let bytes = synth_zip_from_manifest(&manifest);
let err = engine.open_document(&bytes).expect_err("must refuse");
match err {
LoadError::UnsupportedFeatures { missing } => {
assert!(
missing.iter().any(|m| m == "blend_mode/divide_v2"),
"diagnostic should name blend_mode/divide_v2, got {missing:?}"
);
}
other => panic!("expected UnsupportedFeatures, got {other:?}"),
}
assert_engine_untouched(&engine, prior);
}
#[test]
fn refuse_unknown_layer_kind() {
let mut engine = kitchen_sink_engine(4, 4);
let prior = engine.document_ptr_for_test();
let mut manifest = synth_minimal_manifest();
manifest.requires.layer_kind.push("text_layer".to_string());
let bytes = synth_zip_from_manifest(&manifest);
let err = engine.open_document(&bytes).expect_err("must refuse");
match err {
LoadError::UnsupportedFeatures { missing } => {
assert!(
missing.iter().any(|m| m == "layer_kind/text_layer"),
"diagnostic should name layer_kind/text_layer, got {missing:?}"
);
}
other => panic!("expected UnsupportedFeatures, got {other:?}"),
}
assert_engine_untouched(&engine, prior);
}
#[test]
fn refuse_unknown_modifier_kind() {
let mut engine = kitchen_sink_engine(4, 4);
let prior = engine.document_ptr_for_test();
let mut manifest = synth_minimal_manifest();
manifest.requires.modifier.push("clip".to_string());
let bytes = synth_zip_from_manifest(&manifest);
let err = engine.open_document(&bytes).expect_err("must refuse");
match err {
LoadError::UnsupportedFeatures { missing } => {
assert!(
missing.iter().any(|m| m == "filter/clip"),
"diagnostic should name filter/clip, got {missing:?}"
);
}
other => panic!("expected UnsupportedFeatures, got {other:?}"),
}
assert_engine_untouched(&engine, prior);
}
#[test]
fn refuse_corrupt_manifest_when_requires_lies() {
let mut engine = kitchen_sink_engine(4, 4);
let prior = engine.document_ptr_for_test();
let mut manifest = synth_minimal_manifest();
manifest.requires.layer_kind = vec!["group".to_string(), "raster".to_string()];
manifest.nodes.push(ManifestEntry {
id: 42,
type_id: "raster".to_string(),
body: serde_json::json!({
"name": "lying",
"visible": true,
"locked": false,
"opacity": 1.0,
"blend_mode": "divide_v2", "pixels": {
"format": "rgba8unorm",
"pixels": "layers/42.pixels",
"bounds": { "origin": { "x": 0, "y": 0 }, "width": 4, "height": 4 }
},
"modifiers": []
}),
});
let bytes = synth_zip_from_manifest(&manifest);
let err = engine.open_document(&bytes).expect_err("must refuse");
match err {
LoadError::CorruptManifest { reason } => {
assert!(
reason.contains("divide_v2"),
"diagnostic should name the lying type_id, got {reason:?}"
);
}
other => panic!("expected CorruptManifest, got {other:?}"),
}
assert_engine_untouched(&engine, prior);
}
#[test]
fn open_document_leaves_engine_untouched_on_refuse() {
let mut engine = kitchen_sink_engine(4, 4);
let _layer = engine.add_raster_layer(None);
engine.set_document_name("pre-load".to_string());
let prior = engine.document_ptr_for_test();
let prior_layer_count = engine.doc.all_raster_layers().len();
let prior_name = engine.doc.name.clone();
let mut manifest = synth_minimal_manifest();
manifest.requires.veil.push("future_lens_flare".to_string());
let bytes = synth_zip_from_manifest(&manifest);
let _err = engine.open_document(&bytes).expect_err("must refuse");
assert_engine_untouched(&engine, prior);
assert_eq!(engine.doc.all_raster_layers().len(), prior_layer_count);
assert_eq!(engine.doc.name, prior_name);
}
#[test]
fn legacy_type_id_migration() {
let registry = VeilRegistry::new();
for (type_id, _name, _params) in registry.types() {
assert!(
registry.has(type_id),
"legacy migration scaffold: registry must resolve every registered \
type_id back through itself — drift suggests the dispatch surface \
a future migration would plug into has changed"
);
}
}
#[test]
fn manifest_entry_bodies_are_opaque_to_central_code() {
let mut engine = kitchen_sink_engine(8, 8);
let _layer = engine.add_raster_layer(None);
let bundle = drive_save_to_completion(&mut engine);
let manifest: Manifest = serde_json::from_slice(&bundle.manifest_json).unwrap();
let raster_entry = manifest
.nodes
.iter()
.find(|e| e.type_id == crate::document::layer_kinds::raster::TYPE_ID)
.expect("at least one raster in kitchen sink");
let mut tampered = manifest.clone();
if let Some(idx) = tampered.nodes.iter().position(|e| e.id == raster_entry.id) {
let body_mut = &mut tampered.nodes[idx].body;
if let Some(obj) = body_mut.as_object_mut() {
obj.insert(
"future_kind_only_field".to_string(),
serde_json::Value::String("ignored by raster".to_string()),
);
}
}
let tampered_bytes = synth_zip_from_manifest(&tampered);
let mut reloaded = kitchen_sink_engine(1, 1);
reloaded
.open_document(&tampered_bytes)
.expect("unknown body fields must not break central code");
assert_eq!(reloaded.doc.all_raster_layers().len(), 1);
}