Skip to main content

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}