Skip to main content

turnframe_test/workflows/trip/
model.rs

1//! The exploration model and the ready-made fixtures of the trip sample.
2
3use chrono::NaiveDate;
4use turnframe_core::event::ExternalStatus;
5use uuid::Uuid;
6
7use crate::explore::{SimulatedTransition, WorkflowModel};
8use crate::workflows::simulate;
9use crate::workflows::trip::apply::{MAX_EXTRAS, extra_id_for};
10use crate::workflows::trip::command::{TripCommand, TripEvent};
11use crate::workflows::trip::definition::TripWorkflow;
12use crate::workflows::trip::state::{
13    Extra, Leg, LegStatus, NewExtra, Offer, Payer, TripOutcome, TripState, TripStatus, TripTraveler,
14};
15
16/// Identifier of the traveler the model puts on cases.
17pub const SAMPLE_TRAVELER_ID: Uuid = Uuid::from_u128(0x7a1e_0000_0000_4000_8000_0000_0000_0001);
18
19/// Identifier of a second traveler, used to exercise the sensitive change.
20pub const OTHER_TRAVELER_ID: Uuid = Uuid::from_u128(0x7a1e_0000_0000_4000_8000_0000_0000_0002);
21
22/// Name the model gives the trip.
23pub const SAMPLE_NAME: &str = "Lisbon offsite";
24
25/// Descriptions of the extras the model adds, in order.
26pub const SAMPLE_EXTRA_DESCRIPTIONS: [&str; 4] = [
27    "Checked bag",
28    "Seat with extra legroom",
29    "Airport meal",
30    "Hotel night",
31];
32
33/// Ticket number the sample airline issues.
34pub const SAMPLE_TICKET_NUMBER: &str = "055-2100000123";
35
36/// Reason code the sample airline returns when it refuses a rebooking.
37pub const SAMPLE_REFUSAL_CODE: &str = "SEAT_GONE";
38
39/// The fare difference of the sample offer, in cents: €84.
40pub const SAMPLE_FARE_DIFFERENCE_CENTS: i64 = 8_400;
41
42/// The fare difference after the airline quotes again, in cents: €132.
43pub const REQUOTED_FARE_DIFFERENCE_CENTS: i64 = 13_200;
44
45/// Most extras the exploration model adds: the domain's own maximum, so the cap
46/// and the refusal that guards it are inside the explored states. The state space
47/// stays bounded because extras are added in a fixed order and only the first one
48/// without a payer is given one, so the paid extras are always a prefix.
49pub const MODEL_EXTRA_BUDGET: usize = MAX_EXTRAS;
50
51/// The travel date the model sets.
52#[must_use]
53pub fn sample_travel_date() -> NaiveDate {
54    NaiveDate::from_ymd_opt(2026, 10, 6).unwrap_or(NaiveDate::MIN)
55}
56
57/// The two flights of the sample booking: the outbound was cancelled, the return
58/// flies as booked.
59#[must_use]
60pub fn sample_legs() -> Vec<Leg> {
61    vec![
62        Leg {
63            number: 1,
64            flight: "AZ610".to_owned(),
65            from: "FCO".to_owned(),
66            to: "LIS".to_owned(),
67            departs: "2026-10-05 07:40".to_owned(),
68            status: LegStatus::Cancelled,
69            protected: false,
70        },
71        Leg {
72            number: 2,
73            flight: "AZ611".to_owned(),
74            from: "LIS".to_owned(),
75            to: "FCO".to_owned(),
76            departs: "2026-10-09 18:20".to_owned(),
77            status: LegStatus::OnTime,
78            protected: false,
79        },
80    ]
81}
82
83/// The rebooking the airline quotes for a leg of the sample booking.
84#[must_use]
85pub fn sample_offer(leg: u32) -> Offer {
86    Offer {
87        leg,
88        flight: if leg == 1 { "AZ612" } else { "AZ613" }.to_owned(),
89        departs: if leg == 1 {
90            "2026-10-05 13:10"
91        } else {
92            "2026-10-10 09:30"
93        }
94        .to_owned(),
95        fare_difference_cents: SAMPLE_FARE_DIFFERENCE_CENTS,
96    }
97}
98
99/// The airline's quote of the sample offer for `leg`, as the command it applies.
100#[must_use]
101pub fn sample_quote(leg: u32) -> TripCommand {
102    let offer = sample_offer(leg);
103    TripCommand::Requote {
104        leg: offer.leg,
105        flight: offer.flight,
106        departs: offer.departs,
107        fare_difference_cents: offer.fare_difference_cents,
108    }
109}
110
111/// The traveler the model puts on cases.
112#[must_use]
113pub fn sample_traveler() -> TripTraveler {
114    TripTraveler {
115        traveler_id: SAMPLE_TRAVELER_ID,
116        display_name: "Marta Bianchi".to_owned(),
117    }
118}
119
120/// A second traveler, for testing the sensitive traveler change.
121#[must_use]
122pub fn other_traveler() -> TripTraveler {
123    TripTraveler {
124        traveler_id: OTHER_TRAVELER_ID,
125        display_name: "Luca Ferri".to_owned(),
126    }
127}
128
129/// The n-th sample extra, without a payer.
130#[must_use]
131pub fn sample_new_extra(position: usize) -> NewExtra {
132    let description = SAMPLE_EXTRA_DESCRIPTIONS
133        .get(position)
134        .copied()
135        .unwrap_or("Other extra");
136    NewExtra {
137        description: description.to_owned(),
138        quantity: u32::try_from(position).unwrap_or(0) + 1,
139        unit_price_cents: 4_000,
140    }
141}
142
143/// A case with its traveler, the booking's legs and one paid extra, but no name
144/// and no travel date: two obligations are still open.
145#[must_use]
146pub fn incomplete_case() -> TripState {
147    let extra = sample_new_extra(0);
148    TripState {
149        traveler: Some(sample_traveler()),
150        legs: sample_legs(),
151        extras: vec![Extra {
152            extra_id: extra_id_for(0, &extra.description),
153            description: extra.description,
154            quantity: extra.quantity,
155            unit_price_cents: extra.unit_price_cents,
156            payer: Some(Payer::Airline),
157        }],
158        ..TripState::default()
159    }
160}
161
162/// A case whose one extra has no payer yet, so
163/// [`TripObligation::AssignPayer`](super::TripObligation::AssignPayer) is open:
164/// the state where a user asks who can pay while the flow is collecting exactly
165/// that field.
166#[must_use]
167pub fn unassigned_case() -> TripState {
168    let mut state = incomplete_case();
169    for extra in &mut state.extras {
170        extra.payer = None;
171    }
172    state
173}
174
175/// A case with every obligation met, still in `Collecting`.
176#[must_use]
177pub fn complete_case() -> TripState {
178    TripState {
179        name: Some(SAMPLE_NAME.to_owned()),
180        travel_date: Some(sample_travel_date()),
181        ..incomplete_case()
182    }
183}
184
185/// A complete case with the airline's quote for `leg`.
186#[must_use]
187pub fn with_offer(leg: u32) -> TripState {
188    TripState {
189        offer: Some(sample_offer(leg)),
190        ..complete_case()
191    }
192}
193
194/// A complete case whose rebooking card for the outbound leg is waiting for a
195/// click, at the sample fare difference.
196#[must_use]
197pub fn awaiting_rebooking_confirmation() -> TripState {
198    awaiting_rebooking_confirmation_at(SAMPLE_FARE_DIFFERENCE_CENTS)
199}
200
201/// A complete case whose rebooking card for the outbound leg is waiting for a
202/// click, at the given fare difference.
203#[must_use]
204pub fn awaiting_rebooking_confirmation_at(fare_difference_cents: i64) -> TripState {
205    let mut offer = sample_offer(1);
206    offer.fare_difference_cents = fare_difference_cents;
207    TripState {
208        status: TripStatus::AwaitingRebookingConfirmation,
209        external_status: Some(ExternalStatus::AwaitingConfirmation),
210        offer: Some(offer),
211        ..complete_case()
212    }
213}
214
215impl TripState {
216    /// The same state with `leg` protected.
217    #[must_use]
218    pub fn protected(mut self, leg: u32) -> Self {
219        for found in &mut self.legs {
220            if found.number == leg {
221                found.protected = true;
222            }
223        }
224        self
225    }
226}
227
228/// The exploration model of the trip workflow.
229///
230/// It offers, in every state, only the commands that make sense there plus one it
231/// knows will be refused (an empty name), so every explored state also checks that
232/// a refusal changes nothing. All values are fixed constants and identifiers are
233/// derived, so the reachable state space is finite and the same on every run.
234#[derive(Debug, Clone, Copy, Default)]
235#[non_exhaustive]
236pub struct TripModel {
237    /// The definition transitions are applied through.
238    pub workflow: TripWorkflow,
239}
240
241impl TripModel {
242    /// Builds the model.
243    #[must_use]
244    pub const fn new() -> Self {
245        Self {
246            workflow: TripWorkflow::new(),
247        }
248    }
249
250    fn editing_commands(state: &TripState, commands: &mut Vec<TripCommand>) {
251        // The sensitive change is offered in both directions from every editable
252        // state; the traveler is one of three values, so the space grows by a
253        // constant factor.
254        commands.push(TripCommand::ChangeTraveler {
255            traveler: match state.traveler.as_ref() {
256                Some(current) if current.traveler_id == SAMPLE_TRAVELER_ID => other_traveler(),
257                _ => sample_traveler(),
258            },
259        });
260        if state.name.is_none() {
261            commands.push(TripCommand::SetName {
262                value: SAMPLE_NAME.to_owned(),
263            });
264        }
265        if state.travel_date.is_none() {
266            commands.push(TripCommand::SetTravelDate {
267                value: sample_travel_date(),
268            });
269        }
270        if state.extras.len() < MODEL_EXTRA_BUDGET || state.extras.len() >= MAX_EXTRAS {
271            // Past the domain's own cap the command is refused, which costs one
272            // simulation and proves the cap holds.
273            commands.push(TripCommand::AddExtra {
274                extra: sample_new_extra(state.extras.len()),
275            });
276        }
277        if let Some(extra) = state.extras.iter().find(|e| e.payer.is_none()) {
278            commands.push(TripCommand::AssignPayer {
279                extra_id: extra.extra_id,
280                payer: Payer::Airline,
281            });
282        }
283        if state.offer.is_none() {
284            commands.push(sample_quote(1));
285        }
286        if state
287            .legs
288            .iter()
289            .any(|leg| leg.number == 2 && !leg.protected)
290        {
291            commands.push(TripCommand::ProtectLeg { leg: 2 });
292        }
293    }
294}
295
296impl WorkflowModel<TripWorkflow> for TripModel {
297    fn initial_states(&self) -> Vec<Option<TripState>> {
298        vec![None]
299    }
300
301    fn candidate_commands(&self, state: Option<&TripState>) -> Vec<TripCommand> {
302        let Some(state) = state else {
303            return vec![TripCommand::Open];
304        };
305        let mut commands = Vec::new();
306        match state.status {
307            TripStatus::Draft | TripStatus::Refused => {
308                Self::editing_commands(state, &mut commands);
309                if let Some(offer) = &state.offer
310                    && state.is_complete()
311                {
312                    commands.push(TripCommand::RequestRebooking { leg: offer.leg });
313                }
314                commands.push(TripCommand::Withdraw);
315            }
316            TripStatus::AwaitingRebookingConfirmation => {
317                Self::editing_commands(state, &mut commands);
318                commands.push(TripCommand::Rebook);
319                commands.push(TripCommand::Withdraw);
320            }
321            TripStatus::Rebooking => {
322                for status in [
323                    ExternalStatus::ReceivedByIntermediary,
324                    ExternalStatus::Accepted,
325                    ExternalStatus::Rejected,
326                ] {
327                    commands.push(TripCommand::RecordAirlineOutcome {
328                        status,
329                        ticket_number: (status == ExternalStatus::Accepted)
330                            .then(|| SAMPLE_TICKET_NUMBER.to_owned()),
331                        reason_code: (status == ExternalStatus::Rejected)
332                            .then(|| SAMPLE_REFUSAL_CODE.to_owned()),
333                    });
334                }
335            }
336            TripStatus::Ticketed => {
337                for status in [ExternalStatus::Delivered, ExternalStatus::NotDelivered] {
338                    commands.push(TripCommand::RecordAirlineOutcome {
339                        status,
340                        ticket_number: None,
341                        reason_code: None,
342                    });
343                }
344            }
345            TripStatus::Notified | TripStatus::NotNotified | TripStatus::Withdrawn => {}
346        }
347        if state.status.is_editable() {
348            // Always refused: a blank name, which proves a refusal leaves the state
349            // untouched.
350            commands.push(TripCommand::SetName {
351                value: String::new(),
352            });
353        }
354        commands
355    }
356
357    fn simulate(
358        &self,
359        state: Option<&TripState>,
360        command: &TripCommand,
361    ) -> SimulatedTransition<TripState, TripEvent> {
362        simulate(&self.workflow, state, command)
363    }
364
365    fn declared_outcomes(&self) -> Vec<TripOutcome> {
366        vec![
367            TripOutcome::Withdrawn,
368            TripOutcome::Notified,
369            TripOutcome::NotNotified,
370        ]
371    }
372}