mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-09-27 16:51:12 -07:00
forgetest: the operator's part asked for by name, and fewer hands in a campaign
A campaign asked a person for about eighty things: lid, button and interlock actions, app jobs, and sixteen confirmations by eye, most of them as popups to read and answer while the head was already moving. This is the forgetest-only step of cutting that down. The operator channel. A test asks for its operator's part in four ways: ctx.ready() pre-announces a timed step and waits for the click that starts it; ctx.notice() is a standing instruction with no button, the test watching the machine for the result; ctx.act(channel, state) is a machine action by name (lid, interlock, button) - a notice for the operator today, proven done by the switch reading or an `until` condition, recorded in evidence.actions with who performed it, and the seam a bench actuator plugs into through runner.fixture; ctx.confirm() stays for the yes/no the evidence cannot answer. Tests declare `actions`; a `precheck` refuses a start the machine cannot honor (a reason, no result, a queue skips it and carries on). The page shows what you will do before it is asked: the running test's steps, a queue's attended tests still waiting, or the test whose title you clicked while idle; notices and prompts sit under it. The campaign card no longer carries baseline, queue and leftover notes: those go to the runner journal (daemon.log, syslog as `forgetest`, and the run in progress), with a Runner journal button in the footer. The catalog, 43 tests (27 auto, 8 operator, 8 live; was 45: 25/12/8): cloud.mode-switch absorbs cloud.hunt-lid-open and cloud.gfhome-homing (the connect made with the lid open, the hunt judged lid-open with its Z cycle, the re-hunt waited out after the close, the switch back, then $H judged by gfhome's own "homing complete" line with its motion windows; precheck homing_mode = gfcloud). kernel.fire-line is auto with the HV-not-good precheck, camera.snapshot is auto (a second frame with the lid lamp off differs and is smaller), motion.jog-roundtrip is auto (the head accelerometer per leg, the supervisor's own witness). The remaining attended tests use Ready gates and act(); the head's beam detector and the button LEDs replace the eye, leaving two confirms: the emission witness's mark and the app's display in cloud.pause-resume. Proof: tests/test_operator.py (the channel, the precheck, the journal), the cloud replays re-targeted to the merged round trip over a fake grbl and the bench excerpts, 196 host tests green, coverage lint 0 uncovered. Every re-ported attended test is owed one bench run on the next dev image (BRINGUP). Catalog consequence: the merged and reclassified tests' implementation hashes move; nothing else is invalidated.
This commit is contained in:
@@ -69,9 +69,11 @@ def _noop(ctx):
|
||||
pass
|
||||
|
||||
|
||||
def make_test(id, covers, always=False, requires=(), kind="auto", fn=None, subsystem=None, mode=None):
|
||||
def make_test(id, covers, always=False, requires=(), kind="auto", fn=None, subsystem=None, mode=None,
|
||||
actions=(), precheck=None, steps=()):
|
||||
return catalog_mod.Test(id, "Title " + id, subsystem or id.split(".")[0], kind, "api",
|
||||
covers, requires, always, 1, (), "desc", fn or _noop, mode=mode)
|
||||
covers, requires, always, 1, steps, "desc", fn or _noop, mode=mode,
|
||||
actions=actions, precheck=precheck)
|
||||
|
||||
|
||||
def registry(*tests):
|
||||
@@ -165,3 +167,90 @@ class FakeForgectrl:
|
||||
self._srv.server_close()
|
||||
os.environ.pop("FORGECTRL_URL", None)
|
||||
os.environ.pop("FORGECTRL_TOKEN_FILE", None)
|
||||
|
||||
|
||||
# ------------------------------------------------------------ fake grbl
|
||||
|
||||
class FakeGrbl:
|
||||
"""A Grbl-over-TCP stand-in for the controller: answers '?' with a
|
||||
status report built from its mutable `state`/`mpos`, 'ok' to every
|
||||
command line, and records what it was sent. Point the suite at it
|
||||
with GRBL_HOST/GRBL_PORT (see start/stop)."""
|
||||
|
||||
def __init__(self):
|
||||
import socket as _socket
|
||||
import threading as _threading
|
||||
self.state = "Idle"
|
||||
self.mpos = [0.0, 0.0, 0.0]
|
||||
self.sent = []
|
||||
self.extra = b"" # text pushed to the client on the next poll
|
||||
self._sock = _socket.socket()
|
||||
self._sock.setsockopt(_socket.SOL_SOCKET, _socket.SO_REUSEADDR, 1)
|
||||
self._sock.bind(("127.0.0.1", 0))
|
||||
self._sock.listen(5)
|
||||
self.port = self._sock.getsockname()[1]
|
||||
self._stop = False
|
||||
self._th = _threading.Thread(target=self._serve, daemon=True)
|
||||
|
||||
def _serve(self):
|
||||
import socket as _socket
|
||||
import threading as _threading
|
||||
self._sock.settimeout(0.2)
|
||||
while not self._stop:
|
||||
try:
|
||||
c, _ = self._sock.accept()
|
||||
except (_socket.timeout, OSError):
|
||||
continue
|
||||
_threading.Thread(target=self._client, args=(c,), daemon=True).start()
|
||||
|
||||
def _client(self, c):
|
||||
import socket as _socket
|
||||
c.settimeout(0.1)
|
||||
buf = b""
|
||||
try:
|
||||
c.sendall(b"\r\nGrblHAL 1.1f ['$' for help]\r\n")
|
||||
while not self._stop:
|
||||
try:
|
||||
d = c.recv(4096)
|
||||
if not d:
|
||||
break
|
||||
buf += d
|
||||
except _socket.timeout:
|
||||
continue
|
||||
except OSError:
|
||||
break
|
||||
out = b""
|
||||
if self.extra:
|
||||
out += self.extra
|
||||
self.extra = b""
|
||||
while buf:
|
||||
if buf[0:1] == b"?":
|
||||
buf = buf[1:]
|
||||
out += ("<%s|MPos:%.3f,%.3f,%.3f|FS:0,0>\r\n" % (self.state, *self.mpos)).encode()
|
||||
elif buf[0] in (0x18, 0x85, 0x7E, 0x21):
|
||||
buf = buf[1:]
|
||||
elif b"\n" in buf:
|
||||
line, buf = buf.split(b"\n", 1)
|
||||
self.sent.append(line.strip().decode("utf-8", "replace"))
|
||||
out += b"ok\r\n"
|
||||
else:
|
||||
break
|
||||
if out:
|
||||
c.sendall(out)
|
||||
finally:
|
||||
c.close()
|
||||
|
||||
def start(self):
|
||||
self._th.start()
|
||||
os.environ["GRBL_HOST"] = "127.0.0.1"
|
||||
os.environ["GRBL_PORT"] = str(self.port)
|
||||
return self
|
||||
|
||||
def stop(self):
|
||||
self._stop = True
|
||||
try:
|
||||
self._sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
for k in ("GRBL_HOST", "GRBL_PORT"):
|
||||
os.environ.pop(k, None)
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"""The cloud.* job-behavior tests against a fake forgectrl and a replayed
|
||||
gfcloud log: the real test functions run under the real runner Context,
|
||||
the operator prompts answered by a script that also drives the replay
|
||||
(open the lid -> the machine's lid reads open; close it -> the service's
|
||||
re-hunt lines land). The log lines are the machine's own: bench excerpts
|
||||
the operator prompts answered - and the standing notices of the machine
|
||||
actions acted on - by a script that also drives the replay (open the
|
||||
lid -> the machine's lid reads open; close it -> the service's re-hunt
|
||||
lines land). The log lines are the machine's own: bench excerpts
|
||||
(the lid-abort, button-wait, and lid-open-hunt runs) and, for the pause,
|
||||
the run loop's lines as its host test emits them on the print skeleton
|
||||
of the lid-abort excerpt.
|
||||
@@ -62,13 +63,16 @@ def cut(lines, marker, count=1):
|
||||
|
||||
|
||||
class Script:
|
||||
"""Answers every prompt with its first option; a hook per prompt
|
||||
substring drives the fake machine and the log replay."""
|
||||
"""Answers every prompt with its first option; a hook per prompt or
|
||||
notice substring drives the fake machine and the log replay (a
|
||||
machine action is a notice the test watches the machine for, so the
|
||||
hook is what makes the machine show it)."""
|
||||
|
||||
def __init__(self, run, hooks=None):
|
||||
self.run = run
|
||||
self.hooks = hooks or {}
|
||||
self.asked = []
|
||||
self.noticed = []
|
||||
self.th = threading.Thread(target=self._loop, daemon=True)
|
||||
self.stop = False
|
||||
|
||||
@@ -76,16 +80,25 @@ class Script:
|
||||
self.th.start()
|
||||
return self
|
||||
|
||||
def _fire(self, text):
|
||||
for key, fn in self.hooks.items():
|
||||
if key in text:
|
||||
fn()
|
||||
|
||||
def _loop(self):
|
||||
seen = None
|
||||
seen_n = None
|
||||
while not self.stop:
|
||||
n = self.run.notice
|
||||
if n and n["id"] != seen_n:
|
||||
seen_n = n["id"]
|
||||
self.noticed.append(n["text"])
|
||||
self._fire(n["text"])
|
||||
p = self.run.prompt
|
||||
if p and p["id"] != seen:
|
||||
seen = p["id"]
|
||||
self.asked.append(p["question"])
|
||||
for key, fn in self.hooks.items():
|
||||
if key in p["question"]:
|
||||
fn()
|
||||
self._fire(p["question"])
|
||||
self.run.answer(p["id"], p["options"][0])
|
||||
time.sleep(0.02)
|
||||
|
||||
@@ -100,13 +113,23 @@ class CloudSuiteTests(unittest.TestCase):
|
||||
self._attr("cnc/interlock_circuit", "45") # latch locked (bit 3)
|
||||
self._pos(0, 0, 3)
|
||||
os.environ["GF_SYSFS_ROOT"] = self.sysfs
|
||||
# the button LEDs, dark (the tests check a cancel leaves them so)
|
||||
for n in ("button_led_1", "button_led_2", "button_led_3"):
|
||||
os.makedirs(os.path.join(self.tmp, "leds", n), exist_ok=True)
|
||||
with open(os.path.join(self.tmp, "leds", n, "brightness"), "w") as f:
|
||||
f.write("0")
|
||||
os.environ["GF_LEDS_ROOT"] = os.path.join(self.tmp, "leds") + os.sep
|
||||
self.fc = helpers.FakeForgectrl().start()
|
||||
self.grbl = None
|
||||
self.fclog = os.path.join(self.tmp, "forgectrl.log")
|
||||
open(self.fclog, "wb").close()
|
||||
self.homelog = os.path.join(self.tmp, "gfhome.log")
|
||||
open(self.homelog, "wb").close()
|
||||
self.saved = (cloud.GFCLOUD_LOG, cloud.FORGECTRL_LOG, cloud.QUIET_S, cloud.QUIET_TIMEOUT_S,
|
||||
cloud.HUNT_TIMEOUT_S)
|
||||
cloud.HUNT_TIMEOUT_S, cloud.GFHOME_LOG)
|
||||
cloud.GFCLOUD_LOG = self.log
|
||||
cloud.FORGECTRL_LOG = self.fclog
|
||||
cloud.GFHOME_LOG = self.homelog
|
||||
self.engine_line = EFFECTIVE_LINE # what the engine logs at the print; None = nothing
|
||||
self.client_limits = True # the client names its header limits
|
||||
cloud.QUIET_S = 0.4
|
||||
@@ -118,9 +141,12 @@ class CloudSuiteTests(unittest.TestCase):
|
||||
if self.script:
|
||||
self.script.stop = True
|
||||
self.fc.stop()
|
||||
if self.grbl:
|
||||
self.grbl.stop()
|
||||
(cloud.GFCLOUD_LOG, cloud.FORGECTRL_LOG, cloud.QUIET_S, cloud.QUIET_TIMEOUT_S,
|
||||
cloud.HUNT_TIMEOUT_S) = self.saved
|
||||
cloud.HUNT_TIMEOUT_S, cloud.GFHOME_LOG) = self.saved
|
||||
os.environ.pop("GF_SYSFS_ROOT", None)
|
||||
os.environ.pop("GF_LEDS_ROOT", None)
|
||||
shutil.rmtree(self.tmp, ignore_errors=True)
|
||||
|
||||
# -- fakes -----------------------------------------------------------
|
||||
@@ -222,80 +248,107 @@ class CloudSuiteTests(unittest.TestCase):
|
||||
self.assertFails(cloud.enter_cloud, "still running service moves")
|
||||
|
||||
# -- the hunt with the lid open ------------------------------------------
|
||||
def test_hunt_lid_open_restarts_the_client_in_cloud_mode(self):
|
||||
self.in_cloud(pid=1927)
|
||||
# -- the mode switch: hunt with the lid open, then $H -----------------------
|
||||
HUNT_RUN_SAMPLE = {"phase": "run", "verdict": "ok", "armed": False,
|
||||
"fan_gates": {"exhaust": {"state": "unjudged", "reading": 0, "floor": 500}}}
|
||||
|
||||
def mode_switch_setup(self, hunt_lines=None, home_complete=True):
|
||||
"""The fakes a mode-switch run needs: grbl to answer $H, the lid
|
||||
lamp attr, homing_mode = gfcloud, the service lines landing on
|
||||
the switch to cloud (the hunt reads as a run to the cooling
|
||||
engine while it lasts), the re-hunt on the lid close, and gfhome
|
||||
finishing the homing after $H."""
|
||||
self.grbl = helpers.FakeGrbl().start()
|
||||
os.makedirs(self.sysfs + "pic", exist_ok=True)
|
||||
self._attr("pic/lid_led", "236")
|
||||
self.fc.state["settings"]["homing_mode"] = "gfcloud"
|
||||
lines = fixture("huntlid")
|
||||
pre, post = cut(lines, "gfuiservice:__init__ INITIALIZED")
|
||||
hunt_part, close_part = cut(post, "_switch_event lid closed")
|
||||
self.append(pre)
|
||||
if hunt_lines is not None:
|
||||
hunt_part = hunt_lines(hunt_part)
|
||||
fc = self.fc
|
||||
|
||||
def on_post(path, form):
|
||||
if path == "/controller/stop":
|
||||
self.fc.state["mode"] = dict(self.fc.state["mode"], controller="standby", pid=0)
|
||||
elif path == "/controller/start":
|
||||
self.fc.state["mode"] = dict(self.fc.state["mode"], controller="running", pid=2278)
|
||||
self.append(hunt_part, delay=0.2)
|
||||
if path == "/mode" and form.get("controller") == "cloud":
|
||||
fc.state["cool"] = dict(self.HUNT_RUN_SAMPLE)
|
||||
|
||||
def land():
|
||||
self.append(pre, delay=0.0)
|
||||
time.sleep(0.3)
|
||||
self.append(hunt_part, delay=0.0)
|
||||
time.sleep(4.0) # the hunt outlasts the session wait on the bench
|
||||
fc.state["cool"] = {"phase": "idle", "armed": False, "hold": False}
|
||||
threading.Thread(target=land, daemon=True).start()
|
||||
elif path == "/mode" and form.get("controller") == "grbl":
|
||||
self.grbl.state = "Idle"
|
||||
return None
|
||||
self.fc.on_post = on_post
|
||||
|
||||
def lid_closed():
|
||||
self.lid(True)
|
||||
self.append(close_part, delay=0.2)
|
||||
run = self.run_test(cloud.hunt_lid_open,
|
||||
hooks={"Open the lid": lambda: self.lid(False), "Close the lid": lid_closed},
|
||||
test_id="cloud.hunt-lid-open")
|
||||
self.assertEqual([p for p, _ in self.fc.posts], ["/controller/stop", "/controller/start"])
|
||||
|
||||
def homing():
|
||||
self.grbl.state = "Home"
|
||||
time.sleep(0.4)
|
||||
self.grbl.state = "Idle"
|
||||
self.fc.state["status"]["homed"] = True
|
||||
if home_complete:
|
||||
with open(self.homelog, "ab") as f:
|
||||
f.write(b"2026-08-17T09:46:00.000000+00:00 gfhome[2300] INFO homing complete "
|
||||
b"(service quiet 8s, 3 motion windows)\n")
|
||||
self.home_hook = homing
|
||||
return {"Open the lid.": lambda: self.lid(False), "Close the lid.": lid_closed}
|
||||
|
||||
def wait_home_command(self):
|
||||
while "$H" not in self.grbl.sent:
|
||||
time.sleep(0.02)
|
||||
self.home_hook()
|
||||
|
||||
def test_mode_switch_round_trip_with_the_hunt_lid_open_and_the_homing(self):
|
||||
hooks = self.mode_switch_setup()
|
||||
threading.Thread(target=self.wait_home_command, daemon=True).start()
|
||||
run = self.run_test(cloud.mode_switch, hooks=hooks, test_id="cloud.mode-switch")
|
||||
ev = run.evidence
|
||||
posts = [(p, f.get("controller")) for p, f in self.fc.posts if p == "/mode"]
|
||||
self.assertEqual(posts, [("/mode", "cloud"), ("/mode", "grbl")])
|
||||
self.assertEqual(self.fc.state["mode"]["mode"], "grbl")
|
||||
self.assertIn(":completed", ev["hunt_line"])
|
||||
self.assertEqual(ev["refusals_before_hunt_end"], 0)
|
||||
self.assertTrue(ev["lens_homed"])
|
||||
self.assertEqual(ev["motions_after_lid_close"], 3)
|
||||
self.assertEqual(self.fc.state["mode"]["mode"], "cloud") # still cloud
|
||||
self.assertTrue(any("PASS:" in l for l in run.lines))
|
||||
# the prompts, in order: open, confirm lens, close
|
||||
self.assertEqual(len(self.script.asked), 3)
|
||||
self.assertTrue(all(c.get("verdict") != "AIRFLOW" for c in ev["hunt_gates"]))
|
||||
self.assertTrue(ev["homed"])
|
||||
self.assertIn("3 motion windows", ev["gfhome_complete"])
|
||||
self.assertIn("$H", self.grbl.sent)
|
||||
# the lid was a machine action, not a prompt: two notices, no questions
|
||||
self.assertEqual([r["state"] for r in ev["actions"]], ["open", "close"])
|
||||
self.assertEqual(self.script.asked, [])
|
||||
self.assertTrue(any("PASS:" in l for l in run.lines), run.lines[-5:])
|
||||
|
||||
def test_hunt_lid_open_from_grbl_switches_and_stays(self):
|
||||
lines = fixture("huntlid")
|
||||
pre, post = cut(lines, "gfuiservice:__init__ INITIALIZED")
|
||||
hunt_part, close_part = cut(post, "_switch_event lid closed")
|
||||
self.append(pre)
|
||||
def test_mode_switch_fails_when_the_hunt_is_refused_for_the_lid(self):
|
||||
def refused(hunt_part):
|
||||
i = next(i for i, l in enumerate(hunt_part) if "z_axis:home starting z homing cycle" in l)
|
||||
return hunt_part[:i] + ["2026-08-17T09:45:10.595000+00:00 gfcloud[2278] INFO machine:_safe_to_move "
|
||||
"lid opened, unsafe to move"] + hunt_part[i:]
|
||||
hooks = self.mode_switch_setup(hunt_lines=refused)
|
||||
self.assertFails(cloud.mode_switch, "refused for the lid", hooks=hooks)
|
||||
|
||||
def on_post(path, form):
|
||||
if path == "/mode":
|
||||
self.append(hunt_part, delay=0.2)
|
||||
return None
|
||||
self.fc.on_post = on_post
|
||||
run = self.run_test(cloud.hunt_lid_open,
|
||||
hooks={"Open the lid": lambda: self.lid(False),
|
||||
"Close the lid": lambda: (self.lid(True), self.append(close_part, delay=0.2))})
|
||||
self.assertEqual([p for p, _ in self.fc.posts], ["/mode"])
|
||||
self.assertEqual(self.fc.state["mode"]["mode"], "cloud")
|
||||
self.assertEqual(run.baseline_captured["mode"], "cloud")
|
||||
self.assertIn(":completed", run.evidence["hunt_line"])
|
||||
def test_mode_switch_fails_when_the_hunt_skipped_the_lens(self):
|
||||
hooks = self.mode_switch_setup(hunt_lines=lambda part: [l for l in part if "z homing cycle" not in l])
|
||||
self.assertFails(cloud.mode_switch, "did not home the lens", hooks=hooks)
|
||||
|
||||
def test_hunt_lid_open_needs_the_lid_open(self):
|
||||
self.in_cloud()
|
||||
self.append(fixture("huntlid"))
|
||||
self.assertFails(cloud.hunt_lid_open, "lid reads closed")
|
||||
def test_mode_switch_fails_when_gfhome_never_saw_the_head_move(self):
|
||||
hooks = self.mode_switch_setup(home_complete=False)
|
||||
threading.Thread(target=self.wait_home_command, daemon=True).start()
|
||||
self.assertFails(cloud.mode_switch, "no 'homing complete' line", hooks=hooks)
|
||||
|
||||
def test_hunt_refused_for_the_lid_fails(self):
|
||||
self.in_cloud(pid=1927)
|
||||
lines = fixture("huntlid")
|
||||
pre, post = cut(lines, "gfuiservice:__init__ INITIALIZED")
|
||||
# a refusal ahead of the hunt's end (the lid gating the hunt would look like this)
|
||||
i = next(i for i, l in enumerate(post) if "z_axis:home starting z homing cycle" in l)
|
||||
post = post[:i] + ["2026-08-17T09:45:10.595000+00:00 gfcloud[2278] INFO machine:_safe_to_move lid opened, unsafe to move"] + post[i:]
|
||||
self.append(pre)
|
||||
|
||||
def on_post(path, form):
|
||||
if path == "/controller/stop":
|
||||
self.fc.state["mode"] = dict(self.fc.state["mode"], controller="standby", pid=0)
|
||||
elif path == "/controller/start":
|
||||
self.fc.state["mode"] = dict(self.fc.state["mode"], controller="running", pid=2278)
|
||||
self.append(post, delay=0.1)
|
||||
return None
|
||||
self.fc.on_post = on_post
|
||||
self.assertFails(cloud.hunt_lid_open, "refused for the lid", hooks={"Open the lid": lambda: self.lid(False)})
|
||||
def test_mode_switch_precheck_needs_gfcloud_homing(self):
|
||||
self.fc.state["settings"]["homing_mode"] = "switches"
|
||||
self.assertIn("needs gfcloud", cloud.homing_mode_is_gfcloud())
|
||||
self.fc.state["settings"]["homing_mode"] = "gfcloud"
|
||||
self.assertIsNone(cloud.homing_mode_is_gfcloud())
|
||||
|
||||
# -- pause / resume ---------------------------------------------------------
|
||||
def replay_print(self, name, at_run, at_end, tail_delay=0.3):
|
||||
@@ -327,7 +380,7 @@ class CloudSuiteTests(unittest.TestCase):
|
||||
self.append(["2026-08-17T09:41:00.000000+00:00 gfcloud[1522] INFO authentication:authenticate_machine SUCCESS",
|
||||
"2026-08-17T09:41:00.500000+00:00 gfcloud[1522] INFO websocket:_on_open RX-EVENT: ready",
|
||||
"2026-08-17T09:41:00.900000+00:00 gfcloud[1522] INFO websocket:ws_connect ESTABLISHED"])
|
||||
hooks = self.replay_print("pause", "Press the button once NOW", "current state: MachineState.IDLE")
|
||||
hooks = self.replay_print("pause", "the press pauses the print", "current state: MachineState.IDLE")
|
||||
self.fc.state["cool"]["armed"] = True
|
||||
run = self.run_test(cloud.pause_resume, hooks=hooks, test_id="cloud.pause-resume")
|
||||
ev = run.evidence
|
||||
@@ -351,7 +404,7 @@ class CloudSuiteTests(unittest.TestCase):
|
||||
self.client_limits = False
|
||||
self.in_cloud(pid=1522)
|
||||
self.append(["2026-08-17T09:41:00.500000+00:00 gfcloud[1522] INFO websocket:_on_open RX-EVENT: ready"])
|
||||
hooks = self.replay_print("pause", "Press the button once NOW", "current state: MachineState.IDLE")
|
||||
hooks = self.replay_print("pause", "the press pauses the print", "current state: MachineState.IDLE")
|
||||
self.fc.state["cool"]["armed"] = True
|
||||
self.assertFails(cloud.pause_resume, "named no job limits", hooks=hooks)
|
||||
|
||||
@@ -359,7 +412,7 @@ class CloudSuiteTests(unittest.TestCase):
|
||||
self.engine_line = EFFECTIVE_LINE.replace("header 33.0", "header none 0.0")
|
||||
self.in_cloud(pid=1522)
|
||||
self.append(["2026-08-17T09:41:00.500000+00:00 gfcloud[1522] INFO websocket:_on_open RX-EVENT: ready"])
|
||||
hooks = self.replay_print("pause", "Press the button once NOW", "current state: MachineState.IDLE")
|
||||
hooks = self.replay_print("pause", "the press pauses the print", "current state: MachineState.IDLE")
|
||||
self.fc.state["cool"]["armed"] = True
|
||||
self.assertFails(cloud.pause_resume, "never resolved an effective ceiling", hooks=hooks)
|
||||
|
||||
@@ -372,7 +425,7 @@ class CloudSuiteTests(unittest.TestCase):
|
||||
pre, rest = cut(lines, "current state: MachineState.RUNNING")
|
||||
pre, rest = pre + [rest[0]], rest[1:]
|
||||
hooks = {"Click Done here": lambda: self.append(pre, delay=0.1),
|
||||
"Press the button once NOW": lambda: self.append(rest, delay=0.05)}
|
||||
"the press pauses the print": lambda: self.append(rest, delay=0.05)}
|
||||
self.assertFails(cloud.pause_resume, "did not complete after the resume", hooks=hooks)
|
||||
|
||||
def test_pause_resume_fails_without_the_retraced_restart(self):
|
||||
@@ -385,7 +438,7 @@ class CloudSuiteTests(unittest.TestCase):
|
||||
pre, rest = pre + [rest[0]], rest[1:]
|
||||
with self.fast_wait_log():
|
||||
hooks = {"Click Done here": lambda: self.append(pre, delay=0.1),
|
||||
"Press the button once NOW": lambda: self.append(rest, delay=0.05)}
|
||||
"the press pauses the print": lambda: self.append(rest, delay=0.05)}
|
||||
self.assertFails(cloud.pause_resume, "no retraced restart logged", hooks=hooks)
|
||||
|
||||
def test_pause_resume_fails_when_the_kernel_refuses_the_resume(self):
|
||||
@@ -398,7 +451,7 @@ class CloudSuiteTests(unittest.TestCase):
|
||||
pre, rest = pre + [rest[0]], rest[1:]
|
||||
with self.fast_wait_log():
|
||||
hooks = {"Click Done here": lambda: self.append(pre, delay=0.1),
|
||||
"Press the button once NOW": lambda: self.append(rest, delay=0.05)}
|
||||
"the press pauses the print": lambda: self.append(rest, delay=0.05)}
|
||||
self.assertFails(cloud.pause_resume, "the resume was refused", hooks=hooks)
|
||||
|
||||
@contextlib.contextmanager
|
||||
@@ -410,10 +463,13 @@ class CloudSuiteTests(unittest.TestCase):
|
||||
def fast(ctx, offset, needles, timeout, poll=0.5):
|
||||
return saved(ctx, offset, needles, min(timeout, 1.5), poll=0.1)
|
||||
cloud.wait_log = fast
|
||||
press = cloud.PRESS_TIMEOUT_S
|
||||
cloud.PRESS_TIMEOUT_S = 1.5
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
cloud.wait_log = saved
|
||||
cloud.PRESS_TIMEOUT_S = press
|
||||
|
||||
def test_pause_resume_fails_without_the_pause_line(self):
|
||||
self.in_cloud(pid=1522)
|
||||
@@ -423,7 +479,7 @@ class CloudSuiteTests(unittest.TestCase):
|
||||
pre, rest = pre + [rest[0]], rest[1:]
|
||||
with self.fast_wait_log():
|
||||
hooks = {"Click Done here": lambda: self.append(pre, delay=0.1),
|
||||
"Press the button once NOW": lambda: self.append(rest, delay=0.05)}
|
||||
"the press pauses the print": lambda: self.append(rest, delay=0.05)}
|
||||
self.assertFails(cloud.pause_resume, "did not pause the run", hooks=hooks)
|
||||
|
||||
# -- the lid/interlock abort and the button-wait tests, on their excerpts ------
|
||||
@@ -455,12 +511,12 @@ class CloudSuiteTests(unittest.TestCase):
|
||||
self.lid(True)
|
||||
self.fc.state["status"]["switches"]["interlock_ok"] = True
|
||||
return {"Click Done here": next_print,
|
||||
"Open the lid NOW": lambda: (self.lid(False), self.append(lid_tail, delay=0.05)),
|
||||
"Close the lid, then click Done": lambda: self.lid(True),
|
||||
"Open the INTERLOCK loop now": pull_interlock,
|
||||
"Open the LID now as well": lambda: (self.lid(False),
|
||||
self.append(ilk_park, delay=0.05)),
|
||||
"Close the lid and restore the interlock": restore}
|
||||
"leave the lid open until the head has returned": lambda: (self.lid(False),
|
||||
self.append(lid_tail, delay=0.05)),
|
||||
"Close the lid.": lambda: self.lid(True),
|
||||
"Open the remote-interlock loop": pull_interlock,
|
||||
"on its way back": lambda: (self.lid(False), self.append(ilk_park, delay=0.05)),
|
||||
"Restore the remote-interlock loop": restore}
|
||||
|
||||
def test_lid_interlock_abort_on_the_bench_excerpt(self):
|
||||
self.in_cloud(pid=1522)
|
||||
@@ -527,7 +583,8 @@ class CloudSuiteTests(unittest.TestCase):
|
||||
pre, rest = cut(lines, "waiting for button")
|
||||
pre, rest = pre + [rest[0]], rest[1:]
|
||||
hooks = {"Click Done here": lambda: self.append(pre, delay=0.1),
|
||||
"Open the lid now": lambda: self.append(rest, delay=0.05)}
|
||||
"do NOT press it": lambda: (self.lid(False), self.append(rest, delay=0.05)),
|
||||
"Close the lid.": lambda: self.lid(True)}
|
||||
run = self.run_test(cloud.lid_during_button_wait, hooks=hooks, test_id="cloud.lid-during-button-wait")
|
||||
ev = run.evidence
|
||||
self.assertEqual(ev["runs_started_after_wait"], 0)
|
||||
@@ -563,11 +620,11 @@ class CloudSuiteTests(unittest.TestCase):
|
||||
self.append(pre, delay=0.1)
|
||||
run = self.run_test(cloud.pause_cancel_paths,
|
||||
hooks={"Click Done here": next_print,
|
||||
"Press the button once NOW": lambda: self.append(paused, delay=0.05),
|
||||
"Open the lid NOW": lambda: (self.lid(False),
|
||||
self.append(lid_tail, delay=0.05)),
|
||||
"Close the lid, then click Done": lambda: self.lid(True),
|
||||
"Cancel the print from the app now": lambda: self.append(app_tail,
|
||||
"the press pauses the print": lambda: self.append(paused, delay=0.05),
|
||||
"The print is paused: leave the lid open": lambda: (
|
||||
self.lid(False), self.append(lid_tail, delay=0.05)),
|
||||
"Close the lid.": lambda: self.lid(True),
|
||||
"cancel the print from the app now": lambda: self.append(app_tail,
|
||||
delay=0.05)},
|
||||
test_id="cloud.pause-cancel-paths")
|
||||
ev = run.evidence
|
||||
@@ -593,8 +650,9 @@ class CloudSuiteTests(unittest.TestCase):
|
||||
self.assertFails(
|
||||
cloud.pause_cancel_paths, "print 1 did not end ':cancelled'",
|
||||
hooks={"Click Done here": lambda: self.append(pre, delay=0.1),
|
||||
"Press the button once NOW": lambda: self.append(paused, delay=0.05),
|
||||
"Open the lid NOW": lambda: (self.lid(False), self.append(lid_tail, delay=0.05))})
|
||||
"the press pauses the print": lambda: self.append(paused, delay=0.05),
|
||||
"The print is paused: leave the lid open": lambda: (
|
||||
self.lid(False), self.append(lid_tail, delay=0.05))})
|
||||
|
||||
def test_print_finish_is_the_prints_not_another_actions(self):
|
||||
# a motion that completes before the print must not satisfy the print's finish
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
"""The operator channel: notices, `ready`, `act`, and the precheck.
|
||||
|
||||
A test asks for the operator's part in a way the page can show before
|
||||
it is needed and a bench actuator can later perform: `act` names a
|
||||
machine action and watches the machine for it (a standing notice for
|
||||
the human, no button to race), `ready` pre-announces a timed step, and
|
||||
a `precheck` refuses a start the machine cannot honor instead of
|
||||
recording a FAIL. Runner-level events go to the journal, never to the
|
||||
page state.
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
|
||||
import helpers
|
||||
from forgetest import catalog
|
||||
from forgetest.log import Log
|
||||
from forgetest.runner import Context, Failed, Run, Runner, journal
|
||||
|
||||
|
||||
class Catch(logging.Handler):
|
||||
def __init__(self):
|
||||
logging.Handler.__init__(self)
|
||||
self.lines = []
|
||||
|
||||
def emit(self, rec):
|
||||
self.lines.append(rec.getMessage())
|
||||
|
||||
|
||||
class Answerer:
|
||||
"""Answers every prompt with its first option and records it, and
|
||||
records every notice it sees."""
|
||||
|
||||
def __init__(self, run, on_prompt=None):
|
||||
self.run = run
|
||||
self.on_prompt = on_prompt
|
||||
self.asked = []
|
||||
self.notices = []
|
||||
self.stop = False
|
||||
threading.Thread(target=self._loop, daemon=True).start()
|
||||
|
||||
def _loop(self):
|
||||
seen = None
|
||||
seen_n = None
|
||||
while not self.stop:
|
||||
n = self.run.notice
|
||||
if n and n["id"] != seen_n:
|
||||
seen_n = n["id"]
|
||||
self.notices.append(n["text"])
|
||||
p = self.run.prompt
|
||||
if p and p["id"] != seen:
|
||||
seen = p["id"]
|
||||
self.asked.append((p["question"], list(p["options"])))
|
||||
if self.on_prompt:
|
||||
self.on_prompt(p)
|
||||
self.run.answer(p["id"], p["options"][0])
|
||||
time.sleep(0.01)
|
||||
|
||||
|
||||
class ActTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.fc = helpers.FakeForgectrl().start()
|
||||
self.run = Run("test", "t.x", "t.x")
|
||||
self.ctx = Context(self.run, None, helpers.make_test("t.x", [], kind="operator"))
|
||||
self.ans = Answerer(self.run)
|
||||
|
||||
def tearDown(self):
|
||||
self.ans.stop = True
|
||||
self.fc.stop()
|
||||
|
||||
def lid(self, closed):
|
||||
self.fc.state["status"]["switches"]["lid"] = bool(closed)
|
||||
|
||||
def interlock(self, ok):
|
||||
self.fc.state["status"]["switches"]["interlock_ok"] = bool(ok)
|
||||
|
||||
def test_lid_open_is_a_notice_until_the_switch_shows_it(self):
|
||||
threading.Timer(0.3, self.lid, args=(False,)).start()
|
||||
dt = self.ctx.act("lid", "open", timeout=5)
|
||||
self.assertGreater(dt, 0.2)
|
||||
self.assertEqual(self.ans.asked, []) # no button to click
|
||||
self.assertEqual(self.ans.notices, ["Open the lid."])
|
||||
self.assertIsNone(self.run.notice) # cleared when seen
|
||||
rec = self.run.evidence["actions"][0]
|
||||
self.assertEqual((rec["channel"], rec["state"], rec["by"]), ("lid", "open", "operator"))
|
||||
self.assertIsNotNone(rec["took_s"])
|
||||
self.assertTrue(any("ACT lid open: done" in ln for ln in self.run.lines))
|
||||
|
||||
def test_the_tests_context_follows_the_wording(self):
|
||||
self.lid(False)
|
||||
self.ctx.act("lid", "close", text="Leave it closed.", until=lambda: True, timeout=1)
|
||||
self.assertTrue(any("NOTICE: Close the lid. Leave it closed." in ln for ln in self.run.lines))
|
||||
|
||||
def test_already_done_returns_at_once(self):
|
||||
self.lid(False)
|
||||
dt = self.ctx.act("lid", "open", timeout=2)
|
||||
self.assertLess(dt, 1.0)
|
||||
|
||||
def test_interlock_watches_interlock_ok(self):
|
||||
threading.Timer(0.2, self.interlock, args=(False,)).start()
|
||||
self.ctx.act("interlock", "open", timeout=5)
|
||||
threading.Timer(0.2, self.interlock, args=(True,)).start()
|
||||
self.ctx.act("interlock", "close", timeout=5)
|
||||
self.assertEqual([r["state"] for r in self.run.evidence["actions"]], ["open", "close"])
|
||||
|
||||
def test_a_timeout_fails_the_test_and_clears_the_notice(self):
|
||||
with self.assertRaises(Failed) as cm:
|
||||
self.ctx.act("lid", "open", timeout=0.6)
|
||||
self.assertIn("lid open was not seen", str(cm.exception))
|
||||
self.assertIsNone(self.run.notice)
|
||||
self.assertIsNone(self.run.evidence["actions"][0]["took_s"])
|
||||
|
||||
def test_a_timeout_can_be_handed_back_instead(self):
|
||||
self.assertIsNone(self.ctx.act("lid", "open", timeout=0.4, fail=False))
|
||||
|
||||
def test_a_button_press_needs_what_it_is_expected_to_do(self):
|
||||
with self.assertRaises(ValueError):
|
||||
self.ctx.act("button", "press")
|
||||
held = []
|
||||
threading.Timer(0.2, held.append, args=(1,)).start()
|
||||
self.ctx.act("button", "press", until=lambda: bool(held), timeout=5)
|
||||
self.assertEqual(self.ans.notices, ["Press the button once."])
|
||||
|
||||
def test_unknown_actions_are_refused(self):
|
||||
with self.assertRaises(ValueError):
|
||||
self.ctx.act("lid", "press")
|
||||
|
||||
def test_a_fixture_covering_the_channel_performs_it(self):
|
||||
class Fixture:
|
||||
done = []
|
||||
|
||||
def covers(self, channel):
|
||||
return channel == "lid"
|
||||
|
||||
def act(self, channel, state):
|
||||
self.done.append((channel, state))
|
||||
self_outer.lid(state != "open")
|
||||
self_outer = self
|
||||
|
||||
class R:
|
||||
fixture = Fixture()
|
||||
ctx = Context(self.run, R(), helpers.make_test("t.f", [], kind="operator"))
|
||||
ctx.act("lid", "open", timeout=3)
|
||||
self.assertEqual(Fixture.done, [("lid", "open")])
|
||||
self.assertEqual(self.ans.notices, [])
|
||||
self.assertEqual(self.run.evidence["actions"][0]["by"], "fixture")
|
||||
# a channel the fixture does not cover falls back to the operator
|
||||
threading.Timer(0.2, self.interlock, args=(False,)).start()
|
||||
ctx.act("interlock", "open", timeout=3)
|
||||
self.assertEqual(self.ans.notices, [ctx.runner and "Open the remote-interlock loop: unplug the "
|
||||
"Pro's interlock plug, or pull the jumper at J8 on a Basic/Plus."])
|
||||
|
||||
def test_ready_is_a_single_button_prompt(self):
|
||||
self.ctx.ready("On Ready the head moves.")
|
||||
self.assertEqual(self.ans.asked, [("On Ready the head moves.", ["Ready", "Cannot"])])
|
||||
|
||||
def test_cannot_on_ready_fails(self):
|
||||
self.ans.stop = True
|
||||
ans = Answerer(self.run, on_prompt=lambda p: None)
|
||||
ans.stop = True
|
||||
time.sleep(0.05)
|
||||
|
||||
def say_cannot():
|
||||
while self.run.prompt is None:
|
||||
time.sleep(0.01)
|
||||
self.run.answer(self.run.prompt["id"], "Cannot")
|
||||
threading.Thread(target=say_cannot, daemon=True).start()
|
||||
with self.assertRaises(Failed):
|
||||
self.ctx.ready("x")
|
||||
|
||||
def test_notice_is_logged_and_in_the_snapshot(self):
|
||||
self.ctx.notice("Hold still.")
|
||||
self.assertEqual(self.run.snapshot()["notice"]["text"], "Hold still.")
|
||||
self.assertTrue(any("NOTICE: Hold still." in ln for ln in self.run.lines))
|
||||
self.ctx.clear_notice()
|
||||
self.assertIsNone(self.run.snapshot()["notice"])
|
||||
|
||||
|
||||
class PrecheckTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.mkdtemp(prefix="forgetest-pre-")
|
||||
os.environ["FORGETEST_DATA"] = self.tmp
|
||||
os.environ["FORGETEST_MARKER"] = os.path.join(self.tmp, "marker")
|
||||
self.man = helpers.make_manifest()
|
||||
self.blocked = {"why": "HV reports good: open the lid"}
|
||||
self.reg = helpers.registry(
|
||||
helpers.make_test("p.gated", [("forgectrl", "src/main.c")],
|
||||
precheck=lambda: self.blocked["why"]),
|
||||
helpers.make_test("p.free", [("forgectrl", "src/ui.c")]),
|
||||
)
|
||||
self.log = Log(os.path.join(self.tmp, "r.jsonl"))
|
||||
self.runner = Runner(self.log, self.man, self.reg)
|
||||
|
||||
def tearDown(self):
|
||||
shutil.rmtree(self.tmp, ignore_errors=True)
|
||||
|
||||
def test_a_precheck_reason_refuses_the_start_without_a_result(self):
|
||||
ok, msg = self.runner.start_test("p.gated")
|
||||
self.assertFalse(ok)
|
||||
self.assertEqual(msg, "cannot start: HV reports good: open the lid")
|
||||
self.assertEqual([r for r in self.log.read() if r["t"] == "result"], [])
|
||||
# the campaign is not even opened by a refused start
|
||||
self.assertEqual([r for r in self.log.read() if r["t"] == "campaign"], [])
|
||||
|
||||
def test_a_clear_precheck_starts(self):
|
||||
self.blocked["why"] = None
|
||||
ok, msg = self.runner.start_test("p.gated")
|
||||
self.assertTrue(ok, msg)
|
||||
while self.runner.busy():
|
||||
time.sleep(0.02)
|
||||
self.assertEqual(self.runner.state()[0]["tests"]["p.gated"]["status"], "pass")
|
||||
|
||||
def test_the_queue_skips_it_with_the_reason_and_carries_on(self):
|
||||
ok, msg, order = self.runner.start_batch("unattended")
|
||||
self.assertTrue(ok, msg)
|
||||
deadline = time.time() + 20
|
||||
while not self.runner.batch["finished"] and time.time() < deadline:
|
||||
time.sleep(0.02)
|
||||
b = self.runner.batch_snapshot()
|
||||
self.assertEqual([x["test"] for x in b["skipped"]], ["p.gated"])
|
||||
self.assertIn("cannot start: HV reports good", b["skipped"][0]["reason"])
|
||||
self.assertEqual([x["test"] for x in b["done"]], ["p.free"])
|
||||
|
||||
def test_an_erroring_precheck_refuses_rather_than_crashes(self):
|
||||
def boom():
|
||||
raise RuntimeError("sysfs gone")
|
||||
t = helpers.make_test("p.boom", [], precheck=boom)
|
||||
self.assertIn("precheck errored: RuntimeError: sysfs gone", t.cannot_start())
|
||||
|
||||
def test_the_catalog_describes_actions_and_the_precheck(self):
|
||||
t = helpers.make_test("p.x", [], kind="operator", actions=("lid", "button"),
|
||||
precheck=lambda: None)
|
||||
d = t.describe()
|
||||
self.assertEqual(d["actions"], ["lid", "button"])
|
||||
self.assertTrue(d["precheck"])
|
||||
# neither is part of the gate-visible definition
|
||||
self.assertNotIn("actions", t.definition())
|
||||
|
||||
def test_the_decorator_checks_actions(self):
|
||||
saved = dict(catalog.REGISTRY)
|
||||
try:
|
||||
with self.assertRaises(ValueError):
|
||||
catalog.test("x.a", title="t", subsystem="x", kind="operator", actions=("foot",))(lambda c: None)
|
||||
with self.assertRaises(ValueError):
|
||||
catalog.test("x.b", title="t", subsystem="x", kind="auto", actions=("lid",))(lambda c: None)
|
||||
with self.assertRaises(ValueError):
|
||||
catalog.test("x.c", title="t", subsystem="x", precheck="no")(lambda c: None)
|
||||
catalog.test("x.d", title="t", subsystem="x", kind="operator", actions=("lid",),
|
||||
precheck=lambda: None)(lambda c: None)
|
||||
self.assertIn("x.d", catalog.REGISTRY)
|
||||
finally:
|
||||
catalog.REGISTRY.clear()
|
||||
catalog.REGISTRY.update(saved)
|
||||
|
||||
|
||||
class JournalTests(unittest.TestCase):
|
||||
def test_runner_notes_go_to_the_journal_and_the_run_in_progress(self):
|
||||
tmp = tempfile.mkdtemp(prefix="forgetest-j-")
|
||||
os.environ["FORGETEST_DATA"] = tmp
|
||||
os.environ["FORGETEST_MARKER"] = os.path.join(tmp, "marker")
|
||||
h = Catch()
|
||||
journal.addHandler(h)
|
||||
try:
|
||||
r = Runner(Log(os.path.join(tmp, "r.jsonl")), helpers.make_manifest(),
|
||||
helpers.registry(helpers.make_test("j.a", [])))
|
||||
r._note("queue unattended: 1 test(s) to run")
|
||||
self.assertIn("queue unattended: 1 test(s) to run", h.lines)
|
||||
self.assertNotIn("messages", r.state()[0])
|
||||
run = Run("test", "j.a", "j.a")
|
||||
r.current = run
|
||||
r._note("queue unattended: stop requested")
|
||||
self.assertTrue(any("stop requested" in ln for ln in run.lines))
|
||||
finally:
|
||||
journal.removeHandler(h)
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
def test_journal_tail_serves_the_daemon_log(self):
|
||||
from forgetest.server import journal_tail
|
||||
tmp = tempfile.mkdtemp(prefix="forgetest-jt-")
|
||||
try:
|
||||
p = os.path.join(tmp, "daemon.log")
|
||||
self.assertIn(b"no journal yet", journal_tail(p))
|
||||
with open(p, "wb") as f:
|
||||
for i in range(2000):
|
||||
f.write(b"line %d\n" % i)
|
||||
tail = journal_tail(p, max_bytes=200)
|
||||
self.assertTrue(tail.endswith(b"line 1999\n"))
|
||||
self.assertLess(len(tail), 200)
|
||||
self.assertTrue(tail.startswith(b"line ")) # a whole line, not a fragment
|
||||
finally:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -376,8 +376,22 @@ class ServerTests(unittest.TestCase):
|
||||
marker = os.environ["FORGETEST_MARKER"]
|
||||
with open(marker, "w") as f:
|
||||
f.write("x fake.slow\n")
|
||||
r = Runner(self.log, self.man, self.reg, self.bench)
|
||||
self.assertTrue(any("recovered" in m for m in r.messages))
|
||||
import logging
|
||||
from forgetest.runner import journal
|
||||
seen = []
|
||||
|
||||
class Catch(logging.Handler):
|
||||
def emit(self, rec):
|
||||
seen.append(rec.getMessage())
|
||||
h = Catch()
|
||||
journal.addHandler(h)
|
||||
try:
|
||||
r = Runner(self.log, self.man, self.reg, self.bench)
|
||||
finally:
|
||||
journal.removeHandler(h)
|
||||
# the recovery is a journal line, not a page message
|
||||
self.assertTrue(any("recovered" in m for m in seen), seen)
|
||||
self.assertNotIn("messages", r.state()[0])
|
||||
self.assertFalse(os.path.exists(marker))
|
||||
|
||||
def test_10_state_is_conditional(self):
|
||||
|
||||
Reference in New Issue
Block a user