cc-haha/desktop/src/components/chat/CurrentTurnChangeCard.test.tsx
程序员阿江(Relakkes) f17b0828a0 feat(desktop): change-card row opens the workspace diff instead of an inline diff
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 17:40:46 +08:00

333 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<void>; openTarget: () => Promise<void> }) => 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<void> }) => 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<string, string | number>) => {
if (params) {
return Object.entries(params).reduce<string>(
(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(
<CurrentTurnChangeCard
sessionId="s1"
checkpoint={checkpoint}
workDir="/w/proj"
error={null}
isUndoing={false}
isLatest={true}
onUndo={vi.fn()}
/>,
)
}
// ──────────────────────────────────────────────────────────────────────────────
// 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()
})
})