Files
figcraft/crates/engine/src/effects_tests.rs
T
MortenHustedandClaude Opus 5.5 6f90717e71 M3: effects refuse keys they don't read instead of dropping them
node.set refuses unknown properties, but the effects inside it were read
by serde, which drops keys it doesn't know. A drop shadow given
offsetX/offsetY (the names variable bindings use for its offset) was
stored with the default offset [0, 4] and the call said ok. effect.add
and effect.set dropped them the same way.

The three now refuse what the effect doesn't read and say what it
takes: "effects[0]: unknown properties: offsetX, offsetY (a dropShadow
takes blend, color, offset, radius, showBehind, spread, type, visible)".
A key counts as read when setting it to null makes the effect fail to
parse or come out different, so serde's aliases count as read without a
list of them here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 23:53:05 +02:00

135 lines
7.0 KiB
Rust

//! Effects through the session: adding every kind by name, editing noise/texture/glass, scaling,
//! inspecting and generated CSS.
use serde_json::json;
use crate::Session;
use crate::codegen::{CodeLang, code_for};
use crate::params::id_of;
use figcraft_doc::{Effect, EffectKind, Glass, NoiseMode};
fn rect(s: &mut Session) -> figcraft_doc::NodeId {
id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 60})).unwrap()["id"]).unwrap()
}
#[test]
fn effect_add_takes_every_type() {
let mut s = Session::new();
let r = rect(&mut s);
for (_, ty) in Effect::TYPES {
s.execute("effect.add", &json!({"ids": [r], "type": ty})).unwrap();
}
s.execute("effect.add", &json!({"ids": [r], "type": "GLASS"})).unwrap();
let n = s.doc().unwrap().node(r).unwrap();
let labels: Vec<&str> = n.effects.iter().map(Effect::label).collect();
assert_eq!(labels, vec!["Drop shadow", "Inner shadow", "Layer blur", "Background blur", "Noise", "Texture", "Glass", "Glass"]);
let err = s.execute("effect.add", &json!({"ids": [r], "type": "sparkle"})).unwrap_err();
assert!(err.to_string().contains("sparkle"), "{err}");
// Undo removes one at a time.
s.undo().unwrap();
assert_eq!(s.doc().unwrap().node(r).unwrap().effects.len(), 7);
}
#[test]
fn effect_set_edits_noise_texture_and_glass() {
let mut s = Session::new();
let r = rect(&mut s);
s.execute(
"effect.add",
&json!({"ids": [r], "effect": {"type": "noise", "mode": "duo", "size": 2, "density": 0.4, "secondary": {"r": 1, "g": 1, "b": 1, "a": 1}}}),
)
.unwrap();
s.execute("effect.add", &json!({"ids": [r], "effect": {"type": "texture", "radius": 6, "clipToShape": true}})).unwrap();
s.execute("effect.set", &json!({"ids": [r], "index": 1, "effect": {"type": "glass", "refraction": 0.3, "frost": 10, "splay": 0.5}})).unwrap();
let n = s.doc().unwrap().node(r).unwrap();
let EffectKind::Noise(nz) = &n.effects[0].kind else { panic!("{:?}", n.effects) };
assert_eq!((nz.mode, nz.size, nz.density), (NoiseMode::Duo, 2.0, 0.4));
let EffectKind::Glass(g) = &n.effects[1].kind else { panic!("{:?}", n.effects) };
assert_eq!((g.refraction, g.frost, g.splay, g.depth), (0.3, 10.0, 0.5, Glass::default().depth));
// Junk is refused, not stored.
assert!(s.execute("effect.set", &json!({"ids": [r], "index": 0, "effect": {"type": "noise", "mode": "rainbow"}})).is_err());
assert!(s.execute("effect.set", &json!({"ids": [r], "index": 0, "effect": {"type": "glass", "depth": "deep"}})).is_err());
}
/// A key an effect doesn't have is refused, naming what it takes: serde dropped it without a word,
/// so a shadow given `offsetX`/`offsetY` (the names variable bindings use) kept the default offset.
#[test]
fn effects_refuse_keys_they_dont_have() {
let mut s = Session::new();
let r = rect(&mut s);
let shadow = json!({"type": "dropShadow", "color": "#00000040", "offsetX": 0, "offsetY": 1, "radius": 3});
let err = s.execute("node.set", &json!({"ids": [r], "props": {"effects": [shadow]}})).unwrap_err().to_string();
assert!(err.contains("effects[0]") && err.contains("offsetX, offsetY") && err.contains("offset"), "{err}");
assert!(s.doc().unwrap().node(r).unwrap().effects.is_empty(), "nothing stored");
let err = s.execute("effect.add", &json!({"ids": [r], "effect": {"type": "layerBlur", "radious": 8}})).unwrap_err().to_string();
assert!(err.contains("radious"), "{err}");
s.execute("effect.add", &json!({"ids": [r], "type": "dropShadow"})).unwrap();
let err = s.execute("effect.set", &json!({"ids": [r], "index": 0, "effect": {"type": "dropShadow", "spred": 2}})).unwrap_err().to_string();
assert!(err.contains("spred"), "{err}");
// Every kind's own keys still go in, camelCase or snake_case, and so does what inspect reports.
for ty in ["innerShadow", "layerBlur", "backgroundBlur", "noise", "texture", "glass"] {
s.execute("effect.add", &json!({"ids": [r], "type": ty})).unwrap();
}
let reported = crate::inspect::node_json(s.doc().unwrap(), r, 0)["effects"].clone();
s.execute("node.set", &json!({"ids": [r], "props": {"effects": reported}})).unwrap();
let camel = json!({"type": "dropShadow", "offset": [0, 1], "showBehind": true, "visible": true});
let snake = json!({"type": "innerShadow", "offset": [0, 1], "show_behind": false});
s.execute("node.set", &json!({"ids": [r], "props": {"effects": [camel, snake]}})).unwrap();
}
#[test]
fn scaling_scales_grain_and_glass() {
let mut s = Session::new();
let r = rect(&mut s);
for ty in ["noise", "texture", "glass"] {
s.execute("effect.add", &json!({"ids": [r], "type": ty})).unwrap();
}
s.execute("transform.scale", &json!({"ids": [r], "factor": 2})).unwrap();
let n = s.doc().unwrap().node(r).unwrap();
let EffectKind::Noise(nz) = &n.effects[0].kind else { panic!() };
let EffectKind::Texture(t) = &n.effects[1].kind else { panic!() };
let EffectKind::Glass(g) = &n.effects[2].kind else { panic!() };
let d = Glass::default();
assert_eq!((nz.size, t.size, t.radius, g.depth, g.frost), (1.0, 1.0, 8.0, d.depth * 2.0, d.frost * 2.0));
// Unitless values stay.
assert_eq!((g.refraction, nz.density), (d.refraction, 0.5));
}
#[test]
fn inspect_and_css_show_the_new_effects() {
let mut s = Session::new();
let r = rect(&mut s);
for ty in ["noise", "texture", "glass"] {
s.execute("effect.add", &json!({"ids": [r], "type": ty})).unwrap();
}
let doc = s.doc().unwrap();
let sections = crate::devinspect::properties(doc, r);
let fx = sections.iter().find(|s| s.title == "Effects").unwrap();
let labels: Vec<&str> = fx.rows.iter().map(|p| p.label.as_str()).collect();
assert_eq!(labels, vec!["Noise", "Texture", "Glass"]);
assert!(fx.rows[2].value.contains("refraction 80%"), "{}", fx.rows[2].value);
let css = code_for(doc, r, CodeLang::Css);
assert!(css.contains("backdrop-filter: blur(2px);"), "{css}");
}
#[test]
fn negative_or_fractional_index_is_refused_not_item_zero() {
let mut s = Session::new();
let r = rect(&mut s);
s.execute("effect.add", &json!({"ids": [r], "type": "layerBlur"})).unwrap();
s.execute("export.add", &json!({"ids": [r]})).unwrap();
s.execute("export.add", &json!({"ids": [r]})).unwrap();
let before = s.doc().unwrap().node(r).unwrap().clone();
assert_eq!(before.exports.len(), 2);
for index in [json!(-1), json!(-0.5), json!(1.5)] {
let set = json!({"ids": [r], "index": index, "effect": {"type": "dropShadow"}});
assert!(s.execute("effect.set", &set).is_err(), "effect.set index {index}");
let upd = json!({"ids": [r], "index": index, "format": "svg"});
assert!(s.execute("export.update", &upd).is_err(), "export.update index {index}");
assert!(s.execute("export.remove", &json!({"ids": [r], "index": index})).is_err(), "export.remove index {index}");
let n = s.doc().unwrap().node(r).unwrap();
assert_eq!(n.effects, before.effects, "index {index}");
assert_eq!(n.exports, before.exports, "index {index}");
}
}