Files
akbasic/tests/interrupts.c

367 lines
15 KiB
C
Raw Permalink Normal View History

Finish the language: every remaining verb group, and the defects that blocked them Closes groups A, C, D, E, F, H and J of TODO.md section 4, plus RESTORE and RENUMBER, and closes section 6 -- all seventeen reference defects. Seven of those turned out to have been fixed or never ported and nobody had written it down; the audit records the evidence for each. Two of the seventeen were real. math_plus mutated its left operand when the operand was mutable, so A# + 1 could modify A#; it was gated on FOR/NEXT coverage because NEXT relied on the mutation, so tests/for_next.c came first and NEXT now writes the counter back itself. And the binary operators summed both numeric fields of their right operand, which no BASIC program can reach -- that one needed a test written against the value API. Writing the tests turned up eight defects nobody had listed. Seven are fixed: IF A = 2 THEN was a parse error; only == worked IF ... AND ... was a parse error, because a condition parsed as one relation IF A = 1 OR B = 2 THEN was silently always false, and so was IF A THEN EXIT before any NEXT restarted the program and exhausted the variable pool READ never found a DATA line above it, and swallowed the lines between PRINT 2 + 2 at the prompt was filed as program text instead of answering a short read discarded its bytes, so COPY produced empty files every verb taking an argument list said "peek() returned nil token!" on none The eighth is not fixed and cannot be quietly: a FOR whose step overshoots runs its body one extra time, and FOR I = 1 TO 1 runs it zero times. The two errors cancel for a step of 1, which is why neither was noticed. Correcting them changes the expected output of a checked-in acceptance file, and tests/reference/README.md forbids editing one to suit this interpreter. It is tests/for_semantics.c in AKBASIC_KNOWN_FAILING_TESTS, asserting the correct contract, and TODO.md items 19 and 20. Sprites are real libakgl actors with a renderfunc of their own, because akgl_actor_render draws every sprite square and an actor has no per-axis scale. Both are filed upstream. SPRSAV takes an image file, an SSHAPE handle or a 63-element integer array -- a string here cannot hold a zero byte. Verbs that need hardware that does not exist are refused by name with the reason rather than faked: SYS, HEADER, COLLECT, BACKUP, BOOT, FILTER, and DIRECTORY, which is refused for a missing libakstdlib wrapper filed upstream. 94 tests in the default build, 93 with SDL, 94 under ASan and UBSan, doxygen clean. The Go acceptance corpus stayed green throughout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 21:50:37 -04:00
/**
* @file interrupts.c
* @brief Tests the interrupt machinery: arm, raise, enter a handler, RETURN.
*
* This is the part of COLLISION that has nothing to do with sprites, and it is
* tested on its own for two reasons. It is what group C's TRAP will reuse, so it
* has to be correct before anything is built on it; and driving it through the
* API rather than through a verb is the only way to fire an interrupt at an
* exactly known point in a program, which is what most of these assertions turn
* on.
*
* **Every program here is numbered 1, 2, 3, ...** One step of the runtime
* advances by one source line *index*, empty ones included -- so consecutive
* numbering is what makes a step count mean a statement count. The first step of
* any run is line 0, which is always empty, and is accounted for in each count
* below.
*/
#include "harness.h"
/**
* @brief A program with a handler at 3 and a main loop that never ends.
*
* The endless loop is on purpose: every test here steps a fixed number of times
* and asserts what came out, so a regression that fails to enter or fails to
* return shows up as the wrong output rather than as a run that stops early and
* looks plausible.
*/
static const char *PROGRAM_BY_LINE =
"1 PRINT \"START\"\n"
"2 GOTO 5\n"
"3 PRINT \"HANDLER\"\n"
"4 RETURN\n"
"5 PRINT \"MAIN\"\n"
"6 GOTO 5\n";
/** @brief Load a program and put the runtime in RUN mode without stepping it. */
static akerr_ErrorContext AKERR_NOIGNORE *load_program(const char *source)
{
PREPARE_ERROR(errctx);
PASS(errctx, akbasic_runtime_load(&HARNESS_RUNTIME, source));
PASS(errctx, akbasic_runtime_start(&HARNESS_RUNTIME, AKBASIC_MODE_RUN));
SUCCEED_RETURN(errctx);
}
/** @brief A handler named by line number runs, and RETURN resumes where it left. */
static akerr_ErrorContext AKERR_NOIGNORE *test_enter_and_return(void)
{
PREPARE_ERROR(errctx);
TEST_REQUIRE_OK(harness_start(NULL));
PASS(errctx, load_program(PROGRAM_BY_LINE));
PASS(errctx, akbasic_runtime_arm_interrupt(&HARNESS_RUNTIME, AKBASIC_INTERRUPT_SPRITE, 3, NULL));
/* Line 0, then 1 and 2, so the program is sitting on 5 with nothing pending. */
PASS(errctx, akbasic_runtime_run(&HARNESS_RUNTIME, 3));
TEST_REQUIRE_STR(HARNESS_OUTPUT, "START\n");
PASS(errctx, akbasic_runtime_raise_interrupt(&HARNESS_RUNTIME, AKBASIC_INTERRUPT_SPRITE));
TEST_REQUIRE(HARNESS_RUNTIME.handlerenv == NULL, "raising must not enter anything by itself");
/* One step: the handler is entered and its first line runs. */
PASS(errctx, akbasic_runtime_run(&HARNESS_RUNTIME, 1));
TEST_REQUIRE_STR(HARNESS_OUTPUT, "START\nHANDLER\n");
TEST_REQUIRE(HARNESS_RUNTIME.handlerenv != NULL, "the runtime should know it is in a handler");
TEST_REQUIRE(!HARNESS_RUNTIME.interrupts[AKBASIC_INTERRUPT_SPRITE].pending,
"entering the handler should have consumed the event");
/* The RETURN, then the line the interrupt took the program away from. */
PASS(errctx, akbasic_runtime_run(&HARNESS_RUNTIME, 2));
TEST_REQUIRE_STR(HARNESS_OUTPUT, "START\nHANDLER\nMAIN\n");
TEST_REQUIRE(HARNESS_RUNTIME.handlerenv == NULL, "RETURN should have left the handler");
/* Still armed: one collision handled is not a subscription cancelled. */
TEST_REQUIRE(HARNESS_RUNTIME.interrupts[AKBASIC_INTERRUPT_SPRITE].armed,
"the handler should still be armed after it returns");
harness_stop();
SUCCEED_RETURN(errctx);
}
/**
* @brief A handler named by label, on a line the program never falls into.
*
* This is the shape a handler is actually written in -- jumped over by the main
* flow, reachable only through the interrupt -- and it works only because
* akbasic_runtime_scan_labels() files LABEL before anything runs. Without the
* prescan the label does not exist when the interrupt fires and the run stops
* with AKBASIC_ERR_UNDEFINED.
*/
static akerr_ErrorContext AKERR_NOIGNORE *test_label_handler(void)
{
PREPARE_ERROR(errctx);
TEST_REQUIRE_OK(harness_start(NULL));
PASS(errctx, load_program("1 GOTO 5\n"
"2 LABEL BUMPED\n"
"3 PRINT \"HANDLER\"\n"
"4 RETURN\n"
"5 PRINT \"MAIN\"\n"
"6 GOTO 5\n"));
PASS(errctx, akbasic_runtime_arm_interrupt(&HARNESS_RUNTIME, AKBASIC_INTERRUPT_SPRITE,
0, "BUMPED"));
/* Line 0, the GOTO, then the main loop's PRINT. */
PASS(errctx, akbasic_runtime_run(&HARNESS_RUNTIME, 3));
TEST_REQUIRE_STR(HARNESS_OUTPUT, "MAIN\n");
PASS(errctx, akbasic_runtime_raise_interrupt(&HARNESS_RUNTIME, AKBASIC_INTERRUPT_SPRITE));
/* The LABEL line the handler is entered on, then the handler's PRINT. */
PASS(errctx, akbasic_runtime_run(&HARNESS_RUNTIME, 2));
TEST_REQUIRE_STR(HARNESS_OUTPUT, "MAIN\nHANDLER\n");
/* RETURN, then back into the main loop where it left off. */
PASS(errctx, akbasic_runtime_run(&HARNESS_RUNTIME, 3));
TEST_REQUIRE_STR(HARNESS_OUTPUT, "MAIN\nHANDLER\nMAIN\n");
harness_stop();
SUCCEED_RETURN(errctx);
}
/**
* @brief An interrupt does not interrupt an interrupt.
*
* Without the guard a collision that is still true while its own handler runs
* re-enters on the next line, and keeps re-entering until the environment pool
* is gone -- the failure would be AKBASIC_ERR_ENVIRONMENT from somewhere with
* nothing to do with sprites. The event raised inside the handler is not lost,
* though: it is taken as soon as the RETURN lands.
*/
static akerr_ErrorContext AKERR_NOIGNORE *test_no_reentry(void)
{
PREPARE_ERROR(errctx);
TEST_REQUIRE_OK(harness_start(NULL));
PASS(errctx, load_program("1 GOTO 5\n"
"2 PRINT \"IN\"\n"
"3 PRINT \"OUT\"\n"
"4 RETURN\n"
"5 PRINT \"MAIN\"\n"
"6 GOTO 5\n"));
PASS(errctx, akbasic_runtime_arm_interrupt(&HARNESS_RUNTIME, AKBASIC_INTERRUPT_SPRITE, 2, NULL));
PASS(errctx, akbasic_runtime_run(&HARNESS_RUNTIME, 3));
TEST_REQUIRE_STR(HARNESS_OUTPUT, "MAIN\n");
PASS(errctx, akbasic_runtime_raise_interrupt(&HARNESS_RUNTIME, AKBASIC_INTERRUPT_SPRITE));
PASS(errctx, akbasic_runtime_run(&HARNESS_RUNTIME, 1));
TEST_REQUIRE_STR(HARNESS_OUTPUT, "MAIN\nIN\n");
/* Fires again while the handler is still running. */
PASS(errctx, akbasic_runtime_raise_interrupt(&HARNESS_RUNTIME, AKBASIC_INTERRUPT_SPRITE));
PASS(errctx, akbasic_runtime_run(&HARNESS_RUNTIME, 1));
TEST_REQUIRE_STR(HARNESS_OUTPUT, "MAIN\nIN\nOUT\n");
TEST_REQUIRE(HARNESS_RUNTIME.interrupts[AKBASIC_INTERRUPT_SPRITE].pending,
"an event raised inside a handler should be held, not dropped");
/* The RETURN lands, and the held event is taken on the very next step. */
PASS(errctx, akbasic_runtime_run(&HARNESS_RUNTIME, 2));
TEST_REQUIRE_STR(HARNESS_OUTPUT, "MAIN\nIN\nOUT\nIN\n");
harness_stop();
SUCCEED_RETURN(errctx);
}
/** @brief A GOSUB the handler itself makes returns without re-arming interrupts. */
static akerr_ErrorContext AKERR_NOIGNORE *test_nested_gosub_keeps_the_flag(void)
{
PREPARE_ERROR(errctx);
TEST_REQUIRE_OK(harness_start(NULL));
PASS(errctx, load_program("1 GOTO 5\n"
"2 GOSUB 8\n"
"3 RETURN\n"
"5 PRINT \"MAIN\"\n"
"6 GOTO 5\n"
"8 PRINT \"INNER\"\n"
"9 RETURN\n"));
PASS(errctx, akbasic_runtime_arm_interrupt(&HARNESS_RUNTIME, AKBASIC_INTERRUPT_SPRITE, 2, NULL));
PASS(errctx, akbasic_runtime_run(&HARNESS_RUNTIME, 3));
PASS(errctx, akbasic_runtime_raise_interrupt(&HARNESS_RUNTIME, AKBASIC_INTERRUPT_SPRITE));
/* Enter the handler, which immediately GOSUBs deeper. */
PASS(errctx, akbasic_runtime_run(&HARNESS_RUNTIME, 1));
TEST_REQUIRE(HARNESS_RUNTIME.handlerenv != NULL, "the handler should be running");
/* The inner PRINT and the inner RETURN: still inside the handler. */
PASS(errctx, akbasic_runtime_run(&HARNESS_RUNTIME, 2));
TEST_REQUIRE_STR(HARNESS_OUTPUT, "MAIN\nINNER\n");
TEST_REQUIRE(HARNESS_RUNTIME.handlerenv != NULL,
"the inner RETURN belongs to the inner GOSUB, not to the handler");
/* The handler's own RETURN is the one that clears it. */
PASS(errctx, akbasic_runtime_run(&HARNESS_RUNTIME, 1));
TEST_REQUIRE(HARNESS_RUNTIME.handlerenv == NULL, "the handler's RETURN should have cleared it");
harness_stop();
SUCCEED_RETURN(errctx);
}
/** @brief Raising an unarmed source records nothing; disarming drops what is held. */
static akerr_ErrorContext AKERR_NOIGNORE *test_arm_and_disarm(void)
{
PREPARE_ERROR(errctx);
TEST_REQUIRE_OK(harness_start(NULL));
PASS(errctx, load_program(PROGRAM_BY_LINE));
/* Unarmed. A backend may raise every frame without asking who is listening. */
PASS(errctx, akbasic_runtime_raise_interrupt(&HARNESS_RUNTIME, AKBASIC_INTERRUPT_SPRITE));
TEST_REQUIRE(!HARNESS_RUNTIME.interrupts[AKBASIC_INTERRUPT_SPRITE].pending,
"an unarmed source should record nothing");
/* Armed, raised, then taken away again before it could be taken. */
PASS(errctx, akbasic_runtime_arm_interrupt(&HARNESS_RUNTIME, AKBASIC_INTERRUPT_SPRITE, 3, NULL));
PASS(errctx, akbasic_runtime_raise_interrupt(&HARNESS_RUNTIME, AKBASIC_INTERRUPT_SPRITE));
PASS(errctx, akbasic_runtime_disarm_interrupt(&HARNESS_RUNTIME, AKBASIC_INTERRUPT_SPRITE));
TEST_REQUIRE(!HARNESS_RUNTIME.interrupts[AKBASIC_INTERRUPT_SPRITE].pending,
"disarming should drop a pending event rather than deferring it");
/* And the program runs straight through without ever seeing line 3. */
PASS(errctx, akbasic_runtime_run(&HARNESS_RUNTIME, 6));
TEST_REQUIRE_STR(HARNESS_OUTPUT, "START\nMAIN\nMAIN\n");
harness_stop();
SUCCEED_RETURN(errctx);
}
/** @brief NEW disarms everything: the handler lines belong to a program that is gone. */
static akerr_ErrorContext AKERR_NOIGNORE *test_new_disarms(void)
{
PREPARE_ERROR(errctx);
akbasic_ASTLeaf *leaf = NULL;
akbasic_Value *out = NULL;
TEST_REQUIRE_OK(harness_start(NULL));
PASS(errctx, load_program(PROGRAM_BY_LINE));
PASS(errctx, akbasic_runtime_arm_interrupt(&HARNESS_RUNTIME, AKBASIC_INTERRUPT_SPRITE, 3, NULL));
PASS(errctx, akbasic_environment_zero(HARNESS_RUNTIME.environment));
PASS(errctx, harness_parse("NEW", &leaf));
PASS(errctx, akbasic_runtime_evaluate(&HARNESS_RUNTIME, leaf, &out));
TEST_REQUIRE(!HARNESS_RUNTIME.interrupts[AKBASIC_INTERRUPT_SPRITE].armed,
"NEW should have disarmed the interrupt");
harness_stop();
SUCCEED_RETURN(errctx);
}
/**
* @brief A handler label that names nothing is the program's error, not the host's.
*
* It has to be *reported* and stop the run. Letting it out of
* akbasic_runtime_step() would tear down an embedding game over a typo in a
* script, which is what goal 3 exists to prevent.
*/
static akerr_ErrorContext AKERR_NOIGNORE *test_undefined_label_is_reported(void)
{
PREPARE_ERROR(errctx);
TEST_REQUIRE_OK(harness_start(NULL));
PASS(errctx, load_program("1 PRINT \"MAIN\"\n"
"2 GOTO 1\n"));
PASS(errctx, akbasic_runtime_arm_interrupt(&HARNESS_RUNTIME, AKBASIC_INTERRUPT_SPRITE,
0, "NOSUCHLABEL"));
PASS(errctx, akbasic_runtime_run(&HARNESS_RUNTIME, 2));
PASS(errctx, akbasic_runtime_raise_interrupt(&HARNESS_RUNTIME, AKBASIC_INTERRUPT_SPRITE));
/* Returns cleanly -- the error is on the sink, not in the return value. */
PASS(errctx, akbasic_runtime_run(&HARNESS_RUNTIME, 1));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "NOSUCHLABEL") != NULL,
"the report should name the label that could not be found, got: %s",
HARNESS_OUTPUT);
TEST_REQUIRE_INT(HARNESS_RUNTIME.mode, AKBASIC_MODE_QUIT);
harness_stop();
SUCCEED_RETURN(errctx);
}
/** @brief Arming refuses a source out of range, and a target that is both or neither. */
static akerr_ErrorContext AKERR_NOIGNORE *test_arm_refusals(void)
{
PREPARE_ERROR(errctx);
TEST_REQUIRE_OK(harness_start(NULL));
TEST_REQUIRE_STATUS(akbasic_runtime_arm_interrupt(&HARNESS_RUNTIME,
(akbasic_InterruptSource)AKBASIC_MAX_INTERRUPTS,
3, NULL),
AKBASIC_ERR_BOUNDS);
TEST_REQUIRE_STATUS(akbasic_runtime_arm_interrupt(&HARNESS_RUNTIME, AKBASIC_INTERRUPT_SPRITE,
0, NULL),
AKBASIC_ERR_VALUE);
TEST_REQUIRE_STATUS(akbasic_runtime_arm_interrupt(&HARNESS_RUNTIME, AKBASIC_INTERRUPT_SPRITE,
3, "BUMPED"),
AKBASIC_ERR_VALUE);
TEST_REQUIRE_STATUS(akbasic_runtime_raise_interrupt(&HARNESS_RUNTIME,
(akbasic_InterruptSource)-1),
AKBASIC_ERR_BOUNDS);
TEST_REQUIRE_STATUS(akbasic_runtime_disarm_interrupt(&HARNESS_RUNTIME,
(akbasic_InterruptSource)AKBASIC_MAX_INTERRUPTS),
AKBASIC_ERR_BOUNDS);
TEST_REQUIRE_STATUS(akbasic_runtime_arm_interrupt(NULL, AKBASIC_INTERRUPT_SPRITE, 3, NULL),
AKERR_NULLPOINTER);
TEST_REQUIRE_STATUS(akbasic_runtime_service_interrupts(NULL, NULL), AKERR_NULLPOINTER);
TEST_REQUIRE_STATUS(akbasic_runtime_scan_labels(NULL), AKERR_NULLPOINTER);
harness_stop();
SUCCEED_RETURN(errctx);
}
/**
* @brief The label prescan reads LABEL where it is a statement, and nowhere else.
*
* The forward jump itself is covered by the golden case at
* tests/language/statements/label_forward.bas. What is here is the part a
* running program cannot show: that a string literal holding the word LABEL
* files nothing, and that a second statement on a line is still a statement.
*/
static akerr_ErrorContext AKERR_NOIGNORE *test_prescan_boundaries(void)
{
PREPARE_ERROR(errctx);
int64_t line = 0;
TEST_REQUIRE_OK(harness_start(NULL));
PASS(errctx, load_program("1 PRINT \"LABEL DECOY\"\n"
"2 A# = 1 : LABEL SECOND\n"
"3 LABELLED# = 2\n"));
PASS(errctx, akbasic_environment_get_label(HARNESS_RUNTIME.environment, "SECOND", &line));
TEST_REQUIRE_INT(line, 2);
TEST_REQUIRE_STATUS(akbasic_environment_get_label(HARNESS_RUNTIME.environment, "DECOY", &line),
AKBASIC_ERR_UNDEFINED);
/* LABELLED# is an identifier that starts with the word, not the verb. */
TEST_REQUIRE_STATUS(akbasic_environment_get_label(HARNESS_RUNTIME.environment, "ED", &line),
AKBASIC_ERR_UNDEFINED);
harness_stop();
SUCCEED_RETURN(errctx);
}
int main(void)
{
PREPARE_ERROR(errctx);
ATTEMPT {
CATCH(errctx, test_enter_and_return());
CATCH(errctx, test_label_handler());
CATCH(errctx, test_no_reentry());
CATCH(errctx, test_nested_gosub_keeps_the_flag());
CATCH(errctx, test_arm_and_disarm());
CATCH(errctx, test_new_disarms());
CATCH(errctx, test_undefined_label_is_reported());
CATCH(errctx, test_arm_refusals());
CATCH(errctx, test_prescan_boundaries());
} CLEANUP {
} PROCESS(errctx) {
} HANDLE_DEFAULT(errctx) {
LOG_ERROR_WITH_MESSAGE(errctx, "interrupt test failed");
akbasic_test_failures += 1;
} FINISH_NORETURN(errctx);
return akbasic_test_failures;
}