/** * @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 #include #include #include #include #include #include /* * 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 #include #include #include #include #include #include #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; }