From 42b32a4f1a50a6ba2c2d52e73ef3bc0965bf5dac Mon Sep 17 00:00:00 2001 From: Jake Date: Tue, 21 Apr 2026 07:55:52 +0100 Subject: [PATCH] test(storage): pin FTS5 contract for search_transcripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit search_transcripts already backs onto the transcripts_fts virtual table (migration v4, trigger-maintained) via MATCH + ORDER BY rank. Adding a test to lock the behaviour: token matching is case-insensitive, rank- ordered, and non-matching tokens return nothing. This is Phase G of the post-OpenWhispr audit — semantic embeddings stay deferred until the FTS experience actually hits a wall. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/storage/src/database.rs | 55 ++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/crates/storage/src/database.rs b/crates/storage/src/database.rs index 420d93d..dddc9bb 100644 --- a/crates/storage/src/database.rs +++ b/crates/storage/src/database.rs @@ -888,6 +888,61 @@ mod tests { assert!(deleted.is_none()); } + #[tokio::test] + async fn search_transcripts_uses_fts5_and_ranks_by_relevance() { + // Locks in the FTS5 behaviour contract: MATCH-based substring-like + // search (case-insensitive tokens) with rank-ordered results, so + // anyone swapping `search_transcripts` out for embeddings later + // knows what invariants they must preserve. + let pool = test_pool().await; + + let rows = [ + ("t1", "The Parakeet speech model runs on sherpa-onnx."), + ( + "t2", + "Parakeet parakeet parakeet dominates English benchmarks.", + ), + ("t3", "Whisper large-v3 remains the multilingual champion."), + ]; + + for (id, text) in rows { + insert_transcript( + &pool, + &InsertTranscriptParams { + id, + text, + source: "microphone", + profile_id: crate::DEFAULT_PROFILE_ID, + title: None, + audio_path: None, + duration: 1.0, + engine: Some("whisper"), + model_id: Some("whisper-tiny-en"), + inference_ms: None, + sample_rate: None, + audio_channels: None, + format_mode: None, + remove_fillers: false, + british_english: false, + anti_hallucination: false, + }, + ) + .await + .unwrap(); + } + + let hits = search_transcripts(&pool, "parakeet", 10).await.unwrap(); + let ids: Vec<&str> = hits.iter().map(|row| row.id.as_str()).collect(); + assert_eq!( + ids, + vec!["t2", "t1"], + "t3 has no 'parakeet' token; t2 outranks t1 on repetition" + ); + + let no_match = search_transcripts(&pool, "moonshine", 10).await.unwrap(); + assert!(no_match.is_empty()); + } + #[tokio::test] async fn task_crud_roundtrip() { let pool = test_pool().await;