turnframe_test/projection.rs
1//! Pinning what a projector emits, so a change to it cannot pass unnoticed.
2//!
3//! A [`WorkflowDefinition`]'s version must change when its projection means something new:
4//! replay records and evaluation baselines cite it, and the invariant checker only asks
5//! whether a view is coherent. Build a [`ProjectionFingerprint`] from a definition and the
6//! states worth pinning, then snapshot it:
7//!
8//! ```
9//! use turnframe_test::projection::ProjectionFingerprint;
10//! use turnframe_test::workflows::traveler::{self, TravelerWorkflow};
11//!
12//! let workflow = TravelerWorkflow::default();
13//! let fingerprint = ProjectionFingerprint::of(&workflow)
14//! .at("empty", None)
15//! .at("collecting", Some(traveler::incomplete_draft()))
16//! .build();
17//!
18//! assert_eq!(fingerprint.workflow.as_str(), "traveler");
19//! assert_eq!(fingerprint.version.as_str(), "1");
20//! // In a test: insta::assert_yaml_snapshot!(fingerprint.snapshot_name(), fingerprint);
21//! ```
22//!
23//! [`ProjectionFingerprint::snapshot_name`] carries the version: a projector changed without
24//! a bump fails against its snapshot, and one changed with a bump writes a new file to review.
25//! Pinned: phase and owner, obligations, the blocking card and what answering it authorizes,
26//! notice codes and severity, the outcome. Prose is not: it is localized and changes freely.
27
28use serde::{Deserialize, Serialize};
29use turnframe_core::case::CaseRef;
30use turnframe_core::flow::{
31 InteractionRequirement, PhaseOwnership, WorkflowDefinition, WorkflowView,
32};
33use turnframe_core::ids::{CaseRevision, WorkflowKey, WorkflowVersion};
34use turnframe_core::interaction::{InteractionKind, TextResolutionPolicy};
35use turnframe_core::response::NoticeSeverity;
36
37/// The case a fingerprint projects at. Fixed, because the case identifier is
38/// not part of what a projector decides.
39const CASE_ID: &str = "fingerprint";
40
41/// The revision a fingerprint projects at.
42///
43/// A projector must not read the revision — the state explorer reports one that
44/// does — so any value serves, and a fixed one keeps the snapshot stable.
45const REVISION: CaseRevision = CaseRevision(1);
46
47/// What one projected state looks like, reduced to the parts that are contract
48/// rather than copy.
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50#[non_exhaustive]
51pub struct ProjectedShape {
52 /// The name the caller gave this state, so a diff says which one moved.
53 pub state: String,
54 /// The phase, as canonical JSON.
55 pub phase: serde_json::Value,
56 /// Who the phase says must act next.
57 pub ownership: PhaseOwnership,
58 /// Every open obligation's stable identifier, in the order projected.
59 pub obligations: Vec<String>,
60 /// The blocking card the phase requires, if it requires one.
61 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub blocking: Option<RequiredCard>,
63 /// Notice codes and severities, without their prose.
64 pub notices: Vec<NoticeShape>,
65 /// The outcome, present only when the workflow is finished.
66 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub outcome: Option<serde_json::Value>,
68}
69
70/// The part of a required card that is contract rather than copy.
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72#[non_exhaustive]
73pub struct RequiredCard {
74 /// Stable key of the requirement within its phase.
75 pub key: String,
76 /// The shape the card takes.
77 pub kind: InteractionKind,
78 /// Whether it owns an unqualified answer for the case.
79 pub blocking: bool,
80 /// Whether a revision change leaves it standing.
81 pub revision_independent: bool,
82 /// Whether typed text may resolve it.
83 pub text_resolution: TextResolutionPolicy,
84 /// The highest risk answering it authorizes.
85 pub confirms_risk: turnframe_core::command::RiskClass,
86}
87
88impl From<&InteractionRequirement> for RequiredCard {
89 fn from(requirement: &InteractionRequirement) -> Self {
90 Self {
91 key: requirement.key.clone(),
92 kind: requirement.kind,
93 blocking: requirement.blocking,
94 revision_independent: requirement.revision_independent,
95 text_resolution: requirement.text_resolution.clone(),
96 confirms_risk: requirement.confirms_risk,
97 }
98 }
99}
100
101/// A notice reduced to its identity.
102#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
103#[non_exhaustive]
104pub struct NoticeShape {
105 /// The stable code, which is the notice's identity.
106 pub code: String,
107 /// How loudly it is meant to read.
108 pub severity: NoticeSeverity,
109}
110
111/// Everything one projector emits over a chosen set of states, at one version.
112#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
113#[non_exhaustive]
114pub struct ProjectionFingerprint {
115 /// The workflow this projector answers for.
116 pub workflow: WorkflowKey,
117 /// The version it declares, which is what the fingerprint is pinned to.
118 pub version: WorkflowVersion,
119 /// One entry per state, in the order they were added.
120 pub states: Vec<ProjectedShape>,
121}
122
123impl ProjectionFingerprint {
124 /// Starts a fingerprint for `definition`.
125 #[must_use]
126 pub fn of<W: WorkflowDefinition>(definition: &W) -> FingerprintBuilder<'_, W> {
127 FingerprintBuilder {
128 definition,
129 states: Vec::new(),
130 }
131 }
132
133 /// A snapshot name carrying the workflow and the version it pins.
134 ///
135 /// Naming the snapshot after the version is the whole mechanism: a
136 /// projector that changes without a bump fails against the file that
137 /// already exists, and one that changes with a bump writes a new file
138 /// beside the old, so the previous shape stays on record.
139 #[must_use]
140 pub fn snapshot_name(&self) -> String {
141 format!("{}@{}", self.workflow.as_str(), self.version.as_str())
142 }
143}
144
145/// Collects the states a fingerprint covers.
146#[derive(Debug)]
147pub struct FingerprintBuilder<'a, W: WorkflowDefinition> {
148 definition: &'a W,
149 states: Vec<(String, Option<W::State>)>,
150}
151
152impl<'a, W: WorkflowDefinition> FingerprintBuilder<'a, W> {
153 /// Adds a state to pin, under a name a diff can refer to.
154 ///
155 /// Choose states that mean something: the empty case, one obligation
156 /// closed, the phase that raises a card, and each terminal outcome. A
157 /// fingerprint over states nobody reaches pins nothing worth pinning.
158 #[must_use]
159 pub fn at(mut self, name: impl Into<String>, state: Option<W::State>) -> Self {
160 self.states.push((name.into(), state));
161 self
162 }
163
164 /// Projects every state and reduces each view to its contract.
165 #[must_use]
166 pub fn build(self) -> ProjectionFingerprint {
167 let case_ref = CaseRef::new(self.definition.key(), CASE_ID, REVISION);
168 let states = self
169 .states
170 .iter()
171 .map(|(name, state)| {
172 let view = self.definition.project(case_ref.clone(), state.as_ref());
173 shape_of(name, self.definition, &view)
174 })
175 .collect();
176 ProjectionFingerprint {
177 workflow: self.definition.key(),
178 version: self.definition.version(),
179 states,
180 }
181 }
182}
183
184/// Reduces one projected view to the parts that are contract.
185fn shape_of<W: WorkflowDefinition>(
186 name: &str,
187 definition: &W,
188 view: &WorkflowView<W::Phase, W::Obligation, W::Outcome>,
189) -> ProjectedShape {
190 ProjectedShape {
191 state: name.to_owned(),
192 phase: serde_json::to_value(&view.phase).unwrap_or(serde_json::Value::Null),
193 ownership: definition.phase_ownership(&view.phase),
194 obligations: view
195 .obligations
196 .iter()
197 .map(|obligation| {
198 serde_json::to_string(obligation)
199 .unwrap_or_else(|_| String::from("<unserializable>"))
200 })
201 .collect(),
202 blocking: view.blocking_interaction.as_ref().map(RequiredCard::from),
203 notices: view
204 .notices
205 .iter()
206 .map(|notice| NoticeShape {
207 code: notice.code.clone(),
208 severity: notice.severity,
209 })
210 .collect(),
211 outcome: view
212 .outcome
213 .as_ref()
214 .map(|outcome| serde_json::to_value(outcome).unwrap_or(serde_json::Value::Null)),
215 }
216}