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