630 lines
22 KiB
Python
630 lines
22 KiB
Python
"""The finger debugger's panel: picking, refusing, and what a row contains.
|
||
|
||
The bundle is the real export on this machine. It is skipped rather than failed
|
||
when absent, because the export lives in a sibling design repo: a machine without
|
||
it has not broken AutoCal.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from pathlib import Path
|
||
|
||
import numpy as np
|
||
import pytest
|
||
from PySide6.QtCore import QPointF
|
||
|
||
from orisys_autocal.motion.kinematics import PlatformStack
|
||
from orisys_autocal.sensor import (
|
||
DEFAULT_FLAT_SIZE,
|
||
load_bundle,
|
||
rasterise,
|
||
to_platform,
|
||
to_sensor,
|
||
)
|
||
from orisys_autocal.ui.sensor_panel import FingerPanel
|
||
|
||
BUNDLE = Path(
|
||
r"D:\orisys_ws\orisys-sensor-design\build\export\finger\r01\compute"
|
||
)
|
||
|
||
pytestmark = pytest.mark.skipif(
|
||
not BUNDLE.is_dir(), reason=f"no sensor export at {BUNDLE}"
|
||
)
|
||
|
||
|
||
@pytest.fixture(scope="module")
|
||
def bundle():
|
||
return load_bundle(BUNDLE)
|
||
|
||
|
||
@pytest.fixture(scope="module")
|
||
def flat(bundle):
|
||
return rasterise(bundle, size_hw=DEFAULT_FLAT_SIZE)
|
||
|
||
|
||
@pytest.fixture
|
||
def panel(qt_app, bundle, flat):
|
||
widget = FingerPanel(stack=PlatformStack())
|
||
widget.set_bundle(bundle, flat)
|
||
yield widget
|
||
widget.deleteLater()
|
||
|
||
|
||
def _first_surface_pixel(flat) -> tuple[int, int]:
|
||
"""A pixel that carries surface, found rather than assumed."""
|
||
for y in range(0, flat.normals.shape[0], 7):
|
||
for x in range(0, flat.normals.shape[1], 7):
|
||
if flat.sample(x, y) is not None:
|
||
return x, y
|
||
raise AssertionError("the flat raster has no surface pixels at all")
|
||
|
||
|
||
def _shrink_raster(monkeypatch, window_mod, size=(256, 256)):
|
||
"""Force a small flat raster for a test that is not about the raster.
|
||
|
||
``DEFAULT_FLAT_SIZE`` is bound as a default argument, so patching the module
|
||
attribute would not reach it; wrapping the function does.
|
||
"""
|
||
real = window_mod.rasterise
|
||
|
||
def small(bundle, flat_size=None, **kwargs):
|
||
return real(bundle, size, **kwargs)
|
||
|
||
monkeypatch.setattr(window_mod, "rasterise", small)
|
||
|
||
|
||
def test_the_window_loads_the_remembered_bundle_at_startup(qt_app, monkeypatch):
|
||
"""A filled-in path over two empty views reads as a broken screen."""
|
||
import shutil
|
||
|
||
from orisys_app_shared import paths as paths_mod
|
||
from orisys_app_shared import prefs as prefs_pkg
|
||
from orisys_app_shared.i18n import set_locale
|
||
|
||
from orisys_autocal import preferences as prefs_mod
|
||
from orisys_autocal.ui import window as window_mod
|
||
from orisys_autocal.ui.window import AutoCalMainWindow
|
||
|
||
scratch = Path(__file__).resolve().parents[1] / "outputs" / "_test_scratch" / "autoload"
|
||
scratch.mkdir(parents=True, exist_ok=True)
|
||
prefs_file = scratch / "preference.json"
|
||
monkeypatch.setattr(paths_mod, "preference_path", lambda: prefs_file)
|
||
monkeypatch.setattr(prefs_pkg.store, "preference_path", lambda: prefs_file)
|
||
monkeypatch.setattr(prefs_mod, "preference_path", lambda: prefs_file)
|
||
_shrink_raster(monkeypatch, window_mod)
|
||
|
||
prefs = prefs_mod.default_preferences()
|
||
prefs["finger_bundle_path"] = str(BUNDLE)
|
||
set_locale(str(prefs.get("language") or "zh_CN"))
|
||
|
||
window = AutoCalMainWindow(prefs=prefs)
|
||
try:
|
||
assert window.finger_panel.view.flat is not None, "the startup load did not happen"
|
||
pixmap = window.finger_panel.view.pixmap()
|
||
assert pixmap is not None and not pixmap.isNull()
|
||
finally:
|
||
window.poller.shutdown()
|
||
window.close()
|
||
shutil.rmtree(scratch, ignore_errors=True)
|
||
|
||
|
||
def test_a_missing_remembered_bundle_is_survivable(qt_app, monkeypatch, tmp_dir):
|
||
"""The normal case on a machine that has never exported one."""
|
||
from orisys_autocal.ui import window as window_mod
|
||
|
||
_shrink_raster(monkeypatch, window_mod)
|
||
window = window_mod.AutoCalMainWindow(
|
||
prefs={
|
||
"language": "en_US",
|
||
"theme": "light",
|
||
"finger_bundle_path": str(tmp_dir / "not-exported"),
|
||
}
|
||
)
|
||
try:
|
||
assert window.finger_panel.view.flat is None
|
||
# And the panel still says what to do rather than looking dead.
|
||
assert window.finger_panel.status_label.text()
|
||
finally:
|
||
window.poller.shutdown()
|
||
window.close()
|
||
|
||
|
||
def test_the_preview_legend_is_a_label_not_gl_text(qt_app):
|
||
"""Painting a long legend over the GL surface is what made it unreadable."""
|
||
widget = FingerPanel(stack=PlatformStack())
|
||
try:
|
||
assert widget.legend_label.text()
|
||
assert widget.legend_label.wordWrap()
|
||
finally:
|
||
widget.deleteLater()
|
||
|
||
|
||
# ------------------------------------------------- the initial view and axes
|
||
|
||
|
||
def test_the_initial_view_is_the_commissioned_orientation(qt_app, bundle):
|
||
"""Sensor +Y up the screen and sensor +Z toward the viewer, by construction.
|
||
|
||
The user asked for this state to be the configuration the screen opens in, so
|
||
it is asserted in screen terms rather than against the raw angles: what matters
|
||
is where the axes land in the picture. Widget y grows *downward*, so "up the
|
||
screen" is a **negative** pixel offset — the sign that this test exists to get
|
||
right, because getting it wrong draws the tool axis pointing down.
|
||
"""
|
||
from orisys_autocal.sensor import to_sensor
|
||
|
||
from orisys_autocal.ui.pose_preview import PosePreview
|
||
|
||
preview = PosePreview()
|
||
try:
|
||
preview.resize(600, 400)
|
||
preview.set_bundle(bundle)
|
||
preview.initial_view()
|
||
|
||
stack = PlatformStack()
|
||
origin = preview.project(bundle.centre_mm)
|
||
assert origin is not None
|
||
|
||
# Platform +Z is sensor +Y on this mount: it must read as up the screen.
|
||
platform_z = to_sensor(np.array([0.0, 0.0, 1.0]), stack.axis_map)
|
||
tip = preview.project(bundle.centre_mm + platform_z * 11.8)
|
||
assert tip is not None
|
||
assert tip[1] < origin[1] - 5.0, (
|
||
f"sensor +Y must point up the screen, but moved {tip[1] - origin[1]:+.1f} px"
|
||
)
|
||
|
||
# Sensor +Z is the sensing face's own axis and must face the viewer. It is
|
||
# the platform's -Y on this mount. ``forward`` points from the eye into the
|
||
# scene, so a surface normal aimed *at* the camera has a positive dot with
|
||
# it — the opposite of what the depth term in a projection does, and the
|
||
# sign this assertion got wrong first time.
|
||
eye, _distance = preview._camera() # noqa: SLF001
|
||
_centre, forward, _right, _up = preview._view_basis(eye) # noqa: SLF001
|
||
lens_axis = to_sensor(np.array([0.0, -1.0, 0.0]), stack.axis_map)
|
||
lens_axis = lens_axis / np.linalg.norm(lens_axis)
|
||
assert float(lens_axis @ forward) > 0.2, (
|
||
f"sensor +Z must face the viewer, but points away: "
|
||
f"{float(lens_axis @ forward):+.3f}"
|
||
)
|
||
finally:
|
||
preview.deleteLater()
|
||
|
||
|
||
def test_orbiting_and_then_resetting_returns_to_the_initial_view(qt_app, bundle):
|
||
from orisys_autocal.ui.pose_preview import (
|
||
INITIAL_PITCH_RAD,
|
||
INITIAL_YAW_RAD,
|
||
PosePreview,
|
||
)
|
||
|
||
preview = PosePreview()
|
||
try:
|
||
preview.set_bundle(bundle)
|
||
preview._yaw = 1.9 # noqa: SLF001 - pretend the operator dragged
|
||
preview._pitch = -0.4 # noqa: SLF001
|
||
preview._distance_scale = 3.0 # noqa: SLF001
|
||
|
||
preview.reset_view()
|
||
|
||
assert preview._yaw == pytest.approx(INITIAL_YAW_RAD) # noqa: SLF001
|
||
assert preview._pitch == pytest.approx(INITIAL_PITCH_RAD) # noqa: SLF001
|
||
assert preview._distance_scale == pytest.approx(1.0) # noqa: SLF001
|
||
finally:
|
||
preview.deleteLater()
|
||
|
||
|
||
def test_the_motion_axes_can_be_turned_off(qt_app, bundle):
|
||
from orisys_autocal.ui.pose_preview import PosePreview
|
||
|
||
preview = PosePreview()
|
||
try:
|
||
assert preview.show_axes
|
||
preview.set_show_axes(False)
|
||
assert not preview.show_axes
|
||
preview.set_show_axes(True)
|
||
assert preview.show_axes
|
||
finally:
|
||
preview.deleteLater()
|
||
|
||
|
||
def test_the_axis_labels_project_onto_their_arrows(qt_app, bundle):
|
||
"""The 2D label and the 3D arrow must agree, or the label names nothing."""
|
||
from orisys_autocal.sensor import to_sensor
|
||
|
||
from orisys_autocal.ui.pose_preview import PosePreview
|
||
|
||
preview = PosePreview()
|
||
try:
|
||
preview.resize(600, 400)
|
||
preview.set_bundle(bundle)
|
||
stack = PlatformStack()
|
||
origin = bundle.centre_mm
|
||
extent = float(np.linalg.norm(bundle.extent_mm)) or 1.0
|
||
|
||
positions = []
|
||
for axis in ("x", "y", "z"):
|
||
direction = np.zeros(3)
|
||
direction[("x", "y", "z").index(axis)] = 1.0
|
||
tip = origin + to_sensor(direction, stack.axis_map) * extent * 0.45
|
||
screen = preview.project(tip)
|
||
assert screen is not None, f"the {axis} axis tip is behind the camera"
|
||
assert np.isfinite(screen).all()
|
||
positions.append(screen)
|
||
|
||
# Three axes pointing different ways must not project onto one another.
|
||
rounded = {(round(x, 3), round(y, 3)) for x, y in positions}
|
||
assert len(rounded) == 3
|
||
|
||
# The arrow tips can fall outside a small widget at the default zoom, so
|
||
# the overlay clamps the label back inside; that is what keeps it visible.
|
||
clamped = [
|
||
(
|
||
min(max(x, 12.0), preview.width() - 12.0),
|
||
min(max(y, 14.0), preview.height() - 6.0),
|
||
)
|
||
for x, y in positions
|
||
]
|
||
for x, y in clamped:
|
||
assert 0 <= x <= preview.width()
|
||
assert 0 <= y <= preview.height()
|
||
finally:
|
||
preview.deleteLater()
|
||
|
||
|
||
def test_a_point_behind_the_camera_has_no_screen_position(qt_app, bundle):
|
||
"""Drawing a label for it would put a stray letter on the widget."""
|
||
from orisys_autocal.ui.pose_preview import PosePreview
|
||
|
||
preview = PosePreview()
|
||
try:
|
||
preview.set_bundle(bundle)
|
||
assert preview.project(bundle.centre_mm) is not None
|
||
finally:
|
||
preview.deleteLater()
|
||
|
||
|
||
def test_the_flat_image_is_shown_whole_not_cropped(panel):
|
||
"""At native size the 1024 image was cropped to a corner of the UV space."""
|
||
view = panel.view
|
||
view.resize(240, 300)
|
||
view.set_flat(view.flat)
|
||
|
||
shown = view.pixmap()
|
||
assert shown is not None and not shown.isNull()
|
||
assert shown.width() <= view.width()
|
||
assert shown.height() <= view.height()
|
||
# Square image, square display: the aspect ratio is preserved, not squashed.
|
||
assert shown.width() == pytest.approx(shown.height(), abs=2)
|
||
|
||
|
||
def test_a_click_on_the_scaled_image_maps_to_the_pixel_under_it(panel):
|
||
"""Scaling must not move the mapping; a click in the corner is the corner."""
|
||
view = panel.view
|
||
view.resize(240, 300)
|
||
view.set_flat(view.flat)
|
||
shown = view.pixmap()
|
||
offset_x = (view.width() - shown.width()) / 2.0
|
||
offset_y = (view.height() - shown.height()) / 2.0
|
||
|
||
centre = view.pixel_at(QPointF(offset_x + shown.width() / 2.0, offset_y + shown.height() / 2.0))
|
||
assert centre is not None
|
||
assert centre[0] == pytest.approx(view.flat.width // 2, abs=3)
|
||
assert centre[1] == pytest.approx(view.flat.height // 2, abs=3)
|
||
|
||
corner = view.pixel_at(
|
||
QPointF(offset_x + shown.width() - 1, offset_y + shown.height() - 1)
|
||
)
|
||
assert corner is not None
|
||
# One display pixel covers several image pixels at this scale (240 vs 1024),
|
||
# so the last clickable pixel is within a scale-step of the true corner.
|
||
step = view.flat.width / shown.width() + 1
|
||
assert corner[0] > view.flat.width - step
|
||
assert corner[1] > view.flat.height - step
|
||
|
||
assert view.pixel_at(QPointF(offset_x - 5.0, offset_y + shown.height() / 2)) is None
|
||
|
||
|
||
# ------------------------------------------------------- the motion simulation
|
||
|
||
|
||
def test_the_play_button_needs_a_picked_point(panel):
|
||
"""A button that silently does nothing is worse than a disabled one."""
|
||
assert not panel.preview.can_simulate
|
||
assert not panel.sim_button.isEnabled()
|
||
|
||
flat = panel.view.flat
|
||
row = panel.handle_pick(*_first_surface_pixel(flat))
|
||
|
||
assert row is not None
|
||
assert panel.preview.can_simulate
|
||
assert panel.sim_button.isEnabled()
|
||
|
||
|
||
def test_clearing_the_points_disables_the_simulation_again(panel):
|
||
panel.handle_pick(*_first_surface_pixel(panel.view.flat))
|
||
panel.clear_points()
|
||
assert not panel.preview.can_simulate
|
||
assert not panel.sim_button.isEnabled()
|
||
|
||
|
||
def _run_simulation(preview) -> None:
|
||
"""Drive the timer by hand, so the test does not sleep."""
|
||
from orisys_autocal.ui.pose_preview import SIM_DURATION_MS, SIM_FRAME_MS
|
||
|
||
preview.stop_simulation()
|
||
preview.start_simulation()
|
||
for _ in range(int(SIM_DURATION_MS / SIM_FRAME_MS) + 2):
|
||
preview._sim_tick() # noqa: SLF001 - stepping the animation deterministically
|
||
|
||
|
||
def test_playing_the_motion_puts_the_normal_on_the_tool_axis(qt_app, bundle):
|
||
"""The two invariants the whole animation exists to show.
|
||
|
||
After playback the picked normal must lie along the tool axis, and the contact
|
||
must sit at the platform coordinates the readouts report. Asserting the second
|
||
against ``target_xy_mm`` rather than the origin is the point: the tables move to
|
||
the solved X/Y, and every earlier attempt at this transform got a frame wrong,
|
||
so it is checked against the solver's own output instead of a bare (0, 0, 0).
|
||
"""
|
||
from orisys_autocal.ui.pose_preview import PosePreview
|
||
|
||
stack = PlatformStack()
|
||
tool_local = to_sensor(np.asarray(stack.tool_direction, dtype=float), stack.axis_map)
|
||
tool_local = tool_local / np.linalg.norm(tool_local)
|
||
|
||
preview = PosePreview()
|
||
try:
|
||
preview.set_bundle(bundle)
|
||
normals = bundle.vertex_normals()
|
||
worst_angle = 0.0
|
||
for index in (0, 1, 7, 40, 200):
|
||
normal = normals[index]
|
||
point = bundle.vertices_mm[index]
|
||
pose = stack.solve(point, normal)
|
||
|
||
preview.set_point(point, normal)
|
||
preview.set_pose(pose)
|
||
_run_simulation(preview)
|
||
rotation, translation = preview._sim_transform() # noqa: SLF001
|
||
|
||
aligned = rotation @ (normal / np.linalg.norm(normal))
|
||
aligned = aligned / np.linalg.norm(aligned)
|
||
worst_angle = max(
|
||
worst_angle,
|
||
float(np.degrees(np.arccos(np.clip(float(aligned @ tool_local), -1.0, 1.0)))),
|
||
)
|
||
|
||
# The contact ends at the tool, which is drawn at the patch's centre.
|
||
# Asserting the *absolute* platform X/Y here would be asserting the old
|
||
# behaviour: moving the model to its machine coordinates instead of to
|
||
# the tool put it off screen, because the camera looks at the tool.
|
||
landed = rotation @ point + translation
|
||
assert np.allclose(landed, bundle.centre_mm, atol=1e-6)
|
||
# And the marker does not take that translation: it is the measurement,
|
||
# so the body slides out from under it.
|
||
assert np.allclose(
|
||
preview._placed_contact(point), rotation @ point # noqa: SLF001
|
||
)
|
||
|
||
assert worst_angle < 0.5, (
|
||
f"after playing, a normal is still {worst_angle:.3f} deg off the tool axis"
|
||
)
|
||
finally:
|
||
preview.stop_simulation()
|
||
preview.deleteLater()
|
||
|
||
|
||
def test_the_simulation_rotates_first_and_then_translates(qt_app, bundle):
|
||
"""The bench contract: stages settle before the table moves."""
|
||
from orisys_autocal.ui.pose_preview import PosePreview
|
||
|
||
stack = PlatformStack()
|
||
surface_normal = bundle.vertex_normals()[0]
|
||
point = bundle.vertices_mm[0]
|
||
pose = stack.solve(point, surface_normal)
|
||
|
||
preview = PosePreview()
|
||
try:
|
||
preview.set_bundle(bundle)
|
||
preview.set_point(point, surface_normal)
|
||
preview.set_pose(pose)
|
||
|
||
# Mid-rotation: the body has turned and the table has not moved yet.
|
||
preview.start_simulation()
|
||
for _ in range(4):
|
||
preview._sim_tick() # noqa: SLF001
|
||
rotation_share, translation_share = preview.simulation_phases() # noqa: SLF001
|
||
assert rotation_share > 0.0
|
||
assert translation_share == 0.0
|
||
rotation, translation = preview._sim_transform() # noqa: SLF001
|
||
assert float(np.linalg.norm(translation)) == pytest.approx(0.0)
|
||
assert not np.allclose(rotation, np.eye(3))
|
||
|
||
# Finish: both phases complete.
|
||
_run_simulation(preview)
|
||
rotation_share, translation_share = preview.simulation_phases() # noqa: SLF001
|
||
assert rotation_share == pytest.approx(1.0)
|
||
assert translation_share == pytest.approx(1.0)
|
||
finally:
|
||
preview.stop_simulation()
|
||
preview.deleteLater()
|
||
|
||
|
||
def test_the_simulation_stops_and_can_be_replayed(qt_app, bundle):
|
||
from orisys_autocal.ui.pose_preview import PosePreview
|
||
|
||
stack = PlatformStack()
|
||
point = bundle.vertices_mm[0]
|
||
pose = stack.solve(point, bundle.vertex_normals()[0])
|
||
preview = PosePreview()
|
||
try:
|
||
preview.set_bundle(bundle)
|
||
preview.set_point(point, bundle.vertex_normals()[0])
|
||
preview.set_pose(pose)
|
||
|
||
preview.start_simulation()
|
||
assert preview.simulating
|
||
preview.stop_simulation()
|
||
assert not preview.simulating
|
||
# Stopped means back at the start, not frozen mid-move.
|
||
rotation_share, translation_share = preview.simulation_phases() # noqa: SLF001
|
||
assert rotation_share == 0.0 and translation_share == 0.0
|
||
|
||
_run_simulation(preview)
|
||
assert not preview.simulating
|
||
preview.start_simulation()
|
||
assert preview.simulating
|
||
finally:
|
||
preview.stop_simulation()
|
||
preview.deleteLater()
|
||
|
||
|
||
def test_the_simulation_cannot_start_without_a_pose(qt_app, bundle):
|
||
from orisys_autocal.ui.pose_preview import PosePreview
|
||
|
||
preview = PosePreview()
|
||
try:
|
||
preview.set_bundle(bundle)
|
||
assert not preview.can_simulate
|
||
preview.start_simulation()
|
||
assert not preview.simulating
|
||
assert preview._sim_transform() is None # noqa: SLF001
|
||
finally:
|
||
preview.deleteLater()
|
||
|
||
|
||
def test_a_pick_returns_the_row_it_also_shows(panel):
|
||
flat = panel.view.flat
|
||
x, y = _first_surface_pixel(flat)
|
||
|
||
row = panel.handle_pick(x, y)
|
||
|
||
assert row is not None
|
||
assert panel.point_count() == 1
|
||
# What the table shows is what the caller was handed; a panel that rendered
|
||
# one thing and returned another would make an export silently wrong.
|
||
stored = panel.points()[0]
|
||
for key, value in row.items():
|
||
assert stored[key] == pytest.approx(value)
|
||
|
||
|
||
def test_a_pick_carries_a_pose_that_aligns(panel):
|
||
flat = panel.view.flat
|
||
x, y = _first_surface_pixel(flat)
|
||
|
||
row = panel.handle_pick(x, y)
|
||
|
||
assert row["residual"] < 1.0
|
||
# The platform X/Y must be the contact point the solver reported, so a
|
||
# plausible-looking origin here would mean the frames were dropped.
|
||
point, _normal, _face = flat.sample(x, y)
|
||
assert not np.allclose([row["x"], row["y"], row["z"]], 0.0)
|
||
assert abs(row["x"] - point[0]) < 1e-9
|
||
|
||
|
||
def test_a_gap_pixel_is_refused_with_a_reason(panel):
|
||
"""A UV-island gap must not become a calibration point at the origin."""
|
||
flat = panel.view.flat
|
||
gap = None
|
||
for y in range(flat.normals.shape[0]):
|
||
for x in range(flat.normals.shape[1]):
|
||
if flat.sample(x, y) is None:
|
||
gap = (x, y)
|
||
break
|
||
if gap is not None:
|
||
break
|
||
assert gap is not None, "the raster has no gap pixels to test the refusal with"
|
||
|
||
assert panel.handle_pick(*gap) is None
|
||
assert panel.point_count() == 0
|
||
assert panel.status_label.text()
|
||
|
||
|
||
def test_picking_with_no_bundle_says_so(qt_app):
|
||
widget = FingerPanel(stack=PlatformStack())
|
||
try:
|
||
assert widget.handle_pick(10, 10) is None
|
||
assert widget.point_count() == 0
|
||
assert widget.status_label.text()
|
||
finally:
|
||
widget.deleteLater()
|
||
|
||
|
||
def test_clearing_points_empties_the_table(panel):
|
||
flat = panel.view.flat
|
||
x, y = _first_surface_pixel(flat)
|
||
panel.handle_pick(x, y)
|
||
assert panel.point_count() == 1
|
||
|
||
panel.clear_points()
|
||
|
||
assert panel.point_count() == 0
|
||
assert panel.points() == []
|
||
|
||
|
||
def test_the_plate_thickness_reaches_the_solve(panel):
|
||
"""The mounting-plate entry has to reach the solve, or it is decoration.
|
||
|
||
The plate translates the sensor along the platform's tool axis, so it moves
|
||
the contact point's Z and leaves its X/Y alone. Asserting the X/Y moved would
|
||
be asserting the wrong model; asserting Z moved catches a plate field that is
|
||
read but dropped — which is what it did before ``contact_z_mm`` existed.
|
||
"""
|
||
flat = panel.view.flat
|
||
x, y = _first_surface_pixel(flat)
|
||
point, normal, _face = flat.sample(x, y)
|
||
|
||
thin = panel.stack.solve(point, normal, plate_thickness_mm=0.0)
|
||
thick = panel.stack.solve(point, normal, plate_thickness_mm=4.0)
|
||
|
||
assert thick.contact_z_mm - thin.contact_z_mm == pytest.approx(4.0)
|
||
assert thick.target_xy_mm == pytest.approx(thin.target_xy_mm)
|
||
# Picking through the panel uses the spin box, so the two must agree.
|
||
panel.clear_points()
|
||
panel.set_plate_thickness(0.0)
|
||
thin_row = panel.handle_pick(x, y)
|
||
panel.clear_points()
|
||
panel.set_plate_thickness(4.0)
|
||
thick_row = panel.handle_pick(x, y)
|
||
assert thin_row is not None and thick_row is not None
|
||
assert thick_row["residual"] == pytest.approx(thin_row["residual"], abs=1e-6)
|
||
assert thick_row["Z"] - thin_row["Z"] == pytest.approx(4.0)
|
||
assert thick_row["X"] == pytest.approx(thin_row["X"])
|
||
assert thick_row["Y"] == pytest.approx(thin_row["Y"])
|
||
|
||
|
||
def test_the_readouts_name_the_frame_mapping(panel):
|
||
"""The one line the bench will want to check when x and y are interchanged."""
|
||
text = panel._readout_labels["frames"].text() # noqa: SLF001
|
||
assert "x" in text and "z" in text
|
||
assert "-y" in text or "−y" in text
|
||
|
||
|
||
def test_the_preview_refuses_to_draw_without_a_context(qt_app):
|
||
from orisys_autocal.ui.pose_preview import PosePreview
|
||
|
||
preview = PosePreview()
|
||
try:
|
||
assert not preview.has_context()
|
||
# Rendering must be a no-op, not a crash: Qt calls this during teardown
|
||
# on some platforms.
|
||
preview.repaint()
|
||
# And the widget must be able to say why it has no picture, rather than
|
||
# leaving a black rectangle unexplained.
|
||
preview.set_bundle(None)
|
||
assert preview.problem
|
||
finally:
|
||
preview.deleteLater()
|
||
|
||
|
||
def test_the_panel_shows_why_the_preview_is_blank(qt_app):
|
||
"""A blank preview with no reason reads as a broken screen."""
|
||
widget = FingerPanel(stack=PlatformStack())
|
||
try:
|
||
widget.preview.set_bundle(None)
|
||
widget._refresh_reach() # noqa: SLF001
|
||
if not widget.preview.has_context():
|
||
assert widget.reach_label.text()
|
||
finally:
|
||
widget.deleteLater()
|