Skip to main content

turnframe_runtime/narrate/
outcome.rs

1//! What a turn did, gathered by code into the one document the acknowledgement is
2//! written from, with the single thing to ask for next.
3
4use 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/// The turn as the acknowledgement may state it.
13#[derive(Debug, Clone, Default, PartialEq, Serialize)]
14pub(crate) struct TurnOutcome {
15    /// What was done, each as its receipt says it.
16    #[serde(skip_serializing_if = "Vec::is_empty")]
17    pub done: Vec<String>,
18    /// What was not done, each with its reason.
19    #[serde(skip_serializing_if = "Vec::is_empty")]
20    pub not_done: Vec<String>,
21    /// What the user said the assistant got wrong.
22    #[serde(skip_serializing_if = "Vec::is_empty")]
23    pub disputes: Vec<String>,
24    /// Workflows the turn started without writing anything yet.
25    #[serde(skip_serializing_if = "Vec::is_empty")]
26    pub starting: Vec<String>,
27    /// The one thing to ask for next.
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub ask: Option<Ask>,
30    /// The card on screen, when there is one.
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub card: Option<String>,
33    /// What the user may do next, when the record needs nothing more.
34    #[serde(skip_serializing_if = "Vec::is_empty")]
35    pub next: Vec<String>,
36    /// The question a reply ends on when nothing is asked, no card is on screen and nothing
37    /// comes next: no reply leaves the user without a way forward.
38    #[serde(skip)]
39    pub closing: Option<String>,
40    /// The next steps as the operations they run, recorded on the turn as its offers.
41    #[serde(skip)]
42    pub offers: Vec<turnframe_core::response::Offer>,
43    /// Where the records of questions no fact answered stand.
44    #[serde(skip_serializing_if = "Vec::is_empty")]
45    pub standing: Vec<Standing>,
46}
47
48/// Where a record stands: what it holds and what it still needs, as its workflow states them.
49#[derive(Debug, Clone, PartialEq, Serialize)]
50pub(crate) struct Standing {
51    /// The record, by its label.
52    pub record: String,
53    /// What it holds, each as «field: value».
54    #[serde(skip_serializing_if = "Vec::is_empty")]
55    pub holds: Vec<String>,
56    /// What it still needs.
57    #[serde(skip_serializing_if = "Vec::is_empty")]
58    pub needs: Vec<String>,
59}
60
61impl Standing {
62    /// What it holds, as one line code may say; what it needs is the reply's ask.
63    pub fn line(&self) -> Option<String> {
64        (!self.holds.is_empty()).then(|| format!("{}: {}.", self.record, self.holds.join(", ")))
65    }
66}
67
68impl TurnOutcome {
69    /// Whether there is nothing to say: then no acknowledgement is written at all.
70    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    /// Whether all the reply has to say is what it asks next.
81    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/// The one thing the reply asks for, chosen by code.
93#[derive(Debug, Clone, PartialEq, Serialize)]
94pub(crate) struct Ask {
95    /// The record it is about, by its label.
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub record: Option<String>,
98    /// What to ask for: the argument's words or the obligation's sentence.
99    pub what: String,
100    /// Why the value is needed again, when the domain refused the one given.
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub because: Option<String>,
103    /// Whether the last reply asked the same of the same record, and nothing refused it.
104    #[serde(skip_serializing_if = "std::ops::Not::not")]
105    pub again: bool,
106    /// The question the reply asks when no model writes one, and the model rewords.
107    pub question: String,
108    /// Whether it is about a record the turn did not reach, so the question names it.
109    #[serde(skip)]
110    pub elsewhere: bool,
111    /// What the next turn expects, recorded once the reply is out.
112    #[serde(skip)]
113    pub expectation: Option<Expectation>,
114    /// Its record and the obligation or operation it waits on, to match the last reply's.
115    #[serde(skip)]
116    pub about: Option<(CaseKey, String)>,
117}
118
119/// What an expectation asked, in the terms of [`Ask::about`].
120fn 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/// Copy for the questions code writes when no acknowledgement does.
141#[derive(Debug, Clone, PartialEq, Eq)]
142#[non_exhaustive]
143pub struct AskCopy {
144    /// A missing value; `{what}` is replaced by its label.
145    pub value: LocalizedText,
146    /// A value the domain refused; `{what}` is its label, `{because}` the reason.
147    pub refused_value: LocalizedText,
148    /// An open obligation the workflow gave no sentence; `{what}` is its name.
149    pub obligation: LocalizedText,
150    /// A receipt the user contested; `{what}` is the receipt as it was shown.
151    pub contested: LocalizedText,
152    /// A question about a record the turn did not reach; `{record}` is its label and
153    /// `{question}` the question.
154    pub elsewhere: LocalizedText,
155    /// The question a reply ends on when it asks nothing else.
156    pub go_on: LocalizedText,
157    /// A question the last reply asked too; `{question}` is the question.
158    pub again: LocalizedText,
159    /// The offer to open a record none of exists; `{noun}` is what one is called.
160    pub open_new: LocalizedText,
161}
162
163impl AskCopy {
164    /// The built-in copy: English, with Italian.
165    #[must_use]
166    pub fn standard() -> Self {
167        crate::copy::ServerCopy::translated(Self::english(), "it", ITALIAN)
168    }
169
170    /// English alone.
171    #[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
208/// The built-in Italian of [`AskCopy`], by field.
209const 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
234/// What an outcome is gathered from.
235pub(crate) struct Material<'a> {
236    pub receipts: &'a [OperationalReceipt],
237    pub facts: &'a [NarratableFact],
238    pub interactions: &'a [Interaction],
239    /// Cards earlier turns left open.
240    pub open_cards: &'a [Interaction],
241    pub views: &'a [ErasedWorkflowView],
242    /// The cases an act of this turn reached, in act order.
243    pub touched: &'a [CaseKey],
244    /// Other cases in view, asked about once the touched ones need nothing.
245    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    /// What each case lets the user do next once it owes nothing, by case.
251    pub next_steps: &'a [(CaseRef, Vec<turnframe_core::flow::NextStep>)],
252    /// What the last reply asked.
253    pub asked_before: &'a [Expectation],
254    /// Records to offer to open, none existing: the record still to create, the operation
255    /// opening one, and what one is called.
256    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    /// A receipt the user contested, else the first value an act is waiting for, else
270    /// the first open obligation of a record the turn touched. A card on a record the turn
271    /// reached is what comes next, so no obligation is asked beside it.
272    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                // The waiting act is recorded as its own expectation already.
317                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        // The workflow's sentence is the question; without one, its name is read out.
338        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        // An obligation whose workflow names the act that answers it is awaited as that
350        // act, so a bare answer completes it.
351        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        // A record the turn did not reach is not the one the reply talks about: name it.
364        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    /// Where each of `keys` stands, for the ones in view with anything to say.
386    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    /// Gathers the outcome.
421    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        // A card this turn raised, else one an earlier turn left open on a record it reached.
436        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        // Asked again with no refusal to explain it: the reply says so and offers the rest.
463        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
526/// A value as words: a string as it is, a list as its items.
527fn 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
535/// An obligation's name in words: a string, or an object's one tag, with its
536/// underscores read as spaces.
537fn 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
546/// What a fact says was not done, and why, as a sentence the reply may restate.
547fn 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(&[], &copy, &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(&[], &copy, &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, &copy, &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(&[], &copy, &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, &copy, &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(&[], &copy, &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(&[], &copy, &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(&[], &copy, &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(&[], &copy, &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(&[], &copy, &locale).outcome().is_silent());
953    }
954}