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

576 lines
21 KiB
Python

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