From 83185bafa84aa715901944ac7e9018c44faaa170 Mon Sep 17 00:00:00 2001 From: Andrew Kesterson Date: Fri, 31 Jul 2026 08:35:05 -0400 Subject: [PATCH] Read the keyboard: implement GET, GETKEY and SCNCLR The poll_key half of group E, against the akbasic_InputBackend record. The libakgl implementation behind it is akgl_controller_poll_key(), which drains a ring the library fills from SDL events the host pumps -- so the interpreter can answer "is there a key waiting" without owning an event loop, which is what goal 3 requires. GET and GETKEY differ in one way and it is the interesting one. GET takes whatever is there including nothing, and an empty buffer is success with the empty string rather than an error -- that is what happens on most iterations of every GET loop ever written, and upstream is explicit that its poll reports it the same way. GETKEY waits, and since the library may not block, waiting is spelled as holding the step loop: the verb sets a flag and akbasic_runtime_step() declines to advance until a key arrives. Every step still returns and a bounded run() still comes back, so a host keeps its frame rate; the program simply does not move past the GETKEY. The PLAY queue is serviced before that check on purpose -- music should keep playing while a program waits for a keypress. Withdrawing the input device while a GETKEY is holding releases it rather than wedging the script on a device that no longer exists. Both verbs accept an integer variable as well as a string one and give it the raw key code, which is what a program testing for cursor or function keys needs. No key is code zero, matching what a C128 reports. A float variable is refused: it is neither a character nor a code. SCNCLR goes through the text sink rather than a device, because the sink is where PRINT already goes and is the only thing that knows what a screen means for this host. The stdio sink treats it as a no-op; clearing a pipe means nothing. One thing worth knowing before writing any bounded-run test, and now commented in tests/input_verbs.c: source lines are stored indexed by line number and the cursor starts at zero, so a step is spent on each empty slot along the way. A program at line 10 needs eleven steps before it has run anything. 70/70 ctest, clean under -Wall -Wextra, doxygen clean. Co-Authored-By: Claude Opus 5 (1M context) --- CMakeLists.txt | 2 + TODO.md | 24 +++- include/akbasic/input.h | 40 ++++++ include/akbasic/runtime.h | 3 + src/runtime.c | 13 ++ src/runtime_input.c | 217 +++++++++++++++++++++++++++++ src/verbs.c | 3 + src/verbs.h | 5 + tests/input_verbs.c | 197 ++++++++++++++++++++++++++ tests/language/input/no_device.bas | 5 + tests/language/input/no_device.txt | 3 + 11 files changed, 508 insertions(+), 4 deletions(-) create mode 100644 src/runtime_input.c create mode 100644 tests/input_verbs.c create mode 100644 tests/language/input/no_device.bas create mode 100644 tests/language/input/no_device.txt diff --git a/CMakeLists.txt b/CMakeLists.txt index 177b160..b06184a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -111,6 +111,7 @@ set(AKBASIC_SOURCES src/runtime_commands.c src/runtime_functions.c src/runtime_graphics.c + src/runtime_input.c src/scanner.c src/sink_stdio.c src/symtab.c @@ -197,6 +198,7 @@ set(AKBASIC_TESTS error_codes grammar_leaves graphics_verbs + input_verbs parser_commands parser_expressions runtime_evaluate diff --git a/TODO.md b/TODO.md index cd8960f..e26d109 100644 --- a/TODO.md +++ b/TODO.md @@ -424,12 +424,13 @@ Order by what unblocks the most, and by what does not need `libakgl` to grow fir | B. Housekeeping | `NEW`, `CLR`, `CONT`, `RESTORE`, `RENUMBER`, `SWAP`, `TRON`, `TROFF`, `HELP` | nothing | | C. Errors | `TRAP`, `RESUME`, `ER`, `ERR` | needs a BASIC-visible error object | | D. Strings/format | `USING`, `PUDEF`, `WIDTH`, `CHAR` | nothing | -| E. Console | `GET`, `GETKEY`, `SCNCLR`, `WINDOW`, `KEY`, `SLEEP`, `WAIT`, `TI` | `GET`/`GETKEY`/`SCNCLR` nothing; the rest want the sink or a clock | +| E. Console | `WINDOW`, `KEY`, `SLEEP`, `WAIT`, `TI` | the sink, or the clock `akbasic_runtime_settime()` now carries | | F. Disk | `DOPEN`, `DCLOSE`, `APPEND`, `RECORD`, `HEADER`, `COLLECT`, `BACKUP`, `COPY`, `CONCAT`, `RENAME`, `SCRATCH`, `DIRECTORY`, `CATALOG`, `DCLEAR`, `DVERIFY`, `SAVE`, `LOAD`, `VERIFY`, `BLOAD`, `BSAVE`, `BOOT` | `aksl_f*` only; no new akgl | -| G. Graphics | `GRAPHIC`, `DRAW`, `BOX`, `CIRCLE`, `PAINT`, `COLOR`, `SCALE`, `SSHAPE`, `GSHAPE`, `LOCATE` | nothing — `akgl_draw_*` landed at 42b60f7 | +| ~~G. Graphics~~ | ~~`GRAPHIC`, `DRAW`, `BOX`, `CIRCLE`, `PAINT`, `COLOR`, `SCALE`, `SSHAPE`, `GSHAPE`, `LOCATE`~~ | **done** — `src/runtime_graphics.c`, `tests/graphics_verbs.c` | | H. Sprites | `SPRITE`, `MOVSPR`, `SPRCOLOR`, `SPRDEF`, `SPRSAV`, `COLLISION` | `libakgl` sprite/actor API — check before filing | -| I. Audio | `PLAY`, `SOUND`, `ENVELOPE`, `VOL`, `TEMPO` | nothing — `akgl_audio_*` landed at 42b60f7 | -| I′. Audio, still gapped | `FILTER`, `SOUND`'s sweep arguments | `libakgl` has neither; see §7 | +| ~~I. Audio~~ | ~~`PLAY`, `SOUND`, `ENVELOPE`, `VOL`, `TEMPO`~~ | **done** — `src/runtime_audio.c`, `src/play.c`, `tests/audio_verbs.c` | +| I′. Audio, still gapped | `FILTER`, `SOUND`'s sweep arguments | `libakgl` has neither; both refused with `AKBASIC_ERR_DEVICE`, both filed in §7 | +| ~~E′. Console input~~ | ~~`GET`, `GETKEY`, `SCNCLR`~~ | **done** — `src/runtime_input.c`, `tests/input_verbs.c` | | J. Machine | `SYS`, `FETCH`, `STASH`, `POKE`/`PEEK` variants | nothing | `LOCATE` used to appear in both E and G. It belongs to G alone: in BASIC 7.0 `LOCATE` moves the @@ -552,6 +553,21 @@ behaviour to port. They matter to somebody typing in a listing out of a C128 man default-tempo quarter note at 500 ms — 120 beats per minute. If the real constant turns up, that is the one line to change. +23. **`GETKEY` holds the step loop rather than blocking.** A C128 sits on the keyboard inside + `GETKEY` until somebody types. §1.6 forbids the library blocking, so the verb sets a flag + and `akbasic_runtime_step()` declines to advance the program until a key arrives. Every + step still returns and a bounded `akbasic_runtime_run()` still comes back, so a host keeps + its frame rate; the program simply does not move past the `GETKEY`, which is the part a + program author cares about. The `PLAY` queue is serviced *before* this check on purpose — + music should keep playing while a program waits for a keypress. Withdrawing the input + device while a `GETKEY` is holding releases it rather than wedging the script forever. + +24. **`GET` and `GETKEY` into a numeric variable yield the key code.** BASIC 7.0's `GET` takes + a string variable. Accepting an integer one as well, and giving it the raw code, is what a + program testing for cursor or function keys needs and costs nothing. No key is code zero, + matching what a C128 reports. A float variable is refused: it is neither a character nor a + code. + --- ## 6. Reference defects — ported faithfully, fix them deliberately diff --git a/include/akbasic/input.h b/include/akbasic/input.h index 037f7fc..153cefc 100644 --- a/include/akbasic/input.h +++ b/include/akbasic/input.h @@ -39,4 +39,44 @@ typedef struct akbasic_InputBackend akerr_ErrorContext AKERR_NOIGNORE *(*flush_keys)(struct akbasic_InputBackend *self); } akbasic_InputBackend; +/** + * @brief The console input verbs' state, which lives on the runtime. + * + * `waiting` is what makes GETKEY work without blocking. On a C128, GETKEY sits + * on the keyboard until somebody types; section 1.6 forbids that, so instead the + * verb sets this and akbasic_runtime_step() declines to advance the program + * until a key turns up. A host keeps its frame rate, a bounded + * akbasic_runtime_run() still returns, and the program still does not move past + * the GETKEY -- which is the part the program author cares about. + */ +typedef struct +{ + bool waiting; /* a GETKEY is holding the program */ + char variable[64]; /* the variable it will assign the key to */ + bool numeric; /* true when that variable takes a code, not a letter */ +} akbasic_InputState; + +/* --- Internal API: exposed for the step loop and the tests. --- */ + +struct akbasic_Runtime; + +/** + * @brief Answer whether a GETKEY is still holding, assigning the key if one came. + * + * Called from akbasic_runtime_step() before it executes anything. While + * `*blocked` comes back true the step does nothing at all, so the program stays + * exactly where the GETKEY left it. + * + * Taking the input device away while a GETKEY is holding releases the hold + * rather than wedging the script forever -- a host is allowed to change its mind + * about what it lends out. + * + * @param obj Object to initialize, inspect, or modify. + * @param blocked Output destination populated by the function; true while a GETKEY is unsatisfied. + * @return `NULL` on success, otherwise an error context owned by the caller. + * @throws AKERR_NULLPOINTER When either argument is NULL. + * @throws AKBASIC_ERR_UNDEFINED When the variable the GETKEY named has gone out of scope. + */ +akerr_ErrorContext AKERR_NOIGNORE *akbasic_input_service(struct akbasic_Runtime *obj, bool *blocked); + #endif // _AKBASIC_INPUT_H_ diff --git a/include/akbasic/runtime.h b/include/akbasic/runtime.h index 3fc08dd..bbb2652 100644 --- a/include/akbasic/runtime.h +++ b/include/akbasic/runtime.h @@ -111,6 +111,9 @@ typedef struct akbasic_Runtime */ akbasic_AudioState audio_state; + /* GETKEY's hold on the step loop. Same reasoning again: it is the program's. */ + akbasic_InputState input_state; + /* * The host's clock, in milliseconds, as of its last akbasic_runtime_settime() * call. The interpreter does not read a clock: it owns no loop and must not diff --git a/src/runtime.c b/src/runtime.c index ba0203e..699b16c 100644 --- a/src/runtime.c +++ b/src/runtime.c @@ -824,6 +824,7 @@ akerr_ErrorContext *akbasic_runtime_load(akbasic_Runtime *obj, const char *sourc akerr_ErrorContext *akbasic_runtime_step(akbasic_Runtime *obj) { PREPARE_ERROR(errctx); + bool blocked = false; FAIL_ZERO_RETURN(errctx, (obj != NULL), AKERR_NULLPOINTER, "NULL runtime in step"); @@ -839,6 +840,18 @@ akerr_ErrorContext *akbasic_runtime_step(akbasic_Runtime *obj) SUCCEED_RETURN(errctx); } + /* + * A GETKEY with nothing typed yet holds the program here. The step still + * returns -- a host keeps its frame rate and a bounded run() still comes + * back -- it simply does not advance, which is what GETKEY means. The note + * queue above is serviced first on purpose: music should keep playing while + * a program waits for a keypress. + */ + PASS(errctx, akbasic_input_service(obj, &blocked)); + if ( blocked ) { + SUCCEED_RETURN(errctx); + } + PASS(errctx, akbasic_runtime_zero(obj)); PASS(errctx, akbasic_scanner_zero(obj)); diff --git a/src/runtime_input.c b/src/runtime_input.c new file mode 100644 index 0000000..beaa5c0 --- /dev/null +++ b/src/runtime_input.c @@ -0,0 +1,217 @@ +/** + * @file runtime_input.c + * @brief The console input verbs: GET, GETKEY and SCNCLR. + * + * Same rule as the other two device files -- no SDL here, everything through the + * akbasic_InputBackend record. The libakgl implementation behind it is + * akgl_controller_poll_key(), which drains a ring the library fills from SDL + * events the *host* pumps, so the interpreter answers "is there a key waiting" + * without owning an event loop. + * + * GET and GETKEY differ in exactly one way and it is the interesting one. GET + * takes whatever is there, including nothing. GETKEY waits -- and since the + * library may not block, waiting is spelled as holding the step loop rather than + * as sitting on the keyboard. See TODO.md section 5. + */ + +#include + +#include + +#include +#include +#include + +#include "verbs.h" + +/* Most verbs answer "did something happen"; this is that answer. */ +#define SUCCEED_TRUE(__obj, __dest) \ + do { \ + *(__dest) = &(__obj)->staticTrueValue; \ + } while ( 0 ) + +/** @brief Refuse politely when the host lent us no input device. */ +static akerr_ErrorContext *require_input(akbasic_Runtime *obj, const char *verb) +{ + PREPARE_ERROR(errctx); + + FAIL_ZERO_RETURN(errctx, (obj != NULL && verb != NULL), AKERR_NULLPOINTER, + "NULL argument in require_input"); + FAIL_ZERO_RETURN(errctx, (obj->input != NULL), AKBASIC_ERR_DEVICE, + "%s needs an input device and this runtime has none", verb); + SUCCEED_RETURN(errctx); +} + +/** + * @brief Identify the variable a GET or GETKEY assigns into. + * + * A string variable receives the character; a numeric one receives the key code, + * which is what a program testing for cursor keys or function keys wants. + */ +static akerr_ErrorContext *target_variable(akbasic_Runtime *obj, akbasic_ASTLeaf *expr, const char *verb, akbasic_Variable **dest, bool *numeric) +{ + PREPARE_ERROR(errctx); + akbasic_ASTLeaf *arg = NULL; + + FAIL_ZERO_RETURN(errctx, (obj != NULL && expr != NULL && dest != NULL && numeric != NULL), + AKERR_NULLPOINTER, "NULL argument in target_variable"); + arg = akbasic_leaf_first_argument(expr); + if ( arg == NULL ) { + arg = expr->right; + } + FAIL_ZERO_RETURN(errctx, (arg != NULL), AKBASIC_ERR_SYNTAX, + "%s expected a variable", verb); + FAIL_ZERO_RETURN(errctx, + (arg->leaftype == AKBASIC_LEAF_IDENTIFIER_STRING || + arg->leaftype == AKBASIC_LEAF_IDENTIFIER_INT), + AKBASIC_ERR_TYPE, + "%s expected a string or integer variable", verb); + *numeric = (arg->leaftype == AKBASIC_LEAF_IDENTIFIER_INT); + PASS(errctx, akbasic_environment_get(obj->environment, arg->identifier, dest)); + FAIL_ZERO_RETURN(errctx, (*dest != NULL), AKBASIC_ERR_UNDEFINED, + "%s could not reach the variable %s", verb, arg->identifier); + SUCCEED_RETURN(errctx); +} + +/** @brief Write a keystroke, or the absence of one, into the target variable. */ +static akerr_ErrorContext *store_key(akbasic_Variable *variable, bool numeric, int keycode, bool available) +{ + PREPARE_ERROR(errctx); + int64_t zerosubscript[1] = { 0 }; + char text[2]; + + if ( numeric ) { + /* No key is code zero, which is what a C128 reports too. */ + PASS(errctx, akbasic_variable_set_integer(variable, available ? (int64_t)keycode : 0, + zerosubscript, 1)); + SUCCEED_RETURN(errctx); + } + text[0] = (available && keycode > 0 && keycode < 128) ? (char)keycode : '\0'; + text[1] = '\0'; + PASS(errctx, akbasic_variable_set_string(variable, text, zerosubscript, 1)); + SUCCEED_RETURN(errctx); +} + +/* ----------------------------------------------------------------- GET --- */ + +akerr_ErrorContext *akbasic_cmd_get(akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest) +{ + PREPARE_ERROR(errctx); + akbasic_Variable *variable = NULL; + bool numeric = false; + bool available = false; + int keycode = 0; + + (void)lval; (void)rval; + PASS(errctx, require_input(obj, "GET")); + PASS(errctx, target_variable(obj, expr, "GET", &variable, &numeric)); + + /* + * An empty buffer is success with the empty string, not an error. That is + * what happens on most iterations of any GET loop ever written, and libakgl + * is explicit that its poll reports it the same way. + */ + PASS(errctx, obj->input->poll_key(obj->input, &keycode, &available)); + PASS(errctx, store_key(variable, numeric, keycode, available)); + SUCCEED_TRUE(obj, dest); + SUCCEED_RETURN(errctx); +} + +/* -------------------------------------------------------------- GETKEY --- */ + +akerr_ErrorContext *akbasic_cmd_getkey(akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest) +{ + PREPARE_ERROR(errctx); + akbasic_Variable *variable = NULL; + akbasic_ASTLeaf *arg = NULL; + bool numeric = false; + bool available = false; + int keycode = 0; + + (void)lval; (void)rval; + PASS(errctx, require_input(obj, "GETKEY")); + PASS(errctx, target_variable(obj, expr, "GETKEY", &variable, &numeric)); + + PASS(errctx, obj->input->poll_key(obj->input, &keycode, &available)); + if ( available ) { + obj->input_state.waiting = false; + PASS(errctx, store_key(variable, numeric, keycode, true)); + SUCCEED_TRUE(obj, dest); + SUCCEED_RETURN(errctx); + } + + /* + * Nothing typed yet. A C128 would sit here; the library may not, so instead + * the step loop is told to stop advancing the program counter. The line + * re-executes on each step until a key turns up, which looks the same to the + * program and leaves the host its frame rate. TODO.md section 5. + */ + arg = akbasic_leaf_first_argument(expr); + if ( arg == NULL ) { + arg = expr->right; + } + obj->input_state.waiting = true; + obj->input_state.numeric = numeric; + strncpy(obj->input_state.variable, arg->identifier, + sizeof(obj->input_state.variable) - 1); + obj->input_state.variable[sizeof(obj->input_state.variable) - 1] = '\0'; + SUCCEED_TRUE(obj, dest); + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *akbasic_input_service(akbasic_Runtime *obj, bool *blocked) +{ + PREPARE_ERROR(errctx); + akbasic_Variable *variable = NULL; + bool available = false; + int keycode = 0; + + FAIL_ZERO_RETURN(errctx, (obj != NULL && blocked != NULL), AKERR_NULLPOINTER, + "NULL argument in input_service"); + *blocked = false; + if ( !obj->input_state.waiting ) { + SUCCEED_RETURN(errctx); + } + /* + * A host that takes the input device away while a GETKEY is holding would + * otherwise wedge the script forever. Release it instead -- the variable + * keeps whatever it had, which is the empty string. + */ + if ( obj->input == NULL ) { + obj->input_state.waiting = false; + SUCCEED_RETURN(errctx); + } + + PASS(errctx, obj->input->poll_key(obj->input, &keycode, &available)); + if ( !available ) { + *blocked = true; + SUCCEED_RETURN(errctx); + } + + obj->input_state.waiting = false; + PASS(errctx, akbasic_environment_get(obj->environment, obj->input_state.variable, &variable)); + FAIL_ZERO_RETURN(errctx, (variable != NULL), AKBASIC_ERR_UNDEFINED, + "GETKEY could not reach the variable %s", obj->input_state.variable); + PASS(errctx, store_key(variable, obj->input_state.numeric, keycode, true)); + SUCCEED_RETURN(errctx); +} + +/* -------------------------------------------------------------- SCNCLR --- */ + +akerr_ErrorContext *akbasic_cmd_scnclr(akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest) +{ + PREPARE_ERROR(errctx); + + (void)expr; (void)lval; (void)rval; + FAIL_ZERO_RETURN(errctx, (obj != NULL), AKERR_NULLPOINTER, "NULL runtime in SCNCLR"); + + /* + * Clearing the text screen is the sink's job, not a device's -- the sink is + * where PRINT already goes, and it is the only thing that knows what a + * "screen" means for this host. The stdio sink treats it as a no-op, since + * clearing a pipe means nothing. + */ + PASS(errctx, obj->sink->clear(obj->sink)); + SUCCEED_TRUE(obj, dest); + SUCCEED_RETURN(errctx); +} diff --git a/src/verbs.c b/src/verbs.c index f0219b4..73b23fa 100644 --- a/src/verbs.c +++ b/src/verbs.c @@ -54,6 +54,8 @@ static const akbasic_Verb VERBS[] = { { "EXIT", AKBASIC_TOK_COMMAND, -1, NULL, akbasic_cmd_exit }, { "FILTER", AKBASIC_TOK_COMMAND, -1, akbasic_parse_arglist, akbasic_cmd_filter }, { "FOR", AKBASIC_TOK_COMMAND, -1, akbasic_parse_for, akbasic_cmd_for }, + { "GET", AKBASIC_TOK_COMMAND, -1, NULL, akbasic_cmd_get }, + { "GETKEY", AKBASIC_TOK_COMMAND, -1, NULL, akbasic_cmd_getkey }, { "GOSUB", AKBASIC_TOK_COMMAND, -1, NULL, akbasic_cmd_gosub }, { "GOTO", AKBASIC_TOK_COMMAND, -1, NULL, akbasic_cmd_goto }, { "GRAPHIC", AKBASIC_TOK_COMMAND, -1, akbasic_parse_arglist, akbasic_cmd_graphic }, @@ -89,6 +91,7 @@ static const akbasic_Verb VERBS[] = { { "RIGHT", AKBASIC_TOK_FUNCTION, 2, NULL, akbasic_fn_right }, { "RUN", AKBASIC_TOK_COMMAND_IMMEDIATE, -1, NULL, akbasic_cmd_run }, { "SCALE", AKBASIC_TOK_COMMAND, -1, akbasic_parse_arglist, akbasic_cmd_scale }, + { "SCNCLR", AKBASIC_TOK_COMMAND, -1, NULL, akbasic_cmd_scnclr }, { "SGN", AKBASIC_TOK_FUNCTION, 1, NULL, akbasic_fn_sgn }, { "SHL", AKBASIC_TOK_FUNCTION, 2, NULL, akbasic_fn_shl }, { "SHR", AKBASIC_TOK_FUNCTION, 2, NULL, akbasic_fn_shr }, diff --git a/src/verbs.h b/src/verbs.h index cacccf7..0bdfe71 100644 --- a/src/verbs.h +++ b/src/verbs.h @@ -101,4 +101,9 @@ akerr_ErrorContext AKERR_NOIGNORE *akbasic_cmd_sound(struct akbasic_Runtime *obj akerr_ErrorContext AKERR_NOIGNORE *akbasic_cmd_tempo(struct akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest); akerr_ErrorContext AKERR_NOIGNORE *akbasic_cmd_vol(struct akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest); +/* Group E console input verbs -- src/runtime_input.c */ +akerr_ErrorContext AKERR_NOIGNORE *akbasic_cmd_get(struct akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest); +akerr_ErrorContext AKERR_NOIGNORE *akbasic_cmd_getkey(struct akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest); +akerr_ErrorContext AKERR_NOIGNORE *akbasic_cmd_scnclr(struct akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest); + #endif // _AKBASIC_SRC_VERBS_H_ diff --git a/tests/input_verbs.c b/tests/input_verbs.c new file mode 100644 index 0000000..73774ce --- /dev/null +++ b/tests/input_verbs.c @@ -0,0 +1,197 @@ +/** + * @file input_verbs.c + * @brief Tests GET, GETKEY and SCNCLR against the recording mock backend. + * + * The interesting assertion in here is that GETKEY holds the program without + * blocking the caller. The test drives the step loop by hand and checks that the + * program counter does not move while the key buffer is empty, that the step + * still returns, and that the program resumes the moment a key turns up. + */ + +#include + +#include +#include +#include + +#include "harness.h" +#include "mockdevice.h" +#include "testutil.h" + +/** @brief Bring up a runtime with the mock devices 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_devices(&HARNESS_RUNTIME, &MOCK_GRAPHICS, + &MOCK_AUDIO, &MOCK_INPUT)); + PASS(errctx, akbasic_runtime_load(&HARNESS_RUNTIME, source)); + PASS(errctx, akbasic_runtime_start(&HARNESS_RUNTIME, AKBASIC_MODE_RUN)); + SUCCEED_RETURN(errctx); +} + +/** + * @brief GET on an empty buffer yields the empty string and no error. + * + * This is the common case, not the edge case: it is what happens on most + * iterations of every GET loop ever written. + */ +static void test_get_empty(void) +{ + TEST_REQUIRE_OK(run_program("10 GET A$\n20 PRINT \"[\" + A$ + \"]\"\n")); + TEST_REQUIRE_OK(akbasic_runtime_run(&HARNESS_RUNTIME, 0)); + TEST_REQUIRE_STR(HARNESS_OUTPUT, "[]\n"); + harness_stop(); +} + +/** @brief GET takes one key per call, in the order they were typed. */ +static void test_get_drains_in_order(void) +{ + TEST_REQUIRE_OK(run_program("10 GET A$\n20 PRINT A$\n30 GET A$\n40 PRINT A$\n")); + mock_push_keys("HI"); + TEST_REQUIRE_OK(akbasic_runtime_run(&HARNESS_RUNTIME, 0)); + TEST_REQUIRE_STR(HARNESS_OUTPUT, "H\nI\n"); + harness_stop(); +} + +/** + * @brief A numeric variable receives the key code rather than the character. + * + * That is what a program testing for a cursor key or a function key wants, and + * no key is code zero -- which is what a C128 reports too. + */ +static void test_get_numeric(void) +{ + TEST_REQUIRE_OK(run_program("10 GET A#\n20 PRINT A#\n")); + mock_push_keys("A"); + TEST_REQUIRE_OK(akbasic_runtime_run(&HARNESS_RUNTIME, 0)); + TEST_REQUIRE_STR(HARNESS_OUTPUT, "65\n"); + harness_stop(); + + TEST_REQUIRE_OK(run_program("10 GET A#\n20 PRINT A#\n")); + TEST_REQUIRE_OK(akbasic_runtime_run(&HARNESS_RUNTIME, 0)); + TEST_REQUIRE_STR(HARNESS_OUTPUT, "0\n"); + harness_stop(); +} + +/** + * @brief GETKEY holds the program without blocking the caller. + * + * The assertion that matters. A C128 sits on the keyboard inside GETKEY; here + * every step still returns, a bounded run() still comes back, and the program + * simply does not advance -- which is the part a program author cares about. + */ +static void test_getkey_holds_without_blocking(void) +{ + int i = 0; + + /* + * Lines 1 and 2 rather than the customary 10 and 20, because a step budget + * has to clear them. Source lines are stored indexed by line number and the + * cursor starts at zero, so a step spends itself on each empty slot on the + * way: a program at line 10 needs eleven steps before it has run anything. + * That is worth knowing before writing any bounded-run test. + */ + TEST_REQUIRE_OK(run_program("1 GETKEY A$\n2 PRINT \"GOT \" + A$\n")); + + /* + * A bounded run with an empty buffer returns rather than hanging, and the + * program has not reached line 2. If GETKEY blocked, this call would never + * come back and the test would time out instead of failing. + */ + TEST_REQUIRE_OK(akbasic_runtime_run(&HARNESS_RUNTIME, 20)); + TEST_REQUIRE_STR(HARNESS_OUTPUT, ""); + TEST_REQUIRE(HARNESS_RUNTIME.input_state.waiting, + "GETKEY should be holding the program"); + + /* Any number of further steps changes nothing while the buffer is empty. */ + for ( i = 0; i < 10; i++ ) { + TEST_REQUIRE_OK(akbasic_runtime_step(&HARNESS_RUNTIME)); + } + TEST_REQUIRE_STR(HARNESS_OUTPUT, ""); + + /* A keystroke releases it, and the program picks up where it left off. */ + mock_push_keys("X"); + TEST_REQUIRE_OK(akbasic_runtime_run(&HARNESS_RUNTIME, 0)); + TEST_REQUIRE_STR(HARNESS_OUTPUT, "GOT X\n"); + TEST_REQUIRE(!HARNESS_RUNTIME.input_state.waiting, + "GETKEY should have released once a key arrived"); + harness_stop(); +} + +/** @brief A key already waiting satisfies GETKEY on the spot. */ +static void test_getkey_immediate(void) +{ + TEST_REQUIRE_OK(run_program("10 GETKEY A$\n20 PRINT \"GOT \" + A$\n")); + mock_push_keys("Z"); + TEST_REQUIRE_OK(akbasic_runtime_run(&HARNESS_RUNTIME, 0)); + TEST_REQUIRE_STR(HARNESS_OUTPUT, "GOT Z\n"); + TEST_REQUIRE(!HARNESS_RUNTIME.input_state.waiting, + "GETKEY should not have held when a key was already waiting"); + harness_stop(); +} + +/** + * @brief Taking the device away mid-GETKEY releases the hold rather than wedging. + * + * A host is allowed to change its mind about what it lends out, and a script + * stuck forever on a device that no longer exists is the worst of the available + * outcomes. + */ +static void test_getkey_device_withdrawn(void) +{ + TEST_REQUIRE_OK(run_program("1 GETKEY A$\n2 PRINT \"RELEASED\"\n")); + TEST_REQUIRE_OK(akbasic_runtime_run(&HARNESS_RUNTIME, 20)); + TEST_REQUIRE(HARNESS_RUNTIME.input_state.waiting, "GETKEY should be holding"); + + TEST_REQUIRE_OK(akbasic_runtime_set_devices(&HARNESS_RUNTIME, NULL, NULL, NULL)); + TEST_REQUIRE_OK(akbasic_runtime_run(&HARNESS_RUNTIME, 0)); + TEST_REQUIRE_STR(HARNESS_OUTPUT, "RELEASED\n"); + harness_stop(); +} + +/** @brief SCNCLR goes through the sink, so it needs no input device at all. */ +static void test_scnclr(void) +{ + TEST_REQUIRE_OK(harness_start(NULL)); + TEST_REQUIRE_OK(akbasic_runtime_load(&HARNESS_RUNTIME, "10 SCNCLR\n20 PRINT \"AFTER\"\n")); + TEST_REQUIRE_OK(akbasic_runtime_start(&HARNESS_RUNTIME, AKBASIC_MODE_RUN)); + TEST_REQUIRE_OK(akbasic_runtime_run(&HARNESS_RUNTIME, 0)); + /* The stdio sink treats a clear as a no-op: clearing a pipe means nothing. */ + TEST_REQUIRE_STR(HARNESS_OUTPUT, "AFTER\n"); + harness_stop(); +} + +/** @brief GET and GETKEY name themselves when there is no device, and check types. */ +static void test_errors(void) +{ + TEST_REQUIRE_OK(harness_start(NULL)); + TEST_REQUIRE_OK(akbasic_runtime_load(&HARNESS_RUNTIME, "10 GET 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, "GET needs an input device") != NULL, + "GET without a device should name itself, got \"%s\"", HARNESS_OUTPUT); + harness_stop(); + + /* A float variable is neither a character nor a key code. */ + TEST_REQUIRE_OK(run_program("10 GET A%\n")); + TEST_REQUIRE_OK(akbasic_runtime_run(&HARNESS_RUNTIME, 0)); + TEST_REQUIRE(strstr(HARNESS_OUTPUT, "string or integer variable") != NULL, + "GET into a float should be refused, got \"%s\"", HARNESS_OUTPUT); + harness_stop(); +} + +int main(void) +{ + test_get_empty(); + test_get_drains_in_order(); + test_get_numeric(); + test_getkey_holds_without_blocking(); + test_getkey_immediate(); + test_getkey_device_withdrawn(); + test_scnclr(); + test_errors(); + return akbasic_test_failures; +} diff --git a/tests/language/input/no_device.bas b/tests/language/input/no_device.bas new file mode 100644 index 0000000..2249292 --- /dev/null +++ b/tests/language/input/no_device.bas @@ -0,0 +1,5 @@ +10 REM SCNCLR goes through the text sink, which every runtime has, so it works +20 REM with no input device attached. GET needs one and must name itself. +30 SCNCLR +40 PRINT "SCNCLR OK" +50 GET A$ diff --git a/tests/language/input/no_device.txt b/tests/language/input/no_device.txt new file mode 100644 index 0000000..39c897b --- /dev/null +++ b/tests/language/input/no_device.txt @@ -0,0 +1,3 @@ +SCNCLR OK +? 50 : RUNTIME ERROR GET needs an input device and this runtime has none +