use std::collections::HashSet;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::conformance::{
CONFORMANCE_SCHEMA_VERSION, Comparison, ConformanceReason, ConformanceResult,
ConformanceStatus, Equivalence, FeatureId, FeatureKind, FeatureNamespace, ReasonCode,
TestArtifact, is_sha256_digest,
};
use workshop_rs::catalog::{Catalog, CatalogEntry, EnumDomain, Kind, Locale};
use workshop_rs::settings::{self as settings_schema, SettingDefinition, SettingValueDomain};
use workshop_rs::{WorkshopError, convert, emitter, parser, roundtrip};
#[derive(Clone, Copy)]
enum CensusCapabilityKind {
Variable,
PlayerVariable,
Subroutine,
ControlFlow,
String,
}
struct CensusCapability {
kind: CensusCapabilityKind,
name: &'static str,
}
const CENSUS_CAPABILITIES: &[CensusCapability] = &[
CensusCapability {
kind: CensusCapabilityKind::Variable,
name: "global",
},
CensusCapability {
kind: CensusCapabilityKind::PlayerVariable,
name: "player",
},
CensusCapability {
kind: CensusCapabilityKind::Subroutine,
name: "declaration-and-call",
},
CensusCapability {
kind: CensusCapabilityKind::ControlFlow,
name: "if",
},
CensusCapability {
kind: CensusCapabilityKind::ControlFlow,
name: "else-if",
},
CensusCapability {
kind: CensusCapabilityKind::ControlFlow,
name: "else",
},
CensusCapability {
kind: CensusCapabilityKind::ControlFlow,
name: "while",
},
CensusCapability {
kind: CensusCapabilityKind::ControlFlow,
name: "for-global-variable",
},
CensusCapability {
kind: CensusCapabilityKind::String,
name: "custom-string",
},
];
pub const CENSUS_SCHEMA_VERSION: u32 = 1;
pub const CENSUS_IDENTITY_SCHEMA_VERSION: u32 = 1;
const EN_US: &str = "en-US";
const ZH_CN: &str = "zh-CN";
const LOCALIZATION_EN_US_SOURCE: &str = r#"rule ("Localization") {
event {
Ongoing - Global;
}
actions {
Disable Inspector Recording;
}
}
"#;
const LOCALIZATION_ZH_CN_SOURCE: &str = r#"rule ("Localization") {
event {
持续 - 全局;
}
actions {
禁用查看器录制;
}
}
"#;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum CensusSupport {
Exercise,
Unsupported { detail: String },
KnownGap { detail: String },
Inconclusive { detail: String },
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CensusCase {
pub case_id: String,
pub features: Vec<FeatureId>,
#[serde(default = "default_source_locale")]
pub source_locale: String,
pub source: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reference_source: Option<String>,
pub support: CensusSupport,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CensusShard {
pub shard_id: String,
pub cases: Vec<CensusCase>,
}
impl CensusShard {
pub fn new(
shard_id: impl Into<String>,
mut cases: Vec<CensusCase>,
) -> Result<Self, CensusError> {
let shard_id = shard_id.into();
validate_name("shard_id", &shard_id)?;
cases.sort_by(|left, right| left.case_id.cmp(&right.case_id));
for case in &cases {
case.validate()?;
}
if cases
.windows(2)
.any(|pair| pair[0].case_id == pair[1].case_id)
{
return Err(CensusError::new(format!(
"shard '{shard_id}' contains duplicate case IDs"
)));
}
Ok(Self { shard_id, cases })
}
}
impl CensusCase {
fn probe(case_id: impl Into<String>, features: Vec<FeatureId>, source: String) -> Self {
Self {
case_id: case_id.into(),
features,
source_locale: EN_US.to_string(),
source,
reference_source: None,
support: generated_probe_support(),
}
}
fn exercise(mut self) -> Self {
self.support = CensusSupport::Exercise;
self
}
fn locale(mut self, locale: &str) -> Self {
self.source_locale = locale.to_string();
self
}
#[cfg(test)]
fn with_support(mut self, support: CensusSupport) -> Self {
self.support = support;
self
}
fn validate(&self) -> Result<(), CensusError> {
validate_name("case_id", &self.case_id)?;
if self.features.is_empty() {
return Err(CensusError::new(format!(
"case '{}' has no feature IDs",
self.case_id
)));
}
if self.source.trim().is_empty() {
return Err(CensusError::new(format!(
"case '{}' has no source",
self.case_id
)));
}
validate_name("source_locale", &self.source_locale)?;
if self
.reference_source
.as_deref()
.is_some_and(|source| source.trim().is_empty())
{
return Err(CensusError::new(format!(
"case '{}' has an empty reference source",
self.case_id
)));
}
let mut features = HashSet::new();
if self
.features
.iter()
.any(|feature| !features.insert(feature))
{
return Err(CensusError::new(format!(
"case '{}' contains duplicate feature IDs",
self.case_id
)));
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Census {
shards: Vec<CensusShard>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CensusIdentity {
pub schema_version: u32,
pub digest: String,
pub shards: Vec<String>,
}
impl Census {
pub fn assemble(mut shards: Vec<CensusShard>) -> Result<Self, CensusError> {
shards.sort_by(|left, right| left.shard_id.cmp(&right.shard_id));
let mut shard_ids = HashSet::new();
let mut case_ids = HashSet::new();
for shard in &shards {
if !shard_ids.insert(shard.shard_id.clone()) {
return Err(CensusError::new(format!(
"duplicate census shard '{}'",
shard.shard_id
)));
}
for case in &shard.cases {
if !case_ids.insert(case.case_id.clone()) {
return Err(CensusError::new(format!(
"duplicate census case '{}'",
case.case_id
)));
}
}
}
Ok(Self { shards })
}
pub fn builtin(catalog: &Catalog) -> Result<Self, CensusError> {
Self::assemble(vec![
catalog_shard(catalog, Kind::Event, "catalog-events")?,
catalog_shard(catalog, Kind::Action, "catalog-actions")?,
catalog_shard(catalog, Kind::Value, "catalog-values")?,
catalog_shard(catalog, Kind::Operator, "catalog-operators")?,
catalog_shard(catalog, Kind::Structural, "catalog-structural")?,
enum_shard(catalog)?,
settings_shard(catalog)?,
wir_shard()?,
localization_shard()?,
content_id_shard(catalog)?,
])
}
pub fn shards(&self) -> &[CensusShard] {
&self.shards
}
pub fn cases(&self) -> impl Iterator<Item = &CensusCase> {
self.shards.iter().flat_map(|shard| shard.cases.iter())
}
pub fn run(&self, catalog: &Catalog) -> CensusReport {
let mut results: Vec<_> = self
.shards
.iter()
.flat_map(|shard| {
shard
.cases
.iter()
.map(move |case| run_case(case, &shard.shard_id, catalog))
})
.collect();
results.sort_by(|left, right| left.case_id.cmp(&right.case_id));
CensusReport {
schema_version: CENSUS_SCHEMA_VERSION,
conformance_schema_version: CONFORMANCE_SCHEMA_VERSION,
catalog: catalog.identity(),
census: self.identity(),
results,
}
}
pub fn identity(&self) -> CensusIdentity {
let definition = self
.export_json()
.expect("census definitions must remain serializable");
CensusIdentity {
schema_version: CENSUS_IDENTITY_SCHEMA_VERSION,
digest: sha256(&definition),
shards: self
.shards
.iter()
.map(|shard| shard.shard_id.clone())
.collect(),
}
}
pub fn export_json(&self) -> Result<String, CensusError> {
serde_json::to_string_pretty(&self.shards)
.map_err(|error| CensusError::new(format!("cannot serialize census shards: {error}")))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CensusReport {
pub schema_version: u32,
pub conformance_schema_version: u32,
pub catalog: workshop_rs::catalog::CatalogIdentity,
pub census: CensusIdentity,
pub results: Vec<ConformanceResult>,
}
impl CensusReport {
pub fn validate(&self) -> Result<(), CensusError> {
let catalog =
Catalog::builtin().map_err(|error| CensusError::new(format!("catalog: {error}")))?;
self.validate_against(&catalog)
}
pub fn validate_against(&self, catalog: &Catalog) -> Result<(), CensusError> {
if self.schema_version != CENSUS_SCHEMA_VERSION {
return Err(CensusError::new("unsupported census schema version"));
}
if self.conformance_schema_version != CONFORMANCE_SCHEMA_VERSION {
return Err(CensusError::new("unsupported conformance schema version"));
}
if self.catalog != catalog.identity() {
return Err(CensusError::new(
"report catalog identity does not match the loaded catalog",
));
}
if self.census.schema_version != CENSUS_IDENTITY_SCHEMA_VERSION {
return Err(CensusError::new(
"unsupported census identity schema version",
));
}
if !is_sha256_digest(&self.census.digest) {
return Err(CensusError::new(
"census identity digest must be a SHA-256 hex digest",
));
}
if self.census.shards.is_empty()
|| self.census.shards.windows(2).any(|pair| pair[0] >= pair[1])
{
return Err(CensusError::new(
"report shards must be non-empty and strictly sorted",
));
}
for result in &self.results {
result
.validate_against(catalog)
.map_err(|error| CensusError::new(error.to_string()))?;
let matching_shards = self
.census
.shards
.iter()
.filter(|shard| {
result
.case_id
.strip_prefix(shard.as_str())
.is_some_and(|rest| rest.starts_with('/'))
})
.count();
if matching_shards != 1 {
return Err(CensusError::new(format!(
"result '{}' does not map to exactly one census shard",
result.case_id
)));
}
}
Ok(())
}
pub fn to_json(&self) -> Result<String, CensusError> {
self.validate()?;
serde_json::to_string_pretty(self)
.map_err(|error| CensusError::new(format!("cannot serialize census report: {error}")))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CensusError {
pub message: String,
}
impl CensusError {
fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
}
}
}
impl std::fmt::Display for CensusError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(&self.message)
}
}
impl std::error::Error for CensusError {}
fn validate_name(field: &str, value: &str) -> Result<(), CensusError> {
if value.trim().is_empty() || value.chars().any(char::is_control) {
Err(CensusError::new(format!(
"{field} must be non-empty and printable"
)))
} else {
Ok(())
}
}
fn default_source_locale() -> String {
EN_US.to_string()
}
fn feature(namespace: FeatureNamespace, kind: FeatureKind, name: impl Into<String>) -> FeatureId {
FeatureId::new(namespace, kind, name).expect("canonical census feature ID")
}
fn catalog_feature(kind: Kind, id: &str) -> FeatureId {
FeatureId::from_catalog(kind, id).expect("catalog IDs are validated by Catalog::load")
}
fn catalog_shard(
catalog: &Catalog,
kind: Kind,
shard_id: &str,
) -> Result<CensusShard, CensusError> {
let cases = catalog
.entries_of(kind)
.map(|entry| {
let source = match kind {
Kind::Event => event_probe(catalog, entry),
Kind::Action => action_probe(catalog, entry),
Kind::Value => value_probe(catalog, entry),
Kind::Operator => operator_probe(catalog, entry),
Kind::Structural => structural_probe(catalog, entry),
Kind::Setting => unreachable!("settings use the settings table"),
Kind::Enum => unreachable!("enum domains use the enum shard"),
};
CensusCase::probe(
format!("{shard_id}/{}", entry.id),
vec![catalog_feature(kind, &entry.id)],
source,
)
})
.collect();
CensusShard::new(shard_id, cases)
}
fn enum_shard(catalog: &Catalog) -> Result<CensusShard, CensusError> {
enum_member_cases(catalog, "catalog-enums", false, true)
}
fn content_id_shard(catalog: &Catalog) -> Result<CensusShard, CensusError> {
enum_member_cases(catalog, "content-ids", true, false)
}
fn enum_member_cases(
catalog: &Catalog,
shard_id: &str,
content_only: bool,
domain_feature: bool,
) -> Result<CensusShard, CensusError> {
let mut cases = Vec::new();
for domain in catalog.enum_domains() {
if content_only && !matches!(domain.domain.as_str(), "Hero" | "Map") {
continue;
}
for member in &domain.members {
let mut features = Vec::with_capacity(2);
if domain_feature {
features.push(catalog_feature(Kind::Enum, &domain.domain));
}
features.push(
FeatureId::from_enum_member(&domain.domain, &member.member)
.expect("canonical enum member ID"),
);
cases.push(CensusCase::probe(
format!("{shard_id}/{}/{}", domain.domain, member.member),
features,
enum_probe(catalog, domain, &member.member),
));
}
}
CensusShard::new(shard_id, cases)
}
fn settings_shard(catalog: &Catalog) -> Result<CensusShard, CensusError> {
let cases = settings_schema::definitions()
.map(|definition| {
let path = definition.path().to_string();
CensusCase::probe(
format!("settings/{path}"),
vec![feature(
FeatureNamespace::Settings,
FeatureKind::Setting,
path,
)],
settings_probe(&definition, catalog),
)
})
.collect();
CensusShard::new("settings", cases)
}
fn wir_shard() -> Result<CensusShard, CensusError> {
let cases = CENSUS_CAPABILITIES
.iter()
.map(|capability| match capability.kind {
CensusCapabilityKind::Variable => wir_case(
"variables-global",
FeatureKind::Variable,
capability.name,
variables_source(),
),
CensusCapabilityKind::PlayerVariable => CensusCase::probe(
"wir/variables-player".to_string(),
vec![feature(
FeatureNamespace::Wir,
FeatureKind::Variable,
capability.name,
)],
player_variable_source(),
),
CensusCapabilityKind::Subroutine => wir_case(
"subroutine",
FeatureKind::Subroutine,
capability.name,
subroutine_source(),
),
CensusCapabilityKind::ControlFlow => {
let actions = match capability.name {
"if" => "If(True);\n Wait(0);\nEnd;",
"else-if" => "If(True);\n Wait(0);\nElse If(False);\n Wait(0);\nEnd;",
"else" => "If(True);\n Wait(0);\nElse;\n Wait(0);\nEnd;",
"while" => "While(True);\n Wait(0);\nEnd;",
"for-global-variable" => {
"For Global Variable(probe, 0, 1, 1);\n Wait(0);\nEnd;"
}
_ => unreachable!("unknown WIR control-flow census capability"),
};
control_flow_case(capability.name, actions)
}
CensusCapabilityKind::String => CensusCase::probe(
"wir/string/custom-string".to_string(),
vec![feature(
FeatureNamespace::Wir,
FeatureKind::String,
capability.name,
)],
rule_source(
"String",
"Set Global Variable(probe, Custom String(\"census\"));",
),
)
.exercise(),
})
.collect();
CensusShard::new("wir", cases)
}
fn localization_shard() -> Result<CensusShard, CensusError> {
CensusShard::new(
"localization",
vec![
CensusCase::probe(
"localization/en-us-to-zh-cn".to_string(),
vec![feature(
FeatureNamespace::Localization,
FeatureKind::Localization,
"en-us-to-zh-cn",
)],
LOCALIZATION_EN_US_SOURCE.to_string(),
),
CensusCase::probe(
"localization/zh-cn-to-en-us".to_string(),
vec![feature(
FeatureNamespace::Localization,
FeatureKind::Localization,
"zh-cn-to-en-us",
)],
LOCALIZATION_ZH_CN_SOURCE.to_string(),
)
.locale(ZH_CN),
],
)
}
fn wir_case(case_id: &str, kind: FeatureKind, name: &str, source: String) -> CensusCase {
CensusCase::probe(
format!("wir/{case_id}"),
vec![feature(FeatureNamespace::Wir, kind, name)],
source,
)
.exercise()
}
fn control_flow_case(name: &str, actions: &str) -> CensusCase {
CensusCase::probe(
format!("wir/control-flow/{name}"),
vec![feature(
FeatureNamespace::Wir,
FeatureKind::ControlFlow,
name,
)],
rule_source(name, actions),
)
}
fn generated_probe_support() -> CensusSupport {
CensusSupport::Inconclusive {
detail: "generated probe has no independently recorded expected result".to_string(),
}
}
fn rule_source(name: &str, actions: &str) -> String {
format!(
"variables {{\n global:\n 0: probe\n}}\n\nrule (\"{name}\") {{\n event {{\n Ongoing - Global;\n }}\n actions {{\n {actions}\n }}\n}}\n"
)
}
fn variables_source() -> String {
"variables {\n global:\n 0: probe\n}\n\nrule (\"Global variable\") {\n event {\n Ongoing - Global;\n }\n actions {\n Set Global Variable(probe, 1);\n }\n}\n"
.to_string()
}
fn player_variable_source() -> String {
"variables {\n player:\n 0: probe\n}\n\nrule (\"Player variable\") {\n event {\n Ongoing - Each Player;\n All;\n All;\n }\n actions {\n Set Player Variable(Event Player, probe, 1);\n }\n}\n"
.to_string()
}
fn subroutine_source() -> String {
"subroutines {\n 0: probe\n}\n\nrule (\"Subroutine\") {\n event {\n Subroutine;\n probe;\n }\n actions {\n Call Subroutine(probe);\n }\n}\n"
.to_string()
}
fn event_probe(catalog: &Catalog, entry: &CatalogEntry) -> String {
let spelling = catalog
.spelling(Kind::Event, &Locale::new(EN_US), &entry.id)
.unwrap_or(&entry.id);
let filters = if matches!(entry.id.as_str(), "global" | "subroutine") {
String::new()
} else {
" All;\n All;\n".to_string()
};
let subroutine = if entry.id == "subroutine" {
" probe;\n"
} else {
""
};
format!(
"subroutines {{\n 0: probe\n}}\n\nrule (\"Event\") {{\n event {{\n {spelling};\n{filters}{subroutine} }}\n actions {{\n Wait;\n }}\n}}\n"
)
}
fn action_probe(catalog: &Catalog, entry: &CatalogEntry) -> String {
let spelling = catalog
.spelling(Kind::Action, &Locale::new(EN_US), &entry.id)
.unwrap_or(&entry.id);
let call = if matches!(
entry.id.as_str(),
"chasePlayerVariableAtRate" | "chasePlayerVariableOverTime"
) {
format!("{spelling}(Event Player, probe, 0, 1, 0);")
} else {
format!("{spelling};")
};
rule_source("Action", &call)
}
fn value_probe(catalog: &Catalog, entry: &CatalogEntry) -> String {
let spelling = catalog
.spelling(Kind::Value, &Locale::new(EN_US), &entry.id)
.unwrap_or(&entry.id);
rule_source("Value", &format!("Set Global Variable(probe, {spelling});"))
}
fn operator_probe(catalog: &Catalog, entry: &CatalogEntry) -> String {
let spelling = catalog
.spelling(Kind::Operator, &Locale::new(EN_US), &entry.id)
.unwrap_or(&entry.id);
rule_source(
"Operator",
&format!("If(1 {spelling} 1);\n Wait(0);\nEnd;"),
)
}
fn structural_probe(catalog: &Catalog, entry: &CatalogEntry) -> String {
let spelling = catalog
.spelling(Kind::Structural, &Locale::new(EN_US), &entry.id)
.unwrap_or(&entry.id);
let actions = match entry.id.as_str() {
"if" => format!("{spelling}(True);\n Wait(0);\nEnd;"),
"elseIf" => format!("If(True);\n Wait(0);\n{spelling}(False);\n Wait(0);\nEnd;"),
"else" => format!("If(True);\n Wait(0);\n{spelling};\n Wait(0);\nEnd;"),
"end" => format!("If(True);\n Wait(0);\n{spelling};"),
"while" => format!("{spelling}(True);\n Wait(0);\nEnd;"),
"forGlobalVariable" => format!("{spelling}(probe, 0, 1, 1);\n Wait(0);\nEnd;"),
"setGlobalVariable" => format!("{spelling}(probe, 1);"),
"modifyGlobalVariable" => format!("{spelling}(probe, Add, 1);"),
"setPlayerVariable" => format!("{spelling}(Event Player, probe, 1);"),
"modifyPlayerVariable" => format!("{spelling}(Event Player, probe, Add, 1);"),
"callSubroutine" => format!("{spelling}(probe);"),
_ => format!("{spelling};"),
};
let prefix = match entry.id.as_str() {
"setPlayerVariable" | "modifyPlayerVariable" => {
"variables {\n player:\n 0: probe\n}\n\n"
}
"callSubroutine" => "subroutines {\n 0: probe\n}\n\n",
_ => "",
};
format!("{prefix}{}", rule_source("Structural", &actions))
}
fn enum_probe(catalog: &Catalog, domain: &EnumDomain, member: &str) -> String {
let locale = Locale::new(EN_US);
let domain_spelling = catalog
.spelling(Kind::Value, &locale, &domain.domain)
.unwrap_or(&domain.domain);
let member_spelling = catalog
.enum_spelling(&domain.domain, &locale, member)
.unwrap_or(member);
rule_source(
"Enum",
&format!("Set Global Variable(probe, {domain_spelling}({member_spelling}));"),
)
}
fn settings_probe(definition: &SettingDefinition, catalog: &Catalog) -> String {
let mut lines = vec!["settings {".to_string()];
let mut depth = 1;
let parts: Vec<_> = definition.path().split('.').collect();
for (index, part) in parts.iter().enumerate() {
let name = match *part {
"gamemodes" => "modes".to_string(),
"heroes" => "heroes".to_string(),
"<team>" => "General".to_string(),
"<hero>" => {
catalog_enum_spelling(catalog, "Hero", "mei").unwrap_or_else(|| "Mei".to_string())
}
"general" => "General".to_string(),
value if index == 1 && parts.first() == Some(&"gamemodes") => {
mode_spelling(catalog, value)
}
value => value.to_string(),
};
lines.push(format!("{}{} {{", " ".repeat(depth), name));
depth += 1;
}
let indent = " ".repeat(depth);
let name = definition.presentation().english_name;
match definition.domain() {
SettingValueDomain::PresenceOnly => lines.push(format!("{indent}{name}")),
SettingValueDomain::String => lines.push(format!("{indent}{name}: \"census\"")),
SettingValueDomain::Boolean => {
let value = definition
.enum_members()
.next()
.map(|member| member.english_name().to_string())
.unwrap_or_else(|| "On".to_string());
lines.push(format!("{indent}{name}: {value}"));
}
SettingValueDomain::Number(_) => lines.push(format!("{indent}{name}: 1")),
SettingValueDomain::Percent(_) => lines.push(format!("{indent}{name}: 100%")),
SettingValueDomain::Enum { .. } => {
let value = definition
.enum_members()
.next()
.map(|member| member.english_name().to_string())
.unwrap_or_else(|| "Off".to_string());
lines.push(format!("{indent}{name}: {value}"));
}
SettingValueDomain::HeroList | SettingValueDomain::MapList => {
lines.push(format!("{indent}{name} {{"));
lines.push(format!("{indent}}}"));
}
other => unreachable!("census has no probe for settings value domain {other:?}"),
}
while depth > 1 {
depth -= 1;
lines.push(format!("{}{}", " ".repeat(depth), "}"));
}
lines.push("}".to_string());
lines.join("\n")
}
fn mode_spelling(catalog: &Catalog, key: &str) -> String {
catalog_enum_spelling(catalog, "Gamemode", key).unwrap_or_else(|| key.to_string())
}
fn catalog_enum_spelling(catalog: &Catalog, domain: &str, key: &str) -> Option<String> {
let normalize = |value: &str| {
value
.chars()
.filter(|character| character.is_ascii_alphanumeric())
.flat_map(char::to_lowercase)
.collect::<String>()
};
let normalized = normalize(key);
let member = catalog
.enum_domains()
.find(|candidate| candidate.domain == domain)?
.members
.iter()
.find(|candidate| normalize(&candidate.member) == normalized)?;
catalog
.enum_spelling(domain, &Locale::new(EN_US), &member.member)
.map(str::to_string)
}
fn run_case(case: &CensusCase, shard_id: &str, catalog: &Catalog) -> ConformanceResult {
let source = artifact(
format!("census/{shard_id}/{}.ws", case.case_id),
&case.source,
);
let base = |status, comparison, reason, locale| ConformanceResult {
schema_version: CONFORMANCE_SCHEMA_VERSION,
case_id: case.case_id.clone(),
features: case.features.clone(),
status,
comparison,
source: source.clone(),
catalog: catalog.identity(),
locale,
reason,
};
match &case.support {
CensusSupport::Unsupported { detail } => base(
ConformanceStatus::Unsupported,
not_comparable(),
Some(reason(ReasonCode::Unsupported, detail)),
None,
),
CensusSupport::KnownGap { detail } => base(
ConformanceStatus::KnownGap,
not_comparable(),
Some(reason(ReasonCode::KnownGap, detail)),
None,
),
CensusSupport::Inconclusive { detail } => execute_case(case, base, catalog, Some(detail)),
CensusSupport::Exercise => execute_case(case, base, catalog, None),
}
}
struct Fail {
code: ReasonCode,
detail: String,
locale: Locale,
}
impl Fail {
fn workshop(error: &WorkshopError, locale: &Locale) -> Self {
let code = match error {
WorkshopError::Unsupported { .. } => ReasonCode::Unsupported,
WorkshopError::MissingMapping { .. } => ReasonCode::KnownGap,
_ => ReasonCode::UnexpectedRegression,
};
Self {
code,
detail: error.to_string(),
locale: locale.clone(),
}
}
fn regression(detail: impl Into<String>, locale: &Locale) -> Self {
Self {
code: ReasonCode::UnexpectedRegression,
detail: detail.into(),
locale: locale.clone(),
}
}
}
fn execute_case(
case: &CensusCase,
base: impl Fn(
ConformanceStatus,
Comparison,
Option<ConformanceReason>,
Option<Locale>,
) -> ConformanceResult,
catalog: &Catalog,
inconclusive_detail: Option<&str>,
) -> ConformanceResult {
match run_pipeline(case, catalog, inconclusive_detail) {
Ok((comparison, locale)) => {
base(ConformanceStatus::Matched, comparison, None, Some(locale))
}
Err(fail) => failed_text(base, fail.code, fail.detail, Some(fail.locale)),
}
}
fn run_pipeline(
case: &CensusCase,
catalog: &Catalog,
inconclusive_detail: Option<&str>,
) -> Result<(Comparison, Locale), Fail> {
let source_locale = Locale::new(&case.source_locale);
let target_locale = if source_locale.as_str() == ZH_CN {
Locale::new(EN_US)
} else {
Locale::new(ZH_CN)
};
let parse = |source: &str, locale: &Locale| {
parser::parse_with_context(source, catalog, locale, catalog)
.map_err(|error| Fail::workshop(&error, locale))
};
let validate = |program: &workshop_rs::Program, locale: &Locale| {
program
.validate()
.map_err(|error| Fail::regression(error.to_string(), locale))
};
let emit = |program: &workshop_rs::Program, locale: &Locale| {
emitter::emit(program, catalog, locale).map_err(|error| Fail::workshop(&error, locale))
};
let convert_or =
|source: &str, from: &Locale, to: &Locale| -> Result<convert::Conversion, Fail> {
convert::convert(source, catalog, from, to, &Default::default())
.map_err(|error| Fail::workshop(&error, to))
};
let program = parse(&case.source, &source_locale)?;
validate(&program, &source_locale)?;
let emitted = emit(&program, &source_locale)?;
let reparsed = parse(&emitted, &source_locale)?;
validate(&reparsed, &source_locale)?;
let reemitted = emit(&reparsed, &source_locale)?;
if !roundtrip::equivalent(&program, &reparsed)
|| normalize_workshop(&emitted) != normalize_workshop(&reemitted)
{
return Err(Fail::regression(
"en-US semantic or normalized gate diverged",
&source_locale,
));
}
let converted = convert_or(&case.source, &source_locale, &target_locale)?;
let target_program = parse(&converted.text, &target_locale)?;
validate(&target_program, &target_locale)?;
if !roundtrip::equivalent(&program, &target_program) {
return Err(Fail::regression(
"zh-CN conversion changed canonical WIR semantics",
&target_locale,
));
}
let back = convert_or(&converted.text, &target_locale, &source_locale)?;
let back_program = parse(&back.text, &source_locale)?;
if !roundtrip::equivalent(&program, &back_program)
|| normalize_workshop(&back.text) != normalize_workshop(&case.source)
{
return Err(Fail::regression(
"cross-locale semantic or normalized gate diverged",
&source_locale,
));
}
let Some(reference_source) = case.reference_source.as_ref() else {
return Err(Fail {
code: ReasonCode::Inconclusive,
detail: inconclusive_detail
.unwrap_or(
"offline semantic and locale gates passed, but no independent expectation artifact is recorded",
)
.to_string(),
locale: source_locale,
});
};
let expected_program = parse(reference_source, &target_locale)?;
if !roundtrip::equivalent(&program, &expected_program)
|| normalize_workshop(&converted.text) != normalize_workshop(reference_source)
{
return Err(Fail::regression(
"conversion differed from the independent reference source",
&target_locale,
));
}
Ok((
Comparison {
mode: Equivalence::Semantic,
expected: Some(reference_artifact(case, reference_source)),
observed: Some(artifact(
format!("census/{}/converted-output.ws", case.case_id),
&converted.text,
)),
normalizer: Some("canonical-wir;normalized-workshop-text".to_string()),
},
source_locale,
))
}
fn failed_text(
base: impl Fn(
ConformanceStatus,
Comparison,
Option<ConformanceReason>,
Option<Locale>,
) -> ConformanceResult,
code: ReasonCode,
detail: String,
locale: Option<Locale>,
) -> ConformanceResult {
let status = match code {
ReasonCode::Unsupported => ConformanceStatus::Unsupported,
ReasonCode::KnownGap => ConformanceStatus::KnownGap,
ReasonCode::UnexpectedRegression => ConformanceStatus::UnexpectedRegression,
ReasonCode::Inconclusive => ConformanceStatus::Inconclusive,
};
let comparison = if code == ReasonCode::UnexpectedRegression {
Comparison {
mode: Equivalence::Normalized,
expected: None,
observed: None,
normalizer: Some("census-stage".to_string()),
}
} else {
not_comparable()
};
base(status, comparison, Some(reason(code, &detail)), locale)
}
fn reason(code: ReasonCode, detail: &str) -> ConformanceReason {
ConformanceReason {
code,
detail: detail.to_string(),
}
}
fn not_comparable() -> Comparison {
Comparison {
mode: Equivalence::NotComparable,
expected: None,
observed: None,
normalizer: None,
}
}
fn artifact(name: impl Into<String>, content: &str) -> TestArtifact {
TestArtifact {
name: name.into(),
revision: None,
path: None,
sha256: Some(sha256(content)),
license: Some("MIT".to_string()),
}
}
fn reference_artifact(case: &CensusCase, content: &str) -> TestArtifact {
TestArtifact {
name: format!("census reference for {}", case.case_id),
revision: Some("census-v1".to_string()),
path: Some("tests/fixtures/census/reference.ws".to_string()),
sha256: Some(sha256(content)),
license: Some("MIT".to_string()),
}
}
fn sha256(content: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(content.as_bytes());
format!("{:x}", hasher.finalize())
}
fn normalize_workshop(text: &str) -> String {
text.split_whitespace().collect::<Vec<_>>().join(" ")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builtin_census_is_derived_and_deterministic() {
let catalog = Catalog::builtin().expect("builtin catalog");
let first = Census::builtin(&catalog).expect("census");
let second = Census::builtin(&catalog).expect("census");
assert_eq!(first, second);
assert_eq!(first.shards().first().unwrap().shard_id, "catalog-actions");
assert!(
first
.cases()
.any(|case| case.features.iter().any(|feature| feature.name == "wait"))
);
assert!(first.cases().any(|case| {
case.features
.iter()
.any(|feature| feature.kind == FeatureKind::Setting)
}));
assert!(first.cases().any(|case| {
case.features
.iter()
.any(|feature| feature.kind == FeatureKind::ControlFlow)
}));
assert_eq!(first.export_json().unwrap(), second.export_json().unwrap());
assert_eq!(first.identity(), second.identity());
assert_eq!(first.identity().digest.len(), 64);
}
#[test]
fn settings_probes_use_catalog_gamemode_spellings() {
let catalog = Catalog::builtin().expect("builtin catalog");
for (mode, expected) in [
("ffa", "Deathmatch"),
("tdm", "Team Deathmatch"),
("ctf", "Capture The Flag"),
] {
let prefix = format!("gamemodes.{mode}.");
let definition = settings_schema::definitions()
.find(|definition| definition.path().starts_with(&prefix))
.unwrap_or_else(|| panic!("missing settings definition for {mode}"));
let probe = settings_probe(&definition, &catalog);
assert!(
probe.contains(&format!("{expected} {{")),
"{mode} probe used the wrong mode spelling:\n{probe}"
);
}
}
#[test]
fn explicit_non_matching_states_remain_machine_readable() {
let feature_case = |id: &str, support| {
CensusCase::probe(
format!("state-tests/{id}"),
vec![feature(FeatureNamespace::Wir, FeatureKind::Structural, id)],
format!("rule (\"{id}\") {{}}"),
)
.with_support(support)
};
let shard = CensusShard::new(
"state-tests",
vec![
feature_case(
"unsupported",
CensusSupport::Unsupported {
detail: "not declared".to_string(),
},
),
feature_case(
"known-gap",
CensusSupport::KnownGap {
detail: "missing mapping".to_string(),
},
),
feature_case(
"inconclusive",
CensusSupport::Inconclusive {
detail: "no oracle".to_string(),
},
),
],
)
.unwrap();
let report = Census::assemble(vec![shard])
.unwrap()
.run(&Catalog::builtin().unwrap());
report
.validate()
.expect("states use the current conformance adapter");
let json = report.to_json().unwrap();
assert!(json.contains("unsupported"));
assert!(json.contains("known-gap"));
assert!(json.contains("inconclusive"));
}
#[test]
fn builtin_census_report_validates_against_the_catalog() {
let catalog = Catalog::builtin().expect("builtin catalog");
let census = Census::builtin(&catalog).expect("census");
let report = census.run(&catalog);
report
.validate_against(&catalog)
.expect("census results use canonical catalog identities");
assert_eq!(report.census, census.identity());
assert_eq!(
report
.results
.iter()
.filter(|result| result.case_id.starts_with("localization/"))
.count(),
2
);
}
#[test]
fn census_report_rejects_a_malformed_identity_digest() {
let catalog = Catalog::builtin().expect("builtin catalog");
let census = Census::builtin(&catalog).expect("census");
let mut report = census.run(&catalog);
report.census.digest = "not-a-digest".to_string();
let error = report
.validate_against(&catalog)
.expect_err("report identity must carry a SHA-256 digest");
assert!(error.to_string().contains("SHA-256"));
}
}