根本原因: 每打完一輪都要人工決定誰上場、誰搭誰、誰負責記分,記分板也沒有「兩隊對換」和退回選先發球的操作,現場排場地或換人都得重開記分板。 影響: /記分 多一顆 🎲 隨機配對:勾選出席者(預設全選)後,依 tg_members.skill 與今天的 history 戰績排 2、3 號場地(休息最久優先、搭檔與對手不重複、實力平衡),預覽後一鍵推出各場地記分板;每場從休息者抽一位計分人(SCORER_EXCLUDE 不抽),只有他和管理員能按,休息人不夠則不鎖權限;開局可 🙋 換人計分、🔃 🅰️🅱️ 對換;計分鍵盤改為左欄 🅰️+1/🅱️+1,拿掉「重新開始」,0:0 時上一步可退回選先發球。 修法: - 新增 src/pairing.js:移植 badminton-match-hub V2 的配對規則,改成一次只排下一輪、以「休息輪數」取代平均場數;assignScorers 從休息者抽計分人 - history.js 新增 loadTodayGames 讀今日戰績;signup.js attendees() 帶 skill 與 user_id(權限靠 TG id 比對) - scoreboard.js 以 phase(pick → roster → paired)管理同一則訊息的選人/勾選/預覽流程,推出的記分板 ownerId 指向計分人;新增換人計分(ho / hoN / hoany / hocancel),候選排除場上、現任計分人與 SCORER_EXCLUDE - match.js 新增 swapTeams,setFirstServer 記入 history 讓 undo 可退回開局;render.js 重排鍵盤並新增勾選、預覽、換人面板與場地標題 - config 新增 SCORER_EXCLUDE;README、.env.example 與 37 個測試同步更新 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
207 lines
8.4 KiB
JavaScript
207 lines
8.4 KiB
JavaScript
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 a.nickname, a.user_id, COALESCE(m.skill')) {
|
||
return attendance
|
||
.filter((r) => r.poll_date === params[0])
|
||
.map((r) => {
|
||
const byId = members.get(r.user_id)
|
||
const byName = [...members.values()].find((m) => m.nickname === r.nickname)
|
||
return { nickname: r.nickname, user_id: r.user_id, skill: byId?.skill ?? byName?.skill ?? 1 }
|
||
})
|
||
}
|
||
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)
|
||
})
|
||
|
||
test('attendees:出席名單帶 tg_members.skill(user_id 對不到就用暱稱對,都沒有當 1)', async () => {
|
||
const db = fakeDb()
|
||
const bot = fakeBot()
|
||
const signup = createSignup(bot, { log: silentLog, query: db.query })
|
||
db.members.set(1, { nickname: '建喵', skill: 9 })
|
||
db.members.set(3, { nickname: '同名', skill: 4 })
|
||
db.attendance.push(
|
||
{ poll_date: DATE, user_id: 1, nickname: '建喵' },
|
||
{ poll_date: DATE, user_id: 2, nickname: '路人' },
|
||
{ poll_date: DATE, user_id: 99, nickname: '同名' },
|
||
{ poll_date: DATE, user_id: 100, nickname: ' ' },
|
||
)
|
||
assert.deepEqual(await signup.attendees(DATE), [
|
||
{ name: '建喵', skill: 9, userId: 1 },
|
||
{ name: '路人', skill: 1, userId: 2 },
|
||
{ name: '同名', skill: 4, userId: 99 },
|
||
])
|
||
assert.deepEqual(await signup.attendees('20000101'), [])
|
||
})
|