Skip to main content

turnframe_eval/
assertions.rs

1//! Deterministic assertions: the primary mechanism, and no model is involved
2//! (spec §27.6).
3//!
4//! The specification is explicit that these checks must not be delegated to a
5//! judge, and the reason is worth restating: a judge asked "did this turn send
6//! the rebooking?" answers from the text of the reply, and the text of the reply
7//! is precisely the thing that can be wrong. Whether an effect happened is a
8//! fact about the command journal and the event ledger, and reading it costs
9//! nothing.
10//!
11//! Nine expectations are checked here, matching the list of §27.6: the
12//! normalized acts, the target resolution, the compiled commands, the emitted
13//! events, the case revision, the interaction status, the response block types,
14//! the turn's outcome, and — the one that is a claim about absence — the
15//! forbidden effects.
16//!
17//! A tenth is not an expectation an item writes, and exists so the other nine
18//! stay honest: an observation whose event ledger was cut short by a configured
19//! bound fails every item that claims anything about events, because a
20//! forbidden event hiding in the half nobody read would otherwise report a
21//! green safety row.
22//!
23//! A failure carries the expectation it belongs to, what was expected, and what
24//! actually happened, so the message alone is enough to act on:
25//!
26//! ```text
27//! commands: expected [trip.set_travel_date], got [trip.set_name]
28//! ```
29
30use std::fmt;
31
32use turnframe_core::case::CaseKey;
33use turnframe_core::interaction::InteractionStatus;
34
35use crate::corpus::{
36    ActExpectation, Expectations, InteractionStatusExpectation, OutcomeExpectation,
37    RevisionExpectation, StateExpectation, TargetExpectation,
38};
39use crate::observation::Observation;
40
41/// Which of the §27.6 expectations a failure belongs to.
42///
43/// The report groups by this, and the reliability categories of §26.3 are
44/// derived from it, so a forbidden command that appeared is never averaged into
45/// a language score.
46#[derive(
47    Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
48)]
49#[serde(rename_all = "snake_case")]
50#[non_exhaustive]
51pub enum ExpectationName {
52    /// The turn completed, or failed, as the item said it would.
53    Outcome,
54    /// What a case's state holds, or still holds, afterwards.
55    CaseState,
56    /// What some case of a workflow holds afterwards.
57    WorkflowState,
58    /// How many cases of a workflow exist afterwards.
59    CaseCount,
60    /// The acts the message was understood to ask for.
61    Acts,
62    /// How an act's target resolved.
63    TargetResolution,
64    /// The commands that were compiled and journaled.
65    Commands,
66    /// A command that must not appear, and did.
67    ForbiddenCommand,
68    /// The events that were committed.
69    Events,
70    /// An event that must not appear, and did.
71    ForbiddenEvent,
72    /// The event ledger was read under a configured bound and cut short, so
73    /// nothing this item claims about events was actually checked against the
74    /// whole of it.
75    TruncatedLedger,
76    /// The revision a case ended at.
77    CaseRevision,
78    /// The status of a case's cards.
79    InteractionStatus,
80    /// The kinds of response block.
81    ResponseBlocks,
82    /// The phase the turn finished in.
83    TurnPhase,
84}
85
86impl ExpectationName {
87    /// The snake-case label used in reports and machine-readable output.
88    #[must_use]
89    pub const fn as_str(self) -> &'static str {
90        match self {
91            Self::Outcome => "outcome",
92            Self::Acts => "acts",
93            Self::TargetResolution => "target_resolution",
94            Self::Commands => "commands",
95            Self::ForbiddenCommand => "forbidden_command",
96            Self::Events => "events",
97            Self::ForbiddenEvent => "forbidden_event",
98            Self::TruncatedLedger => "truncated_ledger",
99            Self::CaseRevision => "case_revision",
100            Self::CaseState => "case_state",
101            Self::WorkflowState => "workflow_state",
102            Self::CaseCount => "case_count",
103            Self::InteractionStatus => "interaction_status",
104            Self::ResponseBlocks => "response_blocks",
105            Self::TurnPhase => "turn_phase",
106        }
107    }
108}
109
110impl fmt::Display for ExpectationName {
111    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112        f.write_str(self.as_str())
113    }
114}
115
116/// One deterministic expectation that did not hold.
117#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
118#[serde(deny_unknown_fields)]
119pub struct AssertionFailure {
120    /// Which expectation.
121    pub expectation: ExpectationName,
122    /// What the item asked for.
123    pub expected: String,
124    /// What the run actually produced.
125    pub actual: String,
126    /// Which case the failure is about, when it is about one.
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub subject: Option<String>,
129}
130
131impl AssertionFailure {
132    /// Builds a failure.
133    #[must_use]
134    pub fn new(
135        expectation: ExpectationName,
136        expected: impl Into<String>,
137        actual: impl Into<String>,
138    ) -> Self {
139        Self {
140            expectation,
141            expected: expected.into(),
142            actual: actual.into(),
143            subject: None,
144        }
145    }
146
147    /// Names the case the failure is about.
148    #[must_use]
149    pub fn about(mut self, subject: impl Into<String>) -> Self {
150        self.subject = Some(subject.into());
151        self
152    }
153}
154
155impl fmt::Display for AssertionFailure {
156    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
157        write!(f, "{}", self.expectation)?;
158        if let Some(subject) = &self.subject {
159            write!(f, " [{subject}]")?;
160        }
161        write!(f, ": expected {}, got {}", self.expected, self.actual)
162    }
163}
164
165/// Checks every expectation of an item against one observed run.
166///
167/// Returns every failure, not the first one: an item that got the command wrong
168/// usually got the events and the revision wrong too, and seeing all three is
169/// how you tell one broken behaviour from three.
170#[must_use]
171pub fn check(expect: &Expectations, observed: &Observation) -> Vec<AssertionFailure> {
172    let mut failures = Vec::new();
173    check_outcome(&expect.outcome, observed, &mut failures);
174    check_acts(expect.acts.as_deref(), observed, &mut failures);
175    check_resolutions(&expect.target_resolution, observed, &mut failures);
176    check_state(&expect.case_state, observed, &mut failures);
177    check_workflow_state(&expect.workflow_state, observed, &mut failures);
178    check_case_count(&expect.case_count, observed, &mut failures);
179    check_sequence(
180        ExpectationName::Commands,
181        expect.commands.as_deref(),
182        &observed.commands,
183        &mut failures,
184    );
185    check_sequence(
186        ExpectationName::Events,
187        expect.events.as_deref(),
188        &observed.events,
189        &mut failures,
190    );
191    check_forbidden(
192        ExpectationName::ForbiddenCommand,
193        &expect.forbid.commands,
194        &observed.commands,
195        &mut failures,
196    );
197    check_forbidden(
198        ExpectationName::ForbiddenEvent,
199        &expect.forbid.events,
200        &observed.events,
201        &mut failures,
202    );
203    check_ledger(expect, observed, &mut failures);
204    check_revisions(&expect.case_revision, observed, &mut failures);
205    check_interactions(&expect.interaction_status, observed, &mut failures);
206    check_blocks(expect, observed, &mut failures);
207    check_phase(expect, observed, &mut failures);
208    failures
209}
210
211fn check_outcome(
212    expected: &OutcomeExpectation,
213    observed: &Observation,
214    failures: &mut Vec<AssertionFailure>,
215) {
216    let actual = observed.error_code.as_deref();
217    match (expected, actual) {
218        (OutcomeExpectation::Succeeds, None) | (OutcomeExpectation::Fails, Some(_)) => {}
219        (OutcomeExpectation::Succeeds, Some(code)) => failures.push(AssertionFailure::new(
220            ExpectationName::Outcome,
221            "the turn to complete",
222            format!("it failed with `{code}`"),
223        )),
224        (OutcomeExpectation::Fails, None) => failures.push(AssertionFailure::new(
225            ExpectationName::Outcome,
226            "the turn to fail",
227            "it completed",
228        )),
229        (OutcomeExpectation::FailsWith(code), Some(found)) if code.as_str() == found => {}
230        (OutcomeExpectation::FailsWith(code), found) => failures.push(AssertionFailure::new(
231            ExpectationName::Outcome,
232            format!("the turn to fail with `{code}`"),
233            found.map_or_else(|| "it completed".to_owned(), |code| format!("`{code}`")),
234        )),
235    }
236}
237
238fn check_acts(
239    expected: Option<&[ActExpectation]>,
240    observed: &Observation,
241    failures: &mut Vec<AssertionFailure>,
242) {
243    let Some(expected) = expected else {
244        return;
245    };
246    let rendered_actual = render(observed.acts.iter().map(|act| match &act.operation {
247        Some(operation) => format!("{}({operation})", act.kind),
248        None => act.kind.clone(),
249    }));
250    let rendered_expected = render(expected.iter().map(render_expected_act));
251
252    if expected.len() != observed.acts.len() {
253        failures.push(AssertionFailure::new(
254            ExpectationName::Acts,
255            rendered_expected,
256            rendered_actual,
257        ));
258        return;
259    }
260    for (want, got) in expected.iter().zip(&observed.acts) {
261        if !want.admits(&got.kind, got.operation.as_ref()) {
262            failures.push(AssertionFailure::new(
263                ExpectationName::Acts,
264                rendered_expected,
265                rendered_actual,
266            ));
267            return;
268        }
269    }
270}
271
272/// One position of the expected sequence, with every shape it admits.
273///
274/// Recursive, like `admits`: an alternative may carry alternatives of its own,
275/// and a shape the report leaves out is a shape a reader cannot know was
276/// accepted.
277fn render_expected_act(act: &ActExpectation) -> String {
278    let mut shapes = Vec::new();
279    collect_shapes(act, &mut shapes);
280    shapes.join(" | ")
281}
282
283fn collect_shapes(act: &ActExpectation, into: &mut Vec<String>) {
284    into.push(match &act.operation {
285        Some(operation) => format!("{}({operation})", act.kind),
286        None => act.kind.to_string(),
287    });
288    for alternative in &act.or {
289        collect_shapes(alternative, into);
290    }
291}
292
293fn check_resolutions(
294    expected: &[TargetExpectation],
295    observed: &Observation,
296    failures: &mut Vec<AssertionFailure>,
297) {
298    for want in expected {
299        let Some(got) = observed
300            .target_resolutions
301            .iter()
302            .find(|entry| entry.act_index == want.act_index)
303        else {
304            failures.push(
305                AssertionFailure::new(
306                    ExpectationName::TargetResolution,
307                    want.resolution.to_string(),
308                    "no resolution was recorded for that act".to_owned(),
309                )
310                .about(format!("act {}", want.act_index)),
311            );
312            continue;
313        };
314        let case_differs = want
315            .case_id
316            .as_ref()
317            .is_some_and(|case| Some(case) != got.case_id.as_ref());
318        if want.resolution.as_str() != got.resolution || case_differs {
319            failures.push(
320                AssertionFailure::new(
321                    ExpectationName::TargetResolution,
322                    describe_resolution(want.resolution.as_str(), want.case_id.as_ref()),
323                    describe_resolution(&got.resolution, got.case_id.as_ref()),
324                )
325                .about(format!("act {}", want.act_index)),
326            );
327        }
328    }
329}
330
331fn describe_resolution(kind: &str, case: Option<&turnframe_core::ids::CaseId>) -> String {
332    match case {
333        Some(case) => format!("{kind} on {case}"),
334        None => kind.to_owned(),
335    }
336}
337
338fn check_sequence(
339    name: ExpectationName,
340    expected: Option<&[String]>,
341    actual: &[String],
342    failures: &mut Vec<AssertionFailure>,
343) {
344    let Some(expected) = expected else {
345        return;
346    };
347    if expected != actual {
348        failures.push(AssertionFailure::new(
349            name,
350            render(expected.iter().cloned()),
351            render(actual.iter().cloned()),
352        ));
353    }
354}
355
356/// The assertion about absence (spec §27.6, "forbidden effects").
357fn check_forbidden(
358    name: ExpectationName,
359    forbidden: &[String],
360    actual: &[String],
361    failures: &mut Vec<AssertionFailure>,
362) {
363    for banned in forbidden {
364        if actual.contains(banned) {
365            failures.push(AssertionFailure::new(
366                name,
367                format!("`{banned}` never to appear"),
368                format!("it appeared in {}", render(actual.iter().cloned())),
369            ));
370        }
371    }
372}
373
374/// A bound on the ledger that actually bit is a failure, never a pass.
375///
376/// An item whose expectations are about events was measured against half a
377/// ledger, and the half nobody read is exactly where a forbidden event would
378/// hide. So the item fails, loudly, naming the bound instead of reporting a
379/// green safety row it never earned.
380fn check_ledger(
381    expect: &Expectations,
382    observed: &Observation,
383    failures: &mut Vec<AssertionFailure>,
384) {
385    if !observed.events_truncated {
386        return;
387    }
388    if expect.events.is_none() && expect.forbid.events.is_empty() {
389        return;
390    }
391    failures.push(AssertionFailure::new(
392        ExpectationName::TruncatedLedger,
393        "the whole event ledger of the turn",
394        format!(
395            "the first {} events only, because max_observed_events cut the read short",
396            observed.events.len()
397        ),
398    ));
399}
400
401fn check_revisions(
402    expected: &[RevisionExpectation],
403    observed: &Observation,
404    failures: &mut Vec<AssertionFailure>,
405) {
406    for want in expected {
407        let case = CaseKey::new(want.workflow.clone(), want.case_id.clone());
408        let subject = format!("{}/{}", case.workflow, case.case_id);
409        match observed.revision_of(&case) {
410            Some(revision) if revision == want.revision => {}
411            Some(revision) => failures.push(
412                AssertionFailure::new(
413                    ExpectationName::CaseRevision,
414                    format!("revision {}", want.revision),
415                    format!("revision {revision}"),
416                )
417                .about(subject),
418            ),
419            None => failures.push(
420                AssertionFailure::new(
421                    ExpectationName::CaseRevision,
422                    format!("revision {}", want.revision),
423                    "the case was not observed".to_owned(),
424                )
425                .about(subject),
426            ),
427        }
428    }
429}
430
431fn check_interactions(
432    expected: &[InteractionStatusExpectation],
433    observed: &Observation,
434    failures: &mut Vec<AssertionFailure>,
435) {
436    for want in expected {
437        let case = CaseKey::new(want.workflow.clone(), want.case_id.clone());
438        let actual: Vec<InteractionStatus> = observed
439            .interactions_of(&case)
440            .into_iter()
441            .map(|card| card.status)
442            .collect();
443        if actual != want.statuses {
444            failures.push(
445                AssertionFailure::new(
446                    ExpectationName::InteractionStatus,
447                    render_debug(&want.statuses),
448                    render_debug(&actual),
449                )
450                .about(format!("{}/{}", case.workflow, case.case_id)),
451            );
452        }
453    }
454}
455
456fn check_blocks(
457    expect: &Expectations,
458    observed: &Observation,
459    failures: &mut Vec<AssertionFailure>,
460) {
461    let Some(expected) = expect.blocks.as_deref() else {
462        return;
463    };
464    if expected != observed.blocks.as_slice() {
465        failures.push(AssertionFailure::new(
466            ExpectationName::ResponseBlocks,
467            render(expected.iter().map(ToString::to_string)),
468            render(observed.blocks.iter().map(ToString::to_string)),
469        ));
470    }
471}
472
473fn check_phase(
474    expect: &Expectations,
475    observed: &Observation,
476    failures: &mut Vec<AssertionFailure>,
477) {
478    let Some(expected) = expect.turn_phase else {
479        return;
480    };
481    if observed.phase != Some(expected) {
482        failures.push(AssertionFailure::new(
483            ExpectationName::TurnPhase,
484            format!("{expected:?}"),
485            observed.phase.map_or_else(
486                || "no phase marker".to_owned(),
487                |phase| format!("{phase:?}"),
488            ),
489        ));
490    }
491}
492
493fn render<I: IntoIterator<Item = String>>(items: I) -> String {
494    let joined: Vec<String> = items.into_iter().collect();
495    format!("[{}]", joined.join(", "))
496}
497
498fn render_debug<T: fmt::Debug>(items: &[T]) -> String {
499    let joined: Vec<String> = items.iter().map(|item| format!("{item:?}")).collect();
500    format!("[{}]", joined.join(", "))
501}
502
503/// Whether the value found is the one the item named.
504///
505/// `ignore_case` loosens the comparison for two strings and for nothing else:
506/// a number, a boolean or an object is compared as it stands, so the flag
507/// cannot quietly widen an assertion on a shape that has no case at all.
508fn same_value(
509    found: Option<&serde_json::Value>,
510    wanted: &serde_json::Value,
511    ignore_case: bool,
512) -> bool {
513    match (found, ignore_case) {
514        // Lowercased rather than compared byte-wise ignoring ASCII case: the
515        // values this is for are place names and company names, and «Forlì»
516        // against «forlì» would otherwise differ.
517        (Some(serde_json::Value::String(found)), true) => wanted
518            .as_str()
519            .is_some_and(|wanted| found.to_lowercase() == wanted.to_lowercase()),
520        (found, _) => found == Some(wanted),
521    }
522}
523
524/// Compares what a case's state says with what the item expected of it.
525///
526/// A case the observation never read is a failure and not a pass: the
527/// alternative is an expectation that goes quiet whenever the thing it is about
528/// cannot be found, which is the shape of every assertion that has ever been
529/// green for the wrong reason.
530fn check_state(
531    expected: &[StateExpectation],
532    observed: &Observation,
533    failures: &mut Vec<AssertionFailure>,
534) {
535    for expectation in expected {
536        // Ambiguity is a failure, not a coin toss. A `CaseId` is unique
537        // within a WORKFLOW — `CaseKey` carries both — so two seeded
538        // workflows may legitimately use the same one, and picking the first
539        // match would check an expectation against the wrong record and
540        // report the answer with total confidence. An expectation that cannot
541        // say which case it means is an expectation nobody can trust, and
542        // saying so costs one line.
543        let mut matching = observed
544            .states
545            .iter()
546            .filter(|(case, _)| case.case_id == expectation.case_id);
547        let found = matching.next();
548        if matching.next().is_some() {
549            failures.push(
550                AssertionFailure::new(
551                    ExpectationName::CaseState,
552                    &expectation.path,
553                    "two seeded workflows hold a case with this id, so the expectation                      does not say which record it is about",
554                )
555                .about(expectation.case_id.as_str()),
556            );
557            continue;
558        }
559        let Some((_, state)) = found else {
560            failures.push(
561                AssertionFailure::new(
562                    ExpectationName::CaseState,
563                    &expectation.path,
564                    "the case was not read back",
565                )
566                .about(expectation.case_id.as_str()),
567            );
568            continue;
569        };
570        let after = state
571            .after
572            .as_ref()
573            .and_then(|v| v.pointer(&expectation.path));
574        if let Some(wanted) = &expectation.equals {
575            let found =
576                after.map_or_else(|| String::from("nothing at that path"), ToString::to_string);
577            if !same_value(after, wanted, expectation.ignore_case) {
578                failures.push(
579                    AssertionFailure::new(ExpectationName::CaseState, wanted.to_string(), &found)
580                        .about(format!(
581                            "{}{}",
582                            expectation.case_id.as_str(),
583                            expectation.path
584                        )),
585                );
586            }
587        } else if !expectation.one_of.is_empty() {
588            if !expectation
589                .one_of
590                .iter()
591                .any(|wanted| same_value(after, wanted, expectation.ignore_case))
592            {
593                let found =
594                    after.map_or_else(|| String::from("nothing at that path"), ToString::to_string);
595                let wanted: Vec<String> =
596                    expectation.one_of.iter().map(ToString::to_string).collect();
597                failures.push(
598                    AssertionFailure::new(
599                        ExpectationName::CaseState,
600                        format!("one of {}", wanted.join(", ")),
601                        &found,
602                    )
603                    .about(format!(
604                        "{}{}",
605                        expectation.case_id.as_str(),
606                        expectation.path
607                    )),
608                );
609            }
610        } else if expectation.unchanged {
611            let before = state.before.pointer(&expectation.path);
612            if after != before {
613                let was = before.map_or_else(|| String::from("nothing"), ToString::to_string);
614                let now = after.map_or_else(|| String::from("nothing"), ToString::to_string);
615                failures.push(
616                    AssertionFailure::new(
617                        ExpectationName::CaseState,
618                        format!("{was} (unchanged)"),
619                        &now,
620                    )
621                    .about(format!(
622                        "{}{}",
623                        expectation.case_id.as_str(),
624                        expectation.path
625                    )),
626                );
627            }
628        } else if expectation.absent {
629            // A path that does not resolve counts as absent: a workflow may
630            // drop the key instead of nulling it, and telling those two apart
631            // would assert something about the serializer rather than about the
632            // record.
633            if !matches!(after, None | Some(serde_json::Value::Null)) {
634                let found = after.map_or_else(String::new, ToString::to_string);
635                failures.push(
636                    AssertionFailure::new(ExpectationName::CaseState, "nothing there", &found)
637                        .about(format!(
638                            "{}{}",
639                            expectation.case_id.as_str(),
640                            expectation.path
641                        )),
642                );
643            }
644        }
645    }
646}
647
648fn check_workflow_state(
649    expected: &[crate::corpus::WorkflowStateExpectation],
650    observed: &Observation,
651    failures: &mut Vec<AssertionFailure>,
652) {
653    for expectation in expected {
654        let found: Vec<String> = observed
655            .states
656            .iter()
657            .filter(|(case, _)| case.workflow == expectation.workflow)
658            .map(|(_, state)| {
659                state
660                    .after
661                    .as_ref()
662                    .and_then(|after| after.pointer(&expectation.path))
663                    .map_or_else(|| "nothing".to_owned(), ToString::to_string)
664            })
665            .collect();
666        let holds = observed.states.iter().any(|(case, state)| {
667            case.workflow == expectation.workflow
668                && same_value(
669                    state
670                        .after
671                        .as_ref()
672                        .and_then(|after| after.pointer(&expectation.path)),
673                    &expectation.equals,
674                    expectation.ignore_case,
675                )
676        });
677        if !holds {
678            failures.push(
679                AssertionFailure::new(
680                    ExpectationName::WorkflowState,
681                    expectation.equals.to_string(),
682                    format!("[{}]", found.join(", ")),
683                )
684                .about(format!("{}{}", expectation.workflow, expectation.path)),
685            );
686        }
687    }
688}
689
690fn check_case_count(
691    expected: &[crate::corpus::CaseCountExpectation],
692    observed: &Observation,
693    failures: &mut Vec<AssertionFailure>,
694) {
695    for expectation in expected {
696        let count = observed
697            .states
698            .iter()
699            .filter(|(case, state)| case.workflow == expectation.workflow && state.after.is_some())
700            .count();
701        if count != expectation.count {
702            failures.push(
703                AssertionFailure::new(
704                    ExpectationName::CaseCount,
705                    expectation.count.to_string(),
706                    count.to_string(),
707                )
708                .about(expectation.workflow.as_str()),
709            );
710        }
711    }
712}
713
714#[cfg(test)]
715mod tests {
716    use turnframe_core::ids::TurnId;
717
718    use super::*;
719    use crate::corpus::{ActKind, BlockKind, ForbiddenEffects, StateExpectation};
720    use crate::observation::ObservedAct;
721
722    fn observation() -> Observation {
723        Observation {
724            turn_id: TurnId::nil(),
725            error_code: None,
726            acts: vec![ObservedAct {
727                kind: "apply_operation".to_owned(),
728                operation: Some("trip.set_name".to_owned()),
729                outcome: Some("ready_to_execute".to_owned()),
730            }],
731            target_resolutions: Vec::new(),
732            understanding: None,
733            commands: vec!["trip.set_name".to_owned()],
734            events: vec!["trip.name_set".to_owned()],
735            events_truncated: false,
736            revisions: Vec::new(),
737            states: Vec::new(),
738            interactions: Vec::new(),
739            blocks: vec![BlockKind::Receipt],
740            phase: None,
741            provider_failures: 0,
742            cards_created: 0,
743            answer: String::new(),
744            discarded_answers: Vec::new(),
745        }
746    }
747
748    fn with_state(before: serde_json::Value, after: Option<serde_json::Value>) -> Observation {
749        let mut observed = observation();
750        observed.states = vec![(
751            turnframe_core::case::CaseKey::new("trip", "trip-1"),
752            crate::observation::ObservedState { before, after },
753        )];
754        observed
755    }
756
757    fn expect_state(expectation: StateExpectation) -> Expectations {
758        Expectations {
759            case_state: vec![expectation],
760            ..Expectations::default()
761        }
762    }
763
764    fn at(path: &str) -> StateExpectation {
765        StateExpectation {
766            case_id: turnframe_core::ids::CaseId::new("trip-1"),
767            path: path.to_owned(),
768            equals: None,
769            one_of: Vec::new(),
770            unchanged: false,
771            absent: false,
772            ignore_case: false,
773        }
774    }
775
776    /// A value the user's words give in more than one right form passes in any of them,
777    /// and in nothing else.
778    #[test]
779    fn a_value_may_be_one_of_several_right_readings() {
780        let expectation = StateExpectation {
781            one_of: vec![
782                serde_json::json!("offsite di Lisbona"),
783                serde_json::json!("l'offsite di Lisbona"),
784            ],
785            ignore_case: true,
786            ..at("/name")
787        };
788        assert!(expectation.validate().is_ok());
789        let read = |name: &str| {
790            with_state(
791                serde_json::json!({"name": null}),
792                Some(serde_json::json!({ "name": name })),
793            )
794        };
795        assert!(
796            check(
797                &expect_state(expectation.clone()),
798                &read("L'offsite di Lisbona")
799            )
800            .is_empty()
801        );
802        assert_eq!(
803            check(
804                &expect_state(expectation),
805                &read("il viaggio è per Lisbona")
806            )
807            .len(),
808            1
809        );
810    }
811
812    /// The case of a free-text value is the model's typography, not the record.
813    ///
814    /// A person types «lisbon» and the application stores the city the way it
815    /// was handed it, so one turn writes «lisbon» and the next «Lisbon»: the
816    /// same answer, and an assertion that told them apart would report the
817    /// lane as wrong for capitalising a city. The flag says that about ONE
818    /// field, and the test's second half is the point: it must not become a
819    /// looser comparison everywhere, because a domain that canonicalises a
820    /// booking reference to upper case means the case there IS the content.
821    #[test]
822    fn a_free_text_value_may_be_compared_without_its_case_and_nothing_else_is() {
823        let capitalised = with_state(
824            serde_json::json!({"city": null}),
825            Some(serde_json::json!({"city": "Lisbon"})),
826        );
827        assert!(
828            check(
829                &expect_state(StateExpectation {
830                    equals: Some(serde_json::json!("lisbon")),
831                    ignore_case: true,
832                    ..at("/city")
833                }),
834                &capitalised
835            )
836            .is_empty(),
837            "«lisbon» and «Lisbon» are the same city"
838        );
839
840        // Off by default, which is what every expectation that does not say
841        // otherwise gets.
842        assert_eq!(
843            check(
844                &expect_state(StateExpectation {
845                    equals: Some(serde_json::json!("lisbon")),
846                    ..at("/city")
847                }),
848                &capitalised
849            )
850            .len(),
851            1
852        );
853
854        // And it still measures: a different value is still a different value.
855        let elsewhere = with_state(
856            serde_json::json!({"city": null}),
857            Some(serde_json::json!({"city": "Porto"})),
858        );
859        assert_eq!(
860            check(
861                &expect_state(StateExpectation {
862                    equals: Some(serde_json::json!("lisbon")),
863                    ignore_case: true,
864                    ..at("/city")
865                }),
866                &elsewhere
867            )
868            .len(),
869            1,
870            "a different city is a failure whatever the case"
871        );
872    }
873
874    /// A flag saying HOW to compare needs something to compare.
875    #[test]
876    fn ignoring_the_case_of_an_absence_is_refused_rather_than_ignored() {
877        assert!(
878            StateExpectation {
879                absent: true,
880                ignore_case: true,
881                ..at("/email")
882            }
883            .validate()
884            .is_err()
885        );
886        assert!(
887            StateExpectation {
888                equals: Some(serde_json::json!("lisbon")),
889                ignore_case: true,
890                ..at("/city")
891            }
892            .validate()
893            .is_ok()
894        );
895    }
896
897    /// «I do not have one» is an answer, and it is the one nothing could say.
898    ///
899    /// A collecting workflow often turns on a person declining an optional datum,
900    /// as the traveler's loyalty number does: the question is answered and the
901    /// flow moves on. What the turn must do is leave the field with no value,
902    /// and every other expectation here asserts that a value IS somewhere — so
903    /// a turn that quietly writes something into a field the user declined read
904    /// exactly like a turn that respected them.
905    ///
906    /// `equals: null` cannot say it: `equals` is an `Option`, so a JSON null
907    /// arrives as «no expectation» and the entry is refused for asserting
908    /// nothing.
909    #[test]
910    fn a_field_the_user_declined_is_asserted_empty_and_fails_when_something_was_written() {
911        let declined = with_state(
912            serde_json::json!({"email": "old@aurora.example"}),
913            Some(serde_json::json!({"email": null})),
914        );
915        assert!(
916            check(
917                &expect_state(StateExpectation {
918                    absent: true,
919                    ..at("/email")
920                }),
921                &declined
922            )
923            .is_empty(),
924            "a field left with no value is what a decline looks like"
925        );
926
927        // A path the workflow dropped entirely reads the same: telling the two
928        // apart would assert something about the serializer.
929        let dropped = with_state(
930            serde_json::json!({"email": "old@aurora.example"}),
931            Some(serde_json::json!({})),
932        );
933        assert!(
934            check(
935                &expect_state(StateExpectation {
936                    absent: true,
937                    ..at("/email")
938                }),
939                &dropped
940            )
941            .is_empty()
942        );
943
944        // And the half that makes it a measurement: a turn that wrote anyway.
945        let written = with_state(
946            serde_json::json!({"email": null}),
947            Some(serde_json::json!({"email": "made-up@aurora.example"})),
948        );
949        let failures = check(
950            &expect_state(StateExpectation {
951                absent: true,
952                ..at("/email")
953            }),
954            &written,
955        );
956        assert_eq!(failures.len(), 1, "{failures:?}");
957        assert!(
958            failures[0].actual.contains("made-up@aurora.example"),
959            "the failure names what was written: {:?}",
960            failures[0]
961        );
962    }
963
964    #[test]
965    fn a_value_that_ended_up_right_passes_and_a_wrong_one_names_both_sides() {
966        let observed = with_state(
967            serde_json::json!({"gate": "B12"}),
968            Some(serde_json::json!({"gate": "B14"})),
969        );
970        let wanted = StateExpectation {
971            equals: Some(serde_json::json!("B14")),
972            ..at("/gate")
973        };
974        assert!(check(&expect_state(wanted), &observed).is_empty());
975
976        // The half the events cannot tell apart: both stories commit the same
977        // event, and only the value says which one happened.
978        let stale = StateExpectation {
979            equals: Some(serde_json::json!("B12")),
980            ..at("/gate")
981        };
982        let failures = check(&expect_state(stale), &observed);
983        assert_eq!(failures.len(), 1);
984        assert!(failures[0].actual.contains("B14"), "{failures:?}");
985    }
986
987    #[test]
988    fn a_field_that_must_not_move_fails_when_it_moved() {
989        let untouched = with_state(
990            serde_json::json!({"email": "a@b.it"}),
991            Some(serde_json::json!({"email": "a@b.it"})),
992        );
993        let expectation = StateExpectation {
994            unchanged: true,
995            ..at("/email")
996        };
997        assert!(check(&expect_state(expectation.clone()), &untouched).is_empty());
998
999        let moved = with_state(
1000            serde_json::json!({"email": "a@b.it"}),
1001            Some(serde_json::json!({"email": "c@d.it"})),
1002        );
1003        let failures = check(&expect_state(expectation), &moved);
1004        assert_eq!(failures.len(), 1, "{failures:?}");
1005        assert!(failures[0].expected.contains("unchanged"), "{failures:?}");
1006    }
1007
1008    #[test]
1009    fn a_case_that_was_never_read_back_fails_instead_of_passing_quietly() {
1010        let expectation = StateExpectation {
1011            unchanged: true,
1012            ..at("/email")
1013        };
1014        // No state recorded at all: the alternative would be an assertion that
1015        // goes silent exactly when the thing it is about cannot be found.
1016        let failures = check(&expect_state(expectation), &observation());
1017        assert_eq!(failures.len(), 1, "{failures:?}");
1018    }
1019
1020    #[test]
1021    fn an_item_that_asserts_nothing_cannot_fail_on_a_completed_turn() {
1022        assert!(check(&Expectations::default(), &observation()).is_empty());
1023    }
1024
1025    fn with_cases(cases: &[(&str, &str, serde_json::Value)]) -> Observation {
1026        let mut observed = observation();
1027        observed.states = cases
1028            .iter()
1029            .map(|(workflow, case_id, after)| {
1030                (
1031                    turnframe_core::case::CaseKey::new(*workflow, *case_id),
1032                    crate::observation::ObservedState {
1033                        before: serde_json::Value::Null,
1034                        after: Some(after.clone()),
1035                    },
1036                )
1037            })
1038            .collect();
1039        observed
1040    }
1041
1042    #[test]
1043    fn some_case_of_a_workflow_holding_the_value_is_enough() {
1044        let observed = with_cases(&[
1045            ("trip", "trip-1", serde_json::json!({"traveler": null})),
1046            (
1047                "trip",
1048                "tf_2",
1049                serde_json::json!({"traveler": {"display_name": "Nadia Rinaldi"}}),
1050            ),
1051        ]);
1052        let expect = |equals: &str| Expectations {
1053            workflow_state: vec![crate::corpus::WorkflowStateExpectation {
1054                workflow: "trip".into(),
1055                path: "/traveler/display_name".to_owned(),
1056                equals: serde_json::json!(equals),
1057                ignore_case: true,
1058            }],
1059            ..Expectations::default()
1060        };
1061        assert!(check(&expect("nadia rinaldi"), &observed).is_empty());
1062        let failures = check(&expect("Omar"), &observed);
1063        assert_eq!(failures.len(), 1, "{failures:?}");
1064        assert_eq!(failures[0].expectation, ExpectationName::WorkflowState);
1065    }
1066
1067    #[test]
1068    fn the_cases_of_a_workflow_are_counted() {
1069        let observed = with_cases(&[
1070            ("trip", "trip-1", serde_json::json!({})),
1071            ("trip", "tf_2", serde_json::json!({})),
1072            ("traveler", "tf_3", serde_json::json!({})),
1073        ]);
1074        let expect = |count: usize| Expectations {
1075            case_count: vec![crate::corpus::CaseCountExpectation {
1076                workflow: "trip".into(),
1077                count,
1078            }],
1079            ..Expectations::default()
1080        };
1081        assert!(check(&expect(2), &observed).is_empty());
1082        let failures = check(&expect(1), &observed);
1083        assert_eq!(failures.len(), 1, "{failures:?}");
1084        assert_eq!(failures[0].expectation, ExpectationName::CaseCount);
1085    }
1086
1087    #[test]
1088    fn a_wrong_command_names_both_sides() {
1089        let expect = Expectations {
1090            commands: Some(vec!["trip.set_travel_date".to_owned()]),
1091            ..Expectations::default()
1092        };
1093        let failures = check(&expect, &observation());
1094        let message = failures[0].to_string();
1095        assert!(message.contains("trip.set_travel_date"), "{message}");
1096        assert!(message.contains("trip.set_name"), "{message}");
1097    }
1098
1099    #[test]
1100    fn a_forbidden_command_that_appeared_fails() {
1101        let expect = Expectations {
1102            forbid: ForbiddenEffects {
1103                commands: vec!["trip.set_name".to_owned()],
1104                events: Vec::new(),
1105            },
1106            ..Expectations::default()
1107        };
1108        let failures = check(&expect, &observation());
1109        assert_eq!(failures.len(), 1);
1110        assert_eq!(failures[0].expectation, ExpectationName::ForbiddenCommand);
1111        assert!(failures[0].to_string().contains("never to appear"));
1112    }
1113
1114    #[test]
1115    fn an_operation_mismatch_on_a_matching_kind_fails() {
1116        let expect = Expectations {
1117            acts: Some(vec![ActExpectation {
1118                kind: ActKind::ApplyOperation,
1119                operation: Some("trip.rebook".to_owned()),
1120                or: Vec::new(),
1121            }]),
1122            ..Expectations::default()
1123        };
1124        let failures = check(&expect, &observation());
1125        assert_eq!(failures.len(), 1);
1126        assert!(failures[0].to_string().contains("trip.rebook"));
1127    }
1128
1129    /// Two readings that are both right, and a position that admits either.
1130    ///
1131    /// The alternative is an instrument that reports a correct turn as a
1132    /// defect, which is the failure mode this exists to close.
1133    #[test]
1134    fn a_position_that_admits_two_shapes_accepts_either_of_them() {
1135        let expect = Expectations {
1136            acts: Some(vec![ActExpectation {
1137                kind: ActKind::StartWorkflow,
1138                operation: None,
1139                or: vec![ActExpectation {
1140                    kind: ActKind::ApplyOperation,
1141                    operation: Some("trip.set_name".to_owned()),
1142                    or: Vec::new(),
1143                }],
1144            }]),
1145            ..Expectations::default()
1146        };
1147        assert!(
1148            check(&expect, &observation())
1149                .iter()
1150                .all(|failure| failure.expectation != ExpectationName::Acts),
1151            "the observed act is the second shape, and the second shape is admitted"
1152        );
1153    }
1154
1155    /// And it is not a way to assert less: a shape nobody wrote down still
1156    /// fails.
1157    #[test]
1158    fn a_shape_no_alternative_names_still_fails() {
1159        let expect = Expectations {
1160            acts: Some(vec![ActExpectation {
1161                kind: ActKind::StartWorkflow,
1162                operation: None,
1163                or: vec![ActExpectation {
1164                    kind: ActKind::ApplyOperation,
1165                    operation: Some("trip.rebook".to_owned()),
1166                    or: Vec::new(),
1167                }],
1168            }]),
1169            ..Expectations::default()
1170        };
1171        let failures = check(&expect, &observation());
1172        assert_eq!(failures.len(), 1);
1173        assert_eq!(failures[0].expectation, ExpectationName::Acts);
1174        assert!(
1175            failures[0]
1176                .to_string()
1177                .contains("start_workflow | apply_operation(trip.rebook)"),
1178            "and the report names every shape that would have done: {}",
1179            failures[0]
1180        );
1181    }
1182
1183    #[test]
1184    fn a_crashed_turn_fails_even_when_nothing_else_is_asserted() {
1185        let mut observed = observation();
1186        observed.error_code = Some("store.unavailable".to_owned());
1187        let failures = check(&Expectations::default(), &observed);
1188        assert_eq!(failures[0].expectation, ExpectationName::Outcome);
1189    }
1190
1191    #[test]
1192    fn an_expected_failure_is_not_a_failure() {
1193        let mut observed = observation();
1194        observed.error_code = Some("store.unavailable".to_owned());
1195        let expect = Expectations {
1196            outcome: OutcomeExpectation::FailsWith("store.unavailable".to_owned()),
1197            ..Expectations::default()
1198        };
1199        assert!(check(&expect, &observed).is_empty());
1200    }
1201}