fixed # 适配AstrBot

This commit is contained in:
2026-10-23 19:12:16 +08:00
parent d0c1741940
commit 685cff9293
49 changed files with 1557 additions and 165 deletions
+42 -1
View File
@@ -31,8 +31,41 @@ class AstrBotPackageTests(unittest.IsolatedAsyncioTestCase):
version = (ROOT / 'version.txt').read_text().strip()
self.assertEqual(manifest['version'], version)
self.assertEqual(json.loads(files['metadata.yaml'])['version'], version)
metadata = json.loads(files['metadata.yaml'])
self.assertEqual(metadata['display_name'], '农场')
self.assertEqual(metadata['support_platforms'], ['aiocqhttp'])
self.assertTrue(metadata['short_desc'])
self.assertEqual(files['logo.png'],
(ROOT / 'logo-astrbot.png').read_bytes())
self.assertEqual(files['logo-readme.png'],
(ROOT / 'logo.png').read_bytes())
self.assertIn('农场', metadata['tags'])
from io import BytesIO
from PIL import Image
with Image.open(BytesIO(files['logo.png'])) as logo:
self.assertEqual(logo.format, 'PNG')
self.assertEqual(logo.width, logo.height)
self.assertEqual(logo.size, (256, 256))
self.assertIn('A', logo.getbands())
self.assertEqual(logo.getchannel('A').getextrema()[0], 0)
self.assertEqual(files['CHANGELOG.md'], (
ROOT / 'integrations/astrbot/CHANGELOG.md'
).read_bytes())
readme = files['README.md'].decode()
self.assertIn('/main/logo-readme.png', readme)
self.assertIn('width="400"', readme)
self.assertNotIn('<!-- FARM_COMMANDS -->', readme)
self.assertIn('/农场帮助 [文字]', readme)
self.assertIn('/开垦 条件', readme)
self.assertIn('raw.githubusercontent.com', readme)
self.assertIn('images/farm-preview.png', files)
self.assertFalse(any(name.endswith('/assets.zip') for name in files))
from verify_astrbot import verify_package
with patch('verify_astrbot.MAX_ARCHIVE_BYTES', 1):
with self.assertRaisesRegex(ValueError, '16 MiB'):
verify_package(first, marketplace=True)
self.assertEqual(files['runtime.py'], (
ROOT / 'integrations/zhenxun/runtime.py'
ROOT / 'integrations/shared/runtime.py'
).read_bytes())
for name, content in files.items():
self.assertNotIn('.git/', name)
@@ -93,6 +126,14 @@ class AstrBotPackageTests(unittest.IsolatedAsyncioTestCase):
plugin = main.FarmPlugin(object(), {
'font_path': str(choose_font()), 'image_quality': 55,
})
# 使用真实开发素材预置缓存,模拟宿主测试不访问网络。
manifest = json.loads((ROOT /
'src/farm_render/data/asset-manifest.json').read_bytes())
cached = (plugin.runtime.data_dir / 'assets'
/ manifest['sha256'] / 'assets.zip')
cached.parent.mkdir(parents=True, exist_ok=True)
cached.write_bytes((ROOT /
'src/farm_render/data/assets.zip').read_bytes())
await plugin.initialize()
self.assertEqual(set(commands), set(main.COMMANDS))
+30 -6
View File
@@ -17,7 +17,7 @@ spec.loader.exec_module(migration)
class ConfigMigrationTests(unittest.TestCase):
def test_zhenxun_registers_global_quality_in_plugin_metadata(self):
def test_zhenxun_registers_quality_and_resource_configuration(self):
plugin = ModuleType('nonebot.plugin')
plugin.PluginMetadata = lambda **values: SimpleNamespace(**values)
utils = ModuleType('zhenxun.configs.utils')
@@ -42,11 +42,20 @@ class ConfigMigrationTests(unittest.TestCase):
exec(compile(source.read_text(encoding='utf-8'), str(source),
'exec'), namespace)
configs = namespace['__plugin_meta__'].extra['configs']
self.assertEqual(len(configs), 1)
self.assertEqual(configs[0].key, '农场图片质量')
self.assertEqual(configs[0].value, 75)
self.assertEqual(configs[0].default_value, 75)
self.assertIs(configs[0].type, int)
self.assertEqual(len(configs), 3)
by_key = {config.key: config for config in configs}
self.assertEqual(set(by_key), {
'农场图片质量', '资源获取地址', 'farm_service 地址',
})
for key, expected, value_type in (
('农场图片质量', 75, int),
('资源获取地址', 'Github', str),
('farm_service 地址', '', str),
):
with self.subTest(key=key):
self.assertEqual(by_key[key].value, expected)
self.assertEqual(by_key[key].default_value, expected)
self.assertIs(by_key[key].type, value_type)
@unittest.skipUnless(importlib.util.find_spec('pydantic'),
'NoneBot host config requires Pydantic')
@@ -67,6 +76,21 @@ class ConfigMigrationTests(unittest.TestCase):
spec.loader.exec_module(module)
config = module.Config
self.assertEqual(config().farm_image_quality, 75)
self.assertEqual(config().farm_resource_source, 'Github')
self.assertEqual(config().farm_service_url, '')
for source in ('Github', 'Gitea', 'farm_service'):
selected = config(
farm_resource_source=source,
farm_service_url='https://farm.example/service',
)
self.assertEqual(selected.farm_resource_source, source)
self.assertEqual(selected.farm_service_url,
'https://farm.example/service')
for source in ('github', 'unknown', '', None):
with self.subTest(source=source), self.assertRaises(
library.ValidationError,
):
config(farm_resource_source=source)
for value in (1, 95):
actual = config(farm_image_quality=value).farm_image_quality
self.assertEqual(actual, value)
+96
View File
@@ -0,0 +1,96 @@
import asyncio
from dataclasses import replace
from io import BytesIO
from pathlib import Path
import sys
import unittest
from unittest.mock import patch
from PIL import Image
from farm_core.commands import (
COMMAND_SPECS, CommandSpec, command_map, command_specs, help_groups,
help_text,
)
from farm_render import FarmRenderer, RenderService
from farm_render.help import help_layout, render_help, wrap_text
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'scripts'))
from ci_font import choose_font
class HelpMenuTests(unittest.IsolatedAsyncioTestCase):
def setUp(self):
self.renderer = FarmRenderer(choose_font())
def test_registration_and_help_share_platform_definitions(self):
exchange = CommandSpec(
'购买农场币', 'fixture_exchange', '金币兑换',
'购买农场币 数量', '仅用于测试已接入的平台命令。',
platforms=('zhenxun',),
)
specs = (*COMMAND_SPECS, exchange)
for platform in ('zhenxun', 'nonebot', 'astrbot'):
selected = command_specs(platform, specs)
mapping = command_map(platform, specs)
self.assertEqual('购买农场币' in mapping, platform == 'zhenxun')
self.assertEqual('金币兑换' in help_text(selected),
platform == 'zhenxun')
for spec in selected:
self.assertEqual(mapping[spec.name], spec.action)
self.assertIn(spec.usage, help_text(selected))
self.assertEqual(mapping['除虫'], mapping['农场除虫'])
with self.assertRaises(ValueError):
command_map('zhenxun', (*specs, exchange))
def test_long_future_commands_wrap_without_loss_and_grow(self):
specs = command_specs('astrbot')
long = '很长的新增指令参数与说明' * 8
lines = wrap_text(long, self.renderer.font_path, 28, 484)
self.assertEqual(''.join(lines), long)
from farm_render.canvas import Canvas
self.assertTrue(all(Canvas.measure_text(
line, self.renderer.font_path, 28,
) <= 484 for line in lines))
extra = tuple(CommandSpec(
f'新增{i}', f'action{i}', '新增功能', long, long,
) for i in range(10))
before = help_layout(specs, self.renderer.font_path)[1]
after = help_layout((*specs, *extra), self.renderer.font_path)[1]
self.assertGreater(after, before)
grouped = help_groups((*specs, *extra))
self.assertEqual(grouped[-1][0], '新增功能')
async def test_image_codec_quality_cache_and_concurrent_dedup(self):
specs = command_specs('astrbot')
service = RenderService(self.renderer)
try:
with patch('farm_render.service.render_help',
wraps=render_help) as fn:
results = await asyncio.gather(*(
service.help_menu(specs, 'astrbot', 'JPEG', 75)
for _ in range(4)
))
self.assertEqual(fn.call_count, 1)
self.assertTrue(all(r == results[0] for r in results))
self.assertEqual(await service.help_menu(
specs, 'astrbot', 'JPEG', 75,
), results[0])
self.assertEqual(fn.call_count, 1)
changed = (*specs, replace(specs[-1], name='扩展帮助',
usage='扩展帮助'))
await service.help_menu(changed, 'astrbot', 'JPEG', 75)
await service.help_menu(specs, 'nonebot', 'JPEG', 75)
await service.help_menu(specs, 'astrbot', 'JPEG', 90)
self.assertEqual(fn.call_count, 4)
with Image.open(BytesIO(results[0])) as image:
self.assertEqual(image.width, 1200)
self.assertGreater(image.height, 1200)
self.assertEqual(image.format, 'JPEG')
png = await service.help_menu(specs, 'astrbot', 'PNG', 75)
with Image.open(BytesIO(png)) as image:
self.assertEqual(image.format, 'PNG')
finally:
await service.close()
with self.assertRaises(RuntimeError):
await service.help_menu(specs, 'astrbot')
+4 -2
View File
@@ -26,15 +26,17 @@ class NoneBotPackageTests(unittest.TestCase):
other = build(ROOT, self.root / "second")
self.assertEqual(self.bundle.read_bytes(), other.read_bytes())
self.assertEqual(self.files["nonebot_plugin_farm/runtime.py"], (
ROOT / "integrations/zhenxun/runtime.py"
ROOT / "integrations/shared/runtime.py"
).read_bytes())
for suffix in ("farm_core/data/plants.json",
"farm_render/data/assets.zip"):
"farm_render/data/asset-manifest.json"):
self.assertTrue(any(name.endswith(suffix) for name in self.files))
self.assertEqual(verify_files(self.files)["target"], "nonebot")
self.assertIn("pyproject.toml", self.files)
self.assertIn(".github/workflows/publish.yaml", self.files)
self.assertFalse(any("legacy/" in name for name in self.files))
self.assertFalse(any(name.endswith('/assets.zip')
for name in self.files))
with self.assertRaises(FileExistsError):
build(ROOT, self.root / "built")
+67 -8
View File
@@ -28,6 +28,7 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase):
sys.modules[self.package] = module
self.module = importlib.import_module(self.package + ".runtime")
self.runtime = self.module.Runtime(self.root / "data", choose_font())
self.seed_assets(self.runtime)
await self.runtime.start()
self.context = dict(bot_id="bot", platform="OneBot V11", user_id="1",
message_id="registration", display_name="离线玩家")
@@ -39,6 +40,15 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase):
del sys.modules[name]
self.temporary.cleanup()
def seed_assets(self, runtime):
bundle = importlib.import_module(
self.package + '._vendor.farm_render.bundle',
)
target = (runtime.data_dir / 'assets'
/ bundle.expected_manifest()['sha256'] / 'assets.zip')
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(ROOT / 'src/farm_render/data/assets.zip', target)
async def call(self, action, arguments="", message="event", **changes):
return await self.runtime.execute(
action, arguments,
@@ -50,7 +60,7 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase):
from PIL import Image
from unittest.mock import AsyncMock
self.assertEqual(self.module.COMMANDS['地块编号'], 'plot_ids')
self.assertIn('地块编号', await self.call('help'))
self.assertIn('地块编号', await self.call('help', '文字'))
first = await self.call('register')
self.assertIn('6块土地', first)
self.assertIn('地块编号', first)
@@ -71,9 +81,47 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase):
self.assertIn('参数有误', await self.call('plot_ids', '1'))
self.assertIn('开通农场', await self.call('plot_ids', user_id='other'))
async def test_dynamic_help_image_text_fallback_and_no_player_access(self):
from io import BytesIO
from PIL import Image
from unittest.mock import AsyncMock
with patch.object(self.runtime.store, 'migrate_legacy',
new=AsyncMock()) as migrate:
image = await self.call('help', message=None)
migrate.assert_not_awaited()
with Image.open(BytesIO(image)) as decoded:
self.assertEqual(decoded.width, 1200)
text = await self.call('help', '文字', message=None)
for command in self.module.COMMANDS:
self.assertIn(command, text)
self.assertNotIn('购买农场币', text)
self.assertIn('仅我的农场支持高清',
await self.call('help', '高清'))
self.assertIn('参数有误', await self.call('help', '2'))
with patch.object(self.runtime.rendering, 'help_menu',
new=AsyncMock(side_effect=OSError('fixture'))):
self.assertEqual(await self.call('help'), text)
rendering = self.runtime.rendering
self.runtime.rendering = None
try:
self.assertEqual(await self.call('help'), text)
finally:
self.runtime.rendering = rendering
async def test_help_uses_live_global_quality(self):
from unittest.mock import AsyncMock
with patch.object(self.runtime.rendering, 'help_menu',
new=AsyncMock(return_value=b'image')) as render:
self.runtime.image_quality_provider = lambda: 91
self.assertEqual(await self.call('help'), b'image')
self.assertEqual(render.await_args.args[-1], 91)
self.runtime.image_quality_provider = lambda: False
self.assertIn('图片质量配置无效', await self.call('help'))
self.assertIsInstance(await self.call('help', '文字'), str)
async def test_reclaim_conditions_charge_replay_and_arguments(self):
self.assertEqual(self.module.COMMANDS['开垦'], 'reclaim')
self.assertIn('开垦 条件', await self.call('help'))
self.assertIn('开垦 条件', await self.call('help', '文字'))
await self.call('register')
who = self.runtime_identity()
asset = importlib.import_module(
@@ -226,7 +274,7 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase):
))
for action in ('seeds', 'crops'):
self.assertIsInstance(await self.call(action, '1 文字'), str)
help_text = await self.call('help')
help_text = await self.call('help', '文字')
self.assertIn('仅我的农场支持高清', help_text)
self.assertNotIn('地块编号 [高清]', help_text)
self.assertNotIn('[高清/文字]', help_text)
@@ -328,7 +376,7 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase):
async def test_pest_command_clears_pests_and_preserves_growth(self):
self.assertEqual(self.module.COMMANDS["农场除虫"], "clear_pests")
help_text = await self.call("help")
help_text = await self.call("help", "文字")
self.assertIn("每只虫", help_text)
self.assertIn("间隔至少2小时", help_text)
self.runtime.service.clock = lambda: 1000
@@ -414,6 +462,7 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase):
other = self.module.Runtime(
self.root / "without-font", self.root / "missing.ttf",
)
self.seed_assets(other)
await other.start()
try:
response = await other.execute("farm", "", **self.context)
@@ -427,6 +476,7 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase):
(fonts / "HYWenHei-85W.ttf").write_bytes(b"broken")
shutil.copyfile(choose_font(), fonts / "msyh.ttf")
other = self.module.Runtime(self.root / "default-font", font_dir=fonts)
self.seed_assets(other)
await other.start()
try:
self.assertIsNotNone(other.rendering)
@@ -446,6 +496,7 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase):
self.root / "explicit-font", explicit,
font_dir=self.root / "not-present",
)
self.seed_assets(other)
await other.start()
try:
self.assertEqual(other.rendering.renderer.font_path, explicit)
@@ -465,6 +516,7 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase):
old = self.root / 'directories/farm_preview'
target = old.with_name('farm_db')
previous = self.module.Runtime(old, choose_font())
self.seed_assets(previous)
await previous.start()
try:
await previous.execute('register', '旧农场', **self.context)
@@ -474,6 +526,7 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase):
before = old_database.read_bytes()
target.mkdir()
current = self.module.Runtime(target, choose_font())
self.seed_assets(current)
await current.start()
try:
self.assertEqual(old_database.read_bytes(), before)
@@ -557,12 +610,16 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase):
platform.PlatformUtils = SimpleNamespace()
config = ModuleType("zhenxun.configs.config")
quality_values = {'value': 49}
def configured_value(key, default, **kwargs):
if key != '农场图片质量':
return default
value = quality_values['value']
return value if not kwargs['build_model'] else int(value)
config.Config = SimpleNamespace(
get=lambda module: SimpleNamespace(
configs={}, get=lambda key, default, **kwargs: (
quality_values['value'] if not kwargs['build_model']
else int(quality_values['value'])
),
configs={}, get=configured_value,
),
get_config=lambda *args, **kwargs: int(quality_values['value']),
_simple_data={},
@@ -584,6 +641,8 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase):
(self.root / 'farm_db').resolve())
self.assertEqual(set(hooks["commands"]), set(self.module.COMMANDS))
self.assertEqual(len(hooks["start"]), 1)
self.assertEqual(imported.resource_options(), ('Github', ''))
self.seed_assets(imported.runtime)
await hooks["start"][0]()
try:
bot = SimpleNamespace(self_id="bot", adapter=SimpleNamespace(