Skip to content

Commit 41d1fd1

Browse files
committed
add core tests, fix polish empty/timeout fallback
1 parent 462a592 commit 41d1fd1

2 files changed

Lines changed: 227 additions & 2 deletions

File tree

src-tauri/src/polish.rs

Lines changed: 128 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,10 @@ pub async fn polish(
111111
return raw.to_string();
112112
}
113113
match polish_inner(cfg, raw, app, ctx).await {
114+
// An empty completion (no choices, or blank content) would silently
115+
// drop the utterance. Keep the raw transcript instead; don't record
116+
// the empty result in the rolling context.
117+
Ok(out) if out.is_empty() => raw.to_string(),
114118
Ok(out) => {
115119
ctx.append(&out);
116120
out
@@ -191,7 +195,14 @@ async fn polish_inner(
191195
};
192196

193197
let url = format!("{}/chat/completions", cfg.base_url.trim_end_matches('/'));
194-
let mut req = reqwest::Client::new().post(&url).json(&body);
198+
// Bound the round-trip so a hung endpoint can't wedge the collector on a
199+
// single segment. On timeout reqwest errors, falling through to the raw
200+
// transcript like any other polish failure.
201+
let client = reqwest::Client::builder()
202+
.timeout(std::time::Duration::from_secs(30))
203+
.build()
204+
.context("build polish http client")?;
205+
let mut req = client.post(&url).json(&body);
195206

196207
// Bearer auth only if the configured env var is set. Lets local servers
197208
// (Ollama, llama.cpp) work with no key configured.
@@ -223,6 +234,122 @@ async fn polish_inner(
223234
#[cfg(test)]
224235
mod tests {
225236
use super::*;
237+
use tokio::io::{AsyncReadExt, AsyncWriteExt};
238+
use tokio::net::TcpListener;
239+
240+
/// Spin up a one-shot HTTP server that reads a full request then replies
241+
/// with `response`. Returns a `base_url` pointing at it. Draining the
242+
/// request body before replying avoids an RST truncating reqwest's read.
243+
async fn stub_openai(response: String) -> String {
244+
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
245+
let addr = listener.local_addr().unwrap();
246+
tokio::spawn(async move {
247+
let Ok((mut stream, _)) = listener.accept().await else {
248+
return;
249+
};
250+
let mut buf = Vec::new();
251+
let mut tmp = [0u8; 1024];
252+
let header_end = loop {
253+
let n = match stream.read(&mut tmp).await {
254+
Ok(0) | Err(_) => return,
255+
Ok(n) => n,
256+
};
257+
buf.extend_from_slice(&tmp[..n]);
258+
if let Some(pos) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
259+
break pos + 4;
260+
}
261+
};
262+
let headers = String::from_utf8_lossy(&buf[..header_end]).to_ascii_lowercase();
263+
let content_len = headers
264+
.lines()
265+
.find_map(|l| l.strip_prefix("content-length:"))
266+
.and_then(|v| v.trim().parse::<usize>().ok())
267+
.unwrap_or(0);
268+
while buf.len() < header_end + content_len {
269+
match stream.read(&mut tmp).await {
270+
Ok(0) | Err(_) => break,
271+
Ok(n) => buf.extend_from_slice(&tmp[..n]),
272+
}
273+
}
274+
let _ = stream.write_all(response.as_bytes()).await;
275+
let _ = stream.flush().await;
276+
});
277+
format!("http://{addr}/v1")
278+
}
279+
280+
fn http_response(status: &str, body: &str) -> String {
281+
format!(
282+
"HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
283+
body.len()
284+
)
285+
}
286+
287+
fn enabled_cfg(base_url: String) -> PolishConfig {
288+
PolishConfig {
289+
enabled: true,
290+
base_url,
291+
model: "test-model".into(),
292+
api_key_env: String::new(),
293+
per_app_tone: true,
294+
}
295+
}
296+
297+
#[tokio::test]
298+
async fn success_returns_model_output_and_records_context() {
299+
let body = r#"{"choices":[{"message":{"content":" Cleaned up text. "}}]}"#;
300+
let cfg = enabled_cfg(stub_openai(http_response("200 OK", body)).await);
301+
let ctx = PolishContext::new();
302+
let out = polish(&cfg, "um cleaned up text", None, &ctx).await;
303+
assert_eq!(out, "Cleaned up text.");
304+
assert_eq!(ctx.snapshot(), "Cleaned up text.");
305+
assert!(!ctx.is_disabled());
306+
}
307+
308+
#[tokio::test]
309+
async fn http_error_falls_back_to_raw_and_disables_session() {
310+
let cfg = enabled_cfg(stub_openai(http_response("401 Unauthorized", "{}")).await);
311+
let ctx = PolishContext::new();
312+
let out = polish(&cfg, "raw words", None, &ctx).await;
313+
assert_eq!(
314+
out, "raw words",
315+
"an API failure must not drop the transcript"
316+
);
317+
assert!(
318+
ctx.is_disabled(),
319+
"one failure disables polish for the session"
320+
);
321+
assert!(
322+
ctx.snapshot().is_empty(),
323+
"failed polish must not pollute context"
324+
);
325+
}
326+
327+
#[tokio::test]
328+
async fn empty_completion_falls_back_to_raw() {
329+
// Both an empty choices list and blank content must keep the utterance.
330+
for body in [
331+
r#"{"choices":[]}"#,
332+
r#"{"choices":[{"message":{"content":" "}}]}"#,
333+
] {
334+
let cfg = enabled_cfg(stub_openai(http_response("200 OK", body)).await);
335+
let ctx = PolishContext::new();
336+
let out = polish(&cfg, "keep this", None, &ctx).await;
337+
assert_eq!(out, "keep this", "empty completion must not drop text");
338+
assert!(
339+
ctx.snapshot().is_empty(),
340+
"empty output must not enter context"
341+
);
342+
}
343+
}
344+
345+
#[tokio::test]
346+
async fn malformed_body_falls_back_to_raw() {
347+
let cfg = enabled_cfg(stub_openai(http_response("200 OK", "this is not json")).await);
348+
let ctx = PolishContext::new();
349+
let out = polish(&cfg, "keep me", None, &ctx).await;
350+
assert_eq!(out, "keep me");
351+
assert!(ctx.is_disabled());
352+
}
226353

227354
#[test]
228355
fn context_starts_empty() {

src-tauri/src/transport.rs

Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ pub async fn run_session(
8787
break;
8888
}
8989
chunks_sent += 1;
90-
if chunks_sent.is_multiple_of(25) {
90+
if chunks_sent % 25 == 0 {
9191
tracing::debug!(chunks_sent, "transport: streaming");
9292
}
9393
}
@@ -149,3 +149,101 @@ fn uuid_v4() -> String {
149149
.unwrap_or(0);
150150
format!("qol-{nanos:x}")
151151
}
152+
153+
#[cfg(test)]
154+
mod tests {
155+
use super::*;
156+
use crate::config::{Config, InjectMethod, PolishConfig};
157+
use futures_util::{SinkExt, StreamExt};
158+
use std::time::Duration;
159+
use tokio::net::TcpListener;
160+
use tokio_tungstenite::accept_async;
161+
162+
fn test_config(url: String) -> Config {
163+
Config {
164+
aavaaz_url: url,
165+
model: "test-model".into(),
166+
language: Some("en".into()),
167+
hotkey: "Super+Space".into(),
168+
polish: PolishConfig {
169+
enabled: false,
170+
base_url: String::new(),
171+
model: String::new(),
172+
api_key_env: String::new(),
173+
per_app_tone: false,
174+
},
175+
hotwords: vec!["alpha".into(), "bravo".into()],
176+
inject_method: InjectMethod::Type,
177+
}
178+
}
179+
180+
#[tokio::test]
181+
async fn handshake_then_streams_only_unique_completed_segments() {
182+
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
183+
let addr = listener.local_addr().unwrap();
184+
185+
// Fake Aavaaz: accept one client, capture its handshake, then feed a
186+
// mix of messages the client must filter correctly.
187+
let server = tokio::spawn(async move {
188+
let (tcp, _) = listener.accept().await.unwrap();
189+
let mut ws = accept_async(tcp).await.unwrap();
190+
191+
let handshake = match ws.next().await.unwrap().unwrap() {
192+
Message::Text(t) => serde_json::from_str::<serde_json::Value>(&t).unwrap(),
193+
other => panic!("expected handshake text frame, got {other:?}"),
194+
};
195+
196+
let msgs = [
197+
// garbage that isn't a valid envelope -> ignored
198+
"not even json",
199+
// partial (completed=false) -> skipped
200+
r#"{"segments":[{"start":"0.0","end":"1.0","text":"partial","completed":false}]}"#,
201+
r#"{"segments":[{"start":"1.0","end":"2.0","text":"hello","completed":true}]}"#,
202+
// exact duplicate (same start/end) -> deduped
203+
r#"{"segments":[{"start":"1.0","end":"2.0","text":"hello","completed":true}]}"#,
204+
r#"{"segments":[{"start":"2.0","end":"3.0","text":"world","completed":true}]}"#,
205+
];
206+
for m in msgs {
207+
ws.send(Message::Text(m.to_string())).await.unwrap();
208+
}
209+
ws.close(None).await.unwrap();
210+
handshake
211+
});
212+
213+
let (_audio_tx, audio_rx) = mpsc::unbounded_channel::<Vec<f32>>();
214+
let (seg_tx, mut seg_rx) = mpsc::unbounded_channel();
215+
let cfg = test_config(format!("ws://{addr}"));
216+
217+
// _audio_tx stays alive so the audio branch of the select stays
218+
// pending; the server's Close frame is what ends the session.
219+
tokio::time::timeout(Duration::from_secs(5), run_session(cfg, audio_rx, seg_tx))
220+
.await
221+
.expect("run_session timed out")
222+
.expect("run_session returned an error");
223+
224+
let mut got = Vec::new();
225+
while let Some(seg) = seg_rx.recv().await {
226+
got.push(seg.text);
227+
}
228+
assert_eq!(got, vec!["hello", "world"]);
229+
230+
let handshake = server.await.unwrap();
231+
assert_eq!(handshake["task"], "transcribe");
232+
assert_eq!(handshake["model"], "test-model");
233+
assert_eq!(handshake["language"], "en");
234+
assert_eq!(handshake["use_vad"], true);
235+
assert_eq!(handshake["hotwords"], "alpha,bravo");
236+
assert_eq!(handshake["send_last_n_segments"], 1);
237+
assert!(handshake["uid"].as_str().unwrap().starts_with("qol-"));
238+
}
239+
240+
#[tokio::test]
241+
async fn connect_failure_returns_error() {
242+
let (_audio_tx, audio_rx) = mpsc::unbounded_channel::<Vec<f32>>();
243+
let (seg_tx, _seg_rx) = mpsc::unbounded_channel();
244+
// Nothing listens on port 1; connect is refused.
245+
let cfg = test_config("ws://127.0.0.1:1".into());
246+
let res = run_session(cfg, audio_rx, seg_tx).await;
247+
assert!(res.is_err());
248+
}
249+
}

0 commit comments

Comments
 (0)