Files
akbasic/tests/value_arithmetic.c
Andrew Kesterson c8b917d205 Write down that the left operand decides integer or float arithmetic
No behaviour change, by decision. `A# * 0.45` is 0 and `0.45 * A#` is 1.35,
because every operator branches on `self->valuetype` and converts the right
operand to match. It is inherited from the Go reference, a C128 promotes to
float instead, and this interpreter is at least consistent about it -- so a
dialect saying the left operand wins is a defensible position, and changing it
to promotion would alter the result of every mixed expression in every existing
program.

**The defect was that nobody said so.** Chapter 3's "Numbers" did not mention
it, Chapter 13 did not list it among the differences, and nothing fails when a
program gets it wrong -- it computes something else and carries on. The game in
examples/ lost its per-level speed increase to `5.6 + LEVEL# * 0.45` evaluating
to a flat 5.6, and bled velocity out of every bounce through `0 - BLVX%(B#)`
quantising to whole pixels. Both read correctly. Neither produced a diagnostic.

Now said in three places: a section in Chapter 3 with the demonstration and the
two rules that keep a program out of it (put the float on the left, put the
answer somewhere with a `%` on it), a row in Chapter 13 naming it as the
difference from 7.0 most likely to turn a working listing into a quietly wrong
one, and the reasoning on value.h where the operators are declared.

tests/value_arithmetic.c pins it in both directions across multiply and
subtract, with a comment saying it is the documented contract rather than an
accident -- so promotion becomes a decision somebody takes deliberately rather
than a change that could slip in under a passing suite.

TODO.md section 9 item 4, struck as a documentation outcome.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 00:23:20 -04:00

368 lines
15 KiB
C

/**
* @file value_arithmetic.c
* @brief Tests the arithmetic operators, including the parts that look wrong.
*
* Where the reference does something odd, the assertion here pins the reference's
* answer, not the tidy one. A "fix" that changes any of these changes observable
* BASIC behaviour and belongs in its own commit with its own TODO.md entry.
*/
#include <string.h>
#include <akbasic/error.h>
#include <akbasic/value.h>
#include "testutil.h"
static akbasic_Value A;
static akbasic_Value B;
static akbasic_Value SCRATCH;
static void set_int(akbasic_Value *v, int64_t n)
{
test_discard_error(akbasic_value_zero(v));
v->valuetype = AKBASIC_TYPE_INTEGER;
v->intval = n;
}
static void set_float(akbasic_Value *v, double n)
{
test_discard_error(akbasic_value_zero(v));
v->valuetype = AKBASIC_TYPE_FLOAT;
v->floatval = n;
}
static void set_string(akbasic_Value *v, const char *s)
{
test_discard_error(akbasic_value_zero(v));
v->valuetype = AKBASIC_TYPE_STRING;
strncpy(v->stringval, s, AKBASIC_MAX_STRING_LENGTH - 1);
}
/**
* @brief A string that exactly fills the value's inline buffer round-trips.
*
* Written because mutation testing said nothing would have noticed if it did
* not: `AKBASIC_MAX_STRING_LENGTH - 1` mutated to `+ 1` and to `- 0` survived at
* both the strncpy and the NUL terminator in set_string(), and no test in the
* suite wrote a maximum-length string. Both mutants are a real bug of that shape
* -- one truncates a string that fits, the other writes one byte past the end of
* a 256-byte buffer -- and the boundary is the only place either shows.
*
* Concatenation rather than a direct set, because set_string() is static and
* concatenation is how every string in a BASIC program actually gets built.
*
* **The two operands are deliberately different lengths and different letters.**
* A first attempt joined a full-length string to an empty one, and the
* truncating mutant survived it: math_plus clones self into the scratch before
* writing, so the byte a short copy failed to write was already the right one.
* The parts have to sum to the limit without either of them being the answer.
*/
static void test_maximum_length_string(void)
{
enum { LIMIT = AKBASIC_MAX_STRING_LENGTH - 1, HEAD = LIMIT - 55, TAIL = 55 };
akbasic_Value *out = NULL;
char head[AKBASIC_MAX_STRING_LENGTH];
char tail[AKBASIC_MAX_STRING_LENGTH];
char joined[AKBASIC_MAX_STRING_LENGTH];
memset(head, 'X', HEAD);
head[HEAD] = '\0';
memset(tail, 'Z', TAIL);
tail[TAIL] = '\0';
/* memcpy rather than snprintf: the lengths are known here and -Wformat-truncation
* cannot see that HEAD + TAIL is exactly the buffer's capacity. */
memcpy(joined, head, HEAD);
memcpy(joined + HEAD, tail, TAIL);
joined[HEAD + TAIL] = '\0';
/* Exactly the limit: every byte lands, and the terminator is where it should be. */
set_string(&A, head);
set_string(&B, tail);
TEST_REQUIRE_OK(akbasic_value_math_plus(&A, &B, &SCRATCH, &out));
TEST_REQUIRE_INT(strlen(out->stringval), LIMIT);
TEST_REQUIRE_STR(out->stringval, joined);
TEST_REQUIRE_INT(out->stringval[LIMIT - 1], 'Z');
TEST_REQUIRE_INT(out->stringval[LIMIT], '\0');
/* One character past it is an error, not a silent truncation. */
set_string(&A, joined);
set_string(&B, "!");
TEST_REQUIRE_STATUS(akbasic_value_math_plus(&A, &B, &SCRATCH, &out), AKBASIC_ERR_VALUE);
/* The same boundary through the repeat operator, which has its own copy. */
memset(head, 'Q', LIMIT / 2);
head[LIMIT / 2] = '\0';
set_string(&A, head);
set_int(&B, 2);
TEST_REQUIRE_OK(akbasic_value_math_multiply(&A, &B, &SCRATCH, &out));
TEST_REQUIRE_INT(strlen(out->stringval), (LIMIT / 2) * 2);
TEST_REQUIRE_INT(out->stringval[((LIMIT / 2) * 2) - 1], 'Q');
}
/**
* @brief A freshly initialised value pool is empty, and its storage is zeroed.
*
* Also a mutation-driven test: `memset(obj, 0, ...)` mutated to `memset(obj, 1,
* ...)`, the memset deleted outright, and `obj->next = 0` mutated to `= 1` all
* survived, because nothing asserted what an initialised pool looks like. The
* deleted-memset mutant is the one that matters -- a pool over stale stack
* memory hands a BASIC program somebody else's array contents.
*/
static void test_pool_starts_empty(void)
{
static akbasic_ValuePool pool;
akbasic_Value *slice = NULL;
int i = 0;
/* Dirty every byte first, so a missing memset cannot pass by luck. */
memset(&pool, 0xAB, sizeof(pool));
TEST_REQUIRE_OK(akbasic_valuepool_init(&pool));
TEST_REQUIRE_INT(pool.next, 0);
for ( i = 0; i < AKBASIC_MAX_ARRAY_VALUES; i++ ) {
if ( pool.values[i].valuetype != 0 || pool.values[i].intval != 0 ||
pool.values[i].stringval[0] != '\0' ) {
TEST_REQUIRE(false, "pool slot %d was not zeroed by init", i);
break;
}
}
/* The first take starts at slot zero and the bump advances by exactly count. */
TEST_REQUIRE_OK(akbasic_valuepool_take(&pool, 4, &slice));
TEST_REQUIRE(slice == &pool.values[0], "the first take must start at slot zero");
TEST_REQUIRE_INT(pool.next, 4);
TEST_REQUIRE_OK(akbasic_valuepool_take(&pool, 1, &slice));
TEST_REQUIRE(slice == &pool.values[4], "the second take must start where the first ended");
TEST_REQUIRE_INT(pool.next, 5);
/* Re-initialising takes it back to empty rather than merely rewinding. */
TEST_REQUIRE_OK(akbasic_valuepool_init(&pool));
TEST_REQUIRE_INT(pool.next, 0);
TEST_REQUIRE_STATUS(akbasic_valuepool_init(NULL), AKERR_NULLPOINTER);
TEST_REQUIRE_STATUS(akbasic_valuepool_take(&pool, 0, &slice), AKBASIC_ERR_BOUNDS);
TEST_REQUIRE_STATUS(akbasic_valuepool_take(&pool, AKBASIC_MAX_ARRAY_VALUES + 1, &slice),
AKBASIC_ERR_BOUNDS);
}
/**
* @brief The left operand alone decides integer or float arithmetic.
*
* **This is the documented contract, not an accident**, which is the whole
* reason it is asserted: every operator branches on `self->valuetype` and
* converts the right operand to match, so an integer on the left truncates a
* float on the right and `A# * 0.45` is 0 where `0.45 * A#` is 1.35.
*
* It is inherited from the Go reference (basicvalue.go:190-193) and a C128
* promotes to float instead. It may well be the wrong choice -- what makes it
* expensive is that **nothing fails**, so a program computes something else and
* carries on; a game written against this interpreter lost a per-level speed
* increase and bled velocity out of every bounce before either was noticed.
*
* Changing it to promotion is a real change of dialect semantics and wants its
* own decision. This test is here so that it has to be one: docs/03 and docs/13
* describe the behaviour below, and a change that makes them wrong fails here
* first. TODO.md section 9 item 4.
*/
static void test_left_operand_decides_the_type(void)
{
akbasic_Value *out = NULL;
/* Integer on the left: the float on the right is truncated to 0. */
set_int(&A, 3);
set_float(&B, 0.45);
TEST_REQUIRE_OK(akbasic_value_math_multiply(&A, &B, &SCRATCH, &out));
TEST_REQUIRE_INT(out->valuetype, AKBASIC_TYPE_INTEGER);
TEST_REQUIRE_INT(out->intval, 0);
/* Float on the left: the integer on the right is widened. */
set_float(&A, 0.45);
set_int(&B, 3);
TEST_REQUIRE_OK(akbasic_value_math_multiply(&A, &B, &SCRATCH, &out));
TEST_REQUIRE_INT(out->valuetype, AKBASIC_TYPE_FLOAT);
TEST_REQUIRE_FEQ(out->floatval, 1.35);
/* The same asymmetry in subtraction, which is how a program negates. */
set_int(&A, 0);
set_float(&B, 6.4);
TEST_REQUIRE_OK(akbasic_value_math_minus(&A, &B, &SCRATCH, &out));
TEST_REQUIRE_INT(out->valuetype, AKBASIC_TYPE_INTEGER);
TEST_REQUIRE_INT(out->intval, -6);
set_float(&A, 0.0);
set_float(&B, 6.4);
TEST_REQUIRE_OK(akbasic_value_math_minus(&A, &B, &SCRATCH, &out));
TEST_REQUIRE_INT(out->valuetype, AKBASIC_TYPE_FLOAT);
TEST_REQUIRE_FEQ(out->floatval, -6.4);
}
int main(void)
{
akbasic_Value *out = NULL;
char rendered[AKBASIC_MAX_STRING_LENGTH];
TEST_REQUIRE_OK(akbasic_error_register());
/* Integer arithmetic. */
set_int(&A, 7);
set_int(&B, 3);
TEST_REQUIRE_OK(akbasic_value_math_plus(&A, &B, &SCRATCH, &out));
TEST_REQUIRE_INT(out->intval, 10);
TEST_REQUIRE_OK(akbasic_value_math_minus(&A, &B, &SCRATCH, &out));
TEST_REQUIRE_INT(out->intval, 4);
TEST_REQUIRE_OK(akbasic_value_math_multiply(&A, &B, &SCRATCH, &out));
TEST_REQUIRE_INT(out->intval, 21);
TEST_REQUIRE_OK(akbasic_value_math_divide(&A, &B, &SCRATCH, &out));
TEST_REQUIRE_INT(out->intval, 2); /* truncating, as C and Go both do */
/* Float arithmetic. */
set_float(&A, 1.20);
set_float(&B, 0.4);
TEST_REQUIRE_OK(akbasic_value_math_divide(&A, &B, &SCRATCH, &out));
TEST_REQUIRE_OK(akbasic_value_to_string(out, rendered, sizeof(rendered)));
/* tests/language/arithmetic/float.txt says 3.000000, not 2.999999. */
TEST_REQUIRE_STR(rendered, "3.000000");
/* String concatenation, including the mixed-type forms. */
set_string(&A, "SORTED IN ");
set_int(&B, 4);
TEST_REQUIRE_OK(akbasic_value_math_plus(&A, &B, &SCRATCH, &out));
TEST_REQUIRE_STR(out->stringval, "SORTED IN 4");
set_string(&A, "X=");
set_float(&B, 2.5);
TEST_REQUIRE_OK(akbasic_value_math_plus(&A, &B, &SCRATCH, &out));
TEST_REQUIRE_STR(out->stringval, "X=2.500000");
/* String multiplication is repetition. */
set_string(&A, "ab");
set_int(&B, 3);
TEST_REQUIRE_OK(akbasic_value_math_multiply(&A, &B, &SCRATCH, &out));
TEST_REQUIRE_STR(out->stringval, "ababab");
/*
* math_plus clones like every other operator, mutable operand or not.
*
* This used to assert the opposite. The reference mutates `self` in place
* when it is mutable, which made `A# + 1` modify `A#` whenever the left
* operand came out of a variable -- TODO.md section 6 item 4. It was left
* alone because NEXT's loop increment relied on the mutation to advance the
* counter; NEXT writes the result back itself now, so the asymmetry is gone
* and this asserts that it stays gone.
*/
set_int(&A, 10);
A.mutable_ = true;
set_int(&B, 5);
TEST_REQUIRE_OK(akbasic_value_math_plus(&A, &B, &SCRATCH, &out));
TEST_REQUIRE(out == &SCRATCH, "math_plus must use the scratch even for a mutable operand");
TEST_REQUIRE_INT(A.intval, 10);
TEST_REQUIRE_INT(SCRATCH.intval, 15);
set_int(&A, 10);
A.mutable_ = false;
TEST_REQUIRE_OK(akbasic_value_math_plus(&A, &B, &SCRATCH, &out));
TEST_REQUIRE(out == &SCRATCH, "math_plus on an immutable value must use the scratch");
TEST_REQUIRE_INT(A.intval, 10);
/*
* The unused numeric field is ignored, not added in.
*
* The reference reads a right-hand operand as `intval + int64(floatval)`,
* which gives the right answer only for as long as whichever field is unused
* happens to be zero -- TODO.md section 6 item 5, filed as a landmine
* because no program can reach it. It is reachable from here: a value is a
* plain struct and nothing stops one carrying both. Without this the fix is
* untested, and the old code passes every other test in the tree.
*/
set_int(&A, 2);
set_int(&B, 5);
B.floatval = 3.0; /* stale, from whatever B held before */
TEST_REQUIRE_OK(akbasic_value_math_plus(&A, &B, &SCRATCH, &out));
TEST_REQUIRE_INT(out->intval, 7);
set_float(&A, 2.0);
set_float(&B, 5.0);
B.intval = 3; /* stale the other way round */
TEST_REQUIRE_OK(akbasic_value_math_plus(&A, &B, &SCRATCH, &out));
TEST_REQUIRE_FEQ(out->floatval, 7.0);
/* And a truth value used as a number is -1, which is what AND/OR rely on. */
set_int(&A, 0);
TEST_REQUIRE_OK(akbasic_value_set_bool(&B, true));
TEST_REQUIRE_OK(akbasic_value_math_plus(&A, &B, &SCRATCH, &out));
TEST_REQUIRE_INT(out->intval, -1);
/* Type errors. */
set_string(&A, "text");
set_int(&B, 1);
TEST_REQUIRE_STATUS(akbasic_value_math_minus(&A, &B, &SCRATCH, &out), AKBASIC_ERR_TYPE);
TEST_REQUIRE_STATUS(akbasic_value_math_divide(&A, &B, &SCRATCH, &out), AKBASIC_ERR_TYPE);
TEST_REQUIRE_STATUS(akbasic_value_invert(&A, &SCRATCH, &out), AKBASIC_ERR_TYPE);
/*
* A left operand the numeric path cannot read is refused, not computed.
*
* `math_minus`, `math_multiply` and `math_divide` were `if ( INTEGER ) ...
* else <treat as float>`, and that else was a catch-all: it read `floatval`
* from whatever it was handed. A truth value keeps its payload in
* `boolvalue` and leaves `floatval` zero, so the operation computed into a
* field nothing reads and left the type alone -- `(A# == 1) - 1` rendered
* `true` rather than -2. Silent, and wrong in both value and type.
*
* `math_plus` never had it, because it enumerates its cases and ends in an
* error. These assert the other three now agree with it.
*/
TEST_REQUIRE_OK(akbasic_value_set_bool(&A, true));
set_int(&B, 1);
TEST_REQUIRE_STATUS(akbasic_value_math_minus(&A, &B, &SCRATCH, &out), AKBASIC_ERR_TYPE);
TEST_REQUIRE_STATUS(akbasic_value_math_multiply(&A, &B, &SCRATCH, &out), AKBASIC_ERR_TYPE);
TEST_REQUIRE_STATUS(akbasic_value_math_divide(&A, &B, &SCRATCH, &out), AKBASIC_ERR_TYPE);
TEST_REQUIRE_STATUS(akbasic_value_math_plus(&A, &B, &SCRATCH, &out), AKBASIC_ERR_TYPE);
/* An undefined value is refused for the same reason and by the same guard. */
TEST_REQUIRE_OK(akbasic_value_zero(&A));
TEST_REQUIRE_STATUS(akbasic_value_math_minus(&A, &B, &SCRATCH, &out), AKBASIC_ERR_TYPE);
TEST_REQUIRE_STATUS(akbasic_value_math_multiply(&A, &B, &SCRATCH, &out), AKBASIC_ERR_TYPE);
TEST_REQUIRE_STATUS(akbasic_value_math_divide(&A, &B, &SCRATCH, &out), AKBASIC_ERR_TYPE);
/*
* But a truth value on the *right* stays legal, and that asymmetry is the
* point: the left operand picks the branch and must be a number, while the
* right is read through rval_as_int(), which handles -1/0 on purpose. It is
* the same property that lets AND and OR double as logical operators, so
* refusing it here would break `5 - (A# == 1)`, which is 6.
*/
set_int(&A, 5);
TEST_REQUIRE_OK(akbasic_value_set_bool(&B, true));
TEST_REQUIRE_OK(akbasic_value_math_minus(&A, &B, &SCRATCH, &out));
TEST_REQUIRE_INT(out->intval, 6);
TEST_REQUIRE_OK(akbasic_value_math_multiply(&A, &B, &SCRATCH, &out));
TEST_REQUIRE_INT(out->intval, -5);
/* And a string repeat is not caught by the numeric guard in front of it. */
set_string(&A, "xy");
set_int(&B, 2);
TEST_REQUIRE_OK(akbasic_value_math_multiply(&A, &B, &SCRATCH, &out));
TEST_REQUIRE_STR(out->stringval, "xyxy");
/* Division by zero raises rather than trapping or panicking. */
set_int(&A, 1);
set_int(&B, 0);
TEST_REQUIRE_STATUS(akbasic_value_math_divide(&A, &B, &SCRATCH, &out), AKBASIC_ERR_VALUE);
/* Unary minus. */
set_int(&A, 42);
TEST_REQUIRE_OK(akbasic_value_invert(&A, &SCRATCH, &out));
TEST_REQUIRE_INT(out->intval, -42);
/* nil rval is rejected everywhere. */
TEST_REQUIRE_STATUS(akbasic_value_math_plus(&A, NULL, &SCRATCH, &out), AKERR_NULLPOINTER);
test_maximum_length_string();
test_pool_starts_empty();
test_left_operand_decides_the_type();
return akbasic_test_failures;
}