Files
libakstdlib/tests/test_strhash.c

149 lines
4.5 KiB
C
Raw Normal View History

Test the libc wrappers: 52% -> 99% line coverage Every wrapper outside the list and tree code was untested. Six new test files close that, following the plan already written in TODO.md 1.2-1.6: test_stream.c fopen/fread/fwrite/fclose -- happy paths, the round trip, AKERR_EOF on a short read, AKERR_IO on a stream opened in the wrong mode, ENOENT, and the NULL guards test_format.c printf/fprintf/sprintf -- text *and* count asserted (stdout is pointed at a temp file to check aksl_printf), all eight NULL guards, EBADF on a read-only stream, and 512 variadic calls in a loop as sanitizer cover for the missing va_end test_convert.c ato{i,l,ll,f} happy paths, negatives, leading whitespace, NULL guards test_path.c realpath on a file and on a symlink, both compared against realpath(3) since TMPDIR may itself be a link; ENOENT, ENOTDIR, NULL path test_strhash.c djb2 known-answer vectors, len == 0, embedded NUL, stability, NULL guards test_convert_strict.c known-failing (2.1.5): the AKERR_VALUE / ERANGE contract the ato* family cannot express today test_tree.c gains the BFS AKERR_NOT_IMPLEMENTED contract, NULL arguments, and a callback error that is not AKERR_ITERATOR_BREAK propagating out. Tests deliberately say nothing about behaviour TODO.md records as defective -- unchecked ptr/mode/resolved_path, short transfers reported as success, *count left at -1, the djb2 sign extension -- so the eventual fix does not have to come with a test rewrite. Each failure case in test_path.c passes a zeroed buffer, because the wrapper's own error path formats resolved_path with %s (2.1.6). aksl_capture.h gains aksl_temp_file() with an atexit unlink backstop. Without it every test that fails before its own unlink leaves temp files behind -- which is the normal case for a known-failing test, and happens 173 times over in a mutation run. Coverage on src/stdlib.c: 52.0% -> 99.0% of lines (200/202), 23.6% -> 51.0% of branches, 8/21 -> 21/21 functions. The two uncovered lines are both `} HANDLE(e, AKERR_ITERATOR_BREAK) {`, where the macro starts with the `break;` of PROCESS's `case 0:` arm -- reachable only via a non-NULL error context whose status is zero, the pathology 2.2.1 exists to remove. Mutation score on src/stdlib.c: 46.8% -> 89.6% (155/173 killed). CI, the pre-push hook and the docs ratchet from 40 to 80 accordingly, and the 18 survivors are grouped by cause in TODO.md and README.md. A new CI coverage job gates at 90% lines / 45% branches. Verified: ctest --test-dir build # 12/12 ctest --test-dir build-asan # 12/12 under ASan + UBSan ctest --test-dir build-coverage # 14/14, report attached ctest --test-dir build -j8 --repeat until-fail:3 gcc -Wall -Wextra -c on all nine test files # no warnings python3 scripts/mutation_test.py --target src/stdlib.c # 89.6% No temp files left in /tmp after any of the above. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 02:07:08 -04:00
/*
* aksl_strhash_djb2 -- TODO.md section 1.6.
*
* The expected values are the canonical djb2 ones: h = 5381, then
* h = h * 33 + byte for each of len bytes, truncated to 32 bits. They were
* computed independently of this implementation.
*
Fix the six confirmed defects and close the API contract gaps TODO.md section 2.1 recorded six defects reproduced against the built library, and section 2.2 seventeen contract gaps. Both are closed. The four tests registered in AKSL_KNOWN_FAILING_TESTS are folded back into the tests for the things they test, and that list is now empty. The defects: 2.1.1 aksl_list_append conflated Floyd cycle detection with finding the tail, so `tail` tracked the node behind the midpoint. Any append to a list of 2+ nodes silently dropped everything after it. Two separate walks now: Floyd to prove the list is finite, then a plain walk to the end. 2.1.2 aksl_list_iterate started visiting from Floyd's `slow` cursor, so the whole first half of the list -- head included -- was never passed to the callback. It starts at the head. 2.1.3 AKERR_ITERATOR_BREAK did not stop a tree traversal: the frame that raised it handled it and returned success, so the parent carried on into the sibling subtree. The recursion is split out and propagates the break; only the public entry swallows it. 2.1.4 va_end now matches every va_start on every path. 2.1.5 The ato* family had no error channel at all. Reimplemented over a new strto* family with errno cleared, an endptr check and a range check: AKERR_VALUE for junk, ERANGE for overflow. 2.1.6 aksl_realpath never checked resolved_path, could not be told the buffer size, and formatted an unspecified buffer with %s on its own error path. It takes a length; aksl_realpath_alloc is the allocating form. The contract gaps, in brief: errno is cleared before every wrapped call and read back through a fallback so no error can carry status 0; fopen validates pathname and mode; fread/fwrite report the transferred count through a required out-param and no longer call a short transfer a success; aksl_sprintf is gone in favour of aksl_snprintf, which treats truncation as an error; the variadic wrappers carry format attributes; djb2 reads bytes as unsigned; tree traversal is depth- and cycle-bounded and implements BFS, so lalloc/lfree are used rather than merely stored; an unknown searchmode is AKERR_VALUE rather than silent success; list_pop takes the head by reference; aksl_freep, the node initialisers and extern "C" are new. Build: -pg is out of the default build (it never reached the C compiler anyway, and it is what produced the stray gmon.out), -Wall -Wextra are in, and there is a .gitignore. Tests: 11 binaries, all green under the normal and sanitizer builds. Visit-order assertions replace the step counts that could not tell the three depth-first orders apart. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 07:14:11 -04:00
* Most vectors here are 7-bit ASCII, where signed and unsigned char agree and
* the test therefore says nothing either way about byte signedness. The high-bit
* vector is the one that pins TODO.md 2.2.6 down.
Test the libc wrappers: 52% -> 99% line coverage Every wrapper outside the list and tree code was untested. Six new test files close that, following the plan already written in TODO.md 1.2-1.6: test_stream.c fopen/fread/fwrite/fclose -- happy paths, the round trip, AKERR_EOF on a short read, AKERR_IO on a stream opened in the wrong mode, ENOENT, and the NULL guards test_format.c printf/fprintf/sprintf -- text *and* count asserted (stdout is pointed at a temp file to check aksl_printf), all eight NULL guards, EBADF on a read-only stream, and 512 variadic calls in a loop as sanitizer cover for the missing va_end test_convert.c ato{i,l,ll,f} happy paths, negatives, leading whitespace, NULL guards test_path.c realpath on a file and on a symlink, both compared against realpath(3) since TMPDIR may itself be a link; ENOENT, ENOTDIR, NULL path test_strhash.c djb2 known-answer vectors, len == 0, embedded NUL, stability, NULL guards test_convert_strict.c known-failing (2.1.5): the AKERR_VALUE / ERANGE contract the ato* family cannot express today test_tree.c gains the BFS AKERR_NOT_IMPLEMENTED contract, NULL arguments, and a callback error that is not AKERR_ITERATOR_BREAK propagating out. Tests deliberately say nothing about behaviour TODO.md records as defective -- unchecked ptr/mode/resolved_path, short transfers reported as success, *count left at -1, the djb2 sign extension -- so the eventual fix does not have to come with a test rewrite. Each failure case in test_path.c passes a zeroed buffer, because the wrapper's own error path formats resolved_path with %s (2.1.6). aksl_capture.h gains aksl_temp_file() with an atexit unlink backstop. Without it every test that fails before its own unlink leaves temp files behind -- which is the normal case for a known-failing test, and happens 173 times over in a mutation run. Coverage on src/stdlib.c: 52.0% -> 99.0% of lines (200/202), 23.6% -> 51.0% of branches, 8/21 -> 21/21 functions. The two uncovered lines are both `} HANDLE(e, AKERR_ITERATOR_BREAK) {`, where the macro starts with the `break;` of PROCESS's `case 0:` arm -- reachable only via a non-NULL error context whose status is zero, the pathology 2.2.1 exists to remove. Mutation score on src/stdlib.c: 46.8% -> 89.6% (155/173 killed). CI, the pre-push hook and the docs ratchet from 40 to 80 accordingly, and the 18 survivors are grouped by cause in TODO.md and README.md. A new CI coverage job gates at 90% lines / 45% branches. Verified: ctest --test-dir build # 12/12 ctest --test-dir build-asan # 12/12 under ASan + UBSan ctest --test-dir build-coverage # 14/14, report attached ctest --test-dir build -j8 --repeat until-fail:3 gcc -Wall -Wextra -c on all nine test files # no warnings python3 scripts/mutation_test.py --target src/stdlib.c # 89.6% No temp files left in /tmp after any of the above. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 02:07:08 -04:00
*/
#include "aksl_capture.h"
static int test_empty_string_is_the_djb2_seed(void)
{
uint32_t h = 0;
AKSL_CHECK_OK(aksl_strhash_djb2("", 0, &h));
AKSL_CHECK(h == 5381);
return 0;
}
/* len drives the loop, so a zero length ignores the contents entirely. */
static int test_zero_length_ignores_the_buffer(void)
{
char buf[] = "ignored";
uint32_t h = 0;
AKSL_CHECK_OK(aksl_strhash_djb2(buf, 0, &h));
AKSL_CHECK(h == 5381);
return 0;
}
static int test_known_answer_vectors(void)
{
char hello[] = "hello";
char libname[] = "libakstdlib";
uint32_t h = 0;
AKSL_CHECK_OK(aksl_strhash_djb2(hello, 5, &h));
AKSL_CHECK(h == 261238937u);
AKSL_CHECK_OK(aksl_strhash_djb2(libname, 11, &h));
AKSL_CHECK(h == 884285482u);
return 0;
}
Fix the six confirmed defects and close the API contract gaps TODO.md section 2.1 recorded six defects reproduced against the built library, and section 2.2 seventeen contract gaps. Both are closed. The four tests registered in AKSL_KNOWN_FAILING_TESTS are folded back into the tests for the things they test, and that list is now empty. The defects: 2.1.1 aksl_list_append conflated Floyd cycle detection with finding the tail, so `tail` tracked the node behind the midpoint. Any append to a list of 2+ nodes silently dropped everything after it. Two separate walks now: Floyd to prove the list is finite, then a plain walk to the end. 2.1.2 aksl_list_iterate started visiting from Floyd's `slow` cursor, so the whole first half of the list -- head included -- was never passed to the callback. It starts at the head. 2.1.3 AKERR_ITERATOR_BREAK did not stop a tree traversal: the frame that raised it handled it and returned success, so the parent carried on into the sibling subtree. The recursion is split out and propagates the break; only the public entry swallows it. 2.1.4 va_end now matches every va_start on every path. 2.1.5 The ato* family had no error channel at all. Reimplemented over a new strto* family with errno cleared, an endptr check and a range check: AKERR_VALUE for junk, ERANGE for overflow. 2.1.6 aksl_realpath never checked resolved_path, could not be told the buffer size, and formatted an unspecified buffer with %s on its own error path. It takes a length; aksl_realpath_alloc is the allocating form. The contract gaps, in brief: errno is cleared before every wrapped call and read back through a fallback so no error can carry status 0; fopen validates pathname and mode; fread/fwrite report the transferred count through a required out-param and no longer call a short transfer a success; aksl_sprintf is gone in favour of aksl_snprintf, which treats truncation as an error; the variadic wrappers carry format attributes; djb2 reads bytes as unsigned; tree traversal is depth- and cycle-bounded and implements BFS, so lalloc/lfree are used rather than merely stored; an unknown searchmode is AKERR_VALUE rather than silent success; list_pop takes the head by reference; aksl_freep, the node initialisers and extern "C" are new. Build: -pg is out of the default build (it never reached the C compiler anyway, and it is what produced the stray gmon.out), -Wall -Wextra are in, and there is a .gitignore. Tests: 11 binaries, all green under the normal and sanitizer builds. Visit-order assertions replace the step counts that could not tell the three depth-first orders apart. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 07:14:11 -04:00
/*
* TODO.md 2.2.6, pinned. The cursor is an unsigned char * now, so bytes at or
* above 0x80 contribute their unsigned value. Iterating a plain char * on x86 or
* ARM Linux made them negative, giving 5859874 here instead of 5868578 -- a hash
* that disagreed with canonical djb2 and, worse, disagreed with itself across
* platforms depending on whether char happened to be signed.
*
* Benign for the 7-bit identifiers akbasic hashes; quietly wrong for the first
* caller to key a table on a filename or a UTF-8 string literal.
*/
static int test_high_bit_bytes_are_unsigned(void)
{
char buf[2] = { (char)0xff, (char)0xfe };
uint32_t h = 0;
AKSL_CHECK_OK(aksl_strhash_djb2(buf, sizeof(buf), &h));
AKSL_CHECK(h == 5868578u);
/* The sign-extended answer, spelled out so a regression names itself. */
AKSL_CHECK(h != 5859874u);
return 0;
}
/* The NUL-terminated convenience form (TODO.md 3.6) agrees with the length one. */
static int test_str_form_matches_the_length_form(void)
{
const char *s = "libakstdlib";
uint32_t from_str = 0;
uint32_t from_len = 0;
AKSL_CHECK_OK(aksl_strhash_djb2_str(s, &from_str));
AKSL_CHECK_OK(aksl_strhash_djb2(s, strlen(s), &from_len));
AKSL_CHECK(from_str == from_len);
AKSL_CHECK(from_str == 884285482u);
/* The empty string is the seed, not an error. */
AKSL_CHECK_OK(aksl_strhash_djb2_str("", &from_str));
AKSL_CHECK(from_str == 5381);
AKSL_CHECK_STATUS(aksl_strhash_djb2_str(NULL, &from_str), AKERR_NULLPOINTER);
AKSL_CHECK_STATUS(aksl_strhash_djb2_str("x", NULL), AKERR_NULLPOINTER);
return 0;
}
Test the libc wrappers: 52% -> 99% line coverage Every wrapper outside the list and tree code was untested. Six new test files close that, following the plan already written in TODO.md 1.2-1.6: test_stream.c fopen/fread/fwrite/fclose -- happy paths, the round trip, AKERR_EOF on a short read, AKERR_IO on a stream opened in the wrong mode, ENOENT, and the NULL guards test_format.c printf/fprintf/sprintf -- text *and* count asserted (stdout is pointed at a temp file to check aksl_printf), all eight NULL guards, EBADF on a read-only stream, and 512 variadic calls in a loop as sanitizer cover for the missing va_end test_convert.c ato{i,l,ll,f} happy paths, negatives, leading whitespace, NULL guards test_path.c realpath on a file and on a symlink, both compared against realpath(3) since TMPDIR may itself be a link; ENOENT, ENOTDIR, NULL path test_strhash.c djb2 known-answer vectors, len == 0, embedded NUL, stability, NULL guards test_convert_strict.c known-failing (2.1.5): the AKERR_VALUE / ERANGE contract the ato* family cannot express today test_tree.c gains the BFS AKERR_NOT_IMPLEMENTED contract, NULL arguments, and a callback error that is not AKERR_ITERATOR_BREAK propagating out. Tests deliberately say nothing about behaviour TODO.md records as defective -- unchecked ptr/mode/resolved_path, short transfers reported as success, *count left at -1, the djb2 sign extension -- so the eventual fix does not have to come with a test rewrite. Each failure case in test_path.c passes a zeroed buffer, because the wrapper's own error path formats resolved_path with %s (2.1.6). aksl_capture.h gains aksl_temp_file() with an atexit unlink backstop. Without it every test that fails before its own unlink leaves temp files behind -- which is the normal case for a known-failing test, and happens 173 times over in a mutation run. Coverage on src/stdlib.c: 52.0% -> 99.0% of lines (200/202), 23.6% -> 51.0% of branches, 8/21 -> 21/21 functions. The two uncovered lines are both `} HANDLE(e, AKERR_ITERATOR_BREAK) {`, where the macro starts with the `break;` of PROCESS's `case 0:` arm -- reachable only via a non-NULL error context whose status is zero, the pathology 2.2.1 exists to remove. Mutation score on src/stdlib.c: 46.8% -> 89.6% (155/173 killed). CI, the pre-push hook and the docs ratchet from 40 to 80 accordingly, and the 18 survivors are grouped by cause in TODO.md and README.md. A new CI coverage job gates at 90% lines / 45% branches. Verified: ctest --test-dir build # 12/12 ctest --test-dir build-asan # 12/12 under ASan + UBSan ctest --test-dir build-coverage # 14/14, report attached ctest --test-dir build -j8 --repeat until-fail:3 gcc -Wall -Wextra -c on all nine test files # no warnings python3 scripts/mutation_test.py --target src/stdlib.c # 89.6% No temp files left in /tmp after any of the above. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 02:07:08 -04:00
/* The function is length-driven, not NUL-driven: an embedded NUL is hashed. */
static int test_embedded_nul_is_hashed(void)
{
char buf[3] = { 'a', '\0', 'b' };
uint32_t whole = 0;
uint32_t prefix = 0;
AKSL_CHECK_OK(aksl_strhash_djb2(buf, sizeof(buf), &whole));
AKSL_CHECK(whole == 193482728u);
/* Stopping at the NUL would give the one-byte hash instead. */
AKSL_CHECK_OK(aksl_strhash_djb2(buf, 1, &prefix));
AKSL_CHECK(prefix != whole);
return 0;
}
static int test_hash_is_stable_across_calls(void)
{
char buf[] = "repeatable";
uint32_t first = 0;
uint32_t second = 0;
AKSL_CHECK_OK(aksl_strhash_djb2(buf, sizeof(buf) - 1, &first));
AKSL_CHECK_OK(aksl_strhash_djb2(buf, sizeof(buf) - 1, &second));
AKSL_CHECK(first == second);
return 0;
}
static int test_rejects_null_arguments(void)
{
char buf[] = "x";
uint32_t h = 0;
AKSL_CHECK_STATUS_MSG_CONTAINS(aksl_strhash_djb2(NULL, 1, &h),
AKERR_NULLPOINTER, "str");
AKSL_CHECK_STATUS_MSG_CONTAINS(aksl_strhash_djb2(buf, 1, NULL),
AKERR_NULLPOINTER, "hashval");
return 0;
}
int main(void)
{
int failures = 0;
akerr_init();
AKSL_RUN(failures, test_empty_string_is_the_djb2_seed);
AKSL_RUN(failures, test_zero_length_ignores_the_buffer);
AKSL_RUN(failures, test_known_answer_vectors);
Fix the six confirmed defects and close the API contract gaps TODO.md section 2.1 recorded six defects reproduced against the built library, and section 2.2 seventeen contract gaps. Both are closed. The four tests registered in AKSL_KNOWN_FAILING_TESTS are folded back into the tests for the things they test, and that list is now empty. The defects: 2.1.1 aksl_list_append conflated Floyd cycle detection with finding the tail, so `tail` tracked the node behind the midpoint. Any append to a list of 2+ nodes silently dropped everything after it. Two separate walks now: Floyd to prove the list is finite, then a plain walk to the end. 2.1.2 aksl_list_iterate started visiting from Floyd's `slow` cursor, so the whole first half of the list -- head included -- was never passed to the callback. It starts at the head. 2.1.3 AKERR_ITERATOR_BREAK did not stop a tree traversal: the frame that raised it handled it and returned success, so the parent carried on into the sibling subtree. The recursion is split out and propagates the break; only the public entry swallows it. 2.1.4 va_end now matches every va_start on every path. 2.1.5 The ato* family had no error channel at all. Reimplemented over a new strto* family with errno cleared, an endptr check and a range check: AKERR_VALUE for junk, ERANGE for overflow. 2.1.6 aksl_realpath never checked resolved_path, could not be told the buffer size, and formatted an unspecified buffer with %s on its own error path. It takes a length; aksl_realpath_alloc is the allocating form. The contract gaps, in brief: errno is cleared before every wrapped call and read back through a fallback so no error can carry status 0; fopen validates pathname and mode; fread/fwrite report the transferred count through a required out-param and no longer call a short transfer a success; aksl_sprintf is gone in favour of aksl_snprintf, which treats truncation as an error; the variadic wrappers carry format attributes; djb2 reads bytes as unsigned; tree traversal is depth- and cycle-bounded and implements BFS, so lalloc/lfree are used rather than merely stored; an unknown searchmode is AKERR_VALUE rather than silent success; list_pop takes the head by reference; aksl_freep, the node initialisers and extern "C" are new. Build: -pg is out of the default build (it never reached the C compiler anyway, and it is what produced the stray gmon.out), -Wall -Wextra are in, and there is a .gitignore. Tests: 11 binaries, all green under the normal and sanitizer builds. Visit-order assertions replace the step counts that could not tell the three depth-first orders apart. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 07:14:11 -04:00
AKSL_RUN(failures, test_high_bit_bytes_are_unsigned);
AKSL_RUN(failures, test_str_form_matches_the_length_form);
Test the libc wrappers: 52% -> 99% line coverage Every wrapper outside the list and tree code was untested. Six new test files close that, following the plan already written in TODO.md 1.2-1.6: test_stream.c fopen/fread/fwrite/fclose -- happy paths, the round trip, AKERR_EOF on a short read, AKERR_IO on a stream opened in the wrong mode, ENOENT, and the NULL guards test_format.c printf/fprintf/sprintf -- text *and* count asserted (stdout is pointed at a temp file to check aksl_printf), all eight NULL guards, EBADF on a read-only stream, and 512 variadic calls in a loop as sanitizer cover for the missing va_end test_convert.c ato{i,l,ll,f} happy paths, negatives, leading whitespace, NULL guards test_path.c realpath on a file and on a symlink, both compared against realpath(3) since TMPDIR may itself be a link; ENOENT, ENOTDIR, NULL path test_strhash.c djb2 known-answer vectors, len == 0, embedded NUL, stability, NULL guards test_convert_strict.c known-failing (2.1.5): the AKERR_VALUE / ERANGE contract the ato* family cannot express today test_tree.c gains the BFS AKERR_NOT_IMPLEMENTED contract, NULL arguments, and a callback error that is not AKERR_ITERATOR_BREAK propagating out. Tests deliberately say nothing about behaviour TODO.md records as defective -- unchecked ptr/mode/resolved_path, short transfers reported as success, *count left at -1, the djb2 sign extension -- so the eventual fix does not have to come with a test rewrite. Each failure case in test_path.c passes a zeroed buffer, because the wrapper's own error path formats resolved_path with %s (2.1.6). aksl_capture.h gains aksl_temp_file() with an atexit unlink backstop. Without it every test that fails before its own unlink leaves temp files behind -- which is the normal case for a known-failing test, and happens 173 times over in a mutation run. Coverage on src/stdlib.c: 52.0% -> 99.0% of lines (200/202), 23.6% -> 51.0% of branches, 8/21 -> 21/21 functions. The two uncovered lines are both `} HANDLE(e, AKERR_ITERATOR_BREAK) {`, where the macro starts with the `break;` of PROCESS's `case 0:` arm -- reachable only via a non-NULL error context whose status is zero, the pathology 2.2.1 exists to remove. Mutation score on src/stdlib.c: 46.8% -> 89.6% (155/173 killed). CI, the pre-push hook and the docs ratchet from 40 to 80 accordingly, and the 18 survivors are grouped by cause in TODO.md and README.md. A new CI coverage job gates at 90% lines / 45% branches. Verified: ctest --test-dir build # 12/12 ctest --test-dir build-asan # 12/12 under ASan + UBSan ctest --test-dir build-coverage # 14/14, report attached ctest --test-dir build -j8 --repeat until-fail:3 gcc -Wall -Wextra -c on all nine test files # no warnings python3 scripts/mutation_test.py --target src/stdlib.c # 89.6% No temp files left in /tmp after any of the above. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 02:07:08 -04:00
AKSL_RUN(failures, test_embedded_nul_is_hashed);
AKSL_RUN(failures, test_hash_is_stable_across_calls);
AKSL_RUN(failures, test_rejects_null_arguments);
AKSL_REPORT(failures);
}