//! Voice pipeline integration test (D-138, Spike 2). //! //! Exercises the full pipeline: queue → worker → sr-voice child (mock or real) //! → cache → disk. Outputs voiced results to `.tmp/voice-test/`. //! //! Run with mock: cargo test --test voice_pipeline -- --nocapture //! Run with real: SR_VOICE_BIN=sr-voice/target/release/sr-voice \ //! SR_VOICE_MODEL=models/gemma2.gguf \ //! cargo test --test voice_pipeline -- --nocapture use std::collections::BTreeMap; use std::path::PathBuf; use std::sync::{Arc, Mutex}; use std::time::Duration; use settled_reach_server::npc::blueprint::{ CulturalValues, CultureProfile, NamingConventions, OccasionalInjection, SpeechPatterns, VoiceExample, }; use settled_reach_server::npc::tell_state::TellCategory; use settled_reach_server::npc::PersonalityTrait; use settled_reach_server::voice::cache::VoiceCacheStore; use settled_reach_server::voice::prompt_builder::ContentType; use settled_reach_server::voice::queue::{Priority, VoiceQueue, VoiceRequest}; use settled_reach_server::voice::worker::{VoiceProcessConfig, WorkerPool}; /// Output directory for test results (relative to repo root). const OUTPUT_DIR: &str = ".tmp/voice-test"; fn output_dir() -> PathBuf { let manifest = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".into()); PathBuf::from(manifest).join("..").join(OUTPUT_DIR) } /// Known paths relative to CARGO_MANIFEST_DIR (server/). const SR_VOICE_BIN: &str = "sr-voice/target/release/sr-voice"; const SR_VOICE_MODEL: &str = "models/gemma2.gguf"; fn voice_config() -> VoiceProcessConfig { let manifest = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".into()); let manifest = PathBuf::from(manifest); let bin_path = manifest.join(SR_VOICE_BIN); let model_path = manifest.join(SR_VOICE_MODEL); // SR_VOICE_MOCK=1 forces mock mode (used by `make test-voice-mock`) let force_mock = std::env::var("SR_VOICE_MOCK").is_ok(); // Use real sr-voice if both binary and model exist on disk if !force_mock && bin_path.exists() && model_path.exists() { eprintln!("Using real sr-voice: {}", bin_path.display()); eprintln!("Model: {}", model_path.display()); VoiceProcessConfig { binary_path: bin_path.to_string_lossy().into(), model_path: model_path.to_string_lossy().into(), threads: 2, ctx_size: 512, } } else { // Fall back to mock script — no model needed let mock = manifest.join("sr-voice/mock-stdio.sh"); assert!(mock.exists(), "Mock script not found: {}", mock.display()); eprintln!("Using mock sr-voice: {}", mock.display()); if !bin_path.exists() { eprintln!(" (real binary not found: {})", bin_path.display()); } if !model_path.exists() { eprintln!(" (model not found: {})", model_path.display()); } VoiceProcessConfig { binary_path: mock.to_string_lossy().into(), model_path: "unused".into(), threads: 1, ctx_size: 512, } } } fn van_maanens_star_culture() -> CultureProfile { CultureProfile { id: "van-maanens-star".into(), name: "Van Maanen's Star Culture".into(), description: "Working-class pragmatic".into(), naming: NamingConventions { style: "compact".into(), given_names: vec!["Kael".into()], family_names: vec!["Davan".into()], family_name_used_socially: false, }, speech: SpeechPatterns { register: "direct".into(), filler_words: vec!["look".into()], greetings: vec!["hey".into()], farewells: vec!["shift's calling".into()], exclamations: vec!["void take it".into()], }, values: CulturalValues { description: "Pragmatic".into(), favored_traits: vec![PersonalityTrait::Bold], disfavored_traits: vec![PersonalityTrait::Reclusive], }, voice_persona: Some( "PERSONA: You are a Van Maanen's Star station worker.\n\ 1. Be direct. No pleasantries.\n\ 2. You're working-class and pragmatic." .into(), ), voice_examples: vec![VoiceExample { input: "declines to answer a question".into(), output: "Look, that's not mine to say.".into(), }], occasional_injections: vec![OccasionalInjection { kind: "oath".into(), clause: "Use an oath like \"void take it.\"".into(), example: Some(VoiceExample { input: "discovers a critical part is missing".into(), output: "Void take it. The coupling's not here.".into(), }), frequency: 0.25, suppress_on_tells: vec![ TellCategory::Guarded, TellCategory::RoutineDeviation, TellCategory::Friendly, ], }], behavior_modifiers: vec![], } } fn test_requests() -> Vec { vec![ VoiceRequest { priority: Priority::High, npc_stable_id: 1, zone_id: 100, culture_id: "van-maanens-star".into(), base_text: "The coupling is faulty.".into(), content_type: ContentType::Dialogue, content_index: 0, tell_state: None, seed: 42, }, VoiceRequest { priority: Priority::High, npc_stable_id: 1, zone_id: 100, culture_id: "van-maanens-star".into(), base_text: "I heard the night crew had to stop the line twice because the coupling was faulty and nobody had flagged it in the log.".into(), content_type: ContentType::Dialogue, content_index: 1, tell_state: Some(TellCategory::Nervous), seed: 43, }, VoiceRequest { priority: Priority::Standard, npc_stable_id: 2, zone_id: 100, culture_id: "van-maanens-star".into(), base_text: "checks the pressure gauge and writes in a logbook".into(), content_type: ContentType::Behavior, content_index: 0, tell_state: None, seed: 44, }, VoiceRequest { priority: Priority::High, npc_stable_id: 3, zone_id: 100, culture_id: "unknown_culture".into(), base_text: "Unknown culture fallback test.".into(), content_type: ContentType::Dialogue, content_index: 0, tell_state: None, seed: 45, }, ] } #[test] fn voice_pipeline_end_to_end() { // Setup output dir let out = output_dir(); let cache_dir = out.join("cache"); let _ = std::fs::remove_dir_all(&cache_dir); std::fs::create_dir_all(&cache_dir).expect("failed to create cache dir"); let config = voice_config(); // Build cultures map let mut cultures = BTreeMap::new(); cultures.insert("van-maanens-star".to_string(), van_maanens_star_culture()); let cultures = Arc::new(cultures); // Create cache let cache = Arc::new(Mutex::new(VoiceCacheStore::new( cache_dir.clone(), 12345, "test-v1".into(), "test-i1".into(), ))); // Create queue and worker pool let queue = VoiceQueue::new(); let paused = queue.paused_flag(); let mut pool = WorkerPool::spawn( 2, // 2 workers &config, queue.receiver(), Arc::clone(&cache), Arc::clone(&cultures), paused, ); eprintln!("Worker pool started: {} workers", pool.worker_count()); // Submit test requests let requests = test_requests(); let request_count = requests.len(); for req in requests { let submitted = queue.submit(req); assert!(submitted, "queue should accept request"); } eprintln!("Submitted {} requests", request_count); // Wait for processing (mock is instant, real LLM takes seconds per request) let manifest = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".into()); let is_real = std::env::var("SR_VOICE_MOCK").is_err() && PathBuf::from(&manifest).join(SR_VOICE_BIN).exists() && PathBuf::from(&manifest).join(SR_VOICE_MODEL).exists(); let max_wait = if is_real { Duration::from_secs(120) } else { Duration::from_secs(10) }; let start = std::time::Instant::now(); loop { std::thread::sleep(Duration::from_millis(200)); let pending = queue.pending_count(); let active = pool.active_workers(); if pending == 0 && active == 0 { break; } if start.elapsed() > max_wait { eprintln!( "Timeout waiting for pipeline (pending={}, active={})", pending, active ); break; } } let elapsed = start.elapsed(); eprintln!("Pipeline drained in {:.1}s", elapsed.as_secs_f64()); // Shutdown workers pool.shutdown(); eprintln!("Workers shut down"); // Inspect cache let mut cache_guard = cache.lock().unwrap(); let zone_cache = cache_guard.zone_cache(100); let entry_count = zone_cache.len(); eprintln!("Cache entries for zone 100: {}", entry_count); // Write results to a human-readable file let results_path = out.join("results.txt"); let mut results = String::new(); results.push_str(&format!( "Voice Pipeline Test Results\n\ ==========================\n\ Workers: {}\n\ Requests: {}\n\ Cache entries: {}\n\ Time: {:.1}s\n\ Mode: {}\n\n", 2, request_count, entry_count, elapsed.as_secs_f64(), if is_real { "real sr-voice" } else { "mock" }, )); for (key, text) in &zone_cache.entries { results.push_str(&format!( "--- npc={} type={:?} idx={} tell={:?} ---\n{}\n\n", key.npc_stable_id, key.content_type, key.content_index, key.tell_state, text )); } // Save cache to disk drop(cache_guard); // Drop triggers save_all via the Drop impl — but the cache is behind // Arc>, so we can't drop it here. Explicitly save instead. cache .lock() .unwrap() .save_all() .expect("failed to save cache"); std::fs::write(&results_path, &results).expect("failed to write results"); eprintln!("Results written to {}", results_path.display()); eprintln!("\n{}", results); // Assertions assert!( entry_count >= 3, "Expected at least 3 cache entries (3 valid culture requests), got {}", entry_count ); // The unknown culture request should still cache (with base text as fallback) // Total should be 4 (3 van-maanens-star + 1 unknown fallback) assert!( entry_count == 4, "Expected 4 cache entries (3 van-maanens-star + 1 unknown fallback), got {}", entry_count ); }