// 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" ); }