open-standards/wai/wai-rs/tests/channel_lineup.rs
Transaction Science 01053b3874 wai: register wai.video.lineup, its magic WLU1, and the CI step
The record in the registry's table, with its wire identifiers (WLU1,
wai-channel-feed, urn:wai:guide:, wai-asset-id, wai-now-next) and the export
regenerated; SPEC §5's paragraph and the CHANGELOG entry. The lineup's magic
is WLU1: WCL1 is live-recording's link record, which the registry's test of
wire identifiers caught. The conformance workflow runs the module's tests,
tests/channel_lineup.rs, the corpus tool's verify and channel_lineup_verify.py,
and builds the feature alone; the catalogue workflow fires on the corpus,
which tests/cli_ops.rs reads. Studio's generated actions follow the catalogue.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 17:23:18 -04:00

150 lines
8.2 KiB
Rust

//! `wai.video.lineup` (extensions/channel-lineup.md): the committed corpus
//! `live-conformance/channels/` regenerates byte for byte from its fixed seeds and holds no other
//! file, every vector gets the outcome it states, §10 lists exactly the reasons the reference adds,
//! and the worked example of §9 — its commands as the text writes them — imports, guides and
//! verifies the sample channel through the `wai` command line, yielding the corpus's files.
#![cfg(feature = "channel_lineup")]
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::process::Command;
use serde_json::Value;
use wai::channel_lineup::{corpus, Reason};
fn wai_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..")
}
fn corpus_dir() -> PathBuf {
wai_dir().join("live-conformance/channels")
}
fn committed() -> BTreeMap<String, String> {
std::fs::read_dir(corpus_dir())
.expect("the committed corpus")
.flatten()
.map(|e| (e.file_name().to_string_lossy().into_owned(), e.path()))
.filter(|(n, _)| n != "README.md")
.map(|(n, p)| (n, std::fs::read_to_string(p).expect("a text file")))
.collect()
}
#[test]
fn the_committed_corpus_regenerates_from_its_seeds() {
let files = committed();
let generated: BTreeMap<String, String> = corpus::generate().into_iter().collect();
let extra: Vec<&String> = files.keys().filter(|k| !generated.contains_key(*k)).collect();
let missing: Vec<&String> = generated.keys().filter(|k| !files.contains_key(*k)).collect();
let differ: Vec<&String> = generated.iter().filter(|(k, v)| files.get(*k).is_some_and(|f| f != *v)).map(|(k, _)| k).collect();
assert!(extra.is_empty() && missing.is_empty() && differ.is_empty(), "extra {extra:?}, missing {missing:?}, differ {differ:?}: run `wai_channel_lineup_vectors gen`");
}
#[test]
fn every_vector_gets_its_stated_outcome() {
let fails = corpus::verify(&committed());
assert!(fails.is_empty(), "{}", fails.join("\n"));
}
/// §10 of the extension lists exactly the codes `Reason::ADDED` has, each once, in order, and
/// then names live-events' two.
#[test]
fn the_extension_lists_the_reasons() {
let text = std::fs::read_to_string(wai_dir().join("extensions/channel-lineup.md")).expect("the extension");
let at = text.find("\n## 10. Reasons").expect("a §10");
let end = text[at + 1..].find("\n## ").map_or(text.len(), |e| at + 1 + e);
let section = &text[at..end];
let (table, after) = section.split_once("\nA verification also").expect("the table, then the reused codes");
let listed: Vec<String> = table.lines().filter(|l| l.starts_with("| `")).filter_map(|l| l.split('`').nth(1).map(String::from)).collect();
let want: Vec<String> = Reason::ADDED.iter().map(|r| r.code().to_string()).collect();
assert_eq!(listed, want, "§10's table and Reason::ADDED differ");
for r in Reason::REUSED {
assert!(after.contains(&format!("`{}`", r.code())), "§10 names {r}");
}
}
/// The commands of §9's worked example, as the text writes them, each a list of words.
fn worked_example() -> Vec<Vec<String>> {
let text = std::fs::read_to_string(wai_dir().join("extensions/channel-lineup.md")).expect("the extension");
let at = text.find("\n## 9. ").expect("a §9");
let block = text[at..].split("```").nth(1).expect("a code block in §9");
let joined = block.replace("\\\n", " ");
joined.lines().map(str::trim).filter(|l| l.starts_with("wai ")).map(|l| l.split_whitespace().skip(1).map(String::from).collect()).collect()
}
fn run(dir: &Path, args: &[String]) -> std::process::Output {
Command::new(env!("CARGO_BIN_EXE_wai")).args(args).current_dir(dir).output().expect("wai runs")
}
fn scratch(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("wai-channel-lineup-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).expect("a scratch directory");
for (n, body) in committed() {
std::fs::write(d.join(n), body).expect("a corpus file");
}
std::fs::write(d.join("operator.key"), hex(&corpus::operator().to_bytes())).expect("the key file");
d
}
fn hex(b: &[u8]) -> String {
b.iter().map(|x| format!("{x:02x}")).collect()
}
fn vectors() -> Value {
serde_json::from_str(&committed()["vectors.json"]).expect("vectors.json")
}
/// §9: the text's commands, run as written over the corpus's files, yield the corpus's receipt,
/// lineup, cues and guides, and the guide verifies.
#[test]
fn the_worked_example_runs_as_the_text_writes_it() {
let d = scratch("example");
let cmds = worked_example();
assert_eq!(cmds.len(), 4, "§9 shows an import, two guides and a verification: {cmds:?}");
for c in &cmds {
let o = run(&d, c);
assert!(o.status.success(), "wai {}: {}{}", c.join(" "), String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr));
}
let files = committed();
let v = vectors();
let sample = v["imports"].as_array().unwrap().iter().find(|x| x["name"] == "sample").unwrap();
assert_eq!(std::fs::read_to_string(d.join("out/channel.json")).unwrap(), files["sample.channel.json"], "the receipt");
assert_eq!(hex(&std::fs::read(d.join("out/lineup.wlu")).unwrap()), sample["outcome"]["lineup"].as_str().unwrap(), "the lineup");
let cues: Value = serde_json::from_slice(&std::fs::read(d.join("out/cues.json")).unwrap()).unwrap();
assert_eq!(cues, sample["outcome"]["cues"], "the cues");
assert_eq!(std::fs::read_to_string(d.join("guide.xml")).unwrap(), files["sample.guide.xml"], "the XMLTV guide");
assert_eq!(std::fs::read_to_string(d.join("now-next.json")).unwrap(), files["sample.now-next.json"], "the now/next document");
let _ = std::fs::remove_dir_all(&d);
}
/// A refusal is exit 1 with the text's reason as the envelope's code, and nothing is written; a
/// guide that a third party changed does not verify, and says where.
#[test]
fn a_refusal_is_its_reason_and_exit_1() {
let d = scratch("refusal");
let s = |v: &[&str]| v.iter().map(|x| x.to_string()).collect::<Vec<_>>();
let x = std::fs::read_to_string(d.join("sample.xmltv.xml")).unwrap().replace("start=\"20261029193000\"", "start=\"20261029193100\"");
std::fs::write(d.join("gap.xmltv.xml"), x).unwrap();
let o = run(&d, &s(&["channel", "import", "--channel", "sample-1", "--xmltv", "gap.xmltv.xml", "--owner", "owner-c", "--artwork", ".", "--key", "operator.key", "--out-dir", "gap", "--json"]));
assert_eq!(o.status.code(), Some(1), "{}", String::from_utf8_lossy(&o.stdout));
let e: Value = serde_json::from_slice(&o.stdout).expect("an envelope");
assert_eq!((e["ok"].as_bool(), e["error"]["code"].as_str()), (Some(false), Some("import_gap")), "{e}");
assert!(!d.join("gap").exists(), "a refused import writes nothing");
let g = std::fs::read_to_string(d.join("sample.guide.xml")).unwrap().replace(">hl-102</episode-num>", ">nf-1</episode-num>");
std::fs::write(d.join("changed.guide.xml"), g).unwrap();
let lineup = vectors()["imports"][0]["outcome"]["lineup"].as_str().unwrap().to_string();
let bytes: Vec<u8> = (0..lineup.len()).step_by(2).map(|i| u8::from_str_radix(&lineup[i..i + 2], 16).unwrap()).collect();
std::fs::write(d.join("lineup.wlu"), bytes).unwrap();
let o = run(&d, &s(&["channel", "verify", "--receipt", "sample.channel.json", "--lineup", "lineup.wlu", "--guide", "changed.guide.xml", "--json"]));
assert_eq!(o.status.code(), Some(1));
let e: Value = serde_json::from_slice(&o.stdout).expect("an envelope");
assert_eq!((e["error"]["code"].as_str(), e["data"]["entry"].as_u64(), e["data"]["part"].as_str()), (Some("guide_entry_unscheduled"), Some(2), Some("guide")), "{e}");
let o = run(&d, &s(&["channel", "verify", "--receipt", "sample.channel.json", "--lineup", "lineup.wlu", "--guide", "sample.guide.xml", "--json"]));
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stdout));
let e: Value = serde_json::from_slice(&o.stdout).expect("an envelope");
let aired: Vec<bool> = e["data"]["guide"]["programmes"].as_array().unwrap().iter().map(|p| p["aired"].as_bool().unwrap()).collect();
assert_eq!(aired, [true, true, false, false], "the first hour aired in the receipt's window: {e}");
let _ = std::fs::remove_dir_all(&d);
}