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]>
152 lines
7.4 KiB
Rust
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());
|
|
}
|