Skip to main content

omena_testkit/
fixture.rs

1use serde::Serialize;
2use std::collections::BTreeSet;
3
4/// One reusable fixture seed consumed by the testkit boundary report.
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6pub struct OmenaTestkitFixtureSeedV0 {
7    /// Stable fixture label.
8    pub label: &'static str,
9    /// Fixture lane such as `sass-language` or `cascade-proof`.
10    pub lane: &'static str,
11    /// Raw `omena-fixture-v0` text.
12    pub raw: &'static str,
13    /// Product surfaces expected to consume this fixture.
14    pub expected_products: &'static [&'static str],
15    /// Promotion target for M4.
16    pub promotion_target: &'static str,
17}
18
19/// Parsed reusable fixture.
20#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
21#[serde(rename_all = "camelCase")]
22pub struct OmenaFixtureV0 {
23    /// Fixture grammar version.
24    pub schema_version: &'static str,
25    /// Parsed files.
26    pub files: Vec<OmenaFixtureFileV0>,
27    /// Parsed expectations.
28    pub expectations: Vec<OmenaFixtureExpectationV0>,
29}
30
31/// One file section in a reusable fixture.
32#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
33#[serde(rename_all = "camelCase")]
34pub struct OmenaFixtureFileV0 {
35    /// Workspace-relative file path.
36    pub path: String,
37    /// File-header metadata such as dialect or layer.
38    pub metadata: Vec<OmenaFixtureFileMetadataV0>,
39    /// Markers removed from the source while preserving clean-source offsets.
40    /// Hex-encoded files are raw UTF-8 transports and never interpret markers.
41    pub markers: Vec<OmenaFixtureMarkerV0>,
42    /// File text.
43    pub source: String,
44}
45
46/// One metadata key/value pair from a fixture file header.
47#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
48#[serde(rename_all = "camelCase")]
49pub struct OmenaFixtureFileMetadataV0 {
50    /// Metadata key.
51    pub key: String,
52    /// Metadata value.
53    pub value: String,
54}
55
56/// One marker extracted from fixture source text.
57#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
58#[serde(rename_all = "camelCase")]
59pub struct OmenaFixtureMarkerV0 {
60    /// Marker kind such as `cursor`, `namedPoint`, or `rangeStart`.
61    pub kind: &'static str,
62    /// Optional marker payload.
63    pub name: Option<String>,
64    /// Byte offset in the cleaned source.
65    pub byte_start: usize,
66    /// Byte end in the cleaned source.
67    pub byte_end: usize,
68}
69
70/// One expectation section in a reusable fixture.
71#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
72#[serde(rename_all = "camelCase")]
73pub struct OmenaFixtureExpectationV0 {
74    /// Expectation key.
75    pub key: String,
76    /// Expectation text.
77    pub value: String,
78}
79
80/// Known `omena-fixture-v0` expectation families.
81#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
82#[serde(rename_all = "camelCase")]
83pub enum OmenaFixtureExpectationKindV0 {
84    /// Product-surface expectation.
85    Product,
86    /// Free-form assertion text.
87    Assertion,
88    /// Expected diagnostic with code/range/message details in the body.
89    Diagnostic,
90    /// A diagnostic code or code pattern that must not appear.
91    NoDiagnostic,
92    /// Expected diagnostic count, usually `<code>:<n>`.
93    Count,
94    /// Expected cascade winner declaration id.
95    CascadeOutcome,
96    /// Expected declaration participating in a cascade witness.
97    CascadeWitness,
98    /// Expected external-reference boundary state.
99    BoundaryState,
100    /// Unknown or product-owned expectation family.
101    Unknown,
102}
103
104impl OmenaFixtureExpectationV0 {
105    /// Classify the expectation family without interpreting product-owned
106    /// expectation payloads.
107    pub fn kind(&self) -> OmenaFixtureExpectationKindV0 {
108        omena_fixture_expectation_kind_from_key(&self.key)
109    }
110}
111
112/// Classify a raw expectation key by its leading keyword.
113pub fn omena_fixture_expectation_kind_from_key(key: &str) -> OmenaFixtureExpectationKindV0 {
114    match key.split_whitespace().next().unwrap_or_default() {
115        "product" => OmenaFixtureExpectationKindV0::Product,
116        "assertion" => OmenaFixtureExpectationKindV0::Assertion,
117        "diagnostic" => OmenaFixtureExpectationKindV0::Diagnostic,
118        "no-diagnostic" => OmenaFixtureExpectationKindV0::NoDiagnostic,
119        "count" => OmenaFixtureExpectationKindV0::Count,
120        "cascade-outcome" => OmenaFixtureExpectationKindV0::CascadeOutcome,
121        "cascade-witness" => OmenaFixtureExpectationKindV0::CascadeWitness,
122        "boundary-state" => OmenaFixtureExpectationKindV0::BoundaryState,
123        _ => OmenaFixtureExpectationKindV0::Unknown,
124    }
125}
126
127/// Parsed fixture seed evidence.
128#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
129#[serde(rename_all = "camelCase")]
130pub struct OmenaTestkitFixtureSeedReportV0 {
131    /// Stable fixture label.
132    pub label: &'static str,
133    /// Fixture lane.
134    pub lane: &'static str,
135    /// Whether the fixture parses with `omena-fixture-v0`.
136    pub parses: bool,
137    /// Parse error when present.
138    pub parse_error: Option<String>,
139    /// Parsed file count.
140    pub file_count: usize,
141    /// Parsed expectation count.
142    pub expectation_count: usize,
143    /// Parsed file-header metadata count.
144    pub metadata_count: usize,
145    /// Parsed marker count.
146    pub marker_count: usize,
147    /// Expected product surfaces.
148    pub expected_products: Vec<&'static str>,
149    /// Promotion target for M4.
150    pub promotion_target: &'static str,
151    /// Per-expectation evaluation outcomes.
152    ///
153    /// Evaluated against an empty engine output here (the seed corpus is a
154    /// parse-only substrate with no engine wired): `no-diagnostic` and
155    /// `count <code>:0` families pass, while `diagnostic`/`boundary-state`/
156    /// `cascade-outcome`/`cascade-witness` families fail as absent. An
157    /// engine-backed consumer supplies real diagnostics, boundary states, and
158    /// cascade projections via
159    /// [`crate::fixture_eval::evaluate_omena_fixture_v0`].
160    pub expectation_outcomes: Vec<crate::fixture_eval::OmenaFixtureExpectationOutcomeV0>,
161}
162
163/// Fixture seed corpus summary.
164#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
165#[serde(rename_all = "camelCase")]
166pub struct OmenaTestkitFixtureSeedCorpusReportV0 {
167    /// Schema version.
168    pub schema_version: &'static str,
169    /// Product surface name.
170    pub product: &'static str,
171    /// Fixture grammar.
172    pub fixture_grammar: &'static str,
173    /// Fixture count.
174    pub fixture_count: usize,
175    /// Covered lane count.
176    pub lane_count: usize,
177    /// Parsed metadata count across all seed files.
178    pub metadata_count: usize,
179    /// Parsed marker count across all seed files.
180    pub marker_count: usize,
181    /// Whether every seed parses with the shared fixture grammar.
182    pub all_seeds_parse: bool,
183    /// Seed reports.
184    pub reports: Vec<OmenaTestkitFixtureSeedReportV0>,
185}
186
187/// Summarize any `omena-fixture-v0` fixture seed corpus.
188pub fn summarize_omena_testkit_fixture_seed_corpus(
189    seeds: &[OmenaTestkitFixtureSeedV0],
190) -> OmenaTestkitFixtureSeedCorpusReportV0 {
191    let reports = seeds
192        .iter()
193        .copied()
194        .map(report_fixture_seed)
195        .collect::<Vec<_>>();
196    let all_seeds_parse = reports.iter().all(|report| report.parses);
197    let lane_count = reports
198        .iter()
199        .map(|report| report.lane)
200        .collect::<BTreeSet<_>>()
201        .len();
202    let metadata_count = reports.iter().map(|report| report.metadata_count).sum();
203    let marker_count = reports.iter().map(|report| report.marker_count).sum();
204
205    OmenaTestkitFixtureSeedCorpusReportV0 {
206        schema_version: "0",
207        product: "omena-testkit.fixture-seed-corpus",
208        fixture_grammar: "omena-fixture-v0",
209        fixture_count: reports.len(),
210        lane_count,
211        metadata_count,
212        marker_count,
213        all_seeds_parse,
214        reports,
215    }
216}
217
218/// Parse a `omena-fixture-v0` workspace fixture.
219pub fn parse_omena_fixture_v0(raw: &str) -> Result<OmenaFixtureV0, String> {
220    let mut files = Vec::new();
221    let mut expectations = Vec::new();
222    let mut current_file: Option<OmenaFixtureFileV0> = None;
223    let mut current_expectation: Option<OmenaFixtureExpectationV0> = None;
224
225    for line in raw.lines() {
226        if let Some(header) = line
227            .strip_prefix("--- file:")
228            .or_else(|| line.strip_prefix("//-"))
229        {
230            finish_fixture_section(&mut files, current_file.take());
231            finish_fixture_section(&mut expectations, current_expectation.take());
232            let (path, metadata) = parse_omena_fixture_file_header(header.trim())?;
233            current_file = Some(OmenaFixtureFileV0 {
234                path,
235                metadata,
236                markers: Vec::new(),
237                source: String::new(),
238            });
239            continue;
240        }
241
242        if let Some(key) = line.strip_prefix("--- expect:") {
243            finish_fixture_section(&mut files, current_file.take());
244            finish_fixture_section(&mut expectations, current_expectation.take());
245            current_expectation = Some(OmenaFixtureExpectationV0 {
246                key: key.trim().to_string(),
247                value: String::new(),
248            });
249            continue;
250        }
251
252        if let Some(file) = current_file.as_mut() {
253            push_fixture_line(&mut file.source, line);
254        } else if let Some(expectation) = current_expectation.as_mut() {
255            push_fixture_line(&mut expectation.value, line);
256        } else if !line.trim().is_empty() {
257            return Err("fixture content must start with a file or expect marker".to_string());
258        }
259    }
260
261    finish_fixture_section(&mut files, current_file.take());
262    finish_fixture_section(&mut expectations, current_expectation.take());
263
264    for file in &mut files {
265        let hex_encoded = file
266            .metadata
267            .iter()
268            .any(|metadata| metadata.key == "encoding" && metadata.value == "hex");
269        if hex_encoded {
270            file.source = decode_hex_omena_fixture_file(file.source.as_str())?;
271            continue;
272        }
273        let (cleaned_source, markers) = extract_omena_fixture_markers(&file.source)?;
274        file.source = cleaned_source;
275        file.markers = markers;
276    }
277
278    if files.is_empty() {
279        return Err("fixture must contain at least one file section".to_string());
280    }
281    if expectations.is_empty() {
282        return Err("fixture must contain at least one expectation section".to_string());
283    }
284
285    Ok(OmenaFixtureV0 {
286        schema_version: "0",
287        files,
288        expectations,
289    })
290}
291
292fn report_fixture_seed(seed: OmenaTestkitFixtureSeedV0) -> OmenaTestkitFixtureSeedReportV0 {
293    match parse_omena_fixture_v0(seed.raw) {
294        Ok(fixture) => {
295            let metadata_count = fixture.files.iter().map(|file| file.metadata.len()).sum();
296            let marker_count = fixture.files.iter().map(|file| file.markers.len()).sum();
297            // The seed corpus has no engine wired, so evaluate against empty
298            // engine output: this still exercises the evaluator at the hook
299            // point so the assertions are run, not merely parsed.
300            let expectation_outcomes =
301                crate::fixture_eval::evaluate_omena_fixture_v0(&fixture, &[], &[], &[]);
302            OmenaTestkitFixtureSeedReportV0 {
303                label: seed.label,
304                lane: seed.lane,
305                parses: true,
306                parse_error: None,
307                file_count: fixture.files.len(),
308                expectation_count: fixture.expectations.len(),
309                metadata_count,
310                marker_count,
311                expected_products: seed.expected_products.to_vec(),
312                promotion_target: seed.promotion_target,
313                expectation_outcomes,
314            }
315        }
316        Err(error) => OmenaTestkitFixtureSeedReportV0 {
317            label: seed.label,
318            lane: seed.lane,
319            parses: false,
320            parse_error: Some(error),
321            file_count: 0,
322            expectation_count: 0,
323            metadata_count: 0,
324            marker_count: 0,
325            expected_products: seed.expected_products.to_vec(),
326            promotion_target: seed.promotion_target,
327            expectation_outcomes: Vec::new(),
328        },
329    }
330}
331
332fn finish_fixture_section<T>(sections: &mut Vec<T>, current: Option<T>) {
333    if let Some(section) = current {
334        sections.push(section);
335    }
336}
337
338fn push_fixture_line(buffer: &mut String, line: &str) {
339    if !buffer.is_empty() {
340        buffer.push('\n');
341    }
342    buffer.push_str(line);
343}
344
345fn parse_omena_fixture_file_header(
346    header: &str,
347) -> Result<(String, Vec<OmenaFixtureFileMetadataV0>), String> {
348    let mut parts = header.split_whitespace();
349    let path = parts
350        .next()
351        .ok_or_else(|| "fixture file header must include a path".to_string())?;
352    if path.contains(':') {
353        return Err("fixture file header path must precede metadata".to_string());
354    }
355
356    let mut metadata = Vec::new();
357    for part in parts {
358        let Some((key, value)) = part.split_once(':') else {
359            return Err(format!("fixture metadata `{part}` must use key:value"));
360        };
361        validate_omena_fixture_metadata(key, value)?;
362        metadata.push(OmenaFixtureFileMetadataV0 {
363            key: key.to_string(),
364            value: value.to_string(),
365        });
366    }
367
368    Ok((path.to_string(), metadata))
369}
370
371fn validate_omena_fixture_metadata(key: &str, value: &str) -> Result<(), String> {
372    if value.is_empty() {
373        return Err(format!("fixture metadata `{key}` must have a value"));
374    }
375    match key {
376        "dialect" => match value {
377            "css" | "scss" | "less" => Ok(()),
378            _ => Err("fixture dialect metadata must be css, scss, or less".to_string()),
379        },
380        "encoding" => match value {
381            "hex" => Ok(()),
382            _ => Err("fixture encoding metadata must be hex".to_string()),
383        },
384        "layer" | "composes-from" | "consumer-of" => Ok(()),
385        _ => Err(format!("fixture metadata key `{key}` is not supported")),
386    }
387}
388
389fn decode_hex_omena_fixture_file(source: &str) -> Result<String, String> {
390    let encoded = source.trim();
391    if !encoded.len().is_multiple_of(2) {
392        return Err("hex-encoded fixture file source must have even length".to_string());
393    }
394    let mut bytes = Vec::with_capacity(encoded.len() / 2);
395    for pair in encoded.as_bytes().chunks_exact(2) {
396        let high = hex_nibble(pair[0]).ok_or_else(|| {
397            "hex-encoded fixture file source must contain only hex digits".to_string()
398        })?;
399        let low = hex_nibble(pair[1]).ok_or_else(|| {
400            "hex-encoded fixture file source must contain only hex digits".to_string()
401        })?;
402        bytes.push((high << 4) | low);
403    }
404    String::from_utf8(bytes)
405        .map_err(|_| "hex-encoded fixture file source must decode to UTF-8".to_string())
406}
407
408fn hex_nibble(byte: u8) -> Option<u8> {
409    match byte {
410        b'0'..=b'9' => Some(byte - b'0'),
411        b'a'..=b'f' => Some(byte - b'a' + 10),
412        b'A'..=b'F' => Some(byte - b'A' + 10),
413        _ => None,
414    }
415}
416
417fn extract_omena_fixture_markers(
418    source: &str,
419) -> Result<(String, Vec<OmenaFixtureMarkerV0>), String> {
420    let mut cleaned = String::new();
421    let mut markers = Vec::new();
422    let mut cursor = 0;
423
424    while let Some(relative_start) = source[cursor..].find("/*") {
425        let start = cursor + relative_start;
426        cleaned.push_str(&source[cursor..start]);
427        let Some(relative_end) = source[start + 2..].find("*/") else {
428            return Err("fixture marker comment is unterminated".to_string());
429        };
430        let end = start + 2 + relative_end + 2;
431        let body = &source[start + 2..end - 2];
432        if let Some(marker) = parse_omena_fixture_marker(body, cleaned.len())? {
433            markers.push(marker);
434        } else {
435            cleaned.push_str(&source[start..end]);
436        }
437        cursor = end;
438    }
439
440    cleaned.push_str(&source[cursor..]);
441    Ok((cleaned, markers))
442}
443
444fn parse_omena_fixture_marker(
445    body: &str,
446    byte_offset: usize,
447) -> Result<Option<OmenaFixtureMarkerV0>, String> {
448    if body == "|" {
449        return Ok(Some(omena_fixture_marker("cursor", None, byte_offset)));
450    }
451    if let Some(name) = body.strip_prefix("at:") {
452        return Ok(Some(omena_fixture_marker(
453            "namedPoint",
454            Some(validate_omena_fixture_marker_payload("at", name)?),
455            byte_offset,
456        )));
457    }
458    if let Some(name) = body
459        .strip_prefix("</")
460        .and_then(|name| name.strip_suffix('>'))
461    {
462        return Ok(Some(omena_fixture_marker(
463            "rangeEnd",
464            Some(validate_omena_fixture_marker_payload("range end", name)?),
465            byte_offset,
466        )));
467    }
468    if let Some(name) = body
469        .strip_prefix('<')
470        .and_then(|name| name.strip_suffix('>'))
471    {
472        return Ok(Some(omena_fixture_marker(
473            "rangeStart",
474            Some(validate_omena_fixture_marker_payload("range start", name)?),
475            byte_offset,
476        )));
477    }
478    if let Some(name) = body.strip_prefix("name:") {
479        return Ok(Some(omena_fixture_marker(
480            "nameAnchor",
481            Some(validate_omena_fixture_marker_payload("name", name)?),
482            byte_offset,
483        )));
484    }
485    if let Some(target) = body.strip_prefix("from:") {
486        return Ok(Some(omena_fixture_marker(
487            "linkEnd",
488            Some(validate_omena_fixture_marker_payload("from", target)?),
489            byte_offset,
490        )));
491    }
492    Ok(None)
493}
494
495fn omena_fixture_marker(
496    kind: &'static str,
497    name: Option<String>,
498    byte_offset: usize,
499) -> OmenaFixtureMarkerV0 {
500    OmenaFixtureMarkerV0 {
501        kind,
502        name,
503        byte_start: byte_offset,
504        byte_end: byte_offset,
505    }
506}
507
508fn validate_omena_fixture_marker_payload(kind: &str, value: &str) -> Result<String, String> {
509    if value.is_empty() {
510        return Err(format!("fixture marker `{kind}` must have a value"));
511    }
512    Ok(value.to_string())
513}
514
515#[cfg(test)]
516mod tests {
517    use super::*;
518
519    const CROSS_LANGUAGE_FIXTURE: &str = r#"--- file: src/App.tsx
520import styles from "./Button.module.scss";
521styles.button;
522--- file: src/Button.module.scss
523.button { color: red; }
524--- expect: product
525omena-query.source-syntax-index
526--- expect: assertion
527shared fixture parser keeps source and style files in the same workspace fixture
528"#;
529
530    #[test]
531    fn parses_reusable_omena_fixture_v0_sections() -> Result<(), String> {
532        let fixture = parse_omena_fixture_v0(
533            r#"--- file: src/proof.css
534.a { color: red; }
535--- expect: product
536omena-transform-passes.cascade-proof-obligations
537--- expect: assertion
538proof obligations remain product-visible
539"#,
540        )?;
541
542        assert_eq!(fixture.schema_version, "0");
543        assert_eq!(fixture.files.len(), 1);
544        assert_eq!(fixture.files[0].path, "src/proof.css");
545        assert!(fixture.files[0].source.contains(".a"));
546        assert_eq!(fixture.expectations.len(), 2);
547        assert_eq!(fixture.expectations[0].key, "product");
548        assert_eq!(
549            fixture.expectations[0].value,
550            "omena-transform-passes.cascade-proof-obligations"
551        );
552
553        Ok(())
554    }
555
556    #[test]
557    fn keeps_source_and_style_files_in_one_workspace_fixture() -> Result<(), String> {
558        let fixture = parse_omena_fixture_v0(CROSS_LANGUAGE_FIXTURE)?;
559
560        assert_eq!(fixture.files.len(), 2);
561        assert_eq!(fixture.files[0].path, "src/App.tsx");
562        assert_eq!(fixture.files[1].path, "src/Button.module.scss");
563        assert!(
564            fixture
565                .expectations
566                .iter()
567                .any(|expectation| expectation.value == "omena-query.source-syntax-index")
568        );
569
570        Ok(())
571    }
572
573    #[test]
574    fn parses_omena_fixture_v0_metadata_and_markers() -> Result<(), String> {
575        let fixture = parse_omena_fixture_v0(
576            r#"//- src/Card.module.scss dialect:scss layer:style
577.card { color: /*|*/red; }
578.card/*at:selector*/ { color: blue; }
579.card { color: /*<colorRange>*/green/*</colorRange>*/; }
580.card { composes: item/*from:src/Base.module.scss#item*/; }
581--- expect: product
582omena-testkit.fixture-markers
583"#,
584        )?;
585
586        assert_eq!(fixture.files.len(), 1);
587        assert_eq!(fixture.files[0].path, "src/Card.module.scss");
588        assert_eq!(
589            fixture.files[0]
590                .metadata
591                .iter()
592                .map(|metadata| (metadata.key.as_str(), metadata.value.as_str()))
593                .collect::<Vec<_>>(),
594            vec![("dialect", "scss"), ("layer", "style")]
595        );
596        assert_eq!(
597            fixture.files[0]
598                .markers
599                .iter()
600                .map(|marker| (marker.kind, marker.name.as_deref()))
601                .collect::<Vec<_>>(),
602            vec![
603                ("cursor", None),
604                ("namedPoint", Some("selector")),
605                ("rangeStart", Some("colorRange")),
606                ("rangeEnd", Some("colorRange")),
607                ("linkEnd", Some("src/Base.module.scss#item"))
608            ]
609        );
610        assert!(fixture.files[0].source.contains(".card { color: red; }"));
611        assert!(!fixture.files[0].source.contains("/*|*/"));
612        assert_eq!(
613            fixture.files[0].markers[0].byte_start,
614            ".card { color: ".len()
615        );
616
617        Ok(())
618    }
619
620    #[test]
621    fn keeps_non_fixture_comments_in_source() -> Result<(), String> {
622        let fixture = parse_omena_fixture_v0(
623            r#"//- src/Card.module.css dialect:css
624.card { /* regular comment */ color: red; }
625--- expect: product
626omena-testkit.fixture-markers
627"#,
628        )?;
629
630        assert!(fixture.files[0].source.contains("/* regular comment */"));
631        assert!(fixture.files[0].markers.is_empty());
632
633        Ok(())
634    }
635
636    #[test]
637    fn hex_encoded_file_source_is_marker_inert_and_byte_preserving() -> Result<(), String> {
638        let source = concat!(
639            ".card {\n",
640            "  //---- divider comment\n",
641            "  content: \"--- file: nope /*|*/ /*at:point*/ /*<range>*/ /*</range>*/\";\n",
642            "}\n",
643            "/*"
644        );
645        let raw = format!(
646            "--- file: src/Card.module.scss encoding:hex\n{}\n--- expect: product\nomena-testkit.fixture-encoding\n",
647            hex_encode_for_test(source)
648        );
649        let fixture = parse_omena_fixture_v0(raw.as_str())?;
650
651        assert_eq!(fixture.files.len(), 1);
652        assert_eq!(fixture.files[0].path, "src/Card.module.scss");
653        assert_eq!(fixture.files[0].source, source);
654        assert!(fixture.files[0].markers.is_empty());
655
656        Ok(())
657    }
658
659    #[test]
660    fn hex_encoded_file_source_rejects_non_utf8_bytes() {
661        let result = parse_omena_fixture_v0(
662            "--- file: src/raw.css encoding:hex\nff\n--- expect: product\nomena-testkit.fixture-encoding\n",
663        );
664        assert_eq!(
665            result.err().as_deref(),
666            Some("hex-encoded fixture file source must decode to UTF-8")
667        );
668    }
669
670    #[test]
671    fn classifies_m7_diagnostic_cascade_and_boundary_expectations() -> Result<(), String> {
672        let fixture = parse_omena_fixture_v0(
673            r#"//- src/Nested.module.scss dialect:scss
674.article {
675  &.box { &.fill { padding: 1px; } }
676}
677--- expect: diagnostic
678code: unreachableDeclaration
679range: colorRange
680--- expect: no-diagnostic unspecifiedCascadeTie
681--- expect: count unreachableDeclaration:0
682--- expect: cascade-outcome decl-1
683--- expect: cascade-witness decl-2
684--- expect: boundary-state ext-1 Resolved
685--- expect: boundary-state ext-2 Partial
686--- expect: boundary-state ext-3 Stale
687--- expect: boundary-state ext-4 Missing
688--- expect: boundary-state ext-5 Unresolved
689"#,
690        )?;
691
692        assert_eq!(
693            fixture
694                .expectations
695                .iter()
696                .map(OmenaFixtureExpectationV0::kind)
697                .collect::<Vec<_>>(),
698            vec![
699                OmenaFixtureExpectationKindV0::Diagnostic,
700                OmenaFixtureExpectationKindV0::NoDiagnostic,
701                OmenaFixtureExpectationKindV0::Count,
702                OmenaFixtureExpectationKindV0::CascadeOutcome,
703                OmenaFixtureExpectationKindV0::CascadeWitness,
704                OmenaFixtureExpectationKindV0::BoundaryState,
705                OmenaFixtureExpectationKindV0::BoundaryState,
706                OmenaFixtureExpectationKindV0::BoundaryState,
707                OmenaFixtureExpectationKindV0::BoundaryState,
708                OmenaFixtureExpectationKindV0::BoundaryState,
709            ]
710        );
711        assert_eq!(
712            fixture.expectations[1].key,
713            "no-diagnostic unspecifiedCascadeTie"
714        );
715        assert_eq!(
716            fixture.expectations[2].key,
717            "count unreachableDeclaration:0"
718        );
719        Ok(())
720    }
721
722    #[test]
723    fn rejects_unknown_fixture_metadata() {
724        let error = parse_omena_fixture_v0(
725            r#"//- src/Card.module.css unknown:value
726.card { color: red; }
727--- expect: product
728omena-testkit.fixture-markers
729"#,
730        )
731        .err();
732
733        assert_eq!(
734            error.as_deref(),
735            Some("fixture metadata key `unknown` is not supported")
736        );
737    }
738
739    #[test]
740    fn rejects_fixture_without_sections() {
741        let error = parse_omena_fixture_v0("plain text").err();
742
743        assert_eq!(
744            error.as_deref(),
745            Some("fixture content must start with a file or expect marker")
746        );
747    }
748
749    #[test]
750    fn rejects_fixture_without_expectations() {
751        let error = parse_omena_fixture_v0(
752            r#"--- file: src/Button.module.scss
753.button { color: red; }
754"#,
755        )
756        .err();
757
758        assert_eq!(
759            error.as_deref(),
760            Some("fixture must contain at least one expectation section")
761        );
762    }
763
764    #[test]
765    fn summarizes_external_fixture_seed_corpus() {
766        let seeds = [OmenaTestkitFixtureSeedV0 {
767            label: "external",
768            lane: "consumer",
769            raw: r#"--- file: src/input.css
770.x { color: red; }
771--- expect: product
772consumer.product
773"#,
774            expected_products: &["consumer.product"],
775            promotion_target: "omena-testkit/consumer",
776        }];
777
778        let report = summarize_omena_testkit_fixture_seed_corpus(&seeds);
779
780        assert_eq!(report.product, "omena-testkit.fixture-seed-corpus");
781        assert_eq!(report.fixture_count, 1);
782        assert_eq!(report.lane_count, 1);
783        assert!(report.all_seeds_parse);
784        assert_eq!(report.reports[0].file_count, 1);
785        assert_eq!(report.reports[0].expectation_count, 1);
786    }
787
788    fn hex_encode_for_test(source: &str) -> String {
789        source
790            .as_bytes()
791            .iter()
792            .map(|byte| format!("{byte:02x}"))
793            .collect()
794    }
795}