extensions/opportunity-report.md: the ad breaks a channel's own signed records prove, per owner and period (WOR1): the cues of a room a share contract names, made into breaks by live-ads and their SCTE 35 sections read for slots, and the program-as-run's ad entries no cue covers, each attributed by its start to the programme on air; a documented JSON and CSV form for a platform's reported figures, never evidence; and the check (WCK1) setting them side by side, with the operator's signed revenue-share statement where given. wai-rs: the opportunity_report module (feature opportunity_report), its SCTE 35 subset reader, the reported statement's reader, the corpus generator wai_opportunity_vectors and tests/opportunity_report.rs with the worked example. revenue_share's the_runs and asset_at become pub(crate), unchanged. Co-Authored-By: Claude Opus 5.5 <[email protected]>
173 lines
9.9 KiB
Rust
173 lines
9.9 KiB
Rust
//! `wai.delivery.opportunity` (extensions/opportunity-report.md): the committed corpus
|
|
//! `live-conformance/opportunity/vectors.json` regenerates byte for byte from its seeds and is the
|
|
//! only file there besides its README; every vector gets the outcome it states; every reason the
|
|
//! extension adds and every code it reuses refuses a vector; §13 lists exactly the reasons the
|
|
//! reference reports; and the worked example of §14: a channel's day of two owners, signed cues and
|
|
//! program-as-run, a reported statement that under-reports one owner's breaks, and the check that
|
|
//! shows it, computed and then recomputed from the public records alone.
|
|
#![cfg(feature = "opportunity_report")]
|
|
|
|
use std::collections::{BTreeMap, BTreeSet};
|
|
use std::path::PathBuf;
|
|
|
|
use serde_json::{Value, json};
|
|
use wai::opportunity_report::{self as opp, Reason, corpus, scope};
|
|
|
|
fn wai_dir() -> PathBuf {
|
|
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..")
|
|
}
|
|
|
|
fn read(p: &str) -> String {
|
|
std::fs::read_to_string(wai_dir().join(p)).unwrap_or_else(|e| panic!("{p}: {e}"))
|
|
}
|
|
|
|
fn committed() -> String {
|
|
read("live-conformance/opportunity/vectors.json")
|
|
}
|
|
|
|
fn doc() -> Value {
|
|
serde_json::from_str(&committed()).expect("the corpus is JSON")
|
|
}
|
|
|
|
#[test]
|
|
fn the_committed_corpus_regenerates_from_its_seeds() {
|
|
let generated = corpus::generate().expect("the corpus generates");
|
|
assert!(committed() == generated, "live-conformance/opportunity/vectors.json differs from `wai_opportunity_vectors gen`");
|
|
let mut files: Vec<String> = std::fs::read_dir(wai_dir().join("live-conformance/opportunity")).unwrap().flatten().map(|e| e.file_name().to_string_lossy().into_owned()).collect();
|
|
files.sort();
|
|
assert_eq!(files, ["README.md", "vectors.json"]);
|
|
}
|
|
|
|
#[test]
|
|
fn every_vector_gets_its_stated_outcome() {
|
|
let fails = corpus::verify(&committed());
|
|
assert!(fails.is_empty(), "{}", fails.join("\n"));
|
|
}
|
|
|
|
/// Every reason this extension adds and every code it reuses refuses a vector, and the texts it
|
|
/// delegates to refuse one each at their own places.
|
|
#[test]
|
|
fn the_corpus_covers_every_reason_and_case() {
|
|
let d = doc();
|
|
let vectors = d["vectors"].as_array().unwrap();
|
|
let seen: BTreeSet<&str> = vectors.iter().filter_map(|v| v["outcome"]["reason"].as_str()).collect();
|
|
for r in Reason::ALL {
|
|
assert!(seen.contains(r.code()), "nothing in the corpus is refused {r}");
|
|
}
|
|
for r in Reason::REUSED {
|
|
assert!(seen.contains(r), "nothing in the corpus is refused {r}");
|
|
}
|
|
let get = |n: &str| vectors.iter().find(|v| v["name"] == n).unwrap_or_else(|| panic!("no vector {n}"))["outcome"].clone();
|
|
assert_eq!(get("contract_offered")["reason"], "not_countersigned");
|
|
assert_eq!(get("run_aired_misstated")["at"], json!(["run", 0]));
|
|
assert_eq!(get("room_event_signature")["at"].as_array().unwrap()[..3], [json!("room"), json!(0), json!("room")]);
|
|
assert_eq!(get("check_report_refused")["at"], json!(["report", "report"]));
|
|
assert_eq!(get("check_statement_refused")["at"], json!(["statement", "statement"]));
|
|
for n in ["owner_a_day", "owner_b_day", "operator_day", "owner_a_runs_alone", "owner_b_two_rooms", "check_owner_b_under_reported", "check_owner_a_csv", "check_owner_b_without_statement", "check_owner_b_reports_a_programme"] {
|
|
assert_eq!(get(n)["outcome"], "accept", "{n}");
|
|
}
|
|
}
|
|
|
|
/// §13's table names exactly the codes the reference adds, in its order.
|
|
#[test]
|
|
fn section_13_lists_exactly_the_reference_codes() {
|
|
let text = read("extensions/opportunity-report.md");
|
|
let at = text.find("\n## 13. Reasons").expect("§13");
|
|
let end = text[at + 1..].find("\n## ").map_or(text.len(), |e| at + 1 + e);
|
|
let listed: Vec<&str> = text[at..end].lines().filter_map(|l| l.strip_prefix("| `")).filter_map(|l| l.split('`').next()).collect();
|
|
let codes: Vec<&str> = Reason::ALL.iter().map(|r| r.code()).collect();
|
|
assert_eq!(listed, codes);
|
|
}
|
|
|
|
fn line<'a>(o: &'a Value, scope_: &str, key: &str) -> &'a Value {
|
|
o["lines"].as_array().unwrap().iter().find(|l| l["scope"] == scope_ && l["key"] == key).unwrap_or_else(|| panic!("no line {scope_} {key}"))
|
|
}
|
|
|
|
/// The worked example (§14). Owner B computes and signs its report of the day from the channel's
|
|
/// two windows and the room's cues; its platform reports B's impressions per programme, and all
|
|
/// but leaves out B's second documentary; B checks the report against it and against the
|
|
/// operator's signed statement of the day. A party holding only the public records receives the
|
|
/// check's document as text, verifies it, and recomputes the opportunities of every line from
|
|
/// the attributed breaks and the contract alone.
|
|
#[test]
|
|
fn the_worked_example_shows_the_under_report_from_the_public_records() {
|
|
let w = corpus::world().expect("the world");
|
|
let b = &w.reports["owner_b"];
|
|
// B's report, computed from the documents without it, is the corpus's, byte for byte.
|
|
let (rep, report_doc) = opp::create(&w.pools.report_document(b, false), &corpus::key("owner-b"), corpus::pk("owner-b"), corpus::DAY16, corpus::DAY17).expect("B's report");
|
|
assert_eq!(rep.to_bytes(), b.report);
|
|
// Four cued breaks in B's documentaries: 360 s, twelve slots (four, two, four, two).
|
|
assert_eq!((rep.breaks, rep.break_ms, rep.slots), (4, 360_000, 12));
|
|
// The check of the platform's figures, beside the signed statement.
|
|
let inputs = w.pools.check_document(b, "b_programmes", Some(&w.statements["owner_b"]), None);
|
|
let (_, document) = opp::create_check(&inputs, &corpus::key("owner-b"), opp::FLOOR_PPM, opp::CEILING_PPM).expect("the check");
|
|
|
|
// A party holding only the public records: the document, as text.
|
|
let text = serde_json::to_string(&document).unwrap();
|
|
let got: Value = serde_json::from_str(&text).unwrap();
|
|
let a = opp::verify_check(&got).expect("the check verifies");
|
|
let o = opp::check_json(&a);
|
|
let period = line(&o, "period", "");
|
|
// Fourteen impressions reported against twenty-four the signed records prove, and revenue
|
|
// below B's signed part.
|
|
assert_eq!((period["impressions"].as_u64(), period["signed"]["impressions"].as_u64()), (Some(14), Some(24)));
|
|
assert_eq!(period["fill_ppm"], 1_166_666);
|
|
assert_eq!(period["flags"], json!(["impressions_below_signed", "revenue_below_signed"]));
|
|
// The first documentary is in step with the day; the second, as many slots and two
|
|
// impressions, falls far below it.
|
|
let doc_1 = line(&o, "programme", "b/doc-1");
|
|
let doc_2 = line(&o, "programme", "b/doc-2");
|
|
assert_eq!((doc_1["slots"].as_u64(), doc_1["flags"].clone()), (Some(6), json!([])));
|
|
assert_eq!((doc_2["slots"].as_u64(), doc_2["fill_ppm"].as_u64(), doc_2["flags"].clone()), (Some(6), Some(333_333), json!(["below"])));
|
|
|
|
// Recomputed from the report's attributed breaks and the contract: each programme's breaks,
|
|
// seconds and slots, over the breaks of the splits B shares in.
|
|
let report = opp::verify(&report_doc).expect("the report verifies");
|
|
let r = opp::accepted_json(&report);
|
|
let b_splits: BTreeSet<u64> = r["lines"].as_array().unwrap().iter().map(|l| l["split"].as_u64().unwrap()).collect();
|
|
let mut per: BTreeMap<String, (u64, u64, u64)> = BTreeMap::new();
|
|
for x in r["breaks"].as_array().unwrap() {
|
|
if !b_splits.contains(&x["split"].as_u64().unwrap()) {
|
|
continue;
|
|
}
|
|
let e = per.entry(x["asset"].as_str().unwrap_or("").to_string()).or_default();
|
|
e.0 += 1;
|
|
e.1 += x["duration_ms"].as_u64().unwrap();
|
|
e.2 += x["slots"].as_u64().unwrap_or(0);
|
|
}
|
|
for p in ["b/doc-1", "b/doc-2"] {
|
|
let l = line(&o, "programme", p);
|
|
assert_eq!(per[p], (l["breaks"].as_u64().unwrap(), l["break_ms"].as_u64().unwrap(), l["slots"].as_u64().unwrap()), "{p}");
|
|
}
|
|
// The signed figures, from the statement's own lines.
|
|
let st = &a.statement.as_ref().expect("the statement").statement;
|
|
assert_eq!(st.lines.iter().map(|l| l.impressions).sum::<u64>(), 24);
|
|
assert_eq!(period["signed"]["revenue"].as_u64(), Some(st.total));
|
|
// The check's lines are the only figures that change with the reported statement: the same
|
|
// report and statement beside owner A's fair CSV flag nothing.
|
|
let d = doc();
|
|
let v = d["vectors"].as_array().unwrap().iter().find(|v| v["name"] == "check_owner_a_csv").expect("A's check vector");
|
|
let fair = opp::verify_check(&corpus::document(&d, v).expect("it reads")).expect("A's check");
|
|
assert!(fair.check.lines.iter().all(|l| l.flags == 0), "A's reported statement is what its records prove");
|
|
assert_eq!(fair.check.lines[0].scope, scope::PERIOD);
|
|
}
|
|
|
|
/// Owner A's day holds every kind of break §4 makes: a break inside the ad slot after A's first
|
|
/// episode counts as cued and the slot is not counted again; the slot no cue covers is scheduled
|
|
/// and unslotted; slots come from a section or from the policy.
|
|
#[test]
|
|
fn owner_a_holds_every_kind_of_break() {
|
|
let w = corpus::world().expect("the world");
|
|
let a = opp::verify(&w.pools.report_document(&w.reports["owner_a"], true)).expect("A's report");
|
|
let mine: Vec<&opp::Break> = a.counted().collect();
|
|
let cued: Vec<&&opp::Break> = mine.iter().filter(|b| b.class == opp::class::CUED).collect();
|
|
let scheduled: Vec<&&opp::Break> = mine.iter().filter(|b| b.class == opp::class::SCHEDULED).collect();
|
|
assert_eq!(cued.len(), 4);
|
|
assert_eq!(scheduled.len(), 1);
|
|
assert_eq!((scheduled[0].slots, scheduled[0].asset.as_deref()), (None, Some("a/ep-2")), "the uncued ad slot, by the rule preceding");
|
|
let sources: Vec<Option<&str>> = cued.iter().map(|b| b.slot_source).collect();
|
|
assert_eq!(sources, [Some("policy"), Some("section"), Some("section"), Some("section")]);
|
|
assert_eq!(cued[2].asset.as_deref(), Some("a/ep-1"), "the break inside the ad slot, by the rule preceding");
|
|
let runs_alone = opp::verify(&w.pools.report_document(&w.reports["owner_a_runs_alone"], true)).expect("from the runs alone");
|
|
assert_eq!(runs_alone.counted().filter(|b| b.class == opp::class::SCHEDULED).count(), 2, "with no room, both ad slots are scheduled");
|
|
}
|