// TRACES: NFR-A11Y-2 //! Every shared control says what it is, and every slider says what it edits. //! //! NFR-A11Y-2 asks that controls expose names, roles and values to AT-SPI and //! TalkBack. Almost none of that is Rust: it is `accessible-*` properties in //! Slint markup, which no unit test can observe by running the interface — //! there is no accessibility tree without a window, and CI has no display. //! //! So this reads the markup, the way `darkroom-android`'s manifest test reads //! the XML that aapt2 owns and rustc never sees. The property being defended //! is not "the screen reader announces the right words", which needs a device //! and a person; it is the thing that silently regresses instead — **a control //! losing its role or its name in an ordinary refactor**, which compiles, //! renders identically, passes every other test, and is invisible to anyone //! not using a screen reader. That failure has already happened once here: the //! whole application had five `accessible-*` lines in 14,482 lines of markup, //! all of them on one row of the colour mixer, and nothing said so. //! //! ## What is checked, and what deliberately is not //! //! Two properties, both structural: //! //! 1. **Named components declare the roles they promise.** A `Button` that //! stops declaring `accessible-role` stops existing for a screen reader //! entirely, because Slint rejects every other `accessible-*` property that //! is not accompanied by a role — so the role is the load-bearing one and //! the rest fail with it. //! //! 2. **Every `SliderTrack` instantiation supplies a `label`.** This is the //! one that catches a *new* control rather than a broken old one. The track //! cannot name itself — it has no idea what number it is dragging — so an //! unnamed one announces "slider, 0.35" and is the single most-used control //! in the application. A track added without a label is the exact mistake //! this file exists to make loud. //! //! Not checked: whether the words are good, whether they are translated, //! whether contrast passes, or whether Android exposes any of it — spike S13 //! is still unrun and no test on this side of it can answer that. use std::fs; use std::path::{Path, PathBuf}; /// A component that must carry particular `accessible-*` properties, and the /// properties it must carry. /// /// Only the ones whose absence is a behavioural loss are listed. `Button` must /// declare `accessible-action-default` because without it a screen reader can /// read the button and not press it, which is a control that exists and cannot /// be used; it need not declare `accessible-description`, which is a nicety. struct Promise { file: &'static str, component: &'static str, properties: &'static [&'static str], } const PROMISES: &[Promise] = &[ Promise { file: "widgets.slint", component: "Button", properties: &["accessible-role", "accessible-label", "accessible-action-default"], }, Promise { file: "widgets.slint", component: "IconButton", properties: &["accessible-role", "accessible-label", "accessible-action-default"], }, Promise { file: "widgets.slint", component: "FilterChip", properties: &[ "accessible-role", "accessible-label", "accessible-checked", "accessible-action-default", ], }, Promise { file: "widgets.slint", component: "Section", properties: &["accessible-role", "accessible-label", "accessible-expanded"], }, Promise { file: "widgets.slint", component: "ProgressBar", properties: &["accessible-role", "accessible-label", "accessible-value"], }, // The label goes on the `TextInput` inside, not on the box around it — // Slint gives that element its role, so the name belongs beside it. Promise { file: "widgets.slint", component: "Field", properties: &["accessible-label", "accessible-placeholder-text"], }, Promise { file: "controls.slint", component: "SliderTrack", properties: &[ "accessible-role", "accessible-label", "accessible-value", "accessible-value-minimum", "accessible-value-maximum", "accessible-action-increment", "accessible-action-decrement", "accessible-action-set-value", ], }, Promise { file: "controls.slint", component: "Check", properties: &[ "accessible-role", "accessible-label", "accessible-checked", "accessible-action-default", ], }, Promise { file: "controls.slint", component: "ChoiceChip", properties: &[ "accessible-role", "accessible-label", "accessible-checked", "accessible-action-default", ], }, ]; fn ui_dir() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join("ui") } fn read(path: &Path) -> String { fs::read_to_string(path).unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display())) } /// The body of one top-level component declaration, or `None` if the file /// declares no such component. /// /// Every component in these files is declared at column zero, so the block /// runs from its header to the next header or to the end of the file. A /// looser rule than brace matching and a stricter one than "somewhere in the /// file": the point is that a property found for `Button` really is inside /// `Button` and not inside the component below it. fn component_body<'a>(source: &'a str, name: &str) -> Option<&'a str> { let header_starts = |line: &str| { line.starts_with("component ") || line.starts_with("export component ") }; let mut start = None; for (offset, line) in line_offsets(source) { if !header_starts(line) { continue; } // `component Foo inherits Bar {` — the word after `component`. let declared = line .trim_start_matches("export ") .trim_start_matches("component ") .split_whitespace() .next(); match start { None if declared == Some(name) => start = Some(offset), Some(from) => return Some(&source[from..offset]), None => {} } } start.map(|from| &source[from..]) } /// Each line of `source` with its byte offset. fn line_offsets(source: &str) -> impl Iterator { let mut offset = 0; source.lines().map(move |line| { let at = offset; offset += line.len() + 1; (at, line) }) } /// Whether a block sets a property — the name at the start of a line, followed /// by what Slint puts after a name it is binding to. /// /// Three forms, and the third is easy to forget: `name:` for a property, /// `name =>` for a callback, and `name(arg) =>` for a callback that takes one. /// `accessible-action-set-value` is the only member of the third group here and /// leaving it out made this test report the slider as missing the very action /// it declares. /// /// Anchored at the start of the trimmed line so that a *mention* in the prose /// above a component does not count. These files carry more comment than code, /// and several of those comments name the very properties asserted here; a /// substring search would pass on the strength of an explanation of the thing /// that had been deleted, which is the failure `ui_names_no_operation.rs` /// documents at length and the android manifest test strips comments to avoid. fn sets(block: &str, property: &str) -> bool { block.lines().any(|line| { let line = line.trim_start(); line.strip_prefix(property).is_some_and(|rest| { let rest = rest.trim_start(); rest.starts_with(':') || rest.starts_with("=>") || rest.starts_with('(') }) }) } #[test] fn shared_controls_declare_their_roles() { let ui = ui_dir(); let mut failures = Vec::new(); for promise in PROMISES { let path = ui.join(promise.file); let source = read(&path); let Some(body) = component_body(&source, promise.component) else { failures.push(format!( " {} declares no component `{}` — it was renamed or removed, and \ this test then checks nothing", promise.file, promise.component )); continue; }; for property in promise.properties { if !sets(body, property) { failures.push(format!( " {}: `{}` does not set `{property}`", promise.file, promise.component )); } } } assert!( failures.is_empty(), "\n\nNFR-A11Y-2: controls expose names, roles and values to the platform \ accessibility layer.\n\n{}\n\n\ Slint rejects every `accessible-*` property that is not accompanied by an \ `accessible-role`, so the role is what makes the control exist for AT-SPI \ and TalkBack at all, and the actions are what make it usable rather than \ merely readable.\n\n\ These live on the shared components rather than at the call sites \ deliberately: a control annotated where it is used is a control unnamed \ everywhere it is used next. See the preamble to widgets.slint.\n", failures.join("\n") ); } #[test] fn every_slider_track_is_named() { let ui = ui_dir(); let mut files: Vec = fs::read_dir(&ui) .unwrap_or_else(|e| panic!("cannot read {}: {e}", ui.display())) .map(|entry| entry.expect("read dir entry").path()) .filter(|p| p.extension().and_then(|e| e.to_str()) == Some("slint")) .collect(); files.sort(); let mut instantiations = 0usize; let mut unnamed = Vec::new(); for path in &files { let source = read(path); let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("?"); // The declaration itself, in controls.slint, is not an instantiation. for (offset, line) in line_offsets(&source) { if line.trim_start() != "SliderTrack {" { continue; } instantiations += 1; let block = &source[offset..offset + block_len(&source[offset..])]; if !sets(block, "label") { let number = source[..offset].lines().count() + 1; unnamed.push(format!(" {name}:{number}")); } } } // A scan that found nothing passes for the wrong reason. Four tracks are // instantiated today — the develop panel's three shapes and the settings // page's row — and the floor sits below that and well above zero, so // removing one is a code change and not a silent scan failure. assert!( instantiations >= 3, "found only {instantiations} `SliderTrack` instantiations — the scan is \ matching nothing, and a scan over nothing passes" ); assert!( unnamed.is_empty(), "\n\nNFR-A11Y-2: a slider that does not say what it adjusts.\n\n{}\n\n\ `SliderTrack` cannot name itself — it is handed four numbers and knows \ nothing about what they mean — so a track without a `label` announces as \ \"slider, 0.35\". The develop column holds thirty-six of them in the \ colour mixer alone, which is thirty-six controls a screen reader cannot \ tell apart.\n\n\ Pass the same string the row is already drawing above the track.\n", unnamed.join("\n") ); } /// The length of the brace-delimited block beginning at the start of `rest`, /// including both braces. /// /// Braces inside string literals are not handled, and do not occur inside a /// `SliderTrack` instantiation — the only strings there are labels and units. fn block_len(rest: &str) -> usize { let mut depth = 0usize; for (i, c) in rest.char_indices() { match c { '{' => depth += 1, '}' => { depth -= 1; if depth == 0 { return i + 1; } } _ => {} } } rest.len() }