mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-09-27 08:41:13 -07:00
fixture.py: the bench's /data/forgetest/fixture.json (hostname, key, optional ip, the channels wired, arm_press), a resolver for <hostname>.local asked of the network directly (the image has no mDNS resolver), and the client. The runner probes it before every run and at most every 30 s otherwise; ctx.act asks it for a channel it covers and still waits for the machine's own reading, falling back to the operator's notice when the box fails. A test declares with hands=(...) what it asks of a person beyond its typed actions; an operator test with none, whose actions the fixture covers, is routed into the unattended queue, its Ready gates pass, and a prompt it raises anyway is a FAIL naming the undeclared step. Live tests never move; their arm press stays a person's unless the bench opted in, in which case the fixture presses when the button lights. Whatever the box still holds after a run is released before the baseline's post pass and recorded. The page shows what the fixture covers. Contract in ACCEPTANCE.md; the wiring facts, with the interlock connector left to the bench to settle (SAFETY.md and the sister map differ), in BRINGUP. Catalog unchanged in its definitions; the cloud and laser suites' shared code moved, so their implementation hashes move with it.
475 lines
20 KiB
Python
475 lines
20 KiB
Python
"""The bench actuator as the tool sees it.
|
|
|
|
The fixture is a box on the network that opens the lid loop, pulls the
|
|
interlock loop and presses the button on request. What has to hold: the
|
|
tool finds it by name without a resolver on the image, speaks its API
|
|
under the key, falls back to the operator when it cannot act, moves an
|
|
operator test it can perform alone into the unattended queue (never a
|
|
live one, never one that needs a person for anything else), refuses a
|
|
prompt during such a run rather than hanging on it, releases what the
|
|
box still holds after every run, and presses the arm button only where
|
|
the bench opted in.
|
|
"""
|
|
import json
|
|
import os
|
|
import shutil
|
|
import socket
|
|
import struct
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
import unittest
|
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
|
|
import helpers
|
|
from forgetest import fixture as fx
|
|
from forgetest import runner as runner_mod
|
|
from forgetest.log import Log
|
|
from forgetest.runner import Context, Failed, Run, Runner
|
|
|
|
KEY = "0123456789abcdef0123456789abcdef"
|
|
|
|
|
|
class FakeFixture:
|
|
"""The device's API, as fixture/README.md describes it."""
|
|
|
|
def __init__(self, button_enabled=True):
|
|
self.state = {"lid": "closed", "interlock": "closed", "button": "idle"}
|
|
self.button_enabled = button_enabled
|
|
self.calls = []
|
|
srv = self
|
|
|
|
class H(BaseHTTPRequestHandler):
|
|
def log_message(self, *a):
|
|
pass
|
|
|
|
def _json(self, code, obj):
|
|
body = json.dumps(obj).encode()
|
|
self.send_response(code)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def _state(self):
|
|
return {"device": "forgefixture", "hostname": "forgefixture", "version": "1.0.0",
|
|
"idf": "v5.5.5", "uptime_s": 12, "channels": dict(srv.state),
|
|
"button_enabled": srv.button_enabled, "button_pulsing": False,
|
|
"wifi": {"connected": True, "ip": "127.0.0.1", "rssi": -50}}
|
|
|
|
def _auth(self):
|
|
if self.headers.get("X-Fixture-Key") != KEY:
|
|
self._json(401, {"error": "X-Fixture-Key missing or wrong"})
|
|
return False
|
|
return True
|
|
|
|
def do_GET(self):
|
|
srv.calls.append(("GET", self.path))
|
|
if not self._auth():
|
|
return
|
|
self._json(200, self._state())
|
|
|
|
def do_POST(self):
|
|
n = int(self.headers.get("Content-Length") or 0)
|
|
body = json.loads(self.rfile.read(n).decode() or "{}")
|
|
srv.calls.append(("POST", self.path, body))
|
|
if not self._auth():
|
|
return
|
|
if self.path in ("/lid", "/interlock"):
|
|
st = body.get("state")
|
|
if st not in ("open", "close", "closed"):
|
|
return self._json(400, {"error": "state must be \"open\" or \"close\""})
|
|
srv.state[self.path[1:]] = "open" if st == "open" else "closed"
|
|
return self._json(200, self._state())
|
|
if self.path == "/button":
|
|
if not srv.button_enabled:
|
|
return self._json(409, {"error": "button disabled: the enable jumper is out"})
|
|
srv.state["button"] = "pressed"
|
|
threading.Timer(0.05, lambda: srv.state.__setitem__("button", "idle")).start()
|
|
s = self._state()
|
|
s["pulse_ms"] = 200
|
|
return self._json(200, s)
|
|
if self.path == "/release":
|
|
srv.state = {"lid": "closed", "interlock": "closed", "button": "idle"}
|
|
return self._json(200, self._state())
|
|
self._json(404, {"error": "no such path"})
|
|
|
|
self.httpd = HTTPServer(("127.0.0.1", 0), H)
|
|
self.port = self.httpd.server_address[1]
|
|
threading.Thread(target=self.httpd.serve_forever, daemon=True).start()
|
|
|
|
def stop(self):
|
|
self.httpd.shutdown()
|
|
self.httpd.server_close()
|
|
|
|
|
|
class MdnsTests(unittest.TestCase):
|
|
def test_query_packet(self):
|
|
q = fx.mdns_query("forgefixture.local")
|
|
self.assertEqual(q[:12], struct.pack("!HHHHHH", 0, 0, 1, 0, 0, 0))
|
|
self.assertEqual(q[12:], b"\x0cforgefixture\x05local\x00" + struct.pack("!HH", 1, 0x8001))
|
|
|
|
def _response(self, name, ip, extra_name=None):
|
|
labels = b"".join(struct.pack("B", len(p)) + p.encode() for p in name.split(".")) + b"\x00"
|
|
hdr = struct.pack("!HHHHHH", 0, 0x8400, 0, 2 if extra_name else 1, 0, 0)
|
|
rr = labels + struct.pack("!HHIH", 1, 0x8001, 120, 4) + socket.inet_aton(ip)
|
|
if extra_name:
|
|
# a second answer naming the first through a compression pointer
|
|
# to offset 12 (the first name), plus its own first label
|
|
other = struct.pack("B", len(extra_name)) + extra_name.encode() + struct.pack("!H", 0xC000 | 12)
|
|
rr += other + struct.pack("!HHIH", 1, 0x8001, 120, 4) + socket.inet_aton("10.0.0.9")
|
|
return hdr + rr
|
|
|
|
def test_answers_are_the_named_a_records(self):
|
|
data = self._response("forgefixture.local", "192.0.2.50")
|
|
self.assertEqual(fx.mdns_answers(data, "forgefixture.local"), ["192.0.2.50"])
|
|
self.assertEqual(fx.mdns_answers(data, "FORGEFIXTURE.local."), ["192.0.2.50"])
|
|
self.assertEqual(fx.mdns_answers(data, "other.local"), [])
|
|
# a query (QR clear) is never an answer
|
|
self.assertEqual(fx.mdns_answers(fx.mdns_query("forgefixture.local"), "forgefixture.local"), [])
|
|
# compression pointers are followed; the other record is not ours
|
|
data2 = self._response("forgefixture.local", "192.0.2.50", extra_name="printer")
|
|
self.assertEqual(fx.mdns_answers(data2, "forgefixture.local"), ["192.0.2.50"])
|
|
self.assertEqual(fx.mdns_answers(data2, "printer.forgefixture.local"), ["10.0.0.9"])
|
|
# garbage does not raise
|
|
self.assertEqual(fx.mdns_answers(b"\x00\x01", "forgefixture.local"), [])
|
|
|
|
|
|
class ConfigTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.tmp = tempfile.mkdtemp(prefix="forgetest-fixture-")
|
|
|
|
def tearDown(self):
|
|
shutil.rmtree(self.tmp, ignore_errors=True)
|
|
|
|
def write(self, obj):
|
|
p = os.path.join(self.tmp, "fixture.json")
|
|
with open(p, "w") as f:
|
|
json.dump(obj, f)
|
|
return p
|
|
|
|
def test_no_file_is_no_fixture(self):
|
|
self.assertIsNone(fx.load_config(os.path.join(self.tmp, "none.json")))
|
|
|
|
def test_defaults_and_checks(self):
|
|
cfg = fx.load_config(self.write({"key": KEY}))
|
|
self.assertEqual(cfg["hostname"], "forgefixture")
|
|
self.assertEqual(cfg["channels"], ["lid", "interlock", "button"])
|
|
self.assertFalse(cfg["arm_press"])
|
|
self.assertIsNone(cfg["ip"])
|
|
with self.assertRaises(fx.FixtureError):
|
|
fx.load_config(self.write({"hostname": "x"})) # no key
|
|
with self.assertRaises(fx.FixtureError):
|
|
fx.load_config(self.write({"key": KEY, "channels": ["lid", "laser"]}))
|
|
p = self.write({"key": KEY})
|
|
with open(p, "w") as f:
|
|
f.write("{not json")
|
|
with self.assertRaises(fx.FixtureError):
|
|
fx.load_config(p)
|
|
|
|
|
|
class ClientTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.dev = FakeFixture()
|
|
self.resolved = []
|
|
|
|
def tearDown(self):
|
|
self.dev.stop()
|
|
|
|
def resolver(self, hostname):
|
|
self.resolved.append(hostname)
|
|
return "127.0.0.1"
|
|
|
|
def client(self, **over):
|
|
cfg = {"hostname": "forgefixture", "key": KEY, "channels": ["lid", "interlock", "button"],
|
|
"port": self.dev.port}
|
|
cfg.update(over)
|
|
return fx.Fixture(cfg, resolver=self.resolver)
|
|
|
|
def test_status_covers_act_release(self):
|
|
f = self.client()
|
|
st = f.status()
|
|
self.assertEqual(st["device"], "forgefixture")
|
|
self.assertEqual(self.resolved, ["forgefixture"]) # resolved once
|
|
self.assertTrue(f.covers("lid") and f.covers("interlock") and f.covers("button"))
|
|
f.act("lid", "open")
|
|
self.assertEqual(self.dev.state["lid"], "open")
|
|
f.act("interlock", "open")
|
|
f.act("button", "press")
|
|
self.assertEqual(self.dev.calls[-1], ("POST", "/button", {}))
|
|
time.sleep(0.15) # the fake's pulse ends
|
|
self.assertEqual(sorted(fx.Fixture.energized(f.status())), ["interlock", "lid"])
|
|
f.release()
|
|
self.assertEqual(fx.Fixture.energized(f.status()), [])
|
|
self.assertEqual(self.resolved, ["forgefixture"]) # the address is cached
|
|
with self.assertRaises(fx.FixtureError):
|
|
f.act("button", "hold")
|
|
with self.assertRaises(fx.FixtureError):
|
|
f.act("lid", "ajar")
|
|
|
|
def test_the_button_is_covered_only_with_the_jumper_in(self):
|
|
self.dev.button_enabled = False
|
|
f = self.client()
|
|
f.status()
|
|
self.assertTrue(f.covers("lid"))
|
|
self.assertFalse(f.covers("button"))
|
|
with self.assertRaises(fx.FixtureError) as cm:
|
|
f.act("button", "press")
|
|
self.assertIn("jumper", str(cm.exception))
|
|
|
|
def test_a_wrong_key_is_refused(self):
|
|
f = self.client(key="wrong")
|
|
with self.assertRaises(fx.FixtureError) as cm:
|
|
f.status()
|
|
self.assertIn("refused the key", str(cm.exception))
|
|
|
|
def test_an_ip_override_skips_the_lookup(self):
|
|
f = self.client(ip="127.0.0.1")
|
|
f.status()
|
|
self.assertEqual(self.resolved, [])
|
|
|
|
def test_channels_not_wired_are_not_covered(self):
|
|
f = self.client(channels=["lid"])
|
|
f.status()
|
|
self.assertTrue(f.covers("lid"))
|
|
self.assertFalse(f.covers("interlock"))
|
|
|
|
def test_probe_without_a_config_is_none_and_a_silent_box_is_logged(self):
|
|
lines = []
|
|
self.assertIsNone(fx.probe(lines.append, path=os.path.join(tempfile.gettempdir(), "no-such-fixture.json")))
|
|
self.assertEqual(lines, [])
|
|
tmp = tempfile.mkdtemp(prefix="forgetest-fixture-")
|
|
try:
|
|
p = os.path.join(tmp, "fixture.json")
|
|
with open(p, "w") as f:
|
|
json.dump({"key": KEY, "ip": "127.0.0.1", "port": 1}, f)
|
|
# port 1 on localhost: nothing listens there
|
|
f2 = fx.probe(lines.append, path=p, resolver=lambda h: None)
|
|
self.assertIsNone(f2)
|
|
self.assertTrue(lines and "running without it" in lines[-1], lines)
|
|
finally:
|
|
shutil.rmtree(tmp, ignore_errors=True)
|
|
|
|
|
|
class CatalogTests(unittest.TestCase):
|
|
def test_fixture_runnable(self):
|
|
op = helpers.make_test("m.op", [], kind="operator", actions=("lid", "button"))
|
|
self.assertTrue(op.fixture_runnable(("lid", "interlock", "button")))
|
|
self.assertFalse(op.fixture_runnable(("lid",))) # the button is not covered
|
|
self.assertFalse(helpers.make_test("m.live", [], kind="live", actions=("lid",))
|
|
.fixture_runnable(("lid",))) # live never downgrades
|
|
self.assertFalse(helpers.make_test("m.app", [], kind="operator", hands=("app",))
|
|
.fixture_runnable(("lid", "interlock", "button")))
|
|
self.assertFalse(helpers.make_test("m.none", [], kind="operator")
|
|
.fixture_runnable(("lid",))) # no actions: a person does something
|
|
self.assertFalse(helpers.make_test("m.auto", []).fixture_runnable(("lid",)))
|
|
|
|
def test_an_auto_test_asks_nothing_of_a_person(self):
|
|
from forgetest import catalog
|
|
with self.assertRaises(ValueError):
|
|
catalog.test("z.auto", title="t", subsystem="z", hands=("app",))(lambda ctx: None)
|
|
|
|
|
|
class StubFixture:
|
|
"""What the runner needs of a fixture, scripted."""
|
|
|
|
def __init__(self, channels=("lid", "interlock", "button"), button_enabled=True, arm_press=False,
|
|
fail=False, fc=None):
|
|
self.fc = fc # the fake forgectrl whose switches follow the actions
|
|
self.hostname = "forgefixture"
|
|
self._ip = "127.0.0.1"
|
|
self.channels = tuple(channels)
|
|
self.button_enabled = button_enabled
|
|
self.arm_press = arm_press
|
|
self.fail = fail
|
|
self.acts = []
|
|
self.held = []
|
|
self.released = 0
|
|
|
|
def covers(self, channel):
|
|
return channel in self.channels and (channel != "button" or self.button_enabled)
|
|
|
|
def act(self, channel, state):
|
|
if self.fail:
|
|
raise fx.FixtureError("the box is off")
|
|
self.acts.append((channel, state))
|
|
if state == "open":
|
|
self.held.append(channel)
|
|
if self.fc is not None and channel in ("lid", "interlock"):
|
|
sw = self.fc.state["status"]["switches"]
|
|
sw["lid" if channel == "lid" else "interlock_ok"] = state != "open"
|
|
|
|
def status(self):
|
|
return {"channels": {c: ("open" if c in self.held else "closed") for c in ("lid", "interlock")}}
|
|
|
|
@staticmethod
|
|
def energized(state):
|
|
return fx.Fixture.energized(state)
|
|
|
|
def release(self):
|
|
self.released += 1
|
|
self.held = []
|
|
|
|
def summary(self):
|
|
return {"hostname": self.hostname, "ip": self._ip, "channels": list(self.channels),
|
|
"button_enabled": self.button_enabled, "arm_press": self.arm_press}
|
|
|
|
|
|
def t_lid(ctx):
|
|
ctx.ready("On Ready the lid opens")
|
|
ctx.act("lid", "open")
|
|
ctx.act("lid", "close")
|
|
|
|
|
|
def t_asks(ctx):
|
|
ctx.act("lid", "open")
|
|
ctx.confirm("Did it?")
|
|
|
|
|
|
class RoutingTests(unittest.TestCase):
|
|
"""The queues with a fixture up: the runner's probe is scripted."""
|
|
|
|
def setUp(self):
|
|
self.tmp = tempfile.mkdtemp(prefix="forgetest-fixture-run-")
|
|
os.environ["FORGETEST_DATA"] = self.tmp
|
|
os.environ["FORGETEST_MARKER"] = os.path.join(self.tmp, "marker")
|
|
self.fc = helpers.FakeForgectrl().start()
|
|
self.man = helpers.make_manifest()
|
|
self.reg = helpers.registry(
|
|
helpers.make_test("r.auto", [("forgectrl", "src/ui.c")]),
|
|
helpers.make_test("r.lid", [("forgectrl", "src/auth.c")], kind="operator", actions=("lid",), fn=t_lid),
|
|
helpers.make_test("r.btn", [("forgectrl", "src/cool.c")], kind="operator", actions=("button",), fn=t_lid),
|
|
helpers.make_test("r.app", [("forgectrl", "src/main.c")], kind="operator", actions=("lid",),
|
|
hands=("app",), fn=t_lid),
|
|
helpers.make_test("r.live", [("grblhal-glowforge", "src/**")], kind="live", actions=("lid",)),
|
|
helpers.make_test("r.asks", [("linux-fslc", "**")], kind="operator", actions=("lid",), fn=t_asks),
|
|
)
|
|
self.log = Log(os.path.join(self.tmp, "results.jsonl"))
|
|
self.runner = Runner(self.log, self.man, self.reg)
|
|
self.saved_probe = fx.probe
|
|
self.stub = StubFixture(fc=self.fc)
|
|
fx.probe = lambda log, path=None, resolver=None: self.stub
|
|
|
|
def tearDown(self):
|
|
fx.probe = self.saved_probe
|
|
self.fc.stop()
|
|
for k in ("FORGETEST_DATA", "FORGETEST_MARKER"):
|
|
os.environ.pop(k, None)
|
|
shutil.rmtree(self.tmp, ignore_errors=True)
|
|
|
|
def selection(self):
|
|
st, _ = self.runner.state()
|
|
return st["batch_available"], st["fixture"]
|
|
|
|
def test_operator_tests_the_fixture_covers_move_to_the_unattended_queue(self):
|
|
av, summary = self.selection()
|
|
self.assertEqual(av["unattended"], ["r.auto", "r.lid", "r.btn", "r.asks"])
|
|
self.assertEqual(av["attended"], ["r.app", "r.live"]) # needs the app; live never moves
|
|
self.assertEqual(summary["hostname"], "forgefixture")
|
|
|
|
def test_without_the_jumper_the_button_tests_stay_attended(self):
|
|
self.stub.button_enabled = False
|
|
av, _ = self.selection()
|
|
self.assertIn("r.btn", av["attended"])
|
|
self.assertIn("r.lid", av["unattended"])
|
|
|
|
def test_no_fixture_is_the_old_routing(self):
|
|
fx.probe = lambda log, path=None, resolver=None: None
|
|
self.runner._fixture_probed = 0.0
|
|
av, summary = self.selection()
|
|
self.assertEqual(av["unattended"], ["r.auto"])
|
|
self.assertIsNone(summary)
|
|
|
|
def run_queue(self, group, ack_live=False):
|
|
ok, msg, order = self.runner.start_batch(group, ack_live=ack_live)
|
|
self.assertTrue(ok, msg)
|
|
deadline = time.time() + 30
|
|
while not self.runner.batch["finished"] and time.time() < deadline:
|
|
time.sleep(0.05)
|
|
return self.runner.batch_snapshot(), order
|
|
|
|
def last_result(self, tid):
|
|
for rec in reversed(self.log.read()):
|
|
if rec.get("t") == "result" and rec["test"] == tid:
|
|
return rec
|
|
return None
|
|
|
|
def test_the_fixture_performs_the_actions_and_the_ready_gate_passes(self):
|
|
# r.asks confirms after its action: a person is asked -> a FAIL that
|
|
# says so, and the queue stops there, after r.lid and r.btn passed
|
|
b, order = self.run_queue("unattended")
|
|
self.assertEqual(order, ["r.auto", "r.lid", "r.btn", "r.asks"])
|
|
results = {x["test"]: x["result"] for x in b["done"]}
|
|
self.assertEqual(results["r.lid"], "PASS")
|
|
self.assertEqual(results["r.btn"], "PASS")
|
|
self.assertEqual(results["r.asks"], "FAIL")
|
|
rec = self.last_result("r.lid")
|
|
acts = rec["evidence"]["actions"]
|
|
self.assertEqual([(a["channel"], a["state"], a["by"]) for a in acts],
|
|
[("lid", "open", "fixture"), ("lid", "close", "fixture")])
|
|
self.assertTrue(any("READY (fixture performs the step)" in l for l in rec["log"]), rec["log"])
|
|
self.assertEqual(rec["evidence"]["fixture"]["channels"], ["lid", "interlock", "button"])
|
|
asks = self.last_result("r.asks")
|
|
self.assertIn("declare the step in hands=", asks["message"])
|
|
|
|
def test_a_failing_box_hands_the_action_to_the_operator(self):
|
|
self.stub.fail = True
|
|
run = Run("test", "r.lid", "r.lid")
|
|
ctx = Context(run, self.runner, self.reg["r.lid"])
|
|
self.runner.probe_fixture(force=True)
|
|
seen = []
|
|
ctx.wait_for = lambda cond, timeout, poll=0.25: 1.0 # the machine shows the state
|
|
run.set_notice = lambda text: seen.append(text)
|
|
ctx.act("lid", "open")
|
|
rec = run.evidence["actions"][0]
|
|
self.assertEqual(rec["by"], "operator")
|
|
self.assertIn("the box is off", rec["fixture_error"])
|
|
self.assertTrue(seen and "lid" in seen[0].lower())
|
|
|
|
def test_what_the_box_still_holds_is_released_after_a_run(self):
|
|
def holds(ctx):
|
|
ctx.act("lid", "open") # and never closes it
|
|
self.reg["r.hold"] = helpers.make_test("r.hold", [("forgectrl", "src/ui.c")], kind="operator",
|
|
actions=("lid",), fn=holds)
|
|
self.runner.registry = self.reg
|
|
ok, msg, run = self.runner._start_test("r.hold")
|
|
self.assertTrue(ok, msg)
|
|
while run.finished is None:
|
|
time.sleep(0.05)
|
|
self.assertEqual(self.stub.released, 1)
|
|
rec = self.last_result("r.hold")
|
|
self.assertEqual(rec["evidence"]["fixture"]["released"], ["lid"])
|
|
|
|
def test_the_arm_press_is_the_operators_unless_the_bench_opted_in(self):
|
|
run = Run("test", "r.live", "r.live")
|
|
ctx = Context(run, self.runner, self.reg["r.live"])
|
|
self.runner.probe_fixture(force=True)
|
|
seen = []
|
|
run.set_notice = lambda text: seen.append(text)
|
|
self.assertFalse(ctx.arm_press())
|
|
self.assertEqual(run.evidence["actions"][0]["by"], "operator")
|
|
self.assertTrue(seen)
|
|
# opted in: the press waits for the button to light
|
|
self.stub.arm_press = True
|
|
lit = {"v": False}
|
|
saved = runner_mod.hw.button_lit
|
|
runner_mod.hw.button_lit = lambda: lit["v"]
|
|
try:
|
|
seen[:] = []
|
|
self.assertTrue(ctx.arm_press())
|
|
time.sleep(0.3)
|
|
self.assertEqual(self.stub.acts, []) # not yet: dark button
|
|
lit["v"] = True
|
|
deadline = time.time() + 5
|
|
while not self.stub.acts and time.time() < deadline:
|
|
time.sleep(0.05)
|
|
self.assertEqual(self.stub.acts, [("button", "press")])
|
|
self.assertEqual(run.evidence["actions"][1]["by"], "fixture")
|
|
self.assertEqual(seen, []) # no notice went up
|
|
finally:
|
|
runner_mod.hw.button_lit = saved
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|