2026-08-18 11:10:27 +08:00
|
|
|
|
import { test } from 'node:test'
|
|
|
|
|
|
import assert from 'node:assert/strict'
|
|
|
|
|
|
import {
|
|
|
|
|
|
addPoint,
|
|
|
|
|
|
createMatch,
|
|
|
|
|
|
settleMatch,
|
|
|
|
|
|
getServing,
|
|
|
|
|
|
getWinner,
|
|
|
|
|
|
parseTeams,
|
|
|
|
|
|
setFirstServer,
|
|
|
|
|
|
swapCourt,
|
2026-08-24 22:24:16 +08:00
|
|
|
|
swapTeams,
|
2026-08-18 11:10:27 +08:00
|
|
|
|
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])
|
2026-08-24 22:24:16 +08:00
|
|
|
|
assert.equal(m.server, 0)
|
|
|
|
|
|
assert.equal(undo(m), true, '0:0 再上一步 → 退回選先發球')
|
|
|
|
|
|
assert.equal(m.server, null)
|
|
|
|
|
|
assert.deepEqual(
|
|
|
|
|
|
renderKeyboard(m).inline_keyboard.map((r) => r.map((b) => b.text)),
|
|
|
|
|
|
[
|
|
|
|
|
|
['🅰️ 先發球', '🅱️ 先發球'],
|
|
|
|
|
|
['🔀 🅰️ 換位', '🔀 🅱️ 換位'],
|
|
|
|
|
|
['🔃 🅰️🅱️ 對換', '🙋 換人計分'],
|
|
|
|
|
|
['🏁 結束'],
|
|
|
|
|
|
],
|
|
|
|
|
|
'回到開局鍵盤',
|
|
|
|
|
|
)
|
2026-08-18 11:10:27 +08:00
|
|
|
|
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, /🅰️ 🏸<A1> \/ A2/)
|
|
|
|
|
|
assert.match(text, /🅱️ B2 \/ B1/, '下排 B2(4) 在左、B1(3) 在右')
|
|
|
|
|
|
assert.match(text, /發球:🅰️ <b><A1><\/b>(右區) 接發:B1/)
|
|
|
|
|
|
addPoint(m, 0) // A 得分換位:A1 到左區 → 畫面上排右邊
|
|
|
|
|
|
text = renderText(m)
|
|
|
|
|
|
assert.match(text, /🅰️ A2 \/ 🏸<A1>/)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-08-24 22:24:16 +08:00
|
|
|
|
test('結算:時間到以目前比分定勝負,可 undo;計分鍵盤左欄上下為 🅰️ +1 / 🅱️ +1', () => {
|
2026-08-18 11:10:27 +08:00
|
|
|
|
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)),
|
2026-08-24 22:24:16 +08:00
|
|
|
|
[
|
|
|
|
|
|
['🅰️ +1', '↩️ 上一步'],
|
|
|
|
|
|
['🅱️ +1', '⏱ 結算'],
|
|
|
|
|
|
['🏁 結束'],
|
|
|
|
|
|
],
|
|
|
|
|
|
'沒有「重新開始」,上一步可退回選先發球',
|
|
|
|
|
|
)
|
|
|
|
|
|
assert.deepEqual(
|
|
|
|
|
|
renderKeyboard({ ...m, confirmEnd: true }).inline_keyboard.map((r) => r.map((b) => b.text)),
|
|
|
|
|
|
[
|
|
|
|
|
|
['🅰️ +1', '↩️ 上一步'],
|
|
|
|
|
|
['🅱️ +1', '⏱ 結算'],
|
|
|
|
|
|
['✅ 確定結束', '↩️ 取消'],
|
|
|
|
|
|
],
|
|
|
|
|
|
'確認結束時只換掉結束那排',
|
2026-08-18 11:10:27 +08:00
|
|
|
|
)
|
|
|
|
|
|
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 平手/)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-08-24 22:24:16 +08:00
|
|
|
|
test('swapTeams:0:0 時 🅰️🅱️ 上下對換(含發球權),開打後不能換;開局鍵盤有對換鈕', () => {
|
|
|
|
|
|
const m = createMatch(['A1', 'A2'], ['B1', 'B2'])
|
|
|
|
|
|
assert.deepEqual(
|
|
|
|
|
|
renderKeyboard(m).inline_keyboard.map((r) => r.map((b) => b.text)),
|
|
|
|
|
|
[
|
|
|
|
|
|
['🅰️ 先發球', '🅱️ 先發球'],
|
|
|
|
|
|
['🔀 🅰️ 換位', '🔀 🅱️ 換位'],
|
|
|
|
|
|
['🔃 🅰️🅱️ 對換', '🙋 換人計分'],
|
|
|
|
|
|
['🏁 結束'],
|
|
|
|
|
|
],
|
|
|
|
|
|
)
|
|
|
|
|
|
assert.deepEqual(
|
|
|
|
|
|
renderKeyboard(createMatch(['A'], ['B'])).inline_keyboard.map((r) => r.map((b) => b.text)),
|
|
|
|
|
|
[
|
|
|
|
|
|
['🅰️ 先發球', '🅱️ 先發球'],
|
|
|
|
|
|
['🔃 🅰️🅱️ 對換', '🙋 換人計分'],
|
|
|
|
|
|
['🏁 結束'],
|
|
|
|
|
|
],
|
|
|
|
|
|
'單打沒有換位,但有對換',
|
|
|
|
|
|
)
|
|
|
|
|
|
swapCourt(m, 1)
|
|
|
|
|
|
assert.equal(swapTeams(m), true)
|
|
|
|
|
|
assert.deepEqual(m.teams.map((t) => t.players), [['B1', 'B2'], ['A1', 'A2']])
|
|
|
|
|
|
assert.equal(m.teams[0].right, 1, '站位跟著隊伍走')
|
|
|
|
|
|
assert.match(renderText(m), /🅰️ B2 \/ B1/, 'B2 在右發球區 → 上排鏡像顯示在左')
|
|
|
|
|
|
assert.match(renderText(m), /🅱️ A2 \/ A1/)
|
|
|
|
|
|
setFirstServer(m, 0) // 現在的 🅰️(B 隊)先發
|
|
|
|
|
|
assert.equal(swapTeams(m), true)
|
|
|
|
|
|
assert.equal(m.server, 1, '對換後發球權跟著隊伍走')
|
|
|
|
|
|
assert.deepEqual(m.teams.map((t) => t.players), [['A1', 'A2'], ['B1', 'B2']])
|
|
|
|
|
|
addPoint(m, 1)
|
|
|
|
|
|
assert.equal(swapTeams(m), false, '開打後不能對換')
|
|
|
|
|
|
const c = createMatch(['A'], ['B'])
|
|
|
|
|
|
c.court = 2
|
|
|
|
|
|
assert.match(renderText(c), /羽球記分板 2️⃣ 號場地/)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-08-18 11:10:27 +08:00
|
|
|
|
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]], '單打同樣以位置編號記')
|
|
|
|
|
|
})
|