Port the standalone SDL frontend, and give the sink a line editor
An AKGL build of `basic` was a terminal program with unused SDL linked into it. It now opens the reference's 800x600 window, draws BASIC output in the Commodore font, pumps events, lets you type at it, and still mirrors every byte to stdout. The stdout mirror is a composing sink rather than a second write inside the interpreter, and lives in the core library where it needs no SDL. The line editor waits for a typed line by borrowing one frame at a time from the host, so nothing blocks and nothing owns an event loop it should not. The whole golden corpus now runs through the SDL binary as well as the stdio one, byte for byte. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
422
tests/akgl_frontend.c
Normal file
422
tests/akgl_frontend.c
Normal file
@@ -0,0 +1,422 @@
|
||||
/**
|
||||
* @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;
|
||||
}
|
||||
Reference in New Issue
Block a user