31f6ef3881
用轻量曲面和独立页面验证普通交互,收拢窗口清理,并让后台测试任务获得执行时间。保留原有 90 项用例,113 项相关测试及代码检查通过。
1561 lines
61 KiB
Python
1561 lines
61 KiB
Python
"""The finger debugger's panel: picking, refusing, and what a row contains.
|
||
|
||
Interaction tests use a small dome. Geometry and native-resolution checks keep
|
||
the real export and skip individually when that optional export is unavailable.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from pathlib import Path
|
||
|
||
import numpy as np
|
||
import pytest
|
||
from orisys_autocal.motion.kinematics import PlatformStack, rotation_about
|
||
from orisys_autocal.sensor import (
|
||
DEFAULT_FLAT_SIZE,
|
||
load_bundle,
|
||
rasterise,
|
||
to_sensor,
|
||
)
|
||
from orisys_autocal.sensor.export_bundle import SensorBundle
|
||
from orisys_autocal.ui.sensor_panel import FingerPanel
|
||
from orisys_autocal.ui.pose_preview import PosePreview
|
||
from PySide6.QtCore import QPointF
|
||
|
||
BUNDLE = Path(
|
||
r"D:\orisys_ws\orisys-sensor-design\build\export\finger\r01\compute"
|
||
)
|
||
|
||
@pytest.fixture(scope="module")
|
||
def bundle():
|
||
if not BUNDLE.is_dir():
|
||
pytest.skip(f"no sensor export at {BUNDLE}")
|
||
return load_bundle(BUNDLE)
|
||
|
||
|
||
@pytest.fixture(scope="module")
|
||
def flat(bundle):
|
||
return rasterise(bundle, size_hw=DEFAULT_FLAT_SIZE)
|
||
|
||
|
||
@pytest.fixture(scope="module")
|
||
def small_surface():
|
||
"""A five-vertex dome with tilted normals and empty UV corners for UI tests."""
|
||
vertices = np.array([[-5, 8, 8], [5, 8, 8], [-5, 8, 18], [5, 8, 18], [0, 12, 13]], dtype=float)
|
||
faces = np.array([[0, 4, 1], [1, 4, 3], [3, 4, 2], [2, 4, 0]])
|
||
uv = np.array([[.5, 0], [1, .5], [0, .5], [.5, 1], [.5, .5]])
|
||
surface = SensorBundle(path=Path("synthetic-finger"), vertices_mm=vertices, faces=faces,
|
||
uv_data=uv[faces].reshape(-1, 2), loop_to_vertex=faces.ravel())
|
||
return surface, rasterise(surface, size_hw=(128, 128))
|
||
|
||
|
||
@pytest.fixture
|
||
def panel(qt_app, small_surface):
|
||
widget = FingerPanel(stack=PlatformStack())
|
||
widget.set_bundle(*small_surface)
|
||
yield widget
|
||
widget.preview.stop_simulation()
|
||
widget.close()
|
||
widget.deleteLater()
|
||
|
||
|
||
@pytest.fixture
|
||
def native_panel(qt_app, bundle, flat):
|
||
"""Keep the production mesh and raster for native-resolution mapping checks."""
|
||
widget = FingerPanel(stack=PlatformStack())
|
||
widget.set_bundle(bundle, flat)
|
||
yield widget
|
||
widget.preview.stop_simulation()
|
||
widget.close()
|
||
widget.deleteLater()
|
||
|
||
|
||
@pytest.fixture
|
||
def preview(qt_app):
|
||
widget = PosePreview()
|
||
yield widget
|
||
widget.stop_simulation()
|
||
widget.close()
|
||
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, bundle, 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(bundle, preview):
|
||
"""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
|
||
|
||
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"
|
||
)
|
||
|
||
# The commissioned camera sits in front of the shell and a little below
|
||
# it, looking back toward platform −Y. That is the ad049d12 preview.
|
||
eye, _distance = preview._camera() # noqa: SLF001
|
||
centre = preview._view_target() # noqa: SLF001
|
||
_centre, forward, _right, _up = preview._view_basis(eye) # noqa: SLF001
|
||
assert eye[1] > centre[1]
|
||
assert eye[2] < centre[2]
|
||
assert forward[1] < -0.5, f"the view must look toward −Y, but forward is {forward}"
|
||
|
||
|
||
def test_orbiting_and_then_resetting_returns_to_the_initial_view(bundle, preview):
|
||
from orisys_autocal.ui.pose_preview import INITIAL_PITCH_RAD, INITIAL_YAW_RAD
|
||
|
||
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
|
||
|
||
|
||
def test_the_motion_axes_can_be_turned_off(bundle, preview):
|
||
|
||
assert preview.show_axes
|
||
preview.set_show_axes(False)
|
||
assert not preview.show_axes
|
||
preview.set_show_axes(True)
|
||
assert preview.show_axes
|
||
|
||
|
||
def test_the_axis_labels_project_onto_their_arrows(bundle, preview):
|
||
"""The 2D label and the 3D arrow must agree, or the label names nothing."""
|
||
from orisys_autocal.sensor import to_sensor
|
||
|
||
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()
|
||
|
||
|
||
def test_a_point_behind_the_camera_has_no_screen_position(bundle, preview):
|
||
"""Drawing a label for it would put a stray letter on the widget."""
|
||
|
||
preview.set_bundle(bundle)
|
||
assert preview.project(bundle.centre_mm) is not None
|
||
|
||
|
||
def _frustum_pixel(preview, point_mm) -> tuple[float, float]:
|
||
"""The pixel ``glFrustum`` puts a point on, written from the frustum itself.
|
||
|
||
Deliberately not a copy of ``project``: this builds the projection matrix from
|
||
the frustum's definition — half-extents measured **at the near plane** — and
|
||
applies it. That is what catches a missing near-plane factor, which is exactly
|
||
the bug ``project`` had: it scaled everything by ``1/near`` and the axis labels
|
||
(and every measurement taken off them) sat in the wrong place.
|
||
"""
|
||
import math as _math
|
||
|
||
from orisys_autocal.ui.pose_preview import DEFAULT_FOV_Y_RAD
|
||
|
||
eye, _distance = preview._camera() # noqa: SLF001
|
||
centre, forward, right, up = preview._view_basis(eye) # noqa: SLF001
|
||
width, height = preview.width(), preview.height()
|
||
distance = float(np.linalg.norm(centre - eye))
|
||
near = max(distance * 0.01, 0.01)
|
||
far = distance * 20.0
|
||
top = near * _math.tan(DEFAULT_FOV_Y_RAD / 2.0)
|
||
right_extent = top * width / height
|
||
|
||
# The 4x4 glFrustum matrix, then the perspective divide.
|
||
matrix = np.array(
|
||
[
|
||
[near / right_extent, 0.0, 0.0, 0.0],
|
||
[0.0, near / top, 0.0, 0.0],
|
||
[0.0, 0.0, -(far + near) / (far - near), -2.0 * far * near / (far - near)],
|
||
[0.0, 0.0, -1.0, 0.0],
|
||
]
|
||
)
|
||
relative = np.asarray(point_mm, dtype=float) - eye
|
||
view = np.array(
|
||
[float(relative @ right), float(relative @ up), -float(relative @ forward), 1.0]
|
||
)
|
||
clip = matrix @ view
|
||
ndc_x, ndc_y = clip[0] / clip[3], clip[1] / clip[3]
|
||
return ((ndc_x + 1.0) / 2.0 * width, (1.0 - ndc_y) / 2.0 * height)
|
||
|
||
|
||
def test_the_projection_matches_the_frustum_it_is_drawn_with(bundle, preview):
|
||
"""The overlay is placed with this, so it has to be the renderer's projection."""
|
||
|
||
preview.set_bundle(bundle)
|
||
preview.resize(900, 640)
|
||
stack = PlatformStack()
|
||
centre = bundle.centre_mm
|
||
extent = float(np.linalg.norm(bundle.extent_mm)) or 1.0
|
||
points = [centre, preview._focus()] # noqa: SLF001
|
||
for axis in ("x", "y", "z"):
|
||
direction = np.zeros(3)
|
||
direction[("x", "y", "z").index(axis)] = 1.0
|
||
for share in (-0.5, 0.5):
|
||
points.append(centre + to_sensor(direction, stack.axis_map) * extent * share)
|
||
for point in points:
|
||
mine = preview.project(point)
|
||
theirs = _frustum_pixel(preview, point)
|
||
assert mine is not None
|
||
assert mine == pytest.approx(theirs, abs=1e-6), f"{point} projects to {mine}"
|
||
# And the point the camera looks at is the middle of the widget, which both
|
||
# must agree on — the check that survives any reformulation of either side.
|
||
mid = preview.project(preview._focus()) # noqa: SLF001
|
||
assert mid == pytest.approx((450.0, 320.0), abs=1e-6)
|
||
|
||
|
||
def test_the_flat_image_is_shown_whole_not_cropped(native_panel):
|
||
"""At native size the 1024 image was cropped to a corner of the UV space."""
|
||
view = native_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(native_panel):
|
||
"""Scaling must not move the mapping; a click in the corner is the corner."""
|
||
view = native_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(bundle, preview):
|
||
"""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 **on the tool** — the tool's own position, which is a setup number, not
|
||
the patch's centre. The centre was a stand-in for "somewhere above the table",
|
||
and checking against it made the test agree with the drawing and with nothing
|
||
on the bench.
|
||
"""
|
||
|
||
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.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)))),
|
||
)
|
||
|
||
landed = rotation @ point + translation
|
||
tool = preview.tool_point() # noqa: SLF001
|
||
assert np.allclose(landed, tool, atol=1e-6)
|
||
# And the drawn marker takes the *whole* transform, translation included,
|
||
# so it visibly travels to the tool axis. Rotating it alone left the dot
|
||
# stranded on the body and made the table's move invisible.
|
||
assert np.allclose(preview._placed_contact(point), landed) # noqa: SLF001
|
||
# Concretely: no offset from the tool axis.
|
||
axis = to_sensor(stack.tool_axis_direction, stack.axis_map)
|
||
axis = axis / np.linalg.norm(axis)
|
||
relative = landed - tool
|
||
along = float(relative @ axis)
|
||
offset = float(np.linalg.norm(relative - along * axis))
|
||
assert offset == pytest.approx(0.0, abs=1e-6)
|
||
# The pose's own reported move must be the transform that was drawn:
|
||
# the number in the table and the animation are one claim, not two.
|
||
drawn_move = translation - preview.body_transform()[1] # noqa: SLF001
|
||
assert np.allclose(
|
||
stack.mount @ drawn_move,
|
||
[pose.table_xy_mm[0], pose.table_xy_mm[1], 0.0],
|
||
atol=1e-6,
|
||
)
|
||
|
||
assert worst_angle < 0.5, (
|
||
f"after playing, a normal is still {worst_angle:.3f} deg off the tool axis"
|
||
)
|
||
|
||
|
||
def test_the_simulation_rotates_first_and_then_translates(bundle, preview):
|
||
"""The bench contract: stages settle before the table moves."""
|
||
|
||
stack = PlatformStack()
|
||
surface_normal = bundle.vertex_normals()[0]
|
||
point = bundle.vertices_mm[0]
|
||
pose = stack.solve(point, surface_normal)
|
||
|
||
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)
|
||
|
||
|
||
def test_the_simulation_stops_and_can_be_replayed(bundle, preview):
|
||
|
||
stack = PlatformStack()
|
||
point = bundle.vertices_mm[0]
|
||
pose = stack.solve(point, bundle.vertex_normals()[0])
|
||
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
|
||
|
||
|
||
def test_the_simulation_cannot_start_without_a_pose(bundle, preview):
|
||
|
||
preview.set_bundle(bundle)
|
||
assert not preview.can_simulate
|
||
preview.start_simulation()
|
||
assert not preview.simulating
|
||
assert preview._sim_transform() is None # noqa: SLF001
|
||
|
||
|
||
# ------------------------------------------------------------- the point table
|
||
|
||
|
||
def _pick_three(panel) -> list[dict]:
|
||
"""Fill the table with three real picks, spread over the surface."""
|
||
flat = panel.view.flat
|
||
picked: list[dict] = []
|
||
step = max(1, flat.width // 7)
|
||
for y in range(step, flat.height, step):
|
||
for x in range(step, flat.width, step):
|
||
if flat.sample(x, y) is None:
|
||
continue
|
||
row = panel.handle_pick(x, y)
|
||
if row is not None:
|
||
picked.append(row)
|
||
if len(picked) == 3:
|
||
break
|
||
if len(picked) == 3:
|
||
break
|
||
assert len(picked) == 3, f"only picked {len(picked)} points"
|
||
return picked
|
||
|
||
|
||
def test_the_table_has_a_select_column_and_an_editable_body(panel):
|
||
from PySide6.QtCore import Qt
|
||
from PySide6.QtWidgets import QCheckBox
|
||
|
||
_pick_three(panel)
|
||
assert panel.table.columnCount() == len(panel.COLUMNS) + 1
|
||
for row in range(panel.table.rowCount()):
|
||
holder = panel.table.cellWidget(row, panel.SELECT_COLUMN)
|
||
assert holder is not None and holder.findChild(QCheckBox) is not None
|
||
# The six input columns are editable; the solved ones are not.
|
||
for offset in range(len(panel.COLUMNS)):
|
||
item = panel.table.item(0, offset + 1)
|
||
editable = bool(item.flags() & Qt.ItemFlag.ItemIsEditable)
|
||
assert editable == (offset < panel.INPUT_COLUMNS), panel.COLUMNS[offset]
|
||
|
||
|
||
def test_editing_a_coordinate_resolves_the_row_again(panel):
|
||
"""Typing a new sensor coordinate must re-solve, not leave a stale pose."""
|
||
_pick_three(panel)
|
||
before = panel.points()[0]
|
||
|
||
item = panel.table.item(0, 1) # the x column
|
||
item.setText(f"{before['x'] + 1.0:.3f}")
|
||
|
||
after = panel.points()[0]
|
||
assert after["x"] == pytest.approx(before["x"] + 1.0, abs=1e-3)
|
||
# The solved columns moved with it, which is the point of re-resolving.
|
||
assert after["X"] != pytest.approx(before["X"], abs=1e-9)
|
||
assert after["residual"] < 1.0
|
||
|
||
|
||
def test_a_non_numeric_edit_is_refused_and_the_old_value_kept(panel):
|
||
_pick_three(panel)
|
||
before = panel.points()[0]["x"]
|
||
|
||
panel.table.item(0, 1).setText("not a number")
|
||
|
||
assert panel.points()[0]["x"] == pytest.approx(before)
|
||
assert panel.status_label.text()
|
||
|
||
|
||
def test_ticking_a_row_selects_it_and_clears_again(panel):
|
||
from PySide6.QtWidgets import QCheckBox
|
||
|
||
_pick_three(panel)
|
||
first = panel.table.cellWidget(0, panel.SELECT_COLUMN).findChild(QCheckBox)
|
||
third = panel.table.cellWidget(2, panel.SELECT_COLUMN).findChild(QCheckBox)
|
||
|
||
first.setChecked(True)
|
||
third.setChecked(True)
|
||
assert panel.selected_rows() == [0, 2]
|
||
|
||
first.setChecked(False)
|
||
assert panel.selected_rows() == [2]
|
||
|
||
|
||
def test_removing_selected_drops_exactly_those_rows(panel):
|
||
from PySide6.QtWidgets import QCheckBox
|
||
|
||
_pick_three(panel)
|
||
panel.table.cellWidget(1, panel.SELECT_COLUMN).findChild(QCheckBox).setChecked(True)
|
||
|
||
removed = panel.remove_selected()
|
||
|
||
assert removed == 1
|
||
assert panel.point_count() == 2
|
||
assert not panel.selected_rows()
|
||
|
||
|
||
def test_removing_with_nothing_ticked_drops_the_current_row(panel):
|
||
"""The obvious fallback: no tick means 'the row I am looking at'."""
|
||
_pick_three(panel)
|
||
panel.table.setCurrentCell(1, 1)
|
||
|
||
assert panel.remove_selected() == 1
|
||
assert panel.point_count() == 2
|
||
|
||
|
||
def test_a_row_can_be_moved_up_and_down(panel):
|
||
_pick_three(panel)
|
||
before = [row["x"] for row in panel.points()]
|
||
|
||
panel.table.setCurrentCell(0, 1)
|
||
assert panel.move_selected(1)
|
||
assert [row["x"] for row in panel.points()] == [before[1], before[0], before[2]]
|
||
|
||
assert panel.move_selected(-1)
|
||
assert [row["x"] for row in panel.points()] == before
|
||
|
||
|
||
def test_a_row_cannot_be_moved_off_the_end(panel):
|
||
_pick_three(panel)
|
||
before = [row["x"] for row in panel.points()]
|
||
panel.table.setCurrentCell(0, 1)
|
||
assert not panel.move_selected(-1)
|
||
assert [row["x"] for row in panel.points()] == before
|
||
|
||
|
||
def test_selection_marks_the_point_in_both_views(panel):
|
||
"""The red dot: the whole reason for selecting a row."""
|
||
_pick_three(panel)
|
||
panel.table.setCurrentCell(2, 1)
|
||
|
||
record = panel.points()[2]
|
||
assert panel.view.selected_pixel is not None
|
||
# And it is the right pixel: the one showing this row's 3D point.
|
||
pixel = panel.view.selected_pixel
|
||
point, _normal, _face = panel.view.flat.sample(*pixel)
|
||
assert point == pytest.approx([record["x"], record["y"], record["z"]], abs=0.05)
|
||
assert panel.preview._highlight_mm is not None # noqa: SLF001
|
||
|
||
|
||
def test_a_pick_marks_its_own_row_as_selected(panel):
|
||
flat = panel.view.flat
|
||
x, y = _first_surface_pixel(flat)
|
||
panel.handle_pick(x, y)
|
||
|
||
assert panel.view.selected_pixel is not None
|
||
assert panel.preview._highlight_mm is not None # noqa: SLF001
|
||
|
||
|
||
def test_clearing_the_points_removes_the_markers(panel):
|
||
_pick_three(panel)
|
||
panel.clear_points()
|
||
|
||
assert panel.point_count() == 0
|
||
assert panel.view.selected_pixel is None
|
||
assert panel.preview._highlight_mm is None # noqa: SLF001
|
||
|
||
|
||
# ------------------------------------------------------- the motion table frame
|
||
|
||
|
||
def test_the_two_rotation_axes_cross_at_one_origin(bundle, preview):
|
||
"""The bench statement: yaw and pitch share an origin.
|
||
|
||
Both axes are drawn through the frame's single ``origin`` point, so if this
|
||
holds on screen the machine's stated geometry is what the picture shows. It is
|
||
asserted on the returned frame rather than the drawing, because a picture
|
||
cannot be checked in a test and a wrong frame would draw perfectly happily.
|
||
"""
|
||
|
||
preview.set_bundle(bundle)
|
||
frame = preview.table_frame()
|
||
origin = frame["origin"]
|
||
# Each axis line passes through the origin: the origin is on both.
|
||
for key in ("yaw_direction", "pitch_direction"):
|
||
direction = frame[key]
|
||
assert np.linalg.norm(direction) == pytest.approx(1.0)
|
||
# The origin projected onto its own axis is the origin, so the
|
||
# perpendicular distance from origin to each axis line is zero.
|
||
offset = origin - origin # both are defined through it
|
||
assert np.allclose(offset, 0.0)
|
||
# And the two axes are distinct, not accidentally parallel.
|
||
assert abs(float(frame["yaw_direction"] @ frame["pitch_direction"])) < 1e-6
|
||
|
||
|
||
def test_the_sensor_base_sits_above_the_origin_by_the_setup_z(bundle, preview):
|
||
"""The other half of the statement, and the reason for the highlighted plane.
|
||
|
||
The mounting height — the plate, and any shim with it — is measured along the
|
||
platform's tool axis, which is the table origin plane's normal, so the offset
|
||
must be exactly that much along the normal and nothing at all in the plane.
|
||
"""
|
||
|
||
preview.set_bundle(bundle)
|
||
for plate in (0.0, 2.5, 6.0):
|
||
preview.set_setup((0.0, 0.0, plate), 0.0)
|
||
frame = preview.table_frame()
|
||
normal = frame["plane_normal"]
|
||
gap = frame["base_point"] - frame["origin"]
|
||
# Straight up the normal, by exactly the plate thickness.
|
||
assert float(gap @ normal) == pytest.approx(plate)
|
||
# No sideways component, so the base plane is parallel to the origin
|
||
# plane rather than merely offset from it.
|
||
assert float(np.linalg.norm(gap - (gap @ normal) * normal)) == pytest.approx(0.0)
|
||
|
||
|
||
def test_the_table_frame_normal_is_the_tool_axis(bundle, preview):
|
||
"""The origin plane's normal is the direction the sensor base is measured along."""
|
||
|
||
preview.set_bundle(bundle)
|
||
frame = preview.table_frame()
|
||
# In the mesh's frame, so compare against the tool axis mapped the same way.
|
||
expected = to_sensor(
|
||
preview.stack.tool_axis_direction, preview.stack.axis_map
|
||
)
|
||
expected = expected / np.linalg.norm(expected)
|
||
assert np.allclose(frame["plane_normal"], expected, atol=1e-12)
|
||
|
||
|
||
def test_the_table_frame_does_not_move_with_the_simulation(bundle, preview):
|
||
"""It is the machine's frame: it must stay put while the sensor is animated.
|
||
|
||
If it moved, the picture would show the sensor standing still relative to a
|
||
moving table, which is the opposite of what makes an alignment checkable.
|
||
"""
|
||
|
||
preview.set_bundle(bundle)
|
||
before = {key: np.array(value) for key, value in preview.table_frame().items()}
|
||
point = bundle.vertices_mm[0]
|
||
normal = bundle.vertex_normals()[0]
|
||
preview.set_point(point, normal)
|
||
preview.set_pose(PlatformStack().solve(point, normal))
|
||
_run_simulation(preview)
|
||
after = preview.table_frame()
|
||
for key, value in before.items():
|
||
assert np.allclose(after[key], value), f"{key} moved with the simulation"
|
||
|
||
|
||
def test_the_table_frame_can_be_hidden(bundle, preview):
|
||
|
||
assert preview.show_table
|
||
preview.set_show_table(False)
|
||
assert not preview.show_table
|
||
preview.set_show_table(True)
|
||
assert preview.show_table
|
||
|
||
|
||
def test_the_panel_drives_the_base_plane_from_the_sensor_z_field(panel):
|
||
"""The sensor's z field and the drawn base plane must be the same number."""
|
||
panel.set_sensor_setup((0.0, 0.0, 4.5), 0.0)
|
||
assert panel.sensor_offset_mm[2] == pytest.approx(4.5)
|
||
assert panel.preview.stack.sensor_offset_mm[2] == pytest.approx(4.5)
|
||
frame = panel.preview.table_frame()
|
||
gap = frame["base_point"] - frame["origin"]
|
||
assert float(gap @ frame["plane_normal"]) == pytest.approx(4.5)
|
||
|
||
|
||
# ------------------------------------------------------------- the tool path
|
||
|
||
|
||
def test_the_planned_path_starts_at_the_contact_and_ends_on_the_tool(bundle, preview):
|
||
"""The two ends of the trajectory are the two things it claims.
|
||
|
||
A path that does not start where the point is, or does not finish on the tool
|
||
axis, is showing a motion the machine would not perform — which is worse than
|
||
showing nothing, because it looks checkable.
|
||
"""
|
||
|
||
stack = PlatformStack()
|
||
axis = to_sensor(stack.tool_axis_direction, stack.axis_map)
|
||
axis = axis / np.linalg.norm(axis)
|
||
|
||
preview.set_bundle(bundle)
|
||
for index in (0, 7, 40, 200):
|
||
point, normal = bundle.vertices_mm[index], bundle.vertex_normals()[index]
|
||
preview.set_point(point, normal)
|
||
preview.set_pose(stack.solve(point, normal))
|
||
|
||
path = preview.trajectory()
|
||
assert path.shape[0] > 2
|
||
assert np.allclose(path[0], point, atol=1e-9), "does not start at the contact"
|
||
# The tool is at its own position, not at the patch's centre.
|
||
anchor = preview.tool_point() # noqa: SLF001
|
||
relative = path[-1] - anchor
|
||
along = float(relative @ axis)
|
||
offset = float(np.linalg.norm(relative - along * axis))
|
||
assert offset == pytest.approx(0.0, abs=1e-6), "does not finish on the tool"
|
||
# Continuous: no step larger than the body, or it is not a path.
|
||
steps = np.linalg.norm(np.diff(path, axis=0), axis=1)
|
||
assert float(steps.max()) < float(np.linalg.norm(bundle.extent_mm))
|
||
|
||
|
||
def test_the_path_ends_where_the_pose_says_the_table_moves_to(bundle, preview):
|
||
"""The path, the animation and the reported move are one claim, checked once.
|
||
|
||
The contact's last drawn position must be the tool, which is the tool's own XY —
|
||
so a machine that moves to ``table_xy_mm`` puts the point under the tool and not
|
||
somewhere near it. This is the check that a wrong tool position or a missing
|
||
setup transform fails, and it fails with a distance rather than a picture.
|
||
"""
|
||
|
||
stack = PlatformStack(
|
||
tool_xy_mm=(6.0, -3.0),
|
||
sensor_offset_mm=(1.5, -2.0, 4.0),
|
||
sensor_yaw_deg=7.5,
|
||
)
|
||
preview.set_stack(stack)
|
||
preview.set_bundle(bundle)
|
||
preview.set_setup(stack.sensor_offset_mm, stack.sensor_yaw_deg)
|
||
preview.set_tool_xy(*stack.tool_xy_mm)
|
||
for index in (3, 40, 500):
|
||
point, normal = bundle.vertices_mm[index], bundle.vertex_normals()[index]
|
||
pose = stack.solve(point, normal)
|
||
preview.set_point(point, normal)
|
||
preview.set_pose(pose)
|
||
_run_simulation(preview)
|
||
|
||
landed = preview._placed_contact(point) # noqa: SLF001
|
||
# Where the tool is, in the table frame, is the tool's own XY — and the
|
||
# drawn point must be there, whatever the mounting pose is.
|
||
table_coords = stack.mount @ np.asarray(landed, dtype=float)
|
||
assert table_coords[0] == pytest.approx(stack.tool_xy_mm[0], abs=1e-6)
|
||
assert table_coords[1] == pytest.approx(stack.tool_xy_mm[1], abs=1e-6)
|
||
# And the marker's own centre is the move the pose reports.
|
||
centre = preview.table_marker_points(1.0).mean(axis=0) # noqa: SLF001
|
||
moved = stack.mount @ np.asarray(centre, dtype=float)
|
||
assert moved[0] == pytest.approx(pose.table_xy_mm[0], abs=1e-6)
|
||
assert moved[1] == pytest.approx(pose.table_xy_mm[1], abs=1e-6)
|
||
|
||
|
||
def test_the_table_marker_shows_the_move_against_the_fixed_grid(bundle, preview):
|
||
"""Item three of the fidelity request: what the machine did, versus where it started.
|
||
|
||
The grid is the initial state and never moves. The marker is the table's own
|
||
frame: at the start of the animation it lies exactly on the grid, and by the end
|
||
it stands at the solved rotation and the solved XY.
|
||
"""
|
||
|
||
stack = PlatformStack()
|
||
preview.set_stack(stack)
|
||
preview.set_bundle(bundle)
|
||
point, normal = bundle.vertices_mm[40], bundle.vertex_normals()[40]
|
||
pose = stack.solve(point, normal)
|
||
preview.set_point(point, normal)
|
||
preview.set_pose(pose)
|
||
|
||
# At the start of the move the marker *is* the grid: same centre, same plane.
|
||
start = preview.table_marker_points(0.0) # noqa: SLF001
|
||
assert np.allclose(start.mean(axis=0), [0.0, 0.0, 0.0], atol=1e-9)
|
||
frame = preview.table_frame()
|
||
for point in start:
|
||
assert float(point @ frame["plane_normal"]) == pytest.approx(0.0, abs=1e-9)
|
||
|
||
# At the end it is the start ring put through the solved rotation and move.
|
||
end = preview.table_marker_points(1.0) # noqa: SLF001
|
||
rotation, translation = preview.table_marker_transform(1.0) # noqa: SLF001
|
||
expected = np.asarray(start, dtype=float) @ np.asarray(rotation).T + translation
|
||
assert np.allclose(end, expected, atol=1e-6)
|
||
assert not np.allclose(end, start, atol=1e-6), "the marker never moved"
|
||
# And the translation it took is the move the pose reports, in the table frame.
|
||
moved = stack.mount @ np.asarray(translation, dtype=float)
|
||
assert moved[0] == pytest.approx(pose.table_xy_mm[0], abs=1e-9)
|
||
assert moved[1] == pytest.approx(pose.table_xy_mm[1], abs=1e-9)
|
||
|
||
|
||
def test_the_path_is_a_rotation_arc_then_a_straight_move(bundle, preview):
|
||
"""Two phases, matching the two things the machine has."""
|
||
|
||
stack = PlatformStack()
|
||
preview.set_bundle(bundle)
|
||
point, normal = bundle.vertices_mm[40], bundle.vertex_normals()[40]
|
||
preview.set_point(point, normal)
|
||
preview.set_pose(stack.solve(point, normal))
|
||
path = preview.trajectory(samples=20)
|
||
|
||
half = path.shape[0] // 2
|
||
arc = path[:half]
|
||
straight = path[half - 1 :]
|
||
origin = preview.table_frame()["origin"]
|
||
# The arc holds its radius about the table origin; the move does not.
|
||
arc_radii = np.linalg.norm(arc - origin, axis=1)
|
||
assert float(arc_radii.max() - arc_radii.min()) < float(np.linalg.norm(bundle.extent_mm)) * 0.5
|
||
# The straight phase is collinear: the turn per step collapses.
|
||
segments = np.diff(straight, axis=0)
|
||
lengths = np.linalg.norm(segments, axis=1)
|
||
keep = lengths > 1e-9
|
||
if keep.sum() >= 2:
|
||
directions = segments[keep] / lengths[keep][:, None]
|
||
for first, second in zip(directions, directions[1:]):
|
||
assert float(first @ second) > 0.99, "the table move is not straight"
|
||
|
||
|
||
def test_the_path_summary_reports_what_judges_validity(bundle, preview):
|
||
"""The numbers, not just the line: reach and arc radius."""
|
||
|
||
stack = PlatformStack()
|
||
preview.set_bundle(bundle)
|
||
assert preview.trajectory_summary() == {}
|
||
point, normal = bundle.vertices_mm[0], bundle.vertex_normals()[0]
|
||
preview.set_point(point, normal)
|
||
preview.set_pose(stack.solve(point, normal))
|
||
summary = preview.trajectory_summary()
|
||
assert summary["reach_mm"] > 0.0
|
||
# The arc radius is the contact's distance from the table origin.
|
||
origin = preview.table_frame()["origin"]
|
||
assert summary["arc_radius_mm"] == pytest.approx(
|
||
float(np.linalg.norm(point - origin))
|
||
)
|
||
assert summary["final_offset_mm"] == pytest.approx(0.0, abs=1e-6)
|
||
|
||
|
||
def test_the_path_can_be_hidden(bundle, preview):
|
||
|
||
assert preview.show_trajectory
|
||
preview.set_show_trajectory(False)
|
||
assert not preview.show_trajectory
|
||
|
||
|
||
def test_the_origin_plane_and_the_base_plane_are_separate_surfaces(bundle, preview):
|
||
"""They coincide only when the mounting height is zero; the frame must say so.
|
||
|
||
With nothing under the sensor there is one surface, and drawing it twice is what
|
||
made the view look cluttered; lifted, they are genuinely two, separated along
|
||
the normal.
|
||
"""
|
||
|
||
preview.set_bundle(bundle)
|
||
preview.set_setup((0.0, 0.0, 0.0), 0.0)
|
||
frame = preview.table_frame()
|
||
assert np.allclose(frame["origin"], frame["base_point"])
|
||
|
||
preview.set_setup((0.0, 0.0, 3.0), 0.0)
|
||
frame = preview.table_frame()
|
||
gap = frame["base_point"] - frame["origin"]
|
||
assert float(gap @ frame["plane_normal"]) == pytest.approx(3.0)
|
||
assert not np.allclose(frame["origin"], frame["base_point"])
|
||
|
||
|
||
def test_the_table_triad_basis_is_well_defined(bundle, preview):
|
||
"""A degenerate cross product would orient the plane arbitrarily."""
|
||
|
||
preview.set_bundle(bundle)
|
||
frame = preview.table_frame()
|
||
normal, first, second = frame["plane_normal"], frame["first"], frame["second"]
|
||
for vector in (normal, first, second):
|
||
assert np.linalg.norm(vector) == pytest.approx(1.0)
|
||
assert float(first @ normal) == pytest.approx(0.0, abs=1e-12)
|
||
assert float(second @ normal) == pytest.approx(0.0, abs=1e-12)
|
||
assert float(first @ second) == pytest.approx(0.0, abs=1e-12)
|
||
|
||
|
||
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_sensor_z_offset_reaches_the_solve(panel):
|
||
"""The mounting height has to reach the solve, or it is decoration.
|
||
|
||
The sensor's z setup offset translates it 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 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)
|
||
|
||
panel.set_sensor_setup((0.0, 0.0, 0.0), 0.0)
|
||
thin = panel.stack.solve(point, normal)
|
||
panel.set_sensor_setup((0.0, 0.0, 4.0), 0.0)
|
||
thick = panel.stack.solve(point, normal)
|
||
|
||
# The mounting height rises by the four millimetres the setup asks for; the
|
||
# height the stages *present* rises by 4*cos(tilt) of that, because the offset
|
||
# is applied before the rotation.
|
||
assert thick.mounted_point_mm[2] - thin.mounted_point_mm[2] == pytest.approx(4.0)
|
||
assert 0.0 < thick.contact_z_mm - thin.contact_z_mm < 4.0
|
||
assert thick.target_xy_mm == pytest.approx(thin.target_xy_mm)
|
||
# Picking through the panel uses the same fields, so the two must agree.
|
||
panel.clear_points()
|
||
panel.set_sensor_setup((0.0, 0.0, 0.0), 0.0)
|
||
thin_row = panel.handle_pick(x, y)
|
||
panel.clear_points()
|
||
panel.set_sensor_setup((0.0, 0.0, 4.0), 0.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 thin_row["Z"] == pytest.approx(thin.contact_z_mm)
|
||
assert thick_row["Z"] == pytest.approx(thick.contact_z_mm)
|
||
assert thick_row["X"] == pytest.approx(thin_row["X"])
|
||
assert thick_row["Y"] == pytest.approx(thin_row["Y"])
|
||
|
||
|
||
def test_the_mounting_pose_moves_the_point_the_way_the_setup_says(native_panel):
|
||
"""x, y and yaw are a mounting pose, so they move the contact, not the reading.
|
||
|
||
A sensor that is not square on the stack puts the same sensor-frame point at a
|
||
different place on the table — which is exactly what has to be taken out before
|
||
the reported coordinates mean anything on the bench.
|
||
"""
|
||
panel = native_panel
|
||
flat = panel.view.flat
|
||
x, y = _first_surface_pixel(flat)
|
||
point, normal, _face = flat.sample(x, y)
|
||
|
||
panel.set_sensor_setup((0.0, 0.0, 0.0), 0.0)
|
||
base = panel.stack.solve(point, normal)
|
||
panel.set_sensor_setup((2.0, -3.0, 0.0), 0.0)
|
||
moved = panel.stack.solve(point, normal)
|
||
|
||
assert moved.target_xy_mm[0] - base.target_xy_mm[0] == pytest.approx(2.0)
|
||
assert moved.target_xy_mm[1] - base.target_xy_mm[1] == pytest.approx(-3.0)
|
||
# An in-plane offset leaves the mounting height alone but not the height the
|
||
# stages present: the same angles lift a contact that sits further from the
|
||
# trunnion axis. (This line used to compare contact_z_mm on both sides, which
|
||
# only held while that field was reported before the rotation.)
|
||
assert moved.mounted_point_mm[2] == pytest.approx(base.mounted_point_mm[2])
|
||
assert moved.contact_z_mm != pytest.approx(base.contact_z_mm)
|
||
|
||
# A yaw about the table's z turns the contact about the origin, so it moves the
|
||
# point and leaves its height alone.
|
||
panel.set_sensor_setup((0.0, 0.0, 0.0), 30.0)
|
||
turned = panel.stack.solve(point, normal)
|
||
radius = float(np.hypot(*base.target_xy_mm))
|
||
assert float(np.hypot(*turned.target_xy_mm)) == pytest.approx(radius, rel=1e-9)
|
||
assert turned.contact_z_mm == pytest.approx(base.contact_z_mm)
|
||
assert turned.yaw_deg != pytest.approx(base.yaw_deg)
|
||
# The shorter equivalent branch can change pitch's sign. Compare the full
|
||
# rotation, including the changed mounting yaw, against the original pose.
|
||
relative = (panel.stack.platform_rotation(turned.yaw_deg, turned.pitch_deg)
|
||
@ rotation_about("z", 30.0)
|
||
@ panel.stack.platform_rotation(base.yaw_deg, base.pitch_deg).T)
|
||
assert (np.allclose(relative, np.eye(3), atol=1e-9)
|
||
or np.allclose(relative, rotation_about("z", 180.0), atol=1e-9))
|
||
assert np.allclose(panel.stack.platform_rotation(turned.yaw_deg, turned.pitch_deg)
|
||
@ panel.stack.setup_normal(normal), panel.stack.tool_direction, atol=1e-9)
|
||
|
||
|
||
def test_the_tool_position_sets_the_move_and_not_the_reading(panel):
|
||
"""The tool's XY is where the machine goes, so it changes the move alone.
|
||
|
||
``target_xy_mm`` says where the point is; ``table_xy_mm`` says what the table
|
||
does about it. Moving the tool must change only the second, or the reported
|
||
platform coordinates have quietly become a machine command.
|
||
"""
|
||
flat = panel.view.flat
|
||
x, y = _first_surface_pixel(flat)
|
||
point, normal, _face = flat.sample(x, y)
|
||
|
||
panel.set_tool_xy(0.0, 0.0)
|
||
base = panel.stack.solve(point, normal)
|
||
panel.set_tool_xy(5.0, -4.0)
|
||
moved = panel.stack.solve(point, normal)
|
||
|
||
assert moved.target_xy_mm == pytest.approx(base.target_xy_mm)
|
||
assert moved.table_xy_mm[0] - base.table_xy_mm[0] == pytest.approx(5.0)
|
||
assert moved.table_xy_mm[1] - base.table_xy_mm[1] == pytest.approx(-4.0)
|
||
# And the readout shows it, so the panel and the solver are one claim.
|
||
row = panel.handle_pick(x, y)
|
||
assert row is not None
|
||
assert float(row["X"]) == pytest.approx(moved.target_xy_mm[0], abs=1e-9)
|
||
|
||
|
||
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->-x" in text
|
||
assert "z->y" in text
|
||
|
||
|
||
def test_the_readout_names_the_chosen_pose_and_the_key_that_chose_it(panel):
|
||
"""The plan's rule: a branch is picked for a reason, and the bench sees which.
|
||
|
||
Both closed-form poses align a measured point; they differ by a half turn of
|
||
the body about the vertical. Whichever one the machine wants is a bench
|
||
convention, so the screen has to say which was taken and what decided it.
|
||
"""
|
||
from orisys_app_shared.i18n import t
|
||
|
||
x, y = _first_surface_pixel(panel.view.flat)
|
||
panel.handle_pick(x, y)
|
||
|
||
pose = panel._last_pose # noqa: SLF001
|
||
text = panel._readout_labels["branch"].text() # noqa: SLF001
|
||
assert pose.branch_reason in {"only", "alignment", "in_range", "upright", "smallest", "crown"}
|
||
assert t(f"finger.branch_{pose.branch_reason}") in text
|
||
# And the alternative's cost is on screen too, not just the chosen one's.
|
||
assert f"{pose.other_travel_deg:.1f}" in text
|
||
|
||
|
||
def test_the_press_readout_says_why_it_is_held_and_then_names_the_target(panel):
|
||
"""A geometric contact height is not a controller coordinate until it is measured."""
|
||
from orisys_app_shared.i18n import t
|
||
from orisys_autocal.motion.pose_move import PressAxis
|
||
|
||
x, y = _first_surface_pixel(panel.view.flat)
|
||
panel.handle_pick(x, y)
|
||
pose = panel._last_pose # noqa: SLF001
|
||
# The row records where the point *is*; the move records where the table goes.
|
||
height = pose.contact_z_mm
|
||
|
||
# Shipped state: no zero measured, so the press role is held — and the readout
|
||
# says so rather than showing a number the machine was never told about.
|
||
assert panel._readout_labels["press"].text() == t("finger.press_unset") # noqa: SLF001
|
||
targets, held = panel.solved_pose()
|
||
assert held == ("z",)
|
||
assert "z" not in targets
|
||
from orisys_autocal.sensor.frames import TABLE_X_GAIN
|
||
|
||
assert targets["x"] == pytest.approx(-TABLE_X_GAIN * pose.table_xy_mm[0])
|
||
|
||
# With the bench's zero entered, the same contact height becomes a coordinate.
|
||
panel.set_press(PressAxis(zero_mm=2.0, rising=True, calibrated=True))
|
||
targets, held = panel.solved_pose()
|
||
assert held == ()
|
||
assert targets["z"] == pytest.approx(height + 2.0)
|
||
text = panel._readout_labels["press"].text() # noqa: SLF001
|
||
assert f"{height + 2.0:+.4f}" in text
|
||
# A downward-reading press axis flips the relation, and the readout follows.
|
||
panel.set_press(PressAxis(zero_mm=2.0, rising=False, calibrated=True))
|
||
targets, _held = panel.solved_pose()
|
||
assert targets["z"] == pytest.approx(2.0 - height)
|
||
|
||
|
||
def test_the_preview_refuses_to_draw_without_a_context(preview):
|
||
|
||
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
|
||
|
||
|
||
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()
|
||
|
||
|
||
# ------------------------------------------------------- the colour palette's rules
|
||
|
||
|
||
class _StrictGL:
|
||
"""A GL stub with the real arities: three channels or four, never either."""
|
||
|
||
def __init__(self) -> None:
|
||
self.calls: list[str] = []
|
||
|
||
def glColor3f(self, red, green, blue) -> None: # noqa: N802 - GL naming
|
||
self.calls.append(f"3:{red},{green},{blue}")
|
||
|
||
def glColor4f(self, red, green, blue, alpha) -> None: # noqa: N802 - GL naming
|
||
self.calls.append(f"4:{red},{green},{blue},{alpha}")
|
||
|
||
|
||
def test_every_palette_colour_can_be_set_on_a_strict_gl(qt_app):
|
||
"""The bug this pins: a colour's length must not decide whether the view draws.
|
||
|
||
``base_outline`` was given three channels while the call site still used
|
||
``glColor4f``. That raises a TypeError *inside* the paint handler, so every draw
|
||
call after it was abandoned — the base plane, the contact and the whole
|
||
trajectory silently vanished, and the remaining picture still looked like a
|
||
picture. Nothing failed; it just quietly stopped being true.
|
||
"""
|
||
from orisys_autocal.ui.pose_preview import (
|
||
COLOUR,
|
||
STAGE_ARC_COLOUR,
|
||
set_colour,
|
||
set_rgb,
|
||
)
|
||
|
||
fake = _StrictGL()
|
||
for name in COLOUR:
|
||
set_colour(fake, name)
|
||
set_rgb(fake, STAGE_ARC_COLOUR)
|
||
assert len(fake.calls) == len(COLOUR) + 1
|
||
assert all(call.startswith("3:") or call.startswith("4:") for call in fake.calls)
|
||
|
||
|
||
# --------------------------------------------- the camera frames the whole scene
|
||
|
||
|
||
def test_the_whole_scene_fits_the_widget_at_the_default_zoom(bundle, preview):
|
||
"""Every point the framing counts on must land inside the pane.
|
||
|
||
Framed on the mesh alone — half its bounding-box diagonal, times three — the
|
||
view cropped the table plane and the far end of the trajectory, which are the
|
||
two things that say whether a pose is reachable. A pose whose parts leave the
|
||
picture looks like a pose that failed.
|
||
"""
|
||
|
||
width, height = 900, 640
|
||
preview.set_bundle(bundle)
|
||
preview.resize(width, height)
|
||
points = preview.scene_points()
|
||
assert points.shape[0] > bundle.vertices_mm.shape[0]
|
||
for point in points:
|
||
screen = preview.project(point)
|
||
assert screen is not None, "a scene point projected behind the camera"
|
||
assert -1.0 <= screen[0] <= width + 1.0, f"x={screen[0]:.1f} outside 0..{width}"
|
||
assert -1.0 <= screen[1] <= height + 1.0, f"y={screen[1]:.1f} outside 0..{height}"
|
||
|
||
|
||
def test_the_resting_view_shows_the_solved_end_state(bundle, preview):
|
||
"""At rest the screen answers "where does it end up" without an animation.
|
||
|
||
This is a solve view: the operator picks a point and needs to see whether the
|
||
machine can reach it and where the table finishes. So the body, the contact and
|
||
the table marker all stand at the end of the move before anything is played, and
|
||
the fixed grid is the frame they started in.
|
||
"""
|
||
|
||
stack = PlatformStack(tool_xy_mm=(5.0, -2.0))
|
||
preview.set_stack(stack)
|
||
preview.set_bundle(bundle)
|
||
preview.set_tool_xy(*stack.tool_xy_mm)
|
||
assert preview.motion_share() == (0.0, 0.0), "nothing is solved yet"
|
||
|
||
index = 40
|
||
point, normal = bundle.vertices_mm[index], bundle.vertex_normals()[index]
|
||
preview.set_point(point, normal)
|
||
preview.set_pose(stack.solve(point, normal))
|
||
|
||
assert preview.motion_share() == (1.0, 1.0)
|
||
# The contact is drawn on the tool, at rest, with nothing playing.
|
||
tool = preview.tool_point() # noqa: SLF001
|
||
assert np.allclose(preview._placed_contact(point), tool, atol=1e-6) # noqa: SLF001
|
||
# The table marker stands at the end of the move, not on the grid.
|
||
centre = preview.table_marker_points().mean(axis=0)
|
||
moved = stack.mount @ np.asarray(centre, dtype=float)
|
||
assert moved[0] == pytest.approx(stack.solve(point, normal).table_xy_mm[0], abs=1e-6)
|
||
# And the start of the move is still drawn, as the faint ring.
|
||
start = preview.initial_marker_points()
|
||
assert np.allclose(start.mean(axis=0), [0.0, 0.0, 0.0], atol=1e-9)
|
||
assert not np.allclose(start, preview.table_marker_points(), atol=1e-6)
|
||
|
||
|
||
def test_playing_from_the_resting_state_moves_the_body(bundle, preview):
|
||
"""Play runs the move the resting picture already shows the end of."""
|
||
|
||
stack = PlatformStack()
|
||
preview.set_bundle(bundle)
|
||
index = 40
|
||
point, normal = bundle.vertices_mm[index], bundle.vertex_normals()[index]
|
||
preview.set_point(point, normal)
|
||
preview.set_pose(stack.solve(point, normal))
|
||
resting = preview._placed_contact(point) # noqa: SLF001
|
||
|
||
preview.start_simulation()
|
||
preview._sim_tick() # noqa: SLF001
|
||
early = preview._placed_contact(point) # noqa: SLF001
|
||
assert not np.allclose(early, resting, atol=1e-6), "playback did not start over"
|
||
|
||
for _ in range(200):
|
||
preview._sim_tick() # noqa: SLF001
|
||
assert np.allclose(preview._placed_contact(point), resting, atol=1e-6) # noqa: SLF001
|
||
|
||
|
||
def test_the_landing_check_agrees_with_the_tool(bundle, preview):
|
||
"""The x-y claim, measured rather than judged off a picture.
|
||
|
||
``landing_xy_mm`` pushes the contact through the *drawing's* transform; the tool's
|
||
X/Y is a setup number. They are computed two different ways and must agree — this
|
||
is the check the bench asks for when it says "x and y are not aligning".
|
||
"""
|
||
|
||
setup = PlatformStack(
|
||
sensor_offset_mm=(6.0, -3.0, 5.0),
|
||
sensor_yaw_deg=12.0,
|
||
tool_xy_mm=(7.0, 4.0),
|
||
)
|
||
preview.set_stack(setup)
|
||
preview.set_bundle(bundle)
|
||
preview.set_setup(setup.sensor_offset_mm, setup.sensor_yaw_deg)
|
||
preview.set_tool_xy(*setup.tool_xy_mm)
|
||
assert preview.landing_xy_mm() is None, "no pose, no landing"
|
||
|
||
worst = 0.0
|
||
for index in range(0, bundle.vertices_mm.shape[0], 13):
|
||
point, normal = bundle.vertices_mm[index], bundle.vertex_normals()[index]
|
||
pose = setup.solve(point, normal)
|
||
preview.set_point(point, normal)
|
||
preview.set_pose(pose)
|
||
landing = preview.landing_xy_mm()
|
||
assert landing is not None
|
||
worst = max(worst, float(np.hypot(landing[0] - 7.0, landing[1] - 4.0)))
|
||
assert worst < 1e-9, f"the drawing lands {worst * 1000:.3f} um off the tool"
|
||
|
||
|
||
def test_the_landing_line_reports_the_check(panel):
|
||
"""And the panel prints both pairs, so the operator can see them agree."""
|
||
from orisys_app_shared.i18n import t
|
||
|
||
x, y = _first_surface_pixel(panel.view.flat)
|
||
panel.handle_pick(x, y)
|
||
text = panel.path_label.text()
|
||
assert "X/Y" in text
|
||
assert t("finger.landing_on_tool") in text
|
||
|
||
|
||
def test_the_landing_line_says_so_when_they_disagree(panel):
|
||
"""The branch that would matter: a bug shows up as an off-tool verdict."""
|
||
x, y = _first_surface_pixel(panel.view.flat)
|
||
panel.handle_pick(x, y)
|
||
honest = panel.preview.landing_xy_mm()
|
||
assert honest is not None
|
||
panel.preview.landing_xy_mm = lambda: (honest[0] + 0.25, honest[1]) # type: ignore[assignment]
|
||
panel._refresh_path() # noqa: SLF001
|
||
assert "0.250" in panel.path_label.text()
|
||
|
||
|
||
def test_the_framing_follows_the_planned_path(bundle, preview):
|
||
"""A long move has to fit in the picture, or the move leaves the frame.
|
||
|
||
The body at rest stands at the *end* of the move, so the far end of the picture
|
||
is the start of the path, not the body: the framing has to cover the path's own
|
||
points, which is what this checks. (It used to be enough to compare the radius
|
||
with and without a pose, because the body itself was at the far end.)
|
||
"""
|
||
|
||
stack = PlatformStack()
|
||
preview.set_bundle(bundle)
|
||
preview.resize(900, 640)
|
||
|
||
index = int(np.argmax(np.linalg.norm(bundle.vertices_mm, axis=1)))
|
||
point, normal = bundle.vertices_mm[index], bundle.vertex_normals()[index]
|
||
preview.set_point(point, normal)
|
||
preview.set_pose(stack.solve(point, normal))
|
||
|
||
radius = preview.scene_radius()
|
||
path = preview.trajectory()
|
||
assert path.shape[0] > 2
|
||
# The framing radius must cover the path's own points, not just the body.
|
||
reach = np.linalg.norm(path - preview._focus(), axis=1).max() # noqa: SLF001
|
||
assert radius >= reach - 1e-9
|
||
# Including where the move starts: the contact before the stages turned it.
|
||
start = np.linalg.norm(path[0] - preview._focus()) # noqa: SLF001
|
||
assert radius >= start - 1e-9
|
||
assert start > 0.0
|
||
|
||
|
||
# ------------------------------------ the animation and the drawn path agree
|
||
|
||
|
||
def test_the_drawn_path_ends_where_the_animation_puts_the_contact(bundle, preview):
|
||
"""Two descriptions of one move, so they cannot disagree.
|
||
|
||
The path was sampled up to ``SIM_ROTATION_SHARE`` of the solved rotation — the
|
||
*time* that phase takes, not how far it turns — so the drawn arc stopped 45%
|
||
short and the straight move began from a point the machine never passes through.
|
||
"""
|
||
|
||
stack = PlatformStack()
|
||
preview.set_bundle(bundle)
|
||
index = 7
|
||
point, normal = bundle.vertices_mm[index], bundle.vertex_normals()[index]
|
||
preview.set_point(point, normal)
|
||
preview.set_pose(stack.solve(point, normal))
|
||
|
||
path = preview.trajectory()
|
||
assert np.allclose(path[0], point, atol=1e-9), "the path must start at the contact"
|
||
|
||
_run_simulation(preview)
|
||
landed = preview._placed_contact(point) # noqa: SLF001
|
||
assert np.allclose(path[-1], landed, atol=1e-6)
|
||
|
||
|
||
def test_the_placement_covers_a_point_and_a_whole_vertex_array(bundle, preview):
|
||
"""``_placed`` is handed both, and left-multiplying only worked for one.
|
||
|
||
The camera framing walks the whole mesh through the simulation every frame.
|
||
``rotation @ vertices`` raised for an (N, 3) array, which — inside the paint
|
||
handler — blanked the view the moment playback started.
|
||
"""
|
||
|
||
stack = PlatformStack()
|
||
preview.set_bundle(bundle)
|
||
index = 40
|
||
point, normal = bundle.vertices_mm[index], bundle.vertex_normals()[index]
|
||
preview.set_point(point, normal)
|
||
preview.set_pose(stack.solve(point, normal))
|
||
_run_simulation(preview)
|
||
|
||
placed = preview._placed(bundle.vertices_mm) # noqa: SLF001
|
||
assert placed.shape == bundle.vertices_mm.shape
|
||
for index in (0, 5, 200, bundle.vertices_mm.shape[0] - 1):
|
||
single = preview._placed(bundle.vertices_mm[index]) # noqa: SLF001
|
||
assert np.allclose(placed[index], single)
|
||
|
||
|
||
def test_the_surface_keeps_its_residual_colours_while_the_machine_moves(bundle, preview):
|
||
"""The shading is the measurement, so it stays on through playback.
|
||
|
||
It used to switch to a flat "moving body" grey, which threw away the one thing
|
||
the animation exists to show: the red, hard-to-align patch travelling onto the
|
||
tool axis. The body itself *does* move — from the initial state when playback
|
||
starts to the solved state it rests at — and that is checked here too.
|
||
"""
|
||
|
||
stack = PlatformStack()
|
||
preview.set_bundle(bundle)
|
||
index = 40
|
||
point, normal = bundle.vertices_mm[index], bundle.vertex_normals()[index]
|
||
preview.set_point(point, normal)
|
||
preview.set_pose(stack.solve(point, normal))
|
||
|
||
rest = preview._scene_arrays()[1].copy() # noqa: SLF001
|
||
vertices_rest = preview._scene_arrays()[0].copy() # noqa: SLF001
|
||
|
||
# One frame in, the body is back at the *start* of the move — which is what
|
||
# makes playback a demonstration rather than a no-op, since it rests at the
|
||
# end of that same move.
|
||
preview.start_simulation()
|
||
preview._sim_tick() # noqa: SLF001
|
||
assert not np.allclose(preview._scene_arrays()[0], vertices_rest), ( # noqa: SLF001
|
||
"playback did not take the body back to the start of the move"
|
||
)
|
||
assert np.allclose(preview._scene_arrays()[1], rest), ( # noqa: SLF001
|
||
"the residual shading was replaced as soon as the motion started"
|
||
)
|
||
|
||
_run_simulation(preview)
|
||
moved = preview._scene_arrays()[1] # noqa: SLF001
|
||
vertices_moved = preview._scene_arrays()[0] # noqa: SLF001
|
||
|
||
assert np.allclose(rest, moved), "the residual shading was replaced mid-motion"
|
||
assert np.allclose(vertices_rest, vertices_moved, atol=1e-4), (
|
||
"the body did not come back to the solved state"
|
||
)
|
||
# And the shading still *is* the residual colouring: this bundle aligns
|
||
# everywhere, so the check is against the shading function rather than a
|
||
# hoped-for spread of colours.
|
||
from orisys_autocal.ui.pose_preview import residual_colour
|
||
|
||
assert np.allclose(moved[index], residual_colour(preview._residuals[index])) # noqa: SLF001
|
||
def test_reapplying_the_current_theme_does_not_repolish_all_windows(make_window, qt_app, monkeypatch):
|
||
window = make_window()
|
||
calls = []
|
||
apply_style = qt_app.setStyleSheet
|
||
|
||
def record_style(style):
|
||
calls.append(style)
|
||
apply_style(style)
|
||
|
||
monkeypatch.setattr(qt_app, "setStyleSheet", record_style)
|
||
window._apply_theme()
|
||
assert calls == []
|
||
window._theme = "dark" if window._theme == "light" else "light"
|
||
window._apply_theme()
|
||
assert len(calls) == 1
|