open-standards/aac/aac-rs/crates/aac-web/tests/symbol_coverage.rs
Transaction Science 314022de28 Merge aac/visual-scenes into aac/integ-1015
Conflicts:
- aac-core/src/obf.rs (module doc): origin/main's citation fix (560c3e58,
  "SPEC §2.1, §3 item 3") kept, with visual-scenes' lines on scenes and
  hotspots after it. Every section obf.rs cites (§2, §2.1, §3, §4.1, §8)
  exists in the merged SPEC.md.
- data/pagesets/index.json (generated): neither side taken; rebuilt with
  `python3.14 tools/pagesets/catalog.py --write` on the merged sources
  (visual-scenes' families with symbols-nc's rebuilt symbol sets).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 12:40:56 -04:00

152 lines
7.4 KiB
Rust

//! Every cell on every default board layout has a symbol in the default set
//! (Mulberry, falling back to OpenMoji then Blissymbolics), unless its label
//! is listed below with the reason it is intentionally unmapped. Cells are
//! resolved the way the app draws them, through `symbol_for_cell`: the
//! symbol chosen for the cell ("I need help" draws help), else the one its
//! words and action name, so a navigation "Back" is only covered by a
//! navigation symbol, never by the body part. The sprites the index points at must exist and contain every
//! symbol id.
use std::collections::BTreeSet;
use std::path::Path;
use aac_core::symbols::{self, SymbolRef, SymbolSet};
use aac_core::{Cell, CellAction, CellContent};
use aac_web::defaults::{BoardLayout, create_boards_for_layout};
/// Labels intentionally left without a symbol, and why. Keep this short: a
/// new default label should get a symbol (tools/symbols/overrides.toml)
/// rather than an entry here.
const UNMAPPED: &[(&str, &str)] = &[
("?", "punctuation-only label: the grammar-modifier cell shows the question mark itself"),
("Clear", "action cell; no set has a confident symbol for erasing the message"),
("Core", "navigation cell named after the board, not a word to depict"),
("Describing", "navigation cell naming a board category; no set has a symbol for describing words"),
("Needs", "navigation cell naming a board category; no set has a confident symbol for needs"),
("Nouns", "navigation cell naming a board category; no set has a symbol for nouns"),
("Descriptors", "navigation cell naming a board category; no set has a symbol for describing words"),
("Prepositions", "navigation cell naming a board category; no set has a symbol for prepositions"),
("can I", "multi-word phrase with no single-symbol equivalent in any set"),
("do you", "multi-word phrase with no single-symbol equivalent in any set"),
("is it", "multi-word phrase with no single-symbol equivalent in any set"),
("what's that", "multi-word phrase with no single-symbol equivalent in any set"),
("where is", "multi-word phrase with no single-symbol equivalent in any set"),
("who is", "multi-word phrase with no single-symbol equivalent in any set"),
("why not", "multi-word phrase with no single-symbol equivalent in any set"),
("my turn", "the only candidate (Bliss 'turn, play') would draw 'my turn' and 'your turn' identically"),
("your turn", "the only candidate (Bliss 'turn, play') would draw 'my turn' and 'your turn' identically"),
("excuse me", "Bliss 'excuse' is the noun and 'pardon' asks for a repeat; neither is the polite phrase"),
("worse", "no set has one: BCI-AV 2026 has 'better' but no 'worse', and a thumbs-down would read as 'bad'"),
];
fn all_layouts() -> [BoardLayout; 4] {
// Exhaustive match: a new layout must be added to the array below.
match BoardLayout::Basic {
BoardLayout::Basic | BoardLayout::Fitzgerald | BoardLayout::CoreFringe | BoardLayout::Medical => {}
}
[BoardLayout::Basic, BoardLayout::Fitzgerald, BoardLayout::CoreFringe, BoardLayout::Medical]
}
/// How a cell's picture is chosen: the part of its action that
/// `lookup_for_cell` looks at.
fn action_kind(action: &CellAction) -> &'static str {
match action {
CellAction::Navigate(_) => "navigate",
CellAction::SpeakAll | CellAction::Clear | CellAction::Backspace | CellAction::Predict => "action",
CellAction::Speak(_) | CellAction::Modifier(_) | CellAction::Buffer(_) => "content",
}
}
/// One representative cell per (label, action kind, chosen symbol) across
/// every layout.
fn default_cells() -> Vec<Cell> {
let mut seen = BTreeSet::new();
let mut cells = Vec::new();
for layout in all_layouts() {
let (boards, _) = create_boards_for_layout(layout);
for board in boards.values() {
for cell in &board.cells {
if matches!(cell.content, CellContent::Blank) {
continue;
}
if seen.insert((cell.content.label().to_string(), action_kind(&cell.action), cell.symbol.clone())) {
cells.push(cell.clone());
}
}
}
}
cells
}
/// The symbol the app draws for the cell (grid_cell.rs).
fn resolve(cell: &Cell) -> Option<SymbolRef> {
symbols::symbol_for_cell(cell, SymbolSet::Mulberry)
}
#[test]
fn every_default_cell_resolves_in_the_default_set() {
let cells = default_cells();
assert!(cells.len() > 150, "expected the full default vocabulary, got {}", cells.len());
let mut missing = Vec::new();
for cell in &cells {
let label = cell.content.label();
let listed = UNMAPPED.iter().any(|(l, _)| *l == label);
match (resolve(cell), listed) {
(None, false) => missing.push(label.to_string()),
(Some(found), true) => panic!("{label:?} is allow-listed as unmapped but resolves to {found:?}"),
_ => {}
}
}
assert!(missing.is_empty(), "default cells with no symbol and no allow-list reason: {missing:?}");
for (label, reason) in UNMAPPED {
assert!(cells.iter().any(|c| c.content.label() == *label), "stale allow-list entry {label:?}");
assert!(!reason.is_empty());
}
}
#[test]
fn navigation_back_is_never_the_body_part() {
let cells = default_cells();
let body = symbols::lookup("back", SymbolSet::Mulberry).expect("medical 'back' has a symbol");
let mut nav_backs = 0;
for cell in &cells {
let label = cell.content.label();
if matches!(cell.action, CellAction::Navigate(_)) && symbols::normalize_concept(label) == "back" {
let found = resolve(cell).expect("navigation Back has a symbol");
assert_ne!(found.id, body.id, "navigation {label:?} shows the body part");
nav_backs += 1;
}
}
assert!(nav_backs > 0, "expected Fitzgerald's navigation Back cells");
}
#[test]
fn most_default_cells_resolve_without_fallback() {
// Mulberry has no pronouns, articles or most social words, so those come
// from OpenMoji/Bliss; and where it draws a man's face or a child for a
// word that names no gender or age (feelings, Speak, eat, doctor...),
// OpenMoji's neutral picture is used on purpose (SPEC §4.3,
// tools/symbols/overrides.toml). Everything else on the default boards
// should be Mulberry's own. Guard against the mapping silently eroding.
let cells = default_cells();
let own = cells.iter().filter(|c| resolve(c).is_some_and(|s| !s.fallback)).count();
assert!(own * 100 >= cells.len() * 50, "only {own}/{} default cells use Mulberry's own symbols", cells.len());
}
#[test]
fn sprites_exist_and_contain_every_symbol() {
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("public/symbols");
for set in SymbolSet::ALL {
let file = &symbols::set_info(set).file;
let sprite = std::fs::read_to_string(dir.join(file)).unwrap_or_else(|e| panic!("{file}: {e}"));
for bad in ["<script", "<style", "javascript:", " class=", "<foreignObject", "<image"] {
assert!(!sprite.contains(bad), "{file} contains {bad}");
}
for (concept, sets) in symbols::concepts() {
if let Some(id) = sets.get(&set) {
assert!(sprite.contains(&format!("<symbol id=\"{id}\" ")), "{file} lacks {id} ({concept})");
}
}
}
assert!(dir.join("LICENSES.md").exists());
}