Give BASIC menus, dialogs and HUD labels over libakgl's UI helpers

Group K, and the first verbs to reach the akgl_ui subsystem 0.9.0 brought
in: MENU and GETMENU and RMENU, DIALOG, HUD and UISTYLE. A program that
wanted a title screen had to draw one out of CHAR and GETKEY, which is
what both breakout tutorials make a reader do.

The interesting part is the impedance mismatch. libakgl's UI is immediate
mode -- widgets are re-declared inside a frame bracket every frame and
clay borrows their text until the bracket closes -- and a BASIC program
says MENU 1, "START" on line 100 and expects it up on line 900, several
hundred frames later. So src/ui_akgl.c is retained on this side and
immediate on that one: the record's entry points are setters that copy
into akbasic_AkglUi, and akbasic_ui_akgl_render() replays the whole set
once a frame from the host's pump. No BASIC string, which lives in the
per-line value pool, is ever what clay is handed.

The shapes are borrowed rather than invented. MENU retires the way SOLID
does -- no entries retires one, no arguments retire them all. GETMENU
holds the step loop the way GETKEY does, so parking is not blocking: the
step still returns, the host keeps its frame rate, and the sprite, audio
and collision services keep running underneath because they run before
the blocking checks. RMENU(n,1) reads and clears the way BUMP() does.
Withdrawing the device or retiring the menu releases a holding GETMENU
with 0 rather than wedging the script, which is akbasic_input_service()'s
rule for a withdrawn keyboard.

One thing a program has to know, and docs/19-user-interface.md says it
twice: a menu that is up owns the cursor keys and Return. It has to, and
retiring it gives them back -- forget the MENU n before an INPUT and the
INPUT never sees the Return that ends it.

akbasic_runtime_set_ui() is its own function rather than a fifth argument
to akbasic_runtime_set_devices(), whose signature has twenty-eight call
sites in tests and documentation that are about something else.

deps/libakgl is not touched. akgl_UiAnchor has the four corners and dead
centre, so HUD offers exactly those five; TODO.md records what a
top-centre and bottom-centre would cost upstream, along with the three
other things this deliberately leaves out. No new error code either --
DEVICE, BOUNDS, SYNTAX and TYPE cover the group, and 520 stays free.

tools/screenshot.c had to learn that "needs a font" and "draws the text
grid" are two questions. They were one, and a UI figure came out black:
the text layer owns every pixel of the rows it covers and painted over
the widgets. The new ui=1 fence attribute asks for the first without the
second; MAINTENANCE.md documents it.

112/112 in both configurations, 112/112 under ASan and UBSan, coverage
94.1% against the 90% gate with src/runtime_ui.c at 99% of lines and
100% of functions, doxygen clean, and the four new figures byte-identical
on a re-render. TODO.md section 8's gate table was stale on several
counts besides these and is refreshed with measured numbers.

Co-Authored-By: Tachikoma (Claude Code Opus 5 1M) <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwxGB6TdoVvZ11KQQME9cL
Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
This commit is contained in:
2026-08-02 18:37:10 -04:00
parent 5061419768
commit 3b32a682a1
33 changed files with 2424 additions and 31 deletions

View File

@@ -42,6 +42,7 @@
#include <akbasic/sprite.h>
#include <akbasic/runtime.h>
#include <akbasic/sink.h>
#include <akbasic/ui.h>
#include "testutil.h"
@@ -58,6 +59,8 @@ static akbasic_GraphicsBackend GRAPHICS;
static akbasic_AkglGraphics GRAPHICSSTATE;
static akbasic_InputBackend INPUT;
static akbasic_SpriteBackend SPRITES;
static akbasic_UiBackend UI;
static akbasic_AkglUi UISTATE;
static akbasic_AkglSprites SPRITESSTATE;
static TTF_Font *font = NULL;
static char OUTPUT[8192];
@@ -445,6 +448,107 @@ static akerr_ErrorContext AKERR_NOIGNORE *test_input_backend(void)
SUCCEED_RETURN(errctx);
}
/**
* @brief The UI adaptor: retained widgets, a rendered frame, and a real choice.
*
* The seam this asserts is the one the whole file is about, and here it is more
* than a coordinate conversion. libakgl's UI is immediate mode and this
* interpreter is not, so what has to work is that a MENU declared once survives
* a frame bracket it never saw, and that a keystroke libakgl consumed comes back
* out through the record as an activation a BASIC program can read.
*
* The keystrokes are **keycodes, not scancodes** -- akgl_ui_menu_handle_event
* matches on `event.key.key`, and a scancode-filled event is silently ignored.
*/
static akerr_ErrorContext AKERR_NOIGNORE *test_ui_backend(void)
{
PREPARE_ERROR(errctx);
const char *items[3] = { "START", "OPTIONS", "QUIT" };
akbasic_Color fill = { 0x00, 0x00, 0x00, 0xff };
akbasic_Color edge = { 0xff, 0xff, 0xff, 0xff };
akbasic_Color ink = { 0xff, 0xff, 0xff, 0xff };
SDL_Event event;
int selected = 99;
bool activated = true;
bool consumed = false;
PASS(errctx, akbasic_ui_init_akgl(&UI, &UISTATE, akgl_renderer,
AKBASIC_TEST_FONT, 12, TARGET_SIZE, TARGET_SIZE));
/* Nothing declared yet: a frame with no widgets is still a legal frame. */
PASS(errctx, akbasic_ui_akgl_render(&UI));
PASS(errctx, UI.style(&UI, &fill, &edge, &ink, 4.0, 0.0));
PASS(errctx, UI.label(&UI, 0, AKBASIC_UI_ANCHOR_TOP_LEFT, "LIVES 3"));
PASS(errctx, UI.dialog(&UI, "A PANEL"));
PASS(errctx, UI.menu(&UI, 0, items, 3));
/* Freshly defined: first entry highlighted, nothing chosen. */
PASS(errctx, UI.menu_state(&UI, 0, &selected, &activated, false));
TEST_REQUIRE_INT(selected, 0);
TEST_REQUIRE(!activated, "a freshly defined menu should not be activated");
/*
* A whole frame, from what the verbs left behind. This is the assertion that
* the retained set is re-declarable: clay borrows the text until frame_end,
* and every pointer it is handed here belongs to UISTATE rather than to a
* BASIC value that stopped existing several statements ago.
*/
PASS(errctx, akbasic_ui_akgl_render(&UI));
/* Down, then Return -- the menu owns both while it has entries. */
memset(&event, 0, sizeof(event));
event.type = SDL_EVENT_KEY_DOWN;
event.key.key = SDLK_DOWN;
PASS(errctx, akbasic_ui_akgl_handle_event(&UI, &event, &consumed));
TEST_REQUIRE(consumed, "a menu that is up should consume Down");
memset(&event, 0, sizeof(event));
event.type = SDL_EVENT_KEY_DOWN;
event.key.key = SDLK_RETURN;
PASS(errctx, akbasic_ui_akgl_handle_event(&UI, &event, &consumed));
TEST_REQUIRE(consumed, "a menu that is up should consume Return");
PASS(errctx, UI.menu_state(&UI, 0, &selected, &activated, true));
TEST_REQUIRE_INT(selected, 1);
TEST_REQUIRE(activated, "Return should have activated the second entry");
/* Reading with clear consumed the latch; the highlight is untouched. */
PASS(errctx, UI.menu_state(&UI, 0, &selected, &activated, false));
TEST_REQUIRE_INT(selected, 1);
TEST_REQUIRE(!activated, "reading the latch should have cleared it");
/*
* Retiring the menu hands the cursor keys back. Without this a program that
* put a menu up could never take a typed line again, which is what makes the
* REPL usable at all.
*/
PASS(errctx, UI.menu(&UI, 0, NULL, 0));
memset(&event, 0, sizeof(event));
event.type = SDL_EVENT_KEY_DOWN;
event.key.key = SDLK_DOWN;
PASS(errctx, akbasic_ui_akgl_handle_event(&UI, &event, &consumed));
TEST_REQUIRE(!consumed, "a retired menu must not still be eating Down");
/* Escape is nobody's, even with a menu up. */
PASS(errctx, UI.menu(&UI, 0, items, 3));
memset(&event, 0, sizeof(event));
event.type = SDL_EVENT_KEY_DOWN;
event.key.key = SDLK_ESCAPE;
PASS(errctx, akbasic_ui_akgl_handle_event(&UI, &event, &consumed));
TEST_REQUIRE(!consumed, "Escape should reach the program, not the menu");
PASS(errctx, UI.clear(&UI));
PASS(errctx, UI.menu_state(&UI, 0, &selected, &activated, false));
TEST_REQUIRE_INT(selected, 0);
PASS(errctx, akbasic_ui_akgl_render(&UI));
/* Shutdown is idempotent, because teardown paths are already unwinding. */
akbasic_ui_akgl_shutdown(&UI);
akbasic_ui_akgl_shutdown(&UI);
SUCCEED_RETURN(errctx);
}
/**
* @brief A sprite loaded from an image file lands on the target where MOVSPR put it.
*
@@ -1225,6 +1329,7 @@ int main(void)
CATCH(errctx, test_sink_writes_past_a_short_row());
CATCH(errctx, test_sink_renders());
CATCH(errctx, test_input_backend());
CATCH(errctx, test_ui_backend());
CATCH(errctx, test_sprite_from_file());
CATCH(errctx, test_sprite_from_pattern());
CATCH(errctx, test_sprite_from_shape());

View File

@@ -57,6 +57,17 @@ static void test_set_devices(void)
TEST_REQUIRE(HARNESS_RUNTIME.input == NULL, "input backend should have been detached");
TEST_REQUIRE_STATUS(akbasic_runtime_set_devices(NULL, NULL, NULL, NULL, NULL), AKERR_NULLPOINTER);
/*
* The fifth device is set on its own, and detaches the same way. It is not a
* fifth argument above because that signature has twenty-eight call sites
* and none of them is about the UI.
*/
TEST_REQUIRE_OK(akbasic_runtime_set_ui(&HARNESS_RUNTIME, &MOCK_UI));
TEST_REQUIRE(HARNESS_RUNTIME.ui == &MOCK_UI, "UI backend was not stored");
TEST_REQUIRE_OK(akbasic_runtime_set_ui(&HARNESS_RUNTIME, NULL));
TEST_REQUIRE(HARNESS_RUNTIME.ui == NULL, "UI backend should have been detached");
TEST_REQUIRE_STATUS(akbasic_runtime_set_ui(NULL, NULL), AKERR_NULLPOINTER);
harness_stop();
}

View File

@@ -9,12 +9,14 @@
#include <akerror.h>
#include <akbasic/runtime.h>
#include <akbasic/sink.h>
#include <akbasic/ui.h>
static akbasic_Runtime RUNTIME;
static akbasic_GraphicsBackend graphics;
static akbasic_AudioBackend audio;
static akbasic_InputBackend input;
static akbasic_SpriteBackend sprites;
static akbasic_UiBackend ui;
akerr_ErrorContext AKERR_NOIGNORE *akbasic_docs_fragment(void);
akerr_ErrorContext AKERR_NOIGNORE *akbasic_docs_fragment(void)

View File

@@ -0,0 +1,4 @@
10 REM The standalone driver lends the script no UI device.
20 REM Every verb in group K refuses, and each has to name itself.
30 PRINT "BEFORE"
40 MENU 1, "START", "QUIT"

View File

@@ -0,0 +1,3 @@
BEFORE
? 40 : RUNTIME ERROR MENU needs a UI device and this runtime has none

View File

@@ -25,6 +25,7 @@
#include <akbasic/graphics.h>
#include <akbasic/input.h>
#include <akbasic/sprite.h>
#include <akbasic/ui.h>
/** @brief Room for the call log. Longer than any test needs; overflow truncates loudly. */
#define MOCK_LOG_SIZE 8192
@@ -60,6 +61,15 @@ typedef struct
akbasic_Contact contact; /* what the next contact() call describes */
bool hascontact;
int patternbytes[AKBASIC_MAX_SPRITES]; /* bytes the last define() carried, per sprite */
/*
* UI. The verbs are setters rather than draw calls, so what a test asserts
* is both the call sequence *and* what the backend now holds -- a MENU that
* logged correctly and stored nothing would still be broken.
*/
int menuselected[AKBASIC_UI_MAX_MENUS];
bool menuactivated[AKBASIC_UI_MAX_MENUS];
int menucount[AKBASIC_UI_MAX_MENUS];
} akbasic_MockDevice;
static akbasic_MockDevice MOCK;
@@ -67,6 +77,7 @@ static akbasic_GraphicsBackend MOCK_GRAPHICS;
static akbasic_AudioBackend MOCK_AUDIO;
static akbasic_InputBackend MOCK_INPUT;
static akbasic_SpriteBackend MOCK_SPRITES;
static akbasic_UiBackend MOCK_UI;
/**
* @brief Append one formatted call to the log.
@@ -483,6 +494,106 @@ static akerr_ErrorContext *mock_spr_contact(akbasic_SpriteBackend *self, int n,
/* ---------------------------------------------------------------- fixture -- */
/* --------------------------------------------------------------------- ui -- */
static akerr_ErrorContext *mock_ui_dialog(akbasic_UiBackend *self, const char *text)
{
PREPARE_ERROR(errctx);
(void)self;
mock_log("dialog %s\n", (text != NULL) ? text : "(none)");
SUCCEED_RETURN(errctx);
}
static akerr_ErrorContext *mock_ui_label(akbasic_UiBackend *self, int slot, int anchor, const char *text)
{
PREPARE_ERROR(errctx);
(void)self;
mock_log("label %d anchor %d %s\n", slot, anchor, (text != NULL) ? text : "(none)");
SUCCEED_RETURN(errctx);
}
static akerr_ErrorContext *mock_ui_menu(akbasic_UiBackend *self, int slot, const char *const *items, int count)
{
PREPARE_ERROR(errctx);
int i = 0;
(void)self;
FAIL_ZERO_RETURN(errctx, (slot >= 0 && slot < AKBASIC_UI_MAX_MENUS), AKERR_OUTOFBOUNDS,
"mock menu slot %d", slot);
mock_log("menu %d count %d", slot, count);
for ( i = 0; i < count; i++ ) {
mock_log(" [%s]", (items != NULL && items[i] != NULL) ? items[i] : "(null)");
}
mock_log("\n");
MOCK.menucount[slot] = count;
MOCK.menuselected[slot] = 0;
MOCK.menuactivated[slot] = false;
SUCCEED_RETURN(errctx);
}
static akerr_ErrorContext *mock_ui_menu_state(akbasic_UiBackend *self, int slot, int *selected, bool *activated, bool clear)
{
PREPARE_ERROR(errctx);
(void)self;
FAIL_ZERO_RETURN(errctx, (selected != NULL && activated != NULL), AKERR_NULLPOINTER,
"NULL destination in mock_ui_menu_state");
FAIL_ZERO_RETURN(errctx, (slot >= 0 && slot < AKBASIC_UI_MAX_MENUS), AKERR_OUTOFBOUNDS,
"mock menu slot %d", slot);
*selected = MOCK.menuselected[slot];
*activated = MOCK.menuactivated[slot];
if ( clear ) {
MOCK.menuactivated[slot] = false;
}
SUCCEED_RETURN(errctx);
}
static akerr_ErrorContext *mock_ui_style(akbasic_UiBackend *self, akbasic_Color *fill, akbasic_Color *edge, akbasic_Color *ink, double padding, double radius)
{
PREPARE_ERROR(errctx);
(void)self;
if ( fill == NULL || edge == NULL || ink == NULL ) {
mock_log("style default\n");
SUCCEED_RETURN(errctx);
}
mock_log("style #%02x%02x%02x #%02x%02x%02x #%02x%02x%02x pad %.1f radius %.1f\n",
fill->r, fill->g, fill->b, edge->r, edge->g, edge->b,
ink->r, ink->g, ink->b, padding, radius);
SUCCEED_RETURN(errctx);
}
static akerr_ErrorContext *mock_ui_clear(akbasic_UiBackend *self)
{
PREPARE_ERROR(errctx);
int i = 0;
(void)self;
mock_log("uiclear\n");
for ( i = 0; i < AKBASIC_UI_MAX_MENUS; i++ ) {
MOCK.menucount[i] = 0;
MOCK.menuselected[i] = 0;
MOCK.menuactivated[i] = false;
}
SUCCEED_RETURN(errctx);
}
/**
* @brief Stand in for the player choosing an entry.
*
* The one thing a mock has to be able to do that no verb can: a real activation
* comes from a keystroke libakgl consumed, and there is no keyboard here.
*/
__attribute__((unused))
static void mock_menu_choose(int slot, int selected)
{
if ( slot < 0 || slot >= AKBASIC_UI_MAX_MENUS ) {
return;
}
MOCK.menuselected[slot] = selected;
MOCK.menuactivated[slot] = true;
}
/** @brief Reset the recorder and populate all three vtables. */
__attribute__((unused))
static void mock_devices_init(void)
@@ -542,6 +653,14 @@ static void mock_devices_init(void)
MOCK_SPRITES.shape = mock_spr_shape;
MOCK_SPRITES.solid = mock_spr_solid;
MOCK_SPRITES.contact = mock_spr_contact;
MOCK_UI.self = &MOCK;
MOCK_UI.dialog = mock_ui_dialog;
MOCK_UI.label = mock_ui_label;
MOCK_UI.menu = mock_ui_menu;
MOCK_UI.menu_state = mock_ui_menu_state;
MOCK_UI.style = mock_ui_style;
MOCK_UI.clear = mock_ui_clear;
}
/** @brief Set what the next collisions() call will report. Bit n-1 is sprite n. */

508
tests/ui_verbs.c Normal file
View File

@@ -0,0 +1,508 @@
/**
* @file ui_verbs.c
* @brief Tests the group K verbs against the recording mock backend.
*
* Same arrangement as tests/graphics_verbs.c and for the same reason: these
* verbs produce nothing a golden file can compare, so the assertions are on what
* reached the device. Running a real BASIC line rather than calling the handler
* directly exercises the dispatch-table row and the parse handler too, which is
* where an added verb is most likely to be wrong.
*
* The one thing a mock has to fake is a *choice*: a real activation comes from a
* keystroke libakgl consumed, and there is no keyboard here. mock_menu_choose()
* is that, and it is the only thing in this file that is not a BASIC program.
*/
#include <string.h>
#include <akbasic/error.h>
#include <akbasic/runtime.h>
#include <akbasic/ui.h>
#include "harness.h"
#include "mockdevice.h"
#include "testutil.h"
/** @brief Bring up a runtime with the mock UI attached and a program loaded. */
static akerr_ErrorContext AKERR_NOIGNORE *run_program(const char *source)
{
PREPARE_ERROR(errctx);
PASS(errctx, harness_start(NULL));
mock_devices_init();
PASS(errctx, akbasic_runtime_set_ui(&HARNESS_RUNTIME, &MOCK_UI));
PASS(errctx, akbasic_runtime_load(&HARNESS_RUNTIME, source));
PASS(errctx, akbasic_runtime_start(&HARNESS_RUNTIME, AKBASIC_MODE_RUN));
PASS(errctx, akbasic_runtime_run(&HARNESS_RUNTIME, 0));
SUCCEED_RETURN(errctx);
}
/** @brief The white of palette index 2, and the black of index 1. */
#define WHITE "#ffffff"
#define BLACK "#000000"
/**
* @brief MENU defines, redefines, retires one and retires all.
*/
static void test_menu(void)
{
TEST_REQUIRE_OK(run_program("10 MENU 1, \"START\", \"QUIT\"\n"));
TEST_REQUIRE_STR(MOCK.log, "menu 0 count 2 [START] [QUIT]\n");
TEST_REQUIRE(HARNESS_RUNTIME.ui_state.menudefined[0],
"MENU 1 should have marked menu 1 defined");
harness_stop();
/* A number and nothing else retires that one. */
TEST_REQUIRE_OK(run_program("10 MENU 2, \"A\"\n20 MENU 2\n"));
TEST_REQUIRE_STR(MOCK.log,
"menu 1 count 1 [A]\n"
"menu 1 count 0\n");
TEST_REQUIRE(!HARNESS_RUNTIME.ui_state.menudefined[1],
"a bare MENU n should have retired menu n");
harness_stop();
/* No arguments retires them all, the way a bare SOLID does. */
TEST_REQUIRE_OK(run_program("10 MENU 1, \"A\"\n20 MENU 3, \"B\"\n30 MENU\n"));
TEST_REQUIRE_STR(MOCK.log,
"menu 0 count 1 [A]\n"
"menu 2 count 1 [B]\n"
"menu 0 count 0\n"
"menu 1 count 0\n"
"menu 2 count 0\n"
"menu 3 count 0\n");
harness_stop();
/* Entries can be expressions, not only literals. */
TEST_REQUIRE_OK(run_program("10 A$ = \"LOAD\"\n20 MENU 1, A$ + \" GAME\"\n"));
TEST_REQUIRE_STR(MOCK.log, "menu 0 count 1 [LOAD GAME]\n");
harness_stop();
}
/**
* @brief MENU refuses a slot outside 1..4, a number where a string belongs, and
* more entries than libakgl's menu can hold.
*/
static void test_menu_refusals(void)
{
TEST_REQUIRE_OK(run_program("10 MENU 0, \"A\"\n"));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "menu 0 is outside 1..4") != NULL,
"MENU 0 should refuse by name, got \"%s\"", HARNESS_OUTPUT);
harness_stop();
TEST_REQUIRE_OK(run_program("10 MENU 5, \"A\"\n"));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "menu 5 is outside 1..4") != NULL,
"MENU 5 should refuse by name, got \"%s\"", HARNESS_OUTPUT);
harness_stop();
TEST_REQUIRE_OK(run_program("10 MENU 1, 42\n"));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "MENU expected a string") != NULL,
"a numeric MENU entry should be a type error, got \"%s\"", HARNESS_OUTPUT);
harness_stop();
/*
* A seventeenth entry is refused by the verb, and there is deliberately no
* test for it here: AKBASIC_MAX_TOKENS is 32, so seventeen strings and their
* commas exceed the line's token ceiling and the scanner refuses first. The
* check in akbasic_cmd_menu() is unreachable from BASIC as the scanner
* stands and is kept anyway -- it is the thing that would matter the day
* that ceiling moves, and a MENU that silently wrote past
* akgl_UiMenu::items would be a memory error rather than a diagnostic.
*/
}
/**
* @brief RMENU reads the highlight without consuming it, and the latch with.
*
* The read-and-clear on field 1 is the whole contract: without it a program
* polling in a loop sees the same choice forever. Same rule BUMP() carries.
*/
static void test_rmenu(void)
{
TEST_REQUIRE_OK(harness_start(NULL));
mock_devices_init();
TEST_REQUIRE_OK(akbasic_runtime_set_ui(&HARNESS_RUNTIME, &MOCK_UI));
TEST_REQUIRE_OK(akbasic_runtime_load(&HARNESS_RUNTIME,
"10 MENU 1, \"A\", \"B\", \"C\"\n"
"20 PRINT RMENU(1,0)\n"
"30 PRINT RMENU(1,1)\n"
"40 PRINT RMENU(1,1)\n"
"50 PRINT RMENU(1,0)\n"));
TEST_REQUIRE_OK(akbasic_runtime_start(&HARNESS_RUNTIME, AKBASIC_MODE_RUN));
/*
* The MENU line first, *then* the choice. Defining a menu resets its
* selection and its latch, so a choice made before line 10 runs is a choice
* into a menu that does not exist yet -- and line 10 would wipe it.
*
* Fifteen steps, not one. A step advances the program counter by one *line
* number*, not one statement, so line 10 runs on step 11 and line 20 on step
* 21; fifteen lands between them. Same reason the frontend's per-frame
* budget is 256.
*/
TEST_REQUIRE_OK(akbasic_runtime_run(&HARNESS_RUNTIME, 15));
mock_menu_choose(0, 1);
TEST_REQUIRE_OK(akbasic_runtime_run(&HARNESS_RUNTIME, 0));
/*
* 2 -- entries are numbered from one, so the second is 2 and not 1.
* -1 -- BASIC true, once.
* 0 -- because reading it cleared it.
* 2 -- and reading the latch did not disturb the highlight.
*/
TEST_REQUIRE_STR(HARNESS_OUTPUT, "2\n-1\n0\n2\n");
harness_stop();
}
/** @brief RMENU refuses a bad slot and a field outside 0..1. */
static void test_rmenu_refusals(void)
{
TEST_REQUIRE_OK(run_program("10 PRINT RMENU(9,0)\n"));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "menu 9 is outside 1..4") != NULL,
"RMENU should refuse menu 9, got \"%s\"", HARNESS_OUTPUT);
harness_stop();
TEST_REQUIRE_OK(run_program("10 PRINT RMENU(1,7)\n"));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "field 7 is outside 0..1") != NULL,
"RMENU should refuse field 7, got \"%s\"", HARNESS_OUTPUT);
harness_stop();
}
/**
* @brief GETMENU holds the step loop, then assigns the entry that was chosen.
*
* The property being asserted is that the step *returns* while the program does
* not advance -- section 1.6's whole point. A run of a hundred steps with
* nothing chosen must leave the program on the GETMENU line and must come back.
*/
static void test_getmenu_holds(void)
{
TEST_REQUIRE_OK(harness_start(NULL));
mock_devices_init();
TEST_REQUIRE_OK(akbasic_runtime_set_ui(&HARNESS_RUNTIME, &MOCK_UI));
TEST_REQUIRE_OK(akbasic_runtime_load(&HARNESS_RUNTIME,
"10 MENU 1, \"A\", \"B\", \"C\"\n"
"20 GETMENU 1, C%\n"
"30 PRINT C%\n"));
TEST_REQUIRE_OK(akbasic_runtime_start(&HARNESS_RUNTIME, AKBASIC_MODE_RUN));
TEST_REQUIRE_OK(akbasic_runtime_run(&HARNESS_RUNTIME, 100));
TEST_REQUIRE(HARNESS_RUNTIME.ui_state.waiting,
"GETMENU should still be holding after a hundred steps");
TEST_REQUIRE_STR(HARNESS_OUTPUT, "");
mock_menu_choose(0, 2);
TEST_REQUIRE_OK(akbasic_runtime_run(&HARNESS_RUNTIME, 0));
TEST_REQUIRE(!HARNESS_RUNTIME.ui_state.waiting, "GETMENU should have released");
TEST_REQUIRE_STR(HARNESS_OUTPUT, "3\n");
harness_stop();
}
/**
* @brief A GETMENU that arrives after the choice does not wait at all.
*
* The latch survives between frames precisely so an unread activation is never
* lost, so a program that gets round to its GETMENU late must find the answer
* waiting rather than park for a second one.
*/
static void test_getmenu_takes_a_waiting_choice(void)
{
TEST_REQUIRE_OK(harness_start(NULL));
mock_devices_init();
TEST_REQUIRE_OK(akbasic_runtime_set_ui(&HARNESS_RUNTIME, &MOCK_UI));
TEST_REQUIRE_OK(akbasic_runtime_load(&HARNESS_RUNTIME,
"10 MENU 1, \"A\", \"B\", \"C\"\n"
"20 GETMENU 1, C%\n"
"30 PRINT C%\n"));
TEST_REQUIRE_OK(akbasic_runtime_start(&HARNESS_RUNTIME, AKBASIC_MODE_RUN));
/* Through line 10 only, then choose before line 20 ever runs. */
TEST_REQUIRE_OK(akbasic_runtime_run(&HARNESS_RUNTIME, 15));
mock_menu_choose(0, 2);
TEST_REQUIRE_OK(akbasic_runtime_run(&HARNESS_RUNTIME, 0));
TEST_REQUIRE(!HARNESS_RUNTIME.ui_state.waiting,
"GETMENU should not have held when the choice was already made");
TEST_REQUIRE_STR(HARNESS_OUTPUT, "3\n");
harness_stop();
}
/**
* @brief Retiring the menu underneath a holding GETMENU releases it with 0.
*
* Reached from BASIC only through an interrupt handler, which is the one thing
* that can run a line while the program is parked; the menu is retired directly
* here because building a collision interrupt to do it would be testing
* something else.
*/
static void test_getmenu_released_by_retirement(void)
{
TEST_REQUIRE_OK(harness_start(NULL));
mock_devices_init();
TEST_REQUIRE_OK(akbasic_runtime_set_ui(&HARNESS_RUNTIME, &MOCK_UI));
TEST_REQUIRE_OK(akbasic_runtime_load(&HARNESS_RUNTIME,
"10 MENU 1, \"A\", \"B\"\n"
"20 C% = 9\n"
"30 GETMENU 1, C%\n"
"40 PRINT C%\n"));
TEST_REQUIRE_OK(akbasic_runtime_start(&HARNESS_RUNTIME, AKBASIC_MODE_RUN));
TEST_REQUIRE_OK(akbasic_runtime_run(&HARNESS_RUNTIME, 35));
TEST_REQUIRE(HARNESS_RUNTIME.ui_state.waiting, "GETMENU should be holding");
TEST_REQUIRE_OK(MOCK_UI.menu(&MOCK_UI, 0, NULL, 0));
HARNESS_RUNTIME.ui_state.menudefined[0] = false;
TEST_REQUIRE_OK(akbasic_runtime_run(&HARNESS_RUNTIME, 0));
TEST_REQUIRE(!HARNESS_RUNTIME.ui_state.waiting,
"retiring the menu should have released the GETMENU");
TEST_REQUIRE_STR(HARNESS_OUTPUT, "0\n");
harness_stop();
}
/**
* @brief Withdrawing the device releases a holding GETMENU rather than wedging it.
*
* A host is allowed to change its mind about what it lends out, and a script
* parked forever on a device that no longer exists is the worse outcome. Same
* rule akbasic_input_service() keeps for a withdrawn keyboard.
*/
static void test_getmenu_released_by_withdrawal(void)
{
TEST_REQUIRE_OK(harness_start(NULL));
mock_devices_init();
TEST_REQUIRE_OK(akbasic_runtime_set_ui(&HARNESS_RUNTIME, &MOCK_UI));
TEST_REQUIRE_OK(akbasic_runtime_load(&HARNESS_RUNTIME,
"10 MENU 1, \"A\", \"B\"\n"
"20 C% = 9\n"
"30 GETMENU 1, C%\n"
"40 PRINT C%\n"));
TEST_REQUIRE_OK(akbasic_runtime_start(&HARNESS_RUNTIME, AKBASIC_MODE_RUN));
/* Through line 30, where the GETMENU is; see the note in test_rmenu(). */
TEST_REQUIRE_OK(akbasic_runtime_run(&HARNESS_RUNTIME, 35));
TEST_REQUIRE(HARNESS_RUNTIME.ui_state.waiting, "GETMENU should be holding");
TEST_REQUIRE_OK(akbasic_runtime_set_ui(&HARNESS_RUNTIME, NULL));
TEST_REQUIRE_OK(akbasic_runtime_run(&HARNESS_RUNTIME, 0));
TEST_REQUIRE(!HARNESS_RUNTIME.ui_state.waiting,
"withdrawing the UI should have released the GETMENU");
/* Released, not chosen: 0 rather than the 9 the variable held. */
TEST_REQUIRE_STR(HARNESS_OUTPUT, "0\n");
harness_stop();
}
/** @brief GETMENU refuses a menu that has no entries rather than holding forever. */
static void test_getmenu_needs_a_menu(void)
{
TEST_REQUIRE_OK(run_program("10 GETMENU 1, C%\n"));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "has no entries") != NULL,
"GETMENU on an undefined menu should refuse, got \"%s\"", HARNESS_OUTPUT);
harness_stop();
TEST_REQUIRE_OK(run_program("10 MENU 1, \"A\"\n20 GETMENU 1, A$\n"));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "expected a numeric variable") != NULL,
"GETMENU into a string should be a type error, got \"%s\"", HARNESS_OUTPUT);
harness_stop();
}
/** @brief DIALOG puts the panel up with a string and takes it down with nothing. */
static void test_dialog(void)
{
TEST_REQUIRE_OK(run_program("10 DIALOG \"HELLO\"\n20 DIALOG\n"));
TEST_REQUIRE_STR(MOCK.log,
"dialog HELLO\n"
"dialog (none)\n");
harness_stop();
TEST_REQUIRE_OK(run_program("10 DIALOG 42\n"));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "DIALOG expected a string") != NULL,
"a numeric DIALOG should be a type error, got \"%s\"", HARNESS_OUTPUT);
harness_stop();
}
/**
* @brief HUD sets each of the five anchors, retires one, and retires all.
*
* Five and not seven. libakgl's akgl_UiAnchor has the four corners and dead
* centre; there is no top-centre or bottom-centre, and this asserts that 5 is
* refused rather than quietly mapped onto something.
*/
static void test_hud(void)
{
TEST_REQUIRE_OK(run_program("10 HUD 1, 0, \"TL\"\n"
"20 HUD 2, 1, \"TR\"\n"
"30 HUD 3, 2, \"BL\"\n"
"40 HUD 4, 3, \"BR\"\n"
"50 HUD 5, 4, \"MID\"\n"));
TEST_REQUIRE_STR(MOCK.log,
"label 0 anchor 0 TL\n"
"label 1 anchor 1 TR\n"
"label 2 anchor 2 BL\n"
"label 3 anchor 3 BR\n"
"label 4 anchor 4 MID\n");
harness_stop();
TEST_REQUIRE_OK(run_program("10 HUD 2, 0, \"X\"\n20 HUD 2\n"));
TEST_REQUIRE_STR(MOCK.log,
"label 1 anchor 0 X\n"
"label 1 anchor 0 (none)\n");
harness_stop();
/* No arguments retires all eight, the way a bare MENU retires all four. */
TEST_REQUIRE_OK(run_program("10 HUD 1, 0, \"X\"\n20 HUD\n"));
TEST_REQUIRE_STR(MOCK.log,
"label 0 anchor 0 X\n"
"label 0 anchor 0 (none)\n"
"label 1 anchor 0 (none)\n"
"label 2 anchor 0 (none)\n"
"label 3 anchor 0 (none)\n"
"label 4 anchor 0 (none)\n"
"label 5 anchor 0 (none)\n"
"label 6 anchor 0 (none)\n"
"label 7 anchor 0 (none)\n");
harness_stop();
TEST_REQUIRE_OK(run_program("10 HUD 1, 5, \"NOPE\"\n"));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "anchor 5 is outside 0..4") != NULL,
"HUD should refuse anchor 5, got \"%s\"", HARNESS_OUTPUT);
harness_stop();
TEST_REQUIRE_OK(run_program("10 HUD 9, 0, \"NOPE\"\n"));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "slot 9 is outside 1..8") != NULL,
"HUD should refuse slot 9, got \"%s\"", HARNESS_OUTPUT);
harness_stop();
/* A slot and an anchor with nothing to say is a typo, not an empty label. */
TEST_REQUIRE_OK(run_program("10 HUD 1, 0\n"));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "a slot, an anchor and a string") != NULL,
"a two-argument HUD should be refused, got \"%s\"", HARNESS_OUTPUT);
harness_stop();
}
/** @brief UISTYLE converts palette indices, defaults its tail, and resets. */
static void test_uistyle(void)
{
TEST_REQUIRE_OK(run_program("10 UISTYLE 1, 2, 2\n"));
TEST_REQUIRE_STR(MOCK.log,
"style " BLACK " " WHITE " " WHITE " pad 8.0 radius 0.0\n");
harness_stop();
TEST_REQUIRE_OK(run_program("10 UISTYLE 1, 2, 2, 12, 4\n"));
TEST_REQUIRE_STR(MOCK.log,
"style " BLACK " " WHITE " " WHITE " pad 12.0 radius 4.0\n");
harness_stop();
TEST_REQUIRE_OK(run_program("10 UISTYLE\n"));
TEST_REQUIRE_STR(MOCK.log, "style default\n");
harness_stop();
TEST_REQUIRE_OK(run_program("10 UISTYLE 17, 2, 2\n"));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "RUNTIME ERROR") != NULL,
"UISTYLE should refuse palette index 17, got \"%s\"", HARNESS_OUTPUT);
harness_stop();
TEST_REQUIRE_OK(run_program("10 UISTYLE 1, 2\n"));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "a fill, an edge and an ink") != NULL,
"UISTYLE with two colours should be refused, got \"%s\"", HARNESS_OUTPUT);
harness_stop();
}
/** @brief NEW takes every widget down; a deleted program's menu must not survive it. */
static void test_new_retires_everything(void)
{
TEST_REQUIRE_OK(harness_start(NULL));
mock_devices_init();
TEST_REQUIRE_OK(akbasic_runtime_set_ui(&HARNESS_RUNTIME, &MOCK_UI));
TEST_REQUIRE_OK(akbasic_runtime_load(&HARNESS_RUNTIME, "10 MENU 1, \"A\"\n"));
TEST_REQUIRE_OK(akbasic_runtime_start(&HARNESS_RUNTIME, AKBASIC_MODE_RUN));
TEST_REQUIRE_OK(akbasic_runtime_run(&HARNESS_RUNTIME, 0));
TEST_REQUIRE(HARNESS_RUNTIME.ui_state.menudefined[0], "the menu should be up");
mock_log_reset();
TEST_REQUIRE_OK(akbasic_runtime_load(&HARNESS_RUNTIME, "10 NEW\n"));
TEST_REQUIRE_OK(akbasic_runtime_start(&HARNESS_RUNTIME, AKBASIC_MODE_RUN));
TEST_REQUIRE_OK(akbasic_runtime_run(&HARNESS_RUNTIME, 0));
TEST_REQUIRE(strstr(MOCK.log, "uiclear") != NULL,
"NEW should have retired the widgets, log was \"%s\"", MOCK.log);
TEST_REQUIRE(!HARNESS_RUNTIME.ui_state.menudefined[0],
"NEW should have forgotten the menu");
harness_stop();
}
/**
* @brief Every verb in the group refuses by name when no UI device was lent.
*
* This is the standalone driver's situation and every no-SDL build's, so it is
* the common path rather than an edge case.
*/
static void test_no_device(void)
{
TEST_REQUIRE_OK(harness_start(NULL));
TEST_REQUIRE_OK(akbasic_runtime_load(&HARNESS_RUNTIME, "10 MENU 1, \"A\"\n"));
TEST_REQUIRE_OK(akbasic_runtime_start(&HARNESS_RUNTIME, AKBASIC_MODE_RUN));
TEST_REQUIRE_OK(akbasic_runtime_run(&HARNESS_RUNTIME, 0));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "MENU needs a UI device") != NULL,
"MENU without a device should name itself, got \"%s\"", HARNESS_OUTPUT);
harness_stop();
TEST_REQUIRE_OK(harness_start(NULL));
TEST_REQUIRE_OK(akbasic_runtime_load(&HARNESS_RUNTIME, "10 DIALOG \"X\"\n"));
TEST_REQUIRE_OK(akbasic_runtime_start(&HARNESS_RUNTIME, AKBASIC_MODE_RUN));
TEST_REQUIRE_OK(akbasic_runtime_run(&HARNESS_RUNTIME, 0));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "DIALOG needs a UI device") != NULL,
"DIALOG without a device should name itself, got \"%s\"", HARNESS_OUTPUT);
harness_stop();
TEST_REQUIRE_OK(harness_start(NULL));
TEST_REQUIRE_OK(akbasic_runtime_load(&HARNESS_RUNTIME, "10 HUD 1, 0, \"X\"\n"));
TEST_REQUIRE_OK(akbasic_runtime_start(&HARNESS_RUNTIME, AKBASIC_MODE_RUN));
TEST_REQUIRE_OK(akbasic_runtime_run(&HARNESS_RUNTIME, 0));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "HUD needs a UI device") != NULL,
"HUD without a device should name itself, got \"%s\"", HARNESS_OUTPUT);
harness_stop();
TEST_REQUIRE_OK(harness_start(NULL));
TEST_REQUIRE_OK(akbasic_runtime_load(&HARNESS_RUNTIME, "10 UISTYLE 1, 2, 2\n"));
TEST_REQUIRE_OK(akbasic_runtime_start(&HARNESS_RUNTIME, AKBASIC_MODE_RUN));
TEST_REQUIRE_OK(akbasic_runtime_run(&HARNESS_RUNTIME, 0));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "UISTYLE needs a UI device") != NULL,
"UISTYLE without a device should name itself, got \"%s\"", HARNESS_OUTPUT);
harness_stop();
TEST_REQUIRE_OK(harness_start(NULL));
TEST_REQUIRE_OK(akbasic_runtime_load(&HARNESS_RUNTIME, "10 GETMENU 1, C%\n"));
TEST_REQUIRE_OK(akbasic_runtime_start(&HARNESS_RUNTIME, AKBASIC_MODE_RUN));
TEST_REQUIRE_OK(akbasic_runtime_run(&HARNESS_RUNTIME, 0));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "GETMENU needs a UI device") != NULL,
"GETMENU without a device should name itself, got \"%s\"", HARNESS_OUTPUT);
harness_stop();
TEST_REQUIRE_OK(harness_start(NULL));
TEST_REQUIRE_OK(akbasic_runtime_load(&HARNESS_RUNTIME, "10 PRINT RMENU(1,0)\n"));
TEST_REQUIRE_OK(akbasic_runtime_start(&HARNESS_RUNTIME, AKBASIC_MODE_RUN));
TEST_REQUIRE_OK(akbasic_runtime_run(&HARNESS_RUNTIME, 0));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "RMENU needs a UI device") != NULL,
"RMENU without a device should name itself, got \"%s\"", HARNESS_OUTPUT);
harness_stop();
}
/** @brief A NULL runtime is refused rather than dereferenced. */
static void test_set_ui_validation(void)
{
TEST_REQUIRE_STATUS(akbasic_runtime_set_ui(NULL, NULL), AKERR_NULLPOINTER);
TEST_REQUIRE_STATUS(akbasic_ui_state_init(NULL), AKERR_NULLPOINTER);
}
int main(void)
{
test_menu();
test_menu_refusals();
test_rmenu();
test_rmenu_refusals();
test_getmenu_holds();
test_getmenu_takes_a_waiting_choice();
test_getmenu_released_by_retirement();
test_getmenu_released_by_withdrawal();
test_getmenu_needs_a_menu();
test_dialog();
test_hud();
test_uistyle();
test_new_retires_everything();
test_no_device();
test_set_ui_validation();
return akbasic_test_failures;
}