Update Live-streaming code (auto-daily features)
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from app.bettergi_current_party import (
|
||||
CURRENT_PARTY_SCRIPT_NAME,
|
||||
CurrentPartyReadError,
|
||||
clear_current_party_status,
|
||||
prepare_current_party_read,
|
||||
read_current_party_status,
|
||||
)
|
||||
|
||||
|
||||
class BetterGICurrentPartyTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp_dir = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.temp_dir.name)
|
||||
self.group_dir = self.root / "User" / "ScriptGroup"
|
||||
self._write_json(
|
||||
self.group_dir / "切换队伍.json",
|
||||
{
|
||||
"index": 2,
|
||||
"name": "切换队伍",
|
||||
"config": {"marker": "preserved"},
|
||||
"projects": [{"name": "旧项目"}],
|
||||
},
|
||||
)
|
||||
self._write_json(
|
||||
self.group_dir / "其他.json",
|
||||
{"index": 5, "name": "其他", "config": {}, "projects": []},
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
self.temp_dir.cleanup()
|
||||
|
||||
@staticmethod
|
||||
def _write_json(path: Path, data):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
def test_prepare_reader_group_clones_config_and_uses_request_id(self):
|
||||
prepared = prepare_current_party_read(self.root, "request-123")
|
||||
|
||||
update = prepared.updates[0]
|
||||
self.assertEqual(update.data["index"], 6)
|
||||
self.assertEqual(update.data["config"]["marker"], "preserved")
|
||||
self.assertEqual(update.data["name"], "直播系统读取当前队伍")
|
||||
project = update.data["projects"][0]
|
||||
self.assertEqual(project["folderName"], CURRENT_PARTY_SCRIPT_NAME)
|
||||
self.assertEqual(project["jsScriptSettingsObject"]["requestId"], "request-123")
|
||||
self.assertEqual(
|
||||
prepared.status_path,
|
||||
self.root / "User" / "JsScript" / CURRENT_PARTY_SCRIPT_NAME / "status.json",
|
||||
)
|
||||
|
||||
def test_status_reader_ignores_stale_and_running_results(self):
|
||||
prepared = prepare_current_party_read(self.root, "request-123")
|
||||
self._write_json(
|
||||
prepared.status_path,
|
||||
{"state": "success", "request_id": "old", "party_name": "旧队伍"},
|
||||
)
|
||||
self.assertIsNone(read_current_party_status(prepared.status_path, "request-123"))
|
||||
|
||||
self._write_json(
|
||||
prepared.status_path,
|
||||
{"state": "running", "request_id": "request-123"},
|
||||
)
|
||||
self.assertIsNone(read_current_party_status(prepared.status_path, "request-123"))
|
||||
|
||||
self._write_json(
|
||||
prepared.status_path,
|
||||
{
|
||||
"state": "success",
|
||||
"request_id": "request-123",
|
||||
"party_name": "好感队",
|
||||
"candidates": ["好感队"],
|
||||
},
|
||||
)
|
||||
result = read_current_party_status(prepared.status_path, "request-123")
|
||||
self.assertEqual(result.party_name, "好感队")
|
||||
self.assertEqual(result.candidates, ("好感队",))
|
||||
|
||||
def test_status_reader_reports_script_error_and_can_be_cleared(self):
|
||||
prepared = prepare_current_party_read(self.root, "request-123")
|
||||
self._write_json(
|
||||
prepared.status_path,
|
||||
{
|
||||
"state": "error",
|
||||
"request_id": "request-123",
|
||||
"message": "识别到多个候选",
|
||||
},
|
||||
)
|
||||
with self.assertRaisesRegex(CurrentPartyReadError, "多个候选"):
|
||||
read_current_party_status(prepared.status_path, "request-123")
|
||||
|
||||
clear_current_party_status(prepared.status_path)
|
||||
self.assertFalse(prepared.status_path.exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,503 @@
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from app.bettergi_daily import (
|
||||
DAILY_MODE_COMMISSION,
|
||||
DAILY_MODE_DOMAIN,
|
||||
DAILY_MODE_LEY_LINE,
|
||||
DAILY_MODE_NONE,
|
||||
DailyAutomationError,
|
||||
DomainAliasResolver,
|
||||
apply_json_updates,
|
||||
parse_daily_request,
|
||||
prepare_commission_current_party_update,
|
||||
prepare_daily_run,
|
||||
prepare_edit_party_update,
|
||||
prepare_switch_party_update,
|
||||
resolve_party_members,
|
||||
)
|
||||
|
||||
|
||||
class BetterGIDailyTestCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp_dir = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.temp_dir.name)
|
||||
self.alias_path = self.root / "domain_aliases.json"
|
||||
self._write_json(
|
||||
self.alias_path,
|
||||
{
|
||||
"_说明": "test",
|
||||
"铭记之谷": ["风本", "少女套"],
|
||||
},
|
||||
)
|
||||
self._write_json(
|
||||
self.root / "User" / "OneDragon" / "默认配置.json",
|
||||
{
|
||||
"Name": "默认配置",
|
||||
"TaskEnabledList": {
|
||||
"mail-template-id": True,
|
||||
"serenitea-template-id": True,
|
||||
"craft-template-id": True,
|
||||
"domain-template-id": False,
|
||||
"ley-line-template-id": False,
|
||||
"daily-reward-template-id": True,
|
||||
},
|
||||
"TaskOrder": [
|
||||
"mail-template-id",
|
||||
"serenitea-template-id",
|
||||
"craft-template-id",
|
||||
"domain-template-id",
|
||||
"ley-line-template-id",
|
||||
"daily-reward-template-id",
|
||||
],
|
||||
"TaskDefinitions": {
|
||||
"mail-template-id": "领取邮件",
|
||||
"serenitea-template-id": "领取尘歌壶奖励",
|
||||
"craft-template-id": "合成树脂",
|
||||
"domain-template-id": "自动秘境",
|
||||
"ley-line-template-id": "自动地脉花",
|
||||
"daily-reward-template-id": "领取每日奖励",
|
||||
},
|
||||
"CraftingBenchCountry": "枫丹",
|
||||
"AdventurersGuildCountry": "璃月",
|
||||
"PartyName": "战斗队",
|
||||
"DailyRewardPartyName": "好感队",
|
||||
"SecretTreasureObjects": ["须臾树脂"],
|
||||
"WeeklyDomainEnabled": True,
|
||||
"DomainName": "旧秘境",
|
||||
"CompletionAction": "关机",
|
||||
},
|
||||
)
|
||||
self._write_json(
|
||||
self.root / "User" / "config.json",
|
||||
{
|
||||
"marker": "preserved",
|
||||
"autoFightConfig": {"strategyName": "策略A"},
|
||||
"autoDomainConfig": {
|
||||
"specifyResinUse": True,
|
||||
"other": 1,
|
||||
},
|
||||
"autoLeyLineOutcropConfig": {
|
||||
"isGoToSynthesizer": True,
|
||||
"team": "战斗队",
|
||||
"friendshipTeam": "",
|
||||
"fightConfig": {"strategyName": "策略A"},
|
||||
},
|
||||
},
|
||||
)
|
||||
strategy = self.root / "User" / "AutoFight" / "策略A.txt"
|
||||
strategy.parent.mkdir(parents=True, exist_ok=True)
|
||||
strategy.write_text("战斗策略", encoding="utf-8")
|
||||
self._write_json(
|
||||
self.root / "User" / "JsScript" / "AutoDomain" / "settings.json",
|
||||
[
|
||||
{
|
||||
"name": "domainName",
|
||||
"type": "select",
|
||||
"options": ["铭记之谷", "仲夏庭园"],
|
||||
}
|
||||
],
|
||||
)
|
||||
self._write_json(
|
||||
self.root / "User" / "ScriptGroup" / "每日委托.json",
|
||||
{"name": "每日委托", "projects": [{"name": "委托", "status": "Enabled"}]},
|
||||
)
|
||||
self._write_json(
|
||||
self.root / "User" / "ScriptGroup" / "切换队伍.json",
|
||||
{
|
||||
"name": "切换队伍",
|
||||
"projects": [
|
||||
{
|
||||
"folderName": "AcceleratedEditionSwitchParty",
|
||||
"status": "Enabled",
|
||||
"jsScriptSettingsObject": {"partyName": "旧队伍", "debug": True},
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
self._write_json(
|
||||
self.root / "User" / "ScriptGroup" / "修改队员.json",
|
||||
{
|
||||
"name": "修改队员",
|
||||
"projects": [
|
||||
{
|
||||
"folderName": "AutoSwitchRoles",
|
||||
"status": "Enabled",
|
||||
"jsScriptSettingsObject": {"position1": ""},
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
options = [
|
||||
"水-单手剑-芙宁娜",
|
||||
"冰-长枪-爱可菲",
|
||||
"草-法器-纳西妲",
|
||||
"雷-长枪-雷电将军",
|
||||
"岩-长枪-钟离",
|
||||
]
|
||||
self._write_json(
|
||||
self.root / "User" / "JsScript" / "AutoSwitchRoles" / "settings.json",
|
||||
[
|
||||
{"name": f"position{index}", "type": "select", "options": ["", *options]}
|
||||
for index in range(1, 5)
|
||||
],
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
self.temp_dir.cleanup()
|
||||
|
||||
@staticmethod
|
||||
def _write_json(path: Path, data):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
def _prepare(
|
||||
self,
|
||||
argument: str,
|
||||
*,
|
||||
craft_before: bool = True,
|
||||
commission_use_current_party: bool = False,
|
||||
):
|
||||
return prepare_daily_run(
|
||||
self.root,
|
||||
self.alias_path,
|
||||
argument,
|
||||
template_name="默认配置",
|
||||
managed_name="直播系统自动每日",
|
||||
ley_line_craft_resin_before=craft_before,
|
||||
commission_use_current_party=commission_use_current_party,
|
||||
)
|
||||
|
||||
def test_parse_daily_modes(self):
|
||||
self.assertEqual(parse_daily_request("").mode, DAILY_MODE_NONE)
|
||||
domain = parse_daily_request("秘境 风本")
|
||||
self.assertEqual((domain.mode, domain.domain_name), (DAILY_MODE_DOMAIN, "风本"))
|
||||
ley_line = parse_daily_request("地脉 经验 蒙德")
|
||||
self.assertEqual(
|
||||
(ley_line.mode, ley_line.ley_line_type, ley_line.ley_line_country),
|
||||
(DAILY_MODE_LEY_LINE, "启示之花", "蒙德"),
|
||||
)
|
||||
self.assertEqual(parse_daily_request("委托").mode, DAILY_MODE_COMMISSION)
|
||||
|
||||
def test_parse_rejects_missing_or_unknown_arguments(self):
|
||||
with self.assertRaisesRegex(DailyAutomationError, "需要指定秘境"):
|
||||
parse_daily_request("秘境")
|
||||
with self.assertRaisesRegex(DailyAutomationError, "地脉模式格式"):
|
||||
parse_daily_request("地脉 经验")
|
||||
with self.assertRaisesRegex(DailyAutomationError, "不支持的地脉国家"):
|
||||
parse_daily_request("地脉 摩拉 天空岛")
|
||||
with self.assertRaisesRegex(DailyAutomationError, "仅支持"):
|
||||
parse_daily_request("探索")
|
||||
|
||||
def test_domain_alias_resolves_canonical_and_common_names(self):
|
||||
resolver = DomainAliasResolver(self.alias_path, self.root)
|
||||
self.assertEqual(resolver.resolve("风本"), "铭记之谷")
|
||||
self.assertEqual(resolver.resolve("少女套"), "铭记之谷")
|
||||
self.assertEqual(resolver.resolve("仲夏庭园"), "仲夏庭园")
|
||||
|
||||
def test_domain_alias_collision_is_rejected(self):
|
||||
self._write_json(
|
||||
self.alias_path,
|
||||
{
|
||||
"铭记之谷": ["风本"],
|
||||
"仲夏庭园": ["风 本"],
|
||||
},
|
||||
)
|
||||
with self.assertRaisesRegex(DailyAutomationError, "同时指向"):
|
||||
DomainAliasResolver(self.alias_path, self.root).resolve("风本")
|
||||
|
||||
def test_domain_run_generates_exact_flow_and_corrects_bgi_config(self):
|
||||
prepared = self._prepare("秘境 风本")
|
||||
self.assertEqual(prepared.request.domain_name, "铭记之谷")
|
||||
self.assertEqual(prepared.task_name, "自动每日(秘境:铭记之谷)")
|
||||
updates = {update.path.name: update for update in prepared.updates}
|
||||
corrected = updates["config.json"].data
|
||||
self.assertFalse(corrected["autoDomainConfig"]["specifyResinUse"])
|
||||
self.assertEqual(corrected["autoDomainConfig"]["other"], 1)
|
||||
self.assertEqual(corrected["marker"], "preserved")
|
||||
|
||||
managed = updates["直播系统自动每日.json"].data
|
||||
names = [managed["TaskDefinitions"][task_id] for task_id in managed["TaskOrder"]]
|
||||
self.assertEqual(
|
||||
names,
|
||||
["领取邮件", "合成树脂", "自动秘境", "领取尘歌壶奖励", "领取每日奖励"],
|
||||
)
|
||||
self.assertEqual(
|
||||
managed["TaskOrder"],
|
||||
[
|
||||
"mail-template-id",
|
||||
"craft-template-id",
|
||||
"domain-template-id",
|
||||
"serenitea-template-id",
|
||||
"daily-reward-template-id",
|
||||
],
|
||||
)
|
||||
self.assertFalse(managed["WeeklyDomainEnabled"])
|
||||
self.assertEqual(managed["DomainName"], "铭记之谷")
|
||||
self.assertEqual(managed["CompletionAction"], "无")
|
||||
self.assertEqual(managed["PartyName"], "战斗队")
|
||||
|
||||
def test_no_mode_skips_only_the_other_task(self):
|
||||
prepared = self._prepare("")
|
||||
managed = prepared.updates[-1].data
|
||||
names = [managed["TaskDefinitions"][task_id] for task_id in managed["TaskOrder"]]
|
||||
self.assertEqual(
|
||||
names,
|
||||
["领取邮件", "合成树脂", "领取尘歌壶奖励", "领取每日奖励"],
|
||||
)
|
||||
|
||||
def test_ley_line_run_sets_all_days_and_respects_craft_toggle(self):
|
||||
prepared = self._prepare("地脉 摩拉 枫丹", craft_before=False)
|
||||
updates = {update.path.name: update for update in prepared.updates}
|
||||
corrected = updates["config.json"].data
|
||||
self.assertFalse(corrected["autoLeyLineOutcropConfig"]["isGoToSynthesizer"])
|
||||
managed = updates["直播系统自动每日.json"].data
|
||||
names = [managed["TaskDefinitions"][task_id] for task_id in managed["TaskOrder"]]
|
||||
self.assertEqual(
|
||||
names,
|
||||
["领取邮件", "自动地脉花", "领取尘歌壶奖励", "领取每日奖励"],
|
||||
)
|
||||
self.assertTrue(managed["LeyLineResinExhaustionMode"])
|
||||
self.assertFalse(managed["LeyLineOpenModeCountMin"])
|
||||
for day in ("Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"):
|
||||
self.assertTrue(managed[f"LeyLineRun{day}"])
|
||||
self.assertEqual(managed[f"LeyLine{day}Type"], "藏金之花")
|
||||
self.assertEqual(managed[f"LeyLine{day}Country"], "枫丹")
|
||||
|
||||
def test_commission_is_added_as_a_configuration_group_task(self):
|
||||
prepared = self._prepare("委托")
|
||||
managed = prepared.updates[-1].data
|
||||
names = [managed["TaskDefinitions"][task_id] for task_id in managed["TaskOrder"]]
|
||||
self.assertEqual(
|
||||
names,
|
||||
["领取邮件", "合成树脂", "每日委托", "领取尘歌壶奖励", "领取每日奖励"],
|
||||
)
|
||||
commission_id = next(
|
||||
task_id
|
||||
for task_id, name in managed["TaskDefinitions"].items()
|
||||
if name == "每日委托"
|
||||
)
|
||||
self.assertNotIn(
|
||||
commission_id,
|
||||
{
|
||||
"mail-template-id",
|
||||
"craft-template-id",
|
||||
"serenitea-template-id",
|
||||
"daily-reward-template-id",
|
||||
},
|
||||
)
|
||||
def test_commission_requires_nonempty_group(self):
|
||||
(self.root / "User" / "ScriptGroup" / "每日委托.json").unlink()
|
||||
with self.assertRaisesRegex(DailyAutomationError, "每日委托配置组"):
|
||||
self._prepare("委托")
|
||||
|
||||
def test_auto_commission_nova_requires_complete_first_run_config(self):
|
||||
self._write_json(
|
||||
self.root / "User" / "ScriptGroup" / "每日委托.json",
|
||||
{
|
||||
"name": "每日委托",
|
||||
"projects": [
|
||||
{
|
||||
"folderName": "AutoCommissionNova",
|
||||
"status": "Enabled",
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
with self.assertRaisesRegex(DailyAutomationError, "缺少 Data/user-config.json"):
|
||||
self._prepare("委托")
|
||||
|
||||
user_config_path = (
|
||||
self.root
|
||||
/ "User"
|
||||
/ "JsScript"
|
||||
/ "AutoCommissionNova"
|
||||
/ "Data"
|
||||
/ "user-config.json"
|
||||
)
|
||||
self._write_json(
|
||||
user_config_path,
|
||||
{
|
||||
"party": {
|
||||
"global": {
|
||||
"battleTeamName": "战斗队",
|
||||
"elementTeamName": "",
|
||||
"battleStrategy": "策略A",
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
with self.assertRaisesRegex(DailyAutomationError, "元素采集队伍"):
|
||||
self._prepare("委托")
|
||||
|
||||
self._write_json(
|
||||
user_config_path,
|
||||
{
|
||||
"party": {
|
||||
"global": {
|
||||
"battleTeamName": "战斗队",
|
||||
"elementTeamName": "采集队",
|
||||
"battleStrategy": "策略A",
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
self.assertEqual(self._prepare("委托").request.mode, DAILY_MODE_COMMISSION)
|
||||
|
||||
def test_auto_commission_nova_can_inject_current_party_for_battle_and_collection(self):
|
||||
self._write_json(
|
||||
self.root / "User" / "ScriptGroup" / "每日委托.json",
|
||||
{
|
||||
"name": "每日委托",
|
||||
"projects": [
|
||||
{
|
||||
"folderName": "AutoCommissionNova",
|
||||
"status": "Enabled",
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
user_config_path = (
|
||||
self.root
|
||||
/ "User"
|
||||
/ "JsScript"
|
||||
/ "AutoCommissionNova"
|
||||
/ "Data"
|
||||
/ "user-config.json"
|
||||
)
|
||||
self._write_json(
|
||||
user_config_path,
|
||||
{
|
||||
"marker": "preserved",
|
||||
"party": {
|
||||
"global": {
|
||||
"battleTeamName": "",
|
||||
"elementTeamName": "",
|
||||
"battleStrategy": "策略A",
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
prepared = self._prepare("委托", commission_use_current_party=True)
|
||||
self.assertTrue(prepared.requires_current_party)
|
||||
|
||||
update = prepare_commission_current_party_update(self.root, "当前队伍")
|
||||
self.assertEqual(
|
||||
update.data["party"]["global"]["battleTeamName"],
|
||||
"当前队伍",
|
||||
)
|
||||
self.assertEqual(
|
||||
update.data["party"]["global"]["elementTeamName"],
|
||||
"当前队伍",
|
||||
)
|
||||
self.assertEqual(update.data["marker"], "preserved")
|
||||
|
||||
def test_switch_party_updates_only_target_setting(self):
|
||||
update = prepare_switch_party_update(self.root, "深渊队")
|
||||
project = update.data["projects"][0]
|
||||
self.assertEqual(project["jsScriptSettingsObject"]["partyName"], "深渊队")
|
||||
self.assertTrue(project["jsScriptSettingsObject"]["debug"])
|
||||
|
||||
def test_edit_party_accepts_separators_and_contiguous_names(self):
|
||||
separated, display = resolve_party_members(
|
||||
self.root,
|
||||
"芙宁娜 爱可菲 纳西妲 雷电将军",
|
||||
)
|
||||
self.assertEqual(display, ["芙宁娜", "爱可菲", "纳西妲", "雷电将军"])
|
||||
contiguous, contiguous_display = resolve_party_members(
|
||||
self.root,
|
||||
"芙宁娜爱可菲纳西妲雷电将军",
|
||||
)
|
||||
self.assertEqual(contiguous, separated)
|
||||
self.assertEqual(contiguous_display, display)
|
||||
|
||||
update, names = prepare_edit_party_update(
|
||||
self.root,
|
||||
"芙宁娜、爱可菲、纳西妲、雷电将军",
|
||||
)
|
||||
settings = update.data["projects"][0]["jsScriptSettingsObject"]
|
||||
self.assertEqual(names, ("芙宁娜", "爱可菲", "纳西妲", "雷电将军"))
|
||||
self.assertEqual(settings["position4"], "雷-长枪-雷电将军")
|
||||
|
||||
def test_edit_party_supports_auto_switch_roles_67_character_data(self):
|
||||
self._write_json(
|
||||
self.root / "User" / "JsScript" / "AutoSwitchRoles" / "settings.json",
|
||||
[
|
||||
{"name": f"position{index}", "type": "input-text", "default": ""}
|
||||
for index in range(1, 5)
|
||||
],
|
||||
)
|
||||
self._write_json(
|
||||
self.root / "User" / "JsScript" / "AutoSwitchRoles" / "combat_avatar.json",
|
||||
[
|
||||
{"name": "神里绫华", "alias": ["绫华"]},
|
||||
{"name": "申鹤", "alias": []},
|
||||
{"name": "枫原万叶", "alias": ["万叶"]},
|
||||
{"name": "珊瑚宫心海", "alias": ["心海"]},
|
||||
],
|
||||
)
|
||||
|
||||
update, names = prepare_edit_party_update(
|
||||
self.root,
|
||||
"绫华 申鹤 万叶 心海",
|
||||
)
|
||||
|
||||
settings = update.data["projects"][0]["jsScriptSettingsObject"]
|
||||
self.assertEqual(names, ("神里绫华", "申鹤", "枫原万叶", "珊瑚宫心海"))
|
||||
self.assertEqual(
|
||||
[settings[f"position{index}"] for index in range(1, 5)],
|
||||
["神里绫华", "申鹤", "枫原万叶", "珊瑚宫心海"],
|
||||
)
|
||||
|
||||
resolved, display = resolve_party_members(
|
||||
self.root,
|
||||
"神里绫华申鹤枫原万叶珊瑚宫心海",
|
||||
)
|
||||
self.assertEqual(resolved, list(display))
|
||||
|
||||
def test_edit_party_rejects_ambiguous_combat_avatar_alias(self):
|
||||
self._write_json(
|
||||
self.root / "User" / "JsScript" / "AutoSwitchRoles" / "settings.json",
|
||||
[
|
||||
{"name": f"position{index}", "type": "input-text", "default": ""}
|
||||
for index in range(1, 5)
|
||||
],
|
||||
)
|
||||
self._write_json(
|
||||
self.root / "User" / "JsScript" / "AutoSwitchRoles" / "combat_avatar.json",
|
||||
[
|
||||
{"name": "角色甲", "alias": ["同名"]},
|
||||
{"name": "角色乙", "alias": ["同名"]},
|
||||
{"name": "角色丙", "alias": []},
|
||||
{"name": "角色丁", "alias": []},
|
||||
{"name": "角色戊", "alias": []},
|
||||
],
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(DailyAutomationError, "未知或有歧义"):
|
||||
resolve_party_members(self.root, "同名 角色丙 角色丁 角色戊")
|
||||
|
||||
def test_edit_party_requires_four_distinct_known_members(self):
|
||||
with self.assertRaisesRegex(DailyAutomationError, "必须是4人"):
|
||||
resolve_party_members(self.root, "芙宁娜 爱可菲 纳西妲")
|
||||
with self.assertRaisesRegex(DailyAutomationError, "不能重复"):
|
||||
resolve_party_members(self.root, "芙宁娜 芙宁娜 纳西妲 雷电将军")
|
||||
with self.assertRaisesRegex(DailyAutomationError, "未知"):
|
||||
resolve_party_members(self.root, "芙宁娜 爱可菲 纳西妲 不存在")
|
||||
|
||||
def test_apply_json_updates_writes_managed_files(self):
|
||||
prepared = self._prepare("秘境 风本")
|
||||
apply_json_updates(prepared.updates)
|
||||
bgi_config = json.loads((self.root / "User" / "config.json").read_text(encoding="utf-8"))
|
||||
managed = json.loads(
|
||||
(self.root / "User" / "OneDragon" / "直播系统自动每日.json").read_text(encoding="utf-8")
|
||||
)
|
||||
self.assertFalse(bgi_config["autoDomainConfig"]["specifyResinUse"])
|
||||
self.assertEqual(managed["DomainName"], "铭记之谷")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,115 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from app.danmu_queue import BgiLogMonitor, WebServer
|
||||
|
||||
|
||||
class BgiLogDisplayFormatTests(unittest.TestCase):
|
||||
def test_formats_multi_instance_log_as_single_readable_line(self):
|
||||
raw_lines = [
|
||||
"[23:55:25.160] [INF] [Primary:S1:P16720:T1785426828552] "
|
||||
"BetterGenshinImpact.GameTask.TaskTriggerDispatcher\n",
|
||||
"游戏已退出,BetterGI 自动停止截图器\n",
|
||||
"\n",
|
||||
]
|
||||
|
||||
self.assertEqual(
|
||||
BgiLogMonitor.format_display_lines(raw_lines),
|
||||
["[23:55:25] 游戏已退出,BetterGI 自动停止截图器"],
|
||||
)
|
||||
|
||||
def test_formats_legacy_log_header_the_same_way(self):
|
||||
raw_lines = [
|
||||
"[16:39:12.328] [INF] BetterGenshinImpact.GameTask.TaskTriggerDispatcher\n",
|
||||
"游戏已退出,BetterGI 自动停止截图器\n",
|
||||
]
|
||||
|
||||
self.assertEqual(
|
||||
BgiLogMonitor.format_display_lines(raw_lines),
|
||||
["[16:39:12] 游戏已退出,BetterGI 自动停止截图器"],
|
||||
)
|
||||
|
||||
def test_preserves_multiline_message_inside_one_log_entry(self):
|
||||
raw_lines = [
|
||||
"[10:00:00.001] [ERR] [Primary:S1:P1:T2] BetterGI.Component\n",
|
||||
"第一行\n",
|
||||
"第二行\n",
|
||||
"[10:00:01.999] [INF] BetterGI.OtherComponent\n",
|
||||
"下一条\n",
|
||||
]
|
||||
|
||||
self.assertEqual(
|
||||
BgiLogMonitor.format_display_lines(raw_lines),
|
||||
["[10:00:00] 第一行\n第二行", "[10:00:01] 下一条"],
|
||||
)
|
||||
|
||||
def test_frontend_reader_falls_back_to_latest_log_after_midnight(self):
|
||||
with TemporaryDirectory() as temp_dir:
|
||||
log_dir = Path(temp_dir) / "log"
|
||||
log_dir.mkdir()
|
||||
(log_dir / "better-genshin-impact20260730.log").write_text(
|
||||
"[23:55:25.160] [INF] [Primary:S1:P16720:T1785426828552] "
|
||||
"BetterGenshinImpact.GameTask.TaskTriggerDispatcher\n"
|
||||
"游戏已退出,BetterGI 自动停止截图器\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
server = WebServer.__new__(WebServer)
|
||||
server.config = SimpleNamespace(bettergi_work_dir=temp_dir)
|
||||
|
||||
with patch("app.danmu_queue.datetime") as mocked_datetime:
|
||||
mocked_datetime.now.return_value.strftime.return_value = "20260731"
|
||||
result = server._read_bgi_log(50)
|
||||
|
||||
self.assertEqual(
|
||||
result,
|
||||
["[23:55:25] 游戏已退出,BetterGI 自动停止截图器"],
|
||||
)
|
||||
|
||||
|
||||
class BgiLogCompletionTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_configuration_group_marker_still_finishes_current_group(self):
|
||||
monitor = BgiLogMonitor(".", logging.getLogger("test-bgi-group-log"))
|
||||
callback = AsyncMock()
|
||||
monitor.set_finish_callback(callback)
|
||||
with patch.object(monitor, "_get_current_log_size", return_value=0):
|
||||
monitor.set_current_group("薄荷", "group-run")
|
||||
|
||||
monitor._process_line('配置组 "薄荷" 执行结束')
|
||||
await asyncio.sleep(0)
|
||||
|
||||
callback.assert_awaited_once_with("薄荷", "group-run")
|
||||
self.assertIsNone(monitor._current_group)
|
||||
|
||||
async def test_one_dragon_marker_finishes_managed_daily(self):
|
||||
monitor = BgiLogMonitor(".", logging.getLogger("test-bgi-one-dragon-log"))
|
||||
callback = AsyncMock()
|
||||
monitor.set_finish_callback(callback)
|
||||
with patch.object(monitor, "_get_current_log_size", return_value=0):
|
||||
monitor.set_current_one_dragon("自动每日(委托)", "daily-run")
|
||||
|
||||
monitor._process_line("一条龙和配置组任务结束")
|
||||
await asyncio.sleep(0)
|
||||
|
||||
callback.assert_awaited_once_with("自动每日(委托)", "daily-run")
|
||||
self.assertIsNone(monitor._current_group)
|
||||
|
||||
async def test_child_group_completion_does_not_finish_one_dragon_early(self):
|
||||
monitor = BgiLogMonitor(".", logging.getLogger("test-bgi-child-group-log"))
|
||||
callback = AsyncMock()
|
||||
monitor.set_finish_callback(callback)
|
||||
with patch.object(monitor, "_get_current_log_size", return_value=0):
|
||||
monitor.set_current_one_dragon("自动每日", "daily-run")
|
||||
|
||||
monitor._process_line('配置组 "每日委托" 执行结束')
|
||||
await asyncio.sleep(0)
|
||||
|
||||
callback.assert_not_awaited()
|
||||
self.assertEqual(monitor._current_group, "自动每日")
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,156 @@
|
||||
import logging
|
||||
import unittest
|
||||
import urllib.error
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
from app.bilibili_cookie_refresh import BilibiliQrLogin, _request_json
|
||||
|
||||
|
||||
class _JsonResponse:
|
||||
def __init__(self, payload=b'{"code": 0}'):
|
||||
self.payload = payload
|
||||
|
||||
def read(self):
|
||||
return self.payload
|
||||
|
||||
|
||||
class BilibiliRequestTests(unittest.TestCase):
|
||||
def test_get_retries_transient_connection_failures(self):
|
||||
opener = Mock()
|
||||
response = _JsonResponse()
|
||||
opener.open.side_effect = [
|
||||
urllib.error.URLError(ConnectionRefusedError(10061, "connection refused")),
|
||||
urllib.error.URLError(ConnectionRefusedError(10061, "connection refused")),
|
||||
response,
|
||||
]
|
||||
|
||||
with patch("app.bilibili_cookie_refresh.time.sleep") as sleep:
|
||||
payload, actual_response = _request_json(
|
||||
"https://passport.bilibili.com/test",
|
||||
opener=opener,
|
||||
retries=2,
|
||||
)
|
||||
|
||||
self.assertEqual(payload, {"code": 0})
|
||||
self.assertIs(actual_response, response)
|
||||
self.assertEqual(opener.open.call_count, 3)
|
||||
self.assertEqual([call.args[0] for call in sleep.call_args_list], [0.4, 0.8])
|
||||
|
||||
def test_exhausted_retries_hide_low_level_network_error(self):
|
||||
opener = Mock()
|
||||
opener.open.side_effect = urllib.error.URLError(
|
||||
ConnectionRefusedError(10061, "connection refused")
|
||||
)
|
||||
|
||||
with patch("app.bilibili_cookie_refresh.time.sleep"), self.assertRaisesRegex(
|
||||
RuntimeError,
|
||||
"^连接B站登录服务失败,请稍后重试$",
|
||||
) as raised:
|
||||
_request_json(
|
||||
"https://passport.bilibili.com/test",
|
||||
opener=opener,
|
||||
retries=2,
|
||||
)
|
||||
|
||||
self.assertEqual(opener.open.call_count, 3)
|
||||
self.assertNotIn("10061", str(raised.exception))
|
||||
|
||||
|
||||
class _CredentialStore:
|
||||
def __init__(self):
|
||||
self.refresh_token = ""
|
||||
|
||||
def save_refresh_token(self, refresh_token):
|
||||
self.refresh_token = refresh_token
|
||||
|
||||
def is_configured(self):
|
||||
return bool(self.refresh_token)
|
||||
|
||||
|
||||
class BilibiliQrLoginTests(unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self):
|
||||
self.store = _CredentialStore()
|
||||
self.updated_cookies = []
|
||||
self.on_logged_in = AsyncMock()
|
||||
self.login = BilibiliQrLogin(
|
||||
credential_store=self.store,
|
||||
update_cookie=self.updated_cookies.append,
|
||||
logger=logging.getLogger("test-bilibili-qr"),
|
||||
on_logged_in=self.on_logged_in,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _generate_response():
|
||||
return ({
|
||||
"code": 0,
|
||||
"data": {
|
||||
"url": "https://passport.bilibili.com/h5-app/passport/login/scan?navhide=1",
|
||||
"qrcode_key": "private-qrcode-key",
|
||||
},
|
||||
}, None)
|
||||
|
||||
async def test_start_keeps_qrcode_key_on_server(self):
|
||||
with patch("app.bilibili_cookie_refresh._request_json", return_value=self._generate_response()):
|
||||
result = await self.login.start()
|
||||
|
||||
self.assertEqual(result["state"], "awaiting_scan")
|
||||
self.assertTrue(result["has_qr_image"])
|
||||
self.assertNotIn("qr_key", result)
|
||||
self.assertNotIn("qr_url", result)
|
||||
self.assertFalse(result["credential_configured"])
|
||||
|
||||
async def test_poll_reports_scanned_without_saving_credentials(self):
|
||||
with patch("app.bilibili_cookie_refresh._request_json", return_value=self._generate_response()):
|
||||
await self.login.start()
|
||||
with patch("app.bilibili_cookie_refresh._request_json", return_value=({
|
||||
"code": 0,
|
||||
"data": {"code": 86090, "message": "二维码已扫码未确认"},
|
||||
}, None)):
|
||||
result = await self.login.poll()
|
||||
|
||||
self.assertEqual(result["state"], "awaiting_confirm")
|
||||
self.assertEqual(self.store.refresh_token, "")
|
||||
self.assertEqual(self.updated_cookies, [])
|
||||
self.on_logged_in.assert_not_awaited()
|
||||
|
||||
async def test_success_saves_refresh_token_and_updates_cookie(self):
|
||||
with patch("app.bilibili_cookie_refresh._request_json", return_value=self._generate_response()):
|
||||
await self.login.start()
|
||||
|
||||
login_url = (
|
||||
"https://www.bilibili.com/?SESSDATA=session-value&bili_jct=csrf-value"
|
||||
"&DedeUserID=123456"
|
||||
)
|
||||
|
||||
def request_side_effect(url, **_kwargs):
|
||||
if "qrcode/poll" in url:
|
||||
return ({
|
||||
"code": 0,
|
||||
"data": {
|
||||
"code": 0,
|
||||
"url": login_url,
|
||||
"refresh_token": "refresh-token-value",
|
||||
},
|
||||
}, None)
|
||||
if "x/web-interface/nav" in url:
|
||||
return ({
|
||||
"code": 0,
|
||||
"data": {"isLogin": True, "mid": 123456, "uname": "测试账号"},
|
||||
}, None)
|
||||
raise AssertionError(f"unexpected url: {url}")
|
||||
|
||||
with patch("app.bilibili_cookie_refresh._request_json", side_effect=request_side_effect):
|
||||
result = await self.login.poll()
|
||||
|
||||
self.assertEqual(result["state"], "completed")
|
||||
self.assertEqual(result["account"]["uname"], "测试账号")
|
||||
self.assertTrue(result["credential_configured"])
|
||||
self.assertNotIn("refresh_token", result)
|
||||
self.assertEqual(self.store.refresh_token, "refresh-token-value")
|
||||
self.assertEqual(self.updated_cookies[0]["SESSDATA"], "session-value")
|
||||
self.assertEqual(self.updated_cookies[0]["bili_jct"], "csrf-value")
|
||||
self.on_logged_in.assert_awaited_once()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,353 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, call, patch
|
||||
|
||||
from app.bettergi_current_party import (
|
||||
CurrentPartyReadError,
|
||||
CurrentPartyReadResult,
|
||||
PreparedCurrentPartyRead,
|
||||
)
|
||||
from app.bettergi_daily import (
|
||||
DailyAutomationError,
|
||||
DailyRequest,
|
||||
JsonUpdate,
|
||||
PreparedDailyRun,
|
||||
)
|
||||
from app.danmu_queue import (
|
||||
COMMAND_ALLOWED_ROLE_DEFAULTS,
|
||||
BetterGIRunner,
|
||||
CommandHandler,
|
||||
)
|
||||
|
||||
|
||||
class DailyCommandIntegrationTests(unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self):
|
||||
self.temp_dir = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.temp_dir.name)
|
||||
self.config = SimpleNamespace(
|
||||
data={
|
||||
"commands": {
|
||||
"run": {"enabled": True, "aliases": ["执行", "跑"]},
|
||||
"daily": {"enabled": True, "aliases": ["自动每日"]},
|
||||
"switch_party": {
|
||||
"enabled": True,
|
||||
"aliases": ["切换队伍", "更换队伍"],
|
||||
},
|
||||
"edit_party": {
|
||||
"enabled": True,
|
||||
"aliases": ["修改队员", "更换队员"],
|
||||
},
|
||||
}
|
||||
},
|
||||
admin_uids=set(),
|
||||
bettergi_work_dir=str(self.root),
|
||||
daily_cfg={
|
||||
"one_dragon_template": "默认配置",
|
||||
"managed_one_dragon_name": "直播系统自动每日",
|
||||
"ley_line_craft_resin_before": True,
|
||||
"commission_use_current_party": True,
|
||||
"current_party_read_timeout_sec": 45,
|
||||
},
|
||||
broadcast_cfg={"tts_categories": {"execution": True}},
|
||||
music_monitor_cfg={"request_player": {}},
|
||||
)
|
||||
self.queue_mgr = MagicMock()
|
||||
self.queue_mgr.state = {
|
||||
"current_admin_uid": 123,
|
||||
"login_status": "logged_in",
|
||||
"current_group": None,
|
||||
"default_running": False,
|
||||
}
|
||||
self.queue_mgr.is_admin.return_value = True
|
||||
self.queue_mgr.start_group.return_value = {
|
||||
"success": True,
|
||||
"msg": "ok",
|
||||
"run_id": "daily-run",
|
||||
}
|
||||
self.runner = MagicMock()
|
||||
self.runner.kill_bgi = AsyncMock()
|
||||
self.runner.start_groups = AsyncMock(return_value=True)
|
||||
self.runner.start_one_dragon = AsyncMock(return_value=True)
|
||||
self.runner.sync_js_script.return_value = True
|
||||
self.log_monitor = MagicMock()
|
||||
self.broadcaster = SimpleNamespace(broadcast=AsyncMock())
|
||||
self.handler = CommandHandler(
|
||||
self.config,
|
||||
MagicMock(),
|
||||
self.queue_mgr,
|
||||
self.runner,
|
||||
self.log_monitor,
|
||||
MagicMock(),
|
||||
logging.getLogger("test-daily-command"),
|
||||
broadcaster=self.broadcaster,
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
self.temp_dir.cleanup()
|
||||
|
||||
def _prepared_daily(self, *, requires_current_party: bool = False) -> PreparedDailyRun:
|
||||
return PreparedDailyRun(
|
||||
request=DailyRequest(mode="commission" if requires_current_party else "none"),
|
||||
config_name="直播系统自动每日",
|
||||
updates=(
|
||||
JsonUpdate(
|
||||
self.root / "User" / "OneDragon" / "直播系统自动每日.json",
|
||||
{"Name": "直播系统自动每日"},
|
||||
"test",
|
||||
),
|
||||
),
|
||||
requires_current_party=requires_current_party,
|
||||
)
|
||||
|
||||
async def test_daily_replaces_old_task_writes_config_and_starts_one_dragon(self):
|
||||
self.queue_mgr.state["current_group"] = "旧任务"
|
||||
prepared = self._prepared_daily()
|
||||
with (
|
||||
patch("app.danmu_queue.prepare_daily_run", return_value=prepared) as prepare,
|
||||
patch("app.danmu_queue.apply_json_updates") as apply_updates,
|
||||
patch("app.danmu_queue.uuid.uuid4", return_value=SimpleNamespace(hex="daily-run")),
|
||||
):
|
||||
await self.handler._cmd_daily(123, "测试用户", "")
|
||||
|
||||
prepare.assert_called_once()
|
||||
self.queue_mgr.interrupt_group.assert_called_once_with(
|
||||
"daily_replaced",
|
||||
status="cancelled",
|
||||
)
|
||||
self.runner.kill_bgi.assert_awaited_once_with(reason="自动每日配置更新")
|
||||
apply_updates.assert_called_once_with(prepared.updates)
|
||||
self.queue_mgr.start_group.assert_called_once_with(
|
||||
123,
|
||||
"自动每日",
|
||||
run_id="daily-run",
|
||||
)
|
||||
self.log_monitor.set_current_one_dragon.assert_called_once_with(
|
||||
"自动每日",
|
||||
"daily-run",
|
||||
)
|
||||
self.runner.start_one_dragon.assert_awaited_once_with("直播系统自动每日")
|
||||
|
||||
async def test_daily_validation_failure_does_not_stop_current_task(self):
|
||||
self.queue_mgr.state["current_group"] = "旧任务"
|
||||
with patch(
|
||||
"app.danmu_queue.prepare_daily_run",
|
||||
side_effect=DailyAutomationError("未找到一条龙模板"),
|
||||
):
|
||||
await self.handler._cmd_daily(123, "测试用户", "秘境 风本")
|
||||
|
||||
self.queue_mgr.interrupt_group.assert_not_called()
|
||||
self.runner.kill_bgi.assert_not_awaited()
|
||||
self.runner.start_one_dragon.assert_not_awaited()
|
||||
|
||||
async def test_daily_commission_reads_current_party_before_starting(self):
|
||||
prepared = self._prepared_daily(requires_current_party=True)
|
||||
reader_update = JsonUpdate(
|
||||
self.root / "User" / "ScriptGroup" / "直播系统读取当前队伍.json",
|
||||
{"projects": []},
|
||||
"reader",
|
||||
)
|
||||
reader = PreparedCurrentPartyRead(
|
||||
request_id="daily-run",
|
||||
group_name="直播系统读取当前队伍",
|
||||
status_path=self.root / "status.json",
|
||||
updates=(reader_update,),
|
||||
)
|
||||
party_update = JsonUpdate(
|
||||
self.root / "User" / "JsScript" / "AutoCommissionNova" / "Data" / "user-config.json",
|
||||
{
|
||||
"party": {
|
||||
"global": {
|
||||
"battleTeamName": "好感队",
|
||||
"elementTeamName": "好感队",
|
||||
}
|
||||
}
|
||||
},
|
||||
"party",
|
||||
)
|
||||
capture = AsyncMock(return_value="好感队")
|
||||
with (
|
||||
patch("app.danmu_queue.prepare_daily_run", return_value=prepared) as prepare,
|
||||
patch("app.danmu_queue.prepare_current_party_read", return_value=reader),
|
||||
patch(
|
||||
"app.danmu_queue.prepare_commission_current_party_update",
|
||||
return_value=party_update,
|
||||
) as prepare_party,
|
||||
patch("app.danmu_queue.apply_json_updates") as apply_updates,
|
||||
patch.object(self.handler, "_capture_current_party_name", capture),
|
||||
patch("app.danmu_queue.uuid.uuid4", return_value=SimpleNamespace(hex="daily-run")),
|
||||
):
|
||||
await self.handler._cmd_daily(123, "测试用户", "委托")
|
||||
|
||||
self.assertTrue(prepare.call_args.kwargs["commission_use_current_party"])
|
||||
capture.assert_awaited_once_with(reader, 45)
|
||||
prepare_party.assert_called_once_with(str(self.root), "好感队")
|
||||
apply_updates.assert_called_once_with((party_update, *prepared.updates))
|
||||
self.runner.start_one_dragon.assert_awaited_once_with("直播系统自动每日")
|
||||
|
||||
async def test_daily_commission_reader_failure_does_not_start_billed_task(self):
|
||||
prepared = self._prepared_daily(requires_current_party=True)
|
||||
reader = PreparedCurrentPartyRead(
|
||||
request_id="daily-run",
|
||||
group_name="直播系统读取当前队伍",
|
||||
status_path=self.root / "status.json",
|
||||
updates=(),
|
||||
)
|
||||
with (
|
||||
patch("app.danmu_queue.prepare_daily_run", return_value=prepared),
|
||||
patch("app.danmu_queue.prepare_current_party_read", return_value=reader),
|
||||
patch.object(
|
||||
self.handler,
|
||||
"_capture_current_party_name",
|
||||
new=AsyncMock(side_effect=CurrentPartyReadError("未识别到队伍名称")),
|
||||
),
|
||||
patch("app.danmu_queue.apply_json_updates") as apply_updates,
|
||||
patch("app.danmu_queue.uuid.uuid4", return_value=SimpleNamespace(hex="daily-run")),
|
||||
):
|
||||
await self.handler._cmd_daily(123, "测试用户", "委托")
|
||||
|
||||
apply_updates.assert_not_called()
|
||||
self.queue_mgr.start_group.assert_not_called()
|
||||
self.runner.start_one_dragon.assert_not_awaited()
|
||||
|
||||
async def test_current_party_capture_syncs_group_and_waits_for_matching_status(self):
|
||||
reader_update = JsonUpdate(
|
||||
self.root / "User" / "ScriptGroup" / "直播系统读取当前队伍.json",
|
||||
{"projects": []},
|
||||
"reader",
|
||||
)
|
||||
reader = PreparedCurrentPartyRead(
|
||||
request_id="request-123",
|
||||
group_name="直播系统读取当前队伍",
|
||||
status_path=self.root / "status.json",
|
||||
updates=(reader_update,),
|
||||
)
|
||||
with (
|
||||
patch("app.danmu_queue.apply_json_updates") as apply_updates,
|
||||
patch(
|
||||
"app.danmu_queue.read_current_party_status",
|
||||
return_value=CurrentPartyReadResult("好感队", ("好感队",)),
|
||||
),
|
||||
patch("app.danmu_queue.asyncio.sleep", new=AsyncMock()),
|
||||
):
|
||||
party_name = await self.handler._capture_current_party_name(reader, 45)
|
||||
|
||||
self.assertEqual(party_name, "好感队")
|
||||
self.runner.sync_js_script.assert_called_once_with("LiveCurrentParty")
|
||||
apply_updates.assert_called_once_with(reader.updates)
|
||||
self.runner.start_groups.assert_awaited_once_with(["直播系统读取当前队伍"])
|
||||
|
||||
async def test_daily_requires_confirmed_login_before_preparing_files(self):
|
||||
self.queue_mgr.state["login_status"] = "confirming"
|
||||
with patch("app.danmu_queue.prepare_daily_run") as prepare:
|
||||
await self.handler._cmd_daily(123, "测试用户", "")
|
||||
|
||||
prepare.assert_not_called()
|
||||
self.runner.kill_bgi.assert_not_awaited()
|
||||
|
||||
async def test_daily_start_failure_clears_monitor_and_enters_reset_flow(self):
|
||||
prepared = self._prepared_daily()
|
||||
self.runner.start_one_dragon.return_value = False
|
||||
reset = AsyncMock()
|
||||
self.handler.system = SimpleNamespace(_reset_wait_and_retry_login=reset)
|
||||
with (
|
||||
patch("app.danmu_queue.prepare_daily_run", return_value=prepared),
|
||||
patch("app.danmu_queue.apply_json_updates"),
|
||||
patch("app.danmu_queue.uuid.uuid4", return_value=SimpleNamespace(hex="daily-run")),
|
||||
):
|
||||
await self.handler._cmd_daily(123, "测试用户", "")
|
||||
|
||||
self.log_monitor.set_current_one_dragon.assert_has_calls(
|
||||
[call("自动每日", "daily-run"), call(None)]
|
||||
)
|
||||
reset.assert_awaited_once_with(
|
||||
123,
|
||||
"测试用户",
|
||||
"自动每日启动失败,正在关闭原神并启动扫码上号",
|
||||
)
|
||||
|
||||
async def test_switch_party_updates_group_then_reuses_group_execution(self):
|
||||
update = JsonUpdate(
|
||||
self.root / "User" / "ScriptGroup" / "切换队伍.json",
|
||||
{"projects": []},
|
||||
"test",
|
||||
)
|
||||
with (
|
||||
patch("app.danmu_queue.prepare_switch_party_update", return_value=update),
|
||||
patch("app.danmu_queue.apply_json_updates") as apply_updates,
|
||||
patch("app.danmu_queue.uuid.uuid4", return_value=SimpleNamespace(hex="party-run")),
|
||||
patch.object(
|
||||
self.handler,
|
||||
"_resolve_group_name",
|
||||
return_value={
|
||||
"success": True,
|
||||
"name": "切换队伍",
|
||||
"matched": False,
|
||||
"suggestions": [],
|
||||
},
|
||||
),
|
||||
patch.object(self.handler, "_group_uses_nahida_collect", return_value=False),
|
||||
):
|
||||
await self.handler._cmd_switch_party(123, "测试用户", "永冻队")
|
||||
|
||||
apply_updates.assert_called_once_with((update,))
|
||||
self.queue_mgr.start_group.assert_called_once_with(
|
||||
123,
|
||||
"切换队伍",
|
||||
run_id="party-run",
|
||||
)
|
||||
self.runner.start_groups.assert_awaited_once_with(["切换队伍"])
|
||||
|
||||
def test_execution_command_aliases_support_no_space_arguments(self):
|
||||
self.assertEqual(self.handler._split_command_text("执行薄荷"), ("执行", "薄荷"))
|
||||
self.assertEqual(
|
||||
self.handler._split_command_text("自动每日秘境 风本"),
|
||||
("自动每日", "秘境 风本"),
|
||||
)
|
||||
self.assertEqual(
|
||||
self.handler._split_command_text("更换队伍永冻队"),
|
||||
("更换队伍", "永冻队"),
|
||||
)
|
||||
self.assertEqual(
|
||||
self.handler._split_command_text("修改队员芙宁娜纳西妲钟离雷电将军"),
|
||||
("修改队员", "芙宁娜纳西妲钟离雷电将军"),
|
||||
)
|
||||
|
||||
def test_new_command_permissions_match_run(self):
|
||||
for key in ("daily", "switch_party", "edit_party"):
|
||||
self.assertEqual(
|
||||
COMMAND_ALLOWED_ROLE_DEFAULTS[key],
|
||||
COMMAND_ALLOWED_ROLE_DEFAULTS["run"],
|
||||
)
|
||||
|
||||
|
||||
class BetterGIRunnerOneDragonTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_start_one_dragon_uses_supported_cli_shape(self):
|
||||
runner = BetterGIRunner(
|
||||
r"C:\BetterGI\BetterGI.exe",
|
||||
r"C:\BetterGI",
|
||||
logging.getLogger("test-one-dragon-runner"),
|
||||
)
|
||||
create_process = AsyncMock(return_value=SimpleNamespace())
|
||||
with (
|
||||
patch("app.danmu_queue.asyncio.create_subprocess_exec", create_process),
|
||||
patch("app.danmu_queue.asyncio.sleep", new=AsyncMock()),
|
||||
):
|
||||
result = await runner.start_one_dragon("直播系统自动每日")
|
||||
|
||||
self.assertTrue(result)
|
||||
create_process.assert_awaited_once_with(
|
||||
r"C:\BetterGI\BetterGI.exe",
|
||||
"startOneDragon",
|
||||
"直播系统自动每日",
|
||||
cwd=r"C:\BetterGI",
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,256 @@
|
||||
import logging
|
||||
import os
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.faster_qwen_worker import (
|
||||
DEFAULT_CPU_AFFINITY_COUNT,
|
||||
DEFAULT_CPU_THREADS,
|
||||
MAX_NEW_TOKENS,
|
||||
FasterQwenWorkerClient,
|
||||
FasterQwenWorkerTimeout,
|
||||
_generation_kwargs,
|
||||
_configure_worker_environment,
|
||||
)
|
||||
|
||||
|
||||
class _FakeConnection:
|
||||
def __init__(self, responses):
|
||||
self.responses = list(responses)
|
||||
self.sent = []
|
||||
self.closed = False
|
||||
|
||||
def poll(self, _timeout):
|
||||
return bool(self.responses)
|
||||
|
||||
def recv(self):
|
||||
return self.responses.pop(0)
|
||||
|
||||
def send(self, message):
|
||||
self.sent.append(message)
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
|
||||
class _FakeChildConnection:
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
|
||||
class _FakeProcess:
|
||||
_next_pid = 4000
|
||||
|
||||
def __init__(self):
|
||||
type(self)._next_pid += 1
|
||||
self.pid = type(self)._next_pid
|
||||
self.alive = False
|
||||
self.terminated = False
|
||||
self.killed = False
|
||||
|
||||
def start(self):
|
||||
self.alive = True
|
||||
|
||||
def is_alive(self):
|
||||
return self.alive
|
||||
|
||||
def join(self, timeout=None):
|
||||
return None
|
||||
|
||||
def terminate(self):
|
||||
self.terminated = True
|
||||
self.alive = False
|
||||
|
||||
def kill(self):
|
||||
self.killed = True
|
||||
self.alive = False
|
||||
|
||||
|
||||
class _FakeContext:
|
||||
def __init__(self, connections):
|
||||
self.connections = list(connections)
|
||||
self.processes = []
|
||||
|
||||
def Pipe(self, duplex=True):
|
||||
self.assert_duplex = duplex
|
||||
return self.connections.pop(0), _FakeChildConnection()
|
||||
|
||||
def Process(self, **_kwargs):
|
||||
process = _FakeProcess()
|
||||
self.processes.append(process)
|
||||
return process
|
||||
|
||||
|
||||
def _ready(pid):
|
||||
return {
|
||||
"type": "ready",
|
||||
"pid": pid,
|
||||
"load_ms": 100,
|
||||
"warmup_ms": 50,
|
||||
"max_new_tokens": MAX_NEW_TOKENS,
|
||||
}
|
||||
|
||||
|
||||
def _probe_worker_main(connection, _settings):
|
||||
connection.send({
|
||||
"type": "ready",
|
||||
"pid": os.getpid(),
|
||||
"load_ms": 1,
|
||||
"warmup_ms": 1,
|
||||
"max_new_tokens": MAX_NEW_TOKENS,
|
||||
})
|
||||
while True:
|
||||
message = connection.recv()
|
||||
if message.get("command") == "stop":
|
||||
break
|
||||
if message.get("command") == "synthesize":
|
||||
audio = b"RIFF-spawn-probe"
|
||||
connection.send({
|
||||
"type": "result",
|
||||
"request_id": message["request_id"],
|
||||
"audio": audio,
|
||||
"duration_ms": 2,
|
||||
"bytes": len(audio),
|
||||
})
|
||||
connection.close()
|
||||
|
||||
|
||||
class FasterQwenWorkerTests(unittest.TestCase):
|
||||
def test_worker_environment_limits_native_thread_pools(self):
|
||||
names = (
|
||||
"OMP_NUM_THREADS",
|
||||
"MKL_NUM_THREADS",
|
||||
"OPENBLAS_NUM_THREADS",
|
||||
"NUMEXPR_NUM_THREADS",
|
||||
"VECLIB_MAXIMUM_THREADS",
|
||||
"BLIS_NUM_THREADS",
|
||||
)
|
||||
with patch.dict(os.environ, {}, clear=False):
|
||||
threads = _configure_worker_environment({})
|
||||
|
||||
self.assertEqual(threads, DEFAULT_CPU_THREADS)
|
||||
for name in names:
|
||||
self.assertEqual(os.environ[name], str(DEFAULT_CPU_THREADS))
|
||||
self.assertEqual(os.environ["TOKENIZERS_PARALLELISM"], "false")
|
||||
|
||||
def test_default_worker_resource_limits_are_four_threads_and_eight_cores(self):
|
||||
self.assertEqual(DEFAULT_CPU_THREADS, 4)
|
||||
self.assertEqual(DEFAULT_CPU_AFFINITY_COUNT, 8)
|
||||
|
||||
def test_worker_environment_accepts_bounded_override(self):
|
||||
with patch.dict(os.environ, {}, clear=False):
|
||||
self.assertEqual(_configure_worker_environment({"cpu_threads": 4}), 4)
|
||||
self.assertEqual(_configure_worker_environment({"cpu_threads": 99}), 8)
|
||||
|
||||
def test_generation_is_hard_limited_to_384_tokens(self):
|
||||
kwargs = _generation_kwargs(
|
||||
{"language": "Chinese", "non_streaming_mode": True},
|
||||
"测试",
|
||||
)
|
||||
|
||||
self.assertEqual(MAX_NEW_TOKENS, 384)
|
||||
self.assertEqual(kwargs["max_new_tokens"], 384)
|
||||
|
||||
def test_client_returns_worker_audio(self):
|
||||
connection = _FakeConnection([
|
||||
_ready(4100),
|
||||
{
|
||||
"type": "result",
|
||||
"request_id": "placeholder",
|
||||
"audio": b"RIFF-audio",
|
||||
"duration_ms": 1234,
|
||||
"bytes": 10,
|
||||
},
|
||||
])
|
||||
context = _FakeContext([connection])
|
||||
client = FasterQwenWorkerClient(
|
||||
{},
|
||||
logging.getLogger("test-faster-qwen-worker"),
|
||||
context=context,
|
||||
)
|
||||
|
||||
client.ensure_ready()
|
||||
request_id = "fixed-request-id"
|
||||
connection.responses[0]["request_id"] = request_id
|
||||
with patch("app.faster_qwen_worker.uuid.uuid4") as make_uuid:
|
||||
make_uuid.return_value.hex = request_id
|
||||
audio, metadata = client.synthesize("测试")
|
||||
|
||||
self.assertEqual(audio, b"RIFF-audio")
|
||||
self.assertEqual(metadata["duration_ms"], 1234)
|
||||
self.assertEqual(len(context.processes), 1)
|
||||
client.close()
|
||||
|
||||
def test_timeout_terminates_worker_and_starts_a_prewarmed_replacement(self):
|
||||
first_connection = _FakeConnection([_ready(4200)])
|
||||
replacement_connection = _FakeConnection([_ready(4300)])
|
||||
context = _FakeContext([first_connection, replacement_connection])
|
||||
client = FasterQwenWorkerClient(
|
||||
{},
|
||||
logging.getLogger("test-faster-qwen-timeout"),
|
||||
synthesis_timeout_seconds=120,
|
||||
context=context,
|
||||
)
|
||||
|
||||
with self.assertRaises(FasterQwenWorkerTimeout):
|
||||
client.synthesize("会超时的播报")
|
||||
|
||||
self.assertEqual(len(context.processes), 2)
|
||||
self.assertTrue(context.processes[0].terminated)
|
||||
self.assertTrue(context.processes[1].is_alive())
|
||||
self.assertEqual(client.worker_pid, context.processes[1].pid)
|
||||
client.close()
|
||||
|
||||
def test_real_spawned_worker_round_trip(self):
|
||||
client = FasterQwenWorkerClient(
|
||||
{},
|
||||
logging.getLogger("test-faster-qwen-spawn"),
|
||||
startup_timeout_seconds=20,
|
||||
process_target=_probe_worker_main,
|
||||
)
|
||||
|
||||
audio, metadata = client.synthesize("测试子进程")
|
||||
|
||||
self.assertEqual(audio, b"RIFF-spawn-probe")
|
||||
self.assertEqual(metadata["duration_ms"], 2)
|
||||
self.assertGreater(client.worker_pid, 0)
|
||||
client.close()
|
||||
|
||||
def test_startup_failure_backoff_blocks_immediate_retry(self):
|
||||
from app.faster_qwen_worker import (
|
||||
STARTUP_FAILURE_BACKOFF_SECONDS,
|
||||
FasterQwenWorkerError,
|
||||
)
|
||||
|
||||
# 启动永远超时(poll 返回 False),触发启动失败
|
||||
timeout_connection = _FakeConnection([])
|
||||
timeout_connection.poll = lambda _timeout: False
|
||||
context = _FakeContext([timeout_connection, _FakeConnection([_ready(4400)])])
|
||||
client = FasterQwenWorkerClient(
|
||||
{},
|
||||
logging.getLogger("test-faster-qwen-backoff"),
|
||||
startup_timeout_seconds=0.01,
|
||||
context=context,
|
||||
)
|
||||
|
||||
with self.assertRaises(FasterQwenWorkerError):
|
||||
client.ensure_ready()
|
||||
self.assertGreater(client._next_start_after, 0.0)
|
||||
|
||||
# 退避期内立即重试应直接报退避错误,且不创建新进程
|
||||
with self.assertRaises(FasterQwenWorkerError) as ctx:
|
||||
client.ensure_ready()
|
||||
self.assertIn("退避", str(ctx.exception))
|
||||
self.assertEqual(len(context.processes), 1)
|
||||
|
||||
# 退避期过后允许重新启动
|
||||
client._next_start_after = 0.0
|
||||
client.ensure_ready()
|
||||
self.assertEqual(len(context.processes), 2)
|
||||
self.assertEqual(client._next_start_after, 0.0)
|
||||
self.assertGreater(STARTUP_FAILURE_BACKOFF_SECONDS, 0)
|
||||
client.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,77 @@
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from app.danmu_queue import bilibili_gift_cny_values
|
||||
from app.stats_store import StatsStore
|
||||
|
||||
|
||||
class BilibiliGiftValueTests(unittest.TestCase):
|
||||
def test_gold_coin_conversion_uses_one_thousand_per_cny(self):
|
||||
unit_value, total_value = bilibili_gift_cny_values("gold", 100, 1)
|
||||
self.assertAlmostEqual(unit_value, 0.1)
|
||||
self.assertAlmostEqual(total_value, 0.1)
|
||||
|
||||
def test_multi_quantity_conversion_preserves_total(self):
|
||||
unit_value, total_value = bilibili_gift_cny_values("gold", 5000, 2)
|
||||
self.assertAlmostEqual(unit_value, 2.5)
|
||||
self.assertAlmostEqual(total_value, 5.0)
|
||||
|
||||
def test_silver_coin_has_no_cny_value(self):
|
||||
self.assertEqual(bilibili_gift_cny_values("silver", 5000, 1), (0.0, 0.0))
|
||||
|
||||
def test_historical_repair_is_idempotent_and_rebuilds_aggregate(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
path = Path(temp_dir) / "statistics.sqlite3"
|
||||
connection = sqlite3.connect(path)
|
||||
StatsStore._migrate(connection)
|
||||
fields = {
|
||||
"event_id": "gift-1",
|
||||
"platform": "bilibili",
|
||||
"platform_user_id": "123",
|
||||
"occurred_at_utc": "2026-07-28T00:00:00Z",
|
||||
"business_date": "2026-07-28",
|
||||
"gift_id": "1",
|
||||
"gift_name": "灯牌",
|
||||
"quantity": 1,
|
||||
"unit_value": 10.0,
|
||||
"total_value": 10.0,
|
||||
"currency": "CNY",
|
||||
"payload_json": '{"coin_type":"gold","raw_total_coin":100,"value_rule":"gold_battery_10_to_cny_1_v1"}',
|
||||
}
|
||||
StatsStore._insert(connection, "gift_events", fields)
|
||||
StatsStore._aggregate_gift(connection, fields)
|
||||
legacy_fields = dict(fields)
|
||||
legacy_fields.update({
|
||||
"event_id": "gift-legacy",
|
||||
"occurred_at_utc": "2026-07-28T00:01:00Z",
|
||||
"payload_json": '{"coin_type":"gold","raw_total_coin":100,"value_migration":"bilibili_coin_to_cny_v1"}',
|
||||
})
|
||||
StatsStore._insert(connection, "gift_events", legacy_fields)
|
||||
StatsStore._aggregate_gift(connection, legacy_fields)
|
||||
connection.commit()
|
||||
|
||||
first = StatsStore._repair_gift_value_history(connection)
|
||||
second = StatsStore._repair_gift_value_history(connection)
|
||||
|
||||
event = connection.execute(
|
||||
"SELECT unit_value, total_value, json_extract(payload_json, '$.value_rule') "
|
||||
"FROM gift_events WHERE event_id='gift-1'"
|
||||
).fetchone()
|
||||
aggregate = connection.execute(
|
||||
"SELECT quantity, total_value FROM gift_aggregates"
|
||||
).fetchone()
|
||||
connection.close()
|
||||
|
||||
self.assertEqual(first, 2)
|
||||
self.assertEqual(second, 0)
|
||||
self.assertAlmostEqual(event[0], 0.1)
|
||||
self.assertAlmostEqual(event[1], 0.1)
|
||||
self.assertEqual(event[2], "bilibili_gold_coin_1000_to_cny_1_v2")
|
||||
self.assertEqual(aggregate[0], 2)
|
||||
self.assertAlmostEqual(aggregate[1], 0.2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,242 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from datetime import datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from app.danmu_queue import CommandHandler, QueueManager, QueueSystem
|
||||
|
||||
|
||||
class IdleDefaultQueueStateTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp_dir = tempfile.TemporaryDirectory()
|
||||
self.manager = QueueManager(self.temp_dir.name, logging.getLogger("idle-default-test"))
|
||||
self.manager.state["has_user_finished_once"] = True
|
||||
|
||||
def tearDown(self):
|
||||
self.temp_dir.cleanup()
|
||||
|
||||
def _start_default_with_waiting_admin(self, uid=101):
|
||||
self.manager.state.update({
|
||||
"default_running": True,
|
||||
"current_group": "薄荷",
|
||||
"current_group_run_id": "default-run",
|
||||
"group_start_time": datetime.now().isoformat(),
|
||||
"billing_uid": None,
|
||||
})
|
||||
self.manager.join_queue(uid)
|
||||
|
||||
def test_default_completion_preserves_newly_joined_admin(self):
|
||||
self._start_default_with_waiting_admin()
|
||||
original_window = self.manager.state["admin_window_end"]
|
||||
|
||||
result = self.manager.group_finished("default-run")
|
||||
|
||||
self.assertTrue(result["accepted"])
|
||||
self.assertTrue(result["was_default_running"])
|
||||
self.assertTrue(result["need_default"])
|
||||
self.assertEqual(self.manager.state["queue"], [101])
|
||||
self.assertEqual(self.manager.state["current_admin_uid"], 101)
|
||||
self.assertEqual(self.manager.state["admin_window_end"], original_window)
|
||||
self.assertFalse(self.manager.state["default_running"])
|
||||
self.assertIsNone(self.manager.state["current_group"])
|
||||
|
||||
def test_admin_window_can_expire_while_default_group_runs(self):
|
||||
self._start_default_with_waiting_admin()
|
||||
self.manager.state["admin_window_end"] = time.time() - 1
|
||||
|
||||
result = self.manager.check_admin_window_timeout()
|
||||
|
||||
self.assertTrue(result["timeout"])
|
||||
self.assertEqual(result["kicked_uid"], 101)
|
||||
self.assertEqual(self.manager.state["queue"], [])
|
||||
self.assertTrue(self.manager.state["default_running"])
|
||||
self.assertEqual(self.manager.state["current_group"], "薄荷")
|
||||
|
||||
def test_waiting_admin_leave_preserves_default_group(self):
|
||||
self._start_default_with_waiting_admin()
|
||||
|
||||
result = self.manager.leave_queue(101)
|
||||
|
||||
self.assertTrue(result["success"])
|
||||
self.assertTrue(self.manager.state["default_running"])
|
||||
self.assertEqual(self.manager.state["current_group"], "薄荷")
|
||||
self.assertEqual(self.manager.state["current_group_run_id"], "default-run")
|
||||
|
||||
|
||||
class IdleDefaultFlowTests(unittest.IsolatedAsyncioTestCase):
|
||||
@staticmethod
|
||||
def _make_system(manager, runner):
|
||||
system = QueueSystem.__new__(QueueSystem)
|
||||
system.queue_mgr = manager
|
||||
system.user_mgr = SimpleNamespace(
|
||||
users={"101": {"uname": "tester"}},
|
||||
get_points=lambda _uid: 10,
|
||||
)
|
||||
system.config = SimpleNamespace(default_group="薄荷")
|
||||
system.runner = runner
|
||||
system.log_monitor = SimpleNamespace(set_current_group=MagicMock())
|
||||
system.login_monitor = SimpleNamespace(reset=MagicMock())
|
||||
system.stats_store = None
|
||||
system.logger = logging.getLogger("idle-default-race-test")
|
||||
system.broadcast = AsyncMock()
|
||||
system._default_start_lock = asyncio.Lock()
|
||||
return system
|
||||
|
||||
@staticmethod
|
||||
def _make_handler(system):
|
||||
handler = CommandHandler.__new__(CommandHandler)
|
||||
handler.config = SimpleNamespace(admin_uids=[])
|
||||
handler.queue_mgr = system.queue_mgr
|
||||
handler.user_mgr = system.user_mgr
|
||||
handler.runner = system.runner
|
||||
handler.log_monitor = system.log_monitor
|
||||
handler.login_monitor = system.login_monitor
|
||||
handler.logger = system.logger
|
||||
handler.stats_store = None
|
||||
handler.system = system
|
||||
handler.broadcast = AsyncMock()
|
||||
return handler
|
||||
|
||||
async def test_login_serializes_with_default_group_start(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
manager = QueueManager(temp_dir, logging.getLogger("idle-default-login-race"))
|
||||
manager.state["has_user_finished_once"] = True
|
||||
manager.join_queue(101)
|
||||
manager.state.update({
|
||||
"default_running": True,
|
||||
"current_group": "薄荷",
|
||||
"current_group_run_id": "default-run",
|
||||
"group_start_time": datetime.now().isoformat(),
|
||||
})
|
||||
manager._save()
|
||||
|
||||
kill_started = asyncio.Event()
|
||||
allow_kill = asyncio.Event()
|
||||
|
||||
async def blocked_kill(**_kwargs):
|
||||
kill_started.set()
|
||||
await allow_kill.wait()
|
||||
return True
|
||||
|
||||
runner = SimpleNamespace(
|
||||
kill_bgi=AsyncMock(side_effect=blocked_kill),
|
||||
start_groups=AsyncMock(return_value=True),
|
||||
)
|
||||
system = self._make_system(manager, runner)
|
||||
handler = self._make_handler(system)
|
||||
|
||||
login_task = asyncio.create_task(handler._cmd_login(101, "tester"))
|
||||
await kill_started.wait()
|
||||
default_task = asyncio.create_task(system._start_default_group())
|
||||
await asyncio.sleep(0)
|
||||
allow_kill.set()
|
||||
await asyncio.gather(login_task, default_task)
|
||||
|
||||
self.assertEqual(manager.state["login_status"], "logining")
|
||||
self.assertFalse(manager.state["default_running"])
|
||||
self.assertIsNone(manager.state["current_group"])
|
||||
runner.start_groups.assert_awaited_once_with(["扫码上号"])
|
||||
|
||||
async def test_login_watchdog_runs_even_if_default_state_leaks(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
manager = QueueManager(temp_dir, logging.getLogger("login-watchdog-default-leak"))
|
||||
manager.join_queue(101)
|
||||
manager.state.update({
|
||||
"login_status": "logining",
|
||||
"login_started_at": (datetime.now() - timedelta(seconds=300)).isoformat(),
|
||||
"default_running": True,
|
||||
"current_group": "薄荷",
|
||||
})
|
||||
manager._save()
|
||||
system = self._make_system(manager, SimpleNamespace())
|
||||
system._handle_login_timeout = AsyncMock()
|
||||
|
||||
with patch(
|
||||
"app.danmu_queue.asyncio.sleep",
|
||||
side_effect=[None, asyncio.CancelledError()],
|
||||
):
|
||||
with self.assertRaises(asyncio.CancelledError):
|
||||
await system._check_login_watchdog_loop()
|
||||
|
||||
system._handle_login_timeout.assert_awaited_once_with(101, "tester")
|
||||
|
||||
async def test_user_group_completion_starts_default_while_next_admin_waits(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
manager = QueueManager(temp_dir, logging.getLogger("idle-default-flow-test"))
|
||||
manager.state.update({
|
||||
"queue": [1, 2],
|
||||
"current_admin_uid": 1,
|
||||
"current_group": "晶蝶",
|
||||
"current_group_run_id": "user-run",
|
||||
"group_start_time": (datetime.now() - timedelta(seconds=200)).isoformat(),
|
||||
"login_status": "logged_in",
|
||||
"billing_uid": 1,
|
||||
"billing_started_at": datetime.now().isoformat(),
|
||||
"billing_last_at": datetime.now().isoformat(),
|
||||
"has_user_finished_once": True,
|
||||
})
|
||||
manager._save()
|
||||
|
||||
system = QueueSystem.__new__(QueueSystem)
|
||||
system.queue_mgr = manager
|
||||
system.user_mgr = SimpleNamespace(
|
||||
users={"1": {"uname": "first"}, "2": {"uname": "second"}},
|
||||
get_points=lambda _uid: 10,
|
||||
)
|
||||
system.log_monitor = SimpleNamespace(set_current_group=MagicMock())
|
||||
system.logger = logging.getLogger("idle-default-flow-test")
|
||||
system.broadcast = AsyncMock()
|
||||
system._start_default_group = AsyncMock()
|
||||
|
||||
await system._on_group_finished("晶蝶", "user-run")
|
||||
|
||||
self.assertEqual(manager.state["queue"], [2])
|
||||
self.assertEqual(manager.state["current_admin_uid"], 2)
|
||||
self.assertIsNone(manager.state["login_status"])
|
||||
system._start_default_group.assert_awaited_once_with()
|
||||
|
||||
async def test_default_group_can_start_with_waiting_admin(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
manager = QueueManager(temp_dir, logging.getLogger("idle-default-start-test"))
|
||||
manager.state["has_user_finished_once"] = True
|
||||
manager.join_queue(101)
|
||||
original_window = manager.state["admin_window_end"]
|
||||
|
||||
system = QueueSystem.__new__(QueueSystem)
|
||||
system.queue_mgr = manager
|
||||
system.config = SimpleNamespace(default_group="薄荷")
|
||||
system.user_mgr = SimpleNamespace(
|
||||
users={"101": {"uname": "队首昵称"}},
|
||||
_is_masked_uname=lambda name: (not name) or ("*" in name),
|
||||
resolve_uname=AsyncMock(return_value=""),
|
||||
)
|
||||
system.runner = SimpleNamespace(
|
||||
kill_bgi=AsyncMock(),
|
||||
start_groups=AsyncMock(return_value=True),
|
||||
)
|
||||
system.log_monitor = SimpleNamespace(set_current_group=MagicMock())
|
||||
system.stats_store = None
|
||||
system.logger = logging.getLogger("idle-default-start-test")
|
||||
system.broadcast = AsyncMock()
|
||||
system._default_start_lock = asyncio.Lock()
|
||||
|
||||
await system._start_default_group()
|
||||
|
||||
self.assertTrue(manager.state["default_running"])
|
||||
self.assertEqual(manager.state["current_group"], "薄荷")
|
||||
self.assertEqual(manager.state["current_admin_uid"], 101)
|
||||
self.assertEqual(manager.state["admin_window_end"], original_window)
|
||||
self.assertIsNone(manager.state["billing_uid"])
|
||||
system.runner.start_groups.assert_awaited_once_with(["薄荷"])
|
||||
# 播报文案应使用昵称而非纯数字 UID
|
||||
broadcast_texts = [str(call.args[0]) for call in system.broadcast.await_args_list]
|
||||
self.assertTrue(any("队首昵称" in t for t in broadcast_texts))
|
||||
self.assertFalse(any("队首101" in t for t in broadcast_texts))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,73 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from app.mpv_player import MpvPlayer
|
||||
|
||||
|
||||
class _FakePipe:
|
||||
def __init__(self):
|
||||
self.closed = False
|
||||
self.response = b""
|
||||
|
||||
def write(self, request):
|
||||
payload = json.loads(request.decode("utf-8"))
|
||||
self.response = json.dumps({
|
||||
"request_id": payload["request_id"],
|
||||
"error": "success",
|
||||
"data": payload["command"][1],
|
||||
}).encode("utf-8") + b"\n"
|
||||
|
||||
def readline(self):
|
||||
response, self.response = self.response, b""
|
||||
return response
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
|
||||
class MpvPipeTests(unittest.TestCase):
|
||||
def test_pipe_connection_is_reused_across_commands(self):
|
||||
player = MpvPlayer("mpv.exe", logging.getLogger("test-mpv"))
|
||||
fake_pipe = _FakePipe()
|
||||
|
||||
with patch("builtins.open", return_value=fake_pipe) as open_pipe:
|
||||
self.assertEqual(player._pipe_request_sync(["get_property", "duration"], 1), "duration")
|
||||
self.assertEqual(player._pipe_request_sync(["get_property", "time-pos"], 2), "time-pos")
|
||||
|
||||
open_pipe.assert_called_once()
|
||||
player._reset_pipe_sync()
|
||||
self.assertTrue(fake_pipe.closed)
|
||||
|
||||
|
||||
class MpvMaintainTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_maintain_returns_snapshot_for_caller_reuse(self):
|
||||
player = MpvPlayer("mpv.exe", logging.getLogger("test-mpv-maintain"))
|
||||
player.current_url = "https://example.invalid/audio"
|
||||
player.desired_state = "playing"
|
||||
player.started_at = time.time() - 10
|
||||
player.last_progress_at = time.time()
|
||||
state = {
|
||||
"playing": True,
|
||||
"paused": False,
|
||||
"idle": False,
|
||||
"eof": False,
|
||||
"path": player.current_url,
|
||||
"current": {"progress": 10.0, "duration": 100.0},
|
||||
}
|
||||
|
||||
with patch.object(player, "running", return_value=True), patch.object(
|
||||
player, "snapshot", AsyncMock(return_value=state)
|
||||
) as snapshot:
|
||||
result = await player.maintain()
|
||||
|
||||
self.assertEqual(result["action"], "none")
|
||||
self.assertIs(result["snapshot"], state)
|
||||
snapshot.assert_awaited_once()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,136 @@
|
||||
import logging
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from app.netease_qr_login import NeteaseQrLogin, _weapi_form
|
||||
|
||||
|
||||
class _Headers:
|
||||
def __init__(self, set_cookie=None):
|
||||
self.set_cookie = list(set_cookie or [])
|
||||
|
||||
def get_all(self, name, default=None):
|
||||
if name.lower() == "set-cookie":
|
||||
return self.set_cookie
|
||||
return default or []
|
||||
|
||||
|
||||
class _Response:
|
||||
def __init__(self, set_cookie=None):
|
||||
self.headers = _Headers(set_cookie)
|
||||
|
||||
|
||||
class NeteaseQrLoginTests(unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self):
|
||||
self.saved_music_u = ""
|
||||
self.save_callback = AsyncMock(side_effect=self._save)
|
||||
self.account_checker = AsyncMock(return_value={
|
||||
"authenticated": True,
|
||||
"user_id": "10086",
|
||||
"nickname": "测试账号",
|
||||
"vip_type": 11,
|
||||
})
|
||||
self.login = NeteaseQrLogin(
|
||||
api_base=lambda: "https://music.163.com",
|
||||
get_saved_music_u=lambda: self.saved_music_u,
|
||||
save_music_u=self.save_callback,
|
||||
account_checker=self.account_checker,
|
||||
logger=logging.getLogger("test-netease-qr"),
|
||||
)
|
||||
|
||||
async def _save(self, music_u, _account):
|
||||
self.saved_music_u = music_u
|
||||
|
||||
async def _start(self):
|
||||
with patch(
|
||||
"app.netease_qr_login._request_weapi_json",
|
||||
return_value=({"code": 200, "unikey": "private-key"}, _Response()),
|
||||
):
|
||||
return await self.login.start()
|
||||
|
||||
def test_weapi_form_encrypts_payload(self):
|
||||
result = _weapi_form(
|
||||
{"type": 1, "csrf_token": ""},
|
||||
secret_key="0123456789abcdef",
|
||||
)
|
||||
|
||||
self.assertEqual(set(result), {"params", "encSecKey"})
|
||||
self.assertEqual(len(result["encSecKey"]), 256)
|
||||
self.assertNotIn("csrf_token", result["params"])
|
||||
|
||||
async def test_start_keeps_key_on_server(self):
|
||||
with patch(
|
||||
"app.netease_qr_login._request_weapi_json",
|
||||
return_value=({"code": 200, "unikey": "private-key"}, _Response()),
|
||||
) as request_json:
|
||||
result = await self.login.start()
|
||||
|
||||
self.assertEqual(result["state"], "awaiting_scan")
|
||||
self.assertTrue(result["has_qr_image"])
|
||||
self.assertNotIn("key", result)
|
||||
self.assertNotIn("qr_url", result)
|
||||
self.assertFalse(result["credential_configured"])
|
||||
self.assertTrue(request_json.call_args.args[0].endswith("/weapi/login/qrcode/unikey"))
|
||||
self.assertEqual(request_json.call_args.args[1], {"type": 1, "csrf_token": ""})
|
||||
self.assertEqual(
|
||||
self.login._session["qr_url"],
|
||||
"http://music.163.com/login?codekey=private-key",
|
||||
)
|
||||
|
||||
async def test_poll_reports_scan_and_confirmation_states(self):
|
||||
await self._start()
|
||||
with patch(
|
||||
"app.netease_qr_login._request_weapi_json",
|
||||
side_effect=[
|
||||
({"code": 801, "message": "等待扫码"}, _Response()),
|
||||
({"code": 802, "message": "待确认"}, _Response()),
|
||||
],
|
||||
) as request_json:
|
||||
waiting = await self.login.poll()
|
||||
confirming = await self.login.poll()
|
||||
|
||||
self.assertEqual(waiting["state"], "awaiting_scan")
|
||||
self.assertEqual(confirming["state"], "awaiting_confirm")
|
||||
self.assertTrue(
|
||||
request_json.call_args_list[0].args[0].endswith(
|
||||
"/weapi/login/qrcode/client/login"
|
||||
)
|
||||
)
|
||||
self.assertEqual(
|
||||
request_json.call_args_list[0].args[1],
|
||||
{"key": "private-key", "type": 1, "csrf_token": ""},
|
||||
)
|
||||
self.save_callback.assert_not_awaited()
|
||||
|
||||
async def test_success_validates_and_auto_saves_music_u(self):
|
||||
await self._start()
|
||||
response = _Response(["MUSIC_U=private-music-u; Path=/; HttpOnly; SameSite=None"])
|
||||
with patch(
|
||||
"app.netease_qr_login._request_weapi_json",
|
||||
return_value=({"code": 803, "message": "授权登录成功"}, response),
|
||||
):
|
||||
result = await self.login.poll()
|
||||
|
||||
self.assertEqual(result["state"], "completed")
|
||||
self.assertTrue(result["credential_configured"])
|
||||
self.assertEqual(result["account"]["nickname"], "测试账号")
|
||||
self.assertNotIn("music_u", result)
|
||||
self.account_checker.assert_awaited_once_with("https://music.163.com", "private-music-u")
|
||||
self.save_callback.assert_awaited_once()
|
||||
self.assertEqual(self.saved_music_u, "private-music-u")
|
||||
|
||||
async def test_success_without_music_u_is_rejected(self):
|
||||
await self._start()
|
||||
with patch(
|
||||
"app.netease_qr_login._request_weapi_json",
|
||||
return_value=({"code": 803}, _Response()),
|
||||
):
|
||||
result = await self.login.poll()
|
||||
|
||||
self.assertEqual(result["state"], "failed")
|
||||
self.assertIn("缺少 MUSIC_U", result["message"])
|
||||
self.save_callback.assert_not_awaited()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,41 @@
|
||||
import logging
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from app.netease_resolver import NeteaseResolver
|
||||
|
||||
|
||||
class NeteaseResolverTests(unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self):
|
||||
self.resolver = NeteaseResolver(logging.getLogger("test-netease"))
|
||||
|
||||
async def test_rejects_trial_audio_instead_of_playing_preview(self):
|
||||
self.resolver._fetch_player_entry = AsyncMock(return_value={
|
||||
"url": "https://example.test/trial.mp3",
|
||||
"freeTrialInfo": {"start": 0, "end": 15000},
|
||||
})
|
||||
self.resolver._probe_url = AsyncMock(return_value="https://example.test/trial.mp3")
|
||||
|
||||
result = await self.resolver.resolve({"id": "123456", "name": "VIP歌曲"})
|
||||
|
||||
self.assertIsNone(result)
|
||||
self.assertEqual(self.resolver.last_error_code, "preview_only")
|
||||
self.resolver._probe_url.assert_not_awaited()
|
||||
|
||||
async def test_uses_authenticated_full_player_url(self):
|
||||
self.resolver.update_auth("valid-cookie")
|
||||
self.resolver._fetch_player_entry = AsyncMock(return_value={
|
||||
"url": "https://example.test/full.mp3",
|
||||
"freeTrialInfo": None,
|
||||
"freeTrialPrivilege": {"resConsumable": False, "userConsumable": False},
|
||||
})
|
||||
self.resolver._probe_url = AsyncMock(return_value="https://cdn.test/full.mp3")
|
||||
|
||||
result = await self.resolver.resolve({"id": "123456", "name": "VIP歌曲"})
|
||||
|
||||
self.assertEqual(result["url"], "https://cdn.test/full.mp3")
|
||||
self.assertEqual(result["source"], "netease.player.auth")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,105 @@
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.stats_store import StatsStore, business_date
|
||||
|
||||
|
||||
class StatsStoreDurationRepairTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp_dir = tempfile.TemporaryDirectory()
|
||||
self.database_path = Path(self.temp_dir.name) / "statistics.sqlite3"
|
||||
self.connection = sqlite3.connect(self.database_path)
|
||||
StatsStore._migrate(self.connection)
|
||||
self.store = StatsStore(self.database_path)
|
||||
|
||||
def tearDown(self):
|
||||
self.connection.close()
|
||||
self.temp_dir.cleanup()
|
||||
|
||||
def _insert_bilibili(self, connection_id, started, ended=None, reason=None):
|
||||
self.connection.execute(
|
||||
"INSERT INTO bilibili_connections ("
|
||||
"connection_id, connected_at_utc, disconnected_at_utc, business_date, status, disconnect_reason"
|
||||
") VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(connection_id, started, ended, business_date(started), "failed" if ended else "connected", reason),
|
||||
)
|
||||
StatsStore._upsert_live_session(self.connection, {
|
||||
"session_id": connection_id,
|
||||
"kind": "bilibili_connection",
|
||||
"started_at_utc": started,
|
||||
"ended_at_utc": ended,
|
||||
"status": "failed" if ended else "connected",
|
||||
"source": "bilibili_connections",
|
||||
})
|
||||
|
||||
def test_daily_query_merges_overlapping_lifecycles(self):
|
||||
self._insert_bilibili("b1", "2026-07-16T00:00:00Z", "2026-07-16T02:00:00Z")
|
||||
self._insert_bilibili("b2", "2026-07-16T01:00:00Z", "2026-07-16T03:00:00Z")
|
||||
self.connection.execute(
|
||||
"INSERT INTO group_runs (group_run_id, started_at_utc, ended_at_utc, business_date, status) "
|
||||
"VALUES ('g1', '2026-07-16T00:00:00Z', '2026-07-16T02:00:00Z', '2026-07-16', 'completed')"
|
||||
)
|
||||
self.connection.execute(
|
||||
"INSERT INTO group_runs (group_run_id, started_at_utc, ended_at_utc, business_date, status) "
|
||||
"VALUES ('g2', '2026-07-16T01:00:00Z', '2026-07-16T04:00:00Z', '2026-07-16', 'completed')"
|
||||
)
|
||||
self.connection.commit()
|
||||
|
||||
row = self.store._query_daily_sync("2026-07-16", "2026-07-16")[0]
|
||||
|
||||
self.assertEqual(row["bilibili_connection_duration_ms"], 3 * 60 * 60 * 1000)
|
||||
self.assertEqual(row["group_run_total_duration_ms"], 4 * 60 * 60 * 1000)
|
||||
|
||||
def test_daily_query_splits_at_beijing_0400_boundary(self):
|
||||
self.connection.execute(
|
||||
"INSERT INTO group_runs (group_run_id, started_at_utc, ended_at_utc, business_date, status) "
|
||||
"VALUES ('g1', '2026-07-16T19:30:00Z', '2026-07-16T20:30:00Z', '2026-07-16', 'completed')"
|
||||
)
|
||||
self.connection.commit()
|
||||
|
||||
rows = self.store._query_daily_sync("2026-07-16", "2026-07-17")
|
||||
by_day = {row["business_date"]: row for row in rows}
|
||||
|
||||
self.assertEqual(by_day["2026-07-16"]["group_run_total_duration_ms"], 30 * 60 * 1000)
|
||||
self.assertEqual(by_day["2026-07-17"]["group_run_total_duration_ms"], 30 * 60 * 1000)
|
||||
|
||||
def test_reconciliation_ends_stale_connection_at_next_start(self):
|
||||
self._insert_bilibili("b1", "2026-07-16T00:00:00Z")
|
||||
self._insert_bilibili("b2", "2026-07-16T00:05:00Z")
|
||||
self.connection.commit()
|
||||
|
||||
with patch.object(StatsStore, "_pid_is_running", return_value=False):
|
||||
StatsStore._reconcile_stale_lifecycles(self.connection)
|
||||
|
||||
first = self.connection.execute(
|
||||
"SELECT disconnected_at_utc, disconnect_reason FROM bilibili_connections WHERE connection_id='b1'"
|
||||
).fetchone()
|
||||
self.assertEqual(first, ("2026-07-16T00:05:00Z", "startup_reconciliation"))
|
||||
|
||||
def test_historical_repair_is_idempotent_and_rebuilds_daily_duration(self):
|
||||
self._insert_bilibili(
|
||||
"b1", "2026-07-16T00:00:00Z", "2026-07-17T00:00:00Z", "startup_reconciliation"
|
||||
)
|
||||
self._insert_bilibili("b2", "2026-07-16T00:05:00Z", "2026-07-16T00:10:00Z")
|
||||
self.connection.commit()
|
||||
|
||||
first = StatsStore._repair_startup_reconciliation_history(self.connection)
|
||||
second = StatsStore._repair_startup_reconciliation_history(self.connection)
|
||||
|
||||
ended = self.connection.execute(
|
||||
"SELECT disconnected_at_utc FROM bilibili_connections WHERE connection_id='b1'"
|
||||
).fetchone()[0]
|
||||
duration = self.connection.execute(
|
||||
"SELECT SUM(duration_ms) FROM live_session_daily_durations WHERE session_id='b1'"
|
||||
).fetchone()[0]
|
||||
self.assertEqual(first["bilibili_connections"], 1)
|
||||
self.assertEqual(second["bilibili_connections"], 0)
|
||||
self.assertEqual(ended, "2026-07-16T00:05:00Z")
|
||||
self.assertEqual(duration, 5 * 60 * 1000)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,159 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import unittest
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from app.danmu_queue import SystemScheduler
|
||||
|
||||
|
||||
class _FakeConfig:
|
||||
def __init__(self, system_cfg):
|
||||
self.system_cfg = system_cfg
|
||||
|
||||
|
||||
class RebootAfterStopTests(unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self):
|
||||
self.logger = logging.getLogger("test-system-scheduler")
|
||||
self.scheduler = SystemScheduler(
|
||||
_FakeConfig({
|
||||
"reboot_after_stop_enabled": True,
|
||||
"reboot_after_stop_delay_sec": 60,
|
||||
}),
|
||||
self.logger,
|
||||
)
|
||||
|
||||
@patch("app.danmu_queue.os.name", "nt")
|
||||
@patch("app.danmu_queue.subprocess.Popen")
|
||||
def test_schedules_one_minute_reboot_only_once(self, popen):
|
||||
stopped_at = datetime(2026, 7, 23, 23, 0)
|
||||
|
||||
self.assertTrue(self.scheduler._schedule_reboot_after_stop(stopped_at))
|
||||
self.assertFalse(self.scheduler._schedule_reboot_after_stop(stopped_at))
|
||||
|
||||
popen.assert_called_once()
|
||||
command = popen.call_args.args[0]
|
||||
self.assertEqual(command[:5], ["shutdown", "/r", "/t", "60", "/c"])
|
||||
|
||||
async def test_failed_stop_still_schedules_reboot(self):
|
||||
self.scheduler._live_occurrences = MagicMock(
|
||||
return_value=[("stop", datetime.now().replace(second=0, microsecond=0))]
|
||||
)
|
||||
self.scheduler.stop_bilibili_live = AsyncMock(return_value=False)
|
||||
self.scheduler._schedule_reboot_after_stop = MagicMock()
|
||||
self.scheduler._end_live_statistics = MagicMock()
|
||||
|
||||
async def stop_after_first_sleep(_seconds):
|
||||
self.scheduler._stop = True
|
||||
|
||||
with patch("app.danmu_queue.asyncio.sleep", side_effect=stop_after_first_sleep):
|
||||
await self.scheduler.run()
|
||||
|
||||
self.scheduler._schedule_reboot_after_stop.assert_called_once()
|
||||
|
||||
async def test_stop_exception_still_schedules_reboot(self):
|
||||
self.scheduler._live_occurrences = MagicMock(
|
||||
return_value=[("stop", datetime.now().replace(second=0, microsecond=0))]
|
||||
)
|
||||
self.scheduler.stop_bilibili_live = AsyncMock(side_effect=RuntimeError("click failed"))
|
||||
self.scheduler._schedule_reboot_after_stop = MagicMock()
|
||||
self.scheduler._end_live_statistics = MagicMock()
|
||||
|
||||
async def stop_after_first_sleep(_seconds):
|
||||
self.scheduler._stop = True
|
||||
|
||||
with patch("app.danmu_queue.asyncio.sleep", side_effect=stop_after_first_sleep):
|
||||
await self.scheduler.run()
|
||||
|
||||
self.scheduler._schedule_reboot_after_stop.assert_called_once()
|
||||
|
||||
|
||||
class StartupLiveCompensationTests(unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self):
|
||||
self.logger = logging.getLogger("test-startup-live-compensation")
|
||||
self.scheduler = SystemScheduler(
|
||||
_FakeConfig({
|
||||
"live_start_time": "09:00",
|
||||
"live_end_time": "23:00",
|
||||
"launch_bilibili_live_enabled": True,
|
||||
"launch_genshin_enabled": True,
|
||||
"bilibili_push_enabled": True,
|
||||
}),
|
||||
self.logger,
|
||||
)
|
||||
self.start_at = datetime.now() - timedelta(hours=1)
|
||||
self.end_at = datetime.now() + timedelta(hours=1)
|
||||
self.scheduler._startup_window_active = MagicMock(return_value=True)
|
||||
self.scheduler._is_exe_running = MagicMock(return_value=False)
|
||||
self.scheduler._launch_exe = AsyncMock(side_effect=[True, True])
|
||||
self.scheduler.push_bilibili_live = AsyncMock(return_value=True)
|
||||
|
||||
async def test_launches_apps_after_one_minute_then_pushes_after_another(self):
|
||||
with patch("app.danmu_queue.asyncio.sleep", new_callable=AsyncMock) as sleep:
|
||||
await self.scheduler._run_startup_live_compensation(self.start_at, self.end_at)
|
||||
|
||||
self.assertEqual(
|
||||
[call.args[0] for call in sleep.await_args_list],
|
||||
[60, 60],
|
||||
)
|
||||
self.assertEqual(
|
||||
[call.args for call in self.scheduler._launch_exe.await_args_list],
|
||||
[("B站直播姬", "bilibili_live_exe"), ("原神", "genshin_exe")],
|
||||
)
|
||||
self.scheduler.push_bilibili_live.assert_awaited_once()
|
||||
|
||||
async def test_skips_push_when_livehime_was_already_running(self):
|
||||
self.scheduler._is_exe_running = MagicMock(return_value=True)
|
||||
self.scheduler._launch_exe = AsyncMock(side_effect=[False, False])
|
||||
|
||||
with patch("app.danmu_queue.asyncio.sleep", new_callable=AsyncMock):
|
||||
await self.scheduler._run_startup_live_compensation(self.start_at, self.end_at)
|
||||
|
||||
self.scheduler.push_bilibili_live.assert_not_awaited()
|
||||
|
||||
async def test_cancels_before_push_when_live_window_ends(self):
|
||||
self.scheduler._startup_window_active = MagicMock(side_effect=[True, False])
|
||||
|
||||
with patch("app.danmu_queue.asyncio.sleep", new_callable=AsyncMock):
|
||||
await self.scheduler._run_startup_live_compensation(self.start_at, self.end_at)
|
||||
|
||||
self.scheduler.push_bilibili_live.assert_not_awaited()
|
||||
|
||||
async def test_scheduling_marks_prepare_and_start_to_avoid_duplicate_clicks(self):
|
||||
with patch.object(self.scheduler, "_run_startup_live_compensation", new_callable=AsyncMock):
|
||||
scheduled = self.scheduler._schedule_startup_live_compensation(self.start_at, self.end_at)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
if self.scheduler._startup_compensation_task:
|
||||
await self.scheduler._startup_compensation_task
|
||||
|
||||
self.assertTrue(scheduled)
|
||||
self.assertFalse(self.scheduler._schedule_startup_live_compensation(self.start_at, self.end_at))
|
||||
self.assertIn(
|
||||
self.scheduler._event_key("prepare_tts", self.start_at - timedelta(minutes=15)),
|
||||
self.scheduler._triggered_events,
|
||||
)
|
||||
self.assertIn(
|
||||
self.scheduler._event_key("prepare", self.start_at - timedelta(minutes=10)),
|
||||
self.scheduler._triggered_events,
|
||||
)
|
||||
self.assertIn(
|
||||
self.scheduler._event_key("start", self.start_at),
|
||||
self.scheduler._triggered_events,
|
||||
)
|
||||
|
||||
def test_current_live_window_supports_cross_midnight_schedule(self):
|
||||
self.scheduler.config.system_cfg["live_start_time"] = "23:00"
|
||||
self.scheduler.config.system_cfg["live_end_time"] = "02:00"
|
||||
current = datetime(2026, 7, 31, 1, 0)
|
||||
|
||||
window = self.scheduler._current_live_window(current)
|
||||
|
||||
self.assertEqual(
|
||||
window,
|
||||
(datetime(2026, 7, 30, 23, 0), datetime(2026, 7, 31, 2, 0)),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,200 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
import numpy as np
|
||||
|
||||
from app.danmu_queue import Broadcaster, TTSEngine, _BoundedPriorityQueue, _StreamingAudioBuffer
|
||||
|
||||
|
||||
class BoundedPriorityQueueTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_returns_high_priority_first(self):
|
||||
queue = _BoundedPriorityQueue(4)
|
||||
await queue.put({"priority": 2, "sequence": 1, "name": "normal"})
|
||||
await queue.put({"priority": 0, "sequence": 2, "name": "urgent"})
|
||||
|
||||
self.assertEqual((await queue.get())["name"], "urgent")
|
||||
self.assertEqual((await queue.get())["name"], "normal")
|
||||
|
||||
async def test_replaces_worst_pending_item_when_full(self):
|
||||
queue = _BoundedPriorityQueue(2)
|
||||
await queue.put({"priority": 3, "sequence": 1, "name": "low-old"})
|
||||
await queue.put({"priority": 3, "sequence": 2, "name": "low-new"})
|
||||
|
||||
accepted, dropped = await queue.put(
|
||||
{"priority": 0, "sequence": 3, "name": "urgent"}
|
||||
)
|
||||
|
||||
self.assertTrue(accepted)
|
||||
self.assertEqual(dropped["name"], "low-new")
|
||||
self.assertEqual((await queue.get())["name"], "urgent")
|
||||
self.assertEqual((await queue.get())["name"], "low-old")
|
||||
|
||||
async def test_rejects_new_item_when_it_is_not_more_valuable(self):
|
||||
queue = _BoundedPriorityQueue(1)
|
||||
await queue.put({"priority": 0, "sequence": 1, "name": "urgent"})
|
||||
|
||||
accepted, dropped = await queue.put(
|
||||
{"priority": 3, "sequence": 2, "name": "low"}
|
||||
)
|
||||
|
||||
self.assertFalse(accepted)
|
||||
self.assertIsNone(dropped)
|
||||
self.assertEqual((await queue.get())["name"], "urgent")
|
||||
|
||||
|
||||
class StreamingAudioBufferTests(unittest.TestCase):
|
||||
def test_error_unblocks_consumer(self):
|
||||
buffer = _StreamingAudioBuffer()
|
||||
error = RuntimeError("failed")
|
||||
buffer.finish(error)
|
||||
|
||||
self.assertTrue(buffer.ready.is_set())
|
||||
self.assertIs(buffer.error, error)
|
||||
self.assertIs(buffer.chunks.get_nowait(), _StreamingAudioBuffer.END)
|
||||
|
||||
|
||||
class StreamingPreparationTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_prepare_returns_after_first_chunk_before_generation_finishes(self):
|
||||
generation_gate = asyncio.Event()
|
||||
|
||||
class FakeStreamingEngine:
|
||||
streaming = True
|
||||
|
||||
async def _synthesize_to_buffer(self, text, buffer):
|
||||
buffer.put(np.zeros(128, dtype=np.float32), 24000)
|
||||
await generation_gate.wait()
|
||||
buffer.finish()
|
||||
|
||||
tts = TTSEngine.__new__(TTSEngine)
|
||||
tts._engine = FakeStreamingEngine()
|
||||
tts._synthesis_lock = asyncio.Lock()
|
||||
tts._log_event = lambda _message: None
|
||||
|
||||
prepared = await tts.prepare_request({"request_id": "test", "text": "测试"})
|
||||
|
||||
self.assertEqual(prepared["kind"], "stream")
|
||||
self.assertFalse(prepared["synth_task"].done())
|
||||
generation_gate.set()
|
||||
await prepared["synth_task"]
|
||||
|
||||
|
||||
class PipelineOverlapTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_next_item_synthesizes_while_previous_item_is_playing(self):
|
||||
first_play_started = asyncio.Event()
|
||||
release_first_play = asyncio.Event()
|
||||
second_synthesis_started = asyncio.Event()
|
||||
|
||||
class FakeTTS:
|
||||
async def prepare_request(self, request):
|
||||
if request["request_id"] == "second":
|
||||
second_synthesis_started.set()
|
||||
return {"kind": "audio", "audio": request["request_id"].encode()}
|
||||
|
||||
def mark_playing(self, request):
|
||||
return None
|
||||
|
||||
async def play_prepared(self, prepared):
|
||||
if prepared["audio"] == b"first":
|
||||
first_play_started.set()
|
||||
await release_first_play.wait()
|
||||
return True
|
||||
|
||||
def complete_request(self, request):
|
||||
return None
|
||||
|
||||
def fail_request(self, request, error, **kwargs):
|
||||
return None
|
||||
|
||||
broadcaster = Broadcaster.__new__(Broadcaster)
|
||||
broadcaster._stop = False
|
||||
broadcaster._tts_warmup_done = asyncio.Event()
|
||||
broadcaster._tts_warmup_done.set()
|
||||
broadcaster._tts_pending = _BoundedPriorityQueue(4)
|
||||
broadcaster._tts_playback_queue = asyncio.Queue(maxsize=2)
|
||||
broadcaster.tts = FakeTTS()
|
||||
broadcaster.logger = logging.getLogger("tts-pipeline-test")
|
||||
broadcaster._finish_tts_job = lambda *_args, **_kwargs: None
|
||||
|
||||
synth_worker = asyncio.create_task(broadcaster._tts_synthesis_loop())
|
||||
play_worker = asyncio.create_task(broadcaster._tts_playback_loop())
|
||||
expiry = time.monotonic() + 30
|
||||
await broadcaster._tts_pending.put({
|
||||
"priority": 1,
|
||||
"sequence": 1,
|
||||
"expires_at": expiry,
|
||||
"tts_request": {"request_id": "first"},
|
||||
})
|
||||
await broadcaster._tts_pending.put({
|
||||
"priority": 1,
|
||||
"sequence": 2,
|
||||
"expires_at": expiry,
|
||||
"tts_request": {"request_id": "second"},
|
||||
})
|
||||
|
||||
await asyncio.wait_for(first_play_started.wait(), timeout=1)
|
||||
await asyncio.wait_for(second_synthesis_started.wait(), timeout=1)
|
||||
release_first_play.set()
|
||||
|
||||
broadcaster._stop = True
|
||||
synth_worker.cancel()
|
||||
play_worker.cancel()
|
||||
await asyncio.gather(synth_worker, play_worker, return_exceptions=True)
|
||||
|
||||
|
||||
class TTSExpiryPolicyTests(unittest.TestCase):
|
||||
def test_default_expiry_windows_match_configured_policy(self):
|
||||
broadcaster = Broadcaster.__new__(Broadcaster)
|
||||
broadcaster._tts_queue_cfg = {}
|
||||
|
||||
self.assertEqual(broadcaster._tts_policy("login"), (0, 60.0))
|
||||
self.assertEqual(broadcaster._tts_policy("system"), (1, 40.0))
|
||||
self.assertEqual(broadcaster._tts_policy("queue"), (2, 40.0))
|
||||
self.assertEqual(broadcaster._tts_policy("points"), (3, 30.0))
|
||||
|
||||
|
||||
class AudioPlaybackTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_pygame_mixer_is_initialized_once_and_reused(self):
|
||||
class FakeChannel:
|
||||
def get_busy(self):
|
||||
return False
|
||||
|
||||
class FakeSound:
|
||||
def __init__(self, *, file):
|
||||
self.file = file
|
||||
|
||||
def play(self):
|
||||
return FakeChannel()
|
||||
|
||||
class FakeMixer:
|
||||
def __init__(self):
|
||||
self.initialized = False
|
||||
self.init_calls = 0
|
||||
self.Sound = FakeSound
|
||||
|
||||
def get_init(self):
|
||||
return self.initialized
|
||||
|
||||
def init(self):
|
||||
self.initialized = True
|
||||
self.init_calls += 1
|
||||
|
||||
def quit(self):
|
||||
self.initialized = False
|
||||
|
||||
class FakePygame:
|
||||
mixer = FakeMixer()
|
||||
|
||||
tts = TTSEngine.__new__(TTSEngine)
|
||||
tts._pygame = None
|
||||
with patch.dict("sys.modules", {"pygame": FakePygame}):
|
||||
await tts._play_audio(b"first")
|
||||
await tts._play_audio(b"second")
|
||||
|
||||
self.assertEqual(FakePygame.mixer.init_calls, 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user