Files
vectorcraft/crates/ui-egui/tests/safe_mode.rs
T

328 lines
15 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.
//! Safe Mode in the app: the choice after a launch that stopped while a plug-in was loading
//! (Disable and Continue, Start Normally, Start in Safe Mode), Help › Safe Mode with Enable on
//! Re-launch, and Safe Mode in the window title. Safe Mode and installed plug-ins are
//! process-wide, so these tests run in their own test binary, one at a time.
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use serde_json::json;
use vectorcraft_engine::Session;
use vectorcraft_plugins::safe_mode::{self, Item};
use vectorcraft_plugins::{registry, wat as tpl};
use vectorcraft_ui_egui::dialogs::safe_mode as ui;
use vectorcraft_ui_egui::panels::appearance;
use vectorcraft_ui_egui::{Services, VectorcraftApp, chrome, dialogs, theme};
static LOCK: Mutex<()> = Mutex::new(());
/// Safe Mode off, no launch marker, nothing pending, disabled or installed.
fn reset() {
ui::end_launch();
safe_mode::set_active(None);
safe_mode::forget_previous();
safe_mode::set_disabled(vec![]);
for p in registry::list() {
registry::remove(p.id());
}
}
/// Holds the lock for one test and resets the process-wide state when the test starts and when it
/// ends, so a test that fails leaves the next one as it would find a new process.
struct Fresh {
_guard: std::sync::MutexGuard<'static, ()>,
}
fn fresh() -> Fresh {
let guard = LOCK.lock().unwrap_or_else(|e| e.into_inner());
reset();
Fresh { _guard: guard }
}
impl Drop for Fresh {
fn drop(&mut self) {
reset();
}
}
fn filter(id: &str) -> Vec<u8> {
wat::parse_str(tpl::returning(&tpl::manifest(id, "filter", "{}"), r#"{"objects":[]}"#)).unwrap()
}
fn tmp_dir(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("vc-ui-safe-mode-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
/// A plug-in folder holding x and y, as `plugin.install` keeps them.
fn store_with_two(tag: &str) -> PathBuf {
let dir = tmp_dir(tag);
let mut s = Session::new();
s.attach_plugin_store(dir.clone());
for id in ["test.ui.x", "test.ui.y"] {
s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&filter(id))})).unwrap();
}
dir
}
/// A launch of the app after one that stopped while `stopped` was loading (none: Safe Mode from
/// `--safe-mode` when `flag`), with the plug-in folder `dir` and the launch marker folder `launch`.
fn launch(dir: &Path, launch: &Path, flag: bool) -> VectorcraftApp {
for p in registry::list() {
registry::remove(p.id());
}
let stopped = ui::begin_launch(Some(launch), flag);
let mut session = Session::new();
session.attach_plugin_store(dir.to_path_buf());
let mut app = VectorcraftApp::new(session, Services::default());
ui::after_start(&mut app, stopped);
app
}
/// A marker left by a launch that stopped while the stored plug-in x was loading.
fn stopped_while_loading_x(launch: &Path, dir: &Path) {
let x = std::fs::read_dir(dir).unwrap().flatten().map(|e| e.path()).find(|p| p.to_string_lossy().contains("test_ui_x")).unwrap();
let item = json!({"format": "vectorcraft-launch", "version": 1, "item": {"file": x.display().to_string()}});
std::fs::create_dir_all(launch).unwrap();
std::fs::write(launch.join("launch-1-dead.json"), item.to_string()).unwrap();
}
/// One headless frame of the dialog layer.
fn frame(app: &mut VectorcraftApp) {
let ctx = egui::Context::default();
theme::install_fonts(&ctx);
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| dialogs::show(app, ui.ctx()));
out.textures_delta.clear();
}
fn done(launch: &Path) {
ui::end_launch();
safe_mode::set_active(None);
safe_mode::forget_previous();
safe_mode::set_disabled(vec![]);
let _ = std::fs::remove_dir_all(launch);
}
fn kind(app: &VectorcraftApp) -> Option<&str> {
app.ui.dialog.as_ref().map(|d| d.kind.as_str())
}
#[test]
fn disable_and_continue_loads_every_plug_in_but_the_one_that_was_loading() {
let _f = fresh();
let dir = store_with_two("disable");
let launch_dir = dir.join("Launch");
stopped_while_loading_x(&launch_dir, &dir);
let mut app = launch(&dir, &launch_dir, false);
assert_eq!(kind(&app), Some(ui::STOPPED));
assert!(safe_mode::active() && registry::list().is_empty(), "nothing loads until the user chooses");
assert!(chrome::window_title(&app).ends_with("(Safe Mode)"));
frame(&mut app);
dialogs::confirm(&mut app).unwrap();
assert!(!safe_mode::active() && kind(&app).is_none());
assert!(registry::get("test.ui.x").is_none() && registry::get("test.ui.y").is_some(), "only the one that was loading is off");
assert_eq!(chrome::window_title(&app), "VectorCraft");
assert!(dir.join("disabled.json").is_file(), "kept off at later launches");
ui::end_launch();
// The next launch, which quit normally before: nothing is asked, and x stays off.
let app = launch(&dir, &launch_dir, false);
assert!(kind(&app).is_none() && !safe_mode::active());
assert!(registry::get("test.ui.x").is_none() && registry::get("test.ui.y").is_some());
done(&launch_dir);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn start_normally_loads_everything_and_start_in_safe_mode_loads_nothing() {
let _f = fresh();
let dir = store_with_two("normal");
let launch_dir = dir.join("Launch");
stopped_while_loading_x(&launch_dir, &dir);
let mut app = launch(&dir, &launch_dir, false);
app.ui.dialog.as_mut().unwrap().fields.insert("normal".into(), json!(true));
dialogs::confirm(&mut app).unwrap();
assert!(!ui::choosing(&app) && !ui::staying_on(&app), "Start Normally ends Safe Mode");
assert!(registry::get("test.ui.x").is_some() && registry::get("test.ui.y").is_some());
assert!(safe_mode::disabled().is_empty() && !dir.join("disabled.json").exists(), "Start Normally changes no setting");
ui::end_launch();
stopped_while_loading_x(&launch_dir, &dir);
let mut app = launch(&dir, &launch_dir, false);
assert!(ui::choosing(&app) && !ui::staying_on(&app), "Safe Mode until the user chooses");
dialogs::cancel(&mut app);
assert!(!ui::choosing(&app) && ui::staying_on(&app), "Safe Mode for the rest of the launch");
assert!(safe_mode::active() && registry::list().is_empty(), "Start in Safe Mode");
assert_eq!(safe_mode::source(), Some(safe_mode::Source::Startup));
assert!(safe_mode::disabled().is_empty(), "nothing turned off");
ui::end_launch();
safe_mode::set_active(None);
let app = launch(&dir, &launch_dir, false);
assert_eq!(kind(&app), Some(ui::STOPPED), "Start in Safe Mode leaves the choice to the next launch");
done(&launch_dir);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_disabled_list_that_cannot_be_saved_says_so() {
let _f = fresh();
let dir = store_with_two("unsaved");
let launch_dir = dir.join("Launch");
stopped_while_loading_x(&launch_dir, &dir);
// A later version's list, which a save keeps as it is.
let later = r#"{"format": "vectorcraft-disabled-plugins", "version": 2, "items": []}"#;
std::fs::write(dir.join("disabled.json"), later).unwrap();
let unsaved = "Couldn't save the disabled plug-ins for the next launch";
let mut app = launch(&dir, &launch_dir, false);
dialogs::confirm(&mut app).unwrap();
assert_eq!(app.ui.status, unsaved, "Disable and Continue");
assert!(safe_mode::previous().is_some(), "the next launch asks again");
assert!(registry::get("test.ui.x").is_none(), "this launch still skips it");
app.ui.status.clear();
app.run("help.safeMode", json!({})).unwrap();
app.ui.dialog.as_mut().unwrap().fields["items"][0]["enable"] = json!(true);
dialogs::confirm(&mut app).unwrap();
assert_eq!(app.ui.status, unsaved, "Enable on Re-launch");
assert_eq!(std::fs::read_to_string(dir.join("disabled.json")).unwrap(), later);
done(&launch_dir);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_safe_mode_label_leaves_the_menus_their_room() {
let _f = fresh();
safe_mode::set_active(Some(safe_mode::Source::Flag));
let menu_names = ["File", "Edit", "Object", "Type", "Select", "Effect", "View", "Window", "Help"];
for width in [560.0, 720.0, 1280.0] {
let ctx = egui::Context::default();
theme::install_fonts(&ctx);
let mut app = VectorcraftApp::new(Session::new(), Services::default());
let input = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 400.0))), ..Default::default() };
let mut out = ctx.run_ui(input, |ui| chrome::app_bar(&mut app, ui));
out.textures_delta.clear();
let texts: Vec<_> = out
.shapes
.iter()
.filter_map(|s| match &s.shape {
egui::Shape::Text(t) => Some((t.galley.job.text.clone(), t.visual_bounding_rect())),
_ => None,
})
.collect();
let menus: Vec<egui::Rect> = texts.iter().filter(|(t, _)| menu_names.contains(&t.as_str())).map(|(_, r)| *r).collect();
assert!(!menus.is_empty(), "{width}: the menus are drawn: {texts:?}");
let (start, end) = menus.iter().fold((f32::MAX, f32::MIN), |(a, b), r| (a.min(r.left()), b.max(r.right())));
assert!(texts.iter().any(|(t, _)| t == "Safe Mode"), "{width}: the label is drawn");
for (t, r) in texts.iter().filter(|(t, _)| !menu_names.contains(&t.as_str())) {
assert!(r.left() >= end || r.right() <= start, "{width}: {t:?} at {r:?} overlaps the menus from {start} to {end}");
}
}
}
#[test]
fn a_dialog_that_replaces_the_choice_leaves_it_waiting() {
let _f = fresh();
let dir = store_with_two("replaced");
let launch_dir = dir.join("Launch");
stopped_while_loading_x(&launch_dir, &dir);
let mut app = launch(&dir, &launch_dir, false);
// Help › Safe Mode (or any other dialog) takes the choice's place before it is answered.
app.run("help.safeMode", json!({})).unwrap();
assert_eq!(kind(&app), Some(ui::KIND));
assert!(ui::choosing(&app) && !ui::staying_on(&app), "still waiting for the choice");
dialogs::cancel(&mut app);
assert_eq!(kind(&app), Some(ui::STOPPED), "the choice opens again once no dialog is open");
assert!(ui::choosing(&app) && registry::list().is_empty());
dialogs::confirm(&mut app).unwrap();
assert!(!ui::choosing(&app) && !safe_mode::active());
assert!(registry::get("test.ui.x").is_none() && registry::get("test.ui.y").is_some());
done(&launch_dir);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_safe_mode_flag_asks_nothing_and_loads_nothing() {
let _f = fresh();
let dir = store_with_two("flag");
let launch_dir = dir.join("Launch");
let app = launch(&dir, &launch_dir, true);
assert!(kind(&app).is_none() && ui::staying_on(&app));
assert_eq!((safe_mode::source(), registry::list().len()), (Some(safe_mode::Source::Flag), 0));
assert_eq!(app.ui.status, vectorcraft_plugins::Error::SafeMode.to_string());
assert!(chrome::window_title(&app).ends_with("(Safe Mode)"));
// An object's plug-in effect stays in its Appearance list, marked as skipped.
assert!(appearance::effect_row_label("plugin.org.example.glow").ends_with("(Safe Mode)"));
assert!(!appearance::effect_row_label("stylize.dropShadow").contains("Safe Mode"), "built-in effects still draw");
// Two plug-ins' effects with the same row name: the tooltip names each one's plug-in.
let (a, b) = (appearance::skipped_effect_tip("plugin.com.a.blur").unwrap(), appearance::skipped_effect_tip("plugin.com.b.blur").unwrap());
assert!(a.ends_with("\ncom.a.blur") && b.ends_with("\ncom.b.blur"), "{a:?} {b:?}");
assert_eq!(appearance::skipped_effect_tip("stylize.dropShadow"), None);
safe_mode::set_active(None);
assert!(!appearance::effect_row_label("plugin.org.example.glow").contains("Safe Mode"));
assert_eq!(appearance::skipped_effect_tip("plugin.com.a.blur"), None);
done(&launch_dir);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_safe_mode_flag_launch_leaves_the_stopped_plug_in_to_the_next_launch() {
let _f = fresh();
let dir = store_with_two("flag-pending");
let launch_dir = dir.join("Launch");
stopped_while_loading_x(&launch_dir, &dir);
let app = launch(&dir, &launch_dir, true);
assert!(kind(&app).is_none() && registry::list().is_empty(), "--safe-mode asks nothing");
assert!(safe_mode::previous().is_some(), "the plug-in is still pending");
ui::end_launch();
safe_mode::set_active(None);
// The next launch, without the flag, asks about it before anything loads.
let mut app = launch(&dir, &launch_dir, false);
assert_eq!(kind(&app), Some(ui::STOPPED));
assert!(registry::list().is_empty());
dialogs::confirm(&mut app).unwrap();
assert!(registry::get("test.ui.x").is_none() && registry::get("test.ui.y").is_some());
ui::end_launch();
// A launch after the choice asks nothing.
let app = launch(&dir, &launch_dir, false);
assert!(kind(&app).is_none() && !safe_mode::active());
done(&launch_dir);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn help_safe_mode_enables_a_plug_in_for_the_next_launch_only() {
let _f = fresh();
let dir = store_with_two("help");
let launch_dir = dir.join("Launch");
let x = Item { id: Some("test.ui.x".into()), name: Some("X\u{202E}gnp".into()), file: None };
let mut s = Session::new();
s.attach_plugin_store(dir.clone());
assert!(safe_mode::set_enabled(&x, false));
assert!(s.store_disabled_plugins());
let mut app = launch(&dir, &launch_dir, false);
assert!(registry::get("test.ui.x").is_none());
app.run("help.safeMode", json!({})).unwrap();
assert_eq!(kind(&app), Some(ui::KIND));
let d = app.ui.dialog.as_mut().unwrap();
assert_eq!(d.fields["items"][0]["label"], "Xgnp", "a name can't reorder the dialog's text");
frame(&mut app);
// Close keeps everything as it was.
dialogs::cancel(&mut app);
assert_eq!(safe_mode::disabled(), std::slice::from_ref(&x));
app.run("help.safeMode", json!({})).unwrap();
app.ui.dialog.as_mut().unwrap().fields["items"][0]["enable"] = json!(true);
dialogs::confirm(&mut app).unwrap();
assert!(safe_mode::disabled().is_empty());
assert!(registry::get("test.ui.x").is_none(), "not loaded during this launch");
ui::end_launch();
launch(&dir, &launch_dir, false);
assert!(registry::get("test.ui.x").is_some(), "loaded at the next launch");
done(&launch_dir);
let _ = std::fs::remove_dir_all(&dir);
}