建立建喵打羽球 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>
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { addPoint, createMatch, setFirstServer, settleMatch } from '../src/match.js'
|
||||
import { buildHistoryPayload, saveHistory } from '../src/history.js'
|
||||
|
||||
test('buildHistoryPayload:欄位對齊 web 版 history(雙打)', () => {
|
||||
const m = createMatch(['Gary', '蓉蓉'], ['柏威', '小念'])
|
||||
setFirstServer(m, 0)
|
||||
addPoint(m, 0)
|
||||
addPoint(m, 1)
|
||||
settleMatch(m)
|
||||
const now = new Date('2026-08-17T20:30:00+08:00') // 週一
|
||||
const p = buildHistoryPayload(m, now)
|
||||
assert.deepEqual(p, {
|
||||
time: Math.floor(now.getTime() / 1000),
|
||||
dayOfWeek: 1,
|
||||
score: [1, 1],
|
||||
winScore: 21,
|
||||
type: 0,
|
||||
players: ['Gary', '蓉蓉', '柏威', '小念'],
|
||||
team: [['Gary', '蓉蓉'], ['柏威', '小念']],
|
||||
scoreList: [[0, 0, 0, 0], [1, 1, 0, 1]],
|
||||
})
|
||||
})
|
||||
|
||||
test('buildHistoryPayload:單打 players 以 [p1,p1,p3,p3] 讓 players[starter] 對得到人', () => {
|
||||
const m = createMatch(['A'], ['B'], 15)
|
||||
setFirstServer(m, 1)
|
||||
addPoint(m, 1)
|
||||
const p = buildHistoryPayload(m)
|
||||
assert.equal(p.type, 1)
|
||||
assert.equal(p.winScore, 15)
|
||||
assert.deepEqual(p.players, ['A', 'A', 'B', 'B'])
|
||||
assert.deepEqual(p.team, [['A'], ['B']])
|
||||
assert.equal(p.players[p.scoreList[0][1]], 'B')
|
||||
})
|
||||
|
||||
test('saveHistory:先 ensure table 再 INSERT,回傳 insertId', async () => {
|
||||
const sqls = []
|
||||
const query = async (sql, params) => {
|
||||
sqls.push({ sql: sql.replace(/\s+/g, ' ').trim(), params })
|
||||
return sql.includes('INSERT') ? { insertId: 42 } : []
|
||||
}
|
||||
const m = createMatch(['A', 'B'], ['C', 'D'])
|
||||
setFirstServer(m, 0)
|
||||
addPoint(m, 0)
|
||||
const id = await saveHistory(m, { query, table: 'history_test' })
|
||||
assert.equal(id, 42)
|
||||
assert.match(sqls[0].sql, /CREATE TABLE IF NOT EXISTS `history_test`/)
|
||||
assert.match(sqls[1].sql, /INSERT INTO `history_test` \(time, dayOfWeek, score, winScore, type, players, team, scoreList\)/)
|
||||
assert.equal(sqls[1].params[2], '[1,0]')
|
||||
assert.equal(sqls[1].params[5], '["A","B","C","D"]')
|
||||
assert.equal(sqls[1].params[6], '[["A","B"],["C","D"]]')
|
||||
assert.equal(sqls[1].params[7], '[[0,0,0,0]]')
|
||||
})
|
||||
@@ -0,0 +1,167 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import {
|
||||
addPoint,
|
||||
createMatch,
|
||||
settleMatch,
|
||||
getServing,
|
||||
getWinner,
|
||||
parseTeams,
|
||||
setFirstServer,
|
||||
swapCourt,
|
||||
undo,
|
||||
} from '../src/match.js'
|
||||
import { renderKeyboard, renderText } from '../src/render.js'
|
||||
|
||||
test('parseTeams:雙打 / 單打 / 目標分數', () => {
|
||||
assert.deepEqual(parseTeams('小明 小華 vs 阿強 阿美'), {
|
||||
teams: [['小明', '小華'], ['阿強', '阿美']],
|
||||
target: 21,
|
||||
})
|
||||
assert.deepEqual(parseTeams('小明/小華 VS 阿強、阿美 15'), {
|
||||
teams: [['小明', '小華'], ['阿強', '阿美']],
|
||||
target: 15,
|
||||
})
|
||||
assert.deepEqual(parseTeams('小明 vs 阿強').teams, [['小明'], ['阿強']])
|
||||
assert.ok(parseTeams('小明 小華 vs 阿強').error)
|
||||
assert.ok(parseTeams('小明 小華').error)
|
||||
assert.ok(parseTeams('').error)
|
||||
})
|
||||
|
||||
test('雙打發球規則:0 分右區、同隊得分換位、失分換發不換位', () => {
|
||||
const m = createMatch(['A1', 'A2'], ['B1', 'B2'])
|
||||
assert.equal(addPoint(m, 0), false, '未選先發不能加分')
|
||||
setFirstServer(m, 0)
|
||||
|
||||
let s = getServing(m)
|
||||
assert.deepEqual([s.server, s.court, s.receiver], ['A1', 'right', 'B1'])
|
||||
|
||||
addPoint(m, 0) // A 1:0,A 隊換位,1 分左區 → A1 在左區發
|
||||
s = getServing(m)
|
||||
assert.deepEqual([m.score, s.server, s.court, s.receiver], [[1, 0], 'A1', 'left', 'B2'])
|
||||
|
||||
addPoint(m, 0) // A 2:0,再換位,2 分右區 → A1 仍在右區發
|
||||
s = getServing(m)
|
||||
assert.deepEqual([s.server, s.court, s.receiver], ['A1', 'right', 'B1'])
|
||||
|
||||
addPoint(m, 1) // B 得分 2:1,換發,B 隊不換位,1 分左區 → B2 發
|
||||
s = getServing(m)
|
||||
assert.deepEqual([m.score, s.team, s.server, s.court], [[2, 1], 1, 'B2', 'left'])
|
||||
// 接發者:A 隊左區的人。A 隊換位兩次回到原位 → A2 在左區
|
||||
assert.equal(s.receiver, 'A2')
|
||||
})
|
||||
|
||||
test('單打發球:只看分數奇偶決定發球區', () => {
|
||||
const m = createMatch(['A'], ['B'])
|
||||
setFirstServer(m, 1)
|
||||
addPoint(m, 1)
|
||||
const s = getServing(m)
|
||||
assert.deepEqual([s.server, s.court, s.receiver], ['B', 'left', 'A'])
|
||||
})
|
||||
|
||||
test('swapCourt 只能在 0:0,且會改變先發者', () => {
|
||||
const m = createMatch(['A1', 'A2'], ['B1', 'B2'])
|
||||
assert.equal(swapCourt(m, 0), true)
|
||||
setFirstServer(m, 0)
|
||||
assert.equal(getServing(m).server, 'A2')
|
||||
addPoint(m, 0)
|
||||
assert.equal(swapCourt(m, 0), false)
|
||||
})
|
||||
|
||||
test('undo 可復原分數、發球權與站位', () => {
|
||||
const m = createMatch(['A1', 'A2'], ['B1', 'B2'])
|
||||
setFirstServer(m, 0)
|
||||
addPoint(m, 0)
|
||||
addPoint(m, 1)
|
||||
assert.equal(undo(m), true)
|
||||
assert.deepEqual(m.score, [1, 0])
|
||||
assert.equal(m.server, 0)
|
||||
assert.equal(getServing(m).server, 'A1')
|
||||
assert.equal(undo(m), true)
|
||||
assert.deepEqual(m.score, [0, 0])
|
||||
assert.equal(undo(m), false)
|
||||
})
|
||||
|
||||
test('勝負判定:21 分、Deuce 領先 2 分、30 分封頂', () => {
|
||||
const m = createMatch(['A'], ['B'])
|
||||
setFirstServer(m, 0)
|
||||
m.score = [20, 20]
|
||||
addPoint(m, 0)
|
||||
assert.equal(getWinner(m), null, '21:20 未分勝負')
|
||||
addPoint(m, 0)
|
||||
assert.equal(getWinner(m), 0, '22:20 A 勝')
|
||||
assert.equal(m.finished, true)
|
||||
assert.equal(addPoint(m, 1), false, '結束後不能加分')
|
||||
|
||||
const n = createMatch(['A'], ['B'])
|
||||
setFirstServer(n, 0)
|
||||
n.score = [29, 29]
|
||||
addPoint(n, 1)
|
||||
assert.equal(getWinner(n), 1, '30:29 直接獲勝')
|
||||
})
|
||||
|
||||
test('renderText 會標記發球者、跳脫 HTML,並依畫面位置排列(上 1 2 / 下 4 3)', () => {
|
||||
const m = createMatch(['<A1>', 'A2'], ['B1', 'B2'])
|
||||
setFirstServer(m, 0)
|
||||
let text = renderText(m)
|
||||
assert.match(text, /🅰️ 🏸<A1> \/ A2/)
|
||||
assert.match(text, /🅱️ B2 \/ B1/, '下排 B2(4) 在左、B1(3) 在右')
|
||||
assert.match(text, /發球:🅰️ <b><A1><\/b>(右區) 接發:B1/)
|
||||
addPoint(m, 0) // A 得分換位:A1 到左區 → 畫面上排右邊
|
||||
text = renderText(m)
|
||||
assert.match(text, /🅰️ A2 \/ 🏸<A1>/)
|
||||
})
|
||||
|
||||
test('結算:時間到以目前比分定勝負,可 undo;鍵盤為三排', () => {
|
||||
const m = createMatch(['A'], ['B'])
|
||||
assert.equal(settleMatch(m), false, '未選先發不能結算')
|
||||
setFirstServer(m, 0)
|
||||
assert.deepEqual(
|
||||
renderKeyboard(m).inline_keyboard.map((r) => r.map((b) => b.text)),
|
||||
[['🅰️ +1', '🅱️ +1'], ['↩️ 上一步', '🔄 重新開始'], ['⏱ 結算', '🏁 結束']],
|
||||
)
|
||||
addPoint(m, 0)
|
||||
addPoint(m, 0)
|
||||
addPoint(m, 1)
|
||||
assert.equal(settleMatch(m), true)
|
||||
assert.equal(m.finished, true)
|
||||
assert.equal(getWinner(m), 0)
|
||||
assert.match(renderText(m), /⏱ 時間到結算 2 : 1 🅰️ 獲勝/)
|
||||
assert.equal(addPoint(m, 1), false)
|
||||
assert.equal(undo(m), true)
|
||||
assert.equal(m.finished, false)
|
||||
assert.equal(m.settled, false)
|
||||
assert.deepEqual(m.score, [2, 1])
|
||||
|
||||
addPoint(m, 1)
|
||||
settleMatch(m)
|
||||
assert.equal(getWinner(m), null)
|
||||
assert.match(renderText(m), /2 : 2 平手/)
|
||||
})
|
||||
|
||||
test('scoreList 沿用 web 版格式 [round, starter, winCount, winner],undo/reset 一併處理', () => {
|
||||
const m = createMatch(['A1', 'A2'], ['B1', 'B2'])
|
||||
setFirstServer(m, 0)
|
||||
addPoint(m, 0) // 0:0 A 發,偶數 → 1 號位(0)
|
||||
addPoint(m, 0) // 1:0 A 發,奇數 → 2 號位(1),連勝 1
|
||||
addPoint(m, 1) // 2:0 A 發,偶數 → 0;B 得分,連勝歸 0
|
||||
addPoint(m, 1) // 2:1 B 發,奇數 → 4 號位(3),B 連勝 1
|
||||
addPoint(m, 1) // 2:2 B 發,偶數 → 3 號位(2),B 連勝 2
|
||||
assert.deepEqual(m.scoreList, [
|
||||
[0, 0, 0, 0],
|
||||
[1, 1, 1, 0],
|
||||
[2, 0, 0, 1],
|
||||
[3, 3, 1, 1],
|
||||
[4, 2, 2, 1],
|
||||
])
|
||||
assert.ok(!/starter|winCount/.test(renderText(m)), '不顯示在 TG 記分板')
|
||||
undo(m)
|
||||
assert.equal(m.scoreList.length, 4)
|
||||
settleMatch(m)
|
||||
undo(m)
|
||||
assert.equal(m.scoreList.length, 4, '結算/復原不影響 scoreList')
|
||||
const singles = createMatch(['A'], ['B'])
|
||||
setFirstServer(singles, 1)
|
||||
addPoint(singles, 0)
|
||||
assert.deepEqual(singles.scoreList, [[0, 2, 0, 0]], '單打同樣以位置編號記')
|
||||
})
|
||||
@@ -0,0 +1,223 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createScoreboard } from '../src/scoreboard.js'
|
||||
|
||||
function fakeBot() {
|
||||
const calls = []
|
||||
let nextId = 500
|
||||
return {
|
||||
calls,
|
||||
last: (fn) => [...calls].reverse().find((c) => c.fn === fn),
|
||||
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 cq = (messageId, action, fromId = 1) => ({
|
||||
id: 'q',
|
||||
data: `sc:${action}`,
|
||||
from: { id: fromId },
|
||||
message: { chat: { id: -100 }, message_id: messageId },
|
||||
})
|
||||
|
||||
test('/記分 無參數:列出今日出席,依 1→4 點選後自動開局(1、2 一隊,3、4 一隊)', async () => {
|
||||
const bot = fakeBot()
|
||||
const sb = createScoreboard(bot, {
|
||||
dataFile: null,
|
||||
log: silentLog,
|
||||
loadAttendees: async () => ['建喵', '柏威', 'Gary', '小念', '蓉蓉'],
|
||||
})
|
||||
await sb.start(-100, 1, undefined)
|
||||
const sent = bot.last('sendMessage')
|
||||
assert.match(sent.text, /選擇上場人員/)
|
||||
const buttons = sent.opts.reply_markup.inline_keyboard.flat().map((b) => b.text)
|
||||
assert.deepEqual(buttons.slice(0, 5), ['建喵', '柏威', 'Gary', '小念', '蓉蓉'])
|
||||
assert.ok(buttons.includes('✖️ 取消'))
|
||||
const mid = sent.chatId === -100 && 500
|
||||
|
||||
await sb.onCallback(cq(mid, 'pick0'))
|
||||
await sb.onCallback(cq(mid, 'pick0'))
|
||||
assert.equal(bot.last('answerCallbackQuery').opts.text, '這位已經選了')
|
||||
let edit = bot.last('editMessageText')
|
||||
assert.match(edit.text, /🅰️ 1️⃣ 建喵 2️⃣ —/)
|
||||
assert.match(edit.text, /🅱️ 4️⃣ — 3️⃣ —/)
|
||||
assert.ok(edit.opts.reply_markup.inline_keyboard.flat().some((b) => b.text === '1️⃣ 建喵'))
|
||||
|
||||
await sb.onCallback(cq(mid, 'pick2'))
|
||||
edit = bot.last('editMessageText')
|
||||
assert.ok(edit.opts.reply_markup.inline_keyboard.flat().some((b) => b.text === '▶️ 單打開始'), '2 人時可單打開始')
|
||||
|
||||
await sb.onCallback(cq(mid, 'unpick'))
|
||||
edit = bot.last('editMessageText')
|
||||
assert.match(edit.text, /2️⃣ —/)
|
||||
|
||||
await sb.onCallback(cq(mid, 'pick1'))
|
||||
await sb.onCallback(cq(mid, 'pick3'))
|
||||
await sb.onCallback(cq(mid, 'pick4'))
|
||||
edit = bot.last('editMessageText')
|
||||
assert.match(edit.text, /羽球記分板/, '第 4 人選完自動變成記分板')
|
||||
assert.match(edit.text, /🅰️ 建喵 \/ 柏威/)
|
||||
assert.match(edit.text, /🅱️ 蓉蓉 \/ 小念/, '下排照 web 版 4 3 排列')
|
||||
const match = sb.store.get(-100, mid)
|
||||
assert.deepEqual(match.teams.map((t) => t.players), [['建喵', '柏威'], ['小念', '蓉蓉']])
|
||||
assert.equal(match.phase, undefined)
|
||||
})
|
||||
|
||||
test('/記分 無參數:選 2 人按單打開始;沒人報名時提示', async () => {
|
||||
const bot = fakeBot()
|
||||
const sb = createScoreboard(bot, { dataFile: null, log: silentLog, loadAttendees: async () => ['A', 'B', 'C'] })
|
||||
await sb.start(-100, 1, '')
|
||||
await sb.onCallback(cq(500, 'pick2'))
|
||||
await sb.onCallback(cq(500, 'pick0'))
|
||||
await sb.onCallback(cq(500, 'pickgo'))
|
||||
assert.deepEqual(sb.store.get(-100, 500).teams.map((t) => t.players), [['C'], ['A']])
|
||||
|
||||
const bot2 = fakeBot()
|
||||
const sb2 = createScoreboard(bot2, { dataFile: null, log: silentLog, loadAttendees: async () => [] })
|
||||
await sb2.start(-100, 1, undefined)
|
||||
assert.match(bot2.last('sendMessage').text, /今天還沒有人報名/)
|
||||
})
|
||||
|
||||
test('結束要兩段確認:第一次按只詢問,取消可回復,再按確定才結束', async () => {
|
||||
const bot = fakeBot()
|
||||
const sb = createScoreboard(bot, { dataFile: null, log: silentLog })
|
||||
await sb.start(-100, 1, 'A vs B')
|
||||
await sb.onCallback(cq(500, 'srv0'))
|
||||
await sb.onCallback(cq(500, 'pt0'))
|
||||
|
||||
await sb.onCallback(cq(500, 'end'))
|
||||
let edit = bot.last('editMessageText')
|
||||
assert.match(edit.text, /確定要結束嗎/)
|
||||
const texts = edit.opts.reply_markup.inline_keyboard.flat().map((b) => b.text)
|
||||
assert.ok(texts.includes('✅ 確定結束') && texts.includes('↩️ 取消'))
|
||||
assert.ok(!texts.includes('🏁 結束'))
|
||||
assert.ok(sb.store.get(-100, 500), '尚未結束')
|
||||
|
||||
await sb.onCallback(cq(500, 'endcancel'))
|
||||
edit = bot.last('editMessageText')
|
||||
assert.ok(!/確定要結束嗎/.test(edit.text))
|
||||
assert.ok(edit.opts.reply_markup.inline_keyboard.flat().some((b) => b.text === '🏁 結束'))
|
||||
|
||||
await sb.onCallback(cq(500, 'end'))
|
||||
await sb.onCallback(cq(500, 'pt1'), '按其他按鈕會取消確認')
|
||||
edit = bot.last('editMessageText')
|
||||
assert.ok(!/確定要結束嗎/.test(edit.text))
|
||||
assert.equal(sb.store.get(-100, 500).score[1], 1)
|
||||
|
||||
await sb.onCallback(cq(500, 'end'))
|
||||
await sb.onCallback(cq(500, 'end'))
|
||||
edit = bot.last('editMessageText')
|
||||
assert.match(edit.text, /已結束/)
|
||||
assert.deepEqual(edit.opts.reply_markup.inline_keyboard, [])
|
||||
assert.equal(sb.store.get(-100, 500), null)
|
||||
})
|
||||
|
||||
test('權限:只有開局者或群組管理員能按,其他人只跳提示;舊資料無 ownerId 不限制', async () => {
|
||||
const bot = fakeBot()
|
||||
const admins = new Set([99])
|
||||
const sb = createScoreboard(bot, {
|
||||
dataFile: null,
|
||||
log: silentLog,
|
||||
isAdmin: async (_chatId, userId) => admins.has(userId),
|
||||
})
|
||||
await sb.start(-100, 1, 'A vs B', 1) // 開局者 user 1
|
||||
assert.equal(sb.store.get(-100, 500).ownerId, 1)
|
||||
|
||||
await sb.onCallback(cq(500, 'srv0', 2)) // 路人
|
||||
assert.equal(bot.last('answerCallbackQuery').opts.text, '只有開局者或群組管理員可以操作記分板')
|
||||
assert.equal(sb.store.get(-100, 500).server, null, '狀態不變')
|
||||
|
||||
await sb.onCallback(cq(500, 'srv0', 99)) // 管理員
|
||||
assert.equal(sb.store.get(-100, 500).server, 0)
|
||||
await sb.onCallback(cq(500, 'pt0', 1)) // 開局者
|
||||
assert.deepEqual(sb.store.get(-100, 500).score, [1, 0])
|
||||
|
||||
// 選人階段同樣受限,且開局者會帶到記分板
|
||||
const sb2 = createScoreboard(bot, { dataFile: null, log: silentLog, loadAttendees: async () => ['A', 'B'], isAdmin: async () => false })
|
||||
await sb2.start(-100, 1, undefined, 7)
|
||||
await sb2.onCallback(cq(501, 'pick0', 8))
|
||||
assert.equal(sb2.store.get(-100, 501).picked.length, 0)
|
||||
await sb2.onCallback(cq(501, 'pick0', 7))
|
||||
await sb2.onCallback(cq(501, 'pick1', 7))
|
||||
await sb2.onCallback(cq(501, 'pickgo', 7))
|
||||
assert.equal(sb2.store.get(-100, 501).ownerId, 7)
|
||||
|
||||
// 舊資料沒有 ownerId → 任何人可按
|
||||
sb.store.set(-100, 600, { ...sb.store.get(-100, 500), ownerId: undefined })
|
||||
await sb.onCallback(cq(600, 'pt1', 2))
|
||||
assert.deepEqual(sb.store.get(-100, 600).score, [1, 1])
|
||||
})
|
||||
|
||||
test('歷史戰績:分出勝負後「確定結束」或「再一局」會存一次;未結算的結束不存;失敗不結束', async () => {
|
||||
const bot = fakeBot()
|
||||
const saved = []
|
||||
let fail = false
|
||||
const saveHistory = async (m) => {
|
||||
if (fail) throw new Error('db down')
|
||||
saved.push({ score: [...m.score], teams: m.teams.map((t) => [...t.players]) })
|
||||
return saved.length
|
||||
}
|
||||
const sb = createScoreboard(bot, { dataFile: null, log: silentLog, saveHistory })
|
||||
|
||||
// 未結算就結束 → 不存
|
||||
await sb.start(-100, 1, 'A vs B')
|
||||
await sb.onCallback(cq(500, 'srv0'))
|
||||
await sb.onCallback(cq(500, 'pt0'))
|
||||
await sb.onCallback(cq(500, 'end'))
|
||||
assert.match(bot.last('editMessageText').text, /比賽未結算,不會寫入戰績/)
|
||||
await sb.onCallback(cq(500, 'end'))
|
||||
assert.equal(saved.length, 0)
|
||||
assert.match(bot.last('editMessageText').text, /已結束(未結算,未存入戰績)/)
|
||||
|
||||
// 結算後再一局 → 存一筆,並開新局
|
||||
await sb.start(-100, 1, 'A vs B')
|
||||
await sb.onCallback(cq(501, 'srv0'))
|
||||
await sb.onCallback(cq(501, 'pt0'))
|
||||
await sb.onCallback(cq(501, 'pt1'))
|
||||
await sb.onCallback(cq(501, 'pt1'))
|
||||
await sb.onCallback(cq(501, 'settle'))
|
||||
await sb.onCallback(cq(501, 'reset'))
|
||||
assert.equal(saved.length, 1)
|
||||
assert.deepEqual(saved[0].score, [1, 2])
|
||||
assert.match(bot.last('answerCallbackQuery').opts.text, /戰績 #1 已存/)
|
||||
assert.deepEqual(sb.store.get(-100, 501).score, [0, 0])
|
||||
assert.equal(sb.store.get(-100, 501).historyId, undefined)
|
||||
|
||||
// 新局打到分出勝負 → 確定結束存第二筆;DB 失敗時不結束
|
||||
const m = sb.store.get(-100, 501)
|
||||
await sb.onCallback(cq(501, 'srv1'))
|
||||
m.score = [0, 20]
|
||||
await sb.onCallback(cq(501, 'pt1'))
|
||||
assert.equal(m.finished, true)
|
||||
fail = true
|
||||
await sb.onCallback(cq(501, 'end'))
|
||||
assert.match(bot.last('editMessageText').text, /會把這場寫入歷史戰績/)
|
||||
await sb.onCallback(cq(501, 'end'))
|
||||
assert.match(bot.last('answerCallbackQuery').opts.text, /寫入歷史戰績失敗/)
|
||||
assert.ok(sb.store.get(-100, 501), '失敗時不結束')
|
||||
fail = false
|
||||
await sb.onCallback(cq(501, 'end'))
|
||||
assert.equal(saved.length, 2)
|
||||
assert.deepEqual(saved[1].score, [0, 21])
|
||||
assert.match(bot.last('editMessageText').text, /已存入歷史戰績 #2/)
|
||||
assert.equal(sb.store.get(-100, 501), null)
|
||||
})
|
||||
|
||||
test('選人中按取消 → 訊息鎖定並移除狀態', async () => {
|
||||
const bot = fakeBot()
|
||||
const sb = createScoreboard(bot, { dataFile: null, log: silentLog, loadAttendees: async () => ['A', 'B'] })
|
||||
await sb.start(-100, 1, undefined)
|
||||
await sb.onCallback(cq(500, 'end'))
|
||||
assert.match(bot.last('editMessageText').text, /已取消選人/)
|
||||
assert.equal(sb.store.get(-100, 500), null)
|
||||
})
|
||||
@@ -0,0 +1,177 @@
|
||||
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\):建喵、<G>\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_poll;setNickname 會更新今日名單與訊息', 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)
|
||||
})
|
||||
Reference in New Issue
Block a user