Files
photocraft/crates/engine/tests/shortcuts_headless.rs
T
Tanish Manku 8376363d8c Shortcuts without the UI: shell commands and tools in the engine, reserved keys, set files (#5801)
* Shortcuts without the UI: shell commands and tools in the engine, reserved keys, set files

A retro against "everything is drivable headless; the UI is a layer" found the shortcut
work leaning on the UI. Fixed:

- The shell's command table (UI_COMMANDS: View › Zoom In, the panels…) moves into the
  engine as data (shell_commands::SHELL_COMMANDS; menus::UI_COMMANDS is now an alias), with
  the tools (shell_commands::TOOLS: id, Photoshop name, default key; a test keeps it equal
  to the toolbar). edit.keyboardShortcuts binds, lists, imports, exports and checks them for
  conflicts with no UI: a headless .kys import takes tool keys, and an export is the whole set.
  Tools may share a key (Shift cycling); a command given a tool's key takes it from them.
- Reserved system keys are refused by the engine (the dialog uses the same check) and left
  out of imported sets.
- importKys / exportKys / summarize take {"path": …} to read or write files, like
  actions.save; the automation gate refuses those paths for MCP.

* Keyboard shortcuts: bindings commit only after their exports are written (a bad target changes nothing); docs: the command knows the tools

* Keyboard shortcuts: both export targets are resolved and checked before either file is written; a failed later write removes the earlier one

* docs: reserved keys per platform

* Keyboard shortcuts export: files are staged next to their targets and moved into place only when all are written; a failure leaves the user's files untouched

* Keyboard shortcut exports all or nothing: unique create_new staging and backups beside each target, restored on any failure; the same target twice refused; docs: Ctrl+Shift+Esc

* Shortcut exports: a failed backup move removes its placeholder; undo restores backups without deleting first and names any it couldn't; read-only targets refused up front
2026-10-15 15:26:09 -07:00

147 lines
8.1 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.
//! Edit › Keyboard Shortcuts without the UI: the shell's commands and the tools are bindable,
//! listed, imported and exported; tools share keys without conflicts; system keys are refused;
//! sets read and write files by path.
use photocraft_engine::Session;
use serde_json::{Value, json};
fn ks(s: &mut Session, p: Value) -> Value {
s.execute("edit.keyboardShortcuts", p).expect("edit.keyboardShortcuts")
}
fn listed<'a>(r: &'a Value, id: &str) -> &'a Value {
r["commands"].as_array().unwrap().iter().find(|c| c["id"] == id).unwrap_or_else(|| panic!("{id} listed"))
}
#[test]
fn shell_commands_and_tools_are_bindable_without_the_ui() {
let mut s = Session::new();
let r = ks(&mut s, json!({"list": true}));
assert_eq!(listed(&r, "view.zoomIn")["alternates"], json!(["Cmd++"]), "the shell's View › Zoom In");
let mv = listed(&r, "tools.select.move");
assert_eq!((mv["tool"].clone(), mv["shortcut"].clone(), mv["menu"].clone()), (json!(true), json!("V"), json!(["Tools"])));
assert!(
r["conflicts"].as_array().unwrap().iter().all(|c| c["commands"]
.as_array()
.unwrap()
.iter()
.any(|id| !id.as_str().unwrap().starts_with("tools.select."))),
"tools sharing a letter cycle, they don't conflict: {}",
r["conflicts"]
);
// No allowUnknown needed any more.
let r = ks(&mut s, json!({"set": {"tools.select.pencil": "F6", "view.zoomIn": "Cmd+Shift+Z"}}));
assert_eq!((r["overrides"]["tools.select.pencil"].clone(), r["overrides"]["view.zoomIn"].clone()), (json!("F6"), json!("Cmd+Shift+Z")));
}
#[test]
fn tools_may_share_a_key_but_a_command_takes_it() {
let mut s = Session::new();
// Pencil joins the V group: Move and Artboard keep V.
let r = ks(&mut s, json!({"set": {"tools.select.pencil": "V"}}));
assert!(r["overrides"].get("tools.select.move").is_none(), "{}", r["overrides"]);
// A command taking V removes it from every tool that held it.
let r = ks(&mut s, json!({"set": {"edit.copy": "V"}}));
for t in ["tools.select.move", "tools.select.artboard", "tools.select.pencil"] {
assert_eq!(r["overrides"][t], json!(""), "{t}: {}", r["overrides"]);
}
}
#[test]
fn system_keys_are_refused_and_dropped_from_imports() {
let mut s = Session::new();
let key = if cfg!(target_os = "macos") { "Cmd+Tab" } else { "Alt+F4" };
let before = s.prefs().clone();
assert!(s.execute("edit.keyboardShortcuts", json!({"set": {"edit.copy": key}})).is_err());
assert!(s.execute("edit.keyboardShortcuts", json!({"alternates": {"edit.copy": ["F9", key]}})).is_err());
assert_eq!(s.prefs(), &before);
let set = format!(
"<photoshop-keyboard-shortcuts><command name=\"Copy\"><shortcut>F9</shortcut><shortcut>{key}</shortcut></command><command name=\"Cut\"><shortcut>{key}</shortcut></command><tool name=\"Pencil Tool\">N</tool><tool name=\"Move Tool\">V</tool></photoshop-keyboard-shortcuts>"
);
let r = ks(&mut s, json!({"importKys": set}));
assert_eq!(r["overrides"]["edit.copy"], json!("F9"));
assert!(r["overrides"].get("edit.cut").is_none(), "the reserved key is left out: {}", r["overrides"]);
assert_eq!(r["import"]["reserved"].as_array().map(Vec::len), Some(1));
assert_eq!(r["overrides"]["tools.select.pencil"], json!("N"), "tool keys import headless");
assert!(r["overrides"].get("tools.select.move").is_none(), "Move's own V keeps its cycling");
}
#[test]
fn sets_read_and_write_files_and_export_everything() {
let dir = std::env::temp_dir().join(format!("photocraft-shortcuts-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let kys = dir.join("mine.kys");
let html = dir.join("mine.html");
let mut s = Session::new();
ks(&mut s, json!({"set": {"tools.select.pencil": "F6", "edit.copy": "F9"}, "saveSet": "Mine"}));
let r = ks(&mut s, json!({"exportKys": {"path": kys.to_str().unwrap()}, "summarize": {"path": html.to_str().unwrap(), "mac": false}}));
assert!(r.get("kys").is_none() && r["kysPath"].is_string() && r["htmlPath"].is_string(), "{r}");
let text = std::fs::read_to_string(&kys).unwrap();
assert!(text.contains("pc-id=\"tools.select.pencil\">F6</tool>") && text.contains("pc-id=\"view.zoomIn\""), "the whole set: {text}");
assert!(std::fs::read_to_string(&html).unwrap().contains("<h2>Tools</h2>"));
// Back to the defaults, then the file restores the set, tools included.
ks(&mut s, json!({"useSet": ""}));
let r = ks(&mut s, json!({"reset": true, "importKys": {"path": kys.to_str().unwrap()}}));
assert_eq!((r["overrides"]["tools.select.pencil"].clone(), r["overrides"]["edit.copy"].clone()), (json!("F6"), json!("F9")));
// Bad paths and shapes are errors, not panics.
for p in [
json!({"importKys": {"path": dir.join("missing.kys").to_str().unwrap()}}),
json!({"importKys": {"path": ""}}),
json!({"importKys": 5}),
json!({"exportKys": "yes"}),
json!({"exportKys": {"path": 3}}),
json!({"summarize": {"path": ""}}),
] {
assert!(s.execute("edit.keyboardShortcuts", p.clone()).is_err(), "{p}");
}
// A change paired with an export that fails (a folder that doesn't exist, a bad summarize
// value) changes nothing.
let before = ks(&mut s, json!({}))["overrides"].clone();
for p in [
json!({"set": {"edit.copy": "F10"}, "exportKys": {"path": dir.join("no").join("such").join("x.kys").to_str().unwrap()}}),
json!({"set": {"edit.copy": "F10"}, "summarize": 7}),
json!({"set": {"edit.copy": "F10"}, "saveSet": "Other", "summarize": {"path": dir.join("no").join("x.html").to_str().unwrap()}}),
// A good .kys path with a bad summarize: neither file is written.
json!({"set": {"edit.copy": "F10"}, "exportKys": {"path": dir.join("both.kys").to_str().unwrap()}, "summarize": {"path": dir.join("no").join("x.html").to_str().unwrap()}}),
json!({"exportKys": {"path": dir.join("both.kys").to_str().unwrap()}, "summarize": 7}),
json!({"exportKys": {"path": dir.join("both.kys").to_str().unwrap()}, "summarize": {"path": dir.to_str().unwrap()}}),
] {
assert!(s.execute("edit.keyboardShortcuts", p.clone()).is_err(), "{p}");
let r = ks(&mut s, json!({}));
assert_eq!(r["overrides"], before, "{p}");
assert!(r["sets"].get("Other").is_none(), "{p}: {r}");
assert!(!dir.join("both.kys").exists(), "{p}: no file written");
}
// A second file that passes the checks but can't be written (a NUL in its name): the user's
// existing first file is left exactly as it was, and no staged file stays behind.
let mine = dir.join("existing.kys");
std::fs::write(&mine, "the user's own file").unwrap();
let html = format!("{}/a\u{0}.html", dir.to_str().unwrap());
let p = json!({"exportKys": {"path": mine.to_str().unwrap()}, "summarize": {"path": html}});
assert!(s.execute("edit.keyboardShortcuts", p.clone()).is_err(), "{p}");
assert_eq!(std::fs::read_to_string(&mine).unwrap(), "the user's own file");
let left: Vec<_> =
std::fs::read_dir(&dir).unwrap().filter_map(|e| e.ok()).map(|e| e.file_name()).filter(|n| n.to_string_lossy().contains("photocraft-tmp")).collect();
assert!(left.is_empty(), "{left:?}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_read_only_target_is_refused_before_anything_is_written() {
let dir = std::env::temp_dir().join(format!("photocraft-readonly-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let ro = dir.join("locked.kys");
std::fs::write(&ro, "locked").unwrap();
let mut perms = std::fs::metadata(&ro).unwrap().permissions();
perms.set_readonly(true);
std::fs::set_permissions(&ro, perms.clone()).unwrap();
let mut s = Session::new();
let r = s.execute("edit.keyboardShortcuts", json!({"set": {"edit.copy": "F10"}, "exportKys": {"path": ro.to_str().unwrap()}}));
assert!(r.as_ref().is_err_and(|e| e.to_string().contains("read-only")), "{r:?}");
assert_eq!(std::fs::read_to_string(&ro).unwrap(), "locked");
#[allow(clippy::permissions_set_readonly_false)]
perms.set_readonly(false);
let _ = std::fs::set_permissions(&ro, perms);
let _ = std::fs::remove_dir_all(&dir);
}