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
410    let (tx, mut rx) = tokio::sync::mpsc::channel(10_000);
411    let mut engine = Engine::new(tx);
412    if let Err(e) = engine.load(&program) {
413        return VplTestResult {
414            name,
415            passed: false,
416            failure: Some(format!("Engine load error: {e}")),
417            output_events: Vec::new(),
418        };
419    }
420
421    // Parse and process input events
422    let timed_events = match EventFileParser::parse(&fixture.input) {
423        Ok(events) => events,
424        Err(e) => {
425            return VplTestResult {
426                name,
427                passed: false,
428                failure: Some(format!("Input event parse error: {e}")),
429                output_events: Vec::new(),
430            };
431        }
432    };
433
434    let rt = tokio::runtime::Runtime::new().unwrap();
435    let process_result = rt.block_on(async {
436        for timed in &timed_events {
437            engine.process(timed.event.clone()).await?;
438        }
439        Ok::<(), crate::engine::error::EngineError>(())
440    });
441
442    if let Err(e) = process_result {
443        return VplTestResult {
444            name,
445            passed: false,
446            failure: Some(format!("Event processing error: {e}")),
447            output_events: Vec::new(),
448        };
449    }
450
451    // Collect output events
452    let mut output_events = Vec::new();
453    while let Ok(event) = rx.try_recv() {
454        output_events.push(event_to_output(&event));
455    }
456
457    // Run assertions
458    let mut failures = Vec::new();
459
460    // Check EXPECT_NONE
461    if fixture.expect_none && !output_events.is_empty() {
462        failures.push(format!(
463            "expected no output events, got {}:\n{}",
464            output_events.len(),
465            format_events(&output_events)
466        ));
467    }
468
469    // Check EXPECT_COUNT
470    if let Some(expected_count) = fixture.expect_count {
471        if output_events.len() != expected_count {
472            failures.push(format!(
473                "expected {expected_count} output events, got {}",
474                output_events.len()
475            ));
476        }
477    }
478
479    // Check EXPECT (exact event matching)
480    if let Some(ref expect_source) = fixture.expect {
481        match parse_expected_events(expect_source) {
482            Ok(expected) => {
483                if output_events.len() != expected.len() {
484                    failures.push(format!(
485                        "expected {} output events, got {}:\n  expected:\n{}\n  actual:\n{}",
486                        expected.len(),
487                        output_events.len(),
488                        format_events_indented(&expected),
489                        format_events_indented(&output_events)
490                    ));
491                } else {
492                    for (i, (actual, expected)) in
493                        output_events.iter().zip(expected.iter()).enumerate()
494                    {
495                        if let Some(mismatch) = compare_events(actual, expected) {
496                            failures.push(format!("event[{i}]: {mismatch}"));
497                        }
498                    }
499                }
500            }
501            Err(e) => {
502                failures.push(format!("EXPECT parse error: {e}"));
503            }
504        }
505    }
506
507    // Check EXPECT_FIELDS (partial field matching)
508    if let Some(ref expect_fields_source) = fixture.expect_fields {
509        match parse_field_assertions(expect_fields_source) {
510            Ok(assertions) => {
511                for assertion in &assertions {
512                    if let Some(failure) = check_field_assertion(assertion, &output_events) {
513                        failures.push(failure);
514                    }
515                }
516            }
517            Err(e) => {
518                failures.push(format!("EXPECT_FIELDS parse error: {e}"));
519            }
520        }
521    }
522
523    let passed = failures.is_empty();
524    let failure = if failures.is_empty() {
525        None
526    } else {
527        Some(failures.join("\n"))
528    };
529
530    VplTestResult {
531        name,
532        passed,
533        failure,
534        output_events,
535    }
536}
537
538/// Run a fixture that expects a parse/compile error.
539fn run_error_fixture(fixture: &VplTestFixture, expected_error: &str) -> VplTestResult {
540    let name = fixture.name.clone();
541
542    let expected_lower = expected_error.to_lowercase();
543
544    // Try to parse
545    let program = match parse(&fixture.vpl) {
546        Ok(p) => p,
547        Err(e) => {
548            let error_msg = format!("{e}");
549            if error_msg.to_lowercase().contains(&expected_lower) {
550                return VplTestResult {
551                    name,
552                    passed: true,
553                    failure: None,
554                    output_events: Vec::new(),
555                };
556            }
557            return VplTestResult {
558                name,
559                passed: false,
560                failure: Some(format!(
561                    "expected error containing \"{expected_error}\", got: {error_msg}"
562                )),
563                output_events: Vec::new(),
564            };
565        }
566    };
567
568    // Try to load (compile)
569    let (tx, _rx) = tokio::sync::mpsc::channel(10_000);
570    let mut engine = Engine::new(tx);
571    match engine.load(&program) {
572        Ok(()) => VplTestResult {
573            name,
574            passed: false,
575            failure: Some(format!(
576                "expected error containing \"{expected_error}\", but program compiled successfully"
577            )),
578            output_events: Vec::new(),
579        },
580        Err(e) => {
581            let error_msg = format!("{e}");
582            if error_msg.to_lowercase().contains(&expected_lower) {
583                VplTestResult {
584                    name,
585                    passed: true,
586                    failure: None,
587                    output_events: Vec::new(),
588                }
589            } else {
590                VplTestResult {
591                    name,
592                    passed: false,
593                    failure: Some(format!(
594                        "expected error containing \"{expected_error}\", got: {error_msg}"
595                    )),
596                    output_events: Vec::new(),
597                }
598            }
599        }
600    }
601}
602
603/// Compare two events, returning a mismatch description if they differ.
604fn compare_events(actual: &OutputEvent, expected: &OutputEvent) -> Option<String> {
605    if actual.event_type != expected.event_type {
606        return Some(format!(
607            "event type mismatch: expected \"{}\", got \"{}\"",
608            expected.event_type, actual.event_type
609        ));
610    }
611
612    // Check all expected fields are present and match
613    for (key, expected_value) in &expected.fields {
614        match actual.fields.get(key) {
615            Some(actual_value) => {
616                if actual_value != expected_value {
617                    return Some(format!(
618                        "field \"{key}\": expected {expected_value}, got {actual_value}"
619                    ));
620                }
621            }
622            None => {
623                return Some(format!(
624                    "missing expected field \"{key}\" (expected {expected_value})"
625                ));
626            }
627        }
628    }
629
630    None
631}
632
633/// A field assertion for EXPECT_FIELDS.
634#[derive(Debug)]
635struct FieldAssertion {
636    /// Which event index (0-based), or `all` for all events.
637    event_index: EventSelector,
638    /// Field name.
639    field: String,
640    /// Expected value.
641    value: TestValue,
642}
643
644#[derive(Debug)]
645enum EventSelector {
646    /// A specific event index.
647    Index(usize),
648    /// All events must match.
649    All,
650    /// Any event must match.
651    Any,
652}
653
654/// Parse EXPECT_FIELDS section.
655///
656/// Format:
657/// ```text
658/// [0].field_name = value
659/// [all].field_name = value
660/// [any].field_name = value
661/// ```
662fn parse_field_assertions(source: &str) -> Result<Vec<FieldAssertion>, String> {
663    let mut assertions = Vec::new();
664
665    for (line_num, line) in source.lines().enumerate() {
666        let trimmed = line.trim();
667        if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with("//") {
668            continue;
669        }
670
671        // Parse [index].field = value
672        if !trimmed.starts_with('[') {
673            return Err(format!(
674                "line {}: expected '[index].field = value', got: {trimmed}",
675                line_num + 1
676            ));
677        }
678
679        let bracket_end = trimmed
680            .find(']')
681            .ok_or_else(|| format!("line {}: missing ']'", line_num + 1))?;
682
683        let selector_str = &trimmed[1..bracket_end];
684        let event_index = match selector_str {
685            "all" => EventSelector::All,
686            "any" => EventSelector::Any,
687            s => EventSelector::Index(
688                s.parse::<usize>()
689                    .map_err(|_| format!("line {}: invalid index: {s}", line_num + 1))?,
690            ),
691        };
692
693        let rest = trimmed[bracket_end + 1..].trim();
694        let rest = rest
695            .strip_prefix('.')
696            .ok_or_else(|| format!("line {}: expected '.' after ']'", line_num + 1))?;
697
698        let eq_pos = rest
699            .find('=')
700            .ok_or_else(|| format!("line {}: expected '=' in assertion", line_num + 1))?;
701
702        let field = rest[..eq_pos].trim().to_string();
703        let value_str = rest[eq_pos + 1..].trim();
704        let value = parse_test_value(value_str)?;
705
706        assertions.push(FieldAssertion {
707            event_index,
708            field,
709            value,
710        });
711    }
712
713    Ok(assertions)
714}
715
716/// Check a single field assertion against output events.
717fn check_field_assertion(assertion: &FieldAssertion, events: &[OutputEvent]) -> Option<String> {
718    match &assertion.event_index {
719        EventSelector::Index(i) => {
720            let event = events.get(*i)?;
721            match event.fields.get(&assertion.field) {
722                Some(actual) if actual == &assertion.value => None,
723                Some(actual) => Some(format!(
724                    "[{i}].{}: expected {}, got {actual}",
725                    assertion.field, assertion.value
726                )),
727                None => Some(format!("[{i}].{}: field not found", assertion.field)),
728            }
729        }
730        EventSelector::All => {
731            for (i, event) in events.iter().enumerate() {
732                match event.fields.get(&assertion.field) {
733                    Some(actual) if actual == &assertion.value => {}
734                    Some(actual) => {
735                        return Some(format!(
736                            "[all].{}: event[{i}] expected {}, got {actual}",
737                            assertion.field, assertion.value
738                        ));
739                    }
740                    None => {
741                        return Some(format!(
742                            "[all].{}: event[{i}] field not found",
743                            assertion.field
744                        ));
745                    }
746                }
747            }
748            None
749        }
750        EventSelector::Any => {
751            for event in events {
752                if let Some(actual) = event.fields.get(&assertion.field) {
753                    if actual == &assertion.value {
754                        return None;
755                    }
756                }
757            }
758            Some(format!(
759                "[any].{}: no event has value {}",
760                assertion.field, assertion.value
761            ))
762        }
763    }
764}
765
766/// Discover all `.vpl.test` files under a directory.
767pub fn discover_fixtures(dir: &Path) -> Vec<PathBuf> {
768    let mut fixtures = Vec::new();
769    if let Ok(entries) = std::fs::read_dir(dir) {
770        for entry in entries.flatten() {
771            let path = entry.path();
772            if path.is_dir() {
773                fixtures.extend(discover_fixtures(&path));
774            } else if path
775                .file_name()
776                .is_some_and(|n| n.to_string_lossy().ends_with(".vpl.test"))
777            {
778                fixtures.push(path);
779            }
780        }
781    }
782    fixtures.sort();
783    fixtures
784}
785
786/// Run all `.vpl.test` files under a directory and return results.
787pub fn run_all(dir: &Path) -> Vec<VplTestResult> {
788    let fixtures = discover_fixtures(dir);
789    let mut results = Vec::new();
790
791    for path in fixtures {
792        match parse_fixture(&path) {
793            Ok(fixture) => results.push(run_fixture(&fixture)),
794            Err(e) => results.push(VplTestResult {
795                name: path.display().to_string(),
796                passed: false,
797                failure: Some(e),
798                output_events: Vec::new(),
799            }),
800        }
801    }
802
803    results
804}
805
806fn format_events(events: &[OutputEvent]) -> String {
807    events
808        .iter()
809        .map(|e| format!("  {e}"))
810        .collect::<Vec<_>>()
811        .join("\n")
812}
813
814fn format_events_indented(events: &[OutputEvent]) -> String {
815    events
816        .iter()
817        .map(|e| format!("    {e}"))
818        .collect::<Vec<_>>()
819        .join("\n")
820}
821
822#[cfg(test)]
823mod tests {
824    use std::path::PathBuf;
825
826    use super::*;
827
828    #[test]
829    fn test_parse_section_header() {
830        assert_eq!(parse_section_header("=== VPL ==="), Some("VPL".to_string()));
831        assert_eq!(
832            parse_section_header("=== INPUT ==="),
833            Some("INPUT".to_string())
834        );
835        assert_eq!(
836            parse_section_header("=== EXPECT ==="),
837            Some("EXPECT".to_string())
838        );
839        assert_eq!(
840            parse_section_header("=== EXPECT_COUNT ==="),
841            Some("EXPECT_COUNT".to_string())
842        );
843        assert_eq!(parse_section_header("not a section"), None);
844        assert_eq!(parse_section_header("=== ==="), None);
845    }
846
847    #[test]
848    fn test_parse_sections() {
849        let content = r"
850# A test comment
851=== VPL ===
852stream X = Y
853
854=== INPUT ===
855Y { a: 1 }
856
857=== EXPECT ===
858X { a: 1 }
859";
860        let sections = parse_sections(content).unwrap();
861        assert_eq!(sections.len(), 3);
862        assert!(sections.get("VPL").unwrap().contains("stream X = Y"));
863        assert!(sections.get("INPUT").unwrap().contains("Y { a: 1 }"));
864        assert!(sections.get("EXPECT").unwrap().contains("X { a: 1 }"));
865    }
866
867    #[test]
868    fn test_parse_expected_event_line() {
869        let event = parse_expected_event_line(r#"HighTemp { sensor: "S1", temp: 105 }"#).unwrap();
870        assert_eq!(event.event_type, "HighTemp");
871        assert_eq!(
872            event.fields.get("sensor"),
873            Some(&TestValue::Str("S1".to_string()))
874        );
875        assert_eq!(event.fields.get("temp"), Some(&TestValue::Int(105)));
876    }
877
878    #[test]
879    fn test_parse_expected_event_no_fields() {
880        let event = parse_expected_event_line("Alert {}").unwrap();
881        assert_eq!(event.event_type, "Alert");
882        assert!(event.fields.is_empty());
883    }
884
885    #[test]
886    fn test_parse_test_value() {
887        assert_eq!(parse_test_value("42").unwrap(), TestValue::Int(42));
888        assert_eq!(parse_test_value("1.23").unwrap(), TestValue::Float(1.23));
889        assert_eq!(
890            parse_test_value("\"hello\"").unwrap(),
891            TestValue::Str("hello".to_string())
892        );
893        assert_eq!(parse_test_value("true").unwrap(), TestValue::Bool(true));
894        assert_eq!(parse_test_value("null").unwrap(), TestValue::Null);
895    }
896
897    #[test]
898    fn test_parse_fixture_str() {
899        let content = r#"# Test: high temperature alert
900=== VPL ===
901stream HighTemp = SensorReading
902    .where(temperature > 100)
903    .emit(sensor: sensor_id, temp: temperature)
904
905=== INPUT ===
906SensorReading { sensor_id: "S1", temperature: 105 }
907SensorReading { sensor_id: "S2", temperature: 50 }
908
909=== EXPECT ===
910HighTemp { sensor: "S1", temp: 105 }
911"#;
912
913        let fixture = parse_fixture_str(content, &PathBuf::from("test.vpl.test")).unwrap();
914        assert_eq!(fixture.name, "high temperature alert");
915        assert!(fixture.vpl.contains("stream HighTemp"));
916        assert!(fixture.input.contains("SensorReading"));
917        assert!(fixture.expect.is_some());
918    }
919
920    #[test]
921    fn test_run_fixture_high_temp() {
922        let content = r#"# Test: high temperature filter
923=== VPL ===
924stream HighTemp = SensorReading
925    .where(temperature > 100)
926    .emit(sensor: sensor_id, temp: temperature)
927
928=== INPUT ===
929SensorReading { sensor_id: "S1", temperature: 105 }
930SensorReading { sensor_id: "S2", temperature: 50 }
931SensorReading { sensor_id: "S3", temperature: 200 }
932
933=== EXPECT ===
934HighTemp { sensor: "S1", temp: 105 }
935HighTemp { sensor: "S3", temp: 200 }
936"#;
937
938        let fixture = parse_fixture_str(content, &PathBuf::from("test.vpl.test")).unwrap();
939        let result = run_fixture(&fixture);
940        assert!(result.passed, "Test failed: {:?}", result.failure);
941        assert_eq!(result.output_events.len(), 2);
942    }
943
944    #[test]
945    fn test_run_fixture_expect_count() {
946        let content = r"
947=== VPL ===
948stream PassThrough = Event
949    .where(value > 0)
950    .emit(v: value)
951
952=== INPUT ===
953Event { value: 1 }
954Event { value: -1 }
955Event { value: 2 }
956
957=== EXPECT_COUNT ===
9582
959";
960
961        let fixture = parse_fixture_str(content, &PathBuf::from("test.vpl.test")).unwrap();
962        let result = run_fixture(&fixture);
963        assert!(result.passed, "Test failed: {:?}", result.failure);
964    }
965
966    #[test]
967    fn test_run_fixture_expect_none() {
968        let content = r"
969=== VPL ===
970stream HighTemp = SensorReading
971    .where(temperature > 1000)
972    .emit(temp: temperature)
973
974=== INPUT ===
975SensorReading { temperature: 50 }
976SensorReading { temperature: 100 }
977
978=== EXPECT_NONE ===
979";
980
981        let fixture = parse_fixture_str(content, &PathBuf::from("test.vpl.test")).unwrap();
982        let result = run_fixture(&fixture);
983        assert!(result.passed, "Test failed: {:?}", result.failure);
984    }
985
986    #[test]
987    fn test_run_fixture_expect_error() {
988        let content = r"
989=== VPL ===
990stream Bad = Event
991    .where(>>>{{{invalid syntax
992
993=== EXPECT_ERROR ===
994expected
995";
996
997        let fixture = parse_fixture_str(content, &PathBuf::from("test.vpl.test")).unwrap();
998        let result = run_fixture(&fixture);
999        assert!(result.passed, "Test failed: {:?}", result.failure);
1000    }
1001
1002    #[test]
1003    fn test_compare_events_match() {
1004        let actual = OutputEvent {
1005            event_type: "Alert".to_string(),
1006            fields: BTreeMap::from([
1007                ("temp".to_string(), TestValue::Int(105)),
1008                ("sensor".to_string(), TestValue::Str("S1".to_string())),
1009            ]),
1010        };
1011        let expected = OutputEvent {
1012            event_type: "Alert".to_string(),
1013            fields: BTreeMap::from([
1014                ("temp".to_string(), TestValue::Int(105)),
1015                ("sensor".to_string(), TestValue::Str("S1".to_string())),
1016            ]),
1017        };
1018        assert!(compare_events(&actual, &expected).is_none());
1019    }
1020
1021    #[test]
1022    fn test_compare_events_partial_match() {
1023        // Expected only checks specified fields (partial match)
1024        let actual = OutputEvent {
1025            event_type: "Alert".to_string(),
1026            fields: BTreeMap::from([
1027                ("temp".to_string(), TestValue::Int(105)),
1028                ("sensor".to_string(), TestValue::Str("S1".to_string())),
1029                ("extra".to_string(), TestValue::Bool(true)),
1030            ]),
1031        };
1032        let expected = OutputEvent {
1033            event_type: "Alert".to_string(),
1034            fields: BTreeMap::from([("temp".to_string(), TestValue::Int(105))]),
1035        };
1036        // Partial match — expected only asserts on fields it specifies
1037        assert!(compare_events(&actual, &expected).is_none());
1038    }
1039
1040    #[test]
1041    fn test_split_fields() {
1042        let fields = split_fields(r#"name: "hello, world", value: 42"#);
1043        assert_eq!(fields.len(), 2);
1044        assert_eq!(fields[0].trim(), r#"name: "hello, world""#);
1045        assert_eq!(fields[1].trim(), "value: 42");
1046    }
1047
1048    #[test]
1049    fn test_parse_field_assertions() {
1050        let source = r#"
1051[0].temp = 105
1052[all].event_type = "Alert"
1053[any].sensor = "S1"
1054"#;
1055        let assertions = parse_field_assertions(source).unwrap();
1056        assert_eq!(assertions.len(), 3);
1057    }
1058}