0.3.0 closed all ten API gaps this port had filed. The four workarounds go with them: the CMake block that declared libakgl's vendored dependencies by hand, the akgl/actor.h include in three files, and the six vtable pointers assigned by hand in two more, now akgl_render_bind2d(). Two gaps were capabilities rather than inconveniences, and both are now real: The line editor takes the composed UTF-8 text the ring carries in preference to the keycode, so shifted characters, keyboard layouts, compose keys and dead keys all work. A double quote can be typed, which means a BASIC string literal can be typed -- the sharp end of the old limitation. Letters are no longer folded to upper case. SOUND's dir/min/step reach akgl_audio_sweep instead of being refused. dir 3 sweeps once rather than oscillating and TODO.md section 5 says so. A backend with no sweep still refuses the swept note and plays the held one. The adaptors now carry an AKGL_VERSION_AT_LEAST(0, 3, 0) floor, verified by temporarily demanding 0.4.0 and watching it fire. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
404 lines
14 KiB
C
404 lines
14 KiB
C
/**
|
|
* @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/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());
|
|
/*
|
|
* Bind the 2D methods. 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. These six pointers were
|
|
* assigned by hand here until libakgl 0.3.0 split akgl_render_bind2d out
|
|
* of akgl_render_init2d -- API-gap item 7, filed from this file.
|
|
*/
|
|
CATCH(errctx, akgl_render_bind2d(renderer));
|
|
|
|
/*
|
|
* 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;
|
|
}
|