From 16a8ae9ba3b958dd28911e3bbb4d0e9b709cc33b Mon Sep 17 00:00:00 2001 From: ScottW514 Date: Sat, 10 Oct 2026 17:33:43 -0400 Subject: [PATCH] forgetest: cooling.intake-one-fan, and setup.check-airflow reads the intake fan count forgectrl c274f19 adds cool_intake_fans: at 1, a machine built with one intake fan, the second intake is published not fitted and never judged, and the setup's airflow check writes the count it finds. cooling.intake-one-fan, in a suite module of its own, proves both halves on a machine with two intake fans with the setting alone. With cool_intake_fans 1 and an 8 s grace, a session at the cut fan profile must publish intake_2 as not fitted with no floor while it reads a real speed, run OK, and judge intake_1 ok at the intake floor; with the intake floor at its legal maximum it must trip AIRFLOW (hold, fire blocked) with intake_1 TRIPPED and intake_2 still not fitted, and the fault must end with the session; restored, every fan reads ok at its floor. It starts only from a machine with both intakes judged, requires cooling.fan-gate-trips, and puts the count, the intake floor and the grace back as found on every exit path. setup.check-airflow now reads the count from the result: 2 needs all four fans at least 1000 rpm, 1 needs intake 2 reported {"fitted": false} and the other three ok; either way the setting must read what the check found, and the count is put back with the five floors. Fingerprints: the new test has a module of its own, so no cooling test moves; the setup.check-airflow edit is inside its function. Diffed across the change: 127 -> 128 tests, one added, setup.check-airflow the only one moved. Proof: the touched host tests pass (test_cooling_intake_suite 4, test_setup_dark 10, test_cooling_suite 25) and pyflakes is clean. test_cooling_intake_suite replays the test against a scripted engine, and two broken engines fail it with the settings put back: one that ignores the setting, and one that takes intake 1 out with intake 2. test_setup_dark's new AirflowCheckTests take a two-fan and a one-fan result, and fail a count the setting does not match and a result with no count. On the bench reference, image 20260928194539 (dev) with these files and forgectrl c274f19 bind-mounted, cooling.fan-gate-trips, cooling.intake-one-fan and setup.check-airflow pass with a clean baseline before and after each (forgectrl's commit has the readings). --- forgetest/forgetest/suite/__init__.py | 1 + forgetest/forgetest/suite/cooling_intake.py | 113 ++++++++++++ forgetest/forgetest/suite/setup_dark.py | 22 ++- forgetest/tests/test_cooling_intake_suite.py | 176 +++++++++++++++++++ forgetest/tests/test_setup_dark.py | 119 +++++++++++++ 5 files changed, 425 insertions(+), 6 deletions(-) create mode 100644 forgetest/forgetest/suite/cooling_intake.py create mode 100644 forgetest/tests/test_cooling_intake_suite.py diff --git a/forgetest/forgetest/suite/__init__.py b/forgetest/forgetest/suite/__init__.py index 3e64a2c..3ab4eda 100644 --- a/forgetest/forgetest/suite/__init__.py +++ b/forgetest/forgetest/suite/__init__.py @@ -15,6 +15,7 @@ from . import setup_sheet # noqa: F401,E402 from . import logs # noqa: F401,E402 from . import motion # noqa: F401,E402 from . import cooling # noqa: F401,E402 +from . import cooling_intake # noqa: F401,E402 from . import laser # noqa: F401,E402 from . import camera # noqa: F401,E402 from . import update # noqa: F401,E402 diff --git a/forgetest/forgetest/suite/cooling_intake.py b/forgetest/forgetest/suite/cooling_intake.py new file mode 100644 index 0000000..f7d5c3c --- /dev/null +++ b/forgetest/forgetest/suite/cooling_intake.py @@ -0,0 +1,113 @@ +# Copyright 2026 514 LLC d/b/a OpenGlow +# Written by Scott Wiederhold +# https://community.openglow.org +# SPDX-License-Identifier: MIT + +"""cooling.intake-one-fan - a machine built with one intake fan +(cool_intake_fans 1, which the setup's airflow check writes): the second +intake tach is published as not fitted and never judged, and intake 1 +still holds the intake floor. On a machine with two intake fans the +setting alone proves both halves: intake 2 turns and is not judged, and +an intake floor no fan can meet trips with intake 2 still out of it.""" +from ..catalog import test +from .cooling import _COOL_COVERS, _after_session, _cool, _fan_gate, _run_session, _set_gates + +INTAKE_KEYS = ("cool_intake_fans", "cool_tach_intake_min_rpm", "cool_fan_grace_s") +INTAKE_GRACE_S = "8" # past the intakes' 7 s to 90 percent, so only the leg's floor trips +INTAKE_TRIP_WAIT_S = 20 # grace + three ticks, with slack for the 1 Hz pipeline + + +@test("cooling.intake-one-fan", title="With one intake fan set, intake 2 is not judged and intake 1 still is", + subsystem="cooling", kind="auto", mode="grbl", est_min=3, + covers=_COOL_COVERS + [("forgectrl", "src/main.c")], requires=["cooling.fan-gate-trips"], + steps=["Machine idle, fans quiet. The test sets one intake fan, the intake floor and the spin-up " + "grace and restores all three; three short M8/M9 cycles spin the fans."], + description="cool_intake_fans 1 must take intake 2 out of the airflow gates and leave intake 1 in " + "them. With it set, a session at the cut fan profile must publish intake_2 as not fitted " + "with no floor while it reads its real speed, run OK, and judge intake_1 ok at the intake " + "floor; an intake floor at its legal maximum must then trip AIRFLOW (hold, fire blocked) " + "with intake_1 TRIPPED and intake_2 still not fitted, and the fault must end with the " + "session; restored, the next session judges intake_2 again and every fan reads at or " + "above its floor.") +def intake_one_fan(ctx): + fc = ctx.forgectrl + ev = ctx.evidence + before = fc.settings() + orig = {k: before.get(k, "") for k in INTAKE_KEYS} + ev["orig"] = orig + ctx.log("original: %s", orig) + inl = (before.get("gates") or {}).get("cool_tach_intake_min_rpm") or {} + ctx.check(inl.get("gate") == "intake" and inl.get("off") == "low", + "settings reply does not describe the intake floor as the intake gate, off at zero: %s", inl) + ctx.check(orig["cool_intake_fans"] != "1", + "cool_intake_fans is already 1: the test starts from a machine with both intakes judged") + c0 = _cool(fc) + ctx.check(c0.get("verdict") == "OK" and c0.get("gates_off") == [], + "engine is not at OK with every gate on before the test: %s", c0) + + restored = False + with ctx.grbl() as grbl: + st = grbl.status_report() + ctx.check(st["state"].startswith("Idle"), "controller is %s", st["state"]) + try: + # Leg 1: one intake fan. Intake 2 still turns on this machine; it + # is measured and published not fitted, and nothing judges it. + _set_gates(ctx, fc, {"cool_intake_fans": "1", "cool_fan_grace_s": INTAKE_GRACE_S}) + c = _run_session(ctx, grbl, fc, + lambda c: c.get("verdict") == "OK" and _fan_gate(c, "intake_1").get("state") == "ok", + "one intake fan leg", wait=INTAKE_TRIP_WAIT_S + 10) + g1, g2 = _fan_gate(c, "intake_1"), _fan_gate(c, "intake_2") + ev["one_fan"] = {"cool": c, "intake_1": g1, "intake_2": g2} + ctx.check(g2.get("state") == "not fitted" and (g2.get("floor") or 0) == 0, + "intake_2 is not published not fitted with no floor: %s", g2) + ctx.check((g2.get("reading") or 0) > 0, + "intake_2 reads %s: the test needs a machine whose second intake turns", g2.get("reading")) + ctx.check(c.get("verdict") == "OK", "the one intake fan session is not OK: %s", c) + ctx.check(g1.get("state") == "ok" and (g1.get("floor") or 0) > 0, + "intake_1 is not judged ok at the intake floor: %s", g1) + + # Leg 2: the intake floor at its legal maximum. Intake 1 is the + # one intake left in the gates; intake 2 stays out of them. + _set_gates(ctx, fc, {"cool_tach_intake_min_rpm": str(inl.get("hi"))}) + c = _run_session(ctx, grbl, fc, lambda c: c.get("verdict") == "AIRFLOW", "intake 1 trip leg", + wait=INTAKE_TRIP_WAIT_S) + g1, g2 = _fan_gate(c, "intake_1"), _fan_gate(c, "intake_2") + ev["intake_1_trip"] = {"cool": c, "intake_1": g1, "intake_2": g2} + ctx.check(c.get("verdict") == "AIRFLOW", "an intake floor of %s rpm did not trip: %s", inl.get("hi"), c) + ctx.check(c.get("fire_ok") is False and c.get("hold") is True, + "AIRFLOW without fire blocked and a hold: %s", c) + ctx.check(g1.get("state") == "TRIPPED", "intake_1 does not read TRIPPED: %s", g1) + ctx.check(g2.get("state") == "not fitted", "intake_2 was judged with one intake fan set: %s", g2) + ctx.check("intake_1" in (c.get("reason") or ""), "the reason does not name intake_1: %r", c.get("reason")) + c = _after_session(ctx, fc) + ev["intake_1_trip_after"] = c + ctx.check(c.get("verdict") == "OK" and c.get("hold") is False, + "the fan fault outlived its run session: %s", c) + + # Restore, and prove it: intake 2 judged again, every fan at or + # above its floor. The restored grace is the shipped one. + _set_gates(ctx, fc, orig) + restored = True + c = _run_session(ctx, grbl, fc, + lambda c: c.get("verdict") == "OK" and not c.get("gates_off") + and all(g.get("state") == "ok" for g in (c.get("fan_gates") or {}).values()), + "restored", wait=INTAKE_TRIP_WAIT_S + 20) + ev["restored"] = c.get("fan_gates") + ctx.check(c.get("verdict") == "OK" and c.get("gates_off") == [], + "engine did not return to OK with every gate on after the restore: %s", c) + bad = {k: g for k, g in (c.get("fan_gates") or {}).items() if g.get("state") != "ok"} + ctx.check(not bad, "fans not at or above their floors after the restore: %s", bad) + finally: + if not restored: + st, body = fc.post("/settings", params=orig) + ctx.log("restore on failure: POST /settings %s -> %s", orig, st) + try: + c = _run_session(ctx, grbl, fc, lambda c: c.get("verdict") == "OK" and not c.get("gates_off"), + "restore on failure") + ctx.log("restore on failure: engine %s gates_off %s", c.get("verdict"), c.get("gates_off")) + except Exception as e: # the original failure is the one to report + ctx.log("restore on failure: run session did not complete (%s)", e) + after = fc.settings() + ctx.check(all(after.get(k, "") == orig[k] for k in INTAKE_KEYS), + "settings not restored: %s", {k: after.get(k) for k in INTAKE_KEYS}) + ctx.check(fc.wait_idle(60, abort=ctx.aborted), "machine did not return to idle") diff --git a/forgetest/forgetest/suite/setup_dark.py b/forgetest/forgetest/suite/setup_dark.py index 64f4cf3..4793a2e 100644 --- a/forgetest/forgetest/suite/setup_dark.py +++ b/forgetest/forgetest/suite/setup_dark.py @@ -191,20 +191,28 @@ def check_sensors(ctx): ("forgectrl", "src/gates.c"), ("forgectrl", "src/super.c")], requires=["forgectrl.auth", "cooling.fan-gate-trips"], description="POST /wiz/airflow/start: the controller stops, the fans run at the cut profile " - "for 35 s, the purge is measured on and off, and five settings are written: the " - "four floors at 55 percent of steady and the grace from the slowest spin-up. Each " - "fan must read at least 1000 rpm. The five settings are put back as found at the " - "end, the purge fan is on again and drawing over the floor the check just wrote, " - "and the controller must be running again.") + "for 35 s, the purge is measured on and off, and six settings are written: the " + "four floors at 55 percent of steady, the grace from the slowest spin-up, and the " + "intake fans fitted (2 when both intakes turn; 1 when intake 2 never reads a pulse " + "and intake 1 passes, and then intake 2 is reported not fitted and the intake floor " + "comes from intake 1). Each fan judged must read at least 1000 rpm. The six " + "settings are put back as found at the end, the purge fan is on again and drawing " + "over the floor the check just wrote, and the controller must be running again.") def check_airflow(ctx): keys = ["cool_tach_exhaust_min_rpm", "cool_tach_intake_min_rpm", "cool_tach_air_assist_min_rpm", "cool_purge_min_current", "cool_fan_grace_s"] - with Restore(ctx, keys): + with Restore(ctx, keys + ["cool_intake_fans"]): last = run_check(ctx, "airflow", lambda p: None, 240) r = last.get("result") or {} fans = r.get("fans") or {} + intakes = r.get("intake_fans") + ctx.check(intakes in (1, 2), "the result carries no intake fan count: %r", intakes) + ctx.log("intake fans fitted: %s", intakes) for name in ("exhaust", "intake 1", "intake 2", "air assist"): f = fans.get(name) or {} + if name == "intake 2" and intakes == 1: + ctx.check(f == {"fitted": False}, "intake 2 with one intake fan: %s", f) + continue ctx.check(f.get("ok") is True, "%s: %s", name, f) ctx.log("%s: %s rpm steady, 90 percent at %s s", name, f.get("steady_rpm"), f.get("spinup_s")) floors = r.get("floors") or {} @@ -213,6 +221,8 @@ def check_airflow(ctx): s = ctx.forgectrl.settings() or {} for k in keys: ctx.check(s.get(k) == floors.get(k), "%s reads %r, the check wrote %r", k, s.get(k), floors.get(k)) + ctx.check(s.get("cool_intake_fans") == str(intakes), + "cool_intake_fans reads %r, the check found %r", s.get("cool_intake_fans"), intakes) ctx.log("floors: %s", json.dumps(floors)) # The check switches purge air off to read its off current. Leaving # it off costs the next job an airflow hold mid-cut, judged against diff --git a/forgetest/tests/test_cooling_intake_suite.py b/forgetest/tests/test_cooling_intake_suite.py new file mode 100644 index 0000000..59e7b53 --- /dev/null +++ b/forgetest/tests/test_cooling_intake_suite.py @@ -0,0 +1,176 @@ +# Copyright 2026 514 LLC d/b/a OpenGlow +# Written by Scott Wiederhold +# https://community.openglow.org +# SPDX-License-Identifier: MIT + +"""cooling.intake-one-fan replayed host-side under the real runner +Context against a scripted engine: the fake reads cool_intake_fans and +the floors at every M8 (the engine reloads at run start), publishes +intake_2 not fitted with no floor when one intake fan is set, holds every +fan in grace for the configured seconds, then judges the bench readings +against the floors. The negative engines are the two ways the change +could go wrong: the setting ignored (intake 2 still judged) and the +setting taking intake 1 out with it.""" +import threading +import time +import unittest + +import helpers +from forgetest.runner import Context, Failed, Run +from forgetest.suite import cooling, cooling_intake +from test_cooling_suite import FakeGrbl + + +class IntakeOneFanTests(unittest.TestCase): + READINGS = {"exhaust": 11640, "intake_1": 4157, "intake_2": 4158, "air_assist": 11050, "purge": 625} + FLOORS = {"exhaust": ("cool_tach_exhaust_min_rpm", 6400.0, 20000.0), + "intake_1": ("cool_tach_intake_min_rpm", 2290.0, 20000.0), + "intake_2": ("cool_tach_intake_min_rpm", 2290.0, 20000.0), + "air_assist": ("cool_tach_air_assist_min_rpm", 6000.0, 30000.0), + "purge": ("cool_purge_min_current", 300.0, 1023.0)} + GATE_OF = {"exhaust": "exhaust", "intake_1": "intake", "intake_2": "intake", + "air_assist": "air_assist", "purge": "purge"} + + def setUp(self): + self.fc = helpers.FakeForgectrl().start() + self.grbl = FakeGrbl() + self.saved = (cooling.VERDICT_WAIT_S, cooling.SESSION_END_WAIT_S, cooling_intake.INTAKE_TRIP_WAIT_S) + cooling.VERDICT_WAIT_S = 4 + cooling.SESSION_END_WAIT_S = 3 + cooling_intake.INTAKE_TRIP_WAIT_S = 4 + self.fc.state["status"] = dict(self.fc.state["status"], gates_off=[]) + self.fc.state["cool"] = {"phase": "idle", "verdict": "OK", "fire_ok": False, "hold": False, + "gates_off": [], "fan_gates": {}} + for key, _d, _h in self.FLOORS.values(): + self.fc.state["settings"].setdefault(key, "") + self.fc.state["settings"].setdefault("cool_fan_grace_s", "") + self.fc.state["settings"].setdefault("cool_intake_fans", "") + self.honors_setting = True # the engine takes intake 2 out with one intake fan + self.drops_both = False # the engine takes intake 1 out with it + self.grace_scale = 0.1 # seconds of fake grace per configured second + self._describe() + self.grbl.on_command = self._engine + self.fc.on_post = self._on_post + + def tearDown(self): + cooling.VERDICT_WAIT_S, cooling.SESSION_END_WAIT_S, cooling_intake.INTAKE_TRIP_WAIT_S = self.saved + self.grbl.close() + self.fc.stop() + + def setting(self, key, default): + v = self.fc.state["settings"].get(key) or "" + return float(v) if v else default + + def _describe(self): + gates = {} + for fan, (key, default, hi) in self.FLOORS.items(): + v = self.setting(key, default) + gates[key] = {"gate": self.GATE_OF[fan], "def": default, "lo": 0.0, "hi": hi, + "band": [default * 0.9, default * 1.1], "off": "low", "value": v, + "state": "off" if v <= 0 else "ok"} + self.fc.state["settings"]["gates"] = gates + + def _on_post(self, path, form): + if path != "/settings": + return None + self.fc.state["settings"].update(form) + self._describe() + return (200, self.fc.state["settings"]) + + def _engine(self, line): + self._describe() + cool = self.fc.state["cool"] + if line == "M9": + def end(): + time.sleep(0.3) + cool["phase"] = "idle" + cool.update(verdict="OK", fire_ok=True, hold=False, reason="") + for g in cool.get("fan_gates", {}).values(): + if g["state"] != "not fitted": + g["state"] = "idle" + threading.Thread(target=end, daemon=True).start() + return + if line != "M8": + return + cool["phase"] = "run" + one = self.fc.state["settings"].get("cool_intake_fans") == "1" and self.honors_setting + out = {"intake_2"} | ({"intake_1"} if self.drops_both else set()) if one else set() + floors = {fan: 0.0 if fan in out else self.setting(key, default) + for fan, (key, default, _h) in self.FLOORS.items()} + cool.update(verdict="OK", fire_ok=True, hold=False, resume_ok=True, reason="", gates_off=[], + fan_gates={f: {"reading": self.READINGS[f], "floor": floors[f], + "state": "not fitted" if f in out else "grace"} for f in floors}) + grace = self.setting("cool_fan_grace_s", 15.0) * self.grace_scale + + def judge(): + time.sleep(grace) + if cool["phase"] != "run": + return + for f in floors: + if f not in out: + cool["fan_gates"][f]["state"] = "ok" if self.READINGS[f] >= floors[f] else "under" + time.sleep(0.3) + if cool["phase"] != "run": + return + for f in floors: + if f not in out and floors[f] > self.READINGS[f]: + cool["fan_gates"][f]["state"] = "TRIPPED" + if cool["verdict"] != "AIRFLOW": + cool.update(verdict="AIRFLOW", fire_ok=False, hold=True, resume_ok=False, + reason="AIRFLOW: %s %d under the %d floor for 3 s - hold, no resume this job" + % (f, self.READINGS[f], floors[f])) + threading.Thread(target=judge, daemon=True).start() + + def run_test(self): + run = Run("test", "cooling.intake-one-fan", "t") + ctx = Context(run, None, helpers.make_test("cooling.intake-one-fan", [])) + cooling_intake.intake_one_fan(ctx) + return run + + def settings_posts(self): + return [f for p, f in self.fc.posts if p == "/settings"] + + def restored(self): + return {"cool_intake_fans": "", "cool_tach_intake_min_rpm": "", "cool_fan_grace_s": ""} + + def test_one_fan_then_intake_1_trip_then_restore_pass(self): + run = self.run_test() + ev = run.evidence + self.assertEqual(ev["one_fan"]["intake_2"]["state"], "not fitted") + self.assertEqual(ev["one_fan"]["intake_1"]["state"], "ok") + self.assertEqual(ev["intake_1_trip"]["intake_1"]["state"], "TRIPPED") + self.assertEqual(ev["intake_1_trip"]["intake_2"]["state"], "not fitted") + self.assertTrue(all(g["state"] == "ok" for g in ev["restored"].values())) + posts = self.settings_posts() + self.assertEqual(posts[0], {"cool_intake_fans": "1", "cool_fan_grace_s": "8"}) + self.assertEqual(posts[1], {"cool_tach_intake_min_rpm": "20000.0"}) + self.assertEqual(posts[-1], self.restored()) + self.assertEqual(self.grbl.commands.count("M8"), 3) + self.assertEqual(self.grbl.commands.count("M9"), 3) + + def test_an_engine_that_ignores_the_setting_fails_and_restores(self): + self.honors_setting = False + with self.assertRaises(Failed) as cm: + self.run_test() + self.assertIn("intake_2 is not published not fitted", str(cm.exception)) + self.assertEqual(self.settings_posts()[-1], self.restored()) + self.assertEqual(self.fc.state["cool"]["verdict"], "OK") + + def test_an_engine_that_drops_intake_1_too_fails_and_restores(self): + self.drops_both = True + with self.assertRaises(Failed) as cm: + self.run_test() + self.assertIn("intake_1 is not judged ok at the intake floor", str(cm.exception)) + self.assertEqual(self.settings_posts()[-1], self.restored()) + + def test_a_machine_already_at_one_intake_fan_is_refused_untouched(self): + self.fc.state["settings"]["cool_intake_fans"] = "1" + with self.assertRaises(Failed) as cm: + self.run_test() + self.assertIn("already 1", str(cm.exception)) + self.assertEqual(self.settings_posts(), []) + self.assertEqual(self.grbl.commands.count("M8"), 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/forgetest/tests/test_setup_dark.py b/forgetest/tests/test_setup_dark.py index 8bbdad3..4bdc502 100644 --- a/forgetest/tests/test_setup_dark.py +++ b/forgetest/tests/test_setup_dark.py @@ -137,5 +137,124 @@ class SensorsCheckTests(unittest.TestCase): self.assertIn(("/wiz/sensors/abort", {}), [(p, f) for p, f in self.fake.posts]) +class AirflowCheckTests(unittest.TestCase): + """setup.check-airflow's judge against a scripted daemon: the check + writes its floors and the intake fans fitted, and the test reads the + fans the count says are there. A machine built with one intake fan + reports intake 2 not fitted; the bench reference has two.""" + + FLOORS = {"cool_tach_exhaust_min_rpm": "6400", "cool_tach_intake_min_rpm": "2290", + "cool_tach_air_assist_min_rpm": "6050", "cool_purge_min_current": "345", + "cool_fan_grace_s": "12"} + FAN = {"steady_rpm": 4150, "spinup_s": 6.8, "ok": True} + + def result(self, intakes): + fans = {"exhaust": dict(self.FAN), "intake 1": dict(self.FAN), "intake 2": dict(self.FAN), + "air assist": dict(self.FAN)} + if intakes == 1: + fans["intake 2"] = {"fitted": False} + return {"fans": fans, "intake_fans": intakes, "purge_current_on": 628, "purge_current_off": 74, + "floors": dict(self.FLOORS)} + + def setUp(self): + import tempfile + self.sysfs = tempfile.mkdtemp(prefix="forgetest-airflow-") + os.makedirs(os.path.join(self.sysfs, "head")) + with open(os.path.join(self.sysfs, "head", "purge_air"), "w") as f: + f.write("1\n") + self.saved_root = os.environ.get("GF_SYSFS_ROOT") + os.environ["GF_SYSFS_ROOT"] = self.sysfs + "/" + self.fake = helpers.FakeForgectrl().start() + self.fake.state["settings"].update({"cool_tach_intake_min_rpm": "2300"}) + self.fake.state["cool"] = {"phase": "idle", "fan_gates": {"purge": {"reading": 628, "floor": 345}}} + 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.polls = 0 + self.wizard_result = self.result(2) + self.wizard_writes = None # what the check writes; None = what its result says + + def on_get(path, q): + if path == "/wiz/dark": + if self.dark["running"]: + self.polls += 1 + if self.polls >= 2: + r = self.wizard_result + s = self.fake.state["settings"] + s.update(r["floors"]) + s["cool_intake_fans"] = self.wizard_writes or str(r["intake_fans"]) + self.dark.update({"running": False, "progress": 100, "result": r}) + self.versions["airflow"] = 1 + 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/airflow/start": + self.dark.update({"id": "airflow", "running": True, "phase": "fans to the run profile"}) + return 200, {"started": True, "id": "airflow"} + 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): + import shutil + self.fake.stop() + if self.saved_root is None: + os.environ.pop("GF_SYSFS_ROOT", None) + else: + os.environ["GF_SYSFS_ROOT"] = self.saved_root + shutil.rmtree(self.sysfs, ignore_errors=True) + + def run_check(self): + t = catalog.load_suite()["setup.check-airflow"] + run = Run("test", t.id, t.title) + t.fn(Context(run, None, t)) + return run + + def assert_put_back(self): + s = self.fake.state["settings"] + self.assertEqual(s.get("cool_tach_intake_min_rpm"), "2300") + self.assertNotIn("cool_intake_fans", s) + self.assertNotIn("cool_tach_exhaust_min_rpm", s) + + def test_two_intake_fans_pass_and_are_put_back(self): + self.run_check() + self.assert_put_back() + + def test_one_intake_fan_passes_with_intake_2_not_fitted(self): + self.wizard_result = self.result(1) + self.run_check() + self.assert_put_back() + + def test_one_intake_fan_with_the_setting_left_at_two_fails(self): + self.wizard_result = self.result(1) + self.wizard_writes = "2" + with self.assertRaises(Exception) as cm: + self.run_check() + self.assertIn("cool_intake_fans reads '2', the check found 1", str(cm.exception)) + self.assert_put_back() + + def test_a_result_without_the_count_fails(self): + r = self.result(2) + del r["intake_fans"] + self.wizard_result = r + self.wizard_writes = "2" + with self.assertRaises(Exception) as cm: + self.run_check() + self.assertIn("no intake fan count", str(cm.exception)) + self.assert_put_back() + + if __name__ == "__main__": unittest.main()