Skip to main content

turnframe_test/workflows/traveler/
model.rs

1//! The exploration model and the ready-made fixtures of the traveler sample.
2
3use crate::explore::{SimulatedTransition, WorkflowModel};
4use crate::workflows::simulate;
5use crate::workflows::traveler::command::{TravelerCommand, TravelerEvent};
6use crate::workflows::traveler::definition::TravelerWorkflow;
7use crate::workflows::traveler::state::{
8    DeclineReason, FieldState, TravelerOutcome, TravelerState, TravelerStatus,
9};
10
11/// Full name the model sets.
12pub const SAMPLE_NAME: &str = "Marta Bianchi";
13
14/// Contact address the model sets first.
15pub const SAMPLE_EMAIL: &str = "marta@aurora.example";
16
17/// Address the model changes to, exercising the sensitive change.
18pub const OTHER_EMAIL: &str = "marta.bianchi@aurora.example";
19
20/// Loyalty number the model sets.
21pub const SAMPLE_LOYALTY_NUMBER: &str = "AZ1234567";
22
23/// A draft with a name but no address and no loyalty number.
24#[must_use]
25pub fn incomplete_draft() -> TravelerState {
26    TravelerState {
27        full_name: Some(SAMPLE_NAME.to_owned()),
28        ..TravelerState::default()
29    }
30}
31
32/// A draft with every field set, waiting for the activation card.
33#[must_use]
34pub fn awaiting_activation() -> TravelerState {
35    TravelerState {
36        full_name: Some(SAMPLE_NAME.to_owned()),
37        email: Some(SAMPLE_EMAIL.to_owned()),
38        loyalty_number: FieldState::answered(SAMPLE_LOYALTY_NUMBER),
39        status: TravelerStatus::Draft,
40    }
41}
42
43/// A draft that is complete because the loyalty number was **declined**, not given.
44///
45/// It projects to the same phase as [`awaiting_activation`] and carries the
46/// same empty obligation list, which is the point: the two states are only
47/// distinguishable through the notice the projection attaches, and a domain
48/// that dropped the reason could no longer tell them apart at all.
49#[must_use]
50pub fn declined_loyalty_number(reason: DeclineReason) -> TravelerState {
51    TravelerState {
52        loyalty_number: FieldState::declined(reason),
53        ..awaiting_activation()
54    }
55}
56
57/// An active traveler.
58#[must_use]
59pub fn active_traveler() -> TravelerState {
60    TravelerState {
61        status: TravelerStatus::Active,
62        ..awaiting_activation()
63    }
64}
65
66/// The exploration model of the traveler workflow.
67#[derive(Debug, Clone, Copy, Default)]
68#[non_exhaustive]
69pub struct TravelerModel {
70    /// The definition transitions are applied through.
71    pub workflow: TravelerWorkflow,
72}
73
74impl TravelerModel {
75    /// Builds the model.
76    #[must_use]
77    pub const fn new() -> Self {
78        Self::of(TravelerWorkflow::new())
79    }
80
81    /// The model of `workflow`, such as [`TravelerWorkflow::with_cards`].
82    #[must_use]
83    pub const fn of(workflow: TravelerWorkflow) -> Self {
84        Self { workflow }
85    }
86}
87
88impl WorkflowModel<TravelerWorkflow> for TravelerModel {
89    fn initial_states(&self) -> Vec<Option<TravelerState>> {
90        vec![None]
91    }
92
93    fn candidate_commands(&self, state: Option<&TravelerState>) -> Vec<TravelerCommand> {
94        let Some(state) = state else {
95            return vec![TravelerCommand::CreateDraft];
96        };
97        let mut commands = Vec::new();
98        match state.status {
99            TravelerStatus::Draft => {
100                if state.full_name.is_none() {
101                    commands.push(TravelerCommand::SetName {
102                        value: SAMPLE_NAME.to_owned(),
103                    });
104                }
105                if state.email.is_none() {
106                    commands.push(TravelerCommand::ChangeEmail {
107                        value: SAMPLE_EMAIL.to_owned(),
108                    });
109                }
110                // The three-valued field, explored through all three states:
111                // an unanswered question can be answered or declined, and a
112                // decline can still be superseded by an answer later.
113                if !state.loyalty_number.is_answered() {
114                    commands.push(TravelerCommand::SetLoyaltyNumber {
115                        value: SAMPLE_LOYALTY_NUMBER.to_owned(),
116                    });
117                }
118                match state.loyalty_number.decline_reason() {
119                    None => {
120                        commands.push(TravelerCommand::DeclineLoyaltyNumber {
121                            reason: DeclineReason::NotApplicable,
122                        });
123                        commands.push(TravelerCommand::DeclineLoyaltyNumber {
124                            reason: DeclineReason::Unknown,
125                        });
126                    }
127                    Some(DeclineReason::Unknown) => {
128                        // A reason can be corrected without answering.
129                        commands.push(TravelerCommand::DeclineLoyaltyNumber {
130                            reason: DeclineReason::Withheld,
131                        });
132                    }
133                    Some(_) => {}
134                }
135                commands.push(TravelerCommand::Activate);
136                commands.push(TravelerCommand::Delete);
137            }
138            TravelerStatus::Active => {
139                if state.email.as_deref() == Some(SAMPLE_EMAIL) {
140                    commands.push(TravelerCommand::ChangeEmail {
141                        value: OTHER_EMAIL.to_owned(),
142                    });
143                }
144                commands.push(TravelerCommand::Archive);
145                commands.push(TravelerCommand::Delete);
146            }
147            TravelerStatus::Archived | TravelerStatus::Deleted => {}
148        }
149        if state.status.is_editable() {
150            // Always refused: an address that is not one.
151            commands.push(TravelerCommand::ChangeEmail {
152                value: "not-an-address".to_owned(),
153            });
154            if state.loyalty_number.is_answered() {
155                // Also always refused: declining a field the user already
156                // answered would erase the answer.
157                commands.push(TravelerCommand::DeclineLoyaltyNumber {
158                    reason: DeclineReason::Withheld,
159                });
160            }
161        }
162        commands
163    }
164
165    fn simulate(
166        &self,
167        state: Option<&TravelerState>,
168        command: &TravelerCommand,
169    ) -> SimulatedTransition<TravelerState, TravelerEvent> {
170        simulate(&self.workflow, state, command)
171    }
172
173    fn declared_outcomes(&self) -> Vec<TravelerOutcome> {
174        vec![TravelerOutcome::Archived, TravelerOutcome::Deleted]
175    }
176}