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)