agent-ecosystem/test/renderer/components/team/editor/EditorSelectionMenu.test.ts
iliya 5b0c7d13fc feat: add project editor with drag & drop file management
- Backend: ProjectFileService with file CRUD, search, git status, file watcher
- IPC: 12 editor channels with security validation and path containment
- Store: editorSlice with multi-tab management, draft persistence, conflict detection
- UI: CodeMirror 6 editor, file tree with DnD, search-in-files, context menus
- Move: fs.rename with EXDEV fallback, full path remapping across all caches
- Tests: comprehensive coverage for services, IPC handlers, store, and utilities
2026-02-28 23:40:41 +02:00

229 lines
7.8 KiB
TypeScript

/**
* Unit tests for EditorSelectionMenu positioning logic
* and buildSelectionAction helper.
*
* Since @testing-library/react is not available in this project,
* we test the positioning logic and the real buildSelectionAction directly.
*/
import { describe, expect, it } from 'vitest';
import { buildSelectionAction, getCodeFenceLanguage } from '@renderer/utils/buildSelectionAction';
import type { EditorSelectionInfo } from '@shared/types/editor';
// ---------------------------------------------------------------------------
// buildSelectionAction (real import, not a copy)
// ---------------------------------------------------------------------------
describe('buildSelectionAction', () => {
const baseInfo: EditorSelectionInfo = {
text: 'const x = 42;',
filePath: '/project/src/main.ts',
fromLine: 10,
toLine: 10,
screenRect: { top: 100, right: 200, bottom: 120 },
};
it('builds sendMessage action with code fence', () => {
const action = buildSelectionAction('sendMessage', baseInfo);
expect(action.type).toBe('sendMessage');
expect(action.filePath).toBe('/project/src/main.ts');
expect(action.fromLine).toBe(10);
expect(action.toLine).toBe(10);
expect(action.selectedText).toBe('const x = 42;');
expect(action.formattedContext).toBe(
'**main.ts** (line 10):\n```typescript\nconst x = 42;\n```'
);
});
it('builds createTask action', () => {
const action = buildSelectionAction('createTask', baseInfo);
expect(action.type).toBe('createTask');
expect(action.formattedContext).toContain('```typescript');
});
it('formats multi-line selection range', () => {
const info = { ...baseInfo, fromLine: 5, toLine: 15 };
const action = buildSelectionAction('sendMessage', info);
expect(action.formattedContext).toContain('lines 5-15');
});
it('detects language from file extension', () => {
const pyInfo = { ...baseInfo, filePath: '/project/script.py' };
const action = buildSelectionAction('sendMessage', pyInfo);
expect(action.formattedContext).toContain('```python');
expect(action.formattedContext).toContain('**script.py**');
});
it('handles unknown file extensions gracefully', () => {
const unknownInfo = { ...baseInfo, filePath: '/project/data.xyz' };
const action = buildSelectionAction('sendMessage', unknownInfo);
// Empty language string → plain code block
expect(action.formattedContext).toContain('```\n');
});
});
// ---------------------------------------------------------------------------
// getCodeFenceLanguage
// ---------------------------------------------------------------------------
describe('getCodeFenceLanguage', () => {
it('maps common extensions to lowercase code fence identifiers', () => {
expect(getCodeFenceLanguage('app.ts')).toBe('typescript');
expect(getCodeFenceLanguage('component.tsx')).toBe('tsx');
expect(getCodeFenceLanguage('index.js')).toBe('javascript');
expect(getCodeFenceLanguage('main.py')).toBe('python');
expect(getCodeFenceLanguage('lib.rs')).toBe('rust');
expect(getCodeFenceLanguage('main.go')).toBe('go');
expect(getCodeFenceLanguage('style.css')).toBe('css');
expect(getCodeFenceLanguage('page.html')).toBe('html');
expect(getCodeFenceLanguage('config.yaml')).toBe('yaml');
expect(getCodeFenceLanguage('config.yml')).toBe('yaml');
expect(getCodeFenceLanguage('script.sh')).toBe('bash');
});
it('returns empty string for unknown extensions', () => {
expect(getCodeFenceLanguage('data.xyz')).toBe('');
expect(getCodeFenceLanguage('file')).toBe('');
});
it('is case-insensitive for extensions', () => {
expect(getCodeFenceLanguage('App.TS')).toBe('typescript');
expect(getCodeFenceLanguage('Main.PY')).toBe('python');
});
});
// ---------------------------------------------------------------------------
// EditorSelectionInfo type shape
// ---------------------------------------------------------------------------
describe('EditorSelectionInfo type', () => {
it('has expected shape', () => {
const info: EditorSelectionInfo = {
text: 'hello',
filePath: '/a/b.ts',
fromLine: 1,
toLine: 1,
screenRect: { top: 0, right: 0, bottom: 0 },
};
expect(info.text).toBe('hello');
expect(info.screenRect).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// Menu positioning logic (mirrors EditorSelectionMenu.tsx)
// ---------------------------------------------------------------------------
describe('Menu positioning logic', () => {
const MENU_GAP = 8;
const MENU_WIDTH = 68;
const MENU_HEIGHT = 32;
function computeMenuPosition(
info: EditorSelectionInfo,
containerRect: { top: number; left: number; width: number; height: number }
): { top: number; left: number } | null {
// Check visibility
const selBottomInContainer = info.screenRect.bottom - containerRect.top;
const selTopInContainer = info.screenRect.top - containerRect.top;
if (selBottomInContainer < 0 || selTopInContainer > containerRect.height) {
return null; // hidden
}
const rawTop = info.screenRect.top - containerRect.top;
const rawLeft = info.screenRect.right - containerRect.left + MENU_GAP;
const top = Math.max(0, Math.min(rawTop, containerRect.height - MENU_HEIGHT));
const left =
rawLeft + MENU_WIDTH > containerRect.width
? info.screenRect.right - containerRect.left - MENU_WIDTH - MENU_GAP
: rawLeft;
return { top, left: Math.max(0, left) };
}
it('positions menu to the right of selection', () => {
const info: EditorSelectionInfo = {
text: 'x',
filePath: '/a.ts',
fromLine: 1,
toLine: 1,
screenRect: { top: 100, right: 200, bottom: 120 },
};
const container = { top: 50, left: 50, width: 600, height: 400 };
const pos = computeMenuPosition(info, container);
expect(pos).not.toBeNull();
// top = 100 - 50 = 50
expect(pos!.top).toBe(50);
// left = 200 - 50 + 8 = 158
expect(pos!.left).toBe(158);
});
it('returns null when selection is above container', () => {
const info: EditorSelectionInfo = {
text: 'x',
filePath: '/a.ts',
fromLine: 1,
toLine: 1,
screenRect: { top: 10, right: 200, bottom: 30 },
};
const container = { top: 50, left: 50, width: 600, height: 400 };
expect(computeMenuPosition(info, container)).toBeNull();
});
it('returns null when selection is below container', () => {
const info: EditorSelectionInfo = {
text: 'x',
filePath: '/a.ts',
fromLine: 1,
toLine: 1,
screenRect: { top: 500, right: 200, bottom: 520 },
};
const container = { top: 50, left: 50, width: 600, height: 400 };
expect(computeMenuPosition(info, container)).toBeNull();
});
it('clamps top to prevent overflow below container', () => {
const info: EditorSelectionInfo = {
text: 'x',
filePath: '/a.ts',
fromLine: 1,
toLine: 1,
screenRect: { top: 430, right: 200, bottom: 445 },
};
const container = { top: 50, left: 50, width: 600, height: 400 };
const pos = computeMenuPosition(info, container);
expect(pos).not.toBeNull();
// rawTop = 430-50 = 380, max = 400-32 = 368 → clamped to 368
expect(pos!.top).toBe(368);
});
it('flips menu to left when it would overflow right', () => {
const info: EditorSelectionInfo = {
text: 'x',
filePath: '/a.ts',
fromLine: 1,
toLine: 1,
screenRect: { top: 100, right: 620, bottom: 120 },
};
const container = { top: 50, left: 50, width: 600, height: 400 };
const pos = computeMenuPosition(info, container);
expect(pos).not.toBeNull();
// rawLeft = 620-50+8 = 578, 578+68=646 > 600 → flip
// flipped = 620-50-68-8 = 494
expect(pos!.left).toBe(494);
});
});