界面改用「精密仪器工作台」布局:顶栏压缩、视图切布局、标定与回放面板不再裁切
- What: 品牌条 40→28px 并加 实时监测/历史回放/标定 导航;导航改为切换主界面三套布局(实时=2D图像+曲线、回放=自动全程浏览、标定=标定面板 880px+曲线,2D 图像整体缩小);传感器选择(传感器 / 传感器配置 两个带 tooltip 的下拉 + 产品徽标)移到状态轨最左侧,记录模式移入设置区;新增 RecordStatusChip(记录中时长、丢帧、写入异常)与「同一时刻只有一个实心按钮」(新增 dangerOutline 描边危险样式);三轴读数移到曲线上方(16pt 等宽)、只保留最下方时间轴、网格淡化、全程浏览条可拖动;三轴配色统一为蓝/橙/绿并把语义色对比度审计扩展到 toolbar_bg;箭头页脚显示放大倍率与有效区域,深度模式才显示色条与范围;左侧面板改名「2D 图像」且固定显示图像宽高比;标定面板加纵向滚动区、九按钮分两行、六列表头 ResizeToContents;PlaybackDialog 补上从未调用的 retranslate;main() 在事件循环结束后 flush 日志并 os._exit,避免设备 SDK 原生线程把进程挂在解释器收尾。 - Why: 评审提出的 7 点:顶部占高与装饰过多、记录状态不突出、配置切换看不懂且藏得太深、切视图应换主界面布局、标定与回放面板文字被裁、关窗后命令行不退出、2D 图像无固定宽高比且名字不符。 - Verification: ui_check.py(430 项,新增 top area / instrument panels / clean shutdown 段,含中英×1280/1440 的轨不挤压检查)、test_ui_smoke.py(57 项,新增视图切换、标定不裁切、2D 宽高比、回放按钮文字、传感器选择器等用例)、drive_shared.py(146 项)、locale_studio.py(261 键齐全、zh==en、prefs 对齐)、reproduce.py、ui_snapshot.py 四张复核图。 - Not in this change: 录制文件格式、CSV/MP4 写入路径与记录通道数据流不变;三维面板仍是占位符;回放对话框(含离线深度重算)本身只修了按钮文字。
This commit is contained in:
@@ -57,6 +57,33 @@ def _inside_scroll_area(widget) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def rail_squeezed(window) -> list[str]:
|
||||
"""Status-rail widgets showing less than their own text.
|
||||
|
||||
The rail shares one line with the session state, so every item that states a
|
||||
fact (``摄像机 0``, ``开始记录``) must keep its full text; only the labels
|
||||
that carry a tooltip are allowed to shrink. An elided button is how a
|
||||
readable instrument turns back into a jumble.
|
||||
"""
|
||||
layout = window.toolbar.layout().itemAt(0).layout()
|
||||
squeezed: list[str] = []
|
||||
for index in range(layout.count()):
|
||||
widget = layout.itemAt(index).widget()
|
||||
if widget is None or not widget.isVisible():
|
||||
continue
|
||||
if getattr(widget, "minimumSizeHint", None) is None:
|
||||
continue
|
||||
if widget.minimumSizeHint().width() <= 0:
|
||||
continue
|
||||
if widget.width() < widget.sizeHint().width() - 1:
|
||||
squeezed.append(
|
||||
f"{type(widget).__name__}"
|
||||
f"({getattr(widget, 'objectName', lambda: '')()}"
|
||||
f" {widget.width()}<{widget.sizeHint().width()})"
|
||||
)
|
||||
return squeezed
|
||||
|
||||
|
||||
def section(name: str) -> None:
|
||||
print(f"\n== {name} ==")
|
||||
|
||||
@@ -295,15 +322,17 @@ TEXT_PAIRS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||||
("hint_text", ("card_bg", "panel_bg", "window_bg", "input_bg")),
|
||||
("accent_text", ("accent", "accent_hover", "danger_fill", "danger_fill_hover")),
|
||||
("danger_text", ("danger_fill", "danger_fill_hover")),
|
||||
("danger", ("card_bg", "panel_bg")),
|
||||
("danger_hover", ("card_bg", "panel_bg")),
|
||||
("warning", ("card_bg", "panel_bg")),
|
||||
("info", ("card_bg", "panel_bg")),
|
||||
("success", ("card_bg", "panel_bg")),
|
||||
("status_idle", ("panel_bg",)),
|
||||
("status_connecting", ("panel_bg",)),
|
||||
("status_running", ("panel_bg",)),
|
||||
("status_error", ("panel_bg",)),
|
||||
# The status rail lives on the toolbar background, so every semantic colour
|
||||
# it can show is audited there too — not only on the panel/card backgrounds.
|
||||
("danger", ("card_bg", "panel_bg", "toolbar_bg")),
|
||||
("danger_hover", ("card_bg", "panel_bg", "toolbar_bg")),
|
||||
("warning", ("card_bg", "panel_bg", "toolbar_bg")),
|
||||
("info", ("card_bg", "panel_bg", "toolbar_bg")),
|
||||
("success", ("card_bg", "panel_bg", "toolbar_bg")),
|
||||
("status_idle", ("panel_bg", "toolbar_bg")),
|
||||
("status_connecting", ("panel_bg", "toolbar_bg")),
|
||||
("status_running", ("panel_bg", "toolbar_bg")),
|
||||
("status_error", ("panel_bg", "toolbar_bg")),
|
||||
("brand_bar_fg", ("brand_bar_bg",)),
|
||||
("brand_bar_fg_muted", ("brand_bar_bg",)),
|
||||
)
|
||||
@@ -807,6 +836,245 @@ def main() -> int:
|
||||
window.close()
|
||||
app.processEvents()
|
||||
|
||||
section("top area")
|
||||
import orisys_studio.ui.record_status as _record_states
|
||||
from orisys_app_shared.record.experiment import RecordStats as _RecordStats
|
||||
|
||||
#: Brand rail + status rail, collapsed settings, must fit in one instrument
|
||||
#: line: the previous layout spent this height on a 40 px brand bar and a
|
||||
#: tall sensor card stacked above the charts.
|
||||
top_area_limit = 78.0
|
||||
for theme in ("dark", "light"):
|
||||
app.setStyleSheet(build_qss(theme))
|
||||
window = StudioMainWindow(
|
||||
prefs={"language": "zh_CN", "theme": theme, "sensor_type": "box"}
|
||||
)
|
||||
window.resize(1440, 880)
|
||||
window.show()
|
||||
for _ in range(3):
|
||||
app.processEvents()
|
||||
rail_height = window.brand_bar.height() + window.toolbar.height()
|
||||
check(
|
||||
rail_height <= top_area_limit,
|
||||
f"{theme}: the collapsed top area is {rail_height}px (limit {top_area_limit})",
|
||||
)
|
||||
check(
|
||||
not window.toolbar.settings_expanded(),
|
||||
f"{theme}: the settings area starts expanded",
|
||||
)
|
||||
check(window.toolbar.status_chip.text().strip(), f"{theme}: the phase chip is empty")
|
||||
check(
|
||||
window.sensor_picker.isVisible(),
|
||||
f"{theme}: the sensor picker is not on the status rail",
|
||||
)
|
||||
check(
|
||||
window.sensor_picker.type_label.text() == t("sensor.type")
|
||||
and window.sensor_picker.config_label.text() == t("sensor.config"),
|
||||
f"{theme}: the sensor picker labels are not the documented ones: "
|
||||
f"{window.sensor_picker.type_label.text()!r} / "
|
||||
f"{window.sensor_picker.config_label.text()!r}",
|
||||
)
|
||||
check(
|
||||
window.sensor_picker.thumb_label.text() == "BOX",
|
||||
f"{theme}: the product badge does not name the sensor: "
|
||||
f"{window.sensor_picker.thumb_label.text()!r}",
|
||||
)
|
||||
check(
|
||||
window.sensor_picker.config_label.toolTip() != "",
|
||||
f"{theme}: the sensor config picker has no explanation",
|
||||
)
|
||||
check(
|
||||
window.toolbar.sensor_summary.text() != "",
|
||||
f"{theme}: the rail does not name the capture source",
|
||||
)
|
||||
check(
|
||||
window.toolbar.calibration_label.text().strip(),
|
||||
f"{theme}: the rail has no calibration state",
|
||||
)
|
||||
check(
|
||||
window.toolbar.record_chip.text() == t("record.state_idle"),
|
||||
f"{theme}: the idle record state is "
|
||||
f"{window.toolbar.record_chip.text()!r}",
|
||||
)
|
||||
check(
|
||||
t("status.phase_running") != t("record.state_idle"),
|
||||
"acquiring and recording do not have distinct labels",
|
||||
)
|
||||
check(
|
||||
window.toolbar.record_chip.toolTip() != "",
|
||||
"the record chip does not explain its state",
|
||||
)
|
||||
check(
|
||||
not rail_squeezed(window),
|
||||
f"{theme}: the status rail squeezes {rail_squeezed(window)}",
|
||||
)
|
||||
check(
|
||||
"sensor.json" in window.device_label.text(),
|
||||
f"{theme}: the status bar does not name the config file: "
|
||||
f"{window.device_label.text()!r}",
|
||||
)
|
||||
|
||||
# The settings area is one click away, and remembers that it was opened.
|
||||
window.toolbar.btn_settings.setChecked(True)
|
||||
for _ in range(3):
|
||||
app.processEvents()
|
||||
expanded_height = window.brand_bar.height() + window.toolbar.height()
|
||||
check(
|
||||
expanded_height > rail_height,
|
||||
f"{theme}: opening the settings area changed nothing "
|
||||
f"({expanded_height} vs {rail_height})",
|
||||
)
|
||||
check(
|
||||
window.toolbar.source_combo.isVisible(),
|
||||
f"{theme}: the source control is unreachable in the settings area",
|
||||
)
|
||||
check(
|
||||
window.sensor_picker.isVisible(),
|
||||
f"{theme}: the sensor picker is unreachable in the settings area",
|
||||
)
|
||||
check(
|
||||
window.prefs.get("settings_expanded") is True,
|
||||
f"{theme}: the settings state was not persisted",
|
||||
)
|
||||
check(
|
||||
window.act_settings.isChecked(),
|
||||
f"{theme}: the View menu does not follow the settings toggle",
|
||||
)
|
||||
window.toolbar.btn_settings.setChecked(False)
|
||||
for _ in range(3):
|
||||
app.processEvents()
|
||||
check(
|
||||
not window.toolbar.settings_expanded(),
|
||||
f"{theme}: the settings area could not be collapsed again",
|
||||
)
|
||||
window.close()
|
||||
app.processEvents()
|
||||
|
||||
# English labels are the widest case ("Start recording", "Sensor config"),
|
||||
# and the tightest window is 1280: the rail must still fit both.
|
||||
set_locale("en_US")
|
||||
app.setStyleSheet(build_qss("dark"))
|
||||
english_window = StudioMainWindow(
|
||||
prefs={"language": "en_US", "theme": "dark", "sensor_type": "finger"}
|
||||
)
|
||||
english_window.resize(1280, 720)
|
||||
english_window.show()
|
||||
for _ in range(3):
|
||||
app.processEvents()
|
||||
check(
|
||||
not rail_squeezed(english_window),
|
||||
f"en_US at 1280x720: the status rail squeezes {rail_squeezed(english_window)}",
|
||||
)
|
||||
check(
|
||||
"FINGER" == english_window.sensor_picker.thumb_label.text(),
|
||||
f"the product badge is truncated: "
|
||||
f"{english_window.sensor_picker.thumb_label.text()!r}",
|
||||
)
|
||||
english_window.close()
|
||||
app.processEvents()
|
||||
set_locale("zh_CN")
|
||||
|
||||
# Exactly one *filled* action at a time: a red Stop dominating an otherwise
|
||||
# idle top area was the original complaint.
|
||||
app.setStyleSheet(build_qss("dark"))
|
||||
window = StudioMainWindow(
|
||||
prefs={"language": "zh_CN", "theme": "dark", "sensor_type": "box"}
|
||||
)
|
||||
window.resize(1440, 880)
|
||||
window.show()
|
||||
app.processEvents()
|
||||
check(
|
||||
window.toolbar.btn_run.objectName() == "primaryButton",
|
||||
f"idle Run is not the filled primary: {window.toolbar.btn_run.objectName()!r}",
|
||||
)
|
||||
check(
|
||||
not window.toolbar.btn_record.isEnabled(),
|
||||
"recording is offered without a session",
|
||||
)
|
||||
|
||||
stuck = _StuckWorker()
|
||||
window._ensure_workers = lambda: None
|
||||
window._box_worker = stuck
|
||||
window._finger_worker = stuck
|
||||
window.toolbar.btn_run.setChecked(True)
|
||||
app.processEvents()
|
||||
check(
|
||||
window.toolbar.btn_run.objectName() == "dangerButton",
|
||||
"Cancel is not the filled action while a connect hangs",
|
||||
)
|
||||
stuck.emit(_fake_packet(1))
|
||||
window._on_run_tick()
|
||||
app.processEvents()
|
||||
check(window._session_phase == "running", "the session never reached running")
|
||||
check(
|
||||
window.toolbar.btn_run.objectName() == "dangerOutline",
|
||||
f"Stop competes with the record action: "
|
||||
f"{window.toolbar.btn_run.objectName()!r}",
|
||||
)
|
||||
check(
|
||||
window.toolbar.btn_record.objectName() == "primaryButton",
|
||||
"the record action is not the filled primary while acquiring",
|
||||
)
|
||||
check(
|
||||
window._calibration_summary() == t("status.calibrated_all"),
|
||||
f"a fully calibrated packet is not reported as such: "
|
||||
f"{window._calibration_summary()!r}",
|
||||
)
|
||||
window.toolbar.set_record_checked(True)
|
||||
app.processEvents()
|
||||
check(
|
||||
window.toolbar.btn_record.objectName() == "dangerButton",
|
||||
"stopping the recording is not the filled danger",
|
||||
)
|
||||
check(
|
||||
window.toolbar.btn_run.objectName() == "dangerOutline",
|
||||
"two filled actions share the rail while recording",
|
||||
)
|
||||
|
||||
real_stats = window._record_stats
|
||||
window._record_stats = lambda: (
|
||||
_record_states.RECORDING,
|
||||
_RecordStats(duration_s=12.4, frames=372, segments=1, dropped_data=3),
|
||||
"20240101T000000Z_session0001_deadbeef",
|
||||
)
|
||||
window._refresh_record_state()
|
||||
app.processEvents()
|
||||
chip = window.toolbar.record_chip
|
||||
check(
|
||||
"12.4" in chip.text(),
|
||||
f"the record strip does not show the duration: {chip.text()!r}",
|
||||
)
|
||||
check(
|
||||
t("record.dropped", count=3) in chip.text(),
|
||||
f"dropped frames are not surfaced: {chip.text()!r}",
|
||||
)
|
||||
check(
|
||||
"3" in chip.toolTip() and "372" in chip.toolTip(),
|
||||
f"the record tooltip has no detail: {chip.toolTip()!r}",
|
||||
)
|
||||
window._record_stats = lambda: (
|
||||
_record_states.ERROR,
|
||||
_RecordStats(
|
||||
frames=10, segments=1, failed=True, write_errors=2, last_error="disk full"
|
||||
),
|
||||
"",
|
||||
)
|
||||
window._refresh_record_state()
|
||||
check(
|
||||
chip.text() == t("record.state_error", count=2),
|
||||
f"a write failure is not reported: {chip.text()!r}",
|
||||
)
|
||||
check(
|
||||
"disk full" in chip.toolTip(),
|
||||
f"the record tooltip hides the failure reason: {chip.toolTip()!r}",
|
||||
)
|
||||
window._record_stats = real_stats
|
||||
window.toolbar.set_record_checked(False)
|
||||
window._stop_run(reason="user_stop")
|
||||
stuck.release()
|
||||
window.close()
|
||||
app.processEvents()
|
||||
|
||||
section("stopping a hung connect")
|
||||
stuck = _StuckWorker()
|
||||
window = StudioMainWindow(
|
||||
@@ -979,6 +1247,301 @@ def main() -> int:
|
||||
app.processEvents()
|
||||
stuck.release()
|
||||
|
||||
section("instrument panels")
|
||||
window = StudioMainWindow(
|
||||
prefs={"language": "zh_CN", "theme": "dark", "sensor_type": "box"}
|
||||
)
|
||||
window.resize(1440, 880)
|
||||
window.show()
|
||||
app.processEvents()
|
||||
|
||||
plots_panel = window.force_plots
|
||||
check(
|
||||
plots_panel.value_group.y() < plots_panel.plot_frame.y(),
|
||||
"the axis readouts are not above the charts",
|
||||
)
|
||||
check(
|
||||
all(label.text() for label in plots_panel.value_labels),
|
||||
"an axis readout is empty before any sample",
|
||||
)
|
||||
for label in plots_panel.value_labels:
|
||||
check(
|
||||
label.height() >= label.fontMetrics().height(),
|
||||
"an axis readout is clipped",
|
||||
)
|
||||
check(
|
||||
label.fontMetrics().height() >= 20,
|
||||
f"the axis readouts are not large digits: "
|
||||
f"{label.font().family()} {label.fontMetrics().height()}px",
|
||||
)
|
||||
check(
|
||||
[plot.getAxis("bottom").style.get("showValues") for plot in plots_panel.plots]
|
||||
== [False, False, True],
|
||||
"the three charts do not share one bottom time axis",
|
||||
)
|
||||
|
||||
arrow = window.arrow_panel
|
||||
check(
|
||||
arrow.scale_label.isVisible() and arrow.scale_label.text(),
|
||||
"the arrow view does not show its display magnification",
|
||||
)
|
||||
check(
|
||||
"2" in arrow.scale_label.text(),
|
||||
f"the box magnification is not the documented 2x: {arrow.scale_label.text()!r}",
|
||||
)
|
||||
check(
|
||||
arrow.pipeline_label.isVisible() and arrow.pipeline_label.text(),
|
||||
"the arrow view does not name its pipeline",
|
||||
)
|
||||
check(
|
||||
arrow.area_label.isVisible() and arrow.area_label.text(),
|
||||
"the arrow view does not report the valid area",
|
||||
)
|
||||
check(
|
||||
not arrow.colorbar.isVisible(),
|
||||
"the depth colour bar is shown in the arrow view",
|
||||
)
|
||||
arrow.set_view_mode("depth")
|
||||
arrow.set_depth_scale("深度色标 0.000 – 1.000")
|
||||
for _ in range(2):
|
||||
app.processEvents()
|
||||
check(arrow.colorbar.isVisible(), "the depth colour bar is hidden in the depth view")
|
||||
check(
|
||||
not arrow.scale_label.isVisible(),
|
||||
"the arrow magnification is still shown in the depth view",
|
||||
)
|
||||
check(
|
||||
arrow.depth_scale_label.isVisible() and arrow.depth_scale_label.text(),
|
||||
"the depth range is not shown next to its colour bar",
|
||||
)
|
||||
arrow.set_view_mode("arrows_pure")
|
||||
for _ in range(2):
|
||||
app.processEvents()
|
||||
check(
|
||||
not arrow.colorbar.isVisible() and arrow.scale_label.isVisible(),
|
||||
"leaving the depth view did not restore the arrow legend",
|
||||
)
|
||||
|
||||
# The overview strip is the timeline control: dragging it pans the window.
|
||||
stuck = _StuckWorker()
|
||||
window._ensure_workers = lambda: None
|
||||
window._box_worker = stuck
|
||||
window._finger_worker = stuck
|
||||
window.toolbar.btn_run.setChecked(True)
|
||||
app.processEvents()
|
||||
for index in range(120):
|
||||
stuck.emit(_fake_packet(index + 100))
|
||||
window._on_run_tick()
|
||||
for _ in range(3):
|
||||
app.processEvents()
|
||||
check(
|
||||
window.session_store.count == 120,
|
||||
f"the session store missed samples: {window.session_store.count}",
|
||||
)
|
||||
first, last = window.session_store.extent()
|
||||
check(last > first, "the session store has no extent to browse")
|
||||
check(
|
||||
plots_panel.window_label.text() != "",
|
||||
"the overview strip has no window readout",
|
||||
)
|
||||
plots_panel.show_all()
|
||||
app.processEvents()
|
||||
check(plots_panel.mode() == "browse", "the overview button did not enter browsing")
|
||||
target = (first + (last - first) * 0.25, first + (last - first) * 0.5)
|
||||
plots_panel.overview_region.setRegion(target)
|
||||
for _ in range(3):
|
||||
app.processEvents()
|
||||
dragged = plots_panel.window()
|
||||
check(
|
||||
abs(dragged[0] - target[0]) < 0.05 and abs(dragged[1] - target[1]) < 0.05,
|
||||
f"dragging the overview did not move the window: {dragged} vs {target}",
|
||||
)
|
||||
check(
|
||||
plots_panel.mode() == "browse",
|
||||
"dragging the overview left the charts in live mode",
|
||||
)
|
||||
window.brand_bar.btn_live.click()
|
||||
for _ in range(2):
|
||||
app.processEvents()
|
||||
check(
|
||||
plots_panel.mode() == "follow",
|
||||
"the brand rail's live item did not return to live mode",
|
||||
)
|
||||
check(
|
||||
window.brand_bar.btn_live.property("navActive") == "true",
|
||||
"the live nav item is not marked as the current view",
|
||||
)
|
||||
window.act_cal_panel.setChecked(False)
|
||||
app.processEvents()
|
||||
window.brand_bar.btn_calibration.click()
|
||||
app.processEvents()
|
||||
check(
|
||||
window.cal_panel.isVisible(),
|
||||
"the calibration nav item did not reveal the panel",
|
||||
)
|
||||
window.brand_bar.btn_calibration.click()
|
||||
for _ in range(3):
|
||||
app.processEvents()
|
||||
from PyQt5.QtWidgets import QHeaderView as _QHeaderView
|
||||
|
||||
sizes = window.content_splitter.sizes()
|
||||
check(
|
||||
window._view_preset == "calibration",
|
||||
f"the calibration nav item did not switch the view: {window._view_preset!r}",
|
||||
)
|
||||
check(
|
||||
window.cal_panel.isVisible() and not window.arrow_panel.isVisible(),
|
||||
"the calibration view does not trade the 2D panel for the calibration panel",
|
||||
)
|
||||
check(
|
||||
sizes[3] >= 500,
|
||||
f"the calibration panel is too narrow for its table: {sizes[3]}px",
|
||||
)
|
||||
check(
|
||||
window.cal_panel.table.horizontalHeader().sectionResizeMode(0)
|
||||
== _QHeaderView.ResizeToContents,
|
||||
"the calibration columns can still be elided instead of scrolled",
|
||||
)
|
||||
check(
|
||||
window.cal_panel.scroll.isVisible(),
|
||||
"the calibration panel cannot scroll to its own controls",
|
||||
)
|
||||
for button in (
|
||||
window.cal_panel.btn_apply,
|
||||
window.cal_panel.btn_export,
|
||||
window.cal_panel.btn_toggle_validation,
|
||||
):
|
||||
check(
|
||||
button.height() >= button.fontMetrics().height(),
|
||||
f"a calibration button is cut off: {button.text()!r}",
|
||||
)
|
||||
check(
|
||||
button.width() >= button.sizeHint().width(),
|
||||
f"a calibration button is clipped: {button.text()!r} "
|
||||
f"({button.width()} < {button.sizeHint().width()})",
|
||||
)
|
||||
|
||||
window.brand_bar.btn_playback.click()
|
||||
for _ in range(3):
|
||||
app.processEvents()
|
||||
check(
|
||||
window._view_preset == "replay",
|
||||
f"the replay nav item did not switch the view: {window._view_preset!r}",
|
||||
)
|
||||
check(
|
||||
not window.cal_panel.isVisible() and window.arrow_panel.isVisible(),
|
||||
"the replay view is not the 2D + charts arrangement",
|
||||
)
|
||||
check(
|
||||
window.force_plots.mode() == "browse",
|
||||
"the replay view did not open on the whole session",
|
||||
)
|
||||
|
||||
window.brand_bar.btn_live.click()
|
||||
for _ in range(3):
|
||||
app.processEvents()
|
||||
sizes = window.content_splitter.sizes()
|
||||
check(
|
||||
window._view_preset == "live",
|
||||
f"the live nav item did not switch the view: {window._view_preset!r}",
|
||||
)
|
||||
check(
|
||||
sizes[2] > sizes[1],
|
||||
f"the charts are not given more room than the 2D panel: {sizes}",
|
||||
)
|
||||
check(
|
||||
window.force_plots.mode() == "follow",
|
||||
"the live view did not resume following the latest samples",
|
||||
)
|
||||
check(
|
||||
window.brand_bar.btn_live.property("navActive") == "true",
|
||||
"the live nav item is not marked as the current view",
|
||||
)
|
||||
window._stop_run(reason="user_stop")
|
||||
stuck.release()
|
||||
for _ in range(3):
|
||||
app.processEvents()
|
||||
|
||||
# The 2D area keeps the shape of the image it shows, instead of stretching a
|
||||
# 16:9 frame into whatever the panel happens to be.
|
||||
finger_window = StudioMainWindow(
|
||||
prefs={
|
||||
"language": "zh_CN",
|
||||
"theme": "dark",
|
||||
"sensor_type": "finger",
|
||||
"show_finger_3d": False,
|
||||
}
|
||||
)
|
||||
finger_window.resize(1440, 880)
|
||||
finger_window.show()
|
||||
for _ in range(3):
|
||||
app.processEvents()
|
||||
image = finger_window.arrow_panel.image_label
|
||||
check(
|
||||
abs(image.aspect() - 16.0 / 9.0) < 0.01,
|
||||
f"the 2D area is not 16:9 for a finger sensor: {image.aspect()}",
|
||||
)
|
||||
check(
|
||||
abs(image.height() - image.width() / image.aspect()) <= 2,
|
||||
"the 2D image area does not follow the output image's aspect ratio: "
|
||||
f"{image.width()}x{image.height()}",
|
||||
)
|
||||
finger_window.close()
|
||||
app.processEvents()
|
||||
check(
|
||||
window.toolbar.record_chip.text() == t("record.state_idle"),
|
||||
f"the record strip did not fall back to idle: "
|
||||
f"{window.toolbar.record_chip.text()!r}",
|
||||
)
|
||||
window.close()
|
||||
app.processEvents()
|
||||
|
||||
section("clean shutdown")
|
||||
# A camera SDK leaves native threads behind; the entry point must still come
|
||||
# back to the console. This runs the real ``main()`` in a child process and
|
||||
# closes the window from a timer, so a hang shows up as a timeout.
|
||||
import subprocess as _subprocess
|
||||
|
||||
script = (
|
||||
"import os, sys\n"
|
||||
"os.environ.setdefault('QT_QPA_PLATFORM', 'offscreen')\n"
|
||||
"os.environ.setdefault('QT_QPA_FONTDIR', r'C:\\Windows\\Fonts')\n"
|
||||
"from PyQt5.QtCore import QTimer\n"
|
||||
"import orisys_studio.main as studio_main\n"
|
||||
"show = studio_main.StudioMainWindow.show\n"
|
||||
"def show_and_close(self):\n"
|
||||
" show(self)\n"
|
||||
" QTimer.singleShot(400, self.close)\n"
|
||||
"studio_main.StudioMainWindow.show = show_and_close\n"
|
||||
"raise SystemExit(studio_main.main())\n"
|
||||
)
|
||||
env = dict(os.environ)
|
||||
env["PYTHONPATH"] = os.pathsep.join(
|
||||
[str(REPO_ROOT / "shared" / "src"), str(APP_ROOT / "src"), env.get("PYTHONPATH", "")]
|
||||
)
|
||||
started = time.perf_counter()
|
||||
try:
|
||||
completed = _subprocess.run(
|
||||
[sys.executable, "-c", script],
|
||||
cwd=str(APP_ROOT),
|
||||
env=env,
|
||||
capture_output=True,
|
||||
timeout=60,
|
||||
)
|
||||
elapsed = time.perf_counter() - started
|
||||
print(f" window closed and the process returned in {elapsed:.1f}s")
|
||||
check(
|
||||
completed.returncode == 0,
|
||||
f"the app exited with {completed.returncode}: "
|
||||
f"{completed.stderr[-400:]!r}",
|
||||
)
|
||||
check(
|
||||
elapsed < 45.0,
|
||||
f"closing the window did not return to the console ({elapsed:.1f}s)",
|
||||
)
|
||||
except _subprocess.TimeoutExpired:
|
||||
check(False, "the app never exited after its window was closed")
|
||||
|
||||
section("experiment record")
|
||||
import shutil as _shutil
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ Examples
|
||||
python tools/ui_snapshot.py --theme dark --out out_dark.png
|
||||
python tools/ui_snapshot.py --theme light --sensor finger \
|
||||
--panels cal,3d --demo --size 1600x900 --out out_light.png
|
||||
python tools/ui_snapshot.py --demo --scene recording --out out_recording.png
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -34,6 +35,61 @@ if sys.platform == "win32" and "QT_QPA_FONTDIR" not in os.environ:
|
||||
os.chdir(APP_ROOT)
|
||||
|
||||
|
||||
def _run_scene(window, app, scene: str):
|
||||
"""Drive the window into a scene that would otherwise need hardware.
|
||||
|
||||
``recording`` mocks the record channel's stats through the same single seam
|
||||
the app reads them from (``_record_stats``), so nothing is written to disk
|
||||
and no device is opened: the render is of the real widgets, not of a mockup.
|
||||
"""
|
||||
from ui_check import _fake_packet, _StuckWorker
|
||||
|
||||
stuck = _StuckWorker()
|
||||
window._ensure_workers = lambda: None
|
||||
window._box_worker = stuck
|
||||
window._finger_worker = stuck
|
||||
window.toolbar.btn_run.setChecked(True)
|
||||
for _ in range(4):
|
||||
app.processEvents()
|
||||
if scene != "recording":
|
||||
return
|
||||
|
||||
# Real packets so the phase reaches ``running`` and the rail holds real
|
||||
# values; the recording itself is the only mocked part.
|
||||
for index in range(300):
|
||||
stuck.emit(_fake_packet(index))
|
||||
window._on_run_tick()
|
||||
for _ in range(4):
|
||||
app.processEvents()
|
||||
window._run_timer.stop()
|
||||
|
||||
from orisys_app_shared.record.experiment import RecordStats
|
||||
|
||||
from orisys_studio.ui import record_status
|
||||
|
||||
directory = "20240101T101500Z_session0001_a1b2c3d4e5f6"
|
||||
window.toolbar.set_record_checked(True)
|
||||
window.toolbar.set_record_status(directory)
|
||||
window._record_stats = lambda: (
|
||||
record_status.RECORDING,
|
||||
RecordStats(
|
||||
duration_s=12.4,
|
||||
frames=372,
|
||||
segments=1,
|
||||
dropped_data=3,
|
||||
bytes_written=48_000_000,
|
||||
),
|
||||
directory,
|
||||
)
|
||||
window._refresh_record_state()
|
||||
# A visible window inside the session, like the mock's 60-65 s of 0-65 s.
|
||||
first, last = window.session_store.extent()
|
||||
if last > first:
|
||||
window.force_plots.set_window(max(first, last - 6.0), last)
|
||||
for _ in range(4):
|
||||
app.processEvents()
|
||||
|
||||
|
||||
def _demo_data(window) -> None:
|
||||
"""Fill the plots, the calibration table and the arrow canvas."""
|
||||
plots = window.force_plots
|
||||
@@ -95,12 +151,22 @@ def main() -> int:
|
||||
)
|
||||
parser.add_argument("--size", default="1440x880", help="WxH window size")
|
||||
parser.add_argument("--view", default="arrows_pure")
|
||||
parser.add_argument(
|
||||
"--preset",
|
||||
choices=("custom", "live", "replay", "calibration"),
|
||||
default="custom",
|
||||
help="top-level view to render (the same layouts the brand rail switches)",
|
||||
)
|
||||
parser.add_argument("--demo", action="store_true", help="inject demo plot/table data")
|
||||
parser.add_argument(
|
||||
"--scene",
|
||||
choices=("idle", "connecting"),
|
||||
choices=("idle", "connecting", "recording"),
|
||||
default="idle",
|
||||
help="connecting renders the Stop/cancel state of a hung device open",
|
||||
help=(
|
||||
"connecting renders the Stop/cancel state of a hung device open; "
|
||||
"recording renders the record strip of a running recording "
|
||||
"(mocked stats: no file is written, no device is opened)"
|
||||
),
|
||||
)
|
||||
parser.add_argument("--out", required=True, help="output PNG path")
|
||||
args = parser.parse_args()
|
||||
@@ -130,27 +196,23 @@ def main() -> int:
|
||||
"show_finger_3d": "3d" in panels,
|
||||
"show_fps": True,
|
||||
"image_view_mode": args.view,
|
||||
"view_preset": args.preset,
|
||||
}
|
||||
window = StudioMainWindow(prefs=prefs)
|
||||
window.resize(width, height)
|
||||
window.show()
|
||||
app.processEvents()
|
||||
|
||||
if args.demo:
|
||||
_demo_data(window)
|
||||
for _ in range(4):
|
||||
app.processEvents()
|
||||
|
||||
if args.scene == "connecting":
|
||||
# A device open that never returns: the state the Stop button must
|
||||
# survive. ``ui_check._StuckWorker`` drives the same window state.
|
||||
from ui_check import _StuckWorker
|
||||
_run_scene(window, app, "connecting")
|
||||
|
||||
stuck = _StuckWorker()
|
||||
window._ensure_workers = lambda: None
|
||||
window._box_worker = stuck
|
||||
window._finger_worker = stuck
|
||||
window.toolbar.btn_run.setChecked(True)
|
||||
if args.scene == "recording":
|
||||
_run_scene(window, app, "recording")
|
||||
|
||||
if args.demo:
|
||||
_demo_data(window)
|
||||
for _ in range(4):
|
||||
app.processEvents()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user