The graphics verbs documented a coordinate transform that did not exist. With SCALE off a coordinate went straight to akgl_draw_* as a pixel address, so an 800x600 window drew a C128 listing into its corner and left the rest unused -- while the chapter said coordinates were 320x200 and stretching to fit was the host's business. akbasic_GraphicsBackend gains a size entry point, require_graphics() asks it before every verb that draws so a resized window is honoured between two statements, and 320x200 becomes the fallback for a backend that leaves it NULL. It is the record's one optional member, so a host written against the old header keeps the behaviour it had. SCALE now maps onto the device, and RGR(1)/RGR(2) report the drawing surface so a program can use a window whose size it did not choose. RGR(0) is BASIC 7.0's own field, the GRAPHIC mode. SCALE also mapped xmax onto the width rather than onto the last pixel, so SCALE 1, 319, 199 followed by DRAW 1, 319, 199 drew nothing at all -- one pixel past the surface. Fixed in the same line, because it is what makes "SCALE gives a C128 listing the whole window" true rather than nearly true. The akgl test renders against a 128x128 target, deliberately smaller than the old constants: a SCALE still dividing by them misses it entirely rather than landing somewhere plausible. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
385 lines
16 KiB
C
385 lines
16 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(3)\n"));
|
|
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "outside 0..2") != NULL,
|
|
"RGR(3) should be refused, 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 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_no_device();
|
|
return akbasic_test_failures;
|
|
}
|