Files
akbasic/tests/hoststruct.c
Logikoma 21f360ed1d
All checks were successful
akbasic CI Build / cmake_build (push) Successful in 3m43s
akbasic CI Build / sanitizers (push) Successful in 5m6s
akbasic CI Build / coverage (push) Successful in 4m14s
akbasic CI Build / akgl_build (push) Successful in 8m11s
akbasic CI Build / mutation_test (push) Successful in 23m0s
Reject overlong host type and field names
Co-authored-by: Andrew Kesterson <andrew@starfort.tech>
2026-08-06 12:09:08 -04:00

333 lines
12 KiB
C

/**
* @file hoststruct.c
* @brief Tests sharing a host C struct with a script.
*
* The assertions that matter are the ones about *conversion*, because that is
* the boundary where the two type systems disagree and where a silent answer
* would be worst. A range check that quietly wraps is found three frames later
* in code that did nothing wrong, so every refusal is asserted individually.
*
* The sharing itself is asserted in both directions: what the script sees when
* the host changes its struct underneath, and what the host sees when the script
* writes.
*/
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <string.h>
#include <akbasic/error.h>
#include <akbasic/host.h>
#include <akbasic/runtime.h>
#include "harness.h"
#include "testutil.h"
typedef struct
{
char name[8];
int8_t tiny;
int16_t small;
int32_t hp;
uint8_t level;
float x;
double weight;
bool hostile;
} test_Enemy;
static const akbasic_HostField ENEMY_FIELDS[] = {
/* struct member BASIC name C representation */
AKBASIC_HOST_FIELD( test_Enemy, name, "NAME$", AKBASIC_HOSTFIELD_CSTRING ),
AKBASIC_HOST_FIELD( test_Enemy, tiny, "TINY#", AKBASIC_HOSTFIELD_INT8 ),
AKBASIC_HOST_FIELD( test_Enemy, small, "SMALL#", AKBASIC_HOSTFIELD_INT16 ),
AKBASIC_HOST_FIELD( test_Enemy, hp, "HP#", AKBASIC_HOSTFIELD_INT32 ),
AKBASIC_HOST_FIELD( test_Enemy, level, "LEVEL#", AKBASIC_HOSTFIELD_UINT8 ),
AKBASIC_HOST_FIELD( test_Enemy, x, "X%", AKBASIC_HOSTFIELD_FLOAT ),
AKBASIC_HOST_FIELD( test_Enemy, weight, "WEIGHT%", AKBASIC_HOSTFIELD_DOUBLE ),
AKBASIC_HOST_FIELD( test_Enemy, hostile, "HOSTILE#", AKBASIC_HOSTFIELD_BOOL )
};
static const akbasic_HostType ENEMY_TYPE = {
"ENEMY", sizeof(test_Enemy), ENEMY_FIELDS, 8
};
static test_Enemy ENEMY;
/** @brief A fresh runtime with ENEMY registered and bound to FOE@. */
static akerr_ErrorContext AKERR_NOIGNORE *bind_and_run(const char *program)
{
PREPARE_ERROR(errctx);
memset(&ENEMY, 0, sizeof(ENEMY));
snprintf(ENEMY.name, sizeof(ENEMY.name), "GOBLIN");
ENEMY.tiny = 1;
ENEMY.small = 2;
ENEMY.hp = 30;
ENEMY.level = 3;
ENEMY.x = 1.5f;
ENEMY.weight = 2.25;
ENEMY.hostile = true;
PASS(errctx, harness_start(NULL));
PASS(errctx, akbasic_host_register_type(&HARNESS_RUNTIME, &ENEMY_TYPE));
PASS(errctx, akbasic_host_bind(&HARNESS_RUNTIME, "FOE@", "ENEMY", &ENEMY));
PASS(errctx, akbasic_runtime_load(&HARNESS_RUNTIME, program));
PASS(errctx, akbasic_runtime_start(&HARNESS_RUNTIME, AKBASIC_MODE_RUN));
PASS(errctx, akbasic_runtime_run(&HARNESS_RUNTIME, 4000));
SUCCEED_RETURN(errctx);
}
/** @brief Every C representation reaches the script as the right BASIC value. */
static void test_reads(void)
{
TEST_REQUIRE_OK(bind_and_run("10 PRINT FOE@.NAME$\n"
"20 PRINT FOE@.TINY#\n"
"30 PRINT FOE@.SMALL#\n"
"40 PRINT FOE@.HP#\n"
"50 PRINT FOE@.LEVEL#\n"
"60 PRINT FOE@.WEIGHT%\n"
"70 PRINT FOE@.HOSTILE#\n"));
/* A bool reads as -1, the Commodore convention every condition already uses. */
TEST_REQUIRE_STR(HARNESS_OUTPUT, "GOBLIN\n1\n2\n30\n3\n2.250000\n-1\n");
harness_stop();
}
/** @brief A write reaches the host's own struct, with no step in between. */
static void test_writes_reach_the_host(void)
{
TEST_REQUIRE_OK(bind_and_run("10 FOE@.HP# = FOE@.HP# - 10\n"
"20 FOE@.NAME$ = \"ORC\"\n"
"30 FOE@.HOSTILE# = 0\n"
"40 FOE@.WEIGHT% = 9.5\n"));
TEST_REQUIRE_INT(ENEMY.hp, 20);
TEST_REQUIRE_STR(ENEMY.name, "ORC");
TEST_REQUIRE(ENEMY.hostile == false, "a zero should have cleared the host's bool");
TEST_REQUIRE_FEQ(ENEMY.weight, 9.5);
harness_stop();
}
/**
* @brief The script sees what the host holds *now*, not a snapshot.
*
* The half of "shared" that a copy-in binding would get wrong: the game moves
* its own data between statements and the script has to see it.
*/
static void test_host_changes_are_visible(void)
{
TEST_REQUIRE_OK(bind_and_run("10 PRINT FOE@.HP#\n"));
TEST_REQUIRE_STR(HARNESS_OUTPUT, "30\n");
harness_stop();
/* Change it behind the script's back, then run again. */
TEST_REQUIRE_OK(harness_start(NULL));
memset(&ENEMY, 0, sizeof(ENEMY));
ENEMY.hp = 30;
TEST_REQUIRE_OK(akbasic_host_register_type(&HARNESS_RUNTIME, &ENEMY_TYPE));
TEST_REQUIRE_OK(akbasic_host_bind(&HARNESS_RUNTIME, "FOE@", "ENEMY", &ENEMY));
TEST_REQUIRE_OK(akbasic_runtime_load(&HARNESS_RUNTIME, "10 PRINT FOE@.HP#\n"));
ENEMY.hp = 77;
TEST_REQUIRE_OK(akbasic_runtime_start(&HARNESS_RUNTIME, AKBASIC_MODE_RUN));
TEST_REQUIRE_OK(akbasic_runtime_run(&HARNESS_RUNTIME, 4000));
TEST_REQUIRE_STR(HARNESS_OUTPUT, "77\n");
harness_stop();
}
/**
* @brief A value that will not fit is refused, by field, rather than wrapped.
*
* One case per width, because a range check is exactly the sort of thing that is
* right for `int32_t` and wrong for `int8_t` when only one of them is tested.
*/
static void test_conversion_refuses_rather_than_truncates(void)
{
static const char *CASES[] = {
"10 FOE@.TINY# = 200\n", /* int8_t */
"10 FOE@.SMALL# = 70000\n", /* int16_t */
"10 FOE@.LEVEL# = -1\n", /* uint8_t */
"10 FOE@.NAME$ = \"MUCH TOO LONG\"\n" /* char[8] */
};
size_t i = 0;
for ( i = 0; i < sizeof(CASES) / sizeof(CASES[0]); i++ ) {
TEST_REQUIRE_OK(bind_and_run(CASES[i]));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "does not fit") != NULL,
"case %zu should be refused rather than truncated, got \"%s\"",
i, HARNESS_OUTPUT);
harness_stop();
}
/* And the host's struct is untouched by a refused write. */
TEST_REQUIRE_OK(bind_and_run("10 FOE@.TINY# = 200\n"));
TEST_REQUIRE_INT(ENEMY.tiny, 1);
harness_stop();
}
/**
* @brief Copy still copies, and POINT still shares -- across the boundary too.
*
* The reason there is one API rather than two: the language's own distinction
* turns out to be exactly the one a host needs, so which one a script used is
* visible in the listing.
*/
static void test_copy_versus_point(void)
{
TEST_REQUIRE_OK(bind_and_run("10 TYPE MINE\n"
"20 HP#\n"
"30 END TYPE\n"
"40 DIM SNAP@ AS MINE\n"
"50 SNAP@.HP# = FOE@.HP#\n"
"60 SNAP@.HP# = 0\n"
"70 PRINT FOE@.HP#\n"));
/* The snapshot moved; the game did not. */
TEST_REQUIRE_STR(HARNESS_OUTPUT, "30\n");
TEST_REQUIRE_INT(ENEMY.hp, 30);
harness_stop();
TEST_REQUIRE_OK(bind_and_run("10 DIM LIVE@ AS PTR TO ENEMY\n"
"20 POINT LIVE@ AT FOE@\n"
"30 LIVE@->HP# = 5\n"));
TEST_REQUIRE_INT(ENEMY.hp, 5);
harness_stop();
}
/** @brief A suffix that disagrees with the C type is refused at registration. */
static void test_suffix_must_match_the_c_type(void)
{
static const akbasic_HostField WRONG[] = {
AKBASIC_HOST_FIELD( test_Enemy, hp, "HP%", AKBASIC_HOSTFIELD_INT32 )
};
static const akbasic_HostType WRONG_TYPE = { "WRONGT", sizeof(test_Enemy), WRONG, 1 };
akerr_ErrorContext *raised = NULL;
TEST_REQUIRE_OK(harness_start(NULL));
raised = akbasic_host_register_type(&HARNESS_RUNTIME, &WRONG_TYPE);
TEST_REQUIRE(raised != NULL, "a float suffix over an int32_t must be refused");
TEST_REQUIRE_INT(raised->status, AKBASIC_ERR_VALUE);
test_discard_error(raised);
harness_stop();
}
/** @brief Host names use the same bounded storage as script-declared names. */
static void test_registration_name_limits(void)
{
static const akbasic_HostField SHORT_FIELD[] = {
AKBASIC_HOST_FIELD( test_Enemy, hp, "ABCDEFGHIJKLMNOPQRSTUVWXYZ1234#",
AKBASIC_HOSTFIELD_INT32 )
};
static const akbasic_HostField LONG_FIELD_A[] = {
AKBASIC_HOST_FIELD( test_Enemy, hp, "ABCDEFGHIJKLMNOPQRSTUVWXYZ123456A#",
AKBASIC_HOSTFIELD_INT32 )
};
static const akbasic_HostField LONG_FIELD_B[] = {
AKBASIC_HOST_FIELD( test_Enemy, hp, "ABCDEFGHIJKLMNOPQRSTUVWXYZ123456B#",
AKBASIC_HOSTFIELD_INT32 )
};
static const akbasic_HostType SHORT_TYPE = {
"ABCDEFGHIJKLMNOPQRSTUVWXYZ12345", sizeof(test_Enemy), SHORT_FIELD, 1
};
static const akbasic_HostType LONG_TYPE_A = {
"ABCDEFGHIJKLMNOPQRSTUVWXYZ123456A", sizeof(test_Enemy), SHORT_FIELD, 1
};
static const akbasic_HostType LONG_TYPE_B = {
"ABCDEFGHIJKLMNOPQRSTUVWXYZ123456B", sizeof(test_Enemy), SHORT_FIELD, 1
};
static const akbasic_HostType LONG_FIELD_TYPE_A = {
"LONGFIELDA", sizeof(test_Enemy), LONG_FIELD_A, 1
};
static const akbasic_HostType LONG_FIELD_TYPE_B = {
"LONGFIELDB", sizeof(test_Enemy), LONG_FIELD_B, 1
};
akerr_ErrorContext *raised = NULL;
TEST_REQUIRE_OK(harness_start(NULL));
TEST_REQUIRE_OK(akbasic_host_register_type(&HARNESS_RUNTIME, &SHORT_TYPE));
harness_stop();
TEST_REQUIRE_OK(harness_start(NULL));
raised = akbasic_host_register_type(&HARNESS_RUNTIME, &LONG_TYPE_A);
TEST_REQUIRE(raised != NULL, "an overlong type name must be refused");
TEST_REQUIRE_INT(raised->status, AKERR_OUTOFBOUNDS);
test_discard_error(raised);
raised = akbasic_host_register_type(&HARNESS_RUNTIME, &LONG_TYPE_B);
TEST_REQUIRE(raised != NULL, "a second long type name must fail cleanly");
TEST_REQUIRE_INT(raised->status, AKERR_OUTOFBOUNDS);
test_discard_error(raised);
harness_stop();
TEST_REQUIRE_OK(harness_start(NULL));
TEST_REQUIRE_OK(akbasic_host_register_type(&HARNESS_RUNTIME,
&(akbasic_HostType){
"SHORTFIELDS", sizeof(test_Enemy), SHORT_FIELD, 1
}));
harness_stop();
TEST_REQUIRE_OK(harness_start(NULL));
raised = akbasic_host_register_type(&HARNESS_RUNTIME, &LONG_FIELD_TYPE_A);
TEST_REQUIRE(raised != NULL, "an overlong field name must be refused");
TEST_REQUIRE_INT(raised->status, AKERR_OUTOFBOUNDS);
test_discard_error(raised);
raised = akbasic_host_register_type(&HARNESS_RUNTIME, &LONG_FIELD_TYPE_B);
TEST_REQUIRE(raised != NULL, "a second long field name must fail cleanly");
TEST_REQUIRE_INT(raised->status, AKERR_OUTOFBOUNDS);
test_discard_error(raised);
harness_stop();
}
/**
* @brief After unbinding, the name is refused rather than read.
*
* The whole reason the entry point exists: a binding is the only pointer this
* interpreter holds that it did not allocate, so a host has to be able to take
* it back before the storage goes.
*/
static void test_unbind_refuses_later_reads(void)
{
TEST_REQUIRE_OK(harness_start(NULL));
memset(&ENEMY, 0, sizeof(ENEMY));
ENEMY.hp = 30;
TEST_REQUIRE_OK(akbasic_host_register_type(&HARNESS_RUNTIME, &ENEMY_TYPE));
TEST_REQUIRE_OK(akbasic_host_bind(&HARNESS_RUNTIME, "FOE@", "ENEMY", &ENEMY));
TEST_REQUIRE_OK(akbasic_host_unbind(&HARNESS_RUNTIME, "FOE@"));
TEST_REQUIRE_OK(akbasic_runtime_load(&HARNESS_RUNTIME, "10 PRINT FOE@.HP#\n"));
TEST_REQUIRE_OK(akbasic_runtime_start(&HARNESS_RUNTIME, AKBASIC_MODE_RUN));
TEST_REQUIRE_OK(akbasic_runtime_run(&HARNESS_RUNTIME, 4000));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "ERROR") != NULL,
"reading an unbound name should be refused, got \"%s\"", HARNESS_OUTPUT);
harness_stop();
}
/** @brief A binding survives the prescan, which runs again on every RUN. */
static void test_registration_survives_a_rerun(void)
{
TEST_REQUIRE_OK(bind_and_run("10 PRINT FOE@.HP#\n"));
TEST_REQUIRE_STR(HARNESS_OUTPUT, "30\n");
TEST_REQUIRE_OK(akbasic_runtime_start(&HARNESS_RUNTIME, AKBASIC_MODE_RUN));
TEST_REQUIRE_OK(akbasic_runtime_run(&HARNESS_RUNTIME, 4000));
/*
* The prescan clears what the *script* declared and keeps what the *host*
* registered. Wiping the table outright unregistered the host's types the
* first time a script ran, with no way for the host to know it had to
* register them again.
*/
TEST_REQUIRE_STR(HARNESS_OUTPUT, "30\n30\n");
harness_stop();
}
int main(void)
{
TEST_REQUIRE_OK(akbasic_error_register());
test_reads();
test_writes_reach_the_host();
test_host_changes_are_visible();
test_conversion_refuses_rather_than_truncates();
test_copy_versus_point();
test_suffix_must_match_the_c_type();
test_registration_name_limits();
test_unbind_refuses_later_reads();
test_registration_survives_a_rerun();
return akbasic_test_failures;
}