use serde::{Deserialize, Serialize};
use crate::reading::ReadingItem;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Announcement {
pub name: Option<String>,
pub role: AnnouncedRole,
pub states: Vec<AnnouncedState>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AnnouncedRole {
Button,
Link,
TextBox,
CheckBox,
Radio,
ComboBox,
ListBox,
Slider,
SpinButton,
Tab,
Heading {
level: Option<u8>,
},
Navigation,
Main,
Banner,
ContentInfo,
Other(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AnnouncedState {
Expanded,
Collapsed,
Checked,
Unchecked,
Mixed,
Selected,
NotSelected,
Required,
Invalid,
Disabled,
Pressed,
NotPressed,
Focusable,
}
pub fn announce(item: &ReadingItem) -> Announcement {
Announcement {
name: item
.name
.as_deref()
.map(str::trim)
.filter(|name| !name.is_empty())
.map(String::from),
role: role(item),
states: states(item),
}
}
fn role(item: &ReadingItem) -> AnnouncedRole {
let role = item.role.as_deref().unwrap_or("generic");
if role == "heading" {
return AnnouncedRole::Heading {
level: state_value(&item.states, "level").and_then(|level| level.parse().ok()),
};
}
match role {
"button" => AnnouncedRole::Button,
"link" => AnnouncedRole::Link,
"textbox" | "searchbox" => AnnouncedRole::TextBox,
"checkbox" => AnnouncedRole::CheckBox,
"radio" => AnnouncedRole::Radio,
"combobox" => AnnouncedRole::ComboBox,
"listbox" => AnnouncedRole::ListBox,
"slider" => AnnouncedRole::Slider,
"spinbutton" => AnnouncedRole::SpinButton,
"tab" => AnnouncedRole::Tab,
"navigation" => AnnouncedRole::Navigation,
"main" => AnnouncedRole::Main,
"banner" => AnnouncedRole::Banner,
"contentinfo" => AnnouncedRole::ContentInfo,
other => AnnouncedRole::Other(other.to_string()),
}
}
fn states(item: &ReadingItem) -> Vec<AnnouncedState> {
let mut states: Vec<AnnouncedState> = item
.states
.iter()
.filter_map(|state| match split_state(state) {
("expanded", Some("false")) => Some(AnnouncedState::Collapsed),
("expanded", _) => Some(AnnouncedState::Expanded),
("checked", Some("false")) => Some(AnnouncedState::Unchecked),
("checked", Some("mixed")) => Some(AnnouncedState::Mixed),
("checked", _) => Some(AnnouncedState::Checked),
("selected", Some("false")) => Some(AnnouncedState::NotSelected),
("selected", _) => Some(AnnouncedState::Selected),
("required", Some("false")) => None,
("required", _) => Some(AnnouncedState::Required),
("invalid", Some("false")) => None,
("invalid", _) => Some(AnnouncedState::Invalid),
("disabled", Some("false")) => None,
("disabled", _) => Some(AnnouncedState::Disabled),
("pressed", Some("false")) => Some(AnnouncedState::NotPressed),
("pressed", _) => Some(AnnouncedState::Pressed),
_ => None,
})
.collect();
if item.tab_stop && !announces_its_own_focusability(item.role.as_deref()) {
states.push(AnnouncedState::Focusable);
}
states
}
fn state_value<'a>(states: &'a [String], name: &str) -> Option<&'a str> {
states
.iter()
.filter_map(|state| state.split_once('='))
.find_map(|(state_name, value)| (state_name == name).then_some(value))
}
fn split_state(state: &str) -> (&str, Option<&str>) {
match state.split_once('=') {
Some((name, value)) => (name, Some(value)),
None => (state, None),
}
}
fn announces_its_own_focusability(role: Option<&str>) -> bool {
matches!(
role,
Some(
"button"
| "link"
| "textbox"
| "searchbox"
| "checkbox"
| "radio"
| "combobox"
| "listbox"
| "slider"
| "spinbutton"
| "tab"
)
)
}
#[cfg(test)]
mod tests {
use super::{AnnouncedRole, AnnouncedState, announce};
use crate::reading::ReadingItem;
fn item(role: Option<&str>, name: Option<&str>, states: Vec<&str>) -> ReadingItem {
ReadingItem {
seq: 0,
role: role.map(String::from),
name: name.map(String::from),
description: None,
value: None,
states: states.into_iter().map(String::from).collect(),
tab_stop: false,
depth: 0,
node_id: "1".into(),
}
}
#[test]
fn name_wird_beschnitten_und_leer_heisst_keiner() {
assert_eq!(
announce(&item(Some("link"), Some(" Mehr erfahren "), vec![])).name,
Some("Mehr erfahren".to_string())
);
assert_eq!(
announce(&item(Some("button"), Some(" "), vec![])).name,
None
);
assert_eq!(announce(&item(Some("button"), None, vec![])).name, None);
}
#[test]
fn ebene_gehoert_in_die_rolle_nicht_in_die_zustaende() {
let ansage = announce(&item(Some("heading"), Some("Willkommen"), vec!["level=1"]));
assert_eq!(ansage.role, AnnouncedRole::Heading { level: Some(1) });
assert!(ansage.states.is_empty(), "level ist kein Zustand");
}
#[test]
fn ueberschrift_ohne_brauchbare_ebene_bleibt_ueberschrift() {
for states in [vec![], vec!["level=zwei"]] {
assert_eq!(
announce(&item(Some("heading"), Some("Kapitel"), states)).role,
AnnouncedRole::Heading { level: None }
);
}
}
#[test]
fn suchfeld_wird_als_eingabefeld_angesagt() {
assert_eq!(
announce(&item(Some("searchbox"), Some("Suche"), vec![])).role,
AnnouncedRole::TextBox
);
}
#[test]
fn unbekannte_rolle_kommt_unuebersetzt_durch_fehlende_wird_generic() {
assert_eq!(
announce(&item(Some("marquee"), None, vec![])).role,
AnnouncedRole::Other("marquee".to_string())
);
assert_eq!(
announce(&item(None, None, vec![])).role,
AnnouncedRole::Other("generic".to_string())
);
}
#[test]
fn zustaende_behalten_die_reihenfolge_des_knotens() {
let ansage = announce(&item(
Some("textbox"),
Some("E-Mail"),
vec!["required", "invalid"],
));
assert_eq!(
ansage.states,
vec![AnnouncedState::Required, AnnouncedState::Invalid]
);
}
#[test]
fn verneintes_wird_angesagt_wo_es_etwas_aussagt_und_sonst_nicht() {
for (state, erwartet) in [
("expanded=false", AnnouncedState::Collapsed),
("checked=false", AnnouncedState::Unchecked),
("selected=false", AnnouncedState::NotSelected),
("pressed=false", AnnouncedState::NotPressed),
] {
assert_eq!(
announce(&item(Some("button"), Some("Filter"), vec![state])).states,
vec![erwartet],
"{state} sagt etwas aus"
);
}
for state in ["required=false", "invalid=false", "disabled=false"] {
assert!(
announce(&item(Some("textbox"), Some("Name"), vec![state]))
.states
.is_empty(),
"{state} ist der Normalfall und wird nicht angesagt"
);
}
}
#[test]
fn checked_mixed_ist_ein_eigener_zustand() {
assert_eq!(
announce(&item(Some("checkbox"), Some("Alle"), vec!["checked=mixed"])).states,
vec![AnnouncedState::Mixed]
);
}
#[test]
fn unbekannter_zustand_wird_verschwiegen_nicht_durchgereicht() {
assert!(
announce(&item(Some("button"), Some("Los"), vec!["haspopup=menu"]))
.states
.is_empty()
);
}
#[test]
fn fokussierbar_nur_wo_die_rolle_es_nicht_schon_sagt() {
let mut generisch = item(Some("generic"), Some("Karte"), vec![]);
generisch.tab_stop = true;
assert_eq!(
announce(&generisch).states,
vec![AnnouncedState::Focusable],
"ein div mit tabindex ist eine Nachricht"
);
let mut schalter = item(Some("button"), Some("Senden"), vec![]);
schalter.tab_stop = true;
assert!(
announce(&schalter).states.is_empty(),
"bei einem Schalter fuegt fokussierbar nichts hinzu"
);
}
#[test]
fn fokussierbar_kommt_hinter_die_zustaende_des_knotens() {
let mut generisch = item(Some("generic"), Some("Karte"), vec!["expanded=false"]);
generisch.tab_stop = true;
assert_eq!(
announce(&generisch).states,
vec![AnnouncedState::Collapsed, AnnouncedState::Focusable]
);
}
}