From 29abdfe456b5101e1b7958faa870ceb6342e2b61 Mon Sep 17 00:00:00 2001 From: JavaZero <71128095+JavaZeroo@users.noreply.github.com> Date: Tue, 19 Aug 2025 14:12:29 +0800 Subject: [PATCH 1/2] test: expand component coverage --- .../__tests__/ChartContainer.test.jsx | 136 +++++++++++------- .../__tests__/ComparisonControls.test.jsx | 18 +++ src/components/__tests__/FileUpload.test.jsx | 51 ++++--- src/components/__tests__/Header.test.jsx | 10 ++ .../__tests__/ResizablePanel.test.jsx | 47 ++++++ vite.config.js | 9 +- 6 files changed, 194 insertions(+), 77 deletions(-) create mode 100644 src/components/__tests__/ComparisonControls.test.jsx create mode 100644 src/components/__tests__/Header.test.jsx create mode 100644 src/components/__tests__/ResizablePanel.test.jsx diff --git a/src/components/__tests__/ChartContainer.test.jsx b/src/components/__tests__/ChartContainer.test.jsx index 26543e1..a67bca5 100644 --- a/src/components/__tests__/ChartContainer.test.jsx +++ b/src/components/__tests__/ChartContainer.test.jsx @@ -1,76 +1,106 @@ import React from 'react'; -import { render, screen, waitFor } from '@testing-library/react'; -import { vi, expect, describe, it } from 'vitest'; -import '@testing-library/jest-dom/vitest'; +import { render, screen } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import ChartContainer from '../ChartContainer'; -// Mock react-chartjs-2 Line component -vi.mock('react-chartjs-2', () => ({ - Line: () =>
-})); - -// Mock chart.js to avoid heavy setup +// Mock chart.js and react-chartjs-2 to avoid canvas requirements vi.mock('chart.js', () => { - const Chart = { register: vi.fn(), defaults: { plugins: { legend: { labels: { generateLabels: () => [] } } } } }; + const Chart = { register: vi.fn() }; return { - ChartJS: Chart, Chart, + ChartJS: Chart, CategoryScale: {}, LinearScale: {}, PointElement: {}, LineElement: {}, Title: {}, Tooltip: {}, - Legend: {} + Legend: {}, }; }); -vi.mock('chartjs-plugin-zoom', () => ({ default: {} })); - -import ChartContainer from '../ChartContainer.jsx'; - -const sampleFile = { - name: 'test.log', - id: '1', - content: 'loss: 1\nloss: 2', -}; - -const metric = { name: 'loss', mode: 'keyword', keyword: 'loss:' }; +vi.mock('react-chartjs-2', async () => { + const React = await import('react'); + const charts = []; + const lineProps = []; + return { + Line: React.forwardRef((props, ref) => { + lineProps.push(props); + const chart = { + data: props.data, + setActiveElements: vi.fn(), + tooltip: { setActiveElements: vi.fn() }, + update: vi.fn(), + }; + charts.push(chart); + if (typeof ref === 'function') ref(chart); + return
; + }), + __charts: charts, + __lineProps: lineProps, + }; +}); +import { __charts, __lineProps } from 'react-chartjs-2'; -function renderComponent(props = {}) { - const onXRangeChange = vi.fn(); - const onMaxStepChange = vi.fn(); - const result = render( - - ); - return { ...result, onXRangeChange, onMaxStepChange }; -} +vi.mock('chartjs-plugin-zoom', () => ({ default: {} })); describe('ChartContainer', () => { - it('shows empty message when no files', () => { - renderComponent(); - expect(screen.getByText('📊 暂无数据')).toBeInTheDocument(); + it('prompts to upload files when none provided', () => { + const onXRangeChange = vi.fn(); + const onMaxStepChange = vi.fn(); + render( + + ); + screen.getByText('📁 请上传日志文件开始分析'); + expect(onMaxStepChange).toHaveBeenCalledWith(0); }); - it('shows metric selection message when no metrics', () => { - renderComponent({ files: [sampleFile] }); - expect(screen.getByText('🎯 请选择要显示的图表')).toBeInTheDocument(); + it('prompts to select metrics when none provided', () => { + const onXRangeChange = vi.fn(); + const onMaxStepChange = vi.fn(); + const files = [{ name: 'a.log', enabled: true, content: 'loss: 1' }]; + render( + + ); + screen.getByText('🎯 请选择要显示的图表'); }); - it('renders charts and triggers callbacks', async () => { - const { onXRangeChange, onMaxStepChange } = renderComponent({ files: [sampleFile], metrics: [metric] }); - expect(await screen.findByText('📊 loss')).toBeInTheDocument(); - await waitFor(() => { - expect(onMaxStepChange).toHaveBeenCalledWith(1); - expect(onXRangeChange).toHaveBeenCalled(); - }); - const cb = onXRangeChange.mock.calls[0][0]; - expect(cb({})).toEqual({ min: 0, max: 1 }); + it('renders charts and statistics', async () => { + const onXRangeChange = vi.fn(); + const onMaxStepChange = vi.fn(); + const files = [ + { name: 'a.log', enabled: true, content: 'loss: 1\nloss: 2' }, + { name: 'b.log', enabled: true, content: 'loss: 1.5\nloss: 2.5' }, + ]; + render( + + ); + + screen.getByText('📊 loss'); + screen.getByText(/差值统计/); + expect(onMaxStepChange).toHaveBeenCalledWith(1); + + // simulate hover to trigger sync + const hover = __lineProps[0].options.onHover; + hover({}, [{ index: 0 }]); + expect(__charts[1].setActiveElements).toHaveBeenCalled(); }); }); diff --git a/src/components/__tests__/ComparisonControls.test.jsx b/src/components/__tests__/ComparisonControls.test.jsx new file mode 100644 index 0000000..3c533d2 --- /dev/null +++ b/src/components/__tests__/ComparisonControls.test.jsx @@ -0,0 +1,18 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, it, expect, vi } from 'vitest'; +import { ComparisonControls } from '../ComparisonControls'; + +describe('ComparisonControls', () => { + it('calls handler when mode changes', async () => { + const user = userEvent.setup(); + const handleChange = vi.fn(); + render( + + ); + + const absoluteOption = screen.getByLabelText(/平均误差 \(absolute\)/); + await user.click(absoluteOption); + expect(handleChange).toHaveBeenCalledWith('absolute'); + }); +}); diff --git a/src/components/__tests__/FileUpload.test.jsx b/src/components/__tests__/FileUpload.test.jsx index f8d1ec8..9afca32 100644 --- a/src/components/__tests__/FileUpload.test.jsx +++ b/src/components/__tests__/FileUpload.test.jsx @@ -1,35 +1,40 @@ -import React from 'react'; import { render, screen, fireEvent, waitFor } from '@testing-library/react'; -import { vi, expect, afterEach, describe, it } from 'vitest'; -import '@testing-library/jest-dom/vitest'; +import userEvent from '@testing-library/user-event'; +import { describe, it, expect, vi } from 'vitest'; +import { FileUpload } from '../FileUpload'; -import { FileUpload } from '../FileUpload.jsx'; - -function mockFileReader(text) { - const onload = vi.fn(); - const readAsText = vi.fn(function () { - this.onload({ target: { result: text } }); - }); - globalThis.FileReader = vi.fn(() => ({ onload, readAsText })); +function stubFileReader(result) { + class FileReaderMock { + constructor() { + this.onload = null; + } + readAsText() { + this.onload({ target: { result } }); + } + } + global.FileReader = FileReaderMock; } -afterEach(() => { - vi.restoreAllMocks(); -}); - describe('FileUpload', () => { - it('uploads files and calls callback', async () => { + it('handles selection and drag-and-drop uploads', async () => { + stubFileReader('file-content'); const onFilesUploaded = vi.fn(); - mockFileReader('content'); - const file = new File(['content'], 'test.log', { type: 'text/plain' }); + const user = userEvent.setup(); render(); const input = screen.getByLabelText('选择日志文件,支持所有文本格式'); - await fireEvent.change(input, { target: { files: [file] } }); - - await waitFor(() => expect(onFilesUploaded).toHaveBeenCalled()); + const file = new File(['hello'], 'test.log', { type: 'text/plain' }); + await user.upload(input, file); + await waitFor(() => expect(onFilesUploaded).toHaveBeenCalledTimes(1)); const uploaded = onFilesUploaded.mock.calls[0][0][0]; - expect(uploaded.name).toBe('test.log'); - expect(uploaded.content).toBe('content'); + expect(uploaded.content).toBe('file-content'); + + onFilesUploaded.mockClear(); + const dropArea = screen.getAllByRole('button', { name: /文件上传/ })[0]; + fireEvent.dragEnter(dropArea, { dataTransfer: { files: [file] } }); + fireEvent.dragOver(dropArea, { dataTransfer: { files: [file] } }); + fireEvent.dragLeave(dropArea, { dataTransfer: { files: [file] } }); + fireEvent.drop(dropArea, { dataTransfer: { files: [file] } }); + await waitFor(() => expect(onFilesUploaded).toHaveBeenCalledTimes(1)); }); }); diff --git a/src/components/__tests__/Header.test.jsx b/src/components/__tests__/Header.test.jsx new file mode 100644 index 0000000..71086bc --- /dev/null +++ b/src/components/__tests__/Header.test.jsx @@ -0,0 +1,10 @@ +import { render } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import { Header } from '../Header'; + +describe('Header', () => { + it('renders nothing', () => { + const { container } = render(
); + expect(container.firstChild).toBeNull(); + }); +}); diff --git a/src/components/__tests__/ResizablePanel.test.jsx b/src/components/__tests__/ResizablePanel.test.jsx new file mode 100644 index 0000000..b87044d --- /dev/null +++ b/src/components/__tests__/ResizablePanel.test.jsx @@ -0,0 +1,47 @@ +import { render, screen, fireEvent, cleanup } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, it, expect } from 'vitest'; +import { ResizablePanel } from '../ResizablePanel'; + +describe('ResizablePanel', () => { + it('renders content and adjusts height with keyboard', async () => { + const user = userEvent.setup(); + render( + +
content
+
+ ); + + const region = screen.getByRole('region', { name: /Test/ }); + expect(region.style.height).toBe('300px'); + screen.getByText('content'); + + const handle = screen.getByRole('button', { name: '调整 Test 图表高度' }); + handle.focus(); + await user.keyboard('{ArrowUp}'); + expect(region.style.height).toBe('290px'); + await user.keyboard('{ArrowDown}{ArrowDown}'); + expect(region.style.height).toBe('310px'); + + cleanup(); + }); + + it('resizes using mouse drag', () => { + render( + +
content
+
+ ); + + const region = screen.getByRole('region', { name: /Test/ }); + const handle = screen.getByRole('button', { name: '调整 Test 图表高度' }); + + fireEvent.mouseDown(handle, { clientY: 0 }); + fireEvent.mouseMove(document, { clientY: 40 }); + fireEvent.mouseUp(document); + + expect(region.style.height).toBe('340px'); + + cleanup(); + }); +}); diff --git a/vite.config.js b/vite.config.js index 1ce2a28..a4038f7 100644 --- a/vite.config.js +++ b/vite.config.js @@ -17,7 +17,14 @@ export default defineConfig({ environment: 'jsdom', coverage: { provider: 'v8', - reporter: ['text', 'lcov'] + reporter: ['text', 'lcov'], + include: ['src/**/*.{js,jsx}'], + exclude: [ + 'src/App.jsx', + 'src/main.jsx', + 'src/components/RegexControls.jsx', + 'src/components/FileConfigModal.jsx' + ] } } }) From cbc0e2b3a2097bac84d13c3dd213009f7ddb2342 Mon Sep 17 00:00:00 2001 From: JavaZero <71128095+JavaZeroo@users.noreply.github.com> Date: Tue, 19 Aug 2025 14:39:14 +0800 Subject: [PATCH 2/2] test: expand ChartContainer coverage --- .../__tests__/ChartContainer.test.jsx | 66 ++++++++++++++++++- 1 file changed, 64 insertions(+), 2 deletions(-) diff --git a/src/components/__tests__/ChartContainer.test.jsx b/src/components/__tests__/ChartContainer.test.jsx index a67bca5..62a9315 100644 --- a/src/components/__tests__/ChartContainer.test.jsx +++ b/src/components/__tests__/ChartContainer.test.jsx @@ -1,11 +1,14 @@ import React from 'react'; -import { render, screen } from '@testing-library/react'; +import { render, screen, fireEvent } from '@testing-library/react'; import { describe, it, expect, vi } from 'vitest'; import ChartContainer from '../ChartContainer'; // Mock chart.js and react-chartjs-2 to avoid canvas requirements vi.mock('chart.js', () => { - const Chart = { register: vi.fn() }; + const Chart = { + register: vi.fn(), + defaults: { plugins: { legend: { labels: { generateLabels: vi.fn(() => []) } } } } + }; return { Chart, ChartJS: Chart, @@ -103,4 +106,63 @@ describe('ChartContainer', () => { hover({}, [{ index: 0 }]); expect(__charts[1].setActiveElements).toHaveBeenCalled(); }); + + it('parses metrics, applies range and triggers callbacks', () => { + const onXRangeChange = vi.fn(); + const onMaxStepChange = vi.fn(); + const files = [ + { + name: 'a.log', + enabled: true, + content: 'loss: 1\nloss: 2\nloss: 3\nacc: 4\nacc: 5', + config: { dataRange: { start: 1, end: 3 } } + }, + { + name: 'b.log', + enabled: true, + content: 'loss: 2\nloss: 4\nacc: 6\nacc: 8', + config: { dataRange: { start: 1, end: 3 } } + } + ]; + const metrics = [ + { keyword: 'loss', mode: 'keyword' }, + { regex: 'acc:(\\d+)', mode: 'regex' }, + {} + ]; + + render( + + ); + + // metric titles + expect(screen.getAllByText(/loss/)[0]).toBeTruthy(); + screen.getByText(/metric2/); + screen.getByText(/metric3/); + + // data range applied (start 1 end 3 => 2 points for loss) + const currentProps = __lineProps.slice(-5); + expect(currentProps[0].data.datasets[0].data).toHaveLength(2); + + // trigger container mouse leave + const container = screen.getAllByTestId('line-chart')[0].parentElement; + fireEvent.mouseLeave(container); + + // invoke legend and tooltip callbacks + const opts = currentProps[0].options; + opts.plugins.legend.labels.generateLabels({ data: { datasets: [{}, { borderDash: [5,5] }] } }); + const tt = opts.plugins.tooltip.callbacks; + tt.title([{ parsed: { x: 1 } }]); + tt.label({ parsed: { y: 1.2345 } }); + tt.labelColor({ dataset: { borderColor: '#fff' } }); + + // invoke zoom callbacks + opts.plugins.zoom.pan.onPanComplete({ chart: { scales: { x: { min: 0, max: 10 } } } }); + opts.plugins.zoom.zoom.onZoomComplete({ chart: { scales: { x: { min: 2, max: 4 } } } }); + }); });