#![cfg(feature = "presets")]
#![allow(clippy::expect_used)]
use std::collections::BTreeSet;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use serde_json::Value;
use wikioxide::{
Clock, DocumentView, ExistenceMap, ExistenceProvider, ExistenceRecord, ExpandOutcome,
ExpandedDocument, InMemoryTemplateStore, PageContext, ProviderError, SiteConfig, Timestamp,
Title, WikitextParser,
};
fn fixtures_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/expand")
}
struct Fixed(Timestamp);
impl Clock for Fixed {
fn now(&self) -> Timestamp {
self.0
}
}
struct BundleExistence {
existing: BTreeSet<String>,
}
impl ExistenceProvider for BundleExistence {
fn lookup(&self, titles: &[Title]) -> Result<ExistenceMap, ProviderError> {
let mut map = ExistenceMap::new();
for title in titles {
map.insert(
title.clone(),
ExistenceRecord {
exists: self.existing.contains(&title.text()),
hidden_category: Some(false),
},
);
}
Ok(map)
}
}
#[test]
fn expansion_fixtures_match_expectations() {
let mut dirs: Vec<PathBuf> = fs::read_dir(fixtures_dir())
.expect("fixtures/expand must exist")
.filter_map(|e| {
let path = e.ok()?.path();
path.is_dir().then_some(path)
})
.collect();
dirs.sort();
assert!(
dirs.len() >= 18,
"expected ≥18 bundles, found {}",
dirs.len()
);
let mut failures = Vec::new();
for dir in &dirs {
run_bundle(dir, &mut failures);
}
assert!(
failures.is_empty(),
"{} expansion mismatches:\n{}",
failures.len(),
failures.join("\n")
);
}
fn run_bundle(dir: &Path, failures: &mut Vec<String>) {
let name = dir.file_name().and_then(|n| n.to_str()).unwrap_or("?");
let Ok(page) = fs::read_to_string(dir.join("page.wikitext")) else {
return; };
let expected: Value = serde_json::from_str(
&fs::read_to_string(dir.join("expected.json")).expect("expected.json readable"),
)
.expect("expected.json valid");
let meta = read_meta(&dir.join("meta.toml"));
let config = match meta.config.as_str() {
"ruwiki" => SiteConfig::ruwiki(),
_ => SiteConfig::enwiki(),
};
let store = Arc::new(InMemoryTemplateStore::new());
let mut existing: BTreeSet<String> = BTreeSet::new();
let templates_dir = dir.join("templates");
if templates_dir.is_dir() {
for entry in fs::read_dir(&templates_dir).expect("templates dir") {
let path = entry.expect("dir entry").path();
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
continue;
};
let title_text = decode_title(stem);
let Ok(title) = Title::parse(&title_text, config) else {
continue;
};
let body = fs::read_to_string(&path).expect("template body");
let body = body.strip_suffix('\n').unwrap_or(&body).to_owned();
existing.insert(title.text());
store.insert(title, body);
}
}
let missing_path = dir.join("missing.txt");
if let Ok(text) = fs::read_to_string(&missing_path) {
for line in text.lines().map(str::trim).filter(|l| !l.is_empty()) {
if let Ok(title) = Title::parse(line, config) {
store.insert_missing(title);
}
}
}
let existence = Arc::new(BundleExistence { existing });
let parser = WikitextParser::builder()
.site_config(config.clone())
.template_source(store)
.existence_provider(existence)
.clock(Arc::new(Fixed(Timestamp::from_unix(meta.now_unix))))
.build()
.expect("parser builds");
let ctx = match Title::parse(&meta.page_title, config) {
Ok(title) => PageContext::for_title(title),
Err(_) => PageContext::default(),
};
let doc = match parser.expand(&page, &ctx) {
ExpandOutcome::Complete(doc) => doc,
ExpandOutcome::MissingTemplates(m) => {
failures.push(format!(
"[{name}] unexpected MissingTemplates: {:?}",
m.titles().iter().map(Title::text).collect::<Vec<_>>()
));
return;
}
};
check(name, &doc, &expected, failures);
}
fn check(name: &str, doc: &ExpandedDocument, expected: &Value, failures: &mut Vec<String>) {
let mut fail = |key: &str, detail: String| failures.push(format!("[{name}] {key}: {detail}"));
if let Some(want) = expected["templates"].as_array() {
let got: Vec<String> = doc.transclusion_closure().iter().map(Title::text).collect();
let want: Vec<String> = want
.iter()
.filter_map(|t| t["title"].as_str().map(str::to_owned))
.collect();
if got != want {
fail("templates(closure)", format!("want {want:?}, got {got:?}"));
}
}
if let Some(want) = expected["categories"].as_array() {
let got: Vec<(String, String)> = doc
.categories()
.iter()
.map(|c| (c.category().text(), c.sortkey().to_owned()))
.collect();
let want: Vec<(String, String)> = want
.iter()
.map(|c| {
(
c["text"].as_str().unwrap_or_default().to_owned(),
c["sortkey"].as_str().unwrap_or_default().to_owned(),
)
})
.collect();
if got != want {
fail("categories", format!("want {want:?}, got {got:?}"));
}
}
if let Some(want) = expected["links"].as_array() {
let got: Vec<String> = doc.links().iter().map(|l| l.target().text()).collect();
let want: Vec<String> = want
.iter()
.filter_map(|l| l["text"].as_str().map(str::to_owned))
.collect();
if got != want {
fail("links", format!("want {want:?}, got {got:?}"));
}
}
if let Some(want) = expected["external_links"].as_array() {
let got: Vec<&str> = doc.external_links().iter().map(|e| e.url()).collect();
let want: Vec<&str> = want.iter().filter_map(Value::as_str).collect();
if got != want {
fail("external_links", format!("want {want:?}, got {got:?}"));
}
}
if let Some(want) = expected["modules"].as_array() {
let got: Vec<(String, String)> = doc
.modules()
.iter()
.map(|m| (m.module().text(), m.function().to_owned()))
.collect();
let want: Vec<(String, String)> = want
.iter()
.map(|m| {
(
m["module"].as_str().unwrap_or_default().to_owned(),
m["function"].as_str().unwrap_or_default().to_owned(),
)
})
.collect();
if got != want {
fail("modules", format!("want {want:?}, got {got:?}"));
}
}
if let Some(want) = expected["plaintext_equals"].as_str() {
if doc.plain_text() != want {
fail(
"plaintext_equals",
format!("want {want:?}, got {:?}", doc.plain_text()),
);
}
}
if let Some(subs) = expected["plaintext_contains"].as_array() {
for sub in subs.iter().filter_map(Value::as_str) {
if !doc.plain_text().contains(sub) {
fail(
"plaintext_contains",
format!("missing {sub:?}; got {:?}", doc.plain_text()),
);
}
}
}
if let Some(subs) = expected["warnings_contains"].as_array() {
let rendered: Vec<String> = doc.warnings().iter().map(ToString::to_string).collect();
for sub in subs.iter().filter_map(Value::as_str) {
if !rendered
.iter()
.any(|w| w.to_lowercase().contains(&sub.to_lowercase()))
{
fail(
"warnings_contains",
format!("no warning contains {sub:?}; warnings {rendered:?}"),
);
}
}
}
}
struct Meta {
config: String,
page_title: String,
now_unix: i64,
}
fn read_meta(path: &Path) -> Meta {
let text = fs::read_to_string(path).unwrap_or_default();
let mut config = "enwiki".to_owned();
let mut page_title = "Sandbox".to_owned();
let mut now_unix = 0_i64;
for line in text.lines() {
let Some((key, value)) = line.split_once('=') else {
continue;
};
let key = key.trim();
let value = value.trim().trim_matches('"');
match key {
"config" => config = value.to_owned(),
"page_title" => page_title = value.to_owned(),
"now_unix" => now_unix = value.parse().unwrap_or(0),
_ => {}
}
}
Meta {
config,
page_title,
now_unix,
}
}
fn decode_title(stem: &str) -> String {
match stem.split_once("__") {
Some((ns, name)) => format!("{ns}:{}", name.replace('_', " ")),
None => stem.replace('_', " "),
}
}