Files
tg-shuttle-score/test/match.test.js
T
JianMiauandClaude Fable 5 2e88a7d85a 建立建喵打羽球 Bot:移植 Node-RED 報名流程並新增群組記分板
根本原因:
原本的「今日羽球報名」bot 寫在 Node-RED 的 function 節點裡,不易測試與擴充;
羽球團又需要在 Telegram 群組內直接記分、看發球者,並把戰績寫進 web 版共用的 DB。

影響:
同一個 bot(@JianMiauBadmintonBot)改由本專案服務:報名指令、按鈕、暱稱、
週一 10:00 排程、白名單行為與訊息格式皆與 Node-RED 版相同,舊訊息按鈕仍可用;
另新增 /記分(/score、/new)記分板,並在結束/再一局時寫入共用的 history 表。

修法:
- signup.js:逐句移植 Node-RED bd_sm 狀態機(attendance / tg_poll / tg_members)
- scoreboard.js + match.js + render.js:羽球規則(發球區、換位、Deuce、30 分封頂)、
  上排 1 2/下排 4 3 版面、從今日出席選人開局、⏱ 結算、兩段式結束確認、
  開局者/管理員權限、scoreList(web 版格式)
- history.js:寫入 history 表,欄位與 web 版 ensureHistoryTable 相同
- scripts/nodered-switch.mjs:透過 admin API 暫停/恢復 Node-RED 舊流程
- 單元測試 23 項(規則、報名、選人、結算、權限、歷史寫入),Dockerfile / compose

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 11:10:27 +08:00

168 lines
5.8 KiB
JavaScript
Raw 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 {
addPoint,
createMatch,
settleMatch,
getServing,
getWinner,
parseTeams,
setFirstServer,
swapCourt,
undo,
} from '../src/match.js'
import { renderKeyboard, renderText } from '../src/render.js'
test('parseTeams:雙打 / 單打 / 目標分數', () => {
assert.deepEqual(parseTeams('小明 小華 vs 阿強 阿美'), {
teams: [['小明', '小華'], ['阿強', '阿美']],
target: 21,
})
assert.deepEqual(parseTeams('小明/小華 VS 阿強、阿美 15'), {
teams: [['小明', '小華'], ['阿強', '阿美']],
target: 15,
})
assert.deepEqual(parseTeams('小明 vs 阿強').teams, [['小明'], ['阿強']])
assert.ok(parseTeams('小明 小華 vs 阿強').error)
assert.ok(parseTeams('小明 小華').error)
assert.ok(parseTeams('').error)
})
test('雙打發球規則:0 分右區、同隊得分換位、失分換發不換位', () => {
const m = createMatch(['A1', 'A2'], ['B1', 'B2'])
assert.equal(addPoint(m, 0), false, '未選先發不能加分')
setFirstServer(m, 0)
let s = getServing(m)
assert.deepEqual([s.server, s.court, s.receiver], ['A1', 'right', 'B1'])
addPoint(m, 0) // A 1:0,A 隊換位,1 分左區 → A1 在左區發
s = getServing(m)
assert.deepEqual([m.score, s.server, s.court, s.receiver], [[1, 0], 'A1', 'left', 'B2'])
addPoint(m, 0) // A 2:0,再換位,2 分右區 → A1 仍在右區發
s = getServing(m)
assert.deepEqual([s.server, s.court, s.receiver], ['A1', 'right', 'B1'])
addPoint(m, 1) // B 得分 2:1,換發,B 隊不換位,1 分左區 → B2 發
s = getServing(m)
assert.deepEqual([m.score, s.team, s.server, s.court], [[2, 1], 1, 'B2', 'left'])
// 接發者:A 隊左區的人。A 隊換位兩次回到原位 → A2 在左區
assert.equal(s.receiver, 'A2')
})
test('單打發球:只看分數奇偶決定發球區', () => {
const m = createMatch(['A'], ['B'])
setFirstServer(m, 1)
addPoint(m, 1)
const s = getServing(m)
assert.deepEqual([s.server, s.court, s.receiver], ['B', 'left', 'A'])
})
test('swapCourt 只能在 0:0,且會改變先發者', () => {
const m = createMatch(['A1', 'A2'], ['B1', 'B2'])
assert.equal(swapCourt(m, 0), true)
setFirstServer(m, 0)
assert.equal(getServing(m).server, 'A2')
addPoint(m, 0)
assert.equal(swapCourt(m, 0), false)
})
test('undo 可復原分數、發球權與站位', () => {
const m = createMatch(['A1', 'A2'], ['B1', 'B2'])
setFirstServer(m, 0)
addPoint(m, 0)
addPoint(m, 1)
assert.equal(undo(m), true)
assert.deepEqual(m.score, [1, 0])
assert.equal(m.server, 0)
assert.equal(getServing(m).server, 'A1')
assert.equal(undo(m), true)
assert.deepEqual(m.score, [0, 0])
assert.equal(undo(m), false)
})
test('勝負判定:21 分、Deuce 領先 2 分、30 分封頂', () => {
const m = createMatch(['A'], ['B'])
setFirstServer(m, 0)
m.score = [20, 20]
addPoint(m, 0)
assert.equal(getWinner(m), null, '21:20 未分勝負')
addPoint(m, 0)
assert.equal(getWinner(m), 0, '22:20 A 勝')
assert.equal(m.finished, true)
assert.equal(addPoint(m, 1), false, '結束後不能加分')
const n = createMatch(['A'], ['B'])
setFirstServer(n, 0)
n.score = [29, 29]
addPoint(n, 1)
assert.equal(getWinner(n), 1, '30:29 直接獲勝')
})
test('renderText 會標記發球者、跳脫 HTML,並依畫面位置排列(上 1 2 / 下 4 3)', () => {
const m = createMatch(['<A1>', 'A2'], ['B1', 'B2'])
setFirstServer(m, 0)
let text = renderText(m)
assert.match(text, /🅰️ 🏸&lt;A1&gt; \/ A2/)
assert.match(text, /🅱️ B2 \/ B1/, '下排 B2(4) 在左、B1(3) 在右')
assert.match(text, /發球:🅰️ <b>&lt;A1&gt;<\/b>(右區) 接發:B1/)
addPoint(m, 0) // A 得分換位:A1 到左區 → 畫面上排右邊
text = renderText(m)
assert.match(text, /🅰️ A2 \/ 🏸&lt;A1&gt;/)
})
test('結算:時間到以目前比分定勝負,可 undo;鍵盤為三排', () => {
const m = createMatch(['A'], ['B'])
assert.equal(settleMatch(m), false, '未選先發不能結算')
setFirstServer(m, 0)
assert.deepEqual(
renderKeyboard(m).inline_keyboard.map((r) => r.map((b) => b.text)),
[['🅰️ +1', '🅱️ +1'], ['↩️ 上一步', '🔄 重新開始'], ['⏱ 結算', '🏁 結束']],
)
addPoint(m, 0)
addPoint(m, 0)
addPoint(m, 1)
assert.equal(settleMatch(m), true)
assert.equal(m.finished, true)
assert.equal(getWinner(m), 0)
assert.match(renderText(m), /⏱ 時間到結算 2 : 1 🅰️ 獲勝/)
assert.equal(addPoint(m, 1), false)
assert.equal(undo(m), true)
assert.equal(m.finished, false)
assert.equal(m.settled, false)
assert.deepEqual(m.score, [2, 1])
addPoint(m, 1)
settleMatch(m)
assert.equal(getWinner(m), null)
assert.match(renderText(m), /2 : 2 平手/)
})
test('scoreList 沿用 web 版格式 [round, starter, winCount, winner]undo/reset 一併處理', () => {
const m = createMatch(['A1', 'A2'], ['B1', 'B2'])
setFirstServer(m, 0)
addPoint(m, 0) // 0:0 A 發,偶數 → 1 號位(0)
addPoint(m, 0) // 1:0 A 發,奇數 → 2 號位(1),連勝 1
addPoint(m, 1) // 2:0 A 發,偶數 → 0;B 得分,連勝歸 0
addPoint(m, 1) // 2:1 B 發,奇數 → 4 號位(3)B 連勝 1
addPoint(m, 1) // 2:2 B 發,偶數 → 3 號位(2)B 連勝 2
assert.deepEqual(m.scoreList, [
[0, 0, 0, 0],
[1, 1, 1, 0],
[2, 0, 0, 1],
[3, 3, 1, 1],
[4, 2, 2, 1],
])
assert.ok(!/starter|winCount/.test(renderText(m)), '不顯示在 TG 記分板')
undo(m)
assert.equal(m.scoreList.length, 4)
settleMatch(m)
undo(m)
assert.equal(m.scoreList.length, 4, '結算/復原不影響 scoreList')
const singles = createMatch(['A'], ['B'])
setFirstServer(singles, 1)
addPoint(singles, 0)
assert.deepEqual(singles.scoreList, [[0, 2, 0, 0]], '單打同樣以位置編號記')
})