423 lines
14 KiB
C
423 lines
14 KiB
C
|
|
/**
|
||
|
|
* @file akgl_frontend.c
|
||
|
|
* @brief Tests the standalone SDL frontend: the host, not the adaptors.
|
||
|
|
*
|
||
|
|
* tests/akgl_backends.c stands in for a host and asserts that each adaptor
|
||
|
|
* reaches the right libakgl call. This file asserts the thing that was missing
|
||
|
|
* until the frontend existed: that there *is* a host, that it composes the two
|
||
|
|
* output paths so a program's bytes reach the window and stdout alike, that its
|
||
|
|
* event pump feeds the keystroke ring a script reads, that its line editor turns
|
||
|
|
* keystrokes into typed lines, and that closing the window ends a program that
|
||
|
|
* would otherwise never stop.
|
||
|
|
*
|
||
|
|
* Everything runs under the dummy video and audio drivers with a software
|
||
|
|
* renderer, following deps/libakgl/tests/draw.c, so it needs no display and no
|
||
|
|
* sound card. The font is the reference's own Commodore one -- the acceptance
|
||
|
|
* criterion in TODO.md section 3 asks for the drawn text to be in that font, and
|
||
|
|
* the frontend is the only place that opens it.
|
||
|
|
*
|
||
|
|
* Only one frontend may exist at a time: libakgl's renderer and window are
|
||
|
|
* process globals, and a host that never calls akgl_game_init() populates them
|
||
|
|
* itself. Each test therefore brings one up and takes it down again.
|
||
|
|
*/
|
||
|
|
|
||
|
|
#include <stdio.h>
|
||
|
|
#include <string.h>
|
||
|
|
|
||
|
|
#include <SDL3/SDL.h>
|
||
|
|
|
||
|
|
#include <akerror.h>
|
||
|
|
|
||
|
|
#include <akgl/actor.h>
|
||
|
|
#include <akgl/controller.h>
|
||
|
|
#include <akgl/error.h>
|
||
|
|
|
||
|
|
#include <akbasic/error.h>
|
||
|
|
#include <akbasic/frontend.h>
|
||
|
|
#include <akbasic/runtime.h>
|
||
|
|
|
||
|
|
#include "testutil.h"
|
||
|
|
|
||
|
|
/** @brief Window size. Small enough to read the whole target back cheaply. */
|
||
|
|
#define WINDOW_W 320
|
||
|
|
/** @brief Window height. */
|
||
|
|
#define WINDOW_H 200
|
||
|
|
|
||
|
|
/* Both of these carry pools far too large for a stack. */
|
||
|
|
static akbasic_AkglFrontend FRONTEND;
|
||
|
|
static akbasic_Runtime RUNTIME;
|
||
|
|
static char OUTPUT[8192];
|
||
|
|
static FILE *MIRROR = NULL;
|
||
|
|
static FILE *PROGRAM = NULL;
|
||
|
|
|
||
|
|
/** @brief Report whether any pixel in a rectangle is not the background. */
|
||
|
|
static bool anything_drawn(SDL_Surface *shot, int x0, int y0, int w, int h)
|
||
|
|
{
|
||
|
|
uint8_t r = 0;
|
||
|
|
uint8_t g = 0;
|
||
|
|
uint8_t b = 0;
|
||
|
|
uint8_t a = 0;
|
||
|
|
int x = 0;
|
||
|
|
int y = 0;
|
||
|
|
|
||
|
|
if ( shot == NULL ) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
for ( y = y0; y < y0 + h; y++ ) {
|
||
|
|
for ( x = x0; x < x0 + w; x++ ) {
|
||
|
|
if ( !SDL_ReadSurfacePixel(shot, x, y, &r, &g, &b, &a) ) {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
if ( r != 0 || g != 0 || b != 0 ) {
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @brief Queue one key-down, exactly as a keyboard would.
|
||
|
|
*
|
||
|
|
* Straight into SDL's own queue rather than into libakgl's ring, so the
|
||
|
|
* frontend's event pump is the thing under test: nothing reaches the
|
||
|
|
* interpreter unless pump_events() drained this and handed it over.
|
||
|
|
*/
|
||
|
|
static void push_key(int keycode)
|
||
|
|
{
|
||
|
|
SDL_Event event;
|
||
|
|
|
||
|
|
memset(&event, 0, sizeof(event));
|
||
|
|
event.type = SDL_EVENT_KEY_DOWN;
|
||
|
|
event.key.key = keycode;
|
||
|
|
SDL_PushEvent(&event);
|
||
|
|
}
|
||
|
|
|
||
|
|
/** @brief Queue a whole typed line, terminator included. */
|
||
|
|
static void push_line(const char *text)
|
||
|
|
{
|
||
|
|
size_t i = 0;
|
||
|
|
|
||
|
|
for ( i = 0; text[i] != '\0'; i++ ) {
|
||
|
|
push_key((int)(unsigned char)text[i]);
|
||
|
|
}
|
||
|
|
push_key('\r');
|
||
|
|
}
|
||
|
|
|
||
|
|
/** @brief Queue the event a window manager sends when the close button is hit. */
|
||
|
|
static void push_quit(void)
|
||
|
|
{
|
||
|
|
SDL_Event event;
|
||
|
|
|
||
|
|
memset(&event, 0, sizeof(event));
|
||
|
|
event.type = SDL_EVENT_QUIT;
|
||
|
|
SDL_PushEvent(&event);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @brief Bring up a frontend whose mirror is a buffer this file can read.
|
||
|
|
*
|
||
|
|
* @param source A program to feed through the stdio half, or NULL to type it.
|
||
|
|
*/
|
||
|
|
static akerr_ErrorContext AKERR_NOIGNORE *start_frontend(const char *source)
|
||
|
|
{
|
||
|
|
PREPARE_ERROR(errctx);
|
||
|
|
|
||
|
|
memset(OUTPUT, 0, sizeof(OUTPUT));
|
||
|
|
MIRROR = fmemopen(OUTPUT, sizeof(OUTPUT), "w");
|
||
|
|
FAIL_ZERO_RETURN(errctx, (MIRROR != NULL), AKERR_IO, "could not open the mirror buffer");
|
||
|
|
setvbuf(MIRROR, NULL, _IONBF, 0);
|
||
|
|
|
||
|
|
PROGRAM = NULL;
|
||
|
|
if ( source != NULL ) {
|
||
|
|
PROGRAM = fmemopen((void *)(uintptr_t)source, strlen(source), "r");
|
||
|
|
FAIL_ZERO_RETURN(errctx, (PROGRAM != NULL), AKERR_IO,
|
||
|
|
"could not open the program buffer");
|
||
|
|
}
|
||
|
|
|
||
|
|
PASS(errctx, akbasic_frontend_akgl_init(&FRONTEND, "akbasic frontend test",
|
||
|
|
WINDOW_W, WINDOW_H,
|
||
|
|
AKBASIC_TEST_C64_FONT,
|
||
|
|
AKBASIC_FRONTEND_FONT_SIZE,
|
||
|
|
MIRROR, PROGRAM));
|
||
|
|
PASS(errctx, akbasic_frontend_akgl_attach(&FRONTEND, &RUNTIME));
|
||
|
|
SUCCEED_RETURN(errctx);
|
||
|
|
}
|
||
|
|
|
||
|
|
static void stop_frontend(void)
|
||
|
|
{
|
||
|
|
akbasic_frontend_akgl_shutdown(&FRONTEND);
|
||
|
|
if ( MIRROR != NULL ) {
|
||
|
|
fclose(MIRROR);
|
||
|
|
MIRROR = NULL;
|
||
|
|
}
|
||
|
|
if ( PROGRAM != NULL ) {
|
||
|
|
fclose(PROGRAM);
|
||
|
|
PROGRAM = NULL;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @brief A program run through the frontend reaches stdout *and* the window.
|
||
|
|
*
|
||
|
|
* This is the assertion the whole frontend exists for, and it is two assertions
|
||
|
|
* on purpose: the mirrored bytes are what the golden corpus compares, and the
|
||
|
|
* lit pixels are what a user actually sees. Before the frontend existed an AKGL
|
||
|
|
* build produced the first and not the second.
|
||
|
|
*/
|
||
|
|
static akerr_ErrorContext AKERR_NOIGNORE *test_program_reaches_both(void)
|
||
|
|
{
|
||
|
|
PREPARE_ERROR(errctx);
|
||
|
|
SDL_Surface *shot = NULL;
|
||
|
|
|
||
|
|
PASS(errctx, start_frontend("10 PRINT \"HELLO\"\n20 QUIT\n"));
|
||
|
|
PASS(errctx, akbasic_runtime_start(&RUNTIME, AKBASIC_MODE_RUNSTREAM));
|
||
|
|
PASS(errctx, akbasic_frontend_akgl_drive(&FRONTEND, &RUNTIME));
|
||
|
|
|
||
|
|
/* Byte for byte, exactly what the stdio-only driver would have written. */
|
||
|
|
TEST_REQUIRE_STR(OUTPUT, "HELLO\n");
|
||
|
|
/* And the same characters are in the grid the window draws from. */
|
||
|
|
TEST_REQUIRE_STR(FRONTEND.akglstate.text[0], "HELLO");
|
||
|
|
|
||
|
|
/*
|
||
|
|
* Asserted as "the first five cells have something in them and the sixth
|
||
|
|
* does not", rather than against particular pixels: which ones a glyph
|
||
|
|
* lights is FreeType's business. What is being proved is that five
|
||
|
|
* characters of Commodore font reached the renderer where the sink's cursor
|
||
|
|
* said they would.
|
||
|
|
*/
|
||
|
|
shot = SDL_RenderReadPixels(FRONTEND.renderer->sdl_renderer, NULL);
|
||
|
|
TEST_REQUIRE(shot != NULL, "could not read the render target back");
|
||
|
|
TEST_REQUIRE(anything_drawn(shot, 0, 0,
|
||
|
|
5 * FRONTEND.akglstate.cellw,
|
||
|
|
FRONTEND.akglstate.cellh),
|
||
|
|
"HELLO should have been drawn into the first five character cells");
|
||
|
|
TEST_REQUIRE(!anything_drawn(shot, 0,
|
||
|
|
3 * FRONTEND.akglstate.cellh,
|
||
|
|
WINDOW_W, FRONTEND.akglstate.cellh),
|
||
|
|
"nothing was printed on the fourth row, so nothing should be drawn there");
|
||
|
|
SDL_DestroySurface(shot);
|
||
|
|
|
||
|
|
stop_frontend();
|
||
|
|
SUCCEED_RETURN(errctx);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @brief The event pump is what carries a keystroke to the interpreter.
|
||
|
|
*
|
||
|
|
* Pushed into SDL's queue and read back through the frontend's own input
|
||
|
|
* backend, so every link in the chain the host owns is in the path: SDL queue,
|
||
|
|
* pump_events, akgl_controller_handle_event, the ring, the backend.
|
||
|
|
*/
|
||
|
|
static akerr_ErrorContext AKERR_NOIGNORE *test_pump_feeds_input(void)
|
||
|
|
{
|
||
|
|
PREPARE_ERROR(errctx);
|
||
|
|
bool running = false;
|
||
|
|
bool available = false;
|
||
|
|
int keycode = 0;
|
||
|
|
|
||
|
|
PASS(errctx, start_frontend("10 QUIT\n"));
|
||
|
|
PASS(errctx, FRONTEND.input.flush_keys(&FRONTEND.input));
|
||
|
|
|
||
|
|
/* Nothing pumped yet: the ring is empty and that is success, not an error. */
|
||
|
|
PASS(errctx, FRONTEND.input.poll_key(&FRONTEND.input, &keycode, &available));
|
||
|
|
TEST_REQUIRE(!available, "an unpumped frontend should have no keys waiting");
|
||
|
|
|
||
|
|
push_key(SDLK_A);
|
||
|
|
PASS(errctx, akbasic_frontend_akgl_pump(&FRONTEND, &running));
|
||
|
|
TEST_REQUIRE(running, "a key event should not stop the frontend");
|
||
|
|
|
||
|
|
PASS(errctx, FRONTEND.input.poll_key(&FRONTEND.input, &keycode, &available));
|
||
|
|
TEST_REQUIRE(available, "the pump should have handed the key to the interpreter");
|
||
|
|
TEST_REQUIRE_INT(keycode, SDLK_A);
|
||
|
|
|
||
|
|
stop_frontend();
|
||
|
|
SUCCEED_RETURN(errctx);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @brief Closing the window stops a program that would otherwise never stop.
|
||
|
|
*
|
||
|
|
* `10 GOTO 10` is the case that matters: an unbounded run() would own the
|
||
|
|
* process forever and the close button would do nothing. The bounded frame loop
|
||
|
|
* is what makes this test finish at all.
|
||
|
|
*/
|
||
|
|
static akerr_ErrorContext AKERR_NOIGNORE *test_window_close_stops(void)
|
||
|
|
{
|
||
|
|
PREPARE_ERROR(errctx);
|
||
|
|
bool running = true;
|
||
|
|
|
||
|
|
PASS(errctx, start_frontend("10 GOTO 10\n"));
|
||
|
|
PASS(errctx, akbasic_runtime_load(&RUNTIME, "10 GOTO 10\n"));
|
||
|
|
PASS(errctx, akbasic_runtime_start(&RUNTIME, AKBASIC_MODE_RUN));
|
||
|
|
|
||
|
|
push_quit();
|
||
|
|
PASS(errctx, akbasic_frontend_akgl_drive(&FRONTEND, &RUNTIME));
|
||
|
|
TEST_REQUIRE(!FRONTEND.running, "the close event should have stopped the frontend");
|
||
|
|
TEST_REQUIRE(RUNTIME.mode != AKBASIC_MODE_QUIT,
|
||
|
|
"closing the window stops the host, it does not QUIT the script");
|
||
|
|
|
||
|
|
/* And a pump after the close keeps reporting the same thing. */
|
||
|
|
PASS(errctx, akbasic_frontend_akgl_pump(&FRONTEND, &running));
|
||
|
|
TEST_REQUIRE(!running, "a closed frontend stays closed");
|
||
|
|
|
||
|
|
stop_frontend();
|
||
|
|
SUCCEED_RETURN(errctx);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @brief The line editor turns keystrokes into a line, echoed as it is typed.
|
||
|
|
*
|
||
|
|
* Read through the sink directly rather than through the REPL, because what is
|
||
|
|
* being asserted here is the editing itself: the fold to upper case that the
|
||
|
|
* ring's missing modifier state forces, the backspace, and the echo landing in
|
||
|
|
* the grid where the cursor says it is.
|
||
|
|
*/
|
||
|
|
static akerr_ErrorContext AKERR_NOIGNORE *test_line_editor(void)
|
||
|
|
{
|
||
|
|
PREPARE_ERROR(errctx);
|
||
|
|
char line[64];
|
||
|
|
bool eof = true;
|
||
|
|
|
||
|
|
PASS(errctx, start_frontend(NULL));
|
||
|
|
PASS(errctx, FRONTEND.input.flush_keys(&FRONTEND.input));
|
||
|
|
|
||
|
|
/* "prXnt" with the X backspaced away, typed in lower case. */
|
||
|
|
push_key('p');
|
||
|
|
push_key('r');
|
||
|
|
push_key('x');
|
||
|
|
push_key('\b');
|
||
|
|
push_key('i');
|
||
|
|
push_key('n');
|
||
|
|
push_key('t');
|
||
|
|
push_key('\r');
|
||
|
|
|
||
|
|
PASS(errctx, FRONTEND.akglsink.readline(&FRONTEND.akglsink, line, sizeof(line), &eof));
|
||
|
|
TEST_REQUIRE(!eof, "a submitted line is not end of input");
|
||
|
|
/*
|
||
|
|
* Upper case because akgl_controller_poll_key() reports a keycode and no
|
||
|
|
* modifier state, so Shift is invisible and lower case is unreachable. A
|
||
|
|
* C128 is upper case too, which is why this is the right resolution rather
|
||
|
|
* than merely the available one. Filed upstream.
|
||
|
|
*/
|
||
|
|
TEST_REQUIRE_STR(line, "PRINT");
|
||
|
|
/* The echo is in the grid, and the backspace really removed a character. */
|
||
|
|
TEST_REQUIRE_STR(FRONTEND.akglstate.text[0], "PRINT");
|
||
|
|
TEST_REQUIRE_INT(FRONTEND.akglstate.cursorrow, 1);
|
||
|
|
TEST_REQUIRE_INT(FRONTEND.akglstate.cursorcol, 0);
|
||
|
|
TEST_REQUIRE(!FRONTEND.akglstate.editing, "the editor should be idle once the line is in");
|
||
|
|
|
||
|
|
/* Escape abandons a line rather than submitting it. */
|
||
|
|
push_key('z');
|
||
|
|
push_key(0x1b);
|
||
|
|
push_key('o');
|
||
|
|
push_key('k');
|
||
|
|
push_key('\r');
|
||
|
|
PASS(errctx, FRONTEND.akglsink.readline(&FRONTEND.akglsink, line, sizeof(line), &eof));
|
||
|
|
TEST_REQUIRE_STR(line, "OK");
|
||
|
|
TEST_REQUIRE_STR(FRONTEND.akglstate.text[1], "OK");
|
||
|
|
|
||
|
|
/* A closed window ends the wait, reported as end of input rather than raised. */
|
||
|
|
push_quit();
|
||
|
|
PASS(errctx, FRONTEND.akglsink.readline(&FRONTEND.akglsink, line, sizeof(line), &eof));
|
||
|
|
TEST_REQUIRE(eof, "closing the window during an INPUT is end of input");
|
||
|
|
|
||
|
|
stop_frontend();
|
||
|
|
SUCCEED_RETURN(errctx);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @brief A whole REPL session typed at the window, ending with QUIT.
|
||
|
|
*
|
||
|
|
* The end-to-end case: keystrokes into SDL's queue, out through the editor, into
|
||
|
|
* the interpreter, and the result on both output paths. Nothing here is mocked
|
||
|
|
* except the keyboard and the mirror.
|
||
|
|
*/
|
||
|
|
static akerr_ErrorContext AKERR_NOIGNORE *test_repl_session(void)
|
||
|
|
{
|
||
|
|
PREPARE_ERROR(errctx);
|
||
|
|
|
||
|
|
PASS(errctx, start_frontend(NULL));
|
||
|
|
PASS(errctx, FRONTEND.input.flush_keys(&FRONTEND.input));
|
||
|
|
|
||
|
|
/*
|
||
|
|
* Every keystroke queued up front, and the session ended with a typed QUIT
|
||
|
|
* rather than a close event: the pump drains the whole SDL queue in one
|
||
|
|
* call, so a close event queued here would be seen before the first
|
||
|
|
* keystroke was ever read.
|
||
|
|
*/
|
||
|
|
push_line("10 print 1");
|
||
|
|
push_line("run");
|
||
|
|
push_line("quit");
|
||
|
|
|
||
|
|
PASS(errctx, akbasic_runtime_start(&RUNTIME, AKBASIC_MODE_REPL));
|
||
|
|
PASS(errctx, akbasic_frontend_akgl_drive(&FRONTEND, &RUNTIME));
|
||
|
|
|
||
|
|
TEST_REQUIRE_INT(RUNTIME.mode, AKBASIC_MODE_QUIT);
|
||
|
|
/* The reference prints READY at the prompt and after a RUN. */
|
||
|
|
TEST_REQUIRE_STR(OUTPUT, "READY\n1\nREADY\n");
|
||
|
|
|
||
|
|
stop_frontend();
|
||
|
|
SUCCEED_RETURN(errctx);
|
||
|
|
}
|
||
|
|
|
||
|
|
/** @brief Every entry point validates its pointers before touching SDL. */
|
||
|
|
static akerr_ErrorContext AKERR_NOIGNORE *test_arguments_refused(void)
|
||
|
|
{
|
||
|
|
PREPARE_ERROR(errctx);
|
||
|
|
bool running = false;
|
||
|
|
|
||
|
|
TEST_REQUIRE_STATUS(akbasic_frontend_akgl_init(NULL, "t", WINDOW_W, WINDOW_H,
|
||
|
|
AKBASIC_TEST_C64_FONT, 16, NULL, NULL),
|
||
|
|
AKERR_NULLPOINTER);
|
||
|
|
TEST_REQUIRE_STATUS(akbasic_frontend_akgl_init(&FRONTEND, "t", WINDOW_W, WINDOW_H,
|
||
|
|
NULL, 16, NULL, NULL),
|
||
|
|
AKERR_NULLPOINTER);
|
||
|
|
/* A zero-point font would divide by zero measuring the character grid. */
|
||
|
|
TEST_REQUIRE_STATUS(akbasic_frontend_akgl_init(&FRONTEND, "t", WINDOW_W, WINDOW_H,
|
||
|
|
AKBASIC_TEST_C64_FONT, 0, NULL, NULL),
|
||
|
|
AKBASIC_ERR_VALUE);
|
||
|
|
TEST_REQUIRE_STATUS(akbasic_frontend_akgl_attach(NULL, &RUNTIME), AKERR_NULLPOINTER);
|
||
|
|
TEST_REQUIRE_STATUS(akbasic_frontend_akgl_pump(NULL, &running), AKERR_NULLPOINTER);
|
||
|
|
TEST_REQUIRE_STATUS(akbasic_frontend_akgl_drive(&FRONTEND, NULL), AKERR_NULLPOINTER);
|
||
|
|
/* Shutting down something that was never brought up is not an error. */
|
||
|
|
akbasic_frontend_akgl_shutdown(NULL);
|
||
|
|
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());
|
||
|
|
CATCH(errctx, akbasic_error_register());
|
||
|
|
|
||
|
|
CATCH(errctx, test_arguments_refused());
|
||
|
|
CATCH(errctx, test_program_reaches_both());
|
||
|
|
CATCH(errctx, test_pump_feeds_input());
|
||
|
|
CATCH(errctx, test_window_close_stops());
|
||
|
|
CATCH(errctx, test_line_editor());
|
||
|
|
CATCH(errctx, test_repl_session());
|
||
|
|
} CLEANUP {
|
||
|
|
stop_frontend();
|
||
|
|
} PROCESS(errctx) {
|
||
|
|
} HANDLE_DEFAULT(errctx) {
|
||
|
|
LOG_ERROR_WITH_MESSAGE(errctx, "akgl frontend test failed");
|
||
|
|
akbasic_test_failures += 1;
|
||
|
|
/*
|
||
|
|
* FINISH_NORETURN rather than FINISH, matching tests/akgl_backends.c:
|
||
|
|
* FINISH expands a `return __err_context` that this int-returning
|
||
|
|
* function cannot compile even where the branch is unreachable.
|
||
|
|
* HANDLE_DEFAULT has already marked the context handled, so nothing
|
||
|
|
* aborts.
|
||
|
|
*/
|
||
|
|
} FINISH_NORETURN(errctx);
|
||
|
|
|
||
|
|
return akbasic_test_failures;
|
||
|
|
}
|