合并外部维护版本并同步自动每日功能
This commit is contained in:
@@ -111,5 +111,48 @@ class BgiLogCompletionTests(unittest.IsolatedAsyncioTestCase):
|
||||
callback.assert_not_awaited()
|
||||
self.assertEqual(monitor._current_group, "自动每日")
|
||||
|
||||
|
||||
class BgiLogInterruptTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_task_interrupt_marker_triggers_interrupt_callback(self):
|
||||
monitor = BgiLogMonitor(".", logging.getLogger("test-bgi-interrupt-log"))
|
||||
finish = AsyncMock()
|
||||
interrupt = AsyncMock()
|
||||
monitor.set_finish_callback(finish)
|
||||
monitor.set_interrupt_callback(interrupt)
|
||||
with patch.object(monitor, "_get_current_log_size", return_value=0):
|
||||
monitor.set_current_group("霜仙花", "group-run")
|
||||
|
||||
# BetterGI 被外部取消时只写“任务中断”,不写“配置组 X 执行结束”。
|
||||
monitor._process_line('任务中断:"取消自动任务"')
|
||||
await asyncio.sleep(0)
|
||||
|
||||
interrupt.assert_awaited_once_with("霜仙花", "group-run")
|
||||
finish.assert_not_awaited()
|
||||
self.assertIsNone(monitor._current_group)
|
||||
|
||||
async def test_cancel_auto_task_marker_triggers_interrupt_callback(self):
|
||||
monitor = BgiLogMonitor(".", logging.getLogger("test-bgi-cancel-log"))
|
||||
interrupt = AsyncMock()
|
||||
monitor.set_interrupt_callback(interrupt)
|
||||
with patch.object(monitor, "_get_current_log_size", return_value=0):
|
||||
monitor.set_current_group("霜仙花", "group-run")
|
||||
|
||||
monitor._process_line("取消自动任务")
|
||||
await asyncio.sleep(0)
|
||||
|
||||
interrupt.assert_awaited_once_with("霜仙花", "group-run")
|
||||
self.assertIsNone(monitor._current_group)
|
||||
|
||||
async def test_interrupt_does_not_fire_when_no_current_group(self):
|
||||
monitor = BgiLogMonitor(".", logging.getLogger("test-bgi-no-group-log"))
|
||||
interrupt = AsyncMock()
|
||||
monitor.set_interrupt_callback(interrupt)
|
||||
|
||||
monitor._process_line('任务中断:"取消自动任务"')
|
||||
await asyncio.sleep(0)
|
||||
|
||||
interrupt.assert_not_awaited()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from app.faster_qwen_worker import (
|
||||
DEFAULT_CPU_AFFINITY_COUNT,
|
||||
@@ -338,5 +339,56 @@ class FasterQwenWorkerTests(unittest.TestCase):
|
||||
client.close()
|
||||
|
||||
|
||||
class DegradeRebuildTests(unittest.IsolatedAsyncioTestCase):
|
||||
"""合成耗时超阈值 → 后台自动重建 worker 的退化自愈逻辑。"""
|
||||
|
||||
def _make_engine(self):
|
||||
from app.danmu_queue import FasterQwen3TTSEngine
|
||||
|
||||
engine = FasterQwen3TTSEngine.__new__(FasterQwen3TTSEngine)
|
||||
engine.logger = logging.getLogger("test-degrade-rebuild")
|
||||
engine._degrade_rebuild_threshold_ms = 25000
|
||||
engine._degrade_rebuild_cooldown_sec = 1800
|
||||
engine._last_degrade_rebuild_at = 0.0
|
||||
engine._degrade_rebuild_task = None
|
||||
engine.restart_worker = AsyncMock(return_value=True)
|
||||
return engine
|
||||
|
||||
async def test_slow_synthesis_triggers_rebuild(self):
|
||||
engine = self._make_engine()
|
||||
engine._maybe_schedule_degrade_rebuild(40000)
|
||||
await asyncio.sleep(0)
|
||||
engine.restart_worker.assert_awaited_once_with(reason="合成退化自愈")
|
||||
|
||||
async def test_fast_synthesis_does_not_rebuild(self):
|
||||
engine = self._make_engine()
|
||||
engine._maybe_schedule_degrade_rebuild(8000)
|
||||
await asyncio.sleep(0)
|
||||
engine.restart_worker.assert_not_awaited()
|
||||
|
||||
async def test_cooldown_prevents_repeat_rebuild(self):
|
||||
engine = self._make_engine()
|
||||
engine._maybe_schedule_degrade_rebuild(40000)
|
||||
await asyncio.sleep(0)
|
||||
engine.restart_worker.reset_mock()
|
||||
# 冷却期内的再次超阈值不重复重建
|
||||
engine._maybe_schedule_degrade_rebuild(50000)
|
||||
await asyncio.sleep(0)
|
||||
engine.restart_worker.assert_not_awaited()
|
||||
|
||||
async def test_inflight_task_not_duplicated(self):
|
||||
engine = self._make_engine()
|
||||
|
||||
async def slow_rebuild(reason=""):
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
engine.restart_worker = slow_rebuild
|
||||
engine._maybe_schedule_degrade_rebuild(40000)
|
||||
first_task = engine._degrade_rebuild_task
|
||||
engine._maybe_schedule_degrade_rebuild(40000)
|
||||
self.assertIs(engine._degrade_rebuild_task, first_task)
|
||||
await first_task
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
"""LLM 弹幕回复单元测试:协议适配、过滤、限流、上下文、重试。"""
|
||||
|
||||
import asyncio
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from app.llm_client import (
|
||||
LLMError,
|
||||
LLMReplyEngine,
|
||||
OpenAICompatProvider,
|
||||
OllamaNativeProvider,
|
||||
AnthropicProvider,
|
||||
create_provider,
|
||||
)
|
||||
|
||||
|
||||
def _make_engine(**overrides) -> LLMReplyEngine:
|
||||
cfg = {
|
||||
"enabled": True,
|
||||
"provider": "openai",
|
||||
"base_url": "https://api.example.com/v1",
|
||||
"api_key": "sk-test",
|
||||
"model": "test-model",
|
||||
"system_prompt": "你是直播间助手",
|
||||
"max_reply_chars": 60,
|
||||
"replies_per_minute": 6,
|
||||
"per_user_cooldown_sec": 30,
|
||||
"context_turns": 4,
|
||||
"min_text_len": 2,
|
||||
"blacklist_uids": [666],
|
||||
"ignore_prefixes": ["//"],
|
||||
}
|
||||
cfg.update(overrides)
|
||||
return LLMReplyEngine(cfg)
|
||||
|
||||
|
||||
class _FakeProvider:
|
||||
"""可注入的假 provider:返回固定回复,可记录调用。"""
|
||||
|
||||
name = "fake"
|
||||
model = "fake-model"
|
||||
|
||||
def __init__(self, reply: str = "你好呀~", delay: float = 0.0, fail_times: int = 0):
|
||||
self.reply = reply
|
||||
self.delay = delay
|
||||
self.fail_times = fail_times
|
||||
self.calls: list[list[dict]] = []
|
||||
|
||||
async def chat(self, messages, *, temperature=0.8, max_tokens=256):
|
||||
self.calls.append(messages)
|
||||
if self.delay:
|
||||
await asyncio.sleep(self.delay)
|
||||
if self.fail_times > 0:
|
||||
self.fail_times -= 1
|
||||
raise LLMError("模拟失败")
|
||||
return self.reply
|
||||
|
||||
|
||||
def _install(engine: LLMReplyEngine, provider: _FakeProvider):
|
||||
engine._provider = provider
|
||||
engine._cfg_fingerprint = engine._provider_fingerprint()
|
||||
return provider
|
||||
|
||||
|
||||
class TestProviders(unittest.TestCase):
|
||||
def test_create_openai(self):
|
||||
p = create_provider({"provider": "openai", "base_url": "https://x/v1",
|
||||
"api_key": "k", "model": "m"})
|
||||
self.assertIsInstance(p, OpenAICompatProvider)
|
||||
self.assertEqual(p.base_url, "https://x/v1")
|
||||
|
||||
def test_create_ollama(self):
|
||||
p = create_provider({"provider": "ollama", "base_url": "http://localhost:11434",
|
||||
"model": "qwen3"})
|
||||
self.assertIsInstance(p, OllamaNativeProvider)
|
||||
|
||||
def test_create_anthropic(self):
|
||||
p = create_provider({"provider": "anthropic", "base_url": "https://x",
|
||||
"api_key": "k", "model": "claude"})
|
||||
self.assertIsInstance(p, AnthropicProvider)
|
||||
|
||||
def test_unknown_provider(self):
|
||||
with self.assertRaises(LLMError):
|
||||
create_provider({"provider": "nope", "base_url": "https://x"})
|
||||
|
||||
def test_missing_base_url(self):
|
||||
with self.assertRaises(LLMError):
|
||||
create_provider({"provider": "openai"})
|
||||
|
||||
|
||||
class TestFiltering(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_disabled_engine(self):
|
||||
engine = _make_engine(enabled=False)
|
||||
self.assertIsNone(await engine.generate_reply("你好", 1, "路人"))
|
||||
|
||||
async def test_too_short(self):
|
||||
engine = _make_engine()
|
||||
self.assertIsNone(engine.should_reply("哈", 1, "路人"))
|
||||
|
||||
async def test_ignore_prefix(self):
|
||||
engine = _make_engine()
|
||||
self.assertIsNone(engine.should_reply("//悄悄话", 1, "路人"))
|
||||
|
||||
async def test_blacklist(self):
|
||||
engine = _make_engine()
|
||||
self.assertIsNone(engine.should_reply("你好呀", 666, "黑名单"))
|
||||
|
||||
async def test_command_like_text_passes_through(self):
|
||||
"""指令拦截在 CommandHandler 层完成;引擎本身不拦截普通文本。"""
|
||||
engine = _make_engine()
|
||||
self.assertIsNotNone(engine.should_reply("上号", 1, "路人"))
|
||||
|
||||
async def test_per_user_cooldown(self):
|
||||
engine = _make_engine()
|
||||
p = _install(engine, _FakeProvider())
|
||||
first = await engine.generate_reply("在吗", 1, "小明")
|
||||
second = await engine.generate_reply("还在吗", 1, "小明")
|
||||
self.assertTrue(first)
|
||||
self.assertIsNone(second) # 冷却期内被过滤
|
||||
self.assertEqual(len(p.calls), 1)
|
||||
|
||||
async def test_zero_cooldown_disabled(self):
|
||||
"""per_user_cooldown_sec=0 表示禁用冷却,不应被吞成默认 30。"""
|
||||
engine = _make_engine(per_user_cooldown_sec=0)
|
||||
_install(engine, _FakeProvider())
|
||||
self.assertTrue(await engine.generate_reply("第一条", 1, "甲"))
|
||||
self.assertTrue(await engine.generate_reply("第二条", 1, "甲"))
|
||||
|
||||
async def test_global_rate_limit(self):
|
||||
engine = _make_engine(replies_per_minute=1, per_user_cooldown_sec=0)
|
||||
_install(engine, _FakeProvider())
|
||||
self.assertTrue(await engine.generate_reply("第一条", 1, "甲"))
|
||||
self.assertIsNone(engine.should_reply("第二条", 2, "乙"))
|
||||
|
||||
async def test_busy_skip(self):
|
||||
"""并发保护:在途请求未完成时新请求直接放弃。"""
|
||||
engine = _make_engine(per_user_cooldown_sec=0)
|
||||
_install(engine, _FakeProvider(delay=0.2))
|
||||
t1 = asyncio.create_task(engine.generate_reply("第一条", 1, "甲"))
|
||||
await asyncio.sleep(0.05)
|
||||
t2 = asyncio.create_task(engine.generate_reply("第二条", 2, "乙"))
|
||||
self.assertTrue(await t1)
|
||||
self.assertIsNone(await t2)
|
||||
|
||||
async def test_retry_then_success(self):
|
||||
engine = _make_engine(retry_attempts=2, per_user_cooldown_sec=0)
|
||||
_install(engine, _FakeProvider(fail_times=1))
|
||||
with patch("app.llm_client.asyncio.sleep", new=AsyncMock()):
|
||||
reply = await engine.generate_reply("在吗", 1, "小明")
|
||||
self.assertEqual(reply, "你好呀~")
|
||||
|
||||
async def test_sanitize_truncates_and_strips_markdown(self):
|
||||
engine = _make_engine(max_reply_chars=10)
|
||||
# 去掉 ** 后共 14 字,截断到 10 字
|
||||
self.assertEqual(engine._sanitize("**很长**的一段话超过十个字了哦朋友"),
|
||||
"很长的一段话超过十个…")
|
||||
|
||||
|
||||
class TestContext(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_context_included_and_capped(self):
|
||||
engine = _make_engine(context_turns=2, per_user_cooldown_sec=0)
|
||||
p = _install(engine, _FakeProvider())
|
||||
for i in range(5):
|
||||
await engine.generate_reply(f"消息{i}", 1, "小明")
|
||||
# 最后一次调用:system(1) + 最多 2*2 条上下文 + 当前 1 条
|
||||
msgs = p.calls[-1]
|
||||
user_msgs = [m for m in msgs if m["role"] == "user"]
|
||||
self.assertLessEqual(len(msgs), 1 + 4 + 1)
|
||||
self.assertEqual(user_msgs[-1]["content"], "小明: 消息4")
|
||||
|
||||
async def test_system_prompt_present(self):
|
||||
engine = _make_engine()
|
||||
p = _install(engine, _FakeProvider())
|
||||
await engine.generate_reply("在吗", 1, "小明")
|
||||
first = p.calls[0][0]
|
||||
self.assertEqual(first["role"], "system")
|
||||
# 人设在前,情绪标签指令在后(桌宠联动需要)
|
||||
self.assertIn("你是直播间助手", first["content"])
|
||||
self.assertIn("情绪标签", first["content"])
|
||||
self.assertIn("[开心]", first["content"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,112 @@
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "app"))
|
||||
from pet_state import PetState, gift_recovery, split_emotion
|
||||
from app.danmu_queue import get_room_live_status, monitor_pet_live_status
|
||||
|
||||
|
||||
def timestamp(iso: str) -> float:
|
||||
return datetime.fromisoformat(iso).timestamp()
|
||||
|
||||
|
||||
class PetStateTests(unittest.TestCase):
|
||||
def test_only_active_sessions_count_and_partial_hours_carry_over(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
pet = PetState(Path(directory) / "pet.json")
|
||||
start = timestamp("2026-09-25T22:00:00")
|
||||
pet.updated_at = start
|
||||
self.assertEqual(pet.sync(start + 3600)["stamina"], 100)
|
||||
pet.set_live_active(True, start + 3600)
|
||||
pet.set_live_active(False, start + 5400)
|
||||
self.assertEqual(pet.sync(start + 9000)["stamina"], 100)
|
||||
pet.set_live_active(True, start + 9000)
|
||||
self.assertEqual(pet.sync(start + 10800)["stamina"], 90)
|
||||
pet.set_live_active(False, start + 10800)
|
||||
self.assertEqual(pet.sync(start + 86400)["stamina"], 90)
|
||||
|
||||
def test_hourly_decay_mood_and_persistence(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "pet.json"
|
||||
pet = PetState(path)
|
||||
pet.updated_at = timestamp("2026-09-25T09:00:00")
|
||||
pet.set_live_active(True, timestamp("2026-09-25T09:00:00"))
|
||||
state = pet.sync(timestamp("2026-09-25T16:00:00"))
|
||||
self.assertEqual(state, {"stamina": 30, "cleanliness": 30, "mood": 95})
|
||||
self.assertEqual(json.loads(path.read_text(encoding="utf-8"))["stamina"], 30)
|
||||
loaded = PetState(path)
|
||||
self.assertEqual(loaded.values, state)
|
||||
self.assertFalse(loaded.live_active)
|
||||
self.assertEqual(loaded.sync(timestamp("2026-09-25T17:00:00")), state)
|
||||
|
||||
def test_emotion_and_gift_clamp(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
pet = PetState(Path(directory) / "pet.json")
|
||||
pet.values = {"stamina": 95, "cleanliness": 99, "mood": 1}
|
||||
self.assertEqual(pet.apply_emotion("很生气")["mood"], 0)
|
||||
self.assertEqual(pet.restore_gift(20, 20),
|
||||
{"stamina": 100, "cleanliness": 100, "mood": 0})
|
||||
self.assertEqual(split_emotion("[很开心] 今天真好"), ("今天真好", "很开心"))
|
||||
self.assertEqual(split_emotion("[未知] 今天真好"), ("[未知] 今天真好", None))
|
||||
|
||||
def test_mood_reaction_events_cover_four_deltas_and_hourly_gain(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
pet = PetState(Path(directory) / "pet.json")
|
||||
pet.values["mood"] = 50
|
||||
for emotion in ("很开心", "开心", "生气", "很生气", "平静"):
|
||||
pet.apply_emotion(emotion)
|
||||
self.assertEqual([event["delta"] for event in pet.recent_emotions],
|
||||
[3, 1, -1, -3])
|
||||
self.assertEqual(len({event["id"] for event in pet.recent_emotions}), 4)
|
||||
start = timestamp("2026-09-25T09:00:00")
|
||||
pet.values.update(stamina=100, cleanliness=100)
|
||||
pet.updated_at = start
|
||||
pet.live_active = True
|
||||
pet.sync(start + 3600)
|
||||
self.assertEqual(pet.recent_emotions[-1]["delta"], 3)
|
||||
|
||||
def test_named_gifts_and_full_stat_keep_nominal_animation_points(self):
|
||||
self.assertEqual(gift_recovery("饭团"), (5, 0))
|
||||
self.assertEqual(gift_recovery("薯条", 2), (10, 0))
|
||||
self.assertEqual(gift_recovery("人气票"), (0, 5))
|
||||
self.assertEqual(gift_recovery("粉丝团灯牌", 2), (0, 10))
|
||||
self.assertEqual(gift_recovery("灯牌"), (0, 5))
|
||||
self.assertEqual(gift_recovery("其他礼物"), (0, 0))
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
pet = PetState(Path(directory) / "pet.json")
|
||||
nominal_stamina, nominal_cleanliness = gift_recovery("饭团")
|
||||
self.assertEqual((nominal_stamina, nominal_cleanliness), (5, 0))
|
||||
self.assertEqual(pet.restore_gift(nominal_stamina, nominal_cleanliness)["stamina"], 100)
|
||||
|
||||
def test_room_state_only_counts_confirmed_live(self):
|
||||
with patch("app.danmu_queue._request_bilibili_json") as request:
|
||||
for status, expected in ((0, False), (1, True), (2, False)):
|
||||
request.return_value = {"code": 0, "data": {"live_status": status}}
|
||||
self.assertIs(get_room_live_status(76), expected)
|
||||
request.return_value = {"code": -1, "data": {"live_status": 1}}
|
||||
with self.assertRaises(RuntimeError):
|
||||
get_room_live_status(76)
|
||||
|
||||
def test_room_monitor_switches_pet_session_on_confirmed_state(self):
|
||||
pet = Mock()
|
||||
system = SimpleNamespace(pet_state=pet)
|
||||
config = SimpleNamespace(room_id=76, bilibili_cookie="")
|
||||
with patch("app.danmu_queue.asyncio.to_thread", new=AsyncMock(side_effect=[True, False])), \
|
||||
patch("app.danmu_queue.asyncio.sleep", new=AsyncMock(
|
||||
side_effect=[None, asyncio.CancelledError()])):
|
||||
with self.assertRaises(asyncio.CancelledError):
|
||||
asyncio.run(monitor_pet_live_status(system, config, Mock()))
|
||||
self.assertEqual([call.args[0] for call in pet.set_live_active.call_args_list],
|
||||
[True, False, False])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,8 +1,10 @@
|
||||
import asyncio
|
||||
import ctypes
|
||||
import logging
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
@@ -319,5 +321,105 @@ class ManualLiveOverrideTests(unittest.TestCase):
|
||||
self.assertTrue(handler._is_live_time())
|
||||
|
||||
|
||||
class StartupTaskSelfHealTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.logger = logging.getLogger("test-startup-self-heal")
|
||||
self.scheduler = SystemScheduler(
|
||||
_FakeConfig({
|
||||
"enable_startup_shortcut": True,
|
||||
"startup_bat": "run.bat",
|
||||
}),
|
||||
self.logger,
|
||||
)
|
||||
|
||||
@patch("app.danmu_queue.os.name", "nt")
|
||||
@patch("app.danmu_queue.project_path")
|
||||
def test_rebuilds_when_task_missing(self, project_path_mock):
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
root_path = Path(root)
|
||||
(root_path / "run.bat").touch()
|
||||
(root_path / ".venv-tts" / "Scripts").mkdir(parents=True)
|
||||
(root_path / ".venv-tts" / "Scripts" / "pythonw.exe").touch()
|
||||
(root_path / "app").mkdir()
|
||||
(root_path / "app" / "main.py").touch()
|
||||
project_path_mock.side_effect = lambda relative: root_path / relative
|
||||
self.scheduler._startup_task_exists = MagicMock(return_value=False)
|
||||
self.scheduler._create_startup_task = MagicMock(return_value=True)
|
||||
|
||||
self.scheduler.ensure_startup_task_self_heal()
|
||||
|
||||
self.scheduler._create_startup_task.assert_called_once()
|
||||
args = self.scheduler._create_startup_task.call_args.args
|
||||
self.assertEqual(args[0], "BetterGI弹幕排队系统")
|
||||
# 无窗口模式:pythonw 直接运行 main.py,不再弹 cmd 窗口
|
||||
self.assertIn(str(root_path / "app" / "main.py"), args[1])
|
||||
self.assertIn("--role all", args[1])
|
||||
self.assertIn(f"--port {self.scheduler.port}", args[1])
|
||||
|
||||
@patch("app.danmu_queue.os.name", "nt")
|
||||
@patch("app.danmu_queue.project_path")
|
||||
def test_skips_when_task_present(self, project_path_mock):
|
||||
project_path_mock.return_value = MagicMock(
|
||||
exists=MagicMock(return_value=True),
|
||||
__str__=lambda _s: r"C:\proj\run.bat",
|
||||
)
|
||||
self.scheduler._startup_task_exists = MagicMock(return_value=True)
|
||||
self.scheduler._create_startup_task = MagicMock()
|
||||
|
||||
self.scheduler.ensure_startup_task_self_heal()
|
||||
|
||||
self.scheduler._create_startup_task.assert_not_called()
|
||||
|
||||
@patch("app.danmu_queue.os.name", "nt")
|
||||
def test_skips_when_disabled(self):
|
||||
self.scheduler.config.system_cfg["enable_startup_shortcut"] = False
|
||||
self.scheduler._startup_task_exists = MagicMock()
|
||||
self.scheduler._create_startup_task = MagicMock()
|
||||
|
||||
self.scheduler.ensure_startup_task_self_heal()
|
||||
|
||||
self.scheduler._startup_task_exists.assert_not_called()
|
||||
self.scheduler._create_startup_task.assert_not_called()
|
||||
|
||||
|
||||
class ScriptGroupSyncTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.logger = logging.getLogger("test-scriptgroup-sync")
|
||||
self.scheduler = SystemScheduler(
|
||||
_FakeConfig({}),
|
||||
self.logger,
|
||||
)
|
||||
|
||||
def test_copies_and_overwrites_files(self):
|
||||
with tempfile.TemporaryDirectory() as src_dir, tempfile.TemporaryDirectory() as bgi_dir:
|
||||
src = Path(src_dir)
|
||||
(src / "薄荷.json").write_text('{"v": 2}', encoding="utf-8")
|
||||
(src / "晶蝶.json").write_text("{}", encoding="utf-8")
|
||||
dst = Path(bgi_dir) / "User" / "ScriptGroup"
|
||||
dst.mkdir(parents=True)
|
||||
(dst / "薄荷.json").write_text('{"v": 1, "corrupt": true}', encoding="utf-8")
|
||||
(dst / "本地多余.json").write_text("{}", encoding="utf-8")
|
||||
self.scheduler.config.bettergi_work_dir = bgi_dir
|
||||
self.scheduler.config.system_cfg["scriptgroup_backup_dir"] = src_dir
|
||||
|
||||
self.scheduler._sync_script_groups()
|
||||
|
||||
self.assertEqual(
|
||||
(dst / "薄荷.json").read_text(encoding="utf-8"), '{"v": 2}'
|
||||
)
|
||||
self.assertTrue((dst / "晶蝶.json").exists())
|
||||
# 目标目录里多出的文件不删除
|
||||
self.assertTrue((dst / "本地多余.json").exists())
|
||||
|
||||
def test_missing_source_dir_is_skipped(self):
|
||||
with tempfile.TemporaryDirectory() as bgi_dir:
|
||||
self.scheduler.config.bettergi_work_dir = bgi_dir
|
||||
self.scheduler.config.system_cfg["scriptgroup_backup_dir"] = (
|
||||
str(Path(bgi_dir) / "不存在")
|
||||
)
|
||||
# 不抛异常即通过
|
||||
self.scheduler._sync_script_groups()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user