Resolve naming collision between editorSlice and tabSlice. Both slices defined closeTab and setActiveTab, and since editorSlice was spread last in the store composition, it silently overwrote the tabSlice methods, breaking tab management.
852 lines
31 KiB
TypeScript
852 lines
31 KiB
TypeScript
/**
|
|
* Tests for editorSlice — openEditor, closeEditor, expandDirectory, collapseDirectory.
|
|
*/
|
|
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import { createTestStore } from './storeTestUtils';
|
|
|
|
import type { TestStore } from './storeTestUtils';
|
|
import type { FileTreeEntry, ReadDirResult } from '../../../src/shared/types/editor';
|
|
|
|
// =============================================================================
|
|
// Mock API
|
|
// =============================================================================
|
|
|
|
const mockEditorAPI = {
|
|
open: vi.fn(),
|
|
close: vi.fn(),
|
|
readDir: vi.fn(),
|
|
readFile: vi.fn(),
|
|
writeFile: vi.fn(),
|
|
createFile: vi.fn(),
|
|
createDir: vi.fn(),
|
|
deleteFile: vi.fn(),
|
|
moveFile: vi.fn(),
|
|
};
|
|
|
|
vi.mock('@renderer/api', () => ({
|
|
api: {
|
|
editor: {
|
|
open: (...args: unknown[]) => mockEditorAPI.open(...args),
|
|
close: (...args: unknown[]) => mockEditorAPI.close(...args),
|
|
readDir: (...args: unknown[]) => mockEditorAPI.readDir(...args),
|
|
readFile: (...args: unknown[]) => mockEditorAPI.readFile(...args),
|
|
writeFile: (...args: unknown[]) => mockEditorAPI.writeFile(...args),
|
|
createFile: (...args: unknown[]) => mockEditorAPI.createFile(...args),
|
|
createDir: (...args: unknown[]) => mockEditorAPI.createDir(...args),
|
|
deleteFile: (...args: unknown[]) => mockEditorAPI.deleteFile(...args),
|
|
moveFile: (...args: unknown[]) => mockEditorAPI.moveFile(...args),
|
|
},
|
|
// Provide stubs for other API domains if needed
|
|
getProjects: vi.fn(),
|
|
getSessions: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
const mockBridge = {
|
|
getContent: vi.fn(),
|
|
getAllModifiedContent: vi.fn(),
|
|
destroy: vi.fn(),
|
|
deleteState: vi.fn(),
|
|
remapState: vi.fn(),
|
|
};
|
|
|
|
vi.mock('@renderer/utils/editorBridge', () => ({
|
|
editorBridge: {
|
|
getContent: (...args: unknown[]) => mockBridge.getContent(...args),
|
|
getAllModifiedContent: (...args: unknown[]) => mockBridge.getAllModifiedContent(...args),
|
|
destroy: (...args: unknown[]) => mockBridge.destroy(...args),
|
|
deleteState: (...args: unknown[]) => mockBridge.deleteState(...args),
|
|
remapState: (...args: unknown[]) => mockBridge.remapState(...args),
|
|
register: vi.fn(),
|
|
unregister: vi.fn(),
|
|
isRegistered: false,
|
|
updateView: vi.fn(),
|
|
getView: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
vi.mock('@renderer/utils/codemirrorLanguages', () => ({
|
|
getLanguageFromFileName: (name: string) => {
|
|
const ext = name.split('.').pop()?.toLowerCase();
|
|
const map: Record<string, string> = {
|
|
ts: 'TypeScript',
|
|
tsx: 'TSX',
|
|
js: 'JavaScript',
|
|
json: 'JSON',
|
|
md: 'Markdown',
|
|
py: 'Python',
|
|
};
|
|
return map[ext ?? ''] ?? 'Plain Text';
|
|
},
|
|
getSyncLanguageExtension: vi.fn(),
|
|
getAsyncLanguageDesc: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('@shared/utils/logger', () => ({
|
|
createLogger: () => ({
|
|
info: vi.fn(),
|
|
error: vi.fn(),
|
|
warn: vi.fn(),
|
|
debug: vi.fn(),
|
|
}),
|
|
}));
|
|
|
|
// =============================================================================
|
|
// Helpers
|
|
// =============================================================================
|
|
|
|
const PROJECT_PATH = '/Users/test/my-project';
|
|
|
|
function makeEntry(name: string, type: 'file' | 'directory', absPath?: string): FileTreeEntry {
|
|
return {
|
|
name,
|
|
path: absPath ?? `${PROJECT_PATH}/${name}`,
|
|
type,
|
|
};
|
|
}
|
|
|
|
function makeDirResult(entries: FileTreeEntry[], truncated = false): ReadDirResult {
|
|
return { entries, truncated };
|
|
}
|
|
|
|
// =============================================================================
|
|
// Tests
|
|
// =============================================================================
|
|
|
|
describe('editorSlice', () => {
|
|
let store: TestStore;
|
|
|
|
beforeEach(() => {
|
|
vi.resetAllMocks();
|
|
store = createTestStore();
|
|
});
|
|
|
|
describe('initial state', () => {
|
|
it('has null/empty defaults', () => {
|
|
const state = store.getState();
|
|
expect(state.editorProjectPath).toBeNull();
|
|
expect(state.editorFileTree).toBeNull();
|
|
expect(state.editorFileTreeLoading).toBe(false);
|
|
expect(state.editorFileTreeError).toBeNull();
|
|
expect(state.editorExpandedDirs).toEqual({});
|
|
});
|
|
});
|
|
|
|
describe('openEditor', () => {
|
|
it('sets loading state, calls API, and stores file tree', async () => {
|
|
const entries = [makeEntry('src', 'directory'), makeEntry('README.md', 'file')];
|
|
mockEditorAPI.open.mockResolvedValue(undefined);
|
|
mockEditorAPI.readDir.mockResolvedValue(makeDirResult(entries));
|
|
|
|
await store.getState().openEditor(PROJECT_PATH);
|
|
|
|
const state = store.getState();
|
|
expect(state.editorProjectPath).toBe(PROJECT_PATH);
|
|
expect(state.editorFileTree).toEqual(entries);
|
|
expect(state.editorFileTreeLoading).toBe(false);
|
|
expect(state.editorFileTreeError).toBeNull();
|
|
expect(mockEditorAPI.open).toHaveBeenCalledWith(PROJECT_PATH);
|
|
expect(mockEditorAPI.readDir).toHaveBeenCalledWith(PROJECT_PATH);
|
|
});
|
|
|
|
it('sets error state on API failure', async () => {
|
|
mockEditorAPI.open.mockRejectedValue(new Error('Permission denied'));
|
|
|
|
await store.getState().openEditor(PROJECT_PATH);
|
|
|
|
const state = store.getState();
|
|
expect(state.editorFileTreeLoading).toBe(false);
|
|
expect(state.editorFileTreeError).toBe('Permission denied');
|
|
expect(state.editorFileTree).toBeNull();
|
|
});
|
|
|
|
it('resets expanded dirs on new open', async () => {
|
|
// Set some expanded dirs manually
|
|
store.setState({ editorExpandedDirs: { '/old/path': true } });
|
|
|
|
mockEditorAPI.open.mockResolvedValue(undefined);
|
|
mockEditorAPI.readDir.mockResolvedValue(makeDirResult([]));
|
|
|
|
await store.getState().openEditor(PROJECT_PATH);
|
|
|
|
expect(store.getState().editorExpandedDirs).toEqual({});
|
|
});
|
|
});
|
|
|
|
describe('closeEditor', () => {
|
|
it('resets all editor state', async () => {
|
|
// Setup non-default state
|
|
store.setState({
|
|
editorProjectPath: PROJECT_PATH,
|
|
editorFileTree: [makeEntry('file.ts', 'file')],
|
|
editorFileTreeLoading: true,
|
|
editorFileTreeError: 'some error',
|
|
editorExpandedDirs: { '/path': true },
|
|
});
|
|
|
|
mockEditorAPI.close.mockResolvedValue(undefined);
|
|
|
|
store.getState().closeEditor();
|
|
|
|
const state = store.getState();
|
|
expect(state.editorProjectPath).toBeNull();
|
|
expect(state.editorFileTree).toBeNull();
|
|
expect(state.editorFileTreeLoading).toBe(false);
|
|
expect(state.editorFileTreeError).toBeNull();
|
|
expect(state.editorExpandedDirs).toEqual({});
|
|
});
|
|
|
|
it('still resets local state even if IPC close fails', async () => {
|
|
store.setState({ editorProjectPath: PROJECT_PATH });
|
|
mockEditorAPI.close.mockRejectedValue(new Error('IPC error'));
|
|
|
|
store.getState().closeEditor();
|
|
|
|
// Local state reset immediately (fire-and-forget IPC)
|
|
expect(store.getState().editorProjectPath).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('expandDirectory', () => {
|
|
it('marks directory expanded immediately, then merges children', async () => {
|
|
const srcDir = makeEntry('src', 'directory');
|
|
store.setState({
|
|
editorProjectPath: PROJECT_PATH,
|
|
editorFileTree: [srcDir, makeEntry('README.md', 'file')],
|
|
});
|
|
|
|
const children = [makeEntry('index.ts', 'file', `${PROJECT_PATH}/src/index.ts`)];
|
|
mockEditorAPI.readDir.mockResolvedValue(makeDirResult(children));
|
|
|
|
const expandPromise = store.getState().expandDirectory(srcDir.path);
|
|
|
|
// Immediately expanded (optimistic UI)
|
|
expect(store.getState().editorExpandedDirs[srcDir.path]).toBe(true);
|
|
|
|
await expandPromise;
|
|
|
|
// Children merged into tree
|
|
const tree = store.getState().editorFileTree!;
|
|
const srcNode = tree.find((e) => e.name === 'src');
|
|
expect(srcNode?.children).toEqual(children);
|
|
});
|
|
|
|
it('reverts expansion on error', async () => {
|
|
const srcDir = makeEntry('src', 'directory');
|
|
store.setState({
|
|
editorProjectPath: PROJECT_PATH,
|
|
editorFileTree: [srcDir],
|
|
editorExpandedDirs: {},
|
|
});
|
|
|
|
mockEditorAPI.readDir.mockRejectedValue(new Error('Network error'));
|
|
|
|
await store.getState().expandDirectory(srcDir.path);
|
|
|
|
// Expansion reverted
|
|
expect(store.getState().editorExpandedDirs[srcDir.path]).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('collapseDirectory', () => {
|
|
it('removes directory from expandedDirs', () => {
|
|
const dirPath = PROJECT_PATH + '/src';
|
|
store.setState({ editorExpandedDirs: { [dirPath]: true, '/other': true } });
|
|
|
|
store.getState().collapseDirectory(dirPath);
|
|
|
|
expect(store.getState().editorExpandedDirs).toEqual({ '/other': true });
|
|
});
|
|
|
|
it('no-op when directory not expanded', () => {
|
|
store.setState({ editorExpandedDirs: { '/other': true } });
|
|
|
|
store.getState().collapseDirectory('/not-expanded');
|
|
|
|
expect(store.getState().editorExpandedDirs).toEqual({ '/other': true });
|
|
});
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════
|
|
// Group 2: Tab management
|
|
// ═══════════════════════════════════════════════════════
|
|
|
|
describe('openFile', () => {
|
|
it('creates a tab and activates it', () => {
|
|
store.getState().openFile('/project/src/index.ts');
|
|
|
|
const state = store.getState();
|
|
expect(state.editorOpenTabs).toHaveLength(1);
|
|
expect(state.editorOpenTabs[0].filePath).toBe('/project/src/index.ts');
|
|
expect(state.editorOpenTabs[0].fileName).toBe('index.ts');
|
|
expect(state.editorOpenTabs[0].language).toBe('TypeScript');
|
|
expect(state.editorActiveTabId).toBe('/project/src/index.ts');
|
|
});
|
|
|
|
it('activates existing tab instead of creating duplicate', () => {
|
|
store.getState().openFile('/project/src/index.ts');
|
|
store.getState().openFile('/project/src/app.tsx');
|
|
store.getState().openFile('/project/src/index.ts');
|
|
|
|
const state = store.getState();
|
|
expect(state.editorOpenTabs).toHaveLength(2);
|
|
expect(state.editorActiveTabId).toBe('/project/src/index.ts');
|
|
});
|
|
|
|
it('detects language from file extension', () => {
|
|
store.getState().openFile('/project/data.json');
|
|
|
|
expect(store.getState().editorOpenTabs[0].language).toBe('JSON');
|
|
});
|
|
|
|
it('uses "Plain Text" for unknown extensions', () => {
|
|
store.getState().openFile('/project/Dockerfile');
|
|
|
|
expect(store.getState().editorOpenTabs[0].language).toBe('Plain Text');
|
|
});
|
|
});
|
|
|
|
describe('closeEditorTab', () => {
|
|
it('removes tab and activates adjacent', () => {
|
|
store.getState().openFile('/project/a.ts');
|
|
store.getState().openFile('/project/b.ts');
|
|
store.getState().openFile('/project/c.ts');
|
|
|
|
// Active is c.ts, close it
|
|
store.getState().closeEditorTab('/project/c.ts');
|
|
|
|
const state = store.getState();
|
|
expect(state.editorOpenTabs).toHaveLength(2);
|
|
expect(state.editorActiveTabId).toBe('/project/b.ts');
|
|
});
|
|
|
|
it('activates first remaining tab when first is closed', () => {
|
|
store.getState().openFile('/project/a.ts');
|
|
store.getState().openFile('/project/b.ts');
|
|
store.getState().setActiveEditorTab('/project/a.ts');
|
|
|
|
store.getState().closeEditorTab('/project/a.ts');
|
|
|
|
expect(store.getState().editorActiveTabId).toBe('/project/b.ts');
|
|
});
|
|
|
|
it('sets null when last tab is closed', () => {
|
|
store.getState().openFile('/project/a.ts');
|
|
store.getState().closeEditorTab('/project/a.ts');
|
|
|
|
expect(store.getState().editorActiveTabId).toBeNull();
|
|
expect(store.getState().editorOpenTabs).toHaveLength(0);
|
|
});
|
|
|
|
it('cleans up dirty and error state for closed tab', () => {
|
|
store.getState().openFile('/project/a.ts');
|
|
store.setState({
|
|
editorModifiedFiles: { '/project/a.ts': true },
|
|
editorSaveError: { '/project/a.ts': 'Save failed' },
|
|
});
|
|
|
|
store.getState().closeEditorTab('/project/a.ts');
|
|
|
|
expect(store.getState().editorModifiedFiles).toEqual({});
|
|
expect(store.getState().editorSaveError).toEqual({});
|
|
});
|
|
|
|
it('does not change activeTabId when closing non-active tab', () => {
|
|
store.getState().openFile('/project/a.ts');
|
|
store.getState().openFile('/project/b.ts');
|
|
// b.ts is active
|
|
|
|
store.getState().closeEditorTab('/project/a.ts');
|
|
|
|
expect(store.getState().editorActiveTabId).toBe('/project/b.ts');
|
|
expect(store.getState().editorOpenTabs).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe('setActiveEditorTab', () => {
|
|
it('changes the active tab', () => {
|
|
store.getState().openFile('/project/a.ts');
|
|
store.getState().openFile('/project/b.ts');
|
|
// b.ts is active
|
|
|
|
store.getState().setActiveEditorTab('/project/a.ts');
|
|
|
|
expect(store.getState().editorActiveTabId).toBe('/project/a.ts');
|
|
});
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════
|
|
// Group 3: Dirty/Save
|
|
// ═══════════════════════════════════════════════════════
|
|
|
|
describe('markFileModified', () => {
|
|
it('sets dirty flag', () => {
|
|
store.getState().markFileModified('/project/a.ts');
|
|
|
|
expect(store.getState().editorModifiedFiles['/project/a.ts']).toBe(true);
|
|
});
|
|
|
|
it('is idempotent', () => {
|
|
store.getState().markFileModified('/project/a.ts');
|
|
const first = store.getState().editorModifiedFiles;
|
|
|
|
store.getState().markFileModified('/project/a.ts');
|
|
const second = store.getState().editorModifiedFiles;
|
|
|
|
// Same reference (no unnecessary update)
|
|
expect(first).toBe(second);
|
|
});
|
|
});
|
|
|
|
describe('markFileSaved', () => {
|
|
it('removes dirty flag', () => {
|
|
store.setState({ editorModifiedFiles: { '/project/a.ts': true, '/project/b.ts': true } });
|
|
|
|
store.getState().markFileSaved('/project/a.ts');
|
|
|
|
expect(store.getState().editorModifiedFiles).toEqual({ '/project/b.ts': true });
|
|
});
|
|
});
|
|
|
|
describe('hasUnsavedChanges', () => {
|
|
it('returns false when no modified files', () => {
|
|
expect(store.getState().hasUnsavedChanges()).toBe(false);
|
|
});
|
|
|
|
it('returns true when modified files exist', () => {
|
|
store.setState({ editorModifiedFiles: { '/project/a.ts': true } });
|
|
expect(store.getState().hasUnsavedChanges()).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('saveFile', () => {
|
|
it('saves file via API and clears dirty flag', async () => {
|
|
const filePath = '/project/src/index.ts';
|
|
mockBridge.getContent.mockReturnValue('new content');
|
|
mockEditorAPI.writeFile.mockResolvedValue({ mtimeMs: Date.now(), size: 11 });
|
|
|
|
store.setState({ editorModifiedFiles: { [filePath]: true } });
|
|
await store.getState().saveFile(filePath);
|
|
|
|
expect(mockBridge.getContent).toHaveBeenCalledWith(filePath);
|
|
expect(mockEditorAPI.writeFile).toHaveBeenCalledWith(filePath, 'new content', undefined);
|
|
expect(store.getState().editorModifiedFiles[filePath]).toBeUndefined();
|
|
expect(store.getState().editorSaving[filePath]).toBeUndefined();
|
|
});
|
|
|
|
it('sets saving flag during save', async () => {
|
|
const filePath = '/project/src/index.ts';
|
|
let savingDuringCall = false;
|
|
|
|
mockBridge.getContent.mockReturnValue('content');
|
|
mockEditorAPI.writeFile.mockImplementation(async () => {
|
|
savingDuringCall = !!store.getState().editorSaving[filePath];
|
|
return { mtimeMs: Date.now(), size: 7 };
|
|
});
|
|
|
|
await store.getState().saveFile(filePath);
|
|
|
|
expect(savingDuringCall).toBe(true);
|
|
expect(store.getState().editorSaving[filePath]).toBeUndefined();
|
|
});
|
|
|
|
it('does nothing when bridge has no content', async () => {
|
|
mockBridge.getContent.mockReturnValue(null);
|
|
|
|
await store.getState().saveFile('/project/src/index.ts');
|
|
|
|
expect(mockEditorAPI.writeFile).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('sets error on save failure', async () => {
|
|
const filePath = '/project/src/index.ts';
|
|
mockBridge.getContent.mockReturnValue('content');
|
|
mockEditorAPI.writeFile.mockRejectedValue(new Error('Permission denied'));
|
|
|
|
store.setState({ editorModifiedFiles: { [filePath]: true } });
|
|
await store.getState().saveFile(filePath);
|
|
|
|
expect(store.getState().editorSaveError[filePath]).toBe('Permission denied');
|
|
// Dirty flag preserved on error
|
|
expect(store.getState().editorModifiedFiles[filePath]).toBe(true);
|
|
expect(store.getState().editorSaving[filePath]).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('saveAllFiles', () => {
|
|
it('saves all modified files', async () => {
|
|
const files = new Map([
|
|
['/project/a.ts', 'content a'],
|
|
['/project/b.ts', 'content b'],
|
|
]);
|
|
mockBridge.getAllModifiedContent.mockReturnValue(files);
|
|
mockEditorAPI.writeFile.mockResolvedValue({ mtimeMs: Date.now(), size: 10 });
|
|
|
|
store.setState({
|
|
editorModifiedFiles: { '/project/a.ts': true, '/project/b.ts': true },
|
|
});
|
|
|
|
await store.getState().saveAllFiles();
|
|
|
|
expect(mockEditorAPI.writeFile).toHaveBeenCalledTimes(2);
|
|
expect(store.getState().editorModifiedFiles).toEqual({});
|
|
});
|
|
|
|
it('handles partial failures', async () => {
|
|
const files = new Map([
|
|
['/project/a.ts', 'content a'],
|
|
['/project/b.ts', 'content b'],
|
|
]);
|
|
mockBridge.getAllModifiedContent.mockReturnValue(files);
|
|
mockEditorAPI.writeFile
|
|
.mockResolvedValueOnce({ mtimeMs: Date.now(), size: 10 })
|
|
.mockRejectedValueOnce(new Error('Disk full'));
|
|
|
|
store.setState({
|
|
editorModifiedFiles: { '/project/a.ts': true, '/project/b.ts': true },
|
|
});
|
|
|
|
await store.getState().saveAllFiles();
|
|
|
|
// a.ts saved, b.ts still dirty
|
|
expect(store.getState().editorModifiedFiles['/project/a.ts']).toBeUndefined();
|
|
expect(store.getState().editorModifiedFiles['/project/b.ts']).toBe(true);
|
|
expect(store.getState().editorSaveError['/project/b.ts']).toBe('Disk full');
|
|
});
|
|
});
|
|
|
|
describe('discardChanges', () => {
|
|
it('clears dirty flag and error for the file', () => {
|
|
store.setState({
|
|
editorModifiedFiles: { '/project/a.ts': true, '/project/b.ts': true },
|
|
editorSaveError: { '/project/a.ts': 'Error' },
|
|
});
|
|
|
|
store.getState().discardChanges('/project/a.ts');
|
|
|
|
expect(store.getState().editorModifiedFiles).toEqual({ '/project/b.ts': true });
|
|
expect(store.getState().editorSaveError).toEqual({});
|
|
});
|
|
});
|
|
|
|
describe('closeEditor resets all state including Group 2+3', () => {
|
|
it('resets tabs, dirty, saving, errors', () => {
|
|
store.setState({
|
|
editorProjectPath: PROJECT_PATH,
|
|
editorOpenTabs: [
|
|
{ id: '/a.ts', filePath: '/a.ts', fileName: 'a.ts', language: 'TypeScript' },
|
|
],
|
|
editorActiveTabId: '/a.ts',
|
|
editorModifiedFiles: { '/a.ts': true },
|
|
editorSaving: { '/a.ts': true },
|
|
editorSaveError: { '/a.ts': 'Error' },
|
|
});
|
|
|
|
mockEditorAPI.close.mockResolvedValue(undefined);
|
|
store.getState().closeEditor();
|
|
|
|
const state = store.getState();
|
|
expect(state.editorOpenTabs).toEqual([]);
|
|
expect(state.editorActiveTabId).toBeNull();
|
|
expect(state.editorModifiedFiles).toEqual({});
|
|
expect(state.editorSaving).toEqual({});
|
|
expect(state.editorSaveError).toEqual({});
|
|
expect(mockBridge.destroy).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════
|
|
// Tab disambiguation
|
|
// ═══════════════════════════════════════════════════════
|
|
|
|
describe('openFile with disambiguation', () => {
|
|
it('adds disambiguation labels when 2 files share the same name', () => {
|
|
store.getState().openFile('/project/src/main/index.ts');
|
|
store.getState().openFile('/project/src/renderer/index.ts');
|
|
|
|
const tabs = store.getState().editorOpenTabs;
|
|
expect(tabs).toHaveLength(2);
|
|
expect(tabs[0].disambiguatedLabel).toBe('(main)');
|
|
expect(tabs[1].disambiguatedLabel).toBe('(renderer)');
|
|
});
|
|
|
|
it('no labels when names are unique', () => {
|
|
store.getState().openFile('/project/src/app.ts');
|
|
store.getState().openFile('/project/src/index.ts');
|
|
|
|
const tabs = store.getState().editorOpenTabs;
|
|
expect(tabs[0].disambiguatedLabel).toBeUndefined();
|
|
expect(tabs[1].disambiguatedLabel).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('closeEditorTab clears disambiguation when names become unique', () => {
|
|
it('removes label after closing duplicate', () => {
|
|
store.getState().openFile('/project/src/main/index.ts');
|
|
store.getState().openFile('/project/src/renderer/index.ts');
|
|
|
|
// Both have labels
|
|
expect(store.getState().editorOpenTabs[0].disambiguatedLabel).toBe('(main)');
|
|
|
|
// Close one
|
|
store.getState().closeEditorTab('/project/src/main/index.ts');
|
|
|
|
// Remaining should lose its label
|
|
const tabs = store.getState().editorOpenTabs;
|
|
expect(tabs).toHaveLength(1);
|
|
expect(tabs[0].disambiguatedLabel).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('closeEditorTab calls editorBridge.deleteState', () => {
|
|
it('clears cached state for the closed tab', () => {
|
|
store.getState().openFile('/project/a.ts');
|
|
store.getState().closeEditorTab('/project/a.ts');
|
|
|
|
expect(mockBridge.deleteState).toHaveBeenCalledWith('/project/a.ts');
|
|
});
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════
|
|
// Group 4: File operations
|
|
// ═══════════════════════════════════════════════════════
|
|
|
|
describe('createFileInTree', () => {
|
|
it('creates file, refreshes tree, and returns path', async () => {
|
|
const createdPath = '/project/src/new-file.ts';
|
|
mockEditorAPI.createFile.mockResolvedValue({ filePath: createdPath, mtimeMs: 123 });
|
|
mockEditorAPI.readDir.mockResolvedValue(
|
|
makeDirResult([makeEntry('new-file.ts', 'file', createdPath)])
|
|
);
|
|
|
|
store.setState({
|
|
editorProjectPath: PROJECT_PATH,
|
|
editorFileTree: [makeEntry('src', 'directory')],
|
|
});
|
|
|
|
const result = await store.getState().createFileInTree('/project/src', 'new-file.ts');
|
|
|
|
expect(result).toBe(createdPath);
|
|
expect(mockEditorAPI.createFile).toHaveBeenCalledWith('/project/src', 'new-file.ts');
|
|
expect(store.getState().editorCreating).toBe(false);
|
|
expect(store.getState().editorCreateError).toBeNull();
|
|
});
|
|
|
|
it('sets error on failure', async () => {
|
|
mockEditorAPI.createFile.mockRejectedValue(new Error('File already exists'));
|
|
|
|
const result = await store.getState().createFileInTree('/project/src', 'existing.ts');
|
|
|
|
expect(result).toBeNull();
|
|
expect(store.getState().editorCreating).toBe(false);
|
|
expect(store.getState().editorCreateError).toBe('File already exists');
|
|
});
|
|
});
|
|
|
|
describe('createDirInTree', () => {
|
|
it('creates directory, refreshes tree, and returns path', async () => {
|
|
const createdPath = '/project/src/new-dir';
|
|
mockEditorAPI.createDir.mockResolvedValue({ dirPath: createdPath });
|
|
mockEditorAPI.readDir.mockResolvedValue(
|
|
makeDirResult([makeEntry('new-dir', 'directory', createdPath)])
|
|
);
|
|
|
|
store.setState({
|
|
editorProjectPath: PROJECT_PATH,
|
|
editorFileTree: [makeEntry('src', 'directory')],
|
|
});
|
|
|
|
const result = await store.getState().createDirInTree('/project/src', 'new-dir');
|
|
|
|
expect(result).toBe(createdPath);
|
|
expect(mockEditorAPI.createDir).toHaveBeenCalledWith('/project/src', 'new-dir');
|
|
});
|
|
});
|
|
|
|
describe('deleteFileFromTree', () => {
|
|
it('deletes file and closes its tab if open', async () => {
|
|
mockEditorAPI.deleteFile.mockResolvedValue({ deletedPath: '/project/src/old.ts' });
|
|
mockEditorAPI.readDir.mockResolvedValue(makeDirResult([]));
|
|
|
|
store.getState().openFile('/project/src/old.ts');
|
|
store.setState({
|
|
editorProjectPath: PROJECT_PATH,
|
|
editorFileTree: [makeEntry('src', 'directory')],
|
|
});
|
|
|
|
const result = await store.getState().deleteFileFromTree('/project/src/old.ts');
|
|
|
|
expect(result).toBe(true);
|
|
expect(mockEditorAPI.deleteFile).toHaveBeenCalledWith('/project/src/old.ts');
|
|
// Tab should be closed
|
|
expect(store.getState().editorOpenTabs).toHaveLength(0);
|
|
});
|
|
|
|
it('returns false on failure', async () => {
|
|
mockEditorAPI.deleteFile.mockRejectedValue(new Error('Permission denied'));
|
|
|
|
const result = await store.getState().deleteFileFromTree('/project/src/file.ts');
|
|
|
|
expect(result).toBe(false);
|
|
});
|
|
|
|
it('closes tabs for files inside deleted directory', async () => {
|
|
mockEditorAPI.deleteFile.mockResolvedValue({ deletedPath: '/project/src' });
|
|
mockEditorAPI.readDir.mockResolvedValue(makeDirResult([]));
|
|
|
|
store.getState().openFile('/project/src/a.ts');
|
|
store.getState().openFile('/project/src/b.ts');
|
|
store.getState().openFile('/project/other.ts');
|
|
|
|
store.setState({
|
|
editorProjectPath: PROJECT_PATH,
|
|
editorFileTree: [makeEntry('src', 'directory'), makeEntry('other.ts', 'file')],
|
|
});
|
|
|
|
await store.getState().deleteFileFromTree('/project/src');
|
|
|
|
// Only other.ts should remain
|
|
expect(store.getState().editorOpenTabs).toHaveLength(1);
|
|
expect(store.getState().editorOpenTabs[0].filePath).toBe('/project/other.ts');
|
|
});
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════
|
|
// moveFileInTree
|
|
// ═══════════════════════════════════════════════════════
|
|
|
|
describe('moveFileInTree', () => {
|
|
const SRC_DIR = PROJECT_PATH + '/src';
|
|
const LIB_DIR = PROJECT_PATH + '/lib';
|
|
|
|
it('moves file, updates tabs, and returns true', async () => {
|
|
const oldPath = SRC_DIR + '/utils.ts';
|
|
const newPath = LIB_DIR + '/utils.ts';
|
|
mockEditorAPI.moveFile.mockResolvedValue({ newPath });
|
|
mockEditorAPI.readDir.mockResolvedValue(makeDirResult([]));
|
|
|
|
store.getState().openFile(oldPath);
|
|
store.setState({
|
|
editorProjectPath: PROJECT_PATH,
|
|
editorFileTree: [makeEntry('src', 'directory'), makeEntry('lib', 'directory')],
|
|
});
|
|
|
|
const result = await store.getState().moveFileInTree(oldPath, LIB_DIR);
|
|
|
|
expect(result).toBe(true);
|
|
expect(mockEditorAPI.moveFile).toHaveBeenCalledWith(oldPath, LIB_DIR);
|
|
|
|
// Tab should be remapped to new path
|
|
const tabs = store.getState().editorOpenTabs;
|
|
expect(tabs).toHaveLength(1);
|
|
expect(tabs[0].filePath).toBe(newPath);
|
|
expect(tabs[0].id).toBe(newPath);
|
|
expect(tabs[0].fileName).toBe('utils.ts');
|
|
});
|
|
|
|
it('remaps activeTabId when moved file is active', async () => {
|
|
const oldPath = SRC_DIR + '/index.ts';
|
|
const newPath = LIB_DIR + '/index.ts';
|
|
mockEditorAPI.moveFile.mockResolvedValue({ newPath });
|
|
mockEditorAPI.readDir.mockResolvedValue(makeDirResult([]));
|
|
|
|
store.getState().openFile(oldPath);
|
|
store.setState({ editorProjectPath: PROJECT_PATH });
|
|
|
|
await store.getState().moveFileInTree(oldPath, LIB_DIR);
|
|
|
|
expect(store.getState().editorActiveTabId).toBe(newPath);
|
|
});
|
|
|
|
it('remaps modifiedFiles and fileMtimes', async () => {
|
|
const oldPath = SRC_DIR + '/dirty.ts';
|
|
const newPath = LIB_DIR + '/dirty.ts';
|
|
mockEditorAPI.moveFile.mockResolvedValue({ newPath });
|
|
mockEditorAPI.readDir.mockResolvedValue(makeDirResult([]));
|
|
|
|
store.setState({
|
|
editorProjectPath: PROJECT_PATH,
|
|
editorModifiedFiles: { [oldPath]: true },
|
|
editorFileMtimes: { [oldPath]: 123456 },
|
|
});
|
|
|
|
await store.getState().moveFileInTree(oldPath, LIB_DIR);
|
|
|
|
expect(store.getState().editorModifiedFiles[newPath]).toBe(true);
|
|
expect(store.getState().editorModifiedFiles[oldPath]).toBeUndefined();
|
|
expect(store.getState().editorFileMtimes[newPath]).toBe(123456);
|
|
expect(store.getState().editorFileMtimes[oldPath]).toBeUndefined();
|
|
});
|
|
|
|
it('handles directory move (prefix remapping of nested tabs)', async () => {
|
|
const oldDir = SRC_DIR + '/components';
|
|
const newDir = LIB_DIR + '/components';
|
|
const oldFilePath = oldDir + '/Button.tsx';
|
|
const newFilePath = newDir + '/Button.tsx';
|
|
mockEditorAPI.moveFile.mockResolvedValue({ newPath: newDir });
|
|
mockEditorAPI.readDir.mockResolvedValue(makeDirResult([]));
|
|
|
|
store.getState().openFile(oldFilePath);
|
|
store.setState({
|
|
editorProjectPath: PROJECT_PATH,
|
|
editorModifiedFiles: { [oldFilePath]: true },
|
|
editorExpandedDirs: { [oldDir]: true },
|
|
});
|
|
|
|
await store.getState().moveFileInTree(oldDir, LIB_DIR);
|
|
|
|
// Tab should be remapped
|
|
const tabs = store.getState().editorOpenTabs;
|
|
expect(tabs).toHaveLength(1);
|
|
expect(tabs[0].filePath).toBe(newFilePath);
|
|
|
|
// Modified files remapped
|
|
expect(store.getState().editorModifiedFiles[newFilePath]).toBe(true);
|
|
expect(store.getState().editorModifiedFiles[oldFilePath]).toBeUndefined();
|
|
|
|
// Expanded dirs remapped
|
|
expect(store.getState().editorExpandedDirs[newDir]).toBe(true);
|
|
expect(store.getState().editorExpandedDirs[oldDir]).toBeUndefined();
|
|
});
|
|
|
|
it('blocks during save', async () => {
|
|
const filePath = SRC_DIR + '/saving.ts';
|
|
store.setState({
|
|
editorProjectPath: PROJECT_PATH,
|
|
editorSaving: { [filePath]: true },
|
|
});
|
|
|
|
const result = await store.getState().moveFileInTree(filePath, LIB_DIR);
|
|
|
|
expect(result).toBe(false);
|
|
expect(mockEditorAPI.moveFile).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('returns false on API error', async () => {
|
|
const filePath = SRC_DIR + '/index.ts';
|
|
mockEditorAPI.moveFile.mockRejectedValue(new Error('Permission denied'));
|
|
|
|
store.setState({ editorProjectPath: PROJECT_PATH });
|
|
|
|
const result = await store.getState().moveFileInTree(filePath, LIB_DIR);
|
|
|
|
expect(result).toBe(false);
|
|
});
|
|
|
|
it('calls editorBridge.remapState for affected files', async () => {
|
|
const oldPath = SRC_DIR + '/bridge.ts';
|
|
const newPath = LIB_DIR + '/bridge.ts';
|
|
mockEditorAPI.moveFile.mockResolvedValue({ newPath });
|
|
mockEditorAPI.readDir.mockResolvedValue(makeDirResult([]));
|
|
|
|
store.getState().openFile(oldPath);
|
|
store.setState({ editorProjectPath: PROJECT_PATH });
|
|
|
|
await store.getState().moveFileInTree(oldPath, LIB_DIR);
|
|
|
|
expect(mockBridge.remapState).toHaveBeenCalledWith(oldPath, newPath);
|
|
});
|
|
});
|
|
});
|