Skip to main content

varpulis_runtime/
vpl_test.rs

1//! VPL Test DSL — declarative test format for Varpulis programs.
2//!
3//! `.vpl.test` files are self-contained test fixtures that specify a VPL program,
4//! input events, and expected outputs in a single file. The test runner discovers
5//! these files automatically and executes them as integration tests.
6//!
7//! # File Format
8//!
9//! ```text
10//! # Test: descriptive name
11//! # Comments start with # or //
12//!
13//! === VPL ===
14//! stream HighTemp = SensorReading
15//!     .where(temperature > 100)
16//!     .emit(sensor: sensor_id, temp: temperature)
17//!
18//! === INPUT ===
19//! SensorReading { sensor_id: "S1", temperature: 105 }
20//! SensorReading { sensor_id: "S2", temperature: 50 }
21//! SensorReading { sensor_id: "S3", temperature: 200 }
22//!
23//! === EXPECT ===
24//! HighTemp { sensor: "S1", temp: 105 }
25//! HighTemp { sensor: "S3", temp: 200 }
26//! ```
27//!
28//! # Sections
29//!
30//! - `=== VPL ===` — The VPL program source (required)
31//! - `=== INPUT ===` — Input events in `.evt` format (required)
32//! - `=== EXPECT ===` — Expected output events (optional, order matters)
33//! - `=== EXPECT_COUNT ===` — Expected number of output events (optional)
34//! - `=== EXPECT_NONE ===` — Asserts zero output events (optional)
35//! - `=== EXPECT_ERROR ===` — Expected parse or compilation error substring (optional)
36//! - `=== EXPECT_FIELDS ===` — Assert specific fields on output events (optional)
37
38use std::collections::BTreeMap;
39use std::path::{Path, PathBuf};
40
41use varpulis_core::Value;
42use varpulis_parser::parse;
43
44use crate::event_file::EventFileParser;
45use crate::Engine;
46
47/// A parsed `.vpl.test` fixture.
48#[derive(Debug)]
49pub struct VplTestFixture {
50    /// File path for error reporting.
51    pub path: PathBuf,
52    /// Name extracted from first comment line, or filename.
53    pub name: String,
54    /// VPL program source.
55    pub vpl: String,
56    /// Input events source (`.evt` format).
57    pub input: String,
58    /// Expected output events (parsed from `.evt`-like format).
59    pub expect: Option<String>,
60    /// Expected output count.
61    pub expect_count: Option<usize>,
62    /// Expect no output events.
63    pub expect_none: bool,
64    /// Expected error substring (parse or compile error).
65    pub expect_error: Option<String>,
66    /// Expected fields on output events.
67    pub expect_fields: Option<String>,
68}
69
70/// Result of running a single test fixture.
71#[derive(Debug)]
72pub struct VplTestResult {
73    /// Fixture name.
74    pub name: String,
75    /// Whether the test passed.
76    pub passed: bool,
77    /// Failure message if any.
78    pub failure: Option<String>,
79    /// Actual output events (for diagnostics).
80    pub output_events: Vec<OutputEvent>,
81}
82
83/// Simplified output event for comparison.
84#[derive(Debug, Clone, PartialEq)]
85pub struct OutputEvent {
86    /// Event type name.
87    pub event_type: String,
88    /// Fields as sorted key-value pairs.
89    pub fields: BTreeMap<String, TestValue>,
90}
91
92/// A value in test comparison (simplified from `Value`).
93#[derive(Debug, Clone)]
94pub enum TestValue {
95    Int(i64),
96    Float(f64),
97    Str(String),
98    Bool(bool),
99    Null,
100}
101
102impl PartialEq for TestValue {
103    fn eq(&self, other: &Self) -> bool {
104        match (self, other) {
105            (Self::Int(a), Self::Int(b)) => a == b,
106            (Self::Float(a), Self::Float(b)) => (a - b).abs() < 1e-6,
107            (Self::Str(a), Self::Str(b)) => a == b,
108            (Self::Bool(a), Self::Bool(b)) => a == b,
109            (Self::Null, Self::Null) => true,
110            // Allow int/float cross-comparison
111            (Self::Int(a), Self::Float(b)) | (Self::Float(b), Self::Int(a)) => {
112                (*a as f64 - b).abs() < 1e-6
113            }
114            _ => false,
115        }
116    }
117}
118
119impl std::fmt::Display for TestValue {
120    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
121        match self {
122            Self::Int(v) => write!(f, "{v}"),
123            Self::Float(v) => write!(f, "{v}"),
124            Self::Str(v) => write!(f, "\"{v}\""),
125            Self::Bool(v) => write!(f, "{v}"),
126            Self::Null => write!(f, "null"),
127        }
128    }
129}
130
131impl From<&Value> for TestValue {
132    fn from(v: &Value) -> Self {
133        match v {
134            Value::Int(i) => Self::Int(*i),
135            Value::Float(f) => Self::Float(*f),
136            Value::Str(s) => Self::Str(s.to_string()),
137            Value::Bool(b) => Self::Bool(*b),
138            Value::Null => Self::Null,
139            other => Self::Str(format!("{other}")),
140        }
141    }
142}
143
144impl std::fmt::Display for OutputEvent {
145    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
146        write!(f, "{} {{ ", self.event_type)?;
147        let fields: Vec<_> = self
148            .fields
149            .iter()
150            .map(|(k, v)| format!("{k}: {v}"))
151            .collect();
152        write!(f, "{}", fields.join(", "))?;
153        write!(f, " }}")
154    }
155}
156
157/// Parse a `.vpl.test` file into a fixture.
158pub fn parse_fixture(path: &Path) -> Result<VplTestFixture, String> {
159    let content = std::fs::read_to_string(path)
160        .map_err(|e| format!("Failed to read {}: {e}", path.display()))?;
161    parse_fixture_str(&content, path)
162}
163
164/// Parse a `.vpl.test` fixture from a string.
165pub fn parse_fixture_str(content: &str, path: &Path) -> Result<VplTestFixture, String> {
166    let mut name = path
167        .file_stem()
168        .and_then(|s| s.to_str())
169        .unwrap_or("unknown")
170        .trim_end_matches(".vpl")
171        .to_string();
172
173    // Extract name from first comment line
174    for line in content.lines() {
175        let trimmed = line.trim();
176        if trimmed.is_empty() {
177            continue;
178        }
179        if let Some(comment) = trimmed.strip_prefix('#') {
180            let comment = comment.trim();
181            if let Some(test_name) = comment.strip_prefix("Test:") {
182                name = test_name.trim().to_string();
183            }
184            break;
185        }
186        break;
187    }
188
189    // Split into sections
190    let sections = parse_sections(content)?;
191
192    let vpl = sections
193        .get("VPL")
194        .ok_or_else(|| format!("{}: missing === VPL === section", path.display()))?
195        .clone();
196
197    let input = sections.get("INPUT").cloned().unwrap_or_default();
198
199    let expect = sections.get("EXPECT").cloned();
200    let expect_none = sections.contains_key("EXPECT_NONE");
201    let expect_error = sections.get("EXPECT_ERROR").map(|s| s.trim().to_string());
202    let expect_fields = sections.get("EXPECT_FIELDS").cloned();
203
204    let expect_count = sections.get("EXPECT_COUNT").map(|s| {
205        s.trim()
206            .parse::<usize>()
207            .map_err(|_| format!("{}: invalid EXPECT_COUNT value: {s}", path.display()))
208    });
209    let expect_count = match expect_count {
210        Some(Ok(n)) => Some(n),
211        Some(Err(e)) => return Err(e),
212        None => None,
213    };
214
215    Ok(VplTestFixture {
216        path: path.to_path_buf(),
217        name,
218        vpl,
219        input,
220        expect,
221        expect_count,
222        expect_none,
223        expect_error,
224        expect_fields,
225    })
226}
227
228/// Parse section headers `=== NAME ===` and return content by section name.
229fn parse_sections(content: &str) -> Result<BTreeMap<String, String>, String> {
230    let mut sections = BTreeMap::new();
231    let mut current_section: Option<String> = None;
232    let mut current_content = String::new();
233
234    for line in content.lines() {
235        let trimmed = line.trim();
236        if let Some(section_name) = parse_section_header(trimmed) {
237            // Save previous section
238            if let Some(name) = current_section.take() {
239                sections.insert(name, current_content.trim().to_string());
240            }
241            current_section = Some(section_name);
242            current_content = String::new();
243        } else if current_section.is_some() {
244            current_content.push_str(line);
245            current_content.push('\n');
246        }
247        // Lines before first section are ignored (comments/metadata)
248    }
249
250    // Save last section
251    if let Some(name) = current_section {
252        sections.insert(name, current_content.trim().to_string());
253    }
254
255    Ok(sections)
256}
257
258/// Parse `=== SECTION_NAME ===` and return the name.
259fn parse_section_header(line: &str) -> Option<String> {
260    let line = line.trim();
261    if line.starts_with("===") && line.ends_with("===") {
262        let inner = line.trim_start_matches('=').trim_end_matches('=').trim();
263        if !inner.is_empty() {
264            return Some(inner.to_string());
265        }
266    }
267    None
268}
269
270/// Parse expected output events from the EXPECT section.
271fn parse_expected_events(expect_source: &str) -> Result<Vec<OutputEvent>, String> {
272    let mut events = Vec::new();
273
274    for (line_num, line) in expect_source.lines().enumerate() {
275        let trimmed = line.trim();
276        if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with("//") {
277            continue;
278        }
279
280        // Parse: EventType { field1: value1, field2: value2 }
281        let event = parse_expected_event_line(trimmed)
282            .map_err(|e| format!("EXPECT line {}: {e}", line_num + 1))?;
283        events.push(event);
284    }
285
286    Ok(events)
287}
288
289/// Parse a single expected event line: `EventType { key: value, ... }`
290fn parse_expected_event_line(line: &str) -> Result<OutputEvent, String> {
291    let brace_pos = line
292        .find('{')
293        .ok_or_else(|| format!("expected '{{' in event line: {line}"))?;
294
295    let event_type = line[..brace_pos].trim().to_string();
296    if event_type.is_empty() {
297        return Err(format!("empty event type in: {line}"));
298    }
299
300    let fields_str = line[brace_pos + 1..].trim().trim_end_matches('}').trim();
301
302    let mut fields = BTreeMap::new();
303    if !fields_str.is_empty() {
304        // Split on commas, but respect quoted strings
305        for field in split_fields(fields_str) {
306            let field = field.trim();
307            if field.is_empty() {
308                continue;
309            }
310            let colon_pos = field
311                .find(':')
312                .ok_or_else(|| format!("expected ':' in field: {field}"))?;
313
314            let key = field[..colon_pos].trim().to_string();
315            let value_str = field[colon_pos + 1..].trim();
316
317            let value = parse_test_value(value_str)?;
318            fields.insert(key, value);
319        }
320    }
321
322    Ok(OutputEvent { event_type, fields })
323}
324
325/// Split fields on commas, respecting quoted strings.
326fn split_fields(s: &str) -> Vec<&str> {
327    let mut result = Vec::new();
328    let mut start = 0;
329    let mut in_quotes = false;
330
331    for (i, c) in s.char_indices() {
332        match c {
333            '"' => in_quotes = !in_quotes,
334            ',' if !in_quotes => {
335                result.push(&s[start..i]);
336                start = i + 1;
337            }
338            _ => {}
339        }
340    }
341    if start < s.len() {
342        result.push(&s[start..]);
343    }
344    result
345}
346
347/// Parse a test value from a string representation.
348fn parse_test_value(s: &str) -> Result<TestValue, String> {
349    let s = s.trim();
350    if s == "null" {
351        return Ok(TestValue::Null);
352    }
353    if s == "true" {
354        return Ok(TestValue::Bool(true));
355    }
356    if s == "false" {
357        return Ok(TestValue::Bool(false));
358    }
359    // Quoted string
360    if s.starts_with('"') && s.ends_with('"') {
361        return Ok(TestValue::Str(s[1..s.len() - 1].to_string()));
362    }
363    // Try integer
364    if let Ok(i) = s.parse::<i64>() {
365        return Ok(TestValue::Int(i));
366    }
367    // Try float
368    if let Ok(f) = s.parse::<f64>() {
369        return Ok(TestValue::Float(f));
370    }
371    // Unquoted string (e.g., field references)
372    Ok(TestValue::Str(s.to_string()))
373}
374
375/// Convert an engine output event to a test output event.
376fn event_to_output(event: &crate::Event) -> OutputEvent {
377    let mut fields = BTreeMap::new();
378    for (key, value) in &event.data {
379        fields.insert(key.to_string(), TestValue::from(value));
380    }
381    OutputEvent {
382        event_type: event.event_type.to_string(),
383        fields,
384    }
385}
386
387/// Run a single test fixture and return the result.
388pub fn run_fixture(fixture: &VplTestFixture) -> VplTestResult {
389    let name = fixture.name.clone();
390
391    // Handle EXPECT_ERROR — expect a parse or compile failure
392    if let Some(ref expected_error) = fixture.expect_error {
393        return run_error_fixture(fixture, expected_error);
394    }
395
396    // Parse program
397    let program = match parse(&fixture.vpl) {
398        Ok(p) => p,
399        Err(e) => {
400            return VplTestResult {
401                name,
402                passed: false,
403                failure: Some(format!("VPL parse error: {e}")),
404                output_events: Vec::new(),
405            };
406        }
407    };
408
409    // Create engine and process events
410    #[cfg(feature = "async-runtime")]
411    #[allow(unused_variables)]
412    let (engine, output_events) = {
413        let (tx, mut rx) = tokio::sync::mpsc::channel(10_000);
414        let mut engine = Engine::new(tx);
415        if let Err(e) = engine.load(&program) {
416            return VplTestResult {
417                name,
418                passed: false,
419                failure: Some(format!("Engine load error: {e}")),
420                output_events: Vec::new(),
421            };
422        }
423
424        // Parse and process input events
425        let timed_events = match EventFileParser::parse(&fixture.input) {
426            Ok(events) => events,
427            Err(e) => {
428                return VplTestResult {
429                    name,
430                    passed: false,
431                    failure: Some(format!("Input event parse error: {e}")),
432                    output_events: Vec::new(),
433                };
434            }
435        };
436
437        let rt = tokio::runtime::Runtime::new().unwrap();
438        let process_result = rt.block_on(async {
439            for timed in &timed_events {
440                engine.process(timed.event.clone()).await?;
441            }
442            Ok::<(), crate::engine::error::EngineError>(())
443        });
444
445        if let Err(e) = process_result {
446            return VplTestResult {
447                name,
448                passed: false,
449                failure: Some(format!("Event processing error: {e}")),
450                output_events: Vec::new(),
451            };
452        }
453
454        // Collect output events
455        let mut output_events = Vec::new();
456        while let Ok(event) = rx.try_recv() {
457            output_events.push(event_to_output(&event));
458        }
459        (engine, output_events)
460    };
461
462    #[cfg(not(feature = "async-runtime"))]
463    #[allow(unused_variables, unused_mut)]
464    let (mut engine, output_events) = {
465        let mut engine = Engine::new_benchmark();
466        if let Err(e) = engine.load(&program) {
467            return VplTestResult {
468                name,
469                passed: false,
470                failure: Some(format!("Engine load error: {e}")),
471                output_events: Vec::new(),
472            };
473        }
474
475        let timed_events = match EventFileParser::parse(&fixture.input) {
476            Ok(events) => events,
477            Err(e) => {
478                return VplTestResult {
479                    name,
480                    passed: false,
481                    failure: Some(format!("Input event parse error: {e}")),
482                    output_events: Vec::new(),
483                };
484            }
485        };
486
487        let events: Vec<crate::event::Event> = timed_events.into_iter().map(|t| t.event).collect();
488        let collected = match engine.process_batch_sync_collect(events) {
489            Ok(c) => c,
490            Err(e) => {
491                return VplTestResult {
492                    name,
493                    passed: false,
494                    failure: Some(format!("Event processing error: {e}")),
495                    output_events: Vec::new(),
496                };
497            }
498        };
499
500        let output_events: Vec<_> = collected.iter().map(event_to_output).collect();
501        (engine, output_events)
502    };
503
504    // Run assertions
505    let mut failures = Vec::new();
506
507    // Check EXPECT_NONE
508    if fixture.expect_none && !output_events.is_empty() {
509        failures.push(format!(
510            "expected no output events, got {}:\n{}",
511            output_events.len(),
512            format_events(&output_events)
513        ));
514    }
515
516    // Check EXPECT_COUNT
517    if let Some(expected_count) = fixture.expect_count {
518        if output_events.len() != expected_count {
519            failures.push(format!(
520                "expected {expected_count} output events, got {}",
521                output_events.len()
522            ));
523        }
524    }
525
526    // Check EXPECT (exact event matching)
527    if let Some(ref expect_source) = fixture.expect {
528        match parse_expected_events(expect_source) {
529            Ok(expected) => {
530                if output_events.len() != expected.len() {
531                    failures.push(format!(
532                        "expected {} output events, got {}:\n  expected:\n{}\n  actual:\n{}",
533                        expected.len(),
534                        output_events.len(),
535                        format_events_indented(&expected),
536                        format_events_indented(&output_events)
537                    ));
538                } else {
539                    for (i, (actual, expected)) in
540                        output_events.iter().zip(expected.iter()).enumerate()
541                    {
542                        if let Some(mismatch) = compare_events(actual, expected) {
543                            failures.push(format!("event[{i}]: {mismatch}"));
544                        }
545                    }
546                }
547            }
548            Err(e) => {
549                failures.push(format!("EXPECT parse error: {e}"));
550            }
551        }
552    }
553
554    // Check EXPECT_FIELDS (partial field matching)
555    if let Some(ref expect_fields_source) = fixture.expect_fields {
556        match parse_field_assertions(expect_fields_source) {
557            Ok(assertions) => {
558                for assertion in &assertions {
559                    if let Some(failure) = check_field_assertion(assertion, &output_events) {
560                        failures.push(failure);
561                    }
562                }
563            }
564            Err(e) => {
565                failures.push(format!("EXPECT_FIELDS parse error: {e}"));
566            }
567        }
568    }
569
570    let passed = failures.is_empty();
571    let failure = if failures.is_empty() {
572        None
573    } else {
574        Some(failures.join("\n"))
575    };
576
577    VplTestResult {
578        name,
579        passed,
580        failure,
581        output_events,
582    }
583}
584
585/// Run a fixture that expects a parse/compile error.
586fn run_error_fixture(fixture: &VplTestFixture, expected_error: &str) -> VplTestResult {
587    let name = fixture.name.clone();
588
589    let expected_lower = expected_error.to_lowercase();
590
591    // Try to parse
592    let program = match parse(&fixture.vpl) {
593        Ok(p) => p,
594        Err(e) => {
595            let error_msg = format!("{e}");
596            if error_msg.to_lowercase().contains(&expected_lower) {
597                return VplTestResult {
598                    name,
599                    passed: true,
600                    failure: None,
601                    output_events: Vec::new(),
602                };
603            }
604            return VplTestResult {
605                name,
606                passed: false,
607                failure: Some(format!(
608                    "expected error containing \"{expected_error}\", got: {error_msg}"
609                )),
610                output_events: Vec::new(),
611            };
612        }
613    };
614
615    // Try to load (compile)
616    #[cfg(feature = "async-runtime")]
617    let mut engine = {
618        let (tx, _rx) = tokio::sync::mpsc::channel(10_000);
619        Engine::new(tx)
620    };
621    #[cfg(not(feature = "async-runtime"))]
622    let mut engine = Engine::new_benchmark();
623    match engine.load(&program) {
624        Ok(()) => VplTestResult {
625            name,
626            passed: false,
627            failure: Some(format!(
628                "expected error containing \"{expected_error}\", but program compiled successfully"
629            )),
630            output_events: Vec::new(),
631        },
632        Err(e) => {
633            let error_msg = format!("{e}");
634            if error_msg.to_lowercase().contains(&expected_lower) {
635                VplTestResult {
636                    name,
637                    passed: true,
638                    failure: None,
639                    output_events: Vec::new(),
640                }
641            } else {
642                VplTestResult {
643                    name,
644                    passed: false,
645                    failure: Some(format!(
646                        "expected error containing \"{expected_error}\", got: {error_msg}"
647                    )),
648                    output_events: Vec::new(),
649                }
650            }
651        }
652    }
653}
654
655/// Compare two events, returning a mismatch description if they differ.
656fn compare_events(actual: &OutputEvent, expected: &OutputEvent) -> Option<String> {
657    if actual.event_type != expected.event_type {
658        return Some(format!(
659            "event type mismatch: expected \"{}\", got \"{}\"",
660            expected.event_type, actual.event_type
661        ));
662    }
663
664    // Check all expected fields are present and match
665    for (key, expected_value) in &expected.fields {
666        match actual.fields.get(key) {
667            Some(actual_value) => {
668                if actual_value != expected_value {
669                    return Some(format!(
670                        "field \"{key}\": expected {expected_value}, got {actual_value}"
671                    ));
672                }
673            }
674            None => {
675                return Some(format!(
676                    "missing expected field \"{key}\" (expected {expected_value})"
677                ));
678            }
679        }
680    }
681
682    None
683}
684
685/// A field assertion for EXPECT_FIELDS.
686#[derive(Debug)]
687struct FieldAssertion {
688    /// Which event index (0-based), or `all` for all events.
689    event_index: EventSelector,
690    /// Field name.
691    field: String,
692    /// Expected value.
693    value: TestValue,
694}
695
696#[derive(Debug)]
697enum EventSelector {
698    /// A specific event index.
699    Index(usize),
700    /// All events must match.
701    All,
702    /// Any event must match.
703    Any,
704}
705
706/// Parse EXPECT_FIELDS section.
707///
708/// Format:
709/// ```text
710/// [0].field_name = value
711/// [all].field_name = value
712/// [any].field_name = value
713/// ```
714fn parse_field_assertions(source: &str) -> Result<Vec<FieldAssertion>, String> {
715    let mut assertions = Vec::new();
716
717    for (line_num, line) in source.lines().enumerate() {
718        let trimmed = line.trim();
719        if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with("//") {
720            continue;
721        }
722
723        // Parse [index].field = value
724        if !trimmed.starts_with('[') {
725            return Err(format!(
726                "line {}: expected '[index].field = value', got: {trimmed}",
727                line_num + 1
728            ));
729        }
730
731        let bracket_end = trimmed
732            .find(']')
733            .ok_or_else(|| format!("line {}: missing ']'", line_num + 1))?;
734
735        let selector_str = &trimmed[1..bracket_end];
736        let event_index = match selector_str {
737            "all" => EventSelector::All,
738            "any" => EventSelector::Any,
739            s => EventSelector::Index(
740                s.parse::<usize>()
741                    .map_err(|_| format!("line {}: invalid index: {s}", line_num + 1))?,
742            ),
743        };
744
745        let rest = trimmed[bracket_end + 1..].trim();
746        let rest = rest
747            .strip_prefix('.')
748            .ok_or_else(|| format!("line {}: expected '.' after ']'", line_num + 1))?;
749
750        let eq_pos = rest
751            .find('=')
752            .ok_or_else(|| format!("line {}: expected '=' in assertion", line_num + 1))?;
753
754        let field = rest[..eq_pos].trim().to_string();
755        let value_str = rest[eq_pos + 1..].trim();
756        let value = parse_test_value(value_str)?;
757
758        assertions.push(FieldAssertion {
759            event_index,
760            field,
761            value,
762        });
763    }
764
765    Ok(assertions)
766}
767
768/// Check a single field assertion against output events.
769fn check_field_assertion(assertion: &FieldAssertion, events: &[OutputEvent]) -> Option<String> {
770    match &assertion.event_index {
771        EventSelector::Index(i) => {
772            let event = events.get(*i)?;
773            match event.fields.get(&assertion.field) {
774                Some(actual) if actual == &assertion.value => None,
775                Some(actual) => Some(format!(
776                    "[{i}].{}: expected {}, got {actual}",
777                    assertion.field, assertion.value
778                )),
779                None => Some(format!("[{i}].{}: field not found", assertion.field)),
780            }
781        }
782        EventSelector::All => {
783            for (i, event) in events.iter().enumerate() {
784                match event.fields.get(&assertion.field) {
785                    Some(actual) if actual == &assertion.value => {}
786                    Some(actual) => {
787                        return Some(format!(
788                            "[all].{}: event[{i}] expected {}, got {actual}",
789                            assertion.field, assertion.value
790                        ));
791                    }
792                    None => {
793                        return Some(format!(
794                            "[all].{}: event[{i}] field not found",
795                            assertion.field
796                        ));
797                    }
798                }
799            }
800            None
801        }
802        EventSelector::Any => {
803            for event in events {
804                if let Some(actual) = event.fields.get(&assertion.field) {
805                    if actual == &assertion.value {
806                        return None;
807                    }
808                }
809            }
810            Some(format!(
811                "[any].{}: no event has value {}",
812                assertion.field, assertion.value
813            ))
814        }
815    }
816}
817
818/// Discover all `.vpl.test` files under a directory.
819pub fn discover_fixtures(dir: &Path) -> Vec<PathBuf> {
820    let mut fixtures = Vec::new();
821    if let Ok(entries) = std::fs::read_dir(dir) {
822        for entry in entries.flatten() {
823            let path = entry.path();
824            if path.is_dir() {
825                fixtures.extend(discover_fixtures(&path));
826            } else if path
827                .file_name()
828                .is_some_and(|n| n.to_string_lossy().ends_with(".vpl.test"))
829            {
830                fixtures.push(path);
831            }
832        }
833    }
834    fixtures.sort();
835    fixtures
836}
837
838/// Run all `.vpl.test` files under a directory and return results.
839pub fn run_all(dir: &Path) -> Vec<VplTestResult> {
840    let fixtures = discover_fixtures(dir);
841    let mut results = Vec::new();
842
843    for path in fixtures {
844        match parse_fixture(&path) {
845            Ok(fixture) => results.push(run_fixture(&fixture)),
846            Err(e) => results.push(VplTestResult {
847                name: path.display().to_string(),
848                passed: false,
849                failure: Some(e),
850                output_events: Vec::new(),
851            }),
852        }
853    }
854
855    results
856}
857
858fn format_events(events: &[OutputEvent]) -> String {
859    events
860        .iter()
861        .map(|e| format!("  {e}"))
862        .collect::<Vec<_>>()
863        .join("\n")
864}
865
866fn format_events_indented(events: &[OutputEvent]) -> String {
867    events
868        .iter()
869        .map(|e| format!("    {e}"))
870        .collect::<Vec<_>>()
871        .join("\n")
872}
873
874#[cfg(test)]
875mod tests {
876    use std::path::PathBuf;
877
878    use super::*;
879
880    #[test]
881    fn test_parse_section_header() {
882        assert_eq!(parse_section_header("=== VPL ==="), Some("VPL".to_string()));
883        assert_eq!(
884            parse_section_header("=== INPUT ==="),
885            Some("INPUT".to_string())
886        );
887        assert_eq!(
888            parse_section_header("=== EXPECT ==="),
889            Some("EXPECT".to_string())
890        );
891        assert_eq!(
892            parse_section_header("=== EXPECT_COUNT ==="),
893            Some("EXPECT_COUNT".to_string())
894        );
895        assert_eq!(parse_section_header("not a section"), None);
896        assert_eq!(parse_section_header("=== ==="), None);
897    }
898
899    #[test]
900    fn test_parse_sections() {
901        let content = r"
902# A test comment
903=== VPL ===
904stream X = Y
905
906=== INPUT ===
907Y { a: 1 }
908
909=== EXPECT ===
910X { a: 1 }
911";
912        let sections = parse_sections(content).unwrap();
913        assert_eq!(sections.len(), 3);
914        assert!(sections.get("VPL").unwrap().contains("stream X = Y"));
915        assert!(sections.get("INPUT").unwrap().contains("Y { a: 1 }"));
916        assert!(sections.get("EXPECT").unwrap().contains("X { a: 1 }"));
917    }
918
919    #[test]
920    fn test_parse_expected_event_line() {
921        let event = parse_expected_event_line(r#"HighTemp { sensor: "S1", temp: 105 }"#).unwrap();
922        assert_eq!(event.event_type, "HighTemp");
923        assert_eq!(
924            event.fields.get("sensor"),
925            Some(&TestValue::Str("S1".to_string()))
926        );
927        assert_eq!(event.fields.get("temp"), Some(&TestValue::Int(105)));
928    }
929
930    #[test]
931    fn test_parse_expected_event_no_fields() {
932        let event = parse_expected_event_line("Alert {}").unwrap();
933        assert_eq!(event.event_type, "Alert");
934        assert!(event.fields.is_empty());
935    }
936
937    #[test]
938    fn test_parse_test_value() {
939        assert_eq!(parse_test_value("42").unwrap(), TestValue::Int(42));
940        assert_eq!(parse_test_value("1.23").unwrap(), TestValue::Float(1.23));
941        assert_eq!(
942            parse_test_value("\"hello\"").unwrap(),
943            TestValue::Str("hello".to_string())
944        );
945        assert_eq!(parse_test_value("true").unwrap(), TestValue::Bool(true));
946        assert_eq!(parse_test_value("null").unwrap(), TestValue::Null);
947    }
948
949    #[test]
950    fn test_parse_fixture_str() {
951        let content = r#"# Test: high temperature alert
952=== VPL ===
953stream HighTemp = SensorReading
954    .where(temperature > 100)
955    .emit(sensor: sensor_id, temp: temperature)
956
957=== INPUT ===
958SensorReading { sensor_id: "S1", temperature: 105 }
959SensorReading { sensor_id: "S2", temperature: 50 }
960
961=== EXPECT ===
962HighTemp { sensor: "S1", temp: 105 }
963"#;
964
965        let fixture = parse_fixture_str(content, &PathBuf::from("test.vpl.test")).unwrap();
966        assert_eq!(fixture.name, "high temperature alert");
967        assert!(fixture.vpl.contains("stream HighTemp"));
968        assert!(fixture.input.contains("SensorReading"));
969        assert!(fixture.expect.is_some());
970    }
971
972    #[test]
973    fn test_run_fixture_high_temp() {
974        let content = r#"# Test: high temperature filter
975=== VPL ===
976stream HighTemp = SensorReading
977    .where(temperature > 100)
978    .emit(sensor: sensor_id, temp: temperature)
979
980=== INPUT ===
981SensorReading { sensor_id: "S1", temperature: 105 }
982SensorReading { sensor_id: "S2", temperature: 50 }
983SensorReading { sensor_id: "S3", temperature: 200 }
984
985=== EXPECT ===
986HighTemp { sensor: "S1", temp: 105 }
987HighTemp { sensor: "S3", temp: 200 }
988"#;
989
990        let fixture = parse_fixture_str(content, &PathBuf::from("test.vpl.test")).unwrap();
991        let result = run_fixture(&fixture);
992        assert!(result.passed, "Test failed: {:?}", result.failure);
993        assert_eq!(result.output_events.len(), 2);
994    }
995
996    #[test]
997    fn test_run_fixture_expect_count() {
998        let content = r"
999=== VPL ===
1000stream PassThrough = Event
1001    .where(value > 0)
1002    .emit(v: value)
1003
1004=== INPUT ===
1005Event { value: 1 }
1006Event { value: -1 }
1007Event { value: 2 }
1008
1009=== EXPECT_COUNT ===
10102
1011";
1012
1013        let fixture = parse_fixture_str(content, &PathBuf::from("test.vpl.test")).unwrap();
1014        let result = run_fixture(&fixture);
1015        assert!(result.passed, "Test failed: {:?}", result.failure);
1016    }
1017
1018    #[test]
1019    fn test_run_fixture_expect_none() {
1020        let content = r"
1021=== VPL ===
1022stream HighTemp = SensorReading
1023    .where(temperature > 1000)
1024    .emit(temp: temperature)
1025
1026=== INPUT ===
1027SensorReading { temperature: 50 }
1028SensorReading { temperature: 100 }
1029
1030=== EXPECT_NONE ===
1031";
1032
1033        let fixture = parse_fixture_str(content, &PathBuf::from("test.vpl.test")).unwrap();
1034        let result = run_fixture(&fixture);
1035        assert!(result.passed, "Test failed: {:?}", result.failure);
1036    }
1037
1038    #[test]
1039    fn test_run_fixture_expect_error() {
1040        let content = r"
1041=== VPL ===
1042stream Bad = Event
1043    .where(>>>{{{invalid syntax
1044
1045=== EXPECT_ERROR ===
1046expected
1047";
1048
1049        let fixture = parse_fixture_str(content, &PathBuf::from("test.vpl.test")).unwrap();
1050        let result = run_fixture(&fixture);
1051        assert!(result.passed, "Test failed: {:?}", result.failure);
1052    }
1053
1054    #[test]
1055    fn test_compare_events_match() {
1056        let actual = OutputEvent {
1057            event_type: "Alert".to_string(),
1058            fields: BTreeMap::from([
1059                ("temp".to_string(), TestValue::Int(105)),
1060                ("sensor".to_string(), TestValue::Str("S1".to_string())),
1061            ]),
1062        };
1063        let expected = OutputEvent {
1064            event_type: "Alert".to_string(),
1065            fields: BTreeMap::from([
1066                ("temp".to_string(), TestValue::Int(105)),
1067                ("sensor".to_string(), TestValue::Str("S1".to_string())),
1068            ]),
1069        };
1070        assert!(compare_events(&actual, &expected).is_none());
1071    }
1072
1073    #[test]
1074    fn test_compare_events_partial_match() {
1075        // Expected only checks specified fields (partial match)
1076        let actual = OutputEvent {
1077            event_type: "Alert".to_string(),
1078            fields: BTreeMap::from([
1079                ("temp".to_string(), TestValue::Int(105)),
1080                ("sensor".to_string(), TestValue::Str("S1".to_string())),
1081                ("extra".to_string(), TestValue::Bool(true)),
1082            ]),
1083        };
1084        let expected = OutputEvent {
1085            event_type: "Alert".to_string(),
1086            fields: BTreeMap::from([("temp".to_string(), TestValue::Int(105))]),
1087        };
1088        // Partial match — expected only asserts on fields it specifies
1089        assert!(compare_events(&actual, &expected).is_none());
1090    }
1091
1092    #[test]
1093    fn test_split_fields() {
1094        let fields = split_fields(r#"name: "hello, world", value: 42"#);
1095        assert_eq!(fields.len(), 2);
1096        assert_eq!(fields[0].trim(), r#"name: "hello, world""#);
1097        assert_eq!(fields[1].trim(), "value: 42");
1098    }
1099
1100    #[test]
1101    fn test_parse_field_assertions() {
1102        let source = r#"
1103[0].temp = 105
1104[all].event_type = "Alert"
1105[any].sensor = "S1"
1106"#;
1107        let assertions = parse_field_assertions(source).unwrap();
1108        assert_eq!(assertions.len(), 3);
1109    }
1110}