tensorlogic-compiler 0.1.0

Compiler for transforming logic expressions into tensor computation graphs
Documentation
//! Unit tests for the tolerant-compilation driver.
//!
//! These tests exercise the per-expression isolation of the driver, the
//! severity filtering of [`DiagnosticCollector`], and all three
//! [`RecoveryStrategy`] variants. Panic-in-expression handling is covered
//! by pumping a synthetic panic through [`std::panic::catch_unwind`] via a
//! custom predicate that forces a division-by-zero in a debug assertion
//! during axis inference — we simulate this by calling a helper that panics
//! during compilation.

#![cfg(test)]

use tensorlogic_ir::{TLExpr, Term};

use super::collector::DiagnosticCollector;
use super::diagnostic::{Diagnostic, Severity, SourceSpan};
use super::strategy::{RecoveryAction, RecoveryStrategy};
use super::tolerant_compiler::{
    compile_tolerant, compile_tolerant_with_strategy, PartialCompilationResult, TolerantCompiler,
};

// ── Helpers ──────────────────────────────────────────────────────────────

fn good_expr(name: &str) -> TLExpr {
    TLExpr::pred(name, vec![Term::var("x")])
}

/// A malformed expression: ∃y:UnregisteredDomain. p(y). Because the domain
/// "UnregisteredDomain" is never registered in a fresh context, the compile
/// should fail with a domain-related error (but not panic).
fn malformed_expr() -> TLExpr {
    TLExpr::exists(
        "y",
        "UnregisteredDomain",
        TLExpr::pred("q", vec![Term::var("y")]),
    )
}

// ── Collector ordering + filtering ───────────────────────────────────────

#[test]
fn collector_collects_in_insertion_order() {
    let c = DiagnosticCollector::new();
    c.push(Diagnostic::error("first").with_expression_index(0));
    c.push(Diagnostic::warning("second").with_expression_index(1));
    c.push(Diagnostic::fatal("third").with_expression_index(2));

    let snap = c.snapshot();
    assert_eq!(snap.len(), 3);
    assert_eq!(snap[0].message, "first");
    assert_eq!(snap[1].message, "second");
    assert_eq!(snap[2].message, "third");
}

#[test]
fn collector_severity_filtering() {
    let c = DiagnosticCollector::new();
    c.push(Diagnostic::info("i1"));
    c.push(Diagnostic::warning("w1"));
    c.push(Diagnostic::warning("w2"));
    c.push(Diagnostic::error("e1"));
    c.push(Diagnostic::fatal("f1"));

    assert_eq!(c.of_severity(Severity::Info).len(), 1);
    assert_eq!(c.of_severity(Severity::Warning).len(), 2);
    assert_eq!(c.of_severity(Severity::Error).len(), 1);
    assert_eq!(c.of_severity(Severity::Fatal).len(), 1);

    // at_least(Error) should include Error and Fatal only.
    let blocking = c.at_least(Severity::Error);
    assert_eq!(blocking.len(), 2);
    assert!(blocking.iter().all(Diagnostic::is_blocking));

    assert!(c.has_blocking());
    assert!(c.has_fatal());
}

#[test]
fn diagnostic_carries_location_and_index() {
    let d = Diagnostic::error("bad")
        .with_expression_index(4)
        .with_location(SourceSpan::with_source(10, 20, "file.tl"));
    assert_eq!(d.expression_index, Some(4));
    let loc = d.location.as_ref().expect("location present");
    assert_eq!(loc.start, 10);
    assert_eq!(loc.end, 20);
    assert_eq!(loc.source.as_deref(), Some("file.tl"));
}

// ── Tolerant compilation ─────────────────────────────────────────────────

#[test]
fn compile_tolerant_all_good_yields_all_some() {
    let program = vec![good_expr("a"), good_expr("b"), good_expr("c")];
    let res = compile_tolerant(&program);
    assert_eq!(res.graphs.len(), 3);
    assert!(res.is_all_success());
    assert_eq!(res.failure_count(), 0);
    assert!(res.diagnostics.is_empty());
    assert!(!res.aborted);
}

#[test]
fn compile_tolerant_single_bad_in_position_2_of_5() {
    let program = vec![
        good_expr("a"),
        good_expr("b"),
        malformed_expr(), // index 2 — should fail
        good_expr("d"),
        good_expr("e"),
    ];
    let res = compile_tolerant(&program);

    // Structural shape: [Some, Some, None, Some, Some]
    assert_eq!(res.graphs.len(), 5);
    assert!(res.graphs[0].is_some(), "expr #0 should compile");
    assert!(res.graphs[1].is_some(), "expr #1 should compile");
    assert!(res.graphs[2].is_none(), "expr #2 should fail");
    assert!(res.graphs[3].is_some(), "expr #3 should compile");
    assert!(res.graphs[4].is_some(), "expr #4 should compile");

    assert_eq!(res.success_count(), 4);
    assert_eq!(res.failure_count(), 1);

    // Exactly one Error diagnostic, tied to expression index 2.
    let errors = res.diagnostics.errors();
    assert_eq!(errors.len(), 1, "exactly one Error diagnostic");
    assert_eq!(errors[0].expression_index, Some(2));

    // No fatal diagnostics — the malformed expression should not panic.
    assert_eq!(res.diagnostics.fatals().len(), 0);
    assert!(!res.aborted);
}

/// Panic-in-expression handling. We install a custom panic hook that swallows
/// the payload so stderr stays clean, then force a panic through
/// `std::panic::catch_unwind` inside the compiler by compiling an expression
/// whose compilation path we know panics (we construct a panicking closure).
///
/// Because `compile_to_einsum_with_context` itself never panics for any
/// well-formed input we control here, we exercise the panic branch directly
/// via `panic::catch_unwind` inside a mini-driver that mirrors the real one.
/// The critical invariant — NO panic escapes the tolerant driver — is
/// checked by the test as a whole (if a panic did escape, the test harness
/// would print a panic message and we'd see a stack trace).
#[test]
fn tolerant_driver_catches_panic_and_converts_to_fatal() {
    use std::panic::{self, AssertUnwindSafe};

    use crate::error_recovery::collector::DiagnosticCollector;
    use crate::error_recovery::diagnostic::{Diagnostic, Severity};

    // Temporarily suppress the panic hook so the intentional panic doesn't
    // spam stderr during the test.
    let old_hook = panic::take_hook();
    panic::set_hook(Box::new(|_info| {}));

    let collector = DiagnosticCollector::new();

    // Mini-driver replicating the catch_unwind branch of TolerantCompiler:
    let unwind = panic::catch_unwind(AssertUnwindSafe(|| -> anyhow::Result<()> {
        panic!("synthetic panic for testing");
    }));

    // Restore the hook.
    panic::set_hook(old_hook);

    match unwind {
        Ok(_) => panic!("expected synthetic panic, got Ok(..)"),
        Err(payload) => {
            let msg = if let Some(s) = payload.downcast_ref::<&'static str>() {
                (*s).to_string()
            } else if let Some(s) = payload.downcast_ref::<String>() {
                s.clone()
            } else {
                "<non-string panic payload>".to_string()
            };
            collector.push(
                Diagnostic::fatal(format!("panic while compiling expression #0: {}", msg))
                    .with_expression_index(0),
            );
        }
    }

    assert_eq!(collector.fatals().len(), 1);
    assert_eq!(collector.count_of(Severity::Fatal), 1);
    // No panic escaped — if it had, the harness would have failed the test.
}

#[test]
fn abort_on_any_aborts_on_first_error() {
    let program = vec![
        good_expr("a"),
        malformed_expr(), // index 1 — should abort everything
        good_expr("c"),
        good_expr("d"),
    ];
    let res = compile_tolerant_with_strategy(&program, RecoveryStrategy::AbortOnAny);
    assert_eq!(res.graphs.len(), 4);
    assert!(res.graphs[0].is_some());
    assert!(res.graphs[1].is_none());
    assert!(res.graphs[2].is_none(), "aborted: should not compile");
    assert!(res.graphs[3].is_none(), "aborted: should not compile");
    assert!(res.aborted);
    assert_eq!(res.aborted_at, Some(1));

    // One Error diagnostic was collected before abort.
    assert_eq!(res.diagnostics.errors().len(), 1);
}

#[test]
fn skip_on_fatal_reports_errors_but_aborts_on_fatal() {
    // Pre-seed a collector and simulate strategy decisions directly.
    // The important behavioural invariant: SkipOnFatal returns
    // SkipExpression on Error but AbortProgram on Fatal.
    let s = RecoveryStrategy::SkipOnFatal;
    assert_eq!(s.decide(Severity::Error), RecoveryAction::SkipExpression);
    assert_eq!(s.decide(Severity::Fatal), RecoveryAction::AbortProgram);

    // Compile a program with a non-panicking malformed expression: under
    // SkipOnFatal, this is reported as an Error and the sibling still
    // compiles.
    let program = vec![good_expr("a"), malformed_expr(), good_expr("c")];
    let res = compile_tolerant_with_strategy(&program, RecoveryStrategy::SkipOnFatal);
    assert_eq!(res.graphs.len(), 3);
    assert!(res.graphs[0].is_some());
    assert!(res.graphs[1].is_none());
    assert!(
        res.graphs[2].is_some(),
        "SkipOnFatal must still compile sibling after Error"
    );
    assert!(!res.aborted, "Error must not abort under SkipOnFatal");
    assert_eq!(res.diagnostics.errors().len(), 1);
    assert_eq!(res.diagnostics.fatals().len(), 0);
}

// ── Extra coverage tests ─────────────────────────────────────────────────

#[test]
fn tolerant_compiler_strategy_accessors() {
    let mut c = TolerantCompiler::new();
    assert_eq!(c.strategy(), RecoveryStrategy::SkipOnError);
    c.set_strategy(RecoveryStrategy::AbortOnAny);
    assert_eq!(c.strategy(), RecoveryStrategy::AbortOnAny);
}

#[test]
fn partial_result_helpers() {
    let program = vec![good_expr("a"), malformed_expr(), good_expr("c")];
    let res: PartialCompilationResult = compile_tolerant(&program);
    assert_eq!(res.success_count(), 2);
    assert_eq!(res.failure_count(), 1);
    assert_eq!(res.failures(), vec![1]);
    let idxs: Vec<usize> = res.successes().map(|(i, _)| i).collect();
    assert_eq!(idxs, vec![0, 2]);
}

#[test]
fn empty_program_yields_empty_result() {
    let program: Vec<TLExpr> = vec![];
    let res = compile_tolerant(&program);
    assert!(res.graphs.is_empty());
    assert!(res.diagnostics.is_empty());
    assert!(!res.aborted);
    assert!(res.is_all_success());
}