Files
dagx/src/runner/tests.rs
T

288 lines
7.6 KiB
Rust

//! Unit tests for runner module
use crate::error::DagError;
use crate::runner::DagRunner;
use crate::task::Task;
use crate::types::TaskHandle;
use futures::FutureExt;
// Initialize tracing subscriber for tests (idempotent)
#[cfg(feature = "tracing")]
fn init_tracing() {
use std::sync::Once;
static INIT: Once = Once::new();
INIT.call_once(|| {
tracing_subscriber::fmt()
.with_test_writer()
.with_max_level(tracing::Level::TRACE)
.try_init()
.ok();
});
}
#[cfg(not(feature = "tracing"))]
fn init_tracing() {
// No-op when tracing is disabled
}
struct TestTask {
value: i32,
}
#[crate::task]
impl TestTask {
async fn run(&self) -> i32 {
self.value
}
}
#[test]
fn test_dag_runner_new() {
// Test that DagRunner::new() creates a new instance
let dag = DagRunner::new();
// Initial state should be empty
assert_eq!(dag.nodes.lock().len(), 0);
assert_eq!(dag.edges.lock().len(), 0);
assert_eq!(dag.dependents.lock().len(), 0);
// Adding a task works
dag.add_task(TestTask { value: 42 });
assert_eq!(dag.nodes.lock().len(), 1);
}
#[test]
fn test_dag_runner_default() {
// Test lines 105-106 in runner.rs - Default implementation
let dag = DagRunner::default();
// Should behave the same as new()
assert_eq!(dag.nodes.lock().len(), 0);
assert_eq!(dag.edges.lock().len(), 0);
assert_eq!(dag.dependents.lock().len(), 0);
}
#[tokio::test]
async fn test_get_wrong_type() {
init_tracing();
// Test getting with wrong type - this should return TypeMismatch
let dag = DagRunner::new();
let handle = dag.add_task(TestTask { value: 42 });
dag.run(|fut| tokio::spawn(fut).map(Result::unwrap))
.await
.unwrap();
// Try to get with wrong type - downcast will fail
let fake_handle: TaskHandle<String> = TaskHandle {
id: handle.id,
_phantom: std::marker::PhantomData,
};
let result = dag.get(fake_handle);
assert!(result.is_err());
// When downcast fails, we get TypeMismatch
match result.unwrap_err() {
DagError::TypeMismatch { expected, .. } => {
assert_eq!(expected, std::any::type_name::<String>());
}
_ => panic!("Expected TypeMismatch error"),
}
}
#[tokio::test]
async fn test_get_result_not_found() {
init_tracing();
// Test getting a result before running the DAG
let dag = DagRunner::new();
let handle = dag.add_task(TestTask { value: 42 });
let handle_id = handle.id.0; // Save ID before moving handle
let result = dag.get(handle);
assert!(result.is_err());
match result.unwrap_err() {
DagError::ResultNotFound { task_id } => {
assert_eq!(task_id, handle_id);
}
_ => panic!("Expected ResultNotFound error"),
}
}
#[tokio::test]
async fn test_concurrent_run_protection() {
init_tracing();
// Test that run_lock prevents concurrent runs
use std::sync::Arc;
use std::time::Duration;
use tokio::time::sleep;
let dag = DagRunner::new();
// Add a task that takes some time
struct SlowTask;
#[crate::task]
impl SlowTask {
async fn run(&self) -> i32 {
sleep(Duration::from_millis(100)).await;
42
}
}
dag.add_task(SlowTask);
// Wrap in Arc for sharing between tasks
let dag = Arc::new(dag);
let dag1 = Arc::clone(&dag);
let dag2 = Arc::clone(&dag);
// Use a barrier to ensure both runs start at exactly the same time
let barrier = Arc::new(tokio::sync::Barrier::new(2));
let barrier1 = barrier.clone();
let barrier2 = barrier.clone();
// Start two runs concurrently
let handle1 = tokio::spawn(async move {
barrier1.wait().await;
dag1.run(|fut| tokio::spawn(fut).map(Result::unwrap)).await
});
let handle2 = tokio::spawn(async move {
barrier2.wait().await;
dag2.run(|fut| tokio::spawn(fut).map(Result::unwrap)).await
});
// One should succeed, one should fail (concurrent execution not supported)
let result1 = handle1.await.unwrap();
let result2 = handle2.await.unwrap();
// Exactly one should be ok, one should be err
assert!(result1.is_ok() != result2.is_ok());
// The error should be about concurrent execution
if result1.is_err() {
assert!(matches!(
result1.unwrap_err(),
crate::error::DagError::ConcurrentExecution
));
}
if result2.is_err() {
assert!(matches!(
result2.unwrap_err(),
crate::error::DagError::ConcurrentExecution
));
}
}
#[test]
fn test_add_task_increments_id() {
let dag = DagRunner::new();
let handle1 = dag.add_task(TestTask { value: 1 });
let handle2 = dag.add_task(TestTask { value: 2 });
let handle3 = dag.add_task(TestTask { value: 3 });
// Node IDs should be sequential
assert_eq!(handle1.id.0, 0);
assert_eq!(handle2.id.0, 1);
assert_eq!(handle3.id.0, 2);
// Check that nodes were actually added
assert_eq!(dag.nodes.lock().len(), 3);
}
#[tokio::test]
async fn test_multiple_get_calls() {
init_tracing();
// Test that get() can be called multiple times for the same handle
let dag = DagRunner::new();
let handle = dag.add_task(TestTask { value: 100 });
dag.run(|fut| tokio::spawn(fut).map(Result::unwrap))
.await
.unwrap();
// Multiple get calls should all return the same value
let result1 = dag.get(&handle).unwrap();
let result2 = dag.get(&handle).unwrap();
let result3 = dag.get(&handle).unwrap();
assert_eq!(result1, 100);
assert_eq!(result2, 100);
assert_eq!(result3, 100);
}
#[tokio::test]
async fn test_invalid_node_id_in_get() {
init_tracing();
// Test getting with an invalid node ID
let dag = DagRunner::new();
// Create a handle with an ID that doesn't exist
let invalid_handle: TaskHandle<i32> = TaskHandle {
id: crate::types::NodeId(999),
_phantom: std::marker::PhantomData,
};
dag.run(|fut| tokio::spawn(fut).map(Result::unwrap))
.await
.unwrap();
let result = dag.get(invalid_handle);
assert!(result.is_err());
// With centralized output storage, we get ResultNotFound for invalid node IDs
match result.unwrap_err() {
DagError::ResultNotFound { task_id } => {
assert_eq!(task_id, 999);
}
_ => panic!("Expected ResultNotFound error"),
}
}
#[tokio::test]
async fn test_task_panic_in_multi_task_layer() {
init_tracing();
// Test that a panic in one task of a multi-task layer is caught and reported
struct Source;
#[crate::task]
impl Source {
async fn run(&self) -> i32 {
42
}
}
struct PanicTask;
#[crate::task]
impl PanicTask {
async fn run(&self, _input: &i32) -> i32 {
panic!("This task panics!");
}
}
struct GoodTask;
#[crate::task]
impl GoodTask {
async fn run(&self, input: &i32) -> i32 {
input * 2
}
}
let dag = DagRunner::new();
let source = dag.add_task(Source);
// Create two tasks in the same layer - one panics, one doesn't
let _panic_task = dag.add_task(PanicTask).depends_on(&source);
let _good_task = dag.add_task(GoodTask).depends_on(&source);
// Run the DAG - the panic should be caught
let result = dag.run(|fut| tokio::spawn(fut).map(Result::unwrap)).await;
// The run should fail due to the panic
// Note: Tokio catches panics in spawned tasks, so the behavior depends on the runtime
// This test mainly ensures we don't crash
assert!(result.is_ok() || result.is_err());
}