初始提交
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
from pathlib import Path
|
||||
|
||||
import typer
|
||||
from pytest import MonkeyPatch
|
||||
|
||||
from cli.completion.installers import (
|
||||
CompletionInstallerService,
|
||||
CompletionInstallerShellSupport,
|
||||
CompletionShellRuntimePolicy,
|
||||
CompletionShellRuntimePolicyRegistry,
|
||||
)
|
||||
|
||||
|
||||
def test_build_source_command_uses_shell_runtime_policy() -> None:
|
||||
"""
|
||||
校验安装服务会按 shell 运行时策略构建 source 命令。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
installer = CompletionInstallerService()
|
||||
target_file = Path('/tmp/shierfeng-completion')
|
||||
|
||||
assert installer.build_source_command(target_file, 'bash') == 'source /tmp/shierfeng-completion'
|
||||
assert installer.build_source_command(target_file, 'zsh') == 'source /tmp/shierfeng-completion'
|
||||
assert (
|
||||
installer.build_source_command(target_file, 'fish')
|
||||
== 'status --is-interactive; and source /tmp/shierfeng-completion'
|
||||
)
|
||||
assert installer.build_source_command(target_file, 'powershell') == '. "/tmp/shierfeng-completion"'
|
||||
|
||||
|
||||
def test_make_bash_completion_script_compatible_wraps_legacy_instructions() -> None:
|
||||
"""
|
||||
校验 Bash 兼容转换会包装旧版 shell 不支持的指令。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
script_text = 'compopt -o dirnames\ncompopt -o default\ncomplete -o nosort -F _shierfeng_completion shierfeng'
|
||||
|
||||
compatible_script = CompletionInstallerShellSupport.make_bash_completion_script_compatible(script_text)
|
||||
|
||||
assert 'if command -v compopt >/dev/null 2>&1; then' in compatible_script
|
||||
assert 'complete -F _shierfeng_completion shierfeng' in compatible_script
|
||||
assert 'complete -o nosort -F _shierfeng_completion shierfeng 2>/dev/null' in compatible_script
|
||||
|
||||
|
||||
def test_render_completion_script_uses_runtime_policy_transformer(monkeypatch: MonkeyPatch) -> None:
|
||||
"""
|
||||
校验脚本渲染会通过 shell 运行时策略执行脚本后处理。
|
||||
|
||||
:param monkeypatch: pytest monkeypatch 工具
|
||||
:return: None
|
||||
"""
|
||||
|
||||
class DummyComplete:
|
||||
"""
|
||||
模拟 Click completion 生成器。
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
"""
|
||||
初始化模拟生成器。
|
||||
|
||||
:param args: 位置参数
|
||||
:param kwargs: 关键字参数
|
||||
:return: None
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def source() -> str:
|
||||
"""
|
||||
返回原始脚本文本。
|
||||
|
||||
:return: 原始脚本文本
|
||||
"""
|
||||
return 'raw-script'
|
||||
|
||||
runtime_policy_registry = CompletionShellRuntimePolicyRegistry(
|
||||
policies={
|
||||
'bash': CompletionShellRuntimePolicy(
|
||||
name='bash',
|
||||
click_completion_class=DummyComplete,
|
||||
script_transformer=lambda script_text: f'transformed::{script_text}',
|
||||
source_command_builder=lambda target_file: f'source {target_file}',
|
||||
)
|
||||
}
|
||||
)
|
||||
installer = CompletionInstallerService(shell_runtime_policy_registry=runtime_policy_registry)
|
||||
monkeypatch.setattr(installer, 'build_completion_click_command', lambda root_cli: object())
|
||||
|
||||
rendered_script = installer.render_completion_script(typer.Typer(), 'bash')
|
||||
|
||||
assert rendered_script == 'transformed::raw-script'
|
||||
|
||||
|
||||
def test_render_powershell_completion_script_contains_native_completer() -> None:
|
||||
"""
|
||||
校验 PowerShell completion 脚本会注册原生命令补全器。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
installer = CompletionInstallerService()
|
||||
root_cli = typer.Typer()
|
||||
|
||||
@root_cli.command()
|
||||
def demo() -> None:
|
||||
return None
|
||||
|
||||
rendered_script = installer.render_completion_script(root_cli, 'powershell')
|
||||
|
||||
assert 'Register-ArgumentCompleter -Native -CommandName shierfeng' in rendered_script
|
||||
assert '$env:_SHIERFENG_COMPLETE = "powershell_complete"' in rendered_script
|
||||
@@ -0,0 +1,442 @@
|
||||
import importlib
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from pytest import MonkeyPatch
|
||||
|
||||
BACKEND_DIR = Path(__file__).resolve().parents[3]
|
||||
|
||||
sys.path.insert(0, str(BACKEND_DIR))
|
||||
sys.modules.pop('cli.completion.providers', None)
|
||||
sys.modules.pop('cli', None)
|
||||
|
||||
completion_support = importlib.import_module('cli.completion.providers')
|
||||
completion_gateway = completion_support.COMPLETION_PROVIDER_GATEWAY
|
||||
completion_registry = completion_gateway.provider_registry
|
||||
|
||||
|
||||
def test_complete_alembic_revisions_returns_default_choices_and_local_revisions(
|
||||
tmp_path: Path,
|
||||
monkeypatch: MonkeyPatch,
|
||||
) -> None:
|
||||
"""
|
||||
校验 Alembic 版本补全会返回默认候选和本地迁移版本。
|
||||
|
||||
:param tmp_path: pytest 临时目录
|
||||
:param monkeypatch: pytest monkeypatch 工具
|
||||
:return: None
|
||||
"""
|
||||
versions_dir = tmp_path / 'alembic' / 'versions'
|
||||
versions_dir.mkdir(parents=True)
|
||||
(versions_dir / '2026_04_29_1000-abc123_add_demo_table.py').write_text('# test', encoding='utf-8')
|
||||
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
candidates = completion_gateway.complete_alembic_revisions(None, None, 'a')
|
||||
default_candidates = completion_gateway.complete_alembic_revisions(None, None, '')
|
||||
|
||||
assert 'abc123' in candidates
|
||||
assert 'head' in default_candidates
|
||||
assert 'base' in default_candidates
|
||||
assert 'current' in default_candidates
|
||||
assert '-1' in default_candidates
|
||||
|
||||
|
||||
def test_complete_sql_files_returns_project_relative_sql_paths(
|
||||
tmp_path: Path,
|
||||
monkeypatch: MonkeyPatch,
|
||||
) -> None:
|
||||
"""
|
||||
校验 SQL 文件补全会返回项目相对路径。
|
||||
|
||||
:param tmp_path: pytest 临时目录
|
||||
:param monkeypatch: pytest monkeypatch 工具
|
||||
:return: None
|
||||
"""
|
||||
sql_dir = tmp_path / 'sql'
|
||||
sql_dir.mkdir()
|
||||
(sql_dir / 'demo.sql').write_text('select 1;', encoding='utf-8')
|
||||
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
candidates = completion_gateway.complete_sql_files(None, None, 'sql/d')
|
||||
|
||||
assert candidates == ['sql/demo.sql']
|
||||
|
||||
|
||||
def test_complete_cache_names_returns_static_cache_name_choices(monkeypatch: MonkeyPatch) -> None:
|
||||
"""
|
||||
校验缓存名称补全会返回系统内置缓存名称。
|
||||
|
||||
:param monkeypatch: pytest monkeypatch 工具
|
||||
:return: None
|
||||
"""
|
||||
|
||||
class _FakeCacheKeyConfig:
|
||||
"""
|
||||
模拟缓存键配置对象。
|
||||
"""
|
||||
|
||||
def __init__(self, key: str) -> None:
|
||||
"""
|
||||
初始化模拟缓存键配置对象。
|
||||
|
||||
:param key: 缓存名称
|
||||
:return: None
|
||||
"""
|
||||
self.key = key
|
||||
|
||||
class _FakeEnumsModule:
|
||||
"""
|
||||
模拟枚举模块。
|
||||
"""
|
||||
|
||||
RedisInitKeyConfig = [_FakeCacheKeyConfig('sys_config'), _FakeCacheKeyConfig('login_tokens')]
|
||||
|
||||
monkeypatch.setattr(
|
||||
completion_registry.dynamic_service, 'load_runtime_module', lambda module_name: _FakeEnumsModule
|
||||
)
|
||||
|
||||
candidates = completion_gateway.complete_cache_names(None, None, 'sys')
|
||||
|
||||
assert candidates == ['sys_config']
|
||||
|
||||
|
||||
def test_complete_output_paths_returns_directories_and_zip_files(
|
||||
tmp_path: Path,
|
||||
monkeypatch: MonkeyPatch,
|
||||
) -> None:
|
||||
"""
|
||||
校验导出路径补全会返回目录和 zip 文件。
|
||||
|
||||
:param tmp_path: pytest 临时目录
|
||||
:param monkeypatch: pytest monkeypatch 工具
|
||||
:return: None
|
||||
"""
|
||||
build_dir = tmp_path / 'build'
|
||||
build_dir.mkdir()
|
||||
(build_dir / 'gen.zip').write_text('zip', encoding='utf-8')
|
||||
(build_dir / 'notes.txt').write_text('ignore', encoding='utf-8')
|
||||
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
candidates = completion_gateway.complete_output_paths(None, None, 'build/')
|
||||
|
||||
assert 'build/gen.zip' in candidates
|
||||
assert 'build/notes.txt' not in candidates
|
||||
|
||||
|
||||
def test_complete_config_keys_returns_dynamic_readonly_choices(monkeypatch: MonkeyPatch) -> None:
|
||||
"""
|
||||
校验参数键名补全会返回动态只读查询结果。
|
||||
|
||||
:param monkeypatch: pytest monkeypatch 工具
|
||||
:return: None
|
||||
"""
|
||||
|
||||
class _FakeConfigRuntime:
|
||||
"""
|
||||
模拟配置运行时模块。
|
||||
"""
|
||||
|
||||
class ConfigRuntime:
|
||||
"""
|
||||
模拟配置运行时服务对象。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def list_configs(**kwargs: str) -> dict:
|
||||
"""
|
||||
模拟配置列表查询入口。
|
||||
|
||||
:param kwargs: 查询参数
|
||||
:return: 伪造的结果字典
|
||||
"""
|
||||
return kwargs
|
||||
|
||||
CONFIG_RUNTIME = ConfigRuntime()
|
||||
|
||||
monkeypatch.setattr(
|
||||
completion_registry.dynamic_service, 'load_runtime_module', lambda module_name: _FakeConfigRuntime
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
completion_registry.dynamic_service,
|
||||
'run_completion_coroutine',
|
||||
lambda coroutine, *, env: {
|
||||
'ok': True,
|
||||
'items': [
|
||||
{'configKey': 'sys.user.initPassword'},
|
||||
{'configKey': 'sys.user.maxRetryCount'},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
candidates = completion_gateway.complete_config_keys(None, None, 'sys.user.i')
|
||||
|
||||
assert candidates == ['sys.user.initPassword']
|
||||
|
||||
|
||||
def test_complete_gen_table_names_returns_dynamic_readonly_choices(monkeypatch: MonkeyPatch) -> None:
|
||||
"""
|
||||
校验代码生成业务表补全会返回动态只读查询结果。
|
||||
|
||||
:param monkeypatch: pytest monkeypatch 工具
|
||||
:return: None
|
||||
"""
|
||||
|
||||
class _FakeGenRuntime:
|
||||
"""
|
||||
模拟代码生成运行时模块。
|
||||
"""
|
||||
|
||||
class GenRuntime:
|
||||
"""
|
||||
模拟代码生成运行时服务对象。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def list_gen_tables(**kwargs: str) -> dict:
|
||||
"""
|
||||
模拟业务表列表查询入口。
|
||||
|
||||
:param kwargs: 查询参数
|
||||
:return: 伪造的结果字典
|
||||
"""
|
||||
return kwargs
|
||||
|
||||
@staticmethod
|
||||
def list_gen_db_tables(**kwargs: str) -> dict:
|
||||
"""
|
||||
模拟数据库物理表列表查询入口。
|
||||
|
||||
:param kwargs: 查询参数
|
||||
:return: 伪造的结果字典
|
||||
"""
|
||||
return kwargs
|
||||
|
||||
GEN_RUNTIME = GenRuntime()
|
||||
|
||||
monkeypatch.setattr(completion_registry.dynamic_service, 'load_runtime_module', lambda module_name: _FakeGenRuntime)
|
||||
monkeypatch.setattr(
|
||||
completion_registry.dynamic_service,
|
||||
'run_completion_coroutine',
|
||||
lambda coroutine, *, env: {
|
||||
'ok': True,
|
||||
'items': [
|
||||
{'tableName': 'sys_user'},
|
||||
{'tableName': 'sys_role'},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
candidates = completion_gateway.complete_gen_table_names(None, None, 'sys_u')
|
||||
|
||||
assert candidates == ['sys_user']
|
||||
|
||||
|
||||
def test_dynamic_completion_returns_empty_list_when_runtime_fails(monkeypatch: MonkeyPatch) -> None:
|
||||
"""
|
||||
校验动态补全在运行时失败时会优雅降级为空列表。
|
||||
|
||||
:param monkeypatch: pytest monkeypatch 工具
|
||||
:return: None
|
||||
"""
|
||||
monkeypatch.setattr(
|
||||
completion_registry.dynamic_service,
|
||||
'load_runtime_module',
|
||||
lambda module_name: (_ for _ in ()).throw(RuntimeError('boom')),
|
||||
)
|
||||
|
||||
candidates = completion_gateway.complete_gen_db_table_names(None, None, 'sys')
|
||||
|
||||
assert candidates == []
|
||||
|
||||
|
||||
def test_complete_cache_keys_returns_dynamic_readonly_choices(monkeypatch: MonkeyPatch) -> None:
|
||||
"""
|
||||
校验缓存键名补全会基于缓存名称返回动态只读查询结果。
|
||||
|
||||
:param monkeypatch: pytest monkeypatch 工具
|
||||
:return: None
|
||||
"""
|
||||
|
||||
class _FakeCacheRuntime:
|
||||
"""
|
||||
模拟缓存运行时模块。
|
||||
"""
|
||||
|
||||
class CacheRuntime:
|
||||
"""
|
||||
模拟缓存运行时服务对象。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def list_cache_keys(cache_name: str) -> dict[str, str]:
|
||||
"""
|
||||
模拟缓存键列表查询入口。
|
||||
|
||||
:param cache_name: 缓存名称
|
||||
:return: 伪造的参数字典
|
||||
"""
|
||||
return {'cache_name': cache_name}
|
||||
|
||||
CACHE_RUNTIME = CacheRuntime()
|
||||
|
||||
class _FakeContext:
|
||||
"""
|
||||
模拟 Click 上下文。
|
||||
"""
|
||||
|
||||
params = {'cache_name': 'sys_config'}
|
||||
|
||||
monkeypatch.setattr(
|
||||
completion_registry.dynamic_service, 'load_runtime_module', lambda module_name: _FakeCacheRuntime
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
completion_registry.dynamic_service,
|
||||
'run_completion_coroutine',
|
||||
lambda coroutine, *, env: {
|
||||
'ok': True,
|
||||
'keys': ['site.name', 'site.logo'],
|
||||
},
|
||||
)
|
||||
|
||||
candidates = completion_gateway.complete_cache_keys(_FakeContext(), None, 'site.n')
|
||||
|
||||
assert candidates == ['site.name']
|
||||
|
||||
|
||||
def test_complete_job_names_returns_dynamic_readonly_choices(monkeypatch: MonkeyPatch) -> None:
|
||||
"""
|
||||
校验任务名称补全会返回动态只读查询结果。
|
||||
|
||||
:param monkeypatch: pytest monkeypatch 工具
|
||||
:return: None
|
||||
"""
|
||||
|
||||
class _FakeJobRuntime:
|
||||
"""
|
||||
模拟任务运行时模块。
|
||||
"""
|
||||
|
||||
class JobRuntime:
|
||||
"""
|
||||
模拟任务运行时服务对象。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def list_jobs(**kwargs: str) -> dict:
|
||||
"""
|
||||
模拟任务列表查询入口。
|
||||
|
||||
:param kwargs: 查询参数
|
||||
:return: 伪造的参数字典
|
||||
"""
|
||||
return kwargs
|
||||
|
||||
JOB_RUNTIME = JobRuntime()
|
||||
|
||||
monkeypatch.setattr(completion_registry.dynamic_service, 'load_runtime_module', lambda module_name: _FakeJobRuntime)
|
||||
monkeypatch.setattr(
|
||||
completion_registry.dynamic_service,
|
||||
'run_completion_coroutine',
|
||||
lambda coroutine, *, env: {
|
||||
'ok': True,
|
||||
'items': [
|
||||
{'jobName': '同步任务'},
|
||||
{'jobName': '缓存预热'},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
candidates = completion_gateway.complete_job_names(None, None, '同')
|
||||
|
||||
assert candidates == ['同步任务']
|
||||
|
||||
|
||||
def test_complete_job_ids_returns_dynamic_readonly_choices(monkeypatch: MonkeyPatch) -> None:
|
||||
"""
|
||||
校验任务 ID 补全会返回动态只读查询结果。
|
||||
|
||||
:param monkeypatch: pytest monkeypatch 工具
|
||||
:return: None
|
||||
"""
|
||||
|
||||
class _FakeJobRuntime:
|
||||
"""
|
||||
模拟任务运行时模块。
|
||||
"""
|
||||
|
||||
class JobRuntime:
|
||||
"""
|
||||
模拟任务运行时服务对象。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def list_jobs(**kwargs: str) -> dict:
|
||||
"""
|
||||
模拟任务列表查询入口。
|
||||
|
||||
:param kwargs: 查询参数
|
||||
:return: 伪造的参数字典
|
||||
"""
|
||||
return kwargs
|
||||
|
||||
JOB_RUNTIME = JobRuntime()
|
||||
|
||||
monkeypatch.setattr(completion_registry.dynamic_service, 'load_runtime_module', lambda module_name: _FakeJobRuntime)
|
||||
monkeypatch.setattr(
|
||||
completion_registry.dynamic_service,
|
||||
'run_completion_coroutine',
|
||||
lambda coroutine, *, env: {
|
||||
'ok': True,
|
||||
'items': [
|
||||
{'jobId': 101},
|
||||
{'jobId': 202},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
candidates = completion_gateway.complete_job_ids(None, None, '1')
|
||||
|
||||
assert candidates == ['101']
|
||||
|
||||
|
||||
def test_run_completion_coroutine_scopes_app_env(monkeypatch: MonkeyPatch) -> None:
|
||||
"""
|
||||
校验动态补全执行时会临时注入 APP_ENV,并在结束后恢复原值。
|
||||
|
||||
:param monkeypatch: pytest monkeypatch 工具
|
||||
:return: None
|
||||
"""
|
||||
monkeypatch.setenv('APP_ENV', 'prod')
|
||||
captured: dict[str, str | None] = {}
|
||||
|
||||
async def _read_env() -> str | None:
|
||||
captured['during'] = os.environ.get('APP_ENV')
|
||||
return captured['during']
|
||||
|
||||
result = completion_registry.dynamic_service.run_completion_coroutine(_read_env(), env='dev')
|
||||
|
||||
assert result == 'dev'
|
||||
assert captured['during'] == 'dev'
|
||||
assert os.environ.get('APP_ENV') == 'prod'
|
||||
|
||||
|
||||
def test_run_completion_coroutine_clears_scoped_app_env_when_original_missing(monkeypatch: MonkeyPatch) -> None:
|
||||
"""
|
||||
校验动态补全执行结束后会移除临时注入的 APP_ENV。
|
||||
|
||||
:param monkeypatch: pytest monkeypatch 工具
|
||||
:return: None
|
||||
"""
|
||||
monkeypatch.delenv('APP_ENV', raising=False)
|
||||
|
||||
async def _read_env() -> str | None:
|
||||
return os.environ.get('APP_ENV')
|
||||
|
||||
result = completion_registry.dynamic_service.run_completion_coroutine(_read_env(), env='dockerpg')
|
||||
|
||||
assert result == 'dockerpg'
|
||||
assert 'APP_ENV' not in os.environ
|
||||
@@ -0,0 +1,74 @@
|
||||
import importlib
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from cli.exit_codes import RUNTIME_ERROR
|
||||
|
||||
BACKEND_DIR = Path(__file__).resolve().parents[3]
|
||||
|
||||
sys.path.insert(0, str(BACKEND_DIR))
|
||||
sys.modules.pop('cli.core.execution', None)
|
||||
sys.modules.pop('cli', None)
|
||||
|
||||
cli_execution = importlib.import_module('cli.core.execution')
|
||||
EXPLICIT_EXIT_CODE = 23
|
||||
DEFAULT_EXIT_CODE = 7
|
||||
|
||||
|
||||
def test_build_result_does_not_mutate_payload_and_removes_exit_code_from_result_data() -> None:
|
||||
"""
|
||||
校验结果翻译不会修改调用方 payload,且会从结果数据中移除 exit_code 字段。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
payload = {'ok': True, 'message': 'done', 'exit_code': EXPLICIT_EXIT_CODE}
|
||||
|
||||
result = cli_execution.CliExecutionService.build_result(payload)
|
||||
|
||||
assert result.exit_code == EXPLICIT_EXIT_CODE
|
||||
assert result.data == {'ok': True, 'message': 'done'}
|
||||
assert payload == {'ok': True, 'message': 'done', 'exit_code': EXPLICIT_EXIT_CODE}
|
||||
|
||||
|
||||
def test_build_result_uses_runtime_error_when_failed_payload_has_no_explicit_exit_code() -> None:
|
||||
"""
|
||||
校验失败 payload 未显式提供退出码时,会回退到统一运行时错误码。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
payload = {'ok': False, 'message': 'failed'}
|
||||
|
||||
result = cli_execution.CliExecutionService.build_result(payload)
|
||||
|
||||
assert result.exit_code == RUNTIME_ERROR
|
||||
assert result.data == {'ok': False, 'message': 'failed'}
|
||||
assert payload == {'ok': False, 'message': 'failed'}
|
||||
|
||||
|
||||
def test_build_result_uses_default_exit_code_without_mutating_payload() -> None:
|
||||
"""
|
||||
校验显式默认退出码会参与结果翻译,且不会修改原始 payload。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
payload = {'ok': True, 'message': 'warn'}
|
||||
|
||||
result = cli_execution.CliExecutionService.build_result(payload, default_exit_code=DEFAULT_EXIT_CODE)
|
||||
|
||||
assert result.exit_code == DEFAULT_EXIT_CODE
|
||||
assert result.data == {'ok': True, 'message': 'warn'}
|
||||
assert payload == {'ok': True, 'message': 'warn'}
|
||||
|
||||
|
||||
def test_build_result_uses_runtime_error_when_failed_payload_keeps_default_zero() -> None:
|
||||
"""
|
||||
校验失败 payload 在未显式提供退出码且默认退出码为 0 时,会回退到统一运行时错误码。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
payload = {'ok': False, 'message': 'failed'}
|
||||
|
||||
result = cli_execution.CliExecutionService.build_result(payload, default_exit_code=0)
|
||||
|
||||
assert result.exit_code == RUNTIME_ERROR
|
||||
assert result.data == {'ok': False, 'message': 'failed'}
|
||||
@@ -0,0 +1,93 @@
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def backend_dir() -> Path:
|
||||
return Path(__file__).resolve().parents[3]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def run_cli_command(backend_dir: Path) -> Callable[..., subprocess.CompletedProcess[str]]:
|
||||
def _run_cli_command(*args: str) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
[sys.executable, '-m', 'cli.main', *args],
|
||||
cwd=backend_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
return _run_cli_command
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def run_text_cli_command(
|
||||
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
) -> Callable[..., subprocess.CompletedProcess[str]]:
|
||||
def _run_text_cli_command(*args: str) -> subprocess.CompletedProcess[str]:
|
||||
return run_cli_command('--color=never', '--icon=none', *args)
|
||||
|
||||
return _run_text_cli_command
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def run_cli_completion_command(
|
||||
backend_dir: Path,
|
||||
) -> Callable[..., subprocess.CompletedProcess[str]]:
|
||||
def _run_cli_completion_command(
|
||||
*,
|
||||
comp_words: str,
|
||||
comp_cword: int | str,
|
||||
instruction: str = 'bash_complete',
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
[sys.executable, '-m', 'cli.main'],
|
||||
cwd=backend_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env={
|
||||
**os.environ,
|
||||
'COMP_WORDS': comp_words,
|
||||
'COMP_CWORD': str(comp_cword),
|
||||
'_SHIERFENG_COMPLETE': instruction,
|
||||
},
|
||||
)
|
||||
|
||||
return _run_cli_completion_command
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def parse_json_stdout() -> Callable[[subprocess.CompletedProcess[str]], dict[str, Any]]:
|
||||
def _parse_json_stdout(completed: subprocess.CompletedProcess[str]) -> dict[str, Any]:
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
return _parse_json_stdout
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def assert_check_payload_contract() -> Callable[[dict[str, Any], bool], None]:
|
||||
def _assert_check_payload_contract(payload: dict[str, Any], allow_exit_code: bool) -> None:
|
||||
assert isinstance(payload, dict)
|
||||
assert isinstance(payload.get('ok'), bool)
|
||||
assert isinstance(payload.get('message'), str)
|
||||
|
||||
if payload['ok']:
|
||||
if 'error' in payload:
|
||||
assert isinstance(payload['error'], str)
|
||||
if 'exit_code' in payload:
|
||||
assert isinstance(payload['exit_code'], int)
|
||||
else:
|
||||
assert isinstance(payload.get('error'), str)
|
||||
if allow_exit_code:
|
||||
assert isinstance(payload.get('exit_code'), int)
|
||||
|
||||
return _assert_check_payload_contract
|
||||
@@ -0,0 +1,184 @@
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
BACKEND_DIR = Path(__file__).resolve().parents[3]
|
||||
|
||||
|
||||
def test_bootstrap_import_does_not_eagerly_load_heavy_runtime_modules() -> None:
|
||||
"""
|
||||
校验导入 `cli.bootstrap` 时不会提前加载重依赖模块。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
script = """
|
||||
import json
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, '.')
|
||||
for module_name in [
|
||||
'cli.bootstrap',
|
||||
'config.database',
|
||||
'config.get_redis',
|
||||
'config.get_scheduler',
|
||||
'module_admin.service.server_service',
|
||||
'utils.transport_crypto_util',
|
||||
]:
|
||||
sys.modules.pop(module_name, None)
|
||||
|
||||
import cli.bootstrap
|
||||
|
||||
print(json.dumps({
|
||||
'config.database': 'config.database' in sys.modules,
|
||||
'config.get_redis': 'config.get_redis' in sys.modules,
|
||||
'config.get_scheduler': 'config.get_scheduler' in sys.modules,
|
||||
'module_admin.service.server_service': 'module_admin.service.server_service' in sys.modules,
|
||||
'utils.transport_crypto_util': 'utils.transport_crypto_util' in sys.modules,
|
||||
}, ensure_ascii=False))
|
||||
"""
|
||||
completed = subprocess.run(
|
||||
[sys.executable, '-c', script],
|
||||
cwd=BACKEND_DIR,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert completed.returncode == 0, completed.stderr
|
||||
payload = json.loads(completed.stdout)
|
||||
|
||||
assert payload == {
|
||||
'config.database': False,
|
||||
'config.get_redis': False,
|
||||
'config.get_scheduler': False,
|
||||
'module_admin.service.server_service': False,
|
||||
'utils.transport_crypto_util': False,
|
||||
}
|
||||
|
||||
|
||||
def test_build_cli_does_not_eagerly_load_heavy_runtime_dependencies() -> None:
|
||||
"""
|
||||
校验构建 CLI 根应用时不会提前加载 DB、Redis、Scheduler 和业务服务依赖。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
script = """
|
||||
import json
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, '.')
|
||||
for module_name in [
|
||||
'cli.main',
|
||||
'config.env',
|
||||
'config.database',
|
||||
'config.get_redis',
|
||||
'config.get_scheduler',
|
||||
'cli.tui.app',
|
||||
'module_admin.service.server_service',
|
||||
'module_admin.service.job_service',
|
||||
'module_admin.service.config_service',
|
||||
'module_generator.service.gen_service',
|
||||
'textual',
|
||||
'utils.transport_crypto_util',
|
||||
]:
|
||||
sys.modules.pop(module_name, None)
|
||||
|
||||
import cli.main
|
||||
|
||||
cli.main.CLI_APPLICATION_BUILDER.build()
|
||||
|
||||
print(json.dumps({
|
||||
'config.env': 'config.env' in sys.modules,
|
||||
'config.database': 'config.database' in sys.modules,
|
||||
'config.get_redis': 'config.get_redis' in sys.modules,
|
||||
'config.get_scheduler': 'config.get_scheduler' in sys.modules,
|
||||
'cli.tui.app': 'cli.tui.app' in sys.modules,
|
||||
'module_admin.service.server_service': 'module_admin.service.server_service' in sys.modules,
|
||||
'module_admin.service.job_service': 'module_admin.service.job_service' in sys.modules,
|
||||
'module_admin.service.config_service': 'module_admin.service.config_service' in sys.modules,
|
||||
'module_generator.service.gen_service': 'module_generator.service.gen_service' in sys.modules,
|
||||
'textual': 'textual' in sys.modules,
|
||||
'utils.transport_crypto_util': 'utils.transport_crypto_util' in sys.modules,
|
||||
}, ensure_ascii=False))
|
||||
"""
|
||||
completed = subprocess.run(
|
||||
[sys.executable, '-c', script],
|
||||
cwd=BACKEND_DIR,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert completed.returncode == 0, completed.stderr
|
||||
payload = json.loads(completed.stdout)
|
||||
|
||||
assert payload == {
|
||||
'config.env': False,
|
||||
'config.database': False,
|
||||
'config.get_redis': False,
|
||||
'config.get_scheduler': False,
|
||||
'cli.tui.app': False,
|
||||
'module_admin.service.server_service': False,
|
||||
'module_admin.service.job_service': False,
|
||||
'module_admin.service.config_service': False,
|
||||
'module_generator.service.gen_service': False,
|
||||
'textual': False,
|
||||
'utils.transport_crypto_util': False,
|
||||
}
|
||||
|
||||
|
||||
def test_build_wizard_app_does_not_eagerly_load_flow_modules() -> None:
|
||||
"""
|
||||
校验构建 `wizard` 子应用时不会提前加载各个 flow 模块。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
script = """
|
||||
import json
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, '.')
|
||||
for module_name in [
|
||||
'cli.wizard.commands',
|
||||
'cli.wizard.flows.app_run',
|
||||
'cli.wizard.flows.db_upgrade',
|
||||
'cli.wizard.flows.cache_clear',
|
||||
'cli.wizard.flows.gen_export',
|
||||
'cli.wizard.flows.gen_import',
|
||||
'cli.wizard.flows.prod_check',
|
||||
]:
|
||||
sys.modules.pop(module_name, None)
|
||||
|
||||
from cli.wizard.commands import WIZARD_COMMAND_BUILDER
|
||||
|
||||
WIZARD_COMMAND_BUILDER.build()
|
||||
|
||||
print(json.dumps({
|
||||
'cli.wizard.flows.app_run': 'cli.wizard.flows.app_run' in sys.modules,
|
||||
'cli.wizard.flows.db_upgrade': 'cli.wizard.flows.db_upgrade' in sys.modules,
|
||||
'cli.wizard.flows.cache_clear': 'cli.wizard.flows.cache_clear' in sys.modules,
|
||||
'cli.wizard.flows.gen_export': 'cli.wizard.flows.gen_export' in sys.modules,
|
||||
'cli.wizard.flows.gen_import': 'cli.wizard.flows.gen_import' in sys.modules,
|
||||
'cli.wizard.flows.prod_check': 'cli.wizard.flows.prod_check' in sys.modules,
|
||||
}, ensure_ascii=False))
|
||||
"""
|
||||
completed = subprocess.run(
|
||||
[sys.executable, '-c', script],
|
||||
cwd=BACKEND_DIR,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert completed.returncode == 0, completed.stderr
|
||||
payload = json.loads(completed.stdout)
|
||||
|
||||
assert payload == {
|
||||
'cli.wizard.flows.app_run': False,
|
||||
'cli.wizard.flows.db_upgrade': False,
|
||||
'cli.wizard.flows.cache_clear': False,
|
||||
'cli.wizard.flows.gen_export': False,
|
||||
'cli.wizard.flows.gen_import': False,
|
||||
'cli.wizard.flows.prod_check': False,
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
import subprocess
|
||||
from collections.abc import Callable
|
||||
|
||||
from cli.exit_codes import DEPENDENCY_ERROR, SUCCESS
|
||||
|
||||
|
||||
def test_app_config_json_output_is_pure_json(
|
||||
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
|
||||
) -> None:
|
||||
completed = run_cli_command('app', 'config', '--env=dev', '--output=json')
|
||||
payload = parse_json_stdout(completed)
|
||||
|
||||
assert completed.returncode == 0
|
||||
assert completed.stderr == ''
|
||||
assert payload['ok'] is True
|
||||
assert payload['env'] == 'dev'
|
||||
assert isinstance(payload['config'], dict)
|
||||
assert '\u001b[' not in completed.stdout
|
||||
assert '✅' not in completed.stdout
|
||||
assert '❌' not in completed.stdout
|
||||
|
||||
|
||||
def test_app_config_text_output_has_stable_section_structure(
|
||||
run_text_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
) -> None:
|
||||
completed = run_text_cli_command('app', 'config', '--env=dev', '--output=text')
|
||||
|
||||
assert completed.returncode == SUCCESS
|
||||
assert '\x1b[' not in completed.stdout
|
||||
assert '✅' not in completed.stdout
|
||||
assert completed.stdout.startswith('OK SUCCESS\n')
|
||||
assert 'env: dev\n' in completed.stdout
|
||||
assert 'application:\n' in completed.stdout
|
||||
assert 'database:\n' in completed.stdout
|
||||
assert 'redis:\n' in completed.stdout
|
||||
assert 'logging:\n' in completed.stdout
|
||||
assert 'transport_crypto:\n' in completed.stdout
|
||||
|
||||
|
||||
def test_app_env_text_output_has_stable_section_structure(
|
||||
run_text_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
) -> None:
|
||||
completed = run_text_cli_command('app', 'env', '--env=dev', '--output=text')
|
||||
|
||||
assert completed.returncode == SUCCESS
|
||||
assert completed.stdout.startswith('OK SUCCESS\n')
|
||||
assert 'env: dev\n' in completed.stdout
|
||||
assert 'runtime:\n' in completed.stdout
|
||||
assert 'cli_env: dev\n' in completed.stdout
|
||||
assert 'config_env:' in completed.stdout
|
||||
assert 'app_env: dev\n' in completed.stdout
|
||||
assert 'env_file: .env.dev\n' in completed.stdout
|
||||
assert 'env_file_exists:' in completed.stdout
|
||||
assert 'backend_dir:' in completed.stdout
|
||||
assert 'python_executable:' in completed.stdout
|
||||
|
||||
|
||||
def test_app_doctor_text_output_has_stable_check_structure(
|
||||
run_text_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
) -> None:
|
||||
completed = run_text_cli_command('app', 'doctor', '--env=dev', '--output=text')
|
||||
|
||||
assert completed.returncode in {SUCCESS, DEPENDENCY_ERROR}
|
||||
assert '\x1b[' not in completed.stdout
|
||||
assert '✅' not in completed.stdout
|
||||
assert completed.stdout.startswith(('OK SUCCESS\n', 'FAIL FAILED\n'))
|
||||
assert 'env: dev\n' in completed.stdout
|
||||
assert 'checks:\n' in completed.stdout
|
||||
assert 'database:' in completed.stdout
|
||||
assert 'redis:' in completed.stdout
|
||||
assert 'crypto:' in completed.stdout
|
||||
|
||||
|
||||
def test_app_doctor_json_output_has_stable_contract(
|
||||
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
|
||||
assert_check_payload_contract: Callable[[dict, bool], None],
|
||||
) -> None:
|
||||
completed = run_cli_command('app', 'doctor', '--env=dev', '--output=json')
|
||||
payload = parse_json_stdout(completed)
|
||||
|
||||
assert completed.returncode in {SUCCESS, DEPENDENCY_ERROR}
|
||||
assert completed.stderr == ''
|
||||
assert payload['env'] == 'dev'
|
||||
assert isinstance(payload['ok'], bool)
|
||||
assert set(payload) == {'env', 'database', 'redis', 'crypto', 'ok'}
|
||||
assert_check_payload_contract(payload['database'], True)
|
||||
assert_check_payload_contract(payload['redis'], True)
|
||||
assert_check_payload_contract(payload['crypto'], False)
|
||||
|
||||
|
||||
def test_app_env_json_output_has_stable_contract(
|
||||
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
|
||||
) -> None:
|
||||
completed = run_cli_command('app', 'env', '--env=dev', '--output=json')
|
||||
payload = parse_json_stdout(completed)
|
||||
|
||||
assert completed.returncode == SUCCESS
|
||||
assert payload['ok'] is True
|
||||
assert payload['env'] == 'dev'
|
||||
assert set(payload) == {'ok', 'env', 'runtime'}
|
||||
assert isinstance(payload['runtime'], dict)
|
||||
assert set(payload['runtime']) == {
|
||||
'cliEnv',
|
||||
'configEnv',
|
||||
'appEnv',
|
||||
'envFile',
|
||||
'envFilePath',
|
||||
'envFileExists',
|
||||
'backendDir',
|
||||
'pythonExecutable',
|
||||
}
|
||||
assert payload['runtime']['cliEnv'] == 'dev'
|
||||
assert payload['runtime']['appEnv'] == 'dev'
|
||||
assert payload['runtime']['envFile'] == '.env.dev'
|
||||
assert isinstance(payload['runtime']['envFileExists'], bool)
|
||||
|
||||
|
||||
def test_app_routes_json_output_has_stable_contract(
|
||||
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
|
||||
) -> None:
|
||||
completed = run_cli_command('app', 'routes', '--env=dev', '--output=json')
|
||||
payload = parse_json_stdout(completed)
|
||||
|
||||
assert completed.returncode == 0
|
||||
assert payload['ok'] is True
|
||||
assert payload['env'] == 'dev'
|
||||
assert isinstance(payload['count'], int)
|
||||
assert payload['count'] > 0
|
||||
assert payload['groupedRoutes'] is None
|
||||
assert payload['filters'] == {
|
||||
'pathPrefix': '',
|
||||
'method': '',
|
||||
'groupBy': 'none',
|
||||
'includeHidden': False,
|
||||
}
|
||||
assert isinstance(payload['routes'], list)
|
||||
assert payload['routes']
|
||||
|
||||
first_route = payload['routes'][0]
|
||||
assert set(first_route) == {
|
||||
'path',
|
||||
'methods',
|
||||
'name',
|
||||
'summary',
|
||||
'operationId',
|
||||
'tags',
|
||||
'includeInSchema',
|
||||
}
|
||||
assert isinstance(first_route['path'], str)
|
||||
assert isinstance(first_route['methods'], list)
|
||||
assert first_route['methods']
|
||||
assert all(isinstance(method, str) for method in first_route['methods'])
|
||||
assert isinstance(first_route['name'], str | None)
|
||||
assert isinstance(first_route['summary'], str | None)
|
||||
assert isinstance(first_route['operationId'], str | None)
|
||||
assert isinstance(first_route['tags'], list)
|
||||
assert all(isinstance(tag, str) for tag in first_route['tags'])
|
||||
assert isinstance(first_route['includeInSchema'], bool)
|
||||
|
||||
|
||||
def test_server_info_text_output_has_stable_section_structure(
|
||||
run_text_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
) -> None:
|
||||
completed = run_text_cli_command('ops', 'server-info', '--env=dev')
|
||||
|
||||
assert completed.returncode == 0
|
||||
assert '\x1b[' not in completed.stdout
|
||||
assert '✅' not in completed.stdout
|
||||
assert completed.stdout.startswith('OK SUCCESS\n')
|
||||
assert 'host:\n' in completed.stdout
|
||||
assert 'cpu:\n' in completed.stdout
|
||||
assert 'memory:\n' in completed.stdout
|
||||
assert 'python:\n' in completed.stdout
|
||||
assert 'disks:' in completed.stdout
|
||||
|
||||
|
||||
def test_ops_health_text_output_has_stable_check_structure(
|
||||
run_text_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
) -> None:
|
||||
completed = run_text_cli_command('ops', 'health', '--env=dev', '--output=text')
|
||||
|
||||
assert completed.returncode in {SUCCESS, DEPENDENCY_ERROR}
|
||||
assert '\x1b[' not in completed.stdout
|
||||
assert '✅' not in completed.stdout
|
||||
assert completed.stdout.startswith(('OK SUCCESS\n', 'FAIL FAILED\n'))
|
||||
assert 'env: dev\n' in completed.stdout
|
||||
assert 'checks:\n' in completed.stdout
|
||||
assert 'database:' in completed.stdout
|
||||
assert 'redis:' in completed.stdout
|
||||
|
||||
|
||||
def test_ops_deps_text_output_has_stable_structure(
|
||||
run_text_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
) -> None:
|
||||
completed = run_text_cli_command('ops', 'deps', '--env=dev', '--output=text')
|
||||
|
||||
assert completed.returncode in {SUCCESS, DEPENDENCY_ERROR}
|
||||
assert completed.stdout.startswith(('OK SUCCESS\n', 'FAIL FAILED\n'))
|
||||
assert 'message:' in completed.stdout
|
||||
assert 'include_dev: false\n' in completed.stdout
|
||||
assert 'missing_required:' in completed.stdout
|
||||
assert 'packages:\n' in completed.stdout
|
||||
assert 'python:' in completed.stdout
|
||||
assert 'fastapi:' in completed.stdout
|
||||
assert 'typer:' in completed.stdout
|
||||
|
||||
|
||||
def test_ops_health_json_output_has_stable_contract(
|
||||
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
|
||||
assert_check_payload_contract: Callable[[dict, bool], None],
|
||||
) -> None:
|
||||
completed = run_cli_command('ops', 'health', '--env=dev', '--output=json')
|
||||
payload = parse_json_stdout(completed)
|
||||
|
||||
assert completed.returncode in {SUCCESS, DEPENDENCY_ERROR}
|
||||
assert completed.stderr == ''
|
||||
assert payload['env'] == 'dev'
|
||||
assert isinstance(payload['ok'], bool)
|
||||
assert set(payload) == {'env', 'database', 'redis', 'ok'}
|
||||
assert_check_payload_contract(payload['database'], True)
|
||||
assert_check_payload_contract(payload['redis'], True)
|
||||
|
||||
|
||||
def test_ops_deps_json_output_has_stable_contract(
|
||||
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
|
||||
) -> None:
|
||||
completed = run_cli_command('ops', 'deps', '--env=dev', '--output=json')
|
||||
payload = parse_json_stdout(completed)
|
||||
|
||||
assert completed.returncode in {SUCCESS, DEPENDENCY_ERROR}
|
||||
assert set(payload) == {'ok', 'message', 'missingRequired', 'includeDev', 'packages'}
|
||||
assert isinstance(payload['ok'], bool)
|
||||
assert isinstance(payload['message'], str)
|
||||
assert isinstance(payload['missingRequired'], list)
|
||||
assert payload['includeDev'] is False
|
||||
assert isinstance(payload['packages'], dict)
|
||||
assert 'python' in payload['packages']
|
||||
assert 'fastapi' in payload['packages']
|
||||
assert 'typer' in payload['packages']
|
||||
for package_payload in payload['packages'].values():
|
||||
assert isinstance(package_payload, dict)
|
||||
assert isinstance(package_payload.get('installed'), bool)
|
||||
assert isinstance(package_payload.get('version'), str)
|
||||
assert isinstance(package_payload.get('required'), bool)
|
||||
|
||||
|
||||
def test_ops_server_info_json_output_has_stable_contract(
|
||||
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
|
||||
) -> None:
|
||||
completed = run_cli_command('ops', 'server-info', '--env=dev', '--output=json')
|
||||
payload = parse_json_stdout(completed)
|
||||
|
||||
assert completed.returncode == 0
|
||||
assert payload['ok'] is True
|
||||
assert isinstance(payload['server'], dict)
|
||||
|
||||
server = payload['server']
|
||||
assert set(server) == {'cpu', 'mem', 'sys', 'py', 'sysFiles'}
|
||||
|
||||
cpu = server['cpu']
|
||||
assert set(cpu) == {'cpuNum', 'used', 'sys', 'free'}
|
||||
assert isinstance(cpu['cpuNum'], int)
|
||||
assert isinstance(cpu['used'], float | int | str)
|
||||
assert isinstance(cpu['sys'], float | int | str)
|
||||
assert isinstance(cpu['free'], float | int | str)
|
||||
|
||||
memory = server['mem']
|
||||
assert set(memory) == {'total', 'used', 'free', 'usage'}
|
||||
assert isinstance(memory['total'], str)
|
||||
assert isinstance(memory['used'], str)
|
||||
assert isinstance(memory['free'], str)
|
||||
assert isinstance(memory['usage'], float | int)
|
||||
|
||||
sys_info = server['sys']
|
||||
assert set(sys_info) == {'computerIp', 'computerName', 'osArch', 'osName', 'userDir'}
|
||||
assert all(isinstance(value, str) for value in sys_info.values())
|
||||
|
||||
py_info = server['py']
|
||||
assert set(py_info) == {
|
||||
'name',
|
||||
'version',
|
||||
'startTime',
|
||||
'runTime',
|
||||
'home',
|
||||
'total',
|
||||
'used',
|
||||
'free',
|
||||
'usage',
|
||||
}
|
||||
assert isinstance(py_info['name'], str)
|
||||
assert isinstance(py_info['version'], str)
|
||||
assert isinstance(py_info['startTime'], str)
|
||||
assert isinstance(py_info['runTime'], str)
|
||||
assert isinstance(py_info['home'], str)
|
||||
assert isinstance(py_info['total'], str)
|
||||
assert isinstance(py_info['used'], str)
|
||||
assert isinstance(py_info['free'], str)
|
||||
assert isinstance(py_info['usage'], float | int)
|
||||
|
||||
assert isinstance(server['sysFiles'], list)
|
||||
assert server['sysFiles']
|
||||
first_disk = server['sysFiles'][0]
|
||||
assert set(first_disk) == {'dirName', 'sysTypeName', 'typeName', 'total', 'used', 'free', 'usage'}
|
||||
assert all(isinstance(value, str) for value in first_disk.values())
|
||||
@@ -0,0 +1,207 @@
|
||||
import subprocess
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
from cli.exit_codes import SUCCESS
|
||||
|
||||
|
||||
def test_root_help_shows_commands_without_completion_options(
|
||||
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
) -> None:
|
||||
completed = run_cli_command('--help')
|
||||
|
||||
assert completed.returncode == 0
|
||||
assert 'Usage: shierfeng [OPTIONS] COMMAND [ARGS]...' in completed.stdout
|
||||
assert 'app' in completed.stdout
|
||||
assert 'db' in completed.stdout
|
||||
assert 'completion' in completed.stdout
|
||||
assert 'wizard' in completed.stdout
|
||||
assert 'tui' in completed.stdout
|
||||
assert completed.stdout.index('wizard') < completed.stdout.index('tui')
|
||||
assert '--install-completion' not in completed.stdout
|
||||
assert '--show-completion' not in completed.stdout
|
||||
|
||||
|
||||
def test_completion_show_bash_outputs_completion_script(
|
||||
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
) -> None:
|
||||
completed = run_cli_command('completion', 'show', 'bash')
|
||||
|
||||
assert completed.returncode == SUCCESS
|
||||
assert completed.stderr == ''
|
||||
assert '_SHIERFENG_COMPLETE=bash_complete' in completed.stdout
|
||||
assert '_shierfeng_completion()' in completed.stdout
|
||||
assert 'if command -v compopt >/dev/null 2>&1; then' in completed.stdout
|
||||
assert 'if complete -o nosort -F _shierfeng_completion shierfeng 2>/dev/null; then' in completed.stdout
|
||||
assert 'complete -F _shierfeng_completion shierfeng' in completed.stdout
|
||||
|
||||
|
||||
def test_completion_show_powershell_outputs_completion_script(
|
||||
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
) -> None:
|
||||
completed = run_cli_command('completion', 'show', 'powershell')
|
||||
|
||||
assert completed.returncode == SUCCESS
|
||||
assert completed.stderr == ''
|
||||
assert 'Register-ArgumentCompleter -Native -CommandName shierfeng' in completed.stdout
|
||||
assert '$env:_SHIERFENG_COMPLETE = "powershell_complete"' in completed.stdout
|
||||
|
||||
|
||||
def test_bash_completion_protocol_returns_candidates_without_not_supported_error(
|
||||
run_cli_completion_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
) -> None:
|
||||
completed = run_cli_completion_command(comp_words='shierfeng comp', comp_cword=1)
|
||||
|
||||
assert completed.returncode == SUCCESS
|
||||
assert 'plain,completion' in completed.stdout
|
||||
assert 'not supported' not in completed.stdout.lower()
|
||||
assert completed.stderr == ''
|
||||
|
||||
|
||||
def test_powershell_completion_protocol_returns_candidates_without_not_supported_error(
|
||||
run_cli_completion_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
) -> None:
|
||||
completed = run_cli_completion_command(
|
||||
comp_words='shierfeng comp',
|
||||
comp_cword='comp',
|
||||
instruction='powershell_complete',
|
||||
)
|
||||
|
||||
assert completed.returncode == SUCCESS
|
||||
assert 'completion' in completed.stdout
|
||||
assert 'not supported' not in completed.stdout.lower()
|
||||
assert completed.stderr == ''
|
||||
|
||||
|
||||
def test_completion_doctor_json_output_has_stable_contract(
|
||||
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
|
||||
) -> None:
|
||||
completed = run_cli_command('completion', 'doctor', '--output=json')
|
||||
payload = parse_json_stdout(completed)
|
||||
|
||||
assert completed.returncode == SUCCESS
|
||||
assert payload['ok'] is True
|
||||
assert payload['message'] == 'completion 诊断信息已生成'
|
||||
assert set(payload) == {
|
||||
'ok',
|
||||
'message',
|
||||
'activeShell',
|
||||
'projectDir',
|
||||
'envChoices',
|
||||
'completeEnvVar',
|
||||
'recommendedInstallCommand',
|
||||
'shells',
|
||||
}
|
||||
assert isinstance(payload['envChoices'], list)
|
||||
assert payload['completeEnvVar'] == '_SHIERFENG_COMPLETE'
|
||||
assert isinstance(payload['shells'], dict)
|
||||
assert set(payload['shells']) == {'bash', 'zsh', 'fish', 'powershell'}
|
||||
assert payload['shells']['bash']['supported'] is True
|
||||
assert payload['shells']['fish']['autoDiscovery'] is True
|
||||
assert payload['shells']['powershell']['supported'] is True
|
||||
assert isinstance(payload['shells']['bash']['sourceCommand'], str)
|
||||
assert isinstance(payload['shells']['bash']['recommendedInstallCommand'], str)
|
||||
assert isinstance(payload['shells']['powershell']['sourceCommand'], str)
|
||||
assert isinstance(payload['shells']['powershell']['recommendedInstallCommand'], str)
|
||||
|
||||
|
||||
def test_completion_install_json_output_has_stable_contract(
|
||||
tmp_path: Path,
|
||||
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
|
||||
) -> None:
|
||||
target_file = tmp_path / 'shierfeng.bash'
|
||||
rc_file = tmp_path / '.bashrc'
|
||||
completed = run_cli_command(
|
||||
'completion',
|
||||
'install',
|
||||
'--shell=bash',
|
||||
'--target-file',
|
||||
str(target_file),
|
||||
'--activate',
|
||||
'--rc-file',
|
||||
str(rc_file),
|
||||
'--output=json',
|
||||
)
|
||||
payload = parse_json_stdout(completed)
|
||||
|
||||
assert completed.returncode == SUCCESS
|
||||
assert payload['ok'] is True
|
||||
assert set(payload) == {
|
||||
'ok',
|
||||
'message',
|
||||
'shell',
|
||||
'detectedShell',
|
||||
'targetFile',
|
||||
'activated',
|
||||
'activateRequested',
|
||||
'rcFile',
|
||||
'rcFileUpdated',
|
||||
'sourceCommand',
|
||||
'autoDiscovery',
|
||||
'activationRequired',
|
||||
'nextStep',
|
||||
'completeEnvVar',
|
||||
}
|
||||
assert payload['shell'] == 'bash'
|
||||
assert payload['targetFile'] == str(target_file.resolve())
|
||||
assert payload['activated'] is True
|
||||
assert payload['activateRequested'] is True
|
||||
assert payload['rcFile'] == str(rc_file.resolve())
|
||||
assert payload['rcFileUpdated'] is True
|
||||
assert payload['autoDiscovery'] is False
|
||||
assert payload['activationRequired'] is True
|
||||
assert isinstance(payload['nextStep'], str)
|
||||
assert target_file.exists()
|
||||
assert rc_file.exists()
|
||||
assert '_SHIERFENG_COMPLETE=bash_complete' in target_file.read_text(encoding='utf-8')
|
||||
assert payload['sourceCommand'] in rc_file.read_text(encoding='utf-8')
|
||||
|
||||
|
||||
def test_completion_install_powershell_json_output_has_stable_contract(
|
||||
tmp_path: Path,
|
||||
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
|
||||
) -> None:
|
||||
target_file = tmp_path / 'shierfeng.ps1'
|
||||
rc_file = tmp_path / 'Microsoft.PowerShell_profile.ps1'
|
||||
completed = run_cli_command(
|
||||
'completion',
|
||||
'install',
|
||||
'--shell=powershell',
|
||||
'--target-file',
|
||||
str(target_file),
|
||||
'--activate',
|
||||
'--rc-file',
|
||||
str(rc_file),
|
||||
'--output=json',
|
||||
)
|
||||
payload = parse_json_stdout(completed)
|
||||
|
||||
assert completed.returncode == SUCCESS
|
||||
assert payload['ok'] is True
|
||||
assert payload['shell'] == 'powershell'
|
||||
assert payload['targetFile'] == str(target_file.resolve())
|
||||
assert payload['activated'] is True
|
||||
assert payload['activateRequested'] is True
|
||||
assert payload['rcFile'] == str(rc_file.resolve())
|
||||
assert payload['rcFileUpdated'] is True
|
||||
assert payload['autoDiscovery'] is False
|
||||
assert payload['activationRequired'] is True
|
||||
assert payload['sourceCommand'] == f'. "{target_file.resolve()}"'
|
||||
assert target_file.exists()
|
||||
assert rc_file.exists()
|
||||
assert 'Register-ArgumentCompleter -Native -CommandName shierfeng' in target_file.read_text(encoding='utf-8')
|
||||
assert payload['sourceCommand'] in rc_file.read_text(encoding='utf-8')
|
||||
|
||||
|
||||
def test_app_run_help_only_exposes_env_option(
|
||||
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
) -> None:
|
||||
completed = run_cli_command('app', 'run', '--help')
|
||||
|
||||
assert completed.returncode == 0
|
||||
assert 'Usage: shierfeng app run [OPTIONS]' in completed.stdout
|
||||
assert '--env' in completed.stdout
|
||||
assert '--output' not in completed.stdout
|
||||
@@ -0,0 +1,40 @@
|
||||
import subprocess
|
||||
from collections.abc import Callable
|
||||
|
||||
from cli.exit_codes import DATABASE_ERROR, REDIS_ERROR, RUNTIME_ERROR, SUCCESS
|
||||
|
||||
|
||||
def test_config_doctor_json_output_has_stable_contract(
|
||||
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
|
||||
) -> None:
|
||||
completed = run_cli_command('config', 'doctor', '--env=dev', '--output=json')
|
||||
payload = parse_json_stdout(completed)
|
||||
|
||||
assert completed.returncode in {SUCCESS, REDIS_ERROR, DATABASE_ERROR, RUNTIME_ERROR}
|
||||
assert payload['env'] == 'dev'
|
||||
assert isinstance(payload['ok'], bool)
|
||||
if 'error' in payload:
|
||||
assert isinstance(payload['message'], str)
|
||||
assert isinstance(payload['error'], str)
|
||||
return
|
||||
|
||||
assert set(payload) == {
|
||||
'ok',
|
||||
'message',
|
||||
'databaseCount',
|
||||
'cacheCount',
|
||||
'missingInCacheCount',
|
||||
'orphanInCacheCount',
|
||||
'mismatchCount',
|
||||
'sampleLimit',
|
||||
'missingInCache',
|
||||
'orphanInCache',
|
||||
'mismatchKeys',
|
||||
'env',
|
||||
}
|
||||
assert isinstance(payload['databaseCount'], int)
|
||||
assert isinstance(payload['cacheCount'], int)
|
||||
assert isinstance(payload['missingInCache'], list)
|
||||
assert isinstance(payload['orphanInCache'], list)
|
||||
assert isinstance(payload['mismatchKeys'], list)
|
||||
@@ -0,0 +1,235 @@
|
||||
import subprocess
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
from cli.exit_codes import DATABASE_ERROR, GUARD_REJECTED, REDIS_ERROR, RUNTIME_ERROR, SUCCESS
|
||||
|
||||
DB_HISTORY_TEST_LIMIT = 5
|
||||
|
||||
|
||||
def test_dangerous_command_is_rejected_without_yes_in_non_interactive_mode(
|
||||
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
|
||||
) -> None:
|
||||
completed = run_cli_command('db', 'revision', '--env=dev', '--message=test-contract', '--output=json')
|
||||
payload = parse_json_stdout(completed)
|
||||
|
||||
assert completed.returncode == GUARD_REJECTED
|
||||
assert payload['ok'] is False
|
||||
assert payload['message'] == '已取消危险命令执行:db revision'
|
||||
assert '--yes' in payload['hint']
|
||||
|
||||
|
||||
def test_prod_dangerous_command_is_rejected_without_allow_prod(
|
||||
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
|
||||
) -> None:
|
||||
completed = run_cli_command('db', 'revision', '--env=prod', '--message=test-contract', '--output=json')
|
||||
payload = parse_json_stdout(completed)
|
||||
|
||||
assert completed.returncode == GUARD_REJECTED
|
||||
assert payload['ok'] is False
|
||||
assert payload['message'] == '生产环境默认禁止直接执行危险命令:db revision'
|
||||
assert '--allow-prod' in payload['hint']
|
||||
assert '--yes' in payload['hint']
|
||||
|
||||
|
||||
def test_dry_run_command_returns_structured_preview_payload(
|
||||
backend_dir: Path,
|
||||
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
|
||||
) -> None:
|
||||
completed = run_cli_command(
|
||||
'db',
|
||||
'upgrade',
|
||||
'--env=dev',
|
||||
'--revision=head',
|
||||
'--dry-run',
|
||||
'--yes',
|
||||
'--output=json',
|
||||
)
|
||||
payload = parse_json_stdout(completed)
|
||||
|
||||
assert completed.returncode == 0
|
||||
assert payload['ok'] is True
|
||||
assert payload['dryRun'] is True
|
||||
assert isinstance(payload['command'], list)
|
||||
assert payload['command'][-2:] == ['upgrade', 'head']
|
||||
assert payload['cwd'] == str(backend_dir)
|
||||
|
||||
|
||||
def test_db_heads_json_output_has_stable_contract(
|
||||
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
|
||||
) -> None:
|
||||
completed = run_cli_command('db', 'heads', '--env=dev', '--output=json')
|
||||
payload = parse_json_stdout(completed)
|
||||
|
||||
assert completed.returncode in {SUCCESS, DATABASE_ERROR}
|
||||
assert payload['env'] == 'dev'
|
||||
assert isinstance(payload['ok'], bool)
|
||||
if 'error' in payload:
|
||||
assert isinstance(payload['message'], str)
|
||||
assert isinstance(payload['error'], str)
|
||||
return
|
||||
|
||||
assert set(payload) == {'ok', 'message', 'count', 'items', 'env'}
|
||||
assert isinstance(payload['count'], int)
|
||||
assert isinstance(payload['items'], list)
|
||||
if payload['items']:
|
||||
first_item = payload['items'][0]
|
||||
assert set(first_item) == {'revision', 'downRevisions', 'branchLabels', 'dependsOn', 'doc', 'path'}
|
||||
assert isinstance(first_item['revision'], str)
|
||||
assert isinstance(first_item['downRevisions'], list)
|
||||
assert isinstance(first_item['branchLabels'], list)
|
||||
assert isinstance(first_item['dependsOn'], list)
|
||||
assert isinstance(first_item['doc'], str)
|
||||
assert isinstance(first_item['path'], str)
|
||||
|
||||
|
||||
def test_db_history_json_output_has_stable_contract(
|
||||
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
|
||||
) -> None:
|
||||
completed = run_cli_command(
|
||||
'db',
|
||||
'history',
|
||||
'--env=dev',
|
||||
f'--limit={DB_HISTORY_TEST_LIMIT}',
|
||||
'--output=json',
|
||||
)
|
||||
payload = parse_json_stdout(completed)
|
||||
|
||||
assert completed.returncode in {SUCCESS, DATABASE_ERROR}
|
||||
assert payload['env'] == 'dev'
|
||||
assert isinstance(payload['ok'], bool)
|
||||
if 'error' in payload:
|
||||
assert isinstance(payload['message'], str)
|
||||
assert isinstance(payload['error'], str)
|
||||
return
|
||||
|
||||
assert set(payload) == {'ok', 'message', 'count', 'totalCount', 'limit', 'items', 'env'}
|
||||
assert payload['limit'] == DB_HISTORY_TEST_LIMIT
|
||||
assert isinstance(payload['count'], int)
|
||||
assert isinstance(payload['totalCount'], int)
|
||||
assert isinstance(payload['items'], list)
|
||||
|
||||
|
||||
def test_cache_ttl_json_output_has_stable_contract_for_missing_key_or_redis_error(
|
||||
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
|
||||
) -> None:
|
||||
completed = run_cli_command(
|
||||
'cache',
|
||||
'ttl',
|
||||
'sys_config',
|
||||
'definitely_missing_cli_key',
|
||||
'--env=dev',
|
||||
'--output=json',
|
||||
)
|
||||
payload = parse_json_stdout(completed)
|
||||
|
||||
assert completed.returncode in {SUCCESS, REDIS_ERROR, RUNTIME_ERROR}
|
||||
assert isinstance(payload['ok'], bool)
|
||||
if 'error' in payload:
|
||||
assert payload['message'] == '读取缓存剩余过期时间失败'
|
||||
assert isinstance(payload['error'], str)
|
||||
return
|
||||
|
||||
assert set(payload) == {'ok', 'message', 'cacheName', 'cacheKey', 'fullCacheKey'}
|
||||
assert payload['ok'] is False
|
||||
assert payload['cacheName'] == 'sys_config'
|
||||
assert payload['cacheKey'] == 'definitely_missing_cli_key'
|
||||
assert payload['fullCacheKey'] == 'sys_config:definitely_missing_cli_key'
|
||||
|
||||
|
||||
def test_dangerous_command_rejection_text_output_is_human_readable(
|
||||
run_text_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
) -> None:
|
||||
completed = run_text_cli_command('db', 'revision', '--env=dev', '--message=test-contract', '--output=text')
|
||||
|
||||
assert completed.returncode == GUARD_REJECTED
|
||||
assert completed.stdout == (
|
||||
'FAIL FAILED\n'
|
||||
'message: 已取消危险命令执行:db revision\n'
|
||||
'hint: 当前命令需要交互确认;如需非交互执行,请传入 --yes\n'
|
||||
)
|
||||
|
||||
|
||||
def test_dry_run_text_output_contains_preview_command_and_workdir(
|
||||
backend_dir: Path,
|
||||
run_text_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
) -> None:
|
||||
completed = run_text_cli_command(
|
||||
'db',
|
||||
'upgrade',
|
||||
'--env=dev',
|
||||
'--revision=head',
|
||||
'--dry-run',
|
||||
'--yes',
|
||||
'--output=text',
|
||||
)
|
||||
|
||||
assert completed.returncode == 0
|
||||
assert completed.stdout.startswith('OK SUCCESS\n')
|
||||
assert 'message: 数据库已升级到 head(dry-run)\n' in completed.stdout
|
||||
assert 'dry_run: true\n' in completed.stdout
|
||||
assert 'command:\n - alembic\n - -c\n' in completed.stdout
|
||||
assert f' - {backend_dir / "alembic.ini"}\n' in completed.stdout
|
||||
assert f'cwd: {backend_dir}\n' in completed.stdout
|
||||
|
||||
|
||||
def test_dev_lint_json_output_has_stable_contract(
|
||||
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
|
||||
) -> None:
|
||||
completed = run_cli_command(
|
||||
'dev',
|
||||
'lint',
|
||||
'cli/groups/dev/command.py',
|
||||
'--env=dev',
|
||||
'--check-only',
|
||||
'--output=json',
|
||||
)
|
||||
payload = parse_json_stdout(completed)
|
||||
|
||||
assert completed.returncode == SUCCESS
|
||||
assert payload['ok'] is True
|
||||
assert payload['message'] == '开发检查已完成'
|
||||
assert payload['env'] == 'dev'
|
||||
assert payload['checkOnly'] is True
|
||||
assert payload['fix'] is False
|
||||
assert payload['unsafeFixes'] is False
|
||||
assert payload['targets'] == ['cli/groups/dev/command.py']
|
||||
assert isinstance(payload['format'], dict)
|
||||
assert isinstance(payload['check'], dict)
|
||||
assert payload['format']['ok'] is True
|
||||
assert payload['check']['ok'] is True
|
||||
assert isinstance(payload['format']['command'], list)
|
||||
assert isinstance(payload['check']['command'], list)
|
||||
|
||||
|
||||
def test_dev_test_text_output_has_stable_summary(
|
||||
run_text_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
) -> None:
|
||||
completed = run_text_cli_command(
|
||||
'dev',
|
||||
'test',
|
||||
'tests/cli/root/test_contract_cli.py',
|
||||
'--env=dev',
|
||||
'--keyword=test_root_help_shows_commands_without_completion_options',
|
||||
'--maxfail=1',
|
||||
'--quiet',
|
||||
'--output=text',
|
||||
)
|
||||
|
||||
assert completed.returncode == SUCCESS
|
||||
assert completed.stdout.startswith('OK SUCCESS\n')
|
||||
assert 'ok: true\n' in completed.stdout
|
||||
assert 'env: dev\n' in completed.stdout
|
||||
assert 'keyword: test_root_help_shows_commands_without_completion_options\n' in completed.stdout
|
||||
assert 'maxfail: 1\n' in completed.stdout
|
||||
assert 'quiet: true\n' in completed.stdout
|
||||
assert 'targets:\n - tests/cli/root/test_contract_cli.py\n' in completed.stdout
|
||||
assert 'test:\n ok: true\n' in completed.stdout
|
||||
assert 'command: ' in completed.stdout
|
||||
@@ -0,0 +1,368 @@
|
||||
import subprocess
|
||||
from collections.abc import Callable
|
||||
|
||||
from cli.exit_codes import ARGUMENT_ERROR, DATABASE_ERROR, GUARD_REJECTED, RUNTIME_ERROR, SUCCESS
|
||||
|
||||
GEN_TEST_TABLE_NAME = 'demo_table'
|
||||
GEN_CREATE_SQL = 'CREATE TABLE demo_cli_test (id bigint);'
|
||||
MISSING_JOB_ID = 999999999
|
||||
MISSING_GEN_TABLE_ID = 999999999
|
||||
|
||||
|
||||
def test_job_detail_json_output_has_stable_contract(
|
||||
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
|
||||
) -> None:
|
||||
list_completed = run_cli_command('job', 'list', '--env=dev', '--output=json')
|
||||
list_payload = parse_json_stdout(list_completed)
|
||||
job_id = MISSING_JOB_ID
|
||||
if list_payload.get('ok') and isinstance(list_payload.get('items'), list) and list_payload['items']:
|
||||
first_job = list_payload['items'][0]
|
||||
if isinstance(first_job, dict) and isinstance(first_job.get('jobId'), int):
|
||||
job_id = first_job['jobId']
|
||||
|
||||
completed = run_cli_command('job', 'detail', str(job_id), '--env=dev', '--output=json')
|
||||
payload = parse_json_stdout(completed)
|
||||
|
||||
assert completed.returncode in {SUCCESS, DATABASE_ERROR, RUNTIME_ERROR}
|
||||
assert isinstance(payload['ok'], bool)
|
||||
if 'error' in payload:
|
||||
assert isinstance(payload['message'], str)
|
||||
assert isinstance(payload['error'], str)
|
||||
return
|
||||
|
||||
if payload['ok'] is False:
|
||||
assert set(payload) == {'ok', 'message', 'jobId'}
|
||||
assert payload['jobId'] == job_id
|
||||
return
|
||||
|
||||
assert set(payload) == {'ok', 'job'}
|
||||
assert isinstance(payload['job'], dict)
|
||||
assert payload['job']['jobId'] == job_id
|
||||
assert isinstance(payload['job'].get('jobName'), str)
|
||||
assert isinstance(payload['job'].get('invokeTarget'), str)
|
||||
|
||||
|
||||
def test_job_logs_json_output_has_stable_contract(
|
||||
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
|
||||
) -> None:
|
||||
completed = run_cli_command('job', 'logs', '--env=dev', '--output=json')
|
||||
payload = parse_json_stdout(completed)
|
||||
|
||||
assert completed.returncode in {SUCCESS, DATABASE_ERROR}
|
||||
assert isinstance(payload['ok'], bool)
|
||||
if 'error' in payload:
|
||||
assert isinstance(payload['message'], str)
|
||||
assert isinstance(payload['error'], str)
|
||||
return
|
||||
|
||||
assert set(payload) == {'ok', 'filters', 'count', 'items'}
|
||||
assert isinstance(payload['filters'], dict)
|
||||
assert isinstance(payload['count'], int)
|
||||
assert isinstance(payload['items'], list)
|
||||
if payload['items']:
|
||||
first_item = payload['items'][0]
|
||||
assert isinstance(first_item, dict)
|
||||
assert isinstance(first_item.get('jobLogId'), int)
|
||||
assert isinstance(first_item.get('jobName'), str | None)
|
||||
assert isinstance(first_item.get('jobMessage'), str | None)
|
||||
|
||||
|
||||
def test_gen_list_json_output_has_stable_contract(
|
||||
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
|
||||
) -> None:
|
||||
completed = run_cli_command('gen', 'list', '--env=dev', '--output=json')
|
||||
payload = parse_json_stdout(completed)
|
||||
|
||||
assert completed.returncode in {SUCCESS, DATABASE_ERROR}
|
||||
assert payload['env'] == 'dev'
|
||||
assert isinstance(payload['ok'], bool)
|
||||
if 'error' in payload:
|
||||
assert isinstance(payload['message'], str)
|
||||
assert isinstance(payload['error'], str)
|
||||
return
|
||||
|
||||
assert set(payload) == {'ok', 'filters', 'count', 'items', 'env'}
|
||||
assert isinstance(payload['filters'], dict)
|
||||
assert isinstance(payload['count'], int)
|
||||
assert isinstance(payload['items'], list)
|
||||
if payload['items']:
|
||||
first_item = payload['items'][0]
|
||||
assert isinstance(first_item, dict)
|
||||
assert isinstance(first_item.get('tableId'), int)
|
||||
assert isinstance(first_item.get('tableName'), str)
|
||||
|
||||
|
||||
def test_gen_db_list_json_output_has_stable_contract(
|
||||
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
|
||||
) -> None:
|
||||
completed = run_cli_command('gen', 'db-list', '--env=dev', '--output=json')
|
||||
payload = parse_json_stdout(completed)
|
||||
|
||||
assert completed.returncode in {SUCCESS, DATABASE_ERROR}
|
||||
assert payload['env'] == 'dev'
|
||||
assert isinstance(payload['ok'], bool)
|
||||
if 'error' in payload:
|
||||
assert isinstance(payload['message'], str)
|
||||
assert isinstance(payload['error'], str)
|
||||
return
|
||||
|
||||
assert set(payload) == {'ok', 'filters', 'count', 'items', 'env'}
|
||||
assert isinstance(payload['filters'], dict)
|
||||
assert isinstance(payload['count'], int)
|
||||
assert isinstance(payload['items'], list)
|
||||
if payload['items']:
|
||||
first_item = payload['items'][0]
|
||||
assert isinstance(first_item, dict)
|
||||
assert isinstance(first_item.get('tableName'), str)
|
||||
|
||||
|
||||
def test_gen_detail_json_output_has_stable_contract(
|
||||
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
|
||||
) -> None:
|
||||
list_completed = run_cli_command('gen', 'list', '--env=dev', '--output=json')
|
||||
list_payload = parse_json_stdout(list_completed)
|
||||
table_id = MISSING_GEN_TABLE_ID
|
||||
if list_payload.get('ok') and isinstance(list_payload.get('items'), list) and list_payload['items']:
|
||||
first_item = list_payload['items'][0]
|
||||
if isinstance(first_item, dict) and isinstance(first_item.get('tableId'), int):
|
||||
table_id = first_item['tableId']
|
||||
|
||||
completed = run_cli_command('gen', 'detail', str(table_id), '--env=dev', '--output=json')
|
||||
payload = parse_json_stdout(completed)
|
||||
|
||||
assert completed.returncode in {SUCCESS, DATABASE_ERROR, RUNTIME_ERROR}
|
||||
assert payload['env'] == 'dev'
|
||||
assert isinstance(payload['ok'], bool)
|
||||
if 'error' in payload:
|
||||
assert isinstance(payload['message'], str)
|
||||
assert isinstance(payload['error'], str)
|
||||
return
|
||||
|
||||
if payload['ok'] is False:
|
||||
assert set(payload) == {'ok', 'message', 'tableId', 'env'}
|
||||
assert payload['tableId'] == table_id
|
||||
return
|
||||
|
||||
assert set(payload) == {'ok', 'tableId', 'tableName', 'columnCount', 'tableCount', 'detail', 'env'}
|
||||
assert payload['tableId'] == table_id
|
||||
assert isinstance(payload['tableName'], str)
|
||||
assert isinstance(payload['columnCount'], int)
|
||||
assert isinstance(payload['tableCount'], int)
|
||||
assert isinstance(payload['detail'], dict)
|
||||
assert isinstance(payload['detail'].get('info'), dict)
|
||||
assert isinstance(payload['detail'].get('rows'), list)
|
||||
assert isinstance(payload['detail'].get('tables'), list)
|
||||
|
||||
|
||||
def test_gen_sync_db_is_rejected_without_yes_in_non_interactive_mode(
|
||||
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
|
||||
) -> None:
|
||||
completed = run_cli_command('gen', 'sync-db', 'demo_table', '--env=dev', '--output=json')
|
||||
payload = parse_json_stdout(completed)
|
||||
|
||||
assert completed.returncode == GUARD_REJECTED
|
||||
assert payload['ok'] is False
|
||||
assert payload['message'] == '已取消危险命令执行:gen sync-db'
|
||||
assert '--yes' in payload['hint']
|
||||
|
||||
|
||||
def test_gen_export_dry_run_text_output_has_stable_structure(
|
||||
run_text_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
) -> None:
|
||||
completed = run_text_cli_command(
|
||||
'gen',
|
||||
'export',
|
||||
GEN_TEST_TABLE_NAME,
|
||||
'--env=dev',
|
||||
'--dry-run',
|
||||
'--yes',
|
||||
'--output=text',
|
||||
)
|
||||
|
||||
assert completed.returncode == SUCCESS
|
||||
assert completed.stdout.startswith('OK SUCCESS\n')
|
||||
assert 'env: dev\n' in completed.stdout
|
||||
assert 'mode: zip\n' in completed.stdout
|
||||
assert 'dry_run: true\n' in completed.stdout
|
||||
assert 'message: 代码导出演练完成,未执行实际导出\n' in completed.stdout
|
||||
assert 'table_names:\n' in completed.stdout
|
||||
assert f' - {GEN_TEST_TABLE_NAME}\n' in completed.stdout
|
||||
assert 'output_file:' in completed.stdout
|
||||
assert f'gen_code_{GEN_TEST_TABLE_NAME}.zip' in completed.stdout
|
||||
|
||||
|
||||
def test_gen_export_dry_run_json_output_has_stable_contract(
|
||||
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
|
||||
) -> None:
|
||||
completed = run_cli_command(
|
||||
'gen',
|
||||
'export',
|
||||
GEN_TEST_TABLE_NAME,
|
||||
'--env=dev',
|
||||
'--dry-run',
|
||||
'--yes',
|
||||
'--output=json',
|
||||
)
|
||||
payload = parse_json_stdout(completed)
|
||||
|
||||
assert completed.returncode == SUCCESS
|
||||
assert payload['ok'] is True
|
||||
assert payload['env'] == 'dev'
|
||||
assert payload['dryRun'] is True
|
||||
assert payload['mode'] == 'zip'
|
||||
assert payload['message'] == '代码导出演练完成,未执行实际导出'
|
||||
assert payload['tableNames'] == [GEN_TEST_TABLE_NAME]
|
||||
assert isinstance(payload['outputFile'], str)
|
||||
assert payload['outputFile'].endswith(f'gen_code_{GEN_TEST_TABLE_NAME}.zip')
|
||||
|
||||
|
||||
def test_gen_create_table_dry_run_text_output_has_stable_structure(
|
||||
run_text_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
) -> None:
|
||||
completed = run_text_cli_command(
|
||||
'gen',
|
||||
'create-table',
|
||||
'--env=dev',
|
||||
'--dry-run',
|
||||
'--yes',
|
||||
'--sql',
|
||||
GEN_CREATE_SQL,
|
||||
'--output=text',
|
||||
)
|
||||
|
||||
assert completed.returncode == SUCCESS
|
||||
assert completed.stdout.startswith('OK SUCCESS\n')
|
||||
assert 'env: dev\n' in completed.stdout
|
||||
assert 'message: 建表语句演练完成,未执行实际建表\n' in completed.stdout
|
||||
assert 'dry_run: true\n' in completed.stdout
|
||||
assert 'statement_count: 1\n' in completed.stdout
|
||||
assert 'table_names:\n' in completed.stdout
|
||||
assert ' - demo_cli_test\n' in completed.stdout
|
||||
assert f'sql: {GEN_CREATE_SQL}\n' in completed.stdout
|
||||
|
||||
|
||||
def test_gen_create_table_dry_run_json_output_has_stable_contract(
|
||||
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
|
||||
) -> None:
|
||||
completed = run_cli_command(
|
||||
'gen',
|
||||
'create-table',
|
||||
'--env=dev',
|
||||
'--dry-run',
|
||||
'--yes',
|
||||
'--sql',
|
||||
GEN_CREATE_SQL,
|
||||
'--output=json',
|
||||
)
|
||||
payload = parse_json_stdout(completed)
|
||||
|
||||
assert completed.returncode == SUCCESS
|
||||
assert payload['ok'] is True
|
||||
assert payload['env'] == 'dev'
|
||||
assert payload['dryRun'] is True
|
||||
assert payload['message'] == '建表语句演练完成,未执行实际建表'
|
||||
assert payload['statementCount'] == 1
|
||||
assert payload['tableNames'] == ['demo_cli_test']
|
||||
assert payload['sql'] == GEN_CREATE_SQL
|
||||
|
||||
|
||||
def test_gen_create_table_rejects_non_create_sql_in_text_output(
|
||||
run_text_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
) -> None:
|
||||
completed = run_text_cli_command(
|
||||
'gen',
|
||||
'create-table',
|
||||
'--env=dev',
|
||||
'--dry-run',
|
||||
'--yes',
|
||||
'--sql',
|
||||
'DROP TABLE demo_cli_test;',
|
||||
'--output=text',
|
||||
)
|
||||
|
||||
assert completed.returncode == ARGUMENT_ERROR
|
||||
assert completed.stdout == (
|
||||
'FAIL FAILED\nmessage: 创建表结构失败\nerror: 建表语句不合法,仅允许 CREATE TABLE 语句\nenv: dev\n'
|
||||
)
|
||||
|
||||
|
||||
def test_gen_create_table_rejects_non_create_sql_in_json_output(
|
||||
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
|
||||
) -> None:
|
||||
completed = run_cli_command(
|
||||
'gen',
|
||||
'create-table',
|
||||
'--env=dev',
|
||||
'--dry-run',
|
||||
'--yes',
|
||||
'--sql',
|
||||
'DROP TABLE demo_cli_test;',
|
||||
'--output=json',
|
||||
)
|
||||
payload = parse_json_stdout(completed)
|
||||
|
||||
assert completed.returncode == ARGUMENT_ERROR
|
||||
assert payload == {
|
||||
'ok': False,
|
||||
'message': '创建表结构失败',
|
||||
'error': '建表语句不合法,仅允许 CREATE TABLE 语句',
|
||||
'env': 'dev',
|
||||
}
|
||||
|
||||
|
||||
def test_gen_create_table_rejects_conflicting_sql_inputs_in_text_output(
|
||||
run_text_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
) -> None:
|
||||
completed = run_text_cli_command(
|
||||
'gen',
|
||||
'create-table',
|
||||
'--env=dev',
|
||||
'--dry-run',
|
||||
'--yes',
|
||||
'--sql',
|
||||
GEN_CREATE_SQL,
|
||||
'--sql-file',
|
||||
'fake.sql',
|
||||
'--output=text',
|
||||
)
|
||||
|
||||
assert completed.returncode == ARGUMENT_ERROR
|
||||
assert completed.stdout == (
|
||||
'FAIL FAILED\nmessage: 创建表结构失败\nerror: 必须且只能传入 --sql 或 --sql-file 其中一种方式\nenv: dev\n'
|
||||
)
|
||||
|
||||
|
||||
def test_gen_create_table_rejects_conflicting_sql_inputs_in_json_output(
|
||||
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
|
||||
) -> None:
|
||||
completed = run_cli_command(
|
||||
'gen',
|
||||
'create-table',
|
||||
'--env=dev',
|
||||
'--dry-run',
|
||||
'--yes',
|
||||
'--sql',
|
||||
GEN_CREATE_SQL,
|
||||
'--sql-file',
|
||||
'fake.sql',
|
||||
'--output=json',
|
||||
)
|
||||
payload = parse_json_stdout(completed)
|
||||
|
||||
assert completed.returncode == ARGUMENT_ERROR
|
||||
assert payload == {
|
||||
'ok': False,
|
||||
'message': '创建表结构失败',
|
||||
'error': '必须且只能传入 --sql 或 --sql-file 其中一种方式',
|
||||
'env': 'dev',
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import importlib
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
BACKEND_DIR = Path(__file__).resolve().parents[3]
|
||||
|
||||
sys.path.insert(0, str(BACKEND_DIR))
|
||||
cli_guards = importlib.import_module('cli.guards')
|
||||
cli_guards = importlib.reload(cli_guards)
|
||||
|
||||
DEFAULT_DANGEROUS_COMMAND_RULES = cli_guards.DEFAULT_DANGEROUS_COMMAND_RULES
|
||||
DEFAULT_DANGEROUS_COMMAND_RULE_REGISTRY = cli_guards.DEFAULT_DANGEROUS_COMMAND_RULE_REGISTRY
|
||||
|
||||
|
||||
def test_dangerous_command_rules_cover_expected_commands() -> None:
|
||||
"""
|
||||
校验危险命令规则表已覆盖当前设计文档中约定的命令范围。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
assert set(DEFAULT_DANGEROUS_COMMAND_RULES) == {
|
||||
'cache clear',
|
||||
'cache warmup',
|
||||
'db upgrade',
|
||||
'db init',
|
||||
'db downgrade',
|
||||
'db revision',
|
||||
'config set',
|
||||
'config sync-cache',
|
||||
'crypto rotate',
|
||||
'job run-once',
|
||||
'job pause',
|
||||
'job resume',
|
||||
'job sync',
|
||||
'gen import-table',
|
||||
'gen create-table',
|
||||
'gen export',
|
||||
'gen sync-db',
|
||||
}
|
||||
|
||||
|
||||
def test_dangerous_command_rules_expose_risk_level_and_dry_run_metadata() -> None:
|
||||
"""
|
||||
校验危险命令规则能够提供风险级别和 dry-run 能力元数据。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
db_upgrade_rule = DEFAULT_DANGEROUS_COMMAND_RULE_REGISTRY.get_rule('db upgrade')
|
||||
cache_warmup_rule = DEFAULT_DANGEROUS_COMMAND_RULE_REGISTRY.get_rule('cache warmup')
|
||||
|
||||
assert db_upgrade_rule is not None
|
||||
assert db_upgrade_rule.risk_level == 'high'
|
||||
assert db_upgrade_rule.supports_dry_run is True
|
||||
|
||||
assert cache_warmup_rule is not None
|
||||
assert cache_warmup_rule.risk_level == 'normal'
|
||||
assert cache_warmup_rule.supports_dry_run is False
|
||||
|
||||
|
||||
def test_require_dangerous_command_rule_rejects_unknown_command() -> None:
|
||||
"""
|
||||
校验未注册的危险命令名称会被显式拒绝。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
with pytest.raises(ValueError, match='危险命令未注册保护规则'):
|
||||
DEFAULT_DANGEROUS_COMMAND_RULE_REGISTRY.require_rule('unknown dangerous command')
|
||||
@@ -0,0 +1,18 @@
|
||||
import subprocess
|
||||
from collections.abc import Callable
|
||||
|
||||
|
||||
def test_config_doctor_json_stdout_is_not_polluted_by_sqlalchemy_logs(
|
||||
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
|
||||
) -> None:
|
||||
"""
|
||||
校验 JSON 输出模式下不会混入 SQLAlchemy engine 日志。
|
||||
|
||||
:param run_cli_command: CLI 子进程执行器
|
||||
:return: None
|
||||
"""
|
||||
completed = run_cli_command('config', 'doctor', '--env=dev', '--output=json')
|
||||
|
||||
assert 'sqlalchemy.engine.Engine' not in completed.stdout
|
||||
assert 'SELECT DATABASE()' not in completed.stdout
|
||||
assert completed.stdout.lstrip().startswith('{')
|
||||
@@ -0,0 +1,13 @@
|
||||
import importlib
|
||||
|
||||
|
||||
def test_cli_main_runner_reuses_single_project_runtime_locator() -> None:
|
||||
"""
|
||||
校验 CLI 根运行器与导入期参数作用域共享同一个项目定位器实例。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
cli_main = importlib.import_module('cli.main')
|
||||
|
||||
assert cli_main.CLI_MAIN_RUNNER.project_runtime_locator is cli_main.PROJECT_RUNTIME_LOCATOR
|
||||
assert cli_main.CLI_MAIN_RUNNER.import_argv_scope.project_runtime_locator is cli_main.PROJECT_RUNTIME_LOCATOR
|
||||
@@ -0,0 +1,181 @@
|
||||
import importlib
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
BACKEND_DIR = Path(__file__).resolve().parents[3]
|
||||
|
||||
sys.path.insert(0, str(BACKEND_DIR))
|
||||
sys.modules.pop('cli.output', None)
|
||||
sys.modules.pop('cli', None)
|
||||
|
||||
cli_output = importlib.import_module('cli.output')
|
||||
OUTPUT_RENDERER = cli_output.OutputRenderer()
|
||||
|
||||
_ANSI_ESCAPE_PATTERN = re.compile(r'\x1b\[[0-9;]*m')
|
||||
|
||||
|
||||
class _FakeStream:
|
||||
"""
|
||||
模拟终端输出流。
|
||||
|
||||
:param is_tty: 是否模拟为 TTY
|
||||
"""
|
||||
|
||||
def __init__(self, *, is_tty: bool) -> None:
|
||||
self._is_tty = is_tty
|
||||
|
||||
def isatty(self) -> bool:
|
||||
"""
|
||||
返回是否为 TTY。
|
||||
|
||||
:return: 是否为 TTY
|
||||
"""
|
||||
return self._is_tty
|
||||
|
||||
|
||||
def _strip_ansi(text: str) -> str:
|
||||
"""
|
||||
移除 ANSI 转义序列,便于断言文本可视结果。
|
||||
|
||||
:param text: 原始带样式文本
|
||||
:return: 去除 ANSI 后的文本
|
||||
"""
|
||||
return _ANSI_ESCAPE_PATTERN.sub('', text)
|
||||
|
||||
|
||||
def test_decorate_text_output_promotes_success_header_with_emoji_icon() -> None:
|
||||
"""
|
||||
校验成功结果头在 emoji 模式下会被提升为统一状态头。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
OUTPUT_RENDERER.set_icon_mode('emoji')
|
||||
|
||||
rendered = OUTPUT_RENDERER.decorate_text_output('ok: true\nenv: dev')
|
||||
|
||||
assert rendered == '✅ OK SUCCESS\nenv: dev'
|
||||
|
||||
|
||||
def test_decorate_text_output_promotes_failure_header_with_emoji_icon() -> None:
|
||||
"""
|
||||
校验失败结果头在 emoji 模式下会被提升为统一状态头。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
OUTPUT_RENDERER.set_icon_mode('emoji')
|
||||
|
||||
rendered = OUTPUT_RENDERER.decorate_text_output('ok: false\nerror: boom')
|
||||
|
||||
assert rendered == '❌ FAIL FAILED\nerror: boom'
|
||||
|
||||
|
||||
def test_colorize_text_output_renders_inline_status_segments_with_emoji_icon() -> None:
|
||||
"""
|
||||
校验文本着色后会为 message、hint、error 等字段附加状态标签。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
OUTPUT_RENDERER.set_icon_mode('emoji')
|
||||
OUTPUT_RENDERER.set_color_mode('always')
|
||||
decorated = OUTPUT_RENDERER.decorate_text_output('ok: false\nmessage: retry later\nhint: use --yes\nerror: boom')
|
||||
|
||||
rendered = OUTPUT_RENDERER.colorize_text_output(decorated, _FakeStream(is_tty=True))
|
||||
|
||||
assert '\x1b[' in rendered
|
||||
assert _strip_ansi(rendered) == (
|
||||
'❌ FAIL FAILED\nmessage: 💬 INFO retry later\nhint: 💡 HINT use --yes\nerror: 🚨 ERROR boom'
|
||||
)
|
||||
|
||||
|
||||
def test_colorize_text_output_supports_warning_and_info_field_aliases() -> None:
|
||||
"""
|
||||
校验 `warning` 与 `info` 字段也会使用统一状态图标渲染。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
OUTPUT_RENDERER.set_icon_mode('emoji')
|
||||
OUTPUT_RENDERER.set_color_mode('always')
|
||||
decorated = OUTPUT_RENDERER.decorate_text_output('ok: true\nwarning: rotate key soon\ninfo: cache refreshed')
|
||||
|
||||
rendered = OUTPUT_RENDERER.colorize_text_output(decorated, _FakeStream(is_tty=True))
|
||||
|
||||
assert _strip_ansi(rendered) == ('✅ OK SUCCESS\nwarning: 💡 HINT rotate key soon\ninfo: 💬 INFO cache refreshed')
|
||||
|
||||
|
||||
def test_colorize_text_output_styles_status_segments_inside_list_items() -> None:
|
||||
"""
|
||||
校验列表项中的带标签状态片段也会应用统一视觉规则。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
OUTPUT_RENDERER.set_icon_mode('emoji')
|
||||
OUTPUT_RENDERER.set_color_mode('always')
|
||||
rendered = OUTPUT_RENDERER.colorize_text_output(
|
||||
'results:\n - users: true | exported\n - roles: false | error: lock timeout',
|
||||
_FakeStream(is_tty=True),
|
||||
)
|
||||
|
||||
assert _strip_ansi(rendered) == (
|
||||
'results:\n - users: ✅ OK | exported\n - roles: ❌ FAIL | 🚨 ERROR lock timeout'
|
||||
)
|
||||
|
||||
|
||||
def test_colorize_text_output_supports_ascii_icon_mode() -> None:
|
||||
"""
|
||||
校验 ascii 图标模式会渲染为纯 ASCII 状态标签。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
OUTPUT_RENDERER.set_icon_mode('ascii')
|
||||
OUTPUT_RENDERER.set_color_mode('always')
|
||||
decorated = OUTPUT_RENDERER.decorate_text_output('ok: true\nhint: use --yes')
|
||||
|
||||
rendered = OUTPUT_RENDERER.colorize_text_output(decorated, _FakeStream(is_tty=True))
|
||||
|
||||
assert _strip_ansi(rendered) == '[OK] SUCCESS\nhint: [HINT] use --yes'
|
||||
|
||||
|
||||
def test_colorize_text_output_skips_ansi_when_color_mode_is_never() -> None:
|
||||
"""
|
||||
校验关闭颜色后不会输出 ANSI 转义序列。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
OUTPUT_RENDERER.set_icon_mode('emoji')
|
||||
OUTPUT_RENDERER.set_color_mode('never')
|
||||
decorated = OUTPUT_RENDERER.decorate_text_output('ok: false\nhint: use --yes')
|
||||
|
||||
rendered = OUTPUT_RENDERER.colorize_text_output(decorated, _FakeStream(is_tty=True))
|
||||
|
||||
assert rendered == '❌ FAIL FAILED\nhint: use --yes'
|
||||
assert '\x1b[' not in rendered
|
||||
|
||||
|
||||
def test_render_error_text_uses_status_token_in_emoji_mode() -> None:
|
||||
"""
|
||||
校验标准错误输出在 emoji 模式下使用统一错误图标。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
OUTPUT_RENDERER.set_icon_mode('emoji')
|
||||
OUTPUT_RENDERER.set_color_mode('never')
|
||||
|
||||
rendered = OUTPUT_RENDERER.render_error_text('boom', 23, _FakeStream(is_tty=True))
|
||||
|
||||
assert rendered == '🚨 ERROR[23]: boom'
|
||||
|
||||
|
||||
def test_render_error_text_supports_ascii_mode_with_color() -> None:
|
||||
"""
|
||||
校验标准错误输出在 ascii 模式下仍保持统一错误前缀与颜色。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
OUTPUT_RENDERER.set_icon_mode('ascii')
|
||||
OUTPUT_RENDERER.set_color_mode('always')
|
||||
|
||||
rendered = OUTPUT_RENDERER.render_error_text('boom', 23, _FakeStream(is_tty=True))
|
||||
|
||||
assert '\x1b[' in rendered
|
||||
assert _strip_ansi(rendered) == '[ERROR][23]: boom'
|
||||
@@ -0,0 +1,142 @@
|
||||
import importlib
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
from pytest import MonkeyPatch
|
||||
|
||||
BACKEND_DIR = Path(__file__).resolve().parents[3]
|
||||
|
||||
sys.path.insert(0, str(BACKEND_DIR))
|
||||
sys.modules.pop('cli.utils', None)
|
||||
cli_utils = importlib.import_module('cli.utils')
|
||||
|
||||
|
||||
def test_run_nested_cli_command_extracts_json_from_noisy_stdout(monkeypatch: MonkeyPatch) -> None:
|
||||
"""
|
||||
校验内部 CLI 调用可从带噪声的标准输出中提取 JSON 负载。
|
||||
|
||||
:param monkeypatch: pytest monkeypatch 工具
|
||||
:return: None
|
||||
"""
|
||||
|
||||
def _fake_subprocess_run(*args: object, **kwargs: object) -> SimpleNamespace:
|
||||
del args, kwargs
|
||||
return SimpleNamespace(
|
||||
stdout='debug line before json\n{"ok": true, "message": "success"}\ndebug line after json\n',
|
||||
stderr='',
|
||||
returncode=0,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(cli_utils.subprocess, 'run', _fake_subprocess_run)
|
||||
|
||||
result = cli_utils.NESTED_CLI_SUPPORT.run('app', 'env', '--output=json', parse_json=True)
|
||||
|
||||
assert result.payload == {'ok': True, 'message': 'success'}
|
||||
|
||||
|
||||
def test_run_nested_cli_command_extracts_first_json_object_from_mixed_stdout(
|
||||
monkeypatch: MonkeyPatch,
|
||||
) -> None:
|
||||
"""
|
||||
校验内部 CLI 调用可从混杂文本中提取首个合法 JSON 对象。
|
||||
|
||||
:param monkeypatch: pytest monkeypatch 工具
|
||||
:return: None
|
||||
"""
|
||||
|
||||
def _fake_subprocess_run(*args: object, **kwargs: object) -> SimpleNamespace:
|
||||
del args, kwargs
|
||||
return SimpleNamespace(
|
||||
stdout='preface\ntrace {not-json}\n{"ok": true, "message": "job done"}\ntrailer\n',
|
||||
stderr='',
|
||||
returncode=0,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(cli_utils.subprocess, 'run', _fake_subprocess_run)
|
||||
|
||||
result = cli_utils.NESTED_CLI_SUPPORT.run('job', 'run-once', '1', '--output=json', parse_json=True)
|
||||
|
||||
assert result.payload == {'ok': True, 'message': 'job done'}
|
||||
|
||||
|
||||
def test_run_nested_cli_command_falls_back_to_text_payload_when_json_missing(
|
||||
monkeypatch: MonkeyPatch,
|
||||
) -> None:
|
||||
"""
|
||||
校验内部 CLI 未返回 JSON 时会回退为文本结果负载。
|
||||
|
||||
:param monkeypatch: pytest monkeypatch 工具
|
||||
:return: None
|
||||
"""
|
||||
|
||||
def _fake_subprocess_run(*args: object, **kwargs: object) -> SimpleNamespace:
|
||||
del args, kwargs
|
||||
return SimpleNamespace(
|
||||
stdout='任务已提交到调度器',
|
||||
stderr='',
|
||||
returncode=0,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(cli_utils.subprocess, 'run', _fake_subprocess_run)
|
||||
|
||||
result = cli_utils.NESTED_CLI_SUPPORT.run('job', 'run-once', '1', '--output=json', parse_json=True)
|
||||
|
||||
assert result.payload is not None
|
||||
assert result.payload.get('ok') is True
|
||||
assert result.payload.get('message') == '任务已提交到调度器'
|
||||
assert result.payload.get('fallback') == 'non_json_output'
|
||||
|
||||
|
||||
def test_run_nested_cli_command_uses_backend_dir_and_pythonpath(monkeypatch: MonkeyPatch) -> None:
|
||||
"""
|
||||
校验内部 CLI 调用会固定后端工作目录并注入项目路径。
|
||||
|
||||
:param monkeypatch: pytest monkeypatch 工具
|
||||
:return: None
|
||||
"""
|
||||
recorded_kwargs: dict[str, object] = {}
|
||||
|
||||
def _fake_subprocess_run(*args: object, **kwargs: object) -> SimpleNamespace:
|
||||
del args
|
||||
recorded_kwargs.update(kwargs)
|
||||
return SimpleNamespace(stdout='{}', stderr='', returncode=0)
|
||||
|
||||
monkeypatch.setattr(cli_utils.subprocess, 'run', _fake_subprocess_run)
|
||||
|
||||
result = cli_utils.NESTED_CLI_SUPPORT.run('app', 'env', parse_json=False)
|
||||
|
||||
assert result.returncode == 0
|
||||
assert recorded_kwargs['cwd'] == str(BACKEND_DIR)
|
||||
process_env = recorded_kwargs['env']
|
||||
assert isinstance(process_env, dict)
|
||||
assert str(BACKEND_DIR) in process_env.get('PYTHONPATH', '')
|
||||
|
||||
|
||||
def test_run_nested_cli_command_live_uses_backend_dir_without_capturing_output(monkeypatch: MonkeyPatch) -> None:
|
||||
"""
|
||||
校验交互式内部 CLI 调用会复用后端工作目录并直接占用当前终端。
|
||||
|
||||
:param monkeypatch: pytest monkeypatch 工具
|
||||
:return: None
|
||||
"""
|
||||
recorded_kwargs: dict[str, object] = {}
|
||||
recorded_args: tuple[object, ...] = ()
|
||||
|
||||
def _fake_subprocess_run(*args: object, **kwargs: object) -> SimpleNamespace:
|
||||
nonlocal recorded_args
|
||||
recorded_args = args
|
||||
recorded_kwargs.update(kwargs)
|
||||
return SimpleNamespace(returncode=0)
|
||||
|
||||
monkeypatch.setattr(cli_utils.subprocess, 'run', _fake_subprocess_run)
|
||||
|
||||
completed = cli_utils.NESTED_CLI_SUPPORT.run_live('wizard', 'cache-clear', '--output=text')
|
||||
|
||||
assert completed.returncode == 0
|
||||
assert recorded_args
|
||||
assert recorded_kwargs['cwd'] == str(BACKEND_DIR)
|
||||
process_env = recorded_kwargs['env']
|
||||
assert isinstance(process_env, dict)
|
||||
assert str(BACKEND_DIR) in process_env.get('PYTHONPATH', '')
|
||||
assert 'capture_output' not in recorded_kwargs
|
||||
@@ -0,0 +1,151 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
from pytest import MonkeyPatch
|
||||
|
||||
from cli.runtime.app import AppRuntimeService
|
||||
from cli.runtime.app.gateway import AppInfrastructureGateway
|
||||
from cli.runtime.app.support import AppSnapshotSupport
|
||||
from cli.runtime.base import RuntimeEnvironmentService
|
||||
|
||||
REDIS_PORT = 6379
|
||||
|
||||
|
||||
class FakeRuntimeEnvironment(RuntimeEnvironmentService):
|
||||
"""
|
||||
模拟运行时环境服务。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def get_backend_dir() -> str:
|
||||
"""
|
||||
返回固定后端目录。
|
||||
|
||||
:return: 固定目录
|
||||
"""
|
||||
return '/tmp/shierfeng-backend'
|
||||
|
||||
@staticmethod
|
||||
def get_python_executable() -> str:
|
||||
"""
|
||||
返回固定 Python 可执行文件。
|
||||
|
||||
:return: Python 可执行文件路径
|
||||
"""
|
||||
return '/usr/bin/python3'
|
||||
|
||||
|
||||
def test_app_snapshot_support_builds_config_snapshot() -> None:
|
||||
"""
|
||||
校验应用快照支持对象会构建应用配置快照。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
gateway = AppInfrastructureGateway()
|
||||
support = AppSnapshotSupport(gateway, FakeRuntimeEnvironment())
|
||||
|
||||
fake_env_module = SimpleNamespace(
|
||||
AppConfig=SimpleNamespace(
|
||||
app_env='dev',
|
||||
app_name='shierfeng',
|
||||
app_host='127.0.0.1',
|
||||
app_port=8080,
|
||||
app_root_path='/api',
|
||||
app_reload=True,
|
||||
app_workers=1,
|
||||
app_disable_swagger=False,
|
||||
app_disable_redoc=False,
|
||||
),
|
||||
DataBaseConfig=SimpleNamespace(
|
||||
db_type='mysql',
|
||||
db_host='127.0.0.1',
|
||||
db_port=3306,
|
||||
db_database='shierfeng',
|
||||
),
|
||||
RedisConfig=SimpleNamespace(redis_host='127.0.0.1', redis_port=REDIS_PORT),
|
||||
LogConfig=SimpleNamespace(loguru_level='INFO'),
|
||||
TransportCryptoConfig=SimpleNamespace(
|
||||
transport_crypto_enabled=True,
|
||||
transport_crypto_mode='strict',
|
||||
),
|
||||
)
|
||||
|
||||
def _fake_get_env_module() -> SimpleNamespace:
|
||||
return fake_env_module
|
||||
|
||||
object.__setattr__(gateway, 'get_env_module', _fake_get_env_module)
|
||||
|
||||
payload = support.build_app_config_snapshot()
|
||||
|
||||
assert payload['env'] == 'dev'
|
||||
assert payload['dbType'] == 'mysql'
|
||||
assert payload['redisPort'] == REDIS_PORT
|
||||
assert payload['transportCryptoMode'] == 'strict'
|
||||
|
||||
|
||||
def test_app_snapshot_support_builds_env_snapshot(monkeypatch: MonkeyPatch) -> None:
|
||||
"""
|
||||
校验应用快照支持对象会构建环境解析快照。
|
||||
|
||||
:param monkeypatch: pytest monkeypatch 工具
|
||||
:return: None
|
||||
"""
|
||||
gateway = AppInfrastructureGateway()
|
||||
support = AppSnapshotSupport(gateway, FakeRuntimeEnvironment())
|
||||
fake_env_module = SimpleNamespace(
|
||||
AppConfig=SimpleNamespace(app_env='prod'),
|
||||
)
|
||||
|
||||
def _fake_get_env_module() -> SimpleNamespace:
|
||||
return fake_env_module
|
||||
|
||||
object.__setattr__(gateway, 'get_env_module', _fake_get_env_module)
|
||||
monkeypatch.setenv('APP_ENV', 'test')
|
||||
|
||||
payload = support.build_app_env_snapshot()
|
||||
|
||||
assert payload == {
|
||||
'cliEnv': 'test',
|
||||
'configEnv': 'prod',
|
||||
'appEnv': 'test',
|
||||
'envFile': '.env.test',
|
||||
'envFilePath': '/tmp/shierfeng-backend/.env.test',
|
||||
'envFileExists': False,
|
||||
'backendDir': '/tmp/shierfeng-backend',
|
||||
'pythonExecutable': '/usr/bin/python3',
|
||||
}
|
||||
|
||||
|
||||
def test_app_runtime_service_builds_app_instance() -> None:
|
||||
"""
|
||||
校验应用运行时 facade 会通过基础设施网关构建应用实例。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
gateway = AppInfrastructureGateway()
|
||||
service = AppRuntimeService(
|
||||
runtime_environment=FakeRuntimeEnvironment(),
|
||||
infrastructure_gateway=gateway,
|
||||
)
|
||||
|
||||
class FakeServerModule:
|
||||
"""
|
||||
模拟 server 模块。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def create_app() -> dict[str, str]:
|
||||
"""
|
||||
返回模拟应用实例。
|
||||
|
||||
:return: 模拟应用实例
|
||||
"""
|
||||
return {'app': 'ok'}
|
||||
|
||||
def _fake_get_server_module() -> FakeServerModule:
|
||||
return FakeServerModule()
|
||||
|
||||
object.__setattr__(gateway, 'get_server_module', _fake_get_server_module)
|
||||
|
||||
payload = service.build_app_instance()
|
||||
|
||||
assert payload == {'app': 'ok'}
|
||||
@@ -0,0 +1,44 @@
|
||||
from pathlib import Path
|
||||
|
||||
from cli.core.app_builder import ProjectRuntimeLocator
|
||||
from cli.runtime.base import RUNTIME_ENVIRONMENT
|
||||
from cli.utils import NestedCliProjectLocator
|
||||
|
||||
|
||||
def test_backend_project_dir_detection_uses_consistent_rule(tmp_path: Path) -> None:
|
||||
"""
|
||||
校验不同入口的后端目录判定规则保持一致。
|
||||
|
||||
:param tmp_path: pytest 临时目录
|
||||
:return: None
|
||||
"""
|
||||
(tmp_path / 'app.py').write_text('', encoding='utf-8')
|
||||
(tmp_path / 'config').mkdir()
|
||||
(tmp_path / 'config' / 'env.py').write_text('', encoding='utf-8')
|
||||
(tmp_path / 'cli').mkdir()
|
||||
|
||||
project_runtime_locator = ProjectRuntimeLocator()
|
||||
nested_cli_project_locator = NestedCliProjectLocator()
|
||||
|
||||
assert RUNTIME_ENVIRONMENT.is_backend_project_dir(tmp_path) is True
|
||||
assert project_runtime_locator.is_backend_project_dir(tmp_path) is True
|
||||
assert nested_cli_project_locator.is_backend_project_dir(tmp_path) is True
|
||||
|
||||
|
||||
def test_backend_project_dir_detection_rejects_directory_without_cli_package(tmp_path: Path) -> None:
|
||||
"""
|
||||
校验缺少 cli 目录时不会被误判为后端项目根目录。
|
||||
|
||||
:param tmp_path: pytest 临时目录
|
||||
:return: None
|
||||
"""
|
||||
(tmp_path / 'app.py').write_text('', encoding='utf-8')
|
||||
(tmp_path / 'config').mkdir()
|
||||
(tmp_path / 'config' / 'env.py').write_text('', encoding='utf-8')
|
||||
|
||||
project_runtime_locator = ProjectRuntimeLocator()
|
||||
nested_cli_project_locator = NestedCliProjectLocator()
|
||||
|
||||
assert RUNTIME_ENVIRONMENT.is_backend_project_dir(tmp_path) is False
|
||||
assert project_runtime_locator.is_backend_project_dir(tmp_path) is False
|
||||
assert nested_cli_project_locator.is_backend_project_dir(tmp_path) is False
|
||||
@@ -0,0 +1,164 @@
|
||||
import pytest
|
||||
|
||||
from cli.runtime.cache import CacheRuntimeService
|
||||
from cli.runtime.cache.gateway import REDIS_TTL_KEY_NOT_FOUND, CacheInfrastructureGateway
|
||||
from cli.runtime.cache.support import CacheDomainSupport
|
||||
|
||||
EXPECTED_MATCHED_KEY_COUNT = 2
|
||||
|
||||
|
||||
def test_cache_domain_support_extracts_scoped_keys() -> None:
|
||||
"""
|
||||
校验缓存领域支持对象会提取指定缓存名前缀下的相对键名。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
support = CacheDomainSupport(CacheInfrastructureGateway())
|
||||
|
||||
key_items = support.extract_cache_key_items(
|
||||
'sys_config',
|
||||
['sys_config:site_name', 'sys_config:logo', 'login_tokens:user:1'],
|
||||
)
|
||||
|
||||
assert key_items == ['logo', 'site_name']
|
||||
|
||||
|
||||
def test_cache_domain_support_builds_missing_cache_result() -> None:
|
||||
"""
|
||||
校验缓存领域支持对象会生成统一的缓存不存在结果。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
support = CacheDomainSupport(CacheInfrastructureGateway())
|
||||
|
||||
payload = support.build_missing_cache_result('sys_config', 'site_name')
|
||||
|
||||
assert payload['ok'] is False
|
||||
assert payload['fullCacheKey'] == 'sys_config:site_name'
|
||||
assert payload['message'] == '缓存不存在:sys_config:site_name'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_runtime_service_ttl_returns_missing_result() -> None:
|
||||
"""
|
||||
校验缓存运行时在 TTL 返回不存在标记时会输出统一缺失结果。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
|
||||
class FakeRedis:
|
||||
"""
|
||||
模拟 Redis 客户端。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
async def ttl(full_cache_key: str) -> int:
|
||||
"""
|
||||
返回 Redis 键不存在标记。
|
||||
|
||||
:param full_cache_key: 完整缓存键名
|
||||
:return: Redis TTL 特殊值
|
||||
"""
|
||||
assert full_cache_key == 'sys_config:site_name'
|
||||
return REDIS_TTL_KEY_NOT_FOUND
|
||||
|
||||
@staticmethod
|
||||
async def close() -> None:
|
||||
"""
|
||||
关闭客户端。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
|
||||
class FakeRedisUtil:
|
||||
"""
|
||||
模拟 Redis 工具类。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
async def create_redis_pool(*, log_enabled: bool = False) -> FakeRedis:
|
||||
"""
|
||||
返回模拟 Redis 客户端。
|
||||
|
||||
:param log_enabled: 是否启用日志
|
||||
:return: 模拟 Redis 客户端
|
||||
"""
|
||||
del log_enabled
|
||||
return FakeRedis()
|
||||
|
||||
gateway = CacheInfrastructureGateway()
|
||||
service = CacheRuntimeService(infrastructure_gateway=gateway)
|
||||
|
||||
def _fake_get_redis_util() -> FakeRedisUtil:
|
||||
return FakeRedisUtil()
|
||||
|
||||
object.__setattr__(gateway, 'get_redis_util', _fake_get_redis_util)
|
||||
|
||||
payload = await service.get_cache_ttl('sys_config', 'site_name')
|
||||
|
||||
assert payload['ok'] is False
|
||||
assert payload['fullCacheKey'] == 'sys_config:site_name'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_runtime_service_clear_cache_dry_run_reports_scope() -> None:
|
||||
"""
|
||||
校验缓存运行时 dry-run 清理会返回匹配键与清理范围。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
|
||||
class FakeRedis:
|
||||
"""
|
||||
模拟 Redis 客户端。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
async def keys(pattern: str) -> list[str]:
|
||||
"""
|
||||
返回匹配键列表。
|
||||
|
||||
:param pattern: 匹配模式
|
||||
:return: 匹配结果
|
||||
"""
|
||||
assert pattern == 'sys_config:*'
|
||||
return ['sys_config:site_name', 'sys_config:logo']
|
||||
|
||||
@staticmethod
|
||||
async def close() -> None:
|
||||
"""
|
||||
关闭客户端。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
|
||||
class FakeRedisUtil:
|
||||
"""
|
||||
模拟 Redis 工具类。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
async def create_redis_pool(*, log_enabled: bool = False) -> FakeRedis:
|
||||
"""
|
||||
返回模拟 Redis 客户端。
|
||||
|
||||
:param log_enabled: 是否启用日志
|
||||
:return: 模拟 Redis 客户端
|
||||
"""
|
||||
del log_enabled
|
||||
return FakeRedis()
|
||||
|
||||
gateway = CacheInfrastructureGateway()
|
||||
service = CacheRuntimeService(infrastructure_gateway=gateway)
|
||||
|
||||
def _fake_get_redis_util() -> FakeRedisUtil:
|
||||
return FakeRedisUtil()
|
||||
|
||||
object.__setattr__(gateway, 'get_redis_util', _fake_get_redis_util)
|
||||
|
||||
payload = await service.clear_cache(cache_name='sys_config', dry_run=True)
|
||||
|
||||
assert payload['ok'] is True
|
||||
assert payload['matchedCount'] == EXPECTED_MATCHED_KEY_COUNT
|
||||
assert payload['scope'] == {'mode': 'cacheName', 'cacheName': 'sys_config'}
|
||||
assert payload['message'] == '缓存清理演练完成,未执行实际删除'
|
||||
@@ -0,0 +1,210 @@
|
||||
import pytest
|
||||
|
||||
from cli.exit_codes import RUNTIME_ERROR
|
||||
from cli.runtime.config import ConfigRuntimeService
|
||||
from cli.runtime.config.gateway import ConfigInfrastructureGateway
|
||||
from cli.runtime.config.support import ConfigDomainSupport
|
||||
|
||||
MISSING_CONFIG_KEY = 'sys.demo.key'
|
||||
|
||||
|
||||
def test_config_domain_support_builds_missing_config_result() -> None:
|
||||
"""
|
||||
校验参数配置领域支持对象会生成统一的缺失结果。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
support = ConfigDomainSupport(ConfigInfrastructureGateway())
|
||||
|
||||
payload = support.build_missing_config_result(MISSING_CONFIG_KEY, 'cache')
|
||||
|
||||
assert payload == {
|
||||
'ok': False,
|
||||
'message': f'参数缓存不存在:{MISSING_CONFIG_KEY}',
|
||||
'source': 'cache',
|
||||
'exit_code': RUNTIME_ERROR,
|
||||
}
|
||||
|
||||
|
||||
def test_config_domain_support_serializes_cache_payload_with_sanitizer() -> None:
|
||||
"""
|
||||
校验参数配置领域支持对象会对缓存配置做脱敏序列化。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
|
||||
class FakeSanitizer:
|
||||
"""
|
||||
模拟脱敏工具。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def sanitize_data(payload: dict[str, str]) -> dict[str, str]:
|
||||
"""
|
||||
返回带标记的脱敏结果。
|
||||
|
||||
:param payload: 原始载荷
|
||||
:return: 脱敏结果
|
||||
"""
|
||||
return {**payload, 'sanitized': 'yes'}
|
||||
|
||||
gateway = ConfigInfrastructureGateway()
|
||||
support = ConfigDomainSupport(gateway)
|
||||
|
||||
def _fake_get_log_sanitizer() -> FakeSanitizer:
|
||||
return FakeSanitizer()
|
||||
|
||||
object.__setattr__(gateway, 'get_log_sanitizer', _fake_get_log_sanitizer)
|
||||
|
||||
payload = support.serialize_cache_payload(MISSING_CONFIG_KEY, 'demo-value')
|
||||
|
||||
assert payload == {
|
||||
'configKey': MISSING_CONFIG_KEY,
|
||||
'configValue': 'demo-value',
|
||||
'sanitized': 'yes',
|
||||
}
|
||||
|
||||
|
||||
def test_config_runtime_service_builds_cli_config_model_from_orm_record() -> None:
|
||||
"""
|
||||
校验 CLI 会将 ORM 配置记录显式映射为可序列化的配置模型。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
|
||||
class FakeOrmConfigRecord:
|
||||
"""
|
||||
模拟 ORM 配置记录。
|
||||
"""
|
||||
|
||||
config_id = 2
|
||||
config_name = '用户管理-账号初始密码'
|
||||
config_key = 'sys.user.initPassword'
|
||||
config_value = '123456'
|
||||
config_type = 'Y'
|
||||
create_by = 'admin'
|
||||
create_time = None
|
||||
update_by = ''
|
||||
update_time = None
|
||||
remark = '初始化密码 123456'
|
||||
|
||||
class FakeConfigModel:
|
||||
"""
|
||||
模拟 VO 配置模型。
|
||||
"""
|
||||
|
||||
def __init__(self, **kwargs: object) -> None:
|
||||
self.kwargs = kwargs
|
||||
|
||||
class FakeConfigVoModule:
|
||||
"""
|
||||
模拟配置 VO 模块。
|
||||
"""
|
||||
|
||||
ConfigModel = FakeConfigModel
|
||||
|
||||
model = ConfigRuntimeService.build_cli_config_model(FakeConfigVoModule, FakeOrmConfigRecord())
|
||||
|
||||
assert isinstance(model, FakeConfigModel)
|
||||
assert model.kwargs == {
|
||||
'configId': 2,
|
||||
'configName': '用户管理-账号初始密码',
|
||||
'configKey': 'sys.user.initPassword',
|
||||
'configValue': '123456',
|
||||
'configType': 'Y',
|
||||
'createBy': 'admin',
|
||||
'createTime': None,
|
||||
'updateBy': '',
|
||||
'updateTime': None,
|
||||
'remark': '初始化密码 123456',
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_config_runtime_service_returns_missing_db_config_result() -> None:
|
||||
"""
|
||||
校验参数配置运行时在数据库中未找到配置时会返回统一结果。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
|
||||
class FakeSession:
|
||||
"""
|
||||
模拟异步会话。
|
||||
"""
|
||||
|
||||
async def __aenter__(self) -> 'FakeSession':
|
||||
return self
|
||||
|
||||
async def __aexit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc: BaseException | None,
|
||||
tb: object,
|
||||
) -> None:
|
||||
del exc_type, exc, tb
|
||||
|
||||
class FakeSessionFactory:
|
||||
"""
|
||||
模拟异步会话工厂。
|
||||
"""
|
||||
|
||||
def __call__(self) -> FakeSession:
|
||||
return FakeSession()
|
||||
|
||||
class FakeConfigModel:
|
||||
"""
|
||||
模拟配置模型。
|
||||
"""
|
||||
|
||||
def __init__(self, **kwargs: str) -> None:
|
||||
self.kwargs = kwargs
|
||||
|
||||
class FakeConfigVoModule:
|
||||
"""
|
||||
模拟配置 VO 模块。
|
||||
"""
|
||||
|
||||
ConfigModel = FakeConfigModel
|
||||
|
||||
class FakeConfigDao:
|
||||
"""
|
||||
模拟配置 DAO。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
async def get_config_detail_by_info(session: FakeSession, config_model: FakeConfigModel) -> None:
|
||||
"""
|
||||
返回空结果。
|
||||
|
||||
:param session: 数据库会话
|
||||
:param config_model: 配置模型
|
||||
:return: None
|
||||
"""
|
||||
assert isinstance(session, FakeSession)
|
||||
assert config_model.kwargs == {'configKey': MISSING_CONFIG_KEY}
|
||||
|
||||
gateway = ConfigInfrastructureGateway()
|
||||
service = ConfigRuntimeService(infrastructure_gateway=gateway)
|
||||
|
||||
def _fake_get_async_session_local() -> FakeSessionFactory:
|
||||
return FakeSessionFactory()
|
||||
|
||||
def _fake_get_config_dao() -> FakeConfigDao:
|
||||
return FakeConfigDao()
|
||||
|
||||
def _fake_get_config_vo_module() -> FakeConfigVoModule:
|
||||
return FakeConfigVoModule()
|
||||
|
||||
object.__setattr__(gateway, 'get_async_session_local', _fake_get_async_session_local)
|
||||
object.__setattr__(gateway, 'get_config_dao', _fake_get_config_dao)
|
||||
object.__setattr__(gateway, 'get_config_vo_module', _fake_get_config_vo_module)
|
||||
|
||||
payload = await service.get_config(MISSING_CONFIG_KEY, source='db')
|
||||
|
||||
assert payload == {
|
||||
'ok': False,
|
||||
'message': f'参数配置不存在:{MISSING_CONFIG_KEY}',
|
||||
'source': 'db',
|
||||
'exit_code': RUNTIME_ERROR,
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
from cli.exit_codes import ARGUMENT_ERROR
|
||||
from cli.runtime.crypto import CryptoRuntimeService
|
||||
from cli.runtime.crypto.gateway import CryptoInfrastructureGateway
|
||||
from cli.runtime.crypto.support import CryptoDomainSupport
|
||||
|
||||
INVALID_RSA_KEY_SIZE = 1024
|
||||
VALID_RSA_KEY_SIZE = 2048
|
||||
|
||||
|
||||
def test_crypto_domain_support_rejects_invalid_rsa_key_size() -> None:
|
||||
"""
|
||||
校验传输加密领域支持对象会拒绝非法 RSA 密钥长度。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
support = CryptoDomainSupport(CryptoInfrastructureGateway())
|
||||
|
||||
try:
|
||||
support.validate_rsa_key_size(INVALID_RSA_KEY_SIZE)
|
||||
except ValueError as exc:
|
||||
assert 'RSA 密钥长度必须大于等于 2048' in str(exc)
|
||||
else:
|
||||
raise AssertionError('expected ValueError for invalid RSA key size')
|
||||
|
||||
|
||||
def test_crypto_domain_support_rejects_non_list_legacy_key_pairs() -> None:
|
||||
"""
|
||||
校验传输加密领域支持对象会拒绝非数组格式的历史密钥配置。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
gateway = CryptoInfrastructureGateway()
|
||||
support = CryptoDomainSupport(gateway)
|
||||
|
||||
def _fake_transport_crypto_config() -> SimpleNamespace:
|
||||
return SimpleNamespace(transport_crypto_legacy_key_pairs='{"kid": "legacy"}')
|
||||
|
||||
object.__setattr__(gateway, 'get_transport_crypto_config', _fake_transport_crypto_config)
|
||||
|
||||
try:
|
||||
support.load_existing_legacy_key_pairs()
|
||||
except ValueError as exc:
|
||||
assert 'TRANSPORT_CRYPTO_LEGACY_KEY_PAIRS 必须为 JSON 数组' in str(exc)
|
||||
else:
|
||||
raise AssertionError('expected ValueError for invalid legacy key pairs payload')
|
||||
|
||||
|
||||
def test_crypto_runtime_service_keygen_returns_argument_error_for_invalid_size() -> None:
|
||||
"""
|
||||
校验传输加密运行时在非法密钥长度下返回参数错误结果。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
service = CryptoRuntimeService()
|
||||
|
||||
payload = service.generate_crypto_key_pair('kid-20260511', INVALID_RSA_KEY_SIZE)
|
||||
|
||||
assert payload['ok'] is False
|
||||
assert payload['exit_code'] == ARGUMENT_ERROR
|
||||
assert payload['message'] == '生成传输加密密钥失败'
|
||||
|
||||
|
||||
def test_crypto_runtime_service_rotation_rejects_same_kid() -> None:
|
||||
"""
|
||||
校验传输加密运行时会拒绝与当前版本相同的新密钥版本。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
gateway = CryptoInfrastructureGateway()
|
||||
service = CryptoRuntimeService(infrastructure_gateway=gateway)
|
||||
|
||||
provider = SimpleNamespace(
|
||||
validate_runtime_configuration=lambda: None,
|
||||
get_current_key_pair=lambda: SimpleNamespace(
|
||||
kid='kid-current',
|
||||
private_key_pem='PRIVATE-CURRENT',
|
||||
public_key_pem='PUBLIC-CURRENT',
|
||||
),
|
||||
)
|
||||
|
||||
object.__setattr__(gateway, 'get_transport_key_provider', lambda: provider)
|
||||
|
||||
payload = service.build_rotation_payload('kid-current', VALID_RSA_KEY_SIZE)
|
||||
|
||||
assert payload['ok'] is False
|
||||
assert payload['exit_code'] == ARGUMENT_ERROR
|
||||
assert payload['message'] == '生成密钥轮换方案失败'
|
||||
assert payload['error'] == '新密钥版本不能与当前版本相同'
|
||||
|
||||
|
||||
def test_crypto_runtime_service_rotation_builds_merged_payload() -> None:
|
||||
"""
|
||||
校验传输加密运行时会生成包含历史密钥合并结果的轮换辅助数据。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
gateway = CryptoInfrastructureGateway()
|
||||
domain_support = CryptoDomainSupport(gateway)
|
||||
service = CryptoRuntimeService(
|
||||
infrastructure_gateway=gateway,
|
||||
domain_support=domain_support,
|
||||
)
|
||||
provider = SimpleNamespace(
|
||||
validate_runtime_configuration=lambda: None,
|
||||
get_current_key_pair=lambda: SimpleNamespace(
|
||||
kid='kid-current',
|
||||
private_key_pem='PRIVATE-CURRENT',
|
||||
public_key_pem='PUBLIC-CURRENT',
|
||||
),
|
||||
)
|
||||
|
||||
object.__setattr__(gateway, 'get_transport_key_provider', lambda: provider)
|
||||
object.__setattr__(
|
||||
domain_support,
|
||||
'generate_rsa_key_pair',
|
||||
lambda key_size: ('PRIVATE-NEXT', 'PUBLIC-NEXT'),
|
||||
)
|
||||
object.__setattr__(
|
||||
domain_support,
|
||||
'load_existing_legacy_key_pairs',
|
||||
lambda: [
|
||||
{'kid': 'kid-legacy', 'privateKey': 'PRIVATE-LEGACY', 'publicKey': 'PUBLIC-LEGACY'},
|
||||
{'kid': 'kid-next', 'privateKey': 'STALE-PRIVATE', 'publicKey': 'STALE-PUBLIC'},
|
||||
],
|
||||
)
|
||||
|
||||
payload = service.build_rotation_payload('kid-next', VALID_RSA_KEY_SIZE)
|
||||
|
||||
assert payload['ok'] is True
|
||||
assert payload['currentKid'] == 'kid-current'
|
||||
assert payload['nextKid'] == 'kid-next'
|
||||
assert payload['nextKeyPair']['privateKey'] == 'PRIVATE-NEXT'
|
||||
assert payload['nextKeyPair']['publicKey'] == 'PUBLIC-NEXT'
|
||||
assert payload['envPatch']['TRANSPORT_CRYPTO_KID'] == 'kid-next'
|
||||
assert payload['envPatch']['TRANSPORT_CRYPTO_PRIVATE_KEY'] == 'PRIVATE-NEXT'
|
||||
assert payload['envPatch']['TRANSPORT_CRYPTO_PUBLIC_KEY'] == 'PUBLIC-NEXT'
|
||||
|
||||
legacy_key_pairs = payload['legacyKeyPairs']
|
||||
legacy_by_kid = {item['kid']: item for item in legacy_key_pairs}
|
||||
assert 'kid-next' not in legacy_by_kid
|
||||
assert legacy_by_kid['kid-current']['privateKey'] == 'PRIVATE-CURRENT'
|
||||
assert legacy_by_kid['kid-legacy']['publicKey'] == 'PUBLIC-LEGACY'
|
||||
|
||||
serialized_legacy = json.loads(payload['envPatch']['TRANSPORT_CRYPTO_LEGACY_KEY_PAIRS'])
|
||||
serialized_by_kid = {item['kid']: item for item in serialized_legacy}
|
||||
assert serialized_by_kid['kid-current']['publicKey'] == 'PUBLIC-CURRENT'
|
||||
@@ -0,0 +1,144 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from cli.runtime.base import RuntimeEnvironmentService
|
||||
from cli.runtime.db import DatabaseRuntimeService
|
||||
from cli.runtime.db.gateway import DatabaseInfrastructureGateway
|
||||
from cli.runtime.db.support import DatabaseRevisionSupport
|
||||
|
||||
|
||||
class FakeRuntimeEnvironment(RuntimeEnvironmentService):
|
||||
"""
|
||||
模拟运行时环境服务。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def get_backend_dir() -> str:
|
||||
"""
|
||||
返回固定后端目录。
|
||||
|
||||
:return: 固定目录
|
||||
"""
|
||||
return '/tmp/shierfeng-backend'
|
||||
|
||||
|
||||
def test_database_revision_support_serializes_revision() -> None:
|
||||
"""
|
||||
校验数据库迁移版本支持对象会序列化 Alembic revision。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
|
||||
class FakeRevision:
|
||||
"""
|
||||
模拟 Alembic revision 对象。
|
||||
"""
|
||||
|
||||
revision = '202605110001'
|
||||
down_revision = ('202605100001',)
|
||||
branch_labels = {'main'}
|
||||
dependencies = None
|
||||
doc = ' demo revision '
|
||||
path = Path('/tmp/revision.py')
|
||||
|
||||
support = DatabaseRevisionSupport(DatabaseInfrastructureGateway(), FakeRuntimeEnvironment())
|
||||
|
||||
payload = support.serialize_revision(FakeRevision())
|
||||
|
||||
assert payload == {
|
||||
'revision': '202605110001',
|
||||
'downRevisions': ['202605100001'],
|
||||
'branchLabels': ['main'],
|
||||
'dependsOn': [],
|
||||
'doc': 'demo revision',
|
||||
'path': '/tmp/revision.py',
|
||||
}
|
||||
|
||||
|
||||
def test_database_runtime_service_upgrade_dry_run_returns_command_payload() -> None:
|
||||
"""
|
||||
校验数据库运行时 dry-run 升级会返回命令预览结果。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
service = DatabaseRuntimeService(runtime_environment=FakeRuntimeEnvironment())
|
||||
|
||||
payload = service.upgrade_database('head', dry_run=True)
|
||||
|
||||
assert payload['ok'] is True
|
||||
assert payload['dryRun'] is True
|
||||
assert payload['cwd'] == '/tmp/shierfeng-backend'
|
||||
assert payload['command'] == ['alembic', '-c', '/tmp/shierfeng-backend/alembic.ini', 'upgrade', 'head']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_database_runtime_service_ping_database_returns_failure() -> None:
|
||||
"""
|
||||
校验数据库运行时在连接异常时会返回失败结果。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
|
||||
class FakeConnection:
|
||||
"""
|
||||
模拟数据库连接对象。
|
||||
"""
|
||||
|
||||
async def __aenter__(self) -> 'FakeConnection':
|
||||
raise RuntimeError('db boom')
|
||||
|
||||
async def __aexit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc: BaseException | None,
|
||||
tb: object,
|
||||
) -> None:
|
||||
del exc_type, exc, tb
|
||||
|
||||
class FakeEngine:
|
||||
"""
|
||||
模拟数据库引擎。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def connect() -> FakeConnection:
|
||||
"""
|
||||
返回模拟连接对象。
|
||||
|
||||
:return: 模拟连接对象
|
||||
"""
|
||||
return FakeConnection()
|
||||
|
||||
@staticmethod
|
||||
async def dispose() -> None:
|
||||
"""
|
||||
释放引擎资源。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
|
||||
gateway = DatabaseInfrastructureGateway()
|
||||
service = DatabaseRuntimeService(
|
||||
runtime_environment=FakeRuntimeEnvironment(),
|
||||
infrastructure_gateway=gateway,
|
||||
)
|
||||
|
||||
def _fake_get_async_db_engine_factory() -> object:
|
||||
def _factory(*, echo: bool = False) -> FakeEngine:
|
||||
del echo
|
||||
return FakeEngine()
|
||||
|
||||
return _factory
|
||||
|
||||
def _fake_get_sqlalchemy_text() -> object:
|
||||
return lambda sql: sql
|
||||
|
||||
object.__setattr__(gateway, 'get_async_db_engine_factory', _fake_get_async_db_engine_factory)
|
||||
object.__setattr__(gateway, 'get_sqlalchemy_text', _fake_get_sqlalchemy_text)
|
||||
|
||||
payload = await service.ping_database()
|
||||
|
||||
assert payload['ok'] is False
|
||||
assert payload['message'] == '数据库连接失败'
|
||||
assert 'db boom' in payload['error']
|
||||
@@ -0,0 +1,126 @@
|
||||
from cli.exit_codes import DEPENDENCY_ERROR
|
||||
from cli.runtime.base import RuntimeEnvironmentService
|
||||
from cli.runtime.dev import DevelopmentRuntimeService
|
||||
from cli.runtime.dev.gateway import DevelopmentProcessGateway
|
||||
from cli.runtime.dev.support import DevelopmentCommandSupport, DevelopmentToolingSupport
|
||||
|
||||
|
||||
class FakeRuntimeEnvironment(RuntimeEnvironmentService):
|
||||
"""
|
||||
模拟运行时环境服务。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def get_backend_dir() -> str:
|
||||
"""
|
||||
返回固定后端目录。
|
||||
|
||||
:return: 固定目录
|
||||
"""
|
||||
return '/tmp/shierfeng-backend'
|
||||
|
||||
@staticmethod
|
||||
def get_python_executable() -> str:
|
||||
"""
|
||||
返回固定 Python 可执行文件。
|
||||
|
||||
:return: Python 可执行文件路径
|
||||
"""
|
||||
return '/usr/bin/python3'
|
||||
|
||||
|
||||
def test_development_runtime_service_returns_dependency_error_when_pytest_missing() -> None:
|
||||
"""
|
||||
校验开发运行时在 pytest 缺失时会返回依赖错误结果。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
|
||||
class FakeToolingSupport(DevelopmentToolingSupport):
|
||||
"""
|
||||
模拟开发工具支持对象。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def is_pytest_available() -> bool:
|
||||
"""
|
||||
返回 pytest 不可用状态。
|
||||
|
||||
:return: False
|
||||
"""
|
||||
return False
|
||||
|
||||
service = DevelopmentRuntimeService(
|
||||
runtime_environment=FakeRuntimeEnvironment(),
|
||||
tooling_support=FakeToolingSupport(),
|
||||
)
|
||||
|
||||
payload = service.run_tests(['tests/cli'])
|
||||
|
||||
assert payload == {
|
||||
'ok': False,
|
||||
'message': 'pytest 未安装,无法执行 dev test',
|
||||
'hint': '请在当前环境安装 pytest 后重试',
|
||||
'exit_code': DEPENDENCY_ERROR,
|
||||
}
|
||||
|
||||
|
||||
def test_development_runtime_service_run_lint_uses_command_support() -> None:
|
||||
"""
|
||||
校验开发运行时会通过命令支持对象串联 format/check 执行流程。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
|
||||
class FakeCommandSupport(DevelopmentCommandSupport):
|
||||
"""
|
||||
模拟开发命令支持对象。
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.recorded_commands: list[list[str]] = []
|
||||
|
||||
def build_format_command(self, normalized_targets: list[str], *, check_only: bool) -> list[str]:
|
||||
assert normalized_targets == ['tests/cli']
|
||||
assert check_only is True
|
||||
return ['format-command']
|
||||
|
||||
def build_check_command(
|
||||
self,
|
||||
normalized_targets: list[str],
|
||||
*,
|
||||
check_only: bool,
|
||||
fix: bool,
|
||||
unsafe_fixes: bool,
|
||||
) -> list[str]:
|
||||
assert normalized_targets == ['tests/cli']
|
||||
assert check_only is True
|
||||
assert fix is False
|
||||
assert unsafe_fixes is False
|
||||
return ['check-command']
|
||||
|
||||
class FakeProcessGateway(DevelopmentProcessGateway):
|
||||
"""
|
||||
模拟开发子进程执行网关。
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.recorded_commands: list[list[str]] = []
|
||||
|
||||
def run_command(self, command: list[str]) -> dict[str, object]:
|
||||
self.recorded_commands.append(command)
|
||||
return {'ok': True, 'command': command, 'returnCode': 0}
|
||||
|
||||
command_support = FakeCommandSupport()
|
||||
process_gateway = FakeProcessGateway()
|
||||
service = DevelopmentRuntimeService(
|
||||
runtime_environment=FakeRuntimeEnvironment(),
|
||||
command_support=command_support,
|
||||
process_gateway=process_gateway,
|
||||
)
|
||||
|
||||
payload = service.run_lint(['tests/cli'], check_only=True)
|
||||
|
||||
assert payload['ok'] is True
|
||||
assert payload['targets'] == ['tests/cli']
|
||||
assert process_gateway.recorded_commands == [['format-command'], ['check-command']]
|
||||
@@ -0,0 +1,98 @@
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from cli.exit_codes import ARGUMENT_ERROR
|
||||
from cli.runtime.gen import GenRuntimeService
|
||||
from cli.runtime.gen.gateway import GenInfrastructureGateway
|
||||
from cli.runtime.gen.support import GenDomainSupport
|
||||
|
||||
REDIS_UNUSED = 'unused'
|
||||
|
||||
|
||||
def test_gen_domain_support_resolves_sql_text_from_argument() -> None:
|
||||
"""
|
||||
校验代码生成领域支持对象会优先使用直接传入的 SQL 文本。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
support = GenDomainSupport(GenInfrastructureGateway())
|
||||
|
||||
payload = support.resolve_sql_text(' CREATE TABLE demo(id bigint); ', '')
|
||||
|
||||
assert payload == 'CREATE TABLE demo(id bigint);'
|
||||
|
||||
|
||||
def test_gen_domain_support_resolve_sql_text_rejects_invalid_inputs(tmp_path: Path) -> None:
|
||||
"""
|
||||
校验代码生成领域支持对象会拒绝无效 SQL 输入组合。
|
||||
|
||||
:param tmp_path: pytest 临时目录
|
||||
:return: None
|
||||
"""
|
||||
support = GenDomainSupport(GenInfrastructureGateway())
|
||||
sql_file = tmp_path / 'demo.sql'
|
||||
sql_file.write_text('select 1', encoding='utf-8')
|
||||
|
||||
try:
|
||||
support.resolve_sql_text('select 1', str(sql_file))
|
||||
except ValueError as exc:
|
||||
assert '必须且只能传入 --sql 或 --sql-file 其中一种方式' in str(exc)
|
||||
else:
|
||||
raise AssertionError('expected ValueError for conflicting SQL inputs')
|
||||
|
||||
|
||||
def test_gen_domain_support_writes_export_zip(tmp_path: Path) -> None:
|
||||
"""
|
||||
校验代码生成领域支持对象会写出导出压缩包。
|
||||
|
||||
:param tmp_path: pytest 临时目录
|
||||
:return: None
|
||||
"""
|
||||
support = GenDomainSupport(GenInfrastructureGateway())
|
||||
target_file = tmp_path / 'exports' / 'gen.zip'
|
||||
|
||||
output_path = support.write_export_zip(str(target_file), b'zip-content')
|
||||
|
||||
assert output_path == str(target_file.resolve())
|
||||
assert target_file.read_bytes() == b'zip-content'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gen_runtime_service_export_code_dry_run_returns_preview_payload() -> None:
|
||||
"""
|
||||
校验代码生成运行时在 dry-run 导出时会返回结构化预览结果。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
gateway = GenInfrastructureGateway()
|
||||
service = GenRuntimeService(infrastructure_gateway=gateway)
|
||||
|
||||
def _fake_get_gen_config() -> SimpleNamespace:
|
||||
return SimpleNamespace(allow_overwrite=True, GEN_PATH='/tmp/gen')
|
||||
|
||||
object.__setattr__(gateway, 'get_gen_config', _fake_get_gen_config)
|
||||
|
||||
payload = await service.export_code(['sys_user', 'sys_role'], mode='zip', dry_run=True)
|
||||
|
||||
assert payload['ok'] is True
|
||||
assert payload['dryRun'] is True
|
||||
assert payload['tableNames'] == ['sys_user', 'sys_role']
|
||||
assert payload['outputFile'].endswith('gen_code_sys_user_sys_role.zip')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gen_runtime_service_import_tables_requires_at_least_one_table() -> None:
|
||||
"""
|
||||
校验代码生成运行时在未传入表名时会返回参数错误。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
service = GenRuntimeService()
|
||||
|
||||
payload = await service.import_tables([])
|
||||
|
||||
assert payload['ok'] is False
|
||||
assert payload['exit_code'] == ARGUMENT_ERROR
|
||||
assert payload['message'] == '至少需要传入一个表名'
|
||||
@@ -0,0 +1,174 @@
|
||||
import pytest
|
||||
|
||||
from cli.runtime.job import JobRuntimeService
|
||||
from cli.runtime.job.gateway import JobInfrastructureGateway
|
||||
from cli.runtime.job.support import JobDomainSupport, JobSchedulerSupport
|
||||
|
||||
MISSING_JOB_ID = 99
|
||||
|
||||
|
||||
def test_job_domain_support_builds_operation_metadata() -> None:
|
||||
"""
|
||||
校验任务领域支持对象会返回统一的任务操作元数据。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
support = JobDomainSupport(JobInfrastructureGateway())
|
||||
|
||||
payload = support.build_job_operation_metadata('pause')
|
||||
|
||||
assert payload == {
|
||||
'operationLabel': '暂停任务',
|
||||
'successMessage': '定时任务已暂停',
|
||||
}
|
||||
|
||||
|
||||
def test_job_domain_support_serializes_model_dump_items() -> None:
|
||||
"""
|
||||
校验任务领域支持对象会序列化带 `model_dump` 的任务项。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
|
||||
class FakeJobModel:
|
||||
"""
|
||||
模拟任务模型。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def model_dump(*, by_alias: bool = False, exclude_none: bool = False) -> dict[str, object]:
|
||||
"""
|
||||
返回模拟序列化结果。
|
||||
|
||||
:param by_alias: 是否按别名输出
|
||||
:param exclude_none: 是否排除空值
|
||||
:return: 模拟序列化结果
|
||||
"""
|
||||
assert by_alias is True
|
||||
assert exclude_none is True
|
||||
return {'jobId': 1, 'jobName': 'sync-job'}
|
||||
|
||||
support = JobDomainSupport(JobInfrastructureGateway())
|
||||
|
||||
payload = support.serialize_job_item(FakeJobModel())
|
||||
|
||||
assert payload == {'jobId': 1, 'jobName': 'sync-job'}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_job_scheduler_support_closes_scheduler_and_redis() -> None:
|
||||
"""
|
||||
校验任务调度支持对象会关闭调度器和 Redis 资源。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
close_events: list[str] = []
|
||||
|
||||
class FakeRedis:
|
||||
"""
|
||||
模拟 Redis 客户端。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
async def close() -> None:
|
||||
"""
|
||||
记录 Redis 关闭事件。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
close_events.append('redis')
|
||||
|
||||
class FakeSchedulerUtil:
|
||||
"""
|
||||
模拟调度器工具。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
async def close_system_scheduler() -> None:
|
||||
"""
|
||||
记录调度器关闭事件。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
close_events.append('scheduler')
|
||||
|
||||
gateway = JobInfrastructureGateway()
|
||||
support = JobSchedulerSupport(gateway)
|
||||
|
||||
def _fake_get_scheduler_util() -> FakeSchedulerUtil:
|
||||
return FakeSchedulerUtil()
|
||||
|
||||
object.__setattr__(gateway, 'get_scheduler_util', _fake_get_scheduler_util)
|
||||
|
||||
await support.close_scheduler_context(FakeRedis())
|
||||
|
||||
assert close_events == ['scheduler', 'redis']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_job_runtime_service_reports_missing_job_detail() -> None:
|
||||
"""
|
||||
校验任务运行时在任务详情不存在时会返回统一结果。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
|
||||
class FakeSession:
|
||||
"""
|
||||
模拟异步会话。
|
||||
"""
|
||||
|
||||
async def __aenter__(self) -> 'FakeSession':
|
||||
return self
|
||||
|
||||
async def __aexit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc: BaseException | None,
|
||||
tb: object,
|
||||
) -> None:
|
||||
del exc_type, exc, tb
|
||||
|
||||
class FakeSessionFactory:
|
||||
"""
|
||||
模拟异步会话工厂。
|
||||
"""
|
||||
|
||||
def __call__(self) -> FakeSession:
|
||||
return FakeSession()
|
||||
|
||||
class FakeJobService:
|
||||
"""
|
||||
模拟任务服务。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
async def job_detail_services(session: FakeSession, job_id: int) -> dict[str, object]:
|
||||
"""
|
||||
返回不存在任务的空结果。
|
||||
|
||||
:param session: 数据库会话
|
||||
:param job_id: 任务 ID
|
||||
:return: 空结果
|
||||
"""
|
||||
assert isinstance(session, FakeSession)
|
||||
assert job_id == MISSING_JOB_ID
|
||||
return {}
|
||||
|
||||
gateway = JobInfrastructureGateway()
|
||||
service = JobRuntimeService(infrastructure_gateway=gateway)
|
||||
|
||||
def _fake_get_async_session_local() -> FakeSessionFactory:
|
||||
return FakeSessionFactory()
|
||||
|
||||
def _fake_get_job_service() -> FakeJobService:
|
||||
return FakeJobService()
|
||||
|
||||
object.__setattr__(gateway, 'get_async_session_local', _fake_get_async_session_local)
|
||||
object.__setattr__(gateway, 'get_job_service', _fake_get_job_service)
|
||||
|
||||
payload = await service.get_job_detail(MISSING_JOB_ID)
|
||||
|
||||
assert payload['ok'] is False
|
||||
assert payload['jobId'] == MISSING_JOB_ID
|
||||
assert payload['message'] == f'定时任务不存在:{MISSING_JOB_ID}'
|
||||
@@ -0,0 +1,167 @@
|
||||
import socket
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from cli.runtime.ops import OperationsRuntimeService
|
||||
from cli.runtime.ops.gateway import OperationsInfrastructureGateway
|
||||
from cli.runtime.ops.support import OperationsDependencyInspector, OperationsServerInfoSupport
|
||||
|
||||
|
||||
def test_operations_dependency_inspector_reports_missing_required(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""
|
||||
校验运维依赖检查器会报告缺失的必需依赖。
|
||||
|
||||
:param monkeypatch: pytest monkeypatch 工具
|
||||
:return: None
|
||||
"""
|
||||
inspector = OperationsDependencyInspector()
|
||||
monkeypatch.setattr(
|
||||
inspector,
|
||||
'read_package_version',
|
||||
lambda distribution_name: None if distribution_name == 'fastapi' else '1.0.0',
|
||||
)
|
||||
|
||||
payload = inspector.inspect()
|
||||
|
||||
assert payload['ok'] is False
|
||||
assert payload['missingRequired'] == ['fastapi']
|
||||
assert payload['packages']['python']['installed'] is True
|
||||
|
||||
|
||||
def test_operations_server_info_support_resolves_non_loopback_ip(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""
|
||||
校验服务器信息支持对象会在主机名解析失败时回退到网卡地址。
|
||||
|
||||
:param monkeypatch: pytest monkeypatch 工具
|
||||
:return: None
|
||||
"""
|
||||
|
||||
class FakePsutil:
|
||||
"""
|
||||
模拟 psutil 模块。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def net_if_addrs() -> dict[str, list[SimpleNamespace]]:
|
||||
"""
|
||||
返回模拟网卡地址。
|
||||
|
||||
:return: 模拟网卡地址映射
|
||||
"""
|
||||
return {
|
||||
'en0': [
|
||||
SimpleNamespace(family=socket.AF_INET, address='127.0.0.1'),
|
||||
SimpleNamespace(family=socket.AF_INET, address='192.168.1.8'),
|
||||
]
|
||||
}
|
||||
|
||||
gateway = OperationsInfrastructureGateway()
|
||||
support = OperationsServerInfoSupport(gateway)
|
||||
|
||||
def _fake_get_psutil_module() -> FakePsutil:
|
||||
return FakePsutil()
|
||||
|
||||
def _fake_gethostbyname(hostname: str) -> str:
|
||||
del hostname
|
||||
raise OSError('boom')
|
||||
|
||||
monkeypatch.setattr(gateway, 'get_psutil_module', _fake_get_psutil_module)
|
||||
monkeypatch.setattr(socket, 'gethostbyname', _fake_gethostbyname)
|
||||
|
||||
resolved_ip = support.resolve_server_ip('demo-host')
|
||||
|
||||
assert resolved_ip == '192.168.1.8'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_operations_runtime_service_sync_jobs_closes_resources_on_success() -> None:
|
||||
"""
|
||||
校验运维运行时在同步调度成功后会关闭调度器和 Redis 资源。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
close_events: list[str] = []
|
||||
|
||||
class FakeRedis:
|
||||
"""
|
||||
模拟 Redis 客户端。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
async def close() -> None:
|
||||
"""
|
||||
记录关闭事件。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
close_events.append('redis')
|
||||
|
||||
class FakeRedisUtil:
|
||||
"""
|
||||
模拟 Redis 工具。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
async def create_redis_pool(*, log_enabled: bool = False) -> FakeRedis:
|
||||
"""
|
||||
返回模拟 Redis 客户端。
|
||||
|
||||
:param log_enabled: 是否启用日志
|
||||
:return: 模拟 Redis 客户端
|
||||
"""
|
||||
del log_enabled
|
||||
return FakeRedis()
|
||||
|
||||
class FakeSchedulerUtil:
|
||||
"""
|
||||
模拟调度器工具。
|
||||
"""
|
||||
|
||||
_is_leader = True
|
||||
|
||||
@staticmethod
|
||||
async def init_system_scheduler(redis: FakeRedis) -> None:
|
||||
"""
|
||||
初始化调度器。
|
||||
|
||||
:param redis: Redis 客户端
|
||||
:return: None
|
||||
"""
|
||||
assert isinstance(redis, FakeRedis)
|
||||
|
||||
@staticmethod
|
||||
async def request_scheduler_sync() -> None:
|
||||
"""
|
||||
发送调度同步请求。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
async def close_system_scheduler() -> None:
|
||||
"""
|
||||
记录调度器关闭事件。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
close_events.append('scheduler')
|
||||
|
||||
gateway = OperationsInfrastructureGateway()
|
||||
service = OperationsRuntimeService(infrastructure_gateway=gateway)
|
||||
|
||||
def _fake_get_redis_util() -> FakeRedisUtil:
|
||||
return FakeRedisUtil()
|
||||
|
||||
def _fake_get_scheduler_util() -> FakeSchedulerUtil:
|
||||
return FakeSchedulerUtil()
|
||||
|
||||
object.__setattr__(gateway, 'get_redis_util', _fake_get_redis_util)
|
||||
object.__setattr__(gateway, 'get_scheduler_util', _fake_get_scheduler_util)
|
||||
|
||||
payload = await service.sync_jobs()
|
||||
|
||||
assert payload['ok'] is True
|
||||
assert payload['schedulerSyncRequested'] is True
|
||||
assert payload['isLeader'] is True
|
||||
assert close_events == ['scheduler', 'redis']
|
||||
@@ -0,0 +1,579 @@
|
||||
import importlib
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
from pytest import MonkeyPatch
|
||||
|
||||
BACKEND_DIR = Path(__file__).resolve().parents[4]
|
||||
|
||||
sys.path.insert(0, str(BACKEND_DIR))
|
||||
sys.modules.pop('cli.tui.capabilities', None)
|
||||
sys.modules.pop('cli.tui.actions', None)
|
||||
sys.modules.pop('cli.wizard.flows.gen_import', None)
|
||||
sys.modules.pop('cli', None)
|
||||
|
||||
capabilities_module = importlib.import_module('cli.tui.capabilities')
|
||||
actions_module = importlib.import_module('cli.tui.actions')
|
||||
action_assembly_module = importlib.import_module('cli.tui.actions.assembly')
|
||||
action_bootstrap_module = importlib.import_module('cli.tui.actions.bootstrap')
|
||||
action_execution_module = importlib.import_module('cli.tui.actions.execution')
|
||||
adapters_module = importlib.import_module('cli.tui.adapters')
|
||||
|
||||
|
||||
def test_capabilities_registry_describes_supported_views() -> None:
|
||||
"""
|
||||
校验 TUI 能力注册表会集中描述页面支持的动作能力。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
cache_capabilities = capabilities_module.TUI_CAPABILITY_REGISTRY.get_browser_capabilities('cache')
|
||||
gen_capabilities = capabilities_module.TUI_CAPABILITY_REGISTRY.get_browser_capabilities('gen')
|
||||
app_capabilities = capabilities_module.TUI_CAPABILITY_REGISTRY.get_detail_capabilities('app')
|
||||
database_capabilities = capabilities_module.TUI_CAPABILITY_REGISTRY.get_detail_capabilities('database')
|
||||
ops_capabilities = capabilities_module.TUI_CAPABILITY_REGISTRY.get_detail_capabilities('ops')
|
||||
crypto_capabilities = capabilities_module.TUI_CAPABILITY_REGISTRY.get_detail_capabilities('crypto')
|
||||
|
||||
assert [capability.slot for capability in cache_capabilities] == ['global', 'utility']
|
||||
assert [capability.kind for capability in cache_capabilities] == ['wizard_entry', 'low_risk_action']
|
||||
assert [capability.hint_label for capability in cache_capabilities] == ['清理向导', '执行缓存预热']
|
||||
assert [capability.slot for capability in gen_capabilities] == ['primary', 'secondary', 'global', 'utility']
|
||||
assert [capability.kind for capability in gen_capabilities] == [
|
||||
'wizard_entry',
|
||||
'wizard_entry',
|
||||
'preview',
|
||||
'low_risk_action',
|
||||
]
|
||||
assert [capability.hint_label for capability in gen_capabilities] == [
|
||||
'导出向导',
|
||||
'导入向导',
|
||||
'导出预演',
|
||||
'同步表结构',
|
||||
]
|
||||
assert [capability.slot for capability in app_capabilities] == ['primary', 'global', 'utility']
|
||||
assert [capability.kind for capability in app_capabilities] == ['wizard_entry', 'wizard_entry', 'command_hint']
|
||||
assert [capability.hint_label for capability in app_capabilities] == ['直接启动', '打开启动向导', '安装补全']
|
||||
assert [capability.slot for capability in database_capabilities] == ['global', 'utility']
|
||||
assert [capability.kind for capability in database_capabilities] == ['wizard_entry', 'preview']
|
||||
assert [capability.hint_label for capability in database_capabilities] == ['打开升级向导', '初始化预演']
|
||||
assert [capability.slot for capability in ops_capabilities] == ['primary', 'secondary', 'global']
|
||||
assert [capability.kind for capability in ops_capabilities] == [
|
||||
'low_risk_action',
|
||||
'low_risk_action',
|
||||
'wizard_entry',
|
||||
]
|
||||
assert [capability.hint_label for capability in ops_capabilities] == [
|
||||
'数据库探活',
|
||||
'Redis 探活',
|
||||
'打开生产巡检向导',
|
||||
]
|
||||
assert [capability.slot for capability in crypto_capabilities] == ['primary', 'global']
|
||||
assert [capability.kind for capability in crypto_capabilities] == ['wizard_entry', 'preview']
|
||||
assert [capability.hint_label for capability in crypto_capabilities] == ['密钥生成', '执行轮换预演']
|
||||
|
||||
|
||||
def test_action_registry_resolves_browser_action_for_job_record() -> None:
|
||||
"""
|
||||
校验任务页会解析出执行一次、暂停/恢复和同步动作。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
record = adapters_module.BrowserRecordSnapshot(
|
||||
key='job:101',
|
||||
title='同步任务',
|
||||
status='warn',
|
||||
summary='暂停 · Cron 0/30 * * * * ?',
|
||||
metadata_lines=[],
|
||||
detail_sections=[],
|
||||
)
|
||||
|
||||
primary_action = actions_module.TUI_ACTION_REGISTRY.resolve_browser_action(
|
||||
view_key='jobs',
|
||||
slot='primary',
|
||||
record=record,
|
||||
env='dev',
|
||||
)
|
||||
secondary_action = actions_module.TUI_ACTION_REGISTRY.resolve_browser_action(
|
||||
view_key='jobs',
|
||||
slot='secondary',
|
||||
record=record,
|
||||
env='dev',
|
||||
)
|
||||
global_action = actions_module.TUI_ACTION_REGISTRY.resolve_browser_action(
|
||||
view_key='jobs',
|
||||
slot='global',
|
||||
record=record,
|
||||
env='dev',
|
||||
)
|
||||
|
||||
assert primary_action is not None
|
||||
assert primary_action.command_args == ('job', 'run-once', '101')
|
||||
assert secondary_action is not None
|
||||
assert secondary_action.command_args == ('job', 'resume', '101')
|
||||
assert global_action is not None
|
||||
assert global_action.command_args == ('job', 'sync')
|
||||
|
||||
|
||||
def test_action_registry_resolves_browser_action_for_cache_and_gen_entries() -> None:
|
||||
"""
|
||||
校验缓存页和代码生成页会解析出外部向导动作。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
cache_record = adapters_module.BrowserRecordSnapshot(
|
||||
key='cache:sys_config',
|
||||
title='sys_config',
|
||||
status='ok',
|
||||
summary='系统参数缓存',
|
||||
metadata_lines=[],
|
||||
detail_sections=[],
|
||||
)
|
||||
gen_record = adapters_module.BrowserRecordSnapshot(
|
||||
key='gen:201',
|
||||
title='sys_user',
|
||||
status='ok',
|
||||
summary='生成类 SysUser · 模块 system',
|
||||
metadata_lines=[],
|
||||
detail_sections=[],
|
||||
)
|
||||
|
||||
cache_global_action = actions_module.TUI_ACTION_REGISTRY.resolve_browser_action(
|
||||
view_key='cache',
|
||||
slot='global',
|
||||
record=cache_record,
|
||||
env='dev',
|
||||
)
|
||||
cache_utility_action = actions_module.TUI_ACTION_REGISTRY.resolve_browser_action(
|
||||
view_key='cache',
|
||||
slot='utility',
|
||||
record=cache_record,
|
||||
env='dev',
|
||||
)
|
||||
gen_primary_action = actions_module.TUI_ACTION_REGISTRY.resolve_browser_action(
|
||||
view_key='gen',
|
||||
slot='primary',
|
||||
record=gen_record,
|
||||
env='dev',
|
||||
)
|
||||
gen_secondary_action = actions_module.TUI_ACTION_REGISTRY.resolve_browser_action(
|
||||
view_key='gen',
|
||||
slot='secondary',
|
||||
record=gen_record,
|
||||
env='dev',
|
||||
)
|
||||
gen_utility_action = actions_module.TUI_ACTION_REGISTRY.resolve_browser_action(
|
||||
view_key='gen',
|
||||
slot='utility',
|
||||
record=gen_record,
|
||||
env='dev',
|
||||
)
|
||||
gen_global_action = actions_module.TUI_ACTION_REGISTRY.resolve_browser_action(
|
||||
view_key='gen',
|
||||
slot='global',
|
||||
record=gen_record,
|
||||
env='dev',
|
||||
)
|
||||
|
||||
assert cache_global_action is not None
|
||||
assert cache_global_action.execution_mode == 'external'
|
||||
assert cache_global_action.command_args == (
|
||||
'wizard',
|
||||
'cache-clear',
|
||||
'--output=text',
|
||||
'--default-env=dev',
|
||||
'--default-mode=cache-name',
|
||||
'--default-cache-name=sys_config',
|
||||
'--default-dry-run',
|
||||
)
|
||||
assert cache_utility_action is not None
|
||||
assert cache_utility_action.execution_mode == 'nested_json'
|
||||
assert gen_primary_action is not None
|
||||
assert gen_primary_action.execution_mode == 'external'
|
||||
assert gen_primary_action.command_args == (
|
||||
'wizard',
|
||||
'gen-export',
|
||||
'--output=text',
|
||||
'--default-env=dev',
|
||||
'--default-table-names=sys_user',
|
||||
'--default-mode=zip',
|
||||
'--default-dry-run',
|
||||
)
|
||||
assert gen_primary_action.refresh_view is False
|
||||
assert gen_secondary_action is not None
|
||||
assert gen_secondary_action.execution_mode == 'external'
|
||||
assert gen_secondary_action.command_args == (
|
||||
'wizard',
|
||||
'gen-import',
|
||||
'--output=text',
|
||||
'--default-env=dev',
|
||||
'--default-table-names=sys_user',
|
||||
'--default-dry-run',
|
||||
)
|
||||
assert gen_secondary_action.refresh_view is False
|
||||
assert gen_global_action is not None
|
||||
assert gen_global_action.execution_mode == 'nested_json'
|
||||
assert gen_global_action.command_args == ('gen', 'export', 'sys_user', '--dry-run', '--mode=zip')
|
||||
assert gen_utility_action is not None
|
||||
assert gen_utility_action.execution_mode == 'nested_json'
|
||||
assert gen_utility_action.command_args == ('gen', 'sync-db', 'sys_user')
|
||||
|
||||
|
||||
def test_action_template_factories_build_expected_job_and_gen_templates() -> None:
|
||||
"""
|
||||
校验领域动作模板工厂会生成符合预期的命令参数。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
job_record = adapters_module.BrowserRecordSnapshot(
|
||||
key='job:88',
|
||||
title='通知任务',
|
||||
status='ok',
|
||||
summary='运行中 · Cron 0 0/5 * * * ?',
|
||||
metadata_lines=[],
|
||||
detail_sections=[],
|
||||
)
|
||||
gen_record = adapters_module.BrowserRecordSnapshot(
|
||||
key='gen:301',
|
||||
title='sys_notice',
|
||||
status='ok',
|
||||
summary='生成类 SysNotice · 模块 system',
|
||||
metadata_lines=[],
|
||||
detail_sections=[],
|
||||
)
|
||||
|
||||
run_once_template = action_bootstrap_module._JOB_ACTION_TEMPLATE_FACTORY.create_run_once_template()
|
||||
toggle_template = action_bootstrap_module._JOB_ACTION_TEMPLATE_FACTORY.create_toggle_template()
|
||||
export_template = action_bootstrap_module._GEN_ACTION_TEMPLATE_FACTORY.create_export_wizard_template()
|
||||
|
||||
assert run_once_template.command_builder(job_record, 'dev') == ('job', 'run-once', '88')
|
||||
assert toggle_template.command_builder(job_record, 'dev') == ('job', 'pause', '88')
|
||||
assert toggle_template.label_builder is not None
|
||||
assert toggle_template.label_builder(job_record, 'dev') == '暂停任务'
|
||||
assert export_template.command_builder(gen_record, 'dev') == (
|
||||
'wizard',
|
||||
'gen-export',
|
||||
'--output=text',
|
||||
'--default-env=dev',
|
||||
'--default-table-names=sys_notice',
|
||||
'--default-mode=zip',
|
||||
'--default-dry-run',
|
||||
)
|
||||
|
||||
|
||||
def test_action_registry_builder_assembles_expected_slots() -> None:
|
||||
"""
|
||||
校验动作注册表构建器会装配浏览页与详情页动作槽位。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
registry = action_assembly_module.TuiActionRegistryBuilder(
|
||||
jobs=action_bootstrap_module._JOB_ACTION_TEMPLATE_FACTORY,
|
||||
cache=action_bootstrap_module._CACHE_ACTION_TEMPLATE_FACTORY,
|
||||
gen=action_bootstrap_module._GEN_ACTION_TEMPLATE_FACTORY,
|
||||
static=action_bootstrap_module._STATIC_ACTION_TEMPLATE_FACTORY,
|
||||
spec_factory=action_bootstrap_module._ACTION_SPEC_FACTORY,
|
||||
).build()
|
||||
|
||||
assert sorted(registry.browser_resolvers) == ['cache', 'configs', 'gen', 'jobs']
|
||||
assert sorted(registry.detail_resolvers) == ['app', 'crypto', 'database', 'ops']
|
||||
assert sorted(registry.browser_resolvers['gen'].slot_templates) == ['global', 'primary', 'secondary', 'utility']
|
||||
assert sorted(registry.detail_resolvers['ops'].slot_templates) == ['global', 'primary', 'secondary']
|
||||
|
||||
|
||||
def test_action_registry_resolves_detail_action_for_database_and_ops_wizard_entries() -> None:
|
||||
"""
|
||||
校验详情页会解析出数据库升级和生产巡检向导动作。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
database_action = actions_module.TUI_ACTION_REGISTRY.resolve_detail_action(
|
||||
view_key='database',
|
||||
slot='global',
|
||||
env='dev',
|
||||
)
|
||||
database_utility_action = actions_module.TUI_ACTION_REGISTRY.resolve_detail_action(
|
||||
view_key='database',
|
||||
slot='utility',
|
||||
env='dev',
|
||||
)
|
||||
app_action = actions_module.TUI_ACTION_REGISTRY.resolve_detail_action(
|
||||
view_key='app',
|
||||
slot='global',
|
||||
env='dev',
|
||||
)
|
||||
app_primary_action = actions_module.TUI_ACTION_REGISTRY.resolve_detail_action(
|
||||
view_key='app',
|
||||
slot='primary',
|
||||
env='dev',
|
||||
)
|
||||
app_utility_action = actions_module.TUI_ACTION_REGISTRY.resolve_detail_action(
|
||||
view_key='app',
|
||||
slot='utility',
|
||||
env='dev',
|
||||
)
|
||||
ops_primary_action = actions_module.TUI_ACTION_REGISTRY.resolve_detail_action(
|
||||
view_key='ops',
|
||||
slot='primary',
|
||||
env='dev',
|
||||
)
|
||||
ops_secondary_action = actions_module.TUI_ACTION_REGISTRY.resolve_detail_action(
|
||||
view_key='ops',
|
||||
slot='secondary',
|
||||
env='dev',
|
||||
)
|
||||
ops_action = actions_module.TUI_ACTION_REGISTRY.resolve_detail_action(
|
||||
view_key='ops',
|
||||
slot='global',
|
||||
env='dev',
|
||||
)
|
||||
crypto_action = actions_module.TUI_ACTION_REGISTRY.resolve_detail_action(
|
||||
view_key='crypto',
|
||||
slot='global',
|
||||
env='dev',
|
||||
)
|
||||
crypto_primary_action = actions_module.TUI_ACTION_REGISTRY.resolve_detail_action(
|
||||
view_key='crypto',
|
||||
slot='primary',
|
||||
env='dev',
|
||||
)
|
||||
unknown_action = actions_module.TUI_ACTION_REGISTRY.resolve_detail_action(
|
||||
view_key='dashboard',
|
||||
slot='global',
|
||||
env='dev',
|
||||
)
|
||||
|
||||
assert app_primary_action is not None
|
||||
assert app_primary_action.execution_mode == 'external'
|
||||
assert app_primary_action.command_args == ('app', 'run', '--env=dev')
|
||||
assert app_primary_action.refresh_view is False
|
||||
assert app_action is not None
|
||||
assert app_action.execution_mode == 'external'
|
||||
assert app_action.command_args == ('wizard', 'app-run')
|
||||
assert app_action.refresh_view is False
|
||||
assert app_utility_action is not None
|
||||
assert app_utility_action.execution_mode == 'external'
|
||||
assert app_utility_action.command_args == ('completion', 'install', '--activate')
|
||||
assert app_utility_action.refresh_view is False
|
||||
assert database_action is not None
|
||||
assert database_action.execution_mode == 'external'
|
||||
assert database_action.command_args == (
|
||||
'wizard',
|
||||
'db-upgrade',
|
||||
'--output=text',
|
||||
'--default-env=dev',
|
||||
'--default-revision=head',
|
||||
'--default-dry-run',
|
||||
)
|
||||
assert database_utility_action is not None
|
||||
assert database_utility_action.execution_mode == 'nested_json'
|
||||
assert database_utility_action.command_args == ('db', 'init', '--dry-run')
|
||||
assert ops_primary_action is not None
|
||||
assert ops_primary_action.execution_mode == 'nested_json'
|
||||
assert ops_primary_action.command_args == ('ops', 'ping-db')
|
||||
assert ops_secondary_action is not None
|
||||
assert ops_secondary_action.execution_mode == 'nested_json'
|
||||
assert ops_secondary_action.command_args == ('ops', 'ping-redis')
|
||||
assert ops_action is not None
|
||||
assert ops_action.execution_mode == 'external'
|
||||
assert ops_action.command_args == (
|
||||
'wizard',
|
||||
'prod-check',
|
||||
'--output=text',
|
||||
'--default-env=dev',
|
||||
'--default-include-config',
|
||||
)
|
||||
assert crypto_primary_action is not None
|
||||
assert crypto_primary_action.execution_mode == 'external'
|
||||
assert crypto_primary_action.command_args == ('crypto', 'keygen', '--env=dev', '--output=text')
|
||||
assert crypto_primary_action.refresh_view is False
|
||||
assert crypto_action is not None
|
||||
assert crypto_action.execution_mode == 'nested_json'
|
||||
assert crypto_action.command_args == ('crypto', 'rotate', '--dry-run')
|
||||
assert unknown_action is None
|
||||
|
||||
|
||||
def test_action_execution_service_execute_appends_env_output_and_yes(monkeypatch: MonkeyPatch) -> None:
|
||||
"""
|
||||
校验 TUI 动作执行时会复用 CLI JSON 输出并自动附带确认参数。
|
||||
|
||||
:param monkeypatch: pytest monkeypatch 工具
|
||||
:return: None
|
||||
"""
|
||||
calls: list[tuple[str, ...]] = []
|
||||
|
||||
def _fake_run_nested_cli_command(*arguments: str, parse_json: bool = False) -> SimpleNamespace:
|
||||
assert parse_json is True
|
||||
calls.append(arguments)
|
||||
return SimpleNamespace(payload={'ok': True, 'message': '参数缓存刷新成功'})
|
||||
|
||||
monkeypatch.setattr(action_execution_module.NESTED_CLI_SUPPORT, 'run', _fake_run_nested_cli_command)
|
||||
spec = actions_module.TuiActionSpec(
|
||||
action_id='config-sync-cache',
|
||||
label='刷新参数缓存',
|
||||
command_args=('config', 'sync-cache'),
|
||||
preview_title='刷新参数缓存',
|
||||
preview_lines=['line a'],
|
||||
)
|
||||
|
||||
result = actions_module.TUI_ACTION_EXECUTION_SERVICE.execute(spec, 'dev')
|
||||
|
||||
assert result.ok is True
|
||||
assert calls == [('config', 'sync-cache', '--env=dev', '--output=json', '--yes')]
|
||||
|
||||
|
||||
def test_action_execution_service_execute_skips_yes_for_readonly_action(monkeypatch: MonkeyPatch) -> None:
|
||||
"""
|
||||
校验只读动作执行时不会错误追加 `--yes`。
|
||||
|
||||
:param monkeypatch: pytest monkeypatch 工具
|
||||
:return: None
|
||||
"""
|
||||
calls: list[tuple[str, ...]] = []
|
||||
|
||||
def _fake_run_nested_cli_command(*arguments: str, parse_json: bool = False) -> SimpleNamespace:
|
||||
assert parse_json is True
|
||||
calls.append(arguments)
|
||||
return SimpleNamespace(payload={'ok': True, 'message': '数据库连接成功'})
|
||||
|
||||
monkeypatch.setattr(action_execution_module.NESTED_CLI_SUPPORT, 'run', _fake_run_nested_cli_command)
|
||||
spec = actions_module.TuiActionSpec(
|
||||
action_id='ops-ping-db',
|
||||
label='数据库探活',
|
||||
command_args=('ops', 'ping-db'),
|
||||
preview_title='数据库探活',
|
||||
preview_lines=['line a'],
|
||||
append_yes=False,
|
||||
)
|
||||
|
||||
result = actions_module.TUI_ACTION_EXECUTION_SERVICE.execute(spec, 'dev')
|
||||
|
||||
assert result.ok is True
|
||||
assert calls == [('ops', 'ping-db', '--env=dev', '--output=json')]
|
||||
|
||||
|
||||
def test_action_execution_service_execute_external_uses_live_nested_command(monkeypatch: MonkeyPatch) -> None:
|
||||
"""
|
||||
校验外部交互动作会通过 live nested CLI helper 执行。
|
||||
|
||||
:param monkeypatch: pytest monkeypatch 工具
|
||||
:return: None
|
||||
"""
|
||||
calls: list[tuple[str, ...]] = []
|
||||
|
||||
def _fake_run_nested_cli_command_live(*arguments: str) -> SimpleNamespace:
|
||||
calls.append(arguments)
|
||||
return SimpleNamespace(returncode=0)
|
||||
|
||||
monkeypatch.setattr(action_execution_module.NESTED_CLI_SUPPORT, 'run_live', _fake_run_nested_cli_command_live)
|
||||
spec = actions_module.TuiActionSpec(
|
||||
action_id='wizard-cache-clear',
|
||||
label='打开缓存清理向导',
|
||||
command_args=('wizard', 'cache-clear', '--output=text'),
|
||||
preview_title='打开缓存清理向导',
|
||||
preview_lines=['line a'],
|
||||
execution_mode='external',
|
||||
)
|
||||
|
||||
result = actions_module.TUI_ACTION_EXECUTION_SERVICE.execute_external(spec)
|
||||
|
||||
assert result.ok is True
|
||||
assert result.message == '外部交互命令已执行完成'
|
||||
assert calls == [('wizard', 'cache-clear', '--output=text')]
|
||||
|
||||
|
||||
def test_action_presentation_service_build_browser_action_hint_matches_supported_views() -> None:
|
||||
"""
|
||||
校验浏览页动作提示会按页面输出不同文案。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
assert '[X]' in actions_module.TUI_ACTION_PRESENTATION_SERVICE.build_browser_action_hint('jobs')
|
||||
assert '[Y]' in actions_module.TUI_ACTION_PRESENTATION_SERVICE.build_browser_action_hint('configs')
|
||||
assert '[W]' in actions_module.TUI_ACTION_PRESENTATION_SERVICE.build_browser_action_hint('cache')
|
||||
assert '[Y] 清理向导' in actions_module.TUI_ACTION_PRESENTATION_SERVICE.build_browser_action_hint('cache')
|
||||
assert '[X] 导出向导' in actions_module.TUI_ACTION_PRESENTATION_SERVICE.build_browser_action_hint('gen')
|
||||
assert '[Z] 导入向导' in actions_module.TUI_ACTION_PRESENTATION_SERVICE.build_browser_action_hint('gen')
|
||||
assert '[Y] 导出预演' in actions_module.TUI_ACTION_PRESENTATION_SERVICE.build_browser_action_hint('gen')
|
||||
assert '[W] 同步表结构' in actions_module.TUI_ACTION_PRESENTATION_SERVICE.build_browser_action_hint('gen')
|
||||
assert '失败聚合' in actions_module.TUI_ACTION_PRESENTATION_SERVICE.build_browser_action_hint('jobs')
|
||||
assert '浏览键' in actions_module.TUI_ACTION_PRESENTATION_SERVICE.build_browser_action_hint('jobs')
|
||||
|
||||
|
||||
def test_action_presentation_service_build_detail_action_hint_matches_supported_views() -> None:
|
||||
"""
|
||||
校验详情页动作提示会按页面输出不同文案。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
assert '[X] 直接启动' in actions_module.TUI_ACTION_PRESENTATION_SERVICE.build_detail_action_hint('app')
|
||||
assert '[Y] 打开启动向导' in actions_module.TUI_ACTION_PRESENTATION_SERVICE.build_detail_action_hint('app')
|
||||
assert '[W] 安装补全' in actions_module.TUI_ACTION_PRESENTATION_SERVICE.build_detail_action_hint('app')
|
||||
assert '[Y] 打开升级向导' in actions_module.TUI_ACTION_PRESENTATION_SERVICE.build_detail_action_hint('database')
|
||||
assert '[X] 数据库探活' in actions_module.TUI_ACTION_PRESENTATION_SERVICE.build_detail_action_hint('ops')
|
||||
assert '[Z] Redis 探活' in actions_module.TUI_ACTION_PRESENTATION_SERVICE.build_detail_action_hint('ops')
|
||||
assert '[Y] 打开生产巡检向导' in actions_module.TUI_ACTION_PRESENTATION_SERVICE.build_detail_action_hint('ops')
|
||||
assert '[X] 密钥生成' in actions_module.TUI_ACTION_PRESENTATION_SERVICE.build_detail_action_hint('crypto')
|
||||
assert '[Y] 执行轮换预演' in actions_module.TUI_ACTION_PRESENTATION_SERVICE.build_detail_action_hint('crypto')
|
||||
assert (
|
||||
'环境映射、应用配置、启动前检查、补全诊断和路由状态,再决定是安装补全、直接启动应用还是进入启动向导'
|
||||
in actions_module.TUI_ACTION_PRESENTATION_SERVICE.build_detail_action_hint('app')
|
||||
)
|
||||
|
||||
|
||||
def test_action_execution_service_build_result_lines_surfaces_common_payload_fields() -> None:
|
||||
"""
|
||||
校验动作结果详情会提取常见结果字段。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
spec = actions_module.TuiActionSpec(
|
||||
action_id='job-run-once',
|
||||
label='执行一次任务',
|
||||
command_args=('job', 'run-once', '1'),
|
||||
preview_title='执行一次任务',
|
||||
preview_lines=['line a'],
|
||||
)
|
||||
result = actions_module.TuiActionResult(
|
||||
spec=spec,
|
||||
payload={
|
||||
'ok': True,
|
||||
'message': '执行完成',
|
||||
'serviceMessage': '调度器已触发',
|
||||
'hint': '可继续刷新页面确认状态',
|
||||
'jobId': 1,
|
||||
},
|
||||
)
|
||||
|
||||
lines = actions_module.TUI_ACTION_EXECUTION_SERVICE.build_result_lines(result)
|
||||
|
||||
assert any('结果: 成功' in line for line in lines)
|
||||
assert any('服务反馈: 调度器已触发' in line for line in lines)
|
||||
assert any('摘要: 执行完成' in line for line in lines)
|
||||
assert any('建议: 可继续刷新页面确认状态' in line for line in lines)
|
||||
assert any('任务 ID: 1' in line for line in lines)
|
||||
|
||||
|
||||
def test_action_execution_service_build_result_lines_surfaces_external_exit_code() -> None:
|
||||
"""
|
||||
校验外部交互动作结果会展示退出码。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
spec = actions_module.TuiActionSpec(
|
||||
action_id='wizard-gen-export',
|
||||
label='打开导出向导',
|
||||
command_args=('wizard', 'gen-export', '--output=text'),
|
||||
preview_title='打开导出向导',
|
||||
preview_lines=['line a'],
|
||||
execution_mode='external',
|
||||
)
|
||||
result = actions_module.TuiActionResult(
|
||||
spec=spec,
|
||||
external_exit_code=1,
|
||||
external_message='外部交互命令执行失败,退出码 1',
|
||||
)
|
||||
|
||||
lines = actions_module.TUI_ACTION_EXECUTION_SERVICE.build_result_lines(result)
|
||||
|
||||
assert any('结果: 失败' in line for line in lines)
|
||||
assert any('退出码: 1' in line for line in lines)
|
||||
@@ -0,0 +1,87 @@
|
||||
import asyncio
|
||||
import importlib
|
||||
import sys
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
|
||||
import pytest
|
||||
|
||||
BACKEND_DIR = Path(__file__).resolve().parents[4]
|
||||
|
||||
sys.path.insert(0, str(BACKEND_DIR))
|
||||
|
||||
|
||||
def _load_adapter_module(module_name: str) -> ModuleType:
|
||||
sys.modules.pop(module_name, None)
|
||||
sys.modules.pop('cli', None)
|
||||
return importlib.import_module(module_name)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app_adapter() -> ModuleType:
|
||||
return _load_adapter_module('cli.tui.adapters.app')
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cache_adapter() -> ModuleType:
|
||||
return _load_adapter_module('cli.tui.adapters.cache')
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def crypto_adapter() -> ModuleType:
|
||||
return _load_adapter_module('cli.tui.adapters.crypto')
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def database_adapter() -> ModuleType:
|
||||
return _load_adapter_module('cli.tui.adapters.database')
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def jobs_adapter() -> ModuleType:
|
||||
return _load_adapter_module('cli.tui.adapters.jobs')
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def gen_adapter() -> ModuleType:
|
||||
return _load_adapter_module('cli.tui.adapters.gen')
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def configs_adapter() -> ModuleType:
|
||||
return _load_adapter_module('cli.tui.adapters.configs')
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ops_adapter() -> ModuleType:
|
||||
return _load_adapter_module('cli.tui.adapters.ops')
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def health_adapter() -> ModuleType:
|
||||
return _load_adapter_module('cli.tui.adapters.health')
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def load_adapter_module() -> Callable[[str], ModuleType]:
|
||||
return _load_adapter_module
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def dispose_async_db_engine_after_test() -> None:
|
||||
"""
|
||||
在每个 TUI adapter 测试结束后尝试释放全局异步数据库连接池。
|
||||
|
||||
这些适配器测试会按需导入运行时模块;若其中某些路径触发真实数据库访问,
|
||||
模块级 `async_engine` 可能在测试进程结束前仍持有连接,从而在 GC 阶段产生
|
||||
SQLAlchemy 未归还连接告警。这里统一在测试后主动 `dispose()`,将清理职责收口
|
||||
到测试夹具而非业务代码。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
yield
|
||||
database_module = sys.modules.get('config.database')
|
||||
async_engine = getattr(database_module, 'async_engine', None) if database_module is not None else None
|
||||
if async_engine is not None:
|
||||
asyncio.run(async_engine.dispose())
|
||||
@@ -0,0 +1,345 @@
|
||||
from types import ModuleType, SimpleNamespace
|
||||
|
||||
from pytest import MonkeyPatch
|
||||
|
||||
|
||||
def test_collect_app_page_snapshot_builds_env_config_and_route_sections(
|
||||
monkeypatch: MonkeyPatch,
|
||||
app_adapter: ModuleType,
|
||||
) -> None:
|
||||
def fake_run_nested_cli_command(*arguments: str, parse_json: bool = False) -> SimpleNamespace:
|
||||
del parse_json
|
||||
if arguments[0:2] == ('app', 'env'):
|
||||
return SimpleNamespace(
|
||||
payload={
|
||||
'ok': True,
|
||||
'runtime': {
|
||||
'cliEnv': 'dev',
|
||||
'configEnv': 'dev',
|
||||
'appEnv': 'dev',
|
||||
'envFile': '.env.dev',
|
||||
'envFileExists': True,
|
||||
'backendDir': '/srv/backend',
|
||||
'pythonExecutable': '/opt/conda/envs/shierfeng-fastapi/bin/python',
|
||||
},
|
||||
}
|
||||
)
|
||||
if arguments[0:2] == ('app', 'config'):
|
||||
return SimpleNamespace(
|
||||
payload={
|
||||
'ok': True,
|
||||
'config': {
|
||||
'name': 'ShiErFeng',
|
||||
'host': '127.0.0.1',
|
||||
'port': 8000,
|
||||
'rootPath': '/',
|
||||
'workers': 2,
|
||||
'reload': True,
|
||||
'disableSwagger': False,
|
||||
'disableRedoc': True,
|
||||
'logLevel': 'INFO',
|
||||
'dbType': 'mysql',
|
||||
'dbHost': '127.0.0.1',
|
||||
'dbPort': 3306,
|
||||
'dbDatabase': 'shierfeng',
|
||||
'redisHost': '127.0.0.1',
|
||||
'redisPort': 6379,
|
||||
'transportCryptoEnabled': True,
|
||||
'transportCryptoMode': 'strict',
|
||||
},
|
||||
}
|
||||
)
|
||||
if arguments[0:2] == ('app', 'routes'):
|
||||
return SimpleNamespace(
|
||||
payload={
|
||||
'ok': True,
|
||||
'count': 3,
|
||||
'routes': [],
|
||||
'groupedRoutes': {'system': [{}, {}], 'monitor': [{}]},
|
||||
}
|
||||
)
|
||||
if arguments[0:2] == ('app', 'doctor'):
|
||||
return SimpleNamespace(
|
||||
payload={
|
||||
'ok': True,
|
||||
'env': 'dev',
|
||||
'database': {'ok': True, 'message': '数据库连接成功'},
|
||||
'redis': {'ok': True, 'message': 'Redis连接成功'},
|
||||
'crypto': {'ok': True, 'message': '传输加密配置有效'},
|
||||
}
|
||||
)
|
||||
if arguments[0:2] == ('completion', 'doctor'):
|
||||
return SimpleNamespace(
|
||||
payload={
|
||||
'ok': True,
|
||||
'message': 'completion 诊断信息已生成',
|
||||
'activeShell': 'bash',
|
||||
'projectDir': '/srv/backend',
|
||||
'envChoices': ['dev', 'prod'],
|
||||
'completeEnvVar': '_SHIERFENG_COMPLETE',
|
||||
'recommendedInstallCommand': 'shierfeng completion install --shell=bash --activate',
|
||||
'shells': {
|
||||
'bash': {
|
||||
'supported': True,
|
||||
'detected': True,
|
||||
'targetFile': '/tmp/shierfeng.bash',
|
||||
'targetFileExists': True,
|
||||
'rcFile': '/Users/demo/.bashrc',
|
||||
'rcFileExists': True,
|
||||
'autoDiscovery': False,
|
||||
'sourceCommand': 'source /tmp/shierfeng.bash',
|
||||
'recommendedInstallCommand': 'shierfeng completion install --shell=bash --activate',
|
||||
},
|
||||
'zsh': {'supported': True, 'detected': False, 'autoDiscovery': False},
|
||||
'fish': {'supported': True, 'detected': False, 'autoDiscovery': True},
|
||||
'powershell': {
|
||||
'supported': True,
|
||||
'detected': False,
|
||||
'autoDiscovery': False,
|
||||
'sourceCommand': '. "/tmp/shierfeng.ps1"',
|
||||
'recommendedInstallCommand': 'shierfeng completion install --shell=powershell --activate',
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
if arguments[0:2] == ('completion', 'show'):
|
||||
return SimpleNamespace(
|
||||
returncode=0,
|
||||
stdout='_SHIERFENG_COMPLETE=bash_complete\n_shierfeng_completion() {\n COMPREPLY=()\n}\n',
|
||||
stderr='',
|
||||
)
|
||||
return SimpleNamespace(
|
||||
payload={
|
||||
'ok': True,
|
||||
'count': 3,
|
||||
'routes': [
|
||||
{
|
||||
'path': '/system/user/list',
|
||||
'methods': ['GET'],
|
||||
'summary': '查询用户列表',
|
||||
}
|
||||
],
|
||||
'groupedRoutes': {
|
||||
'system': [{}, {}],
|
||||
'monitor': [{}],
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
monkeypatch.setattr(app_adapter.NESTED_CLI_SUPPORT, 'run', fake_run_nested_cli_command)
|
||||
|
||||
snapshot = app_adapter.APP_DETAIL_ADAPTER.collect_snapshot('dev')
|
||||
|
||||
assert snapshot.title == '应用'
|
||||
assert (
|
||||
snapshot.subtitle
|
||||
== '聚焦:环境映射 / 配置摘要 / 启动前检查 | 当前已加载 3 条注册路由,可继续查看环境映射、配置摘要、启动前检查、补全诊断与路由状态'
|
||||
)
|
||||
assert [section.title for section in snapshot.sections] == [
|
||||
'总览判断',
|
||||
'环境解析',
|
||||
'应用配置',
|
||||
'依赖配置',
|
||||
'启动前检查',
|
||||
'补全诊断',
|
||||
'补全脚本预览',
|
||||
'补全安装入口',
|
||||
'路由摘要',
|
||||
'启动入口',
|
||||
]
|
||||
assert snapshot.search is not None
|
||||
assert snapshot.search.placeholder == '按分区或内容搜索'
|
||||
assert any('CLI 环境: dev' in line for line in snapshot.sections[0].lines)
|
||||
assert any('CLI 目标环境: dev' in line for line in snapshot.sections[1].lines)
|
||||
assert any('监听地址: 127.0.0.1:8000' in line for line in snapshot.sections[2].lines)
|
||||
assert any('传输加密: 开启' in line for line in snapshot.sections[3].lines)
|
||||
assert any('数据库: 正常' in line for line in snapshot.sections[4].lines)
|
||||
assert any('活动 Shell: bash' in line for line in snapshot.sections[5].lines)
|
||||
assert any('source /tmp/shierfeng.bash' in line for line in snapshot.sections[5].lines)
|
||||
assert any('Shell: bash' in line for line in snapshot.sections[6].lines)
|
||||
assert any('_SHIERFENG_COMPLETE=bash_complete' in line for line in snapshot.sections[6].lines)
|
||||
assert any('completion install --activate --output=text' in line for line in snapshot.sections[7].lines)
|
||||
assert any('system · 2 条' in line for line in snapshot.sections[8].lines)
|
||||
assert any('app run --env=dev' in line for line in snapshot.sections[9].lines)
|
||||
|
||||
|
||||
def test_collect_app_page_snapshot_applies_query_filter(
|
||||
monkeypatch: MonkeyPatch,
|
||||
app_adapter: ModuleType,
|
||||
) -> None:
|
||||
def fake_run_nested_cli_command(*arguments: str, parse_json: bool = False) -> SimpleNamespace:
|
||||
del parse_json
|
||||
if arguments[0:2] == ('app', 'env'):
|
||||
return SimpleNamespace(payload={'ok': True, 'runtime': {'cliEnv': 'dev', 'configEnv': 'dev'}})
|
||||
if arguments[0:2] == ('app', 'config'):
|
||||
return SimpleNamespace(
|
||||
payload={
|
||||
'ok': True,
|
||||
'config': {
|
||||
'name': 'ShiErFeng',
|
||||
'host': '127.0.0.1',
|
||||
'port': 8000,
|
||||
'rootPath': '/',
|
||||
'workers': 2,
|
||||
'reload': True,
|
||||
'disableSwagger': False,
|
||||
'disableRedoc': True,
|
||||
'logLevel': 'INFO',
|
||||
'dbType': 'mysql',
|
||||
'dbHost': '127.0.0.1',
|
||||
'dbPort': 3306,
|
||||
'dbDatabase': 'shierfeng',
|
||||
'redisHost': '127.0.0.1',
|
||||
'redisPort': 6379,
|
||||
'transportCryptoEnabled': True,
|
||||
'transportCryptoMode': 'strict',
|
||||
},
|
||||
}
|
||||
)
|
||||
if arguments[0:2] == ('completion', 'doctor'):
|
||||
return SimpleNamespace(
|
||||
payload={
|
||||
'ok': True,
|
||||
'message': 'completion 诊断信息已生成',
|
||||
'activeShell': 'bash',
|
||||
'projectDir': '/srv/backend',
|
||||
'envChoices': ['dev'],
|
||||
'completeEnvVar': '_SHIERFENG_COMPLETE',
|
||||
'recommendedInstallCommand': 'shierfeng completion install --shell=bash --activate',
|
||||
'shells': {'bash': {'supported': True, 'detected': True, 'autoDiscovery': False}},
|
||||
}
|
||||
)
|
||||
return SimpleNamespace(payload={'ok': True, 'count': 1, 'routes': [], 'groupedRoutes': {}})
|
||||
|
||||
monkeypatch.setattr(app_adapter.NESTED_CLI_SUPPORT, 'run', fake_run_nested_cli_command)
|
||||
|
||||
snapshot = app_adapter.APP_DETAIL_ADAPTER.collect_snapshot('dev', query='路由')
|
||||
|
||||
assert [section.title for section in snapshot.sections] == ['总览判断', '路由摘要']
|
||||
assert snapshot.search is not None
|
||||
assert snapshot.search.query == '路由'
|
||||
|
||||
|
||||
def test_collect_ops_page_snapshot_builds_health_dependency_and_server_sections(
|
||||
monkeypatch: MonkeyPatch,
|
||||
ops_adapter: ModuleType,
|
||||
) -> None:
|
||||
def fake_run_nested_cli_command(*arguments: str, parse_json: bool = False) -> SimpleNamespace:
|
||||
del parse_json
|
||||
if arguments[0:2] == ('ops', 'health'):
|
||||
return SimpleNamespace(
|
||||
payload={
|
||||
'ok': True,
|
||||
'env': 'dev',
|
||||
'database': {'ok': True, 'message': '数据库连接成功'},
|
||||
'redis': {'ok': True, 'message': 'Redis连接成功'},
|
||||
}
|
||||
)
|
||||
if arguments[0:2] == ('ops', 'ping-db'):
|
||||
return SimpleNamespace(payload={'ok': True, 'message': '数据库连接成功'})
|
||||
if arguments[0:2] == ('ops', 'ping-redis'):
|
||||
return SimpleNamespace(payload={'ok': True, 'message': 'Redis连接成功'})
|
||||
if arguments[0:2] == ('ops', 'deps'):
|
||||
return SimpleNamespace(
|
||||
payload={
|
||||
'ok': True,
|
||||
'message': '核心运行依赖已安装',
|
||||
'missingRequired': [],
|
||||
'packages': {
|
||||
'python': {'installed': True, 'version': '3.10.0'},
|
||||
'fastapi': {'installed': True, 'version': '0.111.0'},
|
||||
'sqlalchemy': {'installed': True, 'version': '2.0.0'},
|
||||
'redis': {'installed': True, 'version': '5.0.0'},
|
||||
'typer': {'installed': True, 'version': '0.12.0'},
|
||||
'alembic': {'installed': True, 'version': '1.13.0'},
|
||||
},
|
||||
}
|
||||
)
|
||||
return SimpleNamespace(
|
||||
payload={
|
||||
'ok': True,
|
||||
'server': {
|
||||
'sys': {
|
||||
'computerName': 'shierfeng-node',
|
||||
'computerIp': '10.0.0.8',
|
||||
'osName': 'Linux',
|
||||
'osArch': 'x86_64',
|
||||
},
|
||||
'cpu': {'cpuNum': 8, 'used': 21.5},
|
||||
'mem': {'total': '16 GB', 'usage': 48.2},
|
||||
'py': {'version': '3.10.0', 'runTime': '2天4小时', 'used': '256 MB', 'total': '1 GB'},
|
||||
'sysFiles': [
|
||||
{
|
||||
'dirName': '/',
|
||||
'used': '120 GB',
|
||||
'total': '256 GB',
|
||||
'usage': '46%',
|
||||
'free': '136 GB',
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
monkeypatch.setattr(ops_adapter.NESTED_CLI_SUPPORT, 'run', fake_run_nested_cli_command)
|
||||
|
||||
snapshot = ops_adapter.OPS_DETAIL_ADAPTER.collect_snapshot('dev')
|
||||
|
||||
assert snapshot.title == '运维'
|
||||
assert (
|
||||
snapshot.subtitle
|
||||
== '聚焦:数据库连通性 / Redis 连通性 / 依赖版本 | 基础探活和依赖状态正常,可继续查看服务器资源与磁盘样本'
|
||||
)
|
||||
assert [section.title for section in snapshot.sections] == [
|
||||
'总览判断',
|
||||
'健康检查',
|
||||
'数据库探活',
|
||||
'Redis 探活',
|
||||
'依赖版本',
|
||||
'服务器摘要',
|
||||
'磁盘样本',
|
||||
'生产巡检入口',
|
||||
]
|
||||
assert any('探活状态: 正常' in line for line in snapshot.sections[0].lines)
|
||||
assert any('数据库: 正常' in line for line in snapshot.sections[1].lines)
|
||||
assert any('数据库连接: 正常' in line for line in snapshot.sections[2].lines)
|
||||
assert any('Redis 连接: 正常' in line for line in snapshot.sections[3].lines)
|
||||
assert any('fastapi · 已安装' in line for line in snapshot.sections[4].lines)
|
||||
assert any('主机名: shierfeng-node' in line for line in snapshot.sections[5].lines)
|
||||
assert any('使用率 46%' in line for line in snapshot.sections[6].lines)
|
||||
assert any('wizard prod-check' in line for line in snapshot.sections[7].lines)
|
||||
assert snapshot.search is not None
|
||||
assert snapshot.search.placeholder == '按分区或内容搜索'
|
||||
|
||||
|
||||
def test_collect_ops_page_snapshot_surfaces_health_risk_subtitle(
|
||||
monkeypatch: MonkeyPatch,
|
||||
ops_adapter: ModuleType,
|
||||
) -> None:
|
||||
def fake_run_nested_cli_command(*arguments: str, parse_json: bool = False) -> SimpleNamespace:
|
||||
del parse_json
|
||||
if arguments[0:2] == ('ops', 'health'):
|
||||
return SimpleNamespace(
|
||||
payload={
|
||||
'ok': False,
|
||||
'env': 'dev',
|
||||
'database': {'ok': False, 'message': '数据库连接失败'},
|
||||
'redis': {'ok': True, 'message': 'Redis连接成功'},
|
||||
}
|
||||
)
|
||||
if arguments[0:2] == ('ops', 'ping-db'):
|
||||
return SimpleNamespace(payload={'ok': False, 'message': '数据库连接失败'})
|
||||
if arguments[0:2] == ('ops', 'ping-redis'):
|
||||
return SimpleNamespace(payload={'ok': True, 'message': 'Redis连接成功'})
|
||||
if arguments[0:2] == ('ops', 'deps'):
|
||||
return SimpleNamespace(payload={'ok': True, 'missingRequired': [], 'packages': {}, 'message': '依赖正常'})
|
||||
return SimpleNamespace(payload={'ok': True, 'server': {'sys': {}, 'cpu': {}, 'mem': {}, 'py': {}}})
|
||||
|
||||
monkeypatch.setattr(ops_adapter.NESTED_CLI_SUPPORT, 'run', fake_run_nested_cli_command)
|
||||
|
||||
snapshot = ops_adapter.OPS_DETAIL_ADAPTER.collect_snapshot('dev')
|
||||
|
||||
assert (
|
||||
snapshot.subtitle
|
||||
== '聚焦:数据库连通性 / Redis 连通性 / 依赖版本 | 运维探活存在异常,优先核对数据库/Redis 连通性与依赖版本'
|
||||
)
|
||||
@@ -0,0 +1,346 @@
|
||||
from types import ModuleType, SimpleNamespace
|
||||
|
||||
from pytest import MonkeyPatch
|
||||
|
||||
|
||||
def test_collect_cache_page_snapshot_builds_cache_browser_records(
|
||||
monkeypatch: MonkeyPatch,
|
||||
cache_adapter: ModuleType,
|
||||
) -> None:
|
||||
calls: list[tuple[str, ...]] = []
|
||||
|
||||
def fake_run_nested_cli_command(*arguments: str, parse_json: bool = False) -> SimpleNamespace:
|
||||
del parse_json
|
||||
calls.append(arguments)
|
||||
if arguments[0:2] == ('cache', 'stats'):
|
||||
return SimpleNamespace(
|
||||
payload={
|
||||
'ok': True,
|
||||
'dbSize': 128,
|
||||
'info': {
|
||||
'redis_version': '7.0',
|
||||
'connected_clients': 12,
|
||||
'used_memory_human': '16M',
|
||||
},
|
||||
'commandStats': [{'name': 'get', 'value': 88}],
|
||||
'cacheNames': [{'cacheName': 'sys_config', 'remark': '系统参数缓存'}],
|
||||
}
|
||||
)
|
||||
if arguments[0:2] == ('cache', 'keys'):
|
||||
return SimpleNamespace(
|
||||
payload={
|
||||
'ok': True,
|
||||
'cacheName': 'sys_config',
|
||||
'count': 2,
|
||||
'keys': ['site_name', 'site_logo'],
|
||||
}
|
||||
)
|
||||
if arguments[0:2] == ('cache', 'get'):
|
||||
return SimpleNamespace(
|
||||
payload={
|
||||
'ok': True,
|
||||
'cacheName': 'sys_config',
|
||||
'cacheKey': arguments[3],
|
||||
'cacheValue': 'hello-world',
|
||||
}
|
||||
)
|
||||
return SimpleNamespace(
|
||||
payload={
|
||||
'ok': True,
|
||||
'cacheName': 'sys_config',
|
||||
'cacheKey': arguments[3],
|
||||
'ttlSeconds': 3600,
|
||||
'persistent': False,
|
||||
'expires': True,
|
||||
}
|
||||
)
|
||||
|
||||
monkeypatch.setattr(cache_adapter.NESTED_CLI_SUPPORT, 'run', fake_run_nested_cli_command)
|
||||
|
||||
snapshot = cache_adapter.CACHE_BROWSER_ADAPTER.collect_snapshot('dev')
|
||||
|
||||
assert snapshot.title == '缓存'
|
||||
assert (
|
||||
snapshot.subtitle
|
||||
== '聚焦:Redis 键数 / 连接数 / 缓存名前缀 | 缓存基线正常,当前已加载 1 个缓存名,Redis 键数 128,连接数 12,可继续查看键列表、键值样本和 TTL'
|
||||
)
|
||||
assert len(snapshot.records) == 1
|
||||
assert snapshot.records[0].title == 'sys_config'
|
||||
detail_sections = snapshot.records[0].resolve_detail_sections()
|
||||
assert [section.title for section in detail_sections] == [
|
||||
'键摘要',
|
||||
'键列表',
|
||||
'键详情 · site_name',
|
||||
'键详情 · site_logo',
|
||||
]
|
||||
assert any('site_name' in line for line in detail_sections[1].lines)
|
||||
assert any('TTL: 3600 秒' in line for line in detail_sections[2].lines)
|
||||
assert any('hello-world' in line for line in detail_sections[2].lines)
|
||||
assert any('TTL: 3600 秒' in line for line in detail_sections[3].lines)
|
||||
assert snapshot.shared_sections[0].title == '总览判断'
|
||||
assert any('已登记缓存名: 1 个' in line for line in snapshot.shared_sections[0].lines)
|
||||
assert any('当前匹配: 1 个' in line for line in snapshot.shared_sections[0].lines)
|
||||
assert snapshot.shared_sections[1].title == 'Redis 摘要'
|
||||
assert snapshot.shared_sections[2].title == '命令统计'
|
||||
assert snapshot.shared_sections[3].title == '缓存清理入口'
|
||||
assert any('wizard cache-clear' in line for line in snapshot.shared_sections[3].lines)
|
||||
assert any(call[0:2] == ('cache', 'get') for call in calls)
|
||||
assert any(call[0:2] == ('cache', 'ttl') for call in calls)
|
||||
|
||||
|
||||
def test_cache_browser_adapter_collect_snapshot_exposes_search_context(
|
||||
monkeypatch: MonkeyPatch,
|
||||
cache_adapter: ModuleType,
|
||||
) -> None:
|
||||
def fake_run_nested_cli_command(*arguments: str, parse_json: bool = False) -> SimpleNamespace:
|
||||
del parse_json
|
||||
if arguments[0:2] == ('cache', 'stats'):
|
||||
return SimpleNamespace(
|
||||
payload={
|
||||
'ok': True,
|
||||
'dbSize': 8,
|
||||
'info': {
|
||||
'redis_version': '7.0',
|
||||
'connected_clients': 2,
|
||||
'used_memory_human': '1M',
|
||||
},
|
||||
'commandStats': [],
|
||||
'cacheNames': [{'cacheName': 'sys_config', 'remark': '系统参数缓存'}],
|
||||
}
|
||||
)
|
||||
return SimpleNamespace(payload={'ok': False, 'message': 'unexpected'})
|
||||
|
||||
monkeypatch.setattr(cache_adapter.NESTED_CLI_SUPPORT, 'run', fake_run_nested_cli_command)
|
||||
|
||||
snapshot = cache_adapter.CACHE_BROWSER_ADAPTER.collect_snapshot('dev', query='sys')
|
||||
|
||||
assert snapshot.title == '缓存'
|
||||
assert snapshot.search is not None
|
||||
assert snapshot.search.query == 'sys'
|
||||
assert snapshot.records
|
||||
assert snapshot.records[0].title == 'sys_config'
|
||||
|
||||
|
||||
def test_collect_database_page_snapshot_builds_check_heads_and_history_sections(
|
||||
monkeypatch: MonkeyPatch,
|
||||
database_adapter: ModuleType,
|
||||
) -> None:
|
||||
def fake_run_nested_cli_command(*arguments: str, parse_json: bool = False) -> SimpleNamespace:
|
||||
del parse_json
|
||||
if arguments[0:2] == ('db', 'current'):
|
||||
return SimpleNamespace(payload={'ok': True, 'currentRevision': '202604300001'})
|
||||
if arguments[0:2] == ('db', 'check'):
|
||||
return SimpleNamespace(payload={'ok': True, 'message': '数据库连接成功'})
|
||||
if arguments[0:2] == ('db', 'heads'):
|
||||
return SimpleNamespace(
|
||||
payload={
|
||||
'ok': True,
|
||||
'message': '已读取 Alembic heads',
|
||||
'count': 1,
|
||||
'items': [
|
||||
{
|
||||
'revision': '202604300001',
|
||||
'downRevisions': ['202604290001'],
|
||||
'branchLabels': ['main'],
|
||||
'dependsOn': [],
|
||||
'doc': 'create user table',
|
||||
'path': '/srv/backend/alembic/versions/202604300001_create_user.py',
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
if arguments[0:2] == ('db', 'history'):
|
||||
return SimpleNamespace(
|
||||
payload={
|
||||
'ok': True,
|
||||
'message': '已读取 Alembic 历史版本',
|
||||
'count': 2,
|
||||
'totalCount': 2,
|
||||
'limit': 8,
|
||||
'items': [
|
||||
{
|
||||
'revision': '202604300001',
|
||||
'downRevisions': ['202604290001'],
|
||||
'branchLabels': ['main'],
|
||||
'dependsOn': [],
|
||||
'doc': 'create user table',
|
||||
'path': '/srv/backend/alembic/versions/202604300001_create_user.py',
|
||||
},
|
||||
{
|
||||
'revision': '202604290001',
|
||||
'downRevisions': [],
|
||||
'branchLabels': [],
|
||||
'dependsOn': [],
|
||||
'doc': 'init schema',
|
||||
'path': '/srv/backend/alembic/versions/202604290001_init_schema.py',
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
return SimpleNamespace(
|
||||
payload={
|
||||
'ok': True,
|
||||
'config': {
|
||||
'dbType': 'mysql',
|
||||
'dbHost': '127.0.0.1',
|
||||
'dbPort': 3306,
|
||||
'dbDatabase': 'shierfeng',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
monkeypatch.setattr(database_adapter.NESTED_CLI_SUPPORT, 'run', fake_run_nested_cli_command)
|
||||
|
||||
snapshot = database_adapter.DATABASE_DETAIL_ADAPTER.collect_snapshot('dev')
|
||||
|
||||
assert snapshot.title == '数据库'
|
||||
assert (
|
||||
snapshot.subtitle
|
||||
== '聚焦:迁移版本 / 连接状态 / Heads | 数据库基线正常,当前 revision 202604300001,可继续查看连接、heads 和历史版本'
|
||||
)
|
||||
assert [section.title for section in snapshot.sections] == [
|
||||
'总览判断',
|
||||
'迁移版本',
|
||||
'连接信息',
|
||||
'连通性检查',
|
||||
'Heads 状态',
|
||||
'历史版本',
|
||||
'初始化预演入口',
|
||||
'升级入口',
|
||||
]
|
||||
assert any('当前 revision: 202604300001' in line for line in snapshot.sections[0].lines)
|
||||
assert any('迁移版本: 202604300001' in line for line in snapshot.sections[1].lines)
|
||||
assert any('连接地址: 127.0.0.1:3306' in line for line in snapshot.sections[2].lines)
|
||||
assert any('数据库连接: 正常' in line for line in snapshot.sections[3].lines)
|
||||
assert any('Heads 数量: 1' in line for line in snapshot.sections[4].lines)
|
||||
assert any('Head 01 · 202604300001' in line for line in snapshot.sections[4].lines)
|
||||
assert any('总版本数: 2' in line for line in snapshot.sections[5].lines)
|
||||
assert any('版本 02 · 202604290001' in line for line in snapshot.sections[5].lines)
|
||||
assert any('db init --dry-run --output=text' in line for line in snapshot.sections[6].lines)
|
||||
assert any('wizard db-upgrade' in line for line in snapshot.sections[7].lines)
|
||||
assert snapshot.search is not None
|
||||
assert snapshot.search.placeholder == '按 revision 搜索'
|
||||
|
||||
|
||||
def test_collect_database_page_snapshot_surfaces_failed_heads(
|
||||
monkeypatch: MonkeyPatch,
|
||||
database_adapter: ModuleType,
|
||||
) -> None:
|
||||
def fake_run_nested_cli_command(*arguments: str, parse_json: bool = False) -> SimpleNamespace:
|
||||
del parse_json
|
||||
if arguments[0:2] == ('db', 'current'):
|
||||
return SimpleNamespace(payload={'ok': True, 'currentRevision': '202604300001'})
|
||||
if arguments[0:2] == ('db', 'check'):
|
||||
return SimpleNamespace(payload={'ok': True, 'message': '数据库连接成功'})
|
||||
if arguments[0:2] == ('db', 'heads'):
|
||||
return SimpleNamespace(payload={'ok': False, 'message': '读取 Alembic heads 失败', 'error': 'broken graph'})
|
||||
if arguments[0:2] == ('db', 'history'):
|
||||
return SimpleNamespace(payload={'ok': True, 'count': 0, 'totalCount': 0, 'limit': 8, 'items': []})
|
||||
return SimpleNamespace(
|
||||
payload={
|
||||
'ok': True,
|
||||
'config': {
|
||||
'dbType': 'mysql',
|
||||
'dbHost': '127.0.0.1',
|
||||
'dbPort': 3306,
|
||||
'dbDatabase': 'shierfeng',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
monkeypatch.setattr(database_adapter.NESTED_CLI_SUPPORT, 'run', fake_run_nested_cli_command)
|
||||
|
||||
snapshot = database_adapter.DATABASE_DETAIL_ADAPTER.collect_snapshot('dev')
|
||||
|
||||
assert snapshot.subtitle == '聚焦:迁移版本 / 连接状态 / Heads | 数据库存在迁移分叉风险,优先确认 heads 和历史版本'
|
||||
assert snapshot.sections[0].title == '总览判断'
|
||||
assert snapshot.sections[0].status == 'warn'
|
||||
assert snapshot.sections[4].title == 'Heads 状态'
|
||||
assert snapshot.sections[4].status == 'fail'
|
||||
assert any('读取 Alembic heads 失败' in line for line in snapshot.sections[4].lines)
|
||||
|
||||
|
||||
def test_collect_database_page_snapshot_applies_query_filter(
|
||||
monkeypatch: MonkeyPatch,
|
||||
database_adapter: ModuleType,
|
||||
) -> None:
|
||||
def fake_run_nested_cli_command(*arguments: str, parse_json: bool = False) -> SimpleNamespace:
|
||||
del parse_json
|
||||
if arguments[0:2] == ('db', 'current'):
|
||||
return SimpleNamespace(payload={'ok': True, 'currentRevision': '202604300001'})
|
||||
if arguments[0:2] == ('db', 'check'):
|
||||
return SimpleNamespace(payload={'ok': True, 'message': '数据库连接成功'})
|
||||
if arguments[0:2] == ('db', 'heads'):
|
||||
return SimpleNamespace(payload={'ok': True, 'count': 1, 'items': []})
|
||||
if arguments[0:2] == ('db', 'history'):
|
||||
return SimpleNamespace(payload={'ok': True, 'count': 0, 'totalCount': 0, 'limit': 8, 'items': []})
|
||||
return SimpleNamespace(
|
||||
payload={
|
||||
'ok': True,
|
||||
'config': {'dbType': 'mysql', 'dbHost': '127.0.0.1', 'dbPort': 3306, 'dbDatabase': 'shierfeng'},
|
||||
}
|
||||
)
|
||||
|
||||
monkeypatch.setattr(database_adapter.NESTED_CLI_SUPPORT, 'run', fake_run_nested_cli_command)
|
||||
|
||||
snapshot = database_adapter.DATABASE_DETAIL_ADAPTER.collect_snapshot('dev', query='历史')
|
||||
|
||||
assert [section.title for section in snapshot.sections] == ['历史版本']
|
||||
assert snapshot.search is not None
|
||||
assert snapshot.search.query == '历史'
|
||||
|
||||
|
||||
def test_collect_crypto_page_snapshot_builds_validate_and_public_key_sections(
|
||||
monkeypatch: MonkeyPatch,
|
||||
crypto_adapter: ModuleType,
|
||||
) -> None:
|
||||
def fake_run_nested_cli_command(*arguments: str, parse_json: bool = False) -> SimpleNamespace:
|
||||
del parse_json
|
||||
if arguments[0:2] == ('crypto', 'validate'):
|
||||
return SimpleNamespace(
|
||||
payload={
|
||||
'ok': True,
|
||||
'message': '传输加密配置校验通过',
|
||||
}
|
||||
)
|
||||
return SimpleNamespace(
|
||||
payload={
|
||||
'ok': True,
|
||||
'publicKey': {
|
||||
'kid': 'default',
|
||||
'alg': 'RSA-OAEP',
|
||||
'envelopeVersion': 'v1',
|
||||
'expireAt': '2099-12-31T23:59:59',
|
||||
'supportedKids': ['default', 'legacy-a'],
|
||||
'publicKey': '-----BEGIN PUBLIC KEY-----\nAAA\nBBB\n-----END PUBLIC KEY-----',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
monkeypatch.setattr(crypto_adapter.NESTED_CLI_SUPPORT, 'run', fake_run_nested_cli_command)
|
||||
|
||||
snapshot = crypto_adapter.CRYPTO_DETAIL_ADAPTER.collect_snapshot('dev')
|
||||
|
||||
assert snapshot.title == '传输加密'
|
||||
assert (
|
||||
snapshot.subtitle
|
||||
== '聚焦:运行校验 / 公钥身份 / 兼容版本 | 当前 KID default,兼容版本 2 个,可继续查看公钥身份、兼容版本与轮换预演入口'
|
||||
)
|
||||
assert [section.title for section in snapshot.sections] == [
|
||||
'总览判断',
|
||||
'运行校验',
|
||||
'公钥身份',
|
||||
'兼容版本',
|
||||
'公钥预览',
|
||||
'密钥生成入口',
|
||||
'轮换预演入口',
|
||||
]
|
||||
assert any('运行校验: 通过' in line for line in snapshot.sections[0].lines)
|
||||
assert any('状态: 通过' in line for line in snapshot.sections[1].lines)
|
||||
assert any('KID: default' in line for line in snapshot.sections[2].lines)
|
||||
assert any('支持版本: legacy-a' in line for line in snapshot.sections[3].lines)
|
||||
assert any('BEGIN PUBLIC KEY' in line for line in snapshot.sections[4].lines)
|
||||
assert any('crypto keygen' in line for line in snapshot.sections[5].lines)
|
||||
assert any('crypto rotate' in line for line in snapshot.sections[6].lines)
|
||||
assert snapshot.search is not None
|
||||
assert snapshot.search.placeholder == '按分区或内容搜索'
|
||||
@@ -0,0 +1,494 @@
|
||||
from types import ModuleType, SimpleNamespace
|
||||
|
||||
from pytest import MonkeyPatch
|
||||
|
||||
|
||||
def test_collect_dashboard_snapshot_adds_risk_heat_panel(
|
||||
monkeypatch: MonkeyPatch,
|
||||
health_adapter: ModuleType,
|
||||
) -> None:
|
||||
def fake_run_nested_cli_command(*arguments: str, parse_json: bool = False) -> SimpleNamespace:
|
||||
del parse_json
|
||||
if arguments[0:2] == ('app', 'env'):
|
||||
return SimpleNamespace(
|
||||
payload={
|
||||
'ok': True,
|
||||
'runtime': {
|
||||
'cliEnv': 'dev',
|
||||
'configEnv': 'dev',
|
||||
'envFile': '.env.dev',
|
||||
'envFileExists': True,
|
||||
},
|
||||
}
|
||||
)
|
||||
if arguments[0:2] == ('app', 'doctor'):
|
||||
return SimpleNamespace(
|
||||
payload={
|
||||
'ok': False,
|
||||
'database': {'ok': False, 'message': '数据库连接失败'},
|
||||
'redis': {'ok': True, 'message': 'Redis 正常'},
|
||||
'crypto': {'ok': True, 'message': '加密组件正常'},
|
||||
}
|
||||
)
|
||||
if arguments[0:2] == ('app', 'routes'):
|
||||
return SimpleNamespace(
|
||||
payload={
|
||||
'ok': True,
|
||||
'count': 3,
|
||||
'routes': [],
|
||||
'groupedRoutes': {'system': [{}, {}], 'monitor': [{}]},
|
||||
}
|
||||
)
|
||||
if arguments[0:2] == ('db', 'current'):
|
||||
return SimpleNamespace(
|
||||
payload={
|
||||
'ok': False,
|
||||
'currentRevision': '-',
|
||||
'message': 'revision unavailable',
|
||||
}
|
||||
)
|
||||
if arguments[0:2] == ('ops', 'deps'):
|
||||
return SimpleNamespace(
|
||||
payload={
|
||||
'ok': True,
|
||||
'message': '核心运行依赖已安装',
|
||||
'missingRequired': [],
|
||||
'packages': {
|
||||
'python': {'installed': True, 'version': '3.10.0'},
|
||||
'fastapi': {'installed': True, 'version': '0.111.0'},
|
||||
'sqlalchemy': {'installed': True, 'version': '2.0.0'},
|
||||
'redis': {'installed': True, 'version': '5.0.0'},
|
||||
},
|
||||
}
|
||||
)
|
||||
if arguments[0:2] == ('ops', 'server-info'):
|
||||
return SimpleNamespace(
|
||||
payload={
|
||||
'ok': True,
|
||||
'server': {
|
||||
'sys': {'computerName': 'shierfeng-node', 'computerIp': '10.0.0.8'},
|
||||
'cpu': {'used': 18.2},
|
||||
'mem': {'usage': 42.5},
|
||||
},
|
||||
}
|
||||
)
|
||||
return SimpleNamespace(
|
||||
payload={
|
||||
'ok': True,
|
||||
'dbSize': 128,
|
||||
'info': {'redis_version': '7.0', 'connected_clients': 12},
|
||||
'cacheNames': ['sys_config'],
|
||||
}
|
||||
)
|
||||
|
||||
monkeypatch.setattr(health_adapter.NESTED_CLI_SUPPORT, 'run', fake_run_nested_cli_command)
|
||||
|
||||
snapshot = health_adapter.DASHBOARD_ADAPTER.collect_snapshot('dev')
|
||||
|
||||
assert any(panel.title == '总览判断' for panel in snapshot.panels)
|
||||
assert any(panel.title == '建议摘要' for panel in snapshot.panels)
|
||||
assert any(panel.title == '风险摘要' for panel in snapshot.panels)
|
||||
assert any(panel.title == '依赖版本' for panel in snapshot.panels)
|
||||
assert any(panel.title == '服务器摘要' for panel in snapshot.panels)
|
||||
app_panel = next(panel for panel in snapshot.panels if panel.title == '应用摘要')
|
||||
conclusion_panel = next(panel for panel in snapshot.panels if panel.title == '总览判断')
|
||||
entry_panel = next(panel for panel in snapshot.panels if panel.title == '建议摘要')
|
||||
assert any('当前环境: dev | CLI 目标环境: dev' in line for line in app_panel.lines)
|
||||
assert any('注册路由: 3 条 | 标签分组: 2 个' in line for line in app_panel.lines)
|
||||
assert conclusion_panel.status == 'fail'
|
||||
assert any('数据库存在异常' in line for line in conclusion_panel.lines)
|
||||
assert any('优先进入数据库页面' in line for line in conclusion_panel.lines)
|
||||
assert entry_panel.status == 'fail'
|
||||
assert any('[B] 数据库' in line for line in entry_panel.lines)
|
||||
assert any('[O] 运维' in line for line in entry_panel.lines)
|
||||
assert any('聚焦:迁移版本 / 连接状态 / Heads' in line for line in entry_panel.lines)
|
||||
assert any('聚焦:数据库连通性 / Redis 连通性 / 依赖版本' in line for line in entry_panel.lines)
|
||||
heat_panel = next(panel for panel in snapshot.panels if panel.title == '风险摘要')
|
||||
assert heat_panel.status == 'fail'
|
||||
assert any('HOT-01' in line for line in heat_panel.lines)
|
||||
assert any(metric.title == '依赖通过率' and '[' in metric.value for metric in snapshot.metrics)
|
||||
|
||||
|
||||
def test_dashboard_adapter_collect_snapshot_exposes_metrics(
|
||||
monkeypatch: MonkeyPatch,
|
||||
health_adapter: ModuleType,
|
||||
) -> None:
|
||||
def fake_run_nested_cli_command(*arguments: str, parse_json: bool = False) -> SimpleNamespace:
|
||||
del parse_json
|
||||
if arguments[0:2] == ('app', 'env'):
|
||||
return SimpleNamespace(payload={'ok': True, 'runtime': {'cliEnv': 'dev', 'configEnv': 'dev'}})
|
||||
if arguments[0:2] == ('app', 'routes'):
|
||||
return SimpleNamespace(payload={'ok': True, 'count': 2, 'routes': [], 'groupedRoutes': {}})
|
||||
if arguments[0:2] == ('app', 'doctor'):
|
||||
return SimpleNamespace(
|
||||
payload={
|
||||
'ok': True,
|
||||
'database': {'ok': True, 'message': '数据库正常'},
|
||||
'redis': {'ok': True, 'message': 'Redis 正常'},
|
||||
'crypto': {'ok': True, 'message': '加密正常'},
|
||||
}
|
||||
)
|
||||
if arguments[0:2] == ('db', 'current'):
|
||||
return SimpleNamespace(payload={'ok': True, 'currentRevision': '202604300001'})
|
||||
if arguments[0:2] == ('ops', 'deps'):
|
||||
return SimpleNamespace(payload={'ok': True, 'missingRequired': [], 'packages': {}})
|
||||
if arguments[0:2] == ('ops', 'server-info'):
|
||||
return SimpleNamespace(payload={'ok': True, 'server': {'sys': {}, 'cpu': {}, 'mem': {}}})
|
||||
return SimpleNamespace(payload={'ok': True, 'dbSize': 27, 'info': {}, 'cacheNames': []})
|
||||
|
||||
monkeypatch.setattr(health_adapter.NESTED_CLI_SUPPORT, 'run', fake_run_nested_cli_command)
|
||||
|
||||
snapshot = health_adapter.DASHBOARD_ADAPTER.collect_snapshot('dev')
|
||||
|
||||
assert snapshot.env == 'dev'
|
||||
assert snapshot.metrics
|
||||
assert any(metric.title == '依赖通过率' for metric in snapshot.metrics)
|
||||
assert any(panel.title == '总览判断' for panel in snapshot.panels)
|
||||
|
||||
|
||||
def test_collect_dashboard_snapshot_uses_focus_hints_for_healthy_business_entry(
|
||||
monkeypatch: MonkeyPatch,
|
||||
health_adapter: ModuleType,
|
||||
) -> None:
|
||||
def fake_run_nested_cli_command(*arguments: str, parse_json: bool = False) -> SimpleNamespace:
|
||||
del parse_json
|
||||
if arguments[0:2] == ('app', 'env'):
|
||||
return SimpleNamespace(payload={'ok': True, 'runtime': {'cliEnv': 'dev', 'configEnv': 'dev'}})
|
||||
if arguments[0:2] == ('app', 'routes'):
|
||||
return SimpleNamespace(
|
||||
payload={'ok': True, 'count': 4, 'routes': [], 'groupedRoutes': {'system': [{}, {}]}}
|
||||
)
|
||||
if arguments[0:2] == ('app', 'doctor'):
|
||||
return SimpleNamespace(
|
||||
payload={
|
||||
'ok': True,
|
||||
'database': {'ok': True, 'message': '数据库正常'},
|
||||
'redis': {'ok': True, 'message': 'Redis 正常'},
|
||||
'crypto': {'ok': True, 'message': '加密组件正常'},
|
||||
}
|
||||
)
|
||||
if arguments[0:2] == ('db', 'current'):
|
||||
return SimpleNamespace(payload={'ok': True, 'currentRevision': '202604300001'})
|
||||
if arguments[0:2] == ('ops', 'deps'):
|
||||
return SimpleNamespace(payload={'ok': True, 'missingRequired': [], 'packages': {}, 'message': '依赖正常'})
|
||||
if arguments[0:2] == ('ops', 'server-info'):
|
||||
return SimpleNamespace(payload={'ok': True, 'server': {'sys': {}, 'cpu': {}, 'mem': {}}})
|
||||
return SimpleNamespace(
|
||||
payload={
|
||||
'ok': True,
|
||||
'dbSize': 27,
|
||||
'info': {'redis_version': '7.2.6', 'connected_clients': 1},
|
||||
'cacheNames': [],
|
||||
}
|
||||
)
|
||||
|
||||
monkeypatch.setattr(health_adapter.NESTED_CLI_SUPPORT, 'run', fake_run_nested_cli_command)
|
||||
|
||||
snapshot = health_adapter.DASHBOARD_ADAPTER.collect_snapshot('dev')
|
||||
|
||||
entry_panel = next(panel for panel in snapshot.panels if panel.title == '建议摘要')
|
||||
|
||||
assert entry_panel.status == 'ok'
|
||||
assert any('聚焦:失败聚合 / 暂停任务 / 执行轨迹' in line for line in entry_panel.lines)
|
||||
assert any('聚焦:高风险配置 / 值不一致 / 缓存漂移' in line for line in entry_panel.lines)
|
||||
assert any('生成前校验' in line and '代码预览' in line for line in entry_panel.lines)
|
||||
|
||||
|
||||
def test_collect_configs_page_snapshot_surfaces_failure_message(
|
||||
monkeypatch: MonkeyPatch,
|
||||
configs_adapter: ModuleType,
|
||||
) -> None:
|
||||
def fake_run_nested_cli_command(*arguments: str, parse_json: bool = False) -> SimpleNamespace:
|
||||
del arguments, parse_json
|
||||
return SimpleNamespace(
|
||||
payload={
|
||||
'ok': False,
|
||||
'message': '读取参数配置诊断信息失败',
|
||||
'error': 'database unavailable',
|
||||
}
|
||||
)
|
||||
|
||||
monkeypatch.setattr(configs_adapter.NESTED_CLI_SUPPORT, 'run', fake_run_nested_cli_command)
|
||||
|
||||
snapshot = configs_adapter.CONFIGS_BROWSER_ADAPTER.collect_snapshot('dev')
|
||||
|
||||
assert snapshot.title == '参数配置'
|
||||
assert snapshot.records[0].status == 'fail'
|
||||
assert snapshot.shared_sections[0].title == '总览判断'
|
||||
assert snapshot.shared_sections[1].title == '配置巡检'
|
||||
assert any('读取参数配置诊断信息失败' in line for line in snapshot.shared_sections[1].lines)
|
||||
assert any('database unavailable' in line for line in snapshot.shared_sections[1].lines)
|
||||
|
||||
|
||||
def test_configs_browser_adapter_collect_snapshot_exposes_search_context(
|
||||
monkeypatch: MonkeyPatch,
|
||||
configs_adapter: ModuleType,
|
||||
) -> None:
|
||||
def fake_run_nested_cli_command(*arguments: str, parse_json: bool = False) -> SimpleNamespace:
|
||||
del parse_json
|
||||
if arguments[0:2] == ('config', 'doctor'):
|
||||
return SimpleNamespace(
|
||||
payload={
|
||||
'ok': True,
|
||||
'databaseCount': 1,
|
||||
'cacheCount': 1,
|
||||
'missingInCacheCount': 0,
|
||||
'orphanInCacheCount': 0,
|
||||
'mismatchCount': 0,
|
||||
'missingInCache': [],
|
||||
'orphanInCache': [],
|
||||
'mismatchKeys': [],
|
||||
}
|
||||
)
|
||||
if arguments[0:2] == ('config', 'list'):
|
||||
return SimpleNamespace(
|
||||
payload={
|
||||
'ok': True,
|
||||
'page': {
|
||||
'rows': [
|
||||
{
|
||||
'configId': 1,
|
||||
'configKey': 'site_name',
|
||||
'configName': '站点名称',
|
||||
'configType': 'Y',
|
||||
'configValue': 'ShiErFeng',
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
)
|
||||
return SimpleNamespace(payload={'ok': False, 'message': 'unexpected'})
|
||||
|
||||
monkeypatch.setattr(configs_adapter.NESTED_CLI_SUPPORT, 'run', fake_run_nested_cli_command)
|
||||
|
||||
snapshot = configs_adapter.CONFIGS_BROWSER_ADAPTER.collect_snapshot('dev', query='site')
|
||||
|
||||
assert snapshot.title == '参数配置'
|
||||
assert snapshot.search is not None
|
||||
assert snapshot.search.query == 'site'
|
||||
assert snapshot.records
|
||||
assert snapshot.records[0].title == 'site_name'
|
||||
|
||||
|
||||
def test_collect_configs_page_snapshot_builds_browser_records(
|
||||
monkeypatch: MonkeyPatch,
|
||||
configs_adapter: ModuleType,
|
||||
) -> None:
|
||||
calls: list[tuple[str, ...]] = []
|
||||
|
||||
def fake_run_nested_cli_command(*arguments: str, parse_json: bool = False) -> SimpleNamespace:
|
||||
del parse_json
|
||||
calls.append(arguments)
|
||||
if arguments[0:2] == ('config', 'doctor'):
|
||||
return SimpleNamespace(
|
||||
payload={
|
||||
'ok': True,
|
||||
'databaseCount': 3,
|
||||
'cacheCount': 3,
|
||||
'missingInCacheCount': 0,
|
||||
'orphanInCacheCount': 0,
|
||||
'mismatchCount': 1,
|
||||
'mismatchKeys': ['site_name'],
|
||||
}
|
||||
)
|
||||
if arguments[0:2] == ('config', 'list'):
|
||||
return SimpleNamespace(
|
||||
payload={
|
||||
'ok': True,
|
||||
'page': {
|
||||
'rows': [
|
||||
{
|
||||
'configId': 1,
|
||||
'configKey': 'site_name',
|
||||
'configName': '站点名称',
|
||||
'configType': 'Y',
|
||||
'configValue': 'ShiErFeng',
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
)
|
||||
return SimpleNamespace(
|
||||
payload={
|
||||
'ok': True,
|
||||
'key': 'site_name',
|
||||
'source': 'both',
|
||||
'inSync': False,
|
||||
'database': {
|
||||
'configId': 1,
|
||||
'configKey': 'site_name',
|
||||
'configName': '站点名称',
|
||||
'configValue': 'ShiErFeng',
|
||||
'configType': 'Y',
|
||||
'remark': '数据库值',
|
||||
},
|
||||
'cache': {
|
||||
'configId': 1,
|
||||
'configKey': 'site_name',
|
||||
'configName': '站点名称',
|
||||
'configValue': 'ShiErFeng-Cache',
|
||||
'configType': 'Y',
|
||||
'remark': '缓存值',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
monkeypatch.setattr(configs_adapter.NESTED_CLI_SUPPORT, 'run', fake_run_nested_cli_command)
|
||||
|
||||
snapshot = configs_adapter.CONFIGS_BROWSER_ADAPTER.collect_snapshot('dev')
|
||||
|
||||
assert snapshot.title == '参数配置'
|
||||
assert (
|
||||
snapshot.subtitle
|
||||
== '聚焦:高风险配置 / 值不一致 / 缓存漂移 | 当前筛选:全部,已匹配 1 项配置,值不一致 1 项,缓存漂移 0 项'
|
||||
)
|
||||
assert snapshot.active_filter_key == 'all'
|
||||
assert [option.key for option in snapshot.filters] == ['all', 'risky', 'mismatch', 'cache-drift']
|
||||
assert len(snapshot.records) == 1
|
||||
assert snapshot.records[0].title == 'site_name'
|
||||
assert snapshot.records[0].status == 'fail'
|
||||
detail_sections = snapshot.records[0].resolve_detail_sections()
|
||||
assert [section.title for section in detail_sections] == ['同步状态', '数据库配置', '缓存配置']
|
||||
assert any('数据库与缓存一致: 否' in line for line in detail_sections[0].lines)
|
||||
assert any('键值: ShiErFeng' in line for line in detail_sections[1].lines)
|
||||
assert any('键值: ShiErFeng-Cache' in line for line in detail_sections[2].lines)
|
||||
assert snapshot.shared_sections[0].title == '总览判断'
|
||||
assert any('值不一致: 1 项' in line for line in snapshot.shared_sections[0].lines)
|
||||
assert snapshot.shared_sections[1].title == '配置巡检'
|
||||
assert snapshot.shared_sections[2].title == '高风险配置'
|
||||
assert snapshot.shared_sections[3].title == '异常样本'
|
||||
assert snapshot.shared_sections[4].title == '配置变更入口'
|
||||
assert any('config set site_name ShiErFeng --output=text' in line for line in snapshot.shared_sections[4].lines)
|
||||
assert any(call[0:2] == ('config', 'get') for call in calls)
|
||||
|
||||
|
||||
def test_collect_configs_page_snapshot_sorts_and_filters_risky_records(
|
||||
monkeypatch: MonkeyPatch,
|
||||
configs_adapter: ModuleType,
|
||||
) -> None:
|
||||
def fake_run_nested_cli_command(*arguments: str, parse_json: bool = False) -> SimpleNamespace:
|
||||
del parse_json
|
||||
if arguments[0:2] == ('config', 'doctor'):
|
||||
return SimpleNamespace(
|
||||
payload={
|
||||
'ok': True,
|
||||
'databaseCount': 3,
|
||||
'cacheCount': 3,
|
||||
'missingInCacheCount': 1,
|
||||
'orphanInCacheCount': 1,
|
||||
'mismatchCount': 1,
|
||||
'missingInCache': ['upload_mode'],
|
||||
'orphanInCache': ['legacy_toggle'],
|
||||
'mismatchKeys': ['site_name'],
|
||||
}
|
||||
)
|
||||
if arguments[0:2] == ('config', 'list'):
|
||||
return SimpleNamespace(
|
||||
payload={
|
||||
'ok': True,
|
||||
'page': {
|
||||
'rows': [
|
||||
{
|
||||
'configId': 2,
|
||||
'configKey': 'upload_mode',
|
||||
'configName': '上传模式',
|
||||
'configType': 'N',
|
||||
'configValue': 'local',
|
||||
},
|
||||
{
|
||||
'configId': 1,
|
||||
'configKey': 'site_name',
|
||||
'configName': '站点名称',
|
||||
'configType': 'Y',
|
||||
'configValue': 'ShiErFeng',
|
||||
},
|
||||
{
|
||||
'configId': 3,
|
||||
'configKey': 'normal_timeout',
|
||||
'configName': '会话超时',
|
||||
'configType': 'N',
|
||||
'configValue': '30m',
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
)
|
||||
return SimpleNamespace(payload={'ok': True, 'key': '-', 'source': 'both', 'inSync': True})
|
||||
|
||||
monkeypatch.setattr(configs_adapter.NESTED_CLI_SUPPORT, 'run', fake_run_nested_cli_command)
|
||||
|
||||
all_snapshot = configs_adapter.CONFIGS_BROWSER_ADAPTER.collect_snapshot('dev')
|
||||
risky_snapshot = configs_adapter.CONFIGS_BROWSER_ADAPTER.collect_snapshot('dev', filter_key='risky')
|
||||
mismatch_snapshot = configs_adapter.CONFIGS_BROWSER_ADAPTER.collect_snapshot('dev', filter_key='mismatch')
|
||||
cache_drift_snapshot = configs_adapter.CONFIGS_BROWSER_ADAPTER.collect_snapshot('dev', filter_key='cache-drift')
|
||||
|
||||
assert [record.title for record in all_snapshot.records] == [
|
||||
'site_name',
|
||||
'upload_mode',
|
||||
'legacy_toggle',
|
||||
'normal_timeout',
|
||||
]
|
||||
assert [record.status for record in all_snapshot.records] == ['fail', 'warn', 'warn', 'ok']
|
||||
assert any('[值不一致] site_name' in line for line in all_snapshot.shared_sections[2].lines)
|
||||
assert any('[缓存缺失] upload_mode' in line for line in all_snapshot.shared_sections[2].lines)
|
||||
assert any('[缓存孤立] legacy_toggle' in line for line in all_snapshot.shared_sections[2].lines)
|
||||
|
||||
assert risky_snapshot.active_filter_key == 'risky'
|
||||
assert [record.title for record in risky_snapshot.records] == ['site_name', 'upload_mode', 'legacy_toggle']
|
||||
assert [record.title for record in mismatch_snapshot.records] == ['site_name']
|
||||
assert [record.title for record in cache_drift_snapshot.records] == ['upload_mode', 'legacy_toggle']
|
||||
|
||||
|
||||
def test_collect_configs_page_snapshot_applies_query_filter(
|
||||
monkeypatch: MonkeyPatch,
|
||||
configs_adapter: ModuleType,
|
||||
) -> None:
|
||||
def fake_run_nested_cli_command(*arguments: str, parse_json: bool = False) -> SimpleNamespace:
|
||||
del parse_json
|
||||
if arguments[0:2] == ('config', 'doctor'):
|
||||
return SimpleNamespace(
|
||||
payload={
|
||||
'ok': True,
|
||||
'databaseCount': 2,
|
||||
'cacheCount': 2,
|
||||
'missingInCacheCount': 0,
|
||||
'orphanInCacheCount': 0,
|
||||
'mismatchCount': 0,
|
||||
'missingInCache': [],
|
||||
'orphanInCache': [],
|
||||
'mismatchKeys': [],
|
||||
}
|
||||
)
|
||||
if arguments[0:2] == ('config', 'list'):
|
||||
return SimpleNamespace(
|
||||
payload={
|
||||
'ok': True,
|
||||
'page': {
|
||||
'rows': [
|
||||
{
|
||||
'configId': 1,
|
||||
'configKey': 'site_name',
|
||||
'configName': '站点名称',
|
||||
'configType': 'Y',
|
||||
'configValue': 'ShiErFeng',
|
||||
},
|
||||
{
|
||||
'configId': 2,
|
||||
'configKey': 'upload_mode',
|
||||
'configName': '上传模式',
|
||||
'configType': 'N',
|
||||
'configValue': 'local',
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
)
|
||||
return SimpleNamespace(payload={'ok': True, 'key': '-', 'source': 'both', 'inSync': True})
|
||||
|
||||
monkeypatch.setattr(configs_adapter.NESTED_CLI_SUPPORT, 'run', fake_run_nested_cli_command)
|
||||
|
||||
snapshot = configs_adapter.CONFIGS_BROWSER_ADAPTER.collect_snapshot('dev', query='site')
|
||||
|
||||
assert [record.title for record in snapshot.records] == ['site_name']
|
||||
assert snapshot.search is not None
|
||||
assert snapshot.search.query == 'site'
|
||||
@@ -0,0 +1,439 @@
|
||||
from types import ModuleType, SimpleNamespace
|
||||
|
||||
from pytest import MonkeyPatch
|
||||
|
||||
|
||||
def test_collect_jobs_page_snapshot_links_first_job_detail_and_logs(
|
||||
monkeypatch: MonkeyPatch,
|
||||
jobs_adapter: ModuleType,
|
||||
) -> None:
|
||||
calls: list[tuple[str, ...]] = []
|
||||
|
||||
def fake_run_nested_cli_command(*arguments: str, parse_json: bool = False) -> SimpleNamespace:
|
||||
del parse_json
|
||||
calls.append(arguments)
|
||||
if arguments[0:2] == ('job', 'list'):
|
||||
return SimpleNamespace(
|
||||
payload={
|
||||
'ok': True,
|
||||
'page': {
|
||||
'rows': [
|
||||
{
|
||||
'jobId': 101,
|
||||
'jobName': '同步任务',
|
||||
'status': '0',
|
||||
'cronExpression': '0/30 * * * * ?',
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
)
|
||||
if arguments[0:2] == ('job', 'detail'):
|
||||
return SimpleNamespace(
|
||||
payload={
|
||||
'ok': True,
|
||||
'job': {
|
||||
'jobId': 101,
|
||||
'jobName': '同步任务',
|
||||
'jobGroup': 'DEFAULT',
|
||||
'status': '0',
|
||||
'cronExpression': '0/30 * * * * ?',
|
||||
'invokeTarget': 'demo.sync',
|
||||
},
|
||||
}
|
||||
)
|
||||
if arguments[0:2] == ('job', 'logs') and '--status=1' in arguments:
|
||||
return SimpleNamespace(
|
||||
payload={
|
||||
'ok': True,
|
||||
'page': {
|
||||
'rows': [
|
||||
{
|
||||
'jobLogId': 2,
|
||||
'jobName': '同步任务',
|
||||
'status': '1',
|
||||
'jobMessage': '执行失败',
|
||||
'exceptionInfo': 'traceback...',
|
||||
'createTime': '2026-04-30 10:01:00',
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
)
|
||||
return SimpleNamespace(
|
||||
payload={
|
||||
'ok': True,
|
||||
'page': {
|
||||
'rows': [
|
||||
{
|
||||
'jobLogId': 1,
|
||||
'jobName': '同步任务',
|
||||
'status': '0',
|
||||
'jobMessage': '执行成功',
|
||||
'createTime': '2026-04-30 10:00:00',
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
monkeypatch.setattr(jobs_adapter.NESTED_CLI_SUPPORT, 'run', fake_run_nested_cli_command)
|
||||
|
||||
snapshot = jobs_adapter.JOBS_BROWSER_ADAPTER.collect_snapshot('dev')
|
||||
|
||||
assert snapshot.title == '任务'
|
||||
assert (
|
||||
snapshot.subtitle
|
||||
== '聚焦:失败聚合 / 暂停任务 / 执行轨迹 | 当前筛选:全部,已匹配 1 条任务,失败任务 1 个,暂停任务 0 个'
|
||||
)
|
||||
assert len(snapshot.records) == 1
|
||||
assert snapshot.records[0].title == '同步任务'
|
||||
assert snapshot.shared_sections[0].title == '总览判断'
|
||||
assert any('存在失败任务' in line for line in snapshot.shared_sections[0].lines)
|
||||
assert any('失败任务: 1 个' in line for line in snapshot.shared_sections[0].lines)
|
||||
assert snapshot.shared_sections[1].title == '失败聚合'
|
||||
assert any('失败日志: 1 条' in line for line in snapshot.shared_sections[1].lines)
|
||||
assert any('同步任务 · 1 次' in line for line in snapshot.shared_sections[1].lines)
|
||||
detail_sections = snapshot.records[0].resolve_detail_sections()
|
||||
assert [section.title for section in detail_sections] == [
|
||||
'任务摘要',
|
||||
'调度配置',
|
||||
'执行摘要',
|
||||
'最近执行记录',
|
||||
'失败执行记录',
|
||||
]
|
||||
assert any('成功率信号:' in line for line in detail_sections[2].lines)
|
||||
assert any('最近一次执行:' in line for line in detail_sections[2].lines)
|
||||
assert any('执行轨道:' in line for line in detail_sections[2].lines)
|
||||
assert any('轨道窗口:' in line for line in detail_sections[2].lines)
|
||||
assert any('2026-04-30 10:00:00' in line for line in detail_sections[3].lines)
|
||||
assert any('o 状态: 成功' in line for line in detail_sections[3].lines)
|
||||
assert any('轨道: ├─采集 ├─执行 └─落盘' in line for line in detail_sections[3].lines)
|
||||
assert any('轨迹: ●─●─◎' in line for line in detail_sections[3].lines)
|
||||
assert any('异常: traceback...' in line for line in detail_sections[4].lines)
|
||||
assert any(call[0:2] == ('job', 'detail') for call in calls)
|
||||
assert any(call[0:2] == ('job', 'logs') and '--job-name=同步任务' in call for call in calls)
|
||||
assert any(call[0:2] == ('job', 'logs') and '--status=1' in call for call in calls)
|
||||
|
||||
|
||||
def test_collect_jobs_page_snapshot_applies_failed_filter(
|
||||
monkeypatch: MonkeyPatch,
|
||||
jobs_adapter: ModuleType,
|
||||
) -> None:
|
||||
def fake_run_nested_cli_command(*arguments: str, parse_json: bool = False) -> SimpleNamespace:
|
||||
del parse_json
|
||||
if arguments[0:2] == ('job', 'list'):
|
||||
return SimpleNamespace(
|
||||
payload={
|
||||
'ok': True,
|
||||
'page': {
|
||||
'rows': [
|
||||
{
|
||||
'jobId': 101,
|
||||
'jobName': '失败任务',
|
||||
'status': '0',
|
||||
'cronExpression': '0/30 * * * * ?',
|
||||
},
|
||||
{
|
||||
'jobId': 102,
|
||||
'jobName': '暂停任务',
|
||||
'status': '1',
|
||||
'cronExpression': '0/45 * * * * ?',
|
||||
},
|
||||
{
|
||||
'jobId': 103,
|
||||
'jobName': '正常任务',
|
||||
'status': '0',
|
||||
'cronExpression': '0/50 * * * * ?',
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
)
|
||||
if arguments[0:2] == ('job', 'logs') and '--status=1' in arguments:
|
||||
return SimpleNamespace(
|
||||
payload={
|
||||
'ok': True,
|
||||
'page': {
|
||||
'rows': [
|
||||
{
|
||||
'jobLogId': 2,
|
||||
'jobName': '失败任务',
|
||||
'status': '1',
|
||||
'jobMessage': '执行失败',
|
||||
'createTime': '2026-04-30 10:01:00',
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
)
|
||||
return SimpleNamespace(payload={'ok': True, 'page': {'rows': []}})
|
||||
|
||||
monkeypatch.setattr(jobs_adapter.NESTED_CLI_SUPPORT, 'run', fake_run_nested_cli_command)
|
||||
|
||||
snapshot = jobs_adapter.JOBS_BROWSER_ADAPTER.collect_snapshot('dev', filter_key='failed')
|
||||
|
||||
assert snapshot.active_filter_key == 'failed'
|
||||
assert [option.key for option in snapshot.filters] == ['all', 'failed', 'paused', 'ok']
|
||||
assert len(snapshot.records) == 1
|
||||
assert snapshot.records[0].title == '失败任务'
|
||||
assert '当前筛选:失败' in snapshot.subtitle
|
||||
assert snapshot.shared_sections[0].title == '总览判断'
|
||||
assert any('当前筛选: 失败' in line for line in snapshot.shared_sections[0].lines)
|
||||
assert snapshot.shared_sections[1].title == '失败聚合'
|
||||
assert any('涉及任务: 1 个' in line for line in snapshot.shared_sections[1].lines)
|
||||
assert snapshot.search is not None
|
||||
assert snapshot.search.placeholder == '按任务名搜索'
|
||||
|
||||
|
||||
def test_collect_jobs_page_snapshot_applies_query_filter(
|
||||
monkeypatch: MonkeyPatch,
|
||||
jobs_adapter: ModuleType,
|
||||
) -> None:
|
||||
def fake_run_nested_cli_command(*arguments: str, parse_json: bool = False) -> SimpleNamespace:
|
||||
del parse_json
|
||||
if arguments[0:2] == ('job', 'list'):
|
||||
return SimpleNamespace(
|
||||
payload={
|
||||
'ok': True,
|
||||
'page': {
|
||||
'rows': [
|
||||
{
|
||||
'jobId': 1,
|
||||
'jobName': 'sync-user',
|
||||
'jobGroup': 'default',
|
||||
'status': '0',
|
||||
'cronExpression': '*',
|
||||
},
|
||||
{
|
||||
'jobId': 2,
|
||||
'jobName': 'clean-cache',
|
||||
'jobGroup': 'ops',
|
||||
'status': '0',
|
||||
'cronExpression': '*',
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
)
|
||||
return SimpleNamespace(payload={'ok': True, 'page': {'rows': []}})
|
||||
|
||||
monkeypatch.setattr(jobs_adapter.NESTED_CLI_SUPPORT, 'run', fake_run_nested_cli_command)
|
||||
|
||||
snapshot = jobs_adapter.JOBS_BROWSER_ADAPTER.collect_snapshot('dev', query='sync')
|
||||
|
||||
assert [record.title for record in snapshot.records] == ['sync-user']
|
||||
assert snapshot.search is not None
|
||||
assert snapshot.search.query == 'sync'
|
||||
|
||||
|
||||
def test_collect_jobs_page_snapshot_surfaces_failure_message(
|
||||
monkeypatch: MonkeyPatch,
|
||||
jobs_adapter: ModuleType,
|
||||
) -> None:
|
||||
def fake_run_nested_cli_command(*arguments: str, parse_json: bool = False) -> SimpleNamespace:
|
||||
del arguments, parse_json
|
||||
return SimpleNamespace(
|
||||
payload={
|
||||
'ok': False,
|
||||
'message': '读取定时任务列表失败',
|
||||
'error': 'database unavailable',
|
||||
}
|
||||
)
|
||||
|
||||
monkeypatch.setattr(jobs_adapter.NESTED_CLI_SUPPORT, 'run', fake_run_nested_cli_command)
|
||||
|
||||
snapshot = jobs_adapter.JOBS_BROWSER_ADAPTER.collect_snapshot('dev')
|
||||
|
||||
assert snapshot.subtitle.startswith('任务数据不可用:')
|
||||
assert snapshot.records[0].status == 'fail'
|
||||
assert any('读取定时任务列表失败' in line for line in snapshot.records[0].detail_sections[0].lines)
|
||||
|
||||
|
||||
def test_collect_gen_page_snapshot_links_first_table_detail(
|
||||
monkeypatch: MonkeyPatch,
|
||||
gen_adapter: ModuleType,
|
||||
) -> None:
|
||||
calls: list[tuple[str, ...]] = []
|
||||
|
||||
def fake_run_nested_cli_command(*arguments: str, parse_json: bool = False) -> SimpleNamespace:
|
||||
del parse_json
|
||||
calls.append(arguments)
|
||||
if arguments[0:2] == ('gen', 'list'):
|
||||
return SimpleNamespace(
|
||||
payload={
|
||||
'ok': True,
|
||||
'page': {
|
||||
'rows': [
|
||||
{
|
||||
'tableId': 201,
|
||||
'tableName': 'sys_user',
|
||||
'className': 'SysUser',
|
||||
'moduleName': 'system',
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
)
|
||||
if arguments[0:2] == ('gen', 'detail'):
|
||||
return SimpleNamespace(
|
||||
payload={
|
||||
'ok': True,
|
||||
'tableId': 201,
|
||||
'tableName': 'sys_user',
|
||||
'columnCount': 12,
|
||||
'detail': {
|
||||
'info': {
|
||||
'className': 'SysUser',
|
||||
'moduleName': 'system',
|
||||
'businessName': 'user',
|
||||
'functionName': '用户管理',
|
||||
},
|
||||
'rows': [
|
||||
{
|
||||
'columnName': 'user_id',
|
||||
'columnType': 'bigint',
|
||||
'isPk': '1',
|
||||
'isRequired': '1',
|
||||
'queryType': 'EQ',
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
)
|
||||
if arguments[0:2] == ('gen', 'preview'):
|
||||
return SimpleNamespace(
|
||||
payload={
|
||||
'ok': True,
|
||||
'tableId': 201,
|
||||
'templateCount': 2,
|
||||
'preview': {
|
||||
'api.py.vm': 'def list_users():\n return []',
|
||||
'index.vue.vm': '<template>\n <div />\n</template>',
|
||||
},
|
||||
}
|
||||
)
|
||||
if arguments[0:2] == ('gen', 'export'):
|
||||
return SimpleNamespace(
|
||||
payload={
|
||||
'ok': True,
|
||||
'env': 'dev',
|
||||
'mode': 'zip',
|
||||
'dryRun': True,
|
||||
'message': '代码导出预演完成',
|
||||
'tableNames': ['sys_user'],
|
||||
'results': [
|
||||
{
|
||||
'tableName': 'sys_user',
|
||||
'ok': True,
|
||||
'message': '已生成 6 份模板预演结果',
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
if arguments[0:2] == ('gen', 'db-list') and '--table-name=sys_user' in arguments:
|
||||
return SimpleNamespace(
|
||||
payload={
|
||||
'ok': True,
|
||||
'page': {
|
||||
'rows': [
|
||||
{
|
||||
'tableName': 'sys_user',
|
||||
'tableComment': '用户表',
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
)
|
||||
return SimpleNamespace(
|
||||
payload={
|
||||
'ok': True,
|
||||
'page': {
|
||||
'rows': [
|
||||
{
|
||||
'tableName': 'sys_role',
|
||||
'tableComment': '角色表',
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
monkeypatch.setattr(gen_adapter.NESTED_CLI_SUPPORT, 'run', fake_run_nested_cli_command)
|
||||
|
||||
snapshot = gen_adapter.GEN_BROWSER_ADAPTER.collect_snapshot('dev')
|
||||
|
||||
assert snapshot.title == '代码生成'
|
||||
assert (
|
||||
snapshot.subtitle
|
||||
== '聚焦:生成前校验 / 同步预检查 / 代码预览 | 当前已匹配 1 张业务表,可导入物理表 1 张,可继续查看表定义、预检查和代码预览'
|
||||
)
|
||||
assert len(snapshot.records) == 1
|
||||
assert snapshot.records[0].title == 'sys_user'
|
||||
detail_sections = snapshot.records[0].resolve_detail_sections()
|
||||
assert [section.title for section in detail_sections] == [
|
||||
'业务表摘要',
|
||||
'生成配置',
|
||||
'字段摘要',
|
||||
'生成前校验',
|
||||
'同步预检查',
|
||||
'字段列表',
|
||||
'代码预览',
|
||||
'导出预览',
|
||||
'导出入口',
|
||||
]
|
||||
assert any('模板预览: 2 份' in line for line in detail_sections[3].lines)
|
||||
assert any('数据库物理表: sys_user' in line for line in detail_sections[4].lines)
|
||||
assert any('模板数量: 2' in line for line in detail_sections[6].lines)
|
||||
assert any('api.py.vm' in line for line in detail_sections[6].lines)
|
||||
assert any('代码导出预演完成' in line for line in detail_sections[7].lines)
|
||||
assert any('wizard gen-export' in line for line in detail_sections[8].lines)
|
||||
assert snapshot.shared_sections[0].title == '总览判断'
|
||||
assert any('可导入物理表: 1 张' in line for line in snapshot.shared_sections[0].lines)
|
||||
assert any('代码预览 / 导出预览 / 导入入口 / 建表入口' in line for line in snapshot.shared_sections[0].lines)
|
||||
assert snapshot.shared_sections[1].title == '可导入数据表'
|
||||
assert snapshot.shared_sections[2].title == '导入入口'
|
||||
assert any(
|
||||
'gen import-table sys_role --dry-run --output=text' in line for line in snapshot.shared_sections[2].lines
|
||||
)
|
||||
assert snapshot.shared_sections[3].title == '建表入口'
|
||||
assert any('gen create-table --dry-run --sql' in line for line in snapshot.shared_sections[3].lines)
|
||||
assert any(call[0:2] == ('gen', 'detail') for call in calls)
|
||||
assert any(call[0:2] == ('gen', 'preview') for call in calls)
|
||||
assert any(call[0:2] == ('gen', 'export') and '--dry-run' in call for call in calls)
|
||||
assert any(call[0:2] == ('gen', 'db-list') and '--table-name=sys_user' in call for call in calls)
|
||||
|
||||
|
||||
def test_gen_browser_adapter_collect_snapshot_exposes_search_context(
|
||||
monkeypatch: MonkeyPatch,
|
||||
gen_adapter: ModuleType,
|
||||
) -> None:
|
||||
def fake_run_nested_cli_command(*arguments: str, parse_json: bool = False) -> SimpleNamespace:
|
||||
del parse_json
|
||||
if arguments[0:2] == ('gen', 'list'):
|
||||
return SimpleNamespace(
|
||||
payload={
|
||||
'ok': True,
|
||||
'page': {
|
||||
'rows': [
|
||||
{
|
||||
'tableId': 201,
|
||||
'tableName': 'sys_user',
|
||||
'className': 'SysUser',
|
||||
'moduleName': 'system',
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
)
|
||||
return SimpleNamespace(payload={'ok': True, 'page': {'rows': []}})
|
||||
|
||||
monkeypatch.setattr(gen_adapter.NESTED_CLI_SUPPORT, 'run', fake_run_nested_cli_command)
|
||||
|
||||
snapshot = gen_adapter.GEN_BROWSER_ADAPTER.collect_snapshot('dev', query='sys')
|
||||
|
||||
assert snapshot.title == '代码生成'
|
||||
assert snapshot.search is not None
|
||||
assert snapshot.search.query == 'sys'
|
||||
assert snapshot.records
|
||||
assert snapshot.records[0].title == 'sys_user'
|
||||
@@ -0,0 +1,64 @@
|
||||
import importlib
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
BACKEND_DIR = Path(__file__).resolve().parents[3]
|
||||
|
||||
|
||||
def _ensure_backend_on_path() -> None:
|
||||
if str(BACKEND_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(BACKEND_DIR))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tui_base_modules() -> SimpleNamespace:
|
||||
_ensure_backend_on_path()
|
||||
sys.modules.pop('cli.main', None)
|
||||
sys.modules.pop('cli.tui.commands', None)
|
||||
sys.modules.pop('cli', None)
|
||||
|
||||
dependency_error = importlib.import_module('cli.exit_codes').DEPENDENCY_ERROR
|
||||
cli_main = importlib.import_module('cli.main')
|
||||
cli_tui_commands = importlib.import_module('cli.tui.commands')
|
||||
|
||||
return SimpleNamespace(
|
||||
BACKEND_DIR=BACKEND_DIR,
|
||||
DEPENDENCY_ERROR=dependency_error,
|
||||
cli_main=cli_main,
|
||||
cli_tui_commands=cli_tui_commands,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tui_modules(tui_base_modules: SimpleNamespace) -> SimpleNamespace:
|
||||
pytest.importorskip('textual')
|
||||
_ensure_backend_on_path()
|
||||
for module_name in (
|
||||
'cli.tui.app',
|
||||
'cli.tui.screens.browser',
|
||||
'cli.tui.copy',
|
||||
'cli.tui.screens.dashboard',
|
||||
'cli.tui.screens.detail',
|
||||
'cli.tui.screens.interactions',
|
||||
'cli.tui.adapters',
|
||||
'cli.tui.search',
|
||||
'cli.tui.widgets',
|
||||
'cli',
|
||||
):
|
||||
sys.modules.pop(module_name, None)
|
||||
|
||||
return SimpleNamespace(
|
||||
**tui_base_modules.__dict__,
|
||||
cli_tui_app=importlib.import_module('cli.tui.app'),
|
||||
cli_tui_browser=importlib.import_module('cli.tui.screens.browser'),
|
||||
cli_tui_copy=importlib.import_module('cli.tui.copy'),
|
||||
cli_tui_dashboard=importlib.import_module('cli.tui.screens.dashboard'),
|
||||
cli_tui_detail=importlib.import_module('cli.tui.screens.detail'),
|
||||
cli_tui_interactions=importlib.import_module('cli.tui.screens.interactions'),
|
||||
cli_tui_adapters=importlib.import_module('cli.tui.adapters'),
|
||||
cli_tui_search=importlib.import_module('cli.tui.search'),
|
||||
cli_tui_widgets=importlib.import_module('cli.tui.widgets'),
|
||||
)
|
||||
@@ -0,0 +1,575 @@
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from pytest import MonkeyPatch
|
||||
|
||||
|
||||
def test_browser_screen_builds_sections_from_selected_record(tui_modules: SimpleNamespace) -> None:
|
||||
snapshot = tui_modules.cli_tui_adapters.BrowserPageSnapshot(
|
||||
title='Jobs',
|
||||
subtitle='subtitle',
|
||||
records=[
|
||||
tui_modules.cli_tui_adapters.BrowserRecordSnapshot(
|
||||
key='job:1',
|
||||
title='Job A',
|
||||
status='ok',
|
||||
summary='summary a',
|
||||
metadata_lines=[],
|
||||
detail_sections=[
|
||||
tui_modules.cli_tui_adapters.DetailSectionSnapshot(
|
||||
title='Job Detail',
|
||||
status='ok',
|
||||
lines=['detail a'],
|
||||
)
|
||||
],
|
||||
)
|
||||
],
|
||||
shared_sections=[
|
||||
tui_modules.cli_tui_adapters.DetailSectionSnapshot(title='Recent Logs', status='warn', lines=['log a'])
|
||||
],
|
||||
)
|
||||
screen = tui_modules.cli_tui_browser.BrowserScreen(
|
||||
snapshot,
|
||||
env='dev',
|
||||
active_view='jobs',
|
||||
navigation_items=tui_modules.cli_tui_app.NAVIGATION_ITEMS,
|
||||
refreshed_at='2026-04-30 10:00:00',
|
||||
)
|
||||
|
||||
sections = screen._get_sections_for_record(0)
|
||||
|
||||
assert [section.title for section in sections] == ['Job Detail', 'Recent Logs']
|
||||
|
||||
|
||||
def test_browser_screen_resolves_job_actions(tui_modules: SimpleNamespace) -> None:
|
||||
snapshot = tui_modules.cli_tui_adapters.BrowserPageSnapshot(
|
||||
title='Jobs',
|
||||
subtitle='subtitle',
|
||||
records=[
|
||||
tui_modules.cli_tui_adapters.BrowserRecordSnapshot(
|
||||
key='job:1',
|
||||
title='Job A',
|
||||
status='ok',
|
||||
summary='正常 · Cron 0/30 * * * * ?',
|
||||
metadata_lines=[],
|
||||
detail_sections=[],
|
||||
)
|
||||
],
|
||||
shared_sections=[],
|
||||
)
|
||||
screen = tui_modules.cli_tui_browser.BrowserScreen(
|
||||
snapshot,
|
||||
env='dev',
|
||||
active_view='jobs',
|
||||
navigation_items=tui_modules.cli_tui_app.NAVIGATION_ITEMS,
|
||||
refreshed_at='2026-04-30 10:00:00',
|
||||
)
|
||||
|
||||
primary_action = screen._resolve_action('primary')
|
||||
secondary_action = screen._resolve_action('secondary')
|
||||
global_action = screen._resolve_action('global')
|
||||
|
||||
assert primary_action is not None
|
||||
assert primary_action.command_args == ('job', 'run-once', '1')
|
||||
assert secondary_action is not None
|
||||
assert secondary_action.command_args == ('job', 'pause', '1')
|
||||
assert global_action is not None
|
||||
assert global_action.command_args == ('job', 'sync')
|
||||
|
||||
|
||||
def test_browser_screen_builds_action_panel_with_feedback(tui_modules: SimpleNamespace) -> None:
|
||||
snapshot = tui_modules.cli_tui_adapters.BrowserPageSnapshot(
|
||||
title='Jobs',
|
||||
subtitle='subtitle',
|
||||
records=[
|
||||
tui_modules.cli_tui_adapters.BrowserRecordSnapshot(
|
||||
key='job:1',
|
||||
title='Job A',
|
||||
status='ok',
|
||||
summary='正常 · Cron 0/30 * * * * ?',
|
||||
metadata_lines=[],
|
||||
detail_sections=[],
|
||||
)
|
||||
],
|
||||
shared_sections=[],
|
||||
)
|
||||
screen = tui_modules.cli_tui_browser.BrowserScreen(
|
||||
snapshot,
|
||||
env='dev',
|
||||
active_view='jobs',
|
||||
navigation_items=tui_modules.cli_tui_app.NAVIGATION_ITEMS,
|
||||
refreshed_at='2026-04-30 10:00:00',
|
||||
)
|
||||
screen._action_feedback_lines = ['动作名称: 执行一次任务', '结果: 成功']
|
||||
|
||||
lines = screen._build_record_action_lines(snapshot.records[0])
|
||||
|
||||
assert any('动作键 [X] · 执行一次任务' in line for line in lines)
|
||||
assert any('浏览操作' in line for line in lines)
|
||||
assert any('最近动作反馈' in line for line in lines)
|
||||
assert any('结果: 成功' in line for line in lines)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_browser_screen_execute_external_action_suspends_app(
|
||||
monkeypatch: MonkeyPatch,
|
||||
tui_modules: SimpleNamespace,
|
||||
) -> None:
|
||||
snapshot = tui_modules.cli_tui_adapters.BrowserPageSnapshot(
|
||||
title='代码生成',
|
||||
subtitle='subtitle',
|
||||
records=[
|
||||
tui_modules.cli_tui_adapters.BrowserRecordSnapshot(
|
||||
key='gen:201',
|
||||
title='sys_user',
|
||||
status='ok',
|
||||
summary='生成类 SysUser · 模块 system',
|
||||
metadata_lines=[],
|
||||
detail_sections=[],
|
||||
)
|
||||
],
|
||||
shared_sections=[],
|
||||
)
|
||||
screen = tui_modules.cli_tui_browser.BrowserScreen(
|
||||
snapshot,
|
||||
env='dev',
|
||||
active_view='gen',
|
||||
navigation_items=tui_modules.cli_tui_app.NAVIGATION_ITEMS,
|
||||
refreshed_at='2026-04-30 10:00:00',
|
||||
)
|
||||
action = tui_modules.cli_tui_browser.TUI_ACTION_REGISTRY.resolve_browser_action(
|
||||
view_key='gen',
|
||||
slot='primary',
|
||||
record=snapshot.records[0],
|
||||
env='dev',
|
||||
)
|
||||
assert action is not None
|
||||
|
||||
recorded_suspend: list[str] = []
|
||||
|
||||
class DummySuspend:
|
||||
def __enter__(self) -> None:
|
||||
recorded_suspend.append('enter')
|
||||
|
||||
def __exit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc: BaseException | None,
|
||||
tb: Any,
|
||||
) -> None:
|
||||
del exc_type, exc, tb
|
||||
recorded_suspend.append('exit')
|
||||
|
||||
def build_suspend() -> DummySuspend:
|
||||
return DummySuspend()
|
||||
|
||||
def refresh_current_view() -> None:
|
||||
pass
|
||||
|
||||
fake_app = SimpleNamespace(
|
||||
suspend=build_suspend,
|
||||
action_refresh_current_view=refresh_current_view,
|
||||
)
|
||||
monkeypatch.setattr(tui_modules.cli_tui_browser.BrowserScreen, 'app', property(lambda self: fake_app))
|
||||
monkeypatch.setattr(
|
||||
tui_modules.cli_tui_interactions,
|
||||
'TUI_ACTION_EXECUTION_SERVICE',
|
||||
SimpleNamespace(
|
||||
execute_external=lambda spec: tui_modules.cli_tui_browser.TuiActionResult(
|
||||
spec=spec,
|
||||
external_exit_code=0,
|
||||
external_message='外部交互命令已执行完成',
|
||||
),
|
||||
build_result_lines=lambda result: [],
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(screen, 'notify', lambda *args, **kwargs: None)
|
||||
|
||||
async def fake_render_record_detail(*, eager: bool = False) -> None:
|
||||
del eager
|
||||
|
||||
monkeypatch.setattr(screen, '_render_record_detail', fake_render_record_detail)
|
||||
|
||||
await screen._execute_action(action)
|
||||
|
||||
assert recorded_suspend == ['enter', 'exit']
|
||||
|
||||
|
||||
def test_browser_screen_filter_shortcut_remembers_and_refreshes(
|
||||
monkeypatch: MonkeyPatch,
|
||||
tui_modules: SimpleNamespace,
|
||||
) -> None:
|
||||
snapshot = tui_modules.cli_tui_adapters.BrowserPageSnapshot(
|
||||
title='任务',
|
||||
subtitle='subtitle',
|
||||
records=[
|
||||
tui_modules.cli_tui_adapters.BrowserRecordSnapshot(
|
||||
key='job:1',
|
||||
title='Job A',
|
||||
status='ok',
|
||||
summary='正常 · Cron 0/30 * * * * ?',
|
||||
metadata_lines=[],
|
||||
detail_sections=[],
|
||||
)
|
||||
],
|
||||
shared_sections=[],
|
||||
filters=list(tui_modules.cli_tui_search.JOB_FILTER_OPTIONS),
|
||||
active_filter_key='all',
|
||||
)
|
||||
screen = tui_modules.cli_tui_browser.BrowserScreen(
|
||||
snapshot,
|
||||
env='dev',
|
||||
active_view='jobs',
|
||||
navigation_items=tui_modules.cli_tui_app.NAVIGATION_ITEMS,
|
||||
refreshed_at='2026-04-30 10:00:00',
|
||||
)
|
||||
|
||||
recorded_filters: list[tuple[str, str]] = []
|
||||
refresh_calls: list[str] = []
|
||||
fake_app = SimpleNamespace(
|
||||
remember_browser_filter=lambda view_key, filter_key: recorded_filters.append((view_key, filter_key)),
|
||||
action_refresh_current_view=lambda: refresh_calls.append('refresh'),
|
||||
)
|
||||
monkeypatch.setattr(tui_modules.cli_tui_browser.BrowserScreen, 'app', property(lambda self: fake_app))
|
||||
|
||||
screen.action_apply_filter_2()
|
||||
|
||||
assert recorded_filters == [('jobs', 'failed')]
|
||||
assert refresh_calls == ['refresh']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_browser_screen_detail_loader_failure_falls_back_to_failure_section(
|
||||
tui_modules: SimpleNamespace,
|
||||
) -> None:
|
||||
snapshot = tui_modules.cli_tui_adapters.BrowserPageSnapshot(
|
||||
title='任务',
|
||||
subtitle='subtitle',
|
||||
records=[
|
||||
tui_modules.cli_tui_adapters.BrowserRecordSnapshot(
|
||||
key='job:1',
|
||||
title='任务一',
|
||||
status='warn',
|
||||
summary='摘要',
|
||||
metadata_lines=[],
|
||||
detail_sections=[],
|
||||
detail_loader=lambda: (_ for _ in ()).throw(RuntimeError('boom')),
|
||||
)
|
||||
],
|
||||
shared_sections=[],
|
||||
)
|
||||
screen = tui_modules.cli_tui_browser.BrowserScreen(
|
||||
snapshot,
|
||||
env='dev',
|
||||
active_view='jobs',
|
||||
navigation_items=tui_modules.cli_tui_app.NAVIGATION_ITEMS,
|
||||
refreshed_at='2026-04-30 10:00:00',
|
||||
)
|
||||
|
||||
rendered_calls: list[bool] = []
|
||||
|
||||
async def fake_render_record_detail(*, eager: bool = False) -> None:
|
||||
del eager
|
||||
rendered_calls.append(True)
|
||||
|
||||
screen._render_record_detail = fake_render_record_detail # type: ignore[method-assign]
|
||||
screen._record_detail_request_id = 1
|
||||
|
||||
await screen._load_record_detail_async(0, 1)
|
||||
|
||||
cached_sections = snapshot.records[0]._cached_detail_sections
|
||||
|
||||
assert rendered_calls == [True]
|
||||
assert cached_sections is not None
|
||||
assert len(cached_sections) == 1
|
||||
assert cached_sections[0].status == 'fail'
|
||||
assert cached_sections[0].title == '任务详情加载失败'
|
||||
assert any('boom' in line for line in cached_sections[0].lines)
|
||||
|
||||
|
||||
def test_browser_screen_search_submit_remembers_and_refreshes(
|
||||
monkeypatch: MonkeyPatch,
|
||||
tui_modules: SimpleNamespace,
|
||||
) -> None:
|
||||
snapshot = tui_modules.cli_tui_adapters.BrowserPageSnapshot(
|
||||
title='任务',
|
||||
subtitle='subtitle',
|
||||
records=[],
|
||||
shared_sections=[],
|
||||
filters=list(tui_modules.cli_tui_search.JOB_FILTER_OPTIONS),
|
||||
active_filter_key='all',
|
||||
search=tui_modules.cli_tui_search.PageSearchContext(
|
||||
placeholder='按任务名搜索',
|
||||
query='',
|
||||
suggestions=['sync-user'],
|
||||
),
|
||||
)
|
||||
screen = tui_modules.cli_tui_browser.BrowserScreen(
|
||||
snapshot,
|
||||
env='dev',
|
||||
active_view='jobs',
|
||||
navigation_items=tui_modules.cli_tui_app.NAVIGATION_ITEMS,
|
||||
refreshed_at='2026-04-30 10:00:00',
|
||||
)
|
||||
|
||||
recorded_queries: list[tuple[str, str]] = []
|
||||
refresh_calls: list[str] = []
|
||||
fake_app = SimpleNamespace(
|
||||
remember_browser_query=lambda view_key, query: recorded_queries.append((view_key, query)),
|
||||
action_refresh_current_view=lambda: refresh_calls.append('refresh'),
|
||||
)
|
||||
monkeypatch.setattr(tui_modules.cli_tui_browser.BrowserScreen, 'app', property(lambda self: fake_app))
|
||||
|
||||
screen._handle_search_submitted('sync')
|
||||
|
||||
assert recorded_queries == [('jobs', 'sync')]
|
||||
assert refresh_calls == ['refresh']
|
||||
|
||||
|
||||
def test_browser_screen_support_builds_summary_actions_and_fallbacks(
|
||||
tui_modules: SimpleNamespace,
|
||||
) -> None:
|
||||
support = tui_modules.cli_tui_browser.BrowserScreenSupport()
|
||||
snapshot = tui_modules.cli_tui_adapters.BrowserPageSnapshot(
|
||||
title='任务',
|
||||
subtitle='subtitle',
|
||||
records=[
|
||||
tui_modules.cli_tui_adapters.BrowserRecordSnapshot(
|
||||
key='job:1',
|
||||
title='Job A',
|
||||
status='ok',
|
||||
summary='正常 · Cron 0/30 * * * * ?',
|
||||
metadata_lines=[],
|
||||
detail_sections=[],
|
||||
)
|
||||
],
|
||||
shared_sections=[],
|
||||
filters=list(tui_modules.cli_tui_search.JOB_FILTER_OPTIONS),
|
||||
active_filter_key='all',
|
||||
search=tui_modules.cli_tui_search.PageSearchContext(
|
||||
placeholder='按任务名搜索',
|
||||
query='sync',
|
||||
suggestions=['sync-user'],
|
||||
),
|
||||
)
|
||||
|
||||
summary = support.build_summary_text(snapshot, 'jobs')
|
||||
filter_bar = support.build_filter_bar_text(snapshot)
|
||||
action = support.resolve_action(active_view='jobs', slot='primary', record=snapshot.records[0], env='dev')
|
||||
action_lines = support.build_record_action_lines(
|
||||
active_view='jobs',
|
||||
record=snapshot.records[0],
|
||||
env='dev',
|
||||
feedback_lines=['结果: 成功'],
|
||||
)
|
||||
empty_record = support.build_empty_record()
|
||||
loading_section = support.build_loading_section()
|
||||
failure_sections = support.build_detail_load_failure_sections(RuntimeError('boom'), '任务详情')
|
||||
empty_section = support.build_empty_section()
|
||||
|
||||
assert '当前页共 1 条记录' in summary
|
||||
assert '当前搜索 · sync' in filter_bar
|
||||
assert action is not None
|
||||
assert action.command_args == ('job', 'run-once', '1')
|
||||
assert any('结果: 成功' in line for line in action_lines)
|
||||
assert empty_record.title == tui_modules.cli_tui_copy.TUI_COPY.build_browser_empty_record_copy('title')
|
||||
assert loading_section.title == tui_modules.cli_tui_copy.TUI_COPY.build_browser_loading_copy('title')
|
||||
assert failure_sections[0].status == 'fail'
|
||||
assert failure_sections[0].title == '任务详情加载失败'
|
||||
assert empty_section.title == tui_modules.cli_tui_copy.TUI_COPY.build_detail_empty_section_copy('title')
|
||||
|
||||
|
||||
def test_browser_screen_focus_service_moves_and_scrolls_between_focus_targets(
|
||||
tui_modules: SimpleNamespace,
|
||||
) -> None:
|
||||
focus_service = tui_modules.cli_tui_browser.BrowserScreenFocusService()
|
||||
focused: list[str] = []
|
||||
sidebar = SimpleNamespace(focus=lambda: focused.append('sidebar'))
|
||||
navigator = SimpleNamespace(focus=lambda: focused.append('records'))
|
||||
sections = SimpleNamespace(focus=lambda: focused.append('sections'))
|
||||
summary = SimpleNamespace(focus=lambda: focused.append('summary'))
|
||||
detail = SimpleNamespace(focus=lambda: focused.append('detail'))
|
||||
workspace_main = object()
|
||||
fake_app = SimpleNamespace(focused=None)
|
||||
fake_screen = SimpleNamespace(
|
||||
app=fake_app,
|
||||
query_one=lambda selector, widget_type=None: {
|
||||
tui_modules.cli_tui_widgets.WorkspaceSidebar: sidebar,
|
||||
tui_modules.cli_tui_widgets.RecordNavigator: navigator,
|
||||
tui_modules.cli_tui_widgets.SectionNavigator: sections,
|
||||
tui_modules.cli_tui_widgets.RecordSummaryView: summary,
|
||||
tui_modules.cli_tui_widgets.SectionDetailView: detail,
|
||||
'#workspace-main': workspace_main,
|
||||
}[selector],
|
||||
)
|
||||
|
||||
focus_service.move_focus(fake_screen, 1)
|
||||
fake_app.focused = navigator
|
||||
focus_service.move_focus(fake_screen, 1)
|
||||
fake_app.focused = object()
|
||||
scroll_target = focus_service.get_scroll_target(fake_screen)
|
||||
|
||||
assert focused == ['sidebar', 'sections']
|
||||
assert scroll_target is workspace_main
|
||||
|
||||
|
||||
def test_browser_screen_clear_search_remembers_empty_and_refreshes(
|
||||
monkeypatch: MonkeyPatch,
|
||||
tui_modules: SimpleNamespace,
|
||||
) -> None:
|
||||
snapshot = tui_modules.cli_tui_adapters.BrowserPageSnapshot(
|
||||
title='任务',
|
||||
subtitle='subtitle',
|
||||
records=[],
|
||||
shared_sections=[],
|
||||
filters=list(tui_modules.cli_tui_search.JOB_FILTER_OPTIONS),
|
||||
active_filter_key='all',
|
||||
search=tui_modules.cli_tui_search.PageSearchContext(
|
||||
placeholder='按任务名搜索',
|
||||
query='sync',
|
||||
suggestions=['sync-user'],
|
||||
),
|
||||
)
|
||||
screen = tui_modules.cli_tui_browser.BrowserScreen(
|
||||
snapshot,
|
||||
env='dev',
|
||||
active_view='jobs',
|
||||
navigation_items=tui_modules.cli_tui_app.NAVIGATION_ITEMS,
|
||||
refreshed_at='2026-04-30 10:00:00',
|
||||
)
|
||||
|
||||
recorded_queries: list[tuple[str, str]] = []
|
||||
refresh_calls: list[str] = []
|
||||
fake_app = SimpleNamespace(
|
||||
remember_browser_query=lambda view_key, query: recorded_queries.append((view_key, query)),
|
||||
action_refresh_current_view=lambda: refresh_calls.append('refresh'),
|
||||
)
|
||||
monkeypatch.setattr(tui_modules.cli_tui_browser.BrowserScreen, 'app', property(lambda self: fake_app))
|
||||
|
||||
screen.action_clear_search()
|
||||
|
||||
assert recorded_queries == [('jobs', '')]
|
||||
assert refresh_calls == ['refresh']
|
||||
|
||||
|
||||
def test_browser_screen_unmount_cancels_background_tasks(tui_modules: SimpleNamespace) -> None:
|
||||
snapshot = tui_modules.cli_tui_adapters.BrowserPageSnapshot(
|
||||
title='缓存',
|
||||
subtitle='subtitle',
|
||||
records=[],
|
||||
shared_sections=[],
|
||||
)
|
||||
screen = tui_modules.cli_tui_browser.BrowserScreen(
|
||||
snapshot,
|
||||
env='dev',
|
||||
active_view='cache',
|
||||
navigation_items=tui_modules.cli_tui_app.NAVIGATION_ITEMS,
|
||||
refreshed_at='2026-04-30 10:00:00',
|
||||
)
|
||||
|
||||
cancelled: list[str] = []
|
||||
|
||||
class DummyTask:
|
||||
def __init__(self, name: str) -> None:
|
||||
self.name = name
|
||||
|
||||
def done(self) -> bool:
|
||||
return False
|
||||
|
||||
def cancel(self) -> None:
|
||||
cancelled.append(self.name)
|
||||
|
||||
screen._record_detail_task = DummyTask('detail') # type: ignore[assignment]
|
||||
screen._action_task = DummyTask('action') # type: ignore[assignment]
|
||||
request_id = screen._record_detail_request_id
|
||||
|
||||
screen.on_unmount()
|
||||
|
||||
assert cancelled == ['detail', 'action']
|
||||
assert screen._record_detail_request_id == request_id + 1
|
||||
|
||||
|
||||
def test_browser_screen_failure_sections_use_current_snapshot_title(tui_modules: SimpleNamespace) -> None:
|
||||
snapshot = tui_modules.cli_tui_adapters.BrowserPageSnapshot(
|
||||
title='缓存',
|
||||
subtitle='subtitle',
|
||||
records=[],
|
||||
shared_sections=[],
|
||||
)
|
||||
screen = tui_modules.cli_tui_browser.BrowserScreen(
|
||||
snapshot,
|
||||
env='dev',
|
||||
active_view='cache',
|
||||
navigation_items=tui_modules.cli_tui_app.NAVIGATION_ITEMS,
|
||||
refreshed_at='2026-04-30 10:00:00',
|
||||
)
|
||||
|
||||
sections = screen._build_detail_load_failure_sections(RuntimeError('boom'))
|
||||
|
||||
assert sections[0].title == '缓存详情加载失败'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_browser_screen_scrolls_in_small_viewport(tui_modules: SimpleNamespace) -> None:
|
||||
thin_scrollbar_size = 1
|
||||
app = tui_modules.cli_tui_app.ShierfengTuiApp('dev')
|
||||
app.action_show_dashboard = lambda: None # type: ignore[method-assign]
|
||||
app.current_view = 'jobs'
|
||||
snapshot = tui_modules.cli_tui_adapters.BrowserPageSnapshot(
|
||||
title='任务',
|
||||
subtitle='超长浏览',
|
||||
records=[
|
||||
tui_modules.cli_tui_adapters.BrowserRecordSnapshot(
|
||||
key='job:1',
|
||||
title='任务一',
|
||||
status='warn',
|
||||
summary='摘要很长 摘要很长 摘要很长',
|
||||
metadata_lines=[f'字段{i}: 值很长 值很长 值很长' for i in range(1, 18)],
|
||||
detail_sections=[
|
||||
tui_modules.cli_tui_adapters.DetailSectionSnapshot(
|
||||
title='明细分区',
|
||||
status='warn',
|
||||
lines=[f'明细{i}: 说明很长 说明很长 说明很长' for i in range(1, 30)],
|
||||
)
|
||||
],
|
||||
)
|
||||
for _ in range(6)
|
||||
],
|
||||
shared_sections=[],
|
||||
)
|
||||
screen = app.screen_factory.build(
|
||||
snapshot=snapshot,
|
||||
env=app.env,
|
||||
active_view=app.current_view,
|
||||
navigation_items=tui_modules.cli_tui_app.NAVIGATION_ITEMS,
|
||||
action_feedback_lines=app.get_action_feedback_lines(app.current_view),
|
||||
)
|
||||
|
||||
async with app.run_test(size=(80, 24)) as pilot:
|
||||
app.screen_navigator.show(screen)
|
||||
await pilot.pause()
|
||||
await pilot.pause()
|
||||
|
||||
main = app.screen.query_one('#workspace-main')
|
||||
browser_body = app.screen.query_one('#browser-body')
|
||||
browser_detail_pane = app.screen.query_one('#browser-detail-pane')
|
||||
record_navigator = app.screen.query_one(tui_modules.cli_tui_widgets.RecordNavigator)
|
||||
record_summary = app.screen.query_one(tui_modules.cli_tui_widgets.RecordSummaryView)
|
||||
section_detail = app.screen.query_one(tui_modules.cli_tui_widgets.SectionDetailView)
|
||||
|
||||
assert main.styles.scrollbar_size_vertical == thin_scrollbar_size
|
||||
assert main.styles.scrollbar_size_horizontal == thin_scrollbar_size
|
||||
assert main.virtual_size.height > main.size.height
|
||||
assert browser_body.styles.scrollbar_size_vertical == thin_scrollbar_size
|
||||
assert browser_body.styles.scrollbar_size_horizontal == thin_scrollbar_size
|
||||
assert browser_body.virtual_size.width > browser_body.size.width
|
||||
assert browser_detail_pane.styles.scrollbar_size_vertical == thin_scrollbar_size
|
||||
assert browser_detail_pane.styles.scrollbar_size_horizontal == thin_scrollbar_size
|
||||
assert browser_detail_pane.virtual_size.height > browser_detail_pane.size.height
|
||||
assert record_navigator.styles.scrollbar_size_vertical == thin_scrollbar_size
|
||||
assert record_navigator.styles.scrollbar_size_horizontal == thin_scrollbar_size
|
||||
assert record_summary.styles.scrollbar_size_vertical == thin_scrollbar_size
|
||||
assert record_summary.styles.scrollbar_size_horizontal == thin_scrollbar_size
|
||||
assert section_detail.styles.scrollbar_size_vertical == thin_scrollbar_size
|
||||
assert section_detail.styles.scrollbar_size_horizontal == thin_scrollbar_size
|
||||
@@ -0,0 +1,490 @@
|
||||
import importlib
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
import typer
|
||||
from pytest import MonkeyPatch
|
||||
from typer.testing import CliRunner
|
||||
|
||||
|
||||
def test_tui_command_returns_dependency_error_when_textual_missing(
|
||||
monkeypatch: MonkeyPatch,
|
||||
tui_base_modules: SimpleNamespace,
|
||||
) -> None:
|
||||
runner = CliRunner()
|
||||
monkeypatch.chdir(tui_base_modules.BACKEND_DIR)
|
||||
|
||||
def raise_missing_textual(module_name: str) -> Any:
|
||||
if module_name == 'cli.tui.app':
|
||||
raise ModuleNotFoundError("No module named 'textual'", name='textual')
|
||||
return importlib.import_module(module_name)
|
||||
|
||||
monkeypatch.setattr(tui_base_modules.cli_tui_commands, 'import_module', raise_missing_textual)
|
||||
isolated_cli = typer.Typer()
|
||||
tui_base_modules.cli_tui_commands.TUI_COMMAND_REGISTRATION.register(isolated_cli)
|
||||
|
||||
result = runner.invoke(isolated_cli, ['tui', '--env=dev'])
|
||||
|
||||
assert result.exit_code == tui_base_modules.DEPENDENCY_ERROR
|
||||
assert '当前环境未安装 TUI 可选依赖' in result.stdout
|
||||
assert 'pip install -r requirements.txt' in result.stdout
|
||||
|
||||
|
||||
def test_tui_app_mount_pushes_first_workspace_screen(
|
||||
monkeypatch: MonkeyPatch,
|
||||
tui_modules: SimpleNamespace,
|
||||
) -> None:
|
||||
recorded_calls: list[tuple[str, object]] = []
|
||||
|
||||
class FakeDashboardScreen:
|
||||
def __init__(
|
||||
self,
|
||||
snapshot: object,
|
||||
env: str,
|
||||
active_view: str,
|
||||
navigation_items: list[object],
|
||||
refreshed_at: str,
|
||||
) -> None:
|
||||
self.snapshot = snapshot
|
||||
self.env = env
|
||||
self.active_view = active_view
|
||||
self.navigation_items = navigation_items
|
||||
self.refreshed_at = refreshed_at
|
||||
|
||||
monkeypatch.setitem(
|
||||
tui_modules.cli_tui_app.TUI_SNAPSHOT_COLLECTOR_REGISTRY.collectors,
|
||||
'dashboard',
|
||||
lambda env: tui_modules.cli_tui_app.DashboardSnapshot(env=env, panels=[]),
|
||||
)
|
||||
monkeypatch.setattr(tui_modules.cli_tui_app, 'DashboardScreen', FakeDashboardScreen)
|
||||
|
||||
app = tui_modules.cli_tui_app.ShierfengTuiApp('dev')
|
||||
monkeypatch.setattr(app, 'push_screen', lambda screen: recorded_calls.append(('push', screen)))
|
||||
monkeypatch.setattr(app, 'switch_screen', lambda screen: recorded_calls.append(('switch', screen)))
|
||||
|
||||
app.on_mount()
|
||||
|
||||
assert recorded_calls
|
||||
assert recorded_calls[0][0] == 'push'
|
||||
assert isinstance(recorded_calls[0][1], FakeDashboardScreen)
|
||||
assert recorded_calls[0][1].env == 'dev'
|
||||
assert recorded_calls[0][1].active_view == 'dashboard'
|
||||
assert recorded_calls[0][1].navigation_items
|
||||
assert recorded_calls[0][1].refreshed_at
|
||||
assert app.current_view == 'dashboard'
|
||||
assert app.screen_navigator.initialized is True
|
||||
|
||||
|
||||
def test_open_view_switches_to_expected_action(tui_modules: SimpleNamespace) -> None:
|
||||
calls: list[str] = []
|
||||
app = tui_modules.cli_tui_app.ShierfengTuiApp('dev')
|
||||
|
||||
def fake_show_view(view_key: str) -> None:
|
||||
calls.append(tui_modules.cli_tui_app.TUI_VIEW_REGISTRY.resolve_view_key(view_key))
|
||||
|
||||
app.show_view = fake_show_view # type: ignore[method-assign]
|
||||
|
||||
app.open_view('jobs')
|
||||
app.open_view('app')
|
||||
app.open_view('ops')
|
||||
app.open_view('crypto')
|
||||
app.open_view('unknown')
|
||||
|
||||
assert calls == ['jobs', 'app', 'ops', 'crypto', 'dashboard']
|
||||
|
||||
|
||||
def test_workspace_header_render_includes_particle_line(tui_modules: SimpleNamespace) -> None:
|
||||
header = tui_modules.cli_tui_widgets.WorkspaceHeader('dev', 'dashboard')
|
||||
|
||||
rendered = header.render()
|
||||
rendered_text = rendered.plain
|
||||
|
||||
assert rendered.__class__.__name__ == 'Text'
|
||||
assert 'ShiErFeng 控制台' in rendered_text
|
||||
assert '环境 DEV · 页面 总览' in rendered_text
|
||||
assert any(symbol in rendered_text for symbol in ('█', '▓', '▒', '░', '·', '─'))
|
||||
|
||||
|
||||
def test_workspace_header_centers_title_using_runtime_width(tui_modules: SimpleNamespace) -> None:
|
||||
header = tui_modules.cli_tui_widgets.WorkspaceHeader('dev', 'dashboard')
|
||||
header.styles.width = 140
|
||||
|
||||
first_line = header.render().plain.splitlines()[0]
|
||||
|
||||
assert first_line.startswith(' ')
|
||||
assert 'ShiErFeng 控制台' in first_line
|
||||
|
||||
|
||||
def test_workspace_header_keeps_title_alignment_stable_across_frames(
|
||||
monkeypatch: MonkeyPatch,
|
||||
tui_modules: SimpleNamespace,
|
||||
) -> None:
|
||||
header = tui_modules.cli_tui_widgets.WorkspaceHeader('dev', 'dashboard')
|
||||
monotonic_values = iter([10.0, 10.0, 11.0, 11.0])
|
||||
workspace_module = importlib.import_module('cli.tui.widgets.workspace')
|
||||
monkeypatch.setattr(workspace_module, 'monotonic', lambda: next(monotonic_values))
|
||||
|
||||
first_line_a = header.render().plain.splitlines()[0]
|
||||
first_line_b = header.render().plain.splitlines()[0]
|
||||
|
||||
assert first_line_a.index('ShiErFeng 控制台') == first_line_b.index('ShiErFeng 控制台')
|
||||
|
||||
|
||||
def test_workspace_hero_pulses_border_with_expected_palette(
|
||||
monkeypatch: MonkeyPatch,
|
||||
tui_modules: SimpleNamespace,
|
||||
) -> None:
|
||||
hero = tui_modules.cli_tui_widgets.WorkspaceHero(
|
||||
title='标题',
|
||||
subtitle='副标题',
|
||||
env='dev',
|
||||
active_view='dashboard',
|
||||
summary='摘要',
|
||||
refreshed_at='2026-05-12 12:00:00',
|
||||
)
|
||||
workspace_module = importlib.import_module('cli.tui.widgets.workspace')
|
||||
monkeypatch.setattr(workspace_module, 'monotonic', lambda: 10.0)
|
||||
original_border_text = str(hero.styles.border)
|
||||
|
||||
hero._pulse_border()
|
||||
|
||||
border_text = str(hero.styles.border)
|
||||
|
||||
assert 'double' in border_text
|
||||
assert border_text != original_border_text
|
||||
|
||||
|
||||
def test_workspace_hero_render_uses_rich_text_for_title_glow(tui_modules: SimpleNamespace) -> None:
|
||||
hero = tui_modules.cli_tui_widgets.WorkspaceHero(
|
||||
title='标题',
|
||||
subtitle='副标题',
|
||||
env='dev',
|
||||
active_view='dashboard',
|
||||
summary='摘要',
|
||||
refreshed_at='2026-05-12 12:00:00',
|
||||
)
|
||||
|
||||
rendered = hero._build_render_text()
|
||||
|
||||
assert rendered.__class__.__name__ == 'Text'
|
||||
assert '标题' in rendered.plain
|
||||
assert '副标题' in rendered.plain
|
||||
assert '运行摘要 · 摘要' in rendered.plain
|
||||
|
||||
|
||||
def test_tui_app_runner_runs_created_application(tui_modules: SimpleNamespace) -> None:
|
||||
recorded_envs: list[str] = []
|
||||
recorded_runs: list[str] = []
|
||||
|
||||
class FakeApp:
|
||||
def __init__(self, env: str) -> None:
|
||||
recorded_envs.append(env)
|
||||
|
||||
def run(self) -> None:
|
||||
recorded_runs.append('run')
|
||||
|
||||
runner = tui_modules.cli_tui_app.TuiAppRunner(FakeApp)
|
||||
|
||||
runner.run('dev')
|
||||
|
||||
assert recorded_envs == ['dev']
|
||||
assert recorded_runs == ['run']
|
||||
|
||||
|
||||
def test_tui_app_show_jobs_uses_remembered_filter(
|
||||
monkeypatch: MonkeyPatch,
|
||||
tui_modules: SimpleNamespace,
|
||||
) -> None:
|
||||
recorded_filter_keys: list[tuple[str, str]] = []
|
||||
app = tui_modules.cli_tui_app.ShierfengTuiApp('dev')
|
||||
app.remember_browser_filter('jobs', 'failed')
|
||||
app.remember_browser_query('jobs', 'sync')
|
||||
monkeypatch.setitem(
|
||||
tui_modules.cli_tui_app.TUI_SNAPSHOT_COLLECTOR_REGISTRY.collectors,
|
||||
'jobs',
|
||||
lambda env, filter_key='all', query='': (
|
||||
recorded_filter_keys.append((filter_key, query))
|
||||
or tui_modules.cli_tui_app.BrowserPageSnapshot(
|
||||
title='任务',
|
||||
subtitle='subtitle',
|
||||
records=[],
|
||||
shared_sections=[],
|
||||
filters=[],
|
||||
active_filter_key=filter_key,
|
||||
search=None,
|
||||
)
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(app.screen_navigator, 'show', lambda screen: None)
|
||||
app.action_show_jobs()
|
||||
|
||||
assert recorded_filter_keys == [('failed', 'sync')]
|
||||
|
||||
|
||||
def test_tui_app_show_configs_uses_remembered_filter(
|
||||
monkeypatch: MonkeyPatch,
|
||||
tui_modules: SimpleNamespace,
|
||||
) -> None:
|
||||
recorded_filter_keys: list[tuple[str, str]] = []
|
||||
app = tui_modules.cli_tui_app.ShierfengTuiApp('dev')
|
||||
app.remember_browser_filter('configs', 'cache-drift')
|
||||
app.remember_browser_query('configs', 'site')
|
||||
monkeypatch.setitem(
|
||||
tui_modules.cli_tui_app.TUI_SNAPSHOT_COLLECTOR_REGISTRY.collectors,
|
||||
'configs',
|
||||
lambda env, filter_key='all', query='': (
|
||||
recorded_filter_keys.append((filter_key, query))
|
||||
or tui_modules.cli_tui_app.BrowserPageSnapshot(
|
||||
title='参数配置',
|
||||
subtitle='subtitle',
|
||||
records=[],
|
||||
shared_sections=[],
|
||||
filters=[],
|
||||
active_filter_key=filter_key,
|
||||
search=None,
|
||||
)
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(app.screen_navigator, 'show', lambda screen: None)
|
||||
app.action_show_configs()
|
||||
|
||||
assert recorded_filter_keys == [('cache-drift', 'site')]
|
||||
|
||||
|
||||
def test_tui_app_show_cache_and_gen_use_remembered_query(
|
||||
monkeypatch: MonkeyPatch,
|
||||
tui_modules: SimpleNamespace,
|
||||
) -> None:
|
||||
cache_queries: list[str] = []
|
||||
gen_queries: list[str] = []
|
||||
app = tui_modules.cli_tui_app.ShierfengTuiApp('dev')
|
||||
app.remember_browser_query('cache', 'sys')
|
||||
app.remember_browser_query('gen', 'user')
|
||||
monkeypatch.setitem(
|
||||
tui_modules.cli_tui_app.TUI_SNAPSHOT_COLLECTOR_REGISTRY.collectors,
|
||||
'cache',
|
||||
lambda env, query='': (
|
||||
cache_queries.append(query)
|
||||
or tui_modules.cli_tui_app.BrowserPageSnapshot(
|
||||
title='缓存',
|
||||
subtitle='subtitle',
|
||||
records=[],
|
||||
shared_sections=[],
|
||||
filters=[],
|
||||
active_filter_key=None,
|
||||
search=None,
|
||||
)
|
||||
),
|
||||
)
|
||||
monkeypatch.setitem(
|
||||
tui_modules.cli_tui_app.TUI_SNAPSHOT_COLLECTOR_REGISTRY.collectors,
|
||||
'gen',
|
||||
lambda env, query='': (
|
||||
gen_queries.append(query)
|
||||
or tui_modules.cli_tui_app.BrowserPageSnapshot(
|
||||
title='代码生成',
|
||||
subtitle='subtitle',
|
||||
records=[],
|
||||
shared_sections=[],
|
||||
filters=[],
|
||||
active_filter_key=None,
|
||||
search=None,
|
||||
)
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(app.screen_navigator, 'show', lambda screen: None)
|
||||
app.action_show_cache()
|
||||
app.action_show_gen()
|
||||
|
||||
assert cache_queries == ['sys']
|
||||
assert gen_queries == ['user']
|
||||
|
||||
|
||||
def test_tui_app_detail_views_use_remembered_query(
|
||||
monkeypatch: MonkeyPatch,
|
||||
tui_modules: SimpleNamespace,
|
||||
) -> None:
|
||||
database_queries: list[str] = []
|
||||
app_queries: list[str] = []
|
||||
ops_queries: list[str] = []
|
||||
crypto_queries: list[str] = []
|
||||
app = tui_modules.cli_tui_app.ShierfengTuiApp('dev')
|
||||
app.remember_browser_query('database', 'head')
|
||||
app.remember_browser_query('app', 'route')
|
||||
app.remember_browser_query('ops', 'disk')
|
||||
app.remember_browser_query('crypto', 'kid')
|
||||
monkeypatch.setitem(
|
||||
tui_modules.cli_tui_app.TUI_SNAPSHOT_COLLECTOR_REGISTRY.collectors,
|
||||
'database',
|
||||
lambda env, query='': (
|
||||
database_queries.append(query)
|
||||
or tui_modules.cli_tui_app.DetailPageSnapshot(title='数据库', subtitle='subtitle', sections=[])
|
||||
),
|
||||
)
|
||||
monkeypatch.setitem(
|
||||
tui_modules.cli_tui_app.TUI_SNAPSHOT_COLLECTOR_REGISTRY.collectors,
|
||||
'app',
|
||||
lambda env, query='': (
|
||||
app_queries.append(query)
|
||||
or tui_modules.cli_tui_app.DetailPageSnapshot(title='应用', subtitle='subtitle', sections=[])
|
||||
),
|
||||
)
|
||||
monkeypatch.setitem(
|
||||
tui_modules.cli_tui_app.TUI_SNAPSHOT_COLLECTOR_REGISTRY.collectors,
|
||||
'ops',
|
||||
lambda env, query='': (
|
||||
ops_queries.append(query)
|
||||
or tui_modules.cli_tui_app.DetailPageSnapshot(title='运维', subtitle='subtitle', sections=[])
|
||||
),
|
||||
)
|
||||
monkeypatch.setitem(
|
||||
tui_modules.cli_tui_app.TUI_SNAPSHOT_COLLECTOR_REGISTRY.collectors,
|
||||
'crypto',
|
||||
lambda env, query='': (
|
||||
crypto_queries.append(query)
|
||||
or tui_modules.cli_tui_app.DetailPageSnapshot(title='传输加密', subtitle='subtitle', sections=[])
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(app.screen_navigator, 'show', lambda screen: None)
|
||||
app.action_show_database()
|
||||
app.action_show_app()
|
||||
app.action_show_ops()
|
||||
app.action_show_crypto()
|
||||
|
||||
assert database_queries == ['head']
|
||||
assert app_queries == ['route']
|
||||
assert ops_queries == ['disk']
|
||||
assert crypto_queries == ['kid']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tui_app_open_view_updates_screen_across_multiple_switches(
|
||||
monkeypatch: MonkeyPatch,
|
||||
tui_modules: SimpleNamespace,
|
||||
) -> None:
|
||||
monkeypatch.setitem(
|
||||
tui_modules.cli_tui_app.TUI_SNAPSHOT_COLLECTOR_REGISTRY.collectors,
|
||||
'dashboard',
|
||||
lambda env: tui_modules.cli_tui_app.DashboardSnapshot(env=env, panels=[]),
|
||||
)
|
||||
monkeypatch.setitem(
|
||||
tui_modules.cli_tui_app.TUI_SNAPSHOT_COLLECTOR_REGISTRY.collectors,
|
||||
'app',
|
||||
lambda env, query='': tui_modules.cli_tui_app.DetailPageSnapshot(
|
||||
title='应用详情',
|
||||
subtitle=query or 'app-subtitle',
|
||||
sections=[
|
||||
tui_modules.cli_tui_adapters.DetailSectionSnapshot(
|
||||
title='应用分区',
|
||||
status='ok',
|
||||
lines=['app-line'],
|
||||
)
|
||||
],
|
||||
),
|
||||
)
|
||||
monkeypatch.setitem(
|
||||
tui_modules.cli_tui_app.TUI_SNAPSHOT_COLLECTOR_REGISTRY.collectors,
|
||||
'ops',
|
||||
lambda env, query='': tui_modules.cli_tui_app.DetailPageSnapshot(
|
||||
title='运维详情',
|
||||
subtitle=query or 'ops-subtitle',
|
||||
sections=[
|
||||
tui_modules.cli_tui_adapters.DetailSectionSnapshot(
|
||||
title='运维分区',
|
||||
status='warn',
|
||||
lines=['ops-line'],
|
||||
)
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
app = tui_modules.cli_tui_app.ShierfengTuiApp('dev')
|
||||
async with app.run_test(size=(120, 40)) as pilot:
|
||||
await pilot.pause()
|
||||
await pilot.pause()
|
||||
|
||||
assert type(app.screen).__name__ == 'DashboardScreen'
|
||||
assert app.current_view == 'dashboard'
|
||||
|
||||
app.open_view('app')
|
||||
await pilot.pause()
|
||||
await pilot.pause()
|
||||
|
||||
assert type(app.screen).__name__ == 'DetailScreen'
|
||||
assert app.current_view == 'app'
|
||||
assert app.screen.snapshot.title == '应用详情'
|
||||
|
||||
app.open_view('ops')
|
||||
await pilot.pause()
|
||||
await pilot.pause()
|
||||
|
||||
assert type(app.screen).__name__ == 'DetailScreen'
|
||||
assert app.current_view == 'ops'
|
||||
assert app.screen.snapshot.title == '运维详情'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tui_sidebar_highlight_updates_screen_across_multiple_switches(
|
||||
monkeypatch: MonkeyPatch,
|
||||
tui_modules: SimpleNamespace,
|
||||
) -> None:
|
||||
monkeypatch.setitem(
|
||||
tui_modules.cli_tui_app.TUI_SNAPSHOT_COLLECTOR_REGISTRY.collectors,
|
||||
'dashboard',
|
||||
lambda env: tui_modules.cli_tui_app.DashboardSnapshot(env=env, panels=[]),
|
||||
)
|
||||
monkeypatch.setitem(
|
||||
tui_modules.cli_tui_app.TUI_SNAPSHOT_COLLECTOR_REGISTRY.collectors,
|
||||
'app',
|
||||
lambda env, query='': tui_modules.cli_tui_app.DetailPageSnapshot(
|
||||
title='应用详情',
|
||||
subtitle='app-subtitle',
|
||||
sections=[
|
||||
tui_modules.cli_tui_adapters.DetailSectionSnapshot(
|
||||
title='应用分区',
|
||||
status='ok',
|
||||
lines=['app-line'],
|
||||
)
|
||||
],
|
||||
),
|
||||
)
|
||||
monkeypatch.setitem(
|
||||
tui_modules.cli_tui_app.TUI_SNAPSHOT_COLLECTOR_REGISTRY.collectors,
|
||||
'ops',
|
||||
lambda env, query='': tui_modules.cli_tui_app.DetailPageSnapshot(
|
||||
title='运维详情',
|
||||
subtitle='ops-subtitle',
|
||||
sections=[
|
||||
tui_modules.cli_tui_adapters.DetailSectionSnapshot(
|
||||
title='运维分区',
|
||||
status='warn',
|
||||
lines=['ops-line'],
|
||||
)
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
app = tui_modules.cli_tui_app.ShierfengTuiApp('dev')
|
||||
async with app.run_test(size=(120, 40)) as pilot:
|
||||
await pilot.pause()
|
||||
await pilot.pause()
|
||||
|
||||
assert type(app.screen).__name__ == 'DashboardScreen'
|
||||
assert app.current_view == 'dashboard'
|
||||
|
||||
await pilot.press('down')
|
||||
await pilot.pause()
|
||||
await pilot.pause()
|
||||
|
||||
assert type(app.screen).__name__ == 'DetailScreen'
|
||||
assert app.current_view == 'app'
|
||||
assert app.screen.snapshot.title == '应用详情'
|
||||
|
||||
await pilot.press('down')
|
||||
await pilot.pause()
|
||||
await pilot.pause()
|
||||
|
||||
assert type(app.screen).__name__ == 'DetailScreen'
|
||||
assert app.current_view == 'ops'
|
||||
assert app.screen.snapshot.title == '运维详情'
|
||||
@@ -0,0 +1,192 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_build_filter_bar_text_includes_search_context(tui_modules: SimpleNamespace) -> None:
|
||||
text = tui_modules.cli_tui_search.TUI_SEARCH_SERVICE.build_filter_bar_text(
|
||||
tui_modules.cli_tui_search.JOB_FILTER_OPTIONS,
|
||||
'failed',
|
||||
search_query='sync',
|
||||
search_placeholder='按任务名搜索',
|
||||
search_suggestions=['sync-job', 'sync-user'],
|
||||
)
|
||||
|
||||
assert '当前筛选 · 失败' in text
|
||||
assert '搜索器 · [/] 按任务名搜索' in text
|
||||
assert '当前搜索 · sync | [Backspace] 清空' in text
|
||||
assert '候选建议 · sync-job sync-user' in text
|
||||
|
||||
|
||||
def test_tui_search_service_builds_filter_context_and_section_filtering(
|
||||
tui_modules: SimpleNamespace,
|
||||
) -> None:
|
||||
service = tui_modules.cli_tui_search.TuiSearchService(
|
||||
tui_modules.cli_tui_search.TuiSearchSuggestionProviderRegistry(
|
||||
providers={
|
||||
'jobs': tui_modules.cli_tui_search.SearchSuggestionProviderSpec(
|
||||
'按任务名搜索',
|
||||
lambda incomplete: ['sync-user', 'sync-role', 'cleanup'],
|
||||
),
|
||||
'app': tui_modules.cli_tui_search.SearchSuggestionProviderSpec('按分区或内容搜索'),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
option = service.resolve_filter_option(tui_modules.cli_tui_search.JOB_FILTER_OPTIONS, 'failed')
|
||||
filter_bar = service.build_filter_bar_text(
|
||||
tui_modules.cli_tui_search.JOB_FILTER_OPTIONS,
|
||||
'failed',
|
||||
search_query='sync',
|
||||
search_placeholder='按任务名搜索',
|
||||
search_suggestions=['sync-user', 'sync-role'],
|
||||
)
|
||||
search_context = service.resolve_search_context('jobs', 'sync')
|
||||
default_context = service.resolve_search_context('app', '总览', default_suggestions=['总览判断', '应用配置'])
|
||||
sections = service.filter_detail_sections(
|
||||
[
|
||||
tui_modules.cli_tui_adapters.DetailSectionSnapshot(title='总览判断', status='ok', lines=['应用正常']),
|
||||
tui_modules.cli_tui_adapters.DetailSectionSnapshot(title='路由摘要', status='warn', lines=['同步任务接口']),
|
||||
],
|
||||
'同步',
|
||||
)
|
||||
|
||||
assert option is not None
|
||||
assert option.key == 'failed'
|
||||
assert '当前筛选 · 失败' in filter_bar
|
||||
assert search_context is not None
|
||||
assert search_context.placeholder == '按任务名搜索'
|
||||
assert search_context.suggestions[:2] == ['sync-user', 'sync-role']
|
||||
assert default_context is not None
|
||||
assert default_context.suggestions == ['总览判断']
|
||||
assert [section.title for section in sections] == ['路由摘要']
|
||||
|
||||
|
||||
def test_tui_search_service_skips_provider_when_query_is_empty(
|
||||
tui_modules: SimpleNamespace,
|
||||
) -> None:
|
||||
provider_calls: list[str] = []
|
||||
|
||||
def record_provider(incomplete: str) -> list[str]:
|
||||
provider_calls.append(incomplete)
|
||||
return ['site_name']
|
||||
|
||||
service = tui_modules.cli_tui_search.TuiSearchService(
|
||||
tui_modules.cli_tui_search.TuiSearchSuggestionProviderRegistry(
|
||||
providers={
|
||||
'configs': tui_modules.cli_tui_search.SearchSuggestionProviderSpec(
|
||||
'按配置键搜索',
|
||||
record_provider,
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
search_context = service.resolve_search_context('configs', '')
|
||||
|
||||
assert search_context is not None
|
||||
assert search_context.placeholder == '按配置键搜索'
|
||||
assert search_context.suggestions == []
|
||||
assert provider_calls == []
|
||||
|
||||
|
||||
def test_search_highlight_helper_wraps_matches(tui_modules: SimpleNamespace) -> None:
|
||||
highlighted = tui_modules.cli_tui_search.TUI_SEARCH_HIGHLIGHTER.highlight('sync-user job', 'sync')
|
||||
|
||||
assert highlighted == '【sync】-user job'
|
||||
|
||||
|
||||
def test_dashboard_screen_support_builds_summary_signal_and_status_track(
|
||||
tui_modules: SimpleNamespace,
|
||||
) -> None:
|
||||
support = tui_modules.cli_tui_dashboard.DashboardScreenSupport()
|
||||
snapshot = tui_modules.cli_tui_app.DashboardSnapshot(
|
||||
env='dev',
|
||||
metrics=[],
|
||||
panels=[
|
||||
tui_modules.cli_tui_adapters.DashboardPanelSnapshot(title='应用', status='ok', lines=['ok']),
|
||||
tui_modules.cli_tui_adapters.DashboardPanelSnapshot(title='任务', status='warn', lines=['warn']),
|
||||
tui_modules.cli_tui_adapters.DashboardPanelSnapshot(title='数据库', status='fail', lines=['fail']),
|
||||
],
|
||||
)
|
||||
|
||||
summary = support.build_summary_text(snapshot)
|
||||
status_track = support.build_status_track(snapshot)
|
||||
signal_lines = support.build_signal_lines(snapshot)
|
||||
|
||||
assert '共 3 个面板' in summary
|
||||
assert status_track == 'o!x'
|
||||
assert any('失败 01' in line for line in signal_lines)
|
||||
|
||||
|
||||
def test_dashboard_screen_focus_service_moves_and_scrolls_between_focus_targets(
|
||||
tui_modules: SimpleNamespace,
|
||||
) -> None:
|
||||
focus_service = tui_modules.cli_tui_dashboard.DashboardScreenFocusService()
|
||||
focused: list[str] = []
|
||||
sidebar = SimpleNamespace(focus=lambda: focused.append('sidebar'))
|
||||
workspace_main = SimpleNamespace(focus=lambda: focused.append('main'))
|
||||
fake_app = SimpleNamespace(focused=None)
|
||||
fake_screen = SimpleNamespace(
|
||||
app=fake_app,
|
||||
query_one=lambda selector, widget_type=None: {
|
||||
tui_modules.cli_tui_widgets.WorkspaceSidebar: sidebar,
|
||||
'#workspace-main': workspace_main,
|
||||
}[selector],
|
||||
)
|
||||
|
||||
focus_service.move_focus(fake_screen, 1)
|
||||
fake_app.focused = sidebar
|
||||
focus_service.move_focus(fake_screen, 1)
|
||||
fake_app.focused = object()
|
||||
scroll_target = focus_service.get_scroll_target(fake_screen)
|
||||
|
||||
assert focused == ['sidebar', 'main']
|
||||
assert scroll_target is workspace_main
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dashboard_grid_expands_to_fit_status_panels(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tui_modules: SimpleNamespace,
|
||||
) -> None:
|
||||
expected_dashboard_panel_count = 8
|
||||
min_expanded_dashboard_grid_height = 20
|
||||
snapshot = tui_modules.cli_tui_app.DashboardSnapshot(
|
||||
env='dev',
|
||||
metrics=[],
|
||||
panels=[
|
||||
tui_modules.cli_tui_adapters.DashboardPanelSnapshot(
|
||||
title=f'面板 {index + 1}',
|
||||
status='ok' if index % 2 == 0 else 'warn',
|
||||
lines=[
|
||||
'## 巡检摘要',
|
||||
f'当前面板: {index + 1}',
|
||||
'状态说明: 这里需要展示多行内容',
|
||||
'',
|
||||
'## 建议动作',
|
||||
'进入对应分区继续查看详情',
|
||||
],
|
||||
)
|
||||
for index in range(8)
|
||||
],
|
||||
)
|
||||
|
||||
monkeypatch.setitem(
|
||||
tui_modules.cli_tui_app.TUI_SNAPSHOT_COLLECTOR_REGISTRY.collectors, 'dashboard', lambda env: snapshot
|
||||
)
|
||||
|
||||
app = tui_modules.cli_tui_app.ShierfengTuiApp('dev')
|
||||
async with app.run_test(size=(160, 60)) as pilot:
|
||||
await pilot.pause()
|
||||
await pilot.pause()
|
||||
|
||||
grid = app.screen.query_one('#dashboard-grid')
|
||||
panels = list(
|
||||
app.screen.query(tui_modules.cli_tui_widgets.StatusPanel).results(tui_modules.cli_tui_widgets.StatusPanel)
|
||||
)
|
||||
|
||||
assert len(panels) == expected_dashboard_panel_count
|
||||
assert grid.virtual_size.height > min_expanded_dashboard_grid_height
|
||||
assert all(panel.content_size.height > 0 for panel in panels)
|
||||
assert len({panel.region.height for panel in panels}) == 1
|
||||
@@ -0,0 +1,394 @@
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from pytest import MonkeyPatch
|
||||
|
||||
|
||||
def test_detail_screen_updates_section_detail_view(tui_modules: SimpleNamespace) -> None:
|
||||
snapshot = tui_modules.cli_tui_app.DetailPageSnapshot(
|
||||
title='Jobs',
|
||||
subtitle='subtitle',
|
||||
sections=[
|
||||
tui_modules.cli_tui_adapters.DetailSectionSnapshot(title='Section A', status='ok', lines=['line a']),
|
||||
tui_modules.cli_tui_adapters.DetailSectionSnapshot(title='Section B', status='warn', lines=['line b']),
|
||||
],
|
||||
)
|
||||
screen = tui_modules.cli_tui_detail.DetailScreen(
|
||||
snapshot,
|
||||
env='dev',
|
||||
active_view='jobs',
|
||||
navigation_items=tui_modules.cli_tui_app.NAVIGATION_ITEMS,
|
||||
refreshed_at='2026-04-30 10:00:00',
|
||||
)
|
||||
|
||||
recorded_sections: list[object] = []
|
||||
|
||||
class FakeSectionDetailView:
|
||||
def show_section(self, section: object, query: str = '') -> None:
|
||||
del query
|
||||
recorded_sections.append(section)
|
||||
|
||||
screen.query_one = lambda widget_type: FakeSectionDetailView() # type: ignore[method-assign]
|
||||
|
||||
screen._update_selected_section(1)
|
||||
|
||||
assert screen.selected_section_index == 1
|
||||
assert recorded_sections
|
||||
assert recorded_sections[0].title == 'Section B'
|
||||
|
||||
|
||||
def test_detail_screen_search_submit_remembers_and_refreshes(
|
||||
monkeypatch: MonkeyPatch,
|
||||
tui_modules: SimpleNamespace,
|
||||
) -> None:
|
||||
snapshot = tui_modules.cli_tui_app.DetailPageSnapshot(
|
||||
title='数据库',
|
||||
subtitle='subtitle',
|
||||
sections=[
|
||||
tui_modules.cli_tui_adapters.DetailSectionSnapshot(title='历史版本', status='ok', lines=['line a']),
|
||||
],
|
||||
search=tui_modules.cli_tui_search.PageSearchContext(
|
||||
placeholder='按 revision 搜索',
|
||||
query='',
|
||||
suggestions=['head', 'base'],
|
||||
),
|
||||
)
|
||||
screen = tui_modules.cli_tui_detail.DetailScreen(
|
||||
snapshot,
|
||||
env='dev',
|
||||
active_view='database',
|
||||
navigation_items=tui_modules.cli_tui_app.NAVIGATION_ITEMS,
|
||||
refreshed_at='2026-04-30 10:00:00',
|
||||
)
|
||||
|
||||
recorded_queries: list[tuple[str, str]] = []
|
||||
refresh_calls: list[str] = []
|
||||
fake_app = SimpleNamespace(
|
||||
remember_browser_query=lambda view_key, query: recorded_queries.append((view_key, query)),
|
||||
action_refresh_current_view=lambda: refresh_calls.append('refresh'),
|
||||
)
|
||||
monkeypatch.setattr(tui_modules.cli_tui_detail.DetailScreen, 'app', property(lambda self: fake_app))
|
||||
|
||||
screen._handle_search_submitted('head')
|
||||
|
||||
assert recorded_queries == [('database', 'head')]
|
||||
assert refresh_calls == ['refresh']
|
||||
|
||||
|
||||
def test_detail_screen_clear_search_remembers_empty_and_refreshes(
|
||||
monkeypatch: MonkeyPatch,
|
||||
tui_modules: SimpleNamespace,
|
||||
) -> None:
|
||||
snapshot = tui_modules.cli_tui_app.DetailPageSnapshot(
|
||||
title='数据库',
|
||||
subtitle='subtitle',
|
||||
sections=[
|
||||
tui_modules.cli_tui_adapters.DetailSectionSnapshot(title='历史版本', status='ok', lines=['line a']),
|
||||
],
|
||||
search=tui_modules.cli_tui_search.PageSearchContext(
|
||||
placeholder='按 revision 搜索',
|
||||
query='head',
|
||||
suggestions=['head', 'base'],
|
||||
),
|
||||
)
|
||||
screen = tui_modules.cli_tui_detail.DetailScreen(
|
||||
snapshot,
|
||||
env='dev',
|
||||
active_view='database',
|
||||
navigation_items=tui_modules.cli_tui_app.NAVIGATION_ITEMS,
|
||||
refreshed_at='2026-04-30 10:00:00',
|
||||
)
|
||||
|
||||
recorded_queries: list[tuple[str, str]] = []
|
||||
refresh_calls: list[str] = []
|
||||
fake_app = SimpleNamespace(
|
||||
remember_browser_query=lambda view_key, query: recorded_queries.append((view_key, query)),
|
||||
action_refresh_current_view=lambda: refresh_calls.append('refresh'),
|
||||
)
|
||||
monkeypatch.setattr(tui_modules.cli_tui_detail.DetailScreen, 'app', property(lambda self: fake_app))
|
||||
|
||||
screen.action_clear_search()
|
||||
|
||||
assert recorded_queries == [('database', '')]
|
||||
assert refresh_calls == ['refresh']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_detail_screen_execute_external_action_suspends_app(
|
||||
monkeypatch: MonkeyPatch,
|
||||
tui_modules: SimpleNamespace,
|
||||
) -> None:
|
||||
snapshot = tui_modules.cli_tui_app.DetailPageSnapshot(
|
||||
title='数据库',
|
||||
subtitle='subtitle',
|
||||
sections=[
|
||||
tui_modules.cli_tui_adapters.DetailSectionSnapshot(title='迁移版本', status='ok', lines=['line a']),
|
||||
],
|
||||
)
|
||||
screen = tui_modules.cli_tui_detail.DetailScreen(
|
||||
snapshot,
|
||||
env='dev',
|
||||
active_view='database',
|
||||
navigation_items=tui_modules.cli_tui_app.NAVIGATION_ITEMS,
|
||||
refreshed_at='2026-04-30 10:00:00',
|
||||
)
|
||||
action = tui_modules.cli_tui_detail.TUI_ACTION_REGISTRY.resolve_detail_action(
|
||||
view_key='database',
|
||||
slot='global',
|
||||
env='dev',
|
||||
)
|
||||
assert action is not None
|
||||
|
||||
recorded_suspend: list[str] = []
|
||||
|
||||
class DummySuspend:
|
||||
def __enter__(self) -> None:
|
||||
recorded_suspend.append('enter')
|
||||
|
||||
def __exit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc: BaseException | None,
|
||||
tb: Any,
|
||||
) -> None:
|
||||
del exc_type, exc, tb
|
||||
recorded_suspend.append('exit')
|
||||
|
||||
def build_suspend() -> DummySuspend:
|
||||
return DummySuspend()
|
||||
|
||||
def refresh_current_view() -> None:
|
||||
pass
|
||||
|
||||
fake_app = SimpleNamespace(
|
||||
suspend=build_suspend,
|
||||
action_refresh_current_view=refresh_current_view,
|
||||
)
|
||||
monkeypatch.setattr(tui_modules.cli_tui_detail.DetailScreen, 'app', property(lambda self: fake_app))
|
||||
monkeypatch.setattr(
|
||||
tui_modules.cli_tui_interactions,
|
||||
'TUI_ACTION_EXECUTION_SERVICE',
|
||||
SimpleNamespace(
|
||||
execute_external=lambda spec: tui_modules.cli_tui_detail.TuiActionResult(
|
||||
spec=spec,
|
||||
external_exit_code=0,
|
||||
external_message='外部交互命令已执行完成',
|
||||
),
|
||||
build_result_lines=lambda result: [],
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(screen, 'notify', lambda *args, **kwargs: None)
|
||||
|
||||
await screen._execute_action(action)
|
||||
|
||||
assert recorded_suspend == ['enter', 'exit']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_detail_screen_execute_nested_json_action_without_suspend(
|
||||
monkeypatch: MonkeyPatch,
|
||||
tui_modules: SimpleNamespace,
|
||||
) -> None:
|
||||
snapshot = tui_modules.cli_tui_app.DetailPageSnapshot(
|
||||
title='传输加密',
|
||||
subtitle='subtitle',
|
||||
sections=[
|
||||
tui_modules.cli_tui_adapters.DetailSectionSnapshot(title='运行校验', status='ok', lines=['line a']),
|
||||
],
|
||||
)
|
||||
screen = tui_modules.cli_tui_detail.DetailScreen(
|
||||
snapshot,
|
||||
env='dev',
|
||||
active_view='crypto',
|
||||
navigation_items=tui_modules.cli_tui_app.NAVIGATION_ITEMS,
|
||||
refreshed_at='2026-04-30 10:00:00',
|
||||
)
|
||||
action = tui_modules.cli_tui_detail.TUI_ACTION_REGISTRY.resolve_detail_action(
|
||||
view_key='crypto',
|
||||
slot='global',
|
||||
env='dev',
|
||||
)
|
||||
assert action is not None
|
||||
assert action.execution_mode == 'nested_json'
|
||||
|
||||
suspend_calls: list[str] = []
|
||||
execute_calls: list[tuple[object, str]] = []
|
||||
|
||||
class DummySuspend:
|
||||
def __enter__(self) -> None:
|
||||
suspend_calls.append('enter')
|
||||
|
||||
def __exit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc: BaseException | None,
|
||||
tb: Any,
|
||||
) -> None:
|
||||
del exc_type, exc, tb
|
||||
suspend_calls.append('exit')
|
||||
|
||||
def build_suspend() -> DummySuspend:
|
||||
return DummySuspend()
|
||||
|
||||
def refresh_current_view() -> None:
|
||||
pass
|
||||
|
||||
fake_app = SimpleNamespace(
|
||||
suspend=build_suspend,
|
||||
action_refresh_current_view=refresh_current_view,
|
||||
)
|
||||
monkeypatch.setattr(tui_modules.cli_tui_detail.DetailScreen, 'app', property(lambda self: fake_app))
|
||||
monkeypatch.setattr(
|
||||
tui_modules.cli_tui_interactions,
|
||||
'TUI_ACTION_EXECUTION_SERVICE',
|
||||
SimpleNamespace(
|
||||
execute=lambda spec, env: (
|
||||
execute_calls.append((spec, env))
|
||||
or tui_modules.cli_tui_detail.TuiActionResult(
|
||||
spec=spec,
|
||||
payload={'ok': True, 'message': '轮换预演完成'},
|
||||
)
|
||||
),
|
||||
build_result_lines=lambda result: [],
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(screen, 'notify', lambda *args, **kwargs: None)
|
||||
|
||||
await screen._execute_action(action)
|
||||
|
||||
assert suspend_calls == []
|
||||
assert execute_calls == [(action, 'dev')]
|
||||
|
||||
|
||||
def test_detail_screen_support_builds_summary_actions_and_fallbacks(
|
||||
tui_modules: SimpleNamespace,
|
||||
) -> None:
|
||||
support = tui_modules.cli_tui_detail.DetailScreenSupport()
|
||||
snapshot = tui_modules.cli_tui_app.DetailPageSnapshot(
|
||||
title='数据库',
|
||||
subtitle='subtitle',
|
||||
sections=[
|
||||
tui_modules.cli_tui_adapters.DetailSectionSnapshot(title='迁移版本', status='ok', lines=['line a']),
|
||||
tui_modules.cli_tui_adapters.DetailSectionSnapshot(title='Heads 状态', status='warn', lines=['line b']),
|
||||
],
|
||||
search=tui_modules.cli_tui_search.PageSearchContext(
|
||||
placeholder='按 revision 搜索',
|
||||
query='head',
|
||||
suggestions=['head'],
|
||||
),
|
||||
)
|
||||
|
||||
summary = support.build_summary_text(snapshot, 'database')
|
||||
query = support.current_search_query(snapshot)
|
||||
action = support.resolve_action(active_view='database', slot='global', env='dev')
|
||||
empty_section = support.build_empty_section()
|
||||
|
||||
assert '2 个分区' in summary
|
||||
assert query == 'head'
|
||||
assert action is not None
|
||||
assert action.command_args[:2] == ('wizard', 'db-upgrade')
|
||||
assert empty_section.title == tui_modules.cli_tui_copy.TUI_COPY.build_detail_empty_section_copy('title')
|
||||
|
||||
|
||||
def test_detail_screen_focus_service_moves_and_scrolls_between_focus_targets(
|
||||
tui_modules: SimpleNamespace,
|
||||
) -> None:
|
||||
focus_service = tui_modules.cli_tui_detail.DetailScreenFocusService()
|
||||
focused: list[str] = []
|
||||
sidebar = SimpleNamespace(focus=lambda: focused.append('sidebar'))
|
||||
sections = SimpleNamespace(focus=lambda: focused.append('sections'))
|
||||
detail = SimpleNamespace(focus=lambda: focused.append('detail'))
|
||||
workspace_main = object()
|
||||
fake_app = SimpleNamespace(focused=None)
|
||||
fake_screen = SimpleNamespace(
|
||||
app=fake_app,
|
||||
query_one=lambda selector, widget_type=None: {
|
||||
tui_modules.cli_tui_widgets.WorkspaceSidebar: sidebar,
|
||||
tui_modules.cli_tui_widgets.SectionNavigator: sections,
|
||||
tui_modules.cli_tui_widgets.SectionDetailView: detail,
|
||||
'#workspace-main': workspace_main,
|
||||
}[selector],
|
||||
)
|
||||
|
||||
focus_service.move_focus(fake_screen, 1)
|
||||
fake_app.focused = sections
|
||||
focus_service.move_focus(fake_screen, 1)
|
||||
fake_app.focused = object()
|
||||
scroll_target = focus_service.get_scroll_target(fake_screen)
|
||||
|
||||
assert focused == ['sidebar', 'detail']
|
||||
assert scroll_target is workspace_main
|
||||
|
||||
|
||||
def test_detail_screen_unmount_cancels_action_task(tui_modules: SimpleNamespace) -> None:
|
||||
snapshot = tui_modules.cli_tui_app.DetailPageSnapshot(
|
||||
title='数据库',
|
||||
subtitle='subtitle',
|
||||
sections=[],
|
||||
)
|
||||
screen = tui_modules.cli_tui_detail.DetailScreen(
|
||||
snapshot,
|
||||
env='dev',
|
||||
active_view='database',
|
||||
navigation_items=tui_modules.cli_tui_app.NAVIGATION_ITEMS,
|
||||
refreshed_at='2026-04-30 10:00:00',
|
||||
)
|
||||
|
||||
cancelled: list[str] = []
|
||||
|
||||
class DummyTask:
|
||||
def done(self) -> bool:
|
||||
return False
|
||||
|
||||
def cancel(self) -> None:
|
||||
cancelled.append('action')
|
||||
|
||||
screen._action_task = DummyTask() # type: ignore[assignment]
|
||||
|
||||
screen.on_unmount()
|
||||
|
||||
assert cancelled == ['action']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_detail_screen_scrolls_in_small_viewport(tui_modules: SimpleNamespace) -> None:
|
||||
thin_scrollbar_size = 1
|
||||
app = tui_modules.cli_tui_app.ShierfengTuiApp('dev')
|
||||
app.action_show_dashboard = lambda: None # type: ignore[method-assign]
|
||||
app.current_view = 'app'
|
||||
snapshot = tui_modules.cli_tui_app.DetailPageSnapshot(
|
||||
title='应用',
|
||||
subtitle='超长详情',
|
||||
sections=[
|
||||
tui_modules.cli_tui_adapters.DetailSectionSnapshot(
|
||||
title='配置总览',
|
||||
status='warn',
|
||||
lines=[f'第{i:02d}行 内容很长 内容很长 内容很长 内容很长' for i in range(1, 40)],
|
||||
)
|
||||
],
|
||||
)
|
||||
screen = app.screen_factory.build(
|
||||
snapshot=snapshot,
|
||||
env=app.env,
|
||||
active_view=app.current_view,
|
||||
navigation_items=tui_modules.cli_tui_app.NAVIGATION_ITEMS,
|
||||
action_feedback_lines=app.get_action_feedback_lines(app.current_view),
|
||||
)
|
||||
|
||||
async with app.run_test(size=(80, 24)) as pilot:
|
||||
app.screen_navigator.show(screen)
|
||||
await pilot.pause()
|
||||
await pilot.pause()
|
||||
|
||||
main = app.screen.query_one('#workspace-main')
|
||||
detail_body = app.screen.query_one('#detail-body')
|
||||
detail_view = app.screen.query_one(tui_modules.cli_tui_widgets.SectionDetailView)
|
||||
|
||||
assert main.styles.scrollbar_size_vertical == thin_scrollbar_size
|
||||
assert main.styles.scrollbar_size_horizontal == thin_scrollbar_size
|
||||
assert main.virtual_size.height > main.size.height
|
||||
assert detail_body.styles.scrollbar_size_vertical == thin_scrollbar_size
|
||||
assert detail_body.styles.scrollbar_size_horizontal == thin_scrollbar_size
|
||||
assert detail_view.styles.scrollbar_size_vertical == thin_scrollbar_size
|
||||
assert detail_view.styles.scrollbar_size_horizontal == thin_scrollbar_size
|
||||
@@ -0,0 +1,68 @@
|
||||
import importlib
|
||||
import sys
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
|
||||
import pytest
|
||||
|
||||
BACKEND_DIR = Path(__file__).resolve().parents[3]
|
||||
|
||||
if str(BACKEND_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(BACKEND_DIR))
|
||||
|
||||
|
||||
def _load_module(module_name: str) -> ModuleType:
|
||||
sys.modules.pop(module_name, None)
|
||||
sys.modules.pop('cli', None)
|
||||
return importlib.import_module(module_name)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def backend_dir() -> Path:
|
||||
return BACKEND_DIR
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def load_module() -> Callable[[str], ModuleType]:
|
||||
return _load_module
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cli_main(load_module: Callable[[str], ModuleType]) -> ModuleType:
|
||||
return load_module('cli.main')
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app_run_flow(load_module: Callable[[str], ModuleType]) -> ModuleType:
|
||||
return load_module('cli.wizard.flows.app_run')
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cache_clear_flow(load_module: Callable[[str], ModuleType]) -> ModuleType:
|
||||
return load_module('cli.wizard.flows.cache_clear')
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_upgrade_flow(load_module: Callable[[str], ModuleType]) -> ModuleType:
|
||||
return load_module('cli.wizard.flows.db_upgrade')
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def gen_export_flow(load_module: Callable[[str], ModuleType]) -> ModuleType:
|
||||
return load_module('cli.wizard.flows.gen_export')
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def gen_import_flow(load_module: Callable[[str], ModuleType]) -> ModuleType:
|
||||
return load_module('cli.wizard.flows.gen_import')
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def prod_check_flow(load_module: Callable[[str], ModuleType]) -> ModuleType:
|
||||
return load_module('cli.wizard.flows.prod_check')
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def exit_codes(load_module: Callable[[str], ModuleType]) -> ModuleType:
|
||||
return load_module('cli.exit_codes')
|
||||
@@ -0,0 +1,33 @@
|
||||
from types import ModuleType, SimpleNamespace
|
||||
|
||||
from pytest import MonkeyPatch
|
||||
|
||||
|
||||
def test_app_run_wizard_execs_nested_cli_after_confirmation(
|
||||
monkeypatch: MonkeyPatch,
|
||||
app_run_flow: ModuleType,
|
||||
) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
answers = iter(['dev', False, True])
|
||||
monkeypatch.setattr(
|
||||
app_run_flow.AppRunWizardFlow,
|
||||
'prompt_service',
|
||||
SimpleNamespace(
|
||||
prompt_env=lambda default_env='dev': next(answers),
|
||||
prompt_confirm=lambda prompt_text, default_value=False: next(answers),
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
app_run_flow.AppRunWizardFlow.context_factory,
|
||||
'build_readonly',
|
||||
lambda env, output: SimpleNamespace(env=env, output=output),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
app_run_flow.AppRunWizardFlow,
|
||||
'nested_live_command_runner',
|
||||
lambda *arguments: captured.update({'arguments': arguments}),
|
||||
)
|
||||
|
||||
app_run_flow.run_app_run_wizard()
|
||||
|
||||
assert captured['arguments'] == ('app', 'run', '--env=dev')
|
||||
@@ -0,0 +1,88 @@
|
||||
from types import ModuleType, SimpleNamespace
|
||||
|
||||
from pytest import MonkeyPatch
|
||||
|
||||
|
||||
def test_cache_clear_wizard_cancels_when_confirmation_rejected(
|
||||
monkeypatch: MonkeyPatch,
|
||||
cache_clear_flow: ModuleType,
|
||||
exit_codes: ModuleType,
|
||||
) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
answers = iter(['dev', 'cache-name', 'sys_config', True, False])
|
||||
monkeypatch.setattr(
|
||||
cache_clear_flow.CacheClearWizardFlow,
|
||||
'prompt_service',
|
||||
SimpleNamespace(
|
||||
prompt_env=lambda default_env='dev': next(answers),
|
||||
prompt_choice=lambda prompt_text, choices, default_value: next(answers),
|
||||
prompt_required_text=lambda prompt_text, default_value='': next(answers),
|
||||
prompt_optional_text=lambda prompt_text, default_value='': '',
|
||||
prompt_confirm=lambda prompt_text, default_value=False: next(answers),
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
cache_clear_flow.CacheClearWizardFlow.context_factory,
|
||||
'build_regular',
|
||||
lambda env, output, allow_prod, yes, dry_run: SimpleNamespace(env=env, output=output),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
cache_clear_flow.CacheClearWizardFlow.execution_service,
|
||||
'complete_result',
|
||||
lambda ctx, result: captured.update({'ctx': ctx, 'result': result}),
|
||||
)
|
||||
|
||||
cache_clear_flow.run_cache_clear_wizard('text')
|
||||
|
||||
assert captured['ctx'].env == 'dev'
|
||||
assert captured['result'].exit_code == exit_codes.GUARD_REJECTED
|
||||
assert captured['result'].data['message'] == '已取消向导执行:wizard cache-clear'
|
||||
|
||||
|
||||
def test_cache_clear_wizard_accepts_default_context_values(
|
||||
monkeypatch: MonkeyPatch,
|
||||
cache_clear_flow: ModuleType,
|
||||
) -> None:
|
||||
captured_defaults: dict[str, object] = {}
|
||||
captured_confirm_defaults: list[tuple[str, bool]] = []
|
||||
answers = iter(['dev', 'cache-name', 'sys_config', True, False])
|
||||
monkeypatch.setattr(
|
||||
cache_clear_flow.CacheClearWizardFlow,
|
||||
'prompt_service',
|
||||
SimpleNamespace(
|
||||
prompt_env=lambda default_env='dev': captured_defaults.update({'env': default_env}) or next(answers),
|
||||
prompt_choice=lambda prompt_text, choices, default_value: (
|
||||
captured_defaults.update({'mode': default_value}) or next(answers)
|
||||
),
|
||||
prompt_required_text=lambda prompt_text, default_value='': (
|
||||
captured_defaults.update({'cache_name': default_value}) or next(answers)
|
||||
),
|
||||
prompt_optional_text=lambda prompt_text, default_value='': '',
|
||||
prompt_confirm=lambda prompt_text, default_value=False: (
|
||||
captured_confirm_defaults.append((prompt_text, default_value)) or next(answers)
|
||||
),
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
cache_clear_flow.CacheClearWizardFlow.context_factory,
|
||||
'build_regular',
|
||||
lambda env, output, allow_prod, yes, dry_run: SimpleNamespace(env=env, output=output),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
cache_clear_flow.CacheClearWizardFlow.execution_service,
|
||||
'complete_result',
|
||||
lambda ctx, result: None,
|
||||
)
|
||||
|
||||
cache_clear_flow.run_cache_clear_wizard(
|
||||
'text',
|
||||
default_env='dev',
|
||||
default_mode='cache-name',
|
||||
default_cache_name='sys_config',
|
||||
default_dry_run=True,
|
||||
)
|
||||
|
||||
assert captured_defaults['env'] == 'dev'
|
||||
assert captured_defaults['mode'] == 'cache-name'
|
||||
assert captured_defaults['cache_name'] == 'sys_config'
|
||||
assert captured_confirm_defaults[0][1] is True
|
||||
@@ -0,0 +1,24 @@
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
|
||||
from pytest import MonkeyPatch
|
||||
from typer.testing import CliRunner
|
||||
|
||||
|
||||
def test_wizard_help_lists_supported_subcommands(
|
||||
monkeypatch: MonkeyPatch,
|
||||
backend_dir: Path,
|
||||
cli_main: ModuleType,
|
||||
) -> None:
|
||||
runner = CliRunner()
|
||||
monkeypatch.chdir(backend_dir)
|
||||
|
||||
result = runner.invoke(cli_main.CLI_APPLICATION_BUILDER.build(), ['wizard', '--help'])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert 'app-run' in result.stdout
|
||||
assert 'db-upgrade' in result.stdout
|
||||
assert 'cache-clear' in result.stdout
|
||||
assert 'gen-export' in result.stdout
|
||||
assert 'gen-import' in result.stdout
|
||||
assert 'prod-check' in result.stdout
|
||||
@@ -0,0 +1,99 @@
|
||||
from types import ModuleType, SimpleNamespace
|
||||
|
||||
from pytest import MonkeyPatch
|
||||
|
||||
|
||||
def test_db_upgrade_wizard_executes_nested_cli_with_confirmed_inputs(
|
||||
monkeypatch: MonkeyPatch,
|
||||
db_upgrade_flow: ModuleType,
|
||||
) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
monkeypatch.setattr(
|
||||
db_upgrade_flow.DbUpgradeWizardFlow,
|
||||
'prompt_service',
|
||||
SimpleNamespace(
|
||||
prompt_env=lambda default_env='dev': 'dev',
|
||||
prompt_required_text=lambda prompt_text, default_value='': 'head',
|
||||
prompt_confirm=lambda prompt_text, default_value=False: True,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
db_upgrade_flow.DbUpgradeWizardFlow.context_factory,
|
||||
'build_regular',
|
||||
lambda env, output, allow_prod, yes, dry_run: SimpleNamespace(
|
||||
env=env,
|
||||
output=output,
|
||||
allow_prod=allow_prod,
|
||||
yes=yes,
|
||||
dry_run=dry_run,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
db_upgrade_flow.DbUpgradeWizardFlow,
|
||||
'nested_command_runner',
|
||||
lambda *arguments, parse_json=False: SimpleNamespace(
|
||||
returncode=0,
|
||||
payload={'ok': True, 'message': '数据库已升级到 head', 'dryRun': True},
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
db_upgrade_flow.DbUpgradeWizardFlow.execution_service,
|
||||
'complete_payload',
|
||||
lambda ctx, payload, default_exit_code=0: captured.update(
|
||||
{
|
||||
'ctx': ctx,
|
||||
'payload': payload,
|
||||
'default_exit_code': default_exit_code,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
db_upgrade_flow.run_db_upgrade_wizard('json')
|
||||
|
||||
assert captured['ctx'].env == 'dev'
|
||||
assert captured['ctx'].output == 'json'
|
||||
assert captured['ctx'].dry_run is True
|
||||
assert captured['payload'] == {'ok': True, 'message': '数据库已升级到 head', 'dryRun': True}
|
||||
|
||||
|
||||
def test_db_upgrade_wizard_accepts_default_context_values(
|
||||
monkeypatch: MonkeyPatch,
|
||||
db_upgrade_flow: ModuleType,
|
||||
) -> None:
|
||||
captured_defaults: dict[str, object] = {}
|
||||
captured_confirm_defaults: list[tuple[str, bool]] = []
|
||||
confirm_answers = iter([True, False])
|
||||
monkeypatch.setattr(
|
||||
db_upgrade_flow.DbUpgradeWizardFlow,
|
||||
'prompt_service',
|
||||
SimpleNamespace(
|
||||
prompt_env=lambda default_env='dev': captured_defaults.update({'env': default_env}) or 'dev',
|
||||
prompt_required_text=lambda prompt_text, default_value='': (
|
||||
captured_defaults.update({'revision': default_value}) or 'head'
|
||||
),
|
||||
prompt_confirm=lambda prompt_text, default_value=False: (
|
||||
captured_confirm_defaults.append((prompt_text, default_value)) or next(confirm_answers)
|
||||
),
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
db_upgrade_flow.DbUpgradeWizardFlow.context_factory,
|
||||
'build_regular',
|
||||
lambda env, output, allow_prod, yes, dry_run: SimpleNamespace(env=env, output=output),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
db_upgrade_flow.DbUpgradeWizardFlow.execution_service,
|
||||
'complete_result',
|
||||
lambda ctx, result: None,
|
||||
)
|
||||
|
||||
db_upgrade_flow.run_db_upgrade_wizard(
|
||||
'text',
|
||||
default_env='dev',
|
||||
default_revision='head',
|
||||
default_dry_run=True,
|
||||
)
|
||||
|
||||
assert captured_defaults['env'] == 'dev'
|
||||
assert captured_defaults['revision'] == 'head'
|
||||
assert captured_confirm_defaults[0][1] is True
|
||||
@@ -0,0 +1,120 @@
|
||||
from types import ModuleType, SimpleNamespace
|
||||
|
||||
from pytest import MonkeyPatch
|
||||
|
||||
|
||||
def test_gen_export_wizard_executes_nested_cli_with_selected_tables(
|
||||
monkeypatch: MonkeyPatch,
|
||||
gen_export_flow: ModuleType,
|
||||
) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
answers = iter(['dev', 'sys_user,sys_role', 'zip', 'build/demo.zip', True, True])
|
||||
monkeypatch.setattr(
|
||||
gen_export_flow.GenExportWizardFlow,
|
||||
'prompt_service',
|
||||
SimpleNamespace(
|
||||
prompt_env=lambda default_env='dev': next(answers),
|
||||
prompt_required_text=lambda prompt_text, default_value='': next(answers),
|
||||
prompt_choice=lambda prompt_text, choices, default_value: next(answers),
|
||||
prompt_optional_text=lambda prompt_text, default_value='': next(answers),
|
||||
prompt_confirm=lambda prompt_text, default_value=False: next(answers),
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
gen_export_flow.GenExportWizardFlow.context_factory,
|
||||
'build_regular',
|
||||
lambda env, output, allow_prod, yes, dry_run: SimpleNamespace(
|
||||
env=env,
|
||||
output=output,
|
||||
allow_prod=allow_prod,
|
||||
yes=yes,
|
||||
dry_run=dry_run,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
gen_export_flow.GenExportWizardFlow,
|
||||
'nested_command_runner',
|
||||
lambda *arguments, parse_json=False: SimpleNamespace(
|
||||
returncode=0,
|
||||
payload={
|
||||
'ok': True,
|
||||
'message': '代码导出演练完成,未执行实际导出',
|
||||
'dryRun': True,
|
||||
'mode': 'zip',
|
||||
'tableNames': ['sys_user', 'sys_role'],
|
||||
'outputFile': '/tmp/demo.zip',
|
||||
},
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
gen_export_flow.GenExportWizardFlow.execution_service,
|
||||
'complete_payload',
|
||||
lambda ctx, payload, default_exit_code=0: captured.update(
|
||||
{
|
||||
'ctx': ctx,
|
||||
'payload': payload,
|
||||
'default_exit_code': default_exit_code,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
gen_export_flow.run_gen_export_wizard('json')
|
||||
|
||||
assert captured['ctx'].env == 'dev'
|
||||
assert captured['ctx'].output == 'json'
|
||||
assert captured['ctx'].dry_run is True
|
||||
assert captured['payload']['tableNames'] == ['sys_user', 'sys_role']
|
||||
assert captured['payload']['mode'] == 'zip'
|
||||
|
||||
|
||||
def test_gen_export_wizard_accepts_default_context_values(
|
||||
monkeypatch: MonkeyPatch,
|
||||
gen_export_flow: ModuleType,
|
||||
) -> None:
|
||||
captured_defaults: dict[str, object] = {}
|
||||
captured_confirm_defaults: list[tuple[str, bool]] = []
|
||||
answers = iter(['dev', 'sys_user', 'zip', 'build/demo.zip', True, False])
|
||||
monkeypatch.setattr(
|
||||
gen_export_flow.GenExportWizardFlow,
|
||||
'prompt_service',
|
||||
SimpleNamespace(
|
||||
prompt_env=lambda default_env='dev': captured_defaults.update({'env': default_env}) or next(answers),
|
||||
prompt_required_text=lambda prompt_text, default_value='': (
|
||||
captured_defaults.update({'table_names': default_value}) or next(answers)
|
||||
),
|
||||
prompt_choice=lambda prompt_text, choices, default_value: (
|
||||
captured_defaults.update({'mode': default_value}) or next(answers)
|
||||
),
|
||||
prompt_optional_text=lambda prompt_text, default_value='': (
|
||||
captured_defaults.update({'output_file': default_value}) or next(answers)
|
||||
),
|
||||
prompt_confirm=lambda prompt_text, default_value=False: (
|
||||
captured_confirm_defaults.append((prompt_text, default_value)) or next(answers)
|
||||
),
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
gen_export_flow.GenExportWizardFlow.context_factory,
|
||||
'build_regular',
|
||||
lambda env, output, allow_prod, yes, dry_run: SimpleNamespace(env=env, output=output),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
gen_export_flow.GenExportWizardFlow.execution_service,
|
||||
'complete_result',
|
||||
lambda ctx, result: None,
|
||||
)
|
||||
|
||||
gen_export_flow.run_gen_export_wizard(
|
||||
'text',
|
||||
default_env='dev',
|
||||
default_table_names='sys_user',
|
||||
default_mode='zip',
|
||||
default_output_file='build/demo.zip',
|
||||
default_dry_run=True,
|
||||
)
|
||||
|
||||
assert captured_defaults['env'] == 'dev'
|
||||
assert captured_defaults['table_names'] == 'sys_user'
|
||||
assert captured_defaults['mode'] == 'zip'
|
||||
assert captured_defaults['output_file'] == 'build/demo.zip'
|
||||
assert captured_confirm_defaults[0][1] is True
|
||||
@@ -0,0 +1,164 @@
|
||||
from types import ModuleType, SimpleNamespace
|
||||
|
||||
from pytest import MonkeyPatch
|
||||
|
||||
|
||||
def test_gen_import_wizard_executes_nested_cli_with_selected_tables(
|
||||
monkeypatch: MonkeyPatch,
|
||||
gen_import_flow: ModuleType,
|
||||
) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
answers = iter(['dev', 'sys_user,sys_role', True, True])
|
||||
monkeypatch.setattr(
|
||||
gen_import_flow.GenImportWizardFlow,
|
||||
'prompt_service',
|
||||
SimpleNamespace(
|
||||
prompt_env=lambda default_env='dev': next(answers),
|
||||
prompt_required_text=lambda prompt_text, default_value='': next(answers),
|
||||
prompt_confirm=lambda prompt_text, default_value=False: next(answers),
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
gen_import_flow.GenImportWizardFlow.context_factory,
|
||||
'build_regular',
|
||||
lambda env, output, allow_prod, yes, dry_run: SimpleNamespace(
|
||||
env=env,
|
||||
output=output,
|
||||
allow_prod=allow_prod,
|
||||
yes=yes,
|
||||
dry_run=dry_run,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
gen_import_flow.GenImportWizardFlow,
|
||||
'nested_command_runner',
|
||||
lambda *arguments, parse_json=False: SimpleNamespace(
|
||||
returncode=0,
|
||||
payload={
|
||||
'ok': True,
|
||||
'message': '物理表导入预演完成,未执行实际导入',
|
||||
'dryRun': True,
|
||||
'tableNames': ['sys_user', 'sys_role'],
|
||||
},
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
gen_import_flow.GenImportWizardFlow.execution_service,
|
||||
'complete_payload',
|
||||
lambda ctx, payload, default_exit_code=0: captured.update(
|
||||
{
|
||||
'ctx': ctx,
|
||||
'payload': payload,
|
||||
'default_exit_code': default_exit_code,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
gen_import_flow.run_gen_import_wizard('json')
|
||||
|
||||
assert captured['ctx'].env == 'dev'
|
||||
assert captured['ctx'].output == 'json'
|
||||
assert captured['ctx'].dry_run is True
|
||||
assert captured['payload']['tableNames'] == ['sys_user', 'sys_role']
|
||||
|
||||
|
||||
def test_gen_import_wizard_accepts_default_context_values(
|
||||
monkeypatch: MonkeyPatch,
|
||||
gen_import_flow: ModuleType,
|
||||
) -> None:
|
||||
captured_defaults: dict[str, object] = {}
|
||||
captured_confirm_defaults: list[tuple[str, bool]] = []
|
||||
answers = iter(['dev', 'sys_user', True, False])
|
||||
monkeypatch.setattr(
|
||||
gen_import_flow.GenImportWizardFlow,
|
||||
'prompt_service',
|
||||
SimpleNamespace(
|
||||
prompt_env=lambda default_env='dev': captured_defaults.update({'env': default_env}) or next(answers),
|
||||
prompt_required_text=lambda prompt_text, default_value='': (
|
||||
captured_defaults.update({'table_names': default_value}) or next(answers)
|
||||
),
|
||||
prompt_confirm=lambda prompt_text, default_value=False: (
|
||||
captured_confirm_defaults.append((prompt_text, default_value)) or next(answers)
|
||||
),
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
gen_import_flow.GenImportWizardFlow.context_factory,
|
||||
'build_regular',
|
||||
lambda env, output, allow_prod, yes, dry_run: SimpleNamespace(env=env, output=output),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
gen_import_flow.GenImportWizardFlow.execution_service,
|
||||
'complete_result',
|
||||
lambda ctx, result: None,
|
||||
)
|
||||
|
||||
gen_import_flow.run_gen_import_wizard(
|
||||
'text',
|
||||
default_env='dev',
|
||||
default_table_names='sys_user',
|
||||
default_dry_run=True,
|
||||
)
|
||||
|
||||
assert captured_defaults['env'] == 'dev'
|
||||
assert captured_defaults['table_names'] == 'sys_user'
|
||||
assert captured_confirm_defaults[0][1] is True
|
||||
|
||||
|
||||
def test_gen_import_wizard_falls_back_to_failure_payload_when_nested_result_has_no_dict_payload(
|
||||
monkeypatch: MonkeyPatch,
|
||||
gen_import_flow: ModuleType,
|
||||
) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
answers = iter(['dev', 'sys_notice', True, True])
|
||||
monkeypatch.setattr(
|
||||
gen_import_flow.GenImportWizardFlow,
|
||||
'prompt_service',
|
||||
SimpleNamespace(
|
||||
prompt_env=lambda default_env='dev': next(answers),
|
||||
prompt_required_text=lambda prompt_text, default_value='': next(answers),
|
||||
prompt_confirm=lambda prompt_text, default_value=False: next(answers),
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
gen_import_flow.GenImportWizardFlow.context_factory,
|
||||
'build_regular',
|
||||
lambda env, output, allow_prod, yes, dry_run: SimpleNamespace(
|
||||
env=env,
|
||||
output=output,
|
||||
allow_prod=allow_prod,
|
||||
yes=yes,
|
||||
dry_run=dry_run,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
gen_import_flow.GenImportWizardFlow,
|
||||
'nested_command_runner',
|
||||
lambda *arguments, parse_json=False: SimpleNamespace(
|
||||
returncode=0,
|
||||
stdout='OK SUCCESS\nmessage: 导入表结构演练完成,未执行实际写入',
|
||||
stderr='',
|
||||
payload=[],
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
gen_import_flow.GenImportWizardFlow.execution_service,
|
||||
'complete_payload',
|
||||
lambda ctx, payload, default_exit_code=0: captured.update(
|
||||
{
|
||||
'ctx': ctx,
|
||||
'payload': payload,
|
||||
'default_exit_code': default_exit_code,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
gen_import_flow.run_gen_import_wizard('text')
|
||||
|
||||
assert captured['ctx'].env == 'dev'
|
||||
assert captured['payload'] == {
|
||||
'ok': False,
|
||||
'message': '代码生成导入向导执行失败',
|
||||
'error': 'OK SUCCESS\nmessage: 导入表结构演练完成,未执行实际写入',
|
||||
'exit_code': 0,
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
from types import ModuleType, SimpleNamespace
|
||||
|
||||
from pytest import MonkeyPatch
|
||||
|
||||
|
||||
def test_prod_check_wizard_aggregates_nested_cli_payloads(
|
||||
monkeypatch: MonkeyPatch,
|
||||
prod_check_flow: ModuleType,
|
||||
) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
answers = iter(['prod', True, True])
|
||||
monkeypatch.setattr(
|
||||
prod_check_flow.ProdCheckWizardFlow,
|
||||
'prompt_service',
|
||||
SimpleNamespace(
|
||||
prompt_env=lambda default_env='prod': next(answers),
|
||||
prompt_confirm=lambda prompt_text, default_value=False: next(answers),
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
prod_check_flow.ProdCheckWizardFlow.context_factory,
|
||||
'build_readonly',
|
||||
lambda env, output: SimpleNamespace(env=env, output=output),
|
||||
)
|
||||
|
||||
def fake_run_nested_cli_command(*arguments: str, parse_json: bool = False) -> SimpleNamespace:
|
||||
del parse_json
|
||||
if arguments[0:2] == ('app', 'env'):
|
||||
return SimpleNamespace(
|
||||
payload={
|
||||
'ok': True,
|
||||
'runtime': {
|
||||
'cliEnv': 'prod',
|
||||
'configEnv': 'prod',
|
||||
'envFile': '.env.prod',
|
||||
'envFileExists': True,
|
||||
},
|
||||
}
|
||||
)
|
||||
if arguments[0:2] == ('app', 'doctor'):
|
||||
return SimpleNamespace(payload={'ok': True, 'message': '应用启动前检查通过'})
|
||||
return SimpleNamespace(
|
||||
payload={
|
||||
'ok': True,
|
||||
'config': {
|
||||
'name': 'demo',
|
||||
'host': '0.0.0.0',
|
||||
'port': 9099,
|
||||
'dbType': 'mysql',
|
||||
'redisHost': '127.0.0.1',
|
||||
'redisPort': 6379,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
prod_check_flow.ProdCheckWizardFlow,
|
||||
'nested_command_runner',
|
||||
fake_run_nested_cli_command,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
prod_check_flow.ProdCheckWizardFlow.execution_service,
|
||||
'complete_payload',
|
||||
lambda ctx, payload, default_exit_code=0: captured.update({'ctx': ctx, 'payload': payload}),
|
||||
)
|
||||
|
||||
prod_check_flow.run_prod_check_wizard('json')
|
||||
|
||||
assert captured['ctx'].env == 'prod'
|
||||
assert captured['payload']['ok'] is True
|
||||
assert captured['payload']['doctor']['message'] == '应用启动前检查通过'
|
||||
assert captured['payload']['runtime']['cliEnv'] == 'prod'
|
||||
assert captured['payload']['config']['name'] == 'demo'
|
||||
|
||||
|
||||
def test_prod_check_wizard_accepts_default_context_values(
|
||||
monkeypatch: MonkeyPatch,
|
||||
prod_check_flow: ModuleType,
|
||||
) -> None:
|
||||
captured_defaults: dict[str, object] = {}
|
||||
confirm_answers = iter([True, False])
|
||||
monkeypatch.setattr(
|
||||
prod_check_flow.ProdCheckWizardFlow,
|
||||
'prompt_service',
|
||||
SimpleNamespace(
|
||||
prompt_env=lambda default_env='prod': captured_defaults.update({'env': default_env}) or 'prod',
|
||||
prompt_confirm=lambda prompt_text, default_value=False: (
|
||||
captured_defaults.update({'include_config': default_value}) or next(confirm_answers)
|
||||
),
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
prod_check_flow.ProdCheckWizardFlow.context_factory,
|
||||
'build_readonly',
|
||||
lambda env, output: SimpleNamespace(env=env, output=output),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
prod_check_flow.ProdCheckWizardFlow.execution_service,
|
||||
'complete_result',
|
||||
lambda ctx, result: None,
|
||||
)
|
||||
|
||||
prod_check_flow.run_prod_check_wizard(
|
||||
'text',
|
||||
default_env='prod',
|
||||
default_include_config=True,
|
||||
)
|
||||
|
||||
assert captured_defaults['env'] == 'prod'
|
||||
assert captured_defaults['include_config'] is True
|
||||
@@ -0,0 +1,865 @@
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from collections.abc import Iterator
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from fastapi import Request
|
||||
from loguru import logger as _logger
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
from common.annotation.log_annotation import Log, RequestLogFieldRoot, ResponseLogFieldRoot
|
||||
from common.enums import BusinessType
|
||||
from config.env import LogConfig
|
||||
from utils.log_util import LoggerInitializer, LogSanitizer, _build_text_assignment_patterns, _build_text_key_pattern
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def enable_log_mask_for_tests() -> Iterator[None]:
|
||||
with patch.object(LogConfig, 'log_mask_enabled', True):
|
||||
yield
|
||||
|
||||
|
||||
def test_sanitize_nested_log_payload() -> None:
|
||||
payload = {
|
||||
'jsonBody': {
|
||||
'password': 'plain-password',
|
||||
'apiKey': 'sk-123456',
|
||||
'systemPrompt': 'top secret prompt',
|
||||
'phonenumber': '13812345678',
|
||||
'email': 'admin@example.com',
|
||||
'ipaddr': '192.168.1.10',
|
||||
}
|
||||
}
|
||||
|
||||
sanitized = LogSanitizer.sanitize_data(payload)
|
||||
|
||||
assert sanitized['jsonBody']['password'] == '******'
|
||||
assert sanitized['jsonBody']['apiKey'] == '******'
|
||||
assert sanitized['jsonBody']['systemPrompt'] == '******'
|
||||
assert sanitized['jsonBody']['phonenumber'] == '138****5678'
|
||||
assert sanitized['jsonBody']['email'] == 'a***n@example.com'
|
||||
assert sanitized['jsonBody']['ipaddr'] == '192.168.1.10'
|
||||
|
||||
|
||||
def test_ip_masking_can_be_enabled_by_partial_mask_field_configuration() -> None:
|
||||
payload = {
|
||||
'jsonBody': {
|
||||
'ipaddr': '192.168.1.10',
|
||||
'login_ip': '192.168.1.11',
|
||||
}
|
||||
}
|
||||
|
||||
with patch.object(
|
||||
LogSanitizer,
|
||||
'_PARTIAL_MASK_FIELDS',
|
||||
LogSanitizer._PARTIAL_MASK_FIELDS | {'ip', 'ipaddr', 'operip', 'loginip'},
|
||||
):
|
||||
sanitized = LogSanitizer.sanitize_data(payload)
|
||||
|
||||
assert sanitized['jsonBody']['ipaddr'] == '192.168.1.*'
|
||||
assert sanitized['jsonBody']['login_ip'] == '192.168.1.*'
|
||||
|
||||
|
||||
def test_sanitize_config_value_by_config_key() -> None:
|
||||
payload = {
|
||||
'configKey': 'sys.transport.privateKey',
|
||||
'configValue': '-----BEGIN PRIVATE KEY-----abc',
|
||||
}
|
||||
|
||||
sanitized = LogSanitizer.sanitize_data(payload)
|
||||
|
||||
assert sanitized['configValue'] == '******'
|
||||
|
||||
|
||||
def test_sanitize_text_message() -> None:
|
||||
text = 'Authorization=Bearer abc.def password=123456 api_key=sk-test'
|
||||
expected_mask_count = 3
|
||||
|
||||
sanitized = LogSanitizer.sanitize_text(text)
|
||||
|
||||
assert 'abc.def' not in sanitized
|
||||
assert '123456' not in sanitized
|
||||
assert 'sk-test' not in sanitized
|
||||
assert sanitized.count('******') >= expected_mask_count
|
||||
|
||||
|
||||
def test_sanitize_text_masks_configured_secret_fields() -> None:
|
||||
text = (
|
||||
'secret_key=my-secret private_key="-----BEGIN PRIVATE KEY-----abc" '
|
||||
"credential='cred-123' credentials=cred-list"
|
||||
)
|
||||
expected_mask_count = 4
|
||||
|
||||
sanitized = LogSanitizer.sanitize_text(text)
|
||||
|
||||
assert 'my-secret' not in sanitized
|
||||
assert '-----BEGIN PRIVATE KEY-----abc' not in sanitized
|
||||
assert 'cred-123' not in sanitized
|
||||
assert 'cred-list' not in sanitized
|
||||
assert sanitized.count('******') >= expected_mask_count
|
||||
|
||||
|
||||
def test_sanitize_text_key_pattern_does_not_cross_lines() -> None:
|
||||
text = 'header\nprivate\n key=value\nfooter'
|
||||
|
||||
sanitized = LogSanitizer.sanitize_text(text)
|
||||
|
||||
assert sanitized == text
|
||||
|
||||
|
||||
def test_sanitize_stringified_json_preserves_newlines_after_masking() -> None:
|
||||
text = '{\n "password": "123456",\n "userName": "admin"\n}'
|
||||
|
||||
sanitized = LogSanitizer.sanitize_data({'operParam': text})
|
||||
|
||||
assert sanitized['operParam'] == '{\n "password": "******",\n "userName": "admin"\n}'
|
||||
|
||||
|
||||
def test_sanitize_text_masks_configured_partial_fields() -> None:
|
||||
text = 'email=admin@example.com phonenumber=13812345678 mobile="13812345679"'
|
||||
|
||||
sanitized = LogSanitizer.sanitize_text(text)
|
||||
|
||||
assert 'admin@example.com' not in sanitized
|
||||
assert '13812345678' not in sanitized
|
||||
assert '13812345679' not in sanitized
|
||||
assert 'a***n@example.com' in sanitized
|
||||
assert '138****5678' in sanitized
|
||||
assert '138****5679' in sanitized
|
||||
|
||||
|
||||
def test_sanitize_text_can_enable_ip_masking_by_partial_mask_field_configuration() -> None:
|
||||
text = 'login_ip=192.168.1.10 ipaddr="192.168.1.11"'
|
||||
ip_fields = LogSanitizer._PARTIAL_MASK_FIELDS | {'ip', 'ipaddr', 'operip', 'loginip'}
|
||||
ip_key_pattern = '|'.join(
|
||||
sorted(
|
||||
{
|
||||
_build_text_key_pattern(field_name)
|
||||
for field_name in (*LogSanitizer._TEXT_PARTIAL_FIELDS, 'ip', 'ipaddr', 'oper_ip', 'login_ip')
|
||||
},
|
||||
key=len,
|
||||
reverse=True,
|
||||
)
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(LogSanitizer, '_PARTIAL_MASK_FIELDS', ip_fields),
|
||||
patch.object(LogSanitizer, '_PARTIAL_KV_PATTERNS', _build_text_assignment_patterns(ip_key_pattern)),
|
||||
):
|
||||
sanitized = LogSanitizer.sanitize_text(text)
|
||||
|
||||
assert '192.168.1.10' not in sanitized
|
||||
assert '192.168.1.11' not in sanitized
|
||||
assert '192.168.1.*' in sanitized
|
||||
|
||||
|
||||
def test_sanitize_text_masks_python_repr_like_payload() -> None:
|
||||
text = "ValueError({'secret_key': 'abc', 'password': '123456', 'api_key': 'sk-123'})"
|
||||
expected_mask_count = 3
|
||||
|
||||
sanitized = LogSanitizer.sanitize_text(text)
|
||||
|
||||
assert 'abc' not in sanitized
|
||||
assert '123456' not in sanitized
|
||||
assert 'sk-123' not in sanitized
|
||||
assert sanitized.count('******') >= expected_mask_count
|
||||
|
||||
|
||||
def test_sanitize_text_masks_alphanumeric_verification_code() -> None:
|
||||
text = '短信验证码为A1B2'
|
||||
|
||||
sanitized = LogSanitizer.sanitize_text(text)
|
||||
|
||||
assert 'A1B2' not in sanitized
|
||||
assert '******' in sanitized
|
||||
|
||||
|
||||
def test_sanitize_text_returns_original_when_mask_disabled() -> None:
|
||||
text = 'Authorization=Bearer abc.def password=123456 api_key=sk-test'
|
||||
|
||||
with patch.object(LogConfig, 'log_mask_enabled', False):
|
||||
sanitized = LogSanitizer.sanitize_text(text)
|
||||
|
||||
assert sanitized == text
|
||||
|
||||
|
||||
def test_build_json_payload_sanitizes_exception_and_extra() -> None:
|
||||
initializer = LoggerInitializer()
|
||||
try:
|
||||
raise ValueError('token=abc.def')
|
||||
except ValueError as exc:
|
||||
exception = SimpleNamespace(
|
||||
type=type(exc),
|
||||
value=exc,
|
||||
traceback=exc.__traceback__,
|
||||
)
|
||||
record = {
|
||||
'time': datetime(2026, 4, 20, 12, 0, 0),
|
||||
'level': SimpleNamespace(name='ERROR'),
|
||||
'message': 'password=123456',
|
||||
'name': 'test_logger',
|
||||
'module': 'test_module',
|
||||
'function': 'test_function',
|
||||
'line': 1,
|
||||
'extra': {'authorization': 'Bearer abc.def', 'trace_id': 'trace-1'},
|
||||
'exception': exception,
|
||||
}
|
||||
|
||||
initializer._patch_record(record)
|
||||
initializer._filter(record)
|
||||
payload = initializer._build_json_payload(record)
|
||||
|
||||
assert '123456' not in payload['message']
|
||||
assert 'abc.def' not in payload['exception']['value']
|
||||
assert 'abc.def' not in payload['exception']['traceback']
|
||||
assert 'ValueError' in payload['exception']['traceback']
|
||||
assert payload['extra']['authorization'] == '******'
|
||||
|
||||
|
||||
def test_json_log_formatter_emits_valid_json_record() -> None:
|
||||
initializer = LoggerInitializer()
|
||||
outputs = []
|
||||
test_logger = _logger.patch(initializer._patch_record)
|
||||
test_logger.remove()
|
||||
test_logger.add(
|
||||
lambda message: outputs.append(str(message)),
|
||||
format=initializer._json_log_formatter,
|
||||
filter=initializer._filter,
|
||||
)
|
||||
|
||||
test_logger.info('hello password=123456')
|
||||
|
||||
assert len(outputs) == 1
|
||||
payload = json.loads(outputs[0])
|
||||
assert payload['message'] == 'hello password=******'
|
||||
assert payload['level'] == 'INFO'
|
||||
|
||||
|
||||
def test_json_log_formatter_does_not_leak_internal_payload_between_handlers() -> None:
|
||||
initializer = LoggerInitializer()
|
||||
first_outputs = []
|
||||
second_outputs = []
|
||||
test_logger = _logger.patch(initializer._patch_record)
|
||||
test_logger.remove()
|
||||
test_logger.add(
|
||||
lambda message: first_outputs.append(str(message)),
|
||||
format=initializer._json_log_formatter,
|
||||
filter=initializer._filter,
|
||||
)
|
||||
test_logger.add(
|
||||
lambda message: second_outputs.append(str(message)),
|
||||
format=initializer._json_log_formatter,
|
||||
filter=initializer._filter,
|
||||
)
|
||||
|
||||
test_logger.info('hello')
|
||||
|
||||
assert 'json_payload' not in json.loads(first_outputs[0])['extra']
|
||||
assert 'json_payload' not in json.loads(second_outputs[0])['extra']
|
||||
|
||||
|
||||
def test_plain_log_formatter_sanitizes_exception_traceback() -> None:
|
||||
initializer = LoggerInitializer()
|
||||
outputs = []
|
||||
test_logger = _logger.patch(initializer._patch_record)
|
||||
test_logger.remove()
|
||||
test_logger.add(
|
||||
lambda message: outputs.append(str(message)),
|
||||
format=initializer._plain_log_formatter,
|
||||
filter=initializer._filter,
|
||||
backtrace=False,
|
||||
diagnose=False,
|
||||
)
|
||||
|
||||
try:
|
||||
raise ValueError('password=123456 token=abc.def')
|
||||
except ValueError:
|
||||
test_logger.exception('boom password=123456')
|
||||
|
||||
output = outputs[0]
|
||||
|
||||
assert '123456' not in output
|
||||
assert 'abc.def' not in output
|
||||
assert 'ValueError' in output
|
||||
assert '******' in output
|
||||
|
||||
|
||||
def test_plain_log_formatter_separates_consecutive_records() -> None:
|
||||
initializer = LoggerInitializer()
|
||||
outputs = []
|
||||
expected_output_count = 2
|
||||
test_logger = _logger.patch(initializer._patch_record)
|
||||
test_logger.remove()
|
||||
test_logger.add(
|
||||
lambda message: outputs.append(str(message)),
|
||||
format=initializer._plain_log_formatter,
|
||||
filter=initializer._filter,
|
||||
)
|
||||
|
||||
test_logger.info('first')
|
||||
test_logger.info('second')
|
||||
|
||||
assert len(outputs) == expected_output_count
|
||||
assert outputs[0].endswith('\n')
|
||||
assert 'first\n' in ''.join(outputs)
|
||||
|
||||
|
||||
def test_get_request_params_returns_structured_payload() -> None:
|
||||
async def receive() -> dict:
|
||||
return {
|
||||
'type': 'http.request',
|
||||
'body': b'{"password":"plain-password","phonenumber":"13812345678"}',
|
||||
'more_body': False,
|
||||
}
|
||||
|
||||
request = Request(
|
||||
{
|
||||
'type': 'http',
|
||||
'method': 'POST',
|
||||
'path': '/test',
|
||||
'headers': [(b'content-type', b'application/json')],
|
||||
'path_params': {},
|
||||
'query_string': b'page=1',
|
||||
},
|
||||
receive=receive,
|
||||
)
|
||||
|
||||
log_decorator = Log(title='测试日志', business_type=BusinessType.OTHER)
|
||||
params = asyncio.run(log_decorator._get_request_params(request))
|
||||
|
||||
assert params['query_params']['page'] == '1'
|
||||
assert params['json_body']['password'] == 'plain-password'
|
||||
|
||||
|
||||
def test_get_request_params_falls_back_to_raw_body_when_json_invalid() -> None:
|
||||
async def receive() -> dict:
|
||||
return {
|
||||
'type': 'http.request',
|
||||
'body': b'{bad json',
|
||||
'more_body': False,
|
||||
}
|
||||
|
||||
request = Request(
|
||||
{
|
||||
'type': 'http',
|
||||
'method': 'POST',
|
||||
'path': '/test',
|
||||
'headers': [(b'content-type', b'application/json')],
|
||||
'path_params': {},
|
||||
'query_string': b'',
|
||||
},
|
||||
receive=receive,
|
||||
)
|
||||
|
||||
log_decorator = Log(title='测试日志', business_type=BusinessType.OTHER)
|
||||
|
||||
with patch('common.annotation.log_annotation.logger.warning') as mock_warning:
|
||||
params = asyncio.run(log_decorator._get_request_params(request))
|
||||
|
||||
assert params['raw_body'] == '{bad json'
|
||||
mock_warning.assert_called_once()
|
||||
assert '解析失败' in mock_warning.call_args.args[0]
|
||||
|
||||
|
||||
def test_get_request_params_decodes_non_utf8_body_without_raising() -> None:
|
||||
async def receive() -> dict:
|
||||
return {
|
||||
'type': 'http.request',
|
||||
'body': b'\xff\xfeabc',
|
||||
'more_body': False,
|
||||
}
|
||||
|
||||
request = Request(
|
||||
{
|
||||
'type': 'http',
|
||||
'method': 'POST',
|
||||
'path': '/test',
|
||||
'headers': [(b'content-type', b'text/plain')],
|
||||
'path_params': {},
|
||||
'query_string': b'',
|
||||
},
|
||||
receive=receive,
|
||||
)
|
||||
|
||||
log_decorator = Log(title='测试日志', business_type=BusinessType.OTHER)
|
||||
params = asyncio.run(log_decorator._get_request_params(request))
|
||||
|
||||
assert params['raw_body'].endswith('abc')
|
||||
assert '\ufffd' in params['raw_body']
|
||||
|
||||
|
||||
def test_log_handles_missing_user_agent_without_error() -> None:
|
||||
log_decorator = Log(title='测试日志', business_type=BusinessType.OTHER, log_type='login')
|
||||
|
||||
assert log_decorator._get_oper_type(None) == 0
|
||||
|
||||
login_log = log_decorator._get_login_log(
|
||||
None,
|
||||
oper_ip='127.0.0.1',
|
||||
oper_location='内网IP',
|
||||
oper_time=datetime(2026, 4, 20, 12, 0, 0),
|
||||
origin_kwargs={},
|
||||
)
|
||||
|
||||
assert login_log['ipaddr'] == '127.0.0.1'
|
||||
assert login_log['loginLocation'] == '内网IP'
|
||||
assert login_log['browser'] == 'Other'
|
||||
assert login_log['os'] == 'Other'
|
||||
|
||||
|
||||
def test_build_log_text_with_summary_mode() -> None:
|
||||
log_decorator = Log(
|
||||
title='测试日志',
|
||||
business_type=BusinessType.OTHER,
|
||||
request_log_mode='summary',
|
||||
)
|
||||
|
||||
log_text = log_decorator._build_log_text(
|
||||
LogSanitizer.sanitize_data(
|
||||
{
|
||||
'query_params': {'page': '1'},
|
||||
'json_body': {'password': 'plain-password', 'modelName': 'demo-model'},
|
||||
}
|
||||
),
|
||||
mode='summary',
|
||||
include_fields=(),
|
||||
exclude_fields=(),
|
||||
payload_kind='request',
|
||||
)
|
||||
log_payload = json.loads(log_text)
|
||||
|
||||
assert log_payload['mode'] == 'summary'
|
||||
assert log_payload['query_param_keys'] == ['page']
|
||||
assert log_payload['json_body_keys'] == ['password', 'modelName']
|
||||
|
||||
|
||||
def test_build_log_text_with_include_mode() -> None:
|
||||
log_decorator = Log(
|
||||
title='测试日志',
|
||||
business_type=BusinessType.OTHER,
|
||||
response_log_mode='include',
|
||||
response_include_fields=('data.token', 'data.userName'),
|
||||
)
|
||||
|
||||
log_text = log_decorator._build_log_text(
|
||||
LogSanitizer.sanitize_data(
|
||||
{
|
||||
'code': 200,
|
||||
'msg': 'success',
|
||||
'data': {'token': 'abc.def', 'userName': 'admin', 'extra': 'ignored'},
|
||||
}
|
||||
),
|
||||
mode='include',
|
||||
include_fields=('data.token', 'data.userName'),
|
||||
exclude_fields=(),
|
||||
payload_kind='response',
|
||||
)
|
||||
log_payload = json.loads(log_text)
|
||||
|
||||
assert log_payload['mode'] == 'include'
|
||||
assert log_payload['selected']['data.token'] == '******'
|
||||
assert log_payload['selected']['data.userName'] == 'admin'
|
||||
assert 'data.extra' not in log_payload['selected']
|
||||
|
||||
|
||||
def test_include_fields_support_snake_case_matching_camel_case_payload() -> None:
|
||||
log_decorator = Log(title='测试日志', business_type=BusinessType.OTHER)
|
||||
|
||||
log_text = log_decorator._build_log_text(
|
||||
LogSanitizer.sanitize_data(
|
||||
{
|
||||
'json_body': {
|
||||
'modelCode': 'deepseek-chat',
|
||||
'supportReasoning': 'Y',
|
||||
}
|
||||
}
|
||||
),
|
||||
mode='include',
|
||||
include_fields=('json_body.model_code', 'json_body.support_reasoning'),
|
||||
exclude_fields=(),
|
||||
payload_kind='request',
|
||||
)
|
||||
log_payload = json.loads(log_text)
|
||||
|
||||
assert log_payload['selected']['json_body.model_code'] == 'deepseek-chat'
|
||||
assert log_payload['selected']['json_body.support_reasoning'] == 'Y'
|
||||
|
||||
|
||||
def test_include_fields_support_camel_case_matching_snake_case_payload() -> None:
|
||||
log_decorator = Log(title='测试日志', business_type=BusinessType.OTHER)
|
||||
|
||||
log_text = log_decorator._build_log_text(
|
||||
LogSanitizer.sanitize_data(
|
||||
{
|
||||
'data': {
|
||||
'user_name': 'admin',
|
||||
'login_ip': '192.168.1.10',
|
||||
}
|
||||
}
|
||||
),
|
||||
mode='include',
|
||||
include_fields=('data.userName', 'data.loginIp'),
|
||||
exclude_fields=(),
|
||||
payload_kind='response',
|
||||
)
|
||||
log_payload = json.loads(log_text)
|
||||
|
||||
assert log_payload['selected']['data.userName'] == 'admin'
|
||||
assert log_payload['selected']['data.loginIp'] == '192.168.1.10'
|
||||
|
||||
|
||||
def test_request_field_helper_builds_canonical_path() -> None:
|
||||
assert RequestLogFieldRoot.JSON_BODY.field('model_code') == 'json_body.model_code'
|
||||
assert ResponseLogFieldRoot.DATA.field('userName') == 'data.userName'
|
||||
|
||||
|
||||
def test_code_field_masking_only_applies_to_login_like_verification_code() -> None:
|
||||
login_payload = {
|
||||
'uuid': 'captcha-session',
|
||||
'code': 'A1B2',
|
||||
'msg': 'login',
|
||||
}
|
||||
business_payload = {
|
||||
'code': '200',
|
||||
'msg': 'ok',
|
||||
}
|
||||
|
||||
sanitized_login_payload = LogSanitizer.sanitize_data(login_payload)
|
||||
sanitized_business_payload = LogSanitizer.sanitize_data(business_payload)
|
||||
|
||||
assert sanitized_login_payload['code'] == '******'
|
||||
assert sanitized_business_payload['code'] == '200'
|
||||
|
||||
|
||||
def test_build_log_text_with_exclude_mode() -> None:
|
||||
log_decorator = Log(
|
||||
title='测试日志',
|
||||
business_type=BusinessType.OTHER,
|
||||
request_log_mode='exclude',
|
||||
request_exclude_fields=('json_body.api_key',),
|
||||
)
|
||||
|
||||
log_text = log_decorator._build_log_text(
|
||||
LogSanitizer.sanitize_data(
|
||||
{
|
||||
'json_body': {
|
||||
'modelCode': 'deepseek-chat',
|
||||
'apiKey': 'sk-test',
|
||||
'baseUrl': 'https://api.example.com',
|
||||
}
|
||||
}
|
||||
),
|
||||
mode='exclude',
|
||||
include_fields=(),
|
||||
exclude_fields=('json_body.api_key',),
|
||||
payload_kind='request',
|
||||
)
|
||||
log_payload = json.loads(log_text)
|
||||
|
||||
assert 'apiKey' not in log_payload['json_body']
|
||||
assert log_payload['json_body']['modelCode'] == 'deepseek-chat'
|
||||
assert log_payload['json_body']['baseUrl'] == 'https://api.example.com'
|
||||
|
||||
|
||||
def test_exclude_fields_support_snake_case_matching_camel_case_payload() -> None:
|
||||
log_decorator = Log(title='测试日志', business_type=BusinessType.OTHER)
|
||||
|
||||
log_text = log_decorator._build_log_text(
|
||||
LogSanitizer.sanitize_data(
|
||||
{
|
||||
'data': {
|
||||
'apiKey': '******',
|
||||
'baseUrl': 'https://api.example.com',
|
||||
}
|
||||
}
|
||||
),
|
||||
mode='exclude',
|
||||
include_fields=(),
|
||||
exclude_fields=('data.api_key',),
|
||||
payload_kind='response',
|
||||
)
|
||||
log_payload = json.loads(log_text)
|
||||
|
||||
assert 'apiKey' not in log_payload['data']
|
||||
assert log_payload['data']['baseUrl'] == 'https://api.example.com'
|
||||
|
||||
|
||||
def test_exclude_fields_remove_multiple_list_indexes_without_shift() -> None:
|
||||
log_decorator = Log(title='测试日志', business_type=BusinessType.OTHER)
|
||||
|
||||
log_text = log_decorator._build_log_text(
|
||||
LogSanitizer.sanitize_data(
|
||||
{
|
||||
'rows': [
|
||||
{'id': 1},
|
||||
{'id': 2},
|
||||
{'id': 3},
|
||||
]
|
||||
}
|
||||
),
|
||||
mode='exclude',
|
||||
include_fields=(),
|
||||
exclude_fields=('rows.0', 'rows.1'),
|
||||
payload_kind='response',
|
||||
)
|
||||
log_payload = json.loads(log_text)
|
||||
|
||||
assert log_payload['rows'] == [{'id': 3}]
|
||||
|
||||
|
||||
def test_collect_field_path_warnings_for_invalid_root() -> None:
|
||||
log_decorator = Log(title='测试日志', business_type=BusinessType.OTHER)
|
||||
|
||||
warnings = log_decorator._collect_field_path_warnings(
|
||||
mode='include',
|
||||
include_fields=('body.modelCode',),
|
||||
exclude_fields=(),
|
||||
payload_kind='request',
|
||||
)
|
||||
|
||||
assert len(warnings) == 1
|
||||
assert 'body.modelCode' in warnings[0]
|
||||
assert 'json_body' in warnings[0]
|
||||
|
||||
|
||||
def test_collect_field_path_warnings_accepts_normalized_root() -> None:
|
||||
log_decorator = Log(title='测试日志', business_type=BusinessType.OTHER)
|
||||
|
||||
warnings = log_decorator._collect_field_path_warnings(
|
||||
mode='include',
|
||||
include_fields=('jsonBody.modelCode',),
|
||||
exclude_fields=(),
|
||||
payload_kind='request',
|
||||
)
|
||||
|
||||
assert warnings == []
|
||||
|
||||
|
||||
def test_collect_field_path_warnings_for_missing_include_fields() -> None:
|
||||
log_decorator = Log(title='测试日志', business_type=BusinessType.OTHER)
|
||||
|
||||
warnings = log_decorator._collect_field_path_warnings(
|
||||
mode='include',
|
||||
include_fields=(),
|
||||
exclude_fields=(),
|
||||
payload_kind='response',
|
||||
)
|
||||
|
||||
assert len(warnings) == 1
|
||||
assert 'include模式' in warnings[0]
|
||||
assert 'data' in warnings[0]
|
||||
|
||||
|
||||
def test_collect_field_path_warnings_for_missing_exclude_fields() -> None:
|
||||
log_decorator = Log(title='测试日志', business_type=BusinessType.OTHER)
|
||||
|
||||
warnings = log_decorator._collect_field_path_warnings(
|
||||
mode='exclude',
|
||||
include_fields=(),
|
||||
exclude_fields=(),
|
||||
payload_kind='request',
|
||||
)
|
||||
|
||||
assert len(warnings) == 1
|
||||
assert 'exclude模式' in warnings[0]
|
||||
assert 'json_body' in warnings[0]
|
||||
|
||||
|
||||
def test_log_warns_when_include_fields_do_not_match_mode() -> None:
|
||||
with patch('common.annotation.log_annotation.logger.warning') as mock_warning:
|
||||
Log(
|
||||
title='测试日志',
|
||||
business_type=BusinessType.OTHER,
|
||||
request_log_mode='summary',
|
||||
request_include_fields=('json_body.modelCode',),
|
||||
)
|
||||
|
||||
mock_warning.assert_called_once()
|
||||
assert '不会生效' in mock_warning.call_args.args[0]
|
||||
|
||||
|
||||
def test_request_include_fields_invalid_root_raises_value_error() -> None:
|
||||
try:
|
||||
Log(
|
||||
title='测试日志',
|
||||
business_type=BusinessType.OTHER,
|
||||
request_log_mode='include',
|
||||
request_include_fields=('body.modelCode',),
|
||||
)
|
||||
except ValueError as exc:
|
||||
assert 'body.modelCode' in str(exc)
|
||||
else:
|
||||
raise AssertionError('expected ValueError for invalid request include root')
|
||||
|
||||
|
||||
def test_request_exclude_fields_invalid_root_raises_value_error() -> None:
|
||||
try:
|
||||
Log(
|
||||
title='测试日志',
|
||||
business_type=BusinessType.OTHER,
|
||||
request_log_mode='exclude',
|
||||
request_exclude_fields=('body.apiKey',),
|
||||
)
|
||||
except ValueError as exc:
|
||||
assert 'body.apiKey' in str(exc)
|
||||
else:
|
||||
raise AssertionError('expected ValueError for invalid request exclude root')
|
||||
|
||||
|
||||
def test_build_log_text_warns_for_missing_include_field() -> None:
|
||||
log_decorator = Log(title='测试日志', business_type=BusinessType.OTHER)
|
||||
|
||||
with patch('common.annotation.log_annotation.logger.warning') as mock_warning:
|
||||
log_text = log_decorator._build_log_text(
|
||||
LogSanitizer.sanitize_data(
|
||||
{
|
||||
'json_body': {
|
||||
'modelCode': 'deepseek-chat',
|
||||
}
|
||||
}
|
||||
),
|
||||
mode='include',
|
||||
include_fields=('json_body.modleCode',),
|
||||
exclude_fields=(),
|
||||
payload_kind='request',
|
||||
)
|
||||
|
||||
log_payload = json.loads(log_text)
|
||||
|
||||
assert log_payload['selected'] == {}
|
||||
mock_warning.assert_called_once()
|
||||
assert '未命中' in mock_warning.call_args.args[0]
|
||||
assert 'modleCode' in mock_warning.call_args.args[0]
|
||||
|
||||
|
||||
def test_missing_include_field_warning_only_emitted_once() -> None:
|
||||
log_decorator = Log(title='测试日志', business_type=BusinessType.OTHER)
|
||||
payload = LogSanitizer.sanitize_data({'data': {'userName': 'admin'}})
|
||||
|
||||
with patch('common.annotation.log_annotation.logger.warning') as mock_warning:
|
||||
log_decorator._build_log_text(
|
||||
payload,
|
||||
mode='include',
|
||||
include_fields=('data.userNmae',),
|
||||
exclude_fields=(),
|
||||
payload_kind='response',
|
||||
)
|
||||
log_decorator._build_log_text(
|
||||
payload,
|
||||
mode='include',
|
||||
include_fields=('data.userNmae',),
|
||||
exclude_fields=(),
|
||||
payload_kind='response',
|
||||
)
|
||||
|
||||
mock_warning.assert_called_once()
|
||||
|
||||
|
||||
def test_include_fields_keep_explicit_none_value() -> None:
|
||||
log_decorator = Log(title='测试日志', business_type=BusinessType.OTHER)
|
||||
|
||||
with patch('common.annotation.log_annotation.logger.warning') as mock_warning:
|
||||
log_text = log_decorator._build_log_text(
|
||||
LogSanitizer.sanitize_data(
|
||||
{
|
||||
'data': {
|
||||
'userName': None,
|
||||
}
|
||||
}
|
||||
),
|
||||
mode='include',
|
||||
include_fields=('data.userName',),
|
||||
exclude_fields=(),
|
||||
payload_kind='response',
|
||||
)
|
||||
|
||||
log_payload = json.loads(log_text)
|
||||
|
||||
assert 'data.userName' in log_payload['selected']
|
||||
assert log_payload['selected']['data.userName'] is None
|
||||
mock_warning.assert_not_called()
|
||||
|
||||
|
||||
def test_include_field_warning_reports_ambiguous_normalized_match() -> None:
|
||||
log_decorator = Log(title='测试日志', business_type=BusinessType.OTHER)
|
||||
|
||||
with patch('common.annotation.log_annotation.logger.warning') as mock_warning:
|
||||
log_text = log_decorator._build_log_text(
|
||||
LogSanitizer.sanitize_data(
|
||||
{
|
||||
'data': {
|
||||
'user_name': 'admin',
|
||||
'userName': 'root',
|
||||
}
|
||||
}
|
||||
),
|
||||
mode='include',
|
||||
include_fields=('data.username',),
|
||||
exclude_fields=(),
|
||||
payload_kind='response',
|
||||
)
|
||||
|
||||
log_payload = json.loads(log_text)
|
||||
|
||||
assert log_payload['selected'] == {}
|
||||
mock_warning.assert_called_once()
|
||||
assert '命名冲突' in mock_warning.call_args.args[0]
|
||||
assert 'user_name' in mock_warning.call_args.args[0]
|
||||
assert 'userName' in mock_warning.call_args.args[0]
|
||||
|
||||
|
||||
def test_build_log_text_warns_for_missing_exclude_field() -> None:
|
||||
log_decorator = Log(title='测试日志', business_type=BusinessType.OTHER)
|
||||
|
||||
with patch('common.annotation.log_annotation.logger.warning') as mock_warning:
|
||||
log_text = log_decorator._build_log_text(
|
||||
LogSanitizer.sanitize_data(
|
||||
{
|
||||
'json_body': {
|
||||
'modelCode': 'deepseek-chat',
|
||||
}
|
||||
}
|
||||
),
|
||||
mode='exclude',
|
||||
include_fields=(),
|
||||
exclude_fields=('json_body.apiKey',),
|
||||
payload_kind='request',
|
||||
)
|
||||
|
||||
log_payload = json.loads(log_text)
|
||||
|
||||
assert log_payload['json_body']['modelCode'] == 'deepseek-chat'
|
||||
mock_warning.assert_called_once()
|
||||
assert '排除路径未命中' in mock_warning.call_args.args[0]
|
||||
|
||||
|
||||
def test_build_summary_payload_supports_message_field_fallback() -> None:
|
||||
log_decorator = Log(title='测试日志', business_type=BusinessType.OTHER)
|
||||
|
||||
summary_payload = log_decorator._build_summary_payload({'code': 200, 'message': '获取成功'}, 'response')
|
||||
status, error_msg = log_decorator._get_status_and_error_msg({'code': 500, 'message': '获取失败'})
|
||||
|
||||
assert summary_payload['msg'] == '获取成功'
|
||||
assert status == 1
|
||||
assert error_msg == '获取失败'
|
||||
|
||||
|
||||
def test_log_decorator_truncates_oversized_json_result() -> None:
|
||||
log_decorator = Log(title='测试日志', business_type=BusinessType.OTHER)
|
||||
json_result = log_decorator._build_log_text(
|
||||
{'code': 200, 'data': {'content': 'x' * 2100}},
|
||||
mode='full',
|
||||
include_fields=(),
|
||||
exclude_fields=(),
|
||||
payload_kind='response',
|
||||
)
|
||||
|
||||
limited_json_result = log_decorator._limit_log_text(json_result, log_decorator._json_result_len, '返回参数过长')
|
||||
|
||||
assert limited_json_result == '返回参数过长'
|
||||
Reference in New Issue
Block a user