//! 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 { 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 { 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 ["