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]>
150 lines
8.2 KiB
Rust
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);
|
|
}
|