1use serde::Serialize;
5use turnframe_core::case::{CaseKey, CaseRef};
6use turnframe_core::event::OperationalReceipt;
7use turnframe_core::flow::ErasedWorkflowView;
8use turnframe_core::interaction::Interaction;
9use turnframe_core::locale::{Locale, LocalizedText};
10use turnframe_core::response::{CaseLabel, Expectation, NarratableFact};
11
12#[derive(Debug, Clone, Default, PartialEq, Serialize)]
14pub(crate) struct TurnOutcome {
15 #[serde(skip_serializing_if = "Vec::is_empty")]
17 pub done: Vec<String>,
18 #[serde(skip_serializing_if = "Vec::is_empty")]
20 pub not_done: Vec<String>,
21 #[serde(skip_serializing_if = "Vec::is_empty")]
23 pub disputes: Vec<String>,
24 #[serde(skip_serializing_if = "Vec::is_empty")]
26 pub starting: Vec<String>,
27 #[serde(skip_serializing_if = "Option::is_none")]
29 pub ask: Option<Ask>,
30 #[serde(skip_serializing_if = "Option::is_none")]
32 pub card: Option<String>,
33 #[serde(skip_serializing_if = "Vec::is_empty")]
35 pub next: Vec<String>,
36 #[serde(skip)]
39 pub closing: Option<String>,
40}
41
42impl TurnOutcome {
43 pub fn is_silent(&self) -> bool {
45 self.done.is_empty()
46 && self.not_done.is_empty()
47 && self.disputes.is_empty()
48 && self.starting.is_empty()
49 && self.ask.is_none()
50 && self.card.is_none()
51 && self.next.is_empty()
52 }
53
54 pub fn only_asks(&self) -> bool {
56 self.ask.is_some()
57 && self.done.is_empty()
58 && self.not_done.is_empty()
59 && self.disputes.is_empty()
60 && self.starting.is_empty()
61 && self.card.is_none()
62 && self.next.is_empty()
63 }
64}
65
66#[derive(Debug, Clone, PartialEq, Serialize)]
68pub(crate) struct Ask {
69 #[serde(skip_serializing_if = "Option::is_none")]
71 pub record: Option<String>,
72 pub what: String,
74 #[serde(skip_serializing_if = "Option::is_none")]
76 pub because: Option<String>,
77 pub question: String,
79 #[serde(skip)]
81 pub elsewhere: bool,
82 #[serde(skip)]
84 pub expectation: Option<Expectation>,
85}
86
87#[derive(Debug, Clone, PartialEq, Eq)]
89#[non_exhaustive]
90pub struct AskCopy {
91 pub value: LocalizedText,
93 pub refused_value: LocalizedText,
95 pub obligation: LocalizedText,
97 pub contested: LocalizedText,
99 pub elsewhere: LocalizedText,
102 pub go_on: LocalizedText,
104}
105
106impl AskCopy {
107 #[must_use]
109 pub fn standard() -> Self {
110 crate::copy::ServerCopy::translated(Self::english(), "it", ITALIAN)
111 }
112
113 #[must_use]
115 pub fn english() -> Self {
116 Self {
117 value: LocalizedText::new("What should the {what} be?"),
118 refused_value: LocalizedText::new("{because} What should the {what} be instead?"),
119 obligation: LocalizedText::new("Still needed: {what}."),
120 contested: LocalizedText::new("{what} What should it be instead?"),
121 elsewhere: LocalizedText::new("{record}: {question}"),
122 go_on: LocalizedText::new("What would you like to do next?"),
123 }
124 }
125}
126
127impl Default for AskCopy {
128 fn default() -> Self {
129 Self::standard()
130 }
131}
132
133crate::copy::server_copy!(
134 AskCopy,
135 [
136 value,
137 refused_value,
138 obligation,
139 contested,
140 elsewhere,
141 go_on
142 ]
143);
144
145const ITALIAN: &[(&str, &str)] = &[
147 ("value", "Che cosa metto come {what}?"),
148 (
149 "refused_value",
150 "{because} Che cosa metto come {what}, invece?",
151 ),
152 ("obligation", "Manca ancora: {what}."),
153 ("contested", "{what} Come dovrebbe essere, invece?"),
154 ("elsewhere", "{record}: {question}"),
155 ("go_on", "Cosa vuoi fare adesso?"),
156];
157
158fn fill(template: &LocalizedText, locale: &Locale, pairs: &[(&str, &str)]) -> String {
159 let mut text = template.resolve(locale).to_owned();
160 for (key, value) in pairs {
161 text = text.replace(&format!("{{{key}}}"), value);
162 }
163 text.trim().to_owned()
164}
165
166pub(crate) struct Material<'a> {
168 pub receipts: &'a [OperationalReceipt],
169 pub facts: &'a [NarratableFact],
170 pub interactions: &'a [Interaction],
171 pub views: &'a [ErasedWorkflowView],
172 pub touched: &'a [CaseKey],
174 pub beside: &'a [CaseKey],
176 pub labels: &'a [CaseLabel],
177 pub disputes: &'a [String],
178 pub started: &'a [turnframe_core::ids::WorkflowKey],
179 pub contested: &'a [String],
180 pub next_steps: &'a [(CaseKey, Vec<LocalizedText>)],
182 pub locale: &'a Locale,
183 pub copy: &'a AskCopy,
184}
185
186impl Material<'_> {
187 fn label(&self, case_ref: &CaseRef) -> Option<String> {
188 self.labels
189 .iter()
190 .find(|label| label.case_ref.key() == case_ref.key())
191 .map(|label| label.label.clone())
192 }
193
194 fn ask(&self) -> Option<Ask> {
198 if let Some(contested) = self.contested.first() {
199 return Some(Ask {
200 record: None,
201 what: contested.clone(),
202 because: None,
203 question: fill(&self.copy.contested, self.locale, &[("what", contested)]),
204 elsewhere: false,
205 expectation: None,
206 });
207 }
208 let waiting = self.facts.iter().find_map(|fact| match fact {
209 NarratableFact::ValueNeeded {
210 case_ref,
211 arguments,
212 reason,
213 ..
214 } => Some((case_ref.clone(), arguments.join(", "), reason.clone())),
215 _ => None,
216 });
217 if let Some((case_ref, what, because)) = waiting {
218 let question = match &because {
219 Some(because) => fill(
220 &self.copy.refused_value,
221 self.locale,
222 &[("what", &what), ("because", because)],
223 ),
224 None => fill(&self.copy.value, self.locale, &[("what", &what)]),
225 };
226 return Some(Ask {
227 record: case_ref.as_ref().and_then(|case_ref| self.label(case_ref)),
228 what,
229 because,
230 question,
231 elsewhere: false,
232 expectation: None,
234 });
235 }
236 let reached = |case_ref: &CaseRef| self.touched.contains(&case_ref.key());
237 let card_next = self
238 .interactions
239 .iter()
240 .any(|card| card.blocking && reached(&card.case_ref))
241 || self
242 .views
243 .iter()
244 .any(|view| view.blocking_interaction.is_some() && reached(&view.case_ref));
245 if card_next {
246 return None;
247 }
248 let (view, obligation) = self.touched.iter().chain(self.beside).find_map(|key| {
249 let view = self.views.iter().find(|view| view.case_ref.key() == *key)?;
250 Some((view, view.obligations.first()?))
251 })?;
252 let (what, question) = match &obligation.sentence {
254 Some(sentence) => {
255 let sentence = sentence.resolve(self.locale).to_owned();
256 (sentence.clone(), sentence)
257 }
258 None => {
259 let what = obligation_words(&obligation.value);
260 let question = fill(&self.copy.obligation, self.locale, &[("what", &what)]);
261 (what, question)
262 }
263 };
264 let expectation = match &obligation.act {
267 Some(act) => Expectation::AwaitingOperation {
268 case_ref: view.case_ref.clone(),
269 obligation: what.clone(),
270 act: act.clone(),
271 },
272 None => Expectation::AwaitingObligation {
273 case_ref: view.case_ref.clone(),
274 obligation: what.clone(),
275 },
276 };
277 let record = self.label(&view.case_ref);
278 let elsewhere = !reached(&view.case_ref) && record.is_some();
280 let question = match record.as_deref().filter(|_| elsewhere) {
281 Some(label) => fill(
282 &self.copy.elsewhere,
283 self.locale,
284 &[("record", label), ("question", &question)],
285 ),
286 None => question,
287 };
288 Some(Ask {
289 record,
290 question,
291 elsewhere,
292 expectation: Some(expectation),
293 what,
294 because: None,
295 })
296 }
297
298 pub fn outcome(&self) -> TurnOutcome {
300 let locale = self.locale;
301 let done = self
302 .receipts
303 .iter()
304 .map(|receipt| {
305 format!(
306 "{}: {}",
307 receipt.title.resolve(locale),
308 receipt.body.resolve(locale)
309 )
310 })
311 .collect();
312 let not_done = self.facts.iter().filter_map(not_done).collect();
313 let card = self.interactions.first().map(|card| {
314 let options: Vec<&str> = card
315 .payload
316 .options
317 .iter()
318 .map(|option| option.label.resolve(locale))
319 .collect();
320 let about = self
321 .touched
322 .contains(&card.case_ref.key())
323 .then(|| self.label(&card.case_ref))
324 .flatten()
325 .map(|label| format!("{label}: "))
326 .unwrap_or_default();
327 format!(
328 "{about}{} ({})",
329 card.payload.title.resolve(locale),
330 options.join(" / ")
331 )
332 });
333 let ask = self.ask();
334 let next = if ask.is_none() && card.is_none() {
335 self.touched
336 .iter()
337 .find_map(|key| {
338 self.next_steps
339 .iter()
340 .find(|(case, steps)| case == key && !steps.is_empty())
341 })
342 .map(|(_, steps)| {
343 steps
344 .iter()
345 .map(|step| step.resolve(locale).to_owned())
346 .collect()
347 })
348 .unwrap_or_default()
349 } else {
350 Vec::new()
351 };
352 let closing = (ask.is_none() && card.is_none() && next.is_empty())
353 .then(|| self.copy.go_on.resolve(locale).to_owned());
354 TurnOutcome {
355 done,
356 not_done,
357 disputes: self.disputes.to_vec(),
358 starting: self.started.iter().map(ToString::to_string).collect(),
359 ask,
360 card,
361 next,
362 closing,
363 }
364 }
365}
366
367fn obligation_words(value: &serde_json::Value) -> String {
370 let name = match value {
371 serde_json::Value::String(name) => Some(name.as_str()),
372 serde_json::Value::Object(map) => map.keys().next().map(String::as_str),
373 _ => None,
374 };
375 name.map_or_else(|| value.to_string(), |name| name.replace('_', " "))
376}
377
378fn not_done(fact: &NarratableFact) -> Option<String> {
380 Some(match fact {
381 NarratableFact::ActRefused {
382 explanation, code, ..
383 } => {
384 if explanation.is_empty() {
385 format!("Refused ({code}).")
386 } else {
387 explanation.clone()
388 }
389 }
390 NarratableFact::ActChangedNothing { explanation, .. } if !explanation.is_empty() => {
391 explanation.clone()
392 }
393 NarratableFact::ActChangedNothing { operation, .. } => format!(
394 "Nothing changed: {} was already so.",
395 operation.as_deref().unwrap_or("what was asked")
396 ),
397 NarratableFact::InstructionDeclined {
398 question,
399 option_label,
400 ..
401 } => format!("The user answered «{question}» with «{option_label}»."),
402 NarratableFact::ActHeld { operation, because } => {
403 format!("{operation} waits: «{because}» was not understood.")
404 }
405 NarratableFact::NotUnderstood { words } => format!("Not understood: «{words}»."),
406 NarratableFact::WorkflowUnavailable { reason, .. } => reason.clone(),
407 NarratableFact::AttachmentNotShown {
408 filename, reason, ..
409 } => format!(
410 "The file {} was not read: {reason}",
411 filename.as_deref().unwrap_or("attached")
412 ),
413 _ => return None,
414 })
415}
416
417#[cfg(test)]
418mod tests {
419 use super::*;
420 use turnframe_core::ids::CaseRevision;
421
422 fn material<'a>(
423 facts: &'a [NarratableFact],
424 copy: &'a AskCopy,
425 locale: &'a Locale,
426 ) -> Material<'a> {
427 Material {
428 receipts: &[],
429 facts,
430 interactions: &[],
431 views: &[],
432 touched: &[],
433 beside: &[],
434 labels: &[],
435 disputes: &[],
436 started: &[],
437 contested: &[],
438 next_steps: &[],
439 locale,
440 copy,
441 }
442 }
443
444 #[test]
445 fn a_record_that_needs_nothing_more_offers_its_next_steps() {
446 let view = |obligations| ErasedWorkflowView {
447 case_ref: CaseRef::new("sample", "s-1", CaseRevision(1)),
448 workflow_version: turnframe_core::ids::WorkflowVersion::from("1"),
449 phase: serde_json::json!("collecting"),
450 phase_ownership: turnframe_core::flow::PhaseOwnership::User,
451 obligations,
452 blocking_interaction: None,
453 notices: Vec::new(),
454 outcome: None,
455 state: Vec::new(),
456 };
457 let owed = turnframe_core::flow::ErasedObligation {
458 id: turnframe_core::flow::ObligationId("\"a\"".to_owned()),
459 value: serde_json::json!("a"),
460 sentence: Some(LocalizedText::new("What is A?")),
461 act: None,
462 };
463 let copy = AskCopy::english();
464 let locale = Locale::from("en-GB");
465 let touched = [CaseRef::new("sample", "s-1", CaseRevision(1)).key()];
466 let next_steps = [(
467 touched[0].clone(),
468 vec![
469 LocalizedText::new("Add another item."),
470 LocalizedText::new("Send it."),
471 ],
472 )];
473
474 let complete = [view(Vec::new())];
475 let mut material = material(&[], ©, &locale);
476 material.views = &complete;
477 material.touched = &touched;
478 material.next_steps = &next_steps;
479 let outcome = material.outcome();
480 assert_eq!(outcome.ask, None);
481 assert_eq!(outcome.next, vec!["Add another item.", "Send it."]);
482
483 let open = [view(vec![owed])];
484 material.views = &open;
485 let outcome = material.outcome();
486 assert!(outcome.ask.is_some());
487 assert!(outcome.next.is_empty(), "what is owed comes first");
488 }
489
490 #[test]
491 fn a_waiting_value_is_the_ask_and_code_writes_its_question() {
492 let facts = [NarratableFact::ValueNeeded {
493 case_ref: Some(CaseRef::new("trip", "trip-1", CaseRevision(1))),
494 operation: "trip.set_name".to_owned(),
495 arguments: vec!["subject".to_owned()],
496 reason: None,
497 }];
498 let copy = AskCopy::english();
499 let locale = Locale::from("en-GB");
500 let outcome = material(&facts, ©, &locale).outcome();
501 let ask = outcome.ask.expect("an ask");
502 assert_eq!(ask.what, "subject");
503 assert_eq!(ask.question, "What should the subject be?");
504 assert!(
505 outcome.not_done.is_empty(),
506 "a missing value is asked for, not reported"
507 );
508 }
509
510 #[test]
511 fn an_obligation_without_a_sentence_is_never_asked_by_its_code() {
512 let case_ref = CaseRef::new("trip", "trip-2", CaseRevision(1));
513 let view = ErasedWorkflowView {
514 case_ref: case_ref.clone(),
515 workflow_version: turnframe_core::ids::WorkflowVersion::from("1"),
516 phase: serde_json::json!("collecting"),
517 phase_ownership: turnframe_core::flow::PhaseOwnership::User,
518 obligations: vec![turnframe_core::flow::ErasedObligation {
519 id: turnframe_core::flow::ObligationId("\"select_traveler\"".to_owned()),
520 value: serde_json::json!("select_traveler"),
521 sentence: None,
522 act: None,
523 }],
524 blocking_interaction: None,
525 notices: Vec::new(),
526 outcome: None,
527 state: Vec::new(),
528 };
529 let copy = AskCopy::english();
530 let locale = Locale::from("en-GB");
531 let views = [view];
532 let touched = [case_ref.key()];
533 let mut material = material(&[], ©, &locale);
534 material.views = &views;
535 material.touched = &touched;
536 let ask = material
537 .outcome()
538 .ask
539 .expect("the open obligation is asked for");
540 assert_eq!(ask.question, "Still needed: select traveler.");
541 }
542
543 #[test]
544 fn a_card_on_screen_is_what_comes_next() {
545 let obligation = |case_id: &str, requirement| ErasedWorkflowView {
546 case_ref: CaseRef::new("sample", case_id, CaseRevision(1)),
547 workflow_version: turnframe_core::ids::WorkflowVersion::from("1"),
548 phase: serde_json::json!("collecting"),
549 phase_ownership: turnframe_core::flow::PhaseOwnership::User,
550 obligations: vec![turnframe_core::flow::ErasedObligation {
551 id: turnframe_core::flow::ObligationId("\"a\"".to_owned()),
552 value: serde_json::json!("a"),
553 sentence: Some(LocalizedText::new("What is A?")),
554 act: None,
555 }],
556 blocking_interaction: requirement,
557 notices: Vec::new(),
558 outcome: None,
559 state: Vec::new(),
560 };
561 let carded = obligation(
562 "s-1",
563 Some(turnframe_core::flow::InteractionRequirement::blocking(
564 "confirm",
565 turnframe_core::interaction::InteractionKind::Boolean,
566 )),
567 );
568 let other = obligation("s-2", None);
569 let touched = [carded.case_ref.key()];
570 let beside = [other.case_ref.key()];
571 let views = [carded, other];
572 let copy = AskCopy::english();
573 let locale = Locale::from("en-GB");
574 let mut material = material(&[], ©, &locale);
575 material.views = &views;
576 material.touched = &touched;
577 material.beside = &beside;
578 assert_eq!(material.outcome().ask, None);
579 }
580
581 fn owing(case_id: &str, owed: bool) -> ErasedWorkflowView {
582 ErasedWorkflowView {
583 case_ref: CaseRef::new("sample", case_id, CaseRevision(1)),
584 workflow_version: turnframe_core::ids::WorkflowVersion::from("1"),
585 phase: serde_json::json!("collecting"),
586 phase_ownership: turnframe_core::flow::PhaseOwnership::User,
587 obligations: owed
588 .then(|| turnframe_core::flow::ErasedObligation {
589 id: turnframe_core::flow::ObligationId("\"a\"".to_owned()),
590 value: serde_json::json!("a"),
591 sentence: Some(LocalizedText::new("What is A?")),
592 act: None,
593 })
594 .into_iter()
595 .collect(),
596 blocking_interaction: None,
597 notices: Vec::new(),
598 outcome: None,
599 state: Vec::new(),
600 }
601 }
602
603 #[test]
604 fn an_obligation_of_a_record_the_turn_did_not_reach_names_that_record() {
605 let views = [owing("s-1", false), owing("s-2", true)];
606 let touched = [views[0].case_ref.key()];
607 let beside = [views[1].case_ref.key()];
608 let labels = [CaseLabel {
609 case_ref: views[1].case_ref.clone(),
610 label: "Sample 2".to_owned(),
611 }];
612 let copy = AskCopy::english();
613 let locale = Locale::from("en-GB");
614 let mut material = material(&[], ©, &locale);
615 material.views = &views;
616 material.touched = &touched;
617 material.beside = &beside;
618 material.labels = &labels;
619 let ask = material
620 .outcome()
621 .ask
622 .expect("the other record's obligation");
623 assert!(ask.elsewhere);
624 assert_eq!(ask.question, "Sample 2: What is A?");
625 }
626
627 #[test]
628 fn an_obligation_of_a_record_the_turn_reached_is_asked_as_written() {
629 let views = [owing("s-1", true)];
630 let touched = [views[0].case_ref.key()];
631 let copy = AskCopy::english();
632 let locale = Locale::from("en-GB");
633 let mut material = material(&[], ©, &locale);
634 material.views = &views;
635 material.touched = &touched;
636 let ask = material.outcome().ask.expect("its obligation");
637 assert!(!ask.elsewhere);
638 assert_eq!(ask.question, "What is A?");
639 }
640
641 #[test]
642 fn nothing_to_say_is_silence() {
643 let copy = AskCopy::english();
644 let locale = Locale::from("en-GB");
645 assert!(material(&[], ©, &locale).outcome().is_silent());
646 }
647}