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

490 lines
22 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 { 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)
})
// 固定亂數(mulberry32
function seeded(seed) {
let a = seed >>> 0
return () => {
a = (a + 0x6d2b79f5) >>> 0
let t = a
t = Math.imul(t ^ (t >>> 15), t | 1)
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
const TEN = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J']
const TEN_SKILL = { A: 9, B: 9, C: 9, D: 9, E: 1, F: 1, G: 1, H: 1, I: 5, J: 5 }
const TEN_ID = Object.fromEntries(TEN.map((name, i) => [name, 11 + i])) // A=11 … J=20
const TEN_MEMBERS = TEN.map((name) => ({ name, skill: TEN_SKILL[name], userId: TEN_ID[name] }))
const buttonTexts = (call) => call.opts.reply_markup.inline_keyboard.flat().map((b) => b.text)
const playersOf = (m) => m.teams.flatMap((t) => t.players)
test('🎲 隨機配對:預設全選 → 可切換 → 依戰績配對預覽 → 開記分板推出 2、3 號場地,計分人從休息者抽', async () => {
const bot = fakeBot()
const todayGames = [{ teams: [['A', 'B'], ['C', 'D']] }, { teams: [['E', 'F'], ['G', 'H']] }] // A~H 剛打完
const sb = createScoreboard(bot, {
dataFile: null,
log: silentLog,
loadAttendees: async () => TEN_MEMBERS,
loadTodayGames: async () => todayGames,
isAdmin: async (_chatId, userId) => userId === 99,
random: seeded(1),
})
await sb.start(-100, 1, undefined, 7) // 開局者 7 不在出席名單
assert.ok(buttonTexts(bot.last('sendMessage')).includes('🎲 隨機配對'))
assert.deepEqual(sb.store.get(-100, 500).skills, TEN_SKILL, '出席名單的 skill 帶進來')
assert.deepEqual(sb.store.get(-100, 500).userIds, TEN_ID, '出席名單的 user id 帶進來')
await sb.onCallback(cq(500, 'rand', 7))
let entry = sb.store.get(-100, 500)
assert.equal(entry.phase, 'roster')
assert.deepEqual(entry.selected, TEN.map(() => true), '預設全選')
let edit = bot.last('editMessageText')
assert.match(edit.text, /已選 10 \/ 10/)
assert.ok(buttonTexts(edit).includes('✅ A'))
assert.ok(buttonTexts(edit).includes('⬜ 全不選'))
await sb.onCallback(cq(500, 'tg8', 7)) // 取消勾 I
assert.equal(sb.store.get(-100, 500).selected[8], false)
edit = bot.last('editMessageText')
assert.match(edit.text, /已選 9 \/ 10/)
assert.ok(buttonTexts(edit).includes('⬜ I'))
assert.ok(buttonTexts(edit).includes('✅ 全選'))
await sb.onCallback(cq(500, 'tg8', 7)) // 勾回
await sb.onCallback(cq(500, 'rall', 7)) // 全不選
assert.equal(sb.store.get(-100, 500).selected.filter(Boolean).length, 0)
await sb.onCallback(cq(500, 'rgo', 7))
assert.equal(bot.last('answerCallbackQuery').opts.text, '至少要選 4 人')
assert.equal(sb.store.get(-100, 500).phase, 'roster')
await sb.onCallback(cq(500, 'rall', 7)) // 全選
await sb.onCallback(cq(500, 'rgo', 7))
entry = sb.store.get(-100, 500)
assert.equal(entry.phase, 'paired')
assert.deepEqual(entry.games.map((g) => g.court), [2, 3])
const active = entry.games.flatMap((g) => [...g.teamA, ...g.teamB])
assert.ok(active.includes('I') && active.includes('J'), '沒打過的 I、J 一定上場')
assert.equal(entry.resting.length, 2)
assert.ok(entry.resting.every((n) => 'ABCDEFGH'.includes(n)), '休息的是剛打完的人')
const scorers = entry.games.map((g) => g.scorer)
assert.ok(scorers.every((s) => s && entry.resting.includes(s)), '計分人從休息者抽')
assert.notEqual(scorers[0], scorers[1], '一人負責一個場地')
edit = bot.last('editMessageText')
assert.match(edit.text, /2️⃣ 號場地 🅰️ .+ vs 🅱️ .+ 📝 計分:/)
assert.match(edit.text, /3️⃣ 號場地/)
assert.match(edit.text, /😴 休息:/)
assert.ok(buttonTexts(edit).includes('✅ 開記分板') && buttonTexts(edit).includes('🔁 重新配對'))
await sb.onCallback(cq(500, 'rgo', 7)) // 重新配對
entry = sb.store.get(-100, 500)
assert.equal(entry.phase, 'paired')
await sb.onCallback(cq(500, 'pgo', 8)) // 路人
assert.equal(bot.last('answerCallbackQuery').opts.text, '只有開局者或群組管理員可以操作記分板')
bot.calls.length = 0
await sb.onCallback(cq(500, 'pgo', 7))
const sent = bot.calls.filter((c) => c.fn === 'sendMessage')
assert.equal(sent.length, 2, '2、3 號場地各一則記分板')
assert.match(sent[0].text, /羽球記分板 2️⃣ 號場地/)
assert.match(sent[1].text, /羽球記分板 3️⃣ 號場地/)
const m2 = sb.store.get(-100, 501)
const m3 = sb.store.get(-100, 502)
assert.equal(m2.court, 2)
assert.equal(m3.court, 3)
assert.deepEqual(m2.teams.map((t) => t.players), [entry.games[0].teamA, entry.games[0].teamB])
assert.equal(m2.server, null)
const scorer2 = entry.games[0].scorer
assert.equal(m2.scorer.name, scorer2)
assert.equal(m2.ownerId, TEN_ID[scorer2], '權限給抽到的計分人,不是開局者')
assert.equal(m3.ownerId, TEN_ID[entry.games[1].scorer])
assert.match(sent[0].text, new RegExp(`📝 計分:${scorer2}`))
assert.equal(sb.store.get(-100, 500), null, '預覽訊息狀態移除')
edit = bot.last('editMessageText')
assert.match(edit.text, /記分板已推出/)
assert.deepEqual(edit.opts.reply_markup.inline_keyboard, [])
// 開局者(7)不是計分人 → 不能按;計分人本人可以
await sb.onCallback(cq(501, 'srv0', 7))
assert.equal(bot.last('answerCallbackQuery').opts.text, '只有開局者或群組管理員可以操作記分板')
assert.equal(m2.server, null)
await sb.onCallback(cq(501, 'swapab', TEN_ID[scorer2]))
assert.deepEqual(sb.store.get(-100, 501).teams.map((t) => t.players), [entry.games[0].teamB, entry.games[0].teamA])
await sb.onCallback(cq(501, 'srv0', 99)) // 管理員也可以
await sb.onCallback(cq(501, 'pt0', TEN_ID[scorer2]))
assert.deepEqual(sb.store.get(-100, 501).score, [1, 0])
})
test('🙋 換人計分:只有計分人 / 管理員能操作、只能在開始計分前;可轉交給場外的人或改成任何人', async () => {
const bot = fakeBot()
const sb = createScoreboard(bot, {
dataFile: null,
log: silentLog,
loadAttendees: async () => TEN_MEMBERS,
loadTodayGames: async () => [],
isAdmin: async (_chatId, userId) => userId === 99,
scorerExclude: new Set(['J']),
random: seeded(3),
})
await sb.start(-100, 1, undefined, 7)
await sb.onCallback(cq(500, 'rand', 7))
await sb.onCallback(cq(500, 'rgo', 7))
const entry = sb.store.get(-100, 500)
assert.ok(entry.games.every((g) => g.scorer !== 'J'), '排除名單不當計分人')
await sb.onCallback(cq(500, 'pgo', 7))
const m = sb.store.get(-100, 501)
const scorer = m.scorer.name
const scorerId = m.ownerId
assert.equal(scorerId, TEN_ID[scorer])
const players = playersOf(m)
// 路人 / 開局者不能開換人
await sb.onCallback(cq(501, 'ho', 7))
assert.equal(bot.last('answerCallbackQuery').opts.text, '只有開局者或群組管理員可以操作記分板')
assert.equal(m.handover, undefined)
// 計分人開換人:候選 = 出席 − 場上 4 人 − 排除名單
await sb.onCallback(cq(501, 'ho', scorerId))
let edit = bot.last('editMessageText')
assert.match(edit.text, new RegExp(`選擇新的計分人.*目前:${scorer}`))
const candidates = m.handover.map((c) => c.name)
assert.ok(candidates.every((n) => !players.includes(n)), '場上的人不列')
assert.ok(!candidates.includes('J'), '排除名單不列')
assert.ok(!candidates.includes(scorer), '現任計分人自己不列')
assert.deepEqual(candidates, TEN.filter((n) => !players.includes(n) && n !== 'J' && n !== scorer))
const texts = buttonTexts(edit)
assert.ok(texts.includes('👥 任何人(不鎖權限)') && texts.includes('↩️ 取消'))
assert.ok(!texts.includes('🅰️ 先發球'))
// 取消 → 回到開局鍵盤
await sb.onCallback(cq(501, 'hocancel', scorerId))
assert.equal(m.handover, undefined)
assert.ok(buttonTexts(bot.last('editMessageText')).includes('🙋 換人計分'))
assert.equal(m.ownerId, scorerId)
// 換人中按別的鍵也會關掉換人面板
await sb.onCallback(cq(501, 'ho', scorerId))
await sb.onCallback(cq(501, 'swapab', scorerId))
assert.equal(m.handover, undefined)
assert.ok(buttonTexts(bot.last('editMessageText')).includes('🅰️ 先發球'))
// 轉交給候選第一位 → 權限換人,原計分人不能再按
await sb.onCallback(cq(501, 'ho', scorerId))
const next = m.handover[0]
await sb.onCallback(cq(501, 'ho0', scorerId))
assert.equal(bot.last('answerCallbackQuery').opts.text, `計分人已改為 ${next.name}`)
assert.equal(m.ownerId, TEN_ID[next.name])
assert.equal(m.scorer.name, next.name)
assert.match(bot.last('editMessageText').text, new RegExp(`📝 計分:${next.name}`))
await sb.onCallback(cq(501, 'srv0', scorerId))
assert.equal(bot.last('answerCallbackQuery').opts.text, '只有開局者或群組管理員可以操作記分板')
assert.equal(m.server, null)
// 管理員改成任何人 → 不鎖權限,開局者也能按
await sb.onCallback(cq(501, 'ho', 99))
await sb.onCallback(cq(501, 'hoany', 99))
assert.equal(m.ownerId, null)
assert.deepEqual(m.scorer, { name: null, userId: null })
assert.match(bot.last('editMessageText').text, /📝 計分:任何人/)
await sb.onCallback(cq(501, 'srv0', 7))
assert.equal(m.server, 0)
// 開始計分後不能換人
await sb.onCallback(cq(501, 'ho', 7))
assert.equal(bot.last('answerCallbackQuery').opts.text, '開始計分後不能換人計分')
assert.equal(m.handover, undefined)
// 沒開換人面板就按候選 → 提示
await sb.onCallback(cq(501, 'ho0', 7))
assert.equal(bot.last('answerCallbackQuery').opts.text, '請重新按「換人計分」')
})
test('🎲 隨機配對:返回 / 取消;4~7 人只推 2 號場地;休息者都被排除 → 任何人;讀不到戰績時照配並提示', async () => {
const bot = fakeBot()
const sb = createScoreboard(bot, {
dataFile: null,
log: silentLog,
loadAttendees: async () => ['A', 'B', 'C', 'D', 'E'],
loadTodayGames: async () => {
throw new Error('db down')
},
scorerExclude: new Set(['A', 'B', 'C', 'D', 'E']),
random: seeded(2),
})
await sb.start(-100, 1, undefined)
await sb.onCallback(cq(500, 'rand'))
await sb.onCallback(cq(500, 'rback'))
assert.equal(sb.store.get(-100, 500).phase, 'pick')
assert.match(bot.last('editMessageText').text, /選擇上場人員/)
await sb.onCallback(cq(500, 'rand'))
await sb.onCallback(cq(500, 'rgo'))
let entry = sb.store.get(-100, 500)
assert.equal(entry.phase, 'paired')
assert.equal(entry.games.length, 1)
assert.equal(entry.games[0].court, 2)
assert.equal(entry.resting.length, 1)
assert.equal(entry.games[0].scorer, null, '休息的人在排除名單 → 沒有計分人')
let edit = bot.last('editMessageText')
assert.match(edit.text, /⚠️ 讀不到今日戰績/)
assert.match(edit.text, /📝 計分:任何人/)
await sb.onCallback(cq(500, 'rback'))
entry = sb.store.get(-100, 500)
assert.equal(entry.phase, 'roster')
assert.equal(entry.games, undefined)
assert.deepEqual(entry.selected, [true, true, true, true, true], '勾選狀態保留')
await sb.onCallback(cq(500, 'rgo'))
bot.calls.length = 0
await sb.onCallback(cq(500, 'pgo'))
const sent = bot.calls.filter((c) => c.fn === 'sendMessage')
assert.equal(sent.length, 1)
const m = sb.store.get(-100, 501)
assert.equal(m.ownerId, null, '沒有計分人 → 不鎖權限')
assert.deepEqual(m.scorer, { name: null, userId: null })
assert.match(sent[0].text, /📝 計分:任何人/)
await sb.onCallback(cq(501, 'srv1', 12345)) // 任何人都能按
assert.equal(m.server, 1)
// 0:0 按上一步 → 退回選先發球,才能換人
await sb.onCallback(cq(501, 'undo', 12345))
assert.equal(m.server, null)
assert.ok(buttonTexts(bot.last('editMessageText')).includes('🙋 換人計分'))
// 換人面板:出席的人都被排除 → 只剩任何人 / 取消
await sb.onCallback(cq(501, 'ho', 12345))
assert.equal(bot.last('answerCallbackQuery').opts.text, '今天沒有其他出席的人可選,可改成「任何人」')
assert.deepEqual(buttonTexts(bot.last('editMessageText')), ['👥 任何人(不鎖權限)', '↩️ 取消'])
await sb.onCallback(cq(501, 'hocancel', 12345))
const bot2 = fakeBot()
const sb2 = createScoreboard(bot2, { dataFile: null, log: silentLog, loadAttendees: async () => ['A', 'B', 'C', 'D'] })
await sb2.start(-100, 1, undefined)
await sb2.onCallback(cq(500, 'rand'))
await sb2.onCallback(cq(500, 'end'))
assert.match(bot2.last('editMessageText').text, /已取消配對/)
assert.equal(sb2.store.get(-100, 500), null)
})