Some checks failed
akbasic CI Build / cmake_build (push) Failing after 3m22s
akbasic CI Build / sanitizers (push) Failing after 4m43s
akbasic CI Build / coverage (push) Failing after 3m43s
akbasic CI Build / akgl_build (push) Failing after 4m48s
akbasic CI Build / mutation_test (push) Failing after 3m31s
Co-authored-by: Andrew Kesterson <andrew@aklabs.net>
1435 lines
56 KiB
C
1435 lines
56 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 `akgl_renderer` global and its `akgl_default_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/heap.h>
|
|
#include <akgl/registry.h>
|
|
#include <akgl/renderer.h>
|
|
#include <akgl/text.h>
|
|
|
|
#include <akbasic/akgl.h>
|
|
#include <akbasic/error.h>
|
|
#include <akbasic/sprite.h>
|
|
#include <akbasic/runtime.h>
|
|
#include <akbasic/sink.h>
|
|
#include <akbasic/ui.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 akbasic_SpriteBackend SPRITES;
|
|
static akbasic_UiBackend UI;
|
|
static akbasic_AkglUi UISTATE;
|
|
static akbasic_AkglSprites SPRITESSTATE;
|
|
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(akgl_renderer->sdl_renderer, 0, 0, 0, 0xff),
|
|
AKGL_ERR_SDL, "%s", SDL_GetError());
|
|
FAIL_ZERO_RETURN(errctx, SDL_RenderClear(akgl_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, akgl_renderer));
|
|
PASS(errctx, akbasic_sprite_init_akgl(&SPRITES, &SPRITESSTATE, akgl_renderer, &GRAPHICSSTATE));
|
|
PASS(errctx, akbasic_runtime_set_devices(&RUNTIME, &GRAPHICS, NULL, NULL, &SPRITES));
|
|
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)
|
|
{
|
|
/*
|
|
* The sprite backend has to be torn down, not just dropped.
|
|
*
|
|
* This used to close the output file and nothing else, and got away with it
|
|
* because akbasic_sprite_init_akgl() claimed no pooled objects -- the actors
|
|
* and textures a program had created leaked, but at two or three per case
|
|
* and 64 actor slots nothing ever ran out. Claiming eight collision proxies
|
|
* per backend changed the arithmetic: twenty cases at eight apiece is a
|
|
* hundred and sixty against a pool of a hundred and twenty-eight, and the
|
|
* suite started failing in whichever case happened to be twelfth.
|
|
*
|
|
* A host releases what it took. So does this.
|
|
*/
|
|
akbasic_sprite_akgl_shutdown(&SPRITES);
|
|
/*
|
|
* And the drawing layer, for the same reason: akbasic_graphics_init_akgl()
|
|
* zeroes the state it is given, so a second start_runtime() would drop the
|
|
* pointer to a live SDL texture on the floor.
|
|
*/
|
|
akbasic_graphics_akgl_shutdown(&GRAPHICS);
|
|
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(akgl_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 The renderer's own size is the space BASIC draws into.
|
|
*
|
|
* The one test that puts a real SDL renderer behind item 9. The target here is
|
|
* 128x128 -- deliberately *smaller* than the 320x200 fallback -- so a `SCALE`
|
|
* that still divided by the old constants would put its far corner at 2.5 times
|
|
* the target's width and light nothing at all.
|
|
*/
|
|
static akerr_ErrorContext AKERR_NOIGNORE *test_size_is_the_renderer(void)
|
|
{
|
|
PREPARE_ERROR(errctx);
|
|
SDL_Surface *shot = NULL;
|
|
|
|
PASS(errctx, clear_target());
|
|
PASS(errctx, start_runtime("10 PRINT RGR(1)\n20 PRINT RGR(2)\n"));
|
|
TEST_REQUIRE_STR(OUTPUT, "128\n128\n");
|
|
stop_runtime();
|
|
|
|
PASS(errctx, clear_target());
|
|
PASS(errctx, start_runtime("10 SCALE 1, 1000, 1000\n"
|
|
"20 DRAW 1, 1000, 1000\n"));
|
|
TEST_REQUIRE_STR(OUTPUT, "");
|
|
|
|
shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL);
|
|
TEST_REQUIRE(shot != NULL, "could not read the render target back");
|
|
/*
|
|
* The far corner of the user space lands on the far corner of the target.
|
|
* The old code divided by the 320x200 constants, which would have put this
|
|
* at (400, 400) -- off a 128x128 surface entirely, lighting nothing.
|
|
*/
|
|
TEST_REQUIRE(pixel_is(shot, TARGET_SIZE - 1, TARGET_SIZE - 1, 0xff, 0xff, 0xff),
|
|
"SCALE 1,1000,1000 then DRAW 1,1000,1000 should reach the bottom-right pixel");
|
|
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(akgl_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(akgl_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(akgl_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, akgl_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, akgl_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 A character placed past a short row's end pads rather than vanishing.
|
|
*
|
|
* A row is a NUL-terminated string, so `CHAR 1, 6, 0, "#"` on an otherwise empty
|
|
* row used to store the `#` at column 6 with `text[0][0]` still `'\0'` -- and
|
|
* the render loop, which stops at the terminator, drew nothing at all. The write
|
|
* succeeded, the cursor moved, the stdout mirror showed the character, and the
|
|
* window stayed blank. That silent nothing is the trap; the documented
|
|
* truncation on the way back is fine and is asserted here too.
|
|
*
|
|
* The padding has to be spaces rather than whatever is in the buffer, because
|
|
* the buffer is not cleared between rows: the gap holds the tail of some longer
|
|
* row that used to be here, which is the second case below.
|
|
*
|
|
* TODO.md section 6 item 32.
|
|
*/
|
|
static akerr_ErrorContext AKERR_NOIGNORE *test_sink_writes_past_a_short_row(void)
|
|
{
|
|
PREPARE_ERROR(errctx);
|
|
|
|
PASS(errctx, akbasic_sink_init_akgl(&AKGLSINK, &AKGLSINKSTATE, akgl_renderer, font,
|
|
TARGET_SIZE, TARGET_SIZE));
|
|
PASS(errctx, AKGLSINK.moveto(&AKGLSINK, 6, 0));
|
|
PASS(errctx, AKGLSINK.write(&AKGLSINK, "#"));
|
|
TEST_REQUIRE_STR(AKGLSINKSTATE.text[0], " #");
|
|
|
|
/*
|
|
* Writing back over a long row still truncates it -- that half is the
|
|
* documented behaviour and the reason a program builds a row whole.
|
|
*/
|
|
PASS(errctx, AKGLSINK.clear(&AKGLSINK));
|
|
PASS(errctx, AKGLSINK.write(&AKGLSINK, "ABCDEFGHIJ"));
|
|
PASS(errctx, AKGLSINK.moveto(&AKGLSINK, 3, 0));
|
|
PASS(errctx, AKGLSINK.write(&AKGLSINK, "X"));
|
|
TEST_REQUIRE_STR(AKGLSINKSTATE.text[0], "ABCX");
|
|
|
|
/*
|
|
* And a write past *that* terminator pads with spaces rather than exposing
|
|
* the "EFGHIJ" still sitting in the buffer behind it. A pad that only
|
|
* replaced the terminator itself would leave those visible.
|
|
*/
|
|
PASS(errctx, AKGLSINK.moveto(&AKGLSINK, 7, 0));
|
|
PASS(errctx, AKGLSINK.write(&AKGLSINK, "Z"));
|
|
TEST_REQUIRE_STR(AKGLSINKSTATE.text[0], "ABCX Z");
|
|
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, akgl_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(akgl_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);
|
|
}
|
|
|
|
/**
|
|
* @brief The UI adaptor: retained widgets, a rendered frame, and a real choice.
|
|
*
|
|
* The seam this asserts is the one the whole file is about, and here it is more
|
|
* than a coordinate conversion. libakgl's UI is immediate mode and this
|
|
* interpreter is not, so what has to work is that a MENU declared once survives
|
|
* a frame bracket it never saw, and that a keystroke libakgl consumed comes back
|
|
* out through the record as an activation a BASIC program can read.
|
|
*
|
|
* The keystrokes are **keycodes, not scancodes** -- akgl_ui_menu_handle_event
|
|
* matches on `event.key.key`, and a scancode-filled event is silently ignored.
|
|
*/
|
|
static akerr_ErrorContext AKERR_NOIGNORE *test_ui_backend(void)
|
|
{
|
|
PREPARE_ERROR(errctx);
|
|
const char *items[3] = { "START", "OPTIONS", "QUIT" };
|
|
akbasic_Color fill = { 0x00, 0x00, 0x00, 0xff };
|
|
akbasic_Color edge = { 0xff, 0xff, 0xff, 0xff };
|
|
akbasic_Color ink = { 0xff, 0xff, 0xff, 0xff };
|
|
SDL_Event event;
|
|
int selected = 99;
|
|
bool activated = true;
|
|
bool consumed = false;
|
|
|
|
PASS(errctx, akbasic_ui_init_akgl(&UI, &UISTATE, akgl_renderer,
|
|
AKBASIC_TEST_FONT, 12, TARGET_SIZE, TARGET_SIZE));
|
|
|
|
/* Nothing declared yet: a frame with no widgets is still a legal frame. */
|
|
PASS(errctx, akbasic_ui_akgl_render(&UI));
|
|
|
|
PASS(errctx, UI.style(&UI, &fill, &edge, &ink, 4.0, 0.0));
|
|
PASS(errctx, UI.label(&UI, 0, AKBASIC_UI_ANCHOR_TOP_LEFT, "LIVES 3"));
|
|
PASS(errctx, UI.dialog(&UI, "A PANEL"));
|
|
PASS(errctx, UI.menu(&UI, 0, items, 3));
|
|
|
|
/* Freshly defined: first entry highlighted, nothing chosen. */
|
|
PASS(errctx, UI.menu_state(&UI, 0, &selected, &activated, false));
|
|
TEST_REQUIRE_INT(selected, 0);
|
|
TEST_REQUIRE(!activated, "a freshly defined menu should not be activated");
|
|
|
|
/*
|
|
* A whole frame, from what the verbs left behind. This is the assertion that
|
|
* the retained set is re-declarable: clay borrows the text until frame_end,
|
|
* and every pointer it is handed here belongs to UISTATE rather than to a
|
|
* BASIC value that stopped existing several statements ago.
|
|
*/
|
|
PASS(errctx, akbasic_ui_akgl_render(&UI));
|
|
|
|
/* Down, then Return -- the menu owns both while it has entries. */
|
|
memset(&event, 0, sizeof(event));
|
|
event.type = SDL_EVENT_KEY_DOWN;
|
|
event.key.key = SDLK_DOWN;
|
|
PASS(errctx, akbasic_ui_akgl_handle_event(&UI, &event, &consumed));
|
|
TEST_REQUIRE(consumed, "a menu that is up should consume Down");
|
|
|
|
memset(&event, 0, sizeof(event));
|
|
event.type = SDL_EVENT_KEY_DOWN;
|
|
event.key.key = SDLK_RETURN;
|
|
PASS(errctx, akbasic_ui_akgl_handle_event(&UI, &event, &consumed));
|
|
TEST_REQUIRE(consumed, "a menu that is up should consume Return");
|
|
|
|
PASS(errctx, UI.menu_state(&UI, 0, &selected, &activated, true));
|
|
TEST_REQUIRE_INT(selected, 1);
|
|
TEST_REQUIRE(activated, "Return should have activated the second entry");
|
|
|
|
/* Reading with clear consumed the latch; the highlight is untouched. */
|
|
PASS(errctx, UI.menu_state(&UI, 0, &selected, &activated, false));
|
|
TEST_REQUIRE_INT(selected, 1);
|
|
TEST_REQUIRE(!activated, "reading the latch should have cleared it");
|
|
|
|
/*
|
|
* Retiring the menu hands the cursor keys back. Without this a program that
|
|
* put a menu up could never take a typed line again, which is what makes the
|
|
* REPL usable at all.
|
|
*/
|
|
PASS(errctx, UI.menu(&UI, 0, NULL, 0));
|
|
memset(&event, 0, sizeof(event));
|
|
event.type = SDL_EVENT_KEY_DOWN;
|
|
event.key.key = SDLK_DOWN;
|
|
PASS(errctx, akbasic_ui_akgl_handle_event(&UI, &event, &consumed));
|
|
TEST_REQUIRE(!consumed, "a retired menu must not still be eating Down");
|
|
|
|
/* Escape is nobody's, even with a menu up. */
|
|
PASS(errctx, UI.menu(&UI, 0, items, 3));
|
|
memset(&event, 0, sizeof(event));
|
|
event.type = SDL_EVENT_KEY_DOWN;
|
|
event.key.key = SDLK_ESCAPE;
|
|
PASS(errctx, akbasic_ui_akgl_handle_event(&UI, &event, &consumed));
|
|
TEST_REQUIRE(!consumed, "Escape should reach the program, not the menu");
|
|
|
|
PASS(errctx, UI.clear(&UI));
|
|
PASS(errctx, UI.menu_state(&UI, 0, &selected, &activated, false));
|
|
TEST_REQUIRE_INT(selected, 0);
|
|
PASS(errctx, akbasic_ui_akgl_render(&UI));
|
|
|
|
/* Shutdown is idempotent, because teardown paths are already unwinding. */
|
|
akbasic_ui_akgl_shutdown(&UI);
|
|
akbasic_ui_akgl_shutdown(&UI);
|
|
SUCCEED_RETURN(errctx);
|
|
}
|
|
|
|
/**
|
|
* @brief A sprite loaded from an image file lands on the target where MOVSPR put it.
|
|
*
|
|
* The one assertion in this repository that exercises the whole file path:
|
|
* a BASIC string that is not an SSHAPE handle, resolved by akgl_path_relative()
|
|
* against the working directory, decoded by SDL_image inside
|
|
* akgl_spritesheet_initialize(), and blitted through the actor's renderfunc.
|
|
*
|
|
* The fixture is 8x8 rather than 24x21 on purpose -- a sprite from a file takes
|
|
* the image's own size, and an assertion at (43, 33) rather than (40, 30) is
|
|
* what says the size came from the image rather than from a constant.
|
|
*/
|
|
static akerr_ErrorContext AKERR_NOIGNORE *test_sprite_from_file(void)
|
|
{
|
|
PREPARE_ERROR(errctx);
|
|
SDL_Surface *shot = NULL;
|
|
|
|
PASS(errctx, clear_target());
|
|
PASS(errctx, start_runtime("10 SPRSAV \"assets/sprite8x8.png\", 1\n"
|
|
"20 SPRITE 1, 1\n"
|
|
"30 MOVSPR 1, 40, 30\n"));
|
|
TEST_REQUIRE_STR(OUTPUT, "");
|
|
PASS(errctx, akbasic_sprite_akgl_render(&SPRITES));
|
|
|
|
shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL);
|
|
TEST_REQUIRE(shot != NULL, "could not read the render target back");
|
|
/*
|
|
* Magenta, not white: SPRITE's colour argument modulates the texture and
|
|
* this program never gave one, so the image's own colour comes through.
|
|
*/
|
|
TEST_REQUIRE(pixel_is(shot, 43, 33, 0xff, 0x00, 0xff),
|
|
"the loaded sprite should be drawn at MOVSPR's coordinate");
|
|
TEST_REQUIRE(pixel_is(shot, 47, 37, 0xff, 0x00, 0xff),
|
|
"the whole 8x8 image should have been drawn, not one pixel of it");
|
|
TEST_REQUIRE(!pixel_is(shot, 49, 39, 0xff, 0x00, 0xff),
|
|
"an 8x8 sprite should stop after eight pixels");
|
|
SDL_DestroySurface(shot);
|
|
|
|
/* A path that names nothing is the program's mistake, and is reported. */
|
|
stop_runtime();
|
|
PASS(errctx, start_runtime("10 SPRSAV \"assets/nosuchfile.png\", 1\n"));
|
|
TEST_REQUIRE(strstr(OUTPUT, "No such sprite image") != NULL,
|
|
"a missing image should be reported by name, got \"%s\"", OUTPUT);
|
|
stop_runtime();
|
|
SUCCEED_RETURN(errctx);
|
|
}
|
|
|
|
/**
|
|
* @brief A sprite defined from 63 pattern bytes reaches real pixels, in its colour.
|
|
*
|
|
* The DATA-driven path a type-in listing uses, end to end: an integer array, the
|
|
* bit unpacking, a surface built a pixel at a time, and a texture. The pattern
|
|
* is one set bit in the top-left corner, so what is asserted is both that the
|
|
* pixel is lit *and* that its neighbour is not -- which is what catches a bit
|
|
* order that came through reversed.
|
|
*/
|
|
static akerr_ErrorContext AKERR_NOIGNORE *test_sprite_from_pattern(void)
|
|
{
|
|
PREPARE_ERROR(errctx);
|
|
SDL_Surface *shot = NULL;
|
|
|
|
PASS(errctx, clear_target());
|
|
PASS(errctx, start_runtime("10 DIM P#(63)\n"
|
|
"20 P#(0) = 128\n"
|
|
"30 SPRSAV P#, 1\n"
|
|
"40 SPRITE 1, 1, 6\n"
|
|
"50 MOVSPR 1, 20, 20\n"));
|
|
TEST_REQUIRE_STR(OUTPUT, "");
|
|
PASS(errctx, akbasic_sprite_akgl_render(&SPRITES));
|
|
|
|
shot = SDL_RenderReadPixels(akgl_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, 20, 20, 0x55, 0xa0, 0x49),
|
|
"the pattern's one set bit should be the sprite's top-left pixel");
|
|
TEST_REQUIRE(!pixel_is(shot, 21, 20, 0x55, 0xa0, 0x49),
|
|
"only one bit was set, so only one pixel should be lit");
|
|
TEST_REQUIRE(!pixel_is(shot, 43, 20, 0x55, 0xa0, 0x49),
|
|
"a reversed bit order would have lit the far end of the row");
|
|
SDL_DestroySurface(shot);
|
|
stop_runtime();
|
|
SUCCEED_RETURN(errctx);
|
|
}
|
|
|
|
/**
|
|
* @brief A region SSHAPE saved becomes a sprite, which is the shared-format claim.
|
|
*
|
|
* The C128 documents SPRSAV's string as the SSHAPE data format at a fixed 24x21,
|
|
* so reusing the shape pool here is faithful rather than a shortcut -- and this
|
|
* is what says the handle actually survives the trip through a BASIC string and
|
|
* into the sprite device.
|
|
*/
|
|
static akerr_ErrorContext AKERR_NOIGNORE *test_sprite_from_shape(void)
|
|
{
|
|
PREPARE_ERROR(errctx);
|
|
SDL_Surface *shot = NULL;
|
|
|
|
PASS(errctx, clear_target());
|
|
PASS(errctx, start_runtime("10 COLOR 1, 3\n"
|
|
"20 BOX 1, 0, 0, 10, 10\n"
|
|
"30 SSHAPE A$, 0, 0, 10, 10\n"
|
|
"40 SPRSAV A$, 1\n"
|
|
"50 SPRITE 1, 1\n"
|
|
"60 MOVSPR 1, 60, 60\n"));
|
|
TEST_REQUIRE_STR(OUTPUT, "");
|
|
PASS(errctx, akbasic_sprite_akgl_render(&SPRITES));
|
|
|
|
shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL);
|
|
TEST_REQUIRE(shot != NULL, "could not read the render target back");
|
|
/*
|
|
* Palette index 3 is red, which is what the BOX was outlined in. The corner
|
|
* of the box is the (0,0) pixel of the saved region, so it lands exactly
|
|
* where MOVSPR put the sprite -- and its interior does not, which is what
|
|
* says a region was copied rather than a rectangle drawn.
|
|
*/
|
|
TEST_REQUIRE(pixel_is(shot, 60, 60, 0x88, 0x39, 0x32),
|
|
"the saved region's corner should have been drawn as a sprite");
|
|
TEST_REQUIRE(!pixel_is(shot, 65, 65, 0x88, 0x39, 0x32),
|
|
"the region was an outline, so its middle should not be red");
|
|
SDL_DestroySurface(shot);
|
|
stop_runtime();
|
|
SUCCEED_RETURN(errctx);
|
|
}
|
|
|
|
/**
|
|
* @brief Run one program and report the collision mask the device computes.
|
|
*
|
|
* Straight at the backend rather than through `BUMP(1)`, because what is under
|
|
* test is the overlap arithmetic and not the accumulate-and-clear the runtime
|
|
* wraps it in -- that half is already covered by tests/sprite_verbs.c against a
|
|
* mock. Going through BUMP here would assert both at once and blame the wrong
|
|
* one when either broke.
|
|
*/
|
|
static akerr_ErrorContext AKERR_NOIGNORE *mask_for(const char *source, uint16_t *dest)
|
|
{
|
|
PREPARE_ERROR(errctx);
|
|
|
|
PASS(errctx, start_runtime(source));
|
|
TEST_REQUIRE_STR(OUTPUT, "");
|
|
PASS(errctx, SPRITES.collisions(&SPRITES, dest));
|
|
SUCCEED_RETURN(errctx);
|
|
}
|
|
|
|
/**
|
|
* @brief What spr_collisions() answers, pinned before anything replaces it.
|
|
*
|
|
* **This code had no test at all.** tests/sprite_verbs.c drives the collision
|
|
* path end to end but through a mock backend, so the real overlap arithmetic
|
|
* could have changed what BUMP(1) reports for every program in existence and the
|
|
* suite would still have passed. That is the gap this closes, and it is closed
|
|
* *before* the arithmetic is touched so there is a before to compare an after
|
|
* against.
|
|
*
|
|
* Two of the cases below are the ones a replacement is most likely to get wrong,
|
|
* and each says so where it stands.
|
|
*/
|
|
static akerr_ErrorContext AKERR_NOIGNORE *test_collision_pairs(void)
|
|
{
|
|
PREPARE_ERROR(errctx);
|
|
uint16_t mask = 0;
|
|
|
|
/* Nothing defined: no actor, so nothing to compare and nothing set. */
|
|
PASS(errctx, mask_for("10 X# = 0\n", &mask));
|
|
TEST_REQUIRE_INT(mask, 0);
|
|
stop_runtime();
|
|
|
|
/*
|
|
* Two 24x21 patterns overlapping. Bit n-1 is sprite n, so sprites 1 and 2
|
|
* are 0x03.
|
|
*/
|
|
PASS(errctx, mask_for("10 DIM P#(63)\n"
|
|
"20 FOR I# = 0 TO 62\n"
|
|
"30 P#(I#) = 255\n"
|
|
"40 NEXT I#\n"
|
|
"50 SPRSAV P#, 1\n"
|
|
"60 SPRSAV P#, 2\n"
|
|
"70 SPRITE 1, 1\n"
|
|
"80 SPRITE 2, 1\n"
|
|
"90 MOVSPR 1, 10, 10\n"
|
|
"100 MOVSPR 2, 20, 20\n", &mask));
|
|
TEST_REQUIRE_INT(mask, 0x03);
|
|
stop_runtime();
|
|
|
|
/*
|
|
* **Edge to edge is not a collision.** Sprite 1 spans x 10..33 inclusive and
|
|
* sprite 2 starts at 34, so the test is `ax + aw < bx + 1` -- the strict `<`
|
|
* in spr_collisions(). A tile-aligned program puts sprites here constantly,
|
|
* and a replacement that answers "touching" instead of "overlapping" changes
|
|
* every one of them.
|
|
*/
|
|
PASS(errctx, mask_for("10 DIM P#(63)\n"
|
|
"20 FOR I# = 0 TO 62\n"
|
|
"30 P#(I#) = 255\n"
|
|
"40 NEXT I#\n"
|
|
"50 SPRSAV P#, 1\n"
|
|
"60 SPRSAV P#, 2\n"
|
|
"70 SPRITE 1, 1\n"
|
|
"80 SPRITE 2, 1\n"
|
|
"90 MOVSPR 1, 10, 10\n"
|
|
"100 MOVSPR 2, 34, 10\n", &mask));
|
|
TEST_REQUIRE_INT(mask, 0);
|
|
stop_runtime();
|
|
|
|
/* A hidden sprite is not in the world, whatever its coordinates say. */
|
|
PASS(errctx, mask_for("10 DIM P#(63)\n"
|
|
"20 FOR I# = 0 TO 62\n"
|
|
"30 P#(I#) = 255\n"
|
|
"40 NEXT I#\n"
|
|
"50 SPRSAV P#, 1\n"
|
|
"60 SPRSAV P#, 2\n"
|
|
"70 SPRITE 1, 1\n"
|
|
"80 SPRITE 2, 0\n"
|
|
"90 MOVSPR 1, 10, 10\n"
|
|
"100 MOVSPR 2, 10, 10\n", &mask));
|
|
TEST_REQUIRE_INT(mask, 0);
|
|
stop_runtime();
|
|
|
|
/*
|
|
* The x-expand bit doubles the box that collides, not just the one that
|
|
* draws. Sprite 1 unexpanded ends at x 33 and does not reach sprite 2 at 40;
|
|
* expanded it spans 48 and does.
|
|
*/
|
|
PASS(errctx, mask_for("10 DIM P#(63)\n"
|
|
"20 FOR I# = 0 TO 62\n"
|
|
"30 P#(I#) = 255\n"
|
|
"40 NEXT I#\n"
|
|
"50 SPRSAV P#, 1\n"
|
|
"60 SPRSAV P#, 2\n"
|
|
"70 SPRITE 1, 1, 1, 0, 1, 0\n"
|
|
"80 SPRITE 2, 1\n"
|
|
"90 MOVSPR 1, 10, 10\n"
|
|
"100 MOVSPR 2, 40, 10\n", &mask));
|
|
TEST_REQUIRE_INT(mask, 0x03);
|
|
stop_runtime();
|
|
|
|
SUCCEED_RETURN(errctx);
|
|
}
|
|
|
|
/**
|
|
* @brief A cross-shaped overlap is a collision, and it is the case to watch.
|
|
*
|
|
* A tall thin sprite crossing a short wide one overlaps without either
|
|
* rectangle containing a corner of the other. libakgl's akgl_collide_rectangles()
|
|
* is documented as answering "no" here -- a corner-containment test cannot see
|
|
* it -- which is why src/sprite_akgl.c does the four comparisons itself rather
|
|
* than calling it.
|
|
*
|
|
* So this assertion is the one that fails if collision is ever moved onto a
|
|
* corner-containment implementation, and it is worth having before rather than
|
|
* after such a move.
|
|
*/
|
|
static akerr_ErrorContext AKERR_NOIGNORE *test_collision_cross_shape(void)
|
|
{
|
|
PREPARE_ERROR(errctx);
|
|
uint16_t mask = 0;
|
|