Skip to main content

omena_testkit/
fixture_eval.rs

1//! Evaluate parsed `omena-fixture-v0` expectations against engine output.
2//!
3//! RFC 0003 (#37 wiring-gap): the fixture grammar *parses* assertion forms
4//! (`diagnostic` / `no-diagnostic` / `count` / `cascade-outcome` /
5//! `cascade-witness` / `boundary-state`) and classifies them with
6//! [`OmenaFixtureExpectationKindV0`], but nothing turned a classified
7//! expectation into a pass/fail. This module closes the evaluator gap for the
8//! P0-independent families.
9//!
10//! ## Dependency-light wiring
11//!
12//! `omena-testkit` must stay free of an `omena-query` dependency to preserve
13//! the workspace DAG (the test substrate sits *below* the engine). So the
14//! evaluator never names an engine type: the consumer supplies diagnostics as
15//! [`OmenaFixtureDiagnosticV0`] (a minimal `{ code }` projection of
16//! `OmenaQueryStyleDiagnosticV0`) and boundary states as
17//! [`OmenaFixtureBoundaryStateV0`] (a `{ reference, state }` projection of the
18//! resolver boundary-state lattice). A real engine-backed consumer such as
19//! `omena-diff-test` — which *already* depends on `omena-query` — maps the
20//! shipped diagnostic/boundary structs into these projections.
21//!
22//! ## Live vs deferred families
23//!
24//! Live now (backed by shipped functions):
25//! - `diagnostic` / `no-diagnostic` / `count` — matched against diagnostic codes.
26//! - `boundary-state` — matched against the resolver boundary-state lattice.
27//! - `cascade-outcome` / `cascade-witness` — matched against per-declaration
28//!   cascade winners/witnesses. #33's in-process bridge now generates the SIFs
29//!   the resolver-generator consumes, so a consumer can run the cascade and
30//!   project each scope's winner id plus its witness (also-considered)
31//!   declaration ids into [`OmenaFixtureCascadeV0`]. The evaluator stays free of
32//!   an `omena-cascade` / `omena-query` dependency by matching only against that
33//!   projection (see "Dependency-light wiring" above).
34
35use serde::Serialize;
36
37use crate::fixture::{OmenaFixtureExpectationKindV0, OmenaFixtureExpectationV0, OmenaFixtureV0};
38
39/// Minimal diagnostic projection consumed by the fixture evaluator.
40///
41/// The engine produces `OmenaQueryStyleDiagnosticV0`; the consumer projects
42/// each one to its `code` so `omena-testkit` need not depend on `omena-query`.
43#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
44#[serde(rename_all = "camelCase")]
45pub struct OmenaFixtureDiagnosticV0 {
46    /// Stable diagnostic code, e.g. `missingSassSymbol` or `missingKeyframes`.
47    pub code: String,
48}
49
50impl OmenaFixtureDiagnosticV0 {
51    /// Build a diagnostic projection from a diagnostic code.
52    pub fn new(code: impl Into<String>) -> Self {
53        Self { code: code.into() }
54    }
55}
56
57/// Minimal boundary-state projection consumed by the fixture evaluator.
58///
59/// The resolver produces `OmenaResolverBoundaryStateV0`; the consumer projects
60/// the external reference id and the lattice state name (`resolved` / `partial`
61/// / `stale` / `missing` / `unresolved`) so the test substrate stays free of an
62/// `omena-resolver` dependency.
63#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
64#[serde(rename_all = "camelCase")]
65pub struct OmenaFixtureBoundaryStateV0 {
66    /// External reference id the fixture names, e.g. `ext-1`.
67    pub reference: String,
68    /// Lattice state name as produced by the resolver boundary lattice.
69    pub state: String,
70}
71
72impl OmenaFixtureBoundaryStateV0 {
73    /// Build a boundary-state projection from a reference id and state name.
74    pub fn new(reference: impl Into<String>, state: impl Into<String>) -> Self {
75        Self {
76            reference: reference.into(),
77            state: state.into(),
78        }
79    }
80}
81
82/// Minimal cascade-outcome projection consumed by the fixture evaluator.
83///
84/// The engine produces a `CascadeOutcome` (`omena-cascade`) per resolved scope;
85/// the consumer projects the winning declaration id and the witness set (the
86/// winner plus every also-considered/challenger declaration that participated
87/// in the cascade comparison) so `omena-testkit` need not depend on
88/// `omena-cascade`. A `cascade-outcome <id>` expectation passes when `<id>`
89/// equals [`winner_id`](Self::winner_id); a `cascade-witness <id>` expectation
90/// passes when `<id>` appears in [`witness_ids`](Self::witness_ids).
91#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
92#[serde(rename_all = "camelCase")]
93pub struct OmenaFixtureCascadeV0 {
94    /// Winning declaration id for the resolved scope, e.g. `decl-1`.
95    pub winner_id: String,
96    /// Declaration ids that participated in the cascade as witnesses.
97    ///
98    /// This is the winner plus every also-considered/challenger declaration the
99    /// engine ranked, so a `cascade-witness` assertion can name any declaration
100    /// that took part in the comparison, not just the winner.
101    pub witness_ids: Vec<String>,
102}
103
104impl OmenaFixtureCascadeV0 {
105    /// Build a cascade projection from a winner id and its witness declaration ids.
106    ///
107    /// The winner id is always treated as a witness, so callers need not repeat
108    /// it in `witness_ids`.
109    pub fn new(
110        winner_id: impl Into<String>,
111        witness_ids: impl IntoIterator<Item = String>,
112    ) -> Self {
113        let winner_id = winner_id.into();
114        let mut witnesses = vec![winner_id.clone()];
115        for id in witness_ids {
116            if !witnesses.contains(&id) {
117                witnesses.push(id);
118            }
119        }
120        Self {
121            winner_id,
122            witness_ids: witnesses,
123        }
124    }
125}
126
127/// Outcome of evaluating one fixture expectation.
128#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
129#[serde(rename_all = "camelCase")]
130pub struct OmenaFixtureExpectationOutcomeV0 {
131    /// Original expectation key, e.g. `diagnostic` or `boundary-state ext-1 resolved`.
132    pub key: String,
133    /// Classified expectation family.
134    pub kind: OmenaFixtureExpectationKindV0,
135    /// Whether the expectation was actually evaluated (false for deferred families).
136    pub evaluated: bool,
137    /// Whether the expectation is satisfied. Always `false` when `evaluated` is `false`.
138    pub satisfied: bool,
139    /// Human-readable detail explaining the pass/fail/deferral.
140    pub detail: String,
141}
142
143/// Evaluate every parsed expectation in `fixture` against supplied engine
144/// output, returning one [`OmenaFixtureExpectationOutcomeV0`] per expectation.
145///
146/// `diagnostics` is the flattened diagnostic set the engine produced for the
147/// fixture's files; `boundary_states` is the resolver boundary-state set keyed
148/// by external reference id; `cascades` is the per-scope cascade projection the
149/// resolver-generator produced from #33's SIFs. All shipped families
150/// (`diagnostic` / `no-diagnostic` / `count` / `boundary-state` /
151/// `cascade-outcome` / `cascade-witness`) are evaluated here.
152pub fn evaluate_omena_fixture_v0(
153    fixture: &OmenaFixtureV0,
154    diagnostics: &[OmenaFixtureDiagnosticV0],
155    boundary_states: &[OmenaFixtureBoundaryStateV0],
156    cascades: &[OmenaFixtureCascadeV0],
157) -> Vec<OmenaFixtureExpectationOutcomeV0> {
158    fixture
159        .expectations
160        .iter()
161        .map(|expectation| evaluate_one(expectation, diagnostics, boundary_states, cascades))
162        .collect()
163}
164
165/// Evaluate a fixture against diagnostics produced lazily by an injected
166/// closure, keeping the engine dependency on the *consumer* side.
167///
168/// `produce_diagnostics` is invoked once per fixture file and the results are
169/// flattened before delegating to [`evaluate_omena_fixture_v0`]. `cascades` is the
170/// per-scope cascade projection the resolver-generator produced from #33's SIFs.
171/// A consumer that already depends on `omena-query` (such as `omena-diff-test`)
172/// wires the real `summarize_omena_query_style_diagnostics_for_file` here
173/// without forcing an engine dependency into this crate.
174pub fn evaluate_omena_fixture_v0_with<F>(
175    fixture: &OmenaFixtureV0,
176    boundary_states: &[OmenaFixtureBoundaryStateV0],
177    cascades: &[OmenaFixtureCascadeV0],
178    mut produce_diagnostics: F,
179) -> Vec<OmenaFixtureExpectationOutcomeV0>
180where
181    F: FnMut(&crate::fixture::OmenaFixtureFileV0) -> Vec<OmenaFixtureDiagnosticV0>,
182{
183    let diagnostics = fixture
184        .files
185        .iter()
186        .flat_map(&mut produce_diagnostics)
187        .collect::<Vec<_>>();
188    evaluate_omena_fixture_v0(fixture, &diagnostics, boundary_states, cascades)
189}
190
191fn evaluate_one(
192    expectation: &OmenaFixtureExpectationV0,
193    diagnostics: &[OmenaFixtureDiagnosticV0],
194    boundary_states: &[OmenaFixtureBoundaryStateV0],
195    cascades: &[OmenaFixtureCascadeV0],
196) -> OmenaFixtureExpectationOutcomeV0 {
197    let kind = expectation.kind();
198    match kind {
199        OmenaFixtureExpectationKindV0::Diagnostic => {
200            evaluate_diagnostic(expectation, diagnostics, kind)
201        }
202        OmenaFixtureExpectationKindV0::NoDiagnostic => {
203            evaluate_no_diagnostic(expectation, diagnostics, kind)
204        }
205        OmenaFixtureExpectationKindV0::Count => evaluate_count(expectation, diagnostics, kind),
206        OmenaFixtureExpectationKindV0::BoundaryState => {
207            evaluate_boundary_state(expectation, boundary_states, kind)
208        }
209        OmenaFixtureExpectationKindV0::CascadeOutcome => {
210            evaluate_cascade_outcome(expectation, cascades, kind)
211        }
212        OmenaFixtureExpectationKindV0::CascadeWitness => {
213            evaluate_cascade_witness(expectation, cascades, kind)
214        }
215        OmenaFixtureExpectationKindV0::Product
216        | OmenaFixtureExpectationKindV0::Assertion
217        | OmenaFixtureExpectationKindV0::Unknown => deferred(
218            expectation,
219            kind,
220            "product-owned expectation family is not engine-evaluated by the testkit",
221        ),
222    }
223}
224
225/// Extract the diagnostic code an expectation targets.
226///
227/// `diagnostic` carries the code in a `code: <name>` body line; the other
228/// families carry it as the first token after the keyword in the key, e.g.
229/// `no-diagnostic missingSassSymbol` or `count missingKeyframes:2`.
230fn expectation_diagnostic_code(expectation: &OmenaFixtureExpectationV0) -> Option<String> {
231    if let Some(code) = code_from_key_tail(&expectation.key) {
232        return Some(code);
233    }
234    code_from_value_body(&expectation.value)
235}
236
237fn code_from_key_tail(key: &str) -> Option<String> {
238    let tail = key.split_whitespace().nth(1)?;
239    // A `count` body is `<code>:<n>`; keep only the code half.
240    let code = tail.split(':').next().unwrap_or(tail);
241    if code.is_empty() {
242        None
243    } else {
244        Some(code.to_string())
245    }
246}
247
248fn code_from_value_body(value: &str) -> Option<String> {
249    value.lines().find_map(|line| {
250        line.trim()
251            .strip_prefix("code:")
252            .map(|code| code.trim().to_string())
253            .filter(|code| !code.is_empty())
254    })
255}
256
257fn count_diagnostics_with_code(diagnostics: &[OmenaFixtureDiagnosticV0], code: &str) -> usize {
258    diagnostics
259        .iter()
260        .filter(|diagnostic| diagnostic.code == code)
261        .count()
262}
263
264fn evaluate_diagnostic(
265    expectation: &OmenaFixtureExpectationV0,
266    diagnostics: &[OmenaFixtureDiagnosticV0],
267    kind: OmenaFixtureExpectationKindV0,
268) -> OmenaFixtureExpectationOutcomeV0 {
269    let Some(code) = expectation_diagnostic_code(expectation) else {
270        return malformed(
271            expectation,
272            kind,
273            "diagnostic expectation is missing a code",
274        );
275    };
276    let observed = count_diagnostics_with_code(diagnostics, &code);
277    let satisfied = observed > 0;
278    let detail = if satisfied {
279        format!("diagnostic `{code}` present ({observed} occurrence(s))")
280    } else {
281        format!("diagnostic `{code}` expected but absent")
282    };
283    outcome(expectation, kind, satisfied, detail)
284}
285
286fn evaluate_no_diagnostic(
287    expectation: &OmenaFixtureExpectationV0,
288    diagnostics: &[OmenaFixtureDiagnosticV0],
289    kind: OmenaFixtureExpectationKindV0,
290) -> OmenaFixtureExpectationOutcomeV0 {
291    let Some(code) = expectation_diagnostic_code(expectation) else {
292        return malformed(
293            expectation,
294            kind,
295            "no-diagnostic expectation is missing a code",
296        );
297    };
298    let observed = count_diagnostics_with_code(diagnostics, &code);
299    let satisfied = observed == 0;
300    let detail = if satisfied {
301        format!("diagnostic `{code}` correctly absent")
302    } else {
303        format!("diagnostic `{code}` present but expected absent ({observed} occurrence(s))")
304    };
305    outcome(expectation, kind, satisfied, detail)
306}
307
308fn evaluate_count(
309    expectation: &OmenaFixtureExpectationV0,
310    diagnostics: &[OmenaFixtureDiagnosticV0],
311    kind: OmenaFixtureExpectationKindV0,
312) -> OmenaFixtureExpectationOutcomeV0 {
313    let Some((code, expected)) = parse_count_target(&expectation.key) else {
314        return malformed(
315            expectation,
316            kind,
317            "count expectation must be `count <code>:<n>`",
318        );
319    };
320    let observed = count_diagnostics_with_code(diagnostics, &code);
321    let satisfied = observed == expected;
322    let detail = format!("diagnostic `{code}` count expected {expected}, observed {observed}");
323    outcome(expectation, kind, satisfied, detail)
324}
325
326fn parse_count_target(key: &str) -> Option<(String, usize)> {
327    let tail = key.split_whitespace().nth(1)?;
328    let (code, count) = tail.split_once(':')?;
329    if code.is_empty() {
330        return None;
331    }
332    let expected = count.trim().parse::<usize>().ok()?;
333    Some((code.to_string(), expected))
334}
335
336fn evaluate_boundary_state(
337    expectation: &OmenaFixtureExpectationV0,
338    boundary_states: &[OmenaFixtureBoundaryStateV0],
339    kind: OmenaFixtureExpectationKindV0,
340) -> OmenaFixtureExpectationOutcomeV0 {
341    let Some((reference, expected_state)) = parse_boundary_target(&expectation.key) else {
342        return malformed(
343            expectation,
344            kind,
345            "boundary-state expectation must be `boundary-state <ref> <state>`",
346        );
347    };
348    let Some(actual) = boundary_states
349        .iter()
350        .find(|state| state.reference == reference)
351    else {
352        return outcome(
353            expectation,
354            kind,
355            false,
356            format!("boundary reference `{reference}` not present in resolver output"),
357        );
358    };
359    // The fixture writes lattice states as `Resolved` / `Partial` / ...; the
360    // resolver lattice projects them lowercase via `as_str`. Compare case-insensitively.
361    let satisfied = actual.state.eq_ignore_ascii_case(&expected_state);
362    let detail = if satisfied {
363        format!("boundary `{reference}` state `{}` matches", actual.state)
364    } else {
365        format!(
366            "boundary `{reference}` expected `{expected_state}`, observed `{}`",
367            actual.state
368        )
369    };
370    outcome(expectation, kind, satisfied, detail)
371}
372
373fn parse_boundary_target(key: &str) -> Option<(String, String)> {
374    let mut parts = key.split_whitespace();
375    parts.next()?; // discard `boundary-state` keyword
376    let reference = parts.next()?;
377    let state = parts.next()?;
378    if reference.is_empty() || state.is_empty() {
379        return None;
380    }
381    Some((reference.to_string(), state.to_string()))
382}
383
384/// Extract the declaration id a cascade family targets: the first token after
385/// the keyword, e.g. `decl-1` in `cascade-outcome decl-1`.
386fn cascade_target_id(key: &str) -> Option<String> {
387    let id = key.split_whitespace().nth(1)?;
388    if id.is_empty() {
389        None
390    } else {
391        Some(id.to_string())
392    }
393}
394
395fn evaluate_cascade_outcome(
396    expectation: &OmenaFixtureExpectationV0,
397    cascades: &[OmenaFixtureCascadeV0],
398    kind: OmenaFixtureExpectationKindV0,
399) -> OmenaFixtureExpectationOutcomeV0 {
400    let Some(expected_winner) = cascade_target_id(&expectation.key) else {
401        return malformed(
402            expectation,
403            kind,
404            "cascade-outcome expectation must be `cascade-outcome <declaration-id>`",
405        );
406    };
407    let satisfied = cascades
408        .iter()
409        .any(|cascade| cascade.winner_id == expected_winner);
410    let detail = if satisfied {
411        format!("cascade winner `{expected_winner}` matches")
412    } else {
413        let observed = cascades
414            .iter()
415            .map(|cascade| cascade.winner_id.as_str())
416            .collect::<Vec<_>>()
417            .join(", ");
418        format!("cascade winner `{expected_winner}` expected, observed winners [{observed}]")
419    };
420    outcome(expectation, kind, satisfied, detail)
421}
422
423fn evaluate_cascade_witness(
424    expectation: &OmenaFixtureExpectationV0,
425    cascades: &[OmenaFixtureCascadeV0],
426    kind: OmenaFixtureExpectationKindV0,
427) -> OmenaFixtureExpectationOutcomeV0 {
428    let Some(expected_witness) = cascade_target_id(&expectation.key) else {
429        return malformed(
430            expectation,
431            kind,
432            "cascade-witness expectation must be `cascade-witness <declaration-id>`",
433        );
434    };
435    let satisfied = cascades
436        .iter()
437        .any(|cascade| cascade.witness_ids.iter().any(|id| id == &expected_witness));
438    let detail = if satisfied {
439        format!("cascade witness `{expected_witness}` participated in the cascade")
440    } else {
441        format!("cascade witness `{expected_witness}` expected but never participated")
442    };
443    outcome(expectation, kind, satisfied, detail)
444}
445
446fn outcome(
447    expectation: &OmenaFixtureExpectationV0,
448    kind: OmenaFixtureExpectationKindV0,
449    satisfied: bool,
450    detail: impl Into<String>,
451) -> OmenaFixtureExpectationOutcomeV0 {
452    OmenaFixtureExpectationOutcomeV0 {
453        key: expectation.key.clone(),
454        kind,
455        evaluated: true,
456        satisfied,
457        detail: detail.into(),
458    }
459}
460
461fn malformed(
462    expectation: &OmenaFixtureExpectationV0,
463    kind: OmenaFixtureExpectationKindV0,
464    detail: impl Into<String>,
465) -> OmenaFixtureExpectationOutcomeV0 {
466    // A malformed live assertion is a failure, not a deferral: the corpus
467    // declared something the evaluator should check and could not.
468    outcome(expectation, kind, false, detail)
469}
470
471fn deferred(
472    expectation: &OmenaFixtureExpectationV0,
473    kind: OmenaFixtureExpectationKindV0,
474    detail: impl Into<String>,
475) -> OmenaFixtureExpectationOutcomeV0 {
476    OmenaFixtureExpectationOutcomeV0 {
477        key: expectation.key.clone(),
478        kind,
479        evaluated: false,
480        satisfied: false,
481        detail: detail.into(),
482    }
483}
484
485#[cfg(test)]
486mod tests {
487    use super::*;
488    use crate::fixture::parse_omena_fixture_v0;
489
490    fn diag(code: &str) -> OmenaFixtureDiagnosticV0 {
491        OmenaFixtureDiagnosticV0::new(code)
492    }
493
494    fn boundary(reference: &str, state: &str) -> OmenaFixtureBoundaryStateV0 {
495        OmenaFixtureBoundaryStateV0::new(reference, state)
496    }
497
498    fn cascade(winner: &str, witnesses: &[&str]) -> OmenaFixtureCascadeV0 {
499        OmenaFixtureCascadeV0::new(winner, witnesses.iter().map(|id| id.to_string()))
500    }
501
502    const DIAGNOSTIC_FIXTURE: &str = r#"//- src/Card.module.scss dialect:scss
503.card { color: red; }
504--- expect: diagnostic
505code: missingSassSymbol
506--- expect: no-diagnostic missingKeyframes
507--- expect: count missingSassSymbol:1
508"#;
509
510    #[test]
511    fn evaluates_passing_diagnostic_family() -> Result<(), String> {
512        let fixture = parse_omena_fixture_v0(DIAGNOSTIC_FIXTURE)?;
513        // missingSassSymbol present once, missingKeyframes absent.
514        let diagnostics = [diag("missingSassSymbol")];
515        let outcomes = evaluate_omena_fixture_v0(&fixture, &diagnostics, &[], &[]);
516
517        assert_eq!(outcomes.len(), 3);
518        assert!(outcomes.iter().all(|outcome| outcome.evaluated));
519        assert!(
520            outcomes.iter().all(|outcome| outcome.satisfied),
521            "all diagnostic-family assertions should pass: {outcomes:?}"
522        );
523        Ok(())
524    }
525
526    #[test]
527    fn fails_when_expected_diagnostic_is_absent() -> Result<(), String> {
528        let fixture = parse_omena_fixture_v0(DIAGNOSTIC_FIXTURE)?;
529        // Wrong engine output: the expected `missingSassSymbol` never appears.
530        let diagnostics = [diag("missingKeyframes")];
531        let outcomes = evaluate_omena_fixture_v0(&fixture, &diagnostics, &[], &[]);
532
533        let diagnostic = &outcomes[0];
534        assert_eq!(diagnostic.kind, OmenaFixtureExpectationKindV0::Diagnostic);
535        assert!(diagnostic.evaluated);
536        assert!(
537            !diagnostic.satisfied,
538            "absent expected diagnostic must fail: {diagnostic:?}"
539        );
540
541        // no-diagnostic missingKeyframes now fails because it appeared.
542        let no_diagnostic = &outcomes[1];
543        assert_eq!(
544            no_diagnostic.kind,
545            OmenaFixtureExpectationKindV0::NoDiagnostic
546        );
547        assert!(!no_diagnostic.satisfied);
548
549        // count missingSassSymbol:1 now fails: observed 0.
550        let count = &outcomes[2];
551        assert_eq!(count.kind, OmenaFixtureExpectationKindV0::Count);
552        assert!(!count.satisfied);
553        Ok(())
554    }
555
556    #[test]
557    fn correct_fixture_does_not_spuriously_fail() -> Result<(), String> {
558        // Over-correction guard: a fully-correct fixture must report zero
559        // failures across all live families.
560        let fixture = parse_omena_fixture_v0(
561            r#"//- src/Card.module.scss dialect:scss
562.card { color: red; }
563--- expect: count missingSassSymbol:2
564--- expect: no-diagnostic missingKeyframes
565--- expect: boundary-state ext-1 Resolved
566"#,
567        )?;
568        let diagnostics = [diag("missingSassSymbol"), diag("missingSassSymbol")];
569        let boundaries = [boundary("ext-1", "resolved")];
570        let outcomes = evaluate_omena_fixture_v0(&fixture, &diagnostics, &boundaries, &[]);
571
572        let live_failures = outcomes
573            .iter()
574            .filter(|outcome| outcome.evaluated && !outcome.satisfied)
575            .collect::<Vec<_>>();
576        assert!(
577            live_failures.is_empty(),
578            "correct fixture must not spuriously fail: {live_failures:?}"
579        );
580        Ok(())
581    }
582
583    #[test]
584    fn boundary_state_family_matches_lattice_case_insensitively() -> Result<(), String> {
585        let fixture = parse_omena_fixture_v0(
586            r#"//- src/Card.module.scss dialect:scss
587.card { color: red; }
588--- expect: boundary-state ext-1 Resolved
589--- expect: boundary-state ext-2 Partial
590"#,
591        )?;
592        // ext-1 matches (case-insensitive), ext-2 is Stale not Partial → fail.
593        let boundaries = [boundary("ext-1", "resolved"), boundary("ext-2", "stale")];
594        let outcomes = evaluate_omena_fixture_v0(&fixture, &[], &boundaries, &[]);
595
596        assert!(outcomes[0].satisfied, "{:?}", outcomes[0]);
597        assert!(!outcomes[1].satisfied, "{:?}", outcomes[1]);
598        Ok(())
599    }
600
601    #[test]
602    fn missing_boundary_reference_fails_evaluated() -> Result<(), String> {
603        let fixture = parse_omena_fixture_v0(
604            r#"//- src/Card.module.scss dialect:scss
605.card { color: red; }
606--- expect: boundary-state ext-9 Resolved
607"#,
608        )?;
609        let outcomes = evaluate_omena_fixture_v0(&fixture, &[], &[], &[]);
610
611        assert!(outcomes[0].evaluated);
612        assert!(!outcomes[0].satisfied);
613        assert!(outcomes[0].detail.contains("not present"));
614        Ok(())
615    }
616
617    const CASCADE_FIXTURE: &str = r#"//- src/Card.module.scss dialect:scss
618.card { color: red; }
619--- expect: cascade-outcome decl-1
620--- expect: cascade-witness decl-2
621"#;
622
623    #[test]
624    fn evaluates_passing_cascade_families() -> Result<(), String> {
625        let fixture = parse_omena_fixture_v0(CASCADE_FIXTURE)?;
626        // decl-1 won; decl-2 participated as an also-considered witness.
627        let cascades = [cascade("decl-1", &["decl-2"])];
628        let outcomes = evaluate_omena_fixture_v0(&fixture, &[], &[], &cascades);
629
630        assert_eq!(outcomes.len(), 2);
631        let outcome_kinds = outcomes.iter().map(|o| o.kind).collect::<Vec<_>>();
632        assert_eq!(
633            outcome_kinds,
634            vec![
635                OmenaFixtureExpectationKindV0::CascadeOutcome,
636                OmenaFixtureExpectationKindV0::CascadeWitness,
637            ]
638        );
639        assert!(
640            outcomes.iter().all(|outcome| outcome.evaluated),
641            "cascade families are now evaluated, not deferred: {outcomes:?}"
642        );
643        assert!(
644            outcomes.iter().all(|outcome| outcome.satisfied),
645            "correct cascade fixture must pass: {outcomes:?}"
646        );
647        Ok(())
648    }
649
650    #[test]
651    fn fails_on_wrong_cascade_winner_and_absent_witness() -> Result<(), String> {
652        let fixture = parse_omena_fixture_v0(CASCADE_FIXTURE)?;
653        // Wrong engine output: decl-9 won (not decl-1) and decl-2 never appears.
654        let cascades = [cascade("decl-9", &["decl-7"])];
655        let outcomes = evaluate_omena_fixture_v0(&fixture, &[], &[], &cascades);
656
657        let outcome = &outcomes[0];
658        assert_eq!(outcome.kind, OmenaFixtureExpectationKindV0::CascadeOutcome);
659        assert!(outcome.evaluated);
660        assert!(
661            !outcome.satisfied,
662            "wrong cascade winner must fail: {outcome:?}"
663        );
664
665        let witness = &outcomes[1];
666        assert_eq!(witness.kind, OmenaFixtureExpectationKindV0::CascadeWitness);
667        assert!(witness.evaluated);
668        assert!(
669            !witness.satisfied,
670            "absent cascade witness must fail: {witness:?}"
671        );
672        Ok(())
673    }
674
675    #[test]
676    fn cascade_winner_is_always_its_own_witness() -> Result<(), String> {
677        // Over-correction guard: a `cascade-witness` naming the winner passes
678        // even when the consumer did not repeat the winner in the witness list.
679        let fixture = parse_omena_fixture_v0(
680            r#"//- src/Card.module.scss dialect:scss
681.card { color: red; }
682--- expect: cascade-witness decl-1
683"#,
684        )?;
685        let cascades = [cascade("decl-1", &[])];
686        let outcomes = evaluate_omena_fixture_v0(&fixture, &[], &[], &cascades);
687
688        assert!(outcomes[0].evaluated);
689        assert!(outcomes[0].satisfied, "{:?}", outcomes[0]);
690        Ok(())
691    }
692
693    #[test]
694    fn cascade_family_fails_when_no_cascade_supplied() -> Result<(), String> {
695        // The seed-corpus path passes no cascades: a cascade assertion must then
696        // fail as absent (evaluated), never silently pass.
697        let fixture = parse_omena_fixture_v0(CASCADE_FIXTURE)?;
698        let outcomes = evaluate_omena_fixture_v0(&fixture, &[], &[], &[]);
699
700        for outcome in &outcomes {
701            assert!(
702                outcome.evaluated,
703                "cascade family is now evaluated: {outcome:?}"
704            );
705            assert!(
706                !outcome.satisfied,
707                "cascade assertion with no engine cascade must fail: {outcome:?}"
708            );
709        }
710        Ok(())
711    }
712
713    #[test]
714    fn malformed_cascade_outcome_fails_as_evaluated() -> Result<(), String> {
715        let fixture = parse_omena_fixture_v0(
716            r#"//- src/Card.module.scss dialect:scss
717.card { color: red; }
718--- expect: cascade-outcome
719"#,
720        )?;
721        let cascades = [cascade("decl-1", &[])];
722        let outcomes = evaluate_omena_fixture_v0(&fixture, &[], &[], &cascades);
723
724        assert_eq!(
725            outcomes[0].kind,
726            OmenaFixtureExpectationKindV0::CascadeOutcome
727        );
728        assert!(outcomes[0].evaluated);
729        assert!(!outcomes[0].satisfied);
730        Ok(())
731    }
732
733    #[test]
734    fn injected_closure_variant_flattens_per_file_diagnostics() -> Result<(), String> {
735        let fixture = parse_omena_fixture_v0(DIAGNOSTIC_FIXTURE)?;
736        // The consumer supplies the engine via a closure; here we emit the
737        // expected diagnostic for the scss file only.
738        let outcomes = evaluate_omena_fixture_v0_with(&fixture, &[], &[], |file| {
739            if file.path.ends_with(".scss") {
740                vec![diag("missingSassSymbol")]
741            } else {
742                Vec::new()
743            }
744        });
745
746        assert!(
747            outcomes.iter().all(|outcome| outcome.satisfied),
748            "closure-fed evaluation should pass: {outcomes:?}"
749        );
750        Ok(())
751    }
752
753    #[test]
754    fn count_zero_passes_when_diagnostic_absent() -> Result<(), String> {
755        let fixture = parse_omena_fixture_v0(
756            r#"//- src/Card.module.scss dialect:scss
757.card { color: red; }
758--- expect: count unreachableDeclaration:0
759"#,
760        )?;
761        let outcomes = evaluate_omena_fixture_v0(&fixture, &[], &[], &[]);
762
763        assert!(outcomes[0].evaluated);
764        assert!(outcomes[0].satisfied, "{:?}", outcomes[0]);
765        Ok(())
766    }
767
768    #[test]
769    fn malformed_count_fails_as_evaluated() -> Result<(), String> {
770        let fixture = parse_omena_fixture_v0(
771            r#"//- src/Card.module.scss dialect:scss
772.card { color: red; }
773--- expect: count missingSassSymbol
774"#,
775        )?;
776        let outcomes = evaluate_omena_fixture_v0(&fixture, &[diag("missingSassSymbol")], &[], &[]);
777
778        assert_eq!(outcomes[0].kind, OmenaFixtureExpectationKindV0::Count);
779        assert!(outcomes[0].evaluated);
780        assert!(!outcomes[0].satisfied);
781        Ok(())
782    }
783}