Files
akbasic/tests/sink_tee.c
Andrew Kesterson fd1f0d7c19 Forward WINDOW through the tee sink
`akbasic_sink_init_tee()` wired write, writeln, readline, clear and --
conditionally -- moveto, and never `window`. The standalone AKGL frontend runs
the interpreter against a tee, so the fully implemented `sink_window()`
underneath it was unreachable: `WINDOW 0, 0, 20, 4` answered "WINDOW needs a
text device with a character grid, and this one has none" on the one build that
has a character grid. It reads as an oversight rather than a decision, because
`moveto` directly above it is forwarded with reasoning that applies unchanged.

`tee_window()` is that function, modelled on `tee_moveto()`, offered only when a
half can take it so the refusal still reads correctly through a stdio-only pair.

**Writing the test found a second one.** Both optional entry points are assigned
conditionally, and neither initializer cleared them first -- so a caller with a
sink on the stack got whatever was in that memory, and `CHAR` and `WINDOW`
decide whether they can act by testing those pointers for NULL. Both
`akbasic_sink_init_tee()` and `akbasic_sink_init_stdio()` now clear them. An
initializer that leaves a field alone is not an initializer.

tests/sink_tee.c gains a stand-in sink with a grid -- the two stdio halves have
neither entry point, which is exactly why they could not show that either is
forwarded -- and asserts both directions, the arguments arriving intact, and
that a grid-less pair still offers neither.

Verified end to end: `WINDOW 0, 0, 20, 4` then `PRINT` succeeds under
build-akgl/basic and still refuses by name under build/basic.

What this does *not* fix is that a drawing still has to be re-issued every
frame: the frontend never clears and SDL is double-buffered, so a drawing issued
once appears in one buffer only. Shrinking the text area makes the rest of the
window the program's; keeping something there is still the program's job. That
half is documented rather than fixed, and is filed as TODO.md section 9 item 5.

TODO.md section 6 item 31, struck.

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

213 lines
8.0 KiB
C

/**
* @file sink_tee.c
* @brief Tests the composing sink that mirrors output to a second sink.
*
* This is the piece that lets an AKGL build draw BASIC output in a window and
* still put it on stdout, without the interpreter carrying a second write. Two
* stdio sinks over fmemopen buffers stand in for the two halves here -- the
* composition is what is being tested, not what either half does with the bytes,
* and using two of the same kind keeps this file free of SDL.
*/
#include <stdio.h>
#include <string.h>
#include <akbasic/error.h>
#include <akbasic/sink.h>
#include "testutil.h"
static char PRIMARY[1024];
static char MIRROR[1024];
/** @brief A sink whose every entry point fails, for asserting the pair stops. */
static akerr_ErrorContext AKERR_NOIGNORE *broken_write(akbasic_TextSink *self, const char *text)
{
PREPARE_ERROR(errctx);
(void)self;
(void)text;
FAIL_RETURN(errctx, AKERR_IO, "this sink always fails");
}
static akerr_ErrorContext AKERR_NOIGNORE *broken_clear(akbasic_TextSink *self)
{
PREPARE_ERROR(errctx);
(void)self;
FAIL_RETURN(errctx, AKERR_IO, "this sink always fails");
}
/*
* A half that has a character grid. The two stdio sinks above have neither
* `moveto` nor `window`, which is exactly why they cannot show that either one
* is forwarded -- so this stands in for the akgl sink, without the SDL this file
* deliberately does not link.
*/
static int MOVETOS = 0;
static int WINDOWS = 0;
static int WINDOW_ARGS[4] = { 0, 0, 0, 0 };
static akerr_ErrorContext AKERR_NOIGNORE *counting_moveto(akbasic_TextSink *self, int col, int row)
{
PREPARE_ERROR(errctx);
(void)self; (void)col; (void)row;
MOVETOS += 1;
SUCCEED_RETURN(errctx);
}
static akerr_ErrorContext AKERR_NOIGNORE *counting_window(akbasic_TextSink *self, int left, int top, int right, int bottom)
{
PREPARE_ERROR(errctx);
(void)self;
WINDOWS += 1;
WINDOW_ARGS[0] = left;
WINDOW_ARGS[1] = top;
WINDOW_ARGS[2] = right;
WINDOW_ARGS[3] = bottom;
SUCCEED_RETURN(errctx);
}
int main(void)
{
akbasic_TextSink primary;
akbasic_StdioSink primarystate;
akbasic_TextSink mirror;
akbasic_StdioSink mirrorstate;
akbasic_TextSink broken;
akbasic_TextSink gridded;
akbasic_TextSink tee;
akbasic_TeeSink teestate;
FILE *primaryout = NULL;
FILE *mirrorout = NULL;
FILE *in = NULL;
char line[64];
bool eof = false;
TEST_REQUIRE_OK(akbasic_error_register());
memset(PRIMARY, 0, sizeof(PRIMARY));
memset(MIRROR, 0, sizeof(MIRROR));
primaryout = fmemopen(PRIMARY, sizeof(PRIMARY), "w");
mirrorout = fmemopen(MIRROR, sizeof(MIRROR), "w");
in = fmemopen((void *)(uintptr_t)"typed\n", 6, "r");
TEST_REQUIRE(primaryout != NULL && mirrorout != NULL && in != NULL,
"could not open the test buffers");
setvbuf(primaryout, NULL, _IONBF, 0);
setvbuf(mirrorout, NULL, _IONBF, 0);
TEST_REQUIRE_OK(akbasic_sink_init_stdio(&primary, &primarystate, primaryout, in));
TEST_REQUIRE_OK(akbasic_sink_init_stdio(&mirror, &mirrorstate, mirrorout, NULL));
/* A reader that is neither half is a wiring mistake, and is refused. */
TEST_REQUIRE_STATUS(akbasic_sink_init_tee(&tee, &teestate, &primary, &mirror, &broken),
AKBASIC_ERR_VALUE);
TEST_REQUIRE_STATUS(akbasic_sink_init_tee(&tee, &teestate, &primary, NULL, NULL),
AKERR_NULLPOINTER);
TEST_REQUIRE_STATUS(akbasic_sink_init_tee(NULL, &teestate, &primary, &mirror, NULL),
AKERR_NULLPOINTER);
TEST_REQUIRE_OK(akbasic_sink_init_tee(&tee, &teestate, &primary, &mirror, &primary));
/* Every write reaches both halves, byte for byte, including the newline. */
TEST_REQUIRE_OK(tee.write(&tee, "AB"));
TEST_REQUIRE_OK(tee.writeln(&tee, "CD"));
TEST_REQUIRE_STR(PRIMARY, "ABCD\n");
TEST_REQUIRE_STR(MIRROR, "ABCD\n");
/* The error-line double newline survives the composition unchanged. */
TEST_REQUIRE_OK(tee.writeln(&tee, "? 20 : RUNTIME ERROR something\n"));
TEST_REQUIRE_STR(MIRROR, "ABCD\n? 20 : RUNTIME ERROR something\n\n");
/* clear reaches both; the stdio half has no screen, so it just succeeds. */
TEST_REQUIRE_OK(tee.clear(&tee));
/* readline comes from the designated reader only. */
TEST_REQUIRE_OK(tee.readline(&tee, line, sizeof(line), &eof));
TEST_REQUIRE(!eof, "the reader had a line to give");
TEST_REQUIRE_STR(line, "typed");
TEST_REQUIRE_OK(tee.readline(&tee, line, sizeof(line), &eof));
TEST_REQUIRE(eof, "running off the reader's end must set eof, not raise");
/* With no reader at all, readline reports end of input rather than raising. */
TEST_REQUIRE_OK(akbasic_sink_init_tee(&tee, &teestate, &primary, &mirror, NULL));
eof = false;
TEST_REQUIRE_OK(tee.readline(&tee, line, sizeof(line), &eof));
TEST_REQUIRE(eof, "a tee with no reader reports EOF");
TEST_REQUIRE_STR(line, "");
TEST_REQUIRE_STATUS(tee.readline(&tee, NULL, sizeof(line), &eof), AKERR_NULLPOINTER);
/*
* `moveto` and `window` are offered only when a half can do them, so that a
* `CHAR` or a `WINDOW` against a stdio-only pair still refuses by name
* rather than appearing to work. Two stdio halves have neither.
*/
TEST_REQUIRE_OK(akbasic_sink_init_tee(&tee, &teestate, &primary, &mirror, NULL));
TEST_REQUIRE(tee.moveto == NULL, "a tee over two grid-less sinks must not offer moveto");
TEST_REQUIRE(tee.window == NULL, "a tee over two grid-less sinks must not offer window");
/*
* And when a half *does* have them, they are forwarded. `window` used to be
* the one entry point this function never wired, which made WINDOW
* unreachable in the only build that has a character grid: the standalone
* frontend runs the interpreter against a tee, so the akgl sink's fully
* implemented window() answered nothing and the verb refused with "needs a
* text device with a character grid". TODO.md section 6 item 31.
*/
gridded.self = &gridded;
gridded.write = broken_write;
gridded.writeln = broken_write;
gridded.readline = NULL;
gridded.clear = broken_clear;
gridded.moveto = counting_moveto;
gridded.window = counting_window;
MOVETOS = 0;
WINDOWS = 0;
TEST_REQUIRE_OK(akbasic_sink_init_tee(&tee, &teestate, &primary, &gridded, NULL));
TEST_REQUIRE(tee.moveto != NULL, "a tee with a gridded half must offer moveto");
TEST_REQUIRE(tee.window != NULL, "a tee with a gridded half must offer window");
TEST_REQUIRE_OK(tee.moveto(&tee, 3, 4));
TEST_REQUIRE_OK(tee.window(&tee, 0, 20, 39, 24));
TEST_REQUIRE_INT(MOVETOS, 1);
TEST_REQUIRE_INT(WINDOWS, 1);
TEST_REQUIRE_INT(WINDOW_ARGS[0], 0);
TEST_REQUIRE_INT(WINDOW_ARGS[1], 20);
TEST_REQUIRE_INT(WINDOW_ARGS[2], 39);
TEST_REQUIRE_INT(WINDOW_ARGS[3], 24);
/* The gridded half being first is wired the same way round. */
WINDOWS = 0;
TEST_REQUIRE_OK(akbasic_sink_init_tee(&tee, &teestate, &gridded, &primary, NULL));
TEST_REQUIRE(tee.window != NULL, "the gridded half may be either one");
TEST_REQUIRE_OK(tee.window(&tee, 1, 2, 3, 4));
TEST_REQUIRE_INT(WINDOWS, 1);
/*
* A failing half stops the pair rather than being swallowed. A half-written
* line is worse than a refused one: the golden corpus compares whole files.
*/
broken.self = &broken;
broken.write = broken_write;
broken.writeln = broken_write;
broken.readline = NULL;
broken.clear = broken_clear;
TEST_REQUIRE_OK(akbasic_sink_init_tee(&tee, &teestate, &primary, &broken, NULL));
TEST_REQUIRE_STATUS(tee.write(&tee, "X"), AKERR_IO);
TEST_REQUIRE_STATUS(tee.writeln(&tee, "X"), AKERR_IO);
TEST_REQUIRE_STATUS(tee.clear(&tee), AKERR_IO);
TEST_REQUIRE_OK(akbasic_sink_init_tee(&tee, &teestate, &broken, &primary, NULL));
TEST_REQUIRE_STATUS(tee.write(&tee, "X"), AKERR_IO);
/* A sink whose state pointer was never set is caught rather than dereferenced. */
tee.self = NULL;
TEST_REQUIRE_STATUS(tee.write(&tee, "X"), AKERR_NULLPOINTER);
fclose(primaryout);
fclose(mirrorout);
fclose(in);
return akbasic_test_failures;
}