Files
DarkRoom/ui/dr-ui/tests/status_is_not_colour_alone.rs
dtourolle f1db919b9d Fail a test when a star, a flag or a label differs by colour alone
NFR-A11Y-3 was argued in comments beside the rating strip, the
pick/reject mark and the focus-peaking chips, and nothing would have
failed if an edit made a set star differ from an unset one only in tint.
Only the clipping readout had a test.

These read the markup, as the accessibility-name tests do, since a
rendered window cannot be asked what a colour-blind reader sees. The
star and the flag must choose their glyph from their state, and the
glyphs they choose must be different drawings in icons.slint. The peaking
chips must be distinct words that reach the screen as text. Each colour
label must carry its own letter and the name the catalog's code stands
for, the mark must draw the letter, and the grid cell, the filter chips
and develop must draw the mark or the name. Breaking any of these by
hand makes the matching test fail.
2026-09-24 21:52:23 -04:00

246 lines
8.5 KiB
Rust

// TRACES: NFR-A11Y-3
//! No status is told apart by hue alone.
//!
//! NFR-A11Y-3 was built into four controls — the rating strip, the
//! pick/reject mark, the focus-peaking colour chips and the colour labels —
//! and argued in comments beside each, with nothing to fail if a later edit
//! made one of them differ only in tint. An edit like that compiles, renders
//! well to most eyes, and passes every other test.
//!
//! So this reads the markup, for the reason `ui_controls_are_accessible.rs`
//! does: there is no way to ask a rendered Slint window what a colour-blind
//! reader would see, and the property worth defending is structural. For each
//! control it asserts that **what varies with the state is something other
//! than colour** — a glyph's name, a letter, a word — and that the glyphs so
//! named really are different drawings.
use std::fs;
use std::path::Path;
fn ui(file: &str) -> String {
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("ui").join(file);
fs::read_to_string(&path).unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display()))
}
/// The body of one top-level component or global, from its header to the
/// next top-level declaration. The same rule `ui_controls_are_accessible.rs`
/// uses: everything here is declared at column zero.
fn body<'a>(source: &'a str, name: &str) -> &'a str {
let is_header = |line: &str| {
let l = line.trim_start_matches("export ");
l.starts_with("component ") || l.starts_with("global ") || l.starts_with("struct ")
};
let declared = |line: &str| {
line.trim_start_matches("export ")
.split_whitespace()
.nth(1)
.map(str::to_string)
};
let mut offset = 0;
let mut start = None;
for line in source.lines() {
if is_header(line) {
match start {
None if declared(line).as_deref() == Some(name) => start = Some(offset),
Some(from) => return &source[from..offset],
None => {}
}
}
offset += line.len() + 1;
}
let from = start.unwrap_or_else(|| panic!("no component `{name}`"));
&source[from..]
}
/// Code lines only: the prose in these files names the very properties
/// asserted here, and a mention in a comment is not a binding.
fn code_lines(block: &str) -> impl Iterator<Item = &str> {
block
.lines()
.map(str::trim)
.filter(|l| !l.starts_with("//"))
}
/// Every string literal on a line.
fn literals(line: &str) -> Vec<String> {
line.split('"')
.enumerate()
.filter(|(i, _)| i % 2 == 1)
.map(|(_, s)| s.to_string())
.collect()
}
/// The `name:` binding of the `Icon` a component draws its state with — the
/// one line that has to depend on `state`.
fn icon_names_for(block: &str, state: &str) -> Vec<String> {
let line = code_lines(block)
.find(|l| l.starts_with("name:") && l.contains(state))
.unwrap_or_else(|| {
panic!(
"no icon's `name:` depends on `{state}` — the state is no longer \
carried by which glyph is drawn, only by how it is tinted"
)
});
let mut names = literals(line);
names.dedup();
names
}
/// The drawing an icon name selects in icons.slint, less its name line.
fn glyph(name: &str) -> String {
let icons = ui("icons.slint");
let header = format!("if root.name == \"{name}\"");
let from = icons
.find(&header)
.unwrap_or_else(|| panic!("icons.slint draws no `{name}`"));
let rest = &icons[from..];
let end = rest.find("\n }").expect("an icon block ends");
rest[header.len()..end].to_string()
}
/// Two glyph names that are genuinely two drawings.
fn assert_distinct_glyphs(names: &[String], what: &str) {
assert!(
names.len() >= 2,
"{what}: one glyph for every state, so only the tint tells them apart: {names:?}"
);
for (i, a) in names.iter().enumerate() {
for b in &names[i + 1..] {
assert_ne!(
glyph(a),
glyph(b),
"{what}: `{a}` and `{b}` are the same drawing, so they differ in colour alone"
);
}
}
}
#[test]
fn a_set_star_and_an_unset_one_are_different_shapes() {
let names = icon_names_for(body(&ui("library.slint"), "StarStrip"), "root.rating");
assert!(names.contains(&"star".to_string()), "{names:?}");
assert_distinct_glyphs(&names, "the rating strip");
}
#[test]
fn a_pick_and_a_reject_are_different_shapes() {
let names = icon_names_for(body(&ui("library.slint"), "FlagMark"), "root.flag");
assert_distinct_glyphs(&names, "the pick/reject mark");
}
#[test]
fn the_peaking_colours_are_chosen_by_name() {
// The chips are words, and each word reaches the screen as text.
let panel = ui("peaking.slint");
let row = code_lines(&panel)
.find(|l| l.starts_with("options:") && l.contains("\"Red\""))
.expect("the peaking colour chips");
let words = literals(row);
assert!(words.len() >= 2, "{words:?}");
let mut unique = words.clone();
unique.sort();
unique.dedup();
assert_eq!(
unique.len(),
words.len(),
"two chips share a word: {words:?}"
);
assert!(
words.iter().all(|w| w.chars().any(char::is_alphabetic)),
"a chip without a word is a swatch: {words:?}"
);
let controls = ui("controls.slint");
for host in ["Segmented", "ChipGrid"] {
assert!(
code_lines(body(&controls, host)).any(|l| l == "label: option;"),
"{host} no longer hands each option to its chip as a label"
);
}
assert!(
code_lines(body(&controls, "ChoiceChip")).any(|l| l == "text: root.label;"),
"ChoiceChip no longer draws its label as text"
);
}
/// The array bound to `name` in the `Labels` global, by code.
fn labels_array(name: &str) -> Vec<String> {
let source = ui("labels.slint");
let line = code_lines(body(&source, "Labels"))
.find(|l| l.contains(&format!("{name}:")) && l.contains('['))
.unwrap_or_else(|| panic!("`Labels.{name}` is not an array literal any more"));
literals(line)
}
#[test]
fn every_colour_label_carries_its_own_letter_and_name() {
// TRACES: FR-CAT-5
let letters = labels_array("letters");
let names = labels_array("names");
assert_eq!(letters.len(), 6, "none and five labels: {letters:?}");
assert_eq!(names.len(), 6, "{names:?}");
let marked = &letters[1..];
assert!(
marked.iter().all(|l| !l.trim().is_empty()),
"a label with no letter is told apart by its fill alone: {letters:?}"
);
let mut unique = marked.to_vec();
unique.sort();
unique.dedup();
assert_eq!(unique.len(), 5, "two labels share a letter: {letters:?}");
// The codes are the catalog's, and each name is the colour that code
// stores — so the word a chip says is the label it filters on.
use dr_types::ColourLabel::*;
for (label, word) in [
(Red, "Red"),
(Yellow, "Yellow"),
(Green, "Green"),
(Blue, "Blue"),
(Purple, "Purple"),
] {
let code = dr_catalog::rating::label_code(label) as usize;
assert_eq!(names[code], word);
assert!(
word.starts_with(&letters[code]),
"{word} is marked {}",
letters[code]
);
}
}
#[test]
fn the_label_mark_draws_the_letter_and_every_label_surface_uses_it() {
// TRACES: FR-CAT-5
let labels = ui("labels.slint");
assert!(
code_lines(body(&labels, "LabelMark"))
.any(|l| l.starts_with("text:") && l.contains("Labels.letters[root.code]")),
"LabelMark no longer draws its label's letter"
);
// The picker names every choice in words beside the mark.
assert!(
code_lines(body(&labels, "LabelPicker"))
.any(|l| l.starts_with("text:") && l.contains("Labels.names[code]")),
"LabelPicker's choices lost their names"
);
// Each place a label is shown draws the mark, never a bare fill.
let library = ui("library.slint");
assert!(
library.contains("code: cell.label;"),
"the grid cell no longer draws its label with LabelMark"
);
let widgets = ui("widgets.slint");
assert!(
code_lines(body(&widgets, "FilterChip")).any(|l| l == "code: root.colour-label;"),
"the label chips no longer draw the mark"
);
let develop = ui("develop.slint");
assert!(
develop.contains("Labels.names[root.colour-label]"),
"develop no longer names the open photograph's label in words"
);
}