215 lines
11 KiB
TypeScript
215 lines
11 KiB
TypeScript
import { describe, expect, test } from "bun:test";
|
||
|
||
import { PREVIS_INTERACTIVE_SELECTOR, blocksPrevisShortcut, isPrevisInteractiveNode, releasePrevisFocusAfterPointer, resolvePrevisShortcut, type PrevisKeyEvent } from "../src/lib/canvas/previs/previs-shortcuts";
|
||
|
||
const press = (overrides: Partial<PrevisKeyEvent> & { key: string }): PrevisKeyEvent => ({ ...overrides });
|
||
|
||
describe("变换模式快捷键", () => {
|
||
test("W/E/R 映射到平移/旋转/缩放", () => {
|
||
expect(resolvePrevisShortcut(press({ key: "w" }))).toEqual({ kind: "transform-mode", mode: "translate" });
|
||
expect(resolvePrevisShortcut(press({ key: "e" }))).toEqual({ kind: "transform-mode", mode: "rotate" });
|
||
expect(resolvePrevisShortcut(press({ key: "r" }))).toEqual({ kind: "transform-mode", mode: "scale" });
|
||
});
|
||
|
||
test("大写与 Shift 组合仍然生效", () => {
|
||
expect(resolvePrevisShortcut(press({ key: "W" }))).toEqual({ kind: "transform-mode", mode: "translate" });
|
||
expect(resolvePrevisShortcut(press({ key: "E", shiftKey: true }))).toEqual({ kind: "transform-mode", mode: "rotate" });
|
||
});
|
||
|
||
test("带 Ctrl/Cmd 时不再当作变换模式", () => {
|
||
// Ctrl+R 是刷新,绝不能被解析成 scale。
|
||
expect(resolvePrevisShortcut(press({ key: "r", ctrlKey: true }))).toBeNull();
|
||
expect(resolvePrevisShortcut(press({ key: "w", metaKey: true }))).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe("对象操作快捷键", () => {
|
||
test("Delete 与 Backspace 都删除选中对象", () => {
|
||
expect(resolvePrevisShortcut(press({ key: "Delete" }))).toEqual({ kind: "delete-selected" });
|
||
expect(resolvePrevisShortcut(press({ key: "Backspace" }))).toEqual({ kind: "delete-selected" });
|
||
});
|
||
|
||
test("H 切换显隐、Escape 取消选择", () => {
|
||
expect(resolvePrevisShortcut(press({ key: "h" }))).toEqual({ kind: "toggle-visibility" });
|
||
expect(resolvePrevisShortcut(press({ key: "Escape" }))).toEqual({ kind: "deselect" });
|
||
});
|
||
|
||
test("没有执行路径的键不解析:F 聚焦、F2 重命名、Ctrl+D 复制", () => {
|
||
expect(resolvePrevisShortcut(press({ key: "f" }))).toBeNull();
|
||
expect(resolvePrevisShortcut(press({ key: "F2" }))).toBeNull();
|
||
expect(resolvePrevisShortcut(press({ key: "d", ctrlKey: true }))).toBeNull();
|
||
expect(resolvePrevisShortcut(press({ key: "d", metaKey: true }))).toBeNull();
|
||
});
|
||
|
||
test("空格切换播放", () => {
|
||
expect(resolvePrevisShortcut(press({ key: " " }))).toEqual({ kind: "toggle-play" });
|
||
expect(resolvePrevisShortcut(press({ key: "Spacebar" }))).toEqual({ kind: "toggle-play" });
|
||
});
|
||
});
|
||
|
||
describe("撤销/重做", () => {
|
||
test("Ctrl/Cmd+Z 撤销", () => {
|
||
expect(resolvePrevisShortcut(press({ key: "z", ctrlKey: true }))).toEqual({ kind: "undo" });
|
||
expect(resolvePrevisShortcut(press({ key: "z", metaKey: true }))).toEqual({ kind: "undo" });
|
||
});
|
||
|
||
test("Ctrl/Cmd+Shift+Z 与 Ctrl/Cmd+Y 都是重做", () => {
|
||
expect(resolvePrevisShortcut(press({ key: "z", ctrlKey: true, shiftKey: true }))).toEqual({ kind: "redo" });
|
||
expect(resolvePrevisShortcut(press({ key: "Z", metaKey: true, shiftKey: true }))).toEqual({ kind: "redo" });
|
||
expect(resolvePrevisShortcut(press({ key: "y", ctrlKey: true }))).toEqual({ kind: "redo" });
|
||
});
|
||
|
||
test("裸 Z/Y 不触发撤销重做", () => {
|
||
for (const key of ["z", "y"]) expect(resolvePrevisShortcut(press({ key }))).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe("修饰键与未知键边界", () => {
|
||
test("Alt 组合一律不解析,留给系统", () => {
|
||
expect(resolvePrevisShortcut(press({ key: "w", altKey: true }))).toBeNull();
|
||
expect(resolvePrevisShortcut(press({ key: "z", ctrlKey: true, altKey: true }))).toBeNull();
|
||
});
|
||
|
||
test("未映射的键返回 null", () => {
|
||
for (const key of ["q", "1", "ArrowUp", "Tab", "", "F5"]) {
|
||
expect(resolvePrevisShortcut(press({ key }))).toBeNull();
|
||
}
|
||
});
|
||
});
|
||
|
||
describe("交互控件语境不劫持按键", () => {
|
||
test("isInteractiveTarget 为真时所有快捷键都不解析", () => {
|
||
for (const key of ["w", "Delete", "Backspace", "h", "Escape", " "]) {
|
||
expect(resolvePrevisShortcut(press({ key, isInteractiveTarget: true }))).toBeNull();
|
||
}
|
||
expect(resolvePrevisShortcut(press({ key: "z", ctrlKey: true, isInteractiveTarget: true }))).toBeNull();
|
||
});
|
||
|
||
test("文本输入控件判为交互语境", () => {
|
||
expect(isPrevisInteractiveNode({ tagName: "INPUT" })).toBe(true);
|
||
expect(isPrevisInteractiveNode({ tagName: "textarea" })).toBe(true);
|
||
expect(isPrevisInteractiveNode({ tagName: "SELECT" })).toBe(true);
|
||
expect(isPrevisInteractiveNode({ isContentEditable: true })).toBe(true);
|
||
});
|
||
|
||
test("BUTTON 与 A 判为交互语境:Space/Delete 属于控件自己", () => {
|
||
expect(isPrevisInteractiveNode({ tagName: "BUTTON" })).toBe(true);
|
||
expect(isPrevisInteractiveNode({ tagName: "button" })).toBe(true);
|
||
expect(isPrevisInteractiveNode({ tagName: "A" })).toBe(true);
|
||
expect(isPrevisInteractiveNode({ tagName: "OPTION" })).toBe(true);
|
||
expect(isPrevisInteractiveNode({ tagName: "LABEL" })).toBe(true);
|
||
expect(isPrevisInteractiveNode({ tagName: "SUMMARY" })).toBe(true);
|
||
});
|
||
|
||
test("常见 role 型控件判为交互语境", () => {
|
||
const roles = ["textbox", "combobox", "spinbutton", "searchbox", "button", "switch", "tab", "menuitem", "menuitemcheckbox", "menuitemradio", "option", "slider", "checkbox", "radio", "link", "listbox", "menu", "radiogroup", "treeitem"];
|
||
for (const role of roles) {
|
||
expect(isPrevisInteractiveNode({ tagName: "DIV", getAttribute: (name: string) => (name === "role" ? role : null) })).toBe(true);
|
||
}
|
||
});
|
||
|
||
test("contenteditable 属性形式也判为交互语境", () => {
|
||
for (const value of ["", "true"]) {
|
||
expect(isPrevisInteractiveNode({ tagName: "DIV", getAttribute: (name: string) => (name === "contenteditable" ? value : null) })).toBe(true);
|
||
}
|
||
expect(isPrevisInteractiveNode({ tagName: "DIV", getAttribute: (name: string) => (name === "contenteditable" ? "false" : null) })).toBe(false);
|
||
});
|
||
|
||
test("普通元素、未知 role 与非法目标不判为交互语境", () => {
|
||
expect(isPrevisInteractiveNode({ tagName: "DIV", getAttribute: () => null })).toBe(false);
|
||
expect(isPrevisInteractiveNode({ tagName: "SPAN" })).toBe(false);
|
||
expect(isPrevisInteractiveNode({ tagName: "DIV", getAttribute: () => "presentation" })).toBe(false);
|
||
expect(isPrevisInteractiveNode(null)).toBe(false);
|
||
expect(isPrevisInteractiveNode(undefined)).toBe(false);
|
||
expect(isPrevisInteractiveNode("input")).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe("blocksPrevisShortcut:交互祖先同样阻止全局快捷键", () => {
|
||
test("有 closest 时用选择器向上找,命中祖先即阻止", () => {
|
||
// 按钮里的 <svg>/<span>:event.target 是子元素,只判自身会漏掉整个控件。
|
||
const queried: string[] = [];
|
||
const nestedChild = {
|
||
tagName: "svg",
|
||
closest: (selector: string) => {
|
||
queried.push(selector);
|
||
return { tagName: "BUTTON" };
|
||
},
|
||
};
|
||
expect(blocksPrevisShortcut(nestedChild)).toBe(true);
|
||
expect(queried).toEqual([PREVIS_INTERACTIVE_SELECTOR]);
|
||
});
|
||
|
||
test("closest 没命中交互祖先时放行", () => {
|
||
const plainChild = { tagName: "SPAN", closest: () => null };
|
||
expect(blocksPrevisShortcut(plainChild)).toBe(false);
|
||
});
|
||
|
||
test("选择器覆盖标签、role 与 contenteditable 三类", () => {
|
||
for (const fragment of ["button", "a", "input", "textarea", "select", "option", "label", "summary"]) {
|
||
expect(PREVIS_INTERACTIVE_SELECTOR.split(",")).toContain(fragment);
|
||
}
|
||
for (const role of ["button", "switch", "tab", "menuitem", "option", "slider", "checkbox", "radio"]) {
|
||
expect(PREVIS_INTERACTIVE_SELECTOR).toContain(`[role="${role}"]`);
|
||
}
|
||
expect(PREVIS_INTERACTIVE_SELECTOR).toContain('[contenteditable="true"]');
|
||
});
|
||
|
||
test("没有 closest 的目标退化为单节点判定", () => {
|
||
expect(blocksPrevisShortcut({ tagName: "BUTTON" })).toBe(true);
|
||
expect(blocksPrevisShortcut({ tagName: "DIV", getAttribute: () => null })).toBe(false);
|
||
expect(blocksPrevisShortcut(null)).toBe(false);
|
||
expect(blocksPrevisShortcut("button")).toBe(false);
|
||
});
|
||
|
||
test("普通 DIV 容器仍允许全局快捷键:视口空白处按 W/Delete 必须生效", () => {
|
||
const viewportBackdrop = { tagName: "DIV", closest: () => null };
|
||
expect(blocksPrevisShortcut(viewportBackdrop)).toBe(false);
|
||
expect(resolvePrevisShortcut(press({ key: "w", isInteractiveTarget: blocksPrevisShortcut(viewportBackdrop) }))).toEqual({ kind: "transform-mode", mode: "translate" });
|
||
expect(resolvePrevisShortcut(press({ key: "Delete", isInteractiveTarget: blocksPrevisShortcut(viewportBackdrop) }))).toEqual({ kind: "delete-selected" });
|
||
});
|
||
});
|
||
|
||
describe("releasePrevisFocusAfterPointer:点完工具按钮后快捷键必须立刻可用", () => {
|
||
const fakeButton = () => {
|
||
let blurred = 0;
|
||
return {
|
||
blur: () => {
|
||
blurred += 1;
|
||
},
|
||
blurCount: () => blurred,
|
||
};
|
||
};
|
||
|
||
test("鼠标激活(detail > 0)释放焦点", () => {
|
||
const target = fakeButton();
|
||
releasePrevisFocusAfterPointer({ detail: 1, currentTarget: target });
|
||
expect(target.blurCount()).toBe(1);
|
||
});
|
||
|
||
test("键盘激活(detail === 0)保留焦点,Tab 序列不断", () => {
|
||
const target = fakeButton();
|
||
releasePrevisFocusAfterPointer({ detail: 0, currentTarget: target });
|
||
expect(target.blurCount()).toBe(0);
|
||
});
|
||
|
||
test("双击等多次点击仍然释放焦点", () => {
|
||
const target = fakeButton();
|
||
releasePrevisFocusAfterPointer({ detail: 2, currentTarget: target });
|
||
expect(target.blurCount()).toBe(1);
|
||
});
|
||
|
||
test("目标没有 blur 方法时不抛异常", () => {
|
||
expect(() => releasePrevisFocusAfterPointer({ detail: 1, currentTarget: {} })).not.toThrow();
|
||
});
|
||
|
||
test("释放焦点后守卫不再拦截:这正是修复的行为", () => {
|
||
// 焦点在按钮上 -> 拦截;释放到视口容器 -> 放行。
|
||
const button = { tagName: "BUTTON", closest: () => ({ tagName: "BUTTON" }) };
|
||
expect(resolvePrevisShortcut(press({ key: "Delete", isInteractiveTarget: blocksPrevisShortcut(button) }))).toBeNull();
|
||
|
||
const bodyAfterBlur = { tagName: "BODY", closest: () => null };
|
||
expect(resolvePrevisShortcut(press({ key: "Delete", isInteractiveTarget: blocksPrevisShortcut(bodyAfterBlur) }))).toEqual({ kind: "delete-selected" });
|
||
});
|
||
});
|