DEV Community

Cover image for Testing an embedded GUI without the board: 46 scripted taps and one make
Wwumin
Wwumin

Posted on Originally published at github.com

Testing an embedded GUI without the board: 46 scripted taps and one make

One PC, one make -C test run, 46 scripted touches: the real state machine of a

1024×600 DRM-direct photo frame, driven from beginning to end. Not an emulator, not
screenshot diffing — the same C sources that run on the board, with only the display
and the touch panel replaced by stubs.

The loop everyone knows

If you build embedded GUIs you know the cycle:

  1. change one line of layout code;
  2. cross-compile;
  3. scp it to the board (or a USB stick, or a SD card);
  4. reboot, watch the screen, poke it with a finger;
  5. discover a button is 3 pixels off. Back to step 1.

Five minutes per iteration — and nobody else can verify it for you. Code review
cannot tell whether a tap really lands on a button; only human eyes can. And the moment
you try to put "regression" into CI, you find the whole program is welded to drm,
linux/input.h and a vendor .so; it will not even compile without the board.

That is how this project started: a photo album for a Rockchip RK1808 board, DRM-direct
1024×600, password lock, slideshow, six mini games, plain C11, no Qt / X11 / Wayland /
libpng / libjpeg. I split "verify on the board" into three layers:

  • logic — pure functions and state machines, millisecond unit tests on the PC (41 CHECKs);
  • whole app — display and touch replaced by stubs, so the real state machine runs through 46 scripted taps on the PC;
  • art — every bitmap is regenerated by Python scripts, reproducible at any time.

The result: make -C test run finishes in 31 seconds on Windows (exit 0, last line
[main] 清理退出), and CI on ubuntu-latest runs the whole chain with no board and no
display
.

The idea: replace implementations, not the code under test

Most "embedded unit test" attempts fail because they mock away the thing they are meant
to test. I did the opposite: don't touch the application, replace the three real-world
entry points
.

1. DRM → a malloc'd framebuffer

The real DRMshowUp() is drmModePageFlip() plus a poll() for vblank. The stub just
writes the BGRA buffer out as a 24-bit BMP:

/* test/stub/host/drm_stub.c */
static uint8_t *g_buf = NULL;          /* plain memory as "video memory" */
static int g_frame = 0;

int DRMshowUp(int fd, struct drmHandle *drm)
{
    /* every 20th frame lands in out/frame_XXXX.bmp for eyeballing */
    if (++g_frame % 20 == 0)
        write_bmp24(path, g_buf, 1024, 600, pitch);
    return 0;
}
Enter fullscreen mode Exit fullscreen mode

The drawing code only requires "a writable framebuffer + a page flip", so the stub fools
all of it. Bonus: the exported frames are the real render, so a layout regression is
one glance away.

2. Touch → a script file

Real input is EV_ABS events from /dev/input/eventN, with press and release arriving
as separate SYN packets. The stub turns each line of clicks.txt into "press, hold for
2 polls, release":

# x   y   hold ms after press
378 303 300     ← tap "Album" on the main menu
518 303 0
658 303 0
378 387 0
176 227 500
Enter fullscreen mode Exit fullscreen mode

That is 46 actions: enter the password, browse the album, go back, open the game menu,
play a few moves of 2048 / Tetris / Snake / Tic-Tac-Toe / Minesweeper / Xiangqi, then
exit. When the script ends the stub quits the process (g_script_done_quit = 1), which is
what makes this a smoke test rather than an infinite loop.

3. Headers → a shadow include directory

The real code does #include "DRMwrap.h" for the vendor SDK. The test build puts -Istub
first, where a shadow header with the same name lives, plus a minimal linux/input.h that
only defines struct input_event. No cross toolchain needed:

CFLAGS = -std=c11 -Wall -Wextra -O1 -g \
         -I../album -Istub/host -Istub -I.
Enter fullscreen mode Exit fullscreen mode

No environment variables either: display.c picks the stub path via #if DRM_STUB_BUILD,
and the touch stub takes care of shutting down.

Three test layers, two orders of magnitude apart

make -C test unit    # seconds: 2048 rules + touch-device detection
make -C test run     # 31 s: the whole state machine, 46 taps
make -C test check   # unit first, then the full app
Enter fullscreen mode Exit fullscreen mode
  • unit_2048 — merge / move / spawn rules of 2048, pure functions, 40+ CHECK assertions.
  • unit_detect — touch device detection: pick the touchscreen out of /proc/bus/input/devices text. Drivers are named whatever the vendor felt like, so it is detected at runtime, not hard-coded.
  • album_test — full smoke test: album_test ../bmp /dev/null.

The value of the split is localisation: a unit failure is a logic bug, a full-app
failure is a flow or layout bug. Both CI failures below were pinned to a single step
immediately.

Art assets must be reproducible too

There is not one binary asset that needs licensing:

  • 24 C files, 5,578 lines;
  • 11 Python scripts, 2,379 lines (backgrounds, buttons, chess pieces, tetrominoes, snake, landscapes, bitmap font, BMP → C array);
  • 158 BMPs generated on demand by make assets;
  • whole repo: 77 files, ~2.5 MB.

One detail: button sprites need real transparency (A=0) in the rounded corners, and
24-bit BMP has no alpha channel at all. So the generator writes hand-made 32-bit BMP
headers
and stores every pixel as BGRA. Where you would normally reach for a library,
hand-writing the format was the more controllable option.

Pushing it into CI — and getting slapped by Linux twice

The workflow is deliberately boring (.github/workflows/ci.yml, ubuntu-latest):

  1. gcc --version
  2. install a CJK font (the generator renders Chinese labels)
  3. make -C test unit
  4. pip install pillow && make assets
  5. count the generated BMPs, fail under 150
  6. make -C test run — 46 taps, real state machine

First run: the unit tests failed.

unit_detect: [FAIL] 位掩码数组长度 1 够装下 ABS 位
Enter fullscreen mode Exit fullscreen mode

My machine (MinGW) has a 32-bit unsigned long, so I asserted N_ABS >= 2 for a 64-bit
bitmask (ABS_MAX = 0x3f). On 64-bit Linux, one unsigned long already holds 64 bits —
one is enough. The fix is not to change the number but to assert on capacity instead
of word width:

CHECK((int)(N_ABS * 8 * sizeof(unsigned long)) >= ABS_MAX + 1,
      "bitmask capacity %d bit holds ABS_MAX+1 = %d bit", ...);
Enter fullscreen mode Exit fullscreen mode

Second run: asset generation failed.

OSError: cannot open resource
  File "tools/gen_assets.py", line 93, ... F(FONT_BOLD, 46) ...
  File "tools/gen_assets.py", line 29, FONT_BOLD = r"C:\Windows\Fonts\msyhbd.ttc"
Enter fullscreen mode Exit fullscreen mode

Yes — hard-coded C:\Windows\Fonts. The fix was tools/fontkit.py: resolve a CJK font per
platform (Microsoft YaHei / SimHei / DengXian on Windows → Noto Sans CJK on Linux →
DejaVu), allow the ALBUM_FONT env var to override, and fall back to Pillow's built-in
bitmap font — degrade instead of crash. CI gained one line:
sudo apt-get install -y fonts-noto-cjk.

Third run: green. Every push to main now runs the chain, and the badge sits on top
of the README.

Those two failures are the most valuable part of the story. I thought "works on my
machine" meant it works; in reality my machine was covering for two platform
assumptions
— the width of long, and the location of a font. That is exactly why you
run a hardware-free test suite in CI: it is a clean box with none of your environment,
there specifically to slap you.

What it buys you

before after
see a layout change cross-compile + flash + eyeball, ~5 min make -C test run, 31 s + exported frames
logic regression tap through it by hand 1-second unit run
can a stranger verify it? no (needs the board) git clone && make -C test unit
regression on every push none 46 taps automatically

And none of it is specific to this board. DRM, evdev and the framebuffer look the same on
RK1808 / RK3568 / Allwinner / Raspberry Pi. The only things you actually need to replace
are display, input, and vendor headers.

Five things the stubs do not cover

To be honest: this guarantees that the state machine is reachable and the layout does
not regress
— not that the result is pixel-identical to the board. Five real-world
differences stay outside the stubs, all documented in the README:

  1. page-flip timing — the real path waits for vblank, and single-buffered transitions can tear on fast refreshes; the stub has no notion of time.
  2. touch coordinate mapping — the stub assumes 1024×600 logical coordinates; the real driver may rotate, scale, or use a different range, which is why touch.c normalises.
  3. vendor init path — DRMinit / DRMcreateFB from DRMwrap only really execute on the board; the stub provides same-signature empty implementations.
  4. pixel format — the board is 32 bpp XRGB8888, the stub writes BGRA BMP; channel order matches, alpha handling does not (board ignores it, stub keeps it).
  5. timing-sensitive input — long press, double tap and fast repeated taps are modelled with g_hold_ticks; the real event rate belongs to the driver, so these behaviours are "reachable", not "feel identical".

The mitigation is plain: look at the exported frames before flashing, then spot-check
three screens on the board
(main menu, album, game menu). The stub is responsible for
"no regressions"; the board is responsible for "actually looks right".

Repo

Full sources, the 46-tap script and the CI config (MIT):

https://github.com/Wwumin/rk1808-photo-album

git clone https://github.com/Wwumin/rk1808-photo-album
cd rk1808-photo-album
make -C test unit     # logic regression, ~1 s
make assets           # regenerate 158 bitmaps (needs Pillow)
make -C test run      # 46-tap smoke test
Enter fullscreen mode Exit fullscreen mode

No board is needed for the PC path; only the on-board path needs the vendor
libDRMwrap.so (see README → Building for the board).

If you also build embedded GUIs while the board is somewhere else, I would love to hear
you — especially on the question of how real a stub should be: too fake and you fool
yourself, too real and you are rewriting the driver.


Project: RK1808 1024×600 DRM-direct photo frame, six mini games, C11. 77 files, 2.5 MB.
CI green on run #40169371435.

Top comments (0)