diff --git a/apps/studio/src/orisys_studio/ui/window_menus.py b/apps/studio/src/orisys_studio/ui/window_menus.py new file mode 100644 index 0000000..3b54016 --- /dev/null +++ b/apps/studio/src/orisys_studio/ui/window_menus.py @@ -0,0 +1,207 @@ +"""Shell menus and actions prepared for S2, with no business signal handlers. + +All actions have the supplied QMainWindow as their Qt parent. The caller binds +commands after applying initial checks, and owns reset-target wording, pause +state and action availability. Construction never calls the window's methods +other than QMainWindow's menu/action API. +""" + +from __future__ import annotations + +from orisys_app_shared.i18n import t +from PySide6.QtCore import Qt +from PySide6.QtGui import QAction, QActionGroup +from PySide6.QtWidgets import QMainWindow + + +class WindowMenus: + """Create the existing shell actions independently of any application controller.""" + + def __init__( + self, + window: QMainWindow, + *, + show_cal: bool, + show_finger_3d: bool, + show_fps: bool, + show_diagnostics: bool, + settings_expanded: bool, + ) -> None: + menu_bar = window.menuBar() + + self.file_menu = menu_bar.addMenu("") + self.act_open_video = QAction(window) + self.file_menu.addAction(self.act_open_video) + self.act_reveal_config = QAction(window) + self.file_menu.addAction(self.act_reveal_config) + self.file_menu.addSeparator() + self.act_playback = QAction(window) + self.file_menu.addAction(self.act_playback) + self.act_playback_latest = QAction(window) + self.file_menu.addAction(self.act_playback_latest) + + # Space is a window action rather than an entry in a menu. + self.act_play = QAction(window) + self.act_play.setShortcut("Space") + self.act_play.setShortcutContext(Qt.ShortcutContext.ApplicationShortcut) + window.addAction(self.act_play) + + # Acquisition session controls (run / preview / pause). Order on the bar: + # File | Session | Analysis | View | Help. + self.session_menu = menu_bar.addMenu("") + + self.act_run = QAction(window) + self.act_run.setShortcut("F5") + self.session_menu.addAction(self.act_run) + + self.act_stop = QAction(window) + self.act_stop.setShortcut("Esc") + self.session_menu.addAction(self.act_stop) + + self.act_preview = QAction(window) + self.act_preview.setShortcut("F6") + self.session_menu.addAction(self.act_preview) + + self.act_reset = QAction(window) + self.act_reset.setShortcut("Ctrl+R") + self.session_menu.addAction(self.act_reset) + + # Pause has a shortcut as well as a rail button: an operator who is holding a + # sample still wants to stop the numbers moving without reaching for the mouse. + self.act_pause = QAction(window) + self.act_pause.setShortcut("F7") + self.act_pause.setCheckable(True) + self.session_menu.addAction(self.act_pause) + + # Offline analysis of a recording. Separate from Session: that menu starts + # and stops a live device session. + self.analysis_menu = menu_bar.addMenu("") + # Serve / leave a stored analysis result (or run the pass when there is none). + self.act_recompute = QAction(window) + self.act_recompute.setCheckable(True) + self.act_recompute.setChecked(False) + self.analysis_menu.addAction(self.act_recompute) + # Reopen the setup dialog after the first pass (profile / calibration / reference). + self.act_analysis_setup = QAction(window) + self.analysis_menu.addAction(self.act_analysis_setup) + + self.view_menu = menu_bar.addMenu("") + self.act_cal_panel = QAction(window) + self.act_cal_panel.setCheckable(True) + self.act_cal_panel.setChecked(show_cal) + self.view_menu.addAction(self.act_cal_panel) + + self.act_finger_3d = QAction(window) + self.act_finger_3d.setCheckable(True) + self.act_finger_3d.setChecked(show_finger_3d) + self.view_menu.addAction(self.act_finger_3d) + + # Everything the 3D pane can be told lives in one dialog: the pane itself is for + # looking at (see ``Mesh3dPanel``). This is the menu way in; the pane's header + # has the gear, which is the same call. + self.act_scene3d_settings = QAction(window) + self.view_menu.addAction(self.act_scene3d_settings) + + self.act_show_fps = QAction(window) + self.act_show_fps.setCheckable(True) + self.act_show_fps.setChecked(show_fps) + self.view_menu.addAction(self.act_show_fps) + + # How much the panels explain themselves around their pictures. Checked *before* + # the signal is connected, so restoring the preference cannot look like a click. + self.act_show_diagnostics = QAction(window) + self.act_show_diagnostics.setCheckable(True) + self.act_show_diagnostics.setChecked(show_diagnostics) + self.view_menu.addAction(self.act_show_diagnostics) + + self.act_settings = QAction(window) + self.act_settings.setCheckable(True) + self.act_settings.setChecked(settings_expanded) + self.view_menu.addAction(self.act_settings) + + self.act_plot_settings = QAction(window) + self.view_menu.addAction(self.act_plot_settings) + + self.act_rotate_arrows = QAction(window) + self.view_menu.addAction(self.act_rotate_arrows) + self.view_menu.addSeparator() + + # The graphics half of the old 重置: curves, window, cursor and the 3D camera. + # Separate because resetting the device tracker is not the same intention as + # wanting the views back, and only the first one belongs to the device (session menu). + self.act_reset_view = QAction(window) + self.act_reset_view.setShortcut("F8") + self.view_menu.addAction(self.act_reset_view) + self.view_menu.addSeparator() + + self.theme_menu = self.view_menu.addMenu("") + self.theme_group = QActionGroup(window) + self.theme_group.setExclusive(True) + self.act_theme_dark = QAction(window) + self.act_theme_dark.setCheckable(True) + self.act_theme_light = QAction(window) + self.act_theme_light.setCheckable(True) + self.theme_group.addAction(self.act_theme_dark) + self.theme_group.addAction(self.act_theme_light) + self.theme_menu.addAction(self.act_theme_dark) + self.theme_menu.addAction(self.act_theme_light) + + self.lang_menu = self.view_menu.addMenu("") + self.lang_group = QActionGroup(window) + self.lang_group.setExclusive(True) + self.act_lang_zh = QAction(window) + self.act_lang_zh.setCheckable(True) + self.act_lang_en = QAction(window) + self.act_lang_en.setCheckable(True) + self.lang_group.addAction(self.act_lang_zh) + self.lang_group.addAction(self.act_lang_en) + self.lang_menu.addAction(self.act_lang_zh) + self.lang_menu.addAction(self.act_lang_en) + + self.help_menu = menu_bar.addMenu("") + self.act_about = QAction(window) + self.help_menu.addAction(self.act_about) + + def retranslate(self) -> None: + """Update static labels; the caller refreshes context-dependent reset wording.""" + self.file_menu.setTitle(t("menu.file")) + self.act_open_video.setText(t("menu.open_video")) + self.act_reveal_config.setText(t("menu.reveal_config")) + self.act_playback.setText(t("menu.playback")) + self.act_playback_latest.setText(t("menu.playback_latest")) + self.session_menu.setTitle(t("menu.session")) + self.act_run.setText(t("menu.run")) + self.act_stop.setText(t("menu.stop")) + self.act_preview.setText(t("menu.preview")) + self.act_pause.setText(t("menu.pause")) + self.analysis_menu.setTitle(t("menu.analysis")) + self.act_recompute.setText(t("menu.recompute")) + self.act_recompute.setToolTip(t("menu.recompute_tip")) + self.act_analysis_setup.setText(t("menu.analysis_setup")) + self.act_analysis_setup.setToolTip(t("menu.analysis_setup_tip")) + self.view_menu.setTitle(t("menu.view")) + self.act_cal_panel.setText(t("menu.calibration_panel")) + self.act_finger_3d.setText(t("menu.finger_3d")) + self.act_scene3d_settings.setText(t("menu.scene3d_settings")) + self.act_show_fps.setText(t("menu.show_fps")) + self.act_show_diagnostics.setText(t("menu.show_diagnostics")) + self.act_settings.setText(t("menu.settings")) + self.act_plot_settings.setText(t("menu.plot_settings")) + self.act_rotate_arrows.setText(t("menu.rotate_arrows")) + self.act_reset_view.setText(t("menu.reset_view")) + self.act_reset_view.setToolTip(t("menu.reset_view_tip")) + self.theme_menu.setTitle(t("menu.theme")) + self.act_theme_dark.setText(t("menu.theme_dark")) + self.act_theme_light.setText(t("menu.theme_light")) + self.lang_menu.setTitle(t("menu.language")) + self.act_lang_zh.setText(t("menu.lang_zh")) + self.act_lang_en.setText(t("menu.lang_en")) + self.help_menu.setTitle(t("menu.help")) + self.act_about.setText(t("menu.about")) + + def sync_appearance(self, *, theme: str, language: str) -> None: + """Mirror appearance preferences without saving them or changing the application.""" + self.act_theme_dark.setChecked(theme != "light") + self.act_theme_light.setChecked(theme == "light") + self.act_lang_zh.setChecked(language.startswith("zh")) + self.act_lang_en.setChecked(not language.startswith("zh")) diff --git a/apps/studio/src/orisys_studio/ui/window_ui.py b/apps/studio/src/orisys_studio/ui/window_ui.py new file mode 100644 index 0000000..48d537d --- /dev/null +++ b/apps/studio/src/orisys_studio/ui/window_ui.py @@ -0,0 +1,78 @@ +"""Central shell widgets prepared for S2, without application wiring. + +Construct on the GUI thread. The caller owns controllers, frame delivery, the +shared domain latch, chart store binding and preference restoration. Nothing +here starts acquisition, applies calibration or creates the lazy 3D view. +""" + +from __future__ import annotations + +from orisys_app_shared.paths import logo_path +from PySide6.QtCore import Qt +from PySide6.QtGui import QIcon +from PySide6.QtWidgets import QMainWindow, QSplitter, QVBoxLayout, QWidget + +from orisys_studio.ui.analysis_progress import AnalysisProgress +from orisys_studio.ui.arrow_panel import ArrowPanel +from orisys_studio.ui.brand_bar import BrandBar +from orisys_studio.ui.cal_panel import CalPanel +from orisys_studio.ui.force_plots import ForcePlotsPanel +from orisys_studio.ui.mesh3d_panel import Mesh3dPanel +from orisys_studio.ui.sensor_picker import SensorPickerWidget +from orisys_studio.ui.toolbar import ToolbarWidget + + +class WindowUi: + """Own widget references, leaving all workflow decisions to the caller.""" + + def __init__(self, window: QMainWindow, *, theme: str) -> None: + icon = QIcon(str(logo_path())) + if not icon.isNull(): + window.setWindowIcon(icon) + + self.central = QWidget() + window.setCentralWidget(self.central) + root = QVBoxLayout(self.central) + root.setContentsMargins(10, 10, 10, 10) + root.setSpacing(8) + + self.brand_bar = BrandBar(theme=theme) + root.addWidget(self.brand_bar) + # The sensor dialog adopts this picker later; it is not in the central layout. + self.sensor_picker = SensorPickerWidget() + self.toolbar = ToolbarWidget(theme=theme) + root.addWidget(self.toolbar) + self.status_chip = self.toolbar.status_chip + self.analysis_progress = AnalysisProgress(theme=theme) + root.addWidget(self.analysis_progress) + + self.content_splitter = QSplitter(Qt.Orientation.Horizontal) + self.mesh3d_panel = Mesh3dPanel(theme=theme) + self.arrow_panel = ArrowPanel(theme=theme) + self.force_plots = ForcePlotsPanel(theme=theme) + self.cal_panel = CalPanel(theme=theme) + self.content_splitter.addWidget(self.mesh3d_panel) + self.content_splitter.addWidget(self.arrow_panel) + self.content_splitter.addWidget(self.force_plots) + self.content_splitter.addWidget(self.cal_panel) + self.content_splitter.setChildrenCollapsible(False) + root.addWidget(self.content_splitter, 1) + + def set_theme(self, theme: str) -> None: + """Refresh local surfaces; the application stylesheet remains the caller's.""" + self.force_plots.set_theme(theme) + self.cal_panel.set_theme(theme) + self.brand_bar.set_theme(theme) + self.toolbar.set_theme(theme) + self.arrow_panel.set_theme(theme) + self.mesh3d_panel.set_theme(theme) + + def retranslate(self) -> None: + """Refresh widget text without changing the application locale or state.""" + self.toolbar.retranslate() + self.sensor_picker.retranslate() + self.mesh3d_panel.retranslate() + self.arrow_panel.retranslate() + self.force_plots.retranslate() + self.cal_panel.retranslate() + self.brand_bar.retranslate() diff --git a/apps/studio/tests/test_window_surfaces.py b/apps/studio/tests/test_window_surfaces.py new file mode 100644 index 0000000..06e59a4 --- /dev/null +++ b/apps/studio/tests/test_window_surfaces.py @@ -0,0 +1,166 @@ +"""Prepared shell surfaces work without the main window or business wiring.""" + +import pytest +from orisys_app_shared.i18n import get_translator, set_locale, t +from orisys_app_shared.sensors import scan_sensor_catalog +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QMainWindow + +from orisys_studio.ui import sensor_picker as picker_module +from orisys_studio.ui.window_menus import WindowMenus +from orisys_studio.ui.window_ui import WindowUi + + +@pytest.fixture +def window(qt_app): + original_locale = get_translator().locale + widget = QMainWindow() + yield widget + widget.close() + widget.deleteLater() + set_locale(original_locale) + + +@pytest.fixture +def surface(window, tmp_path, monkeypatch): + catalog = scan_sensor_catalog( + shipped_root=tmp_path / "shipped", + user_root=tmp_path / "user", + ) + monkeypatch.setattr(picker_module, "scan_sensor_catalog", lambda: catalog) + ui = WindowUi(window, theme="dark") + yield ui + # The picker is intentionally not parented until a sensor dialog adopts it. + ui.sensor_picker.close() + ui.sensor_picker.deleteLater() + + +def menus(window, **changes): + values = dict( + show_cal=False, + show_finger_3d=True, + show_fps=True, + show_diagnostics=False, + settings_expanded=False, + ) + values.update(changes) + return WindowMenus(window, **values) + + +def test_central_surface_preserves_panel_order_and_ownership(window, surface): + assert window.centralWidget() is surface.central + layout = surface.central.layout() + assert [layout.itemAt(i).widget() for i in range(layout.count())] == [ + surface.brand_bar, + surface.toolbar, + surface.analysis_progress, + surface.content_splitter, + ] + assert layout.spacing() == 8 + margins = layout.contentsMargins() + assert (margins.left(), margins.top(), margins.right(), margins.bottom()) == ( + 10, + ) * 4 + splitter = surface.content_splitter + panels = [ + surface.mesh3d_panel, + surface.arrow_panel, + surface.force_plots, + surface.cal_panel, + ] + assert splitter.orientation() == Qt.Orientation.Horizontal + assert [splitter.widget(i) for i in range(splitter.count())] == panels + assert all(panel.parentWidget() is splitter for panel in panels) + assert not splitter.childrenCollapsible() + assert surface.sensor_picker.parentWidget() is None + assert surface.status_chip is surface.toolbar.status_chip + + +def test_surface_refresh_keeps_3d_lazy_and_global_theme_unchanged(qt_app, surface): + stylesheet = qt_app.styleSheet() + plot_background = surface.force_plots.plot_widget.backgroundBrush().color() + surface.set_theme("light") + surface.retranslate() + assert surface.force_plots.plot_widget.backgroundBrush().color() != plot_background + assert qt_app.styleSheet() == stylesheet + assert surface.mesh3d_panel.view is None + assert surface.brand_bar.btn_live.text() == t("brand.nav_live") + + +@pytest.mark.parametrize("locale", ["zh_CN", "en_US"]) +def test_menu_order_and_labels_match_the_shell(window, locale): + set_locale(locale) + ui = menus(window) + ui.retranslate() + assert [action.text() for action in window.menuBar().actions()] == [ + t("menu.file"), + t("menu.session"), + t("menu.analysis"), + t("menu.view"), + t("menu.help"), + ] + assert ui.session_menu.actions() == [ + ui.act_run, + ui.act_stop, + ui.act_preview, + ui.act_reset, + ui.act_pause, + ] + assert ui.act_analysis_setup.toolTip() == t("menu.analysis_setup_tip") + assert ui.act_recompute.toolTip() == t("menu.recompute_tip") + + +def test_shortcuts_are_available_without_any_business_host(window): + ui = menus(window) + assert [ + action.shortcut().toString() + for action in ( + ui.act_run, + ui.act_stop, + ui.act_preview, + ui.act_reset, + ui.act_pause, + ui.act_reset_view, + ui.act_play, + ) + ] == ["F5", "Esc", "F6", "Ctrl+R", "F7", "F8", "Space"] + assert ui.act_play.shortcutContext() == Qt.ShortcutContext.ApplicationShortcut + assert ui.act_play in window.actions() + seen = [] + ui.act_run.triggered.connect(lambda: seen.append("run")) + ui.act_run.trigger() + assert seen == ["run"] + # These actions have no device, calibration or analysis handlers to invoke yet. + ui.act_reset.trigger() + ui.act_recompute.trigger() + assert not hasattr(window, "session") + + +def test_menu_initial_checks_and_appearance_refresh_are_separate(window): + ui = menus(window, show_cal=True, show_fps=False, settings_expanded=True) + assert ui.act_cal_panel.isChecked() + assert ui.act_finger_3d.isChecked() + assert not ui.act_show_fps.isChecked() + assert not ui.act_show_diagnostics.isChecked() + assert ui.act_settings.isChecked() + assert not ui.act_recompute.isChecked() + ui.sync_appearance(theme="light", language="en_US") + assert ui.act_theme_light.isChecked() and not ui.act_theme_dark.isChecked() + assert ui.act_lang_en.isChecked() and not ui.act_lang_zh.isChecked() + ui.act_theme_dark.trigger() + assert ui.act_theme_dark.isChecked() and not ui.act_theme_light.isChecked() + ui.act_lang_zh.trigger() + assert ui.act_lang_zh.isChecked() and not ui.act_lang_en.isChecked() + assert ui.act_cal_panel.isChecked() + + +def test_text_refresh_preserves_controller_owned_action_state(window): + ui = menus(window) + ui.act_reset.setText("context-dependent reset") + ui.act_pause.setChecked(True) + ui.act_run.setEnabled(False) + ui.retranslate() + ui.sync_appearance(theme="dark", language="zh_CN") + assert ui.act_reset.text() == "context-dependent reset" + assert ui.act_pause.isChecked() + assert not ui.act_run.isEnabled() diff --git a/docs/architecture/studio-architecture.md b/docs/architecture/studio-architecture.md index 7d0cafc..8d817cb 100644 --- a/docs/architecture/studio-architecture.md +++ b/docs/architecture/studio-architecture.md @@ -1,13 +1,14 @@ # Studio 软件架构 -本文的当前职责说明对应 2026-10-18 主窗口重构 S1;后续工作和验收由 +本文说明已接线的 S1 和 2026-10-19 准备但尚未接线的 S2;后续工作和验收由 [分步重构计划](studio-window-refactor-plan.zh.md) 维护。下方旧扫描图表保留为历史证据, 不能用其中的行数或“已完成”判断当前代码。 ## 当前职责与数据流 -`app_window.py::StudioMainWindow` 创建控件和控制器并连接信号;本次从 3,181 行减至 -3,052 行。它仍承担菜单、预览、标定交互、分析配置和运行协调,尚未完成薄窗口目标。 +`app_window.py::StudioMainWindow` 创建控件和控制器并连接信号。S1 批次从 3,181 行减至 +3,052 行,这是当日指标;当前另有并行标定修改。窗口仍承担菜单、预览、标定交互、 +分析配置和运行协调,尚未完成薄窗口目标。 `status_presenter.py::StatusPresenter` 只持有工具栏,不持有窗口或会话控制器。 窗口在 GUI 线程收集一次性的 `RailState`,Presenter 负责 phase、来源、标定、有效性及 @@ -39,6 +40,16 @@ graph TD 执行结果、环境及尚未验证范围记录在实施计划。状态转发暂留用于现有调用方,后续按职责迁移, 不增加新的全局状态容器或将剩余逻辑全部并入回放控制器。 +## S2 准备模块,尚未接线 + +`ui/window_ui.py::WindowUi` 创建控件和布局,提供局部主题、文字更新; +`ui/window_menus.py::WindowMenus` 创建菜单、快捷键和勾选初值,不绑定业务回调。 +当前运行图不包含这两个模块,主窗口仍使用原构建路径,标定读取和应用路径由并行任务维护。 + +独立检查由 `test_window_surfaces.py` 负责。接线时仍由窗口装配 FrameView、共享 domain latch、 +曲线 store 和控制器信号;reset 文字及操作可用性保留原负责人。具体顺序见实施计划, +不能把独立模块测试通过等同于主窗口迁移完成。 + ## 历史测量的适用范围 以下第 1–6 节及配套 `studio-architecture.svg`、`studio-architecture.png` 是旧扫描快照。 diff --git a/docs/architecture/studio-window-refactor-plan.zh.md b/docs/architecture/studio-window-refactor-plan.zh.md index d568a21..afbffb8 100644 --- a/docs/architecture/studio-window-refactor-plan.zh.md +++ b/docs/architecture/studio-window-refactor-plan.zh.md @@ -41,6 +41,7 @@ ### S2:界面构建 +- [x] 准备 `ui/window_ui.py` 和 `ui/window_menus.py`,以独立窗口验证,尚未接入运行路径。 - [ ] 提取 `ui/window_ui.py` 的控件和布局构建,保持控件身份、顺序及 parent。 - [ ] 单独迁移菜单和 QAction 创建;由窗口显式连接功能操作。 - [ ] 迁移对应文字与主题更新,保留必要属性兼容入口。 @@ -87,13 +88,42 @@ 显示设置重绘测试。2D/3D 使用同一帧;实时不覆盖回放;重绘不启动新分析。 结束条件是主窗口只承担约定职责,且所有相关检查通过,不以行数作为唯一标准。 -## 验证与当前进度 +## 验证记录 解释器使用 `.venv-build/Scripts/python.exe`。定向 pytest 显式使用独立的 `--basetemp` 和 `-p no:cacheprovider`;日志位于 `.scratch/studio-window-refactor/`。 每个迁移步骤运行对应检查;可交付批次运行 `tools/gate.ps1 -Task studio-window-refactor`。 文档使用工作区 `scripts/doc_lint.py` 和 `scripts/doc_count.py`,不扩大基线。 +### 2026-10-19:S2 模块准备,接线延期 + +用户要求避开另一个进程正在修改的标定配置读取和应用路径,本批只新增模块及独立测试, +不编辑 `app_window.py`、现有面板、标定服务、控制器或配置文件。 +基准为 `main` 的 `a0c13c1` 加当时工作区修改;读取的主窗口快照留在本批 scratch。 + +`WindowUi` 创建中央控件、四个面板及布局,提供局部主题和文字更新。 +`WindowMenus` 创建 QAction、快捷键、菜单组和勾选初值,提供静态文字及主题/语言勾选同步。 +两者均不保存偏好、不绑定业务回调,不启动设备、分析或 3D;真实控件复用现有实现。 +当前主窗口仍走原来的构建路径,因此不能宣称 S2 已完成迁移。 + +未来接线前重新核对并行标定改动,再按顺序替换布局和菜单构建。 +共享 domain latch、FrameView、曲线 store、业务信号和初始偏好恢复由窗口显式装配; +第一次呈现前完成绑定。菜单初值先于业务信号连接;reset 的目标文字、pause 状态及操作可用性 +仍由现有控制器和窗口同步。兼容属性引用同一批控件,不另造重复面板。 + +新增 `test_window_surfaces.py` 和现有 `test_studio_binding_purity.py` 为 13 passed, +测试使用临时的空 sensor catalog,不扫描正在修改的真实配置目录。 +还核对了 35 个菜单与 action 属性与起始快照一致;临时取消主题组互斥后测试能发现错误, +随后原样恢复。新增文件 lint 通过,文档检查无新增问题;主窗口与起始快照逐字节一致。 +本批仅证明模块可独立使用;接线后的主窗口全套回归、真实桌面与设备验证仍待执行。 +日志和 XML 位于 `.scratch/studio-window-refactor/20261019-094111/`,最终结果为 `final.xml`。 + +```powershell +& .\.venv-build\Scripts\python.exe -m pytest apps/studio/tests/test_window_surfaces.py apps/studio/tests/test_studio_binding_purity.py -p no:cacheprovider --basetemp=D:\orisys_ws\orisys-app\.scratch\studio-window-refactor\20261019-094111\pytest-final --junitxml=D:\orisys_ws\orisys-app\.scratch\studio-window-refactor\20261019-094111\final.xml +``` + +### 2026-10-18:S1 接线与历史验证 + 2026-10-18:S0 和 S1 已实施。改动前后同一组 `test_ui_smoke` 与 `test_replay_wiring` 均为 125 passed;新增 Presenter 独立测试为 19 passed。 主窗口暂为 3,052 行;状态显示已迁出,S2–S6 尚未实施。