初始提交

This commit is contained in:
焦龙言
2026-07-24 09:43:10 +08:00
commit b5b4721456
793 changed files with 116305 additions and 0 deletions
@@ -0,0 +1,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