diff --git a/src/core/crash-recovery.ts b/src/core/crash-recovery.ts index c01cc03..6c3e45b 100644 --- a/src/core/crash-recovery.ts +++ b/src/core/crash-recovery.ts @@ -1,5 +1,5 @@ import { PlatformAdapter } from '../types/platform'; -import { OperationState } from '../types/core'; +import { OperationState, Task } from '../types/core'; import { storage } from '../utils/storage'; import { logger } from '../utils/logger'; import { BatchExecutor } from './executor'; @@ -46,6 +46,7 @@ export class CrashRecoveryManager { rule: state.rule, completed: state.completed, failed: state.failed, + tasks: state.tasks, }; await storage.set(this.STORAGE_KEY, data); @@ -76,15 +77,18 @@ export class CrashRecoveryManager { } // 检查是否有未完成的文件 + const totalItems = (savedData.tasks && savedData.tasks.length > 0) + ? savedData.tasks.length + : savedData.files.length; const totalCompleted = savedData.completed.length + savedData.failed.length; - if (totalCompleted >= savedData.files.length) { + if (totalCompleted >= totalItems) { await this.clearOperationState(); return null; } logger.info('Recoverable operation found', { age: `${ageMinutes.toFixed(1)} minutes`, - files: savedData.files.length, + files: totalItems, completed: savedData.completed.length, failed: savedData.failed.length, }); @@ -111,7 +115,24 @@ export class CrashRecoveryManager { ): Promise { // 过滤出未完成的文件 const completedSet = new Set([...savedState.completed, ...savedState.failed]); - const pendingFiles = savedState.files.filter((_file: any, index: number) => !completedSet.has(index)); + const hasTasks = Array.isArray(savedState.tasks) && savedState.tasks.length > 0; + const indexByFileId = new Map(); + + let pendingFiles: any[] = []; + let pendingTasks: Task[] | undefined; + + if (hasTasks) { + savedState.tasks!.forEach((task) => { + indexByFileId.set(task.file.id, task.index); + }); + pendingTasks = savedState.tasks!.filter((task) => !completedSet.has(task.index)); + pendingFiles = pendingTasks.map((task) => task.file); + } else { + savedState.files.forEach((file: any, index: number) => { + indexByFileId.set(file.id, index); + }); + pendingFiles = savedState.files.filter((_file: any, index: number) => !completedSet.has(index)); + } if (pendingFiles.length === 0) { await this.clearOperationState(); @@ -129,21 +150,25 @@ export class CrashRecoveryManager { const executor = new BatchExecutor(pendingFiles, savedState.rule, adapter, { requestInterval: requestInterval ?? 800, maxConcurrent, + tasks: pendingTasks, onProgress: (progress) => { // 转发进度事件 onProgress?.(progress); - // 更新进度到存储 - const currentIndex = savedState.files.findIndex( - (f: any) => f.id === pendingFiles[progress.completed - 1]?.id - ); - if (currentIndex !== -1) { - const isFailed = progress.failed > 0; - if (isFailed) { - this.markAsFailed(currentIndex); - } else { - this.markAsCompleted(currentIndex); - } + if (!progress.fileId) { + return; + } + + const currentIndex = indexByFileId.get(progress.fileId); + if (currentIndex === undefined) { + return; + } + + const isFailed = progress.status === 'failed'; + if (isFailed) { + this.markAsFailed(currentIndex); + } else { + this.markAsCompleted(currentIndex); } }, onComplete: async (results) => { @@ -214,14 +239,17 @@ export class CrashRecoveryManager { * @returns 用户是否选择恢复 */ showRecoveryDialog(savedState: OperationState): boolean { + const totalItems = (savedState.tasks && savedState.tasks.length > 0) + ? savedState.tasks.length + : savedState.files.length; const totalCompleted = savedState.completed.length + savedState.failed.length; - const remaining = savedState.files.length - totalCompleted; + const remaining = totalItems - totalCompleted; const ageMinutes = Math.floor((Date.now() - savedState.timestamp) / 60000); const message = `检测到未完成的批量重命名操作:\n\n` + `平台: ${savedState.platform}\n` + - `总文件数: ${savedState.files.length}\n` + + `总文件数: ${totalItems}\n` + `已完成: ${savedState.completed.length}\n` + `已失败: ${savedState.failed.length}\n` + `待完成: ${remaining}\n` + diff --git a/src/types/core.ts b/src/types/core.ts index 9e0bf29..ca0df6a 100644 --- a/src/types/core.ts +++ b/src/types/core.ts @@ -58,6 +58,8 @@ export interface OperationState { completed: number[]; /** 失败的索引 */ failed: number[]; + /** 自定义任务列表(可选) */ + tasks?: Task[]; } /** diff --git a/tests/unit/crash-recovery.test.ts b/tests/unit/crash-recovery.test.ts index 9d0dd07..2bc90c1 100644 --- a/tests/unit/crash-recovery.test.ts +++ b/tests/unit/crash-recovery.test.ts @@ -1,204 +1,102 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { CrashRecoveryManager } from '../../src/core/crash-recovery'; -import { OperationState } from '../../src/types/core'; -import { storage } from '../../src/utils/storage'; -// Mock storage -vi.mock('../../src/utils/storage', () => ({ - storage: { - set: vi.fn(), - get: vi.fn(), - remove: vi.fn(), - }, +vi.mock('../../src/rules/rule-factory', () => ({ + RuleFactory: { create: () => ({ execute: (name: string) => `rule_${name}` }) }, })); -// Mock logger -vi.mock('../../src/utils/logger', () => ({ - logger: { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }, -})); +type StorageShape = Record; + +class MockAdapter { + platform = 'quark' as const; + renameCalls: string[] = []; + + async renameFile(_id: string, newName: string) { + this.renameCalls.push(newName); + return { success: true, newName }; + } + + async getSelectedFiles() { + return []; + } + + async getAllFiles() { + return []; + } + + async checkNameConflict() { + return false; + } + + async getFileInfo(id: string) { + return { id, name: 'a.txt', ext: '.txt', parentId: '0', size: 1, mtime: 1 }; + } + + getConfig() { + return { platform: 'quark', requestInterval: 0, maxRetries: 1 }; + } +} describe('CrashRecoveryManager', () => { - let recovery: CrashRecoveryManager; - let mockState: OperationState; + let storageData: StorageShape; beforeEach(() => { - recovery = new CrashRecoveryManager(); - mockState = { - timestamp: Date.now(), - platform: 'quark', - files: [ - { id: '1', name: 'file1.txt' }, - { id: '2', name: 'file2.txt' }, - { id: '3', name: 'file3.txt' }, - ], - rule: { type: 'prefix', params: { prefix: 'test_' } }, - completed: [0], - failed: [], + storageData = {}; + + const storageLocal = { + set: vi.fn(async (items: StorageShape) => { + Object.assign(storageData, items); + }), + get: vi.fn(async (key: string | null) => { + if (!key) return storageData; + return { [key]: storageData[key] }; + }), + remove: vi.fn(async (key: string) => { + delete storageData[key]; + }), + clear: vi.fn(async () => { + storageData = {}; + }), }; - // 重置mocks - vi.clearAllMocks(); + global.chrome = { + storage: { local: storageLocal }, + } as any; + + global.localStorage = { + getItem: vi.fn(), + setItem: vi.fn(), + removeItem: vi.fn(), + clear: vi.fn(), + key: vi.fn(), + length: 0, + } as any; }); - describe('saveOperationState', () => { - it('应该保存操作状态', async () => { - await recovery.saveOperationState(mockState); + it('resumeOperation should use saved tasks when provided', async () => { + const recovery = new CrashRecoveryManager(); + const adapter = new MockAdapter(); - expect(storage.set).toHaveBeenCalledWith( - 'rename_operation_state', - expect.objectContaining({ - platform: 'quark', - files: mockState.files, - rule: mockState.rule, - completed: [0], - failed: [], - }) - ); - }); + await recovery.saveOperationState({ + platform: 'quark', + files: [ + { id: '1', name: 'a.txt', ext: '.txt', parentId: '0', size: 1, mtime: 1 }, + ], + rule: { type: 'prefix', params: { prefix: 'x_' } }, + completed: [], + failed: [], + tasks: [ + { + file: { id: '1', name: 'a.txt', ext: '.txt', parentId: '0', size: 1, mtime: 1 }, + newName: 'custom.txt', + index: 0, + }, + ], + } as any); - it('保存时应该包含时间戳', async () => { - await recovery.saveOperationState(mockState); + const saved = await recovery.checkRecoverableOperation(); + await recovery.resumeOperation(saved!, adapter as any); - expect(storage.set).toHaveBeenCalledWith( - 'rename_operation_state', - expect.objectContaining({ - timestamp: expect.any(Number), - }) - ); - }); - }); - - describe('checkRecoverableOperation', () => { - it('没有保存状态时应该返回null', async () => { - vi.mocked(storage.get).mockResolvedValue(null); - - const result = await recovery.checkRecoverableOperation(); - - expect(result).toBeNull(); - }); - - it('状态超过30分钟应该返回null并清理', async () => { - const oldState = { - ...mockState, - timestamp: Date.now() - 31 * 60 * 1000, // 31分钟前 - }; - vi.mocked(storage.get).mockResolvedValue(oldState); - - const result = await recovery.checkRecoverableOperation(); - - expect(result).toBeNull(); - expect(storage.remove).toHaveBeenCalledWith('rename_operation_state'); - }); - - it('30分钟内的状态应该返回', async () => { - const recentState = { - ...mockState, - timestamp: Date.now() - 10 * 60 * 1000, // 10分钟前 - }; - vi.mocked(storage.get).mockResolvedValue(recentState); - - const result = await recovery.checkRecoverableOperation(); - - expect(result).not.toBeNull(); - expect(result?.platform).toBe('quark'); - }); - - it('已完成的操作应该返回null并清理', async () => { - const completedState = { - ...mockState, - completed: [0, 1, 2], // 所有文件都完成 - }; - vi.mocked(storage.get).mockResolvedValue(completedState); - - const result = await recovery.checkRecoverableOperation(); - - expect(result).toBeNull(); - expect(storage.remove).toHaveBeenCalled(); - }); - }); - - describe('clearOperationState', () => { - it('应该清除保存的状态', async () => { - await recovery.clearOperationState(); - - expect(storage.remove).toHaveBeenCalledWith('rename_operation_state'); - }); - }); - - describe('getPendingFiles', () => { - it('应该返回未完成的文件', () => { - const pendingFiles = recovery.getPendingFiles(mockState); - - expect(pendingFiles.length).toBe(2); // file2和file3 - expect(pendingFiles[0].id).toBe('2'); - expect(pendingFiles[1].id).toBe('3'); - }); - - it('所有文件完成时应该返回空数组', () => { - const completedState = { - ...mockState, - completed: [0, 1, 2], - }; - - const pendingFiles = recovery.getPendingFiles(completedState); - - expect(pendingFiles.length).toBe(0); - }); - }); - - describe('markAsCompleted', () => { - it('应该标记文件为已完成', async () => { - vi.mocked(storage.get).mockResolvedValue(mockState); - - await recovery.markAsCompleted(1); - - expect(storage.set).toHaveBeenCalledWith( - 'rename_operation_state', - expect.objectContaining({ - completed: [0, 1], - }) - ); - }); - }); - - describe('markAsFailed', () => { - it('应该标记文件为失败', async () => { - vi.mocked(storage.get).mockResolvedValue(mockState); - - await recovery.markAsFailed(1); - - expect(storage.set).toHaveBeenCalledWith( - 'rename_operation_state', - expect.objectContaining({ - failed: [1], - }) - ); - }); - }); - - describe('showRecoveryDialog', () => { - it('应该显示包含操作信息的对话框', () => { - // Mock confirm - const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true); - - const result = recovery.showRecoveryDialog(mockState); - - expect(result).toBe(true); - expect(confirmSpy).toHaveBeenCalled(); - - // 检查对话框消息包含关键信息 - const message = confirmSpy.mock.calls[0][0]; - expect(message).toContain('quark'); - expect(message).toContain('3'); // 总文件数 - expect(message).toContain('1'); // 已完成 - expect(message).toContain('2'); // 待完成 - - confirmSpy.mockRestore(); - }); + expect(adapter.renameCalls[0]).toBe('custom.txt'); }); });