記分板新增隨機配對、計分人權限與換人計分,調整按鈕排法
根本原因: 每打完一輪都要人工決定誰上場、誰搭誰、誰負責記分,記分板也沒有「兩隊對換」和退回選先發球的操作,現場排場地或換人都得重開記分板。 影響: /記分 多一顆 🎲 隨機配對:勾選出席者(預設全選)後,依 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>
This commit is contained in:
+29
-1
@@ -1,7 +1,7 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { addPoint, createMatch, setFirstServer, settleMatch } from '../src/match.js'
|
||||
import { buildHistoryPayload, saveHistory } from '../src/history.js'
|
||||
import { buildHistoryPayload, dayStartUnix, loadTodayGames, parseGameRow, saveHistory } from '../src/history.js'
|
||||
|
||||
test('buildHistoryPayload:欄位對齊 web 版 history(雙打)', () => {
|
||||
const m = createMatch(['Gary', '蓉蓉'], ['柏威', '小念'])
|
||||
@@ -53,3 +53,31 @@ test('saveHistory:先 ensure table 再 INSERT,回傳 insertId', async () =>
|
||||
assert.equal(sqls[1].params[6], '[["A","B"],["C","D"]]')
|
||||
assert.equal(sqls[1].params[7], '[[0,0,0,0]]')
|
||||
})
|
||||
|
||||
test('loadTodayGames:只抓當天(台北時間)的 history,依時間排序並解析 team;壞掉的列略過', async () => {
|
||||
assert.equal(dayStartUnix('20260824'), Math.floor(Date.parse('2026-08-24T00:00:00+08:00') / 1000))
|
||||
assert.deepEqual(parseGameRow({ id: 1, time: '10', team: '[["A","B"],["C","D"]]' }), {
|
||||
id: 1,
|
||||
time: 10,
|
||||
teams: [['A', 'B'], ['C', 'D']],
|
||||
})
|
||||
assert.deepEqual(parseGameRow({ id: 2, time: 10, team: '[["A"],["B"]]' }).teams, [['A'], ['B']], '單打')
|
||||
assert.equal(parseGameRow({ id: 3, time: 10, team: 'oops' }), null)
|
||||
assert.equal(parseGameRow({ id: 4, time: 10, team: '[["A","B"]]' }), null)
|
||||
|
||||
const sqls = []
|
||||
const query = async (sql, params) => {
|
||||
sqls.push({ sql: sql.replace(/\s+/g, ' ').trim(), params })
|
||||
return [
|
||||
{ id: 7, time: 100, team: '[["A","B"],["C","D"]]' },
|
||||
{ id: 8, time: 200, team: 'bad' },
|
||||
{ id: 9, time: 300, team: '[["E"],["F"]]' },
|
||||
]
|
||||
}
|
||||
const games = await loadTodayGames({ query, table: 'history_test', date: '20260824' })
|
||||
const start = dayStartUnix('20260824')
|
||||
assert.match(sqls[0].sql, /SELECT id, time, team FROM `history_test` WHERE time >= \? AND time < \? ORDER BY time ASC, id ASC/)
|
||||
assert.deepEqual(sqls[0].params, [start, start + 86400])
|
||||
assert.deepEqual(games.map((g) => g.id), [7, 9])
|
||||
assert.deepEqual(games[1].teams, [['E'], ['F']])
|
||||
})
|
||||
|
||||
+67
-2
@@ -9,6 +9,7 @@ import {
|
||||
parseTeams,
|
||||
setFirstServer,
|
||||
swapCourt,
|
||||
swapTeams,
|
||||
undo,
|
||||
} from '../src/match.js'
|
||||
import { renderKeyboard, renderText } from '../src/render.js'
|
||||
@@ -79,6 +80,19 @@ test('undo 可復原分數、發球權與站位', () => {
|
||||
assert.equal(getServing(m).server, 'A1')
|
||||
assert.equal(undo(m), true)
|
||||
assert.deepEqual(m.score, [0, 0])
|
||||
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)),
|
||||
[
|
||||
['🅰️ 先發球', '🅱️ 先發球'],
|
||||
['🔀 🅰️ 換位', '🔀 🅱️ 換位'],
|
||||
['🔃 🅰️🅱️ 對換', '🙋 換人計分'],
|
||||
['🏁 結束'],
|
||||
],
|
||||
'回到開局鍵盤',
|
||||
)
|
||||
assert.equal(undo(m), false)
|
||||
})
|
||||
|
||||
@@ -112,13 +126,27 @@ test('renderText 會標記發球者、跳脫 HTML,並依畫面位置排列(
|
||||
assert.match(text, /🅰️ A2 \/ 🏸<A1>/)
|
||||
})
|
||||
|
||||
test('結算:時間到以目前比分定勝負,可 undo;鍵盤為三排', () => {
|
||||
test('結算:時間到以目前比分定勝負,可 undo;計分鍵盤左欄上下為 🅰️ +1 / 🅱️ +1', () => {
|
||||
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'], ['↩️ 上一步', '🔄 重新開始'], ['⏱ 結算', '🏁 結束']],
|
||||
[
|
||||
['🅰️ +1', '↩️ 上一步'],
|
||||
['🅱️ +1', '⏱ 結算'],
|
||||
['🏁 結束'],
|
||||
],
|
||||
'沒有「重新開始」,上一步可退回選先發球',
|
||||
)
|
||||
assert.deepEqual(
|
||||
renderKeyboard({ ...m, confirmEnd: true }).inline_keyboard.map((r) => r.map((b) => b.text)),
|
||||
[
|
||||
['🅰️ +1', '↩️ 上一步'],
|
||||
['🅱️ +1', '⏱ 結算'],
|
||||
['✅ 確定結束', '↩️ 取消'],
|
||||
],
|
||||
'確認結束時只換掉結束那排',
|
||||
)
|
||||
addPoint(m, 0)
|
||||
addPoint(m, 0)
|
||||
@@ -139,6 +167,43 @@ test('結算:時間到以目前比分定勝負,可 undo;鍵盤為三排',
|
||||
assert.match(renderText(m), /2 : 2 平手/)
|
||||
})
|
||||
|
||||
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️⃣ 號場地/)
|
||||
})
|
||||
|
||||
test('scoreList 沿用 web 版格式 [round, starter, winCount, winner],undo/reset 一併處理', () => {
|
||||
const m = createMatch(['A1', 'A2'], ['B1', 'B2'])
|
||||
setFirstServer(m, 0)
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import {
|
||||
assignScorers,
|
||||
buildStats,
|
||||
clampSkill,
|
||||
enumerateMatchings,
|
||||
pairKey,
|
||||
pairRound,
|
||||
pairSkillSum,
|
||||
rankByRest,
|
||||
roundsRested,
|
||||
} from '../src/pairing.js'
|
||||
|
||||
// 固定亂數(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 game = (a, b) => ({ teams: [a, b] })
|
||||
const NAMES = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L']
|
||||
const activeOf = (result) => result.games.flatMap((g) => [...g.teamA, ...g.teamB])
|
||||
const pairsOf = (result) => result.games.flatMap((g) => [pairKey(...g.teamA), pairKey(...g.teamB)])
|
||||
|
||||
test('buildStats:從今日戰績統計場數、搭檔、對手與最後一場', () => {
|
||||
const stats = buildStats([game(['A', 'B'], ['C', 'D']), game(['A', 'E'], ['F', 'G']), game(['H'], ['A'])])
|
||||
assert.equal(stats.total, 3)
|
||||
assert.equal(stats.playCount.get('A'), 3)
|
||||
assert.equal(stats.playCount.get('B'), 1)
|
||||
assert.equal(stats.pairUse.get(pairKey('A', 'B')), 1)
|
||||
assert.equal(stats.pairUse.get(pairKey('B', 'A')), 1, 'pairKey 不分順序')
|
||||
assert.equal(stats.pairUse.get(pairKey('H', 'A')), undefined, '單打不算搭檔')
|
||||
assert.equal(stats.opponentUse.get(pairKey('A', 'C')), 1)
|
||||
assert.equal(stats.opponentUse.get(pairKey('A', 'H')), 1, '單打算對手')
|
||||
assert.equal(stats.lastGame.get('A'), 2)
|
||||
assert.equal(stats.lastGame.get('B'), 0)
|
||||
assert.equal(stats.lastGame.get('Z'), undefined)
|
||||
assert.equal(clampSkill('12'), 10)
|
||||
assert.equal(clampSkill(null), 1)
|
||||
assert.equal(pairSkillSum(['A', 'B'], { A: 3 }), 4, '查不到的人當 1')
|
||||
})
|
||||
|
||||
test('休息輪數:沒打過 = Infinity、剛打完這輪 = 0,一輪 = 場地數', () => {
|
||||
// 兩個場地:第 1 輪 A~H,第 2 輪 I~L + A~D
|
||||
const stats = buildStats([
|
||||
game(['A', 'B'], ['C', 'D']),
|
||||
game(['E', 'F'], ['G', 'H']),
|
||||
game(['I', 'J'], ['K', 'L']),
|
||||
game(['A', 'C'], ['B', 'D']),
|
||||
])
|
||||
assert.equal(roundsRested('Z', stats, 2), Infinity)
|
||||
assert.equal(roundsRested('A', stats, 2), 0)
|
||||
assert.equal(roundsRested('I', stats, 2), 0)
|
||||
assert.equal(roundsRested('E', stats, 2), 1, '第 1 輪打完、第 2 輪休息')
|
||||
assert.equal(roundsRested('E', stats, 1), 2, '單場地時一輪 = 1 場')
|
||||
})
|
||||
|
||||
test('rankByRest:休息最久優先 → 場數少優先 → 隨機', () => {
|
||||
const stats = buildStats([
|
||||
game(['A', 'B'], ['C', 'D']),
|
||||
game(['E', 'F'], ['G', 'H']),
|
||||
game(['I', 'J'], ['K', 'L']),
|
||||
game(['A', 'B'], ['C', 'D']),
|
||||
])
|
||||
const ranked = rankByRest([...NAMES, 'M'], stats, 2, seeded(3))
|
||||
assert.equal(ranked[0], 'M', '沒打過的最前面')
|
||||
assert.deepEqual(ranked.slice(1, 5).sort(), ['E', 'F', 'G', 'H'], '休息一輪的其次')
|
||||
assert.deepEqual(ranked.slice(5, 9).sort(), ['I', 'J', 'K', 'L'], '剛打完但只打 1 場')
|
||||
assert.deepEqual(ranked.slice(9).sort(), ['A', 'B', 'C', 'D'], '剛打完又打了 2 場的最後')
|
||||
})
|
||||
|
||||
test('pairRound:剛打完的先休息、沒打過的一定上場;8 人以上排 2、3 號場地', () => {
|
||||
const stats = buildStats([game(['A', 'B'], ['C', 'D']), game(['E', 'F'], ['G', 'H'])])
|
||||
for (let seed = 1; seed <= 20; seed += 1) {
|
||||
const result = pairRound(NAMES, {}, stats, { random: seeded(seed) })
|
||||
assert.equal(result.courtCount, 2)
|
||||
assert.deepEqual(result.games.map((g) => g.court), [2, 3])
|
||||
const active = activeOf(result)
|
||||
assert.equal(active.length, 8)
|
||||
assert.equal(new Set(active).size, 8, '沒有人同時在兩個場地')
|
||||
for (const name of ['I', 'J', 'K', 'L']) assert.ok(active.includes(name), `${name} 沒打過必須上場`)
|
||||
assert.equal(result.resting.length, 4)
|
||||
assert.ok(result.resting.every((n) => 'ABCDEFGH'.includes(n)), '休息的都是剛打完的人')
|
||||
}
|
||||
})
|
||||
|
||||
test('pairRound:搭檔今天沒搭過的優先、對手也錯開;實力互補與兩隊平衡', () => {
|
||||
// 8 人全部剛休息完,之前搭過 AB CD EF GH,且 AB 對過 CD、EF 對過 GH
|
||||
const stats = buildStats([game(['A', 'B'], ['C', 'D']), game(['E', 'F'], ['G', 'H']), game(['I', 'J'], ['K', 'L']), game(['I', 'K'], ['J', 'L'])])
|
||||
const eight = NAMES.slice(0, 8)
|
||||
const used = new Set([pairKey('A', 'B'), pairKey('C', 'D'), pairKey('E', 'F'), pairKey('G', 'H')])
|
||||
for (let seed = 1; seed <= 20; seed += 1) {
|
||||
const result = pairRound(eight, {}, stats, { random: seeded(seed) })
|
||||
assert.deepEqual(activeOf(result).sort(), eight, '8 人全上')
|
||||
for (const key of pairsOf(result)) assert.ok(!used.has(key), `搭檔 ${key} 今天已搭過,不該再配`)
|
||||
}
|
||||
|
||||
// 實力:4 強 4 弱 → 每組一強一弱(合計 10),兩隊實力差 0
|
||||
const skills = { A: 9, B: 9, C: 9, D: 9, E: 1, F: 1, G: 1, H: 1 }
|
||||
for (let seed = 1; seed <= 20; seed += 1) {
|
||||
const result = pairRound(eight, skills, buildStats([]), { random: seeded(seed) })
|
||||
for (const g of result.games) {
|
||||
assert.equal(pairSkillSum(g.teamA, skills), 10)
|
||||
assert.equal(pairSkillSum(g.teamB, skills), 10)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('pairRound:4~7 人只排 2 號場地;不足 4 人回錯誤;名字去重去空白', () => {
|
||||
const six = pairRound(['A', 'B', 'C', 'D', 'E', 'F'], {}, buildStats([]), { random: seeded(1) })
|
||||
assert.equal(six.courtCount, 1)
|
||||
assert.equal(six.games.length, 1)
|
||||
assert.equal(six.games[0].court, 2)
|
||||
assert.equal(six.resting.length, 2)
|
||||
|
||||
const four = pairRound([' A ', 'B', 'B', 'C', 'D', ''], {}, buildStats([]), { random: seeded(1) })
|
||||
assert.equal(four.games.length, 1)
|
||||
assert.deepEqual(activeOf(four).sort(), ['A', 'B', 'C', 'D'])
|
||||
assert.deepEqual(four.resting, [])
|
||||
|
||||
assert.ok(pairRound(['A', 'B', 'C'], {}, buildStats([]), { random: seeded(1) }).error)
|
||||
assert.equal(enumerateMatchings(['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H']).length, 105)
|
||||
})
|
||||
|
||||
test('pairRound:實力不平均時搭檔強弱互補、兩隊總和最接近', () => {
|
||||
const skills = { A: 10, B: 8, C: 2, D: 1 }
|
||||
for (let seed = 1; seed <= 10; seed += 1) {
|
||||
const [g] = pairRound(['A', 'B', 'C', 'D'], skills, buildStats([]), { random: seeded(seed) }).games
|
||||
const sums = [pairSkillSum(g.teamA, skills), pairSkillSum(g.teamB, skills)].sort()
|
||||
assert.deepEqual(sums, [10, 11], 'A+D=11 對 B+C=10 最平衡')
|
||||
}
|
||||
})
|
||||
|
||||
test('assignScorers:從休息的人抽計分人、排除名單不抽、不夠補 null', () => {
|
||||
for (let seed = 1; seed <= 10; seed += 1) {
|
||||
const s = assignScorers(['A', 'B', 'C'], 2, { exclude: new Set(['B']), random: seeded(seed) })
|
||||
assert.equal(s.length, 2)
|
||||
assert.ok(s.every((n) => n === 'A' || n === 'C'), '只從沒被排除的休息者抽')
|
||||
assert.notEqual(s[0], s[1], '一人只負責一個場地')
|
||||
}
|
||||
assert.deepEqual(assignScorers(['A'], 2, { exclude: new Set(['A']), random: seeded(1) }), [null, null])
|
||||
assert.deepEqual(assignScorers(['A'], 2, { random: seeded(1) }), ['A', null])
|
||||
assert.deepEqual(assignScorers([], 1, { random: seeded(1) }), [null])
|
||||
})
|
||||
@@ -221,3 +221,269 @@ test('選人中按取消 → 訊息鎖定並移除狀態', async () => {
|
||||
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)
|
||||
})
|
||||
|
||||
@@ -16,6 +16,15 @@ function fakeDb() {
|
||||
if (s.startsWith('SELECT user_id, nickname FROM attendance')) {
|
||||
return attendance.filter((r) => r.poll_date === params[0]).map((r) => ({ user_id: r.user_id, nickname: r.nickname }))
|
||||
}
|
||||
if (s.startsWith('SELECT a.nickname, a.user_id, COALESCE(m.skill')) {
|
||||
return attendance
|
||||
.filter((r) => r.poll_date === params[0])
|
||||
.map((r) => {
|
||||
const byId = members.get(r.user_id)
|
||||
const byName = [...members.values()].find((m) => m.nickname === r.nickname)
|
||||
return { nickname: r.nickname, user_id: r.user_id, skill: byId?.skill ?? byName?.skill ?? 1 }
|
||||
})
|
||||
}
|
||||
if (s.startsWith('SELECT m.nickname, m.user_id AS member')) {
|
||||
const [uid, date] = params
|
||||
const m = members.get(uid)
|
||||
@@ -175,3 +184,23 @@ test('commandRegex:支援 @BotName 後綴與中英文別名', () => {
|
||||
assert.equal('/score 小明 vs 阿強'.match(s)[1], '小明 vs 阿強')
|
||||
assert.equal('/記分'.match(s)[1], undefined)
|
||||
})
|
||||
|
||||
test('attendees:出席名單帶 tg_members.skill(user_id 對不到就用暱稱對,都沒有當 1)', async () => {
|
||||
const db = fakeDb()
|
||||
const bot = fakeBot()
|
||||
const signup = createSignup(bot, { log: silentLog, query: db.query })
|
||||
db.members.set(1, { nickname: '建喵', skill: 9 })
|
||||
db.members.set(3, { nickname: '同名', skill: 4 })
|
||||
db.attendance.push(
|
||||
{ poll_date: DATE, user_id: 1, nickname: '建喵' },
|
||||
{ poll_date: DATE, user_id: 2, nickname: '路人' },
|
||||
{ poll_date: DATE, user_id: 99, nickname: '同名' },
|
||||
{ poll_date: DATE, user_id: 100, nickname: ' ' },
|
||||
)
|
||||
assert.deepEqual(await signup.attendees(DATE), [
|
||||
{ name: '建喵', skill: 9, userId: 1 },
|
||||
{ name: '路人', skill: 1, userId: 2 },
|
||||
{ name: '同名', skill: 4, userId: 99 },
|
||||
])
|
||||
assert.deepEqual(await signup.attendees('20000101'), [])
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user