One Script, Two Test Front Ends

Category: SDL Adventure Game

In the last post I built a headless native test: run the game offscreen, script some clicks, and assert the right dialogue comes out. But I already had a second playthrough test — a Puppeteer script that drives the WebAssembly build in a real browser. Two tests, same adventure, and — this is the bad part — two copies of the entire playthrough: the same click coordinates, the same waits, the same list of expected lines, written out once in C and once in JavaScript.

That’s a trap. The moment I nudge a hotspot or reword a line, one copy goes stale and the tests quietly disagree about what the game does. So before wiring the browser test into CI, I made the two share a single source of truth.

The playthrough is data, not code

The insight is that a playthrough isn’t really code — it’s a list of instructions. “Click here, wait a beat, hold and scrub over there, take a screenshot, expect these words.” So I moved all of that into one JSON file:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
  "steps": [
    { "action": "click", "x": 0.5, "y": 0.497, "wait_ms": 1500 },
    { "action": "screenshot", "name": "01-pool" },
    { "action": "brush", "x0": 0.36, "x1": 0.64, "y0": 0.31, "y1": 0.69,
      "rows": 8, "wait_ms": 1500 },
    ...
  ],
  "expect": [
    "Ho preso gli occhialini",
    "Ecco il tuo salvagente",
    ...
  ]
}

Coordinates are fractions of the screen, so they mean the same thing at 800×600 native and on a scaled browser canvas. There are only four verbs — click, brush (the hold-and-scrub sunscreen minigame), wait, and screenshot — and each side has a tiny interpreter that walks the list and does the obvious thing.

Getting JSON into C

The browser runner just requires the JSON — done. C is the awkward one: it has no JSON parser, and I wasn’t about to add a dependency for a test fixture. So a small Python script reads the JSON at build time and emits a C header:

1
2
3
4
5
static const ScriptStep GINA_STEPS[] = {
    {.action = SCRIPT_CLICK, .x = 0.5, .y = 0.497, .wait_ms = 1500},
    {.action = SCRIPT_SCREENSHOT, .name = "01-pool"},
    ...
};

The Makefile runs the generator before building the test, so the header is always in sync with the JSON — there’s no committed copy to drift. The native play-test collapsed to almost nothing: run the generated steps, then check the generated list of expected lines. The whole choreography — which used to be forty-odd lines of hand-written C — now lives in the JSON and nowhere else.

Screenshots that pull double duty

The browser test earns its keep by taking a screenshot at each screenshot step and uploading them from CI, so I can look at what the game rendered on a real canvas — the hub, the sunscreen close-up, the tree, the dive. That’s the kind of check a log-line assertion can’t give you.

The neat part is that the native test reads those same screenshot steps too. It can’t save a browser PNG, but it can do the cheap analog: read the frame back with SDL_RenderReadPixels and assert it isn’t a single flat colour — a guard against the classic “black screen / missing texture” regression. So one entry in the JSON means “save a picture” in the browser and “prove the frame isn’t blank” natively, and the native test went from one such check to six for free.

Two front ends, one truth

Now both run in CI on every push: the native playthrough gates on the dialogue, the browser playthrough gates on the dialogue and leaves a trail of screenshots. When I want to change the adventure’s flow, I edit one JSON file and both front ends follow. Adding the second adventure’s playthrough will be a new .json and nothing else.

It’s a small thing, but it’s the sort of small thing that decides whether a test suite is an asset or a liability a year from now. Duplicated test fixtures rot; a single description that two runners obey does not.

Next: Walking Around Things