Testing

Embedded graphics code is slow to debug on a board. This project moves almost all of the checking to a PC, then gives you patterns to confirm the wiring, and finally a short integration checklist.

Three levels

1. On the PC

A model of the ST7789 receives the exact bus writes the library produces. Tests check pixels, window violations and cost. No hardware needed.

2. On the board

Nine test patterns make wiring and configuration faults obvious: swapped colours, mirroring, offsets, noise.

3. Integration

Your port, your init sequence and your UI together: timing, the demo loop, and a pixel comparison with the PC.

Level 1: tests on the PC

The model in tests/host/sim.c implements CASET, RASET and RAMWR as the datasheet describes them. It keeps a 240 x 320 frame memory, advances the write position inside the window, and counts every command and data write. Any pixel sent outside the window or the memory is counted as a violation, and the tests require zero.

make test

One command runs everything below. It needs make, a C compiler and Python 3. Clang, the Arm cross-compiler and Pillow are optional locally; CI runs them all.

StepWhat it checks
header-checkThe committed single_header/lcd_gfx.h matches what the generator produces from the sources.
host56 library checks (geometry, clipping, text, images, widgets) plus 13 self-test checks, on the multi-file build.
singleThe same 69 checks on the single-header build, linked from two translation units so duplicate symbols would show up.
equivPixel equivalence: for each of the 16 demo and self-test pages, an FNV-1a hash of the 240 x 240 frame must be identical in the multi-file build, the single-header build and tests/golden/pages.golden.
benchBus writes per primitive against pixel by pixel; fails if any multi-pixel primitive costs as much.
strict-std=c99 -Wall -Wextra -pedantic -Werror with gcc over the library, the single header and the tests.
clangThe same with clang. Skipped, with a message, when clang is not installed.
armCross-compile for Cortex-M7 with arm-none-eabi-gcc -mcpu=cortex-m7 -mthumb -Os and print the object sizes. Skipped when the compiler is missing.
siteLinks, anchors, titles, descriptions, canonical URLs, JSON-LD and the social image of this website.

Golden pixel hashes

Every page is drawn on a cleared frame memory and hashed with 64-bit FNV-1a over the visible 240 x 240 pixels, row by row, each pixel as two bytes with the high byte first, so the value does not depend on the host's byte order. The file stores one line per page: set, index, name, hash and the number of bus writes.

demo 3 "Panel" 00c8ebdc6ce4bd2e 157844
selftest 6 "Solid red" 87249f08992f2325 57611

A refactor that changes a single pixel changes a hash, and the test fails. When you change what a page draws on purpose, regenerate the file with make golden and explain the reason in the pull request.

Benchmark

make bench measures bus writes on the model. A pixel-by-pixel driver sends 12 writes per pixel, so the comparison column is the number of pixels painted times 12.

PrimitivePixelsBus writesPixel by pixelRatio
One pixel112121.0x
Horizontal line, 200 px2002112,40011.4x
Diagonal line, 220 x 802211,1122,6522.4x
Filled rectangle, 100 x 606,0006,01172,00012.0x
Filled circle, radius 6011,51312,844138,15610.8x
Arc, radius 80, 270 degrees4,8477,35558,1647.9x
Text, 12 characters, 16 px font1,7861,91821,43211.2x
Icon, 32 x 321,0241,03512,28811.9x
Image, 120 x 809,6009,611115,20012.0x
Full screen57,60057,611691,20012.0x
Dashboard demo page145,645157,8441,747,74011.1x

The pixel counts include overdraw, because the library writes every pixel it paints, so the dashboard page paints more pixels than the screen holds.

Continuous integration

The GitHub Actions workflow runs make test with gcc and with clang, installs the Arm cross-compiler so the Cortex-M7 build really runs, uploads the rendered screenshots as an artifact, and publishes this site from the docs/ folder to GitHub Pages.

Level 2: test patterns on the board

Call gfx_selftest_page(n) for each page and compare what you see with the expectation below.

PagePatternWhat you should seeIf it is wrong
0Colour barsEight bars in the order red, green, blue, cyan, magenta, yellow, white, black, with the letters under them.Red and blue swapped: BGR bit or byte order.
1Gray rampSixteen clean steps above, a smooth ramp below, no tint.Banding or colour cast: pixel format or missing data bits.
2Frame and cornersA white frame touching all four edges; red top-left, green top-right, blue bottom-left, yellow bottom-right.Mirrored corners: MADCTL. Missing edge or a shift: window offset.
3Grid, cross, circleA square 20 px grid, a centred cross, round circles and two diagonals corner to corner.Ellipses or skew: wrong window or resolution.
4Nested rectanglesTwelve concentric frames, 10 px apart, white, red, white, blue.Uneven margins: wrong window size; wrapped lines: bad window end.
5Text and iconsEvery font readable and unmirrored, six icons at the bottom.Mirrored text: MADCTL. Question marks: source not UTF-8.
6, 7, 8Solid red, green, blueOne perfectly flat colour each.Speckles or stuck bits: timing or a bad data line.

The troubleshooting table lists the causes and fixes. The patterns are also part of the PC tests, so you can compare your panel with the rendered reference sheet.

Level 3: integration

  1. Run the demo loop from Draw the first screen and compare each page with the screenshots on the home page.
  2. Time a full-screen fill. Toggle a GPIO around gfx_fill_screen() and measure it. The result is dominated by 57,600 calls to gfx_port_data(), so it tells you directly how fast your port and your FMC timings are.
  3. Render your own screens on the PC. Link your UI code against the library and tests/host/sim.c and hash it the way pages.c does. A UI change then shows up in review as a hash change.
  4. Check the cost of your redraws. Count writes with the model before you look at the board: a screen that redraws widgets every frame is easy to spot in the numbers.
  5. Wire in input. Widgets are stateless drawing functions, so connect touch or buttons in your own code and call the widget again with the new value.