diff --git a/ui/dr-ui/tests/status_is_not_colour_alone.rs b/ui/dr-ui/tests/status_is_not_colour_alone.rs new file mode 100644 index 0000000..e9a4cc7 --- /dev/null +++ b/ui/dr-ui/tests/status_is_not_colour_alone.rs @@ -0,0 +1,245 @@ +// 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 { + block + .lines() + .map(str::trim) + .filter(|l| !l.starts_with("//")) +} + +/// Every string literal on a line. +fn literals(line: &str) -> Vec { + 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 { + 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 { + 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" + ); +}