Skip to main content

turnframe_test/workflows/claim/
model.rs

1//! The exploration model and the ready-made fixtures of the claim sample.
2
3use turnframe_core::ids::AttachmentId;
4
5use crate::explore::{SimulatedTransition, WorkflowModel};
6use crate::workflows::claim::command::{ClaimCommand, ClaimEvent};
7use crate::workflows::claim::definition::ClaimWorkflow;
8use crate::workflows::claim::state::{
9    ClaimField, ClaimOutcome, ClaimState, ClaimStatus, Proposal, ProposedField,
10};
11use crate::workflows::simulate;
12
13/// The document every fixture reads from.
14pub const SAMPLE_ATTACHMENT: &str = "att-receipt-1";
15
16/// The reference the user types by hand. It belongs to no proposal.
17pub const SAMPLE_REFERENCE: &str = "PO-2026-0042";
18
19/// A second reference, so a test can change one without touching a proposal.
20pub const OTHER_REFERENCE: &str = "PO-2026-0043";
21
22/// Merchant the extractor reads off the document.
23pub const EXTRACTED_MERCHANT: &str = "Hotel Tejo";
24
25/// Total the extractor reads off the document.
26pub const EXTRACTED_TOTAL: &str = "125000";
27
28/// Date the extractor reads off the document.
29pub const EXTRACTED_DATE: &str = "2026-09-01";
30
31/// The merchant a user types when the extractor got it wrong.
32pub const CORRECTED_MERCHANT: &str = "Hotel Tejo Lisboa";
33
34/// The document identifier the fixtures use.
35#[must_use]
36pub fn sample_attachment() -> AttachmentId {
37    AttachmentId::from(SAMPLE_ATTACHMENT)
38}
39
40fn proposed(field: ClaimField, value: &str) -> ProposedField {
41    ProposedField {
42        field,
43        value: value.to_owned(),
44        edited: false,
45    }
46}
47
48/// A reading that found every required field.
49#[must_use]
50pub fn complete_proposal() -> Proposal {
51    Proposal::new(
52        sample_attachment(),
53        vec![
54            proposed(ClaimField::Merchant, EXTRACTED_MERCHANT),
55            proposed(ClaimField::Total, EXTRACTED_TOTAL),
56            proposed(ClaimField::ReceiptDate, EXTRACTED_DATE),
57        ],
58    )
59}
60
61/// A reading that could not find the date: one field is proposed short.
62#[must_use]
63pub fn partial_proposal() -> Proposal {
64    Proposal::new(
65        sample_attachment(),
66        vec![
67            proposed(ClaimField::Merchant, EXTRACTED_MERCHANT),
68            proposed(ClaimField::Total, EXTRACTED_TOTAL),
69        ],
70    )
71}
72
73/// A case with a document attached and nothing read out of it yet.
74#[must_use]
75pub fn awaiting_extraction() -> ClaimState {
76    ClaimState {
77        attachment: Some(sample_attachment()),
78        ..ClaimState::default()
79    }
80}
81
82/// A case whose review card is open on `proposal`.
83#[must_use]
84pub fn under_review(proposal: Proposal) -> ClaimState {
85    ClaimState {
86        proposal: Some(proposal),
87        ..awaiting_extraction()
88    }
89}
90
91/// A case that reached the review with a reference already typed in.
92///
93/// The reference is the part of the state the review card is *not* about, so
94/// this is the fixture the payload-hash test varies.
95#[must_use]
96pub fn under_review_with_reference(proposal: Proposal, reference: &str) -> ClaimState {
97    ClaimState {
98        reference: Some(reference.to_owned()),
99        ..under_review(proposal)
100    }
101}
102
103/// A case whose reading was thrown away, keeping the document.
104#[must_use]
105pub fn abandoned() -> ClaimState {
106    ClaimState {
107        abandoned_from: Some(sample_attachment()),
108        ..awaiting_extraction()
109    }
110}
111
112/// The exploration model of the claim workflow.
113///
114/// Every value comes from a fixed table and the proposals are two constants, so
115/// the reachable state space is finite and identical on every run. The model
116/// deliberately offers a refused command in each phase, since the explorer
117/// checks that a refusal changes nothing.
118#[derive(Debug, Clone, Copy, Default)]
119#[non_exhaustive]
120pub struct ClaimModel {
121    /// The definition transitions are applied through.
122    pub workflow: ClaimWorkflow,
123}
124
125impl ClaimModel {
126    /// Builds the model.
127    #[must_use]
128    pub const fn new() -> Self {
129        Self {
130            workflow: ClaimWorkflow::new(),
131        }
132    }
133}
134
135impl WorkflowModel<ClaimWorkflow> for ClaimModel {
136    fn initial_states(&self) -> Vec<Option<ClaimState>> {
137        vec![None]
138    }
139
140    fn candidate_commands(&self, state: Option<&ClaimState>) -> Vec<ClaimCommand> {
141        let Some(state) = state else {
142            return vec![ClaimCommand::CreateDraft];
143        };
144        if state.status != ClaimStatus::Draft {
145            return Vec::new();
146        }
147        let mut commands = Vec::new();
148        if state.reference.is_none() {
149            commands.push(ClaimCommand::SetReference {
150                value: SAMPLE_REFERENCE.to_owned(),
151            });
152        }
153        match (state.attachment.as_ref(), state.proposal.as_ref()) {
154            (None, _) => {
155                commands.push(ClaimCommand::AttachReceipt {
156                    attachment_id: sample_attachment(),
157                });
158                // Refused: nothing has arrived, so nothing can have been read.
159                commands.push(ClaimCommand::AcceptProposal);
160            }
161            (Some(_), None) => {
162                for proposal in [complete_proposal(), partial_proposal()] {
163                    commands.push(ClaimCommand::ProposeFields {
164                        attachment_id: proposal.attachment_id.clone(),
165                        fields: proposal.fields.clone(),
166                    });
167                }
168                // Refused: no review is open.
169                commands.push(ClaimCommand::AbandonReview);
170            }
171            (Some(_), Some(proposal)) => {
172                if proposal
173                    .field(ClaimField::Merchant)
174                    .is_some_and(|field| field.value != CORRECTED_MERCHANT)
175                {
176                    commands.push(ClaimCommand::ReviseProposedField {
177                        field: ClaimField::Merchant,
178                        value: CORRECTED_MERCHANT.to_owned(),
179                    });
180                }
181                // Filling a value the document did not yield is the same act as
182                // correcting one it did.
183                for missing in proposal.missing() {
184                    commands.push(ClaimCommand::ReviseProposedField {
185                        field: missing,
186                        value: EXTRACTED_DATE.to_owned(),
187                    });
188                }
189                commands.push(ClaimCommand::AcceptProposal);
190                commands.push(ClaimCommand::AbandonReview);
191                // Refused: a second reading may not replace an open proposal.
192                commands.push(ClaimCommand::ProposeFields {
193                    attachment_id: sample_attachment(),
194                    fields: complete_proposal().fields,
195                });
196            }
197        }
198        commands.push(ClaimCommand::DiscardReceipt);
199        // Always refused: a blank reference.
200        commands.push(ClaimCommand::SetReference {
201            value: "   ".to_owned(),
202        });
203        commands
204    }
205
206    fn simulate(
207        &self,
208        state: Option<&ClaimState>,
209        command: &ClaimCommand,
210    ) -> SimulatedTransition<ClaimState, ClaimEvent> {
211        simulate(&self.workflow, state, command)
212    }
213
214    fn declared_outcomes(&self) -> Vec<ClaimOutcome> {
215        vec![ClaimOutcome::Recorded, ClaimOutcome::Discarded]
216    }
217}