Files
lightcraft/crates/ui-egui/src/tests_quit_unsaved.rs
T
Brandon ThomasandClaude Opus 5.5 1a05c3eed2 Issue #103: Settings files: keep damaged ones, never overwrite unread ones, write ui.json atomically, confirm quit with unsaved changes
- Library settings (prefs.json, presets.json, view.json): only a missing
  file means "defaults". A file that doesn't parse is kept as
  <name>.corrupt-<unix time> before the defaults are used; one that can't
  be read (locked, I/O error) is never written this session (save_prefs
  refuses, presets/view saves skip it). Warnings go to library.info →
  settingsWarnings and Session::take_library_warnings; the UI shows each
  once in a window (ui.inspect → notices, button:noticeOk).
- Desktop ui.json (holds the library location): written atomically (temp +
  fsync + rename + dir fsync), saved as soon as the library path changes and
  every few seconds when it changed, failures reported; a damaged file is
  renamed to ui.json.corrupt-<time> and reported, an unreadable one is not
  overwritten.
- Quit/close with changes only in memory: retry the save (log, then a
  snapshot of everything), and if it still fails cancel the close and ask:
  Try Saving Again / Quit Anyway / Cancel (ui.inspect → quitPrompt).
- Tests: engine (damaged prefs kept, unreadable presets/prefs untouched),
  desktop (damaged ui.json kept, atomic write), headless UI (quit prompt
  buttons, settings notice).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 18:12:26 -04:00

129 lines
4.9 KiB
Rust

//! Issue #103: quitting with changes that couldn't be saved asks first (after one more try), and
//! settings-file problems found when the library opened are shown, not swallowed.
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use lightcraft_catalog::{MemStore, Store};
use lightcraft_engine::library::LibraryStores;
use serde_json::json;
use crate::headless::Headless;
use crate::{LightcraftApp, Services};
const T: Duration = Duration::from_secs(20);
/// Every write fails while `fail` is set (an unplugged drive).
#[derive(Clone, Default)]
struct Unplugged {
files: MemStore,
fail: Arc<AtomicBool>,
}
impl Unplugged {
fn check(&self) -> std::io::Result<()> {
if self.fail.load(Ordering::SeqCst) { Err(std::io::Error::other("drive unplugged")) } else { Ok(()) }
}
}
impl Store for Unplugged {
fn read(&mut self, name: &str) -> std::io::Result<Option<Vec<u8>>> {
self.files.read(name)
}
fn write_atomic(&mut self, name: &str, data: &[u8]) -> std::io::Result<()> {
self.check()?;
self.files.write_atomic(name, data)
}
fn append(&mut self, name: &str, data: &[u8]) -> std::io::Result<()> {
self.check()?;
self.files.append(name, data)
}
fn truncate(&mut self, name: &str, len: u64) -> std::io::Result<()> {
self.files.truncate(name, len)
}
fn describe(&self) -> String {
"unplugged".into()
}
}
fn has(h: &Headless, id: &str) -> bool {
h.app.widgets.iter().any(|(w, _)| w == id)
}
/// Click a widget and let the synthetic events (move, press, release) play out.
fn click(h: &mut Headless, id: &str) {
// a window that just appeared is placed on its second frame
h.step();
h.step();
assert_eq!(h.request("ui.clickWidget", json!({"id": id}), T)["ok"], true, "{id}");
for _ in 0..4 {
h.step();
}
}
#[test]
fn quitting_with_unsaved_changes_asks_first() {
let store = Unplugged::default();
let mut s = lightcraft_engine::Session::new();
let stores = LibraryStores { dir: "unplugged".into(), catalog: Box::new(store.clone()), files: Box::new(MemStore::new()), on_disk: false };
s.open_library_in(stores, false).unwrap();
let app = LightcraftApp::new(s, Services { png: None, ..Default::default() });
let mut h = Headless::new(app, [1200.0, 800.0], 1.0);
h.step();
// nothing unsaved: closing is fine
assert!(crate::panels::notices::may_close(&mut h.app));
store.fail.store(true, Ordering::SeqCst);
let r = h.request("engine.execute", json!({"command": "album.create", "params": {"name": "Kept"}}), T);
assert_eq!(r["ok"], false, "{r}");
assert!(!crate::panels::notices::may_close(&mut h.app), "retried, still failing: ask");
h.step();
let prompt = h.app.quit_prompt.clone().unwrap();
assert!(prompt.contains("1 change couldn't be written to disk") && prompt.contains("drive unplugged"), "{prompt}");
for b in ["button:quitRetry", "button:quitAnyway", "button:quitCancel"] {
assert!(has(&h, b), "{b}");
}
assert_eq!(h.request("ui.inspect", json!({}), T)["result"]["quitPrompt"], json!(prompt));
// Cancel: back to work, nothing quit
click(&mut h, "button:quitCancel");
assert!(h.app.quit_prompt.is_none() && !h.app.quit_confirmed);
// the drive is back: Try Saving Again saves and quits
assert!(!crate::panels::notices::may_close(&mut h.app));
h.step();
store.fail.store(false, Ordering::SeqCst);
click(&mut h, "button:quitRetry");
assert!(h.app.session.unsaved().is_none(), "saved");
assert!(h.app.quit_prompt.is_none() && !h.app.quit_confirmed);
// Quit Anyway is a deliberate choice
store.fail.store(true, Ordering::SeqCst);
let _ = h.request("engine.execute", json!({"command": "album.create", "params": {"name": "Lost"}}), T);
assert!(!crate::panels::notices::may_close(&mut h.app));
h.step();
click(&mut h, "button:quitAnyway");
assert!(h.app.quit_confirmed && crate::panels::notices::may_close(&mut h.app));
}
#[test]
fn damaged_settings_file_is_shown() {
let files = MemStore::new();
files.set("presets.json", b"{\"user\": [ {\"id\": ".to_vec());
let mut s = lightcraft_engine::Session::new();
let stores = LibraryStores { dir: "lib".into(), catalog: Box::new(MemStore::new()), files: Box::new(files.clone()), on_disk: false };
s.open_library_in(stores, false).unwrap();
let app = LightcraftApp::new(s, Services { png: None, ..Default::default() });
let mut h = Headless::new(app, [1200.0, 800.0], 1.0);
h.step();
h.step();
assert_eq!(h.app.notices.len(), 1);
assert!(h.app.notices[0].contains("presets.json is damaged"), "{:?}", h.app.notices);
assert!(has(&h, "button:noticeOk"));
assert!(files.files.lock().unwrap().keys().any(|k| k.starts_with("presets.json.corrupt-")));
click(&mut h, "button:noticeOk");
assert!(h.app.notices.is_empty(), "{:?}", h.app.notices);
h.step();
assert!(!has(&h, "button:noticeOk"));
}