//! Settings, keyboard shortcut presets, auto-save and crash recovery. use std::path::PathBuf; use std::sync::Arc; use serde_json::json; use crate::config::{ConfigStore, DirConfig, MemoryConfig, atomic_write, atomic_write_with, temp_path}; use crate::prefs::{PREFS_FILE, Prefs}; use crate::shortcuts::{DEFAULT_PRESET, Keymaps, normalize}; use crate::{Session, autosave}; fn tmp(name: &str) -> PathBuf { use std::sync::atomic::{AtomicU32, Ordering}; static N: AtomicU32 = AtomicU32::new(0); let d = std::env::temp_dir().join(format!("ec-settings-{}-{}-{name}", std::process::id(), N.fetch_add(1, Ordering::Relaxed))); let _ = std::fs::remove_dir_all(&d); std::fs::create_dir_all(&d).unwrap(); d } fn session_with_comp() -> Session { let mut s = Session::default(); s.execute("comp.new", json!({"width": 320, "height": 180, "duration": 10})).unwrap(); s } // ---------------------------------------------------------------- prefs model #[test] fn prefs_round_trip_through_json() { let mut p = Prefs::default(); p.set("general.undoLevels", json!(12)).unwrap(); p.set("labels.2.name", json!("Teal")).unwrap(); p.set("autoSave.location", json!("custom")).unwrap(); p.set("appearance.brightness", json!(0.25)).unwrap(); p.push_recent("/a/b.ecproj"); let back = Prefs::from_json(&p.to_json()); assert_eq!(back, p); assert_eq!(Prefs::from_json(&Prefs::default().to_json()), Prefs::default()); } #[test] fn prefs_migrate_old_layout_and_keep_unknown_keys() { let old = r#"{ "undoLevels": 50, "autoSaveMinutes": 7, "autoSaveVersions": 3, "theme": "light", "labelNames": ["Hot", "Sun"], "futureSetting": {"x": 1}, "general": {"showToolTips": false, "notYetKnown": true}, "memory": {"layerCacheMb": "not a number"} }"#; let p = Prefs::from_json(old); assert_eq!(p.version, crate::prefs::PREFS_VERSION); assert_eq!(p.general.undo_levels, 50); assert!(!p.general.show_tool_tips); assert_eq!(p.auto_save.interval_minutes, 7); assert_eq!(p.auto_save.max_versions, 3); assert_eq!(p.appearance.theme, "light"); assert_eq!(p.labels[0].name, "Hot"); assert_eq!(p.labels[1].name, "Sun"); assert_eq!(p.labels[2].name, "Aqua"); assert_eq!(p.labels.len(), 16); // A bad value falls back to that page's defaults; other pages survive. assert_eq!(p.memory.layer_cache_mb, 1024); // Unknown keys (a newer version's) are kept and written back. let again: serde_json::Value = serde_json::from_str(&p.to_json()).unwrap(); assert_eq!(again["futureSetting"], json!({"x": 1})); assert_eq!(again["general"]["notYetKnown"], json!(true)); assert!(again.get("undoLevels").is_none(), "migrated keys leave the top level"); // Garbage is defaults. assert_eq!(Prefs::from_json("not json"), Prefs::default()); } #[test] fn prefs_set_validates_keys_types_and_ranges() { let mut p = Prefs::default(); assert!(p.set("general.nope", json!(1)).is_err()); assert!(p.set("general.showToolTips", json!([1])).is_err()); p.set("general.undoLevels", json!(500)).unwrap(); assert_eq!(p.general.undo_levels, 99); p.set("general.undoLevels", json!("7")).unwrap(); assert_eq!(p.general.undo_levels, 7); p.set("labels.0.color", json!("zzz")).unwrap(); assert_eq!(p.labels[0].color, Prefs::default().labels[0].color); p.reset(Some("general")).unwrap(); assert_eq!(p.general.undo_levels, 32); assert!(p.reset(Some("bogus")).is_err()); } /// Settings ▸ Appearance ▸ UI Scale (#284): a percentage, 100 by default, kept within 75–200; /// the dialog types or drags any whole percentage in that range (#682). #[test] fn ui_scale_is_a_percentage_from_75_to_200() { let mut p = Prefs::default(); assert_eq!(p.appearance.ui_scale, 100); p.set("appearance.uiScale", json!("150")).unwrap(); assert_eq!(p.appearance.ui_scale, 150); assert_eq!(p.get("appearance.uiScale"), Some(json!(150))); p.set("appearance.uiScale", json!(500)).unwrap(); assert_eq!(p.appearance.ui_scale, 200); p.set("appearance.uiScale", json!(10)).unwrap(); assert_eq!(p.appearance.ui_scale, 75); let page = crate::prefs::pages().into_iter().find(|pg| pg.id == "appearance").unwrap(); let field = crate::prefs::Item::Setting { key: "appearance.uiScale", label: "UI Scale", kind: crate::prefs::Kind::Int(75, 200, "%"), live: true }; assert!(page.items.contains(&field), "Settings ▸ Appearance offers it"); // Older settings files without it get the default. assert_eq!(Prefs::from_json(r#"{"version": 2, "appearance": {"theme": "light"}}"#).appearance.ui_scale, 100); } /// Settings ▸ Appearance: new installs stay Dark (Sync with System is opt-in), and settings saved /// with a single theme load as a fixed mode showing that theme. #[test] fn appearance_defaults_and_legacy_theme_migrate() { let p = Prefs::default(); assert_eq!((p.appearance.appearance_mode.as_str(), p.appearance.dark_theme.as_str(), p.appearance.light_theme.as_str()), ("dark", "dark", "light")); assert_eq!(p.appearance.resolved_theme(Some(true)), "dark"); for (saved, mode, dark, light) in [("darker", "dark", "darker", "light"), ("light", "light", "dark", "light"), ("dark", "dark", "dark", "light")] { let p = Prefs::from_json(&json!({"version": 2, "appearance": {"theme": saved}}).to_string()); assert_eq!(p.appearance.appearance_mode, mode, "{saved}"); assert_eq!(p.appearance.dark_theme, dark, "{saved}"); assert_eq!(p.appearance.light_theme, light, "{saved}"); assert_eq!(p.appearance.theme, saved); } // The flat layout of the earliest builds migrates too. assert_eq!(Prefs::from_json(r#"{"theme": "light"}"#).appearance.appearance_mode, "light"); // A file that already has a mode keeps it; garbage falls back to the defaults. let p = Prefs::from_json(r#"{"version": 2, "appearance": {"theme": "dark", "appearanceMode": "auto", "darkTheme": "darker"}}"#); assert_eq!((p.appearance.appearance_mode.as_str(), p.appearance.dark_theme.as_str()), ("auto", "darker")); let p = Prefs::from_json(r#"{"version": 2, "appearance": {"appearanceMode": "sepia", "darkTheme": "light", "lightTheme": 7}}"#); assert_eq!((p.appearance.appearance_mode.as_str(), p.appearance.dark_theme.as_str(), p.appearance.light_theme.as_str()), ("dark", "dark", "light")); } /// The appearance settings take only their choices, survive a restart, and old clients that set /// the single theme still pick a visible theme without losing the other family's choice. #[test] fn appearance_choices_validate_and_legacy_theme_still_selects() { let mut s = Session::default(); s.execute("prefs.set", json!({"values": {"appearance.appearanceMode": "auto", "appearance.darkTheme": "darker", "appearance.lightTheme": "light"}})) .unwrap(); assert_eq!(s.prefs.appearance.appearance_mode, "auto"); assert_eq!(s.prefs.appearance.dark_theme, "darker"); assert_eq!(s.prefs.appearance.resolved_theme(Some(true)), "light"); assert_eq!(s.prefs.appearance.resolved_theme(Some(false)), "darker"); assert_eq!(s.prefs.appearance.resolved_theme(None), "darker", "no system answer: dark"); assert_eq!(Prefs::from_json(&s.prefs.to_json()).appearance, s.prefs.appearance); for (key, bad) in [("appearance.darkTheme", "light"), ("appearance.lightTheme", "darker"), ("appearance.appearanceMode", "sepia"), ("appearance.theme", "neon")] { assert!(s.execute("prefs.set", json!({"key": key, "value": bad})).is_err(), "{key} = {bad}"); } s.execute("prefs.set", json!({"key": "appearance.theme", "value": "light"})).unwrap(); assert_eq!(s.prefs.appearance.appearance_mode, "light"); assert_eq!(s.prefs.appearance.dark_theme, "darker"); s.execute("prefs.set", json!({"key": "appearance", "value": {"theme": "dark"}})).unwrap(); assert_eq!(s.prefs.appearance.appearance_mode, "dark"); assert_eq!(s.prefs.appearance.dark_theme, "dark"); // A fixed mode mirrors its theme in the single-theme key for old readers. s.execute("prefs.set", json!({"key": "appearance.appearanceMode", "value": "light"})).unwrap(); assert_eq!(s.prefs.get("appearance.theme"), Some(json!("light"))); s.prefs.reset(Some("appearance")).unwrap(); assert_eq!(s.prefs.appearance, crate::prefs::Appearance::default()); } #[test] fn every_schema_key_exists_and_docs_list_the_todo_settings() { let p = Prefs::default(); let mut ids = vec![]; for page in crate::prefs::pages() { assert!(crate::prefs::page_id(page.id).is_some(), "page {}", page.id); ids.push(page.id); for item in page.items { if let crate::prefs::Item::Setting { key, .. } | crate::prefs::Item::AudioDevices { key } = item { assert!(p.get(key).is_some(), "schema key `{key}` is not a setting"); } } } // The Settings menu and the dialog list the same pages. for (path, e) in crate::menus::entries() { if e.command == "app.settings" { let page = e.params["page"].as_str().unwrap(); assert!(ids.contains(&page), "{path:?} opens unknown page {page}"); } } let docs = include_str!("../../../docs/preferences.md"); for k in crate::prefs::todo_keys() { assert!(docs.contains(&format!("`{k}`")), "docs/preferences.md must list the not-yet-wired setting `{k}`"); } } // ---------------------------------------------------------------- prefs effects #[test] fn prefs_commands_get_set_reset_and_persist() { let store = Arc::new(MemoryConfig::default()); let mut s = Session { config: Some(store.clone()), ..Default::default() }; s.execute_checked("prefs.set", json!({"key": "general.recentItems", "value": 4})).unwrap(); assert_eq!(s.execute("prefs.get", json!({"key": "general.recentItems"})).unwrap(), json!(4)); s.execute("prefs.set", json!({"values": {"autoSave.maxVersions": 9, "grids.gridSpacing": 50}})).unwrap(); assert!(s.execute("prefs.set", json!({"key": "nope.nope", "value": 1})).is_err()); let saved = store.read(PREFS_FILE).unwrap(); let mut s2 = Session { config: Some(store.clone()), ..Default::default() }; s2.load_settings(); assert_eq!(s2.prefs.general.recent_items, 4); assert_eq!(s2.prefs.auto_save.max_versions, 9); assert!(saved.contains("\"maxVersions\": 9")); s2.execute("prefs.reset", json!({"page": "project"})).unwrap(); assert_eq!(s2.prefs.auto_save.max_versions, 5); assert!(s2.execute("prefs.pages", json!({})).unwrap().as_array().unwrap().len() >= 17); s2.execute("prefs.open", json!({"page": "Auto-Save"})).unwrap(); assert!( s2.drain_events().iter().any(|e| matches!(e, crate::Event::Frontend { command, params } if command == "app.settings" && params["page"] == "project")) ); } /// Composition Settings presets the user saves live in the settings: they persist, a save with /// an existing name replaces it, they survive Reset All, and invalid ones (from a hand-edited /// settings file) are dropped on load. #[test] fn composition_presets_save_replace_delete_and_persist() { let store = Arc::new(MemoryConfig::default()); let mut s = Session { config: Some(store.clone()), ..Default::default() }; let p = |name: &str, w: u32, fps: f64| json!({"name": name, "width": w, "height": 1440, "pixelAspect": 1.0, "frameRate": fps}); assert_eq!(s.execute_checked("comp.savePreset", p(" Edit 1440p 60 ", 2560, 60.0)).unwrap(), json!({"name": "Edit 1440p 60", "replaced": false})); s.execute_checked("comp.savePreset", p("Vertical", 1440, 30.0)).unwrap(); assert_eq!(s.execute("comp.savePreset", p("Vertical", 1080, 30.0)).unwrap()["replaced"], json!(true)); let list = s.execute("comp.presets", json!({})).unwrap(); assert_eq!(list.as_array().unwrap().len(), 2); assert_eq!(list[0]["name"], "Edit 1440p 60"); assert_eq!((list[0]["width"].clone(), list[0]["frameRate"].clone()), (json!(2560), json!(60.0))); assert_eq!(list[1]["width"], json!(1080), "replaced in place"); for bad in [ p("", 1920, 30.0), p("Tiny", 2, 30.0), p("Fast", 1920, 5000.0), json!({"name": "x", "width": 1920.5, "height": 1080, "pixelAspect": 1.0, "frameRate": 30}), json!({"name": "x"}), ] { assert!(s.execute("comp.savePreset", bad.clone()).is_err(), "{bad}"); } // Persisted, and kept by Reset All. let mut s2 = Session { config: Some(store.clone()), ..Default::default() }; s2.load_settings(); assert_eq!(s2.prefs.comp_presets, s.prefs.comp_presets); s2.execute("prefs.reset", json!({})).unwrap(); assert_eq!(s2.prefs.comp_presets.len(), 2); s2.execute_checked("comp.deletePreset", json!({"name": "Vertical"})).unwrap(); assert!(s2.execute("comp.deletePreset", json!({"name": "Vertical"})).is_err(), "already gone"); let mut s3 = Session { config: Some(store.clone()), ..Default::default() }; s3.load_settings(); assert_eq!(s3.prefs.comp_presets.iter().map(|q| q.name.as_str()).collect::>(), ["Edit 1440p 60"]); // A hand-edited settings file: the broken preset is dropped, the good one kept. let mut v: serde_json::Value = serde_json::from_str(&store.read(PREFS_FILE).unwrap()).unwrap(); v["compPresets"].as_array_mut().unwrap().push(json!({"name": "Broken", "width": 0, "height": 1080, "pixelAspect": 1.0, "frameRate": f64::NAN})); v["compPresets"].as_array_mut().unwrap().push(json!({"name": 7})); let back = Prefs::from_json(&v.to_string()); assert_eq!(back.comp_presets.iter().map(|q| q.name.as_str()).collect::>(), ["Edit 1440p 60"]); } #[test] fn undo_levels_are_honoured() { let mut s = session_with_comp(); s.execute("prefs.set", json!({"key": "general.undoLevels", "value": 3})).unwrap(); for i in 0..6 { s.execute("layer.newNull", json!({"name": format!("N{i}")})).unwrap(); } assert_eq!(s.history.undo.len(), 3); for _ in 0..3 { assert!(s.undo()); } assert!(!s.undo()); assert_eq!(s.active_comp().unwrap().layers.len(), 3); // Lowering the setting trims the history right away. s.execute("prefs.set", json!({"key": "general.undoLevels", "value": 1})).unwrap(); assert!(s.history.undo.len() <= 1); } #[test] fn renamed_labels_show_in_the_label_menu_and_apply_by_name() { let mut s = session_with_comp(); s.execute("layer.newNull", json!({})).unwrap(); s.execute("prefs.set", json!({"key": "labels.0.name", "value": "Hot Sauce"})).unwrap(); s.execute("prefs.set", json!({"key": "labels.0.color", "value": "#102030"})).unwrap(); let red = crate::menus::entries().into_iter().find(|(_, e)| e.command == "edit.label" && e.params["label"] == "Red").unwrap().1; assert_eq!(crate::menus::entry_label(&s, red), "Hot Sauce"); assert_eq!(s.prefs.label_rgb(effectcraft_color::Label::Red), [0x10, 0x20, 0x30]); s.execute("edit.label", json!({"label": "Yellow"})).unwrap(); s.execute("edit.label", json!({"label": "hot sauce"})).unwrap(); let l = &s.active_comp().unwrap().layers[0]; assert_eq!(l.label, effectcraft_color::Label::Red); } #[test] fn cache_budgets_are_applied() { let mut s = Session::default(); s.execute("prefs.set", json!({"key": "memory.layerCacheMb", "value": 128})).unwrap(); assert_eq!(s.layer_cache.budget(), 128 << 20); struct Src(std::sync::Mutex); impl effectcraft_render::FootageSource for Src { fn frame(&self, _: effectcraft_project::ItemId, _: &effectcraft_project::Footage, _: effectcraft_time::Tick) -> Option> { None } fn set_cache_budget(&self, b: usize) { *self.0.lock().unwrap() = b; } } let src = Arc::new(Src(Default::default())); s.footage = src.clone(); s.execute("prefs.set", json!({"key": "memory.mediaCacheMb", "value": 256})).unwrap(); assert_eq!(*src.0.lock().unwrap(), 256 << 20); } #[test] fn new_layers_start_at_the_current_time_when_asked() { let mut s = session_with_comp(); let nested = s.execute("comp.new", json!({"name": "Nested", "open": false})).unwrap()["comp"].as_u64().unwrap(); s.set_time(effectcraft_time::Tick::from_seconds_f64(2.0)); s.execute("layer.addItem", json!({"item": nested})).unwrap(); assert_eq!(s.active_comp().unwrap().layers[0].start_time, effectcraft_time::Tick::ZERO); s.execute("prefs.set", json!({"key": "general.createLayersAtCompStart", "value": false})).unwrap(); s.execute("layer.addItem", json!({"item": nested})).unwrap(); assert_eq!(s.active_comp().unwrap().layers[0].start_time.seconds().round(), 2.0); } #[test] fn default_spatial_interpolation_linear() { let mut s = session_with_comp(); s.execute("layer.newNull", json!({})).unwrap(); s.execute("prop.set", json!({"path": "transform/position", "value": [0, 0], "time": 0})).unwrap(); s.execute("prop.toggleAnimation", json!({"path": "transform/position", "value": true})).ok(); s.execute("prop.addKey", json!({"path": "transform/position", "time": 0})).unwrap(); let auto = |s: &Session| s.active_comp().unwrap().layers[0].props.prop("transform/position").unwrap().keys.iter().map(|k| k.spatial_auto).collect::>(); assert!(auto(&s).iter().all(|a| *a)); s.execute("prefs.set", json!({"key": "general.defaultSpatialLinear", "value": true})).unwrap(); s.execute("prop.addKey", json!({"path": "transform/position", "time": 1, "value": [100, 50]})).unwrap(); assert_eq!(auto(&s), vec![true, false]); } #[test] fn new_project_template_and_default_renderer() { let d = tmp("template"); let tpl = d.join("tpl.ecproj"); let mut a = session_with_comp(); a.execute("file.saveAs", json!({"path": tpl.to_string_lossy()})).unwrap(); let mut s = Session::default(); s.execute("prefs.set", json!({"values": {"project.useTemplate": true, "project.templatePath": tpl.to_string_lossy()}})).unwrap(); s.execute("file.newProject", json!({})).unwrap(); assert_eq!(s.project.comps().count(), 1); assert!(s.path.is_none(), "a template opens untitled"); s.execute("prefs.set", json!({"key": "threeD.defaultRenderer", "value": "advanced"})).unwrap(); s.execute("comp.new", json!({})).unwrap(); assert_eq!(s.active_comp().unwrap().renderer, effectcraft_project::Renderer::Advanced3D); let _ = std::fs::remove_dir_all(d); } // ---------------------------------------------------------------- shortcuts #[test] fn shortcut_text_normalizes() { assert_eq!(normalize("shift+cmd+k").as_deref(), Some("Cmd+Shift+K")); assert_eq!(normalize("Opt+Ctrl+f5").as_deref(), Some("Ctrl+Alt+F5")); assert_eq!(normalize("⌘⇧S").as_deref(), Some("Cmd+Shift+S")); assert_eq!(normalize("Cmd++").as_deref(), Some("Cmd++")); assert_eq!(normalize("space").as_deref(), Some("Space")); assert_eq!(normalize("Cmd+").as_deref(), None); } #[test] fn defaults_have_no_conflicts_and_match_the_menus() { let s = Session::default(); let t = s.shortcuts(); assert!(t.all_conflicts().is_empty(), "{:?}", t.all_conflicts()); assert_eq!(t.shortcut_of("file.save", &json!(null)), Some("Cmd+S")); assert_eq!(t.shortcut_of("app.settings", &json!({"page": "general"})), Some("Cmd+Alt+;")); assert!(t.bindables.len() > 500); } /// Each Fast Previews and Show Channel mode is a command with a default key. #[test] fn fast_previews_and_show_channel_modes_have_default_keys() { let s = Session::default(); let t = s.shortcuts(); for (id, key) in [ ("view.fastPreviews.off", "Cmd+Alt+1"), ("view.fastPreviews.adaptive", "Cmd+Alt+2"), ("view.fastPreviews.draft", "Cmd+Alt+3"), ("view.fastPreviews.fastDraft", "Cmd+Alt+4"), ("view.fastPreviews.wireframe", "Cmd+Alt+5"), ("view.channel.red", "Alt+1"), ("view.channel.green", "Alt+2"), ("view.channel.blue", "Alt+3"), ("view.channel.alpha", "Alt+4"), ("view.channel.redColorized", "Alt+Shift+1"), ("view.channel.greenColorized", "Alt+Shift+2"), ("view.channel.blueColorized", "Alt+Shift+3"), ("view.channel.rgbStraight", "Alt+Shift+4"), ] { assert_eq!(t.shortcut_of(id, &json!(null)), Some(key), "{id}"); } assert!(t.all_conflicts().is_empty(), "{:?}", t.all_conflicts()); // The commands set their mode; a channel's key again is RGB. let mut s = Session::default(); s.execute("view.fastPreviews.fastDraft", json!({})).unwrap(); assert_eq!(s.state.viewer.fast_previews.id(), "fastDraft"); s.execute("view.channel.redColorized", json!({})).unwrap(); assert_eq!((s.state.viewer.channel.id(), s.state.viewer.colorized), ("red", true)); s.execute("view.channel.green", json!({})).unwrap(); assert_eq!((s.state.viewer.channel.id(), s.state.viewer.colorized), ("green", false)); s.execute("view.channel.green", json!({})).unwrap(); assert_eq!(s.state.viewer.channel.id(), "rgb"); // Grayscale and colorized of one channel replace each other; the same again is RGB. s.execute("view.channel.redColorized", json!({})).unwrap(); s.execute("view.channel.red", json!({})).unwrap(); assert_eq!((s.state.viewer.channel.id(), s.state.viewer.colorized), ("red", false)); s.execute("view.channel.redColorized", json!({})).unwrap(); assert_eq!((s.state.viewer.channel.id(), s.state.viewer.colorized), ("red", true)); s.execute("view.channel.redColorized", json!({})).unwrap(); assert_eq!(s.state.viewer.channel.id(), "rgb"); } /// Default keys for commands and menu entries that had none. #[test] fn default_keys_for_commands_that_had_none() { let s = Session::default(); let t = s.shortcuts(); for (id, params, key) in [ ("edit.pasteReversedKeyframes", json!(null), "Cmd+Shift+V"), ("edit.purge", json!({"what": "snapshot"}), "Cmd+Shift+F5"), ("layer.sampling", json!({"sampling": "bilinear", "toggle": true}), "Alt+B"), ("layer.sampling", json!({"sampling": "bicubic", "toggle": true}), "Alt+Shift+B"), ("view.res.custom", json!(null), "Cmd+Alt+J"), ("file.setProxy", json!(null), "Cmd+Alt+P"), ("file.openRecent", json!(null), "Cmd+Alt+Shift+P"), ("anim.applyRecentPreset", json!(null), "Cmd+Alt+Shift+F"), ] { assert_eq!(t.shortcut_of(id, ¶ms), Some(key), "{id} {params}"); } assert!(t.all_conflicts().is_empty(), "{:?}", t.all_conflicts()); } /// Second default keys: on the command, or after a comma in a menu line with bound parameters. #[test] fn second_default_keys() { let s = Session::default(); let t = s.shortcuts(); for (key, want) in [ ("edit.deselectAll", ["Cmd+Shift+A", "F2"]), ("time.start", ["Home", "Cmd+Alt+ArrowLeft"]), ("time.end", ["End", "Cmd+Alt+ArrowRight"]), ("renderQueue.add", ["Cmd+M", "Cmd+Shift+/"]), (r#"window.panel {"panel":"effectControls"}"#, ["F3", "Cmd+Shift+T"]), ] { assert_eq!(t.keys.get(key).map(Vec::as_slice), Some(want.map(String::from).as_slice()), "{key}"); } // The menus keep showing the first key. assert_eq!(t.shortcut_of("window.panel", &json!({"panel": "effectControls"})), Some("F3")); assert_eq!(t.shortcut_of("edit.deselectAll", &json!(null)), Some("Cmd+Shift+A")); assert!(t.all_conflicts().is_empty(), "{:?}", t.all_conflicts()); } /// Cmd+Left / Cmd+Right Arrow step a frame like Page Up / Page Down (Shift: 10 frames). #[test] fn cmd_arrows_step_frames_like_page_keys() { let s = Session::default(); let t = s.shortcuts(); for (id, keys) in [ ("time.nextFrame", ["PageDown", "Cmd+ArrowRight"]), ("time.previousFrame", ["PageUp", "Cmd+ArrowLeft"]), ("time.forward10", ["Shift+PageDown", "Cmd+Shift+ArrowRight"]), ("time.back10", ["Shift+PageUp", "Cmd+Shift+ArrowLeft"]), ] { assert_eq!(t.keys.get(id).map(Vec::as_slice), Some(keys.map(String::from).as_slice()), "{id}"); // The menus and tooltips keep showing the Page key. assert_eq!(t.shortcut_of(id, &json!(null)), Some(keys[0])); } assert!(t.all_conflicts().is_empty(), "{:?}", t.all_conflicts()); } #[test] fn shortcut_edits_detect_conflicts_and_create_a_custom_preset() { let store = Arc::new(MemoryConfig::default()); let mut s = Session { config: Some(store.clone()), ..Default::default() }; let r = s.execute_checked("shortcuts.set", json!({"command": "layer.newNull", "keys": "cmd+s"})).unwrap(); assert_eq!(r["keys"], json!(["Cmd+S"])); assert_eq!(r["created"], json!("Custom")); assert!(r["conflicts"].as_array().unwrap().iter().any(|c| c["key"] == "file.save"), "{r}"); assert_eq!(s.keymaps.active, "Custom"); assert_eq!(s.shortcuts().shortcut_of("layer.newNull", &json!(null)), Some("Cmd+S")); assert!(!s.execute("shortcuts.conflicts", json!({})).unwrap().as_array().unwrap().is_empty()); // The default preset is untouched. s.execute("shortcuts.preset", json!({"op": "select", "name": DEFAULT_PRESET})).unwrap(); assert_eq!(s.shortcuts().shortcut_of("layer.newNull", &json!(null)), Some("Cmd+Alt+Shift+Y")); // Remove a shortcut; reset one. s.execute("shortcuts.preset", json!({"op": "select", "name": "Custom"})).unwrap(); s.execute("shortcuts.set", json!({"command": "file.save", "keys": null})).unwrap(); assert_eq!(s.shortcuts().shortcut_of("file.save", &json!(null)), None); s.execute("shortcuts.reset", json!({"command": "file.save"})).unwrap(); assert_eq!(s.shortcuts().shortcut_of("file.save", &json!(null)), Some("Cmd+S")); // Bound-parameter entries are bindable too. s.execute("shortcuts.set", json!({"command": "app.settings", "params": {"page": "labels"}, "keys": ["F9"]})).unwrap(); assert_eq!(s.shortcuts().shortcut_of("app.settings", &json!({"page": "labels"})), Some("F9")); // Persisted. let k = Keymaps::from_json(&store.read(crate::shortcuts::SHORTCUTS_FILE).unwrap()); assert_eq!(k.active, "Custom"); assert!(s.execute("shortcuts.set", json!({"command": "no.such", "keys": "F1"})).is_err()); assert!(s.execute("shortcuts.set", json!({"command": "file.save", "keys": "Cmd+"})).is_err()); let list = s.execute("shortcuts.list", json!({"keys": "F9"})).unwrap(); assert!(list["commands"].as_array().unwrap().iter().any(|c| c["command"] == "app.settings" && c["params"]["page"] == "labels"), "{list}"); } #[test] fn shortcut_presets_duplicate_rename_delete_export_import() { let d = tmp("presets"); let mut s = Session::default(); s.execute("shortcuts.preset", json!({"op": "new", "name": "Mine"})).unwrap(); s.execute("shortcuts.set", json!({"command": "edit.undo", "keys": ["Cmd+Z", "F2"]})).unwrap(); s.execute("shortcuts.preset", json!({"op": "duplicate", "name": "Mine 2"})).unwrap(); assert_eq!(s.keymaps.names(), vec![DEFAULT_PRESET, "Mine", "Mine 2"]); s.execute("shortcuts.preset", json!({"op": "rename", "name": "Mine 2", "newName": "Other"})).unwrap(); assert!(s.execute("shortcuts.preset", json!({"op": "delete", "name": DEFAULT_PRESET})).is_err()); let path = d.join("mine.json"); s.execute("shortcuts.export", json!({"preset": "Mine", "path": path.to_string_lossy()})).unwrap(); s.execute("shortcuts.preset", json!({"op": "delete", "name": "Mine"})).unwrap(); s.execute("shortcuts.preset", json!({"op": "delete", "name": "Other"})).unwrap(); assert_eq!(s.keymaps.active, DEFAULT_PRESET); let r = s.execute("shortcuts.import", json!({"path": path.to_string_lossy()})).unwrap(); assert_eq!(r["preset"], json!("Mine")); assert_eq!(s.shortcuts().keys["edit.undo"], vec!["Cmd+Z".to_string(), "F2".into()]); s.execute("shortcuts.reset", json!({})).unwrap(); assert_eq!(s.shortcuts().keys["edit.undo"], vec!["Cmd+Z".to_string()]); let _ = std::fs::remove_dir_all(d); } #[test] fn frontend_commands_are_bindable_with_panel_scope() { let mut s = Session::default(); s.set_ui_commands(vec![ crate::shortcuts::UiCommand { id: "timeline.zoomIn".into(), label: "Zoom In Time".into(), shortcut: Some("=".into()) }, crate::shortcuts::UiCommand { id: "tool.hand".into(), label: "Hand Tool".into(), shortcut: Some("H".into()) }, ]); let b = s.shortcuts().find("timeline.zoomIn").unwrap().clone(); assert_eq!(b.scope, "Timeline"); assert_eq!(s.shortcuts().shortcut_of("tool.hand", &json!(null)), Some("H")); // A panel shortcut conflicts with an application shortcut on the same keys. let c = s.shortcuts().conflicts(&b, "H"); assert!(c.iter().any(|c| c.key == "tool.hand")); } // ---------------------------------------------------------------- auto-save / recovery #[test] fn atomic_write_survives_a_crash_mid_write() { let d = tmp("atomic"); let f = d.join("p.ecproj"); atomic_write(&f, b"version 1").unwrap(); // The writer dies halfway through the temporary file. let r = atomic_write_with(&f, b"version 2 is much longer", |tmp, data| { std::fs::write(tmp, &data[..5])?; Err(std::io::Error::other("power cut")) }); assert!(r.is_err()); assert_eq!(std::fs::read(&f).unwrap(), b"version 1"); assert!(!temp_path(&f).exists(), "the partial temp file is cleaned up"); // A temp file left by a hard kill doesn't count as an auto-save. std::fs::write(d.join("p auto-save 1.ecproj.tmp"), b"junk").unwrap(); assert!(autosave::existing(&d, "p").is_empty()); atomic_write(&f, b"version 3").unwrap(); assert_eq!(std::fs::read(&f).unwrap(), b"version 3"); let _ = std::fs::remove_dir_all(d); } #[test] fn autosave_rotates_through_max_versions() { let d = tmp("rotate"); let proj = d.join("Intro.ecproj"); let mut s = session_with_comp(); s.execute("file.saveAs", json!({"path": proj.to_string_lossy()})).unwrap(); s.execute("prefs.set", json!({"key": "autoSave.maxVersions", "value": 3})).unwrap(); let folder = d.join(autosave::AUTOSAVE_FOLDER); let mut written = vec![]; for i in 0..5 { s.execute("layer.newNull", json!({"name": format!("save{i}")})).unwrap(); written.push(s.autosave_now().unwrap()); } let names: Vec = written.iter().map(|p| PathBuf::from(p).file_name().unwrap().to_string_lossy().to_string()).collect(); assert_eq!( names, ["Intro auto-save 1.ecproj", "Intro auto-save 2.ecproj", "Intro auto-save 3.ecproj", "Intro auto-save 1.ecproj", "Intro auto-save 2.ecproj"] ); let have: Vec = autosave::existing(&folder, "Intro").iter().map(|e| e.0).collect(); assert_eq!(have, [1, 2, 3]); let slot2 = std::fs::read_to_string(folder.join("Intro auto-save 2.ecproj")).unwrap(); assert!(slot2.contains("save4"), "slot 2 holds the newest save"); // The project file itself is untouched and still dirty. assert!(s.is_dirty()); assert!(!std::fs::read_to_string(&proj).unwrap().contains("save0")); // Lowering the maximum drops the extra slots. s.execute("prefs.set", json!({"key": "autoSave.maxVersions", "value": 2})).unwrap(); s.autosave_now().unwrap(); let have: Vec = autosave::existing(&folder, "Intro").iter().map(|e| e.0).collect(); assert_eq!(have, [1, 2]); // A fresh session continues after the newest file on disk. let next = autosave::next_slot(&autosave::existing(&folder, "Intro"), None, 2); let newest = autosave::existing(&folder, "Intro").into_iter().max_by_key(|e| e.2).unwrap().0; assert_eq!(next, newest % 2 + 1); let _ = std::fs::remove_dir_all(d); } #[test] fn autosave_tick_waits_for_the_interval_and_changes() { let d = tmp("tick"); let mut s = session_with_comp(); s.execute("file.saveAs", json!({"path": d.join("T.ecproj").to_string_lossy()})).unwrap(); s.execute( "prefs.set", json!({"values": {"autoSave.intervalMinutes": 1, "autoSave.location": "custom", "autoSave.folder": d.join("custom").to_string_lossy()}}), ) .unwrap(); assert!(s.autosave_tick(1000.0).is_none(), "the first tick starts the clock"); s.execute("layer.newNull", json!({})).unwrap(); assert!(s.autosave_tick(1030.0).is_none(), "interval not reached"); let p = s.autosave_tick(1061.0).unwrap().unwrap(); assert!(p.contains("custom") && p.ends_with("T auto-save 1.ecproj"), "{p}"); assert!(s.autosave_tick(1200.0).is_none(), "nothing changed since"); s.execute("prefs.set", json!({"key": "autoSave.enabled", "value": false})).unwrap(); s.execute("layer.newNull", json!({})).unwrap(); assert!(s.autosave_tick(5000.0).is_none(), "disabled"); let _ = std::fs::remove_dir_all(d); } #[test] fn crash_recovery_detects_an_unclean_exit() { let d = tmp("recovery"); let store: Arc = Arc::new(DirConfig::new(d.join("config"))); // First run: open a project, auto-save, then "crash" (no end_recovery). let mut s = session_with_comp(); s.config = Some(store.clone()); assert!(s.begin_recovery().is_none(), "first launch is clean"); let proj = d.join("Crash.ecproj"); s.execute("file.saveAs", json!({"path": proj.to_string_lossy()})).unwrap(); s.execute("layer.newNull", json!({"name": "unsaved work"})).unwrap(); let auto = s.autosave_now().unwrap(); drop(s); // Second run finds the sentinel and offers the auto-save. let mut s2 = Session { config: Some(store.clone()), ..Default::default() }; let r = s2.begin_recovery().expect("unclean exit detected"); assert_eq!(r.project.as_deref(), Some(proj.to_string_lossy().as_ref())); assert_eq!(r.autosave.as_deref(), Some(auto.as_str())); s2.execute("file.open", json!({"path": r.autosave.unwrap()})).unwrap(); assert!(s2.active_comp().unwrap().layers.iter().any(|l| l.name == "unsaved work")); // Clean exit: nothing to recover next time. s2.end_recovery(); let mut s3 = Session { config: Some(store.clone()), ..Default::default() }; assert!(s3.begin_recovery().is_none()); // The setting can turn the offer off. let mut s4 = Session { config: Some(store), ..Default::default() }; s4.prefs.startup.offer_crash_recovery = false; assert!(s4.begin_recovery().is_none()); let _ = std::fs::remove_dir_all(d); } #[test] fn open_recent_increment_and_revert() { let d = tmp("recent"); let a = d.join("A.ecproj"); let mut s = session_with_comp(); s.execute("file.saveAs", json!({"path": a.to_string_lossy()})).unwrap(); let r = s.execute("file.incrementAndSave", json!({})).unwrap(); assert!(r["path"].as_str().unwrap().ends_with("A 2.ecproj")); let r = s.execute("file.incrementAndSave", json!({})).unwrap(); assert!(r["path"].as_str().unwrap().ends_with("A 3.ecproj")); assert!(s.prefs.recent_projects[0].ends_with("A 3.ecproj")); assert!(s.prefs.recent_projects[2].ends_with("A.ecproj")); s.execute("layer.newNull", json!({})).unwrap(); s.execute("file.revert", json!({})).unwrap(); assert_eq!(s.active_comp().unwrap().layers.len(), 0); s.execute("file.openRecent", json!({"index": 2})).unwrap(); assert!(s.path.as_deref().unwrap().ends_with("A.ecproj")); assert!(s.prefs.recent_projects[0].ends_with("A.ecproj")); s.execute("file.clearRecent", json!({})).unwrap(); assert!(!s.is_enabled("file.openRecent")); assert_eq!(std::path::PathBuf::from(autosave::increment_path("/x/Intro 9.ecproj", |_| false)), std::path::PathBuf::from("/x/Intro 10.ecproj")); assert_eq!(std::path::PathBuf::from(autosave::increment_path("/x/v1.ecproj", |p| p.ends_with("v1 2.ecproj"))), std::path::PathBuf::from("/x/v1 3.ecproj")); let _ = std::fs::remove_dir_all(d); } #[test] fn memory_tick_never_waits_for_the_system() { let mut s = Session::default(); let t0 = std::time::Instant::now(); s.memory_tick(); s.memory_tick(); // Reading the system's memory starts PowerShell on Windows (about a second): the UI thread // only takes the last reading. assert!(t0.elapsed() < std::time::Duration::from_millis(200), "{:?}", t0.elapsed()); if crate::sysinfo::memory().is_some() { let mut got = None; for _ in 0..200 { got = s.memory_watch.latest(); if got.is_some() { break; } std::thread::sleep(std::time::Duration::from_millis(50)); } assert!(got.is_some_and(|m| m.total > 0), "the reading arrives in the background"); } } /// Settings ▸ Startup & Repair ▸ Window Graphics takes `auto` or `gl` (#243). #[test] fn window_graphics_is_validated() { let mut s = Session::default(); assert_eq!(s.prefs.startup.window_graphics, "auto"); s.execute("prefs.set", json!({"key": "startup.windowGraphics", "value": "gl"})).unwrap(); for bad in [json!("vulkan"), json!(true)] { assert!(s.execute("prefs.set", json!({"key": "startup.windowGraphics", "value": bad})).is_err()); } assert_eq!(s.prefs.startup.window_graphics, "gl"); assert_eq!(Prefs::from_json(r#"{"startup":{"windowGraphics":"dx9"}}"#).startup.window_graphics, "auto"); } #[test] fn interface_language_is_validated_persisted_and_backward_compatible() { let store = Arc::new(MemoryConfig::default()); let mut s = Session { config: Some(store.clone()), ..Default::default() }; assert_eq!(s.prefs.general.language, "system", "Match System by default (#229)"); s.execute("prefs.set", json!({"key": "general.language", "value": "en"})).unwrap(); s.execute("prefs.set", json!({"key": "general.language", "value": "ja"})).unwrap(); assert_eq!(s.execute("prefs.get", json!({"key": "general.language"})).unwrap(), json!("ja")); // pt-br is registered alongside en and ja, and round-trips through the file like any other. s.execute("prefs.set", json!({"key": "general.language", "value": "pt-br"})).unwrap(); assert_eq!(s.execute("prefs.get", json!({"key": "general.language"})).unwrap(), json!("pt-br")); s.execute("prefs.set", json!({"key": "general.language", "value": "ja"})).unwrap(); let saved = store.read(PREFS_FILE).unwrap(); for bad in [json!("xx"), json!(""), json!(17), json!(null)] { assert!(s.execute("prefs.set", json!({"key": "general.language", "value": bad})).is_err()); assert_eq!(s.prefs.general.language, "ja"); assert_eq!(store.read(PREFS_FILE).unwrap(), saved); } // zh-hans is registered alongside en and ja: it is accepted, persisted and read back. s.execute("prefs.set", json!({"key": "general.language", "value": "zh-hans"})).unwrap(); let mut zh = Session { config: Some(store.clone()), ..Default::default() }; zh.load_settings(); assert_eq!(zh.prefs.general.language, "zh-hans"); // zh-hant is the same: another language of the registered set, not an error. s.execute("prefs.set", json!({"key": "general.language", "value": "zh-hant"})).unwrap(); let mut zh_hant = Session { config: Some(store.clone()), ..Default::default() }; zh_hant.load_settings(); assert_eq!(zh_hant.prefs.general.language, "zh-hant"); s.execute("prefs.set", json!({"key": "general.language", "value": "uk"})).unwrap(); assert_eq!(s.execute("prefs.get", json!({"key": "general.language"})).unwrap(), json!("uk")); let mut uk = Session { config: Some(store.clone()), ..Default::default() }; uk.load_settings(); assert_eq!(uk.prefs.general.language, "uk"); assert_eq!(Prefs::from_json(r#"{"general":{"language":"uk"}}"#).general.language, "uk"); // es is registered the same way. s.execute("prefs.set", json!({"key": "general.language", "value": "es"})).unwrap(); let mut es = Session { config: Some(store.clone()), ..Default::default() }; es.load_settings(); assert_eq!(es.prefs.general.language, "es"); // Czech uses the same validation, persistence and JSON round-trip as every catalog. assert!(crate::prefs::LANGUAGES.contains(&("Čeština", "cs"))); s.execute("prefs.set", json!({"key": "general.language", "value": "cs"})).unwrap(); assert_eq!(s.execute("prefs.get", json!({"key": "general.language"})).unwrap(), json!("cs")); let mut cs = Session { config: Some(store.clone()), ..Default::default() }; cs.load_settings(); assert_eq!(cs.prefs.general.language, "cs"); assert_eq!(Prefs::from_json(r#"{"general":{"language":"cs"}}"#).general.language, "cs"); s.execute("prefs.set", json!({"key": "general.language", "value": "ja"})).unwrap(); let mut reloaded = Session { config: Some(store), ..Default::default() }; reloaded.load_settings(); assert_eq!(reloaded.prefs.general.language, "ja"); reloaded.execute("prefs.reset", json!({"page": "general"})).unwrap(); assert_eq!(reloaded.prefs.general.language, "system"); assert_eq!(Prefs::from_json(r#"{"general":{"undoLevels":17}}"#).general.language, "system"); assert_eq!(Prefs::from_json(r#"{"general":{"language":"unknown"}}"#).general.language, "system"); assert_eq!(Prefs::from_json(r#"{"general":{"language":"en"}}"#).general.language, "en", "a chosen language stays"); } #[test] fn unicode_label_colors_fall_back_without_panicking() { use effectcraft_color::Label; let mut s = Session::default(); let default = s.prefs.label_rgb(Label::Red); for color in ["#€abc", "#a€bc", "#abc€"] { s.execute("prefs.set", json!({"key": "labels.0.color", "value": color})).unwrap(); assert_eq!(s.prefs.label_rgb(Label::Red), default); } s.execute("prefs.set", json!({"key": "labels.0.color", "value": "#aBcD09"})).unwrap(); assert_eq!(s.prefs.label_rgb(Label::Red), [0xab, 0xcd, 9]); } /// `render.backend` says why it renders on the CPU (#262): the Software Only renderer, or the /// host's reason there is no GPU compositor. #[test] fn render_backend_says_why_it_renders_on_the_cpu() { let mut s = Session::default(); let why = |s: &mut Session| s.execute("render.backend", json!({})).unwrap()["why"].clone(); assert_eq!(why(&mut s), json!("no GPU compositor is attached")); s.accel_note = Some("headless: renders on the CPU unless started with --gpu".into()); assert_eq!(why(&mut s), json!("headless: renders on the CPU unless started with --gpu")); assert_eq!(s.execute("render.backend", json!({"backend": "cpu"})).unwrap()["why"], json!("the project's renderer is Mercury Software Only")); } /// Settings ▸ GPU Information names what the GPU compositor runs on, or why it is off, and the /// adapter the window got, so a card the compositor couldn't use doesn't look undetected (#596). #[test] fn gpu_summary_names_the_adapter_or_says_why_there_is_none() { use crate::sysinfo::gpu_summary; use effectcraft_render::{Accelerator, FxStep, Renderer}; struct Named; impl Accelerator for Named { fn name(&self) -> String { "NVIDIA GeForce RTX 4070 (Vulkan)".into() } fn comp_frame(&self, _: &Renderer, _: crate::project::ItemId, _: effectcraft_time::Tick) -> Option { None } fn supports_effect(&self, _: &str) -> bool { false } fn effects(&self, _: &[FxStep], _: &effectcraft_effects::Buf, _: Option) -> Option { None } } let mut s = Session::default(); let off = gpu_summary(&s); assert!(off.contains("Compositing: CPU"), "{off}"); assert!(off.contains("GPU compositing is off: no reason was recorded"), "{off}"); assert!(off.contains("Window graphics: not known yet"), "{off}"); s.accel_note = Some("GPU compositor unavailable: no compute shaders".into()); s.window_adapter = Some("NVIDIA GeForce RTX 4070 (Dx12, DiscreteGpu)".into()); let declined = gpu_summary(&s); assert!(declined.contains("GPU compositing is off: GPU compositor unavailable: no compute shaders"), "{declined}"); assert!(declined.contains("Window graphics: NVIDIA GeForce RTX 4070 (Dx12, DiscreteGpu)"), "the card the window got is listed: {declined}"); s.accel = Some(std::sync::Arc::new(Named)); let on = gpu_summary(&s); assert!(on.starts_with("Compositing: GPU, NVIDIA GeForce RTX 4070 (Vulkan)."), "{on}"); assert!(!on.contains("compositing is off"), "{on}"); }