1use std::collections::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::settings::{self as settings_schema, SettingDefinition, SettingValueDomain};
20use workshop_rs::{WorkshopError, convert, emitter, parser, roundtrip};
21
22#[derive(Clone, Copy)]
23enum CensusCapabilityKind {
24 Variable,
25 PlayerVariable,
26 Subroutine,
27 ControlFlow,
28 String,
29}
30
31struct CensusCapability {
32 kind: CensusCapabilityKind,
33 name: &'static str,
34}
35
36const CENSUS_CAPABILITIES: &[CensusCapability] = &[
37 CensusCapability {
38 kind: CensusCapabilityKind::Variable,
39 name: "global",
40 },
41 CensusCapability {
42 kind: CensusCapabilityKind::PlayerVariable,
43 name: "player",
44 },
45 CensusCapability {
46 kind: CensusCapabilityKind::Subroutine,
47 name: "declaration-and-call",
48 },
49 CensusCapability {
50 kind: CensusCapabilityKind::ControlFlow,
51 name: "if",
52 },
53 CensusCapability {
54 kind: CensusCapabilityKind::ControlFlow,
55 name: "else-if",
56 },
57 CensusCapability {
58 kind: CensusCapabilityKind::ControlFlow,
59 name: "else",
60 },
61 CensusCapability {
62 kind: CensusCapabilityKind::ControlFlow,
63 name: "while",
64 },
65 CensusCapability {
66 kind: CensusCapabilityKind::ControlFlow,
67 name: "for-global-variable",
68 },
69 CensusCapability {
70 kind: CensusCapabilityKind::String,
71 name: "custom-string",
72 },
73];
74
75pub const CENSUS_SCHEMA_VERSION: u32 = 1;
76pub const CENSUS_IDENTITY_SCHEMA_VERSION: u32 = 1;
77const EN_US: &str = "en-US";
78const ZH_CN: &str = "zh-CN";
79const LOCALIZATION_EN_US_SOURCE: &str = r#"rule ("Localization") {
80 event {
81 Ongoing - Global;
82 }
83 actions {
84 Disable Inspector Recording;
85 }
86}
87"#;
88const LOCALIZATION_ZH_CN_SOURCE: &str = r#"rule ("Localization") {
89 event {
90 持续 - 全局;
91 }
92 actions {
93 禁用查看器录制;
94 }
95}
96"#;
97
98#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
100#[serde(tag = "kind", rename_all = "kebab-case")]
101pub enum CensusSupport {
102 Exercise,
103 Unsupported { detail: String },
104 KnownGap { detail: String },
105 Inconclusive { detail: String },
106}
107
108#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
110pub struct CensusCase {
111 pub case_id: String,
112 pub features: Vec<FeatureId>,
113 #[serde(default = "default_source_locale")]
116 pub source_locale: String,
117 pub source: String,
118 #[serde(default, skip_serializing_if = "Option::is_none")]
121 pub reference_source: Option<String>,
122 pub support: CensusSupport,
123}
124
125#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
127pub struct CensusShard {
128 pub shard_id: String,
129 pub cases: Vec<CensusCase>,
130}
131
132impl CensusShard {
133 pub fn new(
134 shard_id: impl Into<String>,
135 mut cases: Vec<CensusCase>,
136 ) -> Result<Self, CensusError> {
137 let shard_id = shard_id.into();
138 validate_name("shard_id", &shard_id)?;
139 cases.sort_by(|left, right| left.case_id.cmp(&right.case_id));
140 for case in &cases {
141 case.validate()?;
142 }
143 if cases
144 .windows(2)
145 .any(|pair| pair[0].case_id == pair[1].case_id)
146 {
147 return Err(CensusError::new(format!(
148 "shard '{shard_id}' contains duplicate case IDs"
149 )));
150 }
151 Ok(Self { shard_id, cases })
152 }
153}
154
155impl CensusCase {
156 fn probe(case_id: impl Into<String>, features: Vec<FeatureId>, source: String) -> Self {
158 Self {
159 case_id: case_id.into(),
160 features,
161 source_locale: EN_US.to_string(),
162 source,
163 reference_source: None,
164 support: generated_probe_support(),
165 }
166 }
167
168 fn exercise(mut self) -> Self {
170 self.support = CensusSupport::Exercise;
171 self
172 }
173
174 fn locale(mut self, locale: &str) -> Self {
176 self.source_locale = locale.to_string();
177 self
178 }
179
180 #[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()?,
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 {
294 let mut results: Vec<_> = self
295 .shards
296 .iter()
297 .flat_map(|shard| {
298 shard
299 .cases
300 .iter()
301 .map(move |case| run_case(case, &shard.shard_id, catalog))
302 })
303 .collect();
304 results.sort_by(|left, right| left.case_id.cmp(&right.case_id));
305 CensusReport {
306 schema_version: CENSUS_SCHEMA_VERSION,
307 conformance_schema_version: CONFORMANCE_SCHEMA_VERSION,
308 catalog: catalog.identity(),
309 census: self.identity(),
310 results,
311 }
312 }
313
314 pub fn identity(&self) -> CensusIdentity {
316 let definition = self
317 .export_json()
318 .expect("census definitions must remain serializable");
319 CensusIdentity {
320 schema_version: CENSUS_IDENTITY_SCHEMA_VERSION,
321 digest: sha256(&definition),
322 shards: self
323 .shards
324 .iter()
325 .map(|shard| shard.shard_id.clone())
326 .collect(),
327 }
328 }
329
330 pub fn export_json(&self) -> Result<String, CensusError> {
332 serde_json::to_string_pretty(&self.shards)
333 .map_err(|error| CensusError::new(format!("cannot serialize census shards: {error}")))
334 }
335}
336
337#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
339#[serde(rename_all = "camelCase")]
340pub struct CensusReport {
341 pub schema_version: u32,
342 pub conformance_schema_version: u32,
343 pub catalog: workshop_rs::catalog::CatalogIdentity,
344 pub census: CensusIdentity,
345 pub results: Vec<ConformanceResult>,
346}
347
348impl CensusReport {
349 pub fn validate(&self) -> Result<(), CensusError> {
350 let catalog =
351 Catalog::builtin().map_err(|error| CensusError::new(format!("catalog: {error}")))?;
352 self.validate_against(&catalog)
353 }
354
355 pub fn validate_against(&self, catalog: &Catalog) -> Result<(), CensusError> {
356 if self.schema_version != CENSUS_SCHEMA_VERSION {
357 return Err(CensusError::new("unsupported census schema version"));
358 }
359 if self.conformance_schema_version != CONFORMANCE_SCHEMA_VERSION {
360 return Err(CensusError::new("unsupported conformance schema version"));
361 }
362 if self.catalog != catalog.identity() {
363 return Err(CensusError::new(
364 "report catalog identity does not match the loaded catalog",
365 ));
366 }
367 if self.census.schema_version != CENSUS_IDENTITY_SCHEMA_VERSION {
368 return Err(CensusError::new(
369 "unsupported census identity schema version",
370 ));
371 }
372 if !is_sha256_digest(&self.census.digest) {
373 return Err(CensusError::new(
374 "census identity digest must be a SHA-256 hex digest",
375 ));
376 }
377 if self.census.shards.is_empty()
378 || self.census.shards.windows(2).any(|pair| pair[0] >= pair[1])
379 {
380 return Err(CensusError::new(
381 "report shards must be non-empty and strictly sorted",
382 ));
383 }
384 for result in &self.results {
385 result
386 .validate_against(catalog)
387 .map_err(|error| CensusError::new(error.to_string()))?;
388 let matching_shards = self
389 .census
390 .shards
391 .iter()
392 .filter(|shard| {
393 result
394 .case_id
395 .strip_prefix(shard.as_str())
396 .is_some_and(|rest| rest.starts_with('/'))
397 })
398 .count();
399 if matching_shards != 1 {
400 return Err(CensusError::new(format!(
401 "result '{}' does not map to exactly one census shard",
402 result.case_id
403 )));
404 }
405 }
406 Ok(())
407 }
408
409 pub fn to_json(&self) -> Result<String, CensusError> {
410 self.validate()?;
411 serde_json::to_string_pretty(self)
412 .map_err(|error| CensusError::new(format!("cannot serialize census report: {error}")))
413 }
414}
415
416#[derive(Debug, Clone, PartialEq, Eq)]
417pub struct CensusError {
418 pub message: String,
419}
420
421impl CensusError {
422 fn new(message: impl Into<String>) -> Self {
423 Self {
424 message: message.into(),
425 }
426 }
427}
428
429impl std::fmt::Display for CensusError {
430 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
431 formatter.write_str(&self.message)
432 }
433}
434
435impl std::error::Error for CensusError {}
436
437fn validate_name(field: &str, value: &str) -> Result<(), CensusError> {
438 if value.trim().is_empty() || value.chars().any(char::is_control) {
439 Err(CensusError::new(format!(
440 "{field} must be non-empty and printable"
441 )))
442 } else {
443 Ok(())
444 }
445}
446
447fn default_source_locale() -> String {
448 EN_US.to_string()
449}
450
451fn feature(namespace: FeatureNamespace, kind: FeatureKind, name: impl Into<String>) -> FeatureId {
452 FeatureId::new(namespace, kind, name).expect("canonical census feature ID")
453}
454
455fn catalog_feature(kind: Kind, id: &str) -> FeatureId {
456 FeatureId::from_catalog(kind, id).expect("catalog IDs are validated by Catalog::load")
457}
458
459fn catalog_shard(
460 catalog: &Catalog,
461 kind: Kind,
462 shard_id: &str,
463) -> Result<CensusShard, CensusError> {
464 let cases = catalog
465 .entries_of(kind)
466 .map(|entry| {
467 let source = match kind {
468 Kind::Event => event_probe(catalog, entry),
469 Kind::Action => action_probe(catalog, entry),
470 Kind::Value => value_probe(catalog, entry),
471 Kind::Operator => operator_probe(catalog, entry),
472 Kind::Structural => structural_probe(catalog, entry),
473 Kind::Setting => unreachable!("settings use the settings table"),
474 Kind::Enum => unreachable!("enum domains use the enum shard"),
475 };
476 CensusCase::probe(
477 format!("{shard_id}/{}", entry.id),
478 vec![catalog_feature(kind, &entry.id)],
479 source,
480 )
481 })
482 .collect();
483 CensusShard::new(shard_id, cases)
484}
485
486fn enum_shard(catalog: &Catalog) -> Result<CensusShard, CensusError> {
487 enum_member_cases(catalog, "catalog-enums", false, true)
488}
489
490fn content_id_shard(catalog: &Catalog) -> Result<CensusShard, CensusError> {
491 enum_member_cases(catalog, "content-ids", true, false)
492}
493
494fn enum_member_cases(
498 catalog: &Catalog,
499 shard_id: &str,
500 content_only: bool,
501 domain_feature: bool,
502) -> Result<CensusShard, CensusError> {
503 let mut cases = Vec::new();
504 for domain in catalog.enum_domains() {
505 if content_only && !matches!(domain.domain.as_str(), "Hero" | "Map") {
506 continue;
507 }
508 for member in &domain.members {
509 let mut features = Vec::with_capacity(2);
510 if domain_feature {
511 features.push(catalog_feature(Kind::Enum, &domain.domain));
512 }
513 features.push(
514 FeatureId::from_enum_member(&domain.domain, &member.member)
515 .expect("canonical enum member ID"),
516 );
517 cases.push(CensusCase::probe(
518 format!("{shard_id}/{}/{}", domain.domain, member.member),
519 features,
520 enum_probe(catalog, domain, &member.member),
521 ));
522 }
523 }
524 CensusShard::new(shard_id, cases)
525}
526fn settings_shard(catalog: &Catalog) -> Result<CensusShard, CensusError> {
527 let cases = settings_schema::definitions()
528 .map(|definition| {
529 let path = definition.path().to_string();
530 CensusCase::probe(
531 format!("settings/{path}"),
532 vec![feature(
533 FeatureNamespace::Settings,
534 FeatureKind::Setting,
535 path,
536 )],
537 settings_probe(&definition, catalog),
538 )
539 })
540 .collect();
541 CensusShard::new("settings", cases)
542}
543
544fn wir_shard() -> Result<CensusShard, CensusError> {
545 let cases = CENSUS_CAPABILITIES
546 .iter()
547 .map(|capability| match capability.kind {
548 CensusCapabilityKind::Variable => wir_case(
549 "variables-global",
550 FeatureKind::Variable,
551 capability.name,
552 variables_source(),
553 ),
554 CensusCapabilityKind::PlayerVariable => CensusCase::probe(
555 "wir/variables-player".to_string(),
556 vec![feature(
557 FeatureNamespace::Wir,
558 FeatureKind::Variable,
559 capability.name,
560 )],
561 player_variable_source(),
562 ),
563 CensusCapabilityKind::Subroutine => wir_case(
564 "subroutine",
565 FeatureKind::Subroutine,
566 capability.name,
567 subroutine_source(),
568 ),
569 CensusCapabilityKind::ControlFlow => {
570 let actions = match capability.name {
571 "if" => "If(True);\n Wait(0);\nEnd;",
572 "else-if" => "If(True);\n Wait(0);\nElse If(False);\n Wait(0);\nEnd;",
573 "else" => "If(True);\n Wait(0);\nElse;\n Wait(0);\nEnd;",
574 "while" => "While(True);\n Wait(0);\nEnd;",
575 "for-global-variable" => {
576 "For Global Variable(probe, 0, 1, 1);\n Wait(0);\nEnd;"
577 }
578 _ => unreachable!("unknown WIR control-flow census capability"),
579 };
580 control_flow_case(capability.name, actions)
581 }
582 CensusCapabilityKind::String => CensusCase::probe(
583 "wir/string/custom-string".to_string(),
584 vec![feature(
585 FeatureNamespace::Wir,
586 FeatureKind::String,
587 capability.name,
588 )],
589 rule_source(
590 "String",
591 "Set Global Variable(probe, Custom String(\"census\"));",
592 ),
593 )
594 .exercise(),
595 })
596 .collect();
597 CensusShard::new("wir", cases)
598}
599
600fn localization_shard() -> Result<CensusShard, CensusError> {
601 CensusShard::new(
602 "localization",
603 vec![
604 CensusCase::probe(
605 "localization/en-us-to-zh-cn".to_string(),
606 vec![feature(
607 FeatureNamespace::Localization,
608 FeatureKind::Localization,
609 "en-us-to-zh-cn",
610 )],
611 LOCALIZATION_EN_US_SOURCE.to_string(),
612 ),
613 CensusCase::probe(
614 "localization/zh-cn-to-en-us".to_string(),
615 vec![feature(
616 FeatureNamespace::Localization,
617 FeatureKind::Localization,
618 "zh-cn-to-en-us",
619 )],
620 LOCALIZATION_ZH_CN_SOURCE.to_string(),
621 )
622 .locale(ZH_CN),
623 ],
624 )
625}
626
627fn wir_case(case_id: &str, kind: FeatureKind, name: &str, source: String) -> CensusCase {
628 CensusCase::probe(
629 format!("wir/{case_id}"),
630 vec![feature(FeatureNamespace::Wir, kind, name)],
631 source,
632 )
633 .exercise()
634}
635
636fn control_flow_case(name: &str, actions: &str) -> CensusCase {
637 CensusCase::probe(
638 format!("wir/control-flow/{name}"),
639 vec![feature(
640 FeatureNamespace::Wir,
641 FeatureKind::ControlFlow,
642 name,
643 )],
644 rule_source(name, actions),
645 )
646}
647
648fn generated_probe_support() -> CensusSupport {
649 CensusSupport::Inconclusive {
650 detail: "generated probe has no independently recorded expected result".to_string(),
651 }
652}
653
654fn rule_source(name: &str, actions: &str) -> String {
655 format!(
656 "variables {{\n global:\n 0: probe\n}}\n\nrule (\"{name}\") {{\n event {{\n Ongoing - Global;\n }}\n actions {{\n {actions}\n }}\n}}\n"
657 )
658}
659
660fn variables_source() -> String {
661 "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"
662 .to_string()
663}
664
665fn player_variable_source() -> String {
666 "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"
667 .to_string()
668}
669
670fn subroutine_source() -> String {
671 "subroutines {\n 0: probe\n}\n\nrule (\"Subroutine\") {\n event {\n Subroutine;\n probe;\n }\n actions {\n Call Subroutine(probe);\n }\n}\n"
672 .to_string()
673}
674
675fn event_probe(catalog: &Catalog, entry: &CatalogEntry) -> String {
676 let spelling = catalog
677 .spelling(Kind::Event, &Locale::new(EN_US), &entry.id)
678 .unwrap_or(&entry.id);
679 let filters = if matches!(entry.id.as_str(), "global" | "subroutine") {
680 String::new()
681 } else {
682 " All;\n All;\n".to_string()
683 };
684 let subroutine = if entry.id == "subroutine" {
685 " probe;\n"
686 } else {
687 ""
688 };
689 format!(
690 "subroutines {{\n 0: probe\n}}\n\nrule (\"Event\") {{\n event {{\n {spelling};\n{filters}{subroutine} }}\n actions {{\n Wait;\n }}\n}}\n"
691 )
692}
693
694fn action_probe(catalog: &Catalog, entry: &CatalogEntry) -> String {
695 let spelling = catalog
696 .spelling(Kind::Action, &Locale::new(EN_US), &entry.id)
697 .unwrap_or(&entry.id);
698 let call = if matches!(
699 entry.id.as_str(),
700 "chasePlayerVariableAtRate" | "chasePlayerVariableOverTime"
701 ) {
702 format!("{spelling}(Event Player, probe, 0, 1, 0);")
703 } else {
704 format!("{spelling};")
705 };
706 rule_source("Action", &call)
707}
708
709fn value_probe(catalog: &Catalog, entry: &CatalogEntry) -> String {
710 let spelling = catalog
711 .spelling(Kind::Value, &Locale::new(EN_US), &entry.id)
712 .unwrap_or(&entry.id);
713 rule_source("Value", &format!("Set Global Variable(probe, {spelling});"))
714}
715
716fn operator_probe(catalog: &Catalog, entry: &CatalogEntry) -> String {
717 let spelling = catalog
718 .spelling(Kind::Operator, &Locale::new(EN_US), &entry.id)
719 .unwrap_or(&entry.id);
720 rule_source(
721 "Operator",
722 &format!("If(1 {spelling} 1);\n Wait(0);\nEnd;"),
723 )
724}
725
726fn structural_probe(catalog: &Catalog, entry: &CatalogEntry) -> String {
727 let spelling = catalog
728 .spelling(Kind::Structural, &Locale::new(EN_US), &entry.id)
729 .unwrap_or(&entry.id);
730 let actions = match entry.id.as_str() {
731 "if" => format!("{spelling}(True);\n Wait(0);\nEnd;"),
732 "elseIf" => format!("If(True);\n Wait(0);\n{spelling}(False);\n Wait(0);\nEnd;"),
733 "else" => format!("If(True);\n Wait(0);\n{spelling};\n Wait(0);\nEnd;"),
734 "end" => format!("If(True);\n Wait(0);\n{spelling};"),
735 "while" => format!("{spelling}(True);\n Wait(0);\nEnd;"),
736 "forGlobalVariable" => format!("{spelling}(probe, 0, 1, 1);\n Wait(0);\nEnd;"),
737 "setGlobalVariable" => format!("{spelling}(probe, 1);"),
738 "modifyGlobalVariable" => format!("{spelling}(probe, Add, 1);"),
739 "setPlayerVariable" => format!("{spelling}(Event Player, probe, 1);"),
740 "modifyPlayerVariable" => format!("{spelling}(Event Player, probe, Add, 1);"),
741 "callSubroutine" => format!("{spelling}(probe);"),
742 _ => format!("{spelling};"),
743 };
744 let prefix = match entry.id.as_str() {
745 "setPlayerVariable" | "modifyPlayerVariable" => {
746 "variables {\n player:\n 0: probe\n}\n\n"
747 }
748 "callSubroutine" => "subroutines {\n 0: probe\n}\n\n",
749 _ => "",
750 };
751 format!("{prefix}{}", rule_source("Structural", &actions))
752}
753
754fn enum_probe(catalog: &Catalog, domain: &EnumDomain, member: &str) -> String {
755 let locale = Locale::new(EN_US);
756 let domain_spelling = catalog
757 .spelling(Kind::Value, &locale, &domain.domain)
758 .unwrap_or(&domain.domain);
759 let member_spelling = catalog
760 .enum_spelling(&domain.domain, &locale, member)
761 .unwrap_or(member);
762 rule_source(
763 "Enum",
764 &format!("Set Global Variable(probe, {domain_spelling}({member_spelling}));"),
765 )
766}
767
768fn settings_probe(definition: &SettingDefinition, catalog: &Catalog) -> String {
769 let mut lines = vec!["settings {".to_string()];
770 let mut depth = 1;
771 let parts: Vec<_> = definition.path().split('.').collect();
772 for (index, part) in parts.iter().enumerate() {
773 let name = match *part {
774 "gamemodes" => "modes".to_string(),
775 "heroes" => "heroes".to_string(),
776 "<team>" => "General".to_string(),
777 "<hero>" => {
778 catalog_enum_spelling(catalog, "Hero", "mei").unwrap_or_else(|| "Mei".to_string())
779 }
780 "general" => "General".to_string(),
781 value if index == 1 && parts.first() == Some(&"gamemodes") => {
782 mode_spelling(catalog, value)
783 }
784 value => value.to_string(),
785 };
786 lines.push(format!("{}{} {{", " ".repeat(depth), name));
787 depth += 1;
788 }
789 let indent = " ".repeat(depth);
790 let name = definition.presentation().english_name;
791 match definition.domain() {
792 SettingValueDomain::PresenceOnly => lines.push(format!("{indent}{name}")),
793 SettingValueDomain::String => lines.push(format!("{indent}{name}: \"census\"")),
794 SettingValueDomain::Boolean => {
795 let value = definition
796 .enum_members()
797 .next()
798 .map(|member| member.english_name().to_string())
799 .unwrap_or_else(|| "On".to_string());
800 lines.push(format!("{indent}{name}: {value}"));
801 }
802 SettingValueDomain::Number(_) => lines.push(format!("{indent}{name}: 1")),
803 SettingValueDomain::Percent(_) => lines.push(format!("{indent}{name}: 100%")),
804 SettingValueDomain::Enum { .. } => {
805 let value = definition
806 .enum_members()
807 .next()
808 .map(|member| member.english_name().to_string())
809 .unwrap_or_else(|| "Off".to_string());
810 lines.push(format!("{indent}{name}: {value}"));
811 }
812 SettingValueDomain::HeroList | SettingValueDomain::MapList => {
813 lines.push(format!("{indent}{name} {{"));
814 lines.push(format!("{indent}}}"));
815 }
816 other => unreachable!("census has no probe for settings value domain {other:?}"),
817 }
818 while depth > 1 {
819 depth -= 1;
820 lines.push(format!("{}{}", " ".repeat(depth), "}"));
821 }
822 lines.push("}".to_string());
823 lines.join("\n")
824}
825
826fn mode_spelling(catalog: &Catalog, key: &str) -> String {
827 catalog_enum_spelling(catalog, "Gamemode", key).unwrap_or_else(|| key.to_string())
828}
829
830fn catalog_enum_spelling(catalog: &Catalog, domain: &str, key: &str) -> Option<String> {
831 let normalize = |value: &str| {
832 value
833 .chars()
834 .filter(|character| character.is_ascii_alphanumeric())
835 .flat_map(char::to_lowercase)
836 .collect::<String>()
837 };
838 let normalized = normalize(key);
839 let member = catalog
840 .enum_domains()
841 .find(|candidate| candidate.domain == domain)?
842 .members
843 .iter()
844 .find(|candidate| normalize(&candidate.member) == normalized)?;
845 catalog
846 .enum_spelling(domain, &Locale::new(EN_US), &member.member)
847 .map(str::to_string)
848}
849
850fn run_case(case: &CensusCase, shard_id: &str, catalog: &Catalog) -> ConformanceResult {
851 let source = artifact(
852 format!("census/{shard_id}/{}.ws", case.case_id),
853 &case.source,
854 );
855 let base = |status, comparison, reason, locale| ConformanceResult {
856 schema_version: CONFORMANCE_SCHEMA_VERSION,
857 case_id: case.case_id.clone(),
858 features: case.features.clone(),
859 status,
860 comparison,
861 source: source.clone(),
862 catalog: catalog.identity(),
863 locale,
864 reason,
865 };
866 match &case.support {
867 CensusSupport::Unsupported { detail } => base(
868 ConformanceStatus::Unsupported,
869 not_comparable(),
870 Some(reason(ReasonCode::Unsupported, detail)),
871 None,
872 ),
873 CensusSupport::KnownGap { detail } => base(
874 ConformanceStatus::KnownGap,
875 not_comparable(),
876 Some(reason(ReasonCode::KnownGap, detail)),
877 None,
878 ),
879 CensusSupport::Inconclusive { detail } => execute_case(case, base, catalog, Some(detail)),
880 CensusSupport::Exercise => execute_case(case, base, catalog, None),
881 }
882}
883
884struct Fail {
886 code: ReasonCode,
887 detail: String,
888 locale: Locale,
889}
890
891impl Fail {
892 fn workshop(error: &WorkshopError, locale: &Locale) -> Self {
893 let code = match error {
894 WorkshopError::Unsupported { .. } => ReasonCode::Unsupported,
895 WorkshopError::MissingMapping { .. } => ReasonCode::KnownGap,
896 _ => ReasonCode::UnexpectedRegression,
897 };
898 Self {
899 code,
900 detail: error.to_string(),
901 locale: locale.clone(),
902 }
903 }
904
905 fn regression(detail: impl Into<String>, locale: &Locale) -> Self {
906 Self {
907 code: ReasonCode::UnexpectedRegression,
908 detail: detail.into(),
909 locale: locale.clone(),
910 }
911 }
912}
913
914fn execute_case(
915 case: &CensusCase,
916 base: impl Fn(
917 ConformanceStatus,
918 Comparison,
919 Option<ConformanceReason>,
920 Option<Locale>,
921 ) -> ConformanceResult,
922 catalog: &Catalog,
923 inconclusive_detail: Option<&str>,
924) -> ConformanceResult {
925 match run_pipeline(case, catalog, inconclusive_detail) {
926 Ok((comparison, locale)) => {
927 base(ConformanceStatus::Matched, comparison, None, Some(locale))
928 }
929 Err(fail) => failed_text(base, fail.code, fail.detail, Some(fail.locale)),
930 }
931}
932
933fn run_pipeline(
937 case: &CensusCase,
938 catalog: &Catalog,
939 inconclusive_detail: Option<&str>,
940) -> Result<(Comparison, Locale), Fail> {
941 let source_locale = Locale::new(&case.source_locale);
942 let target_locale = if source_locale.as_str() == ZH_CN {
943 Locale::new(EN_US)
944 } else {
945 Locale::new(ZH_CN)
946 };
947
948 let parse = |source: &str, locale: &Locale| {
949 parser::parse_with_context(source, catalog, locale, catalog)
950 .map_err(|error| Fail::workshop(&error, locale))
951 };
952 let validate = |program: &workshop_rs::Program, locale: &Locale| {
953 program
954 .validate()
955 .map_err(|error| Fail::regression(error.to_string(), locale))
956 };
957 let emit = |program: &workshop_rs::Program, locale: &Locale| {
958 emitter::emit(program, catalog, locale).map_err(|error| Fail::workshop(&error, locale))
959 };
960 let convert_or =
961 |source: &str, from: &Locale, to: &Locale| -> Result<convert::Conversion, Fail> {
962 convert::convert(source, catalog, from, to, &Default::default())
963 .map_err(|error| Fail::workshop(&error, to))
964 };
965
966 let program = parse(&case.source, &source_locale)?;
967 validate(&program, &source_locale)?;
968 let emitted = emit(&program, &source_locale)?;
969 let reparsed = parse(&emitted, &source_locale)?;
970 validate(&reparsed, &source_locale)?;
971 let reemitted = emit(&reparsed, &source_locale)?;
972 if !roundtrip::equivalent(&program, &reparsed)
973 || normalize_workshop(&emitted) != normalize_workshop(&reemitted)
974 {
975 return Err(Fail::regression(
976 "en-US semantic or normalized gate diverged",
977 &source_locale,
978 ));
979 }
980
981 let converted = convert_or(&case.source, &source_locale, &target_locale)?;
982 let target_program = parse(&converted.text, &target_locale)?;
983 validate(&target_program, &target_locale)?;
984 if !roundtrip::equivalent(&program, &target_program) {
985 return Err(Fail::regression(
986 "zh-CN conversion changed canonical WIR semantics",
987 &target_locale,
988 ));
989 }
990 let back = convert_or(&converted.text, &target_locale, &source_locale)?;
991 let back_program = parse(&back.text, &source_locale)?;
992 if !roundtrip::equivalent(&program, &back_program)
993 || normalize_workshop(&back.text) != normalize_workshop(&case.source)
994 {
995 return Err(Fail::regression(
996 "cross-locale semantic or normalized gate diverged",
997 &source_locale,
998 ));
999 }
1000
1001 let Some(reference_source) = case.reference_source.as_ref() else {
1002 return Err(Fail {
1003 code: ReasonCode::Inconclusive,
1004 detail: inconclusive_detail
1005 .unwrap_or(
1006 "offline semantic and locale gates passed, but no independent expectation artifact is recorded",
1007 )
1008 .to_string(),
1009 locale: source_locale,
1010 });
1011 };
1012 let expected_program = parse(reference_source, &target_locale)?;
1013 if !roundtrip::equivalent(&program, &expected_program)
1014 || normalize_workshop(&converted.text) != normalize_workshop(reference_source)
1015 {
1016 return Err(Fail::regression(
1017 "conversion differed from the independent reference source",
1018 &target_locale,
1019 ));
1020 }
1021 Ok((
1022 Comparison {
1023 mode: Equivalence::Semantic,
1024 expected: Some(reference_artifact(case, reference_source)),
1025 observed: Some(artifact(
1026 format!("census/{}/converted-output.ws", case.case_id),
1027 &converted.text,
1028 )),
1029 normalizer: Some("canonical-wir;normalized-workshop-text".to_string()),
1030 },
1031 source_locale,
1032 ))
1033}
1034
1035fn failed_text(
1036 base: impl Fn(
1037 ConformanceStatus,
1038 Comparison,
1039 Option<ConformanceReason>,
1040 Option<Locale>,
1041 ) -> ConformanceResult,
1042 code: ReasonCode,
1043 detail: String,
1044 locale: Option<Locale>,
1045) -> ConformanceResult {
1046 let status = match code {
1047 ReasonCode::Unsupported => ConformanceStatus::Unsupported,
1048 ReasonCode::KnownGap => ConformanceStatus::KnownGap,
1049 ReasonCode::UnexpectedRegression => ConformanceStatus::UnexpectedRegression,
1050 ReasonCode::Inconclusive => ConformanceStatus::Inconclusive,
1051 };
1052 let comparison = if code == ReasonCode::UnexpectedRegression {
1053 Comparison {
1054 mode: Equivalence::Normalized,
1055 expected: None,
1056 observed: None,
1057 normalizer: Some("census-stage".to_string()),
1058 }
1059 } else {
1060 not_comparable()
1061 };
1062 base(status, comparison, Some(reason(code, &detail)), locale)
1063}
1064
1065fn reason(code: ReasonCode, detail: &str) -> ConformanceReason {
1066 ConformanceReason {
1067 code,
1068 detail: detail.to_string(),
1069 }
1070}
1071
1072fn not_comparable() -> Comparison {
1073 Comparison {
1074 mode: Equivalence::NotComparable,
1075 expected: None,
1076 observed: None,
1077 normalizer: None,
1078 }
1079}
1080
1081fn artifact(name: impl Into<String>, content: &str) -> TestArtifact {
1082 TestArtifact {
1083 name: name.into(),
1084 revision: None,
1085 path: None,
1086 sha256: Some(sha256(content)),
1087 license: Some("MIT".to_string()),
1088 }
1089}
1090
1091fn reference_artifact(case: &CensusCase, content: &str) -> TestArtifact {
1092 TestArtifact {
1093 name: format!("census reference for {}", case.case_id),
1094 revision: Some("census-v1".to_string()),
1095 path: Some("tests/fixtures/census/reference.ws".to_string()),
1096 sha256: Some(sha256(content)),
1097 license: Some("MIT".to_string()),
1098 }
1099}
1100
1101fn sha256(content: &str) -> String {
1102 let mut hasher = Sha256::new();
1103 hasher.update(content.as_bytes());
1104 format!("{:x}", hasher.finalize())
1105}
1106
1107fn normalize_workshop(text: &str) -> String {
1108 text.split_whitespace().collect::<Vec<_>>().join(" ")
1109}
1110
1111#[cfg(test)]
1112mod tests {
1113 use super::*;
1114
1115 #[test]
1116 fn builtin_census_is_derived_and_deterministic() {
1117 let catalog = Catalog::builtin().expect("builtin catalog");
1118 let first = Census::builtin(&catalog).expect("census");
1119 let second = Census::builtin(&catalog).expect("census");
1120 assert_eq!(first, second);
1121 assert_eq!(first.shards().first().unwrap().shard_id, "catalog-actions");
1122 assert!(
1123 first
1124 .cases()
1125 .any(|case| case.features.iter().any(|feature| feature.name == "wait"))
1126 );
1127 assert!(first.cases().any(|case| {
1128 case.features
1129 .iter()
1130 .any(|feature| feature.kind == FeatureKind::Setting)
1131 }));
1132 assert!(first.cases().any(|case| {
1133 case.features
1134 .iter()
1135 .any(|feature| feature.kind == FeatureKind::ControlFlow)
1136 }));
1137 assert_eq!(first.export_json().unwrap(), second.export_json().unwrap());
1138 assert_eq!(first.identity(), second.identity());
1139 assert_eq!(first.identity().digest.len(), 64);
1140 }
1141
1142 #[test]
1143 fn settings_probes_use_catalog_gamemode_spellings() {
1144 let catalog = Catalog::builtin().expect("builtin catalog");
1145
1146 for (mode, expected) in [
1147 ("ffa", "Deathmatch"),
1148 ("tdm", "Team Deathmatch"),
1149 ("ctf", "Capture The Flag"),
1150 ] {
1151 let prefix = format!("gamemodes.{mode}.");
1152 let definition = settings_schema::definitions()
1153 .find(|definition| definition.path().starts_with(&prefix))
1154 .unwrap_or_else(|| panic!("missing settings definition for {mode}"));
1155 let probe = settings_probe(&definition, &catalog);
1156 assert!(
1157 probe.contains(&format!("{expected} {{")),
1158 "{mode} probe used the wrong mode spelling:\n{probe}"
1159 );
1160 }
1161 }
1162
1163 #[test]
1164 fn explicit_non_matching_states_remain_machine_readable() {
1165 let feature_case = |id: &str, support| {
1166 CensusCase::probe(
1167 format!("state-tests/{id}"),
1168 vec![feature(FeatureNamespace::Wir, FeatureKind::Structural, id)],
1169 format!("rule (\"{id}\") {{}}"),
1170 )
1171 .with_support(support)
1172 };
1173 let shard = CensusShard::new(
1174 "state-tests",
1175 vec![
1176 feature_case(
1177 "unsupported",
1178 CensusSupport::Unsupported {
1179 detail: "not declared".to_string(),
1180 },
1181 ),
1182 feature_case(
1183 "known-gap",
1184 CensusSupport::KnownGap {
1185 detail: "missing mapping".to_string(),
1186 },
1187 ),
1188 feature_case(
1189 "inconclusive",
1190 CensusSupport::Inconclusive {
1191 detail: "no oracle".to_string(),
1192 },
1193 ),
1194 ],
1195 )
1196 .unwrap();
1197 let report = Census::assemble(vec![shard])
1198 .unwrap()
1199 .run(&Catalog::builtin().unwrap());
1200 report
1201 .validate()
1202 .expect("states use the current conformance adapter");
1203 let json = report.to_json().unwrap();
1204 assert!(json.contains("unsupported"));
1205 assert!(json.contains("known-gap"));
1206 assert!(json.contains("inconclusive"));
1207 }
1208
1209 #[test]
1210 fn builtin_census_report_validates_against_the_catalog() {
1211 let catalog = Catalog::builtin().expect("builtin catalog");
1212 let census = Census::builtin(&catalog).expect("census");
1213 let report = census.run(&catalog);
1214 report
1215 .validate_against(&catalog)
1216 .expect("census results use canonical catalog identities");
1217 assert_eq!(report.census, census.identity());
1218 assert_eq!(
1219 report
1220 .results
1221 .iter()
1222 .filter(|result| result.case_id.starts_with("localization/"))
1223 .count(),
1224 2
1225 );
1226 }
1227
1228 #[test]
1229 fn census_report_rejects_a_malformed_identity_digest() {
1230 let catalog = Catalog::builtin().expect("builtin catalog");
1231 let census = Census::builtin(&catalog).expect("census");
1232 let mut report = census.run(&catalog);
1233 report.census.digest = "not-a-digest".to_string();
1234
1235 let error = report
1236 .validate_against(&catalog)
1237 .expect_err("report identity must carry a SHA-256 digest");
1238 assert!(error.to_string().contains("SHA-256"));
1239 }
1240}