Files
tg-shuttle-score/test/signup.test.js
T
JianMiauandClaude Fable 5 2e88a7d85a 建立建喵打羽球 Bot:移植 Node-RED 報名流程並新增群組記分板
根本原因:
原本的「今日羽球報名」bot 寫在 Node-RED 的 function 節點裡,不易測試與擴充;
羽球團又需要在 Telegram 群組內直接記分、看發球者,並把戰績寫進 web 版共用的 DB。

影響:
同一個 bot(@JianMiauBadmintonBot)改由本專案服務:報名指令、按鈕、暱稱、
週一 10:00 排程、白名單行為與訊息格式皆與 Node-RED 版相同,舊訊息按鈕仍可用;
另新增 /記分(/score、/new)記分板,並在結束/再一局時寫入共用的 history 表。

修法:
- signup.js:逐句移植 Node-RED bd_sm 狀態機(attendance / tg_poll / tg_members)
- scoreboard.js + match.js + render.js:羽球規則(發球區、換位、Deuce、30 分封頂)、
  上排 1 2/下排 4 3 版面、從今日出席選人開局、⏱ 結算、兩段式結束確認、
  開局者/管理員權限、scoreList(web 版格式)
- history.js:寫入 history 表,欄位與 web 版 ensureHistoryTable 相同
- scripts/nodered-switch.mjs:透過 admin API 暫停/恢復 Node-RED 舊流程
- 單元測試 23 項(規則、報名、選人、結算、權限、歷史寫入),Dockerfile / compose

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 11:10:27 +08:00

178 lines
7.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { createSignup, renderPoll, keyboard } from '../src/signup.js'
import { commandRegex, dateLabel, todayKey } from '../src/util.js'
// ---- 假 DB:用 SQL 開頭判斷要做什麼,維護 attendance / tg_members / tg_poll 三張表 ----
function fakeDb() {
const attendance = [] // { poll_date, user_id, nickname }
const members = new Map() // user_id -> { nickname, tg_name, username }
const polls = new Map() // poll_date -> { chat_id, message_id }
const sqlLog = []
async function query(sql, params) {
const s = sql.replace(/\s+/g, ' ').trim()
sqlLog.push(s.slice(0, 40))
if (s.startsWith('SELECT user_id, nickname FROM attendance')) {
return attendance.filter((r) => r.poll_date === params[0]).map((r) => ({ user_id: r.user_id, nickname: r.nickname }))
}
if (s.startsWith('SELECT m.nickname, m.user_id AS member')) {
const [uid, date] = params
const m = members.get(uid)
const a = attendance.find((r) => r.user_id === uid && r.poll_date === date)
return [{ nickname: m?.nickname ?? null, member: m ? uid : null, joined: a ? uid : null }]
}
if (s.startsWith('INSERT INTO attendance')) {
const [poll_date, user_id, nickname] = params
attendance.push({ poll_date, user_id, nickname })
return { affectedRows: 1 }
}
if (s.startsWith('DELETE FROM attendance')) {
const [date, uid] = params
const i = attendance.findIndex((r) => r.poll_date === date && r.user_id === uid)
if (i >= 0) attendance.splice(i, 1)
return { affectedRows: i >= 0 ? 1 : 0 }
}
if (s.startsWith('INSERT INTO tg_members (user_id, nickname')) {
const [uid, nickname, tg_name, username] = params
members.set(uid, { ...(members.get(uid) ?? {}), nickname, tg_name, username })
return {}
}
if (s.startsWith('INSERT INTO tg_members (user_id, tg_name')) {
const [uid, tg_name, username] = params
members.set(uid, { ...(members.get(uid) ?? {}), tg_name, username })
return {}
}
if (s.startsWith('UPDATE attendance SET nickname')) {
const [nickname, uid, from] = params
attendance.forEach((r) => {
if (r.user_id === uid && r.poll_date >= from) r.nickname = nickname
})
return {}
}
if (s.startsWith('INSERT INTO tg_poll')) {
const [date, chat_id, message_id] = params
polls.set(date, { chat_id, message_id })
return {}
}
if (s.startsWith('SELECT chat_id, message_id FROM tg_poll')) {
const p = polls.get(params[0])
return p ? [p] : []
}
throw new Error(`fakeDb 不認得的 SQL${s}`)
}
return { query, attendance, members, polls, sqlLog }
}
function fakeBot() {
const calls = []
let nextId = 100
return {
calls,
async sendMessage(chatId, text, opts) {
calls.push({ fn: 'sendMessage', chatId, text, opts })
return { message_id: nextId++, chat: { id: chatId } }
},
async editMessageText(text, opts) {
calls.push({ fn: 'editMessageText', text, opts })
return true
},
async answerCallbackQuery(id, opts) {
calls.push({ fn: 'answerCallbackQuery', id, opts })
return true
},
}
}
const silentLog = { info() {}, warn() {}, error() {} }
const DATE = '20260817'
const cq = (userId, choice, name = `U${userId}`) => ({
id: `cq${userId}${choice}`,
data: `bd|${choice}||${DATE}`,
from: { id: userId, first_name: name, username: null },
message: { chat: { id: -100 }, message_id: 4 },
})
test('renderPoll / keyboard 與 Node-RED 版格式一致', () => {
assert.equal(dateLabel('20260817'), '08/17(一)')
const text = renderPoll('20260817', [{ nickname: '建喵' }, { nickname: '<G>' }])
assert.match(text, /^🏸 <b>今日羽球 08\/17\(一\)<\/b>\n按下方按鈕報名,可隨時反悔\n\n✅ 參加 \(2\):建喵、&lt;G&gt;\n\n🕑 更新 \d\d:\d\d$/)
assert.match(renderPoll('20260817', []), /✅ 參加 \(0\):—/)
assert.deepEqual(keyboard('20260817').inline_keyboard[0].map((b) => b.callback_data), ['bd|yes||20260817', 'bd|no||20260817'])
})
test('rsvp:參加 → 寫入、名單更新;重按 → 提示不動 DB;不參加 → 刪除', async () => {
const db = fakeDb()
const bot = fakeBot()
const signup = createSignup(bot, { log: silentLog, query: db.query })
await signup.rsvp(cq(1, 'yes', '建喵'))
assert.deepEqual(db.attendance.map((r) => r.nickname), ['建喵'])
assert.ok(db.members.has(1), '順手寫入 tg_members')
let edit = bot.calls.find((c) => c.fn === 'editMessageText')
assert.match(edit.text, /✅ 參加 \(1\):建喵/)
assert.equal(bot.calls.at(-1).opts.text, '已報名 ✅(建喵)')
bot.calls.length = 0
await signup.rsvp(cq(1, 'yes', '建喵'))
assert.equal(db.attendance.length, 1)
assert.equal(bot.calls.filter((c) => c.fn === 'editMessageText').length, 0, '重按不重畫')
assert.equal(bot.calls.at(-1).opts.text, '你已經報名了 👌')
bot.calls.length = 0
await signup.rsvp(cq(2, 'no', '路人'))
assert.equal(bot.calls.at(-1).opts.text, '歡迎加入羽球團 🏸', '沒報名的新面孔按不參加')
await signup.rsvp(cq(2, 'no', '路人'))
assert.equal(bot.calls.at(-1).opts.text, '已取消報名 ❌', '已是成員但沒報名')
bot.calls.length = 0
await signup.rsvp(cq(1, 'no', '建喵'))
assert.equal(db.attendance.length, 0)
edit = bot.calls.find((c) => c.fn === 'editMessageText')
assert.match(edit.text, /✅ 參加 \(0\):—/)
assert.equal(bot.calls.at(-1).opts.text, '已取消報名 ❌')
})
test('rsvp 使用 tg_members 裡設定的暱稱', async () => {
const db = fakeDb()
const bot = fakeBot()
const signup = createSignup(bot, { log: silentLog, query: db.query })
db.members.set(9, { nickname: 'RURU' })
await signup.rsvp(cq(9, 'yes', 'Ru Chen'))
assert.equal(db.attendance[0].nickname, 'RURU')
assert.equal(bot.calls.at(-1).opts.text, '已報名 ✅(RURU')
})
test('post 會發訊息並記錄 tg_pollsetNickname 會更新今日名單與訊息', async () => {
const db = fakeDb()
const bot = fakeBot()
const signup = createSignup(bot, { log: silentLog, query: db.query })
const today = todayKey()
await signup.post(-100)
assert.deepEqual(db.polls.get(today), { chat_id: '-100', message_id: 100 })
assert.deepEqual(bot.calls[0].opts.reply_markup, keyboard(today))
await signup.rsvp({ ...cq(5, 'yes', 'Old'), data: `bd|yes||${today}` })
bot.calls.length = 0
await signup.setNickname(-100, 77, { id: 5, name: 'Old', username: null }, '新名')
assert.equal(db.members.get(5).nickname, '新名')
assert.equal(db.attendance[0].nickname, '新名')
assert.match(bot.calls[0].text, /暱稱已設為「新名」/)
const edit = bot.calls.find((c) => c.fn === 'editMessageText')
assert.equal(edit.opts.message_id, 100)
assert.match(edit.text, /新名/)
})
test('commandRegex:支援 @BotName 後綴與中英文別名', () => {
const r = commandRegex('羽球|badminton', 'JianMiauBadmintonBot', '(?:\\s+(\\d{8}))?')
assert.ok(r.test('/羽球'))
assert.ok(r.test('/badminton@JianMiauBadmintonBot 20260817'))
assert.equal('/羽球 20260817'.match(r)[1], '20260817')
assert.ok(!r.test('/羽球名單'), '不能誤吃 /羽球名單')
assert.ok(!r.test('/羽球x'))
const s = commandRegex('記分|score|new', 'JianMiauBadmintonBot', '(?:\\s+([\\s\\S]+))?')
assert.equal('/score 小明 vs 阿強'.match(s)[1], '小明 vs 阿強')
assert.equal('/記分'.match(s)[1], undefined)
})