Skip to main content

workshop_rs_cli/
census.rs

1//! Deterministic, sharded census of the canonical Workshop surface.
2//!
3//! The census is derived from this crate's catalog, settings table, and WIR
4//! capabilities. It is tooling that runs contract/regression probes and
5//! produces structured results, not a source-language inventory or a
6//! live-client oracle.
7
8use std::collections::{HashMap, HashSet};
9
10use serde::{Deserialize, Serialize};
11use sha2::{Digest, Sha256};
12
13use crate::conformance::{
14    CONFORMANCE_SCHEMA_VERSION, Comparison, ConformanceReason, ConformanceResult,
15    ConformanceStatus, Equivalence, FeatureId, FeatureKind, FeatureNamespace, ReasonCode,
16    TestArtifact, is_sha256_digest,
17};
18use workshop_rs::catalog::{Catalog, CatalogEntry, EnumDomain, Kind, Locale};
19use workshop_rs::gameplay::{self, HeroId};
20use workshop_rs::settings::{
21    self as settings_schema, SettingDefinition, SettingTarget, SettingTargetKind,
22    SettingValueDomain,
23};
24use workshop_rs::{WorkshopError, convert, emitter, parser, roundtrip};
25
26#[derive(Clone, Copy)]
27enum CensusCapabilityKind {
28    Variable,
29    PlayerVariable,
30    Subroutine,
31    ControlFlow,
32    String,
33}
34
35struct CensusCapability {
36    kind: CensusCapabilityKind,
37    name: &'static str,
38}
39
40const CENSUS_CAPABILITIES: &[CensusCapability] = &[
41    CensusCapability {
42        kind: CensusCapabilityKind::Variable,
43        name: "global",
44    },
45    CensusCapability {
46        kind: CensusCapabilityKind::PlayerVariable,
47        name: "player",
48    },
49    CensusCapability {
50        kind: CensusCapabilityKind::Subroutine,
51        name: "declaration-and-call",
52    },
53    CensusCapability {
54        kind: CensusCapabilityKind::ControlFlow,
55        name: "if",
56    },
57    CensusCapability {
58        kind: CensusCapabilityKind::ControlFlow,
59        name: "else-if",
60    },
61    CensusCapability {
62        kind: CensusCapabilityKind::ControlFlow,
63        name: "else",
64    },
65    CensusCapability {
66        kind: CensusCapabilityKind::ControlFlow,
67        name: "while",
68    },
69    CensusCapability {
70        kind: CensusCapabilityKind::ControlFlow,
71        name: "for-global-variable",
72    },
73    CensusCapability {
74        kind: CensusCapabilityKind::String,
75        name: "custom-string",
76    },
77];
78
79pub const CENSUS_SCHEMA_VERSION: u32 = 1;
80pub const CENSUS_IDENTITY_SCHEMA_VERSION: u32 = 1;
81const EN_US: &str = "en-US";
82const ZH_CN: &str = "zh-CN";
83const LOCALIZATION_EN_US_SOURCE: &str = r#"rule ("Localization") {
84    event {
85        Ongoing - Global;
86    }
87    actions {
88        Disable Inspector Recording;
89    }
90}
91"#;
92const LOCALIZATION_ZH_CN_SOURCE: &str = r#"rule ("Localization") {
93    event {
94        持续 - 全局;
95    }
96    actions {
97        禁用查看器录制;
98    }
99}
100"#;
101
102/// An explicit support classification for a census case.
103#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
104#[serde(tag = "kind", rename_all = "kebab-case")]
105pub enum CensusSupport {
106    Exercise,
107    Unsupported { detail: String },
108    KnownGap { detail: String },
109    Inconclusive { detail: String },
110}
111
112/// One deterministic case with explicit source-locale provenance.
113#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
114pub struct CensusCase {
115    pub case_id: String,
116    pub features: Vec<FeatureId>,
117    /// Locale of `source`; a zh-CN case is a real input, not an
118    /// implementation-generated conversion.
119    #[serde(default = "default_source_locale")]
120    pub source_locale: String,
121    pub source: String,
122    /// Independently recorded source text, when this case has an offline
123    /// expectation. None means the case remains inconclusive offline.
124    #[serde(default, skip_serializing_if = "Option::is_none")]
125    pub reference_source: Option<String>,
126    pub support: CensusSupport,
127}
128
129/// A named collection of independently attributable cases.
130#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
131pub struct CensusShard {
132    pub shard_id: String,
133    pub cases: Vec<CensusCase>,
134}
135
136impl CensusShard {
137    pub fn new(
138        shard_id: impl Into<String>,
139        mut cases: Vec<CensusCase>,
140    ) -> Result<Self, CensusError> {
141        let shard_id = shard_id.into();
142        validate_name("shard_id", &shard_id)?;
143        cases.sort_by(|left, right| left.case_id.cmp(&right.case_id));
144        for case in &cases {
145            case.validate()?;
146        }
147        if cases
148            .windows(2)
149            .any(|pair| pair[0].case_id == pair[1].case_id)
150        {
151            return Err(CensusError::new(format!(
152                "shard '{shard_id}' contains duplicate case IDs"
153            )));
154        }
155        Ok(Self { shard_id, cases })
156    }
157}
158
159impl CensusCase {
160    fn probe(case_id: impl Into<String>, features: Vec<FeatureId>, source: String) -> Self {
161        Self {
162            case_id: case_id.into(),
163            features,
164            source_locale: EN_US.to_string(),
165            source,
166            reference_source: None,
167            support: generated_probe_support(),
168        }
169    }
170
171    fn exercise(mut self) -> Self {
172        self.support = CensusSupport::Exercise;
173        self
174    }
175
176    fn locale(mut self, locale: &str) -> Self {
177        self.source_locale = locale.to_string();
178        self
179    }
180
181    #[cfg(test)]
182    fn with_support(mut self, support: CensusSupport) -> Self {
183        self.support = support;
184        self
185    }
186
187    fn validate(&self) -> Result<(), CensusError> {
188        validate_name("case_id", &self.case_id)?;
189        if self.features.is_empty() {
190            return Err(CensusError::new(format!(
191                "case '{}' has no feature IDs",
192                self.case_id
193            )));
194        }
195        if self.source.trim().is_empty() {
196            return Err(CensusError::new(format!(
197                "case '{}' has no source",
198                self.case_id
199            )));
200        }
201        validate_name("source_locale", &self.source_locale)?;
202        if self
203            .reference_source
204            .as_deref()
205            .is_some_and(|source| source.trim().is_empty())
206        {
207            return Err(CensusError::new(format!(
208                "case '{}' has an empty reference source",
209                self.case_id
210            )));
211        }
212        let mut features = HashSet::new();
213        if self
214            .features
215            .iter()
216            .any(|feature| !features.insert(feature))
217        {
218            return Err(CensusError::new(format!(
219                "case '{}' contains duplicate feature IDs",
220                self.case_id
221            )));
222        }
223        Ok(())
224    }
225}
226
227/// The complete census assembled from deterministic shards.
228#[derive(Debug, Clone, PartialEq, Eq)]
229pub struct Census {
230    shards: Vec<CensusShard>,
231}
232
233/// The stable identity of a reviewed census definition.
234#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
235#[serde(rename_all = "camelCase")]
236pub struct CensusIdentity {
237    pub schema_version: u32,
238    pub digest: String,
239    pub shards: Vec<String>,
240}
241
242impl Census {
243    /// Assemble shards in stable shard and case order.
244    pub fn assemble(mut shards: Vec<CensusShard>) -> Result<Self, CensusError> {
245        shards.sort_by(|left, right| left.shard_id.cmp(&right.shard_id));
246        let mut shard_ids = HashSet::new();
247        let mut case_ids = HashSet::new();
248        for shard in &shards {
249            if !shard_ids.insert(shard.shard_id.clone()) {
250                return Err(CensusError::new(format!(
251                    "duplicate census shard '{}'",
252                    shard.shard_id
253                )));
254            }
255            for case in &shard.cases {
256                if !case_ids.insert(case.case_id.clone()) {
257                    return Err(CensusError::new(format!(
258                        "duplicate census case '{}'",
259                        case.case_id
260                    )));
261                }
262            }
263        }
264        Ok(Self { shards })
265    }
266
267    /// Derive the current surface from the canonical catalog, settings table,
268    /// and WIR capability names owned by this crate.
269    pub fn builtin(catalog: &Catalog) -> Result<Self, CensusError> {
270        Self::assemble(vec![
271            catalog_shard(catalog, Kind::Event, "catalog-events")?,
272            catalog_shard(catalog, Kind::Action, "catalog-actions")?,
273            catalog_shard(catalog, Kind::Value, "catalog-values")?,
274            catalog_shard(catalog, Kind::Operator, "catalog-operators")?,
275            catalog_shard(catalog, Kind::Structural, "catalog-structural")?,
276            enum_shard(catalog)?,
277            settings_shard(catalog)?,
278            wir_shard()?,
279            localization_shard(catalog)?,
280            content_id_shard(catalog)?,
281        ])
282    }
283
284    pub fn shards(&self) -> &[CensusShard] {
285        &self.shards
286    }
287
288    pub fn cases(&self) -> impl Iterator<Item = &CensusCase> {
289        self.shards.iter().flat_map(|shard| shard.cases.iter())
290    }
291
292    /// Execute all cases. No result state is dropped or converted to success.
293    /// Cases are independent and the report sorts results by case ID, so the
294    /// run parallelizes over available cores without changing the output.
295    pub fn run(&self, catalog: &Catalog) -> CensusReport {
296        let cases: Vec<(&str, &CensusCase)> = self
297            .shards
298            .iter()
299            .flat_map(|shard| {
300                shard
301                    .cases
302                    .iter()
303                    .map(move |case| (shard.shard_id.as_str(), case))
304            })
305            .collect();
306        let workers = std::thread::available_parallelism()
307            .map(std::num::NonZero::get)
308            .unwrap_or(1)
309            .min(cases.len().max(1));
310        let results: Vec<ConformanceResult> = if workers <= 1 {
311            cases
312                .iter()
313                .map(|(shard_id, case)| run_case(case, shard_id, catalog))
314                .collect()
315        } else {
316            let next = std::sync::atomic::AtomicUsize::new(0);
317            std::thread::scope(|scope| {
318                let handles: Vec<_> = (0..workers)
319                    .map(|_| {
320                        scope.spawn(|| {
321                            let mut worker_results = Vec::new();
322                            loop {
323                                let index = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
324                                let Some((shard_id, case)) = cases.get(index) else {
325                                    break;
326                                };
327                                worker_results.push(run_case(case, shard_id, catalog));
328                            }
329                            worker_results
330                        })
331                    })
332                    .collect();
333                handles
334                    .into_iter()
335                    .flat_map(|handle| handle.join().expect("census worker panicked"))
336                    .collect()
337            })
338        };
339        let mut results = results;
340        results.sort_by(|left, right| left.case_id.cmp(&right.case_id));
341        CensusReport {
342            schema_version: CENSUS_SCHEMA_VERSION,
343            conformance_schema_version: CONFORMANCE_SCHEMA_VERSION,
344            catalog: catalog.identity(),
345            census: self.identity(),
346            results,
347        }
348    }
349
350    /// Return the deterministic identity of this census definition.
351    pub fn identity(&self) -> CensusIdentity {
352        let definition = self
353            .export_json()
354            .expect("census definitions must remain serializable");
355        CensusIdentity {
356            schema_version: CENSUS_IDENTITY_SCHEMA_VERSION,
357            digest: sha256(&definition),
358            shards: self
359                .shards
360                .iter()
361                .map(|shard| shard.shard_id.clone())
362                .collect(),
363        }
364    }
365
366    /// Export shard definitions without executing them.
367    pub fn export_json(&self) -> Result<String, CensusError> {
368        serde_json::to_string_pretty(&self.shards)
369            .map_err(|error| CensusError::new(format!("cannot serialize census shards: {error}")))
370    }
371}
372
373/// Machine-readable output from a census run.
374#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
375#[serde(rename_all = "camelCase")]
376pub struct CensusReport {
377    pub schema_version: u32,
378    pub conformance_schema_version: u32,
379    pub catalog: workshop_rs::catalog::CatalogIdentity,
380    pub census: CensusIdentity,
381    pub results: Vec<ConformanceResult>,
382}
383
384impl CensusReport {
385    pub fn validate(&self) -> Result<(), CensusError> {
386        let catalog =
387            Catalog::builtin().map_err(|error| CensusError::new(format!("catalog: {error}")))?;
388        self.validate_against(&catalog)
389    }
390
391    pub fn validate_against(&self, catalog: &Catalog) -> Result<(), CensusError> {
392        if self.schema_version != CENSUS_SCHEMA_VERSION {
393            return Err(CensusError::new("unsupported census schema version"));
394        }
395        if self.conformance_schema_version != CONFORMANCE_SCHEMA_VERSION {
396            return Err(CensusError::new("unsupported conformance schema version"));
397        }
398        if self.catalog != catalog.identity() {
399            return Err(CensusError::new(
400                "report catalog identity does not match the loaded catalog",
401            ));
402        }
403        if self.census.schema_version != CENSUS_IDENTITY_SCHEMA_VERSION {
404            return Err(CensusError::new(
405                "unsupported census identity schema version",
406            ));
407        }
408        if !is_sha256_digest(&self.census.digest) {
409            return Err(CensusError::new(
410                "census identity digest must be a SHA-256 hex digest",
411            ));
412        }
413        if self.census.shards.is_empty()
414            || self.census.shards.windows(2).any(|pair| pair[0] >= pair[1])
415        {
416            return Err(CensusError::new(
417                "report shards must be non-empty and strictly sorted",
418            ));
419        }
420        for result in &self.results {
421            result
422                .validate_against(catalog)
423                .map_err(|error| CensusError::new(error.to_string()))?;
424            let matching_shards = self
425                .census
426                .shards
427                .iter()
428                .filter(|shard| {
429                    result
430                        .case_id
431                        .strip_prefix(shard.as_str())
432                        .is_some_and(|rest| rest.starts_with('/'))
433                })
434                .count();
435            if matching_shards != 1 {
436                return Err(CensusError::new(format!(
437                    "result '{}' does not map to exactly one census shard",
438                    result.case_id
439                )));
440            }
441        }
442        Ok(())
443    }
444
445    pub fn to_json(&self) -> Result<String, CensusError> {
446        self.validate()?;
447        serde_json::to_string_pretty(self)
448            .map_err(|error| CensusError::new(format!("cannot serialize census report: {error}")))
449    }
450}
451
452#[derive(Debug, Clone, PartialEq, Eq)]
453pub struct CensusError {
454    pub message: String,
455}
456
457impl CensusError {
458    fn new(message: impl Into<String>) -> Self {
459        Self {
460            message: message.into(),
461        }
462    }
463}
464
465impl std::fmt::Display for CensusError {
466    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
467        formatter.write_str(&self.message)
468    }
469}
470
471impl std::error::Error for CensusError {}
472
473fn validate_name(field: &str, value: &str) -> Result<(), CensusError> {
474    if value.trim().is_empty() || value.chars().any(char::is_control) {
475        Err(CensusError::new(format!(
476            "{field} must be non-empty and printable"
477        )))
478    } else {
479        Ok(())
480    }
481}
482
483fn default_source_locale() -> String {
484    EN_US.to_string()
485}
486
487fn feature(namespace: FeatureNamespace, kind: FeatureKind, name: impl Into<String>) -> FeatureId {
488    FeatureId::new(namespace, kind, name).expect("canonical census feature ID")
489}
490
491fn catalog_feature(kind: Kind, id: &str) -> FeatureId {
492    FeatureId::from_catalog(kind, id).expect("catalog IDs are validated by Catalog::load")
493}
494
495fn catalog_shard(
496    catalog: &Catalog,
497    kind: Kind,
498    shard_id: &str,
499) -> Result<CensusShard, CensusError> {
500    let cases = catalog
501        .entries_of(kind)
502        .map(|entry| {
503            let source = match kind {
504                Kind::Event => event_probe(catalog, entry),
505                Kind::Action => action_probe(catalog, entry),
506                Kind::Value => value_probe(catalog, entry),
507                Kind::Operator => operator_probe(catalog, entry),
508                Kind::Structural => structural_probe(catalog, entry),
509                Kind::Setting => unreachable!("settings use the settings table"),
510                Kind::Enum => unreachable!("enum domains use the enum shard"),
511            };
512            CensusCase::probe(
513                format!("{shard_id}/{}", entry.id),
514                vec![catalog_feature(kind, &entry.id)],
515                source,
516            )
517        })
518        .collect();
519    CensusShard::new(shard_id, cases)
520}
521
522fn enum_shard(catalog: &Catalog) -> Result<CensusShard, CensusError> {
523    enum_member_cases(catalog, "catalog-enums", false, true)
524}
525
526fn content_id_shard(catalog: &Catalog) -> Result<CensusShard, CensusError> {
527    enum_member_cases(catalog, "content-ids", true, false)
528}
529
530fn enum_member_cases(
531    catalog: &Catalog,
532    shard_id: &str,
533    content_only: bool,
534    domain_feature: bool,
535) -> Result<CensusShard, CensusError> {
536    let mut cases = Vec::new();
537    for domain in catalog.enum_domains() {
538        if content_only && !matches!(domain.domain.as_str(), "Hero" | "Map") {
539            continue;
540        }
541        for member in &domain.members {
542            let mut features = Vec::with_capacity(2);
543            if domain_feature {
544                features.push(catalog_feature(Kind::Enum, &domain.domain));
545            }
546            features.push(
547                FeatureId::from_enum_member(&domain.domain, &member.member)
548                    .expect("canonical enum member ID"),
549            );
550            cases.push(CensusCase::probe(
551                format!("{shard_id}/{}/{}", domain.domain, member.member),
552                features,
553                enum_probe(catalog, domain, &member.member),
554            ));
555        }
556    }
557    CensusShard::new(shard_id, cases)
558}
559fn settings_shard(catalog: &Catalog) -> Result<CensusShard, CensusError> {
560    let cases = settings_schema::definitions()
561        .map(|definition| {
562            let path = definition.path().to_string();
563            CensusCase::probe(
564                format!("settings/{path}"),
565                vec![feature(
566                    FeatureNamespace::Settings,
567                    FeatureKind::Setting,
568                    path,
569                )],
570                settings_probe(&definition, catalog),
571            )
572        })
573        .collect();
574    CensusShard::new("settings", cases)
575}
576
577fn wir_shard() -> Result<CensusShard, CensusError> {
578    let cases = CENSUS_CAPABILITIES
579        .iter()
580        .map(|capability| match capability.kind {
581            CensusCapabilityKind::Variable => wir_case(
582                "variables-global",
583                FeatureKind::Variable,
584                capability.name,
585                variables_source(),
586            ),
587            CensusCapabilityKind::PlayerVariable => CensusCase::probe(
588                "wir/variables-player".to_string(),
589                vec![feature(
590                    FeatureNamespace::Wir,
591                    FeatureKind::Variable,
592                    capability.name,
593                )],
594                player_variable_source(),
595            ),
596            CensusCapabilityKind::Subroutine => wir_case(
597                "subroutine",
598                FeatureKind::Subroutine,
599                capability.name,
600                subroutine_source(),
601            ),
602            CensusCapabilityKind::ControlFlow => {
603                let actions = match capability.name {
604                    "if" => "If(True);\n    Wait(0);\nEnd;",
605                    "else-if" => "If(True);\n    Wait(0);\nElse If(False);\n    Wait(0);\nEnd;",
606                    "else" => "If(True);\n    Wait(0);\nElse;\n    Wait(0);\nEnd;",
607                    "while" => "While(True);\n    Wait(0);\nEnd;",
608                    "for-global-variable" => {
609                        "For Global Variable(probe, 0, 1, 1);\n    Wait(0);\nEnd;"
610                    }
611                    _ => unreachable!("unknown WIR control-flow census capability"),
612                };
613                control_flow_case(capability.name, actions)
614            }
615            CensusCapabilityKind::String => CensusCase::probe(
616                "wir/string/custom-string".to_string(),
617                vec![feature(
618                    FeatureNamespace::Wir,
619                    FeatureKind::String,
620                    capability.name,
621                )],
622                rule_source(
623                    "String",
624                    "Set Global Variable(probe, Custom String(\"census\"));",
625                ),
626            )
627            .exercise(),
628        })
629        .collect();
630    CensusShard::new("wir", cases)
631}
632
633fn localization_shard(catalog: &Catalog) -> Result<CensusShard, CensusError> {
634    let mut cases = vec![
635        CensusCase::probe(
636            "localization/en-us-to-zh-cn".to_string(),
637            vec![feature(
638                FeatureNamespace::Localization,
639                FeatureKind::Localization,
640                "en-us-to-zh-cn",
641            )],
642            LOCALIZATION_EN_US_SOURCE.to_string(),
643        ),
644        CensusCase::probe(
645            "localization/zh-cn-to-en-us".to_string(),
646            vec![feature(
647                FeatureNamespace::Localization,
648                FeatureKind::Localization,
649                "zh-cn-to-en-us",
650            )],
651            LOCALIZATION_ZH_CN_SOURCE.to_string(),
652        )
653        .locale(ZH_CN),
654    ];
655    // #448: every spelling the catalog declares for a locale gets a case
656    // written in that spelling, so an alias the frontend cannot represent
657    // shows up as an explicit result instead of never being exercised.
658    let settings_index = SettingsSpellings::build();
659    for locale in catalog.locales() {
660        for kind in [
661            Kind::Event,
662            Kind::Action,
663            Kind::Value,
664            Kind::Operator,
665            Kind::Structural,
666        ] {
667            for entry in catalog.entries_of(kind) {
668                for (index, spelling) in entry.spellings(locale).iter().enumerate() {
669                    cases.push(
670                        CensusCase::probe(
671                            format!(
672                                "localization/{}/{}/{}/{}",
673                                locale.as_str(),
674                                kind.as_str(),
675                                entry.id,
676                                index
677                            ),
678                            vec![catalog_feature(kind, &entry.id)],
679                            localized_probe(catalog, locale, kind, entry, spelling),
680                        )
681                        .locale(locale.as_str()),
682                    );
683                }
684            }
685        }
686        for domain in catalog.enum_domains() {
687            let domain_spelling = CONSTRUCTOR_DOMAINS
688                .contains(&domain.domain.as_str())
689                .then(|| domain.spelling(locale))
690                .flatten();
691            if let Some(spelling) = domain_spelling {
692                let Some(member) = domain.members.first() else {
693                    continue;
694                };
695                let member_spelling =
696                    localized_member(catalog, &domain.domain, &member.member, locale);
697                cases.push(
698                    CensusCase::probe(
699                        format!(
700                            "localization/{}/enum-domain/{}",
701                            locale.as_str(),
702                            domain.domain
703                        ),
704                        vec![catalog_feature(Kind::Enum, &domain.domain)],
705                        global_probe(catalog, locale, &format!("{spelling}({member_spelling})")),
706                    )
707                    .locale(locale.as_str()),
708                );
709            }
710            for member in &domain.members {
711                for (index, spelling) in member.spellings(locale).iter().enumerate() {
712                    cases.push(
713                        CensusCase::probe(
714                            format!(
715                                "localization/{}/enum/{}/{}/{}",
716                                locale.as_str(),
717                                domain.domain,
718                                member.member,
719                                index
720                            ),
721                            vec![
722                                FeatureId::from_enum_member(&domain.domain, &member.member)
723                                    .expect("canonical enum member ID"),
724                            ],
725                            member_probe(catalog, locale, domain, spelling),
726                        )
727                        .locale(locale.as_str()),
728                    );
729                }
730            }
731        }
732        for entry in catalog.localized_strings() {
733            for (index, spelling) in entry.spellings(locale).iter().enumerate() {
734                let string_call = catalog
735                    .spelling(Kind::Value, locale, "string")
736                    .or_else(|| catalog.spelling(Kind::Value, &Locale::new(EN_US), "string"))
737                    .unwrap_or("string");
738                cases.push(
739                    CensusCase::probe(
740                        format!(
741                            "localization/{}/localized-string/{}/{}",
742                            locale.as_str(),
743                            entry.id,
744                            index
745                        ),
746                        vec![feature(
747                            FeatureNamespace::Localization,
748                            FeatureKind::String,
749                            entry.id.clone(),
750                        )],
751                        global_probe(catalog, locale, &format!("{string_call}({spelling})")),
752                    )
753                    .locale(locale.as_str()),
754                );
755            }
756        }
757        for (row, name) in settings_schema::localized_setting_names()
758            .iter()
759            .enumerate()
760        {
761            if !name.locale.eq_ignore_ascii_case(locale.as_str()) {
762                continue;
763            }
764            let case_id = format!(
765                "localization/{}/settings/{}/{}",
766                locale.as_str(),
767                name.section,
768                row
769            );
770            let feature_id = feature(
771                FeatureNamespace::Settings,
772                FeatureKind::Setting,
773                format!("{}/{}", name.section, slug(name.english)),
774            );
775            match localized_settings_probe(catalog, &settings_index, name, locale) {
776                Ok(source) => cases.push(
777                    CensusCase::probe(case_id, vec![feature_id], source).locale(locale.as_str()),
778                ),
779                Err(detail) => cases.push(
780                    CensusCase {
781                        case_id,
782                        features: vec![feature_id],
783                        source_locale: locale.as_str().to_string(),
784                        source: "settings {\n}".to_string(),
785                        reference_source: None,
786                        support: CensusSupport::KnownGap {
787                            detail: format!(
788                                "settings {} {:?} for {}: {detail}",
789                                name.section, name.spelling, name.english
790                            ),
791                        },
792                    }
793                    .locale(locale.as_str()),
794                ),
795            }
796        }
797    }
798    CensusShard::new("localization", cases)
799}
800
801const CONSTRUCTOR_DOMAINS: &[&str] = &["Hero", "Button", "Color", "Map"];
802
803fn slug(name: &str) -> String {
804    name.chars()
805        .map(|character| {
806            if character.is_whitespace() || character == '/' {
807                '-'
808            } else {
809                character
810            }
811        })
812        .collect()
813}
814
815fn localized_spelling(catalog: &Catalog, kind: Kind, locale: &Locale, id: &str) -> String {
816    catalog
817        .spelling(kind, locale, id)
818        .or_else(|| catalog.spelling(kind, &Locale::new(EN_US), id))
819        .unwrap_or(id)
820        .to_string()
821}
822
823fn localized_member(catalog: &Catalog, domain: &str, member: &str, locale: &Locale) -> String {
824    catalog
825        .enum_spelling(domain, locale, member)
826        .or_else(|| catalog.enum_spelling(domain, &Locale::new(EN_US), member))
827        .unwrap_or(member)
828        .to_string()
829}
830
831fn probe_filler(catalog: &Catalog, locale: &Locale, entry: &CatalogEntry, index: usize) -> String {
832    // A modify-operator parameter takes a zero-argument operator call —
833    // emit the first declared operation's spelling rather than a value
834    // filler (#504).
835    if let Some(operation) = entry
836        .param_content(index)
837        .and_then(|content| content.operations.first())
838    {
839        return localized_spelling(catalog, Kind::Operator, locale, operation).to_string();
840    }
841    if let Some(domain) = entry.param_domain(index) {
842        let member = catalog
843            .enum_domain(domain)
844            .and_then(|domain| domain.members.first())
845            .map(|member| member.member.as_str())
846            .unwrap_or("ALL");
847        let spelling = localized_member(catalog, domain, member, locale);
848        if CONSTRUCTOR_DOMAINS.contains(&domain) {
849            let domain_spelling = catalog
850                .enum_domain(domain)
851                .and_then(|domain| domain.spelling(locale))
852                .unwrap_or(domain);
853            return format!("{domain_spelling}({spelling})");
854        }
855        return spelling;
856    }
857    if let Some(default) = entry.param_default(index) {
858        return param_default_filler(catalog, locale, default);
859    }
860    match entry.param_type(index) {
861        Some("Variable") => "probe".to_string(),
862        Some("Subroutine") | Some("Subroutines") => "probe".to_string(),
863        Some("Bool") => localized_spelling(catalog, Kind::Value, locale, "true"),
864        // `Hero`-typed params take the member spelling bare; the enclosing
865        // `Hero(...)` call is the value under test or the entry's own name.
866        Some("Hero") => {
867            let member = catalog
868                .enum_domain("Hero")
869                .and_then(|domain| domain.members.first())
870                .map(|member| member.member.as_str())
871                .unwrap_or("ANA");
872            localized_member(catalog, "Hero", member, locale)
873        }
874        // String params emit canonically as `Custom String("...")`, so the
875        // probe writes that form instead of a bare literal (#448).
876        Some("String") => {
877            let custom_string = localized_spelling(catalog, Kind::Value, locale, "customString");
878            format!("{custom_string}(\"x\")")
879        }
880        Some(kind) if kind.contains("Player") || kind.contains("EntityId") => {
881            localized_spelling(catalog, Kind::Value, locale, "eventPlayer")
882        }
883        _ => "0".to_string(),
884    }
885}
886
887fn param_default_filler(catalog: &Catalog, locale: &Locale, default: &str) -> String {
888    if let Some((domain, member)) = default.split_once('.') {
889        let spelling = localized_member(catalog, domain, member, locale);
890        if CONSTRUCTOR_DOMAINS.contains(&domain) {
891            let domain_spelling = catalog
892                .enum_domain(domain)
893                .and_then(|domain| domain.spelling(locale))
894                .unwrap_or(domain);
895            return format!("{domain_spelling}({spelling})");
896        }
897        return spelling;
898    }
899    if catalog.entry(Kind::Value, default).is_some() {
900        return localized_spelling(catalog, Kind::Value, locale, default);
901    }
902    if let Some(spelling) = catalog.localized_string_spelling(locale, default) {
903        return spelling.to_string();
904    }
905    default.to_string()
906}
907
908fn probe_args(catalog: &Catalog, locale: &Locale, entry: &CatalogEntry) -> String {
909    (0..entry.param_count())
910        .map(|index| probe_filler(catalog, locale, entry, index))
911        .collect::<Vec<_>>()
912        .join(", ")
913}
914
915/// A probe rule written fully in `locale` (#448): every fixed token —
916/// section keywords, the header event, `Wait`, `If`, `End` — carries that
917/// locale's declared spelling so a cross-locale round trip compares the
918/// tested spelling rather than fallback noise in the boilerplate.
919fn probe_rule(catalog: &Catalog, locale: &Locale, actions: &str) -> String {
920    let variables = localized_spelling(catalog, Kind::Structural, locale, "variables");
921    let global = localized_spelling(catalog, Kind::Structural, locale, "global");
922    let player = localized_spelling(catalog, Kind::Structural, locale, "player");
923    let subroutines = localized_spelling(catalog, Kind::Structural, locale, "subroutines");
924    let rule = localized_spelling(catalog, Kind::Structural, locale, "rule");
925    let event = localized_spelling(catalog, Kind::Structural, locale, "event");
926    let ongoing_global = localized_spelling(catalog, Kind::Event, locale, "global");
927    let actions_section = localized_spelling(catalog, Kind::Structural, locale, "actions");
928    format!(
929        "{variables} {{\n    {global}:\n        0: probe\n    {player}:\n        0: probe\n}}\n\n{subroutines} {{\n    0: probe\n}}\n\n{rule} (\"probe\") {{\n    {event} {{\n        {ongoing_global};\n    }}\n    {actions_section} {{\n        {actions}\n    }}\n}}\n"
930    )
931}
932
933fn global_probe(catalog: &Catalog, locale: &Locale, operand: &str) -> String {
934    let set_global = localized_spelling(catalog, Kind::Structural, locale, "setGlobalVariable");
935    probe_rule(catalog, locale, &format!("{set_global}(probe, {operand});"))
936}
937
938fn localized_probe(
939    catalog: &Catalog,
940    locale: &Locale,
941    kind: Kind,
942    entry: &CatalogEntry,
943    spelling: &str,
944) -> String {
945    match kind {
946        Kind::Event => {
947            let extra = match entry.id.as_str() {
948                "global" => String::new(),
949                "subroutine" => "        probe;\n".to_string(),
950                _ => format!(
951                    "        {};\n        {};\n",
952                    localized_member(catalog, "EventTeam", "ALL", locale),
953                    localized_member(catalog, "EventPlayer", "ALL", locale),
954                ),
955            };
956            let subroutines = localized_spelling(catalog, Kind::Structural, locale, "subroutines");
957            let rule = localized_spelling(catalog, Kind::Structural, locale, "rule");
958            let event = localized_spelling(catalog, Kind::Structural, locale, "event");
959            let actions = localized_spelling(catalog, Kind::Structural, locale, "actions");
960            let wait = localized_spelling(catalog, Kind::Action, locale, "wait");
961            format!(
962                "{subroutines} {{\n    0: probe\n}}\n\n{rule} (\"e\") {{\n    {event} {{\n        {spelling};\n{extra}    }}\n    {actions} {{\n        {wait};\n    }}\n}}\n"
963            )
964        }
965        Kind::Action => {
966            let call = if entry.param_count() == 0 {
967                format!("{spelling};")
968            } else {
969                format!("{spelling}({});", probe_args(catalog, locale, entry))
970            };
971            // `For * Variable` actions open a block that needs `End`.
972            let call = if entry.id.starts_with("__for") {
973                let wait = localized_spelling(catalog, Kind::Action, locale, "wait");
974                let end = localized_spelling(catalog, Kind::Structural, locale, "end");
975                format!("{call}\n        {wait}(0);\n    {end};")
976            } else {
977                call
978            };
979            probe_rule(catalog, locale, &call)
980        }
981        Kind::Value => {
982            let operand = match entry.id.as_str() {
983                "string" => {
984                    let hello = catalog
985                        .localized_string_spelling(locale, "hello")
986                        .or_else(|| catalog.localized_string_spelling(&Locale::new(EN_US), "hello"))
987                        .unwrap_or("Hello");
988                    format!("{spelling}({hello})")
989                }
990                "compare" => {
991                    let eq = localized_spelling(catalog, Kind::Operator, locale, "==");
992                    format!("{spelling}(1, {eq}, 1)")
993                }
994                _ if entry.param_count() == 0 => spelling.to_string(),
995                _ => format!("{spelling}({})", probe_args(catalog, locale, entry)),
996            };
997            global_probe(catalog, locale, &operand)
998        }
999        Kind::Operator => {
1000            if matches!(entry.id.as_str(), "==" | "!=" | "<" | "<=" | ">" | ">=") {
1001                let if_ = localized_spelling(catalog, Kind::Structural, locale, "if");
1002                let wait = localized_spelling(catalog, Kind::Action, locale, "wait");
1003                let end = localized_spelling(catalog, Kind::Structural, locale, "end");
1004                probe_rule(
1005                    catalog,
1006                    locale,
1007                    &format!("{if_}(1 {spelling} 1);\n        {wait}(0);\n    {end};"),
1008                )
1009            } else {
1010                let modify =
1011                    localized_spelling(catalog, Kind::Structural, locale, "modifyGlobalVariable");
1012                probe_rule(catalog, locale, &format!("{modify}(probe, {spelling}, 1);"))
1013            }
1014        }
1015        Kind::Structural => structural_spelling_probe(catalog, locale, &entry.id, spelling),
1016        _ => unreachable!("settings and enum spellings use their own probes"),
1017    }
1018}
1019
1020fn structural_spelling_probe(
1021    catalog: &Catalog,
1022    locale: &Locale,
1023    id: &str,
1024    spelling: &str,
1025) -> String {
1026    let kw = |name: &str| localized_spelling(catalog, Kind::Structural, locale, name);
1027    let wait = localized_spelling(catalog, Kind::Action, locale, "wait");
1028    let true_ = localized_spelling(catalog, Kind::Value, locale, "true");
1029    let ongoing_global = localized_spelling(catalog, Kind::Event, locale, "global");
1030    let event_player = localized_spelling(catalog, Kind::Value, locale, "eventPlayer");
1031    let end = kw("end");
1032    let block = |head: &str| format!("{head}\n        {wait}(0);\n    {end};");
1033    match id {
1034        "settings" => format!("{spelling} {{\n}}\n"),
1035        "workshop" => format!("{} {{\n    {spelling} {{\n    }}\n}}\n", kw("settings")),
1036        "variables" => format!(
1037            "{spelling} {{\n    {}:\n        0: probe\n}}\n",
1038            kw("global")
1039        ),
1040        "global" | "player" => {
1041            format!(
1042                "{} {{\n    {spelling}:\n        0: probe\n}}\n",
1043                kw("variables")
1044            )
1045        }
1046        "subroutines" => format!("{spelling} {{\n    0: probe\n}}\n"),
1047        "rule" => format!(
1048            "{spelling} (\"r\") {{\n    {} {{\n        {ongoing_global};\n    }}\n    {} {{\n        {wait};\n    }}\n}}\n",
1049            kw("event"),
1050            kw("actions"),
1051        ),
1052        "event" => format!(
1053            "{} (\"r\") {{\n    {spelling} {{\n        {ongoing_global};\n    }}\n    {} {{\n        {wait};\n    }}\n}}\n",
1054            kw("rule"),
1055            kw("actions"),
1056        ),
1057        "conditions" => {
1058            // Conditions emit canonically in `a == b` comparison form.
1059            let equals = localized_spelling(catalog, Kind::Operator, locale, "==");
1060            format!(
1061                "{} (\"c\") {{\n    {} {{\n        {ongoing_global};\n    }}\n    {spelling} {{\n        {true_} {equals} {true_};\n    }}\n    {} {{\n        {wait};\n    }}\n}}\n",
1062                kw("rule"),
1063                kw("event"),
1064                kw("actions"),
1065            )
1066        }
1067        "actions" => format!(
1068            "{} (\"r\") {{\n    {} {{\n        {ongoing_global};\n    }}\n    {spelling} {{\n        {wait};\n    }}\n}}\n",
1069            kw("rule"),
1070            kw("event"),
1071        ),
1072        "disabled" => probe_rule(catalog, locale, &format!("{spelling} {wait}(0);")),
1073        "if" => probe_rule(catalog, locale, &block(&format!("{spelling}({true_});"))),
1074        "elseIf" => {
1075            let if_ = kw("if");
1076            probe_rule(
1077                catalog,
1078                locale,
1079                &format!(
1080                    "{if_}({true_});\n        {wait}(0);\n    {spelling}({true_});\n        {wait}(0);\n    {end};"
1081                ),
1082            )
1083        }
1084        "else" => {
1085            let if_ = kw("if");
1086            probe_rule(
1087                catalog,
1088                locale,
1089                &format!(
1090                    "{if_}({true_});\n        {wait}(0);\n    {spelling};\n        {wait}(0);\n    {end};"
1091                ),
1092            )
1093        }
1094        "end" => {
1095            let if_ = kw("if");
1096            probe_rule(
1097                catalog,
1098                locale,
1099                &format!("{if_}({true_});\n        {wait}(0);\n    {spelling};"),
1100            )
1101        }
1102        "while" => probe_rule(catalog, locale, &block(&format!("{spelling}({true_});"))),
1103        "forGlobalVariable" => probe_rule(
1104            catalog,
1105            locale,
1106            &block(&format!("{spelling}(probe, 0, 1, 1);")),
1107        ),
1108        "forPlayerVariable" => probe_rule(
1109            catalog,
1110            locale,
1111            &block(&format!("{spelling}({event_player}, probe, 0, 1, 1);")),
1112        ),
1113        "setGlobalVariable" => probe_rule(catalog, locale, &format!("{spelling}(probe, 1);")),
1114        "modifyGlobalVariable" => {
1115            let add = localized_spelling(catalog, Kind::Operator, locale, "add");
1116            probe_rule(catalog, locale, &format!("{spelling}(probe, {add}, 1);"))
1117        }
1118        "setPlayerVariable" => probe_rule(
1119            catalog,
1120            locale,
1121            &format!("{spelling}({event_player}, probe, 1);"),
1122        ),
1123        "modifyPlayerVariable" => {
1124            let add = localized_spelling(catalog, Kind::Operator, locale, "add");
1125            probe_rule(
1126                catalog,
1127                locale,
1128                &format!("{spelling}({event_player}, probe, {add}, 1);"),
1129            )
1130        }
1131        "callSubroutine" => probe_rule(catalog, locale, &format!("{spelling}(probe);")),
1132        _ => probe_rule(catalog, locale, &format!("{spelling};")),
1133    }
1134}
1135
1136fn member_probe(catalog: &Catalog, locale: &Locale, domain: &EnumDomain, spelling: &str) -> String {
1137    if CONSTRUCTOR_DOMAINS.contains(&domain.domain.as_str()) {
1138        let domain_spelling = domain.spelling(locale).unwrap_or(&domain.domain);
1139        return global_probe(catalog, locale, &format!("{domain_spelling}({spelling})"));
1140    }
1141    if domain.domain == "EventTeam" || domain.domain == "EventPlayer" {
1142        let event = localized_spelling(catalog, Kind::Event, locale, "eachPlayer");
1143        let other = if domain.domain == "EventTeam" {
1144            localized_member(catalog, "EventPlayer", "ALL", locale)
1145        } else {
1146            localized_member(catalog, "EventTeam", "ALL", locale)
1147        };
1148        let (team, player) = if domain.domain == "EventTeam" {
1149            (spelling.to_string(), other)
1150        } else {
1151            (other, spelling.to_string())
1152        };
1153        let rule = localized_spelling(catalog, Kind::Structural, locale, "rule");
1154        let event_kw = localized_spelling(catalog, Kind::Structural, locale, "event");
1155        let actions = localized_spelling(catalog, Kind::Structural, locale, "actions");
1156        let wait = localized_spelling(catalog, Kind::Action, locale, "wait");
1157        return format!(
1158            "{rule} (\"e\") {{\n    {event_kw} {{\n        {event};\n        {team};\n        {player};\n    }}\n    {actions} {{\n        {wait};\n    }}\n}}\n"
1159        );
1160    }
1161    let host = [Kind::Value, Kind::Action]
1162        .into_iter()
1163        .flat_map(|kind| catalog.entries_of(kind))
1164        .filter(|entry| {
1165            (0..entry.param_count()).any(|index| entry.param_domain(index) == Some(&domain.domain))
1166        })
1167        .min_by_key(|entry| entry.required_param_count());
1168    let Some(host) = host else {
1169        return global_probe(catalog, locale, spelling);
1170    };
1171    let target = (0..host.param_count())
1172        .find(|index| host.param_domain(*index) == Some(&domain.domain))
1173        .expect("host was selected for carrying this domain");
1174    let mut call_args: Vec<String> = (0..host.param_count())
1175        .map(|index| probe_filler(catalog, locale, host, index))
1176        .collect();
1177    call_args[target] = spelling.to_string();
1178    let host_spelling = localized_spelling(catalog, host.kind, locale, &host.id);
1179    let call = format!("{host_spelling}({})", call_args.join(", "));
1180    match host.kind {
1181        Kind::Value => global_probe(catalog, locale, &call),
1182        _ => probe_rule(catalog, locale, &format!("{call};")),
1183    }
1184}
1185
1186struct SettingsSpellings {
1187    by_label: HashMap<String, Vec<SettingDefinition>>,
1188    by_member: HashMap<String, Vec<SettingDefinition>>,
1189    names: HashMap<(String, String, String), String>,
1190}
1191
1192impl SettingsSpellings {
1193    fn build() -> Self {
1194        let mut by_label: HashMap<String, Vec<SettingDefinition>> = HashMap::new();
1195        let mut by_member: HashMap<String, Vec<SettingDefinition>> = HashMap::new();
1196        for definition in settings_schema::definitions() {
1197            by_label
1198                .entry(definition.presentation().english_name.to_string())
1199                .or_default()
1200                .push(definition.clone());
1201            for member in definition.enum_members() {
1202                by_member
1203                    .entry(member.english_name().to_string())
1204                    .or_default()
1205                    .push(definition.clone());
1206            }
1207        }
1208        let mut names = HashMap::new();
1209        for row in settings_schema::localized_setting_names() {
1210            names.insert(
1211                (
1212                    row.locale.to_string(),
1213                    row.section.to_string(),
1214                    row.english.to_string(),
1215                ),
1216                row.spelling.to_string(),
1217            );
1218        }
1219        Self {
1220            by_label,
1221            by_member,
1222            names,
1223        }
1224    }
1225
1226    fn name(&self, locale: &str, section: &str, english: &str) -> String {
1227        self.lookup(locale, section, english)
1228            .or_else(|| self.lookup(EN_US, section, english))
1229            .unwrap_or(english)
1230            .to_string()
1231    }
1232
1233    fn lookup(&self, locale: &str, section: &str, english: &str) -> Option<&str> {
1234        self.names
1235            .get(&(locale.to_string(), section.to_string(), english.to_string()))
1236            .map(String::as_str)
1237    }
1238}
1239
1240/// A probe hero for `definition`'s `<hero>` slot: ability-scoped defs need
1241/// a hero that owns the slot; prefer a hero whose emitted name equals the
1242/// tested label so generic labels exercise the typed path (#448).
1243fn settings_probe_hero(definition: &SettingDefinition, english: &str) -> Option<Option<String>> {
1244    if !definition.path().contains(".<hero>.") && !definition.path().ends_with(".<hero>") {
1245        return Some(None);
1246    }
1247    let data = gameplay::data::builtin_ref().ok()?;
1248    let heroes = data.query().heroes().to_vec();
1249    let hero_ids: Vec<String> = match definition.target_kind() {
1250        SettingTargetKind::HeroAbility { slot, variant } => heroes
1251            .iter()
1252            .filter(|hero| {
1253                data.query()
1254                    .ability_name(hero.id().as_str(), slot.as_str(), variant.as_ref(), EN_US)
1255                    .is_ok()
1256            })
1257            .map(|hero| hero.id().as_str().to_string())
1258            .collect(),
1259        _ => heroes
1260            .iter()
1261            .map(|hero| hero.id().as_str().to_string())
1262            .collect(),
1263    };
1264    // Prefer a hero that actually renders the tested label (generic
1265    // spellings are shadowed by hero-specific ability names), but any hero
1266    // carrying the slot still writes the spelling verbatim.
1267    let mut matching = Vec::new();
1268    let mut any = Vec::new();
1269    for id in hero_ids {
1270        let target = match definition.target_kind() {
1271            SettingTargetKind::HeroAbility { slot, variant } => SettingTarget::HeroAbility {
1272                team: None,
1273                hero: HeroId::new(id.as_str()),
1274                slot,
1275                variant,
1276            },
1277            _ => SettingTarget::Hero {
1278                team: None,
1279                hero: HeroId::new(id.as_str()),
1280            },
1281        };
1282        let emitted = definition
1283            .localized_name(EN_US, &target)
1284            .ok()
1285            .flatten()
1286            .map(str::to_string);
1287        any.push(id.clone());
1288        if emitted.as_deref() == Some(english) {
1289            matching.push(id);
1290        }
1291    }
1292    matching.into_iter().chain(any).next().map(Some)
1293}
1294
1295/// Substitute the ability slot's localized name into a `%1$s` template
1296/// spelling — the only form in which such a label can appear in source.
1297/// Returns None when the definition is not hero-ability scoped or the hero
1298/// has no ability name to substitute (#448).
1299fn substitute_template(
1300    definition: &SettingDefinition,
1301    spelling: &str,
1302    hero: Option<&str>,
1303    locale: &str,
1304) -> Option<String> {
1305    if !spelling.contains("%1$s") {
1306        return Some(spelling.to_string());
1307    }
1308    let SettingTargetKind::HeroAbility { slot, variant } = definition.target_kind() else {
1309        return None;
1310    };
1311    let ability = gameplay::data::builtin_ref()
1312        .ok()?
1313        .query()
1314        .ability_name(hero?, slot.as_str(), variant.as_ref(), locale)
1315        .ok()?
1316        .to_string();
1317    Some(spelling.replace("%1$s", &ability))
1318}
1319
1320fn localized_settings_context(
1321    catalog: &Catalog,
1322    index: &SettingsSpellings,
1323    locale: &Locale,
1324    definition: &SettingDefinition,
1325    leaf: &str,
1326    hero: Option<&str>,
1327) -> String {
1328    let hero_name = hero
1329        .and_then(|id| {
1330            gameplay::data::builtin_ref()
1331                .ok()?
1332                .query()
1333                .heroes()
1334                .iter()
1335                .find(|hero| hero.id().as_str() == id)
1336                .and_then(|hero| hero.name().value().get(EN_US).map(str::to_string))
1337        })
1338        .map(|english| index.name(locale.as_str(), "heroes", &english))
1339        .unwrap_or_else(|| index.name(locale.as_str(), "heroes", "Ana"));
1340    let settings = localized_spelling(catalog, Kind::Structural, locale, "settings");
1341    let mut lines = vec![format!("{settings} {{")];
1342    let mut depth = 1;
1343    let parts: Vec<_> = definition.path().split('.').collect();
1344    for (part_index, part) in parts.iter().take(parts.len() - 1).enumerate() {
1345        let name = match *part {
1346            "gamemodes" => index.name(locale.as_str(), "namespaces", "modes"),
1347            "heroes" => index.name(locale.as_str(), "namespaces", "heroes"),
1348            "<team>" | "general" => index.name(locale.as_str(), "teams", "General"),
1349            "<hero>" => hero_name.clone(),
1350            value if part_index == 1 && parts.first() == Some(&"gamemodes") => {
1351                let english = catalog_enum_spelling(catalog, "Gamemode", value)
1352                    .unwrap_or_else(|| value.to_string());
1353                index.name(locale.as_str(), "modes", &english)
1354            }
1355            value => index.name(locale.as_str(), "labels", value),
1356        };
1357        lines.push(format!("{}{} {{", "    ".repeat(depth), name));
1358        depth += 1;
1359    }
1360    lines.push(format!("{}{leaf}", "    ".repeat(depth)));
1361    while depth > 1 {
1362        depth -= 1;
1363        lines.push(format!("{}{}", "    ".repeat(depth), "}"));
1364    }
1365    lines.push("}".to_string());
1366    lines.join("\n")
1367}
1368
1369fn leaf_value(
1370    index: &SettingsSpellings,
1371    locale: &Locale,
1372    definition: &SettingDefinition,
1373) -> String {
1374    match definition.domain() {
1375        SettingValueDomain::PresenceOnly => String::new(),
1376        SettingValueDomain::String => ": \"x\"".to_string(),
1377        SettingValueDomain::Boolean | SettingValueDomain::Enum { .. } => {
1378            let member = definition
1379                .enum_members()
1380                .next()
1381                .map(|member| {
1382                    // Settings members are declared under `enums`; the
1383                    // boolean tokens (On/Off/Yes/No) live under `tokens`.
1384                    index
1385                        .lookup(locale.as_str(), "enums", member.english_name())
1386                        .or_else(|| index.lookup(locale.as_str(), "tokens", member.english_name()))
1387                        .unwrap_or(member.english_name())
1388                        .to_string()
1389                })
1390                .unwrap_or_else(|| index.name(locale.as_str(), "tokens", "On"));
1391            format!(": {member}")
1392        }
1393        SettingValueDomain::Number(_) => ": 1".to_string(),
1394        SettingValueDomain::Percent(_) => ": 100%".to_string(),
1395        SettingValueDomain::HeroList | SettingValueDomain::MapList => " {\n}".to_string(),
1396        _ => ": 0".to_string(),
1397    }
1398}
1399
1400fn localized_settings_probe(
1401    catalog: &Catalog,
1402    index: &SettingsSpellings,
1403    name: &settings_schema::LocalizedSettingName,
1404    locale: &Locale,
1405) -> Result<String, String> {
1406    let english = name.english;
1407    let spelling = name.spelling;
1408    let loc = locale.as_str();
1409    let settings = localized_spelling(catalog, Kind::Structural, locale, "settings");
1410    let modes = index.name(loc, "namespaces", "modes");
1411    let heroes = index.name(loc, "namespaces", "heroes");
1412    let general = index.name(loc, "teams", "General");
1413    let assault = index.name(loc, "modes", "Assault");
1414    match name.section {
1415        "namespaces" => Ok(format!("{settings} {{\n    {spelling} {{\n    }}\n}}")),
1416        "modes" => Ok(format!(
1417            "{settings} {{\n    {modes} {{\n        {spelling} {{\n        }}\n    }}\n}}"
1418        )),
1419        "teams" => Ok(format!(
1420            "{settings} {{\n    {heroes} {{\n        {spelling} {{\n            {} {{\n            }}\n        }}\n    }}\n}}",
1421            index.name(loc, "heroes", "Ana")
1422        )),
1423        "heroes" => Ok(format!(
1424            "{settings} {{\n    {heroes} {{\n        {general} {{\n            {spelling} {{\n            }}\n        }}\n    }}\n}}"
1425        )),
1426        "maps" => Ok(format!(
1427            "{settings} {{\n    {modes} {{\n        {assault} {{\n            {} {{\n                {spelling}\n            }}\n        }}\n    }}\n}}",
1428            index.name(loc, "labels", "enabled maps")
1429        )),
1430        "tokens" => match english {
1431            "disabled" => Ok(format!(
1432                "{settings} {{\n    {modes} {{\n        {spelling} {assault} {{\n        }}\n    }}\n}}"
1433            )),
1434            _ => Ok(format!(
1435                "{settings} {{\n    {modes} {{\n        {assault} {{\n            {}: {spelling}\n        }}\n    }}\n}}",
1436                index.name(loc, "labels", "Competitive Rules")
1437            )),
1438        },
1439        "labels" => {
1440            let Some(defs) = index.by_label.get(english) else {
1441                return Err(format!("no settings definition named {english:?}"));
1442            };
1443            let mut reasons = Vec::new();
1444            for definition in defs {
1445                let hero = settings_probe_hero(definition, english);
1446                let leaf = match substitute_template(
1447                    definition,
1448                    spelling,
1449                    hero.as_ref().and_then(|hero| hero.as_deref()),
1450                    loc,
1451                ) {
1452                    Some(leaf) => leaf,
1453                    None => {
1454                        reasons.push("template needs a hero-ability context".to_string());
1455                        continue;
1456                    }
1457                };
1458                return Ok(localized_settings_context(
1459                    catalog,
1460                    index,
1461                    locale,
1462                    definition,
1463                    &format!("{leaf}{}", leaf_value(index, locale, definition)),
1464                    hero.as_ref().and_then(|hero| hero.as_deref()),
1465                ));
1466            }
1467            Err(reasons
1468                .into_iter()
1469                .next()
1470                .unwrap_or_else(|| "no probe context".to_string()))
1471        }
1472        "enums" => {
1473            let Some(defs) = index.by_member.get(english) else {
1474                return Err(format!("no settings definition takes member {english:?}"));
1475            };
1476            let definition = &defs[0];
1477            let hero = settings_probe_hero(definition, definition.presentation().english_name);
1478            let label = index.name(loc, "labels", definition.presentation().english_name);
1479            Ok(localized_settings_context(
1480                catalog,
1481                index,
1482                locale,
1483                definition,
1484                &format!("{label}: {spelling}"),
1485                hero.as_ref().and_then(|hero| hero.as_deref()),
1486            ))
1487        }
1488        other => Err(format!("unhandled settings section {other:?}")),
1489    }
1490}
1491
1492fn wir_case(case_id: &str, kind: FeatureKind, name: &str, source: String) -> CensusCase {
1493    CensusCase::probe(
1494        format!("wir/{case_id}"),
1495        vec![feature(FeatureNamespace::Wir, kind, name)],
1496        source,
1497    )
1498    .exercise()
1499}
1500
1501fn control_flow_case(name: &str, actions: &str) -> CensusCase {
1502    CensusCase::probe(
1503        format!("wir/control-flow/{name}"),
1504        vec![feature(
1505            FeatureNamespace::Wir,
1506            FeatureKind::ControlFlow,
1507            name,
1508        )],
1509        rule_source(name, actions),
1510    )
1511}
1512
1513fn generated_probe_support() -> CensusSupport {
1514    CensusSupport::Inconclusive {
1515        detail: "generated probe has no independently recorded expected result".to_string(),
1516    }
1517}
1518
1519fn rule_source(name: &str, actions: &str) -> String {
1520    format!(
1521        "variables {{\n    global:\n        0: probe\n}}\n\nrule (\"{name}\") {{\n    event {{\n        Ongoing - Global;\n    }}\n    actions {{\n        {actions}\n    }}\n}}\n"
1522    )
1523}
1524
1525fn variables_source() -> String {
1526    "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"
1527        .to_string()
1528}
1529
1530fn player_variable_source() -> String {
1531    "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"
1532        .to_string()
1533}
1534
1535fn subroutine_source() -> String {
1536    "subroutines {\n    0: probe\n}\n\nrule (\"Subroutine\") {\n    event {\n        Subroutine;\n        probe;\n    }\n    actions {\n        Call Subroutine(probe);\n    }\n}\n"
1537        .to_string()
1538}
1539
1540fn event_probe(catalog: &Catalog, entry: &CatalogEntry) -> String {
1541    let spelling = catalog
1542        .spelling(Kind::Event, &Locale::new(EN_US), &entry.id)
1543        .unwrap_or(&entry.id);
1544    let filters = if matches!(entry.id.as_str(), "global" | "subroutine") {
1545        String::new()
1546    } else {
1547        "        All;\n        All;\n".to_string()
1548    };
1549    let subroutine = if entry.id == "subroutine" {
1550        "        probe;\n"
1551    } else {
1552        ""
1553    };
1554    format!(
1555        "subroutines {{\n    0: probe\n}}\n\nrule (\"Event\") {{\n    event {{\n        {spelling};\n{filters}{subroutine}    }}\n    actions {{\n        Wait;\n    }}\n}}\n"
1556    )
1557}
1558
1559fn action_probe(catalog: &Catalog, entry: &CatalogEntry) -> String {
1560    let spelling = catalog
1561        .spelling(Kind::Action, &Locale::new(EN_US), &entry.id)
1562        .unwrap_or(&entry.id);
1563    let call = if matches!(
1564        entry.id.as_str(),
1565        "chasePlayerVariableAtRate" | "chasePlayerVariableOverTime"
1566    ) {
1567        format!("{spelling}(Event Player, probe, 0, 1, 0);")
1568    } else {
1569        format!("{spelling};")
1570    };
1571    rule_source("Action", &call)
1572}
1573
1574fn value_probe(catalog: &Catalog, entry: &CatalogEntry) -> String {
1575    let spelling = catalog
1576        .spelling(Kind::Value, &Locale::new(EN_US), &entry.id)
1577        .unwrap_or(&entry.id);
1578    rule_source("Value", &format!("Set Global Variable(probe, {spelling});"))
1579}
1580
1581fn operator_probe(catalog: &Catalog, entry: &CatalogEntry) -> String {
1582    let spelling = catalog
1583        .spelling(Kind::Operator, &Locale::new(EN_US), &entry.id)
1584        .unwrap_or(&entry.id);
1585    rule_source(
1586        "Operator",
1587        &format!("If(1 {spelling} 1);\n    Wait(0);\nEnd;"),
1588    )
1589}
1590
1591fn structural_probe(catalog: &Catalog, entry: &CatalogEntry) -> String {
1592    let spelling = catalog
1593        .spelling(Kind::Structural, &Locale::new(EN_US), &entry.id)
1594        .unwrap_or(&entry.id);
1595    let actions = match entry.id.as_str() {
1596        "if" => format!("{spelling}(True);\n    Wait(0);\nEnd;"),
1597        "elseIf" => format!("If(True);\n    Wait(0);\n{spelling}(False);\n    Wait(0);\nEnd;"),
1598        "else" => format!("If(True);\n    Wait(0);\n{spelling};\n    Wait(0);\nEnd;"),
1599        "end" => format!("If(True);\n    Wait(0);\n{spelling};"),
1600        "while" => format!("{spelling}(True);\n    Wait(0);\nEnd;"),
1601        "forGlobalVariable" => format!("{spelling}(probe, 0, 1, 1);\n    Wait(0);\nEnd;"),
1602        "setGlobalVariable" => format!("{spelling}(probe, 1);"),
1603        "modifyGlobalVariable" => format!("{spelling}(probe, Add, 1);"),
1604        "setPlayerVariable" => format!("{spelling}(Event Player, probe, 1);"),
1605        "modifyPlayerVariable" => format!("{spelling}(Event Player, probe, Add, 1);"),
1606        "callSubroutine" => format!("{spelling}(probe);"),
1607        _ => format!("{spelling};"),
1608    };
1609    let prefix = match entry.id.as_str() {
1610        "setPlayerVariable" | "modifyPlayerVariable" => {
1611            "variables {\n    player:\n        0: probe\n}\n\n"
1612        }
1613        "callSubroutine" => "subroutines {\n    0: probe\n}\n\n",
1614        _ => "",
1615    };
1616    format!("{prefix}{}", rule_source("Structural", &actions))
1617}
1618
1619fn enum_probe(catalog: &Catalog, domain: &EnumDomain, member: &str) -> String {
1620    let locale = Locale::new(EN_US);
1621    let domain_spelling = catalog
1622        .spelling(Kind::Value, &locale, &domain.domain)
1623        .unwrap_or(&domain.domain);
1624    let member_spelling = catalog
1625        .enum_spelling(&domain.domain, &locale, member)
1626        .unwrap_or(member);
1627    // Only the constructor-form domains parse `Domain(Member)`; every other
1628    // domain writes the member bare (mirrors `Catalog::enum_member_form`).
1629    let operand = if CONSTRUCTOR_DOMAINS.contains(&domain.domain.as_str()) {
1630        format!("{domain_spelling}({member_spelling})")
1631    } else {
1632        member_spelling.to_string()
1633    };
1634    rule_source("Enum", &format!("Set Global Variable(probe, {operand});"))
1635}
1636
1637fn settings_probe(definition: &SettingDefinition, catalog: &Catalog) -> String {
1638    let mut lines = vec!["settings {".to_string()];
1639    let mut depth = 1;
1640    let parts: Vec<_> = definition.path().split('.').collect();
1641    for (index, part) in parts.iter().enumerate() {
1642        let name = match *part {
1643            "gamemodes" => "modes".to_string(),
1644            "heroes" => "heroes".to_string(),
1645            "<team>" => "General".to_string(),
1646            "<hero>" => {
1647                catalog_enum_spelling(catalog, "Hero", "mei").unwrap_or_else(|| "Mei".to_string())
1648            }
1649            "general" => "General".to_string(),
1650            value if index == 1 && parts.first() == Some(&"gamemodes") => {
1651                mode_spelling(catalog, value)
1652            }
1653            value => value.to_string(),
1654        };
1655        lines.push(format!("{}{} {{", "    ".repeat(depth), name));
1656        depth += 1;
1657    }
1658    let indent = "    ".repeat(depth);
1659    let name = definition.presentation().english_name;
1660    match definition.domain() {
1661        SettingValueDomain::PresenceOnly => lines.push(format!("{indent}{name}")),
1662        SettingValueDomain::String => lines.push(format!("{indent}{name}: \"census\"")),
1663        SettingValueDomain::Boolean => {
1664            let value = definition
1665                .enum_members()
1666                .next()
1667                .map(|member| member.english_name().to_string())
1668                .unwrap_or_else(|| "On".to_string());
1669            lines.push(format!("{indent}{name}: {value}"));
1670        }
1671        SettingValueDomain::Number(_) => lines.push(format!("{indent}{name}: 1")),
1672        SettingValueDomain::Percent(_) => lines.push(format!("{indent}{name}: 100%")),
1673        SettingValueDomain::Enum { .. } => {
1674            let value = definition
1675                .enum_members()
1676                .next()
1677                .map(|member| member.english_name().to_string())
1678                .unwrap_or_else(|| "Off".to_string());
1679            lines.push(format!("{indent}{name}: {value}"));
1680        }
1681        SettingValueDomain::HeroList | SettingValueDomain::MapList => {
1682            lines.push(format!("{indent}{name} {{"));
1683            lines.push(format!("{indent}}}"));
1684        }
1685        other => unreachable!("census has no probe for settings value domain {other:?}"),
1686    }
1687    while depth > 1 {
1688        depth -= 1;
1689        lines.push(format!("{}{}", "    ".repeat(depth), "}"));
1690    }
1691    lines.push("}".to_string());
1692    lines.join("\n")
1693}
1694
1695fn mode_spelling(catalog: &Catalog, key: &str) -> String {
1696    catalog_enum_spelling(catalog, "Gamemode", key).unwrap_or_else(|| key.to_string())
1697}
1698
1699fn catalog_enum_spelling(catalog: &Catalog, domain: &str, key: &str) -> Option<String> {
1700    let normalize = |value: &str| {
1701        value
1702            .chars()
1703            .filter(|character| character.is_ascii_alphanumeric())
1704            .flat_map(char::to_lowercase)
1705            .collect::<String>()
1706    };
1707    let normalized = normalize(key);
1708    let member = catalog
1709        .enum_domains()
1710        .find(|candidate| candidate.domain == domain)?
1711        .members
1712        .iter()
1713        .find(|candidate| normalize(&candidate.member) == normalized)?;
1714    catalog
1715        .enum_spelling(domain, &Locale::new(EN_US), &member.member)
1716        .map(str::to_string)
1717}
1718
1719fn run_case(case: &CensusCase, shard_id: &str, catalog: &Catalog) -> ConformanceResult {
1720    let source = artifact(
1721        format!("census/{shard_id}/{}.ws", case.case_id),
1722        &case.source,
1723    );
1724    let base = |status, comparison, reason, locale| ConformanceResult {
1725        schema_version: CONFORMANCE_SCHEMA_VERSION,
1726        case_id: case.case_id.clone(),
1727        features: case.features.clone(),
1728        status,
1729        comparison,
1730        source: source.clone(),
1731        catalog: catalog.identity(),
1732        locale,
1733        reason,
1734    };
1735    match &case.support {
1736        CensusSupport::Unsupported { detail } => base(
1737            ConformanceStatus::Unsupported,
1738            not_comparable(),
1739            Some(reason(ReasonCode::Unsupported, detail)),
1740            None,
1741        ),
1742        CensusSupport::KnownGap { detail } => base(
1743            ConformanceStatus::KnownGap,
1744            not_comparable(),
1745            Some(reason(ReasonCode::KnownGap, detail)),
1746            None,
1747        ),
1748        CensusSupport::Inconclusive { detail } => execute_case(case, base, catalog, Some(detail)),
1749        CensusSupport::Exercise => execute_case(case, base, catalog, None),
1750    }
1751}
1752
1753struct Fail {
1754    code: ReasonCode,
1755    detail: String,
1756    locale: Locale,
1757}
1758
1759impl Fail {
1760    fn workshop(error: &WorkshopError, locale: &Locale) -> Self {
1761        let code = match error {
1762            WorkshopError::Unsupported { .. } => ReasonCode::Unsupported,
1763            WorkshopError::MissingMapping { .. } => ReasonCode::KnownGap,
1764            _ => ReasonCode::UnexpectedRegression,
1765        };
1766        Self {
1767            code,
1768            detail: error.to_string(),
1769            locale: locale.clone(),
1770        }
1771    }
1772
1773    fn regression(detail: impl Into<String>, locale: &Locale) -> Self {
1774        Self {
1775            code: ReasonCode::UnexpectedRegression,
1776            detail: detail.into(),
1777            locale: locale.clone(),
1778        }
1779    }
1780}
1781
1782fn execute_case(
1783    case: &CensusCase,
1784    base: impl Fn(
1785        ConformanceStatus,
1786        Comparison,
1787        Option<ConformanceReason>,
1788        Option<Locale>,
1789    ) -> ConformanceResult,
1790    catalog: &Catalog,
1791    inconclusive_detail: Option<&str>,
1792) -> ConformanceResult {
1793    match run_pipeline(case, catalog, inconclusive_detail) {
1794        Ok((comparison, locale)) => {
1795            base(ConformanceStatus::Matched, comparison, None, Some(locale))
1796        }
1797        Err(fail) => failed_text(base, fail.code, fail.detail, Some(fail.locale)),
1798    }
1799}
1800
1801fn run_pipeline(
1802    case: &CensusCase,
1803    catalog: &Catalog,
1804    inconclusive_detail: Option<&str>,
1805) -> Result<(Comparison, Locale), Fail> {
1806    let source_locale = Locale::new(&case.source_locale);
1807    let target_locale = if source_locale.as_str() == ZH_CN {
1808        Locale::new(EN_US)
1809    } else {
1810        Locale::new(ZH_CN)
1811    };
1812
1813    let parse = |source: &str, locale: &Locale| {
1814        parser::parse_with_context(source, catalog, locale, catalog)
1815            .map_err(|error| Fail::workshop(&error, locale))
1816    };
1817    let validate = |program: &workshop_rs::Program, locale: &Locale| {
1818        program
1819            .validate()
1820            .map_err(|error| Fail::regression(error.to_string(), locale))
1821    };
1822    let emit = |program: &workshop_rs::Program, locale: &Locale| {
1823        emitter::emit(program, catalog, locale).map_err(|error| Fail::workshop(&error, locale))
1824    };
1825    let convert_or =
1826        |source: &str, from: &Locale, to: &Locale| -> Result<convert::Conversion, Fail> {
1827            convert::convert(source, catalog, from, to, &Default::default())
1828                .map_err(|error| Fail::workshop(&error, to))
1829        };
1830
1831    // Some declared spellings are inherently ambiguous in a locale: zh-CN
1832    // writes `下` for `down`, `Vector.DOWN`, and `Rounding.DOWN` alike, so a
1833    // faithful conversion reparses to a preserved `__ambiguous_enum` rather
1834    // than the resolved node. When the target program emits byte-identical
1835    // canonical text, the client-observable surface is right and only the
1836    // resolved-identity check is undecidable — report that explicitly as
1837    // Inconclusive instead of a regression (#448).
1838    let surface_equivalent =
1839        |a: &workshop_rs::Program, b: &workshop_rs::Program, locale: &Locale| {
1840            let (Ok(x), Ok(y)) = (emit(a, locale), emit(b, locale)) else {
1841                return false;
1842            };
1843            normalize_workshop(&x) == normalize_workshop(&y)
1844        };
1845
1846    let program = parse(&case.source, &source_locale)?;
1847    validate(&program, &source_locale)?;
1848    let emitted = emit(&program, &source_locale)?;
1849    let reparsed = parse(&emitted, &source_locale)?;
1850    validate(&reparsed, &source_locale)?;
1851    let reemitted = emit(&reparsed, &source_locale)?;
1852    if !roundtrip::equivalent(&program, &reparsed)
1853        || normalize_workshop(&emitted) != normalize_workshop(&reemitted)
1854    {
1855        return Err(Fail::regression(
1856            "en-US semantic or normalized gate diverged",
1857            &source_locale,
1858        ));
1859    }
1860
1861    let converted = convert_or(&case.source, &source_locale, &target_locale)?;
1862    let target_program = parse(&converted.text, &target_locale)?;
1863    validate(&target_program, &target_locale)?;
1864    if !roundtrip::equivalent(&program, &target_program) {
1865        if surface_equivalent(&program, &target_program, &target_locale) {
1866            return Err(Fail {
1867                code: ReasonCode::Inconclusive,
1868                detail: "converted text matches the canonical emission, but the \
1869                         target-locale spelling is ambiguous across enum domains; \
1870                         resolved semantics cannot be independently verified"
1871                    .to_string(),
1872                locale: target_locale,
1873            });
1874        }
1875        return Err(Fail::regression(
1876            "zh-CN conversion changed canonical WIR semantics",
1877            &target_locale,
1878        ));
1879    }
1880    let back = convert_or(&converted.text, &target_locale, &source_locale)?;
1881    let back_program = parse(&back.text, &source_locale)?;
1882    // Compare against the canonical emission, not the raw source: probes
1883    // written in a non-canonical declared alias (#448) still must land on
1884    // the same canonical program and surface form after a round trip.
1885    if !roundtrip::equivalent(&program, &back_program)
1886        || normalize_workshop(&back.text) != normalize_workshop(&emitted)
1887    {
1888        if surface_equivalent(&program, &back_program, &source_locale) {
1889            return Err(Fail {
1890                code: ReasonCode::Inconclusive,
1891                detail: "round-trip text matches the canonical emission, but the \
1892                         source-locale spelling is ambiguous across enum domains; \
1893                         resolved semantics cannot be independently verified"
1894                    .to_string(),
1895                locale: source_locale,
1896            });
1897        }
1898        return Err(Fail::regression(
1899            "cross-locale semantic or normalized gate diverged",
1900            &source_locale,
1901        ));
1902    }
1903
1904    let Some(reference_source) = case.reference_source.as_ref() else {
1905        return Err(Fail {
1906            code: ReasonCode::Inconclusive,
1907            detail: inconclusive_detail
1908                .unwrap_or(
1909                    "offline semantic and locale gates passed, but no independent expectation artifact is recorded",
1910                )
1911                .to_string(),
1912            locale: source_locale,
1913        });
1914    };
1915    let expected_program = parse(reference_source, &target_locale)?;
1916    if !roundtrip::equivalent(&program, &expected_program)
1917        || normalize_workshop(&converted.text) != normalize_workshop(reference_source)
1918    {
1919        return Err(Fail::regression(
1920            "conversion differed from the independent reference source",
1921            &target_locale,
1922        ));
1923    }
1924    Ok((
1925        Comparison {
1926            mode: Equivalence::Semantic,
1927            expected: Some(reference_artifact(case, reference_source)),
1928            observed: Some(artifact(
1929                format!("census/{}/converted-output.ws", case.case_id),
1930                &converted.text,
1931            )),
1932            normalizer: Some("canonical-wir;normalized-workshop-text".to_string()),
1933        },
1934        source_locale,
1935    ))
1936}
1937
1938fn failed_text(
1939    base: impl Fn(
1940        ConformanceStatus,
1941        Comparison,
1942        Option<ConformanceReason>,
1943        Option<Locale>,
1944    ) -> ConformanceResult,
1945    code: ReasonCode,
1946    detail: String,
1947    locale: Option<Locale>,
1948) -> ConformanceResult {
1949    let status = match code {
1950        ReasonCode::Unsupported => ConformanceStatus::Unsupported,
1951        ReasonCode::KnownGap => ConformanceStatus::KnownGap,
1952        ReasonCode::UnexpectedRegression => ConformanceStatus::UnexpectedRegression,
1953        ReasonCode::Inconclusive => ConformanceStatus::Inconclusive,
1954    };
1955    let comparison = if code == ReasonCode::UnexpectedRegression {
1956        Comparison {
1957            mode: Equivalence::Normalized,
1958            expected: None,
1959            observed: None,
1960            normalizer: Some("census-stage".to_string()),
1961        }
1962    } else {
1963        not_comparable()
1964    };
1965    base(status, comparison, Some(reason(code, &detail)), locale)
1966}
1967
1968fn reason(code: ReasonCode, detail: &str) -> ConformanceReason {
1969    ConformanceReason {
1970        code,
1971        detail: detail.to_string(),
1972    }
1973}
1974
1975fn not_comparable() -> Comparison {
1976    Comparison {
1977        mode: Equivalence::NotComparable,
1978        expected: None,
1979        observed: None,
1980        normalizer: None,
1981    }
1982}
1983
1984fn artifact(name: impl Into<String>, content: &str) -> TestArtifact {
1985    TestArtifact {
1986        name: name.into(),
1987        revision: None,
1988        path: None,
1989        sha256: Some(sha256(content)),
1990        license: Some("MIT".to_string()),
1991    }
1992}
1993
1994fn reference_artifact(case: &CensusCase, content: &str) -> TestArtifact {
1995    TestArtifact {
1996        name: format!("census reference for {}", case.case_id),
1997        revision: Some("census-v1".to_string()),
1998        path: Some("tests/fixtures/census/reference.ws".to_string()),
1999        sha256: Some(sha256(content)),
2000        license: Some("MIT".to_string()),
2001    }
2002}
2003
2004fn sha256(content: &str) -> String {
2005    let mut hasher = Sha256::new();
2006    hasher.update(content.as_bytes());
2007    format!("{:x}", hasher.finalize())
2008}
2009
2010fn normalize_workshop(text: &str) -> String {
2011    text.split_whitespace().collect::<Vec<_>>().join(" ")
2012}
2013
2014#[cfg(test)]
2015mod tests {
2016    use super::*;
2017
2018    #[test]
2019    fn builtin_census_is_derived_and_deterministic() {
2020        let catalog = Catalog::builtin().expect("builtin catalog");
2021        let first = Census::builtin(&catalog).expect("census");
2022        let second = Census::builtin(&catalog).expect("census");
2023        assert_eq!(first, second);
2024        assert_eq!(first.shards().first().unwrap().shard_id, "catalog-actions");
2025        assert!(
2026            first
2027                .cases()
2028                .any(|case| case.features.iter().any(|feature| feature.name == "wait"))
2029        );
2030        assert!(first.cases().any(|case| {
2031            case.features
2032                .iter()
2033                .any(|feature| feature.kind == FeatureKind::Setting)
2034        }));
2035        assert!(first.cases().any(|case| {
2036            case.features
2037                .iter()
2038                .any(|feature| feature.kind == FeatureKind::ControlFlow)
2039        }));
2040        assert_eq!(first.export_json().unwrap(), second.export_json().unwrap());
2041        assert_eq!(first.identity(), second.identity());
2042        assert_eq!(first.identity().digest.len(), 64);
2043    }
2044
2045    #[test]
2046    fn settings_probes_use_catalog_gamemode_spellings() {
2047        let catalog = Catalog::builtin().expect("builtin catalog");
2048
2049        for (mode, expected) in [
2050            ("ffa", "Deathmatch"),
2051            ("tdm", "Team Deathmatch"),
2052            ("ctf", "Capture The Flag"),
2053        ] {
2054            let prefix = format!("gamemodes.{mode}.");
2055            let definition = settings_schema::definitions()
2056                .find(|definition| definition.path().starts_with(&prefix))
2057                .unwrap_or_else(|| panic!("missing settings definition for {mode}"));
2058            let probe = settings_probe(&definition, &catalog);
2059            assert!(
2060                probe.contains(&format!("{expected} {{")),
2061                "{mode} probe used the wrong mode spelling:\n{probe}"
2062            );
2063        }
2064    }
2065
2066    #[test]
2067    fn explicit_non_matching_states_remain_machine_readable() {
2068        let feature_case = |id: &str, support| {
2069            CensusCase::probe(
2070                format!("state-tests/{id}"),
2071                vec![feature(FeatureNamespace::Wir, FeatureKind::Structural, id)],
2072                format!("rule (\"{id}\") {{}}"),
2073            )
2074            .with_support(support)
2075        };
2076        let shard = CensusShard::new(
2077            "state-tests",
2078            vec![
2079                feature_case(
2080                    "unsupported",
2081                    CensusSupport::Unsupported {
2082                        detail: "not declared".to_string(),
2083                    },
2084                ),
2085                feature_case(
2086                    "known-gap",
2087                    CensusSupport::KnownGap {
2088                        detail: "missing mapping".to_string(),
2089                    },
2090                ),
2091                feature_case(
2092                    "inconclusive",
2093                    CensusSupport::Inconclusive {
2094                        detail: "no oracle".to_string(),
2095                    },
2096                ),
2097            ],
2098        )
2099        .unwrap();
2100        let report = Census::assemble(vec![shard])
2101            .unwrap()
2102            .run(&Catalog::builtin().unwrap());
2103        report
2104            .validate()
2105            .expect("states use the current conformance adapter");
2106        let json = report.to_json().unwrap();
2107        assert!(json.contains("unsupported"));
2108        assert!(json.contains("known-gap"));
2109        assert!(json.contains("inconclusive"));
2110    }
2111
2112    #[test]
2113    fn builtin_census_report_validates_against_the_catalog() {
2114        let catalog = Catalog::builtin().expect("builtin catalog");
2115        let census = Census::builtin(&catalog).expect("census");
2116        let report = census.run(&catalog);
2117        report
2118            .validate_against(&catalog)
2119            .expect("census results use canonical catalog identities");
2120        assert_eq!(report.census, census.identity());
2121        assert_eq!(
2122            report.results.len(),
2123            census.cases().count(),
2124            "census drops or duplicates cases"
2125        );
2126        let localization_cases = census
2127            .cases()
2128            .filter(|case| case.case_id.starts_with("localization/"))
2129            .count();
2130        assert_eq!(
2131            report
2132                .results
2133                .iter()
2134                .filter(|result| result.case_id.starts_with("localization/"))
2135                .count(),
2136            localization_cases
2137        );
2138        assert!(
2139            localization_cases > 2,
2140            "localization shard must enumerate per-spelling cases"
2141        );
2142        assert!(
2143            report.results.iter().any(|result| {
2144                result.case_id.starts_with("localization/")
2145                    && result.status == ConformanceStatus::KnownGap
2146            }),
2147            "unwritable localized settings spellings must report as known gaps"
2148        );
2149    }
2150
2151    #[test]
2152    fn census_report_rejects_a_malformed_identity_digest() {
2153        let catalog = Catalog::builtin().expect("builtin catalog");
2154        let census = Census::assemble(vec![
2155            CensusShard::new(
2156                "state-tests",
2157                vec![CensusCase::probe(
2158                    "state-tests/probe".to_string(),
2159                    vec![feature(
2160                        FeatureNamespace::Wir,
2161                        FeatureKind::Structural,
2162                        "probe",
2163                    )],
2164                    "rule (\"probe\") {}".to_string(),
2165                )],
2166            )
2167            .unwrap(),
2168        ])
2169        .unwrap();
2170        let mut report = census.run(&catalog);
2171        report.census.digest = "not-a-digest".to_string();
2172
2173        let error = report
2174            .validate_against(&catalog)
2175            .expect_err("report identity must carry a SHA-256 digest");
2176        assert!(error.to_string().contains("SHA-256"));
2177    }
2178}
2179
2180#[cfg(test)]
2181mod probe_debug {
2182    use super::*;
2183
2184    #[test]
2185    fn dump_failing_localization_probes() {
2186        let catalog = Catalog::builtin().unwrap();
2187        let census = Census::builtin(&catalog).unwrap();
2188        let wanted = [
2189            "localization/es-mx/value/hero/0",
2190            "localization/fr-fr/value/hero/0",
2191        ];
2192        for case in census.cases() {
2193            if !wanted.contains(&case.case_id.as_str()) {
2194                continue;
2195            }
2196            println!("==== {} ====", case.case_id);
2197            println!("{}", case.source);
2198            let result = run_case(case, "localization", &catalog);
2199            println!(
2200                "-> {:?} {:?}",
2201                result.status,
2202                result.reason.map(|r| r.detail)
2203            );
2204        }
2205    }
2206}