Files
effectcraft/crates/ui-egui/tests/ui_agent_audit.rs
T
bflatasticandClaude Opus 5.5 f25b75486b Release gate: the agent-audit test builds in release mode
`cargo xtask ci` runs clippy and the tests with --release, where egui has no Style::debug (it
exists only with debug_assertions), so ui_agent_audit.rs failed to compile since M13.14. The
debug option is set only in debug builds, and the test, which needs the widget types egui records
with it, is ignored in release builds.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-06 08:37:01 +07:00

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"));
}