Files
JWeBot/tests/test_release_064.py
T
2026-10-15 15:17:32 +08:00

509 lines
18 KiB
Python
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.
"""Behavioral coverage for the CS Major autochess extension."""
from __future__ import annotations
import copy
import json
import re
import tempfile
import threading
import unittest
from pathlib import Path
from unittest import mock
from test_jwebot_edge import MODULE, config
from jwebot_official_extensions import cs_autochess as AUTOCHESS
class Release064AutochessTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.root = Path(self.temp.name)
self.engine = MODULE.EdgeApplication(config(self.root))
self.counter = 0
def tearDown(self):
self.engine.close()
self.temp.cleanup()
def command(
self,
question: str,
*,
sender: str = "Alice",
event_id: str | None = None,
) -> str:
self.counter += 1
row = {
"question": question,
"sender": sender,
"group_fingerprint": "group-a",
"event_id": event_id or f"autochess-{self.counter}",
}
return self.engine.local.handle(row, {})
def state(self) -> dict:
row = self.engine.ledger.connection.execute(
"""
SELECT state_json FROM autochess_runs
WHERE group_fingerprint = 'group-a'
ORDER BY season_number DESC
LIMIT 1
"""
).fetchone()
self.assertIsNotNone(row)
return json.loads(row[0])
def save_state(self, state: dict) -> None:
self.engine.ledger.connection.execute(
"""
UPDATE autochess_runs SET state_json = ?, state = ?
WHERE group_fingerprint = 'group-a' AND run_id = ?
""",
(
json.dumps(state, ensure_ascii=False, separators=(",", ":")),
state["status"],
state["run_id"],
),
)
def launch(self, names: list[str]) -> dict:
self.command("/自走棋开始", sender=names[0])
for name in names[1:]:
self.command("/自走棋报名", sender=name)
self.command("/自走棋开赛", sender=names[0])
return self.state()
def settle_to_completion(self) -> dict:
state = self.state()
while state["status"] == "active":
self.command("/自走棋强制结算")
state = self.state()
return state
def test_static_pool_parser_and_attribute_ranges(self):
self.assertEqual(len(AUTOCHESS.PLAYER_POOL), 60)
self.assertEqual(
len({card["id"] for card in AUTOCHESS.PLAYER_POOL}),
60,
)
ranges = {
1: (52, 68),
2: (60, 76),
3: (68, 84),
4: (78, 92),
5: (88, 98),
}
for card in AUTOCHESS.PLAYER_POOL:
with self.subTest(card=card["id"]):
lower, upper = ranges[card["cost"]]
for field in ("firepower", "stability", "clutch"):
self.assertGreaterEqual(card[field], lower)
self.assertLessEqual(card[field], upper)
self.assertIn(card["role"], {"步枪手", "狙击手", "指挥"})
self.assertLessEqual(len(card["teams"]), 3)
if card["role"] == "指挥":
self.assertGreater(card["leadership"], 0)
self.assertEqual(
AUTOCHESS.parse_autochess_action("/自走棋开始"),
("create", ""),
)
self.assertEqual(
AUTOCHESS.parse_autochess_action("/选6"),
("buy", "6"),
)
self.assertEqual(
AUTOCHESS.parse_autochess_action("/战术残局"),
("tactic", "残局"),
)
def test_round_plan_matches_player_count_without_duplicate_pairs(self):
for size in range(2, 9):
keys = [f"p{index}" for index in range(1, size + 1)]
plan = AUTOCHESS.build_round_plan(keys)
group_pairs = [
tuple(sorted(pair))
for stage in plan
if stage["kind"] == "group"
for pair in stage["pairs"]
]
with self.subTest(size=size):
if size == 2:
self.assertFalse(group_pairs)
self.assertFalse(
any(stage["kind"] == "semifinal" for stage in plan)
)
elif size == 4:
semifinal = next(
stage
for stage in plan
if stage["kind"] == "semifinal"
)
self.assertEqual(
semifinal["pairs"],
[["p1", "p4"], ["p2", "p3"]],
)
self.assertFalse(group_pairs)
else:
self.assertEqual(
len(group_pairs),
size * (size - 1) // 2,
)
self.assertEqual(len(group_pairs), len(set(group_pairs)))
self.assertEqual(plan[-1]["kind"], "final")
def test_launch_has_fixed_five_coach_shared_shop_and_complete_data(self):
response = self.command("/自走棋开始")
self.assertTrue(response.startswith("alice #1:"))
self.command("/自走棋报名", sender="Bob")
self.command("/自走棋报名", sender="Carol")
self.command("/自走棋报名", sender="Dave")
response = self.command("/自走棋开赛")
state = self.state()
self.assertEqual(len(state["round_plan"]), 5)
self.assertEqual(
[stage["kind"] for stage in state["round_plan"]],
["pve", "pve", "pve", "semifinal", "final"],
)
shops = []
for player in state["players"].values():
self.assertEqual(len(player["board"]), 5)
self.assertEqual(player["coach_level"], 1)
self.assertEqual(player["tactic"], "稳定")
self.assertEqual(len(player["shop"]), 6)
shops.append(player["shop"])
self.assertTrue(all(shop == shops[0] for shop in shops))
for slot in range(1, 7):
line = next(
value
for value in response.splitlines()
if value.startswith(f"{slot}. ")
)
self.assertRegex(
line,
r"\d费 (?:步枪手|狙击手|指挥).*历史 .*火\d+ 稳\d+ 残\d+",
)
self.assertLessEqual(len(response), MODULE.MAX_REPLY_CHARS)
def test_tactics_coach_and_lineup_changes_preserve_five_starters(self):
state = self.launch(["Alice", "Bob"])
alice_key = state["seed_order"][0]
before_board = copy.deepcopy(state["players"][alice_key]["board"])
before_power = AUTOCHESS.lineup_power(
state["players"][alice_key]
)["power"]
tactic = self.command("/战术残局")
self.assertIn("战术设为 残局", tactic)
coach = self.command("/升级教练")
self.assertIn("教练升到 2 级", coach)
state = self.state()
alice = state["players"][alice_key]
self.assertEqual(alice["board"], before_board)
self.assertEqual(len(alice["board"]), 5)
self.assertGreater(AUTOCHESS.lineup_power(alice)["power"], before_power)
starter_ids = {unit["card_id"] for unit in alice["board"]}
slot = next(
index
for index, card_id in enumerate(alice["shop"], start=1)
if card_id not in starter_ids
)
self.command(f"/选{slot}")
self.assertIn("首发必须保持 5 人", self.command("/出售1"))
swapped = self.command("/上阵 6 2")
self.assertIn("首发仍为完整 5 人", swapped)
state = self.state()
self.assertEqual(len(state["players"][alice_key]["board"]), 5)
def test_replay_debits_one_purchase_and_invalid_archive_rolls_back(self):
state = self.launch(["Alice", "Bob"])
alice_key = state["seed_order"][0]
card_id = state["players"][alice_key]["shop"][0]
cost = AUTOCHESS.CARD_BY_ID[card_id]["cost"]
old_gold = state["players"][alice_key]["gold"]
old_pool = state["pool"][card_id]
replies: list[str] = []
errors: list[BaseException] = []
def buy() -> None:
try:
replies.append(
self.command("/选1", event_id="same-autochess-buy")
)
except BaseException as error:
errors.append(error)
threads = [threading.Thread(target=buy) 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)
state = self.state()
self.assertEqual(state["players"][alice_key]["gold"], old_gold - cost)
self.assertEqual(state["pool"][card_id], old_pool - 1)
before_json = self.engine.ledger.connection.execute(
"""
SELECT state_json FROM autochess_runs
WHERE group_fingerprint = 'group-a' AND run_id = ?
""",
(state["run_id"],),
).fetchone()[0]
before_stories = self.engine.ledger.connection.execute(
"SELECT COUNT(*) FROM autochess_story_logs"
).fetchone()[0]
def invalid_archive(current, person, next_season):
del person, next_season
current["players"][alice_key]["gold"] += 999
return {
"state": current,
"response": "invalid",
"stories": [{"bad": True}],
"battles": [],
}
with self.assertRaisesRegex(
MODULE.LocalCommandError,
"剧情归档无效",
):
self.engine.ledger.apply_autochess_action(
"group-a",
"Alice",
"invalid-autochess-archive",
"invalid",
invalid_archive,
)
after_json = self.engine.ledger.connection.execute(
"""
SELECT state_json FROM autochess_runs
WHERE group_fingerprint = 'group-a' AND run_id = ?
""",
(state["run_id"],),
).fetchone()[0]
after_stories = self.engine.ledger.connection.execute(
"SELECT COUNT(*) FROM autochess_story_logs"
).fetchone()[0]
self.assertEqual(after_json, before_json)
self.assertEqual(after_stories, before_stories)
def test_four_team_seed_bracket_is_bo5_with_mr15_scores(self):
state = self.launch(["Alice", "Bob", "Carol", "Dave"])
seed_order = list(state["seed_order"])
completed = self.settle_to_completion()
self.assertEqual(completed["status"], "completed")
self.assertTrue(completed["champion_key"])
rows = self.engine.ledger.connection.execute(
"""
SELECT battle_type, player_a, player_b, result_json
FROM autochess_battles
WHERE battle_type != 'pve'
ORDER BY battle_id
"""
).fetchall()
semifinals = [row for row in rows if row["battle_type"] == "semifinal_bo5"]
finals = [row for row in rows if row["battle_type"] == "final_bo5"]
self.assertEqual(
{
frozenset((row["player_a"], row["player_b"]))
for row in semifinals
},
{
frozenset((seed_order[0], seed_order[3])),
frozenset((seed_order[1], seed_order[2])),
},
)
self.assertEqual(len(finals), 1)
for row in [*semifinals, *finals]:
result = json.loads(row["result_json"])
self.assertEqual(result["series_format"], "BO5")
self.assertRegex(result["score"], r"^3-[0-2]$")
self.assertTrue(3 <= len(result["maps"]) <= 5)
for item in result["maps"]:
self.assertRegex(item["score"], r"^(?:16-(?:6|9|12|14)|19-17|22-20)$")
self.assertLessEqual(abs(item["fluctuation_a_bps"]), 200)
self.assertLessEqual(abs(item["fluctuation_b_bps"]), 200)
def test_five_team_round_robin_bo3_then_top_four(self):
completed = self.launch(["Alice", "Bob", "Carol", "Dave", "Eve"])
self.assertEqual(
sum(
len(stage.get("pairs", []))
for stage in completed["round_plan"]
if stage["kind"] == "group"
),
10,
)
completed = self.settle_to_completion()
self.assertEqual(len(completed["qualifiers"]), 4)
rows = self.engine.ledger.connection.execute(
"""
SELECT battle_type, player_a, player_b, result_json
FROM autochess_battles
WHERE battle_type != 'pve'
ORDER BY battle_id
"""
).fetchall()
groups = [row for row in rows if row["battle_type"] == "group_bo3"]
self.assertEqual(len(groups), 10)
self.assertEqual(
len(
{
frozenset((row["player_a"], row["player_b"]))
for row in groups
}
),
10,
)
for row in groups:
result = json.loads(row["result_json"])
self.assertEqual(result["series_format"], "BO3")
self.assertRegex(result["score"], r"^2-[01]$")
self.assertEqual(
sum(row["battle_type"] == "semifinal_bo5" for row in rows),
2,
)
self.assertEqual(
sum(row["battle_type"] == "final_bo5" for row in rows),
1,
)
def test_three_humans_plus_ai_has_taffy_host_and_turn_handoffs(self):
self.command("/自走棋开始")
self.command("/自走棋报名", sender="Bob")
self.assertIn(
"先有且仅有 3 名真人",
self.command("/自走棋加AI战队"),
)
self.command("/自走棋报名", sender="Carol")
self.assertIn(
"只有主持人",
self.command("/自走棋加AI战队", sender="Bob"),
)
self.assertIn("第 4 种子", self.command("/自走棋加AI战队"))
self.assertIn(
"已经锁定参赛名单",
self.command("/自走棋报名", sender="Dave"),
)
launch = self.command("/自走棋开赛")
state = self.state()
self.assertIn("Taffy:欢迎大家来到第 1 届 IEM 盐田 Major", launch)
self.assertIn("3 人+1 AI 测试赛", launch)
self.assertIn("1v4、2v3", launch)
self.assertIn("小组赛 BO3,淘汰赛 BO5", launch)
self.assertIn("常规时间 15-15 进入加时", launch)
self.assertIn("alice,请做好赛前准备", launch)
self.assertTrue(state["ai_test_mode"])
self.assertEqual(state["seed_order"][-1], AUTOCHESS.AI_PERSON_KEY)
self.assertTrue(
state["players"][AUTOCHESS.AI_PERSON_KEY]["is_ai"]
)
semifinal = state["round_plan"][-2]
self.assertEqual(
semifinal["pairs"],
[
[state["seed_order"][0], AUTOCHESS.AI_PERSON_KEY],
[state["seed_order"][1], state["seed_order"][2]],
],
)
self.assertIn(
"当前轮到 alice",
self.command("/选1", sender="Bob"),
)
self.assertIn(
"alice 准备完成!到 bob 做准备了",
self.command("/结束回合"),
)
self.assertIn(
"当前轮到 bob",
self.command("/结束回合", sender="Carol"),
)
self.assertIn(
"bob 准备完成!到 carol 做准备了",
self.command("/结束回合", sender="Bob"),
)
self.assertIn(
"全员准备完毕",
self.command("/结束回合", sender="Carol"),
)
settled = self.command("/自走棋结算")
self.assertIn("Taffy:AI 战队 自动运营", settled)
self.assertIn("Taffy:bob,请做好赛前准备", settled)
state = self.state()
self.assertEqual(
state["preparation_order"][0],
state["seed_order"][1],
)
self.assertEqual(
self.engine.ledger.connection.execute(
"""
SELECT COUNT(*) FROM autochess_story_logs
WHERE story_type = 'ai_operation'
"""
).fetchone()[0],
1,
)
self.assertEqual(
self.engine.ledger.connection.execute(
"""
SELECT COUNT(*) FROM autochess_story_logs
WHERE story_text NOT LIKE 'Taffy:%'
"""
).fetchone()[0],
0,
)
def test_identical_final_creates_persisted_overtime_stories(self):
state = self.launch(["Alice", "Bob"])
for _ in range(3):
self.command("/自走棋强制结算")
state = self.state()
self.assertEqual(AUTOCHESS._current_stage(state)["kind"], "final")
first, second = state["seed_order"]
state["players"][second]["board"] = copy.deepcopy(
state["players"][first]["board"]
)
state["players"][first]["tactic"] = "稳定"
state["players"][second]["tactic"] = "稳定"
self.save_state(state)
with mock.patch.object(AUTOCHESS, "_hash_int", return_value=0):
response = self.command(
"/自走棋强制结算",
event_id="forced-overtime-final",
)
self.assertIn("19-17", response)
final = self.engine.ledger.connection.execute(
"""
SELECT result_json FROM autochess_battles
WHERE battle_type = 'final_bo5'
"""
).fetchone()
result = json.loads(final["result_json"])
self.assertEqual(result["score"], "3-0")
self.assertEqual(
[item["score"] for item in result["maps"]],
["19-17", "19-17", "19-17"],
)
overtime = self.engine.ledger.connection.execute(
"""
SELECT story_text, facts_json FROM autochess_story_logs
WHERE story_type = 'overtime'
ORDER BY story_id
"""
).fetchall()
self.assertEqual(len(overtime), 3)
for row in overtime:
self.assertIn("常规时间战成 15-15", row["story_text"])
self.assertEqual(json.loads(row["facts_json"])["overtime_count"], 1)
if __name__ == "__main__":
unittest.main()