test: 固化 80% 覆盖率 CI 门禁(统计/分支/函数/行)并补齐交互用例

This commit is contained in:
2026-08-17 18:10:59 +08:00
parent 9003e4b494
commit c95e18f66e
5 changed files with 205 additions and 27 deletions
+1 -1
View File
@@ -12,7 +12,7 @@
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
"typecheck": "npm run typecheck:node && npm run typecheck:web",
"test": "vitest run",
"test": "vitest run --coverage",
"test:watch": "vitest",
"build": "electron-vite build",
"build:win": "electron-vite build && electron-builder --win",
+69 -1
View File
@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import App from '../App'
@@ -20,6 +20,8 @@ function mockApi(overrides?: Partial<typeof window.api>): typeof window.api {
beforeEach(() => {
window.api = mockApi()
// jsdom 未实现 scrollIntoView,导航定位需要打桩
Element.prototype.scrollIntoView = vi.fn()
})
describe('App - 初始空态', () => {
@@ -57,3 +59,69 @@ describe('App - 加载文件后的非空态', () => {
expect(await screen.findByText('导出报告')).toBeInTheDocument()
})
})
describe('App - 导出报告交互', () => {
it('导出 HTML 成功后弹出保存提示(覆盖 exportReport 成功分支)', async () => {
window.api = mockApi({
saveReport: async () => ({ ok: true, path: 'C:/docs/DiffLens-report.html' })
})
render(<App />)
fireEvent.click(screen.getByText('打开左侧'))
await screen.findByText('导出报告')
fireEvent.click(screen.getByText('导出报告'))
fireEvent.click(screen.getByText('HTML 报告'))
expect(await screen.findByText(/报告已保存/)).toBeInTheDocument()
})
})
describe('App - 行右键菜单', () => {
it('右键内容行弹出菜单(覆盖 onRowContext', async () => {
render(<App />)
fireEvent.click(screen.getByText('打开左侧'))
await screen.findByText('导出报告')
// 空 vs 有内容 → 内容在左侧为删除行,contextMenu 触发左侧菜单
const line = (await screen.findAllByText(/line1/))[0]
fireEvent.contextMenu(line)
expect(await screen.findByText('复制左侧此行内容')).toBeInTheDocument()
})
})
describe('App - 拖拽导入', () => {
it('拖放文件到左侧面板加载(覆盖 dropPane', async () => {
render(<App />)
const file = new File(['drop1\ndrop2'], 'c.txt', { type: 'text/plain' })
const pane = screen.getByText(/选择左侧文件/).closest('.pane-empty') as Element
expect(pane).not.toBeNull()
fireEvent.drop(pane, { dataTransfer: { files: [file] } })
expect(await screen.findByText('导出报告')).toBeInTheDocument()
expect(screen.getAllByText('c.txt').length).toBeGreaterThan(0)
})
it('拖拽悬停/离开切换高亮样式', () => {
render(<App />)
const pane = screen.getByText(/选择左侧文件/).closest('.pane-empty') as Element
fireEvent.dragOver(pane)
expect(pane.className).toContain('dragging')
fireEvent.dragLeave(pane)
expect(pane.className).not.toContain('dragging')
})
})
describe('App - 差异导航与清空', () => {
it('点击下一处差异触发定位(覆盖 go)', async () => {
render(<App />)
fireEvent.click(screen.getByText('打开左侧'))
await screen.findByText('导出报告')
expect(screen.getByText('1 / 2')).toBeInTheDocument()
fireEvent.click(screen.getByTitle('下一处差异'))
expect(screen.getByText('2 / 2')).toBeInTheDocument()
})
it('点击清空恢复空态', async () => {
render(<App />)
fireEvent.click(screen.getByText('打开左侧'))
await screen.findByText('导出报告')
fireEvent.click(screen.getByText('清空'))
expect(await screen.findByText(/选择左侧文件/)).toBeInTheDocument()
})
})
@@ -39,4 +39,13 @@ describe('ContextMenu', () => {
fireEvent.mouseDown(backdrop as Element)
expect(onClose).toHaveBeenCalled()
})
it('右键遮罩层触发关闭', () => {
const onClose = vi.fn()
const { container } = render(
<ContextMenu x={10} y={10} items={[]} onClose={onClose} />
)
fireEvent.contextMenu(container.querySelector('.ctx-backdrop') as Element)
expect(onClose).toHaveBeenCalled()
})
})
+113 -19
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeAll } from 'vitest'
import { render, screen } from '@testing-library/react'
import { render, screen, fireEvent } from '@testing-library/react'
import DiffView from './DiffView'
import type { DiffRow } from '../diff/diffEngine'
@@ -8,8 +8,28 @@ beforeAll(() => {
Element.prototype.scrollIntoView = vi.fn()
})
function makeDiff(activeRowId: string | null): void {
const rows: DiffRow[] = [
function renderDiff(rows: DiffRow[], activeRowId: string | null = null) {
return render(
<DiffView
diff={{
rows,
summary: {
changedLines: rows.filter((r) => r.isChanged).length,
inserted: rows.filter((r) => r.rowKind === 'added').length,
deleted: rows.filter((r) => r.rowKind === 'removed').length,
modified: rows.filter((r) => r.rowKind === 'modified').length
}
}}
leftMeta={{ name: 'a.txt', encoding: 'UTF-8' }}
rightMeta={{ name: 'b.txt', encoding: 'GBK' }}
activeRowId={activeRowId}
onOpen={vi.fn()}
onRowContext={vi.fn()}
/>
)
}
const baseRows: DiffRow[] = [
{
id: 'r0',
rowKind: 'unchanged',
@@ -24,22 +44,11 @@ function makeDiff(activeRowId: string | null): void {
left: { lineNo: null, text: null, segs: null },
right: { lineNo: 2, text: 'world', segs: null }
}
]
render(
<DiffView
diff={{ rows, summary: { changedLines: 1, inserted: 1, deleted: 0, modified: 0 } }}
leftMeta={{ name: 'a.txt', encoding: 'UTF-8' }}
rightMeta={{ name: 'b.txt', encoding: 'GBK' }}
activeRowId={activeRowId}
onOpen={vi.fn()}
onRowContext={vi.fn()}
/>
)
}
]
describe('DiffView', () => {
it('渲染左右文件名与同/新增行内容', () => {
makeDiff(null)
renderDiff(baseRows)
expect(screen.getByText('a.txt')).toBeInTheDocument()
expect(screen.getByText('b.txt')).toBeInTheDocument()
expect(screen.getAllByText('hello').length).toBeGreaterThanOrEqual(2)
@@ -47,14 +56,99 @@ describe('DiffView', () => {
})
it('渲染行号', () => {
makeDiff(null)
// 两栏各自渲染行号 1,总共应有多个
renderDiff(baseRows)
expect(screen.getAllByText('1').length).toBeGreaterThan(0)
expect(screen.getAllByText('2').length).toBeGreaterThan(0)
})
it('设置活动行时调用 scrollIntoView 定位', () => {
makeDiff('r1')
renderDiff(baseRows, 'r1')
expect(Element.prototype.scrollIntoView).toHaveBeenCalled()
})
it('词级 segs 渲染删除/新增高亮段', () => {
const modRow: DiffRow = {
id: 'm0',
rowKind: 'modified',
isChanged: true,
left: {
lineNo: 1,
text: 'hello x',
segs: [
{ text: 'hello ', kind: 'common' },
{ text: 'x', kind: 'delete' }
]
},
right: {
lineNo: 1,
text: 'hello y',
segs: [
{ text: 'hello ', kind: 'common' },
{ text: 'y', kind: 'insert' }
]
}
}
const { container } = renderDiff([modRow])
expect(container.querySelector('.seg-delete')).toBeTruthy()
expect(container.querySelector('.seg-insert')).toBeTruthy()
})
it('左侧滚动时同步右侧面板', () => {
const { container } = renderDiff(baseRows)
const scrollers = container.querySelectorAll('.diff-scroll')
expect(scrollers).toHaveLength(2)
const left = scrollers[0] as HTMLElement
const right = scrollers[1] as HTMLElement
left.scrollTop = 120
fireEvent.scroll(left)
expect(right.scrollTop).toBe(120)
})
it('右侧滚动时同步左侧面板', () => {
const { container } = renderDiff(baseRows)
const scrollers = container.querySelectorAll('.diff-scroll')
const left = scrollers[0] as HTMLElement
const right = scrollers[1] as HTMLElement
right.scrollTop = 66
fireEvent.scroll(right)
expect(left.scrollTop).toBe(66)
})
it('右键内容行触发 onRowContext', () => {
const onRowContext = vi.fn()
render(
<DiffView
diff={{
rows: baseRows,
summary: { changedLines: 1, inserted: 1, deleted: 0, modified: 0 }
}}
leftMeta={{ name: 'a.txt', encoding: 'UTF-8' }}
rightMeta={{ name: 'b.txt', encoding: 'GBK' }}
activeRowId={null}
onOpen={vi.fn()}
onRowContext={onRowContext}
/>
)
fireEvent.contextMenu(screen.getAllByText('hello')[0])
expect(onRowContext).toHaveBeenCalled()
})
it('点击面板头打开按钮触发 onOpen', () => {
const onOpen = vi.fn()
render(
<DiffView
diff={{
rows: baseRows,
summary: { changedLines: 1, inserted: 1, deleted: 0, modified: 0 }
}}
leftMeta={{ name: 'a.txt', encoding: 'UTF-8' }}
rightMeta={{ name: 'b.txt', encoding: 'GBK' }}
activeRowId={null}
onOpen={onOpen}
onRowContext={vi.fn()}
/>
)
fireEvent.click(screen.getAllByText('重新选择')[0])
expect(onOpen).toHaveBeenCalledWith('left')
})
})
+8 -1
View File
@@ -20,7 +20,14 @@ export default defineConfig({
reporter: ['text', 'text-summary'],
include: ['src/renderer/src/**/*.{ts,tsx}'],
exclude: ['src/renderer/src/**/*.{test,spec}.{ts,tsx}', 'src/renderer/src/test/**', 'src/renderer/src/env.d.ts'],
all: true
all: true,
// CI 门禁:四项覆盖率不得低于 80%,否则测试失败
thresholds: {
statements: 80,
branches: 80,
functions: 80,
lines: 80
}
}
}
})