Files
akbasic/tests/akgl_frontend.c

811 lines
30 KiB
C
Raw Normal View History

/**
* @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/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.
*
* Key-down only, with no composed text -- which is what a cursor key, a function
* key or a bare modifier produces.
*/
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 printable keystroke: the key-down *and* the text it composed to.
*
* Two events, because that is what a real keyboard produces and what libakgl
* assembles a keystroke from -- SDL_EVENT_KEY_DOWN records the press and
* SDL_EVENT_TEXT_INPUT delivers the character the layout, the shift state, a
* compose key and a dead key between them worked out. A keycode alone cannot
* express any of that, which is why the ring carries both.
*
* `text` is deliberately allowed to differ from `keycode`: that is how a shifted
* character and a non-US layout are tested at all.
*/
static void push_text_key(int keycode, const char *text)
{
SDL_Event event;
push_key(keycode);
memset(&event, 0, sizeof(event));
event.type = SDL_EVENT_TEXT_INPUT;
event.text.text = text;
SDL_PushEvent(&event);
}
/**
* @brief A one-character string with static storage, for one printable byte.
*
* SDL_PushEvent copies the SDL_Event but *not* the string an
* SDL_EVENT_TEXT_INPUT points at, so the text has to outlive the call that
* queued it and must not be reused before the queue is drained. A local buffer
* is neither: the first attempt at push_line() used one and every event in the
* line ended up pointing at the same overwritten two bytes, which showed up as
* a REPL that never received a line and a test that hung rather than failed.
*/
static const char *one_char(unsigned char c)
{
static char table[128][2];
static bool built = false;
int i = 0;
if ( !built ) {
for ( i = 0; i < 128; i++ ) {
table[i][0] = (char)i;
table[i][1] = '\0';
}
built = true;
}
return table[c & 0x7f];
}
/** @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_text_key((int)(unsigned char)text[i], one_char((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: that the *composed text* is what
* gets typed rather than the keycode, the backspace, the escape, 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. Lower case survives, which it did not
* before libakgl 0.3.0 -- the editor folded everything to upper case because
* a keycode was all it had. */
push_text_key('p', "p");
push_text_key('r', "r");
push_text_key('x', "x");
push_key(SDLK_BACKSPACE);
push_text_key('i', "i");
push_text_key('n', "n");
push_text_key('t', "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");
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");
/*
* A shifted character, which is the whole point of the 0.3.0 keystroke API:
* the key is still SDLK_2 and the character is '"'. Without the composed
* text there is no way to tell those apart, and a BASIC string literal could
* not be typed at the window at all.
*/
push_text_key(SDLK_2, "\"");
push_text_key('h', "H");
push_text_key(SDLK_2, "\"");
push_key('\r');
PASS(errctx, FRONTEND.akglsink.readline(&FRONTEND.akglsink, line, sizeof(line), &eof));
TEST_REQUIRE_STR(line, "\"H\"");
/*
* And a key whose composed character has nothing to do with its keycode,
* which is what a non-US layout looks like: AZERTY's SDLK_Q types "a".
*/
push_text_key(SDLK_Q, "a");
push_key('\r');
PASS(errctx, FRONTEND.akglsink.readline(&FRONTEND.akglsink, line, sizeof(line), &eof));
TEST_REQUIRE_STR(line, "a");
/* A key that composes to nothing is not typed: an arrow is not a character. */
push_key(SDLK_UP);
push_key(SDLK_LEFT);
push_text_key('z', "z");
push_key('\r');
PASS(errctx, FRONTEND.akglsink.readline(&FRONTEND.akglsink, line, sizeof(line), &eof));
TEST_REQUIRE_STR(line, "z");
/* Escape abandons a line rather than submitting it. */
push_text_key('z', "z");
push_key(SDLK_ESCAPE);
push_text_key('o', "o");
push_text_key('k', "k");
push_key('\r');
PASS(errctx, FRONTEND.akglsink.readline(&FRONTEND.akglsink, line, sizeof(line), &eof));
TEST_REQUIRE_STR(line, "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 The frontend starts SDL text input, and the editor survives it not being on.
*
* **This is the regression test for a bug the rest of this file could not catch.**
* Every other test here pushes SDL_EVENT_TEXT_INPUT into the queue by hand,
* which is what a real keyboard produces -- but only once text input has been
* *started*. SDL3 has it off by default and per-window, so a host that never
* calls SDL_StartTextInput() gets no such events at all, every keystroke reaches
* the ring with an empty `text`, and an editor that treats that as "not a
* character" is silently dead. That is exactly what shipped: no echo in the
* window, and nothing on stdout either, because RUN could never be typed.
*
* Two assertions, because there are two independent things wrong with that:
* the frontend must turn text input on, and the editor must not be helpless if
* it is ever off.
*/
static akerr_ErrorContext AKERR_NOIGNORE *test_text_input_is_started(void)
{
PREPARE_ERROR(errctx);
char line[64];
bool eof = true;
PASS(errctx, start_frontend(NULL));
/* The frontend's job: SDL will not emit a text-input event without this. */
TEST_REQUIRE(SDL_TextInputActive(FRONTEND.window),
"the frontend must start SDL text input, or nothing can be typed");
/*
* The editor's job: keystrokes carrying *no* composed text still type,
* folded to upper case, which is all a bare keycode can offer. push_key()
* sends the key-down alone, so this is precisely the shape the broken build
* saw for every single key.
*/
PASS(errctx, FRONTEND.input.flush_keys(&FRONTEND.input));
push_key('r');
push_key('u');
push_key('n');
push_key('\r');
PASS(errctx, FRONTEND.akglsink.readline(&FRONTEND.akglsink, line, sizeof(line), &eof));
TEST_REQUIRE(!eof, "a text-less line is still a line");
TEST_REQUIRE_STR(line, "RUN");
TEST_REQUIRE_STR(FRONTEND.akglstate.text[0], "RUN");
/* And a text-less key that is not printable is still not a character. */
push_key(SDLK_F1);
push_key(SDLK_RIGHT);
push_key('a');
push_key('\r');
PASS(errctx, FRONTEND.akglsink.readline(&FRONTEND.akglsink, line, sizeof(line), &eof));
TEST_REQUIRE_STR(line, "A");
stop_frontend();
SUCCEED_RETURN(errctx);
}
Erase what the text layer drew: three GUI bugs, one cause The sink painted glyphs on top of whatever was already on the renderer and never erased anything, and the host deliberately does not clear the frame so a DRAW survives. The grid was always correct; the screen kept the previous frame. That surfaced as three separate-looking faults: - a backspaced character stayed on screen, including across a line wrap - a scroll left the old rows behind, "papered over with garbage" - the editing cursor smeared an underscore along every column it passed through, which read as an underline under the text being typed render() now fills each row it is about to draw, and each row that has emptied since it last drew, with the background first. Row by row rather than one clear over the whole area: the text layer is authoritative over the rows it occupies and leaves every other pixel alone, so a picture behind the text loses only the strips the text uses instead of all of it. echo_line() also truncates rows that hold nothing but the spaces its own erase pass wrote, which is what backspacing back across a wrap leaves behind. A row of spaces is text as far as render() is concerned, so it would have been repainted forever and kept erasing whatever was under it. The cursor glyph is removed outright, as asked. The smearing was the erase bug rather than the cursor, so one could come back and behave -- a block would read better than an underscore. Four pixel-level tests, because grid assertions could never have caught any of this. Each was checked by reverting the fix and watching it fail; two of them were vacuous when first written and are noted as such where they are fixed. The real-keyboard test also now rubs out a character and scrolls forty lines. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 15:03:37 -04:00
/** @brief True when every pixel of one character cell is the background. */
static bool cell_is_blank(SDL_Surface *shot, akbasic_AkglSink *sink, int col, int row)
{
return !anything_drawn(shot, sink->x + (col * sink->cellw),
sink->y + (row * sink->cellh),
sink->cellw, sink->cellh);
}
/**
* @brief What the sink drew last frame is erased before it draws again.
*
* **Three reported symptoms, one cause.** The sink used to paint glyphs on top
* of whatever was already on the renderer and never erase anything, and the
* frontend deliberately does not clear the frame (§5 deviation 26, so a DRAW
* survives). The grid was always right; the *screen* kept the previous frame:
*
* - a backspaced character stayed on screen, so backspace looked broken
* - a scrolled line left its old pixels behind, "papered over with garbage"
* - the editing cursor left an underscore at every column it passed through,
* which read as an underline under the whole line being typed
*
* These assertions are about *pixels*, deliberately. Every other sink test
* checks the character grid, and the grid was never the problem.
*/
static akerr_ErrorContext AKERR_NOIGNORE *test_render_erases_what_it_drew(void)
{
PREPARE_ERROR(errctx);
SDL_Surface *shot = NULL;
akbasic_AkglSink *sink = NULL;
int row = 0;
PASS(errctx, start_frontend(NULL));
sink = &FRONTEND.akglstate;
/* Something on screen to erase. */
PASS(errctx, FRONTEND.akglsink.write(&FRONTEND.akglsink, "XYZ"));
PASS(errctx, akbasic_sink_akgl_render(&FRONTEND.akglsink));
shot = SDL_RenderReadPixels(FRONTEND.renderer->sdl_renderer, NULL);
TEST_REQUIRE(shot != NULL, "could not read the render target back");
TEST_REQUIRE(!cell_is_blank(shot, sink, 0, 0), "XYZ should have been drawn");
SDL_DestroySurface(shot);
/*
* A clear must reach the glass, not just the grid. This is SCNCLR, and it is
* the simplest shape of the bug: the grid empties and the pixels stay.
*/
PASS(errctx, FRONTEND.akglsink.clear(&FRONTEND.akglsink));
PASS(errctx, akbasic_sink_akgl_render(&FRONTEND.akglsink));
shot = SDL_RenderReadPixels(FRONTEND.renderer->sdl_renderer, NULL);
TEST_REQUIRE(shot != NULL, "could not read the render target back");
for ( row = 0; row < 3; row++ ) {
TEST_REQUIRE(cell_is_blank(shot, sink, 0, row),
"row %d still shows its old pixels after a clear", row);
}
SDL_DestroySurface(shot);
/* A line that gets *shorter* erases the tail it no longer occupies. */
PASS(errctx, FRONTEND.akglsink.write(&FRONTEND.akglsink, "ABCD"));
PASS(errctx, akbasic_sink_akgl_render(&FRONTEND.akglsink));
PASS(errctx, FRONTEND.akglsink.clear(&FRONTEND.akglsink));
PASS(errctx, FRONTEND.akglsink.write(&FRONTEND.akglsink, "AB"));
PASS(errctx, akbasic_sink_akgl_render(&FRONTEND.akglsink));
shot = SDL_RenderReadPixels(FRONTEND.renderer->sdl_renderer, NULL);
TEST_REQUIRE(shot != NULL, "could not read the render target back");
TEST_REQUIRE(!cell_is_blank(shot, sink, 0, 0), "AB should still be drawn");
TEST_REQUIRE(cell_is_blank(shot, sink, 2, 0),
"the third cell held a C and must now be blank");
TEST_REQUIRE(cell_is_blank(shot, sink, 3, 0),
"the fourth cell held a D and must now be blank");
SDL_DestroySurface(shot);
stop_frontend();
SUCCEED_RETURN(errctx);
}
/**
* @brief Scrolling reaches the glass: the bottom row is blank after it scrolls.
*
* `scroll()` shifts the grid up and clears the last row, and that was always
* correct. What was not correct was the screen: nothing erased the row's old
* pixels, so the text appeared to be overwritten with garbage rather than
* scrolled.
*/
static akerr_ErrorContext AKERR_NOIGNORE *test_scroll_reaches_the_screen(void)
{
PREPARE_ERROR(errctx);
SDL_Surface *shot = NULL;
akbasic_AkglSink *sink = NULL;
int i = 0;
PASS(errctx, start_frontend(NULL));
sink = &FRONTEND.akglstate;
/*
* Fill every row *and render the bottom one* before scrolling it away. The
* first version of this test wrote all the rows in one loop, which scrolled
* the bottom row off before it had ever been drawn -- so there were no stale
* pixels to find and the test passed whether or not the bug was there. It is
* only a test of the scroll if the row reaches the glass first.
*/
for ( i = 0; i < sink->rows - 1; i++ ) {
PASS(errctx, FRONTEND.akglsink.writeln(&FRONTEND.akglsink, "FULL"));
}
PASS(errctx, FRONTEND.akglsink.write(&FRONTEND.akglsink, "BOTTOM"));
PASS(errctx, akbasic_sink_akgl_render(&FRONTEND.akglsink));
shot = SDL_RenderReadPixels(FRONTEND.renderer->sdl_renderer, NULL);
TEST_REQUIRE(shot != NULL, "could not read the render target back");
TEST_REQUIRE(!cell_is_blank(shot, sink, 0, sink->rows - 1),
"the bottom row should have been drawn before we scroll it");
SDL_DestroySurface(shot);
/* Now scroll it away. The grid clears the bottom row; the screen must too. */
PASS(errctx, FRONTEND.akglsink.writeln(&FRONTEND.akglsink, ""));
PASS(errctx, akbasic_sink_akgl_render(&FRONTEND.akglsink));
shot = SDL_RenderReadPixels(FRONTEND.renderer->sdl_renderer, NULL);
TEST_REQUIRE(shot != NULL, "could not read the render target back");
TEST_REQUIRE_STR(sink->text[sink->rows - 1], "");
TEST_REQUIRE(cell_is_blank(shot, sink, 0, sink->rows - 1),
"the bottom row scrolled away but its pixels are still on screen");
SDL_DestroySurface(shot);
stop_frontend();
SUCCEED_RETURN(errctx);
}
/**
* @brief Backspace erases across a line wrap, taking the cursor to the row above.
*
* Called out explicitly in the report: backspace has to clear the character and
* step back "at all times, including when the backspace key would move the
* cursor up to the previous line on the Y axis". A line long enough to wrap puts
* the tail of the edit on the row below its anchor, and backspacing past the
* wrap has to erase there and return the cursor to the row above.
*/
static akerr_ErrorContext AKERR_NOIGNORE *test_backspace_across_a_wrap(void)
{
PREPARE_ERROR(errctx);
SDL_Surface *shot = NULL;
akbasic_AkglSink *sink = NULL;
char line[256];
bool eof = true;
int over = 0;
int i = 0;
PASS(errctx, start_frontend(NULL));
sink = &FRONTEND.akglstate;
PASS(errctx, FRONTEND.input.flush_keys(&FRONTEND.input));
/* Two characters past the right-hand edge, so the edit wraps onto row 1. */
over = sink->columns + 2;
for ( i = 0; i < over; i++ ) {
push_text_key('x', "x");
}
push_key('\r');
PASS(errctx, FRONTEND.akglsink.readline(&FRONTEND.akglsink, line, sizeof(line), &eof));
TEST_REQUIRE_INT((int)strlen(line), over);
TEST_REQUIRE(sink->text[1][0] != '\0', "a line past the edge must wrap onto the next row");
/*
* Render it, so row 1 genuinely has pixels to leave behind. Without this the
* pixel assertions below pass whether or not anything erases, because the
* wrapped row would never have been drawn in the first place.
*/
PASS(errctx, akbasic_sink_akgl_render(&FRONTEND.akglsink));
/*
* Now type the same over-long line again and backspace back past the wrap.
* The editor is re-entered, so the anchor is row 2 by now; what matters is
* that the wrapped tail is erased and the cursor comes back a row.
*/
PASS(errctx, FRONTEND.akglsink.clear(&FRONTEND.akglsink));
for ( i = 0; i < over; i++ ) {
push_text_key('y', "y");
}
for ( i = 0; i < 4; i++ ) {
push_key(SDLK_BACKSPACE);
}
push_key('\r');
PASS(errctx, FRONTEND.akglsink.readline(&FRONTEND.akglsink, line, sizeof(line), &eof));
TEST_REQUIRE_INT((int)strlen(line), over - 4);
/* Back inside the first row: the wrap is gone, so row 1 holds nothing. */
TEST_REQUIRE_STR(sink->text[1], "");
PASS(errctx, akbasic_sink_akgl_render(&FRONTEND.akglsink));
shot = SDL_RenderReadPixels(FRONTEND.renderer->sdl_renderer, NULL);
TEST_REQUIRE(shot != NULL, "could not read the render target back");
TEST_REQUIRE(cell_is_blank(shot, sink, 0, 1),
"the wrapped tail was backspaced away but is still on screen");
/* And the last surviving character is where it should be, on row 0. */
TEST_REQUIRE(!cell_is_blank(shot, sink, over - 5, 0),
"the character before the backspaces should still be drawn");
TEST_REQUIRE(cell_is_blank(shot, sink, over - 4, 0),
"the first backspaced character should be gone");
SDL_DestroySurface(shot);
stop_frontend();
SUCCEED_RETURN(errctx);
}
/**
* @brief Nothing is drawn under the text being typed.
*
* The editing cursor used to be an underscore glyph drawn at the cursor cell.
* With nothing erasing the previous frame it accumulated at every column the
* cursor had passed through, which read as an underline beneath the whole line
* and made typed text hard to read. Removed outright; §5 records it.
*/
static akerr_ErrorContext AKERR_NOIGNORE *test_no_cursor_underline(void)
{
PREPARE_ERROR(errctx);
SDL_Surface *shot = NULL;
akbasic_AkglSink *sink = NULL;
int col = 0;
int y = 0;
int lit = 0;
PASS(errctx, start_frontend(NULL));
sink = &FRONTEND.akglstate;
/*
* Rendered *while editing*, which is the only state the cursor was ever
* drawn in -- an earlier version of this test rendered after readline had
* returned, by which time `editing` is false and a re-added cursor would
* sail straight through it.
*/
PASS(errctx, FRONTEND.akglsink.write(&FRONTEND.akglsink, "ab"));
sink->editing = true;
PASS(errctx, akbasic_sink_akgl_render(&FRONTEND.akglsink));
/*
* The bottom scanline of each cell the text occupies and the one after it.
* An underscore lands there; "a" and "b" in this font do not, so anything
* lit on that line is the cursor and nothing else.
*/
shot = SDL_RenderReadPixels(FRONTEND.renderer->sdl_renderer, NULL);
TEST_REQUIRE(shot != NULL, "could not read the render target back");
y = sink->y + sink->cellh - 1;
for ( col = 0; col < 4; col++ ) {
if ( anything_drawn(shot, sink->x + (col * sink->cellw), y, sink->cellw, 1) ) {
lit += 1;
}
}
TEST_REQUIRE_INT(lit, 0);
SDL_DestroySurface(shot);
sink->editing = false;
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_text_input_is_started());
Erase what the text layer drew: three GUI bugs, one cause The sink painted glyphs on top of whatever was already on the renderer and never erased anything, and the host deliberately does not clear the frame so a DRAW survives. The grid was always correct; the screen kept the previous frame. That surfaced as three separate-looking faults: - a backspaced character stayed on screen, including across a line wrap - a scroll left the old rows behind, "papered over with garbage" - the editing cursor smeared an underscore along every column it passed through, which read as an underline under the text being typed render() now fills each row it is about to draw, and each row that has emptied since it last drew, with the background first. Row by row rather than one clear over the whole area: the text layer is authoritative over the rows it occupies and leaves every other pixel alone, so a picture behind the text loses only the strips the text uses instead of all of it. echo_line() also truncates rows that hold nothing but the spaces its own erase pass wrote, which is what backspacing back across a wrap leaves behind. A row of spaces is text as far as render() is concerned, so it would have been repainted forever and kept erasing whatever was under it. The cursor glyph is removed outright, as asked. The smearing was the erase bug rather than the cursor, so one could come back and behave -- a block would read better than an underscore. Four pixel-level tests, because grid assertions could never have caught any of this. Each was checked by reverting the fix and watching it fail; two of them were vacuous when first written and are noted as such where they are fixed. The real-keyboard test also now rubs out a character and scrolls forty lines. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 15:03:37 -04:00
CATCH(errctx, test_render_erases_what_it_drew());
CATCH(errctx, test_scroll_reaches_the_screen());
CATCH(errctx, test_backspace_across_a_wrap());
CATCH(errctx, test_no_cursor_underline());
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;
}