//! Vim preferences and the engine toggle command (SPEC 6 item 29). mod common; use common::{TempVault, temp_config, temp_vault}; use notescraft_engine::{AppSettings, Error, Registry, Session, load_at, register_core, save_at}; use serde_json::{Value, json}; /// An isolated registry, read-only vault and application settings folder. fn fixture(name: &str) -> (Registry, Session, TempVault, TempVault) { let vault = temp_vault(name); let config = temp_config(name); let mut registry = Registry::new(); register_core(&mut registry).expect("register"); let mut session = Session::open_read_only(vault.path()).expect("open read-only"); session.use_settings_dir(config.path()); (registry, session, vault, config) } #[test] fn vim_defaults_off_in_new_and_older_documents() { for document in [json!({}), json!({ "editor": {} })] { let mut settings: AppSettings = serde_json::from_value(document).expect("missing keys use defaults"); assert_eq!(settings.validate(), Vec::::new()); let value = serde_json::to_value(settings).expect("serialize settings"); assert_eq!(value.pointer("/editor/vim"), Some(&json!(false))); } } #[test] fn vim_accepts_only_boolean_values() { for value in [json!("true"), json!(1), Value::Null, json!([]), json!({})] { assert!(serde_json::from_value::(json!({ "editor": { "vim": value } })).is_err(), "{value}"); } } #[test] fn vim_survives_toml_save_and_reload() { let config = temp_config("vim-round-trip"); let mut before: AppSettings = serde_json::from_value(json!({ "editor": { "vim": true } })).expect("boolean preference"); assert_eq!(before.validate(), Vec::::new()); save_at(config.path(), &before).expect("save"); let (after, notes) = load_at(config.path()); assert!(notes.is_empty(), "{notes:?}"); assert_eq!(after, before); let value = serde_json::to_value(after).expect("serialize settings"); assert_eq!(value.pointer("/editor/vim"), Some(&json!(true))); } #[test] fn invalid_editor_section_recovers_without_losing_other_settings() { let config = temp_config("vim-invalid-file"); std::fs::write(config.path().join("config.toml"), "schema_version = 1\n\n[editor]\nvim = \"true\"\n\n[session]\ntheme = \"latte\"\n") .expect("write malformed editor section"); let (settings, notes) = load_at(config.path()); let value = serde_json::to_value(&settings).expect("serialize settings"); assert_eq!(value.pointer("/editor/vim"), Some(&json!(false))); assert_eq!(settings.session.theme, "latte"); assert!(notes.iter().any(|note| note.starts_with("the `editor` section was replaced by its defaults")), "{notes:?}"); } #[test] fn settings_commands_set_validate_and_reset_vim() { let (registry, mut session, _vault, config) = fixture("vim-settings-commands"); registry.run("settings.set", &mut session, &json!({ "key": "editor.vim", "value": true })).expect("enable Vim"); let settings = registry.run("settings.get", &mut session, &Value::Null).expect("get settings"); assert_eq!(settings.pointer("/settings/editor/vim"), Some(&json!(true))); for (key, value, code) in [("editor.vim", json!("true"), "invalid_value"), ("editor.unknown", json!(true), "unknown_key")] { let result = registry.run("settings.set", &mut session, &json!({ "key": key, "value": value })); assert!(matches!(result, Err(Error::Command { ref source, .. }) if source.code == code), "{result:?}"); } let (loaded, _) = load_at(config.path()); assert_eq!(serde_json::to_value(loaded).expect("serialize settings").pointer("/editor/vim"), Some(&json!(true))); registry.run("settings.reset", &mut session, &json!({ "key": "editor.vim" })).expect("reset Vim"); let settings = registry.run("settings.get", &mut session, &Value::Null).expect("get settings"); assert_eq!(settings.pointer("/settings/editor/vim"), Some(&json!(false))); } #[test] fn toggle_vim_persists_each_direction_even_with_read_only_vault() { let (registry, mut session, vault, config) = fixture("vim-toggle"); assert!(registry.is_enabled("editor.toggle_vim", &session)); registry.run("settings.set", &mut session, &json!({ "key": "session.theme", "value": "latte" })).expect("preserve unrelated setting"); for enabled in [true, false] { let result = registry.run("editor.toggle_vim", &mut session, &Value::Null).expect("toggle Vim"); assert_eq!(result.get("vim"), Some(&json!(enabled))); let (settings, notes) = load_at(config.path()); assert!(notes.is_empty(), "{notes:?}"); assert_eq!(settings.session.theme, "latte"); assert_eq!(serde_json::to_value(settings).expect("serialize settings").pointer("/editor/vim"), Some(&json!(enabled))); } assert!(!vault.path().join("config.toml").exists()); } #[test] fn toggle_vim_is_listed_in_the_edit_menu() { let (registry, session, _vault, _config) = fixture("vim-toggle-menu"); let commands = registry.list(&session); let command = commands.iter().find(|command| command.id == "editor.toggle_vim").expect("Vim command metadata"); assert_eq!(command.label, "Vim mode"); assert_eq!(command.menu_path, vec!["Edit"]); } #[test] fn toggle_vim_refuses_to_overwrite_a_newer_schema() { let (registry, mut session, _vault, config) = fixture("vim-toggle-newer-schema"); let original = "schema_version = 2\n\n[editor]\nvim = false\n"; let path = config.path().join("config.toml"); std::fs::write(&path, original).expect("write newer schema"); let result = registry.run("editor.toggle_vim", &mut session, &Value::Null); assert!(matches!(result, Err(Error::Command { ref source, .. }) if source.code == "settings"), "{result:?}"); assert_eq!(std::fs::read_to_string(path).expect("read unchanged settings"), original); } /// The editor defaults the document holds, as JSON. fn editor_defaults() -> serde_json::Value { serde_json::json!({ "default_view": "visual", "font_size": 16, "readable_line_length": true, "line_numbers": false, "strict_line_breaks": false, }) } /// The defaults of the layout keys, with the switches the file may already have. fn editor_defaults_document() -> serde_json::Value { serde_json::json!({ "blocks": { "grip": true, "toolbar": true, "slash_preview": true }, "default_view": "visual", "font_size": 16, "indent_width": 4, "indent_with_tabs": false, "line_numbers": false, "previews": { "colors": true, "named_colors": false }, "readable_line_length": true, "strict_line_breaks": false, "vim": false, }) } /// The editor section as it sits in `config.toml`. fn saved_editor(config: &TempVault) -> Value { let (settings, notes) = load_at(config.path()); assert_eq!(notes, &[] as &[String], "{notes:?}"); serde_json::to_value(&settings).expect("serialize settings").pointer("/editor").cloned().unwrap_or_default() } /// Every layout key defaults the way the design says, and a `config.toml` /// written before they existed loads unchanged. #[test] fn editor_layout_defaults_and_older_documents() { let mut settings = AppSettings::default(); assert_eq!(settings.validate(), Vec::::new()); let editor = serde_json::to_value(&settings).expect("serialize settings").pointer("/editor").cloned().unwrap_or_default(); assert_eq!(editor, editor_defaults_document(), "the whole editor section is at its defaults"); let config = temp_config("editor-layout-old-file"); std::fs::write(config.path().join("config.toml"), b"schema_version = 1\n\n[editor]\nvim = false\n\n[editor.blocks]\ngrip = false\n") .expect("write the old file"); let (loaded, notes) = load_at(config.path()); assert_eq!(notes, &[] as &[String], "{notes:?}"); assert!(!loaded.editor.blocks.grip, "the keys the file has are kept"); let editor = serde_json::to_value(&loaded).expect("serialize settings").pointer("/editor").cloned().unwrap_or_default(); assert_eq!(editor.pointer("/default_view"), Some(&serde_json::json!("visual")), "{editor}"); assert_eq!(editor.pointer("/font_size"), Some(&serde_json::json!(16)), "{editor}"); assert_eq!(editor.pointer("/readable_line_length"), Some(&serde_json::json!(true)), "{editor}"); assert_eq!(editor.pointer("/line_numbers"), Some(&serde_json::json!(false)), "{editor}"); assert_eq!(editor.pointer("/strict_line_breaks"), Some(&serde_json::json!(false)), "{editor}"); } /// Every layout key round-trips through `settings.set`, comes back out of /// `config.toml`, and `settings.reset` puts it back. #[test] fn editor_layout_keys_round_trip_and_reset() { let (registry, mut session, _vault, config) = fixture("editor-layout-round-trip"); let set = |session: &mut Session, key: &str, value: Value| { let params = serde_json::json!({ "key": key, "value": value }); registry.run("settings.set", session, ¶ms) }; let changed = [ ("editor.default_view", serde_json::json!("source")), ("editor.font_size", serde_json::json!(22)), ("editor.readable_line_length", serde_json::json!(false)), ("editor.line_numbers", serde_json::json!(true)), ("editor.strict_line_breaks", serde_json::json!(true)), ]; for (key, value) in &changed { assert!(set(&mut session, key, value.clone()).is_ok(), "{key} must be settable"); } let got = registry.run("settings.get", &mut session, &Value::Null).expect("get settings"); let editor = got.pointer("/settings/editor").cloned().unwrap_or_default(); for (key, value) in &changed { let field = key.trim_start_matches("editor."); assert_eq!(editor.pointer(&format!("/{field}")), Some(value), "{key} in settings.get: {editor}"); assert_eq!(saved_editor(&config).pointer(&format!("/{field}")), Some(value), "{key} in config.toml"); } for (key, _) in &changed { let params = serde_json::json!({ "key": key }); assert!(registry.run("settings.reset", &mut session, ¶ms).is_ok(), "{key} must be resettable"); let field = key.trim_start_matches("editor."); let default = editor_defaults().pointer(&format!("/{field}")).cloned().unwrap_or_default(); assert_eq!(saved_editor(&config).pointer(&format!("/{field}")), Some(&default), "{key} back to its default"); } } /// A font size outside `10..=32` is clamped with a note, both from a file and /// through `settings.set` (the same convention as `git.auto_commit_minutes`). #[test] fn editor_font_size_is_clamped_with_a_note() { let config = temp_config("editor-font-clamp-file"); std::fs::write(config.path().join("config.toml"), "schema_version = 1\n\n[editor]\nfont_size = 3\n").expect("write a tiny font"); let (settings, notes) = load_at(config.path()); assert_eq!(settings.editor.font_size, 10, "3 is clamped to the smallest legible size"); let joined = notes.join("\n"); assert!(joined.contains("editor.font_size: 3 is outside 10..=32"), "{joined}"); let (registry, mut session, _vault, config) = fixture("editor-font-clamp-set"); let result = registry.run("settings.set", &mut session, &serde_json::json!({ "key": "editor.font_size", "value": 200 })).expect("the set succeeds, repaired"); assert!(result.to_string().contains("editor.font_size: 200"), "{result}"); assert_eq!(saved_editor(&config).pointer("/font_size"), Some(&serde_json::json!(32))); // A value that does not even fit a `u8` is refused, not clamped. let too_big = registry.run("settings.set", &mut session, &serde_json::json!({ "key": "editor.font_size", "value": 300 })); assert!(matches!(too_big, Err(Error::Command { ref source, .. }) if source.code == "invalid_value"), "{too_big:?}"); assert_eq!(saved_editor(&config).pointer("/font_size"), Some(&serde_json::json!(32)), "the clamped value survives the refusal"); } /// Wrong types, unknown keys and unknown view names are refused, and a refused /// set leaves `config.toml` byte-identical. #[test] fn editor_layout_refuses_bad_values_without_touching_the_file() { let (registry, mut session, _vault, config) = fixture("editor-layout-refusals"); let set = |session: &mut Session, key: &str, value: Value| { let params = serde_json::json!({ "key": key, "value": value }); registry.run("settings.set", session, ¶ms) }; assert!(set(&mut session, "editor.font_size", serde_json::json!(20)).is_ok(), "one good value first"); let before = std::fs::read(config.path().join("config.toml")).expect("read the config file"); for (key, value, code) in [ ("editor.default_view", serde_json::json!("rendering"), "invalid_value"), ("editor.default_view", serde_json::json!("Visual"), "invalid_value"), ("editor.default_view", serde_json::json!(1), "invalid_value"), ("editor.font_size", serde_json::json!("20"), "invalid_value"), ("editor.font_size", serde_json::json!(-1), "invalid_value"), ("editor.readable_line_length", serde_json::json!("yes"), "invalid_value"), ("editor.line_numbers", serde_json::json!(0), "invalid_value"), ("editor.strict_line_breaks", serde_json::json!(null), "invalid_value"), ("editor.unknown", serde_json::json!(true), "unknown_key"), ("editor.line_numbers.too", serde_json::json!(true), "unknown_key"), ] { let result = set(&mut session, key, value.clone()); assert!(matches!(result, Err(Error::Command { ref source, .. }) if source.code == code), "`{key}` = {value} must fail with {code}, got {result:?}"); } let after = std::fs::read(config.path().join("config.toml")).expect("read the config file again"); assert_eq!(before, after, "a refused value leaves config.toml byte-identical"); assert_eq!(saved_editor(&config).pointer("/font_size"), Some(&serde_json::json!(20)), "the last good value survives"); }