Add akbasic_runtime_global: a host variable lands in the script's root scope
A host creating a variable while a script was suspended got it in whatever scope was active -- usually a FOR or GOSUB body -- and it died when the body popped, silently. Reaching for the root by hand returned NULL without raising, because environment_get only auto-creates in the active environment. Both are still true of environment_get, which is correct for what the interpreter uses it for. The README and the example now point somewhere else. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
213
tests/hostvars.c
Normal file
213
tests/hostvars.c
Normal file
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* @file hostvars.c
|
||||
* @brief Tests the host-facing variable exchange, from the host's side.
|
||||
*
|
||||
* TODO.md section 6 item 17: a host could not reliably *create* a script
|
||||
* variable while a script was suspended, and neither way of trying failed
|
||||
* loudly. Through akbasic_environment_get() the variable landed in whichever
|
||||
* scope happened to be active -- usually a `FOR` or `GOSUB` body -- and died
|
||||
* when that body popped, so the script read it correctly right up until it
|
||||
* stopped. Reaching for the root by hand returned NULL through `dest` without
|
||||
* raising, because that function only auto-creates in the active environment.
|
||||
*
|
||||
* akbasic_runtime_global() is the answer to both, and what is asserted here is
|
||||
* the part a host actually cares about: a value it set mid-run is still there
|
||||
* after the loop it was set from has finished.
|
||||
*/
|
||||
|
||||
#include "harness.h"
|
||||
|
||||
/**
|
||||
* @brief Step a program until it is suspended inside a nested scope.
|
||||
*
|
||||
* One step at a time until a child environment is active, rather than a fixed
|
||||
* budget: a step processes one *source line index*, and the interpreter files
|
||||
* lines under their BASIC line numbers, so `10 FOR` is eleven steps in and any
|
||||
* count written here would be a hostage to the line numbers in the fixture.
|
||||
*
|
||||
* Bounded so a program that never nests fails the assertion below rather than
|
||||
* hanging the suite.
|
||||
*/
|
||||
static akerr_ErrorContext AKERR_NOIGNORE *run_until_nested(const char *source)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
int steps = 0;
|
||||
|
||||
PASS(errctx, akbasic_runtime_load(&HARNESS_RUNTIME, source));
|
||||
PASS(errctx, akbasic_runtime_start(&HARNESS_RUNTIME, AKBASIC_MODE_RUN));
|
||||
while ( steps < AKBASIC_MAX_SOURCE_LINES &&
|
||||
HARNESS_RUNTIME.mode != AKBASIC_MODE_QUIT &&
|
||||
HARNESS_RUNTIME.environment->parent == NULL ) {
|
||||
PASS(errctx, akbasic_runtime_run(&HARNESS_RUNTIME, 1));
|
||||
steps += 1;
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/** @brief Set a global to an integer, the way an embedding host would. */
|
||||
static akerr_ErrorContext AKERR_NOIGNORE *set_global(const char *name, int64_t value)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
akbasic_Variable *variable = NULL;
|
||||
int64_t zerosubscript[1] = { 0 };
|
||||
|
||||
PASS(errctx, akbasic_runtime_global(&HARNESS_RUNTIME, name, &variable));
|
||||
FAIL_ZERO_RETURN(errctx, (variable != NULL), AKBASIC_ERR_UNDEFINED,
|
||||
"runtime_global returned NULL for %s", name);
|
||||
PASS(errctx, akbasic_variable_set_integer(variable, value, zerosubscript, 1));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief A global created while suspended inside a FOR body outlives the loop.
|
||||
*
|
||||
* The case that motivated the whole entry. `H#` is created from outside while
|
||||
* the script sits in the loop, and line 40 -- which runs after `NEXT` has popped
|
||||
* the loop's scope -- has to still see it.
|
||||
*/
|
||||
static akerr_ErrorContext AKERR_NOIGNORE *test_global_survives_a_for_body(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
TEST_REQUIRE_OK(harness_start(NULL));
|
||||
PASS(errctx, run_until_nested("10 FOR I# = 1 TO 3\n"
|
||||
"20 PRINT I#\n"
|
||||
"30 NEXT I#\n"
|
||||
"40 PRINT H#\n"));
|
||||
|
||||
/* Suspended below the root, which is the state that used to break this. */
|
||||
TEST_REQUIRE(HARNESS_RUNTIME.environment != NULL, "the runtime must have a scope");
|
||||
TEST_REQUIRE(HARNESS_RUNTIME.environment->parent != NULL,
|
||||
"the script should be suspended inside the FOR body");
|
||||
|
||||
PASS(errctx, set_global("H#", 42));
|
||||
PASS(errctx, akbasic_runtime_run(&HARNESS_RUNTIME, 0));
|
||||
|
||||
/* 1 2 3 from the loop, then 42 from after it. The 42 is the assertion. */
|
||||
TEST_REQUIRE_STR(HARNESS_OUTPUT, "1\n2\n3\n42\n");
|
||||
harness_stop();
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/** @brief The same from inside a GOSUB, which is the other nesting a host meets. */
|
||||
static akerr_ErrorContext AKERR_NOIGNORE *test_global_survives_a_gosub(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
TEST_REQUIRE_OK(harness_start(NULL));
|
||||
/*
|
||||
* The QUIT is not decoration. Without a terminator the program runs off the
|
||||
* end of line 20 into the subroutine again, and its RETURN then has no GOSUB
|
||||
* to return to -- which is ordinary BASIC and would only obscure what is
|
||||
* being asserted here.
|
||||
*/
|
||||
PASS(errctx, run_until_nested("10 GOSUB 100\n"
|
||||
"20 PRINT H#\n"
|
||||
"30 QUIT\n"
|
||||
"100 PRINT \"IN\"\n"
|
||||
"110 RETURN\n"));
|
||||
|
||||
TEST_REQUIRE(HARNESS_RUNTIME.environment->parent != NULL,
|
||||
"the script should be suspended inside the subroutine");
|
||||
PASS(errctx, set_global("H#", 7));
|
||||
PASS(errctx, akbasic_runtime_run(&HARNESS_RUNTIME, 0));
|
||||
|
||||
TEST_REQUIRE_STR(HARNESS_OUTPUT, "IN\n7\n");
|
||||
harness_stop();
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief The NULL-without-error path is gone: this always creates or raises.
|
||||
*
|
||||
* akbasic_environment_get() against a non-active environment is the shape that
|
||||
* used to bite, and it is asserted here as well -- unchanged, because it is
|
||||
* still the right behaviour for *that* function and it is what makes
|
||||
* runtime_global() necessary rather than merely convenient.
|
||||
*/
|
||||
static akerr_ErrorContext AKERR_NOIGNORE *test_global_never_returns_null(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
akbasic_Environment *root = NULL;
|
||||
akbasic_Variable *variable = NULL;
|
||||
|
||||
TEST_REQUIRE_OK(harness_start(NULL));
|
||||
root = HARNESS_RUNTIME.environment;
|
||||
PASS(errctx, akbasic_runtime_new_environment(&HARNESS_RUNTIME));
|
||||
|
||||
/* The old way, with a child active: NULL, and nothing raised. */
|
||||
variable = NULL;
|
||||
TEST_REQUIRE_OK(akbasic_environment_get(root, "NEW#", &variable));
|
||||
TEST_REQUIRE(variable == NULL,
|
||||
"environment_get must still decline to create in a non-active scope");
|
||||
|
||||
/* The new way: created, in the root, every time. */
|
||||
variable = NULL;
|
||||
PASS(errctx, akbasic_runtime_global(&HARNESS_RUNTIME, "NEW#", &variable));
|
||||
TEST_REQUIRE(variable != NULL, "runtime_global must create rather than return NULL");
|
||||
|
||||
/* And it is genuinely in the root, visible from the child. */
|
||||
TEST_REQUIRE_OK(akbasic_environment_get(HARNESS_RUNTIME.environment, "NEW#", &variable));
|
||||
TEST_REQUIRE(variable != NULL, "the child must see the global the host created");
|
||||
|
||||
/* Twice is the same variable, not a second one shadowing the first. */
|
||||
{
|
||||
akbasic_Variable *again = NULL;
|
||||
PASS(errctx, akbasic_runtime_global(&HARNESS_RUNTIME, "NEW#", &again));
|
||||
TEST_REQUIRE(again == variable, "a repeat call must find the variable it created");
|
||||
}
|
||||
|
||||
TEST_REQUIRE_STATUS(akbasic_runtime_global(NULL, "A#", &variable), AKERR_NULLPOINTER);
|
||||
TEST_REQUIRE_STATUS(akbasic_runtime_global(&HARNESS_RUNTIME, NULL, &variable),
|
||||
AKERR_NULLPOINTER);
|
||||
TEST_REQUIRE_STATUS(akbasic_runtime_global(&HARNESS_RUNTIME, "A#", NULL),
|
||||
AKERR_NULLPOINTER);
|
||||
|
||||
TEST_REQUIRE_OK(akbasic_runtime_prev_environment(&HARNESS_RUNTIME));
|
||||
harness_stop();
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/** @brief Seeding before the script starts, and reading after it stops, still work. */
|
||||
static akerr_ErrorContext AKERR_NOIGNORE *test_seed_and_read_back(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
akbasic_Variable *variable = NULL;
|
||||
akbasic_Value *value = NULL;
|
||||
int64_t zerosubscript[1] = { 0 };
|
||||
|
||||
TEST_REQUIRE_OK(harness_start(NULL));
|
||||
PASS(errctx, set_global("SEED#", 10));
|
||||
|
||||
PASS(errctx, akbasic_runtime_load(&HARNESS_RUNTIME, "10 OUT# = SEED# * 2\n"
|
||||
"20 PRINT OUT#\n"));
|
||||
PASS(errctx, akbasic_runtime_start(&HARNESS_RUNTIME, AKBASIC_MODE_RUN));
|
||||
PASS(errctx, akbasic_runtime_run(&HARNESS_RUNTIME, 0));
|
||||
TEST_REQUIRE_STR(HARNESS_OUTPUT, "20\n");
|
||||
|
||||
PASS(errctx, akbasic_runtime_global(&HARNESS_RUNTIME, "OUT#", &variable));
|
||||
PASS(errctx, akbasic_variable_get_subscript(variable, zerosubscript, 1, &value));
|
||||
TEST_REQUIRE_INT(value->intval, 20);
|
||||
|
||||
harness_stop();
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, test_global_survives_a_for_body());
|
||||
CATCH(errctx, test_global_survives_a_gosub());
|
||||
CATCH(errctx, test_global_never_returns_null());
|
||||
CATCH(errctx, test_seed_and_read_back());
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} HANDLE_DEFAULT(errctx) {
|
||||
LOG_ERROR_WITH_MESSAGE(errctx, "host variable test failed");
|
||||
akbasic_test_failures += 1;
|
||||
} FINISH_NORETURN(errctx);
|
||||
|
||||
return akbasic_test_failures;
|
||||
}
|
||||
Reference in New Issue
Block a user