界面改用「精密仪器工作台」布局:顶栏压缩、视图切布局、标定与回放面板不再裁切
- 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:
@@ -62,6 +62,11 @@ def test_children_stay_inside_their_parent(make_window, size):
|
||||
window = _show(make_window(sensor_type="finger", show_cal_panel=True), size)
|
||||
oversize = []
|
||||
for child in visible_descendants(window):
|
||||
if ui_check._inside_scroll_area(child):
|
||||
# A scroll area's content is *meant* to exceed its viewport: the
|
||||
# calibration panel and the settings area scroll instead of
|
||||
# squeezing their controls into an unreadable width.
|
||||
continue
|
||||
parent = child.parentWidget()
|
||||
if parent is None or not parent.isVisible():
|
||||
continue
|
||||
@@ -1145,4 +1150,333 @@ def test_recording_leaves_unavailable_values_empty(make_window, record_root):
|
||||
_finish(window, stuck)
|
||||
|
||||
|
||||
# ------------------------------------------------------- top rail / settings
|
||||
|
||||
|
||||
def test_top_area_stays_compact(make_window):
|
||||
"""The rail is read at a glance; it is not a place to stack configuration."""
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
window = _show(make_window(), (1440, 880))
|
||||
collapsed = window.brand_bar.height() + window.toolbar.height()
|
||||
assert collapsed <= 78, f"the collapsed top area is {collapsed}px tall"
|
||||
assert window.brand_bar.height() <= 30
|
||||
assert not window.toolbar.settings_expanded()
|
||||
assert window.brand_bar.btn_live.text() and window.brand_bar.btn_playback.text()
|
||||
assert window.toolbar.status_chip.text().strip()
|
||||
assert window.toolbar.record_chip.text() == t("record.state_idle")
|
||||
|
||||
window.toolbar.btn_settings.setChecked(True)
|
||||
from PyQt5.QtWidgets import QApplication
|
||||
|
||||
QApplication.processEvents()
|
||||
assert window.toolbar.settings_expanded()
|
||||
assert window.toolbar.source_combo.isVisible()
|
||||
assert window.sensor_picker.isVisible()
|
||||
expanded = window.brand_bar.height() + window.toolbar.height()
|
||||
assert expanded > collapsed, "opening the settings area changed nothing"
|
||||
assert window.act_settings.isChecked(), "the View menu does not follow the toggle"
|
||||
assert window.prefs["settings_expanded"] is True
|
||||
|
||||
window.toolbar.btn_settings.setChecked(False)
|
||||
QApplication.processEvents()
|
||||
assert not window.toolbar.settings_expanded()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("size", RESOLUTIONS)
|
||||
def test_settings_area_stays_inside_its_container(make_window, size):
|
||||
"""Opening the settings area must not push a widget past its parent.
|
||||
|
||||
The settings area is the one part of the top rail that may scroll: its own
|
||||
content is exempt, everything else must fit.
|
||||
"""
|
||||
from PyQt5.QtWidgets import QApplication
|
||||
|
||||
window = _show(make_window(sensor_type="finger", show_cal_panel=True), size)
|
||||
window._set_settings_expanded(True)
|
||||
for _ in range(3):
|
||||
QApplication.processEvents()
|
||||
|
||||
oversize = []
|
||||
for child in visible_descendants(window):
|
||||
if ui_check._inside_scroll_area(child):
|
||||
continue
|
||||
parent = child.parentWidget()
|
||||
if parent is None or not parent.isVisible():
|
||||
continue
|
||||
geometry = child.geometry()
|
||||
if geometry.width() > parent.width() + 1 or geometry.height() > parent.height() + 1:
|
||||
oversize.append(
|
||||
f"{type(child).__name__} {geometry.width()}x{geometry.height()}"
|
||||
f" > parent {parent.width()}x{parent.height()}"
|
||||
)
|
||||
assert not oversize, oversize
|
||||
|
||||
|
||||
def test_idle_run_is_the_only_filled_action(make_window):
|
||||
window = _show(make_window(), (1440, 880))
|
||||
assert window.toolbar.btn_run.objectName() == "primaryButton"
|
||||
assert not window.toolbar.btn_record.isEnabled(), "recording without a session"
|
||||
|
||||
|
||||
def test_connecting_cancel_stays_filled(make_window):
|
||||
"""Cancel is the way out of a hung device open, so it keeps the weight."""
|
||||
from PyQt5.QtWidgets import QApplication
|
||||
|
||||
window = _show(make_window(), (1440, 880))
|
||||
stuck = ui_check._StuckWorker()
|
||||
window._ensure_workers = lambda: None
|
||||
window._box_worker = stuck
|
||||
window._finger_worker = stuck
|
||||
window.toolbar.btn_run.setChecked(True)
|
||||
QApplication.processEvents()
|
||||
assert window._session_phase == "connecting"
|
||||
assert window.toolbar.btn_run.objectName() == "dangerButton"
|
||||
window.toolbar.btn_run.setChecked(False)
|
||||
stuck.release()
|
||||
|
||||
|
||||
def test_exactly_one_filled_action_while_acquiring(make_window):
|
||||
"""Driven through the real entry point: Run, then the first packet."""
|
||||
window = _show(make_window(), (1440, 880))
|
||||
stuck = ui_check._StuckWorker()
|
||||
window._ensure_workers = lambda: None
|
||||
window._box_worker = stuck
|
||||
window._finger_worker = stuck
|
||||
window.toolbar.btn_run.setChecked(True)
|
||||
window._run_timer.stop()
|
||||
assert window._session_phase == "connecting"
|
||||
assert window.toolbar.btn_run.objectName() == "dangerButton", (
|
||||
"Cancel lost its weight while a connect can hang"
|
||||
)
|
||||
|
||||
stuck.emit(ui_check._fake_packet(1))
|
||||
window._on_run_tick()
|
||||
assert window._session_phase == "running"
|
||||
assert window.toolbar.btn_run.objectName() == "dangerOutline", (
|
||||
"Stop is still the filled action while a session merely acquires"
|
||||
)
|
||||
assert window.toolbar.btn_record.objectName() == "primaryButton"
|
||||
|
||||
window.toolbar.set_record_checked(True)
|
||||
assert window.toolbar.btn_record.objectName() == "dangerButton"
|
||||
assert window.toolbar.btn_run.objectName() == "dangerOutline", (
|
||||
"two filled actions share the rail while recording"
|
||||
)
|
||||
|
||||
window.toolbar.set_record_checked(False)
|
||||
window.toolbar.btn_run.setChecked(False)
|
||||
stuck.release()
|
||||
|
||||
|
||||
def test_record_strip_separates_acquiring_from_recording(make_window):
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.record.experiment import RecordStats
|
||||
|
||||
from orisys_studio.ui import record_status
|
||||
|
||||
window, stuck = _running_window(make_window)
|
||||
chip = window.toolbar.record_chip
|
||||
assert chip.text() == t("record.state_idle")
|
||||
assert t("record.state_idle") != t("status.phase_running"), (
|
||||
"acquiring and recording share one label"
|
||||
)
|
||||
|
||||
real_stats = window._record_stats
|
||||
window._record_stats = lambda: (
|
||||
record_status.RECORDING,
|
||||
RecordStats(duration_s=12.4, frames=372, segments=1, dropped_data=3),
|
||||
"20240101T000000Z_session0001_deadbeef",
|
||||
)
|
||||
window._refresh_record_state()
|
||||
assert t("record.state_recording", duration="00:12.4") in chip.text()
|
||||
assert t("record.dropped", count=3) in chip.text()
|
||||
assert "deadbeef" in chip.toolTip()
|
||||
# A missing placeholder would leave the raw template in place: ``t`` falls
|
||||
# back to the key instead of raising, so the tooltip is asserted on content.
|
||||
assert chip.toolTip() != t("record.strip_tip")
|
||||
|
||||
window._record_stats = lambda: (
|
||||
record_status.ERROR,
|
||||
RecordStats(frames=10, segments=1, failed=True, write_errors=2, last_error="disk full"),
|
||||
"",
|
||||
)
|
||||
window._refresh_record_state()
|
||||
assert chip.text() == t("record.state_error", count=2)
|
||||
assert "disk full" in chip.toolTip()
|
||||
|
||||
window._record_stats = real_stats
|
||||
_finish(window, stuck)
|
||||
|
||||
|
||||
# --------------------------------------------------- curves / arrow legend
|
||||
|
||||
|
||||
def test_axis_readouts_sit_above_the_charts(make_window):
|
||||
window = _show(make_window(), (1440, 880))
|
||||
panel = window.force_plots
|
||||
assert panel.value_group.y() < panel.plot_frame.y(), (
|
||||
"the axis readouts are not above the charts"
|
||||
)
|
||||
for label in panel.value_labels:
|
||||
assert label.text(), "an axis readout is empty"
|
||||
assert label.height() >= label.fontMetrics().height(), "a readout is clipped"
|
||||
assert label.fontMetrics().height() >= 20, "the readouts are not large digits"
|
||||
assert [
|
||||
plot.getAxis("bottom").style.get("showValues") for plot in panel.plots
|
||||
] == [False, False, True], "the three charts do not share one bottom time axis"
|
||||
|
||||
|
||||
def test_overview_region_is_the_timeline_control(make_window):
|
||||
window, stuck = _running_window(make_window)
|
||||
panel = window.force_plots
|
||||
for index in range(120):
|
||||
stuck.emit(ui_check._fake_packet(index + 100))
|
||||
window._on_run_tick()
|
||||
assert window.session_store.count == 120
|
||||
first, last = window.session_store.extent()
|
||||
assert last > first, "the session has no extent to browse"
|
||||
assert panel.window_label.text(), "the overview strip has no readout"
|
||||
|
||||
panel.show_all()
|
||||
assert panel.mode() == "browse"
|
||||
target = (first + (last - first) * 0.25, first + (last - first) * 0.5)
|
||||
panel.overview_region.setRegion(target)
|
||||
dragged = panel.window()
|
||||
assert 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}"
|
||||
)
|
||||
assert panel.mode() == "browse"
|
||||
|
||||
window.brand_bar.btn_live.click()
|
||||
assert panel.mode() == "follow", "the live nav item did not leave history browsing"
|
||||
_finish(window, stuck)
|
||||
|
||||
|
||||
def test_arrow_legend_follows_the_view_mode(make_window):
|
||||
from orisys_app_shared.acquisition.arrows import arrow_display_scale
|
||||
|
||||
assert arrow_display_scale("box") == 2.0
|
||||
assert arrow_display_scale("finger") == 1.0
|
||||
|
||||
window = _show(make_window(), (1440, 880))
|
||||
panel = window.arrow_panel
|
||||
assert panel.scale_label.isVisible()
|
||||
assert "2" in panel.scale_label.text()
|
||||
assert panel.pipeline_label.isVisible() and panel.pipeline_label.text()
|
||||
assert panel.area_label.isVisible() and panel.area_label.text()
|
||||
assert not panel.colorbar.isVisible(), "the depth colour bar is shown in arrows"
|
||||
|
||||
panel.set_view_mode("depth")
|
||||
panel.set_depth_scale("0.000 – 1.000")
|
||||
assert panel.colorbar.isVisible(), "the depth view has no colour bar"
|
||||
assert panel.depth_scale_label.isVisible()
|
||||
assert not panel.scale_label.isVisible(), "the arrow magnification leaked into depth"
|
||||
|
||||
panel.set_view_mode("arrows_pure")
|
||||
assert not panel.colorbar.isVisible()
|
||||
assert panel.scale_label.isVisible()
|
||||
|
||||
|
||||
# ------------------------------------------------- top rail: sensor and views
|
||||
|
||||
|
||||
def test_the_sensor_picker_leads_the_rail(make_window):
|
||||
"""Which product/config is selected must be readable, not buried."""
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
window = _show(make_window(), (1440, 880))
|
||||
picker = window.sensor_picker
|
||||
assert picker.isVisible(), "the sensor picker is not on the status rail"
|
||||
assert picker.type_label.text() == t("sensor.type")
|
||||
assert picker.config_label.text() == t("sensor.config")
|
||||
assert picker.thumb_label.text() == "BOX"
|
||||
assert picker.type_label.toolTip() and picker.config_label.toolTip()
|
||||
|
||||
# The two combos stay wired: switching the product reloads its configs.
|
||||
picker.type_combo.setCurrentIndex(picker.type_combo.findData("finger"))
|
||||
assert picker.sensor_type() == "finger"
|
||||
assert picker.config_name()
|
||||
|
||||
|
||||
def test_the_nav_items_switch_the_main_window_layout(make_window):
|
||||
window, stuck = _running_window(make_window)
|
||||
for index in range(60):
|
||||
stuck.emit(ui_check._fake_packet(index + 100))
|
||||
window._on_run_tick()
|
||||
assert window.session_store.count == 60
|
||||
|
||||
window.brand_bar.btn_calibration.click()
|
||||
assert window.view_preset() == "calibration"
|
||||
sizes = window.content_splitter.sizes()
|
||||
assert window.cal_panel.isVisible()
|
||||
assert not window.arrow_panel.isVisible(), "the calibration view keeps the 2D panel"
|
||||
assert sizes[3] >= 500, f"the calibration panel is too narrow: {sizes}"
|
||||
assert window.brand_bar.btn_calibration.property("navActive") == "true"
|
||||
|
||||
window.brand_bar.btn_playback.click()
|
||||
assert window.view_preset() == "replay"
|
||||
assert window.arrow_panel.isVisible() and not window.cal_panel.isVisible()
|
||||
assert window.force_plots.mode() == "browse", "the replay view did not open the session"
|
||||
|
||||
window.brand_bar.btn_live.click()
|
||||
assert window.view_preset() == "live"
|
||||
sizes = window.content_splitter.sizes()
|
||||
assert sizes[2] > sizes[1], f"the 2D panel is not the smaller one: {sizes}"
|
||||
assert window.force_plots.mode() == "follow"
|
||||
assert window.brand_bar.btn_live.property("navActive") == "true"
|
||||
_finish(window, stuck)
|
||||
|
||||
|
||||
def test_the_calibration_view_does_not_clip_its_controls(make_window):
|
||||
from PyQt5.QtWidgets import QHeaderView
|
||||
|
||||
window = _show(make_window(sensor_type="box", show_cal_panel=True), (1440, 880))
|
||||
window.brand_bar.btn_calibration.click()
|
||||
from PyQt5.QtWidgets import QApplication
|
||||
|
||||
for _ in range(3):
|
||||
QApplication.processEvents()
|
||||
|
||||
cal = window.cal_panel
|
||||
assert cal.scroll.isVisible(), "the calibration panel cannot scroll"
|
||||
assert (
|
||||
cal.table.horizontalHeader().sectionResizeMode(0) == QHeaderView.ResizeToContents
|
||||
), "the diagnostic columns can still be elided"
|
||||
for button in (cal.btn_apply, cal.btn_export, cal.btn_toggle_validation):
|
||||
assert button.height() >= button.fontMetrics().height(), button.text()
|
||||
assert button.width() >= button.sizeHint().width(), (
|
||||
f"{button.text()!r} is clipped: {button.width()} < {button.sizeHint().width()}"
|
||||
)
|
||||
|
||||
|
||||
def test_the_2d_area_keeps_the_output_aspect_ratio(make_window):
|
||||
window = _show(
|
||||
make_window(sensor_type="finger", show_finger_3d=False), (1440, 880)
|
||||
)
|
||||
image = window.arrow_panel.image_label
|
||||
assert abs(image.aspect() - 16.0 / 9.0) < 0.01
|
||||
assert abs(image.height() - image.width() / image.aspect()) <= 2, (
|
||||
f"the 2D area does not follow the output aspect: {image.width()}x{image.height()}"
|
||||
)
|
||||
|
||||
|
||||
def test_the_playback_dialog_labels_its_buttons(make_window):
|
||||
"""The dialog's three controls had no text at all: it never retranslated."""
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
from orisys_studio.ui.playback_dialog import PlaybackDialog
|
||||
|
||||
window = _show(make_window(), (1440, 880))
|
||||
dialog = PlaybackDialog(theme="dark", parent=window)
|
||||
try:
|
||||
assert dialog.open_button.text() == t("playback.open")
|
||||
assert dialog.unit_button.text()
|
||||
assert dialog.depth_button.text() == t("playback.recompute_depth")
|
||||
assert dialog.depth_button.toolTip() == t("playback.recompute_depth_tip")
|
||||
finally:
|
||||
dialog.close()
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user