//! 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> = 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, hidden: bool, control: Option, } struct ExpectBoard { id: Uuid, name: String, rows: u16, cols: u16, cells: Vec, } struct Package { file: String, set: String, size: String, variant: String, root: Uuid, expect: Vec, /// The package as the app imports it. import: ObfImport, /// Each board's raw OBF JSON, by board id. raw: HashMap, 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 { static PACKAGES: OnceLock> = 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 = 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 = 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 = 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 { 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> = 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> = 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 = BTreeMap::new(); let mut opened_from: HashMap = 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 = 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> = 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> = 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 { 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> = 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> { 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 { let shown: HashSet = 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> = 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| { 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}"); }