Skip to content
Merged
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
194 changes: 143 additions & 51 deletions src/components/__tests__/ChartContainer.test.jsx
Original file line number Diff line number Diff line change
@@ -1,76 +1,168 @@
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, fireEvent } 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(),
defaults: { plugins: { legend: { labels: { generateLabels: vi.fn(() => []) } } } }
};
return {
ChartJS: Chart,
Chart,
ChartJS: Chart,
CategoryScale: {},
LinearScale: {},
PointElement: {},
LineElement: {},
Title: {},
Tooltip: {},
Legend: {}
Legend: {},
};
});

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';

vi.mock('chartjs-plugin-zoom', () => ({ default: {} }));

import ChartContainer from '../ChartContainer.jsx';
describe('ChartContainer', () => {
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);
});

const sampleFile = {
name: 'test.log',
id: '1',
content: 'loss: 1\nloss: 2',
};
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('🎯 请选择要显示的图表');
});

const metric = { name: 'loss', mode: 'keyword', keyword: 'loss:' };
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}
/>
);

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 };
}
screen.getByText('📊 loss');
screen.getByText(/差值统计/);
expect(onMaxStepChange).toHaveBeenCalledWith(1);

describe('ChartContainer', () => {
it('shows empty message when no files', () => {
renderComponent();
expect(screen.getByText('📊 暂无数据')).toBeInTheDocument();
// simulate hover to trigger sync
const hover = __lineProps[0].options.onHover;
hover({}, [{ index: 0 }]);
expect(__charts[1].setActiveElements).toHaveBeenCalled();
});

it('shows metric selection message when no metrics', () => {
renderComponent({ files: [sampleFile] });
expect(screen.getByText('🎯 请选择要显示的图表')).toBeInTheDocument();
});
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(
<ChartContainer
files={files}
metrics={metrics}
compareMode="relative"
onXRangeChange={onXRangeChange}
onMaxStepChange={onMaxStepChange}
/>
);

// 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' } });

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 });
// 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 } } } });
});
});
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'
]
}
}
})