Let a test declare the controller mode it needs; the runner switches to it

The cloud job tests enter cloud mode and stay there, by design, so a
queue (or an operator) that goes on to a motion test reaches it with
gfcloud as the controller and no grblHAL process to find:
motion.step-timing-under-load failed on exactly that, before it touched
the machine. Nothing in the runner put the machine into the mode a test
needed; the baseline only preserved the mode it found.

A test now declares `mode="grbl"` (or "cloud") in @test. The runner's pre
pass, after the leftovers are handled and before the preserved state is
captured, switches through POST /mode, waits for the supervisor to settle
(controller running, motion verified) and for the Grbl port to answer,
and fails the test with the reason when the mode cannot be established.
Capturing after the switch means the post pass keeps the mode the test
asked for, so the machine changes mode only where the next test asks for
it and never between tests of the same mode. The cloud job tests keep
managing their own entry (enter_cloud also waits for the service
session) and declare nothing.

Tagged: every motion.* test but the mode-agnostic liveness probe, the six
laser.* tests, cooling.fans-quiet-after-motion, cloud.mode-switch and
cloud.gfhome-homing (both start in GRBL mode). controller_pid() now says
what mode forgectrl reports when the process is missing. The page shows
the declared mode as a badge; the Grbl port probe moved to hw.

Proof: tests/test_mode.py (switch_mode against the fake forgectrl,
including a refused switch, a controller that never comes up and a port
that never opens; the runner end to end from cloud mode, from grbl mode,
an undeclared test, and a failed switch). 151 unit tests pass; the
coverage lint is clean. No catalog consequence beyond the suite modules'
own source hashes: the change is to how a test is started, not to what
it proves.
This commit is contained in:
ScottW514
2026-08-21 12:34:03 -04:00
parent e810d52c9d
commit 54e1689889
13 changed files with 321 additions and 51 deletions
+18 -6
View File
@@ -27,6 +27,12 @@ Every test declares, in code (`forgetest/forgetest/suite/*.py`):
- **hardware** - `api` (forgectrl and the controller stay up) or
`takeover` (forgectrl is stopped for the duration; a marker file makes a
crash recoverable at the next start);
- **mode** - the controller mode the test needs live when it starts
(`grbl` or `cloud`), or none. The runner switches the machine there
before the test (through `POST /mode`, settled and with the Grbl port
answering) and leaves it there; a test with no mode runs in whatever
mode it finds, or manages the mode itself (the `cloud.*` job tests,
through `enter_cloud`, which also waits for the service session);
- **covers** - the source paths whose content the test stands for, as
`(component, glob)` pairs;
- **requires** - tests that must be satisfied first (the emission tests
@@ -119,9 +125,13 @@ bench, or one whose `/data` has been wiped, starts from a full campaign.
started under it says so in its record).
The `cloud.*` job tests run **in cloud mode and stay there**: the first
one switches from GRBL mode (once, its connect-time hunt waited out)
and the following ones reuse the live session; nothing switches back -
switch on the control panel when done. `cloud.mode-switch` is the one
round trip and starts in GRBL mode.
and the following ones reuse the live session; nothing switches back
after them. The tests that need GRBL mode (`motion.*`, `laser.*`,
`cooling.fans-quiet-after-motion`, `cloud.mode-switch`,
`cloud.gfhome-homing`) declare it, and the runner switches back the
moment one of them starts - so the mode changes only where the next
test asks for it, never between tests of the same mode.
`cloud.mode-switch` is the one round trip.
4. Or hand the whole list to a queue. **Run what is left** offers two:
**Unattended** takes every `auto` test the campaign does not already
count as satisfied, and needs nobody in the room; **Operator and live**
@@ -166,9 +176,11 @@ its lid-image level; the position counters, re-zeroed at every service
action) is left to it, and the safety readbacks, latch, ring, module
defaults, and forgectrl's engines are checked as always. The mode itself
is preserved unless the run declared the change (`ctx.mode_changed()`,
the cloud tests entering cloud mode); the persisted `controller_mode`
setting is never written back as a bare setting - only the switch keeps
it in step with the live mode. Deviations are
the cloud tests entering cloud mode) or the test declared a `mode`, in
which case the runner makes the switch in the pre pass, before the
preserved state is captured, and the post pass keeps the mode the test
asked for; the persisted `controller_mode` setting is never written back
as a bare setting - only the switch keeps it in step with the live mode. Deviations are
**leftovers**: logged in the run pane, kept in the result's `evidence`
(`baseline.pre` / `baseline.post`), and surfaced in the page's message
line - a leftover found before a run is attributed to the previous run; one
+3 -2
View File
@@ -38,8 +38,9 @@ pins; the coverage lint is `python3 -m forgetest.coverage --manifest ...`.
## Adding a test
Register it in the subsystem module under `forgetest/suite/` with
`@test(...)`: id `subsystem.name`, kind, hardware, `covers`, `requires`,
`always`, steps. The body gets a `Context` (`log`, `check`, `fail`,
`@test(...)`: id `subsystem.name`, kind, hardware, `mode` (the controller
mode the test needs; the runner switches to it first), `covers`,
`requires`, `always`, steps. The body gets a `Context` (`log`, `check`, `fail`,
`prompt`, `confirm`, `instruct`, `sleep`, `evidence`, `forgectrl`, `sysfs`,
`grbl`, `takeover`). Return normally for PASS, raise `runner.Failed` for
FAIL. Then run the unit tests and the coverage lint.
+30
View File
@@ -101,6 +101,7 @@ SETTLE_S = 150 # the supervisor's probe + rail-off ladder
CAM_IDLE_S = 20 # camera engine idle stop is 10 s
COOL_IDLE_S = 120 # cooldown after motion
IDLE_S = 30 # cnc/state back to idle after a job
GRBL_PORT_S = 30 # the Grbl port after the supervisor reports grblHAL running
XY_STEPS_PER_MM = 53.333 # boards/glowforge.h (x8 microstepping)
RETURN_MAX_MM = 100.0 # a displaced head is jogged back at most this far
@@ -259,6 +260,35 @@ class Baseline:
self.log("WARNING - forgectrl did not settle within %d s (last /mode: %s)" % (timeout, last))
return last
# -- the mode a test needs -------------------------------------------
def switch_mode(self, want, timeout=SETTLE_S):
"""Put the machine in controller mode `want` through the supervisor
and wait for it to settle there: the controller running, motion
verified, and in GRBL mode the Grbl port answering. Returns (ok,
detail). Used by the runner for a test that declares a mode, before
the preserved state is captured - so the baseline keeps the mode
the test asked for, not the one the run found."""
st, mode = self.fc_get("/mode")
if st != 200 or not isinstance(mode, dict):
return False, "forgectrl not answering (/mode -> %s)" % st
if mode.get("mode") == want and mode.get("controller") == "running":
self.mode = want
return True, "already in %s mode" % want
self.log("switching to %s mode (found %s, controller %s)"
% (want, mode.get("mode"), mode.get("controller")))
st, body = self.fc_post("/mode", data={"controller": want})
if st != 200:
return False, "POST /mode controller=%s -> %s %s" % (want, st, body)
mode = self.wait_settled(timeout=timeout) or {}
self.mode = mode.get("mode")
if mode.get("mode") != want or mode.get("controller") != "running":
return False, "%s mode did not come up: %s" % (want, mode)
if want == "grbl":
w = self._wait("Grbl port", lambda: hw.grbl_port_open(2), GRBL_PORT_S)
if w is None:
return False, "grbl controller is running but the Grbl port never opened"
return True, "%s mode up" % want
# -- capture -------------------------------------------------------
def capture(self):
"""Record the preserved state before a run."""
+17 -6
View File
@@ -3,8 +3,9 @@
A test is a function decorated with @test(...). The decorator records
what the release gate needs to know without running anything: the id,
the subsystem, the kind (auto / operator / live), how it takes the
hardware (api / takeover), what source it covers, what it requires, and
whether it belongs to the always-required core. The function body runs
hardware (api / takeover), the controller mode it needs (if any), what
source it covers, what it requires, and whether it belongs to the
always-required core. The function body runs
under the runner with a Context (log, prompts, evidence, hardware
helpers) and reports by returning normally (PASS) or raising
runner.Failed (FAIL).
@@ -18,6 +19,7 @@ from . import manifest as _manifest
KINDS = ("auto", "operator", "live")
HARDWARE = ("api", "takeover")
MODES = ("grbl", "cloud")
_ID_RX = re.compile(r"^[a-z][a-z0-9-]*\.[a-z][a-z0-9-]*$")
REGISTRY = {}
@@ -25,12 +27,13 @@ REGISTRY = {}
class Test:
def __init__(self, id, title, subsystem, kind, hardware, covers, requires,
always, est_min, steps, description, fn):
always, est_min, steps, description, fn, mode=None):
self.id = id
self.title = title
self.subsystem = subsystem
self.kind = kind
self.hardware = hardware
self.mode = mode
self.covers = tuple((str(c), str(g)) for c, g in covers)
self.requires = tuple(requires)
self.always = bool(always)
@@ -78,7 +81,7 @@ class Test:
def describe(self):
d = self.definition()
d.update({"title": self.title, "est_min": self.est_min,
d.update({"title": self.title, "est_min": self.est_min, "mode": self.mode,
"steps": list(self.steps), "description": self.description})
return d
@@ -89,14 +92,22 @@ def source_file_sha(path):
return hashlib.sha256(data).hexdigest()
def test(id, *, title, subsystem, kind="auto", hardware="api", covers=(),
def test(id, *, title, subsystem, kind="auto", hardware="api", mode=None, covers=(),
requires=(), always=False, est_min=1, steps=(), description=""):
"""`mode` names the controller mode the test needs live when it starts
("grbl" or "cloud"); the runner switches the machine there before the
test and leaves it there, so a queue crosses modes only where a test
asks it to. None means the test runs in whatever mode it finds (or
manages the mode itself, as the cloud tests do through enter_cloud,
which also waits for the service session)."""
if not _ID_RX.match(id):
raise ValueError("test id %r must look like subsystem.name" % id)
if kind not in KINDS:
raise ValueError("test %s: kind %r" % (id, kind))
if hardware not in HARDWARE:
raise ValueError("test %s: hardware %r" % (id, hardware))
if mode is not None and mode not in MODES:
raise ValueError("test %s: mode %r" % (id, mode))
for c, g in covers:
if c in _manifest.DEV_ONLY_COMPONENTS:
raise ValueError("test %s: may not cover dev-only component %r" % (id, c))
@@ -105,7 +116,7 @@ def test(id, *, title, subsystem, kind="auto", hardware="api", covers=(),
if id in REGISTRY:
raise ValueError("duplicate test id %r" % id)
REGISTRY[id] = Test(id, title, subsystem, kind, hardware, covers, requires,
always, est_min, steps, description, fn)
always, est_min, steps, description, fn, mode=mode)
return fn
return deco
+11
View File
@@ -195,6 +195,17 @@ def run(cmd, timeout=60):
# ------------------------------------------------------------------ grbl
def grbl_port_open(timeout=5):
"""True when the Grbl port accepts a connection (closed again at once)."""
try:
s = socket.create_connection((os.environ.get("GRBL_HOST") or "127.0.0.1",
int(os.environ.get("GRBL_PORT") or 23)), timeout=timeout)
s.close()
return True
except OSError:
return False
class Grbl:
"""Minimal Grbl-over-TCP client. The suite is the only client while a
motion test runs; nothing here is used to poll status when a sender
+2
View File
@@ -70,6 +70,7 @@ tr:last-child td{border-bottom:0}
.badge.live{background:#fbe1e1;color:#a11}
.badge.operator{background:#fdf3e3;color:#8a5200}
.badge.takeover{background:#e6e8f5;color:#33407a}
.badge.mode{background:#e3f1e8;color:#1f5e3a}
.badge.core{background:var(--navy);color:#fff}
.st{font-weight:600}
.st.pass{color:var(--ok)}.st.inherited{color:var(--inh)}.st.fail,.st.error{color:var(--red)}
@@ -287,6 +288,7 @@ function buildGroups(){var groups={},order=[];
groups[g].forEach(function(t){var d=esc(t.id);var badges='';
if(t.always)badges+="<span class='badge core'>core</span>";badges+="<span class='badge "+esc(t.kind)+"'>"+esc(t.kind)+"</span>";
if(t.hardware==='takeover')badges+="<span class='badge takeover'>takeover</span>";
if(t.mode)badges+="<span class='badge mode'>"+esc(t.mode)+"</span>";
var det="<div class='details"+(openDetails[t.id]?' on':'')+"' id='det-"+d+"'>"+
(t.description?("<div class='dsc'>"+esc(t.description)+"</div>"):'')+
(t.steps&&t.steps.length?"<b>Operator steps:</b><ol>"+t.steps.map(function(x){return '<li>'+esc(x)+'</li>'}).join('')+"</ol>":'')+
+17 -4
View File
@@ -573,10 +573,14 @@ class Runner:
"stopped": b["stopped"], "finished": b["finished"]}
# -- baseline around every run -----------------------------------------
def _baseline_pre(self, run):
def _baseline_pre(self, run, mode=None):
"""Bring the machine to the fresh-boot idle state before a run and
record what the previous run left behind. Returns the captured
preserved state for the post pass."""
record what the previous run left behind; then, for a test that
declares a controller mode, put the machine in that mode. The
preserved state is captured after the switch, so the post pass
keeps the mode the test asked for: a queue that crosses from the
cloud tests to a motion test switches once, there, and stays.
Returns the captured preserved state for the post pass."""
bl = _baseline.Baseline(run.log, abort=run.aborted.is_set)
left = bl.enforce("pre", captured=None)
if left:
@@ -584,6 +588,15 @@ class Runner:
self.messages.append("leftovers before %s (left by %s): %s"
% (run.id, who, "; ".join(str(x) for x in left)))
run.evidence["baseline"] = {"pre": [x.as_dict() for x in left]}
if mode:
found = bl.mode
ok, detail = bl.switch_mode(mode)
run.log("mode: test needs %s - %s" % (mode, detail))
run.evidence["mode"] = {"needs": mode, "found": found, "switched": found != mode,
"ok": ok, "detail": detail}
if not ok:
raise Failed("the test needs %s mode and the machine could not be brought "
"there: %s" % (mode, detail))
run.baseline_captured = bl.capture()
return run.baseline_captured
@@ -601,7 +614,7 @@ class Runner:
result, message = _campaign.PASS, ""
captured = None
try:
captured = self._baseline_pre(run)
captured = self._baseline_pre(run, mode=t.mode)
t.fn(ctx)
if run.aborted.is_set():
result, message = _campaign.ABORTED, "aborted"
+3 -14
View File
@@ -4,7 +4,6 @@ grbl -> cloud -> grbl round trip; the job-behavior tests run in cloud mode
and leave the machine there (see enter_cloud)."""
import json
import os
import socket
import time
from ..catalog import test
@@ -91,18 +90,8 @@ def wait_mode(ctx, fc, want_mode, want_controller="running", timeout=90):
return last
def grbl_port_open(timeout=5):
try:
s = socket.create_connection((os.environ.get("GRBL_HOST") or "127.0.0.1",
int(os.environ.get("GRBL_PORT") or 23)), timeout=timeout)
s.close()
return True
except OSError:
return False
@test("cloud.mode-switch", title="Controller mode switch grbl -> cloud -> grbl", subsystem="cloud",
kind="auto", est_min=4,
kind="auto", mode="grbl", est_min=4,
covers=_CLOUD_COVERS, requires=["forgectrl.auth", "motion.pacing"],
steps=["Bed clear: the cloud client homes the head to the corner on connect (the factory "
"hunt) and the test jogs it back to where it started afterward. Cloud credentials "
@@ -174,7 +163,7 @@ def mode_switch(ctx):
"grbl controller did not come back: %s", m)
ctx.check(m.get("motion") != "fault", "motion fault after the switch")
ctx.sleep(3)
ctx.check(grbl_port_open(), "Grbl port not open after the switch back")
ctx.check(hw.grbl_port_open(), "Grbl port not open after the switch back")
with ctx.grbl() as g:
st = g.status_report()["state"]
ev["grbl_state"] = st
@@ -199,7 +188,7 @@ def mode_switch(ctx):
@test("cloud.gfhome-homing", title="Glowforge web-service homing ($H with homing_mode=gfcloud)",
subsystem="cloud", kind="operator", est_min=5,
subsystem="cloud", kind="operator", mode="grbl", est_min=5,
covers=_CLOUD_COVERS + [("grblhal-glowforge", "src/**")], requires=[],
steps=["homing_mode = gfcloud and cloud credentials configured; bed clear, lid closed.",
"Watch the gantry: the service drives it to the corner with camera corrections.",
+1 -1
View File
@@ -64,7 +64,7 @@ def flow_verify(ctx):
@test("cooling.fans-quiet-after-motion", title="Fan profile returns to idle after motion and after M8/M9",
subsystem="cooling", kind="auto", est_min=2,
subsystem="cooling", kind="auto", mode="grbl", est_min=2,
covers=_COOL_COVERS + [("forgectrl", "src/super.c")], requires=["motion.pacing"],
steps=["Bed clear; the head needs 20 mm of free +X travel."],
description="A dry jog and an M8/M9 cycle must not leave the run fan profile on: within "
+6 -6
View File
@@ -229,7 +229,7 @@ def wait_disarm(ctx, timeout):
@test("laser.power-floor", title="The shipped duty floor holds commanded power above the lasing threshold",
subsystem="laser", kind="auto", est_min=1,
subsystem="laser", kind="auto", mode="grbl", est_min=1,
covers=[("grblhal-glowforge", "src/**")],
description="$35 floors the bottom of the laser's output range, and unfloored the low end "
"of S asks for pulses too far apart for the discharge to re-strike - a "
@@ -274,7 +274,7 @@ def power_floor(ctx):
@test("laser.emission-witness", title="Live emission witness (S400 vector mark) and job-based disarm",
subsystem="laser", kind="live", always=True, est_min=5,
subsystem="laser", kind="live", mode="grbl", always=True, est_min=5,
covers=_LASER_COVERS,
requires=["kernel.latch-locked-idle", "kernel.k1-k2", "motion.jog-roundtrip"],
steps=["Scrap under the head with 20 mm of free +X and +Y travel; lid closed; exhaust on.",
@@ -334,7 +334,7 @@ def emission_witness(ctx):
@test("laser.disarm-in-hold", title="Disarm grace counts down in Hold", subsystem="laser",
kind="live", est_min=4,
kind="live", mode="grbl", est_min=4,
covers=_LASER_COVERS,
requires=["laser.emission-witness"],
steps=["Scrap under the head with 40 mm of free +X travel; lid closed; exhaust on.",
@@ -400,7 +400,7 @@ def disarm_in_hold(ctx):
@test("laser.armed-kill", title="Armed kill mid-fire: the expected stop, then a SIGKILL",
subsystem="laser", kind="live", est_min=6,
subsystem="laser", kind="live", mode="grbl", est_min=6,
covers=_LASER_COVERS + [("forgectrl", "src/main.c")],
requires=["laser.emission-witness", "motion.deadman"],
steps=["Scrap under the head with 40 mm of free +X and +Y travel; lid closed; exhaust on.",
@@ -497,7 +497,7 @@ def armed_kill(ctx):
@test("laser.arm-wait-lid", title="Lid open during the arm wait cancels the job",
subsystem="laser", kind="operator", est_min=3,
subsystem="laser", kind="operator", mode="grbl", est_min=3,
covers=_LASER_COVERS + [("grblhal-glowforge", "src/glowforge_laser.c"),
("grblhal-glowforge", "src/glowforge_switches.c"),
("grblhal-glowforge", "src/glowforge_switch_map.h")],
@@ -568,7 +568,7 @@ def arm_wait_lid(ctx):
@test("laser.pause-resume-lid-cancel", title="One live cut: the button pauses and resumes it, the lid "
"cancels it and sends the head home",
subsystem="laser", kind="live", est_min=7,
subsystem="laser", kind="live", mode="grbl", est_min=7,
covers=_LASER_COVERS + [("grblhal-glowforge", "src/glowforge_switches.c"),
("grblhal-glowforge", "src/glowforge_switch_map.h")],
requires=["laser.emission-witness", "motion.lid-cancel-home", "motion.button-hold-resume"],
+16 -10
View File
@@ -19,7 +19,13 @@ _MOTION_COVERS = [("grblhal-glowforge", "src/**"), ("kernel-module-glowforge", "
def controller_pid():
pids = hw.pidof("grblHAL_glowfor")
if not pids:
raise Failed("controller process not found (grblHAL_glowforge)")
try:
st, m = hw.Forgectrl().get("/mode")
where = ("forgectrl reports mode=%s controller=%s" % (m.get("mode"), m.get("controller"))
if st == 200 and isinstance(m, dict) else "forgectrl /mode -> %s" % st)
except hw.HwError as e:
where = "forgectrl unreachable (%s)" % e
raise Failed("controller process not found (grblHAL_glowforge); %s" % where)
return pids[0]
@@ -133,7 +139,7 @@ def clean_slate(ctx, g):
@test("motion.pacing", title="Protocol-loop pacing (idle, parked, moving) and hold/resume position",
subsystem="motion", kind="auto", est_min=1,
subsystem="motion", kind="auto", mode="grbl", est_min=1,
covers=_MOTION_COVERS, requires=["kernel.latch-locked-idle"],
steps=["Bed clear; the head needs 30 mm of free +X travel."],
description="Idle CPU is low; a job parked in a completed feed hold is coarse-paced (not "
@@ -193,7 +199,7 @@ def pacing(ctx):
@test("motion.jog-roundtrip", title="Motion quality: bounded jogs, max rate, diagonal, hold/resume",
subsystem="motion", kind="operator", est_min=3,
subsystem="motion", kind="operator", mode="grbl", est_min=3,
covers=_MOTION_COVERS, requires=["kernel.latch-locked-idle"],
steps=["Park the head with at least 60 mm of free +X and 40 mm of free +Y travel; bed clear.",
"Watch the gantry: it must move on every jog and end where it started."],
@@ -372,7 +378,7 @@ def _liveness_masked_restart(ctx, fc, ev):
# ---------------------------------------------------------------- cancel / abort
@test("motion.cancel-abort", title="Jog cancel and controlled abort recover cleanly", subsystem="motion",
kind="auto", est_min=2,
kind="auto", mode="grbl", est_min=2,
covers=_MOTION_COVERS, requires=["motion.pacing"],
steps=["Bed clear; the head needs 40 mm of free +X travel."],
description="A jog-cancel (0x85) stops a jog short of its target and returns to Idle with "
@@ -463,7 +469,7 @@ def _return_x(ctx, delta_mm):
@test("motion.deadman", title="Dead-man: controller kill, controller hang, forgectrl restart mid-move",
subsystem="motion", kind="auto", est_min=4,
subsystem="motion", kind="auto", mode="grbl", est_min=4,
covers=_MOTION_COVERS + [("forgectrl", "src/main.c"), ("forgectrl", "init/**")],
requires=["motion.cancel-abort", "kernel.k1-k2"],
steps=["Bed clear; the head needs 40 mm of free +X travel and must not be at the left rail."],
@@ -693,7 +699,7 @@ def expect_cancel_and_return(ctx, g, ev, start, k0, why, tag):
@test("motion.button-hold-resume", title="The button pauses and resumes a job",
subsystem="motion", kind="operator", est_min=2,
subsystem="motion", kind="operator", mode="grbl", est_min=2,
covers=_LID_COVERS, requires=["motion.pacing"],
steps=["Bed clear; the head needs 40 mm of free +X travel. No laser is involved.",
"Press the button once when told (pause), and once more when told (resume)."],
@@ -751,7 +757,7 @@ def button_hold_resume(ctx):
@test("motion.lid-cancel-home", title="Lid open during a job - running or paused - cancels it and returns "
"to the job start",
subsystem="motion", kind="operator", est_min=5,
subsystem="motion", kind="operator", mode="grbl", est_min=5,
covers=_LID_COVERS, requires=["motion.pacing", "motion.cancel-abort"],
steps=["Bed clear; the head needs 40 mm of free +X travel. No laser is involved.",
"Open the lid when told, and leave it open until the head has come back (twice: once "
@@ -833,7 +839,7 @@ def lid_cancel_home(ctx):
@test("motion.interlock-cancel-home", title="The interlock loop cancels a job like the lid and returns to "
"the job start",
subsystem="motion", kind="operator", est_min=4,
subsystem="motion", kind="operator", mode="grbl", est_min=4,
covers=_LID_COVERS, requires=["motion.lid-cancel-home"],
steps=["Bed clear; the head needs 60 mm of free +X travel. No laser is involved.",
"Be able to open the remote-interlock loop: unplug the Pro's interlock plug, or pull the "
@@ -894,7 +900,7 @@ def interlock_cancel_home(ctx):
@test("motion.lid-policy-hold", title="lid_policy=hold parks the job in Door and a cycle start resumes it",
subsystem="motion", kind="operator", est_min=4,
subsystem="motion", kind="operator", mode="grbl", est_min=4,
covers=_LID_COVERS + [("forgectrl", "src/settings.*")], requires=["motion.lid-cancel-home"],
steps=["Bed clear; the head needs 40 mm of free +X travel. No laser is involved.",
"Open the lid when told, then close it when told; the job finishes after that."],
@@ -981,7 +987,7 @@ def _thread_sched(pid):
@test("motion.step-timing-under-load",
title="Step timing holds while userspace competes for the core",
subsystem="motion", kind="auto", est_min=2,
subsystem="motion", kind="auto", mode="grbl", est_min=2,
covers=_MOTION_COVERS, requires=["kernel.latch-locked-idle", "motion.jog-roundtrip"],
steps=["Bed clear, lid closed; the head needs >= 40 mm of free +X travel."],
description="The board has one core, so the thread that stamps steps onto the pulse grid "
+2 -2
View File
@@ -69,9 +69,9 @@ def _noop(ctx):
pass
def make_test(id, covers, always=False, requires=(), kind="auto", fn=None, subsystem=None):
def make_test(id, covers, always=False, requires=(), kind="auto", fn=None, subsystem=None, mode=None):
return catalog_mod.Test(id, "Title " + id, subsystem or id.split(".")[0], kind, "api",
covers, requires, always, 1, (), "desc", fn or _noop)
covers, requires, always, 1, (), "desc", fn or _noop, mode=mode)
def registry(*tests):
+195
View File
@@ -0,0 +1,195 @@
"""The controller mode a test declares: the runner puts the machine there
before the test and the baseline keeps it there afterward.
The cloud tests enter cloud mode and stay (the operator rule: no switch
back after every test), so a queue that runs on past them reaches the
motion tests with gfcloud as the controller and no grblHAL process to
find. A test that names its mode gets the switch made for it, once, in the
pre pass - after the leftovers are handled and before the preserved state
is captured, so the post pass hands back the mode the test asked for
rather than the one the run found.
"""
import os
import shutil
import socket
import tempfile
import threading
import time
import unittest
import helpers
from forgetest import baseline
from forgetest.log import Log
from forgetest.runner import Runner
class GrblPort:
"""A listening socket standing in for the Grbl port of grblHAL."""
def __init__(self):
self.sock = socket.socket()
self.sock.bind(("127.0.0.1", 0))
self.sock.listen(5)
os.environ["GRBL_HOST"] = "127.0.0.1"
os.environ["GRBL_PORT"] = str(self.sock.getsockname()[1])
self._stop = False
self._th = threading.Thread(target=self._accept, daemon=True)
self._th.start()
def _accept(self):
self.sock.settimeout(0.2)
while not self._stop:
try:
c, _ = self.sock.accept()
c.close()
except OSError:
pass
def close(self):
self._stop = True
self.sock.close()
for k in ("GRBL_HOST", "GRBL_PORT"):
os.environ.pop(k, None)
def cloud(fc):
fc.state["mode"] = {"mode": "cloud", "controller": "running", "pid": 7, "motion": "verified"}
fc.state["settings"]["controller_mode"] = "cloud"
class SwitchModeTests(unittest.TestCase):
"""Baseline.switch_mode against the fake forgectrl."""
def setUp(self):
self.fc = helpers.FakeForgectrl().start()
self.port = GrblPort()
baseline.Baseline._unreachable_until = 0.0
self.lines = []
def tearDown(self):
self.port.close()
self.fc.stop()
def bl(self):
return baseline.Baseline(self.lines.append)
def test_already_in_the_mode_posts_nothing(self):
ok, detail = self.bl().switch_mode("grbl")
self.assertTrue(ok, detail)
self.assertEqual(self.fc.posts, [])
def test_cloud_to_grbl_switches_and_waits_for_the_port(self):
cloud(self.fc)
b = self.bl()
ok, detail = b.switch_mode("grbl")
self.assertTrue(ok, detail)
self.assertEqual(self.fc.posts, [("/mode", {"controller": "grbl"})])
self.assertEqual(self.fc.state["mode"]["mode"], "grbl")
self.assertEqual(b.mode, "grbl")
def test_a_controller_that_never_comes_up_is_reported(self):
cloud(self.fc)
self.fc.on_post = lambda path, form: (200, {"ok": True}) # accepted, nothing happens
ok, detail = self.bl().switch_mode("grbl", timeout=2)
self.assertFalse(ok)
self.assertIn("did not come up", detail)
def test_a_refused_switch_is_reported(self):
cloud(self.fc)
self.fc.on_post = lambda path, form: (409, {"error": "busy"})
ok, detail = self.bl().switch_mode("grbl")
self.assertFalse(ok)
self.assertIn("409", detail)
def test_grbl_needs_the_port_open(self):
cloud(self.fc)
self.port.close() # grblHAL running, port never listening
os.environ["GRBL_HOST"], os.environ["GRBL_PORT"] = "127.0.0.1", "1"
old = baseline.GRBL_PORT_S
baseline.GRBL_PORT_S = 2
try:
ok, detail = self.bl().switch_mode("grbl")
finally:
baseline.GRBL_PORT_S = old
self.assertFalse(ok)
self.assertIn("Grbl port", detail)
class RunnerModeTests(unittest.TestCase):
"""The runner end to end: a declared mode is established before the
test function runs and kept by the post pass."""
def setUp(self):
self.tmp = tempfile.mkdtemp(prefix="forgetest-mode-")
os.environ["FORGETEST_DATA"] = self.tmp
os.environ["FORGETEST_MARKER"] = os.path.join(self.tmp, "marker")
self.fc = helpers.FakeForgectrl().start()
self.port = GrblPort()
baseline.Baseline._unreachable_until = 0.0
self.seen = {}
fc, seen = self.fc, self.seen
def t_sees_mode(ctx):
seen["mode"] = fc.state["mode"]["mode"]
reg = helpers.registry(
helpers.make_test("m.grbl", [("forgectrl", "src/ui.c")], fn=t_sees_mode, mode="grbl"),
helpers.make_test("m.any", [("forgectrl", "src/ui.c")], fn=t_sees_mode),
)
self.runner = Runner(Log(os.path.join(self.tmp, "results.jsonl")), helpers.make_manifest(), reg)
def tearDown(self):
self.port.close()
self.fc.stop()
shutil.rmtree(self.tmp, ignore_errors=True)
for k in ("FORGETEST_DATA", "FORGETEST_MARKER"):
os.environ.pop(k, None)
def run_test(self, tid):
ok, msg = self.runner.start_test(tid)
self.assertTrue(ok, msg)
run = self.runner.current
deadline = time.time() + 30
while not run.finished and time.time() < deadline:
time.sleep(0.05)
self.assertTrue(run.finished, "run did not finish")
return run
def test_grbl_test_found_in_cloud_mode_gets_the_switch_and_keeps_it(self):
cloud(self.fc)
run = self.run_test("m.grbl")
self.assertEqual(run.finished["result"], "PASS", run.finished)
self.assertEqual(self.seen["mode"], "grbl")
# one switch, made before the test; the post pass keeps grbl
self.assertEqual(self.fc.posts, [("/mode", {"controller": "grbl"})])
self.assertEqual(self.fc.state["mode"]["mode"], "grbl")
self.assertEqual(run.baseline_captured["mode"], "grbl")
self.assertEqual(run.evidence["mode"]["found"], "cloud")
self.assertTrue(run.evidence["mode"]["switched"])
self.assertTrue(any("mode: test needs grbl" in ln for ln in run.lines), run.lines)
def test_grbl_test_in_grbl_mode_switches_nothing(self):
run = self.run_test("m.grbl")
self.assertEqual(run.finished["result"], "PASS", run.finished)
self.assertEqual(self.fc.posts, [])
self.assertFalse(run.evidence["mode"]["switched"])
def test_undeclared_test_runs_in_the_mode_it_finds(self):
cloud(self.fc)
run = self.run_test("m.any")
self.assertEqual(run.finished["result"], "PASS", run.finished)
self.assertEqual(self.seen["mode"], "cloud")
self.assertEqual(self.fc.posts, [])
self.assertNotIn("mode", run.evidence)
def test_a_switch_that_fails_fails_the_test_before_it_runs(self):
cloud(self.fc)
self.fc.on_post = lambda path, form: (503, {"error": "supervisor busy"})
run = self.run_test("m.grbl")
self.assertEqual(run.finished["result"], "FAIL")
self.assertIn("needs grbl mode", run.finished["message"])
self.assertNotIn("mode", self.seen)
if __name__ == "__main__":
unittest.main()