Files
settled-reach/server/tests/voice_pipeline.rs
T
jpmschweitzerandClaude Opus 4.6 aa79dd97e7 fix(simulation): Clippy cleanup and CI enforcement (#635)
Fix all Clippy warnings across the server codebase (2411 insertions, 1341
deletions). Raise type-complexity-threshold to 750 and too-many-arguments
to 12 in .clippy.toml for idiomatic Bevy ECS system signatures. The server
now passes `cargo clippy -- --deny warnings` cleanly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 10:33:15 +01:00

323 lines
11 KiB
Rust

//! 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<VoiceRequest> {
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<Mutex<>>, 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
);
}