253 lines
9.1 KiB
Python
253 lines
9.1 KiB
Python
"""Behavioral coverage for multiplayer lottery and indexed game records."""
|
||
|
||
import json
|
||
import tempfile
|
||
import threading
|
||
import unittest
|
||
from pathlib import Path
|
||
from unittest import mock
|
||
|
||
from test_jwebot_edge import MODULE, config
|
||
|
||
|
||
class Release063GameTests(unittest.TestCase):
|
||
def setUp(self):
|
||
self.temp = tempfile.TemporaryDirectory()
|
||
self.engine = MODULE.EdgeApplication(config(Path(self.temp.name)))
|
||
self.counter = 0
|
||
|
||
def tearDown(self):
|
||
self.engine.ledger.connection.close()
|
||
self.temp.cleanup()
|
||
|
||
def command(
|
||
self,
|
||
question,
|
||
*,
|
||
sender="Alice",
|
||
group="group-a",
|
||
event_id=None,
|
||
):
|
||
self.counter += 1
|
||
row = {
|
||
"question": question,
|
||
"sender": sender,
|
||
"group_fingerprint": group,
|
||
"event_id": event_id or f"test-{self.counter}",
|
||
}
|
||
return self.engine.local.handle(row, {})
|
||
|
||
def state(self, sender="Alice", group="group-a"):
|
||
key = self.engine.ledger.resolve_person(group, sender)["person_key"]
|
||
row = self.engine.ledger.connection.execute(
|
||
"""
|
||
SELECT state_json FROM player_accounts
|
||
WHERE group_fingerprint = ? AND person_key = ?
|
||
""",
|
||
(group, key),
|
||
).fetchone()
|
||
return json.loads(row[0])
|
||
|
||
def test_lottery_number_forms_sort_and_validate(self):
|
||
parse = self.engine.local.players.parse_lottery_entry
|
||
|
||
self.assertEqual(parse("347"), ((3, 4, 7), 10))
|
||
self.assertEqual(parse("743"), ((3, 4, 7), 10))
|
||
self.assertEqual(parse("报名 3 4 7 100"), ((3, 4, 7), 100))
|
||
self.assertEqual(parse("报名347100"), ((3, 4, 7), 100))
|
||
self.assertEqual(parse("743金额500"), ((3, 4, 7), 500))
|
||
with self.assertRaisesRegex(ValueError, "互不重复"):
|
||
parse("337")
|
||
with self.assertRaisesRegex(ValueError, "1~500"):
|
||
parse("347501")
|
||
|
||
def test_multiplayer_lottery_status_draw_and_payouts(self):
|
||
self.assertIn("第 1 把已开始", self.command("/彩票开始"))
|
||
self.assertIn("alice #1", self.command("/彩票347100"))
|
||
self.assertIn(
|
||
"bob #1",
|
||
self.command("/彩票 报名 9 3 7 200", sender="Bob"),
|
||
)
|
||
self.assertIn(
|
||
"carol #1",
|
||
self.command("/彩票 1 2 3 100", sender="Carol"),
|
||
)
|
||
self.assertIn(
|
||
"Dave #1",
|
||
self.command("/彩票045100", sender="Dave"),
|
||
)
|
||
|
||
status = self.command("/彩票当前状态", sender="Bob")
|
||
self.assertIn("已报名 4 人", status)
|
||
self.assertIn("alice #1:3 4 7,下注 100", status)
|
||
self.assertIn("bob #1:3 7 9,下注 200", status)
|
||
self.assertIn("只有本轮主持人 alice", self.command(
|
||
"/彩票开奖", sender="Bob"))
|
||
|
||
with mock.patch.object(
|
||
self.engine.local.players.rng,
|
||
"sample",
|
||
return_value=[7, 3, 9],
|
||
):
|
||
result = self.command("/彩票开奖", event_id="draw-round-1")
|
||
|
||
self.assertIn("开奖号:3 7 9", result)
|
||
self.assertIn("alice #1:命中 2 个", result)
|
||
self.assertIn("返还 120", result)
|
||
self.assertIn("bob #1:命中 3 个", result)
|
||
self.assertIn("返还 600", result)
|
||
self.assertIn("carol #1:命中 1 个", result)
|
||
self.assertIn("返还 50", result)
|
||
self.assertIn("Dave #1:命中 0 个", result)
|
||
self.assertEqual(self.state()["balance"], 1020)
|
||
self.assertEqual(self.state("Bob")["balance"], 1400)
|
||
self.assertEqual(self.state("Carol")["balance"], 950)
|
||
self.assertEqual(self.state("Dave")["balance"], 900)
|
||
self.assertIn("命中2个", self.command("/游戏记录"))
|
||
self.assertIn("命中3个", self.command(
|
||
"/个人记录", sender="Bob"))
|
||
self.assertIn("没有进行中", self.command("/彩票当前状态"))
|
||
|
||
def test_registration_update_keeps_index_and_cancel_refunds(self):
|
||
self.command("/彩票 开始")
|
||
first = self.command("/彩票 347 100")
|
||
updated = self.command("/彩票 743 200", sender="a1")
|
||
|
||
self.assertIn("alice #1", first)
|
||
self.assertIn("alice #1", updated)
|
||
self.assertIn("报名已更新", updated)
|
||
self.assertEqual(self.state()["balance"], 800)
|
||
status = self.command("/彩票 状态")
|
||
self.assertIn("已报名 1 人", status)
|
||
self.assertIn("3 4 7,下注 200", status)
|
||
self.assertIn("只有本轮主持人 alice", self.command(
|
||
"/彩票 取消", sender="Bob"))
|
||
self.assertIn("已全额退回", self.command("/彩票取消"))
|
||
self.assertEqual(self.state()["balance"], 1000)
|
||
self.assertIn("净变化 +0", self.command("/游戏记录"))
|
||
|
||
self.command("/彩票开始")
|
||
second = self.command("/彩票347")
|
||
self.assertIn("alice #2", second)
|
||
|
||
def test_concurrent_registration_replay_debits_once(self):
|
||
self.command("/彩票开始")
|
||
replies = []
|
||
errors = []
|
||
|
||
def register():
|
||
try:
|
||
replies.append(
|
||
self.command("/彩票347500", event_id="same-entry")
|
||
)
|
||
except BaseException as error:
|
||
errors.append(error)
|
||
|
||
threads = [threading.Thread(target=register) for _ in range(8)]
|
||
for thread in threads:
|
||
thread.start()
|
||
for thread in threads:
|
||
thread.join()
|
||
|
||
self.assertFalse(errors)
|
||
self.assertEqual(len(set(replies)), 1)
|
||
self.assertEqual(self.state()["balance"], 500)
|
||
self.assertIn("已报名 1 人", self.command("/彩票状态"))
|
||
|
||
def test_blackjack_uses_1_to_500_and_indexed_history(self):
|
||
def tied_deck(cards):
|
||
first = [9, 6, 8, 7]
|
||
cards[:] = first + [card for card in cards if card not in first]
|
||
|
||
with mock.patch.object(
|
||
self.engine.local.players.rng,
|
||
"shuffle",
|
||
side_effect=tied_deck,
|
||
):
|
||
started = self.command("/21点开始500")
|
||
self.assertTrue(started.startswith("alice #1:"))
|
||
self.assertEqual(self.state()["balance"], 500)
|
||
settled = self.command("/21点停牌")
|
||
self.assertTrue(settled.startswith("alice #1:"))
|
||
self.assertIn("平局", settled)
|
||
self.assertEqual(self.state()["balance"], 1000)
|
||
history = self.command("/游戏记录")
|
||
self.assertIn("alice #1:21点 平局", history)
|
||
self.assertIn("下注 500,返还 500", history)
|
||
self.assertIn("1~500", self.command("/21点开始501"))
|
||
|
||
def test_prize_draw_has_indexed_history(self):
|
||
with mock.patch.object(
|
||
self.engine.local.players.rng,
|
||
"randrange",
|
||
return_value=99,
|
||
):
|
||
first = self.command("/抽奖10")
|
||
second = self.command("/抽奖10")
|
||
|
||
self.assertTrue(first.startswith("alice #1:"))
|
||
self.assertTrue(second.startswith("alice #2:"))
|
||
history = self.command("/gamehistory")
|
||
self.assertIn("alice #2:抽奖 未中奖", history)
|
||
self.assertIn("alice #1:抽奖 未中奖", history)
|
||
|
||
def test_open_round_and_history_survive_restart(self):
|
||
root = Path(self.temp.name)
|
||
self.command("/彩票开始")
|
||
self.command("/彩票347100")
|
||
self.engine.ledger.connection.close()
|
||
self.engine = MODULE.EdgeApplication(config(root))
|
||
|
||
self.assertIn("alice #1:3 4 7", self.command("/彩票状态"))
|
||
with mock.patch.object(
|
||
self.engine.local.players.rng,
|
||
"sample",
|
||
return_value=[3, 4, 7],
|
||
):
|
||
self.command("/彩票开奖")
|
||
self.engine.ledger.connection.close()
|
||
self.engine = MODULE.EdgeApplication(config(root))
|
||
|
||
history = self.command("/游戏记录")
|
||
self.assertIn("alice #1:彩票 命中3个", history)
|
||
self.assertEqual(self.state()["balance"], 1200)
|
||
|
||
def test_registration_rolls_back_when_response_building_fails(self):
|
||
ledger = self.engine.ledger
|
||
ledger.start_group_lottery(
|
||
"group-a",
|
||
"Alice",
|
||
"start",
|
||
"lottery:start",
|
||
lambda _: "started",
|
||
)
|
||
|
||
def fail(_):
|
||
raise RuntimeError("render failed")
|
||
|
||
with self.assertRaisesRegex(RuntimeError, "render failed"):
|
||
ledger.register_group_lottery(
|
||
"group-a",
|
||
"Alice",
|
||
"register",
|
||
"lottery:347100",
|
||
(3, 4, 7),
|
||
100,
|
||
fail,
|
||
)
|
||
|
||
account = ledger.connection.execute(
|
||
"SELECT COUNT(*) FROM player_accounts"
|
||
).fetchone()[0]
|
||
entries = ledger.connection.execute(
|
||
"SELECT COUNT(*) FROM group_lottery_entries"
|
||
).fetchone()[0]
|
||
actions = ledger.connection.execute(
|
||
"SELECT COUNT(*) FROM player_actions WHERE event_id = 'register'"
|
||
).fetchone()[0]
|
||
self.assertEqual((account, entries, actions), (0, 0, 0))
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|