import asyncio import ctypes import logging import unittest from datetime import datetime, timedelta from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch from app.danmu_queue import CommandHandler, QueueSystem, 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)), ) class LivehimePushVerificationTests(unittest.IsolatedAsyncioTestCase): def setUp(self): self.logger = logging.getLogger("test-livehime-push-verification") self.scheduler = SystemScheduler( _FakeConfig({ "bilibili_push_window_keyword": "直播姬", "bilibili_push_click_x_ratio": 0.787, "bilibili_push_click_y_ratio": 0.927, "bilibili_stop_push_click_x_ratio": 0.787, "bilibili_stop_push_click_y_ratio": 0.927, }), self.logger, ) async def test_push_skips_click_when_bilibili_is_already_live(self): self.scheduler._get_bilibili_live_status = AsyncMock(return_value=1) self.scheduler._click_bilibili_live = AsyncMock() self.assertTrue(await self.scheduler.push_bilibili_live()) self.scheduler._click_bilibili_live.assert_not_awaited() async def test_push_clicks_then_waits_for_confirmed_live_status(self): self.scheduler._get_bilibili_live_status = AsyncMock(side_effect=[0, 0, 1]) self.scheduler._click_bilibili_live = AsyncMock(return_value=True) with patch("app.danmu_queue.asyncio.sleep", new_callable=AsyncMock): result = await self.scheduler.push_bilibili_live() self.assertTrue(result) self.scheduler._click_bilibili_live.assert_awaited_once() async def test_stop_skips_click_when_bilibili_is_already_offline(self): self.scheduler._get_bilibili_live_status = AsyncMock(return_value=0) self.scheduler._click_bilibili_live = AsyncMock() self.assertTrue(await self.scheduler.stop_bilibili_live()) self.scheduler._click_bilibili_live.assert_not_awaited() async def test_live_status_timeout_returns_specific_error(self): self.scheduler._get_bilibili_live_status = AsyncMock(return_value=0) with patch("app.danmu_queue.asyncio.sleep", new_callable=AsyncMock): result = await self.scheduler._wait_for_bilibili_live_status( 1, timeout_seconds=0.01, ) self.assertFalse(result) self.assertIn("未确认开播", self.scheduler.last_live_action_error) @patch("app.danmu_queue.os.name", "nt") async def test_click_restores_and_focuses_before_reading_physical_window_rect(self): hwnd = 12345 user32 = MagicMock() call_order = [] user32.IsIconic.return_value = True user32.GetForegroundWindow.return_value = hwnd user32.GetSystemMetrics.side_effect = lambda index: { 76: 0, 77: 0, 78: 2560, 79: 1440, }[index] user32.SetCursorPos.return_value = True user32.GetCursorPos.return_value = False user32.SetThreadDpiAwarenessContext.return_value = None def show_window(_hwnd, _command): call_order.append("restore") return True def get_window_rect(_hwnd, pointer): call_order.append("rect") rect = ctypes.cast(pointer, ctypes.POINTER(ctypes.wintypes.RECT)).contents rect.left = -11 rect.top = -11 rect.right = 2571 rect.bottom = 1379 return True user32.ShowWindow.side_effect = show_window user32.GetWindowRect.side_effect = get_window_rect self.scheduler._find_window_by_keyword = MagicMock(return_value=hwnd) self.scheduler._force_foreground_window = MagicMock(return_value=True) with ( patch("app.danmu_queue.ctypes.windll.user32", user32), patch("app.danmu_queue.asyncio.sleep", new_callable=AsyncMock), ): result = await self.scheduler._click_bilibili_live( x_ratio_key="bilibili_push_click_x_ratio", y_ratio_key="bilibili_push_click_y_ratio", action_name="开启推流按钮", ) self.assertTrue(result) self.assertLess(call_order.index("restore"), call_order.index("rect")) user32.SetCursorPos.assert_called_once_with(2021, 1277) self.assertEqual(user32.mouse_event.call_count, 2) class ManualLiveOverrideTests(unittest.TestCase): def setUp(self): self.system = QueueSystem.__new__(QueueSystem) self.system.logger = logging.getLogger("test-manual-live-override") self.system._manual_live_override_until_monotonic = 0.0 self.system._manual_live_override_until_wall = 0.0 self.system._bilibili_live_status = None self.system._bilibili_live_status_updated_monotonic = 0.0 self.system._bilibili_live_status_updated_wall = 0.0 def test_manual_live_override_allows_commands_outside_schedule(self): handler = CommandHandler.__new__(CommandHandler) handler.system = self.system handler.config = SimpleNamespace( system_cfg={"live_start_time": "08:30", "live_end_time": "23:30"} ) with patch("app.danmu_queue.time.monotonic", return_value=100.0): self.system.enable_manual_live_override(300) self.assertTrue(handler._is_live_time()) snapshot = self.system.manual_live_override_snapshot() self.assertTrue(snapshot["active"]) self.assertGreater(snapshot["until"], 0) def test_clear_manual_live_override_restores_schedule_gate(self): handler = CommandHandler.__new__(CommandHandler) handler.system = self.system handler.config = SimpleNamespace( system_cfg={"live_start_time": "08:30", "live_end_time": "23:30"} ) with ( patch("app.danmu_queue.time.monotonic", return_value=100.0), patch("app.danmu_queue.datetime") as mocked_datetime, ): mocked_datetime.now.return_value = datetime(2026, 8, 16, 3, 30) mocked_datetime.strptime = datetime.strptime self.system.enable_manual_live_override(300) self.system.clear_manual_live_override() self.assertFalse(handler._is_live_time()) def test_confirmed_bilibili_live_status_allows_commands_outside_schedule(self): handler = CommandHandler.__new__(CommandHandler) handler.system = self.system handler.config = SimpleNamespace( system_cfg={"live_start_time": "08:30", "live_end_time": "23:30"} ) with ( patch("app.danmu_queue.time.monotonic", return_value=100.0), patch("app.danmu_queue.datetime") as mocked_datetime, ): mocked_datetime.now.return_value = datetime(2026, 8, 16, 3, 30) mocked_datetime.strptime = datetime.strptime self.system.set_bilibili_live_status(1) self.assertTrue(handler._is_live_time()) if __name__ == "__main__": unittest.main()