Files
akbasic/tests/graphics_verbs.c
Andrew Kesterson 8594d8471d Honour a subscript in SSHAPE and GSHAPE
`shape_variable()` took the identifier off the leaf and looked the variable up
without ever evaluating the subscript, and both verbs then addressed element
zero with a literal. So `SSHAPE SH$(2), ...` wrote the handle into `SH$(0)` and
`GSHAPE SH$(2)` stamped whatever was in `SH$(0)`.

Ordinary assignment and `PRINT` honour the subscript, which is what made this
expensive: a program keeping several saved shapes in an array got every one of
them resolving to the same element, silently, and the only symptom was that
every stamp came out as the last shape captured. The Breakout in examples/ keeps
its six brick stamps in six separate scalars for exactly this reason.

`SPRSAV` was the counter-example and is the model -- it evaluates its argument
and handles an array element correctly. The subscript resolution itself is now
shared: `collect_subscripts()` comes out of src/environment.c as
`akbasic_environment_collect_subscripts()`, so a verb taking a variable by name
resolves a subscript the same way assignment does rather than each verb deciding
for itself.

tests/graphics_verbs.c covers TODO.md's reduction -- which used to print
"[SHAPE:0] []" and now prints "[] [SHAPE:0]" -- and the case a program actually
wants: two shapes captured into two elements, each stamped back through its own,
asserted against the device log so a fix that merely made the strings look right
would not pass.

Chapter 18's trap 4 becomes history, and Chapter 6 says an array works.

TODO.md section 9 item 6, struck.

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

450 lines
18 KiB
C

/**
* @file graphics_verbs.c
* @brief Tests the group G verbs against the recording mock backend.
*
* These verbs emit nothing a golden file can compare, so the assertions are on
* the call log: what reached the device, in what order, with what geometry and
* what color. Running a real BASIC line rather than calling the handler directly
* is deliberate -- it exercises the dispatch-table row and the parse handler as
* well, which is where an added verb is most likely to be wrong.
*/
#include <string.h>
#include <akbasic/error.h>
#include <akbasic/graphics.h>
#include <akbasic/runtime.h>
#include "harness.h"
#include "mockdevice.h"
#include "testutil.h"
/** @brief Bring up a runtime with the mock devices attached and a program loaded. */
static akerr_ErrorContext AKERR_NOIGNORE *run_program(const char *source)
{
PREPARE_ERROR(errctx);
PASS(errctx, harness_start(NULL));
mock_devices_init();
PASS(errctx, akbasic_runtime_set_devices(&HARNESS_RUNTIME, &MOCK_GRAPHICS,
&MOCK_AUDIO, &MOCK_INPUT, NULL));
PASS(errctx, akbasic_runtime_load(&HARNESS_RUNTIME, source));
PASS(errctx, akbasic_runtime_start(&HARNESS_RUNTIME, AKBASIC_MODE_RUN));
PASS(errctx, akbasic_runtime_run(&HARNESS_RUNTIME, 0));
SUCCEED_RETURN(errctx);
}
/** @brief The white of palette index 2, which most of these draw with. */
#define WHITE "#ffffff"
/** @brief The red of palette index 3. */
#define RED "#883932"
/**
* @brief A single DRAW coordinate pair plots a point; two or more draw a
* polyline; none at all plots wherever LOCATE left the cursor.
*/
static void test_draw(void)
{
TEST_REQUIRE_OK(run_program("10 DRAW 1, 10, 20\n"));
TEST_REQUIRE_STR(MOCK.log, "point 10.0,20.0 " WHITE "\n");
harness_stop();
TEST_REQUIRE_OK(run_program("10 DRAW 1, 0, 0 TO 10, 0 TO 10, 10\n"));
TEST_REQUIRE_STR(MOCK.log,
"line 0.0,0.0-10.0,0.0 " WHITE "\n"
"line 10.0,0.0-10.0,10.0 " WHITE "\n");
harness_stop();
/* LOCATE moves the pixel cursor; a bare DRAW plots it. */
TEST_REQUIRE_OK(run_program("10 LOCATE 33, 44\n20 DRAW 1\n"));
TEST_REQUIRE_STR(MOCK.log, "point 33.0,44.0 " WHITE "\n");
harness_stop();
/* An odd number of coordinates is a typo, not half a line. */
TEST_REQUIRE_OK(harness_start(NULL));
mock_devices_init();
TEST_REQUIRE_OK(akbasic_runtime_set_devices(&HARNESS_RUNTIME, &MOCK_GRAPHICS, NULL, NULL, NULL));
TEST_REQUIRE_OK(akbasic_runtime_load(&HARNESS_RUNTIME, "10 DRAW 1, 5\n"));
TEST_REQUIRE_OK(akbasic_runtime_start(&HARNESS_RUNTIME, AKBASIC_MODE_RUN));
TEST_REQUIRE_OK(akbasic_runtime_run(&HARNESS_RUNTIME, 0));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "X,Y pairs") != NULL,
"an odd coordinate count should be refused, got \"%s\"", HARNESS_OUTPUT);
harness_stop();
}
/** @brief COLOR rebinds a source, and every drawing verb follows the rebinding. */
static void test_color(void)
{
TEST_REQUIRE_OK(run_program("10 COLOR 1, 3\n20 DRAW 1, 1, 1\n"));
TEST_REQUIRE_STR(MOCK.log, "point 1.0,1.0 " RED "\n");
harness_stop();
/* Both arguments are range-checked, and BASIC counts colors from 1. */
TEST_REQUIRE_OK(run_program("10 COLOR 9, 3\n"));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "COLOR source 9") != NULL,
"an out-of-range source should be refused, got \"%s\"", HARNESS_OUTPUT);
harness_stop();
TEST_REQUIRE_OK(run_program("10 COLOR 1, 0\n"));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "COLOR 0") != NULL,
"color 0 should be refused, got \"%s\"", HARNESS_OUTPUT);
harness_stop();
}
/** @brief An unrotated BOX is a rectangle; a rotated one is four lines. */
static void test_box(void)
{
TEST_REQUIRE_OK(run_program("10 BOX 1, 0, 0, 10, 10\n"));
TEST_REQUIRE_STR(MOCK.log, "rect 0.0,0.0-10.0,10.0 " WHITE "\n");
harness_stop();
/*
* Rotated 90 degrees about its own center, a square lands back on itself --
* same four edges, walked from a different starting corner. That is the
* assertion worth making, because it pins the rotation *origin*: rotating
* about the coordinate origin instead of the box's center would put the
* square somewhere else entirely, and rotating about a corner would tilt it.
*
* The -0.0 is real and harmless: cos(pi/2) is not exactly zero, so one
* coordinate lands on negative zero, which compares equal to zero everywhere
* it matters and only shows up because the mock prints it.
*/
TEST_REQUIRE_OK(run_program("10 BOX 1, 0, 0, 10, 10, 90\n"));
TEST_REQUIRE_STR(MOCK.log,
"line 10.0,-0.0-10.0,10.0 " WHITE "\n"
"line 10.0,10.0-0.0,10.0 " WHITE "\n"
"line 0.0,10.0-0.0,0.0 " WHITE "\n"
"line 0.0,0.0-10.0,-0.0 " WHITE "\n");
harness_stop();
}
/** @brief CIRCLE is a polygon of arc-increment segments, and closes. */
static void test_circle(void)
{
int segments = 0;
const char *cursor = NULL;
/* 360 degrees at 90 per segment is four lines, whatever the radius. */
TEST_REQUIRE_OK(run_program("10 CIRCLE 1, 50, 50, 10, 10, 0, 360, 0, 90\n"));
for ( cursor = MOCK.log; (cursor = strstr(cursor, "line ")) != NULL; cursor += 5 ) {
segments += 1;
}
TEST_REQUIRE_INT(segments, 4);
/* The first vertex is directly above the center, as BASIC 7.0 starts it. */
TEST_REQUIRE(strncmp(MOCK.log, "line 50.0,40.0-", 15) == 0,
"CIRCLE should start at the top of the circle, got \"%.30s\"", MOCK.log);
harness_stop();
/* A negative radius and a zero increment are both refusals, not loops. */
TEST_REQUIRE_OK(run_program("10 CIRCLE 1, 50, 50, -5\n"));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "radius") != NULL,
"a negative radius should be refused, got \"%s\"", HARNESS_OUTPUT);
harness_stop();
TEST_REQUIRE_OK(run_program("10 CIRCLE 1, 50, 50, 10, 10, 0, 360, 0, 0\n"));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "increment") != NULL,
"a zero arc increment should be refused, got \"%s\"", HARNESS_OUTPUT);
harness_stop();
}
/**
* @brief PAINT reports a partial fill rather than letting it pass as success.
*
* The device gives up when its span stack runs out, having filled some of the
* region. A program that cannot tell that happened has no way to recover, so the
* error is the feature.
*/
static void test_paint(void)
{
TEST_REQUIRE_OK(run_program("10 PAINT 1, 5, 5\n"));
TEST_REQUIRE_STR(MOCK.log, "paint 5,5 " WHITE "\n");
harness_stop();
TEST_REQUIRE_OK(harness_start(NULL));
mock_devices_init();
MOCK.paint_exhausts = true;
TEST_REQUIRE_OK(akbasic_runtime_set_devices(&HARNESS_RUNTIME, &MOCK_GRAPHICS, NULL, NULL, NULL));
TEST_REQUIRE_OK(akbasic_runtime_load(&HARNESS_RUNTIME, "10 PAINT 1, 5, 5\n"));
TEST_REQUIRE_OK(akbasic_runtime_start(&HARNESS_RUNTIME, AKBASIC_MODE_RUN));
TEST_REQUIRE_OK(akbasic_runtime_run(&HARNESS_RUNTIME, 0));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "only part of the region") != NULL,
"a partial fill should be reported, got \"%s\"", HARNESS_OUTPUT);
harness_stop();
}
/**
* @brief SCALE maps user coordinates onto the device's own size, and only then.
*
* The mock reports 640x400, which is what every scaled expectation below is
* against. If the interpreter ever stops asking the device and goes back to the
* 320x200 constants, every one of them is out by a factor of two -- which is
* the reason the mock's size is not 320x200.
*/
static void test_scale(void)
{
/* Off by default: a coordinate is a device pixel and passes through
untouched, which is what puts the whole of the host's window in reach. */
TEST_REQUIRE_OK(run_program("10 DRAW 1, 160, 100\n"));
TEST_REQUIRE_STR(MOCK.log, "point 160.0,100.0 " WHITE "\n");
harness_stop();
/* A coordinate past the old 320x200 ceiling is not clamped, refused, or
wrapped. This is item 9 in one assertion. */
TEST_REQUIRE_OK(run_program("10 DRAW 1, 639, 399\n"));
TEST_REQUIRE_STR(MOCK.log, "point 639.0,399.0 " WHITE "\n");
harness_stop();
/* On, with the 7.0 default maxima: the far corner of the user space is the
far corner of the *device*, not of a 320x200 screen inside it -- and it
is the last pixel, 639, rather than one past it. */
TEST_REQUIRE_OK(run_program("10 SCALE 1\n20 DRAW 1, 1023, 1023\n"));
TEST_REQUIRE_STR(MOCK.log, "point 639.0,399.0 " WHITE "\n");
harness_stop();
/* And a C128 listing fills the window by declaring the space it was
written for, which is the migration path for every one of them. */
TEST_REQUIRE_OK(run_program("10 SCALE 1, 319, 199\n20 DRAW 1, 319, 199\n"));
TEST_REQUIRE_STR(MOCK.log, "point 639.0,399.0 " WHITE "\n");
harness_stop();
TEST_REQUIRE_OK(run_program("10 SCALE 1, 0, 400\n"));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "positive") != NULL,
"a zero SCALE maximum should be refused, got \"%s\"", HARNESS_OUTPUT);
harness_stop();
}
/**
* @brief A backend that will not say how big it is gets the 320x200 fallback.
*
* `size` is the record's one optional entry point, so a host that filled a
* backend in before it existed has to keep working -- and what it gets is the
* space a C128 listing was written for rather than a division by zero.
*/
static void test_scale_without_a_size(void)
{
TEST_REQUIRE_OK(harness_start(NULL));
mock_devices_init();
MOCK_GRAPHICS.size = NULL;
TEST_REQUIRE_OK(akbasic_runtime_set_devices(&HARNESS_RUNTIME, &MOCK_GRAPHICS, NULL, NULL, NULL));
TEST_REQUIRE_OK(akbasic_runtime_load(&HARNESS_RUNTIME, "10 SCALE 1\n20 DRAW 1, 1023, 1023\n"));
TEST_REQUIRE_OK(akbasic_runtime_start(&HARNESS_RUNTIME, AKBASIC_MODE_RUN));
TEST_REQUIRE_OK(akbasic_runtime_run(&HARNESS_RUNTIME, 0));
TEST_REQUIRE_STR(MOCK.log, "point 319.0,199.0 " WHITE "\n");
harness_stop();
/*
* A device reporting nothing useful -- an SDL window mid-resize measures
* zero -- is not an error and must not become the space, because dividing
* by it would send every scaled coordinate to infinity.
*/
TEST_REQUIRE_OK(harness_start(NULL));
mock_devices_init();
MOCK.devwidth = 0;
MOCK.devheight = 0;
TEST_REQUIRE_OK(akbasic_runtime_set_devices(&HARNESS_RUNTIME, &MOCK_GRAPHICS, NULL, NULL, NULL));
TEST_REQUIRE_OK(akbasic_runtime_load(&HARNESS_RUNTIME, "10 SCALE 1\n20 DRAW 1, 1023, 1023\n"));
TEST_REQUIRE_OK(akbasic_runtime_start(&HARNESS_RUNTIME, AKBASIC_MODE_RUN));
TEST_REQUIRE_OK(akbasic_runtime_run(&HARNESS_RUNTIME, 0));
TEST_REQUIRE_STR(MOCK.log, "point 319.0,199.0 " WHITE "\n");
harness_stop();
}
/** @brief RGR answers the mode, the width and the height, and refuses the rest. */
static void test_rgr(void)
{
TEST_REQUIRE_OK(run_program("10 GRAPHIC 3\n20 PRINT RGR(0)\n"));
TEST_REQUIRE_STR(HARNESS_OUTPUT, "3\n");
harness_stop();
TEST_REQUIRE_OK(run_program("10 PRINT RGR(1)\n20 PRINT RGR(2)\n"));
TEST_REQUIRE_STR(HARNESS_OUTPUT, "640\n400\n");
harness_stop();
/* The whole point of asking: a program can draw to the far corner without
knowing in advance what size window the host opened. */
TEST_REQUIRE_OK(run_program("10 DRAW 1, RGR(1) - 1, RGR(2) - 1\n"));
TEST_REQUIRE_STR(MOCK.log, "point 639.0,399.0 " WHITE "\n");
harness_stop();
TEST_REQUIRE_OK(run_program("10 PRINT RGR(5)\n"));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "outside 0..4") != NULL,
"RGR(5) should be refused, got \"%s\"", HARNESS_OUTPUT);
harness_stop();
/*
* 3 and 4 are the character cell, and they come from the *sink* rather than
* the graphics device -- a grid belongs to a text device. The harness sink
* is stdio and has none, so what a device-less build can assert is the
* refusal; tests/console_verbs.c drives the answers through a stand-in sink
* that does have a grid.
*/
TEST_REQUIRE_OK(run_program("10 PRINT RGR(3)\n"));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "text device with a character grid") != NULL,
"RGR(3) against a grid-less sink should refuse by name, got \"%s\"",
HARNESS_OUTPUT);
harness_stop();
TEST_REQUIRE_OK(run_program("10 PRINT RGR(4)\n"));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "text device with a character grid") != NULL,
"RGR(4) against a grid-less sink should refuse by name, got \"%s\"",
HARNESS_OUTPUT);
harness_stop();
/*
* The mode is state and answers with no device; a size is not, and asking
* for one when there is no screen is a program bug worth reporting.
*/
TEST_REQUIRE_OK(harness_start(NULL));
TEST_REQUIRE_OK(akbasic_runtime_load(&HARNESS_RUNTIME, "10 PRINT RGR(0)\n"));
TEST_REQUIRE_OK(akbasic_runtime_start(&HARNESS_RUNTIME, AKBASIC_MODE_RUN));
TEST_REQUIRE_OK(akbasic_runtime_run(&HARNESS_RUNTIME, 0));
TEST_REQUIRE_STR(HARNESS_OUTPUT, "0\n");
harness_stop();
TEST_REQUIRE_OK(harness_start(NULL));
TEST_REQUIRE_OK(akbasic_runtime_load(&HARNESS_RUNTIME, "10 PRINT RGR(1)\n"));
TEST_REQUIRE_OK(akbasic_runtime_start(&HARNESS_RUNTIME, AKBASIC_MODE_RUN));
TEST_REQUIRE_OK(akbasic_runtime_run(&HARNESS_RUNTIME, 0));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "RGR needs a graphics device") != NULL,
"RGR(1) with no device should refuse, got \"%s\"", HARNESS_OUTPUT);
harness_stop();
}
/** @brief GRAPHIC records the mode, clears on request and refuses a bad mode. */
static void test_graphic(void)
{
TEST_REQUIRE_OK(run_program("10 GRAPHIC 1, 1\n"));
TEST_REQUIRE_STR(MOCK.log, "clear #000000\n");
TEST_REQUIRE_INT(HARNESS_RUNTIME.gfx.mode, 1);
harness_stop();
/* Without the clear argument nothing reaches the device. */
TEST_REQUIRE_OK(run_program("10 GRAPHIC 1\n"));
TEST_REQUIRE_STR(MOCK.log, "");
harness_stop();
/* GRAPHIC CLR drops the saved shapes and resets the whole state. */
TEST_REQUIRE_OK(run_program("10 COLOR 1, 3\n20 GRAPHIC 5\n"));
TEST_REQUIRE_STR(MOCK.log, "freeshapes\n");
TEST_REQUIRE_INT(HARNESS_RUNTIME.gfx.source[1], 2);
harness_stop();
TEST_REQUIRE_OK(run_program("10 GRAPHIC 9\n"));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "GRAPHIC mode 9") != NULL,
"an out-of-range mode should be refused, got \"%s\"", HARNESS_OUTPUT);
harness_stop();
}
/** @brief SSHAPE stores a handle a later GSHAPE can use, and only that. */
static void test_shapes(void)
{
TEST_REQUIRE_OK(run_program("10 SSHAPE A$, 0, 0, 8, 8\n20 GSHAPE A$, 32, 32\n"));
TEST_REQUIRE_STR(MOCK.log, "save 0,0-8,8 -> 0\npaste 0 at 32.0,32.0\n");
harness_stop();
/*
* The string is opaque but not magic: a string that did not come from SSHAPE
* has to be refused, or GSHAPE parses whatever digits it finds and pastes
* some unrelated slot.
*/
TEST_REQUIRE_OK(run_program("10 A$ = \"5\"\n20 GSHAPE A$, 0, 0\n"));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "did not come from SSHAPE") != NULL,
"a hand-written shape string should be refused, got \"%s\"", HARNESS_OUTPUT);
harness_stop();
/* A numeric variable is the wrong type for either verb. */
TEST_REQUIRE_OK(run_program("10 SSHAPE A#, 0, 0, 8, 8\n"));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "string variable") != NULL,
"SSHAPE into a number should be refused, got \"%s\"", HARNESS_OUTPUT);
harness_stop();
}
/**
* @brief Both verbs honour a subscript on a string array.
*
* They used to ignore it. `shape_variable()` took the identifier and looked the
* variable up without evaluating the subscript, and both verbs then addressed
* element zero with a literal -- so `SSHAPE SH$(2), ...` wrote the handle into
* `SH$(0)` and `GSHAPE SH$(2)` stamped whatever was in `SH$(0)`.
*
* Ordinary assignment and `PRINT` honour the subscript, which is what made it
* expensive: a program keeping several saved shapes in an array got every one of
* them resolving to the same element, silently, and the only symptom was that
* every stamp came out as the last shape captured. The Breakout in `examples/`
* keeps its six brick stamps in six separate scalars because of this. TODO.md
* section 9 item 6.
*/
static void test_shape_subscripts(void)
{
TEST_REQUIRE_OK(run_program("10 DIM SH$(4)\n"
"20 SSHAPE SH$(2), 0, 0, 8, 8\n"
"30 PRINT \"[\" + SH$(0) + \"] [\" + SH$(2) + \"]\"\n"));
/* The reduction from TODO.md, which used to print "[SHAPE:0] []". */
TEST_REQUIRE_STR(HARNESS_OUTPUT, "[] [SHAPE:0]\n");
harness_stop();
/*
* Two shapes in two elements, which is the case a program actually wants and
* the one that came out wrong: the second capture used to overwrite the
* first, so both elements named the same handle.
*/
TEST_REQUIRE_OK(run_program("10 DIM SH$(4)\n"
"20 SSHAPE SH$(1), 0, 0, 8, 8\n"
"30 SSHAPE SH$(2), 0, 0, 4, 4\n"
"40 PRINT \"[\" + SH$(1) + \"] [\" + SH$(2) + \"]\"\n"
"50 GSHAPE SH$(2), 10, 10\n"
"60 GSHAPE SH$(1), 20, 20\n"));
TEST_REQUIRE_STR(HARNESS_OUTPUT, "[SHAPE:0] [SHAPE:1]\n");
/* And each GSHAPE stamps the handle its own element names. */
TEST_REQUIRE_STR(MOCK.log,
"save 0,0-8,8 -> 0\n"
"save 0,0-4,4 -> 1\n"
"paste 1 at 10.0,10.0\n"
"paste 0 at 20.0,20.0\n");
harness_stop();
}
/**
* @brief Every verb that needs a device says so by name when it has none.
*
* This is the standalone driver's situation, so it is the common path. COLOR,
* LOCATE and SCALE are excluded on purpose: they only touch interpreter state
* and must keep working before a host has lent the script a renderer.
*/
static void test_no_device(void)
{
TEST_REQUIRE_OK(harness_start(NULL));
TEST_REQUIRE_OK(akbasic_runtime_load(&HARNESS_RUNTIME,
"10 COLOR 1, 3\n"
"20 LOCATE 5, 5\n"
"30 SCALE 1\n"
"40 DRAW 1, 1, 1\n"));
TEST_REQUIRE_OK(akbasic_runtime_start(&HARNESS_RUNTIME, AKBASIC_MODE_RUN));
TEST_REQUIRE_OK(akbasic_runtime_run(&HARNESS_RUNTIME, 0));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "DRAW needs a graphics device") != NULL,
"DRAW without a device should name itself, got \"%s\"", HARNESS_OUTPUT);
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "COLOR") == NULL,
"COLOR should not need a device, got \"%s\"", HARNESS_OUTPUT);
TEST_REQUIRE_INT(HARNESS_RUNTIME.gfx.source[1], 3);
TEST_REQUIRE_FEQ(HARNESS_RUNTIME.gfx.x, 5.0);
TEST_REQUIRE(HARNESS_RUNTIME.gfx.scaling, "SCALE should not need a device");
harness_stop();
}
int main(void)
{
test_draw();
test_color();
test_box();
test_circle();
test_paint();
test_scale();
test_scale_without_a_size();
test_rgr();
test_graphic();
test_shapes();
test_shape_subscripts();
test_no_device();
return akbasic_test_failures;
}