Skip to main content

turnframe_test/
assertions.rs

1//! Reusable checks over the artefacts a turn produces.
2//!
3//! Every helper returns a typed [`AssertionFailure`] instead of panicking, so
4//! it composes inside a proptest, inside an exploration model, or inside a
5//! production self-check. Call sites in `#[test]` code usually end in
6//! `.expect(...)`; call sites in library code can branch on the failure.
7//!
8//! The checks cover the properties spec §27.2 asks for by name:
9//!
10//! * no high-risk command executes without a trusted origin (I12);
11//! * no receipt claims an outcome the ledger does not back (I16);
12//! * a projection carries exactly one phase (§8.4);
13//! * one case resolves to the same phase in two projections, which is what "one
14//!   phase" means for a case rather than for a view (I3);
15//! * two renderings of the same turn produce the same ordered blocks, which is
16//!   what "reload returns the same blocks" means (§27.4, scenario 18).
17
18use turnframe_core::command::{
19    CommandBatch, CommandEnvelope, CommandPolicy, ConfirmationPolicy, RiskClass, origin_satisfies,
20};
21use turnframe_core::event::{OperationalReceipt, ReceiptEvent};
22use turnframe_core::flow::{ErasedWorkflowView, PhaseOwnership, WorkflowDefinition};
23use turnframe_core::ids::{CaseId, CommandId, EventId, ReceiptId, WorkflowKey};
24use turnframe_core::response::AssistantTurn;
25
26/// A check did not hold.
27#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
28#[non_exhaustive]
29pub enum AssertionFailure {
30    /// A command whose policy demands a trusted origin carried an untrusted one.
31    #[error(
32        "command {command_id} needs a trusted origin (risk {risk:?}, confirmation {confirmation:?}) but its origin is untrusted"
33    )]
34    UntrustedOriginForHighRisk {
35        /// The offending command.
36        command_id: CommandId,
37        /// Its risk class.
38        risk: RiskClass,
39        /// Its confirmation policy.
40        confirmation: ConfirmationPolicy,
41    },
42    /// A receipt states an outcome with nothing in the ledger behind it.
43    #[error("receipt {receipt_id} ({status_code}) is not backed by any committed event")]
44    ReceiptWithoutEvents {
45        /// The offending receipt.
46        receipt_id: ReceiptId,
47        /// Its status code.
48        status_code: String,
49    },
50    /// A receipt cites an event the commit did not produce.
51    #[error("receipt {receipt_id} ({status_code}) cites event {event_id}, which was not committed")]
52    ReceiptCitesUnknownEvent {
53        /// The offending receipt.
54        receipt_id: ReceiptId,
55        /// Its status code.
56        status_code: String,
57        /// The event it invented.
58        event_id: EventId,
59    },
60    /// A projection does not carry exactly one phase.
61    #[error("the view of {workflow}/{case_id} does not carry exactly one phase")]
62    NotASinglePhase {
63        /// The workflow.
64        workflow: WorkflowKey,
65        /// The case.
66        case_id: CaseId,
67    },
68    /// Two views that should be of the same case are not.
69    #[error("the views are of {left_workflow}/{left_case} and {right_workflow}/{right_case}")]
70    DifferentCases {
71        /// Workflow of the first view.
72        left_workflow: WorkflowKey,
73        /// Case of the first view.
74        left_case: CaseId,
75        /// Workflow of the second view.
76        right_workflow: WorkflowKey,
77        /// Case of the second view.
78        right_case: CaseId,
79    },
80    /// One case resolved to two different phases.
81    #[error("{workflow}/{case_id} is in phase {left} in one projection and {right} in the other")]
82    PhaseDiffers {
83        /// The workflow.
84        workflow: WorkflowKey,
85        /// The case.
86        case_id: CaseId,
87        /// Phase of the first view, as canonical JSON.
88        left: String,
89        /// Phase of the second view, as canonical JSON.
90        right: String,
91    },
92    /// One case resolved to two different phase ownerships.
93    #[error(
94        "{workflow}/{case_id} is owned by {left:?} in one projection and {right:?} in the other"
95    )]
96    PhaseOwnershipDiffers {
97        /// The workflow.
98        workflow: WorkflowKey,
99        /// The case.
100        case_id: CaseId,
101        /// Ownership in the first view.
102        left: PhaseOwnership,
103        /// Ownership in the second view.
104        right: PhaseOwnership,
105    },
106    /// Two renderings of the same turn have different numbers of blocks.
107    #[error("the turns have {left} and {right} blocks")]
108    BlockCountDiffers {
109        /// Blocks in the first turn.
110        left: usize,
111        /// Blocks in the second turn.
112        right: usize,
113    },
114    /// Two renderings of the same turn differ at one position.
115    #[error("the turns differ at block {index}")]
116    BlocksDiffer {
117        /// Position of the first difference.
118        index: usize,
119    },
120}
121
122/// Checks one envelope against one policy (I12).
123pub fn origin_satisfies_policy<C>(
124    envelope: &CommandEnvelope<C>,
125    policy: &CommandPolicy,
126) -> Result<(), AssertionFailure> {
127    if origin_satisfies(&envelope.origin, policy) {
128        return Ok(());
129    }
130    Err(AssertionFailure::UntrustedOriginForHighRisk {
131        command_id: envelope.command_id,
132        risk: policy.risk,
133        confirmation: policy.confirmation,
134    })
135}
136
137/// Checks that no command in `batch` executes above
138/// [`RiskClass::ReversibleLowRisk`], or under any confirmation policy, without
139/// a trusted origin.
140///
141/// The policy comes from the workflow itself, so the check follows the domain's
142/// own classification rather than a duplicate table in the test.
143pub fn no_high_risk_without_trusted_origin<W: WorkflowDefinition>(
144    definition: &W,
145    state: Option<&W::State>,
146    batch: &CommandBatch<W::Command>,
147) -> Result<(), AssertionFailure> {
148    for envelope in &batch.envelopes {
149        let policy = definition.command_policy(state, &envelope.command);
150        origin_satisfies_policy(envelope, &policy)?;
151    }
152    Ok(())
153}
154
155/// Checks that every receipt cites at least one event and only events the
156/// commit really produced (I16).
157///
158/// It takes [`ReceiptEvent`]s — what [`WorkflowDefinition::receipts`] itself is
159/// given — so a receipt rendered over an event whose payload was erased is
160/// checked by exactly the same rule as any other. It has to be: an erased event
161/// is still in the ledger and still authorizes the claim, and a helper that
162/// quietly accepted an unbacked receipt for it would hide the one case where
163/// the domain had least to go on. Build the list from a commit with
164/// [`Commit::receipt_events`], or from the ledger with
165/// `StoredEvent::to_receipt_event`.
166///
167/// [`Commit::receipt_events`]: turnframe_core::event::Commit::receipt_events
168pub fn receipts_backed_by_events<E>(
169    receipts: &[OperationalReceipt],
170    events: &[ReceiptEvent<E>],
171) -> Result<(), AssertionFailure> {
172    for receipt in receipts {
173        if receipt.event_ids.is_empty() {
174            return Err(AssertionFailure::ReceiptWithoutEvents {
175                receipt_id: receipt.receipt_id,
176                status_code: receipt.status_code.clone(),
177            });
178        }
179        for event_id in &receipt.event_ids {
180            if !events.iter().any(|event| event.event_id() == *event_id) {
181                return Err(AssertionFailure::ReceiptCitesUnknownEvent {
182                    receipt_id: receipt.receipt_id,
183                    status_code: receipt.status_code.clone(),
184                    event_id: *event_id,
185                });
186            }
187        }
188    }
189    Ok(())
190}
191
192/// Checks that a projection carries exactly one phase (§8.4).
193///
194/// "Exactly one" is structural in the typed view — `phase` is a single value by
195/// type — so what is left to check on the erased form is that the phase is a
196/// real value and not a null or a list of concurrent phases smuggled through
197/// the serializer.
198pub fn single_phase(view: &ErasedWorkflowView) -> Result<(), AssertionFailure> {
199    if view.phase.is_null() || view.phase.is_array() {
200        return Err(AssertionFailure::NotASinglePhase {
201            workflow: view.case_ref.workflow.clone(),
202            case_id: view.case_ref.case_id.clone(),
203        });
204    }
205    Ok(())
206}
207
208/// Checks that one case resolves to the same phase in two projections (I3).
209///
210/// "Exactly one phase" is a statement about a *case*, not about a view: a case
211/// that is `Collecting` when the typed workflow projects it and `Dispatching`
212/// when the erased registry projects the same state has two phases, however
213/// well each view checks out on its own. That happens for real — an erased
214/// projection deserializes the state through a second serde path, a second
215/// projector is written for a read model, a cached view survives a version
216/// bump — and none of the single-view invariants can see it.
217///
218/// The two views must be of the same case; a phase comparison across cases
219/// would be meaningless, so it is refused rather than answered.
220pub fn same_phase_in(
221    left: &ErasedWorkflowView,
222    right: &ErasedWorkflowView,
223) -> Result<(), AssertionFailure> {
224    if left.case_ref.key() != right.case_ref.key() {
225        return Err(AssertionFailure::DifferentCases {
226            left_workflow: left.case_ref.workflow.clone(),
227            left_case: left.case_ref.case_id.clone(),
228            right_workflow: right.case_ref.workflow.clone(),
229            right_case: right.case_ref.case_id.clone(),
230        });
231    }
232    single_phase(left)?;
233    single_phase(right)?;
234    if left.phase != right.phase {
235        return Err(AssertionFailure::PhaseDiffers {
236            workflow: left.case_ref.workflow.clone(),
237            case_id: left.case_ref.case_id.clone(),
238            left: left.phase.to_string(),
239            right: right.phase.to_string(),
240        });
241    }
242    if left.phase_ownership != right.phase_ownership {
243        return Err(AssertionFailure::PhaseOwnershipDiffers {
244            workflow: left.case_ref.workflow.clone(),
245            case_id: left.case_ref.case_id.clone(),
246            left: left.phase_ownership,
247            right: right.phase_ownership,
248        });
249    }
250    Ok(())
251}
252
253/// Checks that two assistant turns carry identical blocks in identical order.
254///
255/// This is the reload property: persisting a turn and reading it back, or
256/// regenerating a narration after a provider failure, must not reshuffle,
257/// duplicate or drop a block (spec §27.4, scenario 18).
258pub fn identical_blocks(
259    left: &AssistantTurn,
260    right: &AssistantTurn,
261) -> Result<(), AssertionFailure> {
262    if left.blocks.len() != right.blocks.len() {
263        return Err(AssertionFailure::BlockCountDiffers {
264            left: left.blocks.len(),
265            right: right.blocks.len(),
266        });
267    }
268    for (index, (a, b)) in left.blocks.iter().zip(&right.blocks).enumerate() {
269        if a != b {
270            return Err(AssertionFailure::BlocksDiffer { index });
271        }
272    }
273    Ok(())
274}
275
276#[cfg(test)]
277mod tests {
278    use turnframe_core::event::{OperationalReceipt, ReceiptSeverity};
279    use turnframe_core::ids::{BlockId, ConversationId, EventId, ReceiptId, TurnId};
280    use turnframe_core::locale::LocalizedText;
281    use turnframe_core::response::{
282        AssistantTurn, GeneratedTransition, ReplayToken, ResponseBlock,
283    };
284
285    use super::*;
286
287    fn receipt(event_ids: Vec<EventId>) -> OperationalReceipt {
288        OperationalReceipt {
289            receipt_id: ReceiptId::derive(&event_ids, "trip.rebooking_sent"),
290            event_ids,
291            severity: ReceiptSeverity::Success,
292            title: LocalizedText::new("Sent"),
293            body: LocalizedText::new("The rebooking was sent."),
294            status_code: "trip.rebooking_sent".to_owned(),
295            artifact_refs: Vec::new(),
296        }
297    }
298
299    fn turn(text: &str) -> AssistantTurn {
300        AssistantTurn {
301            turn_id: TurnId::nil(),
302            conversation_id: ConversationId::nil(),
303            blocks: vec![ResponseBlock::Transition(GeneratedTransition {
304                block_id: BlockId::from("t1"),
305                text: text.to_owned(),
306                facts_used: Vec::new(),
307            })],
308            replay_token: ReplayToken::from("rt"),
309            subjects: Vec::new(),
310            expectations: Vec::new(),
311            done: Vec::new(),
312        }
313    }
314
315    #[test]
316    fn a_receipt_with_no_events_is_refused() {
317        let failure = receipts_backed_by_events::<()>(&[receipt(Vec::new())], &[]).unwrap_err();
318        assert!(matches!(
319            failure,
320            AssertionFailure::ReceiptWithoutEvents { .. }
321        ));
322    }
323
324    #[test]
325    fn a_receipt_citing_an_uncommitted_event_is_refused() {
326        let failure =
327            receipts_backed_by_events::<()>(&[receipt(vec![EventId::nil()])], &[]).unwrap_err();
328        assert!(matches!(
329            failure,
330            AssertionFailure::ReceiptCitesUnknownEvent { .. }
331        ));
332    }
333
334    #[test]
335    fn a_phase_that_is_not_one_value_is_refused() {
336        use turnframe_core::case::CaseRef;
337        use turnframe_core::flow::{ErasedWorkflowView, PhaseOwnership};
338        use turnframe_core::ids::{CaseRevision, WorkflowVersion};
339
340        let mut view = ErasedWorkflowView {
341            case_ref: CaseRef::new("trip", "trip-1", CaseRevision(1)),
342            workflow_version: WorkflowVersion::from("1"),
343            phase: serde_json::json!("collecting"),
344            phase_ownership: PhaseOwnership::System,
345            obligations: Vec::new(),
346            blocking_interaction: None,
347            notices: Vec::new(),
348            outcome: None,
349            state: Vec::new(),
350        };
351        assert!(single_phase(&view).is_ok());
352        view.phase = serde_json::json!(["collecting", "dispatching"]);
353        assert!(matches!(
354            single_phase(&view).unwrap_err(),
355            AssertionFailure::NotASinglePhase { .. }
356        ));
357        view.phase = serde_json::Value::Null;
358        assert!(single_phase(&view).is_err());
359    }
360
361    #[test]
362    fn turns_are_compared_block_by_block() {
363        let a = turn("Fatto.");
364        assert!(identical_blocks(&a, &a.clone()).is_ok());
365        assert_eq!(
366            identical_blocks(&a, &turn("Done.")).unwrap_err(),
367            AssertionFailure::BlocksDiffer { index: 0 }
368        );
369        let mut shorter = a.clone();
370        shorter.blocks.clear();
371        assert_eq!(
372            identical_blocks(&a, &shorter).unwrap_err(),
373            AssertionFailure::BlockCountDiffers { left: 1, right: 0 }
374        );
375    }
376}