import { useState } from 'react' import { describe, it, expect, vi } from 'vitest' import { render, screen, fireEvent, act } from '@testing-library/react' import { WikilinkChatInput } from './WikilinkChatInput' import type { VaultEntry } from '../types' const makeEntry = (overrides: Partial = {}): VaultEntry => ({ path: '/vault/note/test.md', filename: 'test.md', title: 'Test Note', isA: 'Note', aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null, cadence: null, archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000, createdAt: 1700000000, fileSize: 100, snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, outgoingLinks: [], ...overrides, }) const entries: VaultEntry[] = [ makeEntry({ path: '/vault/alpha.md', title: 'Alpha', filename: 'alpha.md', isA: 'Project' }), makeEntry({ path: '/vault/beta.md', title: 'Beta', filename: 'beta.md', isA: 'Person' }), makeEntry({ path: '/vault/gamma.md', title: 'Gamma', filename: 'gamma.md' }), makeEntry({ path: '/vault/trashed.md', title: 'Trashed', filename: 'trashed.md', trashed: true }), makeEntry({ path: '/vault/archived.md', title: 'Archived', filename: 'archived.md', archived: true }), ] /** Wrapper that manages controlled value state so onChange/selectSuggestion work correctly. */ function Controlled({ onSend, ...props }: { entries: VaultEntry[] onSend?: (text: string, refs: Array<{ title: string; path: string; type: string | null }>) => void disabled?: boolean placeholder?: string }) { const [value, setValue] = useState('') return ( ) } /** Helper: type in the input and wait for debounce to flush. */ async function typeAndWait(text: string) { fireEvent.change(screen.getByTestId('agent-input'), { target: { value: text, selectionStart: text.length }, }) await act(() => { vi.advanceTimersByTime(150) }) } describe('WikilinkChatInput', () => { it('renders input with placeholder', () => { render() const input = screen.getByTestId('agent-input') as HTMLInputElement expect(input.placeholder).toBe('Ask something...') }) it('calls onChange when typing', () => { const onChange = vi.fn() render( , ) fireEvent.change(screen.getByTestId('agent-input'), { target: { value: 'hello' } }) expect(onChange).toHaveBeenCalledWith('hello') }) it('shows wikilink menu when [[ is typed', async () => { vi.useFakeTimers() render() await typeAndWait('[[a') expect(screen.getByTestId('wikilink-menu')).toBeTruthy() vi.useRealTimers() }) it('filters suggestions matching query', async () => { vi.useFakeTimers() render() await typeAndWait('[[alp') const menu = screen.getByTestId('wikilink-menu') expect(menu.textContent).toContain('Alpha') expect(menu.textContent).not.toContain('Beta') vi.useRealTimers() }) it('excludes trashed and archived entries from suggestions', async () => { vi.useFakeTimers() render() await typeAndWait('[[t') const menu = screen.queryByTestId('wikilink-menu') if (menu) { expect(menu.textContent).not.toContain('Trashed') expect(menu.textContent).not.toContain('Archived') } vi.useRealTimers() }) it('selects suggestion on click and creates pill', async () => { vi.useFakeTimers() render() await typeAndWait('[[a') const menu = screen.getByTestId('wikilink-menu') const items = menu.querySelectorAll('[class*="cursor-pointer"]') expect(items.length).toBeGreaterThan(0) fireEvent.click(items[0]) const pills = screen.queryAllByTestId('reference-pill') expect(pills.length).toBe(1) vi.useRealTimers() }) it('does not duplicate pills for same note', async () => { vi.useFakeTimers() render() // First selection await typeAndWait('[[alp') fireEvent.click(screen.getByTestId('wikilink-menu').querySelectorAll('[class*="cursor-pointer"]')[0]) expect(screen.queryAllByTestId('reference-pill').length).toBe(1) // Second selection of same note await typeAndWait('[[alp') fireEvent.click(screen.getByTestId('wikilink-menu').querySelectorAll('[class*="cursor-pointer"]')[0]) expect(screen.queryAllByTestId('reference-pill').length).toBe(1) // No duplicate vi.useRealTimers() }) it('removes pill when x button is clicked', async () => { vi.useFakeTimers() render() await typeAndWait('[[alp') fireEvent.click(screen.getByTestId('wikilink-menu').querySelectorAll('[class*="cursor-pointer"]')[0]) expect(screen.queryAllByTestId('reference-pill').length).toBe(1) const pill = screen.getByTestId('reference-pill') fireEvent.click(pill.querySelector('button')!) expect(screen.queryAllByTestId('reference-pill').length).toBe(0) vi.useRealTimers() }) it('navigates suggestions with keyboard', async () => { vi.useFakeTimers() render() await typeAndWait('[[a') const input = screen.getByTestId('agent-input') fireEvent.keyDown(input, { key: 'ArrowDown' }) fireEvent.keyDown(input, { key: 'ArrowUp' }) // Escape closes menu fireEvent.keyDown(input, { key: 'Escape' }) expect(screen.queryByTestId('wikilink-menu')).toBeNull() vi.useRealTimers() }) it('selects suggestion with Enter key', async () => { vi.useFakeTimers() render() await typeAndWait('[[alp') expect(screen.getByTestId('wikilink-menu')).toBeTruthy() fireEvent.keyDown(screen.getByTestId('agent-input'), { key: 'Enter' }) expect(screen.queryAllByTestId('reference-pill').length).toBe(1) vi.useRealTimers() }) it('calls onSend with text and references on Enter without menu', async () => { vi.useFakeTimers() const onSend = vi.fn() render() // Type non-wikilink text fireEvent.change(screen.getByTestId('agent-input'), { target: { value: 'hello', selectionStart: 5 }, }) fireEvent.keyDown(screen.getByTestId('agent-input'), { key: 'Enter' }) expect(onSend).toHaveBeenCalledWith('hello', []) vi.useRealTimers() }) it('does not send on Enter+Shift', () => { const onSend = vi.fn() render( , ) fireEvent.keyDown(screen.getByTestId('agent-input'), { key: 'Enter', shiftKey: true }) expect(onSend).not.toHaveBeenCalled() }) it('does not send on Enter when input is empty', () => { const onSend = vi.fn() render( , ) fireEvent.keyDown(screen.getByTestId('agent-input'), { key: 'Enter' }) expect(onSend).not.toHaveBeenCalled() }) it('disables input when disabled prop is true', () => { render() expect((screen.getByTestId('agent-input') as HTMLInputElement).disabled).toBe(true) }) it('closes menu when ]] is typed', async () => { vi.useFakeTimers() render() await typeAndWait('[[alp') expect(screen.getByTestId('wikilink-menu')).toBeTruthy() // Type ]] to close fireEvent.change(screen.getByTestId('agent-input'), { target: { value: '[[alp]]', selectionStart: 7 }, }) expect(screen.queryByTestId('wikilink-menu')).toBeNull() vi.useRealTimers() }) it('shows type badge for non-Note types in suggestions', async () => { vi.useFakeTimers() render() await typeAndWait('[[alp') const menu = screen.getByTestId('wikilink-menu') expect(menu.textContent).toContain('Project') vi.useRealTimers() }) it('matches by alias', async () => { vi.useFakeTimers() const entriesWithAlias = [ ...entries, makeEntry({ path: '/vault/delta.md', title: 'Delta', filename: 'delta.md', aliases: ['DLT'] }), ] render() await typeAndWait('[[DLT') const menu = screen.getByTestId('wikilink-menu') expect(menu.textContent).toContain('Delta') vi.useRealTimers() }) it('sends references with onSend after selecting pills', async () => { vi.useFakeTimers() const onSend = vi.fn() render() // Select a pill first await typeAndWait('[[alp') fireEvent.click(screen.getByTestId('wikilink-menu').querySelectorAll('[class*="cursor-pointer"]')[0]) expect(screen.queryAllByTestId('reference-pill').length).toBe(1) // Type a message and send fireEvent.change(screen.getByTestId('agent-input'), { target: { value: 'tell me about it', selectionStart: 16 }, }) fireEvent.keyDown(screen.getByTestId('agent-input'), { key: 'Enter' }) expect(onSend).toHaveBeenCalledOnce() const [text, refs] = onSend.mock.calls[0] expect(text).toBe('tell me about it') expect(refs).toHaveLength(1) expect(refs[0].title).toBe('Alpha') expect(refs[0].path).toBe('/vault/alpha.md') vi.useRealTimers() }) })