// The MIT License (MIT)
//
// Copyright (C) 2026 Scott Duensing
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to
// deal in the Software without restriction, including without limitation the
// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
// sell copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.  IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
// IN THE SOFTWARE.

// test_suite.c  -- Assertion-based regression tests for the BASIC
// language runtime.  Each test compiles a snippet, runs the VM with
// PRINT output captured, and compares the captured text to an
// expected string.  Failures are reported with a diff-style preview
// and the process exits non-zero, so `make tests` surfaces regressions
// before they reach 86Box.
//
// Add new tests with TEST_EQ(name, source, expected) -- other helpers
// cover compile errors and runtime errors.

#include "compiler/compact.h"
#include "compiler/obfuscate.h"
#include "compiler/parser.h"
#include "compiler/strip.h"
#include "runtime/serialize.h"
#include "runtime/vm.h"
#include "runtime/values.h"

#include "stb_ds_wrap.h"

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

// ============================================================
// Capture buffer
// ============================================================

#define CAPTURE_MAX 8192

// Source buffer for the too-many-globals test: one DIM line per slot plus one.
#define TOO_MANY_GLOBALS_SRC_MAX ((BAS_VM_MAX_GLOBALS + 1) * 32)

typedef struct {
    char buf[CAPTURE_MAX];
    int32_t len;
} CaptureT;


static void captureReset(CaptureT *c) {
    c->buf[0] = '\0';
    c->len    = 0;
}


static void captureAppend(CaptureT *c, const char *s) {
    int32_t n = (int32_t)strlen(s);

    if (c->len + n >= CAPTURE_MAX - 1) {
        n = CAPTURE_MAX - 1 - c->len;
    }

    if (n > 0) {
        memcpy(c->buf + c->len, s, n);
        c->len += n;
        c->buf[c->len] = '\0';
    }
}


static void capturePrint(void *ctx, const char *text, bool newline) {
    CaptureT *c = (CaptureT *)ctx;

    if (text) {
        captureAppend(c, text);
    }

    if (newline) {
        captureAppend(c, "\n");
    }
}


// ============================================================
// Test driver
// ============================================================

static int32_t sPassCount = 0;
static int32_t sFailCount = 0;


static void reportPass(const char *name) {
    printf("PASS  %s\n", name);
    sPassCount++;
}


static void reportFail(const char *name, const char *detail) {
    printf("FAIL  %s\n", name);

    if (detail && detail[0]) {
        printf("      %s\n", detail);
    }

    sFailCount++;
}


static void reportFailDiff(const char *name, const char *expected, const char *got) {
    printf("FAIL  %s\n", name);
    printf("      expected: [%s]\n", expected);
    printf("      got:      [%s]\n", got);
    sFailCount++;
}


// Set up callStack[0] as the implicit main frame for module-level code.
static void primeMainFrame(BasVmT *vm, const BasModuleT *mod) {
    vm->callStack[0].localCount = mod->globalCount > BAS_VM_MAX_LOCALS ? BAS_VM_MAX_LOCALS : mod->globalCount;
    vm->callDepth               = 1;
}


// Compile + run.  On success captures PRINT output into *out.  Returns
// 0 on success, negative on compile error, positive for runtime errors.
// Runtime error text goes into outErr if provided.
static int32_t runAndCapture(const char *source, char *out, int32_t outSize, char *outErr, int32_t outErrSize) {
    int32_t len = (int32_t)strlen(source);

    BasParserT parser;
    basParserInit(&parser, source, len);

    if (!basParse(&parser)) {
        if (outErr) {
            snprintf(outErr, outErrSize, "%s", parser.error);
        }

        basParserFree(&parser);
        return -1;
    }

    BasModuleT *mod = basParserBuildModule(&parser);
    basParserFree(&parser);

    if (!mod) {
        if (outErr) {
            snprintf(outErr, outErrSize, "module build failed");
        }

        return -2;
    }

    BasVmT *vm = basVmCreate();
    basVmLoadModule(vm, mod);

    CaptureT cap;
    captureReset(&cap);
    basVmSetPrintCallback(vm, capturePrint, &cap);

    primeMainFrame(vm, mod);

    BasVmResultE result = basVmRun(vm);

    int32_t rc = 0;

    if (result != BAS_VM_HALTED && result != BAS_VM_OK) {
        if (outErr) {
            snprintf(outErr, outErrSize, "%s", basVmGetError(vm));
        }

        rc = (int32_t)result;
    }

    if (out && outSize > 0) {
        snprintf(out, outSize, "%s", cap.buf);
    }

    basVmDestroy(vm);
    basModuleFree(mod);
    return rc;
}


// TEST_EQ -- compile, run, expect captured output to match
static void testEq(const char *name, const char *source, const char *expected) {
    char out[CAPTURE_MAX];
    char err[256] = "";

    int32_t rc = runAndCapture(source, out, sizeof(out), err, sizeof(err));

    if (rc != 0) {
        char detail[512];
        snprintf(detail, sizeof(detail), "rc=%d err=%s", (int)rc, err);
        reportFail(name, detail);
        return;
    }

    if (strcmp(out, expected) != 0) {
        reportFailDiff(name, expected, out);
        return;
    }

    reportPass(name);
}


// TEST_COMPILE_ERROR -- expect compilation to fail; substring of the
// error message must match.  Pass an empty needle to accept any error.
static void testCompileError(const char *name, const char *source, const char *needle) {
    char out[CAPTURE_MAX];
    char err[256] = "";

    int32_t rc = runAndCapture(source, out, sizeof(out), err, sizeof(err));

    if (rc != -1) {
        char detail[256];
        snprintf(detail, sizeof(detail), "expected compile error; got rc=%d output=[%s]", (int)rc, out);
        reportFail(name, detail);
        return;
    }

    if (needle && needle[0] && strstr(err, needle) == NULL) {
        char detail[512];
        snprintf(detail, sizeof(detail), "error [%s] did not contain [%s]", err, needle);
        reportFail(name, detail);
        return;
    }

    reportPass(name);
}


// runSubAndCapture -- compile, then invoke a specific named SUB via
// basVmCallSub.  Mirrors what fireCtrlEvent does for event handlers.
// Returns 0 if the SUB returned normally, 1 if it returned false
// (unhandled error), -1 on compile/load error.  Captured PRINT output
// goes into out; runtime error text (if any) goes into outErr.
static int32_t runSubAndCapture(const char *source, const char *subName,
                                char *out, int32_t outSize,
                                char *outErr, int32_t outErrSize) {
    int32_t len = (int32_t)strlen(source);

    BasParserT parser;
    basParserInit(&parser, source, len);

    if (!basParse(&parser)) {
        if (outErr) {
            snprintf(outErr, outErrSize, "%s", parser.error);
        }

        basParserFree(&parser);
        return -1;
    }

    BasModuleT *mod = basParserBuildModule(&parser);
    basParserFree(&parser);

    if (!mod) {
        if (outErr) {
            snprintf(outErr, outErrSize, "module build failed");
        }

        return -1;
    }

    BasVmT *vm = basVmCreate();
    basVmLoadModule(vm, mod);

    CaptureT cap;
    captureReset(&cap);
    basVmSetPrintCallback(vm, capturePrint, &cap);

    primeMainFrame(vm, mod);

    // Execute module-level code first (to initialize globals).  A
    // failure here (e.g. an error during global init) must be reported,
    // not swallowed -- otherwise the SUB runs against half-built state
    // and the test passes or fails for the wrong reason.
    BasVmResultE initResult = basVmRun(vm);

    if (initResult != BAS_VM_HALTED && initResult != BAS_VM_OK) {
        if (outErr) {
            snprintf(outErr, outErrSize, "module init error: %s", basVmGetError(vm));
        }

        basVmDestroy(vm);
        basModuleFree(mod);
        return -1;
    }

    // Find the target SUB by name
    int32_t subAddr = -1;

    for (int32_t i = 0; i < mod->procCount; i++) {
        if (strcasecmp(mod->procs[i].name, subName) == 0) {
            subAddr = mod->procs[i].codeAddr;
            break;
        }
    }

    int32_t rc = 0;

    if (subAddr < 0) {
        if (outErr) {
            snprintf(outErr, outErrSize, "SUB '%s' not found", subName);
        }
        rc = -1;
    } else {
        // Clear error state before the call so we see only this call's errors
        vm->errorNumber    = 0;
        vm->errorMsg[0]    = '\0';
        vm->inErrorHandler = false;

        bool ok = basVmCallSub(vm, subAddr);
        rc = ok ? 0 : 1;

        if (!ok && outErr) {
            snprintf(outErr, outErrSize, "%s", basVmGetError(vm));
        }
    }

    if (out && outSize > 0) {
        snprintf(out, outSize, "%s", cap.buf);
    }

    basVmDestroy(vm);
    basModuleFree(mod);
    return rc;
}


// TEST_SUB_EQ -- compile, run module-level code, then invoke a named
// SUB through basVmCallSub (the path used by event handlers), and
// compare captured output.
static void testSubEq(const char *name, const char *source, const char *subName, const char *expected) {
    char out[CAPTURE_MAX];
    char err[256] = "";

    int32_t rc = runSubAndCapture(source, subName, out, sizeof(out), err, sizeof(err));

    if (rc < 0) {
        char detail[512];
        snprintf(detail, sizeof(detail), "setup error: %s", err);
        reportFail(name, detail);
        return;
    }

    if (rc != 0) {
        char detail[512];
        snprintf(detail, sizeof(detail), "SUB returned false (unhandled error): %s", err);
        reportFail(name, detail);
        return;
    }

    if (strcmp(out, expected) != 0) {
        reportFailDiff(name, expected, out);
        return;
    }

    reportPass(name);
}


#define TEST_SUB_EQ(n, s, sub, e)  testSubEq((n), (s), (sub), (e))


// TEST_RUNTIME_ERROR -- expect runtime error; optional substring match
// on the error message.
static void testRuntimeError(const char *name, const char *source, const char *needle) {
    char out[CAPTURE_MAX];
    char err[256] = "";

    int32_t rc = runAndCapture(source, out, sizeof(out), err, sizeof(err));

    if (rc <= 0) {
        char detail[256];
        snprintf(detail, sizeof(detail), "expected runtime error; got rc=%d output=[%s]", (int)rc, out);
        reportFail(name, detail);
        return;
    }

    if (needle && needle[0] && strstr(err, needle) == NULL) {
        char detail[512];
        snprintf(detail, sizeof(detail), "error [%s] did not contain [%s]", err, needle);
        reportFail(name, detail);
        return;
    }

    reportPass(name);
}


#define TEST_EQ(n, s, e)             testEq((n), (s), (e))
#define TEST_COMPILE_ERROR(n, s, e)  testCompileError((n), (s), (e))
#define TEST_RUNTIME_ERROR(n, s, e)  testRuntimeError((n), (s), (e))


// A breakpoint must pause the program even when ON ERROR GOTO is active:
// the debugger pause is not a trappable error.
static void testBreakpointNotTrapped(const char *name) {
    const char *source =
        "ON ERROR GOTO h\n"
        "PRINT \"a\"\n"
        "PRINT \"b\"\n"
        "END\n"
        "h:\n"
        "PRINT \"HANDLER\"\n"
        "RESUME NEXT\n";
    int32_t    breakLine = 3;
    BasParserT parser;

    basParserInit(&parser, source, (int32_t)strlen(source));

    if (!basParse(&parser)) {
        reportFail(name, parser.error);
        basParserFree(&parser);
        return;
    }

    BasModuleT *mod = basParserBuildModule(&parser);
    basParserFree(&parser);

    BasVmT  *vm = basVmCreate();
    CaptureT cap;

    captureReset(&cap);
    basVmLoadModule(vm, mod);
    basVmSetPrintCallback(vm, capturePrint, &cap);
    basVmSetBreakpoints(vm, &breakLine, 1);

    BasVmResultE first = basVmRun(vm);

    vm->running = true;

    BasVmResultE second = basVmRun(vm);

    if (first != BAS_VM_BREAKPOINT || second != BAS_VM_HALTED || strcmp(cap.buf, "a\nb\n") != 0) {
        char detail[512];
        snprintf(detail, sizeof(detail), "first=%d second=%d out=[%s]", (int)first, (int)second, cap.buf);
        reportFail(name, detail);
    } else {
        reportPass(name);
    }

    basVmDestroy(vm);
    basModuleFree(mod);
}


// Full release-build payload path: compile, strip, obfuscate against a
// .frm, compact, serialize, deserialize, run.  Checks that a SetEvent
// handler name survives obfuscation, that a control named after a
// property key ("Text") is left alone in both the .frm and the constant
// pool, and that the round-tripped module still runs.
static void testReleaseRoundTrip(const char *name) {
    const char *source =
        "SUB BtnOK_Click\n"
        "  PRINT \"clicked\"\n"
        "END SUB\n"
        "SUB Text_Change\n"
        "END SUB\n"
        "DIM h AS STRING\n"
        "h = \"BtnOK_Click\"\n"
        "PRINT \"Text\"\n"
        "PRINT h\n";
    const char *frm =
        "Begin Form Form1\n"
        "Caption = \"Demo\"\n"
        "Begin CommandButton BtnOK\n"
        "Caption = \"OK\"\n"
        "End\n"
        "Begin TextBox Text\n"
        "Text = \"abc\"\n"
        "End\n"
        "Begin DBGrid Grid1\n"
        "DataSource = \"BtnOK\"\n"
        "End\n"
        "End\n"
        "Sub Form_Load ()\nEnd Sub\n";
    BasParserT parser;

    basParserInit(&parser, source, (int32_t)strlen(source));

    if (!basParse(&parser)) {
        reportFail(name, parser.error);
        basParserFree(&parser);
        return;
    }

    BasModuleT *mod = basParserBuildModule(&parser);
    basParserFree(&parser);

    basStripModule(mod);

    const char *frmTexts[1] = { frm };
    int32_t     frmLens[1]  = { (int32_t)strlen(frm) };
    BasObfFrmT  obf[1];

    basObfuscateNames(mod, frmTexts, frmLens, 1, obf);
    basCompactBytecode(mod);

    char detail[512] = "";
    bool ok          = true;

    // The button handler was renamed (BtnOK -> C?) and the constant that
    // names it as a SetEvent target followed the rename.
    const BasProcEntryT *btnProc  = NULL;
    const BasProcEntryT *textProc = basModuleFindProc(mod, "Text_Change");

    for (int32_t i = 0; i < mod->procCount; i++) {
        if (strstr(mod->procs[i].name, "_Click")) {
            btnProc = &mod->procs[i];
        }
    }

    if (!btnProc || strncmp(btnProc->name, "BtnOK", 5) == 0) {
        snprintf(detail, sizeof(detail), "BtnOK_Click was not obfuscated");
        ok = false;
    } else if (!basModuleFindProc(mod, btnProc->name)) {
        snprintf(detail, sizeof(detail), "renamed handler not findable");
        ok = false;
    } else if (!textProc) {
        snprintf(detail, sizeof(detail), "Text_Change must keep its name (Text is a property key)");
        ok = false;
    }

    bool sawHandlerConst = false;
    bool sawTextConst    = false;

    for (int32_t i = 0; ok && i < mod->constCount; i++) {
        if (btnProc && strcmp(mod->constants[i]->data, btnProc->name) == 0) {
            sawHandlerConst = true;
        }

        if (strcmp(mod->constants[i]->data, "Text") == 0) {
            sawTextConst = true;
        }
    }

    if (ok && (!sawHandlerConst || !sawTextConst)) {
        snprintf(detail, sizeof(detail), "constants: handler=%d text=%d", (int)sawHandlerConst, (int)sawTextConst);
        ok = false;
    }

    // The .frm keeps the type token and the property key, renames the
    // button both in its Begin line and where a value names it, and drops
    // the trailing code section.
    if (ok && obf[0].data) {
        const char *text = (const char *)obf[0].data;

        if (!strstr(text, "Begin TextBox Text\n") || !strstr(text, "Text = \"abc\"") || strstr(text, "BtnOK") || strstr(text, "Form_Load") || !strstr(text, "DataSource = \"C")) {
            snprintf(detail, sizeof(detail), "frm rewrite wrong:\n%s", text);
            ok = false;
        }
    } else if (ok) {
        snprintf(detail, sizeof(detail), "no obfuscated frm produced");
        ok = false;
    }

    // Serialize, deserialize, run.
    if (ok) {
        int32_t  len  = 0;
        uint8_t *data = basModuleSerialize(mod, &len);
        BasModuleT *back = data ? basModuleDeserialize(data, len) : NULL;

        free(data);

        if (!back) {
            snprintf(detail, sizeof(detail), "serialize round trip failed");
            ok = false;
        } else {
            BasVmT  *vm = basVmCreate();
            CaptureT cap;
            char     expected[256];

            captureReset(&cap);
            basVmLoadModule(vm, back);
            basVmSetPrintCallback(vm, capturePrint, &cap);

            BasVmResultE rc = basVmRun(vm);

            snprintf(expected, sizeof(expected), "Text\n%s\n", btnProc->name);

            if (rc != BAS_VM_HALTED || strcmp(cap.buf, expected) != 0) {
                snprintf(detail, sizeof(detail), "rc=%d out=[%s] expected=[%s]", (int)rc, cap.buf, expected);
                ok = false;
            }

            basVmDestroy(vm);
            basModuleFree(back);
        }
    }

    free(obf[0].data);
    basModuleFree(mod);

    if (ok) {
        reportPass(name);
    } else {
        reportFail(name, detail);
    }
}


// ============================================================
// Widget-level event-dispatch harness
// ============================================================
//
// formrt.c depends on the full DVX widget/window stack and can't be
// linked into the host test binary.  This harness mirrors the dispatch
// semantics of fireCtrlEvent / basFormRtFireEvent using stand-alone
// mini-structs and a bare minimum of VM calls.
//
// IMPORTANT: if you change the dispatch rules in formrt.c (event
// override table, re-entrancy guard, form-scope-vars binding), update
// the matching code below.  The tests are re-implementation tests,
// not link-through tests.

#define MINI_MAX_NAME        32
#define MINI_MAX_OVERRIDES   16

typedef struct {
    char eventName[MINI_MAX_NAME];
    char handlerName[MINI_MAX_NAME];
} MiniOverrideT;


typedef struct MiniCtrlT {
    char          name[MINI_MAX_NAME];
    char          typeName[MINI_MAX_NAME];
    struct MiniFormT *form;
    MiniOverrideT overrides[MINI_MAX_OVERRIDES];
    int32_t       overrideCount;
    bool          eventFiring;
    char          firingEventName[MINI_MAX_NAME];
} MiniCtrlT;


typedef struct MiniFormT {
    char        name[MINI_MAX_NAME];
    MiniCtrlT **ctrls;         // stb_ds array of heap-alloc'd controls
    BasValueT *formVars;
    int32_t    formVarCount;
} MiniFormT;


typedef struct {
    BasVmT      *vm;
    BasModuleT  *module;
    MiniFormT  **forms;         // stb_ds array of heap-alloc'd forms
    MiniFormT   *currentForm;
} MiniRtT;


static MiniFormT *miniCreateForm(MiniRtT *rt, const char *name, int32_t formVarCount) {
    MiniFormT *f = (MiniFormT *)calloc(1, sizeof(MiniFormT));

    if (!f) {
        fprintf(stderr, "FATAL: out of memory allocating MiniFormT\n");
        exit(EXIT_FAILURE);
    }

    snprintf(f->name, MINI_MAX_NAME, "%s", name);
    f->ctrls = NULL;

    if (formVarCount > 0) {
        f->formVars     = (BasValueT *)calloc(formVarCount, sizeof(BasValueT));
        f->formVarCount = formVarCount;
    }

    arrput(rt->forms, f);
    return f;
}


static MiniCtrlT *miniAddCtrl(MiniFormT *f, const char *name, const char *typeName) {
    // Store stable heap pointers: arrput may realloc f->ctrls, so returning
    // an interior address would dangle once a second control is added.
    MiniCtrlT *c = (MiniCtrlT *)calloc(1, sizeof(MiniCtrlT));

    if (!c) {
        fprintf(stderr, "FATAL: out of memory allocating MiniCtrlT\n");
        exit(EXIT_FAILURE);
    }

    snprintf(c->name,     MINI_MAX_NAME, "%s", name);
    snprintf(c->typeName, MINI_MAX_NAME, "%s", typeName ? typeName : "");
    c->form = f;
    arrput(f->ctrls, c);
    return c;
}


static void miniSetEvent(MiniCtrlT *ctrl, const char *eventName, const char *handlerName) {
    // Match formrt's SetEvent: update existing or append
    for (int32_t i = 0; i < ctrl->overrideCount; i++) {
        if (strcasecmp(ctrl->overrides[i].eventName, eventName) == 0) {
            snprintf(ctrl->overrides[i].handlerName, MINI_MAX_NAME, "%s", handlerName);
            return;
        }
    }

    if (ctrl->overrideCount < MINI_MAX_OVERRIDES) {
        MiniOverrideT *o = &ctrl->overrides[ctrl->overrideCount++];
        snprintf(o->eventName,   MINI_MAX_NAME, "%s", eventName);
        snprintf(o->handlerName, MINI_MAX_NAME, "%s", handlerName);
    }
}


static const BasProcEntryT *miniFindProc(BasModuleT *mod, const char *name) {
    if (!mod || !mod->procs || !name) {
        return NULL;
    }

    for (int32_t i = 0; i < mod->procCount; i++) {
        if (strcasecmp(mod->procs[i].name, name) == 0) {
            return &mod->procs[i];
        }
    }

    return NULL;
}


static MiniFormT *miniResolveOwningForm(MiniRtT *rt, const BasProcEntryT *proc) {
    if (!rt || !proc || !proc->formName[0]) {
        return NULL;
    }

    for (int32_t i = 0; i < (int32_t)arrlen(rt->forms); i++) {
        if (strcasecmp(rt->forms[i]->name, proc->formName) == 0) {
            return rt->forms[i];
        }
    }

    return NULL;
}


// Mirror of formrt.c's fireCtrlEvent.  Re-entrancy guard, override
// table, form-scope vars binding -- kept in sync by copy.
static void miniFireCtrlEvent(MiniRtT *rt, MiniCtrlT *ctrl, const char *eventName) {
    if (!rt || !ctrl || !eventName) {
        return;
    }

    if (ctrl->eventFiring) {
        bool sameEvent = (ctrl->firingEventName[0] &&
                          strcasecmp(ctrl->firingEventName, eventName) == 0);
        if (sameEvent) {
            return;
        }
    }

    // Event-override path (SetEvent)
    for (int32_t i = 0; i < ctrl->overrideCount; i++) {
        if (strcasecmp(ctrl->overrides[i].eventName, eventName) != 0) {
            continue;
        }

        if (!rt->vm || !rt->module) {
            return;
        }

        const BasProcEntryT *proc = miniFindProc(rt->module, ctrl->overrides[i].handlerName);

        if (!proc || proc->isFunction) {
            return;
        }

        MiniFormT *prevForm     = rt->currentForm;
        BasValueT *prevVars     = rt->vm->currentFormVars;
        int32_t    prevVarCount = rt->vm->currentFormVarCount;

        MiniFormT *owningForm = miniResolveOwningForm(rt, proc);
        MiniFormT *varsForm   = owningForm ? owningForm : ctrl->form;

        rt->currentForm = ctrl->form;
        basVmSetCurrentFormVars(rt->vm, varsForm->formVars, varsForm->formVarCount);

        bool claimedGuard = !ctrl->eventFiring;

        if (claimedGuard) {
            ctrl->eventFiring = true;
            snprintf(ctrl->firingEventName, MINI_MAX_NAME, "%s", eventName);
        }

        rt->vm->errorMsg[0] = '\0';
        rt->vm->errorNumber = 0;
        basVmCallSub(rt->vm, proc->codeAddr);

        if (claimedGuard) {
            ctrl->eventFiring        = false;
            ctrl->firingEventName[0] = '\0';
        }

        rt->currentForm = prevForm;
        basVmSetCurrentFormVars(rt->vm, prevVars, prevVarCount);
        return;
    }

    // Naming-convention fallback: CtrlName_EventName
    char handlerName[MINI_MAX_NAME * 2];
    snprintf(handlerName, sizeof(handlerName), "%s_%s", ctrl->name, eventName);

    const BasProcEntryT *proc = miniFindProc(rt->module, handlerName);

    if (!proc || proc->isFunction) {
        return;
    }

    MiniFormT *prevForm     = rt->currentForm;
    BasValueT *prevVars     = rt->vm->currentFormVars;
    int32_t    prevVarCount = rt->vm->currentFormVarCount;

    MiniFormT *owningForm = miniResolveOwningForm(rt, proc);
    MiniFormT *varsForm   = owningForm ? owningForm : ctrl->form;

    rt->currentForm = ctrl->form;
    basVmSetCurrentFormVars(rt->vm, varsForm->formVars, varsForm->formVarCount);

    bool claimedGuard = !ctrl->eventFiring;

    if (claimedGuard) {
        ctrl->eventFiring = true;
        snprintf(ctrl->firingEventName, MINI_MAX_NAME, "%s", eventName);
    }

    basVmCallSub(rt->vm, proc->codeAddr);

    if (claimedGuard) {
        ctrl->eventFiring        = false;
        ctrl->firingEventName[0] = '\0';
    }

    rt->currentForm = prevForm;
    basVmSetCurrentFormVars(rt->vm, prevVars, prevVarCount);
}


// Context for an event-dispatch test: what to build and how to fire.
typedef struct {
    const char *source;          // BASIC source
    const char *formName;        // "" if ctrl has no owning BEGINFORM
    int32_t     formVarCount;    // >0 if the module has form-scope vars
    const char *ctrlName;        // control to fire events on
    const char *ctrlType;        // widget type, e.g. "CommandButton" / "Timer"
    // Array of (eventName, handlerName) for SetEvent.  Terminate with NULL.
    const char *overrides[2 * 8];
    // Array of event names to fire in order.  Terminate with NULL.
    const char *fires[8];
} TestDispatchT;


static void testDispatchEq(const char *name, const TestDispatchT *d, const char *expected) {
    int32_t len = (int32_t)strlen(d->source);

    BasParserT parser;
    basParserInit(&parser, d->source, len);

    if (!basParse(&parser)) {
        char detail[256];
        snprintf(detail, sizeof(detail), "compile error: %s", parser.error);
        reportFail(name, detail);
        basParserFree(&parser);
        return;
    }

    BasModuleT *mod = basParserBuildModule(&parser);
    basParserFree(&parser);

    if (!mod) {
        reportFail(name, "module build failed");
        return;
    }

    BasVmT *vm = basVmCreate();
    basVmLoadModule(vm, mod);

    CaptureT cap;
    captureReset(&cap);
    basVmSetPrintCallback(vm, capturePrint, &cap);

    primeMainFrame(vm, mod);

    // Run module-level init (populates globals, runs BEGINFORM init block).
    BasVmResultE initResult = basVmRun(vm);

    if (initResult != BAS_VM_HALTED && initResult != BAS_VM_OK) {
        char detail[256];
        snprintf(detail, sizeof(detail), "module init error: %s", basVmGetError(vm));
        reportFail(name, detail);
        basVmDestroy(vm);
        basModuleFree(mod);
        return;
    }

    // Build the mini runtime + form + control.
    MiniRtT rt;
    memset(&rt, 0, sizeof(rt));
    rt.vm     = vm;
    rt.module = mod;

    MiniFormT *form = miniCreateForm(&rt, d->formName[0] ? d->formName : "_NoForm", d->formVarCount);
    MiniCtrlT *ctrl = miniAddCtrl(form, d->ctrlName, d->ctrlType);

    // Wire overrides.
    for (int32_t i = 0; d->overrides[i] && d->overrides[i + 1]; i += 2) {
        miniSetEvent(ctrl, d->overrides[i], d->overrides[i + 1]);
    }

    // Fire the sequence of events.
    for (int32_t i = 0; d->fires[i]; i++) {
        miniFireCtrlEvent(&rt, ctrl, d->fires[i]);
    }

    if (strcmp(cap.buf, expected) == 0) {
        reportPass(name);
    } else {
        reportFailDiff(name, expected, cap.buf);
    }

    // Teardown.
    for (int32_t i = 0; i < (int32_t)arrlen(rt.forms); i++) {
        MiniFormT *f = rt.forms[i];
        if (f->formVars) {
            for (int32_t j = 0; j < f->formVarCount; j++) {
                basValRelease(&f->formVars[j]);
            }
            free(f->formVars);
        }
        for (int32_t k = 0; k < (int32_t)arrlen(f->ctrls); k++) {
            free(f->ctrls[k]);
        }
        arrfree(f->ctrls);
        free(f);
    }
    arrfree(rt.forms);

    basVmDestroy(vm);
    basModuleFree(mod);
}


// Helper macros to keep tests compact.  `overrides` and `fires` are
// comma lists terminated with NULL/NULL.
#define DISPATCH_OVR(...) { __VA_ARGS__, NULL, NULL }
#define DISPATCH_FIRE(...) { __VA_ARGS__, NULL }


// ============================================================
// Test cases
// ============================================================

int main(void) {
    printf("DVX BASIC Regression Suite\n");
    printf("==========================\n");

    // --- Arithmetic / literals ---
    // DVX BASIC PRINT format: "N \n" for positive/zero integers, "-N \n"
    // for negatives.  No leading space (unlike classic QBASIC).
    TEST_EQ("int-add",          "PRINT 2 + 3\n", "5 \n");
    TEST_EQ("int-sub",          "PRINT 10 - 4\n", "6 \n");
    TEST_EQ("int-mul",          "PRINT 6 * 7\n", "42 \n");
    TEST_EQ("int-div-float",    "PRINT 10 / 4\n", "2.5 \n");
    TEST_EQ("int-idiv",         "PRINT 10 \\ 3\n", "3 \n");
    TEST_EQ("int-mod",          "PRINT 17 MOD 5\n", "2 \n");
    TEST_EQ("int-pow",          "PRINT 2 ^ 10\n", "1024 \n");
    TEST_EQ("precedence",       "PRINT 2 + 3 * 4\n", "14 \n");
    TEST_EQ("parens",           "PRINT (2 + 3) * 4\n", "20 \n");
    TEST_EQ("unary-neg",        "PRINT -5 + 2\n", "-3 \n");
    TEST_EQ("double-lit",       "PRINT 1.5 + 2.25\n", "3.75 \n");

    // --- Strings ---
    TEST_EQ("string-concat",    "PRINT \"foo\" + \"bar\"\n", "foobar\n");
    TEST_EQ("string-len",       "PRINT LEN(\"hello\")\n", "5 \n");
    TEST_EQ("string-left",      "PRINT LEFT$(\"abcdef\", 3)\n", "abc\n");
    TEST_EQ("string-right",     "PRINT RIGHT$(\"abcdef\", 2)\n", "ef\n");
    TEST_EQ("string-mid",       "PRINT MID$(\"abcdef\", 2, 3)\n", "bcd\n");
    TEST_EQ("string-ucase",     "PRINT UCASE$(\"Hello\")\n", "HELLO\n");
    TEST_EQ("string-lcase",     "PRINT LCASE$(\"Hello\")\n", "hello\n");
    TEST_EQ("string-str",       "PRINT STR$(42)\n", " 42\n");
    TEST_EQ("string-val",       "PRINT VAL(\"123\")\n", "123 \n");

    // --- Control flow ---
    TEST_EQ("if-then",
        "DIM x AS INTEGER\nx = 5\nIF x > 3 THEN PRINT \"big\"\n",
        "big\n");
    TEST_EQ("if-else",
        "DIM x AS INTEGER\nx = 1\nIF x > 3 THEN PRINT \"big\" ELSE PRINT \"small\"\n",
        "small\n");
    TEST_EQ("if-block",
        "DIM x AS INTEGER\nx = 5\nIF x > 3 THEN\n  PRINT \"a\"\n  PRINT \"b\"\nEND IF\n",
        "a\nb\n");
    // QuickBASIC single-line IF: ALL colon-separated statements after THEN
    // belong to the conditional branch (a false condition runs none of them).
    TEST_EQ("if-then-colon-false",
        "DIM x AS INTEGER\nx = 0\nIF 0 THEN x = 1 : x = 2\nPRINT x\n",
        "0 \n");
    TEST_EQ("if-then-colon-true",
        "DIM x AS INTEGER\nx = 0\nIF 1 THEN x = 1 : x = 2\nPRINT x\n",
        "2 \n");
    TEST_EQ("select-case",
        "DIM n AS INTEGER\nn = 2\n"
        "SELECT CASE n\n"
        "  CASE 1: PRINT \"one\"\n"
        "  CASE 2: PRINT \"two\"\n"
        "  CASE ELSE: PRINT \"other\"\n"
        "END SELECT\n",
        "two\n");

    // --- FOR loops (the recent bug: double step was truncating) ---
    TEST_EQ("for-int-step",
        "DIM i AS INTEGER\nFOR i = 1 TO 3\n  PRINT i\nNEXT i\n",
        "1 \n2 \n3 \n");
    TEST_EQ("for-neg-step",
        "DIM i AS INTEGER\nFOR i = 3 TO 1 STEP -1\n  PRINT i\nNEXT i\n",
        "3 \n2 \n1 \n");
    TEST_EQ("for-double-step",
        // Regression: DIM AS DOUBLE with fractional STEP must not
        // truncate the increment to zero.  Pre-fix this looped forever.
        "DIM a AS DOUBLE\n"
        "DIM c AS INTEGER\n"
        "c = 0\n"
        "FOR a = 0 TO 1 STEP 0.25\n"
        "  c = c + 1\n"
        "NEXT a\n"
        "PRINT c\n",
        "5 \n");
    TEST_EQ("for-double-six-cycles",
        // Direct analogue of GfxDrawAll's circle loop.
        "DIM a AS DOUBLE\n"
        "DIM c AS INTEGER\n"
        "c = 0\n"
        "FOR a = 0 TO 6.3 STEP 0.2\n"
        "  c = c + 1\n"
        "NEXT a\n"
        "PRINT c\n",
        "32 \n");
    TEST_EQ("for-exit",
        "DIM i AS INTEGER\n"
        "FOR i = 1 TO 10\n"
        "  IF i = 4 THEN EXIT FOR\n"
        "  PRINT i\n"
        "NEXT i\n",
        "1 \n2 \n3 \n");

    // --- WHILE / DO loops ---
    TEST_EQ("do-while",
        "DIM i AS INTEGER\n"
        "i = 0\n"
        "DO WHILE i < 3\n"
        "  PRINT i\n"
        "  i = i + 1\n"
        "LOOP\n",
        "0 \n1 \n2 \n");
    TEST_EQ("do-until",
        "DIM i AS INTEGER\n"
        "i = 0\n"
        "DO\n"
        "  i = i + 1\n"
        "LOOP UNTIL i = 3\n"
        "PRINT i\n",
        "3 \n");

    // --- SUB / FUNCTION ---
    TEST_EQ("sub-call",
        "SUB greet\n  PRINT \"hi\"\nEND SUB\n"
        "greet\n",
        "hi\n");
    TEST_EQ("sub-params",
        "SUB twice(n AS INTEGER)\n  PRINT n * 2\nEND SUB\n"
        "twice 7\n",
        "14 \n");
    TEST_EQ("function-return",
        "FUNCTION sq(n AS INTEGER) AS INTEGER\n  sq = n * n\nEND FUNCTION\n"
        "PRINT sq(9)\n",
        "81 \n");
    TEST_EQ("recursion",
        "FUNCTION fact(n AS INTEGER) AS LONG\n"
        "  IF n <= 1 THEN\n    fact = 1\n  ELSE\n    fact = n * fact(n - 1)\n  END IF\n"
        "END FUNCTION\n"
        "PRINT fact(6)\n",
        "720 \n");
    TEST_EQ("byref-vs-byval",
        "SUB bump(BYVAL a AS INTEGER, b AS INTEGER)\n  a = a + 1\n  b = b + 1\nEND SUB\n"
        "DIM x AS INTEGER\nDIM y AS INTEGER\n"
        "x = 10\ny = 20\n"
        "bump x, y\n"
        "PRINT x\nPRINT y\n",
        "10 \n21 \n");

    // --- Globals from within SUB ---
    TEST_EQ("global-from-sub",
        // Regression: the basdemo Dynamic Form bug was caused by
        // SUBs unable to access module-level DIM.
        "DIM counter AS INTEGER\n"
        "counter = 0\n"
        "SUB inc\n  counter = counter + 1\nEND SUB\n"
        "inc\ninc\ninc\n"
        "PRINT counter\n",
        "3 \n");

    // --- BEGINFORM / ENDFORM ---
    // Inside BEGINFORM, DIMs become form-scope vars; SUBs still live
    // globally but inherit the owning form context when called through
    // basVmCallSub with form vars bound.  Running the init code requires
    // a form to actually be loaded (formrt sets currentFormVars) so we
    // can only verify the parser accepts/rejects forms here.
    TEST_COMPILE_ERROR("nested-beginform",
        "BEGINFORM \"A\"\nBEGINFORM \"B\"\nENDFORM\nENDFORM\n",
        "Nested BEGINFORM");
    TEST_COMPILE_ERROR("beginform-in-sub",
        "SUB foo\nBEGINFORM \"X\"\nENDFORM\nEND SUB\n",
        "BEGINFORM inside SUB");

    // --- Arrays ---
    TEST_EQ("array-1d",
        "DIM a(3) AS INTEGER\n"
        "a(0) = 10\na(1) = 20\na(2) = 30\na(3) = 40\n"
        "PRINT a(0) + a(3)\n",
        "50 \n");
    TEST_EQ("array-lbound-ubound",
        "DIM a(5 TO 9) AS INTEGER\n"
        "PRINT LBOUND(a)\nPRINT UBOUND(a)\n",
        "5 \n9 \n");

    // --- UDT ---
    TEST_EQ("udt-fields",
        "TYPE Pt\n  x AS INTEGER\n  y AS INTEGER\nEND TYPE\n"
        "DIM p AS Pt\n"
        "p.x = 3\np.y = 4\n"
        "PRINT p.x + p.y\n",
        "7 \n");

    // --- ON ERROR GOTO ---
    TEST_EQ("on-error-module",
        "ON ERROR GOTO handler\n"
        "DIM a AS INTEGER\nDIM b AS INTEGER\n"
        "a = 10\nb = 0\n"
        "PRINT a \\ b\n"
        "PRINT \"never\"\n"
        "END\n"
        "handler:\n"
        "PRINT \"caught\"\n"
        "PRINT ERR\n",
        "caught\n11 \n");
    TEST_EQ("on-error-in-sub",
        // Regression: .frm's btnError_Click demo has ON ERROR inside a
        // SUB.  Make sure the handler label resolves within the SUB.
        "SUB doit\n"
        "  ON ERROR GOTO handler\n"
        "  DIM a AS INTEGER\n  DIM b AS INTEGER\n"
        "  a = 10\n  b = 0\n"
        "  PRINT a \\ b\n"
        "  PRINT \"never\"\n"
        "  EXIT SUB\n"
        "  handler:\n"
        "  PRINT \"caught\"\n"
        "  PRINT ERR\n"
        "END SUB\n"
        "doit\n",
        "caught\n11 \n");

    // --- Runtime errors surface ---
    TEST_RUNTIME_ERROR("divzero-no-handler",
        "DIM a AS INTEGER\nDIM b AS INTEGER\n"
        "a = 10\nb = 0\n"
        "PRINT a \\ b\n",
        "");

    // --- Compile-time checks ---
    TEST_COMPILE_ERROR("unknown-keyword",
        "FLOOGLE 1, 2, 3\n",
        "");

    // --- STATIC variables in SUB persist across calls ---
    TEST_EQ("static-local",
        "SUB inc\n"
        "  STATIC n AS INTEGER\n"
        "  n = n + 1\n"
        "  PRINT n\n"
        "END SUB\n"
        "inc\ninc\ninc\n",
        "1 \n2 \n3 \n");

    // --- GOSUB / RETURN ---
    TEST_EQ("gosub-return",
        "GOSUB sub1\n"
        "PRINT \"back\"\n"
        "END\n"
        "sub1:\n"
        "PRINT \"in sub\"\n"
        "RETURN\n",
        "in sub\nback\n");

    // --- SWAP ---
    TEST_EQ("swap",
        "DIM a AS INTEGER\nDIM b AS INTEGER\n"
        "a = 1\nb = 9\n"
        "SWAP a, b\n"
        "PRINT a\nPRINT b\n",
        "9 \n1 \n");

    // --- DATA / READ ---
    TEST_EQ("data-read",
        "DIM x AS INTEGER\n"
        "READ x\nPRINT x\n"
        "READ x\nPRINT x\n"
        "DATA 11, 22\n",
        "11 \n22 \n");

    // --- FORMAT$ ---
    TEST_EQ("format-number",
        "PRINT FORMAT$(1234.5, \"#,##0.00\")\n",
        "1,234.50\n");

    // --- String comparison ---
    TEST_EQ("string-equal",
        "IF \"abc\" = \"abc\" THEN PRINT \"y\" ELSE PRINT \"n\"\n",
        "y\n");
    TEST_EQ("string-less",
        "IF \"abc\" < \"abd\" THEN PRINT \"y\" ELSE PRINT \"n\"\n",
        "y\n");

    // --- OPTION EXPLICIT enforces DIM ---
    TEST_COMPILE_ERROR("option-explicit",
        "OPTION EXPLICIT\n"
        "x = 5\n",
        "");

    // --- Math functions ---
    TEST_EQ("math-abs",     "PRINT ABS(-7)\n", "7 \n");
    TEST_EQ("math-int",     "PRINT INT(3.7)\n", "3 \n");
    TEST_EQ("math-sgn-pos", "PRINT SGN(5)\n", "1 \n");
    TEST_EQ("math-sgn-neg", "PRINT SGN(-5)\n", "-1 \n");
    TEST_EQ("math-sgn-zero","PRINT SGN(0)\n", "0 \n");

    // --- CAST/conversion ---
    TEST_EQ("cint",   "PRINT CINT(3.6)\n", "4 \n");
    TEST_EQ("clng",   "PRINT CLNG(100000)\n", "100000 \n");
    TEST_EQ("cdbl",   "PRINT CDBL(1) / 3\n", "0.333333333333333 \n");

    // --- Float arithmetic preserves fractional results ---
    // Regression: the VM was promoting OP_ADD_INT results back to int16
    // even when either operand was a float, truncating 1.5+2.25 to 3.
    TEST_EQ("float-add-literal",  "PRINT 1.5 + 2.25\n", "3.75 \n");
    TEST_EQ("float-add-var",
        "DIM a AS DOUBLE\nDIM b AS DOUBLE\n"
        "a = 1.5\nb = 2.25\n"
        "PRINT a + b\n",
        "3.75 \n");
    TEST_EQ("float-mul",
        "PRINT 2.5 * 4\n",
        "10 \n");
    TEST_EQ("float-sub",
        "PRINT 10 - 0.5\n",
        "9.5 \n");
    TEST_EQ("div-produces-double",
        "PRINT 7 / 2\n",
        "3.5 \n");

    // --- ON ERROR with float division (the actual .frm demo scenario) ---
    TEST_EQ("on-error-float-div",
        "SUB doit\n"
        "  ON ERROR GOTO handler\n"
        "  DIM a AS INTEGER\n  DIM b AS INTEGER\n"
        "  a = 10\n  b = 0\n"
        "  PRINT a / b\n"
        "  PRINT \"never\"\n"
        "  EXIT SUB\n"
        "  handler:\n"
        "  PRINT \"caught\"\n"
        "  PRINT ERR\n"
        "END SUB\n"
        "doit\n",
        "caught\n11 \n");

    // --- ON ERROR, error during arg evaluation of a nested SUB call ---
    // Mirrors basdemo's btnError_Click: Say "..." + STR$(a / b) causes the
    // divide-by-zero inside an expression that's about to be an argument
    // to Say.  The error handler for the outer SUB must still catch it.
    TEST_EQ("on-error-in-expr-arg",
        "SUB emit(s AS STRING)\n  PRINT s\nEND SUB\n"
        "SUB doit\n"
        "  ON ERROR GOTO handler\n"
        "  DIM a AS INTEGER\n  DIM b AS INTEGER\n"
        "  a = 10\n  b = 0\n"
        "  emit \"10/\" + STR$(b) + \"=\" + STR$(a / b)\n"
        "  PRINT \"never\"\n"
        "  EXIT SUB\n"
        "  handler:\n"
        "  PRINT \"caught\"\n"
        "  PRINT ERR\n"
        "END SUB\n"
        "doit\n",
        "caught\n11 \n");

    // --- SUB is called from another SUB; error fires in the callee ---
    // When one SUB calls another and the *callee* has no handler but the
    // *caller* does, the caller's handler should still catch the error.
    TEST_EQ("on-error-bubbles-up",
        "SUB bomb\n  DIM a AS INTEGER\n  DIM b AS INTEGER\n"
        "  a = 10\n  b = 0\n  PRINT a / b\n"
        "END SUB\n"
        "SUB doit\n"
        "  ON ERROR GOTO handler\n"
        "  bomb\n"
        "  PRINT \"never\"\n"
        "  EXIT SUB\n"
        "  handler:\n"
        "  PRINT \"caught in doit\"\n"
        "  PRINT ERR\n"
        "END SUB\n"
        "doit\n",
        "caught in doit\n11 \n");

    // --- Handler calls other SUBs, then completes normally ---
    // Mirrors basdemo's btnError_Click: the handler itself calls `Say`
    // and sets properties before END SUB.  After the handler ends, the
    // outer SUB's caller (Run All chain) must continue normally.
    TEST_EQ("on-error-handler-calls-sub",
        "SUB emit(s AS STRING)\n  PRINT s\nEND SUB\n"
        "SUB doit\n"
        "  ON ERROR GOTO handler\n"
        "  DIM a AS INTEGER\n  DIM b AS INTEGER\n"
        "  a = 10\n  b = 0\n"
        "  emit \"about to divide\"\n"
        "  emit STR$(a / b)\n"
        "  emit \"never\"\n"
        "  EXIT SUB\n"
        "  handler:\n"
        "  emit \"caught\"\n"
        "  emit STR$(ERR)\n"
        "END SUB\n"
        "doit\n"
        "PRINT \"after-doit\"\n",
        "about to divide\ncaught\n 11\nafter-doit\n");

    // --- Run-All style chain: caller runs several SUBs in sequence,
    // one of them has a handler that catches its own error, the chain
    // must continue with the next SUB in the caller. ---
    TEST_EQ("on-error-chain-continues",
        "SUB stepOne\n  PRINT \"one\"\nEND SUB\n"
        "SUB stepBoom\n"
        "  ON ERROR GOTO h\n"
        "  DIM a AS INTEGER\n  DIM b AS INTEGER\n"
        "  a = 10\n  b = 0\n"
        "  PRINT a \\ b\n"
        "  PRINT \"never\"\n"
        "  EXIT SUB\n"
        "  h:\n"
        "  PRINT \"boom-caught\"\n"
        "END SUB\n"
        "SUB stepTwo\n  PRINT \"two\"\nEND SUB\n"
        "SUB runAll\n"
        "  stepOne\n"
        "  stepBoom\n"
        "  stepTwo\n"
        "END SUB\n"
        "runAll\n",
        "one\nboom-caught\ntwo\n");

    // --- Multiple ON ERROR blocks in sequence in one SUB ---
    TEST_EQ("on-error-reset",
        "SUB doit\n"
        "  ON ERROR GOTO h1\n"
        "  DIM a AS INTEGER\n  DIM b AS INTEGER\n"
        "  a = 10\n  b = 0\n"
        "  PRINT a \\ b\n"
        "  EXIT SUB\n"
        "  h1:\n"
        "  PRINT \"h1\"\n"
        "END SUB\n"
        "doit\n"
        "PRINT \"done\"\n",
        "h1\ndone\n");

    // --- ON ERROR GOTO 0 clears the handler ---
    TEST_RUNTIME_ERROR("on-error-goto-zero",
        "ON ERROR GOTO handler\n"
        "ON ERROR GOTO 0\n"
        "DIM a AS INTEGER\nDIM b AS INTEGER\n"
        "a = 10\nb = 0\n"
        "PRINT a \\ b\n"
        "END\n"
        "handler:\n"
        "PRINT \"not caught\"\n",
        "");

    // --- inErrorHandler must clear after handler completes via END SUB ---
    // If we don't clear it, a *second* error in a later SUB wouldn't trap.
    TEST_EQ("on-error-reusable",
        "SUB doit\n"
        "  ON ERROR GOTO h\n"
        "  DIM a AS INTEGER\n  DIM b AS INTEGER\n"
        "  a = 10\n  b = 0\n"
        "  PRINT a \\ b\n"
        "  EXIT SUB\n"
        "  h:\n"
        "  PRINT \"once\"\n"
        "END SUB\n"
        "doit\n"
        "doit\n",
        "once\nonce\n");

    // --- String builtins ---
    TEST_EQ("string-instr",  "PRINT INSTR(\"abcdef\", \"cd\")\n", "3 \n");
    TEST_EQ("string-chr",    "PRINT CHR$(65)\n", "A\n");
    TEST_EQ("string-asc",    "PRINT ASC(\"A\")\n", "65 \n");
    TEST_EQ("string-space",  "PRINT \"[\" + SPACE$(3) + \"]\"\n", "[   ]\n");
    TEST_EQ("string-string", "PRINT STRING$(4, \"*\")\n", "****\n");
    TEST_EQ("string-ltrim",  "PRINT \"[\" + LTRIM$(\"   hi\") + \"]\"\n", "[hi]\n");
    TEST_EQ("string-rtrim",  "PRINT \"[\" + RTRIM$(\"hi   \") + \"]\"\n", "[hi]\n");

    // --- Logical operators ---
    TEST_EQ("logical-and",
        "IF 1 = 1 AND 2 = 2 THEN PRINT \"y\" ELSE PRINT \"n\"\n",
        "y\n");
    TEST_EQ("logical-or",
        "IF 1 = 0 OR 2 = 2 THEN PRINT \"y\" ELSE PRINT \"n\"\n",
        "y\n");
    TEST_EQ("logical-not",
        "IF NOT (1 = 2) THEN PRINT \"y\" ELSE PRINT \"n\"\n",
        "y\n");

    // --- Short-circuit only fires when needed (documented behavior) ---
    TEST_EQ("and-short-circuit-full",
        "DIM x AS INTEGER\nx = 0\n"
        "IF 1 = 1 AND 2 = 2 THEN x = 5\n"
        "PRINT x\n",
        "5 \n");

    // --- Nested FOR ---
    TEST_EQ("nested-for",
        "DIM i AS INTEGER\nDIM j AS INTEGER\n"
        "FOR i = 1 TO 2\n"
        "  FOR j = 1 TO 2\n"
        "    PRINT i * 10 + j\n"
        "  NEXT j\n"
        "NEXT i\n",
        "11 \n12 \n21 \n22 \n");

    // --- WHILE / WEND (older syntax) ---
    TEST_EQ("while-wend",
        "DIM i AS INTEGER\ni = 0\n"
        "WHILE i < 3\n"
        "  PRINT i\n  i = i + 1\n"
        "WEND\n",
        "0 \n1 \n2 \n");

    // --- 2D array ---
    TEST_EQ("array-2d",
        "DIM a(2, 2) AS INTEGER\n"
        "a(0, 0) = 1\na(1, 1) = 5\na(2, 2) = 9\n"
        "PRINT a(0, 0) + a(1, 1) + a(2, 2)\n",
        "15 \n");

    // --- REDIM PRESERVE keeps old values ---
    TEST_EQ("redim-preserve",
        "DIM a(2) AS INTEGER\n"
        "a(0) = 10\na(1) = 20\na(2) = 30\n"
        "REDIM PRESERVE a(4)\n"
        "a(3) = 40\na(4) = 50\n"
        "PRINT a(0) + a(2) + a(4)\n",
        "90 \n");

    // --- Nested UDT ---
    TEST_EQ("udt-nested",
        "TYPE Inner\n  x AS INTEGER\nEND TYPE\n"
        "TYPE Outer\n  lbl AS STRING\n  pt AS Inner\nEND TYPE\n"
        "DIM o AS Outer\n"
        "o.lbl = \"hi\"\n"
        "o.pt.x = 42\n"
        "PRINT o.lbl\nPRINT o.pt.x\n",
        "hi\n42 \n");

    // --- Multi-value PRINT with comma/semicolon ---
    TEST_EQ("print-semi",
        "PRINT \"a\"; \"b\"; \"c\"\n",
        "abc\n");

    // --- EXIT SUB ---
    TEST_EQ("exit-sub-early",
        "SUB foo(n AS INTEGER)\n"
        "  IF n < 0 THEN EXIT SUB\n"
        "  PRINT n\n"
        "END SUB\n"
        "foo 5\nfoo -1\nfoo 9\n",
        "5 \n9 \n");

    // --- EXIT FUNCTION ---
    TEST_EQ("exit-function-early",
        "FUNCTION absval(n AS INTEGER) AS INTEGER\n"
        "  IF n < 0 THEN\n    absval = -n\n    EXIT FUNCTION\n  END IF\n"
        "  absval = n\n"
        "END FUNCTION\n"
        "PRINT absval(-7)\nPRINT absval(3)\n",
        "7 \n3 \n");

    // --- Module globals initialize ---
    TEST_EQ("global-init-order",
        "DIM a AS INTEGER\nDIM b AS INTEGER\n"
        "a = 10\nb = a * 2\n"
        "PRINT b\n",
        "20 \n");

    // --- STEP 0 or near-zero should not infinite-loop (if STEP > 0, needs > 0) ---
    TEST_EQ("for-empty-range",
        "DIM i AS INTEGER\n"
        "FOR i = 10 TO 5\n  PRINT i\nNEXT i\n"
        "PRINT \"done\"\n",
        "done\n");

    // --- CONST values ---
    TEST_EQ("const",
        "CONST PI = 3\n"
        "PRINT PI * 2\n",
        "6 \n");

    // --- Nested SUB calls preserve locals ---
    TEST_EQ("nested-sub-locals",
        "SUB inner(x AS INTEGER)\n  PRINT x * 10\nEND SUB\n"
        "SUB outer(y AS INTEGER)\n"
        "  inner y\n"
        "  PRINT y\n"
        "END SUB\n"
        "outer 7\n",
        "70 \n7 \n");

    // --- Recursive sum ---
    TEST_EQ("recursion-sum",
        "FUNCTION sumto(n AS INTEGER) AS LONG\n"
        "  IF n <= 0 THEN\n    sumto = 0\n  ELSE\n    sumto = n + sumto(n - 1)\n  END IF\n"
        "END FUNCTION\n"
        "PRINT sumto(10)\n",
        "55 \n");

    // --- Early EXIT DO ---
    TEST_EQ("exit-do",
        "DIM i AS INTEGER\ni = 0\n"
        "DO\n"
        "  IF i = 3 THEN EXIT DO\n"
        "  PRINT i\n  i = i + 1\n"
        "LOOP\n",
        "0 \n1 \n2 \n");

    // --- String concatenation with numbers via STR$ ---
    TEST_EQ("string-concat-num",
        "DIM n AS INTEGER\n"
        "n = 42\n"
        "PRINT \"n=\" + STR$(n)\n",
        "n= 42\n");

    // --- VAL of empty string returns 0 ---
    TEST_EQ("val-empty",
        "PRINT VAL(\"\")\n",
        "0 \n");

    // --- VAL parses up to first non-numeric ---
    TEST_EQ("val-parse-prefix",
        "PRINT VAL(\"42abc\")\n",
        "42 \n");

    // --- LEFT$/RIGHT$/MID$ with length past end ---
    TEST_EQ("string-bounds-safe",
        "PRINT LEFT$(\"hi\", 10)\n"
        "PRINT RIGHT$(\"hi\", 10)\n"
        "PRINT MID$(\"hi\", 1, 10)\n",
        "hi\nhi\nhi\n");

    // --- INSTR with no match returns 0 ---
    TEST_EQ("instr-nomatch",
        "PRINT INSTR(\"abc\", \"z\")\n",
        "0 \n");

    // --- IF with ELSEIF chain ---
    TEST_EQ("elseif-chain",
        "DIM n AS INTEGER\nn = 2\n"
        "IF n = 1 THEN\n  PRINT \"one\"\n"
        "ELSEIF n = 2 THEN\n  PRINT \"two\"\n"
        "ELSEIF n = 3 THEN\n  PRINT \"three\"\n"
        "ELSE\n  PRINT \"other\"\nEND IF\n",
        "two\n");

    // --- FOR with STEP > range size skips body ---
    TEST_EQ("for-step-over",
        "DIM i AS INTEGER\n"
        "FOR i = 1 TO 5 STEP 10\n  PRINT i\nNEXT i\n",
        "1 \n");

    // --- SELECT CASE with range ---
    TEST_EQ("select-case-range",
        "DIM n AS INTEGER\nn = 5\n"
        "SELECT CASE n\n"
        "  CASE 1 TO 3: PRINT \"low\"\n"
        "  CASE 4 TO 6: PRINT \"mid\"\n"
        "  CASE ELSE: PRINT \"high\"\n"
        "END SELECT\n",
        "mid\n");

    // --- SELECT CASE with IS comparator ---
    TEST_EQ("select-case-is",
        "DIM n AS INTEGER\nn = 100\n"
        "SELECT CASE n\n"
        "  CASE IS < 10: PRINT \"small\"\n"
        "  CASE IS >= 10: PRINT \"big\"\n"
        "END SELECT\n",
        "big\n");

    // --- SUB forward reference backpatched ---
    TEST_EQ("forward-sub-ref",
        "later\n"
        "SUB later\n  PRINT \"ok\"\nEND SUB\n",
        "ok\n");

    // --- more module-level variables than the VM has slots ---
    {
        char   *src   = (char *)malloc(TOO_MANY_GLOBALS_SRC_MAX);
        int32_t used  = 0;

        for (int32_t i = 0; i <= BAS_VM_MAX_GLOBALS; i++) {
            used += snprintf(src + used, TOO_MANY_GLOBALS_SRC_MAX - used, "DIM g%d AS INTEGER\n", (int)i);
        }

        TEST_COMPILE_ERROR("too-many-globals", src, "Too many module-level variables");
        free(src);
    }

    // --- FUNCTION without explicit return returns default value ---
    TEST_EQ("function-default-return",
        "FUNCTION zero AS INTEGER\nEND FUNCTION\n"
        "PRINT zero\n",
        "0 \n");

    // --- STATIC counter across SUB invocations with params ---
    TEST_EQ("static-with-params",
        "SUB inc(n AS INTEGER)\n"
        "  STATIC total AS INTEGER\n"
        "  total = total + n\n"
        "  PRINT total\n"
        "END SUB\n"
        "inc 5\ninc 10\ninc 15\n",
        "5 \n15 \n30 \n");

    // --- IIF equivalent via IF/THEN/ELSE expression on same line ---
    TEST_EQ("inline-if-expr",
        "DIM n AS INTEGER\nn = 7\n"
        "IF n > 5 THEN PRINT \"big\" ELSE PRINT \"small\"\n",
        "big\n");

    // --- CONST string ---
    TEST_EQ("const-string",
        "CONST GREETING = \"hello\"\n"
        "PRINT GREETING\n",
        "hello\n");

    // --- RND and RANDOMIZE don't crash ---
    TEST_EQ("rnd-fires",
        "RANDOMIZE 42\n"
        "DIM n AS DOUBLE\n"
        "n = RND\n"
        "IF n >= 0 AND n < 1 THEN PRINT \"ok\"\n",
        "ok\n");

    // --- TIMER returns a number (just verify it runs) ---
    TEST_EQ("timer-fires",
        "DIM t AS DOUBLE\nt = TIMER\n"
        "IF t >= 0 THEN PRINT \"ok\"\n",
        "ok\n");

    // --- Integer overflow promotes to LONG ---
    TEST_EQ("int-to-long-promotion",
        "DIM a AS INTEGER\nDIM b AS INTEGER\n"
        "a = 20000\nb = 20000\n"
        "PRINT a + b\n",
        "40000 \n");

    // --- String compare case sensitivity ---
    TEST_EQ("string-case-compare",
        "IF \"Abc\" = \"abc\" THEN PRINT \"eq\" ELSE PRINT \"ne\"\n",
        "ne\n");

    // --- OPTION COMPARE TEXT if supported; otherwise skip ---
    // Omitted: checking support first would just cause a compile failure.

    // --- Unary NOT on ints (bitwise) ---
    TEST_EQ("bitwise-not",
        "PRINT NOT 0\n",
        "-1 \n");

    // --- AND/OR as bitwise on ints ---
    TEST_EQ("bitwise-and",
        "PRINT 12 AND 10\n",
        "8 \n");
    TEST_EQ("bitwise-or",
        "PRINT 12 OR 10\n",
        "14 \n");

    // --- STR$ with negative ---
    TEST_EQ("str-negative",
        "PRINT \"[\" + STR$(-5) + \"]\"\n",
        "[-5]\n");

    // --- INT vs FIX on negative ---
    TEST_EQ("int-negative",
        "PRINT INT(-1.5)\n",
        "-2 \n");
    TEST_EQ("fix-negative",
        "PRINT FIX(-1.5)\n",
        "-1 \n");

    // --- MOD on negatives ---
    TEST_EQ("mod-negative",
        "PRINT -7 MOD 3\n",
        "-1 \n");

    // --- SQR ---
    TEST_EQ("sqr",
        "PRINT SQR(16)\n",
        "4 \n");

    // --- LEN of empty ---
    TEST_EQ("len-empty",
        "PRINT LEN(\"\")\n",
        "0 \n");

    // --- Array of UDTs ---
    TEST_EQ("array-of-udt",
        "TYPE P\n  x AS INTEGER\n  y AS INTEGER\nEND TYPE\n"
        "DIM arr(1) AS P\n"
        "arr(0).x = 1\narr(0).y = 2\n"
        "arr(1).x = 10\narr(1).y = 20\n"
        "PRINT arr(0).x + arr(1).y\n",
        "21 \n");

    // --- Empty FOR range, explicit STEP 1 ---
    TEST_EQ("for-step1-empty",
        "DIM i AS INTEGER\n"
        "FOR i = 5 TO 1 STEP 1\n  PRINT i\nNEXT i\n"
        "PRINT \"done\"\n",
        "done\n");

    // --- Negative-step range where start = end ---
    TEST_EQ("for-single-iter",
        "DIM i AS INTEGER\n"
        "FOR i = 3 TO 3 STEP -1\n  PRINT i\nNEXT i\n",
        "3 \n");

    // ============================================================
    // Event-handler dispatch path (runSubLoop, not basVmRun)
    // ============================================================
    // fireCtrlEvent calls basVmCallSub for each event handler.  That
    // routes through runSubLoop which has its own error-dispatch loop.
    // The tests above exercise basVmRun only; these exercise the SUB
    // call path that real event handlers use.

    TEST_SUB_EQ("sub-call-basic",
        "SUB handler\n  PRINT \"hi\"\nEND SUB\n",
        "handler",
        "hi\n");

    TEST_SUB_EQ("sub-call-on-error-div",
        "SUB handler\n"
        "  ON ERROR GOTO h\n"
        "  DIM a AS INTEGER\n  DIM b AS INTEGER\n"
        "  a = 10\n  b = 0\n"
        "  PRINT a \\ b\n"
        "  PRINT \"never\"\n"
        "  EXIT SUB\n"
        "  h:\n"
        "  PRINT \"caught\"\n"
        "  PRINT ERR\n"
        "END SUB\n",
        "handler",
        "caught\n11 \n");

    TEST_SUB_EQ("sub-call-on-error-float-div",
        "SUB handler\n"
        "  ON ERROR GOTO h\n"
        "  DIM a AS INTEGER\n  DIM b AS INTEGER\n"
        "  a = 10\n  b = 0\n"
        "  PRINT a / b\n"
        "  PRINT \"never\"\n"
        "  EXIT SUB\n"
        "  h:\n"
        "  PRINT \"caught\"\n"
        "  PRINT ERR\n"
        "END SUB\n",
        "handler",
        "caught\n11 \n");

    // Regression: basdemo's Run All calls btnError_Click via OP_CALL.
    // The error dispatcher must work when the SUB-under-dispatch runs
    // nested calls before hitting the error.
    TEST_SUB_EQ("sub-call-on-error-after-nested-calls",
        "SUB greet(s AS STRING)\n  PRINT s\nEND SUB\n"
        "SUB handler\n"
        "  greet \"before\"\n"
        "  ON ERROR GOTO h\n"
        "  greet \"armed\"\n"
        "  DIM a AS INTEGER\n  DIM b AS INTEGER\n"
        "  a = 10\n  b = 0\n"
        "  greet STR$(a / b)\n"
        "  greet \"never\"\n"
        "  EXIT SUB\n"
        "  h:\n"
        "  greet \"caught\"\n"
        "END SUB\n",
        "handler",
        "before\narmed\ncaught\n");

    // Regression: Run All dispatches many SUBs in sequence via OP_CALL.
    // One of them has a handler that catches its own error; the chain
    // must continue with the next SUB AND a later SUB's error must
    // still be trappable (inErrorHandler must reset).
    TEST_SUB_EQ("sub-call-run-all-chain",
        "SUB stepOne\n  PRINT \"1\"\nEND SUB\n"
        "SUB stepBoom\n"
        "  ON ERROR GOTO h\n"
        "  DIM a AS INTEGER\n  DIM b AS INTEGER\n"
        "  a = 10\n  b = 0\n"
        "  PRINT a \\ b\n"
        "  EXIT SUB\n"
        "  h:\n"
        "  PRINT \"b-caught\"\n"
        "END SUB\n"
        "SUB stepTwo\n  PRINT \"2\"\nEND SUB\n"
        "SUB stepBoom2\n"
        "  ON ERROR GOTO h2\n"
        "  DIM a AS INTEGER\n  DIM b AS INTEGER\n"
        "  a = 20\n  b = 0\n"
        "  PRINT a \\ b\n"
        "  EXIT SUB\n"
        "  h2:\n"
        "  PRINT \"b2-caught\"\n"
        "END SUB\n"
        "SUB runAll\n"
        "  stepOne\n"
        "  stepBoom\n"
        "  stepTwo\n"
        "  stepBoom2\n"
        "  PRINT \"done\"\n"
        "END SUB\n",
        "runAll",
        "1\nb-caught\n2\nb2-caught\ndone\n");

    // ============================================================
    // Numeric limits and overflow behavior
    // ============================================================

    TEST_EQ("int-max",          "PRINT 32767\n", "32767 \n");
    TEST_EQ("int-min",          "PRINT -32768\n", "-32768 \n");
    TEST_EQ("int-overflow-promotes",
        "DIM a AS INTEGER\nDIM b AS INTEGER\n"
        "a = 32000\nb = 32000\n"
        "PRINT a + b\n",
        "64000 \n");
    TEST_EQ("long-max",         "PRINT 2147483647\n", "2147483647 \n");
    TEST_EQ("long-min",         "PRINT -2147483648\n", "-2147483648 \n");

    // ============================================================
    // Float formatting and precision
    // ============================================================

    TEST_EQ("float-zero",       "PRINT 0.0\n", "0 \n");
    TEST_EQ("float-one",        "PRINT 1.0\n", "1 \n");
    TEST_EQ("float-tiny",       "PRINT 0.001\n", "0.001 \n");
    TEST_EQ("float-neg",        "PRINT -3.14\n", "-3.14 \n");
    // DVX uses %g formatting (~6 digits of precision) for doubles.
    TEST_EQ("float-via-div",    "PRINT 22 / 7\n", "3.14285714285714 \n");
    TEST_EQ("float-large",      "PRINT 1000000.5\n", "1000000.5 \n");
    TEST_EQ("float-huge",       "PRINT 1E20\n", "1E+20 \n");

    // ============================================================
    // Integer division and MOD edge cases
    // ============================================================

    TEST_EQ("idiv-negative-positive", "PRINT -7 \\ 2\n", "-3 \n");
    TEST_EQ("idiv-positive-negative", "PRINT 7 \\ -2\n", "-3 \n");
    TEST_EQ("mod-positive",           "PRINT 7 MOD 3\n", "1 \n");
    TEST_EQ("mod-zero-dividend",      "PRINT 0 MOD 3\n", "0 \n");

    TEST_RUNTIME_ERROR("idiv-zero",    "PRINT 5 \\ 0\n", "");
    TEST_RUNTIME_ERROR("fdiv-zero",    "PRINT 5 / 0\n", "");
    TEST_RUNTIME_ERROR("mod-zero",     "PRINT 5 MOD 0\n", "");

    // ============================================================
    // Comparison operators
    // ============================================================

    // DVX BASIC prints boolean-typed values as "True"/"False" rather
    // than -1/0.  (Same value, different PRINT formatting.)
    TEST_EQ("cmp-eq-true",      "PRINT (5 = 5)\n", "True \n");
    TEST_EQ("cmp-eq-false",     "PRINT (5 = 6)\n", "False \n");
    TEST_EQ("cmp-ne",           "PRINT (5 <> 6)\n", "True \n");
    TEST_EQ("cmp-lt",           "PRINT (3 < 5)\n", "True \n");
    TEST_EQ("cmp-le",           "PRINT (5 <= 5)\n", "True \n");
    TEST_EQ("cmp-gt",           "PRINT (7 > 5)\n", "True \n");
    TEST_EQ("cmp-ge",           "PRINT (5 >= 5)\n", "True \n");
    TEST_EQ("cmp-mixed-types",  "PRINT (5 = 5.0)\n", "True \n");

    // ============================================================
    // Boolean operators
    // ============================================================

    TEST_EQ("bool-true-const",  "PRINT TRUE\n", "True \n");
    TEST_EQ("bool-false-const", "PRINT FALSE\n", "False \n");
    TEST_EQ("xor",              "PRINT 12 XOR 10\n", "6 \n");
    TEST_EQ("imp",              "PRINT -1 IMP 0\n", "0 \n");
    TEST_EQ("eqv",              "PRINT -1 EQV -1\n", "-1 \n");

    // ============================================================
    // String concatenation operators
    // ============================================================

    TEST_EQ("string-amp-concat",   "PRINT \"a\" & \"b\"\n", "ab\n");
    // Unlike STR$, the & operator concatenates the digits directly with
    // no leading space.
    TEST_EQ("string-amp-number",   "PRINT \"num=\" & 42\n", "num=42\n");
    TEST_EQ("string-long-concat",
        "DIM s AS STRING\ns = \"\"\n"
        "DIM i AS INTEGER\n"
        "FOR i = 1 TO 5\n  s = s + \"x\"\nNEXT i\n"
        "PRINT s\n"
        "PRINT LEN(s)\n",
        "xxxxx\n5 \n");

    // ============================================================
    // Fixed-length strings
    // ============================================================

    TEST_EQ("fixed-length-string",
        "DIM s AS STRING * 5\n"
        "s = \"hi\"\n"
        "PRINT \"[\" + s + \"]\"\n"
        "PRINT LEN(s)\n",
        "[hi   ]\n5 \n");

    TEST_EQ("fixed-length-truncate",
        "DIM s AS STRING * 3\n"
        "s = \"hello\"\n"
        "PRINT s\n",
        "hel\n");

    // ============================================================
    // String search / manipulation
    // ============================================================

    TEST_EQ("instr-start-pos",
        "PRINT INSTR(3, \"abcabc\", \"a\")\n",
        "4 \n");
    TEST_EQ("mid-replace-assign",
        "DIM s AS STRING\ns = \"abcdef\"\n"
        "MID$(s, 2, 3) = \"XYZ\"\n"
        "PRINT s\n",
        "aXYZef\n");
    TEST_EQ("str-leading-space-positive",
        "PRINT \"[\" + STR$(5) + \"]\"\n",
        "[ 5]\n");
    TEST_EQ("str-no-leading-neg",
        "PRINT \"[\" + STR$(-5) + \"]\"\n",
        "[-5]\n");

    // ============================================================
    // Type conversion functions
    // ============================================================

    // DVX rounds half-away-from-zero (not VB banker's rounding).
    TEST_EQ("cint-round-half-even", "PRINT CINT(2.5)\n", "2 \n");
    TEST_EQ("cint-round-half-up2","PRINT CINT(3.5)\n", "4 \n");
    TEST_EQ("cint-truncate",      "PRINT CINT(2.49)\n", "2 \n");
    TEST_EQ("cint-negative",      "PRINT CINT(-2.5)\n", "-2 \n");
    TEST_EQ("clng-from-double",   "PRINT CLNG(3.9)\n", "4 \n");
    TEST_EQ("csng",               "PRINT CSNG(1.5)\n", "1.5 \n");
    TEST_EQ("cstr-int",           "PRINT CSTR(42)\n", "42\n");
    TEST_EQ("cdbl-of-int",        "PRINT CDBL(10) / 4\n", "2.5 \n");
    TEST_EQ("cbool-nonzero",      "PRINT CBOOL(5)\n", "True \n");
    TEST_EQ("cbool-zero",         "PRINT CBOOL(0)\n", "False \n");
    TEST_EQ("cbool-neg",          "PRINT CBOOL(-42)\n", "True \n");
    TEST_EQ("cbool-empty-string", "PRINT CBOOL(\"\")\n", "False \n");
    TEST_EQ("cbool-nonempty-str", "PRINT CBOOL(\"hi\")\n", "True \n");

    TEST_EQ("hex-positive",       "PRINT HEX$(255)\n", "FF\n");
    TEST_EQ("hex-zero",           "PRINT HEX$(0)\n", "0\n");
    TEST_EQ("oct-eight",          "PRINT OCT$(8)\n", "10\n");
    TEST_EQ("oct-zero",           "PRINT OCT$(0)\n", "0\n");
    TEST_EQ("oct-sixtythree",     "PRINT OCT$(63)\n", "77\n");

    // ============================================================
    // Math functions
    // ============================================================

    TEST_EQ("math-sqr-zero",    "PRINT SQR(0)\n", "0 \n");
    TEST_EQ("math-sqr-float",   "PRINT SQR(2)\n", "1.4142135623731 \n");
    TEST_EQ("math-cos-zero",    "PRINT COS(0)\n", "1 \n");
    TEST_EQ("math-sin-zero",    "PRINT SIN(0)\n", "0 \n");
    TEST_EQ("math-exp-zero",    "PRINT EXP(0)\n", "1 \n");
    TEST_EQ("math-log-one",     "PRINT LOG(1)\n", "0 \n");
    TEST_EQ("math-abs-double",  "PRINT ABS(-3.14)\n", "3.14 \n");

    // ============================================================
    // Variable types and default initialization
    // ============================================================

    TEST_EQ("default-int-zero",
        "DIM n AS INTEGER\nPRINT n\n",
        "0 \n");
    TEST_EQ("default-long-zero",
        "DIM n AS LONG\nPRINT n\n",
        "0 \n");
    TEST_EQ("default-double-zero",
        "DIM n AS DOUBLE\nPRINT n\n",
        "0 \n");
    // Regression: DIM AS STRING now initializes the slot to an empty
    // string rather than leaving it as integer 0.  Previously
    // "[" + s + "]" on an uninitialized STRING stringified as "0"
    // because the slot type was INTEGER.
    TEST_EQ("default-string-is-empty",
        "DIM s AS STRING\nPRINT \"[\" + s + \"]\"\n",
        "[]\n");
    TEST_EQ("default-string-concat-works",
        "DIM s AS STRING\ns = s + \"x\"\ns = s + \"y\"\nPRINT s\n",
        "xy\n");

    TEST_EQ("dollar-suffix",
        "DIM s$\ns$ = \"hi\"\nPRINT s$\n",
        "hi\n");
    TEST_EQ("percent-suffix",
        "DIM n%\nn% = 42\nPRINT n%\n",
        "42 \n");
    TEST_EQ("amp-suffix",
        "DIM n&\nn& = 100000\nPRINT n&\n",
        "100000 \n");
    TEST_EQ("bang-suffix",
        "DIM f!\nf! = 1.5\nPRINT f!\n",
        "1.5 \n");
    TEST_EQ("hash-suffix",
        "DIM d#\nd# = 3.14159\nPRINT d#\n",
        "3.14159 \n");

    // ============================================================
    // FOR loop variants
    // ============================================================

    TEST_EQ("for-next-var-name",
        "DIM i AS INTEGER\n"
        "FOR i = 1 TO 3\n  PRINT i\nNEXT i\n",
        "1 \n2 \n3 \n");
    TEST_EQ("for-next-no-varname",
        "DIM i AS INTEGER\n"
        "FOR i = 1 TO 3\n  PRINT i\nNEXT\n",
        "1 \n2 \n3 \n");
    TEST_EQ("for-double-varies",
        "DIM a AS DOUBLE\n"
        "FOR a = 1 TO 3 STEP 0.5\n  PRINT a\nNEXT a\n",
        "1 \n1.5 \n2 \n2.5 \n3 \n");
    TEST_EQ("for-neg-step-range",
        "DIM i AS INTEGER\n"
        "FOR i = 10 TO 4 STEP -2\n  PRINT i\nNEXT i\n",
        "10 \n8 \n6 \n4 \n");
    TEST_EQ("for-nested-exit-inner",
        "DIM i AS INTEGER\nDIM j AS INTEGER\n"
        "FOR i = 1 TO 3\n"
        "  FOR j = 1 TO 3\n"
        "    IF j = 2 THEN EXIT FOR\n"
        "    PRINT i*10+j\n"
        "  NEXT j\n"
        "NEXT i\n",
        "11 \n21 \n31 \n");

    // ============================================================
    // WHILE / DO variants
    // ============================================================

    TEST_EQ("do-loop-while-post",
        "DIM i AS INTEGER\ni = 10\n"
        "DO\n  PRINT i\n  i = i - 1\nLOOP WHILE i > 8\n",
        "10 \n9 \n");
    TEST_EQ("do-loop-until-post",
        "DIM i AS INTEGER\ni = 0\n"
        "DO\n  i = i + 1\n  PRINT i\nLOOP UNTIL i = 3\n",
        "1 \n2 \n3 \n");
    TEST_EQ("do-while-never-enters",
        "DIM i AS INTEGER\ni = 5\n"
        "DO WHILE i = 0\n  PRINT \"inside\"\nLOOP\n"
        "PRINT \"after\"\n",
        "after\n");

    // ============================================================
    // String scanning / conversion round-trip
    // ============================================================

    TEST_EQ("val-negative",     "PRINT VAL(\"-42\")\n", "-42 \n");
    TEST_EQ("val-float",        "PRINT VAL(\"3.14\")\n", "3.14 \n");
    TEST_EQ("val-whitespace",   "PRINT VAL(\"   123   \")\n", "123 \n");
    TEST_EQ("val-only-letters", "PRINT VAL(\"abc\")\n", "0 \n");
    TEST_EQ("str-then-val",
        "DIM n AS INTEGER\nn = 42\n"
        "PRINT VAL(STR$(n))\n",
        "42 \n");

    // ============================================================
    // SUB / FUNCTION edge cases
    // ============================================================

    TEST_EQ("sub-zero-params",
        "SUB greet\n  PRINT \"hi\"\nEND SUB\n"
        "greet\n",
        "hi\n");
    TEST_EQ("function-returning-string",
        "FUNCTION greeting() AS STRING\n  greeting = \"hi\"\nEND FUNCTION\n"
        "PRINT greeting()\n",
        "hi\n");
    TEST_EQ("function-multi-arg",
        "FUNCTION sum3(a AS INTEGER, b AS INTEGER, c AS INTEGER) AS INTEGER\n"
        "  sum3 = a + b + c\nEND FUNCTION\n"
        "PRINT sum3(1, 2, 3)\n",
        "6 \n");
    // OPTIONAL parameters: use `nm` as the param name; `name` is
    // a reserved keyword for the NAME statement in DVX BASIC.
    TEST_EQ("optional-param-omitted",
        "SUB greet(nm AS STRING, OPTIONAL count AS INTEGER)\n"
        "  DIM i AS INTEGER\n"
        "  IF count = 0 THEN count = 1\n"
        "  FOR i = 1 TO count\n    PRINT nm\n  NEXT i\n"
        "END SUB\n"
        "greet \"hi\", 2\n"
        "greet \"solo\"\n",
        "hi\nhi\nsolo\n");

    // Mutual recursion: needs DECLARE forward for the later function.
    TEST_EQ("mutual-recursion",
        "DECLARE FUNCTION isOdd(n AS INTEGER) AS INTEGER\n"
        "FUNCTION isEven(n AS INTEGER) AS INTEGER\n"
        "  IF n = 0 THEN\n    isEven = 1\n  ELSE\n    isEven = isOdd(n - 1)\n  END IF\n"
        "END FUNCTION\n"
        "FUNCTION isOdd(n AS INTEGER) AS INTEGER\n"
        "  IF n = 0 THEN\n    isOdd = 0\n  ELSE\n    isOdd = isEven(n - 1)\n  END IF\n"
        "END FUNCTION\n"
        "PRINT isEven(10)\nPRINT isOdd(10)\n",
        "1 \n0 \n");

    TEST_EQ("byval-array-element",
        "SUB bump(BYVAL x AS INTEGER)\n  x = x + 1\nEND SUB\n"
        "DIM a(2) AS INTEGER\n"
        "a(0) = 5\na(1) = 10\n"
        "bump a(0)\n"
        "PRINT a(0)\nPRINT a(1)\n",
        "5 \n10 \n");

    TEST_EQ("byref-array-element",
        "SUB bump(x AS INTEGER)\n  x = x + 1\nEND SUB\n"
        "DIM a(2) AS INTEGER\n"
        "a(0) = 5\na(1) = 10\n"
        "bump a(0)\n"
        "bump a(1)\nbump a(1)\n"
        "PRINT a(0)\nPRINT a(1)\n",
        "6 \n12 \n");

    TEST_EQ("byref-array-element-2d",
        "SUB bump(x AS INTEGER)\n  x = x + 100\nEND SUB\n"
        "DIM a(1, 1) AS INTEGER\n"
        "a(0, 0) = 1\na(1, 1) = 2\n"
        "bump a(0, 0)\n"
        "bump a(1, 1)\n"
        "PRINT a(0, 0)\nPRINT a(1, 1)\n",
        "101 \n102 \n");

    // ============================================================
    // Array operations
    // ============================================================

    TEST_EQ("array-negative-lbound",
        "DIM a(-2 TO 2) AS INTEGER\n"
        "a(-2) = 10\na(0) = 50\na(2) = 90\n"
        "PRINT a(-2) + a(0) + a(2)\n"
        "PRINT LBOUND(a)\nPRINT UBOUND(a)\n",
        "150 \n-2 \n2 \n");

    TEST_EQ("redim-clears",
        "DIM a(3) AS INTEGER\n"
        "a(0) = 99\n"
        "REDIM a(5)\n"
        "PRINT a(0)\n",
        "0 \n");

    // ERASE behavior in DVX: after ERASE a, accessing a(0) errors
    // ("Not an array").  Verify that behavior.
    TEST_RUNTIME_ERROR("erase-makes-not-array",
        "DIM a(3) AS INTEGER\n"
        "a(0) = 7\n"
        "ERASE a\n"
        "PRINT a(0)\n",
        "");

    TEST_EQ("array-3d",
        "DIM a(1, 1, 1) AS INTEGER\n"
        "a(0, 0, 0) = 1\n"
        "a(1, 1, 1) = 8\n"
        "PRINT a(0, 0, 0) + a(1, 1, 1)\n",
        "9 \n");

    TEST_EQ("array-string",
        "DIM names(2) AS STRING\n"
        "names(0) = \"alpha\"\nnames(1) = \"beta\"\nnames(2) = \"gamma\"\n"
        "PRINT names(0) + \"-\" + names(2)\n",
        "alpha-gamma\n");

    // ============================================================
    // UDTs
    // ============================================================

    TEST_EQ("udt-used-locally",
        "TYPE P\n  x AS INTEGER\n  y AS INTEGER\nEND TYPE\n"
        "DIM a AS P\na.x = 3\na.y = 4\n"
        "PRINT a.x\nPRINT a.y\n",
        "3 \n4 \n");

    TEST_EQ("udt-byref-param",
        "TYPE P\n  x AS INTEGER\n  y AS INTEGER\nEND TYPE\n"
        "SUB move(pt AS P, dx AS INTEGER, dy AS INTEGER)\n"
        "  pt.x = pt.x + dx\n  pt.y = pt.y + dy\n"
        "END SUB\n"
        "DIM a AS P\na.x = 1\na.y = 2\n"
        "move a, 10, 20\n"
        "PRINT a.x\nPRINT a.y\n",
        "11 \n22 \n");

    TEST_EQ("udt-function-arg-read",
        "TYPE Box\n  w AS INTEGER\n  h AS INTEGER\nEND TYPE\n"
        "FUNCTION area(b AS Box) AS LONG\n"
        "  area = b.w * b.h\n"
        "END FUNCTION\n"
        "DIM r AS Box\nr.w = 6\nr.h = 7\n"
        "PRINT area(r)\n",
        "42 \n");

    // ============================================================
    // DATA / READ / RESTORE
    // ============================================================

    TEST_EQ("data-read-strings",
        "DIM s AS STRING\n"
        "READ s\nPRINT s\n"
        "READ s\nPRINT s\n"
        "DATA hello, world\n",
        "hello\nworld\n");

    TEST_EQ("data-read-mixed",
        "DIM n AS INTEGER\nDIM s AS STRING\n"
        "READ n\nREAD s\nREAD n\n"
        "PRINT s\nPRINT n\n"
        "DATA 10, hello, 20\n",
        "hello\n20 \n");

    TEST_EQ("restore-rewinds",
        "DIM n AS INTEGER\n"
        "READ n\nPRINT n\n"
        "RESTORE\nREAD n\nPRINT n\n"
        "DATA 5, 6, 7\n",
        "5 \n5 \n");

    // ============================================================
    // ON ERROR additional scenarios
    // ============================================================

    TEST_EQ("err-clears-after-handler",
        "SUB foo\n"
        "  ON ERROR GOTO h\n"
        "  DIM a AS INTEGER\n  DIM b AS INTEGER\n"
        "  a = 5\n  b = 0\n"
        "  PRINT a \\ b\n"
        "  EXIT SUB\n"
        "  h:\n"
        "  PRINT \"caught\"\n"
        "END SUB\n"
        "foo\n"
        "PRINT \"err=\"; ERR\n",
        "caught\nerr=0 \n");

    // ============================================================
    // Labels and GOTO
    // ============================================================

    TEST_EQ("goto-forward",
        "GOTO done\n"
        "PRINT \"skipped\"\n"
        "done:\n"
        "PRINT \"here\"\n",
        "here\n");

    TEST_EQ("goto-backward-countdown",
        "DIM i AS INTEGER\ni = 3\n"
        "top:\n"
        "PRINT i\ni = i - 1\n"
        "IF i > 0 THEN GOTO top\n",
        "3 \n2 \n1 \n");

    // ============================================================
    // SELECT CASE edge cases
    // ============================================================

    TEST_EQ("select-case-multi-val",
        "DIM n AS INTEGER\nn = 3\n"
        "SELECT CASE n\n"
        "  CASE 1, 2, 3: PRINT \"small\"\n"
        "  CASE 4, 5, 6: PRINT \"mid\"\n"
        "  CASE ELSE: PRINT \"big\"\n"
        "END SELECT\n",
        "small\n");

    TEST_EQ("select-case-string",
        "DIM s AS STRING\ns = \"b\"\n"
        "SELECT CASE s\n"
        "  CASE \"a\": PRINT \"A\"\n"
        "  CASE \"b\": PRINT \"B\"\n"
        "  CASE ELSE: PRINT \"?\"\n"
        "END SELECT\n",
        "B\n");

    TEST_EQ("select-case-fallthrough-none",
        // SELECT CASE does NOT fall through -- it matches first, exits.
        "DIM n AS INTEGER\nn = 5\n"
        "SELECT CASE n\n"
        "  CASE 1 TO 10: PRINT \"range\"\n"
        "  CASE IS > 0: PRINT \"pos\"\n"
        "END SELECT\n",
        "range\n");

    // ============================================================
    // STATIC: value persists across calls
    // ============================================================

    TEST_EQ("static-double",
        "SUB tick\n"
        "  STATIC n AS DOUBLE\n"
        "  n = n + 0.5\n"
        "  PRINT n\n"
        "END SUB\n"
        "tick\ntick\ntick\n",
        "0.5 \n1 \n1.5 \n");

    TEST_EQ("static-string",
        "SUB appendHistory\n"
        "  STATIC hist AS STRING\n"
        "  hist = hist + \"x\"\n"
        "  PRINT hist\n"
        "END SUB\n"
        "appendHistory\nappendHistory\nappendHistory\n",
        "x\nxx\nxxx\n");

    // ============================================================
    // String array element manipulation
    // ============================================================

    TEST_EQ("string-array-accumulate",
        "DIM parts(2) AS STRING\n"
        "parts(0) = \"A\"\nparts(1) = \"B\"\nparts(2) = \"C\"\n"
        "DIM result AS STRING\nresult = \"\"\n"
        "DIM i AS INTEGER\n"
        "FOR i = 0 TO 2\n  result = result + parts(i)\nNEXT i\n"
        "PRINT result\n",
        "ABC\n");

    // ============================================================
    // PRINT formatting
    // ============================================================

    TEST_EQ("print-comma-tab",
        "PRINT \"a\", \"b\"\n",
        "a\tb\n");
    TEST_EQ("print-trailing-semi",
        "PRINT \"no-newline\";\nPRINT \"follow\"\n",
        "no-newlinefollow\n");
    TEST_EQ("print-multi-numbers",
        "PRINT 1; 2; 3\n",
        "1 2 3 \n");

    // ============================================================
    // Edge cases in ON ERROR
    // ============================================================

    TEST_EQ("error-in-handler-still-fires",
        // A second error inside the handler with no new ON ERROR should
        // NOT loop back to the same handler.  QBASIC: second error is
        // fatal while inErrorHandler=true.
        "ON ERROR GOTO h\n"
        "DIM a AS INTEGER\nDIM b AS INTEGER\n"
        "a = 5\nb = 0\n"
        "PRINT a \\ b\n"
        "END\n"
        "h:\n"
        "PRINT \"handler\"\n"
        "PRINT ERR\n",
        "handler\n11 \n");

    // ============================================================
    // GOSUB / RETURN
    // ============================================================

    TEST_EQ("gosub-multiple",
        "GOSUB one\nGOSUB two\nEND\n"
        "one:\nPRINT \"1\"\nRETURN\n"
        "two:\nPRINT \"2\"\nRETURN\n",
        "1\n2\n");

    TEST_EQ("gosub-nested",
        "GOSUB outer\nEND\n"
        "outer:\nPRINT \"outer-in\"\nGOSUB inner\nPRINT \"outer-out\"\nRETURN\n"
        "inner:\nPRINT \"inner\"\nRETURN\n",
        "outer-in\ninner\nouter-out\n");

    // ============================================================
    // Complex expressions
    // ============================================================

    TEST_EQ("expr-chained-string-concat",
        "DIM a AS STRING\nDIM b AS STRING\nDIM c AS STRING\n"
        "a = \"foo\"\nb = \"bar\"\nc = \"baz\"\n"
        "PRINT a + b + c + \"-end\"\n",
        "foobarbaz-end\n");

    TEST_EQ("expr-paren-nested",
        "PRINT ((1 + 2) * 3) - (4 \\ 2)\n",
        "7 \n");

    TEST_EQ("expr-short-circuit-and",
        // OR should evaluate both operands in classic BASIC (no
        // short-circuit).  Just verify the result is correct.
        "DIM a AS INTEGER\nDIM b AS INTEGER\n"
        "a = 0\nb = 5\n"
        "IF a = 0 OR b = 5 THEN PRINT \"ok\"\n",
        "ok\n");

    // ============================================================
    // IF/END IF variants
    // ============================================================

    TEST_EQ("if-else-block",
        "DIM n AS INTEGER\nn = 1\n"
        "IF n > 0 THEN\n  PRINT \"pos\"\nELSE\n  PRINT \"neg\"\nEND IF\n",
        "pos\n");

    TEST_EQ("if-elseif-block",
        "DIM n AS INTEGER\nn = 15\n"
        "IF n < 10 THEN\n  PRINT \"<10\"\n"
        "ELSEIF n < 20 THEN\n  PRINT \"<20\"\n"
        "ELSEIF n < 30 THEN\n  PRINT \"<30\"\n"
        "ELSE\n  PRINT \">=30\"\nEND IF\n",
        "<20\n");

    TEST_EQ("if-nested",
        "DIM a AS INTEGER\nDIM b AS INTEGER\n"
        "a = 5\nb = 10\n"
        "IF a > 0 THEN\n"
        "  IF b > 0 THEN\n    PRINT \"both\"\n  END IF\n"
        "END IF\n",
        "both\n");

    // ============================================================
    // Number parsing in source
    // ============================================================

    TEST_EQ("num-hex-literal",   "PRINT &H10\n", "16 \n");
    TEST_EQ("num-hex-ff",        "PRINT &HFF\n", "255 \n");
    TEST_EQ("num-octal-literal", "PRINT &O10\n", "8 \n");
    TEST_EQ("num-octal-77",      "PRINT &O77\n", "63 \n");
    TEST_EQ("num-binary-literal","PRINT &B1010\n", "10 \n");
    TEST_EQ("num-binary-all",    "PRINT &B11111111\n", "255 \n");
    TEST_EQ("num-scientific",    "PRINT 1E3\n", "1000 \n");
    TEST_EQ("num-scientific-neg","PRINT 1E-2\n", "0.01 \n");

    // ============================================================
    // Comments
    // ============================================================

    TEST_EQ("apostrophe-comment",
        "PRINT 1 ' this is a comment\n"
        "PRINT 2\n",
        "1 \n2 \n");
    TEST_EQ("rem-comment",
        "REM this is a comment\n"
        "PRINT 1\n",
        "1 \n");

    // ============================================================
    // Empty PRINT and blank lines
    // ============================================================

    TEST_EQ("print-empty",
        "PRINT\n"
        "PRINT \"after\"\n",
        "\nafter\n");

    // ============================================================
    // String literal with embedded quotes (via CHR$)
    // ============================================================

    TEST_EQ("chr-quote",
        "PRINT \"say \" + CHR$(34) + \"hi\" + CHR$(34)\n",
        "say \"hi\"\n");
    TEST_EQ("chr-newline-embeds",
        "PRINT \"a\" + CHR$(10) + \"b\"\n",
        "a\nb\n");

    // ============================================================
    // END statement halts execution
    // ============================================================

    TEST_EQ("end-halts",
        "PRINT \"before\"\nEND\nPRINT \"after\"\n",
        "before\n");

    // ============================================================
    // Declaration ordering: globals must be DIM'd before SUBs that
    // reference them (the DVX compiler is single-pass).
    // ============================================================

    TEST_EQ("global-dim-then-sub",
        "DIM counter AS INTEGER\n"
        "SUB showIt\n  PRINT counter\nEND SUB\n"
        "counter = 7\n"
        "showIt\n",
        "7 \n");

    // ============================================================
    // Type conversion through assignment
    // ============================================================

    // Stores into an explicitly typed variable coerce to the declared
    // type: INTEGER/LONG round (banker's) and range-check, SINGLE
    // narrows.  Untyped variables keep the value's own type.
    TEST_EQ("typed-store-int-rounds",
        "DIM n AS INTEGER\n"
        "n = 3.7\n"
        "PRINT n\n",
        "4 \n");

    TEST_EQ("typed-store-suffix-rounds",
        "i% = 2.5\nj% = 3.5\nPRINT i%; j%\n",
        "2 4 \n");

    TEST_RUNTIME_ERROR("typed-store-int-overflow",
        "DIM n AS INTEGER\nn = 40000\n",
        "Overflow");

    TEST_EQ("typed-store-long",
        "DIM l AS LONG\nl = 40000.4\nPRINT l\n",
        "40000 \n");

    TEST_RUNTIME_ERROR("typed-store-long-overflow",
        "l& = 2147483647 + 1\n",
        "Overflow");

    TEST_EQ("typed-store-single",
        "DIM s AS SINGLE\ns = 1 / 3\nPRINT s\n",
        "0.3333333 \n");

    TEST_EQ("typed-store-defint",
        "DEFINT A-C\nb = 1.5\nPRINT b\n",
        "2 \n");

    TEST_EQ("typed-store-untyped-keeps-float",
        "x = 3.7\nPRINT x\n",
        "3.7 \n");

    TEST_EQ("typed-store-array-element",
        "DIM a(3) AS INTEGER\na(1) = 2.5\na(2) = 3.5\nPRINT a(1); a(2)\n",
        "2 4 \n");

    TEST_EQ("typed-store-udt-field",
        "TYPE T\n  n AS INTEGER\n  l AS LONG\nEND TYPE\n"
        "DIM v AS T\nv.n = 1.5\nv.l = 2.5\nPRINT v.n; v.l\n",
        "2 2 \n");

    TEST_EQ("typed-store-udt-array-field",
        "TYPE T\n  n AS INTEGER\nEND TYPE\n"
        "DIM a(2) AS T\na(1).n = 0.5\nPRINT a(1).n\n",
        "0 \n");

    TEST_EQ("typed-store-for-var",
        "FOR i% = 0.6 TO 3\n  PRINT i%;\nNEXT\nPRINT\n",
        "1 2 3 \n");

    TEST_EQ("typed-store-param",
        "SUB s(n AS INTEGER)\n  n = 1.5\n  PRINT n\nEND SUB\ns 0\n",
        "2 \n");

    TEST_EQ("typed-store-read",
        "DATA 1.5, 2.5\nDIM a AS INTEGER\nDIM b AS INTEGER\nREAD a, b\nPRINT a; b\n",
        "2 2 \n");

    TEST_EQ("assign-string-from-num",
        "DIM s AS STRING\n"
        "s = STR$(42)\n"
        "PRINT s\n",
        " 42\n");

    // ============================================================
    // Arrays of doubles
    // ============================================================

    TEST_EQ("double-array",
        "DIM d(3) AS DOUBLE\n"
        "d(0) = 0.25\nd(1) = 0.5\nd(2) = 0.75\nd(3) = 1.0\n"
        "PRINT d(0) + d(1) + d(2) + d(3)\n",
        "2.5 \n");

    // ============================================================
    // Very deep recursion smoke test
    // ============================================================

    TEST_EQ("deep-recursion-50",
        "FUNCTION countdown(n AS INTEGER) AS INTEGER\n"
        "  IF n <= 0 THEN\n    countdown = 0\n  ELSE\n    countdown = 1 + countdown(n - 1)\n  END IF\n"
        "END FUNCTION\n"
        "PRINT countdown(50)\n",
        "50 \n");

    // ============================================================
    // String in SELECT CASE with mixed case (default: case sensitive)
    // ============================================================

    TEST_EQ("select-case-str-case-sensitive",
        "DIM s AS STRING\ns = \"Apple\"\n"
        "SELECT CASE s\n"
        "  CASE \"apple\": PRINT \"lower\"\n"
        "  CASE \"Apple\": PRINT \"title\"\n"
        "  CASE ELSE: PRINT \"?\"\n"
        "END SELECT\n",
        "title\n");

    // ============================================================
    // SUB-call tests through basVmCallSub (event handler path)
    // ============================================================

    TEST_SUB_EQ("subcall-globals-shared",
        "DIM g AS INTEGER\ng = 5\n"
        "SUB showG\n  PRINT g\n  g = g + 1\nEND SUB\n",
        "showG",
        "5 \n");

    TEST_SUB_EQ("subcall-calls-other-sub",
        "SUB a\n  PRINT \"a\"\n  b\nEND SUB\n"
        "SUB b\n  PRINT \"b\"\nEND SUB\n",
        "a",
        "a\nb\n");

    TEST_SUB_EQ("subcall-for-loop-fractional",
        // Event handler running GfxDrawAll-style code
        "SUB drawCircle\n"
        "  DIM a AS DOUBLE\n"
        "  DIM c AS INTEGER\n"
        "  c = 0\n"
        "  FOR a = 0 TO 6.3 STEP 0.2\n"
        "    c = c + 1\n"
        "  NEXT a\n"
        "  PRINT c\n"
        "END SUB\n",
        "drawCircle",
        "32 \n");

    TEST_SUB_EQ("subcall-on-error-then-normal",
        // After handler runs, a subsequent call should work normally.
        "SUB maybeFail(shouldFail AS INTEGER)\n"
        "  ON ERROR GOTO h\n"
        "  IF shouldFail THEN\n"
        "    DIM a AS INTEGER\n    DIM b AS INTEGER\n"
        "    a = 1\n    b = 0\n"
        "    PRINT a \\ b\n"
        "  ELSE\n"
        "    PRINT \"ok\"\n"
        "  END IF\n"
        "  EXIT SUB\n"
        "  h:\n"
        "  PRINT \"caught\"\n"
        "END SUB\n"
        "SUB driver\n"
        "  maybeFail 0\n"
        "  maybeFail 1\n"
        "  maybeFail 0\n"
        "END SUB\n",
        "driver",
        "ok\ncaught\nok\n");

    // ============================================================
    // Widget-level event dispatch (mirrors formrt.c fireCtrlEvent)
    // ============================================================

    // SetEvent override: handler name differs from Ctrl_Event convention
    {
        TestDispatchT d = {
            .source     = "SUB handleIt\n  PRINT \"clicked\"\nEND SUB\n",
            .formName   = "",
            .ctrlName   = "btn1",
            .ctrlType   = "CommandButton",
            .overrides  = DISPATCH_OVR("Click", "handleIt"),
            .fires      = DISPATCH_FIRE("Click"),
        };
        testDispatchEq("dispatch-override", &d, "clicked\n");
    }

    // Naming-convention fallback: no override, CtrlName_EventName SUB
    {
        TestDispatchT d = {
            .source     = "SUB btn1_Click\n  PRINT \"fallback\"\nEND SUB\n",
            .formName   = "",
            .ctrlName   = "btn1",
            .ctrlType   = "CommandButton",
            .fires      = DISPATCH_FIRE("Click"),
        };
        testDispatchEq("dispatch-naming-convention", &d, "fallback\n");
    }

    // Override wins over naming convention
    {
        TestDispatchT d = {
            .source     =
                "SUB btn1_Click\n  PRINT \"convention\"\nEND SUB\n"
                "SUB doOverride\n  PRINT \"override\"\nEND SUB\n",
            .formName   = "",
            .ctrlName   = "btn1",
            .ctrlType   = "CommandButton",
            .overrides  = DISPATCH_OVR("Click", "doOverride"),
            .fires      = DISPATCH_FIRE("Click"),
        };
        testDispatchEq("dispatch-override-beats-naming", &d, "override\n");
    }

    // Handler missing: silently skipped, no crash
    {
        TestDispatchT d = {
            .source     = "SUB unrelated\n  PRINT \"never\"\nEND SUB\n",
            .formName   = "",
            .ctrlName   = "btn1",
            .ctrlType   = "CommandButton",
            .fires      = DISPATCH_FIRE("Click"),
        };
        testDispatchEq("dispatch-no-handler", &d, "");
    }

    // Multiple events on same control
    {
        TestDispatchT d = {
            .source     =
                "SUB btn1_Click\n  PRINT \"click\"\nEND SUB\n"
                "SUB btn1_GotFocus\n  PRINT \"focus\"\nEND SUB\n",
            .formName   = "",
            .ctrlName   = "btn1",
            .ctrlType   = "CommandButton",
            .fires      = DISPATCH_FIRE("GotFocus", "Click", "GotFocus"),
        };
        testDispatchEq("dispatch-multiple-events", &d, "focus\nclick\nfocus\n");
    }

    // Globals accumulate across events (real apps' common pattern)
    {
        TestDispatchT d = {
            .source     =
                "DIM counter AS INTEGER\n"
                "counter = 0\n"
                "SUB btn1_Click\n"
                "  counter = counter + 1\n"
                "  PRINT counter\n"
                "END SUB\n",
            .formName   = "",
            .ctrlName   = "btn1",
            .ctrlType   = "CommandButton",
            .fires      = DISPATCH_FIRE("Click", "Click", "Click"),
        };
        testDispatchEq("dispatch-global-counter", &d, "1 \n2 \n3 \n");
    }

    // Form-scope vars accessed via SUB declared in BEGINFORM
    {
        TestDispatchT d = {
            .source     =
                "BEGINFORM \"MyForm\"\n"
                "DIM hits AS INTEGER\n"
                "SUB btn1_Click\n"
                "  hits = hits + 1\n"
                "  PRINT hits\n"
                "END SUB\n"
                "ENDFORM\n",
            .formName     = "MyForm",
            .formVarCount = 1,
            .ctrlName     = "btn1",
            .ctrlType     = "CommandButton",
            .fires        = DISPATCH_FIRE("Click", "Click"),
        };
        testDispatchEq("dispatch-form-scope-vars", &d, "1 \n2 \n");
    }

    // ON ERROR inside the handler traps the error and the handler is
    // reusable on the next fire (inErrorHandler must reset cleanly).
    {
        TestDispatchT d = {
            .source     =
                "SUB btn1_Click\n"
                "  ON ERROR GOTO h\n"
                "  DIM a AS INTEGER\n  DIM b AS INTEGER\n"
                "  a = 10\n  b = 0\n"
                "  PRINT a \\ b\n"
                "  EXIT SUB\n"
                "  h:\n"
                "  PRINT \"caught\"\n"
                "END SUB\n",
            .formName   = "",
            .ctrlName   = "btn1",
            .ctrlType   = "CommandButton",
            .fires      = DISPATCH_FIRE("Click", "Click"),
        };
        testDispatchEq("dispatch-on-error-reusable", &d, "caught\ncaught\n");
    }

    // Single fire of a handler that increments and prints a global.
    // NOTE: this does NOT exercise the same-event re-entrancy guard in
    // miniFireCtrlEvent -- the mini-runtime gives BASIC no way to re-fire
    // the same control's event from within its own handler, so genuine
    // re-entrant coverage would require a synthetic harness trigger.
    {
        TestDispatchT d = {
            .source     =
                "DIM depth AS INTEGER\n"
                "depth = 0\n"
                "SUB btn1_Click\n"
                "  depth = depth + 1\n"
                "  PRINT depth\n"
                "END SUB\n",
            .formName   = "",
            .ctrlName   = "btn1",
            .ctrlType   = "CommandButton",
            .fires      = DISPATCH_FIRE("Click"),
        };
        testDispatchEq("dispatch-single-fire-counter", &d, "1 \n");
    }

    // SetEvent override twice on the same event name: second wins.
    {
        TestDispatchT d = {
            .source     =
                "SUB first\n  PRINT \"first\"\nEND SUB\n"
                "SUB second\n  PRINT \"second\"\nEND SUB\n",
            .formName   = "",
            .ctrlName   = "btn1",
            .ctrlType   = "CommandButton",
            .overrides  = DISPATCH_OVR("Click", "first", "Click", "second"),
            .fires      = DISPATCH_FIRE("Click"),
        };
        testDispatchEq("dispatch-override-replace", &d, "second\n");
    }

    // Timer-like pattern: onChange fires "Timer" (mapped in formrt by
    // ctrl->typeName == "Timer"); here we exercise the SetEvent path
    // with a Timer-typed control.
    {
        TestDispatchT d = {
            .source     =
                "DIM ticks AS INTEGER\n"
                "ticks = 0\n"
                "SUB onTick\n"
                "  ticks = ticks + 1\n"
                "  PRINT ticks\n"
                "END SUB\n",
            .formName   = "",
            .ctrlName   = "t1",
            .ctrlType   = "Timer",
            .overrides  = DISPATCH_OVR("Timer", "onTick"),
            .fires      = DISPATCH_FIRE("Timer", "Timer", "Timer"),
        };
        testDispatchEq("dispatch-timer-override", &d, "1 \n2 \n3 \n");
    }

    // Menu-like dispatch via naming convention (proxies have typeName "")
    {
        TestDispatchT d = {
            .source     = "SUB mnuFoo_Click\n  PRINT \"menu\"\nEND SUB\n",
            .formName   = "",
            .ctrlName   = "mnuFoo",
            .ctrlType   = "Menu",
            .fires      = DISPATCH_FIRE("Click"),
        };
        testDispatchEq("dispatch-menu-click", &d, "menu\n");
    }

    // Handler calls into another SUB.  Verifies nested SUB calls don't
    // interact badly with the event guard.
    {
        TestDispatchT d = {
            .source     =
                "SUB say(s AS STRING)\n  PRINT s\nEND SUB\n"
                "SUB btn1_Click\n"
                "  say \"a\"\n  say \"b\"\n  say \"c\"\n"
                "END SUB\n",
            .formName   = "",
            .ctrlName   = "btn1",
            .ctrlType   = "CommandButton",
            .fires      = DISPATCH_FIRE("Click"),
        };
        testDispatchEq("dispatch-handler-nested-calls", &d, "a\nb\nc\n");
    }

    // Different events on the same control during a single sequence:
    // GotFocus, Click, LostFocus.  Guard lets each event through.
    {
        TestDispatchT d = {
            .source     =
                "SUB onFocus\n  PRINT \"focus\"\nEND SUB\n"
                "SUB onClick\n  PRINT \"click\"\nEND SUB\n"
                "SUB onBlur\n  PRINT \"blur\"\nEND SUB\n",
            .formName   = "",
            .ctrlName   = "btn1",
            .ctrlType   = "CommandButton",
            .overrides  = DISPATCH_OVR(
                "GotFocus",  "onFocus",
                "Click",     "onClick",
                "LostFocus", "onBlur"),
            .fires      = DISPATCH_FIRE("GotFocus", "Click", "LostFocus"),
        };
        testDispatchEq("dispatch-focus-click-blur", &d, "focus\nclick\nblur\n");
    }

    // ============================================================
    // Operator precedence edge cases
    // ============================================================

    TEST_EQ("prec-neg-then-pow",     "PRINT -2 ^ 2\n", "-4 \n"); // -(2^2) = -4
    // DVX ^ is left-associative: 2^3^2 == (2^3)^2 = 64.  QBASIC doc
    // says right-assoc but DVX chose left.  Matches actual behavior.
    TEST_EQ("prec-pow-left-assoc",   "PRINT 2 ^ 3 ^ 2\n", "64 \n");
    TEST_EQ("prec-mul-add",          "PRINT 2 + 3 * 4\n", "14 \n");
    TEST_EQ("prec-eq-chain",         "PRINT 1 + 2 = 3\n", "True \n");
    TEST_EQ("prec-string-and-num",   "PRINT \"n=\" + STR$(1 + 2)\n", "n= 3\n");
    // True QuickBASIC precedence: * / bind tighter than \ which binds tighter
    // than MOD, so the multiplicative operators sit on three distinct levels.
    TEST_EQ("prec-mod-below-mul",    "PRINT 8 MOD 3 * 2\n", "2 \n");  // 8 MOD (3*2)
    TEST_EQ("prec-idiv-below-mul",   "PRINT 10 \\ 2 * 3\n", "1 \n");  // 10 \ (2*3)
    // Unary sign on the exponent operand (QB: 2 ^ -2 == 0.25).
    TEST_EQ("pow-neg-exponent",      "PRINT 2 ^ -2 = 0.25\n", "True \n");

    // ============================================================
    // More string operations
    // ============================================================

    TEST_EQ("trim-both-sides",
        "DIM s AS STRING\ns = \"  hello  \"\n"
        "PRINT \"[\" + LTRIM$(RTRIM$(s)) + \"]\"\n",
        "[hello]\n");
    TEST_EQ("chr-special-chars",
        "PRINT CHR$(9) + \"tab\"\n",
        "\ttab\n");

    // ============================================================
    // LEN edge cases
    // ============================================================

    TEST_EQ("len-unicode-is-bytes",
        "PRINT LEN(\"abc\")\n",
        "3 \n");

    // ============================================================
    // Integer overflow detection
    // ============================================================

    TEST_EQ("int-max-plus-one",
        "PRINT 32767 + 1\n",
        "32768 \n");

    // ============================================================
    // String length tests
    // ============================================================

    TEST_EQ("string-len-after-build",
        "DIM s AS STRING\ns = \"abc\"\ns = s + s\n"
        "PRINT LEN(s)\n",
        "6 \n");

    // ============================================================
    // Array bounds: writing past UBOUND is an error
    // ============================================================

    TEST_RUNTIME_ERROR("array-out-of-bounds-write",
        "DIM a(2) AS INTEGER\na(5) = 99\n",
        "");
    TEST_RUNTIME_ERROR("array-out-of-bounds-read",
        "DIM a(2) AS INTEGER\nPRINT a(5)\n",
        "");
    TEST_RUNTIME_ERROR("array-negative-index-no-lbound",
        "DIM a(2) AS INTEGER\nPRINT a(-1)\n",
        "");

    // ============================================================
    // More UDT tests
    // ============================================================

    TEST_EQ("udt-multiple-instances",
        "TYPE P\n  x AS INTEGER\n  y AS INTEGER\nEND TYPE\n"
        "DIM a AS P\nDIM b AS P\n"
        "a.x = 1\na.y = 2\n"
        "b.x = 10\nb.y = 20\n"
        "PRINT a.x + b.x\nPRINT a.y + b.y\n",
        "11 \n22 \n");

    TEST_EQ("udt-return-from-function-via-byref",
        "TYPE P\n  x AS INTEGER\n  y AS INTEGER\nEND TYPE\n"
        "SUB setPt(pt AS P, xv AS INTEGER, yv AS INTEGER)\n"
        "  pt.x = xv\n  pt.y = yv\n"
        "END SUB\n"
        "DIM a AS P\n"
        "setPt a, 7, 8\n"
        "PRINT a.x\nPRINT a.y\n",
        "7 \n8 \n");

    // ============================================================
    // More FOR/loop edge cases
    // ============================================================

    TEST_EQ("for-zero-to-zero",
        "DIM i AS INTEGER\nFOR i = 0 TO 0\n  PRINT i\nNEXT i\n",
        "0 \n");

    TEST_EQ("for-neg-to-neg",
        "DIM i AS INTEGER\nFOR i = -3 TO -1\n  PRINT i\nNEXT i\n",
        "-3 \n-2 \n-1 \n");

    // ============================================================
    // More SELECT CASE
    // ============================================================

    TEST_EQ("select-case-no-match-no-else",
        "DIM n AS INTEGER\nn = 99\n"
        "SELECT CASE n\n"
        "  CASE 1: PRINT \"one\"\n"
        "  CASE 2: PRINT \"two\"\n"
        "END SELECT\n"
        "PRINT \"done\"\n",
        "done\n");

    // ============================================================
    // Global String and STATIC STRING from event-dispatch path
    // ============================================================

    TEST_SUB_EQ("subcall-global-string",
        "DIM g AS STRING\ng = \"init\"\n"
        "SUB appendX\n"
        "  g = g + \"x\"\n"
        "  PRINT g\n"
        "END SUB\n",
        "appendX",
        "initx\n");

    TEST_SUB_EQ("subcall-static-accumulates",
        "SUB count\n"
        "  STATIC n AS INTEGER\n"
        "  n = n + 1\n"
        "  PRINT n\n"
        "END SUB\n"
        "SUB run3\n"
        "  count\n  count\n  count\n"
        "END SUB\n",
        "run3",
        "1 \n2 \n3 \n");

    // ============================================================
    // FUNCTION returning string works in expressions
    // ============================================================

    TEST_EQ("function-string-in-expr",
        "FUNCTION tag(s AS STRING) AS STRING\n"
        "  tag = \"<\" + s + \">\"\nEND FUNCTION\n"
        "PRINT tag(\"foo\") + tag(\"bar\")\n",
        "<foo><bar>\n");

    // ============================================================
    // Dictionary-like pattern using parallel arrays
    // ============================================================

    TEST_EQ("parallel-arrays",
        "DIM keys(2) AS STRING\nDIM vals(2) AS INTEGER\n"
        "keys(0) = \"a\"\nkeys(1) = \"b\"\nkeys(2) = \"c\"\n"
        "vals(0) = 10\nvals(1) = 20\nvals(2) = 30\n"
        "DIM i AS INTEGER\n"
        "FOR i = 0 TO 2\n"
        "  PRINT keys(i) + \"=\" + STR$(vals(i))\n"
        "NEXT i\n",
        "a= 10\nb= 20\nc= 30\n");

    // ============================================================
    // Recursive algorithms
    // ============================================================

    TEST_EQ("fib-recursive",
        "FUNCTION fib(n AS INTEGER) AS LONG\n"
        "  IF n <= 1 THEN\n    fib = n\n  ELSE\n    fib = fib(n - 1) + fib(n - 2)\n  END IF\n"
        "END FUNCTION\n"
        "PRINT fib(10)\n",
        "55 \n");

    // ============================================================
    // String-valued DIM inside a SUB
    // ============================================================

    TEST_EQ("local-string-dim-in-sub",
        "SUB build\n"
        "  DIM s AS STRING\n"
        "  s = s + \"x\"\n"
        "  s = s + \"y\"\n"
        "  PRINT s\n"
        "END SUB\n"
        "build\n"
        "build\n",
        "xy\nxy\n");

    // ============================================================
    // Boolean short-circuit edge case (no crash on null side)
    // ============================================================

    TEST_EQ("bool-and-normal",
        "DIM a AS INTEGER\nDIM b AS INTEGER\n"
        "a = 5\nb = 10\n"
        "IF a > 0 AND b > 0 THEN PRINT \"both-pos\"\n",
        "both-pos\n");

    // ============================================================
    // IF with compound conditions using NOT / AND / OR
    // ============================================================

    TEST_EQ("if-not-or",
        "DIM x AS INTEGER\nx = 5\n"
        "IF NOT (x < 0 OR x > 100) THEN PRINT \"in-range\"\n",
        "in-range\n");

    // ============================================================
    // Regressions from the b638ca9 runtime review
    // ============================================================

    // TYPE values have value semantics: b = a copies, BYVAL copies.
    TEST_EQ("udt-assign-copies",
        "TYPE T\n  x AS INTEGER\nEND TYPE\n"
        "DIM a AS T\nDIM b AS T\n"
        "a.x = 1\nb = a\nb.x = 2\nPRINT a.x; b.x\n",
        "1 2 \n");

    TEST_EQ("udt-byval-copies",
        "TYPE T\n  x AS INTEGER\nEND TYPE\n"
        "SUB bump(BYVAL t AS T)\n  t.x = 99\nEND SUB\n"
        "DIM a AS T\na.x = 1\nbump a\nPRINT a.x\n",
        "1 \n");

    TEST_EQ("udt-byref-shares",
        "TYPE T\n  x AS INTEGER\nEND TYPE\n"
        "SUB bump(t AS T)\n  t.x = 99\nEND SUB\n"
        "DIM a AS T\na.x = 1\nbump a\nPRINT a.x\n",
        "99 \n");

    TEST_EQ("udt-array-element-copies",
        "TYPE T\n  x AS INTEGER\nEND TYPE\n"
        "DIM arr(2) AS T\nDIM v AS T\n"
        "v.x = 5\narr(1) = v\nv.x = 6\nPRINT arr(1).x\n",
        "5 \n");

    // BYREF array element survives a REDIM/ERASE of that array in the callee.
    TEST_EQ("byref-array-element-redim-in-callee",
        "DIM SHARED a(10) AS INTEGER\n"
        "SUB f(x AS INTEGER)\n  REDIM a(200) AS INTEGER\n  x = 5\n  PRINT x\nEND SUB\n"
        "f a(3)\nPRINT \"ok\"\n",
        "5 \nok\n");

    TEST_EQ("byref-array-element-erase-in-callee",
        "DIM SHARED a(10) AS INTEGER\n"
        "SUB f(x AS INTEGER)\n  ERASE a\n  x = 7\n  PRINT x\nEND SUB\n"
        "f a(3)\nPRINT \"ok\"\n",
        "7 \nok\n");

    TEST_EQ("byref-array-element-writes-back",
        "DIM a(5) AS INTEGER\n"
        "SUB f(x AS INTEGER)\n  x = x + 40\nEND SUB\n"
        "a(2) = 2\nf a(2)\nPRINT a(2)\n",
        "42 \n");

    // More decimal positions than the formatter's work buffer holds: the
    // output is bounded, and the digits that fit are right.
    TEST_EQ("print-using-many-decimals",
        "DIM f AS STRING\nf = \"#.\" + STRING$(300, \"#\")\n"
        "PRINT LEFT$(FORMAT$(1.5, f), 6)\n",
        "1.5000\n");

    // CINT/CLNG use banker's rounding and raise Overflow.
    TEST_EQ("cint-bankers",
        "PRINT CINT(2.5); CINT(3.5); CINT(-2.5)\n",
        "2 4 -2 \n");

    // CDBL keeps double precision; CSNG narrows to single.
    TEST_EQ("cdbl-csng-precision",
        "PRINT CDBL(1) / 3; CSNG(1 / 3)\n",
        "0.333333333333333 0.3333333 \n");

    TEST_RUNTIME_ERROR("cint-overflow",
        "PRINT CINT(40000)\n",
        "Overflow");

    // PRINT / STR$ show up to 15 (DOUBLE) and 7 (SINGLE) significant digits.
    TEST_EQ("print-double-digits",
        "PRINT 1234567.89\nPRINT 3.14159265358979#\n",
        "1234567.89 \n3.14159265358979 \n");

    TEST_EQ("str-matches-print",
        "PRINT STR$(1234567)\nPRINT STR$(-0.5)\n",
        " 1234567\n-0.5\n");

    // Stack exhaustion reaches ON ERROR with a real ERR value.
    TEST_EQ("stack-overflow-err-number",
        "ON ERROR GOTO h\n"
        "SUB rec\n  rec\nEND SUB\n"
        "rec\nEND\n"
        "h:\nPRINT ERR\nEND\n",
        "7 \n");

    // RESUME outside a handler is an error, not a restart from byte 0.
    TEST_RUNTIME_ERROR("resume-without-error",
        "PRINT \"start\"\nRESUME\n",
        "RESUME without error");

    // REDIM keeps the element type and can build TYPE arrays.
    TEST_EQ("redim-string-array",
        "REDIM s$(5)\nPRINT \"[\" + s$(1) + \"]\"\n",
        "[]\n");

    TEST_EQ("redim-udt-array",
        "TYPE P\n  x AS INTEGER\nEND TYPE\n"
        "DIM a(2) AS P\nREDIM a(5) AS P\na(4).x = 3\nPRINT a(4).x\n",
        "3 \n");

    TEST_EQ("redim-preserve-2d",
        "DIM a(1, 1) AS INTEGER\na(1, 0) = 5\na(0, 1) = 7\n"
        "REDIM PRESERVE a(1, 2) AS INTEGER\nPRINT a(1, 0); a(0, 1); a(1, 2)\n",
        "5 7 0 \n");

    // A handler that ends the program has handled the error.
    TEST_EQ("handler-end-clears-error",
        "ON ERROR GOTO h\nERROR 5\nEND\nh:\nPRINT ERR\nEND\n",
        "5 \n");

    // File input: fields, quotes, long lines, BINARY positioning.
    TEST_EQ("file-input-fields",
        "OPEN \"test_suite_io.tmp\" FOR OUTPUT AS #1\n"
        "WRITE #1, \"hello, world\", 42\n"
        "PRINT #1, STRING$(2000, \"x\")\n"
        "CLOSE #1\n"
        "DIM a AS STRING\nDIM n AS INTEGER\nDIM l AS STRING\n"
        "OPEN \"test_suite_io.tmp\" FOR INPUT AS #1\n"
        "INPUT #1, a\nINPUT #1, n\nLINE INPUT #1, l\nCLOSE #1\n"
        "KILL \"test_suite_io.tmp\"\n"
        "PRINT a; n; LEN(l)\n",
        "hello, world42 2000 \n");

    TEST_EQ("file-binary-put-bytes",
        "OPEN \"test_suite_bin.tmp\" FOR BINARY AS #1\n"
        "PUT #1, 3, 200%\nCLOSE #1\n"
        "PRINT FILELEN(\"test_suite_bin.tmp\")\n"
        "KILL \"test_suite_bin.tmp\"\n",
        "4 \n");

    // INPUT # with several targets reads one field per target, in the
    // WRITE # layout (quoted strings, comma separated).
    TEST_EQ("file-input-multi-target",
        "OPEN \"test_suite_io2.tmp\" FOR OUTPUT AS #1\n"
        "WRITE #1, \"a, b\", 7, 2.5, 100000\n"
        "CLOSE #1\n"
        "DIM s AS STRING\nDIM n AS INTEGER\nDIM d AS DOUBLE\nDIM l AS LONG\n"
        "OPEN \"test_suite_io2.tmp\" FOR INPUT AS #1\n"
        "INPUT #1, s, n, d, l\nCLOSE #1\n"
        "KILL \"test_suite_io2.tmp\"\n"
        "PRINT s; n; d; l\n",
        "a, b7 2.5 100000 \n");

    // GET of a STRING in BINARY mode reads LEN(var) raw bytes; a
    // fixed-length string therefore reads its declared width.
    TEST_EQ("file-binary-get-string",
        "OPEN \"test_suite_bin2.tmp\" FOR BINARY AS #1\n"
        "PUT #1, 1, \"HelloWorld\"\nCLOSE #1\n"
        "DIM s AS STRING * 5\nDIM t AS STRING\n"
        "OPEN \"test_suite_bin2.tmp\" FOR BINARY AS #1\n"
        "GET #1, 6, s\nt = SPACE$(3)\nGET #1, 1, t\nCLOSE #1\n"
        "KILL \"test_suite_bin2.tmp\"\n"
        "PRINT s; \"|\"; t; \"|\"; LEN(t)\n",
        "World|Hel|3 \n");

    // OPEN ... LEN= sets the RANDOM record size used by GET/PUT record
    // numbers: record 2 of an 8-byte file starts at byte 8.
    TEST_EQ("file-random-len-record-size",
        "OPEN \"test_suite_rnd.tmp\" FOR RANDOM AS #1 LEN = 8\n"
        "PUT #1, 2, 123456&\nCLOSE #1\n"
        "PRINT FILELEN(\"test_suite_rnd.tmp\")\n"
        "DIM l AS LONG\n"
        "OPEN \"test_suite_rnd.tmp\" FOR RANDOM AS #1 LEN = 8\n"
        "GET #1, 2, l\nCLOSE #1\n"
        "KILL \"test_suite_rnd.tmp\"\n"
        "PRINT l\n",
        "12 \n123456 \n");

    TEST_RUNTIME_ERROR("file-random-bad-len",
        "OPEN \"test_suite_rnd2.tmp\" FOR RANDOM AS #1 LEN = 0\n",
        "Bad record length");

    // LEN/INSTR are LONG; CHR$(0) is a real character.
    TEST_EQ("len-long",
        "DIM s AS STRING\ns = STRING$(60000, \"a\")\nPRINT LEN(s); INSTR(s + \"b\", \"b\")\n",
        "60000 60001 \n");

    TEST_EQ("chr-zero",
        "PRINT LEN(CHR$(0)); LEN(\"a\" + CHR$(0) + \"b\"); INSTR(\"a\" + CHR$(0) + \"b\", \"b\")\n",
        "1 3 3 \n");

    // VAL understands &H/&O and rejects atof-only syntax.
    TEST_EQ("val-hex-oct",
        "PRINT VAL(\"&HFF\"); VAL(\"&O17\"); VAL(\"  12.5e1x\"); VAL(\"inf\")\n",
        "255 15 125 0 \n");

    // \ and MOD round operands; NOT rounds too.
    TEST_EQ("idiv-mod-round",
        "PRINT 11.5 \\ 2; 7.5 MOD 2; NOT 0.6\n",
        "6 0 -2 \n");

    // Domain and argument errors instead of NaN / silent results.
    TEST_RUNTIME_ERROR("sqr-negative", "PRINT SQR(-1)\n", "Illegal function call");
    TEST_RUNTIME_ERROR("left-negative", "PRINT LEFT$(\"abc\", -1)\n", "Illegal function call");
    TEST_RUNTIME_ERROR("string-negative", "PRINT STRING$(-1, \"x\")\n", "Illegal function call");

    TEST_EQ("space-large",
        "PRINT LEN(SPACE$(40000))\n",
        "40000 \n");

    // Debugger pause vs ON ERROR, and the release payload path.
    testBreakpointNotTrapped("breakpoint-not-trapped");
    testReleaseRoundTrip("release-round-trip");

    printf("\n--------------------------\n");
    printf("PASS: %d   FAIL: %d\n", (int)sPassCount, (int)sFailCount);

    return sFailCount == 0 ? 0 : 1;
}
