Files
doctate/server/tests/oneliner_heal_decoupled_test.rs
T
Brummel 4531f85b13 fix: settle silent-only cases to Empty oneliner state
Introduce `TranscriptState { Pending, Silent, Content }` in
doctate-common as the canonical three-way state of a recording's
`.transcript.txt` sidecar. Previously each call-site projected the
raw `Option<String>` / `.exists()` onto its own 2-state view and the
projections disagreed: the UI treated a 0-byte silent transcript like
`Content` while the oneliner worker treated it like `Pending`,
leaving silent-only cases stuck on "generiere Titel …" forever with
no persisted oneliner.json.

`update_oneliner` now settles a silent-only case to
`OnelinerState::Empty` when no `Content` transcript exists and no
recording is still `Pending`, so the UI resolves to "unbenannt" and
recovery treats it as terminal.

Single reader: `paths::read_transcript_state`. All call-sites
(scan_recordings, compute_oneliner_display, has_pending_recordings,
all_transcripts_joined, read_recordings, enqueue_pending_for_user,
scan_m4as, cases_needing_oneliner_in, case_recordings.html) go
through the same typed abstraction and must handle `Silent` via
exhaustive match.

Regression tests:
- silent_case_empty_test: heal path settles silent-only case to
  Empty without calling Ollama
- case_page_silent_only_shows_empty_not_generating: UI renders
  "unbenannt", not "generiere Titel …"
2026-04-21 13:37:51 +02:00

130 lines
4.9 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Regression test: `PipelineState::heal_orphans_if_idle` must not block
//! the request handler on Ollama. Before the fix, the function awaited a
//! sequential `regenerate_missing_oneliners_for_user` loop inline, so the
//! HTTP handler stayed pinned for N × LLM-latency. The fix detaches the
//! regeneration via `tokio::spawn`, guarded by a `compare_exchange` on
//! `OnelinerHealBusy` to dedup concurrent page-loads.
use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
use doctate_server::config::Config;
use doctate_server::events;
use doctate_server::gazetteer::Gazetteer;
use doctate_server::{
AnalyzeBusy, OnelinerHealBusy, PipelineState, TranscribeBusy, WorkerBusy, analyze, transcribe,
};
use serde_json::json;
use tempfile::tempdir;
use tokio::time::timeout;
use wiremock::matchers::{method, path as wm_path};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn write_transcript(case_dir: &Path) {
// Seed an m4a + transcript pair, matching the worker's on-disk
// layout. The oneliner worker iterates `.m4a` files and resolves
// the sidecar from the stem; a stray transcript without an m4a
// would be invisible to it.
let stem = "2026-04-16T10-00-00Z";
std::fs::write(case_dir.join(format!("{stem}.m4a")), b"audio").unwrap();
std::fs::write(
case_dir.join(format!("{stem}.transcript.txt")),
"Brustschmerz links, seit heute morgen.",
)
.unwrap();
}
#[tokio::test]
async fn heal_orphans_returns_fast_and_dedups_parallel_calls() {
// Five cases with a transcript but no oneliner.json. Without the fix,
// heal_orphans_if_idle would sequentially block on 5 × 200 ms = 1 s of
// mock delay. With the fix, it must return in well under that window.
let data = tempdir().unwrap();
let slug = "dr_test";
let user_root = data.path().join(slug);
std::fs::create_dir_all(&user_root).unwrap();
for i in 0..5 {
let case_dir = user_root.join(format!("case-{i:02}"));
std::fs::create_dir_all(&case_dir).unwrap();
write_transcript(&case_dir);
}
// Ollama mock: 200 ms delay per chat call. `.expect(5)` asserts at
// MockServer drop that exactly five requests arrived — dedup would
// show up as 10 without the CAS guard.
let mock = MockServer::start().await;
Mock::given(method("POST"))
.and(wm_path("/api/chat"))
.respond_with(
ResponseTemplate::new(200)
.set_delay(Duration::from_millis(200))
.set_body_json(json!({
"message": { "content": "Brustschmerz links" }
})),
)
.expect(5)
.mount(&mock)
.await;
let mut cfg = Config::test_default();
cfg.data_path = data.path().to_path_buf();
cfg.ollama_url = mock.uri();
let config = Arc::new(cfg);
let vocab = Arc::new(Gazetteer::empty());
let events_tx = events::channel();
let http_client = reqwest::Client::new();
let (tx_a, _rx_a) = analyze::channel();
let (tx_t, _rx_t) = transcribe::channel();
let heal_busy: WorkerBusy = Arc::new(AtomicBool::new(false));
let pipeline = PipelineState {
analyze_busy: AnalyzeBusy(Arc::new(AtomicBool::new(false))),
analyze_tx: tx_a,
transcribe_busy: TranscribeBusy(Arc::new(AtomicBool::new(false))),
transcribe_tx: tx_t,
oneliner_heal_busy: OnelinerHealBusy(heal_busy.clone()),
};
// Two back-to-back heal invocations. The first wins the CAS and
// spawns. The second must no-op. Both heal futures themselves must
// finish in a fraction of the mock delay.
let before = Instant::now();
timeout(Duration::from_millis(150), async {
pipeline
.heal_orphans_if_idle(&user_root, slug, &http_client, &config, &vocab, &events_tx)
.await;
pipeline
.heal_orphans_if_idle(&user_root, slug, &http_client, &config, &vocab, &events_tx)
.await;
})
.await
.expect("heal_orphans_if_idle must return without awaiting the LLM loop");
assert!(
before.elapsed() < Duration::from_millis(150),
"heal_orphans returned in {:?} — the inline regen is back",
before.elapsed()
);
// Spawn drops the busy flag on completion — poll until idle.
let poll_start = Instant::now();
while heal_busy.load(Ordering::Acquire) {
if poll_start.elapsed() > Duration::from_secs(5) {
panic!("oneliner heal spawn did not finish within 5s");
}
tokio::time::sleep(Duration::from_millis(25)).await;
}
// Every case now has an oneliner.json.
for i in 0..5 {
let p = user_root.join(format!("case-{i:02}")).join("oneliner.json");
assert!(p.exists(), "oneliner.json missing for case-{i:02}");
}
// Mock is dropped at end of scope → `.expect(5)` verifies no
// double-firing. A failing expectation panics with the actual count.
}