Files
effectcraft/crates/plugin/src/tests.rs
T
Brandon ThomasandClaude Opus 5.5 89c9b1b6ee M13.1: branching history, puppet pin recording, WebAssembly plug-in API, ScriptUI model and hosts, File ▸ Scripts, deterministic render test
- engine: History keeps undone lines as branches; edit.history.list/goto; History panel tree; MCP history tool
- puppet.recordPin / puppet.recordOptions; viewer Cmd-drag records; Record Options form
- effects::plugin (EffectPlugin trait, register_plugin, API v1); effectcraft-plugin (wasmi, sandboxed, fuel-limited); effect.plugins.load/list
- ScriptUI: engine scriptui model + layout + scriptui.* commands; script crate scriptui.js and script-host threads (dialogs block show())
- File ▸ Scripts: install script / ScriptUI panel, sample scripts, window.scriptPanel
- host test: bit-identical renders across runs and thread counts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 04:07:20 -04:00

165 lines
6.1 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! WebAssembly plug-ins: load, register, render through the effect registry, sandbox limits.
use effectcraft_effects::{Buf, EffectCtx, EffectEnv, Image};
use effectcraft_project::build::Ids;
use crate::load_wasm;
fn load_err(r: Result<&'static effectcraft_effects::EffectSpec, String>) -> String {
match r {
Err(e) => e,
Ok(s) => panic!("unexpectedly loaded {}", s.id),
}
}
/// A plug-in in WebAssembly text: multiplies RGB by `gain`, adds `lift` × `tint` when `on`.
fn gain_module(id: &str, render_body: &str) -> String {
let manifest = format!(
r#"{{"api":1,"id":"{id}","name":"Gain {id}","category":"Test Plug-ins","version":"1.0","params":[{{"id":"gain","name":"Gain","type":"slider","default":0.5,"min":0,"max":4}},{{"id":"on","name":"Lift","type":"checkbox","default":false}},{{"id":"tint","name":"Tint","type":"color","default":[0,0,1,1]}}]}}"#
);
let len = manifest.len();
let escaped = manifest.replace('\\', "\\\\").replace('"', "\\\"");
format!(
r#"(module
(memory (export "memory") 2)
(data (i32.const 16) "{escaped}")
(func (export "ec_api_version") (result i32) i32.const 1)
(func (export "ec_manifest_ptr") (result i32) i32.const 16)
(func (export "ec_manifest_len") (result i32) i32.const {len})
(func (export "ec_alloc") (param $n i32) (result i32)
(local $need i32)
local.get $n
i32.const 131071
i32.add
i32.const 16
i32.shr_u
memory.size
i32.sub
local.tee $need
i32.const 0
i32.gt_s
if
local.get $need
memory.grow
drop
end
i32.const 65536)
(func (export "ec_render") (param $px i32) (param $w i32) (param $h i32) (param $params i32) (param $np i32) (param $t f64) (param $scale f64) (result i32)
(local $i i32) (local $end i32) (local $g f32) (local $on f64)
{render_body}
local.get $params
f64.load
f32.demote_f64
local.set $g
local.get $params
f64.load offset=8
local.set $on
local.get $px
local.get $w
local.get $h
i32.mul
i32.const 16
i32.mul
i32.add
local.set $end
local.get $px
local.set $i
block $done
loop $l
local.get $i
local.get $end
i32.ge_u
br_if $done
local.get $i
local.get $i
f32.load
local.get $g
f32.mul
f32.store
local.get $i
local.get $i
f32.load offset=4
local.get $g
f32.mul
f32.store offset=4
local.get $i
local.get $i
f32.load offset=8
local.get $g
f32.mul
local.get $on
local.get $params
f64.load offset=32
f64.mul
f32.demote_f64
f32.add
f32.store offset=8
local.get $i
i32.const 16
i32.add
local.set $i
br $l
end
end
i32.const 0))"#
)
}
fn render(spec: &effectcraft_effects::EffectSpec, gain: f64, on: bool) -> Image {
let mut next = 1;
let g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [4.0, 2.0]);
let mut params = effectcraft_effects::flatten_params(&g, &mut |p| p.value.clone());
params.values.insert("gain".into(), effectcraft_keyframe::Value::Scalar(gain));
params.values.insert("on".into(), effectcraft_keyframe::Value::Bool(on));
let ctx = EffectCtx { params: &params, time: 0.0, layer_size: [4.0, 2.0], seed: 1, adjustment: false, env: EffectEnv::default() };
let buf = Buf { img: Image::filled(4, 2, [0.8, 0.6, 0.2, 1.0]), offset: [0.0; 2], scale: 1.0 };
(spec.render)(&ctx, buf).img
}
#[test]
fn wasm_plugin_registers_and_renders() {
let spec = load_wasm(gain_module("org.test.gain", "").as_bytes(), "gain.wat").unwrap();
assert_eq!(spec.id, "org.test.gain");
assert_eq!(spec.category, "Test Plug-ins");
assert_eq!(spec.params.len(), 3);
// Found like a built-in, listed under its own category.
assert!(effectcraft_effects::find("org.test.gain").is_some());
assert!(effectcraft_effects::lookup("Gain org.test.gain").is_some());
assert!(effectcraft_effects::all().iter().any(|e| e.id == "org.test.gain"));
assert!(effectcraft_effects::categories().contains(&"Test Plug-ins"));
let img = render(spec, 0.5, false);
assert_eq!(img.get(1, 1), [0.4, 0.3, 0.1, 1.0]);
// The checkbox and colour parameters arrive flattened (tint blue = 1 → +1 on blue).
let img = render(spec, 1.0, true);
assert_eq!(img.get(3, 0), [0.8, 0.6, 1.2, 1.0]);
// Deterministic, and safe to run on many threads at once (each takes an instance).
let a: Vec<Image> = (0..8).map(|_| render(spec, 0.25, false)).collect();
std::thread::scope(|sc| {
let hs: Vec<_> = (0..4).map(|_| sc.spawn(|| render(spec, 0.25, false))).collect();
for h in hs {
assert_eq!(h.join().unwrap(), a[0]);
}
});
assert!(a.iter().all(|i| *i == a[0]));
// The same id can't be registered twice.
assert!(load_err(load_wasm(gain_module("org.test.gain", "").as_bytes(), "again.wat")).contains("already registered"));
}
#[test]
fn wasm_plugins_are_sandboxed() {
// A runaway loop runs out of fuel: the frame comes back unchanged instead of hanging.
let spec = load_wasm(gain_module("org.test.spin", "(loop $spin br $spin)").as_bytes(), "spin.wat").unwrap();
let img = render(spec, 0.5, false);
assert_eq!(img.get(0, 0), [0.8, 0.6, 0.2, 1.0]);
// No imports (files, clock, network…).
let imports = r#"(module (import "env" "now" (func $now (result f64))) (memory (export "memory") 1))"#;
assert!(load_err(load_wasm(imports.as_bytes(), "imports.wat")).contains("import"));
// Garbage, wrong API version, bad manifests.
assert!(load_wasm(b"\0asm nope", "bad.wasm").is_err());
let v2 = gain_module("org.test.v2", "")
.replace("(func (export \"ec_api_version\") (result i32) i32.const 1)", "(func (export \"ec_api_version\") (result i32) i32.const 2)");
assert!(load_err(load_wasm(v2.as_bytes(), "v2.wat")).contains("API 2"));
let reserved = gain_module("ec.blur.mine", "");
assert!(load_err(load_wasm(reserved.as_bytes(), "reserved.wat")).contains("reserved"));
}