Files
effectcraft/crates/ui-egui/tests/it/ui_render_queue_keyboard.rs
48c8137835 Accessibility (gap 25), slice 2: Info, Flowchart, Script Console and the 3D Camera Tracker block named, described and keyboard-operable (builds on #1339) (#1341)
* #1335: accessibility slice 2: Info, Flowchart, Script Console and the 3D Camera Tracker block named, described and keyboard-operable

The audit gets read-outs, graphics and logs as described items (a name is required in a fixed panel), dedicated maximized scenarios for the panels the shared loop squeezes, and minimum counts for the new zero panels.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Review: Enter/Space on a favourite star only toggles it; keyboard activation of a property label; settings page scrolls to focus; sharper UI test assertions

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Refresh the accessibility audit baseline after merging upstream/main

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* a11y audit: hold the machine-dependent Media Browser scenario to its upper bound

The Media Browser opens on the home folder, so a runner with fewer files finds fewer controls and
the 'improved, lower the entry' check failed in CI. The entry stays an upper bound.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Merge upstream/main and updated feat-gap25-a11y-audit into feat-gap25-a11y-panels-2

Brought in the updated feat-gap25-a11y-audit (which already contains upstream/main). Conflict: crates/ui-egui/src/menus.rs, which upstream split into menus/*.rs. Took the slim module and re-applied this branch's Flowchart hunks where the code now lives: nudge() skips the Flowchart (menus/invoke.rs) and Enter is left to the Flowchart in handle_shortcuts (menus/shortcuts.rs).

Also removed a duplicate MACHINE_DEPENDENT const in tests/ui_accessibility_audit.rs that the two branches both added. The audit BASELINE still matches the measured findings.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Flowchart: split show() to pass the stricter clippy limits from main

Main now denies cognitive_complexity (30) and too_many_arguments; the
merged flowchart panel exceeded it. The keyboard navigation, the
key-to-node mapping, pan-into-view, node commands, the accessible node
name and the option params move into small helpers with unchanged
behaviour.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Camera tracker a11y test: wait for the analysis result instead of one frame

The test pressed Enter, ran one frame and expected to see the background job.
On a fast runner the job starts, ends and is written back inside that frame,
so the state was never seen. Undo the first analysis, press Enter, and wait
(bounded) for the new analysis step in the undo history.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Accessibility: one hours range for the accessibility pass in ui-parity.md; test that Flowchart automation labels stay English

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Flowchart toolbar: keep the English label for the automation registration

The translated text is shown and spoken; the automation label stays English.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: OpenAI Codex <noreply@openai.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
2026-11-06 19:12:07 +01:00

229 lines
12 KiB
Rust

//! Render Queue keyboard operation and accessible names (docs/gaps.md gap 25): queue items are
//! tree items (named by number, comp and status; selected; open or closed); the arrow keys, Home /
//! End, Right / Left, Enter and Delete work them; the buttons, the check boxes, the twirls and the
//! drop-downs have names and states; a drop-down opens from the keyboard and closes with the focus
//! back on it; the Templates dialog is named.
use effectcraft_engine::Session;
use effectcraft_ui_egui::EffectcraftApp;
use effectcraft_ui_egui::dock::PanelKind;
use egui::accesskit::Role;
use egui::{Event, Key, Modifiers, Pos2, pos2};
use egui_kittest::Harness;
use egui_kittest::kittest::{NodeT, Queryable};
use serde_json::json;
fn harness() -> Harness<'static, EffectcraftApp> {
let mut s = Session::default();
for name in ["Alpha", "Beta", "Gamma"] {
let comp = s.execute("comp.new", json!({"name": name, "width": 320, "height": 180, "duration": 4})).unwrap()["comp"].as_u64().unwrap();
s.execute("renderQueue.add", json!({"comp": comp})).unwrap();
}
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
h.state_mut().show_panel(PanelKind::RenderQueue);
h.run_steps(4);
h
}
fn key(h: &mut Harness<'_, EffectcraftApp>, key: Key, modifiers: Modifiers) {
for pressed in [true, false] {
h.input_mut().events.push(Event::Key { key, physical_key: None, pressed, repeat: false, modifiers });
h.step();
}
h.run_steps(3);
}
fn center(h: &Harness<'_, EffectcraftApp>, id: &str) -> Pos2 {
let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")).clone();
pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0)
}
fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) {
let p = center(h, id);
h.input_mut().events.push(Event::PointerMoved(p));
h.step();
for pressed in [true, false] {
h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
h.step();
}
h.run_steps(3);
}
fn focused(h: &Harness<'_, EffectcraftApp>) -> Option<(Role, String)> {
h.root().children_recursive().find(|n| n.is_focused()).map(|n| (n.accesskit_node().role(), n.accesskit_node().label().unwrap_or_default()))
}
fn queue_len(h: &Harness<'_, EffectcraftApp>) -> usize {
h.state().session.project.render_queue.len()
}
fn row_name(h: &Harness<'_, EffectcraftApp>, n: usize) -> String {
h.query_all_by_role(Role::TreeItem).map(|r| r.accesskit_node().label().unwrap_or_default()).find(|l| l.starts_with(&format!("{n}. "))).unwrap_or_default()
}
#[test]
fn rows_are_named_tree_items_with_selected_and_open_state() {
let mut h = harness();
let first = row_name(&h, 1);
assert!(first.starts_with("1. Alpha, "), "number, comp and status: {first}");
let node = h.get_by_role_and_label(Role::TreeItem, &first);
assert_eq!(node.accesskit_node().is_selected(), Some(false));
assert_eq!(node.accesskit_node().data().is_expanded(), Some(false));
assert_eq!(node.accesskit_node().data().level(), Some(1));
click(&mut h, "renderQueue.item.1.twirl");
let node = h.get_by_role_and_label(Role::TreeItem, &first);
assert_eq!(node.accesskit_node().data().is_expanded(), Some(true), "the open item says so");
let twirl = h.get_by_label("Hide details of Alpha");
assert_eq!(twirl.accesskit_node().role(), Role::Button);
assert_eq!(twirl.accesskit_node().data().is_expanded(), Some(true));
// The settings drop-downs of the open item: what they set and what they are set to.
let settings = h.get_by_role_and_label(Role::ComboBox, "Render Settings");
assert!(settings.accesskit_node().value().is_some_and(|v| !v.is_empty()));
assert!(h.query_by_role_and_label(Role::ComboBox, "Output Module").is_some());
assert!(h.query_by_role_and_label(Role::ComboBox, "Log").is_some());
assert!(h.query_by_role_and_label(Role::ComboBox, "Output To").is_some());
assert!(h.query_by_role_and_label(Role::Button, "Render").is_some());
let notify = h.get_by_role_and_label(Role::CheckBox, "Notify");
assert!(notify.accesskit_node().toggled().is_some());
let render = h.get_by_role_and_label(Role::CheckBox, "Render: Alpha");
assert_eq!(render.accesskit_node().toggled(), Some(egui::accesskit::Toggled::True));
assert_eq!(h.get_by_role(Role::ProgressIndicator).accesskit_node().data().numeric_value(), Some(0.0));
}
#[test]
fn arrows_home_end_right_left_enter_and_delete_work_the_rows() {
let mut h = harness();
h.get_by_role_and_label(Role::TreeItem, &row_name(&h, 1)).focus();
h.run_steps(3);
assert_eq!(focused(&h).map(|f| f.1), Some(row_name(&h, 1)));
let selected = |h: &Harness<'_, EffectcraftApp>| {
h.query_all_by_role(Role::TreeItem)
.filter(|n| n.accesskit_node().is_selected() == Some(true))
.map(|n| n.accesskit_node().label().unwrap_or_default())
.filter(|l| l.starts_with(|c: char| c.is_ascii_digit()))
.collect::<Vec<_>>()
};
assert_eq!(selected(&h), vec![row_name(&h, 1)], "a focused row is selected");
key(&mut h, Key::ArrowDown, Modifiers::NONE);
assert_eq!(focused(&h).map(|f| f.1), Some(row_name(&h, 2)), "Down Arrow moves to the next item and the focus goes with it");
assert_eq!(h.get_by_role_and_label(Role::TreeItem, &row_name(&h, 2)).accesskit_node().is_selected(), Some(true));
key(&mut h, Key::End, Modifiers::NONE);
assert_eq!(focused(&h).map(|f| f.1), Some(row_name(&h, 3)));
key(&mut h, Key::ArrowDown, Modifiers::NONE);
assert_eq!(focused(&h).map(|f| f.1), Some(row_name(&h, 3)), "Down Arrow stays on the last item");
key(&mut h, Key::Home, Modifiers::NONE);
assert_eq!(focused(&h).map(|f| f.1), Some(row_name(&h, 1)));
key(&mut h, Key::ArrowRight, Modifiers::NONE);
assert_eq!(h.get_by_role_and_label(Role::TreeItem, &row_name(&h, 1)).accesskit_node().data().is_expanded(), Some(true), "Right Arrow opens the item");
key(&mut h, Key::ArrowLeft, Modifiers::NONE);
assert_eq!(h.get_by_role_and_label(Role::TreeItem, &row_name(&h, 1)).accesskit_node().data().is_expanded(), Some(false), "Left Arrow closes it");
key(&mut h, Key::Enter, Modifiers::NONE);
assert_eq!(h.get_by_role_and_label(Role::TreeItem, &row_name(&h, 1)).accesskit_node().data().is_expanded(), Some(true), "Enter toggles it");
key(&mut h, Key::Space, Modifiers::NONE);
assert_eq!(h.get_by_role_and_label(Role::TreeItem, &row_name(&h, 1)).accesskit_node().data().is_expanded(), Some(false), "Space toggles it back");
assert!(!h.state().playback.playing && !h.state().playback.caching && h.state().playback.plan.is_none(), "Space on a row does not start a preview");
// Delete removes the selected item, as it does with the mouse's selection.
key(&mut h, Key::ArrowDown, Modifiers::NONE);
assert_eq!(queue_len(&h), 3);
key(&mut h, Key::Delete, Modifiers::NONE);
assert_eq!(queue_len(&h), 2, "Delete removes the selected item");
}
#[test]
fn tab_reaches_every_control_of_a_row() {
let mut h = harness();
h.get_by_role_and_label(Role::TreeItem, &row_name(&h, 1)).focus();
h.run_steps(3);
let mut seen = vec![focused(&h).unwrap()];
for _ in 0..40 {
key(&mut h, Key::Tab, Modifiers::NONE);
match focused(&h) {
Some(f) if !seen.contains(&f) => seen.push(f),
_ => {}
}
}
let names: Vec<&str> = seen.iter().map(|s| s.1.as_str()).collect();
for want in ["Show details of Alpha", "Render: Alpha", "Show details of Beta", "Render: Beta"] {
assert!(names.contains(&want), "Tab reaches `{want}`; reached {names:?}");
}
}
#[test]
fn a_drop_down_opens_from_the_keyboard_and_gives_the_focus_back() {
let mut h = harness();
click(&mut h, "renderQueue.item.1.twirl");
h.get_by_role_and_label(Role::ComboBox, "Render Settings").focus();
h.run_steps(3);
key(&mut h, Key::Enter, Modifiers::NONE);
let (role, first_entry) = focused(&h).expect("the first entry has the focus");
assert_ne!(role, Role::ComboBox, "the keyboard is in the menu: {first_entry}");
key(&mut h, Key::ArrowDown, Modifiers::NONE);
let second = focused(&h).expect("Down Arrow moves to the next entry").1;
assert_ne!(second, first_entry);
key(&mut h, Key::ArrowUp, Modifiers::NONE);
assert_eq!(focused(&h).map(|f| f.1), Some(first_entry), "Up Arrow moves back");
key(&mut h, Key::Enter, Modifiers::NONE);
assert_eq!(focused(&h).map(|f| f.0), Some(Role::ComboBox), "choosing an entry gives the focus back to the drop-down");
}
#[test]
fn the_check_box_and_the_buttons_work_from_the_keyboard() {
let mut h = harness();
let before = h.state().session.project.render_queue.first().map(|i| i.render);
h.get_by_role_and_label(Role::CheckBox, "Render: Alpha").focus();
h.run_steps(3);
key(&mut h, Key::Space, Modifiers::NONE);
assert_ne!(h.state().session.project.render_queue.first().map(|i| i.render), before, "Space toggles the Render check box");
let notify = h.state().session.project.render_prefs.notify;
h.get_by_role_and_label(Role::CheckBox, "Notify").focus();
h.run_steps(3);
key(&mut h, Key::Enter, Modifiers::NONE);
assert_ne!(h.state().session.project.render_prefs.notify, notify, "Enter toggles Notify");
}
#[test]
fn the_templates_dialog_is_named() {
let mut h = harness();
let ctx = h.ctx.clone();
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "app.templates", json!({"kind": "renderSettings"})).unwrap();
h.run_steps(4);
assert!(h.query_by_role_and_label(Role::ComboBox, "Settings Name").is_some());
assert!(h.query_by_role_and_label(Role::TextInput, "template name").is_some());
for name in ["Duplicate", "Delete", " OK "] {
assert_eq!(h.get_by_role_and_label(Role::Button, name).accesskit_node().role(), Role::Button, "{name}");
}
let defaults = h.query_all_by_role(Role::ComboBox).count();
assert!(defaults >= 4, "the default slots are drop-downs: {defaults}");
}
/// A pick with the mouse leaves no widget with the keyboard focus: egui pauses the application's
/// shortcuts (Space, Ctrl+Z) while one has it (#776's rule for the Project panel).
#[test]
fn a_pick_with_the_mouse_leaves_no_keyboard_focus() {
let mut h = harness();
click(&mut h, "renderQueue.item.1.twirl");
click(&mut h, "renderQueue.item.1.renderSettings");
assert!(h.ctx.memory(|m| m.focused()).is_none(), "opening a drop-down with the mouse takes no keyboard focus");
let current = h.state().session.project.render_queue[0].settings.name.clone();
let template =
h.state().session.project.render_templates.render_settings_list().into_iter().map(|t| t.name).find(|n| *n != current).expect("a second template");
h.get_by_label_contains(&template).click();
h.run_steps(4);
assert!(h.ctx.memory(|m| m.focused()).is_none(), "choosing an entry with the mouse leaves no keyboard focus");
assert_eq!(h.state().session.project.render_queue[0].settings.name, template, "the pick applied the template");
}
/// Enter after a click in the panel belongs to the panel (it opens or applies the row), not to
/// the layer rename the Timeline binds to it.
#[test]
fn enter_after_a_click_opens_the_item_and_does_not_rename_a_layer() {
let mut h = harness();
h.state_mut().session.execute("layer.newSolid", json!({"name": "Red", "color": "#c04040"})).unwrap();
h.run_steps(3);
click(&mut h, "renderQueue.item.1.row");
assert_eq!(h.state().ui.focused, PanelKind::RenderQueue);
key(&mut h, Key::Enter, Modifiers::NONE);
assert_eq!(h.get_by_role_and_label(Role::TreeItem, &row_name(&h, 1)).accesskit_node().data().is_expanded(), Some(true), "Enter opens the clicked item");
assert!(h.state().auto.query("timeline.layer.").iter().all(|e| !e.id.ends_with(".nameEdit")), "no layer rename started");
}