Files
forgefirm/forgetest/tests/test_setup_sheet.py
T
2026-09-18 12:14:22 -04:00

386 lines
19 KiB
Python

# Copyright 2026 514 LLC d/b/a OpenGlow
# Written by Scott Wiederhold
# https://community.openglow.org
# SPDX-License-Identifier: MIT
"""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 setup_sheet # noqa: E402
LIVE = ["sheet.frame", "laser.focus", "laser.floor", "laser.dose-curve", "laser.corner", "cooling.flow-load"]
# The focus card's program is read again after it ran: the served ladder
# must then lie in the window the card wrote.
PREVIEWS = ["sheet.frame", "laser.focus", "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}
SPM = 2.922 # the lens screw, half-steps per mm ($102)
EDGE_DEFAULT = 3.35
def lens_block(s):
"""The /status lens block as the daemon builds it from the settings: a
stop count from 1 to 40 stands, anything else is the fallback."""
def count(key, fallback):
try:
n = float(s.get(key, ""))
except ValueError:
return fallback, False
return (int(n), True) if 1 <= n <= 40 else (fallback, False)
try:
edge = float(s.get("lens_hall_edge_z_mm", ""))
except ValueError:
edge = EDGE_DEFAULT
below, a = count("lens_stop_below_steps", 10)
above, b = count("lens_stop_above_steps", 12)
return {"edge_z": round(edge, 2), "below": below, "above": above, "stops_found": a and b,
"reach_min": round(edge - below / SPM, 2), "reach_max": round(edge + above / SPM, 2)}
def ladder_z(edge, below, above):
"""The focus ladder the daemon builds: twelve whole half-steps from
the top of the window to the bottom."""
return [round(edge + round(above - (above + below) * i / 11) / SPM, 2) for i in range(12)]
# The window the focus card finds and writes before its controller starts.
STOPS = {"lens_stop_below_steps": "14", "lens_stop_above_steps": "20"}
FOCUS_WINDOW = {"below": 14, "above": 20, "reach_min": round(EDGE_DEFAULT - 14 / SPM, 2),
"reach_max": round(EDGE_DEFAULT + 20 / SPM, 2)}
# 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": ""},
"window": dict(FOCUS_WINDOW), "ladder_z": ladder_z(EDGE_DEFAULT, 14, 20),
"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 a card writes when it starts (the focus card's window, before its
# controller starts) and when it ends.
START_WRITES = {"laser.focus": dict(STOPS)}
WRITES = {
"laser.focus": {"lens_hall_edge_z_mm": "6.96"},
"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()["setup.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 ("setup.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("setup.sheet")]
self.assertEqual(ids, ["setup.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", "src/wizrun.h"),
("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(setup_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.settings_at_start = {} # wid -> the settings as the wizard found them
self.previews = []
self.queue = []
self.seq = 0
self.stray_z = None # a Z the served program commands beyond the reach, when set
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):
s = self.fake.state["settings"]
if path == "/status":
# the lens block follows the settings, as the daemon's does
return 200, dict(self.fake.state["status"], lens=lens_block(s))
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; the head's Z is the edge, the tail goes back to
# it, and the focus card's ladder spans the window the settings
# hold now
lens = lens_block(s)
lines = ["; mock", "G0 Z%.2f" % lens["edge_z"], "M4 S400", "G0 X60 Y42", "G1 X120 Y42 F3000"]
if q.get("card") == "laser.focus":
lines += ["G0 Z%.2f" % z for z in ladder_z(lens["edge_z"], lens["below"], lens["above"])]
if self.stray_z is not None:
lines.append("G0 Z%.2f" % self.stray_z)
lines += ["M5", "G0 Z%.2f" % lens["edge_z"], "G0 X0 Y0", "M2"]
return 200, ("\n".join(lines) + "\n").encode(), "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.settings_at_start[wid] = dict(s)
s.update(START_WRITES.get(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()["setup.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, PREVIEWS)
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)
# A fresh machine's run: the lens settings were gone before the
# frame, the frame's program ran in the fallback window, the focus
# card's window was in the settings when the floor card started,
# and the window check filed what it compared.
for k in setup_sheet.LENS_SETTINGS:
self.assertNotIn(k, d.settings_at_start["sheet.frame"], k)
self.assertEqual(run.evidence["lens_fresh"]["stops_found"], False)
self.assertEqual(run.evidence["program_z"]["sheet.frame"]["reach"], [-0.07, 7.46])
floor_start = d.settings_at_start["laser.floor"]
self.assertEqual({k: floor_start[k] for k in setup_sheet.LENS_SETTINGS},
{"lens_hall_edge_z_mm": "6.96", "lens_stop_below_steps": "14", "lens_stop_above_steps": "20"})
fw = run.evidence["focus_window"]
self.assertEqual(fw["window"], FOCUS_WINDOW)
self.assertEqual(fw["settings"]["lens_stop_below_steps"], "14")
self.assertEqual(len(fw["ladder_z"]), 12)
self.assertEqual(fw["program_z_before"]["reach"], [-0.07, 7.46]) # the fallback ladder
self.assertEqual(fw["program_z_after"]["reach"], [2.17, 13.8]) # the written window at the new edge
self.assertEqual(len(fw["program_z_after"]["z"]), 14) # head, twelve lines, tail
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()["setup.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()["setup.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()["setup.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))
def test_a_focus_window_the_settings_do_not_hold_fails_the_run(self):
# The defect the bench found: the focus card burned its ladder over
# the stops it found while the controller's Z limit stood on the
# settings, which did not hold them. A daemon whose focus result
# names a window the settings do not hold fails the run before the
# floor card, and the settings still go back.
from forgetest.runner import Failed
saved = dict(START_WRITES["laser.focus"])
START_WRITES["laser.focus"] = {}
try:
t = catalog.load_suite()["setup.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("the settings hold", str(cm.exception))
self.assertEqual(self.daemon.order, ["sheet.place", "sheet.frame", "laser.focus"])
for k, v in FOUND.items():
self.assertEqual(self.fake.state["settings"][k], v, k)
finally:
START_WRITES["laser.focus"] = saved
def test_a_program_z_beyond_the_reach_fails_before_the_burn(self):
# A served program that commands a Z the lens does not reach is
# refused before the card starts: nothing burns.
from forgetest.runner import Failed
self.daemon.stray_z = 10.6
t = catalog.load_suite()["setup.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("outside the lens window", str(cm.exception))
self.assertIn("10.6", str(cm.exception))
self.assertEqual(self.daemon.order, ["sheet.place"])
for k, v in FOUND.items():
self.assertEqual(self.fake.state["settings"][k], v, k)
if __name__ == "__main__":
unittest.main()