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