import '@testing-library/jest-dom' import { describe, expect, it, vi, beforeEach } from 'vitest' import { render, screen, fireEvent } from '@testing-library/react' import { act } from 'react' // ────────────────────────────────────────────────────────────────────────────── // Hoisted mocks (vi.hoisted runs before module evaluation) // ────────────────────────────────────────────────────────────────────────────── const { openPreviewSpy, browserOpenSpy, openTargetSpy, ensureTargetsMock } = vi.hoisted(() => { const openPreviewSpy = vi.fn().mockResolvedValue(undefined) const browserOpenSpy = vi.fn() const openTargetSpy = vi.fn().mockResolvedValue(undefined) const ensureTargetsMock = vi.fn().mockResolvedValue(undefined) return { openPreviewSpy, browserOpenSpy, openTargetSpy, ensureTargetsMock } }) // Mock openTargetStore vi.mock('../../stores/openTargetStore', () => ({ useOpenTargetStore: Object.assign( // Selector hook form: useOpenTargetStore((s) => s.xxx) (selector: (s: { targets: unknown[]; ensureTargets: () => Promise; openTarget: () => Promise }) => unknown) => selector({ targets: [{ id: 'code', kind: 'ide', label: 'VS Code', icon: '', platform: 'darwin' }], ensureTargets: ensureTargetsMock, openTarget: openTargetSpy, }), { // Static .getState() access getState: vi.fn(() => ({ targets: [{ id: 'code', kind: 'ide', label: 'VS Code', icon: '', platform: 'darwin' }], ensureTargets: ensureTargetsMock, openTarget: openTargetSpy, })), }, ), })) // Mock browserPanelStore vi.mock('../../stores/browserPanelStore', () => ({ useBrowserPanelStore: Object.assign( (selector: (s: { open: () => void }) => unknown) => selector({ open: browserOpenSpy }), { getState: vi.fn(() => ({ open: browserOpenSpy })), }, ), })) // Mock workspacePanelStore vi.mock('../../stores/workspacePanelStore', () => ({ useWorkspacePanelStore: Object.assign( (selector: (s: { openPreview: () => Promise }) => unknown) => selector({ openPreview: openPreviewSpy }), { getState: vi.fn(() => ({ openPreview: openPreviewSpy })), }, ), })) // Mock @tauri-apps/plugin-shell vi.mock('@tauri-apps/plugin-shell', () => ({ open: vi.fn().mockResolvedValue(undefined), })) // Mock desktopRuntime.getServerBaseUrl vi.mock('../../lib/desktopRuntime', () => ({ getServerBaseUrl: vi.fn(() => 'http://127.0.0.1:4321'), })) // Mock useTranslation: returns identity-ish t function vi.mock('../../i18n', () => ({ useTranslation: () => (key: string, params?: Record) => { if (params) { return Object.entries(params).reduce( (acc, [k, v]) => acc.replace(`{${k}}`, String(v)), key, ) } return key }, })) // ────────────────────────────────────────────────────────────────────────────── // Import after mocks // ────────────────────────────────────────────────────────────────────────────── import { CurrentTurnChangeCard } from './CurrentTurnChangeCard' import type { SessionTurnCheckpoint } from '../../api/sessions' // ────────────────────────────────────────────────────────────────────────────── // Helpers // ────────────────────────────────────────────────────────────────────────────── function makeCheckpoint(filesChanged: string[]): SessionTurnCheckpoint { return { code: { available: true, filesChanged, insertions: 10, deletions: 0, }, target: { targetUserMessageId: 'msg-1', userMessageIndex: 0, userMessageCount: 1, }, conversation: { messagesRemoved: 0, }, } } function renderCard(filesChanged: string[]) { const checkpoint = makeCheckpoint(filesChanged) return render( , ) } // ────────────────────────────────────────────────────────────────────────────── // Tests // ────────────────────────────────────────────────────────────────────────────── describe('CurrentTurnChangeCard – rich file row (icon / name / type)', () => { beforeEach(() => { vi.clearAllMocks() ensureTargetsMock.mockResolvedValue(undefined) openPreviewSpy.mockResolvedValue(undefined) }) it('renders the filename (not just full path) for each file', () => { renderCard(['/w/proj/README.md', '/w/proj/src/index.ts']) expect(screen.getByText('README.md')).toBeInTheDocument() expect(screen.getByText('index.ts')).toBeInTheDocument() }) it('renders the extension badge for a markdown file', () => { renderCard(['/w/proj/README.md']) // The type subtitle contains the ext in uppercase: "· MD" expect(screen.getByText(/MD/)).toBeInTheDocument() }) it('renders the extension badge for a TypeScript file', () => { renderCard(['/w/proj/src/main.ts']) expect(screen.getByText(/TS/)).toBeInTheDocument() }) it('renders the extension badge for an HTML file', () => { renderCard(['/w/proj/index.html']) expect(screen.getByText(/HTML/)).toBeInTheDocument() }) }) describe('CurrentTurnChangeCard – row opens the workspace diff', () => { beforeEach(() => { vi.clearAllMocks() ensureTargetsMock.mockResolvedValue(undefined) openPreviewSpy.mockResolvedValue(undefined) }) it('clicking a file row calls openPreview(sessionId, displayPath, "diff")', () => { renderCard(['/w/proj/src/main.ts']) const row = screen.getByRole('button', { name: /turnChangesOpenInWorkspaceAria/ }) fireEvent.click(row) // displayPath is the workDir-relative path (matches the workspace file tree) expect(openPreviewSpy).toHaveBeenCalledWith('s1', 'src/main.ts', 'diff') }) it('passes the workDir-relative displayPath (not the absolute path) to openPreview', () => { renderCard(['/w/proj/README.md']) const row = screen.getByRole('button', { name: /turnChangesOpenInWorkspaceAria/ }) fireEvent.click(row) expect(openPreviewSpy).toHaveBeenCalledWith('s1', 'README.md', 'diff') }) it('does NOT render an inline diff surface after clicking a row', () => { renderCard(['/w/proj/src/main.ts']) const row = screen.getByRole('button', { name: /turnChangesOpenInWorkspaceAria/ }) fireEvent.click(row) // No inline diff is rendered inside the card anymore — the diff opens in the // right-side workspace panel instead. expect(screen.queryByText('chat.turnChangesDiffLoading')).not.toBeInTheDocument() expect(screen.queryByText('chat.turnChangesDiffUnavailable')).not.toBeInTheDocument() // The CodeMirror diff surface (.cm-editor) is never mounted in the card. expect(document.querySelector('.cm-editor')).toBeNull() }) it('each file row exposes a single "open in workspace" button (no expand/collapse toggle)', () => { renderCard(['/w/proj/README.md', '/w/proj/src/index.ts']) expect(screen.getAllByRole('button', { name: /turnChangesOpenInWorkspaceAria/ })).toHaveLength(2) }) }) describe('CurrentTurnChangeCard – open-with buttons', () => { beforeEach(() => { vi.clearAllMocks() ensureTargetsMock.mockResolvedValue(undefined) openPreviewSpy.mockResolvedValue(undefined) }) it('renders an "open-with" button for each previewable file', () => { renderCard(['/w/proj/README.md', '/w/proj/index.html']) // aria-label is the i18n key itself (identity mock) const buttons = screen.getAllByRole('button', { name: 'openWith.title' }) expect(buttons).toHaveLength(2) }) it('does NOT render an "open-with" button for a source file (row still opens workspace)', () => { renderCard(['/w/proj/src/main.ts']) expect(screen.queryAllByRole('button', { name: 'openWith.title' })).toHaveLength(0) // source files keep their workspace-open row — only the open-with pill is dropped expect(screen.getByRole('button', { name: /turnChangesOpenInWorkspaceAria/ })).toBeInTheDocument() }) it('mixed turn: only previewable rows (md/html) get the open-with button, not .ts', () => { renderCard(['/w/proj/README.md', '/w/proj/src/main.ts', '/w/proj/index.html']) expect(screen.getAllByRole('button', { name: 'openWith.title' })).toHaveLength(2) }) it('clicking README.md open-with opens menu with workspace preview item', async () => { renderCard(['/w/proj/README.md']) const [openWithBtn] = screen.getAllByRole('button', { name: 'openWith.title' }) await act(async () => { fireEvent.click(openWithBtn!) }) // The menu should show a workspace preview item (i18n key) expect(await screen.findByText('openWith.workspacePreview')).toBeInTheDocument() }) it('clicking workspace preview item in README.md menu calls openPreview', async () => { renderCard(['/w/proj/README.md']) const [openWithBtn] = screen.getAllByRole('button', { name: 'openWith.title' }) await act(async () => { fireEvent.click(openWithBtn!) }) const previewItem = await screen.findByText('openWith.workspacePreview') await act(async () => { fireEvent.click(previewItem) }) expect(openPreviewSpy).toHaveBeenCalledWith('s1', 'README.md', 'file') }) it('clicking index.html open-with opens menu with in-app browser item', async () => { renderCard(['/w/proj/index.html']) const [openWithBtn] = screen.getAllByRole('button', { name: 'openWith.title' }) await act(async () => { fireEvent.click(openWithBtn!) }) expect(await screen.findByText('openWith.inAppBrowser')).toBeInTheDocument() }) it('ensureTargets is called when open-with button is clicked', async () => { renderCard(['/w/proj/README.md']) const [openWithBtn] = screen.getAllByRole('button', { name: 'openWith.title' }) await act(async () => { fireEvent.click(openWithBtn!) }) expect(ensureTargetsMock).toHaveBeenCalledTimes(1) }) it('open-with button does not also trigger the row workspace-open (stopPropagation)', async () => { renderCard(['/w/proj/README.md']) const [openWithBtn] = screen.getAllByRole('button', { name: 'openWith.title' }) await act(async () => { fireEvent.click(openWithBtn!) }) // The diff open (3rd arg 'diff') must not have fired from clicking the pill. expect(openPreviewSpy).not.toHaveBeenCalledWith('s1', 'README.md', 'diff') }) }) describe('CurrentTurnChangeCard – collapse long file lists', () => { beforeEach(() => { vi.clearAllMocks() ensureTargetsMock.mockResolvedValue(undefined) openPreviewSpy.mockResolvedValue(undefined) }) function makeFiles(count: number): string[] { return Array.from({ length: count }, (_, i) => `/w/proj/src/file${i + 1}.ts`) } it('does NOT render a show-more toggle with ≤5 files', () => { renderCard(makeFiles(5)) expect(screen.getAllByRole('button', { name: /turnChangesOpenInWorkspaceAria/ })).toHaveLength(5) expect(screen.queryByText('chat.turnChangesShowMore')).not.toBeInTheDocument() expect(screen.queryByText('chat.turnChangesShowLess')).not.toBeInTheDocument() }) it('with 8 files shows only 5 rows + a "show more" toggle (remaining = 3)', () => { renderCard(makeFiles(8)) // only the first 5 workspace-open rows are rendered expect(screen.getAllByRole('button', { name: /turnChangesOpenInWorkspaceAria/ })).toHaveLength(5) // the show-more toggle is present (identity-mock key). The real key carries the // remaining count via '{count}'; with the placeholder-bearing real string this // renders as "再显示 3 个文件" (8 - COLLAPSED_COUNT(5) = 3). expect(screen.getByText('chat.turnChangesShowMore')).toBeInTheDocument() // …and it is the only toggle (no "show less" while collapsed) expect(screen.queryByText('chat.turnChangesShowLess')).not.toBeInTheDocument() }) it('clicking "show more" reveals all 8 rows and shows "show less"; clicking again re-collapses', () => { renderCard(makeFiles(8)) const showMore = screen.getByText('chat.turnChangesShowMore') fireEvent.click(showMore) expect(screen.getAllByRole('button', { name: /turnChangesOpenInWorkspaceAria/ })).toHaveLength(8) const showLess = screen.getByText('chat.turnChangesShowLess') expect(showLess).toBeInTheDocument() expect(screen.queryByText('chat.turnChangesShowMore')).not.toBeInTheDocument() fireEvent.click(showLess) expect(screen.getAllByRole('button', { name: /turnChangesOpenInWorkspaceAria/ })).toHaveLength(5) expect(screen.getByText('chat.turnChangesShowMore')).toBeInTheDocument() }) })