Files
effectcraft/crates/ui-egui/tests/it/ui_layer_source_picker.rs
T
skylartaylor 9ee853715d M0.37: migrate EffectCraft to egui 0.37
Reconcile main 991904888a and preserve the reviewed lifecycle, IME, rendering and lockfile changes.

Formatting, strict lint, layers, assets and WASM passed. Full CI retained 3,961 passes, 14 baseline-matching GPU failures and 31 ignored executions. The local review commit has explicit approval to retain these documented failures; native/browser acceptance and paired app performance remain open.
2026-10-25 02:01:16 +01:00

216 lines
11 KiB
Rust

//! Picking a layer-valued property with the shared drop-down: the Timeline draws a layer-valued
//! Light Options property (an Environment light's Source) as a drop-down of the comp's layers,
//! Effect Controls' layer parameters (a Displacement Map's layer) pick through the same widget,
//! and both write an undoable `prop.set`.
use effectcraft_engine::Session;
use effectcraft_engine::keyframe::Value;
use effectcraft_engine::project::LayerId;
use effectcraft_ui_egui::EffectcraftApp;
use effectcraft_ui_egui::dock::PanelKind;
use egui::{Pos2, Rect, pos2, vec2};
use egui_kittest::Harness;
use egui_kittest::kittest::NodeT;
use egui_kittest::kittest::Queryable;
use serde_json::json;
fn click(h: &mut Harness<'_, EffectcraftApp>, at: Pos2) {
h.event(egui::Event::PointerMoved(at));
h.step();
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
h.step();
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
h.run_steps(3);
}
fn rect_of(h: &Harness<'_, EffectcraftApp>, id: &str) -> Rect {
let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no automation element {id}")).clone();
Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3]))
}
/// Whether `want` is painted inside `row` (the drop-down's current-value text).
fn painted_in(h: &Harness<'_, EffectcraftApp>, row: Rect, want: &str) -> bool {
h.output().shapes.iter().any(|c| match &c.shape {
egui::Shape::Text(t) => t.galley.text() == want && row.expand(3.0).contains(t.pos),
_ => false,
})
}
/// The current value of a layer-valued property, by uid.
fn layer_value(h: &Harness<'_, EffectcraftApp>, layer: u64, prop: u64) -> Option<u64> {
let comp = h.state().session.active_comp().unwrap();
match &comp.layer(LayerId(layer)).unwrap().props.find(prop).unwrap().value {
Value::Layer(v) => *v,
other => panic!("property {prop} is not layer-valued: {other:?}"),
}
}
/// The "N. name" a drop-down shows for a layer.
fn entry_for(h: &Harness<'_, EffectcraftApp>, layer: u64) -> String {
let comp = h.state().session.active_comp().unwrap();
let i = comp.layers.iter().position(|l| l.id.0 == layer).unwrap();
format!("{}. {}", i + 1, comp.layers[i].name)
}
/// Puts the layer-valued `prop` of `layer` back to `value` with a direct `prop.set` (no merge key,
/// so the pick below records its own step instead of folding into a picker write), picks `none`
/// from the drop-down at `value_id`, and checks the clear takes one undo step that restores `value`.
#[track_caller]
fn none_pick_clears(h: &mut Harness<'_, EffectcraftApp>, value_id: &str, none: &str, layer: u64, prop: u64, value: u64) {
h.state_mut().session.execute("prop.set", json!({"layer": layer, "prop": prop, "value": value})).unwrap();
h.run_steps(2);
assert_eq!(layer_value(h, layer, prop), Some(value));
let steps = h.state().session.history.undo.len();
let at = rect_of(h, value_id).center();
click(h, at);
h.get_by_role_and_label(egui::accesskit::Role::Button, none).click();
h.run_steps(3);
assert_eq!(layer_value(h, layer, prop), None, "selecting None clears the value");
assert_eq!(h.state().session.history.undo.len(), steps + 1, "one clear undo step");
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
h.run_steps(2);
assert_eq!(layer_value(h, layer, prop), Some(value), "undo restores the selected layer");
}
/// A light's Source (a layer-valued Light Options property) picks a comp layer from the
/// Timeline and undo restores None.
#[test]
fn timeline_source_drop_down_picks_the_layer_and_undo_restores_none() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Main", "width": 640, "height": 360, "frameRate": 30, "duration": 4})).unwrap();
s.execute("comp.settings", json!({"renderer": "advanced3D"})).unwrap();
let sky = s.execute("layer.newSolid", json!({"name": "Sky HDRI", "color": "#66aaff"})).unwrap()["layer"].as_u64().unwrap();
let light = s.execute("layer.newLight", json!({"kind": "Environment", "name": "Env Light"})).unwrap()["layer"].as_u64().unwrap();
let (group, source) = {
let comp = s.active_comp().unwrap();
let opts = comp.layer(LayerId(light)).unwrap().props.sub("lightOptions").unwrap();
(opts.uid, opts.get("source").unwrap().uid)
};
let mut app = EffectcraftApp::new(s);
app.ui.timeline.open_layers.insert(light);
app.ui.timeline.open_groups.insert(group);
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| app);
h.run_steps(4);
let value_id = format!("timeline.prop.{source}.value");
// Unset: the row is a drop-down reading "None" — an element to click, not bare text.
assert_eq!(layer_value(&h, light, source), None);
assert!(painted_in(&h, rect_of(&h, &value_id), "None"), "the Source row shows a None drop-down");
// Opening it lists "None" and every layer of the comp, by index and name.
let at = rect_of(&h, &value_id).center();
click(&mut h, at);
let entry = entry_for(&h, sky);
assert!(h.query_by_role_and_label(egui::accesskit::Role::Button, "None").is_some(), "the list starts with None");
assert!(h.query_by_role_and_label(egui::accesskit::Role::Button, &entry).is_some(), "the comp's layers are listed ({entry})");
let steps = h.state().session.history.undo.len();
h.get_by_role_and_label(egui::accesskit::Role::Button, &entry).click();
h.run_steps(3);
assert_eq!(layer_value(&h, light, source), Some(sky), "the Source holds the layer id");
assert_eq!(h.state().session.history.undo.len(), steps + 1, "one undo step");
// The row names the layer (index and name), not its internal id.
assert!(painted_in(&h, rect_of(&h, &value_id), &entry), "the Source row shows the layer's name");
// Dismissing the list without choosing writes nothing; reopening marks the chosen layer as
// the current entry (and "None" as not current).
click(&mut h, pos2(1500.0, 300.0));
h.run_steps(2);
assert_eq!(layer_value(&h, light, source), Some(sky), "dismissing the list writes nothing");
let reopen = rect_of(&h, &value_id).center();
click(&mut h, reopen);
assert_eq!(
h.get_by_role_and_label(egui::accesskit::Role::Button, &entry).accesskit_node().toggled(),
Some(egui::accesskit::Toggled::True),
"the current entry is highlighted"
);
assert_eq!(
h.get_by_role_and_label(egui::accesskit::Role::Button, "None").accesskit_node().toggled(),
Some(egui::accesskit::Toggled::False),
"None is not the current entry"
);
click(&mut h, pos2(1500.0, 300.0));
h.run_steps(2);
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
h.run_steps(2);
assert_eq!(layer_value(&h, light, source), None, "undo restores None");
none_pick_clears(&mut h, &value_id, "None", light, source, sky);
}
/// The shared widget is the one Effect Controls already used: a Displacement Map's layer
/// parameter still picks from the drop-down and still undoes.
#[test]
fn effect_controls_layer_param_picks_through_the_shared_widget() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Main", "width": 640, "height": 360, "frameRate": 30, "duration": 4})).unwrap();
let map = s.execute("layer.newSolid", json!({"name": "Disp", "color": "#ff0000"})).unwrap()["layer"].as_u64().unwrap();
let base = s.execute("layer.newSolid", json!({"name": "Base", "color": "#20c040"})).unwrap()["layer"].as_u64().unwrap();
let fx = s.execute("effect.apply", json!({"layers": [base], "effect": "Displacement Map"})).unwrap()["effects"][0].as_u64().unwrap();
let prop = {
let comp = s.active_comp().unwrap();
comp.layer(LayerId(base)).unwrap().effects().unwrap().find_group(fx).unwrap().get("displacementMapLayer").unwrap().uid
};
s.state.selected_layers = vec![LayerId(base)];
let mut app = EffectcraftApp::new(s);
app.show_panel(PanelKind::EffectControls);
let mut h = Harness::builder().with_size(vec2(1600.0, 1100.0)).build_eframe(|_| app);
h.run_steps(4);
let value_id = format!("effectControls.prop.{prop}.value");
let row = rect_of(&h, &value_id);
click(&mut h, row.center());
let entry = entry_for(&h, map);
assert!(h.query_by_role_and_label(egui::accesskit::Role::Button, &entry).is_some(), "the comp's layers are listed ({entry})");
let steps = h.state().session.history.undo.len();
h.get_by_role_and_label(egui::accesskit::Role::Button, &entry).click();
h.run_steps(3);
assert_eq!(layer_value(&h, base, prop), Some(map), "the parameter holds the layer id");
assert_eq!(h.state().session.history.undo.len(), steps + 1, "one undo step");
// The row names the picked layer, as the Timeline's Source row does.
assert!(painted_in(&h, rect_of(&h, &value_id), &entry), "the parameter row shows the layer's name");
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
h.run_steps(2);
assert_eq!(layer_value(&h, base, prop), None, "undo restores None");
none_pick_clears(&mut h, &value_id, "None", base, prop, map);
}
/// The visual record (the `ui_3d` ignored-snapshot convention): the Source drop-down open over
/// the layer list, and the row after a layer is chosen. Renders with wgpu when `EC_SNAPSHOT_DIR`
/// is set: `EC_SNAPSHOT_DIR=shots cargo test -p effectcraft-ui-egui --test it ui_layer_source_picker:: -- --ignored`.
#[test]
#[ignore = "renders with wgpu; run with --ignored (set EC_SNAPSHOT_DIR to keep PNGs)"]
fn snapshot_source_dropdown_open_and_chosen() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Main", "width": 1280, "height": 720, "frameRate": 30, "duration": 5})).unwrap();
s.execute("comp.settings", json!({"renderer": "advanced3D"})).unwrap();
let sky = s.execute("layer.newSolid", json!({"name": "Sky HDRI", "color": "#66aaff"})).unwrap()["layer"].as_u64().unwrap();
let light = s.execute("layer.newLight", json!({"kind": "Environment", "name": "Env Light"})).unwrap()["layer"].as_u64().unwrap();
let (group, source) = {
let comp = s.active_comp().unwrap();
let opts = comp.layer(LayerId(light)).unwrap().props.sub("lightOptions").unwrap();
(opts.uid, opts.get("source").unwrap().uid)
};
let mut app = EffectcraftApp::new(s);
app.ui.timeline.open_layers.insert(light);
app.ui.timeline.open_groups.insert(group);
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).with_pixels_per_point(2.0).wgpu().build_eframe(|_| app);
h.run_steps(4);
let save = |h: &mut Harness<'_, EffectcraftApp>, name: &str| {
if let Ok(dir) = std::env::var("EC_SNAPSHOT_DIR") {
h.render().expect("render").save(format!("{dir}/{name}")).expect("save");
}
};
save(&mut h, "source_unset.png");
let at = rect_of(&h, &format!("timeline.prop.{source}.value")).center();
click(&mut h, at);
save(&mut h, "source_dropdown_open.png");
let entry = entry_for(&h, sky);
h.get_by_role_and_label(egui::accesskit::Role::Button, &entry).click();
h.run_steps(3);
assert_eq!(layer_value(&h, light, source), Some(sky));
save(&mut h, "source_chosen.png");
}