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        payer: None,
141    }
142}
143
144/// A case with its traveler, the booking's legs and one paid extra, but no name
145/// and no travel date: two obligations are still open.
146#[must_use]
147pub fn incomplete_case() -> TripState {
148    let extra = sample_new_extra(0);
149    TripState {
150        traveler: Some(sample_traveler()),
151        legs: sample_legs(),
152        extras: vec![Extra {
153            extra_id: extra_id_for(0, &extra.description),
154            description: extra.description,
155            quantity: extra.quantity,
156            unit_price_cents: extra.unit_price_cents,
157            payer: Some(Payer::Airline),
158        }],
159        ..TripState::default()
160    }
161}
162
163/// A case whose one extra has no payer yet, so
164/// [`TripObligation::AssignPayer`](super::TripObligation::AssignPayer) is open:
165/// the state where a user asks who can pay while the flow is collecting exactly
166/// that field.
167#[must_use]
168pub fn unassigned_case() -> TripState {
169    let mut state = incomplete_case();
170    for extra in &mut state.extras {
171        extra.payer = None;
172    }
173    state
174}
175
176/// A case with every obligation met, still in `Collecting`.
177#[must_use]
178pub fn complete_case() -> TripState {
179    TripState {
180        name: Some(SAMPLE_NAME.to_owned()),
181        travel_date: Some(sample_travel_date()),
182        ..incomplete_case()
183    }
184}
185
186/// A complete case with the airline's quote for `leg`.
187#[must_use]
188pub fn with_offer(leg: u32) -> TripState {
189    TripState {
190        offer: Some(sample_offer(leg)),
191        ..complete_case()
192    }
193}
194
195/// A complete case whose rebooking card for the outbound leg is waiting for a
196/// click, at the sample fare difference.
197#[must_use]
198pub fn awaiting_rebooking_confirmation() -> TripState {
199    awaiting_rebooking_confirmation_at(SAMPLE_FARE_DIFFERENCE_CENTS)
200}
201
202/// A complete case whose rebooking card for the outbound leg is waiting for a
203/// click, at the given fare difference.
204#[must_use]
205pub fn awaiting_rebooking_confirmation_at(fare_difference_cents: i64) -> TripState {
206    let mut offer = sample_offer(1);
207    offer.fare_difference_cents = fare_difference_cents;
208    TripState {
209        status: TripStatus::AwaitingRebookingConfirmation,
210        external_status: Some(ExternalStatus::AwaitingConfirmation),
211        offer: Some(offer),
212        ..complete_case()
213    }
214}
215
216impl TripState {
217    /// The same state with `leg` protected.
218    #[must_use]
219    pub fn protected(mut self, leg: u32) -> Self {
220        for found in &mut self.legs {
221            if found.number == leg {
222                found.protected = true;
223            }
224        }
225        self
226    }
227}
228
229/// The exploration model of the trip workflow.
230///
231/// It offers, in every state, only the commands that make sense there plus one it
232/// knows will be refused (an empty name), so every explored state also checks that
233/// a refusal changes nothing. All values are fixed constants and identifiers are
234/// derived, so the reachable state space is finite and the same on every run.
235#[derive(Debug, Clone, Copy, Default)]
236#[non_exhaustive]
237pub struct TripModel {
238    /// The definition transitions are applied through.
239    pub workflow: TripWorkflow,
240}
241
242impl TripModel {
243    /// Builds the model.
244    #[must_use]
245    pub const fn new() -> Self {
246        Self {
247            workflow: TripWorkflow::new(),
248        }
249    }
250
251    fn editing_commands(state: &TripState, commands: &mut Vec<TripCommand>) {
252        // The sensitive change is offered in both directions from every editable
253        // state; the traveler is one of three values, so the space grows by a
254        // constant factor.
255        commands.push(TripCommand::ChangeTraveler {
256            traveler: match state.traveler.as_ref() {
257                Some(current) if current.traveler_id == SAMPLE_TRAVELER_ID => other_traveler(),
258                _ => sample_traveler(),
259            },
260        });
261        if state.name.is_none() {
262            commands.push(TripCommand::SetName {
263                value: SAMPLE_NAME.to_owned(),
264            });
265        }
266        if state.travel_date.is_none() {
267            commands.push(TripCommand::SetTravelDate {
268                value: sample_travel_date(),
269            });
270        }
271        if state.extras.len() < MODEL_EXTRA_BUDGET || state.extras.len() >= MAX_EXTRAS {
272            // Past the domain's own cap the command is refused, which costs one
273            // simulation and proves the cap holds.
274            commands.push(TripCommand::AddExtra {
275                extra: sample_new_extra(state.extras.len()),
276            });
277        }
278        if let Some(extra) = state.extras.iter().find(|e| e.payer.is_none()) {
279            commands.push(TripCommand::AssignPayer {
280                extra_id: extra.extra_id,
281                payer: Payer::Airline,
282            });
283        }
284        if state.offer.is_none() {
285            commands.push(sample_quote(1));
286        }
287        if state
288            .legs
289            .iter()
290            .any(|leg| leg.number == 2 && !leg.protected)
291        {
292            commands.push(TripCommand::ProtectLeg { leg: 2 });
293        }
294    }
295}
296
297impl WorkflowModel<TripWorkflow> for TripModel {
298    fn initial_states(&self) -> Vec<Option<TripState>> {
299        vec![None]
300    }
301
302    fn candidate_commands(&self, state: Option<&TripState>) -> Vec<TripCommand> {
303        let Some(state) = state else {
304            return vec![TripCommand::Open];
305        };
306        let mut commands = Vec::new();
307        match state.status {
308            TripStatus::Draft | TripStatus::Refused => {
309                Self::editing_commands(state, &mut commands);
310                if let Some(offer) = &state.offer
311                    && state.is_complete()
312                {
313                    commands.push(TripCommand::RequestRebooking { leg: offer.leg });
314                }
315                commands.push(TripCommand::Withdraw);
316            }
317            TripStatus::AwaitingRebookingConfirmation => {
318                Self::editing_commands(state, &mut commands);
319                commands.push(TripCommand::Rebook);
320                commands.push(TripCommand::Withdraw);
321            }
322            TripStatus::Rebooking => {
323                for status in [
324                    ExternalStatus::ReceivedByIntermediary,
325                    ExternalStatus::Accepted,
326                    ExternalStatus::Rejected,
327                ] {
328                    commands.push(TripCommand::RecordAirlineOutcome {
329                        status,
330                        ticket_number: (status == ExternalStatus::Accepted)
331                            .then(|| SAMPLE_TICKET_NUMBER.to_owned()),
332                        reason_code: (status == ExternalStatus::Rejected)
333                            .then(|| SAMPLE_REFUSAL_CODE.to_owned()),
334                    });
335                }
336            }
337            TripStatus::Ticketed => {
338                for status in [ExternalStatus::Delivered, ExternalStatus::NotDelivered] {
339                    commands.push(TripCommand::RecordAirlineOutcome {
340                        status,
341                        ticket_number: None,
342                        reason_code: None,
343                    });
344                }
345            }
346            TripStatus::Notified | TripStatus::NotNotified | TripStatus::Withdrawn => {}
347        }
348        if state.status.is_editable() {
349            // Always refused: a blank name, which proves a refusal leaves the state
350            // untouched.
351            commands.push(TripCommand::SetName {
352                value: String::new(),
353            });
354        }
355        commands
356    }
357
358    fn simulate(
359        &self,
360        state: Option<&TripState>,
361        command: &TripCommand,
362    ) -> SimulatedTransition<TripState, TripEvent> {
363        simulate(&self.workflow, state, command)
364    }
365
366    fn declared_outcomes(&self) -> Vec<TripOutcome> {
367        vec![
368            TripOutcome::Withdrawn,
369            TripOutcome::Notified,
370            TripOutcome::NotNotified,
371        ]
372    }
373}