diff --git a/src/components/WikilinkChatInput.test.tsx b/src/components/WikilinkChatInput.test.tsx new file mode 100644 index 00000000..f6ede97a --- /dev/null +++ b/src/components/WikilinkChatInput.test.tsx @@ -0,0 +1,283 @@ +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() + }) +}) diff --git a/src/utils/ai-context.test.ts b/src/utils/ai-context.test.ts index d9d37d32..d23cffc5 100644 --- a/src/utils/ai-context.test.ts +++ b/src/utils/ai-context.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { resolveTarget, collectLinkedEntries, buildContextualPrompt } from './ai-context' +import { resolveTarget, collectLinkedEntries, buildContextualPrompt, buildContextSnapshot } from './ai-context' import type { VaultEntry } from '../types' const makeEntry = (overrides: Partial = {}): VaultEntry => ({ @@ -183,3 +183,128 @@ describe('buildContextualPrompt', () => { expect(prompt).toContain('AI assistant integrated into Laputa') }) }) + +describe('buildContextSnapshot', () => { + const active = makeEntry({ path: '/vault/a.md', title: 'Alpha', isA: 'Project', status: 'active', owner: 'Alice' }) + const entries = [ + active, + makeEntry({ path: '/vault/b.md', title: 'Beta', isA: 'Person' }), + makeEntry({ path: '/vault/c.md', title: 'Gamma', isA: 'Note' }), + ] + const allContent: Record = { + '/vault/a.md': '# Alpha\nProject content.', + '/vault/b.md': '# Beta\nPerson content.', + '/vault/c.md': '# Gamma\nNote content.', + } + + it('includes activeNote with body and frontmatter', () => { + const result = buildContextSnapshot({ activeEntry: active, allContent, entries }) + expect(result).toContain('Alpha') + expect(result).toContain('Project content.') + expect(result).toContain('"type": "Project"') + expect(result).toContain('"status": "active"') + expect(result).toContain('"owner": "Alice"') + }) + + it('includes system preamble', () => { + const result = buildContextSnapshot({ activeEntry: active, allContent, entries }) + expect(result).toContain('AI assistant integrated into Laputa') + expect(result).toContain('Context Snapshot') + }) + + it('includes vault summary with types and totalNotes', () => { + const result = buildContextSnapshot({ activeEntry: active, allContent, entries }) + const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0]) + expect(json.vault.totalNotes).toBe(3) + expect(json.vault.types).toContain('Project') + expect(json.vault.types).toContain('Person') + expect(json.vault.types).toContain('Note') + }) + + it('includes openTabs excluding active note', () => { + const tab = makeEntry({ path: '/vault/b.md', title: 'Beta', isA: 'Person' }) + const result = buildContextSnapshot({ + activeEntry: active, allContent, entries, + openTabs: [active, tab], + }) + const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0]) + expect(json.openTabs).toHaveLength(1) + expect(json.openTabs[0].title).toBe('Beta') + }) + + it('omits openTabs when none besides active', () => { + const result = buildContextSnapshot({ + activeEntry: active, allContent, entries, + openTabs: [active], + }) + const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0]) + expect(json.openTabs).toBeUndefined() + }) + + it('includes noteListFilter when present', () => { + const result = buildContextSnapshot({ + activeEntry: active, allContent, entries, + noteListFilter: { type: 'Project', query: 'search' }, + }) + const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0]) + expect(json.noteListFilter.type).toBe('Project') + expect(json.noteListFilter.query).toBe('search') + }) + + it('omits noteListFilter when empty', () => { + const result = buildContextSnapshot({ + activeEntry: active, allContent, entries, + noteListFilter: { type: null, query: '' }, + }) + const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0]) + expect(json.noteListFilter).toBeUndefined() + }) + + it('includes referencedNotes with bodies', () => { + const result = buildContextSnapshot({ + activeEntry: active, allContent, entries, + references: [ + { title: 'Beta', path: '/vault/b.md', type: 'Person' }, + { title: 'Gamma', path: '/vault/c.md', type: null }, + ], + }) + const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0]) + expect(json.referencedNotes).toHaveLength(2) + expect(json.referencedNotes[0].title).toBe('Beta') + expect(json.referencedNotes[0].body).toContain('Person content.') + expect(json.referencedNotes[1].type).toBe('Note') // null fallback + }) + + it('filters out references with no content', () => { + const result = buildContextSnapshot({ + activeEntry: active, allContent, entries, + references: [ + { title: 'Beta', path: '/vault/b.md', type: 'Person' }, + { title: 'Missing', path: '/vault/missing.md', type: null }, + ], + }) + const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0]) + expect(json.referencedNotes).toHaveLength(1) + expect(json.referencedNotes[0].title).toBe('Beta') + }) + + it('omits referencedNotes when no references provided', () => { + const result = buildContextSnapshot({ activeEntry: active, allContent, entries }) + const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0]) + expect(json.referencedNotes).toBeUndefined() + }) + + it('includes belongsTo and relatedTo in frontmatter', () => { + const entryWithRels = makeEntry({ + path: '/vault/a.md', title: 'Alpha', + belongsTo: ['[[Parent]]'], + relatedTo: ['[[Sibling]]'], + relationships: { people: ['[[Alice]]'] }, + }) + const result = buildContextSnapshot({ activeEntry: entryWithRels, allContent, entries }) + const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0]) + expect(json.activeNote.frontmatter.belongsTo).toEqual(['[[Parent]]']) + expect(json.activeNote.frontmatter.relatedTo).toEqual(['[[Sibling]]']) + expect(json.activeNote.frontmatter.relationships).toEqual({ people: ['[[Alice]]'] }) + }) +})