//! `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 { 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 = 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 = table.lines().filter(|l| l.starts_with("| `")).filter_map(|l| l.split('`').nth(1).map(String::from)).collect(); let want: Vec = 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> { 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::>(); 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", ">nf-1"); 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 = (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 = 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); }