fixed commetn from songxiao

This commit is contained in:
ykcheungab
2026-09-25 17:46:37 +08:00
parent 6effe74172
commit 12781854ca
56 changed files with 4935 additions and 419 deletions
+79 -4
View File
@@ -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"),
]
+139
View File
@@ -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()
+20 -4
View File
@@ -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))
+3 -2
View File
@@ -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"
+56
View File
@@ -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
+107
View File
@@ -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()
+180 -23
View File
@@ -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)
-6
View File
@@ -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]