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 #[serde(skip)]
42 pub offers: Vec<turnframe_core::response::Offer>,
43 #[serde(skip_serializing_if = "Vec::is_empty")]
45 pub standing: Vec<Standing>,
46}
47
48#[derive(Debug, Clone, PartialEq, Serialize)]
50pub(crate) struct Standing {
51 pub record: String,
53 #[serde(skip_serializing_if = "Vec::is_empty")]
55 pub holds: Vec<String>,
56 #[serde(skip_serializing_if = "Vec::is_empty")]
58 pub needs: Vec<String>,
59}
60
61impl Standing {
62 pub fn line(&self) -> Option<String> {
64 (!self.holds.is_empty()).then(|| format!("{}: {}.", self.record, self.holds.join(", ")))
65 }
66}
67
68impl TurnOutcome {
69 pub fn is_silent(&self) -> bool {
71 self.done.is_empty()
72 && self.not_done.is_empty()
73 && self.disputes.is_empty()
74 && self.starting.is_empty()
75 && self.ask.is_none()
76 && self.card.is_none()
77 && self.next.is_empty()
78 }
79
80 pub fn only_asks(&self) -> bool {
82 self.ask.is_some()
83 && self.done.is_empty()
84 && self.not_done.is_empty()
85 && self.disputes.is_empty()
86 && self.starting.is_empty()
87 && self.card.is_none()
88 && self.next.is_empty()
89 }
90}
91
92#[derive(Debug, Clone, PartialEq, Serialize)]
94pub(crate) struct Ask {
95 #[serde(skip_serializing_if = "Option::is_none")]
97 pub record: Option<String>,
98 pub what: String,
100 #[serde(skip_serializing_if = "Option::is_none")]
102 pub because: Option<String>,
103 #[serde(skip_serializing_if = "std::ops::Not::not")]
105 pub again: bool,
106 pub question: String,
108 #[serde(skip)]
110 pub elsewhere: bool,
111 #[serde(skip)]
113 pub expectation: Option<Expectation>,
114 #[serde(skip)]
116 pub about: Option<(CaseKey, String)>,
117}
118
119fn asked(expectation: &Expectation) -> Option<(CaseKey, String)> {
121 match expectation {
122 Expectation::AwaitingObligation {
123 case_ref,
124 obligation,
125 }
126 | Expectation::AwaitingOperation {
127 case_ref,
128 obligation,
129 ..
130 } => Some((case_ref.key(), obligation.clone())),
131 Expectation::AwaitingValue {
132 act,
133 case_ref: Some(case_ref),
134 ..
135 } => Some((case_ref.key(), act.operation()?.as_str().to_owned())),
136 _ => None,
137 }
138}
139
140#[derive(Debug, Clone, PartialEq, Eq)]
142#[non_exhaustive]
143pub struct AskCopy {
144 pub value: LocalizedText,
146 pub refused_value: LocalizedText,
148 pub obligation: LocalizedText,
150 pub contested: LocalizedText,
152 pub elsewhere: LocalizedText,
155 pub go_on: LocalizedText,
157 pub again: LocalizedText,
159 pub open_new: LocalizedText,
161}
162
163impl AskCopy {
164 #[must_use]
166 pub fn standard() -> Self {
167 crate::copy::ServerCopy::translated(Self::english(), "it", ITALIAN)
168 }
169
170 #[must_use]
172 pub fn english() -> Self {
173 Self {
174 value: LocalizedText::new("What should the {what} be?"),
175 refused_value: LocalizedText::new("{because} What should the {what} be instead?"),
176 obligation: LocalizedText::new("Still needed: {what}."),
177 contested: LocalizedText::new("{what} What should it be instead?"),
178 elsewhere: LocalizedText::new("{record}: {question}"),
179 go_on: LocalizedText::new("What would you like to do next?"),
180 again: LocalizedText::new(
181 "I still need this to go on. {question} Or tell me what else you would like to do.",
182 ),
183 open_new: LocalizedText::new("Open a new {noun}."),
184 }
185 }
186}
187
188impl Default for AskCopy {
189 fn default() -> Self {
190 Self::standard()
191 }
192}
193
194crate::copy::server_copy!(
195 AskCopy,
196 [
197 value,
198 refused_value,
199 obligation,
200 contested,
201 elsewhere,
202 go_on,
203 again,
204 open_new
205 ]
206);
207
208const ITALIAN: &[(&str, &str)] = &[
210 ("value", "Che cosa metto come {what}?"),
211 (
212 "refused_value",
213 "{because} Che cosa metto come {what}, invece?",
214 ),
215 ("obligation", "Manca ancora: {what}."),
216 ("contested", "{what} Come dovrebbe essere, invece?"),
217 ("elsewhere", "{record}: {question}"),
218 ("go_on", "Cosa vuoi fare adesso?"),
219 (
220 "again",
221 "Mi serve ancora per andare avanti. {question} Oppure dimmi cos'altro vuoi fare.",
222 ),
223 ("open_new", "Aprire un nuovo {noun}."),
224];
225
226fn fill(template: &LocalizedText, locale: &Locale, pairs: &[(&str, &str)]) -> String {
227 let mut text = template.resolve(locale).to_owned();
228 for (key, value) in pairs {
229 text = text.replace(&format!("{{{key}}}"), value);
230 }
231 text.trim().to_owned()
232}
233
234pub(crate) struct Material<'a> {
236 pub receipts: &'a [OperationalReceipt],
237 pub facts: &'a [NarratableFact],
238 pub interactions: &'a [Interaction],
239 pub open_cards: &'a [Interaction],
241 pub views: &'a [ErasedWorkflowView],
242 pub touched: &'a [CaseKey],
244 pub beside: &'a [CaseKey],
246 pub labels: &'a [CaseLabel],
247 pub disputes: &'a [String],
248 pub started: &'a [turnframe_core::ids::WorkflowKey],
249 pub contested: &'a [String],
250 pub next_steps: &'a [(CaseRef, Vec<turnframe_core::flow::NextStep>)],
252 pub asked_before: &'a [Expectation],
254 pub openings: &'a [(CaseRef, turnframe_core::ids::OperationKey, String)],
257 pub locale: &'a Locale,
258 pub copy: &'a AskCopy,
259}
260
261impl Material<'_> {
262 fn label(&self, case_ref: &CaseRef) -> Option<String> {
263 self.labels
264 .iter()
265 .find(|label| label.case_ref.key() == case_ref.key())
266 .map(|label| label.label.clone())
267 }
268
269 fn ask(&self) -> Option<Ask> {
273 if let Some(contested) = self.contested.first() {
274 return Some(Ask {
275 record: None,
276 what: contested.clone(),
277 because: None,
278 question: fill(&self.copy.contested, self.locale, &[("what", contested)]),
279 again: false,
280 elsewhere: false,
281 expectation: None,
282 about: None,
283 });
284 }
285 let waiting = self.facts.iter().find_map(|fact| match fact {
286 NarratableFact::ValueNeeded {
287 case_ref,
288 operation,
289 arguments,
290 reason,
291 } => Some((
292 case_ref.clone(),
293 operation.clone(),
294 arguments.join(", "),
295 reason.clone(),
296 )),
297 _ => None,
298 });
299 if let Some((case_ref, operation, what, because)) = waiting {
300 let question = match &because {
301 Some(because) => fill(
302 &self.copy.refused_value,
303 self.locale,
304 &[("what", &what), ("because", because)],
305 ),
306 None => fill(&self.copy.value, self.locale, &[("what", &what)]),
307 };
308 return Some(Ask {
309 record: case_ref.as_ref().and_then(|case_ref| self.label(case_ref)),
310 about: case_ref.map(|case_ref| (case_ref.key(), operation)),
311 what,
312 because,
313 question,
314 again: false,
315 elsewhere: false,
316 expectation: None,
318 });
319 }
320 let reached = |case_ref: &CaseRef| self.touched.contains(&case_ref.key());
321 let card_next = self
322 .interactions
323 .iter()
324 .chain(self.open_cards)
325 .any(|card| card.blocking && reached(&card.case_ref))
326 || self
327 .views
328 .iter()
329 .any(|view| view.blocking_interaction.is_some() && reached(&view.case_ref));
330 if card_next {
331 return None;
332 }
333 let (view, obligation) = self.touched.iter().chain(self.beside).find_map(|key| {
334 let view = self.views.iter().find(|view| view.case_ref.key() == *key)?;
335 Some((view, view.obligations.first()?))
336 })?;
337 let (what, question) = match &obligation.sentence {
339 Some(sentence) => {
340 let sentence = sentence.resolve(self.locale).to_owned();
341 (sentence.clone(), sentence)
342 }
343 None => {
344 let what = obligation_words(&obligation.value);
345 let question = fill(&self.copy.obligation, self.locale, &[("what", &what)]);
346 (what, question)
347 }
348 };
349 let expectation = match &obligation.act {
352 Some(act) => Expectation::AwaitingOperation {
353 case_ref: view.case_ref.clone(),
354 obligation: what.clone(),
355 act: act.clone(),
356 },
357 None => Expectation::AwaitingObligation {
358 case_ref: view.case_ref.clone(),
359 obligation: what.clone(),
360 },
361 };
362 let record = self.label(&view.case_ref);
363 let elsewhere = !reached(&view.case_ref) && record.is_some();
365 let question = match record.as_deref().filter(|_| elsewhere) {
366 Some(label) => fill(
367 &self.copy.elsewhere,
368 self.locale,
369 &[("record", label), ("question", &question)],
370 ),
371 None => question,
372 };
373 Some(Ask {
374 record,
375 question,
376 elsewhere,
377 about: Some((view.case_ref.key(), what.clone())),
378 expectation: Some(expectation),
379 what,
380 because: None,
381 again: false,
382 })
383 }
384
385 pub fn standing(&self, keys: &[CaseKey]) -> Vec<Standing> {
387 keys.iter()
388 .filter_map(|key| {
389 let view = self.views.iter().find(|view| view.case_ref.key() == *key)?;
390 let holds: Vec<String> = view
391 .state
392 .iter()
393 .map(|field| {
394 format!(
395 "{}: {}",
396 field.field.replace('_', " "),
397 spoken(&field.value)
398 )
399 })
400 .collect();
401 let needs: Vec<String> = view
402 .obligations
403 .iter()
404 .map(|obligation| match &obligation.sentence {
405 Some(sentence) => sentence.resolve(self.locale).to_owned(),
406 None => obligation_words(&obligation.value),
407 })
408 .collect();
409 (!holds.is_empty() || !needs.is_empty()).then(|| Standing {
410 record: self
411 .label(&view.case_ref)
412 .unwrap_or_else(|| view.case_ref.case_id.to_string()),
413 holds,
414 needs,
415 })
416 })
417 .collect()
418 }
419
420 pub fn outcome(&self) -> TurnOutcome {
422 let locale = self.locale;
423 let done = self
424 .receipts
425 .iter()
426 .map(|receipt| {
427 format!(
428 "{}: {}",
429 receipt.title.resolve(locale),
430 receipt.body.resolve(locale)
431 )
432 })
433 .collect();
434 let not_done = self.facts.iter().filter_map(not_done).collect();
435 let shown = self.interactions.first().or_else(|| {
437 self.open_cards
438 .iter()
439 .find(|card| card.blocking && self.touched.contains(&card.case_ref.key()))
440 });
441 let card = shown.map(|card| {
442 let options: Vec<&str> = card
443 .payload
444 .options
445 .iter()
446 .map(|option| option.label.resolve(locale))
447 .collect();
448 let about = self
449 .touched
450 .contains(&card.case_ref.key())
451 .then(|| self.label(&card.case_ref))
452 .flatten()
453 .map(|label| format!("{label}: "))
454 .unwrap_or_default();
455 format!(
456 "{about}{} ({})",
457 card.payload.title.resolve(locale),
458 options.join(" / ")
459 )
460 });
461 let mut ask = self.ask();
462 if let Some(ask) = ask.as_mut().filter(|ask| ask.because.is_none()) {
464 ask.again = ask.about.as_ref().is_some_and(|about| {
465 self.asked_before
466 .iter()
467 .any(|before| asked(before).as_ref() == Some(about))
468 });
469 if ask.again {
470 ask.question = fill(&self.copy.again, locale, &[("question", &ask.question)]);
471 }
472 }
473 let open = ask.as_ref().is_none_or(|ask| ask.again);
474 let offers: Vec<turnframe_core::response::Offer> = if open && card.is_none() {
475 self.touched
476 .iter()
477 .find_map(|key| {
478 self.next_steps
479 .iter()
480 .find(|(case, steps)| case.key() == *key && !steps.is_empty())
481 })
482 .map(|(case, steps)| {
483 steps
484 .iter()
485 .map(|step| turnframe_core::response::Offer {
486 case_ref: case.clone(),
487 operation: step.operation.clone(),
488 words: step.words.resolve(locale).to_owned(),
489 arguments: step.arguments.clone(),
490 })
491 .collect()
492 })
493 .unwrap_or_default()
494 } else {
495 Vec::new()
496 };
497 let mut offers = offers;
498 if open && card.is_none() {
499 offers.extend(self.openings.iter().map(|(case_ref, operation, noun)| {
500 turnframe_core::response::Offer {
501 case_ref: case_ref.clone(),
502 operation: operation.clone(),
503 words: fill(&self.copy.open_new, locale, &[("noun", noun)]),
504 arguments: serde_json::Map::new(),
505 }
506 }));
507 }
508 let next: Vec<String> = offers.iter().map(|offer| offer.words.clone()).collect();
509 let closing = (ask.is_none() && card.is_none() && next.is_empty())
510 .then(|| self.copy.go_on.resolve(locale).to_owned());
511 TurnOutcome {
512 done,
513 not_done,
514 disputes: self.disputes.to_vec(),
515 starting: self.started.iter().map(ToString::to_string).collect(),
516 ask,
517 card,
518 next,
519 closing,
520 offers,
521 standing: Vec::new(),
522 }
523 }
524}
525
526fn spoken(value: &serde_json::Value) -> String {
528 match value {
529 serde_json::Value::String(text) => text.clone(),
530 serde_json::Value::Array(items) => items.iter().map(spoken).collect::<Vec<_>>().join("; "),
531 other => other.to_string(),
532 }
533}
534
535fn obligation_words(value: &serde_json::Value) -> String {
538 let name = match value {
539 serde_json::Value::String(name) => Some(name.as_str()),
540 serde_json::Value::Object(map) => map.keys().next().map(String::as_str),
541 _ => None,
542 };
543 name.map_or_else(|| value.to_string(), |name| name.replace('_', " "))
544}
545
546fn not_done(fact: &NarratableFact) -> Option<String> {
548 Some(match fact {
549 NarratableFact::ActRefused {
550 explanation, code, ..
551 } => {
552 if explanation.is_empty() {
553 format!("Refused ({code}).")
554 } else {
555 explanation.clone()
556 }
557 }
558 NarratableFact::ActChangedNothing { explanation, .. } if !explanation.is_empty() => {
559 explanation.clone()
560 }
561 NarratableFact::ActChangedNothing { operation, .. } => format!(
562 "Nothing changed: {} was already so.",
563 operation.as_deref().unwrap_or("what was asked")
564 ),
565 NarratableFact::InstructionDeclined {
566 question,
567 option_label,
568 ..
569 } => format!("The user answered «{question}» with «{option_label}»."),
570 NarratableFact::ActHeld { operation, because } => {
571 format!("{operation} waits: «{because}» was not understood.")
572 }
573 NarratableFact::NotUnderstood { words } => format!("Not understood: «{words}»."),
574 NarratableFact::WorkflowUnavailable { reason, .. } => reason.clone(),
575 NarratableFact::AttachmentNotShown {
576 filename, reason, ..
577 } => format!(
578 "The file {} was not read: {reason}",
579 filename.as_deref().unwrap_or("attached")
580 ),
581 _ => return None,
582 })
583}
584
585#[cfg(test)]
586mod tests {
587 use super::*;
588 use turnframe_core::ids::CaseRevision;
589
590 fn material<'a>(
591 facts: &'a [NarratableFact],
592 copy: &'a AskCopy,
593 locale: &'a Locale,
594 ) -> Material<'a> {
595 Material {
596 receipts: &[],
597 facts,
598 interactions: &[],
599 open_cards: &[],
600 views: &[],
601 touched: &[],
602 beside: &[],
603 labels: &[],
604 disputes: &[],
605 started: &[],
606 contested: &[],
607 next_steps: &[],
608 asked_before: &[],
609 openings: &[],
610 locale,
611 copy,
612 }
613 }
614
615 #[test]
616 fn an_ask_the_last_reply_asked_says_so_and_offers_the_next_steps_beside_it() {
617 let case_ref = CaseRef::new("sample", "s-1", CaseRevision(2));
618 let view = ErasedWorkflowView {
619 case_ref: case_ref.clone(),
620 workflow_version: turnframe_core::ids::WorkflowVersion::from("1"),
621 phase: serde_json::json!("collecting"),
622 phase_ownership: turnframe_core::flow::PhaseOwnership::User,
623 obligations: vec![turnframe_core::flow::ErasedObligation {
624 id: turnframe_core::flow::ObligationId("\"a\"".to_owned()),
625 value: serde_json::json!("a"),
626 sentence: Some(LocalizedText::new("What is A?")),
627 act: None,
628 }],
629 blocking_interaction: None,
630 notices: Vec::new(),
631 outcome: None,
632 state: Vec::new(),
633 };
634 let copy = AskCopy::english();
635 let locale = Locale::from("en-GB");
636 let views = [view];
637 let touched = [case_ref.key()];
638 let next_steps = [(
639 case_ref.clone(),
640 vec![turnframe_core::flow::NextStep::new(
641 "sample.add",
642 LocalizedText::new("Add another item."),
643 )],
644 )];
645 let before = [Expectation::AwaitingObligation {
646 case_ref: CaseRef::new("sample", "s-1", CaseRevision(1)),
647 obligation: "What is A?".to_owned(),
648 }];
649 let mut material = material(&[], ©, &locale);
650 material.views = &views;
651 material.touched = &touched;
652 material.next_steps = &next_steps;
653
654 let first = material.outcome();
655 assert!(!first.ask.as_ref().expect("an ask").again);
656 assert!(first.next.is_empty(), "what is owed comes first");
657
658 material.asked_before = &before;
659 let again = material.outcome();
660 let ask = again.ask.expect("the same ask");
661 assert!(ask.again);
662 assert_eq!(
663 ask.question,
664 "I still need this to go on. What is A? Or tell me what else you would like to do."
665 );
666 assert_eq!(again.next, vec!["Add another item."]);
667 }
668
669 #[test]
670 fn a_record_stands_as_what_it_holds_and_what_it_still_needs() {
671 let case_ref = CaseRef::new("sample", "s-1", CaseRevision(1));
672 let view = ErasedWorkflowView {
673 case_ref: case_ref.clone(),
674 workflow_version: turnframe_core::ids::WorkflowVersion::from("1"),
675 phase: serde_json::json!("collecting"),
676 phase_ownership: turnframe_core::flow::PhaseOwnership::User,
677 obligations: vec![turnframe_core::flow::ErasedObligation {
678 id: turnframe_core::flow::ObligationId("\"b\"".to_owned()),
679 value: serde_json::json!("set_b"),
680 sentence: None,
681 act: None,
682 }],
683 blocking_interaction: None,
684 notices: Vec::new(),
685 outcome: None,
686 state: vec![
687 turnframe_core::flow::StateField::new("the_a", serde_json::json!("X")),
688 turnframe_core::flow::StateField::new("items", serde_json::json!(["one", "two"])),
689 ],
690 };
691 let copy = AskCopy::english();
692 let locale = Locale::from("en-GB");
693 let views = [view];
694 let labels = [CaseLabel {
695 case_ref: case_ref.clone(),
696 label: "S 1".to_owned(),
697 }];
698 let mut material = material(&[], ©, &locale);
699 material.views = &views;
700 material.labels = &labels;
701 let standing = material.standing(&[case_ref.key()]);
702 assert_eq!(
703 standing,
704 vec![Standing {
705 record: "S 1".to_owned(),
706 holds: vec!["the a: X".to_owned(), "items: one; two".to_owned()],
707 needs: vec!["set b".to_owned()],
708 }]
709 );
710 assert_eq!(
711 standing[0].line().as_deref(),
712 Some("S 1: the a: X, items: one; two.")
713 );
714 }
715
716 #[test]
717 fn a_value_asked_again_after_a_refusal_gives_the_refusal_as_its_reason() {
718 let case_ref = CaseRef::new("trip", "trip-1", CaseRevision(1));
719 let facts = [NarratableFact::ValueNeeded {
720 case_ref: Some(case_ref.clone()),
721 operation: "trip.set_name".to_owned(),
722 arguments: vec!["subject".to_owned()],
723 reason: Some("Too long.".to_owned()),
724 }];
725 let act = crate::resume::card_act(
726 turnframe_core::understanding::ActAction::Apply {
727 operation: "trip.set_name".into(),
728 },
729 turnframe_core::understanding::ActTarget::Card,
730 );
731 let before = [Expectation::AwaitingValue {
732 act: Box::new(act),
733 case_ref: Some(case_ref),
734 missing: vec!["subject".to_owned()],
735 }];
736 let copy = AskCopy::english();
737 let locale = Locale::from("en-GB");
738 let mut material = material(&facts, ©, &locale);
739 material.asked_before = &before;
740 let ask = material.outcome().ask.expect("an ask");
741 assert!(!ask.again, "the refusal is the reason already given");
742 assert_eq!(
743 ask.question,
744 "Too long. What should the subject be instead?"
745 );
746 }
747
748 #[test]
749 fn a_record_that_needs_nothing_more_offers_its_next_steps() {
750 let view = |obligations| ErasedWorkflowView {
751 case_ref: CaseRef::new("sample", "s-1", CaseRevision(1)),
752 workflow_version: turnframe_core::ids::WorkflowVersion::from("1"),
753 phase: serde_json::json!("collecting"),
754 phase_ownership: turnframe_core::flow::PhaseOwnership::User,
755 obligations,
756 blocking_interaction: None,
757 notices: Vec::new(),
758 outcome: None,
759 state: Vec::new(),
760 };
761 let owed = turnframe_core::flow::ErasedObligation {
762 id: turnframe_core::flow::ObligationId("\"a\"".to_owned()),
763 value: serde_json::json!("a"),
764 sentence: Some(LocalizedText::new("What is A?")),
765 act: None,
766 };
767 let copy = AskCopy::english();
768 let locale = Locale::from("en-GB");
769 let touched = [CaseRef::new("sample", "s-1", CaseRevision(1)).key()];
770 let next_steps = [(
771 CaseRef::new("sample", "s-1", CaseRevision(1)),
772 vec![
773 turnframe_core::flow::NextStep::new(
774 "sample.add",
775 LocalizedText::new("Add another item."),
776 ),
777 turnframe_core::flow::NextStep::new("sample.send", LocalizedText::new("Send it.")),
778 ],
779 )];
780
781 let complete = [view(Vec::new())];
782 let mut material = material(&[], ©, &locale);
783 material.views = &complete;
784 material.touched = &touched;
785 material.next_steps = &next_steps;
786 let outcome = material.outcome();
787 assert_eq!(outcome.ask, None);
788 assert_eq!(outcome.next, vec!["Add another item.", "Send it."]);
789
790 let open = [view(vec![owed])];
791 material.views = &open;
792 let outcome = material.outcome();
793 assert!(outcome.ask.is_some());
794 assert!(outcome.next.is_empty(), "what is owed comes first");
795 }
796
797 #[test]
798 fn a_waiting_value_is_the_ask_and_code_writes_its_question() {
799 let facts = [NarratableFact::ValueNeeded {
800 case_ref: Some(CaseRef::new("trip", "trip-1", CaseRevision(1))),
801 operation: "trip.set_name".to_owned(),
802 arguments: vec!["subject".to_owned()],
803 reason: None,
804 }];
805 let copy = AskCopy::english();
806 let locale = Locale::from("en-GB");
807 let outcome = material(&facts, ©, &locale).outcome();
808 let ask = outcome.ask.expect("an ask");
809 assert_eq!(ask.what, "subject");
810 assert_eq!(ask.question, "What should the subject be?");
811 assert!(
812 outcome.not_done.is_empty(),
813 "a missing value is asked for, not reported"
814 );
815 }
816
817 #[test]
818 fn an_obligation_without_a_sentence_is_never_asked_by_its_code() {
819 let case_ref = CaseRef::new("trip", "trip-2", CaseRevision(1));
820 let view = ErasedWorkflowView {
821 case_ref: case_ref.clone(),
822 workflow_version: turnframe_core::ids::WorkflowVersion::from("1"),
823 phase: serde_json::json!("collecting"),
824 phase_ownership: turnframe_core::flow::PhaseOwnership::User,
825 obligations: vec![turnframe_core::flow::ErasedObligation {
826 id: turnframe_core::flow::ObligationId("\"select_traveler\"".to_owned()),
827 value: serde_json::json!("select_traveler"),
828 sentence: None,
829 act: None,
830 }],
831 blocking_interaction: None,
832 notices: Vec::new(),
833 outcome: None,
834 state: Vec::new(),
835 };
836 let copy = AskCopy::english();
837 let locale = Locale::from("en-GB");
838 let views = [view];
839 let touched = [case_ref.key()];
840 let mut material = material(&[], ©, &locale);
841 material.views = &views;
842 material.touched = &touched;
843 let ask = material
844 .outcome()
845 .ask
846 .expect("the open obligation is asked for");
847 assert_eq!(ask.question, "Still needed: select traveler.");
848 }
849
850 #[test]
851 fn a_card_on_screen_is_what_comes_next() {
852 let obligation = |case_id: &str, requirement| ErasedWorkflowView {
853 case_ref: CaseRef::new("sample", case_id, CaseRevision(1)),
854 workflow_version: turnframe_core::ids::WorkflowVersion::from("1"),
855 phase: serde_json::json!("collecting"),
856 phase_ownership: turnframe_core::flow::PhaseOwnership::User,
857 obligations: vec![turnframe_core::flow::ErasedObligation {
858 id: turnframe_core::flow::ObligationId("\"a\"".to_owned()),
859 value: serde_json::json!("a"),
860 sentence: Some(LocalizedText::new("What is A?")),
861 act: None,
862 }],
863 blocking_interaction: requirement,
864 notices: Vec::new(),
865 outcome: None,
866 state: Vec::new(),
867 };
868 let carded = obligation(
869 "s-1",
870 Some(turnframe_core::flow::InteractionRequirement::blocking(
871 "confirm",
872 turnframe_core::interaction::InteractionKind::Boolean,
873 )),
874 );
875 let other = obligation("s-2", None);
876 let touched = [carded.case_ref.key()];
877 let beside = [other.case_ref.key()];
878 let views = [carded, other];
879 let copy = AskCopy::english();
880 let locale = Locale::from("en-GB");
881 let mut material = material(&[], ©, &locale);
882 material.views = &views;
883 material.touched = &touched;
884 material.beside = &beside;
885 assert_eq!(material.outcome().ask, None);
886 }
887
888 fn owing(case_id: &str, owed: bool) -> ErasedWorkflowView {
889 ErasedWorkflowView {
890 case_ref: CaseRef::new("sample", case_id, CaseRevision(1)),
891 workflow_version: turnframe_core::ids::WorkflowVersion::from("1"),
892 phase: serde_json::json!("collecting"),
893 phase_ownership: turnframe_core::flow::PhaseOwnership::User,
894 obligations: owed
895 .then(|| turnframe_core::flow::ErasedObligation {
896 id: turnframe_core::flow::ObligationId("\"a\"".to_owned()),
897 value: serde_json::json!("a"),
898 sentence: Some(LocalizedText::new("What is A?")),
899 act: None,
900 })
901 .into_iter()
902 .collect(),
903 blocking_interaction: None,
904 notices: Vec::new(),
905 outcome: None,
906 state: Vec::new(),
907 }
908 }
909
910 #[test]
911 fn an_obligation_of_a_record_the_turn_did_not_reach_names_that_record() {
912 let views = [owing("s-1", false), owing("s-2", true)];
913 let touched = [views[0].case_ref.key()];
914 let beside = [views[1].case_ref.key()];
915 let labels = [CaseLabel {
916 case_ref: views[1].case_ref.clone(),
917 label: "Sample 2".to_owned(),
918 }];
919 let copy = AskCopy::english();
920 let locale = Locale::from("en-GB");
921 let mut material = material(&[], ©, &locale);
922 material.views = &views;
923 material.touched = &touched;
924 material.beside = &beside;
925 material.labels = &labels;
926 let ask = material
927 .outcome()
928 .ask
929 .expect("the other record's obligation");
930 assert!(ask.elsewhere);
931 assert_eq!(ask.question, "Sample 2: What is A?");
932 }
933
934 #[test]
935 fn an_obligation_of_a_record_the_turn_reached_is_asked_as_written() {
936 let views = [owing("s-1", true)];
937 let touched = [views[0].case_ref.key()];
938 let copy = AskCopy::english();
939 let locale = Locale::from("en-GB");
940 let mut material = material(&[], ©, &locale);
941 material.views = &views;
942 material.touched = &touched;
943 let ask = material.outcome().ask.expect("its obligation");
944 assert!(!ask.elsewhere);
945 assert_eq!(ask.question, "What is A?");
946 }
947
948 #[test]
949 fn nothing_to_say_is_silence() {
950 let copy = AskCopy::english();
951 let locale = Locale::from("en-GB");
952 assert!(material(&[], ©, &locale).outcome().is_silent());
953 }
954}