effectcraft/crates/ui-egui/tests/ui_agent_audit.rs

173 lines
7.5 KiB
Rust

//! The agent-interface audit (UI side, M13.14):
//!
//! - every registered engine command is reachable over the control channel (`engine.commands`
//! lists it; `engine.execute` routes it: an unknown parameter is rejected by that command);
//! - every panel kind, opened headless, registers an automation id for every interactive widget
//! it draws: egui's interactive widget rects of the frame are matched against the registered
//! automation elements, and any widget no element covers is a gap.
use effectcraft_engine::Session;
use effectcraft_ui_egui::EffectcraftApp;
use effectcraft_ui_egui::control::{self, ControlRequest, Outcome};
use effectcraft_ui_egui::dock::PanelKind;
use egui::Rect;
use egui_kittest::Harness;
use serde_json::{Value, json};
fn session() -> Session {
let mut s = Session::default();
s.execute("file.openDemoProject", json!({})).unwrap();
s
}
fn harness(s: Session) -> Harness<'static, EffectcraftApp> {
let mut h = Harness::builder().with_size(egui::vec2(1680.0, 1020.0)).build_eframe(|_| EffectcraftApp::new(s));
h.run_steps(3);
h
}
fn send(h: &mut Harness<'_, EffectcraftApp>, method: &str, params: Value) -> Value {
let ctx = h.ctx.clone();
let (req, _rx) = ControlRequest::new(method, params);
match control::handle(h.state_mut(), &ctx, &req) {
Outcome::Done(v) => v,
_ => panic!("{method}: no immediate reply"),
}
}
#[test]
fn every_engine_command_is_reachable_over_the_control_channel() {
let mut h = harness(session());
let listed = send(&mut h, "engine.commands", json!({}));
let ids: std::collections::HashSet<String> = listed["result"].as_array().unwrap().iter().map(|c| c["id"].as_str().unwrap().to_string()).collect();
for spec in effectcraft_engine::command_specs() {
assert!(ids.contains(spec.id), "engine.commands misses {}", spec.id);
let v = send(&mut h, "engine.execute", json!({"command": spec.id, "params": {"__audit": 1}}));
let e = v["error"].as_str().unwrap_or_default();
assert!(v["ok"] == false && e.contains("unknown parameter") && e.contains(spec.id), "{}: {v}", spec.id);
}
}
/// Interactive widgets (click or drag sense, enabled, visible) of the last frame, in screen
/// points, with their accessibility label when egui knows one.
fn interactive(ctx: &egui::Context) -> Vec<(Rect, String)> {
let screen = ctx.content_rect();
ctx.viewport(|v| {
let w = &v.this_pass.widgets;
let mut out = vec![];
for (layer, rects) in w.layers() {
if layer.order == egui::Order::Tooltip || layer.order == egui::Order::Debug {
continue;
}
for r in rects {
let sense = r.sense;
if !(sense.senses_click() || sense.senses_drag()) || !r.enabled {
continue;
}
let ir = r.interact_rect.intersect(screen);
if ir.width() < 2.0 || ir.height() < 2.0 {
continue;
}
let info = w.info(r.id);
// Selectable text (labels sense drags to select their text) is not a control.
if info.is_some_and(|i| i.typ == egui::WidgetType::Label) {
continue;
}
out.push((ir, info.and_then(|i| i.label.clone()).unwrap_or_default()));
}
}
out
})
}
/// Automation elements that cover a widget: overlapping at least half of the smaller of the two,
/// and not a container many times the widget's size (a panel or a whole row).
fn covered(w: Rect, elements: &[Rect]) -> bool {
elements.iter().any(|e| {
let i = e.intersect(w);
if i.width() <= 0.0 || i.height() <= 0.0 {
return false;
}
let (aw, ae) = (w.area(), e.area());
i.area() >= 0.5 * aw.min(ae) && ae <= 40.0 * aw.max(64.0)
})
}
/// (panel, widget rect, label) of every interactive widget no automation element covers, for
/// one panel shown maximized.
fn gaps_of(h: &mut Harness<'_, EffectcraftApp>, p: PanelKind) -> Vec<String> {
h.state_mut().show_panel(p);
h.state_mut().ui.maximized = Some(p);
h.run_steps(4);
let ctx = h.ctx.clone();
let widgets = interactive(&ctx);
let app = h.state();
let elements: Vec<Rect> = app
.auto
.previous
.iter()
.chain(app.auto.elements.iter())
.map(|e| Rect::from_min_size(egui::pos2(e.rect[0], e.rect[1]), egui::vec2(e.rect[2], e.rect[3])))
.collect();
let Some(panel) =
app.auto.find(&format!("panel.{}", p.id())).map(|e| Rect::from_min_size(egui::pos2(e.rect[0], e.rect[1]), egui::vec2(e.rect[2], e.rect[3])))
else {
return vec![format!("{}: panel.{} not registered", p.title(), p.id())];
};
widgets
.into_iter()
.filter(|(r, _)| panel.contains(r.center()))
.filter(|(r, _)| !covered(*r, &elements))
.map(|(r, l)| format!("{}: [{:.0},{:.0} {:.0}x{:.0}] {l:?}", p.title(), r.min.x, r.min.y, r.width(), r.height()))
.collect()
}
#[test]
#[cfg_attr(not(debug_assertions), ignore = "egui records widget types only in debug builds")]
fn every_panel_registers_automation_ids_for_its_widgets() {
let mut s = session();
// Content for the selection-driven panels: the title (text with effects), a Render Queue
// item, a comp marker.
s.execute("layer.select", json!({"layers": ["EFFECTCRAFT"]})).unwrap();
s.execute("renderQueue.add", json!({"format": "png", "output": "/tmp/ec-audit-[#####].png"})).unwrap();
s.execute("markers.add", json!({})).ok();
let title = s.active_comp().unwrap().layers.iter().find(|l| l.name == "EFFECTCRAFT").map(|l| l.id.0).unwrap();
let mut h = harness(s);
h.state_mut().ui.layer_panel = Some(title);
// egui records widget types (to tell labels from controls) with this debug option, which
// only debug builds have.
#[cfg(debug_assertions)]
h.ctx.all_styles_mut(|st| st.debug.show_interactive_widgets = true);
let mut gaps = vec![];
for p in PanelKind::ALL {
gaps.extend(gaps_of(&mut h, p));
}
// The timeline again with the title twirled open (properties, keys) and in the Graph Editor.
send(&mut h, "ui.set", json!({"timeline": {"openLayers": [title]}}));
gaps.extend(gaps_of(&mut h, PanelKind::Timeline));
send(&mut h, "ui.set", json!({"timeline": {"graphEditor": true}}));
gaps.extend(gaps_of(&mut h, PanelKind::Timeline));
send(&mut h, "ui.set", json!({"timeline": {"graphEditor": false}}));
// Outside the panels: the header and the menu bar.
h.state_mut().ui.maximized = None;
h.run_steps(3);
let ctx = h.ctx.clone();
let widgets = interactive(&ctx);
let app = h.state();
let elements: Vec<Rect> =
app.auto.previous.iter().map(|e| Rect::from_min_size(egui::pos2(e.rect[0], e.rect[1]), egui::vec2(e.rect[2], e.rect[3]))).collect();
let panels: Vec<Rect> = app
.auto
.previous
.iter()
.filter(|e| e.id.starts_with("panel.") && e.id.matches('.').count() == 1)
.map(|e| Rect::from_min_size(egui::pos2(e.rect[0], e.rect[1]), egui::vec2(e.rect[2], e.rect[3])))
.collect();
for (r, l) in widgets {
if !panels.iter().any(|p| p.contains(r.center())) && !covered(r, &elements) {
gaps.push(format!("chrome: [{:.0},{:.0} {:.0}x{:.0}] {l:?}", r.min.x, r.min.y, r.width(), r.height()));
}
}
assert!(gaps.is_empty(), "{} interactive widgets without an automation id:\n{}", gaps.len(), gaps.join("\n"));
}