#!/bin/bash
#
# kdiag_test.sh — the kdiag command's interface.
#
# kdiag is the PROGRAMMER's command in the 2026-08-14 three-command redesign:
# ktext renders a document for an author, kdesc describes a klammer set for a
# designer, kdiag dissects katoms and the Machine.  It had no suite of its own,
# which is how four defects survived in it -- including a "--check" that
# reported "0 diagnostics, 0 errors" for every input there is, while every
# other suite stayed green.  An absent test is what let that read as a pass, so
# each of the four is pinned here by OUTCOME.
#
# Its peculiarities, and why each is what it is:
#
#   * Its input positional is OPTIONAL, so "kdiag --machine" answers a question
#     about the Machine rather than about a document.
#   * --klammer registers the @@ tier and --system the @@@ tier; --process does
#     both.  A registered definition's katoms are CONSUMED, so they show up
#     only under --replaced -- their disappearance is the evidence.
#   * Extraction is TOLERANT here and nowhere else.  The tiers are ordered (a
#     klammer names a target), so running one without the other leaves
#     definitions that cannot be registered.  In kdiag that is not an error:
#     the definition is skipped and its katoms stay visible, which IS the
#     report.  ktext must keep throwing on the same input, and the contrast is
#     asserted, not assumed.
#   * It deliberately has NO --klammersets.  A klammerset arrives the way
#     anything else does, "@read sks/sks.k @": a debugger must not depend on
#     the machinery it debugs, so kdiag still runs when symbol resolution is
#     what broke.
#
# Engine tier: no klammerset is loaded, so every fixture defines what it needs
# inline.  The one case that wants the SKS reads it by path.
#
# Usage: ./kdiag_test.sh   (needs KLAMMERTEXT_HOME set; kdiag and ktext on PATH)
# Exit code: 0 if all tests pass, 1 otherwise.

PASS=0
FAIL=0
KDIAG=kdiag
KTEXT=ktext
K=${KLAMMERTEXT_HOME:?KLAMMERTEXT_HOME must be set}

red=$'\033[31m'
green=$'\033[32m'
bold=$'\033[1m'
reset=$'\033[0m'

plain() { printf '%s' "$1" | sed 's/\x1b\[[0-9;]*m//g'; }

# GNU coreutils' "timeout" is NOT present on macOS, and Homebrew's is named
# "gtimeout", so a bare "timeout" made this suite fail WHOLESALE there -- every
# case, because the command never ran at all (found on Olion, 2026-08-16).  The
# guard is a safety net against a hung command, not part of what is being
# tested, so it is optional: bound the command where the tool exists, run it
# directly where it does not.
if command -v timeout >/dev/null 2>&1; then
    limited() { timeout 60 "$@"; }
elif command -v gtimeout >/dev/null 2>&1; then
    limited() { gtimeout 60 "$@"; }
else
    limited() { "$@"; }
fi

# run KDIAG_ARGS... — sets OUT (colour codes stripped) and STATUS.  The status
# must be taken from the command itself: a pipeline inside a command
# substitution reports the SED's status, so "kdiag | plain" would call every
# failure a success.  Strip afterwards instead.
OUT=''
STATUS=0
run() {
    OUT=$(limited "$@" 2>&1); STATUS=$?
    OUT=$(plain "$OUT")
}

# A signal death is not an error exit: 128+n.  Report it distinctly, so a
# crash can never be read as a reported error.
died_by_signal() { [ "$1" -gt 128 ]; }

pass() { echo "${green}PASS${reset} $1"; PASS=$((PASS + 1)); }
fail() { echo "${red}FAIL${reset} $1"; [ -n "$2" ] && echo "       $2"; FAIL=$((FAIL + 1)); }

# shows NAME PATTERN KDIAG_ARGS... — exits 0 and the output matches PATTERN.
shows() {
    local name="$1" pattern="$2"; shift 2
    run "$KDIAG" "$@"
    if died_by_signal "$STATUS"; then
        fail "$name" "died by signal $((STATUS - 128))"; return
    fi
    if [ "$STATUS" -ne 0 ]; then
        fail "$name" "exit $STATUS: $(printf '%s' "$OUT" | head -2)"; return
    fi
    if printf '%s' "$OUT" | grep -Eq -- "$pattern"; then
        pass "$name"
    else
        fail "$name" "no match for [$pattern]; got: $(printf '%s' "$OUT" | head -2)"
    fi
}

# absent NAME PATTERN KDIAG_ARGS... — exits 0 and the output does NOT match.
absent() {
    local name="$1" pattern="$2"; shift 2
    run "$KDIAG" "$@"
    if died_by_signal "$STATUS"; then
        fail "$name" "died by signal $((STATUS - 128))"; return
    fi
    if [ "$STATUS" -ne 0 ]; then
        fail "$name" "exit $STATUS: $(printf '%s' "$OUT" | head -2)"; return
    fi
    if printf '%s' "$OUT" | grep -Eq -- "$pattern"; then
        fail "$name" "unexpected match [$pattern]"
    else
        pass "$name"
    fi
}

# reports NAME PATTERN KDIAG_ARGS... — NONZERO exit, no signal, PATTERN shown.
reports() {
    local name="$1" pattern="$2"; shift 2
    run "$KDIAG" "$@"
    if died_by_signal "$STATUS"; then
        fail "$name" "died by signal $((STATUS - 128))"; return
    fi
    if [ "$STATUS" -eq 0 ]; then
        fail "$name" "expected a nonzero exit, got 0"; return
    fi
    if printf '%s' "$OUT" | grep -Eq -- "$pattern"; then
        pass "$name"
    else
        fail "$name" "no match for [$pattern]; got: $(printf '%s' "$OUT" | head -2)"
    fi
}

# A klammer definition and an application of it, with no target named: the
# general target needs no @@@target, so the @@ tier can be exercised alone.
GREET='@@greet name : Hello, *name*. @@ @greet World @'

# Input that declares its own target AND a klammer for it.  The two tiers are
# ordered, so this is what tells --klammer and --system apart.
OWNTARGET='@@@target foo | Foo output @@@ @@bar.foo : B @@ text'

# A file for --read to pull in.
READFIXTURE=$(mktemp /tmp/kdiag_read.XXXXXX)
printf 'READFILE\n' > "$READFIXTURE"
trap 'rm -f "$READFIXTURE"' EXIT

echo "${bold}kdiag interface tests${reset}"
echo "====================="
echo

echo "-- the input positional is optional --"
# The Machine's initial state is a question about the Machine, and the one
# thing a programmer wants before feeding it anything.
shows " 1. --machine with no input at all"  'Machine'          --machine
shows " 2. ... and shows the built-in argtypes" 'Argtypes \([0-9]+\)' --machine
shows " 3. ... and the pseudo-targets"      'Targets \([0-9]+\)'  --machine
shows " 4. an input is still accepted"      '⟨@i'              --type '@i x @'
# Listed under "Arguments:", so it must not be repeated under "Options:".
usage=$(plain "$(limited "$KDIAG" 2>&1)")
n_input=$(printf '%s\n' "$usage" | grep -c '\[<input>\]')
if [ "$n_input" = "2" ]; then
    pass " 5. usage lists [<input>] once as a positional (plus the Usage: line)"
else
    fail " 5. usage lists [<input>] $n_input times, expected 2" \
         "$(printf '%s\n' "$usage" | grep -n '\[<input>\]')"
fi

echo
echo "-- registration: one flag per definition tier --"
# A registered definition is CONSUMED.  Its disappearance from the katom
# display is the evidence that it registered, and --replaced brings it back.
absent " 6. --klammer consumes the @@ definition"       '⟨@@greet⟩' --klammer "$GREET"
shows  " 7. ... and --replaced shows it again"          '⟨@@greet⟩' --klammer --replaced "$GREET"
shows  " 8. ... and the klammer is in the registry"     'Klammers \(1\)' --klammer --machine "$GREET"
absent " 9. --system consumes the @@@ definition"       '⟨@@@target⟩' --system "$OWNTARGET"
shows  "10. ... and the target is in the registry"      'foo → '     --system --machine "$OWNTARGET"
shows  "11. --process registers both tiers"             'Klammers \(1\)' --process --machine "$OWNTARGET"
absent "12. ... and consumes both"                      '⟨@@'        --process "$OWNTARGET"

echo
echo "-- tolerant extraction: kdiag only --"
# --klammer alone cannot register @@bar.foo, because the target "foo" it names
# is declared by the @@@ tier this run did not process.  Skipping it is the
# report, not a failure -- the definition's katoms stay on screen.
shows "13. --klammer alone does not fail on an unregisterable klammer" \
      '⟨@@bar.foo⟩' --klammer "$OWNTARGET"
absent "14. ... and does not register it either" \
      'Klammers \(1\)' --klammer --machine "$OWNTARGET"
shows "15. --system alone leaves the @@ katoms visible" \
      '⟨@@bar.foo⟩' --system "$OWNTARGET"
# The contrast.  In ktext the same input is a genuine error: a document naming
# an undefined target would otherwise render wrongly and silently.
# The pattern must sit on ONE line of the message: an error is wrapped for the
# terminal, so "is not defined" can arrive with a newline inside it and a
# line-oriented grep will never see it.
run "$KTEXT" --klammersets none -s '@@bar.nosuchtarget : B @@' -d
if [ "$STATUS" -ne 0 ] && printf '%s' "$OUT" | grep -q 'target "nosuchtarget"'; then
    pass "16. ktext still THROWS on a klammer naming an undefined target"
else
    fail "16. ktext accepted an undefined target (exit $STATUS)" \
         "$(printf '%s' "$OUT" | head -2)"
fi

echo
echo "-- --check --"
# The regression: check_machine() read machine.m_katoms, which read() fills and
# process() -- how kdiag builds its katoms -- does not.  So it had nothing to
# check and said so, for every input there is.  These two are the guard: a
# clean check must be able to fail.
shows   "17. a correct input checks clean"      '0 diagnostics' --process --check "$GREET"
reports "18. an undefined klammer is found"     '@nosuch is not defined' \
        --process --check '@@g : @nosuch x @ @@'
reports "19. ... and the body it sits in is named" 'in body of @g' \
        --process --check '@@g : @nosuch x @ @@'
reports "20. a wrong argument count is found"   'is given 2' \
        --process --check "@@greet name : Hello, *name*. @@ @greet a | b @"
# Without --klammer/--system/--process nothing is registered, so every
# application is undefined.  Reporting 80 spurious errors without saying why
# would be worse than the old silence; the hint is part of the report.
reports "21. --check alone explains why nothing is registered" \
        'No klammers are registered' --check "$GREET"
reports "22. ... and names the way to fix it"   '--process' --check "$GREET"
# --machine must print even when the check failed: the state shown last has to
# reflect everything that happened, so the nonzero exit waits for it.
reports "23. --machine still prints after a failed check" \
        'Machine' --process --check --machine '@nosuch x @'

echo
echo "-- a klammerset reaches kdiag only as input --"
# No --klammersets, deliberately: a debugger must not depend on the machinery
# it debugs.  The flag must be REJECTED, not silently ignored.
reports "24. --klammersets is not a kdiag argument" 'argument error' \
        --klammersets sks '@i x @'
# ...so the advice on an undefined-klammer error must not offer it.  It did
# until 2026-08-15: the one remedy the error suggested was a flag this command
# rejects.  A wrong hint is worse than none, because it is followed.
#
# The trigger is a GENERATOR: an @eval whose result holds a klammer.  A klammer
# written literally in the input is only displayed, never applied, so nothing
# reaches the catch block -- which is also why this advice went years unread.
UNDEF='@eval chr(64)+"nosuch 7 "+chr(64) @'
reports "24a. the error advice names a way kdiag has" '@read sks/sks\.k @' \
        --process "$UNDEF"
# absent_in_error NAME PATTERN ARGS... -- the pattern is missing whatever the
# exit status; these run on a path that exits 1 by design.
absent_in_error() {
    local name="$1" pattern="$2"; shift 2
    run "$KDIAG" "$@"
    if printf '%s' "$OUT" | grep -Eq -- "$pattern"; then
        fail "$name" "unexpected match [$pattern]"
    else
        pass "$name"
    fi
}
absent_in_error "24b. ... and not the flag it rejects" '\-\-klammersets' \
        --process "$UNDEF"
# The advice is appended to the description, which is then justified to 80
# columns -- without a blank line between them the two ran together into one
# word ("...unspecified targetkdiag loads no klammerset...").
absent_in_error "24c. ... and does not run onto the message" 'target[a-z]' \
        --process "$UNDEF"
shows "25. @read loads the SKS instead"   'Klammers \([0-9][0-9]+\)' \
      "@read $K/sks/sks.k @" --process --machine
shows "26. ... and the SKS then checks clean" '0 diagnostics' \
      "@read $K/sks/sks.k @" --process --check
# ...and the clean result above is a real one: the same input with a fault
# added must still be caught, or case 26 says nothing.
reports "27. ... while a fault added to it is still caught" '@nosuchklammer is not defined' \
        "@read $K/sks/sks.k @ @@bad : @nosuchklammer x @ @@" --process --check

echo
echo "-- the katom display flags --"
shows "28. --type subscripts the katom type"    '⟨@i.⟩'      --type '@i x @'
shows "29. --index subscripts the list index"   '⟨@i.'       --index '@i x @'
shows "30. --spans shows span endpoints"        '⟨@i.⟩.*⟨@.⟩' --spans '@i x @'
shows "31. --args parses positional arguments"  'required'   --args --pos 2 'a | b | c'
shows "32. --pos sets how many are positional"  'rest:  c'   --args --pos 2 'a | b | c'
shows "33. --ignore removes removed text"       '^a b'       --ignore 'a #[gone]# b'
shows "34. --all shows what was removed"        '⟨gone⟩'     --ignore --all 'a #[gone]# b'
shows "35. --ws applies whitespace operators"   'ab'         --ws 'a#-  b'
# A DECOMPOSED sequence: the base letter followed by U+0308 COMBINING
# DIAERESIS, not the precomposed U+00FC.  The diacritic FOLLOWS the base
# letter -- that is the design of the ^ codes -- so the two spellings look
# identical in an editor and only one of them matches.
shows "36. --nonascii decodes to a combining sequence" 'ü'  --nonascii '^u"'
shows "37. --literal marks a literal span"      'x'          --literal "^'x'^"
# Evaluating needs a target: Eval::eval re-reads its result under K_target, and
# kdiag has no target argument, so with the variable unset EVERY @eval died
# with an argument error naming something the user never wrote.  A command that
# specifies no target evaluates under the GENERAL target (Andy, 2026-08-15),
# which is also what kdiag means -- it loads no klammerset, so nothing
# target-specific is in scope.  All three eval modes are checked, because the
# failure was in the shared read-back and not in any one of them.
shows "38. --eval evaluates a Python expression"     '^4$'  --eval '@eval 2 + 2 @'
shows "38a. ... and --process does too"              '^4$'  --process '@eval 2 + 2 @'
shows "38b. ... in :shell mode"                      '^hi$' --process '@eval :shell echo hi @'
shows "38c. ... and a module.function auto-imports"  '^3$'  --process '@eval len("abc") @'
shows "38d. --read reads a file"       'READFILE'   --read "@read $READFIXTURE @"
shows "39. --cond selects a branch"             'yes'        --cond '@cond true | yes | no @'
shows "40. --rewrite reports a rewrite"         '.'          --rewrite '@i x @'
shows "41. --text shows text katoms"            'x'          --text '@i x @'
shows "42. --ignored shows ignored katoms"      '.'          --ignore --ignored 'a #[gone]# b'

echo
echo "-- verbosity is about processing --"
shows "43. -v takes a value"                    '.'          -v 1 '@i x @'

echo
echo "====================="
echo "Results: ${PASS} passed, ${FAIL} failed"
[ "$FAIL" -eq 0 ] || exit 1
exit 0
