Refactor oneliner storage to use enum
The oneliner is now stored as `OnelinerState` enum which can represent three states: `Ready`, `Empty` or `Error`. This allows the client to differentiate between a case with no medical content and a case where the oneliner generation failed. The `OnelinerState` enum is serialized to JSON and stored in `oneliner.json` file. The `OnelinerEntry` struct has been updated to reflect this change. The client-desktop application has been updated to handle the new `OnelinerState` enum and display appropriate UI elements for each state. The server-side code has also been updated to read and write the `OnelinerState` enum, and to handle the new file format. The `reset_case_artefacts` function in `server/src/routes/case_actions.rs` has been updated to remove `oneliner.txt` and create `oneliner.json` instead. The tests have been updated to reflect these changes.
This commit is contained in:
@@ -837,7 +837,11 @@ async fn reset_case_clears_derived_and_unfails_audio() {
|
||||
// A failed recording: raw audio written, but filename carries `.failed`.
|
||||
std::fs::write(dir.join("2026-04-15T09-05-00Z.m4a.failed"), b"audio-bytes").unwrap();
|
||||
// Derived artefacts the reset must wipe.
|
||||
std::fs::write(dir.join("oneliner.txt"), "Knie re.").unwrap();
|
||||
std::fs::write(
|
||||
dir.join("oneliner.json"),
|
||||
br#"{"kind":"ready","text":"Knie re.","generated_at":"2026-04-18T10:00:00Z"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(dir.join("document.md"), "# Doc\n").unwrap();
|
||||
std::fs::write(dir.join("analysis_input.json"), "{}").unwrap();
|
||||
|
||||
@@ -854,7 +858,7 @@ async fn reset_case_clears_derived_and_unfails_audio() {
|
||||
assert!(!dir.join("2026-04-15T09-05-00Z.m4a.failed").exists());
|
||||
// Derived artefacts gone.
|
||||
assert!(!dir.join("2026-04-15T09-00-00Z.transcript.txt").exists());
|
||||
assert!(!dir.join("oneliner.txt").exists());
|
||||
assert!(!dir.join("oneliner.json").exists());
|
||||
assert!(!dir.join("document.md").exists());
|
||||
assert!(!dir.join("analysis_input.json").exists());
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ use std::sync::Arc;
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode, header};
|
||||
use doctate_common::oneliners::OnelinerState;
|
||||
use doctate_server::config::{Config, User};
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
@@ -32,7 +33,17 @@ fn make_admin(slug: &str) -> User {
|
||||
}
|
||||
|
||||
fn seed_oneliner(case_dir: &Path, text: &str) {
|
||||
std::fs::write(case_dir.join("oneliner.txt"), text).unwrap();
|
||||
let state = OnelinerState::Ready {
|
||||
text: text.to_owned(),
|
||||
generated_at: "2026-04-18T10:00:00Z".into(),
|
||||
};
|
||||
let bytes = serde_json::to_vec(&state).unwrap();
|
||||
std::fs::write(case_dir.join("oneliner.json"), bytes).unwrap();
|
||||
}
|
||||
|
||||
fn seed_oneliner_state(case_dir: &Path, state: &OnelinerState) {
|
||||
let bytes = serde_json::to_vec(state).unwrap();
|
||||
std::fs::write(case_dir.join("oneliner.json"), bytes).unwrap();
|
||||
}
|
||||
|
||||
fn config_with_llm_users(data_path: PathBuf, llm_url: String, users: Vec<User>) -> Arc<Config> {
|
||||
@@ -326,6 +337,34 @@ async fn case_page_uses_oneliner_as_h1_when_present() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn case_page_falls_back_to_generic_title_for_empty_state() {
|
||||
// An `Empty` state (LLM deliberately returned no medical content) is
|
||||
// not a text; the H1 must fall back to the generic "Fall" label, not
|
||||
// leak the kind string or crash the template.
|
||||
let config = config_with_llm(unique_tmp("cp-empty"));
|
||||
let case_id = "22222222-2222-2222-2222-222222222222";
|
||||
let case_dir = seed_case(&config.data_path, "dr_a", case_id);
|
||||
seed_oneliner_state(
|
||||
&case_dir,
|
||||
&OnelinerState::Empty {
|
||||
generated_at: "2026-04-18T10:00:00Z".into(),
|
||||
},
|
||||
);
|
||||
|
||||
let app = doctate_server::create_router(config);
|
||||
let cookie = login(app.clone(), "dr_a").await;
|
||||
|
||||
let resp = app.oneshot(get_page(case_id, &cookie, "")).await.unwrap();
|
||||
let body = body_string(resp).await;
|
||||
|
||||
assert!(body.contains("Fall"), "expected generic 'Fall' fallback");
|
||||
assert!(
|
||||
!body.contains("\"kind\""),
|
||||
"must not leak JSON kind discriminator into HTML"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn case_page_admin_sees_admin_view_toggle() {
|
||||
let data_path = unique_tmp("cp-toggle");
|
||||
|
||||
@@ -8,6 +8,7 @@ use axum::http::{Request, StatusCode};
|
||||
use filetime::FileTime;
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
use doctate_common::oneliners::OnelinerState;
|
||||
use doctate_server::config::{Config, User};
|
||||
|
||||
const TEST_KEY: &str = "test-key-oneliners";
|
||||
@@ -50,13 +51,29 @@ async fn seed_case(
|
||||
filetime::set_file_mtime(&m4a, FileTime::from_system_time(target_mtime)).unwrap();
|
||||
|
||||
if let Some(text) = oneliner_text {
|
||||
tokio::fs::write(case_dir.join("oneliner.txt"), text)
|
||||
.await
|
||||
.unwrap();
|
||||
write_ready_state(&case_dir, text).await;
|
||||
}
|
||||
case_dir
|
||||
}
|
||||
|
||||
async fn write_ready_state(case_dir: &Path, text: &str) {
|
||||
let state = OnelinerState::Ready {
|
||||
text: text.to_owned(),
|
||||
generated_at: "2026-04-18T10:00:00Z".into(),
|
||||
};
|
||||
let bytes = serde_json::to_vec(&state).unwrap();
|
||||
tokio::fs::write(case_dir.join("oneliner.json"), bytes)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
async fn write_state(case_dir: &Path, state: &OnelinerState) {
|
||||
let bytes = serde_json::to_vec(state).unwrap();
|
||||
tokio::fs::write(case_dir.join("oneliner.json"), bytes)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
async fn mark_deleted(case_dir: &Path) {
|
||||
tokio::fs::write(case_dir.join(".deleted"), "{}")
|
||||
.await
|
||||
@@ -155,7 +172,8 @@ async fn recent_case_with_oneliner_shows_up() {
|
||||
let arr = body["oneliners"].as_array().unwrap();
|
||||
assert_eq!(arr.len(), 1);
|
||||
assert_eq!(arr[0]["case_id"], case_id);
|
||||
assert_eq!(arr[0]["oneliner"], "Kniegelenk re., V.a. Meniskus");
|
||||
assert_eq!(arr[0]["oneliner"]["kind"], "ready");
|
||||
assert_eq!(arr[0]["oneliner"]["text"], "Kniegelenk re., V.a. Meniskus");
|
||||
assert!(arr[0]["created_at"].is_string());
|
||||
assert!(arr[0]["updated_at"].is_string());
|
||||
|
||||
@@ -365,8 +383,8 @@ async fn multiple_cases_sorted_newest_first() {
|
||||
let body = body_json(app.oneshot(get("/api/oneliners")).await.unwrap()).await;
|
||||
let arr = body["oneliners"].as_array().unwrap();
|
||||
assert_eq!(arr.len(), 2);
|
||||
assert_eq!(arr[0]["oneliner"], "newer");
|
||||
assert_eq!(arr[1]["oneliner"], "older");
|
||||
assert_eq!(arr[0]["oneliner"]["text"], "newer");
|
||||
assert_eq!(arr[1]["oneliner"]["text"], "older");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dp);
|
||||
}
|
||||
@@ -398,9 +416,7 @@ async fn sorts_by_last_recording_not_created() {
|
||||
FileTime::from_system_time(SystemTime::now() - Duration::from_secs(60)),
|
||||
)
|
||||
.unwrap();
|
||||
tokio::fs::write(dir_a.join("oneliner.txt"), "A (fresh addendum)")
|
||||
.await
|
||||
.unwrap();
|
||||
write_ready_state(&dir_a, "A (fresh addendum)").await;
|
||||
|
||||
// Case B: single recording 10h ago — created_at newer than A's, but
|
||||
// last_recording_at older than A's fresh addendum.
|
||||
@@ -421,3 +437,58 @@ async fn sorts_by_last_recording_not_created() {
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dp);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_state_serializes_as_kind_empty() {
|
||||
let (config, dp) = test_config();
|
||||
let case_dir = seed_case(
|
||||
&dp,
|
||||
"550e8400-e29b-41d4-a716-000000000030",
|
||||
Duration::from_secs(60),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
write_state(
|
||||
&case_dir,
|
||||
&OnelinerState::Empty {
|
||||
generated_at: "2026-04-18T10:00:00Z".into(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let app = doctate_server::create_router(config);
|
||||
|
||||
let body = body_json(app.oneshot(get("/api/oneliners")).await.unwrap()).await;
|
||||
let arr = body["oneliners"].as_array().unwrap();
|
||||
assert_eq!(arr.len(), 1);
|
||||
assert_eq!(arr[0]["oneliner"]["kind"], "empty");
|
||||
assert!(arr[0]["oneliner"].get("text").is_none());
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dp);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn error_state_serializes_as_kind_error() {
|
||||
let (config, dp) = test_config();
|
||||
let case_dir = seed_case(
|
||||
&dp,
|
||||
"550e8400-e29b-41d4-a716-000000000031",
|
||||
Duration::from_secs(60),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
write_state(
|
||||
&case_dir,
|
||||
&OnelinerState::Error {
|
||||
generated_at: "2026-04-18T10:00:00Z".into(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let app = doctate_server::create_router(config);
|
||||
|
||||
let body = body_json(app.oneshot(get("/api/oneliners")).await.unwrap()).await;
|
||||
let arr = body["oneliners"].as_array().unwrap();
|
||||
assert_eq!(arr.len(), 1);
|
||||
assert_eq!(arr[0]["oneliner"]["kind"], "error");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dp);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user