Files
firmware/test/integration/test_tool_transform.py
T
Oleksandr ShyshatskyiandClaude Opus 5 c185ef3285 The replica is a build tool; test/ is 28 unit tests
Two changes that turned out to be one.

MOVED, NOT DELETED. `test/ffsim/` and `test/integration/printer/` are
`tools/replica/`. They were never tests: bin/patch.sh calls
bin/build-payload.py, which starts a replica and assembles the payload by
running the PRINTER'S OWN opkg against / inside it -- so the "test harness"
was on the build path, and deleting it as asked would have broken `make
build`. qa/lib/replica.py drives the same directory unmodified. No content
changed; what changed is qa/lib/replica.py's build directory, three launcher
sys.path lines, the Makefile targets and the CI shellcheck glob.
tools/replica/README.md says who drives it and why it is not under test/.

CUT TO 28. The five surviving modules were 136 tests; they are 28, chosen on
one question: would this failure be SILENT? What is kept is the mistakes that
run perfectly and are wrong --

  test_startup       8  no stamp unless the values are verifiably saved (a
                        stamp written early is a printer that has lost its
                        factory calibration for good), and the handover order
  test_tool_transform 7 every one is a move to the wrong place: a sign, a
                        frame left applied while probing, a stale gcode_move
                        cache, a config a calibrated printer cannot parse
  test_ffscreen      5  the four ways it can do harm rather than look wrong
  test_chamber       4  the chamber mistakes that cost a print before anyone
                        sees them, plus the parse gate they rest on
  test_gcode         4  a renamed macro, and the safe file being cold

What went: the panel narration, the geometry probe and parse_geometry, the
z_adjust and per-job-Z bookkeeping, the config layout checks, the G-code
bounds checks. Each is either a wrong number shown to a person, or a failure
the printer reports loudly the first time -- and qa/replica/test_boot_screen.py
already renders the real frames on the real machine.

The rewrite is not a delete: assertions were folded so a kept test asks its
question in every direction it can be got wrong (both models, both rotations,
all four ways the migration can fail), and each file's header now says what it
dropped and why, where the next person will open it.

Also gone: conftest's `rootfs` fixture, orphaned two commits ago. Nothing under
test/ needs the firmware, docker, or a network any more.

28 host-side tests pass in 0.15s. qa/static unchanged at 218. `make rootfs`
re-verified end to end from its new path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UzLebCrvc8ssWR5JuEsha4
2026-08-29 23:27:34 +03:00

217 lines
8.0 KiB
Python

"""The per-tool G-code frame in ff_toolchange.
Per-tool XYZ used to be folded into Klipper's single homing_origin, which
forced a shadow copy of "which part of that number is ours" (_z_tool_term)
and meant SET_GCODE_OFFSET Z=0 -- issued by the end/cancel block -- wiped
the tool's ~3.2 mm nozzle-to-station gap along with the babystep. It is a
move transform below gcode_move now, so the two layers are independent:
homing_origin is the operator's, the transform is the tool's.
WHAT IS LEFT. This was twenty-four tests and is seven. Everything kept is a
mistake whose symptom is a MOVE TO THE WRONG PLACE -- an offset with the wrong
sign, a frame still applied while probing in machine coordinates, a gcode_move
cache not invalidated when the frame changed, or a klippy that will not parse
its config on any printer that has been calibrated. The bookkeeping around
them -- z_adjust staging for SAVE_CONFIG, the per-job Z term, the
uncalibrated-tool warning -- is dropped: those are wrong numbers reported to a
person, not a nozzle in the bed.
gcode_move and the toolhead are faked. They are Klipper's, not ours, and
_ToolTransform touches exactly two things on each: move()/get_position()
below it and reset_last_position() above.
"""
import importlib.util
import inspect
import os
import pytest
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
def _load(name):
path = os.path.join(ROOT, "pkgs", "klipper", "payload", "klipper", "klippy", "extras", name + ".py")
spec = importlib.util.spec_from_file_location(name, path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
ff_toolchange = _load("ff_toolchange")
class FakeToolhead:
"""Whatever the transform chains down to. Records what it was asked."""
def __init__(self, position=(0., 0., 0., 0.)):
self.position = list(position)
self.moves = []
def move(self, newpos, speed):
self.moves.append((list(newpos), speed))
self.position = list(newpos)
def get_position(self):
return list(self.position)
class FakeGcodeMove:
def __init__(self):
self.resets = 0
def reset_last_position(self):
self.resets += 1
class FakePrinter:
def __init__(self, gcode_move):
self.objects = {"gcode_move": gcode_move}
def lookup_object(self, name, default=None):
return self.objects.get(name, default)
class FakeGcode:
def __init__(self):
self.messages = []
def respond_info(self, message):
self.messages.append(message)
class FakeTool:
"""Only what _set_tool_frame's calibration warning asks about."""
def __init__(self, calibrated=True):
self._calibrated = calibrated
def calibrated(self):
return self._calibrated
def make(offsets, mounted=None, calibrated=True):
"""A toolchanger with only the parts the frame API touches. __init__
wants a whole klippy config; none of this needs one."""
tc = ff_toolchange.FFToolchange.__new__(ff_toolchange.FFToolchange)
tc.offset_x = [o[0] for o in offsets]
tc.offset_y = [o[1] for o in offsets]
tc.offset_z = [o[2] for o in offsets]
tc.job_z = 0.0
tc.tools = [FakeTool(calibrated) for _ in offsets]
tc.gcode = FakeGcode()
tc.gcode_move = FakeGcodeMove()
tc.printer = FakePrinter(tc.gcode_move)
tc.gcode_transform = ff_toolchange._ToolTransform(tc)
tc.gcode_transform.next_transform = FakeToolhead()
tc._current_tool_or_none = lambda: (mounted, "fake")
return tc
OFFSETS = [(0.0, 0.0, 3.15), (-0.30, 0.07, 3.19),
(0.12, -0.04, 3.11), (0.05, 0.21, 3.24)]
# ------------------------------------------------------------- arithmetic
def test_a_mounted_tool_adds_its_offsets():
"""The signs, against numbers worked out by hand.
The round trip below cannot see this: an offset applied with the wrong
sign and removed with the wrong sign agrees with itself perfectly.
"""
tc = make(OFFSETS)
tc.gcode_transform.tool = 1
tc.gcode_transform.move([10., 20., 5., 1.], 60.)
sent = tc.gcode_transform.next_transform.moves[0][0]
assert sent[:3] == pytest.approx([9.70, 20.07, 8.19])
def test_get_position_undoes_what_move_did():
"""The round trip is the whole contract: G-code coordinates stay
tool-independent, so M114 reports the frame the file is written in."""
tc = make(OFFSETS)
for tool in range(len(OFFSETS)):
tc.gcode_transform.tool = tool
asked = [10., 20., 5., 1.]
tc.gcode_transform.move(asked, 60.)
assert tc.gcode_transform.get_position() == pytest.approx(asked)
def test_the_extruder_axis_is_never_touched():
"""newpos[3:] is the extruder position -- adding a Z offset to it would
extrude the offset."""
tc = make(OFFSETS)
tc.gcode_transform.tool = 3
tc.gcode_transform.move([10., 20., 5., 42.5], 60.)
assert tc.gcode_transform.next_transform.moves[0][0][3] == 42.5
# ---------------------------------------------------------- the frame API
def test_selecting_a_tool_invalidates_the_position_cache():
"""gcode_move caches last_position from position_with_transform(). Change
the frame without resetting it and the next move starts from a point the
toolhead is not at."""
tc = make(OFFSETS)
before = tc.gcode_move.resets
tc._set_tool_frame(1)
assert tc.gcode_transform.tool == 1
assert tc.gcode_move.resets == before + 1
def test_suspending_the_frame_drops_to_machine_coordinates():
"""The offset calibration and the dock moves address the machine. Zeroing
Klipper's G-code offset does NOT reach this layer, and the per-job Z term
has to come off with the tool's -- both are the frame."""
tc = make(OFFSETS, mounted=1)
tc.job_z = 0.04
tc._set_tool_frame(1)
assert tc.suspend_tool_frame() == 1
assert tc.gcode_transform.tool is None
tc.gcode_transform.move([10., 20., 5., 0.], 60.)
assert tc.gcode_transform.next_transform.moves[-1][0][:3] == [10., 20., 5.]
def test_restoring_the_frame_asks_the_sensors_not_the_memory():
"""After a calibration the carriage may hold a different tool than the one
suspended -- or none. What is mounted is the honest answer, and an
unanswerable question leaves no frame rather than the last one."""
tc = make(OFFSETS, mounted=3)
tc._set_tool_frame(1)
tc.suspend_tool_frame()
assert tc.restore_tool_frame() is True
assert tc.gcode_transform.tool == 3
for sensors_say in (-1, None): # empty carriage, no answer
tc = make(OFFSETS, mounted=sensors_say)
tc._set_tool_frame(2)
assert tc.restore_tool_frame() is False
assert tc.gcode_transform.tool is None
# ------------------------------------------- the transform exists in time
def test_init_builds_the_transform_before_it_is_read():
"""Two ways __init__ has broken klippy at config parse, both static.
refresh_offsets() reads gcode_transform to decide whether gcode_move's
cache needs invalidating, and __init__ calls it. On a calibrated machine
the derived offsets differ from the [0.0] seed, so the read happens -- and
a transform created further down __init__ meant AttributeError at config
parse on every printer that had ever been calibrated. The uncalibrated
case, where nothing differs and the read is short-circuited, is exactly
the one the replica boots in, so no replica gate can see this.
And `self.offset_x = self.offset_y = self.offset_z = [0.] * N` binds ONE
list to three names, so a later write to any of them writes all three --
every tool would get X's offset on all three axes.
"""
source = inspect.getsource(ff_toolchange.FFToolchange.__init__)
built = source.index("self.gcode_transform = _ToolTransform(self)")
refreshed = source.index("self.refresh_offsets()")
assert built < refreshed, (
"refresh_offsets() runs before the transform exists -- klippy will "
"not parse its config on a calibrated printer")
assert "self.offset_x = self.offset_y" not in source, (
"the three offset series are chain-assigned, so they are one list")