open-standards/aac/aac-rs/crates/aac-core/tests/pagesets.rs
Transaction Science e7ccf3c636 fix(aac): updating, restoring or importing takes no word away. A hide kept by an earlier version is read as that version showed the board
The released app showed every key's word, so a person who saw a word twice may
have hidden one of its two keys. When this version first read that hide, it
judged the board as it shows it now, one key per word, and recorded the hide
as taking the word away. The other key was then left out for the hidden one.
For example, in Core and topics in Spanish, a hidden "no" left no "no"
anywhere.

- record_kept_hides is the settle step's rule (aac-web shell.rs): a hide
  found without a record on a board kept by an earlier version takes the word
  only where the word showed at no other key then (only_place and no shown
  mate). A key still waiting for a later stage counts as before, so in Finnish
  with both "a" and "one" hidden, yksi stays hidden.
- The cell editor's hides keep today's rule (record_hides).
- Tests (aac-core tests/released_hides.rs) use the released app's own data
  (tests/fixtures/released_r8_hides.json, from review-probes/vsd/r8). All
  seven cases, from the stored board (update and backup restore) and from the
  OBZ export, show the word once where the released app showed it, and
  nowhere where it showed nowhere. With today's rule in the settle step, the
  four failing cases fail.
- SPEC §4.1 item 5 is updated.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 10:59:09 -04:00

1312 lines
50 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! The open page sets, built from their sources by `tools/pagesets/build.py`
//! (into a temporary directory, or the one `AAC_PAGESETS_DIR` names), imported
//! the way the app imports a `.obz`, and held to their designs:
//!
//! - every package imports through `obf::import_obz` with each cell where the
//! build put it, saying or doing what it should, locked, and nothing lost
//! (on a version without hidden cells, the only warning is that hidden words
//! are shown — and a hidden word is kept hidden once the cell can say so);
//! - every package passes an independent OBF validator (the
//! `open_board_format` crate), so any OBF app can open it;
//! - each word class is one the app reads, and the app's own where it has one;
//! - every symbol named resolves in the app's symbol index, and is the very
//! symbol the app draws for that cell;
//! - each button shows and says what the app's own export of it would (one
//! difference on purpose: a Back cell carries the control's word, never the
//! gloss of "back", SPEC §5.6 item 5);
//! - Growing Core: the board is full at every size, nothing that appeared ever
//! goes, and every word's buttons on every size share one point of the
//! screen, never more than one cell of the smaller board apart; every page
//! has Back in the top-left corner; reveal variants hide exactly the later
//! words, in place;
//! - Fixed Paths: one path per word, of 1 to 3 hits, the same words at every
//! size; a page's Back is in the place of the key that opened it; a filter
//! hides exactly the words outside it, and a key only when nothing behind it
//! is shown;
//! - languages are labelled honestly (SPEC §4.1);
//! - the committed catalogue (`data/pagesets/index.json`) lists exactly the
//! packages the sources build, at their sizes; `pagesets::reveal` hides at
//! run time exactly what a reveal package hides.
//!
//! The packages are not committed: the build needs Python 3.11+ with PyYAML.
//! `AAC_PYTHON` names the interpreter. Without one (and no
//! `AAC_PAGESETS_DIR`), these tests say so and pass — except in CI (`CI`
//! set), where they fail: a job without the tools tests nothing;
//! `tools/pagesets/catalog.py --check`, which the site's build runs, holds the
//! same sources to the committed catalogue.
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use std::path::PathBuf;
use std::sync::OnceLock;
use aac_core::obf::{self, ObfExportOptions, ObfImport};
use aac_core::symbols::{self, SymbolSet};
use aac_core::{Board, BoardId, Cell, CellAction, CellContent, GrammarModifier};
use serde_json::Value;
use uuid::Uuid;
/// The directory the packages were built into, or `None` when they cannot
/// be built here.
fn built() -> Option<&'static PathBuf> {
static DIR: OnceLock<Option<PathBuf>> = OnceLock::new();
DIR.get_or_init(|| {
if let Ok(d) = std::env::var("AAC_PAGESETS_DIR") {
return Some(PathBuf::from(d));
}
let tools = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../tools/pagesets/build.py");
let ok = |py: &str| {
std::process::Command::new(py)
.args(["-c", "import sys, yaml; sys.exit(0 if sys.version_info >= (3, 11) else 1)"])
.output()
.is_ok_and(|o| o.status.success())
};
let named = std::env::var("AAC_PYTHON").ok();
let Some(py) = named.iter().map(String::as_str).chain(["python3.14", "python3.13", "python3.12", "python3.11", "python3"]).find(|p| ok(p)) else {
// In CI a missing Python is a broken job, never a pass: every
// test here would test nothing.
assert!(
std::env::var_os("CI").is_none_or(|v| v.is_empty()),
"page sets: CI has no Python 3.11+ with PyYAML (set AAC_PYTHON): the page-set tests cannot run"
);
eprintln!("page sets: no Python 3.11+ with PyYAML here to build them; set AAC_PYTHON or AAC_PAGESETS_DIR");
return None;
};
// Cargo's own scratch space for integration tests, in the target
// directory: rebuilt on every run.
let out = PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join("pagesets");
let _ = std::fs::remove_dir_all(&out);
let res = std::process::Command::new(py).arg(&tools).arg("--out").arg(&out).output().expect("run build.py");
assert!(
res.status.success(),
"tools/pagesets/build.py failed:\n{}{}",
String::from_utf8_lossy(&res.stdout),
String::from_utf8_lossy(&res.stderr)
);
Some(out)
})
.as_ref()
}
fn dir() -> PathBuf {
built().expect("built").clone()
}
fn read_json(name: &str) -> Value {
let text = std::fs::read_to_string(dir().join(name)).unwrap_or_else(|e| panic!("{name}: {e}"));
serde_json::from_str(&text).expect("valid JSON")
}
/// One cell as the build recorded it in `index.json`.
#[derive(Debug, Clone)]
struct Expect {
key: String,
row: u16,
col: u16,
action: String,
extra: Option<String>,
hidden: bool,
control: Option<String>,
}
struct ExpectBoard {
id: Uuid,
name: String,
rows: u16,
cols: u16,
cells: Vec<Expect>,
}
struct Package {
file: String,
set: String,
size: String,
variant: String,
root: Uuid,
expect: Vec<ExpectBoard>,
/// The package as the app imports it.
import: ObfImport,
/// Each board's raw OBF JSON, by board id.
raw: HashMap<Uuid, Value>,
manifest: Value,
}
impl Package {
fn board(&self, id: Uuid) -> &Board {
self.import
.boards
.iter()
.find(|b| b.id.0 == id)
.unwrap_or_else(|| panic!("{}: board {id} not imported", self.file))
}
fn root(&self) -> &Board {
self.board(self.root)
}
fn has_hidden(&self) -> bool {
self.expect.iter().any(|b| b.cells.iter().any(|c| c.hidden))
}
}
fn s(v: &Value) -> String {
v.as_str().map(str::to_string).unwrap_or_default()
}
fn packages() -> &'static Vec<Package> {
static PACKAGES: OnceLock<Vec<Package>> = OnceLock::new();
PACKAGES.get_or_init(|| {
if built().is_none() {
return Vec::new();
}
let index = read_json("cells.json");
let mut out = Vec::new();
for p in index["packages"].as_array().expect("packages") {
let file = s(&p["file"]);
let bytes = std::fs::read(dir().join(&file)).unwrap_or_else(|e| panic!("{file}: {e}"));
let import =
obf::import_obz(&bytes).unwrap_or_else(|e| panic!("{file} does not import: {e}"));
let mut raw = HashMap::new();
let mut manifest = Value::Null;
for entry in obf::zip_entries(&bytes).expect("zip") {
let v: Value = serde_json::from_slice(&entry.data).expect("json entry");
if entry.name == "manifest.json" {
manifest = v;
} else {
raw.insert(
Uuid::parse_str(v["id"].as_str().expect("board id")).expect("uuid"),
v,
);
}
}
let expect = p["boards"]
.as_array()
.expect("boards")
.iter()
.map(|b| ExpectBoard {
id: Uuid::parse_str(b["id"].as_str().expect("id")).expect("uuid"),
name: s(&b["name"]),
rows: b["rows"].as_u64().expect("rows") as u16,
cols: b["cols"].as_u64().expect("cols") as u16,
cells: b["cells"]
.as_array()
.expect("cells")
.iter()
.map(|c| Expect {
key: s(&c[0]),
row: c[1].as_u64().expect("row") as u16,
col: c[2].as_u64().expect("col") as u16,
action: s(&c[3]),
extra: c[4].as_str().map(str::to_string),
hidden: c[5].as_bool().expect("hidden"),
control: c[6].as_str().map(str::to_string),
})
.collect(),
})
.collect();
out.push(Package {
file,
set: s(&p["set"]),
size: s(&p["size"]),
variant: s(&p["variant"]),
root: Uuid::parse_str(p["root"].as_str().expect("root")).expect("uuid"),
expect,
import,
raw,
manifest,
});
}
assert!(
out.len() >= 17,
"expected the 11 Growing Core sizes and 3 Fixed Paths sizes, with their variants"
);
out
})
}
fn label(cell: &Cell) -> &str {
match &cell.content {
CellContent::Text(t) => t,
CellContent::Symbol { label, .. } | CellContent::Photo { label, .. } => label,
CellContent::Blank => "",
}
}
fn modifier(name: &str) -> GrammarModifier {
match name {
"plural" => GrammarModifier::Plural,
"past" => GrammarModifier::PastTense,
"present-progressive" => GrammarModifier::PresentProgressive,
"future" => GrammarModifier::FutureTense,
"possessive" => GrammarModifier::Possessive,
"negation" => GrammarModifier::Negation,
"question" => GrammarModifier::Question,
other => panic!("unknown modifier {other}"),
}
}
fn same_action(a: &CellAction, b: &CellAction) -> bool {
serde_json::to_value(a).expect("action") == serde_json::to_value(b).expect("action")
}
#[test]
fn every_package_imports_with_its_positions_and_actions() {
if built().is_none() {
return;
}
for p in packages() {
assert_eq!(
p.import.root.map(|r| r.0),
Some(p.root),
"{}: the root board",
p.file
);
assert_eq!(
p.import.boards.len(),
p.expect.len(),
"{}: board count",
p.file
);
// Nothing is lost. A version that has no hidden cells shows them,
// and says so; that is the only warning allowed.
for w in &p.import.warnings {
assert!(
p.has_hidden() && w.ends_with("hidden buttons are shown."),
"{}: unexpected import warning: {w}",
p.file
);
}
for eb in &p.expect {
let board = p.board(eb.id);
assert_eq!(board.name, eb.name, "{}: board name", p.file);
assert_eq!(
(board.layout.rows, board.layout.cols),
(eb.rows, eb.cols),
"{} {}: grid",
p.file,
eb.name
);
assert!(board.locked, "{} {}: board locked", p.file, eb.name);
assert_eq!(
board.cells.len(),
eb.cells.len(),
"{} {}: cell count",
p.file,
eb.name
);
for e in &eb.cells {
let cell = board.cell_at(e.row, e.col).unwrap_or_else(|| {
panic!(
"{} {}: no cell at {},{} for {}",
p.file, eb.name, e.row, e.col, e.key
)
});
assert_eq!(
label(cell),
e.key,
"{} {}: the words at {},{}",
p.file,
eb.name,
e.row,
e.col
);
assert!(
matches!(cell.content, CellContent::Text(_)),
"{} {}: {} is stored as words",
p.file,
eb.name,
e.key
);
assert!(cell.locked, "{} {}: {} locked", p.file, eb.name, e.key);
let want = match e.action.as_str() {
"speak" => CellAction::Speak(e.key.clone()),
"navigate" => {
let target =
Uuid::parse_str(e.extra.as_deref().expect("target")).expect("uuid");
p.board(target); // it opens a board of the package
CellAction::Navigate(BoardId(target))
}
"modifier" => {
CellAction::Modifier(modifier(e.extra.as_deref().expect("modifier")))
}
other => panic!("unknown action {other}"),
};
assert!(
same_action(&cell.action, &want),
"{} {}: {} does {:?}, not {:?}",
p.file,
eb.name,
e.key,
cell.action,
want
);
// Hidden in place: once a cell can say so, it does.
let saved = serde_json::to_value(cell).expect("cell");
match saved.get("hidden") {
Some(h) => assert_eq!(
h.as_bool(),
Some(e.hidden),
"{} {}: {} hidden",
p.file,
eb.name,
e.key
),
None if e.hidden => assert!(
p.import
.warnings
.iter()
.any(|w| w.ends_with("hidden buttons are shown.")),
"{}: a hidden word was neither kept hidden nor reported",
p.file
),
None => {}
}
let raw_hidden = raw_button(p, eb.id, &e.key)
.get("hidden")
.and_then(Value::as_bool)
.unwrap_or(false);
assert_eq!(
raw_hidden, e.hidden,
"{} {}: {} OBF hidden",
p.file, eb.name, e.key
);
}
}
}
}
fn raw_button<'a>(p: &'a Package, board: Uuid, key: &str) -> &'a Value {
p.raw[&board]["buttons"]
.as_array()
.expect("buttons")
.iter()
.find(|b| b["ext_aurasense_provenance"]["source_label"] == key)
.unwrap_or_else(|| panic!("{}: no button {key}", p.file))
}
#[test]
fn every_package_passes_an_independent_open_board_format_validator() {
if built().is_none() {
return;
}
// The open_board_format crate (Unlicense OR MIT), not this app's reader:
// any OBF-capable app should be able to open these sets.
for p in packages() {
let bytes = std::fs::read(dir().join(&p.file)).expect("package");
let report =
open_board_format::validate_obz(&bytes).unwrap_or_else(|e| panic!("{}: {e:?}", p.file));
let errors: Vec<String> = report
.issues
.iter()
.filter(|i| i.severity == open_board_format::ValidationSeverity::Error)
.map(|i| format!("{}: {}", i.path, i.message))
.collect();
assert!(errors.is_empty(), "{}: {errors:?}", p.file);
}
}
#[test]
fn word_classes_are_ones_the_app_reads_and_agree_with_its_vocabulary() {
if built().is_none() {
return;
}
let exceptions = read_json("cells.json")["class_exceptions"].clone();
let mut checked = 0;
for p in packages() {
for board in p.raw.values() {
for b in board["buttons"].as_array().expect("buttons") {
let key = s(&b["ext_aurasense_provenance"]["source_label"]);
let speaks = b.get("vocalization").is_some();
match b.get("ext_aurasense_word_class").and_then(Value::as_str) {
Some(name) => {
let class = obf::parse_word_class(name)
.unwrap_or_else(|| panic!("{}: {key}: unknown class {name}", p.file));
assert!(
speaks,
"{}: {key}: a page key or control has no word class",
p.file
);
if let Some(app) = obf::word_class_of(&key)
&& app != class
{
let ex = &exceptions[&key];
assert!(
ex["ours"] == name && ex["app"] == obf::word_class_name(app),
"{}: {key} is {name} here and {} in the app's vocabulary, with no reason recorded",
p.file,
obf::word_class_name(app)
);
}
checked += 1;
}
None => assert!(
!speaks || obf::word_class_of(&key).is_none(),
"{}: {key} has a class in the app but none here",
p.file
),
}
}
}
}
assert!(checked > 1000, "word classes checked: {checked}");
}
#[test]
fn every_symbol_resolves_and_is_the_one_the_app_draws() {
if built().is_none() {
return;
}
let known: HashSet<(String, String)> = symbols::concepts()
.flat_map(|(_, sets)| {
sets.iter()
.map(|(set, id)| (set.as_str().to_string(), id.clone()))
})
.collect();
let mut with_symbol = 0;
for p in packages() {
for eb in &p.expect {
let board = &p.raw[&eb.id];
let images: HashMap<String, &Value> = board["images"]
.as_array()
.expect("images")
.iter()
.map(|i| (s(&i["id"]), i))
.collect();
let imported = p.board(eb.id);
for e in &eb.cells {
let b = raw_button(p, eb.id, &e.key);
let cell = imported.cell_at(e.row, e.col).expect("cell");
// What the app draws: a chosen concept (Track B), else the
// cell's own words and action.
let app = match b.get("ext_aurasense_symbol").and_then(Value::as_str) {
Some(concept) => {
let found = symbols::lookup(concept, SymbolSet::Mulberry);
assert!(
found.is_some(),
"{}: {}: chosen symbol {concept} is not in the index",
p.file,
e.key
);
found
}
None => symbols::lookup_for_cell(&e.key, &cell.action, SymbolSet::Mulberry),
};
match (b.get("image_id").and_then(Value::as_str), app) {
(Some(image_id), Some(app)) => {
let img = images.get(image_id).unwrap_or_else(|| {
panic!("{}: {}: image {image_id} missing", p.file, e.key)
});
let set = s(&img["symbol"]["set"]);
let file = s(&img["symbol"]["filename"]);
let id = file.strip_suffix(".svg").expect(".svg");
assert!(
known.contains(&(set.clone(), id.to_string())),
"{}: {}: {set}/{id} is not a symbol of the index",
p.file,
e.key
);
assert_eq!(
(set.as_str(), id),
(app.set.as_str(), app.id),
"{}: {}: not the symbol the app draws",
p.file,
e.key
);
let info = symbols::set_info(app.set);
assert_eq!(
img["license"]["type"],
info.license.spdx.as_str(),
"{}: {}: licence",
p.file,
e.key
);
with_symbol += 1;
}
(None, None) => {}
(Some(_), None) => panic!(
"{}: {}: names a symbol the app does not draw",
p.file, e.key
),
(None, Some(app)) => panic!(
"{}: {}: the app draws {} but the file names none",
p.file, e.key, app.id
),
}
}
}
}
assert!(with_symbol > 1000, "buttons with symbols: {with_symbol}");
}
#[test]
fn every_button_says_what_the_apps_own_export_would() {
if built().is_none() {
return;
}
let opts = ObfExportOptions::new("en");
for p in packages().iter().filter(|p| p.variant == "full") {
for board in &p.import.boards {
let again = obf::to_obf_with(board, &opts);
let ours: HashMap<String, &Value> = p.raw[&board.id.0]["buttons"]
.as_array()
.expect("buttons")
.iter()
.map(|b| (s(&b["id"]), b))
.collect();
for b in again["buttons"].as_array().expect("buttons") {
let mine = ours[&s(&b["id"])];
let key = s(&b["label"]);
for field in [
"label",
"vocalization",
"ext_aurasense_spoken",
"background_color",
"border_color",
"ext_aurasense_text_color",
"action",
"ext_aurasense_locked",
] {
assert_eq!(
mine.get(field),
b.get(field),
"{} {}: {key}: {field}",
p.file,
board.name
);
}
// A Back or Home cell: the control's word, never the gloss
// of the English word; both the app's export and the file
// take it from aac-core's nav words.
assert_eq!(
mine.get("translations"),
b.get("translations"),
"{} {}: {key}: translations",
p.file,
board.name
);
if key == "Back" {
assert_eq!(
mine["translations"]["es"]["label"], "Atrás",
"{}: Back in Spanish",
p.file
);
}
}
}
}
}
/// Normalised area of a cell: (v0, v1, u0, u1).
fn area(row: u16, col: u16, rows: u16, cols: u16) -> (f64, f64, f64, f64) {
let (r, c, rs, cs) = (
f64::from(row),
f64::from(col),
f64::from(rows),
f64::from(cols),
);
(r / rs, (r + 1.0) / rs, c / cs, (c + 1.0) / cs)
}
fn ladder() -> Vec<String> {
packages()
.iter()
.filter(|p| p.set == "growing-core" && p.variant == "full")
.map(|p| p.size.clone())
.collect()
}
fn dims(size: &str) -> (u16, u16) {
let (r, c) = size.split_once('x').expect("RxC");
(r.parse().expect("rows"), c.parse().expect("cols"))
}
#[test]
fn growing_core_words_keep_their_place_as_the_board_grows() {
if built().is_none() {
return;
}
let sizes = ladder();
assert_eq!(sizes.len(), 11, "the ladder: {sizes:?}");
// word -> size -> (row, col)
let mut at: BTreeMap<String, BTreeMap<usize, (u16, u16)>> = BTreeMap::new();
for (k, size) in sizes.iter().enumerate() {
let p = packages()
.iter()
.find(|p| p.set == "growing-core" && p.variant == "full" && &p.size == size)
.expect("size");
let root = p.root();
let (rows, cols) = dims(size);
assert_eq!((root.layout.rows, root.layout.cols), (rows, cols));
assert_eq!(
root.cells.len(),
usize::from(rows * cols),
"{size}: the core is full"
);
for c in &root.cells {
at.entry(label(c).to_string())
.or_default()
.insert(k, (c.position.row, c.position.col));
}
}
for (word, places) in &at {
let first = *places.keys().next().expect("a size");
assert_eq!(
places.len(),
sizes.len() - first,
"{word} appears at {} and must stay at every larger size",
sizes[first]
);
// One point of the screen lies in every button the word has.
let (mut v0, mut v1, mut u0, mut u1) = (0.0_f64, 1.0_f64, 0.0_f64, 1.0_f64);
for (&k, &(r, c)) in places {
let (rows, cols) = dims(&sizes[k]);
let a = area(r, c, rows, cols);
v0 = v0.max(a.0);
v1 = v1.min(a.1);
u0 = u0.max(a.2);
u1 = u1.min(a.3);
}
assert!(
v0 < v1 && u0 < u1,
"{word}: its buttons share no point across sizes"
);
// Never more than one cell of the smaller board apart.
for (&a, &(ra, ca)) in places {
for (&b, &(rb, cb)) in places.range(a + 1..) {
let (rsa, csa) = dims(&sizes[a]);
let (rsb, csb) = dims(&sizes[b]);
let dv = ((f64::from(ra) + 0.5) / f64::from(rsa)
- (f64::from(rb) + 0.5) / f64::from(rsb))
.abs();
let du = ((f64::from(ca) + 0.5) / f64::from(csa)
- (f64::from(cb) + 0.5) / f64::from(csb))
.abs();
assert!(
dv * f64::from(rsa) < 1.0 && du * f64::from(csa) < 1.0,
"{word}: moves {dv:.3} down and {du:.3} across from {} to {}",
sizes[a],
sizes[b]
);
}
}
}
}
#[test]
fn growing_core_pages_have_back_in_the_same_place() {
if built().is_none() {
return;
}
for p in packages().iter().filter(|p| p.set == "growing-core") {
for board in p.import.boards.iter().filter(|b| b.id.0 != p.root) {
let back = board
.cell_at(0, 0)
.unwrap_or_else(|| panic!("{} {}: no top-left cell", p.file, board.name));
assert_eq!(
label(back),
"Back",
"{} {}: Back is top-left",
p.file,
board.name
);
assert!(
same_action(&back.action, &CellAction::Navigate(BoardId(p.root))),
"{} {}: Back goes to the core",
p.file,
board.name
);
assert_eq!(
board.layout,
p.root().layout,
"{} {}: a page is the core's size",
p.file,
board.name
);
}
}
}
#[test]
fn growing_core_reveal_hides_exactly_the_later_words_in_place() {
if built().is_none() {
return;
}
let sizes = ladder();
for p in packages()
.iter()
.filter(|p| p.set == "growing-core" && p.variant != "full")
{
let stage = p.manifest["ext_aurasense_pageset"]["reveal_stage"]
.as_str()
.expect("reveal stage")
.to_string();
assert!(
sizes.contains(&stage),
"{}: {stage} is on the ladder",
p.file
);
let full = packages()
.iter()
.find(|q| q.set == "growing-core" && q.variant == "full" && q.size == p.size)
.expect("full");
let small = packages()
.iter()
.find(|q| q.set == "growing-core" && q.variant == "full" && q.size == stage)
.expect("stage");
let shown_then: BTreeSet<&str> = small.root().cells.iter().map(label).collect();
let eb = p.expect.iter().find(|b| b.id == p.root).expect("root");
let visible: BTreeSet<&str> = eb
.cells
.iter()
.filter(|c| !c.hidden)
.map(|c| c.key.as_str())
.collect();
assert_eq!(
visible, shown_then,
"{}: shows exactly the {stage} words",
p.file
);
for c in &p.root().cells {
let same = full
.root()
.cells
.iter()
.find(|f| label(f) == label(c))
.expect("in the full board");
assert_eq!(
c.position,
same.position,
"{}: {} in place",
p.file,
label(c)
);
}
}
}
#[test]
fn fixed_paths_one_meaning_per_place_and_one_path_per_word() {
if built().is_none() {
return;
}
let mut vocab_by_size: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
for p in packages().iter().filter(|p| p.set == "fixed-paths") {
// From Home over page and group keys; a page's Back cell closes it.
let mut paths: BTreeMap<String, usize> = BTreeMap::new();
let mut opened_from: HashMap<Uuid, (Uuid, (u16, u16))> = HashMap::new();
let mut frontier = vec![(p.root, 1usize)];
let mut seen = HashSet::from([p.root]);
while let Some((id, depth)) = frontier.pop() {
let board = p.board(id);
for c in &board.cells {
match &c.action {
CellAction::Speak(w) => {
assert!(
paths.insert(w.clone(), depth).is_none(),
"{}: {w} has two paths",
p.file
);
assert!(depth <= 3, "{}: {w} takes {depth} hits", p.file);
}
CellAction::Navigate(target) if label(c) == "Back" => {
// In the place of the key that opened the page, back
// to the board that key is on.
assert_eq!(
opened_from.get(&id),
Some(&(target.0, (c.position.row, c.position.col))),
"{} {}: Back is where the page's key was, and goes back to its board",
p.file,
board.name
);
}
CellAction::Navigate(target) => {
assert!(
seen.insert(target.0),
"{}: {} is opened from two places",
p.file,
label(c)
);
opened_from.insert(target.0, (id, (c.position.row, c.position.col)));
frontier.push((target.0, depth + 1));
}
CellAction::Modifier(_) => {
assert_eq!(depth, 2, "{}: endings are on the Word forms page", p.file)
}
other => panic!("{}: unexpected action {other:?}", p.file),
}
}
}
assert_eq!(
seen.len(),
p.import.boards.len(),
"{}: every board is reachable from Home",
p.file
);
if p.variant == "full" {
vocab_by_size.insert(p.size.clone(), paths.keys().cloned().collect());
} else {
// A filter shows exactly its words, and a key while anything behind it shows.
let filter: BTreeSet<String> = p.manifest["ext_aurasense_pageset"]["filter_words"]
.as_array()
.expect("filter words")
.iter()
.map(s)
.collect();
for eb in &p.expect {
for c in eb.cells.iter().filter(|c| c.action == "speak") {
assert_eq!(
c.hidden,
!filter.contains(&c.key),
"{}: {} hidden",
p.file,
c.key
);
}
for c in eb
.cells
.iter()
.filter(|c| c.action == "navigate" && c.control.is_none())
{
let target =
Uuid::parse_str(c.extra.as_deref().expect("target")).expect("uuid");
let shows = shows_anything(p, target);
assert_eq!(c.hidden, !shows, "{}: key {} hidden", p.file, c.key);
}
}
let full = packages()
.iter()
.find(|q| q.set == "fixed-paths" && q.variant == "full" && q.size == p.size)
.expect("full");
for (a, b) in p.expect.iter().zip(&full.expect) {
let pa: Vec<_> = a.cells.iter().map(|c| (&c.key, c.row, c.col)).collect();
let pb: Vec<_> = b.cells.iter().map(|c| (&c.key, c.row, c.col)).collect();
assert_eq!(pa, pb, "{}: a filter moves nothing", p.file);
}
}
}
let sets: Vec<&BTreeSet<String>> = vocab_by_size.values().collect();
assert_eq!(sets.len(), 3, "30, 60 and 84");
assert!(
sets.windows(2).all(|w| w[0] == w[1]),
"the same words at every size"
);
assert!(sets[0].len() > 400, "words: {}", sets[0].len());
}
fn shows_anything(p: &Package, board: Uuid) -> bool {
let eb = p.expect.iter().find(|b| b.id == board).expect("board");
eb.cells.iter().any(|c| match c.action.as_str() {
"speak" => !c.hidden,
"navigate" if c.control.is_none() => shows_anything(
p,
Uuid::parse_str(c.extra.as_deref().expect("t")).expect("uuid"),
),
_ => false,
})
}
#[test]
fn languages_are_labelled_honestly() {
if built().is_none() {
return;
}
let report = read_json("languages.json");
let langs = report["languages"].as_array().expect("languages");
assert_eq!(
langs.len(),
aac_core::i18n::supported_languages().len(),
"every language the app offers"
);
for l in langs {
let code = s(&l["code"]);
let app = aac_core::language_by_code(&code)
.unwrap_or_else(|| panic!("{code} is not an app language"));
let status = serde_json::to_value(app.core_status).expect("status");
assert_eq!(l["core_status"], status, "{code}: the app's own status");
if code != "en" {
assert_eq!(
l["core_status"], "GlossOnly",
"{code}: glosses are never another language's core"
);
assert!(
l["needs"].as_array().is_some_and(|n| !n.is_empty()),
"{code}: the native work it needs"
);
}
}
for p in packages() {
for board in p.raw.values() {
assert_eq!(board["locale"], "en", "{}: the sets are English", p.file);
assert_eq!(
board["ext_aurasense_provenance"]["core_status"], "ValidatedNativeCorpus",
"{}",
p.file
);
}
}
}
#[test]
fn the_committed_catalogue_lists_what_the_sources_build() {
if built().is_none() {
return;
}
let built_families = read_json("families.json");
let catalogue = aac_core::pagesets::Catalogue::builtin();
let fams = built_families["families"].as_array().expect("families");
for f in fams {
let key = s(&f["key"]);
let listed = catalogue.family(&key).unwrap_or_else(|| {
panic!("{key} is not in data/pagesets/index.json: run catalog.py --write")
});
let pkgs = f["packages"].as_array().expect("packages");
assert_eq!(listed.packages.len(), pkgs.len(), "{key}: packages");
for p in pkgs {
let pk = s(&p["key"]);
let entry = listed
.package(&pk)
.unwrap_or_else(|| panic!("{pk} is not in the catalogue"));
let len = std::fs::metadata(dir().join(s(&p["file"])))
.expect("package")
.len();
assert_eq!(
entry.bytes, len,
"{pk}: the catalogue's size is not what the sources build (catalog.py --write)"
);
}
}
}
#[test]
fn reveal_at_run_time_hides_what_a_reveal_package_hides() {
if built().is_none() {
return;
}
let ladder = ladder();
let mut checked = 0;
for p in packages()
.iter()
.filter(|p| p.set == "growing-core" && p.variant != "full")
{
let stage = p.manifest["ext_aurasense_pageset"]["reveal_stage"]
.as_str()
.expect("stage")
.to_string();
let full = packages()
.iter()
.find(|q| q.set == "growing-core" && q.variant == "full" && q.size == p.size)
.expect("full");
let bytes = std::fs::read(dir().join(&full.file)).expect("package");
let files: BTreeMap<String, Vec<u8>> = obf::zip_entries(&bytes)
.expect("zip")
.into_iter()
.map(|e| (e.name, e.data))
.collect();
let stages = aac_core::pagesets::button_stages(&files);
let mut import = obf::import_obz(&bytes).expect("import");
aac_core::pagesets::reveal(&mut import.boards, &stages, &ladder, &stage);
// Same places, same hidden words, board by board.
for (eb, board) in p.expect.iter().zip(full.expect.iter()) {
let mine = import
.boards
.iter()
.find(|b| b.id.0 == board.id)
.expect("board");
for e in &eb.cells {
let c = mine.cell_at(e.row, e.col).expect("cell");
assert_eq!(
c.hidden, e.hidden,
"{}: {} at {},{}",
p.file, e.key, e.row, e.col
);
checked += 1;
}
}
}
assert!(checked > 100, "cells checked: {checked}");
}
#[test]
fn a_word_in_another_sense_than_its_glosses_keeps_it() {
if built().is_none() {
return;
}
// "back" as in "come back": its glosses are the body part.
let mut found = 0;
for p in packages().iter().filter(|p| p.variant == "full") {
for board in &p.import.boards {
for c in board
.cells
.iter()
.filter(|c| matches!(&c.action, CellAction::Speak(w) if w == "back"))
{
assert!(c.sense.is_some(), "{}: back keeps its sense", p.file);
assert!(!c.glossable(), "{}: back is not glossed", p.file);
let v = obf::to_obf_with(board, &ObfExportOptions::new("es"));
let b = v["buttons"]
.as_array()
.expect("buttons")
.iter()
.find(|b| b["id"] == c.id.0.to_string())
.expect("button");
assert_eq!(b["label"], "back", "{}: in English", p.file);
assert_eq!(b["label_locale"], "en", "{}: and says so", p.file);
assert!(
b["translations"].as_object().is_some_and(|t| t.len() == 1),
"{}: no gloss",
p.file
);
found += 1;
}
}
}
assert!(found >= 2, "back in both sets: {found}");
}
// ─── One word, one place on a set that grows ────────────────────────────────
/// A package's boards at a step of its growth: as the package comes (`None`),
/// revealed up to a Growing Core stage (`Some("3x3")`, as the app starts a
/// bigger board with fewer words), or with every word shown (`Some("all")`,
/// as when every hidden word is shown).
fn boards_at(p: &Package, step: Option<&str>) -> Vec<Board> {
let mut boards = p.import.boards.clone();
match step {
None => {}
Some("all") => boards.iter_mut().flat_map(|b| b.cells.iter_mut()).for_each(|c| c.hidden = false),
Some(stage) => {
let bytes = std::fs::read(dir().join(&p.file)).expect("package");
let files: BTreeMap<String, Vec<u8>> =
obf::zip_entries(&bytes).expect("zip").into_iter().map(|e| (e.name, e.data)).collect();
let stages = aac_core::pagesets::button_stages(&files);
aac_core::pagesets::reveal(&mut boards, &stages, &ladder(), stage);
}
}
boards
}
/// The steps of a package's growth to check: as it comes, every Growing
/// Core stage up to its size, and every word shown.
fn steps(p: &Package) -> Vec<Option<String>> {
let mut out = vec![None, Some("all".to_string())];
if p.set == "growing-core" {
let ladder = ladder();
let upto = ladder.iter().position(|s| *s == p.size).expect("size on the ladder");
out.extend(ladder[..=upto].iter().cloned().map(Some));
}
out
}
/// The words shown on `board` that are left out in `lang` for a key not
/// shown with them: none may be.
fn left_without_their_word(board: &Board, lang: &str) -> Vec<String> {
let shown: HashSet<String> = board
.cells
.iter()
.filter(|c| !c.hidden)
.map(|c| aac_core::translations::cell_gloss_key(c, label(c)))
.collect();
board
.cells
.iter()
.filter(|c| !c.hidden)
.filter_map(|c| {
let keeper = aac_core::samewords::kept_by(c, lang)?;
(!shown.contains(&keeper)).then(|| format!("{} (kept by {keeper})", label(c)))
})
.collect()
}
/// The marks a package carries for one word, one place are the ones the app
/// works out itself when it reads a board (aac-core `samewords::mark`): the
/// build and the app pick the same keeper.
#[test]
fn the_app_marks_every_package_as_its_build_did() {
if built().is_none() {
return;
}
let mut marked = 0;
for p in packages() {
for board in &p.import.boards {
let mut mine = board.clone();
aac_core::samewords::mark(&mut mine);
for (theirs, ours) in board.cells.iter().zip(&mine.cells) {
let (a, b) = (theirs.extra.get(aac_core::samewords::SAME_WORD), ours.extra.get(aac_core::samewords::SAME_WORD));
assert_eq!(a, b, "{} · {} · {}", p.file, board.name, label(theirs));
marked += usize::from(a.is_some());
}
}
}
assert!(marked > 100, "words marked: {marked}");
}
/// On a set that grows, the key shown first keeps a word two keys share
/// (proposed SPEC §4.1 item 5): at every step of every package — as it
/// comes, at each Growing Core stage, with every word shown — every word
/// shown in every language shows its words, or is left out where the key
/// that keeps them is shown with it. A key hidden for later never leaves a
/// word without its place.
#[test]
fn on_a_set_that_grows_no_word_is_left_out_for_a_key_not_shown_yet() {
if built().is_none() {
return;
}
let langs: Vec<&String> = aac_core::samewords::all().keys().collect();
let mut problems = Vec::new();
let mut checked = 0;
for p in packages() {
for step in steps(p) {
for board in boards_at(p, step.as_deref()) {
for lang in &langs {
for w in left_without_their_word(&board, lang) {
problems.push(format!("{} · {} · {} · {lang}: {w}", p.file, board.name, step.as_deref().unwrap_or("as it comes")));
}
checked += 1;
}
}
}
}
assert!(checked > 1000, "boards checked: {checked}");
assert!(problems.is_empty(), "{} words left out for a key not shown:\n{}", problems.len(), problems.join("\n"));
}
/// The third review's cases (tests/fixtures/samewords_r3_growing.json): each
/// word was left out at its step for a key not shown yet — Growing Core
/// 8 × 10 from 3 × 3 had no negation in Spanish, Portuguese, Polish, Hindi
/// and Chinese ("no" comes at 4 × 5), Fixed Paths 84's first words had no
/// "a" in ten languages. Each now shows its word there.
#[test]
fn the_third_reviews_growing_cases_each_show_their_word() {
if built().is_none() {
return;
}
let fixture: Value = serde_json::from_str(include_str!("fixtures/samewords_r3_growing.json")).expect("fixture");
let cases = fixture["cases"].as_array().expect("cases");
assert!(cases.len() >= 300, "the review's cases: {}", cases.len());
let mut by_step: HashMap<(String, String), Vec<Board>> = HashMap::new();
for case in cases {
let (pkg, board_name, view, lang, key) =
(s(&case["package"]), s(&case["board"]), s(&case["view"]), s(&case["lang"]), s(&case["key"]));
let p = packages()
.iter()
.find(|p| p.file.rsplit('/').next() == Some(&format!("{pkg}.obz")))
.unwrap_or_else(|| panic!("{pkg}: built"));
let step = (view != "as it comes").then_some(view.as_str());
let boards = by_step.entry((pkg.clone(), view.clone())).or_insert_with(|| boards_at(p, step));
let board = boards.iter().find(|b| b.name == board_name).unwrap_or_else(|| panic!("{pkg}: {board_name}"));
let cell = board
.cells
.iter()
.find(|c| aac_core::translations::cell_gloss_key(c, label(c)) == key)
.unwrap_or_else(|| panic!("{pkg} · {board_name}: {key}"));
assert!(!cell.hidden, "{pkg} · {board_name} · {view}: {key} is shown");
assert!(
!aac_core::samewords::hidden_in(cell, &lang),
"{pkg} · {board_name} · {view} · {lang}: {key} shows its word (kept by {:?})",
aac_core::samewords::kept_by(cell, &lang)
);
}
}
/// A board kept by a version before `shown_from` was written — stored on the
/// device, in a backup, in an exported file — carries each word's `stage`
/// (Growing Core) or `path` (Fixed Paths) but not its step. The app works the
/// step out from them, so every word of every package gets the step the build
/// gives it, and the app marks the board as the build did: an update or a
/// restore never takes a word away.
#[test]
fn a_word_kept_by_an_earlier_version_gets_the_step_the_build_gives_it() {
if built().is_none() {
return;
}
let mut checked = 0;
for p in packages() {
for board in &p.import.boards {
let mut old = board.clone();
for cell in &mut old.cells {
if let Some(Value::Object(set)) = cell.extra.get_mut("ext_aurasense_pageset") {
set.remove(aac_core::samewords::SHOWN_FROM);
}
}
for (cell, kept) in board.cells.iter().zip(&old.cells) {
if let Some(said) = cell.extra.get("ext_aurasense_pageset").and_then(|s| s.get("shown_from")).and_then(Value::as_u64) {
assert_eq!(aac_core::samewords::shown_from(kept), said, "{} · {} · {}", p.file, board.name, label(cell));
checked += 1;
}
}
aac_core::samewords::mark(&mut old);
for (cell, kept) in board.cells.iter().zip(&old.cells) {
assert_eq!(
cell.extra.get(aac_core::samewords::SAME_WORD),
kept.extra.get(aac_core::samewords::SAME_WORD),
"{} · {} · {}: marked as the build did",
p.file,
board.name,
label(cell)
);
}
}
}
assert!(checked > 1000, "words checked: {checked}");
}
/// A Fixed Paths set started with its first words, kept by the released app
/// (its words carry no `shown_from`, its later words no "shown later" mark),
/// reads as the same set added now: the words it hides for later are marked
/// so, never recorded as the person's hides, and every mark is the fresh
/// set's — in every language. A word the person hid (a first word) stays
/// their hide.
#[test]
fn a_fixed_paths_starter_kept_by_the_released_app_reads_as_one_added_now() {
if built().is_none() {
return;
}
use aac_core::samewords::{HID, SAME_WORD, mark, mark_later, record_kept_hides};
let later = aac_core::behaviours::SHOWN_LATER;
let mut checked = 0;
for p in packages().iter().filter(|p| p.set == "fixed-paths" && p.variant != "full") {
for lang in ["fi-FI", "es-ES", "vi-VN", "en-US"] {
let settle = |boards: &mut Vec<Board>| {
for b in boards.iter_mut() {
mark_later(b);
record_kept_hides(b, lang);
mark(b);
}
};
// Added now.
let mut fresh = p.import.boards.clone();
settle(&mut fresh);
// Kept by the released app.
let mut kept = p.import.boards.clone();
for cell in kept.iter_mut().flat_map(|b| b.cells.iter_mut()) {
if let Some(Value::Object(set)) = cell.extra.get_mut("ext_aurasense_pageset") {
set.remove(aac_core::samewords::SHOWN_FROM);
}
cell.extra.remove(later);
}
settle(&mut kept);
for (a, b) in fresh.iter().flat_map(|b| &b.cells).zip(kept.iter().flat_map(|b| &b.cells)) {
for field in [HID, SAME_WORD, later] {
assert_eq!(a.extra.get(field), b.extra.get(field), "{} · {lang} · {} · {field}", p.file, label(a));
}
checked += 1;
}
assert!(fresh.iter().flat_map(|b| &b.cells).all(|c| !c.extra.contains_key(HID)), "{}: nothing the person hid", p.file);
assert!(fresh.iter().flat_map(|b| &b.cells).any(|c| c.extra.contains_key(later)), "{}: words hidden for later", p.file);
}
// The person's own hide of a first word, kept by the released app,
// stays theirs.
let mut kept = p.import.boards.clone();
let board = kept
.iter_mut()
.find(|b| b.cells.iter().any(|c| label(c) == "a" && !c.hidden))
.unwrap_or_else(|| panic!("{}: \"a\" is a first word", p.file));
let a = board.cells.iter_mut().find(|c| label(c) == "a" && !c.hidden).expect("a");
a.hidden = true;
let id = a.id;
mark_later(board);
record_kept_hides(board, "fi-FI");
let a = board.cell_by_id(&id).unwrap();
assert!(!a.extra.contains_key(later) && a.extra.contains_key(HID), "{}: the person's hide", p.file);
}
assert!(checked > 1000, "cells checked: {checked}");
}