The author could not run cargo; this is the formatter's first pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
333 lines
12 KiB
Rust
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()
|
|
}
|