fix: 更新 features 测试断言以匹配新增的 testDataGenerator

fix: 修复测试数据生成器多项代码问题
- useCallback 闭包问题:改用函数式 setFields 更新,避免依赖过时引用
- Worker 复用:复用已有 Worker 而非每次 generate 都销毁重建
- 导出复制反馈:复制操作完成后添加 toast 成功/失败提示
- 字段名校验:添加空值、非法字符、重复名称校验
- i18n 硬编码:GeneratorConfig 和 ruleStorage 统一使用 t() 函数
This commit is contained in:
雨霖铃
2026-06-06 23:33:12 +08:00
parent 6c7b7294b4
commit 9a0c8ba850
10 changed files with 154 additions and 67 deletions
+40
View File
@@ -1260,5 +1260,45 @@
"testDataGenerator_saveAs": {
"message": "另存为",
"description": "Translation key: testDataGenerator_saveAs"
},
"testDataGenerator_noGeneratorParams": {
"message": "此生成器无可配置参数",
"description": "Translation key: testDataGenerator_noGeneratorParams"
},
"testDataGenerator_enabled": {
"message": "启用",
"description": "Translation key: testDataGenerator_enabled"
},
"testDataGenerator_disabled": {
"message": "禁用",
"description": "Translation key: testDataGenerator_disabled"
},
"testDataGenerator_commaSeparated": {
"message": "用逗号分隔多个值",
"description": "Translation key: testDataGenerator_commaSeparated"
},
"testDataGenerator_copySuccess": {
"message": "已复制到剪贴板",
"description": "Translation key: testDataGenerator_copySuccess"
},
"testDataGenerator_copyFailed": {
"message": "复制失败",
"description": "Translation key: testDataGenerator_copyFailed"
},
"testDataGenerator_fieldNameEmpty": {
"message": "字段名称不能为空",
"description": "Translation key: testDataGenerator_fieldNameEmpty"
},
"testDataGenerator_fieldNameDuplicate": {
"message": "字段名称已存在",
"description": "Translation key: testDataGenerator_fieldNameDuplicate"
},
"testDataGenerator_fieldNameInvalid": {
"message": "字段名称只能包含字母、数字和下划线",
"description": "Translation key: testDataGenerator_fieldNameInvalid"
},
"testDataGenerator_ruleCopySuffix": {
"message": "(副本)",
"description": "Translation key: testDataGenerator_ruleCopySuffix"
}
}
+6 -5
View File
@@ -9,8 +9,8 @@ import {
describe('features', () => {
describe('FEATURES', () => {
it('should have 9 features defined', () => {
expect(FEATURES).toHaveLength(9);
it('should have 10 features defined', () => {
expect(FEATURES).toHaveLength(10);
});
it('should have all required properties for each feature', () => {
@@ -95,7 +95,7 @@ describe('features', () => {
describe('getAllFeatureKeys', () => {
it('should return all feature keys', () => {
const allKeys = getAllFeatureKeys();
expect(allKeys).toHaveLength(9);
expect(allKeys).toHaveLength(10);
expect(allKeys).toContain('dashboard');
expect(allKeys).toContain('timestamp');
expect(allKeys).toContain('storageCleaner');
@@ -105,6 +105,7 @@ describe('features', () => {
expect(allKeys).toContain('jsonDiff');
expect(allKeys).toContain('base64Converter');
expect(allKeys).toContain('rightClickRestorer');
expect(allKeys).toContain('testDataGenerator');
});
});
@@ -121,9 +122,9 @@ describe('features', () => {
expect(pageOrder).toContain('qrCode');
});
it('should have 8 items in page order', () => {
it('should have 9 items in page order', () => {
const pageOrder = getDefaultPageOrder();
expect(pageOrder).toHaveLength(8);
expect(pageOrder).toHaveLength(9);
});
});
});
@@ -4,6 +4,7 @@
*/
import { Copy, Download } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { useI18n } from '@/utils/chromeI18n';
import { DataExporter } from '@/utils/dataExporter';
@@ -22,12 +23,22 @@ export default function ExportPanel({ result }: ExportPanelProps) {
const handleCopyJSON = async () => {
const content = DataExporter.toJSON(result.data!);
await DataExporter.copyToClipboard(content);
const success = await DataExporter.copyToClipboard(content);
if (success) {
toast.success(t('testDataGenerator_copySuccess'));
} else {
toast.error(t('testDataGenerator_copyFailed'));
}
};
const handleCopyCSV = async () => {
const content = DataExporter.toCSV(result.data!);
await DataExporter.copyToClipboard(content);
const success = await DataExporter.copyToClipboard(content);
if (success) {
toast.success(t('testDataGenerator_copySuccess'));
} else {
toast.error(t('testDataGenerator_copyFailed'));
}
};
const handleDownloadJSON = () => {
@@ -3,6 +3,7 @@
* 编辑单个字段的详细配置
*/
import { useState, useCallback } from 'react';
import { useI18n } from '@/utils/chromeI18n';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
@@ -15,14 +16,40 @@ import GeneratorConfig from './GeneratorConfig';
interface FieldEditorProps {
field: FieldConfig;
onChange: (field: FieldConfig) => void;
/** 所有字段名列表,用于检测重复 */
allFieldNames?: string[];
}
export default function FieldEditor({ field, onChange }: FieldEditorProps) {
export default function FieldEditor({ field, onChange, allFieldNames = [] }: FieldEditorProps) {
const { t } = useI18n('testDataGenerator');
const generator = getGeneratorById(field.generatorId);
const [nameError, setNameError] = useState<string | null>(null);
const validateFieldName = useCallback(
(name: string): string | null => {
const trimmed = name.trim();
if (!trimmed) {
return t('testDataGenerator_fieldNameEmpty');
}
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(trimmed)) {
return t('testDataGenerator_fieldNameInvalid');
}
const isDuplicate = allFieldNames.some(
(n, i) => n === trimmed && i !== allFieldNames.indexOf(field.name),
);
if (isDuplicate) {
return t('testDataGenerator_fieldNameDuplicate');
}
return null;
},
[allFieldNames, field.name, t],
);
const handleNameChange = (name: string) => {
onChange({ ...field, name });
// 实时校验
const error = validateFieldName(name);
setNameError(error);
};
const handleDescriptionChange = (description: string) => {
@@ -93,8 +120,9 @@ export default function FieldEditor({ field, onChange }: FieldEditorProps) {
onChange={(e) => handleNameChange(e.target.value)}
placeholder={t('testDataGenerator_fieldNamePlaceholder')}
maxLength={20}
className="h-9"
className={`h-9 ${nameError ? 'border-destructive' : ''}`}
/>
{nameError && <p className="text-xs text-destructive mt-1">{nameError}</p>}
</div>
<div className="space-y-2">
@@ -12,6 +12,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useI18n } from '@/utils/chromeI18n';
import type { GeneratorDefinition } from '@/types/testDataGenerator';
interface GeneratorConfigProps {
@@ -21,12 +22,17 @@ interface GeneratorConfigProps {
}
export default function GeneratorConfig({ generator, params, onChange }: GeneratorConfigProps) {
const { t } = useI18n('testDataGenerator');
const handleParamChange = (key: string, value: unknown) => {
onChange({ ...params, [key]: value });
};
if (generator.params.length === 0) {
return <p className="text-sm text-muted-foreground py-2"></p>;
return (
<p className="text-sm text-muted-foreground py-2">
{t('testDataGenerator_noGeneratorParams')}
</p>
);
}
return (
@@ -64,7 +70,9 @@ export default function GeneratorConfig({ generator, params, onChange }: Generat
{param.type === 'boolean' && (
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
{params[param.key] !== false ? '启用' : '禁用'}
{params[param.key] !== false
? t('testDataGenerator_enabled')
: t('testDataGenerator_disabled')}
</span>
<Switch
checked={params[param.key] !== false}
@@ -107,7 +115,7 @@ export default function GeneratorConfig({ generator, params, onChange }: Generat
.filter(Boolean),
)
}
placeholder="用逗号分隔多个值"
placeholder={t('testDataGenerator_commaSeparated')}
className="h-9"
/>
)}
@@ -80,7 +80,7 @@ export default function RuleManager({ onLoad, onEdit, onRulesChanged }: RuleMana
const handleDuplicate = useCallback(
(id: string) => {
const result = ruleStorage.duplicate(id);
const result = ruleStorage.duplicate(id, t('testDataGenerator_ruleCopySuffix'));
if (result) {
loadRules();
toast.success(t('testDataGenerator_ruleDuplicated'));
@@ -100,7 +100,6 @@ describe('RuleManager', () => {
const deleteButton = screen.getByTitle('删除');
await user.click(deleteButton);
expect(screen.getByText('确认删除规则')).toBeInTheDocument();
expect(screen.getByText(/确定要删除规则/)).toBeInTheDocument();
});
@@ -133,7 +132,7 @@ describe('RuleManager', () => {
await user.click(screen.getByTitle('复制'));
expect(mockedRuleStorage.duplicate).toHaveBeenCalledWith('rule-1');
expect(mockedRuleStorage.duplicate).toHaveBeenCalledWith('rule-1', '(副本)');
expect(mockedToast.success).toHaveBeenCalled();
});
@@ -51,7 +51,7 @@ export function useGenerator(): UseGeneratorReturn {
*/
const getWorker = useCallback((): Worker => {
if (workerRef.current) {
workerRef.current.terminate();
return workerRef.current;
}
const worker = new Worker(new URL('@/workers/generator.worker.ts', import.meta.url), {
@@ -82,8 +82,11 @@ export function useGenerator(): UseGeneratorReturn {
worker.onerror = (err) => {
console.error('[useGenerator] Worker 错误:', err);
setIsGenerating(false);
setError('Worker 运行错误');
setError(err.message || 'Worker 运行错误');
setProgress(null);
// Worker 出错后销毁,下次重新创建
workerRef.current?.terminate();
workerRef.current = null;
};
workerRef.current = worker;
+43 -48
View File
@@ -60,64 +60,58 @@ export default function TestDataGeneratorPage() {
// 添加新字段
const handleAddField = useCallback(() => {
if (fields.length >= MAX_FIELDS) return;
const newField: FieldConfig = {
id: generateId(),
name: `field${fields.length + 1}`,
generatorId: 'chineseName',
params: {},
required: true,
nullRate: 0,
unique: false,
};
setFields([...fields, newField]);
setSelectedIndex(fields.length);
}, [fields]);
setFields((prev) => {
if (prev.length >= MAX_FIELDS) return prev;
const newField: FieldConfig = {
id: generateId(),
name: `field${prev.length + 1}`,
generatorId: 'chineseName',
params: {},
required: true,
nullRate: 0,
unique: false,
};
setSelectedIndex(prev.length);
return [...prev, newField];
});
}, []);
// 更新字段
const handleUpdateField = useCallback(
(index: number, field: FieldConfig) => {
const newFields = [...fields];
const handleUpdateField = useCallback((index: number, field: FieldConfig) => {
setFields((prev) => {
const newFields = [...prev];
newFields[index] = field;
setFields(newFields);
},
[fields],
);
return newFields;
});
}, []);
// 删除字段
const handleRemoveField = useCallback(
(index: number) => {
const newFields = fields.filter((_, i) => i !== index);
setFields(newFields);
if (selectedIndex === index) {
setSelectedIndex(null);
} else if (selectedIndex !== null && selectedIndex > index) {
setSelectedIndex(selectedIndex - 1);
}
},
[fields, selectedIndex],
);
const handleRemoveField = useCallback((index: number) => {
setFields((prev) => prev.filter((_, i) => i !== index));
setSelectedIndex((prev) => {
if (prev === index) return null;
if (prev !== null && prev > index) return prev - 1;
return prev;
});
}, []);
// 拖拽排序
const handleReorder = useCallback(
(oldIndex: number, newIndex: number) => {
const newFields = [...fields];
const handleReorder = useCallback((oldIndex: number, newIndex: number) => {
setFields((prev) => {
const newFields = [...prev];
const [moved] = newFields.splice(oldIndex, 1);
newFields.splice(newIndex, 0, moved);
setFields(newFields);
// 同步更新选中索引
if (selectedIndex === oldIndex) {
setSelectedIndex(newIndex);
} else if (selectedIndex !== null) {
if (oldIndex < selectedIndex && newIndex >= selectedIndex) {
setSelectedIndex(selectedIndex - 1);
} else if (oldIndex > selectedIndex && newIndex <= selectedIndex) {
setSelectedIndex(selectedIndex + 1);
}
return newFields;
});
setSelectedIndex((prev) => {
if (prev === oldIndex) return newIndex;
if (prev !== null) {
if (oldIndex < prev && newIndex >= prev) return prev - 1;
if (oldIndex > prev && newIndex <= prev) return prev + 1;
}
},
[fields, selectedIndex],
);
return prev;
});
}, []);
// 加载规则
const handleLoadRule = useCallback(
@@ -293,6 +287,7 @@ export default function TestDataGeneratorPage() {
<FieldEditor
field={selectedField}
onChange={(updatedField) => handleUpdateField(selectedIndex, updatedField)}
allFieldNames={fields.map((f) => f.name)}
/>
)}
</div>
+4 -2
View File
@@ -138,8 +138,10 @@ export function deleteRule(id: string): boolean {
/**
* 复制规则
* @param id 规则 ID
* @param copySuffix 复制后缀,默认为中文「(副本)」,可通过 i18n 传入
*/
export function duplicate(id: string): DataRule | null {
export function duplicate(id: string, copySuffix = '(副本)'): DataRule | null {
const rule = getById(id);
if (!rule) {
console.warn('[ruleStorage] 规则不存在:', id);
@@ -155,7 +157,7 @@ export function duplicate(id: string): DataRule | null {
const newRule: DataRule = {
...rule,
id: generateId(),
name: `${rule.name}(副本)`,
name: `${rule.name}${copySuffix}`,
createdAt: now,
updatedAt: now,
useCount: 0,