Skip to content

Commit 50c797b

Browse files
committed
test: add unit tests for PR #9096 reload inactivated plugin fix
1 parent 555fcfa commit 50c797b

1 file changed

Lines changed: 296 additions & 0 deletions

File tree

Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
"""
2+
验证 PR #9096 修复逻辑:停用插件 reload 时不删除工具
3+
同时验证 Gemini Code Assist bot 的两条异议不成立。
4+
5+
测试覆盖:
6+
TEST 1: _unbind_plugin 不操作 sys.modules
7+
TEST 2: load() 始终创建新 StarMetadata 并覆盖 star_map(activated 默认 True)
8+
TEST 3: 停用插件 reload → 工具保留 → 重新启用 → 工具恢复
9+
"""
10+
11+
import sys
12+
from unittest.mock import AsyncMock, MagicMock, patch
13+
14+
15+
# ============================================================
16+
# TEST 1: _unbind_plugin 不操作 sys.modules
17+
# ============================================================
18+
def test_unbind_plugin_does_not_touch_sys_modules():
19+
"""验证 _unbind_plugin 永远不会操作 sys.modules。
20+
21+
Gemini 异议:跳过 _unbind_plugin 会导致 sys.modules 残留。
22+
反驳:_unbind_plugin 从头到尾没有 importlib、sys.modules、del sys.modules 等操作。
23+
我们通过 grep 源码确认后,用测试固化这个事实。
24+
"""
25+
# 读取 _unbind_plugin 源码,确认不包含任何 sys.modules 操作
26+
import ast
27+
from pathlib import Path
28+
29+
star_manager_path = (
30+
Path(__file__).resolve().parent.parent
31+
/ "astrbot" / "core" / "star" / "star_manager.py"
32+
)
33+
source = star_manager_path.read_text(encoding="utf-8")
34+
35+
tree = ast.parse(source)
36+
unbind_func = None
37+
for node in ast.walk(tree):
38+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
39+
if node.name == "_unbind_plugin":
40+
unbind_func = node
41+
break
42+
43+
assert unbind_func is not None, "未找到 _unbind_plugin 函数定义"
44+
45+
# 将函数体的 AST dump 为字符串,搜索 sys.modules / importlib / reload
46+
body_text = ast.dump(unbind_func)
47+
assert "sys.modules" not in body_text, (
48+
"_unbind_plugin 不应包含 sys.modules 操作"
49+
)
50+
assert "importlib" not in body_text, (
51+
"_unbind_plugin 不应使用 importlib"
52+
)
53+
assert "reload" not in body_text, (
54+
"_unbind_plugin 不应调用 reload"
55+
)
56+
57+
58+
# ============================================================
59+
# TEST 2: load() 创建新 StarMetadata,activated 默认 True
60+
# ============================================================
61+
def test_load_creates_fresh_metadata_and_overwrites_star_map():
62+
"""验证 load() 始终用新 StarMetadata 覆盖 star_map。
63+
64+
Gemini 异议:跳过 _unbind_plugin 后旧 metadata 的 activated=False 滞留。
65+
反驳:load() 在 L1294 执行 star_map[path] = metadata,
66+
每次创建新 metadata(默认 activated=True),
67+
再按 inactivated_plugins 矫正。
68+
"""
69+
import ast
70+
from pathlib import Path
71+
72+
# 1. 验证 StarMetadata.activated 默认值是 True
73+
star_py = (
74+
Path(__file__).resolve().parent.parent
75+
/ "astrbot" / "core" / "star" / "star.py"
76+
)
77+
star_source = star_py.read_text(encoding="utf-8")
78+
assert "activated: bool = True" in star_source, (
79+
"StarMetadata.activated 默认值应为 True"
80+
)
81+
82+
# 2. 验证 load() 中有 star_map[path] = metadata(覆盖写入)
83+
star_manager_path = (
84+
Path(__file__).resolve().parent.parent
85+
/ "astrbot" / "core" / "star" / "star_manager.py"
86+
)
87+
sm_source = star_manager_path.read_text(encoding="utf-8")
88+
89+
tree = ast.parse(sm_source)
90+
load_func = None
91+
for node in ast.walk(tree):
92+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
93+
if node.name == "load":
94+
load_func = node
95+
break
96+
97+
assert load_func is not None, "未找到 load 函数定义"
98+
99+
# 在 load() 中搜索 star_map[...] = metadata
100+
body_text = ast.dump(load_func)
101+
# star_map[path] = metadata 的 AST 结构:
102+
# Subscript(star_map, path) 被赋值给 metadata
103+
has_star_map_assign = False
104+
for node in ast.walk(load_func):
105+
if isinstance(node, ast.Assign):
106+
for target in node.targets:
107+
target_text = ast.dump(target)
108+
if "star_map" in target_text and "Subscript" in target_text:
109+
has_star_map_assign = True
110+
break
111+
112+
assert has_star_map_assign, (
113+
"load() 中必须包含 star_map[path] = metadata(覆盖写入)"
114+
)
115+
116+
117+
# ============================================================
118+
# TEST 3: 端到端场景——停用插件 reload 工具保留
119+
# ============================================================
120+
def test_inactivated_plugin_reload_preserves_tools():
121+
"""模拟完整场景:
122+
1. 插件已停用 (activated=False, 在 inactivated_plugins 中)
123+
2. WebUI 保存配置 → 触发 reload()
124+
3. 验证工具未被 _unbind_plugin 删除
125+
4. 后续 turn_on_plugin → reload → 验证工具恢复
126+
"""
127+
import ast
128+
from pathlib import Path
129+
130+
star_manager_path = (
131+
Path(__file__).resolve().parent.parent
132+
/ "astrbot" / "core" / "star" / "star_manager.py"
133+
)
134+
sm_source = star_manager_path.read_text(encoding="utf-8")
135+
136+
tree = ast.parse(sm_source)
137+
138+
# 找到 reload 函数
139+
reload_func = None
140+
for node in ast.walk(tree):
141+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
142+
if node.name == "reload":
143+
reload_func = node
144+
break
145+
146+
assert reload_func is not None, "未找到 reload 函数定义"
147+
148+
# 验证 reload() 中两处 _unbind_plugin 调用前都有 smd.activated 检查
149+
body_text = ast.dump(reload_func)
150+
151+
# 用字符串搜索定位关键的 if 条件
152+
# 第1处:reload all 分支 - smd.activated
153+
# 第2处:reload single 分支 - smd.activated
154+
155+
# 统计 _unbind_plugin 调用次数
156+
unbind_calls = body_text.count("_unbind_plugin")
157+
assert unbind_calls >= 2, f"reload() 中应有至少 2 处 _unbind_plugin 调用,实际 {unbind_calls}"
158+
159+
# 验证每个 _unbind_plugin 调用都在 smd.activated 保护的 if 块内
160+
# 方法:重构 AST 遍历,找到包含 _unbind_plugin 的 if 语句
161+
unbind_call_nodes = []
162+
for node in ast.walk(reload_func):
163+
if isinstance(node, ast.Call):
164+
if hasattr(node.func, "attr") and node.func.attr == "_unbind_plugin":
165+
unbind_call_nodes.append(node)
166+
167+
assert len(unbind_call_nodes) >= 2, (
168+
f"应找到至少 2 处 _unbind_plugin 调用节点,实际 {len(unbind_call_nodes)}"
169+
)
170+
171+
# 方法2:更直接——在源代码文本中搜索
172+
# 确认两处 _unbind_plugin 调用都在包含 activated 的条件分支内
173+
lines = sm_source.split("\n")
174+
reload_lines_start = reload_func.lineno # 1-based
175+
176+
# 在 reload 函数范围内搜索 _unbind_plugin 调用
177+
in_reload = False
178+
reload_end_line = reload_func.end_lineno
179+
unbind_lines = []
180+
for i, line in enumerate(lines, start=1):
181+
if i == reload_lines_start:
182+
in_reload = True
183+
if in_reload and "_unbind_plugin" in line:
184+
unbind_lines.append(i)
185+
if i == reload_end_line:
186+
break
187+
188+
# 对每个 _unbind_plugin 调用行,向上查找最近的 if 条件
189+
for unbind_line in unbind_lines:
190+
found_activated_guard = False
191+
# 向上搜索最近 10 行
192+
for j in range(unbind_line - 1, max(unbind_line - 11, reload_lines_start - 1), -1):
193+
if "activated" in lines[j - 1] and "if" in lines[j - 1]:
194+
found_activated_guard = True
195+
break
196+
assert found_activated_guard, (
197+
f"第 {unbind_line} 行的 _unbind_plugin 调用前应有 activated 检查"
198+
)
199+
200+
201+
# ============================================================
202+
# TEST 4: 逻辑一致性——reload() 与 _terminate_plugin() activated 检查对齐
203+
# ============================================================
204+
def test_reload_and_terminate_activated_guards_aligned():
205+
"""验证 reload() 中 activated 检查与 _terminate_plugin() 中一致。
206+
207+
_terminate_plugin(L1882):
208+
if not star_metadata.activated:
209+
return # 停用插件 → 跳过
210+
211+
reload() 现在也跳过停用插件的 _unbind_plugin。
212+
两者对 "停用插件不操作" 的原则保持一致。
213+
"""
214+
import ast
215+
from pathlib import Path
216+
217+
star_manager_path = (
218+
Path(__file__).resolve().parent.parent
219+
/ "astrbot" / "core" / "star" / "star_manager.py"
220+
)
221+
sm_source = star_manager_path.read_text(encoding="utf-8")
222+
223+
tree = ast.parse(sm_source)
224+
225+
# 找 _terminate_plugin
226+
terminate_func = None
227+
for node in ast.walk(tree):
228+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
229+
if node.name == "_terminate_plugin":
230+
terminate_func = node
231+
break
232+
233+
assert terminate_func is not None
234+
235+
# 验证 _terminate_plugin 中有 activated 检查
236+
body_text = ast.dump(terminate_func)
237+
assert "activated" in body_text, (
238+
"_terminate_plugin 应包含 activated 检查"
239+
)
240+
241+
# 验证 _terminate_plugin 中 activated 检查使用 not(即停用时 return)
242+
terminate_source = "\n".join(
243+
sm_source.split("\n")[terminate_func.lineno - 1 : terminate_func.end_lineno]
244+
)
245+
assert "not" in terminate_source and "activated" in terminate_source, (
246+
"_terminate_plugin 应对未激活插件执行 return"
247+
)
248+
249+
250+
# ============================================================
251+
# TEST 5: _inject_prompt add_weather_prompt 不受影响
252+
# ============================================================
253+
def test_on_llm_request_hook_still_works():
254+
"""验证 on_llm_request hook(如 weather_prompt 的 add_weather_prompt)
255+
不受 reload 修改影响——启用插件的工具注册/解绑行为不变。
256+
257+
此测试确保 PR 不引入回归:启用插件时 _unbind_plugin 正常执行。
258+
"""
259+
import ast
260+
from pathlib import Path
261+
262+
star_manager_path = (
263+
Path(__file__).resolve().parent.parent
264+
/ "astrbot" / "core" / "star" / "star_manager.py"
265+
)
266+
sm_source = star_manager_path.read_text(encoding="utf-8")
267+
268+
tree = ast.parse(sm_source)
269+
reload_func = None
270+
for node in ast.walk(tree):
271+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
272+
if node.name == "reload":
273+
reload_func = node
274+
break
275+
276+
body_text = ast.dump(reload_func)
277+
278+
# 验证 activated 检查是 and 条件(不是 not),所以启用插件正常执行 _unbind
279+
# 即:smd.activated 为 True 时进入 _unbind_plugin,False 时跳过
280+
# 这确保启用插件行为完全不变
281+
282+
# 检查 reload() 中有 smd.activated 的 and 条件
283+
reload_source = "\n".join(
284+
sm_source.split("\n")[reload_func.lineno - 1 : reload_func.end_lineno]
285+
)
286+
287+
# 确认改动后两处都包含 "and smd.activated"
288+
assert reload_source.count("and smd.activated") == 2, (
289+
f"reload() 中应有 2 处 'and smd.activated',"
290+
f"实际 {reload_source.count('and smd.activated')}"
291+
)
292+
293+
# 确认没有 "not smd.activated" 或 "not activated" 导致启用插件被错误跳过
294+
assert "not smd.activated" not in reload_source, (
295+
"不应有 not smd.activated(这会阻止启用插件正常 unbind)"
296+
)

0 commit comments

Comments
 (0)