mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-09-27 16:51:12 -07:00
commissioning: the layer, the acceptance tests, the harness rule, the docs, and the bench drills
meta-forgefirm: the forgefirm-users init replays the account at boot; sshd refuses root and empty passwords and runs only while the panel turns it on; the release image keeps an empty root password for the console; the console banner; avahi announces forgefirm.local; https in libmicrohttpd and ulfius; the panel on 80 and 443; the license bundle on the rootfs; release.sh checks the root policy on the built rootfs. forgetest: the commission suites (commission, commission_dark, commission_sheet: 23 cases); the runner turns cloud mode on with the typed phrase for a test that declares it; the baseline's motor_lock is 0; the log-export test checks the bundle for the camera key; the record helpers write bytes as given and join the daemon's paths as POSIX. The stream harness gains rule 24: a hold verdict is held again after a resume. Bench drills: lens_travel.py and lens_stop_accel.py. Docs: BRINGUP carries the present state; CAMPAIGN-LOG carries the dated record.
This commit is contained in:
@@ -108,6 +108,7 @@ class FakeForgectrl:
|
||||
}
|
||||
self.posts = []
|
||||
self.on_post = None
|
||||
self.on_get = None # (path, query) -> (status, body) | (status, bytes, ctype) | None
|
||||
fake = self
|
||||
|
||||
class H(http.server.BaseHTTPRequestHandler):
|
||||
@@ -123,7 +124,18 @@ class FakeForgectrl:
|
||||
self.wfile.write(data)
|
||||
|
||||
def do_GET(self):
|
||||
path = self.path.split("?", 1)[0]
|
||||
path, _, query = self.path.partition("?")
|
||||
if fake.on_get:
|
||||
r = fake.on_get(path, dict(_up.parse_qsl(query, keep_blank_values=True)))
|
||||
if r is not None:
|
||||
if isinstance(r[1], (bytes, bytearray)):
|
||||
self.send_response(r[0])
|
||||
self.send_header("Content-Type", r[2] if len(r) > 2 else "application/octet-stream")
|
||||
self.send_header("Content-Length", str(len(r[1])))
|
||||
self.end_headers()
|
||||
self.wfile.write(r[1])
|
||||
return
|
||||
return self._send(r[0], r[1])
|
||||
key = {"/mode": "mode", "/status": "status", "/cool/status": "cool", "/cam/status": "cam",
|
||||
"/diag/status": "diag", "/settings": "settings",
|
||||
"/logs/tail": "logs_tail"}.get(path)
|
||||
|
||||
@@ -82,11 +82,11 @@ class BaselineTests(unittest.TestCase):
|
||||
self.assertEqual(set(items), {"cnc/motor_lock", "cnc/step_freq", "cnc/streaming"})
|
||||
for x in left:
|
||||
self.assertEqual(x.action, "restored", str(x))
|
||||
self.assertEqual(self._read("cnc/motor_lock"), "8")
|
||||
self.assertEqual(self._read("cnc/motor_lock"), "0")
|
||||
self.assertEqual(self._read("cnc/step_freq"), "28160")
|
||||
self.assertEqual(self._read("cnc/streaming"), "0")
|
||||
self.assertEqual(items["cnc/motor_lock"].found, "15")
|
||||
self.assertEqual(items["cnc/motor_lock"].expected, "8")
|
||||
self.assertEqual(items["cnc/motor_lock"].expected, "0")
|
||||
|
||||
def test_unlocked_latch_is_relocked(self):
|
||||
self._attr("cnc/interlock_circuit", "5") # bit 3 clear = unlocked
|
||||
@@ -194,7 +194,7 @@ class BaselineTests(unittest.TestCase):
|
||||
else:
|
||||
# a young host: the reference is taken from the fake tree
|
||||
self.assertIsNotNone(ref)
|
||||
self.assertEqual(ref["sysfs"]["cnc/motor_lock"], "8")
|
||||
self.assertEqual(ref["sysfs"]["cnc/motor_lock"], "0")
|
||||
self.assertTrue(os.path.exists(os.path.join(self.tmp, "boot-test-boot.json")))
|
||||
# and loaded back the second time
|
||||
self.lines[:] = []
|
||||
@@ -205,7 +205,7 @@ class BaselineTests(unittest.TestCase):
|
||||
os.environ.pop("FORGETEST_BOOT_ID", None)
|
||||
|
||||
def test_fixed_constants_checked_against_a_dump(self):
|
||||
ref = {"sysfs": {"cnc/motor_lock": "8", "cnc/step_freq": "10000"}}
|
||||
ref = {"sysfs": {"cnc/motor_lock": "0", "cnc/step_freq": "10000"}}
|
||||
diffs = baseline.check_fixed_against(ref, self.lines.append)
|
||||
self.assertEqual(diffs, ["cnc/step_freq: boot=10000 constant=28160"])
|
||||
|
||||
@@ -286,7 +286,7 @@ class BaselineTests(unittest.TestCase):
|
||||
self.assertEqual(ref["ts"], "old")
|
||||
else:
|
||||
self.assertTrue(any("retaking" in l for l in self.lines))
|
||||
self.assertEqual(ref["sysfs"]["cnc/motor_lock"], "8")
|
||||
self.assertEqual(ref["sysfs"]["cnc/motor_lock"], "0")
|
||||
finally:
|
||||
os.environ.pop("FORGETEST_BOOT_ID", None)
|
||||
|
||||
@@ -325,6 +325,7 @@ class BaselineModeTests(BaselineTests):
|
||||
The fake client takes the marker down as gfcloud does, first thing
|
||||
at its start; with refuse the POST is refused and no client starts."""
|
||||
baseline.NOHUNT_MARKER = os.path.join(self.tmp, "nohunt-marker")
|
||||
self.fc.state["settings"].setdefault("cloud_enabled", "1")
|
||||
seen = []
|
||||
|
||||
def on_post(path, form):
|
||||
@@ -355,6 +356,19 @@ class BaselineModeTests(BaselineTests):
|
||||
self.assertEqual(seen, [("/mode", {"controller": "cloud"}, True)])
|
||||
self.assertFalse(os.path.exists(baseline.NOHUNT_MARKER)) # one start, never left behind
|
||||
|
||||
def test_a_switch_to_cloud_turns_cloud_mode_on_when_it_is_off(self):
|
||||
# A test that declares cloud mode gets it: cloud_enabled goes to 1
|
||||
# first, with the typed phrase the daemon asks for and a log line,
|
||||
# and only then the mode switch.
|
||||
lines = []
|
||||
seen = self.nohunt_marker()
|
||||
self.fc.state["settings"]["cloud_enabled"] = "0"
|
||||
ok, detail = baseline.Baseline(lines.append).switch_mode("cloud")
|
||||
self.assertTrue(ok, detail)
|
||||
self.assertEqual(seen[0], ("/settings", {"cloud_enabled": "1", "phrase": "I UNDERSTAND"}, False))
|
||||
self.assertEqual(seen[1], ("/mode", {"controller": "cloud"}, True))
|
||||
self.assertTrue(any("cloud mode turned on for the test" in ln for ln in lines))
|
||||
|
||||
def test_a_switch_to_grbl_sets_no_marker(self):
|
||||
seen = self.nohunt_marker()
|
||||
self.cloud()
|
||||
|
||||
@@ -650,11 +650,13 @@ class CloudSuiteTests(unittest.TestCase):
|
||||
threading.Thread(target=self.wait_home_command, daemon=True).start()
|
||||
self.assertFails(cloud.mode_switch, "no 'homing complete' line", hooks=hooks)
|
||||
|
||||
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())
|
||||
def test_mode_switch_needs_no_setting_from_the_operator(self):
|
||||
# The test sets the gfcloud homing itself and restores it: no precheck
|
||||
# names a setting the operator should change first.
|
||||
from forgetest import catalog
|
||||
t = catalog.load_suite()["cloud.mode-switch"]
|
||||
self.assertIsNone(getattr(t, "precheck", None))
|
||||
self.assertNotIn("homing_mode_is_gfcloud", dir(cloud))
|
||||
|
||||
# -- pause / resume ---------------------------------------------------------
|
||||
def replay_print(self, name, at_run, at_end, tail_delay=0.3):
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"""The setup's checks in the catalog: registered as automatic tests with
|
||||
no precheck, the takeover ones marked so, every covers map naming the
|
||||
check runner; and the check driver itself against the fake daemon: a
|
||||
start, a prompt answered, the result taken, the record read at version
|
||||
1, and the settings a check may write put back as found."""
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
import helpers # noqa: E402
|
||||
from forgetest import catalog # noqa: E402
|
||||
from forgetest.runner import Context, Run # noqa: E402
|
||||
|
||||
IDS = ["commission.check-switches", "commission.check-sensors", "commission.check-airflow",
|
||||
"commission.check-cameras", "commission.check-motion", "commission.check-flow-verify"]
|
||||
|
||||
|
||||
class RegistrationTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.reg = catalog.load_suite()
|
||||
|
||||
def test_every_check_runs_unattended_with_the_fixture(self):
|
||||
# The lid and button checks name their machine actions, which the
|
||||
# bench fixture performs; the rest need no hands at all.
|
||||
for tid in IDS:
|
||||
self.assertIn(tid, self.reg)
|
||||
for tid in ("commission.check-switches", "commission.check-cameras"):
|
||||
self.assertEqual(self.reg[tid].kind, "operator", tid)
|
||||
self.assertTrue(self.reg[tid].fixture_runnable(("button", "lid", "interlock")), tid)
|
||||
for tid in ("commission.check-sensors", "commission.check-airflow", "commission.check-motion",
|
||||
"commission.check-flow-verify"):
|
||||
self.assertEqual(self.reg[tid].kind, "auto", tid)
|
||||
|
||||
def test_no_check_asks_the_operator_to_prepare_anything(self):
|
||||
for tid in IDS:
|
||||
self.assertIsNone(getattr(self.reg[tid], "precheck", None), tid)
|
||||
|
||||
def test_the_checks_that_take_the_machine_say_so(self):
|
||||
for tid in ("commission.check-airflow", "commission.check-motion", "commission.check-flow-verify"):
|
||||
self.assertEqual(self.reg[tid].hardware, "takeover", tid)
|
||||
|
||||
def test_every_check_covers_the_runner_and_the_page(self):
|
||||
for tid in IDS:
|
||||
covers = set(self.reg[tid].covers)
|
||||
self.assertIn(("forgectrl", "src/wizdark.*"), covers, tid)
|
||||
self.assertIn(("forgectrl", "src/ui/wizard.*"), covers, tid)
|
||||
|
||||
|
||||
class SensorsCheckTests(unittest.TestCase):
|
||||
"""The check driver against a scripted daemon."""
|
||||
|
||||
RESULT = {"coolant_down_c": 22.4, "coolant_up_c": 21.9, "chassis_c": 28.0, "soc_c": 45.2,
|
||||
"lid_ir_max": [110, 120, 105, 118], "accel_events": 0, "laser_pgood": 1,
|
||||
"hv_current_max": 0, "exhaust_rpm_idle": 3400, "intake_rpm_idle": 2200}
|
||||
|
||||
def setUp(self):
|
||||
self.fake = helpers.FakeForgectrl().start()
|
||||
self.fake.state["settings"].update({"cool_temp_offset_c": "0.5"})
|
||||
self.dark = {"id": "", "running": False, "phase": "", "progress": 0, "elapsed_s": 0,
|
||||
"log": [], "prompt": None, "result": None, "error": "",
|
||||
"shots": {"lid": False, "head": False}}
|
||||
self.versions = {}
|
||||
self.answers = []
|
||||
|
||||
def on_get(path, q):
|
||||
if path == "/wiz/dark":
|
||||
return 200, dict(self.dark)
|
||||
if path == "/wiz":
|
||||
return 200, {"versions": dict(self.versions), "required": [], "completed": True}
|
||||
return None
|
||||
|
||||
def on_post(path, form):
|
||||
s = self.fake.state["settings"]
|
||||
if path == "/wiz/sensors/start":
|
||||
self.dark.update({"id": "sensors", "running": True, "phase": "sampling",
|
||||
"prompt": {"seq": 7, "id": "room-temp", "kind": "number",
|
||||
"text": "Optional: the room temperature.",
|
||||
"options": ["Set", "Skip"]}})
|
||||
return 200, {"started": True, "id": "sensors"}
|
||||
if path == "/wiz/sensors/answer":
|
||||
self.answers.append(dict(form))
|
||||
if form.get("seq") != "7":
|
||||
return 409, {"error": "no such prompt is open"}
|
||||
if form.get("value") == "Skip":
|
||||
self.dark.update({"running": False, "prompt": None, "progress": 100,
|
||||
"result": dict(self.RESULT)})
|
||||
self.versions["sensors"] = 1
|
||||
return 200, {"ok": True}
|
||||
if path == "/settings":
|
||||
for k, v in form.items():
|
||||
if v == "":
|
||||
s.pop(k, None)
|
||||
else:
|
||||
s[k] = v
|
||||
return 200, dict(s)
|
||||
return None
|
||||
self.fake.on_get = on_get
|
||||
self.fake.on_post = on_post
|
||||
|
||||
def tearDown(self):
|
||||
self.fake.stop()
|
||||
|
||||
def test_start_prompt_answer_result_and_restore(self):
|
||||
t = catalog.load_suite()["commission.check-sensors"]
|
||||
run = Run("test", t.id, t.title)
|
||||
t.fn(Context(run, None, t))
|
||||
self.assertIn(("/wiz/sensors/start", {}), [(p, f) for p, f in self.fake.posts])
|
||||
self.assertEqual(self.answers, [{"seq": "7", "value": "Skip"}])
|
||||
self.assertEqual(run.evidence["result"]["laser_pgood"], 1)
|
||||
self.assertEqual(run.evidence["settings_before"], {"cool_temp_offset_c": "0.5"})
|
||||
self.assertEqual(self.fake.state["settings"]["cool_temp_offset_c"], "0.5")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,266 @@
|
||||
"""The setup's sheet in the catalog: one live test on the button with a
|
||||
piece of wood at hand, its covers map naming the live runner, the
|
||||
renderer, the sender, the page, and the driver's laser module; and the
|
||||
whole run against a scripted daemon: the placement's jogs and origin,
|
||||
then each card's preview and program read, its start, the arm prompt and
|
||||
the pick answered, the witnesses judged, the record read at version 1,
|
||||
every card's evidence filed under its own key, and the settings the cards
|
||||
wrote put back as found at the end."""
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
import helpers # noqa: E402
|
||||
from forgetest import catalog # noqa: E402
|
||||
from forgetest.runner import Context, Run # noqa: E402
|
||||
from forgetest.suite import commission_sheet # noqa: E402
|
||||
|
||||
LIVE = ["sheet.frame", "laser.focus", "laser.floor", "laser.dose-curve", "laser.corner", "cooling.flow-load"]
|
||||
EMISSION = {"hv_max": 610, "laser_on_samples": 900, "thermopile_delta": 600, "lit_s": 30.0}
|
||||
|
||||
# Each wizard as the daemon runs it: its prompts in order (id, kind,
|
||||
# options) and the result it ends with. A "jog" prompt repeats until the
|
||||
# origin is set.
|
||||
SCRIPT = {
|
||||
"sheet.place": ([("place", "jog", ["X-10", "X+10", "Set origin"]),
|
||||
("sheet-kind", "choice", ["Full sheet", "One card"]),
|
||||
("thickness", "number", ["Set", "Skip"])],
|
||||
{"origin_x": 100, "origin_y": 200, "origin_z": 0, "steps_per_mm": {"x": 40.0, "y": 40.0},
|
||||
"z_referenced": True, "thickness_mm": 3.2, "alone": False}),
|
||||
"sheet.frame": ([("frame-arm", "continue", ["Continue"]), ("frame-ok", "choice", ["Yes", "No", "Again"])],
|
||||
{"mark_s": 400, "mark_feed": 3000, "emission": EMISSION}),
|
||||
"laser.focus": ([("focus-arm", "continue", ["Continue"]),
|
||||
("focus-pick", "multichoice", [str(i) for i in range(1, 13)]),
|
||||
("thickness", "number", ["Keep", "Set"])],
|
||||
{"pick": 11, "thickness_mm": 3.2, "pick_half_steps": -11, "edge_z_mm": 6.96,
|
||||
"steps_per_mm": 2.922, "max_height_mm": 13.81,
|
||||
"focus_range_mm": {"min": 2.17, "max": 13.81},
|
||||
"stops": {"found": True, "below": 14, "above": 20, "contact_below": 16,
|
||||
"contact_above": 22, "why": ""}, "emission": EMISSION}),
|
||||
"laser.floor": ([("floor-arm", "continue", ["Continue"]),
|
||||
("floor-pick", "choice", ["2", "4", "6", "8", "None"])],
|
||||
{"faintest_density": 8.0, "floor_density": 10.0, "emission": EMISSION}),
|
||||
"laser.dose-curve": ([("dose-arm", "continue", ["Continue"])],
|
||||
{"points": [[10, 0.44], [100, 100]], "curve": "10:0.44,100:100", "emission": EMISSION}),
|
||||
"laser.corner": ([("corner-arm", "continue", ["Continue"]),
|
||||
("corner-pick", "choice", ["1.00", "1.25", "1.50", "1.75", "2.00"])],
|
||||
{"gamma": 1.5, "emission": EMISSION}),
|
||||
"cooling.flow-load": ([("load-arm", "continue", ["Continue"])],
|
||||
{"lit_s": 30.0, "dose_raw_s": 12000, "peak_c": 0.72, "k_density": 2.14e-5,
|
||||
"k_cw": 2.78e-5, "emission": EMISSION}),
|
||||
}
|
||||
# What each card writes when it ends.
|
||||
WRITES = {
|
||||
"laser.focus": {"lens_hall_edge_z_mm": "6.96", "lens_stop_below_steps": "14",
|
||||
"lens_stop_above_steps": "20"},
|
||||
"laser.floor": {"laser_floor_density": "10"},
|
||||
"laser.dose-curve": {"laser_dose_curve": "10:0.44,100:100"},
|
||||
"laser.corner": {"laser_corner_gamma": "1.5"},
|
||||
"cooling.flow-load": {"cool_laser_heat_density": "2.14e-5", "cool_laser_heat_cw": "2.78e-5"},
|
||||
}
|
||||
FOUND = {"lens_hall_edge_z_mm": "3.35", "lens_stop_below_steps": "10", "lens_stop_above_steps": "12",
|
||||
"laser_floor_density": "12",
|
||||
"laser_dose_curve": "off", "laser_corner_gamma": "2", "cool_laser_heat_density": "1e-5",
|
||||
"cool_laser_heat_cw": "1e-5"}
|
||||
|
||||
|
||||
class RegistrationTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.t = catalog.load_suite()["commission.sheet"]
|
||||
|
||||
def test_one_live_test_on_the_button_with_the_sheet_at_hand(self):
|
||||
t = self.t
|
||||
self.assertEqual(t.kind, "live")
|
||||
self.assertEqual(t.mode, "grbl")
|
||||
self.assertEqual(t.hardware, "takeover")
|
||||
self.assertIn("button", t.actions)
|
||||
self.assertTrue(t.hands)
|
||||
self.assertFalse(t.fixture_runnable(("button", "lid", "interlock")))
|
||||
self.assertIsNone(getattr(t, "precheck", None))
|
||||
for want in ("commission.check-motion", "laser.emission-witness", "cooling.flow-verify"):
|
||||
self.assertIn(want, t.requires)
|
||||
|
||||
def test_no_other_sheet_test_remains(self):
|
||||
ids = [i for i in catalog.load_suite() if i.startswith("commission.sheet")]
|
||||
self.assertEqual(ids, ["commission.sheet"])
|
||||
|
||||
def test_covers_the_live_runner_the_renderer_the_sender_the_page_and_the_driver(self):
|
||||
covers = set(self.t.covers)
|
||||
for want in (("forgectrl", "src/wizlive.*"), ("forgectrl", "src/sheet.*"), ("forgectrl", "src/jobstream.*"),
|
||||
("forgectrl", "src/curverec.*"), ("forgectrl", "src/font_hershey.*"),
|
||||
("forgectrl", "src/ui/wizard.*"), ("forgectrl", "tools/hershey_gen.py"),
|
||||
("grblhal-glowforge", "src/glowforge_laser.c")):
|
||||
self.assertIn(want, covers, "lacks %s" % (want,))
|
||||
|
||||
def test_the_steps_ask_for_one_press_and_no_page(self):
|
||||
text = " ".join(self.t.steps).lower()
|
||||
self.assertIn("press the machine's button once", text)
|
||||
self.assertNotIn("when the page", text)
|
||||
self.assertEqual(len(commission_sheet.CARDS), 6)
|
||||
|
||||
|
||||
class ScriptedDaemon:
|
||||
"""The wizard runner as the suite sees it: /wiz/<id>/start opens the
|
||||
first prompt, each answer opens the next or ends the wizard with its
|
||||
result and its writes, /wiz/dark reports it, GET /wiz carries the
|
||||
versions, the sheet preview and program are served per card."""
|
||||
|
||||
def __init__(self, fake):
|
||||
self.fake = fake
|
||||
fake.state["settings"].update(FOUND)
|
||||
self.dark = {"id": "", "running": False, "phase": "", "progress": 0, "elapsed_s": 0,
|
||||
"log": [], "prompt": None, "result": None, "error": "",
|
||||
"shots": {"lid": False, "head": False}}
|
||||
self.versions = {}
|
||||
self.answers = {} # wid -> [values]
|
||||
self.order = [] # the wizards as started
|
||||
self.previews = []
|
||||
self.queue = []
|
||||
self.seq = 0
|
||||
fake.on_get = self.on_get
|
||||
fake.on_post = self.on_post
|
||||
|
||||
def open_next(self):
|
||||
if not self.queue:
|
||||
return False
|
||||
pid, kind, options = self.queue[0]
|
||||
self.seq += 1
|
||||
self.dark["prompt"] = {"seq": self.seq, "id": pid, "kind": kind, "text": pid, "options": options}
|
||||
return True
|
||||
|
||||
def on_get(self, path, q):
|
||||
if path == "/wiz/dark":
|
||||
return 200, dict(self.dark)
|
||||
if path == "/wiz":
|
||||
return 200, {"versions": dict(self.versions), "required": [], "completed": True}
|
||||
if path == "/wiz/sheet.svg":
|
||||
self.previews.append(q.get("card"))
|
||||
if q.get("card") not in LIVE:
|
||||
return 404, {"error": "no such card"}
|
||||
return 200, b"<svg xmlns='http://www.w3.org/2000/svg'></svg>", "image/svg+xml"
|
||||
if path == "/wiz/sheet.gcode":
|
||||
# the frame and the text are M4 at the mark dose, as the daemon serves them
|
||||
return 200, b"; mock\nM4 S400\nG0 X60 Y42\nG1 X120 Y42 F3000\nM5\n", "text/plain"
|
||||
return None
|
||||
|
||||
def on_post(self, path, form):
|
||||
s = self.fake.state["settings"]
|
||||
if path.startswith("/wiz/") and path.endswith("/start"):
|
||||
wid = path[len("/wiz/"):-len("/start")]
|
||||
if wid not in SCRIPT:
|
||||
return 404, {"error": "no such wizard"}
|
||||
self.order.append(wid)
|
||||
self.queue = list(SCRIPT[wid][0])
|
||||
self.dark.update({"id": wid, "running": True, "phase": "starting", "result": None, "error": ""})
|
||||
self.open_next()
|
||||
return 200, {"started": True, "id": wid}
|
||||
if path.startswith("/wiz/") and path.endswith("/answer"):
|
||||
wid = path[len("/wiz/"):-len("/answer")]
|
||||
p = self.dark.get("prompt")
|
||||
if not p or form.get("seq") != str(p["seq"]):
|
||||
return 409, {"error": "no such prompt is open"}
|
||||
self.answers.setdefault(wid, []).append(form.get("value"))
|
||||
if not (p["kind"] == "jog" and form.get("value") != "Set origin"):
|
||||
self.queue.pop(0)
|
||||
if not self.open_next():
|
||||
s.update(WRITES.get(wid, {}))
|
||||
self.dark.update({"running": False, "prompt": None, "progress": 100,
|
||||
"result": dict(SCRIPT[wid][1])})
|
||||
self.versions[wid] = 1
|
||||
return 200, {"ok": True}
|
||||
if path == "/settings":
|
||||
for k, v in form.items():
|
||||
if v == "":
|
||||
s.pop(k, None)
|
||||
else:
|
||||
s[k] = v
|
||||
return 200, dict(s)
|
||||
return None
|
||||
|
||||
|
||||
class SheetRunTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.fake = helpers.FakeForgectrl().start()
|
||||
self.daemon = ScriptedDaemon(self.fake)
|
||||
|
||||
def tearDown(self):
|
||||
self.fake.stop()
|
||||
|
||||
def test_the_whole_sheet_on_one_piece(self):
|
||||
t = catalog.load_suite()["commission.sheet"]
|
||||
run = Run("test", t.id, t.title)
|
||||
run.unattended = True # the ready gate passes; the arm cue is a notice
|
||||
t.fn(Context(run, None, t))
|
||||
d = self.daemon
|
||||
self.assertEqual(d.order, ["sheet.place"] + LIVE)
|
||||
self.assertEqual(d.answers["sheet.place"], ["X+10", "X-10", "Set origin", "Full sheet", "3.2"])
|
||||
self.assertEqual(d.answers["sheet.frame"], ["Continue", "Yes"])
|
||||
self.assertEqual(d.answers["laser.focus"], ["Continue", "11", "Keep"])
|
||||
self.assertEqual(d.answers["laser.floor"], ["Continue", "8"])
|
||||
self.assertEqual(d.answers["laser.corner"], ["Continue", "1.50"])
|
||||
self.assertEqual(d.previews, LIVE)
|
||||
cards = run.evidence["cards"]
|
||||
self.assertEqual(sorted(cards), sorted(["sheet.place"] + LIVE))
|
||||
for wid in LIVE:
|
||||
self.assertEqual(cards[wid]["emission"]["hv_max"], 610, wid)
|
||||
self.assertEqual(cards[wid]["result"], SCRIPT[wid][1], wid)
|
||||
self.assertNotIn("emission", run.evidence)
|
||||
self.assertEqual(cards["sheet.place"]["result"]["origin_x"], 100)
|
||||
self.assertEqual(run.evidence["settings_before"], FOUND)
|
||||
for k, v in FOUND.items():
|
||||
self.assertEqual(self.fake.state["settings"][k], v, k) # restored
|
||||
|
||||
def test_imperial_units_answer_the_thickness_in_inches(self):
|
||||
# The number prompts read ui_units: on an imperial machine the
|
||||
# placement's thickness is 0.125 in and the record carries 3.175 mm.
|
||||
self.fake.state["settings"]["ui_units"] = "imperial"
|
||||
place_prompts, place_result = SCRIPT["sheet.place"]
|
||||
SCRIPT["sheet.place"] = (place_prompts, dict(place_result, thickness_mm=3.175))
|
||||
try:
|
||||
t = catalog.load_suite()["commission.sheet"]
|
||||
run = Run("test", t.id, t.title)
|
||||
run.unattended = True
|
||||
t.fn(Context(run, None, t))
|
||||
self.assertEqual(self.daemon.answers["sheet.place"][-1], "0.125")
|
||||
self.assertEqual(run.evidence["thickness_answer"], "0.125")
|
||||
finally:
|
||||
SCRIPT["sheet.place"] = (place_prompts, place_result)
|
||||
|
||||
def test_a_metric_answer_on_an_imperial_machine_fails_the_placement(self):
|
||||
# The defect the bench found: 3.2 read as inches is over the 30 mm
|
||||
# cap and the wizard records 0; the test must not read that as a pass.
|
||||
from forgetest.runner import Failed
|
||||
place_prompts, place_result = SCRIPT["sheet.place"]
|
||||
SCRIPT["sheet.place"] = (place_prompts, dict(place_result, thickness_mm=0.0))
|
||||
try:
|
||||
t = catalog.load_suite()["commission.sheet"]
|
||||
run = Run("test", t.id, t.title)
|
||||
run.unattended = True
|
||||
with self.assertRaises(Failed) as cm:
|
||||
t.fn(Context(run, None, t))
|
||||
self.assertIn("thickness 0.0 mm", str(cm.exception))
|
||||
self.assertEqual(self.daemon.order, ["sheet.place"]) # nothing burned
|
||||
finally:
|
||||
SCRIPT["sheet.place"] = (place_prompts, place_result)
|
||||
|
||||
def test_a_dark_burn_fails_the_run_and_still_restores(self):
|
||||
from forgetest.runner import Failed
|
||||
SCRIPT_DARK = dict(SCRIPT["laser.floor"][1])
|
||||
SCRIPT_DARK["emission"] = {"hv_max": 0, "laser_on_samples": 0, "thermopile_delta": 0}
|
||||
SCRIPT["laser.floor"] = (SCRIPT["laser.floor"][0], SCRIPT_DARK)
|
||||
try:
|
||||
t = catalog.load_suite()["commission.sheet"]
|
||||
run = Run("test", t.id, t.title)
|
||||
run.unattended = True
|
||||
with self.assertRaises(Failed):
|
||||
t.fn(Context(run, None, t))
|
||||
self.assertEqual(self.daemon.order, ["sheet.place", "sheet.frame", "laser.focus", "laser.floor"])
|
||||
for k, v in FOUND.items():
|
||||
self.assertEqual(self.fake.state["settings"][k], v, k)
|
||||
finally:
|
||||
SCRIPT["laser.floor"] = (SCRIPT["laser.floor"][0], dict(SCRIPT_DARK, emission=EMISSION))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,415 @@
|
||||
"""The commission.* suite on the host: the registration (ids, kinds, the
|
||||
takeover tests, the operator tests' hands), the record builders, the
|
||||
mDNS packet code, the cookie parsing, the LED cue, the settle rule for a
|
||||
gated supervisor, and the cloud-off surface test driven end to end
|
||||
against the fake daemon."""
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import struct
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
import helpers
|
||||
from forgetest import baseline, catalog
|
||||
from forgetest.runner import Context, Run
|
||||
from forgetest.suite import commission
|
||||
|
||||
IDS = ("commission.gate-blocks-controllers", "commission.override-until-reboot",
|
||||
"commission.advisories-rehash", "commission.account-login", "commission.https-only-writes",
|
||||
"commission.ssh-until-reboot", "commission.cloud-disabled-surface",
|
||||
"commission.factory-return", "commission.mdns-announce", "commission.first-run-flow",
|
||||
"commission.first-run-page", "commission.what-changed", "commission.record-export",
|
||||
"commission.mirror")
|
||||
OPERATOR = ("commission.first-run-flow", "commission.first-run-page")
|
||||
|
||||
|
||||
class RegistrationTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.reg = catalog.load_suite()
|
||||
|
||||
def test_every_test_is_registered_with_its_kind(self):
|
||||
kinds = {tid: self.reg[tid].kind for tid in IDS if tid in self.reg}
|
||||
self.assertEqual(sorted(kinds), sorted(IDS))
|
||||
for tid in OPERATOR:
|
||||
self.assertEqual(kinds[tid], "operator", tid)
|
||||
for tid in IDS:
|
||||
if tid not in OPERATOR:
|
||||
self.assertEqual(kinds[tid], "auto", tid)
|
||||
self.assertNotIn("commission.root-ssh-refused", self.reg) # folded into ssh-until-reboot
|
||||
self.assertNotIn("commission.wizard-first-run", self.reg) # split into the flow and the page
|
||||
|
||||
def test_the_record_swapping_tests_are_takeovers(self):
|
||||
for tid in ("commission.gate-blocks-controllers", "commission.advisories-rehash",
|
||||
"commission.first-run-flow", "commission.first-run-page",
|
||||
"commission.what-changed", "commission.mirror"):
|
||||
self.assertEqual(self.reg[tid].hardware, "takeover", tid)
|
||||
self.assertEqual(self.reg["commission.record-export"].hardware, "api")
|
||||
|
||||
def test_the_lifecycle_tests_cover_their_files(self):
|
||||
changed = set(self.reg["commission.what-changed"].covers)
|
||||
export = set(self.reg["commission.record-export"].covers)
|
||||
mirror = set(self.reg["commission.mirror"].covers)
|
||||
self.assertIn(("forgectrl", "src/ui/panel.js"), changed)
|
||||
self.assertIn(("forgectrl", "src/recordhtml.*"), export)
|
||||
self.assertIn(("forgectrl", "src/logs.*"), export)
|
||||
self.assertIn(("forgectrl", "src/wizdark.*"), mirror)
|
||||
self.assertIn("commission.check-sensors", self.reg["commission.mirror"].requires)
|
||||
|
||||
def test_flag_of_reads_the_status_document(self):
|
||||
w = {"required": ["airflow", {"id": "laser.floor", "reason": "the tube was replaced"}],
|
||||
"recommended": [{"id": "laser.focus", "reason": "the tray was replaced"}]}
|
||||
self.assertEqual(commission.flag_of(w, "required", "laser.floor"), "the tube was replaced")
|
||||
self.assertEqual(commission.flag_of(w, "recommended", "laser.focus"), "the tray was replaced")
|
||||
self.assertIsNone(commission.flag_of(w, "required", "airflow")) # a plain id is the table, not a flag
|
||||
self.assertIsNone(commission.flag_of(w, "required", "laser.focus"))
|
||||
self.assertIsNone(commission.flag_of({}, "recommended", "x"))
|
||||
|
||||
def test_the_first_run_flow_runs_unattended_with_the_fixture_and_the_page_walk_does_not(self):
|
||||
flow = self.reg["commission.first-run-flow"]
|
||||
self.assertEqual(list(flow.actions), ["button"])
|
||||
self.assertFalse(flow.hands)
|
||||
self.assertTrue(flow.fixture_runnable(("button", "lid", "interlock")))
|
||||
page = self.reg["commission.first-run-page"]
|
||||
self.assertTrue(page.hands)
|
||||
self.assertFalse(page.fixture_runnable(("button", "lid", "interlock")))
|
||||
self.assertIn("commission.first-run-flow", page.requires)
|
||||
|
||||
def test_the_first_run_split_keeps_the_backend_and_the_page_apart(self):
|
||||
flow = set(self.reg["commission.first-run-flow"].covers)
|
||||
page = set(self.reg["commission.first-run-page"].covers)
|
||||
for want in (("forgectrl", "src/wiz.*"), ("forgectrl", "src/commission.*"), ("forgectrl", "src/users.*"),
|
||||
("forgectrl", "src/button.*"), ("forgectrl", "src/led.*"), ("forgectrl", "src/advisories.*")):
|
||||
self.assertIn(want, flow, want)
|
||||
self.assertNotIn(want, page, want)
|
||||
for want in (("forgectrl", "src/ui/wizard.*"), ("forgectrl", "src/ui/md.js"), ("forgectrl", "src/tls.*")):
|
||||
self.assertIn(want, page, want)
|
||||
|
||||
def test_the_only_attended_tests_need_a_workstation_or_the_sheet(self):
|
||||
# With the bench actuator up, three commissioning tests keep a
|
||||
# person: the page walk, the print from the Glowforge app, and
|
||||
# the sheet. Everything else runs from the queue.
|
||||
attended = sorted(tid for tid, t in self.reg.items() if tid.startswith("commission.")
|
||||
and t.kind != "auto" and not t.fixture_runnable(("button", "lid", "interlock")))
|
||||
self.assertEqual(attended, ["commission.cloud-header-capture", "commission.first-run-page",
|
||||
"commission.sheet"])
|
||||
|
||||
def test_the_factory_return_never_runs_the_return(self):
|
||||
import inspect
|
||||
t = self.reg["commission.factory-return"]
|
||||
src = inspect.getsource(t.fn)
|
||||
self.assertNotIn('"confirm": "1"', src) # the only argument that starts the return
|
||||
self.assertIn('"confirm": "0"', src)
|
||||
self.assertEqual(t.kind, "auto") # the return itself is a bench drill, not a test
|
||||
self.assertFalse(t.hands)
|
||||
|
||||
def test_mdns_covers_nothing_by_design(self):
|
||||
self.assertEqual(self.reg["commission.mdns-announce"].covers, ())
|
||||
|
||||
def test_the_login_test_makes_its_own_account(self):
|
||||
# No bench credentials, no precheck: the test installs a temporary
|
||||
# account under a takeover and restores the real one.
|
||||
t = self.reg["commission.account-login"]
|
||||
self.assertIsNone(getattr(t, "precheck", None))
|
||||
self.assertEqual(t.hardware, "takeover")
|
||||
with open(commission.__file__) as f:
|
||||
src = f.read()
|
||||
for k in ("FORGETEST_LOGIN_NAME", "FORGETEST_LOGIN_PASSWORD"):
|
||||
self.assertNotIn(k, src)
|
||||
|
||||
|
||||
class RecordTests(unittest.TestCase):
|
||||
STATUS = {"sheet_id": "ABCDE-FGHIJ",
|
||||
"documents": [{"id": "safety-and-risk", "hash": "a" * 64, "consent": "typed"},
|
||||
{"id": "licenses", "hash": "b" * 64, "consent": "check"}],
|
||||
"wizards": [{"id": "advisories", "version": 1}, {"id": "account", "version": 1},
|
||||
{"id": "machine", "version": 2}]}
|
||||
|
||||
def test_complete_record_counts_as_commissioned(self):
|
||||
rec = commission.complete_record(self.STATUS)
|
||||
self.assertEqual(rec["schema"], 1)
|
||||
self.assertEqual(rec["advisories"]["safety-and-risk"]["hash"], "a" * 64)
|
||||
self.assertEqual(rec["advisories"]["safety-and-risk"]["method"], "typed")
|
||||
self.assertIn("pressed_at", rec["acceptance"])
|
||||
self.assertEqual(rec["account"]["name"], "bench")
|
||||
self.assertEqual(rec["wizards"]["machine"]["version"], 2)
|
||||
self.assertEqual(rec["sheet_id"], "ABCDE-FGHIJ")
|
||||
self.assertTrue(rec["completed"])
|
||||
json.dumps(rec)
|
||||
|
||||
def test_complete_record_keeps_the_base_account_and_machine(self):
|
||||
base = {"account": {"name": "owner", "uid": 1000, "created": "x"}, "machine": {"model": "pro"},
|
||||
"sheet_id": "KKKKK-LLLLL", "created": "then"}
|
||||
rec = commission.complete_record(self.STATUS, base)
|
||||
self.assertEqual(rec["account"]["name"], "owner")
|
||||
self.assertEqual(rec["machine"], {"model": "pro"})
|
||||
self.assertEqual(rec["sheet_id"], "KKKKK-LLLLL")
|
||||
self.assertEqual(rec["created"], "then")
|
||||
|
||||
def test_without_wizards_keeps_consent_and_account(self):
|
||||
rec = commission.complete_record(self.STATUS)
|
||||
rec["flags"] = {"machine": {"level": "required", "reason": "x"}}
|
||||
out = commission.record_without_wizards(rec)
|
||||
self.assertEqual(out["wizards"], {})
|
||||
self.assertEqual(out["flags"], {})
|
||||
self.assertEqual(out["advisories"], rec["advisories"])
|
||||
self.assertEqual(out["account"], rec["account"])
|
||||
self.assertEqual(rec["flags"], {"machine": {"level": "required", "reason": "x"}}) # a copy
|
||||
|
||||
def test_write_file_and_remove(self):
|
||||
tmp = tempfile.mkdtemp(prefix="forgetest-comm-")
|
||||
try:
|
||||
p = os.path.join(tmp, "sub", "commissioning.json")
|
||||
commission.write_file(p, b"{}\n")
|
||||
self.assertEqual(commission.read_file(p), b"{}\n")
|
||||
if os.name == "posix": # a mode means nothing on a Windows host
|
||||
self.assertEqual(oct(os.stat(p).st_mode & 0o777), oct(0o600))
|
||||
commission.write_file(p, None)
|
||||
self.assertIsNone(commission.read_file(p))
|
||||
commission.write_file(p, None) # a second remove is silent
|
||||
finally:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
def test_paths_follow_the_daemon_environment(self):
|
||||
os.environ["FORGECTRL_DATA_DIR"] = "/tmp/ffdata"
|
||||
os.environ["GF_RUN_DIR"] = "/tmp/ffrun"
|
||||
try:
|
||||
self.assertEqual(commission.record_path(), "/tmp/ffdata/commissioning.json")
|
||||
self.assertEqual(commission.users_path(), "/tmp/ffdata/users")
|
||||
self.assertEqual(commission.override_path(), "/tmp/ffrun/commissioning-override")
|
||||
self.assertEqual(commission.ssh_flag_path(), "/tmp/ffrun/ssh-enabled")
|
||||
finally:
|
||||
os.environ.pop("FORGECTRL_DATA_DIR", None)
|
||||
os.environ.pop("GF_RUN_DIR", None)
|
||||
|
||||
|
||||
class MdnsTests(unittest.TestCase):
|
||||
def test_query_is_a_unicast_response_question(self):
|
||||
q = commission.mdns_query("forgefirm.local", qid=0x1234)
|
||||
qid, flags, qd, an, ns, ar = struct.unpack(">HHHHHH", q[:12])
|
||||
self.assertEqual((qid, flags, qd, an, ns, ar), (0x1234, 0, 1, 0, 0, 0))
|
||||
self.assertEqual(q[12:], b"\x09forgefirm\x05local\x00" + struct.pack(">HH", 1, 0x8001))
|
||||
|
||||
def _response(self, qid, name_bytes, addr, flags=0x8400, extra=b""):
|
||||
q = b"\x09forgefirm\x05local\x00" + struct.pack(">HH", 1, 1)
|
||||
rr = name_bytes + struct.pack(">HHIH", 1, 0x8001, 120, 4) + socket.inet_aton(addr)
|
||||
return struct.pack(">HHHHHH", qid, flags, 1, 1, 0, 0) + q + rr + extra
|
||||
|
||||
def test_answers_follow_a_compression_pointer(self):
|
||||
pkt = self._response(7, b"\xc0\x0c", "192.168.1.9")
|
||||
self.assertEqual(commission.mdns_answers(pkt, 7), [("forgefirm.local", "192.168.1.9")])
|
||||
|
||||
def test_answers_with_the_name_spelled_out(self):
|
||||
pkt = self._response(0, b"\x09forgefirm\x05local\x00", "10.0.0.5")
|
||||
self.assertEqual(commission.mdns_answers(pkt), [("forgefirm.local", "10.0.0.5")])
|
||||
|
||||
def test_wrong_id_or_a_query_yields_nothing(self):
|
||||
pkt = self._response(7, b"\xc0\x0c", "192.168.1.9")
|
||||
self.assertEqual(commission.mdns_answers(pkt, 8), [])
|
||||
self.assertEqual(commission.mdns_answers(self._response(7, b"\xc0\x0c", "1.2.3.4", flags=0), 7), [])
|
||||
self.assertEqual(commission.mdns_answers(b"\x00" * 5), [])
|
||||
|
||||
def test_a_truncated_packet_yields_nothing(self):
|
||||
pkt = self._response(7, b"\xc0\x0c", "192.168.1.9")
|
||||
self.assertEqual(commission.mdns_answers(pkt[:20], 7), [])
|
||||
|
||||
|
||||
class SmallHelpersTests(unittest.TestCase):
|
||||
def test_cookie_parsing(self):
|
||||
sid = "ab" * 32
|
||||
value = "ffsid=%s; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=43200" % sid
|
||||
self.assertEqual(commission.session_from_cookie(value), sid)
|
||||
self.assertEqual(commission.cookie_flags(value), {"path", "httponly", "secure", "samesite", "max-age"})
|
||||
self.assertIsNone(commission.session_from_cookie("ffsid=; Path=/; Max-Age=0"))
|
||||
self.assertIsNone(commission.session_from_cookie(""))
|
||||
|
||||
def test_the_teal_breathe_cue(self):
|
||||
self.assertTrue(commission.breathes_teal({"target": [0, 180, 200], "pulse_on": [0, 1400, 1400],
|
||||
"pulse_off": [0, 1400, 1400]}))
|
||||
self.assertFalse(commission.breathes_teal({"target": [0, 255, 40], "pulse_on": [0, 0, 0],
|
||||
"pulse_off": [0, 0, 0]})) # solid green
|
||||
self.assertFalse(commission.breathes_teal({"target": [200, 200, 200], "pulse_on": [1800, 1800, 1800],
|
||||
"pulse_off": [1800] * 3})) # white
|
||||
self.assertFalse(commission.breathes_teal({"target": [None, None, None], "pulse_on": [None] * 3}))
|
||||
self.assertFalse(commission.breathes_teal({}))
|
||||
|
||||
def test_decode(self):
|
||||
self.assertEqual(commission.decode(b'{"a": 1}'), {"a": 1})
|
||||
self.assertEqual(commission.decode(b"cloud mode is not enabled"), "cloud mode is not enabled")
|
||||
|
||||
|
||||
class GatedSettleTests(unittest.TestCase):
|
||||
"""A supervisor that reports the commissioning gate closed is settled:
|
||||
it spawns nothing until the gate opens, so a takeover that installs
|
||||
a gating record must not wait the whole settle timeout for it."""
|
||||
|
||||
def setUp(self):
|
||||
self.fake = helpers.FakeForgectrl().start()
|
||||
baseline.Baseline._unreachable_until = 0.0
|
||||
|
||||
def tearDown(self):
|
||||
self.fake.stop()
|
||||
|
||||
def test_gated_returns_at_once(self):
|
||||
self.fake.state["mode"] = {"mode": "grbl", "controller": "gated", "pid": 0, "motion": "unverified",
|
||||
"gated": True, "why": "commissioning required: machine"}
|
||||
lines = []
|
||||
body = baseline.Baseline(lines.append).wait_settled(timeout=8)
|
||||
self.assertEqual(body["controller"], "gated")
|
||||
self.assertTrue(any("controller=gated" in ln for ln in lines))
|
||||
|
||||
|
||||
class FirstRunSeedTests(unittest.TestCase):
|
||||
"""The record the first-run tests install: a first run behind the
|
||||
checks and the sheet."""
|
||||
|
||||
STATUS = {"wizards": [{"id": "advisories", "version": 1, "class": "form"},
|
||||
{"id": "account", "version": 1, "class": "form"},
|
||||
{"id": "cloud", "version": 1, "class": "form"},
|
||||
{"id": "switches", "version": 1, "class": "dark"},
|
||||
{"id": "motion", "version": 2, "class": "dark"},
|
||||
{"id": "sheet.frame", "version": 1, "class": "live"}]}
|
||||
|
||||
def test_no_consent_no_account_not_complete_and_every_check_done(self):
|
||||
base = {"created": "2026-09-01T00:00:00Z", "completed": "2026-09-02T00:00:00Z",
|
||||
"advisories": {"safety-and-risk": {"hash": "x"}}, "acceptance": {"pressed_at": "t"},
|
||||
"account": {"name": "scott", "uid": 1000}, "flags": {"flow_thin": True},
|
||||
"wizards": {"advisories": {"version": 1}, "switches": {"version": 1, "result": {"lid": True}}}}
|
||||
rec = commission.first_run_record(self.STATUS, base)
|
||||
self.assertNotIn("completed", rec)
|
||||
self.assertNotIn("acceptance", rec)
|
||||
self.assertNotIn("account", rec)
|
||||
self.assertEqual(rec["advisories"], {})
|
||||
self.assertEqual(rec["created"], "2026-09-01T00:00:00Z")
|
||||
self.assertEqual(rec["flags"], {"flow_thin": True})
|
||||
self.assertEqual(sorted(rec["wizards"]), ["motion", "sheet.frame", "switches"])
|
||||
self.assertEqual(rec["wizards"]["switches"]["result"], {"lid": True}) # kept from the base
|
||||
self.assertEqual(rec["wizards"]["motion"]["version"], 2) # written at the catalog version
|
||||
|
||||
def test_without_a_base_record(self):
|
||||
rec = commission.first_run_record(self.STATUS, None)
|
||||
self.assertEqual(sorted(rec["wizards"]), ["motion", "sheet.frame", "switches"])
|
||||
self.assertEqual(rec["wizards"]["sheet.frame"]["version"], 1)
|
||||
|
||||
|
||||
class SshdPolicyTests(unittest.TestCase):
|
||||
def test_the_three_keys_out_of_sshd_t(self):
|
||||
from forgetest import hw
|
||||
calls = []
|
||||
|
||||
def fake_run(cmd, timeout=60):
|
||||
calls.append(cmd)
|
||||
return 0, "port 22\npermitrootlogin no\npasswordauthentication yes\npermitemptypasswords no\nx11forwarding no\n"
|
||||
real = hw.run
|
||||
hw.run = fake_run
|
||||
try:
|
||||
self.assertEqual(commission.sshd_policy(), {"permitrootlogin": "no", "passwordauthentication": "yes",
|
||||
"permitemptypasswords": "no"})
|
||||
finally:
|
||||
hw.run = real
|
||||
self.assertEqual(calls, [["/usr/sbin/sshd", "-T"]])
|
||||
|
||||
def test_a_failing_sshd_reports_the_error(self):
|
||||
from forgetest import hw
|
||||
real = hw.run
|
||||
hw.run = lambda cmd, timeout=60: (1, "sshd: no hostkeys available")
|
||||
try:
|
||||
self.assertEqual(commission.sshd_policy(), {"error": "sshd: no hostkeys available"})
|
||||
finally:
|
||||
hw.run = real
|
||||
|
||||
|
||||
class CloudDisabledSurfaceTests(unittest.TestCase):
|
||||
"""The cloud-off surface test against the fake daemon: cloud_enabled
|
||||
is turned off with one write that sweeps the cloud choices, the three
|
||||
cloud-pointing writes and the phrase-less cloud_enabled=1 are refused,
|
||||
the settings are left alone, and the prior values come back."""
|
||||
|
||||
def setUp(self):
|
||||
self.fake = helpers.FakeForgectrl().start()
|
||||
self.fake.state["settings"].update({"controller_mode": "grbl", "homing_mode": "none",
|
||||
"cloud_enabled": "1", "ui_units": "metric"})
|
||||
|
||||
def on_post(path, form):
|
||||
s = self.fake.state["settings"]
|
||||
if path == "/settings":
|
||||
# as the daemon rules it: on from off takes the typed phrase
|
||||
if form.get("cloud_enabled") == "1" and s.get("cloud_enabled") != "1" \
|
||||
and form.get("phrase") != "I UNDERSTAND":
|
||||
return 400, "type I UNDERSTAND to turn cloud mode on"
|
||||
# the request's own cloud choices, as main.c checks them
|
||||
enabled = form.get("cloud_enabled", s.get("cloud_enabled")) == "1"
|
||||
if not enabled and form.get("controller_mode") == "cloud":
|
||||
return 409, "cloud mode is not enabled on this machine"
|
||||
if not enabled and form.get("homing_mode") == "gfcloud":
|
||||
return 409, "cloud homing needs cloud mode enabled"
|
||||
for k, v in form.items():
|
||||
if k == "phrase":
|
||||
continue
|
||||
if v == "":
|
||||
s.pop(k, None)
|
||||
else:
|
||||
s[k] = v
|
||||
# off sweeps what pointed at the cloud, as the cloud step does
|
||||
if form.get("cloud_enabled") == "0":
|
||||
if "homing_mode" not in form and s.get("homing_mode") == "gfcloud":
|
||||
s["homing_mode"] = "none"
|
||||
if "controller_mode" not in form and s.get("controller_mode") == "cloud":
|
||||
s["controller_mode"] = "grbl"
|
||||
return 200, dict(s)
|
||||
if path == "/mode" and form.get("controller") == "cloud" and s.get("cloud_enabled") != "1":
|
||||
return 409, "cloud mode is not enabled on this machine"
|
||||
return None
|
||||
self.fake.on_post = on_post
|
||||
|
||||
def tearDown(self):
|
||||
self.fake.stop()
|
||||
|
||||
def run_test(self):
|
||||
t = catalog.load_suite()["commission.cloud-disabled-surface"]
|
||||
run = Run("test", t.id, t.title)
|
||||
t.fn(Context(run, None, t))
|
||||
return run
|
||||
|
||||
def test_refusals_and_restore(self):
|
||||
run = self.run_test()
|
||||
s = self.fake.state["settings"]
|
||||
self.assertEqual(s["cloud_enabled"], "1") # restored, with the phrase
|
||||
self.assertEqual(s["controller_mode"], "grbl")
|
||||
self.assertEqual(s["homing_mode"], "none")
|
||||
self.assertNotIn("phrase", s)
|
||||
posts = [(p, f) for p, f in self.fake.posts]
|
||||
self.assertIn(("/settings", {"cloud_enabled": "0"}), posts)
|
||||
self.assertIn(("/settings", {"cloud_enabled": "1"}), posts) # refused: no phrase
|
||||
self.assertIn(("/settings", {"cloud_enabled": "1", "phrase": "I UNDERSTAND"}), posts)
|
||||
self.assertIn(("/mode", {"controller": "cloud"}), posts)
|
||||
self.assertEqual(run.evidence["mode_cloud"]["status"], 409)
|
||||
self.assertEqual(run.evidence["settings cloud_enabled=1 no phrase"], 400)
|
||||
self.assertEqual(run.evidence["found"]["cloud_enabled"], "1")
|
||||
|
||||
def test_cloud_homing_and_boot_mode_are_swept_by_the_one_write_and_go_back(self):
|
||||
# As the daemon rules it: cloud_enabled=0 takes the gfcloud homing
|
||||
# and the cloud boot mode down with it, so the test writes the one
|
||||
# key, checks the sweep, and puts the three back, cloud mode first.
|
||||
self.fake.state["settings"].update({"controller_mode": "cloud", "homing_mode": "gfcloud"})
|
||||
run = self.run_test()
|
||||
s = self.fake.state["settings"]
|
||||
self.assertEqual((s["cloud_enabled"], s["homing_mode"], s["controller_mode"]), ("1", "gfcloud", "cloud"))
|
||||
posts = [(p, f) for p, f in self.fake.posts if p == "/settings"]
|
||||
order = [f for _, f in posts]
|
||||
self.assertEqual(order[:1], [{"cloud_enabled": "0"}])
|
||||
self.assertEqual(order[-3:], [{"cloud_enabled": "1", "phrase": "I UNDERSTAND"},
|
||||
{"homing_mode": "gfcloud"}, {"controller_mode": "cloud"}])
|
||||
self.assertEqual(run.evidence["found"], {"cloud_enabled": "1", "homing_mode": "gfcloud",
|
||||
"controller_mode": "cloud"})
|
||||
|
||||
def test_an_unset_value_is_cleared_back(self):
|
||||
self.fake.state["settings"].pop("cloud_enabled")
|
||||
self.run_test()
|
||||
self.assertNotIn("cloud_enabled", self.fake.state["settings"])
|
||||
self.assertIn(("/settings", {"cloud_enabled": ""}), self.fake.posts)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -586,6 +586,33 @@ class RoutingTests(unittest.TestCase):
|
||||
finally:
|
||||
runner_mod.hw.button_lit = saved
|
||||
|
||||
def test_the_arm_press_waits_as_long_as_the_card_says_for_the_light(self):
|
||||
"""A card that settles the coolant before it lights the button
|
||||
takes minutes; the caller names the wait, and the actuator presses
|
||||
when the light comes instead of handing the press to a person at
|
||||
the default 60 s (found on the bench by the flow-load card)."""
|
||||
run = Run("test", "r.live", "r.live")
|
||||
ctx = Context(run, self.runner, self.reg["r.live"])
|
||||
self.runner.probe_fixture(force=True)
|
||||
run.fixture_takeover = True
|
||||
lit_at = time.time() + 0.6
|
||||
saved = runner_mod.hw.button_lit
|
||||
runner_mod.hw.button_lit = lambda: time.time() >= lit_at
|
||||
seen = []
|
||||
run.set_notice = lambda text: seen.append(text)
|
||||
try:
|
||||
self.assertTrue(ctx.arm_press(lit_timeout=5))
|
||||
deadline = time.time() + 5
|
||||
while ("button", "press") not in self.stub.acts and time.time() < deadline:
|
||||
time.sleep(0.05)
|
||||
self.assertIn(("button", "press"), self.stub.acts)
|
||||
self.assertEqual([n for n in seen if n], []) # nobody was asked
|
||||
rec = run.evidence["actions"][-1]
|
||||
self.assertEqual(rec["by"], "fixture")
|
||||
self.assertGreaterEqual(rec["took_s"], 0.5)
|
||||
finally:
|
||||
runner_mod.hw.button_lit = saved
|
||||
|
||||
def test_an_actuator_lost_after_the_takeover_is_said_out_loud(self):
|
||||
"""Falling back to the operator without a word is how one dropped
|
||||
actuator becomes a press nobody can account for afterwards."""
|
||||
|
||||
Reference in New Issue
Block a user