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": { "testDataGenerator_saveAs": {
"message": "另存为", "message": "另存为",
"description": "Translation key: testDataGenerator_saveAs" "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', () => {
describe('FEATURES', () => { describe('FEATURES', () => {
it('should have 9 features defined', () => { it('should have 10 features defined', () => {
expect(FEATURES).toHaveLength(9); expect(FEATURES).toHaveLength(10);
}); });
it('should have all required properties for each feature', () => { it('should have all required properties for each feature', () => {
@@ -95,7 +95,7 @@ describe('features', () => {
describe('getAllFeatureKeys', () => { describe('getAllFeatureKeys', () => {
it('should return all feature keys', () => { it('should return all feature keys', () => {
const allKeys = getAllFeatureKeys(); const allKeys = getAllFeatureKeys();
expect(allKeys).toHaveLength(9); expect(allKeys).toHaveLength(10);
expect(allKeys).toContain('dashboard'); expect(allKeys).toContain('dashboard');
expect(allKeys).toContain('timestamp'); expect(allKeys).toContain('timestamp');
expect(allKeys).toContain('storageCleaner'); expect(allKeys).toContain('storageCleaner');
@@ -105,6 +105,7 @@ describe('features', () => {
expect(allKeys).toContain('jsonDiff'); expect(allKeys).toContain('jsonDiff');
expect(allKeys).toContain('base64Converter'); expect(allKeys).toContain('base64Converter');
expect(allKeys).toContain('rightClickRestorer'); expect(allKeys).toContain('rightClickRestorer');
expect(allKeys).toContain('testDataGenerator');
}); });
}); });
@@ -121,9 +122,9 @@ describe('features', () => {
expect(pageOrder).toContain('qrCode'); expect(pageOrder).toContain('qrCode');
}); });
it('should have 8 items in page order', () => { it('should have 9 items in page order', () => {
const pageOrder = getDefaultPageOrder(); const pageOrder = getDefaultPageOrder();
expect(pageOrder).toHaveLength(8); expect(pageOrder).toHaveLength(9);
}); });
}); });
}); });
@@ -4,6 +4,7 @@
*/ */
import { Copy, Download } from 'lucide-react'; import { Copy, Download } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useI18n } from '@/utils/chromeI18n'; import { useI18n } from '@/utils/chromeI18n';
import { DataExporter } from '@/utils/dataExporter'; import { DataExporter } from '@/utils/dataExporter';
@@ -22,12 +23,22 @@ export default function ExportPanel({ result }: ExportPanelProps) {
const handleCopyJSON = async () => { const handleCopyJSON = async () => {
const content = DataExporter.toJSON(result.data!); 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 handleCopyCSV = async () => {
const content = DataExporter.toCSV(result.data!); 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 = () => { const handleDownloadJSON = () => {
@@ -3,6 +3,7 @@
* 编辑单个字段的详细配置 * 编辑单个字段的详细配置
*/ */
import { useState, useCallback } from 'react';
import { useI18n } from '@/utils/chromeI18n'; import { useI18n } from '@/utils/chromeI18n';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
@@ -15,14 +16,40 @@ import GeneratorConfig from './GeneratorConfig';
interface FieldEditorProps { interface FieldEditorProps {
field: FieldConfig; field: FieldConfig;
onChange: (field: FieldConfig) => void; 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 { t } = useI18n('testDataGenerator');
const generator = getGeneratorById(field.generatorId); 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) => { const handleNameChange = (name: string) => {
onChange({ ...field, name }); onChange({ ...field, name });
// 实时校验
const error = validateFieldName(name);
setNameError(error);
}; };
const handleDescriptionChange = (description: string) => { const handleDescriptionChange = (description: string) => {
@@ -93,8 +120,9 @@ export default function FieldEditor({ field, onChange }: FieldEditorProps) {
onChange={(e) => handleNameChange(e.target.value)} onChange={(e) => handleNameChange(e.target.value)}
placeholder={t('testDataGenerator_fieldNamePlaceholder')} placeholder={t('testDataGenerator_fieldNamePlaceholder')}
maxLength={20} maxLength={20}
className="h-9" className={`h-9 ${nameError ? 'border-destructive' : ''}`}
/> />
{nameError && <p className="text-xs text-destructive mt-1">{nameError}</p>}
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@@ -12,6 +12,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { useI18n } from '@/utils/chromeI18n';
import type { GeneratorDefinition } from '@/types/testDataGenerator'; import type { GeneratorDefinition } from '@/types/testDataGenerator';
interface GeneratorConfigProps { interface GeneratorConfigProps {
@@ -21,12 +22,17 @@ interface GeneratorConfigProps {
} }
export default function GeneratorConfig({ generator, params, onChange }: GeneratorConfigProps) { export default function GeneratorConfig({ generator, params, onChange }: GeneratorConfigProps) {
const { t } = useI18n('testDataGenerator');
const handleParamChange = (key: string, value: unknown) => { const handleParamChange = (key: string, value: unknown) => {
onChange({ ...params, [key]: value }); onChange({ ...params, [key]: value });
}; };
if (generator.params.length === 0) { 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 ( return (
@@ -64,7 +70,9 @@ export default function GeneratorConfig({ generator, params, onChange }: Generat
{param.type === 'boolean' && ( {param.type === 'boolean' && (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
{params[param.key] !== false ? '启用' : '禁用'} {params[param.key] !== false
? t('testDataGenerator_enabled')
: t('testDataGenerator_disabled')}
</span> </span>
<Switch <Switch
checked={params[param.key] !== false} checked={params[param.key] !== false}
@@ -107,7 +115,7 @@ export default function GeneratorConfig({ generator, params, onChange }: Generat
.filter(Boolean), .filter(Boolean),
) )
} }
placeholder="用逗号分隔多个值" placeholder={t('testDataGenerator_commaSeparated')}
className="h-9" className="h-9"
/> />
)} )}
@@ -80,7 +80,7 @@ export default function RuleManager({ onLoad, onEdit, onRulesChanged }: RuleMana
const handleDuplicate = useCallback( const handleDuplicate = useCallback(
(id: string) => { (id: string) => {
const result = ruleStorage.duplicate(id); const result = ruleStorage.duplicate(id, t('testDataGenerator_ruleCopySuffix'));
if (result) { if (result) {
loadRules(); loadRules();
toast.success(t('testDataGenerator_ruleDuplicated')); toast.success(t('testDataGenerator_ruleDuplicated'));
@@ -100,7 +100,6 @@ describe('RuleManager', () => {
const deleteButton = screen.getByTitle('删除'); const deleteButton = screen.getByTitle('删除');
await user.click(deleteButton); await user.click(deleteButton);
expect(screen.getByText('确认删除规则')).toBeInTheDocument();
expect(screen.getByText(/确定要删除规则/)).toBeInTheDocument(); expect(screen.getByText(/确定要删除规则/)).toBeInTheDocument();
}); });
@@ -133,7 +132,7 @@ describe('RuleManager', () => {
await user.click(screen.getByTitle('复制')); await user.click(screen.getByTitle('复制'));
expect(mockedRuleStorage.duplicate).toHaveBeenCalledWith('rule-1'); expect(mockedRuleStorage.duplicate).toHaveBeenCalledWith('rule-1', '(副本)');
expect(mockedToast.success).toHaveBeenCalled(); expect(mockedToast.success).toHaveBeenCalled();
}); });
@@ -51,7 +51,7 @@ export function useGenerator(): UseGeneratorReturn {
*/ */
const getWorker = useCallback((): Worker => { const getWorker = useCallback((): Worker => {
if (workerRef.current) { if (workerRef.current) {
workerRef.current.terminate(); return workerRef.current;
} }
const worker = new Worker(new URL('@/workers/generator.worker.ts', import.meta.url), { const worker = new Worker(new URL('@/workers/generator.worker.ts', import.meta.url), {
@@ -82,8 +82,11 @@ export function useGenerator(): UseGeneratorReturn {
worker.onerror = (err) => { worker.onerror = (err) => {
console.error('[useGenerator] Worker 错误:', err); console.error('[useGenerator] Worker 错误:', err);
setIsGenerating(false); setIsGenerating(false);
setError('Worker 运行错误'); setError(err.message || 'Worker 运行错误');
setProgress(null); setProgress(null);
// Worker 出错后销毁,下次重新创建
workerRef.current?.terminate();
workerRef.current = null;
}; };
workerRef.current = worker; workerRef.current = worker;
+43 -48
View File
@@ -60,64 +60,58 @@ export default function TestDataGeneratorPage() {
// 添加新字段 // 添加新字段
const handleAddField = useCallback(() => { const handleAddField = useCallback(() => {
if (fields.length >= MAX_FIELDS) return; setFields((prev) => {
const newField: FieldConfig = { if (prev.length >= MAX_FIELDS) return prev;
id: generateId(), const newField: FieldConfig = {
name: `field${fields.length + 1}`, id: generateId(),
generatorId: 'chineseName', name: `field${prev.length + 1}`,
params: {}, generatorId: 'chineseName',
required: true, params: {},
nullRate: 0, required: true,
unique: false, nullRate: 0,
}; unique: false,
setFields([...fields, newField]); };
setSelectedIndex(fields.length); setSelectedIndex(prev.length);
}, [fields]); return [...prev, newField];
});
}, []);
// 更新字段 // 更新字段
const handleUpdateField = useCallback( const handleUpdateField = useCallback((index: number, field: FieldConfig) => {
(index: number, field: FieldConfig) => { setFields((prev) => {
const newFields = [...fields]; const newFields = [...prev];
newFields[index] = field; newFields[index] = field;
setFields(newFields); return newFields;
}, });
[fields], }, []);
);
// 删除字段 // 删除字段
const handleRemoveField = useCallback( const handleRemoveField = useCallback((index: number) => {
(index: number) => { setFields((prev) => prev.filter((_, i) => i !== index));
const newFields = fields.filter((_, i) => i !== index); setSelectedIndex((prev) => {
setFields(newFields); if (prev === index) return null;
if (selectedIndex === index) { if (prev !== null && prev > index) return prev - 1;
setSelectedIndex(null); return prev;
} else if (selectedIndex !== null && selectedIndex > index) { });
setSelectedIndex(selectedIndex - 1); }, []);
}
},
[fields, selectedIndex],
);
// 拖拽排序 // 拖拽排序
const handleReorder = useCallback( const handleReorder = useCallback((oldIndex: number, newIndex: number) => {
(oldIndex: number, newIndex: number) => { setFields((prev) => {
const newFields = [...fields]; const newFields = [...prev];
const [moved] = newFields.splice(oldIndex, 1); const [moved] = newFields.splice(oldIndex, 1);
newFields.splice(newIndex, 0, moved); newFields.splice(newIndex, 0, moved);
setFields(newFields); return newFields;
// 同步更新选中索引 });
if (selectedIndex === oldIndex) { setSelectedIndex((prev) => {
setSelectedIndex(newIndex); if (prev === oldIndex) return newIndex;
} else if (selectedIndex !== null) { if (prev !== null) {
if (oldIndex < selectedIndex && newIndex >= selectedIndex) { if (oldIndex < prev && newIndex >= prev) return prev - 1;
setSelectedIndex(selectedIndex - 1); if (oldIndex > prev && newIndex <= prev) return prev + 1;
} else if (oldIndex > selectedIndex && newIndex <= selectedIndex) {
setSelectedIndex(selectedIndex + 1);
}
} }
}, return prev;
[fields, selectedIndex], });
); }, []);
// 加载规则 // 加载规则
const handleLoadRule = useCallback( const handleLoadRule = useCallback(
@@ -293,6 +287,7 @@ export default function TestDataGeneratorPage() {
<FieldEditor <FieldEditor
field={selectedField} field={selectedField}
onChange={(updatedField) => handleUpdateField(selectedIndex, updatedField)} onChange={(updatedField) => handleUpdateField(selectedIndex, updatedField)}
allFieldNames={fields.map((f) => f.name)}
/> />
)} )}
</div> </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); const rule = getById(id);
if (!rule) { if (!rule) {
console.warn('[ruleStorage] 规则不存在:', id); console.warn('[ruleStorage] 规则不存在:', id);
@@ -155,7 +157,7 @@ export function duplicate(id: string): DataRule | null {
const newRule: DataRule = { const newRule: DataRule = {
...rule, ...rule,
id: generateId(), id: generateId(),
name: `${rule.name}(副本)`, name: `${rule.name}${copySuffix}`,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
useCount: 0, useCount: 0,