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