Yesterday, v0.12.3 of RGBMatrixEmulator released, featuring mainly non-functional changes around automated testing and a new CI pipeline that runs in GitHub Actions. For those not familiar with RGBME, here’s the full context. Long story short, it’s an emulator for Raspberry Pi HUB75 LED matrices.
In the last few RBME releases I accidentally caused a a few bugs, so I set out to write some tests to help me avoid that in the future. It ended up being a lot harder than anticipated! Today I want to talk a bit about testing an emulator like this, because unit tests are less useful than functional tests, but due to the architecture of RGBME (and ultimately the library it emulates), it ended up being more of an exercise in manipulating Python’s package loader than actually writing tests!
RGBME / rpi-rgb-led-matrix
Architecture
I need to give a little background here about how these libraries are used before I talk about my tests. Let’s boil these down into a few bullet points:
- RGBME emulates rpi-rgb-led-matrix Python bindings.
- rpi-rgb-led-matrix has some sample Python scripts to show developers how to use the library.
- To run one, you would have to execute it from the
samples
directory – in other words, it has relative imports!
- To run one, you would have to execute it from the
- RGBME also supports arbitrary matrix sizes. Common ones are 32x32, 64x32, etc. but you could chain several together and make a huge one, if you wanted.
- RGBME outputs pixels to the screen via display adapters – anything from your browser, terminal, a Pygame window, etc.
Testing Approach
Rather than obsess over unit tests from all the functions I wrote from the emulator, I decided that I’d rather focus on making sure the emulator outputs the correct pixels. I also wanted to parameterize the tests – as mentioned RGBME supports arbitrary sizes, so I wanted good test coverage for common sizes.
To do this, my plan was reasonably straightforward:
- Include the samples from rpi-rgb-led-matrix (actually already done, but prior to this release required manual testing)
- Create a new display adapter type that simply cached the most recent frame buffer (a 2D array of RGB as a 3-tuple)
- Capture known good reference images from each sample
- Set up a test framework that imports each sample class and runs it with the new display adapter, then compares it to the reference image
Complexities Commence
From the approach, #1 and #2 were quite simple. Including samples as mentioned required no work. Creating a new display adapter was easy because this library is basically all dependency injection, so I just needed to build a class with the right adapter interface, and everything just works. The adapter is basically headless and does nothing but cache the last several frames. Astute reviewers will note the strange halt_after
and halt_fn
attributes… We will touch on that shortly.
Capturing reference images is where things get interesting. I could have done this by hand, but that would have been time-consuming and error-prone, not to mention not scalable if I ever need someone else to be able to do it. The question is how to automate it?
All About That Samplebase
At the heart of the sample scripts is a base class called Samplebase
. It handles argument parsing, setting up matrix options, setting up a matrix object (!), and running the sample – all of this mostly through a god-like function called process()
. Subclasses can implement run()
which will contain matrix draw routines to put pixels to the screen.
Given that this code comes directly from the library I’m trying to emulate, I didn’t really want to alter it to suit a test framework since in theory the tests should behave identically to running samples in any other environment.
Here’s the guts (link to full class as well):
class SampleBase(object):
# Other functions omitted
def process(self):
# An argparse.ArgumentParser instance
self.args = self.parser.parse_args()
options = RGBMatrixOptions()
# A bunch of calls like this omitted for brevity...
options.rows = self.args.led_rows
options.cols = self.args.led_cols
self.matrix = RGBMatrix(options = options)
try:
# Start loop
print("Press CTRL-C to stop sample")
self.run()
except KeyboardInterrupt:
print("Exiting\n")
sys.exit(0)
return True
Of the functions Samplebase
performs, I really, really need to be in control of that RGBMatrix
object (it’s the thing RGBME emulates). If I can’t get to it, my only other option is to write a custom sample base class that I can inject a matrix into.
A Samplebase
Subclass
Now let’s look at something that actually draws to the screen. We’ll use RunText
(link), a sample that just scrolls “Hello World!”:
#!/usr/bin/env python
# Display a runtext with double-buffering.
from samplebase import SampleBase
from RGBMatrixEmulator import graphics
import time
class RunText(SampleBase):
def __init__(self, *args, **kwargs):
super(RunText, self).__init__(*args, **kwargs)
self.parser.add_argument("-t", "--text", help="The text to scroll on the RGB LED panel", default="Hello world!")
def run(self):
# Set up for canvases, fonts, colors, etc.
offscreen_canvas = self.matrix.CreateFrameCanvas()
font = graphics.Font()
font.LoadFont("./fonts/7x13.bdf")
textColor = graphics.Color(255, 255, 0)
pos = offscreen_canvas.width
my_text = self.args.text
# Looping draw calls
while True:
offscreen_canvas.Clear()
len = graphics.DrawText(offscreen_canvas, font, pos, 10, textColor, my_text)
pos -= 1
if (pos + len < 0):
pos = offscreen_canvas.width
time.sleep(0.05)
offscreen_canvas = self.matrix.SwapOnVSync(offscreen_canvas)
# Run the stuff!
if __name__ == "__main__":
run_text = RunText()
if (not run_text.process()):
run_text.print_help()
A few things to note here. First is the relative import:
# This is in samples/samplebase!
from samplebase import Samplebase
Next is the call to run_text.process()
– that means that the base class sets up that matrix object we need before we get a chance to do so!
Solving Relative Imports
The first pressing problem is to get the samples loaded correctly at all. This took a lot of trial-and-error, as it turns out weird things break when you mess with path on the fly.
First, I created a file to house the loader code. This reference file handles a lot of the stuff we need later, like, y’know, actually running the samples, or generating those reference images that we’ll use to test against later. The important piece is this:
import sys, os, importlib
from collections import namedtuple
sys.path.append(os.path.abspath(os.path.join(__file__, "..", "..")))
import samples
sys.modules["samplebase"] = samples.samplebase
Reference = namedtuple("Reference", ("file_name", "name", "frame", "halt_fn"))
_REFERENCES = [
Reference("canvas-brightness", "CanvasBrightness", 256, send_kb_interrupt),
Reference("graphics", "GraphicsTest", 140, send_kb_interrupt),
Reference("grayscale-block", "GrayscaleBlock", 256, send_kb_interrupt),
Reference("image-brightness", "ImageBrightness", 256, send_kb_interrupt),
Reference("image-scroller", "ImageScroller", 50, send_kb_interrupt),
Reference("pulsing-brightness", "GrayscaleBlock", 256, send_kb_interrupt),
Reference("pulsing-colors", "PulsingColors", 256, send_kb_interrupt),
Reference(
"rotating-block-generator", "RotatingBlockGenerator", 256, send_kb_interrupt
),
Reference("runtext", "RunText", 264, send_kb_interrupt),
Reference("simple-square", "SimpleSquare", 256, send_kb_interrupt),
Reference("singleton", "MultCanvas", 256, send_kb_interrupt),
# Not a class
# Reference("image-draw", "ImageDraw", 256, send_kb_interrupt)
# Needs an extra arg
# Reference("image-viewer", "ImageViewer", 256, send_kb_interrupt)
]
REFERENCES = []
for reference in _REFERENCES:
module = importlib.import_module(f"samples.{reference.file_name}")
sample = getattr(module, reference.name)
sample.name = reference.name
sample.file_name = reference.file_name
sample.frame = reference.frame
sample.halt_fn = reference.halt_fn
REFERENCES.append(sample)
So let’s walk through this.
- First, we need to import some packages to interact with paths, the OS, and importing other packages.
- We need to add the relative path to the
samples
directory to the Python path.- It’s two levels up from the current file, so append it to the path with…
sys.path.append(os.path.abspath(os.path.join(__file__, "..", "..")))
- It’s two levels up from the current file, so append it to the path with…
- Now
samples
can be imported easily. - To make
from samplebase import Samplebase
work in each sample, it can be added to the modules directly:sys.modules["samplebase"] = samples.samplebase
- Next, there’s
_REFERENCES
, an internal list of named tuples containing all the sample file paths, the class names, the frame we want to screencap, and the way we want the script to be halted (more later!). - And finally – for each tuple, import it from
samples
via the file name and set all the attributes as needed.
Whew! That’s kind of a mess!
So What’s send_kb_interrupt
?
If you remember back to the Samplebase.process()
code, there was this try..catch
block:
try:
# Start loop
print("Press CTRL-C to stop sample")
self.run()
except KeyboardInterrupt:
print("Exiting\n")
sys.exit(0)
This does kinda what you would expect – wait for CTRL + C and then exit. The send_kb_interrupt
simply raises a custom exception SampleExecutionHalted
that is unambiguous so we can capture the exit event later. Together with the frame number, these are injected as halt_fn
and halt_after
to the display adapter. The adapter tracks what frame it’s on, then calls the halt function to throw the exit exception and stop execution.
We need this because this makes the tests deterministic. Luckily for me, they’re single-threaded, so halting on a specific frame number in each test will always give the same output.
Putting It All Together
Ok, so we have samples loading in and a way to deterministically test them. But how do we inject that matrix object we need?
def run_sample(sample_class, size, screenshot_path=None):
sys.argv = [
f"{sample_class.file_name}.py",
f"--led-cols={size[0]}",
f"--led-rows={size[1]}",
]
cwd = os.getcwd()
os.chdir(os.path.abspath(os.path.join(__file__, "..", "..", "samples")))
sample = sample_class()
sample.usleep = lambda _: 1 + 1
def mockedSetAttr(inst, name, value):
if name == "matrix":
inst.__dict__[name] = value
inst.matrix.CreateFrameCanvas()
adapter = inst.matrix.canvas.display_adapter
adapter.halt_after = sample.frame
adapter.halt_fn = sample.halt_fn
adapter.width = inst.matrix.width
adapter.height = inst.matrix.height
adapter.options = inst.matrix.options
else:
super(inst.__class__, inst).__setattr__(name, value)
sample_class.__setattr__ = mockedSetAttr
try:
sample.process()
except SampleExecutionHalted:
adapter = sample.matrix.canvas.display_adapter
if screenshot_path:
refdir = os.path.join(screenshot_path, sample_class.file_name)
if not os.path.exists(refdir):
os.mkdir(refdir)
refpath = os.path.join(refdir, f"w{adapter.width}h{adapter.height}.png")
adapter._dump_screenshot(refpath)
return adapter._last_frame()
except Exception:
traceback.print_exc()
finally:
sample.matrix.canvas.display_adapter._reset()
os.chdir(cwd)
This is a custom sample run function that takes a sample and a matrix size and instantiates it, runs it, and does any extra post-run handling required.
First, we spoof the arguments to the file via the name of the sample filename + the --led-cols
and --led-rows
flags. Those flags are used by both libraries to determine how big of a matrix the script is executing on.
Next, theres a directory change to samples
(after saving our current working directory for later). The script is technically runnable at this point, but there’s some more we need to do.
sample = sample_class()
# This lets us control the matrix assignment!
# self.matrix = RGBMatrix(options=options)
def mockedSetAttr(inst, name, value):
if name == "matrix":
inst.__dict__[name] = value
inst.matrix.CreateFrameCanvas()
adapter = inst.matrix.canvas.display_adapter
adapter.halt_after = sample.frame
adapter.halt_fn = sample.halt_fn
adapter.width = inst.matrix.width
adapter.height = inst.matrix.height
adapter.options = inst.matrix.options
else:
super(inst.__class__, inst).__setattr__(name, value)
sample_class.__setattr__ = mockedSetAttr
This guy is the meat and potatoes of this function. This is instantiating the sample, then hooking into that assignment to the matrix
option we really, really need (remember?). This piece, when it’s assigned, lets us make sure there’s a display adapter with the right config to capture frames and halt at the right time. Exactly what we need!
Finally, in the try..except..finally
, the custom error finally shows up (SampleExecutionHalted
) allowing us to capture a screenshot here, if we want – and we do, in order to get reference images for later. The display adapter is also reset in the finally
block, making sure we don’t hold onto old frames for new tests: sample.matrix.canvas.display_adapter._reset()
All in, once you have this in place, all the samples are runnable in a deterministic way!
The Easy Part
Now for the last piece – the tests!
I think this is pretty straight-forward, it’s just a comparison of two images pixel-by-pixel:
class TestSampleRunMatchesReference(TestCase):
@parameterized.expand(TESTS)
def test_sample(self, name, sample, size):
expected = reference_to_nparray(sample, size)
if expected is None:
print(f"\n{sample.file_name} {size} has no reference image. Skipping...")
return
# Suppress "Press CTRL-C to stop sample" messages
with io.StringIO() as buf, contextlib.redirect_stdout(buf):
actual = run_sample(sample, size)
if not np.array_equal(expected, actual):
image = Image.fromarray(np.array(actual, dtype="uint8"), "RGB")
image.save(
os.path.abspath(
os.path.join(
__file__,
"..",
"result",
f"{sample.file_name}-w{size[0]}h{size[1]}.png",
)
)
)
self.assertTrue(
False,
f"Actual results do not match reference screenshot. See test/result/{sample.file_name}-w{size[0]}h{size[1]}.png to compare",
)
self.assertTrue(True)
There’s a bit I glossed over here, namely that the RGBME instance reads from a config file for some extra configuration, which also needed to be spoofed. I took the easy way out and wrote that file for each test set eup – this is in line with the testing goals in that I wanted to simulate actually running each test.
I didn’t anticipate this problem being this difficult. I learned quite a bit about Python’s package loader, ended up needing to inject attributes via __setattr__
mocks, and had to implement a kind of hot reloading for my new display adapter. All in, a very interesting weekend project!
That’s all for now. Thanks for reading!