Files
klammertext/tst/klammerset_test.sh
Andy Kopra 240cff4278 An output policy for the three commands, and @cond as a true special form
A snapshot of the development tree.  The substantial changes since the last one:

COMMAND OUTPUT POLICY.  The three commands display text in exactly three cases,
and each owns a stream: LOGGING under "-v" greater than 0 and an ERROR before
termination go to STDERR; OUTPUT THE USER ASKED FOR goes to STDOUT.  For ktext
that output is a document, so "ktext doc.kt -d | ..." is now safe -- logging
used to share the stream and land inside the document.  A bare command prints
its usage and succeeds rather than failing.  Colour is emitted only to a
terminal, per stream, and NO_COLOR is honoured.

"-v 1" reports every decision whose outcome you could not have read off your own
input: the klammerset that was loaded and from which file, a font's directory, a
":files" name's file, how "-o" was expanded.  Higher levels are the trace.

The commands no longer warn and continue: an anomaly is an error, described with
its location.  Two exceptions remain, each for a stated reason -- a condition
that is expected and temporary by design, and a judgment that is a heuristic
rather than exact.

@cond IS NOW A TRUE SPECIAL FORM, resolved at APPLICATION time rather than when
the file is read.  Two consequences for a writer:

  * a state variable reaches the predicate.  "@@@state Flag :value true @@@
    @cond *Flag* | T | F @" renders "T"; it used to see the literal "*Flag*" and
    silently take the false branch.  The document now behaves like a klammer
    body, whose arguments are bound before its conditionals are decided.
  * nothing in a discarded branch happens -- it is not read, not evaluated, not
    expanded.  An @eval in the branch not taken used to run anyway.

Its predicate relation is total and strict: true, True, 1; false, False, 0, and
empty; anything else is an error at the @cond rather than silently false.

@eval REACHING OUTSIDE.  ":shell" and ":haskell" now keep the command's standard
error out of the document (it appears under "-v 1") and treat a nonzero exit as
an error naming what the command reported.  A command that exits nonzero on
purpose -- "grep" finding no match -- says so with "|| true".

KLAMMER SETS.  Several combine: "--klammersets a b c" loads all three in the
order given, sharing one namespace, with the definition modes deciding
collisions.  "none" means none and may not be combined with other symbols.  A
klammerset with symbol X is declared in a file X/X.k, which is what lets two
sets require the same third set without loading it twice.

TESTS.  Four new suites: the kdiag command's interface, the @eval primitive's
contract with the outside world, and verbosity at both tiers.  Three suites
that could not run on macOS at all now do.

Assembled from dev commit 6c8ee6c22fca.
2026-08-16 01:37:59 +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 /tmp/klammerset_doc.XXXXXX)
ENVDIR=$(mktemp -d /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 /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 ]