初始提交

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