mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-10-18 18:41:41 -07:00
forgetest: a failed press is recorded as the fixture's only when its request went out
_press_taken recorded the press as the fixture's for every FixtureError
that arrived with the button out. That included failures in which the
box made no press for the request: its refusal (a 401, or the 409 of a
jumper pulled after the probe), a failed mDNS lookup, a refused
connect. When the person at the machine pressed meanwhile, or the arm
wait ended for another reason, the evidence and the exported artifact
gave the box a press it never made, and press_now returned True.
FixtureError now carries `sent`: True when the failure came after the
request went out (a timeout or a reset once the request was sent,
which urllib raises bare; a failed connect or send is a URLError), so
the box may have pressed. The one retry after a fresh lookup keeps it,
also when the lookup itself fails. A refusal and a failed lookup leave
it False. With the button out, _press_taken still puts up no notice,
and it records the press as the fixture's only for a sent error;
otherwise the record stays the operator's, and press_now returns
whether the fixture pressed. No judge reads `by`, and no energy path
changes.
Proof: forgetest-ci "Unit tests" (`python -B -m unittest discover -s
tests -v` in forgetest): test_fixture.py runs 22 tests, OK on Ubuntu
24.04 with python 3.12, and on Windows with python 3.14 with
test_ready_reads_a_short_press_as_an_edge skipped (no os.mkfifo).
test_a_failure_after_the_request_went_out_says_so drives the real
client against the fake box: a press whose reply comes after the
client's timeout is sent, with one press, also when the refresh lookup
after it finds nothing; the jumper-out refusal, a failed lookup and a
refused connect are not sent, with no press.
test_the_arm_notice_comes_down_when_the_press_is_taken gains the leg
where the box refuses while the button goes out: press_now returns
False with no notice and the record the operator's, and arm_press
leaves the same. test_server.py (10), test_operator.py (17),
test_queue.py (15), test_mode.py (9), test_setup_dark.py (21) and
test_setup_burns.py (24) pass on both. Negative controls on scratch
copies: _press_taken giving every failure to the box fails the refusal
leg (press_now True); `sent` never set fails the first client leg;
`sent` set for a URLError too fails the refused-connect leg; the retry
that drops the flag fails the retry leg. pyflakes reports no undefined
name.
Acceptance: every catalog test's fingerprint moves: runner.py is a
shared judge, in every implementation hash (43 of 43 move); fixture.py
is in none. Against the campaign this series already needs, that adds
no run: 850498f moved all 43 once. tests/ are in none.
This commit is contained in:
@@ -43,7 +43,14 @@ PULSE_WAIT_S = 1.5 # a pulse in progress ends within the firmware's
|
||||
|
||||
|
||||
class FixtureError(Exception):
|
||||
pass
|
||||
"""A request the fixture did not carry out as asked. `sent` is True
|
||||
when the failure came after the request went out (no reply, or a
|
||||
reply cut off), so the box may have acted on it; a refusal, a failed
|
||||
lookup and a failed connect leave it False."""
|
||||
|
||||
def __init__(self, *args, sent=False):
|
||||
super().__init__(*args)
|
||||
self.sent = sent
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- mDNS
|
||||
@@ -271,11 +278,20 @@ class Fixture:
|
||||
body = {}
|
||||
return e.code, body
|
||||
except (urllib.error.URLError, socket.timeout, OSError) as e:
|
||||
# urllib raises a failed connect or send as a URLError; a
|
||||
# timeout or a reset after the request went out comes bare, and
|
||||
# the box may have acted on the request
|
||||
sent = not isinstance(e, urllib.error.URLError)
|
||||
if retry and not self.ip_override:
|
||||
# the address may have moved: one fresh lookup, one retry
|
||||
# the address may have moved: one fresh lookup, one retry,
|
||||
# and a failure of either still says the first one went out
|
||||
try:
|
||||
self.address(refresh=True)
|
||||
return self._request(method, path, body, retry=False)
|
||||
raise FixtureError("fixture %s: %s %s: %s" % (self.where(), method, path, e))
|
||||
except FixtureError as again:
|
||||
again.sent = again.sent or sent
|
||||
raise
|
||||
raise FixtureError("fixture %s: %s %s: %s" % (self.where(), method, path, e), sent=sent)
|
||||
|
||||
def status(self):
|
||||
st, body = self._request("GET", "/")
|
||||
|
||||
@@ -485,7 +485,7 @@ class Context:
|
||||
except _fixture.FixtureError as e:
|
||||
rec["fixture_error"] = str(e)
|
||||
if self._press_taken(rec, e):
|
||||
return True
|
||||
return rec["by"] == "fixture"
|
||||
self.log("ARM press: fixture failed (%s) - asking the operator", e)
|
||||
self.notice(text)
|
||||
self._clear_when_taken()
|
||||
@@ -496,16 +496,23 @@ class Context:
|
||||
|
||||
def _press_taken(self, rec, err):
|
||||
"""After the box reported a failed press of a lit button: True when
|
||||
the button is out, so the controller took a press (the box pressed
|
||||
and its reply was lost). The press is recorded as the box's, and
|
||||
no notice goes up, because it would ask for a second press, and a
|
||||
press during the job pauses it. A button still lit, or unreadable,
|
||||
is False: the operator is asked."""
|
||||
the button is out, so the controller took a press. No notice goes
|
||||
up then, because it would ask for a second press, and a press during
|
||||
the job pauses it. The press is recorded as the box's only when its
|
||||
request went out and the failure came after it (err.sent: the box
|
||||
pressed and its reply was lost). After a refusal, a failed lookup
|
||||
or a failed connect the box made no press, and the record stays as
|
||||
it was: the press was a person's. A button still lit, or
|
||||
unreadable, is False: the operator is asked."""
|
||||
if hw.button_lit() is not False:
|
||||
return False
|
||||
if getattr(err, "sent", False):
|
||||
rec["by"] = "fixture"
|
||||
self.log("ARM press: the fixture reported a failure (%s), and the button is out: the press "
|
||||
"was taken; no notice", err)
|
||||
else:
|
||||
self.log("ARM press: the fixture made no press (%s), and the button is out: a press was "
|
||||
"taken all the same; no notice", err)
|
||||
return True
|
||||
|
||||
def _clear_when_taken(self, lit_timeout=ARM_LIT_WAIT_S):
|
||||
|
||||
@@ -40,6 +40,8 @@ class FakeFixture:
|
||||
self.button_enabled = button_enabled
|
||||
self.calls = []
|
||||
self.presses = 0 # presses the device performed
|
||||
self.reply_delay = 0.0 # seconds between a press and its reply
|
||||
self.replied = 0 # delayed replies the device is done with (sent or lost)
|
||||
srv = self
|
||||
|
||||
class H(BaseHTTPRequestHandler):
|
||||
@@ -95,6 +97,14 @@ class FakeFixture:
|
||||
threading.Timer(0.05, lambda: srv.state.__setitem__("button", "idle")).start()
|
||||
s = self._state()
|
||||
s["pulse_ms"] = 200
|
||||
if srv.reply_delay:
|
||||
time.sleep(srv.reply_delay) # the reply comes after the client gave up
|
||||
try:
|
||||
self._json(200, s)
|
||||
except OSError:
|
||||
pass
|
||||
srv.replied += 1
|
||||
return None
|
||||
return self._json(200, s)
|
||||
if self.path == "/release":
|
||||
srv.state = {"lid": "closed", "interlock": "closed", "button": "idle"}
|
||||
@@ -109,6 +119,11 @@ class FakeFixture:
|
||||
self.httpd.shutdown()
|
||||
self.httpd.server_close()
|
||||
|
||||
def wait_replied(self, n, timeout=5.0):
|
||||
deadline = time.time() + timeout
|
||||
while self.replied < n and time.time() < deadline:
|
||||
time.sleep(0.02)
|
||||
|
||||
|
||||
class ConfigTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
@@ -155,11 +170,11 @@ class ClientTests(unittest.TestCase):
|
||||
self.resolved.append(hostname)
|
||||
return "127.0.0.1"
|
||||
|
||||
def client(self, **over):
|
||||
def client(self, timeout=3.0, resolver=None, **over):
|
||||
cfg = {"hostname": "forgefixture", "key": KEY, "channels": ["lid", "interlock", "button"],
|
||||
"port": self.dev.port}
|
||||
cfg.update(over)
|
||||
return fx.Fixture(cfg, resolver=self.resolver)
|
||||
return fx.Fixture(cfg, timeout=timeout, resolver=resolver or self.resolver)
|
||||
|
||||
def test_status_covers_act_release(self):
|
||||
f = self.client()
|
||||
@@ -196,6 +211,35 @@ class ClientTests(unittest.TestCase):
|
||||
f.act("button", "press")
|
||||
self.assertIn("jumper", str(cm.exception))
|
||||
|
||||
def test_a_failure_after_the_request_went_out_says_so(self):
|
||||
# The press made and its reply lost (it comes after the client's
|
||||
# timeout): sent, one press. A refusal, a failed lookup and a
|
||||
# refused connect: not sent, no press. The one retry keeps it.
|
||||
self.dev.reply_delay = 0.6
|
||||
with self.assertRaises(fx.FixtureError) as cm:
|
||||
self.client(timeout=0.2, ip="127.0.0.1").act("button", "press")
|
||||
self.assertTrue(cm.exception.sent)
|
||||
self.dev.wait_replied(1)
|
||||
self.assertEqual(self.dev.presses, 1)
|
||||
answers = ["127.0.0.1"] # the refresh lookup after the timeout finds nothing
|
||||
with self.assertRaises(fx.FixtureError) as cm:
|
||||
self.client(timeout=0.2, resolver=lambda h: answers.pop(0) if answers else None).act("button", "press")
|
||||
self.assertTrue(cm.exception.sent)
|
||||
self.dev.wait_replied(2)
|
||||
self.assertEqual(self.dev.presses, 2)
|
||||
self.dev.reply_delay = 0.0
|
||||
self.dev.button_enabled = False
|
||||
with self.assertRaises(fx.FixtureError) as cm:
|
||||
self.client(ip="127.0.0.1").act("button", "press")
|
||||
self.assertFalse(cm.exception.sent)
|
||||
with self.assertRaises(fx.FixtureError) as cm:
|
||||
self.client(resolver=lambda h: None).act("button", "press")
|
||||
self.assertFalse(cm.exception.sent)
|
||||
with self.assertRaises(fx.FixtureError) as cm:
|
||||
self.client(timeout=0.5, ip="127.0.0.1", port=1).act("button", "press")
|
||||
self.assertFalse(cm.exception.sent)
|
||||
self.assertEqual(self.dev.presses, 2)
|
||||
|
||||
def test_a_wrong_key_is_refused(self):
|
||||
f = self.client(key="wrong")
|
||||
with self.assertRaises(fx.FixtureError) as cm:
|
||||
@@ -631,7 +675,7 @@ class RoutingTests(unittest.TestCase):
|
||||
# press was taken, and no notice asks for a second one.
|
||||
def pressed_then_failed(channel, state, ms=None):
|
||||
lit["v"] = False # the controller takes the press
|
||||
raise fx.FixtureError("timed out")
|
||||
raise fx.FixtureError("timed out", sent=True)
|
||||
self.stub.act = pressed_then_failed
|
||||
lit["v"] = True
|
||||
self.assertTrue(ctx.press_now())
|
||||
@@ -645,6 +689,25 @@ class RoutingTests(unittest.TestCase):
|
||||
time.sleep(0.1)
|
||||
self.assertEqual(ctx.evidence["actions"][-1]["by"], "fixture")
|
||||
self.assertIsNone(run.notice)
|
||||
# A refusal (or a request that never reached the box) while the
|
||||
# button went out: a press at the machine was taken, so no notice
|
||||
# goes up either, and the record does not give it to the box.
|
||||
def refused_while_pressed(channel, state, ms=None):
|
||||
lit["v"] = False # a person's press is taken
|
||||
raise fx.FixtureError("button press -> 409 button disabled: the enable jumper is out")
|
||||
self.stub.act = refused_while_pressed
|
||||
lit["v"] = True
|
||||
self.assertFalse(ctx.press_now())
|
||||
self.assertIsNone(run.notice)
|
||||
self.assertEqual(ctx.evidence["actions"][-1]["by"], "operator")
|
||||
lit["v"] = True
|
||||
self.assertTrue(ctx.arm_press())
|
||||
deadline = time.time() + 2
|
||||
while "fixture_error" not in ctx.evidence["actions"][-1] and time.time() < deadline:
|
||||
time.sleep(0.02)
|
||||
time.sleep(0.1)
|
||||
self.assertEqual(ctx.evidence["actions"][-1]["by"], "operator")
|
||||
self.assertIsNone(run.notice)
|
||||
finally:
|
||||
runner_mod.hw.button_lit = saved
|
||||
|
||||
|
||||
Reference in New Issue
Block a user