turnframe_test/workflows/trip/
model.rs1use 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
16pub const SAMPLE_TRAVELER_ID: Uuid = Uuid::from_u128(0x7a1e_0000_0000_4000_8000_0000_0000_0001);
18
19pub const OTHER_TRAVELER_ID: Uuid = Uuid::from_u128(0x7a1e_0000_0000_4000_8000_0000_0000_0002);
21
22pub const SAMPLE_NAME: &str = "Lisbon offsite";
24
25pub const SAMPLE_EXTRA_DESCRIPTIONS: [&str; 4] = [
27 "Checked bag",
28 "Seat with extra legroom",
29 "Airport meal",
30 "Hotel night",
31];
32
33pub const SAMPLE_TICKET_NUMBER: &str = "055-2100000123";
35
36pub const SAMPLE_REFUSAL_CODE: &str = "SEAT_GONE";
38
39pub const SAMPLE_FARE_DIFFERENCE_CENTS: i64 = 8_400;
41
42pub const REQUOTED_FARE_DIFFERENCE_CENTS: i64 = 13_200;
44
45pub const MODEL_EXTRA_BUDGET: usize = MAX_EXTRAS;
50
51#[must_use]
53pub fn sample_travel_date() -> NaiveDate {
54 NaiveDate::from_ymd_opt(2026, 10, 6).unwrap_or(NaiveDate::MIN)
55}
56
57#[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#[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#[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#[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#[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#[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#[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#[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#[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#[must_use]
187pub fn with_offer(leg: u32) -> TripState {
188 TripState {
189 offer: Some(sample_offer(leg)),
190 ..complete_case()
191 }
192}
193
194#[must_use]
197pub fn awaiting_rebooking_confirmation() -> TripState {
198 awaiting_rebooking_confirmation_at(SAMPLE_FARE_DIFFERENCE_CENTS)
199}
200
201#[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 #[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#[derive(Debug, Clone, Copy, Default)]
235#[non_exhaustive]
236pub struct TripModel {
237 pub workflow: TripWorkflow,
239}
240
241impl TripModel {
242 #[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 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 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 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}