Update Live-streaming code (auto-daily features)

This commit is contained in:
2026-08-15 16:49:17 +08:00
commit 06ff69e945
350 changed files with 62343 additions and 0 deletions
+102
View File
@@ -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()
+503
View File
@@ -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()
+115
View File
@@ -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()
+156
View File
@@ -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()
+353
View File
@@ -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()
+256
View File
@@ -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()
+77
View File
@@ -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()
+242
View File
@@ -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()
+73
View File
@@ -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()
+136
View File
@@ -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()
+41
View File
@@ -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()
+105
View File
@@ -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()
+159
View File
@@ -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()
+200
View File
@@ -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()