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()