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) })