feat: 新增正则替换规则和并发控制功能

- 新增正则替换规则(RegexRule),支持捕获组、自定义flags和包含扩展名选项
- 为平台配置添加maxConcurrent字段,支持批量操作并发控制
- 重构BatchExecutor实现,使用工作池模式限制最大并发数
- 在UI配置面板中添加正则规则表单及实时验证
- 更新多语言资源文件和README文档
- 添加相关单元测试确保功能稳定性
这个提交包含在:
Zain 2026-02-14 12:37:29 +08:00
父节点 ceeb2b81f1
当前提交 748bf94d6f
共有 19 个文件被更改,包括 513 次插入 和 34 次删除

5
.gitignore vendored
查看文件

@ -35,4 +35,7 @@ coverage/
*.tmp
.cache/
.serena/
.claude/
.claude/
findings.md
task_plan.md
progress.md

查看文件

@ -55,7 +55,26 @@
}
```
#### 2. 前缀规则 (Prefix)
#### 2. 正则替换规则 (Regex)
使用正则表达式批量替换文件名(默认不处理扩展名),支持捕获组与 `$1` 等替换语法。
**使用场景**:
- 交换两段内容:`001-hello` → `hello-001`
- 清理特定模式:删除 `【1080p】`、`(字幕组)` 等
**配置参数**:
```typescript
{
pattern: string; // 正则表达式(不包含前后 /)
replace: string; // 替换表达式
caseSensitive: boolean; // 是否区分大小写(false 会启用 i)
global: boolean; // 是否全局替换(true 会启用 g)
flags?: string; // 自定义 flags(高级,可选,建议填 m/s/u 等;g/i 由上面开关控制)
includeExtension?: boolean; // 是否包含扩展名(允许修改后缀)
}
```
#### 3. 前缀规则 (Prefix)
为所有文件添加统一前缀,可自定义分隔符。
**使用场景**:
@ -70,7 +89,7 @@
}
```
#### 3. 后缀规则 (Suffix)
#### 4. 后缀规则 (Suffix)
为文件名(扩展名之前)添加后缀标记。
**使用场景**:
@ -85,7 +104,7 @@
}
```
#### 4. 编号规则 (Numbering)
#### 5. 编号规则 (Numbering)
自动为文件添加序号,支持自定义格式。
**使用场景**:
@ -103,7 +122,7 @@
}
```
#### 5. 清理规则 (Sanitize)
#### 6. 清理规则 (Sanitize)
清除文件名中的非法字符或特定字符集。
**使用场景**:
@ -122,6 +141,7 @@
#### 批量处理系统
- **速率控制**:800ms 间隔执行,防止 API 限流
- **最大并发**:默认 3(按平台可调),避免请求堆积导致的失败/风控
- **重试机制**:指数退避算法,自动重试失败操作
- **崩溃恢复**:异常中断后可继续未完成的任务
- **幂等性保证**:避免重复执行相同操作

查看文件

@ -19,6 +19,7 @@ export abstract class BasePlatformAdapter implements PlatformAdapter {
this.config = {
platform: 'quark',
requestInterval: 800,
maxConcurrent: 3,
maxRetries: 3,
timeout: 30000,
...config,

查看文件

@ -105,18 +105,32 @@ export class ConfigPanel extends LitElement {
global: true,
};
@state()
private regexValidationError: string | null = null;
/**
* Handle rule type change
* @private
*/
private handleRuleChange(type: RuleType): void {
this.selectedRuleType = type;
this.regexValidationError = null;
// Reset params based on rule type
switch (type) {
case 'replace':
this.ruleParams = { search: '', replace: '', caseSensitive: false, global: true };
break;
case 'regex':
this.ruleParams = {
pattern: '',
replace: '',
caseSensitive: false,
global: true,
flags: '',
includeExtension: false,
};
break;
case 'prefix':
this.ruleParams = { prefix: '', separator: '' };
break;
@ -137,6 +151,7 @@ export class ConfigPanel extends LitElement {
break;
}
this.validateRegexIfNeeded();
this.emitConfigChange();
}
@ -150,9 +165,64 @@ export class ConfigPanel extends LitElement {
[key]: value,
};
this.validateRegexIfNeeded();
this.emitConfigChange();
}
private validateRegexIfNeeded(): void {
if (this.selectedRuleType !== 'regex') {
this.regexValidationError = null;
return;
}
const pattern = String(this.ruleParams.pattern || '');
if (!pattern) {
this.regexValidationError = null;
return;
}
const flags = this.getRegexFlags(this.ruleParams);
try {
// eslint-disable-next-line no-new
new RegExp(pattern, flags);
this.regexValidationError = null;
} catch (error) {
const errorObj = error instanceof Error ? error : new Error(String(error));
this.regexValidationError = errorObj.message;
}
}
private getRegexFlags(params: Record<string, any>): string {
const global = Boolean(params.global);
const caseSensitive = Boolean(params.caseSensitive);
const base = (global ? 'g' : '') + (caseSensitive ? '' : 'i');
// 自定义 flags(高级,可选):仅允许追加其它 flags,g/i 由开关控制
const raw = String(params.flags || '');
const cleaned = raw.replace(/[^a-z]/gi, '').toLowerCase();
const extraSet = new Set<string>();
for (const ch of cleaned) {
if (ch === 'g' || ch === 'i') continue;
extraSet.add(ch);
}
// 与规则实现保持一致的稳定顺序
const preferredOrder = ['m', 's', 'u', 'y', 'd', 'v'];
const extras: string[] = [];
for (const ch of preferredOrder) {
if (extraSet.has(ch)) {
extras.push(ch);
extraSet.delete(ch);
}
}
for (const ch of extraSet) {
extras.push(ch);
}
return base + extras.join('');
}
/**
* Emit config change event
* @private
@ -382,6 +452,7 @@ export class ConfigPanel extends LitElement {
private renderRuleSelector() {
const rules: { type: RuleType; label: string }[] = [
{ type: 'replace', label: I18nService.t('rule_replace') },
{ type: 'regex', label: I18nService.t('rule_regex') },
{ type: 'prefix', label: I18nService.t('rule_prefix') },
{ type: 'suffix', label: I18nService.t('rule_suffix') },
{ type: 'numbering', label: I18nService.t('rule_numbering') },
@ -477,6 +548,83 @@ export class ConfigPanel extends LitElement {
</div>
`;
case 'regex': {
const flags = this.getRegexFlags(this.ruleParams);
return html`
<div class="form-group">
<label class="form-label">${I18nService.t('param_regex_pattern')}</label>
<input
type="text"
class="form-input"
.value=${this.ruleParams.pattern || ''}
@input=${(e: Event) => this.updateParam('pattern', (e.target as HTMLInputElement).value)}
placeholder="${I18nService.t('param_regex_pattern_placeholder')}"
/>
${this.regexValidationError
? html`<div class="hint-text hint-text-error">
${I18nService.t('param_regex_invalid')}: ${this.regexValidationError}
</div>`
: html`<div class="hint-text">${I18nService.t('param_regex_flags_hint', [flags || '(none)'])}</div>`}
</div>
<div class="form-group">
<label class="form-label">${I18nService.t('param_replace_with')}</label>
<input
type="text"
class="form-input"
.value=${this.ruleParams.replace || ''}
@input=${(e: Event) => this.updateParam('replace', (e.target as HTMLInputElement).value)}
placeholder="${I18nService.t('param_replace_placeholder')}"
/>
</div>
<div class="form-group">
<label class="form-checkbox">
<input
type="checkbox"
?checked=${this.ruleParams.caseSensitive || false}
@change=${(e: Event) => this.updateParam('caseSensitive', (e.target as HTMLInputElement).checked)}
/>
<span>${I18nService.t('param_case_sensitive')}</span>
</label>
</div>
<div class="form-group">
<label class="form-checkbox">
<input
type="checkbox"
?checked=${this.ruleParams.global ?? true}
@change=${(e: Event) => this.updateParam('global', (e.target as HTMLInputElement).checked)}
/>
<span>${I18nService.t('param_replace_all')}</span>
</label>
</div>
<div class="form-group">
<label class="form-label">${I18nService.t('param_regex_flags')}</label>
<input
type="text"
class="form-input"
.value=${this.ruleParams.flags || ''}
@input=${(e: Event) => this.updateParam('flags', (e.target as HTMLInputElement).value)}
placeholder="${I18nService.t('param_regex_flags_placeholder')}"
/>
<div class="hint-text">${I18nService.t('param_regex_flags_extra_hint')}</div>
</div>
<div class="form-group">
<label class="form-checkbox">
<input
type="checkbox"
?checked=${this.ruleParams.includeExtension || false}
@change=${(e: Event) => this.updateParam('includeExtension', (e.target as HTMLInputElement).checked)}
/>
<span>${I18nService.t('param_regex_include_extension')}</span>
</label>
</div>
`;
}
case 'prefix':
return html`
<div class="form-group">
@ -909,6 +1057,10 @@ export class ConfigPanel extends LitElement {
line-height: 1.5;
}
.hint-text-error {
color: var(--cdr-danger, #ff4d4f);
}
.warning-message {
padding: 8px 12px;
background: var(--cdr-warning-bg, #fff7e6);

查看文件

@ -481,6 +481,7 @@ export class FileSelectorPanel extends LitElement {
this.adapter,
{
requestInterval: this.adapter.getConfig().requestInterval,
maxConcurrent: this.adapter.getConfig().maxConcurrent,
skipUnchanged: true,
onProgress: (progress) => {
this.handleProgress(progress);
@ -694,6 +695,7 @@ export class FileSelectorPanel extends LitElement {
const executor = new BatchExecutor(retryFiles, this.ruleConfig, this.adapter, {
requestInterval: this.adapter.getConfig().requestInterval,
maxConcurrent: this.adapter.getConfig().maxConcurrent,
tasks,
onProgress: (progress) => {
this.handleProgress(progress);

查看文件

@ -399,6 +399,10 @@ export class RenamePreview extends LitElement {
// Replace 规则要求 search 非空
return typeof params.search === 'string' && params.search.length > 0;
case 'regex':
// Regex 规则要求 pattern 非空
return typeof params.pattern === 'string' && params.pattern.length > 0;
case 'prefix':
// Prefix 规则要求 prefix 非空
return typeof params.prefix === 'string' && params.prefix.length > 0;

查看文件

@ -125,8 +125,10 @@ export class CrashRecoveryManager {
});
// 创建新的executor继续执行
const { requestInterval, maxConcurrent } = adapter.getConfig();
const executor = new BatchExecutor(pendingFiles, savedState.rule, adapter, {
requestInterval: 800,
requestInterval: requestInterval ?? 800,
maxConcurrent,
onProgress: (progress) => {
// 转发进度事件
onProgress?.(progress);

查看文件

@ -10,6 +10,8 @@ import { RuleFactory } from '../rules/rule-factory';
export interface BatchExecutorOptions {
/** 请求间隔(毫秒) */
requestInterval: number;
/** 最大并发请求数(默认从平台配置读取,兜底为 3) */
maxConcurrent?: number;
/** 是否跳过无需变更的任务(默认不跳过,保持与平台行为一致) */
skipUnchanged?: boolean;
/** 自定义任务列表(可选,用于重试等场景,优先级高于 rule 生成的任务) */
@ -38,7 +40,7 @@ export enum ExecutorState {
*
* 核心功能:
* - 800ms请求间隔控制
* - 不限制并发数的并发执行策略
* - 最大并发控制(默认 3,可配置)
* - 进度事件系统
* - 支持暂停/恢复/取消功能
*
@ -68,6 +70,8 @@ export class BatchExecutor {
private pausePromise: Promise<void> | null = null;
private pauseResolver: (() => void) | null = null;
private abortController: AbortController | null = null;
private rateLimitChain: Promise<void> = Promise.resolve();
private lastRequestStartAt = 0;
private isCancelled(): boolean {
return this.state === ExecutorState.CANCELLED;
@ -104,6 +108,8 @@ export class BatchExecutor {
this.abortController = new AbortController();
this.results = { success: [], failed: [] };
this.tasks = [];
this.rateLimitChain = Promise.resolve();
this.lastRequestStartAt = 0;
try {
// 准备所有重命名任务
@ -119,13 +125,38 @@ export class BatchExecutor {
return this.results;
}
// 并发执行,但控制请求间隔
const promises = tasks.map((task, i) =>
this.delayedRename(task, i * this.options.requestInterval)
);
// 并发执行(限制最大并发 + 全局请求间隔)
const maxConcurrent = this.getMaxConcurrent(tasks.length);
let cursor = 0;
// 等待所有任务完成
await Promise.allSettled(promises);
const runWorker = async () => {
for (;;) {
const task = tasks[cursor++];
if (!task) return;
// 取消/暂停检查
if (this.isCancelled()) return;
if (this.state === ExecutorState.PAUSED && this.pausePromise) {
await this.pausePromise;
}
if (this.isCancelled()) return;
// 全局节流:确保相邻请求的 start 间隔 >= requestInterval
await this.waitForRequestSlot();
// 再次检查(避免在等待 slot 期间被暂停/取消)
if (this.isCancelled()) return;
if (this.state === ExecutorState.PAUSED && this.pausePromise) {
await this.pausePromise;
}
if (this.isCancelled()) return;
await this.processTask(task);
}
};
const workers = Array.from({ length: maxConcurrent }, () => runWorker());
await Promise.allSettled(workers);
// 保持 CANCELLED 状态,避免被覆盖为 COMPLETED
if (!this.isCancelled()) {
@ -226,25 +257,7 @@ export class BatchExecutor {
* 延迟执行重命名
* @private
*/
private async delayedRename(task: Task, delay: number): Promise<void> {
// 等待延迟
await sleep(delay);
// 检查是否已取消
if (this.isCancelled()) {
return;
}
// 检查是否暂停
if (this.state === ExecutorState.PAUSED && this.pausePromise) {
await this.pausePromise;
}
// 再次检查是否已取消(可能发生在暂停期间)
if (this.isCancelled()) {
return;
}
private async processTask(task: Task): Promise<void> {
try {
const result = await this.adapter.renameFile(task.file.id, task.newName);
@ -278,6 +291,38 @@ export class BatchExecutor {
}
}
private getMaxConcurrent(taskCount: number): number {
const fallback = this.adapter.getConfig().maxConcurrent ?? 3;
const raw = this.options.maxConcurrent ?? fallback;
const n = Math.max(1, Math.trunc(raw));
return Math.min(n, Math.max(1, taskCount));
}
private async waitForRequestSlot(): Promise<void> {
const interval = this.options.requestInterval;
if (!interval || interval <= 0) {
return;
}
const doWait = async () => {
const now = Date.now();
if (this.lastRequestStartAt > 0) {
const delta = now - this.lastRequestStartAt;
const delay = interval - delta;
if (delay > 0) {
await sleep(delay);
}
}
this.lastRequestStartAt = Date.now();
};
const chained = this.rateLimitChain.then(doWait, doWait);
this.rateLimitChain = chained;
await chained;
}
/**
* 发射进度事件
* @private

查看文件

@ -35,6 +35,8 @@
"preview_section_title": "Preview (First 3 Files)",
"rule_replace": "Replace Text",
"rule_replace_desc": "Replace characters in filename",
"rule_regex": "Regex Replace",
"rule_regex_desc": "Replace filename using regular expression",
"rule_prefix": "Add Prefix",
"rule_prefix_desc": "Add text before filename",
"rule_suffix": "Add Suffix",
@ -45,6 +47,14 @@
"rule_sanitize_desc": "Remove illegal characters",
"param_search_text": "Search Text",
"param_search_placeholder": "Text to replace",
"param_regex_pattern": "Regex Pattern",
"param_regex_pattern_placeholder": "e.g. ^old_|(\\d+)",
"param_regex_invalid": "Invalid regex",
"param_regex_flags_hint": "Current flags: $1",
"param_regex_flags": "Custom Flags (Advanced)",
"param_regex_flags_placeholder": "e.g. msu",
"param_regex_flags_extra_hint": "Tip: g/i are controlled by the toggles above. Prefer entering m/s/u/y/d/v here.",
"param_regex_include_extension": "Include extension (allow changing suffix)",
"param_replace_with": "Replace With",
"param_replace_placeholder": "New text",
"param_case_sensitive": "Case Sensitive",

查看文件

@ -35,6 +35,8 @@
"preview_section_title": "预览(前3个文件)",
"rule_replace": "字符串替换",
"rule_replace_desc": "替换文件名中的字符",
"rule_regex": "正则替换",
"rule_regex_desc": "使用正则表达式替换文件名",
"rule_prefix": "添加前缀",
"rule_prefix_desc": "在文件名前添加文本",
"rule_suffix": "添加后缀",
@ -45,6 +47,14 @@
"rule_sanitize_desc": "移除非法字符",
"param_search_text": "搜索文本",
"param_search_placeholder": "要替换的文本",
"param_regex_pattern": "正则表达式",
"param_regex_pattern_placeholder": "例如: ^old_|(\\d+)",
"param_regex_invalid": "正则无效",
"param_regex_flags_hint": "当前 flags: $1",
"param_regex_flags": "自定义 flags(高级,可选)",
"param_regex_flags_placeholder": "例如: msu",
"param_regex_flags_extra_hint": "提示:g/i 由“替换所有/大小写敏感”开关控制,这里建议只填 m/s/u/y/d/v。",
"param_regex_include_extension": "包含扩展名(允许修改后缀)",
"param_replace_with": "替换为",
"param_replace_placeholder": "新的文本",
"param_case_sensitive": "大小写敏感",

查看文件

@ -35,6 +35,8 @@
"preview_section_title": "預覽(前3個檔案)",
"rule_replace": "字串替換",
"rule_replace_desc": "替換檔案名稱中的字元",
"rule_regex": "正則替換",
"rule_regex_desc": "使用正則表達式替換檔案名稱",
"rule_prefix": "新增前綴",
"rule_prefix_desc": "在檔案名稱前新增文字",
"rule_suffix": "新增後綴",
@ -45,6 +47,14 @@
"rule_sanitize_desc": "移除非法字元",
"param_search_text": "搜尋文字",
"param_search_placeholder": "要替換的文字",
"param_regex_pattern": "正則表達式",
"param_regex_pattern_placeholder": "例如: ^old_|(\\d+)",
"param_regex_invalid": "正則無效",
"param_regex_flags_hint": "目前 flags: $1",
"param_regex_flags": "自訂 flags(進階,可選)",
"param_regex_flags_placeholder": "例如: msu",
"param_regex_flags_extra_hint": "提示:g/i 由「替換所有/區分大小寫」開關控制,這裡建議只填 m/s/u/y/d/v。",
"param_regex_include_extension": "包含副檔名(允許修改後綴)",
"param_replace_with": "替換為",
"param_replace_placeholder": "新的文字",
"param_case_sensitive": "區分大小寫",

93
src/rules/regex.ts 普通文件
查看文件

@ -0,0 +1,93 @@
import { BaseRule } from './base-rule';
import { RegexRuleParams } from '../types/rule';
/**
* 正则替换规则
* 支持使用正则表达式对“文件名(不含扩展名)”进行替换。
*
* 注意:为了避免误伤扩展名,这里默认不处理扩展名部分。
*/
export class RegexRule extends BaseRule {
protected config!: RegexRuleParams;
private getFlags(): string {
const { caseSensitive = false, global = false, flags = '' } = this.config;
const base = (global ? 'g' : '') + (caseSensitive ? '' : 'i');
// Keep only letters, lower-cased; `g/i` are controlled by toggles.
const cleaned = String(flags).replace(/[^a-z]/gi, '').toLowerCase();
const extraSet = new Set<string>();
for (const ch of cleaned) {
if (ch === 'g' || ch === 'i') continue;
extraSet.add(ch);
}
// Stable ordering for common flags to reduce user confusion.
const preferredOrder = ['m', 's', 'u', 'y', 'd', 'v'];
const extras: string[] = [];
for (const ch of preferredOrder) {
if (extraSet.has(ch)) {
extras.push(ch);
extraSet.delete(ch);
}
}
// Append any remaining flags in first-seen order.
for (const ch of extraSet) {
extras.push(ch);
}
return base + extras.join('');
}
execute(fileName: string): string {
const { name, ext } = this.parseFileName(fileName);
const { pattern, replace, includeExtension = false } = this.config;
const re = new RegExp(pattern, this.getFlags());
if (includeExtension) {
return fileName.replace(re, replace);
}
const newName = name.replace(re, replace);
return newName + ext;
}
validate(config: any): boolean {
if (typeof config !== 'object' || config === null) {
return false;
}
if (typeof config.pattern !== 'string' || config.pattern.length === 0) {
return false;
}
if (typeof config.replace !== 'string') {
return false;
}
if ('flags' in config && typeof config.flags !== 'string') {
return false;
}
if ('includeExtension' in config && typeof config.includeExtension !== 'boolean') {
return false;
}
const base = (config.global ? 'g' : '') + (config.caseSensitive ? '' : 'i');
const cleaned = String(config.flags ?? '').replace(/[^a-z]/gi, '').toLowerCase();
const extras = Array.from(new Set(cleaned.split(''))).filter((ch) => ch !== 'g' && ch !== 'i');
const flags = base + extras.join('');
try {
// Ensure pattern/flags are valid.
// eslint-disable-next-line no-new
new RegExp(config.pattern, flags);
} catch {
return false;
}
return true;
}
}

查看文件

@ -1,5 +1,6 @@
import { RuleConfig, RuleExecutor } from '../types/rule';
import { ReplaceRule } from './replace';
import { RegexRule } from './regex';
import { NumberingRule } from './numbering';
import { PrefixRule } from './prefix';
import { SuffixRule } from './suffix';
@ -20,6 +21,9 @@ export class RuleFactory {
case 'replace':
return new ReplaceRule(config.params as any);
case 'regex':
return new RegexRule(config.params as any);
case 'prefix':
return new PrefixRule(config.params as any);

查看文件

@ -10,7 +10,7 @@
* 1. Edit version in package.json
* 2. Run: npm run sync:version (or automatically via prebuild/predev)
*
* Generated at: 2025-12-30T09:25:09.857Z
* Generated at: 2026-02-14T04:29:55.417Z
*/
/**

查看文件

@ -120,6 +120,8 @@ export interface PlatformConfig {
platform: PlatformName;
/** API请求间隔(毫秒) */
requestInterval: number;
/** 最大并发请求数(用于批量重命名等操作) */
maxConcurrent?: number;
/** 最大重试次数 */
maxRetries: number;
/** 请求超时时间(毫秒) */

查看文件

@ -1,7 +1,7 @@
/**
* 重命名规则类型
*/
export type RuleType = 'replace' | 'prefix' | 'suffix' | 'numbering' | 'sanitize';
export type RuleType = 'replace' | 'regex' | 'prefix' | 'suffix' | 'numbering' | 'sanitize';
/**
* 规则配置接口
@ -27,6 +27,31 @@ export interface ReplaceRuleParams {
global?: boolean;
}
/**
* 正则替换规则参数
*/
export interface RegexRuleParams {
/** 正则表达式(不包含前后 /) */
pattern: string;
/** 替换表达式 */
replace: string;
/** 是否大小写敏感 */
caseSensitive?: boolean;
/** 是否全局替换 */
global?: boolean;
/**
* 自定义 flags(高级,可选)
* - `g/i` 由 `global/caseSensitive` 控制,建议这里输入其它 flags(如 `m/s/u`)
*/
flags?: string;
/**
* 是否在匹配时包含扩展名(允许修改后缀)
* - false(默认):只对文件名主体(不含扩展名)做替换
* - true:对完整文件名(含扩展名)做替换
*/
includeExtension?: boolean;
}
/**
* 前缀规则参数
*/

查看文件

@ -69,6 +69,7 @@ describe('BasePlatformAdapter', () => {
expect(config.platform).toBe('quark');
expect(config.requestInterval).toBe(800);
expect(config.maxConcurrent).toBe(3);
expect(config.maxRetries).toBe(3);
expect(config.timeout).toBe(30000);
});
@ -83,6 +84,7 @@ describe('BasePlatformAdapter', () => {
expect(config.platform).toBe('quark');
expect(config.requestInterval).toBe(1000);
expect(config.maxConcurrent).toBe(3); // 保持默认值
expect(config.maxRetries).toBe(5);
expect(config.timeout).toBe(30000); // 保持默认值
});
@ -91,6 +93,7 @@ describe('BasePlatformAdapter', () => {
const customConfig = {
platform: 'aliyun' as PlatformName,
requestInterval: 1500,
maxConcurrent: 5,
maxRetries: 10,
timeout: 60000,
};

查看文件

@ -206,6 +206,34 @@ describe('BatchExecutor', () => {
expect(results.success.length + results.failed.length).toBe(files.length);
});
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', () => {

查看文件

@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest';
import { ReplaceRule } from '../../src/rules/replace';
import { RegexRule } from '../../src/rules/regex';
import { NumberingRule } from '../../src/rules/numbering';
import { RuleFactory } from '../../src/rules/rule-factory';
@ -82,6 +83,58 @@ describe('NumberingRule', () => {
});
});
describe('RegexRule', () => {
it('should replace using regex pattern', () => {
const rule = new RegexRule({
pattern: '^old_',
replace: 'new_',
caseSensitive: false,
global: false,
});
const result = rule.execute('old_file.txt', 0, 1);
expect(result).toBe('new_file.txt');
});
it('should support capture groups', () => {
const rule = new RegexRule({
pattern: '^(\\d+)-(.*)$',
replace: '$2-$1',
caseSensitive: false,
global: false,
});
const result = rule.execute('001-hello.txt', 0, 1);
expect(result).toBe('hello-001.txt');
});
it('should ignore g/i in custom flags and respect toggles', () => {
const rule = new RegexRule({
pattern: 'a',
replace: 'b',
caseSensitive: false,
global: false,
flags: 'g', // should NOT force global replace
});
const result = rule.execute('aaa.txt', 0, 1);
expect(result).toBe('baa.txt');
});
it('should allow updating extension when includeExtension is true', () => {
const rule = new RegexRule({
pattern: '\\.jpeg$',
replace: '.jpg',
caseSensitive: false,
global: false,
includeExtension: true,
});
const result = rule.execute('photo.jpeg', 0, 1);
expect(result).toBe('photo.jpg');
});
});
describe('RuleFactory', () => {
it('should create replace rule', () => {
const rule = RuleFactory.create({
@ -95,6 +148,18 @@ describe('RuleFactory', () => {
expect(rule).toBeInstanceOf(ReplaceRule);
});
it('should create regex rule', () => {
const rule = RuleFactory.create({
type: 'regex',
params: {
pattern: 'a+',
replace: 'b',
},
});
expect(rule).toBeInstanceOf(RegexRule);
});
it('should create numbering rule', () => {
const rule = RuleFactory.create({
type: 'numbering',