feat: 新增通知图片测试命令
This commit is contained in:
538
tests/test_danding_qqpush_test_command.py
Normal file
538
tests/test_danding_qqpush_test_command.py
Normal file
@@ -0,0 +1,538 @@
|
||||
"""通知图片测试命令测试:命令/别名、固定预览内容、群聊/私聊场景与发送约定。
|
||||
|
||||
直接导入插件包会触发 NoneBot 插件初始化,因此这里按路径加载 test_command.py,
|
||||
同级模块用轻量替身(text_parser 用真实实现,命令匹配与权限用真实 NoneBot 规则)。
|
||||
"""
|
||||
import ast
|
||||
import asyncio
|
||||
import base64
|
||||
import importlib.util
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
import nonebot
|
||||
import pytest
|
||||
from nonebot.adapters.onebot.v11 import Message, MessageSegment
|
||||
from nonebot.adapters.onebot.v11.event import MessageEvent
|
||||
from nonebot.rule import CommandRule
|
||||
|
||||
PLUGIN_DIR = (
|
||||
Path(__file__).resolve().parents[1]
|
||||
/ "danding_bot"
|
||||
/ "plugins"
|
||||
/ "danding_qqpush"
|
||||
).resolve()
|
||||
PACKAGE_NAME = "danding_bot.plugins.danding_qqpush"
|
||||
|
||||
# 与 .env 中的 SUPERUSERS 一致,用于权限判定
|
||||
SUPERUSER_ID = 1424473282
|
||||
NON_SUPERUSER_ID = 10001111
|
||||
|
||||
|
||||
class ConfigStub:
|
||||
"""渲染器所需配置字段的轻量替身。"""
|
||||
|
||||
Token = "danding-test"
|
||||
ImageWidth = 800
|
||||
ImageFontSize = 24
|
||||
ImagePadding = 30
|
||||
ImageLineSpacing = 1.4
|
||||
ImageBgColor = (252, 252, 252)
|
||||
ImageTextColor = (0, 0, 0)
|
||||
MaxTextLength = 2000
|
||||
FontPaths = ["C:/Windows/Fonts/msyh.ttc"]
|
||||
|
||||
|
||||
class ImageRendererStub:
|
||||
"""只记录构造参数的替身,用于验证渲染器按配置构建。"""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
self.kwargs = kwargs
|
||||
|
||||
def render_to_base64(self, text, title=None, scene="common"):
|
||||
return "base64://" + base64.b64encode(b"fake-png").decode("utf-8")
|
||||
|
||||
|
||||
class FakeMessageEvent:
|
||||
"""仅保留 scene 判定需要的字段。"""
|
||||
|
||||
def __init__(self, group_id=None, user_id=10001):
|
||||
self.group_id = group_id
|
||||
self.user_id = user_id
|
||||
|
||||
|
||||
class FakeMatcher:
|
||||
"""记录 finish 调用(失败提示走这里)。"""
|
||||
|
||||
def __init__(self):
|
||||
self.finished = []
|
||||
|
||||
async def finish(self, message=None, **kwargs):
|
||||
self.finished.append(message)
|
||||
|
||||
|
||||
class RecordingBot:
|
||||
"""记录 bot.send 的 (event, message)。"""
|
||||
|
||||
def __init__(self):
|
||||
self.sent = []
|
||||
|
||||
async def send(self, event, message, **kwargs):
|
||||
self.sent.append((event, message))
|
||||
return {"message_id": len(self.sent)}
|
||||
|
||||
|
||||
def build_permission_bot(driver):
|
||||
"""构造权限判定用的真实 OneBot V11 Bot。
|
||||
|
||||
matcher.permission 经 NoneBot 依赖注入会校验 bot 类型,
|
||||
必须是真正的 nonebot.adapters.Bot,所以用真实 Adapter + Bot,
|
||||
并把已初始化驱动配置中的超级用户列表(字符串化)拷进 Bot 配置,
|
||||
保证超级用户判定与真实运行时一致且不产生 pydantic 归一化告警。
|
||||
"""
|
||||
from nonebot.adapters.onebot.v11.adapter import Adapter
|
||||
from nonebot.adapters.onebot.v11.bot import Bot
|
||||
|
||||
bot = Bot(Adapter(driver), self_id="10000")
|
||||
bot.config.superusers = {str(user_id) for user_id in driver.config.superusers}
|
||||
return bot
|
||||
|
||||
|
||||
@contextmanager
|
||||
def silence_nonebot_logs():
|
||||
"""初始化期间完全静默 NoneBot 日志,避免把 .env 配置打进测试输出。"""
|
||||
logging.getLogger("nonebot").setLevel(logging.CRITICAL + 1)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
logging.getLogger("nonebot").setLevel(logging.CRITICAL)
|
||||
|
||||
|
||||
def ensure_nonebot_initialized():
|
||||
"""on_command 需要已初始化的 NoneBot:只创建驱动,不启动服务、不加载插件。"""
|
||||
try:
|
||||
nonebot.get_driver()
|
||||
except ValueError:
|
||||
with silence_nonebot_logs():
|
||||
nonebot.init(
|
||||
log_level="CRITICAL",
|
||||
driver="~fastapi+~websockets",
|
||||
host="127.0.0.1",
|
||||
port=18080,
|
||||
)
|
||||
driver = nonebot.get_driver()
|
||||
# 保证超级用户判定不受 .env 的 SUPERUSERS 配置影响;
|
||||
# 与 .env 一致使用字符串形式,避免驱动配置被 int/str 混装归一化
|
||||
if str(SUPERUSER_ID) not in driver.config.superusers:
|
||||
driver.config.superusers.add(str(SUPERUSER_ID))
|
||||
return driver
|
||||
|
||||
|
||||
def _load_module(name, path):
|
||||
spec = importlib.util.spec_from_file_location(f"{PACKAGE_NAME}.{name}", path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def load_test_command():
|
||||
"""按路径加载 test_command.py,同时用替身满足同级模块依赖。"""
|
||||
if PACKAGE_NAME in sys.modules:
|
||||
return sys.modules[PACKAGE_NAME + ".test_command"]
|
||||
|
||||
ensure_nonebot_initialized()
|
||||
|
||||
package = types.ModuleType(PACKAGE_NAME)
|
||||
package.__path__ = [str(PLUGIN_DIR)]
|
||||
sys.modules[PACKAGE_NAME] = package
|
||||
|
||||
# text_parser 用真实实现:预览文本要经它把 # 解析为换行
|
||||
_load_module("text_parser", PLUGIN_DIR / "text_parser.py")
|
||||
|
||||
config_module = types.ModuleType(PACKAGE_NAME + ".config")
|
||||
config_module.Config = ConfigStub
|
||||
sys.modules[config_module.__name__] = config_module
|
||||
|
||||
image_render_module = types.ModuleType(PACKAGE_NAME + ".image_render")
|
||||
image_render_module.ImageRenderer = ImageRendererStub
|
||||
sys.modules[image_render_module.__name__] = image_render_module
|
||||
|
||||
# preview_image_render 是 test_command 唯一依赖的同级辅助模块
|
||||
_load_module("preview_image_render", PLUGIN_DIR / "preview_image_render.py")
|
||||
|
||||
module = _load_module("test_command", PLUGIN_DIR / "test_command.py")
|
||||
# 供 handler 测试使用的插件配置替身
|
||||
package.plugin_config = ConfigStub()
|
||||
return module
|
||||
|
||||
|
||||
def load_real_image_renderer():
|
||||
"""按路径加载真实渲染模块(不引入 NoneBot)。"""
|
||||
name = PACKAGE_NAME + ".real_image_render"
|
||||
if name in sys.modules:
|
||||
return sys.modules[name]
|
||||
# 真实 image_render 不含相对导入,直接按路径加载即可
|
||||
path = PLUGIN_DIR / "image_render.py"
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def source_tree():
|
||||
tree = ast.parse((PLUGIN_DIR / "test_command.py").read_text(encoding="utf-8"))
|
||||
if os.getenv("DSH_DEBUG_QQPUSH"):
|
||||
print(
|
||||
"\nDEBUG async nodes:",
|
||||
[
|
||||
(n.name, [is_handle_decorator(d) for d in n.decorator_list])
|
||||
for n in ast.walk(tree)
|
||||
if isinstance(n, ast.AsyncFunctionDef)
|
||||
],
|
||||
)
|
||||
return tree
|
||||
|
||||
|
||||
def source_text() -> str:
|
||||
return (PLUGIN_DIR / "test_command.py").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def build_event(text: str, group_id: int | None = None, user_id: int = SUPERUSER_ID):
|
||||
"""构造 OneBot V11 消息事件,用于命令匹配与权限判定。"""
|
||||
if group_id is None:
|
||||
return MessageEvent(
|
||||
time=0,
|
||||
self_id=10000,
|
||||
post_type="message",
|
||||
sub_type="friend",
|
||||
user_id=user_id,
|
||||
message_type="private",
|
||||
message_id=1,
|
||||
message=Message(text),
|
||||
original_message=Message(text),
|
||||
raw_message=text,
|
||||
font=0,
|
||||
sender={"user_id": user_id},
|
||||
)
|
||||
return MessageEvent(
|
||||
time=0,
|
||||
self_id=10000,
|
||||
post_type="message",
|
||||
sub_type="normal",
|
||||
user_id=user_id,
|
||||
message_type="group",
|
||||
message_id=1,
|
||||
message=Message(text),
|
||||
original_message=Message(text),
|
||||
raw_message=text,
|
||||
font=0,
|
||||
sender={"user_id": user_id},
|
||||
group_id=group_id,
|
||||
)
|
||||
|
||||
|
||||
def command_matches(matcher, event) -> bool:
|
||||
"""按 NoneBot 依赖注入的语义执行命令匹配规则。"""
|
||||
state: dict = {}
|
||||
for checker in matcher.rule.checkers:
|
||||
if isinstance(checker.call, CommandRule):
|
||||
state = {
|
||||
"_prefix": {
|
||||
"command": (event.get_plaintext().strip(),),
|
||||
"command_arg": None,
|
||||
"command_whitespace": None,
|
||||
}
|
||||
}
|
||||
break
|
||||
return asyncio.run(matcher.rule(build_permission_bot(nonebot.get_driver()), event, state))
|
||||
|
||||
|
||||
def has_permission(matcher, event) -> bool:
|
||||
"""按 NoneBot 运行时约定调用 matcher 的 permission(Superuser 需要 bot.adapter/bot.config)。"""
|
||||
return asyncio.run(
|
||||
matcher.permission(build_permission_bot(nonebot.get_driver()), event)
|
||||
)
|
||||
|
||||
|
||||
def test_main_command_and_aliases_are_registered_and_match_plain_messages():
|
||||
"""主命令「测试通知图片」与两个别名都要能直接触发(群聊、私聊都不需要 @机器人)。"""
|
||||
module = load_test_command()
|
||||
matcher = module.test_notification_image
|
||||
|
||||
assert module.TEST_COMMAND == "测试通知图片"
|
||||
assert set(module.TEST_COMMAND_ALIASES) == {"通知图片测试", "测试通知卡片"}
|
||||
|
||||
for text in (module.TEST_COMMAND, *module.TEST_COMMAND_ALIASES):
|
||||
for group_id in (None, 621016172):
|
||||
assert command_matches(
|
||||
matcher, build_event(text, group_id)
|
||||
), f"命令 {text} 应可触发(group_id={group_id})"
|
||||
|
||||
# 无关消息不触发
|
||||
for text in ("测试通知", "通知图片", "测试通知图片额外内容"):
|
||||
assert not command_matches(matcher, build_event(text))
|
||||
|
||||
|
||||
def test_command_is_superuser_only_and_not_at_restricted():
|
||||
module = load_test_command()
|
||||
matcher = module.test_notification_image
|
||||
|
||||
call = command_registration_source()
|
||||
keywords = keyword_names(call)
|
||||
|
||||
permission = keywords["permission"]
|
||||
assert isinstance(permission, ast.Name) and permission.id == "SUPERUSER"
|
||||
assert any(
|
||||
isinstance(node, ast.ImportFrom)
|
||||
and node.module == "nonebot.permission"
|
||||
and any(alias.name == "SUPERUSER" for alias in node.names)
|
||||
for node in ast.walk(source_tree())
|
||||
)
|
||||
|
||||
# 群聊与私聊都要能触发:不限制 to_me,也不限制会话类型
|
||||
assert "rule" not in keywords
|
||||
assert ast.literal_eval(keywords["block"]) is True
|
||||
assert "to_me" not in source_text()
|
||||
|
||||
# 权限判定走真实 NoneBot Permission 对象
|
||||
assert has_permission(matcher, build_event(module.TEST_COMMAND)) is True
|
||||
assert not has_permission(
|
||||
matcher, build_event(module.TEST_COMMAND, user_id=NON_SUPERUSER_ID)
|
||||
)
|
||||
|
||||
|
||||
def test_single_handler_is_registered_on_the_matcher():
|
||||
handlers = [
|
||||
node
|
||||
for node in ast.walk(source_tree())
|
||||
if isinstance(node, ast.AsyncFunctionDef)
|
||||
if any(is_handle_decorator(decorator) for decorator in node.decorator_list)
|
||||
]
|
||||
assert [node.name for node in handlers] == ["handle_test_notification_image"]
|
||||
assert [arg.arg for arg in handlers[0].args.args] == ["matcher", "bot", "event"]
|
||||
# 使用当前事件的 Bot 发送,渲染放到线程池
|
||||
assert "bot.send(event, build_message(image_base64))" in source_text()
|
||||
assert "asyncio.to_thread" in source_text()
|
||||
|
||||
|
||||
def test_plugin_package_registers_the_test_command():
|
||||
"""插件包导入时须加载该模块,命令才会真正注册。"""
|
||||
source = (PLUGIN_DIR / "__init__.py").read_text(encoding="utf-8")
|
||||
|
||||
assert any(
|
||||
isinstance(node, ast.ImportFrom)
|
||||
and node.module is None
|
||||
and any(alias.name == "test_command" for alias in node.names)
|
||||
for node in ast.walk(ast.parse(source))
|
||||
), "插件 __init__.py 应以相对导入加载 test_command"
|
||||
assert "测试通知图片" in source
|
||||
assert source.index("plugin_config = Config.model_validate") < source.index(
|
||||
"from . import test_command"
|
||||
), "插件包须先完成配置初始化,再导入测试命令"
|
||||
|
||||
|
||||
def test_preview_content_covers_title_chinese_multiline_emoji_and_punctuation():
|
||||
module = load_test_command()
|
||||
text = module.preview_text()
|
||||
|
||||
assert module.PREVIEW_TITLE
|
||||
# 标题同时演示 # 作为换行符的用法
|
||||
assert module.PREVIEW_TITLE != module.PREVIEW_TITLE.replace("#", "")
|
||||
|
||||
# 标题、中文、多行、Emoji 与中文标点都要有,方便肉眼核对版式
|
||||
assert "#" in text, "预览文本应使用 # 表示换行"
|
||||
assert any("\u4e00" <= char <= "\u9fff" for char in text), "预览应包含中文"
|
||||
assert len(text.split("#")) >= 3, "预览应为多行内容"
|
||||
assert any(char in text for char in ",。、;:!?"), "预览应包含中文标点"
|
||||
assert any(ord(char) > 0x1F000 for char in text), "预览应包含 Emoji"
|
||||
|
||||
# 经 TextParser 解析后,与 PREVIEW_TEXT 完全一致(# 均表示换行)
|
||||
parser = sys.modules[PACKAGE_NAME + ".text_parser"].TextParser(
|
||||
max_length=ConfigStub.MaxTextLength
|
||||
)
|
||||
assert parser.parse(text) == module.PREVIEW_TEXT
|
||||
assert module.PREVIEW_TEXT.count("\n") == text.count("#")
|
||||
assert "#" not in module.PREVIEW_TEXT
|
||||
|
||||
|
||||
def test_scene_resolution_uses_group_for_group_events_and_private_otherwise():
|
||||
module = load_test_command()
|
||||
|
||||
assert module.resolve_scene(FakeMessageEvent(group_id=123456789)) == "group"
|
||||
assert module.resolve_scene(FakeMessageEvent()) == "private"
|
||||
# group_id=0 视为无效群号,同样回退到私聊
|
||||
assert module.resolve_scene(FakeMessageEvent(group_id=0)) == "private"
|
||||
assert module.resolve_scene(build_event("测试通知图片", 621016172)) == "group"
|
||||
assert module.resolve_scene(build_event("测试通知图片")) == "private"
|
||||
|
||||
# 两个场景徽章都由现有渲染器提供,测试命令不新增场景
|
||||
real = load_real_image_renderer()
|
||||
assert "group" in real.ImageRenderer.SCENE_STYLES
|
||||
assert "private" in real.ImageRenderer.SCENE_STYLES
|
||||
|
||||
|
||||
def test_render_runs_in_thread_and_passes_scene_and_title(monkeypatch):
|
||||
module = load_test_command()
|
||||
calls = []
|
||||
|
||||
class RecordingRenderer:
|
||||
def render_to_base64(self, text, title=None, scene="common"):
|
||||
calls.append({"text": text, "title": title, "scene": scene})
|
||||
return "base64://cG5n"
|
||||
|
||||
monkeypatch.setattr(module, "get_preview_renderer", lambda config: RecordingRenderer())
|
||||
real_to_thread = asyncio.to_thread
|
||||
to_thread_calls = []
|
||||
|
||||
async def spy_to_thread(func, *args, **kwargs):
|
||||
to_thread_calls.append(func)
|
||||
return await real_to_thread(func, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(module.asyncio, "to_thread", spy_to_thread)
|
||||
|
||||
for group_id, expected_scene in ((987654321, "group"), (None, "private")):
|
||||
calls.clear()
|
||||
to_thread_calls.clear()
|
||||
matcher = FakeMatcher()
|
||||
bot = RecordingBot()
|
||||
|
||||
asyncio.run(
|
||||
module.handle_test_notification_image(
|
||||
matcher, bot, FakeMessageEvent(group_id=group_id)
|
||||
)
|
||||
)
|
||||
|
||||
assert to_thread_calls, "Pillow 渲染必须放到 asyncio.to_thread"
|
||||
assert [call["scene"] for call in calls] == [expected_scene]
|
||||
assert calls[0]["title"] == module.PREVIEW_TITLE
|
||||
assert calls[0]["text"] == module.PREVIEW_TEXT
|
||||
assert calls[0]["text"] == module.preview_text().replace("#", "\n")
|
||||
assert not matcher.finished
|
||||
|
||||
|
||||
def test_handler_sends_onebot_v11_image_message_via_event_bot():
|
||||
module = load_test_command()
|
||||
matcher = FakeMatcher()
|
||||
bot = RecordingBot()
|
||||
event = FakeMessageEvent(group_id=1)
|
||||
|
||||
rendered = "base64://" + base64.b64encode(b"png-bytes").decode("utf-8")
|
||||
message = module.build_message(rendered)
|
||||
|
||||
assert message[0].type == "image"
|
||||
assert message[0].data["file"] == rendered
|
||||
assert module.build_message(rendered) == message
|
||||
|
||||
asyncio.run(module.handle_test_notification_image(matcher, bot, event))
|
||||
|
||||
assert len(bot.sent) == 1
|
||||
sent_event, sent_message = bot.sent[0]
|
||||
assert sent_event is event
|
||||
assert sent_message[0].type == "image"
|
||||
assert sent_message[0].data["file"].startswith("base64://")
|
||||
assert not matcher.finished
|
||||
|
||||
|
||||
def test_render_failure_logs_and_replies_with_readable_hint(monkeypatch):
|
||||
module = load_test_command()
|
||||
matcher = FakeMatcher()
|
||||
bot = RecordingBot()
|
||||
logged = []
|
||||
|
||||
class BrokenRenderer:
|
||||
def render_to_base64(self, text, title=None, scene="common"):
|
||||
raise RuntimeError("字体缺失")
|
||||
|
||||
monkeypatch.setattr(module, "get_preview_renderer", lambda config: BrokenRenderer())
|
||||
monkeypatch.setattr(module.logger, "error", lambda message: logged.append(str(message)))
|
||||
|
||||
asyncio.run(module.handle_test_notification_image(matcher, bot, FakeMessageEvent()))
|
||||
|
||||
assert not bot.sent
|
||||
assert len(matcher.finished) == 1
|
||||
assert isinstance(matcher.finished[0], str)
|
||||
assert "字体缺失" in logged[0]
|
||||
assert "scene=private" in logged[0]
|
||||
|
||||
|
||||
def test_preview_renderer_is_built_from_plugin_config_and_reused():
|
||||
module = load_test_command()
|
||||
helper = sys.modules[PACKAGE_NAME + ".preview_image_render"]
|
||||
helper._preview_renderer = None
|
||||
|
||||
first = module.get_preview_renderer(ConfigStub())
|
||||
second = module.get_preview_renderer(ConfigStub())
|
||||
|
||||
assert isinstance(first, ImageRendererStub)
|
||||
assert first is second, "渲染器实例应被缓存复用"
|
||||
assert first.kwargs["width"] == ConfigStub.ImageWidth
|
||||
assert first.kwargs["font_size"] == ConfigStub.ImageFontSize
|
||||
assert first.kwargs["padding"] == ConfigStub.ImagePadding
|
||||
assert first.kwargs["line_spacing"] == ConfigStub.ImageLineSpacing
|
||||
assert tuple(first.kwargs["bg_color"]) == tuple(ConfigStub.ImageBgColor)
|
||||
assert tuple(first.kwargs["text_color"]) == tuple(ConfigStub.ImageTextColor)
|
||||
assert list(first.kwargs["font_paths"]) == list(ConfigStub.FontPaths)
|
||||
|
||||
helper._preview_renderer = None
|
||||
|
||||
|
||||
def test_preview_renders_through_the_shared_renderer():
|
||||
"""预览内容必须能真正渲染成通知卡片(使用真实 ImageRenderer)。"""
|
||||
module = load_test_command()
|
||||
real = load_real_image_renderer()
|
||||
renderer = real.ImageRenderer(
|
||||
width=800, font_size=24, padding=30, font_paths=ConfigStub.FontPaths
|
||||
)
|
||||
|
||||
png = renderer.render(module.PREVIEW_TEXT, title=module.PREVIEW_TITLE, scene="group")
|
||||
assert png.startswith(b"\x89PNG")
|
||||
|
||||
from PIL import Image
|
||||
|
||||
image = Image.open(io.BytesIO(png))
|
||||
assert image.size[0] == 800
|
||||
assert image.size[1] >= real.ImageRenderer.MIN_HEIGHT
|
||||
|
||||
encoded = renderer.render_to_base64(
|
||||
module.PREVIEW_TEXT, module.PREVIEW_TITLE, "private"
|
||||
)
|
||||
assert encoded.startswith("base64://")
|
||||
assert base64.b64decode(encoded[len("base64://"):]) == renderer.render(
|
||||
module.PREVIEW_TEXT, title=module.PREVIEW_TITLE, scene="private"
|
||||
)
|
||||
|
||||
|
||||
def is_handle_decorator(decorator) -> bool:
|
||||
"""识别 @matcher.handle 与 @matcher.handle() 两种等价写法。"""
|
||||
target = decorator.func if isinstance(decorator, ast.Call) else decorator
|
||||
return isinstance(target, ast.Attribute) and target.attr == "handle"
|
||||
|
||||
|
||||
def command_registration_source():
|
||||
"""解析 test_command.py 中 on_command 的注册调用。"""
|
||||
for node in ast.walk(source_tree()):
|
||||
if not isinstance(node, ast.Call):
|
||||
continue
|
||||
func = node.func
|
||||
if isinstance(func, ast.Name) and func.id == "on_command":
|
||||
return node
|
||||
raise AssertionError("test_command.py 中未找到 on_command 注册调用")
|
||||
|
||||
|
||||
def keyword_names(call):
|
||||
return {
|
||||
keyword.arg: keyword.value
|
||||
for keyword in call.keywords
|
||||
if keyword.arg is not None
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
raise SystemExit(pytest.main([__file__, "-q"]))
|
||||
Reference in New Issue
Block a user