ShiErFeng/shierfeng-fastapi-backend/tests/cli/tui/actions/test_action_execution.py
2026-07-24 09:43:10 +08:00

580 lines
23 KiB
Python

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)