Files
akbasic/tests/disk_verbs.c
Tachikoma a15e172c2a Take libakerror 2.0.2, libakstdlib and libakgl 0.9.0
Bumps all three ak* submodules to their current main, applies what their
upgrade notes require, and retires the two workarounds they make obsolete.

libakerror 2.0.1 -> 2.0.2 (63 commits). Two of its fixes were this
repository's own filed issues, and both workarounds are gone:

  - It namespaces its embedded `coverage` target now, the same
    CMAKE_SOURCE_DIR test it already applied to `mutation` (its issue #15).
    The add_custom_target() shadow that renamed it on the way past could
    only ever fire on a name the dependency has stopped using, so it is
    deleted rather than left as dead code.
  - It installs akerrorConfigVersion.cmake at SameMajorVersion (its issue
    #16). MAINTENANCE.md said to add a `1.0` floor to our find_dependency
    calls when this landed; the floor is now `2.0`, and we have no
    find_dependency calls to add it to, so the paragraph says that instead
    of an instruction nobody can follow.

Its IGNORE context also changed shape: `__akerr_last_ignored` was an extern
pointer, and is now a per-translation-unit `static akerr_last_ignored` holding
a copy, so the pool slot can be released. Nothing here referenced the symbol,
but it costs us 1.35 MiB of thread-local storage -- 38 TUs x 37,296 bytes,
measured as the entire TLS segment of build/basic, where 2.0.1 produced no TLS
segment at all -- plus 84 -Wunused-variable warnings. Filed upstream as
libakerror issue #37 and recorded in MAINTENANCE.md rather than patched here,
because patching a submodule forks it.

libakstdlib gains directory and file-metadata wrappers with no version bump.
aksl_snprintf keeps its `int *count` -- an intermediate commit removed it and
the merge put it back -- but now reports the required length on truncation
rather than 0. Every call site here reads it only after a successful return,
so nothing moved.

The directory wrappers close the gap DIRECTORY was refused for (libakstdlib
issue #10). The verb is still unwritten, so it still refuses, but it no longer
blames a wrapper that exists: the message is "DIRECTORY is not implemented
yet" and tests/disk_verbs.c asserts both that it says so and that it does not
name libakstdlib. What writing it would need is akbasic issue #55.

libakgl moves to the current main at 0.9.0. It registers libccd and tg as
submodules, so a tree that only ran `git submodule update --init --recursive`
before the bump needs it again or the configure fails on a missing
libccd/src/ccd/config.h.cmake.in.

Verified: 114/114 default, 116/116 under -DAKBASIC_WITH_AKGL=ON, docs_examples
green in both. libakerror's UPGRADING.md documents a 2.0.3 that project()
never stamped, so the version tables read 2.0.2 -- libakerror issue #38.

Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
Co-Authored-By: Claude Code (Claude Opus 5, claude-opus-5[1m]) <noreply@anthropic.com>
2026-08-05 23:26:15 -04:00

334 lines
11 KiB
C

/**
* @file disk_verbs.c
* @brief Tests the group F verbs: channels, files, and the ones that are refused.
*
* These touch the filesystem, so they work in a temporary directory of their own
* and clean up after themselves. What is asserted is the seam between BASIC and
* `aksl_f*` -- that a verb reaches the right call with the right arguments --
* plus, for the five that need a real 1541, that they refuse *by name* rather
* than doing something plausible and wrong.
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <akbasic/error.h>
#include <akbasic/runtime.h>
#include "harness.h"
#include "testutil.h"
/** @brief Run a program to completion in RUN mode, from a string. */
static akerr_ErrorContext AKERR_NOIGNORE *run_program(const char *source)
{
PREPARE_ERROR(errctx);
PASS(errctx, harness_start(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 Read a whole file, for checking what a verb wrote. */
static bool file_contents(const char *name, char *dest, size_t len)
{
FILE *fp = fopen(name, "rb");
size_t got = 0;
if ( fp == NULL ) {
return false;
}
got = fread(dest, 1, len - 1, fp);
dest[got] = '\0';
fclose(fp);
return true;
}
/** @brief A channel written and then read back gives the same lines. */
static void test_channel_round_trip(void)
{
char contents[256];
remove("t_chan.txt");
TEST_REQUIRE_OK(run_program("10 DOPEN 1, \"t_chan.txt\", W\n"
"20 PRINT #1, \"FIRST\"\n"
"30 PRINT #1, \"SECOND\"\n"
"40 DCLOSE 1\n"
"50 DOPEN 2, \"t_chan.txt\"\n"
"60 INPUT #2, A$\n"
"70 INPUT #2, B$\n"
"80 DCLOSE 2\n"
"90 PRINT A$\n"
"100 PRINT B$\n"));
TEST_REQUIRE_STR(HARNESS_OUTPUT, "FIRST\nSECOND\n");
TEST_REQUIRE(file_contents("t_chan.txt", contents, sizeof(contents)),
"the file should exist");
TEST_REQUIRE_STR(contents, "FIRST\nSECOND\n");
harness_stop();
remove("t_chan.txt");
}
/** @brief A numeric variable reads a numeric line, and end of file gives zero. */
static void test_channel_numeric(void)
{
remove("t_num.txt");
TEST_REQUIRE_OK(run_program("10 DOPEN 1, \"t_num.txt\", W\n"
"20 PRINT #1, 42\n"
"30 DCLOSE 1\n"
"40 DOPEN 2, \"t_num.txt\"\n"
"50 INPUT #2, A#\n"
"60 INPUT #2, B#\n"
"70 DCLOSE 2\n"
"80 PRINT A#\n"
"90 PRINT B#\n"));
TEST_REQUIRE_STR(HARNESS_OUTPUT, "42\n0\n");
harness_stop();
remove("t_num.txt");
}
/** @brief APPEND adds to a file rather than replacing it. */
static void test_append(void)
{
char contents[256];
remove("t_app.txt");
TEST_REQUIRE_OK(run_program("10 DOPEN 1, \"t_app.txt\", W\n"
"20 PRINT #1, \"ONE\"\n"
"30 DCLOSE 1\n"
"40 APPEND 1, \"t_app.txt\"\n"
"50 PRINT #1, \"TWO\"\n"
"60 DCLOSE 1\n"));
TEST_REQUIRE(file_contents("t_app.txt", contents, sizeof(contents)),
"the file should exist");
TEST_REQUIRE_STR(contents, "ONE\nTWO\n");
harness_stop();
remove("t_app.txt");
}
/** @brief Reading a channel opened for writing is refused, and the reverse too. */
static void test_channel_direction(void)
{
remove("t_dir.txt");
TEST_REQUIRE_OK(run_program("10 DOPEN 1, \"t_dir.txt\", W\n"
"20 INPUT #1, A$\n"));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "opened for writing") != NULL,
"reading a write channel should be refused, got \"%s\"", HARNESS_OUTPUT);
harness_stop();
TEST_REQUIRE_OK(run_program("10 DOPEN 1, \"t_dir.txt\"\n"
"20 PRINT #1, \"NO\"\n"));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "opened for reading") != NULL,
"writing a read channel should be refused, got \"%s\"", HARNESS_OUTPUT);
harness_stop();
remove("t_dir.txt");
}
/** @brief An unopened or already-open channel is reported rather than silently reused. */
static void test_channel_state(void)
{
TEST_REQUIRE_OK(run_program("10 PRINT #3, \"NOWHERE\"\n"));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "is not open") != NULL,
"an unopened channel should be refused, got \"%s\"", HARNESS_OUTPUT);
harness_stop();
remove("t_two.txt");
TEST_REQUIRE_OK(run_program("10 DOPEN 1, \"t_two.txt\", W\n"
"20 DOPEN 1, \"t_two.txt\", W\n"));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "already open") != NULL,
"re-opening a channel should be refused, got \"%s\"", HARNESS_OUTPUT);
harness_stop();
remove("t_two.txt");
TEST_REQUIRE_OK(run_program("10 DOPEN 99, \"x\"\n"));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "outside 0..9") != NULL,
"channel 99 should be refused, got \"%s\"", HARNESS_OUTPUT);
harness_stop();
}
/** @brief COPY duplicates a file and CONCAT appends one to another. */
static void test_copy_and_concat(void)
{
char contents[256];
remove("t_a.txt");
remove("t_b.txt");
TEST_REQUIRE_OK(run_program("10 DOPEN 1, \"t_a.txt\", W\n"
"20 PRINT #1, \"AAA\"\n"
"30 DCLOSE 1\n"
"40 COPY \"t_a.txt\", \"t_b.txt\"\n"
"50 CONCAT \"t_a.txt\", \"t_b.txt\"\n"));
TEST_REQUIRE(file_contents("t_b.txt", contents, sizeof(contents)),
"the copy should exist");
TEST_REQUIRE_STR(contents, "AAA\nAAA\n");
harness_stop();
remove("t_a.txt");
remove("t_b.txt");
}
/** @brief RENAME moves a file and SCRATCH deletes one. */
static void test_rename_and_scratch(void)
{
char contents[256];
remove("t_from.txt");
remove("t_to.txt");
TEST_REQUIRE_OK(run_program("10 DOPEN 1, \"t_from.txt\", W\n"
"20 PRINT #1, \"MOVED\"\n"
"30 DCLOSE 1\n"
"40 RENAME \"t_from.txt\", \"t_to.txt\"\n"));
TEST_REQUIRE(!file_contents("t_from.txt", contents, sizeof(contents)),
"the original should be gone");
TEST_REQUIRE(file_contents("t_to.txt", contents, sizeof(contents)),
"the new name should exist");
harness_stop();
TEST_REQUIRE_OK(run_program("10 SCRATCH \"t_to.txt\"\n"));
TEST_REQUIRE(!file_contents("t_to.txt", contents, sizeof(contents)),
"SCRATCH should have deleted it");
harness_stop();
/* Deleting something that is not there is reported rather than ignored. */
TEST_REQUIRE_OK(run_program("10 SCRATCH \"t_nosuch.txt\"\n"));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "could not delete") != NULL,
"deleting a missing file should be reported, got \"%s\"", HARNESS_OUTPUT);
harness_stop();
}
/** @brief BSAVE writes a memory range and BLOAD reads one back. */
static void test_bsave_bload(void)
{
remove("t_bin.dat");
TEST_REQUIRE_OK(run_program("10 A$ = \"BINARY\"\n"
"20 BSAVE \"t_bin.dat\", POINTER(A$), POINTER(A$) + 7\n"
"30 B$ = \".......\"\n"
"40 BLOAD \"t_bin.dat\", POINTER(B$), 7\n"
"50 PRINT B$\n"));
TEST_REQUIRE_STR(HARNESS_OUTPUT, "BINARY\n");
harness_stop();
remove("t_bin.dat");
/* BLOAD needs a length, unlike a C128's; see TODO.md section 5. */
TEST_REQUIRE_OK(run_program("10 A$ = \"X\"\n20 BLOAD \"t_bin.dat\", POINTER(A$)\n"));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "maximum length") != NULL,
"BLOAD without a length should be refused, got \"%s\"", HARNESS_OUTPUT);
harness_stop();
}
/**
* @brief The five verbs that need a real drive refuse by name, with the reason.
*
* The point is that they do not do something plausible instead. `HEADER` on a
* filesystem would have to mean "delete everything here", which is a
* spectacularly bad thing to do to somebody who typed a C128 verb.
*/
static void test_no_drive_verbs(void)
{
struct { const char *program; const char *expect; } cases[] = {
{ "10 HEADER \"DISK\"\n", "formats a disk" },
{ "10 COLLECT\n", "block allocation map" },
{ "10 BACKUP\n", "duplicates one disk" },
{ "10 BOOT\n", "boot sector" },
};
size_t i = 0;
for ( i = 0; i < sizeof(cases) / sizeof(cases[0]); i++ ) {
TEST_REQUIRE_OK(run_program(cases[i].program));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, cases[i].expect) != NULL,
"expected \"%s\" in the refusal, got \"%s\"",
cases[i].expect, HARNESS_OUTPUT);
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "no disk drive") != NULL,
"the refusal should say why, got \"%s\"", HARNESS_OUTPUT);
harness_stop();
}
/*
* DIRECTORY is refused for a different reason than the five above: not for
* want of a drive, but because it is unwritten. It used to name the missing
* libakstdlib wrapper; that wrapper landed, so naming it would be a lie.
*/
TEST_REQUIRE_OK(run_program("10 DIRECTORY\n"));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "not implemented") != NULL,
"DIRECTORY should say it is unwritten, got \"%s\"", HARNESS_OUTPUT);
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "libakstdlib") == NULL,
"DIRECTORY must not still blame libakstdlib, got \"%s\"", HARNESS_OUTPUT);
harness_stop();
}
/** @brief DCLEAR closes every channel, which is the half of it that means something. */
static void test_dclear(void)
{
remove("t_clear.txt");
TEST_REQUIRE_OK(run_program("10 DOPEN 1, \"t_clear.txt\", W\n"
"20 DCLEAR\n"
"30 PRINT #1, \"NO\"\n"));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "is not open") != NULL,
"DCLEAR should have closed the channel, got \"%s\"", HARNESS_OUTPUT);
harness_stop();
remove("t_clear.txt");
}
/**
* @brief DLOAD from a prompt loads the whole program and nothing else.
*
* It used to lose the last line. DLOAD scans the file through the runtime's own
* scanner, so by the time it returns, the tokens of the line that ran it are
* gone and `hadlinenumber` and `environment->lineno` describe the last line of
* the *file*. The REPL then carried on parsing the buffer it thought was still
* its own, decided the leftovers were program text because hadlinenumber was
* now true, and filed the `DLOAD` command itself under that line number --
* silently replacing the last line of every program loaded from a prompt.
*
* Found while making the examples in docs/ execute: a save/load round trip is
* the second thing chapter 2 shows a reader.
*/
static akerr_ErrorContext AKERR_NOIGNORE *test_dload_keeps_the_last_line(void)
{
PREPARE_ERROR(errctx);
remove("t_round.bas");
TEST_REQUIRE_OK(harness_start("10 PRINT \"FIRST\"\n"
"20 PRINT \"SECOND\"\n"
"30 PRINT \"THIRD\"\n"
"DSAVE \"t_round.bas\"\n"
"NEW\n"
"DLOAD \"t_round.bas\"\n"
"LIST\n"
"RUN\n"));
PASS(errctx, akbasic_runtime_start(&HARNESS_RUNTIME, AKBASIC_MODE_REPL));
PASS(errctx, akbasic_runtime_run(&HARNESS_RUNTIME, 200));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "FIRST\nSECOND\nTHIRD\n") != NULL,
"a save/load round trip should run all three lines, got \"%s\"",
HARNESS_OUTPUT);
/*
* And the command is not in the program. LIST rather than the run output,
* because a clobbered last line reloads the file and re-runs it, which
* prints the right text for the wrong reason.
*/
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "DLOAD") == NULL,
"the DLOAD command should not have been filed as program text, got \"%s\"",
HARNESS_OUTPUT);
harness_stop();
remove("t_round.bas");
SUCCEED_RETURN(errctx);
}
int main(void)
{
test_channel_round_trip();
test_channel_numeric();
test_append();
test_channel_direction();
test_channel_state();
test_copy_and_concat();
test_rename_and_scratch();
test_bsave_bload();
test_no_drive_verbs();
test_dclear();
IGNORE(test_dload_keeps_the_last_line());
return akbasic_test_failures;
}