修复直播测试与 TTS 预热

This commit is contained in:
2026-08-17 15:44:23 +08:00
parent f623959fd4
commit da8a82bfd5
8 changed files with 718 additions and 676 deletions
+86
View File
@@ -9,6 +9,7 @@ from app.faster_qwen_worker import (
MAX_NEW_TOKENS,
FasterQwenWorkerClient,
FasterQwenWorkerTimeout,
faster_qwen_worker_main,
_generation_kwargs,
_configure_worker_environment,
)
@@ -91,6 +92,14 @@ def _ready(pid):
}
def _progress(stage):
return {
"type": "progress",
"stage": stage,
"pid": 4000,
}
def _probe_worker_main(connection, _settings):
connection.send({
"type": "ready",
@@ -153,6 +162,8 @@ class FasterQwenWorkerTests(unittest.TestCase):
def test_client_returns_worker_audio(self):
connection = _FakeConnection([
_progress("model_loading"),
_progress("warmup_started"),
_ready(4100),
{
"type": "result",
@@ -181,6 +192,81 @@ class FasterQwenWorkerTests(unittest.TestCase):
self.assertEqual(len(context.processes), 1)
client.close()
def test_client_default_startup_timeout_allows_slow_model_load(self):
client = FasterQwenWorkerClient({})
self.assertEqual(client.startup_timeout_seconds, 900.0)
client.close()
def test_worker_applies_process_limits_after_model_warmup(self):
order = []
class FakeModel:
def warmup(self):
order.append("warmup")
class WorkerConnection:
def __init__(self):
self.sent = []
self.closed = False
def send(self, message):
self.sent.append(message)
def recv(self):
return {"command": "stop"}
def close(self):
self.closed = True
connection = WorkerConnection()
with (
patch("app.faster_qwen_worker._configure_worker_environment", return_value=3),
patch(
"app.faster_qwen_worker._load_runtime",
side_effect=lambda _settings, progress=None: (
progress("from_pretrained_started") if progress else None,
order.append("load"),
{"model": FakeModel()},
)[-1],
),
patch(
"app.faster_qwen_worker._apply_worker_process_limits",
side_effect=lambda _settings: order.append("limits") or {
"cpu_affinity_count": 6,
"process_priority": "below_normal",
},
),
patch(
"app.faster_qwen_worker._synthesize_wav",
side_effect=lambda _runtime, _text: order.append("voice_clone") or b"wav",
),
):
faster_qwen_worker_main(connection, {})
self.assertEqual(order, ["load", "warmup", "voice_clone", "limits"])
progress_stages = [
item.get("stage")
for item in connection.sent
if item.get("type") == "progress"
]
self.assertEqual(
progress_stages,
[
"process_started",
"environment_ready",
"model_loading",
"from_pretrained_started",
"model_loaded",
"warmup_started",
"graph_warmup_complete",
"voice_clone_warmup_started",
"voice_clone_warmup_complete",
"warmup_complete",
],
)
self.assertEqual(connection.sent[-1]["type"], "ready")
def test_timeout_terminates_worker_and_starts_a_prewarmed_replacement(self):
first_connection = _FakeConnection([_ready(4200)])
replacement_connection = _FakeConnection([_ready(4300)])
+165 -1
View File
@@ -1,10 +1,12 @@
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 SystemScheduler
from app.danmu_queue import CommandHandler, QueueSystem, SystemScheduler
class _FakeConfig:
@@ -155,5 +157,167 @@ class StartupLiveCompensationTests(unittest.IsolatedAsyncioTestCase):
)
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()
+44
View File
@@ -2,6 +2,7 @@ import asyncio
import logging
import time
import unittest
from types import SimpleNamespace
from unittest.mock import patch
import numpy as np
@@ -144,6 +145,49 @@ class PipelineOverlapTests(unittest.IsolatedAsyncioTestCase):
await asyncio.gather(synth_worker, play_worker, return_exceptions=True)
class BackgroundWarmupTests(unittest.IsolatedAsyncioTestCase):
async def test_start_returns_before_tts_warmup_finishes(self):
warmup_started = asyncio.Event()
release_warmup = asyncio.Event()
class FakeTTS:
enabled = True
async def warmup(self):
warmup_started.set()
await release_warmup.wait()
async def idle_worker():
await asyncio.Event().wait()
broadcaster = Broadcaster.__new__(Broadcaster)
broadcaster._tts_started = False
broadcaster._tts_warmup_done = asyncio.Event()
broadcaster._tts_warmup_task = None
broadcaster._tts_worker_tasks = set()
broadcaster._tts_queue_cfg = {"warmup_on_start": True}
broadcaster._tts_pending = SimpleNamespace(maxsize=8)
broadcaster._tts_playback_queue = SimpleNamespace(maxsize=2)
broadcaster._tts_synthesis_loop = idle_worker
broadcaster._tts_playback_loop = idle_worker
broadcaster.tts = FakeTTS()
broadcaster.logger = logging.getLogger("tts-background-warmup-test")
await asyncio.wait_for(broadcaster.start(), timeout=0.2)
await asyncio.wait_for(warmup_started.wait(), timeout=0.2)
self.assertFalse(broadcaster._tts_warmup_done.is_set())
self.assertIsNotNone(broadcaster._tts_warmup_task)
release_warmup.set()
await asyncio.wait_for(broadcaster._tts_warmup_done.wait(), timeout=0.2)
tasks = list(broadcaster._tts_worker_tasks)
for task in tasks:
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
class TTSExpiryPolicyTests(unittest.TestCase):
def test_default_expiry_windows_match_configured_policy(self):
broadcaster = Broadcaster.__new__(Broadcaster)