简化 Autocal 界面测试并减少等待开销

用轻量曲面和独立页面验证普通交互,收拢窗口清理,并让后台测试任务获得执行时间。保留原有 90 项用例,113 项相关测试及代码检查通过。
This commit is contained in:
ykcheungab
2026-10-15 21:17:28 +08:00
parent a3aeca1d12
commit 31f6ef3881
4 changed files with 609 additions and 715 deletions
+4 -2
View File
@@ -2,11 +2,13 @@
import time
from PySide6.QtTest import QTest
from PySide6.QtCore import QCoreApplication
def wait_review(widget):
deadline = time.monotonic() + 10
while widget._job is not None and time.monotonic() < deadline:
QTest.qWait(10)
QCoreApplication.processEvents()
# Release the GIL so the Python recording worker can finish its I/O.
time.sleep(.001)
assert widget._job is None, "background review did not finish"
+90 -219
View File
@@ -1,8 +1,7 @@
"""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.
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
@@ -11,27 +10,26 @@ from pathlib import Path
import numpy as np
import pytest
from orisys_autocal.motion.kinematics import PlatformStack
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"
)
pytestmark = pytest.mark.skipif(
not BUNDLE.is_dir(), reason=f"no sensor export at {BUNDLE}"
)
@pytest.fixture(scope="module")
def bundle():
if not BUNDLE.is_dir():
pytest.skip(f"no sensor export at {BUNDLE}")
return load_bundle(BUNDLE)
@@ -40,11 +38,44 @@ 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, bundle, flat):
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()
@@ -71,7 +102,7 @@ def _shrink_raster(monkeypatch, window_mod, size=(256, 256)):
monkeypatch.setattr(window_mod, "rasterise", small)
def test_the_window_loads_the_remembered_bundle_at_startup(qt_app, monkeypatch):
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
@@ -139,7 +170,7 @@ def test_the_preview_legend_is_a_label_not_gl_text(qt_app):
# ------------------------------------------------- the initial view and axes
def test_the_initial_view_is_the_commissioned_orientation(qt_app, bundle):
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
@@ -149,10 +180,7 @@ def test_the_initial_view_is_the_commissioned_orientation(qt_app, bundle):
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()
@@ -177,19 +205,11 @@ def test_the_initial_view_is_the_commissioned_orientation(qt_app, bundle):
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}"
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,
)
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 = PosePreview()
try:
preview.set_bundle(bundle)
preview._yaw = 1.9 # noqa: SLF001 - pretend the operator dragged
preview._pitch = -0.4 # noqa: SLF001
@@ -200,31 +220,21 @@ def test_orbiting_and_then_resetting_returns_to_the_initial_view(qt_app, bundle)
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
def test_the_motion_axes_can_be_turned_off(bundle, preview):
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):
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
from orisys_autocal.ui.pose_preview import PosePreview
preview = PosePreview()
try:
preview.resize(600, 400)
preview.set_bundle(bundle)
stack = PlatformStack()
@@ -257,20 +267,13 @@ def test_the_axis_labels_project_onto_their_arrows(qt_app, bundle):
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):
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."""
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 _frustum_pixel(preview, point_mm) -> tuple[float, float]:
@@ -313,12 +316,9 @@ def _frustum_pixel(preview, point_mm) -> tuple[float, float]:
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(qt_app, bundle):
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."""
from orisys_autocal.ui.pose_preview import PosePreview
preview = PosePreview()
try:
preview.set_bundle(bundle)
preview.resize(900, 640)
stack = PlatformStack()
@@ -339,13 +339,11 @@ def test_the_projection_matches_the_frustum_it_is_drawn_with(qt_app, bundle):
# 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)
finally:
preview.deleteLater()
def test_the_flat_image_is_shown_whole_not_cropped(panel):
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 = panel.view
view = native_panel.view
view.resize(240, 300)
view.set_flat(view.flat)
@@ -357,9 +355,9 @@ def test_the_flat_image_is_shown_whole_not_cropped(panel):
assert shown.width() == pytest.approx(shown.height(), abs=2)
def test_a_click_on_the_scaled_image_maps_to_the_pixel_under_it(panel):
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 = panel.view
view = native_panel.view
view.resize(240, 300)
view.set_flat(view.flat)
shown = view.pixmap()
@@ -417,7 +415,7 @@ def _run_simulation(preview) -> None:
preview._sim_tick() # noqa: SLF001 - stepping the animation deterministically
def test_playing_the_motion_puts_the_normal_on_the_tool_axis(qt_app, bundle):
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
@@ -426,14 +424,11 @@ def test_playing_the_motion_puts_the_normal_on_the_tool_axis(qt_app, bundle):
and checking against it made the test agree with the drawing and with nothing
on the bench.
"""
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
@@ -480,22 +475,16 @@ def test_playing_the_motion_puts_the_normal_on_the_tool_axis(qt_app, bundle):
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):
def test_the_simulation_rotates_first_and_then_translates(bundle, preview):
"""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)
@@ -516,19 +505,13 @@ def test_the_simulation_rotates_first_and_then_translates(qt_app, bundle):
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
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 = PosePreview()
try:
preview.set_bundle(bundle)
preview.set_point(point, bundle.vertex_normals()[0])
preview.set_pose(pose)
@@ -545,23 +528,15 @@ def test_the_simulation_stops_and_can_be_replayed(qt_app, bundle):
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
def test_the_simulation_cannot_start_without_a_pose(bundle, preview):
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()
# ------------------------------------------------------------- the point table
@@ -720,7 +695,7 @@ def test_clearing_the_points_removes_the_markers(panel):
# ------------------------------------------------------- the motion table frame
def test_the_two_rotation_axes_cross_at_one_origin(qt_app, bundle):
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
@@ -728,10 +703,7 @@ def test_the_two_rotation_axes_cross_at_one_origin(qt_app, bundle):
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.
"""
from orisys_autocal.ui.pose_preview import PosePreview
preview = PosePreview()
try:
preview.set_bundle(bundle)
frame = preview.table_frame()
origin = frame["origin"]
@@ -745,21 +717,16 @@ def test_the_two_rotation_axes_cross_at_one_origin(qt_app, bundle):
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
finally:
preview.deleteLater()
def test_the_sensor_base_sits_above_the_origin_by_the_setup_z(qt_app, bundle):
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.
"""
from orisys_autocal.ui.pose_preview import PosePreview
preview = PosePreview()
try:
preview.set_bundle(bundle)
for plate in (0.0, 2.5, 6.0):
preview.set_setup((0.0, 0.0, plate), 0.0)
@@ -771,16 +738,11 @@ def test_the_sensor_base_sits_above_the_origin_by_the_setup_z(qt_app, bundle):
# 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)
finally:
preview.deleteLater()
def test_the_table_frame_normal_is_the_tool_axis(qt_app, bundle):
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."""
from orisys_autocal.ui.pose_preview import PosePreview
preview = PosePreview()
try:
preview.set_bundle(bundle)
frame = preview.table_frame()
# In the mesh's frame, so compare against the tool axis mapped the same way.
@@ -789,20 +751,15 @@ def test_the_table_frame_normal_is_the_tool_axis(qt_app, bundle):
)
expected = expected / np.linalg.norm(expected)
assert np.allclose(frame["plane_normal"], expected, atol=1e-12)
finally:
preview.deleteLater()
def test_the_table_frame_does_not_move_with_the_simulation(qt_app, bundle):
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.
"""
from orisys_autocal.ui.pose_preview import PosePreview
preview = PosePreview()
try:
preview.set_bundle(bundle)
before = {key: np.array(value) for key, value in preview.table_frame().items()}
point = bundle.vertices_mm[0]
@@ -813,23 +770,15 @@ def test_the_table_frame_does_not_move_with_the_simulation(qt_app, bundle):
after = preview.table_frame()
for key, value in before.items():
assert np.allclose(after[key], value), f"{key} moved with the simulation"
finally:
preview.stop_simulation()
preview.deleteLater()
def test_the_table_frame_can_be_hidden(qt_app, bundle):
from orisys_autocal.ui.pose_preview import PosePreview
def test_the_table_frame_can_be_hidden(bundle, preview):
preview = PosePreview()
try:
assert preview.show_table
preview.set_show_table(False)
assert not preview.show_table
preview.set_show_table(True)
assert preview.show_table
finally:
preview.deleteLater()
def test_the_panel_drives_the_base_plane_from_the_sensor_z_field(panel):
@@ -845,21 +794,18 @@ def test_the_panel_drives_the_base_plane_from_the_sensor_z_field(panel):
# ------------------------------------------------------------- the tool path
def test_the_planned_path_starts_at_the_contact_and_ends_on_the_tool(qt_app, bundle):
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.
"""
from orisys_autocal.ui.pose_preview import PosePreview
stack = PlatformStack()
axis = to_sensor(stack.tool_axis_direction, stack.axis_map)
axis = axis / np.linalg.norm(axis)
preview = PosePreview()
try:
preview.set_bundle(bundle)
for index in (0, 7, 40, 200):
point, normal = bundle.vertices_mm[index], bundle.vertex_normals()[index]
@@ -878,11 +824,9 @@ def test_the_planned_path_starts_at_the_contact_and_ends_on_the_tool(qt_app, bun
# 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))
finally:
preview.deleteLater()
def test_the_path_ends_where_the_pose_says_the_table_moves_to(qt_app, bundle):
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 —
@@ -890,15 +834,12 @@ def test_the_path_ends_where_the_pose_says_the_table_moves_to(qt_app, bundle):
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.
"""
from orisys_autocal.ui.pose_preview import PosePreview
stack = PlatformStack(
tool_xy_mm=(6.0, -3.0),
sensor_offset_mm=(1.5, -2.0, 4.0),
sensor_yaw_deg=7.5,
)
preview = PosePreview()
try:
preview.set_stack(stack)
preview.set_bundle(bundle)
preview.set_setup(stack.sensor_offset_mm, stack.sensor_yaw_deg)
@@ -921,23 +862,17 @@ def test_the_path_ends_where_the_pose_says_the_table_moves_to(qt_app, bundle):
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)
finally:
preview.stop_simulation()
preview.deleteLater()
def test_the_table_marker_shows_the_move_against_the_fixed_grid(qt_app, bundle):
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.
"""
from orisys_autocal.ui.pose_preview import PosePreview
stack = PlatformStack()
preview = PosePreview()
try:
preview.set_stack(stack)
preview.set_bundle(bundle)
point, normal = bundle.vertices_mm[40], bundle.vertex_normals()[40]
@@ -962,17 +897,12 @@ def test_the_table_marker_shows_the_move_against_the_fixed_grid(qt_app, bundle):
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)
finally:
preview.deleteLater()
def test_the_path_is_a_rotation_arc_then_a_straight_move(qt_app, bundle):
def test_the_path_is_a_rotation_arc_then_a_straight_move(bundle, preview):
"""Two phases, matching the two things the machine has."""
from orisys_autocal.ui.pose_preview import PosePreview
stack = PlatformStack()
preview = PosePreview()
try:
preview.set_bundle(bundle)
point, normal = bundle.vertices_mm[40], bundle.vertex_normals()[40]
preview.set_point(point, normal)
@@ -994,17 +924,12 @@ def test_the_path_is_a_rotation_arc_then_a_straight_move(qt_app, bundle):
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"
finally:
preview.deleteLater()
def test_the_path_summary_reports_what_judges_validity(qt_app, bundle):
def test_the_path_summary_reports_what_judges_validity(bundle, preview):
"""The numbers, not just the line: reach and arc radius."""
from orisys_autocal.ui.pose_preview import PosePreview
stack = PlatformStack()
preview = PosePreview()
try:
preview.set_bundle(bundle)
assert preview.trajectory_summary() == {}
point, normal = bundle.vertices_mm[0], bundle.vertex_normals()[0]
@@ -1018,33 +943,23 @@ def test_the_path_summary_reports_what_judges_validity(qt_app, bundle):
float(np.linalg.norm(point - origin))
)
assert summary["final_offset_mm"] == pytest.approx(0.0, abs=1e-6)
finally:
preview.deleteLater()
def test_the_path_can_be_hidden(qt_app, bundle):
from orisys_autocal.ui.pose_preview import PosePreview
def test_the_path_can_be_hidden(bundle, preview):
preview = PosePreview()
try:
assert preview.show_trajectory
preview.set_show_trajectory(False)
assert not preview.show_trajectory
finally:
preview.deleteLater()
def test_the_origin_plane_and_the_base_plane_are_separate_surfaces(qt_app, bundle):
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.
"""
from orisys_autocal.ui.pose_preview import PosePreview
preview = PosePreview()
try:
preview.set_bundle(bundle)
preview.set_setup((0.0, 0.0, 0.0), 0.0)
frame = preview.table_frame()
@@ -1055,16 +970,11 @@ def test_the_origin_plane_and_the_base_plane_are_separate_surfaces(qt_app, bundl
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"])
finally:
preview.deleteLater()
def test_the_table_triad_basis_is_well_defined(qt_app, bundle):
def test_the_table_triad_basis_is_well_defined(bundle, preview):
"""A degenerate cross product would orient the plane arbitrarily."""
from orisys_autocal.ui.pose_preview import PosePreview
preview = PosePreview()
try:
preview.set_bundle(bundle)
frame = preview.table_frame()
normal, first, second = frame["plane_normal"], frame["first"], frame["second"]
@@ -1073,8 +983,6 @@ def test_the_table_triad_basis_is_well_defined(qt_app, bundle):
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)
finally:
preview.deleteLater()
def test_a_pick_returns_the_row_it_also_shows(panel):
@@ -1184,13 +1092,14 @@ def test_the_sensor_z_offset_reaches_the_solve(panel):
assert thick_row["Y"] == pytest.approx(thin_row["Y"])
def test_the_mounting_pose_moves_the_point_the_way_the_setup_says(panel):
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)
@@ -1217,7 +1126,15 @@ def test_the_mounting_pose_moves_the_point_the_way_the_setup_says(panel):
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)
assert turned.pitch_deg == pytest.approx(base.pitch_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):
@@ -1306,11 +1223,8 @@ def test_the_press_readout_says_why_it_is_held_and_then_names_the_target(panel):
assert targets["z"] == pytest.approx(2.0 - height)
def test_the_preview_refuses_to_draw_without_a_context(qt_app):
from orisys_autocal.ui.pose_preview import PosePreview
def test_the_preview_refuses_to_draw_without_a_context(preview):
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.
@@ -1319,8 +1233,6 @@ def test_the_preview_refuses_to_draw_without_a_context(qt_app):
# 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):
@@ -1378,7 +1290,7 @@ def test_every_palette_colour_can_be_set_on_a_strict_gl(qt_app):
# --------------------------------------------- the camera frames the whole scene
def test_the_whole_scene_fits_the_widget_at_the_default_zoom(qt_app, bundle):
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
@@ -1386,11 +1298,8 @@ def test_the_whole_scene_fits_the_widget_at_the_default_zoom(qt_app, bundle):
two things that say whether a pose is reachable. A pose whose parts leave the
picture looks like a pose that failed.
"""
from orisys_autocal.ui.pose_preview import PosePreview
width, height = 900, 640
preview = PosePreview()
try:
preview.set_bundle(bundle)
preview.resize(width, height)
points = preview.scene_points()
@@ -1400,11 +1309,9 @@ def test_the_whole_scene_fits_the_widget_at_the_default_zoom(qt_app, bundle):
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}"
finally:
preview.deleteLater()
def test_the_resting_view_shows_the_solved_end_state(qt_app, bundle):
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
@@ -1412,11 +1319,8 @@ def test_the_resting_view_shows_the_solved_end_state(qt_app, bundle):
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.
"""
from orisys_autocal.ui.pose_preview import PosePreview
stack = PlatformStack(tool_xy_mm=(5.0, -2.0))
preview = PosePreview()
try:
preview.set_stack(stack)
preview.set_bundle(bundle)
preview.set_tool_xy(*stack.tool_xy_mm)
@@ -1439,17 +1343,12 @@ def test_the_resting_view_shows_the_solved_end_state(qt_app, bundle):
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)
finally:
preview.deleteLater()
def test_playing_from_the_resting_state_moves_the_body(qt_app, bundle):
def test_playing_from_the_resting_state_moves_the_body(bundle, preview):
"""Play runs the move the resting picture already shows the end of."""
from orisys_autocal.ui.pose_preview import PosePreview
stack = PlatformStack()
preview = PosePreview()
try:
preview.set_bundle(bundle)
index = 40
point, normal = bundle.vertices_mm[index], bundle.vertex_normals()[index]
@@ -1465,27 +1364,21 @@ def test_playing_from_the_resting_state_moves_the_body(qt_app, bundle):
for _ in range(200):
preview._sim_tick() # noqa: SLF001
assert np.allclose(preview._placed_contact(point), resting, atol=1e-6) # noqa: SLF001
finally:
preview.stop_simulation()
preview.deleteLater()
def test_the_landing_check_agrees_with_the_tool(qt_app, bundle):
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".
"""
from orisys_autocal.ui.pose_preview import PosePreview
setup = PlatformStack(
sensor_offset_mm=(6.0, -3.0, 5.0),
sensor_yaw_deg=12.0,
tool_xy_mm=(7.0, 4.0),
)
preview = PosePreview()
try:
preview.set_stack(setup)
preview.set_bundle(bundle)
preview.set_setup(setup.sensor_offset_mm, setup.sensor_yaw_deg)
@@ -1502,8 +1395,6 @@ def test_the_landing_check_agrees_with_the_tool(qt_app, bundle):
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"
finally:
preview.deleteLater()
def test_the_landing_line_reports_the_check(panel):
@@ -1528,7 +1419,7 @@ def test_the_landing_line_says_so_when_they_disagree(panel):
assert "0.250" in panel.path_label.text()
def test_the_framing_follows_the_planned_path(qt_app, bundle):
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
@@ -1536,11 +1427,8 @@ def test_the_framing_follows_the_planned_path(qt_app, bundle):
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.)
"""
from orisys_autocal.ui.pose_preview import PosePreview
stack = PlatformStack()
preview = PosePreview()
try:
preview.set_bundle(bundle)
preview.resize(900, 640)
@@ -1559,25 +1447,20 @@ def test_the_framing_follows_the_planned_path(qt_app, bundle):
start = np.linalg.norm(path[0] - preview._focus()) # noqa: SLF001
assert radius >= start - 1e-9
assert start > 0.0
finally:
preview.deleteLater()
# ------------------------------------ the animation and the drawn path agree
def test_the_drawn_path_ends_where_the_animation_puts_the_contact(qt_app, bundle):
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.
"""
from orisys_autocal.ui.pose_preview import PosePreview
stack = PlatformStack()
preview = PosePreview()
try:
preview.set_bundle(bundle)
index = 7
point, normal = bundle.vertices_mm[index], bundle.vertex_normals()[index]
@@ -1590,22 +1473,17 @@ def test_the_drawn_path_ends_where_the_animation_puts_the_contact(qt_app, bundle
_run_simulation(preview)
landed = preview._placed_contact(point) # noqa: SLF001
assert np.allclose(path[-1], landed, atol=1e-6)
finally:
preview.deleteLater()
def test_the_placement_covers_a_point_and_a_whole_vertex_array(qt_app, bundle):
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.
"""
from orisys_autocal.ui.pose_preview import PosePreview
stack = PlatformStack()
preview = PosePreview()
try:
preview.set_bundle(bundle)
index = 40
point, normal = bundle.vertices_mm[index], bundle.vertex_normals()[index]
@@ -1618,11 +1496,9 @@ def test_the_placement_covers_a_point_and_a_whole_vertex_array(qt_app, bundle):
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)
finally:
preview.deleteLater()
def test_the_surface_keeps_its_residual_colours_while_the_machine_moves(qt_app, bundle):
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
@@ -1630,11 +1506,8 @@ def test_the_surface_keeps_its_residual_colours_while_the_machine_moves(qt_app,
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.
"""
from orisys_autocal.ui.pose_preview import PosePreview
stack = PlatformStack()
preview = PosePreview()
try:
preview.set_bundle(bundle)
index = 40
point, normal = bundle.vertices_mm[index], bundle.vertex_normals()[index]
@@ -1670,8 +1543,6 @@ def test_the_surface_keeps_its_residual_colours_while_the_machine_moves(qt_app,
from orisys_autocal.ui.pose_preview import residual_colour
assert np.allclose(moved[index], residual_colour(preview._residuals[index])) # noqa: SLF001
finally:
preview.deleteLater()
def test_reapplying_the_current_theme_does_not_repolish_all_windows(make_window, qt_app, monkeypatch):
window = make_window()
calls = []
+28 -14
View File
@@ -16,6 +16,22 @@ from orisys_autocal.record_overview import load_overview, overview_rows, read_pl
from orisys_autocal.session_review import review_session
@pytest.fixture
def overview_dialog(qt_app):
"""Keep chart ownership local to the tests that exercise the overview UI."""
from orisys_autocal.ui.record_overview_dialog import RecordOverviewDialog
dialogs = []
def create(directory, **kwargs):
dialog = RecordOverviewDialog(directory, **kwargs)
dialogs.append(dialog)
return dialog
yield create
for dialog in reversed(dialogs):
dialog.close()
dialog.deleteLater()
@pytest.fixture
def overview_session(repeated_session):
root, selections = repeated_session
@@ -105,14 +121,13 @@ def test_overview_requires_all_planned_hold_windows_for_reviewed_status(overview
assert overview_rows(review)[0]["status"] == "reviewed"
def test_overview_gui_plan_click_trial_filter_and_saved_roundtrip(qt_app, overview_session, monkeypatch, tmp_path):
def test_overview_gui_plan_click_trial_filter_and_saved_roundtrip(qt_app, overview_session, monkeypatch, tmp_path, overview_dialog):
from orisys_autocal.ui import record_overview_dialog
from orisys_autocal.ui.session_review_dialog import SessionReviewDialog
root, selections, plan_path = overview_session
dialog = record_overview_dialog.RecordOverviewDialog(root)
dialog.review = review_session(root, selections=selections)
dialog._refresh()
dialog = overview_dialog(
root, review=review_session(root, selections=selections))
monkeypatch.setattr(record_overview_dialog.QFileDialog, "getOpenFileName", lambda *args: (str(plan_path), ""))
dialog.findChild(QAction, "overview_import_plan").trigger()
assert dialog.table.rowCount() == 4
@@ -165,7 +180,6 @@ def test_overview_gui_plan_click_trial_filter_and_saved_roundtrip(qt_app, overvi
dialog.findChild(QAction, "overview_load").trigger()
wait_review(dialog)
assert dialog.table.rowCount() == 4
dialog.close()
def test_calibration_review_action_opens_records_tab(make_window, overview_session, monkeypatch):
@@ -184,16 +198,17 @@ def test_calibration_review_action_opens_records_tab(make_window, overview_sessi
window.close()
def test_records_tab_browse_error_and_reload(make_window, overview_session, monkeypatch, qt_app, tmp_path):
def test_records_tab_browse_error_and_reload(overview_session, monkeypatch, qt_app, tmp_path):
from PySide6.QtCore import Qt
from PySide6.QtTest import QTest
from orisys_autocal.ui.record_overview_dialog import QFileDialog
from PySide6.QtWidgets import QVBoxLayout, QWidget
from orisys_autocal.ui.record_overview_dialog import QFileDialog, RecordOverviewPage
root, _, _ = overview_session
window = make_window()
window = QWidget()
window.resize(1600, 1000)
window.workspace.set_current_key("records")
page = window.workspace.page("records")
page = RecordOverviewPage(window)
QVBoxLayout(window).addWidget(page)
assert page.overview is None and page.path_label.text()
monkeypatch.setattr(QFileDialog, "getExistingDirectory", lambda *args: str(root))
page.open_button.click()
@@ -217,10 +232,10 @@ def test_records_tab_browse_error_and_reload(make_window, overview_session, monk
assert page.overview.save_button.isVisible()
assert window.grab().save(str(tmp_path / "records-tab-small.png"))
window.close()
window.deleteLater()
def test_operator_outline_identity_incomplete_inspection_and_equal_scale(qt_app, overview_session, tmp_path):
from orisys_autocal.ui.record_overview_dialog import RecordOverviewDialog
def test_operator_outline_identity_incomplete_inspection_and_equal_scale(qt_app, overview_session, tmp_path, overview_dialog):
from PySide6.QtCore import Qt
root, _, _ = overview_session
@@ -232,7 +247,7 @@ def test_operator_outline_identity_incomplete_inspection_and_equal_scale(qt_app,
}
manifest["trials"][0].update(status="incomplete", reason="operator stopped")
write_json(root / "session.json", manifest)
dialog = RecordOverviewDialog(root)
dialog = overview_dialog(root)
assert "F-TEST" in dialog.identity.text() and "finger" in dialog.identity.text()
assert dialog.outline_state == "snapshot"
assert dialog.trials.currentData() == "trial-001"
@@ -250,4 +265,3 @@ def test_operator_outline_identity_incomplete_inspection_and_equal_scale(qt_app,
assert (x.max() - x.min()) / area.width() == pytest.approx((y.max() - y.min()) / area.height(), rel=.02)
assert len(dialog.uv_chart.series()) == 5
assert dialog.grab().save(str(tmp_path / "operator-incomplete.png"))
dialog.close()
+23 -16
View File
@@ -19,6 +19,22 @@ def _csv(path, rows):
writer.writerows(rows)
@pytest.fixture
def review_dialog(qt_app):
"""Give each test fresh dialogs and release their chart trees afterwards."""
from orisys_autocal.ui.session_review_dialog import SessionReviewDialog
dialogs = []
def create(directory, **kwargs):
dialog = SessionReviewDialog(directory, **kwargs)
dialogs.append(dialog)
return dialog
yield create
for dialog in reversed(dialogs):
dialog.close()
dialog.deleteLater()
@pytest.fixture
def session(tmp_path):
manifest = {
@@ -106,10 +122,8 @@ def test_review_refuses_mismatched_identity_and_paths_outside_session(session):
review_session(session)
def test_review_dialog_shows_saved_windows_charts_and_new_export(qt_app, session, tmp_path):
from orisys_autocal.ui.session_review_dialog import SessionReviewDialog
dialog = SessionReviewDialog(session)
def test_review_dialog_shows_saved_windows_charts_and_new_export(qt_app, session, tmp_path, review_dialog):
dialog = review_dialog(session)
assert dialog.sites.count() == 2
assert dialog.table.rowCount() == 5
assert dialog.pages.currentIndex() == 0 and dialog.pages.count() == 2
@@ -127,7 +141,6 @@ def test_review_dialog_shows_saved_windows_charts_and_new_export(qt_app, session
dialog.show()
qt_app.processEvents()
assert dialog.grab().save(str(tmp_path / "review.png"))
dialog.close()
def test_reviewed_windows_roundtrip_and_reject_changed_inputs(session):
@@ -168,10 +181,10 @@ def test_invalid_review_selection_does_not_become_verified(session):
}})
def test_operator_confirms_windows_in_dialog_and_reloads_them(qt_app, session, monkeypatch):
def test_operator_confirms_windows_in_dialog_and_reloads_them(session, monkeypatch, review_dialog):
from orisys_autocal.ui import session_review_dialog
dialog = session_review_dialog.SessionReviewDialog(session)
dialog = review_dialog(session)
for row in (0, 1):
dialog.table.selectRow(row)
dialog.end_s.setValue(2)
@@ -182,14 +195,12 @@ def test_operator_confirms_windows_in_dialog_and_reloads_them(qt_app, session, m
dialog.save_button.click()
wait_review(dialog)
saved = next(session.glob("derived/*/review.json"))
fresh = session_review_dialog.SessionReviewDialog(session)
fresh = review_dialog(session)
monkeypatch.setattr(session_review_dialog.QFileDialog, "getOpenFileName",
lambda *args: (str(saved), ""))
fresh.load_button.click()
wait_review(fresh)
assert fresh.review["sites"] == dialog.review["sites"]
dialog.close()
fresh.close()
def test_baseline_image_matches_first_reviewed_zero_frame(session):
@@ -301,15 +312,12 @@ def test_quality_ignores_unconfirmed_data_and_unmatched_loads(repeated_session):
assert quality["hysteresis"] == []
def test_quality_dialog_is_reachable_and_shows_validation_tables(qt_app, repeated_session, monkeypatch, tmp_path):
def test_quality_dialog_is_reachable_and_shows_validation_tables(qt_app, repeated_session, monkeypatch, tmp_path, review_dialog):
from PySide6.QtWidgets import QTableWidget
from orisys_autocal.ui.session_review_dialog import SessionReviewDialog
from orisys_autocal.ui.quality_review_dialog import QualityReviewDialog
directory, selections = repeated_session
review = SessionReviewDialog(directory)
review.review = review_session(directory, selections=selections)
review._show_site()
review = review_dialog(directory, review=review_session(directory, selections=selections))
opened = []
monkeypatch.setattr(QualityReviewDialog, "exec", lambda dialog: opened.append(dialog))
review.quality_button.click()
@@ -328,4 +336,3 @@ def test_quality_dialog_is_reachable_and_shows_validation_tables(qt_app, repeate
qt_app.processEvents()
assert dialog.grab().save(str(tmp_path / "validation.png"))
dialog.close()
review.close()