三维未激活采样显示灰点,二维增加深度叠箭头。

分析对话框默认使用传感器对话框保存的标定,关闭按钮改为保存。框选曲线时数值卡片仍显示光标读数。相关单测通过。
This commit is contained in:
ykcheungab
2026-10-08 17:04:30 +08:00
parent 3d2fb0c4eb
commit 091d0fda35
18 changed files with 507 additions and 202 deletions
+2
View File
@@ -106,6 +106,7 @@
"filter_off": "Not filtering (raw readings)",
"ok": "OK",
"cancel": "Cancel",
"save": "Save",
"close": "Close"
},
"toolbar": {
@@ -214,6 +215,7 @@
"view_arrows_pure": "Arrows",
"view_arrows_stitched": "Arrows / Captured",
"view_depth": "Depth map",
"view_depth_arrows": "Depth + arrows",
"view_recorded": "Recorded image",
"view_mode_tip": "Image display mode while running",
"arrow_settings_tip": "Finger 2D presentation (orientation; display only)",
+2
View File
@@ -106,6 +106,7 @@
"filter_off": "未滤波(原始读数)",
"ok": "确定",
"cancel": "取消",
"save": "保存",
"close": "关闭"
},
"toolbar": {
@@ -214,6 +215,7 @@
"view_arrows_pure": "箭头",
"view_arrows_stitched": "箭头/采集图像",
"view_depth": "深度图",
"view_depth_arrows": "深度 + 箭头",
"view_recorded": "原始图像",
"view_mode_tip": "运行时的图像显示模式",
"arrow_settings_tip": "手指 2D 显示方向(只影响画面)",
+2 -2
View File
@@ -284,8 +284,8 @@
"purpose": "write the calibration panel's coefficients (the panel's own action)" },
{ "id": "dialog_apply", "path": "sensor_dialog.btn_apply", "objectName": null, "class": "QPushButton",
"purpose": "push the last export into the running device" },
{ "id": "dialog_close", "path": "sensor_dialog.btn_close", "objectName": null, "class": "QPushButton",
"purpose": "close the dialog" }
{ "id": "dialog_save", "path": "sensor_dialog.btn_save", "objectName": null, "class": "QPushButton",
"purpose": "keep the current sensor and calibration choice and dismiss the dialog" }
]
}
],
+26 -9
View File
@@ -1855,21 +1855,22 @@ class StudioMainWindow(QMainWindow):
entry = self.sensor_picker.selected_entry()
return None if entry is None else entry.ref
def live_calibration_choice(self) -> tuple[str, Path | None, str]:
"""``(role, file_path_or_None, device_id)`` remembered for the selected bundle.
def live_calibration_for(
self, ref: str
) -> tuple[str, Path | None, str] | None:
"""Saved live choice for one catalog ref, or ``None`` when nothing was saved.
``file_path`` is only set for the browsed-file role; the profile default is
resolved when the chain opens. A missing file falls back to raw force.
``None`` is not raw force: a caller that has its own default (the analysis
prompt) keeps that default until the sensor dialog has stored a choice.
"""
from orisys_studio.ui.sensor_dialog import ROLE_DEFAULT, ROLE_FILE, ROLE_NONE
key = self._live_calibration_prefs_key()
store = self.prefs.get("live_calibration")
if not key or not isinstance(store, dict):
return ROLE_NONE, None, ""
entry = store.get(key)
if not ref or not isinstance(store, dict):
return None
entry = store.get(ref)
if not isinstance(entry, dict):
return ROLE_NONE, None, ""
return None
role = str(entry.get("role") or ROLE_NONE)
if role not in (ROLE_DEFAULT, ROLE_FILE, ROLE_NONE):
role = ROLE_NONE
@@ -1882,6 +1883,20 @@ class StudioMainWindow(QMainWindow):
path = candidate
return role, path, str(entry.get("device_id") or "")
def live_calibration_choice(self) -> tuple[str, Path | None, str]:
"""``(role, file_path_or_None, device_id)`` remembered for the selected bundle.
``file_path`` is only set for the browsed-file role; the profile default is
resolved when the chain opens. No saved choice is raw force for a live open.
"""
from orisys_studio.ui.sensor_dialog import ROLE_NONE
key = self._live_calibration_prefs_key()
found = None if key is None else self.live_calibration_for(key)
if found is None:
return ROLE_NONE, None, ""
return found
def set_live_calibration_choice(
self,
*,
@@ -2948,6 +2963,7 @@ class StudioMainWindow(QMainWindow):
initial_reference_frame=initial_reference_frame,
initial_device_id=initial_device_id,
theme=self._theme,
remembered_calibration=self.live_calibration_for,
)
if dialog.exec() != VideoAnalysisDialog.DialogCode.Accepted:
return None
@@ -2990,6 +3006,7 @@ class StudioMainWindow(QMainWindow):
initial_reference_frame=getattr(self, "_analysis_reference_override", None),
lock_profile=True,
theme=self._theme,
remembered_calibration=self.live_calibration_for,
)
if dialog.exec() != VideoAnalysisDialog.DialogCode.Accepted:
return False
+3 -2
View File
@@ -14,6 +14,7 @@ import numpy as np
from orisys_app_shared.acquisition.arrows import arrow_display_scale, hex_to_bgr
from orisys_app_shared.acquisition.image_backend import (
DEPTH,
DEPTH_ARROWS,
DomainMaskLatch,
ImageFrame,
plan_image,
@@ -232,7 +233,7 @@ class FrameView:
self._last_depth_frame = frame.depth
span = self.active_depth_span()
self.publish_shared_depth_scale(span)
if mode == DEPTH:
if mode in (DEPTH, DEPTH_ARROWS):
self._arrow_panel.set_depth_scale(
self.depth_scale_text(frame),
tip_key=self.depth_scale_tip_key(),
@@ -267,7 +268,7 @@ class FrameView:
# Never substitute another picture here: a missing depth map that showed
# the processed frame made "no depth" look like a depth reading. A draw
# failure used to return the black base image; say so instead.
if mode == DEPTH and rendered is None:
if mode in (DEPTH, DEPTH_ARROWS) and rendered is None:
message = t("panels.depth_unavailable")
elif diagnostic is not None:
message = t("panels.arrow_render_failed")
@@ -14,6 +14,7 @@ from orisys_app_shared.acquisition.image_backend import (
ARROW_PURE,
ARROW_STITCHED,
DEPTH,
DEPTH_ARROWS,
ImagePlan,
)
from orisys_app_shared.i18n import t
@@ -32,7 +33,7 @@ from PySide6.QtWidgets import (
from orisys_studio.ui import icons
from orisys_studio.ui.fit_text import ShrinkableLabel
VIEW_MODES = (ARROW_PURE, ARROW_STITCHED, DEPTH)
VIEW_MODES = (ARROW_PURE, ARROW_STITCHED, DEPTH, DEPTH_ARROWS)
#: While replaying a recording there is no optical flow to draw and no depth is
#: stored: the panel shows the recorded frames, and nothing else.
REPLAY_VIEW_MODES = ("recorded",)
@@ -53,6 +54,7 @@ _VIEW_LABELS = {
ARROW_PURE: "panels.view_arrows_pure",
ARROW_STITCHED: "panels.view_arrows_stitched",
DEPTH: "panels.view_depth",
DEPTH_ARROWS: "panels.view_depth_arrows",
"recorded": "panels.view_recorded",
}
_ROTATION_STEPS = (0, 90, 180, 270)
@@ -433,15 +435,17 @@ class ArrowPanel(QFrame):
stays regardless is what the picture *is*: the plan's own line ("this recording
stores only the raw camera image"), the depth scale, and the colour bar.
"""
depth = self.view_mode() == DEPTH
mode = self.view_mode()
depth = mode == DEPTH
overlay = mode == DEPTH_ARROWS
modes = self.view_modes()
has_arrows = ARROW_PURE in modes or ARROW_STITCHED in modes
has_arrows = ARROW_PURE in modes or ARROW_STITCHED in modes or overlay
explain = bool(self._show_diagnostics)
self.scale_label.setVisible(explain and not depth and has_arrows)
self.pipeline_label.setVisible(explain and not depth)
self.area_label.setVisible(explain and not depth and has_arrows)
self.colorbar.setVisible(depth)
self.depth_scale_label.setVisible(depth and bool(self._depth_scale_text))
self.colorbar.setVisible(depth or overlay)
self.depth_scale_label.setVisible((depth or overlay) and bool(self._depth_scale_text))
self.note_label.setVisible(bool(self._plan_note))
def set_diagnostics_visible(self, visible: bool) -> None:
+19 -85
View File
@@ -1057,15 +1057,14 @@ class ForcePlotsPanel(QFrame):
next to cards reading the live 0.0, which reads as "the replay is not
open". Availability is decided per sample here as well, so a cursor on a
row with no value in this unit shows a dash, never a stale number.
A zoomed window is still the cursor: the strip may name how long that
window is, but the big numbers stay on the sample under the playhead.
"""
if self._mode != "browse" and not self.is_recording_source():
return
if not self._source_ready:
return
if self._selection_active():
# The cards are stating the selection's peak / mean / σ; the cursor is
# inside that window and must not overwrite them.
return
first, last = self._source.extent()
t0 = max(first, self._cursor_t - CURSOR_READOUT_S)
t1 = min(last, self._cursor_t + CURSOR_READOUT_S)
@@ -1095,12 +1094,11 @@ class ForcePlotsPanel(QFrame):
self._render_value_cards()
def _update_selection_readout(self) -> None:
"""Refresh what the selection is worth: the cards, and the strip's size.
"""Refresh the strip's window size; the cards stay on the cursor.
The per-channel numbers live on the cards (peak, then mean · σ); the strip
under the charts keeps only how long the selection is and how many samples
it holds, because that is all that fits there. The full line stays in the
strip's tooltip.
The strip under the charts keeps how long the selection is and how many
samples it holds. Peak / mean / σ used to sit on the value cards when the
window was inset; those cards now always follow the playhead.
"""
if not self._source_ready:
self._selection = None
@@ -1109,12 +1107,10 @@ class ForcePlotsPanel(QFrame):
self._render_value_cards()
return
if not self._selection_active():
# Following live, or fitted to the whole session: the cards are the
# readout of the cursor, not of a window.
self._selection = None
self.stats_label.setText("")
self.stats_label.setToolTip("")
self._render_value_cards()
self._readout_at_cursor()
return
t0, t1 = self._window
stats = self._source.statistics(t0, t1, unit=self._unit)
@@ -1122,37 +1118,17 @@ class ForcePlotsPanel(QFrame):
self._selection = None
self.stats_label.setText(t("chart.selection_empty"))
self.stats_label.setToolTip("")
self._render_value_cards()
self._readout_at_cursor()
return
self._selection = stats
self.stats_label.setText(
t(
"chart.selection_summary",
duration=f"{stats.duration_s:.2f}",
samples=stats.samples,
)
)
self.stats_label.setToolTip(self._selection_tooltip(stats))
self._render_value_cards()
def _selection_tooltip(self, stats) -> str:
"""The full selection readout, for the strip's tooltip."""
unit = unit_display(self._unit)
parts = []
for index, label in enumerate(CHANNEL_LABELS):
peak = float(stats.peaks[index])
if not np.isfinite(peak):
continue
parts.append(
f"{label} 峰 {peak:.3g}{unit} · 均 {float(stats.means[index]):.3g} "
f"· σ {float(stats.stds[index]):.3g}"
)
return t(
"chart.selection",
summary = t(
"chart.selection_summary",
duration=f"{stats.duration_s:.2f}",
samples=stats.samples,
detail=" ".join(parts),
)
self.stats_label.setText(summary)
self.stats_label.setToolTip(summary)
self._readout_at_cursor()
def _selection_active(self) -> bool:
"""Whether the visible window is a *selection* and not the whole session.
@@ -1421,20 +1397,8 @@ class ForcePlotsPanel(QFrame):
curve.setData([], [])
def _render_value_cards(self) -> None:
"""One card per channel: what it reads *now*, in the active unit.
While a selection is being inspected the big number is that window's peak
and the line under it its mean and σ — the selection statistics used to be
a single long line under the charts, which could not fit and pushed the
panel wider.
"""
decimals = (
self.presentation.newton_decimals
if self._unit == "N" else self.presentation.raw_decimals
)
fmt = "{value:." + str(decimals) + "f}"
"""One card per channel: the sample under the cursor, in the active unit."""
unavailable = t("chart.value_unavailable")
selecting = self._selection
for index, (value_label, unit_label, detail_label, value) in enumerate(
zip(
self.value_labels,
@@ -1444,11 +1408,7 @@ class ForcePlotsPanel(QFrame):
)
):
available = self._available[index]
if selecting is not None:
peak = float(selecting.peaks[index])
available = bool(np.isfinite(peak))
value = peak
elif self.presentation.enabled:
if self.presentation.enabled:
value = self._display_values[index]
if not available:
# An axis with no curve in this unit: a dash, never a raw count
@@ -1462,44 +1422,18 @@ class ForcePlotsPanel(QFrame):
continue
value_label.setText(
format_force(value, self._unit, presentation=self.presentation)
if selecting is None
else fmt.format(value=value)
)
tip = t("chart.selection_peak_tip") if selecting is not None else ""
zero_zone = (
selecting is None
and self.presentation.enabled
self.presentation.enabled
and abs(float(value)) == 0.0
and index < len(self._display_suppressed)
and self._display_suppressed[index]
)
if zero_zone:
tip = t("chart.zero_zone_tip")
value_label.setToolTip(tip)
value_label.setToolTip(t("chart.zero_zone_tip") if zero_zone else "")
unit_label.setText(unit_display(self._unit))
if selecting is not None:
detail_label.setText(self._selection_detail(index))
elif zero_zone:
detail_label.setText(t("chart.zero_zone"))
else:
detail_label.setText("")
detail_label.setText(t("chart.zero_zone") if zero_zone else "")
self.unit_badge.setText(unit_display(self._unit))
def _selection_detail(self, index: int) -> str:
"""``mean · σ`` for one channel of the current selection."""
stats = self._selection
if stats is None:
return ""
mean = float(stats.means[index])
std = float(stats.stds[index])
if not np.isfinite(mean):
return ""
return t(
"chart.selection_detail",
mean=f"{mean:.4g}",
std=f"{std:.4g}" if np.isfinite(std) else "-",
)
def retranslate(self) -> None:
self.title.setText(t("panels.plots"))
for badge, label in zip(self.channel_badges, CHANNEL_LABELS):
@@ -200,13 +200,13 @@ class SensorDialog(QDialog):
self.btn_export.clicked.connect(self._on_export)
self.btn_apply = QPushButton()
self.btn_apply.clicked.connect(self._on_apply)
self.btn_close = QPushButton()
self.btn_close.setProperty("compact", "true")
self.btn_close.clicked.connect(self.close)
self.btn_save = QPushButton()
self.btn_save.setProperty("compact", "true")
self.btn_save.clicked.connect(self._on_save)
actions.addWidget(self.btn_export)
actions.addWidget(self.btn_apply)
actions.addStretch(1)
actions.addWidget(self.btn_close)
actions.addWidget(self.btn_save)
root.addLayout(actions)
# The picker's own changes change the calibration context, so the dialog
@@ -244,6 +244,11 @@ class SensorDialog(QDialog):
apply()
self.refresh()
def _on_save(self) -> None:
"""Keep the current sensor/calibration choice and dismiss the dialog."""
self._persist_choice()
self.close()
def _on_browse(self) -> None:
chosen, _filter = QFileDialog.getOpenFileName(
self,
@@ -554,7 +559,7 @@ class SensorDialog(QDialog):
self.btn_export.setToolTip(t("cal.export_tip"))
self.btn_apply.setText(t("cal.apply"))
self.btn_apply.setToolTip(t("sensor.live_calibration_apply_tip"))
self.btn_close.setText(t("dialog.cancel"))
self.btn_save.setText(t("dialog.save"))
self.picker.retranslate()
self.refresh()
@@ -22,6 +22,7 @@ reason it cannot be used is one line, both in the language the catalog already s
from __future__ import annotations
import json
from collections.abc import Callable
from pathlib import Path
from orisys_app_shared.analysis.bare_video import (
@@ -92,6 +93,10 @@ class VideoAnalysisDialog(QDialog):
initial_device_id: str | None = None,
lock_profile: bool = False,
theme: str = "dark",
remembered_calibration: Callable[
[str], tuple[str, Path | None, str] | None
]
| None = None,
) -> None:
super().__init__(window)
self._video = Path(video)
@@ -110,6 +115,10 @@ class VideoAnalysisDialog(QDialog):
1 if initial_reference_frame is None else int(initial_reference_frame)
)
self._restore_device = str(initial_device_id or "")
#: ``(role, path, device_id)`` saved by the sensor dialog for a catalog ref,
#: or ``None`` when that bundle has no saved choice. Missing means "keep
#: this dialog's own default", which is not the same as an explicit raw-force choice.
self._remembered_calibration = remembered_calibration
self._device_source: Path | None = None
self._lock_profile = bool(lock_profile)
self.setObjectName("videoAnalysisDialog")
@@ -300,7 +309,10 @@ class VideoAnalysisDialog(QDialog):
)
self.buttons.button(QDialogButtonBox.StandardButton.Ok).setEnabled(not blocked)
self._refresh_time_label()
device_id = self._apply_remembered_calibration()
self._sync_device_row()
if device_id:
self.device_edit.setText(device_id)
def _refresh_calibrations(self, *, custom: Path | None) -> None:
"""The calibration field: the profile's default, raw force, or a chosen file."""
@@ -328,6 +340,32 @@ class VideoAnalysisDialog(QDialog):
self.calibration_combo.blockSignals(False)
self._sync_device_row()
def _apply_remembered_calibration(self) -> str:
"""Select the sensor dialog's saved choice for this profile.
Returns a device serial to put back after the row sync, or ``""`` when
there is nothing saved (the combo keeps the profile default or raw force).
"""
entry = self.entry()
memory = self._remembered_calibration
if entry is None or not callable(memory):
return ""
found = memory(entry.ref)
if found is None:
return ""
role, path, device_id = found
if role == ROLE_FILE and path is not None and Path(path).is_file():
self.select_calibration(Path(path))
return str(device_id or "")
if role == ROLE_NONE:
self.select_calibration(None)
return ""
if role == ROLE_DEFAULT:
index = self.calibration_combo.findData(ROLE_DEFAULT)
if index >= 0:
self.calibration_combo.setCurrentIndex(index)
return ""
def _chosen_calibration(self) -> Path | None:
role = str(self.calibration_combo.currentData() or "")
entry = self.entry()
@@ -6,11 +6,13 @@ import json
from pathlib import Path
import pytest
from orisys_app_shared.analysis.bare_video import VideoFacts
from orisys_app_shared.i18n import t
from orisys_app_shared.sensors import scan_sensor_catalog
from orisys_studio.ui import sensor_picker as picker_module
from orisys_studio.ui.sensor_dialog import ROLE_DEFAULT, ROLE_FILE, ROLE_NONE, SensorDialog
from orisys_studio.ui.sensor_picker import SensorPickerWidget
from orisys_studio.ui.video_analysis_dialog import VideoAnalysisDialog
from PySide6.QtWidgets import QWidget
CONFIG = {
@@ -319,3 +321,71 @@ def test_apply_while_running_queues_set_pending_calibration(make_window, tmp_pat
window.apply_live_calibration_choice()
assert pending == []
assert window.statusBar().currentMessage() == t("sensor.live_calibration_remembered")
def _facts(path: Path) -> VideoFacts:
return VideoFacts(path=path, frame_count=4, fps=10.0, width=8, height=8)
def test_analysis_prompt_defaults_to_the_saved_sensor_file(make_window, tmp_path):
"""The file saved from the sensor dialog is the analysis prompt's starting choice."""
window = make_window()
entry = window.sensor_picker.selected_entry()
assert entry is not None
assert window.live_calibration_for(entry.ref) is None
cal = tmp_path / "picked.json"
cal.write_text("{}", encoding="utf-8")
window.set_live_calibration_choice(role=ROLE_FILE, path=cal)
dialog = VideoAnalysisDialog(
video=tmp_path / "clip.mp4",
facts=_facts(tmp_path / "clip.mp4"),
catalog=scan_sensor_catalog(),
window=window,
initial_profile=entry.ref,
remembered_calibration=window.live_calibration_for,
)
try:
assert dialog.calibration_combo.currentData() == ROLE_FILE
assert dialog._chosen_calibration() == cal
finally:
dialog.deleteLater()
def test_analysis_prompt_keeps_its_default_until_a_choice_is_saved(
make_window, tmp_path
):
window = make_window()
dialog = VideoAnalysisDialog(
video=tmp_path / "clip.mp4",
facts=_facts(tmp_path / "clip.mp4"),
catalog=scan_sensor_catalog(),
window=window,
initial_profile="shipped/box",
remembered_calibration=window.live_calibration_for,
)
try:
assert dialog.calibration_combo.currentData() == ROLE_DEFAULT
finally:
dialog.deleteLater()
def test_a_reopened_analysis_setup_still_beats_the_saved_file(make_window, tmp_path):
window = make_window()
entry = window.sensor_picker.selected_entry()
assert entry is not None
cal = tmp_path / "picked.json"
cal.write_text("{}", encoding="utf-8")
window.set_live_calibration_choice(role=ROLE_FILE, path=cal)
dialog = VideoAnalysisDialog(
video=tmp_path / "clip.mp4",
facts=_facts(tmp_path / "clip.mp4"),
catalog=scan_sensor_catalog(),
window=window,
initial_profile=entry.ref,
prefer_no_calibration=True,
remembered_calibration=window.live_calibration_for,
)
try:
assert dialog.calibration_combo.currentData() == ROLE_NONE
finally:
dialog.deleteLater()
+3 -3
View File
@@ -120,9 +120,9 @@ def test_2d_3d_flow_sample_points_match() -> None:
composed = composer.compose(
assets, _depth_frame(assets, flow=flow), settings
)
arrows_3d = (
0 if composed.flow_arrows is None else int(composed.flow_arrows.origins.shape[0])
)
arrows = None if composed.flow_arrows is None else composed.flow_arrows.lengths
# Positive lengths are arrows. Negative lengths are the idle points.
arrows_3d = 0 if arrows is None else int(np.count_nonzero(arrows > 0.0))
# Exactly what the 2D canvas draws from: ``draw_arrow_display`` calls
# ``select_flow_samples`` with the policy the window passes it, and draws the
# samples the flag marks active.
+23 -16
View File
@@ -1741,7 +1741,12 @@ def test_the_replay_view_loads_a_recording_and_scrubs_it(make_window, replay_rec
assert panel.mode() == "follow"
assert window.toolbar.run_role() == "acquire", "the rail kept the transport"
assert window.toolbar.btn_run.text() == t("toolbar.run_label")
assert window.arrow_panel.view_modes() == ("arrows_pure", "arrows_stitched", "depth")
assert window.arrow_panel.view_modes() == (
"arrows_pure",
"arrows_stitched",
"depth",
"depth_arrows",
)
assert "raw_0001" not in window.toolbar.source_summary.text(), (
"the replay's video is still named in the live view"
)
@@ -2747,14 +2752,15 @@ def test_a_recording_without_a_video_says_it_cannot_be_played(make_window, recor
# ------------------------------------------- selection readout and notices
def test_a_selection_moves_its_statistics_onto_the_value_cards(make_window):
"""The per-channel line under the charts did not fit; the cards hold it now.
def test_a_zoomed_window_keeps_the_cursor_on_the_value_cards(make_window):
"""Zooming names the window in the strip; the cards stay on the playhead.
Selecting a window makes the big number that window's peak and the line under
it its mean · σ. The strip keeps only how big the selection is, and leaving
the selection puts the cursor readout back.
Selecting a window used to put that window's peak and mean · σ on the cards.
The big numbers are the sample under the cursor again; the strip keeps only
how big the selection is.
"""
from orisys_app_shared.i18n import t
from orisys_studio.ui.force_plots import format_force
window, stuck = _running_window(make_window)
window.toolbar.units_combo.setCurrentIndex(0) # newton
@@ -2768,6 +2774,7 @@ def test_a_selection_moves_its_statistics_onto_the_value_cards(make_window):
assert plots.stats_label.text() == ""
assert plots.value_details[0].text() == "", "a live reading carries a mean line"
plots.set_cursor(2.0)
plots.set_window(1.0, 3.0)
assert plots.mode() == "browse"
assert plots._selection is not None, "the window was not read as a selection"
@@ -2777,17 +2784,17 @@ def test_a_selection_moves_its_statistics_onto_the_value_cards(make_window):
assert t("chart.selection_summary", duration="1", samples=2)[:4] in summary or (
"选区" in summary or "Selection" in summary
), summary
# The numbers a reader cannot fit in the strip are on the cards instead.
expected_peak = float(plots._selection.peaks[0])
expected = f"{expected_peak:.2f}"
assert plots.value_labels[0].text() == expected, (
f"the FN card is not the selection's peak: {plots.value_labels[0].text()!r}"
assert "峰" not in summary and "均" not in summary, summary
expected = format_force(
plots._display_values[0], plots.unit(), presentation=plots.presentation
)
assert plots.value_labels[0].text() == expected, (
f"the FN card is not the cursor sample: {plots.value_labels[0].text()!r}"
)
assert plots.value_details[0].text() == "", (
f"the card still carries a mean line: {plots.value_details[0].text()!r}"
)
detail = plots.value_details[0].text()
assert detail and detail != "", detail
assert f"{float(plots._selection.means[0]):.4g}" in detail, (detail, plots._selection.means[0])
assert "均" in detail or "mean" in detail, detail
assert plots.value_labels[0].toolTip(), "the peak is not explained anywhere"
# "Whole session" is the default fit, so it is not a selection.
plots.show_all()
+18 -23
View File
@@ -2775,9 +2775,8 @@ def main() -> int:
plots.overview_curve.getData()[0].size > 0,
"the whole-session overview strip is empty",
)
# A window narrower than the session is a selection: its peak becomes the big
# number on each card and its mean/σ the line under it, while the strip keeps
# only the size of the selection (the long per-channel line did not fit).
# A window narrower than the session is a selection in the strip only: the
# cards stay on the sample under the cursor.
check(plots._selection is not None, "zooming in did not record a selection")
summary = plots.stats_label.text()
check(summary != "", "the strip does not state the size of the selection")
@@ -2789,34 +2788,25 @@ def main() -> int:
"选区" in summary or "Selection" in summary,
f"the strip does not say that the numbers describe a selection: {summary!r}",
)
expected_peak = float(plots._selection.peaks[0])
peak_text = (
f"{expected_peak:.3f}" if plots.unit() == "N" else f"{expected_peak:.1f}"
from orisys_studio.ui.force_plots import format_force
expected_cursor = format_force(
plots._display_values[0], plots.unit(), presentation=plots.presentation
)
check(
plots.value_labels[0].text() == peak_text,
f"the FN card is not the selection's peak: {plots.value_labels[0].text()!r}",
plots.value_labels[0].text() == expected_cursor,
f"the FN card is not the cursor sample: {plots.value_labels[0].text()!r}",
)
check(
plots.value_details[0].text() != ""
and ("均" in plots.value_details[0].text() or "mean" in plots.value_details[0].text()),
f"the card does not state the selection's mean: "
f"{plots.value_details[0].text()!r}",
plots.value_details[0].text() == "",
f"the card still carries a mean line: {plots.value_details[0].text()!r}",
)
for card, label, detail in zip(
plots.value_cards, plots.value_labels, plots.value_details
):
check(
detail.width() >= detail.sizeHint().width(),
f"the selection detail line is clipped: "
f"{detail.width()} < {detail.sizeHint().width()}",
)
# Leaving the selection hands the cards back to the cursor readout.
# Leaving the selection clears the strip; the cards stay a cursor readout.
plots.follow_latest()
check(plots._selection is None, "the selection survived 'back to latest'")
check(
plots.value_details[0].text() == "",
f"the mean line survived leaving the selection: "
f"a detail line appeared after leaving the selection: "
f"{plots.value_details[0].text()!r}",
)
@@ -3673,7 +3663,12 @@ def main() -> int:
f"{replay_window.toolbar.source_summary.text()!r}",
)
check(
replay_window.arrow_panel.view_modes() == ("arrows_pure", "arrows_stitched", "depth"),
replay_window.arrow_panel.view_modes() == (
"arrows_pure",
"arrows_stitched",
"depth",
"depth_arrows",
),
"the 2D panel did not return to its live views",
)
check(