Files
huangzd1997 2a51006888 feat(前端防连点): 工具页提交按钮加全局冷却,并精简 4.0.28 更新日志
各工具页的「开始上架 / 启动任务 / 匹配店铺」按钮在提交逻辑跑完后立刻恢复可点,
手快连点会重复发起(2026-09-17 上架事故:同一店铺被接连提交三次,服务端并存多个
同店铺任务,客户端并发打开同一店铺时全部失败)。

- 新增 shared/utils/submit-guard.ts:冷却按按钮元素各自计算(WeakMap,点 A 不影响
  B),在捕获阶段拦截、抢在 Vue 的 @click 之前;冷却自首次有效点击起算,被拦的
  点击不会把冷却越拖越长;
- main.ts 全局安装 installSubmitGuard(),覆盖所有 .btn-run 主按钮,页面零改动;
- 13 个单测覆盖边界:不同按钮互不影响、被拦不延长冷却、非法时长回落默认等;
- client-changelog 每条精简到 40 字以内(超出面板显示不下,已有守护测试)。
2026-09-17 15:41:41 +08:00

199 lines
6.3 KiB
TypeScript

/**
* 提交按钮防连点(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<string, unknown>).document
;(globalThis as Record<string, unknown>).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<string, unknown>).document = originalDocument
}
})
test('全局安装:只拦 .btn-run 的连点,且第二次点击阻断冒泡', () => {
const registered: Array<{ handler: (event: unknown) => void }> = []
const originalDocument = (globalThis as Record<string, unknown>).document
;(globalThis as Record<string, unknown>).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<string, unknown>).document = originalDocument
}
})