open-standards/wai/wai-vector/tests/text_layer.rs
Transaction Science 10ba6e9bcc wai-vector: the text layer and section 5, text and accessibility (D7-7)
graphics-vector.md section 5 (Draft): what a sink exposes to assistive
technology and to a text consumer, and how. Each text block's active set (the
effective windows of the items that draw it, merged), the projection at a
frame and the transcript as exact JSON, the text record and its digest, ruby,
purposes, writing modes and the cluster rule.

- wai-vector: src/text.rs (no_std, no dependency), the a11y and text commands,
  and vectors verify recomputing every text record.
- The corpus: a new a11y group, each payload with its text record
  (<name>.a11y.jsonl) and its digest in the expected file; canonical and
  refusal vectors for TEXT. No existing expected file changes.
- tests/text_layer.rs: each rule of section 5, with expected JSON written by
  hand from the text, each falsified once.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 02:43:02 -04:00

489 lines
26 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 text layer (`graphics-vector.md` §2.11, §5): the text digests, each block's
//! active set, and the projection and the transcript as JSON.
//!
//! Each test reads committed payloads of `vector-conformance/` and holds what §5 says
//! of them, written out here by hand: the JSON is never taken from the code it checks.
//! The worked example is read from the extension's own text.
//!
//! Every leg runs these: natively, and under WASI with the `wai` directory preopened.
use std::path::PathBuf;
use wai_vector::json::hex;
use wai_vector::refuse::Code;
use wai_vector::sha256::sha256;
use wai_vector::text::{self, TextLayer};
use wai_vector::wvg1::{self, Loaded, SetStore};
fn wai() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..")
}
fn read(rel: &str) -> Vec<u8> {
std::fs::read(wai().join(rel)).unwrap_or_else(|e| panic!("{rel}: {e}"))
}
/// A resolver holding every set under `sets/`.
fn store() -> SetStore {
let mut s = SetStore::new();
let mut sets: Vec<PathBuf> = std::fs::read_dir(wai().join("vector-conformance/sets")).unwrap().flatten().map(|e| e.path()).collect();
sets.sort();
for p in sets {
s.add(std::fs::read(p).unwrap());
}
s
}
fn payload(group: &str, name: &str) -> Vec<u8> {
read(&format!("vector-conformance/vectors/{group}/{name}.wvg1"))
}
fn load(group: &str, name: &str) -> Loaded {
wvg1::load(&payload(group, name), &mut store()).unwrap_or_else(|e| panic!("{group}/{name}: {}", e.code.name()))
}
/// The committed text record of an `a11y` vector, by line.
fn committed(name: &str) -> Vec<String> {
let text = String::from_utf8(read(&format!("vector-conformance/expected/a11y/{name}.a11y.jsonl"))).unwrap();
assert!(text.ends_with('\n'), "{name}: every line of a record ends in U+000A");
text.lines().map(String::from).collect()
}
/// A node as §5.4 writes it: `active` (a transcript's) first when given, then `dir`,
/// `group`, `index`, `lang`, `mode`, `purpose`, `ruby` when given, `text`.
#[allow(clippy::too_many_arguments)]
fn node(active: Option<&str>, dir: u8, group: u16, index: usize, lang: &str, mode: u8, purpose: u8, ruby: Option<&str>, text: &str) -> String {
let active = active.map(|a| format!("\"active\":{a},")).unwrap_or_default();
let ruby = ruby.map(|r| format!("\"ruby\":{r},")).unwrap_or_default();
format!("{{{active}\"dir\":{dir},\"group\":{group},\"index\":{index},\"lang\":\"{lang}\",\"mode\":{mode},\"purpose\":{purpose},{ruby}\"text\":\"{text}\"}}")
}
/// A plain English label: left to right, ungrouped, horizontal, purpose 7.
fn label(active: Option<&str>, index: usize, text: &str) -> String {
node(active, 0, 0, index, "en", 0, 7, None, text)
}
fn array(nodes: &[String]) -> String {
format!("[{}]", nodes.join(","))
}
/// A record's line for one frame.
fn frame(num: u32, den: u32, g: u64, nodes: &[String]) -> String {
format!("{{\"den\":{den},\"g\":{g},\"num\":{num},\"projection\":{}}}", array(nodes))
}
/// The fenced block that follows `marker` in the extension's §5.4, by line.
fn fenced_after(marker: &str) -> Vec<String> {
let doc = String::from_utf8(read("extensions/graphics-vector.md")).unwrap();
let at = doc.find(marker).unwrap_or_else(|| panic!("the extension has no {marker}"));
let rest = &doc[at..];
let open = rest.find("```").expect("a fenced block");
let body = &rest[open..];
let body = &body[body.find('\n').unwrap() + 1..];
body[..body.find("```").expect("the fence closes")].lines().map(String::from).collect()
}
#[test]
fn the_worked_example_of_section_5_4_byte_for_byte() {
let example = fenced_after("**A worked example.**");
assert_eq!(example.len(), 2, "the example is a transcript and one frame's line");
let l = load("a11y", "worked-example");
let layer = TextLayer::new(&l.doc);
// The transcript, and frame 30 at 25 fps: tick 144000, after the annotation's window.
assert_eq!(layer.transcript(), example[0], "the transcript");
assert_eq!(l.doc.rates[0].ticks_per_frame(l.doc.header.tb), 4800);
assert_eq!(format!("{{\"den\":1,\"g\":30,\"num\":25,\"projection\":{}}}", layer.projection(0, 30).unwrap()), example[1], "frame 30");
// The committed record holds both lines: the transcript first, then frames 0 to 74.
let lines = committed("worked-example");
assert_eq!(lines.len(), 1 + 75);
assert_eq!(lines[0], example[0]);
assert_eq!(lines[1 + 30], example[1]);
assert_eq!(text::record(&l.doc), lines.iter().map(|l| format!("{l}\n")).collect::<String>());
// What the example says of itself, written out again here.
// The Arabic block's words: common nouns, "the door of the house".
let words = "\u{0628}\u{0627}\u{0628} \u{0627}\u{0644}\u{0628}\u{064A}\u{062A}";
let both = "[[24000,264000]]";
let want = array(&[
node(Some(both), 1, 1, 0, "ar", 0, 3, None, words),
node(Some(both), 0, 1, 1, "en", 0, 3, None, "Director of photography"),
node(Some("[[24000,120000]]"), 0, 1, 2, "en", 0, 7, Some("{\"base\":1,\"len\":8,\"start\":0}"), "DOP"),
]);
assert_eq!(example[0], want);
assert_eq!(example[1], frame(25, 1, 30, &[node(None, 1, 1, 0, "ar", 0, 3, None, words), node(None, 0, 1, 1, "en", 0, 3, None, "Director of photography")]));
// Before the windows open nothing is projected; inside the annotation's, all three.
assert_eq!(lines[1 + 4], frame(25, 1, 4, &[]));
assert!(lines[1 + 5].contains("\"text\":\"DOP\"") && lines[1 + 24].contains("\"text\":\"DOP\"") && !lines[1 + 25].contains("DOP"));
assert_eq!(lines[1 + 55], frame(25, 1, 55, &[]), "tick 264000 is past both windows");
}
#[test]
fn purpose_200_is_written_raw() {
let l = load("a11y", "purposes");
let names = ["title", "credit", "lower_third", "caption", "subtitle", "forced_narrative", "label", "legal", "alert", "logo", "graphic", "data", "decorative", "p14", "p200", "p255"];
let purposes: Vec<u8> = (1..=13).chain([14, 200, 255]).collect();
assert_eq!(l.doc.texts.iter().map(|t| t.purpose).collect::<Vec<u8>>(), purposes, "the TEXT bytes");
// Every node carries the TEXT byte itself; block 12, decorative, is in neither.
let nodes = |active: Option<&str>| -> Vec<String> { (0..16).filter(|&i| i != 12).map(|i| node(active, 0, 0, i, "en", 0, purposes[i], None, names[i])).collect() };
let layer = TextLayer::new(&l.doc);
assert_eq!(layer.transcript(), array(&nodes(Some("[[0,24000]]"))));
assert_eq!(layer.projection(0, 0).unwrap(), array(&nodes(None)));
let t = layer.transcript();
assert!(t.contains("\"index\":14,\"lang\":\"en\",\"mode\":0,\"purpose\":200,\"text\":\"p200\""), "200 is written as 200");
assert!(t.contains("\"purpose\":14,") && t.contains("\"purpose\":255,"));
assert_eq!(t.matches("\"purpose\":7,").count(), 1, "only the label block reads 7");
assert_eq!(committed("purposes")[0], t);
}
#[test]
fn touching_intervals_merge() {
let l = load("a11y", "intervals");
let want = array(&[
label(Some("[[0,48000]]"), 0, "touching windows"),
label(Some("[[0,60000]]"), 1, "overlapping windows"),
label(Some("[[0,24000],[24001,48000]]"), 2, "apart windows"),
label(Some("[[0,96000]]"), 3, "nested windows"),
label(Some("[[0,48000],[72000,96000]]"), 4, "later then earlier"),
]);
assert_eq!(text::transcript(&l.doc), want);
assert_eq!(committed("intervals")[0], want);
// The windows as carried: [0, 24000) and [24000, 48000) for block 0.
let wins: Vec<(u32, u32)> = l.doc.items.iter().filter(|it| it.text == 0).map(|it| (it.t_on, it.t_off)).collect();
assert_eq!(wins, [(0, 24_000), (24_000, 48_000)]);
assert_eq!(text::active_sets(&l.doc)[0], [(0, 48_000)]);
// Tick 24000 is in neither interval of block 2, which stays two intervals.
assert_eq!(text::active_sets(&l.doc)[2], [(0, 24_000), (24_001, 48_000)]);
}
#[test]
fn windows_are_clipped_to_the_document() {
let l = load("a11y", "clipped");
assert_eq!((l.doc.header.t0, l.doc.header.dur), (50_000, 94_000), "the document's window is [50000, 144000)");
let want = array(&[
label(Some("[[50000,72000]]"), 0, "starts before"),
label(Some("[[120000,144000]]"), 1, "ends after"),
label(Some("[[50000,144000]]"), 2, "spans it"),
label(Some("[]"), 3, "over by then"),
label(Some("[]"), 4, "not yet"),
label(Some("[[50000,144000]]"), 5, "two parts"),
]);
assert_eq!(text::transcript(&l.doc), want);
// The windows as carried run past the document on both sides.
assert_eq!((l.doc.items[0].t_on, l.doc.items[0].t_off), (0, 72_000));
assert_eq!((l.doc.items[2].t_on, l.doc.items[2].t_off), (0, 1 << 31));
// Frames 11 to 29: the first at tick 52800, the last at 139200.
let lines = committed("clipped");
assert_eq!(lines.len(), 1 + 19);
assert_eq!(lines[0], want);
assert_eq!(lines[1], frame(25, 1, 11, &[label(None, 0, "starts before"), label(None, 2, "spans it"), label(None, 5, "two parts")]));
assert_eq!(lines[19], frame(25, 1, 29, &[label(None, 1, "ends after"), label(None, 2, "spans it"), label(None, 5, "two parts")]));
}
#[test]
fn the_ruby_object_is_base_len_start() {
let l = load("a11y", "ruby");
let base = "\u{0939}\u{093F}\u{0902}\u{0926}\u{0940}";
assert_eq!(base.len(), 15);
// TEXT carries r = 1 for block 0; the JSON carries the block's index, r − 1 = 0.
assert_eq!(l.doc.texts.iter().map(|t| (t.ruby, t.ruby_start, t.ruby_len)).collect::<Vec<_>>(), [(0, 0, 0), (1, 0, 9), (1, 9, 6)]);
let first = "{\"base\":0,\"len\":9,\"start\":0}";
let second = "{\"base\":0,\"len\":6,\"start\":9}";
let want = array(&[
node(Some("[[0,96000]]"), 0, 0, 0, "hi", 0, 7, None, base),
node(Some("[[0,48000]]"), 0, 0, 1, "hi-Latn", 0, 7, Some(first), "hin"),
node(Some("[[48000,120000]]"), 0, 0, 2, "hi-Latn", 0, 7, Some(second), "di"),
]);
let layer = TextLayer::new(&l.doc);
assert_eq!(layer.transcript(), want);
// Frame 21, tick 100800: the base's window has ended, and the annotation keeps its
// ruby object.
assert_eq!(layer.projection(0, 21).unwrap(), array(&[node(None, 0, 0, 2, "hi-Latn", 0, 7, Some(second), "di")]));
assert_eq!(committed("ruby")[1 + 21], frame(25, 1, 21, &[node(None, 0, 0, 2, "hi-Latn", 0, 7, Some(second), "di")]));
// The range rebuilds the annotated part of the base exactly.
assert_eq!(&base[0..9], "\u{0939}\u{093F}\u{0902}");
assert_eq!(&base[9..15], "\u{0926}\u{0940}");
}
#[test]
fn mode_is_in_the_block_identity() {
let l = load("a11y", "modes");
let d = &l.doc;
assert_eq!(d.texts.iter().map(|t| t.mode).collect::<Vec<u8>>(), [1, 2, 0]);
// The three blocks are equal in every other field.
for t in 1..3 {
let (a, b) = (d.texts[0], d.texts[t]);
assert_eq!((a.purpose, a.dir, a.lang_len, a.group, a.ruby, a.str_len), (b.purpose, b.dir, b.lang_len, b.group, b.ruby, b.str_len));
assert_eq!((d.lang(0), d.string(0)), (d.lang(t), d.string(t)));
}
// "wai:text-block", 0x01, purpose 7, dir 0, the mode, lang_len 2, "en", str_len 3 as
// u32 little-endian, "WAI": built here byte by byte.
let identity = |mode: u8| {
let mut m = b"wai:text-block".to_vec();
m.extend_from_slice(&[0x01, 7, 0, mode, 2, b'e', b'n', 3, 0, 0, 0, b'W', b'A', b'I']);
sha256(&m)
};
for (t, mode) in [(0usize, 1u8), (1, 2), (2, 0)] {
assert_eq!(text::block_identity(d, t), identity(mode), "block {t}, mode {mode}");
}
assert!(identity(0) != identity(1) && identity(1) != identity(2) && identity(0) != identity(2));
// The same digests from another SHA-256 (Python's hashlib), over the same bytes.
assert_eq!(hex(&text::block_identity(d, 2)), IDENTITY_MODE_0);
assert_eq!(hex(&text::block_identity(d, 0)), IDENTITY_MODE_1);
assert_eq!(hex(&text::block_identity(d, 1)), IDENTITY_MODE_2);
// The expected file states them in block order, and each node carries its mode.
let expected = String::from_utf8(read("vector-conformance/expected/a11y/modes.json")).unwrap();
assert!(expected.contains(&format!("\"blocks\":[\"{IDENTITY_MODE_1}\",\"{IDENTITY_MODE_2}\",\"{IDENTITY_MODE_0}\"]")));
let nodes: Vec<String> = [1u8, 2, 0].iter().enumerate().map(|(i, &m)| node(Some("[[0,24000]]"), 0, 0, i, "en", m, 7, None, "WAI")).collect();
assert_eq!(text::transcript(d), array(&nodes));
}
// hashlib.sha256(b"wai:text-block\x01\x07\x00" + bytes([mode]) + b"\x02en\x03\x00\x00\x00WAI")
const IDENTITY_MODE_0: &str = "7717339a5b738a00c3f6191d2f43cc78156c65651b8c55d4ec234efa06efc968";
const IDENTITY_MODE_1: &str = "47d74c9ccb8456e3f1171904dda15a84bf74897c9bcc12d9f4fef166730e8c1a";
const IDENTITY_MODE_2: &str = "916bd4458608698f35b350172b1827ab26e53094e6d9fd784a62311f4270c7bc";
#[test]
fn group_contiguity_1_2_1_is_refused() {
let groups = |name: &str| -> Vec<u16> { wvg1::split(&payload("canonical", name)).unwrap().texts.iter().map(|t| t.group).collect() };
let refusal = |name: &str| wvg1::load(&payload("canonical", name), &mut store()).err().unwrap_or_else(|| panic!("canonical/{name} was accepted"));
// [1, 2, 1]: group 1 resumes after group 2.
assert_eq!(groups("group-not-contiguous"), [1, 2, 1]);
let r = refusal("group-not-contiguous");
assert_eq!((r.code, r.step, r.index), (Code::PayloadMalformed, "5g", 2), "refused at block 2, by the canonical rules");
assert!(r.rule.contains("contiguous"), "{}", r.rule);
// [1, 0, 1]: an ungrouped block splits it too.
assert_eq!(groups("group-split-by-ungrouped"), [1, 0, 1]);
let r = refusal("group-split-by-ungrouped");
assert_eq!((r.code, r.step), (Code::PayloadMalformed, "5g"));
assert!(r.rule.contains("contiguous"), "{}", r.rule);
// Contiguous groups load, with or without an ungrouped block between them.
for (name, want) in [("groups-in-order", [1, 1, 2]), ("groups-with-ungrouped", [1, 0, 2])] {
assert_eq!(groups(name), want);
assert!(wvg1::load(&payload("canonical", name), &mut store()).is_ok(), "{name}");
}
}
#[test]
fn text_hash_is_texts_then_strings() {
for (group, name) in [("a11y", "worked-example"), ("a11y", "strings"), ("canonical", "every-table")] {
let bytes = payload(group, name);
let l = wvg1::load(&bytes, &mut store()).unwrap();
let h = &l.doc.header;
// The two areas cut out of the payload by the header's counts (§2.2, §2.3):
// STRINGS is the last table before INLINE, and TEXTS ends where ITEMS,
// GLYPHS, CLUSTERS, STRINGS and INLINE begin.
let strings_end = bytes.len() - h.inline_len as usize;
let strings = &bytes[strings_end - h.strings_len as usize..strings_end];
let texts_end = strings_end - h.strings_len as usize - 12 * h.n_clusters as usize - 12 * h.n_glyphs as usize - 48 * h.n_items as usize;
let texts = &bytes[texts_end - 20 * h.n_texts as usize..texts_end];
assert!(h.n_texts >= 3 && !strings.is_empty());
let want = sha256(&[texts, strings].concat());
assert_eq!(text::text_hash(&l.doc), want, "{group}/{name}: TEXTS, then STRINGS");
assert_ne!(sha256(&[strings, texts].concat()), want);
let expected = String::from_utf8(read(&format!("vector-conformance/expected/{group}/{name}.json"))).unwrap();
assert!(expected.contains(&format!("\"text_hash\":\"{}\"", hex(&want))), "{group}/{name}: the expected file states it");
}
// The worked example's, from another SHA-256 (Python's hashlib) over the same bytes.
assert_eq!(hex(&text::text_hash(&load("a11y", "worked-example").doc)), TEXT_HASH_WORKED_EXAMPLE);
}
// hashlib.sha256(TEXTS + STRINGS) of a11y/worked-example.wvg1, the areas cut by offset.
const TEXT_HASH_WORKED_EXAMPLE: &str = "70d5ce883f06a8aedd33bfa1fbc638e69909a380c19007a252584a7d38e0d432";
#[test]
fn decorative_blocks_are_excluded() {
let l = load("a11y", "decorative");
assert_eq!(l.doc.texts.iter().map(|t| t.purpose).collect::<Vec<u8>>(), [7, 13, 12]);
// Block 1 is drawn for the whole document, like the others.
assert_eq!(text::active_sets(&l.doc), [[(0, 120_000)], [(0, 120_000)], [(0, 120_000)]]);
let whole = Some("[[0,120000]]");
let lines = committed("decorative");
assert_eq!(lines.len(), 1 + 25);
assert_eq!(lines[0], array(&[label(whole, 0, "Scores"), node(whole, 0, 0, 2, "en", 0, 12, None, "3 - 1")]));
for g in 0..25u64 {
assert_eq!(lines[1 + g as usize], frame(25, 1, g, &[label(None, 0, "Scores"), node(None, 0, 0, 2, "en", 0, 12, None, "3 - 1")]));
}
let layer = TextLayer::new(&l.doc);
assert_eq!(layer.transcript(), lines[0]);
assert_eq!(layer.blocks_at(0), [0, 2]);
assert!(!text::record(&l.doc).contains("* * *"), "the ornament's string is nowhere in the record");
}
#[test]
fn windows_are_cut_by_enclosing_groups() {
let l = load("a11y", "nested-groups");
let want = array(&[
label(Some("[[24000,96000]]"), 0, "in the outer group"),
label(Some("[[48000,60000]]"), 1, "in both groups"),
label(Some("[[72000,96000]]"), 2, "in both, later"),
label(Some("[]"), 3, "in a group that never opens"),
label(Some("[[100000,130000]]"), 4, "in no group"),
]);
assert_eq!(text::transcript(&l.doc), want);
assert_eq!(committed("nested-groups")[0], want);
// The runs as carried are active far longer than their groups.
let runs: Vec<(u32, u32)> = l.doc.items.iter().filter(|it| it.kind == wvg1::KIND_RUN).map(|it| (it.t_on, it.t_off)).collect();
assert_eq!(runs, [(0, 240_000), (0, 60_000), (72_000, 240_000), (0, 240_000), (100_000, 130_000)]);
// What a frame draws agrees: at tick 96000 the outer group has closed.
let layer = TextLayer::new(&l.doc);
assert_eq!(layer.blocks_at(95_999), [0, 2]);
assert_eq!(layer.blocks_at(96_000), Vec::<usize>::new());
}
#[test]
fn effective_windows_are_the_costs() {
// §5.4 uses §3.4(b)'s effective windows. Over every accepted payload of the corpus,
// the text layer's are the ones the cost was computed from.
let mut n = 0;
let root = wai().join("vector-conformance/vectors");
let mut groups: Vec<PathBuf> = std::fs::read_dir(&root).unwrap().flatten().map(|e| e.path()).collect();
groups.sort();
for g in groups {
let mut files: Vec<PathBuf> = std::fs::read_dir(&g).unwrap().flatten().map(|e| e.path()).filter(|p| p.extension().is_some_and(|x| x == "wvg1")).collect();
files.sort();
for f in files {
let Ok(d) = wvg1::parse(&std::fs::read(&f).unwrap()) else { continue };
let Ok(sets) = wvg1::load_sets(&d, &mut store()) else { continue };
let cost: Vec<Option<(i64, i64)>> = wai_vector::place::Scene::new(&d, &sets).item_win;
let mine: Vec<Option<(i64, i64)>> = text::effective_windows(&d).into_iter().map(|w| w.map(|(a, b)| (i64::from(a), i64::from(b)))).collect();
assert_eq!(mine, cost, "{}", f.display());
n += 1;
}
}
assert!(n > 60, "{n} payloads");
}
#[test]
fn strings_take_three_escapes_and_no_others() {
let l = load("a11y", "strings");
let whole = Some("[[0,24000]]");
// The string of block 0 holds a quotation mark, a backslash and U+000A; in JSON
// those three are escaped, and `<`, `&`, `>` and `/` are not.
assert_eq!(l.doc.string(0), b"He said \"A&B\" <b>\\</b>\n2/2");
let want = array(&[
label(whole, 0, "He said \\\"A&B\\\" <b>\\\\</b>\\n2/2"),
label(whole, 1, "OK \u{1F600}"),
node(whole, 1, 0, 2, "he", 0, 7, None, "\u{05E9}\u{05DC}\u{05D5}\u{05DD}"),
node(whole, 0, 0, 3, "hi", 0, 7, None, "\u{0939}\u{093F}\u{0928}\u{094D}\u{0926}\u{0940}"),
node(whole, 0, 0, 4, "sr-Latn-RS", 0, 7, None, "Zdravo"),
label(whole, 5, "soft\u{00AD}hyphen"),
]);
let t = text::transcript(&l.doc);
assert_eq!(t, want);
assert_eq!(committed("strings")[0], want);
// Nothing is written as \u, and no byte below U+0020 is in a line.
assert!(!t.contains("\\u") && !t.contains("\\/"));
assert!(t.bytes().all(|b| b >= 0x20));
assert!(t.contains("\u{1F600}") && t.as_bytes().windows(2).any(|w| w == [0xC2, 0xAD]));
}
#[test]
fn a_window_holds_its_first_tick_and_not_its_last() {
// One block active over [24000, 50000), at four rates in RATE order.
let l = load("a11y", "rates");
let n = node(None, 0, 0, 0, "en", 0, 3, None, "Four rates");
let mut want = vec![array(&[node(Some("[[24000,50000]]"), 0, 0, 0, "en", 0, 3, None, "Four rates")])];
// (num, den, frames, the frames whose tick is in the window)
for (num, den, count, inside) in [(24_000u32, 1_001u32, 12u64, 5..=9u64), (25, 1, 13, 5..=10), (30_000, 1_001, 15, 6..=12), (60, 1, 30, 12..=24)] {
for g in 0..count {
want.push(frame(num, den, g, if inside.contains(&g) { std::slice::from_ref(&n) } else { &[] }));
}
}
assert_eq!(committed("rates"), want);
assert_eq!(text::record(&l.doc).lines().map(String::from).collect::<Vec<_>>(), want);
// At 60 fps a frame is 2,000 ticks: frame 12 sits on the first tick, frame 25 on the
// tick the window ends at.
let layer = TextLayer::new(&l.doc);
assert_eq!(layer.projection_request(60, 1, 12).unwrap(), array(std::slice::from_ref(&n)));
assert_eq!(layer.projection_request(60, 1, 25).unwrap(), "[]");
// A title that fades to nothing stays in the projection until its window ends.
let w = committed("windows");
let title = node(None, 0, 0, 0, "en", 0, 1, None, "Opening title");
let credit = node(None, 0, 0, 1, "en", 0, 2, None, "Written here");
assert_eq!(w.len(), 1 + 50);
for (g, nodes) in [(4u64, vec![]), (5, vec![title.clone()]), (18, vec![title.clone()]), (19, vec![title]), (20, vec![]), (24, vec![]), (25, vec![credit.clone()]), (44, vec![credit]), (45, vec![]), (49, vec![])] {
assert_eq!(w[1 + g as usize], frame(25, 1, g, &nodes), "frame {g}");
}
}
#[test]
fn a_paths_alternative_is_active_over_the_paths_window() {
let l = load("a11y", "alternatives");
let want = array(&[
node(Some("[[0,72000]]"), 0, 0, 0, "en", 0, 10, None, "WAI"),
node(Some("[[48000,120000]]"), 0, 0, 1, "en", 0, 11, None, "A red square"),
label(Some("[[0,24000],[96000,120000]]"), 2, "Both"),
]);
assert_eq!(text::transcript(&l.doc), want);
assert_eq!(committed("alternatives")[0], want);
// Blocks 0 and 1 are drawn by paths alone.
for t in 0..2u16 {
assert!(l.doc.items.iter().filter(|it| it.text == t).all(|it| it.kind == wvg1::KIND_PATH));
}
// And the groups vector: each node carries its group number, in block order.
let g = [
node(Some("[[0,48000]]"), 0, 1, 0, "en", 0, 3, None, "Camera"),
node(Some("[[0,48000]]"), 0, 1, 1, "en", 0, 3, None, "Second unit"),
label(Some("[[48000,72000]]"), 2, "Interval"),
node(Some("[[72000,120000]]"), 0, 2, 3, "en", 0, 2, None, "Editing"),
node(Some("[[72000,120000]]"), 0, 2, 4, "en", 0, 2, None, "Sound"),
node(Some("[[96000,120000]]"), 0, 2, 5, "en", 0, 2, None, "Music"),
];
assert_eq!(committed("groups")[0], array(&g));
}
#[test]
fn a_frame_outside_the_document_is_a_request_error() {
let l = load("a11y", "clipped");
let layer = TextLayer::new(&l.doc);
// The document's frames at 25 fps are 11 to 29.
assert!(layer.projection(0, 11).is_ok() && layer.projection(0, 29).is_ok());
for g in [0u64, 10, 30, 1 << 40, u64::MAX] {
assert_eq!(layer.projection(0, g).map_err(|e| e.code), Err(Code::FrameRange), "frame {g}");
}
assert_eq!(layer.projection_request(30, 1, 11).map_err(|e| e.code), Err(Code::RateUnlisted));
assert_eq!(layer.projection(1, 11).map_err(|e| e.code), Err(Code::RateUnlisted));
assert_eq!(text::projection(&l.doc, 0, 11).unwrap(), layer.projection_request(25, 1, 11).unwrap());
// A payload with no text: the empty array, at every frame.
let none = committed("no-text");
assert_eq!(none[0], "[]");
assert_eq!(none[1..], (0..5).map(|g| frame(25, 1, g, &[])).collect::<Vec<_>>());
}
#[test]
fn a_verifier_finds_bytes_no_cluster_covers() {
// §5.3's encoder rule holds for every block a run draws in the text and a11y
// groups: each byte in exactly one cluster, but for U+000A and U+00AD.
let mut n = 0;
for group in ["text", "a11y"] {
let dir = wai().join("vector-conformance/vectors").join(group);
let mut files: Vec<PathBuf> = std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.path()).filter(|p| p.extension().is_some_and(|x| x == "wvg1")).collect();
files.sort();
for f in files {
let d = wvg1::parse(&std::fs::read(&f).unwrap()).unwrap();
for t in 0..d.texts.len() {
if d.items.iter().any(|it| it.kind == wvg1::KIND_RUN && it.text as usize == t) {
let c = text::coverage(&d, t);
assert!(c.complete(), "{} block {t}: {c:?}", f.display());
n += 1;
}
}
}
}
assert!(n > 50, "{n} blocks");
// The line separator and the soft hyphen of a11y/strings are in no cluster.
let d = load("a11y", "strings").doc;
let covered = |t: usize, at: usize| d.items.iter().enumerate().filter(|(_, it)| it.kind == wvg1::KIND_RUN && it.text as usize == t).any(|(i, _)| d.run_clusters(i).iter().any(|c| (c.byte_first as usize..(c.byte_first + c.byte_len) as usize).contains(&at)));
assert_eq!(d.string(0)[22], 0x0A);
assert!(!covered(0, 22) && covered(0, 21) && covered(0, 23));
assert_eq!(&d.string(5)[4..6], [0xC2, 0xAD]);
assert!(!covered(5, 4) && !covered(5, 5));
// A payload that breaks the rule still loads, and a verifier reports where: with
// one cluster dropped its bytes are uncovered, and with one repeated they are
// covered twice.
let mut d = load("a11y", "decorative").doc;
let first = d.ix.item_cluster0[0] as usize;
d.clusters[first + 1].byte_first = 0;
assert_eq!(text::coverage(&d, 0), text::Coverage { uncovered: vec![(1, 2)], repeated: vec![(0, 1)] });
assert!(!text::coverage(&d, 0).complete());
assert!(wvg1::parse(&d.write()).is_ok(), "a sink does not check the rule");
}