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