added motion trajectory simulation
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
@@ -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")
|
||||
@@ -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():
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user