Files
forgefirm/forgetest/tests/test_takeover_position.py
T
ScottW514 f9c298a347 Catalog: eleven tests leave for their host proofs
Each left after its host cases passed (forgectrl 1dc85f6) and with
the coverage lint clean at the pins, its only failure the known
setup.check-lens entry on src/lensfind.*:

- forgectrl.tokens: host_routes.sh's scoped tokens (a route refused to a
  token not given it, neither token in the list or the store), with
  tokens_test and auth_scoped_test.
- update.product-gate: host_update.sh's product gate on the upload, with
  fwproduct_test.
- setup.first-run-page: tests/wizard_walk.py.
- setup.check-sensors, setup.check-intakes, setup.check-cameras,
  setup.check-flow-verify, setup.check-envelope, setup.floor:
  host_setup.sh's checks and the floor burn, with wizcalc_test.
  envelope_test.py's cases are unchanged. setup.floor's emission stays
  with setup.focus and laser.emission-witness.
- motion.job: jobpost_test, with jobrun_test and lease_test; jobpost.* is
  covered by exthost.motion-job, which stays.
- exthost.motion-jog: host_routes.sh's credential case.

Two HOST_TESTED rows, each a binary forgectrl's build.yml runs at the pin
c274f19 that proves the path the lint named: src/fwproduct.* to
fwproduct_test, src/grblport.* to grblport_test.

exthost.motion-job now requires motion.jog-roundtrip in place of
motion.job. bedsize.py held setup.check-envelope alone and goes;
forgectrl.py loses _words_of and its hw import, motion.py the job
runner's helpers, setup_burns.py FLOOR_KEY and FLOOR_KEYS.

forgetest's own tests: test_setup_dark.py loses the sensors and intakes
judges and the departed ids; test_setup_burns.py loses the floor's cases,
and the guards burn() and preview_ok() hold for the focus burn (the
program's tail and reach, the thickness in inches and the one refused, a
prompt not answered, the witnesses) run on setup.focus.

Stay: forgectrl.auth (the fuse view's button refusal on the real
button); forgectrl.settings-bounds (the lid lamp: cam.c writes
/sys/glowforge/pic/lid_led, not under GF_SYSFS_ROOT); forgectrl.panel-serves
(src/ui.h, which no binary at the pin proves; Z inside the lens reach is
setup.check-lens's); logs.routing; setup.account-login (the account's
render and its home, image content); setup.ssh-until-reboot
(src/paths.h); setup.machine-name (udhcpc -x hostname and no mDNS
responder, image content); setup.first-run-flow (src/button.*, led.*,
advisories.*, hooks.h and the four advisory documents); exthost.motion-job
(jobpost.*, which no binary at the pin proves).

Proof: the coverage lint, 55 tests; pyflakes; test_setup_burns,
test_setup_dark, test_takeover_position and test_image (54 OK).

Shared text moved the fingerprints of exthost.armed-freeze,
exthost.platform, exthost.service, forgectrl.auth,
forgectrl.panel-serves, forgectrl.settings-bounds, laser.armed-kill,
laser.emission-witness, laser.pause-resume-lid-cancel,
motion.cancel-abort, motion.deadman, motion.jog-roundtrip,
motion.liveness-probe, motion.microstep-modes, motion.pacing,
motion.release, motion.step-timing-under-load, setup.account-login,
setup.check-airflow, setup.check-lens, setup.check-motion,
setup.check-switches, setup.cloud-header-capture, setup.first-run-flow,
setup.focus, setup.machine-name, setup.ssh-until-reboot and
update.slots-and-signature; exthost.motion-job's own text moved with its
requires.
2026-10-14 21:03:58 -04:00

191 lines
7.9 KiB
Python

# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""A takeover judges the head where the baseline cannot. forgectrl's start
at the end of a takeover is a controller start, which re-zeroes the step
counters wherever the head stands, so after it a head left out reads as
home. The takeover reads the counters as the controller goes away (against
where the run expects the head) and before it comes back (against where the
takeover found it); a miss is a leftover that fails the run, and the head is
never moved on it. Runs against a fake sysfs tree and a fake anchor file."""
import os
import shutil
import struct
import tempfile
import unittest
from forgetest import baseline, hw, runner
class FakeRun:
def __init__(self, cap):
self.baseline_captured = cap
self.lines = []
def log(self, s):
self.lines.append(s)
class TakeoverPositionTests(unittest.TestCase):
def setUp(self):
# the clean machine test_baseline.py builds, so the post pass
# finds nothing but what a test here leaves
self.tmp = tempfile.mkdtemp(prefix="forgetest-tko-")
self.sysfs = os.path.join(self.tmp, "sysfs") + os.sep
self.leds = os.path.join(self.tmp, "leds") + os.sep
for group in ("cnc", "pic", "head", "thermal"):
os.makedirs(self.sysfs + group)
for name in baseline.BUTTON_LEDS + ("lid_led",):
os.makedirs(self.leds + name)
with open(self.leds + name + "/brightness", "w") as f:
f.write("0")
for attr, val in baseline.fixed_sysfs() + baseline.IDLE_READBACKS:
self._attr(attr, val)
self._attr("cnc/interlock_circuit", "45")
self._attr("pic/lid_led", "0")
self._pos(0, 0, 0)
os.environ["GF_SYSFS_ROOT"] = self.sysfs
os.environ["GF_LEDS_ROOT"] = self.leds
os.environ["FORGECTRL_URL"] = "http://127.0.0.1:1" # nothing listens
baseline.Baseline._unreachable_until = 0.0
self.anchor = os.path.join(self.tmp, "grblhal.homed")
self._new_anchor(1)
self.old_anchor, baseline.ANCHOR_PATH = baseline.ANCHOR_PATH, self.anchor
self.spm = baseline.counter_steps_per_mm()
def tearDown(self):
baseline.ANCHOR_PATH = self.old_anchor
shutil.rmtree(self.tmp, ignore_errors=True)
for k in ("GF_SYSFS_ROOT", "GF_LEDS_ROOT", "FORGECTRL_URL"):
os.environ.pop(k, None)
def _attr(self, attr, val):
with open(self.sysfs + attr, "w") as f:
f.write(str(val))
def _pos(self, x, y, z):
with open(self.sysfs + "cnc/position", "wb") as f:
f.write(struct.pack("<5i", x, y, z, 0, 0))
def _new_anchor(self, ns):
"""A controller start or a home: a new anchor file."""
with open(self.anchor + ".new", "w") as f:
f.write("0 0 0 4 startup")
os.replace(self.anchor + ".new", self.anchor)
os.utime(self.anchor, ns=(ns, ns))
def cap(self):
return {"position": baseline.read_position(), "frame": baseline.counter_frame()}
def takeover(self, cap):
run = FakeRun(cap)
return runner.Takeover(run.log, "test.id", run=run), run
def mm(self, mm):
return round(mm * self.spm)
def start(self, t):
"""Takeover.__enter__'s order: the frame is read while the
controller runs, then its exit takes the anchor with it (forgectrl
unlinks it) and leaves the counters, which are then read."""
t.frame = baseline.counter_frame()
if os.path.exists(self.anchor):
os.unlink(self.anchor)
t.position_at_start()
def test_a_head_where_the_run_expects_it_is_not_recorded(self):
cap = self.cap()
t, run = self.takeover(cap)
self.start(t)
t.position_at_end()
self.assertNotIn("restart_positions", cap)
def test_a_head_left_out_at_the_start_is_recorded(self):
cap = self.cap()
self._pos(self.mm(10), self.mm(10), 0)
t, run = self.takeover(cap)
self.start(t)
self.assertEqual(cap["restart_positions"],
[{"where": "takeover start", "found": [self.mm(10), self.mm(10), 0],
"expected": [0, 0, 0]}])
def test_the_frame_is_read_before_the_controller_goes(self):
# found on the bench: forgectrl unlinks the anchor when the
# controller exits, so a frame read after the stop never matched
# and the check judged nothing. Read before the stop, it does.
cap = self.cap()
self._pos(self.mm(10), self.mm(10), 0)
late, run = self.takeover(cap)
os.unlink(self.anchor)
late.frame = baseline.counter_frame()
late.position_at_start()
self.assertNotIn("restart_positions", cap)
self._new_anchor(1)
cap = self.cap() # a run that began at the origin in this frame
cap["position"] = [0, 0, 0]
on_time, run = self.takeover(cap)
self.start(on_time)
self.assertEqual([r["where"] for r in cap["restart_positions"]], ["takeover start"])
def test_the_dead_band_is_not_a_leftover(self):
cap = self.cap()
self._pos(self.mm(0.05), 0, 0)
t, run = self.takeover(cap)
self.start(t)
self.assertNotIn("restart_positions", cap)
def test_the_post_pass_fails_the_run_and_moves_nothing(self):
# a check's case: a declared camera home, the head
# left out, the record put back under a takeover, whose restart
# zeroes the counters where the head stands
cap = self.cap()
self._new_anchor(3) # the camera home
cap["position"], cap["rezero_declared"], cap["frame"] = [0, 0, 0], True, baseline.counter_frame()
self._pos(self.mm(10), self.mm(10), 0)
t, run = self.takeover(cap)
self.start(t)
t.position_at_end()
self._new_anchor(4) # forgectrl's start: a controller start
self._pos(0, 0, 0)
lines = []
left = baseline.Baseline(lines.append).enforce("post", captured=cap)
self.assertEqual([x.item for x in left], ["position at the takeover start"])
self.assertTrue(left[0].action.startswith("unrestorable"), left[0].action)
self.assertIn("not moved", left[0].action)
self.assertEqual(baseline.read_position(), [0, 0, 0])
# without the takeover's record the same run reads clean: the gap
del cap["restart_positions"]
self.assertEqual(baseline.Baseline(lines.append).enforce("post", captured=cap), [])
def test_a_failed_reading_never_keeps_forgectrl_down(self):
# the exit path restores the attributes, relocks the latch and
# starts forgectrl whatever the position reading does
os.environ["FORGETEST_MARKER"] = os.path.join(self.tmp, "marker")
calls = []
real = (hw.initd, baseline.read_position, runner.Takeover.wait_settled)
hw.initd = lambda service, action, timeout=60: (calls.append((service, action)) or (0, ""))
def broken():
raise OSError("unreadable")
baseline.read_position = broken
runner.Takeover.wait_settled = lambda self: None
try:
cap = {"position": [0, 0, 0], "frame": baseline.counter_frame()}
t, run = self.takeover(cap)
t.found = ([0, 0, 0], hw.sysfs_read("cnc/x_mode"))
t.saved = {"cnc/x_mode": hw.sysfs_read("cnc/x_mode")}
t.__exit__(None, None, None)
finally:
hw.initd, baseline.read_position, runner.Takeover.wait_settled = real
os.environ.pop("FORGETEST_MARKER", None)
self.assertEqual(calls, [("forgectrl", "start")])
with open(self.sysfs + "cnc/laser_latch") as f:
self.assertEqual(f.read().strip(), "1")
self.assertTrue(any("could not be judged" in l for l in run.lines), run.lines)
if __name__ == "__main__":
unittest.main()