镜像自地址
https://github.com/lhly/cloud-drive-renamer.git
已同步 2026-10-20 05:37:47 +08:00
- 在平台适配器中统一暴露 getCurrentDirectoryKey() 方法,用于确定当前目录范围 - 新增 LastRenameOperation 数据模型和核心操作函数,支持单条撤销记录的创建、合并和范围校验 - 在 file-selector-panel 中集成撤销逻辑,支持执行后保存记录、retry 成功后合并记录以及撤销操作 - 在 config-panel 底部操作区新增撤销按钮,根据当前目录范围动态显示/隐藏 - 添加多语言支持,包含撤销相关文案 - 添加单元测试和集成测试,确保撤销功能的正确性和稳定性
332 行
8.4 KiB
TypeScript
332 行
8.4 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
import { BatchExecutor, ExecutorState } from '../../src/core/executor';
|
|
import { FileItem, PlatformAdapter, RenameResult } from '../../src/types/platform';
|
|
import { RuleConfig } from '../../src/types/rule';
|
|
|
|
// Mock adapter
|
|
class MockAdapter implements PlatformAdapter {
|
|
readonly platform = 'quark' as const;
|
|
renameDelay = 100; // 模拟API延迟
|
|
failureRate = 0; // 失败率(0-1)
|
|
|
|
async getSelectedFiles(): Promise<FileItem[]> {
|
|
return [];
|
|
}
|
|
|
|
getCurrentDirectoryKey(): string {
|
|
return 'root';
|
|
}
|
|
|
|
async renameFile(fileId: string, newName: string): Promise<RenameResult> {
|
|
// 模拟延迟
|
|
await new Promise((resolve) => setTimeout(resolve, this.renameDelay));
|
|
|
|
// 模拟失败
|
|
if (Math.random() < this.failureRate) {
|
|
return {
|
|
success: false,
|
|
error: new Error('Mock rename failed'),
|
|
};
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
newName,
|
|
};
|
|
}
|
|
|
|
async checkNameConflict(fileName: string, parentId: string): Promise<boolean> {
|
|
return false;
|
|
}
|
|
|
|
async getFileInfo(fileId: string): Promise<FileItem> {
|
|
return {
|
|
id: fileId,
|
|
name: 'test.txt',
|
|
ext: '.txt',
|
|
parentId: '0',
|
|
size: 1024,
|
|
mtime: Date.now(),
|
|
};
|
|
}
|
|
|
|
getConfig() {
|
|
return {
|
|
platform: 'quark' as const,
|
|
requestInterval: 800,
|
|
maxRetries: 3,
|
|
};
|
|
}
|
|
}
|
|
|
|
// Mock rule
|
|
const mockRule: RuleConfig = {
|
|
type: 'prefix',
|
|
params: {
|
|
prefix: 'test_',
|
|
},
|
|
};
|
|
|
|
// Mock RuleFactory
|
|
vi.mock('../../src/rules/rule-factory', () => ({
|
|
RuleFactory: {
|
|
create: () => ({
|
|
execute: (fileName: string, index: number) => `test_${fileName}`,
|
|
}),
|
|
},
|
|
}));
|
|
|
|
describe('BatchExecutor', () => {
|
|
let adapter: MockAdapter;
|
|
let files: FileItem[];
|
|
|
|
beforeEach(() => {
|
|
adapter = new MockAdapter();
|
|
files = [
|
|
{
|
|
id: '1',
|
|
name: 'file1.txt',
|
|
ext: '.txt',
|
|
parentId: '0',
|
|
size: 1024,
|
|
mtime: Date.now(),
|
|
},
|
|
{
|
|
id: '2',
|
|
name: 'file2.txt',
|
|
ext: '.txt',
|
|
parentId: '0',
|
|
size: 2048,
|
|
mtime: Date.now(),
|
|
},
|
|
{
|
|
id: '3',
|
|
name: 'file3.txt',
|
|
ext: '.txt',
|
|
parentId: '0',
|
|
size: 3072,
|
|
mtime: Date.now(),
|
|
},
|
|
];
|
|
});
|
|
|
|
describe('构造函数', () => {
|
|
it('应该正确初始化', () => {
|
|
const executor = new BatchExecutor(files, mockRule, adapter, {
|
|
requestInterval: 800,
|
|
});
|
|
|
|
expect(executor.getState()).toBe(ExecutorState.IDLE);
|
|
});
|
|
|
|
it('空文件列表应该抛出错误', () => {
|
|
expect(() => {
|
|
new BatchExecutor([], mockRule, adapter, {
|
|
requestInterval: 800,
|
|
});
|
|
}).toThrow('Files array cannot be empty');
|
|
});
|
|
|
|
it('缺少规则应该抛出错误', () => {
|
|
expect(() => {
|
|
new BatchExecutor(files, null as any, adapter, {
|
|
requestInterval: 800,
|
|
});
|
|
}).toThrow('Rule configuration is required');
|
|
});
|
|
});
|
|
|
|
describe('execute', () => {
|
|
it('应该成功执行批量重命名', async () => {
|
|
const executor = new BatchExecutor(files, mockRule, adapter, {
|
|
requestInterval: 100, // 使用较短的间隔加快测试
|
|
});
|
|
|
|
const results = await executor.execute();
|
|
|
|
expect(results.success.length).toBe(3);
|
|
expect(results.failed.length).toBe(0);
|
|
expect(executor.getState()).toBe(ExecutorState.COMPLETED);
|
|
});
|
|
|
|
it('应该控制请求间隔', async () => {
|
|
const startTime = Date.now();
|
|
const requestInterval = 200;
|
|
|
|
const executor = new BatchExecutor(files, mockRule, adapter, {
|
|
requestInterval,
|
|
});
|
|
|
|
await executor.execute();
|
|
|
|
const elapsed = Date.now() - startTime;
|
|
const expectedMin = (files.length - 1) * requestInterval;
|
|
|
|
// 实际时间应该大于预期最小时间
|
|
expect(elapsed).toBeGreaterThanOrEqual(expectedMin);
|
|
});
|
|
|
|
it('应该触发进度回调', async () => {
|
|
const progressEvents: any[] = [];
|
|
|
|
const executor = new BatchExecutor(files, mockRule, adapter, {
|
|
requestInterval: 100,
|
|
onProgress: (progress) => {
|
|
progressEvents.push(progress);
|
|
},
|
|
});
|
|
|
|
await executor.execute();
|
|
|
|
expect(progressEvents.length).toBe(files.length);
|
|
expect(progressEvents[0].completed).toBe(1);
|
|
expect(progressEvents[2].completed).toBe(3);
|
|
});
|
|
|
|
it('应该触发完成回调', async () => {
|
|
let completedResults: any = null;
|
|
|
|
const executor = new BatchExecutor(files, mockRule, adapter, {
|
|
requestInterval: 100,
|
|
onComplete: (results) => {
|
|
completedResults = results;
|
|
},
|
|
});
|
|
|
|
await executor.execute();
|
|
|
|
expect(completedResults).not.toBeNull();
|
|
expect(completedResults.success.length).toBe(3);
|
|
});
|
|
|
|
it('应该处理部分失败', async () => {
|
|
adapter.failureRate = 0.5; // 50%失败率
|
|
|
|
const executor = new BatchExecutor(files, mockRule, adapter, {
|
|
requestInterval: 100,
|
|
});
|
|
|
|
const results = await executor.execute();
|
|
|
|
expect(results.success.length + results.failed.length).toBe(files.length);
|
|
});
|
|
|
|
it('should increase adaptive interval factor when backoff error occurs', async () => {
|
|
const backoffAdapter = new MockAdapter();
|
|
let called = 0;
|
|
backoffAdapter.renameFile = async () => {
|
|
called++;
|
|
if (called === 1) {
|
|
return { success: false, error: new Error('TooManyRequests') } as any;
|
|
}
|
|
return { success: true, newName: 'ok' } as any;
|
|
};
|
|
|
|
const executor = new BatchExecutor(files, mockRule, backoffAdapter, {
|
|
requestInterval: 50,
|
|
});
|
|
|
|
await executor.execute();
|
|
|
|
expect(executor.getAdaptiveIntervalFactor()).toBeGreaterThan(1);
|
|
});
|
|
|
|
it('应该限制最大并发数', async () => {
|
|
// 让单次请求更慢,便于观测并发峰值
|
|
adapter.renameDelay = 200;
|
|
|
|
let inFlight = 0;
|
|
let maxInFlight = 0;
|
|
|
|
const originalRename = adapter.renameFile.bind(adapter);
|
|
adapter.renameFile = async (fileId: string, newName: string) => {
|
|
inFlight++;
|
|
maxInFlight = Math.max(maxInFlight, inFlight);
|
|
try {
|
|
return await originalRename(fileId, newName);
|
|
} finally {
|
|
inFlight--;
|
|
}
|
|
};
|
|
|
|
const executor = new BatchExecutor(files, mockRule, adapter, {
|
|
requestInterval: 0,
|
|
maxConcurrent: 2,
|
|
});
|
|
|
|
await executor.execute();
|
|
|
|
expect(maxInFlight).toBeLessThanOrEqual(2);
|
|
});
|
|
});
|
|
|
|
describe('pause/resume', () => {
|
|
it('应该支持暂停和恢复', async () => {
|
|
const executor = new BatchExecutor(files, mockRule, adapter, {
|
|
requestInterval: 100,
|
|
});
|
|
|
|
// 启动执行
|
|
const executePromise = executor.execute();
|
|
|
|
// 等待一段时间后暂停
|
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
executor.pause();
|
|
|
|
expect(executor.getState()).toBe(ExecutorState.PAUSED);
|
|
|
|
// 等待一段时间后恢复
|
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
executor.resume();
|
|
|
|
expect(executor.getState()).toBe(ExecutorState.RUNNING);
|
|
|
|
// 等待完成
|
|
await executePromise;
|
|
|
|
expect(executor.getState()).toBe(ExecutorState.COMPLETED);
|
|
});
|
|
});
|
|
|
|
describe('cancel', () => {
|
|
it('应该支持取消', async () => {
|
|
const executor = new BatchExecutor(files, mockRule, adapter, {
|
|
requestInterval: 100,
|
|
});
|
|
|
|
// 启动执行
|
|
const executePromise = executor.execute();
|
|
|
|
// 等待一段时间后取消
|
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
executor.cancel();
|
|
|
|
expect(executor.getState()).toBe(ExecutorState.CANCELLED);
|
|
|
|
// 等待完成
|
|
await executePromise;
|
|
|
|
const results = executor.getResults();
|
|
// 取消后不是所有文件都会完成
|
|
expect(results.success.length + results.failed.length).toBeLessThan(files.length);
|
|
});
|
|
});
|
|
|
|
describe('getStatistics', () => {
|
|
it('应该返回正确的统计信息', async () => {
|
|
const executor = new BatchExecutor(files, mockRule, adapter, {
|
|
requestInterval: 100,
|
|
});
|
|
|
|
await executor.execute();
|
|
|
|
const stats = executor.getStatistics();
|
|
|
|
expect(stats.completed).toBe(files.length);
|
|
expect(stats.total).toBe(files.length);
|
|
expect(stats.percentage).toBe(100);
|
|
expect(stats.elapsed).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
});
|