初始提交

This commit is contained in:
焦龙言
2026-07-24 09:43:10 +08:00
commit b5b4721456
793 changed files with 116305 additions and 0 deletions
@@ -0,0 +1,112 @@
from pathlib import Path
import typer
from pytest import MonkeyPatch
from cli.completion.installers import (
CompletionInstallerService,
CompletionInstallerShellSupport,
CompletionShellRuntimePolicy,
CompletionShellRuntimePolicyRegistry,
)
def test_build_source_command_uses_shell_runtime_policy() -> None:
"""
校验安装服务会按 shell 运行时策略构建 source 命令。
:return: None
"""
installer = CompletionInstallerService()
target_file = Path('/tmp/shierfeng-completion')
assert installer.build_source_command(target_file, 'bash') == 'source /tmp/shierfeng-completion'
assert installer.build_source_command(target_file, 'zsh') == 'source /tmp/shierfeng-completion'
assert (
installer.build_source_command(target_file, 'fish')
== 'status --is-interactive; and source /tmp/shierfeng-completion'
)
assert installer.build_source_command(target_file, 'powershell') == '. "/tmp/shierfeng-completion"'
def test_make_bash_completion_script_compatible_wraps_legacy_instructions() -> None:
"""
校验 Bash 兼容转换会包装旧版 shell 不支持的指令。
:return: None
"""
script_text = 'compopt -o dirnames\ncompopt -o default\ncomplete -o nosort -F _shierfeng_completion shierfeng'
compatible_script = CompletionInstallerShellSupport.make_bash_completion_script_compatible(script_text)
assert 'if command -v compopt >/dev/null 2>&1; then' in compatible_script
assert 'complete -F _shierfeng_completion shierfeng' in compatible_script
assert 'complete -o nosort -F _shierfeng_completion shierfeng 2>/dev/null' in compatible_script
def test_render_completion_script_uses_runtime_policy_transformer(monkeypatch: MonkeyPatch) -> None:
"""
校验脚本渲染会通过 shell 运行时策略执行脚本后处理。
:param monkeypatch: pytest monkeypatch 工具
:return: None
"""
class DummyComplete:
"""
模拟 Click completion 生成器。
"""
def __init__(self, *args, **kwargs) -> None:
"""
初始化模拟生成器。
:param args: 位置参数
:param kwargs: 关键字参数
:return: None
"""
@staticmethod
def source() -> str:
"""
返回原始脚本文本。
:return: 原始脚本文本
"""
return 'raw-script'
runtime_policy_registry = CompletionShellRuntimePolicyRegistry(
policies={
'bash': CompletionShellRuntimePolicy(
name='bash',
click_completion_class=DummyComplete,
script_transformer=lambda script_text: f'transformed::{script_text}',
source_command_builder=lambda target_file: f'source {target_file}',
)
}
)
installer = CompletionInstallerService(shell_runtime_policy_registry=runtime_policy_registry)
monkeypatch.setattr(installer, 'build_completion_click_command', lambda root_cli: object())
rendered_script = installer.render_completion_script(typer.Typer(), 'bash')
assert rendered_script == 'transformed::raw-script'
def test_render_powershell_completion_script_contains_native_completer() -> None:
"""
校验 PowerShell completion 脚本会注册原生命令补全器。
:return: None
"""
installer = CompletionInstallerService()
root_cli = typer.Typer()
@root_cli.command()
def demo() -> None:
return None
rendered_script = installer.render_completion_script(root_cli, 'powershell')
assert 'Register-ArgumentCompleter -Native -CommandName shierfeng' in rendered_script
assert '$env:_SHIERFENG_COMPLETE = "powershell_complete"' in rendered_script
@@ -0,0 +1,442 @@
import importlib
import os
import sys
from pathlib import Path
from pytest import MonkeyPatch
BACKEND_DIR = Path(__file__).resolve().parents[3]
sys.path.insert(0, str(BACKEND_DIR))
sys.modules.pop('cli.completion.providers', None)
sys.modules.pop('cli', None)
completion_support = importlib.import_module('cli.completion.providers')
completion_gateway = completion_support.COMPLETION_PROVIDER_GATEWAY
completion_registry = completion_gateway.provider_registry
def test_complete_alembic_revisions_returns_default_choices_and_local_revisions(
tmp_path: Path,
monkeypatch: MonkeyPatch,
) -> None:
"""
校验 Alembic 版本补全会返回默认候选和本地迁移版本。
:param tmp_path: pytest 临时目录
:param monkeypatch: pytest monkeypatch 工具
:return: None
"""
versions_dir = tmp_path / 'alembic' / 'versions'
versions_dir.mkdir(parents=True)
(versions_dir / '2026_04_29_1000-abc123_add_demo_table.py').write_text('# test', encoding='utf-8')
monkeypatch.chdir(tmp_path)
candidates = completion_gateway.complete_alembic_revisions(None, None, 'a')
default_candidates = completion_gateway.complete_alembic_revisions(None, None, '')
assert 'abc123' in candidates
assert 'head' in default_candidates
assert 'base' in default_candidates
assert 'current' in default_candidates
assert '-1' in default_candidates
def test_complete_sql_files_returns_project_relative_sql_paths(
tmp_path: Path,
monkeypatch: MonkeyPatch,
) -> None:
"""
校验 SQL 文件补全会返回项目相对路径。
:param tmp_path: pytest 临时目录
:param monkeypatch: pytest monkeypatch 工具
:return: None
"""
sql_dir = tmp_path / 'sql'
sql_dir.mkdir()
(sql_dir / 'demo.sql').write_text('select 1;', encoding='utf-8')
monkeypatch.chdir(tmp_path)
candidates = completion_gateway.complete_sql_files(None, None, 'sql/d')
assert candidates == ['sql/demo.sql']
def test_complete_cache_names_returns_static_cache_name_choices(monkeypatch: MonkeyPatch) -> None:
"""
校验缓存名称补全会返回系统内置缓存名称。
:param monkeypatch: pytest monkeypatch 工具
:return: None
"""
class _FakeCacheKeyConfig:
"""
模拟缓存键配置对象。
"""
def __init__(self, key: str) -> None:
"""
初始化模拟缓存键配置对象。
:param key: 缓存名称
:return: None
"""
self.key = key
class _FakeEnumsModule:
"""
模拟枚举模块。
"""
RedisInitKeyConfig = [_FakeCacheKeyConfig('sys_config'), _FakeCacheKeyConfig('login_tokens')]
monkeypatch.setattr(
completion_registry.dynamic_service, 'load_runtime_module', lambda module_name: _FakeEnumsModule
)
candidates = completion_gateway.complete_cache_names(None, None, 'sys')
assert candidates == ['sys_config']
def test_complete_output_paths_returns_directories_and_zip_files(
tmp_path: Path,
monkeypatch: MonkeyPatch,
) -> None:
"""
校验导出路径补全会返回目录和 zip 文件。
:param tmp_path: pytest 临时目录
:param monkeypatch: pytest monkeypatch 工具
:return: None
"""
build_dir = tmp_path / 'build'
build_dir.mkdir()
(build_dir / 'gen.zip').write_text('zip', encoding='utf-8')
(build_dir / 'notes.txt').write_text('ignore', encoding='utf-8')
monkeypatch.chdir(tmp_path)
candidates = completion_gateway.complete_output_paths(None, None, 'build/')
assert 'build/gen.zip' in candidates
assert 'build/notes.txt' not in candidates
def test_complete_config_keys_returns_dynamic_readonly_choices(monkeypatch: MonkeyPatch) -> None:
"""
校验参数键名补全会返回动态只读查询结果。
:param monkeypatch: pytest monkeypatch 工具
:return: None
"""
class _FakeConfigRuntime:
"""
模拟配置运行时模块。
"""
class ConfigRuntime:
"""
模拟配置运行时服务对象。
"""
@staticmethod
def list_configs(**kwargs: str) -> dict:
"""
模拟配置列表查询入口。
:param kwargs: 查询参数
:return: 伪造的结果字典
"""
return kwargs
CONFIG_RUNTIME = ConfigRuntime()
monkeypatch.setattr(
completion_registry.dynamic_service, 'load_runtime_module', lambda module_name: _FakeConfigRuntime
)
monkeypatch.setattr(
completion_registry.dynamic_service,
'run_completion_coroutine',
lambda coroutine, *, env: {
'ok': True,
'items': [
{'configKey': 'sys.user.initPassword'},
{'configKey': 'sys.user.maxRetryCount'},
],
},
)
candidates = completion_gateway.complete_config_keys(None, None, 'sys.user.i')
assert candidates == ['sys.user.initPassword']
def test_complete_gen_table_names_returns_dynamic_readonly_choices(monkeypatch: MonkeyPatch) -> None:
"""
校验代码生成业务表补全会返回动态只读查询结果。
:param monkeypatch: pytest monkeypatch 工具
:return: None
"""
class _FakeGenRuntime:
"""
模拟代码生成运行时模块。
"""
class GenRuntime:
"""
模拟代码生成运行时服务对象。
"""
@staticmethod
def list_gen_tables(**kwargs: str) -> dict:
"""
模拟业务表列表查询入口。
:param kwargs: 查询参数
:return: 伪造的结果字典
"""
return kwargs
@staticmethod
def list_gen_db_tables(**kwargs: str) -> dict:
"""
模拟数据库物理表列表查询入口。
:param kwargs: 查询参数
:return: 伪造的结果字典
"""
return kwargs
GEN_RUNTIME = GenRuntime()
monkeypatch.setattr(completion_registry.dynamic_service, 'load_runtime_module', lambda module_name: _FakeGenRuntime)
monkeypatch.setattr(
completion_registry.dynamic_service,
'run_completion_coroutine',
lambda coroutine, *, env: {
'ok': True,
'items': [
{'tableName': 'sys_user'},
{'tableName': 'sys_role'},
],
},
)
candidates = completion_gateway.complete_gen_table_names(None, None, 'sys_u')
assert candidates == ['sys_user']
def test_dynamic_completion_returns_empty_list_when_runtime_fails(monkeypatch: MonkeyPatch) -> None:
"""
校验动态补全在运行时失败时会优雅降级为空列表。
:param monkeypatch: pytest monkeypatch 工具
:return: None
"""
monkeypatch.setattr(
completion_registry.dynamic_service,
'load_runtime_module',
lambda module_name: (_ for _ in ()).throw(RuntimeError('boom')),
)
candidates = completion_gateway.complete_gen_db_table_names(None, None, 'sys')
assert candidates == []
def test_complete_cache_keys_returns_dynamic_readonly_choices(monkeypatch: MonkeyPatch) -> None:
"""
校验缓存键名补全会基于缓存名称返回动态只读查询结果。
:param monkeypatch: pytest monkeypatch 工具
:return: None
"""
class _FakeCacheRuntime:
"""
模拟缓存运行时模块。
"""
class CacheRuntime:
"""
模拟缓存运行时服务对象。
"""
@staticmethod
def list_cache_keys(cache_name: str) -> dict[str, str]:
"""
模拟缓存键列表查询入口。
:param cache_name: 缓存名称
:return: 伪造的参数字典
"""
return {'cache_name': cache_name}
CACHE_RUNTIME = CacheRuntime()
class _FakeContext:
"""
模拟 Click 上下文。
"""
params = {'cache_name': 'sys_config'}
monkeypatch.setattr(
completion_registry.dynamic_service, 'load_runtime_module', lambda module_name: _FakeCacheRuntime
)
monkeypatch.setattr(
completion_registry.dynamic_service,
'run_completion_coroutine',
lambda coroutine, *, env: {
'ok': True,
'keys': ['site.name', 'site.logo'],
},
)
candidates = completion_gateway.complete_cache_keys(_FakeContext(), None, 'site.n')
assert candidates == ['site.name']
def test_complete_job_names_returns_dynamic_readonly_choices(monkeypatch: MonkeyPatch) -> None:
"""
校验任务名称补全会返回动态只读查询结果。
:param monkeypatch: pytest monkeypatch 工具
:return: None
"""
class _FakeJobRuntime:
"""
模拟任务运行时模块。
"""
class JobRuntime:
"""
模拟任务运行时服务对象。
"""
@staticmethod
def list_jobs(**kwargs: str) -> dict:
"""
模拟任务列表查询入口。
:param kwargs: 查询参数
:return: 伪造的参数字典
"""
return kwargs
JOB_RUNTIME = JobRuntime()
monkeypatch.setattr(completion_registry.dynamic_service, 'load_runtime_module', lambda module_name: _FakeJobRuntime)
monkeypatch.setattr(
completion_registry.dynamic_service,
'run_completion_coroutine',
lambda coroutine, *, env: {
'ok': True,
'items': [
{'jobName': '同步任务'},
{'jobName': '缓存预热'},
],
},
)
candidates = completion_gateway.complete_job_names(None, None, '同')
assert candidates == ['同步任务']
def test_complete_job_ids_returns_dynamic_readonly_choices(monkeypatch: MonkeyPatch) -> None:
"""
校验任务 ID 补全会返回动态只读查询结果。
:param monkeypatch: pytest monkeypatch 工具
:return: None
"""
class _FakeJobRuntime:
"""
模拟任务运行时模块。
"""
class JobRuntime:
"""
模拟任务运行时服务对象。
"""
@staticmethod
def list_jobs(**kwargs: str) -> dict:
"""
模拟任务列表查询入口。
:param kwargs: 查询参数
:return: 伪造的参数字典
"""
return kwargs
JOB_RUNTIME = JobRuntime()
monkeypatch.setattr(completion_registry.dynamic_service, 'load_runtime_module', lambda module_name: _FakeJobRuntime)
monkeypatch.setattr(
completion_registry.dynamic_service,
'run_completion_coroutine',
lambda coroutine, *, env: {
'ok': True,
'items': [
{'jobId': 101},
{'jobId': 202},
],
},
)
candidates = completion_gateway.complete_job_ids(None, None, '1')
assert candidates == ['101']
def test_run_completion_coroutine_scopes_app_env(monkeypatch: MonkeyPatch) -> None:
"""
校验动态补全执行时会临时注入 APP_ENV,并在结束后恢复原值。
:param monkeypatch: pytest monkeypatch 工具
:return: None
"""
monkeypatch.setenv('APP_ENV', 'prod')
captured: dict[str, str | None] = {}
async def _read_env() -> str | None:
captured['during'] = os.environ.get('APP_ENV')
return captured['during']
result = completion_registry.dynamic_service.run_completion_coroutine(_read_env(), env='dev')
assert result == 'dev'
assert captured['during'] == 'dev'
assert os.environ.get('APP_ENV') == 'prod'
def test_run_completion_coroutine_clears_scoped_app_env_when_original_missing(monkeypatch: MonkeyPatch) -> None:
"""
校验动态补全执行结束后会移除临时注入的 APP_ENV。
:param monkeypatch: pytest monkeypatch 工具
:return: None
"""
monkeypatch.delenv('APP_ENV', raising=False)
async def _read_env() -> str | None:
return os.environ.get('APP_ENV')
result = completion_registry.dynamic_service.run_completion_coroutine(_read_env(), env='dockerpg')
assert result == 'dockerpg'
assert 'APP_ENV' not in os.environ
@@ -0,0 +1,74 @@
import importlib
import sys
from pathlib import Path
from cli.exit_codes import RUNTIME_ERROR
BACKEND_DIR = Path(__file__).resolve().parents[3]
sys.path.insert(0, str(BACKEND_DIR))
sys.modules.pop('cli.core.execution', None)
sys.modules.pop('cli', None)
cli_execution = importlib.import_module('cli.core.execution')
EXPLICIT_EXIT_CODE = 23
DEFAULT_EXIT_CODE = 7
def test_build_result_does_not_mutate_payload_and_removes_exit_code_from_result_data() -> None:
"""
校验结果翻译不会修改调用方 payload,且会从结果数据中移除 exit_code 字段。
:return: None
"""
payload = {'ok': True, 'message': 'done', 'exit_code': EXPLICIT_EXIT_CODE}
result = cli_execution.CliExecutionService.build_result(payload)
assert result.exit_code == EXPLICIT_EXIT_CODE
assert result.data == {'ok': True, 'message': 'done'}
assert payload == {'ok': True, 'message': 'done', 'exit_code': EXPLICIT_EXIT_CODE}
def test_build_result_uses_runtime_error_when_failed_payload_has_no_explicit_exit_code() -> None:
"""
校验失败 payload 未显式提供退出码时,会回退到统一运行时错误码。
:return: None
"""
payload = {'ok': False, 'message': 'failed'}
result = cli_execution.CliExecutionService.build_result(payload)
assert result.exit_code == RUNTIME_ERROR
assert result.data == {'ok': False, 'message': 'failed'}
assert payload == {'ok': False, 'message': 'failed'}
def test_build_result_uses_default_exit_code_without_mutating_payload() -> None:
"""
校验显式默认退出码会参与结果翻译,且不会修改原始 payload。
:return: None
"""
payload = {'ok': True, 'message': 'warn'}
result = cli_execution.CliExecutionService.build_result(payload, default_exit_code=DEFAULT_EXIT_CODE)
assert result.exit_code == DEFAULT_EXIT_CODE
assert result.data == {'ok': True, 'message': 'warn'}
assert payload == {'ok': True, 'message': 'warn'}
def test_build_result_uses_runtime_error_when_failed_payload_keeps_default_zero() -> None:
"""
校验失败 payload 在未显式提供退出码且默认退出码为 0 时,会回退到统一运行时错误码。
:return: None
"""
payload = {'ok': False, 'message': 'failed'}
result = cli_execution.CliExecutionService.build_result(payload, default_exit_code=0)
assert result.exit_code == RUNTIME_ERROR
assert result.data == {'ok': False, 'message': 'failed'}
@@ -0,0 +1,93 @@
import json
import os
import subprocess
import sys
from collections.abc import Callable
from pathlib import Path
from typing import Any
import pytest
@pytest.fixture
def backend_dir() -> Path:
return Path(__file__).resolve().parents[3]
@pytest.fixture
def run_cli_command(backend_dir: Path) -> Callable[..., subprocess.CompletedProcess[str]]:
def _run_cli_command(*args: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
[sys.executable, '-m', 'cli.main', *args],
cwd=backend_dir,
capture_output=True,
text=True,
check=False,
)
return _run_cli_command
@pytest.fixture
def run_text_cli_command(
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
) -> Callable[..., subprocess.CompletedProcess[str]]:
def _run_text_cli_command(*args: str) -> subprocess.CompletedProcess[str]:
return run_cli_command('--color=never', '--icon=none', *args)
return _run_text_cli_command
@pytest.fixture
def run_cli_completion_command(
backend_dir: Path,
) -> Callable[..., subprocess.CompletedProcess[str]]:
def _run_cli_completion_command(
*,
comp_words: str,
comp_cword: int | str,
instruction: str = 'bash_complete',
) -> subprocess.CompletedProcess[str]:
return subprocess.run(
[sys.executable, '-m', 'cli.main'],
cwd=backend_dir,
capture_output=True,
text=True,
check=False,
env={
**os.environ,
'COMP_WORDS': comp_words,
'COMP_CWORD': str(comp_cword),
'_SHIERFENG_COMPLETE': instruction,
},
)
return _run_cli_completion_command
@pytest.fixture
def parse_json_stdout() -> Callable[[subprocess.CompletedProcess[str]], dict[str, Any]]:
def _parse_json_stdout(completed: subprocess.CompletedProcess[str]) -> dict[str, Any]:
return json.loads(completed.stdout)
return _parse_json_stdout
@pytest.fixture
def assert_check_payload_contract() -> Callable[[dict[str, Any], bool], None]:
def _assert_check_payload_contract(payload: dict[str, Any], allow_exit_code: bool) -> None:
assert isinstance(payload, dict)
assert isinstance(payload.get('ok'), bool)
assert isinstance(payload.get('message'), str)
if payload['ok']:
if 'error' in payload:
assert isinstance(payload['error'], str)
if 'exit_code' in payload:
assert isinstance(payload['exit_code'], int)
else:
assert isinstance(payload.get('error'), str)
if allow_exit_code:
assert isinstance(payload.get('exit_code'), int)
return _assert_check_payload_contract
@@ -0,0 +1,184 @@
import json
import subprocess
import sys
from pathlib import Path
BACKEND_DIR = Path(__file__).resolve().parents[3]
def test_bootstrap_import_does_not_eagerly_load_heavy_runtime_modules() -> None:
"""
校验导入 `cli.bootstrap` 时不会提前加载重依赖模块。
:return: None
"""
script = """
import json
import sys
sys.path.insert(0, '.')
for module_name in [
'cli.bootstrap',
'config.database',
'config.get_redis',
'config.get_scheduler',
'module_admin.service.server_service',
'utils.transport_crypto_util',
]:
sys.modules.pop(module_name, None)
import cli.bootstrap
print(json.dumps({
'config.database': 'config.database' in sys.modules,
'config.get_redis': 'config.get_redis' in sys.modules,
'config.get_scheduler': 'config.get_scheduler' in sys.modules,
'module_admin.service.server_service': 'module_admin.service.server_service' in sys.modules,
'utils.transport_crypto_util': 'utils.transport_crypto_util' in sys.modules,
}, ensure_ascii=False))
"""
completed = subprocess.run(
[sys.executable, '-c', script],
cwd=BACKEND_DIR,
capture_output=True,
text=True,
check=False,
)
assert completed.returncode == 0, completed.stderr
payload = json.loads(completed.stdout)
assert payload == {
'config.database': False,
'config.get_redis': False,
'config.get_scheduler': False,
'module_admin.service.server_service': False,
'utils.transport_crypto_util': False,
}
def test_build_cli_does_not_eagerly_load_heavy_runtime_dependencies() -> None:
"""
校验构建 CLI 根应用时不会提前加载 DB、Redis、Scheduler 和业务服务依赖。
:return: None
"""
script = """
import json
import sys
sys.path.insert(0, '.')
for module_name in [
'cli.main',
'config.env',
'config.database',
'config.get_redis',
'config.get_scheduler',
'cli.tui.app',
'module_admin.service.server_service',
'module_admin.service.job_service',
'module_admin.service.config_service',
'module_generator.service.gen_service',
'textual',
'utils.transport_crypto_util',
]:
sys.modules.pop(module_name, None)
import cli.main
cli.main.CLI_APPLICATION_BUILDER.build()
print(json.dumps({
'config.env': 'config.env' in sys.modules,
'config.database': 'config.database' in sys.modules,
'config.get_redis': 'config.get_redis' in sys.modules,
'config.get_scheduler': 'config.get_scheduler' in sys.modules,
'cli.tui.app': 'cli.tui.app' in sys.modules,
'module_admin.service.server_service': 'module_admin.service.server_service' in sys.modules,
'module_admin.service.job_service': 'module_admin.service.job_service' in sys.modules,
'module_admin.service.config_service': 'module_admin.service.config_service' in sys.modules,
'module_generator.service.gen_service': 'module_generator.service.gen_service' in sys.modules,
'textual': 'textual' in sys.modules,
'utils.transport_crypto_util': 'utils.transport_crypto_util' in sys.modules,
}, ensure_ascii=False))
"""
completed = subprocess.run(
[sys.executable, '-c', script],
cwd=BACKEND_DIR,
capture_output=True,
text=True,
check=False,
)
assert completed.returncode == 0, completed.stderr
payload = json.loads(completed.stdout)
assert payload == {
'config.env': False,
'config.database': False,
'config.get_redis': False,
'config.get_scheduler': False,
'cli.tui.app': False,
'module_admin.service.server_service': False,
'module_admin.service.job_service': False,
'module_admin.service.config_service': False,
'module_generator.service.gen_service': False,
'textual': False,
'utils.transport_crypto_util': False,
}
def test_build_wizard_app_does_not_eagerly_load_flow_modules() -> None:
"""
校验构建 `wizard` 子应用时不会提前加载各个 flow 模块。
:return: None
"""
script = """
import json
import sys
sys.path.insert(0, '.')
for module_name in [
'cli.wizard.commands',
'cli.wizard.flows.app_run',
'cli.wizard.flows.db_upgrade',
'cli.wizard.flows.cache_clear',
'cli.wizard.flows.gen_export',
'cli.wizard.flows.gen_import',
'cli.wizard.flows.prod_check',
]:
sys.modules.pop(module_name, None)
from cli.wizard.commands import WIZARD_COMMAND_BUILDER
WIZARD_COMMAND_BUILDER.build()
print(json.dumps({
'cli.wizard.flows.app_run': 'cli.wizard.flows.app_run' in sys.modules,
'cli.wizard.flows.db_upgrade': 'cli.wizard.flows.db_upgrade' in sys.modules,
'cli.wizard.flows.cache_clear': 'cli.wizard.flows.cache_clear' in sys.modules,
'cli.wizard.flows.gen_export': 'cli.wizard.flows.gen_export' in sys.modules,
'cli.wizard.flows.gen_import': 'cli.wizard.flows.gen_import' in sys.modules,
'cli.wizard.flows.prod_check': 'cli.wizard.flows.prod_check' in sys.modules,
}, ensure_ascii=False))
"""
completed = subprocess.run(
[sys.executable, '-c', script],
cwd=BACKEND_DIR,
capture_output=True,
text=True,
check=False,
)
assert completed.returncode == 0, completed.stderr
payload = json.loads(completed.stdout)
assert payload == {
'cli.wizard.flows.app_run': False,
'cli.wizard.flows.db_upgrade': False,
'cli.wizard.flows.cache_clear': False,
'cli.wizard.flows.gen_export': False,
'cli.wizard.flows.gen_import': False,
'cli.wizard.flows.prod_check': False,
}
@@ -0,0 +1,311 @@
import subprocess
from collections.abc import Callable
from cli.exit_codes import DEPENDENCY_ERROR, SUCCESS
def test_app_config_json_output_is_pure_json(
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
) -> None:
completed = run_cli_command('app', 'config', '--env=dev', '--output=json')
payload = parse_json_stdout(completed)
assert completed.returncode == 0
assert completed.stderr == ''
assert payload['ok'] is True
assert payload['env'] == 'dev'
assert isinstance(payload['config'], dict)
assert '\u001b[' not in completed.stdout
assert '✅' not in completed.stdout
assert '❌' not in completed.stdout
def test_app_config_text_output_has_stable_section_structure(
run_text_cli_command: Callable[..., subprocess.CompletedProcess[str]],
) -> None:
completed = run_text_cli_command('app', 'config', '--env=dev', '--output=text')
assert completed.returncode == SUCCESS
assert '\x1b[' not in completed.stdout
assert '✅' not in completed.stdout
assert completed.stdout.startswith('OK SUCCESS\n')
assert 'env: dev\n' in completed.stdout
assert 'application:\n' in completed.stdout
assert 'database:\n' in completed.stdout
assert 'redis:\n' in completed.stdout
assert 'logging:\n' in completed.stdout
assert 'transport_crypto:\n' in completed.stdout
def test_app_env_text_output_has_stable_section_structure(
run_text_cli_command: Callable[..., subprocess.CompletedProcess[str]],
) -> None:
completed = run_text_cli_command('app', 'env', '--env=dev', '--output=text')
assert completed.returncode == SUCCESS
assert completed.stdout.startswith('OK SUCCESS\n')
assert 'env: dev\n' in completed.stdout
assert 'runtime:\n' in completed.stdout
assert 'cli_env: dev\n' in completed.stdout
assert 'config_env:' in completed.stdout
assert 'app_env: dev\n' in completed.stdout
assert 'env_file: .env.dev\n' in completed.stdout
assert 'env_file_exists:' in completed.stdout
assert 'backend_dir:' in completed.stdout
assert 'python_executable:' in completed.stdout
def test_app_doctor_text_output_has_stable_check_structure(
run_text_cli_command: Callable[..., subprocess.CompletedProcess[str]],
) -> None:
completed = run_text_cli_command('app', 'doctor', '--env=dev', '--output=text')
assert completed.returncode in {SUCCESS, DEPENDENCY_ERROR}
assert '\x1b[' not in completed.stdout
assert '✅' not in completed.stdout
assert completed.stdout.startswith(('OK SUCCESS\n', 'FAIL FAILED\n'))
assert 'env: dev\n' in completed.stdout
assert 'checks:\n' in completed.stdout
assert 'database:' in completed.stdout
assert 'redis:' in completed.stdout
assert 'crypto:' in completed.stdout
def test_app_doctor_json_output_has_stable_contract(
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
assert_check_payload_contract: Callable[[dict, bool], None],
) -> None:
completed = run_cli_command('app', 'doctor', '--env=dev', '--output=json')
payload = parse_json_stdout(completed)
assert completed.returncode in {SUCCESS, DEPENDENCY_ERROR}
assert completed.stderr == ''
assert payload['env'] == 'dev'
assert isinstance(payload['ok'], bool)
assert set(payload) == {'env', 'database', 'redis', 'crypto', 'ok'}
assert_check_payload_contract(payload['database'], True)
assert_check_payload_contract(payload['redis'], True)
assert_check_payload_contract(payload['crypto'], False)
def test_app_env_json_output_has_stable_contract(
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
) -> None:
completed = run_cli_command('app', 'env', '--env=dev', '--output=json')
payload = parse_json_stdout(completed)
assert completed.returncode == SUCCESS
assert payload['ok'] is True
assert payload['env'] == 'dev'
assert set(payload) == {'ok', 'env', 'runtime'}
assert isinstance(payload['runtime'], dict)
assert set(payload['runtime']) == {
'cliEnv',
'configEnv',
'appEnv',
'envFile',
'envFilePath',
'envFileExists',
'backendDir',
'pythonExecutable',
}
assert payload['runtime']['cliEnv'] == 'dev'
assert payload['runtime']['appEnv'] == 'dev'
assert payload['runtime']['envFile'] == '.env.dev'
assert isinstance(payload['runtime']['envFileExists'], bool)
def test_app_routes_json_output_has_stable_contract(
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
) -> None:
completed = run_cli_command('app', 'routes', '--env=dev', '--output=json')
payload = parse_json_stdout(completed)
assert completed.returncode == 0
assert payload['ok'] is True
assert payload['env'] == 'dev'
assert isinstance(payload['count'], int)
assert payload['count'] > 0
assert payload['groupedRoutes'] is None
assert payload['filters'] == {
'pathPrefix': '',
'method': '',
'groupBy': 'none',
'includeHidden': False,
}
assert isinstance(payload['routes'], list)
assert payload['routes']
first_route = payload['routes'][0]
assert set(first_route) == {
'path',
'methods',
'name',
'summary',
'operationId',
'tags',
'includeInSchema',
}
assert isinstance(first_route['path'], str)
assert isinstance(first_route['methods'], list)
assert first_route['methods']
assert all(isinstance(method, str) for method in first_route['methods'])
assert isinstance(first_route['name'], str | None)
assert isinstance(first_route['summary'], str | None)
assert isinstance(first_route['operationId'], str | None)
assert isinstance(first_route['tags'], list)
assert all(isinstance(tag, str) for tag in first_route['tags'])
assert isinstance(first_route['includeInSchema'], bool)
def test_server_info_text_output_has_stable_section_structure(
run_text_cli_command: Callable[..., subprocess.CompletedProcess[str]],
) -> None:
completed = run_text_cli_command('ops', 'server-info', '--env=dev')
assert completed.returncode == 0
assert '\x1b[' not in completed.stdout
assert '✅' not in completed.stdout
assert completed.stdout.startswith('OK SUCCESS\n')
assert 'host:\n' in completed.stdout
assert 'cpu:\n' in completed.stdout
assert 'memory:\n' in completed.stdout
assert 'python:\n' in completed.stdout
assert 'disks:' in completed.stdout
def test_ops_health_text_output_has_stable_check_structure(
run_text_cli_command: Callable[..., subprocess.CompletedProcess[str]],
) -> None:
completed = run_text_cli_command('ops', 'health', '--env=dev', '--output=text')
assert completed.returncode in {SUCCESS, DEPENDENCY_ERROR}
assert '\x1b[' not in completed.stdout
assert '✅' not in completed.stdout
assert completed.stdout.startswith(('OK SUCCESS\n', 'FAIL FAILED\n'))
assert 'env: dev\n' in completed.stdout
assert 'checks:\n' in completed.stdout
assert 'database:' in completed.stdout
assert 'redis:' in completed.stdout
def test_ops_deps_text_output_has_stable_structure(
run_text_cli_command: Callable[..., subprocess.CompletedProcess[str]],
) -> None:
completed = run_text_cli_command('ops', 'deps', '--env=dev', '--output=text')
assert completed.returncode in {SUCCESS, DEPENDENCY_ERROR}
assert completed.stdout.startswith(('OK SUCCESS\n', 'FAIL FAILED\n'))
assert 'message:' in completed.stdout
assert 'include_dev: false\n' in completed.stdout
assert 'missing_required:' in completed.stdout
assert 'packages:\n' in completed.stdout
assert 'python:' in completed.stdout
assert 'fastapi:' in completed.stdout
assert 'typer:' in completed.stdout
def test_ops_health_json_output_has_stable_contract(
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
assert_check_payload_contract: Callable[[dict, bool], None],
) -> None:
completed = run_cli_command('ops', 'health', '--env=dev', '--output=json')
payload = parse_json_stdout(completed)
assert completed.returncode in {SUCCESS, DEPENDENCY_ERROR}
assert completed.stderr == ''
assert payload['env'] == 'dev'
assert isinstance(payload['ok'], bool)
assert set(payload) == {'env', 'database', 'redis', 'ok'}
assert_check_payload_contract(payload['database'], True)
assert_check_payload_contract(payload['redis'], True)
def test_ops_deps_json_output_has_stable_contract(
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
) -> None:
completed = run_cli_command('ops', 'deps', '--env=dev', '--output=json')
payload = parse_json_stdout(completed)
assert completed.returncode in {SUCCESS, DEPENDENCY_ERROR}
assert set(payload) == {'ok', 'message', 'missingRequired', 'includeDev', 'packages'}
assert isinstance(payload['ok'], bool)
assert isinstance(payload['message'], str)
assert isinstance(payload['missingRequired'], list)
assert payload['includeDev'] is False
assert isinstance(payload['packages'], dict)
assert 'python' in payload['packages']
assert 'fastapi' in payload['packages']
assert 'typer' in payload['packages']
for package_payload in payload['packages'].values():
assert isinstance(package_payload, dict)
assert isinstance(package_payload.get('installed'), bool)
assert isinstance(package_payload.get('version'), str)
assert isinstance(package_payload.get('required'), bool)
def test_ops_server_info_json_output_has_stable_contract(
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
) -> None:
completed = run_cli_command('ops', 'server-info', '--env=dev', '--output=json')
payload = parse_json_stdout(completed)
assert completed.returncode == 0
assert payload['ok'] is True
assert isinstance(payload['server'], dict)
server = payload['server']
assert set(server) == {'cpu', 'mem', 'sys', 'py', 'sysFiles'}
cpu = server['cpu']
assert set(cpu) == {'cpuNum', 'used', 'sys', 'free'}
assert isinstance(cpu['cpuNum'], int)
assert isinstance(cpu['used'], float | int | str)
assert isinstance(cpu['sys'], float | int | str)
assert isinstance(cpu['free'], float | int | str)
memory = server['mem']
assert set(memory) == {'total', 'used', 'free', 'usage'}
assert isinstance(memory['total'], str)
assert isinstance(memory['used'], str)
assert isinstance(memory['free'], str)
assert isinstance(memory['usage'], float | int)
sys_info = server['sys']
assert set(sys_info) == {'computerIp', 'computerName', 'osArch', 'osName', 'userDir'}
assert all(isinstance(value, str) for value in sys_info.values())
py_info = server['py']
assert set(py_info) == {
'name',
'version',
'startTime',
'runTime',
'home',
'total',
'used',
'free',
'usage',
}
assert isinstance(py_info['name'], str)
assert isinstance(py_info['version'], str)
assert isinstance(py_info['startTime'], str)
assert isinstance(py_info['runTime'], str)
assert isinstance(py_info['home'], str)
assert isinstance(py_info['total'], str)
assert isinstance(py_info['used'], str)
assert isinstance(py_info['free'], str)
assert isinstance(py_info['usage'], float | int)
assert isinstance(server['sysFiles'], list)
assert server['sysFiles']
first_disk = server['sysFiles'][0]
assert set(first_disk) == {'dirName', 'sysTypeName', 'typeName', 'total', 'used', 'free', 'usage'}
assert all(isinstance(value, str) for value in first_disk.values())
@@ -0,0 +1,207 @@
import subprocess
from collections.abc import Callable
from pathlib import Path
from cli.exit_codes import SUCCESS
def test_root_help_shows_commands_without_completion_options(
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
) -> None:
completed = run_cli_command('--help')
assert completed.returncode == 0
assert 'Usage: shierfeng [OPTIONS] COMMAND [ARGS]...' in completed.stdout
assert 'app' in completed.stdout
assert 'db' in completed.stdout
assert 'completion' in completed.stdout
assert 'wizard' in completed.stdout
assert 'tui' in completed.stdout
assert completed.stdout.index('wizard') < completed.stdout.index('tui')
assert '--install-completion' not in completed.stdout
assert '--show-completion' not in completed.stdout
def test_completion_show_bash_outputs_completion_script(
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
) -> None:
completed = run_cli_command('completion', 'show', 'bash')
assert completed.returncode == SUCCESS
assert completed.stderr == ''
assert '_SHIERFENG_COMPLETE=bash_complete' in completed.stdout
assert '_shierfeng_completion()' in completed.stdout
assert 'if command -v compopt >/dev/null 2>&1; then' in completed.stdout
assert 'if complete -o nosort -F _shierfeng_completion shierfeng 2>/dev/null; then' in completed.stdout
assert 'complete -F _shierfeng_completion shierfeng' in completed.stdout
def test_completion_show_powershell_outputs_completion_script(
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
) -> None:
completed = run_cli_command('completion', 'show', 'powershell')
assert completed.returncode == SUCCESS
assert completed.stderr == ''
assert 'Register-ArgumentCompleter -Native -CommandName shierfeng' in completed.stdout
assert '$env:_SHIERFENG_COMPLETE = "powershell_complete"' in completed.stdout
def test_bash_completion_protocol_returns_candidates_without_not_supported_error(
run_cli_completion_command: Callable[..., subprocess.CompletedProcess[str]],
) -> None:
completed = run_cli_completion_command(comp_words='shierfeng comp', comp_cword=1)
assert completed.returncode == SUCCESS
assert 'plain,completion' in completed.stdout
assert 'not supported' not in completed.stdout.lower()
assert completed.stderr == ''
def test_powershell_completion_protocol_returns_candidates_without_not_supported_error(
run_cli_completion_command: Callable[..., subprocess.CompletedProcess[str]],
) -> None:
completed = run_cli_completion_command(
comp_words='shierfeng comp',
comp_cword='comp',
instruction='powershell_complete',
)
assert completed.returncode == SUCCESS
assert 'completion' in completed.stdout
assert 'not supported' not in completed.stdout.lower()
assert completed.stderr == ''
def test_completion_doctor_json_output_has_stable_contract(
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
) -> None:
completed = run_cli_command('completion', 'doctor', '--output=json')
payload = parse_json_stdout(completed)
assert completed.returncode == SUCCESS
assert payload['ok'] is True
assert payload['message'] == 'completion 诊断信息已生成'
assert set(payload) == {
'ok',
'message',
'activeShell',
'projectDir',
'envChoices',
'completeEnvVar',
'recommendedInstallCommand',
'shells',
}
assert isinstance(payload['envChoices'], list)
assert payload['completeEnvVar'] == '_SHIERFENG_COMPLETE'
assert isinstance(payload['shells'], dict)
assert set(payload['shells']) == {'bash', 'zsh', 'fish', 'powershell'}
assert payload['shells']['bash']['supported'] is True
assert payload['shells']['fish']['autoDiscovery'] is True
assert payload['shells']['powershell']['supported'] is True
assert isinstance(payload['shells']['bash']['sourceCommand'], str)
assert isinstance(payload['shells']['bash']['recommendedInstallCommand'], str)
assert isinstance(payload['shells']['powershell']['sourceCommand'], str)
assert isinstance(payload['shells']['powershell']['recommendedInstallCommand'], str)
def test_completion_install_json_output_has_stable_contract(
tmp_path: Path,
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
) -> None:
target_file = tmp_path / 'shierfeng.bash'
rc_file = tmp_path / '.bashrc'
completed = run_cli_command(
'completion',
'install',
'--shell=bash',
'--target-file',
str(target_file),
'--activate',
'--rc-file',
str(rc_file),
'--output=json',
)
payload = parse_json_stdout(completed)
assert completed.returncode == SUCCESS
assert payload['ok'] is True
assert set(payload) == {
'ok',
'message',
'shell',
'detectedShell',
'targetFile',
'activated',
'activateRequested',
'rcFile',
'rcFileUpdated',
'sourceCommand',
'autoDiscovery',
'activationRequired',
'nextStep',
'completeEnvVar',
}
assert payload['shell'] == 'bash'
assert payload['targetFile'] == str(target_file.resolve())
assert payload['activated'] is True
assert payload['activateRequested'] is True
assert payload['rcFile'] == str(rc_file.resolve())
assert payload['rcFileUpdated'] is True
assert payload['autoDiscovery'] is False
assert payload['activationRequired'] is True
assert isinstance(payload['nextStep'], str)
assert target_file.exists()
assert rc_file.exists()
assert '_SHIERFENG_COMPLETE=bash_complete' in target_file.read_text(encoding='utf-8')
assert payload['sourceCommand'] in rc_file.read_text(encoding='utf-8')
def test_completion_install_powershell_json_output_has_stable_contract(
tmp_path: Path,
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
) -> None:
target_file = tmp_path / 'shierfeng.ps1'
rc_file = tmp_path / 'Microsoft.PowerShell_profile.ps1'
completed = run_cli_command(
'completion',
'install',
'--shell=powershell',
'--target-file',
str(target_file),
'--activate',
'--rc-file',
str(rc_file),
'--output=json',
)
payload = parse_json_stdout(completed)
assert completed.returncode == SUCCESS
assert payload['ok'] is True
assert payload['shell'] == 'powershell'
assert payload['targetFile'] == str(target_file.resolve())
assert payload['activated'] is True
assert payload['activateRequested'] is True
assert payload['rcFile'] == str(rc_file.resolve())
assert payload['rcFileUpdated'] is True
assert payload['autoDiscovery'] is False
assert payload['activationRequired'] is True
assert payload['sourceCommand'] == f'. "{target_file.resolve()}"'
assert target_file.exists()
assert rc_file.exists()
assert 'Register-ArgumentCompleter -Native -CommandName shierfeng' in target_file.read_text(encoding='utf-8')
assert payload['sourceCommand'] in rc_file.read_text(encoding='utf-8')
def test_app_run_help_only_exposes_env_option(
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
) -> None:
completed = run_cli_command('app', 'run', '--help')
assert completed.returncode == 0
assert 'Usage: shierfeng app run [OPTIONS]' in completed.stdout
assert '--env' in completed.stdout
assert '--output' not in completed.stdout
@@ -0,0 +1,40 @@
import subprocess
from collections.abc import Callable
from cli.exit_codes import DATABASE_ERROR, REDIS_ERROR, RUNTIME_ERROR, SUCCESS
def test_config_doctor_json_output_has_stable_contract(
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
) -> None:
completed = run_cli_command('config', 'doctor', '--env=dev', '--output=json')
payload = parse_json_stdout(completed)
assert completed.returncode in {SUCCESS, REDIS_ERROR, DATABASE_ERROR, RUNTIME_ERROR}
assert payload['env'] == 'dev'
assert isinstance(payload['ok'], bool)
if 'error' in payload:
assert isinstance(payload['message'], str)
assert isinstance(payload['error'], str)
return
assert set(payload) == {
'ok',
'message',
'databaseCount',
'cacheCount',
'missingInCacheCount',
'orphanInCacheCount',
'mismatchCount',
'sampleLimit',
'missingInCache',
'orphanInCache',
'mismatchKeys',
'env',
}
assert isinstance(payload['databaseCount'], int)
assert isinstance(payload['cacheCount'], int)
assert isinstance(payload['missingInCache'], list)
assert isinstance(payload['orphanInCache'], list)
assert isinstance(payload['mismatchKeys'], list)
@@ -0,0 +1,235 @@
import subprocess
from collections.abc import Callable
from pathlib import Path
from cli.exit_codes import DATABASE_ERROR, GUARD_REJECTED, REDIS_ERROR, RUNTIME_ERROR, SUCCESS
DB_HISTORY_TEST_LIMIT = 5
def test_dangerous_command_is_rejected_without_yes_in_non_interactive_mode(
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
) -> None:
completed = run_cli_command('db', 'revision', '--env=dev', '--message=test-contract', '--output=json')
payload = parse_json_stdout(completed)
assert completed.returncode == GUARD_REJECTED
assert payload['ok'] is False
assert payload['message'] == '已取消危险命令执行:db revision'
assert '--yes' in payload['hint']
def test_prod_dangerous_command_is_rejected_without_allow_prod(
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
) -> None:
completed = run_cli_command('db', 'revision', '--env=prod', '--message=test-contract', '--output=json')
payload = parse_json_stdout(completed)
assert completed.returncode == GUARD_REJECTED
assert payload['ok'] is False
assert payload['message'] == '生产环境默认禁止直接执行危险命令:db revision'
assert '--allow-prod' in payload['hint']
assert '--yes' in payload['hint']
def test_dry_run_command_returns_structured_preview_payload(
backend_dir: Path,
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
) -> None:
completed = run_cli_command(
'db',
'upgrade',
'--env=dev',
'--revision=head',
'--dry-run',
'--yes',
'--output=json',
)
payload = parse_json_stdout(completed)
assert completed.returncode == 0
assert payload['ok'] is True
assert payload['dryRun'] is True
assert isinstance(payload['command'], list)
assert payload['command'][-2:] == ['upgrade', 'head']
assert payload['cwd'] == str(backend_dir)
def test_db_heads_json_output_has_stable_contract(
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
) -> None:
completed = run_cli_command('db', 'heads', '--env=dev', '--output=json')
payload = parse_json_stdout(completed)
assert completed.returncode in {SUCCESS, DATABASE_ERROR}
assert payload['env'] == 'dev'
assert isinstance(payload['ok'], bool)
if 'error' in payload:
assert isinstance(payload['message'], str)
assert isinstance(payload['error'], str)
return
assert set(payload) == {'ok', 'message', 'count', 'items', 'env'}
assert isinstance(payload['count'], int)
assert isinstance(payload['items'], list)
if payload['items']:
first_item = payload['items'][0]
assert set(first_item) == {'revision', 'downRevisions', 'branchLabels', 'dependsOn', 'doc', 'path'}
assert isinstance(first_item['revision'], str)
assert isinstance(first_item['downRevisions'], list)
assert isinstance(first_item['branchLabels'], list)
assert isinstance(first_item['dependsOn'], list)
assert isinstance(first_item['doc'], str)
assert isinstance(first_item['path'], str)
def test_db_history_json_output_has_stable_contract(
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
) -> None:
completed = run_cli_command(
'db',
'history',
'--env=dev',
f'--limit={DB_HISTORY_TEST_LIMIT}',
'--output=json',
)
payload = parse_json_stdout(completed)
assert completed.returncode in {SUCCESS, DATABASE_ERROR}
assert payload['env'] == 'dev'
assert isinstance(payload['ok'], bool)
if 'error' in payload:
assert isinstance(payload['message'], str)
assert isinstance(payload['error'], str)
return
assert set(payload) == {'ok', 'message', 'count', 'totalCount', 'limit', 'items', 'env'}
assert payload['limit'] == DB_HISTORY_TEST_LIMIT
assert isinstance(payload['count'], int)
assert isinstance(payload['totalCount'], int)
assert isinstance(payload['items'], list)
def test_cache_ttl_json_output_has_stable_contract_for_missing_key_or_redis_error(
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
) -> None:
completed = run_cli_command(
'cache',
'ttl',
'sys_config',
'definitely_missing_cli_key',
'--env=dev',
'--output=json',
)
payload = parse_json_stdout(completed)
assert completed.returncode in {SUCCESS, REDIS_ERROR, RUNTIME_ERROR}
assert isinstance(payload['ok'], bool)
if 'error' in payload:
assert payload['message'] == '读取缓存剩余过期时间失败'
assert isinstance(payload['error'], str)
return
assert set(payload) == {'ok', 'message', 'cacheName', 'cacheKey', 'fullCacheKey'}
assert payload['ok'] is False
assert payload['cacheName'] == 'sys_config'
assert payload['cacheKey'] == 'definitely_missing_cli_key'
assert payload['fullCacheKey'] == 'sys_config:definitely_missing_cli_key'
def test_dangerous_command_rejection_text_output_is_human_readable(
run_text_cli_command: Callable[..., subprocess.CompletedProcess[str]],
) -> None:
completed = run_text_cli_command('db', 'revision', '--env=dev', '--message=test-contract', '--output=text')
assert completed.returncode == GUARD_REJECTED
assert completed.stdout == (
'FAIL FAILED\n'
'message: 已取消危险命令执行:db revision\n'
'hint: 当前命令需要交互确认;如需非交互执行,请传入 --yes\n'
)
def test_dry_run_text_output_contains_preview_command_and_workdir(
backend_dir: Path,
run_text_cli_command: Callable[..., subprocess.CompletedProcess[str]],
) -> None:
completed = run_text_cli_command(
'db',
'upgrade',
'--env=dev',
'--revision=head',
'--dry-run',
'--yes',
'--output=text',
)
assert completed.returncode == 0
assert completed.stdout.startswith('OK SUCCESS\n')
assert 'message: 数据库已升级到 head(dry-run)\n' in completed.stdout
assert 'dry_run: true\n' in completed.stdout
assert 'command:\n - alembic\n - -c\n' in completed.stdout
assert f' - {backend_dir / "alembic.ini"}\n' in completed.stdout
assert f'cwd: {backend_dir}\n' in completed.stdout
def test_dev_lint_json_output_has_stable_contract(
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
) -> None:
completed = run_cli_command(
'dev',
'lint',
'cli/groups/dev/command.py',
'--env=dev',
'--check-only',
'--output=json',
)
payload = parse_json_stdout(completed)
assert completed.returncode == SUCCESS
assert payload['ok'] is True
assert payload['message'] == '开发检查已完成'
assert payload['env'] == 'dev'
assert payload['checkOnly'] is True
assert payload['fix'] is False
assert payload['unsafeFixes'] is False
assert payload['targets'] == ['cli/groups/dev/command.py']
assert isinstance(payload['format'], dict)
assert isinstance(payload['check'], dict)
assert payload['format']['ok'] is True
assert payload['check']['ok'] is True
assert isinstance(payload['format']['command'], list)
assert isinstance(payload['check']['command'], list)
def test_dev_test_text_output_has_stable_summary(
run_text_cli_command: Callable[..., subprocess.CompletedProcess[str]],
) -> None:
completed = run_text_cli_command(
'dev',
'test',
'tests/cli/root/test_contract_cli.py',
'--env=dev',
'--keyword=test_root_help_shows_commands_without_completion_options',
'--maxfail=1',
'--quiet',
'--output=text',
)
assert completed.returncode == SUCCESS
assert completed.stdout.startswith('OK SUCCESS\n')
assert 'ok: true\n' in completed.stdout
assert 'env: dev\n' in completed.stdout
assert 'keyword: test_root_help_shows_commands_without_completion_options\n' in completed.stdout
assert 'maxfail: 1\n' in completed.stdout
assert 'quiet: true\n' in completed.stdout
assert 'targets:\n - tests/cli/root/test_contract_cli.py\n' in completed.stdout
assert 'test:\n ok: true\n' in completed.stdout
assert 'command: ' in completed.stdout
@@ -0,0 +1,368 @@
import subprocess
from collections.abc import Callable
from cli.exit_codes import ARGUMENT_ERROR, DATABASE_ERROR, GUARD_REJECTED, RUNTIME_ERROR, SUCCESS
GEN_TEST_TABLE_NAME = 'demo_table'
GEN_CREATE_SQL = 'CREATE TABLE demo_cli_test (id bigint);'
MISSING_JOB_ID = 999999999
MISSING_GEN_TABLE_ID = 999999999
def test_job_detail_json_output_has_stable_contract(
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
) -> None:
list_completed = run_cli_command('job', 'list', '--env=dev', '--output=json')
list_payload = parse_json_stdout(list_completed)
job_id = MISSING_JOB_ID
if list_payload.get('ok') and isinstance(list_payload.get('items'), list) and list_payload['items']:
first_job = list_payload['items'][0]
if isinstance(first_job, dict) and isinstance(first_job.get('jobId'), int):
job_id = first_job['jobId']
completed = run_cli_command('job', 'detail', str(job_id), '--env=dev', '--output=json')
payload = parse_json_stdout(completed)
assert completed.returncode in {SUCCESS, DATABASE_ERROR, RUNTIME_ERROR}
assert isinstance(payload['ok'], bool)
if 'error' in payload:
assert isinstance(payload['message'], str)
assert isinstance(payload['error'], str)
return
if payload['ok'] is False:
assert set(payload) == {'ok', 'message', 'jobId'}
assert payload['jobId'] == job_id
return
assert set(payload) == {'ok', 'job'}
assert isinstance(payload['job'], dict)
assert payload['job']['jobId'] == job_id
assert isinstance(payload['job'].get('jobName'), str)
assert isinstance(payload['job'].get('invokeTarget'), str)
def test_job_logs_json_output_has_stable_contract(
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
) -> None:
completed = run_cli_command('job', 'logs', '--env=dev', '--output=json')
payload = parse_json_stdout(completed)
assert completed.returncode in {SUCCESS, DATABASE_ERROR}
assert isinstance(payload['ok'], bool)
if 'error' in payload:
assert isinstance(payload['message'], str)
assert isinstance(payload['error'], str)
return
assert set(payload) == {'ok', 'filters', 'count', 'items'}
assert isinstance(payload['filters'], dict)
assert isinstance(payload['count'], int)
assert isinstance(payload['items'], list)
if payload['items']:
first_item = payload['items'][0]
assert isinstance(first_item, dict)
assert isinstance(first_item.get('jobLogId'), int)
assert isinstance(first_item.get('jobName'), str | None)
assert isinstance(first_item.get('jobMessage'), str | None)
def test_gen_list_json_output_has_stable_contract(
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
) -> None:
completed = run_cli_command('gen', 'list', '--env=dev', '--output=json')
payload = parse_json_stdout(completed)
assert completed.returncode in {SUCCESS, DATABASE_ERROR}
assert payload['env'] == 'dev'
assert isinstance(payload['ok'], bool)
if 'error' in payload:
assert isinstance(payload['message'], str)
assert isinstance(payload['error'], str)
return
assert set(payload) == {'ok', 'filters', 'count', 'items', 'env'}
assert isinstance(payload['filters'], dict)
assert isinstance(payload['count'], int)
assert isinstance(payload['items'], list)
if payload['items']:
first_item = payload['items'][0]
assert isinstance(first_item, dict)
assert isinstance(first_item.get('tableId'), int)
assert isinstance(first_item.get('tableName'), str)
def test_gen_db_list_json_output_has_stable_contract(
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
) -> None:
completed = run_cli_command('gen', 'db-list', '--env=dev', '--output=json')
payload = parse_json_stdout(completed)
assert completed.returncode in {SUCCESS, DATABASE_ERROR}
assert payload['env'] == 'dev'
assert isinstance(payload['ok'], bool)
if 'error' in payload:
assert isinstance(payload['message'], str)
assert isinstance(payload['error'], str)
return
assert set(payload) == {'ok', 'filters', 'count', 'items', 'env'}
assert isinstance(payload['filters'], dict)
assert isinstance(payload['count'], int)
assert isinstance(payload['items'], list)
if payload['items']:
first_item = payload['items'][0]
assert isinstance(first_item, dict)
assert isinstance(first_item.get('tableName'), str)
def test_gen_detail_json_output_has_stable_contract(
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
) -> None:
list_completed = run_cli_command('gen', 'list', '--env=dev', '--output=json')
list_payload = parse_json_stdout(list_completed)
table_id = MISSING_GEN_TABLE_ID
if list_payload.get('ok') and isinstance(list_payload.get('items'), list) and list_payload['items']:
first_item = list_payload['items'][0]
if isinstance(first_item, dict) and isinstance(first_item.get('tableId'), int):
table_id = first_item['tableId']
completed = run_cli_command('gen', 'detail', str(table_id), '--env=dev', '--output=json')
payload = parse_json_stdout(completed)
assert completed.returncode in {SUCCESS, DATABASE_ERROR, RUNTIME_ERROR}
assert payload['env'] == 'dev'
assert isinstance(payload['ok'], bool)
if 'error' in payload:
assert isinstance(payload['message'], str)
assert isinstance(payload['error'], str)
return
if payload['ok'] is False:
assert set(payload) == {'ok', 'message', 'tableId', 'env'}
assert payload['tableId'] == table_id
return
assert set(payload) == {'ok', 'tableId', 'tableName', 'columnCount', 'tableCount', 'detail', 'env'}
assert payload['tableId'] == table_id
assert isinstance(payload['tableName'], str)
assert isinstance(payload['columnCount'], int)
assert isinstance(payload['tableCount'], int)
assert isinstance(payload['detail'], dict)
assert isinstance(payload['detail'].get('info'), dict)
assert isinstance(payload['detail'].get('rows'), list)
assert isinstance(payload['detail'].get('tables'), list)
def test_gen_sync_db_is_rejected_without_yes_in_non_interactive_mode(
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
) -> None:
completed = run_cli_command('gen', 'sync-db', 'demo_table', '--env=dev', '--output=json')
payload = parse_json_stdout(completed)
assert completed.returncode == GUARD_REJECTED
assert payload['ok'] is False
assert payload['message'] == '已取消危险命令执行:gen sync-db'
assert '--yes' in payload['hint']
def test_gen_export_dry_run_text_output_has_stable_structure(
run_text_cli_command: Callable[..., subprocess.CompletedProcess[str]],
) -> None:
completed = run_text_cli_command(
'gen',
'export',
GEN_TEST_TABLE_NAME,
'--env=dev',
'--dry-run',
'--yes',
'--output=text',
)
assert completed.returncode == SUCCESS
assert completed.stdout.startswith('OK SUCCESS\n')
assert 'env: dev\n' in completed.stdout
assert 'mode: zip\n' in completed.stdout
assert 'dry_run: true\n' in completed.stdout
assert 'message: 代码导出演练完成,未执行实际导出\n' in completed.stdout
assert 'table_names:\n' in completed.stdout
assert f' - {GEN_TEST_TABLE_NAME}\n' in completed.stdout
assert 'output_file:' in completed.stdout
assert f'gen_code_{GEN_TEST_TABLE_NAME}.zip' in completed.stdout
def test_gen_export_dry_run_json_output_has_stable_contract(
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
) -> None:
completed = run_cli_command(
'gen',
'export',
GEN_TEST_TABLE_NAME,
'--env=dev',
'--dry-run',
'--yes',
'--output=json',
)
payload = parse_json_stdout(completed)
assert completed.returncode == SUCCESS
assert payload['ok'] is True
assert payload['env'] == 'dev'
assert payload['dryRun'] is True
assert payload['mode'] == 'zip'
assert payload['message'] == '代码导出演练完成,未执行实际导出'
assert payload['tableNames'] == [GEN_TEST_TABLE_NAME]
assert isinstance(payload['outputFile'], str)
assert payload['outputFile'].endswith(f'gen_code_{GEN_TEST_TABLE_NAME}.zip')
def test_gen_create_table_dry_run_text_output_has_stable_structure(
run_text_cli_command: Callable[..., subprocess.CompletedProcess[str]],
) -> None:
completed = run_text_cli_command(
'gen',
'create-table',
'--env=dev',
'--dry-run',
'--yes',
'--sql',
GEN_CREATE_SQL,
'--output=text',
)
assert completed.returncode == SUCCESS
assert completed.stdout.startswith('OK SUCCESS\n')
assert 'env: dev\n' in completed.stdout
assert 'message: 建表语句演练完成,未执行实际建表\n' in completed.stdout
assert 'dry_run: true\n' in completed.stdout
assert 'statement_count: 1\n' in completed.stdout
assert 'table_names:\n' in completed.stdout
assert ' - demo_cli_test\n' in completed.stdout
assert f'sql: {GEN_CREATE_SQL}\n' in completed.stdout
def test_gen_create_table_dry_run_json_output_has_stable_contract(
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
) -> None:
completed = run_cli_command(
'gen',
'create-table',
'--env=dev',
'--dry-run',
'--yes',
'--sql',
GEN_CREATE_SQL,
'--output=json',
)
payload = parse_json_stdout(completed)
assert completed.returncode == SUCCESS
assert payload['ok'] is True
assert payload['env'] == 'dev'
assert payload['dryRun'] is True
assert payload['message'] == '建表语句演练完成,未执行实际建表'
assert payload['statementCount'] == 1
assert payload['tableNames'] == ['demo_cli_test']
assert payload['sql'] == GEN_CREATE_SQL
def test_gen_create_table_rejects_non_create_sql_in_text_output(
run_text_cli_command: Callable[..., subprocess.CompletedProcess[str]],
) -> None:
completed = run_text_cli_command(
'gen',
'create-table',
'--env=dev',
'--dry-run',
'--yes',
'--sql',
'DROP TABLE demo_cli_test;',
'--output=text',
)
assert completed.returncode == ARGUMENT_ERROR
assert completed.stdout == (
'FAIL FAILED\nmessage: 创建表结构失败\nerror: 建表语句不合法,仅允许 CREATE TABLE 语句\nenv: dev\n'
)
def test_gen_create_table_rejects_non_create_sql_in_json_output(
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
) -> None:
completed = run_cli_command(
'gen',
'create-table',
'--env=dev',
'--dry-run',
'--yes',
'--sql',
'DROP TABLE demo_cli_test;',
'--output=json',
)
payload = parse_json_stdout(completed)
assert completed.returncode == ARGUMENT_ERROR
assert payload == {
'ok': False,
'message': '创建表结构失败',
'error': '建表语句不合法,仅允许 CREATE TABLE 语句',
'env': 'dev',
}
def test_gen_create_table_rejects_conflicting_sql_inputs_in_text_output(
run_text_cli_command: Callable[..., subprocess.CompletedProcess[str]],
) -> None:
completed = run_text_cli_command(
'gen',
'create-table',
'--env=dev',
'--dry-run',
'--yes',
'--sql',
GEN_CREATE_SQL,
'--sql-file',
'fake.sql',
'--output=text',
)
assert completed.returncode == ARGUMENT_ERROR
assert completed.stdout == (
'FAIL FAILED\nmessage: 创建表结构失败\nerror: 必须且只能传入 --sql 或 --sql-file 其中一种方式\nenv: dev\n'
)
def test_gen_create_table_rejects_conflicting_sql_inputs_in_json_output(
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
) -> None:
completed = run_cli_command(
'gen',
'create-table',
'--env=dev',
'--dry-run',
'--yes',
'--sql',
GEN_CREATE_SQL,
'--sql-file',
'fake.sql',
'--output=json',
)
payload = parse_json_stdout(completed)
assert completed.returncode == ARGUMENT_ERROR
assert payload == {
'ok': False,
'message': '创建表结构失败',
'error': '必须且只能传入 --sql 或 --sql-file 其中一种方式',
'env': 'dev',
}
@@ -0,0 +1,69 @@
import importlib
import sys
from pathlib import Path
import pytest
BACKEND_DIR = Path(__file__).resolve().parents[3]
sys.path.insert(0, str(BACKEND_DIR))
cli_guards = importlib.import_module('cli.guards')
cli_guards = importlib.reload(cli_guards)
DEFAULT_DANGEROUS_COMMAND_RULES = cli_guards.DEFAULT_DANGEROUS_COMMAND_RULES
DEFAULT_DANGEROUS_COMMAND_RULE_REGISTRY = cli_guards.DEFAULT_DANGEROUS_COMMAND_RULE_REGISTRY
def test_dangerous_command_rules_cover_expected_commands() -> None:
"""
校验危险命令规则表已覆盖当前设计文档中约定的命令范围。
:return: None
"""
assert set(DEFAULT_DANGEROUS_COMMAND_RULES) == {
'cache clear',
'cache warmup',
'db upgrade',
'db init',
'db downgrade',
'db revision',
'config set',
'config sync-cache',
'crypto rotate',
'job run-once',
'job pause',
'job resume',
'job sync',
'gen import-table',
'gen create-table',
'gen export',
'gen sync-db',
}
def test_dangerous_command_rules_expose_risk_level_and_dry_run_metadata() -> None:
"""
校验危险命令规则能够提供风险级别和 dry-run 能力元数据。
:return: None
"""
db_upgrade_rule = DEFAULT_DANGEROUS_COMMAND_RULE_REGISTRY.get_rule('db upgrade')
cache_warmup_rule = DEFAULT_DANGEROUS_COMMAND_RULE_REGISTRY.get_rule('cache warmup')
assert db_upgrade_rule is not None
assert db_upgrade_rule.risk_level == 'high'
assert db_upgrade_rule.supports_dry_run is True
assert cache_warmup_rule is not None
assert cache_warmup_rule.risk_level == 'normal'
assert cache_warmup_rule.supports_dry_run is False
def test_require_dangerous_command_rule_rejects_unknown_command() -> None:
"""
校验未注册的危险命令名称会被显式拒绝。
:return: None
"""
with pytest.raises(ValueError, match='危险命令未注册保护规则'):
DEFAULT_DANGEROUS_COMMAND_RULE_REGISTRY.require_rule('unknown dangerous command')
@@ -0,0 +1,18 @@
import subprocess
from collections.abc import Callable
def test_config_doctor_json_stdout_is_not_polluted_by_sqlalchemy_logs(
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
) -> None:
"""
校验 JSON 输出模式下不会混入 SQLAlchemy engine 日志。
:param run_cli_command: CLI 子进程执行器
:return: None
"""
completed = run_cli_command('config', 'doctor', '--env=dev', '--output=json')
assert 'sqlalchemy.engine.Engine' not in completed.stdout
assert 'SELECT DATABASE()' not in completed.stdout
assert completed.stdout.lstrip().startswith('{')
@@ -0,0 +1,13 @@
import importlib
def test_cli_main_runner_reuses_single_project_runtime_locator() -> None:
"""
校验 CLI 根运行器与导入期参数作用域共享同一个项目定位器实例。
:return: None
"""
cli_main = importlib.import_module('cli.main')
assert cli_main.CLI_MAIN_RUNNER.project_runtime_locator is cli_main.PROJECT_RUNTIME_LOCATOR
assert cli_main.CLI_MAIN_RUNNER.import_argv_scope.project_runtime_locator is cli_main.PROJECT_RUNTIME_LOCATOR
@@ -0,0 +1,181 @@
import importlib
import re
import sys
from pathlib import Path
BACKEND_DIR = Path(__file__).resolve().parents[3]
sys.path.insert(0, str(BACKEND_DIR))
sys.modules.pop('cli.output', None)
sys.modules.pop('cli', None)
cli_output = importlib.import_module('cli.output')
OUTPUT_RENDERER = cli_output.OutputRenderer()
_ANSI_ESCAPE_PATTERN = re.compile(r'\x1b\[[0-9;]*m')
class _FakeStream:
"""
模拟终端输出流。
:param is_tty: 是否模拟为 TTY
"""
def __init__(self, *, is_tty: bool) -> None:
self._is_tty = is_tty
def isatty(self) -> bool:
"""
返回是否为 TTY。
:return: 是否为 TTY
"""
return self._is_tty
def _strip_ansi(text: str) -> str:
"""
移除 ANSI 转义序列,便于断言文本可视结果。
:param text: 原始带样式文本
:return: 去除 ANSI 后的文本
"""
return _ANSI_ESCAPE_PATTERN.sub('', text)
def test_decorate_text_output_promotes_success_header_with_emoji_icon() -> None:
"""
校验成功结果头在 emoji 模式下会被提升为统一状态头。
:return: None
"""
OUTPUT_RENDERER.set_icon_mode('emoji')
rendered = OUTPUT_RENDERER.decorate_text_output('ok: true\nenv: dev')
assert rendered == '✅ OK SUCCESS\nenv: dev'
def test_decorate_text_output_promotes_failure_header_with_emoji_icon() -> None:
"""
校验失败结果头在 emoji 模式下会被提升为统一状态头。
:return: None
"""
OUTPUT_RENDERER.set_icon_mode('emoji')
rendered = OUTPUT_RENDERER.decorate_text_output('ok: false\nerror: boom')
assert rendered == '❌ FAIL FAILED\nerror: boom'
def test_colorize_text_output_renders_inline_status_segments_with_emoji_icon() -> None:
"""
校验文本着色后会为 message、hint、error 等字段附加状态标签。
:return: None
"""
OUTPUT_RENDERER.set_icon_mode('emoji')
OUTPUT_RENDERER.set_color_mode('always')
decorated = OUTPUT_RENDERER.decorate_text_output('ok: false\nmessage: retry later\nhint: use --yes\nerror: boom')
rendered = OUTPUT_RENDERER.colorize_text_output(decorated, _FakeStream(is_tty=True))
assert '\x1b[' in rendered
assert _strip_ansi(rendered) == (
'❌ FAIL FAILED\nmessage: 💬 INFO retry later\nhint: 💡 HINT use --yes\nerror: 🚨 ERROR boom'
)
def test_colorize_text_output_supports_warning_and_info_field_aliases() -> None:
"""
校验 `warning` 与 `info` 字段也会使用统一状态图标渲染。
:return: None
"""
OUTPUT_RENDERER.set_icon_mode('emoji')
OUTPUT_RENDERER.set_color_mode('always')
decorated = OUTPUT_RENDERER.decorate_text_output('ok: true\nwarning: rotate key soon\ninfo: cache refreshed')
rendered = OUTPUT_RENDERER.colorize_text_output(decorated, _FakeStream(is_tty=True))
assert _strip_ansi(rendered) == ('✅ OK SUCCESS\nwarning: 💡 HINT rotate key soon\ninfo: 💬 INFO cache refreshed')
def test_colorize_text_output_styles_status_segments_inside_list_items() -> None:
"""
校验列表项中的带标签状态片段也会应用统一视觉规则。
:return: None
"""
OUTPUT_RENDERER.set_icon_mode('emoji')
OUTPUT_RENDERER.set_color_mode('always')
rendered = OUTPUT_RENDERER.colorize_text_output(
'results:\n - users: true | exported\n - roles: false | error: lock timeout',
_FakeStream(is_tty=True),
)
assert _strip_ansi(rendered) == (
'results:\n - users: ✅ OK | exported\n - roles: ❌ FAIL | 🚨 ERROR lock timeout'
)
def test_colorize_text_output_supports_ascii_icon_mode() -> None:
"""
校验 ascii 图标模式会渲染为纯 ASCII 状态标签。
:return: None
"""
OUTPUT_RENDERER.set_icon_mode('ascii')
OUTPUT_RENDERER.set_color_mode('always')
decorated = OUTPUT_RENDERER.decorate_text_output('ok: true\nhint: use --yes')
rendered = OUTPUT_RENDERER.colorize_text_output(decorated, _FakeStream(is_tty=True))
assert _strip_ansi(rendered) == '[OK] SUCCESS\nhint: [HINT] use --yes'
def test_colorize_text_output_skips_ansi_when_color_mode_is_never() -> None:
"""
校验关闭颜色后不会输出 ANSI 转义序列。
:return: None
"""
OUTPUT_RENDERER.set_icon_mode('emoji')
OUTPUT_RENDERER.set_color_mode('never')
decorated = OUTPUT_RENDERER.decorate_text_output('ok: false\nhint: use --yes')
rendered = OUTPUT_RENDERER.colorize_text_output(decorated, _FakeStream(is_tty=True))
assert rendered == '❌ FAIL FAILED\nhint: use --yes'
assert '\x1b[' not in rendered
def test_render_error_text_uses_status_token_in_emoji_mode() -> None:
"""
校验标准错误输出在 emoji 模式下使用统一错误图标。
:return: None
"""
OUTPUT_RENDERER.set_icon_mode('emoji')
OUTPUT_RENDERER.set_color_mode('never')
rendered = OUTPUT_RENDERER.render_error_text('boom', 23, _FakeStream(is_tty=True))
assert rendered == '🚨 ERROR[23]: boom'
def test_render_error_text_supports_ascii_mode_with_color() -> None:
"""
校验标准错误输出在 ascii 模式下仍保持统一错误前缀与颜色。
:return: None
"""
OUTPUT_RENDERER.set_icon_mode('ascii')
OUTPUT_RENDERER.set_color_mode('always')
rendered = OUTPUT_RENDERER.render_error_text('boom', 23, _FakeStream(is_tty=True))
assert '\x1b[' in rendered
assert _strip_ansi(rendered) == '[ERROR][23]: boom'
@@ -0,0 +1,142 @@
import importlib
import sys
from pathlib import Path
from types import SimpleNamespace
from pytest import MonkeyPatch
BACKEND_DIR = Path(__file__).resolve().parents[3]
sys.path.insert(0, str(BACKEND_DIR))
sys.modules.pop('cli.utils', None)
cli_utils = importlib.import_module('cli.utils')
def test_run_nested_cli_command_extracts_json_from_noisy_stdout(monkeypatch: MonkeyPatch) -> None:
"""
校验内部 CLI 调用可从带噪声的标准输出中提取 JSON 负载。
:param monkeypatch: pytest monkeypatch 工具
:return: None
"""
def _fake_subprocess_run(*args: object, **kwargs: object) -> SimpleNamespace:
del args, kwargs
return SimpleNamespace(
stdout='debug line before json\n{"ok": true, "message": "success"}\ndebug line after json\n',
stderr='',
returncode=0,
)
monkeypatch.setattr(cli_utils.subprocess, 'run', _fake_subprocess_run)
result = cli_utils.NESTED_CLI_SUPPORT.run('app', 'env', '--output=json', parse_json=True)
assert result.payload == {'ok': True, 'message': 'success'}
def test_run_nested_cli_command_extracts_first_json_object_from_mixed_stdout(
monkeypatch: MonkeyPatch,
) -> None:
"""
校验内部 CLI 调用可从混杂文本中提取首个合法 JSON 对象。
:param monkeypatch: pytest monkeypatch 工具
:return: None
"""
def _fake_subprocess_run(*args: object, **kwargs: object) -> SimpleNamespace:
del args, kwargs
return SimpleNamespace(
stdout='preface\ntrace {not-json}\n{"ok": true, "message": "job done"}\ntrailer\n',
stderr='',
returncode=0,
)
monkeypatch.setattr(cli_utils.subprocess, 'run', _fake_subprocess_run)
result = cli_utils.NESTED_CLI_SUPPORT.run('job', 'run-once', '1', '--output=json', parse_json=True)
assert result.payload == {'ok': True, 'message': 'job done'}
def test_run_nested_cli_command_falls_back_to_text_payload_when_json_missing(
monkeypatch: MonkeyPatch,
) -> None:
"""
校验内部 CLI 未返回 JSON 时会回退为文本结果负载。
:param monkeypatch: pytest monkeypatch 工具
:return: None
"""
def _fake_subprocess_run(*args: object, **kwargs: object) -> SimpleNamespace:
del args, kwargs
return SimpleNamespace(
stdout='任务已提交到调度器',
stderr='',
returncode=0,
)
monkeypatch.setattr(cli_utils.subprocess, 'run', _fake_subprocess_run)
result = cli_utils.NESTED_CLI_SUPPORT.run('job', 'run-once', '1', '--output=json', parse_json=True)
assert result.payload is not None
assert result.payload.get('ok') is True
assert result.payload.get('message') == '任务已提交到调度器'
assert result.payload.get('fallback') == 'non_json_output'
def test_run_nested_cli_command_uses_backend_dir_and_pythonpath(monkeypatch: MonkeyPatch) -> None:
"""
校验内部 CLI 调用会固定后端工作目录并注入项目路径。
:param monkeypatch: pytest monkeypatch 工具
:return: None
"""
recorded_kwargs: dict[str, object] = {}
def _fake_subprocess_run(*args: object, **kwargs: object) -> SimpleNamespace:
del args
recorded_kwargs.update(kwargs)
return SimpleNamespace(stdout='{}', stderr='', returncode=0)
monkeypatch.setattr(cli_utils.subprocess, 'run', _fake_subprocess_run)
result = cli_utils.NESTED_CLI_SUPPORT.run('app', 'env', parse_json=False)
assert result.returncode == 0
assert recorded_kwargs['cwd'] == str(BACKEND_DIR)
process_env = recorded_kwargs['env']
assert isinstance(process_env, dict)
assert str(BACKEND_DIR) in process_env.get('PYTHONPATH', '')
def test_run_nested_cli_command_live_uses_backend_dir_without_capturing_output(monkeypatch: MonkeyPatch) -> None:
"""
校验交互式内部 CLI 调用会复用后端工作目录并直接占用当前终端。
:param monkeypatch: pytest monkeypatch 工具
:return: None
"""
recorded_kwargs: dict[str, object] = {}
recorded_args: tuple[object, ...] = ()
def _fake_subprocess_run(*args: object, **kwargs: object) -> SimpleNamespace:
nonlocal recorded_args
recorded_args = args
recorded_kwargs.update(kwargs)
return SimpleNamespace(returncode=0)
monkeypatch.setattr(cli_utils.subprocess, 'run', _fake_subprocess_run)
completed = cli_utils.NESTED_CLI_SUPPORT.run_live('wizard', 'cache-clear', '--output=text')
assert completed.returncode == 0
assert recorded_args
assert recorded_kwargs['cwd'] == str(BACKEND_DIR)
process_env = recorded_kwargs['env']
assert isinstance(process_env, dict)
assert str(BACKEND_DIR) in process_env.get('PYTHONPATH', '')
assert 'capture_output' not in recorded_kwargs
@@ -0,0 +1,151 @@
from types import SimpleNamespace
from pytest import MonkeyPatch
from cli.runtime.app import AppRuntimeService
from cli.runtime.app.gateway import AppInfrastructureGateway
from cli.runtime.app.support import AppSnapshotSupport
from cli.runtime.base import RuntimeEnvironmentService
REDIS_PORT = 6379
class FakeRuntimeEnvironment(RuntimeEnvironmentService):
"""
模拟运行时环境服务。
"""
@staticmethod
def get_backend_dir() -> str:
"""
返回固定后端目录。
:return: 固定目录
"""
return '/tmp/shierfeng-backend'
@staticmethod
def get_python_executable() -> str:
"""
返回固定 Python 可执行文件。
:return: Python 可执行文件路径
"""
return '/usr/bin/python3'
def test_app_snapshot_support_builds_config_snapshot() -> None:
"""
校验应用快照支持对象会构建应用配置快照。
:return: None
"""
gateway = AppInfrastructureGateway()
support = AppSnapshotSupport(gateway, FakeRuntimeEnvironment())
fake_env_module = SimpleNamespace(
AppConfig=SimpleNamespace(
app_env='dev',
app_name='shierfeng',
app_host='127.0.0.1',
app_port=8080,
app_root_path='/api',
app_reload=True,
app_workers=1,
app_disable_swagger=False,
app_disable_redoc=False,
),
DataBaseConfig=SimpleNamespace(
db_type='mysql',
db_host='127.0.0.1',
db_port=3306,
db_database='shierfeng',
),
RedisConfig=SimpleNamespace(redis_host='127.0.0.1', redis_port=REDIS_PORT),
LogConfig=SimpleNamespace(loguru_level='INFO'),
TransportCryptoConfig=SimpleNamespace(
transport_crypto_enabled=True,
transport_crypto_mode='strict',
),
)
def _fake_get_env_module() -> SimpleNamespace:
return fake_env_module
object.__setattr__(gateway, 'get_env_module', _fake_get_env_module)
payload = support.build_app_config_snapshot()
assert payload['env'] == 'dev'
assert payload['dbType'] == 'mysql'
assert payload['redisPort'] == REDIS_PORT
assert payload['transportCryptoMode'] == 'strict'
def test_app_snapshot_support_builds_env_snapshot(monkeypatch: MonkeyPatch) -> None:
"""
校验应用快照支持对象会构建环境解析快照。
:param monkeypatch: pytest monkeypatch 工具
:return: None
"""
gateway = AppInfrastructureGateway()
support = AppSnapshotSupport(gateway, FakeRuntimeEnvironment())
fake_env_module = SimpleNamespace(
AppConfig=SimpleNamespace(app_env='prod'),
)
def _fake_get_env_module() -> SimpleNamespace:
return fake_env_module
object.__setattr__(gateway, 'get_env_module', _fake_get_env_module)
monkeypatch.setenv('APP_ENV', 'test')
payload = support.build_app_env_snapshot()
assert payload == {
'cliEnv': 'test',
'configEnv': 'prod',
'appEnv': 'test',
'envFile': '.env.test',
'envFilePath': '/tmp/shierfeng-backend/.env.test',
'envFileExists': False,
'backendDir': '/tmp/shierfeng-backend',
'pythonExecutable': '/usr/bin/python3',
}
def test_app_runtime_service_builds_app_instance() -> None:
"""
校验应用运行时 facade 会通过基础设施网关构建应用实例。
:return: None
"""
gateway = AppInfrastructureGateway()
service = AppRuntimeService(
runtime_environment=FakeRuntimeEnvironment(),
infrastructure_gateway=gateway,
)
class FakeServerModule:
"""
模拟 server 模块。
"""
@staticmethod
def create_app() -> dict[str, str]:
"""
返回模拟应用实例。
:return: 模拟应用实例
"""
return {'app': 'ok'}
def _fake_get_server_module() -> FakeServerModule:
return FakeServerModule()
object.__setattr__(gateway, 'get_server_module', _fake_get_server_module)
payload = service.build_app_instance()
assert payload == {'app': 'ok'}
@@ -0,0 +1,44 @@
from pathlib import Path
from cli.core.app_builder import ProjectRuntimeLocator
from cli.runtime.base import RUNTIME_ENVIRONMENT
from cli.utils import NestedCliProjectLocator
def test_backend_project_dir_detection_uses_consistent_rule(tmp_path: Path) -> None:
"""
校验不同入口的后端目录判定规则保持一致。
:param tmp_path: pytest 临时目录
:return: None
"""
(tmp_path / 'app.py').write_text('', encoding='utf-8')
(tmp_path / 'config').mkdir()
(tmp_path / 'config' / 'env.py').write_text('', encoding='utf-8')
(tmp_path / 'cli').mkdir()
project_runtime_locator = ProjectRuntimeLocator()
nested_cli_project_locator = NestedCliProjectLocator()
assert RUNTIME_ENVIRONMENT.is_backend_project_dir(tmp_path) is True
assert project_runtime_locator.is_backend_project_dir(tmp_path) is True
assert nested_cli_project_locator.is_backend_project_dir(tmp_path) is True
def test_backend_project_dir_detection_rejects_directory_without_cli_package(tmp_path: Path) -> None:
"""
校验缺少 cli 目录时不会被误判为后端项目根目录。
:param tmp_path: pytest 临时目录
:return: None
"""
(tmp_path / 'app.py').write_text('', encoding='utf-8')
(tmp_path / 'config').mkdir()
(tmp_path / 'config' / 'env.py').write_text('', encoding='utf-8')
project_runtime_locator = ProjectRuntimeLocator()
nested_cli_project_locator = NestedCliProjectLocator()
assert RUNTIME_ENVIRONMENT.is_backend_project_dir(tmp_path) is False
assert project_runtime_locator.is_backend_project_dir(tmp_path) is False
assert nested_cli_project_locator.is_backend_project_dir(tmp_path) is False
@@ -0,0 +1,164 @@
import pytest
from cli.runtime.cache import CacheRuntimeService
from cli.runtime.cache.gateway import REDIS_TTL_KEY_NOT_FOUND, CacheInfrastructureGateway
from cli.runtime.cache.support import CacheDomainSupport
EXPECTED_MATCHED_KEY_COUNT = 2
def test_cache_domain_support_extracts_scoped_keys() -> None:
"""
校验缓存领域支持对象会提取指定缓存名前缀下的相对键名。
:return: None
"""
support = CacheDomainSupport(CacheInfrastructureGateway())
key_items = support.extract_cache_key_items(
'sys_config',
['sys_config:site_name', 'sys_config:logo', 'login_tokens:user:1'],
)
assert key_items == ['logo', 'site_name']
def test_cache_domain_support_builds_missing_cache_result() -> None:
"""
校验缓存领域支持对象会生成统一的缓存不存在结果。
:return: None
"""
support = CacheDomainSupport(CacheInfrastructureGateway())
payload = support.build_missing_cache_result('sys_config', 'site_name')
assert payload['ok'] is False
assert payload['fullCacheKey'] == 'sys_config:site_name'
assert payload['message'] == '缓存不存在:sys_config:site_name'
@pytest.mark.asyncio
async def test_cache_runtime_service_ttl_returns_missing_result() -> None:
"""
校验缓存运行时在 TTL 返回不存在标记时会输出统一缺失结果。
:return: None
"""
class FakeRedis:
"""
模拟 Redis 客户端。
"""
@staticmethod
async def ttl(full_cache_key: str) -> int:
"""
返回 Redis 键不存在标记。
:param full_cache_key: 完整缓存键名
:return: Redis TTL 特殊值
"""
assert full_cache_key == 'sys_config:site_name'
return REDIS_TTL_KEY_NOT_FOUND
@staticmethod
async def close() -> None:
"""
关闭客户端。
:return: None
"""
class FakeRedisUtil:
"""
模拟 Redis 工具类。
"""
@staticmethod
async def create_redis_pool(*, log_enabled: bool = False) -> FakeRedis:
"""
返回模拟 Redis 客户端。
:param log_enabled: 是否启用日志
:return: 模拟 Redis 客户端
"""
del log_enabled
return FakeRedis()
gateway = CacheInfrastructureGateway()
service = CacheRuntimeService(infrastructure_gateway=gateway)
def _fake_get_redis_util() -> FakeRedisUtil:
return FakeRedisUtil()
object.__setattr__(gateway, 'get_redis_util', _fake_get_redis_util)
payload = await service.get_cache_ttl('sys_config', 'site_name')
assert payload['ok'] is False
assert payload['fullCacheKey'] == 'sys_config:site_name'
@pytest.mark.asyncio
async def test_cache_runtime_service_clear_cache_dry_run_reports_scope() -> None:
"""
校验缓存运行时 dry-run 清理会返回匹配键与清理范围。
:return: None
"""
class FakeRedis:
"""
模拟 Redis 客户端。
"""
@staticmethod
async def keys(pattern: str) -> list[str]:
"""
返回匹配键列表。
:param pattern: 匹配模式
:return: 匹配结果
"""
assert pattern == 'sys_config:*'
return ['sys_config:site_name', 'sys_config:logo']
@staticmethod
async def close() -> None:
"""
关闭客户端。
:return: None
"""
class FakeRedisUtil:
"""
模拟 Redis 工具类。
"""
@staticmethod
async def create_redis_pool(*, log_enabled: bool = False) -> FakeRedis:
"""
返回模拟 Redis 客户端。
:param log_enabled: 是否启用日志
:return: 模拟 Redis 客户端
"""
del log_enabled
return FakeRedis()
gateway = CacheInfrastructureGateway()
service = CacheRuntimeService(infrastructure_gateway=gateway)
def _fake_get_redis_util() -> FakeRedisUtil:
return FakeRedisUtil()
object.__setattr__(gateway, 'get_redis_util', _fake_get_redis_util)
payload = await service.clear_cache(cache_name='sys_config', dry_run=True)
assert payload['ok'] is True
assert payload['matchedCount'] == EXPECTED_MATCHED_KEY_COUNT
assert payload['scope'] == {'mode': 'cacheName', 'cacheName': 'sys_config'}
assert payload['message'] == '缓存清理演练完成,未执行实际删除'
@@ -0,0 +1,210 @@
import pytest
from cli.exit_codes import RUNTIME_ERROR
from cli.runtime.config import ConfigRuntimeService
from cli.runtime.config.gateway import ConfigInfrastructureGateway
from cli.runtime.config.support import ConfigDomainSupport
MISSING_CONFIG_KEY = 'sys.demo.key'
def test_config_domain_support_builds_missing_config_result() -> None:
"""
校验参数配置领域支持对象会生成统一的缺失结果。
:return: None
"""
support = ConfigDomainSupport(ConfigInfrastructureGateway())
payload = support.build_missing_config_result(MISSING_CONFIG_KEY, 'cache')
assert payload == {
'ok': False,
'message': f'参数缓存不存在:{MISSING_CONFIG_KEY}',
'source': 'cache',
'exit_code': RUNTIME_ERROR,
}
def test_config_domain_support_serializes_cache_payload_with_sanitizer() -> None:
"""
校验参数配置领域支持对象会对缓存配置做脱敏序列化。
:return: None
"""
class FakeSanitizer:
"""
模拟脱敏工具。
"""
@staticmethod
def sanitize_data(payload: dict[str, str]) -> dict[str, str]:
"""
返回带标记的脱敏结果。
:param payload: 原始载荷
:return: 脱敏结果
"""
return {**payload, 'sanitized': 'yes'}
gateway = ConfigInfrastructureGateway()
support = ConfigDomainSupport(gateway)
def _fake_get_log_sanitizer() -> FakeSanitizer:
return FakeSanitizer()
object.__setattr__(gateway, 'get_log_sanitizer', _fake_get_log_sanitizer)
payload = support.serialize_cache_payload(MISSING_CONFIG_KEY, 'demo-value')
assert payload == {
'configKey': MISSING_CONFIG_KEY,
'configValue': 'demo-value',
'sanitized': 'yes',
}
def test_config_runtime_service_builds_cli_config_model_from_orm_record() -> None:
"""
校验 CLI 会将 ORM 配置记录显式映射为可序列化的配置模型。
:return: None
"""
class FakeOrmConfigRecord:
"""
模拟 ORM 配置记录。
"""
config_id = 2
config_name = '用户管理-账号初始密码'
config_key = 'sys.user.initPassword'
config_value = '123456'
config_type = 'Y'
create_by = 'admin'
create_time = None
update_by = ''
update_time = None
remark = '初始化密码 123456'
class FakeConfigModel:
"""
模拟 VO 配置模型。
"""
def __init__(self, **kwargs: object) -> None:
self.kwargs = kwargs
class FakeConfigVoModule:
"""
模拟配置 VO 模块。
"""
ConfigModel = FakeConfigModel
model = ConfigRuntimeService.build_cli_config_model(FakeConfigVoModule, FakeOrmConfigRecord())
assert isinstance(model, FakeConfigModel)
assert model.kwargs == {
'configId': 2,
'configName': '用户管理-账号初始密码',
'configKey': 'sys.user.initPassword',
'configValue': '123456',
'configType': 'Y',
'createBy': 'admin',
'createTime': None,
'updateBy': '',
'updateTime': None,
'remark': '初始化密码 123456',
}
@pytest.mark.asyncio
async def test_config_runtime_service_returns_missing_db_config_result() -> None:
"""
校验参数配置运行时在数据库中未找到配置时会返回统一结果。
:return: None
"""
class FakeSession:
"""
模拟异步会话。
"""
async def __aenter__(self) -> 'FakeSession':
return self
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc: BaseException | None,
tb: object,
) -> None:
del exc_type, exc, tb
class FakeSessionFactory:
"""
模拟异步会话工厂。
"""
def __call__(self) -> FakeSession:
return FakeSession()
class FakeConfigModel:
"""
模拟配置模型。
"""
def __init__(self, **kwargs: str) -> None:
self.kwargs = kwargs
class FakeConfigVoModule:
"""
模拟配置 VO 模块。
"""
ConfigModel = FakeConfigModel
class FakeConfigDao:
"""
模拟配置 DAO。
"""
@staticmethod
async def get_config_detail_by_info(session: FakeSession, config_model: FakeConfigModel) -> None:
"""
返回空结果。
:param session: 数据库会话
:param config_model: 配置模型
:return: None
"""
assert isinstance(session, FakeSession)
assert config_model.kwargs == {'configKey': MISSING_CONFIG_KEY}
gateway = ConfigInfrastructureGateway()
service = ConfigRuntimeService(infrastructure_gateway=gateway)
def _fake_get_async_session_local() -> FakeSessionFactory:
return FakeSessionFactory()
def _fake_get_config_dao() -> FakeConfigDao:
return FakeConfigDao()
def _fake_get_config_vo_module() -> FakeConfigVoModule:
return FakeConfigVoModule()
object.__setattr__(gateway, 'get_async_session_local', _fake_get_async_session_local)
object.__setattr__(gateway, 'get_config_dao', _fake_get_config_dao)
object.__setattr__(gateway, 'get_config_vo_module', _fake_get_config_vo_module)
payload = await service.get_config(MISSING_CONFIG_KEY, source='db')
assert payload == {
'ok': False,
'message': f'参数配置不存在:{MISSING_CONFIG_KEY}',
'source': 'db',
'exit_code': RUNTIME_ERROR,
}
@@ -0,0 +1,149 @@
import json
from types import SimpleNamespace
from cli.exit_codes import ARGUMENT_ERROR
from cli.runtime.crypto import CryptoRuntimeService
from cli.runtime.crypto.gateway import CryptoInfrastructureGateway
from cli.runtime.crypto.support import CryptoDomainSupport
INVALID_RSA_KEY_SIZE = 1024
VALID_RSA_KEY_SIZE = 2048
def test_crypto_domain_support_rejects_invalid_rsa_key_size() -> None:
"""
校验传输加密领域支持对象会拒绝非法 RSA 密钥长度。
:return: None
"""
support = CryptoDomainSupport(CryptoInfrastructureGateway())
try:
support.validate_rsa_key_size(INVALID_RSA_KEY_SIZE)
except ValueError as exc:
assert 'RSA 密钥长度必须大于等于 2048' in str(exc)
else:
raise AssertionError('expected ValueError for invalid RSA key size')
def test_crypto_domain_support_rejects_non_list_legacy_key_pairs() -> None:
"""
校验传输加密领域支持对象会拒绝非数组格式的历史密钥配置。
:return: None
"""
gateway = CryptoInfrastructureGateway()
support = CryptoDomainSupport(gateway)
def _fake_transport_crypto_config() -> SimpleNamespace:
return SimpleNamespace(transport_crypto_legacy_key_pairs='{"kid": "legacy"}')
object.__setattr__(gateway, 'get_transport_crypto_config', _fake_transport_crypto_config)
try:
support.load_existing_legacy_key_pairs()
except ValueError as exc:
assert 'TRANSPORT_CRYPTO_LEGACY_KEY_PAIRS 必须为 JSON 数组' in str(exc)
else:
raise AssertionError('expected ValueError for invalid legacy key pairs payload')
def test_crypto_runtime_service_keygen_returns_argument_error_for_invalid_size() -> None:
"""
校验传输加密运行时在非法密钥长度下返回参数错误结果。
:return: None
"""
service = CryptoRuntimeService()
payload = service.generate_crypto_key_pair('kid-20260511', INVALID_RSA_KEY_SIZE)
assert payload['ok'] is False
assert payload['exit_code'] == ARGUMENT_ERROR
assert payload['message'] == '生成传输加密密钥失败'
def test_crypto_runtime_service_rotation_rejects_same_kid() -> None:
"""
校验传输加密运行时会拒绝与当前版本相同的新密钥版本。
:return: None
"""
gateway = CryptoInfrastructureGateway()
service = CryptoRuntimeService(infrastructure_gateway=gateway)
provider = SimpleNamespace(
validate_runtime_configuration=lambda: None,
get_current_key_pair=lambda: SimpleNamespace(
kid='kid-current',
private_key_pem='PRIVATE-CURRENT',
public_key_pem='PUBLIC-CURRENT',
),
)
object.__setattr__(gateway, 'get_transport_key_provider', lambda: provider)
payload = service.build_rotation_payload('kid-current', VALID_RSA_KEY_SIZE)
assert payload['ok'] is False
assert payload['exit_code'] == ARGUMENT_ERROR
assert payload['message'] == '生成密钥轮换方案失败'
assert payload['error'] == '新密钥版本不能与当前版本相同'
def test_crypto_runtime_service_rotation_builds_merged_payload() -> None:
"""
校验传输加密运行时会生成包含历史密钥合并结果的轮换辅助数据。
:return: None
"""
gateway = CryptoInfrastructureGateway()
domain_support = CryptoDomainSupport(gateway)
service = CryptoRuntimeService(
infrastructure_gateway=gateway,
domain_support=domain_support,
)
provider = SimpleNamespace(
validate_runtime_configuration=lambda: None,
get_current_key_pair=lambda: SimpleNamespace(
kid='kid-current',
private_key_pem='PRIVATE-CURRENT',
public_key_pem='PUBLIC-CURRENT',
),
)
object.__setattr__(gateway, 'get_transport_key_provider', lambda: provider)
object.__setattr__(
domain_support,
'generate_rsa_key_pair',
lambda key_size: ('PRIVATE-NEXT', 'PUBLIC-NEXT'),
)
object.__setattr__(
domain_support,
'load_existing_legacy_key_pairs',
lambda: [
{'kid': 'kid-legacy', 'privateKey': 'PRIVATE-LEGACY', 'publicKey': 'PUBLIC-LEGACY'},
{'kid': 'kid-next', 'privateKey': 'STALE-PRIVATE', 'publicKey': 'STALE-PUBLIC'},
],
)
payload = service.build_rotation_payload('kid-next', VALID_RSA_KEY_SIZE)
assert payload['ok'] is True
assert payload['currentKid'] == 'kid-current'
assert payload['nextKid'] == 'kid-next'
assert payload['nextKeyPair']['privateKey'] == 'PRIVATE-NEXT'
assert payload['nextKeyPair']['publicKey'] == 'PUBLIC-NEXT'
assert payload['envPatch']['TRANSPORT_CRYPTO_KID'] == 'kid-next'
assert payload['envPatch']['TRANSPORT_CRYPTO_PRIVATE_KEY'] == 'PRIVATE-NEXT'
assert payload['envPatch']['TRANSPORT_CRYPTO_PUBLIC_KEY'] == 'PUBLIC-NEXT'
legacy_key_pairs = payload['legacyKeyPairs']
legacy_by_kid = {item['kid']: item for item in legacy_key_pairs}
assert 'kid-next' not in legacy_by_kid
assert legacy_by_kid['kid-current']['privateKey'] == 'PRIVATE-CURRENT'
assert legacy_by_kid['kid-legacy']['publicKey'] == 'PUBLIC-LEGACY'
serialized_legacy = json.loads(payload['envPatch']['TRANSPORT_CRYPTO_LEGACY_KEY_PAIRS'])
serialized_by_kid = {item['kid']: item for item in serialized_legacy}
assert serialized_by_kid['kid-current']['publicKey'] == 'PUBLIC-CURRENT'
@@ -0,0 +1,144 @@
from pathlib import Path
import pytest
from cli.runtime.base import RuntimeEnvironmentService
from cli.runtime.db import DatabaseRuntimeService
from cli.runtime.db.gateway import DatabaseInfrastructureGateway
from cli.runtime.db.support import DatabaseRevisionSupport
class FakeRuntimeEnvironment(RuntimeEnvironmentService):
"""
模拟运行时环境服务。
"""
@staticmethod
def get_backend_dir() -> str:
"""
返回固定后端目录。
:return: 固定目录
"""
return '/tmp/shierfeng-backend'
def test_database_revision_support_serializes_revision() -> None:
"""
校验数据库迁移版本支持对象会序列化 Alembic revision。
:return: None
"""
class FakeRevision:
"""
模拟 Alembic revision 对象。
"""
revision = '202605110001'
down_revision = ('202605100001',)
branch_labels = {'main'}
dependencies = None
doc = ' demo revision '
path = Path('/tmp/revision.py')
support = DatabaseRevisionSupport(DatabaseInfrastructureGateway(), FakeRuntimeEnvironment())
payload = support.serialize_revision(FakeRevision())
assert payload == {
'revision': '202605110001',
'downRevisions': ['202605100001'],
'branchLabels': ['main'],
'dependsOn': [],
'doc': 'demo revision',
'path': '/tmp/revision.py',
}
def test_database_runtime_service_upgrade_dry_run_returns_command_payload() -> None:
"""
校验数据库运行时 dry-run 升级会返回命令预览结果。
:return: None
"""
service = DatabaseRuntimeService(runtime_environment=FakeRuntimeEnvironment())
payload = service.upgrade_database('head', dry_run=True)
assert payload['ok'] is True
assert payload['dryRun'] is True
assert payload['cwd'] == '/tmp/shierfeng-backend'
assert payload['command'] == ['alembic', '-c', '/tmp/shierfeng-backend/alembic.ini', 'upgrade', 'head']
@pytest.mark.asyncio
async def test_database_runtime_service_ping_database_returns_failure() -> None:
"""
校验数据库运行时在连接异常时会返回失败结果。
:return: None
"""
class FakeConnection:
"""
模拟数据库连接对象。
"""
async def __aenter__(self) -> 'FakeConnection':
raise RuntimeError('db boom')
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc: BaseException | None,
tb: object,
) -> None:
del exc_type, exc, tb
class FakeEngine:
"""
模拟数据库引擎。
"""
@staticmethod
def connect() -> FakeConnection:
"""
返回模拟连接对象。
:return: 模拟连接对象
"""
return FakeConnection()
@staticmethod
async def dispose() -> None:
"""
释放引擎资源。
:return: None
"""
gateway = DatabaseInfrastructureGateway()
service = DatabaseRuntimeService(
runtime_environment=FakeRuntimeEnvironment(),
infrastructure_gateway=gateway,
)
def _fake_get_async_db_engine_factory() -> object:
def _factory(*, echo: bool = False) -> FakeEngine:
del echo
return FakeEngine()
return _factory
def _fake_get_sqlalchemy_text() -> object:
return lambda sql: sql
object.__setattr__(gateway, 'get_async_db_engine_factory', _fake_get_async_db_engine_factory)
object.__setattr__(gateway, 'get_sqlalchemy_text', _fake_get_sqlalchemy_text)
payload = await service.ping_database()
assert payload['ok'] is False
assert payload['message'] == '数据库连接失败'
assert 'db boom' in payload['error']
@@ -0,0 +1,126 @@
from cli.exit_codes import DEPENDENCY_ERROR
from cli.runtime.base import RuntimeEnvironmentService
from cli.runtime.dev import DevelopmentRuntimeService
from cli.runtime.dev.gateway import DevelopmentProcessGateway
from cli.runtime.dev.support import DevelopmentCommandSupport, DevelopmentToolingSupport
class FakeRuntimeEnvironment(RuntimeEnvironmentService):
"""
模拟运行时环境服务。
"""
@staticmethod
def get_backend_dir() -> str:
"""
返回固定后端目录。
:return: 固定目录
"""
return '/tmp/shierfeng-backend'
@staticmethod
def get_python_executable() -> str:
"""
返回固定 Python 可执行文件。
:return: Python 可执行文件路径
"""
return '/usr/bin/python3'
def test_development_runtime_service_returns_dependency_error_when_pytest_missing() -> None:
"""
校验开发运行时在 pytest 缺失时会返回依赖错误结果。
:return: None
"""
class FakeToolingSupport(DevelopmentToolingSupport):
"""
模拟开发工具支持对象。
"""
@staticmethod
def is_pytest_available() -> bool:
"""
返回 pytest 不可用状态。
:return: False
"""
return False
service = DevelopmentRuntimeService(
runtime_environment=FakeRuntimeEnvironment(),
tooling_support=FakeToolingSupport(),
)
payload = service.run_tests(['tests/cli'])
assert payload == {
'ok': False,
'message': 'pytest 未安装,无法执行 dev test',
'hint': '请在当前环境安装 pytest 后重试',
'exit_code': DEPENDENCY_ERROR,
}
def test_development_runtime_service_run_lint_uses_command_support() -> None:
"""
校验开发运行时会通过命令支持对象串联 format/check 执行流程。
:return: None
"""
class FakeCommandSupport(DevelopmentCommandSupport):
"""
模拟开发命令支持对象。
"""
def __init__(self) -> None:
self.recorded_commands: list[list[str]] = []
def build_format_command(self, normalized_targets: list[str], *, check_only: bool) -> list[str]:
assert normalized_targets == ['tests/cli']
assert check_only is True
return ['format-command']
def build_check_command(
self,
normalized_targets: list[str],
*,
check_only: bool,
fix: bool,
unsafe_fixes: bool,
) -> list[str]:
assert normalized_targets == ['tests/cli']
assert check_only is True
assert fix is False
assert unsafe_fixes is False
return ['check-command']
class FakeProcessGateway(DevelopmentProcessGateway):
"""
模拟开发子进程执行网关。
"""
def __init__(self) -> None:
self.recorded_commands: list[list[str]] = []
def run_command(self, command: list[str]) -> dict[str, object]:
self.recorded_commands.append(command)
return {'ok': True, 'command': command, 'returnCode': 0}
command_support = FakeCommandSupport()
process_gateway = FakeProcessGateway()
service = DevelopmentRuntimeService(
runtime_environment=FakeRuntimeEnvironment(),
command_support=command_support,
process_gateway=process_gateway,
)
payload = service.run_lint(['tests/cli'], check_only=True)
assert payload['ok'] is True
assert payload['targets'] == ['tests/cli']
assert process_gateway.recorded_commands == [['format-command'], ['check-command']]
@@ -0,0 +1,98 @@
from pathlib import Path
from types import SimpleNamespace
import pytest
from cli.exit_codes import ARGUMENT_ERROR
from cli.runtime.gen import GenRuntimeService
from cli.runtime.gen.gateway import GenInfrastructureGateway
from cli.runtime.gen.support import GenDomainSupport
REDIS_UNUSED = 'unused'
def test_gen_domain_support_resolves_sql_text_from_argument() -> None:
"""
校验代码生成领域支持对象会优先使用直接传入的 SQL 文本。
:return: None
"""
support = GenDomainSupport(GenInfrastructureGateway())
payload = support.resolve_sql_text(' CREATE TABLE demo(id bigint); ', '')
assert payload == 'CREATE TABLE demo(id bigint);'
def test_gen_domain_support_resolve_sql_text_rejects_invalid_inputs(tmp_path: Path) -> None:
"""
校验代码生成领域支持对象会拒绝无效 SQL 输入组合。
:param tmp_path: pytest 临时目录
:return: None
"""
support = GenDomainSupport(GenInfrastructureGateway())
sql_file = tmp_path / 'demo.sql'
sql_file.write_text('select 1', encoding='utf-8')
try:
support.resolve_sql_text('select 1', str(sql_file))
except ValueError as exc:
assert '必须且只能传入 --sql 或 --sql-file 其中一种方式' in str(exc)
else:
raise AssertionError('expected ValueError for conflicting SQL inputs')
def test_gen_domain_support_writes_export_zip(tmp_path: Path) -> None:
"""
校验代码生成领域支持对象会写出导出压缩包。
:param tmp_path: pytest 临时目录
:return: None
"""
support = GenDomainSupport(GenInfrastructureGateway())
target_file = tmp_path / 'exports' / 'gen.zip'
output_path = support.write_export_zip(str(target_file), b'zip-content')
assert output_path == str(target_file.resolve())
assert target_file.read_bytes() == b'zip-content'
@pytest.mark.asyncio
async def test_gen_runtime_service_export_code_dry_run_returns_preview_payload() -> None:
"""
校验代码生成运行时在 dry-run 导出时会返回结构化预览结果。
:return: None
"""
gateway = GenInfrastructureGateway()
service = GenRuntimeService(infrastructure_gateway=gateway)
def _fake_get_gen_config() -> SimpleNamespace:
return SimpleNamespace(allow_overwrite=True, GEN_PATH='/tmp/gen')
object.__setattr__(gateway, 'get_gen_config', _fake_get_gen_config)
payload = await service.export_code(['sys_user', 'sys_role'], mode='zip', dry_run=True)
assert payload['ok'] is True
assert payload['dryRun'] is True
assert payload['tableNames'] == ['sys_user', 'sys_role']
assert payload['outputFile'].endswith('gen_code_sys_user_sys_role.zip')
@pytest.mark.asyncio
async def test_gen_runtime_service_import_tables_requires_at_least_one_table() -> None:
"""
校验代码生成运行时在未传入表名时会返回参数错误。
:return: None
"""
service = GenRuntimeService()
payload = await service.import_tables([])
assert payload['ok'] is False
assert payload['exit_code'] == ARGUMENT_ERROR
assert payload['message'] == '至少需要传入一个表名'
@@ -0,0 +1,174 @@
import pytest
from cli.runtime.job import JobRuntimeService
from cli.runtime.job.gateway import JobInfrastructureGateway
from cli.runtime.job.support import JobDomainSupport, JobSchedulerSupport
MISSING_JOB_ID = 99
def test_job_domain_support_builds_operation_metadata() -> None:
"""
校验任务领域支持对象会返回统一的任务操作元数据。
:return: None
"""
support = JobDomainSupport(JobInfrastructureGateway())
payload = support.build_job_operation_metadata('pause')
assert payload == {
'operationLabel': '暂停任务',
'successMessage': '定时任务已暂停',
}
def test_job_domain_support_serializes_model_dump_items() -> None:
"""
校验任务领域支持对象会序列化带 `model_dump` 的任务项。
:return: None
"""
class FakeJobModel:
"""
模拟任务模型。
"""
@staticmethod
def model_dump(*, by_alias: bool = False, exclude_none: bool = False) -> dict[str, object]:
"""
返回模拟序列化结果。
:param by_alias: 是否按别名输出
:param exclude_none: 是否排除空值
:return: 模拟序列化结果
"""
assert by_alias is True
assert exclude_none is True
return {'jobId': 1, 'jobName': 'sync-job'}
support = JobDomainSupport(JobInfrastructureGateway())
payload = support.serialize_job_item(FakeJobModel())
assert payload == {'jobId': 1, 'jobName': 'sync-job'}
@pytest.mark.asyncio
async def test_job_scheduler_support_closes_scheduler_and_redis() -> None:
"""
校验任务调度支持对象会关闭调度器和 Redis 资源。
:return: None
"""
close_events: list[str] = []
class FakeRedis:
"""
模拟 Redis 客户端。
"""
@staticmethod
async def close() -> None:
"""
记录 Redis 关闭事件。
:return: None
"""
close_events.append('redis')
class FakeSchedulerUtil:
"""
模拟调度器工具。
"""
@staticmethod
async def close_system_scheduler() -> None:
"""
记录调度器关闭事件。
:return: None
"""
close_events.append('scheduler')
gateway = JobInfrastructureGateway()
support = JobSchedulerSupport(gateway)
def _fake_get_scheduler_util() -> FakeSchedulerUtil:
return FakeSchedulerUtil()
object.__setattr__(gateway, 'get_scheduler_util', _fake_get_scheduler_util)
await support.close_scheduler_context(FakeRedis())
assert close_events == ['scheduler', 'redis']
@pytest.mark.asyncio
async def test_job_runtime_service_reports_missing_job_detail() -> None:
"""
校验任务运行时在任务详情不存在时会返回统一结果。
:return: None
"""
class FakeSession:
"""
模拟异步会话。
"""
async def __aenter__(self) -> 'FakeSession':
return self
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc: BaseException | None,
tb: object,
) -> None:
del exc_type, exc, tb
class FakeSessionFactory:
"""
模拟异步会话工厂。
"""
def __call__(self) -> FakeSession:
return FakeSession()
class FakeJobService:
"""
模拟任务服务。
"""
@staticmethod
async def job_detail_services(session: FakeSession, job_id: int) -> dict[str, object]:
"""
返回不存在任务的空结果。
:param session: 数据库会话
:param job_id: 任务 ID
:return: 空结果
"""
assert isinstance(session, FakeSession)
assert job_id == MISSING_JOB_ID
return {}
gateway = JobInfrastructureGateway()
service = JobRuntimeService(infrastructure_gateway=gateway)
def _fake_get_async_session_local() -> FakeSessionFactory:
return FakeSessionFactory()
def _fake_get_job_service() -> FakeJobService:
return FakeJobService()
object.__setattr__(gateway, 'get_async_session_local', _fake_get_async_session_local)
object.__setattr__(gateway, 'get_job_service', _fake_get_job_service)
payload = await service.get_job_detail(MISSING_JOB_ID)
assert payload['ok'] is False
assert payload['jobId'] == MISSING_JOB_ID
assert payload['message'] == f'定时任务不存在:{MISSING_JOB_ID}'
@@ -0,0 +1,167 @@
import socket
from types import SimpleNamespace
import pytest
from cli.runtime.ops import OperationsRuntimeService
from cli.runtime.ops.gateway import OperationsInfrastructureGateway
from cli.runtime.ops.support import OperationsDependencyInspector, OperationsServerInfoSupport
def test_operations_dependency_inspector_reports_missing_required(monkeypatch: pytest.MonkeyPatch) -> None:
"""
校验运维依赖检查器会报告缺失的必需依赖。
:param monkeypatch: pytest monkeypatch 工具
:return: None
"""
inspector = OperationsDependencyInspector()
monkeypatch.setattr(
inspector,
'read_package_version',
lambda distribution_name: None if distribution_name == 'fastapi' else '1.0.0',
)
payload = inspector.inspect()
assert payload['ok'] is False
assert payload['missingRequired'] == ['fastapi']
assert payload['packages']['python']['installed'] is True
def test_operations_server_info_support_resolves_non_loopback_ip(monkeypatch: pytest.MonkeyPatch) -> None:
"""
校验服务器信息支持对象会在主机名解析失败时回退到网卡地址。
:param monkeypatch: pytest monkeypatch 工具
:return: None
"""
class FakePsutil:
"""
模拟 psutil 模块。
"""
@staticmethod
def net_if_addrs() -> dict[str, list[SimpleNamespace]]:
"""
返回模拟网卡地址。
:return: 模拟网卡地址映射
"""
return {
'en0': [
SimpleNamespace(family=socket.AF_INET, address='127.0.0.1'),
SimpleNamespace(family=socket.AF_INET, address='192.168.1.8'),
]
}
gateway = OperationsInfrastructureGateway()
support = OperationsServerInfoSupport(gateway)
def _fake_get_psutil_module() -> FakePsutil:
return FakePsutil()
def _fake_gethostbyname(hostname: str) -> str:
del hostname
raise OSError('boom')
monkeypatch.setattr(gateway, 'get_psutil_module', _fake_get_psutil_module)
monkeypatch.setattr(socket, 'gethostbyname', _fake_gethostbyname)
resolved_ip = support.resolve_server_ip('demo-host')
assert resolved_ip == '192.168.1.8'
@pytest.mark.asyncio
async def test_operations_runtime_service_sync_jobs_closes_resources_on_success() -> None:
"""
校验运维运行时在同步调度成功后会关闭调度器和 Redis 资源。
:return: None
"""
close_events: list[str] = []
class FakeRedis:
"""
模拟 Redis 客户端。
"""
@staticmethod
async def close() -> None:
"""
记录关闭事件。
:return: None
"""
close_events.append('redis')
class FakeRedisUtil:
"""
模拟 Redis 工具。
"""
@staticmethod
async def create_redis_pool(*, log_enabled: bool = False) -> FakeRedis:
"""
返回模拟 Redis 客户端。
:param log_enabled: 是否启用日志
:return: 模拟 Redis 客户端
"""
del log_enabled
return FakeRedis()
class FakeSchedulerUtil:
"""
模拟调度器工具。
"""
_is_leader = True
@staticmethod
async def init_system_scheduler(redis: FakeRedis) -> None:
"""
初始化调度器。
:param redis: Redis 客户端
:return: None
"""
assert isinstance(redis, FakeRedis)
@staticmethod
async def request_scheduler_sync() -> None:
"""
发送调度同步请求。
:return: None
"""
@staticmethod
async def close_system_scheduler() -> None:
"""
记录调度器关闭事件。
:return: None
"""
close_events.append('scheduler')
gateway = OperationsInfrastructureGateway()
service = OperationsRuntimeService(infrastructure_gateway=gateway)
def _fake_get_redis_util() -> FakeRedisUtil:
return FakeRedisUtil()
def _fake_get_scheduler_util() -> FakeSchedulerUtil:
return FakeSchedulerUtil()
object.__setattr__(gateway, 'get_redis_util', _fake_get_redis_util)
object.__setattr__(gateway, 'get_scheduler_util', _fake_get_scheduler_util)
payload = await service.sync_jobs()
assert payload['ok'] is True
assert payload['schedulerSyncRequested'] is True
assert payload['isLeader'] is True
assert close_events == ['scheduler', 'redis']
@@ -0,0 +1,579 @@
import importlib
import sys
from pathlib import Path
from types import SimpleNamespace
from pytest import MonkeyPatch
BACKEND_DIR = Path(__file__).resolve().parents[4]
sys.path.insert(0, str(BACKEND_DIR))
sys.modules.pop('cli.tui.capabilities', None)
sys.modules.pop('cli.tui.actions', None)
sys.modules.pop('cli.wizard.flows.gen_import', None)
sys.modules.pop('cli', None)
capabilities_module = importlib.import_module('cli.tui.capabilities')
actions_module = importlib.import_module('cli.tui.actions')
action_assembly_module = importlib.import_module('cli.tui.actions.assembly')
action_bootstrap_module = importlib.import_module('cli.tui.actions.bootstrap')
action_execution_module = importlib.import_module('cli.tui.actions.execution')
adapters_module = importlib.import_module('cli.tui.adapters')
def test_capabilities_registry_describes_supported_views() -> None:
"""
校验 TUI 能力注册表会集中描述页面支持的动作能力。
:return: None
"""
cache_capabilities = capabilities_module.TUI_CAPABILITY_REGISTRY.get_browser_capabilities('cache')
gen_capabilities = capabilities_module.TUI_CAPABILITY_REGISTRY.get_browser_capabilities('gen')
app_capabilities = capabilities_module.TUI_CAPABILITY_REGISTRY.get_detail_capabilities('app')
database_capabilities = capabilities_module.TUI_CAPABILITY_REGISTRY.get_detail_capabilities('database')
ops_capabilities = capabilities_module.TUI_CAPABILITY_REGISTRY.get_detail_capabilities('ops')
crypto_capabilities = capabilities_module.TUI_CAPABILITY_REGISTRY.get_detail_capabilities('crypto')
assert [capability.slot for capability in cache_capabilities] == ['global', 'utility']
assert [capability.kind for capability in cache_capabilities] == ['wizard_entry', 'low_risk_action']
assert [capability.hint_label for capability in cache_capabilities] == ['清理向导', '执行缓存预热']
assert [capability.slot for capability in gen_capabilities] == ['primary', 'secondary', 'global', 'utility']
assert [capability.kind for capability in gen_capabilities] == [
'wizard_entry',
'wizard_entry',
'preview',
'low_risk_action',
]
assert [capability.hint_label for capability in gen_capabilities] == [
'导出向导',
'导入向导',
'导出预演',
'同步表结构',
]
assert [capability.slot for capability in app_capabilities] == ['primary', 'global', 'utility']
assert [capability.kind for capability in app_capabilities] == ['wizard_entry', 'wizard_entry', 'command_hint']
assert [capability.hint_label for capability in app_capabilities] == ['直接启动', '打开启动向导', '安装补全']
assert [capability.slot for capability in database_capabilities] == ['global', 'utility']
assert [capability.kind for capability in database_capabilities] == ['wizard_entry', 'preview']
assert [capability.hint_label for capability in database_capabilities] == ['打开升级向导', '初始化预演']
assert [capability.slot for capability in ops_capabilities] == ['primary', 'secondary', 'global']
assert [capability.kind for capability in ops_capabilities] == [
'low_risk_action',
'low_risk_action',
'wizard_entry',
]
assert [capability.hint_label for capability in ops_capabilities] == [
'数据库探活',
'Redis 探活',
'打开生产巡检向导',
]
assert [capability.slot for capability in crypto_capabilities] == ['primary', 'global']
assert [capability.kind for capability in crypto_capabilities] == ['wizard_entry', 'preview']
assert [capability.hint_label for capability in crypto_capabilities] == ['密钥生成', '执行轮换预演']
def test_action_registry_resolves_browser_action_for_job_record() -> None:
"""
校验任务页会解析出执行一次、暂停/恢复和同步动作。
:return: None
"""
record = adapters_module.BrowserRecordSnapshot(
key='job:101',
title='同步任务',
status='warn',
summary='暂停 · Cron 0/30 * * * * ?',
metadata_lines=[],
detail_sections=[],
)
primary_action = actions_module.TUI_ACTION_REGISTRY.resolve_browser_action(
view_key='jobs',
slot='primary',
record=record,
env='dev',
)
secondary_action = actions_module.TUI_ACTION_REGISTRY.resolve_browser_action(
view_key='jobs',
slot='secondary',
record=record,
env='dev',
)
global_action = actions_module.TUI_ACTION_REGISTRY.resolve_browser_action(
view_key='jobs',
slot='global',
record=record,
env='dev',
)
assert primary_action is not None
assert primary_action.command_args == ('job', 'run-once', '101')
assert secondary_action is not None
assert secondary_action.command_args == ('job', 'resume', '101')
assert global_action is not None
assert global_action.command_args == ('job', 'sync')
def test_action_registry_resolves_browser_action_for_cache_and_gen_entries() -> None:
"""
校验缓存页和代码生成页会解析出外部向导动作。
:return: None
"""
cache_record = adapters_module.BrowserRecordSnapshot(
key='cache:sys_config',
title='sys_config',
status='ok',
summary='系统参数缓存',
metadata_lines=[],
detail_sections=[],
)
gen_record = adapters_module.BrowserRecordSnapshot(
key='gen:201',
title='sys_user',
status='ok',
summary='生成类 SysUser · 模块 system',
metadata_lines=[],
detail_sections=[],
)
cache_global_action = actions_module.TUI_ACTION_REGISTRY.resolve_browser_action(
view_key='cache',
slot='global',
record=cache_record,
env='dev',
)
cache_utility_action = actions_module.TUI_ACTION_REGISTRY.resolve_browser_action(
view_key='cache',
slot='utility',
record=cache_record,
env='dev',
)
gen_primary_action = actions_module.TUI_ACTION_REGISTRY.resolve_browser_action(
view_key='gen',
slot='primary',
record=gen_record,
env='dev',
)
gen_secondary_action = actions_module.TUI_ACTION_REGISTRY.resolve_browser_action(
view_key='gen',
slot='secondary',
record=gen_record,
env='dev',
)
gen_utility_action = actions_module.TUI_ACTION_REGISTRY.resolve_browser_action(
view_key='gen',
slot='utility',
record=gen_record,
env='dev',
)
gen_global_action = actions_module.TUI_ACTION_REGISTRY.resolve_browser_action(
view_key='gen',
slot='global',
record=gen_record,
env='dev',
)
assert cache_global_action is not None
assert cache_global_action.execution_mode == 'external'
assert cache_global_action.command_args == (
'wizard',
'cache-clear',
'--output=text',
'--default-env=dev',
'--default-mode=cache-name',
'--default-cache-name=sys_config',
'--default-dry-run',
)
assert cache_utility_action is not None
assert cache_utility_action.execution_mode == 'nested_json'
assert gen_primary_action is not None
assert gen_primary_action.execution_mode == 'external'
assert gen_primary_action.command_args == (
'wizard',
'gen-export',
'--output=text',
'--default-env=dev',
'--default-table-names=sys_user',
'--default-mode=zip',
'--default-dry-run',
)
assert gen_primary_action.refresh_view is False
assert gen_secondary_action is not None
assert gen_secondary_action.execution_mode == 'external'
assert gen_secondary_action.command_args == (
'wizard',
'gen-import',
'--output=text',
'--default-env=dev',
'--default-table-names=sys_user',
'--default-dry-run',
)
assert gen_secondary_action.refresh_view is False
assert gen_global_action is not None
assert gen_global_action.execution_mode == 'nested_json'
assert gen_global_action.command_args == ('gen', 'export', 'sys_user', '--dry-run', '--mode=zip')
assert gen_utility_action is not None
assert gen_utility_action.execution_mode == 'nested_json'
assert gen_utility_action.command_args == ('gen', 'sync-db', 'sys_user')
def test_action_template_factories_build_expected_job_and_gen_templates() -> None:
"""
校验领域动作模板工厂会生成符合预期的命令参数。
:return: None
"""
job_record = adapters_module.BrowserRecordSnapshot(
key='job:88',
title='通知任务',
status='ok',
summary='运行中 · Cron 0 0/5 * * * ?',
metadata_lines=[],
detail_sections=[],
)
gen_record = adapters_module.BrowserRecordSnapshot(
key='gen:301',
title='sys_notice',
status='ok',
summary='生成类 SysNotice · 模块 system',
metadata_lines=[],
detail_sections=[],
)
run_once_template = action_bootstrap_module._JOB_ACTION_TEMPLATE_FACTORY.create_run_once_template()
toggle_template = action_bootstrap_module._JOB_ACTION_TEMPLATE_FACTORY.create_toggle_template()
export_template = action_bootstrap_module._GEN_ACTION_TEMPLATE_FACTORY.create_export_wizard_template()
assert run_once_template.command_builder(job_record, 'dev') == ('job', 'run-once', '88')
assert toggle_template.command_builder(job_record, 'dev') == ('job', 'pause', '88')
assert toggle_template.label_builder is not None
assert toggle_template.label_builder(job_record, 'dev') == '暂停任务'
assert export_template.command_builder(gen_record, 'dev') == (
'wizard',
'gen-export',
'--output=text',
'--default-env=dev',
'--default-table-names=sys_notice',
'--default-mode=zip',
'--default-dry-run',
)
def test_action_registry_builder_assembles_expected_slots() -> None:
"""
校验动作注册表构建器会装配浏览页与详情页动作槽位。
:return: None
"""
registry = action_assembly_module.TuiActionRegistryBuilder(
jobs=action_bootstrap_module._JOB_ACTION_TEMPLATE_FACTORY,
cache=action_bootstrap_module._CACHE_ACTION_TEMPLATE_FACTORY,
gen=action_bootstrap_module._GEN_ACTION_TEMPLATE_FACTORY,
static=action_bootstrap_module._STATIC_ACTION_TEMPLATE_FACTORY,
spec_factory=action_bootstrap_module._ACTION_SPEC_FACTORY,
).build()
assert sorted(registry.browser_resolvers) == ['cache', 'configs', 'gen', 'jobs']
assert sorted(registry.detail_resolvers) == ['app', 'crypto', 'database', 'ops']
assert sorted(registry.browser_resolvers['gen'].slot_templates) == ['global', 'primary', 'secondary', 'utility']
assert sorted(registry.detail_resolvers['ops'].slot_templates) == ['global', 'primary', 'secondary']
def test_action_registry_resolves_detail_action_for_database_and_ops_wizard_entries() -> None:
"""
校验详情页会解析出数据库升级和生产巡检向导动作。
:return: None
"""
database_action = actions_module.TUI_ACTION_REGISTRY.resolve_detail_action(
view_key='database',
slot='global',
env='dev',
)
database_utility_action = actions_module.TUI_ACTION_REGISTRY.resolve_detail_action(
view_key='database',
slot='utility',
env='dev',
)
app_action = actions_module.TUI_ACTION_REGISTRY.resolve_detail_action(
view_key='app',
slot='global',
env='dev',
)
app_primary_action = actions_module.TUI_ACTION_REGISTRY.resolve_detail_action(
view_key='app',
slot='primary',
env='dev',
)
app_utility_action = actions_module.TUI_ACTION_REGISTRY.resolve_detail_action(
view_key='app',
slot='utility',
env='dev',
)
ops_primary_action = actions_module.TUI_ACTION_REGISTRY.resolve_detail_action(
view_key='ops',
slot='primary',
env='dev',
)
ops_secondary_action = actions_module.TUI_ACTION_REGISTRY.resolve_detail_action(
view_key='ops',
slot='secondary',
env='dev',
)
ops_action = actions_module.TUI_ACTION_REGISTRY.resolve_detail_action(
view_key='ops',
slot='global',
env='dev',
)
crypto_action = actions_module.TUI_ACTION_REGISTRY.resolve_detail_action(
view_key='crypto',
slot='global',
env='dev',
)
crypto_primary_action = actions_module.TUI_ACTION_REGISTRY.resolve_detail_action(
view_key='crypto',
slot='primary',
env='dev',
)
unknown_action = actions_module.TUI_ACTION_REGISTRY.resolve_detail_action(
view_key='dashboard',
slot='global',
env='dev',
)
assert app_primary_action is not None
assert app_primary_action.execution_mode == 'external'
assert app_primary_action.command_args == ('app', 'run', '--env=dev')
assert app_primary_action.refresh_view is False
assert app_action is not None
assert app_action.execution_mode == 'external'
assert app_action.command_args == ('wizard', 'app-run')
assert app_action.refresh_view is False
assert app_utility_action is not None
assert app_utility_action.execution_mode == 'external'
assert app_utility_action.command_args == ('completion', 'install', '--activate')
assert app_utility_action.refresh_view is False
assert database_action is not None
assert database_action.execution_mode == 'external'
assert database_action.command_args == (
'wizard',
'db-upgrade',
'--output=text',
'--default-env=dev',
'--default-revision=head',
'--default-dry-run',
)
assert database_utility_action is not None
assert database_utility_action.execution_mode == 'nested_json'
assert database_utility_action.command_args == ('db', 'init', '--dry-run')
assert ops_primary_action is not None
assert ops_primary_action.execution_mode == 'nested_json'
assert ops_primary_action.command_args == ('ops', 'ping-db')
assert ops_secondary_action is not None
assert ops_secondary_action.execution_mode == 'nested_json'
assert ops_secondary_action.command_args == ('ops', 'ping-redis')
assert ops_action is not None
assert ops_action.execution_mode == 'external'
assert ops_action.command_args == (
'wizard',
'prod-check',
'--output=text',
'--default-env=dev',
'--default-include-config',
)
assert crypto_primary_action is not None
assert crypto_primary_action.execution_mode == 'external'
assert crypto_primary_action.command_args == ('crypto', 'keygen', '--env=dev', '--output=text')
assert crypto_primary_action.refresh_view is False
assert crypto_action is not None
assert crypto_action.execution_mode == 'nested_json'
assert crypto_action.command_args == ('crypto', 'rotate', '--dry-run')
assert unknown_action is None
def test_action_execution_service_execute_appends_env_output_and_yes(monkeypatch: MonkeyPatch) -> None:
"""
校验 TUI 动作执行时会复用 CLI JSON 输出并自动附带确认参数。
:param monkeypatch: pytest monkeypatch 工具
:return: None
"""
calls: list[tuple[str, ...]] = []
def _fake_run_nested_cli_command(*arguments: str, parse_json: bool = False) -> SimpleNamespace:
assert parse_json is True
calls.append(arguments)
return SimpleNamespace(payload={'ok': True, 'message': '参数缓存刷新成功'})
monkeypatch.setattr(action_execution_module.NESTED_CLI_SUPPORT, 'run', _fake_run_nested_cli_command)
spec = actions_module.TuiActionSpec(
action_id='config-sync-cache',
label='刷新参数缓存',
command_args=('config', 'sync-cache'),
preview_title='刷新参数缓存',
preview_lines=['line a'],
)
result = actions_module.TUI_ACTION_EXECUTION_SERVICE.execute(spec, 'dev')
assert result.ok is True
assert calls == [('config', 'sync-cache', '--env=dev', '--output=json', '--yes')]
def test_action_execution_service_execute_skips_yes_for_readonly_action(monkeypatch: MonkeyPatch) -> None:
"""
校验只读动作执行时不会错误追加 `--yes`。
:param monkeypatch: pytest monkeypatch 工具
:return: None
"""
calls: list[tuple[str, ...]] = []
def _fake_run_nested_cli_command(*arguments: str, parse_json: bool = False) -> SimpleNamespace:
assert parse_json is True
calls.append(arguments)
return SimpleNamespace(payload={'ok': True, 'message': '数据库连接成功'})
monkeypatch.setattr(action_execution_module.NESTED_CLI_SUPPORT, 'run', _fake_run_nested_cli_command)
spec = actions_module.TuiActionSpec(
action_id='ops-ping-db',
label='数据库探活',
command_args=('ops', 'ping-db'),
preview_title='数据库探活',
preview_lines=['line a'],
append_yes=False,
)
result = actions_module.TUI_ACTION_EXECUTION_SERVICE.execute(spec, 'dev')
assert result.ok is True
assert calls == [('ops', 'ping-db', '--env=dev', '--output=json')]
def test_action_execution_service_execute_external_uses_live_nested_command(monkeypatch: MonkeyPatch) -> None:
"""
校验外部交互动作会通过 live nested CLI helper 执行。
:param monkeypatch: pytest monkeypatch 工具
:return: None
"""
calls: list[tuple[str, ...]] = []
def _fake_run_nested_cli_command_live(*arguments: str) -> SimpleNamespace:
calls.append(arguments)
return SimpleNamespace(returncode=0)
monkeypatch.setattr(action_execution_module.NESTED_CLI_SUPPORT, 'run_live', _fake_run_nested_cli_command_live)
spec = actions_module.TuiActionSpec(
action_id='wizard-cache-clear',
label='打开缓存清理向导',
command_args=('wizard', 'cache-clear', '--output=text'),
preview_title='打开缓存清理向导',
preview_lines=['line a'],
execution_mode='external',
)
result = actions_module.TUI_ACTION_EXECUTION_SERVICE.execute_external(spec)
assert result.ok is True
assert result.message == '外部交互命令已执行完成'
assert calls == [('wizard', 'cache-clear', '--output=text')]
def test_action_presentation_service_build_browser_action_hint_matches_supported_views() -> None:
"""
校验浏览页动作提示会按页面输出不同文案。
:return: None
"""
assert '[X]' in actions_module.TUI_ACTION_PRESENTATION_SERVICE.build_browser_action_hint('jobs')
assert '[Y]' in actions_module.TUI_ACTION_PRESENTATION_SERVICE.build_browser_action_hint('configs')
assert '[W]' in actions_module.TUI_ACTION_PRESENTATION_SERVICE.build_browser_action_hint('cache')
assert '[Y] 清理向导' in actions_module.TUI_ACTION_PRESENTATION_SERVICE.build_browser_action_hint('cache')
assert '[X] 导出向导' in actions_module.TUI_ACTION_PRESENTATION_SERVICE.build_browser_action_hint('gen')
assert '[Z] 导入向导' in actions_module.TUI_ACTION_PRESENTATION_SERVICE.build_browser_action_hint('gen')
assert '[Y] 导出预演' in actions_module.TUI_ACTION_PRESENTATION_SERVICE.build_browser_action_hint('gen')
assert '[W] 同步表结构' in actions_module.TUI_ACTION_PRESENTATION_SERVICE.build_browser_action_hint('gen')
assert '失败聚合' in actions_module.TUI_ACTION_PRESENTATION_SERVICE.build_browser_action_hint('jobs')
assert '浏览键' in actions_module.TUI_ACTION_PRESENTATION_SERVICE.build_browser_action_hint('jobs')
def test_action_presentation_service_build_detail_action_hint_matches_supported_views() -> None:
"""
校验详情页动作提示会按页面输出不同文案。
:return: None
"""
assert '[X] 直接启动' in actions_module.TUI_ACTION_PRESENTATION_SERVICE.build_detail_action_hint('app')
assert '[Y] 打开启动向导' in actions_module.TUI_ACTION_PRESENTATION_SERVICE.build_detail_action_hint('app')
assert '[W] 安装补全' in actions_module.TUI_ACTION_PRESENTATION_SERVICE.build_detail_action_hint('app')
assert '[Y] 打开升级向导' in actions_module.TUI_ACTION_PRESENTATION_SERVICE.build_detail_action_hint('database')
assert '[X] 数据库探活' in actions_module.TUI_ACTION_PRESENTATION_SERVICE.build_detail_action_hint('ops')
assert '[Z] Redis 探活' in actions_module.TUI_ACTION_PRESENTATION_SERVICE.build_detail_action_hint('ops')
assert '[Y] 打开生产巡检向导' in actions_module.TUI_ACTION_PRESENTATION_SERVICE.build_detail_action_hint('ops')
assert '[X] 密钥生成' in actions_module.TUI_ACTION_PRESENTATION_SERVICE.build_detail_action_hint('crypto')
assert '[Y] 执行轮换预演' in actions_module.TUI_ACTION_PRESENTATION_SERVICE.build_detail_action_hint('crypto')
assert (
'环境映射、应用配置、启动前检查、补全诊断和路由状态,再决定是安装补全、直接启动应用还是进入启动向导'
in actions_module.TUI_ACTION_PRESENTATION_SERVICE.build_detail_action_hint('app')
)
def test_action_execution_service_build_result_lines_surfaces_common_payload_fields() -> None:
"""
校验动作结果详情会提取常见结果字段。
:return: None
"""
spec = actions_module.TuiActionSpec(
action_id='job-run-once',
label='执行一次任务',
command_args=('job', 'run-once', '1'),
preview_title='执行一次任务',
preview_lines=['line a'],
)
result = actions_module.TuiActionResult(
spec=spec,
payload={
'ok': True,
'message': '执行完成',
'serviceMessage': '调度器已触发',
'hint': '可继续刷新页面确认状态',
'jobId': 1,
},
)
lines = actions_module.TUI_ACTION_EXECUTION_SERVICE.build_result_lines(result)
assert any('结果: 成功' in line for line in lines)
assert any('服务反馈: 调度器已触发' in line for line in lines)
assert any('摘要: 执行完成' in line for line in lines)
assert any('建议: 可继续刷新页面确认状态' in line for line in lines)
assert any('任务 ID: 1' in line for line in lines)
def test_action_execution_service_build_result_lines_surfaces_external_exit_code() -> None:
"""
校验外部交互动作结果会展示退出码。
:return: None
"""
spec = actions_module.TuiActionSpec(
action_id='wizard-gen-export',
label='打开导出向导',
command_args=('wizard', 'gen-export', '--output=text'),
preview_title='打开导出向导',
preview_lines=['line a'],
execution_mode='external',
)
result = actions_module.TuiActionResult(
spec=spec,
external_exit_code=1,
external_message='外部交互命令执行失败,退出码 1',
)
lines = actions_module.TUI_ACTION_EXECUTION_SERVICE.build_result_lines(result)
assert any('结果: 失败' in line for line in lines)
assert any('退出码: 1' in line for line in lines)
@@ -0,0 +1,87 @@
import asyncio
import importlib
import sys
from collections.abc import Callable
from pathlib import Path
from types import ModuleType
import pytest
BACKEND_DIR = Path(__file__).resolve().parents[4]
sys.path.insert(0, str(BACKEND_DIR))
def _load_adapter_module(module_name: str) -> ModuleType:
sys.modules.pop(module_name, None)
sys.modules.pop('cli', None)
return importlib.import_module(module_name)
@pytest.fixture
def app_adapter() -> ModuleType:
return _load_adapter_module('cli.tui.adapters.app')
@pytest.fixture
def cache_adapter() -> ModuleType:
return _load_adapter_module('cli.tui.adapters.cache')
@pytest.fixture
def crypto_adapter() -> ModuleType:
return _load_adapter_module('cli.tui.adapters.crypto')
@pytest.fixture
def database_adapter() -> ModuleType:
return _load_adapter_module('cli.tui.adapters.database')
@pytest.fixture
def jobs_adapter() -> ModuleType:
return _load_adapter_module('cli.tui.adapters.jobs')
@pytest.fixture
def gen_adapter() -> ModuleType:
return _load_adapter_module('cli.tui.adapters.gen')
@pytest.fixture
def configs_adapter() -> ModuleType:
return _load_adapter_module('cli.tui.adapters.configs')
@pytest.fixture
def ops_adapter() -> ModuleType:
return _load_adapter_module('cli.tui.adapters.ops')
@pytest.fixture
def health_adapter() -> ModuleType:
return _load_adapter_module('cli.tui.adapters.health')
@pytest.fixture
def load_adapter_module() -> Callable[[str], ModuleType]:
return _load_adapter_module
@pytest.fixture(autouse=True)
def dispose_async_db_engine_after_test() -> None:
"""
在每个 TUI adapter 测试结束后尝试释放全局异步数据库连接池。
这些适配器测试会按需导入运行时模块;若其中某些路径触发真实数据库访问,
模块级 `async_engine` 可能在测试进程结束前仍持有连接,从而在 GC 阶段产生
SQLAlchemy 未归还连接告警。这里统一在测试后主动 `dispose()`,将清理职责收口
到测试夹具而非业务代码。
:return: None
"""
yield
database_module = sys.modules.get('config.database')
async_engine = getattr(database_module, 'async_engine', None) if database_module is not None else None
if async_engine is not None:
asyncio.run(async_engine.dispose())
@@ -0,0 +1,345 @@
from types import ModuleType, SimpleNamespace
from pytest import MonkeyPatch
def test_collect_app_page_snapshot_builds_env_config_and_route_sections(
monkeypatch: MonkeyPatch,
app_adapter: ModuleType,
) -> None:
def fake_run_nested_cli_command(*arguments: str, parse_json: bool = False) -> SimpleNamespace:
del parse_json
if arguments[0:2] == ('app', 'env'):
return SimpleNamespace(
payload={
'ok': True,
'runtime': {
'cliEnv': 'dev',
'configEnv': 'dev',
'appEnv': 'dev',
'envFile': '.env.dev',
'envFileExists': True,
'backendDir': '/srv/backend',
'pythonExecutable': '/opt/conda/envs/shierfeng-fastapi/bin/python',
},
}
)
if arguments[0:2] == ('app', 'config'):
return SimpleNamespace(
payload={
'ok': True,
'config': {
'name': 'ShiErFeng',
'host': '127.0.0.1',
'port': 8000,
'rootPath': '/',
'workers': 2,
'reload': True,
'disableSwagger': False,
'disableRedoc': True,
'logLevel': 'INFO',
'dbType': 'mysql',
'dbHost': '127.0.0.1',
'dbPort': 3306,
'dbDatabase': 'shierfeng',
'redisHost': '127.0.0.1',
'redisPort': 6379,
'transportCryptoEnabled': True,
'transportCryptoMode': 'strict',
},
}
)
if arguments[0:2] == ('app', 'routes'):
return SimpleNamespace(
payload={
'ok': True,
'count': 3,
'routes': [],
'groupedRoutes': {'system': [{}, {}], 'monitor': [{}]},
}
)
if arguments[0:2] == ('app', 'doctor'):
return SimpleNamespace(
payload={
'ok': True,
'env': 'dev',
'database': {'ok': True, 'message': '数据库连接成功'},
'redis': {'ok': True, 'message': 'Redis连接成功'},
'crypto': {'ok': True, 'message': '传输加密配置有效'},
}
)
if arguments[0:2] == ('completion', 'doctor'):
return SimpleNamespace(
payload={
'ok': True,
'message': 'completion 诊断信息已生成',
'activeShell': 'bash',
'projectDir': '/srv/backend',
'envChoices': ['dev', 'prod'],
'completeEnvVar': '_SHIERFENG_COMPLETE',
'recommendedInstallCommand': 'shierfeng completion install --shell=bash --activate',
'shells': {
'bash': {
'supported': True,
'detected': True,
'targetFile': '/tmp/shierfeng.bash',
'targetFileExists': True,
'rcFile': '/Users/demo/.bashrc',
'rcFileExists': True,
'autoDiscovery': False,
'sourceCommand': 'source /tmp/shierfeng.bash',
'recommendedInstallCommand': 'shierfeng completion install --shell=bash --activate',
},
'zsh': {'supported': True, 'detected': False, 'autoDiscovery': False},
'fish': {'supported': True, 'detected': False, 'autoDiscovery': True},
'powershell': {
'supported': True,
'detected': False,
'autoDiscovery': False,
'sourceCommand': '. "/tmp/shierfeng.ps1"',
'recommendedInstallCommand': 'shierfeng completion install --shell=powershell --activate',
},
},
}
)
if arguments[0:2] == ('completion', 'show'):
return SimpleNamespace(
returncode=0,
stdout='_SHIERFENG_COMPLETE=bash_complete\n_shierfeng_completion() {\n COMPREPLY=()\n}\n',
stderr='',
)
return SimpleNamespace(
payload={
'ok': True,
'count': 3,
'routes': [
{
'path': '/system/user/list',
'methods': ['GET'],
'summary': '查询用户列表',
}
],
'groupedRoutes': {
'system': [{}, {}],
'monitor': [{}],
},
}
)
monkeypatch.setattr(app_adapter.NESTED_CLI_SUPPORT, 'run', fake_run_nested_cli_command)
snapshot = app_adapter.APP_DETAIL_ADAPTER.collect_snapshot('dev')
assert snapshot.title == '应用'
assert (
snapshot.subtitle
== '聚焦:环境映射 / 配置摘要 / 启动前检查 | 当前已加载 3 条注册路由,可继续查看环境映射、配置摘要、启动前检查、补全诊断与路由状态'
)
assert [section.title for section in snapshot.sections] == [
'总览判断',
'环境解析',
'应用配置',
'依赖配置',
'启动前检查',
'补全诊断',
'补全脚本预览',
'补全安装入口',
'路由摘要',
'启动入口',
]
assert snapshot.search is not None
assert snapshot.search.placeholder == '按分区或内容搜索'
assert any('CLI 环境: dev' in line for line in snapshot.sections[0].lines)
assert any('CLI 目标环境: dev' in line for line in snapshot.sections[1].lines)
assert any('监听地址: 127.0.0.1:8000' in line for line in snapshot.sections[2].lines)
assert any('传输加密: 开启' in line for line in snapshot.sections[3].lines)
assert any('数据库: 正常' in line for line in snapshot.sections[4].lines)
assert any('活动 Shell: bash' in line for line in snapshot.sections[5].lines)
assert any('source /tmp/shierfeng.bash' in line for line in snapshot.sections[5].lines)
assert any('Shell: bash' in line for line in snapshot.sections[6].lines)
assert any('_SHIERFENG_COMPLETE=bash_complete' in line for line in snapshot.sections[6].lines)
assert any('completion install --activate --output=text' in line for line in snapshot.sections[7].lines)
assert any('system · 2 条' in line for line in snapshot.sections[8].lines)
assert any('app run --env=dev' in line for line in snapshot.sections[9].lines)
def test_collect_app_page_snapshot_applies_query_filter(
monkeypatch: MonkeyPatch,
app_adapter: ModuleType,
) -> None:
def fake_run_nested_cli_command(*arguments: str, parse_json: bool = False) -> SimpleNamespace:
del parse_json
if arguments[0:2] == ('app', 'env'):
return SimpleNamespace(payload={'ok': True, 'runtime': {'cliEnv': 'dev', 'configEnv': 'dev'}})
if arguments[0:2] == ('app', 'config'):
return SimpleNamespace(
payload={
'ok': True,
'config': {
'name': 'ShiErFeng',
'host': '127.0.0.1',
'port': 8000,
'rootPath': '/',
'workers': 2,
'reload': True,
'disableSwagger': False,
'disableRedoc': True,
'logLevel': 'INFO',
'dbType': 'mysql',
'dbHost': '127.0.0.1',
'dbPort': 3306,
'dbDatabase': 'shierfeng',
'redisHost': '127.0.0.1',
'redisPort': 6379,
'transportCryptoEnabled': True,
'transportCryptoMode': 'strict',
},
}
)
if arguments[0:2] == ('completion', 'doctor'):
return SimpleNamespace(
payload={
'ok': True,
'message': 'completion 诊断信息已生成',
'activeShell': 'bash',
'projectDir': '/srv/backend',
'envChoices': ['dev'],
'completeEnvVar': '_SHIERFENG_COMPLETE',
'recommendedInstallCommand': 'shierfeng completion install --shell=bash --activate',
'shells': {'bash': {'supported': True, 'detected': True, 'autoDiscovery': False}},
}
)
return SimpleNamespace(payload={'ok': True, 'count': 1, 'routes': [], 'groupedRoutes': {}})
monkeypatch.setattr(app_adapter.NESTED_CLI_SUPPORT, 'run', fake_run_nested_cli_command)
snapshot = app_adapter.APP_DETAIL_ADAPTER.collect_snapshot('dev', query='路由')
assert [section.title for section in snapshot.sections] == ['总览判断', '路由摘要']
assert snapshot.search is not None
assert snapshot.search.query == '路由'
def test_collect_ops_page_snapshot_builds_health_dependency_and_server_sections(
monkeypatch: MonkeyPatch,
ops_adapter: ModuleType,
) -> None:
def fake_run_nested_cli_command(*arguments: str, parse_json: bool = False) -> SimpleNamespace:
del parse_json
if arguments[0:2] == ('ops', 'health'):
return SimpleNamespace(
payload={
'ok': True,
'env': 'dev',
'database': {'ok': True, 'message': '数据库连接成功'},
'redis': {'ok': True, 'message': 'Redis连接成功'},
}
)
if arguments[0:2] == ('ops', 'ping-db'):
return SimpleNamespace(payload={'ok': True, 'message': '数据库连接成功'})
if arguments[0:2] == ('ops', 'ping-redis'):
return SimpleNamespace(payload={'ok': True, 'message': 'Redis连接成功'})
if arguments[0:2] == ('ops', 'deps'):
return SimpleNamespace(
payload={
'ok': True,
'message': '核心运行依赖已安装',
'missingRequired': [],
'packages': {
'python': {'installed': True, 'version': '3.10.0'},
'fastapi': {'installed': True, 'version': '0.111.0'},
'sqlalchemy': {'installed': True, 'version': '2.0.0'},
'redis': {'installed': True, 'version': '5.0.0'},
'typer': {'installed': True, 'version': '0.12.0'},
'alembic': {'installed': True, 'version': '1.13.0'},
},
}
)
return SimpleNamespace(
payload={
'ok': True,
'server': {
'sys': {
'computerName': 'shierfeng-node',
'computerIp': '10.0.0.8',
'osName': 'Linux',
'osArch': 'x86_64',
},
'cpu': {'cpuNum': 8, 'used': 21.5},
'mem': {'total': '16 GB', 'usage': 48.2},
'py': {'version': '3.10.0', 'runTime': '2天4小时', 'used': '256 MB', 'total': '1 GB'},
'sysFiles': [
{
'dirName': '/',
'used': '120 GB',
'total': '256 GB',
'usage': '46%',
'free': '136 GB',
}
],
},
}
)
monkeypatch.setattr(ops_adapter.NESTED_CLI_SUPPORT, 'run', fake_run_nested_cli_command)
snapshot = ops_adapter.OPS_DETAIL_ADAPTER.collect_snapshot('dev')
assert snapshot.title == '运维'
assert (
snapshot.subtitle
== '聚焦:数据库连通性 / Redis 连通性 / 依赖版本 | 基础探活和依赖状态正常,可继续查看服务器资源与磁盘样本'
)
assert [section.title for section in snapshot.sections] == [
'总览判断',
'健康检查',
'数据库探活',
'Redis 探活',
'依赖版本',
'服务器摘要',
'磁盘样本',
'生产巡检入口',
]
assert any('探活状态: 正常' in line for line in snapshot.sections[0].lines)
assert any('数据库: 正常' in line for line in snapshot.sections[1].lines)
assert any('数据库连接: 正常' in line for line in snapshot.sections[2].lines)
assert any('Redis 连接: 正常' in line for line in snapshot.sections[3].lines)
assert any('fastapi · 已安装' in line for line in snapshot.sections[4].lines)
assert any('主机名: shierfeng-node' in line for line in snapshot.sections[5].lines)
assert any('使用率 46%' in line for line in snapshot.sections[6].lines)
assert any('wizard prod-check' in line for line in snapshot.sections[7].lines)
assert snapshot.search is not None
assert snapshot.search.placeholder == '按分区或内容搜索'
def test_collect_ops_page_snapshot_surfaces_health_risk_subtitle(
monkeypatch: MonkeyPatch,
ops_adapter: ModuleType,
) -> None:
def fake_run_nested_cli_command(*arguments: str, parse_json: bool = False) -> SimpleNamespace:
del parse_json
if arguments[0:2] == ('ops', 'health'):
return SimpleNamespace(
payload={
'ok': False,
'env': 'dev',
'database': {'ok': False, 'message': '数据库连接失败'},
'redis': {'ok': True, 'message': 'Redis连接成功'},
}
)
if arguments[0:2] == ('ops', 'ping-db'):
return SimpleNamespace(payload={'ok': False, 'message': '数据库连接失败'})
if arguments[0:2] == ('ops', 'ping-redis'):
return SimpleNamespace(payload={'ok': True, 'message': 'Redis连接成功'})
if arguments[0:2] == ('ops', 'deps'):
return SimpleNamespace(payload={'ok': True, 'missingRequired': [], 'packages': {}, 'message': '依赖正常'})
return SimpleNamespace(payload={'ok': True, 'server': {'sys': {}, 'cpu': {}, 'mem': {}, 'py': {}}})
monkeypatch.setattr(ops_adapter.NESTED_CLI_SUPPORT, 'run', fake_run_nested_cli_command)
snapshot = ops_adapter.OPS_DETAIL_ADAPTER.collect_snapshot('dev')
assert (
snapshot.subtitle
== '聚焦:数据库连通性 / Redis 连通性 / 依赖版本 | 运维探活存在异常,优先核对数据库/Redis 连通性与依赖版本'
)
@@ -0,0 +1,346 @@
from types import ModuleType, SimpleNamespace
from pytest import MonkeyPatch
def test_collect_cache_page_snapshot_builds_cache_browser_records(
monkeypatch: MonkeyPatch,
cache_adapter: ModuleType,
) -> None:
calls: list[tuple[str, ...]] = []
def fake_run_nested_cli_command(*arguments: str, parse_json: bool = False) -> SimpleNamespace:
del parse_json
calls.append(arguments)
if arguments[0:2] == ('cache', 'stats'):
return SimpleNamespace(
payload={
'ok': True,
'dbSize': 128,
'info': {
'redis_version': '7.0',
'connected_clients': 12,
'used_memory_human': '16M',
},
'commandStats': [{'name': 'get', 'value': 88}],
'cacheNames': [{'cacheName': 'sys_config', 'remark': '系统参数缓存'}],
}
)
if arguments[0:2] == ('cache', 'keys'):
return SimpleNamespace(
payload={
'ok': True,
'cacheName': 'sys_config',
'count': 2,
'keys': ['site_name', 'site_logo'],
}
)
if arguments[0:2] == ('cache', 'get'):
return SimpleNamespace(
payload={
'ok': True,
'cacheName': 'sys_config',
'cacheKey': arguments[3],
'cacheValue': 'hello-world',
}
)
return SimpleNamespace(
payload={
'ok': True,
'cacheName': 'sys_config',
'cacheKey': arguments[3],
'ttlSeconds': 3600,
'persistent': False,
'expires': True,
}
)
monkeypatch.setattr(cache_adapter.NESTED_CLI_SUPPORT, 'run', fake_run_nested_cli_command)
snapshot = cache_adapter.CACHE_BROWSER_ADAPTER.collect_snapshot('dev')
assert snapshot.title == '缓存'
assert (
snapshot.subtitle
== '聚焦:Redis 键数 / 连接数 / 缓存名前缀 | 缓存基线正常,当前已加载 1 个缓存名,Redis 键数 128,连接数 12,可继续查看键列表、键值样本和 TTL'
)
assert len(snapshot.records) == 1
assert snapshot.records[0].title == 'sys_config'
detail_sections = snapshot.records[0].resolve_detail_sections()
assert [section.title for section in detail_sections] == [
'键摘要',
'键列表',
'键详情 · site_name',
'键详情 · site_logo',
]
assert any('site_name' in line for line in detail_sections[1].lines)
assert any('TTL: 3600 秒' in line for line in detail_sections[2].lines)
assert any('hello-world' in line for line in detail_sections[2].lines)
assert any('TTL: 3600 秒' in line for line in detail_sections[3].lines)
assert snapshot.shared_sections[0].title == '总览判断'
assert any('已登记缓存名: 1 个' in line for line in snapshot.shared_sections[0].lines)
assert any('当前匹配: 1 个' in line for line in snapshot.shared_sections[0].lines)
assert snapshot.shared_sections[1].title == 'Redis 摘要'
assert snapshot.shared_sections[2].title == '命令统计'
assert snapshot.shared_sections[3].title == '缓存清理入口'
assert any('wizard cache-clear' in line for line in snapshot.shared_sections[3].lines)
assert any(call[0:2] == ('cache', 'get') for call in calls)
assert any(call[0:2] == ('cache', 'ttl') for call in calls)
def test_cache_browser_adapter_collect_snapshot_exposes_search_context(
monkeypatch: MonkeyPatch,
cache_adapter: ModuleType,
) -> None:
def fake_run_nested_cli_command(*arguments: str, parse_json: bool = False) -> SimpleNamespace:
del parse_json
if arguments[0:2] == ('cache', 'stats'):
return SimpleNamespace(
payload={
'ok': True,
'dbSize': 8,
'info': {
'redis_version': '7.0',
'connected_clients': 2,
'used_memory_human': '1M',
},
'commandStats': [],
'cacheNames': [{'cacheName': 'sys_config', 'remark': '系统参数缓存'}],
}
)
return SimpleNamespace(payload={'ok': False, 'message': 'unexpected'})
monkeypatch.setattr(cache_adapter.NESTED_CLI_SUPPORT, 'run', fake_run_nested_cli_command)
snapshot = cache_adapter.CACHE_BROWSER_ADAPTER.collect_snapshot('dev', query='sys')
assert snapshot.title == '缓存'
assert snapshot.search is not None
assert snapshot.search.query == 'sys'
assert snapshot.records
assert snapshot.records[0].title == 'sys_config'
def test_collect_database_page_snapshot_builds_check_heads_and_history_sections(
monkeypatch: MonkeyPatch,
database_adapter: ModuleType,
) -> None:
def fake_run_nested_cli_command(*arguments: str, parse_json: bool = False) -> SimpleNamespace:
del parse_json
if arguments[0:2] == ('db', 'current'):
return SimpleNamespace(payload={'ok': True, 'currentRevision': '202604300001'})
if arguments[0:2] == ('db', 'check'):
return SimpleNamespace(payload={'ok': True, 'message': '数据库连接成功'})
if arguments[0:2] == ('db', 'heads'):
return SimpleNamespace(
payload={
'ok': True,
'message': '已读取 Alembic heads',
'count': 1,
'items': [
{
'revision': '202604300001',
'downRevisions': ['202604290001'],
'branchLabels': ['main'],
'dependsOn': [],
'doc': 'create user table',
'path': '/srv/backend/alembic/versions/202604300001_create_user.py',
}
],
}
)
if arguments[0:2] == ('db', 'history'):
return SimpleNamespace(
payload={
'ok': True,
'message': '已读取 Alembic 历史版本',
'count': 2,
'totalCount': 2,
'limit': 8,
'items': [
{
'revision': '202604300001',
'downRevisions': ['202604290001'],
'branchLabels': ['main'],
'dependsOn': [],
'doc': 'create user table',
'path': '/srv/backend/alembic/versions/202604300001_create_user.py',
},
{
'revision': '202604290001',
'downRevisions': [],
'branchLabels': [],
'dependsOn': [],
'doc': 'init schema',
'path': '/srv/backend/alembic/versions/202604290001_init_schema.py',
},
],
}
)
return SimpleNamespace(
payload={
'ok': True,
'config': {
'dbType': 'mysql',
'dbHost': '127.0.0.1',
'dbPort': 3306,
'dbDatabase': 'shierfeng',
},
}
)
monkeypatch.setattr(database_adapter.NESTED_CLI_SUPPORT, 'run', fake_run_nested_cli_command)
snapshot = database_adapter.DATABASE_DETAIL_ADAPTER.collect_snapshot('dev')
assert snapshot.title == '数据库'
assert (
snapshot.subtitle
== '聚焦:迁移版本 / 连接状态 / Heads | 数据库基线正常,当前 revision 202604300001,可继续查看连接、heads 和历史版本'
)
assert [section.title for section in snapshot.sections] == [
'总览判断',
'迁移版本',
'连接信息',
'连通性检查',
'Heads 状态',
'历史版本',
'初始化预演入口',
'升级入口',
]
assert any('当前 revision: 202604300001' in line for line in snapshot.sections[0].lines)
assert any('迁移版本: 202604300001' in line for line in snapshot.sections[1].lines)
assert any('连接地址: 127.0.0.1:3306' in line for line in snapshot.sections[2].lines)
assert any('数据库连接: 正常' in line for line in snapshot.sections[3].lines)
assert any('Heads 数量: 1' in line for line in snapshot.sections[4].lines)
assert any('Head 01 · 202604300001' in line for line in snapshot.sections[4].lines)
assert any('总版本数: 2' in line for line in snapshot.sections[5].lines)
assert any('版本 02 · 202604290001' in line for line in snapshot.sections[5].lines)
assert any('db init --dry-run --output=text' in line for line in snapshot.sections[6].lines)
assert any('wizard db-upgrade' in line for line in snapshot.sections[7].lines)
assert snapshot.search is not None
assert snapshot.search.placeholder == '按 revision 搜索'
def test_collect_database_page_snapshot_surfaces_failed_heads(
monkeypatch: MonkeyPatch,
database_adapter: ModuleType,
) -> None:
def fake_run_nested_cli_command(*arguments: str, parse_json: bool = False) -> SimpleNamespace:
del parse_json
if arguments[0:2] == ('db', 'current'):
return SimpleNamespace(payload={'ok': True, 'currentRevision': '202604300001'})
if arguments[0:2] == ('db', 'check'):
return SimpleNamespace(payload={'ok': True, 'message': '数据库连接成功'})
if arguments[0:2] == ('db', 'heads'):
return SimpleNamespace(payload={'ok': False, 'message': '读取 Alembic heads 失败', 'error': 'broken graph'})
if arguments[0:2] == ('db', 'history'):
return SimpleNamespace(payload={'ok': True, 'count': 0, 'totalCount': 0, 'limit': 8, 'items': []})
return SimpleNamespace(
payload={
'ok': True,
'config': {
'dbType': 'mysql',
'dbHost': '127.0.0.1',
'dbPort': 3306,
'dbDatabase': 'shierfeng',
},
}
)
monkeypatch.setattr(database_adapter.NESTED_CLI_SUPPORT, 'run', fake_run_nested_cli_command)
snapshot = database_adapter.DATABASE_DETAIL_ADAPTER.collect_snapshot('dev')
assert snapshot.subtitle == '聚焦:迁移版本 / 连接状态 / Heads | 数据库存在迁移分叉风险,优先确认 heads 和历史版本'
assert snapshot.sections[0].title == '总览判断'
assert snapshot.sections[0].status == 'warn'
assert snapshot.sections[4].title == 'Heads 状态'
assert snapshot.sections[4].status == 'fail'
assert any('读取 Alembic heads 失败' in line for line in snapshot.sections[4].lines)
def test_collect_database_page_snapshot_applies_query_filter(
monkeypatch: MonkeyPatch,
database_adapter: ModuleType,
) -> None:
def fake_run_nested_cli_command(*arguments: str, parse_json: bool = False) -> SimpleNamespace:
del parse_json
if arguments[0:2] == ('db', 'current'):
return SimpleNamespace(payload={'ok': True, 'currentRevision': '202604300001'})
if arguments[0:2] == ('db', 'check'):
return SimpleNamespace(payload={'ok': True, 'message': '数据库连接成功'})
if arguments[0:2] == ('db', 'heads'):
return SimpleNamespace(payload={'ok': True, 'count': 1, 'items': []})
if arguments[0:2] == ('db', 'history'):
return SimpleNamespace(payload={'ok': True, 'count': 0, 'totalCount': 0, 'limit': 8, 'items': []})
return SimpleNamespace(
payload={
'ok': True,
'config': {'dbType': 'mysql', 'dbHost': '127.0.0.1', 'dbPort': 3306, 'dbDatabase': 'shierfeng'},
}
)
monkeypatch.setattr(database_adapter.NESTED_CLI_SUPPORT, 'run', fake_run_nested_cli_command)
snapshot = database_adapter.DATABASE_DETAIL_ADAPTER.collect_snapshot('dev', query='历史')
assert [section.title for section in snapshot.sections] == ['历史版本']
assert snapshot.search is not None
assert snapshot.search.query == '历史'
def test_collect_crypto_page_snapshot_builds_validate_and_public_key_sections(
monkeypatch: MonkeyPatch,
crypto_adapter: ModuleType,
) -> None:
def fake_run_nested_cli_command(*arguments: str, parse_json: bool = False) -> SimpleNamespace:
del parse_json
if arguments[0:2] == ('crypto', 'validate'):
return SimpleNamespace(
payload={
'ok': True,
'message': '传输加密配置校验通过',
}
)
return SimpleNamespace(
payload={
'ok': True,
'publicKey': {
'kid': 'default',
'alg': 'RSA-OAEP',
'envelopeVersion': 'v1',
'expireAt': '2099-12-31T23:59:59',
'supportedKids': ['default', 'legacy-a'],
'publicKey': '-----BEGIN PUBLIC KEY-----\nAAA\nBBB\n-----END PUBLIC KEY-----',
},
}
)
monkeypatch.setattr(crypto_adapter.NESTED_CLI_SUPPORT, 'run', fake_run_nested_cli_command)
snapshot = crypto_adapter.CRYPTO_DETAIL_ADAPTER.collect_snapshot('dev')
assert snapshot.title == '传输加密'
assert (
snapshot.subtitle
== '聚焦:运行校验 / 公钥身份 / 兼容版本 | 当前 KID default,兼容版本 2 个,可继续查看公钥身份、兼容版本与轮换预演入口'
)
assert [section.title for section in snapshot.sections] == [
'总览判断',
'运行校验',
'公钥身份',
'兼容版本',
'公钥预览',
'密钥生成入口',
'轮换预演入口',
]
assert any('运行校验: 通过' in line for line in snapshot.sections[0].lines)
assert any('状态: 通过' in line for line in snapshot.sections[1].lines)
assert any('KID: default' in line for line in snapshot.sections[2].lines)
assert any('支持版本: legacy-a' in line for line in snapshot.sections[3].lines)
assert any('BEGIN PUBLIC KEY' in line for line in snapshot.sections[4].lines)
assert any('crypto keygen' in line for line in snapshot.sections[5].lines)
assert any('crypto rotate' in line for line in snapshot.sections[6].lines)
assert snapshot.search is not None
assert snapshot.search.placeholder == '按分区或内容搜索'
@@ -0,0 +1,494 @@
from types import ModuleType, SimpleNamespace
from pytest import MonkeyPatch
def test_collect_dashboard_snapshot_adds_risk_heat_panel(
monkeypatch: MonkeyPatch,
health_adapter: ModuleType,
) -> None:
def fake_run_nested_cli_command(*arguments: str, parse_json: bool = False) -> SimpleNamespace:
del parse_json
if arguments[0:2] == ('app', 'env'):
return SimpleNamespace(
payload={
'ok': True,
'runtime': {
'cliEnv': 'dev',
'configEnv': 'dev',
'envFile': '.env.dev',
'envFileExists': True,
},
}
)
if arguments[0:2] == ('app', 'doctor'):
return SimpleNamespace(
payload={
'ok': False,
'database': {'ok': False, 'message': '数据库连接失败'},
'redis': {'ok': True, 'message': 'Redis 正常'},
'crypto': {'ok': True, 'message': '加密组件正常'},
}
)
if arguments[0:2] == ('app', 'routes'):
return SimpleNamespace(
payload={
'ok': True,
'count': 3,
'routes': [],
'groupedRoutes': {'system': [{}, {}], 'monitor': [{}]},
}
)
if arguments[0:2] == ('db', 'current'):
return SimpleNamespace(
payload={
'ok': False,
'currentRevision': '-',
'message': 'revision unavailable',
}
)
if arguments[0:2] == ('ops', 'deps'):
return SimpleNamespace(
payload={
'ok': True,
'message': '核心运行依赖已安装',
'missingRequired': [],
'packages': {
'python': {'installed': True, 'version': '3.10.0'},
'fastapi': {'installed': True, 'version': '0.111.0'},
'sqlalchemy': {'installed': True, 'version': '2.0.0'},
'redis': {'installed': True, 'version': '5.0.0'},
},
}
)
if arguments[0:2] == ('ops', 'server-info'):
return SimpleNamespace(
payload={
'ok': True,
'server': {
'sys': {'computerName': 'shierfeng-node', 'computerIp': '10.0.0.8'},
'cpu': {'used': 18.2},
'mem': {'usage': 42.5},
},
}
)
return SimpleNamespace(
payload={
'ok': True,
'dbSize': 128,
'info': {'redis_version': '7.0', 'connected_clients': 12},
'cacheNames': ['sys_config'],
}
)
monkeypatch.setattr(health_adapter.NESTED_CLI_SUPPORT, 'run', fake_run_nested_cli_command)
snapshot = health_adapter.DASHBOARD_ADAPTER.collect_snapshot('dev')
assert any(panel.title == '总览判断' for panel in snapshot.panels)
assert any(panel.title == '建议摘要' for panel in snapshot.panels)
assert any(panel.title == '风险摘要' for panel in snapshot.panels)
assert any(panel.title == '依赖版本' for panel in snapshot.panels)
assert any(panel.title == '服务器摘要' for panel in snapshot.panels)
app_panel = next(panel for panel in snapshot.panels if panel.title == '应用摘要')
conclusion_panel = next(panel for panel in snapshot.panels if panel.title == '总览判断')
entry_panel = next(panel for panel in snapshot.panels if panel.title == '建议摘要')
assert any('当前环境: dev | CLI 目标环境: dev' in line for line in app_panel.lines)
assert any('注册路由: 3 条 | 标签分组: 2 个' in line for line in app_panel.lines)
assert conclusion_panel.status == 'fail'
assert any('数据库存在异常' in line for line in conclusion_panel.lines)
assert any('优先进入数据库页面' in line for line in conclusion_panel.lines)
assert entry_panel.status == 'fail'
assert any('[B] 数据库' in line for line in entry_panel.lines)
assert any('[O] 运维' in line for line in entry_panel.lines)
assert any('聚焦:迁移版本 / 连接状态 / Heads' in line for line in entry_panel.lines)
assert any('聚焦:数据库连通性 / Redis 连通性 / 依赖版本' in line for line in entry_panel.lines)
heat_panel = next(panel for panel in snapshot.panels if panel.title == '风险摘要')
assert heat_panel.status == 'fail'
assert any('HOT-01' in line for line in heat_panel.lines)
assert any(metric.title == '依赖通过率' and '[' in metric.value for metric in snapshot.metrics)
def test_dashboard_adapter_collect_snapshot_exposes_metrics(
monkeypatch: MonkeyPatch,
health_adapter: ModuleType,
) -> None:
def fake_run_nested_cli_command(*arguments: str, parse_json: bool = False) -> SimpleNamespace:
del parse_json
if arguments[0:2] == ('app', 'env'):
return SimpleNamespace(payload={'ok': True, 'runtime': {'cliEnv': 'dev', 'configEnv': 'dev'}})
if arguments[0:2] == ('app', 'routes'):
return SimpleNamespace(payload={'ok': True, 'count': 2, 'routes': [], 'groupedRoutes': {}})
if arguments[0:2] == ('app', 'doctor'):
return SimpleNamespace(
payload={
'ok': True,
'database': {'ok': True, 'message': '数据库正常'},
'redis': {'ok': True, 'message': 'Redis 正常'},
'crypto': {'ok': True, 'message': '加密正常'},
}
)
if arguments[0:2] == ('db', 'current'):
return SimpleNamespace(payload={'ok': True, 'currentRevision': '202604300001'})
if arguments[0:2] == ('ops', 'deps'):
return SimpleNamespace(payload={'ok': True, 'missingRequired': [], 'packages': {}})
if arguments[0:2] == ('ops', 'server-info'):
return SimpleNamespace(payload={'ok': True, 'server': {'sys': {}, 'cpu': {}, 'mem': {}}})
return SimpleNamespace(payload={'ok': True, 'dbSize': 27, 'info': {}, 'cacheNames': []})
monkeypatch.setattr(health_adapter.NESTED_CLI_SUPPORT, 'run', fake_run_nested_cli_command)
snapshot = health_adapter.DASHBOARD_ADAPTER.collect_snapshot('dev')
assert snapshot.env == 'dev'
assert snapshot.metrics
assert any(metric.title == '依赖通过率' for metric in snapshot.metrics)
assert any(panel.title == '总览判断' for panel in snapshot.panels)
def test_collect_dashboard_snapshot_uses_focus_hints_for_healthy_business_entry(
monkeypatch: MonkeyPatch,
health_adapter: ModuleType,
) -> None:
def fake_run_nested_cli_command(*arguments: str, parse_json: bool = False) -> SimpleNamespace:
del parse_json
if arguments[0:2] == ('app', 'env'):
return SimpleNamespace(payload={'ok': True, 'runtime': {'cliEnv': 'dev', 'configEnv': 'dev'}})
if arguments[0:2] == ('app', 'routes'):
return SimpleNamespace(
payload={'ok': True, 'count': 4, 'routes': [], 'groupedRoutes': {'system': [{}, {}]}}
)
if arguments[0:2] == ('app', 'doctor'):
return SimpleNamespace(
payload={
'ok': True,
'database': {'ok': True, 'message': '数据库正常'},
'redis': {'ok': True, 'message': 'Redis 正常'},
'crypto': {'ok': True, 'message': '加密组件正常'},
}
)
if arguments[0:2] == ('db', 'current'):
return SimpleNamespace(payload={'ok': True, 'currentRevision': '202604300001'})
if arguments[0:2] == ('ops', 'deps'):
return SimpleNamespace(payload={'ok': True, 'missingRequired': [], 'packages': {}, 'message': '依赖正常'})
if arguments[0:2] == ('ops', 'server-info'):
return SimpleNamespace(payload={'ok': True, 'server': {'sys': {}, 'cpu': {}, 'mem': {}}})
return SimpleNamespace(
payload={
'ok': True,
'dbSize': 27,
'info': {'redis_version': '7.2.6', 'connected_clients': 1},
'cacheNames': [],
}
)
monkeypatch.setattr(health_adapter.NESTED_CLI_SUPPORT, 'run', fake_run_nested_cli_command)
snapshot = health_adapter.DASHBOARD_ADAPTER.collect_snapshot('dev')
entry_panel = next(panel for panel in snapshot.panels if panel.title == '建议摘要')
assert entry_panel.status == 'ok'
assert any('聚焦:失败聚合 / 暂停任务 / 执行轨迹' in line for line in entry_panel.lines)
assert any('聚焦:高风险配置 / 值不一致 / 缓存漂移' in line for line in entry_panel.lines)
assert any('生成前校验' in line and '代码预览' in line for line in entry_panel.lines)
def test_collect_configs_page_snapshot_surfaces_failure_message(
monkeypatch: MonkeyPatch,
configs_adapter: ModuleType,
) -> None:
def fake_run_nested_cli_command(*arguments: str, parse_json: bool = False) -> SimpleNamespace:
del arguments, parse_json
return SimpleNamespace(
payload={
'ok': False,
'message': '读取参数配置诊断信息失败',
'error': 'database unavailable',
}
)
monkeypatch.setattr(configs_adapter.NESTED_CLI_SUPPORT, 'run', fake_run_nested_cli_command)
snapshot = configs_adapter.CONFIGS_BROWSER_ADAPTER.collect_snapshot('dev')
assert snapshot.title == '参数配置'
assert snapshot.records[0].status == 'fail'
assert snapshot.shared_sections[0].title == '总览判断'
assert snapshot.shared_sections[1].title == '配置巡检'
assert any('读取参数配置诊断信息失败' in line for line in snapshot.shared_sections[1].lines)
assert any('database unavailable' in line for line in snapshot.shared_sections[1].lines)
def test_configs_browser_adapter_collect_snapshot_exposes_search_context(
monkeypatch: MonkeyPatch,
configs_adapter: ModuleType,
) -> None:
def fake_run_nested_cli_command(*arguments: str, parse_json: bool = False) -> SimpleNamespace:
del parse_json
if arguments[0:2] == ('config', 'doctor'):
return SimpleNamespace(
payload={
'ok': True,
'databaseCount': 1,
'cacheCount': 1,
'missingInCacheCount': 0,
'orphanInCacheCount': 0,
'mismatchCount': 0,
'missingInCache': [],
'orphanInCache': [],
'mismatchKeys': [],
}
)
if arguments[0:2] == ('config', 'list'):
return SimpleNamespace(
payload={
'ok': True,
'page': {
'rows': [
{
'configId': 1,
'configKey': 'site_name',
'configName': '站点名称',
'configType': 'Y',
'configValue': 'ShiErFeng',
}
]
},
}
)
return SimpleNamespace(payload={'ok': False, 'message': 'unexpected'})
monkeypatch.setattr(configs_adapter.NESTED_CLI_SUPPORT, 'run', fake_run_nested_cli_command)
snapshot = configs_adapter.CONFIGS_BROWSER_ADAPTER.collect_snapshot('dev', query='site')
assert snapshot.title == '参数配置'
assert snapshot.search is not None
assert snapshot.search.query == 'site'
assert snapshot.records
assert snapshot.records[0].title == 'site_name'
def test_collect_configs_page_snapshot_builds_browser_records(
monkeypatch: MonkeyPatch,
configs_adapter: ModuleType,
) -> None:
calls: list[tuple[str, ...]] = []
def fake_run_nested_cli_command(*arguments: str, parse_json: bool = False) -> SimpleNamespace:
del parse_json
calls.append(arguments)
if arguments[0:2] == ('config', 'doctor'):
return SimpleNamespace(
payload={
'ok': True,
'databaseCount': 3,
'cacheCount': 3,
'missingInCacheCount': 0,
'orphanInCacheCount': 0,
'mismatchCount': 1,
'mismatchKeys': ['site_name'],
}
)
if arguments[0:2] == ('config', 'list'):
return SimpleNamespace(
payload={
'ok': True,
'page': {
'rows': [
{
'configId': 1,
'configKey': 'site_name',
'configName': '站点名称',
'configType': 'Y',
'configValue': 'ShiErFeng',
}
]
},
}
)
return SimpleNamespace(
payload={
'ok': True,
'key': 'site_name',
'source': 'both',
'inSync': False,
'database': {
'configId': 1,
'configKey': 'site_name',
'configName': '站点名称',
'configValue': 'ShiErFeng',
'configType': 'Y',
'remark': '数据库值',
},
'cache': {
'configId': 1,
'configKey': 'site_name',
'configName': '站点名称',
'configValue': 'ShiErFeng-Cache',
'configType': 'Y',
'remark': '缓存值',
},
}
)
monkeypatch.setattr(configs_adapter.NESTED_CLI_SUPPORT, 'run', fake_run_nested_cli_command)
snapshot = configs_adapter.CONFIGS_BROWSER_ADAPTER.collect_snapshot('dev')
assert snapshot.title == '参数配置'
assert (
snapshot.subtitle
== '聚焦:高风险配置 / 值不一致 / 缓存漂移 | 当前筛选:全部,已匹配 1 项配置,值不一致 1 项,缓存漂移 0 项'
)
assert snapshot.active_filter_key == 'all'
assert [option.key for option in snapshot.filters] == ['all', 'risky', 'mismatch', 'cache-drift']
assert len(snapshot.records) == 1
assert snapshot.records[0].title == 'site_name'
assert snapshot.records[0].status == 'fail'
detail_sections = snapshot.records[0].resolve_detail_sections()
assert [section.title for section in detail_sections] == ['同步状态', '数据库配置', '缓存配置']
assert any('数据库与缓存一致: 否' in line for line in detail_sections[0].lines)
assert any('键值: ShiErFeng' in line for line in detail_sections[1].lines)
assert any('键值: ShiErFeng-Cache' in line for line in detail_sections[2].lines)
assert snapshot.shared_sections[0].title == '总览判断'
assert any('值不一致: 1 项' in line for line in snapshot.shared_sections[0].lines)
assert snapshot.shared_sections[1].title == '配置巡检'
assert snapshot.shared_sections[2].title == '高风险配置'
assert snapshot.shared_sections[3].title == '异常样本'
assert snapshot.shared_sections[4].title == '配置变更入口'
assert any('config set site_name ShiErFeng --output=text' in line for line in snapshot.shared_sections[4].lines)
assert any(call[0:2] == ('config', 'get') for call in calls)
def test_collect_configs_page_snapshot_sorts_and_filters_risky_records(
monkeypatch: MonkeyPatch,
configs_adapter: ModuleType,
) -> None:
def fake_run_nested_cli_command(*arguments: str, parse_json: bool = False) -> SimpleNamespace:
del parse_json
if arguments[0:2] == ('config', 'doctor'):
return SimpleNamespace(
payload={
'ok': True,
'databaseCount': 3,
'cacheCount': 3,
'missingInCacheCount': 1,
'orphanInCacheCount': 1,
'mismatchCount': 1,
'missingInCache': ['upload_mode'],
'orphanInCache': ['legacy_toggle'],
'mismatchKeys': ['site_name'],
}
)
if arguments[0:2] == ('config', 'list'):
return SimpleNamespace(
payload={
'ok': True,
'page': {
'rows': [
{
'configId': 2,
'configKey': 'upload_mode',
'configName': '上传模式',
'configType': 'N',
'configValue': 'local',
},
{
'configId': 1,
'configKey': 'site_name',
'configName': '站点名称',
'configType': 'Y',
'configValue': 'ShiErFeng',
},
{
'configId': 3,
'configKey': 'normal_timeout',
'configName': '会话超时',
'configType': 'N',
'configValue': '30m',
},
]
},
}
)
return SimpleNamespace(payload={'ok': True, 'key': '-', 'source': 'both', 'inSync': True})
monkeypatch.setattr(configs_adapter.NESTED_CLI_SUPPORT, 'run', fake_run_nested_cli_command)
all_snapshot = configs_adapter.CONFIGS_BROWSER_ADAPTER.collect_snapshot('dev')
risky_snapshot = configs_adapter.CONFIGS_BROWSER_ADAPTER.collect_snapshot('dev', filter_key='risky')
mismatch_snapshot = configs_adapter.CONFIGS_BROWSER_ADAPTER.collect_snapshot('dev', filter_key='mismatch')
cache_drift_snapshot = configs_adapter.CONFIGS_BROWSER_ADAPTER.collect_snapshot('dev', filter_key='cache-drift')
assert [record.title for record in all_snapshot.records] == [
'site_name',
'upload_mode',
'legacy_toggle',
'normal_timeout',
]
assert [record.status for record in all_snapshot.records] == ['fail', 'warn', 'warn', 'ok']
assert any('[值不一致] site_name' in line for line in all_snapshot.shared_sections[2].lines)
assert any('[缓存缺失] upload_mode' in line for line in all_snapshot.shared_sections[2].lines)
assert any('[缓存孤立] legacy_toggle' in line for line in all_snapshot.shared_sections[2].lines)
assert risky_snapshot.active_filter_key == 'risky'
assert [record.title for record in risky_snapshot.records] == ['site_name', 'upload_mode', 'legacy_toggle']
assert [record.title for record in mismatch_snapshot.records] == ['site_name']
assert [record.title for record in cache_drift_snapshot.records] == ['upload_mode', 'legacy_toggle']
def test_collect_configs_page_snapshot_applies_query_filter(
monkeypatch: MonkeyPatch,
configs_adapter: ModuleType,
) -> None:
def fake_run_nested_cli_command(*arguments: str, parse_json: bool = False) -> SimpleNamespace:
del parse_json
if arguments[0:2] == ('config', 'doctor'):
return SimpleNamespace(
payload={
'ok': True,
'databaseCount': 2,
'cacheCount': 2,
'missingInCacheCount': 0,
'orphanInCacheCount': 0,
'mismatchCount': 0,
'missingInCache': [],
'orphanInCache': [],
'mismatchKeys': [],
}
)
if arguments[0:2] == ('config', 'list'):
return SimpleNamespace(
payload={
'ok': True,
'page': {
'rows': [
{
'configId': 1,
'configKey': 'site_name',
'configName': '站点名称',
'configType': 'Y',
'configValue': 'ShiErFeng',
},
{
'configId': 2,
'configKey': 'upload_mode',
'configName': '上传模式',
'configType': 'N',
'configValue': 'local',
},
]
},
}
)
return SimpleNamespace(payload={'ok': True, 'key': '-', 'source': 'both', 'inSync': True})
monkeypatch.setattr(configs_adapter.NESTED_CLI_SUPPORT, 'run', fake_run_nested_cli_command)
snapshot = configs_adapter.CONFIGS_BROWSER_ADAPTER.collect_snapshot('dev', query='site')
assert [record.title for record in snapshot.records] == ['site_name']
assert snapshot.search is not None
assert snapshot.search.query == 'site'
@@ -0,0 +1,439 @@
from types import ModuleType, SimpleNamespace
from pytest import MonkeyPatch
def test_collect_jobs_page_snapshot_links_first_job_detail_and_logs(
monkeypatch: MonkeyPatch,
jobs_adapter: ModuleType,
) -> None:
calls: list[tuple[str, ...]] = []
def fake_run_nested_cli_command(*arguments: str, parse_json: bool = False) -> SimpleNamespace:
del parse_json
calls.append(arguments)
if arguments[0:2] == ('job', 'list'):
return SimpleNamespace(
payload={
'ok': True,
'page': {
'rows': [
{
'jobId': 101,
'jobName': '同步任务',
'status': '0',
'cronExpression': '0/30 * * * * ?',
}
]
},
}
)
if arguments[0:2] == ('job', 'detail'):
return SimpleNamespace(
payload={
'ok': True,
'job': {
'jobId': 101,
'jobName': '同步任务',
'jobGroup': 'DEFAULT',
'status': '0',
'cronExpression': '0/30 * * * * ?',
'invokeTarget': 'demo.sync',
},
}
)
if arguments[0:2] == ('job', 'logs') and '--status=1' in arguments:
return SimpleNamespace(
payload={
'ok': True,
'page': {
'rows': [
{
'jobLogId': 2,
'jobName': '同步任务',
'status': '1',
'jobMessage': '执行失败',
'exceptionInfo': 'traceback...',
'createTime': '2026-04-30 10:01:00',
}
]
},
}
)
return SimpleNamespace(
payload={
'ok': True,
'page': {
'rows': [
{
'jobLogId': 1,
'jobName': '同步任务',
'status': '0',
'jobMessage': '执行成功',
'createTime': '2026-04-30 10:00:00',
}
]
},
}
)
monkeypatch.setattr(jobs_adapter.NESTED_CLI_SUPPORT, 'run', fake_run_nested_cli_command)
snapshot = jobs_adapter.JOBS_BROWSER_ADAPTER.collect_snapshot('dev')
assert snapshot.title == '任务'
assert (
snapshot.subtitle
== '聚焦:失败聚合 / 暂停任务 / 执行轨迹 | 当前筛选:全部,已匹配 1 条任务,失败任务 1 个,暂停任务 0 个'
)
assert len(snapshot.records) == 1
assert snapshot.records[0].title == '同步任务'
assert snapshot.shared_sections[0].title == '总览判断'
assert any('存在失败任务' in line for line in snapshot.shared_sections[0].lines)
assert any('失败任务: 1 个' in line for line in snapshot.shared_sections[0].lines)
assert snapshot.shared_sections[1].title == '失败聚合'
assert any('失败日志: 1 条' in line for line in snapshot.shared_sections[1].lines)
assert any('同步任务 · 1 次' in line for line in snapshot.shared_sections[1].lines)
detail_sections = snapshot.records[0].resolve_detail_sections()
assert [section.title for section in detail_sections] == [
'任务摘要',
'调度配置',
'执行摘要',
'最近执行记录',
'失败执行记录',
]
assert any('成功率信号:' in line for line in detail_sections[2].lines)
assert any('最近一次执行:' in line for line in detail_sections[2].lines)
assert any('执行轨道:' in line for line in detail_sections[2].lines)
assert any('轨道窗口:' in line for line in detail_sections[2].lines)
assert any('2026-04-30 10:00:00' in line for line in detail_sections[3].lines)
assert any('o 状态: 成功' in line for line in detail_sections[3].lines)
assert any('轨道: ├─采集 ├─执行 └─落盘' in line for line in detail_sections[3].lines)
assert any('轨迹: ●─●─◎' in line for line in detail_sections[3].lines)
assert any('异常: traceback...' in line for line in detail_sections[4].lines)
assert any(call[0:2] == ('job', 'detail') for call in calls)
assert any(call[0:2] == ('job', 'logs') and '--job-name=同步任务' in call for call in calls)
assert any(call[0:2] == ('job', 'logs') and '--status=1' in call for call in calls)
def test_collect_jobs_page_snapshot_applies_failed_filter(
monkeypatch: MonkeyPatch,
jobs_adapter: ModuleType,
) -> None:
def fake_run_nested_cli_command(*arguments: str, parse_json: bool = False) -> SimpleNamespace:
del parse_json
if arguments[0:2] == ('job', 'list'):
return SimpleNamespace(
payload={
'ok': True,
'page': {
'rows': [
{
'jobId': 101,
'jobName': '失败任务',
'status': '0',
'cronExpression': '0/30 * * * * ?',
},
{
'jobId': 102,
'jobName': '暂停任务',
'status': '1',
'cronExpression': '0/45 * * * * ?',
},
{
'jobId': 103,
'jobName': '正常任务',
'status': '0',
'cronExpression': '0/50 * * * * ?',
},
]
},
}
)
if arguments[0:2] == ('job', 'logs') and '--status=1' in arguments:
return SimpleNamespace(
payload={
'ok': True,
'page': {
'rows': [
{
'jobLogId': 2,
'jobName': '失败任务',
'status': '1',
'jobMessage': '执行失败',
'createTime': '2026-04-30 10:01:00',
}
]
},
}
)
return SimpleNamespace(payload={'ok': True, 'page': {'rows': []}})
monkeypatch.setattr(jobs_adapter.NESTED_CLI_SUPPORT, 'run', fake_run_nested_cli_command)
snapshot = jobs_adapter.JOBS_BROWSER_ADAPTER.collect_snapshot('dev', filter_key='failed')
assert snapshot.active_filter_key == 'failed'
assert [option.key for option in snapshot.filters] == ['all', 'failed', 'paused', 'ok']
assert len(snapshot.records) == 1
assert snapshot.records[0].title == '失败任务'
assert '当前筛选:失败' in snapshot.subtitle
assert snapshot.shared_sections[0].title == '总览判断'
assert any('当前筛选: 失败' in line for line in snapshot.shared_sections[0].lines)
assert snapshot.shared_sections[1].title == '失败聚合'
assert any('涉及任务: 1 个' in line for line in snapshot.shared_sections[1].lines)
assert snapshot.search is not None
assert snapshot.search.placeholder == '按任务名搜索'
def test_collect_jobs_page_snapshot_applies_query_filter(
monkeypatch: MonkeyPatch,
jobs_adapter: ModuleType,
) -> None:
def fake_run_nested_cli_command(*arguments: str, parse_json: bool = False) -> SimpleNamespace:
del parse_json
if arguments[0:2] == ('job', 'list'):
return SimpleNamespace(
payload={
'ok': True,
'page': {
'rows': [
{
'jobId': 1,
'jobName': 'sync-user',
'jobGroup': 'default',
'status': '0',
'cronExpression': '*',
},
{
'jobId': 2,
'jobName': 'clean-cache',
'jobGroup': 'ops',
'status': '0',
'cronExpression': '*',
},
]
},
}
)
return SimpleNamespace(payload={'ok': True, 'page': {'rows': []}})
monkeypatch.setattr(jobs_adapter.NESTED_CLI_SUPPORT, 'run', fake_run_nested_cli_command)
snapshot = jobs_adapter.JOBS_BROWSER_ADAPTER.collect_snapshot('dev', query='sync')
assert [record.title for record in snapshot.records] == ['sync-user']
assert snapshot.search is not None
assert snapshot.search.query == 'sync'
def test_collect_jobs_page_snapshot_surfaces_failure_message(
monkeypatch: MonkeyPatch,
jobs_adapter: ModuleType,
) -> None:
def fake_run_nested_cli_command(*arguments: str, parse_json: bool = False) -> SimpleNamespace:
del arguments, parse_json
return SimpleNamespace(
payload={
'ok': False,
'message': '读取定时任务列表失败',
'error': 'database unavailable',
}
)
monkeypatch.setattr(jobs_adapter.NESTED_CLI_SUPPORT, 'run', fake_run_nested_cli_command)
snapshot = jobs_adapter.JOBS_BROWSER_ADAPTER.collect_snapshot('dev')
assert snapshot.subtitle.startswith('任务数据不可用:')
assert snapshot.records[0].status == 'fail'
assert any('读取定时任务列表失败' in line for line in snapshot.records[0].detail_sections[0].lines)
def test_collect_gen_page_snapshot_links_first_table_detail(
monkeypatch: MonkeyPatch,
gen_adapter: ModuleType,
) -> None:
calls: list[tuple[str, ...]] = []
def fake_run_nested_cli_command(*arguments: str, parse_json: bool = False) -> SimpleNamespace:
del parse_json
calls.append(arguments)
if arguments[0:2] == ('gen', 'list'):
return SimpleNamespace(
payload={
'ok': True,
'page': {
'rows': [
{
'tableId': 201,
'tableName': 'sys_user',
'className': 'SysUser',
'moduleName': 'system',
}
]
},
}
)
if arguments[0:2] == ('gen', 'detail'):
return SimpleNamespace(
payload={
'ok': True,
'tableId': 201,
'tableName': 'sys_user',
'columnCount': 12,
'detail': {
'info': {
'className': 'SysUser',
'moduleName': 'system',
'businessName': 'user',
'functionName': '用户管理',
},
'rows': [
{
'columnName': 'user_id',
'columnType': 'bigint',
'isPk': '1',
'isRequired': '1',
'queryType': 'EQ',
}
],
},
}
)
if arguments[0:2] == ('gen', 'preview'):
return SimpleNamespace(
payload={
'ok': True,
'tableId': 201,
'templateCount': 2,
'preview': {
'api.py.vm': 'def list_users():\n return []',
'index.vue.vm': '<template>\n <div />\n</template>',
},
}
)
if arguments[0:2] == ('gen', 'export'):
return SimpleNamespace(
payload={
'ok': True,
'env': 'dev',
'mode': 'zip',
'dryRun': True,
'message': '代码导出预演完成',
'tableNames': ['sys_user'],
'results': [
{
'tableName': 'sys_user',
'ok': True,
'message': '已生成 6 份模板预演结果',
}
],
}
)
if arguments[0:2] == ('gen', 'db-list') and '--table-name=sys_user' in arguments:
return SimpleNamespace(
payload={
'ok': True,
'page': {
'rows': [
{
'tableName': 'sys_user',
'tableComment': '用户表',
}
]
},
}
)
return SimpleNamespace(
payload={
'ok': True,
'page': {
'rows': [
{
'tableName': 'sys_role',
'tableComment': '角色表',
}
]
},
}
)
monkeypatch.setattr(gen_adapter.NESTED_CLI_SUPPORT, 'run', fake_run_nested_cli_command)
snapshot = gen_adapter.GEN_BROWSER_ADAPTER.collect_snapshot('dev')
assert snapshot.title == '代码生成'
assert (
snapshot.subtitle
== '聚焦:生成前校验 / 同步预检查 / 代码预览 | 当前已匹配 1 张业务表,可导入物理表 1 张,可继续查看表定义、预检查和代码预览'
)
assert len(snapshot.records) == 1
assert snapshot.records[0].title == 'sys_user'
detail_sections = snapshot.records[0].resolve_detail_sections()
assert [section.title for section in detail_sections] == [
'业务表摘要',
'生成配置',
'字段摘要',
'生成前校验',
'同步预检查',
'字段列表',
'代码预览',
'导出预览',
'导出入口',
]
assert any('模板预览: 2 份' in line for line in detail_sections[3].lines)
assert any('数据库物理表: sys_user' in line for line in detail_sections[4].lines)
assert any('模板数量: 2' in line for line in detail_sections[6].lines)
assert any('api.py.vm' in line for line in detail_sections[6].lines)
assert any('代码导出预演完成' in line for line in detail_sections[7].lines)
assert any('wizard gen-export' in line for line in detail_sections[8].lines)
assert snapshot.shared_sections[0].title == '总览判断'
assert any('可导入物理表: 1 张' in line for line in snapshot.shared_sections[0].lines)
assert any('代码预览 / 导出预览 / 导入入口 / 建表入口' in line for line in snapshot.shared_sections[0].lines)
assert snapshot.shared_sections[1].title == '可导入数据表'
assert snapshot.shared_sections[2].title == '导入入口'
assert any(
'gen import-table sys_role --dry-run --output=text' in line for line in snapshot.shared_sections[2].lines
)
assert snapshot.shared_sections[3].title == '建表入口'
assert any('gen create-table --dry-run --sql' in line for line in snapshot.shared_sections[3].lines)
assert any(call[0:2] == ('gen', 'detail') for call in calls)
assert any(call[0:2] == ('gen', 'preview') for call in calls)
assert any(call[0:2] == ('gen', 'export') and '--dry-run' in call for call in calls)
assert any(call[0:2] == ('gen', 'db-list') and '--table-name=sys_user' in call for call in calls)
def test_gen_browser_adapter_collect_snapshot_exposes_search_context(
monkeypatch: MonkeyPatch,
gen_adapter: ModuleType,
) -> None:
def fake_run_nested_cli_command(*arguments: str, parse_json: bool = False) -> SimpleNamespace:
del parse_json
if arguments[0:2] == ('gen', 'list'):
return SimpleNamespace(
payload={
'ok': True,
'page': {
'rows': [
{
'tableId': 201,
'tableName': 'sys_user',
'className': 'SysUser',
'moduleName': 'system',
}
]
},
}
)
return SimpleNamespace(payload={'ok': True, 'page': {'rows': []}})
monkeypatch.setattr(gen_adapter.NESTED_CLI_SUPPORT, 'run', fake_run_nested_cli_command)
snapshot = gen_adapter.GEN_BROWSER_ADAPTER.collect_snapshot('dev', query='sys')
assert snapshot.title == '代码生成'
assert snapshot.search is not None
assert snapshot.search.query == 'sys'
assert snapshot.records
assert snapshot.records[0].title == 'sys_user'
@@ -0,0 +1,64 @@
import importlib
import sys
from pathlib import Path
from types import SimpleNamespace
import pytest
BACKEND_DIR = Path(__file__).resolve().parents[3]
def _ensure_backend_on_path() -> None:
if str(BACKEND_DIR) not in sys.path:
sys.path.insert(0, str(BACKEND_DIR))
@pytest.fixture
def tui_base_modules() -> SimpleNamespace:
_ensure_backend_on_path()
sys.modules.pop('cli.main', None)
sys.modules.pop('cli.tui.commands', None)
sys.modules.pop('cli', None)
dependency_error = importlib.import_module('cli.exit_codes').DEPENDENCY_ERROR
cli_main = importlib.import_module('cli.main')
cli_tui_commands = importlib.import_module('cli.tui.commands')
return SimpleNamespace(
BACKEND_DIR=BACKEND_DIR,
DEPENDENCY_ERROR=dependency_error,
cli_main=cli_main,
cli_tui_commands=cli_tui_commands,
)
@pytest.fixture
def tui_modules(tui_base_modules: SimpleNamespace) -> SimpleNamespace:
pytest.importorskip('textual')
_ensure_backend_on_path()
for module_name in (
'cli.tui.app',
'cli.tui.screens.browser',
'cli.tui.copy',
'cli.tui.screens.dashboard',
'cli.tui.screens.detail',
'cli.tui.screens.interactions',
'cli.tui.adapters',
'cli.tui.search',
'cli.tui.widgets',
'cli',
):
sys.modules.pop(module_name, None)
return SimpleNamespace(
**tui_base_modules.__dict__,
cli_tui_app=importlib.import_module('cli.tui.app'),
cli_tui_browser=importlib.import_module('cli.tui.screens.browser'),
cli_tui_copy=importlib.import_module('cli.tui.copy'),
cli_tui_dashboard=importlib.import_module('cli.tui.screens.dashboard'),
cli_tui_detail=importlib.import_module('cli.tui.screens.detail'),
cli_tui_interactions=importlib.import_module('cli.tui.screens.interactions'),
cli_tui_adapters=importlib.import_module('cli.tui.adapters'),
cli_tui_search=importlib.import_module('cli.tui.search'),
cli_tui_widgets=importlib.import_module('cli.tui.widgets'),
)
@@ -0,0 +1,575 @@
from types import SimpleNamespace
from typing import Any
import pytest
from pytest import MonkeyPatch
def test_browser_screen_builds_sections_from_selected_record(tui_modules: SimpleNamespace) -> None:
snapshot = tui_modules.cli_tui_adapters.BrowserPageSnapshot(
title='Jobs',
subtitle='subtitle',
records=[
tui_modules.cli_tui_adapters.BrowserRecordSnapshot(
key='job:1',
title='Job A',
status='ok',
summary='summary a',
metadata_lines=[],
detail_sections=[
tui_modules.cli_tui_adapters.DetailSectionSnapshot(
title='Job Detail',
status='ok',
lines=['detail a'],
)
],
)
],
shared_sections=[
tui_modules.cli_tui_adapters.DetailSectionSnapshot(title='Recent Logs', status='warn', lines=['log a'])
],
)
screen = tui_modules.cli_tui_browser.BrowserScreen(
snapshot,
env='dev',
active_view='jobs',
navigation_items=tui_modules.cli_tui_app.NAVIGATION_ITEMS,
refreshed_at='2026-04-30 10:00:00',
)
sections = screen._get_sections_for_record(0)
assert [section.title for section in sections] == ['Job Detail', 'Recent Logs']
def test_browser_screen_resolves_job_actions(tui_modules: SimpleNamespace) -> None:
snapshot = tui_modules.cli_tui_adapters.BrowserPageSnapshot(
title='Jobs',
subtitle='subtitle',
records=[
tui_modules.cli_tui_adapters.BrowserRecordSnapshot(
key='job:1',
title='Job A',
status='ok',
summary='正常 · Cron 0/30 * * * * ?',
metadata_lines=[],
detail_sections=[],
)
],
shared_sections=[],
)
screen = tui_modules.cli_tui_browser.BrowserScreen(
snapshot,
env='dev',
active_view='jobs',
navigation_items=tui_modules.cli_tui_app.NAVIGATION_ITEMS,
refreshed_at='2026-04-30 10:00:00',
)
primary_action = screen._resolve_action('primary')
secondary_action = screen._resolve_action('secondary')
global_action = screen._resolve_action('global')
assert primary_action is not None
assert primary_action.command_args == ('job', 'run-once', '1')
assert secondary_action is not None
assert secondary_action.command_args == ('job', 'pause', '1')
assert global_action is not None
assert global_action.command_args == ('job', 'sync')
def test_browser_screen_builds_action_panel_with_feedback(tui_modules: SimpleNamespace) -> None:
snapshot = tui_modules.cli_tui_adapters.BrowserPageSnapshot(
title='Jobs',
subtitle='subtitle',
records=[
tui_modules.cli_tui_adapters.BrowserRecordSnapshot(
key='job:1',
title='Job A',
status='ok',
summary='正常 · Cron 0/30 * * * * ?',
metadata_lines=[],
detail_sections=[],
)
],
shared_sections=[],
)
screen = tui_modules.cli_tui_browser.BrowserScreen(
snapshot,
env='dev',
active_view='jobs',
navigation_items=tui_modules.cli_tui_app.NAVIGATION_ITEMS,
refreshed_at='2026-04-30 10:00:00',
)
screen._action_feedback_lines = ['动作名称: 执行一次任务', '结果: 成功']
lines = screen._build_record_action_lines(snapshot.records[0])
assert any('动作键 [X] · 执行一次任务' in line for line in lines)
assert any('浏览操作' in line for line in lines)
assert any('最近动作反馈' in line for line in lines)
assert any('结果: 成功' in line for line in lines)
@pytest.mark.asyncio
async def test_browser_screen_execute_external_action_suspends_app(
monkeypatch: MonkeyPatch,
tui_modules: SimpleNamespace,
) -> None:
snapshot = tui_modules.cli_tui_adapters.BrowserPageSnapshot(
title='代码生成',
subtitle='subtitle',
records=[
tui_modules.cli_tui_adapters.BrowserRecordSnapshot(
key='gen:201',
title='sys_user',
status='ok',
summary='生成类 SysUser · 模块 system',
metadata_lines=[],
detail_sections=[],
)
],
shared_sections=[],
)
screen = tui_modules.cli_tui_browser.BrowserScreen(
snapshot,
env='dev',
active_view='gen',
navigation_items=tui_modules.cli_tui_app.NAVIGATION_ITEMS,
refreshed_at='2026-04-30 10:00:00',
)
action = tui_modules.cli_tui_browser.TUI_ACTION_REGISTRY.resolve_browser_action(
view_key='gen',
slot='primary',
record=snapshot.records[0],
env='dev',
)
assert action is not None
recorded_suspend: list[str] = []
class DummySuspend:
def __enter__(self) -> None:
recorded_suspend.append('enter')
def __exit__(
self,
exc_type: type[BaseException] | None,
exc: BaseException | None,
tb: Any,
) -> None:
del exc_type, exc, tb
recorded_suspend.append('exit')
def build_suspend() -> DummySuspend:
return DummySuspend()
def refresh_current_view() -> None:
pass
fake_app = SimpleNamespace(
suspend=build_suspend,
action_refresh_current_view=refresh_current_view,
)
monkeypatch.setattr(tui_modules.cli_tui_browser.BrowserScreen, 'app', property(lambda self: fake_app))
monkeypatch.setattr(
tui_modules.cli_tui_interactions,
'TUI_ACTION_EXECUTION_SERVICE',
SimpleNamespace(
execute_external=lambda spec: tui_modules.cli_tui_browser.TuiActionResult(
spec=spec,
external_exit_code=0,
external_message='外部交互命令已执行完成',
),
build_result_lines=lambda result: [],
),
)
monkeypatch.setattr(screen, 'notify', lambda *args, **kwargs: None)
async def fake_render_record_detail(*, eager: bool = False) -> None:
del eager
monkeypatch.setattr(screen, '_render_record_detail', fake_render_record_detail)
await screen._execute_action(action)
assert recorded_suspend == ['enter', 'exit']
def test_browser_screen_filter_shortcut_remembers_and_refreshes(
monkeypatch: MonkeyPatch,
tui_modules: SimpleNamespace,
) -> None:
snapshot = tui_modules.cli_tui_adapters.BrowserPageSnapshot(
title='任务',
subtitle='subtitle',
records=[
tui_modules.cli_tui_adapters.BrowserRecordSnapshot(
key='job:1',
title='Job A',
status='ok',
summary='正常 · Cron 0/30 * * * * ?',
metadata_lines=[],
detail_sections=[],
)
],
shared_sections=[],
filters=list(tui_modules.cli_tui_search.JOB_FILTER_OPTIONS),
active_filter_key='all',
)
screen = tui_modules.cli_tui_browser.BrowserScreen(
snapshot,
env='dev',
active_view='jobs',
navigation_items=tui_modules.cli_tui_app.NAVIGATION_ITEMS,
refreshed_at='2026-04-30 10:00:00',
)
recorded_filters: list[tuple[str, str]] = []
refresh_calls: list[str] = []
fake_app = SimpleNamespace(
remember_browser_filter=lambda view_key, filter_key: recorded_filters.append((view_key, filter_key)),
action_refresh_current_view=lambda: refresh_calls.append('refresh'),
)
monkeypatch.setattr(tui_modules.cli_tui_browser.BrowserScreen, 'app', property(lambda self: fake_app))
screen.action_apply_filter_2()
assert recorded_filters == [('jobs', 'failed')]
assert refresh_calls == ['refresh']
@pytest.mark.asyncio
async def test_browser_screen_detail_loader_failure_falls_back_to_failure_section(
tui_modules: SimpleNamespace,
) -> None:
snapshot = tui_modules.cli_tui_adapters.BrowserPageSnapshot(
title='任务',
subtitle='subtitle',
records=[
tui_modules.cli_tui_adapters.BrowserRecordSnapshot(
key='job:1',
title='任务一',
status='warn',
summary='摘要',
metadata_lines=[],
detail_sections=[],
detail_loader=lambda: (_ for _ in ()).throw(RuntimeError('boom')),
)
],
shared_sections=[],
)
screen = tui_modules.cli_tui_browser.BrowserScreen(
snapshot,
env='dev',
active_view='jobs',
navigation_items=tui_modules.cli_tui_app.NAVIGATION_ITEMS,
refreshed_at='2026-04-30 10:00:00',
)
rendered_calls: list[bool] = []
async def fake_render_record_detail(*, eager: bool = False) -> None:
del eager
rendered_calls.append(True)
screen._render_record_detail = fake_render_record_detail # type: ignore[method-assign]
screen._record_detail_request_id = 1
await screen._load_record_detail_async(0, 1)
cached_sections = snapshot.records[0]._cached_detail_sections
assert rendered_calls == [True]
assert cached_sections is not None
assert len(cached_sections) == 1
assert cached_sections[0].status == 'fail'
assert cached_sections[0].title == '任务详情加载失败'
assert any('boom' in line for line in cached_sections[0].lines)
def test_browser_screen_search_submit_remembers_and_refreshes(
monkeypatch: MonkeyPatch,
tui_modules: SimpleNamespace,
) -> None:
snapshot = tui_modules.cli_tui_adapters.BrowserPageSnapshot(
title='任务',
subtitle='subtitle',
records=[],
shared_sections=[],
filters=list(tui_modules.cli_tui_search.JOB_FILTER_OPTIONS),
active_filter_key='all',
search=tui_modules.cli_tui_search.PageSearchContext(
placeholder='按任务名搜索',
query='',
suggestions=['sync-user'],
),
)
screen = tui_modules.cli_tui_browser.BrowserScreen(
snapshot,
env='dev',
active_view='jobs',
navigation_items=tui_modules.cli_tui_app.NAVIGATION_ITEMS,
refreshed_at='2026-04-30 10:00:00',
)
recorded_queries: list[tuple[str, str]] = []
refresh_calls: list[str] = []
fake_app = SimpleNamespace(
remember_browser_query=lambda view_key, query: recorded_queries.append((view_key, query)),
action_refresh_current_view=lambda: refresh_calls.append('refresh'),
)
monkeypatch.setattr(tui_modules.cli_tui_browser.BrowserScreen, 'app', property(lambda self: fake_app))
screen._handle_search_submitted('sync')
assert recorded_queries == [('jobs', 'sync')]
assert refresh_calls == ['refresh']
def test_browser_screen_support_builds_summary_actions_and_fallbacks(
tui_modules: SimpleNamespace,
) -> None:
support = tui_modules.cli_tui_browser.BrowserScreenSupport()
snapshot = tui_modules.cli_tui_adapters.BrowserPageSnapshot(
title='任务',
subtitle='subtitle',
records=[
tui_modules.cli_tui_adapters.BrowserRecordSnapshot(
key='job:1',
title='Job A',
status='ok',
summary='正常 · Cron 0/30 * * * * ?',
metadata_lines=[],
detail_sections=[],
)
],
shared_sections=[],
filters=list(tui_modules.cli_tui_search.JOB_FILTER_OPTIONS),
active_filter_key='all',
search=tui_modules.cli_tui_search.PageSearchContext(
placeholder='按任务名搜索',
query='sync',
suggestions=['sync-user'],
),
)
summary = support.build_summary_text(snapshot, 'jobs')
filter_bar = support.build_filter_bar_text(snapshot)
action = support.resolve_action(active_view='jobs', slot='primary', record=snapshot.records[0], env='dev')
action_lines = support.build_record_action_lines(
active_view='jobs',
record=snapshot.records[0],
env='dev',
feedback_lines=['结果: 成功'],
)
empty_record = support.build_empty_record()
loading_section = support.build_loading_section()
failure_sections = support.build_detail_load_failure_sections(RuntimeError('boom'), '任务详情')
empty_section = support.build_empty_section()
assert '当前页共 1 条记录' in summary
assert '当前搜索 · sync' in filter_bar
assert action is not None
assert action.command_args == ('job', 'run-once', '1')
assert any('结果: 成功' in line for line in action_lines)
assert empty_record.title == tui_modules.cli_tui_copy.TUI_COPY.build_browser_empty_record_copy('title')
assert loading_section.title == tui_modules.cli_tui_copy.TUI_COPY.build_browser_loading_copy('title')
assert failure_sections[0].status == 'fail'
assert failure_sections[0].title == '任务详情加载失败'
assert empty_section.title == tui_modules.cli_tui_copy.TUI_COPY.build_detail_empty_section_copy('title')
def test_browser_screen_focus_service_moves_and_scrolls_between_focus_targets(
tui_modules: SimpleNamespace,
) -> None:
focus_service = tui_modules.cli_tui_browser.BrowserScreenFocusService()
focused: list[str] = []
sidebar = SimpleNamespace(focus=lambda: focused.append('sidebar'))
navigator = SimpleNamespace(focus=lambda: focused.append('records'))
sections = SimpleNamespace(focus=lambda: focused.append('sections'))
summary = SimpleNamespace(focus=lambda: focused.append('summary'))
detail = SimpleNamespace(focus=lambda: focused.append('detail'))
workspace_main = object()
fake_app = SimpleNamespace(focused=None)
fake_screen = SimpleNamespace(
app=fake_app,
query_one=lambda selector, widget_type=None: {
tui_modules.cli_tui_widgets.WorkspaceSidebar: sidebar,
tui_modules.cli_tui_widgets.RecordNavigator: navigator,
tui_modules.cli_tui_widgets.SectionNavigator: sections,
tui_modules.cli_tui_widgets.RecordSummaryView: summary,
tui_modules.cli_tui_widgets.SectionDetailView: detail,
'#workspace-main': workspace_main,
}[selector],
)
focus_service.move_focus(fake_screen, 1)
fake_app.focused = navigator
focus_service.move_focus(fake_screen, 1)
fake_app.focused = object()
scroll_target = focus_service.get_scroll_target(fake_screen)
assert focused == ['sidebar', 'sections']
assert scroll_target is workspace_main
def test_browser_screen_clear_search_remembers_empty_and_refreshes(
monkeypatch: MonkeyPatch,
tui_modules: SimpleNamespace,
) -> None:
snapshot = tui_modules.cli_tui_adapters.BrowserPageSnapshot(
title='任务',
subtitle='subtitle',
records=[],
shared_sections=[],
filters=list(tui_modules.cli_tui_search.JOB_FILTER_OPTIONS),
active_filter_key='all',
search=tui_modules.cli_tui_search.PageSearchContext(
placeholder='按任务名搜索',
query='sync',
suggestions=['sync-user'],
),
)
screen = tui_modules.cli_tui_browser.BrowserScreen(
snapshot,
env='dev',
active_view='jobs',
navigation_items=tui_modules.cli_tui_app.NAVIGATION_ITEMS,
refreshed_at='2026-04-30 10:00:00',
)
recorded_queries: list[tuple[str, str]] = []
refresh_calls: list[str] = []
fake_app = SimpleNamespace(
remember_browser_query=lambda view_key, query: recorded_queries.append((view_key, query)),
action_refresh_current_view=lambda: refresh_calls.append('refresh'),
)
monkeypatch.setattr(tui_modules.cli_tui_browser.BrowserScreen, 'app', property(lambda self: fake_app))
screen.action_clear_search()
assert recorded_queries == [('jobs', '')]
assert refresh_calls == ['refresh']
def test_browser_screen_unmount_cancels_background_tasks(tui_modules: SimpleNamespace) -> None:
snapshot = tui_modules.cli_tui_adapters.BrowserPageSnapshot(
title='缓存',
subtitle='subtitle',
records=[],
shared_sections=[],
)
screen = tui_modules.cli_tui_browser.BrowserScreen(
snapshot,
env='dev',
active_view='cache',
navigation_items=tui_modules.cli_tui_app.NAVIGATION_ITEMS,
refreshed_at='2026-04-30 10:00:00',
)
cancelled: list[str] = []
class DummyTask:
def __init__(self, name: str) -> None:
self.name = name
def done(self) -> bool:
return False
def cancel(self) -> None:
cancelled.append(self.name)
screen._record_detail_task = DummyTask('detail') # type: ignore[assignment]
screen._action_task = DummyTask('action') # type: ignore[assignment]
request_id = screen._record_detail_request_id
screen.on_unmount()
assert cancelled == ['detail', 'action']
assert screen._record_detail_request_id == request_id + 1
def test_browser_screen_failure_sections_use_current_snapshot_title(tui_modules: SimpleNamespace) -> None:
snapshot = tui_modules.cli_tui_adapters.BrowserPageSnapshot(
title='缓存',
subtitle='subtitle',
records=[],
shared_sections=[],
)
screen = tui_modules.cli_tui_browser.BrowserScreen(
snapshot,
env='dev',
active_view='cache',
navigation_items=tui_modules.cli_tui_app.NAVIGATION_ITEMS,
refreshed_at='2026-04-30 10:00:00',
)
sections = screen._build_detail_load_failure_sections(RuntimeError('boom'))
assert sections[0].title == '缓存详情加载失败'
@pytest.mark.asyncio
async def test_browser_screen_scrolls_in_small_viewport(tui_modules: SimpleNamespace) -> None:
thin_scrollbar_size = 1
app = tui_modules.cli_tui_app.ShierfengTuiApp('dev')
app.action_show_dashboard = lambda: None # type: ignore[method-assign]
app.current_view = 'jobs'
snapshot = tui_modules.cli_tui_adapters.BrowserPageSnapshot(
title='任务',
subtitle='超长浏览',
records=[
tui_modules.cli_tui_adapters.BrowserRecordSnapshot(
key='job:1',
title='任务一',
status='warn',
summary='摘要很长 摘要很长 摘要很长',
metadata_lines=[f'字段{i}: 值很长 值很长 值很长' for i in range(1, 18)],
detail_sections=[
tui_modules.cli_tui_adapters.DetailSectionSnapshot(
title='明细分区',
status='warn',
lines=[f'明细{i}: 说明很长 说明很长 说明很长' for i in range(1, 30)],
)
],
)
for _ in range(6)
],
shared_sections=[],
)
screen = app.screen_factory.build(
snapshot=snapshot,
env=app.env,
active_view=app.current_view,
navigation_items=tui_modules.cli_tui_app.NAVIGATION_ITEMS,
action_feedback_lines=app.get_action_feedback_lines(app.current_view),
)
async with app.run_test(size=(80, 24)) as pilot:
app.screen_navigator.show(screen)
await pilot.pause()
await pilot.pause()
main = app.screen.query_one('#workspace-main')
browser_body = app.screen.query_one('#browser-body')
browser_detail_pane = app.screen.query_one('#browser-detail-pane')
record_navigator = app.screen.query_one(tui_modules.cli_tui_widgets.RecordNavigator)
record_summary = app.screen.query_one(tui_modules.cli_tui_widgets.RecordSummaryView)
section_detail = app.screen.query_one(tui_modules.cli_tui_widgets.SectionDetailView)
assert main.styles.scrollbar_size_vertical == thin_scrollbar_size
assert main.styles.scrollbar_size_horizontal == thin_scrollbar_size
assert main.virtual_size.height > main.size.height
assert browser_body.styles.scrollbar_size_vertical == thin_scrollbar_size
assert browser_body.styles.scrollbar_size_horizontal == thin_scrollbar_size
assert browser_body.virtual_size.width > browser_body.size.width
assert browser_detail_pane.styles.scrollbar_size_vertical == thin_scrollbar_size
assert browser_detail_pane.styles.scrollbar_size_horizontal == thin_scrollbar_size
assert browser_detail_pane.virtual_size.height > browser_detail_pane.size.height
assert record_navigator.styles.scrollbar_size_vertical == thin_scrollbar_size
assert record_navigator.styles.scrollbar_size_horizontal == thin_scrollbar_size
assert record_summary.styles.scrollbar_size_vertical == thin_scrollbar_size
assert record_summary.styles.scrollbar_size_horizontal == thin_scrollbar_size
assert section_detail.styles.scrollbar_size_vertical == thin_scrollbar_size
assert section_detail.styles.scrollbar_size_horizontal == thin_scrollbar_size
@@ -0,0 +1,490 @@
import importlib
from types import SimpleNamespace
from typing import Any
import pytest
import typer
from pytest import MonkeyPatch
from typer.testing import CliRunner
def test_tui_command_returns_dependency_error_when_textual_missing(
monkeypatch: MonkeyPatch,
tui_base_modules: SimpleNamespace,
) -> None:
runner = CliRunner()
monkeypatch.chdir(tui_base_modules.BACKEND_DIR)
def raise_missing_textual(module_name: str) -> Any:
if module_name == 'cli.tui.app':
raise ModuleNotFoundError("No module named 'textual'", name='textual')
return importlib.import_module(module_name)
monkeypatch.setattr(tui_base_modules.cli_tui_commands, 'import_module', raise_missing_textual)
isolated_cli = typer.Typer()
tui_base_modules.cli_tui_commands.TUI_COMMAND_REGISTRATION.register(isolated_cli)
result = runner.invoke(isolated_cli, ['tui', '--env=dev'])
assert result.exit_code == tui_base_modules.DEPENDENCY_ERROR
assert '当前环境未安装 TUI 可选依赖' in result.stdout
assert 'pip install -r requirements.txt' in result.stdout
def test_tui_app_mount_pushes_first_workspace_screen(
monkeypatch: MonkeyPatch,
tui_modules: SimpleNamespace,
) -> None:
recorded_calls: list[tuple[str, object]] = []
class FakeDashboardScreen:
def __init__(
self,
snapshot: object,
env: str,
active_view: str,
navigation_items: list[object],
refreshed_at: str,
) -> None:
self.snapshot = snapshot
self.env = env
self.active_view = active_view
self.navigation_items = navigation_items
self.refreshed_at = refreshed_at
monkeypatch.setitem(
tui_modules.cli_tui_app.TUI_SNAPSHOT_COLLECTOR_REGISTRY.collectors,
'dashboard',
lambda env: tui_modules.cli_tui_app.DashboardSnapshot(env=env, panels=[]),
)
monkeypatch.setattr(tui_modules.cli_tui_app, 'DashboardScreen', FakeDashboardScreen)
app = tui_modules.cli_tui_app.ShierfengTuiApp('dev')
monkeypatch.setattr(app, 'push_screen', lambda screen: recorded_calls.append(('push', screen)))
monkeypatch.setattr(app, 'switch_screen', lambda screen: recorded_calls.append(('switch', screen)))
app.on_mount()
assert recorded_calls
assert recorded_calls[0][0] == 'push'
assert isinstance(recorded_calls[0][1], FakeDashboardScreen)
assert recorded_calls[0][1].env == 'dev'
assert recorded_calls[0][1].active_view == 'dashboard'
assert recorded_calls[0][1].navigation_items
assert recorded_calls[0][1].refreshed_at
assert app.current_view == 'dashboard'
assert app.screen_navigator.initialized is True
def test_open_view_switches_to_expected_action(tui_modules: SimpleNamespace) -> None:
calls: list[str] = []
app = tui_modules.cli_tui_app.ShierfengTuiApp('dev')
def fake_show_view(view_key: str) -> None:
calls.append(tui_modules.cli_tui_app.TUI_VIEW_REGISTRY.resolve_view_key(view_key))
app.show_view = fake_show_view # type: ignore[method-assign]
app.open_view('jobs')
app.open_view('app')
app.open_view('ops')
app.open_view('crypto')
app.open_view('unknown')
assert calls == ['jobs', 'app', 'ops', 'crypto', 'dashboard']
def test_workspace_header_render_includes_particle_line(tui_modules: SimpleNamespace) -> None:
header = tui_modules.cli_tui_widgets.WorkspaceHeader('dev', 'dashboard')
rendered = header.render()
rendered_text = rendered.plain
assert rendered.__class__.__name__ == 'Text'
assert 'ShiErFeng 控制台' in rendered_text
assert '环境 DEV · 页面 总览' in rendered_text
assert any(symbol in rendered_text for symbol in ('█', '▓', '▒', '░', '·', '─'))
def test_workspace_header_centers_title_using_runtime_width(tui_modules: SimpleNamespace) -> None:
header = tui_modules.cli_tui_widgets.WorkspaceHeader('dev', 'dashboard')
header.styles.width = 140
first_line = header.render().plain.splitlines()[0]
assert first_line.startswith(' ')
assert 'ShiErFeng 控制台' in first_line
def test_workspace_header_keeps_title_alignment_stable_across_frames(
monkeypatch: MonkeyPatch,
tui_modules: SimpleNamespace,
) -> None:
header = tui_modules.cli_tui_widgets.WorkspaceHeader('dev', 'dashboard')
monotonic_values = iter([10.0, 10.0, 11.0, 11.0])
workspace_module = importlib.import_module('cli.tui.widgets.workspace')
monkeypatch.setattr(workspace_module, 'monotonic', lambda: next(monotonic_values))
first_line_a = header.render().plain.splitlines()[0]
first_line_b = header.render().plain.splitlines()[0]
assert first_line_a.index('ShiErFeng 控制台') == first_line_b.index('ShiErFeng 控制台')
def test_workspace_hero_pulses_border_with_expected_palette(
monkeypatch: MonkeyPatch,
tui_modules: SimpleNamespace,
) -> None:
hero = tui_modules.cli_tui_widgets.WorkspaceHero(
title='标题',
subtitle='副标题',
env='dev',
active_view='dashboard',
summary='摘要',
refreshed_at='2026-05-12 12:00:00',
)
workspace_module = importlib.import_module('cli.tui.widgets.workspace')
monkeypatch.setattr(workspace_module, 'monotonic', lambda: 10.0)
original_border_text = str(hero.styles.border)
hero._pulse_border()
border_text = str(hero.styles.border)
assert 'double' in border_text
assert border_text != original_border_text
def test_workspace_hero_render_uses_rich_text_for_title_glow(tui_modules: SimpleNamespace) -> None:
hero = tui_modules.cli_tui_widgets.WorkspaceHero(
title='标题',
subtitle='副标题',
env='dev',
active_view='dashboard',
summary='摘要',
refreshed_at='2026-05-12 12:00:00',
)
rendered = hero._build_render_text()
assert rendered.__class__.__name__ == 'Text'
assert '标题' in rendered.plain
assert '副标题' in rendered.plain
assert '运行摘要 · 摘要' in rendered.plain
def test_tui_app_runner_runs_created_application(tui_modules: SimpleNamespace) -> None:
recorded_envs: list[str] = []
recorded_runs: list[str] = []
class FakeApp:
def __init__(self, env: str) -> None:
recorded_envs.append(env)
def run(self) -> None:
recorded_runs.append('run')
runner = tui_modules.cli_tui_app.TuiAppRunner(FakeApp)
runner.run('dev')
assert recorded_envs == ['dev']
assert recorded_runs == ['run']
def test_tui_app_show_jobs_uses_remembered_filter(
monkeypatch: MonkeyPatch,
tui_modules: SimpleNamespace,
) -> None:
recorded_filter_keys: list[tuple[str, str]] = []
app = tui_modules.cli_tui_app.ShierfengTuiApp('dev')
app.remember_browser_filter('jobs', 'failed')
app.remember_browser_query('jobs', 'sync')
monkeypatch.setitem(
tui_modules.cli_tui_app.TUI_SNAPSHOT_COLLECTOR_REGISTRY.collectors,
'jobs',
lambda env, filter_key='all', query='': (
recorded_filter_keys.append((filter_key, query))
or tui_modules.cli_tui_app.BrowserPageSnapshot(
title='任务',
subtitle='subtitle',
records=[],
shared_sections=[],
filters=[],
active_filter_key=filter_key,
search=None,
)
),
)
monkeypatch.setattr(app.screen_navigator, 'show', lambda screen: None)
app.action_show_jobs()
assert recorded_filter_keys == [('failed', 'sync')]
def test_tui_app_show_configs_uses_remembered_filter(
monkeypatch: MonkeyPatch,
tui_modules: SimpleNamespace,
) -> None:
recorded_filter_keys: list[tuple[str, str]] = []
app = tui_modules.cli_tui_app.ShierfengTuiApp('dev')
app.remember_browser_filter('configs', 'cache-drift')
app.remember_browser_query('configs', 'site')
monkeypatch.setitem(
tui_modules.cli_tui_app.TUI_SNAPSHOT_COLLECTOR_REGISTRY.collectors,
'configs',
lambda env, filter_key='all', query='': (
recorded_filter_keys.append((filter_key, query))
or tui_modules.cli_tui_app.BrowserPageSnapshot(
title='参数配置',
subtitle='subtitle',
records=[],
shared_sections=[],
filters=[],
active_filter_key=filter_key,
search=None,
)
),
)
monkeypatch.setattr(app.screen_navigator, 'show', lambda screen: None)
app.action_show_configs()
assert recorded_filter_keys == [('cache-drift', 'site')]
def test_tui_app_show_cache_and_gen_use_remembered_query(
monkeypatch: MonkeyPatch,
tui_modules: SimpleNamespace,
) -> None:
cache_queries: list[str] = []
gen_queries: list[str] = []
app = tui_modules.cli_tui_app.ShierfengTuiApp('dev')
app.remember_browser_query('cache', 'sys')
app.remember_browser_query('gen', 'user')
monkeypatch.setitem(
tui_modules.cli_tui_app.TUI_SNAPSHOT_COLLECTOR_REGISTRY.collectors,
'cache',
lambda env, query='': (
cache_queries.append(query)
or tui_modules.cli_tui_app.BrowserPageSnapshot(
title='缓存',
subtitle='subtitle',
records=[],
shared_sections=[],
filters=[],
active_filter_key=None,
search=None,
)
),
)
monkeypatch.setitem(
tui_modules.cli_tui_app.TUI_SNAPSHOT_COLLECTOR_REGISTRY.collectors,
'gen',
lambda env, query='': (
gen_queries.append(query)
or tui_modules.cli_tui_app.BrowserPageSnapshot(
title='代码生成',
subtitle='subtitle',
records=[],
shared_sections=[],
filters=[],
active_filter_key=None,
search=None,
)
),
)
monkeypatch.setattr(app.screen_navigator, 'show', lambda screen: None)
app.action_show_cache()
app.action_show_gen()
assert cache_queries == ['sys']
assert gen_queries == ['user']
def test_tui_app_detail_views_use_remembered_query(
monkeypatch: MonkeyPatch,
tui_modules: SimpleNamespace,
) -> None:
database_queries: list[str] = []
app_queries: list[str] = []
ops_queries: list[str] = []
crypto_queries: list[str] = []
app = tui_modules.cli_tui_app.ShierfengTuiApp('dev')
app.remember_browser_query('database', 'head')
app.remember_browser_query('app', 'route')
app.remember_browser_query('ops', 'disk')
app.remember_browser_query('crypto', 'kid')
monkeypatch.setitem(
tui_modules.cli_tui_app.TUI_SNAPSHOT_COLLECTOR_REGISTRY.collectors,
'database',
lambda env, query='': (
database_queries.append(query)
or tui_modules.cli_tui_app.DetailPageSnapshot(title='数据库', subtitle='subtitle', sections=[])
),
)
monkeypatch.setitem(
tui_modules.cli_tui_app.TUI_SNAPSHOT_COLLECTOR_REGISTRY.collectors,
'app',
lambda env, query='': (
app_queries.append(query)
or tui_modules.cli_tui_app.DetailPageSnapshot(title='应用', subtitle='subtitle', sections=[])
),
)
monkeypatch.setitem(
tui_modules.cli_tui_app.TUI_SNAPSHOT_COLLECTOR_REGISTRY.collectors,
'ops',
lambda env, query='': (
ops_queries.append(query)
or tui_modules.cli_tui_app.DetailPageSnapshot(title='运维', subtitle='subtitle', sections=[])
),
)
monkeypatch.setitem(
tui_modules.cli_tui_app.TUI_SNAPSHOT_COLLECTOR_REGISTRY.collectors,
'crypto',
lambda env, query='': (
crypto_queries.append(query)
or tui_modules.cli_tui_app.DetailPageSnapshot(title='传输加密', subtitle='subtitle', sections=[])
),
)
monkeypatch.setattr(app.screen_navigator, 'show', lambda screen: None)
app.action_show_database()
app.action_show_app()
app.action_show_ops()
app.action_show_crypto()
assert database_queries == ['head']
assert app_queries == ['route']
assert ops_queries == ['disk']
assert crypto_queries == ['kid']
@pytest.mark.asyncio
async def test_tui_app_open_view_updates_screen_across_multiple_switches(
monkeypatch: MonkeyPatch,
tui_modules: SimpleNamespace,
) -> None:
monkeypatch.setitem(
tui_modules.cli_tui_app.TUI_SNAPSHOT_COLLECTOR_REGISTRY.collectors,
'dashboard',
lambda env: tui_modules.cli_tui_app.DashboardSnapshot(env=env, panels=[]),
)
monkeypatch.setitem(
tui_modules.cli_tui_app.TUI_SNAPSHOT_COLLECTOR_REGISTRY.collectors,
'app',
lambda env, query='': tui_modules.cli_tui_app.DetailPageSnapshot(
title='应用详情',
subtitle=query or 'app-subtitle',
sections=[
tui_modules.cli_tui_adapters.DetailSectionSnapshot(
title='应用分区',
status='ok',
lines=['app-line'],
)
],
),
)
monkeypatch.setitem(
tui_modules.cli_tui_app.TUI_SNAPSHOT_COLLECTOR_REGISTRY.collectors,
'ops',
lambda env, query='': tui_modules.cli_tui_app.DetailPageSnapshot(
title='运维详情',
subtitle=query or 'ops-subtitle',
sections=[
tui_modules.cli_tui_adapters.DetailSectionSnapshot(
title='运维分区',
status='warn',
lines=['ops-line'],
)
],
),
)
app = tui_modules.cli_tui_app.ShierfengTuiApp('dev')
async with app.run_test(size=(120, 40)) as pilot:
await pilot.pause()
await pilot.pause()
assert type(app.screen).__name__ == 'DashboardScreen'
assert app.current_view == 'dashboard'
app.open_view('app')
await pilot.pause()
await pilot.pause()
assert type(app.screen).__name__ == 'DetailScreen'
assert app.current_view == 'app'
assert app.screen.snapshot.title == '应用详情'
app.open_view('ops')
await pilot.pause()
await pilot.pause()
assert type(app.screen).__name__ == 'DetailScreen'
assert app.current_view == 'ops'
assert app.screen.snapshot.title == '运维详情'
@pytest.mark.asyncio
async def test_tui_sidebar_highlight_updates_screen_across_multiple_switches(
monkeypatch: MonkeyPatch,
tui_modules: SimpleNamespace,
) -> None:
monkeypatch.setitem(
tui_modules.cli_tui_app.TUI_SNAPSHOT_COLLECTOR_REGISTRY.collectors,
'dashboard',
lambda env: tui_modules.cli_tui_app.DashboardSnapshot(env=env, panels=[]),
)
monkeypatch.setitem(
tui_modules.cli_tui_app.TUI_SNAPSHOT_COLLECTOR_REGISTRY.collectors,
'app',
lambda env, query='': tui_modules.cli_tui_app.DetailPageSnapshot(
title='应用详情',
subtitle='app-subtitle',
sections=[
tui_modules.cli_tui_adapters.DetailSectionSnapshot(
title='应用分区',
status='ok',
lines=['app-line'],
)
],
),
)
monkeypatch.setitem(
tui_modules.cli_tui_app.TUI_SNAPSHOT_COLLECTOR_REGISTRY.collectors,
'ops',
lambda env, query='': tui_modules.cli_tui_app.DetailPageSnapshot(
title='运维详情',
subtitle='ops-subtitle',
sections=[
tui_modules.cli_tui_adapters.DetailSectionSnapshot(
title='运维分区',
status='warn',
lines=['ops-line'],
)
],
),
)
app = tui_modules.cli_tui_app.ShierfengTuiApp('dev')
async with app.run_test(size=(120, 40)) as pilot:
await pilot.pause()
await pilot.pause()
assert type(app.screen).__name__ == 'DashboardScreen'
assert app.current_view == 'dashboard'
await pilot.press('down')
await pilot.pause()
await pilot.pause()
assert type(app.screen).__name__ == 'DetailScreen'
assert app.current_view == 'app'
assert app.screen.snapshot.title == '应用详情'
await pilot.press('down')
await pilot.pause()
await pilot.pause()
assert type(app.screen).__name__ == 'DetailScreen'
assert app.current_view == 'ops'
assert app.screen.snapshot.title == '运维详情'
@@ -0,0 +1,192 @@
from types import SimpleNamespace
import pytest
def test_build_filter_bar_text_includes_search_context(tui_modules: SimpleNamespace) -> None:
text = tui_modules.cli_tui_search.TUI_SEARCH_SERVICE.build_filter_bar_text(
tui_modules.cli_tui_search.JOB_FILTER_OPTIONS,
'failed',
search_query='sync',
search_placeholder='按任务名搜索',
search_suggestions=['sync-job', 'sync-user'],
)
assert '当前筛选 · 失败' in text
assert '搜索器 · [/] 按任务名搜索' in text
assert '当前搜索 · sync | [Backspace] 清空' in text
assert '候选建议 · sync-job sync-user' in text
def test_tui_search_service_builds_filter_context_and_section_filtering(
tui_modules: SimpleNamespace,
) -> None:
service = tui_modules.cli_tui_search.TuiSearchService(
tui_modules.cli_tui_search.TuiSearchSuggestionProviderRegistry(
providers={
'jobs': tui_modules.cli_tui_search.SearchSuggestionProviderSpec(
'按任务名搜索',
lambda incomplete: ['sync-user', 'sync-role', 'cleanup'],
),
'app': tui_modules.cli_tui_search.SearchSuggestionProviderSpec('按分区或内容搜索'),
}
)
)
option = service.resolve_filter_option(tui_modules.cli_tui_search.JOB_FILTER_OPTIONS, 'failed')
filter_bar = service.build_filter_bar_text(
tui_modules.cli_tui_search.JOB_FILTER_OPTIONS,
'failed',
search_query='sync',
search_placeholder='按任务名搜索',
search_suggestions=['sync-user', 'sync-role'],
)
search_context = service.resolve_search_context('jobs', 'sync')
default_context = service.resolve_search_context('app', '总览', default_suggestions=['总览判断', '应用配置'])
sections = service.filter_detail_sections(
[
tui_modules.cli_tui_adapters.DetailSectionSnapshot(title='总览判断', status='ok', lines=['应用正常']),
tui_modules.cli_tui_adapters.DetailSectionSnapshot(title='路由摘要', status='warn', lines=['同步任务接口']),
],
'同步',
)
assert option is not None
assert option.key == 'failed'
assert '当前筛选 · 失败' in filter_bar
assert search_context is not None
assert search_context.placeholder == '按任务名搜索'
assert search_context.suggestions[:2] == ['sync-user', 'sync-role']
assert default_context is not None
assert default_context.suggestions == ['总览判断']
assert [section.title for section in sections] == ['路由摘要']
def test_tui_search_service_skips_provider_when_query_is_empty(
tui_modules: SimpleNamespace,
) -> None:
provider_calls: list[str] = []
def record_provider(incomplete: str) -> list[str]:
provider_calls.append(incomplete)
return ['site_name']
service = tui_modules.cli_tui_search.TuiSearchService(
tui_modules.cli_tui_search.TuiSearchSuggestionProviderRegistry(
providers={
'configs': tui_modules.cli_tui_search.SearchSuggestionProviderSpec(
'按配置键搜索',
record_provider,
)
}
)
)
search_context = service.resolve_search_context('configs', '')
assert search_context is not None
assert search_context.placeholder == '按配置键搜索'
assert search_context.suggestions == []
assert provider_calls == []
def test_search_highlight_helper_wraps_matches(tui_modules: SimpleNamespace) -> None:
highlighted = tui_modules.cli_tui_search.TUI_SEARCH_HIGHLIGHTER.highlight('sync-user job', 'sync')
assert highlighted == '【sync】-user job'
def test_dashboard_screen_support_builds_summary_signal_and_status_track(
tui_modules: SimpleNamespace,
) -> None:
support = tui_modules.cli_tui_dashboard.DashboardScreenSupport()
snapshot = tui_modules.cli_tui_app.DashboardSnapshot(
env='dev',
metrics=[],
panels=[
tui_modules.cli_tui_adapters.DashboardPanelSnapshot(title='应用', status='ok', lines=['ok']),
tui_modules.cli_tui_adapters.DashboardPanelSnapshot(title='任务', status='warn', lines=['warn']),
tui_modules.cli_tui_adapters.DashboardPanelSnapshot(title='数据库', status='fail', lines=['fail']),
],
)
summary = support.build_summary_text(snapshot)
status_track = support.build_status_track(snapshot)
signal_lines = support.build_signal_lines(snapshot)
assert '共 3 个面板' in summary
assert status_track == 'o!x'
assert any('失败 01' in line for line in signal_lines)
def test_dashboard_screen_focus_service_moves_and_scrolls_between_focus_targets(
tui_modules: SimpleNamespace,
) -> None:
focus_service = tui_modules.cli_tui_dashboard.DashboardScreenFocusService()
focused: list[str] = []
sidebar = SimpleNamespace(focus=lambda: focused.append('sidebar'))
workspace_main = SimpleNamespace(focus=lambda: focused.append('main'))
fake_app = SimpleNamespace(focused=None)
fake_screen = SimpleNamespace(
app=fake_app,
query_one=lambda selector, widget_type=None: {
tui_modules.cli_tui_widgets.WorkspaceSidebar: sidebar,
'#workspace-main': workspace_main,
}[selector],
)
focus_service.move_focus(fake_screen, 1)
fake_app.focused = sidebar
focus_service.move_focus(fake_screen, 1)
fake_app.focused = object()
scroll_target = focus_service.get_scroll_target(fake_screen)
assert focused == ['sidebar', 'main']
assert scroll_target is workspace_main
@pytest.mark.asyncio
async def test_dashboard_grid_expands_to_fit_status_panels(
monkeypatch: pytest.MonkeyPatch,
tui_modules: SimpleNamespace,
) -> None:
expected_dashboard_panel_count = 8
min_expanded_dashboard_grid_height = 20
snapshot = tui_modules.cli_tui_app.DashboardSnapshot(
env='dev',
metrics=[],
panels=[
tui_modules.cli_tui_adapters.DashboardPanelSnapshot(
title=f'面板 {index + 1}',
status='ok' if index % 2 == 0 else 'warn',
lines=[
'## 巡检摘要',
f'当前面板: {index + 1}',
'状态说明: 这里需要展示多行内容',
'',
'## 建议动作',
'进入对应分区继续查看详情',
],
)
for index in range(8)
],
)
monkeypatch.setitem(
tui_modules.cli_tui_app.TUI_SNAPSHOT_COLLECTOR_REGISTRY.collectors, 'dashboard', lambda env: snapshot
)
app = tui_modules.cli_tui_app.ShierfengTuiApp('dev')
async with app.run_test(size=(160, 60)) as pilot:
await pilot.pause()
await pilot.pause()
grid = app.screen.query_one('#dashboard-grid')
panels = list(
app.screen.query(tui_modules.cli_tui_widgets.StatusPanel).results(tui_modules.cli_tui_widgets.StatusPanel)
)
assert len(panels) == expected_dashboard_panel_count
assert grid.virtual_size.height > min_expanded_dashboard_grid_height
assert all(panel.content_size.height > 0 for panel in panels)
assert len({panel.region.height for panel in panels}) == 1
@@ -0,0 +1,394 @@
from types import SimpleNamespace
from typing import Any
import pytest
from pytest import MonkeyPatch
def test_detail_screen_updates_section_detail_view(tui_modules: SimpleNamespace) -> None:
snapshot = tui_modules.cli_tui_app.DetailPageSnapshot(
title='Jobs',
subtitle='subtitle',
sections=[
tui_modules.cli_tui_adapters.DetailSectionSnapshot(title='Section A', status='ok', lines=['line a']),
tui_modules.cli_tui_adapters.DetailSectionSnapshot(title='Section B', status='warn', lines=['line b']),
],
)
screen = tui_modules.cli_tui_detail.DetailScreen(
snapshot,
env='dev',
active_view='jobs',
navigation_items=tui_modules.cli_tui_app.NAVIGATION_ITEMS,
refreshed_at='2026-04-30 10:00:00',
)
recorded_sections: list[object] = []
class FakeSectionDetailView:
def show_section(self, section: object, query: str = '') -> None:
del query
recorded_sections.append(section)
screen.query_one = lambda widget_type: FakeSectionDetailView() # type: ignore[method-assign]
screen._update_selected_section(1)
assert screen.selected_section_index == 1
assert recorded_sections
assert recorded_sections[0].title == 'Section B'
def test_detail_screen_search_submit_remembers_and_refreshes(
monkeypatch: MonkeyPatch,
tui_modules: SimpleNamespace,
) -> None:
snapshot = tui_modules.cli_tui_app.DetailPageSnapshot(
title='数据库',
subtitle='subtitle',
sections=[
tui_modules.cli_tui_adapters.DetailSectionSnapshot(title='历史版本', status='ok', lines=['line a']),
],
search=tui_modules.cli_tui_search.PageSearchContext(
placeholder='按 revision 搜索',
query='',
suggestions=['head', 'base'],
),
)
screen = tui_modules.cli_tui_detail.DetailScreen(
snapshot,
env='dev',
active_view='database',
navigation_items=tui_modules.cli_tui_app.NAVIGATION_ITEMS,
refreshed_at='2026-04-30 10:00:00',
)
recorded_queries: list[tuple[str, str]] = []
refresh_calls: list[str] = []
fake_app = SimpleNamespace(
remember_browser_query=lambda view_key, query: recorded_queries.append((view_key, query)),
action_refresh_current_view=lambda: refresh_calls.append('refresh'),
)
monkeypatch.setattr(tui_modules.cli_tui_detail.DetailScreen, 'app', property(lambda self: fake_app))
screen._handle_search_submitted('head')
assert recorded_queries == [('database', 'head')]
assert refresh_calls == ['refresh']
def test_detail_screen_clear_search_remembers_empty_and_refreshes(
monkeypatch: MonkeyPatch,
tui_modules: SimpleNamespace,
) -> None:
snapshot = tui_modules.cli_tui_app.DetailPageSnapshot(
title='数据库',
subtitle='subtitle',
sections=[
tui_modules.cli_tui_adapters.DetailSectionSnapshot(title='历史版本', status='ok', lines=['line a']),
],
search=tui_modules.cli_tui_search.PageSearchContext(
placeholder='按 revision 搜索',
query='head',
suggestions=['head', 'base'],
),
)
screen = tui_modules.cli_tui_detail.DetailScreen(
snapshot,
env='dev',
active_view='database',
navigation_items=tui_modules.cli_tui_app.NAVIGATION_ITEMS,
refreshed_at='2026-04-30 10:00:00',
)
recorded_queries: list[tuple[str, str]] = []
refresh_calls: list[str] = []
fake_app = SimpleNamespace(
remember_browser_query=lambda view_key, query: recorded_queries.append((view_key, query)),
action_refresh_current_view=lambda: refresh_calls.append('refresh'),
)
monkeypatch.setattr(tui_modules.cli_tui_detail.DetailScreen, 'app', property(lambda self: fake_app))
screen.action_clear_search()
assert recorded_queries == [('database', '')]
assert refresh_calls == ['refresh']
@pytest.mark.asyncio
async def test_detail_screen_execute_external_action_suspends_app(
monkeypatch: MonkeyPatch,
tui_modules: SimpleNamespace,
) -> None:
snapshot = tui_modules.cli_tui_app.DetailPageSnapshot(
title='数据库',
subtitle='subtitle',
sections=[
tui_modules.cli_tui_adapters.DetailSectionSnapshot(title='迁移版本', status='ok', lines=['line a']),
],
)
screen = tui_modules.cli_tui_detail.DetailScreen(
snapshot,
env='dev',
active_view='database',
navigation_items=tui_modules.cli_tui_app.NAVIGATION_ITEMS,
refreshed_at='2026-04-30 10:00:00',
)
action = tui_modules.cli_tui_detail.TUI_ACTION_REGISTRY.resolve_detail_action(
view_key='database',
slot='global',
env='dev',
)
assert action is not None
recorded_suspend: list[str] = []
class DummySuspend:
def __enter__(self) -> None:
recorded_suspend.append('enter')
def __exit__(
self,
exc_type: type[BaseException] | None,
exc: BaseException | None,
tb: Any,
) -> None:
del exc_type, exc, tb
recorded_suspend.append('exit')
def build_suspend() -> DummySuspend:
return DummySuspend()
def refresh_current_view() -> None:
pass
fake_app = SimpleNamespace(
suspend=build_suspend,
action_refresh_current_view=refresh_current_view,
)
monkeypatch.setattr(tui_modules.cli_tui_detail.DetailScreen, 'app', property(lambda self: fake_app))
monkeypatch.setattr(
tui_modules.cli_tui_interactions,
'TUI_ACTION_EXECUTION_SERVICE',
SimpleNamespace(
execute_external=lambda spec: tui_modules.cli_tui_detail.TuiActionResult(
spec=spec,
external_exit_code=0,
external_message='外部交互命令已执行完成',
),
build_result_lines=lambda result: [],
),
)
monkeypatch.setattr(screen, 'notify', lambda *args, **kwargs: None)
await screen._execute_action(action)
assert recorded_suspend == ['enter', 'exit']
@pytest.mark.asyncio
async def test_detail_screen_execute_nested_json_action_without_suspend(
monkeypatch: MonkeyPatch,
tui_modules: SimpleNamespace,
) -> None:
snapshot = tui_modules.cli_tui_app.DetailPageSnapshot(
title='传输加密',
subtitle='subtitle',
sections=[
tui_modules.cli_tui_adapters.DetailSectionSnapshot(title='运行校验', status='ok', lines=['line a']),
],
)
screen = tui_modules.cli_tui_detail.DetailScreen(
snapshot,
env='dev',
active_view='crypto',
navigation_items=tui_modules.cli_tui_app.NAVIGATION_ITEMS,
refreshed_at='2026-04-30 10:00:00',
)
action = tui_modules.cli_tui_detail.TUI_ACTION_REGISTRY.resolve_detail_action(
view_key='crypto',
slot='global',
env='dev',
)
assert action is not None
assert action.execution_mode == 'nested_json'
suspend_calls: list[str] = []
execute_calls: list[tuple[object, str]] = []
class DummySuspend:
def __enter__(self) -> None:
suspend_calls.append('enter')
def __exit__(
self,
exc_type: type[BaseException] | None,
exc: BaseException | None,
tb: Any,
) -> None:
del exc_type, exc, tb
suspend_calls.append('exit')
def build_suspend() -> DummySuspend:
return DummySuspend()
def refresh_current_view() -> None:
pass
fake_app = SimpleNamespace(
suspend=build_suspend,
action_refresh_current_view=refresh_current_view,
)
monkeypatch.setattr(tui_modules.cli_tui_detail.DetailScreen, 'app', property(lambda self: fake_app))
monkeypatch.setattr(
tui_modules.cli_tui_interactions,
'TUI_ACTION_EXECUTION_SERVICE',
SimpleNamespace(
execute=lambda spec, env: (
execute_calls.append((spec, env))
or tui_modules.cli_tui_detail.TuiActionResult(
spec=spec,
payload={'ok': True, 'message': '轮换预演完成'},
)
),
build_result_lines=lambda result: [],
),
)
monkeypatch.setattr(screen, 'notify', lambda *args, **kwargs: None)
await screen._execute_action(action)
assert suspend_calls == []
assert execute_calls == [(action, 'dev')]
def test_detail_screen_support_builds_summary_actions_and_fallbacks(
tui_modules: SimpleNamespace,
) -> None:
support = tui_modules.cli_tui_detail.DetailScreenSupport()
snapshot = tui_modules.cli_tui_app.DetailPageSnapshot(
title='数据库',
subtitle='subtitle',
sections=[
tui_modules.cli_tui_adapters.DetailSectionSnapshot(title='迁移版本', status='ok', lines=['line a']),
tui_modules.cli_tui_adapters.DetailSectionSnapshot(title='Heads 状态', status='warn', lines=['line b']),
],
search=tui_modules.cli_tui_search.PageSearchContext(
placeholder='按 revision 搜索',
query='head',
suggestions=['head'],
),
)
summary = support.build_summary_text(snapshot, 'database')
query = support.current_search_query(snapshot)
action = support.resolve_action(active_view='database', slot='global', env='dev')
empty_section = support.build_empty_section()
assert '2 个分区' in summary
assert query == 'head'
assert action is not None
assert action.command_args[:2] == ('wizard', 'db-upgrade')
assert empty_section.title == tui_modules.cli_tui_copy.TUI_COPY.build_detail_empty_section_copy('title')
def test_detail_screen_focus_service_moves_and_scrolls_between_focus_targets(
tui_modules: SimpleNamespace,
) -> None:
focus_service = tui_modules.cli_tui_detail.DetailScreenFocusService()
focused: list[str] = []
sidebar = SimpleNamespace(focus=lambda: focused.append('sidebar'))
sections = SimpleNamespace(focus=lambda: focused.append('sections'))
detail = SimpleNamespace(focus=lambda: focused.append('detail'))
workspace_main = object()
fake_app = SimpleNamespace(focused=None)
fake_screen = SimpleNamespace(
app=fake_app,
query_one=lambda selector, widget_type=None: {
tui_modules.cli_tui_widgets.WorkspaceSidebar: sidebar,
tui_modules.cli_tui_widgets.SectionNavigator: sections,
tui_modules.cli_tui_widgets.SectionDetailView: detail,
'#workspace-main': workspace_main,
}[selector],
)
focus_service.move_focus(fake_screen, 1)
fake_app.focused = sections
focus_service.move_focus(fake_screen, 1)
fake_app.focused = object()
scroll_target = focus_service.get_scroll_target(fake_screen)
assert focused == ['sidebar', 'detail']
assert scroll_target is workspace_main
def test_detail_screen_unmount_cancels_action_task(tui_modules: SimpleNamespace) -> None:
snapshot = tui_modules.cli_tui_app.DetailPageSnapshot(
title='数据库',
subtitle='subtitle',
sections=[],
)
screen = tui_modules.cli_tui_detail.DetailScreen(
snapshot,
env='dev',
active_view='database',
navigation_items=tui_modules.cli_tui_app.NAVIGATION_ITEMS,
refreshed_at='2026-04-30 10:00:00',
)
cancelled: list[str] = []
class DummyTask:
def done(self) -> bool:
return False
def cancel(self) -> None:
cancelled.append('action')
screen._action_task = DummyTask() # type: ignore[assignment]
screen.on_unmount()
assert cancelled == ['action']
@pytest.mark.asyncio
async def test_detail_screen_scrolls_in_small_viewport(tui_modules: SimpleNamespace) -> None:
thin_scrollbar_size = 1
app = tui_modules.cli_tui_app.ShierfengTuiApp('dev')
app.action_show_dashboard = lambda: None # type: ignore[method-assign]
app.current_view = 'app'
snapshot = tui_modules.cli_tui_app.DetailPageSnapshot(
title='应用',
subtitle='超长详情',
sections=[
tui_modules.cli_tui_adapters.DetailSectionSnapshot(
title='配置总览',
status='warn',
lines=[f'第{i:02d}行 内容很长 内容很长 内容很长 内容很长' for i in range(1, 40)],
)
],
)
screen = app.screen_factory.build(
snapshot=snapshot,
env=app.env,
active_view=app.current_view,
navigation_items=tui_modules.cli_tui_app.NAVIGATION_ITEMS,
action_feedback_lines=app.get_action_feedback_lines(app.current_view),
)
async with app.run_test(size=(80, 24)) as pilot:
app.screen_navigator.show(screen)
await pilot.pause()
await pilot.pause()
main = app.screen.query_one('#workspace-main')
detail_body = app.screen.query_one('#detail-body')
detail_view = app.screen.query_one(tui_modules.cli_tui_widgets.SectionDetailView)
assert main.styles.scrollbar_size_vertical == thin_scrollbar_size
assert main.styles.scrollbar_size_horizontal == thin_scrollbar_size
assert main.virtual_size.height > main.size.height
assert detail_body.styles.scrollbar_size_vertical == thin_scrollbar_size
assert detail_body.styles.scrollbar_size_horizontal == thin_scrollbar_size
assert detail_view.styles.scrollbar_size_vertical == thin_scrollbar_size
assert detail_view.styles.scrollbar_size_horizontal == thin_scrollbar_size
@@ -0,0 +1,68 @@
import importlib
import sys
from collections.abc import Callable
from pathlib import Path
from types import ModuleType
import pytest
BACKEND_DIR = Path(__file__).resolve().parents[3]
if str(BACKEND_DIR) not in sys.path:
sys.path.insert(0, str(BACKEND_DIR))
def _load_module(module_name: str) -> ModuleType:
sys.modules.pop(module_name, None)
sys.modules.pop('cli', None)
return importlib.import_module(module_name)
@pytest.fixture
def backend_dir() -> Path:
return BACKEND_DIR
@pytest.fixture
def load_module() -> Callable[[str], ModuleType]:
return _load_module
@pytest.fixture
def cli_main(load_module: Callable[[str], ModuleType]) -> ModuleType:
return load_module('cli.main')
@pytest.fixture
def app_run_flow(load_module: Callable[[str], ModuleType]) -> ModuleType:
return load_module('cli.wizard.flows.app_run')
@pytest.fixture
def cache_clear_flow(load_module: Callable[[str], ModuleType]) -> ModuleType:
return load_module('cli.wizard.flows.cache_clear')
@pytest.fixture
def db_upgrade_flow(load_module: Callable[[str], ModuleType]) -> ModuleType:
return load_module('cli.wizard.flows.db_upgrade')
@pytest.fixture
def gen_export_flow(load_module: Callable[[str], ModuleType]) -> ModuleType:
return load_module('cli.wizard.flows.gen_export')
@pytest.fixture
def gen_import_flow(load_module: Callable[[str], ModuleType]) -> ModuleType:
return load_module('cli.wizard.flows.gen_import')
@pytest.fixture
def prod_check_flow(load_module: Callable[[str], ModuleType]) -> ModuleType:
return load_module('cli.wizard.flows.prod_check')
@pytest.fixture
def exit_codes(load_module: Callable[[str], ModuleType]) -> ModuleType:
return load_module('cli.exit_codes')
@@ -0,0 +1,33 @@
from types import ModuleType, SimpleNamespace
from pytest import MonkeyPatch
def test_app_run_wizard_execs_nested_cli_after_confirmation(
monkeypatch: MonkeyPatch,
app_run_flow: ModuleType,
) -> None:
captured: dict[str, object] = {}
answers = iter(['dev', False, True])
monkeypatch.setattr(
app_run_flow.AppRunWizardFlow,
'prompt_service',
SimpleNamespace(
prompt_env=lambda default_env='dev': next(answers),
prompt_confirm=lambda prompt_text, default_value=False: next(answers),
),
)
monkeypatch.setattr(
app_run_flow.AppRunWizardFlow.context_factory,
'build_readonly',
lambda env, output: SimpleNamespace(env=env, output=output),
)
monkeypatch.setattr(
app_run_flow.AppRunWizardFlow,
'nested_live_command_runner',
lambda *arguments: captured.update({'arguments': arguments}),
)
app_run_flow.run_app_run_wizard()
assert captured['arguments'] == ('app', 'run', '--env=dev')
@@ -0,0 +1,88 @@
from types import ModuleType, SimpleNamespace
from pytest import MonkeyPatch
def test_cache_clear_wizard_cancels_when_confirmation_rejected(
monkeypatch: MonkeyPatch,
cache_clear_flow: ModuleType,
exit_codes: ModuleType,
) -> None:
captured: dict[str, object] = {}
answers = iter(['dev', 'cache-name', 'sys_config', True, False])
monkeypatch.setattr(
cache_clear_flow.CacheClearWizardFlow,
'prompt_service',
SimpleNamespace(
prompt_env=lambda default_env='dev': next(answers),
prompt_choice=lambda prompt_text, choices, default_value: next(answers),
prompt_required_text=lambda prompt_text, default_value='': next(answers),
prompt_optional_text=lambda prompt_text, default_value='': '',
prompt_confirm=lambda prompt_text, default_value=False: next(answers),
),
)
monkeypatch.setattr(
cache_clear_flow.CacheClearWizardFlow.context_factory,
'build_regular',
lambda env, output, allow_prod, yes, dry_run: SimpleNamespace(env=env, output=output),
)
monkeypatch.setattr(
cache_clear_flow.CacheClearWizardFlow.execution_service,
'complete_result',
lambda ctx, result: captured.update({'ctx': ctx, 'result': result}),
)
cache_clear_flow.run_cache_clear_wizard('text')
assert captured['ctx'].env == 'dev'
assert captured['result'].exit_code == exit_codes.GUARD_REJECTED
assert captured['result'].data['message'] == '已取消向导执行:wizard cache-clear'
def test_cache_clear_wizard_accepts_default_context_values(
monkeypatch: MonkeyPatch,
cache_clear_flow: ModuleType,
) -> None:
captured_defaults: dict[str, object] = {}
captured_confirm_defaults: list[tuple[str, bool]] = []
answers = iter(['dev', 'cache-name', 'sys_config', True, False])
monkeypatch.setattr(
cache_clear_flow.CacheClearWizardFlow,
'prompt_service',
SimpleNamespace(
prompt_env=lambda default_env='dev': captured_defaults.update({'env': default_env}) or next(answers),
prompt_choice=lambda prompt_text, choices, default_value: (
captured_defaults.update({'mode': default_value}) or next(answers)
),
prompt_required_text=lambda prompt_text, default_value='': (
captured_defaults.update({'cache_name': default_value}) or next(answers)
),
prompt_optional_text=lambda prompt_text, default_value='': '',
prompt_confirm=lambda prompt_text, default_value=False: (
captured_confirm_defaults.append((prompt_text, default_value)) or next(answers)
),
),
)
monkeypatch.setattr(
cache_clear_flow.CacheClearWizardFlow.context_factory,
'build_regular',
lambda env, output, allow_prod, yes, dry_run: SimpleNamespace(env=env, output=output),
)
monkeypatch.setattr(
cache_clear_flow.CacheClearWizardFlow.execution_service,
'complete_result',
lambda ctx, result: None,
)
cache_clear_flow.run_cache_clear_wizard(
'text',
default_env='dev',
default_mode='cache-name',
default_cache_name='sys_config',
default_dry_run=True,
)
assert captured_defaults['env'] == 'dev'
assert captured_defaults['mode'] == 'cache-name'
assert captured_defaults['cache_name'] == 'sys_config'
assert captured_confirm_defaults[0][1] is True
@@ -0,0 +1,24 @@
from pathlib import Path
from types import ModuleType
from pytest import MonkeyPatch
from typer.testing import CliRunner
def test_wizard_help_lists_supported_subcommands(
monkeypatch: MonkeyPatch,
backend_dir: Path,
cli_main: ModuleType,
) -> None:
runner = CliRunner()
monkeypatch.chdir(backend_dir)
result = runner.invoke(cli_main.CLI_APPLICATION_BUILDER.build(), ['wizard', '--help'])
assert result.exit_code == 0
assert 'app-run' in result.stdout
assert 'db-upgrade' in result.stdout
assert 'cache-clear' in result.stdout
assert 'gen-export' in result.stdout
assert 'gen-import' in result.stdout
assert 'prod-check' in result.stdout
@@ -0,0 +1,99 @@
from types import ModuleType, SimpleNamespace
from pytest import MonkeyPatch
def test_db_upgrade_wizard_executes_nested_cli_with_confirmed_inputs(
monkeypatch: MonkeyPatch,
db_upgrade_flow: ModuleType,
) -> None:
captured: dict[str, object] = {}
monkeypatch.setattr(
db_upgrade_flow.DbUpgradeWizardFlow,
'prompt_service',
SimpleNamespace(
prompt_env=lambda default_env='dev': 'dev',
prompt_required_text=lambda prompt_text, default_value='': 'head',
prompt_confirm=lambda prompt_text, default_value=False: True,
),
)
monkeypatch.setattr(
db_upgrade_flow.DbUpgradeWizardFlow.context_factory,
'build_regular',
lambda env, output, allow_prod, yes, dry_run: SimpleNamespace(
env=env,
output=output,
allow_prod=allow_prod,
yes=yes,
dry_run=dry_run,
),
)
monkeypatch.setattr(
db_upgrade_flow.DbUpgradeWizardFlow,
'nested_command_runner',
lambda *arguments, parse_json=False: SimpleNamespace(
returncode=0,
payload={'ok': True, 'message': '数据库已升级到 head', 'dryRun': True},
),
)
monkeypatch.setattr(
db_upgrade_flow.DbUpgradeWizardFlow.execution_service,
'complete_payload',
lambda ctx, payload, default_exit_code=0: captured.update(
{
'ctx': ctx,
'payload': payload,
'default_exit_code': default_exit_code,
}
),
)
db_upgrade_flow.run_db_upgrade_wizard('json')
assert captured['ctx'].env == 'dev'
assert captured['ctx'].output == 'json'
assert captured['ctx'].dry_run is True
assert captured['payload'] == {'ok': True, 'message': '数据库已升级到 head', 'dryRun': True}
def test_db_upgrade_wizard_accepts_default_context_values(
monkeypatch: MonkeyPatch,
db_upgrade_flow: ModuleType,
) -> None:
captured_defaults: dict[str, object] = {}
captured_confirm_defaults: list[tuple[str, bool]] = []
confirm_answers = iter([True, False])
monkeypatch.setattr(
db_upgrade_flow.DbUpgradeWizardFlow,
'prompt_service',
SimpleNamespace(
prompt_env=lambda default_env='dev': captured_defaults.update({'env': default_env}) or 'dev',
prompt_required_text=lambda prompt_text, default_value='': (
captured_defaults.update({'revision': default_value}) or 'head'
),
prompt_confirm=lambda prompt_text, default_value=False: (
captured_confirm_defaults.append((prompt_text, default_value)) or next(confirm_answers)
),
),
)
monkeypatch.setattr(
db_upgrade_flow.DbUpgradeWizardFlow.context_factory,
'build_regular',
lambda env, output, allow_prod, yes, dry_run: SimpleNamespace(env=env, output=output),
)
monkeypatch.setattr(
db_upgrade_flow.DbUpgradeWizardFlow.execution_service,
'complete_result',
lambda ctx, result: None,
)
db_upgrade_flow.run_db_upgrade_wizard(
'text',
default_env='dev',
default_revision='head',
default_dry_run=True,
)
assert captured_defaults['env'] == 'dev'
assert captured_defaults['revision'] == 'head'
assert captured_confirm_defaults[0][1] is True
@@ -0,0 +1,120 @@
from types import ModuleType, SimpleNamespace
from pytest import MonkeyPatch
def test_gen_export_wizard_executes_nested_cli_with_selected_tables(
monkeypatch: MonkeyPatch,
gen_export_flow: ModuleType,
) -> None:
captured: dict[str, object] = {}
answers = iter(['dev', 'sys_user,sys_role', 'zip', 'build/demo.zip', True, True])
monkeypatch.setattr(
gen_export_flow.GenExportWizardFlow,
'prompt_service',
SimpleNamespace(
prompt_env=lambda default_env='dev': next(answers),
prompt_required_text=lambda prompt_text, default_value='': next(answers),
prompt_choice=lambda prompt_text, choices, default_value: next(answers),
prompt_optional_text=lambda prompt_text, default_value='': next(answers),
prompt_confirm=lambda prompt_text, default_value=False: next(answers),
),
)
monkeypatch.setattr(
gen_export_flow.GenExportWizardFlow.context_factory,
'build_regular',
lambda env, output, allow_prod, yes, dry_run: SimpleNamespace(
env=env,
output=output,
allow_prod=allow_prod,
yes=yes,
dry_run=dry_run,
),
)
monkeypatch.setattr(
gen_export_flow.GenExportWizardFlow,
'nested_command_runner',
lambda *arguments, parse_json=False: SimpleNamespace(
returncode=0,
payload={
'ok': True,
'message': '代码导出演练完成,未执行实际导出',
'dryRun': True,
'mode': 'zip',
'tableNames': ['sys_user', 'sys_role'],
'outputFile': '/tmp/demo.zip',
},
),
)
monkeypatch.setattr(
gen_export_flow.GenExportWizardFlow.execution_service,
'complete_payload',
lambda ctx, payload, default_exit_code=0: captured.update(
{
'ctx': ctx,
'payload': payload,
'default_exit_code': default_exit_code,
}
),
)
gen_export_flow.run_gen_export_wizard('json')
assert captured['ctx'].env == 'dev'
assert captured['ctx'].output == 'json'
assert captured['ctx'].dry_run is True
assert captured['payload']['tableNames'] == ['sys_user', 'sys_role']
assert captured['payload']['mode'] == 'zip'
def test_gen_export_wizard_accepts_default_context_values(
monkeypatch: MonkeyPatch,
gen_export_flow: ModuleType,
) -> None:
captured_defaults: dict[str, object] = {}
captured_confirm_defaults: list[tuple[str, bool]] = []
answers = iter(['dev', 'sys_user', 'zip', 'build/demo.zip', True, False])
monkeypatch.setattr(
gen_export_flow.GenExportWizardFlow,
'prompt_service',
SimpleNamespace(
prompt_env=lambda default_env='dev': captured_defaults.update({'env': default_env}) or next(answers),
prompt_required_text=lambda prompt_text, default_value='': (
captured_defaults.update({'table_names': default_value}) or next(answers)
),
prompt_choice=lambda prompt_text, choices, default_value: (
captured_defaults.update({'mode': default_value}) or next(answers)
),
prompt_optional_text=lambda prompt_text, default_value='': (
captured_defaults.update({'output_file': default_value}) or next(answers)
),
prompt_confirm=lambda prompt_text, default_value=False: (
captured_confirm_defaults.append((prompt_text, default_value)) or next(answers)
),
),
)
monkeypatch.setattr(
gen_export_flow.GenExportWizardFlow.context_factory,
'build_regular',
lambda env, output, allow_prod, yes, dry_run: SimpleNamespace(env=env, output=output),
)
monkeypatch.setattr(
gen_export_flow.GenExportWizardFlow.execution_service,
'complete_result',
lambda ctx, result: None,
)
gen_export_flow.run_gen_export_wizard(
'text',
default_env='dev',
default_table_names='sys_user',
default_mode='zip',
default_output_file='build/demo.zip',
default_dry_run=True,
)
assert captured_defaults['env'] == 'dev'
assert captured_defaults['table_names'] == 'sys_user'
assert captured_defaults['mode'] == 'zip'
assert captured_defaults['output_file'] == 'build/demo.zip'
assert captured_confirm_defaults[0][1] is True
@@ -0,0 +1,164 @@
from types import ModuleType, SimpleNamespace
from pytest import MonkeyPatch
def test_gen_import_wizard_executes_nested_cli_with_selected_tables(
monkeypatch: MonkeyPatch,
gen_import_flow: ModuleType,
) -> None:
captured: dict[str, object] = {}
answers = iter(['dev', 'sys_user,sys_role', True, True])
monkeypatch.setattr(
gen_import_flow.GenImportWizardFlow,
'prompt_service',
SimpleNamespace(
prompt_env=lambda default_env='dev': next(answers),
prompt_required_text=lambda prompt_text, default_value='': next(answers),
prompt_confirm=lambda prompt_text, default_value=False: next(answers),
),
)
monkeypatch.setattr(
gen_import_flow.GenImportWizardFlow.context_factory,
'build_regular',
lambda env, output, allow_prod, yes, dry_run: SimpleNamespace(
env=env,
output=output,
allow_prod=allow_prod,
yes=yes,
dry_run=dry_run,
),
)
monkeypatch.setattr(
gen_import_flow.GenImportWizardFlow,
'nested_command_runner',
lambda *arguments, parse_json=False: SimpleNamespace(
returncode=0,
payload={
'ok': True,
'message': '物理表导入预演完成,未执行实际导入',
'dryRun': True,
'tableNames': ['sys_user', 'sys_role'],
},
),
)
monkeypatch.setattr(
gen_import_flow.GenImportWizardFlow.execution_service,
'complete_payload',
lambda ctx, payload, default_exit_code=0: captured.update(
{
'ctx': ctx,
'payload': payload,
'default_exit_code': default_exit_code,
}
),
)
gen_import_flow.run_gen_import_wizard('json')
assert captured['ctx'].env == 'dev'
assert captured['ctx'].output == 'json'
assert captured['ctx'].dry_run is True
assert captured['payload']['tableNames'] == ['sys_user', 'sys_role']
def test_gen_import_wizard_accepts_default_context_values(
monkeypatch: MonkeyPatch,
gen_import_flow: ModuleType,
) -> None:
captured_defaults: dict[str, object] = {}
captured_confirm_defaults: list[tuple[str, bool]] = []
answers = iter(['dev', 'sys_user', True, False])
monkeypatch.setattr(
gen_import_flow.GenImportWizardFlow,
'prompt_service',
SimpleNamespace(
prompt_env=lambda default_env='dev': captured_defaults.update({'env': default_env}) or next(answers),
prompt_required_text=lambda prompt_text, default_value='': (
captured_defaults.update({'table_names': default_value}) or next(answers)
),
prompt_confirm=lambda prompt_text, default_value=False: (
captured_confirm_defaults.append((prompt_text, default_value)) or next(answers)
),
),
)
monkeypatch.setattr(
gen_import_flow.GenImportWizardFlow.context_factory,
'build_regular',
lambda env, output, allow_prod, yes, dry_run: SimpleNamespace(env=env, output=output),
)
monkeypatch.setattr(
gen_import_flow.GenImportWizardFlow.execution_service,
'complete_result',
lambda ctx, result: None,
)
gen_import_flow.run_gen_import_wizard(
'text',
default_env='dev',
default_table_names='sys_user',
default_dry_run=True,
)
assert captured_defaults['env'] == 'dev'
assert captured_defaults['table_names'] == 'sys_user'
assert captured_confirm_defaults[0][1] is True
def test_gen_import_wizard_falls_back_to_failure_payload_when_nested_result_has_no_dict_payload(
monkeypatch: MonkeyPatch,
gen_import_flow: ModuleType,
) -> None:
captured: dict[str, object] = {}
answers = iter(['dev', 'sys_notice', True, True])
monkeypatch.setattr(
gen_import_flow.GenImportWizardFlow,
'prompt_service',
SimpleNamespace(
prompt_env=lambda default_env='dev': next(answers),
prompt_required_text=lambda prompt_text, default_value='': next(answers),
prompt_confirm=lambda prompt_text, default_value=False: next(answers),
),
)
monkeypatch.setattr(
gen_import_flow.GenImportWizardFlow.context_factory,
'build_regular',
lambda env, output, allow_prod, yes, dry_run: SimpleNamespace(
env=env,
output=output,
allow_prod=allow_prod,
yes=yes,
dry_run=dry_run,
),
)
monkeypatch.setattr(
gen_import_flow.GenImportWizardFlow,
'nested_command_runner',
lambda *arguments, parse_json=False: SimpleNamespace(
returncode=0,
stdout='OK SUCCESS\nmessage: 导入表结构演练完成,未执行实际写入',
stderr='',
payload=[],
),
)
monkeypatch.setattr(
gen_import_flow.GenImportWizardFlow.execution_service,
'complete_payload',
lambda ctx, payload, default_exit_code=0: captured.update(
{
'ctx': ctx,
'payload': payload,
'default_exit_code': default_exit_code,
}
),
)
gen_import_flow.run_gen_import_wizard('text')
assert captured['ctx'].env == 'dev'
assert captured['payload'] == {
'ok': False,
'message': '代码生成导入向导执行失败',
'error': 'OK SUCCESS\nmessage: 导入表结构演练完成,未执行实际写入',
'exit_code': 0,
}
@@ -0,0 +1,110 @@
from types import ModuleType, SimpleNamespace
from pytest import MonkeyPatch
def test_prod_check_wizard_aggregates_nested_cli_payloads(
monkeypatch: MonkeyPatch,
prod_check_flow: ModuleType,
) -> None:
captured: dict[str, object] = {}
answers = iter(['prod', True, True])
monkeypatch.setattr(
prod_check_flow.ProdCheckWizardFlow,
'prompt_service',
SimpleNamespace(
prompt_env=lambda default_env='prod': next(answers),
prompt_confirm=lambda prompt_text, default_value=False: next(answers),
),
)
monkeypatch.setattr(
prod_check_flow.ProdCheckWizardFlow.context_factory,
'build_readonly',
lambda env, output: SimpleNamespace(env=env, output=output),
)
def fake_run_nested_cli_command(*arguments: str, parse_json: bool = False) -> SimpleNamespace:
del parse_json
if arguments[0:2] == ('app', 'env'):
return SimpleNamespace(
payload={
'ok': True,
'runtime': {
'cliEnv': 'prod',
'configEnv': 'prod',
'envFile': '.env.prod',
'envFileExists': True,
},
}
)
if arguments[0:2] == ('app', 'doctor'):
return SimpleNamespace(payload={'ok': True, 'message': '应用启动前检查通过'})
return SimpleNamespace(
payload={
'ok': True,
'config': {
'name': 'demo',
'host': '0.0.0.0',
'port': 9099,
'dbType': 'mysql',
'redisHost': '127.0.0.1',
'redisPort': 6379,
},
}
)
monkeypatch.setattr(
prod_check_flow.ProdCheckWizardFlow,
'nested_command_runner',
fake_run_nested_cli_command,
)
monkeypatch.setattr(
prod_check_flow.ProdCheckWizardFlow.execution_service,
'complete_payload',
lambda ctx, payload, default_exit_code=0: captured.update({'ctx': ctx, 'payload': payload}),
)
prod_check_flow.run_prod_check_wizard('json')
assert captured['ctx'].env == 'prod'
assert captured['payload']['ok'] is True
assert captured['payload']['doctor']['message'] == '应用启动前检查通过'
assert captured['payload']['runtime']['cliEnv'] == 'prod'
assert captured['payload']['config']['name'] == 'demo'
def test_prod_check_wizard_accepts_default_context_values(
monkeypatch: MonkeyPatch,
prod_check_flow: ModuleType,
) -> None:
captured_defaults: dict[str, object] = {}
confirm_answers = iter([True, False])
monkeypatch.setattr(
prod_check_flow.ProdCheckWizardFlow,
'prompt_service',
SimpleNamespace(
prompt_env=lambda default_env='prod': captured_defaults.update({'env': default_env}) or 'prod',
prompt_confirm=lambda prompt_text, default_value=False: (
captured_defaults.update({'include_config': default_value}) or next(confirm_answers)
),
),
)
monkeypatch.setattr(
prod_check_flow.ProdCheckWizardFlow.context_factory,
'build_readonly',
lambda env, output: SimpleNamespace(env=env, output=output),
)
monkeypatch.setattr(
prod_check_flow.ProdCheckWizardFlow.execution_service,
'complete_result',
lambda ctx, result: None,
)
prod_check_flow.run_prod_check_wizard(
'text',
default_env='prod',
default_include_config=True,
)
assert captured_defaults['env'] == 'prod'
assert captured_defaults['include_config'] is True
@@ -0,0 +1,865 @@
import asyncio
import json
import os
import sys
from collections.abc import Iterator
from datetime import datetime
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from fastapi import Request
from loguru import logger as _logger
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
from common.annotation.log_annotation import Log, RequestLogFieldRoot, ResponseLogFieldRoot
from common.enums import BusinessType
from config.env import LogConfig
from utils.log_util import LoggerInitializer, LogSanitizer, _build_text_assignment_patterns, _build_text_key_pattern
@pytest.fixture(autouse=True)
def enable_log_mask_for_tests() -> Iterator[None]:
with patch.object(LogConfig, 'log_mask_enabled', True):
yield
def test_sanitize_nested_log_payload() -> None:
payload = {
'jsonBody': {
'password': 'plain-password',
'apiKey': 'sk-123456',
'systemPrompt': 'top secret prompt',
'phonenumber': '13812345678',
'email': 'admin@example.com',
'ipaddr': '192.168.1.10',
}
}
sanitized = LogSanitizer.sanitize_data(payload)
assert sanitized['jsonBody']['password'] == '******'
assert sanitized['jsonBody']['apiKey'] == '******'
assert sanitized['jsonBody']['systemPrompt'] == '******'
assert sanitized['jsonBody']['phonenumber'] == '138****5678'
assert sanitized['jsonBody']['email'] == 'a***n@example.com'
assert sanitized['jsonBody']['ipaddr'] == '192.168.1.10'
def test_ip_masking_can_be_enabled_by_partial_mask_field_configuration() -> None:
payload = {
'jsonBody': {
'ipaddr': '192.168.1.10',
'login_ip': '192.168.1.11',
}
}
with patch.object(
LogSanitizer,
'_PARTIAL_MASK_FIELDS',
LogSanitizer._PARTIAL_MASK_FIELDS | {'ip', 'ipaddr', 'operip', 'loginip'},
):
sanitized = LogSanitizer.sanitize_data(payload)
assert sanitized['jsonBody']['ipaddr'] == '192.168.1.*'
assert sanitized['jsonBody']['login_ip'] == '192.168.1.*'
def test_sanitize_config_value_by_config_key() -> None:
payload = {
'configKey': 'sys.transport.privateKey',
'configValue': '-----BEGIN PRIVATE KEY-----abc',
}
sanitized = LogSanitizer.sanitize_data(payload)
assert sanitized['configValue'] == '******'
def test_sanitize_text_message() -> None:
text = 'Authorization=Bearer abc.def password=123456 api_key=sk-test'
expected_mask_count = 3
sanitized = LogSanitizer.sanitize_text(text)
assert 'abc.def' not in sanitized
assert '123456' not in sanitized
assert 'sk-test' not in sanitized
assert sanitized.count('******') >= expected_mask_count
def test_sanitize_text_masks_configured_secret_fields() -> None:
text = (
'secret_key=my-secret private_key="-----BEGIN PRIVATE KEY-----abc" '
"credential='cred-123' credentials=cred-list"
)
expected_mask_count = 4
sanitized = LogSanitizer.sanitize_text(text)
assert 'my-secret' not in sanitized
assert '-----BEGIN PRIVATE KEY-----abc' not in sanitized
assert 'cred-123' not in sanitized
assert 'cred-list' not in sanitized
assert sanitized.count('******') >= expected_mask_count
def test_sanitize_text_key_pattern_does_not_cross_lines() -> None:
text = 'header\nprivate\n key=value\nfooter'
sanitized = LogSanitizer.sanitize_text(text)
assert sanitized == text
def test_sanitize_stringified_json_preserves_newlines_after_masking() -> None:
text = '{\n "password": "123456",\n "userName": "admin"\n}'
sanitized = LogSanitizer.sanitize_data({'operParam': text})
assert sanitized['operParam'] == '{\n "password": "******",\n "userName": "admin"\n}'
def test_sanitize_text_masks_configured_partial_fields() -> None:
text = 'email=admin@example.com phonenumber=13812345678 mobile="13812345679"'
sanitized = LogSanitizer.sanitize_text(text)
assert 'admin@example.com' not in sanitized
assert '13812345678' not in sanitized
assert '13812345679' not in sanitized
assert 'a***n@example.com' in sanitized
assert '138****5678' in sanitized
assert '138****5679' in sanitized
def test_sanitize_text_can_enable_ip_masking_by_partial_mask_field_configuration() -> None:
text = 'login_ip=192.168.1.10 ipaddr="192.168.1.11"'
ip_fields = LogSanitizer._PARTIAL_MASK_FIELDS | {'ip', 'ipaddr', 'operip', 'loginip'}
ip_key_pattern = '|'.join(
sorted(
{
_build_text_key_pattern(field_name)
for field_name in (*LogSanitizer._TEXT_PARTIAL_FIELDS, 'ip', 'ipaddr', 'oper_ip', 'login_ip')
},
key=len,
reverse=True,
)
)
with (
patch.object(LogSanitizer, '_PARTIAL_MASK_FIELDS', ip_fields),
patch.object(LogSanitizer, '_PARTIAL_KV_PATTERNS', _build_text_assignment_patterns(ip_key_pattern)),
):
sanitized = LogSanitizer.sanitize_text(text)
assert '192.168.1.10' not in sanitized
assert '192.168.1.11' not in sanitized
assert '192.168.1.*' in sanitized
def test_sanitize_text_masks_python_repr_like_payload() -> None:
text = "ValueError({'secret_key': 'abc', 'password': '123456', 'api_key': 'sk-123'})"
expected_mask_count = 3
sanitized = LogSanitizer.sanitize_text(text)
assert 'abc' not in sanitized
assert '123456' not in sanitized
assert 'sk-123' not in sanitized
assert sanitized.count('******') >= expected_mask_count
def test_sanitize_text_masks_alphanumeric_verification_code() -> None:
text = '短信验证码为A1B2'
sanitized = LogSanitizer.sanitize_text(text)
assert 'A1B2' not in sanitized
assert '******' in sanitized
def test_sanitize_text_returns_original_when_mask_disabled() -> None:
text = 'Authorization=Bearer abc.def password=123456 api_key=sk-test'
with patch.object(LogConfig, 'log_mask_enabled', False):
sanitized = LogSanitizer.sanitize_text(text)
assert sanitized == text
def test_build_json_payload_sanitizes_exception_and_extra() -> None:
initializer = LoggerInitializer()
try:
raise ValueError('token=abc.def')
except ValueError as exc:
exception = SimpleNamespace(
type=type(exc),
value=exc,
traceback=exc.__traceback__,
)
record = {
'time': datetime(2026, 4, 20, 12, 0, 0),
'level': SimpleNamespace(name='ERROR'),
'message': 'password=123456',
'name': 'test_logger',
'module': 'test_module',
'function': 'test_function',
'line': 1,
'extra': {'authorization': 'Bearer abc.def', 'trace_id': 'trace-1'},
'exception': exception,
}
initializer._patch_record(record)
initializer._filter(record)
payload = initializer._build_json_payload(record)
assert '123456' not in payload['message']
assert 'abc.def' not in payload['exception']['value']
assert 'abc.def' not in payload['exception']['traceback']
assert 'ValueError' in payload['exception']['traceback']
assert payload['extra']['authorization'] == '******'
def test_json_log_formatter_emits_valid_json_record() -> None:
initializer = LoggerInitializer()
outputs = []
test_logger = _logger.patch(initializer._patch_record)
test_logger.remove()
test_logger.add(
lambda message: outputs.append(str(message)),
format=initializer._json_log_formatter,
filter=initializer._filter,
)
test_logger.info('hello password=123456')
assert len(outputs) == 1
payload = json.loads(outputs[0])
assert payload['message'] == 'hello password=******'
assert payload['level'] == 'INFO'
def test_json_log_formatter_does_not_leak_internal_payload_between_handlers() -> None:
initializer = LoggerInitializer()
first_outputs = []
second_outputs = []
test_logger = _logger.patch(initializer._patch_record)
test_logger.remove()
test_logger.add(
lambda message: first_outputs.append(str(message)),
format=initializer._json_log_formatter,
filter=initializer._filter,
)
test_logger.add(
lambda message: second_outputs.append(str(message)),
format=initializer._json_log_formatter,
filter=initializer._filter,
)
test_logger.info('hello')
assert 'json_payload' not in json.loads(first_outputs[0])['extra']
assert 'json_payload' not in json.loads(second_outputs[0])['extra']
def test_plain_log_formatter_sanitizes_exception_traceback() -> None:
initializer = LoggerInitializer()
outputs = []
test_logger = _logger.patch(initializer._patch_record)
test_logger.remove()
test_logger.add(
lambda message: outputs.append(str(message)),
format=initializer._plain_log_formatter,
filter=initializer._filter,
backtrace=False,
diagnose=False,
)
try:
raise ValueError('password=123456 token=abc.def')
except ValueError:
test_logger.exception('boom password=123456')
output = outputs[0]
assert '123456' not in output
assert 'abc.def' not in output
assert 'ValueError' in output
assert '******' in output
def test_plain_log_formatter_separates_consecutive_records() -> None:
initializer = LoggerInitializer()
outputs = []
expected_output_count = 2
test_logger = _logger.patch(initializer._patch_record)
test_logger.remove()
test_logger.add(
lambda message: outputs.append(str(message)),
format=initializer._plain_log_formatter,
filter=initializer._filter,
)
test_logger.info('first')
test_logger.info('second')
assert len(outputs) == expected_output_count
assert outputs[0].endswith('\n')
assert 'first\n' in ''.join(outputs)
def test_get_request_params_returns_structured_payload() -> None:
async def receive() -> dict:
return {
'type': 'http.request',
'body': b'{"password":"plain-password","phonenumber":"13812345678"}',
'more_body': False,
}
request = Request(
{
'type': 'http',
'method': 'POST',
'path': '/test',
'headers': [(b'content-type', b'application/json')],
'path_params': {},
'query_string': b'page=1',
},
receive=receive,
)
log_decorator = Log(title='测试日志', business_type=BusinessType.OTHER)
params = asyncio.run(log_decorator._get_request_params(request))
assert params['query_params']['page'] == '1'
assert params['json_body']['password'] == 'plain-password'
def test_get_request_params_falls_back_to_raw_body_when_json_invalid() -> None:
async def receive() -> dict:
return {
'type': 'http.request',
'body': b'{bad json',
'more_body': False,
}
request = Request(
{
'type': 'http',
'method': 'POST',
'path': '/test',
'headers': [(b'content-type', b'application/json')],
'path_params': {},
'query_string': b'',
},
receive=receive,
)
log_decorator = Log(title='测试日志', business_type=BusinessType.OTHER)
with patch('common.annotation.log_annotation.logger.warning') as mock_warning:
params = asyncio.run(log_decorator._get_request_params(request))
assert params['raw_body'] == '{bad json'
mock_warning.assert_called_once()
assert '解析失败' in mock_warning.call_args.args[0]
def test_get_request_params_decodes_non_utf8_body_without_raising() -> None:
async def receive() -> dict:
return {
'type': 'http.request',
'body': b'\xff\xfeabc',
'more_body': False,
}
request = Request(
{
'type': 'http',
'method': 'POST',
'path': '/test',
'headers': [(b'content-type', b'text/plain')],
'path_params': {},
'query_string': b'',
},
receive=receive,
)
log_decorator = Log(title='测试日志', business_type=BusinessType.OTHER)
params = asyncio.run(log_decorator._get_request_params(request))
assert params['raw_body'].endswith('abc')
assert '\ufffd' in params['raw_body']
def test_log_handles_missing_user_agent_without_error() -> None:
log_decorator = Log(title='测试日志', business_type=BusinessType.OTHER, log_type='login')
assert log_decorator._get_oper_type(None) == 0
login_log = log_decorator._get_login_log(
None,
oper_ip='127.0.0.1',
oper_location='内网IP',
oper_time=datetime(2026, 4, 20, 12, 0, 0),
origin_kwargs={},
)
assert login_log['ipaddr'] == '127.0.0.1'
assert login_log['loginLocation'] == '内网IP'
assert login_log['browser'] == 'Other'
assert login_log['os'] == 'Other'
def test_build_log_text_with_summary_mode() -> None:
log_decorator = Log(
title='测试日志',
business_type=BusinessType.OTHER,
request_log_mode='summary',
)
log_text = log_decorator._build_log_text(
LogSanitizer.sanitize_data(
{
'query_params': {'page': '1'},
'json_body': {'password': 'plain-password', 'modelName': 'demo-model'},
}
),
mode='summary',
include_fields=(),
exclude_fields=(),
payload_kind='request',
)
log_payload = json.loads(log_text)
assert log_payload['mode'] == 'summary'
assert log_payload['query_param_keys'] == ['page']
assert log_payload['json_body_keys'] == ['password', 'modelName']
def test_build_log_text_with_include_mode() -> None:
log_decorator = Log(
title='测试日志',
business_type=BusinessType.OTHER,
response_log_mode='include',
response_include_fields=('data.token', 'data.userName'),
)
log_text = log_decorator._build_log_text(
LogSanitizer.sanitize_data(
{
'code': 200,
'msg': 'success',
'data': {'token': 'abc.def', 'userName': 'admin', 'extra': 'ignored'},
}
),
mode='include',
include_fields=('data.token', 'data.userName'),
exclude_fields=(),
payload_kind='response',
)
log_payload = json.loads(log_text)
assert log_payload['mode'] == 'include'
assert log_payload['selected']['data.token'] == '******'
assert log_payload['selected']['data.userName'] == 'admin'
assert 'data.extra' not in log_payload['selected']
def test_include_fields_support_snake_case_matching_camel_case_payload() -> None:
log_decorator = Log(title='测试日志', business_type=BusinessType.OTHER)
log_text = log_decorator._build_log_text(
LogSanitizer.sanitize_data(
{
'json_body': {
'modelCode': 'deepseek-chat',
'supportReasoning': 'Y',
}
}
),
mode='include',
include_fields=('json_body.model_code', 'json_body.support_reasoning'),
exclude_fields=(),
payload_kind='request',
)
log_payload = json.loads(log_text)
assert log_payload['selected']['json_body.model_code'] == 'deepseek-chat'
assert log_payload['selected']['json_body.support_reasoning'] == 'Y'
def test_include_fields_support_camel_case_matching_snake_case_payload() -> None:
log_decorator = Log(title='测试日志', business_type=BusinessType.OTHER)
log_text = log_decorator._build_log_text(
LogSanitizer.sanitize_data(
{
'data': {
'user_name': 'admin',
'login_ip': '192.168.1.10',
}
}
),
mode='include',
include_fields=('data.userName', 'data.loginIp'),
exclude_fields=(),
payload_kind='response',
)
log_payload = json.loads(log_text)
assert log_payload['selected']['data.userName'] == 'admin'
assert log_payload['selected']['data.loginIp'] == '192.168.1.10'
def test_request_field_helper_builds_canonical_path() -> None:
assert RequestLogFieldRoot.JSON_BODY.field('model_code') == 'json_body.model_code'
assert ResponseLogFieldRoot.DATA.field('userName') == 'data.userName'
def test_code_field_masking_only_applies_to_login_like_verification_code() -> None:
login_payload = {
'uuid': 'captcha-session',
'code': 'A1B2',
'msg': 'login',
}
business_payload = {
'code': '200',
'msg': 'ok',
}
sanitized_login_payload = LogSanitizer.sanitize_data(login_payload)
sanitized_business_payload = LogSanitizer.sanitize_data(business_payload)
assert sanitized_login_payload['code'] == '******'
assert sanitized_business_payload['code'] == '200'
def test_build_log_text_with_exclude_mode() -> None:
log_decorator = Log(
title='测试日志',
business_type=BusinessType.OTHER,
request_log_mode='exclude',
request_exclude_fields=('json_body.api_key',),
)
log_text = log_decorator._build_log_text(
LogSanitizer.sanitize_data(
{
'json_body': {
'modelCode': 'deepseek-chat',
'apiKey': 'sk-test',
'baseUrl': 'https://api.example.com',
}
}
),
mode='exclude',
include_fields=(),
exclude_fields=('json_body.api_key',),
payload_kind='request',
)
log_payload = json.loads(log_text)
assert 'apiKey' not in log_payload['json_body']
assert log_payload['json_body']['modelCode'] == 'deepseek-chat'
assert log_payload['json_body']['baseUrl'] == 'https://api.example.com'
def test_exclude_fields_support_snake_case_matching_camel_case_payload() -> None:
log_decorator = Log(title='测试日志', business_type=BusinessType.OTHER)
log_text = log_decorator._build_log_text(
LogSanitizer.sanitize_data(
{
'data': {
'apiKey': '******',
'baseUrl': 'https://api.example.com',
}
}
),
mode='exclude',
include_fields=(),
exclude_fields=('data.api_key',),
payload_kind='response',
)
log_payload = json.loads(log_text)
assert 'apiKey' not in log_payload['data']
assert log_payload['data']['baseUrl'] == 'https://api.example.com'
def test_exclude_fields_remove_multiple_list_indexes_without_shift() -> None:
log_decorator = Log(title='测试日志', business_type=BusinessType.OTHER)
log_text = log_decorator._build_log_text(
LogSanitizer.sanitize_data(
{
'rows': [
{'id': 1},
{'id': 2},
{'id': 3},
]
}
),
mode='exclude',
include_fields=(),
exclude_fields=('rows.0', 'rows.1'),
payload_kind='response',
)
log_payload = json.loads(log_text)
assert log_payload['rows'] == [{'id': 3}]
def test_collect_field_path_warnings_for_invalid_root() -> None:
log_decorator = Log(title='测试日志', business_type=BusinessType.OTHER)
warnings = log_decorator._collect_field_path_warnings(
mode='include',
include_fields=('body.modelCode',),
exclude_fields=(),
payload_kind='request',
)
assert len(warnings) == 1
assert 'body.modelCode' in warnings[0]
assert 'json_body' in warnings[0]
def test_collect_field_path_warnings_accepts_normalized_root() -> None:
log_decorator = Log(title='测试日志', business_type=BusinessType.OTHER)
warnings = log_decorator._collect_field_path_warnings(
mode='include',
include_fields=('jsonBody.modelCode',),
exclude_fields=(),
payload_kind='request',
)
assert warnings == []
def test_collect_field_path_warnings_for_missing_include_fields() -> None:
log_decorator = Log(title='测试日志', business_type=BusinessType.OTHER)
warnings = log_decorator._collect_field_path_warnings(
mode='include',
include_fields=(),
exclude_fields=(),
payload_kind='response',
)
assert len(warnings) == 1
assert 'include模式' in warnings[0]
assert 'data' in warnings[0]
def test_collect_field_path_warnings_for_missing_exclude_fields() -> None:
log_decorator = Log(title='测试日志', business_type=BusinessType.OTHER)
warnings = log_decorator._collect_field_path_warnings(
mode='exclude',
include_fields=(),
exclude_fields=(),
payload_kind='request',
)
assert len(warnings) == 1
assert 'exclude模式' in warnings[0]
assert 'json_body' in warnings[0]
def test_log_warns_when_include_fields_do_not_match_mode() -> None:
with patch('common.annotation.log_annotation.logger.warning') as mock_warning:
Log(
title='测试日志',
business_type=BusinessType.OTHER,
request_log_mode='summary',
request_include_fields=('json_body.modelCode',),
)
mock_warning.assert_called_once()
assert '不会生效' in mock_warning.call_args.args[0]
def test_request_include_fields_invalid_root_raises_value_error() -> None:
try:
Log(
title='测试日志',
business_type=BusinessType.OTHER,
request_log_mode='include',
request_include_fields=('body.modelCode',),
)
except ValueError as exc:
assert 'body.modelCode' in str(exc)
else:
raise AssertionError('expected ValueError for invalid request include root')
def test_request_exclude_fields_invalid_root_raises_value_error() -> None:
try:
Log(
title='测试日志',
business_type=BusinessType.OTHER,
request_log_mode='exclude',
request_exclude_fields=('body.apiKey',),
)
except ValueError as exc:
assert 'body.apiKey' in str(exc)
else:
raise AssertionError('expected ValueError for invalid request exclude root')
def test_build_log_text_warns_for_missing_include_field() -> None:
log_decorator = Log(title='测试日志', business_type=BusinessType.OTHER)
with patch('common.annotation.log_annotation.logger.warning') as mock_warning:
log_text = log_decorator._build_log_text(
LogSanitizer.sanitize_data(
{
'json_body': {
'modelCode': 'deepseek-chat',
}
}
),
mode='include',
include_fields=('json_body.modleCode',),
exclude_fields=(),
payload_kind='request',
)
log_payload = json.loads(log_text)
assert log_payload['selected'] == {}
mock_warning.assert_called_once()
assert '未命中' in mock_warning.call_args.args[0]
assert 'modleCode' in mock_warning.call_args.args[0]
def test_missing_include_field_warning_only_emitted_once() -> None:
log_decorator = Log(title='测试日志', business_type=BusinessType.OTHER)
payload = LogSanitizer.sanitize_data({'data': {'userName': 'admin'}})
with patch('common.annotation.log_annotation.logger.warning') as mock_warning:
log_decorator._build_log_text(
payload,
mode='include',
include_fields=('data.userNmae',),
exclude_fields=(),
payload_kind='response',
)
log_decorator._build_log_text(
payload,
mode='include',
include_fields=('data.userNmae',),
exclude_fields=(),
payload_kind='response',
)
mock_warning.assert_called_once()
def test_include_fields_keep_explicit_none_value() -> None:
log_decorator = Log(title='测试日志', business_type=BusinessType.OTHER)
with patch('common.annotation.log_annotation.logger.warning') as mock_warning:
log_text = log_decorator._build_log_text(
LogSanitizer.sanitize_data(
{
'data': {
'userName': None,
}
}
),
mode='include',
include_fields=('data.userName',),
exclude_fields=(),
payload_kind='response',
)
log_payload = json.loads(log_text)
assert 'data.userName' in log_payload['selected']
assert log_payload['selected']['data.userName'] is None
mock_warning.assert_not_called()
def test_include_field_warning_reports_ambiguous_normalized_match() -> None:
log_decorator = Log(title='测试日志', business_type=BusinessType.OTHER)
with patch('common.annotation.log_annotation.logger.warning') as mock_warning:
log_text = log_decorator._build_log_text(
LogSanitizer.sanitize_data(
{
'data': {
'user_name': 'admin',
'userName': 'root',
}
}
),
mode='include',
include_fields=('data.username',),
exclude_fields=(),
payload_kind='response',
)
log_payload = json.loads(log_text)
assert log_payload['selected'] == {}
mock_warning.assert_called_once()
assert '命名冲突' in mock_warning.call_args.args[0]
assert 'user_name' in mock_warning.call_args.args[0]
assert 'userName' in mock_warning.call_args.args[0]
def test_build_log_text_warns_for_missing_exclude_field() -> None:
log_decorator = Log(title='测试日志', business_type=BusinessType.OTHER)
with patch('common.annotation.log_annotation.logger.warning') as mock_warning:
log_text = log_decorator._build_log_text(
LogSanitizer.sanitize_data(
{
'json_body': {
'modelCode': 'deepseek-chat',
}
}
),
mode='exclude',
include_fields=(),
exclude_fields=('json_body.apiKey',),
payload_kind='request',
)
log_payload = json.loads(log_text)
assert log_payload['json_body']['modelCode'] == 'deepseek-chat'
mock_warning.assert_called_once()
assert '排除路径未命中' in mock_warning.call_args.args[0]
def test_build_summary_payload_supports_message_field_fallback() -> None:
log_decorator = Log(title='测试日志', business_type=BusinessType.OTHER)
summary_payload = log_decorator._build_summary_payload({'code': 200, 'message': '获取成功'}, 'response')
status, error_msg = log_decorator._get_status_and_error_msg({'code': 500, 'message': '获取失败'})
assert summary_payload['msg'] == '获取成功'
assert status == 1
assert error_msg == '获取失败'
def test_log_decorator_truncates_oversized_json_result() -> None:
log_decorator = Log(title='测试日志', business_type=BusinessType.OTHER)
json_result = log_decorator._build_log_text(
{'code': 200, 'data': {'content': 'x' * 2100}},
mode='full',
include_fields=(),
exclude_fields=(),
payload_kind='response',
)
limited_json_result = log_decorator._limit_log_text(json_result, log_decorator._json_result_len, '返回参数过长')
assert limited_json_result == '返回参数过长'