返回 CodeWhale
selected_asr.rs
根目录 / crates / tui / src / voice / tests / selected_asr.rs
1 //! `transcribe_selected` keeps a recording on the ASR route that was selected.
2 //!
3 //! The configured provider in every fixture below has a literal key and a
4 //! loopback wiremock endpoint, so a regression that falls back to it is
5 //! observable as a recorded request rather than inferred from a sleep.
6
7 use wiremock::matchers::{method, path};
8 use wiremock::{Mock, MockServer, ResponseTemplate};
9
10 use crate::config::Config;
11 use crate::test_support::{EnvVarGuard, lock_test_env};
12 use crate::voice::{ASR_MODEL, DictateError, VOICE_CONTROL_MODEL, transcribe_selected};
13
14 const FIXTURE_KEY: &str = "fixture-provider-key";
15
16 async fn provider_fixture(content: &str) -> MockServer {
17 let server = MockServer::start().await;
18 Mock::given(method("POST"))
19 .and(path("/v1/chat/completions"))
20 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
21 "choices": [{ "message": { "content": content } }]
22 })))
23 .mount(&server)
24 .await;
25 server
26 }
27
28 /// Fully explicit provider route: the key and endpoint come from the config
29 /// table, so nothing is read from real auth state or ambient endpoint env.
30 fn fixture_config(server: &MockServer) -> Config {
31 toml::from_str(&format!(
32 "provider = \"openai\"\n\n[providers.openai]\napi_key = \"{FIXTURE_KEY}\"\nbase_url = \"{}/v1\"\nmodel = \"fixture-model\"\n",
33 server.uri()
34 ))
35 .expect("fixture config parses")
36 }
37
38 /// Hold every env mutation for the whole test: the returned guards live until
39 /// the caller drops them, which is after any `spawn_blocking` transcriber has
40 /// been awaited to completion.
41 struct IsolatedEnv {
42 // Field order is drop order: guards restore before the lock releases.
43 _guards: Vec<EnvVarGuard>,
44 _home: tempfile::TempDir,
45 _lock: crate::test_support::TestEnvLock,
46 }
47
48 fn isolated_env(path_override: Option<&std::path::Path>) -> IsolatedEnv {
49 let lock = lock_test_env();
50 let home = tempfile::tempdir().expect("isolated codewhale home");
51 let mut guards = vec![
52 EnvVarGuard::set("CODEWHALE_HOME", home.path()),
53 EnvVarGuard::remove("GROQ_API_KEY"),
54 EnvVarGuard::remove("CODEWHALE_BASE_URL"),
55 EnvVarGuard::remove("DEEPSEEK_BASE_URL"),
56 EnvVarGuard::remove("OPENAI_BASE_URL"),
57 EnvVarGuard::remove("OPENAI_API_KEY"),
58 EnvVarGuard::remove(codewhale_config::CLI_API_KEY_ENV),
59 EnvVarGuard::remove(codewhale_config::CLI_API_KEY_SOURCE_ENV),
60 EnvVarGuard::remove(codewhale_config::LEGACY_CLI_API_KEY_SOURCE_ENV),
61 ];
62 if let Some(dir) = path_override {
63 guards.push(EnvVarGuard::set("PATH", dir));
64 }
65 IsolatedEnv {
66 _guards: guards,
67 _home: home,
68 _lock: lock,
69 }
70 }
71
72 #[tokio::test]
73 async fn failed_local_whisper_never_uploads_to_the_configured_provider() {
74 // No whisper binary resolves from an empty PATH; HOME is left alone.
75 let empty_path = tempfile::tempdir().expect("empty PATH dir");
76 let _env = isolated_env(Some(empty_path.path()));
77 let server = provider_fixture("{\"text\":\"must not be requested\"}").await;
78 let config = fixture_config(&server);
79
80 let result = transcribe_selected(&config, "local-whisper", &[0; 16], None).await;
81
82 assert!(
83 matches!(&result, Err(DictateError::Transcription(msg)) if msg.contains("local whisper")),
84 "{result:?}"
85 );
86 let requests = server.received_requests().await.expect("request log");
87 assert_eq!(
88 requests.len(),
89 0,
90 "audio reached the provider: {requests:?}"
91 );
92 }
93
94 #[tokio::test]
95 async fn failed_groq_without_a_key_never_falls_back_to_the_configured_provider() {
96 let _env = isolated_env(None);
97 let server = provider_fixture("{\"text\":\"must not be requested\"}").await;
98 let config = fixture_config(&server);
99
100 let result = transcribe_selected(&config, "groq", &[0; 16], Some("existing text")).await;
101
102 assert!(
103 matches!(&result, Err(DictateError::Transcription(msg)) if msg.contains("GROQ_API_KEY")),
104 "{result:?}"
105 );
106 let requests = server.received_requests().await.expect("request log");
107 assert_eq!(
108 requests.len(),
109 0,
110 "audio reached the provider: {requests:?}"
111 );
112 }
113
114 #[tokio::test]
115 async fn selected_provider_asr_uses_plain_or_composer_aware_request() {
116 let _env = isolated_env(None);
117 let server = provider_fixture("{\"text\":\"hello from provider\"}").await;
118 let config = fixture_config(&server);
119
120 // Plain ASR returns the raw message content.
121 let plain = transcribe_selected(&config, "provider", &[0; 16], None)
122 .await
123 .expect("plain provider ASR");
124 assert_eq!(plain, "{\"text\":\"hello from provider\"}");
125 // Voice control parses the JSON reply and sends the composer text along.
126 let assisted = transcribe_selected(&config, "provider", &[0; 16], Some("draft so far"))
127 .await
128 .expect("voice-control provider ASR");
129 assert_eq!(assisted, "hello from provider");
130
131 let requests = server.received_requests().await.expect("request log");
132 assert_eq!(requests.len(), 2, "{requests:?}");
133 for request in &requests {
134 assert_eq!(
135 request
136 .headers
137 .get("authorization")
138 .and_then(|value| value.to_str().ok()),
139 Some(format!("Bearer {FIXTURE_KEY}").as_str())
140 );
141 }
142 let plain_body: serde_json::Value = serde_json::from_slice(&requests[0].body).unwrap();
143 assert_eq!(plain_body["model"], ASR_MODEL);
144 assert!(!plain_body.to_string().contains("draft so far"));
145
146 let control_body: serde_json::Value = serde_json::from_slice(&requests[1].body).unwrap();
147 assert_eq!(control_body["model"], VOICE_CONTROL_MODEL);
148 let context = control_body["messages"][1]["content"][0]["text"]
149 .as_str()
150 .expect("composer context block");
151 let context: serde_json::Value = serde_json::from_str(context).unwrap();
152 assert_eq!(context["current_text"], "draft so far");
153 }
154
154 lines RUST