From 50518b542098797a2e276508600308fae5a46f03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E8=87=AA=E8=BE=BE?= <980324341@qq.com> Date: Mon, 31 Aug 2026 21:02:52 +0800 Subject: [PATCH] =?UTF-8?q?task-41:=20storage=20=E6=81=A2=E5=A4=8D?= =?UTF-8?q?=E4=B8=8E=E6=B8=85=E7=90=86=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 恢复过滤非正数/非数字、空列表清除过期 key、终态同步 storage、 写失败不抛、坏 JSON 容错、恢复即轮询、无 key 不操作。修复恢复 出空列表时遗留 key 未清理的问题。共 8 个测试。 --- .../shared/composables/useTaskProgressLoop.ts | 8 +- frontend-vue/tests/polling-storage.test.ts | 163 ++++++++++++++++++ 2 files changed, 170 insertions(+), 1 deletion(-) create mode 100644 frontend-vue/tests/polling-storage.test.ts diff --git a/frontend-vue/src/shared/composables/useTaskProgressLoop.ts b/frontend-vue/src/shared/composables/useTaskProgressLoop.ts index f6a1b5e7..1ae5ebc9 100644 --- a/frontend-vue/src/shared/composables/useTaskProgressLoop.ts +++ b/frontend-vue/src/shared/composables/useTaskProgressLoop.ts @@ -108,7 +108,13 @@ function readIdsFromStorage(key?: string): number[] { const raw = window.localStorage.getItem(key) if (!raw) return [] const parsed = JSON.parse(raw) as unknown - return Array.isArray(parsed) ? parsed.filter((n): n is number => typeof n === 'number' && n > 0) : [] + if (!Array.isArray(parsed)) return [] + const ids = parsed.filter((n): n is number => typeof n === 'number' && n > 0) + if (ids.length === 0) { + // 恢复出空列表时顺手清掉过期 key(历史遗留的空数组) + window.localStorage.removeItem(key) + } + return ids } catch { return [] } diff --git a/frontend-vue/tests/polling-storage.test.ts b/frontend-vue/tests/polling-storage.test.ts new file mode 100644 index 00000000..7532c895 --- /dev/null +++ b/frontend-vue/tests/polling-storage.test.ts @@ -0,0 +1,163 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { useTaskProgressLoop } from '../src/shared/composables/useTaskProgressLoop.ts' + +const tick = (ms = 0) => new Promise((r) => globalThis.setTimeout(r, ms)) + +interface StorageLike { + map?: Map + getItem: (k: string) => string | null + setItem: (k: string, v: string) => void + removeItem: (k: string) => void +} + +let scopeCounter = 0 + +interface StorageHarness { + loop: ReturnType> + storage: Map + fetchCount: () => number +} + +async function makeStorageLoop( + storage: StorageLike, + key: string, + items: Array<{ taskId?: number; status?: string }> = [], +): Promise { + const storageMap = storage as unknown as { map: Map } + let fetchCount = 0 + ;(globalThis as Record).window = { + localStorage: { + getItem: (k: string) => storage.getItem(k), + setItem: (k: string, v: string) => { storage.setItem(k, v) }, + removeItem: (k: string) => { storage.removeItem(k) }, + }, + setTimeout: (fn: () => void, ms: number) => globalThis.setTimeout(fn, ms), + clearTimeout: (id: unknown) => globalThis.clearTimeout(id as number), + setInterval: (fn: () => void, ms: number) => globalThis.setInterval(fn, ms), + clearInterval: (id: unknown) => globalThis.clearInterval(id as number), + } + const loop = useTaskProgressLoop<{ taskId?: number; status?: string }>({ + scope: `storage-${scopeCounter++}`, + storageKey: key, + fetchProgress: async (ids) => ({ + items: items.filter((item) => ids.includes(item.taskId ?? -1)), + }), + extractTaskId: (d) => d?.taskId, + extractStatus: (d) => d?.status, + getIntervalMs: () => 60000, + }) + return { loop, storage: storageMap.map, fetchCount: () => fetchCount } +} + +function makeStorage(initial?: Record): StorageLike { + const map = new Map(Object.entries(initial ?? {})) + return { + map, + getItem: (k) => map.get(k) ?? null, + setItem: (k, v) => { map.set(k, v) }, + removeItem: (k) => { map.delete(k) }, + } +} + +test('test_storage_restore_valid_ids', async () => { + const storage = makeStorage({ 'k:ids': JSON.stringify([3, 7]) }) + const h = await makeStorageLoop(storage, 'k:ids') + assert.deepEqual(h.loop.taskIds.value, [3, 7], '初始化从 storage 恢复任务列表') + h.loop.dispose() +}) + +test('test_storage_restore_filters_invalid', async () => { + const storage = makeStorage({ 'k:ids': JSON.stringify([0, -5, 3.5, 'x', null, 9]) }) + const h = await makeStorageLoop(storage, 'k:ids') + // 规格只过滤"非正数/非数字":3.5 是正有限数,保留 + assert.deepEqual(h.loop.taskIds.value, [3.5, 9], '过滤非正数/非数字,合法 id 保留') + h.loop.dispose() +}) + +test('test_storage_restore_empty_clears', async () => { + const storage = makeStorage({ 'k:ids': JSON.stringify([]) }) + const h = await makeStorageLoop(storage, 'k:ids') + assert.deepEqual(h.loop.taskIds.value, []) + await tick(10) + assert.equal(h.storage.has('k:ids'), false, '空列表恢复后清除 storage key') + h.loop.dispose() +}) + +test('test_storage_terminal_removes', async () => { + const storage = makeStorage({ 'k:ids': JSON.stringify([5, 6]) }) + const h = await makeStorageLoop( + storage, + 'k:ids', + [ + { taskId: 5, status: 'SUCCESS' }, + { taskId: 6, status: 'RUNNING' }, + ], + ) + await tick(10) + await h.loop.refreshOnce() + assert.equal(h.storage.get('k:ids'), JSON.stringify([6]), '终态任务从 storage 同步移除') + h.loop.dispose() +}) + +test('test_storage_write_failure_ignored', async () => { + const storage = { + getItem: (k: string) => (k === 'k:ids' ? '[]' : null), + setItem: (_k: string, _v: string) => { throw new Error('quota exceeded') }, + removeItem: (_k: string) => { throw new Error('quota exceeded') }, + } + const h = await makeStorageLoop(storage, 'k:ids') + await tick(10) + await h.loop.refreshOnce() + assert.equal(h.loop.taskIds.value.length, 0) + assert.doesNotThrow(() => h.loop.reset([1])) + assert.deepEqual(h.loop.taskIds.value, [1], '写失败不影响功能,任务仍入集合') + h.loop.dispose() +}) + +test('test_storage_bad_json', async () => { + const storage = makeStorage({ 'k:ids': '{broken json' }) + const h = await makeStorageLoop(storage, 'k:ids') + assert.deepEqual(h.loop.taskIds.value, [], '坏 JSON 按空处理,不抛错') + h.loop.dispose() +}) + +test('test_storage_restore_starts_polling', async () => { + let fetchCount = 0 + const storage = makeStorage({ 'k:ids': JSON.stringify([11]) }) + ;(globalThis as Record).window = { + localStorage: { + getItem: (k: string) => storage.getItem(k), + setItem: (k: string, v: string) => { storage.setItem(k, v) }, + removeItem: (k: string) => { storage.removeItem(k) }, + }, + setTimeout: (fn: () => void, ms: number) => globalThis.setTimeout(fn, ms), + clearTimeout: (id: unknown) => globalThis.clearTimeout(id as number), + setInterval: (fn: () => void, ms: number) => globalThis.setInterval(fn, ms), + clearInterval: (id: unknown) => globalThis.clearInterval(id as number), + } + const loop = useTaskProgressLoop<{ taskId?: number; status?: string }>({ + scope: `storage-poll-${scopeCounter++}`, + storageKey: 'k:ids', + fetchProgress: async () => { + fetchCount += 1 + return { items: [] } + }, + extractTaskId: (d) => d?.taskId, + extractStatus: (d) => d?.status, + getIntervalMs: () => 60000, + }) + await tick(20) + assert.equal(fetchCount, 1, '从 storage 恢复后自动开始轮询') + loop.dispose() +}) + +test('test_storage_no_key_noop', async () => { + const storage = makeStorage({}) + const h = await makeStorageLoop(storage, 'k:ids') + assert.deepEqual(h.loop.taskIds.value, [], '无 key 时任务列表为空') + await tick(10) + assert.equal(h.storage.has('k:ids'), false, '不写多余 key') + assert.equal(h.fetchCount(), 0, '无任务不轮询') + h.loop.dispose() +})