fixed # 修复astrbot日志未使用logging.basicConfig

This commit is contained in:
2026-10-27 11:40:59 +08:00
parent b3455ded58
commit d05b2e5765
20 changed files with 333 additions and 46 deletions
+9 -1
View File
@@ -31,7 +31,15 @@
启动会核对清单、大小、SHA-256、JSON 契约和 ZIP 图片内容,成功后才原子切换
数据目录下的 `catalogue/current.json`。断网、404 或版本无效时优先使用已经
验证的缓存,再使用插件基线。日志位于同一数据目录的 `farm-preview.log`。
验证的缓存,再使用插件基线。真寻和NoneBot日志位于同一数据目录的
`farm-preview.log`;AstrBot统一使用宿主日志,在AstrBot控制台或日志页查看,
插件不创建独立日志文件,也不修改宿主日志级别或处理器。
终端同时显示启动阶段、缓存复用、下载开始、进度、完成及回退信息。下载进度
最多每秒输出一次;服务器提供文件总大小时显示百分比及 MiB,未提供时只显示
已下载量。文件下载完成后仍需校验,只有整套校验通过才切换版本。插件优先
复用宿主已有的 INFO 终端日志通道;真寻和NoneBot没有时添加一个并在关闭时
移除。AstrBot始终使用 `astrbot.api.logger`,输出级别和位置由宿主管理。
备份应包含完整数据目录及其 `catalogue` 缓存。玩家库存或地块若引用基线没有的
新作物 ID,而可用缓存和远端均不能提供这些 ID,插件会明确拒绝启动,避免
+6
View File
@@ -1,5 +1,11 @@
# 更新日志
## 待发布
- 全部插件及私有共享模块日志接入AstrBot官方logger,保留启动下载进度。
- 不再配置独立日志文件或修改宿主日志处理器;移除包内开发命令行入口。
- 新增发布包日志规范检查,拒绝内置logging及自建日志处理器。
## 2.0.2 — 待发布
### 新增
+1 -2
View File
@@ -2,14 +2,13 @@
import asyncio
from collections import OrderedDict
import logging
import re
from time import monotonic
import aiohttp
from astrbot.api import logger
logger = logging.getLogger(__name__)
MAX_BYTES = 2 * 1024 * 1024
MAX_ENTRIES = 16
FETCH_TIMEOUT = 2
+1 -4
View File
@@ -2,10 +2,10 @@
import asyncio
import json
import logging
from pathlib import Path
from astrbot.api import AstrBotConfig
from astrbot.api import logger
from astrbot.api.event import AstrMessageEvent, filter
from astrbot.api.message_components import At, Image
from astrbot.api.star import Context, Star, StarTools
@@ -16,9 +16,6 @@ from .runtime import Runtime, command_map
COMMANDS = command_map('astrbot')
logger = logging.getLogger(__name__)
class FarmPlugin(Star):
def __init__(self, context: Context, config: AstrBotConfig):
super().__init__(context)
+9
View File
@@ -0,0 +1,9 @@
"""AstrBot拥有日志配置和输出,插件不创建或关闭任何日志处理器。"""
def open_logs(data_dir):
return None, None
def close_logs(file_handler, console_handler):
pass
+9 -12
View File
@@ -10,6 +10,7 @@ import shlex
from urllib.error import HTTPError, URLError
from zipfile import BadZipFile
from .runtime_logging import open_logs, close_logs
from ._vendor.farm_core import Catalogue, FarmService, Identity, SQLiteStore
from ._vendor.farm_core.domain import FarmError
from ._vendor.farm_core.commands import (
@@ -133,6 +134,7 @@ class Runtime:
self.rendering = None
self.started = False
self.log_handler = None
self.console_handler = None
self.legacy_source = self.data_dir / 'farm.db'
self._resource_lock = asyncio.Lock()
self._closing = False
@@ -153,15 +155,9 @@ class Runtime:
return
epoch = self._start_epoch
self.data_dir.mkdir(parents=True, exist_ok=True)
self.log_handler = logging.FileHandler(
self.data_dir / "farm-preview.log", encoding="utf-8",
)
self.log_handler.setFormatter(logging.Formatter(
"%(asctime)s %(levelname)s %(name)s %(message)s",
))
logging.getLogger(__package__).setLevel(logging.INFO)
logging.getLogger(__package__).addHandler(self.log_handler)
self.log_handler, self.console_handler = open_logs(self.data_dir)
try:
logger.info('农场启动:正在检查作物版本和本地缓存')
if self.resource_options_provider is not None:
self.resource_source, self.service_url = (
self.resource_options_provider()
@@ -191,6 +187,8 @@ class Runtime:
if self._closing or epoch != self._start_epoch:
return
self.started = True
logger.info('农场启动完成(%s)',
'图片可用' if self.rendering else '文字模式')
logger.info("farm preview initialized schema=%s", SCHEMA_VERSION)
async def _prepare_resources(self):
@@ -206,6 +204,7 @@ class Runtime:
)
validate_options(self.resource_source, self.service_url)
resource = 'assets'
logger.info('农场启动:正在准备图片素材和字体')
selected = self.catalogue_release
bundle_path = selected.bundle_path if selected else None
if bundle_path is None:
@@ -265,10 +264,8 @@ class Runtime:
await self.store.close()
self.started = False
self._closing = False
if self.log_handler:
logging.getLogger(__package__).removeHandler(self.log_handler)
self.log_handler.close()
self.log_handler = None
close_logs(self.log_handler, self.console_handler)
self.log_handler = self.console_handler = None
async def execute(
self, action, arguments, *, bot_id, platform, user_id, message_id,
+43
View File
@@ -0,0 +1,43 @@
"""真寻和NoneBot的日志生命周期;AstrBot构建使用宿主专用实现。"""
import logging
def has_console_handler(target):
while target is not None:
if any(isinstance(handler, logging.StreamHandler)
and not isinstance(handler, logging.FileHandler)
and handler.level <= logging.INFO
for handler in target.handlers):
return True
target = target.parent if target.propagate else None
return False
def open_logs(data_dir):
target = logging.getLogger(__package__)
file_handler = logging.FileHandler(
data_dir / 'farm-preview.log', encoding='utf-8',
)
file_handler.setFormatter(logging.Formatter(
'%(asctime)s %(levelname)s %(name)s %(message)s',
))
target.setLevel(logging.INFO)
target.addHandler(file_handler)
console_handler = None
if not has_console_handler(target):
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.INFO)
console_handler.setFormatter(logging.Formatter(
'%(asctime)s [农场] %(message)s', datefmt='%H:%M:%S',
))
target.addHandler(console_handler)
return file_handler, console_handler
def close_logs(file_handler, console_handler):
target = logging.getLogger(__package__)
for handler in (file_handler, console_handler):
if handler is not None:
target.removeHandler(handler)
handler.close()
+33 -1
View File
@@ -15,6 +15,33 @@ from verify_zhenxun import read_package as read_shared
from help_image import build_help_image
def host_logging(content):
"""仅替换日志入口,保留共享业务源码和注释;未知用法交给包校验拒绝。"""
lines = content.decode('utf-8').splitlines(keepends=True)
edits = []
for node in ast.parse(content).body:
if (isinstance(node, ast.Import)
and any(alias.name == 'logging' for alias in node.names)):
if len(node.names) != 1 or node.names[0].asname:
raise ValueError('Unsupported logging import')
edits.append((node.lineno, node.end_lineno, ''))
if (isinstance(node, ast.Assign) and len(node.targets) == 1
and isinstance(node.targets[0], ast.Name)
and isinstance(node.value, ast.Call)
and isinstance(node.value.func, ast.Attribute)
and isinstance(node.value.func.value, ast.Name)
and node.value.func.value.id == 'logging'
and node.value.func.attr == 'getLogger'):
alias = node.targets[0].id
statement = 'from astrbot.api import logger'
if alias != 'logger':
statement += ' as ' + alias
edits.append((node.lineno, node.end_lineno, statement + '\n'))
for start, end, replacement in sorted(edits, reverse=True):
lines[start-1:end] = [replacement]
return ''.join(lines).encode('utf-8')
def render_readme(root):
template = (root / 'readme/astrbot.md').read_text(encoding='utf-8')
marker = '<!-- FARM_COMMANDS -->'
@@ -49,7 +76,7 @@ def build(root, output, *, help_image=None):
files = {name: content for name, content in shared.items()
if name.startswith('_vendor/') or name in (
'_version.py', 'version.txt', 'LICENSE',
)}
) if not name.endswith('/__main__.py')}
source = root / 'integrations/astrbot'
for name in ('main.py', 'avatars.py', '_conf_schema.json',
'requirements.txt', 'CHANGELOG.md'):
@@ -65,6 +92,9 @@ def build(root, output, *, help_image=None):
files['runtime.py'] = (
root / 'integrations/shared/runtime.py'
).read_bytes()
files['runtime_logging.py'] = (
source / 'runtime_logging.py'
).read_bytes()
files['verify_package.py'] = (
root / 'scripts/verify_astrbot.py'
).read_bytes()
@@ -82,6 +112,8 @@ def build(root, output, *, help_image=None):
}, ensure_ascii=False, indent=2) + '\n').encode()
for name, content in files.items():
if name.endswith('.py'):
content = host_logging(content)
files[name] = content
ast.parse(content, filename=name, feature_version=(3, 10))
manifest = {
'schema': 1, 'target': 'astrbot', 'plugin': ROOT_NAME,
+3
View File
@@ -32,6 +32,9 @@ def build(root, output, *, help_image=None):
files[prefix + "runtime.py"] = (
root / "integrations/shared/runtime.py"
).read_bytes()
files[prefix + 'runtime_logging.py'] = (
root / 'integrations/shared/runtime_logging.py'
).read_bytes()
files[prefix + "_vendor/__init__.py"] = b'"""Private farm modules."""\n'
for package in ("farm_core", "farm_render"):
source = root / "src" / package
+3
View File
@@ -57,6 +57,9 @@ def build(root, output, *, help_image=None):
files["runtime.py"] = (
root / "integrations/shared/runtime.py"
).read_bytes()
files['runtime_logging.py'] = (
root / 'integrations/shared/runtime_logging.py'
).read_bytes()
files["_vendor/__init__.py"] = b'"""Private shared farm modules."""\n'
for package in ("farm_core", "farm_render"):
source = root / "src" / package
+33 -1
View File
@@ -1,6 +1,7 @@
"""AstrBot插件ZIP的独立完整性校验;只使用标准库。"""
import argparse
import ast
import hashlib
import json
from pathlib import Path, PurePosixPath
@@ -37,8 +38,39 @@ def verify_files(files):
safe_path(name)
if hashlib.sha256(files[name]).hexdigest() != digest:
raise ValueError('Package checksum mismatch')
if name.endswith('/__main__.py'):
raise ValueError('Development CLI must not be in AstrBot plugin')
if name.endswith('.py'):
tree = ast.parse(files[name], filename=name)
host_loggers = {
alias.asname or alias.name
for node in ast.walk(tree)
if isinstance(node, ast.ImportFrom)
and node.module == 'astrbot.api'
for alias in node.names if alias.name == 'logger'
}
for node in ast.walk(tree):
if (isinstance(node, ast.Import) and any(
alias.name.split('.')[0] == 'logging'
for alias in node.names)) or (
isinstance(node, ast.ImportFrom)
and (node.module or '').split('.')[0] == 'logging'):
raise ValueError('AstrBot plugin must not import logging')
if isinstance(node, ast.Attribute) and node.attr in {
'basicConfig', 'FileHandler', 'StreamHandler',
'getLogger', 'setLevel', 'addHandler', 'removeHandler'}:
raise ValueError('AstrBot must own logging configuration')
if (isinstance(node, ast.Call)
and isinstance(node.func, ast.Attribute)
and isinstance(node.func.value, ast.Name)
and node.func.value.id in {'logger', 'LOGGER'}
and node.func.value.id not in host_loggers):
raise ValueError(
'AstrBot logger must come from astrbot.api',
)
required = {'main.py', 'avatars.py', 'metadata.yaml', '_conf_schema.json',
'runtime.py', 'README.md', 'CHANGELOG.md', 'logo.png',
'runtime.py', 'runtime_logging.py', 'README.md',
'CHANGELOG.md', 'logo.png',
'logo-readme.png',
'images/farm-preview.png',
'requirements.txt', 'LICENSE', 'version.txt', '_version.py',
+3 -3
View File
@@ -434,9 +434,9 @@ class SQLiteStore:
connection.commit()
except BaseException as exc:
connection.rollback()
LOGGER.log(
logging.INFO if isinstance(exc, FarmError)
else logging.WARNING,
report = (LOGGER.info if isinstance(exc, FarmError)
else LOGGER.warning)
report(
"farm rollback action=%s operation=%s error=%s",
action, operation_id, type(exc).__name__,
)
+44 -15
View File
@@ -43,21 +43,48 @@ def checked_url(url):
def _copy_remote(url, destination, limit):
request = Request(checked_url(url), headers={'User-Agent': 'Farm-assets/1'})
parts = urlsplit(url).path.split('/')
name = {
'manifest.json': '作物版本清单', 'plants.json': '作物列表',
'metadata.json': '展示配置', 'land-soils.zip': '土地素材',
'assets.zip': '字体包' if 'fonts' in parts else '图片素材包',
}.get(parts[-1], '资源')
started = time.monotonic()
with urlopen(request, timeout=15) as response:
checked_url(response.geturl())
length = response.headers.get('Content-Length')
if length is not None and int(length) > limit:
raise ValueError('Resource download exceeds limit')
total = 0
while True:
chunk = response.read(min(65536, limit-total+1))
if not chunk:
break
total += len(chunk)
if total > limit or time.monotonic()-started > 60:
raise ValueError('Resource download limit or timeout')
destination.write(chunk)
last_report = started
logger.info('农场下载开始:%s', name)
try:
with urlopen(request, timeout=15) as response:
checked_url(response.geturl())
header = response.headers.get('Content-Length')
length = int(header) if header is not None else 0
if length > limit:
raise ValueError('Resource download exceeds limit')
total = 0
while True:
chunk = response.read(min(65536, limit-total+1))
if not chunk:
break
total += len(chunk)
now = time.monotonic()
if total > limit or now-started > 60:
raise ValueError('Resource download limit or timeout')
destination.write(chunk)
if now-last_report >= 1:
if length > 0:
logger.info(
'农场下载进度:%s %.1f%%(%.2f/%.2f MiB)',
name, min(100, total * 100 / length),
total / 1048576, length / 1048576,
)
else:
logger.info('农场下载进度:%s 已下载 %.2f MiB',
name, total / 1048576)
last_report = now
logger.info('农场下载完成:%s 共 %.2f MiB(待校验)',
name, total / 1048576)
except Exception as error:
logger.warning('农场下载失败:%s(%s)', name, type(error).__name__)
raise
def verify_bundle(path, manifest):
@@ -93,7 +120,9 @@ def ensure_assets(data_dir, source='Github', service_url=''):
target = (Path(data_dir) / 'assets' / expected['sha256'] / 'assets.zip')
with _LOCK:
try:
return verify_bundle(target, expected)
cached = verify_bundle(target, expected)
logger.info('农场图片素材:复用已校验缓存')
return cached
except (OSError, ValueError):
pass
target.parent.mkdir(parents=True, exist_ok=True)
+4
View File
@@ -140,6 +140,7 @@ def load_catalogue_release(data_dir, source='Github', service_url='',
if not set(required_ids).issubset(
plant.plant_id for plant in current.catalogue):
raise ValueError('catalogue missing player crop ID')
logger.info('农场作物版本未变化:复用已校验缓存')
return replace(current, origin='remote')
try:
candidate = _snapshot(target, manifest, previous, required_ids,
@@ -174,6 +175,7 @@ def load_catalogue_release(data_dir, source='Github', service_url='',
previous_images = (current or bootstrap).metadata['images']
if not set(previous_images).issubset(candidate.metadata['images']):
raise ValueError('Catalogue removed existing image paths')
logger.info('农场作物列表、配置和素材校验通过:正在切换版本')
_atomic(root / 'current.json', canonical(manifest))
logger.info('farm catalogue ready release=%s source=%s',
manifest['release'], source)
@@ -186,8 +188,10 @@ def load_catalogue_release(data_dir, source='Github', service_url='',
if not set(required_ids).issubset(
plant.plant_id for plant in current.catalogue):
raise ValueError('catalogue missing player crop ID')
logger.info('农场更新不可用:使用已校验的作物缓存版本')
return current
bootstrap = _bootstrap(required_ids)
logger.info('农场更新不可用:使用内置作物版本')
# 若旧方式图片缓存可用,离线初始版本也能继续绘图。
cached = (Path(data_dir) / 'assets'
/ bootstrap.manifest['files']['assets.zip']['sha256']
+4
View File
@@ -3,6 +3,7 @@
import hashlib
from importlib import resources
import json
import logging
from pathlib import Path
import tempfile
from zipfile import ZipFile
@@ -10,6 +11,7 @@ from zipfile import ZipFile
from .bundle import _LOCK, _copy_remote, validate_options
logger = logging.getLogger(__name__)
FONT_NAME = "Xiaolai-Regular.ttf"
FILES = {
FONT_NAME: (
@@ -61,6 +63,7 @@ def ensure_font(data_dir, source="Github", service_url=""):
with _LOCK:
if all(_valid(root / name, *checks)
for name, checks in FILES.items()):
logger.info('农场字体:复用已校验缓存')
return root / FONT_NAME
root.mkdir(parents=True, exist_ok=True)
archive_path = root / "assets.zip"
@@ -103,4 +106,5 @@ def ensure_font(data_dir, source="Github", service_url=""):
# 先写许可,再使字体可用;不使用 extractall 或远端提供的路径。
for name in ("OFL-Xiaolai.txt", FONT_NAME):
_atomic_write(root / name, content[name])
logger.info('农场字体和许可证校验通过')
return root / FONT_NAME
+42
View File
@@ -102,3 +102,45 @@ class BundleTests(unittest.TestCase):
with patch.object(bundle, 'urlopen', return_value=response):
with self.assertRaises(ValueError):
bundle._copy_remote('https://farm.example/a', BytesIO(), 10)
def test_download_reports_throttled_progress_without_urls(self):
class Response(BytesIO):
headers = {'Content-Length': '262144'}
def geturl(self):
return 'https://farm.example/private/assets.zip'
with patch.object(bundle, 'urlopen', return_value=Response(
b'x' * 262144)), patch.object(
bundle.time, 'monotonic',
side_effect=[0, 0.2, 1.1, 1.2, 2.2]), \
self.assertLogs(bundle.logger, level='INFO') as captured:
bundle._copy_remote('https://farm.example/private/assets.zip',
BytesIO(), 262144)
messages = '\n'.join(captured.output)
self.assertIn('下载开始', messages)
self.assertIn('50.0%', messages)
self.assertIn('100.0%', messages)
self.assertIn('下载完成', messages)
self.assertEqual(messages.count('下载进度'), 2)
self.assertNotIn('farm.example', messages)
self.assertNotIn('private', messages)
def test_unknown_length_and_failed_download_report_truthfully(self):
class Response(BytesIO):
headers = {}
def geturl(self):
return 'https://farm.example/a'
with patch.object(bundle, 'urlopen', return_value=Response(b'abc')), \
patch.object(bundle.time, 'monotonic', side_effect=[0, 1.1]), \
self.assertLogs(bundle.logger, level='INFO') as captured:
bundle._copy_remote('https://farm.example/a', BytesIO(), 10)
self.assertIn('下载完成', '\n'.join(captured.output))
self.assertIn('已下载', '\n'.join(captured.output))
self.assertNotIn('%', '\n'.join(captured.output))
with patch.object(bundle, 'urlopen', side_effect=OSError('secret')), \
self.assertLogs(bundle.logger, level='INFO') as captured:
with self.assertRaises(OSError):
bundle._copy_remote('https://farm.example/a', BytesIO(), 10)
messages = '\n'.join(captured.output)
self.assertIn('下载失败', messages)
self.assertNotIn('下载完成', messages)
self.assertNotIn('secret', messages)
+8 -1
View File
@@ -1,8 +1,10 @@
import asyncio
import importlib.util
from pathlib import Path
import sys
from types import ModuleType
import unittest
from unittest.mock import AsyncMock, patch
from unittest.mock import AsyncMock, Mock, patch
ROOT = Path(__file__).resolve().parents[1]
@@ -10,6 +12,11 @@ ROOT = Path(__file__).resolve().parents[1]
class AvatarTests(unittest.IsolatedAsyncioTestCase):
def setUp(self):
api = ModuleType('astrbot.api')
api.logger = Mock()
host = patch.dict(sys.modules, {'astrbot.api': api})
host.start()
self.addCleanup(host.stop)
spec = importlib.util.spec_from_file_location(
'farm_astrbot_avatars',
ROOT / 'integrations/astrbot/avatars.py',
+38 -5
View File
@@ -7,7 +7,7 @@ import sys
import tempfile
from types import ModuleType, SimpleNamespace
import unittest
from unittest.mock import AsyncMock, patch
from unittest.mock import AsyncMock, Mock, patch
ROOT = Path(__file__).resolve().parents[1]
@@ -62,19 +62,43 @@ class AstrBotPackageTests(unittest.IsolatedAsyncioTestCase):
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/shared/runtime.py'
).read_bytes())
source_tree = ast.parse((ROOT / 'integrations/shared/runtime.py')
.read_bytes())
built_tree = ast.parse(files['runtime.py'])
source_runtime = next(node for node in source_tree.body
if isinstance(node, ast.ClassDef))
built_runtime = next(node for node in built_tree.body
if isinstance(node, ast.ClassDef))
self.assertEqual(ast.dump(source_runtime), ast.dump(built_runtime))
self.assertFalse('_vendor/farm_core/__main__.py' in files)
for name, content in files.items():
self.assertNotIn('.git/', name)
self.assertNotIn('farm.sqlite3', name)
if name.endswith('.py'):
tree = ast.parse(content)
for node in ast.walk(tree):
if isinstance(node, ast.Import):
self.assertFalse(any(
alias.name.split('.')[0] == 'logging'
for alias in node.names
), name)
if isinstance(node, ast.ImportFrom):
self.assertFalse((node.module or '').startswith(
('nonebot', 'zhenxun'),
('nonebot', 'zhenxun', 'logging'),
))
for snippet in (b'\nimport logging\n',
b'\nlogger.setLevel(20)\n'):
invalid = dict(files)
invalid['main.py'] += snippet
invalid_manifest = json.loads(invalid['FARM-MANIFEST.json'])
invalid_manifest['files']['main.py'] = hashlib.sha256(
invalid['main.py'],
).hexdigest()
invalid['FARM-MANIFEST.json'] = json.dumps(
invalid_manifest,
).encode()
with self.assertRaisesRegex(ValueError, 'logging'):
verify_files(invalid)
files['main.py'] += b'\n# tampered\n'
with self.assertRaisesRegex(ValueError, 'checksum'):
verify_files(files)
@@ -91,6 +115,7 @@ class AstrBotPackageTests(unittest.IsolatedAsyncioTestCase):
package.__path__ = [str(self.root / 'host/repository')]
api = ModuleType('astrbot.api')
api.AstrBotConfig = dict
api.logger = Mock()
events = ModuleType('astrbot.api.event')
commands = {}
@@ -152,6 +177,14 @@ class AstrBotPackageTests(unittest.IsolatedAsyncioTestCase):
side_effect=OSError('offline fixture'),
):
await plugin.initialize()
self.assertIs(main.logger, api.logger)
self.assertIsNone(plugin.runtime.log_handler)
self.assertIsNone(plugin.runtime.console_handler)
self.assertFalse((plugin.runtime.data_dir /
'farm-preview.log').exists())
api.logger.info.assert_any_call(
'农场启动:正在检查作物版本和本地缓存',
)
self.assertEqual(set(commands), set(main.COMMANDS))
class Event:
+39
View File
@@ -84,6 +84,45 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase):
for action in ('seeds', 'crops'):
self.assertIsInstance(await self.call(action, '1'), bytes)
async def test_start_progress_reaches_console_and_file(self):
import logging
from contextlib import redirect_stderr
from io import StringIO
await self.runtime.close()
output = StringIO()
package_log = logging.getLogger(self.package)
with patch.object(logging.getLogger(), 'handlers', []), \
patch.object(package_log, 'handlers', []), \
redirect_stderr(output):
await self.runtime.start()
await self.runtime.close()
self.assertFalse(package_log.handlers)
self.assertIn('正在检查作物版本', output.getvalue())
self.assertIn('使用内置作物版本', output.getvalue())
self.assertIn('字体', output.getvalue())
self.assertIn('农场启动完成', output.getvalue())
saved = (self.runtime.data_dir / 'farm-preview.log').read_text(
encoding='utf-8',
)
self.assertIn('正在检查作物版本', saved)
async def test_start_reuses_existing_console_without_duplicate_output(self):
import logging
from io import StringIO
await self.runtime.close()
output = StringIO()
handler = logging.StreamHandler(output)
handler.setLevel(logging.INFO)
with patch.object(logging.getLogger(), 'handlers', [handler]):
await self.runtime.start()
await self.runtime.start()
self.assertIsNone(self.runtime.console_handler)
await self.runtime.close()
self.assertEqual(output.getvalue().count('正在检查作物版本'), 1)
self.assertEqual(output.getvalue().count('农场启动完成'), 1)
async def test_catalogue_is_loaded_once_before_store_initialization(self):
import json
from unittest.mock import AsyncMock
+1 -1
View File
@@ -1 +1 @@
2.0.3
2.0.4