/** * 提交按钮防连点(shared/utils/submit-guard)行为测试。 * * 背景(2026-09-17 上架事故):前端「开始上架 / 启动任务」按钮在提交完成后立刻 * 恢复可点,手快连点会重复创建任务;同一店铺并存多个任务后,客户端并发打开 * 同一店铺时全部失败。这里把冷却逻辑抽成纯函数以便单测。 */ import { test } from 'node:test' import assert from 'node:assert/strict' import { createClickGate, installSubmitGuard, DEFAULT_SUBMIT_COOLDOWN_MS, SUBMIT_BUTTON_SELECTOR, } from '../src/shared/utils/submit-guard.ts' function fakeClock(start = 1_000) { let current = start return { now: () => current, advance: (ms: number) => { current += ms }, } } test('默认冷却时长是正数秒级', () => { assert.ok(DEFAULT_SUBMIT_COOLDOWN_MS >= 1000, '冷却至少 1 秒,否则挡不住连点') }) test('默认选择器覆盖各工具页的主按钮', () => { assert.equal(SUBMIT_BUTTON_SELECTOR, '.btn-run') }) test('首次点击放行', () => { const clock = fakeClock() const gate = createClickGate(1500, clock.now) assert.equal(gate.shouldBlock({}), false) }) test('冷却期内重复点击被拦截', () => { const clock = fakeClock() const gate = createClickGate(1500, clock.now) const button = {} assert.equal(gate.shouldBlock(button), false) clock.advance(100) assert.equal(gate.shouldBlock(button), true) clock.advance(1399) assert.equal(gate.shouldBlock(button), true) }) test('冷却结束时(边界)放行', () => { const clock = fakeClock() const gate = createClickGate(1500, clock.now) const button = {} assert.equal(gate.shouldBlock(button), false) clock.advance(1500) assert.equal(gate.shouldBlock(button), false, '恰好到达冷却终点应放行') }) test('不同按钮互不影响', () => { const clock = fakeClock() const gate = createClickGate(1500, clock.now) const startButton = {} const matchButton = {} assert.equal(gate.shouldBlock(startButton), false) assert.equal(gate.shouldBlock(matchButton), false, '另一个按钮不该被前一个的冷却波及') clock.advance(100) assert.equal(gate.shouldBlock(startButton), true) }) test('自定义冷却时长生效', () => { const clock = fakeClock() const gate = createClickGate(300, clock.now) const button = {} assert.equal(gate.shouldBlock(button), false) clock.advance(299) assert.equal(gate.shouldBlock(button), true) clock.advance(2) assert.equal(gate.shouldBlock(button), false) }) test('被拦截的点击不会延长冷却', () => { const clock = fakeClock() const gate = createClickGate(1000, clock.now) const button = {} assert.equal(gate.shouldBlock(button), false) for (let i = 0; i < 5; i += 1) { clock.advance(100) assert.equal(gate.shouldBlock(button), true) } // 从首次点击起算 1000ms 后就该放行,而不是被连点拖长 clock.advance(500) assert.equal(gate.shouldBlock(button), false) }) test('同一时刻的两次点击只有第一次放行', () => { const clock = fakeClock() const gate = createClickGate(1500, clock.now) const button = {} assert.equal(gate.shouldBlock(button), false) assert.equal(gate.shouldBlock(button), true, '同一 tick 的第二次点击必须被拦') }) test('reset 后立即放行', () => { const clock = fakeClock() const gate = createClickGate(1500, clock.now) const button = {} assert.equal(gate.shouldBlock(button), false) assert.equal(gate.shouldBlock(button), true) gate.reset(button) assert.equal(gate.shouldBlock(button), false) }) test('非法冷却时长回落到默认值', () => { const clock = fakeClock() const gate = createClickGate(Number.NaN, clock.now) const button = {} assert.equal(gate.shouldBlock(button), false) clock.advance(DEFAULT_SUBMIT_COOLDOWN_MS - 1) assert.equal(gate.shouldBlock(button), true, 'NaN 应回落为默认冷却而不是立刻放行') }) test('全局安装:捕获阶段注册且可卸载', () => { const registered: Array<{ type: string; handler: unknown; capture: boolean }> = [] const removed: string[] = [] const originalDocument = (globalThis as Record).document ;(globalThis as Record).document = { addEventListener: (type: string, handler: unknown, capture: boolean) => { registered.push({ type, handler, capture }) }, removeEventListener: (type: string) => { removed.push(type) }, } try { const uninstall = installSubmitGuard({ cooldownMs: 1000, now: () => 0 }) assert.equal(registered.length, 1) assert.equal(registered[0].type, 'click') assert.equal(registered[0].capture, true, '必须捕获阶段,否则 Vue 的 @click 已先执行') uninstall() assert.deepEqual(removed, ['click']) } finally { ;(globalThis as Record).document = originalDocument } }) test('全局安装:只拦 .btn-run 的连点,且第二次点击阻断冒泡', () => { const registered: Array<{ handler: (event: unknown) => void }> = [] const originalDocument = (globalThis as Record).document ;(globalThis as Record).document = { addEventListener: (_type: string, handler: (event: unknown) => void) => { registered.push({ handler }) }, removeEventListener: () => undefined, } try { installSubmitGuard({ cooldownMs: 1000, now: () => 5000 }) const handler = registered[0].handler const button = { closest: (selector: string) => (selector === '.btn-run' ? button : null) } const elsewhere = { closest: () => null } const makeEvent = (target: unknown) => { const calls: string[] = [] return { calls, target, stopImmediatePropagation: () => calls.push('stop'), preventDefault: () => calls.push('prevent'), } } const first = makeEvent(button) handler(first) assert.deepEqual(first.calls, [], '首次点击不应被拦') const second = makeEvent(button) handler(second) assert.deepEqual(second.calls, ['stop', 'prevent'], '连点必须被拦下') const other = makeEvent(elsewhere) handler(other) assert.deepEqual(other.calls, [], '非提交按钮的点击不受影响') } finally { ;(globalThis as Record).document = originalDocument } })