Skip to main content

omena_testkit/
scenario.rs

1use serde::Serialize;
2
3use crate::fixture::{OmenaFixtureFileV0, OmenaFixtureV0, parse_omena_fixture_v0};
4
5/// v0.1 scenario archetypes supported by the Rust testkit substrate.
6#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
7#[serde(rename_all = "camelCase")]
8pub enum CmeScenarioArchetypeV0 {
9    /// Boundary checks that validate a product surface without executing a
10    /// transform pipeline.
11    Boundary,
12    /// Transform-execution checks that require a style input and a transform
13    /// product expectation.
14    TransformExecute,
15    /// LSP request/response scenarios with source, style, and position markers.
16    LspScenario,
17    /// Shadow runner scenarios exposing `shadow.omena(<verb>)` intent.
18    ShadowOmenaVerb,
19}
20
21impl CmeScenarioArchetypeV0 {
22    /// Stable archetype identifier used by reports and downstream adapters.
23    pub const fn id(self) -> &'static str {
24        match self {
25            Self::Boundary => "boundary",
26            Self::TransformExecute => "transform-execute",
27            Self::LspScenario => "lsp-scenario",
28            Self::ShadowOmenaVerb => "shadow-omena-verb",
29        }
30    }
31}
32
33/// Readiness evidence for one `omena-fixture-v0` scenario.
34#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
35#[serde(rename_all = "camelCase")]
36pub struct CmeScenarioReadinessV0 {
37    /// Whether the fixture parsed successfully before scenario classification.
38    pub fixture_parses: bool,
39    /// Whether the fixture declares at least one product expectation.
40    pub has_expected_products: bool,
41    /// Whether the fixture has the files required by the archetype.
42    pub has_required_files: bool,
43    /// Whether the fixture has the markers required by the archetype.
44    pub has_required_markers: bool,
45    /// Whether the fixture exposes the product or introspection surface expected
46    /// by the archetype.
47    pub has_supported_introspection: bool,
48    /// Whether this scenario is ready for downstream runner adoption.
49    pub ready: bool,
50    /// Reasons preventing runner adoption.
51    pub unsupported_reasons: Vec<&'static str>,
52}
53
54/// Rust call-site evidence captured when a scenario is built through the macro.
55#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
56#[serde(rename_all = "camelCase")]
57pub struct CmeScenarioCallSiteV0 {
58    /// Source file where the scenario macro was invoked.
59    pub file: &'static str,
60    /// 1-based source line where the scenario macro was invoked.
61    pub line: u32,
62    /// 1-based source column where the scenario macro was invoked.
63    pub column: u32,
64}
65
66/// Scenario summary produced by the v0.1 testkit scenario macro substrate.
67#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
68#[serde(rename_all = "camelCase")]
69pub struct CmeScenarioV0 {
70    /// Schema version.
71    pub schema_version: &'static str,
72    /// Product surface.
73    pub product: &'static str,
74    /// Fixture grammar consumed by this scenario.
75    pub fixture_grammar: &'static str,
76    /// Scenario archetype.
77    pub archetype: CmeScenarioArchetypeV0,
78    /// Stable archetype identifier.
79    pub archetype_id: &'static str,
80    /// Parsed file count.
81    pub file_count: usize,
82    /// Parsed source file count.
83    pub source_file_count: usize,
84    /// Parsed style file count.
85    pub style_file_count: usize,
86    /// Parsed expectation count.
87    pub expectation_count: usize,
88    /// Parsed marker count.
89    pub marker_count: usize,
90    /// Parsed metadata count.
91    pub metadata_count: usize,
92    /// Product expectations declared by the fixture.
93    pub expected_products: Vec<String>,
94    /// Extracted `shadow.omena(<verb>)` introspection verbs.
95    pub shadow_omena_verbs: Vec<String>,
96    /// Readiness evidence.
97    pub readiness: CmeScenarioReadinessV0,
98    /// Macro call-site evidence, present only for macro-built scenarios.
99    pub call_site: Option<CmeScenarioCallSiteV0>,
100}
101
102/// One built-in scenario macro seed.
103#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
104#[serde(rename_all = "camelCase")]
105pub struct OmenaTestkitScenarioMacroSeedReportV0 {
106    /// Stable seed label.
107    pub label: &'static str,
108    /// Scenario summary.
109    pub scenario: CmeScenarioV0,
110}
111
112/// Boundary evidence that the scenario macro preserves call-site location data.
113#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
114#[serde(rename_all = "camelCase")]
115pub struct OmenaTestkitScenarioMacroCallSiteProbeV0 {
116    /// Whether the probe scenario carried call-site evidence.
117    pub present: bool,
118    /// Whether the captured file location points at the testkit source file.
119    pub file_suffix_matches: bool,
120    /// Whether the captured line is non-zero.
121    pub line_non_zero: bool,
122    /// Whether the captured column is non-zero.
123    pub column_non_zero: bool,
124}
125
126/// Built-in scenario macro substrate report.
127#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
128#[serde(rename_all = "camelCase")]
129pub struct OmenaTestkitScenarioMacroReportV0 {
130    /// Schema version.
131    pub schema_version: &'static str,
132    /// Product surface.
133    pub product: &'static str,
134    /// Fixture grammar.
135    pub fixture_grammar: &'static str,
136    /// Supported archetype identifiers.
137    pub supported_archetypes: Vec<&'static str>,
138    /// Scenario seed count.
139    pub scenario_count: usize,
140    /// Whether every built-in scenario seed is ready.
141    pub all_scenario_macros_ready: bool,
142    /// Whether macro expansion records Rust call-site evidence.
143    pub call_site_evidence_supported: bool,
144    /// Macro call-site evidence probe.
145    pub call_site_probe: OmenaTestkitScenarioMacroCallSiteProbeV0,
146    /// Built-in scenario seed reports.
147    pub reports: Vec<OmenaTestkitScenarioMacroSeedReportV0>,
148}
149
150struct OmenaTestkitScenarioMacroSeedV0 {
151    label: &'static str,
152    archetype: CmeScenarioArchetypeV0,
153    raw: &'static str,
154}
155
156const SCENARIO_MACRO_SEEDS: &[OmenaTestkitScenarioMacroSeedV0] = &[
157    OmenaTestkitScenarioMacroSeedV0 {
158        label: "boundary-product-scenario",
159        archetype: CmeScenarioArchetypeV0::Boundary,
160        raw: r#"--- file: src/Button.module.scss
161.button { color: red; }
162--- expect: product
163omena-parser.style-facts
164--- expect: assertion
165boundary scenario keeps the product expectation and workspace file together
166"#,
167    },
168    OmenaTestkitScenarioMacroSeedV0 {
169        label: "transform-execute-scenario",
170        archetype: CmeScenarioArchetypeV0::TransformExecute,
171        raw: r#"//- src/Button.module.scss dialect:scss layer:style
172.button { color: light-dark(red, blue); }
173--- expect: product
174omena-query.transform-execute
175--- expect: assertion
176transform-execute scenario carries a style fixture and transform product
177"#,
178    },
179    OmenaTestkitScenarioMacroSeedV0 {
180        label: "lsp-hover-scenario",
181        archetype: CmeScenarioArchetypeV0::LspScenario,
182        raw: r#"--- file: src/App.tsx
183import styles from "./Button.module.scss";
184export const App = () => <div className={styles./*|*/button} />;
185--- file: src/Button.module.scss
186.button { color: red; }
187--- expect: product
188omena-lsp-server.hover
189--- expect: assertion
190lsp scenario carries source, style, and request-position marker evidence
191"#,
192    },
193    OmenaTestkitScenarioMacroSeedV0 {
194        label: "shadow-omena-verb-scenario",
195        archetype: CmeScenarioArchetypeV0::ShadowOmenaVerb,
196        raw: r#"--- file: src/App.tsx
197import styles from "./Button.module.scss";
198const cls = styles.button;
199--- file: src/Button.module.scss
200.button { color: red; }
201--- expect: product
202shadow.omena.hover
203--- expect: product
204shadow.omena.definition
205--- expect: assertion
206shadow scenario exposes product-facing omena verbs without binding to one runner
207"#,
208    },
209];
210
211/// Build a v0.1 scenario summary from a `omena-fixture-v0` fixture.
212pub fn summarize_cme_scenario_v0(
213    raw: &str,
214    archetype: CmeScenarioArchetypeV0,
215) -> Result<CmeScenarioV0, String> {
216    let fixture = parse_omena_fixture_v0(raw)?;
217    Ok(build_cme_scenario_v0(fixture, archetype, None))
218}
219
220/// Build a v0.1 scenario summary and record the macro invocation call-site.
221#[track_caller]
222pub fn summarize_cme_scenario_at_call_site_v0(
223    raw: &str,
224    archetype: CmeScenarioArchetypeV0,
225) -> Result<CmeScenarioV0, String> {
226    let caller = std::panic::Location::caller();
227    let call_site = CmeScenarioCallSiteV0 {
228        file: caller.file(),
229        line: caller.line(),
230        column: caller.column(),
231    };
232    let fixture = parse_omena_fixture_v0(raw)?;
233    Ok(build_cme_scenario_v0(fixture, archetype, Some(call_site)))
234}
235
236/// Construct a `omena-fixture-v0` scenario using the v0.1 archetype macro.
237#[macro_export]
238macro_rules! cme_scenario_v0 {
239    (boundary, $raw:expr) => {
240        $crate::summarize_cme_scenario_at_call_site_v0(
241            $raw,
242            $crate::CmeScenarioArchetypeV0::Boundary,
243        )
244    };
245    (transform_execute, $raw:expr) => {
246        $crate::summarize_cme_scenario_at_call_site_v0(
247            $raw,
248            $crate::CmeScenarioArchetypeV0::TransformExecute,
249        )
250    };
251    (lsp_scenario, $raw:expr) => {
252        $crate::summarize_cme_scenario_at_call_site_v0(
253            $raw,
254            $crate::CmeScenarioArchetypeV0::LspScenario,
255        )
256    };
257    (shadow_omena, $raw:expr) => {
258        $crate::summarize_cme_scenario_at_call_site_v0(
259            $raw,
260            $crate::CmeScenarioArchetypeV0::ShadowOmenaVerb,
261        )
262    };
263}
264
265/// Summarize the built-in v0.1 scenario macro substrate.
266pub fn summarize_omena_testkit_scenario_macro_report() -> OmenaTestkitScenarioMacroReportV0 {
267    let call_site_probe = summarize_scenario_macro_call_site_probe();
268    let call_site_evidence_supported = call_site_probe.present
269        && call_site_probe.file_suffix_matches
270        && call_site_probe.line_non_zero
271        && call_site_probe.column_non_zero;
272    let reports = SCENARIO_MACRO_SEEDS
273        .iter()
274        .map(|seed| OmenaTestkitScenarioMacroSeedReportV0 {
275            label: seed.label,
276            scenario: summarize_cme_scenario_v0(seed.raw, seed.archetype)
277                .unwrap_or_else(|error| scenario_parse_error(seed.archetype, error)),
278        })
279        .collect::<Vec<_>>();
280    let all_scenario_macros_ready = reports.iter().all(|report| report.scenario.readiness.ready);
281
282    OmenaTestkitScenarioMacroReportV0 {
283        schema_version: "0",
284        product: "omena-testkit.scenario-macro-report",
285        fixture_grammar: "omena-fixture-v0",
286        supported_archetypes: vec![
287            CmeScenarioArchetypeV0::Boundary.id(),
288            CmeScenarioArchetypeV0::TransformExecute.id(),
289            CmeScenarioArchetypeV0::LspScenario.id(),
290            CmeScenarioArchetypeV0::ShadowOmenaVerb.id(),
291        ],
292        scenario_count: reports.len(),
293        all_scenario_macros_ready,
294        call_site_evidence_supported,
295        call_site_probe,
296        reports,
297    }
298}
299
300fn summarize_scenario_macro_call_site_probe() -> OmenaTestkitScenarioMacroCallSiteProbeV0 {
301    let scenario = crate::cme_scenario_v0!(boundary, SCENARIO_MACRO_SEEDS[0].raw).ok();
302    let call_site = scenario.and_then(|scenario| scenario.call_site);
303
304    OmenaTestkitScenarioMacroCallSiteProbeV0 {
305        present: call_site.is_some(),
306        file_suffix_matches: call_site
307            .as_ref()
308            .is_some_and(|site| site.file.ends_with("scenario.rs")),
309        line_non_zero: call_site.as_ref().is_some_and(|site| site.line > 0),
310        column_non_zero: call_site.as_ref().is_some_and(|site| site.column > 0),
311    }
312}
313
314fn build_cme_scenario_v0(
315    fixture: OmenaFixtureV0,
316    archetype: CmeScenarioArchetypeV0,
317    call_site: Option<CmeScenarioCallSiteV0>,
318) -> CmeScenarioV0 {
319    let source_file_count = fixture
320        .files
321        .iter()
322        .filter(|file| file_is_source(file))
323        .count();
324    let style_file_count = fixture
325        .files
326        .iter()
327        .filter(|file| file_is_style(file))
328        .count();
329    let expectation_count = fixture.expectations.len();
330    let marker_count = fixture.files.iter().map(|file| file.markers.len()).sum();
331    let metadata_count = fixture.files.iter().map(|file| file.metadata.len()).sum();
332    let expected_products = fixture
333        .expectations
334        .iter()
335        .filter(|expectation| expectation.key == "product")
336        .map(|expectation| expectation.value.clone())
337        .collect::<Vec<_>>();
338    let shadow_omena_verbs = shadow_omena_verbs_from_products(expected_products.as_slice());
339    let readiness = cme_scenario_readiness(
340        archetype,
341        style_file_count,
342        source_file_count,
343        marker_count,
344        expected_products.as_slice(),
345        shadow_omena_verbs.as_slice(),
346    );
347
348    CmeScenarioV0 {
349        schema_version: "0",
350        product: "omena-testkit.scenario",
351        fixture_grammar: "omena-fixture-v0",
352        archetype,
353        archetype_id: archetype.id(),
354        file_count: fixture.files.len(),
355        source_file_count,
356        style_file_count,
357        expectation_count,
358        marker_count,
359        metadata_count,
360        expected_products,
361        shadow_omena_verbs,
362        readiness,
363        call_site,
364    }
365}
366
367fn cme_scenario_readiness(
368    archetype: CmeScenarioArchetypeV0,
369    style_file_count: usize,
370    source_file_count: usize,
371    marker_count: usize,
372    expected_products: &[String],
373    shadow_omena_verbs: &[String],
374) -> CmeScenarioReadinessV0 {
375    let has_expected_products = !expected_products.is_empty();
376    let has_required_files = match archetype {
377        CmeScenarioArchetypeV0::Boundary => style_file_count + source_file_count > 0,
378        CmeScenarioArchetypeV0::TransformExecute => style_file_count > 0,
379        CmeScenarioArchetypeV0::LspScenario => style_file_count > 0 && source_file_count > 0,
380        CmeScenarioArchetypeV0::ShadowOmenaVerb => style_file_count + source_file_count > 0,
381    };
382    let has_required_markers = match archetype {
383        CmeScenarioArchetypeV0::LspScenario => marker_count > 0,
384        CmeScenarioArchetypeV0::Boundary
385        | CmeScenarioArchetypeV0::TransformExecute
386        | CmeScenarioArchetypeV0::ShadowOmenaVerb => true,
387    };
388    let has_supported_introspection = match archetype {
389        CmeScenarioArchetypeV0::Boundary => has_expected_products,
390        CmeScenarioArchetypeV0::TransformExecute => expected_products.iter().any(|product| {
391            product == "omena-query.transform-execute"
392                || product.starts_with("omena-transform-passes.")
393        }),
394        CmeScenarioArchetypeV0::LspScenario => expected_products
395            .iter()
396            .any(|product| product.starts_with("omena-lsp-server.")),
397        CmeScenarioArchetypeV0::ShadowOmenaVerb => !shadow_omena_verbs.is_empty(),
398    };
399    let mut unsupported_reasons = Vec::new();
400    if !has_expected_products {
401        unsupported_reasons.push("missingProductExpectation");
402    }
403    if !has_required_files {
404        unsupported_reasons.push("missingRequiredFiles");
405    }
406    if !has_required_markers {
407        unsupported_reasons.push("missingRequiredMarkers");
408    }
409    if !has_supported_introspection {
410        unsupported_reasons.push("unsupportedIntrospectionSurface");
411    }
412
413    CmeScenarioReadinessV0 {
414        fixture_parses: true,
415        has_expected_products,
416        has_required_files,
417        has_required_markers,
418        has_supported_introspection,
419        ready: unsupported_reasons.is_empty(),
420        unsupported_reasons,
421    }
422}
423
424fn shadow_omena_verbs_from_products(expected_products: &[String]) -> Vec<String> {
425    expected_products
426        .iter()
427        .filter_map(|product| product.strip_prefix("shadow.omena."))
428        .map(ToString::to_string)
429        .collect()
430}
431
432fn scenario_parse_error(archetype: CmeScenarioArchetypeV0, _error: String) -> CmeScenarioV0 {
433    CmeScenarioV0 {
434        schema_version: "0",
435        product: "omena-testkit.scenario",
436        fixture_grammar: "omena-fixture-v0",
437        archetype,
438        archetype_id: archetype.id(),
439        file_count: 0,
440        source_file_count: 0,
441        style_file_count: 0,
442        expectation_count: 0,
443        marker_count: 0,
444        metadata_count: 0,
445        expected_products: Vec::new(),
446        shadow_omena_verbs: Vec::new(),
447        readiness: CmeScenarioReadinessV0 {
448            fixture_parses: false,
449            has_expected_products: false,
450            has_required_files: false,
451            has_required_markers: false,
452            has_supported_introspection: false,
453            ready: false,
454            unsupported_reasons: vec!["fixtureParseError"],
455        },
456        call_site: None,
457    }
458}
459
460fn file_is_source(file: &OmenaFixtureFileV0) -> bool {
461    metadata_value(file, "dialect")
462        .is_some_and(|dialect| matches!(dialect, "ts" | "tsx" | "js" | "jsx"))
463        || file.path.ends_with(".ts")
464        || file.path.ends_with(".tsx")
465        || file.path.ends_with(".js")
466        || file.path.ends_with(".jsx")
467        || file.path.ends_with(".mts")
468        || file.path.ends_with(".cts")
469}
470
471fn file_is_style(file: &OmenaFixtureFileV0) -> bool {
472    metadata_value(file, "dialect")
473        .is_some_and(|dialect| matches!(dialect, "css" | "scss" | "less"))
474        || file.path.ends_with(".css")
475        || file.path.ends_with(".scss")
476        || file.path.ends_with(".sass")
477        || file.path.ends_with(".less")
478}
479
480fn metadata_value<'a>(file: &'a OmenaFixtureFileV0, key: &str) -> Option<&'a str> {
481    file.metadata
482        .iter()
483        .find(|metadata| metadata.key == key)
484        .map(|metadata| metadata.value.as_str())
485}
486
487#[cfg(test)]
488mod tests {
489    use super::*;
490
491    #[test]
492    fn scenario_macro_builds_boundary_archetype_summary() -> Result<(), String> {
493        let scenario = crate::cme_scenario_v0!(
494            boundary,
495            r#"--- file: src/Button.module.css
496.button { color: red; }
497--- expect: product
498omena-parser.style-facts
499"#
500        )?;
501
502        assert_eq!(scenario.product, "omena-testkit.scenario");
503        assert_eq!(scenario.archetype_id, "boundary");
504        assert_eq!(scenario.style_file_count, 1);
505        assert_eq!(scenario.expected_products, vec!["omena-parser.style-facts"]);
506        assert!(scenario.shadow_omena_verbs.is_empty());
507        assert!(scenario.readiness.ready);
508        let call_site = scenario
509            .call_site
510            .as_ref()
511            .ok_or("macro scenario must carry call-site evidence")?;
512        assert!(call_site.file.ends_with("scenario.rs"));
513        assert!(call_site.line > 0);
514        assert!(call_site.column > 0);
515
516        Ok(())
517    }
518
519    #[test]
520    fn scenario_macro_builds_transform_execute_archetype_summary() -> Result<(), String> {
521        let scenario = crate::cme_scenario_v0!(
522            transform_execute,
523            r#"//- src/Button.module.scss dialect:scss
524.button { color: light-dark(red, blue); }
525--- expect: product
526omena-query.transform-execute
527"#
528        )?;
529
530        assert_eq!(scenario.archetype_id, "transform-execute");
531        assert_eq!(scenario.style_file_count, 1);
532        assert_eq!(scenario.metadata_count, 1);
533        assert!(
534            scenario
535                .expected_products
536                .contains(&"omena-query.transform-execute".to_string())
537        );
538        assert!(scenario.readiness.ready);
539        assert!(
540            scenario.call_site.is_some(),
541            "macro scenario must preserve call-site evidence"
542        );
543
544        Ok(())
545    }
546
547    #[test]
548    fn transform_execute_scenario_requires_transform_product() -> Result<(), String> {
549        let scenario = summarize_cme_scenario_v0(
550            r#"--- file: src/Button.module.scss
551.button { color: red; }
552--- expect: product
553omena-parser.style-facts
554"#,
555            CmeScenarioArchetypeV0::TransformExecute,
556        )?;
557
558        assert!(!scenario.readiness.ready);
559        assert_eq!(scenario.call_site, None);
560        assert_eq!(
561            scenario.readiness.unsupported_reasons,
562            vec!["unsupportedIntrospectionSurface"]
563        );
564
565        Ok(())
566    }
567
568    #[test]
569    fn scenario_macro_builds_lsp_archetype_summary() -> Result<(), String> {
570        let scenario = crate::cme_scenario_v0!(
571            lsp_scenario,
572            r#"--- file: src/App.tsx
573import styles from "./Button.module.scss";
574styles./*|*/button;
575--- file: src/Button.module.scss
576.button { color: red; }
577--- expect: product
578omena-lsp-server.definition
579"#
580        )?;
581
582        assert_eq!(scenario.archetype_id, "lsp-scenario");
583        assert_eq!(scenario.source_file_count, 1);
584        assert_eq!(scenario.style_file_count, 1);
585        assert_eq!(scenario.marker_count, 1);
586        assert!(scenario.readiness.has_required_markers);
587        assert!(scenario.readiness.has_supported_introspection);
588        assert!(scenario.readiness.ready);
589
590        Ok(())
591    }
592
593    #[test]
594    fn lsp_scenario_requires_request_marker() -> Result<(), String> {
595        let scenario = summarize_cme_scenario_v0(
596            r#"--- file: src/App.tsx
597import styles from "./Button.module.scss";
598styles.button;
599--- file: src/Button.module.scss
600.button { color: red; }
601--- expect: product
602omena-lsp-server.definition
603"#,
604            CmeScenarioArchetypeV0::LspScenario,
605        )?;
606
607        assert!(!scenario.readiness.ready);
608        assert_eq!(scenario.marker_count, 0);
609        assert_eq!(
610            scenario.readiness.unsupported_reasons,
611            vec!["missingRequiredMarkers"]
612        );
613
614        Ok(())
615    }
616
617    #[test]
618    fn scenario_macro_builds_shadow_omena_verb_summary() -> Result<(), String> {
619        let scenario = crate::cme_scenario_v0!(
620            shadow_omena,
621            r#"--- file: src/App.tsx
622import styles from "./Button.module.scss";
623styles.button;
624--- file: src/Button.module.scss
625.button { color: red; }
626--- expect: product
627shadow.omena.hover
628--- expect: product
629shadow.omena.references
630"#
631        )?;
632
633        assert_eq!(scenario.archetype_id, "shadow-omena-verb");
634        assert_eq!(scenario.shadow_omena_verbs, vec!["hover", "references"]);
635        assert!(scenario.readiness.has_supported_introspection);
636        assert!(scenario.readiness.ready);
637
638        Ok(())
639    }
640}