Draw: implement the BASIC 7.0 graphics verbs
GRAPHIC, COLOR, DRAW, BOX, CIRCLE, PAINT, SCALE, SSHAPE, GSHAPE and LOCATE, all against the akbasic_GraphicsBackend record rather than akgl_draw_* directly, so src/runtime_graphics.c includes no SDL and the whole group is testable in a build with no SDL on the machine. The reference lists every one of these as unimplemented, so the semantics come from Commodore BASIC 7.0 rather than from a port, and four places where a modern renderer cannot do what a C128 did are recorded in TODO.md section 5 rather than silently substituted: - CIRCLE is drawn as a polygon of inc-degree segments and akgl_draw_circle is deliberately unused. 7.0's CIRCLE takes two radii, an arc range and a rotation, so the primitive could serve only the fully-defaulted call, and a shape that changed character depending on whether the radii happened to be equal would be worse than one uniformly a polygon. - SSHAPE puts a SHAPE:<n> handle in the string variable rather than the pixels, because a value's string is a fixed 256 bytes and a region is a device surface. GSHAPE refuses a string without that prefix instead of parsing whatever digits it finds and pasting an unrelated slot. - BOX fills on a negative angle; 7.0 puts the fill flag after the rotation, which would make a filled box a seventh argument. - GRAPHIC stores its mode and honours only the one consequence that means anything here -- mode 0 is text -- while still refusing an out-of-range mode, since that is a typo worth catching. PAINT surfaces the flood fill's AKERR_OUTOFBOUNDS as an error rather than success. The device gives up when its span stack runs out having filled *part* of the region, and a program that cannot tell that happened cannot recover from it. Note the shape of that handler: HANDLE sets handled = true on the context, so a FAIL_RETURN from inside the HANDLE block hands the caller something already marked handled, whose FINISH_LOGIC then declines to pass it up and releases it -- the error disappears and PAINT reports success. Flag inside the block, raise after FINISH. COLOR, LOCATE and SCALE need no device on purpose, so a program can set itself up before a host has lent it a renderer. Adds a second golden corpus under tests/language/. The corpus in deps/basicinterpret is a submodule and nothing here may add files to it, but goal 2's new verbs still need the .bas/.txt half of their coverage. Registered under local_ so a failure names which corpus it came from. What it can cover is limited -- these verbs draw rather than print -- so the behaviour that reaches a device is asserted against tests/mockdevice.h instead. 65/65 ctest, clean under -Wall -Wextra, doxygen clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
287
tests/graphics_verbs.c
Normal file
287
tests/graphics_verbs.c
Normal file
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* @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));
|
||||
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));
|
||||
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));
|
||||
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 320x200 space, and only then. */
|
||||
static void test_scale(void)
|
||||
{
|
||||
/* Off by default: a coordinate passes through untouched. */
|
||||
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();
|
||||
|
||||
/* On, with the 7.0 default maxima: the middle of the user space is the
|
||||
middle of the screen. */
|
||||
TEST_REQUIRE_OK(run_program("10 SCALE 1\n20 DRAW 1, 1023, 1023\n"));
|
||||
TEST_REQUIRE_STR(MOCK.log, "point 320.0,200.0 " WHITE "\n");
|
||||
harness_stop();
|
||||
|
||||
TEST_REQUIRE_OK(run_program("10 SCALE 1, 640, 400\n20 DRAW 1, 320, 200\n"));
|
||||
TEST_REQUIRE_STR(MOCK.log, "point 160.0,100.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 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 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_graphic();
|
||||
test_shapes();
|
||||
test_no_device();
|
||||
return akbasic_test_failures;
|
||||
}
|
||||
6
tests/language/graphics/argument_errors.bas
Normal file
6
tests/language/graphics/argument_errors.bas
Normal file
@@ -0,0 +1,6 @@
|
||||
10 REM A range check reported through the driver, end to end. The program stops
|
||||
20 REM at the first error, so this file covers one; the rest of the checks are
|
||||
30 REM asserted in tests/graphics_verbs.c against the recording backend.
|
||||
40 PRINT "BEFORE"
|
||||
50 SCALE 1, 0, 400
|
||||
60 PRINT "UNREACHABLE"
|
||||
3
tests/language/graphics/argument_errors.txt
Normal file
3
tests/language/graphics/argument_errors.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
BEFORE
|
||||
? 50 : RUNTIME ERROR SCALE maxima must be positive
|
||||
|
||||
8
tests/language/graphics/no_device.bas
Normal file
8
tests/language/graphics/no_device.bas
Normal file
@@ -0,0 +1,8 @@
|
||||
10 REM The standalone driver lends the script no graphics device.
|
||||
20 REM COLOR, LOCATE and SCALE only touch interpreter state and must still work.
|
||||
30 COLOR 1, 3
|
||||
40 LOCATE 40, 50
|
||||
50 SCALE 1, 640, 400
|
||||
60 PRINT "STATE VERBS OK"
|
||||
70 REM DRAW needs the device, and has to say which verb wanted it.
|
||||
80 DRAW 1, 10, 10
|
||||
3
tests/language/graphics/no_device.txt
Normal file
3
tests/language/graphics/no_device.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
STATE VERBS OK
|
||||
? 80 : RUNTIME ERROR DRAW needs a graphics device and this runtime has none
|
||||
|
||||
Reference in New Issue
Block a user