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>
323 lines
11 KiB
Rust
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
|
|
);
|
|
}
|