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