259 lines
7.7 KiB
TypeScript
259 lines
7.7 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import { renderHook, act } from '@testing-library/react';
|
|
import { useEntityLoader, useSaveShortcut } from '../../../src/renderer/navigation/useEntityEditor';
|
|
import { useAppStore } from '../../../src/renderer/store';
|
|
|
|
describe('useEntityLoader', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
useAppStore.setState({
|
|
activeProject: { id: 'project-1', name: 'Test', path: '/tmp/test' } as any,
|
|
tabs: [{ type: 'scripts', id: 'entity-1', isTransient: false }],
|
|
activeTabId: 'entity-1',
|
|
});
|
|
});
|
|
|
|
it('defers loading until activeProject is set', async () => {
|
|
useAppStore.setState({ activeProject: null });
|
|
|
|
const fetcher = vi.fn().mockResolvedValue({ id: 'entity-1', title: 'Test' });
|
|
const onLoaded = vi.fn();
|
|
const onReset = vi.fn();
|
|
|
|
const { result, rerender } = renderHook(
|
|
({ id }) => useEntityLoader(id, fetcher, { onLoaded, onReset }),
|
|
{ initialProps: { id: 'entity-1' } },
|
|
);
|
|
|
|
expect(result.current.isLoading).toBe(true);
|
|
expect(fetcher).not.toHaveBeenCalled();
|
|
// onReset is called when activeProject is missing (clear state)
|
|
expect(onReset).toHaveBeenCalled();
|
|
|
|
onReset.mockClear();
|
|
|
|
// Simulate project becoming available
|
|
act(() => {
|
|
useAppStore.setState({
|
|
activeProject: { id: 'project-1', name: 'Test', path: '/tmp/test' } as any,
|
|
});
|
|
});
|
|
rerender({ id: 'entity-1' });
|
|
|
|
await vi.waitFor(() => {
|
|
expect(fetcher).toHaveBeenCalledWith('entity-1');
|
|
expect(onLoaded).toHaveBeenCalledWith({ id: 'entity-1', title: 'Test' });
|
|
expect(result.current.isLoading).toBe(false);
|
|
});
|
|
});
|
|
|
|
it('loads entity data when activeProject is already set', async () => {
|
|
const fetcher = vi.fn().mockResolvedValue({ id: 'entity-1', title: 'Hello' });
|
|
const onLoaded = vi.fn();
|
|
const onReset = vi.fn();
|
|
|
|
const { result } = renderHook(() =>
|
|
useEntityLoader('entity-1', fetcher, { onLoaded, onReset }),
|
|
);
|
|
|
|
await vi.waitFor(() => {
|
|
expect(onLoaded).toHaveBeenCalledWith({ id: 'entity-1', title: 'Hello' });
|
|
expect(result.current.isLoading).toBe(false);
|
|
});
|
|
});
|
|
|
|
it('closes tab when entity is not found', async () => {
|
|
const fetcher = vi.fn().mockResolvedValue(null);
|
|
const onLoaded = vi.fn();
|
|
const onReset = vi.fn();
|
|
|
|
renderHook(() =>
|
|
useEntityLoader('entity-1', fetcher, { onLoaded, onReset }),
|
|
);
|
|
|
|
await vi.waitFor(() => {
|
|
expect(fetcher).toHaveBeenCalledWith('entity-1');
|
|
expect(onLoaded).not.toHaveBeenCalled();
|
|
expect(onReset).toHaveBeenCalled();
|
|
const state = useAppStore.getState();
|
|
expect(state.tabs.find(t => t.id === 'entity-1')).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
it('resets and reloads when entity ID changes', async () => {
|
|
const fetcher = vi.fn()
|
|
.mockResolvedValueOnce({ id: 'entity-1', title: 'First' })
|
|
.mockResolvedValueOnce({ id: 'entity-2', title: 'Second' });
|
|
|
|
const onLoaded = vi.fn();
|
|
const onReset = vi.fn();
|
|
|
|
useAppStore.setState({
|
|
tabs: [
|
|
{ type: 'scripts', id: 'entity-1', isTransient: false },
|
|
{ type: 'scripts', id: 'entity-2', isTransient: false },
|
|
],
|
|
});
|
|
|
|
const { rerender } = renderHook(
|
|
({ id }) => useEntityLoader(id, fetcher, { onLoaded, onReset }),
|
|
{ initialProps: { id: 'entity-1' } },
|
|
);
|
|
|
|
await vi.waitFor(() => {
|
|
expect(onLoaded).toHaveBeenCalledWith({ id: 'entity-1', title: 'First' });
|
|
});
|
|
|
|
onLoaded.mockClear();
|
|
rerender({ id: 'entity-2' });
|
|
|
|
await vi.waitFor(() => {
|
|
expect(onLoaded).toHaveBeenCalledWith({ id: 'entity-2', title: 'Second' });
|
|
});
|
|
});
|
|
|
|
it('cancels in-flight load when entity ID changes quickly', async () => {
|
|
let resolveFirst: (value: unknown) => void;
|
|
const firstPromise = new Promise((resolve) => { resolveFirst = resolve; });
|
|
const fetcher = vi.fn()
|
|
.mockReturnValueOnce(firstPromise)
|
|
.mockResolvedValueOnce({ id: 'entity-2', title: 'Second' });
|
|
|
|
const onLoaded = vi.fn();
|
|
const onReset = vi.fn();
|
|
|
|
useAppStore.setState({
|
|
tabs: [
|
|
{ type: 'scripts', id: 'entity-1', isTransient: false },
|
|
{ type: 'scripts', id: 'entity-2', isTransient: false },
|
|
],
|
|
});
|
|
|
|
const { rerender } = renderHook(
|
|
({ id }) => useEntityLoader(id, fetcher, { onLoaded, onReset }),
|
|
{ initialProps: { id: 'entity-1' } },
|
|
);
|
|
|
|
// Switch before first resolves
|
|
rerender({ id: 'entity-2' });
|
|
|
|
await vi.waitFor(() => {
|
|
expect(onLoaded).toHaveBeenCalledWith({ id: 'entity-2', title: 'Second' });
|
|
});
|
|
|
|
// Late resolve of first should have no effect
|
|
resolveFirst!({ id: 'entity-1', title: 'First' });
|
|
|
|
// onLoaded should only have been called once (for entity-2)
|
|
await vi.waitFor(() => {
|
|
expect(onLoaded).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
it('calls onReset and isLoading=false when entityId is null', async () => {
|
|
const fetcher = vi.fn();
|
|
const onLoaded = vi.fn();
|
|
const onReset = vi.fn();
|
|
|
|
const { result } = renderHook(() =>
|
|
useEntityLoader(null, fetcher, { onLoaded, onReset }),
|
|
);
|
|
|
|
await vi.waitFor(() => {
|
|
expect(onReset).toHaveBeenCalled();
|
|
expect(result.current.isLoading).toBe(false);
|
|
expect(fetcher).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
it('allows reload via returned reload function', async () => {
|
|
const fetcher = vi.fn()
|
|
.mockResolvedValueOnce({ id: 'entity-1', title: 'V1' })
|
|
.mockResolvedValueOnce({ id: 'entity-1', title: 'V2' });
|
|
|
|
const onLoaded = vi.fn();
|
|
const onReset = vi.fn();
|
|
|
|
const { result } = renderHook(() =>
|
|
useEntityLoader('entity-1', fetcher, { onLoaded, onReset }),
|
|
);
|
|
|
|
await vi.waitFor(() => {
|
|
expect(onLoaded).toHaveBeenCalledWith({ id: 'entity-1', title: 'V1' });
|
|
});
|
|
|
|
await act(async () => {
|
|
result.current.reload();
|
|
});
|
|
|
|
await vi.waitFor(() => {
|
|
expect(onLoaded).toHaveBeenCalledWith({ id: 'entity-1', title: 'V2' });
|
|
expect(fetcher).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('useSaveShortcut', () => {
|
|
let eventTarget: EventTarget;
|
|
|
|
beforeEach(() => {
|
|
eventTarget = new EventTarget();
|
|
window.addEventListener = eventTarget.addEventListener.bind(eventTarget);
|
|
window.removeEventListener = eventTarget.removeEventListener.bind(eventTarget);
|
|
window.dispatchEvent = eventTarget.dispatchEvent.bind(eventTarget);
|
|
});
|
|
|
|
it('calls onSave when Cmd+S is pressed', () => {
|
|
const onSave = vi.fn();
|
|
renderHook(() => useSaveShortcut(onSave));
|
|
|
|
const event = new KeyboardEvent('keydown', {
|
|
key: 's',
|
|
metaKey: true,
|
|
bubbles: true,
|
|
});
|
|
window.dispatchEvent(event);
|
|
|
|
expect(onSave).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('calls onSave when Ctrl+S is pressed', () => {
|
|
const onSave = vi.fn();
|
|
renderHook(() => useSaveShortcut(onSave));
|
|
|
|
const event = new KeyboardEvent('keydown', {
|
|
key: 's',
|
|
ctrlKey: true,
|
|
bubbles: true,
|
|
});
|
|
window.dispatchEvent(event);
|
|
|
|
expect(onSave).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('does not call onSave for other key combinations', () => {
|
|
const onSave = vi.fn();
|
|
renderHook(() => useSaveShortcut(onSave));
|
|
|
|
window.dispatchEvent(new KeyboardEvent('keydown', { key: 's', bubbles: true }));
|
|
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'a', metaKey: true, bubbles: true }));
|
|
|
|
expect(onSave).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('cleans up event listener on unmount', () => {
|
|
const onSave = vi.fn();
|
|
const { unmount } = renderHook(() => useSaveShortcut(onSave));
|
|
|
|
unmount();
|
|
|
|
window.dispatchEvent(new KeyboardEvent('keydown', {
|
|
key: 's',
|
|
metaKey: true,
|
|
bubbles: true,
|
|
}));
|
|
|
|
expect(onSave).not.toHaveBeenCalled();
|
|
});
|
|
});
|