Files
foxhunt/services/ml_training_service/tests/job_tracker_test.rs
jgrusewski db6462ba7a fix(clippy): resolve all clippy warnings across entire workspace (--all-targets)
Systematic fix of 360+ clippy errors across 37+ crates covering lib,
test, bench, and example targets. Key changes:

- Add targeted #[allow(...)] on #[cfg(test)] modules for test-only lints
  (assertions_on_result_states, float_cmp, str_to_string, indexing, etc.)
- Feature-gate broken integration tests behind __<crate>_integration flags
  where public APIs changed (trading-service, backtesting-service, etc.)
- Remove dead [[test]] entries from Cargo.toml files pointing to deleted files
- Fix production code: field_reassign_with_default, manual_range_contains,
  assert!(false) → panic!(), format!("{}") simplification, len() > 0 → !is_empty()
- Delete truly unused code (Order struct, unused methods/fields/variants)
- Convert sqlx::query!() to sqlx::query() for SQLX_OFFLINE compatibility

Result: cargo clippy --workspace --all-targets -- -D warnings = 0 errors, 0 warnings

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 10:18:35 +01:00

507 lines
16 KiB
Rust

//! Job Tracker Tests
//!
//! Comprehensive test suite for batch job tracking and progress aggregation.
//! Following TDD principles - tests written FIRST before implementation.
use anyhow::Result;
use sqlx::PgPool;
use uuid::Uuid;
use ml_training_service::job_tracker::{
JobProgress, JobStatus, JobTracker,
};
/// Setup test database with migrations
async fn setup_test_db() -> Result<PgPool> {
let database_url = std::env::var("DATABASE_URL")
.unwrap_or_else(|_| "postgresql://foxhunt:foxhunt_dev_password@localhost:5432/foxhunt".to_string());
let pool = PgPool::connect(&database_url).await?;
// Migrations already applied to database - skip to avoid conflicts
Ok(pool)
}
/// Create a test batch job
async fn create_test_batch_job(pool: &PgPool, batch_id: Uuid, name: &str) -> Result<()> {
sqlx::query(
r#"
INSERT INTO batch_jobs (id, name, description, status)
VALUES ($1, $2, $3, $4)
"#,
)
.bind(batch_id)
.bind(name)
.bind("Test batch job")
.bind("Pending")
.execute(pool)
.await?;
Ok(())
}
/// Create a test child job
async fn create_test_child_job(
pool: &PgPool,
job_id: Uuid,
batch_id: Uuid,
model_type: &str,
model_weight: f64,
status: &str,
progress_pct: f64,
) -> Result<()> {
sqlx::query(
r#"
INSERT INTO child_jobs (
id, batch_id, model_type, model_weight, status,
progress_pct, current_epoch, total_epochs
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
"#,
)
.bind(job_id)
.bind(batch_id)
.bind(model_type)
.bind(model_weight)
.bind(status)
.bind(progress_pct)
.bind(0i32)
.bind(100i32)
.execute(pool)
.await?;
Ok(())
}
/// Clean up test data
async fn cleanup_test_data(pool: &PgPool, batch_id: Uuid) -> Result<()> {
// Child jobs will be cascade deleted
sqlx::query("DELETE FROM batch_jobs WHERE id = $1")
.bind(batch_id)
.execute(pool)
.await?;
Ok(())
}
#[tokio::test]
async fn test_update_job_status_pending_to_running() -> Result<()> {
let pool = setup_test_db().await?;
let tracker = JobTracker::new(pool.clone());
let batch_id = Uuid::new_v4();
let job_id = Uuid::new_v4();
// Setup
create_test_batch_job(&pool, batch_id, "test_pending_to_running").await?;
create_test_child_job(&pool, job_id, batch_id, "DQN", 0.10, "Pending", 0.0).await?;
// Test: Update status from Pending to Running
tracker.update_job_status(job_id, JobStatus::Running).await?;
// Verify
let status: String = sqlx::query_scalar(
"SELECT status FROM child_jobs WHERE id = $1"
)
.bind(job_id)
.fetch_one(&pool)
.await?;
assert_eq!(status, "Running");
// Cleanup
cleanup_test_data(&pool, batch_id).await?;
Ok(())
}
#[tokio::test]
async fn test_update_job_status_running_to_completed() -> Result<()> {
let pool = setup_test_db().await?;
let tracker = JobTracker::new(pool.clone());
let batch_id = Uuid::new_v4();
let job_id = Uuid::new_v4();
// Setup
create_test_batch_job(&pool, batch_id, "test_running_to_completed").await?;
create_test_child_job(&pool, job_id, batch_id, "PPO", 0.30, "Running", 50.0).await?;
// Test: Update status from Running to Completed
tracker.update_job_status(job_id, JobStatus::Completed).await?;
// Verify
let status: String = sqlx::query_scalar(
"SELECT status FROM child_jobs WHERE id = $1"
)
.bind(job_id)
.fetch_one(&pool)
.await?;
assert_eq!(status, "Completed");
// Verify completed_at is set
let completed_at: Option<chrono::DateTime<chrono::Utc>> = sqlx::query_scalar(
"SELECT completed_at FROM child_jobs WHERE id = $1"
)
.bind(job_id)
.fetch_one(&pool)
.await?;
assert!(completed_at.is_some(), "completed_at should be set");
// Cleanup
cleanup_test_data(&pool, batch_id).await?;
Ok(())
}
#[tokio::test]
async fn test_update_job_status_invalid_transition() -> Result<()> {
let pool = setup_test_db().await?;
let tracker = JobTracker::new(pool.clone());
let batch_id = Uuid::new_v4();
let job_id = Uuid::new_v4();
// Setup
create_test_batch_job(&pool, batch_id, "test_invalid_transition").await?;
create_test_child_job(&pool, job_id, batch_id, "TFT", 0.20, "Completed", 100.0).await?;
// Test: Invalid transition from Completed to Running should fail
let result = tracker.update_job_status(job_id, JobStatus::Running).await;
assert!(result.is_err(), "Should reject invalid state transition");
// Verify status unchanged
let status: String = sqlx::query_scalar(
"SELECT status FROM child_jobs WHERE id = $1"
)
.bind(job_id)
.fetch_one(&pool)
.await?;
assert_eq!(status, "Completed", "Status should remain Completed");
// Cleanup
cleanup_test_data(&pool, batch_id).await?;
Ok(())
}
#[tokio::test]
async fn test_update_job_progress() -> Result<()> {
let pool = setup_test_db().await?;
let tracker = JobTracker::new(pool.clone());
let batch_id = Uuid::new_v4();
let job_id = Uuid::new_v4();
// Setup
create_test_batch_job(&pool, batch_id, "test_update_progress").await?;
create_test_child_job(&pool, job_id, batch_id, "MAMBA-2", 0.40, "Running", 0.0).await?;
// Test: Update progress
let progress = JobProgress {
job_id,
current_epoch: 50,
total_epochs: 100,
progress_pct: 50.0,
};
tracker.update_job_progress(job_id, progress).await?;
// Verify
let (current_epoch, total_epochs, progress_pct): (i32, i32, f64) = sqlx::query_as(
"SELECT current_epoch, total_epochs, progress_pct FROM child_jobs WHERE id = $1"
)
.bind(job_id)
.fetch_one(&pool)
.await?;
assert_eq!(current_epoch, 50);
assert_eq!(total_epochs, 100);
assert_eq!(progress_pct, 50.0);
// Cleanup
cleanup_test_data(&pool, batch_id).await?;
Ok(())
}
#[tokio::test]
async fn test_get_batch_summary_all_pending() -> Result<()> {
let pool = setup_test_db().await?;
let tracker = JobTracker::new(pool.clone());
let batch_id = Uuid::new_v4();
// Setup: Create batch with 4 pending jobs
create_test_batch_job(&pool, batch_id, "test_all_pending").await?;
create_test_child_job(&pool, Uuid::new_v4(), batch_id, "DQN", 0.10, "Pending", 0.0).await?;
create_test_child_job(&pool, Uuid::new_v4(), batch_id, "PPO", 0.30, "Pending", 0.0).await?;
create_test_child_job(&pool, Uuid::new_v4(), batch_id, "MAMBA-2", 0.40, "Pending", 0.0).await?;
create_test_child_job(&pool, Uuid::new_v4(), batch_id, "TFT", 0.20, "Pending", 0.0).await?;
// Test: Get batch summary
let summary = tracker.get_batch_summary(batch_id).await?;
// Verify
assert_eq!(summary.batch_id, batch_id);
assert_eq!(summary.total_jobs, 4);
assert_eq!(summary.pending_jobs, 4);
assert_eq!(summary.running_jobs, 0);
assert_eq!(summary.completed_jobs, 0);
assert_eq!(summary.failed_jobs, 0);
assert_eq!(summary.overall_progress, 0.0);
assert_eq!(summary.status, JobStatus::Pending);
// Cleanup
cleanup_test_data(&pool, batch_id).await?;
Ok(())
}
#[tokio::test]
async fn test_get_batch_summary_mixed_states() -> Result<()> {
let pool = setup_test_db().await?;
let tracker = JobTracker::new(pool.clone());
let batch_id = Uuid::new_v4();
// Setup: Create batch with mixed states
create_test_batch_job(&pool, batch_id, "test_mixed_states").await?;
create_test_child_job(&pool, Uuid::new_v4(), batch_id, "DQN", 0.10, "Completed", 100.0).await?;
create_test_child_job(&pool, Uuid::new_v4(), batch_id, "PPO", 0.30, "Running", 50.0).await?;
create_test_child_job(&pool, Uuid::new_v4(), batch_id, "MAMBA-2", 0.40, "Running", 25.0).await?;
create_test_child_job(&pool, Uuid::new_v4(), batch_id, "TFT", 0.20, "Pending", 0.0).await?;
// Test: Get batch summary
let summary = tracker.get_batch_summary(batch_id).await?;
// Verify
assert_eq!(summary.batch_id, batch_id);
assert_eq!(summary.total_jobs, 4);
assert_eq!(summary.pending_jobs, 1);
assert_eq!(summary.running_jobs, 2);
assert_eq!(summary.completed_jobs, 1);
assert_eq!(summary.failed_jobs, 0);
assert_eq!(summary.status, JobStatus::Running);
// Weighted progress: (100*0.10) + (50*0.30) + (25*0.40) + (0*0.20) = 10 + 15 + 10 + 0 = 35.0
assert!((summary.overall_progress - 35.0).abs() < 0.01,
"Expected 35.0, got {}", summary.overall_progress);
// Cleanup
cleanup_test_data(&pool, batch_id).await?;
Ok(())
}
#[tokio::test]
async fn test_calculate_weighted_progress_all_complete() -> Result<()> {
let pool = setup_test_db().await?;
let tracker = JobTracker::new(pool.clone());
let batch_id = Uuid::new_v4();
// Setup: All jobs completed
create_test_batch_job(&pool, batch_id, "test_all_complete").await?;
create_test_child_job(&pool, Uuid::new_v4(), batch_id, "DQN", 0.10, "Completed", 100.0).await?;
create_test_child_job(&pool, Uuid::new_v4(), batch_id, "PPO", 0.30, "Completed", 100.0).await?;
create_test_child_job(&pool, Uuid::new_v4(), batch_id, "MAMBA-2", 0.40, "Completed", 100.0).await?;
create_test_child_job(&pool, Uuid::new_v4(), batch_id, "TFT", 0.20, "Completed", 100.0).await?;
// Test: Get batch summary
let summary = tracker.get_batch_summary(batch_id).await?;
// Verify: All complete = 100% progress
assert_eq!(summary.overall_progress, 100.0);
assert_eq!(summary.status, JobStatus::Completed);
assert_eq!(summary.completed_jobs, 4);
// Cleanup
cleanup_test_data(&pool, batch_id).await?;
Ok(())
}
#[tokio::test]
async fn test_calculate_weighted_progress_half_complete() -> Result<()> {
let pool = setup_test_db().await?;
let tracker = JobTracker::new(pool.clone());
let batch_id = Uuid::new_v4();
// Setup: Each job at 50% progress
create_test_batch_job(&pool, batch_id, "test_half_complete").await?;
create_test_child_job(&pool, Uuid::new_v4(), batch_id, "DQN", 0.10, "Running", 50.0).await?;
create_test_child_job(&pool, Uuid::new_v4(), batch_id, "PPO", 0.30, "Running", 50.0).await?;
create_test_child_job(&pool, Uuid::new_v4(), batch_id, "MAMBA-2", 0.40, "Running", 50.0).await?;
create_test_child_job(&pool, Uuid::new_v4(), batch_id, "TFT", 0.20, "Running", 50.0).await?;
// Test: Get batch summary
let summary = tracker.get_batch_summary(batch_id).await?;
// Verify: Weighted average = 50% (since all weights sum to 1.0)
assert_eq!(summary.overall_progress, 50.0);
assert_eq!(summary.status, JobStatus::Running);
// Cleanup
cleanup_test_data(&pool, batch_id).await?;
Ok(())
}
#[tokio::test]
async fn test_calculate_weighted_progress_dqn_only() -> Result<()> {
let pool = setup_test_db().await?;
let tracker = JobTracker::new(pool.clone());
let batch_id = Uuid::new_v4();
// Setup: Only DQN completed (10% weight)
create_test_batch_job(&pool, batch_id, "test_dqn_only").await?;
create_test_child_job(&pool, Uuid::new_v4(), batch_id, "DQN", 0.10, "Completed", 100.0).await?;
create_test_child_job(&pool, Uuid::new_v4(), batch_id, "PPO", 0.30, "Pending", 0.0).await?;
create_test_child_job(&pool, Uuid::new_v4(), batch_id, "MAMBA-2", 0.40, "Pending", 0.0).await?;
create_test_child_job(&pool, Uuid::new_v4(), batch_id, "TFT", 0.20, "Pending", 0.0).await?;
// Test: Get batch summary
let summary = tracker.get_batch_summary(batch_id).await?;
// Verify: Only DQN at 100% with 10% weight = 10.0% overall
assert_eq!(summary.overall_progress, 10.0);
assert_eq!(summary.completed_jobs, 1);
assert_eq!(summary.pending_jobs, 3);
// Cleanup
cleanup_test_data(&pool, batch_id).await?;
Ok(())
}
#[tokio::test]
async fn test_get_batch_summary_nonexistent_batch() -> Result<()> {
let pool = setup_test_db().await?;
let tracker = JobTracker::new(pool.clone());
let nonexistent_batch_id = Uuid::new_v4();
// Test: Query nonexistent batch should error
let result = tracker.get_batch_summary(nonexistent_batch_id).await;
assert!(result.is_err(), "Should return error for nonexistent batch");
Ok(())
}
#[tokio::test]
async fn test_batch_status_transitions() -> Result<()> {
let pool = setup_test_db().await?;
let tracker = JobTracker::new(pool.clone());
let batch_id = Uuid::new_v4();
let job1_id = Uuid::new_v4();
let job2_id = Uuid::new_v4();
// Setup: 2 jobs
create_test_batch_job(&pool, batch_id, "test_status_transitions").await?;
create_test_child_job(&pool, job1_id, batch_id, "DQN", 0.50, "Pending", 0.0).await?;
create_test_child_job(&pool, job2_id, batch_id, "PPO", 0.50, "Pending", 0.0).await?;
// Test: Initially pending
let summary = tracker.get_batch_summary(batch_id).await?;
assert_eq!(summary.status, JobStatus::Pending);
// Test: Start first job -> batch becomes Running
tracker.update_job_status(job1_id, JobStatus::Running).await?;
let summary = tracker.get_batch_summary(batch_id).await?;
assert_eq!(summary.status, JobStatus::Running);
// Test: Complete first job -> batch still Running
tracker.update_job_status(job1_id, JobStatus::Completed).await?;
let summary = tracker.get_batch_summary(batch_id).await?;
assert_eq!(summary.status, JobStatus::Pending); // Second job still pending
// Test: Start second job -> batch Running again
tracker.update_job_status(job2_id, JobStatus::Running).await?;
let summary = tracker.get_batch_summary(batch_id).await?;
assert_eq!(summary.status, JobStatus::Running);
// Test: Complete second job -> batch Completed
tracker.update_job_status(job2_id, JobStatus::Completed).await?;
let summary = tracker.get_batch_summary(batch_id).await?;
assert_eq!(summary.status, JobStatus::Completed);
// Cleanup
cleanup_test_data(&pool, batch_id).await?;
Ok(())
}
#[tokio::test]
async fn test_batch_status_with_failures() -> Result<()> {
let pool = setup_test_db().await?;
let tracker = JobTracker::new(pool.clone());
let batch_id = Uuid::new_v4();
let job1_id = Uuid::new_v4();
let job2_id = Uuid::new_v4();
// Setup: 2 jobs
create_test_batch_job(&pool, batch_id, "test_failures").await?;
create_test_child_job(&pool, job1_id, batch_id, "DQN", 0.50, "Running", 30.0).await?;
create_test_child_job(&pool, job2_id, batch_id, "PPO", 0.50, "Running", 40.0).await?;
// Test: Fail first job
tracker.update_job_status(job1_id, JobStatus::Failed).await?;
let summary = tracker.get_batch_summary(batch_id).await?;
assert_eq!(summary.failed_jobs, 1);
assert_eq!(summary.status, JobStatus::Running); // Still has running job
// Test: Fail second job -> batch becomes Failed
tracker.update_job_status(job2_id, JobStatus::Failed).await?;
let summary = tracker.get_batch_summary(batch_id).await?;
assert_eq!(summary.failed_jobs, 2);
assert_eq!(summary.status, JobStatus::Failed);
// Cleanup
cleanup_test_data(&pool, batch_id).await?;
Ok(())
}
#[tokio::test]
async fn test_progress_updates_trigger_batch_aggregation() -> Result<()> {
let pool = setup_test_db().await?;
let tracker = JobTracker::new(pool.clone());
let batch_id = Uuid::new_v4();
let job_id = Uuid::new_v4();
// Setup
create_test_batch_job(&pool, batch_id, "test_progress_aggregation").await?;
create_test_child_job(&pool, job_id, batch_id, "MAMBA-2", 0.40, "Running", 0.0).await?;
// Test: Update progress to 75%
let progress = JobProgress {
job_id,
current_epoch: 75,
total_epochs: 100,
progress_pct: 75.0,
};
tracker.update_job_progress(job_id, progress).await?;
// Verify: Batch summary reflects updated progress
let summary = tracker.get_batch_summary(batch_id).await?;
// MAMBA-2 at 75% with 0.40 weight = 30.0% overall
assert!((summary.overall_progress - 30.0).abs() < 0.01,
"Expected 30.0, got {}", summary.overall_progress);
// Cleanup
cleanup_test_data(&pool, batch_id).await?;
Ok(())
}