Files
akbasic/tests/akgl_backends.c

417 lines
15 KiB
C
Raw Normal View History

Wire the sink and the three devices to libakgl src/sink_akgl.c, src/graphics_akgl.c, src/audio_akgl.c and src/input_akgl.c, in the akbasic_akgl target, which is the only thing here that links SDL. -DAKBASIC_WITH_AKGL=ON had never been configured in this repository before, and it now builds and passes. The sink is what section 3 has been waiting on. Its character grid comes from akgl_text_measure(font, "A", &w, &h), the direct equivalent of the reference's font.SizeUTF8("A") and the call that did not exist until 42b60f7. Wrapping is done on the character grid rather than by handing SDL_ttf a wraplength, because the cursor has to land somewhere definite: a program that PRINTs a long string and then PRINTs again expects the second to start on the row after the first ended, and only the code that placed the characters knows which row that is. tests/akgl_backends.c draws into a 128x128 software renderer under the dummy video driver and reads the pixels back -- the pattern deps/libakgl/tests/draw.c established, which needs no display and no offscreen harness. It asserts the seam rather than libakgl's own behaviour: a BASIC line in, a lit pixel of the right colour out. Four things in libakgl had to be worked around to get here. All four are commented at their site with "filed upstream" and recorded in TODO.md section 3: - An embedded libakgl requires SDL, SDL_image, SDL_mixer, SDL_ttf and jansson to be *installed*. It builds its own vendored copies only when it is top-level, and they are sitting right there in deps/libakgl/deps. Every lookup is guarded with if(NOT TARGET ...), so this adds those five subdirectories before add_subdirectory(deps/libakgl) -- the same trick and the same ordering requirement akerror::akerror and akstdlib::akstdlib already need. - akgl/controller.h does not compile on its own: it declares handlers taking an akgl_Actor * and includes nothing that declares the type. - There is no way to attach a 2D backend to a renderer you already have. akgl_render_init2d() installs the vtable but also creates its own window and writes the camera global, so it belongs to the akgl_game_init() path -- which is exactly the path an embedding host is not on. The test assigns the six pointers by hand. - akgl_text_rendertextat() segfaults on a backend whose vtable is empty; it reaches through renderer->draw_texture without checking it. Same class of defect 42b60f7's own commit added a draw test for. The sink's readline reports end of input rather than reading: a drawn text layer is not a source of lines, and INPUT through one wants a line editor built on the keystroke ring. EOF rather than an error is the contract sink.h states, so INPUT already handles it. That editor is the next piece of work there. 70/70 core ctest with no SDL on the include path, 71/71 with the akgl suite, clean under -Wall -Wextra, doxygen clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 08:46:53 -04:00
/**
* @file akgl_backends.c
* @brief Tests the libakgl-backed sink and device backends against real pixels.
*
* Everything here draws into a small software renderer under the dummy video
* driver and reads the target back, which is the pattern
* deps/libakgl/tests/draw.c established -- it needs no display, no offscreen
* harness and no audio hardware.
*
* These are the only tests in this repository that link SDL, and they only build
* under -DAKBASIC_WITH_AKGL=ON. Everything they exercise below the adaptor is
* libakgl's own and is tested over there; what is asserted here is the seam:
* that a BASIC verb reaches the right akgl call with the right geometry and the
* right colour, and that the text sink puts characters where its own cursor says
* they are.
*/
#include <string.h>
#include <SDL3/SDL.h>
#include <SDL3_ttf/SDL_ttf.h>
#include <akerror.h>
#include <akgl/actor.h>
#include <akgl/controller.h>
#include <akgl/error.h>
/*
* game.h purely for the `renderer` global and its `_akgl_renderer` storage. The
* interpreter never calls akgl_game_init() -- it drives subsystems directly, and
* owning the game loop is exactly what goal 3 forbids -- but the draw calls read
* that global, so a test standing in for a host has to populate it the same way
* deps/libakgl/tests/draw.c does.
*/
#include <akgl/game.h>
#include <akgl/renderer.h>
#include <akgl/text.h>
#include <akbasic/akgl.h>
#include <akbasic/error.h>
#include <akbasic/runtime.h>
#include <akbasic/sink.h>
#include "testutil.h"
/** @brief Width and height of the offscreen target. Small enough to read back whole. */
#define TARGET_SIZE 128
/* The interpreter carries every pool it owns, so it does not fit on a stack. */
static akbasic_Runtime RUNTIME;
static akbasic_TextSink SINK;
static akbasic_StdioSink SINKSTATE;
static akbasic_AkglSink AKGLSINKSTATE;
static akbasic_TextSink AKGLSINK;
static akbasic_GraphicsBackend GRAPHICS;
static akbasic_AkglGraphics GRAPHICSSTATE;
static akbasic_InputBackend INPUT;
static TTF_Font *font = NULL;
static char OUTPUT[8192];
static FILE *OUT = NULL;
/** @brief Report whether one pixel of @p shot carries @p color. Alpha is not compared. */
static bool pixel_is(SDL_Surface *shot, int x, int y, uint8_t r, uint8_t g, uint8_t b)
{
uint8_t gotr = 0;
uint8_t gotg = 0;
uint8_t gotb = 0;
uint8_t gota = 0;
if ( shot == NULL ) {
return false;
}
if ( !SDL_ReadSurfacePixel(shot, x, y, &gotr, &gotg, &gotb, &gota) ) {
return false;
}
return ((gotr == r) && (gotg == g) && (gotb == b));
}
/** @brief Clear the target to opaque black so a drawn pixel is unambiguous. */
static akerr_ErrorContext AKERR_NOIGNORE *clear_target(void)
{
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, SDL_SetRenderDrawColor(renderer->sdl_renderer, 0, 0, 0, 0xff),
AKGL_ERR_SDL, "%s", SDL_GetError());
FAIL_ZERO_RETURN(errctx, SDL_RenderClear(renderer->sdl_renderer),
AKGL_ERR_SDL, "%s", SDL_GetError());
SUCCEED_RETURN(errctx);
}
/** @brief Bring up a runtime with the akgl graphics backend attached. */
static akerr_ErrorContext AKERR_NOIGNORE *start_runtime(const char *source)
{
PREPARE_ERROR(errctx);
memset(OUTPUT, 0, sizeof(OUTPUT));
OUT = fmemopen(OUTPUT, sizeof(OUTPUT), "w");
FAIL_ZERO_RETURN(errctx, (OUT != NULL), AKERR_IO, "could not open the output buffer");
setvbuf(OUT, NULL, _IONBF, 0);
PASS(errctx, akbasic_sink_init_stdio(&SINK, &SINKSTATE, OUT, NULL));
PASS(errctx, akbasic_runtime_init(&RUNTIME, &SINK));
PASS(errctx, akbasic_graphics_init_akgl(&GRAPHICS, &GRAPHICSSTATE, renderer));
PASS(errctx, akbasic_runtime_set_devices(&RUNTIME, &GRAPHICS, NULL, NULL));
PASS(errctx, akbasic_runtime_load(&RUNTIME, source));
PASS(errctx, akbasic_runtime_start(&RUNTIME, AKBASIC_MODE_RUN));
PASS(errctx, akbasic_runtime_run(&RUNTIME, 0));
SUCCEED_RETURN(errctx);
}
static void stop_runtime(void)
{
if ( OUT != NULL ) {
fclose(OUT);
OUT = NULL;
}
}
/**
* @brief A DRAW reaches real pixels, in the colour COLOR bound to the source.
*
* This is the assertion the whole adaptor exists for: a BASIC line in, a lit
* pixel out, with nothing mocked in between.
*/
static akerr_ErrorContext AKERR_NOIGNORE *test_draw_reaches_pixels(void)
{
PREPARE_ERROR(errctx);
SDL_Surface *shot = NULL;
PASS(errctx, clear_target());
PASS(errctx, start_runtime("10 COLOR 1, 3\n20 DRAW 1, 10, 20\n"));
TEST_REQUIRE_STR(OUTPUT, "");
shot = SDL_RenderReadPixels(renderer->sdl_renderer, NULL);
TEST_REQUIRE(shot != NULL, "could not read the render target back");
/* Palette index 3 is red: 0x88, 0x39, 0x32 in src/graphics_tables.c. */
TEST_REQUIRE(pixel_is(shot, 10, 20, 0x88, 0x39, 0x32),
"DRAW 1,10,20 after COLOR 1,3 should have lit (10,20) red");
TEST_REQUIRE(!pixel_is(shot, 11, 20, 0x88, 0x39, 0x32),
"DRAW should have lit one pixel, not two");
SDL_DestroySurface(shot);
stop_runtime();
SUCCEED_RETURN(errctx);
}
/** @brief A BOX outlines: its corners are lit and its middle is not. */
static akerr_ErrorContext AKERR_NOIGNORE *test_box_outlines(void)
{
PREPARE_ERROR(errctx);
SDL_Surface *shot = NULL;
PASS(errctx, clear_target());
PASS(errctx, start_runtime("10 BOX 1, 10, 10, 40, 40\n"));
TEST_REQUIRE_STR(OUTPUT, "");
shot = SDL_RenderReadPixels(renderer->sdl_renderer, NULL);
TEST_REQUIRE(shot != NULL, "could not read the render target back");
TEST_REQUIRE(pixel_is(shot, 10, 10, 0xff, 0xff, 0xff), "the box's corner should be lit");
TEST_REQUIRE(pixel_is(shot, 25, 10, 0xff, 0xff, 0xff), "the box's top edge should be lit");
TEST_REQUIRE(!pixel_is(shot, 25, 25, 0xff, 0xff, 0xff),
"an unrotated BOX outlines rather than fills");
SDL_DestroySurface(shot);
stop_runtime();
SUCCEED_RETURN(errctx);
}
/**
* @brief PAINT fills the region a BOX encloses and stops at its edge.
*
* Also the one place the akgl flood fill is exercised through a BASIC verb,
* which is where its partial-fill error would surface if it ever fired.
*/
static akerr_ErrorContext AKERR_NOIGNORE *test_paint_fills_region(void)
{
PREPARE_ERROR(errctx);
SDL_Surface *shot = NULL;
PASS(errctx, clear_target());
PASS(errctx, start_runtime("10 BOX 1, 10, 10, 40, 40\n"
"20 COLOR 2, 6\n"
"30 PAINT 2, 25, 25\n"));
TEST_REQUIRE_STR(OUTPUT, "");
shot = SDL_RenderReadPixels(renderer->sdl_renderer, NULL);
TEST_REQUIRE(shot != NULL, "could not read the render target back");
/* Palette index 6 is green: 0x55, 0xa0, 0x49. */
TEST_REQUIRE(pixel_is(shot, 25, 25, 0x55, 0xa0, 0x49),
"the inside of the box should have been painted green");
TEST_REQUIRE(!pixel_is(shot, 50, 50, 0x55, 0xa0, 0x49),
"the paint should have stopped at the box's edge");
SDL_DestroySurface(shot);
stop_runtime();
SUCCEED_RETURN(errctx);
}
/** @brief SSHAPE and GSHAPE round-trip a region through the handle in a string. */
static akerr_ErrorContext AKERR_NOIGNORE *test_shape_roundtrip(void)
{
PREPARE_ERROR(errctx);
SDL_Surface *shot = NULL;
PASS(errctx, clear_target());
PASS(errctx, start_runtime("10 BOX 1, 4, 4, 12, 12\n"
"20 SSHAPE A$, 0, 0, 16, 16\n"
"30 GSHAPE A$, 64, 64\n"));
TEST_REQUIRE_STR(OUTPUT, "");
shot = SDL_RenderReadPixels(renderer->sdl_renderer, NULL);
TEST_REQUIRE(shot != NULL, "could not read the render target back");
/* The saved corner was at (4,4); pasted at (64,64) it lands at (68,68). */
TEST_REQUIRE(pixel_is(shot, 68, 68, 0xff, 0xff, 0xff),
"the pasted shape should carry the box's corner");
SDL_DestroySurface(shot);
stop_runtime();
SUCCEED_RETURN(errctx);
}
/**
* @brief The sink measures a character grid and puts text where its cursor says.
*
* akgl_text_measure() is the call this whole file was blocked on until libakgl
* 42b60f7, so the grid it produces is worth asserting directly rather than only
* through what gets drawn.
*/
static akerr_ErrorContext AKERR_NOIGNORE *test_sink_grid_and_wrap(void)
{
PREPARE_ERROR(errctx);
int cellw = 0;
int cellh = 0;
PASS(errctx, akgl_text_measure(font, "A", &cellw, &cellh));
TEST_REQUIRE(cellw > 0 && cellh > 0, "the fixture font should measure a real cell");
PASS(errctx, akbasic_sink_init_akgl(&AKGLSINK, &AKGLSINKSTATE, renderer, font,
TARGET_SIZE, TARGET_SIZE));
TEST_REQUIRE_INT(AKGLSINKSTATE.cellw, cellw);
TEST_REQUIRE_INT(AKGLSINKSTATE.columns, TARGET_SIZE / cellw);
TEST_REQUIRE_INT(AKGLSINKSTATE.rows, TARGET_SIZE / cellh);
PASS(errctx, AKGLSINK.writeln(&AKGLSINK, "HELLO"));
PASS(errctx, AKGLSINK.write(&AKGLSINK, "WORLD"));
TEST_REQUIRE_STR(AKGLSINKSTATE.text[0], "HELLO");
TEST_REQUIRE_STR(AKGLSINKSTATE.text[1], "WORLD");
TEST_REQUIRE_INT(AKGLSINKSTATE.cursorrow, 1);
TEST_REQUIRE_INT(AKGLSINKSTATE.cursorcol, 5);
/* A clear empties the grid and takes the cursor home. */
PASS(errctx, AKGLSINK.clear(&AKGLSINK));
TEST_REQUIRE_STR(AKGLSINKSTATE.text[0], "");
TEST_REQUIRE_INT(AKGLSINKSTATE.cursorrow, 0);
TEST_REQUIRE_INT(AKGLSINKSTATE.cursorcol, 0);
/*
* A text area too small for one character is refused rather than silently
* producing a zero-column grid, which would divide by zero on the first
* wrap.
*/
TEST_REQUIRE_STATUS(akbasic_sink_init_akgl(&AKGLSINK, &AKGLSINKSTATE, renderer, font, 1, 1),
AKBASIC_ERR_BOUNDS);
TEST_REQUIRE_STATUS(akbasic_sink_init_akgl(&AKGLSINK, &AKGLSINKSTATE, NULL, font,
TARGET_SIZE, TARGET_SIZE),
AKERR_NULLPOINTER);
SUCCEED_RETURN(errctx);
}
/** @brief The sink's text actually reaches the target when the host renders. */
static akerr_ErrorContext AKERR_NOIGNORE *test_sink_renders(void)
{
PREPARE_ERROR(errctx);
SDL_Surface *shot = NULL;
int x = 0;
int y = 0;
bool lit = false;
PASS(errctx, clear_target());
PASS(errctx, akbasic_sink_init_akgl(&AKGLSINK, &AKGLSINKSTATE, renderer, font,
TARGET_SIZE, TARGET_SIZE));
PASS(errctx, AKGLSINK.write(&AKGLSINK, "X"));
PASS(errctx, akbasic_sink_akgl_render(&AKGLSINK));
/*
* Asserted as "something was drawn in the first cell" rather than against
* particular pixels: which ones a glyph lights is FreeType's business and it
* is free to hint them differently. The seam being tested is that the text
* reached the renderer at the cursor's cell at all.
*/
shot = SDL_RenderReadPixels(renderer->sdl_renderer, NULL);
TEST_REQUIRE(shot != NULL, "could not read the render target back");
for ( y = 0; y < AKGLSINKSTATE.cellh && !lit; y++ ) {
for ( x = 0; x < AKGLSINKSTATE.cellw && !lit; x++ ) {
lit = !pixel_is(shot, x, y, 0, 0, 0);
}
}
TEST_REQUIRE(lit, "the sink drew nothing into the first character cell");
SDL_DestroySurface(shot);
SUCCEED_RETURN(errctx);
}
/**
* @brief The input adaptor drains the ring the host's event pump fills.
*
* Synthetic key events through akgl_controller_handle_event(), which is exactly
* how a host feeds it, and then read back through the akbasic backend.
*/
static akerr_ErrorContext AKERR_NOIGNORE *test_input_backend(void)
{
PREPARE_ERROR(errctx);
SDL_Event event;
int keycode = 0;
bool available = false;
/*
* handle_event refuses a NULL appstate outright, so it needs something to
* point at even though nothing here reads it. deps/libakgl/tests/controller.c
* uses a placeholder for the same reason.
*/
int appstate_placeholder = 0;
PASS(errctx, akbasic_input_init_akgl(&INPUT));
PASS(errctx, INPUT.flush_keys(&INPUT));
/* An empty ring is success with nothing available, never an error. */
PASS(errctx, INPUT.poll_key(&INPUT, &keycode, &available));
TEST_REQUIRE(!available, "an empty ring should report nothing available");
memset(&event, 0, sizeof(event));
event.type = SDL_EVENT_KEY_DOWN;
event.key.key = SDLK_A;
PASS(errctx, akgl_controller_handle_event(&appstate_placeholder, &event));
PASS(errctx, INPUT.poll_key(&INPUT, &keycode, &available));
TEST_REQUIRE(available, "a key the host pumped should reach the interpreter");
TEST_REQUIRE_INT(keycode, SDLK_A);
PASS(errctx, INPUT.poll_key(&INPUT, &keycode, &available));
TEST_REQUIRE(!available, "the ring should drain to empty");
SUCCEED_RETURN(errctx);
}
int main(void)
{
PREPARE_ERROR(errctx);
SDL_SetHint(SDL_HINT_VIDEO_DRIVER, "dummy");
SDL_SetHint(SDL_HINT_AUDIO_DRIVER, "dummy");
SDL_SetHint(SDL_HINT_RENDER_DRIVER, "software");
ATTEMPT {
CATCH(errctx, akgl_error_init());
renderer = &_akgl_renderer;
FAIL_ZERO_BREAK(errctx, SDL_Init(SDL_INIT_VIDEO), AKGL_ERR_SDL,
"Couldn't initialize SDL: %s", SDL_GetError());
FAIL_ZERO_BREAK(errctx, TTF_Init(), AKGL_ERR_SDL,
"Couldn't initialize SDL_ttf: %s", SDL_GetError());
FAIL_ZERO_BREAK(errctx,
SDL_CreateWindowAndRenderer("net/aklabs/akbasic/test_akgl_backends",
TARGET_SIZE, TARGET_SIZE, 0,
&window, &renderer->sdl_renderer),
AKGL_ERR_SDL, "Couldn't create window/renderer: %s", SDL_GetError());
/*
* Populate the backend's vtable by hand, which is not where anybody would
* expect to have to do it.
*
* akgl_text_rendertextat() reaches through renderer->draw_texture, and an
* SDL_Renderer alone does not fill that in --
* deps/libakgl/tests/draw.c gets away without it because the draw
* primitives take the SDL_Renderer directly. The obvious call,
* akgl_render_init2d(), does install these six pointers, but it also
* creates its own window from the game properties and writes to the
* `camera` global, so it is part of the akgl_game_init() path rather than
* something a caller who already has a renderer can use. A host embedding
* this interpreter is in exactly that position. Filed upstream: what is
* wanted is the vtable half of init2d on its own.
*/
renderer->shutdown = &akgl_render_2d_shutdown;
renderer->frame_start = &akgl_render_2d_frame_start;
renderer->frame_end = &akgl_render_2d_frame_end;
renderer->draw_texture = &akgl_render_2d_draw_texture;
renderer->draw_mesh = &akgl_render_2d_draw_mesh;
renderer->draw_world = &akgl_render_2d_draw_world;
/*
* libakgl's own monospaced fixture, borrowed rather than copied. Being
* monospaced is what makes a character grid meaningful to assert on.
*/
font = TTF_OpenFont(AKBASIC_TEST_FONT, 12);
FAIL_ZERO_BREAK(errctx, font, AKGL_ERR_SDL,
"Couldn't open %s: %s", AKBASIC_TEST_FONT, SDL_GetError());
CATCH(errctx, test_draw_reaches_pixels());
CATCH(errctx, test_box_outlines());
CATCH(errctx, test_paint_fills_region());
CATCH(errctx, test_shape_roundtrip());
CATCH(errctx, test_sink_grid_and_wrap());
CATCH(errctx, test_sink_renders());
CATCH(errctx, test_input_backend());
} CLEANUP {
if ( font != NULL ) {
TTF_CloseFont(font);
}
TTF_Quit();
SDL_Quit();
} PROCESS(errctx) {
} HANDLE_DEFAULT(errctx) {
LOG_ERROR_WITH_MESSAGE(errctx, "akgl backend test failed");
akbasic_test_failures += 1;
/*
* FINISH_NORETURN rather than FINISH, matching src/main.c: FINISH
* expands a `return __err_context` that this int-returning function
* cannot compile even when the branch is unreachable. HANDLE_DEFAULT
* above has already marked the context handled, so nothing aborts.
*/
} FINISH_NORETURN(errctx);
return akbasic_test_failures;
}