1use 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#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
114pub struct CensusCase {
115 pub case_id: String,
116 pub features: Vec<FeatureId>,
117 #[serde(default = "default_source_locale")]
120 pub source_locale: String,
121 pub source: String,
122 #[serde(default, skip_serializing_if = "Option::is_none")]
125 pub reference_source: Option<String>,
126 pub support: CensusSupport,
127}
128
129#[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#[derive(Debug, Clone, PartialEq, Eq)]
229pub struct Census {
230 shards: Vec<CensusShard>,
231}
232
233#[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 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 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 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 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 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#[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 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 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 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 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
915fn 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 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 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
1240fn 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 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
1295fn 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 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 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 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 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}