Files
DarkRoom/ui/dr-ui/tests/ui_controls_are_accessible.rs
dtourolleandClaude Opus 5 158642d4ce Reflow the accessibility test the way rustfmt wants it
The author could not run cargo; this is the formatter's first pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:50:51 +02:00

333 lines
12 KiB
Rust

// 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<Item = (usize, &str)> {
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<PathBuf> = 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()
}