Files
JianMiauandClaude Fable 5 0386c94546 記分板新增隨機配對、計分人權限與換人計分,調整按鈕排法
根本原因:
每打完一輪都要人工決定誰上場、誰搭誰、誰負責記分,記分板也沒有「兩隊對換」和退回選先發球的操作,現場排場地或換人都得重開記分板。

影響:
/記分 多一顆 🎲 隨機配對:勾選出席者(預設全選)後,依 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>
2026-08-24 22:24:16 +08:00

207 lines
8.4 KiB
JavaScript
Raw Permalink 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 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\):建喵、&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)
})
test('attendees:出席名單帶 tg_members.skilluser_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'), [])
})