镜像自地址
https://github.com/lhly/cloud-drive-renamer.git
已同步 2026-10-20 21:57:47 +08:00
新增 UC 网盘适配器,包括页面脚本注入、API 调用、错误处理和完整的重命名工作流。同时将包管理器从 npm 迁移至 pnpm,更新 CI/CD 工作流、文档和锁文件,并扩展权限范围以支持 UC 网盘域名。
325 行
10 KiB
TypeScript
325 行
10 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { FileSelectorPanel } from '../../src/content/components/file-selector-panel';
|
|
import { BatchExecutor, ExecutorState } from '../../src/core/executor';
|
|
import { ConflictResolution, ConflictType } from '../../src/core/conflict-detector';
|
|
import { crashRecovery } from '../../src/core/crash-recovery';
|
|
import { FileItem, PlatformAdapter, RenameResult } from '../../src/types/platform';
|
|
|
|
type FileSelectorPanelConflictHarness = FileSelectorPanel & {
|
|
allFiles: FileItem[];
|
|
newNameMap: Map<string, string>;
|
|
uncheckList: Set<string>;
|
|
extractErrorMap: Map<string, string>;
|
|
handleExecute(): Promise<void>;
|
|
syncStatus: 'idle' | 'syncing' | 'success' | 'failed';
|
|
executionItems: Array<{ file: FileItem; newName: string }>;
|
|
open: boolean;
|
|
updateComplete: Promise<void>;
|
|
loadAllFiles(): Promise<void>;
|
|
ensureCrashRecovery(): Promise<void>;
|
|
};
|
|
|
|
type ConflictDialogElement = HTMLElement & {
|
|
open: boolean;
|
|
items: Array<{
|
|
fileId: string;
|
|
originalName: string;
|
|
targetName: string;
|
|
type: ConflictType;
|
|
conflictingFiles: string[];
|
|
}>;
|
|
};
|
|
|
|
class ConflictTestAdapter implements PlatformAdapter {
|
|
readonly platform = 'aliyun' as const;
|
|
readonly checkNameConflict = vi.fn(async (_fileName: string, _parentId: string) => false);
|
|
|
|
getCurrentDirectoryKey(): string {
|
|
return 'root';
|
|
}
|
|
|
|
async getSelectedFiles(): Promise<FileItem[]> {
|
|
return [];
|
|
}
|
|
|
|
async getAllFiles(): Promise<FileItem[]> {
|
|
return [];
|
|
}
|
|
|
|
async renameFile(_fileId: string, newName: string): Promise<RenameResult> {
|
|
return { success: true, newName };
|
|
}
|
|
|
|
async getFileInfo(fileId: string): Promise<FileItem> {
|
|
return {
|
|
id: fileId,
|
|
name: `${fileId}.png`,
|
|
ext: '.png',
|
|
parentId: 'root',
|
|
size: 1,
|
|
mtime: Date.now(),
|
|
};
|
|
}
|
|
|
|
getConfig() {
|
|
return {
|
|
platform: 'aliyun' as const,
|
|
requestInterval: 0,
|
|
maxConcurrent: 1,
|
|
maxRetries: 1,
|
|
};
|
|
}
|
|
}
|
|
|
|
const files: FileItem[] = [
|
|
{
|
|
id: '1',
|
|
name: 'tile-board.png',
|
|
ext: '.png',
|
|
parentId: 'root',
|
|
size: 1,
|
|
mtime: 1,
|
|
},
|
|
{
|
|
id: '2',
|
|
name: '2p5d-slice-smoke.png',
|
|
ext: '.png',
|
|
parentId: 'root',
|
|
size: 1,
|
|
mtime: 1,
|
|
},
|
|
{
|
|
id: '3',
|
|
name: 'default-entry-smoke.png',
|
|
ext: '.png',
|
|
parentId: 'root',
|
|
size: 1,
|
|
mtime: 1,
|
|
},
|
|
];
|
|
|
|
describe('FileSelectorPanel conflict execution flow', () => {
|
|
beforeEach(() => {
|
|
vi.restoreAllMocks();
|
|
vi.stubGlobal('chrome', {
|
|
storage: {
|
|
local: {
|
|
get: vi.fn(async () => ({})),
|
|
set: vi.fn(async () => undefined),
|
|
remove: vi.fn(async () => undefined),
|
|
clear: vi.fn(async () => undefined),
|
|
},
|
|
},
|
|
});
|
|
vi.stubGlobal('alert', vi.fn());
|
|
vi.stubGlobal('prompt', vi.fn(() => null));
|
|
vi.spyOn(crashRecovery, 'saveOperationState').mockResolvedValue();
|
|
vi.spyOn(crashRecovery, 'clearOperationState').mockResolvedValue();
|
|
vi.spyOn(BatchExecutor.prototype, 'execute').mockResolvedValue({
|
|
success: [
|
|
{
|
|
fileId: '1',
|
|
original: 'tile-board.png',
|
|
renamed: 'TTTtile-board.png',
|
|
index: 0,
|
|
},
|
|
],
|
|
failed: [],
|
|
});
|
|
vi.spyOn(BatchExecutor.prototype, 'getState').mockReturnValue(ExecutorState.COMPLETED);
|
|
});
|
|
|
|
it('only checks conflicts for files whose names actually change', async () => {
|
|
const adapter = new ConflictTestAdapter();
|
|
const panel = new FileSelectorPanel() as FileSelectorPanelConflictHarness;
|
|
panel.adapter = adapter;
|
|
panel.allFiles = files;
|
|
panel.uncheckList = new Set();
|
|
panel.extractErrorMap = new Map();
|
|
panel.newNameMap = new Map([
|
|
['1', 'TTTtile-board.png'],
|
|
['2', '2p5d-slice-smoke.png'],
|
|
['3', 'default-entry-smoke.png'],
|
|
]);
|
|
|
|
await panel.handleExecute();
|
|
|
|
expect(adapter.checkNameConflict).toHaveBeenCalledTimes(1);
|
|
expect(adapter.checkNameConflict).toHaveBeenCalledWith('TTTtile-board.png', 'root');
|
|
});
|
|
|
|
it('keeps sync status neutral when adapter reports no visible rows to sync', async () => {
|
|
const adapter = new ConflictTestAdapter();
|
|
adapter.syncAfterRename = vi.fn(async () => ({
|
|
success: true,
|
|
method: 'none' as const,
|
|
message: 'no visible rows',
|
|
}));
|
|
|
|
const panel = new FileSelectorPanel() as FileSelectorPanelConflictHarness;
|
|
panel.adapter = adapter;
|
|
panel.allFiles = [files[0]];
|
|
panel.uncheckList = new Set();
|
|
panel.extractErrorMap = new Map();
|
|
panel.newNameMap = new Map([['1', 'TTTtile-board.png']]);
|
|
|
|
await panel.handleExecute();
|
|
|
|
await vi.waitFor(() => {
|
|
expect(adapter.syncAfterRename).toHaveBeenCalledTimes(1);
|
|
expect(panel.syncStatus).toBe('idle');
|
|
});
|
|
});
|
|
|
|
it('prevents a second execute while conflict detection is still pending', async () => {
|
|
const adapter = new ConflictTestAdapter();
|
|
let resolveConflictCheck: ((value: boolean) => void) | undefined;
|
|
adapter.checkNameConflict.mockImplementationOnce(
|
|
() => new Promise<boolean>((resolve) => {
|
|
resolveConflictCheck = resolve;
|
|
})
|
|
);
|
|
|
|
const panel = new FileSelectorPanel() as FileSelectorPanelConflictHarness;
|
|
panel.adapter = adapter;
|
|
panel.allFiles = [files[0]];
|
|
panel.uncheckList = new Set();
|
|
panel.extractErrorMap = new Map();
|
|
panel.newNameMap = new Map([['1', 'TTTtile-board.png']]);
|
|
|
|
const firstExecute = panel.handleExecute();
|
|
await Promise.resolve();
|
|
|
|
const secondExecute = panel.handleExecute();
|
|
await Promise.resolve();
|
|
|
|
expect(adapter.checkNameConflict).toHaveBeenCalledTimes(1);
|
|
|
|
resolveConflictCheck?.(false);
|
|
await firstExecute;
|
|
await secondExecute;
|
|
});
|
|
|
|
it('continues execution when unchanged files would only conflict with themselves', async () => {
|
|
const adapter = new ConflictTestAdapter();
|
|
adapter.checkNameConflict.mockImplementation(async (fileName: string) => fileName !== 'TTTtile-board.png');
|
|
|
|
const panel = new FileSelectorPanel() as FileSelectorPanelConflictHarness;
|
|
panel.adapter = adapter;
|
|
panel.allFiles = files;
|
|
panel.uncheckList = new Set();
|
|
panel.extractErrorMap = new Map();
|
|
panel.newNameMap = new Map([
|
|
['1', 'TTTtile-board.png'],
|
|
['2', '2p5d-slice-smoke.png'],
|
|
['3', 'default-entry-smoke.png'],
|
|
]);
|
|
|
|
await panel.handleExecute();
|
|
|
|
expect(crashRecovery.saveOperationState).toHaveBeenCalledTimes(1);
|
|
expect(crashRecovery.saveOperationState).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
files: [expect.objectContaining({ id: '1', name: 'tile-board.png' })],
|
|
})
|
|
);
|
|
expect(panel.executionItems.map((item) => item.file.id)).toEqual(['1']);
|
|
});
|
|
|
|
it('waits for user conflict resolution from the custom dialog before building the execution plan', async () => {
|
|
const adapter = new ConflictTestAdapter();
|
|
adapter.checkNameConflict.mockResolvedValue(true);
|
|
vi.spyOn(adapter, 'getAllFiles').mockResolvedValue([files[0]]);
|
|
|
|
const panel = new FileSelectorPanel() as FileSelectorPanelConflictHarness;
|
|
panel.adapter = adapter;
|
|
panel.open = true;
|
|
panel.allFiles = [files[0]];
|
|
panel.uncheckList = new Set();
|
|
panel.extractErrorMap = new Map();
|
|
panel.newNameMap = new Map([['1', 'taken.png']]);
|
|
|
|
vi.spyOn(panel, 'loadAllFiles').mockResolvedValue(undefined);
|
|
vi.spyOn(panel, 'ensureCrashRecovery').mockResolvedValue(undefined);
|
|
|
|
document.body.appendChild(panel);
|
|
await panel.updateComplete;
|
|
|
|
const executePromise = panel.handleExecute();
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
await panel.updateComplete;
|
|
|
|
expect(crashRecovery.saveOperationState).not.toHaveBeenCalled();
|
|
|
|
await vi.waitFor(() => {
|
|
const dialog = panel.shadowRoot?.querySelector('conflict-resolution-dialog') as ConflictDialogElement | null;
|
|
expect(dialog).toBeTruthy();
|
|
expect(dialog?.open).toBe(true);
|
|
});
|
|
|
|
const dialog = panel.shadowRoot?.querySelector('conflict-resolution-dialog') as ConflictDialogElement | null;
|
|
dialog?.dispatchEvent(new CustomEvent('conflict-dialog-resolve', {
|
|
detail: { resolution: ConflictResolution.AUTO_NUMBER },
|
|
bubbles: true,
|
|
composed: true,
|
|
}));
|
|
|
|
await executePromise;
|
|
|
|
expect(crashRecovery.saveOperationState).toHaveBeenCalledTimes(1);
|
|
expect(panel.executionItems[0].newName).toMatch(/taken\(1\)\.png/);
|
|
|
|
panel.remove();
|
|
});
|
|
|
|
it('opens the custom dialog and renders real external conflicts with detail rows', async () => {
|
|
const adapter = new ConflictTestAdapter();
|
|
adapter.checkNameConflict.mockResolvedValue(true);
|
|
vi.spyOn(adapter, 'getAllFiles').mockResolvedValue([files[0]]);
|
|
|
|
const panel = new FileSelectorPanel() as FileSelectorPanelConflictHarness;
|
|
panel.adapter = adapter;
|
|
panel.open = true;
|
|
panel.allFiles = [files[0]];
|
|
panel.uncheckList = new Set();
|
|
panel.extractErrorMap = new Map();
|
|
panel.newNameMap = new Map([['1', 'taken.png']]);
|
|
|
|
vi.spyOn(panel, 'loadAllFiles').mockResolvedValue(undefined);
|
|
vi.spyOn(panel, 'ensureCrashRecovery').mockResolvedValue(undefined);
|
|
|
|
document.body.appendChild(panel);
|
|
await panel.updateComplete;
|
|
|
|
const executePromise = panel.handleExecute();
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
await panel.updateComplete;
|
|
|
|
const expectedItems = [
|
|
{
|
|
fileId: '1',
|
|
originalName: 'tile-board.png',
|
|
targetName: 'taken.png',
|
|
type: ConflictType.NAME_EXISTS,
|
|
conflictingFiles: [],
|
|
},
|
|
];
|
|
|
|
await vi.waitFor(() => {
|
|
const dialog = panel.shadowRoot?.querySelector('conflict-resolution-dialog') as ConflictDialogElement | null;
|
|
expect(dialog).toBeTruthy();
|
|
expect(dialog?.items).toEqual(expectedItems);
|
|
});
|
|
|
|
const dialog = panel.shadowRoot?.querySelector('conflict-resolution-dialog') as ConflictDialogElement | null;
|
|
dialog?.dispatchEvent(new CustomEvent('dialog-close', { bubbles: true, composed: true }));
|
|
await executePromise;
|
|
|
|
expect(crashRecovery.saveOperationState).not.toHaveBeenCalled();
|
|
|
|
panel.remove();
|
|
});
|
|
});
|