Files
lightcraft/crates/ui-egui/tests/accessibility.rs
T
Andrew FrahnandClaude Opus 5.5 530320a6bd LR-BEHAV-ACCESS: AccessKit tree audit test; name fields, give sliders range
tests/accessibility.rs walks the AccessKit tree headless in every view,
Develop tool and 13 dialogs and fails on unnamed controls, sliders without
value/range and untitled dialogs. Fixes what it found: text fields are
named by caption or hint, develop sliders expose min/max/step/value text
and take Increment/Decrement, Tab-focused sliders take the arrow keys,
Auto Stack's slider is named. Also as_chunks in spots.rs for the newer
clippy lint. Docs: ui-parity audit section, checklist, gaps, ROADMAP.

Sources: own design.

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

190 lines
6.9 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();
if !interactive(role) {
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:?}");
}
}