refactor: 准备独立的 Studio 控件与菜单构建模块

让界面构建可脱离主窗口独立测试。界面与绑定检查 13 项通过,运行入口尚未接线。
This commit is contained in:
ykcheungab
2026-10-19 09:48:26 +08:00
parent 31750f0f84
commit c6c16df753
5 changed files with 496 additions and 4 deletions
@@ -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"))
@@ -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()
+166
View File
@@ -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()
+14 -3
View File
@@ -1,13 +1,14 @@
# Studio 软件架构 # Studio 软件架构
本文的当前职责说明对应 2026-10-18 主窗口重构 S1;后续工作和验收由 本文说明已接线的 S1 和 2026-10-19 准备但尚未接线的 S2;后续工作和验收由
[分步重构计划](studio-window-refactor-plan.zh.md) 维护。下方旧扫描图表保留为历史证据, [分步重构计划](studio-window-refactor-plan.zh.md) 维护。下方旧扫描图表保留为历史证据,
不能用其中的行数或“已完成”判断当前代码。 不能用其中的行数或“已完成”判断当前代码。
## 当前职责与数据流 ## 当前职责与数据流
`app_window.py::StudioMainWindow` 创建控件和控制器并连接信号;本次从 3,181 行减至 `app_window.py::StudioMainWindow` 创建控件和控制器并连接信号。S1 批次从 3,181 行减至
3,052 行。它仍承担菜单、预览、标定交互、分析配置和运行协调,尚未完成薄窗口目标。 3,052 行,这是当日指标;当前另有并行标定修改。窗口仍承担菜单、预览、标定交互、
分析配置和运行协调,尚未完成薄窗口目标。
`status_presenter.py::StatusPresenter` 只持有工具栏,不持有窗口或会话控制器。 `status_presenter.py::StatusPresenter` 只持有工具栏,不持有窗口或会话控制器。
窗口在 GUI 线程收集一次性的 `RailState`,Presenter 负责 phase、来源、标定、有效性及 窗口在 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` 是旧扫描快照。 以下第 1–6 节及配套 `studio-architecture.svg`、`studio-architecture.png` 是旧扫描快照。
@@ -41,6 +41,7 @@
### S2:界面构建 ### S2:界面构建
- [x] 准备 `ui/window_ui.py` 和 `ui/window_menus.py`,以独立窗口验证,尚未接入运行路径。
- [ ] 提取 `ui/window_ui.py` 的控件和布局构建,保持控件身份、顺序及 parent。 - [ ] 提取 `ui/window_ui.py` 的控件和布局构建,保持控件身份、顺序及 parent。
- [ ] 单独迁移菜单和 QAction 创建;由窗口显式连接功能操作。 - [ ] 单独迁移菜单和 QAction 创建;由窗口显式连接功能操作。
- [ ] 迁移对应文字与主题更新,保留必要属性兼容入口。 - [ ] 迁移对应文字与主题更新,保留必要属性兼容入口。
@@ -87,13 +88,42 @@
显示设置重绘测试。2D/3D 使用同一帧;实时不覆盖回放;重绘不启动新分析。 显示设置重绘测试。2D/3D 使用同一帧;实时不覆盖回放;重绘不启动新分析。
结束条件是主窗口只承担约定职责,且所有相关检查通过,不以行数作为唯一标准。 结束条件是主窗口只承担约定职责,且所有相关检查通过,不以行数作为唯一标准。
## 验证与当前进度 ## 验证记录
解释器使用 `.venv-build/Scripts/python.exe`。定向 pytest 显式使用独立的 解释器使用 `.venv-build/Scripts/python.exe`。定向 pytest 显式使用独立的
`--basetemp` 和 `-p no:cacheprovider`;日志位于 `.scratch/studio-window-refactor/`。 `--basetemp` 和 `-p no:cacheprovider`;日志位于 `.scratch/studio-window-refactor/`。
每个迁移步骤运行对应检查;可交付批次运行 `tools/gate.ps1 -Task studio-window-refactor`。 每个迁移步骤运行对应检查;可交付批次运行 `tools/gate.ps1 -Task studio-window-refactor`。
文档使用工作区 `scripts/doc_lint.py` 和 `scripts/doc_count.py`,不扩大基线。 文档使用工作区 `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` 与 2026-10-18:S0 和 S1 已实施。改动前后同一组 `test_ui_smoke` 与
`test_replay_wiring` 均为 125 passed;新增 Presenter 独立测试为 19 passed。 `test_replay_wiring` 均为 125 passed;新增 Presenter 独立测试为 19 passed。
主窗口暂为 3,052 行;状态显示已迁出,S2–S6 尚未实施。 主窗口暂为 3,052 行;状态显示已迁出,S2–S6 尚未实施。