Files
lightcraft/crates/ui-egui/tests/accessibility.rs
Andrew FrahnandClaude Opus 5.5 c3be1ceead LR-BEHAV-ACCESS: canvas tools by keyboard; name hand-drawn widgets; Tab order test
canvas_keys.rs gives every handle on the photo a focusable stand-in with
an AccessKit name and position: crop corners, edges and box (crop.drag),
spot targets and sources plus a New spot point (Return adds), radial and
linear gradient handles and mask pins. Arrows move by 0.5% (Shift 5%),
one undo step per press; the stand-in keeps arrows/Return/Space from the
shortcuts. The audit now counts any focusable or clickable node; ~50
hand-drawn icons, tiles and rows are announced as buttons. A test walks
Tab through the Edit/Crop/Remove/Masking panels top to bottom.

Sources: own design.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 04:13:49 +11:00

252 lines
9.8 KiB
Rust

//! Accessibility audit: walks the AccessKit tree egui exposes to screen readers (VoiceOver,
//! Narrator, AT-SPI) in each main view and Develop tool, headless, and fails on an interactive
//! node with no name, a slider without its value and range, or a canvas tool no key can reach.
use egui::accesskit::Role;
use egui::vec2;
use egui_kittest::{Harness, kittest::NodeT};
use lightcraft_ui_egui::{
LightcraftApp, Services,
state::{RightPanel, ViewMode},
};
fn harness(view: ViewMode, right: RightPanel) -> Harness<'static, LightcraftApp> {
let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() });
app.ui.left_panel = true;
app.ui.view = view;
app.ui.right = right;
let mut h = Harness::builder().with_size(vec2(1500.0, 950.0)).build_ui_state(
|ui, app: &mut LightcraftApp| {
app.logic(&ui.ctx().clone());
app.ui(ui);
},
app,
);
h.run_steps(10);
h
}
fn interactive(role: Role) -> bool {
matches!(
role,
Role::Button
| Role::CheckBox
| Role::Switch
| Role::RadioButton
| Role::Slider
| Role::SpinButton
| Role::TextInput
| Role::MultilineTextInput
| Role::SearchInput
| Role::ComboBox
| Role::EditableComboBox
| Role::Tab
| Role::Link
| Role::MenuItem
| Role::MenuItemCheckBox
| Role::MenuItemRadio
| Role::ListBoxOption
)
}
/// Every problem found in the harness's current tree, one line each.
fn audit(h: &Harness<'_, LightcraftApp>, place: &str) -> Vec<String> {
let mut problems = Vec::new();
let mut count = 0;
for node in h.root().children_recursive() {
let n = node.accesskit_node();
let role = n.role();
// egui's own scroll bars are announced by their role, as native ones are
if role == Role::ScrollBar {
continue;
}
// a widget that takes the focus or a click is interactive whatever its role
let acts = |a| n.data().supports_action(a);
if !interactive(role) && !acts(egui::accesskit::Action::Focus) && !acts(egui::accesskit::Action::Click) {
continue;
}
count += 1;
let name = n.label().unwrap_or_default();
let name = if name.trim().is_empty() { n.value().unwrap_or_default() } else { name };
if name.trim().is_empty() {
problems.push(format!("{place}: unnamed {role:?} at {:?}", n.bounding_box()));
}
if role == Role::Slider && (n.numeric_value().is_none() || n.min_numeric_value().is_none() || n.max_numeric_value().is_none()) {
problems.push(format!("{place}: slider {name:?} lacks value/range"));
}
}
assert!(count > 5, "{place}: the tree has interactive nodes ({count})");
problems
}
#[test]
fn every_interactive_node_has_a_name_in_every_view_and_tool() {
let mut problems = Vec::new();
for view in [ViewMode::PhotoGrid, ViewMode::SquareGrid, ViewMode::Detail, ViewMode::Compare, ViewMode::Survey, ViewMode::Reference] {
let h = harness(view, RightPanel::None);
problems.extend(audit(&h, &format!("{view:?}")));
}
for right in [
RightPanel::Edit,
RightPanel::Profiles,
RightPanel::Crop,
RightPanel::Remove,
RightPanel::Masking,
RightPanel::RedEye,
RightPanel::Versions,
RightPanel::Activity,
RightPanel::Keywords,
RightPanel::Info,
] {
let h = harness(ViewMode::Detail, right);
problems.extend(audit(&h, &format!("Detail/{right:?}")));
}
// the dialogs: each a window with a title, every control in it named
for dialog in [
"dialog.export",
"dialog.copySettings",
"dialog.pasteSettings",
"dialog.createPreset",
"dialog.rename",
"dialog.newAlbum",
"dialog.newSmartAlbum",
"dialog.captureTime",
"dialog.labelNames",
"dialog.allMetadata",
"dialog.saveMetadataPreset",
"dialog.autoStack",
"dialog.contactSheet",
] {
let mut h = harness(ViewMode::PhotoGrid, RightPanel::None);
if let Err(e) = h.state_mut().run(dialog, serde_json::json!({})) {
problems.push(format!("{dialog}: does not open ({e})"));
continue;
}
h.run_steps(6);
problems.extend(audit(&h, dialog));
let windows = h.root().children_recursive().map(|n| n.accesskit_node()).filter(|n| n.role() == Role::Window).count();
let untitled = h
.root()
.children_recursive()
.map(|n| n.accesskit_node())
.filter(|n| n.role() == Role::Window && n.label().unwrap_or_default().trim().is_empty())
.count();
if untitled > 0 {
problems.push(format!("{dialog}: {untitled} of {windows} windows have no title"));
}
}
problems.sort();
problems.dedup();
assert!(problems.is_empty(), "{} accessibility problems:\n{}", problems.len(), problems.join("\n"));
}
fn slider_value(h: &Harness<'_, LightcraftApp>, name: &str) -> Option<f64> {
h.root()
.children_recursive()
.map(|n| n.accesskit_node())
.find(|n| n.role() == Role::Slider && n.label().as_deref() == Some(name))
.and_then(|n| n.numeric_value())
}
/// A screen reader's Increment on a develop slider moves it one step, as an arrow key does.
#[test]
fn screen_reader_increment_moves_a_slider() {
let mut h = harness(ViewMode::Detail, RightPanel::Edit);
let before = slider_value(&h, "Exposure").expect("Exposure is in the tree");
let target = h
.root()
.children_recursive()
.find(|n| n.accesskit_node().role() == Role::Slider && n.accesskit_node().label().as_deref() == Some("Exposure"))
.unwrap()
.accesskit_node()
.locate();
h.event(egui::Event::AccessKitActionRequest(egui::accesskit::ActionRequest {
action: egui::accesskit::Action::Increment,
target_node: target.0,
target_tree: target.1,
data: None,
}));
h.run_steps(4);
let after = slider_value(&h, "Exposure").unwrap();
assert!(after > before, "{before} -> {after}");
}
/// The canvas tools' settings (crop angle and offsets, spot size, mask amount) are reachable with
/// Tab alone, and the arrow keys then adjust them.
#[test]
fn canvas_tool_sliders_are_reachable_with_tab() {
for right in [RightPanel::Crop, RightPanel::Remove] {
let mut h = harness(ViewMode::Detail, right);
let mut reached = None;
for _ in 0..300 {
h.key_press(egui::Key::Tab);
h.run_steps(1);
let focused = h.root().children_recursive().map(|n| n.accesskit_node()).find(|n| n.is_focused() && n.role() == Role::Slider);
if let Some(n) = focused {
reached = Some((n.label().unwrap_or_default(), n.numeric_value()));
break;
}
}
let (name, before) = reached.unwrap_or_else(|| panic!("{right:?}: no slider takes the focus with Tab"));
h.key_press(egui::Key::ArrowUp);
h.run_steps(4);
let after = slider_value(&h, &name);
assert_ne!(before, after, "{right:?}: ArrowUp on the Tab-focused {name:?}");
}
}
/// The canvas handles are in the tree by name, with their position as the value.
#[test]
fn canvas_handles_are_named_with_their_position() {
let h = harness(ViewMode::Detail, RightPanel::Crop);
let found: Vec<(String, String)> = h
.root()
.children_recursive()
.map(|n| n.accesskit_node())
.filter(|n| n.role() == Role::Button)
.map(|n| (n.label().unwrap_or_default(), n.value().unwrap_or_default()))
.collect();
for name in ["Crop top-left corner", "Crop right edge", "Crop box"] {
assert!(found.iter().any(|(l, v)| l == name && v.contains('%')), "{name}: {found:?}");
}
let h = harness(ViewMode::Detail, RightPanel::Remove);
assert!(h.root().children_recursive().any(|n| n.accesskit_node().label().is_some_and(|l| l.starts_with("New spot"))));
}
/// Tab walks each right panel from top to bottom (rows left to right): the order a sighted user
/// reads it in. Each step down may not jump back up by more than a row.
#[test]
fn tab_walks_the_right_panels_top_to_bottom() {
for right in [RightPanel::Edit, RightPanel::Crop, RightPanel::Remove, RightPanel::Masking] {
let mut h = harness(ViewMode::Detail, right);
let panel_left = 1500.0 - h.state().ui.right_width - 10.0;
// the tool strip beside the panel is a column of its own, walked before it
let strip = h
.root()
.children_recursive()
.map(|n| n.accesskit_node())
.find(|n| n.label().as_deref() == Some("Edit (E)"))
.and_then(|n| n.bounding_box())
.map(|b| b.x0 as f32)
.unwrap_or(f32::INFINITY);
let mut seen: Vec<(String, f32)> = Vec::new();
for _ in 0..120 {
h.key_press(egui::Key::Tab);
h.run_steps(1);
let Some(n) = h.root().children_recursive().map(|n| n.accesskit_node()).find(|n| n.is_focused()) else { continue };
let Some(b) = n.bounding_box() else { continue };
if (b.x0 as f32) < panel_left || (b.x0 as f32) >= strip - 1.0 {
continue;
}
let name = n.label().unwrap_or_default();
if seen.iter().any(|(s, y)| *s == name && (*y - b.y0 as f32).abs() < 1.0) && !name.is_empty() {
break;
}
seen.push((name, b.y0 as f32));
}
assert!(seen.len() > 3, "{right:?}: Tab reaches the panel: {seen:?}");
for w in seen.windows(2) {
assert!(w[1].1 >= w[0].1 - 30.0, "{right:?}: Tab jumps up from {:?} to {:?}\n{seen:?}", w[0], w[1]);
}
}
}