added motion trajectory simulation

This commit is contained in:
ykcheungab
2026-09-23 09:58:00 +08:00
parent f1bec62ca4
commit 3347d17a20
25 changed files with 5198 additions and 1762 deletions
+26
View File
@@ -81,6 +81,32 @@ def tmp_dir():
shutil.rmtree(path, ignore_errors=True)
@pytest.fixture(autouse=True)
def _isolate_preference_file(monkeypatch):
"""Send every preference write to scratch, whatever the test does.
Not a convenience: a single test that built a window without this redirect
left its own scratch bundle path in the *real* ``apps/autocal/preference.json``
through ``closeEvent``, so the installed app then started up remembering a
bundle that did not exist. A test-side leak that presents as a bench bug is
worth a session-wide net rather than a note in each test.
"""
import shutil
from orisys_app_shared import paths as paths_mod
from orisys_app_shared import prefs as prefs_pkg
from orisys_autocal import preferences as prefs_mod
scratch = _scratch_dir()
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)
yield
shutil.rmtree(scratch, ignore_errors=True)
@pytest.fixture
def make_window(qt_app, monkeypatch):
"""Build a window whose preference writes stay inside a scratch directory.
+1 -1
View File
@@ -62,7 +62,7 @@ _SCOPED_ENUMS = {
"TextFlag",
"AspectRatioMode",
),
"QTableWidget": ("SelectionMode", "EditTrigger"),
"QTableWidget": ("SelectionMode", "EditTrigger", "SelectionBehavior"),
"QSizePolicy": ("Policy",),
"QHeaderView": ("ResizeMode",),
"QMessageBox": ("StandardButton", "Icon"),
File diff suppressed because it is too large Load Diff
+334
View File
@@ -0,0 +1,334 @@
"""The closed-form solve: exactness, agreement with the sweep, and what it reports.
The sweep in ``test_kinematics.py`` is the reference this path is checked against.
It is slow but line-by-line verifiable, and solvers in this file have been
confidently wrong five times; a fast path with no reference is how that happens.
"""
from __future__ import annotations
import math
from pathlib import Path
import numpy as np
import pytest
from orisys_autocal.motion.kinematics import (
PlatformStack,
Branch,
rotation_about,
)
from orisys_autocal.sensor import load_bundle
#: The revision the bench confirmed. Skipped rather than failed when absent: the
#: export lives in a sibling design repo, and a machine without it has not broken
#: AutoCal.
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)
def _random_units(count: int, seed: int) -> list[np.ndarray]:
generator = np.random.default_rng(seed)
vectors = generator.normal(size=(count, 3))
return [vector / np.linalg.norm(vector) for vector in vectors]
def _apply(stack: PlatformStack, unit: np.ndarray, yaw_deg: float, pitch_deg: float) -> np.ndarray:
"""Re-apply a returned pose with the library's own rotations."""
return (
rotation_about(stack.pitch_axis, pitch_deg)
@ rotation_about(stack.yaw_axis, yaw_deg)
@ unit
)
# ------------------------------------------------------------------ exactness
def test_the_closed_form_is_exact_on_random_normals():
"""Two atan2 calls should land on the answer, not near it."""
stack = PlatformStack()
worst = 0.0
for unit in _random_units(2000, seed=20240517):
branches = stack.alignment_branches(unit, stack.tool_direction)
assert branches, f"no branch found for {unit}"
worst = max(worst, min(branch.residual_deg for branch in branches))
assert worst < 1e-4, f"closed form left {worst:.3e} deg on the table"
def test_every_branch_reproduces_its_own_claimed_residual():
"""The number reported must be the number the rotations actually give."""
stack = PlatformStack()
for unit in _random_units(200, seed=7):
for branch in stack.alignment_branches(unit, stack.tool_direction):
landed = _apply(stack, unit, branch.yaw_deg, branch.pitch_deg)
measured = math.degrees(
math.acos(
max(-1.0, min(1.0, float(landed @ stack.tool_direction)))
)
)
# ``acos`` near 1 loses about sqrt(eps) in radians because the
# derivative blows up there; 1e-5 deg is the floating-point floor,
# not slack in the answer.
assert measured == pytest.approx(branch.residual_deg, abs=1e-5)
# ------------------------------------------------------------------ agreement
def test_the_closed_form_is_no_worse_than_the_sweep():
"""The sweep is the reference; the fast path may beat it but never lose."""
stack = PlatformStack()
for unit in _random_units(400, seed=99):
fast = stack._search_alignment(unit, stack.tool_direction) # noqa: SLF001
branches = stack.alignment_branches(unit, stack.tool_direction)
best = min(branch.residual_deg for branch in branches)
assert best <= fast[2] + 1e-9, (
f"closed form left {best:.6f} deg where the sweep found {fast[2]:.6f}"
)
def test_the_two_methods_agree_inside_the_envelope():
stack = PlatformStack()
for unit in _random_units(200, seed=1234):
analytical = stack.solve(np.zeros(3), unit, method="analytical")
search = stack.solve(np.zeros(3), unit, method="search")
assert analytical.residual_deg <= search.residual_deg + 1e-6
for pose in (analytical, search):
assert abs(pose.yaw_deg) <= stack.yaw_limit_deg + 1e-6
assert abs(pose.pitch_deg) <= stack.pitch_limit_deg + 1e-6
# ----------------------------------------------------------- axis equivalence
def test_the_two_pitch_bindings_are_the_same_family():
"""Confirmed at the bench: a 90 degree yaw takes one binding to the other.
``R_y(p)·R_z(t)`` and ``R_x(p)·R_z(t − 90)`` agree to machine precision. So
which axis is called pitch is a naming and zero-offset choice, not a
correctness question — the two reach exactly the same set of alignments.
"""
unit = np.array([0.3, 0.5, -0.81])
unit = unit / np.linalg.norm(unit)
tool = np.array([0.0, 0.0, -1.0])
worst = 0.0
for yaw in (-120.0, -45.0, 0.0, 37.0, 90.0, 150.0):
for pitch in (-90.0, -20.0, 0.0, 15.0, 75.0):
about_y = rotation_about("y", pitch) @ rotation_about("z", yaw) @ unit
about_x = rotation_about("x", pitch) @ rotation_about("z", yaw - 90.0) @ unit
worst = max(worst, abs(float(about_y @ tool) - float(about_x @ tool)))
assert worst == pytest.approx(0.0, abs=1e-12), (
f"the two pitch bindings diverge in alignment by {worst:.3e}"
)
def test_the_offset_between_the_bindings_is_a_constant_ninety_degrees():
"""A constant shift, so it is a commissioning zero rather than a correction."""
unit = np.array([0.3, 0.5, -0.81])
unit = unit / np.linalg.norm(unit)
tool = np.array([0.0, 0.0, -1.0])
for yaw in (-120.0, -30.0, 0.0, 45.0, 120.0):
for pitch in (-90.0, 0.0, 25.0, 75.0):
about_y = rotation_about("y", pitch) @ rotation_about("z", yaw) @ unit
same = rotation_about("x", pitch) @ rotation_about("z", yaw - 90.0) @ unit
assert float(about_y @ tool) == pytest.approx(float(same @ tool), abs=1e-12)
# ---------------------------------------------------------------- reporting
def test_the_normal_is_aligned_to_plus_z_not_minus_z(bundle):
"""The regression that would have caught the sensor being turned over.
``approach_sign`` was ``-1``, which asked the solver to bring every normal onto
**-Z** — the sensing face is the *top* surface of a sensor mounted at the bottom
of its shell, so it points up, and the tool feeds down onto it. Nothing caught
it because ``arccos`` is sign-blind: a face turned fully over still scored
``0.0000`` degrees, and every test here asserted only that the residual was
small. The failure showed up exclusively as the model flipping upside down.
So this asserts the *direction* the normal lands in, which is the thing the
residual cannot express.
"""
stack = PlatformStack()
assert stack.approach_sign == 1
assert np.allclose(stack.tool_direction, [0.0, 0.0, 1.0])
for normal in bundle.vertex_normals():
unit = stack.normal_to_platform(normal)
unit = unit / np.linalg.norm(unit)
pose = stack.solve(np.zeros(3), normal)
# Rotate in the **platform** frame, which is where the residual is measured.
# ``stage_rotation`` is the same rotation conjugated into the mesh's frame,
# so using it here compares a sensor-frame vector against a platform-frame
# target and reports a mismatch that does not exist.
landed = _apply(stack, unit, pose.yaw_deg, pose.pitch_deg)
landed = landed / np.linalg.norm(landed)
# Aligned with the face's aim, not merely parallel to its line.
assert landed[2] > 0.0, (
f"the normal landed on {np.round(landed, 3)} — pointing away from +Z"
)
def test_the_nominal_pose_is_upright(bundle):
"""No rotation is the commissioned pose, and it must read as upright."""
stack = PlatformStack()
assert stack.uprightness(0.0, 0.0) == pytest.approx(1.0)
def test_the_surface_normals_point_up_so_the_face_faces_the_tool(bundle):
"""A sanity check on the geometry the polarity has to agree with.
The mean normal has a positive platform-Z component: the patch faces upward.
If this ever inverted, ``approach_sign`` would need revisiting rather than the
solver.
"""
stack = PlatformStack()
platform = np.array(
[stack.normal_to_platform(n) for n in bundle.vertex_normals()]
)
platform /= np.linalg.norm(platform, axis=1)[:, None]
assert float(platform[:, 2].mean()) > 0.0, (
f"mean platform-Z component of the normals is {platform[:, 2].mean():+.3f}"
)
def test_few_vertices_end_turned_over_and_those_are_edge_on(bundle):
"""Turning the face over is the failure mode; measure it and bound it.
The remainder are vertices whose normals are 60-90 degrees off the tool axis —
the rim of the dome. Aligning an edge-on face has to roll the body about that
normal, and the sign is decided by rounding, so they cannot be pushed to zero
and should not be asserted away.
"""
stack = PlatformStack()
normals = bundle.vertex_normals()
flipped = 0
for normal in normals:
pose = stack.solve(np.zeros(3), normal)
if stack.uprightness(pose.yaw_deg, pose.pitch_deg) < -0.5:
flipped += 1
assert flipped <= len(normals) * 0.03, (
f"{flipped} of {len(normals)} vertices ended turned over"
)
def test_a_pose_reports_whether_it_is_upright():
"""A pose can align the normal and still turn the sensor over."""
stack = PlatformStack()
seen = {True: 0, False: 0}
for unit in _random_units(300, seed=3):
pose = stack.solve(np.zeros(3), unit)
seen[pose.upright] += 1
# Whatever it says must match the measured orientation.
assert pose.upright == (stack.uprightness(pose.yaw_deg, pose.pitch_deg) >= 0.0)
assert seen[True] > 0
def test_an_out_of_range_pose_is_reported_and_never_silently_clamped():
"""Tight travel must give a measured residual, not a pose the stage cannot reach."""
stack = PlatformStack(pitch_limit_deg=45.0)
for unit in _random_units(50, seed=5):
pose = stack.solve(np.zeros(3), unit)
assert abs(pose.pitch_deg) <= 45.0 + 1e-6
assert abs(pose.yaw_deg) <= stack.yaw_limit_deg + 1e-6
if not pose.in_range:
assert "travel" in pose.reason
def test_nothing_is_reported_out_of_range_when_the_travel_is_ample():
stack = PlatformStack()
for unit in _random_units(300, seed=11):
pose = stack.solve(np.zeros(3), unit)
assert pose.in_range, f"{unit} reported out of range at full travel"
assert pose.reason == "" or "indeterminate" in pose.reason
def test_reachability_agrees_with_the_envelope():
"""Every normal is reachable when the yaw can turn a full half-turn."""
stack = PlatformStack()
for unit in _random_units(500, seed=13):
reachable, why = stack.reachable(unit, stack.tool_direction)
assert reachable, f"{unit} unreachable: {why}"
# And with the pitch stage able to turn, no normal is rejected either.
assert stack.reachable(
np.array([0.0, 0.0, 1.0]), stack.tool_direction
)[0]
# --------------------------------------------------------------------- poles
@pytest.mark.parametrize(
"normal",
[
np.array([0.0, 1.0, 0.0]), # on the tool axis
np.array([0.0, -1.0, 0.0]), # on the tool axis, other way
np.array([1.0, 0.0, 0.0]), # on the yaw plane
np.array([0.0, 0.0, 1.0]), # on the yaw axis
],
)
def test_poles_still_return_a_usable_pose(normal):
stack = PlatformStack()
pose = stack.solve(np.zeros(3), normal)
assert pose.ok
assert math.isfinite(pose.yaw_deg) and math.isfinite(pose.pitch_deg)
landed = _apply(stack, stack.normal_to_platform(normal) / np.linalg.norm(
stack.normal_to_platform(normal)
), pose.yaw_deg, pose.pitch_deg)
measured = math.degrees(
math.acos(max(-1.0, min(1.0, float(landed @ stack.tool_direction))))
)
assert measured <= stack.tolerance_deg + 1e-6
def test_the_degenerate_normal_is_still_refused():
stack = PlatformStack()
pose = stack.solve(np.zeros(3), np.zeros(3))
assert not pose.ok
assert "degenerate" in pose.reason
# ----------------------------------------------------------------- selection
def test_select_branch_prefers_in_range_then_upright_then_small():
reachable_upright = Branch(10.0, 5.0, 1e-6, True, True)
reachable_inverted = Branch(10.0, 5.0, 1e-6, False, True)
unreachable_upright = Branch(179.0, 5.0, 1e-6, True, False)
# In range beats everything: an inverted pose the machine can reach is worth
# more than an upright one it cannot.
assert PlatformStack.select_branch(
[unreachable_upright, reachable_inverted, reachable_upright]
) is reachable_upright
# Among reachable poses, upright wins.
assert PlatformStack.select_branch(
[reachable_inverted, reachable_upright]
) is reachable_upright
# Among poses that are out of range and one of them upright, upright wins.
unreachable_inverted = Branch(179.0, 5.0, 1e-6, False, False)
assert PlatformStack.select_branch(
[unreachable_inverted, unreachable_upright]
) is unreachable_upright
# A materially better alignment beats an upright pose.
much_better = Branch(10.0, 5.0, 0.0, False, True)
worse_upright = Branch(10.0, 5.0, 5.0, True, True)
assert PlatformStack.select_branch([much_better, worse_upright]) is much_better
# And with everything else equal, the smaller move wins.
smaller = Branch(5.0, 5.0, 1e-6, True, True)
larger = Branch(60.0, 5.0, 1e-6, True, True)
assert PlatformStack.select_branch([larger, smaller]) is smaller
def test_an_unknown_method_is_refused():
stack = PlatformStack()
with pytest.raises(ValueError):
stack.solve(np.zeros(3), np.array([0.0, 1.0, 0.0]), method="nonsense")
+193 -24
View File
@@ -1,19 +1,20 @@
"""The simulated 5-axis solve: residuals, the stage envelope, and the frames.
The solver is the part of this feature that is easiest to get wrong and hardest
to notice: a wrong derivation still returns two plausible angles. So the central
test here does not check a formula — it brute-forces the stage envelope and
requires the solver to be no worse than the best pose that exists.
The shipped solver is **closed form** (see ``test_ik_analytical.py``, which checks
it against both the sweep and an exhaustive brute force). What this module covers
is everything around it: the mount mapping, the envelope, the frames, the reported
contact point, and the selectable sweep path.
That check is what caught the four wrong versions before it: rotating the
sensor-frame normal instead of the platform-frame one, composing the stages in
the wrong order, a seeded search that skipped the right basin, and a sweep that
ranked yaws by the wrong quantity and so reported a quarter of the patch as
unreachable when all of it aligns.
The solver is the part of this feature that is easiest to get wrong and hardest
to notice — a wrong derivation still returns two plausible angles — so its results
are still checked against a brute-force search, just not with a fine grid over
seven normals when the closed form now guarantees exactness on its own.
"""
from __future__ import annotations
import dataclasses
import numpy as np
import pytest
@@ -47,7 +48,13 @@ NORMALS = (
def _brute_force_best(
stack: PlatformStack, unit_platform: np.ndarray, *, step: float = 0.25
) -> float:
"""The lowest residual any pose in the envelope achieves — by search."""
"""The lowest residual any pose in the envelope achieves — by search.
Kept, but no longer run over the whole grid for every normal: the shipped
solver is closed form and ``test_ik_analytical.py`` checks it against an
exhaustive sweep. This survives as the reference for the *sweep* path, which
is still selectable and still needs a floor to be measured against.
"""
tool = stack.tool_direction
yaw_limit = min(180.0, stack.yaw_limit_deg)
pitch_limit = min(360.0, stack.pitch_limit_deg)
@@ -122,20 +129,61 @@ def test_swapping_the_mapping_changes_the_answer():
def test_the_bench_envelope_is_the_default():
stack = PlatformStack()
assert stack.yaw_limit_deg == 90.0
# Confirmed at the bench and against the trunnion drawing: the vertical stage
# turns a full half-turn either way, the horizontal one turns continuously.
assert stack.yaw_limit_deg == 180.0
assert stack.pitch_limit_deg == 360.0
@pytest.mark.parametrize("normal", NORMALS)
def test_the_solver_is_never_worse_than_a_brute_force_search(normal):
"""The test that pins the derivation down."""
def test_the_nominal_normal_needs_no_rotation_and_lands_on_plus_z():
"""The polarity check, and the anchor for the whole solve.
The sensor sits at the bottom of its shell with the sensing face up, so the
face points along ``+tool_axis`` and the nominal pose is *no rotation at all*.
This is the test that was missing. ``approach_sign`` was ``-1``, aiming every
normal at ``-Z``; the solver dutifully used pitch 180 and turned the sensor
over, and nothing noticed because the residual is sign-blind — a face turned
fully over still scored 0.0000 degrees. Assert the direction landed in, not
just the angle between the lines.
"""
stack = PlatformStack()
assert stack.approach_sign == 1
assert np.allclose(stack.tool_direction, [0.0, 0.0, 1.0])
nominal = np.array([0.0, 1.0, 0.0]) # sensor +Y, the direction the face looks
unit = stack.normal_to_platform(nominal)
unit = unit / np.linalg.norm(unit)
pose = stack.solve(np.array([0.0, 5.0, 0.0]), nominal)
landed = rotation_about(stack.pitch_axis, pose.pitch_deg) @ rotation_about(
stack.yaw_axis, pose.yaw_deg
) @ unit
landed = landed / np.linalg.norm(landed)
assert landed[2] > 0.0, f"the face was aimed at {np.round(landed, 3)}, not +Z"
# And it gets there without turning: the commissioned pose is the identity.
assert abs(pose.pitch_deg) < 1e-6
assert abs(pose.yaw_deg) < 1e-6
assert pose.upright
def test_the_sweep_is_never_worse_than_a_brute_force_search():
"""The sweep path still needs a floor, but one normal is enough to pin it.
The shipped solver is closed form and ``test_ik_analytical.py`` checks it
against an exhaustive sweep over many normals. This keeps the *search* path
honest with one coarse brute force, rather than seven fine ones — the fine
version cost about three seconds per normal to confirm something the closed
form now guarantees outright.
"""
stack = PlatformStack()
normal = NORMALS[4]
unit = stack.normal_to_platform(normal)
unit = unit / np.linalg.norm(unit)
pose = stack.solve(np.array([0.0, 5.0, 0.0]), normal)
best = _brute_force_best(stack, unit, step=0.5)
assert pose.residual_deg <= best + 0.05, (
f"the solver returned {pose.residual_deg:.3f} deg where "
pose = stack.solve(np.array([0.0, 5.0, 0.0]), normal, method="search")
best = _brute_force_best(stack, unit, step=2.0)
assert pose.residual_deg <= best + 0.1, (
f"the sweep returned {pose.residual_deg:.3f} deg where "
f"{best:.3f} deg is achievable"
)
@@ -361,13 +409,134 @@ def test_the_plate_lifts_the_point_along_the_tool_axis():
assert np.allclose(lifted - plain, [0.0, 0.0, 2.5])
def test_the_tool_offset_shifts_the_reported_point():
stack = PlatformStack(tool_offset_mm=(1.0, 2.0, 3.0))
point = np.array([0.0, 5.0, 0.0])
def test_the_tool_position_is_the_same_number_the_move_uses():
"""One field for "where the tool is", not two that can drift apart.
There used to be a separate ``tool_offset_mm`` added to the *reported* contact
while the drawing left the geometry alone: the panel then reported one landing
point and drew another, 10 mm away. The reported move, the drawn move and the
landing check all read ``tool_xy_mm`` now, and this pins that.
"""
stack = PlatformStack(tool_xy_mm=(10.0, -4.0))
point = np.array([2.0, 9.0, 1.0])
normal = np.array([0.3, 0.9, 0.2])
normal = normal / np.linalg.norm(normal)
pose = stack.solve(point, normal)
landed = stack.platform_rotation(pose.yaw_deg, pose.pitch_deg) @ stack.setup_point(point)
final = landed + np.array([pose.table_xy_mm[0], pose.table_xy_mm[1], 0.0])
assert final[0] == pytest.approx(10.0, abs=1e-9)
assert final[1] == pytest.approx(-4.0, abs=1e-9)
# And there is no second place a tool position could hide.
assert not hasattr(stack, "tool_offset_mm")
def test_the_reported_xy_is_where_the_point_is_not_where_the_table_goes():
"""Two different numbers, kept apart on purpose."""
stack = PlatformStack(tool_xy_mm=(3.0, 3.0))
point = np.array([2.0, 5.0, 3.0])
stored = stack.setup_point(point)
pose = stack.solve(point, np.array([0.0, 1.0, 0.0]))
plain = PlatformStack().solve(point, np.array([0.0, 1.0, 0.0]))
assert pose.target_xy_mm[0] == pytest.approx(plain.target_xy_mm[0] + 1.0)
assert pose.target_xy_mm[1] == pytest.approx(plain.target_xy_mm[1] + 2.0)
assert pose.target_xy_mm[0] == pytest.approx(stored[0])
assert pose.target_xy_mm[1] == pytest.approx(stored[1])
# The move is what carries that point to the tool, so it is the rest of the way.
assert pose.table_xy_mm[0] == pytest.approx(3.0 - stored[0])
assert pose.table_xy_mm[1] == pytest.approx(3.0 - stored[1])
# ------------------------------------------------- the setup and the tool position
def test_the_default_tool_is_above_the_table_origin():
"""The default has to be the origin, or every reported move is off by a patch."""
stack = PlatformStack()
assert stack.tool_xy_mm == (0.0, 0.0)
point = np.array([3.0, 8.0, 1.0])
pose = stack.solve(point, np.array([0.0, 1.0, 0.0]))
# With the tool at the origin, the move is the contact's position negated: the
# table has to bring a point that is over there back to the middle.
assert pose.table_xy_mm[0] == pytest.approx(-pose.target_xy_mm[0])
assert pose.table_xy_mm[1] == pytest.approx(-pose.target_xy_mm[1])
def test_the_table_move_lands_the_contact_on_the_tool():
"""The one invariant the whole machine exists to satisfy."""
stack = PlatformStack(
tool_xy_mm=(4.0, -7.0),
sensor_offset_mm=(1.0, 2.0, 3.0),
sensor_yaw_deg=25.0,
)
for point, normal in (
(np.array([2.0, 9.0, 1.0]), np.array([0.3, 0.9, 0.2])),
(np.array([-4.0, 6.0, 3.0]), np.array([-0.6, 0.5, 0.4])),
(np.array([0.0, 11.0, 0.0]), np.array([0.0, 1.0, 0.0])),
):
pose = stack.solve(point, normal / np.linalg.norm(normal))
assert pose.ok
landed = stack.platform_rotation(pose.yaw_deg, pose.pitch_deg) @ stack.setup_point(
point
)
final = landed + np.array([pose.table_xy_mm[0], pose.table_xy_mm[1], 0.0])
assert final[0] == pytest.approx(stack.tool_xy_mm[0], abs=1e-9)
assert final[1] == pytest.approx(stack.tool_xy_mm[1], abs=1e-9)
def test_the_mounting_offset_moves_the_point_and_the_mounting_yaw_the_whole_frame():
"""x/y translate the contact; the yaw turns it about the table origin."""
stack = PlatformStack()
point = np.array([2.0, 6.0, 1.0])
normal = np.array([0.2, 0.95, 0.1])
normal = normal / np.linalg.norm(normal)
base = stack.solve(point, normal)
shifted = dataclasses.replace(stack, sensor_offset_mm=(1.5, -2.5, 0.0))
moved = shifted.solve(point, normal)
assert moved.target_xy_mm[0] - base.target_xy_mm[0] == pytest.approx(1.5)
assert moved.target_xy_mm[1] - base.target_xy_mm[1] == pytest.approx(-2.5)
assert moved.contact_z_mm == pytest.approx(base.contact_z_mm)
turned = dataclasses.replace(stack, sensor_yaw_deg=35.0)
yawed = turned.solve(point, normal)
# A yaw about the table's z keeps the radius from the origin and the height.
assert float(np.hypot(*yawed.target_xy_mm)) == pytest.approx(
float(np.hypot(*base.target_xy_mm)), rel=1e-9
)
assert yawed.contact_z_mm == pytest.approx(base.contact_z_mm)
# The mounting yaw is absorbed by the stage: it has to turn that much less.
difference = (base.yaw_deg - yawed.yaw_deg) % 360.0
assert min(difference, 360.0 - difference) == pytest.approx(35.0, abs=1e-9)
assert yawed.pitch_deg == pytest.approx(base.pitch_deg)
assert yawed.residual_deg == pytest.approx(base.residual_deg, abs=1e-9)
def test_the_setup_z_is_the_mounting_plate():
"""The plate is not a separate input: it is the z of the mounting pose."""
stack = PlatformStack()
point = np.array([1.0, 7.0, 2.0])
normal = np.array([0.1, 0.99, 0.0])
normal = normal / np.linalg.norm(normal)
plain = stack.solve(point, normal)
lifted = dataclasses.replace(stack, sensor_offset_mm=(0.0, 0.0, 4.25))
raised = lifted.solve(point, normal)
assert raised.contact_z_mm - plain.contact_z_mm == pytest.approx(4.25)
assert raised.target_xy_mm == pytest.approx(plain.target_xy_mm)
# And it is the same number as the plate parameter it replaced.
assert lifted.setup_point(point)[2] == pytest.approx(
stack.point_to_platform(point, plate_thickness_mm=4.25)[2]
)
def test_the_setup_normal_is_the_mounting_yaw_applied_to_the_mount():
stack = PlatformStack(sensor_yaw_deg=90.0)
normal = np.array([1.0, 0.0, 0.0])
# Sensor +x is platform +x; a 90 degree mounting yaw sends it to platform +y.
assert np.allclose(stack.setup_normal(normal), [0.0, 1.0, 0.0], atol=1e-12)
assert stack.normal_to_platform(normal) == pytest.approx([1.0, 0.0, 0.0])
def test_a_non_finite_sensor_yaw_is_refused():
with pytest.raises(ValueError):
PlatformStack(sensor_yaw_deg=float("nan")).setup_rotation()
def test_the_approach_sign_is_validated():
-586
View File
@@ -1,586 +0,0 @@
"""The single-page shell, from an operator's point of view.
These assertions are about the layout contract rather than any one panel:
* the motion status strip is outside the tabs, so it survives every tab change;
* all five tabs exist, are labelled, and their pages fit;
* the right-hand readout rail shows every drive and issues no per-axis command;
* the window refuses to shrink below the size at which the page would clip.
The overflow check lives in ``test_panel_smoke.py`` because it needs the colour
tables; what is here is the structure.
"""
from __future__ import annotations
import pytest
from conftest import pump
from orisys_autocal.sequence_io import SequenceFormatError, SequencePoint
from orisys_autocal.ui.workspace import TABS
def _show(window, size=(1600, 900)):
from PySide6.QtWidgets import QApplication
window.resize(*size)
window.show()
QApplication.processEvents()
return window
def test_the_tab_set_is_the_documented_one(make_window):
window = _show(make_window())
assert window.workspace.page_keys() == [key for key, _ in TABS]
def test_every_tab_has_a_localised_label(make_window):
from orisys_app_shared.i18n import t
window = _show(make_window())
for index, (_key, locale_key) in enumerate(TABS):
label = window.workspace.tabText(index)
assert label, f"tab {index} has no label"
assert label == t(locale_key)
assert label != locale_key, f"tab {index} shows its raw key"
def test_the_status_strip_survives_every_tab_change(make_window):
"""The whole point of putting the strip above the tabs."""
window = _show(make_window())
for key in window.workspace.page_keys():
window.workspace.set_current_key(key)
assert window.status_strip.isVisible(), f"the strip hid on tab {key}"
assert window.status_strip.height() <= 48
def test_the_strip_reports_connection_and_bus_state(make_window):
from orisys_app_shared.i18n import t
from orisys_autocal.motion import BusStatus, Snapshot
window = _show(make_window())
strip = window.status_strip
strip.set_snapshot(Snapshot(connected=False))
assert strip.chip_connection.text() == t("connection.state_closed")
assert strip.chip_init.text() == t("bus.state_pending")
strip.set_snapshot(
Snapshot(connected=True, bus=BusStatus(bus_type="ecat", init_status=1, total_axes=5))
)
assert strip.chip_connection.text() == t("connection.state_open")
assert strip.chip_bus.text() == t("bus.type_ecat")
assert strip.chip_init.text() == t("bus.state_ok")
def test_a_refusal_stays_visible_on_the_strip(make_window):
"""A command refused while the operator was on another tab must not vanish."""
window = _show(make_window())
window.workspace.set_current_key("sensor")
window.status_strip.set_action_result("轴0 回零模式未设置", False)
assert "回零模式" in window.status_strip.action_label.text()
def test_the_readout_rail_covers_every_axis(make_window):
window = _show(make_window())
assert len(window.axis_readout._rows) == window.axis_panel.axis_count == 5
def test_the_readout_rail_shows_position_and_state(make_window):
from orisys_autocal.motion import AxisState, Snapshot
window = _show(make_window())
snapshot = Snapshot(
connected=True,
axes=(
AxisState(axis=0, enabled=True, units=10.0, dpos=1000.0, idle=True),
AxisState(axis=1, enabled=False, units=10.0, dpos=0.0, idle=True),
),
)
window.axis_readout.update_snapshot(snapshot)
# 1000 user units at units=10 is the documented 2.000 mm, and the readout
# shows both so an operator never has to convert in their head.
text = window.axis_readout._rows[0]["position"].text()
assert "1000.000" in text and "2.000 mm" in text, text
assert window.axis_readout._rows[1]["state"].text()
def test_the_readout_rail_issues_no_per_axis_motion(make_window):
"""Per-axis commands live in one place only: the Axis-debug tab."""
window = _show(make_window())
rail = window.axis_readout
assert not hasattr(rail, "jogRequested")
assert not hasattr(rail, "moveAbsRequested")
assert hasattr(rail, "stopAllRequested")
assert hasattr(rail, "enableAllRequested")
def test_enable_all_toggles_with_the_current_state(make_window):
from orisys_autocal.motion import AxisState, Snapshot
window = _show(make_window())
requested: list[bool] = []
window.axis_readout.enableAllRequested.connect(requested.append)
window.axis_readout.update_snapshot(
Snapshot(connected=True, axes=tuple(AxisState(axis=a, enabled=True) for a in range(5)))
)
window.axis_readout.enable_all_button.click()
assert requested == [False], "enable-all did not become disable-all"
window.axis_readout.update_snapshot(Snapshot(connected=True, axes=()))
window.axis_readout.enable_all_button.click()
assert requested == [False, True]
def test_the_window_refuses_to_shrink_below_the_page(make_window):
window = _show(make_window())
window.resize(800, 600)
assert window.width() >= window.minimumWidth()
assert window.height() >= window.minimumHeight()
assert window.minimumWidth() >= 1300
def test_the_bus_data_group_starts_collapsed(make_window):
"""It is commissioning-only; expanded, it cost the page ~270 px."""
window = _show(make_window())
assert not window.bus_data_group.isChecked()
assert not window.bus_data_panel.isVisible()
def test_expanding_bus_data_does_not_resize_the_window(make_window):
window = _show(make_window())
before = (window.width(), window.height())
window.bus_data_group.setChecked(True)
pump(_app(), 0.1)
assert (window.width(), window.height()) == before
def _app():
from PySide6.QtWidgets import QApplication
return QApplication.instance()
def test_the_sequence_tab_shows_the_documented_profile_fields(make_window):
from orisys_autocal.ui.sequence_panel import PROFILE_FIELDS
window = _show(make_window())
panel = window.sequence_panel
assert panel.profile_keys() == [key for key, _locale, _kind in PROFILE_FIELDS]
def test_the_sequence_panel_exposes_its_three_zones(make_window):
window = _show(make_window())
panel = window.sequence_panel
assert panel.validation_checks() == [
"on_surface",
"within_travel",
"contact_confirmable",
]
assert panel.point_row_count() > 0, "the point list is empty"
assert panel.summary_label.text()
def test_every_pending_control_names_the_phase_that_owns_it(make_window):
"""A disabled control must say why, or it reads as a broken button."""
window = _show(make_window())
panel = window.sequence_panel
assert panel.pending_controls() == ["generate", "simulate", "dry_run"]
for name, phase in panel.pending_phases().items():
button = panel._pending_buttons[name]
assert not button.isEnabled(), f"{name} is usable but not implemented"
assert phase in button.toolTip(), f"{name} does not name its phase"
assert button.text(), f"{name} has no label"
def test_import_and_export_are_live_controls(make_window):
"""A sequence is a document, and P8 says it is a versioned artifact."""
window = _show(make_window())
panel = window.sequence_panel
for name in ("export", "import"):
button = panel._live_buttons[name]
assert button.isEnabled(), f"{name} is not usable"
assert button.text(), f"{name} has no label"
assert button.toolTip()
def test_the_point_list_has_no_empty_action_column(make_window):
"""An empty action cell reads as a broken control."""
window = _show(make_window())
panel = window.sequence_panel
assert "actions" not in panel.column_keys()
def test_the_sequence_tab_hands_out_no_runnable_steps(make_window):
"""A runner must not be able to start from points nothing has validated."""
window = _show(make_window())
assert window.sequence_panel.planned_steps() == []
def test_point_coordinates_are_generated_not_typed(make_window):
"""A typed coordinate is one nothing has checked against the sensor."""
from orisys_autocal.ui.sequence_panel import _EDITABLE_POINT_COLUMNS
assert _EDITABLE_POINT_COLUMNS == ()
window = _show(make_window())
panel = window.sequence_panel
# The point list is a real table, so it scrolls instead of clipping rows.
assert panel.table.rowCount() == panel.point_row_count()
assert panel.point_row(0)["x"] == "0.000"
# Every cell is read-only: the table's edit triggers are off, so a generated
# coordinate cannot be typed over.
from PySide6.QtWidgets import QTableWidget
assert panel.table.editTriggers() == QTableWidget.EditTrigger.NoEditTriggers
def test_the_profile_round_trips_through_preferences(make_window):
window = _show(make_window())
panel = window.sequence_panel
panel.field("pitch_mm").setText("3.5")
panel.field("repeats").setText("4")
window._persist_prefs()
stored = window.prefs["sequence_profile"]
assert stored["pitch_mm"] == 3.5
assert stored["repeats"] == 4
def test_an_unusable_profile_number_falls_back_rather_than_raising(make_window):
from orisys_autocal.preferences import default_preferences
window = _show(make_window())
panel = window.sequence_panel
panel.field("pitch_mm").setText("not a number")
assert panel.profile()["pitch_mm"] == 0.0
# A blank repeats field must not become zero repeats.
panel.field("repeats").setText("")
assert panel.profile()["repeats"] >= 1
assert "pitch_mm" in default_preferences()["sequence_profile"]
def test_the_stored_profile_is_loaded_into_the_form(make_window):
window = _show(make_window(sequence_profile={"pitch_mm": 7.5, "order": "row"}))
panel = window.sequence_panel
assert panel.profile()["pitch_mm"] == 7.5
assert panel.profile()["order"] == "row"
def test_the_axis_table_shows_seven_columns(make_window):
from orisys_autocal.ui.axis_panel import COLUMNS
window = _show(make_window())
assert len(COLUMNS) == 7
assert window.axis_panel.table.columnCount() == 8 # seven data + actions
def test_the_axis_detail_line_carries_what_the_table_dropped(make_window):
"""Axis type, accel, feedback and the status word must stay reachable."""
from orisys_autocal.motion import AxisState, Snapshot
window = _show(make_window())
window.axis_panel.set_selected_axis(3)
window.axis_panel.update_snapshot(
Snapshot(
connected=True,
axes=(AxisState(axis=3, enabled=True, atype=65, accel=1234.0, mpos=5.0),),
)
)
detail = window.axis_panel._detail_label.text()
assert "65" in detail and "1234" in detail
assert "3" in detail
def test_the_axis_detail_follows_the_selected_axis(make_window):
from orisys_autocal.motion import AxisState, Snapshot
window = _show(make_window())
window.axis_panel.update_snapshot(
Snapshot(
connected=True,
axes=(AxisState(axis=0, atype=65), AxisState(axis=1, atype=67)),
)
)
window.axis_panel.set_selected_axis(0)
assert "65" in window.axis_panel._detail_label.text()
window.axis_panel.set_selected_axis(1)
assert "67" in window.axis_panel._detail_label.text()
def test_the_window_defaults_to_the_design_resolution(make_window):
from orisys_autocal.ui.window import DEFAULT_WINDOW_SIZE, MINIMUM_WINDOW_SIZE
assert DEFAULT_WINDOW_SIZE == (1920, 1080)
assert MINIMUM_WINDOW_SIZE[0] >= 1440
def test_the_default_theme_is_light(make_window):
from orisys_autocal.preferences import DEFAULT_THEME
assert DEFAULT_THEME == "light"
window = _show(make_window())
assert window._theme == "light"
def test_pausing_the_strip_pauses_the_poller(make_window):
window = _show(make_window())
window.status_strip.pause_button.setChecked(True)
window._on_pause_polling()
assert window.poller.paused
window.status_strip.pause_button.setChecked(False)
window._on_pause_polling()
assert not window.poller.paused
def test_the_diagnostics_footer_does_not_move_the_layout(make_window):
window = _show(make_window())
splitters = window.main_splitter.sizes()
window._set_diagnostics(True)
window._set_diagnostics(False)
assert window.main_splitter.sizes() == splitters
def test_splitter_sizes_round_trip_through_preferences(make_window):
window = _show(make_window())
window.main_splitter.setSizes([340, 1500])
window._persist_prefs()
stored = window.prefs["main_splitter_sizes"]
assert len(stored) == 2
assert stored[1] > stored[0]
def test_the_rail_is_a_single_scrollable_column(make_window):
"""Two thin rails bracketing the workspace cost it 240 px at every size."""
from orisys_autocal.ui.window import MAIN_SPLITTER_SIZES
assert len(MAIN_SPLITTER_SIZES) == 2, "the layout is back to three columns"
window = _show(make_window())
assert window.main_splitter.count() == 2
assert window.left_rail.widget() is window.rail_content
# The readout lives in the rail now, not in a rail of its own.
assert window.rail_content.findChild(type(window.axis_readout)) is window.axis_readout
def test_the_rail_scrolls_rather_than_clipping_its_controls(make_window):
"""At 900 px high the three rail groups do not fit; the rail must scroll."""
window = _show(make_window(), (1440, 900))
viewport = window.left_rail.viewport().height()
content = window.rail_content.sizeHint().height()
scroll = window.left_rail.verticalScrollBar()
assert content <= viewport or scroll.maximum() > 0, (
f"rail content is {content}px in a {viewport}px viewport and cannot scroll"
)
# --------------------------------------------------------------- run controls
def test_the_run_bar_is_visible_from_every_tab(make_window):
"""Abort must never require finding the tab that started the run."""
window = _show(make_window())
for key in window.workspace.page_keys():
window.workspace.set_current_key(key)
assert window.run_bar.isVisible(), f"the run bar hid on tab {key}"
def test_run_is_wired_and_refuses_by_naming_its_phase(make_window):
window = _show(make_window())
window.run_bar.run_button.click()
assert "P7" in window.status_strip.action_label.text()
def test_abort_stops_the_axes_and_is_enabled_once_a_run_exists(make_window):
window = _show(make_window())
assert not window.run_bar.abort_button.isEnabled()
requested = []
window.run_bar.abortRequested.connect(lambda: requested.append(True))
window.run_bar.set_run(True)
assert window.run_bar.abort_button.isEnabled()
window.run_bar.abort_button.click()
assert requested == [True]
assert window.run_bar.phase == "failed"
def test_the_run_bar_shows_progress_and_timing(make_window):
window = _show(make_window())
window.run_bar.set_progress(7, 40)
assert "7" in window.run_bar.step_label.text()
assert "40" in window.run_bar.step_label.text()
assert window.run_bar.progress.maximum() == 40
assert window.run_bar.progress.value() == 7
window.run_bar.set_timing(125.0, 300.0)
assert "2:05" in window.run_bar.time_label.text()
# An unknown estimate must say so rather than showing a wrong number.
window.run_bar.set_timing(125.0, None)
assert "2:05" in window.run_bar.time_label.text()
# ---------------------------------------------------------------- poll health
def test_the_health_chip_reports_the_age_of_the_last_reading(make_window):
from orisys_autocal.motion import Snapshot
window = _show(make_window())
strip = window.status_strip
strip.set_snapshot(Snapshot(connected=True))
assert strip.chip_health.text()
assert strip.chip_health._tone == "ok"
def test_the_health_chip_ages_when_the_poll_stops(make_window):
"""A stalled poll must not keep looking healthy."""
from orisys_autocal.motion import Snapshot
window = _show(make_window())
strip = window.status_strip
strip.set_poll_interval_ms(100)
strip.set_snapshot(Snapshot(connected=True))
strip._last_snapshot_at -= 1.5 # fifteen intervals ago
strip.refresh_health()
assert strip.chip_health._tone == "error", strip.chip_health.text()
def test_the_health_chip_says_paused_rather_than_ageing(make_window):
from orisys_autocal.motion import Snapshot
window = _show(make_window())
strip = window.status_strip
strip.set_snapshot(Snapshot(connected=True))
strip.set_paused(True)
strip.refresh_health()
assert strip.chip_health._tone == "muted"
assert strip.chip_health.text() != ""
def test_the_status_bar_uses_its_own_theme_token(make_window):
"""The brand navy reads well as a header and heavy as a footer."""
from orisys_app_shared.theme import load_colors
for theme in ("light", "dark"):
colours = load_colors(theme)
assert "status_bar_bg" in colours, theme
assert "status_bar_text" in colours, theme
window = _show(make_window())
assert "QStatusBar" in window.statusBar().styleSheet()
def test_the_sequence_summary_reports_count_and_span(make_window):
"""The two numbers an operator reasons with, before anything runs."""
window = _show(make_window())
panel = window.sequence_panel
panel.set_points(
[
SequencePoint(id=1, target_mm={"x": 0.0, "y": 0.0, "z": 12.4}),
SequencePoint(id=2, target_mm={"x": 10.0, "y": 0.0, "z": 12.4}),
]
)
text = panel.summary_label.text()
assert "2" in text
# A nil Z span prints once, and the real X span prints both ends.
assert "0.000–10.000" in text
assert "12.400" in text
def test_the_sequence_summary_updates_with_the_profile(make_window):
window = _show(make_window())
panel = window.sequence_panel
panel.set_points([SequencePoint(id=1, target_mm={"x": 0.0, "y": 0.0, "z": 1.0})])
before = panel.summary_label.text()
panel.field("repeats").setText("10")
assert panel.summary_label.text() != before
def test_an_empty_sequence_says_so_rather_than_showing_zeros(make_window):
window = _show(make_window())
panel = window.sequence_panel
panel.set_points([])
assert panel.summary_label.text()
assert "0" not in panel.summary_label.text()[:2]
def test_the_design_is_dirty_until_it_is_saved_or_loaded(make_window):
"""Import replaces the design, and there is no autosave."""
window = _show(make_window())
panel = window.sequence_panel
panel.mark_clean()
assert not panel.dirty
panel.field("pitch_mm").setText("9")
assert panel.dirty, "a profile edit did not mark the design dirty"
def test_export_then_import_restores_the_design(make_window, tmp_dir):
"""The round trip through the real panel, not just the format module."""
window = _show(make_window())
panel = window.sequence_panel
panel.set_name("bench sweep")
panel.field("pitch_mm").setText("3.5")
panel.set_points(
[
SequencePoint(id=1, target_mm={"x": 0.0, "y": 0.0, "z": 12.4}),
SequencePoint(id=2, target_mm={"x": 3.5, "y": 0.0, "z": 12.4}),
]
)
path = tmp_dir / "sweep.oryseq"
window.export_sequence(str(path))
assert path.is_file()
assert not panel.dirty, "export did not clear the dirty flag"
# Change everything, then import it back.
panel.set_name("")
panel.field("pitch_mm").setText("1")
panel.set_points([])
document = window.import_sequence(str(path))
assert document.point_count == 2
assert panel.name() == "bench sweep"
assert panel.profile()["pitch_mm"] == 3.5
assert panel.point_row_count() == 2
assert not panel.dirty
def test_importing_a_bad_file_reports_and_changes_nothing(make_window, tmp_dir):
window = _show(make_window())
panel = window.sequence_panel
panel.field("pitch_mm").setText("4")
path = tmp_dir / "bad.oryseq"
path.write_text("not json at all", encoding="utf-8")
with pytest.raises(SequenceFormatError) as excinfo:
window.import_sequence(str(path))
assert "not valid JSON" in str(excinfo.value)
# The design is untouched: a refused import is not a partial import.
assert panel.profile()["pitch_mm"] == 4
def test_an_exported_sequence_names_the_sensor_it_was_designed_for(make_window, tmp_dir):
import json
window = _show(make_window())
window.prefs["sensor_setup"] = {
"sensor_type": "finger",
"geometry_id": "finger-1.0.0",
"probe_tip_set": True,
}
path = tmp_dir / "named.oryseq"
window.export_sequence(str(path))
document = json.loads(path.read_text(encoding="utf-8"))
assert document["device"]["sensor_type"] == "finger"
assert document["device"]["geometry_id"] == "finger-1.0.0"
assert document["versions"]["app"]
def test_language_switch_relabels_the_new_shell(make_window):
from orisys_app_shared.i18n import t
window = _show(make_window(language="zh_CN"))
zh_tabs = [window.workspace.tabText(i) for i in range(window.workspace.count())]
window._set_language("en_US")
assert window.status_strip.title.text() == t("status.motion")
assert window.run_bar.title.text() == t("run.title")
en_tabs = [window.workspace.tabText(i) for i in range(window.workspace.count())]
assert en_tabs != zh_tabs
assert all(en_tabs)
-328
View File
@@ -1,328 +0,0 @@
"""Guard and state-machine behaviour of the motion service.
Driven against the simulated platform, so every assertion here is about AutoCal's
own logic: what it refuses, what it reads, and when. No test in this file touches
a controller or a DLL.
"""
from __future__ import annotations
import time
import pytest
from orisys_autocal.motion import (
INIT_FAILED,
INIT_OK,
INIT_RUNNING,
AxisParams,
ConnectionSpec,
DemoTransport,
MotionService,
)
@pytest.fixture
def service():
return MotionService(DemoTransport())
def _initialise(service, timeout: float = 5.0) -> None:
"""Download a BAS program and wait for the simulated bus to report ready."""
service.download_bas(__file__, 1)
deadline = time.monotonic() + timeout
while time.monotonic() < deadline and service.bus.init_status != INIT_OK:
service.poll([0])
time.sleep(0.05)
# ----------------------------------------------------------------- connection
def test_a_fresh_service_is_disconnected_and_touches_nothing(service):
assert not service.connected
assert not service.bas_loaded
assert service.poll([0]).connected is False
def test_connect_reports_success_and_an_unknown_bus_without_a_bas(service):
result = service.connect(ConnectionSpec(mode="demo"))
assert result.ok
assert service.connected
# No BAS program has run, so the bus type must not be guessed at.
snapshot = service.poll([0])
assert snapshot.bus.bus_type == "unknown"
assert not snapshot.bus.initialized
def test_connect_failure_is_reported_without_raising(service):
result = service.connect(ConnectionSpec(mode="eth", ip="127.0.0.1"))
assert not result.ok
assert not service.connected
def test_disconnect_clears_bus_state(service):
service.connect(ConnectionSpec(mode="demo"))
service.disconnect()
assert not service.connected
assert service.bus.bus_type == "unknown"
# ---------------------------------------------------------------- bus init
def test_download_requires_a_real_file(service):
service.connect(ConnectionSpec(mode="demo"))
assert not service.download_bas("", 1).ok
assert not service.download_bas(r"D:\nope\missing.bas", 1).ok
def test_download_needs_a_connection(service):
assert not service.download_bas(__file__, 1).ok
def test_download_then_poll_reaches_initialised(service):
service.connect(ConnectionSpec(mode="demo"))
assert service.download_bas(__file__, 1).ok
_initialise(service)
assert service.bus.init_status == INIT_OK
assert service.bus.bus_type == "ecat"
assert service.bus.total_axes == 5
assert service.bus.node_count == 5
def test_bus_is_running_immediately_after_download(service):
service.connect(ConnectionSpec(mode="demo"))
service.download_bas(__file__, 1)
assert service.bus.init_status == INIT_RUNNING
def test_reinitialise_is_refused_while_already_running(service):
service.connect(ConnectionSpec(mode="demo"))
service.download_bas(__file__, 1)
assert not service.reinit_bus().ok
def test_reinitialise_is_refused_without_a_bas_program(service):
service.connect(ConnectionSpec(mode="demo"))
result = service.reinit_bus()
assert not result.ok
assert "BAS" in result.message or "Bas" in result.message
def test_init_timeout_is_reported_rather_than_raising():
"""A controller that never publishes its globals must time out, not hang."""
class SilentTransport(DemoTransport):
def user_var(self, name):
from orisys_app_shared.zmotion import ZAuxError
raise ZAuxError(30002, f"{name} is not defined")
service = MotionService(SilentTransport())
service._init_timeout = 0.05 # noqa: SLF001 - shortening the vendor window
service.connect(ConnectionSpec(mode="demo"))
service.download_bas(__file__, 1)
time.sleep(0.1)
snapshot = service.poll([0])
assert snapshot.bus.init_status == INIT_FAILED
assert snapshot.bus.message # carries the ZDevelop hint, not an empty string
# ------------------------------------------------------------------- guards
def test_motion_is_refused_before_the_bus_is_initialised(service):
service.connect(ConnectionSpec(mode="demo"))
for attempt in (
service.jog(0, True),
service.move_abs(0, 100.0),
service.move_rel(0, 10.0),
service.home(0),
):
assert not attempt.ok
assert attempt.message
def test_motion_is_refused_when_not_connected(service):
assert not service.jog(0, True).ok
assert not service.move_abs(0, 1.0).ok
assert not service.stop(0).ok
def test_motion_on_an_unknown_axis_is_refused(service):
service.connect(ConnectionSpec(mode="demo"))
_initialise(service)
result = service.jog(99, True)
assert not result.ok
assert "99" in result.message or "0-4" in result.message
@pytest.mark.parametrize("bad", [float("nan"), float("inf"), float("-inf")])
def test_a_non_finite_target_is_refused(service, bad):
service.connect(ConnectionSpec(mode="demo"))
_initialise(service)
result = service.move_abs(0, bad)
assert not result.ok
assert result.message
def test_home_needs_a_configured_mode(service):
"""0 is not a valid homing mode; inheriting one would be worse than saying so."""
service.connect(ConnectionSpec(mode="demo"))
_initialise(service)
result = service.home(0)
assert not result.ok
assert "0" in result.message
def test_home_runs_once_a_mode_is_set(service):
service.connect(ConnectionSpec(mode="demo"))
_initialise(service)
service.set_axis_params(0, AxisParams(home_mode=17))
assert service.home(0).ok
def test_home_is_refused_on_rtex(service):
"""The vendor example refuses RTEX homing; so do we."""
class RtexTransport(DemoTransport):
def user_var(self, name):
return 1.0 if name == "BUS_TYPE" else super().user_var(name)
rtex = MotionService(RtexTransport())
rtex.connect(ConnectionSpec(mode="demo"))
_initialise(rtex)
assert rtex.bus.bus_type == "rtex"
result = rtex.home(0)
assert not result.ok
assert result.message
# ------------------------------------------------------------------- motion
def test_jog_and_stop_move_the_state_machine(service):
service.connect(ConnectionSpec(mode="demo"))
_initialise(service)
assert service.jog(0, True).ok
state = service.poll([0]).axis(0)
assert state is not None and not state.idle
assert service.stop(0).ok
state = service.poll([0]).axis(0)
assert state is not None and state.idle
def test_absolute_and_relative_moves_change_the_command_position(service):
service.connect(ConnectionSpec(mode="demo"))
_initialise(service)
assert service.move_abs(1, 250.0).ok
assert service.poll([1]).axis(1).dpos == pytest.approx(250.0)
assert service.move_rel(1, -50.0).ok
assert service.poll([1]).axis(1).dpos == pytest.approx(200.0)
def test_set_axis_params_writes_and_remembers(service):
service.connect(ConnectionSpec(mode="demo"))
_initialise(service)
params = AxisParams(units=5.0, speed=200.0, accel=2000.0)
assert service.set_axis_params(2, params).ok
assert service.params(2) == params
assert service.to_mm(2, 1000.0) == pytest.approx(1.0)
def test_enable_toggles_the_axis(service):
service.connect(ConnectionSpec(mode="demo"))
_initialise(service)
assert service.set_axis_enable(3, False).ok
assert service.poll([3]).axis(3).enabled is False
assert service.set_axis_enable(3, True).ok
assert service.poll([3]).axis(3).enabled is True
def test_stop_all_covers_every_axis(service):
service.connect(ConnectionSpec(mode="demo"))
_initialise(service)
results = service.stop_all()
assert len(results) == service.axis_count
assert all(result.ok for result in results)
def test_clear_alarm_reaches_the_drive(service):
service.connect(ConnectionSpec(mode="demo"))
_initialise(service)
assert service.clear_alarm(0).ok
# ----------------------------------------------------------------- bus data
def test_sdo_round_trips_on_ethercat(service):
service.connect(ConnectionSpec(mode="demo"))
_initialise(service)
assert service.sdo_write(0, 0x6040, 0, 3, 6).ok
result, value = service.sdo_read(0, 0x6040, 0, 3)
assert result.ok
assert value == 6
def test_sdo_is_refused_on_rtex(service):
class RtexTransport(DemoTransport):
def user_var(self, name):
return 1.0 if name == "BUS_TYPE" else super().user_var(name)
rtex = MotionService(RtexTransport())
rtex.connect(ConnectionSpec(mode="demo"))
_initialise(rtex)
result, _value = rtex.sdo_read(0, 0x6040, 0, 3)
assert not result.ok
def test_rtex_is_refused_on_ethercat(service):
service.connect(ConnectionSpec(mode="demo"))
_initialise(service)
result, _value = service.rtex_read(0, 1)
assert not result.ok
def test_bus_data_is_refused_before_the_bus_type_is_known(service):
service.connect(ConnectionSpec(mode="demo"))
result, _value = service.sdo_read(0, 0x6040, 0, 3)
assert not result.ok
# ------------------------------------------------------------------- polling
def test_polling_reports_every_requested_axis(service):
service.connect(ConnectionSpec(mode="demo"))
snapshot = service.poll([0, 1, 2, 3, 4])
assert [state.axis for state in snapshot.axes] == [0, 1, 2, 3, 4]
assert all(state.read_ok for state in snapshot.axes)
def test_a_read_failure_is_marked_not_silently_zeroed():
"""A failed read must not look like a position of zero."""
class FailingReads(DemoTransport):
def get_dpos(self, axis):
from orisys_app_shared.zmotion import ZAuxError
raise ZAuxError(30000, "no response")
service = MotionService(FailingReads())
service.connect(ConnectionSpec(mode="demo"))
snapshot = service.poll([0])
state = snapshot.axis(0)
assert state is not None
assert state.read_ok is False
assert state.error
def test_language_switches_the_refusal_text(service):
assert not service.jog(0, True).ok
zh = service.jog(0, True).message
service.set_language("en_US")
en = service.jog(0, True).message
assert zh != en
-331
View File
@@ -1,331 +0,0 @@
"""Offscreen UI smoke tests: construction, layout, gating, and preferences.
The window is built for real, with the simulated platform standing in for the
controller. Nothing here needs a display or hardware.
"""
from __future__ import annotations
import pytest
from conftest import pump, visible_descendants
RESOLUTIONS = ((1440, 900), (1600, 900), (1920, 1080))
def _show(window, size=(1440, 880)):
from PySide6.QtWidgets import QApplication
window.resize(*size)
window.show()
QApplication.processEvents()
return window
def _enabled(widget) -> bool:
"""Whether ``widget`` is effectively enabled, shown or not.
A plain ``isEnabled()`` is False for every child of a window that has not
been shown, which makes an assertion about gating depend on whether the
test happened to realise the window. ``isEnabledTo`` answers the question
the tests actually mean: would a user be able to operate this control?
"""
return widget.isEnabledTo(widget.window())
# ------------------------------------------------------------------ structure
@pytest.mark.parametrize(("theme", "language"), [
("dark", "zh_CN"),
("light", "zh_CN"),
("dark", "en_US"),
("light", "en_US"),
])
def test_the_window_builds_in_every_theme_and_language(make_window, theme, language):
window = _show(make_window(theme=theme, language=language))
assert window.windowTitle()
assert window.axis_panel.table.rowCount() == 5
def test_the_product_name_is_localised_in_the_window_title(make_window):
window = _show(make_window(language="zh_CN"))
zh = window.windowTitle()
window._set_language("en_US")
assert window.windowTitle() != zh
def test_every_axis_row_has_a_labelled_control(make_window):
window = _show(make_window())
panel = window.axis_panel
for row in range(panel.axis_count):
widgets = panel._rows[row]
for key in ("enable_button", "fwd_button", "rev_button", "stop_button"):
assert widgets[key].text().strip(), f"row {row} {key} has no label"
def test_the_table_shows_one_column_per_documented_quantity(make_window):
from orisys_app_shared.i18n import t
from orisys_autocal.ui.axis_panel import COLUMNS
window = _show(make_window())
headers = [
window.axis_panel.table.horizontalHeaderItem(index).text()
for index in range(window.axis_panel.table.columnCount())
]
assert headers == [t(key) for key in COLUMNS] + [t("axes.controls")]
@pytest.mark.parametrize("size", RESOLUTIONS)
def test_every_axis_row_is_reachable(make_window, size):
"""No drive may be hidden by the splitter.
The panel asks for three rows without scrolling; at the default window size
all five must be visible, because a clipped fifth row on a five-axis platform
reads as a missing drive rather than as a scrollbar. If the panel is ever
squeezed below that, the table has to scroll rather than silently cut a row.
"""
window = _show(make_window(), size)
table = window.axis_panel.table
rows_height = sum(
table.rowHeight(row) for row in range(window.axis_panel.axis_count)
)
header = table.horizontalHeader().height()
available = table.viewport().height()
assert available >= rows_height or table.verticalScrollBar().maximum() > 0, (
f"{size}: rows need {rows_height}px, viewport has {available}px "
f"(header {header}px) and it cannot scroll"
)
@pytest.mark.parametrize("size", RESOLUTIONS)
def test_no_visible_child_escapes_its_parent(make_window, size):
window = _show(make_window(), size)
oversize = []
for child in visible_descendants(window):
if _inside_scroll_area(child):
# A scroll area's content is *meant* to exceed its viewport: the rail
# scrolls instead of squeezing its controls into an unreadable column.
continue
parent = child.parentWidget()
if parent is None or not parent.isVisible():
continue
geometry = child.geometry()
if geometry.width() > parent.width() + 1 or geometry.height() > parent.height() + 1:
oversize.append(
f"{type(child).__name__} {geometry.width()}x{geometry.height()}"
f" > parent {parent.width()}x{parent.height()}"
)
assert not oversize, oversize
def _inside_scroll_area(widget) -> bool:
"""Whether ``widget`` is content of a QAbstractScrollArea, not a laid-out child."""
from PySide6.QtWidgets import QAbstractScrollArea
parent = widget.parentWidget()
while parent is not None:
if isinstance(parent, QAbstractScrollArea):
return True
parent = parent.parentWidget()
return False
# ----------------------------------------------------------------------- gating
def test_motion_controls_start_disabled_without_a_bus(make_window):
window = _show(make_window())
assert not _enabled(window.axis_panel._rows[0]["fwd_button"])
assert not _enabled(window.axis_panel._rows[0]["rev_button"])
def test_bus_data_panels_follow_the_reported_bus_type(make_window):
window = _show(make_window())
window.bus_data_panel.set_connected(True)
window.bus_data_panel.set_bus_type("ecat")
assert _enabled(window.bus_data_panel.ecat_box)
assert not _enabled(window.bus_data_panel.rtex_box)
window.bus_data_panel.set_bus_type("rtex")
assert _enabled(window.bus_data_panel.rtex_box)
assert not _enabled(window.bus_data_panel.ecat_box)
window.bus_data_panel.set_bus_type("unknown")
assert not _enabled(window.bus_data_panel.ecat_box)
assert not _enabled(window.bus_data_panel.rtex_box)
def test_bus_data_panels_are_disabled_while_disconnected(make_window):
window = _show(make_window())
window.bus_data_panel.set_bus_type("ecat")
window.bus_data_panel.set_connected(False)
assert not _enabled(window.bus_data_panel.ecat_box)
def test_axis_rows_dim_when_the_bus_reports_fewer_drives(make_window):
window = _show(make_window())
window.axis_panel.set_connected(True)
window.axis_panel.set_axis_count(3)
assert _enabled(window.axis_panel._rows[2]["enable_button"])
assert not _enabled(window.axis_panel._rows[3]["enable_button"])
def test_unknown_connection_modes_report_instead_of_pretending(make_window):
from orisys_app_shared.i18n import t
window = _show(make_window())
panel = window.connection_panel
index = panel.mode_combo.findData("com")
panel.mode_combo.setCurrentIndex(index)
panel._emit_connect()
assert panel.status_label.text() != t("connection.state_open")
# ------------------------------------------------------------------- actions
def test_the_selected_axis_follows_a_table_click(make_window):
window = _show(make_window())
window.axis_panel._on_cell_clicked(3, 0)
assert window.axis_panel.selected_axis == 3
assert window.axis_panel.axis_index_label.text() == "3"
def test_blank_numeric_fields_fall_back_to_defaults(make_window):
from orisys_autocal.motion import DEFAULT_UNITS
window = _show(make_window())
panel = window.axis_panel
panel.units_edit.setText("")
assert panel.current_params().units == DEFAULT_UNITS
panel.units_edit.setText("not a number")
assert panel.current_params().units == DEFAULT_UNITS
def test_a_blank_target_does_not_emit_a_move(make_window):
window = _show(make_window())
emitted = []
window.axis_panel.moveAbsRequested.connect(lambda axis, value: emitted.append(value))
window.axis_panel.target_edit.setText("")
window.axis_panel._on_move_abs()
assert emitted == []
def test_applying_parameters_persists_them_in_the_panel(make_window):
from orisys_autocal.motion import AxisParams
window = _show(make_window())
panel = window.axis_panel
panel.set_selected_axis(1)
panel.units_edit.setText("5")
panel.speed_edit.setText("250")
panel.accel_edit.setText("2500")
panel._on_apply()
stored = panel.stored_params()[1]
assert isinstance(stored, AxisParams)
assert stored.units == 5.0
# --------------------------------------------------------------- preferences
def test_the_window_geometry_round_trips_through_preferences(make_window):
# Above the enforced minimum (1400x860): a smaller request would be clamped,
# which tests Qt's constraint rather than the preference round-trip.
window = _show(make_window(), (1520, 920))
window._persist_prefs()
geometry = window.prefs["window_geometry"]
assert len(geometry) == 4
assert geometry[2] == pytest.approx(1520, abs=5)
assert geometry[3] == pytest.approx(920, abs=5)
def test_a_malformed_geometry_is_ignored_rather_than_crashing(make_window):
window = _show(make_window(window_geometry=["nonsense"]))
assert window.width() > 0
def test_axis_parameters_survive_a_persist_reload(make_window):
from orisys_autocal import preferences as prefs_mod
window = _show(make_window())
window.axis_panel.set_selected_axis(2)
window.axis_panel.units_edit.setText("7")
window.axis_panel._on_apply()
window._persist_prefs()
reloaded = prefs_mod.load_preferences()
assert reloaded["axis_params"]["2"]["units"] == 7.0
def test_the_bus_path_is_remembered(make_window):
window = _show(make_window())
window.bus_panel.set_bas_path(r"D:\somewhere\ECAT.bas")
window._persist_prefs()
assert window.prefs["bas_path"] == r"D:\somewhere\ECAT.bas"
def test_sdo_fields_are_remembered(make_window):
window = _show(make_window())
panel = window.bus_data_panel
panel.node_edit.setText("2")
panel.index_edit.setText("24608")
panel.subindex_edit.setText("1")
window._persist_prefs()
assert window.prefs["sdo_last"]["node"] == 2
assert window.prefs["sdo_last"]["index"] == 24608
# --------------------------------------------------------------- demo session
def test_a_demo_session_fills_the_table_and_gates_motion(make_window, qt_app):
"""End to end through the real signal path: connect, initialise, jog."""
from orisys_autocal.motion import ConnectionSpec
window = _show(make_window())
window.connect_controller(ConnectionSpec(mode="demo"), label="connect")
assert _wait_for(lambda: window._last_snapshot.connected, qt_app)
# Download the shipped ECAT.bas, then wait for the simulated scan.
window.bus_panel.set_bas_path(str(_shipped_bas("ECAT.bas")))
window._on_download_bas()
assert _wait_for(lambda: window._last_snapshot.bus.init_status == 1, qt_app, timeout=8.0)
assert window._last_snapshot.bus.bus_type == "ecat"
assert _enabled(window.bus_data_panel.ecat_box)
assert _enabled(window.axis_panel._rows[0]["fwd_button"])
# ATYPE is no longer a table column — the seven-column table shows position
# and status, and the drive's full frame is on the selected-axis detail line.
window.axis_panel.set_selected_axis(0)
assert "65" in window.axis_panel._detail_label.text()
pump(qt_app, 0.2)
def test_closing_the_window_stops_the_poller(make_window, qt_app):
window = _show(make_window())
window.close()
pump(qt_app, 0.2)
assert not window.poller._thread.isRunning()
def _shipped_bas(name: str):
from orisys_autocal.ui.bus_panel import default_bas_dir
return default_bas_dir() / name
def _wait_for(predicate, app, timeout: float = 5.0) -> bool:
import time
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
app.processEvents()
if predicate():
return True
time.sleep(0.02)
return False
@@ -0,0 +1,64 @@
"""Preferences must never be written outside the test scratch directory.
The installed app reads ``apps/autocal/preference.json`` at startup. A test that
builds a window and closes it saves preferences through ``closeEvent``, so a test
that forgets to redirect the path rewrites the operator's real settings — which is
exactly what happened: the finger screen started up pointing at a scratch bundle
from a previous run and reported it as missing.
``conftest._isolate_preference_file`` is the net. These tests pin it, because a
fixture that silently stopped applying would look like nothing at all until the app
was next launched by hand.
"""
from __future__ import annotations
from pathlib import Path
APP_ROOT = Path(__file__).resolve().parents[1]
INSTALLED = APP_ROOT / "preference.json"
def test_the_preference_path_is_redirected_under_the_scratch_root():
from orisys_autocal.preferences import path
resolved = Path(path()).resolve()
scratch = (APP_ROOT / "outputs" / "_test_scratch").resolve()
assert resolved != INSTALLED.resolve()
assert scratch in resolved.parents, resolved
def test_saving_preferences_leaves_the_installed_file_alone():
from orisys_autocal import preferences as prefs_mod
before = INSTALLED.read_bytes() if INSTALLED.is_file() else None
prefs = prefs_mod.default_preferences()
prefs["finger_bundle_path"] = str(APP_ROOT / "outputs" / "_test_scratch" / "gone")
written = Path(prefs_mod.save_preferences(prefs)).resolve()
assert written != INSTALLED.resolve()
assert written.is_file(), "the save did not reach the redirected file either"
after = INSTALLED.read_bytes() if INSTALLED.is_file() else None
assert after == before, "a test rewrote the installed preference.json"
def test_a_window_that_closes_still_lands_in_the_scratch_directory(qt_app):
"""The failure mode itself: construct, close, check where it wrote."""
from orisys_autocal import preferences as prefs_mod
from orisys_autocal.ui.window import AutoCalMainWindow
before = INSTALLED.read_bytes() if INSTALLED.is_file() else None
prefs = prefs_mod.default_preferences()
prefs["finger_bundle_path"] = str(APP_ROOT / "outputs" / "_test_scratch" / "gone")
window = AutoCalMainWindow(prefs=prefs, auto_load_bundle=False)
try:
window.poller.shutdown()
window.close()
finally:
window.poller.shutdown()
after = INSTALLED.read_bytes() if INSTALLED.is_file() else None
assert after == before, "closing a test window rewrote the installed preferences"