Happy Hare test harness¶
What it is: a fake Klipper and a fake Moonraker, good enough that the real Happy Hare code runs inside them. You can load a config, boot the MMU, push filament through gates, present RFID tags and talk to Spoolman — on your laptop, with no printer, no Klipper installed, and no hardware.
Why it exists: the NFC/RFID → Spoolman feature and its LED work were built across five sessions and, per their own handoff notes, had never been executed — only checked for syntax. Running it for the first time found seven real bugs. That is what this is for.
It has since grown well past NFC. Tool changes, gate homing (both kinds), endless spool and runout now run here too — see the coverage map in §2 for what is and isn't covered.
Who this is for: you're comfortable with Happy Hare and Klipper concepts but new to Python. Python-specific things are explained as they come up.
Driving this same harness by hand, interactively, rather than from a test file, is its own
page: The Happy Hare Simulator (make console).
1. Running the tests¶
From the repo root:
make test
That is the whole setup. On a fresh clone the first run takes a few extra seconds to build itself an environment, then goes straight into the tests.
What that first run is doing, and how to steer it
The tests need two libraries Happy Hare itself doesn't (`greenlet`, `jinja2`), so they run in a **virtualenv** — a private Python install that lives in the repo directory but is *not* part of the git repo, and is never installed onto a printer. `make test` creates it at `venv/` and installs `test/requirements.txt` into it if it isn't already there. **On a printer there is usually nothing to create.** Klipper's own `~/klippy-env` already contains greenlet and Jinja2 — it requires both — which is the whole of `test/requirements.txt`, so `make test` and `make console` use it directly and say so. That matters most on Debian/Raspberry Pi OS, where `ensurepip` ships in the separate `python3-venv` package and `python3 -m venv` would otherwise produce a venv with no pip. Point `KLIPPY_ENV=` elsewhere if yours is not at `~/klippy-env`, or at a path that does not exist to force the venv route. Git ignores `venv/` (Python's `venv` module writes an ignore rule into it), so it will never show up in `git status` or a commit. The tests are not its only tenant. On a system whose Python refuses to install anything outside a virtualenv (PEP 668 "externally managed" — Homebrew, Debian Bookworm), and where Klipper's own `klippy-env` isn't there to be used instead, the installer needs it too — it cannot render a config without `jinja2`. So `./install.sh`, and equally a bare `make build`, `make verify_pickle` or any other goal that runs the builder, will create this venv and use it. Those install only `installer/requirements.txt`, tracked by its own stamp file, so the two sets never invalidate each other and the installer never pays for `greenlet`. `make variables` prints which interpreter each half settled on, if you ever wonder. It is only built once. Later runs reuse it and go straight to the tests; editing `test/requirements.txt` reinstalls automatically. Some knobs:make venv # build the venv, don't run anything
make clean_venv # throw it away (`make clean` deliberately does not)
make VENV=/somewhere/else test # put the venv somewhere other than ./venv
make KLIPPY_ENV=/nonexistent test # ignore klipper's env, build the venv instead
make NO_VENV=1 test # don't use a venv at all (see below)
make PY=/usr/bin/python3 test # ditto, against a named interpreter
make BOOTSTRAP_PY=python3.9 VENV=venv39 test
make test offers everything — well over 900 tests and growing, a couple of minutes
on a warm laptop. Expect to see:
OK (skipped=1, expected failures=4)
skipped and expected failures are normal and explained in §6. Anything else — FAILED
(failures=…) or (errors=…) — is a genuine problem.
A minute and a half is still too long to sit through on every change, which is why make
test opens a file picker first rather than starting straight away.
Running less than everything¶
make test opens a picker first. Everything starts ticked, so pressing Enter runs the whole
suite exactly as it always did — but untick the expensive files and you get a focused run:
Happy Hare tests - 900+ tests in ~30 files times from last run (~ = reference, never run locally)
1 [x] installer.test_build 1 0.0s
2 [x] test_mmu_adc_compat 14 0.0s
3 [x] test_mmu_bootup 34 2.3s
…
7 [x] test_mmu_console 148 37s
…
15 [x] test_mmu_nfc 17 9.7s
…
30 [x] test_mmu_toolchange 20 0.9s
selected: ~30 files - 900+ tests - ~1m42s last time
[Enter] run 1 3 5-8 toggle a all n none v invert
+TEXT / -TEXT tick by name p previous selection s sort by time q quit
>
The right-hand column is how long each file took on your machine, last run, and it is the
column to look at when deciding what to drop, because the cost is wildly uneven. From a real
full run: test_mmu_console took 37 s for 176 tests and test_mmu_nfc 9.7 s for 17, while
several files — including test_mmu_tag_parser's 36 tests — came in under a tenth of a second
between them. A handful of the twenty-seven files account for most of the run. The times
fill in after your first run, s sorts by them, and the footer estimates what the current
selection will cost.
Never run the suite on this machine at all? The picker still isn't guessing: test/benchmark.json
ships a checked-in reference measurement, so every file you haven't personally timed yet shows
that number instead, marked with a trailing ~ (header and footer say so too, e.g. "reference
times only - never run locally"). Run any file for real and its row switches to your own number
immediately — reference and local times can be mixed in the same screen, one row at a time.
Two things to know about the numbers. They cover each file's class fixtures as well as its
tests, which is where nearly all the time actually is — setUpClass building a printer, not
the assertions. And part of that cost is shared and cached across a run (test/hh/cfg.py
caches Kconfig parsing per profile, and the fake gcode_macro.py caches compiled Jinja macro
templates), so a file run on its own can still cost a little more than the same file inside a
full run — test_mmu_config is 6.7 s in a full run and 7.5 s alone, the gap being the one
Kconfig parse and macro compile nothing earlier in that solo run had already warmed. That gap
used to be nearly two orders of magnitude wider (0.1 s vs 35 s) before those two caches existed —
most of the profile-parsing and macro-compiling cost used to be paid fresh on every single boot,
not just the first one in a run. Treat the column as a guide to relative cost within a run, not
an absolute per-file price.
Typing n then +nfc then Enter runs just the NFC files. p recalls the last selection you
narrowed to — a full run doesn't overwrite it, so you can alternate between a focused loop
and a full check without retyping. q quits without running anything, and exits non-zero on
purpose so make test && git commit can't sail past it — you will see make print
*** [test] Error 1 after a quit, which is expected.
The picker is skipped, and everything runs, whenever it can't work or you didn't ask for it:
make ALL=1 test # no picker, run everything
make LAST=1 test # no picker, re-run the last selection
make UT='test_mmu_nfc*.py' test # no picker, filename pattern (as before)
make test | tee log # no picker — not a terminal, so it just runs
make test ARGS='-k homing' # extra unittest flags, picker still opens
A file that fails to import also skips the picker: it can't be listed, so the run goes ahead and fails loudly rather than quietly leaving it out.
The picker only deals in whole files. For one class or one test, go straight to the runner —
-m unittest means "run Python's built-in test runner", and the argument is a dotted module
path, so test/test_mmu_leds.py becomes test.test_mmu_leds:
# one file
./venv/bin/python -m unittest test.test_mmu_leds
# one class within a file
./venv/bin/python -m unittest test.test_mmu_leds.TestPendingOverlay
# one single test
./venv/bin/python -m unittest test.test_mmu_leds.TestPendingOverlay.test_cancel_clears_the_overlay
# -v prints each test name and its docstring as it runs
./venv/bin/python -m unittest -v test.test_mmu_motion
Your selection and the timings live in .mmu_test_state at the repo root. It's gitignored,
and deleting it just means the picker falls all the way back to test/benchmark.json — the
checked-in reference numbers described above, regenerated by hand from an occasional full run
(see that file's own comment) rather than by CI, since none runs make test today.
2. What is where¶
test/
test_mmu_*.py the tests themselves — this is what you read and write
select.py the file picker `make test` opens (§1)
console.py the interactive console (see Dev-Simulator.md)
hh/ the harness: the fake Klipper and fake Moonraker
hh/klippy_root/ 41 stand-in modules that pretend to be Klipper's own code
installer/ legacy installer tests, currently skipped (see §6)
The test files are grouped by what they're about — foundation (imports, config rendering,
bootup, profiles), filament handling (motion, toolchange, encoder, endless spool), NFC and
Spoolman, and presentation (LEDs, the console). Browse test/test_mmu_*.py directly for the
current list rather than trusting a table here — it only grows, and a stale count is worse
than none.
One file to know by name: test_mmu_console.py is one of the largest, at close to 150 tests
covering the interactive console's rendering and command dispatch —
worth a look as an example of testing something more presentation-heavy than the filament
model itself.
Coverage map¶
Green is not the same as covered. Roughly where things stand:
| Area | State | Notes |
|---|---|---|
| Config rendering and load | solid | real templates, three machine profiles |
| Bootup sequence | solid | including the error sentinel that stops bootup faking success |
| Tag decoding, Spoolman round trip | solid | including auto-create and the miss cache |
| Load / unload / tool change | good | the happy path and its common failures |
| Gate homing — switch and encoder | good | both branches of _home_to_gate |
| Preload and insert handling | good | |
| Endless spool and runout | good | including the clog-vs-runout decision |
| LEDs | good | effects and overlays; not the neopixel protocol |
| Sync feedback / buffer sensors | partial | EMU's analog sensor boots; the tension logic has a known bug |
| Physical selector homing and selection | good | both selector families home, select and move filament — test_mmu_selector.py |
| Calibration | partial | seeded by default for speed, but MMU_CALIBRATE_SELECTOR (manual and AUTO=1) and MMU_CALIBRATE_BOWDEN run for real — test_mmu_selector.py |
Developer commands (_MMU_TEST) |
partial | every option is run and must not raise — test_mmu_dev_test.py. What the stress probes provoke is step-generation timing the harness does not model |
| Espooler, FlowGuard | none | |
| Multi-unit machines | good | ercf_vvd renders, boots and loads on both units |
| Klipper motion and timing | none | out of scope by design — see §9 |
Blunter version: only a small fraction of the commands in the
Command Reference are actually driven from a test. The ones that are
cover what a print depends on, and the internal _MMU_* sequence macros run underneath them
— but most administrative and calibration commands have never been called here. A green
suite says the operational core works, not that the command set does.
3. How the fakes work¶
You mostly won't touch these, but knowing the shape helps when something behaves oddly.
The fake klippy tree. Happy Hare installs by symlinking extras/**.py into
<klipper>/klippy/extras/, and its imports only resolve in that shape. So the harness
builds that exact layout in a temp directory — Happy Hare's real files symlinked
alongside 41 stand-in modules (mcu.py, toolhead.py, pins.py, and so on). Happy Hare
cannot tell the difference.
The reactor and virtual time. Klipper's "reactor" is its scheduler: it runs timers and callbacks. The real one uses the wall clock. Ours uses a fake clock you control:
hh.reactor.advance(20.0) # 20 seconds pass instantly
This matters because Happy Hare is full of long timers — a 20-second pending-spool
timeout, a 5-second warning window, a 2.5-second boot delay. Real waiting would make the
suite unusable. advance() runs every timer that falls due, in order.
The filament model. Two numbers per gate: where the filament's leading edge (the tip)
is and where its trailing end (the tail) is, in millimetres, measured so that 0 is the
gate's sensor. Filament occupies everything between them, so a switch reads "triggered"
when it sits inside that span. When Happy Hare commands a move, the harness works out which
sensor trips first and how far the filament actually gets. Default layout:
spool ... park(-100) ... entry(-50) ... gate/exit(0) ... encoder(+20) ... extruder(+700)
The tail is normally infinitely far back — a spool is attached, so anything behind the tip
is filament. fil.exhaust(gate) gives it a real end, which is what a runout physically
is. Without that, every simulated runout looks like a clog to Happy Hare because the gate
sensor never releases.
The encoder is not a switch: it reports motion, so what matters is how much of a move
happened while filament covered the wheel. That is fil.travel_over(), and the harness
turns it into real pulses so Happy Hare's own counter callback does the accumulating.
The fake Moonraker provides a working in-memory Spoolman — not a mock. When Happy Hare auto-creates a spool, a spool really is created, and the next scan of that tag really resolves to it. That round trip is the thing under test.
4. Writing a test¶
Python note: a test is a method whose name starts with test_, inside a class inheriting
unittest.TestCase. setUp runs before each test, tearDown after. Assertions are
methods: self.assertEqual(a, b), self.assertTrue(x), self.assertIsNone(x).
A minimal test:
import unittest
from test.hh import session
class TestMyThing(unittest.TestCase):
def setUp(self):
self.hh = session('boxturtle') # pick a machine profile
self.hh.boot() # config load -> connect -> ready -> bootup
self.assertEqual(self.hh.errors, [])
def tearDown(self):
self.hh.close() # always; it cleans up threads and temp files
def test_gate_starts_empty(self):
self.assertEqual(self.hh.mmu.gate_status[0], 0)
self.hh.mmu is the real MmuController. Anything you could inspect on a live
printer, you can inspect here.
Things you'll commonly do¶
# run a gcode command exactly as a user would
hh.run_gcode('MMU_PRELOAD GATE=1')
# put filament somewhere (quietly — see §5)
hh.place_filament(0) # at the park position
hh.place_filament(0, position=-40.0) # somewhere specific
# drive a sensor through its real callback path
hh.sensor('mmu_entry_0').set(True)
hh.sensor('mmu_entry_0').present # what Happy Hare currently believes
# run the spool out (see §5.6) — the filament keeps its tip, but loses its tail
hh.filament().exhaust(0)
# move time forward
hh.reactor.advance(5.0)
# check nothing went wrong
self.assertEqual(hh.errors, [])
Choosing a profile¶
A profile is a set of menuconfig choices; the harness renders the real shipped templates from them, so a broken template shows up as a test failure.
| Profile | What it gives you |
|---|---|
ercf_vvd |
the console default. The only multi-unit profile, and a transcription of a real machine: ERCF 1.1sb (9 gates, LinearServoSelector, encoder) + ViViD 1.0 (4 gates, IndexedSelector), 13 gates. Also the only one with a sparse per-gate device list, a filament heater, and full LED coverage: unit0 wires all four segments (9 exit on an external chain, 9 entry, 4 status, 3 logo) while unit1 has 28 exit LEDs over 4 gates |
boxturtle |
4 gates, no NFC — the default for most tests |
tradrack |
a physical (servo) selector, single unit, no encoder — the simplest physical-selector case |
emu |
5 gates and the only shipped profile with an analog buffer sensor |
encoder |
BoxTurtle plus an encoder, homing to it instead of to the gate switch |
nfc_single |
one common NFC reader |
nfc_per_gate |
one reader per gate |
nfc_pn532_uart |
one common PN532 over HSU/UART — the only host-serial reader |
nfc_spoolman |
per-gate NFC + Spoolman enabled + auto-create |
There are more nfc_* profiles than these — one per reader type and transport
(nfc_pn5180, nfc_pn532, nfc_pn532_sw_i2c, nfc_pn532_uart_per_gate, ...). They
exist because each renders a different set of config keys, which is where template
bugs hide. test/hh/profiles.py is the list, with a comment on each explaining what it
catches.
Physical selectors, and what "calibrated" means here¶
test/hh/selector.py models where a unit's selector endstops sit — a separate axis from
the filament path, which is one scalar per gate and has no carriage. Two geometries, because
the shipped families disagree: the LinearSelector family (ERCF, Tradrack) has one home
switch and reaches gates by plain moves to calibrated offsets, while IndexedSelector (ViViD)
has no home switch at all and one index switch per gate, visited in selector_gate_order.
The carriage is tracked, in SelectorAxis.carriage, the same way filament.py tracks the
filament — because the two meanings of "position" have to be kept apart. MmuGenericRail.home()
rebases the axis to forcepos immediately before every homing move, so the stepper coordinate
says nothing about where the carriage physically is. In the fake MCU_stepper:
| effect | |
|---|---|
set_position() |
redefines the coordinate frame; mcu-preserving, as in real Klipper |
harness_note_motion() |
real travel; moves the mcu step count |
Motion reaches the axis from exactly two places — the fake HomingMove and
Session._on_manual_move. Both must call advance(); a plain move that is not observed leaves
the carriage on the home switch through the retract inside rail.home(), and the second homing
move then measures zero.
Calibration is seeded by default, but not fake. Session.calibrate() writes selector
offsets, bowden length, gear rotation distance and encoder resolution using HH's own published
formulas and the harness's own filament geometry, so no numbers are invented. It is a shortcut,
not a substitute: MMU_CALIBRATE_SELECTOR (manual and AUTO=1) measures real travel and is
covered by TestSelectorCalibration. To drive the real flow, boot uncalibrated
(hh.boot() with no calibrate=, or make console ARGS='--no-calibrate') and place the
carriage where the procedure expects it — axis.place(mm) in a test, /selector in the console.
boot(calibrate=True) seeds before klippy:ready, so Happy Hare's own handle_ready loads
the variables and the "not found in mmu_vars.cfg" warnings never fire. Called after ready, as
tests do, calibrate() applies the values in memory instead. A bare boot() seeds nothing,
because uncalibrated is a real state HH has to cope with and tests assert it.
Tip forming is the one macro with an effect. Bodies do not run (see §"Three things that
will look like bugs"), but HH measures how far the extruder moved during _MMU_FORM_TIP and
refuses the unload if the answer is zero — so on a machine with an encoder a no-op tip form
reads as a jam. printer.harness_macro_effects maps a macro alias to a callable; the one
registered effect retracts the extruder and moves the filament model together, by a distance
read from the machine's own _MMU_FORM_TIP_VARS.
The first four profiles above are shipped machine types. encoder is derived — BoxTurtle with menuconfig
options flipped. That is only safe when the resulting config renders complete: enabling a
feature outside the starter that ships it can leave dependent parameters blank, producing a
machine that boots but behaves like nothing real. Render it and read the section before
trusting it; an earlier attempt to bolt a proportional buffer onto BoxTurtle had to be
reverted for exactly this reason.
For NFC reads, add virtual_nfc=True so readers return tags from the filament model:
hh = session('nfc_per_gate', virtual_nfc=True)
hh.boot()
hh.filament().attach_tag(0, '04A1B2C3') # gate 0's filament carries this tag
Testing both halves together¶
from test.hh.roundtrip import RoundTrip
with RoundTrip(profile='nfc_spoolman') as rt:
rt.present_tag('DEADBEEF', gate=None, material='PETG', min_temp=230, max_temp=250)
# Klipper asked Moonraker, Moonraker created a spool and called back
self.assertEqual(rt.mmu.pending_spool_id, rt.db.created_spools[0])
present_tag() injects at the exact point a real reader hands off, so no hardware is
involved. RoundTrip pumps messages between the two sides until everything settles.
5. Six things that will bite you¶
These are all real behaviours, learned by getting them wrong.
1. Placing filament is an event. Covering the entry switch is an insert, and Happy
Hare responds by preloading that gate. place_filament() suppresses that by default so
you can set up a scenario. Pass quiet=False when you actually want to test insert
handling.
2. LED tests must wait ~12 seconds first. effect_initialized (the rainbow) is a
unit-wide timed effect lasting 8 seconds from boot, and a flash requested while it holds
the unit is dropped. Without hh.reactor.advance(12.0) you're measuring the rainbow.
3. effect_state doesn't show the pending overlay. It records the underlying
configured effect — that's deliberate. Check the overlay with
led_manager._pending_overlay_effect(unit, 'exit') instead.
4. spoolman_led_segment: gate_status is not a segment name. It means "the segments
showing per-gate availability", i.e. exit and entry. Passing 'gate_status' where a
segment name is expected silently returns None.
5. Geometry is constrained by Happy Hare, not free. The entry switch must sit between
the park position and the gate sensor, so a parked filament leaves it clear — Happy Hare
marks a gate GATE_UNKNOWN if preload finishes with it still covered. Also: a preload can
only realistically start with filament already past the entry switch, because that's
what a user's push produces.
6. A runout needs fil.exhaust(gate), not just an empty gate. Moving filament away
isn't a runout — the spool is still attached, so the gate sensor stays covered and Happy
Hare correctly calls it a clog. exhaust() gives the filament a real trailing end. Getting
this wrong makes Happy Hare look broken when it is being right about an impossible machine.
6. Skips and expected failures¶
OK (skipped=1, expected failures=4)
expected failures are known bugs, written as tests of what should happen and
marked @unittest.expectedFailure. They're self-healing: Python reports an
unexpected success as a failure, so the moment someone fixes the bug the suite goes red
and tells you to delete the marker. If a test you didn't touch suddenly fails that way,
you probably fixed something — check, then remove the marker and its comment.
Currently:
| Where | Bug |
|---|---|
test_mmu_profiles.py ×2 |
the proportional buffer reports TENSION almost always — its low threshold is computed positive when the config help says it should be about −0.9 |
test_mmu_tag_parser.py |
a blank tag is reported as a Bambu Lab tag |
test_mmu_motion.py |
a synced (print-time) move does not advance the filament model — unlike the other three drive modes it never reaches MmuStepper._submit_move, so motion_queuing's trapq hook never fires. A HARNESS gap rather than a Happy Hare bug, which is the one entry here that will not be fixed by changing extras/ |
skipped is test/installer/test_build.py — legacy installer tests that can't run
(the functions they call no longer exist). Its header explains what restoring it needs.
7. Debugging a failing test¶
Read the assertion first. Most have a message explaining what the code is supposed to do and why.
Ask Happy Hare what it did. Turn on its own trace logging:
hh.mmu.p.log_level = 4 # 4 = trace
hh.run_gcode('MMU_PRELOAD GATE=0')
for line in hh.console:
print(line)
This prints Happy Hare's real internal narration — every move, every homing result, every
decision. It is by far the fastest way to see what actually happened. There's also a full
log file at hh.tmpdir + '/mmu.log'.
Ask the model what moved:
print(hh.filament().history)
# [(0, 100.0, 'homing -> mmu_exit_0'), (0, -100.0, 'move')]
# gate, millimetres, why
print(hh.filament().describe(0))
# gate 0 tip=-100.0 mmu_entry_0=0 mmu_exit_0=0 mmu_shared_exit=0 filament_compression=0
Check what got sent where:
hh.gcode.executed # every gcode command run
hh.errors # anything Happy Hare reported as an error
hh.webhooks.calls # calls Klipper made to Moonraker
hh.pins.types_by_pin() # which pin was set up as what
If a test hangs or times out, it's usually the reactor waiting on something that never
happens. advance() has a watchdog that fails with the list of pending timers rather than
hanging forever.
8. Working on Happy Hare with this¶
A reasonable loop:
- Run the file closest to your change first — it's seconds, not a minute.
make test,n,+the file's name, Enter; after thatmake LAST=1 testrepeats it with no picker. - Change the code.
- Re-run that file, then
make test+ Enter (everything) before committing. - Add a test for what you changed. If you fixed a bug, the test should fail before your fix and pass after — check that, or you don't know it's testing anything.
When you find a bug you're not fixing now, write it as an @unittest.expectedFailure
describing the correct behaviour, with a comment explaining the cause. It documents the
problem, proves it's real, and cleans itself up when fixed.
Prefer driving real commands (hh.run_gcode('MMU_PRELOAD GATE=1')) over calling
internal methods. Internals skip the command wrapper that sets up state, which has already
caused confusing failures.
9. What this does not cover¶
Worth knowing so you don't over-trust a green run. The coverage map in §2 has the per-area picture; these are the structural limits behind it.
- No real motion. No acceleration, step generation or timing. The harness tests Happy Hare's sequencing, not Klipper's motion planner. Timing bugs, "timer too close", and step-generation issues are invisible here.
- No real hardware protocol. The RC522 init sequence is exercised, but tag reads are faked at the driver level. The PN532 and PN7160 I²C drivers aren't covered at all.
- Proprietary tag formats are untested. Bambu, Creality, QIDI and Anycubic parsing needs captured dumps from real spools — synthesising them would only prove the test agrees with itself.
- Encoder and gear calibration are seeded, never measured.
MMU_CALIBRATE_ENCODERandMMU_CALIBRATE_GEARwould only re-derive the numbers the harness generates its moves from, so they would confirm arithmetic rather than test anything. - Macros load but mostly don't run. The shipped
config/macros/*.cfgare read verbatim so sequences can find them, but a test that asserts on macro behaviour would be testing Klipper's Jinja, not Happy Hare. - The fakes could be wrong. They're written against real Klipper's behaviour, but where they diverge, a test can pass while the real thing fails.
Green means "Happy Hare's logic does what we think" — not "this will work on a printer". It is still the difference between finding a bug in ten seconds on a laptop and finding it mid-print.