Files
Genarrative/src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx
T
kdletters 9b49085869 修复画布生成器偶发无法打开
同步生成器 active/inactive ref 与 state

激活已有生成器时清理选参考图和菜单临时状态

窗口失焦时释放 Shift 与空格按住态

补充生成器状态与快捷键回归测试

记录图片画布生成器排障经验
2026-07-07 16:22:09 +08:00

765 lines
26 KiB
TypeScript

/* @vitest-environment jsdom */
import { act, fireEvent, render, screen } from '@testing-library/react';
import { useRef, useState } from 'react';
import { describe, expect, it, vi } from 'vitest';
import type {
CanvasGenerationDialogState,
CanvasTool,
GenerateDialogState,
QuickEditPanelState,
} from './ImageCanvasEditorTypes';
import { useImageCanvasKeyboardShortcuts } from './useImageCanvasKeyboardShortcuts';
function createPlaceholderDialog(
mode: GenerateDialogState['mode'] = 'generate',
): GenerateDialogState {
return {
id: 'dialog-1',
mode,
prompt: '生成一张图片',
status: 'idle',
composerOpen: true,
placeholder: {
x: 10,
y: 20,
width: 320,
height: 240,
originalWidth: 320,
originalHeight: 240,
},
};
}
function applyGenerateDialogUpdater(
updater:
| GenerateDialogState
| null
| ((dialog: GenerateDialogState | null) => GenerateDialogState | null),
currentDialog: GenerateDialogState | null,
) {
return typeof updater === 'function' ? updater(currentDialog) : updater;
}
function applyQuickEditPanelUpdater(
updater:
| QuickEditPanelState
| null
| ((panel: QuickEditPanelState | null) => QuickEditPanelState | null),
currentPanel: QuickEditPanelState | null,
) {
return typeof updater === 'function' ? updater(currentPanel) : updater;
}
function KeyboardShortcutsHarness({
selectedLayerId = null,
selectedLayerIds = selectedLayerId ? [selectedLayerId] : [],
initialGenerateDialog = null,
initialQuickEditPanel = null,
initialTool = 'select',
undoCanvasChange = vi.fn(),
redoCanvasChange = vi.fn(),
deleteLayerById = vi.fn(),
deleteSelectedCanvasObjects = vi.fn(),
selectAllCanvasObjects = vi.fn(),
clearCanvasSelection = vi.fn(),
copySelectedCanvasLayers = vi.fn(),
pasteCanvasClipboard = vi.fn(),
duplicateSelectedCanvasLayers = vi.fn(),
moveSelectedCanvasLayers = vi.fn(),
nudgeSelectedCanvasObjects = vi.fn(),
requestRemoveCanvasGenerationDialog = vi.fn(),
switchTool,
updateScaleFromCenter = vi.fn(),
zoomViewportBy = vi.fn(),
fitLayers = vi.fn(),
toggleSidebarPanel = vi.fn(),
toggleMinimap = vi.fn(),
startProjectRename = vi.fn(),
exportCanvasAssets = vi.fn(),
closeEditorChromePanels = vi.fn(),
}: {
selectedLayerId?: string | null;
selectedLayerIds?: string[];
initialGenerateDialog?: GenerateDialogState | null;
initialQuickEditPanel?: QuickEditPanelState | null;
initialTool?: CanvasTool;
undoCanvasChange?: () => void;
redoCanvasChange?: () => void;
deleteLayerById?: (layerId: string | null) => void;
deleteSelectedCanvasObjects?: () => void;
selectAllCanvasObjects?: () => void;
clearCanvasSelection?: () => void;
copySelectedCanvasLayers?: (options?: { cut?: boolean }) => void;
pasteCanvasClipboard?: () => boolean;
duplicateSelectedCanvasLayers?: () => void;
moveSelectedCanvasLayers?: (mode: 'up' | 'down' | 'top' | 'bottom') => void;
nudgeSelectedCanvasObjects?: (delta: { x: number; y: number }) => void;
requestRemoveCanvasGenerationDialog?: (
dialog: CanvasGenerationDialogState,
) => void;
switchTool?: (tool: CanvasTool) => void;
updateScaleFromCenter?: (nextScale: number) => void;
zoomViewportBy?: (scaleFactor: number) => void;
fitLayers?: () => void;
toggleSidebarPanel?: (panel: 'assets' | 'layers') => void;
toggleMinimap?: () => void;
startProjectRename?: () => void;
exportCanvasAssets?: () => void;
closeEditorChromePanels?: () => void;
}) {
const [activeTool, setActiveTool] = useState<CanvasTool>(initialTool);
const [generateDialog, setGenerateDialogState] =
useState<GenerateDialogState | null>(initialGenerateDialog);
const [quickEditPanel, setQuickEditPanelState] =
useState<QuickEditPanelState | null>(initialQuickEditPanel);
const [imageContextMenuOpen, setImageContextMenuOpen] = useState(true);
const [contextMenuOpen, setContextMenuOpen] = useState(true);
const [isSpecMenuOpen, setIsSpecMenuOpen] = useState(true);
const [, setIsGenerationReferenceMenuOpen] = useState(true);
const [isPublicationReferenceMenuOpen, setIsPublicationReferenceMenuOpen] =
useState(true);
const [, setIsPickingGenerationReferenceFromCanvas] = useState(true);
const [, setIsPickingQuickEditReferenceFromCanvas] = useState(true);
const [
isPickingPublicationReferenceFromCanvas,
setIsPickingPublicationReferenceFromCanvas,
] = useState(true);
const [isCharacterSpecMenuOpen, setIsCharacterSpecMenuOpen] = useState(true);
const [isCharacterReferenceMenuOpen, setIsCharacterReferenceMenuOpen] =
useState(true);
const [isPickingCharacterSpecFromCanvas, setIsPickingCharacterSpecFromCanvas] =
useState(true);
const [
isPickingCharacterReferenceFromCanvas,
setIsPickingCharacterReferenceFromCanvas,
] = useState(true);
const [isIconSpecMenuOpen, setIsIconSpecMenuOpen] = useState(true);
const [isPickingIconSpecFromCanvas, setIsPickingIconSpecFromCanvas] =
useState(true);
const [, setIsUiDesignSpecMenuOpen] = useState(true);
const [, setIsPickingUiDesignSpecFromCanvas] =
useState(true);
const [isSpacePanning, setIsSpacePanning] = useState(false);
const [shiftPressed, setShiftPressed] = useState(false);
const generateDialogRef = useRef<GenerateDialogState | null>(generateDialog);
const selectedLayerIdRef = useRef<string | null>(selectedLayerId);
const selectedLayerIdsRef = useRef<string[]>(selectedLayerIds);
generateDialogRef.current = generateDialog;
selectedLayerIdRef.current = selectedLayerId;
selectedLayerIdsRef.current = selectedLayerIds;
useImageCanvasKeyboardShortcuts({
generateDialogRef,
selectedLayerIdRef,
selectedLayerIdsRef,
redoCanvasChange,
undoCanvasChange,
deleteLayerById,
deleteSelectedCanvasObjects,
selectAllCanvasObjects,
clearCanvasSelection,
copySelectedCanvasLayers,
pasteCanvasClipboard,
duplicateSelectedCanvasLayers,
moveSelectedCanvasLayers,
nudgeSelectedCanvasObjects,
requestRemoveCanvasGenerationDialog,
setActiveTool: (tool) => setActiveTool(tool),
switchTool: switchTool ?? ((tool) => setActiveTool(tool)),
updateScaleFromCenter,
zoomViewportBy,
fitLayers,
toggleSidebarPanel,
toggleMinimap,
startProjectRename,
exportCanvasAssets,
setGenerateDialog: (updater) =>
setGenerateDialogState((currentDialog) =>
applyGenerateDialogUpdater(updater, currentDialog),
),
setImageContextMenu: () => setImageContextMenuOpen(false),
setContextMenu: () => setContextMenuOpen(false),
setQuickEditPanel: (updater) =>
setQuickEditPanelState((currentPanel) =>
applyQuickEditPanelUpdater(updater, currentPanel),
),
closeEditorChromePanels,
setIsSpecMenuOpen,
setIsGenerationReferenceMenuOpen,
setIsPublicationReferenceMenuOpen,
setIsPickingGenerationReferenceFromCanvas,
setIsPickingQuickEditReferenceFromCanvas,
setIsPickingPublicationReferenceFromCanvas,
setIsCharacterSpecMenuOpen,
setIsCharacterReferenceMenuOpen,
setIsPickingCharacterSpecFromCanvas,
setIsPickingCharacterReferenceFromCanvas,
setIsIconSpecMenuOpen,
setIsPickingIconSpecFromCanvas,
setIsUiDesignSpecMenuOpen,
setIsPickingUiDesignSpecFromCanvas,
setIsSpacePanning,
setShiftPressed,
});
return (
<div>
<input aria-label="快捷键输入框" />
<span data-testid="active-tool">{activeTool}</span>
<span data-testid="generate-dialog">
{generateDialog
? `${generateDialog.mode}:${generateDialog.status}:${String(
generateDialog.composerOpen,
)}`
: 'none'}
</span>
<span data-testid="quick-edit">
{quickEditPanel ? quickEditPanel.status : 'none'}
</span>
<span data-testid="image-context">{String(imageContextMenuOpen)}</span>
<span data-testid="context-menu">{String(contextMenuOpen)}</span>
<span data-testid="spec-menu">{String(isSpecMenuOpen)}</span>
<span data-testid="publication-reference-menu">
{String(isPublicationReferenceMenuOpen)}
</span>
<span data-testid="publication-reference-picking">
{String(isPickingPublicationReferenceFromCanvas)}
</span>
<span data-testid="character-menu">
{String(isCharacterSpecMenuOpen)}
</span>
<span data-testid="character-reference-menu">
{String(isCharacterReferenceMenuOpen)}
</span>
<span data-testid="character-picking">
{String(isPickingCharacterSpecFromCanvas)}
</span>
<span data-testid="character-reference-picking">
{String(isPickingCharacterReferenceFromCanvas)}
</span>
<span data-testid="icon-menu">{String(isIconSpecMenuOpen)}</span>
<span data-testid="icon-picking">
{String(isPickingIconSpecFromCanvas)}
</span>
<span data-testid="space-panning">{String(isSpacePanning)}</span>
<span data-testid="shift-pressed">{String(shiftPressed)}</span>
</div>
);
}
describe('useImageCanvasKeyboardShortcuts', () => {
it('routes undo and redo shortcuts while ignoring editable inputs', () => {
const undoCanvasChange = vi.fn();
const redoCanvasChange = vi.fn();
render(
<KeyboardShortcutsHarness
undoCanvasChange={undoCanvasChange}
redoCanvasChange={redoCanvasChange}
/>,
);
act(() => {
fireEvent.keyDown(window, { key: 'z', code: 'KeyZ', ctrlKey: true });
});
expect(undoCanvasChange).toHaveBeenCalledTimes(1);
act(() => {
fireEvent.keyDown(window, {
key: 'Z',
code: 'KeyZ',
ctrlKey: true,
shiftKey: true,
});
});
expect(redoCanvasChange).toHaveBeenCalledTimes(1);
act(() => {
fireEvent.keyDown(screen.getByLabelText('快捷键输入框'), {
key: 'z',
code: 'KeyZ',
ctrlKey: true,
});
});
expect(undoCanvasChange).toHaveBeenCalledTimes(1);
act(() => {
fireEvent.keyDown(window, { key: 'z', code: 'KeyZ', metaKey: true });
});
expect(undoCanvasChange).toHaveBeenCalledTimes(1);
});
it('routes Windows edit shortcuts to selected layer commands and canvas paste', () => {
const selectAllCanvasObjects = vi.fn();
const copySelectedCanvasLayers = vi.fn();
const pasteCanvasClipboard = vi.fn(() => true);
const duplicateSelectedCanvasLayers = vi.fn();
const exportCanvasAssets = vi.fn();
render(
<KeyboardShortcutsHarness
selectAllCanvasObjects={selectAllCanvasObjects}
copySelectedCanvasLayers={copySelectedCanvasLayers}
pasteCanvasClipboard={pasteCanvasClipboard}
duplicateSelectedCanvasLayers={duplicateSelectedCanvasLayers}
exportCanvasAssets={exportCanvasAssets}
/>,
);
act(() => {
fireEvent.keyDown(window, { key: 'a', code: 'KeyA', ctrlKey: true });
fireEvent.keyDown(window, { key: 'c', code: 'KeyC', ctrlKey: true });
fireEvent.keyDown(window, { key: 'v', code: 'KeyV', ctrlKey: true });
fireEvent.keyDown(window, { key: 'x', code: 'KeyX', ctrlKey: true });
fireEvent.keyDown(window, { key: 'd', code: 'KeyD', ctrlKey: true });
fireEvent.keyDown(window, {
key: 'S',
code: 'KeyS',
ctrlKey: true,
shiftKey: true,
});
});
expect(selectAllCanvasObjects).toHaveBeenCalledTimes(1);
expect(copySelectedCanvasLayers).toHaveBeenNthCalledWith(1);
expect(copySelectedCanvasLayers).toHaveBeenNthCalledWith(2, { cut: true });
expect(pasteCanvasClipboard).toHaveBeenCalledTimes(1);
expect(duplicateSelectedCanvasLayers).toHaveBeenCalledTimes(1);
expect(exportCanvasAssets).toHaveBeenCalledTimes(1);
});
it('leaves system paste available when the canvas clipboard is empty', () => {
const pasteCanvasClipboard = vi.fn(() => false);
render(
<KeyboardShortcutsHarness pasteCanvasClipboard={pasteCanvasClipboard} />,
);
const pasteEvent = new KeyboardEvent('keydown', {
key: 'v',
code: 'KeyV',
ctrlKey: true,
bubbles: true,
cancelable: true,
});
window.dispatchEvent(pasteEvent);
expect(pasteCanvasClipboard).toHaveBeenCalledTimes(1);
expect(pasteEvent.defaultPrevented).toBe(false);
});
it('leaves native copy available when text is selected', () => {
const copySelectedCanvasLayers = vi.fn();
const removeAllRanges = vi.fn();
const selectedText = vi.fn(() => '清爽游戏按钮');
vi.spyOn(window, 'getSelection').mockReturnValue({
toString: selectedText,
removeAllRanges,
} as unknown as Selection);
render(
<KeyboardShortcutsHarness
copySelectedCanvasLayers={copySelectedCanvasLayers}
/>,
);
const copyEvent = new KeyboardEvent('keydown', {
key: 'c',
code: 'KeyC',
ctrlKey: true,
bubbles: true,
cancelable: true,
});
window.dispatchEvent(copyEvent);
expect(copySelectedCanvasLayers).not.toHaveBeenCalled();
expect(copyEvent.defaultPrevented).toBe(false);
});
it('routes Windows view and layer ordering shortcuts', () => {
const fitLayers = vi.fn();
const updateScaleFromCenter = vi.fn();
const zoomViewportBy = vi.fn();
const moveSelectedCanvasLayers = vi.fn();
render(
<KeyboardShortcutsHarness
fitLayers={fitLayers}
updateScaleFromCenter={updateScaleFromCenter}
zoomViewportBy={zoomViewportBy}
moveSelectedCanvasLayers={moveSelectedCanvasLayers}
/>,
);
act(() => {
fireEvent.keyDown(window, { key: '0', code: 'Digit0', ctrlKey: true });
fireEvent.keyDown(window, { key: '1', code: 'Digit1', ctrlKey: true });
fireEvent.keyDown(window, { key: '=', code: 'Equal', ctrlKey: true });
fireEvent.keyDown(window, { key: '-', code: 'Minus', ctrlKey: true });
fireEvent.keyDown(window, {
key: ']',
code: 'BracketRight',
ctrlKey: true,
});
fireEvent.keyDown(window, {
key: '}',
code: 'BracketRight',
ctrlKey: true,
shiftKey: true,
});
fireEvent.keyDown(window, {
key: '[',
code: 'BracketLeft',
ctrlKey: true,
});
fireEvent.keyDown(window, {
key: '{',
code: 'BracketLeft',
ctrlKey: true,
shiftKey: true,
});
});
expect(fitLayers).toHaveBeenCalledTimes(1);
expect(updateScaleFromCenter).toHaveBeenCalledWith(1);
expect(zoomViewportBy).toHaveBeenCalledWith(1.16);
expect(zoomViewportBy).toHaveBeenCalledWith(0.86);
expect(moveSelectedCanvasLayers).toHaveBeenNthCalledWith(1, 'up');
expect(moveSelectedCanvasLayers).toHaveBeenNthCalledWith(2, 'top');
expect(moveSelectedCanvasLayers).toHaveBeenNthCalledWith(3, 'down');
expect(moveSelectedCanvasLayers).toHaveBeenNthCalledWith(4, 'bottom');
});
it('routes tool, panel and project shortcuts', () => {
const switchTool = vi.fn();
const toggleSidebarPanel = vi.fn();
const toggleMinimap = vi.fn();
const startProjectRename = vi.fn();
render(
<KeyboardShortcutsHarness
switchTool={switchTool}
toggleSidebarPanel={toggleSidebarPanel}
toggleMinimap={toggleMinimap}
startProjectRename={startProjectRename}
/>,
);
act(() => {
fireEvent.keyDown(window, { key: 'v', code: 'KeyV' });
fireEvent.keyDown(window, { key: 'h', code: 'KeyH' });
fireEvent.keyDown(window, { key: 'u', code: 'KeyU' });
fireEvent.keyDown(window, { key: 'g', code: 'KeyG' });
fireEvent.keyDown(window, { key: 'V', code: 'KeyV', shiftKey: true });
fireEvent.keyDown(window, { key: 'm', code: 'KeyM' });
fireEvent.keyDown(window, { key: '1', code: 'Digit1', altKey: true });
fireEvent.keyDown(window, { key: '2', code: 'Digit2', altKey: true });
fireEvent.keyDown(window, { key: 'm', code: 'KeyM', altKey: true });
fireEvent.keyDown(window, { key: 'F2', code: 'F2' });
});
expect(switchTool).toHaveBeenNthCalledWith(1, 'select');
expect(switchTool).toHaveBeenNthCalledWith(2, 'hand');
expect(switchTool).toHaveBeenNthCalledWith(3, 'upload');
expect(switchTool).toHaveBeenNthCalledWith(4, 'generate');
expect(switchTool).toHaveBeenNthCalledWith(5, 'video');
expect(switchTool).toHaveBeenNthCalledWith(6, 'music');
expect(toggleSidebarPanel).toHaveBeenNthCalledWith(1, 'assets');
expect(toggleSidebarPanel).toHaveBeenNthCalledWith(2, 'layers');
expect(toggleMinimap).toHaveBeenCalledTimes(1);
expect(startProjectRename).toHaveBeenCalledTimes(1);
});
it('releases held modifier interaction state when the window loses focus', () => {
render(<KeyboardShortcutsHarness />);
act(() => {
fireEvent.keyDown(window, { key: 'Shift', code: 'ShiftLeft' });
fireEvent.keyDown(window, { key: ' ', code: 'Space' });
});
expect(screen.getByTestId('shift-pressed').textContent).toBe('true');
expect(screen.getByTestId('space-panning').textContent).toBe('true');
act(() => {
fireEvent.blur(window);
});
expect(screen.getByTestId('shift-pressed').textContent).toBe('false');
expect(screen.getByTestId('space-panning').textContent).toBe('false');
});
it('nudges selected canvas objects with arrow keys', () => {
const nudgeSelectedCanvasObjects = vi.fn();
render(
<KeyboardShortcutsHarness
nudgeSelectedCanvasObjects={nudgeSelectedCanvasObjects}
/>,
);
act(() => {
fireEvent.keyDown(window, { key: 'ArrowUp', code: 'ArrowUp' });
fireEvent.keyDown(window, {
key: 'ArrowRight',
code: 'ArrowRight',
shiftKey: true,
});
});
expect(nudgeSelectedCanvasObjects).toHaveBeenNthCalledWith(1, {
x: 0,
y: -1,
});
expect(nudgeSelectedCanvasObjects).toHaveBeenNthCalledWith(2, {
x: 10,
y: 0,
});
});
it('deletes the selected layer with Backspace outside editable inputs', () => {
const deleteLayerById = vi.fn();
render(
<KeyboardShortcutsHarness
selectedLayerId="layer-selected"
selectedLayerIds={[]}
deleteLayerById={deleteLayerById}
/>,
);
act(() => {
fireEvent.keyDown(window, { key: 'Backspace', code: 'Backspace' });
});
expect(deleteLayerById).toHaveBeenCalledWith('layer-selected');
});
it('deletes complete canvas selection before falling back to active layer', () => {
const deleteLayerById = vi.fn();
const deleteSelectedCanvasObjects = vi.fn();
render(
<KeyboardShortcutsHarness
selectedLayerId="layer-selected"
selectedLayerIds={['layer-selected', 'generation-dialog:dialog-1']}
deleteLayerById={deleteLayerById}
deleteSelectedCanvasObjects={deleteSelectedCanvasObjects}
/>,
);
act(() => {
fireEvent.keyDown(window, { key: 'Delete', code: 'Delete' });
});
expect(deleteSelectedCanvasObjects).toHaveBeenCalledTimes(1);
expect(deleteLayerById).not.toHaveBeenCalled();
});
it('requests deleting editable generation placeholders with Backspace', () => {
const requestRemoveCanvasGenerationDialog = vi.fn();
const dialog = createPlaceholderDialog('character');
render(
<KeyboardShortcutsHarness
initialTool="character"
initialGenerateDialog={dialog}
requestRemoveCanvasGenerationDialog={requestRemoveCanvasGenerationDialog}
/>,
);
act(() => {
fireEvent.keyDown(window, { key: 'Backspace', code: 'Backspace' });
});
expect(requestRemoveCanvasGenerationDialog).toHaveBeenCalledWith(dialog);
expect(screen.getByTestId('generate-dialog').textContent).toBe(
'character:idle:true',
);
expect(screen.getByTestId('active-tool').textContent).toBe('select');
expect(screen.getByTestId('character-menu').textContent).toBe('false');
expect(screen.getByTestId('character-picking').textContent).toBe('false');
expect(screen.getByTestId('icon-menu').textContent).toBe('false');
expect(screen.getByTestId('icon-picking').textContent).toBe('false');
});
it('requests deleting generating placeholders with Delete', () => {
const requestRemoveCanvasGenerationDialog = vi.fn();
const generatingDialog = createPlaceholderDialog('generate');
generatingDialog.status = 'generating';
render(
<KeyboardShortcutsHarness
initialTool="generate"
initialGenerateDialog={generatingDialog}
requestRemoveCanvasGenerationDialog={requestRemoveCanvasGenerationDialog}
/>,
);
act(() => {
fireEvent.keyDown(window, { key: 'Delete', code: 'Delete' });
});
expect(requestRemoveCanvasGenerationDialog).toHaveBeenCalledWith(
generatingDialog,
);
expect(screen.getByTestId('active-tool').textContent).toBe('select');
});
it('removes audio generation placeholders with Backspace', () => {
const requestRemoveCanvasGenerationDialog = vi.fn();
render(
<KeyboardShortcutsHarness
initialTool="music"
initialGenerateDialog={createPlaceholderDialog('audio-sound-effect')}
requestRemoveCanvasGenerationDialog={requestRemoveCanvasGenerationDialog}
/>,
);
act(() => {
fireEvent.keyDown(window, { key: 'Backspace', code: 'Backspace' });
});
expect(requestRemoveCanvasGenerationDialog).toHaveBeenCalledWith(
expect.objectContaining({ id: 'dialog-1' }),
);
expect(screen.getByTestId('active-tool').textContent).toBe('select');
});
it('removes publication generation placeholders with Backspace', () => {
const requestRemoveCanvasGenerationDialog = vi.fn();
const dialog = createPlaceholderDialog('publication');
render(
<KeyboardShortcutsHarness
initialTool="publication"
initialGenerateDialog={dialog}
requestRemoveCanvasGenerationDialog={requestRemoveCanvasGenerationDialog}
/>,
);
act(() => {
fireEvent.keyDown(window, { key: 'Backspace', code: 'Backspace' });
});
expect(requestRemoveCanvasGenerationDialog).toHaveBeenCalledWith(dialog);
expect(screen.getByTestId('active-tool').textContent).toBe('select');
expect(screen.getByTestId('publication-reference-menu').textContent).toBe(
'false',
);
expect(
screen.getByTestId('publication-reference-picking').textContent,
).toBe('false');
});
it('closes transient editor panels with Escape and collapses idle composers', () => {
const closeEditorChromePanels = vi.fn();
render(
<KeyboardShortcutsHarness
closeEditorChromePanels={closeEditorChromePanels}
initialGenerateDialog={createPlaceholderDialog('generate')}
initialQuickEditPanel={{
sourceLayerId: 'layer-source',
prompt: '局部修改',
size: '1024x1024',
model: 'mock',
status: 'idle',
}}
/>,
);
act(() => {
fireEvent.keyDown(window, { key: 'Escape', code: 'Escape' });
});
expect(closeEditorChromePanels).toHaveBeenCalledTimes(1);
expect(screen.getByTestId('generate-dialog').textContent).toBe(
'generate:idle:false',
);
expect(screen.getByTestId('quick-edit').textContent).toBe('none');
expect(screen.getByTestId('image-context').textContent).toBe('false');
expect(screen.getByTestId('context-menu').textContent).toBe('false');
expect(screen.getByTestId('spec-menu').textContent).toBe('false');
expect(screen.getByTestId('publication-reference-menu').textContent).toBe(
'false',
);
expect(
screen.getByTestId('publication-reference-picking').textContent,
).toBe('false');
expect(screen.getByTestId('character-menu').textContent).toBe('false');
expect(screen.getByTestId('icon-menu').textContent).toBe('false');
});
it('collapses idle publication composer with Escape', () => {
render(
<KeyboardShortcutsHarness
initialGenerateDialog={createPlaceholderDialog('publication')}
/>,
);
act(() => {
fireEvent.keyDown(window, { key: 'Escape', code: 'Escape' });
});
expect(screen.getByTestId('generate-dialog').textContent).toBe(
'publication:idle:false',
);
expect(screen.getByTestId('publication-reference-menu').textContent).toBe(
'false',
);
expect(
screen.getByTestId('publication-reference-picking').textContent,
).toBe('false');
});
it('keeps generating panels open when Escape closes transient chrome', () => {
const generatingDialog = createPlaceholderDialog('generate');
generatingDialog.status = 'generating';
render(
<KeyboardShortcutsHarness
initialGenerateDialog={generatingDialog}
initialQuickEditPanel={{
sourceLayerId: 'layer-source',
prompt: '继续修改',
size: '1024x1024',
model: 'mock',
status: 'generating',
}}
/>,
);
act(() => {
fireEvent.keyDown(window, { key: 'Escape', code: 'Escape' });
});
expect(screen.getByTestId('generate-dialog').textContent).toBe(
'generate:generating:true',
);
expect(screen.getByTestId('quick-edit').textContent).toBe('generating');
});
it('toggles temporary panning with Space outside editable inputs', () => {
render(<KeyboardShortcutsHarness />);
act(() => {
fireEvent.keyDown(window, { key: ' ', code: 'Space' });
});
expect(screen.getByTestId('space-panning').textContent).toBe('true');
act(() => {
fireEvent.keyUp(window, { key: ' ', code: 'Space' });
});
expect(screen.getByTestId('space-panning').textContent).toBe('false');
act(() => {
fireEvent.keyDown(screen.getByLabelText('快捷键输入框'), {
key: ' ',
code: 'Space',
});
});
expect(screen.getByTestId('space-panning').textContent).toBe('false');
});
it('tracks Shift key state', () => {
render(<KeyboardShortcutsHarness />);
act(() => {
fireEvent.keyDown(window, { key: 'Shift', code: 'ShiftLeft' });
});
expect(screen.getByTestId('shift-pressed').textContent).toBe('true');
act(() => {
fireEvent.keyUp(window, { key: 'Shift', code: 'ShiftLeft' });
});
expect(screen.getByTestId('shift-pressed').textContent).toBe('false');
});
});