初始提交
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user