Files
effectcraft/crates/ui-egui/tests/it/ui_layer_source_picker.rs

208 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_label(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_label("None").is_some(), "the list starts with None");
assert!(h.query_by_label(&entry).is_some(), "the comp's layers are listed ({entry})");
let steps = h.state().session.history.undo.len();
h.get_by_label(&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_label(&entry).accesskit_node().toggled(), Some(egui::accesskit::Toggled::True), "the current entry is highlighted");
assert_eq!(h.get_by_label("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_label(&entry).is_some(), "the comp's layers are listed ({entry})");
let steps = h.state().session.history.undo.len();
h.get_by_label(&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_label(&entry).click();
h.run_steps(3);
assert_eq!(layer_value(&h, light, source), Some(sky));
save(&mut h, "source_chosen.png");
}