mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
333 lines
14 KiB
TypeScript
333 lines
14 KiB
TypeScript
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()
|
||
})
|
||
})
|