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
95 changes: 73 additions & 22 deletions src/App.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useState, useCallback, useEffect } from 'react';
import React, { useState, useCallback, useEffect, useRef } from 'react';
import { FileUpload } from './components/FileUpload';
import { RegexControls } from './components/RegexControls';
import { FileList } from './components/FileList';
Expand All @@ -9,27 +9,36 @@ import { FileConfigModal } from './components/FileConfigModal';
import { PanelLeftClose, PanelLeftOpen } from 'lucide-react';
import { mergeFilesWithReplacement } from './utils/mergeFiles.js';

// 默认全局解析配置
export const DEFAULT_GLOBAL_PARSING_CONFIG = {
metrics: [
{
name: 'Loss',
mode: 'keyword',
keyword: 'loss:',
regex: 'loss:\\s*([\\d.eE+-]+)'
},
{
name: 'Grad Norm',
mode: 'keyword',
keyword: 'norm:',
regex: 'grad[\\s_]norm:\\s*([\\d.eE+-]+)'
}
],
useStepKeyword: false,
stepKeyword: 'step:'
};

function App() {
const [uploadedFiles, setUploadedFiles] = useState([]);

const [uploadedFiles, setUploadedFiles] = useState(() => {
const stored = localStorage.getItem('uploadedFiles');
return stored ? JSON.parse(stored) : [];
});

// 全局解析配置状态
const [globalParsingConfig, setGlobalParsingConfig] = useState({
metrics: [
{
name: 'Loss',
mode: 'keyword', // 'keyword' | 'regex'
keyword: 'loss:',
regex: 'loss:\\s*([\\d.eE+-]+)'
},
{
name: 'Grad Norm',
mode: 'keyword',
keyword: 'norm:',
regex: 'grad[\\s_]norm:\\s*([\\d.eE+-]+)'
}
],
useStepKeyword: false,
stepKeyword: 'step:'
const [globalParsingConfig, setGlobalParsingConfig] = useState(() => {
const stored = localStorage.getItem('globalParsingConfig');
return stored ? JSON.parse(stored) : JSON.parse(JSON.stringify(DEFAULT_GLOBAL_PARSING_CONFIG));
});

const [compareMode, setCompareMode] = useState('normal');
Expand All @@ -42,6 +51,29 @@ function App() {
const [xRange, setXRange] = useState({ min: undefined, max: undefined });
const [maxStep, setMaxStep] = useState(0);
const [sidebarVisible, setSidebarVisible] = useState(true);
const savingDisabledRef = useRef(false);

// 持久化配置到 localStorage
useEffect(() => {
if (savingDisabledRef.current) return;
localStorage.setItem('globalParsingConfig', JSON.stringify(globalParsingConfig));
}, [globalParsingConfig]);

useEffect(() => {
if (savingDisabledRef.current) return;
const serialized = uploadedFiles.map(({ id, name, enabled, content, config }) => ({
id,
name,
enabled,
content,
config
}));
if (serialized.length > 0) {
localStorage.setItem('uploadedFiles', JSON.stringify(serialized));
} else {
localStorage.removeItem('uploadedFiles');
}
}, [uploadedFiles]);

const handleFilesUploaded = useCallback((files) => {
const filesWithDefaults = files.map(file => ({
Expand Down Expand Up @@ -132,7 +164,19 @@ function App() {
useStepKeyword: newConfig.useStepKeyword,
stepKeyword: newConfig.stepKeyword
}
})));
})));
}, []);

// 重置配置
const handleResetConfig = useCallback(() => {
savingDisabledRef.current = true;
localStorage.removeItem('globalParsingConfig');
localStorage.removeItem('uploadedFiles');
setGlobalParsingConfig(JSON.parse(JSON.stringify(DEFAULT_GLOBAL_PARSING_CONFIG)));
setUploadedFiles([]);
setTimeout(() => {
savingDisabledRef.current = false;
}, 0);
}, []);

// 全局拖拽事件处理
Expand Down Expand Up @@ -306,9 +350,16 @@ function App() {
</svg>
<span>GitHub</span>
</a>
<button
onClick={handleResetConfig}
className="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium bg-red-100 text-red-700 hover:bg-red-200 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2"
aria-label="重置配置"
>
重置配置
</button>
</div>
</div>

<FileUpload onFilesUploaded={handleFilesUploaded} />

<RegexControls
Expand Down
118 changes: 118 additions & 0 deletions src/components/__tests__/AppConfigPersistence.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import React from 'react';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import '@testing-library/jest-dom/vitest';
import App from '../../App.jsx';

// 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: vi.fn(() => []) } } } }
};
return {
Chart,
ChartJS: Chart,
CategoryScale: {},
LinearScale: {},
PointElement: {},
LineElement: {},
Title: {},
Tooltip: {},
Legend: {},
};
});

vi.mock('react-chartjs-2', async () => {
const React = await import('react');
return {
Line: React.forwardRef(() => <div data-testid="line-chart" />)
};
});

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

function stubFileReader(result) {
class FileReaderMock {
constructor() {
this.onload = null;
}
readAsText() {
this.onload({ target: { result } });
}
}
global.FileReader = FileReaderMock;
}

describe('App configuration persistence', () => {
beforeEach(() => {
localStorage.clear();
});

it('saves and restores config from localStorage', async () => {
stubFileReader('loss: 1');
const user = userEvent.setup();

const { unmount } = render(<App />);

const input = screen.getByLabelText('选择日志文件,支持所有文本格式');
const file = new File(['hello'], 'test.log', { type: 'text/plain' });
await user.upload(input, file);

const stepToggle = screen.getAllByLabelText('使用 Step 关键字')[0];
await user.click(stepToggle);
const stepInput = screen.getByPlaceholderText('step:');
fireEvent.change(stepInput, { target: { value: 'iter:' } });

await waitFor(() => expect(JSON.parse(localStorage.getItem('uploadedFiles'))).toHaveLength(1));
await waitFor(() => {
const cfg = JSON.parse(localStorage.getItem('globalParsingConfig'));
expect(cfg.stepKeyword).toBe('iter:');
expect(cfg.useStepKeyword).toBe(true);
});

unmount();

render(<App />);

expect(await screen.findByText('test.log')).toBeInTheDocument();
const restoredToggle = screen.getAllByLabelText('使用 Step 关键字')[0];
expect(restoredToggle).toBeChecked();
const restoredInput = screen.getByPlaceholderText('step:');
expect(restoredInput.value).toBe('iter:');
});

it('resets config and clears localStorage', async () => {
localStorage.setItem('globalParsingConfig', JSON.stringify({ metrics: [], useStepKeyword: true, stepKeyword: 'foo:' }));
localStorage.setItem('uploadedFiles', JSON.stringify([
{
id: '1',
name: 'saved.log',
enabled: true,
content: 'loss:1',
config: { metrics: [], dataRange: { start: 0, end: undefined, useRange: false }, useStepKeyword: true, stepKeyword: 'foo:' }
}
]));

const user = userEvent.setup();
render(<App />);

expect(screen.getByText('saved.log')).toBeInTheDocument();
expect(screen.getAllByLabelText('使用 Step 关键字')[0]).toBeChecked();

const resetButtons = screen.getAllByRole('button', { name: '重置配置' });
for (const btn of resetButtons) {
await user.click(btn);
}

await waitFor(() => {
expect(localStorage.getItem('uploadedFiles')).toBeNull();
expect(localStorage.getItem('globalParsingConfig')).toBeNull();
expect(screen.queryByText('saved.log')).not.toBeInTheDocument();
});

expect(screen.getAllByLabelText('使用 Step 关键字')[0]).not.toBeChecked();
});
});