1use serde::{Deserialize, Serialize};
9use std::collections::HashSet;
10
11use crate::catalog::{Catalog, CatalogIdentity, Kind, Locale};
12
13pub const CONFORMANCE_SCHEMA_VERSION: u32 = 1;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
18#[serde(rename_all = "kebab-case")]
19pub enum FeatureNamespace {
20 Catalog,
22 Wir,
24 Settings,
26 Localization,
28}
29
30impl FeatureNamespace {
31 pub const fn as_str(self) -> &'static str {
33 match self {
34 Self::Catalog => "catalog",
35 Self::Wir => "wir",
36 Self::Settings => "settings",
37 Self::Localization => "localization",
38 }
39 }
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
49#[serde(rename_all = "kebab-case")]
50pub enum FeatureKind {
51 Event,
53 Action,
55 Value,
57 Operator,
59 Enum,
61 EnumMember,
63 Setting,
65 Variable,
67 Subroutine,
69 ControlFlow,
71 String,
73 Localization,
75 ContentId,
77 Structural,
79}
80
81impl FeatureKind {
82 pub const fn as_str(self) -> &'static str {
84 match self {
85 Self::Event => "event",
86 Self::Action => "action",
87 Self::Value => "value",
88 Self::Operator => "operator",
89 Self::Enum => "enum",
90 Self::EnumMember => "enum-member",
91 Self::Setting => "setting",
92 Self::Variable => "variable",
93 Self::Subroutine => "subroutine",
94 Self::ControlFlow => "control-flow",
95 Self::String => "string",
96 Self::Localization => "localization",
97 Self::ContentId => "content-id",
98 Self::Structural => "structural",
99 }
100 }
101}
102
103impl From<Kind> for FeatureKind {
104 fn from(kind: Kind) -> Self {
105 match kind {
106 Kind::Structural => Self::Structural,
107 Kind::Action => Self::Action,
108 Kind::Value => Self::Value,
109 Kind::Event => Self::Event,
110 Kind::Operator => Self::Operator,
111 Kind::Enum => Self::Enum,
112 Kind::Setting => Self::Setting,
113 }
114 }
115}
116
117#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)]
119pub struct FeatureId {
120 pub namespace: FeatureNamespace,
122 pub kind: FeatureKind,
124 pub name: String,
126}
127
128impl FeatureId {
129 pub fn new(
135 namespace: FeatureNamespace,
136 kind: FeatureKind,
137 name: impl Into<String>,
138 ) -> Result<Self, ConformanceError> {
139 let name = name.into();
140 if name.is_empty() {
141 return Err(ConformanceError::invalid(
142 "feature.name",
143 "must not be empty",
144 ));
145 }
146 if name
147 .chars()
148 .any(|character| character.is_whitespace() || character.is_control())
149 {
150 return Err(ConformanceError::invalid(
151 "feature.name",
152 "must not contain whitespace or control characters",
153 ));
154 }
155 Ok(Self {
156 namespace,
157 kind,
158 name,
159 })
160 }
161
162 pub fn from_catalog(kind: Kind, id: impl Into<String>) -> Result<Self, ConformanceError> {
164 Self::new(FeatureNamespace::Catalog, kind.into(), id)
165 }
166
167 pub fn from_enum_member(
169 domain: impl Into<String>,
170 member: impl Into<String>,
171 ) -> Result<Self, ConformanceError> {
172 let domain = domain.into();
173 let member = member.into();
174 if domain.is_empty() || member.is_empty() {
175 return Err(ConformanceError::invalid(
176 "feature.name",
177 "enum member identities require a domain and member",
178 ));
179 }
180 Self::new(
181 FeatureNamespace::Catalog,
182 FeatureKind::EnumMember,
183 format!("{domain}/{member}"),
184 )
185 }
186
187 pub fn owned(
190 namespace: FeatureNamespace,
191 kind: FeatureKind,
192 name: impl Into<String>,
193 ) -> Result<Self, ConformanceError> {
194 Self::new(namespace, kind, name)
195 }
196}
197
198#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
200#[serde(rename_all = "kebab-case")]
201pub enum EvidenceBasis {
202 WorkshopClient,
204 PinnedExternalOracle,
206 SemanticContract,
208 PreservedRegression,
210}
211
212#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
214#[serde(rename_all = "kebab-case")]
215pub enum EvidenceClass {
216 Synthetic,
218 MinimizedRegression,
220 RealProject,
222 LiveClient,
224}
225
226#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
228pub struct EvidenceArtifact {
229 pub name: String,
231 pub revision: Option<String>,
233 pub path: Option<String>,
235 #[serde(rename = "sha256")]
237 pub sha256: Option<String>,
238 pub license: Option<String>,
240}
241
242impl EvidenceArtifact {
243 pub fn new(name: impl Into<String>) -> Self {
245 Self {
246 name: name.into(),
247 revision: None,
248 path: None,
249 sha256: None,
250 license: None,
251 }
252 }
253}
254
255#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
257#[serde(rename_all = "camelCase")]
258pub struct ExpectationSource {
259 pub basis: EvidenceBasis,
260 pub artifact: EvidenceArtifact,
261 pub tracking_ref: Option<String>,
263}
264
265#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
267#[serde(rename_all = "camelCase")]
268pub struct ClientEvidence {
269 pub game: String,
271 pub client_version: Option<String>,
273 pub season: Option<String>,
275 pub captured_at: String,
277 pub environment: Option<String>,
279}
280
281#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
286pub struct ImplementationIdentity {
287 pub name: String,
288 pub version: String,
289 pub revision: Option<String>,
290 pub artifact: Option<EvidenceArtifact>,
292}
293
294#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
296#[serde(rename_all = "camelCase")]
297pub struct Evidence {
298 pub class: EvidenceClass,
299 pub fixture: EvidenceArtifact,
301 pub expectation: ExpectationSource,
303 pub catalog: CatalogIdentity,
305 pub locale: Option<Locale>,
307 pub client: Option<ClientEvidence>,
309 pub implementation: Option<ImplementationIdentity>,
311}
312
313#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
315#[serde(rename_all = "kebab-case")]
316pub enum Equivalence {
317 Semantic,
319 Normalized,
322 ExactText,
324 NotComparable,
327}
328
329#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
331#[serde(rename_all = "camelCase")]
332pub struct Comparison {
333 pub mode: Equivalence,
334 pub expected: Option<EvidenceArtifact>,
335 pub observed: Option<EvidenceArtifact>,
336 pub normalizer: Option<String>,
338}
339
340#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
342#[serde(rename_all = "kebab-case")]
343pub enum ReasonCode {
344 Unsupported,
345 KnownGap,
346 UnexpectedRegression,
347 Inconclusive,
348}
349
350#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
352#[serde(rename_all = "camelCase")]
353pub struct ConformanceReason {
354 pub code: ReasonCode,
355 pub detail: String,
356 pub tracking_ref: Option<String>,
357}
358
359#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
361#[serde(rename_all = "kebab-case")]
362pub enum ConformanceStatus {
363 Matched,
365 Unsupported,
368 KnownGap,
370 UnexpectedRegression,
372 Inconclusive,
374}
375
376impl ConformanceStatus {
377 pub const fn is_match(self) -> bool {
379 matches!(self, Self::Matched)
380 }
381}
382
383#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
385#[serde(rename_all = "camelCase")]
386pub struct ConformanceResult {
387 pub schema_version: u32,
388 pub case_id: String,
390 pub features: Vec<FeatureId>,
392 pub status: ConformanceStatus,
393 pub comparison: Comparison,
394 pub evidence: Evidence,
395 pub reason: Option<ConformanceReason>,
398}
399
400impl ConformanceResult {
401 pub fn validate(&self) -> Result<(), ConformanceError> {
403 if self.schema_version != CONFORMANCE_SCHEMA_VERSION {
404 return Err(ConformanceError::invalid(
405 "schemaVersion",
406 format!(
407 "unsupported schema version {}; expected {}",
408 self.schema_version, CONFORMANCE_SCHEMA_VERSION
409 ),
410 ));
411 }
412 validate_non_empty("caseId", &self.case_id)?;
413 if self.features.is_empty() {
414 return Err(ConformanceError::invalid(
415 "features",
416 "must contain at least one feature",
417 ));
418 }
419 let mut seen_features: HashSet<&FeatureId> = HashSet::with_capacity(self.features.len());
420 for (index, feature) in self.features.iter().enumerate() {
421 FeatureId::new(feature.namespace, feature.kind, feature.name.clone())
422 .map_err(|error| error.at(format!("features[{index}]")))?;
423 if !seen_features.insert(feature) {
424 return Err(ConformanceError::invalid(
425 format!("features[{index}]"),
426 "must not contain duplicate feature identities",
427 ));
428 }
429 }
430 validate_evidence(&self.evidence)?;
431 if self.status.is_match() && self.comparison.mode == Equivalence::NotComparable {
432 return Err(ConformanceError::invalid(
433 "comparison.mode",
434 "matched results must declare semantic, normalized, or exact-text equivalence",
435 ));
436 }
437 validate_comparison(&self.comparison, &self.evidence)?;
438 if self.status.is_match() {
439 if self.comparison.expected.is_none() || self.comparison.observed.is_none() {
440 return Err(ConformanceError::invalid(
441 "comparison",
442 "matched results require expected and observed artifacts",
443 ));
444 }
445 } else {
446 let reason = self.reason.as_ref().ok_or_else(|| {
447 ConformanceError::invalid(
448 "reason",
449 "non-matching results require a structured reason",
450 )
451 })?;
452 validate_reason(self.status, reason)?;
453 if self.status == ConformanceStatus::UnexpectedRegression
454 && self.comparison.mode == Equivalence::NotComparable
455 {
456 return Err(ConformanceError::invalid(
457 "comparison.mode",
458 "an unexpected regression must identify the comparison contract",
459 ));
460 }
461 }
462 Ok(())
463 }
464
465 pub fn validate_against(&self, catalog: &Catalog) -> Result<(), ConformanceError> {
469 self.validate()?;
470 if self.evidence.catalog != catalog.identity() {
471 return Err(ConformanceError::invalid(
472 "evidence.catalog",
473 "must match the catalog supplied to validate_against",
474 ));
475 }
476 for (index, feature) in self.features.iter().enumerate() {
477 if feature.namespace != FeatureNamespace::Catalog {
478 continue;
479 }
480 match feature.kind {
481 FeatureKind::Enum => {
482 if catalog.enum_domain(&feature.name).is_none() {
483 return Err(ConformanceError::invalid(
484 format!("features[{index}]"),
485 format!("unknown canonical enum domain '{}'", feature.name),
486 ));
487 }
488 }
489 FeatureKind::EnumMember => {
490 let (domain, member) = feature.name.split_once('/').ok_or_else(|| {
491 ConformanceError::invalid(
492 format!("features[{index}]"),
493 "enum-member identity must contain domain/member",
494 )
495 })?;
496 let known = catalog.enum_domain(domain).is_some_and(|candidate| {
497 candidate.members.iter().any(|item| item.member == member)
498 });
499 if !known {
500 return Err(ConformanceError::invalid(
501 format!("features[{index}]"),
502 format!("unknown canonical enum member '{domain}/{member}'"),
503 ));
504 }
505 }
506 kind => {
507 let catalog_kind = match kind {
508 FeatureKind::Event => Kind::Event,
509 FeatureKind::Action => Kind::Action,
510 FeatureKind::Value => Kind::Value,
511 FeatureKind::Operator => Kind::Operator,
512 FeatureKind::Setting => Kind::Setting,
513 FeatureKind::Structural => Kind::Structural,
514 _ => {
515 return Err(ConformanceError::invalid(
516 format!("features[{index}]"),
517 "this feature kind cannot use the catalog namespace",
518 ));
519 }
520 };
521 if catalog.entry(catalog_kind, &feature.name).is_none() {
522 return Err(ConformanceError::invalid(
523 format!("features[{index}]"),
524 format!(
525 "unknown canonical {} '{}'",
526 catalog_kind.as_str(),
527 feature.name
528 ),
529 ));
530 }
531 }
532 }
533 }
534 Ok(())
535 }
536
537 pub fn from_json(json: &str) -> Result<Self, ConformanceDecodeError> {
539 let result: Self = serde_json::from_str(json).map_err(ConformanceDecodeError::Json)?;
540 result.validate().map_err(ConformanceDecodeError::Invalid)?;
541 Ok(result)
542 }
543
544 pub const fn is_match(&self) -> bool {
546 self.status.is_match()
547 }
548}
549
550fn validate_evidence(evidence: &Evidence) -> Result<(), ConformanceError> {
551 validate_artifact(
552 "evidence.fixture",
553 &evidence.fixture,
554 false,
555 evidence.class == EvidenceClass::LiveClient,
556 )?;
557 validate_artifact(
558 "evidence.expectation.artifact",
559 &evidence.expectation.artifact,
560 matches!(
561 evidence.expectation.basis,
562 EvidenceBasis::PinnedExternalOracle | EvidenceBasis::PreservedRegression
563 ),
564 false,
565 )?;
566 if evidence.class == EvidenceClass::LiveClient
567 && evidence.expectation.basis != EvidenceBasis::WorkshopClient
568 {
569 return Err(ConformanceError::invalid(
570 "evidence.expectation.basis",
571 "live-client evidence must use workshop-client evidence basis",
572 ));
573 }
574 if matches!(
575 evidence.expectation.basis,
576 EvidenceBasis::PinnedExternalOracle | EvidenceBasis::PreservedRegression
577 ) && evidence
578 .expectation
579 .artifact
580 .revision
581 .as_deref()
582 .is_none_or(str::is_empty)
583 {
584 return Err(ConformanceError::invalid(
585 "evidence.expectation.artifact.revision",
586 "pinned oracle and preserved regression evidence require an immutable revision",
587 ));
588 }
589 if evidence.class == EvidenceClass::LiveClient {
590 let client = evidence.client.as_ref().ok_or_else(|| {
591 ConformanceError::invalid(
592 "evidence.client",
593 "live-client evidence requires client provenance",
594 )
595 })?;
596 validate_non_empty("evidence.client.game", &client.game)?;
597 validate_non_empty("evidence.client.capturedAt", &client.captured_at)?;
598 if evidence.locale.is_none() {
599 return Err(ConformanceError::invalid(
600 "evidence.locale",
601 "live-client evidence requires a client locale",
602 ));
603 }
604 } else if evidence.client.is_some() {
605 return Err(ConformanceError::invalid(
606 "evidence.client",
607 "client provenance is only valid for live-client evidence",
608 ));
609 }
610 if evidence.class == EvidenceClass::MinimizedRegression
611 && evidence.expectation.basis != EvidenceBasis::PreservedRegression
612 {
613 return Err(ConformanceError::invalid(
614 "evidence.expectation.basis",
615 "minimized-regression evidence must use preserved-regression basis",
616 ));
617 }
618 validate_non_empty(
619 "evidence.catalog.implementationVersion",
620 &evidence.catalog.implementation_version,
621 )?;
622 validate_non_empty(
623 "evidence.catalog.catalogVersion",
624 &evidence.catalog.catalog_version,
625 )?;
626 if evidence
627 .catalog
628 .catalog_digest
629 .as_deref()
630 .is_none_or(str::is_empty)
631 {
632 return Err(ConformanceError::invalid(
633 "evidence.catalog.catalogDigest",
634 "conformance evidence requires a pinned catalog digest",
635 ));
636 }
637 Ok(())
638}
639
640fn validate_artifact(
641 field: &str,
642 artifact: &EvidenceArtifact,
643 require_revision: bool,
644 require_digest: bool,
645) -> Result<(), ConformanceError> {
646 validate_non_empty(&format!("{field}.name"), &artifact.name)?;
647 if require_revision && artifact.revision.as_deref().is_none_or(str::is_empty) {
648 return Err(ConformanceError::invalid(
649 format!("{field}.revision"),
650 "must identify an immutable revision",
651 ));
652 }
653 if let Some(digest) = &artifact.sha256 {
654 if digest.len() != 64 || !digest.bytes().all(|byte| byte.is_ascii_hexdigit()) {
655 return Err(ConformanceError::invalid(
656 format!("{field}.sha256"),
657 "must be a 64-character hexadecimal SHA-256 digest",
658 ));
659 }
660 }
661 if require_digest && artifact.sha256.is_none() {
662 return Err(ConformanceError::invalid(
663 format!("{field}.sha256"),
664 "materialized evidence requires a SHA-256 digest",
665 ));
666 }
667 Ok(())
668}
669
670fn validate_comparison(
671 comparison: &Comparison,
672 evidence: &Evidence,
673) -> Result<(), ConformanceError> {
674 if comparison.mode == Equivalence::Normalized
675 && comparison
676 .normalizer
677 .as_deref()
678 .is_none_or(|normalizer| normalizer.trim().is_empty())
679 {
680 return Err(ConformanceError::invalid(
681 "comparison.normalizer",
682 "normalized comparisons require a named normalizer",
683 ));
684 }
685 if let Some(expected) = &comparison.expected {
686 validate_artifact("comparison.expected", expected, false, false)?;
687 }
688 if let Some(observed) = &comparison.observed {
689 validate_artifact("comparison.observed", observed, false, false)?;
690 }
691 if let (Some(expected), Some(observed)) = (&comparison.expected, &comparison.observed) {
692 if expected == observed {
693 return Err(ConformanceError::invalid(
694 "comparison",
695 "expected and observed artifacts must be distinct",
696 ));
697 }
698 }
699 if let Some(expected) = &comparison.expected {
700 if Some(expected) == Some(&evidence.fixture) {
701 return Err(ConformanceError::invalid(
702 "comparison.expected",
703 "expected artifact must not be the executed fixture",
704 ));
705 }
706 if evidence
707 .implementation
708 .as_ref()
709 .and_then(|implementation| implementation.artifact.as_ref())
710 == Some(expected)
711 {
712 return Err(ConformanceError::invalid(
713 "comparison.expected",
714 "expected artifact must not be the implementation artifact",
715 ));
716 }
717 }
718 if let Some(observed) = &comparison.observed {
719 if Some(observed) == Some(&evidence.fixture)
720 || Some(observed) == Some(&evidence.expectation.artifact)
721 || evidence
722 .implementation
723 .as_ref()
724 .and_then(|implementation| implementation.artifact.as_ref())
725 == Some(observed)
726 {
727 return Err(ConformanceError::invalid(
728 "comparison.observed",
729 "observed artifact must be distinct from fixture, expectation, and implementation artifacts",
730 ));
731 }
732 }
733 Ok(())
734}
735
736fn validate_reason(
737 status: ConformanceStatus,
738 reason: &ConformanceReason,
739) -> Result<(), ConformanceError> {
740 validate_non_empty("reason.detail", &reason.detail)?;
741 let expected = match status {
742 ConformanceStatus::Unsupported => ReasonCode::Unsupported,
743 ConformanceStatus::KnownGap => ReasonCode::KnownGap,
744 ConformanceStatus::UnexpectedRegression => ReasonCode::UnexpectedRegression,
745 ConformanceStatus::Inconclusive => ReasonCode::Inconclusive,
746 ConformanceStatus::Matched => {
747 return Err(ConformanceError::invalid(
748 "reason",
749 "matched results must not carry a non-matching reason",
750 ));
751 }
752 };
753 if reason.code != expected {
754 return Err(ConformanceError::invalid(
755 "reason.code",
756 "reason code must match conformance status",
757 ));
758 }
759 if reason.code == ReasonCode::KnownGap
760 && reason
761 .tracking_ref
762 .as_deref()
763 .is_none_or(|tracking_ref| tracking_ref.trim().is_empty())
764 {
765 return Err(ConformanceError::invalid(
766 "reason.trackingRef",
767 "known gaps require a tracking reference",
768 ));
769 }
770 Ok(())
771}
772
773fn validate_non_empty(field: &str, value: &str) -> Result<(), ConformanceError> {
774 if value.trim().is_empty() {
775 Err(ConformanceError::invalid(field, "must not be empty"))
776 } else {
777 Ok(())
778 }
779}
780
781#[derive(Debug, Clone, PartialEq, Eq)]
783pub struct ConformanceError {
784 pub field: String,
785 pub message: String,
786}
787
788impl ConformanceError {
789 fn invalid(field: impl Into<String>, message: impl Into<String>) -> Self {
790 Self {
791 field: field.into(),
792 message: message.into(),
793 }
794 }
795
796 fn at(self, field: String) -> Self {
797 Self { field, ..self }
798 }
799}
800
801impl std::fmt::Display for ConformanceError {
802 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
803 write!(
804 formatter,
805 "invalid conformance {}: {}",
806 self.field, self.message
807 )
808 }
809}
810
811impl std::error::Error for ConformanceError {}
812
813#[derive(Debug)]
815pub enum ConformanceDecodeError {
816 Json(serde_json::Error),
817 Invalid(ConformanceError),
818}
819
820impl std::fmt::Display for ConformanceDecodeError {
821 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
822 match self {
823 Self::Json(error) => write!(formatter, "invalid conformance JSON: {error}"),
824 Self::Invalid(error) => error.fmt(formatter),
825 }
826 }
827}
828
829impl std::error::Error for ConformanceDecodeError {}
830
831#[cfg(test)]
832mod tests {
833 use super::*;
834 use crate::catalog::{Catalog, Locale};
835
836 fn catalog() -> CatalogIdentity {
837 Catalog::builtin().expect("built-in catalog").identity()
838 }
839
840 fn evidence(class: EvidenceClass, basis: EvidenceBasis) -> Evidence {
841 Evidence {
842 class,
843 fixture: EvidenceArtifact {
844 name: "fixture".to_string(),
845 revision: Some("abc123".to_string()),
846 path: Some("cases/basic.ws".to_string()),
847 sha256: Some(
848 "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(),
849 ),
850 license: Some("MIT".to_string()),
851 },
852 expectation: ExpectationSource {
853 basis,
854 artifact: EvidenceArtifact {
855 name: "semantic-contract".to_string(),
856 revision: Some("contract-1".to_string()),
857 path: Some("docs/adr/0002-conformance-contract.md".to_string()),
858 sha256: None,
859 license: Some("MIT".to_string()),
860 },
861 tracking_ref: None,
862 },
863 catalog: catalog(),
864 locale: Some(Locale::new("en-US")),
865 client: None,
866 implementation: Some(ImplementationIdentity {
867 name: "workshop-rs".to_string(),
868 version: "0.1.0".to_string(),
869 revision: Some("impl123".to_string()),
870 artifact: None,
871 }),
872 }
873 }
874
875 fn hashed_artifact(name: &str) -> EvidenceArtifact {
876 EvidenceArtifact {
877 name: name.to_string(),
878 sha256: Some(
879 "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string(),
880 ),
881 ..EvidenceArtifact::new(name)
882 }
883 }
884
885 fn matched() -> ConformanceResult {
886 ConformanceResult {
887 schema_version: CONFORMANCE_SCHEMA_VERSION,
888 case_id: "basic-action".to_string(),
889 features: vec![
890 FeatureId::from_catalog(Kind::Action, "setHealth").expect("valid feature"),
891 ],
892 status: ConformanceStatus::Matched,
893 comparison: Comparison {
894 mode: Equivalence::Semantic,
895 expected: Some(hashed_artifact("expected")),
896 observed: Some(hashed_artifact("observed")),
897 normalizer: Some("canonical-wir".to_string()),
898 },
899 evidence: evidence(EvidenceClass::Synthetic, EvidenceBasis::SemanticContract),
900 reason: None,
901 }
902 }
903
904 #[test]
905 fn feature_ids_are_locale_and_provider_independent() {
906 let feature = FeatureId::from_catalog(Kind::Action, "setHealth").expect("valid feature");
907 assert_eq!(feature.kind, FeatureKind::Action);
908 assert_eq!(feature.name, "setHealth");
909 assert_eq!(
910 serde_json::to_string(&feature).unwrap(),
911 r#"{"namespace":"catalog","kind":"action","name":"setHealth"}"#
912 );
913 let member = FeatureId::from_enum_member("Hero", "ANA").expect("valid member");
914 assert_eq!(member.name, "Hero/ANA");
915 }
916
917 #[test]
918 fn result_serializes_and_round_trips() {
919 let result = matched();
920 result.validate().expect("valid result");
921 result
922 .validate_against(&Catalog::builtin().expect("built-in catalog"))
923 .expect("catalog-backed feature exists");
924 let json = serde_json::to_string(&result).expect("serialize result");
925 let decoded = ConformanceResult::from_json(&json).expect("deserialize valid result");
926 assert_eq!(decoded, result);
927 }
928
929 #[test]
930 fn catalog_validation_rejects_fabricated_catalog_features() {
931 let mut result = matched();
932 result.features = vec![
933 FeatureId::from_catalog(Kind::Action, "notAWorkshopAction")
934 .expect("syntactically valid feature"),
935 ];
936 assert!(
937 result
938 .validate_against(&Catalog::builtin().expect("built-in catalog"))
939 .is_err()
940 );
941 }
942
943 #[test]
944 fn catalog_validation_rejects_mismatched_catalog_evidence() {
945 let mut result = matched();
946 result.evidence.catalog.catalog_digest = Some("f".repeat(64));
947 let catalog = Catalog::builtin().expect("built-in catalog");
948
949 let error = result
950 .validate_against(&catalog)
951 .expect_err("evidence must be bound to the supplied catalog");
952 assert_eq!(error.field, "evidence.catalog");
953 }
954
955 #[test]
956 fn known_gap_is_not_a_match_and_requires_detail() {
957 let mut result = matched();
958 result.status = ConformanceStatus::KnownGap;
959 result.comparison.mode = Equivalence::NotComparable;
960 assert!(result.validate().is_err());
961 result.reason = Some(ConformanceReason {
962 code: ReasonCode::KnownGap,
963 detail: "client spelling is not yet evidenced".to_string(),
964 tracking_ref: Some("#18".to_string()),
965 });
966 result.validate().expect("documented gap");
967 assert!(!result.is_match());
968 }
969
970 #[test]
971 fn duplicate_features_and_blank_details_are_invalid() {
972 let mut result = matched();
973 result.features.push(result.features[0].clone());
974 assert!(result.validate().is_err());
975
976 let mut result = matched();
977 result.status = ConformanceStatus::Inconclusive;
978 result.reason = Some(ConformanceReason {
979 code: ReasonCode::Inconclusive,
980 detail: " \n".to_string(),
981 tracking_ref: None,
982 });
983 assert!(result.validate().is_err());
984 }
985
986 #[test]
987 fn matched_artifacts_cannot_reuse_fixture_or_implementation_output() {
988 let mut result = matched();
989 result.comparison.observed = Some(result.evidence.fixture.clone());
990 assert!(result.validate().is_err());
991
992 let mut result = matched();
993 let implementation_artifact = hashed_artifact("implementation-output");
994 result.evidence.implementation.as_mut().unwrap().artifact =
995 Some(implementation_artifact.clone());
996 result.comparison.observed = Some(implementation_artifact);
997 assert!(result.validate().is_err());
998
999 let mut result = matched();
1000 let implementation_artifact = hashed_artifact("implementation-output");
1001 result.evidence.implementation.as_mut().unwrap().artifact =
1002 Some(implementation_artifact.clone());
1003 result.comparison.expected = Some(implementation_artifact);
1004 assert!(result.validate().is_err());
1005 }
1006
1007 #[test]
1008 fn live_client_requires_client_and_locale_provenance() {
1009 let mut result = matched();
1010 result.evidence.class = EvidenceClass::LiveClient;
1011 result.evidence.expectation.basis = EvidenceBasis::WorkshopClient;
1012 result.evidence.client = Some(ClientEvidence {
1013 game: "overwatch-2".to_string(),
1014 client_version: Some("season-1".to_string()),
1015 season: Some("season-1".to_string()),
1016 captured_at: "2026-08-18T00:00:00Z".to_string(),
1017 environment: None,
1018 });
1019 result.validate().expect("complete live evidence");
1020 result.evidence.locale = None;
1021 assert!(result.validate().is_err());
1022 }
1023
1024 #[test]
1025 fn implementation_output_cannot_be_the_evidence_basis() {
1026 let mut result = matched();
1027 result.evidence.implementation = Some(ImplementationIdentity {
1028 name: "changed-implementation".to_string(),
1029 version: "dev".to_string(),
1030 revision: None,
1031 artifact: None,
1032 });
1033 result
1034 .validate()
1035 .expect("implementation metadata is allowed");
1036 assert_ne!(
1037 result.evidence.expectation.basis,
1038 EvidenceBasis::PinnedExternalOracle,
1039 "implementation metadata is not an oracle"
1040 );
1041 }
1042
1043 #[test]
1044 fn validated_json_rejects_an_invalid_status_reason() {
1045 let mut value = serde_json::to_value(matched()).expect("serialize result");
1046 value["status"] = serde_json::json!("known-gap");
1047 let json = serde_json::to_string(&value).expect("serialize invalid result");
1048 assert!(ConformanceResult::from_json(&json).is_err());
1049 }
1050}