B1 Edit mode is never brought back by a reload, ends when the app is hidden, and only a tap edits: a switch or dwell choice still speaks, and the edit bar's buttons join the message bar's scan group, so Done is in reach. B2 Repeat plays a recording again only for the words it was played for. B3 The cell editor shows the words as the person sees them; words typed in another language are the person's own (Cell::lang), never glossed, and keep the picture they had. H1 The microphone is let go however a recording ends: the cell editor and Message banking closing (also while it is still being asked for), the page hidden or left, a recorder that fails to start. H2 Recordings play through the one AudioContext (unlocked by the first tap or key), so dwell, gamepad and timed switch presses play them on iPad; speech and a recording never overlap; the newest are decoded ahead. H3 Renaming, deleting and putting a recording on a board, Show hidden words and Profiles are behind the lock; Delete puts a recording in a bin for 30 days, with Put back. H4 Message banking says recordings are kept in this browser and can be cleared, asks for a backup until one is made, after every five new recordings and when the browser will not keep the site's storage, and on iPad and iPhone suggests the Home Screen. M1 Import Replace is undoable and says when words would move; a backup's settings come back if the person wants them. M2 Undo puts back only what an edit changed (inverse operations): usage counts and other changes stay. M3 Another tab's saved boards and home are taken as they change; media changes are broadcast. M4 Backups are written one recording at a time into a Blob; a backup file is parsed once; imported media is keyed compactly; package files are not copied. M5 Every cell that says or opens something needs words. M6 Show hidden words is for now: not kept over a reload. Also: no remote pictures are loaded; resize up to 50; new boards made only for a cell that is saved; page set counts (15, 11/33/66, 35); adding a set no longer makes it home by default; an idle relock; the lock's copy (no PIN used elsewhere) and a notice after the long hold cleared it; Type to talk at setup, and the Medical set is not coached as a beginner's; profiles are kept and say what is per profile; Aphasia supports is in the tools (its Reduced complexity switch, which did nothing, is gone); the Fitzgerald set's other category boards are linked from a new last row. Co-Authored-By: Claude Opus 5.5 <[email protected]>
149 lines
7.4 KiB
Rust
149 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 `lookup_for_cell`, 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::{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"),
|
|
("Social", "navigation cell naming a board category; no set has a symbol for social words"),
|
|
("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) across every layout.
|
|
fn default_cells() -> Vec<(String, CellAction)> {
|
|
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 {
|
|
let label = match &cell.content {
|
|
CellContent::Text(label) | CellContent::Symbol { label, .. } | CellContent::Photo { label, .. } => label,
|
|
CellContent::Blank => continue,
|
|
};
|
|
if seen.insert((label.clone(), action_kind(&cell.action))) {
|
|
cells.push((label.clone(), cell.action.clone()));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
cells
|
|
}
|
|
|
|
fn resolve(label: &str, action: &CellAction) -> Option<SymbolRef> {
|
|
symbols::lookup_for_cell(label, action, 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 (label, action) in &cells {
|
|
let listed = UNMAPPED.iter().any(|(l, _)| l == label);
|
|
match (resolve(label, action), listed) {
|
|
(None, false) => missing.push(label.clone()),
|
|
(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(|(l, _)| l == 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 (label, action) in &cells {
|
|
if matches!(action, CellAction::Navigate(_)) && symbols::normalize_concept(label) == "back" {
|
|
let found = resolve(label, action).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(|(l, a)| resolve(l, a).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());
|
|
}
|