diff --git a/web/app/components/workflow/skill/file-tree/tree-edit-input.spec.tsx b/web/app/components/workflow/skill/file-tree/tree-edit-input.spec.tsx new file mode 100644 index 0000000000..868f6aa078 --- /dev/null +++ b/web/app/components/workflow/skill/file-tree/tree-edit-input.spec.tsx @@ -0,0 +1,201 @@ +import type { NodeApi } from 'react-arborist' +import type { TreeNodeData } from '../type' +import { fireEvent, render, screen } from '@testing-library/react' +import TreeEditInput from './tree-edit-input' + +type MockNodeApi = Pick, 'data' | 'reset' | 'submit'> + +function createMockNode(overrides: Partial> = {}): MockNodeApi { + const nodeType = overrides.node_type ?? 'file' + return { + data: { + id: 'node-1', + name: overrides.name ?? 'skill.md', + node_type: nodeType, + path: `/${overrides.name ?? 'skill.md'}`, + extension: nodeType === 'folder' ? '' : 'md', + size: 0, + children: [], + }, + reset: vi.fn(), + submit: vi.fn(), + } +} + +function renderInput(node: MockNodeApi) { + return render(} />) +} + +describe('TreeEditInput', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render an input with the node name as default value', () => { + const node = createMockNode({ name: 'readme.md' }) + renderInput(node) + + const input = screen.getByRole('textbox') + expect(input).toBeInTheDocument() + expect(input).toHaveValue('readme.md') + }) + + it('should show file placeholder for file nodes', () => { + const node = createMockNode({ node_type: 'file' }) + renderInput(node) + + expect(screen.getByPlaceholderText(/fileNamePlaceholder/)).toBeInTheDocument() + }) + + it('should show folder placeholder for folder nodes', () => { + const node = createMockNode({ node_type: 'folder', name: 'src' }) + renderInput(node) + + expect(screen.getByPlaceholderText(/folderNamePlaceholder/)).toBeInTheDocument() + }) + + it('should have correct aria-label for file nodes', () => { + const node = createMockNode({ node_type: 'file' }) + renderInput(node) + + expect(screen.getByLabelText(/renameFileInput/)).toBeInTheDocument() + }) + + it('should have correct aria-label for folder nodes', () => { + const node = createMockNode({ node_type: 'folder', name: 'src' }) + renderInput(node) + + expect(screen.getByLabelText(/renameFolderInput/)).toBeInTheDocument() + }) + }) + + describe('Auto-focus and selection', () => { + it('should focus the input on mount', () => { + const node = createMockNode({ name: 'index.ts' }) + renderInput(node) + + expect(screen.getByRole('textbox')).toHaveFocus() + }) + + it('should select only the stem for a file with extension', () => { + const node = createMockNode({ name: 'skill.md' }) + renderInput(node) + + const input = screen.getByRole('textbox') as HTMLInputElement + expect(input.selectionStart).toBe(0) + expect(input.selectionEnd).toBe(5) // "skill" = 5 chars + }) + + it('should select only the last segment for multiple dots', () => { + const node = createMockNode({ name: 'index.test.ts' }) + renderInput(node) + + const input = screen.getByRole('textbox') as HTMLInputElement + expect(input.selectionStart).toBe(0) + expect(input.selectionEnd).toBe(10) // "index.test" = 10 chars + }) + + it('should select all text for a file without extension', () => { + const node = createMockNode({ name: 'Makefile' }) + renderInput(node) + + const input = screen.getByRole('textbox') as HTMLInputElement + expect(input.selectionStart).toBe(0) + expect(input.selectionEnd).toBe(8) // "Makefile" = 8 chars + }) + + it('should select all text for a dotfile', () => { + const node = createMockNode({ name: '.gitignore' }) + renderInput(node) + + const input = screen.getByRole('textbox') as HTMLInputElement + expect(input.selectionStart).toBe(0) + expect(input.selectionEnd).toBe(10) // ".gitignore" = 10, lastIndexOf('.') is 0 which is not > 0 + }) + + it('should select all text for a folder', () => { + const node = createMockNode({ node_type: 'folder', name: 'src.backup' }) + renderInput(node) + + const input = screen.getByRole('textbox') as HTMLInputElement + expect(input.selectionStart).toBe(0) + expect(input.selectionEnd).toBe(10) // "src.backup" = 10 chars, folder always selects all + }) + }) + + describe('Keyboard interactions', () => { + it('should call node.submit with input value on Enter', () => { + const node = createMockNode({ name: 'old.txt' }) + renderInput(node) + + const input = screen.getByRole('textbox') + fireEvent.change(input, { target: { value: 'new.txt' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + + expect(node.submit).toHaveBeenCalledWith('new.txt') + }) + + it('should call node.submit with empty string when input is cleared', () => { + const node = createMockNode({ name: 'old.txt' }) + renderInput(node) + + const input = screen.getByRole('textbox') + fireEvent.change(input, { target: { value: '' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + + expect(node.submit).toHaveBeenCalledWith('') + }) + + it('should call node.reset on Escape', () => { + const node = createMockNode() + renderInput(node) + + fireEvent.keyDown(screen.getByRole('textbox'), { key: 'Escape' }) + + expect(node.reset).toHaveBeenCalledTimes(1) + expect(node.submit).not.toHaveBeenCalled() + }) + + it('should stop propagation for all key events', () => { + const node = createMockNode() + render( +
{ throw new Error('should not propagate') }}> + } /> +
, + ) + + const input = screen.getByRole('textbox') + fireEvent.keyDown(input, { key: 'a' }) + fireEvent.keyDown(input, { key: 'Enter' }) + fireEvent.keyDown(input, { key: 'Escape' }) + }) + }) + + describe('Blur', () => { + it('should call node.reset on blur', () => { + const node = createMockNode() + renderInput(node) + + fireEvent.blur(screen.getByRole('textbox')) + + expect(node.reset).toHaveBeenCalledTimes(1) + }) + }) + + describe('Click', () => { + it('should stop click propagation', () => { + const outerClick = vi.fn() + const node = createMockNode() + render( +
+ } /> +
, + ) + + fireEvent.click(screen.getByRole('textbox')) + + expect(outerClick).not.toHaveBeenCalled() + }) + }) +})