use std::fmt;
use crate::a11y::{A11yInfo, A11yRole, A11yTree, A11yTreeBuilder, A11yTreeNode, A11yUpdate};
use crate::focus::FocusManager;
use crate::frame::{OwnedFrame, ScreenLayout, Surface};
use crate::WidgetId;
#[derive(Default)]
pub struct A11yScenario {
layout: ScreenLayout,
overrides: Vec<(usize, A11yInfo)>,
focus: FocusManager,
builder: A11yTreeBuilder,
}
impl fmt::Debug for A11yScenario {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("A11yScenario")
.field("surfaces", &self.layout.surfaces().len())
.field("overrides", &self.overrides)
.field("focus", &self.focus)
.field("builder", &self.builder)
.finish()
}
}
impl A11yScenario {
pub fn new() -> Self {
Self::default()
}
pub fn with_root_label(mut self, label: impl Into<String>) -> Self {
self.builder = A11yTreeBuilder::new().with_root_label(label);
self
}
pub fn with_surface(mut self, surface: Surface) -> Self {
self.push(surface);
self
}
pub fn with_focus(mut self, id: WidgetId) -> Self {
self.set_focus(Some(id));
self
}
pub fn with_a11y_override(mut self, idx: usize, info: A11yInfo) -> Self {
self.overrides.push((idx, info));
self
}
pub fn push(&mut self, surface: Surface) {
self.layout.push(surface);
}
pub fn surfaces_mut(&mut self) -> &mut [Surface] {
self.layout.surfaces_mut()
}
pub fn set_focus(&mut self, id: Option<WidgetId>) {
self.focus.set_focus(id);
}
pub fn frame(&self) -> OwnedFrame {
let mut frame = OwnedFrame::from_layout(&self.layout);
for (idx, info) in &self.overrides {
if let Some(node) = frame.nodes_mut().get_mut(*idx) {
node.a11y = Some(info.clone());
}
}
frame
}
pub fn snapshot(&self) -> A11ySnapshot {
A11ySnapshot::new(self.builder.tree(&self.frame(), &self.focus))
}
pub fn update(&mut self) -> A11yUpdate {
let frame = self.frame();
self.builder.update(&frame, &self.focus)
}
pub fn reset(&mut self) {
self.builder.reset();
}
}
#[derive(Debug, Clone)]
pub struct A11ySnapshot {
tree: A11yTree,
text: String,
}
impl A11ySnapshot {
fn new(tree: A11yTree) -> Self {
let text = tree.to_snapshot();
Self { tree, text }
}
pub fn tree(&self) -> &A11yTree {
&self.tree
}
pub fn text(&self) -> &str {
&self.text
}
pub fn contains(&self, needle: &str) -> bool {
self.text.contains(needle)
}
pub fn line(&self, label: &str) -> Option<&str> {
let quoted = format!("\"{label}\"");
self.text
.lines()
.map(str::trim_start)
.find(|line| line.contains("ed))
}
pub fn find(&self, label: &str) -> Option<&A11yTreeNode> {
self.tree.find(label)
}
pub fn roles(&self) -> Vec<A11yRole> {
self.tree.nodes().iter().map(A11yTreeNode::role).collect()
}
pub fn labels(&self) -> Vec<&str> {
self.tree
.nodes()
.iter()
.filter_map(A11yTreeNode::label)
.collect()
}
pub fn focused(&self) -> Option<&A11yTreeNode> {
self.tree.focused()
}
#[track_caller]
pub fn assert_matches(&self, expected: &str) {
let actual = self.text.trim_matches('\n');
let expected = expected.trim_matches('\n');
assert!(
actual == expected,
"accessibility tree did not match.\n--- expected ---\n{expected}\n--- actual ---\n{actual}\n"
);
}
}
impl fmt::Display for A11ySnapshot {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.text)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::a11y::{frame_to_tree, A11yNodeId};
use crate::primitives::list::{ListItem, ListView};
use crate::primitives::toolbar::{Toolbar, ToolbarButton};
use crate::types::{Decoration, StyledText};
use crate::Rect;
fn list(id: &str, title: &str, items: &[&str], selected: usize) -> ListView {
ListView {
id: WidgetId::new(id),
title: Some(StyledText::plain(title)),
items: items
.iter()
.map(|text| ListItem {
text: StyledText::plain(*text),
icon: None,
detail: None,
decoration: Decoration::default(),
})
.collect(),
selected_idx: selected,
scroll_offset: 0,
has_focus: false,
bordered: false,
h_scroll: 0,
max_content_width: None,
show_v_scrollbar: false,
}
}
fn list_surface(id: &str, title: &str, items: &[&str], selected: usize) -> Surface {
Surface::List {
rect: Rect::new(0.0, 1.0, 30.0, 11.0),
list: list(id, title, items, selected),
}
}
fn button(id: &str, label: &str) -> ToolbarButton {
ToolbarButton::Action {
id: WidgetId::new(id),
label: label.to_string(),
icon: None,
key_hint: None,
enabled: true,
is_active: false,
tooltip: String::new(),
}
}
fn toolbar_surface() -> Surface {
let bar = Toolbar::new(WidgetId::new("tools")).with_buttons(vec![
button("run", "Run"),
ToolbarButton::Separator,
button("stop", "Stop"),
]);
Surface::Toolbar {
rect: Rect::new(0.0, 0.0, 30.0, 1.0),
bar,
hovered: None,
pressed: None,
}
}
#[test]
fn scenario_snapshots_a_list_screen() {
let snapshot = A11yScenario::new()
.with_surface(toolbar_surface())
.with_surface(list_surface("files", "Files", &["a.rs", "b.rs"], 1))
.snapshot();
snapshot.assert_matches(
"\
window \"quadraui\" [focused]
toolbar
button \"Run\"
presentation
button \"Stop\"
list \"Files\" [size=2]
list_item \"a.rs\" [pos=1/2]
list_item \"b.rs\" [selected pos=2/2]",
);
}
#[test]
fn snapshot_text_matches_the_tree_it_carries() {
let scenario =
A11yScenario::new().with_surface(list_surface("files", "Files", &["a.rs"], 0));
let snapshot = scenario.snapshot();
let direct = frame_to_tree(&scenario.frame(), &FocusManager::new());
assert_eq!(snapshot.text(), direct.to_snapshot());
assert_eq!(snapshot.to_string(), direct.to_snapshot());
}
#[test]
fn focus_descends_into_the_focused_primitive() {
let snapshot = A11yScenario::new()
.with_surface(list_surface("files", "Files", &["a.rs", "b.rs"], 1))
.with_focus(WidgetId::new("files"))
.snapshot();
assert_eq!(
snapshot.focused().and_then(A11yTreeNode::label),
Some("b.rs")
);
assert_eq!(
snapshot.line("b.rs"),
Some("list_item \"b.rs\" [focused selected pos=2/2]")
);
}
#[test]
fn roles_and_labels_read_in_document_order() {
let snapshot = A11yScenario::new()
.with_surface(list_surface("files", "Files", &["a.rs"], 0))
.snapshot();
assert_eq!(
snapshot.roles(),
vec![A11yRole::Window, A11yRole::List, A11yRole::ListItem]
);
assert_eq!(snapshot.labels(), vec!["quadraui", "Files", "a.rs"]);
assert!(snapshot.contains("list \"Files\""));
assert!(snapshot.find("Files").is_some());
assert!(snapshot.find("nope").is_none());
}
#[test]
fn node_ids_survive_a_re_snapshot() {
let mut scenario =
A11yScenario::new().with_surface(list_surface("files", "Files", &["a.rs", "b.rs"], 0));
let first = scenario.snapshot();
let before = first.find("a.rs").expect("row").id;
scenario.surfaces_mut()[0] = list_surface("files", "Files", &["a.rs", "b.rs"], 1);
let second = scenario.snapshot();
assert_eq!(second.find("a.rs").expect("row").id, before);
assert_eq!(
second.tree().root(),
first.tree().root(),
"the root id is fixed"
);
assert_eq!(
before,
A11yNodeId::for_widget(&WidgetId::new("files")).child("item.0")
);
}
#[test]
fn first_update_is_full_then_incremental() {
let mut scenario =
A11yScenario::new().with_surface(list_surface("files", "Files", &["a.rs", "b.rs"], 0));
let full = scenario.update();
assert!(full.full);
assert_eq!(full.nodes.len(), scenario.snapshot().tree().nodes().len());
let unchanged = scenario.update();
assert!(!unchanged.full);
assert!(
unchanged.nodes.is_empty(),
"an identical frame emits nothing"
);
scenario.surfaces_mut()[0] = list_surface("files", "Files", &["a.rs", "b.rs"], 1);
let changed = scenario.update();
assert!(!changed.full);
assert_eq!(changed.nodes.len(), 2, "only the two rows that moved");
scenario.reset();
assert!(scenario.update().full, "reset forces a full tree again");
}
#[test]
fn consumer_override_relabels_a_primitive() {
let snapshot = A11yScenario::new()
.with_surface(list_surface("files", "Files", &["a.rs"], 0))
.with_a11y_override(0, A11yInfo::new().with_label("Open editors"))
.snapshot();
assert_eq!(
snapshot.line("Open editors"),
Some("list \"Open editors\" [size=1]")
);
assert!(snapshot.find("Files").is_none());
}
#[test]
fn root_label_is_overridable() {
let snapshot = A11yScenario::new()
.with_root_label("kubeui")
.with_surface(list_surface("files", "Files", &["a.rs"], 0))
.snapshot();
assert!(snapshot.contains("window \"kubeui\""));
}
#[test]
#[should_panic(expected = "accessibility tree did not match")]
fn assert_matches_reports_a_mismatch() {
A11yScenario::new()
.snapshot()
.assert_matches("list \"nope\"");
}
}