diff --git a/src/App.jsx b/src/App.jsx index 950e072..980cf91 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -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'; @@ -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'); @@ -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 => ({ @@ -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); }, []); // 全局拖拽事件处理 @@ -306,9 +350,16 @@ function App() { GitHub + - + { + 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(() =>
) + }; +}); + +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(); + + 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(); + + 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(); + + 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(); + }); +}); +