Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 83 additions & 53 deletions src/components/__tests__/ChartContainer.test.jsx
Original file line number Diff line number Diff line change
@@ -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: () => <div data-testid="chart" />
}));

// 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 <div data-testid="line-chart" />;
}),
__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(
<ChartContainer
files={[]}
metrics={[]}
compareMode="normal"
onXRangeChange={onXRangeChange}
onMaxStepChange={onMaxStepChange}
{...props}
/>
);
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(
<ChartContainer
files={[]}
metrics={[{ name: 'loss', keyword: 'loss', mode: 'keyword' }]}
compareMode="normal"
onXRangeChange={onXRangeChange}
onMaxStepChange={onMaxStepChange}
/>
);
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(
<ChartContainer
files={files}
metrics={[]}
compareMode="normal"
onXRangeChange={onXRangeChange}
onMaxStepChange={onMaxStepChange}
/>
);
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(
<ChartContainer
files={files}
metrics={[{ name: 'loss', keyword: 'loss', mode: 'keyword' }]}
compareMode="relative"
onXRangeChange={onXRangeChange}
onMaxStepChange={onMaxStepChange}
/>
);

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();
});
});
18 changes: 18 additions & 0 deletions src/components/__tests__/ComparisonControls.test.jsx
Original file line number Diff line number Diff line change
@@ -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(
<ComparisonControls compareMode="normal" onCompareModeChange={handleChange} />
);

const absoluteOption = screen.getByLabelText(/平均误差 \(absolute\)/);
await user.click(absoluteOption);
expect(handleChange).toHaveBeenCalledWith('absolute');
});
});
51 changes: 28 additions & 23 deletions src/components/__tests__/FileUpload.test.jsx
Original file line number Diff line number Diff line change
@@ -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(<FileUpload onFilesUploaded={onFilesUploaded} />);

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));
});
});
10 changes: 10 additions & 0 deletions src/components/__tests__/Header.test.jsx
Original file line number Diff line number Diff line change
@@ -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(<Header />);
expect(container.firstChild).toBeNull();
});
});
47 changes: 47 additions & 0 deletions src/components/__tests__/ResizablePanel.test.jsx
Original file line number Diff line number Diff line change
@@ -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(
<ResizablePanel title="Test" initialHeight={300}>
<div>content</div>
</ResizablePanel>
);

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(
<ResizablePanel title="Test" initialHeight={300}>
<div>content</div>
</ResizablePanel>
);

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();
});
});
9 changes: 8 additions & 1 deletion vite.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
]
}
}
})