diff --git a/src/pages/TestDataGenerator/hooks/__tests__/useGenerator.test.ts b/src/pages/TestDataGenerator/hooks/__tests__/useGenerator.test.ts new file mode 100644 index 0000000..7ce233a --- /dev/null +++ b/src/pages/TestDataGenerator/hooks/__tests__/useGenerator.test.ts @@ -0,0 +1,117 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useGenerator } from '@/pages/TestDataGenerator/hooks/useGenerator'; +import type { FieldConfig, WorkerResponseMessage } from '@/types/testDataGenerator'; + +const mockField: FieldConfig = { + id: 'field-1', + name: 'username', + generatorId: 'string', + params: {}, + required: true, + nullRate: 0, + unique: false, +}; + +type WorkerListener = (event: MessageEvent) => void; + +class MockWorker { + static instances: MockWorker[] = []; + onmessage: WorkerListener | null = null; + onerror: ((event: ErrorEvent) => void) | null = null; + postedMessages: unknown[] = []; + + constructor(_url: URL, _options?: WorkerOptions) { + MockWorker.instances.push(this); + } + + postMessage(message: unknown) { + this.postedMessages.push(message); + } + + terminate() { + const index = MockWorker.instances.indexOf(this); + if (index >= 0) { + MockWorker.instances.splice(index, 1); + } + } + + emit(message: WorkerResponseMessage) { + this.onmessage?.({ data: message } as MessageEvent); + } +} + +describe('useGenerator', () => { + beforeEach(() => { + MockWorker.instances = []; + vi.stubGlobal('Worker', MockWorker); + }); + + it('应忽略过期 generationId 的 complete 消息', async () => { + const { result } = renderHook(() => useGenerator()); + + act(() => { + result.current.generate([mockField], 10); + }); + + const worker = MockWorker.instances[0]; + expect(worker).toBeDefined(); + + act(() => { + result.current.cancel(); + }); + + act(() => { + result.current.generate([mockField], 5); + }); + + act(() => { + worker.emit({ + type: 'complete', + generationId: 1, + payload: { + success: true, + data: [{ username: 'stale' }], + stats: { total: 10, success: 10, failed: 0, duration: 1 }, + }, + }); + }); + + expect(result.current.result).toBeNull(); + expect(result.current.isGenerating).toBe(true); + + act(() => { + worker.emit({ + type: 'complete', + generationId: 3, + payload: { + success: true, + data: [{ username: 'fresh' }], + stats: { total: 5, success: 5, failed: 0, duration: 1 }, + }, + }); + }); + + await waitFor(() => { + expect(result.current.isGenerating).toBe(false); + }); + expect(result.current.result?.data?.[0]).toEqual({ username: 'fresh' }); + }); + + it('cancel 后应发送 cancel 消息并使 generationId 失效', () => { + const { result } = renderHook(() => useGenerator()); + + act(() => { + result.current.generate([mockField], 100); + }); + + const worker = MockWorker.instances[0]; + + act(() => { + result.current.cancel(); + }); + + expect(result.current.isGenerating).toBe(false); + expect(worker.postedMessages).toEqual(expect.arrayContaining([{ type: 'cancel' }])); + }); +}); diff --git a/src/pages/TestDataGenerator/hooks/useGenerator.ts b/src/pages/TestDataGenerator/hooks/useGenerator.ts index 7d38bd5..e1d3221 100644 --- a/src/pages/TestDataGenerator/hooks/useGenerator.ts +++ b/src/pages/TestDataGenerator/hooks/useGenerator.ts @@ -8,7 +8,8 @@ import type { FieldConfig, GenerateResult, GenerateProgress, - WorkerMessage, + WorkerRequestMessage, + WorkerResponseMessage, } from '@/types/testDataGenerator'; export interface UseGeneratorReturn { @@ -35,6 +36,7 @@ export function useGenerator(): UseGeneratorReturn { const [error, setError] = useState(null); const workerRef = useRef(null); + const generationIdRef = useRef(0); // 清理 Worker useEffect(() => { @@ -58,22 +60,30 @@ export function useGenerator(): UseGeneratorReturn { type: 'module', }); - worker.onmessage = (e: MessageEvent) => { + worker.onmessage = (e: MessageEvent) => { const data = e.data; const { type } = data; + if (data.generationId !== generationIdRef.current) { + return; + } + switch (type) { case 'progress': - setProgress(data.payload as GenerateProgress); + setProgress(data.payload); break; case 'complete': setIsGenerating(false); - setResult(data.payload as GenerateResult); + if (data.payload.success) { + setResult(data.payload); + } else if (data.payload.error && data.payload.error !== '生成已取消') { + setError(data.payload.error); + } setProgress(null); break; case 'error': setIsGenerating(false); - setError((data.payload as { error: string }).error); + setError(data.payload.error); setProgress(null); break; } @@ -100,15 +110,17 @@ export function useGenerator(): UseGeneratorReturn { (fields: FieldConfig[], count: number, csvMode = false) => { if (isGenerating) return; + const generationId = ++generationIdRef.current; + setIsGenerating(true); setProgress(null); setResult(null); setError(null); const worker = getWorker(); - const message: WorkerMessage = { + const message: WorkerRequestMessage = { type: 'start', - payload: { fields, count, csvMode }, + payload: { generationId, fields, count, csvMode }, }; worker.postMessage(message); }, @@ -120,7 +132,8 @@ export function useGenerator(): UseGeneratorReturn { */ const cancel = useCallback(() => { if (workerRef.current && isGenerating) { - const message: WorkerMessage = { type: 'cancel' }; + ++generationIdRef.current; + const message: WorkerRequestMessage = { type: 'cancel' }; workerRef.current.postMessage(message); setIsGenerating(false); setProgress(null); diff --git a/src/types/testDataGenerator.ts b/src/types/testDataGenerator.ts index 8d141bd..71a096e 100644 --- a/src/types/testDataGenerator.ts +++ b/src/types/testDataGenerator.ts @@ -156,19 +156,29 @@ export interface GenerateProgress { } /** - * Worker 消息类型 + * Worker 请求消息类型 */ -export type WorkerMessage = +export type WorkerRequestMessage = | { type: 'start'; payload: WorkerStartPayload } - | { type: 'progress'; payload: GenerateProgress } - | { type: 'complete'; payload: GenerateResult } - | { type: 'error'; payload: { error: string } } | { type: 'cancel' }; +/** + * Worker 响应消息类型 + */ +export type WorkerResponseMessage = + | { type: 'progress'; generationId: number; payload: GenerateProgress } + | { type: 'complete'; generationId: number; payload: GenerateResult } + | { type: 'error'; generationId: number; payload: { error: string } }; + +/** @deprecated 使用 WorkerRequestMessage | WorkerResponseMessage */ +export type WorkerMessage = WorkerRequestMessage | WorkerResponseMessage; + /** * Worker 启动参数 */ export interface WorkerStartPayload { + /** 生成任务 ID,用于忽略过期 Worker 响应 */ + generationId: number; /** 字段配置列表 */ fields: FieldConfig[]; /** 生成数量 */ diff --git a/src/utils/__tests__/ruleStorage.test.ts b/src/utils/__tests__/ruleStorage.test.ts new file mode 100644 index 0000000..27330d7 --- /dev/null +++ b/src/utils/__tests__/ruleStorage.test.ts @@ -0,0 +1,65 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import * as ruleStorage from '../ruleStorage'; + +const STORAGE_KEY = 'testDataGenerator_rules'; + +const mockField = { + id: 'field-1', + name: 'username', + generatorId: 'string', + params: {}, + required: true, + nullRate: 0, + unique: false, +}; + +describe('ruleStorage', () => { + beforeEach(() => { + localStorage.clear(); + vi.restoreAllMocks(); + }); + + it('save 在 localStorage 写入失败时应返回 null', () => { + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new DOMException('QuotaExceededError'); + }); + + const result = ruleStorage.save({ + name: 'Test Rule', + fields: [mockField], + }); + + expect(result).toBeNull(); + expect(localStorage.getItem(STORAGE_KEY)).toBeNull(); + }); + + it('update 在 localStorage 写入失败时应返回 null', () => { + const saved = ruleStorage.save({ + name: 'Test Rule', + fields: [mockField], + }); + expect(saved).not.toBeNull(); + + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new DOMException('QuotaExceededError'); + }); + + const updated = ruleStorage.update(saved!.id, { name: 'Updated Rule' }); + expect(updated).toBeNull(); + }); + + it('deleteRule 在 localStorage 写入失败时应返回 false', () => { + const saved = ruleStorage.save({ + name: 'Test Rule', + fields: [mockField], + }); + expect(saved).not.toBeNull(); + + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new DOMException('QuotaExceededError'); + }); + + expect(ruleStorage.deleteRule(saved!.id)).toBe(false); + expect(ruleStorage.getById(saved!.id)).toBeDefined(); + }); +}); diff --git a/src/utils/ruleStorage.ts b/src/utils/ruleStorage.ts index 8253abf..eecc2db 100644 --- a/src/utils/ruleStorage.ts +++ b/src/utils/ruleStorage.ts @@ -78,7 +78,9 @@ export function save( useCount: 0, }; rules.unshift(newRule); - setAll(rules); + if (!setAll(rules)) { + return null; + } return newRule; } @@ -95,7 +97,9 @@ export function save( updatedAt: now, }; rules[index] = updatedRule; - setAll(rules); + if (!setAll(rules)) { + return null; + } return updatedRule; } @@ -117,7 +121,9 @@ export function update(id: string, updates: Partial): DataRule | null updatedAt: Date.now(), }; rules[index] = updatedRule; - setAll(rules); + if (!setAll(rules)) { + return null; + } return updatedRule; } @@ -132,8 +138,7 @@ export function deleteRule(id: string): boolean { return false; } rules.splice(index, 1); - setAll(rules); - return true; + return setAll(rules); } /** @@ -165,7 +170,9 @@ export function duplicate(id: string, copySuffix = '(副本)'): DataRule | n const rules = getAll(); rules.unshift(newRule); - setAll(rules); + if (!setAll(rules)) { + return null; + } return newRule; } @@ -267,11 +274,13 @@ export function clear(): void { /** * 保存所有规则到存储 */ -function setAll(rules: DataRule[]): void { +function setAll(rules: DataRule[]): boolean { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(rules)); + return true; } catch (error) { console.error('[ruleStorage] 保存规则失败:', error); + return false; } } diff --git a/src/workers/generator.worker.ts b/src/workers/generator.worker.ts index 5795cee..2780b6f 100644 --- a/src/workers/generator.worker.ts +++ b/src/workers/generator.worker.ts @@ -6,11 +6,14 @@ import { getGeneratorById } from '@/lib/generators'; import type { FieldConfig, - WorkerMessage, + WorkerRequestMessage, GenerateResult, GenerateProgress, } from '@/types/testDataGenerator'; +/** 每生成 N 行让出一次事件循环,以便处理 cancel 消息 */ +const YIELD_EVERY = 100; + // 生成结果缓存 let generatedData: Record[] = []; let isCancelled = false; @@ -18,7 +21,7 @@ let isCancelled = false; /** * Worker 消息处理器 */ -self.onmessage = async (e: MessageEvent) => { +self.onmessage = async (e: MessageEvent) => { const data = e.data; const { type } = data; @@ -37,11 +40,12 @@ self.onmessage = async (e: MessageEvent) => { * 处理开始生成消息 */ async function handleStart(payload: { + generationId: number; fields: FieldConfig[]; count: number; csvMode: boolean; }): Promise { - const { fields, count } = payload; + const { generationId, fields, count } = payload; generatedData = []; try { @@ -51,6 +55,7 @@ async function handleStart(payload: { if (!generator) { self.postMessage({ type: 'error', + generationId, payload: { error: `生成器 "${field.generatorId}" 不存在` }, }); return; @@ -65,6 +70,7 @@ async function handleStart(payload: { if (isCancelled) { self.postMessage({ type: 'complete', + generationId, payload: { success: false, error: '生成已取消', @@ -136,7 +142,23 @@ async function handleStart(payload: { total: count, estimatedTimeLeft: Math.round(((Date.now() - startTime) / (i + 1)) * (count - i - 1)), }; - self.postMessage({ type: 'progress', payload: progress }); + self.postMessage({ type: 'progress', generationId, payload: progress }); + } + + // 定期让出事件循环,使 cancel 消息能被处理 + if ((i + 1) % YIELD_EVERY === 0) { + await new Promise((resolve) => setTimeout(resolve, 0)); + if (isCancelled) { + self.postMessage({ + type: 'complete', + generationId, + payload: { + success: false, + error: '生成已取消', + }, + }); + return; + } } } @@ -155,10 +177,11 @@ async function handleStart(payload: { }, }; - self.postMessage({ type: 'complete', payload: result }); + self.postMessage({ type: 'complete', generationId, payload: result }); } catch (error) { self.postMessage({ type: 'error', + generationId, payload: { error: `生成失败: ${error}` }, }); }