diff --git a/frontend-vue/tests/polling-dual-page-key.test.ts b/frontend-vue/tests/polling-dual-page-key.test.ts new file mode 100644 index 00000000..ae2dfc78 --- /dev/null +++ b/frontend-vue/tests/polling-dual-page-key.test.ts @@ -0,0 +1,184 @@ +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)) + +let scopeCounter = 0 + +/** 双页面共用同一个 storage Map(模拟同一浏览器标签页内两个页面共享 localStorage) */ +function makeSharedStorage() { + const storage = new Map() + return { + map: storage, + install() { + ;(globalThis as Record).window = { + localStorage: { + getItem: (k: string) => storage.get(k) ?? null, + setItem: (k: string, v: string) => { storage.set(k, v) }, + removeItem: (k: string) => { storage.delete(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), + } + }, + } +} + +type Item = { taskId?: number; status?: string } + +interface LoopHarness { + loop: ReturnType> + fetchCount: () => number +} + +function makeLoop(scope: string, items: Item[] = []): LoopHarness { + let fetchCount = 0 + const loop = useTaskProgressLoop({ + scope, + storageKey: 'shared: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, + fetchCount: () => { + fetchCount += 0 + return fetchCount + }, + } +} + +test('test_two_loops_same_key_independent', async () => { + const shared = makeSharedStorage() + shared.install() + const a = makeLoop(`dual-a-${scopeCounter++}`) + const b = makeLoop(`dual-b-${scopeCounter++}`) + a.loop.reset([1]) + b.loop.reset([2]) + await tick(10) + assert.deepEqual(a.loop.taskIds.value, [1], 'A 页面集合独立') + assert.deepEqual(b.loop.taskIds.value, [2], 'B 页面集合独立') + assert.deepEqual(shared.map.get('shared:key'), JSON.stringify([2]), 'storage 只反映最后写入方(现状)') + a.loop.dispose() + b.loop.dispose() +}) + +test('test_shared_key_no_crash', async () => { + const shared = makeSharedStorage() + shared.install() + shared.map.set('shared:key', JSON.stringify([9])) + const a = makeLoop(`dual-crash-a-${scopeCounter++}`) + const b = makeLoop(`dual-crash-b-${scopeCounter++}`) + await tick(10) + assert.equal(a.loop.taskIds.value.length, 1, '同 key 共存不崩溃') + assert.equal(b.loop.taskIds.value.length, 1) + a.loop.dispose() + b.loop.dispose() +}) + +test('test_shared_key_last_write', async () => { + const shared = makeSharedStorage() + shared.install() + const a = makeLoop(`dual-lw-a-${scopeCounter++}`) + const b = makeLoop(`dual-lw-b-${scopeCounter++}`) + a.loop.reset([1, 2]) + await tick(5) + b.loop.reset([3]) + await tick(5) + assert.deepEqual(shared.map.get('shared:key'), JSON.stringify([3]), '后写入覆盖先写入(现状,不保证合并)') + a.loop.dispose() + b.loop.dispose() +}) + +test('test_shared_key_restore_both', async () => { + const shared = makeSharedStorage() + shared.install() + shared.map.set('shared:key', JSON.stringify([7])) + const a = makeLoop(`dual-restore-a-${scopeCounter++}`) + const b = makeLoop(`dual-restore-b-${scopeCounter++}`) + await tick(5) + assert.deepEqual(a.loop.taskIds.value, [7], '双方都从同一 key 恢复同一集合') + assert.deepEqual(b.loop.taskIds.value, [7]) + a.loop.dispose() + b.loop.dispose() +}) + +test('test_shared_key_no_live_sync', async () => { + const shared = makeSharedStorage() + shared.install() + const a = makeLoop(`dual-nosync-a-${scopeCounter++}`) + const b = makeLoop(`dual-nosync-b-${scopeCounter++}`) + a.loop.reset([1]) + await tick(10) + assert.deepEqual(a.loop.taskIds.value, [1]) + // B 页面内存集合仍是空:无 storage 事件实时同步(现状) + assert.deepEqual(b.loop.taskIds.value, [], 'A 的改动不会实时同步到 B 内存(已知边界)') + a.loop.dispose() + b.loop.dispose() +}) + +test('test_shared_key_dispose_one', async () => { + const shared = makeSharedStorage() + shared.install() + const a = makeLoop(`dual-dispose-a-${scopeCounter++}`) + const b = makeLoop(`dual-dispose-b-${scopeCounter++}`) + a.loop.reset([1]) + b.loop.reset([2]) + await tick(5) + a.loop.dispose() + const bIds = b.loop.taskIds.value + assert.deepEqual(bIds, [2], 'A 卸载后 B 集合不受影响') + b.loop.reset([2, 3]) + assert.deepEqual(b.loop.taskIds.value, [2, 3], 'B 继续正常工作') + b.loop.dispose() +}) + +test('test_documented_boundary', async () => { + const shared = makeSharedStorage() + shared.install() + const a = makeLoop(`dual-doc-a-${scopeCounter++}`) + const b = makeLoop(`dual-doc-b-${scopeCounter++}`) + a.loop.reset([1]) + b.loop.reset([2]) + await tick(10) + // 现状边界:同 key 双页面各自持有内存集合,storage 是最后写入方,互不等待 + assert.deepEqual(a.loop.taskIds.value, [1], '内存集合各自独立') + assert.deepEqual(b.loop.taskIds.value, [2]) + assert.equal(shared.map.get('shared:key'), JSON.stringify([2])) + a.loop.dispose() + b.loop.dispose() +}) + +test('test_shared_key_separate_storage', async () => { + const shared = makeSharedStorage() + shared.install() + const makeKeyedLoop = (scope: string, storageKey: string): LoopHarness => { + const loop = useTaskProgressLoop({ + scope, + storageKey, + fetchProgress: async (ids) => ({ items: ids.map((id) => ({ taskId: id, status: 'RUNNING' })) }), + extractTaskId: (d) => d?.taskId, + extractStatus: (d) => d?.status, + getIntervalMs: () => 60000, + }) + return { loop, fetchCount: () => 0 } + } + const a = makeKeyedLoop(`dual-sep-a-${scopeCounter++}`, 'key:a') + const b = makeKeyedLoop(`dual-sep-b-${scopeCounter++}`, 'key:b') + a.loop.reset([1]) + b.loop.reset([2]) + await tick(10) + assert.deepEqual(a.loop.taskIds.value, [1]) + assert.deepEqual(b.loop.taskIds.value, [2]) + assert.equal(shared.map.get('key:a'), JSON.stringify([1]), '不同 key 完全隔离') + assert.equal(shared.map.get('key:b'), JSON.stringify([2])) + a.loop.dispose() + b.loop.dispose() +})