fix: unified handling of editor reloading (#32)
Co-authored-by: hugo <hugoms@me.com>
This commit is contained in:
@@ -185,6 +185,7 @@ describe('Editor does not reset content on auto-save (cursor stability)', () =>
|
||||
(window as any).electronAPI.meta.getCategories = vi.fn().mockReturnValue(neverSettles);
|
||||
|
||||
useAppStore.setState({
|
||||
activeProject: { id: 'project-1', name: 'Test', path: '/tmp/test' } as any,
|
||||
preferredEditorMode: 'markdown',
|
||||
posts: [],
|
||||
media: [],
|
||||
|
||||
@@ -62,6 +62,7 @@ describe('Editor dashboard timeline', () => {
|
||||
(window as any).electronAPI.app.setPreviewPostTarget = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
useAppStore.setState({
|
||||
activeProject: { id: 'project-1', name: 'Test', path: '/tmp/test' } as any,
|
||||
tabs: [],
|
||||
activeTabId: null,
|
||||
posts: [],
|
||||
|
||||
@@ -155,6 +155,7 @@ describe('Editor metadata collapse', () => {
|
||||
(window as any).electronAPI.meta.getCategories = vi.fn().mockReturnValue(neverSettles);
|
||||
|
||||
useAppStore.setState({
|
||||
activeProject: { id: 'project-1', name: 'Test', path: '/tmp/test' } as any,
|
||||
preferredEditorMode: 'wysiwyg',
|
||||
posts: [],
|
||||
media: [],
|
||||
|
||||
@@ -165,6 +165,7 @@ describe('Editor visual mode persistence', () => {
|
||||
(window as any).electronAPI.meta.getCategories = vi.fn().mockReturnValue(neverSettles);
|
||||
|
||||
useAppStore.setState({
|
||||
activeProject: { id: 'project-1', name: 'Test', path: '/tmp/test' } as any,
|
||||
preferredEditorMode: 'wysiwyg',
|
||||
posts: [],
|
||||
media: [],
|
||||
|
||||
@@ -3,11 +3,16 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
|
||||
import { MenuEditorView } from '../../../src/renderer/components/MenuEditorView/MenuEditorView';
|
||||
import { useAppStore } from '../../../src/renderer/store';
|
||||
|
||||
describe('MenuEditorView entry editor', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
useAppStore.setState({
|
||||
activeProject: { id: 'project-1', name: 'Test', path: '/tmp/test' } as any,
|
||||
});
|
||||
|
||||
(window as any).electronAPI = {
|
||||
...(window as any).electronAPI,
|
||||
menu: {
|
||||
|
||||
@@ -86,6 +86,7 @@ describe('ScriptsView', () => {
|
||||
};
|
||||
|
||||
useAppStore.setState({
|
||||
activeProject: { id: 'project-1', name: 'Test', path: '/tmp/test' } as any,
|
||||
panelVisible: false,
|
||||
panelActiveTab: 'tasks',
|
||||
panelOutputEntries: [],
|
||||
@@ -437,4 +438,30 @@ describe('ScriptsView', () => {
|
||||
expect(startTaskMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('defers loading until activeProject is set to avoid startup race condition', async () => {
|
||||
useAppStore.setState({ activeProject: null });
|
||||
|
||||
const getMock = (window as any).electronAPI.scripts.get;
|
||||
const { rerender } = render(<ScriptsView scriptId="script-1" />);
|
||||
|
||||
// Give the effect a chance to run
|
||||
await vi.waitFor(() => {
|
||||
expect(getMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Now simulate project context becoming available
|
||||
useAppStore.setState({
|
||||
activeProject: { id: 'project-1', name: 'Test', path: '/tmp/test' } as any,
|
||||
});
|
||||
|
||||
// Re-render to pick up store change
|
||||
rerender(<ScriptsView scriptId="script-1" />);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(getMock).toHaveBeenCalledWith('script-1');
|
||||
const textarea = screen.getByLabelText('Script Content') as HTMLTextAreaElement;
|
||||
expect(textarea.value).toContain('print("hello")');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,11 +2,16 @@ import React from 'react';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { render, act, screen, fireEvent } from '@testing-library/react';
|
||||
import { TagsView } from '../../../src/renderer/components/TagsView/TagsView';
|
||||
import { useAppStore } from '../../../src/renderer/store';
|
||||
|
||||
describe('TagsView subscriptions', () => {
|
||||
beforeEach(() => {
|
||||
const onMock = vi.fn((_channel: string, _callback: (...args: unknown[]) => void) => vi.fn());
|
||||
|
||||
useAppStore.setState({
|
||||
activeProject: { id: 'project-1', name: 'Test', path: '/tmp/test' } as any,
|
||||
});
|
||||
|
||||
(window as any).electronAPI = {
|
||||
...(window as any).electronAPI,
|
||||
tags: {
|
||||
@@ -66,6 +71,10 @@ describe('TagsView template dropdown', () => {
|
||||
beforeEach(() => {
|
||||
const onMock = vi.fn((_channel: string, _callback: (...args: unknown[]) => void) => vi.fn());
|
||||
|
||||
useAppStore.setState({
|
||||
activeProject: { id: 'project-1', name: 'Test', path: '/tmp/test' } as any,
|
||||
});
|
||||
|
||||
(window as any).electronAPI = {
|
||||
...(window as any).electronAPI,
|
||||
tags: {
|
||||
|
||||
@@ -42,6 +42,10 @@ describe('TemplatesView', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
useAppStore.setState({
|
||||
activeProject: { id: 'project-1', name: 'Test', path: '/tmp/test' } as any,
|
||||
});
|
||||
|
||||
(window as any).electronAPI = {
|
||||
...(window as any).electronAPI,
|
||||
templates: {
|
||||
@@ -208,4 +212,27 @@ describe('TemplatesView', () => {
|
||||
expect(saveButton).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it('defers loading until activeProject is set to avoid startup race condition', async () => {
|
||||
useAppStore.setState({ activeProject: null });
|
||||
|
||||
const getMock = (window as any).electronAPI.templates.get;
|
||||
const { rerender } = render(<TemplatesView templateId="template-1" />);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(getMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
useAppStore.setState({
|
||||
activeProject: { id: 'project-1', name: 'Test', path: '/tmp/test' } as any,
|
||||
});
|
||||
|
||||
rerender(<TemplatesView templateId="template-1" />);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(getMock).toHaveBeenCalledWith('template-1');
|
||||
const titleInput = screen.getByLabelText('Title') as HTMLInputElement;
|
||||
expect(titleInput.value).toBe('Custom Post');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ describe('chat surface shared usage guards', () => {
|
||||
Element.prototype.scrollIntoView = vi.fn();
|
||||
}
|
||||
|
||||
useAppStore.setState({ tabs: [], activeTabId: null, activeView: 'posts' });
|
||||
useAppStore.setState({ tabs: [], activeTabId: null, activeView: 'posts', activeProject: { id: 'project-1', name: 'Test', path: '/tmp/test' } as any });
|
||||
|
||||
window.electronAPI.chat = {
|
||||
checkReady: vi.fn().mockResolvedValue({ ready: true }),
|
||||
|
||||
258
tests/renderer/navigation/useEntityEditor.test.ts
Normal file
258
tests/renderer/navigation/useEntityEditor.test.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user