fixed commetn from songxiao
This commit is contained in:
@@ -12,8 +12,10 @@ from orisys_studio.display_prefs import DisplayPrefsController
|
||||
|
||||
PLOT_DEFAULTS = {
|
||||
"window_sec": 10.0,
|
||||
"fn_min": 0.0,
|
||||
"fn_max": 5.0,
|
||||
"shear_max": 5.0,
|
||||
"fn_raw_min": 0.0,
|
||||
"fn_raw_max": 1e5,
|
||||
"shear_raw_max": 1e5,
|
||||
}
|
||||
@@ -46,7 +48,10 @@ class _Charts:
|
||||
self.fixed_newton: list[bool] = []
|
||||
self.units: list[str] = []
|
||||
self.window_sec: list[float] = []
|
||||
self.limits: list[tuple] = []
|
||||
self.limits: list[dict] = []
|
||||
self.deadband_floors: list[dict] = []
|
||||
self.presentation_enabled: list[bool] = []
|
||||
self.presentation: list[dict] = []
|
||||
|
||||
def unit(self) -> str:
|
||||
return self._unit
|
||||
@@ -61,8 +66,17 @@ class _Charts:
|
||||
def set_window_sec(self, value: float) -> None: # noqa: N802
|
||||
self.window_sec.append(float(value))
|
||||
|
||||
def set_axis_limits(self, *values) -> None: # noqa: N802
|
||||
self.limits.append(tuple(float(v) for v in values))
|
||||
def set_axis_limits(self, **values) -> None: # noqa: N802
|
||||
self.limits.append({key: float(value) for key, value in values.items()})
|
||||
|
||||
def set_deadband_floors(self, *, newton: float, raw: float) -> None: # noqa: N802
|
||||
self.deadband_floors.append({"newton": float(newton), "raw": float(raw)})
|
||||
|
||||
def configure_presentation(self, **values) -> None: # noqa: N802
|
||||
self.presentation.append(dict(values))
|
||||
|
||||
def set_presentation_enabled(self, enabled: bool) -> None: # noqa: N802
|
||||
self.presentation_enabled.append(bool(enabled))
|
||||
|
||||
|
||||
class _ArrowPanel:
|
||||
@@ -197,27 +211,88 @@ def test_the_plot_dialog_values_come_from_the_preferences_with_defaults():
|
||||
settings = controller.plot_settings()
|
||||
assert settings["window_sec"] == 3.0
|
||||
assert settings["fn_max"] == PLOT_DEFAULTS["fn_max"], "a missing range used no default"
|
||||
assert settings["fn_min"] == 0.0, "FN has no lower bound by default"
|
||||
assert settings["deadband_n"] == 0.0, "the deadband default is 'auto'"
|
||||
assert settings["units"] == "newton"
|
||||
|
||||
|
||||
def test_a_stored_zero_is_a_value_and_not_a_missing_preference():
|
||||
"""0 means "auto" on the raw bounds and the deadband, so it must survive."""
|
||||
controller, parts = _controller(prefs={"plot_fn_raw_max": 0.0})
|
||||
|
||||
settings = controller.plot_settings()
|
||||
assert settings["fn_raw_max"] == 0.0, "a stored 0 became the default"
|
||||
|
||||
|
||||
def test_applying_plot_settings_writes_the_preferences_and_the_panel():
|
||||
controller, parts = _controller(prefs={})
|
||||
controller.apply_plot_settings(
|
||||
{
|
||||
"window_sec": 20.0,
|
||||
"fn_min": -2.0,
|
||||
"fn_max": 8.0,
|
||||
"shear_max": 9.0,
|
||||
"fn_raw_min": -1000.0,
|
||||
"fn_raw_max": 2e5,
|
||||
"shear_raw_max": 3e5,
|
||||
"deadband_n": 0.25,
|
||||
"deadband_raw": 0.0,
|
||||
}
|
||||
)
|
||||
assert parts["host"].prefs["plot_window_sec"] == 20.0
|
||||
assert parts["host"].prefs["plot_fn_min"] == -2.0
|
||||
assert parts["host"].prefs["plot_shear_raw_max"] == 3e5
|
||||
assert parts["host"].prefs["plot_deadband_n"] == 0.25
|
||||
assert parts["charts"].window_sec == [20.0]
|
||||
assert parts["charts"].limits == [(8.0, 9.0, 2e5, 3e5)]
|
||||
assert parts["charts"].limits[-1] == {
|
||||
"fn_min": -2.0,
|
||||
"fn_max": 8.0,
|
||||
"shear_max": 9.0,
|
||||
"fn_raw_min": -1000.0,
|
||||
"fn_raw_max": 2e5,
|
||||
"shear_raw_max": 3e5,
|
||||
}
|
||||
# 0 on a deadband floor means "auto", and the charts are told exactly that.
|
||||
assert parts["charts"].deadband_floors[-1] == {"newton": 0.25, "raw": 0.0}
|
||||
assert parts["host"].persisted == 1
|
||||
|
||||
|
||||
def test_a_partial_plot_settings_mapping_leaves_the_rest_as_stored():
|
||||
"""The older five-value shape must not silently reset a range."""
|
||||
controller, parts = _controller(prefs={"plot_fn_min": -5.0, "plot_deadband_raw": 900.0})
|
||||
controller.apply_plot_settings(
|
||||
{"window_sec": 6.0, "fn_max": 12.0, "shear_max": 3.0,
|
||||
"fn_raw_max": 4e5, "shear_raw_max": 5e5}
|
||||
)
|
||||
assert parts["host"].prefs["plot_fn_min"] == -5.0
|
||||
assert parts["charts"].limits[-1]["fn_min"] == -5.0
|
||||
assert parts["charts"].deadband_floors[-1]["raw"] == 900.0
|
||||
|
||||
|
||||
def test_the_display_baseline_is_not_a_finger_only_setting():
|
||||
"""Every sensor type and both units: an idle newton offset is the same problem."""
|
||||
for sensor, units in (("box", "newton"), ("box", "raw"),
|
||||
("finger", "newton"), ("finger", "raw")):
|
||||
controller, parts = _controller(prefs={"raw_auto_zero": True},
|
||||
sensor_type=sensor, units=units)
|
||||
assert parts["charts"].presentation_enabled[-1] is True, (sensor, units)
|
||||
assert parts["charts"].presentation[-1]["unit"] == (
|
||||
"N" if units == "newton" else "raw"
|
||||
)
|
||||
|
||||
controller, parts = _controller(prefs={"raw_auto_zero": False}, sensor_type="box",
|
||||
units="newton")
|
||||
assert parts["charts"].presentation_enabled[-1] is False
|
||||
|
||||
|
||||
def test_the_deadband_floors_reach_the_charts_from_the_preferences():
|
||||
controller, parts = _controller(prefs={"plot_deadband_n": 0.5, "plot_deadband_raw": 2000.0})
|
||||
assert parts["charts"].deadband_floors[-1] == {"newton": 0.5, "raw": 2000.0}
|
||||
|
||||
controller.set_raw_auto_zero(True) # re-applies the whole policy
|
||||
assert parts["charts"].deadband_floors[-1] == {"newton": 0.5, "raw": 2000.0}
|
||||
|
||||
|
||||
def test_the_sample_rate_prefers_the_measured_rate_over_the_stored_one():
|
||||
controller, parts = _controller(prefs={"measured_fps": 12.0}, latest_fps=48.5)
|
||||
assert controller.device_sample_rate() == 48.5
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
"""The force charts as the operator sees them: baseline, deadband and ranges.
|
||||
|
||||
The display policy is a *view* concern: an idle channel must read zero and draw a
|
||||
flat line, while the session store and ``forces.csv`` keep the measurement. These
|
||||
tests drive the panel on its own (no acquisition, no window) and read back what
|
||||
the curves and the value cards were actually given.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from orisys_app_shared.force.session import SessionSample, SessionStore
|
||||
from orisys_studio.ui.force_plots import ForcePlotsPanel, _nice_ticks, format_force
|
||||
|
||||
#: The two idle triplets from the customer's screenshots: a raw FN of ~9555 and
|
||||
#: an FX whose bias sits ~13k counts off zero.
|
||||
RAW_BIAS = np.array([9555.0, -13368.0, -353.0])
|
||||
NEWTON_BIAS = np.array([0.4, -0.25, 0.1])
|
||||
|
||||
|
||||
def _panel(unit: str = "raw") -> ForcePlotsPanel:
|
||||
"""A chart in one unit, with the display baseline switched on."""
|
||||
panel = ForcePlotsPanel(theme="dark")
|
||||
panel.set_units(unit)
|
||||
panel.configure_presentation(sensor_type="box", unit=unit)
|
||||
panel.set_presentation_enabled(True)
|
||||
panel.set_fixed_newton(True)
|
||||
return panel
|
||||
|
||||
|
||||
def _rest(
|
||||
panel: ForcePlotsPanel,
|
||||
bias: np.ndarray,
|
||||
*,
|
||||
unit: str = "raw",
|
||||
samples: int = 80,
|
||||
spread: float = 0.0,
|
||||
sigma: float = 0.0,
|
||||
seed: int = 0,
|
||||
) -> None:
|
||||
"""Feed a quiet startup window, the way a live session would.
|
||||
|
||||
``spread`` is a bounded wander (the device really does sit still) and
|
||||
``sigma`` is the random jitter of a `noisier` product.
|
||||
"""
|
||||
rng = np.random.default_rng(seed)
|
||||
for index in range(samples):
|
||||
sample = bias + ((index % 5) - 2) * spread
|
||||
if sigma:
|
||||
sample = sample + rng.normal(0.0, sigma, size=3)
|
||||
panel.push(index / 30.0, *sample, unit=unit)
|
||||
|
||||
|
||||
def _ticks(panel: ForcePlotsPanel, channel: int) -> list[float]:
|
||||
"""The Y tick values actually installed on one axis."""
|
||||
levels = panel.plots[channel].getAxis("left")._tickLevels
|
||||
assert levels, "the axis has no explicit ticks"
|
||||
return [float(value) for value, _text in levels[0]]
|
||||
|
||||
|
||||
def _curve(panel: ForcePlotsPanel, channel: int) -> np.ndarray:
|
||||
_times, values = panel.curves[channel].getData()
|
||||
return np.asarray(values, dtype=float)
|
||||
|
||||
|
||||
# ------------------------------------------------------------ resting channel
|
||||
def test_the_cards_read_zero_from_the_first_samples_of_a_session(qt_app):
|
||||
"""The reported defect: FN 1118 raw, FX 34.16, FY -186.1 on the cards.
|
||||
|
||||
The screenshot was taken as the session started, so the aid has to be
|
||||
effective from the first samples — not once the whole quiet window closed.
|
||||
"""
|
||||
panel = _panel("raw")
|
||||
for index in range(4):
|
||||
panel.push(index / 30.0, *RAW_BIAS, unit="raw")
|
||||
if index < 2:
|
||||
# Nothing has been estimated from fewer than three samples yet; those
|
||||
# first frames honestly show the measurement.
|
||||
continue
|
||||
assert [label.text() for label in panel.value_labels] == ["0"] * 3, index
|
||||
|
||||
assert not panel.presentation.ready, "the whole three second window was needed"
|
||||
assert panel.presentation.display_quality == "provisional"
|
||||
for channel in (0, 1, 2):
|
||||
assert set(np.unique(_curve(panel, channel))) == {0.0}, channel
|
||||
|
||||
|
||||
def test_the_newton_cards_read_zero_from_the_first_samples_too(qt_app):
|
||||
"""Both units: a newton offset at rest is the same display problem."""
|
||||
panel = _panel("N")
|
||||
for index in range(4):
|
||||
panel.push(index / 30.0, *NEWTON_BIAS, unit="N")
|
||||
if index < 2:
|
||||
continue
|
||||
assert [label.text() for label in panel.value_labels] == ["0.000"] * 3, index
|
||||
assert not panel.presentation.ready
|
||||
|
||||
|
||||
def test_a_card_and_its_own_curve_are_the_same_value(qt_app):
|
||||
"""A card may never read a number its curve does not carry.
|
||||
|
||||
The extra "the estimate is ready" gate in ``_render_value_cards`` is what
|
||||
used to let the card show the raw measurement while the curve was already
|
||||
drawn through the applied estimate.
|
||||
"""
|
||||
panel = _panel("raw")
|
||||
for index in range(4):
|
||||
panel.push(index / 30.0, *RAW_BIAS, unit="raw")
|
||||
# A real contact, flagged as one so it does not enter the idle estimate.
|
||||
panel.push(1.0, *(RAW_BIAS + [4000.0, 0.0, 0.0]), unit="raw", contact_found=True)
|
||||
|
||||
for channel in range(3):
|
||||
drawn = float(_curve(panel, channel)[-1])
|
||||
assert panel.value_labels[channel].text() == format_force(
|
||||
drawn, "raw", presentation=panel.presentation
|
||||
), channel
|
||||
assert panel.value_labels[0].text() not in ("0", "1118"), "the contact was lost"
|
||||
|
||||
|
||||
def test_a_resting_raw_channel_reads_zero_and_draws_a_flat_curve(qt_app):
|
||||
"""FN 9555 / FX -13368 at rest must read 0 and draw a line on zero."""
|
||||
panel = _panel("raw")
|
||||
_rest(panel, RAW_BIAS, spread=20.0)
|
||||
|
||||
assert panel.presentation.ready, "the quiet window never closed"
|
||||
for index, label in enumerate(panel.value_labels):
|
||||
assert label.text() == "0", (index, label.text())
|
||||
for channel in (0, 1, 2):
|
||||
values = _curve(panel, channel)
|
||||
assert values.size, channel
|
||||
assert set(np.unique(values)) == {0.0}, (channel, np.unique(values))
|
||||
|
||||
|
||||
def test_a_resting_newton_channel_is_baselined_too(qt_app):
|
||||
"""The same aid in newtons: it is a display problem in either unit."""
|
||||
panel = _panel("N")
|
||||
_rest(panel, NEWTON_BIAS, unit="N")
|
||||
|
||||
assert panel.presentation.ready
|
||||
assert [label.text() for label in panel.value_labels] == ["0.000"] * 3
|
||||
assert set(np.unique(_curve(panel, 0))) == {0.0}
|
||||
|
||||
|
||||
def test_noise_outside_the_estimated_band_is_still_rare(qt_app):
|
||||
"""A p99 band, so a resting *noisy* channel is flat but not rigid.
|
||||
|
||||
The offset removal is what fixes a 13k-count bias; the percentile band holds
|
||||
the remaining jitter at zero. A few idle samples may sit just outside it —
|
||||
that is what the operator's deadband floor in the dialog is for.
|
||||
"""
|
||||
panel = _panel("raw")
|
||||
_rest(panel, RAW_BIAS, sigma=30.0)
|
||||
|
||||
assert panel.presentation.ready
|
||||
values = _curve(panel, 1)
|
||||
zeroed = float(np.mean(values == 0.0))
|
||||
assert zeroed >= 0.95, f"only {zeroed:.2%} of a resting channel was zeroed"
|
||||
assert abs(float(np.median(values))) < 1.0
|
||||
|
||||
|
||||
def test_the_record_keeps_the_measurement_while_the_display_is_zeroed(qt_app):
|
||||
"""The deadband is a display copy: the buffer and the store see the truth."""
|
||||
panel = _panel("raw")
|
||||
store = SessionStore()
|
||||
panel.set_store(store)
|
||||
for index in range(6):
|
||||
panel.push(index / 30.0, *RAW_BIAS, unit="raw")
|
||||
store.append(
|
||||
SessionSample(
|
||||
index,
|
||||
index / 30.0,
|
||||
fn_raw=RAW_BIAS[0],
|
||||
fx_raw=RAW_BIAS[1],
|
||||
fy_raw=RAW_BIAS[2],
|
||||
fn_n=NEWTON_BIAS[0],
|
||||
fx_n=NEWTON_BIAS[1],
|
||||
fy_n=NEWTON_BIAS[2],
|
||||
valid=True,
|
||||
)
|
||||
)
|
||||
|
||||
# The very values the customer complained about are still measured, from the
|
||||
# first sample on — in the live buffer...
|
||||
state = panel.history.state()
|
||||
assert state is not None
|
||||
assert abs(float(np.median(state.values[:, 0])) - RAW_BIAS[0]) < 100.0
|
||||
assert abs(float(np.median(state.values[:, 1])) - RAW_BIAS[1]) < 100.0
|
||||
# ...and in the session store (which is what forces.csv is written from).
|
||||
stored = store.data().raw
|
||||
assert stored.shape[0] == 6
|
||||
assert float(np.median(stored[:, 0])) == RAW_BIAS[0]
|
||||
assert float(np.median(stored[:, 1])) == RAW_BIAS[1]
|
||||
assert float(np.median(stored[:, 2])) == RAW_BIAS[2]
|
||||
|
||||
# ...while the cards and the curve drawn from it are already flat on zero.
|
||||
assert [label.text() for label in panel.value_labels] == ["0"] * 3
|
||||
assert set(np.unique(_curve(panel, 1))) == {0.0}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ deadband
|
||||
def test_a_small_excursion_is_suppressed_and_a_larger_one_is_not(qt_app):
|
||||
panel = _panel("raw")
|
||||
_rest(panel, RAW_BIAS, spread=20.0)
|
||||
estimate = panel.presentation.last_estimate
|
||||
assert estimate is not None and estimate.quality == "ok"
|
||||
deadband = float(estimate.deadbands[0])
|
||||
assert deadband > 0.0
|
||||
|
||||
panel.push(3.0, *(RAW_BIAS + [0.5 * deadband, 0.0, 0.0]), unit="raw")
|
||||
assert panel.value_labels[0].text() == "0", "half the deadband was drawn"
|
||||
|
||||
panel.push(3.1, *(RAW_BIAS + [3.0 * deadband, 0.0, 0.0]), unit="raw")
|
||||
assert panel.value_labels[0].text() != "0", (
|
||||
"an excursion well outside the deadband was swallowed"
|
||||
)
|
||||
assert abs(_curve(panel, 0)[-1]) > 2.0 * deadband
|
||||
# ...and the other axes, which stayed quiet, are still zero.
|
||||
assert panel.value_labels[1].text() == "0"
|
||||
|
||||
|
||||
def test_the_operator_floor_widens_the_deadband_at_once(qt_app):
|
||||
panel = _panel("raw")
|
||||
_rest(panel, RAW_BIAS, spread=20.0)
|
||||
|
||||
panel.set_deadband_floors(raw=250000.0)
|
||||
panel.push(3.0, *(RAW_BIAS + [50000.0, 0.0, 0.0]), unit="raw")
|
||||
assert panel.value_labels[0].text() == "0", (
|
||||
"an excursion inside the operator's floor was drawn"
|
||||
)
|
||||
|
||||
panel.set_deadband_floors(raw=0.0) # "auto": the layer's own floor returns
|
||||
assert panel.presentation.min_deadband_for("raw") == 50.0
|
||||
|
||||
|
||||
# -------------------------------------------------------------------- ranges
|
||||
def test_the_configured_ranges_reach_every_axis(qt_app):
|
||||
"""FN is asymmetric and may go below zero; FX/FY are symmetric."""
|
||||
panel = ForcePlotsPanel(theme="dark")
|
||||
panel.set_axis_limits(
|
||||
fn_min=-5.0,
|
||||
fn_max=15.0,
|
||||
shear_max=3.0,
|
||||
fn_raw_min=-100000.0,
|
||||
fn_raw_max=1000000.0,
|
||||
shear_raw_max=500000.0,
|
||||
)
|
||||
panel.set_fixed_newton(True)
|
||||
|
||||
# The panel starts in raw, so the raw limits are the ones on the axes.
|
||||
assert panel.plots[0].getViewBox().viewRange()[1] == [-100000.0, 1000000.0]
|
||||
assert panel.plots[1].getViewBox().viewRange()[1] == [-500000.0, 500000.0]
|
||||
|
||||
panel.set_units("N")
|
||||
assert panel.plots[0].getViewBox().viewRange()[1] == [-5.0, 15.0]
|
||||
assert panel.plots[1].getViewBox().viewRange()[1] == [-3.0, 3.0]
|
||||
assert panel.plots[2].getViewBox().viewRange()[1] == [-3.0, 3.0]
|
||||
assert panel._fixed_y_range(0) == (-5.0, 15.0)
|
||||
assert panel._fixed_y_range(1) == (-3.0, 3.0)
|
||||
# A range that starts below zero carries an explicit 0 tick: that is the
|
||||
# "missing origin" the customer could not find.
|
||||
assert 0.0 in _ticks(panel, 0)
|
||||
|
||||
panel.set_units("raw")
|
||||
assert panel._fixed_y_range(0) == (-100000.0, 1000000.0)
|
||||
assert panel._fixed_y_range(1) == (-500000.0, 500000.0)
|
||||
|
||||
|
||||
def test_the_zero_line_is_only_drawn_where_zero_is_inside_the_range(qt_app):
|
||||
"""pyqtgraph does not clip a layout item to its own view box."""
|
||||
panel = ForcePlotsPanel(theme="dark")
|
||||
panel.set_axis_limits(
|
||||
fn_min=0.0,
|
||||
fn_max=20.0,
|
||||
shear_max=3.0,
|
||||
fn_raw_min=100.0,
|
||||
fn_raw_max=1000000.0,
|
||||
shear_raw_max=500000.0,
|
||||
)
|
||||
view_box = panel.plots[0].getViewBox()
|
||||
assert not panel.zero_lines[0].isVisibleTo(view_box), (
|
||||
"a zero line was painted on an axis that does not reach zero"
|
||||
)
|
||||
assert panel.zero_lines[1].isVisibleTo(panel.plots[1].getViewBox())
|
||||
assert 0.0 not in _ticks(panel, 0)
|
||||
|
||||
# Give FN a negative lower bound and the origin appears.
|
||||
panel.set_axis_limits(
|
||||
fn_min=0.0,
|
||||
fn_max=20.0,
|
||||
shear_max=3.0,
|
||||
fn_raw_min=-100000.0,
|
||||
fn_raw_max=1000000.0,
|
||||
shear_raw_max=500000.0,
|
||||
)
|
||||
assert panel.zero_lines[0].isVisibleTo(view_box)
|
||||
assert 0.0 in _ticks(panel, 0)
|
||||
|
||||
|
||||
def test_an_inverted_range_is_widened_rather_than_collapsing_the_axis(qt_app):
|
||||
panel = ForcePlotsPanel(theme="dark")
|
||||
panel.set_axis_limits(
|
||||
fn_min=5.0,
|
||||
fn_max=2.0,
|
||||
shear_max=3.0,
|
||||
fn_raw_min=0.0,
|
||||
fn_raw_max=1000000.0,
|
||||
shear_raw_max=500000.0,
|
||||
)
|
||||
ymin, ymax = panel._fixed_y_range(0)
|
||||
assert ymax > ymin
|
||||
|
||||
|
||||
def test_the_default_ranges_are_the_ones_they_always_were(qt_app):
|
||||
"""Defaults preserve today's behaviour: FN 0…20 N, shear ±20 N (and raw)."""
|
||||
panel = ForcePlotsPanel(theme="dark")
|
||||
assert panel._fixed_y_range(0) == (0.0, 1000000.0)
|
||||
assert panel._fixed_y_range(1) == (-500000.0, 500000.0)
|
||||
|
||||
panel.set_units("N")
|
||||
panel.set_fixed_newton(True)
|
||||
assert panel._fixed_y_range(0) == (0.0, 20.0)
|
||||
assert panel._fixed_y_range(1) == (-20.0, 20.0)
|
||||
# With the lower bound at 0 nothing changes about the tick list either.
|
||||
assert _nice_ticks(0.0, 20.0, 5) == [
|
||||
(0.0, "0"),
|
||||
(5.0, "5"),
|
||||
(10.0, "10"),
|
||||
(15.0, "15"),
|
||||
(20.0, "20"),
|
||||
]
|
||||
@@ -0,0 +1,139 @@
|
||||
"""The live Pause control: what it stops, and what it deliberately leaves alone.
|
||||
|
||||
Pause means "stop computing and stop the picture, keep the camera". It must never be
|
||||
confused with 停止 (which releases the device) or with the chart's own browse pause, and
|
||||
it must not survive a session.
|
||||
|
||||
It lives on the rail's transport button — the same control the replay view uses to play
|
||||
and pause a recording. ``checked`` means "acquiring and not paused", so unchecking the
|
||||
button *while a session exists* is the pause, and checking it again is the resume. That
|
||||
is how one two-state toggle carries a three-state transport: the session state, not the
|
||||
button, is what tells stopped from paused.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
from tests.conftest import finish_run, running_window, show_window
|
||||
|
||||
|
||||
def test_the_transport_is_idle_with_no_session(make_window):
|
||||
"""Nothing to pause: the button says it runs, and there is no pause to reach."""
|
||||
window = show_window(make_window(sensor_type="box", source_mode="camera"), (1440, 880))
|
||||
assert not window.toolbar.btn_run.isChecked()
|
||||
assert window.toolbar.btn_run.text() == t("toolbar.run_label")
|
||||
assert not window.act_pause.isEnabled()
|
||||
window.close()
|
||||
|
||||
|
||||
def test_unchecking_the_transport_pauses_and_never_stops_the_device(make_window):
|
||||
window, stuck = running_window(make_window)
|
||||
try:
|
||||
assert window.toolbar.btn_run.isChecked(), "the transport does not show acquiring"
|
||||
assert window.toolbar.btn_run.text() == t("toolbar.pause")
|
||||
|
||||
window.toolbar.btn_run.setChecked(False)
|
||||
assert window._display_paused is True
|
||||
assert stuck.is_paused is True, "the worker was not asked to pause"
|
||||
assert stuck.pause_requests == 1
|
||||
assert window.act_pause.isChecked(), "the menu action did not follow the transport"
|
||||
assert window.toolbar.btn_run.text() == t("toolbar.resume_label")
|
||||
# The device is still open and the session is still the session.
|
||||
assert window._running and window._session_phase == "running", (
|
||||
"pausing ended the session instead of holding it"
|
||||
)
|
||||
assert "暂停" in window.statusBar().currentMessage() or (
|
||||
"Paus" in window.statusBar().currentMessage()
|
||||
)
|
||||
|
||||
window.toolbar.btn_run.setChecked(True)
|
||||
assert window._display_paused is False
|
||||
assert stuck.is_paused is False, "the worker was not asked to resume"
|
||||
assert stuck.pause_requests == 2
|
||||
assert not window.act_pause.isChecked()
|
||||
assert window.toolbar.btn_run.text() == t("toolbar.pause")
|
||||
finally:
|
||||
finish_run(window, stuck)
|
||||
|
||||
|
||||
def test_unchecking_the_transport_with_no_session_does_nothing(make_window):
|
||||
"""The unchecked state only means paused *while a session exists*."""
|
||||
window = show_window(make_window(sensor_type="box", source_mode="camera"), (1440, 880))
|
||||
window._on_run_toggled(False)
|
||||
assert window._display_paused is False
|
||||
assert not window._running
|
||||
assert window.toolbar.btn_run.text() == t("toolbar.run_label")
|
||||
window.close()
|
||||
|
||||
|
||||
def test_the_stop_button_is_what_ends_a_paused_session(make_window):
|
||||
"""Pause holds the session; only Stop releases the device."""
|
||||
window, stuck = running_window(make_window)
|
||||
try:
|
||||
window.toolbar.btn_run.setChecked(False)
|
||||
assert window._display_paused is True and window._running
|
||||
assert window.toolbar.btn_stop.isEnabled(), "no way out of the paused session"
|
||||
|
||||
window.toolbar.btn_stop.click()
|
||||
assert not window._running, "Stop did not end the session"
|
||||
assert window._session_phase == "idle"
|
||||
finally:
|
||||
stuck.release()
|
||||
|
||||
|
||||
def test_the_shortcut_drives_the_same_state_as_the_transport(make_window):
|
||||
"""One authoritative control, so the two can never disagree."""
|
||||
window, stuck = running_window(make_window)
|
||||
try:
|
||||
window.act_pause.setChecked(True)
|
||||
assert window._display_paused is True
|
||||
assert stuck.is_paused is True
|
||||
assert not window.toolbar.btn_run.isChecked(), (
|
||||
"the transport did not follow the action"
|
||||
)
|
||||
assert window.toolbar.btn_run.text() == t("toolbar.resume_label")
|
||||
|
||||
window.act_pause.setChecked(False)
|
||||
assert window._display_paused is False
|
||||
assert stuck.is_paused is False
|
||||
assert window.toolbar.btn_run.isChecked()
|
||||
assert window.toolbar.btn_run.text() == t("toolbar.pause")
|
||||
finally:
|
||||
finish_run(window, stuck)
|
||||
|
||||
|
||||
def test_the_transport_says_which_way_it_goes(make_window):
|
||||
window, stuck = running_window(make_window)
|
||||
try:
|
||||
acquiring = window.toolbar.btn_run.text()
|
||||
acquiring_tip = window.toolbar.btn_run.toolTip()
|
||||
window.toolbar.btn_run.setChecked(False)
|
||||
paused = window.toolbar.btn_run.text()
|
||||
paused_tip = window.toolbar.btn_run.toolTip()
|
||||
assert acquiring and paused and acquiring != paused, (acquiring, paused)
|
||||
assert acquiring == t("toolbar.pause")
|
||||
assert paused == t("toolbar.resume_label")
|
||||
# The tooltips explain what each state does (the label cannot) — the pause
|
||||
# one including the gap a pause leaves in a recording.
|
||||
assert acquiring_tip == t("toolbar.pause_tip")
|
||||
assert paused_tip == t("toolbar.resume_label_tip")
|
||||
gap_tip = t("toolbar.pause_tip")
|
||||
assert "gap" in gap_tip or "空档" in gap_tip, gap_tip
|
||||
finally:
|
||||
finish_run(window, stuck)
|
||||
|
||||
|
||||
def test_stopping_a_paused_session_clears_the_pause(make_window):
|
||||
"""The next run must compute: a latched pause would look like a dead device."""
|
||||
window, stuck = running_window(make_window)
|
||||
window.toolbar.btn_run.setChecked(False)
|
||||
assert window._display_paused is True
|
||||
|
||||
window._stop_run(reason="user_stop")
|
||||
assert window._display_paused is False
|
||||
assert not window.act_pause.isChecked()
|
||||
assert not window.toolbar.btn_run.isChecked()
|
||||
# The session is over, so the transport is idle again — not paused.
|
||||
assert window.toolbar.btn_run.text() == t("toolbar.run_label")
|
||||
stuck.release()
|
||||
@@ -245,14 +245,22 @@ def test_the_finger_adapter_can_be_built_from_a_domain(scene):
|
||||
assert adapter.map_x is not None and adapter.map_y is not None, (
|
||||
"the domain must carry the unroll map for the adapter to use"
|
||||
)
|
||||
assert domain.source_hw == (288, 512), domain.source_hw
|
||||
# The map states its own source frame, and that frame is the raw 1280x720 frame scaled by
|
||||
# ``source_scale`` (0.42 -> 538x302), *not* the 300x280 domain the adapter publishes. Pinning
|
||||
# the relationship rather than one literal keeps this about the convention: a map whose
|
||||
# coordinates were stated in native pixels would fail here.
|
||||
scale = domain.source_scale
|
||||
assert scale == 0.42, scale
|
||||
assert domain.source_hw == (round(720 * scale), round(1280 * scale)), domain.source_hw
|
||||
assert domain.source_hw != domain.shape_hw
|
||||
|
||||
|
||||
def test_the_shipped_map_cannot_be_applied_to_the_shipped_flow():
|
||||
"""Why the app must not remap the finger's flow: the map is domain→*source*.
|
||||
|
||||
The unroll map is the same size as the UV domain, but its values are pixel
|
||||
coordinates in the 512x288 source image — not in the 300x280 domain. The live
|
||||
coordinates in the scaled source image (538x302 at ``source_scale`` 0.42) — not in the
|
||||
300x280 domain. The live
|
||||
flow is already computed on the unrolled frame, so feeding it through this map
|
||||
would be a double remap: it would index the flow at source-pixel coordinates.
|
||||
This test pins the reason the app passes no map, so a later "improvement" has to
|
||||
@@ -272,15 +280,23 @@ def test_the_shipped_map_cannot_be_applied_to_the_shipped_flow():
|
||||
assert domain.map_x is not None and domain.map_y is not None
|
||||
rows, columns = domain.shape_hw
|
||||
source_rows, source_columns = domain.source_hw
|
||||
assert (rows, columns) == (300, 280) and (source_rows, source_columns) == (288, 512)
|
||||
assert (rows, columns) == (300, 280)
|
||||
scale = domain.source_scale
|
||||
assert (source_rows, source_columns) == (round(720 * scale), round(1280 * scale))
|
||||
|
||||
# Bounded by the source, not by the domain: that is what makes them source pixels.
|
||||
assert 0.0 <= float(domain.map_x[domain.map_x >= 0].max()) <= source_columns - 1
|
||||
assert 0.0 <= float(domain.map_y[domain.map_y >= 0].max()) <= source_rows - 1
|
||||
# The values actually present are what decide the two halves of the mistake, not the declared
|
||||
# size of the source frame: x reaches past the last column of the 280-wide field (a remap would
|
||||
# read out of range and crash), while y never leaves the 300 rows (a remap would read valid but
|
||||
# wrong rows, silently). The old form of this assertion accepted ``source_rows <= rows`` as a
|
||||
# proxy for the y half, which held at ``source_scale`` 0.4 (288 <= 300) but not at 0.42
|
||||
# (302 > 300) — even though the y values never exceed 284 either way.
|
||||
assert float(domain.map_x.max()) > columns - 1, (
|
||||
"x would index past the last column of a 280-wide flow field"
|
||||
)
|
||||
assert float(domain.map_y.max()) > rows - 1 or source_rows <= rows, (
|
||||
assert float(domain.map_y.max()) < rows - 1, (
|
||||
"y stays inside the 300-row field: the y half of the mistake is silent"
|
||||
)
|
||||
|
||||
|
||||
@@ -138,6 +138,11 @@ def test_both_parts_are_drawn_and_the_housing_can_be_hidden(gl_widget, qt_app):
|
||||
widget, renderer, _ = gl_widget
|
||||
assert set(renderer.part_ids) == {"sensing_surface", "housing"}
|
||||
|
||||
# ``DisplaySettings.housing_visible`` ships False (the frame dominated the
|
||||
# picture), so the visible case has to be asked for explicitly rather than
|
||||
# relying on the default — otherwise both renders below are the hidden one and
|
||||
# the comparison cannot mean anything.
|
||||
widget.set_settings(DisplaySettings(housing_visible=True))
|
||||
with_housing = _drawn(_render(widget, qt_app))
|
||||
assert with_housing.size, "nothing was drawn at all"
|
||||
widget.set_settings(DisplaySettings(housing_visible=False))
|
||||
|
||||
@@ -163,8 +163,9 @@ def test_no_scene3d_entry_point_is_still_a_stub(qt_app):
|
||||
settings = panel.settings()
|
||||
assert settings.max_fps == 30
|
||||
assert settings.display_gain == 0.0
|
||||
assert settings.housing_visible is True, (
|
||||
"the panel's resting state and DisplaySettings' default must agree"
|
||||
assert settings.housing_visible is False, (
|
||||
"the panel's resting state and DisplaySettings' default must agree: the housing "
|
||||
"ships hidden, and the checkbox starts unchecked to match"
|
||||
)
|
||||
assert settings.color_scale is not None
|
||||
assert settings.color_scale.mode == "automatic"
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"""The product-type list the rail offers, and what each type is routed to.
|
||||
|
||||
`finger-mems` is studio-side only (the MEMS product line is deprecated and has no read path in
|
||||
the SDK), so it runs on the box/mini acquisition pipeline. That is not an accident to be
|
||||
rediscovered: the worker routing is a binary ``== "finger"`` test, which means a new type has to
|
||||
be *added* to the runnable set but must *not* silently become the finger pipeline.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from orisys_app_shared.sensors import list_configs_for_type, list_sensor_types
|
||||
from orisys_studio import app_window
|
||||
|
||||
APP = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_the_picker_offers_every_product_type():
|
||||
types = list_sensor_types()
|
||||
assert types == ["box", "mini", "finger", "finger-mems"], types
|
||||
assert set(app_window.RUNNABLE_TYPES) >= set(types), (
|
||||
"a type the picker offers but Run refuses is a dead entry"
|
||||
)
|
||||
|
||||
|
||||
def test_finger_mems_has_a_bundle_but_no_calibration():
|
||||
"""Copied from box, minus the coefficients: an uncalibrated device must read raw."""
|
||||
configs = list_configs_for_type("finger-mems")
|
||||
assert len(configs) == 1, configs
|
||||
path = Path(configs[0][1])
|
||||
assert path.is_file(), path
|
||||
assert path.parent.name == "finger-mems"
|
||||
assert not (path.parent / "force_calibration.json").exists(), (
|
||||
"the MEMS bundle ships without calibration on purpose: inventing coefficients would "
|
||||
"make the charts show newtons the device never measured"
|
||||
)
|
||||
|
||||
|
||||
def test_finger_mems_takes_the_box_pipeline():
|
||||
from orisys_studio.session_controller import SessionController
|
||||
|
||||
controller = SessionController.__new__(SessionController)
|
||||
controller._box_worker = object() # type: ignore[attr-defined]
|
||||
controller._finger_worker = object() # type: ignore[attr-defined]
|
||||
assert SessionController.worker_for(controller, "finger-mems") is controller._box_worker
|
||||
assert SessionController.worker_for(controller, "box") is controller._box_worker
|
||||
assert SessionController.worker_for(controller, "finger") is controller._finger_worker
|
||||
|
||||
|
||||
def test_finger_mems_rotates_its_arrow_view_like_box():
|
||||
"""It is a box-style stitched camera, so the 2D rotation applies to it too."""
|
||||
assert "finger-mems" in app_window.ARROW_ROTATE_TYPES
|
||||
assert "finger" not in app_window.ARROW_ROTATE_TYPES
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Review of the rail's unified play control: play/pause, stop, reset — and nothing else.
|
||||
|
||||
Written against the requirement rather than the implementation:
|
||||
|
||||
* the play-control group is exactly **play/pause**, **stop**, **reset** (no extra pause
|
||||
button, no role switch that turns the third button into a stop);
|
||||
* the primary button is a play/pause toggle in **both** views, and pressing it while a
|
||||
session runs pauses without stopping — the device stays connected;
|
||||
* stop ends the session (live) or leaves the replay (playback);
|
||||
* reset is disabled in the replay view, where there is no tracker to zero.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ui_check
|
||||
|
||||
from tests.conftest import finish_run, running_window, show_window
|
||||
|
||||
|
||||
def test_the_group_is_exactly_play_pause_stop_reset(make_window):
|
||||
window = show_window(make_window(sensor_type="box", source_mode="camera"), (1440, 880))
|
||||
toolbar = window.toolbar
|
||||
assert not hasattr(toolbar, "btn_pause"), "an extra pause button is still in the rail"
|
||||
assert hasattr(toolbar, "btn_stop"), "there is no dedicated stop button"
|
||||
assert not hasattr(toolbar, "set_reset_role"), (
|
||||
"the reset button still switches role to stop; one control cannot be two"
|
||||
)
|
||||
assert toolbar.btn_run.isCheckable(), "the play/pause control must be a toggle"
|
||||
window.close()
|
||||
|
||||
|
||||
def test_the_primary_button_pauses_and_does_not_stop(make_window):
|
||||
"""The heart of the unification: one toggle, three states, and stop is not the toggle."""
|
||||
window, stuck = running_window(make_window)
|
||||
try:
|
||||
assert window._session_active, "the fixture did not start a session"
|
||||
assert window.toolbar.btn_run.isChecked(), (
|
||||
"a running session must show the transport as engaged (pause available)"
|
||||
)
|
||||
assert "暂停" in window.toolbar.btn_run.text() or "Paus" in window.toolbar.btn_run.text(), (
|
||||
f"a running session reads {window.toolbar.btn_run.text()!r}, not pause"
|
||||
)
|
||||
|
||||
# Press it: pause, not stop.
|
||||
window.toolbar.btn_run.setChecked(False)
|
||||
assert window._session_active, "pressing the transport ended the session"
|
||||
assert window._display_paused is True, "the window did not record the pause"
|
||||
assert stuck.is_paused is True, "the worker was not paused"
|
||||
assert "继续" in window.toolbar.btn_run.text() or "Resume" in window.toolbar.btn_run.text(), (
|
||||
f"a paused session reads {window.toolbar.btn_run.text()!r}, not resume"
|
||||
)
|
||||
|
||||
# Press it again: resume.
|
||||
window.toolbar.btn_run.setChecked(True)
|
||||
assert window._display_paused is False
|
||||
assert stuck.is_paused is False, "the worker was not resumed"
|
||||
assert window._session_active, "resuming ended the session"
|
||||
finally:
|
||||
finish_run(window, stuck)
|
||||
|
||||
|
||||
def test_stop_ends_the_session(make_window):
|
||||
window, stuck = running_window(make_window)
|
||||
try:
|
||||
# A first frame is what makes the session a *running* one (the rail's reset and
|
||||
# the phase both key off it), so drive one before pressing stop.
|
||||
stuck.emit(ui_check._fake_packet(1))
|
||||
window._on_run_tick()
|
||||
assert window.toolbar.btn_stop.isEnabled(), "stop is not reachable while running"
|
||||
|
||||
window.toolbar.btn_stop.click()
|
||||
assert window._session_phase != "running", (
|
||||
f"the stop button left the session in {window._session_phase!r}"
|
||||
)
|
||||
assert not window.toolbar.btn_run.isChecked(), "the transport stayed engaged after stop"
|
||||
assert window._display_paused is False, "the pause flag survived the stop"
|
||||
finally:
|
||||
stuck.release()
|
||||
|
||||
|
||||
def test_reset_is_for_the_device_and_is_disabled_in_the_replay_view(make_window):
|
||||
"""Reset stays a *device* control, and it is the thing the replay view cannot offer.
|
||||
|
||||
The enabled state of reset once frames are flowing is covered by
|
||||
``test_ui_smoke.py::test_a_reset_reaches_the_device_and_is_recorded`` (which drives
|
||||
``_on_reset``); this test pins what the unification is about: reset and stop are two
|
||||
different controls, and the replay view disables the one that needs a device.
|
||||
"""
|
||||
window, stuck = running_window(make_window)
|
||||
try:
|
||||
assert window.toolbar.btn_reset.text() != window.toolbar.btn_stop.text(), (
|
||||
"reset and stop must be distinguishable controls"
|
||||
)
|
||||
assert window.toolbar.btn_stop.isEnabled(), "stop is not reachable during a session"
|
||||
finally:
|
||||
finish_run(window, stuck)
|
||||
|
||||
replay = show_window(make_window(sensor_type="box", source_mode="camera"), (1440, 880))
|
||||
replay.brand_bar.btn_playback.click()
|
||||
assert not replay.toolbar.btn_reset.isEnabled(), (
|
||||
"reset is offered in the replay view, where there is no tracker to zero"
|
||||
)
|
||||
assert hasattr(replay.toolbar, "btn_stop"), "the replay view has no stop control"
|
||||
assert replay.toolbar.btn_run.text().startswith("▶"), (
|
||||
f"the replay transport idle reads {replay.toolbar.btn_run.text()!r}, not play"
|
||||
)
|
||||
replay.close()
|
||||
@@ -225,10 +225,10 @@ def test_a_hung_connect_can_be_cancelled_immediately(make_window, cancel_via):
|
||||
assert window._session_phase == "connecting"
|
||||
try:
|
||||
if cancel_via == "toolbar":
|
||||
assert window.toolbar.btn_run.isEnabled(), "Stop is disabled while connecting"
|
||||
assert window.toolbar.btn_run.text() == t("toolbar.cancel_label")
|
||||
assert window.toolbar.btn_stop.isEnabled(), "Stop is disabled while connecting"
|
||||
assert window.toolbar.btn_stop.text() == t("toolbar.cancel_label")
|
||||
assert window.act_stop.isEnabled()
|
||||
cancel = lambda: window.toolbar.btn_run.setChecked(False)
|
||||
cancel = window.toolbar.btn_stop.click
|
||||
else:
|
||||
assert window.act_stop.shortcut().toString() == "Esc"
|
||||
cancel = window._on_stop_shortcut
|
||||
@@ -1062,7 +1062,12 @@ def test_idle_run_is_the_only_filled_action(make_window):
|
||||
|
||||
|
||||
def test_connecting_cancel_stays_filled(make_window):
|
||||
"""Cancel is the way out of a hung device open, so it keeps the weight."""
|
||||
"""Cancel is the way out of a hung device open, so it keeps the weight.
|
||||
|
||||
It lives on the rail's Stop button now (the transport is a play/pause toggle in
|
||||
both views, so it can no longer be the stop), which is why the filled danger is
|
||||
checked there.
|
||||
"""
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
window = _show(make_window(), (1440, 880))
|
||||
@@ -1073,8 +1078,8 @@ def test_connecting_cancel_stays_filled(make_window):
|
||||
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)
|
||||
assert window.toolbar.btn_stop.objectName() == "dangerButton"
|
||||
window.toolbar.btn_stop.click()
|
||||
stuck.release()
|
||||
|
||||
|
||||
@@ -1088,26 +1093,86 @@ def test_exactly_one_filled_action_while_acquiring(make_window):
|
||||
window.toolbar.btn_run.setChecked(True)
|
||||
window._run_timer.stop()
|
||||
assert window._session_phase == "connecting"
|
||||
assert window.toolbar.btn_run.objectName() == "dangerButton", (
|
||||
assert window.toolbar.btn_stop.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", (
|
||||
assert window.toolbar.btn_stop.objectName() == "dangerOutline", (
|
||||
"Stop is still the filled action while a session merely acquires"
|
||||
)
|
||||
assert window.toolbar.btn_record.objectName() == "primaryButton"
|
||||
assert window.toolbar.btn_run.objectName() == "ghostButton", (
|
||||
"the transport competes with the filled record action"
|
||||
)
|
||||
|
||||
window.toolbar.set_record_checked(True)
|
||||
assert window.toolbar.btn_record.objectName() == "dangerButton"
|
||||
assert window.toolbar.btn_run.objectName() == "dangerOutline", (
|
||||
assert window.toolbar.btn_stop.objectName() == "dangerOutline", (
|
||||
"two filled actions share the rail while recording"
|
||||
)
|
||||
|
||||
window.toolbar.set_record_checked(False)
|
||||
window.toolbar.btn_stop.click()
|
||||
stuck.release()
|
||||
|
||||
|
||||
def test_the_live_rail_is_a_play_pause_toggle_with_its_own_stop(make_window):
|
||||
"""Slot 1 means the same thing in both views: play/pause, and Stop beside it.
|
||||
|
||||
The live view used to fill that slot with a stop toggle and keep a second,
|
||||
icon-only pause button next to it, so the same position meant two different
|
||||
things depending on which view was on screen.
|
||||
"""
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
window = _show(make_window(), (1440, 880))
|
||||
stuck = ui_check._StuckWorker()
|
||||
window._ensure_workers = lambda: None
|
||||
window._box_worker = stuck
|
||||
window._finger_worker = stuck
|
||||
|
||||
# Idle: the transport starts the session, and Stop has nothing to end.
|
||||
assert window.toolbar.btn_run.text() == t("toolbar.run_label")
|
||||
assert not window.toolbar.btn_stop.isEnabled()
|
||||
|
||||
# Connecting: still a pause toggle, and Stop is the way out of a hung open.
|
||||
window.toolbar.btn_run.setChecked(True)
|
||||
window._run_timer.stop()
|
||||
QApplication.processEvents()
|
||||
assert window._session_phase == "connecting"
|
||||
assert window.toolbar.btn_run.text() == t("toolbar.pause")
|
||||
assert window.toolbar.btn_stop.text() == t("toolbar.cancel_label")
|
||||
assert window.toolbar.btn_stop.isEnabled()
|
||||
|
||||
stuck.emit(ui_check._fake_packet(1))
|
||||
window._on_run_tick()
|
||||
QApplication.processEvents()
|
||||
assert window._session_phase == "running"
|
||||
assert window.toolbar.btn_run.text() == t("toolbar.pause")
|
||||
|
||||
# Unchecked *with* a session is paused, not stopped.
|
||||
window.toolbar.btn_run.setChecked(False)
|
||||
QApplication.processEvents()
|
||||
assert window._session_phase == "running", "the transport stopped instead of pausing"
|
||||
assert window.toolbar.btn_run.text() == t("toolbar.resume_label")
|
||||
assert stuck.is_paused is True, "the worker was not asked to pause"
|
||||
assert window.toolbar.btn_stop.isEnabled(), "the way out vanished while paused"
|
||||
|
||||
window.toolbar.btn_run.setChecked(True)
|
||||
QApplication.processEvents()
|
||||
assert stuck.is_paused is False, "the worker was not asked to resume"
|
||||
|
||||
# Stop ends it; the transport goes back to idle.
|
||||
window.toolbar.btn_stop.click()
|
||||
QApplication.processEvents()
|
||||
assert window._session_phase == "idle"
|
||||
assert not window.toolbar.btn_run.isChecked()
|
||||
assert window.toolbar.btn_run.text() == t("toolbar.run_label")
|
||||
stuck.release()
|
||||
|
||||
|
||||
@@ -1590,9 +1655,16 @@ def test_a_late_calibration_does_not_plot_raw_counts_on_the_newton_axis(make_win
|
||||
assert peak < 5.0, f"a raw count is still drawn on the newton axis (peak {peak})"
|
||||
|
||||
# The sample that caused the switch still reaches the readouts; the early
|
||||
# return used to leave the cards showing the previous sample.
|
||||
# return used to leave the cards showing the previous sample. The number on
|
||||
# the card is the *display* copy of it: this fake FN is a slow 2.0…2.4 N ramp
|
||||
# with no contact flagged, which is exactly the resting drift the display
|
||||
# baseline holds at zero. What matters here is that the card is not a raw
|
||||
# count, not the previous sample and not a dash.
|
||||
assert abs(plots._last_values[0] - (2.0 + 39 * 0.01)) < 1e-9
|
||||
assert plots.value_labels[0].text() == "2.390", plots.value_labels[0].text()
|
||||
assert plots.presentation.display_quality != "none", (
|
||||
"the display policy was not applied to the freshly shown newton unit"
|
||||
)
|
||||
assert plots.value_labels[0].text() == "0.000", plots.value_labels[0].text()
|
||||
assert plots.value_labels[0].text() != t("chart.value_unavailable")
|
||||
_finish(window, stuck)
|
||||
|
||||
@@ -1926,8 +1998,10 @@ def test_the_replay_view_offers_a_stop_that_always_works(make_window, replay_rec
|
||||
"""The operator must not have to work out *what* is stuck before stopping it.
|
||||
|
||||
The transport is a play/pause toggle, which is ambiguous exactly when something is
|
||||
wrong. So the rail's second button becomes an unconditional stop in this view, and
|
||||
it covers both things that can run there: plain playback and a recompute pass.
|
||||
wrong. So the rail's Stop button — the same one the live view uses to end a session
|
||||
— is an unconditional stop in this view, and it covers both things that can run
|
||||
there: plain playback and a recompute pass. The tracker reset owns no device here,
|
||||
so it is disabled rather than repurposed.
|
||||
"""
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
@@ -1939,11 +2013,12 @@ def test_the_replay_view_offers_a_stop_that_always_works(make_window, replay_rec
|
||||
assert window._load_recording(replay_recording)
|
||||
QApplication.processEvents()
|
||||
|
||||
assert window.toolbar.reset_role() == "stop", (
|
||||
"the replay view kept the tracker reset button instead of a stop"
|
||||
)
|
||||
assert window.toolbar.btn_reset.text() == t("toolbar.stop_playback")
|
||||
assert not window.toolbar.btn_reset.isEnabled(), (
|
||||
"the replay view offers to zero a device tracker it has not got"
|
||||
)
|
||||
assert window.toolbar.btn_reset.text() == t("toolbar.reset")
|
||||
assert window.toolbar.btn_stop.text() == t("toolbar.stop_label")
|
||||
assert not window.toolbar.btn_stop.isEnabled(), (
|
||||
"a stop is offered while nothing is running"
|
||||
)
|
||||
|
||||
@@ -1951,8 +2026,8 @@ def test_the_replay_view_offers_a_stop_that_always_works(make_window, replay_rec
|
||||
window.toolbar.btn_run.setChecked(True)
|
||||
QApplication.processEvents()
|
||||
assert window._play_timer.isActive()
|
||||
assert window.toolbar.btn_reset.isEnabled(), "no stop offered while playing"
|
||||
window.toolbar.btn_reset.click()
|
||||
assert window.toolbar.btn_stop.isEnabled(), "no stop offered while playing"
|
||||
window.toolbar.btn_stop.click()
|
||||
QApplication.processEvents()
|
||||
assert not window._play_timer.isActive(), "the stop did not stop the playback"
|
||||
assert not window.toolbar.btn_run.isChecked(), "the transport still shows playing"
|
||||
@@ -1965,8 +2040,8 @@ def test_the_replay_view_offers_a_stop_that_always_works(make_window, replay_rec
|
||||
window._stop_run = lambda **kwargs: stopped.append(kwargs.get("reason"))
|
||||
window._recompute_running = True
|
||||
window._update_run_enabled()
|
||||
assert window.toolbar.btn_reset.isEnabled(), "no stop offered while a pass runs"
|
||||
window.toolbar.btn_reset.click()
|
||||
assert window.toolbar.btn_stop.isEnabled(), "no stop offered while a pass runs"
|
||||
window.toolbar.btn_stop.click()
|
||||
QApplication.processEvents()
|
||||
assert stopped == ["user_stop"], stopped
|
||||
assert t("toolbar.stopped_pass") in window.statusBar().currentMessage(), (
|
||||
@@ -1976,7 +2051,7 @@ def test_the_replay_view_offers_a_stop_that_always_works(make_window, replay_rec
|
||||
# Nothing running: it says so instead of doing nothing at all.
|
||||
# (The pass is over, so the stop button is correctly disabled — the escape hatch is
|
||||
# therefore exercised the way Esc reaches it, through the controller.)
|
||||
assert not window.toolbar.btn_reset.isEnabled(), (
|
||||
assert not window.toolbar.btn_stop.isEnabled(), (
|
||||
"a stop is still offered after the pass was stopped"
|
||||
)
|
||||
window.replay_ctl.stop_activity()
|
||||
@@ -2009,10 +2084,14 @@ def test_escape_stops_the_replay_too(make_window, replay_recording):
|
||||
def test_the_live_view_keeps_the_tracker_reset(make_window):
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
window = _show(make_window(), (1440, 880))
|
||||
window.brand_bar.btn_live.click()
|
||||
QApplication.processEvents()
|
||||
assert window.toolbar.reset_role() == "reset"
|
||||
assert window.toolbar.btn_reset.text() == t("toolbar.reset"), (
|
||||
"the rail's reset slot is not the tracker reset in the live view"
|
||||
)
|
||||
window.close()
|
||||
|
||||
|
||||
@@ -2549,6 +2628,84 @@ def test_the_dialog_writes_the_keys_the_session_reads_back(qt_app):
|
||||
assert read_back.describe() == "flowEMA(0.5) + median(3) + lowpass(10Hz,order2)"
|
||||
|
||||
|
||||
def test_the_plot_dialog_carries_the_ranges_and_the_zero_threshold(qt_app):
|
||||
"""FN has a lower bound (which may be negative); both units a deadband."""
|
||||
from orisys_studio.ui.plot_settings_dialog import PlotSettingsDialog
|
||||
|
||||
dialog = PlotSettingsDialog(
|
||||
units="newton", fn_min=-2.0, fn_max=6.0, deadband_n=0.25, deadband_raw=2000.0
|
||||
)
|
||||
assert dialog.settings()["fn_min"] == -2.0
|
||||
assert dialog.settings()["fn_max"] == 6.0
|
||||
assert dialog.deadbands() == {"deadband_n": 0.25, "deadband_raw": 2000.0}
|
||||
# Only the half that belongs to the unit being shown can be edited.
|
||||
assert dialog.fn_min_spin.isEnabled() and not dialog.fn_raw_min_spin.isEnabled()
|
||||
assert dialog.deadband_n_spin.isEnabled()
|
||||
assert not dialog.deadband_raw_spin.isEnabled()
|
||||
|
||||
raw = PlotSettingsDialog(units="raw")
|
||||
assert raw.fn_raw_min_spin.isEnabled() and not raw.fn_min_spin.isEnabled()
|
||||
assert raw.deadband_raw_spin.isEnabled() and not raw.deadband_n_spin.isEnabled()
|
||||
raw.fn_raw_min_spin.setValue(-50000.0)
|
||||
raw.deadband_raw_spin.setValue(5000.0)
|
||||
assert raw.settings()["fn_raw_min"] == -50000.0
|
||||
|
||||
raw._reset_defaults()
|
||||
assert raw.settings()["fn_raw_min"] == 0.0
|
||||
assert raw.settings()["fn_min"] == 0.0
|
||||
assert raw.settings()["window_sec"] == 4.0
|
||||
assert raw.deadbands() == {"deadband_n": 0.0, "deadband_raw": 0.0}, (
|
||||
"the shipped deadband answer is 'auto'"
|
||||
)
|
||||
|
||||
|
||||
def test_the_plot_dialog_values_reach_the_chart(make_window):
|
||||
"""Dialog -> controller -> panel: where an operator's range actually lands."""
|
||||
from orisys_studio.ui.plot_settings_dialog import PlotSettingsDialog
|
||||
|
||||
window = make_window()
|
||||
dialog = PlotSettingsDialog(
|
||||
units="newton",
|
||||
window_sec=6.0,
|
||||
fn_min=-2.0,
|
||||
fn_max=6.0,
|
||||
shear_max=3.0,
|
||||
fn_raw_min=-1000.0,
|
||||
fn_raw_max=5e5,
|
||||
shear_raw_max=2e5,
|
||||
deadband_n=0.25,
|
||||
deadband_raw=3000.0,
|
||||
)
|
||||
window.display_ctl.apply_plot_settings(dialog.settings())
|
||||
|
||||
plots = window.force_plots
|
||||
assert (plots._fn_min, plots._fn_max, plots._shear_max) == (-2.0, 6.0, 3.0)
|
||||
assert (plots._fn_raw_min, plots._fn_raw_max, plots._shear_raw_max) == (
|
||||
-1000.0, 5e5, 2e5,
|
||||
)
|
||||
assert plots.presentation.min_deadband_for("N") == 0.25
|
||||
assert plots.presentation.min_deadband_for("raw") == 3000.0
|
||||
assert window.prefs["plot_fn_min"] == -2.0
|
||||
assert window.prefs["plot_deadband_raw"] == 3000.0
|
||||
assert plots.history.window_sec == 6.0
|
||||
# And the dialog would open on the same numbers next time.
|
||||
assert window.display_ctl.plot_settings()["fn_min"] == -2.0
|
||||
window.close()
|
||||
|
||||
|
||||
def test_the_display_baseline_is_on_for_every_sensor_by_default(make_window):
|
||||
"""It used to be Finger-raw only; an idle box sensor drifts just as much."""
|
||||
window, stuck = _running_window(make_window) # sensor_type="box"
|
||||
|
||||
plots = window.force_plots
|
||||
assert plots._presentation_enabled is True, "a box sensor lost its baseline"
|
||||
assert plots.presentation.enabled is True
|
||||
# The per-unit floors came from the preferences, not from a sensor type.
|
||||
assert plots.presentation.min_deadband_for("raw") == 50.0
|
||||
assert plots.presentation.min_deadband_for("N") == 0.0
|
||||
_finish(window, stuck)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -103,7 +103,6 @@ class _Toolbar:
|
||||
self._expanded = expanded
|
||||
self.source_views: list[str] = []
|
||||
self.run_roles: list[str] = []
|
||||
self.reset_roles: list[str] = []
|
||||
|
||||
def set_source_view(self, view: str) -> None:
|
||||
self.source_views.append(view)
|
||||
@@ -111,9 +110,6 @@ class _Toolbar:
|
||||
def set_run_role(self, role: str) -> None:
|
||||
self.run_roles.append(role)
|
||||
|
||||
def set_reset_role(self, role: str) -> None:
|
||||
self.reset_roles.append(role)
|
||||
|
||||
def settings_expanded(self) -> bool:
|
||||
return self._expanded
|
||||
|
||||
@@ -237,14 +233,12 @@ def test_a_preset_decides_which_panels_a_view_shows():
|
||||
assert parts["charts"].visible is True
|
||||
assert parts["host"].prefs["view_preset"] == "calibration"
|
||||
assert parts["toolbar"].source_views[-1] == "live"
|
||||
assert parts["toolbar"].reset_roles[-1] == "reset"
|
||||
assert parts["host"].persisted == 1
|
||||
|
||||
controller.apply_preset("replay")
|
||||
assert parts["image"].visible is True
|
||||
assert parts["toolbar"].source_views[-1] == "replay"
|
||||
assert parts["toolbar"].run_roles[-1] == "playback"
|
||||
assert parts["toolbar"].reset_roles[-1] == "stop"
|
||||
# The charts follow the recording in this view, and the replay's own entries are
|
||||
# re-synced with it. (Calibration did neither of those: it has no replay source.)
|
||||
assert parts["replay"].sources == [True]
|
||||
|
||||
Reference in New Issue
Block a user