Files
klammertext/tst/klammerset_test.sh
T
ack fe475e774a Klammertext runs natively on Windows; a Windows source-install guide; the bundled sh for @eval :shell
Assembled from klammertext-dev f344a37d0f6e. The commands, the library,
the Standard Klammer Set, the reflection klammerset and the Python
binding build and run natively on Windows (MSYS2 UCRT64 gcc, python.org
Python), and both test tiers pass there. doc/install/windows_source_install.md
is the fifth install guide: a source install parallel to the Linux and
macOS ones, verified by following it on a pristine Windows 11.

Behaviour on every system: paths Klammertext prints use forward slashes;
output is LF; the command line is UTF-8 on Windows; @eval :shell is a
POSIX shell command everywhere -- on Windows it runs the sh Klammertext
ships, BusyBox for Windows (win/busybox/, GPLv2, with its licence and the
source pointer). KLAMMERTEXT_FONTS and KLAMMERTEXT_KLAMMERSETS are lists in
the system's PATH convention (';' on Windows). On Linux and macOS nothing
observable changes.
2026-09-25 00:32:55 +02:00

475 lines
20 KiB
Bash
Executable File

#!/bin/bash
#
# klammerset_test.sh — Regression tests for the @@@klammerset system command.
#
# @@@klammerset declares a Klammerset: a named, logically related group of
# klammer definitions. The declaration is operative — processing it reads
# the :requires files and then the :files, in list order, at the point of the
# declaration; relative names resolve against the declaring file's directory,
# never the cwd. A repeated declaration of an already-registered symbol is
# skipped (loaded once), which is what makes :requires idempotent. The
# klammers themselves live in the Machine's flat Klammer_registry; the
# Klammerset holds metadata and the file list only.
#
# Engine tier: no SKS. Fixtures live in tst/klammerset/ and define their own
# target ("fix") inline.
#
# Usage: ./klammerset_test.sh
# Exit code: 0 if all tests pass, 1 otherwise.
PASS=0
FAIL=0
KTEXT=ktext
K=${KLAMMERTEXT_HOME:?KLAMMERTEXT_HOME must be set}
FIX=$K/tst/klammerset
# The fixtures are laid out as <symbol>/<symbol>.k under $FIX, because since
# 2026-08-14 the command line takes klammerset SYMBOLS only -- a set that
# cannot be named by one cannot be loaded. Putting $FIX on the search path is
# how the suite makes its own fixtures nameable, and it also exercises stage 2
# (KLAMMERTEXT_KLAMMERSETS) for both the command line and ":requires".
# Tests that need a different path set it themselves on the command.
export KLAMMERTEXT_KLAMMERSETS=$FIX
red=$'\033[31m'
green=$'\033[32m'
bold=$'\033[1m'
reset=$'\033[0m'
# strip leading/trailing blank lines and surrounding whitespace
trim() { awk '{ sub(/[ \t\r]+$/, "") } { line[NR]=$0 } END { f=1; while (f<=NR && line[f]=="") f++; l=NR; while (l>=1 && line[l]=="") l--; for (i=f;i<=l;i++) print line[i] }'; }
# check_eq TEST_NAME EXPECTED KTEXT_ARGS...
# Runs ktext, expects exit status 0, and compares trimmed stdout to EXPECTED.
check_eq() {
local test_name="$1"
local expected="$2"
shift 2
local output status
output=$("$KTEXT" "$@" 2>/tmp/klammerset_test_err.$$)
status=$?
output=$(printf '%s' "$output" | trim)
if [ $status -ne 0 ]; then
echo "${red}FAIL${reset} $test_name — ktext exited $status"
echo " stderr: $(head -3 /tmp/klammerset_test_err.$$)"
FAIL=$((FAIL + 1))
return
fi
if [ "$output" = "$expected" ]; then
echo "${green}PASS${reset} $test_name"
PASS=$((PASS + 1))
else
echo "${red}FAIL${reset} $test_name"
echo " expected: [$expected]"
echo " got: [$output]"
FAIL=$((FAIL + 1))
fi
}
# check_contains TEST_NAME SUBSTRING KTEXT_ARGS...
# Runs ktext, expects exit status 0, and checks that the combined output
# contains SUBSTRING.
check_contains() {
local test_name="$1"
local needle="$2"
shift 2
local output status
output=$("$KTEXT" "$@" 2>&1)
status=$?
if [ $status -ne 0 ]; then
echo "${red}FAIL${reset} $test_name — ktext exited $status"
echo " output: $(echo "$output" | head -3)"
FAIL=$((FAIL + 1))
return
fi
if printf '%s' "$output" | grep -qF "$needle"; then
echo "${green}PASS${reset} $test_name"
PASS=$((PASS + 1))
else
echo "${red}FAIL${reset} $test_name"
echo " expected output to contain: [$needle]"
echo " got: $(echo "$output" | head -5)"
FAIL=$((FAIL + 1))
fi
}
# check_fails TEST_NAME SUBSTRING KTEXT_ARGS...
# Runs ktext, expects a NONZERO exit status, and checks that the combined
# output contains SUBSTRING.
check_fails() {
local test_name="$1"
local needle="$2"
shift 2
local output status
output=$("$KTEXT" "$@" 2>&1)
status=$?
if [ $status -eq 0 ]; then
echo "${red}FAIL${reset} $test_name — expected an error, ktext exited 0"
FAIL=$((FAIL + 1))
return
fi
if printf '%s' "$output" | grep -qF "$needle"; then
echo "${green}PASS${reset} $test_name"
PASS=$((PASS + 1))
else
echo "${red}FAIL${reset} $test_name"
echo " expected error output to contain: [$needle]"
echo " got: $(echo "$output" | head -5)"
FAIL=$((FAIL + 1))
fi
}
echo "${bold}@@@klammerset tests${reset}"
echo "======================="
echo
# --- Loading ---------------------------------------------------------------
# 1. :files load in list order: klammers.k defines @greet for the target
# that base.k declares, so base.k must have been read first.
check_eq "1. :files load in order (target before klammer)" \
"Hello World" \
--klammersets kit -s '@greet World @' -t fix
# 2. :requires loads the dependency before the set's own files.
check_eq "2. :requires loads the dependency" \
"--" \
--klammersets kit -s '@dash@' -t fix
# 3. Program order: a definition AFTER the declaration in the declaring file
# is available (there is no :text argument; the declaring file's own
# content plays that role).
check_eq "3. trailing definition in the declaring file" \
"AFTER" \
--klammersets kit -s '@after@' -t fix
# 4. Relative :files names resolve against the DECLARING file's directory,
# not the cwd (run from an unrelated directory).
output=$( (cd /tmp && "$KTEXT" --klammersets kit -s '@greet Elsewhere @' -t fix) 2>/dev/null | trim )
if [ "$output" = "Hello Elsewhere" ]; then
echo "${green}PASS${reset} 4. :files resolve against the declaring file's directory"
PASS=$((PASS + 1))
else
echo "${red}FAIL${reset} 4. :files resolve against the declaring file's directory"
echo " expected: [Hello Elsewhere]"
echo " got: [$output]"
FAIL=$((FAIL + 1))
fi
# 5. A filename with a space in the :files list (standalone "/" separator).
check_eq "5. spacey filename in :files" \
"SPACEY" \
--klammersets sp -s '@spacey@' -t fix
# --- A klammerset is identified by where it is -------------------------------
#
# Symbol X must be declared in X/X.k (Andy, 2026-08-15). The convention already
# governed symbol RESOLUTION; requiring it of the DECLARATION makes the symbol a
# function of the path, which is what lets the already-loaded guard run before a
# file is opened (cases 23-23b below) instead of after it has been re-executed.
#
# It also continues the reasoning behind symbols-only on the command line: if
# every klammerset is reachable by symbol the set of all of them is ENUMERABLE;
# if every declaration is in X/X.k each one is also IDENTIFIABLE from where it
# sits, so a symbol names one file and one file declares one symbol.
#
# "wrap" is the fixture that violates it: wrap/dup.k declares the symbol "kit".
# It formerly tested that such a duplicate was tolerated and SKIPPED; the
# duplicate is now impossible instead, which is the stronger guarantee.
check_fails "6. a declaration outside X/X.k is an error" \
'must be declared in a file named "kit/kit.k"' \
--klammersets wrap -s '@greet Again @' -t fix
check_fails "6a. ... and the error names the file it is actually in" \
'dup.k' \
--klammersets wrap -s '@greet Again @' -t fix
# 7. EXEMPT: a document may declare a klammerset -- that is how a designer
# writes one, and kdesc's provenance filter depends on it. Such a set is
# local to the document: nothing can ":requires" it, so it has no identity to
# protect and needs no file named after it.
check_eq "7. a klammerset declared in a document is exempt" \
"ok" \
--klammersets none -s '@@@klammerset mine | Local to this document @@@ ok'
# --- Introspection -----------------------------------------------------------
# The machine state moved from "ktext -m" to "kdiag --machine" in the
# 2026-08-14 argument redesign: the Machine's internals are the programmer's
# question, and kdiag is the programmer's command. kdiag's input is optional,
# so the state can be shown without a document at all.
kdiag_contains() {
local test_name="$1" expected="$2"; shift 2
local output
output=$(kdiag "$@" 2>/dev/null)
if printf '%s' "$output" | grep -qF -- "$expected"; then
echo "${green}PASS${reset} $test_name"; PASS=$((PASS + 1))
else
echo "${red}FAIL${reset} $test_name"
echo " expected to contain: [$expected]"
FAIL=$((FAIL + 1))
fi
}
# 8. --machine lists the registered klammersets. kdiag has no --klammersets:
# a klammerset reaches it the way anything else does, by being read in the
# input. That is deliberate -- the symbol mechanism is a higher-level
# convenience, and kdiag must still work when symbol resolution is what
# broke. (The :requires inside kit.k still uses a symbol, resolved by the
# Machine, so this exercises both paths at once.)
kdiag_contains "8. --machine shows the klammerset symbol" \
"kit" \
"@read $FIX/kit/kit.k @" --process --machine
kdiag_contains "9. --machine shows the required klammerset too" \
"Utility klammers for engine tests" \
"@read $FIX/kit/kit.k @" --process --machine
# --- Errors --------------------------------------------------------------------
# 10. A listed file that does not exist is a clean klammerset error.
check_fails "10. missing file in :files" \
"missing.k" \
--klammersets broken -s 'x' -t fix
# 11. A symbol must be an identifier (starts with a letter; letters, digits,
# underscores).
check_fails "11. invalid symbol rejected" \
"not valid" \
--klammersets none -s '@@@klammerset 9bad | Bad symbol @@@' -d
# --- The search path (symbol -> <dir>/<symbol>/<symbol>.k) -------------------
# Runtime fixtures: a document directory holding a local klammerset, and a
# separate directory serving as a KLAMMERTEXT_KLAMMERSETS stage.
DOCDIR=$(mktemp -d "${TMPDIR:-/tmp}"/klammerset_doc.XXXXXX)
ENVDIR=$(mktemp -d "${TMPDIR:-/tmp}"/klammerset_env.XXXXXX)
trap 'rm -rf "$DOCDIR" "$ENVDIR" /tmp/klammerset_test_err.$$' EXIT
mkdir -p "$DOCDIR/locset" "$ENVDIR/envset" "$ENVDIR/locset"
printf '@@@klammerset locset | Document-local set @@@\n@@@target fixL | Fixture @@@\n@@local_k.fixL : LOCAL @@\n' \
> "$DOCDIR/locset/locset.k"
printf '@local_k@\n' > "$DOCDIR/doc.kt"
printf '@@@klammerset envset | Installed set @@@\n@@@target fixE | Fixture @@@\n@@env_k.fixE : ENV @@\n' \
> "$ENVDIR/envset/envset.k"
printf '@@@klammerset locset | Shadow candidate @@@\n@@@target fixL | Fixture @@@\n@@local_k.fixL : ENV-SHADOWED @@\n' \
> "$ENVDIR/locset/locset.k"
# 12. Stage 1 for a file input: the document's directory.
check_eq "12. symbol resolves in the document's directory" \
"LOCAL" \
"$DOCDIR/doc.kt" --klammersets locset -t fixL -d
# 13. Stage 1 for string input: the cwd stands in for the document.
output=$( (cd "$DOCDIR" && "$KTEXT" -s '@local_k@' --klammersets locset -t fixL) 2>/dev/null | trim )
if [ "$output" = "LOCAL" ]; then
echo "${green}PASS${reset} 13. cwd stands in for the document (string input)"
PASS=$((PASS + 1))
else
echo "${red}FAIL${reset} 13. cwd stands in for the document (string input)"
echo " expected: [LOCAL] got: [$output]"
FAIL=$((FAIL + 1))
fi
# 14. Stage 2: the KLAMMERTEXT_KLAMMERSETS directories.
output=$(KLAMMERTEXT_KLAMMERSETS=$ENVDIR "$KTEXT" -s '@env_k@' --klammersets envset -t fixE 2>/dev/null | trim)
if [ "$output" = "ENV" ]; then
echo "${green}PASS${reset} 14. symbol resolves in KLAMMERTEXT_KLAMMERSETS"
PASS=$((PASS + 1))
else
echo "${red}FAIL${reset} 14. symbol resolves in KLAMMERTEXT_KLAMMERSETS"
echo " expected: [ENV] got: [$output]"
FAIL=$((FAIL + 1))
fi
# 15. Shadowing: the document-local set wins over the installed one.
output=$(KLAMMERTEXT_KLAMMERSETS=$ENVDIR "$KTEXT" "$DOCDIR/doc.kt" --klammersets locset -t fixL -d 2>/dev/null | trim)
if [ "$output" = "LOCAL" ]; then
echo "${green}PASS${reset} 15. document-local set shadows the installed one"
PASS=$((PASS + 1))
else
echo "${red}FAIL${reset} 15. document-local set shadows the installed one"
echo " expected: [LOCAL] got: [$output]"
FAIL=$((FAIL + 1))
fi
# 16. :requires by symbol, resolved with the declaring directory as the
# local stage (the required set sits inside the declaring set's dir).
mkdir -p "$DOCDIR/kit2/locset"
cp "$DOCDIR/locset/locset.k" "$DOCDIR/kit2/locset/locset.k"
printf '@@@klammerset kit2 | Requires by symbol :requires locset @@@\n' > "$DOCDIR/kit2/kit2.k"
output=$(KLAMMERTEXT_KLAMMERSETS=$DOCDIR "$KTEXT" --klammersets kit2 \
-s '@local_k@' -t fixL 2>/dev/null | trim)
if [ "$output" = "LOCAL" ]; then
echo "${green}PASS${reset} 16. :requires accepts a symbol (declaring-dir stage)"
PASS=$((PASS + 1))
else
echo "${red}FAIL${reset} 16. :requires accepts a symbol (declaring-dir stage)"
echo " expected: [LOCAL] got: [$output]"
FAIL=$((FAIL + 1))
fi
# 17. Stage 3: $KLAMMERTEXT_HOME — the kdesc listing enumerates sks
# (sks/sks.k already satisfies the <symbol>/<symbol>.k convention).
output=$(kdesc --klammersets 2>/dev/null)
if printf '%s' "$output" | grep -q 'sks/sks\.k'; then
echo "${green}PASS${reset} 17. kdesc --klammersets lists sks from KLAMMERTEXT_HOME"
PASS=$((PASS + 1))
else
echo "${red}FAIL${reset} 17. kdesc --klammersets lists sks from KLAMMERTEXT_HOME"
echo " got: $(echo "$output" | head -3)"
FAIL=$((FAIL + 1))
fi
# 18. An unknown symbol is a clean error, and it names both the convention and
# the directories searched -- without those a user cannot tell whether the
# name or the location is wrong. This is the failure mode the symbol-only
# rule makes common: there is no longer a pathname to fall back on.
check_fails "18. unknown symbol is a clean error" \
"was not found" \
-s 'x' --klammersets nosuchset -d
check_fails "18a. ... naming the <symbol>/<symbol>.k convention" \
"x/x.k" \
-s 'x' --klammersets nosuchset -d
check_fails "18b. ... and the directories searched" \
"$FIX" \
-s 'x' --klammersets nosuchset -d
# 19. A pathname is no longer accepted: only symbols resolve. The error must
# say so rather than reporting a missing file.
check_fails "19. a pathname is rejected as a symbol" \
"not found" \
-s 'x' --klammersets "$FIX/kit/kit.k" -d
# 20. One bad symbol among good ones fails the whole load rather than
# silently loading the rest.
check_fails "20. a bad symbol among good ones still fails" \
"was not found" \
-s 'x' --klammersets kit nosuchset -t fix -d
# --- Several klammersets combine ---------------------------------------------
#
# The headline claim of "--klammersets" (plural), stated in the ktext usage and
# in "kdesc --klammersets help": the symbols are a LIST, loaded in the order
# given, and the klammers of all of them share ONE flat namespace -- membership
# is provenance, not containment. Where two sets define the same klammer the
# definition modes decide, so a set of house overrides is loaded after the set
# it adjusts and a set of defaults before it.
#
# The fixtures: "base" declares the target and the ".k", "housea" and "houseb"
# each require base and each override @who with ":::". Both requiring base is
# what lets either be written first, so command-line order is the only variable.
# 21. One namespace: each set's own klammers are available whatever the order.
check_eq "21. combined sets share one namespace" \
"ONLY-A ONLY-B" \
--klammersets housea houseb -s '@a_only@ @b_only@' -t fx
check_eq "21a. ... in either order" \
"ONLY-A ONLY-B" \
--klammersets houseb housea -s '@a_only@ @b_only@' -t fx
# 22. Order decides: two ":::" overrides of the same klammer, so the set
# written LAST is the one whose definition survives.
check_eq "22. the last set written wins an override" \
"HOUSE-B" \
--klammersets housea houseb -s '@who@' -t fx
check_eq "22a. ... and reversing the order reverses the outcome" \
"HOUSE-A" \
--klammersets houseb housea -s '@who@' -t fx
# 23. :requires is loaded once even though both sets ask for it -- the
# already-loaded guard -- so the base definition is there exactly once.
check_eq "23. a set required by both is loaded once" \
"BASE" \
--klammersets base -s '@who@' -t fx
# 23a. A DIAMOND: two sets requiring a third. "needs_a" and "needs_b" both
# require "selfdef", whose declaring file holds its own @@@target -- which
# is legal and normal, since there is deliberately no :text option and the
# declaring file's content plays that role.
#
# This failed until 2026-08-15 with "Target fy is already defined",
# pointing at a line the author wrote once. The already-loaded guard sat
# in Klammerset_registry::add, which runs only after a file has been read
# and its declaration reached -- by which time the second read had
# re-executed the declaring file's own definitions. The guard now runs
# BEFORE the read, which is possible only because X is declared in X/X.k
# and the symbol is therefore known from the path.
check_eq "23a. a diamond :requires loads the shared set once" \
"SELFDEF" \
--klammersets needs_a needs_b -s '@sd@' -t fy
check_eq "23b. ... and requiring it once is unchanged" \
"SELFDEF" \
--klammersets needs_a -s '@sd@' -t fy
check_eq "23c. ... as is naming the same set twice on the command line" \
"SELFDEF" \
--klammersets selfdef selfdef -s '@sd@' -t fy
# 23d. The search path is FROZEN for the run. A path decides WHICH FILE a
# symbol means -- identity, not value -- so if a document could extend it
# mid-run, "which klammerset is X" would depend on evaluation order and
# "kdesc --klammersets" could not be a complete answer. It could:
# the embedded Python shares the process, so an @eval doing os.environ
# changed what a later getenv returned. The environment is now read once.
LATE=$(mktemp -d "${TMPDIR:-/tmp}"/klammerset_late.XXXXXX)
mkdir -p "$LATE/late"
printf '@@@klammerset late | Appeared mid-run @@@\n@@@target lt | T @@@\n@@l.lt : LATE @@\n' \
> "$LATE/late/late.k"
# The @eval still sets the variable -- what changed is that resolution no
# longer consults it -- so the symbol must remain unfindable.
out=$(KLAMMERTEXT_KLAMMERSETS=$FIX "$KTEXT" --klammersets none -d \
-s "@eval os.environ.__setitem__('KLAMMERTEXT_KLAMMERSETS','$LATE') or '' @" 2>&1)
if printf '%s' "$out" | grep -q "was not found" || \
! KLAMMERTEXT_KLAMMERSETS=$FIX "$KTEXT" --klammersets late -s 'x' -d >/dev/null 2>&1; then
echo "${green}PASS${reset} 23d. an @eval cannot extend the klammerset search path"
PASS=$((PASS + 1))
else
echo "${red}FAIL${reset} 23d. the search path moved during the run"
FAIL=$((FAIL + 1))
fi
rm -rf "$LATE"
# --- "none" is the absence of a klammerset, not the name of one ---------------
#
# It therefore says nothing about what should be loaded alongside it. Written
# first it used to discard the rest of the list silently -- the user asked for
# a set, got none, and was told only that some target was undefined; written
# last it was looked up as a symbol and reported missing. Neither reading is
# guessed at now: "none" is accepted only alone.
# 24. "none" alone still loads nothing, which is what it is for.
check_eq "24. none alone loads no klammerset" \
"x" \
--klammersets none -s 'x' -d
check_fails "25. none before another symbol is an error" \
"cannot be combined" \
--klammersets none kit -s 'x' -t fix -d
check_fails "25a. none after another symbol is an error too" \
"cannot be combined" \
--klammersets kit none -s 'x' -t fix -d
# 26. The rule belongs to load_klammersets(), the single loader all three
# commands share -- so kdesc obeys it as well. It did not: kdesc decided
# what "none" meant a second time, and so accepted "none sks" and loaded
# neither. Deciding it twice is how the two commands came to disagree.
out=$(kdesc --klammersets none sks -k table 2>&1); status=$?
if [ $status -ne 0 ] && printf '%s' "$out" | grep -qF "cannot be combined"; then
echo "${green}PASS${reset} 26. kdesc applies the same none rule"
PASS=$((PASS + 1))
else
echo "${red}FAIL${reset} 26. kdesc applies the same none rule — exit $status"
echo " got: $(printf '%s' "$out" | head -2)"
FAIL=$((FAIL + 1))
fi
echo
echo "======================="
echo "Results: ${green}$PASS passed${reset}, $([ $FAIL -gt 0 ] && echo "${red}$FAIL failed${reset}" || echo "0 failed")"
[ $FAIL -eq 0 ]