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