import { render, screen, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { ReactNode } from 'react'; import type { GraphNode } from 'gitnexus-shared'; import { CodeReferencesPanel } from '../../src/components/CodeReferencesPanel'; import { BackendError, readFile } from '../../src/services/backend-client'; const fileNode: GraphNode = { id: 'File:src/foo.ts', label: 'File', properties: { name: 'foo.ts', filePath: 'src/foo.ts' }, }; // Mutable mock state: the useAppState factory closes over this object so each // test can reassign fields (e.g. currentRepo) before rendering. const appState = { graph: null, selectedNode: fileNode, codeReferences: [], removeCodeReference: vi.fn(), clearCodeReferences: vi.fn(), setSelectedNode: vi.fn(), codeReferenceFocus: null, projectName: 'reels', currentRepo: undefined as string | undefined, }; vi.mock('../../src/hooks/useAppState', () => ({ useAppState: () => appState, })); vi.mock('../../src/services/backend-client', () => ({ readFile: vi.fn(), BackendError: class BackendError extends Error { constructor( message: string, _status: number, public readonly code: string, ) { super(message); } }, })); vi.mock('react-syntax-highlighter', () => ({ Prism: ({ children }: { children?: ReactNode }) =>
{children}
, })); vi.mock('react-syntax-highlighter/dist/esm/styles/prism', () => ({ vscDarkPlus: {}, })); vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key, }), })); const citationReads = () => vi.mocked(readFile).mock.calls.filter(([, opts]) => opts && 'startLine' in (opts as object)); describe('CodeReferencesPanel repo identity (#2420)', () => { beforeEach(() => { vi.clearAllMocks(); appState.codeReferences = []; appState.selectedNode = fileNode; appState.projectName = 'reels'; appState.currentRepo = undefined; vi.mocked(readFile).mockResolvedValue({ content: 'const a = 1;', totalLines: 1 }); }); it('reads the selected file from the active repo path, not the display name', () => { appState.currentRepo = '/ws/b/reels'; appState.projectName = 'reels'; render(); expect(readFile).toHaveBeenCalledWith('src/foo.ts', { repo: '/ws/b/reels' }); }); it('falls back to the project display name when no repo path is active', () => { appState.currentRepo = undefined; appState.projectName = 'reels'; render(); expect(readFile).toHaveBeenCalledWith('src/foo.ts', { repo: 'reels' }); }); it('renders the dedicated source-unavailable state for retained indexes without a checkout', async () => { vi.mocked(readFile).mockRejectedValue( new BackendError('source unavailable', 410, 'source_unavailable'), ); render(); await waitFor(() => { expect(screen.getByText('graph:codePanel.sourceUnavailable')).toBeInTheDocument(); }); }); it('does not free replacement-batch citation ids when a cancelled repo-switch batch settles', async () => { appState.codeReferences = [ { id: 'cite-1', filePath: 'src/foo.ts', startLine: 0, endLine: 0, source: 'ai', }, ]; appState.currentRepo = '/ws/a/reels'; let releaseFirst!: (value: { content: string; startLine: number; totalLines: number }) => void; const firstCitation = new Promise<{ content: string; startLine: number; totalLines: number }>( (resolve) => { releaseFirst = resolve; }, ); vi.mocked(readFile).mockImplementation((_path, opts) => { if (opts && 'startLine' in opts) return firstCitation; return Promise.resolve({ content: 'const a = 1;', totalLines: 1 }); }); const { rerender } = render(); await waitFor(() => expect(citationReads()).toHaveLength(1)); vi.mocked(readFile).mockImplementation((_path, opts) => { if (opts && 'startLine' in opts) { return Promise.resolve({ content: 'const a = 1;', startLine: 0, totalLines: 1 }); } return Promise.resolve({ content: 'const a = 1;', totalLines: 1 }); }); appState.currentRepo = '/ws/b/reels'; rerender(); await waitFor(() => expect(citationReads()).toHaveLength(2)); expect(citationReads()[1]?.[1]).toEqual(expect.objectContaining({ repo: '/ws/b/reels' })); releaseFirst({ content: 'stale', startLine: 0, totalLines: 1 }); await new Promise((r) => setTimeout(r, 30)); expect(citationReads()).toHaveLength(2); }); it('does not re-issue in-flight citation reads when a new reference is appended', async () => { appState.codeReferences = [ { id: 'cite-1', filePath: 'src/foo.ts', startLine: 0, endLine: 0, source: 'ai', }, ]; appState.currentRepo = '/ws/a/reels'; let releaseFirst!: (value: { content: string; startLine: number; totalLines: number }) => void; const firstCitation = new Promise<{ content: string; startLine: number; totalLines: number }>( (resolve) => { releaseFirst = resolve; }, ); vi.mocked(readFile).mockImplementation((filePath, opts) => { if (opts && 'startLine' in opts) { if (filePath === 'src/foo.ts') return firstCitation; return Promise.resolve({ content: 'const b = 2;', startLine: 0, totalLines: 1 }); } return Promise.resolve({ content: 'const a = 1;', totalLines: 1 }); }); const { rerender } = render(); await waitFor(() => expect(citationReads()).toHaveLength(1)); appState.codeReferences = [ ...appState.codeReferences, { id: 'cite-2', filePath: 'src/bar.ts', startLine: 0, endLine: 0, source: 'ai', }, ]; rerender(); await waitFor(() => expect(citationReads()).toHaveLength(2)); expect(citationReads()[1]?.[0]).toBe('src/bar.ts'); releaseFirst({ content: 'first', startLine: 0, totalLines: 1 }); await new Promise((r) => setTimeout(r, 30)); expect(citationReads()).toHaveLength(2); }); it('prunes cached citation snippets when AI references are cleared', async () => { appState.codeReferences = [ { id: 'cite-1', filePath: 'src/foo.ts', startLine: 0, endLine: 0, source: 'ai', }, ]; appState.currentRepo = '/ws/a/reels'; vi.mocked(readFile).mockImplementation((_path, opts) => { if (opts && 'startLine' in opts) { return Promise.resolve({ content: 'FIRST_SNIPPET', startLine: 0, totalLines: 1 }); } return Promise.resolve({ content: 'const a = 1;', totalLines: 1 }); }); const { rerender } = render(); await waitFor(() => expect(screen.getByText('FIRST_SNIPPET')).toBeInTheDocument()); appState.codeReferences = []; rerender(); vi.mocked(readFile).mockImplementation((_path, opts) => { if (opts && 'startLine' in opts) { return Promise.resolve({ content: 'SECOND_SNIPPET', startLine: 0, totalLines: 1 }); } return Promise.resolve({ content: 'const a = 1;', totalLines: 1 }); }); appState.codeReferences = [ { id: 'cite-1', filePath: 'src/foo.ts', startLine: 0, endLine: 0, source: 'ai', }, ]; rerender(); await waitFor(() => expect(screen.getByText('SECOND_SNIPPET')).toBeInTheDocument()); expect(screen.queryByText('FIRST_SNIPPET')).not.toBeInTheDocument(); expect(citationReads().length).toBeGreaterThanOrEqual(2); }); });